diff --git a/firmware/README-API.md b/firmware/README-API.md
new file mode 100644
index 0000000..359ca4b
--- /dev/null
+++ b/firmware/README-API.md
@@ -0,0 +1,722 @@
+# System Control - API Documentation
+
+This document describes all REST API endpoints and WebSocket messages required for the ESP32 firmware implementation.
+
+## Table of Contents
+
+- [REST API Endpoints](#rest-api-endpoints)
+ - [WiFi](#wifi)
+ - [Light Control](#light-control)
+ - [Schema](#schema)
+ - [Devices](#devices)
+ - [Scenes](#scenes)
+- [WebSocket](#websocket)
+ - [Connection](#connection)
+ - [Client to Server Messages](#client-to-server-messages)
+ - [Server to Client Messages](#server-to-client-messages)
+
+---
+
+## REST API Endpoints
+
+### WiFi
+
+#### Scan Networks
+
+Scans for available WiFi networks.
+
+- **URL:** `/api/wifi/scan`
+- **Method:** `GET`
+- **Response:**
+
+```json
+[
+ {
+ "ssid": "NetworkName",
+ "rssi": -45
+ },
+ {
+ "ssid": "AnotherNetwork",
+ "rssi": -72
+ }
+]
+```
+
+| Field | Type | Description |
+|-------|--------|------------------------------------|
+| ssid | string | Network name (SSID) |
+| rssi | number | Signal strength in dBm |
+
+---
+
+#### Save WiFi Configuration
+
+Saves WiFi credentials and initiates connection.
+
+- **URL:** `/api/wifi/config`
+- **Method:** `POST`
+- **Content-Type:** `application/json`
+- **Request Body:**
+
+```json
+{
+ "ssid": "NetworkName",
+ "password": "SecretPassword123"
+}
+```
+
+| Field | Type | Required | Description |
+|----------|--------|----------|-------------------|
+| ssid | string | Yes | Network name |
+| password | string | No | Network password |
+
+- **Response:** `200 OK` on success, `400 Bad Request` on error
+
+---
+
+#### Get WiFi Status
+
+Returns current WiFi connection status.
+
+- **URL:** `/api/wifi/status`
+- **Method:** `GET`
+- **Response:**
+
+```json
+{
+ "connected": true,
+ "ssid": "NetworkName",
+ "ip": "192.168.1.100",
+ "rssi": -45
+}
+```
+
+| Field | Type | Description |
+|-----------|---------|------------------------------------------|
+| connected | boolean | Whether connected to a network |
+| ssid | string | Connected network name (if connected) |
+| ip | string | Assigned IP address (if connected) |
+| rssi | number | Signal strength in dBm (if connected) |
+
+---
+
+### Light Control
+
+#### Set Light Power
+
+Turns the main light on or off.
+
+- **URL:** `/api/light/power`
+- **Method:** `POST`
+- **Content-Type:** `application/json`
+- **Request Body:**
+
+```json
+{
+ "on": true
+}
+```
+
+| Field | Type | Required | Description |
+|-------|---------|----------|--------------------------|
+| on | boolean | Yes | `true` = on, `false` = off |
+
+- **Response:** `200 OK` on success
+
+---
+
+#### Set Light Mode
+
+Sets the lighting mode.
+
+- **URL:** `/api/light/mode`
+- **Method:** `POST`
+- **Content-Type:** `application/json`
+- **Request Body:**
+
+```json
+{
+ "mode": "simulation"
+}
+```
+
+| Field | Type | Required | Description |
+|-------|--------|----------|------------------------------------------------|
+| mode | string | Yes | One of: `day`, `night`, `simulation` |
+
+- **Response:** `200 OK` on success
+
+---
+
+#### Set Active Schema
+
+Sets the active schema for simulation mode.
+
+- **URL:** `/api/light/schema`
+- **Method:** `POST`
+- **Content-Type:** `application/json`
+- **Request Body:**
+
+```json
+{
+ "schema": "schema_01.csv"
+}
+```
+
+| Field | Type | Required | Description |
+|--------|--------|----------|-------------------------------------------------------|
+| schema | string | Yes | Schema filename: `schema_01.csv`, `schema_02.csv`, etc. |
+
+- **Response:** `200 OK` on success
+
+---
+
+#### Get Light Status
+
+Returns current light status (alternative to WebSocket).
+
+- **URL:** `/api/light/status`
+- **Method:** `GET`
+- **Response:**
+
+```json
+{
+ "on": true,
+ "mode": "simulation",
+ "schema": "schema_01.csv",
+ "color": {
+ "r": 255,
+ "g": 240,
+ "b": 220
+ }
+}
+```
+
+| Field | Type | Description |
+|--------|---------|--------------------------------------|
+| on | boolean | Current power state |
+| mode | string | Current mode (day/night/simulation) |
+| schema | string | Active schema filename |
+| color | object | Current RGB color being displayed |
+
+---
+
+### Schema
+
+#### Load Schema
+
+Loads a schema file.
+
+- **URL:** `/api/schema/{filename}`
+- **Method:** `GET`
+- **URL Parameters:**
+ - `filename`: Schema file name (e.g., `schema_01.csv`)
+- **Response:** CSV text data
+
+```
+255,240,220,0,100,250
+255,230,200,0,120,250
+...
+```
+
+The CSV format has 48 rows (one per 30-minute interval) with 6 values per row:
+
+| Column | Description | Range |
+|--------|--------------------------------|---------|
+| 1 | Red (R) | 0-255 |
+| 2 | Green (G) | 0-255 |
+| 3 | Blue (B) | 0-255 |
+| 4 | Value 1 (V1) - custom value | 0-255 |
+| 5 | Value 2 (V2) - custom value | 0-255 |
+| 6 | Value 3 (V3) - custom value | 0-255 |
+
+---
+
+#### Save Schema
+
+Saves a schema file.
+
+- **URL:** `/api/schema/{filename}`
+- **Method:** `POST`
+- **Content-Type:** `text/csv`
+- **URL Parameters:**
+ - `filename`: Schema file name (e.g., `schema_01.csv`)
+- **Request Body:** CSV text data (same format as above)
+- **Response:** `200 OK` on success
+
+---
+
+### Devices
+
+#### Scan for Devices
+
+Scans for available Matter devices to pair.
+
+- **URL:** `/api/devices/scan`
+- **Method:** `GET`
+- **Response:**
+
+```json
+[
+ {
+ "id": "matter-001",
+ "type": "light",
+ "name": "Matter Lamp"
+ },
+ {
+ "id": "matter-002",
+ "type": "sensor",
+ "name": "Temperature Sensor"
+ }
+]
+```
+
+| Field | Type | Description |
+|-------|--------|-----------------------------------------------|
+| id | string | Unique device identifier |
+| type | string | Device type: `light`, `sensor`, `unknown` |
+| name | string | Device name (can be empty) |
+
+---
+
+#### Pair Device
+
+Pairs a discovered device.
+
+- **URL:** `/api/devices/pair`
+- **Method:** `POST`
+- **Content-Type:** `application/json`
+- **Request Body:**
+
+```json
+{
+ "id": "matter-001",
+ "name": "Living Room Lamp"
+}
+```
+
+| Field | Type | Required | Description |
+|-------|--------|----------|------------------------------|
+| id | string | Yes | Device ID from scan |
+| name | string | Yes | User-defined device name |
+
+- **Response:** `200 OK` on success
+
+---
+
+#### Get Paired Devices
+
+Returns list of all paired devices.
+
+- **URL:** `/api/devices/paired`
+- **Method:** `GET`
+- **Response:**
+
+```json
+[
+ {
+ "id": "matter-001",
+ "type": "light",
+ "name": "Living Room Lamp"
+ }
+]
+```
+
+| Field | Type | Description |
+|-------|--------|-------------------------------------------|
+| id | string | Unique device identifier |
+| type | string | Device type: `light`, `sensor`, `unknown` |
+| name | string | User-defined device name |
+
+---
+
+#### Update Device Name
+
+Updates the name of a paired device.
+
+- **URL:** `/api/devices/update`
+- **Method:** `POST`
+- **Content-Type:** `application/json`
+- **Request Body:**
+
+```json
+{
+ "id": "matter-001",
+ "name": "New Device Name"
+}
+```
+
+| Field | Type | Required | Description |
+|-------|--------|----------|------------------------|
+| id | string | Yes | Device ID |
+| name | string | Yes | New device name |
+
+- **Response:** `200 OK` on success
+
+---
+
+#### Unpair Device
+
+Removes a paired device.
+
+- **URL:** `/api/devices/unpair`
+- **Method:** `POST`
+- **Content-Type:** `application/json`
+- **Request Body:**
+
+```json
+{
+ "id": "matter-001"
+}
+```
+
+| Field | Type | Required | Description |
+|-------|--------|----------|---------------|
+| id | string | Yes | Device ID |
+
+- **Response:** `200 OK` on success
+
+---
+
+#### Toggle Device
+
+Toggles a device (e.g., light on/off).
+
+- **URL:** `/api/devices/toggle`
+- **Method:** `POST`
+- **Content-Type:** `application/json`
+- **Request Body:**
+
+```json
+{
+ "id": "matter-001"
+}
+```
+
+| Field | Type | Required | Description |
+|-------|--------|----------|---------------|
+| id | string | Yes | Device ID |
+
+- **Response:** `200 OK` on success
+
+---
+
+### Scenes
+
+#### Get All Scenes
+
+Returns all configured scenes.
+
+- **URL:** `/api/scenes`
+- **Method:** `GET`
+- **Response:**
+
+```json
+[
+ {
+ "id": "scene-1",
+ "name": "Evening Mood",
+ "icon": "🌅",
+ "actions": {
+ "light": "on",
+ "mode": "simulation",
+ "schema": "schema_02.csv",
+ "devices": [
+ {
+ "id": "matter-001",
+ "state": "on"
+ }
+ ]
+ }
+ },
+ {
+ "id": "scene-2",
+ "name": "Night Mode",
+ "icon": "🌙",
+ "actions": {
+ "light": "on",
+ "mode": "night"
+ }
+ }
+]
+```
+
+**Scene Object:**
+
+| Field | Type | Description |
+|---------|--------|------------------------------------|
+| id | string | Unique scene identifier |
+| name | string | Scene name |
+| icon | string | Emoji icon for the scene |
+| actions | object | Actions to execute (see below) |
+
+**Actions Object:**
+
+| Field | Type | Optional | Description |
+|---------|--------|----------|------------------------------------------|
+| light | string | Yes | `"on"` or `"off"` |
+| mode | string | Yes | `"day"`, `"night"`, or `"simulation"` |
+| schema | string | Yes | Schema filename (e.g., `schema_01.csv`) |
+| devices | array | Yes | Array of device actions (see below) |
+
+**Device Action Object:**
+
+| Field | Type | Description |
+|-------|--------|----------------------------------|
+| id | string | Device ID |
+| state | string | `"on"` or `"off"` |
+
+---
+
+#### Create/Update Scene
+
+Creates a new scene or updates an existing one.
+
+- **URL:** `/api/scenes`
+- **Method:** `POST`
+- **Content-Type:** `application/json`
+- **Request Body:**
+
+```json
+{
+ "id": "scene-1",
+ "name": "Evening Mood",
+ "icon": "🌅",
+ "actions": {
+ "light": "on",
+ "mode": "simulation",
+ "schema": "schema_02.csv",
+ "devices": [
+ {
+ "id": "matter-001",
+ "state": "on"
+ }
+ ]
+ }
+}
+```
+
+- **Response:** `200 OK` on success
+
+---
+
+#### Delete Scene
+
+Deletes a scene.
+
+- **URL:** `/api/scenes`
+- **Method:** `DELETE`
+- **Content-Type:** `application/json`
+- **Request Body:**
+
+```json
+{
+ "id": "scene-1"
+}
+```
+
+| Field | Type | Required | Description |
+|-------|--------|----------|---------------|
+| id | string | Yes | Scene ID |
+
+- **Response:** `200 OK` on success
+
+---
+
+#### Activate Scene
+
+Executes all actions of a scene.
+
+- **URL:** `/api/scenes/activate`
+- **Method:** `POST`
+- **Content-Type:** `application/json`
+- **Request Body:**
+
+```json
+{
+ "id": "scene-1"
+}
+```
+
+| Field | Type | Required | Description |
+|-------|--------|----------|---------------|
+| id | string | Yes | Scene ID |
+
+- **Response:** `200 OK` on success
+
+---
+
+## WebSocket
+
+### Connection
+
+- **URL:** `ws://{host}/ws` or `wss://{host}/ws`
+- **Protocol:** JSON messages
+
+The WebSocket connection is used for real-time status updates. The client should reconnect automatically if the connection is lost (recommended: 3 second delay).
+
+---
+
+### Client to Server Messages
+
+#### Request Status
+
+Requests the current system status.
+
+```json
+{
+ "type": "getStatus"
+}
+```
+
+---
+
+### Server to Client Messages
+
+#### Status Update
+
+Sent in response to `getStatus` or when status changes.
+
+```json
+{
+ "type": "status",
+ "on": true,
+ "mode": "simulation",
+ "schema": "schema_01.csv",
+ "color": {
+ "r": 255,
+ "g": 240,
+ "b": 220
+ }
+}
+```
+
+| Field | Type | Description |
+|--------|---------|--------------------------------------|
+| type | string | Always `"status"` |
+| on | boolean | Light power state |
+| mode | string | Current mode (day/night/simulation) |
+| schema | string | Active schema filename |
+| color | object | Current RGB color (optional) |
+
+---
+
+#### Color Update
+
+Sent when the current color changes (during simulation).
+
+```json
+{
+ "type": "color",
+ "r": 255,
+ "g": 200,
+ "b": 150
+}
+```
+
+| Field | Type | Description |
+|-------|--------|--------------------------|
+| type | string | Always `"color"` |
+| r | number | Red value (0-255) |
+| g | number | Green value (0-255) |
+| b | number | Blue value (0-255) |
+
+---
+
+#### WiFi Status Update
+
+Sent when WiFi connection status changes.
+
+```json
+{
+ "type": "wifi",
+ "connected": true,
+ "ip": "192.168.1.100",
+ "rssi": -45
+}
+```
+
+| Field | Type | Description |
+|-----------|---------|---------------------------------------|
+| type | string | Always `"wifi"` |
+| connected | boolean | Whether connected to a network |
+| ip | string | Assigned IP address (if connected) |
+| rssi | number | Signal strength in dBm (if connected) |
+
+---
+
+## Static Files
+
+The web interface files should be served from the `/storage/www/` directory:
+
+| Path | Description |
+|----------------------|------------------------------------------------|
+| `/` | Serves `index.html` (or `captive.html` in AP mode) |
+| `/index.html` | Main HTML file (full interface) |
+| `/captive.html` | Captive portal (WiFi setup only) |
+| `/css/shared.css` | Shared styles for all pages |
+| `/css/index.css` | Styles for main interface |
+| `/css/captive.css` | Styles for captive portal |
+| `/js/wifi-shared.js` | Shared WiFi configuration logic |
+| `/js/*.js` | JavaScript modules |
+
+---
+
+## Captive Portal
+
+When the ESP32 is in Access Point (AP) mode (no WiFi configured or connection failed), it should serve the captive portal:
+
+### Behavior
+
+1. **AP Mode Activation:**
+ - ESP32 creates an access point (e.g., "marklin-setup")
+ - DNS server redirects all requests to the ESP32's IP (captive portal detection)
+
+2. **Captive Portal Detection:**
+ - Respond to common captive portal detection URLs:
+ - `/generate_204` (Android)
+ - `/hotspot-detect.html` (Apple)
+ - `/connecttest.txt` (Windows)
+ - Return redirect or serve `captive.html`
+
+3. **Serving Files in AP Mode:**
+ - `/` → `captive.html`
+ - `/captive.html` → Captive portal page
+ - `/js/wifi-shared.js` → WiFi functions
+ - API endpoints remain the same (`/api/wifi/*`)
+
+4. **After Successful Configuration:**
+ - ESP32 attempts to connect to the configured network
+ - If successful, switch to Station mode and serve `index.html`
+ - If failed, remain in AP mode
+
+### Recommended AP Settings
+
+| Setting | Value |
+|---------------|--------------------------|
+| SSID | `marklin-setup` |
+| Password | None (open) or `marklin` |
+| IP Address | `192.168.4.1` |
+| Gateway | `192.168.4.1` |
+| Subnet | `255.255.255.0` |
+
+---
+
+## Error Handling
+
+All endpoints should return appropriate HTTP status codes:
+
+| Code | Description |
+|------|---------------------------------------|
+| 200 | Success |
+| 400 | Bad Request (invalid input) |
+| 404 | Not Found (resource doesn't exist) |
+| 500 | Internal Server Error |
+
+Error responses should include a JSON body with an error message:
+
+```json
+{
+ "error": "Description of what went wrong"
+}
+```
diff --git a/firmware/README-captive.md b/firmware/README-captive.md
new file mode 100644
index 0000000..4b4d8a5
--- /dev/null
+++ b/firmware/README-captive.md
@@ -0,0 +1,209 @@
+# Captive Portal Implementation Guide
+
+This document describes how to implement the captive portal functionality on the ESP32 side to work with `captive.html`.
+
+## Overview
+
+When the ESP32 has no WiFi credentials stored (or connection fails), it should start in Access Point (AP) mode and serve a captive portal that allows users to configure WiFi settings.
+
+## How Captive Portal Detection Works
+
+Operating systems automatically send HTTP requests to known URLs to check for internet connectivity:
+
+| OS | Detection URL | Expected Response |
+|---|---|---|
+| **iOS/macOS** | `http://captive.apple.com/hotspot-detect.html` | `
Success Success` |
+| **Android** | `http://connectivitycheck.gstatic.com/generate_204` | HTTP 204 No Content |
+| **Windows** | `http://www.msftconnecttest.com/connecttest.txt` | `Microsoft Connect Test` |
+
+If the response doesn't match, the OS assumes there's a captive portal and opens a browser.
+
+## ESP32 Implementation Steps
+
+### 1. Start Access Point Mode
+
+```c
+wifi_config_t ap_config = {
+ .ap = {
+ .ssid = "SystemControl-Setup",
+ .ssid_len = 0,
+ .password = "", // Open network for easy access
+ .max_connection = 4,
+ .authmode = WIFI_AUTH_OPEN
+ }
+};
+esp_wifi_set_mode(WIFI_MODE_AP);
+esp_wifi_set_config(WIFI_IF_AP, &ap_config);
+esp_wifi_start();
+```
+
+### 2. Start DNS Server (DNS Hijacking)
+
+Redirect ALL DNS queries to the ESP32's IP address:
+
+```c
+// Simplified example - use a proper DNS server component
+void dns_server_task(void *pvParameters) {
+ // Listen on UDP port 53
+ // For any DNS query, respond with ESP32's AP IP (e.g., 192.168.4.1)
+}
+```
+
+### 3. Configure HTTP Server with Redirects
+
+```c
+// Handler for captive portal detection URLs
+esp_err_t captive_redirect_handler(httpd_req_t *req) {
+ httpd_resp_set_status(req, "302 Found");
+ httpd_resp_set_hdr(req, "Location", "http://192.168.4.1/captive.html");
+ httpd_resp_send(req, NULL, 0);
+ return ESP_OK;
+}
+
+// Register handlers for detection URLs
+httpd_uri_t apple_detect = {
+ .uri = "/hotspot-detect.html",
+ .method = HTTP_GET,
+ .handler = captive_redirect_handler
+};
+
+httpd_uri_t android_detect = {
+ .uri = "/generate_204",
+ .method = HTTP_GET,
+ .handler = captive_redirect_handler
+};
+
+// Catch-all for any unknown paths
+httpd_uri_t catch_all = {
+ .uri = "/*",
+ .method = HTTP_GET,
+ .handler = captive_redirect_handler
+};
+```
+
+### 4. Serve Static Files
+
+Serve the captive portal files from SPIFFS/LittleFS:
+
+- `/captive.html` - Main captive portal page
+- `/favicon.svg` - Favicon
+- `/css/shared.css` - Shared styles
+- `/css/captive.css` - Captive-specific styles
+- `/js/i18n.js` - Internationalization
+- `/js/wifi-shared.js` - WiFi configuration logic
+
+### 5. Implement WiFi Configuration API
+
+```c
+// POST /api/wifi/config
+// Body: { "ssid": "NetworkName", "password": "SecretPassword" }
+esp_err_t wifi_config_handler(httpd_req_t *req) {
+ // 1. Parse JSON body
+ // 2. Store credentials in NVS
+ // 3. Send success response
+ // 4. Schedule restart/reconnect
+ return ESP_OK;
+}
+
+// GET /api/wifi/scan
+// Returns: [{ "ssid": "Network1", "rssi": -45 }, ...]
+esp_err_t wifi_scan_handler(httpd_req_t *req) {
+ // 1. Perform WiFi scan
+ // 2. Return JSON array of networks
+ return ESP_OK;
+}
+```
+
+## Flow After User Submits WiFi Credentials
+
+```
+1. User enters SSID + Password, clicks "Connect"
+ ↓
+2. Frontend sends POST /api/wifi/config
+ ↓
+3. ESP32 stores credentials in NVS (Non-Volatile Storage)
+ ↓
+4. ESP32 sends HTTP 200 OK response
+ ↓
+5. Frontend shows countdown (10 seconds)
+ ↓
+6. ESP32 stops AP mode
+ ↓
+7. ESP32 connects to configured WiFi
+ ↓
+8. ESP32 gets new IP from router (e.g., 192.168.1.42)
+ ↓
+9. User connects phone/PC to normal WiFi
+ ↓
+10. User accesses ESP32 via new IP or mDNS (e.g., http://system-control.local)
+```
+
+## Recommended: mDNS Support
+
+Register an mDNS hostname so users can access the device without knowing the IP:
+
+```c
+mdns_init();
+mdns_hostname_set("system-control");
+mdns_instance_name_set("System Control");
+```
+
+Then the device is accessible at: `http://system-control.local`
+
+## Error Handling / Fallback
+
+If WiFi connection fails after credentials are saved:
+
+1. Wait for connection timeout (e.g., 30 seconds)
+2. If connection fails, restart in AP mode
+3. Show error message on captive portal
+4. Allow user to re-enter credentials
+
+```c
+// Pseudo-code
+if (wifi_connect_timeout()) {
+ nvs_erase_key("wifi_ssid");
+ nvs_erase_key("wifi_password");
+ esp_restart(); // Will boot into AP mode again
+}
+```
+
+## API Endpoints Summary
+
+| Method | Endpoint | Description |
+|--------|----------|-------------|
+| GET | `/api/wifi/scan` | Scan for available networks |
+| POST | `/api/wifi/config` | Save WiFi credentials |
+| GET | `/api/wifi/status` | Get current connection status |
+
+### Request/Response Examples
+
+**GET /api/wifi/scan**
+```json
+[
+ { "ssid": "HomeNetwork", "rssi": -45, "secure": true },
+ { "ssid": "GuestWiFi", "rssi": -67, "secure": false }
+]
+```
+
+**POST /api/wifi/config**
+```json
+{ "ssid": "HomeNetwork", "password": "MySecretPassword" }
+```
+
+**GET /api/wifi/status**
+```json
+{
+ "connected": true,
+ "ssid": "HomeNetwork",
+ "ip": "192.168.1.42",
+ "rssi": -52
+}
+```
+
+## Security Considerations
+
+1. **Open AP**: The setup AP is intentionally open for easy access. Keep setup time short.
+2. **HTTPS**: Consider using HTTPS for the main interface (after WiFi setup).
+3. **Timeout**: Auto-disable AP mode after successful connection.
+4. **Button Reset**: Implement a physical button to reset WiFi credentials and re-enter AP mode.
diff --git a/firmware/storage/www/captive.html b/firmware/storage/www/captive.html
new file mode 100644
index 0000000..53e333e
--- /dev/null
+++ b/firmware/storage/www/captive.html
@@ -0,0 +1,138 @@
+
+
+
+
+
+
+
+
+
+ System Control - WLAN Setup
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 📡 Netzwerke suchen
+
+
+
+
+
Suche nach Netzwerken...
+
+
+
+
+
+
oder manuell eingeben
+
+
+ WLAN-Name (SSID)
+
+
+
+
+
+
+
+ 💾 Verbinden
+
+
+
+
+
+
+ ℹ️ Hinweis:
+ 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.
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/firmware/storage/www/css/captive.css b/firmware/storage/www/css/captive.css
new file mode 100644
index 0000000..a727980
--- /dev/null
+++ b/firmware/storage/www/css/captive.css
@@ -0,0 +1,201 @@
+/* Captive Portal CSS - WiFi setup specific styles */
+/* Base styles are in shared.css */
+
+body {
+ padding: 16px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.container {
+ width: 100%;
+ max-width: 400px;
+}
+
+/* Header */
+.header {
+ text-align: center;
+ margin-bottom: 24px;
+}
+
+.header h1 {
+ font-size: 1.4rem;
+ margin-bottom: 8px;
+}
+
+.header p {
+ color: var(--text-muted);
+ font-size: 0.9rem;
+}
+
+/* Theme Toggle - Absolute positioned */
+/* Handled by .captive-header in shared.css */
+
+/* Card - Larger padding for captive */
+.card {
+ border-radius: 16px;
+ padding: 24px;
+ box-shadow: 0 4px 20px var(--shadow);
+}
+
+/* Form Group - More spacing */
+.form-group {
+ margin-bottom: 16px;
+}
+
+label {
+ margin-bottom: 8px;
+ font-weight: 500;
+}
+
+/* Inputs - Thicker border for captive */
+input[type="text"],
+input[type="password"],
+select {
+ padding: 14px 16px;
+ border-width: 2px;
+ border-radius: 10px;
+}
+
+/* Buttons - Full width for captive */
+.btn {
+ width: 100%;
+ padding: 14px 20px;
+ border-radius: 10px;
+ font-size: 1rem;
+ font-weight: 600;
+ min-height: 50px;
+}
+
+.btn-secondary {
+ margin-top: 10px;
+}
+
+.btn-group {
+ flex-direction: column;
+ gap: 10px;
+ margin-top: 20px;
+}
+
+/* Status - Centered text */
+.status {
+ text-align: center;
+ border-radius: 10px;
+ padding: 12px 16px;
+}
+
+/* Divider */
+.divider {
+ display: flex;
+ align-items: center;
+ margin: 20px 0;
+ color: var(--text-muted);
+ font-size: 0.85rem;
+}
+
+.divider::before,
+.divider::after {
+ content: '';
+ flex: 1;
+ height: 1px;
+ background: var(--border);
+}
+
+.divider span {
+ padding: 0 12px;
+}
+
+/* Network List */
+.network-list {
+ max-height: 200px;
+ overflow-y: auto;
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ margin-bottom: 16px;
+}
+
+.network-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 12px 16px;
+ border-bottom: 1px solid var(--border);
+ cursor: pointer;
+ transition: background 0.2s;
+}
+
+.network-item:last-child {
+ border-bottom: none;
+}
+
+.network-item:hover {
+ background: var(--accent);
+}
+
+.network-item.selected {
+ background: var(--accent);
+ border-left: 3px solid var(--success);
+}
+
+.network-name {
+ font-weight: 500;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.network-signal {
+ color: var(--text-muted);
+ font-size: 0.85rem;
+}
+
+.signal-icon {
+ font-size: 1.1rem;
+}
+
+/* Password Toggle */
+.password-toggle {
+ position: relative;
+}
+
+.password-toggle input {
+ padding-right: 50px;
+}
+
+.password-toggle button {
+ position: absolute;
+ right: 12px;
+ top: 50%;
+ transform: translateY(-50%);
+ background: none;
+ border: none;
+ color: var(--text-muted);
+ font-size: 1.2rem;
+ cursor: pointer;
+ padding: 4px;
+}
+
+/* Info Box */
+.info-box {
+ background: var(--accent);
+ border-radius: 10px;
+ padding: 12px 16px;
+ margin-top: 20px;
+ font-size: 0.85rem;
+ color: var(--text-muted);
+}
+
+.info-box strong {
+ color: var(--text);
+}
+
+/* Spinner - Smaller for captive */
+.spinner {
+ width: 30px;
+ height: 30px;
+}
+
+.loading {
+ padding: 20px;
+}
\ No newline at end of file
diff --git a/firmware/storage/www/css/index.css b/firmware/storage/www/css/index.css
new file mode 100644
index 0000000..7b38b94
--- /dev/null
+++ b/firmware/storage/www/css/index.css
@@ -0,0 +1,1201 @@
+/* Control CSS - Main application specific styles */
+/* Base styles are in shared.css */
+
+body {
+ padding: 12px;
+}
+
+.container {
+ max-width: 900px;
+ margin: 0 auto;
+}
+
+/* Header */
+.header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 20px;
+ flex-wrap: wrap;
+ gap: 10px;
+}
+
+.header h1 {
+ font-size: 1.5rem;
+ margin: 0;
+}
+
+.form-group {
+ margin-bottom: 12px;
+}
+
+/* Schema Editor */
+.schema-controls {
+ display: flex;
+ gap: 10px;
+ align-items: flex-end;
+ flex-wrap: wrap;
+ margin-bottom: 16px;
+}
+
+.schema-controls .form-group {
+ margin-bottom: 0;
+ flex: 1;
+ min-width: 150px;
+}
+
+.schema-controls .btn {
+ flex: 0 0 auto;
+}
+
+.time-grid {
+ display: grid;
+ gap: 6px;
+ max-height: 60vh;
+ overflow-y: auto;
+ -webkit-overflow-scrolling: touch;
+}
+
+.time-row {
+ display: grid;
+ grid-template-columns: 55px 40px repeat(6, 1fr);
+ gap: 6px;
+ align-items: center;
+ padding: 8px;
+ background: var(--bg-color);
+ border-radius: 8px;
+ border: 1px solid var(--border);
+}
+
+.time-label {
+ font-weight: 600;
+ color: var(--text);
+ font-size: 0.85rem;
+}
+
+.color-preview {
+ width: 100%;
+ height: 32px;
+ border-radius: 6px;
+ border: 2px solid var(--border);
+ cursor: pointer;
+ transition: transform 0.1s;
+ min-width: 32px;
+}
+
+.color-preview:hover {
+ transform: scale(1.05);
+}
+
+.value-input {
+ width: 100%;
+ padding: 8px 4px;
+ text-align: center;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--card-bg);
+ color: var(--text);
+ font-size: 14px;
+ min-width: 0;
+}
+
+.value-input:focus {
+ outline: none;
+ border-color: var(--success);
+}
+
+/* Hide number input spinners on mobile for cleaner look */
+.value-input::-webkit-outer-spin-button,
+.value-input::-webkit-inner-spin-button {
+ -webkit-appearance: none;
+ margin: 0;
+}
+
+.value-input[type=number] {
+ -moz-appearance: textfield;
+ appearance: textfield;
+}
+
+.value-header {
+ display: grid;
+ grid-template-columns: 55px 40px repeat(6, 1fr);
+ gap: 6px;
+ padding: 8px;
+ font-size: 0.7rem;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+}
+
+.value-header span {
+ text-align: center;
+}
+
+.value-header span:first-child {
+ text-align: left;
+}
+
+/* Color Picker Modal */
+.modal-overlay {
+ display: none;
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.7);
+ z-index: 100;
+ align-items: center;
+ justify-content: center;
+ padding: 16px;
+}
+
+.modal-overlay.active {
+ display: flex;
+}
+
+.modal {
+ background: var(--card-bg);
+ border-radius: 16px;
+ padding: 20px;
+ width: 100%;
+ max-width: 350px;
+ border: 1px solid var(--border);
+ max-height: 90vh;
+ overflow-y: auto;
+}
+
+.modal h3 {
+ margin-bottom: 20px;
+ text-align: center;
+ font-size: 1.1rem;
+}
+
+.color-picker-container {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.color-slider {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.color-slider label {
+ width: 24px;
+ margin: 0;
+ font-weight: bold;
+ font-size: 1rem;
+}
+
+.color-slider input[type="range"] {
+ flex: 1;
+ height: 28px;
+ -webkit-appearance: none;
+ appearance: none;
+ border-radius: 14px;
+ padding: 10px 0;
+ background: transparent;
+}
+
+.color-slider input[type="range"]::-webkit-slider-runnable-track {
+ height: 8px;
+ border-radius: 4px;
+}
+
+.color-slider input[type="range"]::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ appearance: none;
+ width: 28px;
+ height: 28px;
+ border-radius: 50%;
+ background: var(--text);
+ cursor: pointer;
+ margin-top: -10px;
+ box-shadow: 0 2px 6px var(--shadow);
+}
+
+#rangeR::-webkit-slider-runnable-track {
+ background: linear-gradient(to right, #000, #f00);
+}
+
+#rangeG::-webkit-slider-runnable-track {
+ background: linear-gradient(to right, #000, #0f0);
+}
+
+#rangeB::-webkit-slider-runnable-track {
+ background: linear-gradient(to right, #000, #00f);
+}
+
+.color-slider .value {
+ width: 40px;
+ text-align: right;
+ font-family: monospace;
+ font-size: 0.95rem;
+}
+
+.preview-large {
+ width: 100%;
+ height: 60px;
+ border-radius: 12px;
+ border: 2px solid var(--border);
+ margin: 8px 0;
+}
+
+.modal-buttons {
+ display: flex;
+ gap: 10px;
+ margin-top: 16px;
+}
+
+.modal-buttons .btn {
+ flex: 1;
+}
+
+/* Scrollbar Styling */
+.time-grid::-webkit-scrollbar {
+ width: 6px;
+}
+
+.time-grid::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+.time-grid::-webkit-scrollbar-thumb {
+ background: var(--accent);
+ border-radius: 3px;
+}
+
+/* Loading Spinner */
+.loading {
+ display: none;
+ text-align: center;
+ padding: 30px;
+ color: var(--text-muted);
+}
+
+.loading.active {
+ display: block;
+}
+
+.spinner {
+ width: 36px;
+ height: 36px;
+ border: 3px solid var(--border);
+ border-top-color: var(--success);
+ border-radius: 50%;
+ margin: 0 auto 12px;
+ animation: spin 1s linear infinite;
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+/* Tabs */
+.tabs {
+ display: flex;
+ gap: 4px;
+ margin-bottom: 16px;
+ overflow-x: auto;
+ -webkit-overflow-scrolling: touch;
+ background: var(--bg-color);
+ padding: 4px;
+ border-radius: 10px;
+ border: 1px solid var(--border);
+}
+
+.tab {
+ flex: 1;
+ padding: 12px 20px;
+ background: transparent;
+ border: none;
+ border-radius: 8px;
+ color: var(--text-muted);
+ cursor: pointer;
+ font-size: 0.95rem;
+ font-weight: 500;
+ transition: background 0.2s, color 0.2s;
+ white-space: nowrap;
+ min-height: 44px;
+ touch-action: manipulation;
+}
+
+.tab:hover {
+ background: var(--accent);
+}
+
+.tab.active {
+ background: var(--card-bg);
+ color: var(--text);
+ box-shadow: 0 2px 4px var(--shadow);
+}
+
+[data-theme="light"] .tab.active {
+ background: var(--primary);
+ color: #fff;
+}
+
+.tab-content {
+ display: none;
+}
+
+.tab-content.active {
+ display: block;
+}
+
+/* Sub-Tabs */
+.sub-tabs {
+ display: flex;
+ gap: 4px;
+ margin-bottom: 16px;
+ background: var(--bg-color);
+ padding: 4px;
+ border-radius: 10px;
+ border: 1px solid var(--border);
+}
+
+.sub-tab {
+ flex: 1;
+ padding: 10px 16px;
+ background: transparent;
+ border: none;
+ border-radius: 8px;
+ color: var(--text-muted);
+ cursor: pointer;
+ font-size: 0.9rem;
+ transition: background 0.2s, color 0.2s;
+ white-space: nowrap;
+ min-height: 40px;
+ touch-action: manipulation;
+}
+
+.sub-tab:hover {
+ background: var(--accent);
+}
+
+.sub-tab.active {
+ background: var(--card-bg);
+ color: var(--text);
+ box-shadow: 0 2px 4px var(--shadow);
+}
+
+[data-theme="light"] .sub-tab.active {
+ background: var(--primary);
+ color: #fff;
+}
+
+.sub-tab-content {
+ display: none;
+}
+
+.sub-tab-content.active {
+ display: block;
+}
+
+/* Connection Info */
+#connection-info p {
+ margin: 8px 0;
+ font-size: 0.95rem;
+}
+
+/* Control Sections */
+.control-section {
+ padding: 16px 0;
+ border-bottom: 1px solid var(--border);
+}
+
+.control-section:first-child {
+ padding-top: 0;
+}
+
+.control-section:last-child {
+ border-bottom: none;
+ padding-bottom: 0;
+}
+
+.control-section h3 {
+ font-size: 0.85rem;
+ font-weight: 600;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ margin-bottom: 12px;
+}
+
+/* Control Panel - Light Toggle */
+.control-group {
+ margin-bottom: 0;
+}
+
+.toggle-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 8px 0;
+}
+
+.toggle-label {
+ font-size: 1.1rem;
+ font-weight: 600;
+ color: var(--text);
+}
+
+.toggle-switch {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 12px 20px;
+ border-radius: 30px;
+ border: 2px solid var(--border);
+ background: var(--bg-color);
+ cursor: pointer;
+ transition: all 0.3s ease;
+ min-width: 120px;
+ justify-content: center;
+}
+
+.toggle-switch:hover {
+ border-color: var(--success);
+}
+
+.toggle-switch.active {
+ background: var(--success);
+ border-color: var(--success);
+}
+
+.toggle-switch.active .toggle-state {
+ color: #fff;
+}
+
+.toggle-switch.active .toggle-icon {
+ filter: brightness(1.2);
+}
+
+.toggle-state {
+ font-weight: 700;
+ font-size: 0.95rem;
+ color: var(--text-muted);
+ transition: color 0.3s;
+}
+
+.toggle-icon {
+ font-size: 1.4rem;
+ transition: filter 0.3s;
+}
+
+/* Mode Selector */
+.mode-selector {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 10px;
+ margin-bottom: 16px;
+}
+
+.mode-btn {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 8px;
+ padding: 16px 12px;
+ border-radius: 12px;
+ border: 2px solid var(--border);
+ background: var(--bg-color);
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.mode-btn:hover {
+ border-color: var(--success);
+ transform: translateY(-2px);
+}
+
+.mode-btn.active {
+ background: var(--success);
+ border-color: var(--success);
+}
+
+.mode-btn.active .mode-name {
+ color: #fff;
+}
+
+.mode-icon {
+ font-size: 1.8rem;
+}
+
+.mode-name {
+ font-size: 0.9rem;
+ font-weight: 600;
+ color: var(--text);
+ transition: color 0.2s;
+}
+
+/* Simulation Options */
+.simulation-options {
+ max-height: 0;
+ overflow: hidden;
+ opacity: 0;
+ transition: max-height 0.3s ease, opacity 0.3s ease, margin 0.3s ease;
+ margin-top: 0;
+}
+
+.simulation-options.visible {
+ max-height: 100px;
+ opacity: 1;
+ margin-top: 16px;
+}
+
+/* Status Display */
+.status-display {
+ display: grid;
+ gap: 12px;
+}
+
+.status-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 10px 0;
+ border-bottom: 1px solid var(--border);
+}
+
+.status-item:last-child {
+ border-bottom: none;
+}
+
+.status-label {
+ color: var(--text-muted);
+ font-size: 0.9rem;
+}
+
+.status-value {
+ font-weight: 600;
+ color: var(--text);
+}
+
+.current-color-preview {
+ width: 60px;
+ height: 30px;
+ border-radius: 6px;
+ border: 2px solid var(--border);
+ background: rgb(255, 240, 220);
+}
+
+/* Card Description */
+.card-description {
+ color: var(--text-muted);
+ font-size: 0.9rem;
+ margin-top: -10px;
+ margin-bottom: 16px;
+}
+
+/* Device List */
+.device-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ margin-top: 16px;
+}
+
+.device-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 12px;
+ background: var(--bg-color);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ gap: 12px;
+}
+
+.device-item.unpaired {
+ border-left: 3px solid var(--success);
+}
+
+.device-item.paired {
+ border-left: 3px solid var(--accent);
+}
+
+.device-info {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ flex: 1;
+ min-width: 0;
+}
+
+.device-icon {
+ font-size: 1.5rem;
+ flex-shrink: 0;
+}
+
+.device-details {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+ flex: 1;
+}
+
+.device-name {
+ font-weight: 600;
+ color: var(--text);
+ font-size: 0.95rem;
+}
+
+.device-name-input {
+ font-weight: 600;
+ color: var(--text);
+ font-size: 0.95rem;
+ background: var(--card-bg);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 6px 10px;
+ width: 100%;
+ min-width: 0;
+}
+
+.device-name-input:focus {
+ outline: none;
+ border-color: var(--success);
+}
+
+.device-id {
+ font-size: 0.75rem;
+ color: var(--text-muted);
+ font-family: monospace;
+}
+
+/* Small Button */
+.btn-small {
+ padding: 8px 12px;
+ font-size: 0.85rem;
+ min-height: 36px;
+}
+
+.btn-danger:hover {
+ background: var(--error);
+ color: #fff;
+}
+
+/* Empty State */
+.empty-state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 30px 20px;
+ text-align: center;
+ color: var(--text-muted);
+}
+
+.empty-icon {
+ font-size: 2.5rem;
+ margin-bottom: 12px;
+ opacity: 0.6;
+}
+
+.empty-state p {
+ margin: 0;
+ font-size: 0.95rem;
+}
+
+.empty-hint {
+ font-size: 0.85rem !important;
+ margin-top: 8px !important;
+ opacity: 0.7;
+}
+
+/* Mobile Responsive */
+@media (max-width: 600px) {
+ body {
+ padding: 8px;
+ }
+
+ .header {
+ flex-direction: column;
+ align-items: stretch;
+ text-align: center;
+ }
+
+ .header h1 {
+ order: 1;
+ }
+
+ .theme-toggle {
+ order: 2;
+ justify-content: center;
+ }
+
+ .card {
+ padding: 14px;
+ border-radius: 10px;
+ }
+
+ h2 {
+ font-size: 1rem;
+ }
+
+ .tabs {
+ justify-content: stretch;
+ }
+
+ .tab {
+ flex: 1;
+ text-align: center;
+ padding: 12px 10px;
+ font-size: 0.9rem;
+ }
+
+ /* Sub-tabs mobile */
+ .sub-tabs {
+ padding: 3px;
+ }
+
+ .sub-tab {
+ padding: 8px 12px;
+ font-size: 0.85rem;
+ min-height: 36px;
+ }
+
+ /* Device list mobile */
+ .device-item {
+ flex-wrap: wrap;
+ gap: 10px;
+ }
+
+ .device-info {
+ width: 100%;
+ }
+
+ .device-item .btn {
+ width: 100%;
+ }
+
+ .device-name-input {
+ font-size: 16px;
+ /* Prevents zoom on iOS */
+ }
+
+ /* Mode selector mobile */
+ .mode-selector {
+ grid-template-columns: repeat(3, 1fr);
+ gap: 8px;
+ }
+
+ .mode-btn {
+ padding: 12px 8px;
+ }
+
+ .mode-icon {
+ font-size: 1.5rem;
+ }
+
+ .mode-name {
+ font-size: 0.8rem;
+ }
+
+ .toggle-switch {
+ min-width: 100px;
+ padding: 10px 16px;
+ }
+
+ .btn-group {
+ flex-direction: column;
+ }
+
+ .btn-group .btn {
+ width: 100%;
+ }
+
+ .schema-controls {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .schema-controls .form-group {
+ min-width: 100%;
+ }
+
+ .schema-controls .btn {
+ width: 100%;
+ }
+
+ /* Simplified grid for mobile */
+ .time-row {
+ grid-template-columns: 50px 36px repeat(3, 1fr);
+ gap: 4px;
+ padding: 6px;
+ }
+
+ .value-header {
+ grid-template-columns: 50px 36px repeat(3, 1fr);
+ gap: 4px;
+ font-size: 0.65rem;
+ }
+
+ /* Hide V1, V2, V3 on mobile for simplicity */
+ .time-row input:nth-child(n+6),
+ .value-header span:nth-child(n+6) {
+ display: none;
+ }
+
+ .value-input {
+ padding: 6px 2px;
+ font-size: 13px;
+ }
+
+ .time-label {
+ font-size: 0.8rem;
+ }
+
+ .color-preview {
+ height: 28px;
+ }
+}
+
+/* Extra small screens */
+@media (max-width: 380px) {
+ .header h1 {
+ font-size: 1.2rem;
+ }
+
+ .tab {
+ padding: 10px 8px;
+ font-size: 0.85rem;
+ }
+}
+
+/* Landscape mobile */
+@media (max-height: 500px) and (orientation: landscape) {
+ .time-grid {
+ max-height: 50vh;
+ }
+
+ .modal {
+ max-height: 95vh;
+ padding: 16px;
+ }
+}
+
+/* Safe area for notched phones */
+@supports (padding: max(0px)) {
+ body {
+ padding-left: max(12px, env(safe-area-inset-left));
+ padding-right: max(12px, env(safe-area-inset-right));
+ padding-bottom: max(12px, env(safe-area-inset-bottom));
+ }
+}
+
+/* Scene Styles */
+.scenes-config-list {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ margin-top: 15px;
+}
+
+.scene-config-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ background: var(--input-bg);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 12px;
+ transition: border-color 0.2s;
+}
+
+.scene-config-item:hover {
+ border-color: var(--success);
+}
+
+.scene-info {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ flex: 1;
+ min-width: 0;
+}
+
+.scene-icon {
+ font-size: 1.5rem;
+ flex-shrink: 0;
+}
+
+.scene-details {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+}
+
+.scene-name {
+ font-weight: 500;
+ color: var(--text);
+}
+
+.scene-actions-text {
+ font-size: 0.8rem;
+ color: var(--text-muted);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.scene-buttons {
+ display: flex;
+ gap: 6px;
+ flex-shrink: 0;
+}
+
+/* Scenes on control page */
+.scenes-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
+ gap: 12px;
+}
+
+.scene-btn {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ padding: 16px 12px;
+ background: var(--input-bg);
+ border: 2px solid var(--border);
+ border-radius: 12px;
+ cursor: pointer;
+ transition: all 0.2s;
+ min-height: 80px;
+}
+
+.scene-btn:hover {
+ border-color: var(--success);
+ background: var(--accent);
+}
+
+.scene-btn:active {
+ transform: scale(0.95);
+}
+
+.scene-btn-icon {
+ font-size: 1.8rem;
+}
+
+.scene-btn-name {
+ font-size: 0.85rem;
+ color: var(--text);
+ text-align: center;
+}
+
+/* Devices on control page */
+.devices-control-grid {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.device-control-item {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 12px;
+ background: var(--input-bg);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+}
+
+.device-control-icon {
+ font-size: 1.3rem;
+ flex-shrink: 0;
+}
+
+.device-control-name {
+ flex: 1;
+ color: var(--text);
+}
+
+.toggle-switch.small {
+ min-width: auto;
+ padding: 8px 12px;
+}
+
+/* Icon selector for scene modal */
+.icon-selector {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.icon-btn {
+ width: 44px;
+ height: 44px;
+ font-size: 1.3rem;
+ background: var(--input-bg);
+ border: 2px solid var(--border);
+ border-radius: 8px;
+ cursor: pointer;
+ transition: all 0.2s;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.icon-btn:hover {
+ border-color: var(--text-muted);
+}
+
+.icon-btn.active {
+ border-color: var(--success);
+ background: var(--accent);
+}
+
+/* Scene actions editor */
+.scene-actions-editor {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.scene-action-row {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+
+.scene-action-row label {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 140px;
+ cursor: pointer;
+}
+
+.scene-action-row select {
+ flex: 1;
+ min-width: 120px;
+ padding: 8px 12px;
+ background: var(--input-bg);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ color: var(--text);
+ font-size: 0.9rem;
+}
+
+.scene-action-row input[type="checkbox"] {
+ width: 18px;
+ height: 18px;
+ accent-color: var(--success);
+}
+
+/* Scene device list */
+.scene-devices-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ max-height: 200px;
+ overflow-y: auto;
+ padding: 4px;
+}
+
+.scene-device-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 10px 12px;
+ background: var(--input-bg);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ transition: border-color 0.2s;
+}
+
+.scene-device-item:hover {
+ border-color: var(--success);
+}
+
+.scene-device-checkbox {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ cursor: pointer;
+ flex: 1;
+ min-width: 0;
+}
+
+.scene-device-checkbox input[type="checkbox"] {
+ width: 18px;
+ height: 18px;
+ accent-color: var(--success);
+ flex-shrink: 0;
+}
+
+.scene-device-checkbox .device-icon {
+ font-size: 1.2rem;
+ flex-shrink: 0;
+}
+
+.scene-device-checkbox .device-name {
+ color: var(--text);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.scene-device-state {
+ padding: 6px 10px;
+ background: var(--input-bg);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ color: var(--text);
+ font-size: 0.85rem;
+ flex-shrink: 0;
+}
+
+.scene-device-state:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.empty-state.small {
+ padding: 16px;
+ font-size: 0.9rem;
+}
+
+.empty-state.small .empty-icon {
+ font-size: 1.5rem;
+}
+
+/* Responsive for scenes */
+@media (max-width: 600px) {
+ .scenes-grid {
+ grid-template-columns: repeat(2, 1fr);
+ }
+
+ .scene-config-item {
+ flex-direction: column;
+ gap: 12px;
+ align-items: flex-start;
+ }
+
+ .scene-buttons {
+ width: 100%;
+ justify-content: flex-end;
+ }
+
+ .scene-action-row {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+
+ .scene-action-row select {
+ width: 100%;
+ }
+}
\ No newline at end of file
diff --git a/firmware/storage/www/css/shared.css b/firmware/storage/www/css/shared.css
new file mode 100644
index 0000000..a8d4db9
--- /dev/null
+++ b/firmware/storage/www/css/shared.css
@@ -0,0 +1,300 @@
+/* Shared CSS - Base styles for all pages */
+
+/* CSS Variables - Dark Mode (default) */
+:root {
+ --bg-color: #1a1a2e;
+ --card-bg: #16213e;
+ --accent: #0f3460;
+ --text: #eaeaea;
+ --text-muted: #a0a0a0;
+ --success: #00d26a;
+ --error: #ff6b6b;
+ --border: #2a2a4a;
+ --input-bg: #1a1a2e;
+ --shadow: rgba(0, 0, 0, 0.3);
+ --primary: #c41e3a;
+}
+
+/* CSS Variables - Light Mode */
+[data-theme="light"] {
+ --bg-color: #faf8f5;
+ --card-bg: #ffffff;
+ --accent: #fef2f2;
+ --text: #1a1a2e;
+ --text-muted: #6b7280;
+ --success: #c41e3a;
+ --error: #dc2626;
+ --border: #e5d9d0;
+ --input-bg: #ffffff;
+ --shadow: rgba(196, 30, 58, 0.1);
+ --primary: #c41e3a;
+}
+
+/* Reset */
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+ background: var(--bg-color);
+ color: var(--text);
+ min-height: 100vh;
+ transition: background 0.3s, color 0.3s;
+}
+
+/* Typography */
+h1 {
+ font-size: 1.5rem;
+ color: var(--text);
+}
+
+h2 {
+ font-size: 1.1rem;
+ margin-bottom: 15px;
+ color: var(--text);
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+h2::before {
+ content: '';
+ display: inline-block;
+ width: 4px;
+ height: 20px;
+ background: var(--success);
+ border-radius: 2px;
+}
+
+/* Card */
+.card {
+ background: var(--card-bg);
+ border-radius: 12px;
+ padding: 16px;
+ margin-bottom: 16px;
+ border: 1px solid var(--border);
+ box-shadow: 0 2px 8px var(--shadow);
+}
+
+/* Form Elements */
+.form-group {
+ margin-bottom: 12px;
+}
+
+label {
+ display: block;
+ margin-bottom: 6px;
+ color: var(--text-muted);
+ font-size: 0.85rem;
+}
+
+input[type="text"],
+input[type="password"],
+select {
+ width: 100%;
+ padding: 12px 14px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: var(--input-bg);
+ color: var(--text);
+ font-size: 16px;
+ transition: border-color 0.2s;
+}
+
+input:focus,
+select:focus {
+ outline: none;
+ border-color: var(--success);
+}
+
+/* Buttons */
+.btn {
+ padding: 12px 20px;
+ border: none;
+ border-radius: 8px;
+ font-size: 0.95rem;
+ cursor: pointer;
+ transition: transform 0.1s, opacity 0.2s;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ min-height: 44px;
+ touch-action: manipulation;
+}
+
+.btn:hover {
+ opacity: 0.9;
+}
+
+.btn:active {
+ transform: scale(0.98);
+}
+
+.btn-primary {
+ background: var(--success);
+ color: #fff;
+}
+
+[data-theme="light"] .btn-primary {
+ background: var(--primary);
+ color: #fff;
+}
+
+[data-theme="light"] .btn-primary:hover {
+ background: #a31830;
+}
+
+.btn-secondary {
+ background: var(--accent);
+ color: var(--text);
+}
+
+.btn-group {
+ display: flex;
+ gap: 8px;
+ margin-top: 16px;
+ flex-wrap: wrap;
+}
+
+.btn-group .btn {
+ flex: 1;
+ min-width: 120px;
+}
+
+/* Status Messages */
+.status {
+ padding: 10px 14px;
+ border-radius: 8px;
+ margin-top: 12px;
+ display: none;
+ font-size: 0.9rem;
+}
+
+.status.success {
+ display: block;
+ background: rgba(0, 210, 106, 0.15);
+ border: 1px solid var(--success);
+ color: var(--success);
+}
+
+.status.error {
+ display: block;
+ background: rgba(255, 107, 107, 0.15);
+ border: 1px solid var(--error);
+ color: var(--error);
+}
+
+.status.info {
+ display: block;
+ background: rgba(15, 52, 96, 0.5);
+ border: 1px solid var(--accent);
+ color: var(--text);
+}
+
+/* Theme Toggle */
+.theme-toggle {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ background: var(--card-bg);
+ padding: 8px 12px;
+ border-radius: 20px;
+ border: 1px solid var(--border);
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.theme-toggle:hover {
+ border-color: var(--success);
+}
+
+.theme-toggle-icon {
+ font-size: 1.2rem;
+ transition: transform 0.3s;
+}
+
+.theme-toggle-label {
+ font-size: 0.85rem;
+ color: var(--text-muted);
+}
+
+/* Language Toggle */
+.lang-toggle {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ background: var(--card-bg);
+ padding: 8px 12px;
+ border-radius: 20px;
+ border: 1px solid var(--border);
+ cursor: pointer;
+ transition: all 0.2s;
+ font-size: 0.85rem;
+ color: var(--text-muted);
+}
+
+.lang-toggle:hover {
+ border-color: var(--success);
+}
+
+.lang-flag {
+ font-size: 1.1rem;
+}
+
+/* Header Controls */
+.header-controls {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.captive-header {
+ position: absolute;
+ top: 12px;
+ right: 12px;
+}
+
+[data-theme="light"] .header h1 {
+ color: var(--primary);
+}
+
+/* Loading Spinner */
+.loading {
+ display: none;
+ text-align: center;
+ padding: 30px;
+ color: var(--text-muted);
+}
+
+.loading.active {
+ display: block;
+}
+
+.spinner {
+ width: 36px;
+ height: 36px;
+ border: 3px solid var(--border);
+ border-top-color: var(--success);
+ border-radius: 50%;
+ margin: 0 auto 12px;
+ animation: spin 1s linear infinite;
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+/* Safe area for notched phones */
+@supports (padding: max(0px)) {
+ body {
+ padding-left: max(12px, env(safe-area-inset-left));
+ padding-right: max(12px, env(safe-area-inset-right));
+ padding-bottom: max(12px, env(safe-area-inset-bottom));
+ }
+}
\ No newline at end of file
diff --git a/firmware/storage/www/favicon.svg b/firmware/storage/www/favicon.svg
new file mode 100644
index 0000000..104f44f
--- /dev/null
+++ b/firmware/storage/www/favicon.svg
@@ -0,0 +1 @@
+🚂
\ No newline at end of file
diff --git a/firmware/storage/www/index.html b/firmware/storage/www/index.html
new file mode 100644
index 0000000..53f2a96
--- /dev/null
+++ b/firmware/storage/www/index.html
@@ -0,0 +1,429 @@
+
+
+
+
+
+
+
+
+
+
+ System Control
+
+
+
+
+
+
+
+
+
+ 🎛️ Bedienung
+ ⚙️ Konfiguration
+
+
+
+
+
+
Lichtsteuerung
+
+
+
Ein/Aus
+
+
+ Licht
+
+ AUS
+ 💡
+
+
+
+
+
+
+
+
Betriebsmodus
+
+
+ ☀️
+ Tag
+
+
+ 🌙
+ Nacht
+
+
+ 🔄
+ Simulation
+
+
+
+
+
+ Aktives Schema
+
+ Schema 1 (Standard)
+ Schema 2 (Warm)
+ Schema 3 (Natur)
+
+
+
+
+
+
+
+
Aktueller Status
+
+
+ Modus
+ Simulation
+
+
+ Schema
+ Schema 1
+
+
+
+
+
+
+
+
+
Szenen
+
+
+
+
🎬
+
Keine Szenen definiert
+
Erstelle Szenen unter Konfiguration
+
+
+
+
+
+
+
+
Externe Geräte
+
+
+
+
🔗
+
Keine Geräte hinzugefügt
+
Füge Geräte unter Konfiguration
+ hinzu
+
+
+
+
+
+
+
+
+ 📶 WLAN
+ 💡 Schema
+ 🔗 Geräte
+ 🎬 Szenen
+
+
+
+
+
+
WLAN Konfiguration
+
+
+ WLAN Name (SSID)
+
+
+
+
+ WLAN Passwort
+
+
+
+
+ Verfügbare Netzwerke
+
+ Nach Netzwerken suchen...
+
+
+
+
+ 🔍
+ Suchen
+ 💾 Speichern
+
+
+
+
+
+
+
Verbindungsstatus
+
+
Status: Wird geladen...
+
IP-Adresse: -
+
Signal:
+
+
+
+
+
+
+
+
Licht-Schema Editor
+
+
+
+ Schema-Datei
+
+ Schema 1 (Standard)
+ Schema 2 (Warm)
+ Schema 3 (Natur)
+
+
+
🔄 Laden
+
💾
+ Speichern
+
+
+
+
+
+
+
Schema wird geladen...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Szenen verwalten
+
Erstelle und bearbeite Szenen für
+ schnellen Zugriff
+
+
+ ➕ Neue
+ Szene
+
+
+
+
+
+
🎬
+
Keine Szenen erstellt
+
Klicke auf "Neue Szene" um eine
+ Szene zu erstellen
+
+
+
+
+
+
+
+
+
+
+
Neue Geräte
+
Unprovisionierte Matter-Geräte in der Nähe
+
+
+
+ 🔍 Geräte
+ suchen
+
+
+
+
+
Suche nach Geräten...
+
+
+
+
+
+
📡
+
Keine neuen Geräte gefunden
+
Drücke "Geräte suchen" um nach
+ Matter-Geräten zu suchen
+
+
+
+
+
+
+
+
Zugeordnete Geräte
+
Bereits hinzugefügte externe Geräte
+
+
+
+
+
📦
+
Keine Geräte hinzugefügt
+
+
+
+
+
+
+
+
+
+
+
Farbe wählen -
+
+
+
+ R
+
+ 255
+
+
+ G
+
+ 255
+
+
+ B
+
+ 255
+
+
+
+
+
+
+ Abbrechen
+ Übernehmen
+
+
+
+
+
+
+
+
Neue Szene erstellen
+
+
+ Name
+
+
+
+
+
+
+
+
+
+
+ Abbrechen
+ 💾 Speichern
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/firmware/storage/www/js/app.js b/firmware/storage/www/js/app.js
new file mode 100644
index 0000000..ae12198
--- /dev/null
+++ b/firmware/storage/www/js/app.js
@@ -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();
+ }
+});
diff --git a/firmware/storage/www/js/devices.js b/firmware/storage/www/js/devices.js
new file mode 100644
index 0000000..84b6135
--- /dev/null
+++ b/firmware/storage/www/js/devices.js
@@ -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 = `
+ ${icon}
+ ${device.name}
+ ${device.type === 'light' ? `💡 ` : ''}
+ `;
+ 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 = `
+
+
${icon}
+
+ ${device.name || unknownDevice}
+ ${device.id}
+
+
+
+ ➕ ${t('btn.add')}
+
+ `;
+
+ 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 = `
+
+
${icon}
+
+
+ ${device.id}
+
+
+
+ 🗑️
+
+ `;
+
+ 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');
+ }
+}
diff --git a/firmware/storage/www/js/i18n.js b/firmware/storage/www/js/i18n.js
new file mode 100644
index 0000000..d163f28
--- /dev/null
+++ b/firmware/storage/www/js/i18n.js
@@ -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();
+}
diff --git a/firmware/storage/www/js/light.js b/firmware/storage/www/js/light.js
new file mode 100644
index 0000000..3cbcf0b
--- /dev/null
+++ b/firmware/storage/www/js/light.js
@@ -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;
+ }
+}
diff --git a/firmware/storage/www/js/scenes.js b/firmware/storage/www/js/scenes.js
new file mode 100644
index 0000000..3589c38
--- /dev/null
+++ b/firmware/storage/www/js/scenes.js
@@ -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 = `
+
+
${scene.icon}
+
+ ${scene.name}
+ ${actionsText.join(', ')}
+
+
+
+ ✏️
+ 🗑️
+
+ `;
+
+ 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 = `
+ ${scene.icon}
+ ${scene.name}
+ `;
+ 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 = `
+
+
+ ${icon}
+ ${device.name}
+
+ ${device.type === 'light' ? `
+
+ ${t('scene.light.on')}
+ ${t('scene.light.off')}
+
+ ` : ''}
+ `;
+
+ 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');
+ }
+}
diff --git a/firmware/storage/www/js/schema.js b/firmware/storage/www/js/schema.js
new file mode 100644
index 0000000..9f58dd7
--- /dev/null
+++ b/firmware/storage/www/js/schema.js
@@ -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 = `
+ ${time}
+
+
+
+
+
+
+
+ `;
+
+ 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');
+ }
+}
diff --git a/firmware/storage/www/js/ui.js b/firmware/storage/www/js/ui.js
new file mode 100644
index 0000000..e2ffecf
--- /dev/null
+++ b/firmware/storage/www/js/ui.js
@@ -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)
diff --git a/firmware/storage/www/js/websocket.js b/firmware/storage/www/js/websocket.js
new file mode 100644
index 0000000..56c8e39
--- /dev/null
+++ b/firmware/storage/www/js/websocket.js
@@ -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) };
+}
diff --git a/firmware/storage/www/js/wifi-shared.js b/firmware/storage/www/js/wifi-shared.js
new file mode 100644
index 0000000..6de63cb
--- /dev/null
+++ b/firmware/storage/www/js/wifi-shared.js
@@ -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 = `${t('wifi.searching')} `;
+ }
+
+ 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 = `${t('devices.unpaired.empty')}
`;
+ } 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 = `
+
+ ${signalIcon}
+ ${escapeHtml(network.ssid)}
+
+ ${network.rssi} dBm
+ `;
+ networkList.appendChild(item);
+ });
+ }
+ networkList.style.display = 'block';
+ }
+
+ // Render for main interface (select dropdown)
+ if (select) {
+ select.innerHTML = `${t('wifi.scan.hint')} `;
+ 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 = `
+
+ ${signalIcon}
+ ${escapeHtml(network.ssid)}
+
+ ${network.rssi} dBm
+ `;
+ networkList.appendChild(item);
+ });
+ networkList.style.display = 'block';
+ }
+
+ if (select) {
+ select.innerHTML = `${t('wifi.scan.hint')} `;
+ 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');
+ }
+}