42 Commits

Author SHA1 Message Date
684ce36270 add vite helper files?
Some checks failed
ESP-IDF Build / build (esp32c6, release-v5.4) (push) Failing after 5m4s
ESP-IDF Build / build (esp32c6, release-v5.5) (push) Failing after 7m51s
ESP-IDF Build / build (esp32s3, release-v5.4) (push) Failing after 1m56s
ESP-IDF Build / build (esp32s3, release-v5.5) (push) Failing after 4m50s
Signed-off-by: Peter Siegmund <developer@mars3142.org>
2026-01-25 09:33:13 +01:00
98b5df1ff2 add ignore for FreeCAD and node-js intermediate files
Some checks failed
ESP-IDF Build / build (esp32c6, release-v5.5) (push) Has been cancelled
ESP-IDF Build / build (esp32s3, release-v5.4) (push) Has been cancelled
ESP-IDF Build / build (esp32s3, release-v5.5) (push) Has been cancelled
ESP-IDF Build / build (esp32c6, release-v5.4) (push) Has been cancelled
Signed-off-by: Peter Siegmund <developer@mars3142.org>
2026-01-25 09:29:16 +01:00
8128b958cb Merge branch 'feature/website'
Signed-off-by: Peter Siegmund <developer@mars3142.org>
2026-01-25 09:26:42 +01:00
955b4bef04 rebuild websites with svelte
still early WIP

Signed-off-by: Peter Siegmund <developer@mars3142.org>
2026-01-25 09:24:19 +01:00
81141d8859 connect via MQTTS
Signed-off-by: Peter Siegmund <developer@mars3142.org>
2026-01-25 02:15:05 +01:00
e01006cd49 remove PSRAM usage
Signed-off-by: Peter Siegmund <developer@mars3142.org>
2026-01-25 00:28:00 +01:00
c28d7d08df edit of all config data via website
Signed-off-by: Peter Siegmund <developer@mars3142.org>
2026-01-25 00:14:52 +01:00
df50aaedda get/post led segments
Some checks failed
ESP-IDF Build / build (esp32c6, release-v5.4) (push) Failing after 4m49s
ESP-IDF Build / build (esp32c6, release-v5.5) (push) Failing after 4m44s
ESP-IDF Build / build (esp32s3, release-v5.4) (push) Failing after 4m34s
ESP-IDF Build / build (esp32s3, release-v5.5) (push) Failing after 4m43s
Signed-off-by: Peter Siegmund <developer@mars3142.org>
2026-01-21 21:53:03 +01:00
1f02d35a97 try to use react for SPA
Some checks failed
ESP-IDF Build / build (esp32c6, release-v5.4) (push) Failing after 4m45s
ESP-IDF Build / build (esp32c6, release-v5.5) (push) Failing after 7m18s
ESP-IDF Build / build (esp32s3, release-v5.4) (push) Failing after 4m16s
ESP-IDF Build / build (esp32s3, release-v5.5) (push) Failing after 4m26s
initial setup - no real function yet

Signed-off-by: Peter Siegmund <developer@mars3142.org>
2026-01-19 22:47:57 +01:00
501c2de874 update time on website via webSocket
Some checks failed
ESP-IDF Build / build (esp32c6, release-v5.4) (push) Failing after 4m44s
ESP-IDF Build / build (esp32c6, release-v5.5) (push) Failing after 4m37s
ESP-IDF Build / build (esp32s3, release-v5.4) (push) Failing after 4m22s
ESP-IDF Build / build (esp32s3, release-v5.5) (push) Failing after 4m24s
Signed-off-by: Peter Siegmund <developer@mars3142.org>
2026-01-19 00:19:57 +01:00
b39a3be956 show correct schame on website
Signed-off-by: Peter Siegmund <developer@mars3142.org>
2026-01-18 22:45:46 +01:00
3ec7bf7acb more status values
Signed-off-by: Peter Siegmund <developer@mars3142.org>
2026-01-18 22:41:20 +01:00
a12dfe7760 update comments
Signed-off-by: Peter Siegmund <developer@mars3142.org>
2026-01-18 16:51:47 +01:00
dc40acfd06 change schema via REST
Some checks failed
ESP-IDF Build / build (esp32c6, release-v5.4) (push) Failing after 4m46s
ESP-IDF Build / build (esp32c6, release-v5.5) (push) Failing after 4m56s
ESP-IDF Build / build (esp32s3, release-v5.4) (push) Failing after 4m45s
ESP-IDF Build / build (esp32s3, release-v5.5) (push) Failing after 4m44s
Signed-off-by: Peter Siegmund <developer@mars3142.org>
2026-01-18 16:41:41 +01:00
3d7de05614 read schema files for website
Signed-off-by: Peter Siegmund <developer@mars3142.org>
2026-01-18 16:26:05 +01:00
3f32b791b7 change light mode
day/night/simulation

Signed-off-by: Peter Siegmund <developer@mars3142.org>
2026-01-18 16:09:09 +01:00
ccdc2bb63f send current state via WS
Some checks failed
ESP-IDF Build / build (esp32c6, release-v5.4) (push) Failing after 6m9s
ESP-IDF Build / build (esp32c6, release-v5.5) (push) Failing after 4m54s
ESP-IDF Build / build (esp32s3, release-v5.4) (push) Failing after 4m35s
ESP-IDF Build / build (esp32s3, release-v5.5) (push) Failing after 4m59s
Signed-off-by: Peter Siegmund <developer@mars3142.org>
2026-01-18 15:29:03 +01:00
7d12d98ec9 refresh device ui after REST call
Signed-off-by: Peter Siegmund <developer@mars3142.org>
2026-01-18 15:12:09 +01:00
cdac9cbfb8 rework message manager
use of listener pattern instead of message queue

Signed-off-by: Peter Siegmund <developer@mars3142.org>
2026-01-18 14:26:13 +01:00
1fade06bdb light on/off via REST or GPIO
Signed-off-by: Peter Siegmund <developer@mars3142.org>
2026-01-18 14:13:05 +01:00
f7cedf24e8 shared website header
Some checks failed
ESP-IDF Build / build (esp32c6, release-v5.4) (push) Failing after 7m45s
ESP-IDF Build / build (esp32c6, release-v5.5) (push) Failing after 4m52s
ESP-IDF Build / build (esp32s3, release-v5.4) (push) Failing after 4m35s
ESP-IDF Build / build (esp32s3, release-v5.5) (push) Failing after 4m58s
Signed-off-by: Peter Siegmund <developer@mars3142.org>
2026-01-18 10:59:08 +01:00
1c52f7d679 fixed devcontainer image
Signed-off-by: Peter Siegmund <developer@mars3142.org>
2026-01-18 10:58:35 +01:00
7a73fc4b7b implement reset via back button
Signed-off-by: Peter Siegmund <developer@mars3142.org>
2026-01-18 10:58:24 +01:00
1fbc28a628 optimize AP mode
Some checks failed
ESP-IDF Build / build (esp32c6, release-v5.4) (push) Failing after 6m44s
ESP-IDF Build / build (esp32c6, release-v5.5) (push) Failing after 3m59s
ESP-IDF Build / build (esp32s3, release-v5.4) (push) Failing after 3m51s
ESP-IDF Build / build (esp32s3, release-v5.5) (push) Failing after 3m52s
- save wifi data
- show status led

Signed-off-by: Peter Siegmund <developer@mars3142.org>
2026-01-15 00:36:19 +01:00
bccfb80791 show capative portal on connect
Some checks failed
ESP-IDF Build / build (esp32c6, release-v5.4) (push) Failing after 6m44s
ESP-IDF Build / build (esp32c6, release-v5.5) (push) Failing after 4m12s
ESP-IDF Build / build (esp32s3, release-v5.4) (push) Failing after 3m54s
ESP-IDF Build / build (esp32s3, release-v5.5) (push) Failing after 3m56s
Signed-off-by: Peter Siegmund <developer@mars3142.org>
2026-01-13 00:05:54 +01:00
b77fdee21d refactor persistence manager from cpp to c
Some checks failed
ESP-IDF Build / build (esp32c6, release-v5.4) (push) Failing after 4m12s
ESP-IDF Build / build (esp32c6, release-v5.5) (push) Failing after 4m17s
ESP-IDF Build / build (esp32s3, release-v5.4) (push) Failing after 3m49s
ESP-IDF Build / build (esp32s3, release-v5.5) (push) Failing after 4m0s
Signed-off-by: Peter Siegmund <developer@mars3142.org>
2026-01-10 11:12:31 +01:00
ef0cda1d67 update web ui
Some checks failed
ESP-IDF Build / build (esp32c6, release-v5.4) (push) Failing after 4m6s
ESP-IDF Build / build (esp32c6, release-v5.5) (push) Failing after 4m19s
ESP-IDF Build / build (esp32s3, release-v5.4) (push) Failing after 3m52s
ESP-IDF Build / build (esp32s3, release-v5.5) (push) Failing after 4m4s
Signed-off-by: Peter Siegmund <developer@mars3142.org>
2026-01-10 00:33:34 +01:00
cfca3f1535 mask password in log
Some checks failed
ESP-IDF Build / build (esp32c6, release-v5.4) (push) Failing after 3m21s
ESP-IDF Build / build (esp32c6, release-v5.5) (push) Failing after 5m51s
ESP-IDF Build / build (esp32s3, release-v5.4) (push) Failing after 3m6s
ESP-IDF Build / build (esp32s3, release-v5.5) (push) Failing after 3m26s
Signed-off-by: Peter Siegmund <developer@mars3142.org>
2026-01-06 00:18:15 +01:00
d18c9bfea1 add capa endpoint
Signed-off-by: Peter Siegmund <developer@mars3142.org>
2026-01-06 00:18:01 +01:00
28e991cf58 scan for WiFi and schon connection state (loading once)
Signed-off-by: Peter Siegmund <developer@mars3142.org>
2026-01-06 00:08:03 +01:00
ebf0dc6556 show website via mdns
Signed-off-by: Peter Siegmund <developer@mars3142.org>
2026-01-05 23:47:00 +01:00
29785a96bc add thunder configuration
Some checks failed
ESP-IDF Build / build (esp32c6, release-v5.4) (push) Successful in 3m38s
ESP-IDF Build / build (esp32c6, release-v5.5) (push) Successful in 3m48s
ESP-IDF Build / build (esp32s3, release-v5.4) (push) Failing after 3m17s
ESP-IDF Build / build (esp32s3, release-v5.5) (push) Failing after 3m10s
Signed-off-by: Peter Siegmund <developer@mars3142.org>
2026-01-01 17:52:02 +01:00
ee587f1381 hide thread configuration (for now)
Signed-off-by: Peter Siegmund <developer@mars3142.org>
2026-01-01 17:40:23 +01:00
a66c48e713 rename WLED to LED
Signed-off-by: Peter Siegmund <developer@mars3142.org>
2026-01-01 17:34:44 +01:00
b0e93d613c add segment settings
Signed-off-by: Peter Siegmund <developer@mars3142.org>
2026-01-01 17:31:41 +01:00
52f6c2acab vibe coded website (plus captive portal)
Some checks failed
ESP-IDF Build / build (esp32c6, release-v5.4) (push) Successful in 3m57s
ESP-IDF Build / build (esp32c6, release-v5.5) (push) Successful in 3m48s
ESP-IDF Build / build (esp32s3, release-v5.4) (push) Failing after 3m18s
ESP-IDF Build / build (esp32s3, release-v5.5) (push) Failing after 3m14s
needs missing ESP32 implementation

Signed-off-by: Peter Siegmund <developer@mars3142.org>
2026-01-01 16:39:27 +01:00
dfad7cfb76 version bump
Some checks failed
ESP-IDF Build / build (esp32c6, release-v5.4) (push) Failing after 4m42s
ESP-IDF Build / build (esp32c6, release-v5.5) (push) Failing after 4m30s
ESP-IDF Build / build (esp32s3, release-v5.4) (push) Failing after 4m24s
ESP-IDF Build / build (esp32s3, release-v5.5) (push) Failing after 4m30s
Signed-off-by: Peter Siegmund <developer@mars3142.org>
2025-12-27 00:14:44 +01:00
5d78572481 send screen names to ESP Insights
Signed-off-by: Peter Siegmund <developer@mars3142.org>
2025-12-27 00:13:53 +01:00
9e9fb15f86 fix I2C timeout crash
Signed-off-by: Peter Siegmund <developer@mars3142.org>
2025-12-27 00:10:51 +01:00
e7af663bc3 send button event to ESP Insights
Signed-off-by: Peter Siegmund <developer@mars3142.org>
2025-12-27 00:10:37 +01:00
e81fc62645 multiple wifi connection for login check
Some checks failed
ESP-IDF Build / build (esp32c6, release-v5.4) (push) Successful in 6m28s
ESP-IDF Build / build (esp32c6, release-v5.5) (push) Successful in 6m15s
ESP-IDF Build / build (esp32s3, release-v5.4) (push) Failing after 3m13s
ESP-IDF Build / build (esp32s3, release-v5.5) (push) Failing after 3m27s
Signed-off-by: Peter Siegmund <developer@mars3142.org>
2025-12-26 00:13:10 +01:00
aa10eb55f4 fix imports
Some checks failed
ESP-IDF Build / build (esp32c6, release-v5.4) (push) Successful in 3m36s
ESP-IDF Build / build (esp32c6, release-v5.5) (push) Successful in 3m42s
ESP-IDF Build / build (esp32s3, release-v5.4) (push) Failing after 3m9s
ESP-IDF Build / build (esp32s3, release-v5.5) (push) Failing after 2m59s
Signed-off-by: Peter Siegmund <developer@mars3142.org>
2025-12-25 00:24:09 +01:00
128 changed files with 13645 additions and 1010 deletions

2
.gitignore vendored
View File

@@ -24,3 +24,5 @@
**/*_front.png
**/*_schematic*.png
**/wiki/*
*.FCBak
firmware/**/node_modules

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}
ENV LC_ALL=C.UTF-8

View File

@@ -1,4 +1,9 @@
cmake_minimum_required(VERSION 3.5)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
idf_build_set_property(BOOTLOADER_EXTRA_COMPONENT_DIRS "${CMAKE_CURRENT_LIST_DIR}/bootloader_components_extra/" APPEND)
project(system_control)
target_add_binary_data(${PROJECT_NAME}.elf "main/isrgrootx1.pem" TEXT)

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

@@ -11,7 +11,7 @@ void analytics_init(void)
.log_type = ESP_DIAG_LOG_TYPE_ERROR | ESP_DIAG_LOG_TYPE_EVENT | ESP_DIAG_LOG_TYPE_WARNING,
.node_id = NULL,
.auth_key = insights_auth_key_start,
.alloc_ext_ram = false,
.alloc_ext_ram = true,
};
esp_insights_init(&config);

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_scanner.c
src/ble_manager.c
src/dns_hijack.c
src/wifi_manager.c
INCLUDE_DIRS "include"
REQUIRES
@@ -9,5 +10,7 @@ idf_component_register(SRCS
driver
nvs_flash
esp_insights
analytics
led-manager
api-server
)

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 "dns_hijack.h"
#include "analytics.h"
#include "api_server.h"
#include <esp_event.h>
#include <esp_insights.h>
#include <esp_log.h>
@@ -11,132 +14,166 @@
#include <led_status.h>
#include <lwip/err.h>
#include <lwip/sys.h>
#include <mdns.h>
#include <nvs_flash.h>
#include <persistence_manager.h>
#include <sdkconfig.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;
// The bits for the event group
// Event group bits
#define WIFI_CONNECTED_BIT BIT0
#define WIFI_FAIL_BIT BIT1
static const char *TAG = "wifi_manager";
static int s_retry_num = 0;
static void event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data)
static void led_status_reconnect()
{
#if CONFIG_WIFI_ENABLED
if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START)
{
led_behavior_t led0_behavior = {
led_behavior_t led_behavior = {
.on_time_ms = 250,
.off_time_ms = 100,
.color = {.red = 50, .green = 50, .blue = 0},
.index = 0,
.mode = LED_MODE_BLINK,
.color = {.red = 50, .green = 50, .blue = 0},
.on_time_ms = 200,
.off_time_ms = 200,
};
led_status_set_behavior(led0_behavior);
led_status_set_behavior(led_behavior);
}
static void wifi_event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data)
{
if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START)
{
ESP_LOGI(TAG, "WIFI_EVENT_STA_START: Connecting to AP...");
esp_wifi_connect();
}
else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED)
{
if (s_retry_num < CONFIG_WIFI_CONNECT_RETRIES)
{
led_behavior_t led0_behavior = {
.index = 0,
.mode = LED_MODE_BLINK,
.color = {.red = 50, .green = 50, .blue = 0},
.on_time_ms = 200,
.off_time_ms = 200,
};
led_status_set_behavior(led0_behavior);
esp_wifi_connect();
s_retry_num++;
ESP_DIAG_EVENT(TAG, "Retrying to connect to the AP");
return;
}
led_behavior_t led0_behavior = {
.index = 0,
.mode = LED_MODE_BLINK,
.color = {.red = 50, .green = 0, .blue = 0},
.on_time_ms = 1000,
.off_time_ms = 500,
};
led_status_set_behavior(led0_behavior);
ESP_LOGW(TAG, "WIFI_EVENT_STA_DISCONNECTED: Verbindung verloren, versuche erneut...");
xEventGroupSetBits(s_wifi_event_group, WIFI_FAIL_BIT);
led_status_reconnect();
esp_wifi_connect();
}
else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP)
{
led_behavior_t led0_behavior = {
.index = 0,
.mode = LED_MODE_SOLID,
.color = {.red = 0, .green = 50, .blue = 0},
};
led_status_set_behavior(led0_behavior);
ip_event_got_ip_t *event = (ip_event_got_ip_t *)event_data;
ESP_DIAG_EVENT(TAG, "Got IP address:" IPSTR, IP2STR(&event->ip_info.ip));
s_retry_num = 0;
ESP_LOGI(TAG, "IP_EVENT_STA_GOT_IP: IP-Adresse erhalten: " IPSTR, IP2STR(&event->ip_info.ip));
analytics_init();
xEventGroupSetBits(s_wifi_event_group, WIFI_CONNECTED_BIT);
}
#endif
else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_LOST_IP)
{
ESP_LOGW(TAG, "IP_EVENT_STA_LOST_IP: IP-Adresse verloren! Versuche Reconnect...");
xEventGroupSetBits(s_wifi_event_group, WIFI_FAIL_BIT);
led_status_reconnect();
esp_wifi_connect();
}
}
static void wifi_create_ap()
{
ESP_ERROR_CHECK(esp_wifi_stop());
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_AP));
wifi_config_t ap_config = {.ap = {.ssid = "system-control",
.ssid_len = strlen("system-control"),
.password = "",
.max_connection = 4,
.authmode = WIFI_AUTH_OPEN}};
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_AP, &ap_config));
ESP_ERROR_CHECK(esp_wifi_start());
ESP_LOGI(TAG, "Access Point 'system-control' started");
dns_server_start("192.168.4.1");
led_behavior_t led_behavior = {
.color = {.red = 50, .green = 0, .blue = 0},
.index = 0,
.mode = LED_MODE_SOLID,
};
led_status_set_behavior(led_behavior);
}
void wifi_manager_init()
{
#if CONFIG_WIFI_ENABLED
s_wifi_event_group = xEventGroupCreate();
ESP_ERROR_CHECK(esp_netif_init());
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();
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));
wifi_config_t wifi_config = {
.sta =
{
.ssid = CONFIG_WIFI_SSID,
.password = CONFIG_WIFI_PASSWORD,
.threshold.authmode = WIFI_AUTH_WPA2_PSK,
},
};
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_DIAG_EVENT(TAG, "waiting for wifi connection...");
/* Waiting until either the connection is established (WIFI_CONNECTED_BIT) or
connection failed for the maximum number of retries (WIFI_FAIL_BIT). The bits are set by event_handler() */
EventBits_t bits =
xEventGroupWaitBits(s_wifi_event_group, WIFI_CONNECTED_BIT | WIFI_FAIL_BIT, pdFALSE, pdFALSE, portMAX_DELAY);
int retries = 0;
EventBits_t bits;
do
{
ESP_LOGI(TAG, "Warte auf IP-Adresse (DHCP)...");
bits = xEventGroupWaitBits(s_wifi_event_group, WIFI_CONNECTED_BIT | WIFI_FAIL_BIT, pdFALSE, pdFALSE,
10000 / portTICK_PERIOD_MS);
if (bits & WIFI_CONNECTED_BIT)
{
ESP_DIAG_EVENT(TAG, "Connected to AP SSID:%s", CONFIG_WIFI_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 SSID:%s", CONFIG_WIFI_SSID);
ESP_LOGW(TAG, "WiFi connection failed (keine IP?), switching to Access Point mode");
// AP-Netzwerkschnittstelle initialisieren, falls noch nicht geschehen
esp_netif_create_default_wifi_ap();
wifi_create_ap();
}
}
else
{
ESP_LOGE(TAG, "Unexpected event");
// Create Access Point
esp_netif_create_default_wifi_ap();
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
ESP_ERROR_CHECK(esp_wifi_init(&cfg));
wifi_create_ap();
}
#endif
// API server start
api_server_config_t s_config = API_SERVER_CONFIG_DEFAULT();
ESP_ERROR_CHECK(api_server_start(&s_config));
}

View File

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

View File

@@ -17,7 +17,7 @@
// Project-specific headers
#include "common/Widget.h"
#include "IPersistenceManager.h"
#include "persistence_manager.h"
#include "u8g2.h"
class MenuItem;
@@ -61,14 +61,8 @@ typedef struct
std::function<void(ButtonType button)> onButtonClicked;
/**
* @brief Shared pointer to platform-independent persistence manager
* @details This provides access to persistent key-value storage across different
* platforms. The actual implementation (SDL3 or ESP32/NVS) is determined
* at compile time based on the target platform.
*
* @note The persistence manager is shared across all menu widgets and maintains
* its state throughout the application lifecycle.
* @brief Zeiger auf C-Persistence-Manager-Instanz
*/
std::shared_ptr<IPersistenceManager> persistenceManager;
persistence_manager_t *persistenceManager;
} menu_options_t;

View File

@@ -59,5 +59,37 @@ class MenuItem;
*/
typedef std::function<void(MenuItem, ButtonType)> ButtonCallback;
/**
* @def IMPLEMENT_GET_NAME
* @brief Macro to implement getName() method using __FILE__
* @details Extracts the class name from the source filename automatically.
* Use this macro in .cpp files to implement Widget::getName().
* @param ClassName The class name for the method scope (e.g., MainMenu)
*/
#define IMPLEMENT_GET_NAME(ClassName) \
const char *ClassName::getName() const \
{ \
static const char *cachedName = nullptr; \
if (!cachedName) \
{ \
const char *file = __FILE__; \
const char *lastSlash = file; \
for (const char *p = file; *p; ++p) \
{ \
if (*p == '/' || *p == '\\') \
lastSlash = p + 1; \
} \
static char buffer[64]; \
size_t i = 0; \
for (; lastSlash[i] && lastSlash[i] != '.' && i < sizeof(buffer) - 1; ++i) \
{ \
buffer[i] = lastSlash[i]; \
} \
buffer[i] = '\0'; \
cachedName = buffer; \
} \
return cachedName; \
}
// Include MenuItem.h after the typedef to avoid circular dependency
#include "data/MenuItem.h"

View File

@@ -175,6 +175,21 @@ class Menu : public Widget
*/
void toggle(const MenuItem &menuItem);
/**
* @brief Setzt den Zustand eines Toggle-Menüeintrags explizit
* @param menuItem Der zu ändernde Toggle-Menüeintrag
* @param state Neuer Zustand (true = aktiviert, false = deaktiviert)
*
* @pre menuItem muss vom Typ TOGGLE sein
* @post Der Wert des Menüeintrags wird auf den angegebenen Zustand gesetzt
*
* @details Diese Methode setzt den Wert eines Toggle-Menüeintrags gezielt auf den gewünschten Zustand.
* Der geänderte Eintrag ersetzt das Original in der internen Menüstruktur.
*
* @note Diese Methode verändert direkt den internen Zustand des Menüs.
*/
void setToggle(const MenuItem &menuItem, const bool state);
/**
* @brief Changes the selected value of a selection menu item based on button input
* @param menuItem The selection menu item to modify
@@ -191,6 +206,8 @@ class Menu : public Widget
*/
MenuItem switchValue(const MenuItem &menuItem, ButtonType button);
void setSelectionIndex(const MenuItem &menuItem, int index);
private:
MenuItem replaceItem(int index, const MenuItem &item);

View File

@@ -13,6 +13,7 @@
#include "u8g2.h"
#include "common/Common.h"
#include "message_manager.h"
/**
* @class Widget
@@ -49,7 +50,9 @@ class Widget
* @details Ensures that derived class destructors are called correctly when
* a widget is destroyed through a base class pointer.
*/
virtual ~Widget() = default;
virtual ~Widget();
virtual void onMessageReceived(const message_t *msg);
/**
* @brief Called when the widget becomes active or enters the foreground
@@ -150,6 +153,19 @@ class Widget
*/
virtual void OnButtonClicked(ButtonType button);
/**
* @brief Returns the name of this widget for diagnostic purposes
* @return A string identifying the widget type
*
* @details This method returns a human-readable name for the widget which
* is used for logging and diagnostic events. Derived classes should
* override this method to return their specific screen/widget name.
*
* @note The base implementation returns "Widget". Override in derived classes
* to provide meaningful screen names for diagnostics.
*/
virtual const char *getName() const;
protected:
/**
* @brief Pointer to the u8g2 display context used for rendering operations
@@ -165,4 +181,8 @@ class Widget
* the u8g2 context and assumes it is managed externally.
*/
u8g2_t *u8g2;
private:
static std::vector<Widget *> s_instances;
static void globalMessageCallback(const message_t *msg);
};

View File

@@ -24,6 +24,7 @@ class ClockScreenSaver final : public Widget
void Update(uint64_t dt) override;
void Render() override;
void OnButtonClicked(ButtonType button) override;
const char *getName() const override;
private:
static constexpr int MOVE_INTERVAL = 50; // milliseconds between movements

View File

@@ -7,6 +7,8 @@ class ExternalDevices final : public Menu
public:
explicit ExternalDevices(menu_options_t *options);
const char *getName() const override;
private:
void onButtonPressed(const MenuItem &menuItem, ButtonType button) override;
menu_options_t *m_options;

View File

@@ -80,6 +80,8 @@ class LightMenu final : public Menu
*/
explicit LightMenu(menu_options_t *options);
const char *getName() const override;
private:
/**
* @brief Handles button press events specific to light control menu items
@@ -118,6 +120,8 @@ class LightMenu final : public Menu
*/
void onButtonPressed(const MenuItem &menuItem, ButtonType button) override;
void onMessageReceived(const message_t *msg);
/**
* @brief Pointer to menu options configuration structure
* @details Stores a reference to the menu configuration passed during construction.

View File

@@ -57,6 +57,8 @@ class MainMenu final : public Menu
*/
explicit MainMenu(menu_options_t *options);
const char *getName() const override;
private:
/**
* @brief Handles button press events specific to main menu items

View File

@@ -26,6 +26,7 @@ class ScreenSaver final : public Widget
void Update(uint64_t dt) override;
void Render() override;
void OnButtonClicked(ButtonType button) override;
const char *getName() const override;
private:
/**

View File

@@ -74,4 +74,6 @@ public:
* @see Menu::Menu for base class construction details
*/
explicit SettingsMenu(menu_options_t *options);
const char *getName() const override;
};

View File

@@ -151,6 +151,8 @@ class SplashScreen final : public Widget
*/
void Render() override;
const char *getName() const override;
private:
/**
* @brief Pointer to menu options configuration structure

View File

@@ -28,7 +28,7 @@ constexpr int BOTTOM_OFFSET = 10;
Menu::Menu(menu_options_t *options) : Widget(options->u8g2), m_options(options)
{
// Set up button callback using lambda to forward to member function
// Set up button callback using a lambda to forward to the member function
m_options->onButtonClicked = [this](const ButtonType button) { OnButtonClicked(button); };
}
@@ -64,7 +64,7 @@ void Menu::setItemSize(const size_t size, int8_t startIndex)
constexpr int key_length = 20;
char key[key_length] = "";
snprintf(key, key_length, "section_%zu", i + 1 - startIndex);
index = m_options->persistenceManager->GetValue(key, index);
index = persistence_manager_get_int(m_options->persistenceManager, key, index);
}
addSelection(i + 1, caption, m_items.at(0).getValues(), index);
}
@@ -82,6 +82,12 @@ void Menu::toggle(const MenuItem &menuItem)
replaceItem(menuItem.getId(), item);
}
void Menu::setToggle(const MenuItem &menuItem, const bool state)
{
const auto item = menuItem.copyWith(state ? std::to_string(true) : std::to_string(false));
replaceItem(menuItem.getId(), item);
}
MenuItem Menu::switchValue(const MenuItem &menuItem, ButtonType button)
{
MenuItem result = menuItem;
@@ -120,6 +126,15 @@ MenuItem Menu::switchValue(const MenuItem &menuItem, ButtonType button)
return result;
}
void Menu::setSelectionIndex(const MenuItem &menuItem, int index)
{
if (index >= 0 && index < menuItem.getItemCount())
{
auto item = menuItem.copyWith(index);
replaceItem(menuItem.getId(), item);
}
}
MenuItem Menu::replaceItem(const int index, const MenuItem &item)
{
m_items.at(index) = item;
@@ -134,13 +149,13 @@ void Menu::Render()
m_selected_item = 0;
}
// Early return if no items to render
// Early return if there are no items to render
if (m_items.empty())
{
return;
}
// Clear screen with black background
// Clear the screen with a black background
u8g2_SetDrawColor(u8g2, 0);
u8g2_DrawBox(u8g2, 0, 0, u8g2->width, u8g2->height);
@@ -151,7 +166,7 @@ void Menu::Render()
drawScrollBar();
drawSelectionBox();
// Calculate center position for main item
// Calculate center position for the main item
const int centerY = u8g2->height / 2 + 3;
// Render the currently selected item (main/center item)
@@ -176,7 +191,7 @@ void Menu::Render()
void Menu::renderWidget(const MenuItem *item, const uint8_t *font, const int x, const int y) const
{
// Set font and draw main text
// Set font and draw the main text
u8g2_SetFont(u8g2, font);
u8g2_DrawStr(u8g2, x, y, item->getText().c_str());
@@ -206,7 +221,7 @@ void Menu::renderWidget(const MenuItem *item, const uint8_t *font, const int x,
}
case MenuItemTypes::TOGGLE: {
// Draw checkbox frame
// Draw the checkbox frame
const int frameX = u8g2->width - UIConstants::FRAME_BOX_SIZE - UIConstants::SELECTION_MARGIN;
const int frameY = y - UIConstants::FRAME_OFFSET;
u8g2_DrawFrame(u8g2, frameX, frameY, UIConstants::FRAME_BOX_SIZE, UIConstants::FRAME_BOX_SIZE);
@@ -272,7 +287,7 @@ void Menu::onPressedDown()
if (m_items.empty())
return;
// Wrap around to first item when at the end
// Wrap around to the first item when at the end
m_selected_item = (m_selected_item + 1) % m_items.size();
}
@@ -281,7 +296,7 @@ void Menu::onPressedUp()
if (m_items.empty())
return;
// Wrap around to last item when at the beginning
// Wrap around to the last item when at the beginning
m_selected_item = (m_selected_item == 0) ? m_items.size() - 1 : m_selected_item - 1;
}
@@ -314,7 +329,7 @@ void Menu::onPressedSelect() const
void Menu::onPressedBack() const
{
// Navigate back to previous screen if callback is available
// Navigate back to the previous screen if callback is available
if (m_options && m_options->popScreen)
{
m_options->popScreen();

View File

@@ -1,7 +1,20 @@
#include "common/Widget.h"
#include <algorithm>
std::vector<Widget *> Widget::s_instances;
Widget::Widget(u8g2_t *u8g2) : u8g2(u8g2)
{
s_instances.push_back(this);
if (s_instances.size() == 1)
{
message_manager_register_listener(globalMessageCallback);
}
}
Widget::~Widget()
{
s_instances.erase(std::remove(s_instances.begin(), s_instances.end(), this), s_instances.end());
}
void Widget::onEnter()
@@ -31,3 +44,20 @@ void Widget::Render()
void Widget::OnButtonClicked(ButtonType button)
{
}
const char *Widget::getName() const
{
return "Widget";
}
void Widget::onMessageReceived(const message_t *msg)
{
}
void Widget::globalMessageCallback(const message_t *msg)
{
for (auto *w : s_instances)
{
w->onMessageReceived(msg);
}
}

View File

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

View File

@@ -23,3 +23,5 @@ void ExternalDevices::onButtonPressed(const MenuItem &menuItem, const ButtonType
ble_connect_to_device(menuItem.getId());
}
}
IMPLEMENT_GET_NAME(ExternalDevices)

View File

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

View File

@@ -48,3 +48,5 @@ void MainMenu::onButtonPressed(const MenuItem &menuItem, const ButtonType button
}
}
}
IMPLEMENT_GET_NAME(MainMenu)

View File

@@ -327,3 +327,5 @@ void ScreenSaver::OnButtonClicked(ButtonType button)
m_options->popScreen();
}
}
IMPLEMENT_GET_NAME(ScreenSaver)

View File

@@ -9,3 +9,5 @@ SettingsMenu::SettingsMenu(menu_options_t *options) : Menu(options)
{
addText(SettingsMenuItem::OTA_UPLOAD, "OTA Einspielen");
}
IMPLEMENT_GET_NAME(SettingsMenu)

View File

@@ -28,3 +28,5 @@ void SplashScreen::Render()
u8g2_SetFont(u8g2, u8g2_font_haxrcorp4089_tr);
u8g2_DrawStr(u8g2, 35, 50, "Initialisierung...");
}
IMPLEMENT_GET_NAME(SplashScreen)

View File

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

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
hsv_t interpolated_hsv;
interpolated_hsv.h = fmod(h1 + (h2 - h1) * factor, 360.0);
if (interpolated_hsv.h < 0)
interpolated_hsv.h = fmodf(h1 + (h2 - h1) * factor, 360.0f);
if (interpolated_hsv.h < 0.0f)
{
interpolated_hsv.h += 360.0;
interpolated_hsv.h += 360.0f;
}
interpolated_hsv.s = start_hsv.s + (end_hsv.s - start_hsv.s) * factor;
interpolated_hsv.v = start_hsv.v + (end_hsv.v - start_hsv.v) * factor;

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
src/PersistenceManager.cpp
src/persistence_manager.c
INCLUDE_DIRS "include"
REQUIRES
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
led-manager
persistence-manager
message-manager
spiffs
)

View File

@@ -9,6 +9,11 @@ typedef struct
int cycle_duration_minutes;
} simulation_config_t;
#ifdef __cplusplus
extern "C"
{
#endif
char *get_time(void);
esp_err_t add_light_item(const char time[5], uint8_t red, uint8_t green, uint8_t blue, uint8_t white,
uint8_t brightness, uint8_t saturation);
@@ -18,3 +23,6 @@ void start_simulate_night(void);
void start_simulation_task(void);
void stop_simulation_task(void);
void start_simulation(void);
#ifdef __cplusplus
}
#endif

View File

@@ -1,4 +1,23 @@
#pragma once
#include "esp_err.h"
#ifdef __cplusplus
extern "C"
{
#endif
void initialize_storage();
void load_file(const char *filename);
char **read_lines_filtered(const char *filename, int *out_count);
void free_lines(char **lines, int count);
/**
* Write an array of lines to a file (CSV or other text).
* @param filename File name (without /spiffs/)
* @param lines Array of lines (null-terminated strings)
* @param count Number of lines
* @return ESP_OK on success, error code otherwise
*/
esp_err_t write_lines(const char *filename, char **lines, int count);
#ifdef __cplusplus
}
#endif

View File

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

View File

@@ -3,14 +3,22 @@
#include "esp_log.h"
#include "esp_spiffs.h"
#include "simulator.h"
#include <cstring>
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static const char *TAG = "storage";
static bool is_spiffs_mounted = false;
void initialize_storage()
{
if (is_spiffs_mounted)
{
return;
}
esp_vfs_spiffs_conf_t conf = {
.base_path = "/spiffs",
.partition_label = NULL,
@@ -36,64 +44,113 @@ void initialize_storage()
}
return;
}
is_spiffs_mounted = true;
}
void load_file(const char *filename)
{
ESP_LOGI(TAG, "Loading file: %s", filename);
FILE *f = fopen(filename, "r");
if (f == NULL)
{
ESP_LOGE(TAG, "Failed to open file for reading");
return;
}
char line[128];
int line_count = 0;
char **lines = read_lines_filtered(filename, &line_count);
uint8_t line_number = 0;
while (fgets(line, sizeof(line), f))
for (int i = 0; i < line_count; ++i)
{
char *pos = strchr(line, '\n');
if (pos)
{
*pos = '\0';
}
if (strlen(line) == 0)
{
continue;
}
char *trimmed = line;
while (*trimmed == ' ' || *trimmed == '\t')
{
trimmed++;
}
if (*trimmed == '#' || *trimmed == '\0')
{
continue;
}
char time[10] = {0};
int red, green, blue, white, brightness, saturation;
int items_scanned = sscanf(line, "%d,%d,%d,%d,%d,%d", &red, &green, &blue, &white, &brightness, &saturation);
int items_scanned =
sscanf(lines[i], "%d,%d,%d,%d,%d,%d", &red, &green, &blue, &white, &brightness, &saturation);
if (items_scanned == 6)
{
int total_minutes = line_number * 30;
int hours = total_minutes / 60;
int minutes = total_minutes % 60;
snprintf(time, sizeof(time), "%02d%02d", hours, minutes);
add_light_item(time, red, green, blue, white, brightness, saturation);
line_number++;
}
else
{
ESP_LOGW(TAG, "Could not parse line: %s", line);
ESP_LOGW(TAG, "Could not parse line: %s", lines[i]);
}
}
fclose(f);
free_lines(lines, line_count);
ESP_LOGI(TAG, "Finished loading file. Loaded %d entries.", line_number);
}
char **read_lines_filtered(const char *filename, int *out_count)
{
char fullpath[128];
snprintf(fullpath, sizeof(fullpath), "/spiffs/%s", filename[0] == '/' ? filename + 1 : filename);
FILE *f = fopen(fullpath, "r");
if (!f)
{
ESP_LOGE(TAG, "Failed to open file: %s", fullpath);
*out_count = 0;
return NULL;
}
size_t capacity = 16;
size_t count = 0;
char **lines = (char **)malloc(capacity * sizeof(char *));
char line[256];
while (fgets(line, sizeof(line), f))
{
// Zeilenumbruch entfernen
char *pos = strchr(line, '\n');
if (pos)
*pos = '\0';
// Trim vorne
char *trimmed = line;
while (*trimmed == ' ' || *trimmed == '\t')
trimmed++;
// Leere oder Kommentarzeile überspringen
if (*trimmed == '\0' || *trimmed == '#')
continue;
// Trim hinten
size_t len = strlen(trimmed);
while (len > 0 && (trimmed[len - 1] == ' ' || trimmed[len - 1] == '\t'))
trimmed[--len] = '\0';
// Kopieren
if (count >= capacity)
{
capacity *= 2;
lines = (char **)realloc(lines, capacity * sizeof(char *));
}
lines[count++] = strdup(trimmed);
}
fclose(f);
*out_count = (int)count;
return lines;
}
void free_lines(char **lines, int count)
{
for (int i = 0; i < count; ++i)
free(lines[i]);
free(lines);
}
esp_err_t write_lines(const char *filename, char **lines, int count)
{
char fullpath[128];
snprintf(fullpath, sizeof(fullpath), "/spiffs/%s", filename[0] == '/' ? filename + 1 : filename);
FILE *f = fopen(fullpath, "w");
if (!f)
{
ESP_LOGE(TAG, "Failed to open file for writing: %s", fullpath);
return ESP_FAIL;
}
for (int i = 0; i < count; ++i)
{
if (fprintf(f, "%s\n", lines[i]) < 0)
{
ESP_LOGE(TAG, "Failed to write line %d", i);
fclose(f);
return ESP_FAIL;
}
}
fclose(f);
ESP_LOGI(TAG, "Wrote %d lines to %s", count, fullpath);
return ESP_OK;
}

View File

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

View File

@@ -6,26 +6,90 @@ menu "System Control"
help
Enable or disable WiFi connectivity.
config WIFI_SSID
config WIFI_NETWORK_COUNT
depends on WIFI_ENABLED
string "WiFi SSID"
default "YourSSID"
int "Number of WiFi Networks"
default 1
range 1 5
help
The SSID of the WiFi network to connect to.
Number of WiFi networks to configure (1-5).
config WIFI_PASSWORD
config WIFI_SSID_1
depends on WIFI_ENABLED
string "WiFi Password"
default "YourPassword"
string "WiFi SSID 1"
default "YourSSID1"
help
The password of the WiFi network to connect to.
The SSID of the first WiFi network.
config WIFI_PASSWORD_1
depends on WIFI_ENABLED
string "WiFi Password 1"
default "YourPassword1"
help
The password of the first WiFi network.
config WIFI_SSID_2
depends on WIFI_ENABLED && WIFI_NETWORK_COUNT >= 2
string "WiFi SSID 2"
default ""
help
The SSID of the second WiFi network.
config WIFI_PASSWORD_2
depends on WIFI_ENABLED && WIFI_NETWORK_COUNT >= 2
string "WiFi Password 2"
default ""
help
The password of the second WiFi network.
config WIFI_SSID_3
depends on WIFI_ENABLED && WIFI_NETWORK_COUNT >= 3
string "WiFi SSID 3"
default ""
help
The SSID of the third WiFi network.
config WIFI_PASSWORD_3
depends on WIFI_ENABLED && WIFI_NETWORK_COUNT >= 3
string "WiFi Password 3"
default ""
help
The password of the third WiFi network.
config WIFI_SSID_4
depends on WIFI_ENABLED && WIFI_NETWORK_COUNT >= 4
string "WiFi SSID 4"
default ""
help
The SSID of the fourth WiFi network.
config WIFI_PASSWORD_4
depends on WIFI_ENABLED && WIFI_NETWORK_COUNT >= 4
string "WiFi Password 4"
default ""
help
The password of the fourth WiFi network.
config WIFI_SSID_5
depends on WIFI_ENABLED && WIFI_NETWORK_COUNT >= 5
string "WiFi SSID 5"
default ""
help
The SSID of the fifth WiFi network.
config WIFI_PASSWORD_5
depends on WIFI_ENABLED && WIFI_NETWORK_COUNT >= 5
string "WiFi Password 5"
default ""
help
The password of the fifth WiFi network.
config WIFI_CONNECT_RETRIES
depends on WIFI_ENABLED
int "WiFi Connection Retry Attempts"
default 5
int "WiFi Connection Retry Attempts per Network"
default 3
help
Number of times to retry connecting to the WiFi network before giving up.
Number of times to retry connecting to each WiFi network before trying the next one.
endmenu
menu "Display Settings"

View File

@@ -1,262 +0,0 @@
#include <stdio.h>
#include <string.h>
#include "esp_log.h"
#include "sdkconfig.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "u8g2_esp32_hal.h"
static const char* TAG = "u8g2_hal";
static const unsigned int I2C_TIMEOUT_MS = 1000;
static spi_device_handle_t handle_spi; // SPI handle.
static i2c_cmd_handle_t handle_i2c; // I2C handle.
static u8g2_esp32_hal_t u8g2_esp32_hal; // HAL state data.
#define HOST SPI2_HOST
#undef ESP_ERROR_CHECK
#define ESP_ERROR_CHECK(x) \
do { \
esp_err_t rc = (x); \
if (rc != ESP_OK) { \
ESP_LOGE("err", "esp_err_t = %d", rc); \
assert(0 && #x); \
} \
} while (0);
/*
* Initialze the ESP32 HAL.
*/
void u8g2_esp32_hal_init(u8g2_esp32_hal_t u8g2_esp32_hal_param) {
u8g2_esp32_hal = u8g2_esp32_hal_param;
} // u8g2_esp32_hal_init
/*
* HAL callback function as prescribed by the U8G2 library. This callback is
* invoked to handle SPI communications.
*/
uint8_t u8g2_esp32_spi_byte_cb(u8x8_t* u8x8,
uint8_t msg,
uint8_t arg_int,
void* arg_ptr) {
ESP_LOGD(TAG, "spi_byte_cb: Received a msg: %d, arg_int: %d, arg_ptr: %p",
msg, arg_int, arg_ptr);
switch (msg) {
case U8X8_MSG_BYTE_SET_DC:
if (u8g2_esp32_hal.dc != U8G2_ESP32_HAL_UNDEFINED) {
gpio_set_level(u8g2_esp32_hal.dc, arg_int);
}
break;
case U8X8_MSG_BYTE_INIT: {
if (u8g2_esp32_hal.bus.spi.clk == U8G2_ESP32_HAL_UNDEFINED ||
u8g2_esp32_hal.bus.spi.mosi == U8G2_ESP32_HAL_UNDEFINED ||
u8g2_esp32_hal.bus.spi.cs == U8G2_ESP32_HAL_UNDEFINED) {
break;
}
spi_bus_config_t bus_config;
memset(&bus_config, 0, sizeof(spi_bus_config_t));
bus_config.sclk_io_num = u8g2_esp32_hal.bus.spi.clk; // CLK
bus_config.mosi_io_num = u8g2_esp32_hal.bus.spi.mosi; // MOSI
bus_config.miso_io_num = GPIO_NUM_NC; // MISO
bus_config.quadwp_io_num = GPIO_NUM_NC; // Not used
bus_config.quadhd_io_num = GPIO_NUM_NC; // Not used
// ESP_LOGI(TAG, "... Initializing bus.");
ESP_ERROR_CHECK(spi_bus_initialize(HOST, &bus_config, 1));
spi_device_interface_config_t dev_config;
dev_config.address_bits = 0;
dev_config.command_bits = 0;
dev_config.dummy_bits = 0;
dev_config.mode = 0;
dev_config.duty_cycle_pos = 0;
dev_config.cs_ena_posttrans = 0;
dev_config.cs_ena_pretrans = 0;
dev_config.clock_speed_hz = 10000;
dev_config.spics_io_num = u8g2_esp32_hal.bus.spi.cs;
dev_config.flags = 0;
dev_config.queue_size = 200;
dev_config.pre_cb = NULL;
dev_config.post_cb = NULL;
// ESP_LOGI(TAG, "... Adding device bus.");
ESP_ERROR_CHECK(spi_bus_add_device(HOST, &dev_config, &handle_spi));
break;
}
case U8X8_MSG_BYTE_SEND: {
spi_transaction_t trans_desc;
trans_desc.addr = 0;
trans_desc.cmd = 0;
trans_desc.flags = 0;
trans_desc.length = 8 * arg_int; // Number of bits NOT number of bytes.
trans_desc.rxlength = 0;
trans_desc.tx_buffer = arg_ptr;
trans_desc.rx_buffer = NULL;
// ESP_LOGI(TAG, "... Transmitting %d bytes.", arg_int);
ESP_ERROR_CHECK(spi_device_transmit(handle_spi, &trans_desc));
break;
}
}
return 0;
} // u8g2_esp32_spi_byte_cb
/*
* HAL callback function as prescribed by the U8G2 library. This callback is
* invoked to handle I2C communications.
*/
uint8_t u8g2_esp32_i2c_byte_cb(u8x8_t* u8x8,
uint8_t msg,
uint8_t arg_int,
void* arg_ptr) {
ESP_LOGD(TAG, "i2c_cb: Received a msg: %d, arg_int: %d, arg_ptr: %p", msg,
arg_int, arg_ptr);
switch (msg) {
case U8X8_MSG_BYTE_SET_DC: {
if (u8g2_esp32_hal.dc != U8G2_ESP32_HAL_UNDEFINED) {
gpio_set_level(u8g2_esp32_hal.dc, arg_int);
}
break;
}
case U8X8_MSG_BYTE_INIT: {
if (u8g2_esp32_hal.bus.i2c.sda == U8G2_ESP32_HAL_UNDEFINED ||
u8g2_esp32_hal.bus.i2c.scl == U8G2_ESP32_HAL_UNDEFINED) {
break;
}
i2c_config_t conf = {0};
conf.mode = I2C_MODE_MASTER;
ESP_LOGI(TAG, "sda_io_num %d", u8g2_esp32_hal.bus.i2c.sda);
conf.sda_io_num = u8g2_esp32_hal.bus.i2c.sda;
conf.sda_pullup_en = GPIO_PULLUP_ENABLE;
ESP_LOGI(TAG, "scl_io_num %d", u8g2_esp32_hal.bus.i2c.scl);
conf.scl_io_num = u8g2_esp32_hal.bus.i2c.scl;
conf.scl_pullup_en = GPIO_PULLUP_ENABLE;
ESP_LOGI(TAG, "clk_speed %d", I2C_MASTER_FREQ_HZ);
conf.master.clk_speed = I2C_MASTER_FREQ_HZ;
ESP_LOGI(TAG, "i2c_param_config %d", conf.mode);
ESP_ERROR_CHECK(i2c_param_config(I2C_MASTER_NUM, &conf));
ESP_LOGI(TAG, "i2c_driver_install %d", I2C_MASTER_NUM);
ESP_ERROR_CHECK(i2c_driver_install(I2C_MASTER_NUM, conf.mode,
I2C_MASTER_RX_BUF_DISABLE,
I2C_MASTER_TX_BUF_DISABLE, 0));
break;
}
case U8X8_MSG_BYTE_SEND: {
uint8_t* data_ptr = (uint8_t*)arg_ptr;
ESP_LOG_BUFFER_HEXDUMP(TAG, data_ptr, arg_int, ESP_LOG_VERBOSE);
while (arg_int > 0) {
ESP_ERROR_CHECK(
i2c_master_write_byte(handle_i2c, *data_ptr, ACK_CHECK_EN));
data_ptr++;
arg_int--;
}
break;
}
case U8X8_MSG_BYTE_START_TRANSFER: {
uint8_t i2c_address = u8x8_GetI2CAddress(u8x8);
handle_i2c = i2c_cmd_link_create();
ESP_LOGD(TAG, "Start I2C transfer to %02X.", i2c_address >> 1);
ESP_ERROR_CHECK(i2c_master_start(handle_i2c));
ESP_ERROR_CHECK(i2c_master_write_byte(
handle_i2c, i2c_address | I2C_MASTER_WRITE, ACK_CHECK_EN));
break;
}
case U8X8_MSG_BYTE_END_TRANSFER: {
ESP_LOGD(TAG, "End I2C transfer.");
ESP_ERROR_CHECK(i2c_master_stop(handle_i2c));
ESP_ERROR_CHECK(i2c_master_cmd_begin(I2C_MASTER_NUM, handle_i2c,
pdMS_TO_TICKS(I2C_TIMEOUT_MS)));
i2c_cmd_link_delete(handle_i2c);
break;
}
}
return 0;
} // u8g2_esp32_i2c_byte_cb
/*
* HAL callback function as prescribed by the U8G2 library. This callback is
* invoked to handle callbacks for GPIO and delay functions.
*/
uint8_t u8g2_esp32_gpio_and_delay_cb(u8x8_t* u8x8,
uint8_t msg,
uint8_t arg_int,
void* arg_ptr) {
ESP_LOGD(TAG,
"gpio_and_delay_cb: Received a msg: %d, arg_int: %d, arg_ptr: %p",
msg, arg_int, arg_ptr);
switch (msg) {
// Initialize the GPIO and DELAY HAL functions. If the pins for DC and
// RESET have been specified then we define those pins as GPIO outputs.
case U8X8_MSG_GPIO_AND_DELAY_INIT: {
uint64_t bitmask = 0;
if (u8g2_esp32_hal.dc != U8G2_ESP32_HAL_UNDEFINED) {
bitmask = bitmask | (1ull << u8g2_esp32_hal.dc);
}
if (u8g2_esp32_hal.reset != U8G2_ESP32_HAL_UNDEFINED) {
bitmask = bitmask | (1ull << u8g2_esp32_hal.reset);
}
if (u8g2_esp32_hal.bus.spi.cs != U8G2_ESP32_HAL_UNDEFINED) {
bitmask = bitmask | (1ull << u8g2_esp32_hal.bus.spi.cs);
}
if (bitmask == 0) {
break;
}
gpio_config_t gpioConfig;
gpioConfig.pin_bit_mask = bitmask;
gpioConfig.mode = GPIO_MODE_OUTPUT;
gpioConfig.pull_up_en = GPIO_PULLUP_DISABLE;
gpioConfig.pull_down_en = GPIO_PULLDOWN_ENABLE;
gpioConfig.intr_type = GPIO_INTR_DISABLE;
gpio_config(&gpioConfig);
break;
}
// Set the GPIO reset pin to the value passed in through arg_int.
case U8X8_MSG_GPIO_RESET:
if (u8g2_esp32_hal.reset != U8G2_ESP32_HAL_UNDEFINED) {
gpio_set_level(u8g2_esp32_hal.reset, arg_int);
}
break;
// Set the GPIO client select pin to the value passed in through arg_int.
case U8X8_MSG_GPIO_CS:
if (u8g2_esp32_hal.bus.spi.cs != U8G2_ESP32_HAL_UNDEFINED) {
gpio_set_level(u8g2_esp32_hal.bus.spi.cs, arg_int);
}
break;
// Set the Software I²C pin to the value passed in through arg_int.
case U8X8_MSG_GPIO_I2C_CLOCK:
if (u8g2_esp32_hal.bus.i2c.scl != U8G2_ESP32_HAL_UNDEFINED) {
gpio_set_level(u8g2_esp32_hal.bus.i2c.scl, arg_int);
// printf("%c",(arg_int==1?'C':'c'));
}
break;
// Set the Software I²C pin to the value passed in through arg_int.
case U8X8_MSG_GPIO_I2C_DATA:
if (u8g2_esp32_hal.bus.i2c.sda != U8G2_ESP32_HAL_UNDEFINED) {
gpio_set_level(u8g2_esp32_hal.bus.i2c.sda, arg_int);
// printf("%c",(arg_int==1?'D':'d'));
}
break;
// Delay for the number of milliseconds passed in through arg_int.
case U8X8_MSG_DELAY_MILLI:
vTaskDelay(arg_int / portTICK_PERIOD_MS);
break;
}
return 0;
} // u8g2_esp32_gpio_and_delay_cb

View File

@@ -3,6 +3,5 @@ dependencies:
git: https://github.com/olikraus/u8g2.git
# u8g2_hal:
# git: https://github.com/mkfrey/u8g2-hal-esp-idf.git
espressif/button: ^4.1.3
espressif/button: ^4.1.4
espressif/esp_insights: ^1.2.7
espressif/mqtt: ^1.0.0

View File

@@ -9,19 +9,17 @@
#ifndef U8G2_ESP32_HAL_H_
#define U8G2_ESP32_HAL_H_
#include "u8g2.h"
#include "driver/gpio.h"
#include "driver/i2c.h"
#include "driver/spi_master.h"
#include "hal/i2c_types.h"
#define U8G2_ESP32_HAL_UNDEFINED GPIO_NUM_NC
#if SOC_I2C_NUM > 1
#define I2C_MASTER_NUM I2C_NUM_1 // I2C port number for master dev
#else
#define I2C_MASTER_NUM I2C_NUM_0 // I2C port number for master dev
#endif
#define I2C_MASTER_TX_BUF_DISABLE 0 // I2C master do not need buffer
#define I2C_MASTER_RX_BUF_DISABLE 0 // I2C master do not need buffer

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,18 +2,23 @@
#include "analytics.h"
#include "button_handling.h"
#include "common.h"
#include "common/InactivityTracker.h"
#include "hal/u8g2_esp32_hal.h"
#include "hal_esp32/PersistenceManager.h"
#include "i2c_checker.h"
#include "led_status.h"
#include "message_manager.h"
#include "my_mqtt_client.h"
#include "persistence_manager.h"
#include "simulator.h"
#include "ui/ClockScreenSaver.h"
#include "ui/ScreenSaver.h"
#include "ui/SplashScreen.h"
#include "wifi_manager.h"
#include <cstring>
#include <driver/i2c.h>
#include <esp_diagnostics.h>
#include <esp_insights.h>
#include <esp_log.h>
#include <esp_task_wdt.h>
#include <esp_timer.h>
@@ -32,7 +37,8 @@ uint8_t received_signal;
std::shared_ptr<Widget> m_widget;
std::vector<std::shared_ptr<Widget>> m_history;
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;
@@ -62,6 +68,7 @@ void setScreen(const std::shared_ptr<Widget> &screen)
{
if (screen != nullptr)
{
ESP_DIAG_EVENT(TAG, "Screen set: %s", screen->getName());
m_widget = screen;
m_history.clear();
m_history.emplace_back(m_widget);
@@ -77,6 +84,7 @@ void pushScreen(const std::shared_ptr<Widget> &screen)
{
m_widget->onPause();
}
ESP_DIAG_EVENT(TAG, "Screen pushed: %s", screen->getName());
m_widget = screen;
m_widget->onEnter();
m_history.emplace_back(m_widget);
@@ -90,27 +98,25 @@ void popScreen()
m_history.pop_back();
if (m_widget)
{
if (m_persistenceManager != nullptr)
{
m_persistenceManager->Save();
}
persistence_manager_save(&g_persistence_manager);
m_widget->onExit();
}
m_widget = m_history.back();
ESP_DIAG_EVENT(TAG, "Screen popped, now: %s", m_widget->getName());
m_widget->onResume();
}
}
static void init_ui(void)
{
m_persistenceManager = std::make_shared<PersistenceManager>();
persistence_manager_init(&g_persistence_manager, "config");
options = {
.u8g2 = &u8g2,
.setScreen = [](const std::shared_ptr<Widget> &screen) { setScreen(screen); },
.pushScreen = [](const std::shared_ptr<Widget> &screen) { pushScreen(screen); },
.popScreen = []() { popScreen(); },
.onButtonClicked = nullptr,
.persistenceManager = m_persistenceManager,
.persistenceManager = &g_persistence_manager,
};
m_widget = std::make_shared<SplashScreen>(&options);
m_inactivityTracker = std::make_unique<InactivityTracker>(60000, []() {
@@ -123,6 +129,17 @@ static void init_ui(void)
u8g2_SendBuffer(&u8g2);
}
static void on_message_received(const message_t *msg)
{
if (msg && msg->type == MESSAGE_TYPE_SETTINGS &&
(std::strcmp(msg->data.settings.key, "light_active") == 0 ||
std::strcmp(msg->data.settings.key, "light_variant") == 0 ||
std::strcmp(msg->data.settings.key, "light_mode") == 0))
{
start_simulation();
}
}
static void handle_button(uint8_t button)
{
m_inactivityTracker->reset();
@@ -180,14 +197,65 @@ void app_task(void *args)
return;
}
// Initialize display so that info can be shown
setup_screen();
// Check BACK button and delete settings if necessary (with countdown)
gpio_config_t io_conf = {};
io_conf.intr_type = GPIO_INTR_DISABLE;
io_conf.mode = GPIO_MODE_INPUT;
io_conf.pin_bit_mask = (1ULL << BUTTON_BACK);
io_conf.pull_down_en = GPIO_PULLDOWN_DISABLE;
io_conf.pull_up_en = GPIO_PULLUP_ENABLE;
gpio_config(&io_conf);
vTaskDelay(pdMS_TO_TICKS(10));
if (gpio_get_level(BUTTON_BACK) == 0)
{
u8g2_SetFont(&u8g2, u8g2_font_ncenB08_tr);
for (int i = 5; i > 0; --i)
{
u8g2_ClearBuffer(&u8g2);
u8g2_DrawStr(&u8g2, 5, 20, "BACK gedrueckt!");
u8g2_DrawStr(&u8g2, 5, 35, "Halte fuer Reset...");
char buf[32];
snprintf(buf, sizeof(buf), "Loesche in %d s", i);
u8g2_DrawStr(&u8g2, 5, 55, buf);
u8g2_SendBuffer(&u8g2);
vTaskDelay(pdMS_TO_TICKS(1000));
if (gpio_get_level(BUTTON_BACK) != 0)
{
// Button released, abort
break;
}
if (i == 1)
{
// After 5 seconds still pressed: perform factory reset
u8g2_ClearBuffer(&u8g2);
u8g2_DrawStr(&u8g2, 5, 30, "Alle Einstellungen ");
u8g2_DrawStr(&u8g2, 5, 45, "werden geloescht...");
u8g2_SendBuffer(&u8g2);
persistence_manager_factory_reset();
vTaskDelay(pdMS_TO_TICKS(1000));
u8g2_ClearBuffer(&u8g2);
u8g2_DrawStr(&u8g2, 5, 35, "Fertig. Neustart...");
u8g2_SendBuffer(&u8g2);
vTaskDelay(pdMS_TO_TICKS(1000));
esp_restart();
}
}
}
message_manager_init();
setup_buttons();
init_ui();
#if CONFIG_WIFI_ENABLED
wifi_manager_init();
analytics_init();
#endif
mqtt_client_start();
message_manager_register_listener(on_message_received);
start_simulation();

View File

@@ -16,10 +16,12 @@
static const char *TAG = "button_handling";
const uint8_t gpios[] = {BUTTON_DOWN, BUTTON_UP, BUTTON_LEFT, BUTTON_RIGHT, BUTTON_SELECT, BUTTON_BACK};
static const char *button_names[] = {"DOWN", "UP", "LEFT", "RIGHT", "SELECT", "BACK"};
typedef struct
{
uint8_t gpio;
uint8_t index;
} button_user_data_t;
static button_user_data_t button_data[6];
@@ -35,8 +37,9 @@ static void button_event_cb(void *arg, void *usr_data)
}
button_user_data_t *data = (button_user_data_t *)usr_data;
uint8_t gpio_num = data->gpio;
const char *button_name = button_names[data->index];
ESP_LOGI(TAG, "Button pressed on GPIO %d", gpio_num);
ESP_DIAG_EVENT(TAG, "Button %s pressed (GPIO %d)", button_name, gpio_num);
if (xQueueSend(buttonQueue, &gpio_num, 0) != pdTRUE)
{
@@ -60,6 +63,7 @@ static void init_button(uint8_t gpio, int index)
}
button_data[index].gpio = gpio;
button_data[index].index = index;
iot_button_register_cb(gpio_btn, BUTTON_SINGLE_CLICK, NULL, button_event_cb, &button_data[index]);
}

View File

@@ -0,0 +1,292 @@
#include <stdio.h>
#include <string.h>
#include "esp_log.h"
#include "sdkconfig.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "hal/u8g2_esp32_hal.h"
static const char *TAG = "u8g2_hal";
static const unsigned int I2C_TIMEOUT_MS = 1000;
static spi_device_handle_t handle_spi; // SPI handle.
static i2c_cmd_handle_t handle_i2c; // I2C handle.
static u8g2_esp32_hal_t u8g2_esp32_hal; // HAL state data.
static bool i2c_transfer_failed = false; // Flag to track I2C transfer errors
#define HOST SPI2_HOST
#undef ESP_ERROR_CHECK
#define ESP_ERROR_CHECK(x) \
do \
{ \
esp_err_t rc = (x); \
if (rc != ESP_OK) \
{ \
ESP_LOGE("err", "esp_err_t = %d", rc); \
assert(0 && #x); \
} \
} while (0);
// Softer error handling for I2C operations that may fail temporarily
#define I2C_ERROR_CHECK(x) \
do \
{ \
esp_err_t rc = (x); \
if (rc != ESP_OK) \
{ \
ESP_LOGW(TAG, "I2C error: %s = %d", #x, rc); \
i2c_transfer_failed = true; \
} \
} while (0);
/*
* Initialze the ESP32 HAL.
*/
void u8g2_esp32_hal_init(u8g2_esp32_hal_t u8g2_esp32_hal_param)
{
u8g2_esp32_hal = u8g2_esp32_hal_param;
} // u8g2_esp32_hal_init
/*
* HAL callback function as prescribed by the U8G2 library. This callback is
* invoked to handle SPI communications.
*/
uint8_t u8g2_esp32_spi_byte_cb(u8x8_t *u8x8, uint8_t msg, uint8_t arg_int, void *arg_ptr)
{
ESP_LOGD(TAG, "spi_byte_cb: Received a msg: %d, arg_int: %d, arg_ptr: %p", msg, arg_int, arg_ptr);
switch (msg)
{
case U8X8_MSG_BYTE_SET_DC:
if (u8g2_esp32_hal.dc != U8G2_ESP32_HAL_UNDEFINED)
{
gpio_set_level(u8g2_esp32_hal.dc, arg_int);
}
break;
case U8X8_MSG_BYTE_INIT: {
if (u8g2_esp32_hal.bus.spi.clk == U8G2_ESP32_HAL_UNDEFINED ||
u8g2_esp32_hal.bus.spi.mosi == U8G2_ESP32_HAL_UNDEFINED ||
u8g2_esp32_hal.bus.spi.cs == U8G2_ESP32_HAL_UNDEFINED)
{
break;
}
spi_bus_config_t bus_config;
memset(&bus_config, 0, sizeof(spi_bus_config_t));
bus_config.sclk_io_num = u8g2_esp32_hal.bus.spi.clk; // CLK
bus_config.mosi_io_num = u8g2_esp32_hal.bus.spi.mosi; // MOSI
bus_config.miso_io_num = GPIO_NUM_NC; // MISO
bus_config.quadwp_io_num = GPIO_NUM_NC; // Not used
bus_config.quadhd_io_num = GPIO_NUM_NC; // Not used
// ESP_LOGI(TAG, "... Initializing bus.");
ESP_ERROR_CHECK(spi_bus_initialize(HOST, &bus_config, 1));
spi_device_interface_config_t dev_config;
dev_config.address_bits = 0;
dev_config.command_bits = 0;
dev_config.dummy_bits = 0;
dev_config.mode = 0;
dev_config.duty_cycle_pos = 0;
dev_config.cs_ena_posttrans = 0;
dev_config.cs_ena_pretrans = 0;
dev_config.clock_speed_hz = 10000;
dev_config.spics_io_num = u8g2_esp32_hal.bus.spi.cs;
dev_config.flags = 0;
dev_config.queue_size = 200;
dev_config.pre_cb = NULL;
dev_config.post_cb = NULL;
// ESP_LOGI(TAG, "... Adding device bus.");
ESP_ERROR_CHECK(spi_bus_add_device(HOST, &dev_config, &handle_spi));
break;
}
case U8X8_MSG_BYTE_SEND: {
spi_transaction_t trans_desc;
trans_desc.addr = 0;
trans_desc.cmd = 0;
trans_desc.flags = 0;
trans_desc.length = 8 * arg_int; // Number of bits NOT number of bytes.
trans_desc.rxlength = 0;
trans_desc.tx_buffer = arg_ptr;
trans_desc.rx_buffer = NULL;
// ESP_LOGI(TAG, "... Transmitting %d bytes.", arg_int);
ESP_ERROR_CHECK(spi_device_transmit(handle_spi, &trans_desc));
break;
}
}
return 0;
} // u8g2_esp32_spi_byte_cb
/*
* HAL callback function as prescribed by the U8G2 library. This callback is
* invoked to handle I2C communications.
*/
uint8_t u8g2_esp32_i2c_byte_cb(u8x8_t *u8x8, uint8_t msg, uint8_t arg_int, void *arg_ptr)
{
ESP_LOGD(TAG, "i2c_cb: Received a msg: %d, arg_int: %d, arg_ptr: %p", msg, arg_int, arg_ptr);
switch (msg)
{
case U8X8_MSG_BYTE_SET_DC: {
if (u8g2_esp32_hal.dc != U8G2_ESP32_HAL_UNDEFINED)
{
gpio_set_level(u8g2_esp32_hal.dc, arg_int);
}
break;
}
case U8X8_MSG_BYTE_INIT: {
if (u8g2_esp32_hal.bus.i2c.sda == U8G2_ESP32_HAL_UNDEFINED ||
u8g2_esp32_hal.bus.i2c.scl == U8G2_ESP32_HAL_UNDEFINED)
{
break;
}
i2c_config_t conf = {0};
conf.mode = I2C_MODE_MASTER;
ESP_LOGI(TAG, "sda_io_num %d", u8g2_esp32_hal.bus.i2c.sda);
conf.sda_io_num = u8g2_esp32_hal.bus.i2c.sda;
conf.sda_pullup_en = GPIO_PULLUP_ENABLE;
ESP_LOGI(TAG, "scl_io_num %d", u8g2_esp32_hal.bus.i2c.scl);
conf.scl_io_num = u8g2_esp32_hal.bus.i2c.scl;
conf.scl_pullup_en = GPIO_PULLUP_ENABLE;
ESP_LOGI(TAG, "clk_speed %d", I2C_MASTER_FREQ_HZ);
conf.master.clk_speed = I2C_MASTER_FREQ_HZ;
ESP_LOGI(TAG, "i2c_param_config %d", conf.mode);
ESP_ERROR_CHECK(i2c_param_config(I2C_MASTER_NUM, &conf));
ESP_LOGI(TAG, "i2c_driver_install %d", I2C_MASTER_NUM);
ESP_ERROR_CHECK(
i2c_driver_install(I2C_MASTER_NUM, conf.mode, I2C_MASTER_RX_BUF_DISABLE, I2C_MASTER_TX_BUF_DISABLE, 0));
break;
}
case U8X8_MSG_BYTE_SEND: {
if (i2c_transfer_failed)
{
break; // Skip sending if transfer already failed
}
uint8_t *data_ptr = (uint8_t *)arg_ptr;
ESP_LOG_BUFFER_HEXDUMP(TAG, data_ptr, arg_int, ESP_LOG_VERBOSE);
while (arg_int > 0)
{
I2C_ERROR_CHECK(i2c_master_write_byte(handle_i2c, *data_ptr, ACK_CHECK_EN));
if (i2c_transfer_failed)
{
break;
}
data_ptr++;
arg_int--;
}
break;
}
case U8X8_MSG_BYTE_START_TRANSFER: {
uint8_t i2c_address = u8x8_GetI2CAddress(u8x8);
handle_i2c = i2c_cmd_link_create();
i2c_transfer_failed = false; // Reset error flag at start of transfer
ESP_LOGD(TAG, "Start I2C transfer to %02X.", i2c_address >> 1);
I2C_ERROR_CHECK(i2c_master_start(handle_i2c));
I2C_ERROR_CHECK(i2c_master_write_byte(handle_i2c, i2c_address | I2C_MASTER_WRITE, ACK_CHECK_EN));
break;
}
case U8X8_MSG_BYTE_END_TRANSFER: {
ESP_LOGD(TAG, "End I2C transfer.");
if (!i2c_transfer_failed)
{
I2C_ERROR_CHECK(i2c_master_stop(handle_i2c));
I2C_ERROR_CHECK(i2c_master_cmd_begin(I2C_MASTER_NUM, handle_i2c, pdMS_TO_TICKS(I2C_TIMEOUT_MS)));
}
i2c_cmd_link_delete(handle_i2c);
break;
}
}
return 0;
} // u8g2_esp32_i2c_byte_cb
/*
* HAL callback function as prescribed by the U8G2 library. This callback is
* invoked to handle callbacks for GPIO and delay functions.
*/
uint8_t u8g2_esp32_gpio_and_delay_cb(u8x8_t *u8x8, uint8_t msg, uint8_t arg_int, void *arg_ptr)
{
ESP_LOGD(TAG, "gpio_and_delay_cb: Received a msg: %d, arg_int: %d, arg_ptr: %p", msg, arg_int, arg_ptr);
switch (msg)
{
// Initialize the GPIO and DELAY HAL functions. If the pins for DC and
// RESET have been specified then we define those pins as GPIO outputs.
case U8X8_MSG_GPIO_AND_DELAY_INIT: {
uint64_t bitmask = 0;
if (u8g2_esp32_hal.dc != U8G2_ESP32_HAL_UNDEFINED)
{
bitmask = bitmask | (1ull << u8g2_esp32_hal.dc);
}
if (u8g2_esp32_hal.reset != U8G2_ESP32_HAL_UNDEFINED)
{
bitmask = bitmask | (1ull << u8g2_esp32_hal.reset);
}
if (u8g2_esp32_hal.bus.spi.cs != U8G2_ESP32_HAL_UNDEFINED)
{
bitmask = bitmask | (1ull << u8g2_esp32_hal.bus.spi.cs);
}
if (bitmask == 0)
{
break;
}
gpio_config_t gpioConfig;
gpioConfig.pin_bit_mask = bitmask;
gpioConfig.mode = GPIO_MODE_OUTPUT;
gpioConfig.pull_up_en = GPIO_PULLUP_DISABLE;
gpioConfig.pull_down_en = GPIO_PULLDOWN_ENABLE;
gpioConfig.intr_type = GPIO_INTR_DISABLE;
gpio_config(&gpioConfig);
break;
}
// Set the GPIO reset pin to the value passed in through arg_int.
case U8X8_MSG_GPIO_RESET:
if (u8g2_esp32_hal.reset != U8G2_ESP32_HAL_UNDEFINED)
{
gpio_set_level(u8g2_esp32_hal.reset, arg_int);
}
break;
// Set the GPIO client select pin to the value passed in through arg_int.
case U8X8_MSG_GPIO_CS:
if (u8g2_esp32_hal.bus.spi.cs != U8G2_ESP32_HAL_UNDEFINED)
{
gpio_set_level(u8g2_esp32_hal.bus.spi.cs, arg_int);
}
break;
// Set the Software I²C pin to the value passed in through arg_int.
case U8X8_MSG_GPIO_I2C_CLOCK:
if (u8g2_esp32_hal.bus.i2c.scl != U8G2_ESP32_HAL_UNDEFINED)
{
gpio_set_level(u8g2_esp32_hal.bus.i2c.scl, arg_int);
// printf("%c",(arg_int==1?'C':'c'));
}
break;
// Set the Software I²C pin to the value passed in through arg_int.
case U8X8_MSG_GPIO_I2C_DATA:
if (u8g2_esp32_hal.bus.i2c.sda != U8G2_ESP32_HAL_UNDEFINED)
{
gpio_set_level(u8g2_esp32_hal.bus.i2c.sda, arg_int);
// printf("%c",(arg_int==1?'D':'d'));
}
break;
// Delay for the number of milliseconds passed in through arg_int.
case U8X8_MSG_DELAY_MILLI:
vTaskDelay(arg_int / portTICK_PERIOD_MS);
break;
}
return 0;
} // u8g2_esp32_gpio_and_delay_cb

View File

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

View File

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

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

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