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/.devcontainer/Dockerfile b/firmware/.devcontainer/Dockerfile index dafb8ad..2534683 100644 --- a/firmware/.devcontainer/Dockerfile +++ b/firmware/.devcontainer/Dockerfile @@ -1,4 +1,4 @@ -ARG DOCKER_TAG=latest +ARG DOCKER_TAG=release-v5.4 FROM espressif/idf:${DOCKER_TAG} ENV LC_ALL=C.UTF-8 diff --git a/firmware/CMakeLists.txt b/firmware/CMakeLists.txt index 0fd6d96..b543594 100755 --- a/firmware/CMakeLists.txt +++ b/firmware/CMakeLists.txt @@ -1,4 +1,9 @@ cmake_minimum_required(VERSION 3.5) include($ENV{IDF_PATH}/tools/cmake/project.cmake) + +idf_build_set_property(BOOTLOADER_EXTRA_COMPONENT_DIRS "${CMAKE_CURRENT_LIST_DIR}/bootloader_components_extra/" APPEND) + project(system_control) + +target_add_binary_data(${PROJECT_NAME}.elf "main/isrgrootx1.pem" TEXT) \ No newline at end of file diff --git a/firmware/README-API.md b/firmware/README-API.md new file mode 100644 index 0000000..277ab86 --- /dev/null +++ b/firmware/README-API.md @@ -0,0 +1,859 @@ +# 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) + - [Capabilities](#capabilities) + - [WiFi](#wifi) + - [Light Control](#light-control) + - [LED Configuration](#led-configuration) + - [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 + +### 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 + +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 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. + +- **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, + "thunder": false, + "mode": "simulation", + "schema": "schema_01.csv", + "color": { + "r": 255, + "g": 240, + "b": 220 + } +} +``` + +| 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 | + +--- + +### LED Configuration + +#### Get LED Configuration + +Returns the current LED segment configuration. + +- **URL:** `/api/wled/config` +- **Method:** `GET` +- **Response:** + +```json +{ + "segments": [ + { + "name": "Main Light", + "start": 0, + "leds": 60 + }, + { + "name": "Accent Light", + "start": 60, + "leds": 30 + } + ] +} +``` + +| Field | Type | Description | +|--------------------|--------|------------------------------------------| +| 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 LED Configuration + +Saves the LED segment configuration. + +- **URL:** `/api/wled/config` +- **Method:** `POST` +- **Content-Type:** `application/json` +- **Request Body:** + +```json +{ + "segments": [ + { + "name": "Main Light", + "start": 0, + "leds": 60 + }, + { + "name": "Accent Light", + "start": 60, + "leds": 30 + } + ] +} +``` + +| Field | Type | Required | Description | +|--------------------|--------|----------|------------------------------------------| +| 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:** +- Segments define how the LED strip is divided into logical groups +- Changes are persisted to NVS (non-volatile storage) +- Each segment can be controlled independently in the light schema + +--- + +### 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 `/spiffs/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/bootloader_components/my_boot_hooks/CMakeLists.txt b/firmware/bootloader_components/my_boot_hooks/CMakeLists.txt new file mode 100644 index 0000000..9d43803 --- /dev/null +++ b/firmware/bootloader_components/my_boot_hooks/CMakeLists.txt @@ -0,0 +1,9 @@ +idf_component_register(SRCS "hooks.c" + REQUIRES extra_component) + +# We need to force GCC to integrate this static library into the +# bootloader link. Indeed, by default, as the hooks in the bootloader are weak, +# the linker would just ignore the symbols in the extra. (i.e. not strictly +# required) +# To do so, we need to define the symbol (function) `bootloader_hooks_include` +# within hooks.c source file. \ No newline at end of file diff --git a/firmware/bootloader_components/my_boot_hooks/hooks.c b/firmware/bootloader_components/my_boot_hooks/hooks.c new file mode 100644 index 0000000..1433c67 --- /dev/null +++ b/firmware/bootloader_components/my_boot_hooks/hooks.c @@ -0,0 +1,21 @@ +#include "esp_log.h" + +/* Function used to tell the linker to include this file + * with all its symbols. + */ +void bootloader_hooks_include(void) +{ +} + +/* Keep in my mind that a lot of functions cannot be called from here + * as system initialization has not been performed yet, including + * BSS, SPI flash, or memory protection. */ +void bootloader_before_init(void) +{ + ESP_LOGI("HOOK", "This hook is called BEFORE bootloader initialization"); +} + +void bootloader_after_init(void) +{ + ESP_LOGI("HOOK", "This hook is called AFTER bootloader initialization"); +} diff --git a/firmware/bootloader_components_extra/extra_component/CMakeLists.txt b/firmware/bootloader_components_extra/extra_component/CMakeLists.txt new file mode 100644 index 0000000..ac58fa9 --- /dev/null +++ b/firmware/bootloader_components_extra/extra_component/CMakeLists.txt @@ -0,0 +1 @@ +idf_component_register(SRCS "extra_component.c") diff --git a/firmware/bootloader_components_extra/extra_component/extra_component.c b/firmware/bootloader_components_extra/extra_component/extra_component.c new file mode 100644 index 0000000..78b558c --- /dev/null +++ b/firmware/bootloader_components_extra/extra_component/extra_component.c @@ -0,0 +1,11 @@ +/* + * SPDX-FileCopyrightText: 2024 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Unlicense OR CC0-1.0 + */ +#include "esp_log.h" + +void bootloader_extra_dir_function(void) +{ + ESP_LOGI("EXTRA", "This function is called from an extra component"); +} diff --git a/firmware/components/api-server/CMakeLists.txt b/firmware/components/api-server/CMakeLists.txt new file mode 100644 index 0000000..9a6ef99 --- /dev/null +++ b/firmware/components/api-server/CMakeLists.txt @@ -0,0 +1,19 @@ +idf_component_register(SRCS + src/api_server.c + src/common.c + src/api_handlers.c + src/websocket_handler.c + INCLUDE_DIRS "include" + REQUIRES + esp_http_server + mdns + esp_wifi + esp_netif + esp_event + json + led-manager + simulator + persistence-manager + message-manager + 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/common.h b/firmware/components/api-server/include/common.h new file mode 100644 index 0000000..f5d153a --- /dev/null +++ b/firmware/components/api-server/include/common.h @@ -0,0 +1,9 @@ +#ifndef COMMON_H +#define COMMON_H + +#include + +void common_init(void); +cJSON *create_light_status_json(void); + +#endif // COMMON_H 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..d358fc0 --- /dev/null +++ b/firmware/components/api-server/src/api_handlers.c @@ -0,0 +1,1165 @@ +#include "api_handlers.h" +#include "common.h" +#include "message_manager.h" + +#include "esp_heap_caps.h" +#include "led_segment.h" +#include "persistence_manager.h" +#include "storage.h" +#include +#include +#include +#include +#include +#include + +#define MAX_BODY_SIZE 4096 + +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"); + + // Thread only available for esp32c6 or esp32h2 + 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; +} + +// ============================================================================ +// WiFi API +// ============================================================================ + +esp_err_t api_wifi_scan_handler(httpd_req_t *req) +{ + ESP_LOGI(TAG, "GET /api/wifi/scan"); + + 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 = heap_caps_calloc(ap_num, sizeof(wifi_ap_record_t), MALLOC_CAP_DEFAULT); + 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); + bool secure = ap_list[i].authmode != WIFI_AUTH_OPEN; + cJSON_AddBoolToObject(entry, "secure", secure); + 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; +} + +static void reboot_task(void *param) +{ + vTaskDelay(pdMS_TO_TICKS(100)); + esp_restart(); +} + +static bool is_valid(const cJSON *string) +{ + return string && cJSON_IsString(string) && string->valuestring && strlen(string->valuestring) > 0; +} + +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'; + + cJSON *json = cJSON_Parse(buf); + if (json) + { + cJSON *ssid = cJSON_GetObjectItem(json, "ssid"); + cJSON *pw = cJSON_GetObjectItem(json, "password"); + if (is_valid(ssid) && is_valid(pw)) + { + persistence_manager_t pm; + if (persistence_manager_init(&pm, "wifi_config") == ESP_OK) + { + persistence_manager_set_string(&pm, "ssid", ssid->valuestring); + persistence_manager_set_string(&pm, "password", pw->valuestring); + persistence_manager_deinit(&pm); + } + } + if (is_valid(pw)) + { + size_t pwlen = strlen(pw->valuestring); + char *masked = heap_caps_malloc(pwlen + 1, MALLOC_CAP_DEFAULT); + 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); + } + + // Define a reboot task function + xTaskCreate(reboot_task, "reboot_task", 2048, NULL, 5, NULL); + + 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"); + + 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; +} + +// ============================================================================ +// 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); + + cJSON *json = cJSON_Parse(buf); + if (json) + { + cJSON *active = cJSON_GetObjectItem(json, "on"); + if (cJSON_IsBool(active)) + { + message_t msg = {}; + msg.type = MESSAGE_TYPE_SETTINGS; + msg.data.settings.type = SETTINGS_TYPE_BOOL; + strncpy(msg.data.settings.key, "light_active", sizeof(msg.data.settings.key) - 1); + msg.data.settings.value.bool_value = cJSON_IsTrue(active); + message_manager_post(&msg, pdMS_TO_TICKS(100)); + } + cJSON_Delete(json); + } + + 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); + + cJSON *json = cJSON_Parse(buf); + if (json) + { + cJSON *mode = cJSON_GetObjectItem(json, "mode"); + if (cJSON_IsString(mode)) + { + message_t msg = {}; + msg.type = MESSAGE_TYPE_SETTINGS; + msg.data.settings.type = SETTINGS_TYPE_INT; + strncpy(msg.data.settings.key, "light_mode", sizeof(msg.data.settings.key) - 1); + if (strcmp(mode->valuestring, "simulation") == 0) + { + msg.data.settings.value.int_value = 0; + } + else if (strcmp(mode->valuestring, "day") == 0) + { + msg.data.settings.value.int_value = 1; + } + else if (strcmp(mode->valuestring, "night") == 0) + { + msg.data.settings.value.int_value = 2; + } + else + { + msg.data.settings.value.int_value = -1; // Unknown mode + } + message_manager_post(&msg, pdMS_TO_TICKS(100)); + } + cJSON_Delete(json); + } + + 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); + + cJSON *json = cJSON_Parse(buf); + if (json) + { + cJSON *schema_file = cJSON_GetObjectItem(json, "schema"); + if (cJSON_IsString(schema_file)) + { + int schema_id = 0; + sscanf(schema_file->valuestring, "schema_%d.csv", &schema_id); + + message_t msg = {}; + msg.type = MESSAGE_TYPE_SETTINGS; + msg.data.settings.type = SETTINGS_TYPE_INT; + strncpy(msg.data.settings.key, "light_variant", sizeof(msg.data.settings.key) - 1); + msg.data.settings.value.int_value = schema_id; + message_manager_post(&msg, pdMS_TO_TICKS(100)); + } + cJSON_Delete(json); + } + + 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"); + 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; +} + +// ============================================================================ +// LED Configuration API +// ============================================================================ + +esp_err_t api_wled_config_get_handler(httpd_req_t *req) +{ + ESP_LOGI(TAG, "GET /api/wled/config"); + + extern led_segment_t segments[LED_SEGMENT_MAX_LEN]; + extern size_t segment_count; + size_t required_size = sizeof(segments) * segment_count; + + cJSON *json = cJSON_CreateObject(); + + persistence_manager_t pm; + if (persistence_manager_init(&pm, "led_config") == ESP_OK) + { + persistence_manager_get_blob(&pm, "segments", segments, required_size, NULL); + uint8_t segment_count = persistence_manager_get_int(&pm, "segment_count", 0); + persistence_manager_deinit(&pm); + + cJSON *segments_arr = cJSON_CreateArray(); + for (uint8_t i = 0; i < segment_count; ++i) + { + cJSON *seg = cJSON_CreateObject(); + cJSON_AddStringToObject(seg, "name", segments[i].name); + cJSON_AddNumberToObject(seg, "start", segments[i].start); + cJSON_AddNumberToObject(seg, "leds", segments[i].leds); + cJSON_AddItemToArray(segments_arr, seg); + } + cJSON_AddItemToObject(json, "segments", segments_arr); + } + else + { + cJSON_AddItemToObject(json, "segments", cJSON_CreateArray()); + } + + char *response = cJSON_PrintUnformatted(json); + cJSON_Delete(json); + esp_err_t res = send_json_response(req, response); + free(response); + return res; +} + +esp_err_t api_wled_config_post_handler(httpd_req_t *req) +{ + ESP_LOGI(TAG, "POST /api/wled/config"); + + char *buf = heap_caps_malloc(MAX_BODY_SIZE, MALLOC_CAP_DEFAULT); + if (!buf) + return send_error_response(req, 500, "Memory allocation failed"); + int total = 0, ret; + while (total < MAX_BODY_SIZE - 1) + { + ret = httpd_req_recv(req, buf + total, MAX_BODY_SIZE - 1 - total); + if (ret <= 0) + break; + total += ret; + } + buf[total] = '\0'; + + ESP_LOGI(TAG, "Received WLED config: %s", buf); + + cJSON *json = cJSON_Parse(buf); + free(buf); + + if (!json) + { + return send_error_response(req, 400, "Invalid JSON"); + } + + cJSON *segments_arr = cJSON_GetObjectItem(json, "segments"); + if (!cJSON_IsArray(segments_arr)) + { + cJSON_Delete(json); + return send_error_response(req, 400, "Missing segments array"); + } + + extern led_segment_t segments[LED_SEGMENT_MAX_LEN]; + extern size_t segment_count; + size_t count = cJSON_GetArraySize(segments_arr); + if (count > LED_SEGMENT_MAX_LEN) + count = LED_SEGMENT_MAX_LEN; + segment_count = count; + for (size_t i = 0; i < LED_SEGMENT_MAX_LEN; ++i) + { + cJSON *seg = cJSON_GetArrayItem(segments_arr, i); + cJSON *name = cJSON_GetObjectItem(seg, "name"); + cJSON *start = cJSON_GetObjectItem(seg, "start"); + cJSON *leds = cJSON_GetObjectItem(seg, "leds"); + if (cJSON_IsString(name) && cJSON_IsNumber(start) && cJSON_IsNumber(leds) && i < count) + { + strncpy(segments[i].name, name->valuestring, sizeof(segments[i].name) - 1); + segments[i].name[sizeof(segments[i].name) - 1] = '\0'; + segments[i].start = (uint16_t)start->valuedouble; + segments[i].leds = (uint16_t)leds->valuedouble; + } + else + { + // Invalid entry, skip or set defaults + segments[i].name[0] = '\0'; + segments[i].start = 0; + segments[i].leds = 0; + } + } + cJSON_Delete(json); + + persistence_manager_t pm; + if (persistence_manager_init(&pm, "led_config") == ESP_OK) + { + persistence_manager_set_blob(&pm, "segments", segments, sizeof(led_segment_t) * segment_count); + persistence_manager_set_int(&pm, "segment_count", (int32_t)segment_count); + persistence_manager_deinit(&pm); + } + + set_cors_headers(req); + return httpd_resp_sendstr(req, "{\"status\":\"ok\"}"); +} + +// ============================================================================ +// Schema API +// ============================================================================ +static char *heap_caps_strdup(const char *src, uint32_t caps) +{ + if (!src) + return NULL; + size_t len = strlen(src) + 1; + char *dst = heap_caps_malloc(len, caps); + if (dst) + memcpy(dst, src, len); + return dst; +} + +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); + + // Read schema file + char path[128]; + snprintf(path, sizeof(path), "%s", filename); + + int line_count = 0; + char **lines = read_lines_filtered(path, &line_count); + + set_cors_headers(req); + httpd_resp_set_type(req, "text/csv"); + + if (!lines || line_count == 0) + { + return httpd_resp_sendstr(req, ""); + } + + // Calculate total length + size_t total_len = 0; + for (int i = 0; i < line_count; ++i) + total_len += strlen(lines[i]) + 1; + char *csv = heap_caps_malloc(total_len + 1, MALLOC_CAP_DEFAULT); + char *p = csv; + for (int i = 0; i < line_count; ++i) + { + size_t l = strlen(lines[i]); + memcpy(p, lines[i], l); + p += l; + *p++ = '\n'; + } + *p = '\0'; + free_lines(lines, line_count); + esp_err_t res = httpd_resp_sendstr(req, csv); + free(csv); + return res; +} + +esp_err_t api_schema_post_handler(httpd_req_t *req) +{ + ESP_LOGI(TAG, "POST /api/schema/*"); + + // Extract filename from URI + if (!req) + { + ESP_LOGE(TAG, "Request pointer is NULL"); + return send_error_response(req, 500, "Internal error: req is NULL"); + } + const char *uri = req->uri; + ESP_LOGI(TAG, "Request URI: %s", uri ? uri : "(null)"); + if (!uri) + { + ESP_LOGE(TAG, "Request URI is NULL"); + return send_error_response(req, 400, "Invalid schema path (no URI)"); + } + const char *filename = strrchr(uri, '/'); + if (filename == NULL || filename[1] == '\0') + { + ESP_LOGE(TAG, "Could not extract filename from URI: %s", uri); + return send_error_response(req, 400, "Invalid schema path"); + } + filename++; + ESP_LOGI(TAG, "Extracted filename: %s", filename); + + // Dynamically read POST body (like api_wled_config_post_handler) + char *buf = heap_caps_malloc(MAX_BODY_SIZE, MALLOC_CAP_DEFAULT); + if (!buf) + { + ESP_LOGE(TAG, "Memory allocation failed for POST body"); + return send_error_response(req, 500, "Memory allocation failed"); + } + int total = 0, ret; + while (total < MAX_BODY_SIZE - 1) + { + ret = httpd_req_recv(req, buf + total, MAX_BODY_SIZE - 1 - total); + if (ret <= 0) + break; + total += ret; + } + buf[total] = '\0'; + + ESP_LOGI(TAG, "Saving schema %s, size: %d bytes", filename, total); + + // Split CSV body into line array + int line_count = 0; + // Count lines + for (int i = 0; i < total; ++i) + if (buf[i] == '\n') + line_count++; + if (total > 0 && buf[total - 1] != '\n') + line_count++; // last line without \n + + char **lines = (char **)heap_caps_malloc(line_count * sizeof(char *), MALLOC_CAP_DEFAULT); + int idx = 0; + char *saveptr = NULL; + char *line = strtok_r(buf, "\n", &saveptr); + while (line && idx < line_count) + { + // Ignore empty lines + if (line[0] != '\0') + lines[idx++] = heap_caps_strdup(line, MALLOC_CAP_DEFAULT); + line = strtok_r(NULL, "\n", &saveptr); + } + int actual_count = idx; + esp_err_t err = write_lines(filename, lines, actual_count); + for (int i = 0; i < actual_count; ++i) + free(lines[i]); + free(lines); + set_cors_headers(req); + + if (err != ESP_OK) + return send_error_response(req, 500, "Failed to save schema"); + 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; + wifi_mode_t mode = 0; + esp_wifi_get_mode(&mode); + // Always serve captive.html in AP mode + if (mode == WIFI_MODE_AP || mode == WIFI_MODE_APSTA) + { + if (strcmp(uri, "/") == 0 || strcmp(uri, "/index.html") == 0) + { + uri = "/captive.html"; + } + } + else + { + // 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); + + // Serve captive.html directly (status 200, text/html) + const char *base_path = CONFIG_API_SERVER_STATIC_FILES_PATH; + char filepath[256]; + snprintf(filepath, sizeof(filepath), "%s/captive.html", base_path); + FILE *f = fopen(filepath, "r"); + if (!f) + { + ESP_LOGE(TAG, "captive.html not found: %s", filepath); + httpd_resp_set_status(req, "500 Internal Server Error"); + httpd_resp_sendstr(req, "Captive portal not available"); + return ESP_FAIL; + } + httpd_resp_set_type(req, "text/html"); + 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 captive chunk"); + return ESP_FAIL; + } + } + fclose(f); + httpd_resp_send_chunk(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..69e57f8 --- /dev/null +++ b/firmware/components/api-server/src/api_server.c @@ -0,0 +1,200 @@ +#include "api_server.h" +#include "api_handlers.h" +#include "websocket_handler.h" + +#include "common.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; + } + + // Common initialization + common_init(); + + 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/common.c b/firmware/components/api-server/src/common.c new file mode 100644 index 0000000..f3ed6d4 --- /dev/null +++ b/firmware/components/api-server/src/common.c @@ -0,0 +1,79 @@ +#include "common.h" +#include +#include + +#include "api_server.h" +#include "color.h" +#include "message_manager.h" +#include "persistence_manager.h" +#include "simulator.h" +#include +#include + +const char *system_time = NULL; +rgb_t color = {0, 0, 0}; + +static void on_message_received(const message_t *msg) +{ + if (msg->type == MESSAGE_TYPE_SIMULATION) + { + system_time = msg->data.simulation.time; + color.red = msg->data.simulation.red; + color.green = msg->data.simulation.green; + color.blue = msg->data.simulation.blue; + + cJSON *json = create_light_status_json(); + cJSON_AddStringToObject(json, "type", "status"); + char *response = cJSON_PrintUnformatted(json); + cJSON_Delete(json); + api_server_ws_broadcast(response); + free(response); + } +} + +void common_init(void) +{ + message_manager_register_listener(on_message_received); +} + +// Gibt ein cJSON-Objekt with dem aktuellen Lichtstatus zurΓΌck +cJSON *create_light_status_json(void) +{ + persistence_manager_t pm; + persistence_manager_init(&pm, "config"); + cJSON *json = cJSON_CreateObject(); + + bool light_active = persistence_manager_get_bool(&pm, "light_active", false); + cJSON_AddBoolToObject(json, "on", light_active); + + cJSON_AddBoolToObject(json, "thunder", false); + + int mode = persistence_manager_get_int(&pm, "light_mode", 0); + const char *mode_str = "simulation"; + if (mode == 1) + { + mode_str = "day"; + } + else if (mode == 2) + { + mode_str = "night"; + } + cJSON_AddStringToObject(json, "mode", mode_str); + + int variant = persistence_manager_get_int(&pm, "light_variant", 3); + char schema_filename[20]; + snprintf(schema_filename, sizeof(schema_filename), "schema_%02d.csv", variant); + cJSON_AddStringToObject(json, "schema", schema_filename); + + persistence_manager_deinit(&pm); + + cJSON *c = cJSON_CreateObject(); + cJSON_AddNumberToObject(c, "r", color.red); + cJSON_AddNumberToObject(c, "g", color.green); + cJSON_AddNumberToObject(c, "b", color.blue); + cJSON_AddItemToObject(json, "color", c); + + cJSON_AddStringToObject(json, "clock", system_time); + + return json; +} 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..310c7b1 --- /dev/null +++ b/firmware/components/api-server/src/websocket_handler.c @@ -0,0 +1,276 @@ +#include "websocket_handler.h" +#include "api_server.h" +#include "common.h" + +#include "message_manager.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 on_message_received(const message_t *msg) +{ + cJSON *json = create_light_status_json(); + cJSON_AddStringToObject(json, "type", "status"); + char *response = cJSON_PrintUnformatted(json); + cJSON_Delete(json); + api_server_ws_broadcast(response); + free(response); +} + +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 bool 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 true; + } + } + ESP_LOGW(TAG, "Max WebSocket clients reached, cannot add fd=%d", fd); + return false; +} + +// 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) + { + // 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, + .type = HTTPD_WS_TYPE_TEXT, + .payload = (uint8_t *)response, + .len = strlen(response)}; + + esp_err_t ret = httpd_ws_send_frame(req, &ws_resp); + free(response); + return ret; + } + + 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"); + int fd = httpd_req_to_sockfd(req); + if (!add_client(fd)) + { + // Zu viele Clients, Verbindung schließen + httpd_sess_trigger_close(req->handle, fd); + return ESP_FAIL; + } + 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) +{ + message_manager_register_listener(on_message_received); + + 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..a57233e 100644 --- a/firmware/components/connectivity-manager/CMakeLists.txt +++ b/firmware/components/connectivity-manager/CMakeLists.txt @@ -2,6 +2,7 @@ idf_component_register(SRCS src/ble/ble_connection.c src/ble/ble_scanner.c src/ble_manager.c + src/dns_hijack.c src/wifi_manager.c INCLUDE_DIRS "include" REQUIRES @@ -9,5 +10,7 @@ idf_component_register(SRCS driver nvs_flash esp_insights + analytics led-manager + api-server ) diff --git a/firmware/components/connectivity-manager/include/dns_hijack.h b/firmware/components/connectivity-manager/include/dns_hijack.h new file mode 100644 index 0000000..4638e52 --- /dev/null +++ b/firmware/components/connectivity-manager/include/dns_hijack.h @@ -0,0 +1,13 @@ +#pragma once + +#ifdef __cplusplus +extern "C" +{ +#endif + + void dns_server_start(const char *ap_ip); + void dns_set_ap_ip(const char *ip); + +#ifdef __cplusplus +} +#endif diff --git a/firmware/components/connectivity-manager/src/dns_hijack.c b/firmware/components/connectivity-manager/src/dns_hijack.c new file mode 100644 index 0000000..eb5657b --- /dev/null +++ b/firmware/components/connectivity-manager/src/dns_hijack.c @@ -0,0 +1,79 @@ +// Minimaler DNS-Server fΓΌr Captive Portal (alle Anfragen auf AP-IP) +// Quelle: https://github.com/espressif/esp-idf/blob/master/examples/protocols/sntp/main/dns_server.c (angepasst) +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define DNS_PORT 53 +#define DNS_MAX_LEN 512 +static const char *TAG = "dns_hijack"; + +static char s_ap_ip[16] = "192.168.4.1"; // Default AP-IP, ggf. dynamisch setzen + +void dns_set_ap_ip(const char *ip) +{ + strncpy(s_ap_ip, ip, sizeof(s_ap_ip) - 1); + s_ap_ip[sizeof(s_ap_ip) - 1] = 0; +} + +static void dns_server_task(void *pvParameters) +{ + int sock = socket(AF_INET, SOCK_DGRAM, 0); + if (sock < 0) + { + ESP_LOGE(TAG, "Failed to create socket"); + vTaskDelete(NULL); + return; + } + struct sockaddr_in server_addr = {0}; + server_addr.sin_family = AF_INET; + server_addr.sin_port = htons(DNS_PORT); + server_addr.sin_addr.s_addr = htonl(INADDR_ANY); + bind(sock, (struct sockaddr *)&server_addr, sizeof(server_addr)); + + uint8_t buf[DNS_MAX_LEN]; + while (1) + { + struct sockaddr_in from; + socklen_t fromlen = sizeof(from); + int len = recvfrom(sock, buf, sizeof(buf), 0, (struct sockaddr *)&from, &fromlen); + if (len < 0) + continue; + // DNS Header: 12 bytes, Antwort-Flag setzen + buf[2] |= 0x80; // QR=1 (Antwort) + buf[3] = 0x80; // RA=1, RCODE=0 + // Fragen: 1, Antworten: 1 + buf[7] = 1; + // Antwort anhΓ€ngen (Name Pointer auf Frage) + int pos = len; + buf[pos++] = 0xC0; + buf[pos++] = 0x0C; // Name pointer + buf[pos++] = 0x00; + buf[pos++] = 0x01; // Type A + buf[pos++] = 0x00; + buf[pos++] = 0x01; // Class IN + buf[pos++] = 0x00; + buf[pos++] = 0x00; + buf[pos++] = 0x00; + buf[pos++] = 0x3C; // TTL 60s + buf[pos++] = 0x00; + buf[pos++] = 0x04; // Data length + inet_pton(AF_INET, s_ap_ip, &buf[pos]); + pos += 4; + sendto(sock, buf, pos, 0, (struct sockaddr *)&from, fromlen); + } + close(sock); + vTaskDelete(NULL); +} + +void dns_server_start(const char *ap_ip) +{ + dns_set_ap_ip(ap_ip); + xTaskCreate(dns_server_task, "dns_server", 4096, NULL, 3, NULL); +} diff --git a/firmware/components/connectivity-manager/src/wifi_manager.c b/firmware/components/connectivity-manager/src/wifi_manager.c index 559bb1a..612eb81 100644 --- a/firmware/components/connectivity-manager/src/wifi_manager.c +++ b/firmware/components/connectivity-manager/src/wifi_manager.c @@ -1,5 +1,8 @@ #include "wifi_manager.h" +#include "dns_hijack.h" +#include "analytics.h" +#include "api_server.h" #include #include #include @@ -11,219 +14,166 @@ #include #include #include +#include #include +#include #include #include -// Event group to signal when we are connected +// Event group to signal WiFi connection status static EventGroupHandle_t s_wifi_event_group; -// The bits for the event group +// Event group bits #define WIFI_CONNECTED_BIT BIT0 #define WIFI_FAIL_BIT BIT1 static const char *TAG = "wifi_manager"; -static int s_retry_num = 0; -static int s_current_network_index = 0; - -// WiFi network configuration structure -typedef struct +static void led_status_reconnect() { - const char *ssid; - const char *password; -} wifi_network_config_t; - -// Array of configured WiFi networks -static const wifi_network_config_t s_wifi_networks[] = { -#if CONFIG_WIFI_ENABLED - {CONFIG_WIFI_SSID_1, CONFIG_WIFI_PASSWORD_1}, -#if CONFIG_WIFI_NETWORK_COUNT >= 2 - {CONFIG_WIFI_SSID_2, CONFIG_WIFI_PASSWORD_2}, -#endif -#if CONFIG_WIFI_NETWORK_COUNT >= 3 - {CONFIG_WIFI_SSID_3, CONFIG_WIFI_PASSWORD_3}, -#endif -#if CONFIG_WIFI_NETWORK_COUNT >= 4 - {CONFIG_WIFI_SSID_4, CONFIG_WIFI_PASSWORD_4}, -#endif -#if CONFIG_WIFI_NETWORK_COUNT >= 5 - {CONFIG_WIFI_SSID_5, CONFIG_WIFI_PASSWORD_5}, -#endif -#endif -}; - -static const int s_wifi_network_count = sizeof(s_wifi_networks) / sizeof(s_wifi_networks[0]); - -static void try_next_network(void); - -static void connect_to_network(int index) -{ -#if CONFIG_WIFI_ENABLED - if (index >= s_wifi_network_count) - { - ESP_LOGE(TAG, "No more networks to try"); - xEventGroupSetBits(s_wifi_event_group, WIFI_FAIL_BIT); - return; - } - - const wifi_network_config_t *network = &s_wifi_networks[index]; - - // Skip empty SSIDs - if (network->ssid == NULL || strlen(network->ssid) == 0) - { - ESP_LOGW(TAG, "Skipping empty SSID at index %d", index); - s_current_network_index++; - s_retry_num = 0; - try_next_network(); - return; - } - - ESP_DIAG_EVENT(TAG, "Trying to connect to network %d: %s", index + 1, network->ssid); - - wifi_config_t wifi_config = { - .sta = - { - .threshold.authmode = WIFI_AUTH_WPA2_PSK, - }, + led_behavior_t led_behavior = { + .on_time_ms = 250, + .off_time_ms = 100, + .color = {.red = 50, .green = 50, .blue = 0}, + .index = 0, + .mode = LED_MODE_BLINK, }; - - strncpy((char *)wifi_config.sta.ssid, network->ssid, sizeof(wifi_config.sta.ssid) - 1); - strncpy((char *)wifi_config.sta.password, network->password, sizeof(wifi_config.sta.password) - 1); - - ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config)); - esp_wifi_connect(); -#endif + led_status_set_behavior(led_behavior); } -static void try_next_network(void) +static void wifi_event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data) { -#if CONFIG_WIFI_ENABLED - s_current_network_index++; - s_retry_num = 0; - - if (s_current_network_index < s_wifi_network_count) - { - connect_to_network(s_current_network_index); - } - else - { - ESP_LOGE(TAG, "Failed to connect to any configured network"); - led_behavior_t led0_behavior = { - .index = 0, - .mode = LED_MODE_BLINK, - .color = {.red = 50, .green = 0, .blue = 0}, - .on_time_ms = 1000, - .off_time_ms = 500, - }; - led_status_set_behavior(led0_behavior); - xEventGroupSetBits(s_wifi_event_group, WIFI_FAIL_BIT); - } -#endif -} - -static void event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data) -{ -#if CONFIG_WIFI_ENABLED if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) { - led_behavior_t led0_behavior = { - .index = 0, - .mode = LED_MODE_BLINK, - .color = {.red = 50, .green = 50, .blue = 0}, - .on_time_ms = 200, - .off_time_ms = 200, - }; - led_status_set_behavior(led0_behavior); - - connect_to_network(s_current_network_index); + ESP_LOGI(TAG, "WIFI_EVENT_STA_START: Connecting to AP..."); + esp_wifi_connect(); } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) { - if (s_retry_num < CONFIG_WIFI_CONNECT_RETRIES) - { - led_behavior_t led0_behavior = { - .index = 0, - .mode = LED_MODE_BLINK, - .color = {.red = 50, .green = 50, .blue = 0}, - .on_time_ms = 200, - .off_time_ms = 200, - }; - led_status_set_behavior(led0_behavior); - - s_retry_num++; - ESP_DIAG_EVENT(TAG, "Retrying network %d (%d/%d)", s_current_network_index + 1, s_retry_num, - CONFIG_WIFI_CONNECT_RETRIES); - esp_wifi_connect(); - return; - } - - // Retries exhausted for current network, try next one - ESP_LOGW(TAG, "Failed to connect to network %d after %d retries, trying next...", s_current_network_index + 1, - CONFIG_WIFI_CONNECT_RETRIES); - try_next_network(); + ESP_LOGW(TAG, "WIFI_EVENT_STA_DISCONNECTED: Verbindung verloren, versuche erneut..."); + xEventGroupSetBits(s_wifi_event_group, WIFI_FAIL_BIT); + led_status_reconnect(); + esp_wifi_connect(); } else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) { - led_behavior_t led0_behavior = { - .index = 0, - .mode = LED_MODE_SOLID, - .color = {.red = 0, .green = 50, .blue = 0}, - }; - led_status_set_behavior(led0_behavior); - ip_event_got_ip_t *event = (ip_event_got_ip_t *)event_data; - ESP_DIAG_EVENT(TAG, "Got IP address:" IPSTR " (network %d: %s)", IP2STR(&event->ip_info.ip), - s_current_network_index + 1, s_wifi_networks[s_current_network_index].ssid); - s_retry_num = 0; + ESP_LOGI(TAG, "IP_EVENT_STA_GOT_IP: IP-Adresse erhalten: " IPSTR, IP2STR(&event->ip_info.ip)); + analytics_init(); xEventGroupSetBits(s_wifi_event_group, WIFI_CONNECTED_BIT); } -#endif + else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_LOST_IP) + { + ESP_LOGW(TAG, "IP_EVENT_STA_LOST_IP: IP-Adresse verloren! Versuche Reconnect..."); + xEventGroupSetBits(s_wifi_event_group, WIFI_FAIL_BIT); + led_status_reconnect(); + esp_wifi_connect(); + } +} + +static void wifi_create_ap() +{ + ESP_ERROR_CHECK(esp_wifi_stop()); + ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_AP)); + wifi_config_t ap_config = {.ap = {.ssid = "system-control", + .ssid_len = strlen("system-control"), + .password = "", + .max_connection = 4, + .authmode = WIFI_AUTH_OPEN}}; + ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_AP, &ap_config)); + ESP_ERROR_CHECK(esp_wifi_start()); + ESP_LOGI(TAG, "Access Point 'system-control' started"); + dns_server_start("192.168.4.1"); + + led_behavior_t led_behavior = { + .color = {.red = 50, .green = 0, .blue = 0}, + .index = 0, + .mode = LED_MODE_SOLID, + }; + led_status_set_behavior(led_behavior); } void wifi_manager_init() { -#if CONFIG_WIFI_ENABLED s_wifi_event_group = xEventGroupCreate(); - s_current_network_index = 0; - s_retry_num = 0; ESP_ERROR_CHECK(esp_netif_init()); - ESP_ERROR_CHECK(esp_event_loop_create_default()); - esp_netif_create_default_wifi_sta(); - wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); - ESP_ERROR_CHECK(esp_wifi_init(&cfg)); + // Default WiFi Station + esp_netif_t *sta_netif = esp_netif_create_default_wifi_sta(); - esp_event_handler_instance_t instance_any_id; - esp_event_handler_instance_t instance_got_ip; - ESP_ERROR_CHECK( - esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &event_handler, NULL, &instance_any_id)); - ESP_ERROR_CHECK( - esp_event_handler_instance_register(IP_EVENT, IP_EVENT_STA_GOT_IP, &event_handler, NULL, &instance_got_ip)); + // Event Handler registrieren + ESP_ERROR_CHECK(esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &wifi_event_handler, NULL, NULL)); + ESP_ERROR_CHECK(esp_event_handler_instance_register(IP_EVENT, ESP_EVENT_ANY_ID, &wifi_event_handler, NULL, NULL)); - ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); - ESP_ERROR_CHECK(esp_wifi_start()); - - ESP_DIAG_EVENT(TAG, "WiFi manager initialized with %d network(s), waiting for connection...", s_wifi_network_count); - - /* Waiting until either the connection is established (WIFI_CONNECTED_BIT) or - connection failed for all networks (WIFI_FAIL_BIT). The bits are set by event_handler() */ - EventBits_t bits = - xEventGroupWaitBits(s_wifi_event_group, WIFI_CONNECTED_BIT | WIFI_FAIL_BIT, pdFALSE, pdFALSE, portMAX_DELAY); - - if (bits & WIFI_CONNECTED_BIT) + // Try to load stored WiFi configuration + persistence_manager_t pm; + char ssid[33] = {0}; + char password[65] = {0}; + bool have_ssid = false, have_password = false; + if (persistence_manager_init(&pm, "wifi_config") == ESP_OK) { - ESP_DIAG_EVENT(TAG, "Connected to AP SSID:%s", s_wifi_networks[s_current_network_index].ssid); + persistence_manager_get_string(&pm, "ssid", ssid, sizeof(ssid), ""); + persistence_manager_get_string(&pm, "password", password, sizeof(password), ""); + have_ssid = strlen(ssid) > 0; + have_password = strlen(password) > 0; } - else if (bits & WIFI_FAIL_BIT) + + if (have_ssid && have_password) { - ESP_LOGE(TAG, "Failed to connect to any configured WiFi network"); + led_status_reconnect(); + + ESP_LOGI(TAG, "Found WiFi configuration: SSID='%s'", ssid); + wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); + ESP_ERROR_CHECK(esp_wifi_init(&cfg)); + ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); + wifi_config_t wifi_config = {0}; + strncpy((char *)wifi_config.sta.ssid, ssid, sizeof(wifi_config.sta.ssid) - 1); + strncpy((char *)wifi_config.sta.password, password, sizeof(wifi_config.sta.password) - 1); + wifi_config.sta.threshold.authmode = WIFI_AUTH_WPA2_PSK; + ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config)); + ESP_ERROR_CHECK(esp_wifi_start()); + + int retries = 0; + EventBits_t bits; + do + { + ESP_LOGI(TAG, "Warte auf IP-Adresse (DHCP)..."); + bits = xEventGroupWaitBits(s_wifi_event_group, WIFI_CONNECTED_BIT | WIFI_FAIL_BIT, pdFALSE, pdFALSE, + 10000 / portTICK_PERIOD_MS); + if (bits & WIFI_CONNECTED_BIT) + { + led_behavior_t led_behavior = { + .index = 0, + .color = {.red = 0, .green = 50, .blue = 0}, + .mode = LED_MODE_SOLID, + }; + led_status_set_behavior(led_behavior); + ESP_LOGI(TAG, "WiFi connection established successfully (mit IP)"); + break; + } + retries++; + } while (!(bits & WIFI_CONNECTED_BIT) && retries < CONFIG_WIFI_CONNECT_RETRIES); + + if (!(bits & WIFI_CONNECTED_BIT)) + { + ESP_LOGW(TAG, "WiFi connection failed (keine IP?), switching to Access Point mode"); + // AP-Netzwerkschnittstelle initialisieren, falls noch nicht geschehen + esp_netif_create_default_wifi_ap(); + wifi_create_ap(); + } } else { - ESP_LOGE(TAG, "Unexpected event"); + // Create Access Point + esp_netif_create_default_wifi_ap(); + wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); + ESP_ERROR_CHECK(esp_wifi_init(&cfg)); + wifi_create_ap(); } -#endif + + // API server start + api_server_config_t s_config = API_SERVER_CONFIG_DEFAULT(); + ESP_ERROR_CHECK(api_server_start(&s_config)); } diff --git a/firmware/components/insa/CMakeLists.txt b/firmware/components/insa/CMakeLists.txt index 6c3388d..3e5360f 100644 --- a/firmware/components/insa/CMakeLists.txt +++ b/firmware/components/insa/CMakeLists.txt @@ -18,4 +18,5 @@ idf_component_register(SRCS led-manager persistence-manager simulator + message-manager ) diff --git a/firmware/components/insa/include/MenuOptions.h b/firmware/components/insa/include/MenuOptions.h index e926d8f..99f8b97 100644 --- a/firmware/components/insa/include/MenuOptions.h +++ b/firmware/components/insa/include/MenuOptions.h @@ -17,7 +17,7 @@ // Project-specific headers #include "common/Widget.h" -#include "IPersistenceManager.h" +#include "persistence_manager.h" #include "u8g2.h" class MenuItem; @@ -28,7 +28,7 @@ class MenuItem; * @details This structure serves as a configuration container that provides menu widgets * with access to the display system, screen management functions, input * handling callbacks, and persistent storage. - * + * * @see Widget * @see ButtonType * @see IPersistenceManager @@ -61,14 +61,8 @@ typedef struct std::function onButtonClicked; /** - * @brief Shared pointer to platform-independent persistence manager - * @details This provides access to persistent key-value storage across different - * platforms. The actual implementation (SDL3 or ESP32/NVS) is determined - * at compile time based on the target platform. - * - * @note The persistence manager is shared across all menu widgets and maintains - * its state throughout the application lifecycle. + * @brief Zeiger auf C-Persistence-Manager-Instanz */ - std::shared_ptr persistenceManager; + persistence_manager_t *persistenceManager; } menu_options_t; \ No newline at end of file diff --git a/firmware/components/insa/include/common/Menu.h b/firmware/components/insa/include/common/Menu.h index a9b992a..e8794c3 100644 --- a/firmware/components/insa/include/common/Menu.h +++ b/firmware/components/insa/include/common/Menu.h @@ -175,6 +175,21 @@ class Menu : public Widget */ void toggle(const MenuItem &menuItem); + /** + * @brief Setzt den Zustand eines Toggle-MenΓΌeintrags explizit + * @param menuItem Der zu Γ€ndernde Toggle-MenΓΌeintrag + * @param state Neuer Zustand (true = aktiviert, false = deaktiviert) + * + * @pre menuItem muss vom Typ TOGGLE sein + * @post Der Wert des MenΓΌeintrags wird auf den angegebenen Zustand gesetzt + * + * @details Diese Methode setzt den Wert eines Toggle-MenΓΌeintrags gezielt auf den gewΓΌnschten Zustand. + * Der geΓ€nderte Eintrag ersetzt das Original in der internen MenΓΌstruktur. + * + * @note Diese Methode verΓ€ndert direkt den internen Zustand des MenΓΌs. + */ + void setToggle(const MenuItem &menuItem, const bool state); + /** * @brief Changes the selected value of a selection menu item based on button input * @param menuItem The selection menu item to modify @@ -191,6 +206,8 @@ class Menu : public Widget */ MenuItem switchValue(const MenuItem &menuItem, ButtonType button); + void setSelectionIndex(const MenuItem &menuItem, int index); + private: MenuItem replaceItem(int index, const MenuItem &item); diff --git a/firmware/components/insa/include/common/Widget.h b/firmware/components/insa/include/common/Widget.h index 603c6b4..a6d03ca 100644 --- a/firmware/components/insa/include/common/Widget.h +++ b/firmware/components/insa/include/common/Widget.h @@ -13,6 +13,7 @@ #include "u8g2.h" #include "common/Common.h" +#include "message_manager.h" /** * @class Widget @@ -49,7 +50,9 @@ class Widget * @details Ensures that derived class destructors are called correctly when * a widget is destroyed through a base class pointer. */ - virtual ~Widget() = default; + virtual ~Widget(); + + virtual void onMessageReceived(const message_t *msg); /** * @brief Called when the widget becomes active or enters the foreground @@ -178,4 +181,8 @@ class Widget * the u8g2 context and assumes it is managed externally. */ u8g2_t *u8g2; + + private: + static std::vector s_instances; + static void globalMessageCallback(const message_t *msg); }; \ No newline at end of file diff --git a/firmware/components/insa/include/ui/LightMenu.h b/firmware/components/insa/include/ui/LightMenu.h index 10a4d43..d393466 100644 --- a/firmware/components/insa/include/ui/LightMenu.h +++ b/firmware/components/insa/include/ui/LightMenu.h @@ -120,6 +120,8 @@ class LightMenu final : public Menu */ void onButtonPressed(const MenuItem &menuItem, ButtonType button) override; + void onMessageReceived(const message_t *msg); + /** * @brief Pointer to menu options configuration structure * @details Stores a reference to the menu configuration passed during construction. diff --git a/firmware/components/insa/src/common/Menu.cpp b/firmware/components/insa/src/common/Menu.cpp index 3f5742a..efacc65 100644 --- a/firmware/components/insa/src/common/Menu.cpp +++ b/firmware/components/insa/src/common/Menu.cpp @@ -28,7 +28,7 @@ constexpr int BOTTOM_OFFSET = 10; Menu::Menu(menu_options_t *options) : Widget(options->u8g2), m_options(options) { - // Set up button callback using lambda to forward to member function + // Set up button callback using a lambda to forward to the member function m_options->onButtonClicked = [this](const ButtonType button) { OnButtonClicked(button); }; } @@ -64,7 +64,7 @@ void Menu::setItemSize(const size_t size, int8_t startIndex) constexpr int key_length = 20; char key[key_length] = ""; snprintf(key, key_length, "section_%zu", i + 1 - startIndex); - index = m_options->persistenceManager->GetValue(key, index); + index = persistence_manager_get_int(m_options->persistenceManager, key, index); } addSelection(i + 1, caption, m_items.at(0).getValues(), index); } @@ -82,6 +82,12 @@ void Menu::toggle(const MenuItem &menuItem) replaceItem(menuItem.getId(), item); } +void Menu::setToggle(const MenuItem &menuItem, const bool state) +{ + const auto item = menuItem.copyWith(state ? std::to_string(true) : std::to_string(false)); + replaceItem(menuItem.getId(), item); +} + MenuItem Menu::switchValue(const MenuItem &menuItem, ButtonType button) { MenuItem result = menuItem; @@ -120,6 +126,15 @@ MenuItem Menu::switchValue(const MenuItem &menuItem, ButtonType button) return result; } +void Menu::setSelectionIndex(const MenuItem &menuItem, int index) +{ + if (index >= 0 && index < menuItem.getItemCount()) + { + auto item = menuItem.copyWith(index); + replaceItem(menuItem.getId(), item); + } +} + MenuItem Menu::replaceItem(const int index, const MenuItem &item) { m_items.at(index) = item; @@ -134,13 +149,13 @@ void Menu::Render() m_selected_item = 0; } - // Early return if no items to render + // Early return if there are no items to render if (m_items.empty()) { return; } - // Clear screen with black background + // Clear the screen with a black background u8g2_SetDrawColor(u8g2, 0); u8g2_DrawBox(u8g2, 0, 0, u8g2->width, u8g2->height); @@ -151,7 +166,7 @@ void Menu::Render() drawScrollBar(); drawSelectionBox(); - // Calculate center position for main item + // Calculate center position for the main item const int centerY = u8g2->height / 2 + 3; // Render the currently selected item (main/center item) @@ -176,7 +191,7 @@ void Menu::Render() void Menu::renderWidget(const MenuItem *item, const uint8_t *font, const int x, const int y) const { - // Set font and draw main text + // Set font and draw the main text u8g2_SetFont(u8g2, font); u8g2_DrawStr(u8g2, x, y, item->getText().c_str()); @@ -206,7 +221,7 @@ void Menu::renderWidget(const MenuItem *item, const uint8_t *font, const int x, } case MenuItemTypes::TOGGLE: { - // Draw checkbox frame + // Draw the checkbox frame const int frameX = u8g2->width - UIConstants::FRAME_BOX_SIZE - UIConstants::SELECTION_MARGIN; const int frameY = y - UIConstants::FRAME_OFFSET; u8g2_DrawFrame(u8g2, frameX, frameY, UIConstants::FRAME_BOX_SIZE, UIConstants::FRAME_BOX_SIZE); @@ -272,7 +287,7 @@ void Menu::onPressedDown() if (m_items.empty()) return; - // Wrap around to first item when at the end + // Wrap around to the first item when at the end m_selected_item = (m_selected_item + 1) % m_items.size(); } @@ -281,7 +296,7 @@ void Menu::onPressedUp() if (m_items.empty()) return; - // Wrap around to last item when at the beginning + // Wrap around to the last item when at the beginning m_selected_item = (m_selected_item == 0) ? m_items.size() - 1 : m_selected_item - 1; } @@ -314,7 +329,7 @@ void Menu::onPressedSelect() const void Menu::onPressedBack() const { - // Navigate back to previous screen if callback is available + // Navigate back to the previous screen if callback is available if (m_options && m_options->popScreen) { m_options->popScreen(); diff --git a/firmware/components/insa/src/common/Widget.cpp b/firmware/components/insa/src/common/Widget.cpp index 16054b0..274efd9 100644 --- a/firmware/components/insa/src/common/Widget.cpp +++ b/firmware/components/insa/src/common/Widget.cpp @@ -1,7 +1,20 @@ #include "common/Widget.h" +#include + +std::vector Widget::s_instances; Widget::Widget(u8g2_t *u8g2) : u8g2(u8g2) { + s_instances.push_back(this); + if (s_instances.size() == 1) + { + message_manager_register_listener(globalMessageCallback); + } +} + +Widget::~Widget() +{ + s_instances.erase(std::remove(s_instances.begin(), s_instances.end(), this), s_instances.end()); } void Widget::onEnter() @@ -36,3 +49,15 @@ const char *Widget::getName() const { return "Widget"; } + +void Widget::onMessageReceived(const message_t *msg) +{ +} + +void Widget::globalMessageCallback(const message_t *msg) +{ + for (auto *w : s_instances) + { + w->onMessageReceived(msg); + } +} diff --git a/firmware/components/insa/src/ui/ClockScreenSaver.cpp b/firmware/components/insa/src/ui/ClockScreenSaver.cpp index 0b45171..3692c37 100644 --- a/firmware/components/insa/src/ui/ClockScreenSaver.cpp +++ b/firmware/components/insa/src/ui/ClockScreenSaver.cpp @@ -1,5 +1,5 @@ #include "ui/ClockScreenSaver.h" -#include "hal_esp32/PersistenceManager.h" +#include "persistence_manager.h" #include "simulator.h" #include #include @@ -38,13 +38,14 @@ void ClockScreenSaver::updateTextDimensions() void ClockScreenSaver::getCurrentTimeString(char *buffer, size_t bufferSize) const { - if (m_options && m_options->persistenceManager->GetValue("light_active", false) && - m_options->persistenceManager->GetValue("light_mode", 0) == 0) + if (m_options && m_options->persistenceManager && + persistence_manager_get_bool(m_options->persistenceManager, "light_active", false) && + persistence_manager_get_int(m_options->persistenceManager, "light_mode", 0) == 0) { char *simulated_time = get_time(); if (simulated_time != nullptr) { - strncpy(buffer, simulated_time, bufferSize); + snprintf(buffer, bufferSize, "%s Uhr", simulated_time); return; } } diff --git a/firmware/components/insa/src/ui/LightMenu.cpp b/firmware/components/insa/src/ui/LightMenu.cpp index 05dfb71..4adbb53 100644 --- a/firmware/components/insa/src/ui/LightMenu.cpp +++ b/firmware/components/insa/src/ui/LightMenu.cpp @@ -1,7 +1,8 @@ #include "ui/LightMenu.h" - #include "led_strip_ws2812.h" +#include "message_manager.h" #include "simulator.h" +#include /** * @namespace LightMenuItem @@ -27,7 +28,7 @@ LightMenu::LightMenu(menu_options_t *options) : Menu(options), m_options(options bool active = false; if (m_options && m_options->persistenceManager) { - active = m_options->persistenceManager->GetValue(LightMenuOptions::LIGHT_ACTIVE, active); + active = persistence_manager_get_bool(m_options->persistenceManager, LightMenuOptions::LIGHT_ACTIVE, active); } addToggle(LightMenuItem::ACTIVATE, "Einschalten", active); @@ -39,7 +40,8 @@ LightMenu::LightMenu(menu_options_t *options) : Menu(options), m_options(options int mode_value = 0; if (m_options && m_options->persistenceManager) { - mode_value = m_options->persistenceManager->GetValue(LightMenuOptions::LIGHT_MODE, mode_value); + mode_value = + persistence_manager_get_int(m_options->persistenceManager, LightMenuOptions::LIGHT_MODE, mode_value); } addSelection(LightMenuItem::MODE, "Modus", items, mode_value); @@ -50,7 +52,9 @@ LightMenu::LightMenu(menu_options_t *options) : Menu(options), m_options(options int variant_value = 3; if (m_options && m_options->persistenceManager) { - variant_value = m_options->persistenceManager->GetValue(LightMenuOptions::LIGHT_VARIANT, variant_value) - 1; + variant_value = + persistence_manager_get_int(m_options->persistenceManager, LightMenuOptions::LIGHT_VARIANT, variant_value) - + 1; } addSelection(LightMenuItem::VARIANT, "Variante", variants, variant_value); } @@ -68,12 +72,13 @@ void LightMenu::onButtonPressed(const MenuItem &menuItem, const ButtonType butto { toggle(menuItem); const auto value = getItem(menuItem.getId()).getValue() == "1"; - if (m_options && m_options->persistenceManager) - { - m_options->persistenceManager->SetValue(LightMenuOptions::LIGHT_ACTIVE, value); - } - - start_simulation(); + // Post change via message_manager + message_t msg = {}; + msg.type = MESSAGE_TYPE_SETTINGS; + msg.data.settings.type = SETTINGS_TYPE_BOOL; + strncpy(msg.data.settings.key, LightMenuOptions::LIGHT_ACTIVE, sizeof(msg.data.settings.key) - 1); + msg.data.settings.value.bool_value = value; + message_manager_post(&msg, pdMS_TO_TICKS(100)); } break; } @@ -86,11 +91,14 @@ void LightMenu::onButtonPressed(const MenuItem &menuItem, const ButtonType butto const auto value = getItem(item.getId()).getIndex(); if (m_options && m_options->persistenceManager) { - m_options->persistenceManager->SetValue(LightMenuOptions::LIGHT_MODE, value); - m_options->persistenceManager->Save(); + // Post change via message_manager + message_t msg = {}; + msg.type = MESSAGE_TYPE_SETTINGS; + msg.data.settings.type = SETTINGS_TYPE_INT; + strncpy(msg.data.settings.key, LightMenuOptions::LIGHT_MODE, sizeof(msg.data.settings.key) - 1); + msg.data.settings.value.int_value = value; + message_manager_post(&msg, pdMS_TO_TICKS(100)); } - - start_simulation(); } break; } @@ -103,11 +111,14 @@ void LightMenu::onButtonPressed(const MenuItem &menuItem, const ButtonType butto const auto value = getItem(item.getId()).getIndex() + 1; if (m_options && m_options->persistenceManager) { - m_options->persistenceManager->SetValue(LightMenuOptions::LIGHT_VARIANT, value); - m_options->persistenceManager->Save(); + // Post change via message_manager + message_t msg = {}; + msg.type = MESSAGE_TYPE_SETTINGS; + msg.data.settings.type = SETTINGS_TYPE_INT; + strncpy(msg.data.settings.key, LightMenuOptions::LIGHT_VARIANT, sizeof(msg.data.settings.key) - 1); + msg.data.settings.value.int_value = value; + message_manager_post(&msg, pdMS_TO_TICKS(100)); } - - start_simulation(); } break; } @@ -124,4 +135,25 @@ void LightMenu::onButtonPressed(const MenuItem &menuItem, const ButtonType butto } } +void LightMenu::onMessageReceived(const message_t *msg) +{ + // Here you can react to messages, e.g. set toggle status + // Example: If light_active was changed, synchronize toggle + if (msg && msg->type == MESSAGE_TYPE_SETTINGS) + { + if (std::strcmp(msg->data.settings.key, LightMenuOptions::LIGHT_ACTIVE) == 0) + { + setToggle(getItem(LightMenuItem::ACTIVATE), msg->data.settings.value.bool_value); + } + if (std::strcmp(msg->data.settings.key, LightMenuOptions::LIGHT_MODE) == 0) + { + setSelectionIndex(getItem(LightMenuItem::MODE), msg->data.settings.value.int_value); + } + if (std::strcmp(msg->data.settings.key, LightMenuOptions::LIGHT_VARIANT) == 0) + { + setSelectionIndex(getItem(LightMenuItem::VARIANT), msg->data.settings.value.int_value - 1); + } + } +} + IMPLEMENT_GET_NAME(LightMenu) diff --git a/firmware/components/led-manager/include/color.h b/firmware/components/led-manager/include/color.h index 6de1694..fee62f1 100644 --- a/firmware/components/led-manager/include/color.h +++ b/firmware/components/led-manager/include/color.h @@ -12,9 +12,9 @@ typedef struct typedef struct { - uint8_t h; - uint8_t s; - uint8_t v; + float h; + float s; + float v; } hsv_t; __BEGIN_DECLS diff --git a/firmware/components/led-manager/include/led_segment.h b/firmware/components/led-manager/include/led_segment.h new file mode 100644 index 0000000..8a1af7c --- /dev/null +++ b/firmware/components/led-manager/include/led_segment.h @@ -0,0 +1,16 @@ +#pragma once + +#include +#include + +#define LED_SEGMENT_MAX_LEN 15 + +typedef struct +{ + char name[32]; + uint16_t start; + uint16_t leds; +} led_segment_t; + +led_segment_t segments[LED_SEGMENT_MAX_LEN]; +size_t segment_count; diff --git a/firmware/components/led-manager/src/color.c b/firmware/components/led-manager/src/color.c index 28ae1ac..771a517 100644 --- a/firmware/components/led-manager/src/color.c +++ b/firmware/components/led-manager/src/color.c @@ -45,10 +45,10 @@ rgb_t interpolate_color_hsv(rgb_t start, rgb_t end, float factor) // Interpolate HSV values hsv_t interpolated_hsv; - interpolated_hsv.h = fmod(h1 + (h2 - h1) * factor, 360.0); - if (interpolated_hsv.h < 0) + interpolated_hsv.h = fmodf(h1 + (h2 - h1) * factor, 360.0f); + if (interpolated_hsv.h < 0.0f) { - interpolated_hsv.h += 360.0; + interpolated_hsv.h += 360.0f; } interpolated_hsv.s = start_hsv.s + (end_hsv.s - start_hsv.s) * factor; interpolated_hsv.v = start_hsv.v + (end_hsv.v - start_hsv.v) * factor; diff --git a/firmware/components/message-manager/CMakeLists.txt b/firmware/components/message-manager/CMakeLists.txt new file mode 100644 index 0000000..32926cd --- /dev/null +++ b/firmware/components/message-manager/CMakeLists.txt @@ -0,0 +1,8 @@ +idf_component_register( + SRCS "src/message_manager.c" + INCLUDE_DIRS "include" + PRIV_REQUIRES + persistence-manager + my_mqtt_client + app_update +) diff --git a/firmware/components/message-manager/include/message_manager.h b/firmware/components/message-manager/include/message_manager.h new file mode 100644 index 0000000..c53a116 --- /dev/null +++ b/firmware/components/message-manager/include/message_manager.h @@ -0,0 +1,79 @@ +#pragma once + +#include +#include +#include +#include + +#ifdef __cplusplus +extern "C" +{ +#endif + + typedef enum + { + MESSAGE_TYPE_SETTINGS, + MESSAGE_TYPE_BUTTON, + MESSAGE_TYPE_SIMULATION + } message_type_t; + + typedef enum + { + BUTTON_EVENT_PRESS, + BUTTON_EVENT_RELEASE + } button_event_type_t; + + typedef struct + { + button_event_type_t event_type; + uint8_t button_id; + } button_message_t; + + typedef enum + { + SETTINGS_TYPE_BOOL, + SETTINGS_TYPE_INT, + SETTINGS_TYPE_FLOAT, + SETTINGS_TYPE_STRING + } settings_type_t; + + typedef struct + { + settings_type_t type; + char key[32]; + union { + bool bool_value; + int32_t int_value; + float float_value; + char string_value[64]; + } value; + } settings_message_t; + + typedef struct + { + char time[6]; + uint8_t red; + uint8_t green; + uint8_t blue; + } simulation_message_t; + + typedef struct + { + message_type_t type; + union { + settings_message_t settings; + button_message_t button; + simulation_message_t simulation; + } data; + } message_t; + + // Observer Pattern: Listener-Typ und Registrierungsfunktionen + typedef void (*message_listener_t)(const message_t *msg); + void message_manager_register_listener(message_listener_t listener); + void message_manager_unregister_listener(message_listener_t listener); + void message_manager_init(void); + bool message_manager_post(const message_t *msg, TickType_t timeout); + +#ifdef __cplusplus +} +#endif diff --git a/firmware/components/message-manager/src/message_manager.c b/firmware/components/message-manager/src/message_manager.c new file mode 100644 index 0000000..5550cae --- /dev/null +++ b/firmware/components/message-manager/src/message_manager.c @@ -0,0 +1,134 @@ +#include "message_manager.h" +#include "my_mqtt_client.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define MESSAGE_QUEUE_LENGTH 16 +#define MESSAGE_QUEUE_ITEM_SIZE sizeof(message_t) + +static const char *TAG = "message_manager"; +static QueueHandle_t message_queue = NULL; + +// Observer Pattern: Listener-Liste +#define MAX_MESSAGE_LISTENERS 8 +static message_listener_t message_listeners[MAX_MESSAGE_LISTENERS] = {0}; +static size_t message_listener_count = 0; + +void message_manager_register_listener(message_listener_t listener) +{ + if (listener && message_listener_count < MAX_MESSAGE_LISTENERS) + { + // Doppelte Registrierung vermeiden + for (size_t i = 0; i < message_listener_count; ++i) + { + if (message_listeners[i] == listener) + return; + } + message_listeners[message_listener_count++] = listener; + } +} + +void message_manager_unregister_listener(message_listener_t listener) +{ + for (size_t i = 0; i < message_listener_count; ++i) + { + if (message_listeners[i] == listener) + { + // Nachfolgende Listener nach vorne schieben + for (size_t j = i; j < message_listener_count - 1; ++j) + { + message_listeners[j] = message_listeners[j + 1]; + } + message_listeners[--message_listener_count] = NULL; + break; + } + } +} + +static void message_manager_task(void *param) +{ + message_t msg; + persistence_manager_t pm; + while (1) + { + if (xQueueReceive(message_queue, &msg, portMAX_DELAY) == pdTRUE) + { + switch (msg.type) + { + case MESSAGE_TYPE_SETTINGS: + if (persistence_manager_init(&pm, "config") == ESP_OK) + { + switch (msg.data.settings.type) + { + case SETTINGS_TYPE_BOOL: + persistence_manager_set_bool(&pm, msg.data.settings.key, msg.data.settings.value.bool_value); + break; + case SETTINGS_TYPE_INT: + persistence_manager_set_int(&pm, msg.data.settings.key, msg.data.settings.value.int_value); + break; + case SETTINGS_TYPE_FLOAT: + persistence_manager_set_float(&pm, msg.data.settings.key, msg.data.settings.value.float_value); + break; + case SETTINGS_TYPE_STRING: + persistence_manager_set_string(&pm, msg.data.settings.key, + msg.data.settings.value.string_value); + break; + } + persistence_manager_deinit(&pm); + ESP_LOGD(TAG, "Setting written: %s", msg.data.settings.key); + } + break; + case MESSAGE_TYPE_BUTTON: + ESP_LOGD(TAG, "Button event: id=%d, type=%d", msg.data.button.button_id, msg.data.button.event_type); + break; + case MESSAGE_TYPE_SIMULATION: + /// just logging + ESP_LOGD(TAG, "Simulation event: time=%s, color=(%d,%d,%d)", msg.data.simulation.time, + msg.data.simulation.red, msg.data.simulation.green, msg.data.simulation.blue); + break; + } + // Observer Pattern: Listener benachrichtigen + for (size_t i = 0; i < message_listener_count; ++i) + { + if (message_listeners[i]) + { + message_listeners[i](&msg); + } + } + + uint8_t mac[6]; + esp_read_mac(mac, ESP_MAC_WIFI_STA); + const esp_app_desc_t *app_desc = esp_app_get_description(); + char topic[60]; + snprintf(topic, sizeof(topic), "device/%s/%02x%02x", app_desc->project_name, mac[4], mac[5]); + + char *data = "{\"key\":\"value\"}"; + mqtt_client_publish(topic, data, strlen(data), 0, false); + } + } +} + +void message_manager_init(void) +{ + if (!message_queue) + { + message_queue = xQueueCreate(MESSAGE_QUEUE_LENGTH, MESSAGE_QUEUE_ITEM_SIZE); + xTaskCreate(message_manager_task, "message_manager_task", 4096, NULL, 5, NULL); + } +} + +bool message_manager_post(const message_t *msg, TickType_t timeout) +{ + if (!message_queue) + return false; + ESP_LOGD(TAG, "Post: type=%d", msg->type); + return xQueueSend(message_queue, msg, timeout) == pdTRUE; +} diff --git a/firmware/components/my_mqtt_client/CMakeLists.txt b/firmware/components/my_mqtt_client/CMakeLists.txt new file mode 100644 index 0000000..f3c20a5 --- /dev/null +++ b/firmware/components/my_mqtt_client/CMakeLists.txt @@ -0,0 +1,7 @@ +idf_component_register( + SRCS "src/my_mqtt_client.c" + INCLUDE_DIRS "include" + REQUIRES + mqtt + app_update +) \ No newline at end of file diff --git a/firmware/components/my_mqtt_client/Kconfig b/firmware/components/my_mqtt_client/Kconfig new file mode 100644 index 0000000..baf7e88 --- /dev/null +++ b/firmware/components/my_mqtt_client/Kconfig @@ -0,0 +1,21 @@ +menu "MQTT Client Settings" + +config MQTT_CLIENT_BROKER_URL + string "MQTT Broker URL (TLS)" + default "mqtts://example.com:8883" + help + Die Adresse des MQTT-Brokers (z.B. mqtts://broker.example.com:8883) + +config MQTT_CLIENT_USERNAME + string "MQTT Username" + default "user" + help + Benutzername fΓΌr die Authentifizierung (optional) + +config MQTT_CLIENT_PASSWORD + string "MQTT Password" + default "password" + help + Passwort fΓΌr die Authentifizierung (optional) + +endmenu diff --git a/firmware/components/my_mqtt_client/README.md b/firmware/components/my_mqtt_client/README.md new file mode 100644 index 0000000..1d59eb2 --- /dev/null +++ b/firmware/components/my_mqtt_client/README.md @@ -0,0 +1,18 @@ +# MQTT Client Component for ESP-IDF + +Diese Komponente stellt eine einfache MQTT-Client-Implementierung bereit, die Daten an einen TLS-gesicherten MQTT-Broker sendet. + +## Dateien +- mqtt_client.c: Implementierung des MQTT-Clients +- mqtt_client.h: Header-Datei +- CMakeLists.txt: Build-Konfiguration +- Kconfig: Konfiguration fΓΌr die Komponente + +## AbhΓ€ngigkeiten +- ESP-IDF (empfohlen: >= v4.0) +- Komponenten: esp-mqtt, esp-tls + +## Nutzung +1. FΓΌge die Komponente in dein Projekt ein. +2. Passe die Konfiguration in `Kconfig` an. +3. Binde die Komponente in deinem Code ein und nutze die API aus `mqtt_client.h`. diff --git a/firmware/components/my_mqtt_client/idf_component.yml b/firmware/components/my_mqtt_client/idf_component.yml new file mode 100644 index 0000000..a63de67 --- /dev/null +++ b/firmware/components/my_mqtt_client/idf_component.yml @@ -0,0 +1,2 @@ +dependencies: + espressif/mqtt: ^1.0.0 diff --git a/firmware/components/my_mqtt_client/include/my_mqtt_client.h b/firmware/components/my_mqtt_client/include/my_mqtt_client.h new file mode 100644 index 0000000..eab36f9 --- /dev/null +++ b/firmware/components/my_mqtt_client/include/my_mqtt_client.h @@ -0,0 +1,14 @@ +#pragma once +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +void mqtt_client_start(void); +void mqtt_client_publish(const char *topic, const char *data, size_t len, int qos, bool retain); + +#ifdef __cplusplus +} +#endif diff --git a/firmware/components/my_mqtt_client/src/my_mqtt_client.c b/firmware/components/my_mqtt_client/src/my_mqtt_client.c new file mode 100644 index 0000000..ffbe2fd --- /dev/null +++ b/firmware/components/my_mqtt_client/src/my_mqtt_client.c @@ -0,0 +1,122 @@ +#include "my_mqtt_client.h" +#include "esp_app_desc.h" +#include "esp_err.h" +#include "esp_interface.h" +#include "esp_log.h" +#include "esp_mac.h" +#include "esp_system.h" +#include "mqtt_client.h" +#include "sdkconfig.h" + +static const char *TAG = "mqtt_client"; +static esp_mqtt_client_handle_t client = NULL; + +extern const uint8_t isrgrootx1_pem_start[] asm("_binary_isrgrootx1_pem_start"); +extern const uint8_t isrgrootx1_pem_end[] asm("_binary_isrgrootx1_pem_end"); + +static void mqtt_event_handler(void *handler_args, esp_event_base_t base, int32_t event_id, void *event_data) +{ + esp_mqtt_event_handle_t event = event_data; + int msg_id; + + switch ((esp_mqtt_event_id_t)event_id) + { + case MQTT_EVENT_CONNECTED: + ESP_LOGI(TAG, "MQTT_EVENT_CONNECTED"); + msg_id = esp_mqtt_client_subscribe(client, "topic/qos0", 0); + ESP_LOGI(TAG, "sent subscribe successful, msg_id=%d", msg_id); + msg_id = esp_mqtt_client_subscribe(client, "topic/qos1", 1); + ESP_LOGI(TAG, "sent subscribe successful, msg_id=%d", msg_id); + msg_id = esp_mqtt_client_unsubscribe(client, "topic/qos1"); + ESP_LOGI(TAG, "sent unsubscribe successful, msg_id=%d", msg_id); + break; + + case MQTT_EVENT_DISCONNECTED: + ESP_LOGI(TAG, "MQTT_EVENT_DISCONNECTED"); + break; + + case MQTT_EVENT_SUBSCRIBED: + ESP_LOGI(TAG, "MQTT_EVENT_SUBSCRIBED, msg_id=%d, return code=0x%02x ", event->msg_id, (uint8_t)*event->data); + msg_id = esp_mqtt_client_publish(client, "topic/qos0", "data", 0, 0, 0); + ESP_LOGI(TAG, "sent publish successful, msg_id=%d", msg_id); + break; + + case MQTT_EVENT_UNSUBSCRIBED: + ESP_LOGI(TAG, "MQTT_EVENT_UNSUBSCRIBED, msg_id=%d", event->msg_id); + break; + + case MQTT_EVENT_PUBLISHED: + ESP_LOGI(TAG, "MQTT_EVENT_PUBLISHED, msg_id=%d", event->msg_id); + break; + + case MQTT_EVENT_DATA: + ESP_LOGI(TAG, "MQTT_EVENT_DATA:"); + ESP_LOGI(TAG, "TOPIC=%.*s\r\n", event->topic_len, event->topic); + ESP_LOGI(TAG, "DATA=%.*s\r\n", event->data_len, event->data); + break; + + case MQTT_EVENT_ERROR: + ESP_LOGE(TAG, "MQTT_EVENT_ERROR"); + if (event->error_handle) + { + ESP_LOGE(TAG, "error_type: %d", event->error_handle->error_type); + ESP_LOGE(TAG, "esp-tls error code: 0x%x", event->error_handle->esp_tls_last_esp_err); + ESP_LOGE(TAG, "tls_stack_err: 0x%x", event->error_handle->esp_tls_stack_err); + ESP_LOGE(TAG, "transport_sock_errno: %d", event->error_handle->esp_transport_sock_errno); + } + break; + + default: + ESP_LOGI(TAG, "Other event id:%d", event->event_id); + break; + } +} + +void mqtt_client_start(void) +{ + ESP_LOGI(TAG, "Starte MQTT-Client mit URI: %s", CONFIG_MQTT_CLIENT_BROKER_URL); + + uint8_t mac[6]; + esp_read_mac(mac, ESP_MAC_WIFI_STA); + const esp_app_desc_t *app_desc = esp_app_get_description(); + char client_id[60]; + snprintf(client_id, sizeof(client_id), "%s/%02x%02x", app_desc->project_name, mac[4], mac[5]); + + const esp_mqtt_client_config_t mqtt_cfg = { + .broker.address.uri = CONFIG_MQTT_CLIENT_BROKER_URL, + .broker.verification.certificate = (const char *)isrgrootx1_pem_start, + .broker.verification.certificate_len = isrgrootx1_pem_end - isrgrootx1_pem_start, + .credentials.username = CONFIG_MQTT_CLIENT_USERNAME, + .credentials.client_id = client_id, + .credentials.authentication.password = CONFIG_MQTT_CLIENT_PASSWORD, + }; + client = esp_mqtt_client_init(&mqtt_cfg); + if (client == NULL) + { + ESP_LOGE(TAG, "Fehler bei esp_mqtt_client_init!"); + return; + } + esp_mqtt_client_register_event(client, ESP_EVENT_ANY_ID, mqtt_event_handler, NULL); + esp_err_t err = esp_mqtt_client_start(client); + if (err != ESP_OK) + { + ESP_LOGE(TAG, "esp_mqtt_client_start fehlgeschlagen: %s", esp_err_to_name(err)); + } + else + { + ESP_LOGI(TAG, "MQTT-Client gestartet"); + } +} + +void mqtt_client_publish(const char *topic, const char *data, size_t len, int qos, bool retain) +{ + if (client) + { + int msg_id = esp_mqtt_client_publish(client, topic, data, len, qos, retain); + ESP_LOGI(TAG, "Publish: topic=%s, msg_id=%d, qos=%d, retain=%d, len=%d", topic, msg_id, qos, retain, (int)len); + } + else + { + ESP_LOGW(TAG, "Publish aufgerufen, aber Client ist nicht initialisiert!"); + } +} diff --git a/firmware/components/persistence-manager/CMakeLists.txt b/firmware/components/persistence-manager/CMakeLists.txt index ea6266b..e453012 100644 --- a/firmware/components/persistence-manager/CMakeLists.txt +++ b/firmware/components/persistence-manager/CMakeLists.txt @@ -1,5 +1,5 @@ idf_component_register(SRCS - src/PersistenceManager.cpp + src/persistence_manager.c INCLUDE_DIRS "include" REQUIRES nvs_flash diff --git a/firmware/components/persistence-manager/include/IPersistenceManager.h b/firmware/components/persistence-manager/include/IPersistenceManager.h deleted file mode 100644 index 284e733..0000000 --- a/firmware/components/persistence-manager/include/IPersistenceManager.h +++ /dev/null @@ -1,108 +0,0 @@ -#pragma once - -#include -#include - -/** - * @interface IPersistenceManager - * @brief Abstract interface for platform-independent persistence management - * @details This interface defines the contract for key-value storage and retrieval - * systems across different platforms (Desktop/SDL3 and ESP32). - */ -class IPersistenceManager -{ -public: - virtual ~IPersistenceManager() = default; - - /** - * @brief Template methods for type-safe setting and retrieving of values - * @tparam T The type of value to set (must be one of: bool, int, float, double, std::string) - * @param key The key to associate with the value - * @param value The value to store - */ - template - void SetValue(const std::string& key, const T& value) { - static_assert(std::is_same_v || - std::is_same_v || - std::is_same_v || - std::is_same_v || - std::is_same_v, - "Unsupported type for IPersistenceManager"); - SetValueImpl(key, value); - } - - /** - * @brief Template method for type-safe retrieval of values - * @tparam T The type of value to retrieve - * @param key The key to look up - * @param defaultValue The default value to return if the key is not found - * @return The stored value or default value if the key doesn't exist - */ - template - [[nodiscard]] T GetValue(const std::string& key, const T& defaultValue = T{}) const { - return GetValueImpl(key, defaultValue); - } - - /** - * @brief Utility methods for key management - */ - [[nodiscard]] virtual bool HasKey(const std::string& key) const = 0; ///< Check if a key exists - virtual void RemoveKey(const std::string& key) = 0; ///< Remove a key-value pair - virtual void Clear() = 0; ///< Clear all stored data - [[nodiscard]] virtual size_t GetKeyCount() const = 0; ///< Get the number of stored keys - - /** - * @brief Persistence operations - */ - virtual bool Save() = 0; ///< Save data to persistent storage - virtual bool Load() = 0; ///< Load data from persistent storage - -protected: - /** - * @brief Template-specific implementations that must be overridden by derived classes - * @details These methods handle the actual storage and retrieval of different data types - */ - virtual void SetValueImpl(const std::string& key, bool value) = 0; - virtual void SetValueImpl(const std::string& key, int value) = 0; - virtual void SetValueImpl(const std::string& key, float value) = 0; - virtual void SetValueImpl(const std::string& key, double value) = 0; - virtual void SetValueImpl(const std::string& key, const std::string& value) = 0; - - [[nodiscard]] virtual bool GetValueImpl(const std::string& key, bool defaultValue) const = 0; - [[nodiscard]] virtual int GetValueImpl(const std::string& key, int defaultValue) const = 0; - [[nodiscard]] virtual float GetValueImpl(const std::string& key, float defaultValue) const = 0; - [[nodiscard]] virtual double GetValueImpl(const std::string& key, double defaultValue) const = 0; - [[nodiscard]] virtual std::string GetValueImpl(const std::string& key, const std::string& defaultValue) const = 0; - -private: - /** - * @brief Template dispatch methods for type-safe value retrieval - * @tparam T The type to retrieve - * @param key The key to look up - * @param defaultValue The default value to return - * @return The retrieved value or default if not found - */ - template - [[nodiscard]] T GetValueImpl(const std::string& key, const T& defaultValue) const - { - if constexpr (std::is_same_v) { - return GetValueImpl(key, static_cast(defaultValue)); - } else if constexpr (std::is_same_v) { - return GetValueImpl(key, static_cast(defaultValue)); - } else if constexpr (std::is_same_v) { - return GetValueImpl(key, static_cast(defaultValue)); - } else if constexpr (std::is_same_v) { - return GetValueImpl(key, static_cast(defaultValue)); - } else if constexpr (std::is_same_v) { - return GetValueImpl(key, static_cast(defaultValue)); - } else { - static_assert(std::is_same_v || - std::is_same_v || - std::is_same_v || - std::is_same_v || - std::is_same_v, - "Unsupported type for IPersistenceManager"); - return defaultValue; // This line will never be reached, but satisfies the compiler - } - } -}; \ No newline at end of file diff --git a/firmware/components/persistence-manager/include/hal_esp32/PersistenceManager.h b/firmware/components/persistence-manager/include/hal_esp32/PersistenceManager.h deleted file mode 100644 index 723e152..0000000 --- a/firmware/components/persistence-manager/include/hal_esp32/PersistenceManager.h +++ /dev/null @@ -1,58 +0,0 @@ -#pragma once - -#include "IPersistenceManager.h" -#include -#include - -#include -#include - -/** - * @class PersistenceManager - * @brief ESP32-specific implementation using NVS (Non-Volatile Storage) - * @details This implementation uses ESP32's NVS API for persistent storage - * in flash memory, providing a platform-optimized solution for - * embedded systems. - */ -class PersistenceManager final : public IPersistenceManager -{ - private: - nvs_handle_t nvs_handle_; - std::string namespace_; - bool initialized_; - - public: - explicit PersistenceManager(const std::string &nvs_namespace = "config"); - ~PersistenceManager() override; - - bool HasKey(const std::string &key) const override; - void RemoveKey(const std::string &key) override; - void Clear() override; - size_t GetKeyCount() const override; - - bool Save() override; - bool Load() override; - - bool Initialize(); - void Deinitialize(); - bool IsInitialized() const - { - return initialized_; - } - - protected: - void SetValueImpl(const std::string &key, bool value) override; - void SetValueImpl(const std::string &key, int value) override; - void SetValueImpl(const std::string &key, float value) override; - void SetValueImpl(const std::string &key, double value) override; - void SetValueImpl(const std::string &key, const std::string &value) override; - - bool GetValueImpl(const std::string &key, bool defaultValue) const override; - int GetValueImpl(const std::string &key, int defaultValue) const override; - float GetValueImpl(const std::string &key, float defaultValue) const override; - double GetValueImpl(const std::string &key, double defaultValue) const override; - std::string GetValueImpl(const std::string &key, const std::string &defaultValue) const override; - - private: - bool EnsureInitialized() const; -}; \ No newline at end of file diff --git a/firmware/components/persistence-manager/include/persistence_manager.h b/firmware/components/persistence-manager/include/persistence_manager.h new file mode 100644 index 0000000..140952f --- /dev/null +++ b/firmware/components/persistence-manager/include/persistence_manager.h @@ -0,0 +1,237 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#ifdef __cplusplus +extern "C" +{ +#endif + + /** + * @brief Structure to manage persistent storage using NVS. + * + * This struct holds the NVS handle, namespace, and initialization state + * for managing persistent key-value storage on the device. + */ + typedef struct + { + /** Handle to the NVS storage. */ + nvs_handle_t nvs_handle; + /** Namespace used for NVS operations (max 15 chars + null terminator). */ + char nvs_namespace[16]; + /** Indicates if the persistence manager is initialized. */ + bool initialized; + } persistence_manager_t; + + /** + * @brief Erases the entire NVS flash (factory reset). + * + * Warning: This will remove all stored data and namespaces! + * + * @return esp_err_t ESP_OK on success, otherwise error code. + */ + esp_err_t persistence_manager_factory_reset(void); + + /** + * @brief Initialize the persistence manager with a given NVS namespace. + * + * @param pm Pointer to the persistence manager structure. + * @param nvs_namespace Namespace to use for NVS operations. + */ + esp_err_t persistence_manager_init(persistence_manager_t *pm, const char *nvs_namespace); + + /** + * @brief Deinitialize the persistence manager and release resources. + * + * @param pm Pointer to the persistence manager structure. + */ + esp_err_t persistence_manager_deinit(persistence_manager_t *pm); + + /** + * @brief Check if the persistence manager is initialized. + * + * @param pm Pointer to the persistence manager structure. + * @return true if initialized, false otherwise. + */ + bool persistence_manager_is_initialized(const persistence_manager_t *pm); + + /** + * @brief Check if a key exists in the NVS storage. + * + * @param pm Pointer to the persistence manager structure. + * @param key Key to check for existence. + * @return true if the key exists, false otherwise. + */ + bool persistence_manager_has_key(const persistence_manager_t *pm, const char *key); + + /** + * @brief Remove a key from the NVS storage. + * + * @param pm Pointer to the persistence manager structure. + * @param key Key to remove. + */ + void persistence_manager_remove_key(persistence_manager_t *pm, const char *key); + + /** + * @brief Clear all keys from the NVS storage in the current namespace. + * + * @param pm Pointer to the persistence manager structure. + */ + void persistence_manager_clear(persistence_manager_t *pm); + + /** + * @brief Get the number of keys stored in the current namespace. + * + * @param pm Pointer to the persistence manager structure. + * @return Number of keys. + */ + size_t persistence_manager_get_key_count(const persistence_manager_t *pm); + + /** + * @brief Save all pending changes to NVS storage. + * + * @param pm Pointer to the persistence manager structure. + * @return true if successful, false otherwise. + */ + bool persistence_manager_save(persistence_manager_t *pm); + + /** + * @brief Load all data from NVS storage. + * + * @param pm Pointer to the persistence manager structure. + * @return true if successful, false otherwise. + */ + bool persistence_manager_load(persistence_manager_t *pm); + + /** + * @brief Set a boolean value for a key in NVS storage. + * + * @param pm Pointer to the persistence manager structure. + * @param key Key to set. + * @param value Boolean value to store. + */ + void persistence_manager_set_bool(persistence_manager_t *pm, const char *key, bool value); + + /** + * @brief Set an integer value for a key in NVS storage. + * + * @param pm Pointer to the persistence manager structure. + * @param key Key to set. + * @param value Integer value to store. + */ + void persistence_manager_set_int(persistence_manager_t *pm, const char *key, int32_t value); + + /** + * @brief Set a float value for a key in NVS storage. + * + * @param pm Pointer to the persistence manager structure. + * @param key Key to set. + * @param value Float value to store. + */ + void persistence_manager_set_float(persistence_manager_t *pm, const char *key, float value); + + /** + * @brief Set a double value for a key in NVS storage. + * + * @param pm Pointer to the persistence manager structure. + * @param key Key to set. + * @param value Double value to store. + */ + void persistence_manager_set_double(persistence_manager_t *pm, const char *key, double value); + + /** + * @brief Set a string value for a key in NVS storage. + * + * @param pm Pointer to the persistence manager structure. + * @param key Key to set. + * @param value String value to store. + */ + void persistence_manager_set_string(persistence_manager_t *pm, const char *key, const char *value); + + /** + * @brief Get a boolean value for a key from NVS storage. + * + * @param pm Pointer to the persistence manager structure. + * @param key Key to retrieve. + * @param default_value Value to return if key does not exist. + * @return Boolean value. + */ + bool persistence_manager_get_bool(const persistence_manager_t *pm, const char *key, bool default_value); + + /** + * @brief Get an integer value for a key from NVS storage. + * + * @param pm Pointer to the persistence manager structure. + * @param key Key to retrieve. + * @param default_value Value to return if key does not exist. + * @return Integer value. + */ + int32_t persistence_manager_get_int(const persistence_manager_t *pm, const char *key, int32_t default_value); + + /** + * @brief Get a float value for a key from NVS storage. + * + * @param pm Pointer to the persistence manager structure. + * @param key Key to retrieve. + * @param default_value Value to return if key does not exist. + * @return Float value. + */ + float persistence_manager_get_float(const persistence_manager_t *pm, const char *key, float default_value); + + /** + * @brief Get a double value for a key from NVS storage. + * + * @param pm Pointer to the persistence manager structure. + * @param key Key to retrieve. + * @param default_value Value to return if key does not exist. + * @return Double value. + */ + double persistence_manager_get_double(const persistence_manager_t *pm, const char *key, double default_value); + + /** + * @brief Get a string value for a key from NVS storage. + * + * @param pm Pointer to the persistence manager structure. + * @param key Key to retrieve. + * @param out_value Buffer to store the retrieved string. + * @param max_len Maximum length of the output buffer. + * @param default_value Value to use if key does not exist. + */ + void persistence_manager_get_string(const persistence_manager_t *pm, const char *key, char *out_value, + size_t max_len, const char *default_value); + + /** + * @brief Set a blob (binary data) value for a key in NVS storage. + * + * This function stores arbitrary binary data under the given key. + * + * @param pm Pointer to the persistence manager structure. + * @param key Key to set. + * @param value Pointer to the data to store. + * @param length Length of the data in bytes. + */ + void persistence_manager_set_blob(persistence_manager_t *pm, const char *key, const void *value, size_t length); + + /** + * @brief Get a blob (binary data) value for a key from NVS storage. + * + * This function retrieves binary data previously stored under the given key. + * + * @param pm Pointer to the persistence manager structure. + * @param key Key to retrieve. + * @param out_value Buffer to store the retrieved data. + * @param max_length Maximum length of the output buffer in bytes. + * @param out_length Pointer to variable to receive the actual data length. + * @return true if the blob was found and read successfully, false otherwise. + */ + bool persistence_manager_get_blob(const persistence_manager_t *pm, const char *key, void *out_value, + size_t max_length, size_t *out_length); + +#ifdef __cplusplus +} +#endif diff --git a/firmware/components/persistence-manager/src/PersistenceManager.cpp b/firmware/components/persistence-manager/src/PersistenceManager.cpp deleted file mode 100644 index fca3591..0000000 --- a/firmware/components/persistence-manager/src/PersistenceManager.cpp +++ /dev/null @@ -1,279 +0,0 @@ -#include "hal_esp32/PersistenceManager.h" -#include -#include - -static const char *TAG = "PersistenceManager"; - -PersistenceManager::PersistenceManager(const std::string &nvs_namespace) - : namespace_(nvs_namespace), initialized_(false) -{ - Initialize(); - Load(); -} - -PersistenceManager::~PersistenceManager() -{ - Deinitialize(); -} - -bool PersistenceManager::Initialize() -{ - if (initialized_) - { - return true; - } - - // Open NVS handle - esp_err_t err = nvs_open(namespace_.c_str(), NVS_READWRITE, &nvs_handle_); - if (err != ESP_OK) - { - ESP_LOGE(TAG, "Failed to open NVS handle: %s", esp_err_to_name(err)); - return false; - } - - initialized_ = true; - ESP_LOGI(TAG, "PersistenceManager initialized with namespace: %s", namespace_.c_str()); - return true; -} - -void PersistenceManager::Deinitialize() -{ - if (initialized_) - { - nvs_close(nvs_handle_); - initialized_ = false; - } -} - -bool PersistenceManager::EnsureInitialized() const -{ - if (!initialized_) - { - ESP_LOGE(TAG, "PersistenceManager not initialized"); - return false; - } - return true; -} - -bool PersistenceManager::HasKey(const std::string &key) const -{ - if (!EnsureInitialized()) - return false; - - size_t required_size = 0; - esp_err_t err = nvs_get_blob(nvs_handle_, key.c_str(), nullptr, &required_size); - return err == ESP_OK; -} - -void PersistenceManager::RemoveKey(const std::string &key) -{ - if (!EnsureInitialized()) - return; - - esp_err_t err = nvs_erase_key(nvs_handle_, key.c_str()); - if (err != ESP_OK && err != ESP_ERR_NVS_NOT_FOUND) - { - ESP_LOGE(TAG, "Failed to remove key '%s': %s", key.c_str(), esp_err_to_name(err)); - } -} - -void PersistenceManager::Clear() -{ - if (!EnsureInitialized()) - return; - - esp_err_t err = nvs_erase_all(nvs_handle_); - if (err != ESP_OK) - { - ESP_LOGE(TAG, "Failed to clear all keys: %s", esp_err_to_name(err)); - } -} - -size_t PersistenceManager::GetKeyCount() const -{ - if (!EnsureInitialized()) - return 0; - - nvs_iterator_t it = nullptr; - esp_err_t err = nvs_entry_find(NVS_DEFAULT_PART_NAME, namespace_.c_str(), NVS_TYPE_ANY, &it); - - if (err != ESP_OK || it == nullptr) - { - return 0; - } - - size_t count = 0; - - while (it != nullptr) - { - count++; - err = nvs_entry_next(&it); - if (err != ESP_OK) - { - break; - } - } - - nvs_release_iterator(it); - return count; -} - -bool PersistenceManager::Save() -{ - if (!EnsureInitialized()) - return false; - - esp_err_t err = nvs_commit(nvs_handle_); - if (err != ESP_OK) - { - ESP_LOGE(TAG, "Failed to commit NVS: %s", esp_err_to_name(err)); - return false; - } - return true; -} - -bool PersistenceManager::Load() -{ - return EnsureInitialized(); -} - -void PersistenceManager::SetValueImpl(const std::string &key, bool value) -{ - if (!EnsureInitialized()) - return; - - uint8_t val = value ? 1 : 0; - esp_err_t err = nvs_set_u8(nvs_handle_, key.c_str(), val); - if (err != ESP_OK) - { - ESP_LOGE(TAG, "Failed to set bool key '%s': %s", key.c_str(), esp_err_to_name(err)); - } -} - -void PersistenceManager::SetValueImpl(const std::string &key, int value) -{ - if (!EnsureInitialized()) - return; - - esp_err_t err = nvs_set_i32(nvs_handle_, key.c_str(), value); - if (err != ESP_OK) - { - ESP_LOGE(TAG, "Failed to set int key '%s': %s", key.c_str(), esp_err_to_name(err)); - } -} - -void PersistenceManager::SetValueImpl(const std::string &key, float value) -{ - if (!EnsureInitialized()) - return; - - esp_err_t err = nvs_set_blob(nvs_handle_, key.c_str(), &value, sizeof(float)); - if (err != ESP_OK) - { - ESP_LOGE(TAG, "Failed to set float key '%s': %s", key.c_str(), esp_err_to_name(err)); - } -} - -void PersistenceManager::SetValueImpl(const std::string &key, double value) -{ - if (!EnsureInitialized()) - return; - - esp_err_t err = nvs_set_blob(nvs_handle_, key.c_str(), &value, sizeof(double)); - if (err != ESP_OK) - { - ESP_LOGE(TAG, "Failed to set double key '%s': %s", key.c_str(), esp_err_to_name(err)); - } -} - -void PersistenceManager::SetValueImpl(const std::string &key, const std::string &value) -{ - if (!EnsureInitialized()) - return; - - esp_err_t err = nvs_set_str(nvs_handle_, key.c_str(), value.c_str()); - if (err != ESP_OK) - { - ESP_LOGE(TAG, "Failed to set string key '%s': %s", key.c_str(), esp_err_to_name(err)); - } -} - -bool PersistenceManager::GetValueImpl(const std::string &key, bool defaultValue) const -{ - if (!EnsureInitialized()) - return defaultValue; - - uint8_t value; - esp_err_t err = nvs_get_u8(nvs_handle_, key.c_str(), &value); - if (err != ESP_OK) - { - return defaultValue; - } - return value != 0; -} - -int PersistenceManager::GetValueImpl(const std::string &key, int defaultValue) const -{ - if (!EnsureInitialized()) - return defaultValue; - - int32_t value; - esp_err_t err = nvs_get_i32(nvs_handle_, key.c_str(), &value); - if (err != ESP_OK) - { - return defaultValue; - } - return static_cast(value); -} - -float PersistenceManager::GetValueImpl(const std::string &key, float defaultValue) const -{ - if (!EnsureInitialized()) - return defaultValue; - - float value; - size_t required_size = sizeof(float); - esp_err_t err = nvs_get_blob(nvs_handle_, key.c_str(), &value, &required_size); - if (err != ESP_OK || required_size != sizeof(float)) - { - return defaultValue; - } - return value; -} - -double PersistenceManager::GetValueImpl(const std::string &key, double defaultValue) const -{ - if (!EnsureInitialized()) - return defaultValue; - - double value; - size_t required_size = sizeof(double); - esp_err_t err = nvs_get_blob(nvs_handle_, key.c_str(), &value, &required_size); - if (err != ESP_OK || required_size != sizeof(double)) - { - return defaultValue; - } - return value; -} - -std::string PersistenceManager::GetValueImpl(const std::string &key, const std::string &defaultValue) const -{ - if (!EnsureInitialized()) - return defaultValue; - - size_t required_size = 0; - esp_err_t err = nvs_get_str(nvs_handle_, key.c_str(), nullptr, &required_size); - if (err != ESP_OK) - { - return defaultValue; - } - - std::string value(required_size - 1, '\0'); // -1 for null terminator - err = nvs_get_str(nvs_handle_, key.c_str(), value.data(), &required_size); - if (err != ESP_OK) - { - return defaultValue; - } - - return value; -} diff --git a/firmware/components/persistence-manager/src/persistence_manager.c b/firmware/components/persistence-manager/src/persistence_manager.c new file mode 100644 index 0000000..51b8b14 --- /dev/null +++ b/firmware/components/persistence-manager/src/persistence_manager.c @@ -0,0 +1,274 @@ +#include "persistence_manager.h" +#include +#include + +#define TAG "persistence_manager" + +esp_err_t persistence_manager_factory_reset(void) +{ + // Erase the entire NVS flash (factory reset) + esp_err_t err = nvs_flash_erase(); + if (err != ESP_OK) + { + ESP_LOGE(TAG, "Factory reset failed: %s", esp_err_to_name(err)); + } + return err; +} + +esp_err_t persistence_manager_init(persistence_manager_t *pm, const char *nvs_namespace) +{ + if (!pm) + return ESP_ERR_INVALID_ARG; + strncpy(pm->nvs_namespace, nvs_namespace ? nvs_namespace : "config", sizeof(pm->nvs_namespace) - 1); + pm->nvs_namespace[sizeof(pm->nvs_namespace) - 1] = '\0'; + pm->initialized = false; + esp_err_t err = nvs_open(pm->nvs_namespace, NVS_READWRITE, &pm->nvs_handle); + if (err == ESP_OK) + { + pm->initialized = true; + ESP_LOGD(TAG, "Initialized with namespace: %s", pm->nvs_namespace); + return ESP_OK; + } + ESP_LOGE(TAG, "Failed to open NVS handle: %s", esp_err_to_name(err)); + return err; +} + +esp_err_t persistence_manager_deinit(persistence_manager_t *pm) +{ + if (pm && pm->initialized) + { + nvs_close(pm->nvs_handle); + pm->initialized = false; + } + return ESP_OK; +} + +bool persistence_manager_is_initialized(const persistence_manager_t *pm) +{ + return pm && pm->initialized; +} + +bool persistence_manager_has_key(const persistence_manager_t *pm, const char *key) +{ + if (!persistence_manager_is_initialized(pm)) + return false; + size_t required_size = 0; + esp_err_t err = nvs_get_blob(pm->nvs_handle, key, NULL, &required_size); + return err == ESP_OK; +} + +void persistence_manager_remove_key(persistence_manager_t *pm, const char *key) +{ + if (!persistence_manager_is_initialized(pm)) + return; + esp_err_t err = nvs_erase_key(pm->nvs_handle, key); + if (err != ESP_OK && err != ESP_ERR_NVS_NOT_FOUND) + { + ESP_LOGE(TAG, "Failed to remove key '%s': %s", key, esp_err_to_name(err)); + } +} + +void persistence_manager_clear(persistence_manager_t *pm) +{ + if (!persistence_manager_is_initialized(pm)) + return; + esp_err_t err = nvs_erase_all(pm->nvs_handle); + if (err != ESP_OK) + { + ESP_LOGE(TAG, "Failed to clear all keys: %s", esp_err_to_name(err)); + } +} + +size_t persistence_manager_get_key_count(const persistence_manager_t *pm) +{ + if (!persistence_manager_is_initialized(pm)) + return 0; + nvs_iterator_t it = NULL; + esp_err_t err = nvs_entry_find(NVS_DEFAULT_PART_NAME, pm->nvs_namespace, NVS_TYPE_ANY, &it); + if (err != ESP_OK || it == NULL) + return 0; + size_t count = 0; + while (it != NULL) + { + count++; + err = nvs_entry_next(&it); + if (err != ESP_OK) + break; + } + nvs_release_iterator(it); + return count; +} + +bool persistence_manager_save(persistence_manager_t *pm) +{ + if (!persistence_manager_is_initialized(pm)) + return false; + esp_err_t err = nvs_commit(pm->nvs_handle); + if (err != ESP_OK) + { + ESP_LOGE(TAG, "Failed to commit NVS: %s", esp_err_to_name(err)); + return false; + } + return true; +} + +bool persistence_manager_load(persistence_manager_t *pm) +{ + return persistence_manager_is_initialized(pm); +} + +void persistence_manager_set_bool(persistence_manager_t *pm, const char *key, bool value) +{ + if (!persistence_manager_is_initialized(pm)) + return; + uint8_t val = value ? 1 : 0; + esp_err_t err = nvs_set_u8(pm->nvs_handle, key, val); + if (err != ESP_OK) + { + ESP_LOGE(TAG, "Failed to set bool key '%s': %s", key, esp_err_to_name(err)); + } +} + +void persistence_manager_set_int(persistence_manager_t *pm, const char *key, int32_t value) +{ + if (!persistence_manager_is_initialized(pm)) + return; + esp_err_t err = nvs_set_i32(pm->nvs_handle, key, value); + if (err != ESP_OK) + { + ESP_LOGE(TAG, "Failed to set int key '%s': %s", key, esp_err_to_name(err)); + } +} + +void persistence_manager_set_float(persistence_manager_t *pm, const char *key, float value) +{ + if (!persistence_manager_is_initialized(pm)) + return; + esp_err_t err = nvs_set_blob(pm->nvs_handle, key, &value, sizeof(float)); + if (err != ESP_OK) + { + ESP_LOGE(TAG, "Failed to set float key '%s': %s", key, esp_err_to_name(err)); + } +} + +void persistence_manager_set_double(persistence_manager_t *pm, const char *key, double value) +{ + if (!persistence_manager_is_initialized(pm)) + return; + esp_err_t err = nvs_set_blob(pm->nvs_handle, key, &value, sizeof(double)); + if (err != ESP_OK) + { + ESP_LOGE(TAG, "Failed to set double key '%s': %s", key, esp_err_to_name(err)); + } +} + +void persistence_manager_set_string(persistence_manager_t *pm, const char *key, const char *value) +{ + if (!persistence_manager_is_initialized(pm)) + return; + esp_err_t err = nvs_set_str(pm->nvs_handle, key, value); + if (err != ESP_OK) + { + ESP_LOGE(TAG, "Failed to set string key '%s': %s", key, esp_err_to_name(err)); + } +} + +void persistence_manager_set_blob(persistence_manager_t *pm, const char *key, const void *value, size_t length) +{ + if (!persistence_manager_is_initialized(pm) || !value || length == 0) + return; + esp_err_t err = nvs_set_blob(pm->nvs_handle, key, value, length); + if (err != ESP_OK) + { + ESP_LOGE(TAG, "Failed to set blob key '%s': %s", key, esp_err_to_name(err)); + } +} + +bool persistence_manager_get_bool(const persistence_manager_t *pm, const char *key, bool default_value) +{ + if (!persistence_manager_is_initialized(pm)) + return default_value; + uint8_t value; + esp_err_t err = nvs_get_u8(pm->nvs_handle, key, &value); + if (err != ESP_OK) + return default_value; + return value != 0; +} + +int32_t persistence_manager_get_int(const persistence_manager_t *pm, const char *key, int32_t default_value) +{ + if (!persistence_manager_is_initialized(pm)) + return default_value; + int32_t value; + esp_err_t err = nvs_get_i32(pm->nvs_handle, key, &value); + if (err != ESP_OK) + return default_value; + return value; +} + +float persistence_manager_get_float(const persistence_manager_t *pm, const char *key, float default_value) +{ + if (!persistence_manager_is_initialized(pm)) + return default_value; + float value; + size_t required_size = sizeof(float); + esp_err_t err = nvs_get_blob(pm->nvs_handle, key, &value, &required_size); + if (err != ESP_OK || required_size != sizeof(float)) + return default_value; + return value; +} + +double persistence_manager_get_double(const persistence_manager_t *pm, const char *key, double default_value) +{ + if (!persistence_manager_is_initialized(pm)) + return default_value; + double value; + size_t required_size = sizeof(double); + esp_err_t err = nvs_get_blob(pm->nvs_handle, key, &value, &required_size); + if (err != ESP_OK || required_size != sizeof(double)) + return default_value; + return value; +} + +void persistence_manager_get_string(const persistence_manager_t *pm, const char *key, char *out_value, size_t max_len, + const char *default_value) +{ + if (!persistence_manager_is_initialized(pm)) + { + strncpy(out_value, default_value, max_len - 1); + out_value[max_len - 1] = '\0'; + return; + } + size_t required_size = 0; + esp_err_t err = nvs_get_str(pm->nvs_handle, key, NULL, &required_size); + if (err != ESP_OK || required_size == 0 || required_size > max_len) + { + strncpy(out_value, default_value, max_len - 1); + out_value[max_len - 1] = '\0'; + return; + } + err = nvs_get_str(pm->nvs_handle, key, out_value, &required_size); + if (err != ESP_OK) + { + strncpy(out_value, default_value, max_len - 1); + out_value[max_len - 1] = '\0'; + return; + } +} + +bool persistence_manager_get_blob(const persistence_manager_t *pm, const char *key, void *out_value, size_t max_length, + size_t *out_length) +{ + if (!persistence_manager_is_initialized(pm) || !out_value || max_length == 0) + return false; + size_t required_size = 0; + esp_err_t err = nvs_get_blob(pm->nvs_handle, key, NULL, &required_size); + if (err != ESP_OK || required_size == 0 || required_size > max_length) + return false; + err = nvs_get_blob(pm->nvs_handle, key, out_value, &required_size); + if (err != ESP_OK) + return false; + if (out_length) + *out_length = required_size; + return true; +} diff --git a/firmware/components/simulator/CMakeLists.txt b/firmware/components/simulator/CMakeLists.txt index de0f075..b19e264 100644 --- a/firmware/components/simulator/CMakeLists.txt +++ b/firmware/components/simulator/CMakeLists.txt @@ -5,5 +5,6 @@ idf_component_register(SRCS PRIV_REQUIRES led-manager persistence-manager + message-manager spiffs ) diff --git a/firmware/components/simulator/include/simulator.h b/firmware/components/simulator/include/simulator.h index f15f057..9614a2a 100644 --- a/firmware/components/simulator/include/simulator.h +++ b/firmware/components/simulator/include/simulator.h @@ -9,12 +9,20 @@ typedef struct int cycle_duration_minutes; } simulation_config_t; -char *get_time(void); -esp_err_t add_light_item(const char time[5], uint8_t red, uint8_t green, uint8_t blue, uint8_t white, - uint8_t brightness, uint8_t saturation); -void cleanup_light_items(void); -void start_simulate_day(void); -void start_simulate_night(void); -void start_simulation_task(void); -void stop_simulation_task(void); -void start_simulation(void); +#ifdef __cplusplus +extern "C" +{ +#endif + + char *get_time(void); + esp_err_t add_light_item(const char time[5], uint8_t red, uint8_t green, uint8_t blue, uint8_t white, + uint8_t brightness, uint8_t saturation); + void cleanup_light_items(void); + void start_simulate_day(void); + void start_simulate_night(void); + void start_simulation_task(void); + void stop_simulation_task(void); + void start_simulation(void); +#ifdef __cplusplus +} +#endif \ No newline at end of file diff --git a/firmware/components/simulator/include/storage.h b/firmware/components/simulator/include/storage.h index 642ac66..9c67231 100644 --- a/firmware/components/simulator/include/storage.h +++ b/firmware/components/simulator/include/storage.h @@ -1,4 +1,23 @@ #pragma once -void initialize_storage(); -void load_file(const char *filename); +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" +{ +#endif + void initialize_storage(); + void load_file(const char *filename); + char **read_lines_filtered(const char *filename, int *out_count); + void free_lines(char **lines, int count); + /** + * Write an array of lines to a file (CSV or other text). + * @param filename File name (without /spiffs/) + * @param lines Array of lines (null-terminated strings) + * @param count Number of lines + * @return ESP_OK on success, error code otherwise + */ + esp_err_t write_lines(const char *filename, char **lines, int count); +#ifdef __cplusplus +} +#endif diff --git a/firmware/components/simulator/src/simulator.cpp b/firmware/components/simulator/src/simulator.cpp index 2a02e31..3eb1a7d 100644 --- a/firmware/components/simulator/src/simulator.cpp +++ b/firmware/components/simulator/src/simulator.cpp @@ -1,8 +1,9 @@ #include "simulator.h" #include "color.h" -#include "hal_esp32/PersistenceManager.h" #include "led_strip_ws2812.h" +#include "message_manager.h" +#include "persistence_manager.h" #include "storage.h" #include #include @@ -15,12 +16,12 @@ #include static const char *TAG = "simulator"; -static char *time; +static char *time = NULL; static char *time_to_string(int hhmm) { static char buffer[20]; - snprintf(buffer, sizeof(buffer), "%02d:%02d Uhr", hhmm / 100, hhmm % 100); + snprintf(buffer, sizeof(buffer), "%02d:%02d", hhmm / 100, hhmm % 100); return buffer; } @@ -81,10 +82,10 @@ esp_err_t add_light_item(const char time[5], uint8_t red, uint8_t green, uint8_t uint8_t brightness, uint8_t saturation) { // Allocate memory for a new node in PSRAM. - light_item_node_t *new_node = (light_item_node_t *)heap_caps_malloc(sizeof(light_item_node_t), MALLOC_CAP_SPIRAM); + light_item_node_t *new_node = (light_item_node_t *)heap_caps_malloc(sizeof(light_item_node_t), MALLOC_CAP_DEFAULT); if (new_node == NULL) { - ESP_LOGE(TAG, "Failed to allocate memory in PSRAM for new light_item_node_t."); + ESP_LOGE(TAG, "Failed to allocate memory for new light_item_node_t."); return ESP_FAIL; } @@ -145,10 +146,12 @@ static void initialize_light_items(void) initialize_storage(); static char filename[30]; - auto persistence = PersistenceManager(); - int variant = persistence.GetValue("light_variant", 1); - snprintf(filename, sizeof(filename), "/spiffs/schema_%02d.csv", variant); + persistence_manager_t persistence; + persistence_manager_init(&persistence, "config"); + int variant = persistence_manager_get_int(&persistence, "light_variant", 1); + snprintf(filename, sizeof(filename), "schema_%02d.csv", variant); load_file(filename); + persistence_manager_deinit(&persistence); if (head == NULL) { @@ -233,6 +236,17 @@ static light_item_node_t *find_next_light_item_for_time(int hhmm) return next_item; } +static void send_simulation_message(const char *time, rgb_t color) +{ + message_t msg = {}; + msg.type = MESSAGE_TYPE_SIMULATION; + strncpy(msg.data.simulation.time, time, sizeof(msg.data.simulation.time) - 1); + msg.data.simulation.red = color.red; + msg.data.simulation.green = color.green; + msg.data.simulation.blue = color.blue; + message_manager_post(&msg, pdMS_TO_TICKS(100)); +} + void start_simulate_day(void) { initialize_light_items(); @@ -240,8 +254,9 @@ void start_simulate_day(void) light_item_node_t *current_item = find_best_light_item_for_time(1200); if (current_item != NULL) { - led_strip_update(LED_STATE_DAY, - (rgb_t){.red = current_item->red, .green = current_item->green, .blue = current_item->blue}); + rgb_t color = {.red = current_item->red, .green = current_item->green, .blue = current_item->blue}; + led_strip_update(LED_STATE_DAY, color); + send_simulation_message("12:00", color); } } @@ -252,8 +267,9 @@ void start_simulate_night(void) light_item_node_t *current_item = find_best_light_item_for_time(0); if (current_item != NULL) { - led_strip_update(LED_STATE_NIGHT, - (rgb_t){.red = current_item->red, .green = current_item->green, .blue = current_item->blue}); + rgb_t color = {.red = current_item->red, .green = current_item->green, .blue = current_item->blue}; + led_strip_update(LED_STATE_NIGHT, color); + send_simulation_message("00:00", color); } } @@ -294,45 +310,51 @@ void simulate_cycle(void *args) light_item_node_t *current_item = find_best_light_item_for_time(hhmm); light_item_node_t *next_item = find_next_light_item_for_time(hhmm); - if (current_item != NULL && next_item != NULL) + if (current_item != NULL) { - int current_item_time_min = (atoi(current_item->time) / 100) * 60 + (atoi(current_item->time) % 100); - int next_item_time_min = (atoi(next_item->time) / 100) * 60 + (atoi(next_item->time) % 100); + rgb_t color = {0, 0, 0}; - if (next_item_time_min < current_item_time_min) + // Use head as fallback if next_item is NULL + next_item = next_item ? next_item : head; + if (next_item != NULL) { - next_item_time_min += total_minutes_in_day; - } + int current_item_time_min = (atoi(current_item->time) / 100) * 60 + (atoi(current_item->time) % 100); + int next_item_time_min = (atoi(next_item->time) / 100) * 60 + (atoi(next_item->time) % 100); - int minutes_since_current_item_start = current_minute_of_day - current_item_time_min; - if (minutes_since_current_item_start < 0) + if (next_item_time_min < current_item_time_min) + { + next_item_time_min += total_minutes_in_day; + } + + int minutes_since_current_item_start = current_minute_of_day - current_item_time_min; + if (minutes_since_current_item_start < 0) + { + minutes_since_current_item_start += total_minutes_in_day; + } + + int interval_duration = next_item_time_min - current_item_time_min; + if (interval_duration == 0) + { + interval_duration = 1; + } + + float interpolation_factor = (float)minutes_since_current_item_start / (float)interval_duration; + + // Prepare colors for interpolation + rgb_t start_rgb = {.red = current_item->red, .green = current_item->green, .blue = current_item->blue}; + rgb_t end_rgb = {.red = next_item->red, .green = next_item->green, .blue = next_item->blue}; + + // Use the interpolation function + color = interpolate_color(start_rgb, end_rgb, interpolation_factor); + led_strip_update(LED_STATE_SIMULATION, color); + } + else { - minutes_since_current_item_start += total_minutes_in_day; + // No next_item and no head, use only current + color = (rgb_t){.red = current_item->red, .green = current_item->green, .blue = current_item->blue}; + led_strip_update(LED_STATE_SIMULATION, color); } - - int interval_duration = next_item_time_min - current_item_time_min; - if (interval_duration == 0) - { - interval_duration = 1; - } - - float interpolation_factor = (float)minutes_since_current_item_start / (float)interval_duration; - - // Prepare colors for interpolation - rgb_t start_rgb = {.red = current_item->red, .green = current_item->green, .blue = current_item->blue}; - rgb_t end_rgb = {.red = next_item->red, .green = next_item->green, .blue = next_item->blue}; - - // Use the interpolation function - rgb_t final_rgb = interpolate_color(start_rgb, end_rgb, interpolation_factor); - - led_strip_update(LED_STATE_SIMULATION, final_rgb); - } - else if (current_item != NULL) - { - // No next item, just use current - led_strip_update( - LED_STATE_SIMULATION, - (rgb_t){.red = current_item->red, .green = current_item->green, .blue = current_item->blue}); + send_simulation_message(time, color); } vTaskDelay(pdMS_TO_TICKS(delay_ms)); @@ -351,7 +373,7 @@ void start_simulation_task(void) stop_simulation_task(); simulation_config_t *config = - (simulation_config_t *)heap_caps_malloc(sizeof(simulation_config_t), MALLOC_CAP_SPIRAM); + (simulation_config_t *)heap_caps_malloc(sizeof(simulation_config_t), MALLOC_CAP_DEFAULT); if (config == NULL) { ESP_LOGE(TAG, "Failed to allocate memory for simulation config."); @@ -398,25 +420,22 @@ void start_simulation(void) { stop_simulation_task(); - auto persistence = PersistenceManager(); - if (persistence.GetValue("light_active", false)) + persistence_manager_t persistence; + persistence_manager_init(&persistence, "config"); + if (persistence_manager_get_bool(&persistence, "light_active", false)) { - - int mode = persistence.GetValue("light_mode", 0); + int mode = persistence_manager_get_int(&persistence, "light_mode", 0); switch (mode) { case 0: // Simulation mode start_simulation_task(); break; - case 1: // Day mode start_simulate_day(); break; - case 2: // Night mode start_simulate_night(); break; - default: ESP_LOGW(TAG, "Unknown light mode: %d", mode); break; @@ -426,4 +445,5 @@ void start_simulation(void) { led_strip_update(LED_STATE_OFF, rgb_t{}); } + persistence_manager_deinit(&persistence); } diff --git a/firmware/components/simulator/src/storage.cpp b/firmware/components/simulator/src/storage.cpp index 6f5a3d5..e788f62 100644 --- a/firmware/components/simulator/src/storage.cpp +++ b/firmware/components/simulator/src/storage.cpp @@ -3,14 +3,22 @@ #include "esp_log.h" #include "esp_spiffs.h" #include "simulator.h" -#include #include #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,64 +44,113 @@ void initialize_storage() } return; } + + is_spiffs_mounted = true; } void load_file(const char *filename) { ESP_LOGI(TAG, "Loading file: %s", filename); - FILE *f = fopen(filename, "r"); - if (f == NULL) - { - ESP_LOGE(TAG, "Failed to open file for reading"); - return; - } - - char line[128]; + int line_count = 0; + char **lines = read_lines_filtered(filename, &line_count); uint8_t line_number = 0; - while (fgets(line, sizeof(line), f)) + for (int i = 0; i < line_count; ++i) { - char *pos = strchr(line, '\n'); - if (pos) - { - *pos = '\0'; - } - - if (strlen(line) == 0) - { - continue; - } - - char *trimmed = line; - while (*trimmed == ' ' || *trimmed == '\t') - { - trimmed++; - } - if (*trimmed == '#' || *trimmed == '\0') - { - continue; - } - char time[10] = {0}; int red, green, blue, white, brightness, saturation; - - int items_scanned = sscanf(line, "%d,%d,%d,%d,%d,%d", &red, &green, &blue, &white, &brightness, &saturation); + int items_scanned = + sscanf(lines[i], "%d,%d,%d,%d,%d,%d", &red, &green, &blue, &white, &brightness, &saturation); if (items_scanned == 6) { int total_minutes = line_number * 30; int hours = total_minutes / 60; int minutes = total_minutes % 60; - snprintf(time, sizeof(time), "%02d%02d", hours, minutes); - add_light_item(time, red, green, blue, white, brightness, saturation); line_number++; } else { - ESP_LOGW(TAG, "Could not parse line: %s", line); + ESP_LOGW(TAG, "Could not parse line: %s", lines[i]); } } - - fclose(f); + free_lines(lines, line_count); ESP_LOGI(TAG, "Finished loading file. Loaded %d entries.", line_number); } + +char **read_lines_filtered(const char *filename, int *out_count) +{ + char fullpath[128]; + snprintf(fullpath, sizeof(fullpath), "/spiffs/%s", filename[0] == '/' ? filename + 1 : filename); + FILE *f = fopen(fullpath, "r"); + if (!f) + { + ESP_LOGE(TAG, "Failed to open file: %s", fullpath); + *out_count = 0; + return NULL; + } + + size_t capacity = 16; + size_t count = 0; + char **lines = (char **)malloc(capacity * sizeof(char *)); + char line[256]; + while (fgets(line, sizeof(line), f)) + { + // Zeilenumbruch entfernen + char *pos = strchr(line, '\n'); + if (pos) + *pos = '\0'; + // Trim vorne + char *trimmed = line; + while (*trimmed == ' ' || *trimmed == '\t') + trimmed++; + // Leere oder Kommentarzeile ΓΌberspringen + if (*trimmed == '\0' || *trimmed == '#') + continue; + // Trim hinten + size_t len = strlen(trimmed); + while (len > 0 && (trimmed[len - 1] == ' ' || trimmed[len - 1] == '\t')) + trimmed[--len] = '\0'; + // Kopieren + if (count >= capacity) + { + capacity *= 2; + lines = (char **)realloc(lines, capacity * sizeof(char *)); + } + lines[count++] = strdup(trimmed); + } + fclose(f); + *out_count = (int)count; + return lines; +} + +void free_lines(char **lines, int count) +{ + for (int i = 0; i < count; ++i) + free(lines[i]); + free(lines); +} + +esp_err_t write_lines(const char *filename, char **lines, int count) +{ + char fullpath[128]; + snprintf(fullpath, sizeof(fullpath), "/spiffs/%s", filename[0] == '/' ? filename + 1 : filename); + FILE *f = fopen(fullpath, "w"); + if (!f) + { + ESP_LOGE(TAG, "Failed to open file for writing: %s", fullpath); + return ESP_FAIL; + } + for (int i = 0; i < count; ++i) + { + if (fprintf(f, "%s\n", lines[i]) < 0) + { + ESP_LOGE(TAG, "Failed to write line %d", i); + fclose(f); + return ESP_FAIL; + } + } + fclose(f); + ESP_LOGI(TAG, "Wrote %d lines to %s", count, fullpath); + return ESP_OK; +} diff --git a/firmware/main/CMakeLists.txt b/firmware/main/CMakeLists.txt index da1dab4..d3309e1 100755 --- a/firmware/main/CMakeLists.txt +++ b/firmware/main/CMakeLists.txt @@ -1,10 +1,10 @@ idf_component_register(SRCS - main.cpp - app_task.cpp - button_handling.c - i2c_checker.c - hal/u8g2_esp32_hal.c - INCLUDE_DIRS "." + src/main.cpp + src/app_task.cpp + src/button_handling.c + src/i2c_checker.c + src/hal/u8g2_esp32_hal.c + INCLUDE_DIRS "include" PRIV_REQUIRES analytics insa @@ -21,6 +21,7 @@ idf_component_register(SRCS app_update rmaker_common driver + my_mqtt_client ) spiffs_create_partition_image(storage ../storage FLASH_IN_PROJECT) 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/main/app_task.h b/firmware/main/include/app_task.h similarity index 100% rename from firmware/main/app_task.h rename to firmware/main/include/app_task.h diff --git a/firmware/main/button_handling.h b/firmware/main/include/button_handling.h similarity index 100% rename from firmware/main/button_handling.h rename to firmware/main/include/button_handling.h diff --git a/firmware/main/common.h b/firmware/main/include/common.h similarity index 100% rename from firmware/main/common.h rename to firmware/main/include/common.h diff --git a/firmware/main/hal/u8g2_esp32_hal.h b/firmware/main/include/hal/u8g2_esp32_hal.h similarity index 100% rename from firmware/main/hal/u8g2_esp32_hal.h rename to firmware/main/include/hal/u8g2_esp32_hal.h diff --git a/firmware/main/i2c_checker.h b/firmware/main/include/i2c_checker.h similarity index 100% rename from firmware/main/i2c_checker.h rename to firmware/main/include/i2c_checker.h diff --git a/firmware/main/isrgrootx1.pem b/firmware/main/isrgrootx1.pem new file mode 100644 index 0000000..b85c803 --- /dev/null +++ b/firmware/main/isrgrootx1.pem @@ -0,0 +1,31 @@ +-----BEGIN CERTIFICATE----- +MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw +TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh +cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4 +WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu +ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY +MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc +h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+ +0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U +A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW +T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH +B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC +B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv +KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn +OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn +jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw +qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI +rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV +HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq +hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL +ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ +3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK +NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5 +ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur +TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC +jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc +oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq +4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA +mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d +emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc= +-----END CERTIFICATE----- diff --git a/firmware/main/app_task.cpp b/firmware/main/src/app_task.cpp similarity index 66% rename from firmware/main/app_task.cpp rename to firmware/main/src/app_task.cpp index 63996b1..ef12984 100644 --- a/firmware/main/app_task.cpp +++ b/firmware/main/src/app_task.cpp @@ -2,16 +2,20 @@ #include "analytics.h" #include "button_handling.h" +#include "common.h" #include "common/InactivityTracker.h" #include "hal/u8g2_esp32_hal.h" -#include "hal_esp32/PersistenceManager.h" #include "i2c_checker.h" #include "led_status.h" +#include "message_manager.h" +#include "my_mqtt_client.h" +#include "persistence_manager.h" #include "simulator.h" #include "ui/ClockScreenSaver.h" #include "ui/ScreenSaver.h" #include "ui/SplashScreen.h" #include "wifi_manager.h" +#include #include #include #include @@ -33,7 +37,8 @@ uint8_t received_signal; std::shared_ptr m_widget; std::vector> m_history; std::unique_ptr m_inactivityTracker; -std::shared_ptr m_persistenceManager; +// Persistence Manager for C-API +persistence_manager_t g_persistence_manager; extern QueueHandle_t buttonQueue; @@ -93,10 +98,7 @@ void popScreen() m_history.pop_back(); if (m_widget) { - if (m_persistenceManager != nullptr) - { - m_persistenceManager->Save(); - } + persistence_manager_save(&g_persistence_manager); m_widget->onExit(); } m_widget = m_history.back(); @@ -107,14 +109,14 @@ void popScreen() static void init_ui(void) { - m_persistenceManager = std::make_shared(); + persistence_manager_init(&g_persistence_manager, "config"); options = { .u8g2 = &u8g2, .setScreen = [](const std::shared_ptr &screen) { setScreen(screen); }, .pushScreen = [](const std::shared_ptr &screen) { pushScreen(screen); }, .popScreen = []() { popScreen(); }, .onButtonClicked = nullptr, - .persistenceManager = m_persistenceManager, + .persistenceManager = &g_persistence_manager, }; m_widget = std::make_shared(&options); m_inactivityTracker = std::make_unique(60000, []() { @@ -127,6 +129,17 @@ static void init_ui(void) u8g2_SendBuffer(&u8g2); } +static void on_message_received(const message_t *msg) +{ + if (msg && msg->type == MESSAGE_TYPE_SETTINGS && + (std::strcmp(msg->data.settings.key, "light_active") == 0 || + std::strcmp(msg->data.settings.key, "light_variant") == 0 || + std::strcmp(msg->data.settings.key, "light_mode") == 0)) + { + start_simulation(); + } +} + static void handle_button(uint8_t button) { m_inactivityTracker->reset(); @@ -184,14 +197,65 @@ void app_task(void *args) return; } + // Initialize display so that info can be shown setup_screen(); + + // Check BACK button and delete settings if necessary (with countdown) + gpio_config_t io_conf = {}; + io_conf.intr_type = GPIO_INTR_DISABLE; + io_conf.mode = GPIO_MODE_INPUT; + io_conf.pin_bit_mask = (1ULL << BUTTON_BACK); + io_conf.pull_down_en = GPIO_PULLDOWN_DISABLE; + io_conf.pull_up_en = GPIO_PULLUP_ENABLE; + gpio_config(&io_conf); + + vTaskDelay(pdMS_TO_TICKS(10)); + if (gpio_get_level(BUTTON_BACK) == 0) + { + u8g2_SetFont(&u8g2, u8g2_font_ncenB08_tr); + for (int i = 5; i > 0; --i) + { + u8g2_ClearBuffer(&u8g2); + u8g2_DrawStr(&u8g2, 5, 20, "BACK gedrueckt!"); + u8g2_DrawStr(&u8g2, 5, 35, "Halte fuer Reset..."); + char buf[32]; + snprintf(buf, sizeof(buf), "Loesche in %d s", i); + u8g2_DrawStr(&u8g2, 5, 55, buf); + u8g2_SendBuffer(&u8g2); + vTaskDelay(pdMS_TO_TICKS(1000)); + if (gpio_get_level(BUTTON_BACK) != 0) + { + // Button released, abort + break; + } + if (i == 1) + { + // After 5 seconds still pressed: perform factory reset + u8g2_ClearBuffer(&u8g2); + u8g2_DrawStr(&u8g2, 5, 30, "Alle Einstellungen "); + u8g2_DrawStr(&u8g2, 5, 45, "werden geloescht..."); + u8g2_SendBuffer(&u8g2); + persistence_manager_factory_reset(); + vTaskDelay(pdMS_TO_TICKS(1000)); + u8g2_ClearBuffer(&u8g2); + u8g2_DrawStr(&u8g2, 5, 35, "Fertig. Neustart..."); + u8g2_SendBuffer(&u8g2); + vTaskDelay(pdMS_TO_TICKS(1000)); + esp_restart(); + } + } + } + + message_manager_init(); + setup_buttons(); init_ui(); -#if CONFIG_WIFI_ENABLED wifi_manager_init(); - analytics_init(); -#endif + + mqtt_client_start(); + + message_manager_register_listener(on_message_received); start_simulation(); diff --git a/firmware/main/button_handling.c b/firmware/main/src/button_handling.c similarity index 100% rename from firmware/main/button_handling.c rename to firmware/main/src/button_handling.c diff --git a/firmware/main/hal/u8g2_esp32_hal.c b/firmware/main/src/hal/u8g2_esp32_hal.c similarity index 99% rename from firmware/main/hal/u8g2_esp32_hal.c rename to firmware/main/src/hal/u8g2_esp32_hal.c index fe58c88..09de770 100644 --- a/firmware/main/hal/u8g2_esp32_hal.c +++ b/firmware/main/src/hal/u8g2_esp32_hal.c @@ -7,7 +7,7 @@ #include "freertos/FreeRTOS.h" #include "freertos/task.h" -#include "u8g2_esp32_hal.h" +#include "hal/u8g2_esp32_hal.h" static const char *TAG = "u8g2_hal"; static const unsigned int I2C_TIMEOUT_MS = 1000; diff --git a/firmware/main/i2c_checker.c b/firmware/main/src/i2c_checker.c similarity index 100% rename from firmware/main/i2c_checker.c rename to firmware/main/src/i2c_checker.c diff --git a/firmware/main/main.cpp b/firmware/main/src/main.cpp similarity index 85% rename from firmware/main/main.cpp rename to firmware/main/src/main.cpp index 1614819..cf29983 100644 --- a/firmware/main/main.cpp +++ b/firmware/main/src/main.cpp @@ -1,8 +1,8 @@ #include "app_task.h" #include "color.h" -#include "hal_esp32/PersistenceManager.h" #include "led_status.h" #include "led_strip_ws2812.h" +#include "persistence_manager.h" #include "wifi_manager.h" #include #include @@ -24,8 +24,9 @@ void app_main(void) ESP_ERROR_CHECK(nvs_flash_init()); } - auto persistence = PersistenceManager(); - persistence.Load(); + persistence_manager_t persistence; + persistence_manager_init(&persistence, "config"); + persistence_manager_load(&persistence); led_status_init(CONFIG_STATUS_WLED_PIN); 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 diff --git a/firmware/storage/www/captive.html b/firmware/storage/www/captive.html new file mode 100644 index 0000000..9e17dbe --- /dev/null +++ b/firmware/storage/www/captive.html @@ -0,0 +1,120 @@ + + + + + + + + + + System Control - WLAN Setup + + + + + + +
+
+
+ + +
+

πŸš‚ System Control

+
+ +
+
+ + +
+ +
+ +
+ + +
+
+ +
+ +
+ +
+ +
+ ℹ️ 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..a0b6cb9 --- /dev/null +++ b/firmware/storage/www/css/captive.css @@ -0,0 +1,217 @@ +@media (max-width: 600px) { + .header { + flex-direction: row; + align-items: flex-start; + text-align: left; + } + + .header h1 { + flex: 1 1 100%; + text-align: center; + order: 2; + margin-top: 8px; + } + + .header-controls { + order: 1; + flex: 1 1 auto; + justify-content: flex-start; + display: flex; + gap: 8px; + } +} + +/* Captive Portal CSS - WiFi setup specific styles */ +/* Base styles are in shared.css */ + + +body { + padding: 12px; +} + +.container { + max-width: 900px; + margin: 0 auto; + width: 100%; +} + +/* 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; +} + + +.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); +} + +#connect-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; + box-shadow: none; + background-color: #888 !important; +} +} + +/* 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; +} + + +/* 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..09088ab --- /dev/null +++ b/firmware/storage/www/css/index.css @@ -0,0 +1,1334 @@ +/* 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; +} + + +.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); + display: none; +} + +.status-item.visible { + display: flex; +} + +.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%; + } +} + +/* LED 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 LED */ +@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/css/shared.css b/firmware/storage/www/css/shared.css new file mode 100644 index 0000000..8106d2c --- /dev/null +++ b/firmware/storage/www/css/shared.css @@ -0,0 +1,357 @@ +/* Passwortfeld Toggle (zentral fΓΌr alle Seiten) */ +.password-toggle { + position: relative; + display: flex; + align-items: center; + gap: 0; +} + +.password-toggle input { + padding-right: 50px; + flex: 1; +} + +.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; + transition: color 0.2s; +} + +.password-toggle button:active { + color: var(--accent); +} + +/* Passwortfeld Toggle */ +.password-toggle { + display: flex; + align-items: center; + gap: 6px; +} + +.password-toggle input[type="password"], +.password-toggle input[type="text"] { + flex: 1; +} + +.password-toggle button { + background: none; + border: none; + font-size: 1.2em; + cursor: pointer; + color: var(--text-muted); + padding: 0 6px; + transition: color 0.2s; +} + +.password-toggle button:active { + color: var(--accent); +} + +/* 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/css/style.css b/firmware/storage/www/css/style.css new file mode 100644 index 0000000..f181add --- /dev/null +++ b/firmware/storage/www/css/style.css @@ -0,0 +1,1574 @@ +/* --- shared.css --- */ +/* Passwortfeld Toggle (zentral fΓΌr alle Seiten) */ +.password-toggle { + position: relative; + display: flex; + align-items: center; + gap: 0; +} + +.password-toggle input { + padding-right: 50px; + flex: 1; +} + +.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; + transition: color 0.2s; +} + +.password-toggle button:active { + color: var(--accent); +} + +/* Passwortfeld Toggle */ +.password-toggle { + display: flex; + align-items: center; + gap: 6px; +} + +.password-toggle input[type="password"], +.password-toggle input[type="text"] { + flex: 1; +} + +.password-toggle button { + background: none; + border: none; + font-size: 1.2em; + cursor: pointer; + color: var(--text-muted); + padding: 0 6px; + transition: color 0.2s; +} + +.password-toggle button:active { + color: var(--accent); +} + +/* 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: #190c0c; + --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)); + } +} + +/* --- index.css --- */ +/* 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; +} + +.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); + display: none; +} + +.status-item.visible { + display: flex; +} + +.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; + + /* --- captive.css --- */ + @media (max-width: 600px) { + .header { + flex-direction: row; + align-items: flex-start; + text-align: left; + } + + .header h1 { + flex: 1 1 100%; + text-align: center; + order: 2; + margin-top: 8px; + } + + .header-controls { + order: 1; + flex: 1 1 auto; + justify-content: flex-start; + display: flex; + gap: 8px; + } + } + + /* Captive Portal CSS - WiFi setup specific styles */ + /* Base styles are in shared.css */ + + body { + padding: 12px; + } + + .container { + max-width: 900px; + margin: 0 auto; + width: 100%; + } + + /* 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; + } + + .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); + } + + #connect-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; + box-shadow: none; + background-color: #888 !important; + } +} + +/* 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; +} + +/* 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/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..fc69273 --- /dev/null +++ b/firmware/storage/www/index.html @@ -0,0 +1,475 @@ + + + + + + + + + + + System Control + + + + + +
+
+
+ + +
+

πŸš‚ System Control

+
+ +
+ + +
+ + +
+
+

Lichtsteuerung

+ +
+
+
+ Licht + +
+
+ Gewitter + +
+
+
+
+ +
+

Betriebsmodus

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

Aktueller Status

+
+
+ Modus + Simulation +
+
+ Aktuelle Farbe +
+
+
+ Uhrzeit + --:-- Uhr +
+
+
+
+ + + + + + +
+ + +
+
+ + + + +
+ + +
+
+

WLAN Konfiguration

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

Verbindungsstatus

+
+

Status: Wird geladen...

+

IP-Adresse: -

+

Signal: -

+
+
+
+ + +
+ +
+

LED Konfiguration

+

Konfiguriere die LED-Segmente und Anzahl + LEDs pro + Segment

+ +
+

Segmente

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

Keine Segmente konfiguriert

+

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

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

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..5a5940c --- /dev/null +++ b/firmware/storage/www/js/app.js @@ -0,0 +1,65 @@ +// 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); + +document.addEventListener('DOMContentLoaded', async () => { + initI18n(); + initTheme(); + await initCapabilities(); + initWebSocket(); + updateConnectionStatus(); + loadLightStatus(); + + // Only load scenes and devices if thread is enabled + if (isThreadEnabled()) { + 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/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 }; +} 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..7e63d54 --- /dev/null +++ b/firmware/storage/www/js/i18n.js @@ -0,0 +1,491 @@ +// 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.light': 'πŸ’‘ Lichtsteuerung', + 'subtab.devices': 'πŸ”— GerΓ€te', + 'subtab.scenes': '🎬 Szenen', + + // 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', + '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': 'LED-Konfiguration gespeichert!', + 'wled.error.save': 'Fehler beim Speichern der LED-Konfiguration', + 'wled.loaded': 'LED-Konfiguration geladen', + + // Light Control + 'control.light.title': 'Lichtsteuerung', + 'control.light.light': 'Licht', + 'control.light.thunder': 'Gewitter', + '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', + 'control.status.clock': 'Uhrzeit', + + // 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 WLAN-Scan', + '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', + 'wifi.networks.notfound': 'Keine Netzwerke gefunden.', + 'wifi.signal': 'Signal', + 'wifi.secure': 'Gesichert', + 'wifi.open': 'Offen', + + // 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', + 'clock.suffix': 'Uhr' + }, + + en: { + // Page + 'page.title': 'System Control', + + // Main Tabs + 'tab.control': 'πŸŽ›οΈ Control', + 'tab.config': 'βš™οΈ Settings', + + // Sub Tabs + 'subtab.wifi': 'πŸ“Ά WiFi', + 'subtab.light': 'πŸ’‘ Light Control', + 'subtab.devices': 'πŸ”— Devices', + 'subtab.scenes': '🎬 Scenes', + + // 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', + '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': 'LED configuration saved!', + 'wled.error.save': 'Error saving LED configuration', + 'wled.loaded': 'LED configuration loaded', + + // Light Control + 'control.light.title': 'Light Control', + 'control.light.light': 'Light', + 'control.light.thunder': 'Thunder', + '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', + 'control.status.clock': "Time", + + // 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', + 'wifi.networks.notfound': 'No networks found.', + 'wifi.signal': 'Signal', + 'wifi.secure': 'Secured', + 'wifi.open': 'Open', + + // 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', + 'clock.suffix': "o'clock" + } +}; + +// 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')); + } + // WLAN-Optionen dynamisch ΓΌbersetzen + if (typeof updateWifiOptionsLanguage === 'function') { + updateWifiOptionsLanguage(); + } +} + +/** + * 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..2828e10 --- /dev/null +++ b/firmware/storage/www/js/light.js @@ -0,0 +1,212 @@ +// Light control +let thunderOn = false; + +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 = 'πŸ’‘'; + } +} + +// 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; + 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'); + } + + [ + 'control.status.clock' + ].forEach(i18nKey => { + const label = document.querySelector(`.status-item .status-label[data-i18n="${i18nKey}"]`); + const item = label ? label.closest('.status-item') : null; + if (item) { + if (currentMode === 'simulation') { + item.classList.add('visible'); + } else { + item.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; + } +} + +/** + * 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; + } + + // 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})`; + } + } + + // Update clock/time + if (status.clock) { + const clockEl = document.getElementById('current-clock'); + if (clockEl) { + // Use one translation key for the suffix, language is handled by t() + clockEl.textContent = status.clock + ' ' + t('clock.suffix'); + } + } + } + } catch (error) { + console.log('Light status not available'); + } +} 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..b6679ad --- /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' && typeof schemaData !== 'undefined' && 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..0f84517 --- /dev/null +++ b/firmware/storage/www/js/websocket.js @@ -0,0 +1,152 @@ +// 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) { + const activeSchemaEl = document.getElementById('active-schema'); + if (activeSchemaEl) { + activeSchemaEl.value = status.schema; + } + const schemaNames = { + 'schema_01.csv': 'Schema 1', + 'schema_02.csv': 'Schema 2', + 'schema_03.csv': 'Schema 3' + }; + const currentSchemaEl = document.getElementById('current-schema'); + if (currentSchemaEl) { + currentSchemaEl.textContent = schemaNames[status.schema] || status.schema; + } + } + + if (status.color) { + updateColorPreview(status.color.r, status.color.g, status.color.b); + } + + // Update clock/time + if (status.clock) { + const clockEl = document.getElementById('current-clock'); + if (clockEl) { + clockEl.textContent = status.clock + ' ' + (typeof t === 'function' ? t('clock.suffix') : ''); + } + } +} + +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..91ce6c8 --- /dev/null +++ b/firmware/storage/www/js/wifi-shared.js @@ -0,0 +1,271 @@ +/** + * Passwortfeld sichtbar/unsichtbar schalten (shared) + */ +function togglePassword() { + const input = document.getElementById('password'); + const btn = document.getElementById('password-btn'); + if (!input || !btn) return; + if (input.type === 'password') { + input.type = 'text'; + btn.textContent = 'πŸ™ˆ'; + } else { + input.type = 'password'; + btn.textContent = 'πŸ‘οΈ'; + } +} +// 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'); + if (!response.ok) { + // Fehlerhafte API-Antwort, aber ESP32 ist erreichbar + const errorText = await response.text(); + showStatus('wifi-status', t('wifi.error.scan') + ': ' + errorText, 'error'); + if (loading) loading.classList.remove('active'); + return; + } + 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'); + } + // Nur bei Netzwerkfehlern Demo-Daten anzeigen + if (error instanceof TypeError) { + 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'); + } else { + showStatus('wifi-status', t('wifi.error.scan') + ': ' + error.message, 'error'); + } + } +} + +/** + * 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'); + } +} diff --git a/firmware/storage/www/js/wled.js b/firmware/storage/www/js/wled.js new file mode 100644 index 0000000..0cca37f --- /dev/null +++ b/firmware/storage/www/js/wled.js @@ -0,0 +1,172 @@ +// LED Configuration Module +// Manages LED segments and 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 LED 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); diff --git a/firmware/website/.run/dev.run.xml b/firmware/website/.run/dev.run.xml new file mode 100644 index 0000000..46d8ec8 --- /dev/null +++ b/firmware/website/.run/dev.run.xml @@ -0,0 +1,12 @@ + + + + + + + + + \ No newline at end of file diff --git a/firmware/website/jsconfig.json b/firmware/website/jsconfig.json new file mode 100644 index 0000000..31e2eea --- /dev/null +++ b/firmware/website/jsconfig.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "moduleResolution": "bundler", + "target": "ESNext", + "module": "ESNext", + /** + * svelte-preprocess cannot figure out whether you have + * a value or a type, so tell TypeScript to enforce using + * `import type` instead of `import` for Types. + */ + "verbatimModuleSyntax": true, + "isolatedModules": true, + "resolveJsonModule": true, + /** + * To have warnings / errors of the Svelte compiler at the + * correct position, enable source maps by default. + */ + "sourceMap": true, + "esModuleInterop": true, + "skipLibCheck": true, + /** + * Typecheck JS in `.svelte` and `.js` files by default. + * Disable this if you'd like to use dynamic types. + */ + "checkJs": true + }, + /** + * Use global.d.ts instead of compilerOptions.types + * to avoid limiting type declarations. + */ + "include": ["src/**/*.d.ts", "src/**/*.js", "src/**/*.svelte"] +} diff --git a/firmware/website/package-lock.json b/firmware/website/package-lock.json new file mode 100644 index 0000000..1f82691 --- /dev/null +++ b/firmware/website/package-lock.json @@ -0,0 +1,1560 @@ +{ + "name": "learn-svelte", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "learn-svelte", + "dependencies": { + "@fontsource/atkinson-hyperlegible": "^5.2.6", + "@picocss/pico": "^2.1.1", + "gsap": "^3.13.0" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^6.1.1", + "svelte": "^5.38.1", + "vite": "^7.1.2", + "vite-plugin-compression": "^0.5.1" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fontsource/atkinson-hyperlegible": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/atkinson-hyperlegible/-/atkinson-hyperlegible-5.2.8.tgz", + "integrity": "sha512-HciLcJ5DIK/OVOdo71EbEN4NnvDFlp6/SpAxtcbWf2aAdcsOuPqITxj5KNEXb48qSPSdnnZdGGnSJChPKi3/bA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@picocss/pico": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@picocss/pico/-/pico-2.1.1.tgz", + "integrity": "sha512-kIDugA7Ps4U+2BHxiNHmvgPIQDWPDU4IeU6TNRdvXQM1uZX+FibqDQT2xUOnnO2yq/LUHcwnGlu1hvf4KfXnMg==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.55.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.3.tgz", + "integrity": "sha512-qyX8+93kK/7R5BEXPC2PjUt0+fS/VO2BVHjEHyIEWiYn88rcRBHmdLgoJjktBltgAf+NY7RfCGB1SoyKS/p9kg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.55.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.3.tgz", + "integrity": "sha512-6sHrL42bjt5dHQzJ12Q4vMKfN+kUnZ0atHHnv4V0Wd9JMTk7FDzSY35+7qbz3ypQYMBPANbpGK7JpnWNnhGt8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.55.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.3.tgz", + "integrity": "sha512-1ht2SpGIjEl2igJ9AbNpPIKzb1B5goXOcmtD0RFxnwNuMxqkR6AUaaErZz+4o+FKmzxcSNBOLrzsICZVNYa1Rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.55.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.3.tgz", + "integrity": "sha512-FYZ4iVunXxtT+CZqQoPVwPhH7549e/Gy7PIRRtq4t5f/vt54pX6eG9ebttRH6QSH7r/zxAFA4EZGlQ0h0FvXiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.55.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.3.tgz", + "integrity": "sha512-M/mwDCJ4wLsIgyxv2Lj7Len+UMHd4zAXu4GQ2UaCdksStglWhP61U3uowkaYBQBhVoNpwx5Hputo8eSqM7K82Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.55.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.3.tgz", + "integrity": "sha512-5jZT2c7jBCrMegKYTYTpni8mg8y3uY8gzeq2ndFOANwNuC/xJbVAoGKR9LhMDA0H3nIhvaqUoBEuJoICBudFrA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.55.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.3.tgz", + "integrity": "sha512-YeGUhkN1oA+iSPzzhEjVPS29YbViOr8s4lSsFaZKLHswgqP911xx25fPOyE9+khmN6W4VeM0aevbDp4kkEoHiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.55.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.3.tgz", + "integrity": "sha512-eo0iOIOvcAlWB3Z3eh8pVM8hZ0oVkK3AjEM9nSrkSug2l15qHzF3TOwT0747omI6+CJJvl7drwZepT+re6Fy/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.55.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.3.tgz", + "integrity": "sha512-DJay3ep76bKUDImmn//W5SvpjRN5LmK/ntWyeJs/dcnwiiHESd3N4uteK9FDLf0S0W8E6Y0sVRXpOCoQclQqNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.55.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.3.tgz", + "integrity": "sha512-BKKWQkY2WgJ5MC/ayvIJTHjy0JUGb5efaHCUiG/39sSUvAYRBaO3+/EK0AZT1RF3pSj86O24GLLik9mAYu0IJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.55.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.3.tgz", + "integrity": "sha512-Q9nVlWtKAG7ISW80OiZGxTr6rYtyDSkauHUtvkQI6TNOJjFvpj4gcH+KaJihqYInnAzEEUetPQubRwHef4exVg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.55.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.3.tgz", + "integrity": "sha512-2H5LmhzrpC4fFRNwknzmmTvvyJPHwESoJgyReXeFoYYuIDfBhP29TEXOkCJE/KxHi27mj7wDUClNq78ue3QEBQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.55.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.3.tgz", + "integrity": "sha512-9S542V0ie9LCTznPYlvaeySwBeIEa7rDBgLHKZ5S9DBgcqdJYburabm8TqiqG6mrdTzfV5uttQRHcbKff9lWtA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.55.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.3.tgz", + "integrity": "sha512-ukxw+YH3XXpcezLgbJeasgxyTbdpnNAkrIlFGDl7t+pgCxZ89/6n1a+MxlY7CegU+nDgrgdqDelPRNQ/47zs0g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.55.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.3.tgz", + "integrity": "sha512-Iauw9UsTTvlF++FhghFJjqYxyXdggXsOqGpFBylaRopVpcbfyIIsNvkf9oGwfgIcf57z3m8+/oSYTo6HutBFNw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.55.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.3.tgz", + "integrity": "sha512-3OqKAHSEQXKdq9mQ4eajqUgNIK27VZPW3I26EP8miIzuKzCJ3aW3oEn2pzF+4/Hj/Moc0YDsOtBgT5bZ56/vcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.55.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.3.tgz", + "integrity": "sha512-0CM8dSVzVIaqMcXIFej8zZrSFLnGrAE8qlNbbHfTw1EEPnFTg1U1ekI0JdzjPyzSfUsHWtodilQQG/RA55berA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.55.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.3.tgz", + "integrity": "sha512-+fgJE12FZMIgBaKIAGd45rxf+5ftcycANJRWk8Vz0NnMTM5rADPGuRFTYar+Mqs560xuART7XsX2lSACa1iOmQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.55.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.3.tgz", + "integrity": "sha512-tMD7NnbAolWPzQlJQJjVFh/fNH3K/KnA7K8gv2dJWCwwnaK6DFCYST1QXYWfu5V0cDwarWC8Sf/cfMHniNq21A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.55.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.3.tgz", + "integrity": "sha512-u5KsqxOxjEeIbn7bUK1MPM34jrnPwjeqgyin4/N6e/KzXKfpE9Mi0nCxcQjaM9lLmPcHmn/xx1yOjgTMtu1jWQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.55.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.3.tgz", + "integrity": "sha512-vo54aXwjpTtsAnb3ca7Yxs9t2INZg7QdXN/7yaoG7nPGbOBXYXQY41Km+S1Ov26vzOAzLcAjmMdjyEqS1JkVhw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.55.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.3.tgz", + "integrity": "sha512-HI+PIVZ+m+9AgpnY3pt6rinUdRYrGHvmVdsNQ4odNqQ/eRF78DVpMR7mOq7nW06QxpczibwBmeQzB68wJ+4W4A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.55.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.3.tgz", + "integrity": "sha512-vRByotbdMo3Wdi+8oC2nVxtc3RkkFKrGaok+a62AT8lz/YBuQjaVYAS5Zcs3tPzW43Vsf9J0wehJbUY5xRSekA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.55.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.3.tgz", + "integrity": "sha512-POZHq7UeuzMJljC5NjKi8vKMFN6/5EOqcX1yGntNLp7rUTpBAXQ1hW8kWPFxYLv07QMcNM75xqVLGPWQq6TKFA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.55.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.3.tgz", + "integrity": "sha512-aPFONczE4fUFKNXszdvnd2GqKEYQdV5oEsIbKPujJmWlCI9zEsv1Otig8RKK+X9bed9gFUN6LAeN4ZcNuu4zjg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz", + "integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.4.tgz", + "integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", + "deepmerge": "^4.3.1", + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.1" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-5.0.2.tgz", + "integrity": "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "obug": "^2.1.0" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/devalue": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.2.tgz", + "integrity": "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.2.tgz", + "integrity": "sha512-zA6497ha+qKvoWIK+WM9NAh5ni17sKZKhbS5B3PoYbBvaYHZWoS33zmFybmyqpn07RLUxSmn+RCls2/XF+d0oQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/gsap": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.14.2.tgz", + "integrity": "sha512-P8/mMxVLU7o4+55+1TCnQrPmgjPKnwkzkXOK1asnR9Jg2lna4tEY5qBJjMmAaOBDDZWtlRjBXjLa0w53G/uBLA==", + "license": "Standard 'no charge' license: https://gsap.com/standard-license." + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.55.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.3.tgz", + "integrity": "sha512-y9yUpfQvetAjiDLtNMf1hL9NXchIJgWt6zIKeoB+tCd3npX08Eqfzg60V9DhIGVMtQ0AlMkFw5xa+AQ37zxnAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.55.3", + "@rollup/rollup-android-arm64": "4.55.3", + "@rollup/rollup-darwin-arm64": "4.55.3", + "@rollup/rollup-darwin-x64": "4.55.3", + "@rollup/rollup-freebsd-arm64": "4.55.3", + "@rollup/rollup-freebsd-x64": "4.55.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.55.3", + "@rollup/rollup-linux-arm-musleabihf": "4.55.3", + "@rollup/rollup-linux-arm64-gnu": "4.55.3", + "@rollup/rollup-linux-arm64-musl": "4.55.3", + "@rollup/rollup-linux-loong64-gnu": "4.55.3", + "@rollup/rollup-linux-loong64-musl": "4.55.3", + "@rollup/rollup-linux-ppc64-gnu": "4.55.3", + "@rollup/rollup-linux-ppc64-musl": "4.55.3", + "@rollup/rollup-linux-riscv64-gnu": "4.55.3", + "@rollup/rollup-linux-riscv64-musl": "4.55.3", + "@rollup/rollup-linux-s390x-gnu": "4.55.3", + "@rollup/rollup-linux-x64-gnu": "4.55.3", + "@rollup/rollup-linux-x64-musl": "4.55.3", + "@rollup/rollup-openbsd-x64": "4.55.3", + "@rollup/rollup-openharmony-arm64": "4.55.3", + "@rollup/rollup-win32-arm64-msvc": "4.55.3", + "@rollup/rollup-win32-ia32-msvc": "4.55.3", + "@rollup/rollup-win32-x64-gnu": "4.55.3", + "@rollup/rollup-win32-x64-msvc": "4.55.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/svelte": { + "version": "5.47.1", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.47.1.tgz", + "integrity": "sha512-MhSWfWEpG5T57z0Oyfk9D1GhAz/KTZKZZlWtGEsy9zNk2fafpuU7sJQlXNSA8HtvwKxVC9XlDyl5YovXUXjjHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/estree": "^1.0.5", + "acorn": "^8.12.1", + "aria-query": "^5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.6.2", + "esm-env": "^1.2.1", + "esrap": "^2.2.1", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-plugin-compression": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/vite-plugin-compression/-/vite-plugin-compression-0.5.1.tgz", + "integrity": "sha512-5QJKBDc+gNYVqL/skgFAP81Yuzo9R+EAf19d+EtsMF/i8kFUpNi3J/H01QD3Oo8zBQn+NzoCIFkpPLynoOzaJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "debug": "^4.3.3", + "fs-extra": "^10.0.0" + }, + "peerDependencies": { + "vite": ">=2.0.0" + } + }, + "node_modules/vitefu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz", + "integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/firmware/website/package.json b/firmware/website/package.json new file mode 100644 index 0000000..dcf9bce --- /dev/null +++ b/firmware/website/package.json @@ -0,0 +1,20 @@ +{ + "name": "learn-svelte", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^6.1.1", + "svelte": "^5.38.1", + "vite": "^7.1.2", + "vite-plugin-compression": "^0.5.1" + }, + "dependencies": { + "@fontsource/atkinson-hyperlegible": "^5.2.6", + "@picocss/pico": "^2.1.1", + "gsap": "^3.13.0" + } +} diff --git a/firmware/website/public/favicon.svg b/firmware/website/public/favicon.svg new file mode 100644 index 0000000..104f44f --- /dev/null +++ b/firmware/website/public/favicon.svg @@ -0,0 +1 @@ +πŸš‚ \ No newline at end of file diff --git a/firmware/website/src/App.svelte b/firmware/website/src/App.svelte new file mode 100644 index 0000000..b3af96c --- /dev/null +++ b/firmware/website/src/App.svelte @@ -0,0 +1,27 @@ + + +
+ +{#if $isCaptive} + +{:else} + +{/if} diff --git a/firmware/website/src/Captive.svelte b/firmware/website/src/Captive.svelte new file mode 100644 index 0000000..8cf5ff5 --- /dev/null +++ b/firmware/website/src/Captive.svelte @@ -0,0 +1,5 @@ + + +

{$t("welcome")} - Captive Portal

diff --git a/firmware/website/src/Index.svelte b/firmware/website/src/Index.svelte new file mode 100644 index 0000000..baca94e --- /dev/null +++ b/firmware/website/src/Index.svelte @@ -0,0 +1,5 @@ + + +

{$t("welcome")}

diff --git a/firmware/website/src/app.css b/firmware/website/src/app.css new file mode 100644 index 0000000..bd313c2 --- /dev/null +++ b/firmware/website/src/app.css @@ -0,0 +1,70 @@ +: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; +} + +[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; +} + +[data-theme="light"] .header h1 { + color: var(--primary); +} + +* { + 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; + padding: 12px; +} + +h1 { + font-size: 1.5rem; + color: var(--text); +} + +#app { + max-width: 900px; + margin: 0 auto; +} + +@media (max-width: 600px) { + body { + padding: 6px; + } +} + +@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/website/src/compoents/Header.svelte b/firmware/website/src/compoents/Header.svelte new file mode 100644 index 0000000..b91ed79 --- /dev/null +++ b/firmware/website/src/compoents/Header.svelte @@ -0,0 +1,133 @@ + + +
+
+ { + const newLang = currentLangCode === "de" ? "en" : "de"; + handleLangChange(newLang); + }} + /> + + +
+

πŸš‚ System Control

+
+ + diff --git a/firmware/website/src/compoents/Toggle.svelte b/firmware/website/src/compoents/Toggle.svelte new file mode 100644 index 0000000..32c605e --- /dev/null +++ b/firmware/website/src/compoents/Toggle.svelte @@ -0,0 +1,37 @@ + + + + + diff --git a/firmware/website/src/i18n/de.json b/firmware/website/src/i18n/de.json new file mode 100644 index 0000000..7bc9b31 --- /dev/null +++ b/firmware/website/src/i18n/de.json @@ -0,0 +1,6 @@ +{ + "hello": "Hallo Welt", + "welcome": "Willkommen", + "language": "Sprache", + "save": "Speichern" +} \ No newline at end of file diff --git a/firmware/website/src/i18n/en.json b/firmware/website/src/i18n/en.json new file mode 100644 index 0000000..ab909b1 --- /dev/null +++ b/firmware/website/src/i18n/en.json @@ -0,0 +1,6 @@ +{ + "hello": "Hello World", + "welcome": "Welcome", + "language": "Language", + "save": "Save" +} \ No newline at end of file diff --git a/firmware/website/src/i18n/index.ts b/firmware/website/src/i18n/index.ts new file mode 100644 index 0000000..a46f53a --- /dev/null +++ b/firmware/website/src/i18n/index.ts @@ -0,0 +1,12 @@ +import de from './de.json'; +import en from './en.json'; + +export const translations = { de, en }; + +export type Lang = keyof typeof translations; + +export function getInitialLang(): Lang { + const navLang = navigator.language.slice(0, 2); + if (navLang in translations) return navLang as Lang; + return 'en'; +} diff --git a/firmware/website/src/i18n/store.ts b/firmware/website/src/i18n/store.ts new file mode 100644 index 0000000..04f18ff --- /dev/null +++ b/firmware/website/src/i18n/store.ts @@ -0,0 +1,16 @@ +import { writable, derived } from 'svelte/store'; +import { translations, getInitialLang, type Lang } from './index'; + +function getLang(): Lang { + const stored = localStorage.getItem('lang'); + if (stored && stored in translations) return stored as Lang; + return getInitialLang(); +} + +export const lang = writable(getLang()); + +export const t = derived(lang, $lang => { + return (key: string) => { + return translations[$lang][key] || key; + }; +}); diff --git a/firmware/website/src/main.js b/firmware/website/src/main.js new file mode 100644 index 0000000..34d488f --- /dev/null +++ b/firmware/website/src/main.js @@ -0,0 +1,9 @@ +import { mount } from 'svelte' +import App from './App.svelte' +import './app.css' + +const app = mount(App, { + target: document.getElementById('app'), +}) + +export default app diff --git a/firmware/website/src/theme.ts b/firmware/website/src/theme.ts new file mode 100644 index 0000000..84a4732 --- /dev/null +++ b/firmware/website/src/theme.ts @@ -0,0 +1,18 @@ +// src/theme.ts +export function setTheme(theme: 'light' | 'dark') { + 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"]') as HTMLMetaElement | null; + + if (icon) icon.textContent = theme === 'light' ? 'β˜€οΈ' : 'πŸŒ™'; + if (label) label.textContent = theme === 'light' ? 'Light' : 'Dark'; + if (metaTheme) metaTheme.content = theme === 'light' ? '#f0f2f5' : '#1a1a2e'; +} + +export function toggleTheme() { + const current = document.documentElement.getAttribute('data-theme') as 'light' | 'dark' | null || 'dark'; + setTheme(current === 'dark' ? 'light' : 'dark'); +} diff --git a/firmware/website/src/vite-env.d.ts b/firmware/website/src/vite-env.d.ts new file mode 100644 index 0000000..4078e74 --- /dev/null +++ b/firmware/website/src/vite-env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/firmware/website/svelte.config.js b/firmware/website/svelte.config.js new file mode 100644 index 0000000..ffb1d68 --- /dev/null +++ b/firmware/website/svelte.config.js @@ -0,0 +1,17 @@ +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte' + +/** @type {import("@sveltejs/vite-plugin-svelte").SvelteConfig} */ +export default { + // Consult https://svelte.dev/docs#compile-time-svelte-preprocess + // for more information about preprocessors + preprocess: vitePreprocess(), + compilerOptions: { + runes: true, + }, + vitePlugin: { + inspector: { + showToggleButton: 'always', + toggleButtonPos: 'bottom-right' + } + } +} diff --git a/firmware/website/vite.config.js b/firmware/website/vite.config.js new file mode 100644 index 0000000..d370ba3 --- /dev/null +++ b/firmware/website/vite.config.js @@ -0,0 +1,14 @@ +import { defineConfig } from 'vite' +import { svelte } from '@sveltejs/vite-plugin-svelte' +import viteCompression from 'vite-plugin-compression'; + +export default defineConfig({ + plugins: [ + svelte(), + viteCompression() + ], + build: { + outDir: '../storage/website', + assetsDir: '', + }, +})