Merge branch 'feature/website'

Signed-off-by: Peter Siegmund <developer@mars3142.org>
This commit is contained in:
2026-01-25 09:26:42 +01:00
109 changed files with 13149 additions and 791 deletions

2
firmware/.clangd Normal file
View File

@@ -0,0 +1,2 @@
CompileFlags:
Remove: [-f*, -m*]

View File

@@ -1,4 +1,4 @@
ARG DOCKER_TAG=latest ARG DOCKER_TAG=release-v5.4
FROM espressif/idf:${DOCKER_TAG} FROM espressif/idf:${DOCKER_TAG}
ENV LC_ALL=C.UTF-8 ENV LC_ALL=C.UTF-8

View File

@@ -1,4 +1,9 @@
cmake_minimum_required(VERSION 3.5) cmake_minimum_required(VERSION 3.5)
include($ENV{IDF_PATH}/tools/cmake/project.cmake) 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) project(system_control)
target_add_binary_data(${PROJECT_NAME}.elf "main/isrgrootx1.pem" TEXT)

859
firmware/README-API.md Normal file
View File

@@ -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"
}
```

209
firmware/README-captive.md Normal file
View File

@@ -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` | `<HTML><HEAD><TITLE>Success</TITLE></HEAD><BODY>Success</BODY></HTML>` |
| **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.

View File

@@ -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.

View File

@@ -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");
}

View File

@@ -0,0 +1 @@
idf_component_register(SRCS "extra_component.c")

View File

@@ -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");
}

View File

@@ -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
)

View File

@@ -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 <hostname>.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

View File

@@ -0,0 +1,5 @@
dependencies:
idf:
version: '>=5.0.0'
espressif/mdns:
version: '*'

View File

@@ -0,0 +1,63 @@
#pragma once
#include <esp_http_server.h>
#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

View File

@@ -0,0 +1,119 @@
#pragma once
#include <esp_err.h>
#include <esp_http_server.h>
#include <stdbool.h>
#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

View File

@@ -0,0 +1,9 @@
#ifndef COMMON_H
#define COMMON_H
#include <cJSON.h>
void common_init(void);
cJSON *create_light_status_json(void);
#endif // COMMON_H

View File

@@ -0,0 +1,60 @@
#pragma once
#include <esp_http_server.h>
#include <stdbool.h>
#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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,200 @@
#include "api_server.h"
#include "api_handlers.h"
#include "websocket_handler.h"
#include "common.h"
#include "storage.h"
#include <esp_http_server.h>
#include <esp_log.h>
#include <mdns.h>
#include <string.h>
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);
}

View File

@@ -0,0 +1,79 @@
#include "common.h"
#include <cJSON.h>
#include <stdbool.h>
#include "api_server.h"
#include "color.h"
#include "message_manager.h"
#include "persistence_manager.h"
#include "simulator.h"
#include <string.h>
#include <time.h>
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;
}

View File

@@ -0,0 +1,276 @@
#include "websocket_handler.h"
#include "api_server.h"
#include "common.h"
#include "message_manager.h"
#include <esp_http_server.h>
#include <esp_log.h>
#include <string.h>
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;
}

View File

@@ -2,6 +2,7 @@ idf_component_register(SRCS
src/ble/ble_connection.c src/ble/ble_connection.c
src/ble/ble_scanner.c src/ble/ble_scanner.c
src/ble_manager.c src/ble_manager.c
src/dns_hijack.c
src/wifi_manager.c src/wifi_manager.c
INCLUDE_DIRS "include" INCLUDE_DIRS "include"
REQUIRES REQUIRES
@@ -9,5 +10,7 @@ idf_component_register(SRCS
driver driver
nvs_flash nvs_flash
esp_insights esp_insights
analytics
led-manager led-manager
api-server
) )

View File

@@ -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

View File

@@ -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 <arpa/inet.h>
#include <esp_log.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <lwip/inet.h>
#include <lwip/sockets.h>
#include <netinet/in.h>
#include <string.h>
#include <sys/socket.h>
#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);
}

View File

@@ -1,5 +1,8 @@
#include "wifi_manager.h" #include "wifi_manager.h"
#include "dns_hijack.h"
#include "analytics.h"
#include "api_server.h"
#include <esp_event.h> #include <esp_event.h>
#include <esp_insights.h> #include <esp_insights.h>
#include <esp_log.h> #include <esp_log.h>
@@ -11,219 +14,166 @@
#include <led_status.h> #include <led_status.h>
#include <lwip/err.h> #include <lwip/err.h>
#include <lwip/sys.h> #include <lwip/sys.h>
#include <mdns.h>
#include <nvs_flash.h> #include <nvs_flash.h>
#include <persistence_manager.h>
#include <sdkconfig.h> #include <sdkconfig.h>
#include <string.h> #include <string.h>
// Event group to signal when we are connected // Event group to signal WiFi connection status
static EventGroupHandle_t s_wifi_event_group; static EventGroupHandle_t s_wifi_event_group;
// The bits for the event group // Event group bits
#define WIFI_CONNECTED_BIT BIT0 #define WIFI_CONNECTED_BIT BIT0
#define WIFI_FAIL_BIT BIT1 #define WIFI_FAIL_BIT BIT1
static const char *TAG = "wifi_manager"; static const char *TAG = "wifi_manager";
static int s_retry_num = 0; static void led_status_reconnect()
static int s_current_network_index = 0;
// WiFi network configuration structure
typedef struct
{ {
const char *ssid; led_behavior_t led_behavior = {
const char *password; .on_time_ms = 250,
} wifi_network_config_t; .off_time_ms = 100,
.color = {.red = 50, .green = 50, .blue = 0},
// 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,
},
};
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
}
static void try_next_network(void)
{
#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, .index = 0,
.mode = LED_MODE_BLINK, .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); led_status_set_behavior(led_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) static void wifi_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) if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START)
{ {
led_behavior_t led0_behavior = { ESP_LOGI(TAG, "WIFI_EVENT_STA_START: Connecting to AP...");
.index = 0, esp_wifi_connect();
.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);
} }
else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED)
{ {
if (s_retry_num < CONFIG_WIFI_CONNECT_RETRIES) ESP_LOGW(TAG, "WIFI_EVENT_STA_DISCONNECTED: Verbindung verloren, versuche erneut...");
{ xEventGroupSetBits(s_wifi_event_group, WIFI_FAIL_BIT);
led_behavior_t led0_behavior = { led_status_reconnect();
.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(); 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();
} }
else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) 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; 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), ESP_LOGI(TAG, "IP_EVENT_STA_GOT_IP: IP-Adresse erhalten: " IPSTR, IP2STR(&event->ip_info.ip));
s_current_network_index + 1, s_wifi_networks[s_current_network_index].ssid); analytics_init();
s_retry_num = 0;
xEventGroupSetBits(s_wifi_event_group, WIFI_CONNECTED_BIT); 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() void wifi_manager_init()
{ {
#if CONFIG_WIFI_ENABLED
s_wifi_event_group = xEventGroupCreate(); s_wifi_event_group = xEventGroupCreate();
s_current_network_index = 0;
s_retry_num = 0;
ESP_ERROR_CHECK(esp_netif_init()); ESP_ERROR_CHECK(esp_netif_init());
ESP_ERROR_CHECK(esp_event_loop_create_default()); ESP_ERROR_CHECK(esp_event_loop_create_default());
esp_netif_create_default_wifi_sta();
// Default WiFi Station
esp_netif_t *sta_netif = esp_netif_create_default_wifi_sta();
// 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));
// 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)
{
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;
}
if (have_ssid && have_password)
{
led_status_reconnect();
ESP_LOGI(TAG, "Found WiFi configuration: SSID='%s'", ssid);
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
ESP_ERROR_CHECK(esp_wifi_init(&cfg)); ESP_ERROR_CHECK(esp_wifi_init(&cfg));
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));
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); 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()); ESP_ERROR_CHECK(esp_wifi_start());
ESP_DIAG_EVENT(TAG, "WiFi manager initialized with %d network(s), waiting for connection...", s_wifi_network_count); int retries = 0;
EventBits_t bits;
/* Waiting until either the connection is established (WIFI_CONNECTED_BIT) or do
connection failed for all networks (WIFI_FAIL_BIT). The bits are set by event_handler() */ {
EventBits_t bits = ESP_LOGI(TAG, "Warte auf IP-Adresse (DHCP)...");
xEventGroupWaitBits(s_wifi_event_group, WIFI_CONNECTED_BIT | WIFI_FAIL_BIT, pdFALSE, pdFALSE, portMAX_DELAY); bits = xEventGroupWaitBits(s_wifi_event_group, WIFI_CONNECTED_BIT | WIFI_FAIL_BIT, pdFALSE, pdFALSE,
10000 / portTICK_PERIOD_MS);
if (bits & WIFI_CONNECTED_BIT) if (bits & WIFI_CONNECTED_BIT)
{ {
ESP_DIAG_EVENT(TAG, "Connected to AP SSID:%s", s_wifi_networks[s_current_network_index].ssid); 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;
} }
else if (bits & WIFI_FAIL_BIT) retries++;
} while (!(bits & WIFI_CONNECTED_BIT) && retries < CONFIG_WIFI_CONNECT_RETRIES);
if (!(bits & WIFI_CONNECTED_BIT))
{ {
ESP_LOGE(TAG, "Failed to connect to any configured WiFi network"); 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 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));
} }

View File

@@ -18,4 +18,5 @@ idf_component_register(SRCS
led-manager led-manager
persistence-manager persistence-manager
simulator simulator
message-manager
) )

View File

@@ -17,7 +17,7 @@
// Project-specific headers // Project-specific headers
#include "common/Widget.h" #include "common/Widget.h"
#include "IPersistenceManager.h" #include "persistence_manager.h"
#include "u8g2.h" #include "u8g2.h"
class MenuItem; class MenuItem;
@@ -61,14 +61,8 @@ typedef struct
std::function<void(ButtonType button)> onButtonClicked; std::function<void(ButtonType button)> onButtonClicked;
/** /**
* @brief Shared pointer to platform-independent persistence manager * @brief Zeiger auf C-Persistence-Manager-Instanz
* @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.
*/ */
std::shared_ptr<IPersistenceManager> persistenceManager; persistence_manager_t *persistenceManager;
} menu_options_t; } menu_options_t;

View File

@@ -175,6 +175,21 @@ class Menu : public Widget
*/ */
void toggle(const MenuItem &menuItem); 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 * @brief Changes the selected value of a selection menu item based on button input
* @param menuItem The selection menu item to modify * @param menuItem The selection menu item to modify
@@ -191,6 +206,8 @@ class Menu : public Widget
*/ */
MenuItem switchValue(const MenuItem &menuItem, ButtonType button); MenuItem switchValue(const MenuItem &menuItem, ButtonType button);
void setSelectionIndex(const MenuItem &menuItem, int index);
private: private:
MenuItem replaceItem(int index, const MenuItem &item); MenuItem replaceItem(int index, const MenuItem &item);

View File

@@ -13,6 +13,7 @@
#include "u8g2.h" #include "u8g2.h"
#include "common/Common.h" #include "common/Common.h"
#include "message_manager.h"
/** /**
* @class Widget * @class Widget
@@ -49,7 +50,9 @@ class Widget
* @details Ensures that derived class destructors are called correctly when * @details Ensures that derived class destructors are called correctly when
* a widget is destroyed through a base class pointer. * 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 * @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. * the u8g2 context and assumes it is managed externally.
*/ */
u8g2_t *u8g2; u8g2_t *u8g2;
private:
static std::vector<Widget *> s_instances;
static void globalMessageCallback(const message_t *msg);
}; };

View File

@@ -120,6 +120,8 @@ class LightMenu final : public Menu
*/ */
void onButtonPressed(const MenuItem &menuItem, ButtonType button) override; void onButtonPressed(const MenuItem &menuItem, ButtonType button) override;
void onMessageReceived(const message_t *msg);
/** /**
* @brief Pointer to menu options configuration structure * @brief Pointer to menu options configuration structure
* @details Stores a reference to the menu configuration passed during construction. * @details Stores a reference to the menu configuration passed during construction.

View File

@@ -28,7 +28,7 @@ constexpr int BOTTOM_OFFSET = 10;
Menu::Menu(menu_options_t *options) : Widget(options->u8g2), m_options(options) 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); }; 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; constexpr int key_length = 20;
char key[key_length] = ""; char key[key_length] = "";
snprintf(key, key_length, "section_%zu", i + 1 - startIndex); 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); addSelection(i + 1, caption, m_items.at(0).getValues(), index);
} }
@@ -82,6 +82,12 @@ void Menu::toggle(const MenuItem &menuItem)
replaceItem(menuItem.getId(), item); 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 Menu::switchValue(const MenuItem &menuItem, ButtonType button)
{ {
MenuItem result = menuItem; MenuItem result = menuItem;
@@ -120,6 +126,15 @@ MenuItem Menu::switchValue(const MenuItem &menuItem, ButtonType button)
return result; 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) MenuItem Menu::replaceItem(const int index, const MenuItem &item)
{ {
m_items.at(index) = item; m_items.at(index) = item;
@@ -134,13 +149,13 @@ void Menu::Render()
m_selected_item = 0; m_selected_item = 0;
} }
// Early return if no items to render // Early return if there are no items to render
if (m_items.empty()) if (m_items.empty())
{ {
return; return;
} }
// Clear screen with black background // Clear the screen with a black background
u8g2_SetDrawColor(u8g2, 0); u8g2_SetDrawColor(u8g2, 0);
u8g2_DrawBox(u8g2, 0, 0, u8g2->width, u8g2->height); u8g2_DrawBox(u8g2, 0, 0, u8g2->width, u8g2->height);
@@ -151,7 +166,7 @@ void Menu::Render()
drawScrollBar(); drawScrollBar();
drawSelectionBox(); drawSelectionBox();
// Calculate center position for main item // Calculate center position for the main item
const int centerY = u8g2->height / 2 + 3; const int centerY = u8g2->height / 2 + 3;
// Render the currently selected item (main/center item) // 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 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_SetFont(u8g2, font);
u8g2_DrawStr(u8g2, x, y, item->getText().c_str()); 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: { case MenuItemTypes::TOGGLE: {
// Draw checkbox frame // Draw the checkbox frame
const int frameX = u8g2->width - UIConstants::FRAME_BOX_SIZE - UIConstants::SELECTION_MARGIN; const int frameX = u8g2->width - UIConstants::FRAME_BOX_SIZE - UIConstants::SELECTION_MARGIN;
const int frameY = y - UIConstants::FRAME_OFFSET; const int frameY = y - UIConstants::FRAME_OFFSET;
u8g2_DrawFrame(u8g2, frameX, frameY, UIConstants::FRAME_BOX_SIZE, UIConstants::FRAME_BOX_SIZE); u8g2_DrawFrame(u8g2, frameX, frameY, UIConstants::FRAME_BOX_SIZE, UIConstants::FRAME_BOX_SIZE);
@@ -272,7 +287,7 @@ void Menu::onPressedDown()
if (m_items.empty()) if (m_items.empty())
return; 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(); m_selected_item = (m_selected_item + 1) % m_items.size();
} }
@@ -281,7 +296,7 @@ void Menu::onPressedUp()
if (m_items.empty()) if (m_items.empty())
return; 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; 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 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) if (m_options && m_options->popScreen)
{ {
m_options->popScreen(); m_options->popScreen();

View File

@@ -1,7 +1,20 @@
#include "common/Widget.h" #include "common/Widget.h"
#include <algorithm>
std::vector<Widget *> Widget::s_instances;
Widget::Widget(u8g2_t *u8g2) : u8g2(u8g2) 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() void Widget::onEnter()
@@ -36,3 +49,15 @@ const char *Widget::getName() const
{ {
return "Widget"; return "Widget";
} }
void Widget::onMessageReceived(const message_t *msg)
{
}
void Widget::globalMessageCallback(const message_t *msg)
{
for (auto *w : s_instances)
{
w->onMessageReceived(msg);
}
}

View File

@@ -1,5 +1,5 @@
#include "ui/ClockScreenSaver.h" #include "ui/ClockScreenSaver.h"
#include "hal_esp32/PersistenceManager.h" #include "persistence_manager.h"
#include "simulator.h" #include "simulator.h"
#include <cstring> #include <cstring>
#include <ctime> #include <ctime>
@@ -38,13 +38,14 @@ void ClockScreenSaver::updateTextDimensions()
void ClockScreenSaver::getCurrentTimeString(char *buffer, size_t bufferSize) const void ClockScreenSaver::getCurrentTimeString(char *buffer, size_t bufferSize) const
{ {
if (m_options && m_options->persistenceManager->GetValue("light_active", false) && if (m_options && m_options->persistenceManager &&
m_options->persistenceManager->GetValue("light_mode", 0) == 0) 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(); char *simulated_time = get_time();
if (simulated_time != nullptr) if (simulated_time != nullptr)
{ {
strncpy(buffer, simulated_time, bufferSize); snprintf(buffer, bufferSize, "%s Uhr", simulated_time);
return; return;
} }
} }

View File

@@ -1,7 +1,8 @@
#include "ui/LightMenu.h" #include "ui/LightMenu.h"
#include "led_strip_ws2812.h" #include "led_strip_ws2812.h"
#include "message_manager.h"
#include "simulator.h" #include "simulator.h"
#include <cstring>
/** /**
* @namespace LightMenuItem * @namespace LightMenuItem
@@ -27,7 +28,7 @@ LightMenu::LightMenu(menu_options_t *options) : Menu(options), m_options(options
bool active = false; bool active = false;
if (m_options && m_options->persistenceManager) 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); addToggle(LightMenuItem::ACTIVATE, "Einschalten", active);
@@ -39,7 +40,8 @@ LightMenu::LightMenu(menu_options_t *options) : Menu(options), m_options(options
int mode_value = 0; int mode_value = 0;
if (m_options && m_options->persistenceManager) 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); 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; int variant_value = 3;
if (m_options && m_options->persistenceManager) 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); addSelection(LightMenuItem::VARIANT, "Variante", variants, variant_value);
} }
@@ -68,12 +72,13 @@ void LightMenu::onButtonPressed(const MenuItem &menuItem, const ButtonType butto
{ {
toggle(menuItem); toggle(menuItem);
const auto value = getItem(menuItem.getId()).getValue() == "1"; const auto value = getItem(menuItem.getId()).getValue() == "1";
if (m_options && m_options->persistenceManager) // Post change via message_manager
{ message_t msg = {};
m_options->persistenceManager->SetValue(LightMenuOptions::LIGHT_ACTIVE, value); 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);
start_simulation(); msg.data.settings.value.bool_value = value;
message_manager_post(&msg, pdMS_TO_TICKS(100));
} }
break; break;
} }
@@ -86,11 +91,14 @@ void LightMenu::onButtonPressed(const MenuItem &menuItem, const ButtonType butto
const auto value = getItem(item.getId()).getIndex(); const auto value = getItem(item.getId()).getIndex();
if (m_options && m_options->persistenceManager) if (m_options && m_options->persistenceManager)
{ {
m_options->persistenceManager->SetValue(LightMenuOptions::LIGHT_MODE, value); // Post change via message_manager
m_options->persistenceManager->Save(); 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; break;
} }
@@ -103,11 +111,14 @@ void LightMenu::onButtonPressed(const MenuItem &menuItem, const ButtonType butto
const auto value = getItem(item.getId()).getIndex() + 1; const auto value = getItem(item.getId()).getIndex() + 1;
if (m_options && m_options->persistenceManager) if (m_options && m_options->persistenceManager)
{ {
m_options->persistenceManager->SetValue(LightMenuOptions::LIGHT_VARIANT, value); // Post change via message_manager
m_options->persistenceManager->Save(); 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; 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) IMPLEMENT_GET_NAME(LightMenu)

View File

@@ -12,9 +12,9 @@ typedef struct
typedef struct typedef struct
{ {
uint8_t h; float h;
uint8_t s; float s;
uint8_t v; float v;
} hsv_t; } hsv_t;
__BEGIN_DECLS __BEGIN_DECLS

View File

@@ -0,0 +1,16 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#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;

View File

@@ -45,10 +45,10 @@ rgb_t interpolate_color_hsv(rgb_t start, rgb_t end, float factor)
// Interpolate HSV values // Interpolate HSV values
hsv_t interpolated_hsv; hsv_t interpolated_hsv;
interpolated_hsv.h = fmod(h1 + (h2 - h1) * factor, 360.0); interpolated_hsv.h = fmodf(h1 + (h2 - h1) * factor, 360.0f);
if (interpolated_hsv.h < 0) 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.s = start_hsv.s + (end_hsv.s - start_hsv.s) * factor;
interpolated_hsv.v = start_hsv.v + (end_hsv.v - start_hsv.v) * factor; interpolated_hsv.v = start_hsv.v + (end_hsv.v - start_hsv.v) * factor;

View File

@@ -0,0 +1,8 @@
idf_component_register(
SRCS "src/message_manager.c"
INCLUDE_DIRS "include"
PRIV_REQUIRES
persistence-manager
my_mqtt_client
app_update
)

View File

@@ -0,0 +1,79 @@
#pragma once
#include <freertos/FreeRTOS.h>
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#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

View File

@@ -0,0 +1,134 @@
#include "message_manager.h"
#include "my_mqtt_client.h"
#include <esp_app_desc.h>
#include <esp_log.h>
#include <esp_mac.h>
#include <esp_system.h>
#include <freertos/FreeRTOS.h>
#include <freertos/queue.h>
#include <freertos/task.h>
#include <persistence_manager.h>
#include <sdkconfig.h>
#include <string.h>
#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;
}

View File

@@ -0,0 +1,7 @@
idf_component_register(
SRCS "src/my_mqtt_client.c"
INCLUDE_DIRS "include"
REQUIRES
mqtt
app_update
)

View File

@@ -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

View File

@@ -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`.

View File

@@ -0,0 +1,2 @@
dependencies:
espressif/mqtt: ^1.0.0

View File

@@ -0,0 +1,14 @@
#pragma once
#include <stdbool.h>
#include <stddef.h>
#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

View File

@@ -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!");
}
}

View File

@@ -1,5 +1,5 @@
idf_component_register(SRCS idf_component_register(SRCS
src/PersistenceManager.cpp src/persistence_manager.c
INCLUDE_DIRS "include" INCLUDE_DIRS "include"
REQUIRES REQUIRES
nvs_flash nvs_flash

View File

@@ -1,108 +0,0 @@
#pragma once
#include <string>
#include <type_traits>
/**
* @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<typename T>
void SetValue(const std::string& key, const T& value) {
static_assert(std::is_same_v<T, bool> ||
std::is_same_v<T, int> ||
std::is_same_v<T, float> ||
std::is_same_v<T, double> ||
std::is_same_v<T, std::string>,
"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<typename T>
[[nodiscard]] T GetValue(const std::string& key, const T& defaultValue = T{}) const {
return GetValueImpl<T>(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<typename T>
[[nodiscard]] T GetValueImpl(const std::string& key, const T& defaultValue) const
{
if constexpr (std::is_same_v<T, bool>) {
return GetValueImpl(key, static_cast<bool>(defaultValue));
} else if constexpr (std::is_same_v<T, int>) {
return GetValueImpl(key, static_cast<int>(defaultValue));
} else if constexpr (std::is_same_v<T, float>) {
return GetValueImpl(key, static_cast<float>(defaultValue));
} else if constexpr (std::is_same_v<T, double>) {
return GetValueImpl(key, static_cast<double>(defaultValue));
} else if constexpr (std::is_same_v<T, std::string>) {
return GetValueImpl(key, static_cast<const std::string&>(defaultValue));
} else {
static_assert(std::is_same_v<T, bool> ||
std::is_same_v<T, int> ||
std::is_same_v<T, float> ||
std::is_same_v<T, double> ||
std::is_same_v<T, std::string>,
"Unsupported type for IPersistenceManager");
return defaultValue; // This line will never be reached, but satisfies the compiler
}
}
};

View File

@@ -1,58 +0,0 @@
#pragma once
#include "IPersistenceManager.h"
#include <string>
#include <unordered_map>
#include <nvs.h>
#include <nvs_flash.h>
/**
* @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;
};

View File

@@ -0,0 +1,237 @@
#pragma once
#include <esp_err.h>
#include <nvs.h>
#include <nvs_flash.h>
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#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

View File

@@ -1,279 +0,0 @@
#include "hal_esp32/PersistenceManager.h"
#include <cstring>
#include <esp_log.h>
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<int>(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;
}

View File

@@ -0,0 +1,274 @@
#include "persistence_manager.h"
#include <esp_log.h>
#include <string.h>
#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;
}

View File

@@ -5,5 +5,6 @@ idf_component_register(SRCS
PRIV_REQUIRES PRIV_REQUIRES
led-manager led-manager
persistence-manager persistence-manager
message-manager
spiffs spiffs
) )

View File

@@ -9,12 +9,20 @@ typedef struct
int cycle_duration_minutes; int cycle_duration_minutes;
} simulation_config_t; } simulation_config_t;
char *get_time(void); #ifdef __cplusplus
esp_err_t add_light_item(const char time[5], uint8_t red, uint8_t green, uint8_t blue, uint8_t white, 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); uint8_t brightness, uint8_t saturation);
void cleanup_light_items(void); void cleanup_light_items(void);
void start_simulate_day(void); void start_simulate_day(void);
void start_simulate_night(void); void start_simulate_night(void);
void start_simulation_task(void); void start_simulation_task(void);
void stop_simulation_task(void); void stop_simulation_task(void);
void start_simulation(void); void start_simulation(void);
#ifdef __cplusplus
}
#endif

View File

@@ -1,4 +1,23 @@
#pragma once #pragma once
void initialize_storage(); #include "esp_err.h"
void load_file(const char *filename);
#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

View File

@@ -1,8 +1,9 @@
#include "simulator.h" #include "simulator.h"
#include "color.h" #include "color.h"
#include "hal_esp32/PersistenceManager.h"
#include "led_strip_ws2812.h" #include "led_strip_ws2812.h"
#include "message_manager.h"
#include "persistence_manager.h"
#include "storage.h" #include "storage.h"
#include <esp_heap_caps.h> #include <esp_heap_caps.h>
#include <esp_log.h> #include <esp_log.h>
@@ -15,12 +16,12 @@
#include <string.h> #include <string.h>
static const char *TAG = "simulator"; static const char *TAG = "simulator";
static char *time; static char *time = NULL;
static char *time_to_string(int hhmm) static char *time_to_string(int hhmm)
{ {
static char buffer[20]; 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; 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) uint8_t brightness, uint8_t saturation)
{ {
// Allocate memory for a new node in PSRAM. // 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) 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; return ESP_FAIL;
} }
@@ -145,10 +146,12 @@ static void initialize_light_items(void)
initialize_storage(); initialize_storage();
static char filename[30]; static char filename[30];
auto persistence = PersistenceManager(); persistence_manager_t persistence;
int variant = persistence.GetValue("light_variant", 1); persistence_manager_init(&persistence, "config");
snprintf(filename, sizeof(filename), "/spiffs/schema_%02d.csv", variant); int variant = persistence_manager_get_int(&persistence, "light_variant", 1);
snprintf(filename, sizeof(filename), "schema_%02d.csv", variant);
load_file(filename); load_file(filename);
persistence_manager_deinit(&persistence);
if (head == NULL) if (head == NULL)
{ {
@@ -233,6 +236,17 @@ static light_item_node_t *find_next_light_item_for_time(int hhmm)
return next_item; 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) void start_simulate_day(void)
{ {
initialize_light_items(); 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); light_item_node_t *current_item = find_best_light_item_for_time(1200);
if (current_item != NULL) if (current_item != NULL)
{ {
led_strip_update(LED_STATE_DAY, rgb_t color = {.red = current_item->red, .green = current_item->green, .blue = current_item->blue};
(rgb_t){.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); light_item_node_t *current_item = find_best_light_item_for_time(0);
if (current_item != NULL) if (current_item != NULL)
{ {
led_strip_update(LED_STATE_NIGHT, rgb_t color = {.red = current_item->red, .green = current_item->green, .blue = current_item->blue};
(rgb_t){.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,7 +310,13 @@ void simulate_cycle(void *args)
light_item_node_t *current_item = find_best_light_item_for_time(hhmm); 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); light_item_node_t *next_item = find_next_light_item_for_time(hhmm);
if (current_item != NULL && next_item != NULL) if (current_item != NULL)
{
rgb_t color = {0, 0, 0};
// Use head as fallback if next_item is NULL
next_item = next_item ? next_item : head;
if (next_item != NULL)
{ {
int current_item_time_min = (atoi(current_item->time) / 100) * 60 + (atoi(current_item->time) % 100); 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 next_item_time_min = (atoi(next_item->time) / 100) * 60 + (atoi(next_item->time) % 100);
@@ -323,16 +345,16 @@ void simulate_cycle(void *args)
rgb_t end_rgb = {.red = next_item->red, .green = next_item->green, .blue = next_item->blue}; rgb_t end_rgb = {.red = next_item->red, .green = next_item->green, .blue = next_item->blue};
// Use the interpolation function // Use the interpolation function
rgb_t final_rgb = interpolate_color(start_rgb, end_rgb, interpolation_factor); color = interpolate_color(start_rgb, end_rgb, interpolation_factor);
led_strip_update(LED_STATE_SIMULATION, color);
led_strip_update(LED_STATE_SIMULATION, final_rgb);
} }
else if (current_item != NULL) else
{ {
// No next item, just use current // No next_item and no head, use only current
led_strip_update( color = (rgb_t){.red = current_item->red, .green = current_item->green, .blue = current_item->blue};
LED_STATE_SIMULATION, led_strip_update(LED_STATE_SIMULATION, color);
(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)); vTaskDelay(pdMS_TO_TICKS(delay_ms));
@@ -351,7 +373,7 @@ void start_simulation_task(void)
stop_simulation_task(); stop_simulation_task();
simulation_config_t *config = 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) if (config == NULL)
{ {
ESP_LOGE(TAG, "Failed to allocate memory for simulation config."); ESP_LOGE(TAG, "Failed to allocate memory for simulation config.");
@@ -398,25 +420,22 @@ void start_simulation(void)
{ {
stop_simulation_task(); stop_simulation_task();
auto persistence = PersistenceManager(); persistence_manager_t persistence;
if (persistence.GetValue("light_active", false)) persistence_manager_init(&persistence, "config");
if (persistence_manager_get_bool(&persistence, "light_active", false))
{ {
int mode = persistence_manager_get_int(&persistence, "light_mode", 0);
int mode = persistence.GetValue("light_mode", 0);
switch (mode) switch (mode)
{ {
case 0: // Simulation mode case 0: // Simulation mode
start_simulation_task(); start_simulation_task();
break; break;
case 1: // Day mode case 1: // Day mode
start_simulate_day(); start_simulate_day();
break; break;
case 2: // Night mode case 2: // Night mode
start_simulate_night(); start_simulate_night();
break; break;
default: default:
ESP_LOGW(TAG, "Unknown light mode: %d", mode); ESP_LOGW(TAG, "Unknown light mode: %d", mode);
break; break;
@@ -426,4 +445,5 @@ void start_simulation(void)
{ {
led_strip_update(LED_STATE_OFF, rgb_t{}); led_strip_update(LED_STATE_OFF, rgb_t{});
} }
persistence_manager_deinit(&persistence);
} }

View File

@@ -3,14 +3,22 @@
#include "esp_log.h" #include "esp_log.h"
#include "esp_spiffs.h" #include "esp_spiffs.h"
#include "simulator.h" #include "simulator.h"
#include <cstring>
#include <errno.h> #include <errno.h>
#include <stdio.h> #include <stdio.h>
#include <stdlib.h>
#include <string.h>
static const char *TAG = "storage"; static const char *TAG = "storage";
static bool is_spiffs_mounted = false;
void initialize_storage() void initialize_storage()
{ {
if (is_spiffs_mounted)
{
return;
}
esp_vfs_spiffs_conf_t conf = { esp_vfs_spiffs_conf_t conf = {
.base_path = "/spiffs", .base_path = "/spiffs",
.partition_label = NULL, .partition_label = NULL,
@@ -36,64 +44,113 @@ void initialize_storage()
} }
return; return;
} }
is_spiffs_mounted = true;
} }
void load_file(const char *filename) void load_file(const char *filename)
{ {
ESP_LOGI(TAG, "Loading file: %s", filename); ESP_LOGI(TAG, "Loading file: %s", filename);
FILE *f = fopen(filename, "r"); int line_count = 0;
if (f == NULL) char **lines = read_lines_filtered(filename, &line_count);
{
ESP_LOGE(TAG, "Failed to open file for reading");
return;
}
char line[128];
uint8_t line_number = 0; 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}; char time[10] = {0};
int red, green, blue, white, brightness, saturation; int red, green, blue, white, brightness, saturation;
int items_scanned =
int items_scanned = sscanf(line, "%d,%d,%d,%d,%d,%d", &red, &green, &blue, &white, &brightness, &saturation); sscanf(lines[i], "%d,%d,%d,%d,%d,%d", &red, &green, &blue, &white, &brightness, &saturation);
if (items_scanned == 6) if (items_scanned == 6)
{ {
int total_minutes = line_number * 30; int total_minutes = line_number * 30;
int hours = total_minutes / 60; int hours = total_minutes / 60;
int minutes = total_minutes % 60; int minutes = total_minutes % 60;
snprintf(time, sizeof(time), "%02d%02d", hours, minutes); snprintf(time, sizeof(time), "%02d%02d", hours, minutes);
add_light_item(time, red, green, blue, white, brightness, saturation); add_light_item(time, red, green, blue, white, brightness, saturation);
line_number++; line_number++;
} }
else else
{ {
ESP_LOGW(TAG, "Could not parse line: %s", line); ESP_LOGW(TAG, "Could not parse line: %s", lines[i]);
} }
} }
free_lines(lines, line_count);
fclose(f);
ESP_LOGI(TAG, "Finished loading file. Loaded %d entries.", line_number); 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;
}

View File

@@ -1,10 +1,10 @@
idf_component_register(SRCS idf_component_register(SRCS
main.cpp src/main.cpp
app_task.cpp src/app_task.cpp
button_handling.c src/button_handling.c
i2c_checker.c src/i2c_checker.c
hal/u8g2_esp32_hal.c src/hal/u8g2_esp32_hal.c
INCLUDE_DIRS "." INCLUDE_DIRS "include"
PRIV_REQUIRES PRIV_REQUIRES
analytics analytics
insa insa
@@ -21,6 +21,7 @@ idf_component_register(SRCS
app_update app_update
rmaker_common rmaker_common
driver driver
my_mqtt_client
) )
spiffs_create_partition_image(storage ../storage FLASH_IN_PROJECT) spiffs_create_partition_image(storage ../storage FLASH_IN_PROJECT)

View File

@@ -3,6 +3,5 @@ dependencies:
git: https://github.com/olikraus/u8g2.git git: https://github.com/olikraus/u8g2.git
# u8g2_hal: # u8g2_hal:
# git: https://github.com/mkfrey/u8g2-hal-esp-idf.git # 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/esp_insights: ^1.2.7
espressif/mqtt: ^1.0.0

View File

@@ -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-----

View File

@@ -2,16 +2,20 @@
#include "analytics.h" #include "analytics.h"
#include "button_handling.h" #include "button_handling.h"
#include "common.h"
#include "common/InactivityTracker.h" #include "common/InactivityTracker.h"
#include "hal/u8g2_esp32_hal.h" #include "hal/u8g2_esp32_hal.h"
#include "hal_esp32/PersistenceManager.h"
#include "i2c_checker.h" #include "i2c_checker.h"
#include "led_status.h" #include "led_status.h"
#include "message_manager.h"
#include "my_mqtt_client.h"
#include "persistence_manager.h"
#include "simulator.h" #include "simulator.h"
#include "ui/ClockScreenSaver.h" #include "ui/ClockScreenSaver.h"
#include "ui/ScreenSaver.h" #include "ui/ScreenSaver.h"
#include "ui/SplashScreen.h" #include "ui/SplashScreen.h"
#include "wifi_manager.h" #include "wifi_manager.h"
#include <cstring>
#include <driver/i2c.h> #include <driver/i2c.h>
#include <esp_diagnostics.h> #include <esp_diagnostics.h>
#include <esp_insights.h> #include <esp_insights.h>
@@ -33,7 +37,8 @@ uint8_t received_signal;
std::shared_ptr<Widget> m_widget; std::shared_ptr<Widget> m_widget;
std::vector<std::shared_ptr<Widget>> m_history; std::vector<std::shared_ptr<Widget>> m_history;
std::unique_ptr<InactivityTracker> m_inactivityTracker; std::unique_ptr<InactivityTracker> m_inactivityTracker;
std::shared_ptr<PersistenceManager> m_persistenceManager; // Persistence Manager for C-API
persistence_manager_t g_persistence_manager;
extern QueueHandle_t buttonQueue; extern QueueHandle_t buttonQueue;
@@ -93,10 +98,7 @@ void popScreen()
m_history.pop_back(); m_history.pop_back();
if (m_widget) if (m_widget)
{ {
if (m_persistenceManager != nullptr) persistence_manager_save(&g_persistence_manager);
{
m_persistenceManager->Save();
}
m_widget->onExit(); m_widget->onExit();
} }
m_widget = m_history.back(); m_widget = m_history.back();
@@ -107,14 +109,14 @@ void popScreen()
static void init_ui(void) static void init_ui(void)
{ {
m_persistenceManager = std::make_shared<PersistenceManager>(); persistence_manager_init(&g_persistence_manager, "config");
options = { options = {
.u8g2 = &u8g2, .u8g2 = &u8g2,
.setScreen = [](const std::shared_ptr<Widget> &screen) { setScreen(screen); }, .setScreen = [](const std::shared_ptr<Widget> &screen) { setScreen(screen); },
.pushScreen = [](const std::shared_ptr<Widget> &screen) { pushScreen(screen); }, .pushScreen = [](const std::shared_ptr<Widget> &screen) { pushScreen(screen); },
.popScreen = []() { popScreen(); }, .popScreen = []() { popScreen(); },
.onButtonClicked = nullptr, .onButtonClicked = nullptr,
.persistenceManager = m_persistenceManager, .persistenceManager = &g_persistence_manager,
}; };
m_widget = std::make_shared<SplashScreen>(&options); m_widget = std::make_shared<SplashScreen>(&options);
m_inactivityTracker = std::make_unique<InactivityTracker>(60000, []() { m_inactivityTracker = std::make_unique<InactivityTracker>(60000, []() {
@@ -127,6 +129,17 @@ static void init_ui(void)
u8g2_SendBuffer(&u8g2); 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) static void handle_button(uint8_t button)
{ {
m_inactivityTracker->reset(); m_inactivityTracker->reset();
@@ -184,14 +197,65 @@ void app_task(void *args)
return; return;
} }
// Initialize display so that info can be shown
setup_screen(); 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(); setup_buttons();
init_ui(); init_ui();
#if CONFIG_WIFI_ENABLED
wifi_manager_init(); wifi_manager_init();
analytics_init();
#endif mqtt_client_start();
message_manager_register_listener(on_message_received);
start_simulation(); start_simulation();

View File

@@ -7,7 +7,7 @@
#include "freertos/FreeRTOS.h" #include "freertos/FreeRTOS.h"
#include "freertos/task.h" #include "freertos/task.h"
#include "u8g2_esp32_hal.h" #include "hal/u8g2_esp32_hal.h"
static const char *TAG = "u8g2_hal"; static const char *TAG = "u8g2_hal";
static const unsigned int I2C_TIMEOUT_MS = 1000; static const unsigned int I2C_TIMEOUT_MS = 1000;

View File

@@ -1,8 +1,8 @@
#include "app_task.h" #include "app_task.h"
#include "color.h" #include "color.h"
#include "hal_esp32/PersistenceManager.h"
#include "led_status.h" #include "led_status.h"
#include "led_strip_ws2812.h" #include "led_strip_ws2812.h"
#include "persistence_manager.h"
#include "wifi_manager.h" #include "wifi_manager.h"
#include <ble_manager.h> #include <ble_manager.h>
#include <esp_event.h> #include <esp_event.h>
@@ -24,8 +24,9 @@ void app_main(void)
ESP_ERROR_CHECK(nvs_flash_init()); ESP_ERROR_CHECK(nvs_flash_init());
} }
auto persistence = PersistenceManager(); persistence_manager_t persistence;
persistence.Load(); persistence_manager_init(&persistence, "config");
persistence_manager_load(&persistence);
led_status_init(CONFIG_STATUS_WLED_PIN); led_status_init(CONFIG_STATUS_WLED_PIN);

View File

@@ -42,3 +42,6 @@ CONFIG_SPIRAM=y
# SPI RAM config # SPI RAM config
CONFIG_SPIRAM_SPEED=80 CONFIG_SPIRAM_SPEED=80
CONFIG_SPIRAM_USE_CAPS_ALLOC=y CONFIG_SPIRAM_USE_CAPS_ALLOC=y
# HTTP Server WebSocket Support
CONFIG_HTTPD_WS_SUPPORT=y

View File

@@ -0,0 +1,120 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#1a1a2e">
<meta name="apple-mobile-web-app-capable" content="yes">
<link rel="icon" type="image/svg+xml" href="favicon.svg">
<title data-i18n="captive.title">System Control - WLAN Setup</title>
<link rel="stylesheet" href="css/shared.css">
<link rel="stylesheet" href="css/captive.css">
</head>
<body>
<div class="container">
<div class="header">
<div class="header-controls">
<button class="lang-toggle" onclick="toggleLanguage()" aria-label="Sprache wechseln">
<span class="lang-flag" id="lang-flag">🇩🇪</span>
<span class="lang-label" id="lang-label">DE</span>
</button>
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Theme wechseln">
<span class="theme-toggle-icon" id="theme-icon">🌙</span>
<span class="theme-toggle-label" id="theme-label">Dark</span>
</button>
</div>
<h1>🚂 System Control</h1>
</div>
<div class="card">
<div class="form-group">
<label for="ssid" data-i18n="wifi.ssid">WLAN-Name (SSID)</label>
<input type="text" id="ssid" data-i18n-placeholder="wifi.ssid.placeholder"
placeholder="Netzwerkname eingeben">
</div>
<div class="form-group">
<label for="password" data-i18n="wifi.password.short">Passwort</label>
<div class="password-toggle">
<input type="password" id="password" data-i18n-placeholder="captive.password.placeholder"
placeholder="WLAN-Passwort">
<button type="button" onclick="togglePassword()" id="password-btn">👁️</button>
</div>
</div>
<div class="btn-group">
<button class="btn btn-primary" id="connect-btn" onclick="saveWifi()" data-i18n="captive.connect"
disabled>
💾 Verbinden
</button>
</div>
<div id="wifi-status" class="status"></div>
<div class="info-box">
<strong> <span data-i18n="captive.note.title">Hinweis:</span></strong>
<span data-i18n="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.</span>
</div>
</div>
</div>
<script src="js/i18n.js"></script>
<script src="js/wifi-shared.js"></script>
<script>
// 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';
if (metaTheme) metaTheme.content = '#faf8f5';
} else {
icon.textContent = '🌙';
label.textContent = 'Dark';
if (metaTheme) metaTheme.content = '#1a1a2e';
}
}
function toggleTheme() {
const current = document.documentElement.getAttribute('data-theme') || 'dark';
setTheme(current === 'dark' ? 'light' : 'dark');
}
// Button aktivieren/deaktivieren
function updateConnectBtn() {
const ssid = document.getElementById('ssid').value;
const pw = document.getElementById('password').value;
const btn = document.getElementById('connect-btn');
btn.disabled = !(ssid.length > 0 && pw.length > 0);
}
// Initialize on load
document.addEventListener('DOMContentLoaded', () => {
initTheme();
initI18n();
document.getElementById('ssid').addEventListener('input', updateConnectBtn);
document.getElementById('password').addEventListener('input', updateConnectBtn);
updateConnectBtn();
});
</script>
</body>
</html>

View File

@@ -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;
}

File diff suppressed because it is too large Load Diff

View File

@@ -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));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">🚂</text></svg>

After

Width:  |  Height:  |  Size: 109 B

View File

@@ -0,0 +1,475 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#1a1a2e">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="icon" type="image/svg+xml" href="favicon.svg">
<title data-i18n="page.title">System Control</title>
<link rel="stylesheet" href="css/shared.css">
<link rel="stylesheet" href="css/index.css">
</head>
<body>
<div class="container">
<div class="header">
<div class="header-controls">
<button class="lang-toggle" onclick="toggleLanguage()" aria-label="Sprache wechseln">
<span class="lang-flag" id="lang-flag">🇩🇪</span>
<span class="lang-label" id="lang-label">DE</span>
</button>
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Theme wechseln">
<span class="theme-toggle-icon" id="theme-icon">🌙</span>
<span class="theme-toggle-label" id="theme-label">Dark</span>
</button>
</div>
<h1>🚂 System Control</h1>
</div>
<div class="tabs">
<button class="tab active" onclick="switchTab('control')" data-i18n="tab.control">🎛️ Bedienung</button>
<button class="tab" onclick="switchTab('config')" data-i18n="tab.config">⚙️ Konfiguration</button>
</div>
<!-- Bedienung Tab -->
<div id="tab-control" class="tab-content active">
<div class="card">
<h2 data-i18n="control.light.title">Lichtsteuerung</h2>
<div class="control-section">
<div class="control-group">
<div class="toggle-row">
<span class="toggle-label" data-i18n="control.light.light">Licht</span>
<button class="toggle-switch" id="light-toggle" onclick="toggleLight()">
<span class="toggle-state" id="light-state" data-i18n="common.off">AUS</span>
<span class="toggle-icon" id="light-icon">💡</span>
</button>
</div>
<div class="toggle-row">
<span class="toggle-label" data-i18n="control.light.thunder">Gewitter</span>
<button class="toggle-switch" id="thunder-toggle" onclick="toggleThunder()">
<span class="toggle-state" id="thunder-state" data-i18n="common.off">AUS</span>
<span class="toggle-icon" id="thunder-icon"></span>
</button>
</div>
</div>
<div id="light-status" class="status"></div>
</div>
<div class="control-section">
<h3 data-i18n="control.mode.title">Betriebsmodus</h3>
<div class="mode-selector">
<button class="mode-btn" id="mode-day" onclick="setMode('day')">
<span class="mode-icon">☀️</span>
<span class="mode-name" data-i18n="mode.day">Tag</span>
</button>
<button class="mode-btn" id="mode-night" onclick="setMode('night')">
<span class="mode-icon">🌙</span>
<span class="mode-name" data-i18n="mode.night">Nacht</span>
</button>
<button class="mode-btn" id="mode-simulation" onclick="setMode('simulation')">
<span class="mode-icon">🔄</span>
<span class="mode-name" data-i18n="mode.simulation">Simulation</span>
</button>
</div>
<div id="simulation-options" class="simulation-options">
<div class="form-group">
<label for="active-schema" data-i18n="control.schema.active">Aktives Schema</label>
<select id="active-schema" onchange="setActiveSchema()">
<option value="schema_01.csv" data-i18n="schema.name.1">Schema 1 (Standard)</option>
<option value="schema_02.csv" data-i18n="schema.name.2">Schema 2 (Warm)</option>
<option value="schema_03.csv" data-i18n="schema.name.3">Schema 3 (Natur)</option>
</select>
</div>
</div>
<div id="mode-status" class="status"></div>
</div>
<div class="control-section">
<h3 data-i18n="control.status.title">Aktueller Status</h3>
<div class="status-display">
<div class="status-item visible">
<span class="status-label" data-i18n="control.status.mode">Modus</span>
<span class="status-value" id="current-mode" data-i18n="mode.simulation">Simulation</span>
</div>
<div class="status-item visible">
<span class="status-label" data-i18n="control.status.color">Aktuelle Farbe</span>
<div class="current-color-preview" id="current-color"></div>
</div>
<div class="status-item">
<span class="status-label" data-i18n="control.status.clock">Uhrzeit</span>
<span class="status-value" id="current-clock">--:-- Uhr</span>
</div>
</div>
</div>
</div>
<!-- Szenen Card -->
<div class="card" id="scenes-control-card" style="display: none;">
<h2 data-i18n="scenes.title">Szenen</h2>
<div id="scenes-control-list" class="scenes-grid">
<!-- Wird dynamisch gefüllt -->
<div class="empty-state" id="no-scenes-control">
<span class="empty-icon">🎬</span>
<p data-i18n="scenes.empty">Keine Szenen definiert</p>
<p class="empty-hint" data-i18n="scenes.empty.hint">Erstelle Szenen unter Konfiguration</p>
</div>
</div>
<div id="scenes-control-status" class="status"></div>
</div>
<!-- Externe Geräte Card -->
<div class="card" id="devices-control-card" style="display: none;">
<h2 data-i18n="devices.external">Externe Geräte</h2>
<div id="devices-control-list" class="devices-control-grid">
<!-- Wird dynamisch gefüllt -->
<div class="empty-state" id="no-devices-control">
<span class="empty-icon">🔗</span>
<p data-i18n="devices.control.empty">Keine Geräte hinzugefügt</p>
<p class="empty-hint" data-i18n="devices.control.empty.hint">Füge Geräte unter Konfiguration
hinzu</p>
</div>
</div>
</div>
</div>
<!-- Konfiguration Tab -->
<div id="tab-config" class="tab-content">
<div class="sub-tabs">
<button class="sub-tab active" onclick="switchSubTab('wifi')" data-i18n="subtab.wifi">📶 WLAN</button>
<button class="sub-tab" onclick="switchSubTab('schema')" data-i18n="subtab.light">💡
Lichtsteuerung</button>
<button class="sub-tab" id="subtab-btn-devices" onclick="switchSubTab('devices')"
data-i18n="subtab.devices">🔗 Geräte</button>
<button class="sub-tab" id="subtab-btn-scenes" onclick="switchSubTab('scenes')"
data-i18n="subtab.scenes">🎬 Szenen</button>
</div>
<!-- WLAN Sub-Tab -->
<div id="subtab-wifi" class="sub-tab-content active">
<div class="card">
<h2 data-i18n="wifi.config.title">WLAN Konfiguration</h2>
<div class="form-group">
<label for="ssid" data-i18n="wifi.ssid">WLAN Name (SSID)</label>
<input type="text" id="ssid" data-i18n-placeholder="wifi.ssid.placeholder"
placeholder="Netzwerkname eingeben" autocomplete="off" autocapitalize="off">
</div>
<div class="form-group">
<label for="password" data-i18n="wifi.password">WLAN Passwort</label>
<div class="password-toggle">
<input type="password" id="password" data-i18n-placeholder="wifi.password.placeholder"
placeholder="Passwort eingeben" autocomplete="off">
<button type="button" onclick="togglePassword()" id="password-btn">👁️</button>
</div>
</div>
<div class="form-group">
<label data-i18n="wifi.available">Verfügbare Netzwerke</label>
<select id="available-networks"
onchange="if(this.value) document.getElementById('ssid').value = this.value">
<option value="" data-i18n="wifi.scan.hint">Nach Netzwerken suchen...</option>
</select>
</div>
<div class="btn-group">
<button class="btn btn-secondary" onclick="scanNetworks()" data-i18n="btn.scan">🔍
Suchen</button>
<button class="btn btn-primary" onclick="saveWifi()" data-i18n="btn.save">💾 Speichern</button>
</div>
<div id="wifi-status" class="status"></div>
</div>
<div class="card">
<h2 data-i18n="wifi.status.title">Verbindungsstatus</h2>
<div id="connection-info">
<p><strong data-i18n="wifi.status.status">Status:</strong> <span id="conn-status"
data-i18n="common.loading">Wird geladen...</span></p>
<p><strong data-i18n="wifi.status.ip">IP-Adresse:</strong> <span id="conn-ip">-</span></p>
<p><strong data-i18n="wifi.status.signal">Signal:</strong> <span id="conn-rssi">-</span></p>
</div>
</div>
</div>
<!-- Schema Sub-Tab (Lichtsteuerung) -->
<div id="subtab-schema" class="sub-tab-content">
<!-- LED Konfiguration -->
<div class="card">
<h2 data-i18n="wled.config.title">LED Konfiguration</h2>
<p class="card-description" data-i18n="wled.config.desc">Konfiguriere die LED-Segmente und Anzahl
LEDs pro
Segment</p>
<div class="segment-header">
<h3 data-i18n="wled.segments.title">Segmente</h3>
<button class="btn btn-secondary btn-small" onclick="addWledSegment()"
data-i18n="wled.segment.add"> Segment hinzufügen</button>
</div>
<div id="wled-segments-list" class="wled-segments-list">
<!-- Wird dynamisch gefüllt -->
<div class="empty-state" id="no-wled-segments">
<span class="empty-icon">💡</span>
<p data-i18n="wled.segments.empty">Keine Segmente konfiguriert</p>
<p class="empty-hint" data-i18n="wled.segments.empty.hint">Klicke auf "Segment hinzufügen"
um ein Segment zu erstellen</p>
</div>
</div>
<div class="btn-group">
<button class="btn btn-primary" onclick="saveWledConfig()" data-i18n="btn.save">💾
Speichern</button>
</div>
<div id="wled-status" class="status"></div>
</div>
<!-- Schema Editor -->
<div class="card">
<h2 data-i18n="schema.editor.title">Licht-Schema Editor</h2>
<div class="schema-controls">
<div class="form-group">
<label for="schema-select" data-i18n="schema.file">Schema-Datei</label>
<select id="schema-select" onchange="loadSchema()">
<option value="schema_01.csv" data-i18n="schema.name.1">Schema 1 (Standard)</option>
<option value="schema_02.csv" data-i18n="schema.name.2">Schema 2 (Warm)</option>
<option value="schema_03.csv" data-i18n="schema.name.3">Schema 3 (Natur)</option>
</select>
</div>
<button class="btn btn-secondary" onclick="loadSchema()" data-i18n="btn.load">🔄 Laden</button>
<button class="btn btn-primary" onclick="saveSchema()" data-i18n="btn.save">💾
Speichern</button>
</div>
<div id="schema-status" class="status"></div>
<div id="schema-loading" class="loading">
<div class="spinner"></div>
<p data-i18n="schema.loading">Schema wird geladen...</p>
</div>
<div class="value-header">
<span data-i18n="schema.header.time">Zeit</span>
<span data-i18n="schema.header.color">Farbe</span>
<span>R</span>
<span>G</span>
<span>B</span>
<span>V1</span>
<span>V2</span>
<span>V3</span>
</div>
<div id="schema-grid" class="time-grid">
<!-- Wird dynamisch gefüllt -->
</div>
</div>
</div>
<!-- Szenen Sub-Tab -->
<div id="subtab-scenes" class="sub-tab-content">
<div class="card">
<h2 data-i18n="scenes.manage.title">Szenen verwalten</h2>
<p class="card-description" data-i18n="scenes.manage.desc">Erstelle und bearbeite Szenen für
schnellen Zugriff</p>
<div class="btn-group">
<button class="btn btn-primary" onclick="openSceneModal()" data-i18n="btn.new.scene"> Neue
Szene</button>
</div>
<div id="scenes-config-list" class="scenes-config-list">
<!-- Wird dynamisch gefüllt -->
<div class="empty-state" id="no-scenes-config">
<span class="empty-icon">🎬</span>
<p data-i18n="scenes.config.empty">Keine Szenen erstellt</p>
<p class="empty-hint" data-i18n="scenes.config.empty.hint">Klicke auf "Neue Szene" um eine
Szene zu erstellen</p>
</div>
</div>
<div id="scenes-status" class="status"></div>
</div>
</div>
<!-- Geräte Sub-Tab -->
<div id="subtab-devices" class="sub-tab-content">
<div class="card">
<h2 data-i18n="devices.new.title">Neue Geräte</h2>
<p class="card-description" data-i18n="devices.new.desc">Unprovisionierte Matter-Geräte in der Nähe
</p>
<div class="btn-group">
<button class="btn btn-secondary" onclick="scanDevices()" data-i18n="btn.scan.devices">🔍 Geräte
suchen</button>
</div>
<div id="devices-loading" class="loading">
<div class="spinner"></div>
<p data-i18n="devices.searching">Suche nach Geräten...</p>
</div>
<div id="unpaired-devices" class="device-list">
<!-- Wird dynamisch gefüllt -->
<div class="empty-state" id="no-unpaired-devices">
<span class="empty-icon">📡</span>
<p data-i18n="devices.unpaired.empty">Keine neuen Geräte gefunden</p>
<p class="empty-hint" data-i18n="devices.unpaired.empty.hint">Drücke "Geräte suchen" um nach
Matter-Geräten zu suchen</p>
</div>
</div>
<div id="devices-status" class="status"></div>
</div>
<div class="card">
<h2 data-i18n="devices.paired.title">Zugeordnete Geräte</h2>
<p class="card-description" data-i18n="devices.paired.desc">Bereits hinzugefügte externe Geräte</p>
<div id="paired-devices" class="device-list">
<!-- Wird dynamisch gefüllt -->
<div class="empty-state" id="no-paired-devices">
<span class="empty-icon">📦</span>
<p data-i18n="devices.paired.empty">Keine Geräte hinzugefügt</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Color Picker Modal -->
<div id="color-modal" class="modal-overlay">
<div class="modal">
<h3><span data-i18n="modal.color.title">Farbe wählen</span> - <span id="modal-time"></span></h3>
<div class="color-picker-container">
<div class="color-slider">
<label style="color: #f66;">R</label>
<input type="range" id="rangeR" min="0" max="255" value="255" oninput="updateModalColor()">
<span id="valR" class="value">255</span>
</div>
<div class="color-slider">
<label style="color: #6f6;">G</label>
<input type="range" id="rangeG" min="0" max="255" value="255" oninput="updateModalColor()">
<span id="valG" class="value">255</span>
</div>
<div class="color-slider">
<label style="color: #66f;">B</label>
<input type="range" id="rangeB" min="0" max="255" value="255" oninput="updateModalColor()">
<span id="valB" class="value">255</span>
</div>
<div id="preview-large" class="preview-large"></div>
</div>
<div class="modal-buttons">
<button class="btn btn-secondary" onclick="closeColorModal()" data-i18n="btn.cancel">Abbrechen</button>
<button class="btn btn-primary" onclick="applyColor()" data-i18n="btn.apply">Übernehmen</button>
</div>
</div>
</div>
<!-- Scene Modal -->
<div id="scene-modal" class="modal-overlay">
<div class="modal">
<h3 id="scene-modal-title" data-i18n="modal.scene.new">Neue Szene erstellen</h3>
<div class="form-group">
<label for="scene-name" data-i18n="scene.name">Name</label>
<input type="text" id="scene-name" data-i18n-placeholder="scene.name.placeholder"
placeholder="z.B. Abendstimmung" autocomplete="off">
</div>
<div class="form-group">
<label data-i18n="scene.icon">Icon auswählen</label>
<div class="icon-selector">
<button type="button" class="icon-btn active" data-icon="🌅"
onclick="selectSceneIcon('🌅')">🌅</button>
<button type="button" class="icon-btn" data-icon="🌙" onclick="selectSceneIcon('🌙')">🌙</button>
<button type="button" class="icon-btn" data-icon="☀️" onclick="selectSceneIcon('☀️')">☀️</button>
<button type="button" class="icon-btn" data-icon="🎬" onclick="selectSceneIcon('🎬')">🎬</button>
<button type="button" class="icon-btn" data-icon="💤" onclick="selectSceneIcon('💤')">💤</button>
<button type="button" class="icon-btn" data-icon="🎉" onclick="selectSceneIcon('🎉')">🎉</button>
<button type="button" class="icon-btn" data-icon="🍿" onclick="selectSceneIcon('🍿')">🍿</button>
<button type="button" class="icon-btn" data-icon="📚" onclick="selectSceneIcon('📚')">📚</button>
</div>
</div>
<div class="form-group">
<label data-i18n="scene.actions">Aktionen</label>
<div class="scene-actions-editor">
<div class="scene-action-row">
<label>
<input type="checkbox" id="scene-action-light" checked>
<span data-i18n="scene.action.light">Licht Ein/Aus</span>
</label>
<select id="scene-light-state">
<option value="on" data-i18n="scene.light.on">Einschalten</option>
<option value="off" data-i18n="scene.light.off">Ausschalten</option>
</select>
</div>
<div class="scene-action-row">
<label>
<input type="checkbox" id="scene-action-mode">
<span data-i18n="scene.action.mode">Modus setzen</span>
</label>
<select id="scene-mode-value">
<option value="day" data-i18n="mode.day">Tag</option>
<option value="night" data-i18n="mode.night">Nacht</option>
<option value="simulation" data-i18n="mode.simulation">Simulation</option>
</select>
</div>
<div class="scene-action-row">
<label>
<input type="checkbox" id="scene-action-schema">
<span data-i18n="scene.action.schema">Schema wählen</span>
</label>
<select id="scene-schema-value">
<option value="schema_01.csv">Schema 1</option>
<option value="schema_02.csv">Schema 2</option>
<option value="schema_03.csv">Schema 3</option>
</select>
</div>
</div>
</div>
<div class="form-group">
<label data-i18n="devices.external">Externe Geräte</label>
<div id="scene-devices-list" class="scene-devices-list">
<!-- Wird dynamisch gefüllt -->
<div class="empty-state small" id="no-scene-devices">
<span class="empty-icon">🔗</span>
<p data-i18n="devices.none.available">Keine Geräte verfügbar</p>
</div>
</div>
</div>
<div class="modal-buttons">
<button class="btn btn-secondary" onclick="closeSceneModal()" data-i18n="btn.cancel">Abbrechen</button>
<button class="btn btn-primary" onclick="saveScene()" data-i18n="btn.save">💾 Speichern</button>
</div>
</div>
</div>
<!-- JavaScript Modules -->
<script src="js/i18n.js"></script>
<script src="js/capabilities.js"></script>
<script src="js/wifi-shared.js"></script>
<script src="js/ui.js"></script>
<script src="js/websocket.js"></script>
<script src="js/light.js"></script>
<script src="js/scenes.js"></script>
<script src="js/devices.js"></script>
<script src="js/schema.js"></script>
<script src="js/wled.js"></script>
<script src="js/app.js"></script>
</body>
</html>

View File

@@ -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();
}
});

View File

@@ -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 };
}

View File

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

View File

@@ -0,0 +1,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();
}

View File

@@ -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');
}
}

View File

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

View File

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

View File

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

View File

@@ -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) };
}

View File

@@ -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 = `<option value="">${t('wifi.searching')}</option>`;
}
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 = `<div class="network-item"><span class="network-name">${t('devices.unpaired.empty')}</span></div>`;
} else {
networks.forEach(network => {
const signalIcon = getSignalIcon(network.rssi);
const item = document.createElement('div');
item.className = 'network-item';
item.onclick = () => selectNetwork(network.ssid, item);
item.innerHTML = `
<span class="network-name">
<span class="signal-icon">${signalIcon}</span>
${escapeHtml(network.ssid)}
</span>
<span class="network-signal">${network.rssi} dBm</span>
`;
networkList.appendChild(item);
});
}
networkList.style.display = 'block';
}
// Render for main interface (select dropdown)
if (select) {
select.innerHTML = `<option value="">${t('wifi.scan.hint')}</option>`;
networks.forEach(network => {
const option = document.createElement('option');
option.value = network.ssid;
option.textContent = `${network.ssid} (${network.rssi} dBm)`;
select.appendChild(option);
});
// Note: onchange handler is set inline in HTML
}
showStatus('wifi-status', t('wifi.networks.found', { count: networks.length }), 'success');
} catch (error) {
if (loading) {
loading.classList.remove('active');
}
// 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 = `
<span class="network-name">
<span class="signal-icon">${signalIcon}</span>
${escapeHtml(network.ssid)}
</span>
<span class="network-signal">${network.rssi} dBm</span>
`;
networkList.appendChild(item);
});
networkList.style.display = 'block';
}
if (select) {
select.innerHTML = `<option value="">${t('wifi.scan.hint')}</option>`;
demoNetworks.forEach(network => {
const option = document.createElement('option');
option.value = network.ssid;
option.textContent = `${network.ssid} (${network.rssi} dBm)`;
select.appendChild(option);
});
}
showStatus('wifi-status', 'Demo: ' + t('wifi.networks.found', { count: demoNetworks.length }), 'info');
} 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');
}
}

View File

@@ -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 = `
<div class="segment-name-field">
<label class="segment-number">${t('wled.segment.name', { num: index + 1 })}</label>
<input type="text" class="segment-name-input" value="${escapeHtml(segment.name || '')}"
placeholder="${t('wled.segment.name', { num: index + 1 })}"
onchange="updateSegment(${index}, 'name', this.value)">
</div>
<div class="segment-field">
<label data-i18n="wled.segment.start">${t('wled.segment.start')}</label>
<input type="number" min="0" value="${segment.start || 0}"
onchange="updateSegment(${index}, 'start', parseInt(this.value))">
</div>
<div class="segment-field">
<label data-i18n="wled.segment.leds">${t('wled.segment.leds')}</label>
<input type="number" min="1" value="${segment.leds || 1}"
onchange="updateSegment(${index}, 'leds', parseInt(this.value))">
</div>
<button class="segment-remove-btn" onclick="removeWledSegment(${index})" title="${t('wled.segment.remove')}">
🗑️
</button>
`;
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);

View File

@@ -0,0 +1,12 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="dev" type="js.build_tools.npm" nameIsGenerated="true">
<package-json value="$PROJECT_DIR$/package.json" />
<command value="run" />
<scripts>
<script value="dev" />
</scripts>
<node-interpreter value="project" />
<envs />
<method v="2" />
</configuration>
</component>

View File

@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#1a1a2e">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="icon" type="image/svg+xml" href="favicon.svg">
<title>System Control</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

View File

@@ -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"]
}

1560
firmware/website/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">🚂</text></svg>

After

Width:  |  Height:  |  Size: 109 B

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import Header from "./compoents/Header.svelte";
import Index from "./Index.svelte";
import Captive from "./Captive.svelte";
import { onMount } from "svelte";
import { writable } from "svelte/store";
const isCaptive = writable(false);
function checkHash() {
isCaptive.set(window.location.hash === "#/captive");
}
onMount(() => {
checkHash();
window.addEventListener("hashchange", checkHash);
return () => window.removeEventListener("hashchange", checkHash);
});
</script>
<Header />
{#if $isCaptive}
<Captive />
{:else}
<Index />
{/if}

View File

@@ -0,0 +1,5 @@
<script lang="ts">
import { t } from "./i18n/store";
</script>
<h1>{$t("welcome")} - Captive Portal</h1>

View File

@@ -0,0 +1,5 @@
<script lang="ts">
import { t } from "./i18n/store";
</script>
<h1>{$t("welcome")}</h1>

View File

@@ -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));
}
}

View File

@@ -0,0 +1,133 @@
<script lang="ts">
import Toggle from "./Toggle.svelte";
import {toggleTheme} from "../theme";
import {onMount} from "svelte";
import {writable} from "svelte/store";
import {lang, t} from "../i18n/store";
const theme = writable<"dark" | "light">("dark");
function applyInitialTheme() {
const userTheme = localStorage.getItem("theme");
if (userTheme) {
document.documentElement.setAttribute("data-theme", userTheme);
} else if (window.matchMedia("(prefers-color-scheme: light)").matches) {
document.documentElement.setAttribute("data-theme", "light");
} else {
document.documentElement.setAttribute("data-theme", "dark");
}
}
function updateThemeFromDom() {
const t = document.documentElement.getAttribute("data-theme");
theme.set(t === "light" ? "light" : "dark");
}
function handleThemeToggle() {
toggleTheme();
updateThemeFromDom();
}
let themeIcon = $state("🌙");
let themeLabel = $state("Dark");
let currentLangCode = $state($lang);
let currentLang = $state("Deutsch");
let currentFlag = $state("🇩🇪");
$effect(() => {
theme.subscribe(($theme) => {
themeIcon = $theme === "light" ? "☀️" : "🌙";
themeLabel = $theme === "light" ? "Light" : "Dark";
});
lang.subscribe(($lang) => {
currentLangCode = $lang;
currentLang = $lang === "de" ? "Deutsch" : "English";
currentFlag = $lang === "de" ? "🇩🇪" : "🇬🇧";
});
});
function handleLangChange(newLang: "de" | "en") {
lang.set(newLang);
localStorage.setItem("lang", newLang);
}
onMount(() => {
applyInitialTheme();
updateThemeFromDom();
window.addEventListener("storage", updateThemeFromDom);
// Listener für OS-Theme-Änderung
const mql = window.matchMedia("(prefers-color-scheme: light)");
const osThemeListener = () => {
// Nur reagieren, wenn kein User-Theme gesetzt ist
if (!localStorage.getItem("theme")) {
applyInitialTheme();
updateThemeFromDom();
}
};
mql.addEventListener("change", osThemeListener);
return () => {
window.removeEventListener("storage", updateThemeFromDom);
mql.removeEventListener("change", osThemeListener);
};
});
</script>
<div class="header">
<div class="header-controls">
<Toggle
label={currentLang}
icon={currentFlag}
ariaLabel="Sprache wechseln"
onClick={() => {
const newLang = currentLangCode === "de" ? "en" : "de";
handleLangChange(newLang);
}}
/>
<Toggle
label={themeLabel}
icon={themeIcon}
ariaLabel="Theme wechseln"
onClick={handleThemeToggle}
/>
</div>
<h1>🚂 System Control</h1>
</div>
<style>
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 10px;
}
.header-controls {
display: flex;
align-items: center;
gap: 8px;
}
@media (max-width: 600px) {
.header {
flex-direction: column;
align-items: stretch;
text-align: center;
}
.header h1 {
order: 1;
}
}
@media (max-width: 380px) {
.header h1 {
font-size: 1.2rem;
}
}
</style>

View File

@@ -0,0 +1,37 @@
<script lang="ts">
const { label, icon, ariaLabel, onClick } = $props<{
label: string;
icon: string;
ariaLabel: string;
onClick?: () => void;
}>();
</script>
<button class="toggle" aria-label={ariaLabel} onclick={onClick}>
<span class="icon" id="icon">{icon}</span>
<span class="label" id="label">{label}</span>
</button>
<style>
.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);
}
.toggle:hover {
border-color: var(--success);
}
.icon {
font-size: 1.2rem;
}
</style>

Some files were not shown because too many files have changed in this diff Show More