vibe coded website (plus captive portal)
Some checks failed
ESP-IDF Build / build (esp32c6, release-v5.4) (push) Successful in 3m57s
ESP-IDF Build / build (esp32c6, release-v5.5) (push) Successful in 3m48s
ESP-IDF Build / build (esp32s3, release-v5.4) (push) Failing after 3m18s
ESP-IDF Build / build (esp32s3, release-v5.5) (push) Failing after 3m14s

needs missing ESP32 implementation

Signed-off-by: Peter Siegmund <developer@mars3142.org>
This commit is contained in:
2026-01-01 16:39:27 +01:00
parent dfad7cfb76
commit 52f6c2acab
17 changed files with 5017 additions and 0 deletions

View File

@@ -0,0 +1,60 @@
// Global variables
let schemaData = [];
let currentEditRow = null;
let lightOn = false;
let currentMode = 'simulation';
let ws = null;
let wsReconnectTimer = null;
let pairedDevices = [];
let scenes = [];
let currentEditScene = null;
let selectedSceneIcon = '🌅';
// Event listeners
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeColorModal();
closeSceneModal();
}
});
document.getElementById('color-modal').addEventListener('click', (e) => {
if (e.target.classList.contains('modal-overlay')) {
closeColorModal();
}
});
document.getElementById('scene-modal').addEventListener('click', (e) => {
if (e.target.classList.contains('modal-overlay')) {
closeSceneModal();
}
});
// Prevent zoom on double-tap for iOS
let lastTouchEnd = 0;
document.addEventListener('touchend', (e) => {
const now = Date.now();
if (now - lastTouchEnd <= 300) {
e.preventDefault();
}
lastTouchEnd = now;
}, false);
// Initialization
document.addEventListener('DOMContentLoaded', () => {
initI18n();
initTheme();
initWebSocket();
updateConnectionStatus();
loadScenes();
loadPairedDevices();
// WiFi status polling (less frequent)
setInterval(updateConnectionStatus, 30000);
});
// Close WebSocket on page unload
window.addEventListener('beforeunload', () => {
if (ws) {
ws.close();
}
});

View File

@@ -0,0 +1,231 @@
// Device management
function renderDevicesControl() {
const list = document.getElementById('devices-control-list');
const noDevices = document.getElementById('no-devices-control');
list.querySelectorAll('.device-control-item').forEach(el => el.remove());
if (pairedDevices.length === 0) {
noDevices.style.display = 'flex';
} else {
noDevices.style.display = 'none';
pairedDevices.forEach(device => {
const item = document.createElement('div');
item.className = 'device-control-item';
const icon = device.type === 'light' ? '💡' : device.type === 'sensor' ? '🌡️' : '📟';
item.innerHTML = `
<span class="device-control-icon">${icon}</span>
<span class="device-control-name">${device.name}</span>
${device.type === 'light' ? `<button class="toggle-switch small" onclick="toggleExternalDevice('${device.id}')"><span class="toggle-icon">💡</span></button>` : ''}
`;
list.insertBefore(item, noDevices);
});
}
}
async function toggleExternalDevice(deviceId) {
try {
await fetch('/api/devices/toggle', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: deviceId })
});
} catch (error) {
console.log('Demo: Gerät umgeschaltet');
}
}
async function scanDevices() {
const loading = document.getElementById('devices-loading');
const unpairedList = document.getElementById('unpaired-devices');
const noDevices = document.getElementById('no-unpaired-devices');
loading.classList.add('active');
noDevices.style.display = 'none';
// Entferne vorherige Ergebnisse (außer empty-state)
unpairedList.querySelectorAll('.device-item').forEach(el => el.remove());
try {
const response = await fetch('/api/devices/scan');
const devices = await response.json();
loading.classList.remove('active');
if (devices.length === 0) {
noDevices.style.display = 'flex';
} else {
devices.forEach(device => {
const item = createUnpairedDeviceItem(device);
unpairedList.insertBefore(item, noDevices);
});
}
showStatus('devices-status', t('devices.found', { count: devices.length }), 'success');
} catch (error) {
loading.classList.remove('active');
// Demo data
const demoDevices = [
{ id: 'matter-001', type: 'light', name: 'Matter Lamp' },
{ id: 'matter-002', type: 'sensor', name: 'Temperature Sensor' }
];
demoDevices.forEach(device => {
const item = createUnpairedDeviceItem(device);
unpairedList.insertBefore(item, noDevices);
});
showStatus('devices-status', `Demo: ${t('devices.found', { count: 2 })}`, 'success');
}
}
function createUnpairedDeviceItem(device) {
const item = document.createElement('div');
item.className = 'device-item unpaired';
item.dataset.id = device.id;
const icon = device.type === 'light' ? '💡' : device.type === 'sensor' ? '🌡️' : '📟';
const unknownDevice = getCurrentLanguage() === 'en' ? 'Unknown Device' : 'Unbekanntes Gerät';
item.innerHTML = `
<div class="device-info">
<span class="device-icon">${icon}</span>
<div class="device-details">
<span class="device-name">${device.name || unknownDevice}</span>
<span class="device-id">${device.id}</span>
</div>
</div>
<button class="btn btn-primary btn-small" onclick="pairDevice('${device.id}', '${device.name || unknownDevice}', '${device.type || 'unknown'}')">
${t('btn.add')}
</button>
`;
return item;
}
async function pairDevice(id, name, type) {
try {
const response = await fetch('/api/devices/pair', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, name })
});
if (response.ok) {
showStatus('devices-status', t('devices.added', { name }), 'success');
// Entferne aus unpaired Liste
document.querySelector(`.device-item[data-id="${id}"]`)?.remove();
// Lade paired Geräte neu
loadPairedDevices();
} else {
throw new Error(t('error'));
}
} catch (error) {
// Demo mode
showStatus('devices-status', `Demo: ${t('devices.added', { name })}`, 'success');
document.querySelector(`.device-item.unpaired[data-id="${id}"]`)?.remove();
// Füge zu Demo-Liste hinzu
pairedDevices.push({ id, name, type });
renderPairedDevices();
}
}
async function loadPairedDevices() {
try {
const response = await fetch('/api/devices/paired');
pairedDevices = await response.json();
renderPairedDevices();
} catch (error) {
// Keep demo data
renderPairedDevices();
}
}
function renderPairedDevices() {
const list = document.getElementById('paired-devices');
const noDevices = document.getElementById('no-paired-devices');
// Remove previous entries
list.querySelectorAll('.device-item').forEach(el => el.remove());
if (pairedDevices.length === 0) {
noDevices.style.display = 'flex';
} else {
noDevices.style.display = 'none';
pairedDevices.forEach(device => {
const item = createPairedDeviceItem(device);
list.insertBefore(item, noDevices);
});
}
// Also update the control page
renderDevicesControl();
}
function createPairedDeviceItem(device) {
const item = document.createElement('div');
item.className = 'device-item paired';
item.dataset.id = device.id;
const icon = device.type === 'light' ? '💡' : device.type === 'sensor' ? '🌡️' : '📟';
const placeholder = getCurrentLanguage() === 'en' ? 'Device name' : 'Gerätename';
item.innerHTML = `
<div class="device-info">
<span class="device-icon">${icon}</span>
<div class="device-details">
<input type="text" class="device-name-input" value="${device.name}"
onchange="updateDeviceName('${device.id}', this.value)"
placeholder="${placeholder}">
<span class="device-id">${device.id}</span>
</div>
</div>
<button class="btn btn-secondary btn-small btn-danger" onclick="unpairDevice('${device.id}', '${device.name}')">
🗑️
</button>
`;
return item;
}
async function updateDeviceName(id, newName) {
try {
const response = await fetch('/api/devices/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, name: newName })
});
if (response.ok) {
showStatus('devices-status', t('devices.name.updated'), 'success');
}
} catch (error) {
// Demo mode - update locally
const device = pairedDevices.find(d => d.id === id);
if (device) device.name = newName;
showStatus('devices-status', `Demo: ${t('devices.name.updated')}`, 'success');
}
}
async function unpairDevice(id, name) {
if (!confirm(t('devices.confirm.remove', { name }))) return;
try {
const response = await fetch('/api/devices/unpair', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id })
});
if (response.ok) {
showStatus('devices-status', t('devices.removed', { name }), 'success');
loadPairedDevices();
}
} catch (error) {
// Demo mode
pairedDevices = pairedDevices.filter(d => d.id !== id);
renderPairedDevices();
showStatus('devices-status', `Demo: ${t('devices.removed', { name })}`, 'success');
}
}

View File

@@ -0,0 +1,445 @@
// Internationalization (i18n) - Language support
// Supported languages: German (de), English (en)
const translations = {
de: {
// Page
'page.title': 'System Control',
// Main Tabs
'tab.control': '🎛️ Bedienung',
'tab.config': '⚙️ Konfiguration',
// Sub Tabs
'subtab.wifi': '📶 WLAN',
'subtab.schema': '💡 Schema',
'subtab.devices': '🔗 Geräte',
'subtab.scenes': '🎬 Szenen',
// Light Control
'control.light.title': 'Lichtsteuerung',
'control.light.onoff': 'Ein/Aus',
'control.light.light': 'Licht',
'control.mode.title': 'Betriebsmodus',
'control.schema.active': 'Aktives Schema',
'control.status.title': 'Aktueller Status',
'control.status.mode': 'Modus',
'control.status.schema': 'Schema',
'control.status.color': 'Aktuelle Farbe',
// Common
'common.on': 'AN',
'common.off': 'AUS',
'common.loading': 'Wird geladen...',
// Modes
'mode.day': 'Tag',
'mode.night': 'Nacht',
'mode.simulation': 'Simulation',
// Schema names
'schema.name.1': 'Schema 1 (Standard)',
'schema.name.2': 'Schema 2 (Warm)',
'schema.name.3': 'Schema 3 (Natur)',
// Scenes
'scenes.title': 'Szenen',
'scenes.empty': 'Keine Szenen definiert',
'scenes.empty.hint': 'Erstelle Szenen unter Konfiguration',
'scenes.manage.title': 'Szenen verwalten',
'scenes.manage.desc': 'Erstelle und bearbeite Szenen für schnellen Zugriff',
'scenes.config.empty': 'Keine Szenen erstellt',
'scenes.config.empty.hint': 'Klicke auf "Neue Szene" um eine Szene zu erstellen',
'scenes.activated': '"{name}" aktiviert',
'scenes.created': 'Szene erstellt',
'scenes.updated': 'Szene aktualisiert',
'scenes.deleted': '"{name}" gelöscht',
'scenes.confirm.delete': '"{name}" wirklich löschen?',
'scenes.error.name': 'Bitte Namen eingeben',
// Devices
'devices.external': 'Externe Geräte',
'devices.control.empty': 'Keine Geräte hinzugefügt',
'devices.control.empty.hint': 'Füge Geräte unter Konfiguration hinzu',
'devices.new.title': 'Neue Geräte',
'devices.new.desc': 'Unprovisionierte Matter-Geräte in der Nähe',
'devices.searching': 'Suche nach Geräten...',
'devices.unpaired.empty': 'Keine neuen Geräte gefunden',
'devices.unpaired.empty.hint': 'Drücke "Geräte suchen" um nach Matter-Geräten zu suchen',
'devices.paired.title': 'Zugeordnete Geräte',
'devices.paired.desc': 'Bereits hinzugefügte externe Geräte',
'devices.paired.empty': 'Keine Geräte hinzugefügt',
'devices.none.available': 'Keine Geräte verfügbar',
'devices.found': '{count} Gerät(e) gefunden',
'devices.added': '"{name}" erfolgreich hinzugefügt',
'devices.removed': '"{name}" entfernt',
'devices.name.updated': 'Name aktualisiert',
'devices.confirm.remove': '"{name}" wirklich entfernen?',
// WiFi
'wifi.config.title': 'WLAN Konfiguration',
'wifi.ssid': 'WLAN Name (SSID)',
'wifi.ssid.placeholder': 'Netzwerkname eingeben',
'wifi.password': 'WLAN Passwort',
'wifi.password.short': 'Passwort',
'wifi.password.placeholder': 'Passwort eingeben',
'wifi.available': 'Verfügbare Netzwerke',
'wifi.scan.hint': 'Nach Netzwerken suchen...',
'wifi.status.title': 'Verbindungsstatus',
'wifi.status.status': 'Status:',
'wifi.status.ip': 'IP-Adresse:',
'wifi.status.signal': 'Signal:',
'wifi.connected': '✅ Verbunden',
'wifi.disconnected': '❌ Nicht verbunden',
'wifi.unavailable': '⚠️ Status nicht verfügbar',
'wifi.searching': 'Suche läuft...',
'wifi.scan.error': 'Fehler beim Scannen',
'wifi.scan.failed': 'Netzwerksuche fehlgeschlagen',
'wifi.saved': 'WLAN-Konfiguration gespeichert! Gerät verbindet sich...',
'wifi.error.ssid': 'Bitte WLAN-Name eingeben',
'wifi.error.save': 'Fehler beim Speichern',
'wifi.networks.found': '{count} Netzwerk(e) gefunden',
// Schema Editor
'schema.editor.title': 'Licht-Schema Editor',
'schema.file': 'Schema-Datei',
'schema.loading': 'Schema wird geladen...',
'schema.header.time': 'Zeit',
'schema.header.color': 'Farbe',
'schema.loaded': '{file} erfolgreich geladen',
'schema.saved': '{file} erfolgreich gespeichert!',
'schema.demo': 'Demo-Daten geladen (Server nicht erreichbar)',
// Color Modal
'modal.color.title': 'Farbe wählen',
// Scene Modal
'modal.scene.new': 'Neue Szene erstellen',
'modal.scene.edit': 'Szene bearbeiten',
'scene.name': 'Name',
'scene.name.placeholder': 'z.B. Abendstimmung',
'scene.icon': 'Icon auswählen',
'scene.actions': 'Aktionen',
'scene.action.light': 'Licht Ein/Aus',
'scene.action.mode': 'Modus setzen',
'scene.action.schema': 'Schema wählen',
'scene.light.on': 'Einschalten',
'scene.light.off': 'Ausschalten',
// Buttons
'btn.scan': '🔍 Suchen',
'btn.save': '💾 Speichern',
'btn.load': '🔄 Laden',
'btn.cancel': 'Abbrechen',
'btn.apply': 'Übernehmen',
'btn.new.scene': ' Neue Szene',
'btn.scan.devices': '🔍 Geräte suchen',
'btn.add': 'Hinzufügen',
'btn.remove': 'Entfernen',
'btn.edit': 'Bearbeiten',
'btn.delete': 'Löschen',
// Captive Portal
'captive.title': 'System Control - WLAN Setup',
'captive.subtitle': 'WLAN-Einrichtung',
'captive.scan': '📡 Netzwerke suchen',
'captive.scanning': 'Suche nach Netzwerken...',
'captive.or.manual': 'oder manuell eingeben',
'captive.password.placeholder': 'WLAN-Passwort',
'captive.connect': '💾 Verbinden',
'captive.note.title': 'Hinweis:',
'captive.note.text': 'Nach dem Speichern verbindet sich das Gerät mit dem gewählten Netzwerk. Diese Seite wird dann nicht mehr erreichbar sein. Verbinden Sie sich mit Ihrem normalen WLAN, um auf das Gerät zuzugreifen.',
'captive.connecting': 'Verbindung wird hergestellt... {seconds}s',
'captive.done': 'Gerät sollte jetzt verbunden sein. Sie können diese Seite schließen.',
// General
'loading': 'Laden...',
'error': 'Fehler',
'success': 'Erfolg'
},
en: {
// Page
'page.title': 'System Control',
// Main Tabs
'tab.control': '🎛️ Control',
'tab.config': '⚙️ Settings',
// Sub Tabs
'subtab.wifi': '📶 WiFi',
'subtab.schema': '💡 Schema',
'subtab.devices': '🔗 Devices',
'subtab.scenes': '🎬 Scenes',
// Light Control
'control.light.title': 'Light Control',
'control.light.onoff': 'On/Off',
'control.light.light': 'Light',
'control.mode.title': 'Operating Mode',
'control.schema.active': 'Active Schema',
'control.status.title': 'Current Status',
'control.status.mode': 'Mode',
'control.status.schema': 'Schema',
'control.status.color': 'Current Color',
// Common
'common.on': 'ON',
'common.off': 'OFF',
'common.loading': 'Loading...',
// Modes
'mode.day': 'Day',
'mode.night': 'Night',
'mode.simulation': 'Simulation',
// Schema names
'schema.name.1': 'Schema 1 (Standard)',
'schema.name.2': 'Schema 2 (Warm)',
'schema.name.3': 'Schema 3 (Natural)',
// Scenes
'scenes.title': 'Scenes',
'scenes.empty': 'No scenes defined',
'scenes.empty.hint': 'Create scenes in settings',
'scenes.manage.title': 'Manage Scenes',
'scenes.manage.desc': 'Create and edit scenes for quick access',
'scenes.config.empty': 'No scenes created',
'scenes.config.empty.hint': 'Click "New Scene" to create a scene',
'scenes.activated': '"{name}" activated',
'scenes.created': 'Scene created',
'scenes.updated': 'Scene updated',
'scenes.deleted': '"{name}" deleted',
'scenes.confirm.delete': 'Really delete "{name}"?',
'scenes.error.name': 'Please enter a name',
// Devices
'devices.external': 'External Devices',
'devices.control.empty': 'No devices added',
'devices.control.empty.hint': 'Add devices in settings',
'devices.new.title': 'New Devices',
'devices.new.desc': 'Unprovisioned Matter devices nearby',
'devices.searching': 'Searching for devices...',
'devices.unpaired.empty': 'No new devices found',
'devices.unpaired.empty.hint': 'Press "Scan devices" to search for Matter devices',
'devices.paired.title': 'Paired Devices',
'devices.paired.desc': 'Already added external devices',
'devices.paired.empty': 'No devices added',
'devices.none.available': 'No devices available',
'devices.found': '{count} device(s) found',
'devices.added': '"{name}" added successfully',
'devices.removed': '"{name}" removed',
'devices.name.updated': 'Name updated',
'devices.confirm.remove': 'Really remove "{name}"?',
// WiFi
'wifi.config.title': 'WiFi Configuration',
'wifi.ssid': 'WiFi Name (SSID)',
'wifi.ssid.placeholder': 'Enter network name',
'wifi.password': 'WiFi Password',
'wifi.password.short': 'Password',
'wifi.password.placeholder': 'Enter password',
'wifi.available': 'Available Networks',
'wifi.scan.hint': 'Search for networks...',
'wifi.status.title': 'Connection Status',
'wifi.status.status': 'Status:',
'wifi.status.ip': 'IP Address:',
'wifi.status.signal': 'Signal:',
'wifi.connected': '✅ Connected',
'wifi.disconnected': '❌ Not connected',
'wifi.unavailable': '⚠️ Status unavailable',
'wifi.searching': 'Searching...',
'wifi.scan.error': 'Scan error',
'wifi.scan.failed': 'Network scan failed',
'wifi.saved': 'WiFi configuration saved! Device connecting...',
'wifi.error.ssid': 'Please enter WiFi name',
'wifi.error.save': 'Error saving',
'wifi.networks.found': '{count} network(s) found',
// Schema Editor
'schema.editor.title': 'Light Schema Editor',
'schema.file': 'Schema File',
'schema.loading': 'Loading schema...',
'schema.header.time': 'Time',
'schema.header.color': 'Color',
'schema.loaded': '{file} loaded successfully',
'schema.saved': '{file} saved successfully!',
'schema.demo': 'Demo data loaded (server unreachable)',
// Color Modal
'modal.color.title': 'Choose Color',
// Scene Modal
'modal.scene.new': 'Create New Scene',
'modal.scene.edit': 'Edit Scene',
'scene.name': 'Name',
'scene.name.placeholder': 'e.g. Evening Mood',
'scene.icon': 'Choose Icon',
'scene.actions': 'Actions',
'scene.action.light': 'Light On/Off',
'scene.action.mode': 'Set Mode',
'scene.action.schema': 'Choose Schema',
'scene.light.on': 'Turn On',
'scene.light.off': 'Turn Off',
// Buttons
'btn.scan': '🔍 Scan',
'btn.save': '💾 Save',
'btn.load': '🔄 Load',
'btn.cancel': 'Cancel',
'btn.apply': 'Apply',
'btn.new.scene': ' New Scene',
'btn.scan.devices': '🔍 Scan Devices',
'btn.add': 'Add',
'btn.remove': 'Remove',
'btn.edit': 'Edit',
'btn.delete': 'Delete',
// Captive Portal
'captive.title': 'System Control - WiFi Setup',
'captive.subtitle': 'WiFi Setup',
'captive.scan': '📡 Scan Networks',
'captive.scanning': 'Scanning for networks...',
'captive.or.manual': 'or enter manually',
'captive.password.placeholder': 'WiFi password',
'captive.connect': '💾 Connect',
'captive.note.title': 'Note:',
'captive.note.text': 'After saving, the device will connect to the selected network. This page will no longer be accessible. Connect to your regular WiFi to access the device.',
'captive.connecting': 'Connecting... {seconds}s',
'captive.done': 'Device should now be connected. You can close this page.',
// General
'loading': 'Loading...',
'error': 'Error',
'success': 'Success'
}
};
// Current language
let currentLang = localStorage.getItem('lang') || 'de';
/**
* Get translation for a key
* @param {string} key - Translation key
* @param {object} params - Optional parameters for interpolation
* @returns {string} Translated text
*/
function t(key, params = {}) {
const lang = translations[currentLang] || translations.de;
let text = lang[key] || translations.de[key] || key;
// Replace parameters like {count}, {name}, etc.
Object.keys(params).forEach(param => {
text = text.replace(new RegExp(`\\{${param}\\}`, 'g'), params[param]);
});
return text;
}
/**
* Set current language
* @param {string} lang - Language code ('de' or 'en')
*/
function setLanguage(lang) {
if (translations[lang]) {
currentLang = lang;
localStorage.setItem('lang', lang);
document.documentElement.lang = lang;
updatePageLanguage();
updateLanguageToggle();
}
}
/**
* Toggle between languages
*/
function toggleLanguage() {
setLanguage(currentLang === 'de' ? 'en' : 'de');
}
/**
* Get current language
* @returns {string} Current language code
*/
function getCurrentLanguage() {
return currentLang;
}
/**
* Update all elements with data-i18n attribute
*/
function updatePageLanguage() {
// Update elements with data-i18n attribute
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
const translated = t(key);
if (translated !== key) {
el.textContent = translated;
}
});
// Update elements with data-i18n-placeholder attribute
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
const key = el.getAttribute('data-i18n-placeholder');
const translated = t(key);
if (translated !== key) {
el.placeholder = translated;
}
});
// Update elements with data-i18n-title attribute
document.querySelectorAll('[data-i18n-title]').forEach(el => {
const key = el.getAttribute('data-i18n-title');
const translated = t(key);
if (translated !== key) {
el.title = translated;
}
});
// Update elements with data-i18n-aria attribute
document.querySelectorAll('[data-i18n-aria]').forEach(el => {
const key = el.getAttribute('data-i18n-aria');
const translated = t(key);
if (translated !== key) {
el.setAttribute('aria-label', translated);
}
});
// Update page title
const titleEl = document.querySelector('title[data-i18n]');
if (titleEl) {
document.title = t(titleEl.getAttribute('data-i18n'));
}
}
/**
* Update language toggle button
*/
function updateLanguageToggle() {
const langFlag = document.getElementById('lang-flag');
const langLabel = document.getElementById('lang-label');
if (langFlag) {
langFlag.textContent = currentLang === 'de' ? '🇩🇪' : '🇬🇧';
}
if (langLabel) {
langLabel.textContent = currentLang.toUpperCase();
}
}
/**
* Initialize i18n
*/
function initI18n() {
// Check browser language as fallback
if (!localStorage.getItem('lang')) {
const browserLang = navigator.language.split('-')[0];
if (translations[browserLang]) {
currentLang = browserLang;
}
}
document.documentElement.lang = currentLang;
updatePageLanguage();
updateLanguageToggle();
}

View File

@@ -0,0 +1,102 @@
// Light control
async function toggleLight() {
lightOn = !lightOn;
updateLightToggle();
try {
const response = await fetch('/api/light/power', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ on: lightOn })
});
if (response.ok) {
showStatus('light-status', `${t('control.light.light')} ${lightOn ? t('common.on') : t('common.off')}`, 'success');
} else {
throw new Error(t('error'));
}
} catch (error) {
showStatus('light-status', `Demo: ${t('control.light.light')} ${lightOn ? t('common.on') : t('common.off')}`, 'success');
}
}
function updateLightToggle() {
const toggle = document.getElementById('light-toggle');
const state = document.getElementById('light-state');
const icon = document.getElementById('light-icon');
if (lightOn) {
toggle.classList.add('active');
state.textContent = t('common.on');
icon.textContent = '💡';
} else {
toggle.classList.remove('active');
state.textContent = t('common.off');
icon.textContent = '💡';
}
}
// Mode control
async function setMode(mode) {
currentMode = mode;
updateModeButtons();
updateSimulationOptions();
try {
const response = await fetch('/api/light/mode', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode })
});
if (response.ok) {
const modeName = t(`mode.${mode}`);
showStatus('mode-status', `${t('control.status.mode')}: "${modeName}"`, 'success');
document.getElementById('current-mode').textContent = modeName;
} else {
throw new Error(t('error'));
}
} catch (error) {
const modeName = t(`mode.${mode}`);
showStatus('mode-status', `Demo: ${t('control.status.mode')} "${modeName}"`, 'success');
document.getElementById('current-mode').textContent = modeName;
}
}
function updateModeButtons() {
document.querySelectorAll('.mode-btn').forEach(btn => btn.classList.remove('active'));
document.getElementById(`mode-${currentMode}`).classList.add('active');
}
function updateSimulationOptions() {
const options = document.getElementById('simulation-options');
if (currentMode === 'simulation') {
options.classList.add('visible');
} else {
options.classList.remove('visible');
}
}
async function setActiveSchema() {
const schema = document.getElementById('active-schema').value;
const schemaNum = schema.replace('schema_0', '').replace('.csv', '');
const schemaName = t(`schema.name.${schemaNum}`);
try {
const response = await fetch('/api/light/schema', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ schema })
});
if (response.ok) {
showStatus('mode-status', `${t('control.status.schema')}: "${schemaName}"`, 'success');
document.getElementById('current-schema').textContent = schemaName;
} else {
throw new Error(t('error'));
}
} catch (error) {
showStatus('mode-status', `Demo: ${schemaName}`, 'success');
document.getElementById('current-schema').textContent = schemaName;
}
}

View File

@@ -0,0 +1,330 @@
// Scene functions
async function loadScenes() {
try {
const response = await fetch('/api/scenes');
scenes = await response.json();
} catch (error) {
// Demo data
scenes = [
{ id: 'scene-1', name: 'Abendstimmung', icon: '🌅', actions: { light: 'on', mode: 'simulation', schema: 'schema_02.csv' } },
{ id: 'scene-2', name: 'Nachtmodus', icon: '🌙', actions: { light: 'on', mode: 'night' } }
];
}
renderScenesConfig();
renderScenesControl();
}
function renderScenesConfig() {
const list = document.getElementById('scenes-config-list');
const noScenes = document.getElementById('no-scenes-config');
list.querySelectorAll('.scene-config-item').forEach(el => el.remove());
if (scenes.length === 0) {
noScenes.style.display = 'flex';
} else {
noScenes.style.display = 'none';
scenes.forEach(scene => {
const item = createSceneConfigItem(scene);
list.insertBefore(item, noScenes);
});
}
}
function createSceneConfigItem(scene) {
const item = document.createElement('div');
item.className = 'scene-config-item';
item.dataset.id = scene.id;
const actionsText = [];
if (scene.actions.light) actionsText.push(`${t('control.light.light')} ${scene.actions.light === 'on' ? t('common.on') : t('common.off')}`);
if (scene.actions.mode) actionsText.push(`${t('control.status.mode')}: ${t('mode.' + scene.actions.mode)}`);
if (scene.actions.schema) actionsText.push(`${t('control.status.schema')}: ${scene.actions.schema.replace('.csv', '')}`);
if (scene.actions.devices && scene.actions.devices.length > 0) {
actionsText.push(t('devices.found', { count: scene.actions.devices.length }));
}
item.innerHTML = `
<div class="scene-info">
<span class="scene-icon">${scene.icon}</span>
<div class="scene-details">
<span class="scene-name">${scene.name}</span>
<span class="scene-actions-text">${actionsText.join(', ')}</span>
</div>
</div>
<div class="scene-buttons">
<button class="btn btn-secondary btn-small" onclick="editScene('${scene.id}')">✏️</button>
<button class="btn btn-secondary btn-small btn-danger" onclick="deleteScene('${scene.id}', '${scene.name}')">🗑️</button>
</div>
`;
return item;
}
function renderScenesControl() {
const list = document.getElementById('scenes-control-list');
const noScenes = document.getElementById('no-scenes-control');
list.querySelectorAll('.scene-btn').forEach(el => el.remove());
if (scenes.length === 0) {
noScenes.style.display = 'flex';
} else {
noScenes.style.display = 'none';
scenes.forEach(scene => {
const btn = document.createElement('button');
btn.className = 'scene-btn';
btn.onclick = () => activateScene(scene.id);
btn.innerHTML = `
<span class="scene-btn-icon">${scene.icon}</span>
<span class="scene-btn-name">${scene.name}</span>
`;
list.insertBefore(btn, noScenes);
});
}
}
function openSceneModal() {
currentEditScene = null;
selectedSceneIcon = '🌅';
document.getElementById('scene-modal-title').textContent = t('modal.scene.new');
document.getElementById('scene-name').value = '';
document.getElementById('scene-action-light').checked = true;
document.getElementById('scene-light-state').value = 'on';
document.getElementById('scene-action-mode').checked = false;
document.getElementById('scene-mode-value').value = 'simulation';
document.getElementById('scene-action-schema').checked = false;
document.getElementById('scene-schema-value').value = 'schema_01.csv';
document.querySelectorAll('.icon-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.icon === '🌅');
});
renderSceneDevicesList();
document.getElementById('scene-modal').classList.add('active');
document.body.style.overflow = 'hidden';
}
function editScene(sceneId) {
const scene = scenes.find(s => s.id === sceneId);
if (!scene) return;
currentEditScene = sceneId;
selectedSceneIcon = scene.icon;
document.getElementById('scene-modal-title').textContent = t('modal.scene.edit');
document.getElementById('scene-name').value = scene.name;
document.getElementById('scene-action-light').checked = !!scene.actions.light;
document.getElementById('scene-light-state').value = scene.actions.light || 'on';
document.getElementById('scene-action-mode').checked = !!scene.actions.mode;
document.getElementById('scene-mode-value').value = scene.actions.mode || 'simulation';
document.getElementById('scene-action-schema').checked = !!scene.actions.schema;
document.getElementById('scene-schema-value').value = scene.actions.schema || 'schema_01.csv';
document.querySelectorAll('.icon-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.icon === scene.icon);
});
renderSceneDevicesList(scene.actions.devices || []);
document.getElementById('scene-modal').classList.add('active');
document.body.style.overflow = 'hidden';
}
// Render device list in scene modal
function renderSceneDevicesList(selectedDevices = []) {
const list = document.getElementById('scene-devices-list');
const noDevices = document.getElementById('no-scene-devices');
// Remove previous entries (except empty-state)
list.querySelectorAll('.scene-device-item').forEach(el => el.remove());
if (pairedDevices.length === 0) {
noDevices.style.display = 'flex';
} else {
noDevices.style.display = 'none';
pairedDevices.forEach(device => {
const selectedDevice = selectedDevices.find(d => d.id === device.id);
const isSelected = !!selectedDevice;
const deviceState = selectedDevice ? selectedDevice.state : 'on';
const item = document.createElement('div');
item.className = 'scene-device-item';
item.dataset.id = device.id;
const icon = device.type === 'light' ? '💡' : device.type === 'sensor' ? '🌡️' : '📟';
item.innerHTML = `
<label class="scene-device-checkbox">
<input type="checkbox" ${isSelected ? 'checked' : ''}
onchange="toggleSceneDevice('${device.id}')">
<span class="device-icon">${icon}</span>
<span class="device-name">${device.name}</span>
</label>
${device.type === 'light' ? `
<select class="scene-device-state" id="scene-device-state-${device.id}"
${!isSelected ? 'disabled' : ''}>
<option value="on" ${deviceState === 'on' ? 'selected' : ''}>${t('scene.light.on')}</option>
<option value="off" ${deviceState === 'off' ? 'selected' : ''}>${t('scene.light.off')}</option>
</select>
` : ''}
`;
list.insertBefore(item, noDevices);
});
}
}
function toggleSceneDevice(deviceId) {
const stateSelect = document.getElementById(`scene-device-state-${deviceId}`);
if (stateSelect) {
const checkbox = document.querySelector(`.scene-device-item[data-id="${deviceId}"] input[type="checkbox"]`);
stateSelect.disabled = !checkbox.checked;
}
}
function getSelectedSceneDevices() {
const devices = [];
document.querySelectorAll('.scene-device-item').forEach(item => {
const checkbox = item.querySelector('input[type="checkbox"]');
if (checkbox && checkbox.checked) {
const deviceId = item.dataset.id;
const stateSelect = document.getElementById(`scene-device-state-${deviceId}`);
devices.push({
id: deviceId,
state: stateSelect ? stateSelect.value : 'on'
});
}
});
return devices;
}
function closeSceneModal() {
document.getElementById('scene-modal').classList.remove('active');
document.body.style.overflow = '';
currentEditScene = null;
}
function selectSceneIcon(icon) {
selectedSceneIcon = icon;
document.querySelectorAll('.icon-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.icon === icon);
});
}
async function saveScene() {
const name = document.getElementById('scene-name').value.trim();
if (!name) {
showStatus('scenes-status', t('scenes.error.name'), 'error');
return;
}
const actions = {};
if (document.getElementById('scene-action-light').checked) {
actions.light = document.getElementById('scene-light-state').value;
}
if (document.getElementById('scene-action-mode').checked) {
actions.mode = document.getElementById('scene-mode-value').value;
}
if (document.getElementById('scene-action-schema').checked) {
actions.schema = document.getElementById('scene-schema-value').value;
}
// Add device actions
const selectedDevices = getSelectedSceneDevices();
if (selectedDevices.length > 0) {
actions.devices = selectedDevices;
}
const sceneData = {
id: currentEditScene || `scene-${Date.now()}`,
name,
icon: selectedSceneIcon,
actions
};
try {
const response = await fetch('/api/scenes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(sceneData)
});
if (response.ok) {
showStatus('scenes-status', currentEditScene ? t('scenes.updated') : t('scenes.created'), 'success');
loadScenes();
closeSceneModal();
}
} catch (error) {
// Demo mode
if (currentEditScene) {
const index = scenes.findIndex(s => s.id === currentEditScene);
if (index !== -1) scenes[index] = sceneData;
} else {
scenes.push(sceneData);
}
renderScenesConfig();
renderScenesControl();
showStatus('scenes-status', `Demo: ${currentEditScene ? t('scenes.updated') : t('scenes.created')}`, 'success');
closeSceneModal();
}
}
async function deleteScene(sceneId, name) {
if (!confirm(t('scenes.confirm.delete', { name }))) return;
try {
const response = await fetch('/api/scenes', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: sceneId })
});
if (response.ok) {
showStatus('scenes-status', t('scenes.deleted', { name }), 'success');
loadScenes();
}
} catch (error) {
// Demo mode
scenes = scenes.filter(s => s.id !== sceneId);
renderScenesConfig();
renderScenesControl();
showStatus('scenes-status', `Demo: ${t('scenes.deleted', { name })}`, 'success');
}
}
async function activateScene(sceneId) {
const scene = scenes.find(s => s.id === sceneId);
if (!scene) return;
try {
await fetch('/api/scenes/activate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: sceneId })
});
showStatus('scenes-control-status', t('scenes.activated', { name: scene.name }), 'success');
} catch (error) {
// Demo: Execute actions
if (scene.actions.light === 'on') {
lightOn = true;
updateLightToggle();
} else if (scene.actions.light === 'off') {
lightOn = false;
updateLightToggle();
}
if (scene.actions.mode) {
currentMode = scene.actions.mode;
updateModeButtons();
updateSimulationOptions();
}
// Device actions in demo mode
if (scene.actions.devices && scene.actions.devices.length > 0) {
scene.actions.devices.forEach(deviceAction => {
console.log(`Demo: Device ${deviceAction.id} -> ${deviceAction.state}`);
});
}
showStatus('scenes-control-status', `Demo: ${t('scenes.activated', { name: scene.name })}`, 'success');
}
}

View File

@@ -0,0 +1,208 @@
// Schema functions
async function loadSchema() {
const schemaFile = document.getElementById('schema-select').value;
const grid = document.getElementById('schema-grid');
const loading = document.getElementById('schema-loading');
grid.innerHTML = '';
loading.classList.add('active');
try {
const response = await fetch(`/api/schema/${schemaFile}`);
const text = await response.text();
schemaData = parseCSV(text);
renderSchemaGrid();
showStatus('schema-status', t('schema.loaded', { file: schemaFile }), 'success');
} catch (error) {
// Demo data for local testing
schemaData = generateDemoData();
renderSchemaGrid();
showStatus('schema-status', t('schema.demo'), 'error');
} finally {
loading.classList.remove('active');
}
}
function parseCSV(text) {
const lines = text.trim().split('\n');
return lines
.filter(line => line.trim() && !line.startsWith('#'))
.map(line => {
const values = line.split(',').map(v => parseInt(v.trim()));
return {
r: values[0] || 0,
g: values[1] || 0,
b: values[2] || 0,
v1: values[3] || 0,
v2: values[4] || 0,
v3: values[5] || 250
};
});
}
function generateDemoData() {
const data = [];
for (let i = 0; i < 48; i++) {
const hour = i / 2;
let r, g, b;
if (hour < 6 || hour >= 22) {
r = 25; g = 25; b = 112;
} else if (hour < 8) {
const t = (hour - 6) / 2;
r = Math.round(25 + 230 * t);
g = Math.round(25 + 150 * t);
b = Math.round(112 + 50 * t);
} else if (hour < 18) {
r = 255; g = 240; b = 220;
} else {
const t = (hour - 18) / 4;
r = Math.round(255 - 230 * t);
g = Math.round(240 - 215 * t);
b = Math.round(220 - 108 * t);
}
data.push({
r, g, b,
v1: 0,
v2: Math.round(100 + 155 * Math.sin(Math.PI * hour / 12)),
v3: 250
});
}
return data;
}
function renderSchemaGrid() {
const grid = document.getElementById('schema-grid');
grid.innerHTML = '';
for (let i = 0; i < 48; i++) {
const hour = Math.floor(i / 2);
const minute = (i % 2) * 30;
const time = `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`;
const data = schemaData[i] || { r: 0, g: 0, b: 0, v1: 0, v2: 100, v3: 250 };
const row = document.createElement('div');
row.className = 'time-row';
row.dataset.index = i;
row.innerHTML = `
<span class="time-label">${time}</span>
<div class="color-preview"
style="background: rgb(${data.r}, ${data.g}, ${data.b})"
onclick="openColorModal(${i})"
title="Tippen zum Bearbeiten"></div>
<input type="number" class="value-input" inputmode="numeric" pattern="[0-9]*" min="0" max="255" value="${data.r}"
onchange="updateValue(${i}, 'r', this.value)">
<input type="number" class="value-input" inputmode="numeric" pattern="[0-9]*" min="0" max="255" value="${data.g}"
onchange="updateValue(${i}, 'g', this.value)">
<input type="number" class="value-input" inputmode="numeric" pattern="[0-9]*" min="0" max="255" value="${data.b}"
onchange="updateValue(${i}, 'b', this.value)">
<input type="number" class="value-input" inputmode="numeric" pattern="[0-9]*" min="0" max="255" value="${data.v1}"
onchange="updateValue(${i}, 'v1', this.value)">
<input type="number" class="value-input" inputmode="numeric" pattern="[0-9]*" min="0" max="255" value="${data.v2}"
onchange="updateValue(${i}, 'v2', this.value)">
<input type="number" class="value-input" inputmode="numeric" pattern="[0-9]*" min="0" max="255" value="${data.v3}"
onchange="updateValue(${i}, 'v3', this.value)">
`;
grid.appendChild(row);
}
}
function updateValue(index, field, value) {
const numValue = Math.max(0, Math.min(255, parseInt(value) || 0));
schemaData[index][field] = numValue;
const row = document.querySelector(`.time-row[data-index="${index}"]`);
if (row) {
const preview = row.querySelector('.color-preview');
const data = schemaData[index];
preview.style.background = `rgb(${data.r}, ${data.g}, ${data.b})`;
}
}
function openColorModal(index) {
currentEditRow = index;
const data = schemaData[index];
document.getElementById('rangeR').value = data.r;
document.getElementById('rangeG').value = data.g;
document.getElementById('rangeB').value = data.b;
const hour = Math.floor(index / 2);
const minute = (index % 2) * 30;
document.getElementById('modal-time').textContent =
`${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`;
updateModalColor();
document.getElementById('color-modal').classList.add('active');
document.body.style.overflow = 'hidden';
}
function closeColorModal() {
document.getElementById('color-modal').classList.remove('active');
document.body.style.overflow = '';
currentEditRow = null;
}
function updateModalColor() {
const r = document.getElementById('rangeR').value;
const g = document.getElementById('rangeG').value;
const b = document.getElementById('rangeB').value;
document.getElementById('valR').textContent = r;
document.getElementById('valG').textContent = g;
document.getElementById('valB').textContent = b;
document.getElementById('preview-large').style.background = `rgb(${r}, ${g}, ${b})`;
}
function applyColor() {
if (currentEditRow === null) return;
const r = parseInt(document.getElementById('rangeR').value);
const g = parseInt(document.getElementById('rangeG').value);
const b = parseInt(document.getElementById('rangeB').value);
schemaData[currentEditRow].r = r;
schemaData[currentEditRow].g = g;
schemaData[currentEditRow].b = b;
const row = document.querySelector(`.time-row[data-index="${currentEditRow}"]`);
if (row) {
const inputs = row.querySelectorAll('.value-input');
inputs[0].value = r;
inputs[1].value = g;
inputs[2].value = b;
row.querySelector('.color-preview').style.background = `rgb(${r}, ${g}, ${b})`;
}
closeColorModal();
}
async function saveSchema() {
const schemaFile = document.getElementById('schema-select').value;
const csv = schemaData.map(row =>
`${row.r},${row.g},${row.b},${row.v1},${row.v2},${row.v3}`
).join('\n');
try {
const response = await fetch(`/api/schema/${schemaFile}`, {
method: 'POST',
headers: { 'Content-Type': 'text/csv' },
body: csv
});
if (response.ok) {
showStatus('schema-status', t('schema.saved', { file: schemaFile }), 'success');
} else {
throw new Error(t('error'));
}
} catch (error) {
showStatus('schema-status', t('error') + ': ' + error.message, 'error');
}
}

View File

@@ -0,0 +1,53 @@
// Theme management
function initTheme() {
const savedTheme = localStorage.getItem('theme') || 'dark';
setTheme(savedTheme);
}
function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
const icon = document.getElementById('theme-icon');
const label = document.getElementById('theme-label');
const metaTheme = document.querySelector('meta[name="theme-color"]');
if (theme === 'light') {
icon.textContent = '☀️';
label.textContent = 'Light';
metaTheme.content = '#f0f2f5';
} else {
icon.textContent = '🌙';
label.textContent = 'Dark';
metaTheme.content = '#1a1a2e';
}
}
function toggleTheme() {
const current = document.documentElement.getAttribute('data-theme') || 'dark';
setTheme(current === 'dark' ? 'light' : 'dark');
}
// Tab switching
function switchTab(tabName) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
document.querySelector(`.tab[onclick="switchTab('${tabName}')"]`).classList.add('active');
document.getElementById(`tab-${tabName}`).classList.add('active');
}
// Sub-tab switching
function switchSubTab(subTabName) {
document.querySelectorAll('.sub-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.sub-tab-content').forEach(c => c.classList.remove('active'));
document.querySelector(`.sub-tab[onclick="switchSubTab('${subTabName}')"]`).classList.add('active');
document.getElementById(`subtab-${subTabName}`).classList.add('active');
if (subTabName === 'schema' && schemaData.length === 0) {
loadSchema();
}
}
// Note: showStatus is defined in wifi-shared.js (loaded first)

View File

@@ -0,0 +1,138 @@
// WebSocket connection
function initWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws`;
try {
ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('WebSocket connected');
clearTimeout(wsReconnectTimer);
// Request initial status
ws.send(JSON.stringify({ type: 'getStatus' }));
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
handleWebSocketMessage(data);
} catch (e) {
console.error('WebSocket message error:', e);
}
};
ws.onclose = () => {
console.log('WebSocket disconnected, reconnecting in 3s...');
ws = null;
wsReconnectTimer = setTimeout(initWebSocket, 3000);
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
ws.close();
};
} catch (error) {
console.log('WebSocket not available, using demo mode');
initDemoMode();
}
}
function handleWebSocketMessage(data) {
switch (data.type) {
case 'status':
updateStatusFromData(data);
break;
case 'color':
updateColorPreview(data.r, data.g, data.b);
break;
case 'wifi':
updateWifiStatus(data);
break;
}
}
function updateStatusFromData(status) {
if (status.on !== undefined) {
lightOn = status.on;
updateLightToggle();
}
if (status.mode) {
currentMode = status.mode;
updateModeButtons();
updateSimulationOptions();
}
if (status.schema) {
document.getElementById('active-schema').value = status.schema;
const schemaNames = {
'schema_01.csv': 'Schema 1',
'schema_02.csv': 'Schema 2',
'schema_03.csv': 'Schema 3'
};
document.getElementById('current-schema').textContent = schemaNames[status.schema] || status.schema;
}
if (status.color) {
updateColorPreview(status.color.r, status.color.g, status.color.b);
}
}
function updateColorPreview(r, g, b) {
const colorPreview = document.getElementById('current-color');
colorPreview.style.background = `rgb(${r}, ${g}, ${b})`;
}
function updateWifiStatus(status) {
document.getElementById('conn-status').textContent = status.connected ? '✅ Verbunden' : '❌ Nicht verbunden';
document.getElementById('conn-ip').textContent = status.ip || '-';
document.getElementById('conn-rssi').textContent = status.rssi ? `${status.rssi} dBm` : '-';
}
// Send via WebSocket
function wsSend(data) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(data));
return true;
}
return false;
}
// Demo mode for local testing
function initDemoMode() {
updateSimulationOptions();
updateColorPreview(255, 240, 220);
// Simulate color changes in demo mode
let hue = 0;
setInterval(() => {
if (!ws) {
hue = (hue + 1) % 360;
const rgb = hslToRgb(hue / 360, 0.7, 0.6);
updateColorPreview(rgb.r, rgb.g, rgb.b);
}
}, 100);
}
function hslToRgb(h, s, l) {
let r, g, b;
if (s === 0) {
r = g = b = l;
} else {
const hue2rgb = (p, q, t) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hue2rgb(p, q, h + 1 / 3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1 / 3);
}
return { r: Math.round(r * 255), g: Math.round(g * 255), b: Math.round(b * 255) };
}

View File

@@ -0,0 +1,249 @@
// Shared WiFi configuration functions
// Used by both captive.html and index.html
/**
* Show status message
* @param {string} elementId - ID of the status element
* @param {string} message - Message to display
* @param {string} type - Type: 'success', 'error', or 'info'
*/
function showStatus(elementId, message, type) {
const status = document.getElementById(elementId);
if (!status) return;
status.textContent = message;
status.className = `status ${type}`;
if (type !== 'info') {
setTimeout(() => {
status.className = 'status';
}, 5000);
}
}
/**
* Scan for available WiFi networks
*/
async function scanNetworks() {
const loading = document.getElementById('loading');
const networkList = document.getElementById('network-list');
const select = document.getElementById('available-networks');
// Show loading state
if (loading) {
loading.classList.add('active');
}
if (networkList) {
networkList.style.display = 'none';
networkList.innerHTML = '';
}
if (select) {
select.innerHTML = `<option value="">${t('wifi.searching')}</option>`;
}
try {
const response = await fetch('/api/wifi/scan');
const networks = await response.json();
if (loading) {
loading.classList.remove('active');
}
// Sort by signal strength
networks.sort((a, b) => b.rssi - a.rssi);
// Render for captive portal (network list)
if (networkList) {
if (networks.length === 0) {
networkList.innerHTML = `<div class="network-item"><span class="network-name">${t('devices.unpaired.empty')}</span></div>`;
} else {
networks.forEach(network => {
const signalIcon = getSignalIcon(network.rssi);
const item = document.createElement('div');
item.className = 'network-item';
item.onclick = () => selectNetwork(network.ssid, item);
item.innerHTML = `
<span class="network-name">
<span class="signal-icon">${signalIcon}</span>
${escapeHtml(network.ssid)}
</span>
<span class="network-signal">${network.rssi} dBm</span>
`;
networkList.appendChild(item);
});
}
networkList.style.display = 'block';
}
// Render for main interface (select dropdown)
if (select) {
select.innerHTML = `<option value="">${t('wifi.scan.hint')}</option>`;
networks.forEach(network => {
const option = document.createElement('option');
option.value = network.ssid;
option.textContent = `${network.ssid} (${network.rssi} dBm)`;
select.appendChild(option);
});
// Note: onchange handler is set inline in HTML
}
showStatus('wifi-status', t('wifi.networks.found', { count: networks.length }), 'success');
} catch (error) {
if (loading) {
loading.classList.remove('active');
}
// Demo mode for local testing
const demoNetworks = [
{ ssid: 'Demo-Netzwerk', rssi: -45 },
{ ssid: 'Gast-WLAN', rssi: -67 },
{ ssid: 'Nachbar-WiFi', rssi: -82 }
];
if (networkList) {
demoNetworks.forEach(network => {
const signalIcon = getSignalIcon(network.rssi);
const item = document.createElement('div');
item.className = 'network-item';
item.onclick = () => selectNetwork(network.ssid, item);
item.innerHTML = `
<span class="network-name">
<span class="signal-icon">${signalIcon}</span>
${escapeHtml(network.ssid)}
</span>
<span class="network-signal">${network.rssi} dBm</span>
`;
networkList.appendChild(item);
});
networkList.style.display = 'block';
}
if (select) {
select.innerHTML = `<option value="">${t('wifi.scan.hint')}</option>`;
demoNetworks.forEach(network => {
const option = document.createElement('option');
option.value = network.ssid;
option.textContent = `${network.ssid} (${network.rssi} dBm)`;
select.appendChild(option);
});
}
showStatus('wifi-status', 'Demo: ' + t('wifi.networks.found', { count: demoNetworks.length }), 'info');
}
}
/**
* Select a network from the list (captive portal)
* @param {string} ssid - Network SSID
* @param {HTMLElement} element - Clicked element
*/
function selectNetwork(ssid, element) {
// Remove previous selection
document.querySelectorAll('.network-item').forEach(item => {
item.classList.remove('selected');
});
// Add selection to clicked item
element.classList.add('selected');
// Fill in SSID
document.getElementById('ssid').value = ssid;
// Focus password field
document.getElementById('password').focus();
}
/**
* Get signal strength icon
* @param {number} rssi - Signal strength in dBm
* @returns {string} Emoji icon
*/
function getSignalIcon(rssi) {
if (rssi >= -50) return '📶';
if (rssi >= -60) return '📶';
if (rssi >= -70) return '📶';
return '📶';
}
/**
* Escape HTML to prevent XSS
* @param {string} text - Text to escape
* @returns {string} Escaped text
*/
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Save WiFi configuration
*/
async function saveWifi() {
const ssid = document.getElementById('ssid').value.trim();
const password = document.getElementById('password').value;
if (!ssid) {
showStatus('wifi-status', t('wifi.error.ssid'), 'error');
return;
}
showStatus('wifi-status', t('common.loading'), 'info');
try {
const response = await fetch('/api/wifi/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ssid, password })
});
if (response.ok) {
showStatus('wifi-status', t('wifi.saved'), 'success');
// Show countdown for captive portal
if (document.querySelector('.info-box')) {
let countdown = 10;
const countdownInterval = setInterval(() => {
showStatus('wifi-status', t('captive.connecting', { seconds: countdown }), 'success');
countdown--;
if (countdown < 0) {
clearInterval(countdownInterval);
showStatus('wifi-status', t('captive.done'), 'success');
}
}, 1000);
}
} else {
const error = await response.json().catch(() => ({}));
throw new Error(error.error || t('wifi.error.save'));
}
} catch (error) {
if (error.message.includes('fetch')) {
// Demo mode
showStatus('wifi-status', 'Demo: ' + t('wifi.saved'), 'success');
} else {
showStatus('wifi-status', t('error') + ': ' + error.message, 'error');
}
}
}
/**
* Update connection status (for main interface)
*/
async function updateConnectionStatus() {
const connStatus = document.getElementById('conn-status');
const connIp = document.getElementById('conn-ip');
const connRssi = document.getElementById('conn-rssi');
if (!connStatus) return;
try {
const response = await fetch('/api/wifi/status');
const status = await response.json();
connStatus.textContent = status.connected ? t('wifi.connected') : t('wifi.disconnected');
if (connIp) connIp.textContent = status.ip || '-';
if (connRssi) connRssi.textContent = status.rssi ? `${status.rssi} dBm` : '-';
} catch (error) {
connStatus.textContent = t('wifi.unavailable');
}
}