From 52f6c2acab4633d823a0b8b5e81905746eada873 Mon Sep 17 00:00:00 2001 From: Peter Siegmund Date: Thu, 1 Jan 2026 16:39:27 +0100 Subject: [PATCH 01/33] vibe coded website (plus captive portal) needs missing ESP32 implementation Signed-off-by: Peter Siegmund --- firmware/README-API.md | 722 ++++++++++++++ firmware/README-captive.md | 209 +++++ firmware/storage/www/captive.html | 138 +++ firmware/storage/www/css/captive.css | 201 ++++ firmware/storage/www/css/index.css | 1201 ++++++++++++++++++++++++ firmware/storage/www/css/shared.css | 300 ++++++ firmware/storage/www/favicon.svg | 1 + firmware/storage/www/index.html | 429 +++++++++ firmware/storage/www/js/app.js | 60 ++ firmware/storage/www/js/devices.js | 231 +++++ firmware/storage/www/js/i18n.js | 445 +++++++++ firmware/storage/www/js/light.js | 102 ++ firmware/storage/www/js/scenes.js | 330 +++++++ firmware/storage/www/js/schema.js | 208 ++++ firmware/storage/www/js/ui.js | 53 ++ firmware/storage/www/js/websocket.js | 138 +++ firmware/storage/www/js/wifi-shared.js | 249 +++++ 17 files changed, 5017 insertions(+) create mode 100644 firmware/README-API.md create mode 100644 firmware/README-captive.md create mode 100644 firmware/storage/www/captive.html create mode 100644 firmware/storage/www/css/captive.css create mode 100644 firmware/storage/www/css/index.css create mode 100644 firmware/storage/www/css/shared.css create mode 100644 firmware/storage/www/favicon.svg create mode 100644 firmware/storage/www/index.html create mode 100644 firmware/storage/www/js/app.js create mode 100644 firmware/storage/www/js/devices.js create mode 100644 firmware/storage/www/js/i18n.js create mode 100644 firmware/storage/www/js/light.js create mode 100644 firmware/storage/www/js/scenes.js create mode 100644 firmware/storage/www/js/schema.js create mode 100644 firmware/storage/www/js/ui.js create mode 100644 firmware/storage/www/js/websocket.js create mode 100644 firmware/storage/www/js/wifi-shared.js 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` | `SuccessSuccess` | +| **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 + + + + + +
+ + +
+ +
+
+

πŸš‚ System Control

+

WLAN-Einrichtung

+
+ +
+
+ + +
+
+

Suche nach Netzwerken...

+
+ + +
+ +
oder manuell eingeben
+ +
+ + +
+ +
+ +
+ + +
+
+ +
+ +
+ +
+ +
+ ℹ️ 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 + + + + + +
+
+

πŸš‚ System Control

+
+ + +
+
+ +
+ + +
+ + +
+
+

Lichtsteuerung

+ +
+

Ein/Aus

+
+
+ Licht + +
+
+
+
+ +
+

Betriebsmodus

+
+ + + +
+ +
+
+ + +
+
+
+
+ +
+

Aktueller Status

+
+
+ Modus + Simulation +
+
+ Schema + Schema 1 +
+
+ Aktuelle Farbe +
+
+
+
+
+ + +
+

Szenen

+
+ +
+ 🎬 +

Keine Szenen definiert

+

Erstelle Szenen unter Konfiguration

+
+
+
+
+ + +
+

Externe GerΓ€te

+
+ +
+ πŸ”— +

Keine GerΓ€te hinzugefΓΌgt

+

FΓΌge GerΓ€te unter Konfiguration + hinzu

+
+
+
+
+ + +
+
+ + + + +
+ + +
+
+

WLAN Konfiguration

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ +
+

Verbindungsstatus

+
+

Status: Wird geladen...

+

IP-Adresse: -

+

Signal: -

+
+
+
+ + +
+
+

Licht-Schema Editor

+ +
+
+ + +
+ + +
+ +
+ +
+
+

Schema wird geladen...

+
+ +
+ Zeit + Farbe + R + G + B + V1 + V2 + V3 +
+ +
+ +
+
+
+ + +
+
+

Szenen verwalten

+

Erstelle und bearbeite Szenen fΓΌr + schnellen Zugriff

+ +
+ +
+ +
+ +
+ 🎬 +

Keine Szenen erstellt

+

Klicke auf "Neue Szene" um eine + Szene zu erstellen

+
+
+ +
+
+
+ + +
+
+

Neue GerΓ€te

+

Unprovisionierte Matter-GerΓ€te in der NΓ€he +

+ +
+ +
+ +
+
+

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

+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + \ 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} +
+
+ + `; + + 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 = ` + + ${device.type === 'light' ? ` + + ` : ''} + `; + + 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 = ``; + } + + 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 = ``; + 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 = ``; + 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'); + } +} From b0e93d613c0c2951d62c9a9c7f31d89d4018f485 Mon Sep 17 00:00:00 2001 From: Peter Siegmund Date: Thu, 1 Jan 2026 17:31:41 +0100 Subject: [PATCH 02/33] add segment settings Signed-off-by: Peter Siegmund --- firmware/README-API.md | 84 ++++++++++++++ firmware/storage/www/css/index.css | 132 ++++++++++++++++++++++ firmware/storage/www/index.html | 37 ++++++- firmware/storage/www/js/i18n.js | 40 ++++++- firmware/storage/www/js/wled.js | 172 +++++++++++++++++++++++++++++ 5 files changed, 461 insertions(+), 4 deletions(-) create mode 100644 firmware/storage/www/js/wled.js diff --git a/firmware/README-API.md b/firmware/README-API.md index 359ca4b..c77cdd1 100644 --- a/firmware/README-API.md +++ b/firmware/README-API.md @@ -7,6 +7,7 @@ This document describes all REST API endpoints and WebSocket messages required f - [REST API Endpoints](#rest-api-endpoints) - [WiFi](#wifi) - [Light Control](#light-control) + - [WLED Configuration](#wled-configuration) - [Schema](#schema) - [Devices](#devices) - [Scenes](#scenes) @@ -201,6 +202,89 @@ Returns current light status (alternative to WebSocket). --- +### WLED Configuration + +#### Get WLED Configuration + +Returns the current WLED configuration including host and all segments. + +- **URL:** `/api/wled/config` +- **Method:** `GET` +- **Response:** + +```json +{ + "host": "192.168.1.100", + "segments": [ + { + "name": "Main Light", + "start": 0, + "leds": 60 + }, + { + "name": "Accent Light", + "start": 60, + "leds": 30 + } + ] +} +``` + +| Field | Type | Description | +|--------------------|--------|------------------------------------------| +| host | string | WLED host address (IP or hostname) | +| segments | array | List of LED segments | +| segments[].name | string | Optional segment name | +| segments[].start | number | Start LED index (0-based) | +| segments[].leds | number | Number of LEDs in this segment | + +--- + +#### Save WLED Configuration + +Saves the WLED configuration. + +- **URL:** `/api/wled/config` +- **Method:** `POST` +- **Content-Type:** `application/json` +- **Request Body:** + +```json +{ + "host": "192.168.1.100", + "segments": [ + { + "name": "Main Light", + "start": 0, + "leds": 60 + }, + { + "name": "Accent Light", + "start": 60, + "leds": 30 + } + ] +} +``` + +| Field | Type | Required | Description | +|--------------------|--------|----------|------------------------------------------| +| host | string | Yes | WLED host address (IP or hostname) | +| segments | array | Yes | List of LED segments (can be empty) | +| segments[].name | string | No | Optional segment name | +| segments[].start | number | Yes | Start LED index (0-based) | +| segments[].leds | number | Yes | Number of LEDs in this segment | + +- **Response:** `200 OK` on success, `400 Bad Request` on validation error + +**Notes:** +- The firmware uses this configuration to communicate with a WLED controller +- Segments are mapped to the WLED JSON API segment control +- Changes are persisted to NVS (non-volatile storage) +- The host can be an IP address (e.g., `192.168.1.100`) or hostname (e.g., `wled.local`) + +--- + ### Schema #### Load Schema diff --git a/firmware/storage/www/css/index.css b/firmware/storage/www/css/index.css index 7b38b94..2cc17cd 100644 --- a/firmware/storage/www/css/index.css +++ b/firmware/storage/www/css/index.css @@ -1198,4 +1198,136 @@ body { .scene-action-row select { width: 100%; } +} + +/* WLED Configuration */ +.segment-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.segment-header h3 { + margin: 0; + font-size: 1rem; + color: var(--text); +} + +.btn-small { + padding: 6px 12px; + font-size: 0.85rem; +} + +.wled-segments-list { + display: flex; + flex-direction: column; + gap: 12px; + margin-bottom: 20px; +} + +.wled-segment-item { + display: grid; + grid-template-columns: 1fr auto auto auto; + gap: 12px; + align-items: flex-end; + padding: 16px; + background: var(--bg-color); + border-radius: 10px; + border: 1px solid var(--border); +} + +.segment-name-field { + display: flex; + flex-direction: column; + gap: 4px; +} + +.segment-number { + font-weight: 600; + font-size: 0.75rem; + color: var(--primary); +} + +.segment-name-input { + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--card-bg); + color: var(--text); + font-size: 0.9rem; + height: 38px; + box-sizing: border-box; +} + +.segment-field { + display: flex; + flex-direction: column; + gap: 4px; +} + +.segment-field label { + font-size: 0.75rem; + color: var(--text-secondary); + white-space: nowrap; +} + +.segment-field input { + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--card-bg); + color: var(--text); + font-size: 0.9rem; + text-align: center; + width: 70px; + height: 38px; + box-sizing: border-box; +} + +.segment-remove-btn { + padding: 8px; + background: transparent; + border: 1px solid var(--border); + border-radius: 6px; + cursor: pointer; + color: var(--text-secondary); + transition: all 0.2s; + height: 38px; + width: 38px; + box-sizing: border-box; + align-self: flex-end; +} + +.segment-remove-btn:hover { + background: #ff4444; + border-color: #ff4444; + color: white; +} + +/* Responsive for WLED */ +@media (max-width: 600px) { + .segment-header { + flex-direction: column; + align-items: flex-start; + gap: 10px; + } + + .wled-segment-item { + grid-template-columns: 1fr 1fr; + grid-template-rows: auto auto auto; + } + + .segment-number { + grid-column: 1 / -1; + } + + .segment-name-input { + grid-column: 1 / -1; + } + + .segment-remove-btn { + grid-column: 1 / -1; + justify-self: end; + } } \ No newline at end of file diff --git a/firmware/storage/www/index.html b/firmware/storage/www/index.html index 53f2a96..0383a43 100644 --- a/firmware/storage/www/index.html +++ b/firmware/storage/www/index.html @@ -135,7 +135,8 @@
- +
@@ -185,8 +186,39 @@
- +
+ +
+

WLED Konfiguration

+

Konfiguriere die WLED-Segmente und LEDs pro + Segment

+ +
+

Segmente

+ +
+ +
+ +
+ πŸ’‘ +

Keine Segmente konfiguriert

+

Klicke auf "Segment hinzufΓΌgen" + um ein Segment zu erstellen

+
+
+ +
+ +
+ +
+
+ +

Licht-Schema Editor

@@ -423,6 +455,7 @@ + diff --git a/firmware/storage/www/js/i18n.js b/firmware/storage/www/js/i18n.js index d163f28..0fdc3de 100644 --- a/firmware/storage/www/js/i18n.js +++ b/firmware/storage/www/js/i18n.js @@ -12,10 +12,28 @@ const translations = { // Sub Tabs 'subtab.wifi': 'πŸ“Ά WLAN', - 'subtab.schema': 'πŸ’‘ Schema', + 'subtab.light': 'πŸ’‘ Lichtsteuerung', 'subtab.devices': 'πŸ”— GerΓ€te', 'subtab.scenes': '🎬 Szenen', + // WLED Configuration + 'wled.config.title': 'WLED Konfiguration', + 'wled.config.desc': 'Konfiguriere die WLED-Segmente und LEDs pro Segment', + 'wled.host': 'WLED Host', + 'wled.host.placeholder': 'z.B. 192.168.1.100 oder wled.local', + 'wled.segments.title': 'Segmente', + 'wled.segments.empty': 'Keine Segmente konfiguriert', + 'wled.segments.empty.hint': 'Klicke auf "Segment hinzufΓΌgen" um ein Segment zu erstellen', + 'wled.segment.add': 'βž• Segment hinzufΓΌgen', + 'wled.segment.name': 'Segment {num}', + 'wled.segment.leds': 'Anzahl LEDs', + 'wled.segment.start': 'Start-LED', + 'wled.segment.remove': 'Entfernen', + 'wled.saved': 'WLED-Konfiguration gespeichert!', + 'wled.error.host': 'Bitte WLED Host eingeben', + 'wled.error.save': 'Fehler beim Speichern der WLED-Konfiguration', + 'wled.loaded': 'WLED-Konfiguration geladen', + // Light Control 'control.light.title': 'Lichtsteuerung', 'control.light.onoff': 'Ein/Aus', @@ -168,10 +186,28 @@ const translations = { // Sub Tabs 'subtab.wifi': 'πŸ“Ά WiFi', - 'subtab.schema': 'πŸ’‘ Schema', + 'subtab.light': 'πŸ’‘ Light Control', 'subtab.devices': 'πŸ”— Devices', 'subtab.scenes': '🎬 Scenes', + // WLED Configuration + 'wled.config.title': 'WLED Configuration', + 'wled.config.desc': 'Configure WLED segments and LEDs per segment', + 'wled.host': 'WLED Host', + 'wled.host.placeholder': 'e.g. 192.168.1.100 or wled.local', + 'wled.segments.title': 'Segments', + 'wled.segments.empty': 'No segments configured', + 'wled.segments.empty.hint': 'Click "Add Segment" to create a segment', + 'wled.segment.add': 'βž• Add Segment', + 'wled.segment.name': 'Segment {num}', + 'wled.segment.leds': 'Number of LEDs', + 'wled.segment.start': 'Start LED', + 'wled.segment.remove': 'Remove', + 'wled.saved': 'WLED configuration saved!', + 'wled.error.host': 'Please enter WLED host', + 'wled.error.save': 'Error saving WLED configuration', + 'wled.loaded': 'WLED configuration loaded', + // Light Control 'control.light.title': 'Light Control', 'control.light.onoff': 'On/Off', diff --git a/firmware/storage/www/js/wled.js b/firmware/storage/www/js/wled.js new file mode 100644 index 0000000..737c98f --- /dev/null +++ b/firmware/storage/www/js/wled.js @@ -0,0 +1,172 @@ +// WLED Configuration Module +// Manages WLED segments and LED configuration + +let wledConfig = { + segments: [] +}; + +/** + * Initialize WLED module + */ +function initWled() { + loadWledConfig(); +} + +/** + * Load WLED configuration from server + */ +async function loadWledConfig() { + try { + const response = await fetch('/api/wled/config'); + if (response.ok) { + wledConfig = await response.json(); + renderWledSegments(); + showStatus('wled-status', t('wled.loaded'), 'success'); + } + } catch (error) { + console.log('Using default WLED config'); + wledConfig = { segments: [] }; + renderWledSegments(); + } +} + +/** + * Render WLED segments list + */ +function renderWledSegments() { + const list = document.getElementById('wled-segments-list'); + const emptyState = document.getElementById('no-wled-segments'); + + if (!list) return; + + // Clear existing segments (keep empty state) + const existingItems = list.querySelectorAll('.wled-segment-item'); + existingItems.forEach(item => item.remove()); + + if (wledConfig.segments.length === 0) { + if (emptyState) emptyState.style.display = 'block'; + return; + } + + if (emptyState) emptyState.style.display = 'none'; + + wledConfig.segments.forEach((segment, index) => { + const item = createSegmentElement(segment, index); + list.insertBefore(item, emptyState); + }); +} + +/** + * Create segment DOM element + * @param {object} segment - Segment data + * @param {number} index - Segment index + * @returns {HTMLElement} Segment element + */ +function createSegmentElement(segment, index) { + const item = document.createElement('div'); + item.className = 'wled-segment-item'; + item.dataset.index = index; + + item.innerHTML = ` +
+ + +
+
+ + +
+
+ + +
+ + `; + + return item; +} + +/** + * Add a new WLED segment + */ +function addWledSegment() { + // Calculate next start position + let nextStart = 0; + if (wledConfig.segments.length > 0) { + const lastSegment = wledConfig.segments[wledConfig.segments.length - 1]; + nextStart = (lastSegment.start || 0) + (lastSegment.leds || 0); + } + + wledConfig.segments.push({ + name: '', + start: nextStart, + leds: 10 + }); + + renderWledSegments(); +} + +/** + * Update a segment property + * @param {number} index - Segment index + * @param {string} property - Property name + * @param {*} value - New value + */ +function updateSegment(index, property, value) { + if (index >= 0 && index < wledConfig.segments.length) { + wledConfig.segments[index][property] = value; + } +} + +/** + * Remove a WLED segment + * @param {number} index - Segment index to remove + */ +function removeWledSegment(index) { + if (index >= 0 && index < wledConfig.segments.length) { + wledConfig.segments.splice(index, 1); + renderWledSegments(); + } +} + +/** + * Save WLED configuration to server + */ +async function saveWledConfig() { + try { + const response = await fetch('/api/wled/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(wledConfig) + }); + + if (response.ok) { + showStatus('wled-status', t('wled.saved'), 'success'); + } else { + throw new Error('Save failed'); + } + } catch (error) { + console.error('Error saving WLED config:', error); + showStatus('wled-status', t('wled.error.save'), 'error'); + } +} + +/** + * Helper function to escape HTML + * @param {string} text - Text to escape + * @returns {string} Escaped text + */ +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +// Initialize when DOM is ready +document.addEventListener('DOMContentLoaded', initWled); From a66c48e713726bc10c850b4bc33e0b3d89811231 Mon Sep 17 00:00:00 2001 From: Peter Siegmund Date: Thu, 1 Jan 2026 17:34:44 +0100 Subject: [PATCH 03/33] rename WLED to LED Signed-off-by: Peter Siegmund --- firmware/README-API.md | 21 ++++++++------------- firmware/storage/www/css/index.css | 4 ++-- firmware/storage/www/index.html | 7 ++++--- firmware/storage/www/js/i18n.js | 30 ++++++++++++------------------ firmware/storage/www/js/wled.js | 6 +++--- 5 files changed, 29 insertions(+), 39 deletions(-) diff --git a/firmware/README-API.md b/firmware/README-API.md index c77cdd1..90b83ef 100644 --- a/firmware/README-API.md +++ b/firmware/README-API.md @@ -7,7 +7,7 @@ This document describes all REST API endpoints and WebSocket messages required f - [REST API Endpoints](#rest-api-endpoints) - [WiFi](#wifi) - [Light Control](#light-control) - - [WLED Configuration](#wled-configuration) + - [LED Configuration](#led-configuration) - [Schema](#schema) - [Devices](#devices) - [Scenes](#scenes) @@ -202,11 +202,11 @@ Returns current light status (alternative to WebSocket). --- -### WLED Configuration +### LED Configuration -#### Get WLED Configuration +#### Get LED Configuration -Returns the current WLED configuration including host and all segments. +Returns the current LED segment configuration. - **URL:** `/api/wled/config` - **Method:** `GET` @@ -214,7 +214,6 @@ Returns the current WLED configuration including host and all segments. ```json { - "host": "192.168.1.100", "segments": [ { "name": "Main Light", @@ -232,7 +231,6 @@ Returns the current WLED configuration including host and all segments. | Field | Type | Description | |--------------------|--------|------------------------------------------| -| host | string | WLED host address (IP or hostname) | | segments | array | List of LED segments | | segments[].name | string | Optional segment name | | segments[].start | number | Start LED index (0-based) | @@ -240,9 +238,9 @@ Returns the current WLED configuration including host and all segments. --- -#### Save WLED Configuration +#### Save LED Configuration -Saves the WLED configuration. +Saves the LED segment configuration. - **URL:** `/api/wled/config` - **Method:** `POST` @@ -251,7 +249,6 @@ Saves the WLED configuration. ```json { - "host": "192.168.1.100", "segments": [ { "name": "Main Light", @@ -269,7 +266,6 @@ Saves the WLED configuration. | Field | Type | Required | Description | |--------------------|--------|----------|------------------------------------------| -| host | string | Yes | WLED host address (IP or hostname) | | segments | array | Yes | List of LED segments (can be empty) | | segments[].name | string | No | Optional segment name | | segments[].start | number | Yes | Start LED index (0-based) | @@ -278,10 +274,9 @@ Saves the WLED configuration. - **Response:** `200 OK` on success, `400 Bad Request` on validation error **Notes:** -- The firmware uses this configuration to communicate with a WLED controller -- Segments are mapped to the WLED JSON API segment control +- Segments define how the LED strip is divided into logical groups - Changes are persisted to NVS (non-volatile storage) -- The host can be an IP address (e.g., `192.168.1.100`) or hostname (e.g., `wled.local`) +- Each segment can be controlled independently in the light schema --- diff --git a/firmware/storage/www/css/index.css b/firmware/storage/www/css/index.css index 2cc17cd..a739e9e 100644 --- a/firmware/storage/www/css/index.css +++ b/firmware/storage/www/css/index.css @@ -1200,7 +1200,7 @@ body { } } -/* WLED Configuration */ +/* LED Configuration */ .segment-header { display: flex; justify-content: space-between; @@ -1305,7 +1305,7 @@ body { color: white; } -/* Responsive for WLED */ +/* Responsive for LED */ @media (max-width: 600px) { .segment-header { flex-direction: column; diff --git a/firmware/storage/www/index.html b/firmware/storage/www/index.html index 0383a43..88a9812 100644 --- a/firmware/storage/www/index.html +++ b/firmware/storage/www/index.html @@ -188,10 +188,11 @@
- +
-

WLED Konfiguration

-

Konfiguriere die WLED-Segmente und LEDs pro +

LED Konfiguration

+

Konfiguriere die LED-Segmente und Anzahl + LEDs pro Segment

diff --git a/firmware/storage/www/js/i18n.js b/firmware/storage/www/js/i18n.js index 0fdc3de..9e9b936 100644 --- a/firmware/storage/www/js/i18n.js +++ b/firmware/storage/www/js/i18n.js @@ -16,11 +16,9 @@ const translations = { 'subtab.devices': 'πŸ”— GerΓ€te', 'subtab.scenes': '🎬 Szenen', - // WLED Configuration - 'wled.config.title': 'WLED Konfiguration', - 'wled.config.desc': 'Konfiguriere die WLED-Segmente und LEDs pro Segment', - 'wled.host': 'WLED Host', - 'wled.host.placeholder': 'z.B. 192.168.1.100 oder wled.local', + // LED Configuration + 'wled.config.title': 'LED Konfiguration', + 'wled.config.desc': 'Konfiguriere die LED-Segmente und Anzahl LEDs pro Segment', 'wled.segments.title': 'Segmente', 'wled.segments.empty': 'Keine Segmente konfiguriert', 'wled.segments.empty.hint': 'Klicke auf "Segment hinzufΓΌgen" um ein Segment zu erstellen', @@ -29,10 +27,9 @@ const translations = { 'wled.segment.leds': 'Anzahl LEDs', 'wled.segment.start': 'Start-LED', 'wled.segment.remove': 'Entfernen', - 'wled.saved': 'WLED-Konfiguration gespeichert!', - 'wled.error.host': 'Bitte WLED Host eingeben', - 'wled.error.save': 'Fehler beim Speichern der WLED-Konfiguration', - 'wled.loaded': 'WLED-Konfiguration geladen', + 'wled.saved': 'LED-Konfiguration gespeichert!', + 'wled.error.save': 'Fehler beim Speichern der LED-Konfiguration', + 'wled.loaded': 'LED-Konfiguration geladen', // Light Control 'control.light.title': 'Lichtsteuerung', @@ -190,11 +187,9 @@ const translations = { 'subtab.devices': 'πŸ”— Devices', 'subtab.scenes': '🎬 Scenes', - // WLED Configuration - 'wled.config.title': 'WLED Configuration', - 'wled.config.desc': 'Configure WLED segments and LEDs per segment', - 'wled.host': 'WLED Host', - 'wled.host.placeholder': 'e.g. 192.168.1.100 or wled.local', + // LED Configuration + 'wled.config.title': 'LED Configuration', + 'wled.config.desc': 'Configure LED segments and number of LEDs per segment', 'wled.segments.title': 'Segments', 'wled.segments.empty': 'No segments configured', 'wled.segments.empty.hint': 'Click "Add Segment" to create a segment', @@ -203,10 +198,9 @@ const translations = { 'wled.segment.leds': 'Number of LEDs', 'wled.segment.start': 'Start LED', 'wled.segment.remove': 'Remove', - 'wled.saved': 'WLED configuration saved!', - 'wled.error.host': 'Please enter WLED host', - 'wled.error.save': 'Error saving WLED configuration', - 'wled.loaded': 'WLED configuration loaded', + 'wled.saved': 'LED configuration saved!', + 'wled.error.save': 'Error saving LED configuration', + 'wled.loaded': 'LED configuration loaded', // Light Control 'control.light.title': 'Light Control', diff --git a/firmware/storage/www/js/wled.js b/firmware/storage/www/js/wled.js index 737c98f..0cca37f 100644 --- a/firmware/storage/www/js/wled.js +++ b/firmware/storage/www/js/wled.js @@ -1,5 +1,5 @@ -// WLED Configuration Module -// Manages WLED segments and LED configuration +// LED Configuration Module +// Manages LED segments and configuration let wledConfig = { segments: [] @@ -24,7 +24,7 @@ async function loadWledConfig() { showStatus('wled-status', t('wled.loaded'), 'success'); } } catch (error) { - console.log('Using default WLED config'); + console.log('Using default LED config'); wledConfig = { segments: [] }; renderWledSegments(); } From ee587f13810d22ea61fdfd6951d8f7e04f25183f Mon Sep 17 00:00:00 2001 From: Peter Siegmund Date: Thu, 1 Jan 2026 17:40:23 +0100 Subject: [PATCH 04/33] hide thread configuration (for now) Signed-off-by: Peter Siegmund --- firmware/README-API.md | 28 ++++++++ firmware/storage/www/index.html | 7 +- firmware/storage/www/js/app.js | 11 ++- firmware/storage/www/js/capabilities.js | 94 +++++++++++++++++++++++++ 4 files changed, 135 insertions(+), 5 deletions(-) create mode 100644 firmware/storage/www/js/capabilities.js diff --git a/firmware/README-API.md b/firmware/README-API.md index 90b83ef..f5c6ab1 100644 --- a/firmware/README-API.md +++ b/firmware/README-API.md @@ -5,6 +5,7 @@ This document describes all REST API endpoints and WebSocket messages required f ## Table of Contents - [REST API Endpoints](#rest-api-endpoints) + - [Capabilities](#capabilities) - [WiFi](#wifi) - [Light Control](#light-control) - [LED Configuration](#led-configuration) @@ -20,6 +21,33 @@ This document describes all REST API endpoints and WebSocket messages required f ## REST API Endpoints +### Capabilities + +#### Get Device Capabilities + +Returns the device capabilities. Used to determine which features are available. + +- **URL:** `/api/capabilities` +- **Method:** `GET` +- **Response:** + +```json +{ + "thread": true +} +``` + +| Field | Type | Description | +|--------|---------|-------------------------------------------------------| +| thread | boolean | Whether Thread/Matter features are enabled | + +**Notes:** +- If `thread` is `true`, the UI shows Matter device management and Scenes +- If `thread` is `false` or the endpoint is unavailable, these features are hidden +- The client can also force-enable features via URL parameter `?thread=true` + +--- + ### WiFi #### Scan Networks diff --git a/firmware/storage/www/index.html b/firmware/storage/www/index.html index 88a9812..85415bd 100644 --- a/firmware/storage/www/index.html +++ b/firmware/storage/www/index.html @@ -137,8 +137,10 @@ - - + +
@@ -449,6 +451,7 @@ + diff --git a/firmware/storage/www/js/app.js b/firmware/storage/www/js/app.js index ae12198..7a81dc7 100644 --- a/firmware/storage/www/js/app.js +++ b/firmware/storage/www/js/app.js @@ -41,13 +41,18 @@ document.addEventListener('touchend', (e) => { }, false); // Initialization -document.addEventListener('DOMContentLoaded', () => { +document.addEventListener('DOMContentLoaded', async () => { initI18n(); initTheme(); + await initCapabilities(); initWebSocket(); updateConnectionStatus(); - loadScenes(); - loadPairedDevices(); + + // Only load scenes and devices if thread is enabled + if (isThreadEnabled()) { + loadScenes(); + loadPairedDevices(); + } // WiFi status polling (less frequent) setInterval(updateConnectionStatus, 30000); }); diff --git a/firmware/storage/www/js/capabilities.js b/firmware/storage/www/js/capabilities.js new file mode 100644 index 0000000..1ffa677 --- /dev/null +++ b/firmware/storage/www/js/capabilities.js @@ -0,0 +1,94 @@ +// Capabilities Module +// Checks device capabilities and controls feature visibility + +let capabilities = { + thread: false +}; + +/** + * Initialize capabilities module + * Fetches from server, falls back to URL parameter for offline testing + */ +async function initCapabilities() { + // Try to fetch from server first + const success = await fetchCapabilities(); + + // If server not available, check URL parameter (for offline testing) + if (!success) { + const urlParams = new URLSearchParams(window.location.search); + if (urlParams.get('thread') === 'true') { + capabilities.thread = true; + } + } + + // Apply visibility based on capabilities + applyCapabilities(); +} + +/** + * Fetch capabilities from server + * @returns {boolean} true if successful + */ +async function fetchCapabilities() { + try { + const response = await fetch('/api/capabilities'); + if (response.ok) { + const data = await response.json(); + capabilities = { ...capabilities, ...data }; + return true; + } + return false; + } catch (error) { + console.log('Capabilities not available, using defaults'); + return false; + } +} + +/** + * Check if thread/Matter is enabled + * @returns {boolean} + */ +function isThreadEnabled() { + return capabilities.thread === true; +} + +/** + * Apply capabilities to UI - show/hide elements + */ +function applyCapabilities() { + const threadEnabled = isThreadEnabled(); + + // Elements to show/hide based on thread capability + const threadElements = [ + // Control tab elements + 'scenes-control-card', + 'devices-control-card', + // Config sub-tabs + 'subtab-btn-devices', + 'subtab-btn-scenes', + // Config sub-tab contents + 'subtab-devices', + 'subtab-scenes' + ]; + + threadElements.forEach(id => { + const element = document.getElementById(id); + if (element) { + element.style.display = threadEnabled ? '' : 'none'; + } + }); + + // Also hide scene devices section in scene modal if thread disabled + const sceneDevicesSection = document.querySelector('#scene-modal .form-group:has(#scene-devices-list)'); + if (sceneDevicesSection) { + sceneDevicesSection.style.display = threadEnabled ? '' : 'none'; + } +} + +/** + * Get all capabilities + * @returns {object} + */ +function getCapabilities() { + return { ...capabilities }; +} From 29785a96bc58bad7159ed741f6beb8ac720c37a1 Mon Sep 17 00:00:00 2001 From: Peter Siegmund Date: Thu, 1 Jan 2026 17:52:02 +0100 Subject: [PATCH 05/33] add thunder configuration Signed-off-by: Peter Siegmund --- firmware/README-API.md | 42 ++++++++++++--- firmware/storage/www/index.html | 8 ++- firmware/storage/www/js/app.js | 1 + firmware/storage/www/js/i18n.js | 4 +- firmware/storage/www/js/light.js | 89 ++++++++++++++++++++++++++++++++ 5 files changed, 135 insertions(+), 9 deletions(-) diff --git a/firmware/README-API.md b/firmware/README-API.md index f5c6ab1..d2f3afb 100644 --- a/firmware/README-API.md +++ b/firmware/README-API.md @@ -154,6 +154,34 @@ Turns the main light on or off. --- +#### Set Thunder Effect + +Turns the thunder/lightning effect on or off. + +- **URL:** `/api/light/thunder` +- **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 + +**Notes:** +- When enabled, random lightning flashes are triggered +- Can be combined with any light mode +- Thunder effect stops automatically when light is turned off + +--- + #### Set Light Mode Sets the lighting mode. @@ -211,6 +239,7 @@ Returns current light status (alternative to WebSocket). ```json { "on": true, + "thunder": false, "mode": "simulation", "schema": "schema_01.csv", "color": { @@ -221,12 +250,13 @@ Returns current light status (alternative to WebSocket). } ``` -| 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 | +| Field | Type | Description | +|---------|---------|--------------------------------------| +| on | boolean | Current power state | +| thunder | boolean | Current thunder effect state | +| mode | string | Current mode (day/night/simulation) | +| schema | string | Active schema filename | +| color | object | Current RGB color being displayed | --- diff --git a/firmware/storage/www/index.html b/firmware/storage/www/index.html index 85415bd..74e8b04 100644 --- a/firmware/storage/www/index.html +++ b/firmware/storage/www/index.html @@ -40,7 +40,6 @@

Lichtsteuerung

-

Ein/Aus

Licht @@ -49,6 +48,13 @@ πŸ’‘
+
+ Gewitter + +
diff --git a/firmware/storage/www/js/app.js b/firmware/storage/www/js/app.js index 7a81dc7..6e76058 100644 --- a/firmware/storage/www/js/app.js +++ b/firmware/storage/www/js/app.js @@ -47,6 +47,7 @@ document.addEventListener('DOMContentLoaded', async () => { await initCapabilities(); initWebSocket(); updateConnectionStatus(); + loadLightStatus(); // Only load scenes and devices if thread is enabled if (isThreadEnabled()) { diff --git a/firmware/storage/www/js/i18n.js b/firmware/storage/www/js/i18n.js index 9e9b936..072c707 100644 --- a/firmware/storage/www/js/i18n.js +++ b/firmware/storage/www/js/i18n.js @@ -33,8 +33,8 @@ const translations = { // Light Control 'control.light.title': 'Lichtsteuerung', - 'control.light.onoff': 'Ein/Aus', 'control.light.light': 'Licht', + 'control.light.thunder': 'Gewitter', 'control.mode.title': 'Betriebsmodus', 'control.schema.active': 'Aktives Schema', 'control.status.title': 'Aktueller Status', @@ -204,8 +204,8 @@ const translations = { // Light Control 'control.light.title': 'Light Control', - 'control.light.onoff': 'On/Off', 'control.light.light': 'Light', + 'control.light.thunder': 'Thunder', 'control.mode.title': 'Operating Mode', 'control.schema.active': 'Active Schema', 'control.status.title': 'Current Status', diff --git a/firmware/storage/www/js/light.js b/firmware/storage/www/js/light.js index 3cbcf0b..3983f08 100644 --- a/firmware/storage/www/js/light.js +++ b/firmware/storage/www/js/light.js @@ -1,4 +1,6 @@ // Light control +let thunderOn = false; + async function toggleLight() { lightOn = !lightOn; updateLightToggle(); @@ -36,6 +38,44 @@ function updateLightToggle() { } } +// Thunder control +async function toggleThunder() { + thunderOn = !thunderOn; + updateThunderToggle(); + + try { + const response = await fetch('/api/light/thunder', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ on: thunderOn }) + }); + + if (response.ok) { + showStatus('light-status', `${t('control.light.thunder')} ${thunderOn ? t('common.on') : t('common.off')}`, 'success'); + } else { + throw new Error(t('error')); + } + } catch (error) { + showStatus('light-status', `Demo: ${t('control.light.thunder')} ${thunderOn ? t('common.on') : t('common.off')}`, 'success'); + } +} + +function updateThunderToggle() { + const toggle = document.getElementById('thunder-toggle'); + const state = document.getElementById('thunder-state'); + const icon = document.getElementById('thunder-icon'); + + if (thunderOn) { + 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; @@ -100,3 +140,52 @@ async function setActiveSchema() { document.getElementById('current-schema').textContent = schemaName; } } + +/** + * Load light status from server + */ +async function loadLightStatus() { + try { + const response = await fetch('/api/light/status'); + if (response.ok) { + const status = await response.json(); + + // Update light state + if (typeof status.on === 'boolean') { + lightOn = status.on; + updateLightToggle(); + } + + // Update thunder state + if (typeof status.thunder === 'boolean') { + thunderOn = status.thunder; + updateThunderToggle(); + } + + // Update mode + if (status.mode) { + currentMode = status.mode; + updateModeButtons(); + updateSimulationOptions(); + document.getElementById('current-mode').textContent = t(`mode.${status.mode}`); + } + + // Update schema + if (status.schema) { + document.getElementById('active-schema').value = status.schema; + const schemaNum = status.schema.replace('schema_0', '').replace('.csv', ''); + document.getElementById('current-schema').textContent = t(`schema.name.${schemaNum}`); + } + + // Update current color + if (status.color) { + const colorPreview = document.getElementById('current-color'); + if (colorPreview) { + colorPreview.style.backgroundColor = `rgb(${status.color.r}, ${status.color.g}, ${status.color.b})`; + } + } + } + } catch (error) { + console.log('Light status not available'); + } +} From ebf0dc6556a90881d5f66bb5fcf7ec22b2f71cba Mon Sep 17 00:00:00 2001 From: Peter Siegmund Date: Mon, 5 Jan 2026 23:47:00 +0100 Subject: [PATCH 06/33] show website via mdns Signed-off-by: Peter Siegmund --- firmware/components/api-server/CMakeLists.txt | 14 + firmware/components/api-server/Kconfig | 38 + .../components/api-server/idf_component.yml | 5 + .../api-server/include/api_handlers.h | 63 ++ .../api-server/include/api_server.h | 119 +++ .../api-server/include/websocket_handler.h | 60 ++ .../components/api-server/src/api_handlers.c | 771 ++++++++++++++++++ .../components/api-server/src/api_server.c | 196 +++++ .../api-server/src/websocket_handler.c | 255 ++++++ .../connectivity-manager/CMakeLists.txt | 1 + .../connectivity-manager/src/wifi_manager.c | 5 + .../components/simulator/include/storage.h | 11 +- firmware/components/simulator/src/storage.cpp | 10 +- firmware/main/idf_component.yml | 3 +- firmware/sdkconfig.defaults | 3 + 15 files changed, 1549 insertions(+), 5 deletions(-) create mode 100644 firmware/components/api-server/CMakeLists.txt create mode 100644 firmware/components/api-server/Kconfig create mode 100644 firmware/components/api-server/idf_component.yml create mode 100644 firmware/components/api-server/include/api_handlers.h create mode 100644 firmware/components/api-server/include/api_server.h create mode 100644 firmware/components/api-server/include/websocket_handler.h create mode 100644 firmware/components/api-server/src/api_handlers.c create mode 100644 firmware/components/api-server/src/api_server.c create mode 100644 firmware/components/api-server/src/websocket_handler.c diff --git a/firmware/components/api-server/CMakeLists.txt b/firmware/components/api-server/CMakeLists.txt new file mode 100644 index 0000000..bee7a48 --- /dev/null +++ b/firmware/components/api-server/CMakeLists.txt @@ -0,0 +1,14 @@ +idf_component_register(SRCS + src/api_server.c + src/api_handlers.c + src/websocket_handler.c + INCLUDE_DIRS "include" + REQUIRES + esp_http_server + mdns + esp_wifi + esp_netif + esp_event + json + simulator +) diff --git a/firmware/components/api-server/Kconfig b/firmware/components/api-server/Kconfig new file mode 100644 index 0000000..06fe4aa --- /dev/null +++ b/firmware/components/api-server/Kconfig @@ -0,0 +1,38 @@ +menu "API Server Configuration" + + config API_SERVER_HOSTNAME + string "mDNS Hostname" + default "system-control" + help + The mDNS hostname for the API server. + The device will be accessible at .local + + config API_SERVER_PORT + int "HTTP Server Port" + default 80 + range 1 65535 + help + The port number for the HTTP server. + + config API_SERVER_MAX_WS_CLIENTS + int "Maximum WebSocket Clients" + default 4 + range 1 8 + help + Maximum number of concurrent WebSocket connections. + + config API_SERVER_ENABLE_CORS + bool "Enable CORS" + default y + help + Enable Cross-Origin Resource Sharing (CORS) headers. + This is required for web interfaces served from different origins. + + config API_SERVER_STATIC_FILES_PATH + string "Static Files Path" + default "/spiffs/www" + help + Base path for serving static web files. + +endmenu + diff --git a/firmware/components/api-server/idf_component.yml b/firmware/components/api-server/idf_component.yml new file mode 100644 index 0000000..3c6c200 --- /dev/null +++ b/firmware/components/api-server/idf_component.yml @@ -0,0 +1,5 @@ +dependencies: + idf: + version: '>=5.0.0' + espressif/mdns: + version: '*' diff --git a/firmware/components/api-server/include/api_handlers.h b/firmware/components/api-server/include/api_handlers.h new file mode 100644 index 0000000..03635e1 --- /dev/null +++ b/firmware/components/api-server/include/api_handlers.h @@ -0,0 +1,63 @@ +#pragma once + +#include + +#ifdef __cplusplus +extern "C" +{ +#endif + + /** + * @brief Register all API handlers with the HTTP server + * + * @param server HTTP server handle + * @return esp_err_t ESP_OK on success + */ + esp_err_t api_handlers_register(httpd_handle_t server); + + // Capabilities API + esp_err_t api_capabilities_get_handler(httpd_req_t *req); + + // WiFi API + esp_err_t api_wifi_scan_handler(httpd_req_t *req); + esp_err_t api_wifi_config_handler(httpd_req_t *req); + esp_err_t api_wifi_status_handler(httpd_req_t *req); + + // Light Control API + esp_err_t api_light_power_handler(httpd_req_t *req); + esp_err_t api_light_thunder_handler(httpd_req_t *req); + esp_err_t api_light_mode_handler(httpd_req_t *req); + esp_err_t api_light_schema_handler(httpd_req_t *req); + esp_err_t api_light_status_handler(httpd_req_t *req); + + // LED Configuration API + esp_err_t api_wled_config_get_handler(httpd_req_t *req); + esp_err_t api_wled_config_post_handler(httpd_req_t *req); + + // Schema API + esp_err_t api_schema_get_handler(httpd_req_t *req); + esp_err_t api_schema_post_handler(httpd_req_t *req); + + // Devices API (Matter) + esp_err_t api_devices_scan_handler(httpd_req_t *req); + esp_err_t api_devices_pair_handler(httpd_req_t *req); + esp_err_t api_devices_paired_handler(httpd_req_t *req); + esp_err_t api_devices_update_handler(httpd_req_t *req); + esp_err_t api_devices_unpair_handler(httpd_req_t *req); + esp_err_t api_devices_toggle_handler(httpd_req_t *req); + + // Scenes API + esp_err_t api_scenes_get_handler(httpd_req_t *req); + esp_err_t api_scenes_post_handler(httpd_req_t *req); + esp_err_t api_scenes_delete_handler(httpd_req_t *req); + esp_err_t api_scenes_activate_handler(httpd_req_t *req); + + // Static file serving + esp_err_t api_static_file_handler(httpd_req_t *req); + + // Captive portal detection + esp_err_t api_captive_portal_handler(httpd_req_t *req); + +#ifdef __cplusplus +} +#endif diff --git a/firmware/components/api-server/include/api_server.h b/firmware/components/api-server/include/api_server.h new file mode 100644 index 0000000..1b1d85e --- /dev/null +++ b/firmware/components/api-server/include/api_server.h @@ -0,0 +1,119 @@ +#pragma once + +#include +#include +#include + +#ifdef __cplusplus +extern "C" +{ +#endif + + /** + * @brief Configuration for the API server + */ + typedef struct + { + const char *hostname; ///< mDNS hostname (default: "system-control") + uint16_t port; ///< HTTP server port (default: 80) + const char *base_path; ///< Base path for static files (default: "/storage/www") + bool enable_cors; ///< Enable CORS headers (default: true) + } api_server_config_t; + +#ifdef CONFIG_API_SERVER_ENABLE_CORS +#define API_SERVER_ENABLE_CORS true +#else +#define API_SERVER_ENABLE_CORS false +#endif + +/** + * @brief Default configuration for the API server + */ +#define API_SERVER_CONFIG_DEFAULT() \ + { \ + .hostname = CONFIG_API_SERVER_HOSTNAME, \ + .port = CONFIG_API_SERVER_PORT, \ + .base_path = CONFIG_API_SERVER_STATIC_FILES_PATH, \ + .enable_cors = API_SERVER_ENABLE_CORS, \ + } + + /** + * @brief Initialize and start the API server with mDNS + * + * This function starts an HTTP server with: + * - REST API endpoints + * - WebSocket endpoint at /ws + * - mDNS registration (system-control.local) + * - Static file serving from SPIFFS + * + * @param config Pointer to server configuration, or NULL for defaults + * @return esp_err_t ESP_OK on success + */ + esp_err_t api_server_start(const api_server_config_t *config); + + /** + * @brief Stop the API server and mDNS + * + * @return esp_err_t ESP_OK on success + */ + esp_err_t api_server_stop(void); + + /** + * @brief Check if the API server is running + * + * @return true if server is running + */ + bool api_server_is_running(void); + + /** + * @brief Get the HTTP server handle + * + * @return httpd_handle_t Server handle, or NULL if not running + */ + httpd_handle_t api_server_get_handle(void); + + /** + * @brief Broadcast a message to all connected WebSocket clients + * + * @param message JSON message to broadcast + * @return esp_err_t ESP_OK on success + */ + esp_err_t api_server_ws_broadcast(const char *message); + + /** + * @brief Broadcast a status update to all WebSocket clients + * + * @param on Light power state + * @param mode Current mode (day/night/simulation) + * @param schema Active schema filename + * @param r Red value (0-255) + * @param g Green value (0-255) + * @param b Blue value (0-255) + * @return esp_err_t ESP_OK on success + */ + esp_err_t api_server_ws_broadcast_status(bool on, const char *mode, const char *schema, uint8_t r, uint8_t g, + uint8_t b); + + /** + * @brief Broadcast a color update to all WebSocket clients + * + * @param r Red value (0-255) + * @param g Green value (0-255) + * @param b Blue value (0-255) + * @return esp_err_t ESP_OK on success + */ + esp_err_t api_server_ws_broadcast_color(uint8_t r, uint8_t g, uint8_t b); + + /** + * @brief Broadcast a WiFi status update to all WebSocket clients + * + * @param connected Connection state + * @param ip IP address (can be NULL if not connected) + * @param rssi Signal strength + * @return esp_err_t ESP_OK on success + */ + esp_err_t api_server_ws_broadcast_wifi(bool connected, const char *ip, int rssi); + +#ifdef __cplusplus +} +#endif diff --git a/firmware/components/api-server/include/websocket_handler.h b/firmware/components/api-server/include/websocket_handler.h new file mode 100644 index 0000000..64b57bc --- /dev/null +++ b/firmware/components/api-server/include/websocket_handler.h @@ -0,0 +1,60 @@ +#pragma once + +#include +#include + +#ifdef __cplusplus +extern "C" +{ +#endif + +/** + * @brief Maximum number of concurrent WebSocket connections + */ +#define WS_MAX_CLIENTS CONFIG_API_SERVER_MAX_WS_CLIENTS + + /** + * @brief Initialize WebSocket handler + * + * @param server HTTP server handle + * @return esp_err_t ESP_OK on success + */ + esp_err_t websocket_handler_init(httpd_handle_t server); + + /** + * @brief WebSocket request handler + * + * @param req HTTP request + * @return esp_err_t ESP_OK on success + */ + esp_err_t websocket_handler(httpd_req_t *req); + + /** + * @brief Send message to a specific WebSocket client + * + * @param server HTTP server handle + * @param fd Socket file descriptor + * @param message Message to send + * @return esp_err_t ESP_OK on success + */ + esp_err_t websocket_send(httpd_handle_t server, int fd, const char *message); + + /** + * @brief Broadcast message to all connected WebSocket clients + * + * @param server HTTP server handle + * @param message Message to broadcast + * @return esp_err_t ESP_OK on success + */ + esp_err_t websocket_broadcast(httpd_handle_t server, const char *message); + + /** + * @brief Get number of connected WebSocket clients + * + * @return int Number of connected clients + */ + int websocket_get_client_count(void); + +#ifdef __cplusplus +} +#endif diff --git a/firmware/components/api-server/src/api_handlers.c b/firmware/components/api-server/src/api_handlers.c new file mode 100644 index 0000000..8e96f67 --- /dev/null +++ b/firmware/components/api-server/src/api_handlers.c @@ -0,0 +1,771 @@ +#include "api_handlers.h" + +#include +#include +#include +#include + +static const char *TAG = "api_handlers"; + +// Helper function to set CORS headers +static esp_err_t set_cors_headers(httpd_req_t *req) +{ + httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); + httpd_resp_set_hdr(req, "Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS"); + httpd_resp_set_hdr(req, "Access-Control-Allow-Headers", "Content-Type"); + return ESP_OK; +} + +// Helper function to send JSON response +static esp_err_t send_json_response(httpd_req_t *req, const char *json) +{ + set_cors_headers(req); + httpd_resp_set_type(req, "application/json"); + return httpd_resp_sendstr(req, json); +} + +// Helper function to send error response +static esp_err_t send_error_response(httpd_req_t *req, int status_code, const char *message) +{ + set_cors_headers(req); + httpd_resp_set_type(req, "application/json"); + httpd_resp_set_status(req, status_code == 400 ? "400 Bad Request" + : status_code == 404 ? "404 Not Found" + : "500 Internal Server Error"); + char buffer[128]; + snprintf(buffer, sizeof(buffer), "{\"error\":\"%s\"}", message); + return httpd_resp_sendstr(req, buffer); +} + +// OPTIONS handler for CORS preflight +static esp_err_t options_handler(httpd_req_t *req) +{ + set_cors_headers(req); + httpd_resp_set_status(req, "204 No Content"); + httpd_resp_send(req, NULL, 0); + return ESP_OK; +} + +// ============================================================================ +// Capabilities API +// ============================================================================ + +esp_err_t api_capabilities_get_handler(httpd_req_t *req) +{ + ESP_LOGI(TAG, "GET /api/capabilities"); + + // TODO: Implement actual capability detection + const char *response = "{\"thread\":false}"; + return send_json_response(req, response); +} + +// ============================================================================ +// WiFi API +// ============================================================================ + +esp_err_t api_wifi_scan_handler(httpd_req_t *req) +{ + ESP_LOGI(TAG, "GET /api/wifi/scan"); + + // TODO: Implement actual WiFi scanning + const char *response = "[" + "{\"ssid\":\"Network1\",\"rssi\":-45}," + "{\"ssid\":\"Network2\",\"rssi\":-72}" + "]"; + return send_json_response(req, response); +} + +esp_err_t api_wifi_config_handler(httpd_req_t *req) +{ + ESP_LOGI(TAG, "POST /api/wifi/config"); + + char buf[256]; + int ret = httpd_req_recv(req, buf, sizeof(buf) - 1); + if (ret <= 0) + { + return send_error_response(req, 400, "Failed to receive request body"); + } + buf[ret] = '\0'; + + ESP_LOGI(TAG, "Received WiFi config: %s", buf); + + // TODO: Parse JSON and connect to WiFi + set_cors_headers(req); + httpd_resp_set_status(req, "200 OK"); + return httpd_resp_sendstr(req, "{\"status\":\"ok\"}"); +} + +esp_err_t api_wifi_status_handler(httpd_req_t *req) +{ + ESP_LOGI(TAG, "GET /api/wifi/status"); + + // TODO: Implement actual WiFi status retrieval + const char *response = "{" + "\"connected\":true," + "\"ssid\":\"NetworkName\"," + "\"ip\":\"192.168.1.100\"," + "\"rssi\":-45" + "}"; + return send_json_response(req, response); +} + +// ============================================================================ +// Light Control API +// ============================================================================ + +esp_err_t api_light_power_handler(httpd_req_t *req) +{ + ESP_LOGI(TAG, "POST /api/light/power"); + + char buf[64]; + int ret = httpd_req_recv(req, buf, sizeof(buf) - 1); + if (ret <= 0) + { + return send_error_response(req, 400, "Failed to receive request body"); + } + buf[ret] = '\0'; + + ESP_LOGI(TAG, "Received light power: %s", buf); + + // TODO: Parse JSON and control light + set_cors_headers(req); + return httpd_resp_sendstr(req, "{\"status\":\"ok\"}"); +} + +esp_err_t api_light_thunder_handler(httpd_req_t *req) +{ + ESP_LOGI(TAG, "POST /api/light/thunder"); + + char buf[64]; + int ret = httpd_req_recv(req, buf, sizeof(buf) - 1); + if (ret <= 0) + { + return send_error_response(req, 400, "Failed to receive request body"); + } + buf[ret] = '\0'; + + ESP_LOGI(TAG, "Received thunder setting: %s", buf); + + // TODO: Parse JSON and control thunder effect + set_cors_headers(req); + return httpd_resp_sendstr(req, "{\"status\":\"ok\"}"); +} + +esp_err_t api_light_mode_handler(httpd_req_t *req) +{ + ESP_LOGI(TAG, "POST /api/light/mode"); + + char buf[64]; + int ret = httpd_req_recv(req, buf, sizeof(buf) - 1); + if (ret <= 0) + { + return send_error_response(req, 400, "Failed to receive request body"); + } + buf[ret] = '\0'; + + ESP_LOGI(TAG, "Received light mode: %s", buf); + + // TODO: Parse JSON and set light mode + set_cors_headers(req); + return httpd_resp_sendstr(req, "{\"status\":\"ok\"}"); +} + +esp_err_t api_light_schema_handler(httpd_req_t *req) +{ + ESP_LOGI(TAG, "POST /api/light/schema"); + + char buf[128]; + int ret = httpd_req_recv(req, buf, sizeof(buf) - 1); + if (ret <= 0) + { + return send_error_response(req, 400, "Failed to receive request body"); + } + buf[ret] = '\0'; + + ESP_LOGI(TAG, "Received schema setting: %s", buf); + + // TODO: Parse JSON and set active schema + set_cors_headers(req); + return httpd_resp_sendstr(req, "{\"status\":\"ok\"}"); +} + +esp_err_t api_light_status_handler(httpd_req_t *req) +{ + ESP_LOGI(TAG, "GET /api/light/status"); + + // TODO: Implement actual light status retrieval + const char *response = "{" + "\"on\":true," + "\"thunder\":false," + "\"mode\":\"simulation\"," + "\"schema\":\"schema_01.csv\"," + "\"color\":{\"r\":255,\"g\":240,\"b\":220}" + "}"; + return send_json_response(req, response); +} + +// ============================================================================ +// LED Configuration API +// ============================================================================ + +esp_err_t api_wled_config_get_handler(httpd_req_t *req) +{ + ESP_LOGI(TAG, "GET /api/wled/config"); + + // TODO: Implement actual LED config retrieval + const char *response = "{" + "\"segments\":[" + "{\"name\":\"Main Light\",\"start\":0,\"leds\":60}," + "{\"name\":\"Accent Light\",\"start\":60,\"leds\":30}" + "]" + "}"; + return send_json_response(req, response); +} + +esp_err_t api_wled_config_post_handler(httpd_req_t *req) +{ + ESP_LOGI(TAG, "POST /api/wled/config"); + + char buf[512]; + int ret = httpd_req_recv(req, buf, sizeof(buf) - 1); + if (ret <= 0) + { + return send_error_response(req, 400, "Failed to receive request body"); + } + buf[ret] = '\0'; + + ESP_LOGI(TAG, "Received WLED config: %s", buf); + + // TODO: Parse JSON and save LED configuration + set_cors_headers(req); + return httpd_resp_sendstr(req, "{\"status\":\"ok\"}"); +} + +// ============================================================================ +// Schema API +// ============================================================================ + +esp_err_t api_schema_get_handler(httpd_req_t *req) +{ + ESP_LOGI(TAG, "GET /api/schema/*"); + + // Extract filename from URI + const char *uri = req->uri; + const char *filename = strrchr(uri, '/'); + if (filename == NULL) + { + return send_error_response(req, 400, "Invalid schema path"); + } + filename++; // Skip the '/' + + ESP_LOGI(TAG, "Requested schema: %s", filename); + + // TODO: Read actual schema file from storage + // For now, return sample CSV data + set_cors_headers(req); + httpd_resp_set_type(req, "text/csv"); + const char *sample_csv = "255,240,220,0,100,250\n" + "255,230,200,0,120,250\n" + "255,220,180,0,140,250\n"; + return httpd_resp_sendstr(req, sample_csv); +} + +esp_err_t api_schema_post_handler(httpd_req_t *req) +{ + ESP_LOGI(TAG, "POST /api/schema/*"); + + // Extract filename from URI + const char *uri = req->uri; + const char *filename = strrchr(uri, '/'); + if (filename == NULL) + { + return send_error_response(req, 400, "Invalid schema path"); + } + filename++; + + char buf[2048]; + int ret = httpd_req_recv(req, buf, sizeof(buf) - 1); + if (ret <= 0) + { + return send_error_response(req, 400, "Failed to receive request body"); + } + buf[ret] = '\0'; + + ESP_LOGI(TAG, "Saving schema %s, size: %d bytes", filename, ret); + + // TODO: Save schema to storage + set_cors_headers(req); + return httpd_resp_sendstr(req, "{\"status\":\"ok\"}"); +} + +// ============================================================================ +// Devices API (Matter) +// ============================================================================ + +esp_err_t api_devices_scan_handler(httpd_req_t *req) +{ + ESP_LOGI(TAG, "GET /api/devices/scan"); + + // TODO: Implement Matter device scanning + const char *response = "[" + "{\"id\":\"matter-001\",\"type\":\"light\",\"name\":\"Matter Lamp\"}," + "{\"id\":\"matter-002\",\"type\":\"sensor\",\"name\":\"Temperature Sensor\"}" + "]"; + return send_json_response(req, response); +} + +esp_err_t api_devices_pair_handler(httpd_req_t *req) +{ + ESP_LOGI(TAG, "POST /api/devices/pair"); + + char buf[256]; + int ret = httpd_req_recv(req, buf, sizeof(buf) - 1); + if (ret <= 0) + { + return send_error_response(req, 400, "Failed to receive request body"); + } + buf[ret] = '\0'; + + ESP_LOGI(TAG, "Pairing device: %s", buf); + + // TODO: Implement Matter device pairing + set_cors_headers(req); + return httpd_resp_sendstr(req, "{\"status\":\"ok\"}"); +} + +esp_err_t api_devices_paired_handler(httpd_req_t *req) +{ + ESP_LOGI(TAG, "GET /api/devices/paired"); + + // TODO: Get list of paired devices + const char *response = "[" + "{\"id\":\"matter-001\",\"type\":\"light\",\"name\":\"Living Room Lamp\"}" + "]"; + return send_json_response(req, response); +} + +esp_err_t api_devices_update_handler(httpd_req_t *req) +{ + ESP_LOGI(TAG, "POST /api/devices/update"); + + char buf[256]; + int ret = httpd_req_recv(req, buf, sizeof(buf) - 1); + if (ret <= 0) + { + return send_error_response(req, 400, "Failed to receive request body"); + } + buf[ret] = '\0'; + + ESP_LOGI(TAG, "Updating device: %s", buf); + + // TODO: Update device name + set_cors_headers(req); + return httpd_resp_sendstr(req, "{\"status\":\"ok\"}"); +} + +esp_err_t api_devices_unpair_handler(httpd_req_t *req) +{ + ESP_LOGI(TAG, "POST /api/devices/unpair"); + + char buf[128]; + int ret = httpd_req_recv(req, buf, sizeof(buf) - 1); + if (ret <= 0) + { + return send_error_response(req, 400, "Failed to receive request body"); + } + buf[ret] = '\0'; + + ESP_LOGI(TAG, "Unpairing device: %s", buf); + + // TODO: Unpair device + set_cors_headers(req); + return httpd_resp_sendstr(req, "{\"status\":\"ok\"}"); +} + +esp_err_t api_devices_toggle_handler(httpd_req_t *req) +{ + ESP_LOGI(TAG, "POST /api/devices/toggle"); + + char buf[128]; + int ret = httpd_req_recv(req, buf, sizeof(buf) - 1); + if (ret <= 0) + { + return send_error_response(req, 400, "Failed to receive request body"); + } + buf[ret] = '\0'; + + ESP_LOGI(TAG, "Toggling device: %s", buf); + + // TODO: Toggle device + set_cors_headers(req); + return httpd_resp_sendstr(req, "{\"status\":\"ok\"}"); +} + +// ============================================================================ +// Scenes API +// ============================================================================ + +esp_err_t api_scenes_get_handler(httpd_req_t *req) +{ + ESP_LOGI(TAG, "GET /api/scenes"); + + // TODO: Get scenes from storage + const char *response = "[" + "{" + "\"id\":\"scene-1\"," + "\"name\":\"Evening Mood\"," + "\"icon\":\"πŸŒ…\"," + "\"actions\":{" + "\"light\":\"on\"," + "\"mode\":\"simulation\"," + "\"schema\":\"schema_02.csv\"" + "}" + "}," + "{" + "\"id\":\"scene-2\"," + "\"name\":\"Night Mode\"," + "\"icon\":\"πŸŒ™\"," + "\"actions\":{" + "\"light\":\"on\"," + "\"mode\":\"night\"" + "}" + "}" + "]"; + return send_json_response(req, response); +} + +esp_err_t api_scenes_post_handler(httpd_req_t *req) +{ + ESP_LOGI(TAG, "POST /api/scenes"); + + char buf[512]; + int ret = httpd_req_recv(req, buf, sizeof(buf) - 1); + if (ret <= 0) + { + return send_error_response(req, 400, "Failed to receive request body"); + } + buf[ret] = '\0'; + + ESP_LOGI(TAG, "Creating/updating scene: %s", buf); + + // TODO: Save scene to storage + set_cors_headers(req); + return httpd_resp_sendstr(req, "{\"status\":\"ok\"}"); +} + +esp_err_t api_scenes_delete_handler(httpd_req_t *req) +{ + ESP_LOGI(TAG, "DELETE /api/scenes"); + + char buf[128]; + int ret = httpd_req_recv(req, buf, sizeof(buf) - 1); + if (ret <= 0) + { + return send_error_response(req, 400, "Failed to receive request body"); + } + buf[ret] = '\0'; + + ESP_LOGI(TAG, "Deleting scene: %s", buf); + + // TODO: Delete scene from storage + set_cors_headers(req); + return httpd_resp_sendstr(req, "{\"status\":\"ok\"}"); +} + +esp_err_t api_scenes_activate_handler(httpd_req_t *req) +{ + ESP_LOGI(TAG, "POST /api/scenes/activate"); + + char buf[128]; + int ret = httpd_req_recv(req, buf, sizeof(buf) - 1); + if (ret <= 0) + { + return send_error_response(req, 400, "Failed to receive request body"); + } + buf[ret] = '\0'; + + ESP_LOGI(TAG, "Activating scene: %s", buf); + + // TODO: Activate scene + set_cors_headers(req); + return httpd_resp_sendstr(req, "{\"status\":\"ok\"}"); +} + +// ============================================================================ +// Static file serving +// ============================================================================ + +// Get MIME type from file extension +static const char *get_mime_type(const char *path) +{ + const char *ext = strrchr(path, '.'); + if (ext == NULL) + return "text/plain"; + + if (strcmp(ext, ".html") == 0) + return "text/html"; + if (strcmp(ext, ".css") == 0) + return "text/css"; + if (strcmp(ext, ".js") == 0) + return "application/javascript"; + if (strcmp(ext, ".json") == 0) + return "application/json"; + if (strcmp(ext, ".png") == 0) + return "image/png"; + if (strcmp(ext, ".jpg") == 0 || strcmp(ext, ".jpeg") == 0) + return "image/jpeg"; + if (strcmp(ext, ".svg") == 0) + return "image/svg+xml"; + if (strcmp(ext, ".ico") == 0) + return "image/x-icon"; + if (strcmp(ext, ".csv") == 0) + return "text/csv"; + + return "text/plain"; +} + +esp_err_t api_static_file_handler(httpd_req_t *req) +{ + char filepath[CONFIG_HTTPD_MAX_URI_LEN + 16]; + const char *uri = req->uri; + + // Default to index.html for root + if (strcmp(uri, "/") == 0) + { + uri = "/index.html"; + } + + const char *base_path = CONFIG_API_SERVER_STATIC_FILES_PATH; + int written = snprintf(filepath, sizeof(filepath), "%s%s", base_path, uri); + if (written < 0 || (size_t)written >= sizeof(filepath)) + { + ESP_LOGE(TAG, "URI too long: %s", uri); + return send_error_response(req, 400, "URI too long"); + } + + ESP_LOGI(TAG, "Serving static file: %s", filepath); + + // Check if file exists + struct stat st; + if (stat(filepath, &st) != 0) + { + ESP_LOGW(TAG, "File not found: %s", filepath); + return send_error_response(req, 404, "File not found"); + } + + // Open and serve file + FILE *f = fopen(filepath, "r"); + if (f == NULL) + { + ESP_LOGE(TAG, "Failed to open file: %s", filepath); + return send_error_response(req, 500, "Failed to open file"); + } + + set_cors_headers(req); + httpd_resp_set_type(req, get_mime_type(filepath)); + + char buf[512]; + size_t read_bytes; + while ((read_bytes = fread(buf, 1, sizeof(buf), f)) > 0) + { + if (httpd_resp_send_chunk(req, buf, read_bytes) != ESP_OK) + { + fclose(f); + ESP_LOGE(TAG, "Failed to send file chunk"); + return ESP_FAIL; + } + } + + fclose(f); + httpd_resp_send_chunk(req, NULL, 0); // End response + return ESP_OK; +} + +// ============================================================================ +// Captive portal detection +// ============================================================================ + +esp_err_t api_captive_portal_handler(httpd_req_t *req) +{ + ESP_LOGI(TAG, "Captive portal detection: %s", req->uri); + + // Redirect to captive portal page + httpd_resp_set_status(req, "302 Found"); + httpd_resp_set_hdr(req, "Location", "/captive.html"); + httpd_resp_send(req, NULL, 0); + return ESP_OK; +} + +// ============================================================================ +// Handler Registration +// ============================================================================ + +esp_err_t api_handlers_register(httpd_handle_t server) +{ + esp_err_t err; + + // Capabilities + httpd_uri_t capabilities_get = { + .uri = "/api/capabilities", .method = HTTP_GET, .handler = api_capabilities_get_handler}; + err = httpd_register_uri_handler(server, &capabilities_get); + if (err != ESP_OK) + return err; + + // WiFi endpoints + httpd_uri_t wifi_scan = {.uri = "/api/wifi/scan", .method = HTTP_GET, .handler = api_wifi_scan_handler}; + err = httpd_register_uri_handler(server, &wifi_scan); + if (err != ESP_OK) + return err; + + httpd_uri_t wifi_config = {.uri = "/api/wifi/config", .method = HTTP_POST, .handler = api_wifi_config_handler}; + err = httpd_register_uri_handler(server, &wifi_config); + if (err != ESP_OK) + return err; + + httpd_uri_t wifi_status = {.uri = "/api/wifi/status", .method = HTTP_GET, .handler = api_wifi_status_handler}; + err = httpd_register_uri_handler(server, &wifi_status); + if (err != ESP_OK) + return err; + + // Light endpoints + httpd_uri_t light_power = {.uri = "/api/light/power", .method = HTTP_POST, .handler = api_light_power_handler}; + err = httpd_register_uri_handler(server, &light_power); + if (err != ESP_OK) + return err; + + httpd_uri_t light_thunder = { + .uri = "/api/light/thunder", .method = HTTP_POST, .handler = api_light_thunder_handler}; + err = httpd_register_uri_handler(server, &light_thunder); + if (err != ESP_OK) + return err; + + httpd_uri_t light_mode = {.uri = "/api/light/mode", .method = HTTP_POST, .handler = api_light_mode_handler}; + err = httpd_register_uri_handler(server, &light_mode); + if (err != ESP_OK) + return err; + + httpd_uri_t light_schema = {.uri = "/api/light/schema", .method = HTTP_POST, .handler = api_light_schema_handler}; + err = httpd_register_uri_handler(server, &light_schema); + if (err != ESP_OK) + return err; + + httpd_uri_t light_status = {.uri = "/api/light/status", .method = HTTP_GET, .handler = api_light_status_handler}; + err = httpd_register_uri_handler(server, &light_status); + if (err != ESP_OK) + return err; + + // WLED config endpoints + httpd_uri_t wled_config_get = { + .uri = "/api/wled/config", .method = HTTP_GET, .handler = api_wled_config_get_handler}; + err = httpd_register_uri_handler(server, &wled_config_get); + if (err != ESP_OK) + return err; + + httpd_uri_t wled_config_post = { + .uri = "/api/wled/config", .method = HTTP_POST, .handler = api_wled_config_post_handler}; + err = httpd_register_uri_handler(server, &wled_config_post); + if (err != ESP_OK) + return err; + + // Schema endpoints (wildcard) + httpd_uri_t schema_get = {.uri = "/api/schema/*", .method = HTTP_GET, .handler = api_schema_get_handler}; + err = httpd_register_uri_handler(server, &schema_get); + if (err != ESP_OK) + return err; + + httpd_uri_t schema_post = {.uri = "/api/schema/*", .method = HTTP_POST, .handler = api_schema_post_handler}; + err = httpd_register_uri_handler(server, &schema_post); + if (err != ESP_OK) + return err; + + // Devices endpoints + httpd_uri_t devices_scan = {.uri = "/api/devices/scan", .method = HTTP_GET, .handler = api_devices_scan_handler}; + err = httpd_register_uri_handler(server, &devices_scan); + if (err != ESP_OK) + return err; + + httpd_uri_t devices_pair = {.uri = "/api/devices/pair", .method = HTTP_POST, .handler = api_devices_pair_handler}; + err = httpd_register_uri_handler(server, &devices_pair); + if (err != ESP_OK) + return err; + + httpd_uri_t devices_paired = { + .uri = "/api/devices/paired", .method = HTTP_GET, .handler = api_devices_paired_handler}; + err = httpd_register_uri_handler(server, &devices_paired); + if (err != ESP_OK) + return err; + + httpd_uri_t devices_update = { + .uri = "/api/devices/update", .method = HTTP_POST, .handler = api_devices_update_handler}; + err = httpd_register_uri_handler(server, &devices_update); + if (err != ESP_OK) + return err; + + httpd_uri_t devices_unpair = { + .uri = "/api/devices/unpair", .method = HTTP_POST, .handler = api_devices_unpair_handler}; + err = httpd_register_uri_handler(server, &devices_unpair); + if (err != ESP_OK) + return err; + + httpd_uri_t devices_toggle = { + .uri = "/api/devices/toggle", .method = HTTP_POST, .handler = api_devices_toggle_handler}; + err = httpd_register_uri_handler(server, &devices_toggle); + if (err != ESP_OK) + return err; + + // Scenes endpoints + httpd_uri_t scenes_get = {.uri = "/api/scenes", .method = HTTP_GET, .handler = api_scenes_get_handler}; + err = httpd_register_uri_handler(server, &scenes_get); + if (err != ESP_OK) + return err; + + httpd_uri_t scenes_post = {.uri = "/api/scenes", .method = HTTP_POST, .handler = api_scenes_post_handler}; + err = httpd_register_uri_handler(server, &scenes_post); + if (err != ESP_OK) + return err; + + httpd_uri_t scenes_delete = {.uri = "/api/scenes", .method = HTTP_DELETE, .handler = api_scenes_delete_handler}; + err = httpd_register_uri_handler(server, &scenes_delete); + if (err != ESP_OK) + return err; + + httpd_uri_t scenes_activate = { + .uri = "/api/scenes/activate", .method = HTTP_POST, .handler = api_scenes_activate_handler}; + err = httpd_register_uri_handler(server, &scenes_activate); + if (err != ESP_OK) + return err; + + // Captive portal detection endpoints + httpd_uri_t captive_generate_204 = { + .uri = "/generate_204", .method = HTTP_GET, .handler = api_captive_portal_handler}; + err = httpd_register_uri_handler(server, &captive_generate_204); + if (err != ESP_OK) + return err; + + httpd_uri_t captive_hotspot = { + .uri = "/hotspot-detect.html", .method = HTTP_GET, .handler = api_captive_portal_handler}; + err = httpd_register_uri_handler(server, &captive_hotspot); + if (err != ESP_OK) + return err; + + httpd_uri_t captive_connecttest = { + .uri = "/connecttest.txt", .method = HTTP_GET, .handler = api_captive_portal_handler}; + err = httpd_register_uri_handler(server, &captive_connecttest); + if (err != ESP_OK) + return err; + + // OPTIONS handler for CORS preflight (wildcard) + httpd_uri_t options = {.uri = "/api/*", .method = HTTP_OPTIONS, .handler = options_handler}; + err = httpd_register_uri_handler(server, &options); + if (err != ESP_OK) + return err; + + // Static file handler (must be last due to wildcard) + httpd_uri_t static_files = {.uri = "/*", .method = HTTP_GET, .handler = api_static_file_handler}; + err = httpd_register_uri_handler(server, &static_files); + if (err != ESP_OK) + return err; + + ESP_LOGI(TAG, "All API handlers registered"); + return ESP_OK; +} diff --git a/firmware/components/api-server/src/api_server.c b/firmware/components/api-server/src/api_server.c new file mode 100644 index 0000000..f25f0db --- /dev/null +++ b/firmware/components/api-server/src/api_server.c @@ -0,0 +1,196 @@ +#include "api_server.h" +#include "api_handlers.h" +#include "websocket_handler.h" + +#include "storage.h" +#include +#include +#include +#include + +static const char *TAG = "api_server"; + +static httpd_handle_t s_server = NULL; +static api_server_config_t s_config = API_SERVER_CONFIG_DEFAULT(); + +static esp_err_t init_mdns(const char *hostname) +{ + esp_err_t err = mdns_init(); + if (err != ESP_OK) + { + ESP_LOGE(TAG, "Failed to initialize mDNS: %s", esp_err_to_name(err)); + return err; + } + + err = mdns_hostname_set(hostname); + if (err != ESP_OK) + { + ESP_LOGE(TAG, "Failed to set mDNS hostname: %s", esp_err_to_name(err)); + return err; + } + + err = mdns_instance_name_set("System Control"); + if (err != ESP_OK) + { + ESP_LOGE(TAG, "Failed to set mDNS instance name: %s", esp_err_to_name(err)); + return err; + } + + // Add HTTP service + err = mdns_service_add("System Control Web Server", "_http", "_tcp", s_config.port, NULL, 0); + if (err != ESP_OK) + { + ESP_LOGE(TAG, "Failed to add mDNS HTTP service: %s", esp_err_to_name(err)); + return err; + } + + ESP_LOGI(TAG, "mDNS initialized: %s.local", hostname); + return ESP_OK; +} + +static esp_err_t start_webserver(void) +{ + httpd_config_t config = HTTPD_DEFAULT_CONFIG(); + config.server_port = s_config.port; + config.lru_purge_enable = true; + config.max_uri_handlers = 32; + config.max_open_sockets = 7; + config.uri_match_fn = httpd_uri_match_wildcard; + + ESP_LOGI(TAG, "Starting HTTP server on port %d", config.server_port); + + esp_err_t err = httpd_start(&s_server, &config); + if (err != ESP_OK) + { + ESP_LOGE(TAG, "Failed to start HTTP server: %s", esp_err_to_name(err)); + return err; + } + + // WebSocket-Handler explizit vor allen API-Handlern + err = websocket_handler_init(s_server); + if (err != ESP_OK) + { + ESP_LOGE(TAG, "Failed to initialize WebSocket handler: %s", esp_err_to_name(err)); + httpd_stop(s_server); + s_server = NULL; + return err; + } + + // Register API handlers + err = api_handlers_register(s_server); + if (err != ESP_OK) + { + ESP_LOGE(TAG, "Failed to register API handlers: %s", esp_err_to_name(err)); + httpd_stop(s_server); + s_server = NULL; + return err; + } + + ESP_LOGI(TAG, "HTTP server started successfully"); + return ESP_OK; +} + +esp_err_t api_server_start(const api_server_config_t *config) +{ + if (s_server != NULL) + { + ESP_LOGW(TAG, "Server already running"); + return ESP_ERR_INVALID_STATE; + } + + if (config != NULL) + { + s_config = *config; + } + + initialize_storage(); + + // Initialize mDNS + esp_err_t err = init_mdns(s_config.hostname); + if (err != ESP_OK) + { + return err; + } + + // Start web server + err = start_webserver(); + if (err != ESP_OK) + { + mdns_free(); + return err; + } + + return ESP_OK; +} + +esp_err_t api_server_stop(void) +{ + if (s_server == NULL) + { + ESP_LOGW(TAG, "Server not running"); + return ESP_ERR_INVALID_STATE; + } + + esp_err_t err = httpd_stop(s_server); + if (err != ESP_OK) + { + ESP_LOGE(TAG, "Failed to stop HTTP server: %s", esp_err_to_name(err)); + return err; + } + + s_server = NULL; + mdns_free(); + + ESP_LOGI(TAG, "Server stopped"); + return ESP_OK; +} + +bool api_server_is_running(void) +{ + return s_server != NULL; +} + +httpd_handle_t api_server_get_handle(void) +{ + return s_server; +} + +esp_err_t api_server_ws_broadcast(const char *message) +{ + if (s_server == NULL) + { + return ESP_ERR_INVALID_STATE; + } + return websocket_broadcast(s_server, message); +} + +esp_err_t api_server_ws_broadcast_status(bool on, const char *mode, const char *schema, uint8_t r, uint8_t g, uint8_t b) +{ + char buffer[256]; + snprintf(buffer, sizeof(buffer), + "{\"type\":\"status\",\"on\":%s,\"mode\":\"%s\",\"schema\":\"%s\"," + "\"color\":{\"r\":%d,\"g\":%d,\"b\":%d}}", + on ? "true" : "false", mode, schema, r, g, b); + return api_server_ws_broadcast(buffer); +} + +esp_err_t api_server_ws_broadcast_color(uint8_t r, uint8_t g, uint8_t b) +{ + char buffer[64]; + snprintf(buffer, sizeof(buffer), "{\"type\":\"color\",\"r\":%d,\"g\":%d,\"b\":%d}", r, g, b); + return api_server_ws_broadcast(buffer); +} + +esp_err_t api_server_ws_broadcast_wifi(bool connected, const char *ip, int rssi) +{ + char buffer[128]; + if (connected && ip != NULL) + { + snprintf(buffer, sizeof(buffer), "{\"type\":\"wifi\",\"connected\":true,\"ip\":\"%s\",\"rssi\":%d}", ip, rssi); + } + else + { + snprintf(buffer, sizeof(buffer), "{\"type\":\"wifi\",\"connected\":false}"); + } + return api_server_ws_broadcast(buffer); +} diff --git a/firmware/components/api-server/src/websocket_handler.c b/firmware/components/api-server/src/websocket_handler.c new file mode 100644 index 0000000..7c25e12 --- /dev/null +++ b/firmware/components/api-server/src/websocket_handler.c @@ -0,0 +1,255 @@ +#include "websocket_handler.h" + +#include +#include +#include + +static const char *TAG = "websocket_handler"; + +// Store connected WebSocket client file descriptors +static int ws_clients[WS_MAX_CLIENTS]; +static int ws_client_count = 0; + +static void ws_clients_init(void) +{ + for (int i = 0; i < WS_MAX_CLIENTS; i++) + ws_clients[i] = -1; +} + +// Add a client to the list +static void add_client(int fd) +{ + for (int i = 0; i < WS_MAX_CLIENTS; i++) + { + if (ws_clients[i] == -1) + { + ws_clients[i] = fd; + ws_client_count++; + ESP_LOGI(TAG, "WebSocket client connected: fd=%d (total: %d)", fd, ws_client_count); + return; + } + } + ESP_LOGW(TAG, "Max WebSocket clients reached, cannot add fd=%d", fd); +} + +// Remove a client from the list +static void remove_client(int fd) +{ + for (int i = 0; i < WS_MAX_CLIENTS; i++) + { + if (ws_clients[i] == fd) + { + ws_clients[i] = -1; + ws_client_count--; + ESP_LOGI(TAG, "WebSocket client disconnected: fd=%d (total: %d)", fd, ws_client_count); + return; + } + } +} + +// Handle incoming WebSocket message +static esp_err_t handle_ws_message(httpd_req_t *req, httpd_ws_frame_t *ws_pkt) +{ + ESP_LOGI(TAG, "Received WS message: %s", (char *)ws_pkt->payload); + + // Parse the message and handle different types + // For now, we just check if it's a status request + if (ws_pkt->payload != NULL && strstr((char *)ws_pkt->payload, "getStatus") != NULL) + { + // Send status response + // TODO: Get actual status values + const char *response = "{" + "\"type\":\"status\"," + "\"on\":true," + "\"mode\":\"simulation\"," + "\"schema\":\"schema_01.csv\"," + "\"color\":{\"r\":255,\"g\":240,\"b\":220}" + "}"; + + httpd_ws_frame_t ws_resp = {.final = true, + .fragmented = false, + .type = HTTPD_WS_TYPE_TEXT, + .payload = (uint8_t *)response, + .len = strlen(response)}; + + return httpd_ws_send_frame(req, &ws_resp); + } + + return ESP_OK; +} + +esp_err_t websocket_handler(httpd_req_t *req) +{ + if (req->method == HTTP_GET) + { + // This is the handshake + ESP_LOGI(TAG, "WebSocket handshake"); + return ESP_OK; + } + + // Receive the frame + httpd_ws_frame_t ws_pkt; + memset(&ws_pkt, 0, sizeof(httpd_ws_frame_t)); + ws_pkt.type = HTTPD_WS_TYPE_TEXT; + + // Get frame length first + esp_err_t ret = httpd_ws_recv_frame(req, &ws_pkt, 0); + if (ret != ESP_OK) + { + ESP_LOGE(TAG, "httpd_ws_recv_frame failed to get frame len: %s", esp_err_to_name(ret)); + return ret; + } + + if (ws_pkt.len > 0) + { + // Allocate buffer for payload + ws_pkt.payload = malloc(ws_pkt.len + 1); + if (ws_pkt.payload == NULL) + { + ESP_LOGE(TAG, "Failed to allocate memory for WS payload"); + return ESP_ERR_NO_MEM; + } + + // Receive the payload + ret = httpd_ws_recv_frame(req, &ws_pkt, ws_pkt.len); + if (ret != ESP_OK) + { + ESP_LOGE(TAG, "httpd_ws_recv_frame failed: %s", esp_err_to_name(ret)); + free(ws_pkt.payload); + return ret; + } + + // Null-terminate the payload + ws_pkt.payload[ws_pkt.len] = '\0'; + + // Handle the message + ret = handle_ws_message(req, &ws_pkt); + + free(ws_pkt.payload); + return ret; + } + + // Handle close frame + if (ws_pkt.type == HTTPD_WS_TYPE_CLOSE) + { + int fd = httpd_req_to_sockfd(req); + remove_client(fd); + } + + return ESP_OK; +} + +// Async send structure +typedef struct +{ + httpd_handle_t hd; + int fd; + char *message; +} ws_async_arg_t; + +// Async send work function +static void ws_async_send(void *arg) +{ + ws_async_arg_t *async_arg = (ws_async_arg_t *)arg; + + httpd_ws_frame_t ws_pkt = {.final = true, + .fragmented = false, + .type = HTTPD_WS_TYPE_TEXT, + .payload = (uint8_t *)async_arg->message, + .len = strlen(async_arg->message)}; + + esp_err_t ret = httpd_ws_send_frame_async(async_arg->hd, async_arg->fd, &ws_pkt); + if (ret != ESP_OK) + { + ESP_LOGW(TAG, "Failed to send WS frame to fd=%d: %s", async_arg->fd, esp_err_to_name(ret)); + // Remove client on error + remove_client(async_arg->fd); + } + + free(async_arg->message); + free(async_arg); +} + +esp_err_t websocket_handler_init(httpd_handle_t server) +{ + ws_clients_init(); + // Register WebSocket URI handler + httpd_uri_t ws_uri = {.uri = "/ws", + .method = HTTP_GET, + .handler = websocket_handler, + .is_websocket = true, + .handle_ws_control_frames = true, + .user_ctx = NULL}; + + esp_err_t ret = httpd_register_uri_handler(server, &ws_uri); + if (ret != ESP_OK) + { + ESP_LOGE(TAG, "Failed to register WebSocket handler: %s", esp_err_to_name(ret)); + return ret; + } + + ESP_LOGI(TAG, "WebSocket handler initialized at /ws"); + return ESP_OK; +} + +esp_err_t websocket_send(httpd_handle_t server, int fd, const char *message) +{ + if (server == NULL || message == NULL) + { + return ESP_ERR_INVALID_ARG; + } + + ws_async_arg_t *arg = malloc(sizeof(ws_async_arg_t)); + if (arg == NULL) + { + return ESP_ERR_NO_MEM; + } + + arg->hd = server; + arg->fd = fd; + arg->message = strdup(message); + if (arg->message == NULL) + { + free(arg); + return ESP_ERR_NO_MEM; + } + + esp_err_t ret = httpd_queue_work(server, ws_async_send, arg); + if (ret != ESP_OK) + { + free(arg->message); + free(arg); + } + + return ret; +} + +esp_err_t websocket_broadcast(httpd_handle_t server, const char *message) +{ + if (server == NULL || message == NULL) + { + return ESP_ERR_INVALID_ARG; + } + + esp_err_t ret = ESP_OK; + + for (int i = 0; i < WS_MAX_CLIENTS; i++) + { + if (ws_clients[i] != -1) + { + esp_err_t send_ret = websocket_send(server, ws_clients[i], message); + if (send_ret != ESP_OK) + { + ESP_LOGW(TAG, "Failed to queue WS message for fd=%d", ws_clients[i]); + ret = send_ret; // Return last error + } + } + } + + return ret; +} + +int websocket_get_client_count(void) +{ + return ws_client_count; +} diff --git a/firmware/components/connectivity-manager/CMakeLists.txt b/firmware/components/connectivity-manager/CMakeLists.txt index 28bd01e..ecbe78a 100644 --- a/firmware/components/connectivity-manager/CMakeLists.txt +++ b/firmware/components/connectivity-manager/CMakeLists.txt @@ -10,4 +10,5 @@ idf_component_register(SRCS nvs_flash esp_insights led-manager + api-server ) diff --git a/firmware/components/connectivity-manager/src/wifi_manager.c b/firmware/components/connectivity-manager/src/wifi_manager.c index 559bb1a..66abe2e 100644 --- a/firmware/components/connectivity-manager/src/wifi_manager.c +++ b/firmware/components/connectivity-manager/src/wifi_manager.c @@ -1,5 +1,7 @@ #include "wifi_manager.h" +#include "api_server.h" + #include #include #include @@ -216,6 +218,9 @@ void wifi_manager_init() if (bits & WIFI_CONNECTED_BIT) { ESP_DIAG_EVENT(TAG, "Connected to AP SSID:%s", s_wifi_networks[s_current_network_index].ssid); + + api_server_config_t s_config = API_SERVER_CONFIG_DEFAULT(); + ESP_ERROR_CHECK(api_server_start(&s_config)); } else if (bits & WIFI_FAIL_BIT) { diff --git a/firmware/components/simulator/include/storage.h b/firmware/components/simulator/include/storage.h index 642ac66..81dd8a6 100644 --- a/firmware/components/simulator/include/storage.h +++ b/firmware/components/simulator/include/storage.h @@ -1,4 +1,11 @@ #pragma once -void initialize_storage(); -void load_file(const char *filename); +#ifdef __cplusplus +extern "C" +{ +#endif + void initialize_storage(); + void load_file(const char *filename); +#ifdef __cplusplus +} +#endif diff --git a/firmware/components/simulator/src/storage.cpp b/firmware/components/simulator/src/storage.cpp index 6f5a3d5..e8accfd 100644 --- a/firmware/components/simulator/src/storage.cpp +++ b/firmware/components/simulator/src/storage.cpp @@ -3,14 +3,20 @@ #include "esp_log.h" #include "esp_spiffs.h" #include "simulator.h" -#include #include #include static const char *TAG = "storage"; +static bool is_spiffs_mounted = false; + void initialize_storage() { + if (is_spiffs_mounted) + { + return; + } + esp_vfs_spiffs_conf_t conf = { .base_path = "/spiffs", .partition_label = NULL, @@ -36,6 +42,8 @@ void initialize_storage() } return; } + + is_spiffs_mounted = true; } void load_file(const char *filename) diff --git a/firmware/main/idf_component.yml b/firmware/main/idf_component.yml index dc3a1fe..0a9790b 100755 --- a/firmware/main/idf_component.yml +++ b/firmware/main/idf_component.yml @@ -3,6 +3,5 @@ dependencies: git: https://github.com/olikraus/u8g2.git # u8g2_hal: # git: https://github.com/mkfrey/u8g2-hal-esp-idf.git - espressif/button: ^4.1.3 + espressif/button: ^4.1.4 espressif/esp_insights: ^1.2.7 - espressif/mqtt: ^1.0.0 diff --git a/firmware/sdkconfig.defaults b/firmware/sdkconfig.defaults index a4dd8c4..e70ad99 100755 --- a/firmware/sdkconfig.defaults +++ b/firmware/sdkconfig.defaults @@ -42,3 +42,6 @@ CONFIG_SPIRAM=y # SPI RAM config CONFIG_SPIRAM_SPEED=80 CONFIG_SPIRAM_USE_CAPS_ALLOC=y + +# HTTP Server WebSocket Support +CONFIG_HTTPD_WS_SUPPORT=y From 28e991cf58f0a765033fa3e42f9edce44ad391f0 Mon Sep 17 00:00:00 2001 From: Peter Siegmund Date: Tue, 6 Jan 2026 00:04:09 +0100 Subject: [PATCH 07/33] scan for WiFi and schon connection state (loading once) Signed-off-by: Peter Siegmund --- firmware/.clangd | 2 + firmware/README-API.md | 2 +- .../components/api-server/src/api_handlers.c | 87 ++++++++++++++++--- 3 files changed, 76 insertions(+), 15 deletions(-) create mode 100644 firmware/.clangd diff --git a/firmware/.clangd b/firmware/.clangd new file mode 100644 index 0000000..437f255 --- /dev/null +++ b/firmware/.clangd @@ -0,0 +1,2 @@ +CompileFlags: + Remove: [-f*, -m*] diff --git a/firmware/README-API.md b/firmware/README-API.md index d2f3afb..277ab86 100644 --- a/firmware/README-API.md +++ b/firmware/README-API.md @@ -784,7 +784,7 @@ Sent when WiFi connection status changes. ## Static Files -The web interface files should be served from the `/storage/www/` directory: +The web interface files should be served from the `/spiffs/www/` directory: | Path | Description | |----------------------|------------------------------------------------| diff --git a/firmware/components/api-server/src/api_handlers.c b/firmware/components/api-server/src/api_handlers.c index 8e96f67..44b9a57 100644 --- a/firmware/components/api-server/src/api_handlers.c +++ b/firmware/components/api-server/src/api_handlers.c @@ -1,7 +1,9 @@ #include "api_handlers.h" +#include #include #include +#include #include #include @@ -67,12 +69,39 @@ esp_err_t api_wifi_scan_handler(httpd_req_t *req) { ESP_LOGI(TAG, "GET /api/wifi/scan"); - // TODO: Implement actual WiFi scanning - const char *response = "[" - "{\"ssid\":\"Network1\",\"rssi\":-45}," - "{\"ssid\":\"Network2\",\"rssi\":-72}" - "]"; - return send_json_response(req, response); + wifi_scan_config_t scan_config = {.ssid = NULL, .bssid = NULL, .channel = 0, .show_hidden = true}; + esp_err_t err = esp_wifi_scan_start(&scan_config, true); + if (err != ESP_OK) + { + return send_error_response(req, 500, "WiFi scan failed"); + } + + uint16_t ap_num = 0; + esp_wifi_scan_get_ap_num(&ap_num); + wifi_ap_record_t *ap_list = calloc(ap_num, sizeof(wifi_ap_record_t)); + if (!ap_list) + { + return send_error_response(req, 500, "Memory allocation failed"); + } + esp_wifi_scan_get_ap_records(&ap_num, ap_list); + + cJSON *json = cJSON_CreateArray(); + for (int i = 0; i < ap_num; i++) + { + if (ap_list[i].ssid[0] != '\0') + { + cJSON *entry = cJSON_CreateObject(); + cJSON_AddStringToObject(entry, "ssid", (const char *)ap_list[i].ssid); + cJSON_AddNumberToObject(entry, "rssi", ap_list[i].rssi); + cJSON_AddItemToArray(json, entry); + } + } + char *response = cJSON_PrintUnformatted(json); + cJSON_Delete(json); + free(ap_list); + esp_err_t res = send_json_response(req, response); + free(response); + return res; } esp_err_t api_wifi_config_handler(httpd_req_t *req) @@ -99,14 +128,44 @@ esp_err_t api_wifi_status_handler(httpd_req_t *req) { ESP_LOGI(TAG, "GET /api/wifi/status"); - // TODO: Implement actual WiFi status retrieval - const char *response = "{" - "\"connected\":true," - "\"ssid\":\"NetworkName\"," - "\"ip\":\"192.168.1.100\"," - "\"rssi\":-45" - "}"; - return send_json_response(req, response); + wifi_ap_record_t ap_info; + bool connected = false; + char ssid[33] = ""; + char ip[16] = ""; + int rssi = 0; + + wifi_mode_t mode; + esp_wifi_get_mode(&mode); + if (mode == WIFI_MODE_STA || mode == WIFI_MODE_APSTA) + { + if (esp_wifi_sta_get_ap_info(&ap_info) == ESP_OK) + { + connected = true; + strncpy(ssid, (const char *)ap_info.ssid, sizeof(ssid) - 1); + rssi = ap_info.rssi; + } + esp_netif_t *netif = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF"); + if (netif) + { + esp_netif_ip_info_t ip_info; + if (esp_netif_get_ip_info(netif, &ip_info) == ESP_OK) + { + snprintf(ip, sizeof(ip), "%d.%d.%d.%d", esp_ip4_addr1(&ip_info.ip), esp_ip4_addr2(&ip_info.ip), + esp_ip4_addr3(&ip_info.ip), esp_ip4_addr4(&ip_info.ip)); + } + } + } + + cJSON *json = cJSON_CreateObject(); + cJSON_AddBoolToObject(json, "connected", connected); + cJSON_AddStringToObject(json, "ssid", ssid); + cJSON_AddStringToObject(json, "ip", ip); + cJSON_AddNumberToObject(json, "rssi", rssi); + char *response = cJSON_PrintUnformatted(json); + cJSON_Delete(json); + esp_err_t res = send_json_response(req, response); + free(response); + return res; } // ============================================================================ From d18c9bfea10f8c67e6727021fea34230310f245d Mon Sep 17 00:00:00 2001 From: Peter Siegmund Date: Tue, 6 Jan 2026 00:18:01 +0100 Subject: [PATCH 08/33] add capa endpoint Signed-off-by: Peter Siegmund --- firmware/components/api-server/src/api_handlers.c | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/firmware/components/api-server/src/api_handlers.c b/firmware/components/api-server/src/api_handlers.c index 44b9a57..3d97866 100644 --- a/firmware/components/api-server/src/api_handlers.c +++ b/firmware/components/api-server/src/api_handlers.c @@ -56,9 +56,18 @@ esp_err_t api_capabilities_get_handler(httpd_req_t *req) { ESP_LOGI(TAG, "GET /api/capabilities"); - // TODO: Implement actual capability detection - const char *response = "{\"thread\":false}"; - return send_json_response(req, response); + // Thread nur fΓΌr esp32c6 oder esp32h2 verfΓΌgbar + bool thread = false; +#if defined(CONFIG_IDF_TARGET_ESP32C6) || defined(CONFIG_IDF_TARGET_ESP32H2) + thread = true; +#endif + cJSON *json = cJSON_CreateObject(); + cJSON_AddBoolToObject(json, "thread", thread); + char *response = cJSON_PrintUnformatted(json); + cJSON_Delete(json); + esp_err_t res = send_json_response(req, response); + free(response); + return res; } // ============================================================================ From cfca3f15356425cf00faaf3037239c93e6dd441c Mon Sep 17 00:00:00 2001 From: Peter Siegmund Date: Tue, 6 Jan 2026 00:18:15 +0100 Subject: [PATCH 09/33] mask password in log Signed-off-by: Peter Siegmund --- .../components/api-server/src/api_handlers.c | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/firmware/components/api-server/src/api_handlers.c b/firmware/components/api-server/src/api_handlers.c index 3d97866..921b6b9 100644 --- a/firmware/components/api-server/src/api_handlers.c +++ b/firmware/components/api-server/src/api_handlers.c @@ -125,7 +125,40 @@ esp_err_t api_wifi_config_handler(httpd_req_t *req) } buf[ret] = '\0'; - ESP_LOGI(TAG, "Received WiFi config: %s", buf); + // Passwort maskieren + cJSON *json = cJSON_Parse(buf); + if (json) + { + cJSON *pw = cJSON_GetObjectItem(json, "password"); + if (pw && cJSON_IsString(pw) && pw->valuestring) + { + size_t pwlen = strlen(pw->valuestring); + char *masked = malloc(pwlen + 1); + if (masked) + { + memset(masked, '*', pwlen); + masked[pwlen] = '\0'; + cJSON_ReplaceItemInObject(json, "password", cJSON_CreateString(masked)); + char *logstr = cJSON_PrintUnformatted(json); + ESP_LOGI(TAG, "Received WiFi config: %s", logstr); + free(logstr); + free(masked); + } + else + { + ESP_LOGI(TAG, "Received WiFi config: %s", buf); + } + } + else + { + ESP_LOGI(TAG, "Received WiFi config: %s", buf); + } + cJSON_Delete(json); + } + else + { + ESP_LOGI(TAG, "Received WiFi config: %s", buf); + } // TODO: Parse JSON and connect to WiFi set_cors_headers(req); From ef0cda1d673f9e668ebbed87b37f250511be76f7 Mon Sep 17 00:00:00 2001 From: Peter Siegmund Date: Sat, 10 Jan 2026 00:33:34 +0100 Subject: [PATCH 10/33] update web ui Signed-off-by: Peter Siegmund --- firmware/components/api-server/CMakeLists.txt | 1 + .../components/api-server/include/common.h | 8 ++++++++ .../components/api-server/src/api_handlers.c | 19 +++++++++--------- firmware/components/api-server/src/common.c | 20 +++++++++++++++++++ .../api-server/src/websocket_handler.c | 19 +++++++++--------- firmware/storage/www/index.html | 6 +++--- firmware/storage/www/js/i18n.js | 14 ++++++++++++- firmware/storage/www/js/ui.js | 2 +- 8 files changed, 64 insertions(+), 25 deletions(-) create mode 100644 firmware/components/api-server/include/common.h create mode 100644 firmware/components/api-server/src/common.c diff --git a/firmware/components/api-server/CMakeLists.txt b/firmware/components/api-server/CMakeLists.txt index bee7a48..13dee36 100644 --- a/firmware/components/api-server/CMakeLists.txt +++ b/firmware/components/api-server/CMakeLists.txt @@ -1,5 +1,6 @@ idf_component_register(SRCS src/api_server.c + src/common.c src/api_handlers.c src/websocket_handler.c INCLUDE_DIRS "include" diff --git a/firmware/components/api-server/include/common.h b/firmware/components/api-server/include/common.h new file mode 100644 index 0000000..ffb9a73 --- /dev/null +++ b/firmware/components/api-server/include/common.h @@ -0,0 +1,8 @@ +#ifndef COMMON_H +#define COMMON_H + +#include + +cJSON *create_light_status_json(void); + +#endif // COMMON_H diff --git a/firmware/components/api-server/src/api_handlers.c b/firmware/components/api-server/src/api_handlers.c index 921b6b9..dad1a78 100644 --- a/firmware/components/api-server/src/api_handlers.c +++ b/firmware/components/api-server/src/api_handlers.c @@ -1,4 +1,5 @@ #include "api_handlers.h" +#include "common.h" #include #include @@ -102,6 +103,8 @@ esp_err_t api_wifi_scan_handler(httpd_req_t *req) cJSON *entry = cJSON_CreateObject(); cJSON_AddStringToObject(entry, "ssid", (const char *)ap_list[i].ssid); cJSON_AddNumberToObject(entry, "rssi", ap_list[i].rssi); + bool secure = ap_list[i].authmode != WIFI_AUTH_OPEN; + cJSON_AddBoolToObject(entry, "secure", secure); cJSON_AddItemToArray(json, entry); } } @@ -293,16 +296,12 @@ esp_err_t api_light_schema_handler(httpd_req_t *req) esp_err_t api_light_status_handler(httpd_req_t *req) { ESP_LOGI(TAG, "GET /api/light/status"); - - // TODO: Implement actual light status retrieval - const char *response = "{" - "\"on\":true," - "\"thunder\":false," - "\"mode\":\"simulation\"," - "\"schema\":\"schema_01.csv\"," - "\"color\":{\"r\":255,\"g\":240,\"b\":220}" - "}"; - return send_json_response(req, response); + cJSON *json = create_light_status_json(); + char *response = cJSON_PrintUnformatted(json); + cJSON_Delete(json); + esp_err_t res = send_json_response(req, response); + free(response); + return res; } // ============================================================================ diff --git a/firmware/components/api-server/src/common.c b/firmware/components/api-server/src/common.c new file mode 100644 index 0000000..d30c979 --- /dev/null +++ b/firmware/components/api-server/src/common.c @@ -0,0 +1,20 @@ +#include "common.h" +#include +#include + +// Gibt ein cJSON-Objekt with dem aktuellen Lichtstatus zurΓΌck +cJSON *create_light_status_json(void) +{ + cJSON *json = cJSON_CreateObject(); + // TODO: Echte Werte einfΓΌgen, aktuell Dummy-Daten + cJSON_AddBoolToObject(json, "on", false); + cJSON_AddBoolToObject(json, "thunder", false); + cJSON_AddStringToObject(json, "mode", "day"); + cJSON_AddStringToObject(json, "schema", "schema_03.csv"); + cJSON *color = cJSON_CreateObject(); + cJSON_AddNumberToObject(color, "r", 255); + cJSON_AddNumberToObject(color, "g", 240); + cJSON_AddNumberToObject(color, "b", 220); + cJSON_AddItemToObject(json, "color", color); + return json; +} diff --git a/firmware/components/api-server/src/websocket_handler.c b/firmware/components/api-server/src/websocket_handler.c index 7c25e12..6f9023b 100644 --- a/firmware/components/api-server/src/websocket_handler.c +++ b/firmware/components/api-server/src/websocket_handler.c @@ -1,4 +1,5 @@ #include "websocket_handler.h" +#include "common.h" #include #include @@ -56,15 +57,11 @@ static esp_err_t handle_ws_message(httpd_req_t *req, httpd_ws_frame_t *ws_pkt) // For now, we just check if it's a status request if (ws_pkt->payload != NULL && strstr((char *)ws_pkt->payload, "getStatus") != NULL) { - // Send status response - // TODO: Get actual status values - const char *response = "{" - "\"type\":\"status\"," - "\"on\":true," - "\"mode\":\"simulation\"," - "\"schema\":\"schema_01.csv\"," - "\"color\":{\"r\":255,\"g\":240,\"b\":220}" - "}"; + // Status-JSON generieren + cJSON *json = create_light_status_json(); + cJSON_AddStringToObject(json, "type", "status"); + char *response = cJSON_PrintUnformatted(json); + cJSON_Delete(json); httpd_ws_frame_t ws_resp = {.final = true, .fragmented = false, @@ -72,7 +69,9 @@ static esp_err_t handle_ws_message(httpd_req_t *req, httpd_ws_frame_t *ws_pkt) .payload = (uint8_t *)response, .len = strlen(response)}; - return httpd_ws_send_frame(req, &ws_resp); + esp_err_t ret = httpd_ws_send_frame(req, &ws_resp); + free(response); + return ret; } return ESP_OK; diff --git a/firmware/storage/www/index.html b/firmware/storage/www/index.html index 74e8b04..09ff919 100644 --- a/firmware/storage/www/index.html +++ b/firmware/storage/www/index.html @@ -70,7 +70,7 @@ πŸŒ™ Nacht - @@ -109,7 +109,7 @@
-
+