Compare commits
36 Commits
dfad7cfb76
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
684ce36270
|
|||
|
98b5df1ff2
|
|||
|
8128b958cb
|
|||
|
955b4bef04
|
|||
|
81141d8859
|
|||
|
e01006cd49
|
|||
|
c28d7d08df
|
|||
|
df50aaedda
|
|||
|
1f02d35a97
|
|||
|
501c2de874
|
|||
|
b39a3be956
|
|||
|
3ec7bf7acb
|
|||
|
a12dfe7760
|
|||
|
dc40acfd06
|
|||
|
3d7de05614
|
|||
|
3f32b791b7
|
|||
|
ccdc2bb63f
|
|||
|
7d12d98ec9
|
|||
|
cdac9cbfb8
|
|||
|
1fade06bdb
|
|||
|
f7cedf24e8
|
|||
|
1c52f7d679
|
|||
|
7a73fc4b7b
|
|||
|
1fbc28a628
|
|||
|
bccfb80791
|
|||
|
b77fdee21d
|
|||
|
ef0cda1d67
|
|||
|
cfca3f1535
|
|||
|
d18c9bfea1
|
|||
|
28e991cf58
|
|||
|
ebf0dc6556
|
|||
|
29785a96bc
|
|||
|
ee587f1381
|
|||
|
a66c48e713
|
|||
|
b0e93d613c
|
|||
|
52f6c2acab
|
2
.gitignore
vendored
2
.gitignore
vendored
@@ -24,3 +24,5 @@
|
|||||||
**/*_front.png
|
**/*_front.png
|
||||||
**/*_schematic*.png
|
**/*_schematic*.png
|
||||||
**/wiki/*
|
**/wiki/*
|
||||||
|
*.FCBak
|
||||||
|
firmware/**/node_modules
|
||||||
|
|||||||
2
firmware/.clangd
Normal file
2
firmware/.clangd
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
CompileFlags:
|
||||||
|
Remove: [-f*, -m*]
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
ARG DOCKER_TAG=latest
|
ARG DOCKER_TAG=release-v5.4
|
||||||
FROM espressif/idf:${DOCKER_TAG}
|
FROM espressif/idf:${DOCKER_TAG}
|
||||||
|
|
||||||
ENV LC_ALL=C.UTF-8
|
ENV LC_ALL=C.UTF-8
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
cmake_minimum_required(VERSION 3.5)
|
cmake_minimum_required(VERSION 3.5)
|
||||||
|
|
||||||
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
||||||
|
|
||||||
|
idf_build_set_property(BOOTLOADER_EXTRA_COMPONENT_DIRS "${CMAKE_CURRENT_LIST_DIR}/bootloader_components_extra/" APPEND)
|
||||||
|
|
||||||
project(system_control)
|
project(system_control)
|
||||||
|
|
||||||
|
target_add_binary_data(${PROJECT_NAME}.elf "main/isrgrootx1.pem" TEXT)
|
||||||
859
firmware/README-API.md
Normal file
859
firmware/README-API.md
Normal 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
209
firmware/README-captive.md
Normal 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.
|
||||||
@@ -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.
|
||||||
21
firmware/bootloader_components/my_boot_hooks/hooks.c
Normal file
21
firmware/bootloader_components/my_boot_hooks/hooks.c
Normal 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");
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
idf_component_register(SRCS "extra_component.c")
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2024 Espressif Systems (Shanghai) CO LTD
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: Unlicense OR CC0-1.0
|
||||||
|
*/
|
||||||
|
#include "esp_log.h"
|
||||||
|
|
||||||
|
void bootloader_extra_dir_function(void)
|
||||||
|
{
|
||||||
|
ESP_LOGI("EXTRA", "This function is called from an extra component");
|
||||||
|
}
|
||||||
19
firmware/components/api-server/CMakeLists.txt
Normal file
19
firmware/components/api-server/CMakeLists.txt
Normal 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
|
||||||
|
)
|
||||||
38
firmware/components/api-server/Kconfig
Normal file
38
firmware/components/api-server/Kconfig
Normal 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
|
||||||
|
|
||||||
5
firmware/components/api-server/idf_component.yml
Normal file
5
firmware/components/api-server/idf_component.yml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
dependencies:
|
||||||
|
idf:
|
||||||
|
version: '>=5.0.0'
|
||||||
|
espressif/mdns:
|
||||||
|
version: '*'
|
||||||
63
firmware/components/api-server/include/api_handlers.h
Normal file
63
firmware/components/api-server/include/api_handlers.h
Normal 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
|
||||||
119
firmware/components/api-server/include/api_server.h
Normal file
119
firmware/components/api-server/include/api_server.h
Normal 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
|
||||||
9
firmware/components/api-server/include/common.h
Normal file
9
firmware/components/api-server/include/common.h
Normal 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
|
||||||
60
firmware/components/api-server/include/websocket_handler.h
Normal file
60
firmware/components/api-server/include/websocket_handler.h
Normal 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
|
||||||
1165
firmware/components/api-server/src/api_handlers.c
Normal file
1165
firmware/components/api-server/src/api_handlers.c
Normal file
File diff suppressed because it is too large
Load Diff
200
firmware/components/api-server/src/api_server.c
Normal file
200
firmware/components/api-server/src/api_server.c
Normal 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);
|
||||||
|
}
|
||||||
79
firmware/components/api-server/src/common.c
Normal file
79
firmware/components/api-server/src/common.c
Normal 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;
|
||||||
|
}
|
||||||
276
firmware/components/api-server/src/websocket_handler.c
Normal file
276
firmware/components/api-server/src/websocket_handler.c
Normal 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;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ idf_component_register(SRCS
|
|||||||
src/ble/ble_connection.c
|
src/ble/ble_connection.c
|
||||||
src/ble/ble_scanner.c
|
src/ble/ble_scanner.c
|
||||||
src/ble_manager.c
|
src/ble_manager.c
|
||||||
|
src/dns_hijack.c
|
||||||
src/wifi_manager.c
|
src/wifi_manager.c
|
||||||
INCLUDE_DIRS "include"
|
INCLUDE_DIRS "include"
|
||||||
REQUIRES
|
REQUIRES
|
||||||
@@ -9,5 +10,7 @@ idf_component_register(SRCS
|
|||||||
driver
|
driver
|
||||||
nvs_flash
|
nvs_flash
|
||||||
esp_insights
|
esp_insights
|
||||||
|
analytics
|
||||||
led-manager
|
led-manager
|
||||||
|
api-server
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
79
firmware/components/connectivity-manager/src/dns_hijack.c
Normal file
79
firmware/components/connectivity-manager/src/dns_hijack.c
Normal 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);
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
#include "wifi_manager.h"
|
#include "wifi_manager.h"
|
||||||
|
#include "dns_hijack.h"
|
||||||
|
|
||||||
|
#include "analytics.h"
|
||||||
|
#include "api_server.h"
|
||||||
#include <esp_event.h>
|
#include <esp_event.h>
|
||||||
#include <esp_insights.h>
|
#include <esp_insights.h>
|
||||||
#include <esp_log.h>
|
#include <esp_log.h>
|
||||||
@@ -11,219 +14,166 @@
|
|||||||
#include <led_status.h>
|
#include <led_status.h>
|
||||||
#include <lwip/err.h>
|
#include <lwip/err.h>
|
||||||
#include <lwip/sys.h>
|
#include <lwip/sys.h>
|
||||||
|
#include <mdns.h>
|
||||||
#include <nvs_flash.h>
|
#include <nvs_flash.h>
|
||||||
|
#include <persistence_manager.h>
|
||||||
#include <sdkconfig.h>
|
#include <sdkconfig.h>
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
|
|
||||||
// Event group to signal when we are connected
|
// Event group to signal WiFi connection status
|
||||||
static EventGroupHandle_t s_wifi_event_group;
|
static EventGroupHandle_t s_wifi_event_group;
|
||||||
|
|
||||||
// The bits for the event group
|
// Event group bits
|
||||||
#define WIFI_CONNECTED_BIT BIT0
|
#define WIFI_CONNECTED_BIT BIT0
|
||||||
#define WIFI_FAIL_BIT BIT1
|
#define WIFI_FAIL_BIT BIT1
|
||||||
|
|
||||||
static const char *TAG = "wifi_manager";
|
static const char *TAG = "wifi_manager";
|
||||||
|
|
||||||
static int s_retry_num = 0;
|
static void led_status_reconnect()
|
||||||
static int s_current_network_index = 0;
|
|
||||||
|
|
||||||
// WiFi network configuration structure
|
|
||||||
typedef struct
|
|
||||||
{
|
{
|
||||||
const char *ssid;
|
led_behavior_t led_behavior = {
|
||||||
const char *password;
|
.on_time_ms = 250,
|
||||||
} wifi_network_config_t;
|
.off_time_ms = 100,
|
||||||
|
.color = {.red = 50, .green = 50, .blue = 0},
|
||||||
// Array of configured WiFi networks
|
.index = 0,
|
||||||
static const wifi_network_config_t s_wifi_networks[] = {
|
.mode = LED_MODE_BLINK,
|
||||||
#if CONFIG_WIFI_ENABLED
|
|
||||||
{CONFIG_WIFI_SSID_1, CONFIG_WIFI_PASSWORD_1},
|
|
||||||
#if CONFIG_WIFI_NETWORK_COUNT >= 2
|
|
||||||
{CONFIG_WIFI_SSID_2, CONFIG_WIFI_PASSWORD_2},
|
|
||||||
#endif
|
|
||||||
#if CONFIG_WIFI_NETWORK_COUNT >= 3
|
|
||||||
{CONFIG_WIFI_SSID_3, CONFIG_WIFI_PASSWORD_3},
|
|
||||||
#endif
|
|
||||||
#if CONFIG_WIFI_NETWORK_COUNT >= 4
|
|
||||||
{CONFIG_WIFI_SSID_4, CONFIG_WIFI_PASSWORD_4},
|
|
||||||
#endif
|
|
||||||
#if CONFIG_WIFI_NETWORK_COUNT >= 5
|
|
||||||
{CONFIG_WIFI_SSID_5, CONFIG_WIFI_PASSWORD_5},
|
|
||||||
#endif
|
|
||||||
#endif
|
|
||||||
};
|
|
||||||
|
|
||||||
static const int s_wifi_network_count = sizeof(s_wifi_networks) / sizeof(s_wifi_networks[0]);
|
|
||||||
|
|
||||||
static void try_next_network(void);
|
|
||||||
|
|
||||||
static void connect_to_network(int index)
|
|
||||||
{
|
|
||||||
#if CONFIG_WIFI_ENABLED
|
|
||||||
if (index >= s_wifi_network_count)
|
|
||||||
{
|
|
||||||
ESP_LOGE(TAG, "No more networks to try");
|
|
||||||
xEventGroupSetBits(s_wifi_event_group, WIFI_FAIL_BIT);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const wifi_network_config_t *network = &s_wifi_networks[index];
|
|
||||||
|
|
||||||
// Skip empty SSIDs
|
|
||||||
if (network->ssid == NULL || strlen(network->ssid) == 0)
|
|
||||||
{
|
|
||||||
ESP_LOGW(TAG, "Skipping empty SSID at index %d", index);
|
|
||||||
s_current_network_index++;
|
|
||||||
s_retry_num = 0;
|
|
||||||
try_next_network();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ESP_DIAG_EVENT(TAG, "Trying to connect to network %d: %s", index + 1, network->ssid);
|
|
||||||
|
|
||||||
wifi_config_t wifi_config = {
|
|
||||||
.sta =
|
|
||||||
{
|
|
||||||
.threshold.authmode = WIFI_AUTH_WPA2_PSK,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
led_status_set_behavior(led_behavior);
|
||||||
strncpy((char *)wifi_config.sta.ssid, network->ssid, sizeof(wifi_config.sta.ssid) - 1);
|
|
||||||
strncpy((char *)wifi_config.sta.password, network->password, sizeof(wifi_config.sta.password) - 1);
|
|
||||||
|
|
||||||
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config));
|
|
||||||
esp_wifi_connect();
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static void try_next_network(void)
|
static void wifi_event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data)
|
||||||
{
|
{
|
||||||
#if CONFIG_WIFI_ENABLED
|
|
||||||
s_current_network_index++;
|
|
||||||
s_retry_num = 0;
|
|
||||||
|
|
||||||
if (s_current_network_index < s_wifi_network_count)
|
|
||||||
{
|
|
||||||
connect_to_network(s_current_network_index);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
ESP_LOGE(TAG, "Failed to connect to any configured network");
|
|
||||||
led_behavior_t led0_behavior = {
|
|
||||||
.index = 0,
|
|
||||||
.mode = LED_MODE_BLINK,
|
|
||||||
.color = {.red = 50, .green = 0, .blue = 0},
|
|
||||||
.on_time_ms = 1000,
|
|
||||||
.off_time_ms = 500,
|
|
||||||
};
|
|
||||||
led_status_set_behavior(led0_behavior);
|
|
||||||
xEventGroupSetBits(s_wifi_event_group, WIFI_FAIL_BIT);
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
static void event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data)
|
|
||||||
{
|
|
||||||
#if CONFIG_WIFI_ENABLED
|
|
||||||
if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START)
|
if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START)
|
||||||
{
|
{
|
||||||
led_behavior_t led0_behavior = {
|
ESP_LOGI(TAG, "WIFI_EVENT_STA_START: Connecting to AP...");
|
||||||
.index = 0,
|
esp_wifi_connect();
|
||||||
.mode = LED_MODE_BLINK,
|
|
||||||
.color = {.red = 50, .green = 50, .blue = 0},
|
|
||||||
.on_time_ms = 200,
|
|
||||||
.off_time_ms = 200,
|
|
||||||
};
|
|
||||||
led_status_set_behavior(led0_behavior);
|
|
||||||
|
|
||||||
connect_to_network(s_current_network_index);
|
|
||||||
}
|
}
|
||||||
else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED)
|
else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED)
|
||||||
{
|
{
|
||||||
if (s_retry_num < CONFIG_WIFI_CONNECT_RETRIES)
|
ESP_LOGW(TAG, "WIFI_EVENT_STA_DISCONNECTED: Verbindung verloren, versuche erneut...");
|
||||||
{
|
xEventGroupSetBits(s_wifi_event_group, WIFI_FAIL_BIT);
|
||||||
led_behavior_t led0_behavior = {
|
led_status_reconnect();
|
||||||
.index = 0,
|
esp_wifi_connect();
|
||||||
.mode = LED_MODE_BLINK,
|
|
||||||
.color = {.red = 50, .green = 50, .blue = 0},
|
|
||||||
.on_time_ms = 200,
|
|
||||||
.off_time_ms = 200,
|
|
||||||
};
|
|
||||||
led_status_set_behavior(led0_behavior);
|
|
||||||
|
|
||||||
s_retry_num++;
|
|
||||||
ESP_DIAG_EVENT(TAG, "Retrying network %d (%d/%d)", s_current_network_index + 1, s_retry_num,
|
|
||||||
CONFIG_WIFI_CONNECT_RETRIES);
|
|
||||||
esp_wifi_connect();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Retries exhausted for current network, try next one
|
|
||||||
ESP_LOGW(TAG, "Failed to connect to network %d after %d retries, trying next...", s_current_network_index + 1,
|
|
||||||
CONFIG_WIFI_CONNECT_RETRIES);
|
|
||||||
try_next_network();
|
|
||||||
}
|
}
|
||||||
else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP)
|
else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP)
|
||||||
{
|
{
|
||||||
led_behavior_t led0_behavior = {
|
|
||||||
.index = 0,
|
|
||||||
.mode = LED_MODE_SOLID,
|
|
||||||
.color = {.red = 0, .green = 50, .blue = 0},
|
|
||||||
};
|
|
||||||
led_status_set_behavior(led0_behavior);
|
|
||||||
|
|
||||||
ip_event_got_ip_t *event = (ip_event_got_ip_t *)event_data;
|
ip_event_got_ip_t *event = (ip_event_got_ip_t *)event_data;
|
||||||
ESP_DIAG_EVENT(TAG, "Got IP address:" IPSTR " (network %d: %s)", IP2STR(&event->ip_info.ip),
|
ESP_LOGI(TAG, "IP_EVENT_STA_GOT_IP: IP-Adresse erhalten: " IPSTR, IP2STR(&event->ip_info.ip));
|
||||||
s_current_network_index + 1, s_wifi_networks[s_current_network_index].ssid);
|
analytics_init();
|
||||||
s_retry_num = 0;
|
|
||||||
xEventGroupSetBits(s_wifi_event_group, WIFI_CONNECTED_BIT);
|
xEventGroupSetBits(s_wifi_event_group, WIFI_CONNECTED_BIT);
|
||||||
}
|
}
|
||||||
#endif
|
else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_LOST_IP)
|
||||||
|
{
|
||||||
|
ESP_LOGW(TAG, "IP_EVENT_STA_LOST_IP: IP-Adresse verloren! Versuche Reconnect...");
|
||||||
|
xEventGroupSetBits(s_wifi_event_group, WIFI_FAIL_BIT);
|
||||||
|
led_status_reconnect();
|
||||||
|
esp_wifi_connect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void wifi_create_ap()
|
||||||
|
{
|
||||||
|
ESP_ERROR_CHECK(esp_wifi_stop());
|
||||||
|
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_AP));
|
||||||
|
wifi_config_t ap_config = {.ap = {.ssid = "system-control",
|
||||||
|
.ssid_len = strlen("system-control"),
|
||||||
|
.password = "",
|
||||||
|
.max_connection = 4,
|
||||||
|
.authmode = WIFI_AUTH_OPEN}};
|
||||||
|
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_AP, &ap_config));
|
||||||
|
ESP_ERROR_CHECK(esp_wifi_start());
|
||||||
|
ESP_LOGI(TAG, "Access Point 'system-control' started");
|
||||||
|
dns_server_start("192.168.4.1");
|
||||||
|
|
||||||
|
led_behavior_t led_behavior = {
|
||||||
|
.color = {.red = 50, .green = 0, .blue = 0},
|
||||||
|
.index = 0,
|
||||||
|
.mode = LED_MODE_SOLID,
|
||||||
|
};
|
||||||
|
led_status_set_behavior(led_behavior);
|
||||||
}
|
}
|
||||||
|
|
||||||
void wifi_manager_init()
|
void wifi_manager_init()
|
||||||
{
|
{
|
||||||
#if CONFIG_WIFI_ENABLED
|
|
||||||
s_wifi_event_group = xEventGroupCreate();
|
s_wifi_event_group = xEventGroupCreate();
|
||||||
s_current_network_index = 0;
|
|
||||||
s_retry_num = 0;
|
|
||||||
|
|
||||||
ESP_ERROR_CHECK(esp_netif_init());
|
ESP_ERROR_CHECK(esp_netif_init());
|
||||||
|
|
||||||
ESP_ERROR_CHECK(esp_event_loop_create_default());
|
ESP_ERROR_CHECK(esp_event_loop_create_default());
|
||||||
esp_netif_create_default_wifi_sta();
|
|
||||||
|
|
||||||
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
|
// Default WiFi Station
|
||||||
ESP_ERROR_CHECK(esp_wifi_init(&cfg));
|
esp_netif_t *sta_netif = esp_netif_create_default_wifi_sta();
|
||||||
|
|
||||||
esp_event_handler_instance_t instance_any_id;
|
// Event Handler registrieren
|
||||||
esp_event_handler_instance_t instance_got_ip;
|
ESP_ERROR_CHECK(esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &wifi_event_handler, NULL, NULL));
|
||||||
ESP_ERROR_CHECK(
|
ESP_ERROR_CHECK(esp_event_handler_instance_register(IP_EVENT, ESP_EVENT_ANY_ID, &wifi_event_handler, NULL, NULL));
|
||||||
esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &event_handler, NULL, &instance_any_id));
|
|
||||||
ESP_ERROR_CHECK(
|
|
||||||
esp_event_handler_instance_register(IP_EVENT, IP_EVENT_STA_GOT_IP, &event_handler, NULL, &instance_got_ip));
|
|
||||||
|
|
||||||
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
|
// Try to load stored WiFi configuration
|
||||||
ESP_ERROR_CHECK(esp_wifi_start());
|
persistence_manager_t pm;
|
||||||
|
char ssid[33] = {0};
|
||||||
ESP_DIAG_EVENT(TAG, "WiFi manager initialized with %d network(s), waiting for connection...", s_wifi_network_count);
|
char password[65] = {0};
|
||||||
|
bool have_ssid = false, have_password = false;
|
||||||
/* Waiting until either the connection is established (WIFI_CONNECTED_BIT) or
|
if (persistence_manager_init(&pm, "wifi_config") == ESP_OK)
|
||||||
connection failed for all networks (WIFI_FAIL_BIT). The bits are set by event_handler() */
|
|
||||||
EventBits_t bits =
|
|
||||||
xEventGroupWaitBits(s_wifi_event_group, WIFI_CONNECTED_BIT | WIFI_FAIL_BIT, pdFALSE, pdFALSE, portMAX_DELAY);
|
|
||||||
|
|
||||||
if (bits & WIFI_CONNECTED_BIT)
|
|
||||||
{
|
{
|
||||||
ESP_DIAG_EVENT(TAG, "Connected to AP SSID:%s", s_wifi_networks[s_current_network_index].ssid);
|
persistence_manager_get_string(&pm, "ssid", ssid, sizeof(ssid), "");
|
||||||
|
persistence_manager_get_string(&pm, "password", password, sizeof(password), "");
|
||||||
|
have_ssid = strlen(ssid) > 0;
|
||||||
|
have_password = strlen(password) > 0;
|
||||||
}
|
}
|
||||||
else if (bits & WIFI_FAIL_BIT)
|
|
||||||
|
if (have_ssid && have_password)
|
||||||
{
|
{
|
||||||
ESP_LOGE(TAG, "Failed to connect to any configured WiFi network");
|
led_status_reconnect();
|
||||||
|
|
||||||
|
ESP_LOGI(TAG, "Found WiFi configuration: SSID='%s'", ssid);
|
||||||
|
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
|
||||||
|
ESP_ERROR_CHECK(esp_wifi_init(&cfg));
|
||||||
|
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
|
||||||
|
wifi_config_t wifi_config = {0};
|
||||||
|
strncpy((char *)wifi_config.sta.ssid, ssid, sizeof(wifi_config.sta.ssid) - 1);
|
||||||
|
strncpy((char *)wifi_config.sta.password, password, sizeof(wifi_config.sta.password) - 1);
|
||||||
|
wifi_config.sta.threshold.authmode = WIFI_AUTH_WPA2_PSK;
|
||||||
|
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config));
|
||||||
|
ESP_ERROR_CHECK(esp_wifi_start());
|
||||||
|
|
||||||
|
int retries = 0;
|
||||||
|
EventBits_t bits;
|
||||||
|
do
|
||||||
|
{
|
||||||
|
ESP_LOGI(TAG, "Warte auf IP-Adresse (DHCP)...");
|
||||||
|
bits = xEventGroupWaitBits(s_wifi_event_group, WIFI_CONNECTED_BIT | WIFI_FAIL_BIT, pdFALSE, pdFALSE,
|
||||||
|
10000 / portTICK_PERIOD_MS);
|
||||||
|
if (bits & WIFI_CONNECTED_BIT)
|
||||||
|
{
|
||||||
|
led_behavior_t led_behavior = {
|
||||||
|
.index = 0,
|
||||||
|
.color = {.red = 0, .green = 50, .blue = 0},
|
||||||
|
.mode = LED_MODE_SOLID,
|
||||||
|
};
|
||||||
|
led_status_set_behavior(led_behavior);
|
||||||
|
ESP_LOGI(TAG, "WiFi connection established successfully (mit IP)");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
retries++;
|
||||||
|
} while (!(bits & WIFI_CONNECTED_BIT) && retries < CONFIG_WIFI_CONNECT_RETRIES);
|
||||||
|
|
||||||
|
if (!(bits & WIFI_CONNECTED_BIT))
|
||||||
|
{
|
||||||
|
ESP_LOGW(TAG, "WiFi connection failed (keine IP?), switching to Access Point mode");
|
||||||
|
// AP-Netzwerkschnittstelle initialisieren, falls noch nicht geschehen
|
||||||
|
esp_netif_create_default_wifi_ap();
|
||||||
|
wifi_create_ap();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,4 +18,5 @@ idf_component_register(SRCS
|
|||||||
led-manager
|
led-manager
|
||||||
persistence-manager
|
persistence-manager
|
||||||
simulator
|
simulator
|
||||||
|
message-manager
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
// Project-specific headers
|
// Project-specific headers
|
||||||
#include "common/Widget.h"
|
#include "common/Widget.h"
|
||||||
#include "IPersistenceManager.h"
|
#include "persistence_manager.h"
|
||||||
#include "u8g2.h"
|
#include "u8g2.h"
|
||||||
|
|
||||||
class MenuItem;
|
class MenuItem;
|
||||||
@@ -61,14 +61,8 @@ typedef struct
|
|||||||
std::function<void(ButtonType button)> onButtonClicked;
|
std::function<void(ButtonType button)> onButtonClicked;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Shared pointer to platform-independent persistence manager
|
* @brief Zeiger auf C-Persistence-Manager-Instanz
|
||||||
* @details This provides access to persistent key-value storage across different
|
|
||||||
* platforms. The actual implementation (SDL3 or ESP32/NVS) is determined
|
|
||||||
* at compile time based on the target platform.
|
|
||||||
*
|
|
||||||
* @note The persistence manager is shared across all menu widgets and maintains
|
|
||||||
* its state throughout the application lifecycle.
|
|
||||||
*/
|
*/
|
||||||
std::shared_ptr<IPersistenceManager> persistenceManager;
|
persistence_manager_t *persistenceManager;
|
||||||
|
|
||||||
} menu_options_t;
|
} menu_options_t;
|
||||||
@@ -175,6 +175,21 @@ class Menu : public Widget
|
|||||||
*/
|
*/
|
||||||
void toggle(const MenuItem &menuItem);
|
void toggle(const MenuItem &menuItem);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Setzt den Zustand eines Toggle-Menüeintrags explizit
|
||||||
|
* @param menuItem Der zu ändernde Toggle-Menüeintrag
|
||||||
|
* @param state Neuer Zustand (true = aktiviert, false = deaktiviert)
|
||||||
|
*
|
||||||
|
* @pre menuItem muss vom Typ TOGGLE sein
|
||||||
|
* @post Der Wert des Menüeintrags wird auf den angegebenen Zustand gesetzt
|
||||||
|
*
|
||||||
|
* @details Diese Methode setzt den Wert eines Toggle-Menüeintrags gezielt auf den gewünschten Zustand.
|
||||||
|
* Der geänderte Eintrag ersetzt das Original in der internen Menüstruktur.
|
||||||
|
*
|
||||||
|
* @note Diese Methode verändert direkt den internen Zustand des Menüs.
|
||||||
|
*/
|
||||||
|
void setToggle(const MenuItem &menuItem, const bool state);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Changes the selected value of a selection menu item based on button input
|
* @brief Changes the selected value of a selection menu item based on button input
|
||||||
* @param menuItem The selection menu item to modify
|
* @param menuItem The selection menu item to modify
|
||||||
@@ -191,6 +206,8 @@ class Menu : public Widget
|
|||||||
*/
|
*/
|
||||||
MenuItem switchValue(const MenuItem &menuItem, ButtonType button);
|
MenuItem switchValue(const MenuItem &menuItem, ButtonType button);
|
||||||
|
|
||||||
|
void setSelectionIndex(const MenuItem &menuItem, int index);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
MenuItem replaceItem(int index, const MenuItem &item);
|
MenuItem replaceItem(int index, const MenuItem &item);
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
#include "u8g2.h"
|
#include "u8g2.h"
|
||||||
|
|
||||||
#include "common/Common.h"
|
#include "common/Common.h"
|
||||||
|
#include "message_manager.h"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @class Widget
|
* @class Widget
|
||||||
@@ -49,7 +50,9 @@ class Widget
|
|||||||
* @details Ensures that derived class destructors are called correctly when
|
* @details Ensures that derived class destructors are called correctly when
|
||||||
* a widget is destroyed through a base class pointer.
|
* a widget is destroyed through a base class pointer.
|
||||||
*/
|
*/
|
||||||
virtual ~Widget() = default;
|
virtual ~Widget();
|
||||||
|
|
||||||
|
virtual void onMessageReceived(const message_t *msg);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Called when the widget becomes active or enters the foreground
|
* @brief Called when the widget becomes active or enters the foreground
|
||||||
@@ -178,4 +181,8 @@ class Widget
|
|||||||
* the u8g2 context and assumes it is managed externally.
|
* the u8g2 context and assumes it is managed externally.
|
||||||
*/
|
*/
|
||||||
u8g2_t *u8g2;
|
u8g2_t *u8g2;
|
||||||
|
|
||||||
|
private:
|
||||||
|
static std::vector<Widget *> s_instances;
|
||||||
|
static void globalMessageCallback(const message_t *msg);
|
||||||
};
|
};
|
||||||
@@ -120,6 +120,8 @@ class LightMenu final : public Menu
|
|||||||
*/
|
*/
|
||||||
void onButtonPressed(const MenuItem &menuItem, ButtonType button) override;
|
void onButtonPressed(const MenuItem &menuItem, ButtonType button) override;
|
||||||
|
|
||||||
|
void onMessageReceived(const message_t *msg);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Pointer to menu options configuration structure
|
* @brief Pointer to menu options configuration structure
|
||||||
* @details Stores a reference to the menu configuration passed during construction.
|
* @details Stores a reference to the menu configuration passed during construction.
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ constexpr int BOTTOM_OFFSET = 10;
|
|||||||
|
|
||||||
Menu::Menu(menu_options_t *options) : Widget(options->u8g2), m_options(options)
|
Menu::Menu(menu_options_t *options) : Widget(options->u8g2), m_options(options)
|
||||||
{
|
{
|
||||||
// Set up button callback using lambda to forward to member function
|
// Set up button callback using a lambda to forward to the member function
|
||||||
m_options->onButtonClicked = [this](const ButtonType button) { OnButtonClicked(button); };
|
m_options->onButtonClicked = [this](const ButtonType button) { OnButtonClicked(button); };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ void Menu::setItemSize(const size_t size, int8_t startIndex)
|
|||||||
constexpr int key_length = 20;
|
constexpr int key_length = 20;
|
||||||
char key[key_length] = "";
|
char key[key_length] = "";
|
||||||
snprintf(key, key_length, "section_%zu", i + 1 - startIndex);
|
snprintf(key, key_length, "section_%zu", i + 1 - startIndex);
|
||||||
index = m_options->persistenceManager->GetValue(key, index);
|
index = persistence_manager_get_int(m_options->persistenceManager, key, index);
|
||||||
}
|
}
|
||||||
addSelection(i + 1, caption, m_items.at(0).getValues(), index);
|
addSelection(i + 1, caption, m_items.at(0).getValues(), index);
|
||||||
}
|
}
|
||||||
@@ -82,6 +82,12 @@ void Menu::toggle(const MenuItem &menuItem)
|
|||||||
replaceItem(menuItem.getId(), item);
|
replaceItem(menuItem.getId(), item);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Menu::setToggle(const MenuItem &menuItem, const bool state)
|
||||||
|
{
|
||||||
|
const auto item = menuItem.copyWith(state ? std::to_string(true) : std::to_string(false));
|
||||||
|
replaceItem(menuItem.getId(), item);
|
||||||
|
}
|
||||||
|
|
||||||
MenuItem Menu::switchValue(const MenuItem &menuItem, ButtonType button)
|
MenuItem Menu::switchValue(const MenuItem &menuItem, ButtonType button)
|
||||||
{
|
{
|
||||||
MenuItem result = menuItem;
|
MenuItem result = menuItem;
|
||||||
@@ -120,6 +126,15 @@ MenuItem Menu::switchValue(const MenuItem &menuItem, ButtonType button)
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Menu::setSelectionIndex(const MenuItem &menuItem, int index)
|
||||||
|
{
|
||||||
|
if (index >= 0 && index < menuItem.getItemCount())
|
||||||
|
{
|
||||||
|
auto item = menuItem.copyWith(index);
|
||||||
|
replaceItem(menuItem.getId(), item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
MenuItem Menu::replaceItem(const int index, const MenuItem &item)
|
MenuItem Menu::replaceItem(const int index, const MenuItem &item)
|
||||||
{
|
{
|
||||||
m_items.at(index) = item;
|
m_items.at(index) = item;
|
||||||
@@ -134,13 +149,13 @@ void Menu::Render()
|
|||||||
m_selected_item = 0;
|
m_selected_item = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Early return if no items to render
|
// Early return if there are no items to render
|
||||||
if (m_items.empty())
|
if (m_items.empty())
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear screen with black background
|
// Clear the screen with a black background
|
||||||
u8g2_SetDrawColor(u8g2, 0);
|
u8g2_SetDrawColor(u8g2, 0);
|
||||||
u8g2_DrawBox(u8g2, 0, 0, u8g2->width, u8g2->height);
|
u8g2_DrawBox(u8g2, 0, 0, u8g2->width, u8g2->height);
|
||||||
|
|
||||||
@@ -151,7 +166,7 @@ void Menu::Render()
|
|||||||
drawScrollBar();
|
drawScrollBar();
|
||||||
drawSelectionBox();
|
drawSelectionBox();
|
||||||
|
|
||||||
// Calculate center position for main item
|
// Calculate center position for the main item
|
||||||
const int centerY = u8g2->height / 2 + 3;
|
const int centerY = u8g2->height / 2 + 3;
|
||||||
|
|
||||||
// Render the currently selected item (main/center item)
|
// Render the currently selected item (main/center item)
|
||||||
@@ -176,7 +191,7 @@ void Menu::Render()
|
|||||||
|
|
||||||
void Menu::renderWidget(const MenuItem *item, const uint8_t *font, const int x, const int y) const
|
void Menu::renderWidget(const MenuItem *item, const uint8_t *font, const int x, const int y) const
|
||||||
{
|
{
|
||||||
// Set font and draw main text
|
// Set font and draw the main text
|
||||||
u8g2_SetFont(u8g2, font);
|
u8g2_SetFont(u8g2, font);
|
||||||
u8g2_DrawStr(u8g2, x, y, item->getText().c_str());
|
u8g2_DrawStr(u8g2, x, y, item->getText().c_str());
|
||||||
|
|
||||||
@@ -206,7 +221,7 @@ void Menu::renderWidget(const MenuItem *item, const uint8_t *font, const int x,
|
|||||||
}
|
}
|
||||||
|
|
||||||
case MenuItemTypes::TOGGLE: {
|
case MenuItemTypes::TOGGLE: {
|
||||||
// Draw checkbox frame
|
// Draw the checkbox frame
|
||||||
const int frameX = u8g2->width - UIConstants::FRAME_BOX_SIZE - UIConstants::SELECTION_MARGIN;
|
const int frameX = u8g2->width - UIConstants::FRAME_BOX_SIZE - UIConstants::SELECTION_MARGIN;
|
||||||
const int frameY = y - UIConstants::FRAME_OFFSET;
|
const int frameY = y - UIConstants::FRAME_OFFSET;
|
||||||
u8g2_DrawFrame(u8g2, frameX, frameY, UIConstants::FRAME_BOX_SIZE, UIConstants::FRAME_BOX_SIZE);
|
u8g2_DrawFrame(u8g2, frameX, frameY, UIConstants::FRAME_BOX_SIZE, UIConstants::FRAME_BOX_SIZE);
|
||||||
@@ -272,7 +287,7 @@ void Menu::onPressedDown()
|
|||||||
if (m_items.empty())
|
if (m_items.empty())
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// Wrap around to first item when at the end
|
// Wrap around to the first item when at the end
|
||||||
m_selected_item = (m_selected_item + 1) % m_items.size();
|
m_selected_item = (m_selected_item + 1) % m_items.size();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,7 +296,7 @@ void Menu::onPressedUp()
|
|||||||
if (m_items.empty())
|
if (m_items.empty())
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// Wrap around to last item when at the beginning
|
// Wrap around to the last item when at the beginning
|
||||||
m_selected_item = (m_selected_item == 0) ? m_items.size() - 1 : m_selected_item - 1;
|
m_selected_item = (m_selected_item == 0) ? m_items.size() - 1 : m_selected_item - 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,7 +329,7 @@ void Menu::onPressedSelect() const
|
|||||||
|
|
||||||
void Menu::onPressedBack() const
|
void Menu::onPressedBack() const
|
||||||
{
|
{
|
||||||
// Navigate back to previous screen if callback is available
|
// Navigate back to the previous screen if callback is available
|
||||||
if (m_options && m_options->popScreen)
|
if (m_options && m_options->popScreen)
|
||||||
{
|
{
|
||||||
m_options->popScreen();
|
m_options->popScreen();
|
||||||
|
|||||||
@@ -1,7 +1,20 @@
|
|||||||
#include "common/Widget.h"
|
#include "common/Widget.h"
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
std::vector<Widget *> Widget::s_instances;
|
||||||
|
|
||||||
Widget::Widget(u8g2_t *u8g2) : u8g2(u8g2)
|
Widget::Widget(u8g2_t *u8g2) : u8g2(u8g2)
|
||||||
{
|
{
|
||||||
|
s_instances.push_back(this);
|
||||||
|
if (s_instances.size() == 1)
|
||||||
|
{
|
||||||
|
message_manager_register_listener(globalMessageCallback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget::~Widget()
|
||||||
|
{
|
||||||
|
s_instances.erase(std::remove(s_instances.begin(), s_instances.end(), this), s_instances.end());
|
||||||
}
|
}
|
||||||
|
|
||||||
void Widget::onEnter()
|
void Widget::onEnter()
|
||||||
@@ -36,3 +49,15 @@ const char *Widget::getName() const
|
|||||||
{
|
{
|
||||||
return "Widget";
|
return "Widget";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Widget::onMessageReceived(const message_t *msg)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
void Widget::globalMessageCallback(const message_t *msg)
|
||||||
|
{
|
||||||
|
for (auto *w : s_instances)
|
||||||
|
{
|
||||||
|
w->onMessageReceived(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#include "ui/ClockScreenSaver.h"
|
#include "ui/ClockScreenSaver.h"
|
||||||
#include "hal_esp32/PersistenceManager.h"
|
#include "persistence_manager.h"
|
||||||
#include "simulator.h"
|
#include "simulator.h"
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <ctime>
|
#include <ctime>
|
||||||
@@ -38,13 +38,14 @@ void ClockScreenSaver::updateTextDimensions()
|
|||||||
|
|
||||||
void ClockScreenSaver::getCurrentTimeString(char *buffer, size_t bufferSize) const
|
void ClockScreenSaver::getCurrentTimeString(char *buffer, size_t bufferSize) const
|
||||||
{
|
{
|
||||||
if (m_options && m_options->persistenceManager->GetValue("light_active", false) &&
|
if (m_options && m_options->persistenceManager &&
|
||||||
m_options->persistenceManager->GetValue("light_mode", 0) == 0)
|
persistence_manager_get_bool(m_options->persistenceManager, "light_active", false) &&
|
||||||
|
persistence_manager_get_int(m_options->persistenceManager, "light_mode", 0) == 0)
|
||||||
{
|
{
|
||||||
char *simulated_time = get_time();
|
char *simulated_time = get_time();
|
||||||
if (simulated_time != nullptr)
|
if (simulated_time != nullptr)
|
||||||
{
|
{
|
||||||
strncpy(buffer, simulated_time, bufferSize);
|
snprintf(buffer, bufferSize, "%s Uhr", simulated_time);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
#include "ui/LightMenu.h"
|
#include "ui/LightMenu.h"
|
||||||
|
|
||||||
#include "led_strip_ws2812.h"
|
#include "led_strip_ws2812.h"
|
||||||
|
#include "message_manager.h"
|
||||||
#include "simulator.h"
|
#include "simulator.h"
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @namespace LightMenuItem
|
* @namespace LightMenuItem
|
||||||
@@ -27,7 +28,7 @@ LightMenu::LightMenu(menu_options_t *options) : Menu(options), m_options(options
|
|||||||
bool active = false;
|
bool active = false;
|
||||||
if (m_options && m_options->persistenceManager)
|
if (m_options && m_options->persistenceManager)
|
||||||
{
|
{
|
||||||
active = m_options->persistenceManager->GetValue(LightMenuOptions::LIGHT_ACTIVE, active);
|
active = persistence_manager_get_bool(m_options->persistenceManager, LightMenuOptions::LIGHT_ACTIVE, active);
|
||||||
}
|
}
|
||||||
addToggle(LightMenuItem::ACTIVATE, "Einschalten", active);
|
addToggle(LightMenuItem::ACTIVATE, "Einschalten", active);
|
||||||
|
|
||||||
@@ -39,7 +40,8 @@ LightMenu::LightMenu(menu_options_t *options) : Menu(options), m_options(options
|
|||||||
int mode_value = 0;
|
int mode_value = 0;
|
||||||
if (m_options && m_options->persistenceManager)
|
if (m_options && m_options->persistenceManager)
|
||||||
{
|
{
|
||||||
mode_value = m_options->persistenceManager->GetValue(LightMenuOptions::LIGHT_MODE, mode_value);
|
mode_value =
|
||||||
|
persistence_manager_get_int(m_options->persistenceManager, LightMenuOptions::LIGHT_MODE, mode_value);
|
||||||
}
|
}
|
||||||
addSelection(LightMenuItem::MODE, "Modus", items, mode_value);
|
addSelection(LightMenuItem::MODE, "Modus", items, mode_value);
|
||||||
|
|
||||||
@@ -50,7 +52,9 @@ LightMenu::LightMenu(menu_options_t *options) : Menu(options), m_options(options
|
|||||||
int variant_value = 3;
|
int variant_value = 3;
|
||||||
if (m_options && m_options->persistenceManager)
|
if (m_options && m_options->persistenceManager)
|
||||||
{
|
{
|
||||||
variant_value = m_options->persistenceManager->GetValue(LightMenuOptions::LIGHT_VARIANT, variant_value) - 1;
|
variant_value =
|
||||||
|
persistence_manager_get_int(m_options->persistenceManager, LightMenuOptions::LIGHT_VARIANT, variant_value) -
|
||||||
|
1;
|
||||||
}
|
}
|
||||||
addSelection(LightMenuItem::VARIANT, "Variante", variants, variant_value);
|
addSelection(LightMenuItem::VARIANT, "Variante", variants, variant_value);
|
||||||
}
|
}
|
||||||
@@ -68,12 +72,13 @@ void LightMenu::onButtonPressed(const MenuItem &menuItem, const ButtonType butto
|
|||||||
{
|
{
|
||||||
toggle(menuItem);
|
toggle(menuItem);
|
||||||
const auto value = getItem(menuItem.getId()).getValue() == "1";
|
const auto value = getItem(menuItem.getId()).getValue() == "1";
|
||||||
if (m_options && m_options->persistenceManager)
|
// Post change via message_manager
|
||||||
{
|
message_t msg = {};
|
||||||
m_options->persistenceManager->SetValue(LightMenuOptions::LIGHT_ACTIVE, value);
|
msg.type = MESSAGE_TYPE_SETTINGS;
|
||||||
}
|
msg.data.settings.type = SETTINGS_TYPE_BOOL;
|
||||||
|
strncpy(msg.data.settings.key, LightMenuOptions::LIGHT_ACTIVE, sizeof(msg.data.settings.key) - 1);
|
||||||
start_simulation();
|
msg.data.settings.value.bool_value = value;
|
||||||
|
message_manager_post(&msg, pdMS_TO_TICKS(100));
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -86,11 +91,14 @@ void LightMenu::onButtonPressed(const MenuItem &menuItem, const ButtonType butto
|
|||||||
const auto value = getItem(item.getId()).getIndex();
|
const auto value = getItem(item.getId()).getIndex();
|
||||||
if (m_options && m_options->persistenceManager)
|
if (m_options && m_options->persistenceManager)
|
||||||
{
|
{
|
||||||
m_options->persistenceManager->SetValue(LightMenuOptions::LIGHT_MODE, value);
|
// Post change via message_manager
|
||||||
m_options->persistenceManager->Save();
|
message_t msg = {};
|
||||||
|
msg.type = MESSAGE_TYPE_SETTINGS;
|
||||||
|
msg.data.settings.type = SETTINGS_TYPE_INT;
|
||||||
|
strncpy(msg.data.settings.key, LightMenuOptions::LIGHT_MODE, sizeof(msg.data.settings.key) - 1);
|
||||||
|
msg.data.settings.value.int_value = value;
|
||||||
|
message_manager_post(&msg, pdMS_TO_TICKS(100));
|
||||||
}
|
}
|
||||||
|
|
||||||
start_simulation();
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -103,11 +111,14 @@ void LightMenu::onButtonPressed(const MenuItem &menuItem, const ButtonType butto
|
|||||||
const auto value = getItem(item.getId()).getIndex() + 1;
|
const auto value = getItem(item.getId()).getIndex() + 1;
|
||||||
if (m_options && m_options->persistenceManager)
|
if (m_options && m_options->persistenceManager)
|
||||||
{
|
{
|
||||||
m_options->persistenceManager->SetValue(LightMenuOptions::LIGHT_VARIANT, value);
|
// Post change via message_manager
|
||||||
m_options->persistenceManager->Save();
|
message_t msg = {};
|
||||||
|
msg.type = MESSAGE_TYPE_SETTINGS;
|
||||||
|
msg.data.settings.type = SETTINGS_TYPE_INT;
|
||||||
|
strncpy(msg.data.settings.key, LightMenuOptions::LIGHT_VARIANT, sizeof(msg.data.settings.key) - 1);
|
||||||
|
msg.data.settings.value.int_value = value;
|
||||||
|
message_manager_post(&msg, pdMS_TO_TICKS(100));
|
||||||
}
|
}
|
||||||
|
|
||||||
start_simulation();
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -124,4 +135,25 @@ void LightMenu::onButtonPressed(const MenuItem &menuItem, const ButtonType butto
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void LightMenu::onMessageReceived(const message_t *msg)
|
||||||
|
{
|
||||||
|
// Here you can react to messages, e.g. set toggle status
|
||||||
|
// Example: If light_active was changed, synchronize toggle
|
||||||
|
if (msg && msg->type == MESSAGE_TYPE_SETTINGS)
|
||||||
|
{
|
||||||
|
if (std::strcmp(msg->data.settings.key, LightMenuOptions::LIGHT_ACTIVE) == 0)
|
||||||
|
{
|
||||||
|
setToggle(getItem(LightMenuItem::ACTIVATE), msg->data.settings.value.bool_value);
|
||||||
|
}
|
||||||
|
if (std::strcmp(msg->data.settings.key, LightMenuOptions::LIGHT_MODE) == 0)
|
||||||
|
{
|
||||||
|
setSelectionIndex(getItem(LightMenuItem::MODE), msg->data.settings.value.int_value);
|
||||||
|
}
|
||||||
|
if (std::strcmp(msg->data.settings.key, LightMenuOptions::LIGHT_VARIANT) == 0)
|
||||||
|
{
|
||||||
|
setSelectionIndex(getItem(LightMenuItem::VARIANT), msg->data.settings.value.int_value - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
IMPLEMENT_GET_NAME(LightMenu)
|
IMPLEMENT_GET_NAME(LightMenu)
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ typedef struct
|
|||||||
|
|
||||||
typedef struct
|
typedef struct
|
||||||
{
|
{
|
||||||
uint8_t h;
|
float h;
|
||||||
uint8_t s;
|
float s;
|
||||||
uint8_t v;
|
float v;
|
||||||
} hsv_t;
|
} hsv_t;
|
||||||
|
|
||||||
__BEGIN_DECLS
|
__BEGIN_DECLS
|
||||||
|
|||||||
16
firmware/components/led-manager/include/led_segment.h
Normal file
16
firmware/components/led-manager/include/led_segment.h
Normal 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;
|
||||||
@@ -45,10 +45,10 @@ rgb_t interpolate_color_hsv(rgb_t start, rgb_t end, float factor)
|
|||||||
|
|
||||||
// Interpolate HSV values
|
// Interpolate HSV values
|
||||||
hsv_t interpolated_hsv;
|
hsv_t interpolated_hsv;
|
||||||
interpolated_hsv.h = fmod(h1 + (h2 - h1) * factor, 360.0);
|
interpolated_hsv.h = fmodf(h1 + (h2 - h1) * factor, 360.0f);
|
||||||
if (interpolated_hsv.h < 0)
|
if (interpolated_hsv.h < 0.0f)
|
||||||
{
|
{
|
||||||
interpolated_hsv.h += 360.0;
|
interpolated_hsv.h += 360.0f;
|
||||||
}
|
}
|
||||||
interpolated_hsv.s = start_hsv.s + (end_hsv.s - start_hsv.s) * factor;
|
interpolated_hsv.s = start_hsv.s + (end_hsv.s - start_hsv.s) * factor;
|
||||||
interpolated_hsv.v = start_hsv.v + (end_hsv.v - start_hsv.v) * factor;
|
interpolated_hsv.v = start_hsv.v + (end_hsv.v - start_hsv.v) * factor;
|
||||||
|
|||||||
8
firmware/components/message-manager/CMakeLists.txt
Normal file
8
firmware/components/message-manager/CMakeLists.txt
Normal 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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
134
firmware/components/message-manager/src/message_manager.c
Normal file
134
firmware/components/message-manager/src/message_manager.c
Normal 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;
|
||||||
|
}
|
||||||
7
firmware/components/my_mqtt_client/CMakeLists.txt
Normal file
7
firmware/components/my_mqtt_client/CMakeLists.txt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
idf_component_register(
|
||||||
|
SRCS "src/my_mqtt_client.c"
|
||||||
|
INCLUDE_DIRS "include"
|
||||||
|
REQUIRES
|
||||||
|
mqtt
|
||||||
|
app_update
|
||||||
|
)
|
||||||
21
firmware/components/my_mqtt_client/Kconfig
Normal file
21
firmware/components/my_mqtt_client/Kconfig
Normal 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
|
||||||
18
firmware/components/my_mqtt_client/README.md
Normal file
18
firmware/components/my_mqtt_client/README.md
Normal 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`.
|
||||||
2
firmware/components/my_mqtt_client/idf_component.yml
Normal file
2
firmware/components/my_mqtt_client/idf_component.yml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
dependencies:
|
||||||
|
espressif/mqtt: ^1.0.0
|
||||||
14
firmware/components/my_mqtt_client/include/my_mqtt_client.h
Normal file
14
firmware/components/my_mqtt_client/include/my_mqtt_client.h
Normal 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
|
||||||
122
firmware/components/my_mqtt_client/src/my_mqtt_client.c
Normal file
122
firmware/components/my_mqtt_client/src/my_mqtt_client.c
Normal 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!");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
idf_component_register(SRCS
|
idf_component_register(SRCS
|
||||||
src/PersistenceManager.cpp
|
src/persistence_manager.c
|
||||||
INCLUDE_DIRS "include"
|
INCLUDE_DIRS "include"
|
||||||
REQUIRES
|
REQUIRES
|
||||||
nvs_flash
|
nvs_flash
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -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;
|
|
||||||
};
|
|
||||||
@@ -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
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -5,5 +5,6 @@ idf_component_register(SRCS
|
|||||||
PRIV_REQUIRES
|
PRIV_REQUIRES
|
||||||
led-manager
|
led-manager
|
||||||
persistence-manager
|
persistence-manager
|
||||||
|
message-manager
|
||||||
spiffs
|
spiffs
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,12 +9,20 @@ typedef struct
|
|||||||
int cycle_duration_minutes;
|
int cycle_duration_minutes;
|
||||||
} simulation_config_t;
|
} simulation_config_t;
|
||||||
|
|
||||||
char *get_time(void);
|
#ifdef __cplusplus
|
||||||
esp_err_t add_light_item(const char time[5], uint8_t red, uint8_t green, uint8_t blue, uint8_t white,
|
extern "C"
|
||||||
uint8_t brightness, uint8_t saturation);
|
{
|
||||||
void cleanup_light_items(void);
|
#endif
|
||||||
void start_simulate_day(void);
|
|
||||||
void start_simulate_night(void);
|
char *get_time(void);
|
||||||
void start_simulation_task(void);
|
esp_err_t add_light_item(const char time[5], uint8_t red, uint8_t green, uint8_t blue, uint8_t white,
|
||||||
void stop_simulation_task(void);
|
uint8_t brightness, uint8_t saturation);
|
||||||
void start_simulation(void);
|
void cleanup_light_items(void);
|
||||||
|
void start_simulate_day(void);
|
||||||
|
void start_simulate_night(void);
|
||||||
|
void start_simulation_task(void);
|
||||||
|
void stop_simulation_task(void);
|
||||||
|
void start_simulation(void);
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -1,4 +1,23 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
void initialize_storage();
|
#include "esp_err.h"
|
||||||
void load_file(const char *filename);
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C"
|
||||||
|
{
|
||||||
|
#endif
|
||||||
|
void initialize_storage();
|
||||||
|
void load_file(const char *filename);
|
||||||
|
char **read_lines_filtered(const char *filename, int *out_count);
|
||||||
|
void free_lines(char **lines, int count);
|
||||||
|
/**
|
||||||
|
* Write an array of lines to a file (CSV or other text).
|
||||||
|
* @param filename File name (without /spiffs/)
|
||||||
|
* @param lines Array of lines (null-terminated strings)
|
||||||
|
* @param count Number of lines
|
||||||
|
* @return ESP_OK on success, error code otherwise
|
||||||
|
*/
|
||||||
|
esp_err_t write_lines(const char *filename, char **lines, int count);
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
#include "simulator.h"
|
#include "simulator.h"
|
||||||
|
|
||||||
#include "color.h"
|
#include "color.h"
|
||||||
#include "hal_esp32/PersistenceManager.h"
|
|
||||||
#include "led_strip_ws2812.h"
|
#include "led_strip_ws2812.h"
|
||||||
|
#include "message_manager.h"
|
||||||
|
#include "persistence_manager.h"
|
||||||
#include "storage.h"
|
#include "storage.h"
|
||||||
#include <esp_heap_caps.h>
|
#include <esp_heap_caps.h>
|
||||||
#include <esp_log.h>
|
#include <esp_log.h>
|
||||||
@@ -15,12 +16,12 @@
|
|||||||
#include <string.h>
|
#include <string.h>
|
||||||
|
|
||||||
static const char *TAG = "simulator";
|
static const char *TAG = "simulator";
|
||||||
static char *time;
|
static char *time = NULL;
|
||||||
|
|
||||||
static char *time_to_string(int hhmm)
|
static char *time_to_string(int hhmm)
|
||||||
{
|
{
|
||||||
static char buffer[20];
|
static char buffer[20];
|
||||||
snprintf(buffer, sizeof(buffer), "%02d:%02d Uhr", hhmm / 100, hhmm % 100);
|
snprintf(buffer, sizeof(buffer), "%02d:%02d", hhmm / 100, hhmm % 100);
|
||||||
return buffer;
|
return buffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,10 +82,10 @@ esp_err_t add_light_item(const char time[5], uint8_t red, uint8_t green, uint8_t
|
|||||||
uint8_t brightness, uint8_t saturation)
|
uint8_t brightness, uint8_t saturation)
|
||||||
{
|
{
|
||||||
// Allocate memory for a new node in PSRAM.
|
// Allocate memory for a new node in PSRAM.
|
||||||
light_item_node_t *new_node = (light_item_node_t *)heap_caps_malloc(sizeof(light_item_node_t), MALLOC_CAP_SPIRAM);
|
light_item_node_t *new_node = (light_item_node_t *)heap_caps_malloc(sizeof(light_item_node_t), MALLOC_CAP_DEFAULT);
|
||||||
if (new_node == NULL)
|
if (new_node == NULL)
|
||||||
{
|
{
|
||||||
ESP_LOGE(TAG, "Failed to allocate memory in PSRAM for new light_item_node_t.");
|
ESP_LOGE(TAG, "Failed to allocate memory for new light_item_node_t.");
|
||||||
return ESP_FAIL;
|
return ESP_FAIL;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,10 +146,12 @@ static void initialize_light_items(void)
|
|||||||
initialize_storage();
|
initialize_storage();
|
||||||
|
|
||||||
static char filename[30];
|
static char filename[30];
|
||||||
auto persistence = PersistenceManager();
|
persistence_manager_t persistence;
|
||||||
int variant = persistence.GetValue("light_variant", 1);
|
persistence_manager_init(&persistence, "config");
|
||||||
snprintf(filename, sizeof(filename), "/spiffs/schema_%02d.csv", variant);
|
int variant = persistence_manager_get_int(&persistence, "light_variant", 1);
|
||||||
|
snprintf(filename, sizeof(filename), "schema_%02d.csv", variant);
|
||||||
load_file(filename);
|
load_file(filename);
|
||||||
|
persistence_manager_deinit(&persistence);
|
||||||
|
|
||||||
if (head == NULL)
|
if (head == NULL)
|
||||||
{
|
{
|
||||||
@@ -233,6 +236,17 @@ static light_item_node_t *find_next_light_item_for_time(int hhmm)
|
|||||||
return next_item;
|
return next_item;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void send_simulation_message(const char *time, rgb_t color)
|
||||||
|
{
|
||||||
|
message_t msg = {};
|
||||||
|
msg.type = MESSAGE_TYPE_SIMULATION;
|
||||||
|
strncpy(msg.data.simulation.time, time, sizeof(msg.data.simulation.time) - 1);
|
||||||
|
msg.data.simulation.red = color.red;
|
||||||
|
msg.data.simulation.green = color.green;
|
||||||
|
msg.data.simulation.blue = color.blue;
|
||||||
|
message_manager_post(&msg, pdMS_TO_TICKS(100));
|
||||||
|
}
|
||||||
|
|
||||||
void start_simulate_day(void)
|
void start_simulate_day(void)
|
||||||
{
|
{
|
||||||
initialize_light_items();
|
initialize_light_items();
|
||||||
@@ -240,8 +254,9 @@ void start_simulate_day(void)
|
|||||||
light_item_node_t *current_item = find_best_light_item_for_time(1200);
|
light_item_node_t *current_item = find_best_light_item_for_time(1200);
|
||||||
if (current_item != NULL)
|
if (current_item != NULL)
|
||||||
{
|
{
|
||||||
led_strip_update(LED_STATE_DAY,
|
rgb_t color = {.red = current_item->red, .green = current_item->green, .blue = current_item->blue};
|
||||||
(rgb_t){.red = current_item->red, .green = current_item->green, .blue = current_item->blue});
|
led_strip_update(LED_STATE_DAY, color);
|
||||||
|
send_simulation_message("12:00", color);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,8 +267,9 @@ void start_simulate_night(void)
|
|||||||
light_item_node_t *current_item = find_best_light_item_for_time(0);
|
light_item_node_t *current_item = find_best_light_item_for_time(0);
|
||||||
if (current_item != NULL)
|
if (current_item != NULL)
|
||||||
{
|
{
|
||||||
led_strip_update(LED_STATE_NIGHT,
|
rgb_t color = {.red = current_item->red, .green = current_item->green, .blue = current_item->blue};
|
||||||
(rgb_t){.red = current_item->red, .green = current_item->green, .blue = current_item->blue});
|
led_strip_update(LED_STATE_NIGHT, color);
|
||||||
|
send_simulation_message("00:00", color);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,45 +310,51 @@ void simulate_cycle(void *args)
|
|||||||
light_item_node_t *current_item = find_best_light_item_for_time(hhmm);
|
light_item_node_t *current_item = find_best_light_item_for_time(hhmm);
|
||||||
light_item_node_t *next_item = find_next_light_item_for_time(hhmm);
|
light_item_node_t *next_item = find_next_light_item_for_time(hhmm);
|
||||||
|
|
||||||
if (current_item != NULL && next_item != NULL)
|
if (current_item != NULL)
|
||||||
{
|
{
|
||||||
int current_item_time_min = (atoi(current_item->time) / 100) * 60 + (atoi(current_item->time) % 100);
|
rgb_t color = {0, 0, 0};
|
||||||
int next_item_time_min = (atoi(next_item->time) / 100) * 60 + (atoi(next_item->time) % 100);
|
|
||||||
|
|
||||||
if (next_item_time_min < current_item_time_min)
|
// Use head as fallback if next_item is NULL
|
||||||
|
next_item = next_item ? next_item : head;
|
||||||
|
if (next_item != NULL)
|
||||||
{
|
{
|
||||||
next_item_time_min += total_minutes_in_day;
|
int current_item_time_min = (atoi(current_item->time) / 100) * 60 + (atoi(current_item->time) % 100);
|
||||||
}
|
int next_item_time_min = (atoi(next_item->time) / 100) * 60 + (atoi(next_item->time) % 100);
|
||||||
|
|
||||||
int minutes_since_current_item_start = current_minute_of_day - current_item_time_min;
|
if (next_item_time_min < current_item_time_min)
|
||||||
if (minutes_since_current_item_start < 0)
|
{
|
||||||
|
next_item_time_min += total_minutes_in_day;
|
||||||
|
}
|
||||||
|
|
||||||
|
int minutes_since_current_item_start = current_minute_of_day - current_item_time_min;
|
||||||
|
if (minutes_since_current_item_start < 0)
|
||||||
|
{
|
||||||
|
minutes_since_current_item_start += total_minutes_in_day;
|
||||||
|
}
|
||||||
|
|
||||||
|
int interval_duration = next_item_time_min - current_item_time_min;
|
||||||
|
if (interval_duration == 0)
|
||||||
|
{
|
||||||
|
interval_duration = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
float interpolation_factor = (float)minutes_since_current_item_start / (float)interval_duration;
|
||||||
|
|
||||||
|
// Prepare colors for interpolation
|
||||||
|
rgb_t start_rgb = {.red = current_item->red, .green = current_item->green, .blue = current_item->blue};
|
||||||
|
rgb_t end_rgb = {.red = next_item->red, .green = next_item->green, .blue = next_item->blue};
|
||||||
|
|
||||||
|
// Use the interpolation function
|
||||||
|
color = interpolate_color(start_rgb, end_rgb, interpolation_factor);
|
||||||
|
led_strip_update(LED_STATE_SIMULATION, color);
|
||||||
|
}
|
||||||
|
else
|
||||||
{
|
{
|
||||||
minutes_since_current_item_start += total_minutes_in_day;
|
// No next_item and no head, use only current
|
||||||
|
color = (rgb_t){.red = current_item->red, .green = current_item->green, .blue = current_item->blue};
|
||||||
|
led_strip_update(LED_STATE_SIMULATION, color);
|
||||||
}
|
}
|
||||||
|
send_simulation_message(time, color);
|
||||||
int interval_duration = next_item_time_min - current_item_time_min;
|
|
||||||
if (interval_duration == 0)
|
|
||||||
{
|
|
||||||
interval_duration = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
float interpolation_factor = (float)minutes_since_current_item_start / (float)interval_duration;
|
|
||||||
|
|
||||||
// Prepare colors for interpolation
|
|
||||||
rgb_t start_rgb = {.red = current_item->red, .green = current_item->green, .blue = current_item->blue};
|
|
||||||
rgb_t end_rgb = {.red = next_item->red, .green = next_item->green, .blue = next_item->blue};
|
|
||||||
|
|
||||||
// Use the interpolation function
|
|
||||||
rgb_t final_rgb = interpolate_color(start_rgb, end_rgb, interpolation_factor);
|
|
||||||
|
|
||||||
led_strip_update(LED_STATE_SIMULATION, final_rgb);
|
|
||||||
}
|
|
||||||
else if (current_item != NULL)
|
|
||||||
{
|
|
||||||
// No next item, just use current
|
|
||||||
led_strip_update(
|
|
||||||
LED_STATE_SIMULATION,
|
|
||||||
(rgb_t){.red = current_item->red, .green = current_item->green, .blue = current_item->blue});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
vTaskDelay(pdMS_TO_TICKS(delay_ms));
|
vTaskDelay(pdMS_TO_TICKS(delay_ms));
|
||||||
@@ -351,7 +373,7 @@ void start_simulation_task(void)
|
|||||||
stop_simulation_task();
|
stop_simulation_task();
|
||||||
|
|
||||||
simulation_config_t *config =
|
simulation_config_t *config =
|
||||||
(simulation_config_t *)heap_caps_malloc(sizeof(simulation_config_t), MALLOC_CAP_SPIRAM);
|
(simulation_config_t *)heap_caps_malloc(sizeof(simulation_config_t), MALLOC_CAP_DEFAULT);
|
||||||
if (config == NULL)
|
if (config == NULL)
|
||||||
{
|
{
|
||||||
ESP_LOGE(TAG, "Failed to allocate memory for simulation config.");
|
ESP_LOGE(TAG, "Failed to allocate memory for simulation config.");
|
||||||
@@ -398,25 +420,22 @@ void start_simulation(void)
|
|||||||
{
|
{
|
||||||
stop_simulation_task();
|
stop_simulation_task();
|
||||||
|
|
||||||
auto persistence = PersistenceManager();
|
persistence_manager_t persistence;
|
||||||
if (persistence.GetValue("light_active", false))
|
persistence_manager_init(&persistence, "config");
|
||||||
|
if (persistence_manager_get_bool(&persistence, "light_active", false))
|
||||||
{
|
{
|
||||||
|
int mode = persistence_manager_get_int(&persistence, "light_mode", 0);
|
||||||
int mode = persistence.GetValue("light_mode", 0);
|
|
||||||
switch (mode)
|
switch (mode)
|
||||||
{
|
{
|
||||||
case 0: // Simulation mode
|
case 0: // Simulation mode
|
||||||
start_simulation_task();
|
start_simulation_task();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 1: // Day mode
|
case 1: // Day mode
|
||||||
start_simulate_day();
|
start_simulate_day();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 2: // Night mode
|
case 2: // Night mode
|
||||||
start_simulate_night();
|
start_simulate_night();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
ESP_LOGW(TAG, "Unknown light mode: %d", mode);
|
ESP_LOGW(TAG, "Unknown light mode: %d", mode);
|
||||||
break;
|
break;
|
||||||
@@ -426,4 +445,5 @@ void start_simulation(void)
|
|||||||
{
|
{
|
||||||
led_strip_update(LED_STATE_OFF, rgb_t{});
|
led_strip_update(LED_STATE_OFF, rgb_t{});
|
||||||
}
|
}
|
||||||
|
persistence_manager_deinit(&persistence);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,14 +3,22 @@
|
|||||||
#include "esp_log.h"
|
#include "esp_log.h"
|
||||||
#include "esp_spiffs.h"
|
#include "esp_spiffs.h"
|
||||||
#include "simulator.h"
|
#include "simulator.h"
|
||||||
#include <cstring>
|
|
||||||
#include <errno.h>
|
#include <errno.h>
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
static const char *TAG = "storage";
|
static const char *TAG = "storage";
|
||||||
|
|
||||||
|
static bool is_spiffs_mounted = false;
|
||||||
|
|
||||||
void initialize_storage()
|
void initialize_storage()
|
||||||
{
|
{
|
||||||
|
if (is_spiffs_mounted)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
esp_vfs_spiffs_conf_t conf = {
|
esp_vfs_spiffs_conf_t conf = {
|
||||||
.base_path = "/spiffs",
|
.base_path = "/spiffs",
|
||||||
.partition_label = NULL,
|
.partition_label = NULL,
|
||||||
@@ -36,64 +44,113 @@ void initialize_storage()
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is_spiffs_mounted = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void load_file(const char *filename)
|
void load_file(const char *filename)
|
||||||
{
|
{
|
||||||
ESP_LOGI(TAG, "Loading file: %s", filename);
|
ESP_LOGI(TAG, "Loading file: %s", filename);
|
||||||
FILE *f = fopen(filename, "r");
|
int line_count = 0;
|
||||||
if (f == NULL)
|
char **lines = read_lines_filtered(filename, &line_count);
|
||||||
{
|
|
||||||
ESP_LOGE(TAG, "Failed to open file for reading");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
char line[128];
|
|
||||||
uint8_t line_number = 0;
|
uint8_t line_number = 0;
|
||||||
while (fgets(line, sizeof(line), f))
|
for (int i = 0; i < line_count; ++i)
|
||||||
{
|
{
|
||||||
char *pos = strchr(line, '\n');
|
|
||||||
if (pos)
|
|
||||||
{
|
|
||||||
*pos = '\0';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (strlen(line) == 0)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
char *trimmed = line;
|
|
||||||
while (*trimmed == ' ' || *trimmed == '\t')
|
|
||||||
{
|
|
||||||
trimmed++;
|
|
||||||
}
|
|
||||||
if (*trimmed == '#' || *trimmed == '\0')
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
char time[10] = {0};
|
char time[10] = {0};
|
||||||
int red, green, blue, white, brightness, saturation;
|
int red, green, blue, white, brightness, saturation;
|
||||||
|
int items_scanned =
|
||||||
int items_scanned = sscanf(line, "%d,%d,%d,%d,%d,%d", &red, &green, &blue, &white, &brightness, &saturation);
|
sscanf(lines[i], "%d,%d,%d,%d,%d,%d", &red, &green, &blue, &white, &brightness, &saturation);
|
||||||
if (items_scanned == 6)
|
if (items_scanned == 6)
|
||||||
{
|
{
|
||||||
int total_minutes = line_number * 30;
|
int total_minutes = line_number * 30;
|
||||||
int hours = total_minutes / 60;
|
int hours = total_minutes / 60;
|
||||||
int minutes = total_minutes % 60;
|
int minutes = total_minutes % 60;
|
||||||
|
|
||||||
snprintf(time, sizeof(time), "%02d%02d", hours, minutes);
|
snprintf(time, sizeof(time), "%02d%02d", hours, minutes);
|
||||||
|
|
||||||
add_light_item(time, red, green, blue, white, brightness, saturation);
|
add_light_item(time, red, green, blue, white, brightness, saturation);
|
||||||
line_number++;
|
line_number++;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
ESP_LOGW(TAG, "Could not parse line: %s", line);
|
ESP_LOGW(TAG, "Could not parse line: %s", lines[i]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
free_lines(lines, line_count);
|
||||||
fclose(f);
|
|
||||||
ESP_LOGI(TAG, "Finished loading file. Loaded %d entries.", line_number);
|
ESP_LOGI(TAG, "Finished loading file. Loaded %d entries.", line_number);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
char **read_lines_filtered(const char *filename, int *out_count)
|
||||||
|
{
|
||||||
|
char fullpath[128];
|
||||||
|
snprintf(fullpath, sizeof(fullpath), "/spiffs/%s", filename[0] == '/' ? filename + 1 : filename);
|
||||||
|
FILE *f = fopen(fullpath, "r");
|
||||||
|
if (!f)
|
||||||
|
{
|
||||||
|
ESP_LOGE(TAG, "Failed to open file: %s", fullpath);
|
||||||
|
*out_count = 0;
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t capacity = 16;
|
||||||
|
size_t count = 0;
|
||||||
|
char **lines = (char **)malloc(capacity * sizeof(char *));
|
||||||
|
char line[256];
|
||||||
|
while (fgets(line, sizeof(line), f))
|
||||||
|
{
|
||||||
|
// Zeilenumbruch entfernen
|
||||||
|
char *pos = strchr(line, '\n');
|
||||||
|
if (pos)
|
||||||
|
*pos = '\0';
|
||||||
|
// Trim vorne
|
||||||
|
char *trimmed = line;
|
||||||
|
while (*trimmed == ' ' || *trimmed == '\t')
|
||||||
|
trimmed++;
|
||||||
|
// Leere oder Kommentarzeile überspringen
|
||||||
|
if (*trimmed == '\0' || *trimmed == '#')
|
||||||
|
continue;
|
||||||
|
// Trim hinten
|
||||||
|
size_t len = strlen(trimmed);
|
||||||
|
while (len > 0 && (trimmed[len - 1] == ' ' || trimmed[len - 1] == '\t'))
|
||||||
|
trimmed[--len] = '\0';
|
||||||
|
// Kopieren
|
||||||
|
if (count >= capacity)
|
||||||
|
{
|
||||||
|
capacity *= 2;
|
||||||
|
lines = (char **)realloc(lines, capacity * sizeof(char *));
|
||||||
|
}
|
||||||
|
lines[count++] = strdup(trimmed);
|
||||||
|
}
|
||||||
|
fclose(f);
|
||||||
|
*out_count = (int)count;
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
void free_lines(char **lines, int count)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < count; ++i)
|
||||||
|
free(lines[i]);
|
||||||
|
free(lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t write_lines(const char *filename, char **lines, int count)
|
||||||
|
{
|
||||||
|
char fullpath[128];
|
||||||
|
snprintf(fullpath, sizeof(fullpath), "/spiffs/%s", filename[0] == '/' ? filename + 1 : filename);
|
||||||
|
FILE *f = fopen(fullpath, "w");
|
||||||
|
if (!f)
|
||||||
|
{
|
||||||
|
ESP_LOGE(TAG, "Failed to open file for writing: %s", fullpath);
|
||||||
|
return ESP_FAIL;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < count; ++i)
|
||||||
|
{
|
||||||
|
if (fprintf(f, "%s\n", lines[i]) < 0)
|
||||||
|
{
|
||||||
|
ESP_LOGE(TAG, "Failed to write line %d", i);
|
||||||
|
fclose(f);
|
||||||
|
return ESP_FAIL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fclose(f);
|
||||||
|
ESP_LOGI(TAG, "Wrote %d lines to %s", count, fullpath);
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
idf_component_register(SRCS
|
idf_component_register(SRCS
|
||||||
main.cpp
|
src/main.cpp
|
||||||
app_task.cpp
|
src/app_task.cpp
|
||||||
button_handling.c
|
src/button_handling.c
|
||||||
i2c_checker.c
|
src/i2c_checker.c
|
||||||
hal/u8g2_esp32_hal.c
|
src/hal/u8g2_esp32_hal.c
|
||||||
INCLUDE_DIRS "."
|
INCLUDE_DIRS "include"
|
||||||
PRIV_REQUIRES
|
PRIV_REQUIRES
|
||||||
analytics
|
analytics
|
||||||
insa
|
insa
|
||||||
@@ -21,6 +21,7 @@ idf_component_register(SRCS
|
|||||||
app_update
|
app_update
|
||||||
rmaker_common
|
rmaker_common
|
||||||
driver
|
driver
|
||||||
|
my_mqtt_client
|
||||||
)
|
)
|
||||||
|
|
||||||
spiffs_create_partition_image(storage ../storage FLASH_IN_PROJECT)
|
spiffs_create_partition_image(storage ../storage FLASH_IN_PROJECT)
|
||||||
|
|||||||
@@ -3,6 +3,5 @@ dependencies:
|
|||||||
git: https://github.com/olikraus/u8g2.git
|
git: https://github.com/olikraus/u8g2.git
|
||||||
# u8g2_hal:
|
# u8g2_hal:
|
||||||
# git: https://github.com/mkfrey/u8g2-hal-esp-idf.git
|
# git: https://github.com/mkfrey/u8g2-hal-esp-idf.git
|
||||||
espressif/button: ^4.1.3
|
espressif/button: ^4.1.4
|
||||||
espressif/esp_insights: ^1.2.7
|
espressif/esp_insights: ^1.2.7
|
||||||
espressif/mqtt: ^1.0.0
|
|
||||||
|
|||||||
31
firmware/main/isrgrootx1.pem
Normal file
31
firmware/main/isrgrootx1.pem
Normal 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-----
|
||||||
@@ -2,16 +2,20 @@
|
|||||||
|
|
||||||
#include "analytics.h"
|
#include "analytics.h"
|
||||||
#include "button_handling.h"
|
#include "button_handling.h"
|
||||||
|
#include "common.h"
|
||||||
#include "common/InactivityTracker.h"
|
#include "common/InactivityTracker.h"
|
||||||
#include "hal/u8g2_esp32_hal.h"
|
#include "hal/u8g2_esp32_hal.h"
|
||||||
#include "hal_esp32/PersistenceManager.h"
|
|
||||||
#include "i2c_checker.h"
|
#include "i2c_checker.h"
|
||||||
#include "led_status.h"
|
#include "led_status.h"
|
||||||
|
#include "message_manager.h"
|
||||||
|
#include "my_mqtt_client.h"
|
||||||
|
#include "persistence_manager.h"
|
||||||
#include "simulator.h"
|
#include "simulator.h"
|
||||||
#include "ui/ClockScreenSaver.h"
|
#include "ui/ClockScreenSaver.h"
|
||||||
#include "ui/ScreenSaver.h"
|
#include "ui/ScreenSaver.h"
|
||||||
#include "ui/SplashScreen.h"
|
#include "ui/SplashScreen.h"
|
||||||
#include "wifi_manager.h"
|
#include "wifi_manager.h"
|
||||||
|
#include <cstring>
|
||||||
#include <driver/i2c.h>
|
#include <driver/i2c.h>
|
||||||
#include <esp_diagnostics.h>
|
#include <esp_diagnostics.h>
|
||||||
#include <esp_insights.h>
|
#include <esp_insights.h>
|
||||||
@@ -33,7 +37,8 @@ uint8_t received_signal;
|
|||||||
std::shared_ptr<Widget> m_widget;
|
std::shared_ptr<Widget> m_widget;
|
||||||
std::vector<std::shared_ptr<Widget>> m_history;
|
std::vector<std::shared_ptr<Widget>> m_history;
|
||||||
std::unique_ptr<InactivityTracker> m_inactivityTracker;
|
std::unique_ptr<InactivityTracker> m_inactivityTracker;
|
||||||
std::shared_ptr<PersistenceManager> m_persistenceManager;
|
// Persistence Manager for C-API
|
||||||
|
persistence_manager_t g_persistence_manager;
|
||||||
|
|
||||||
extern QueueHandle_t buttonQueue;
|
extern QueueHandle_t buttonQueue;
|
||||||
|
|
||||||
@@ -93,10 +98,7 @@ void popScreen()
|
|||||||
m_history.pop_back();
|
m_history.pop_back();
|
||||||
if (m_widget)
|
if (m_widget)
|
||||||
{
|
{
|
||||||
if (m_persistenceManager != nullptr)
|
persistence_manager_save(&g_persistence_manager);
|
||||||
{
|
|
||||||
m_persistenceManager->Save();
|
|
||||||
}
|
|
||||||
m_widget->onExit();
|
m_widget->onExit();
|
||||||
}
|
}
|
||||||
m_widget = m_history.back();
|
m_widget = m_history.back();
|
||||||
@@ -107,14 +109,14 @@ void popScreen()
|
|||||||
|
|
||||||
static void init_ui(void)
|
static void init_ui(void)
|
||||||
{
|
{
|
||||||
m_persistenceManager = std::make_shared<PersistenceManager>();
|
persistence_manager_init(&g_persistence_manager, "config");
|
||||||
options = {
|
options = {
|
||||||
.u8g2 = &u8g2,
|
.u8g2 = &u8g2,
|
||||||
.setScreen = [](const std::shared_ptr<Widget> &screen) { setScreen(screen); },
|
.setScreen = [](const std::shared_ptr<Widget> &screen) { setScreen(screen); },
|
||||||
.pushScreen = [](const std::shared_ptr<Widget> &screen) { pushScreen(screen); },
|
.pushScreen = [](const std::shared_ptr<Widget> &screen) { pushScreen(screen); },
|
||||||
.popScreen = []() { popScreen(); },
|
.popScreen = []() { popScreen(); },
|
||||||
.onButtonClicked = nullptr,
|
.onButtonClicked = nullptr,
|
||||||
.persistenceManager = m_persistenceManager,
|
.persistenceManager = &g_persistence_manager,
|
||||||
};
|
};
|
||||||
m_widget = std::make_shared<SplashScreen>(&options);
|
m_widget = std::make_shared<SplashScreen>(&options);
|
||||||
m_inactivityTracker = std::make_unique<InactivityTracker>(60000, []() {
|
m_inactivityTracker = std::make_unique<InactivityTracker>(60000, []() {
|
||||||
@@ -127,6 +129,17 @@ static void init_ui(void)
|
|||||||
u8g2_SendBuffer(&u8g2);
|
u8g2_SendBuffer(&u8g2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void on_message_received(const message_t *msg)
|
||||||
|
{
|
||||||
|
if (msg && msg->type == MESSAGE_TYPE_SETTINGS &&
|
||||||
|
(std::strcmp(msg->data.settings.key, "light_active") == 0 ||
|
||||||
|
std::strcmp(msg->data.settings.key, "light_variant") == 0 ||
|
||||||
|
std::strcmp(msg->data.settings.key, "light_mode") == 0))
|
||||||
|
{
|
||||||
|
start_simulation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static void handle_button(uint8_t button)
|
static void handle_button(uint8_t button)
|
||||||
{
|
{
|
||||||
m_inactivityTracker->reset();
|
m_inactivityTracker->reset();
|
||||||
@@ -184,14 +197,65 @@ void app_task(void *args)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize display so that info can be shown
|
||||||
setup_screen();
|
setup_screen();
|
||||||
|
|
||||||
|
// Check BACK button and delete settings if necessary (with countdown)
|
||||||
|
gpio_config_t io_conf = {};
|
||||||
|
io_conf.intr_type = GPIO_INTR_DISABLE;
|
||||||
|
io_conf.mode = GPIO_MODE_INPUT;
|
||||||
|
io_conf.pin_bit_mask = (1ULL << BUTTON_BACK);
|
||||||
|
io_conf.pull_down_en = GPIO_PULLDOWN_DISABLE;
|
||||||
|
io_conf.pull_up_en = GPIO_PULLUP_ENABLE;
|
||||||
|
gpio_config(&io_conf);
|
||||||
|
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(10));
|
||||||
|
if (gpio_get_level(BUTTON_BACK) == 0)
|
||||||
|
{
|
||||||
|
u8g2_SetFont(&u8g2, u8g2_font_ncenB08_tr);
|
||||||
|
for (int i = 5; i > 0; --i)
|
||||||
|
{
|
||||||
|
u8g2_ClearBuffer(&u8g2);
|
||||||
|
u8g2_DrawStr(&u8g2, 5, 20, "BACK gedrueckt!");
|
||||||
|
u8g2_DrawStr(&u8g2, 5, 35, "Halte fuer Reset...");
|
||||||
|
char buf[32];
|
||||||
|
snprintf(buf, sizeof(buf), "Loesche in %d s", i);
|
||||||
|
u8g2_DrawStr(&u8g2, 5, 55, buf);
|
||||||
|
u8g2_SendBuffer(&u8g2);
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(1000));
|
||||||
|
if (gpio_get_level(BUTTON_BACK) != 0)
|
||||||
|
{
|
||||||
|
// Button released, abort
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (i == 1)
|
||||||
|
{
|
||||||
|
// After 5 seconds still pressed: perform factory reset
|
||||||
|
u8g2_ClearBuffer(&u8g2);
|
||||||
|
u8g2_DrawStr(&u8g2, 5, 30, "Alle Einstellungen ");
|
||||||
|
u8g2_DrawStr(&u8g2, 5, 45, "werden geloescht...");
|
||||||
|
u8g2_SendBuffer(&u8g2);
|
||||||
|
persistence_manager_factory_reset();
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(1000));
|
||||||
|
u8g2_ClearBuffer(&u8g2);
|
||||||
|
u8g2_DrawStr(&u8g2, 5, 35, "Fertig. Neustart...");
|
||||||
|
u8g2_SendBuffer(&u8g2);
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(1000));
|
||||||
|
esp_restart();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message_manager_init();
|
||||||
|
|
||||||
setup_buttons();
|
setup_buttons();
|
||||||
init_ui();
|
init_ui();
|
||||||
|
|
||||||
#if CONFIG_WIFI_ENABLED
|
|
||||||
wifi_manager_init();
|
wifi_manager_init();
|
||||||
analytics_init();
|
|
||||||
#endif
|
mqtt_client_start();
|
||||||
|
|
||||||
|
message_manager_register_listener(on_message_received);
|
||||||
|
|
||||||
start_simulation();
|
start_simulation();
|
||||||
|
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
#include "freertos/FreeRTOS.h"
|
#include "freertos/FreeRTOS.h"
|
||||||
#include "freertos/task.h"
|
#include "freertos/task.h"
|
||||||
|
|
||||||
#include "u8g2_esp32_hal.h"
|
#include "hal/u8g2_esp32_hal.h"
|
||||||
|
|
||||||
static const char *TAG = "u8g2_hal";
|
static const char *TAG = "u8g2_hal";
|
||||||
static const unsigned int I2C_TIMEOUT_MS = 1000;
|
static const unsigned int I2C_TIMEOUT_MS = 1000;
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
#include "app_task.h"
|
#include "app_task.h"
|
||||||
#include "color.h"
|
#include "color.h"
|
||||||
#include "hal_esp32/PersistenceManager.h"
|
|
||||||
#include "led_status.h"
|
#include "led_status.h"
|
||||||
#include "led_strip_ws2812.h"
|
#include "led_strip_ws2812.h"
|
||||||
|
#include "persistence_manager.h"
|
||||||
#include "wifi_manager.h"
|
#include "wifi_manager.h"
|
||||||
#include <ble_manager.h>
|
#include <ble_manager.h>
|
||||||
#include <esp_event.h>
|
#include <esp_event.h>
|
||||||
@@ -24,8 +24,9 @@ void app_main(void)
|
|||||||
ESP_ERROR_CHECK(nvs_flash_init());
|
ESP_ERROR_CHECK(nvs_flash_init());
|
||||||
}
|
}
|
||||||
|
|
||||||
auto persistence = PersistenceManager();
|
persistence_manager_t persistence;
|
||||||
persistence.Load();
|
persistence_manager_init(&persistence, "config");
|
||||||
|
persistence_manager_load(&persistence);
|
||||||
|
|
||||||
led_status_init(CONFIG_STATUS_WLED_PIN);
|
led_status_init(CONFIG_STATUS_WLED_PIN);
|
||||||
|
|
||||||
@@ -42,3 +42,6 @@ CONFIG_SPIRAM=y
|
|||||||
# SPI RAM config
|
# SPI RAM config
|
||||||
CONFIG_SPIRAM_SPEED=80
|
CONFIG_SPIRAM_SPEED=80
|
||||||
CONFIG_SPIRAM_USE_CAPS_ALLOC=y
|
CONFIG_SPIRAM_USE_CAPS_ALLOC=y
|
||||||
|
|
||||||
|
# HTTP Server WebSocket Support
|
||||||
|
CONFIG_HTTPD_WS_SUPPORT=y
|
||||||
|
|||||||
120
firmware/storage/www/captive.html
Normal file
120
firmware/storage/www/captive.html
Normal 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>
|
||||||
217
firmware/storage/www/css/captive.css
Normal file
217
firmware/storage/www/css/captive.css
Normal 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;
|
||||||
|
}
|
||||||
1334
firmware/storage/www/css/index.css
Normal file
1334
firmware/storage/www/css/index.css
Normal file
File diff suppressed because it is too large
Load Diff
357
firmware/storage/www/css/shared.css
Normal file
357
firmware/storage/www/css/shared.css
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
1574
firmware/storage/www/css/style.css
Normal file
1574
firmware/storage/www/css/style.css
Normal file
File diff suppressed because it is too large
Load Diff
1
firmware/storage/www/favicon.svg
Normal file
1
firmware/storage/www/favicon.svg
Normal 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 |
475
firmware/storage/www/index.html
Normal file
475
firmware/storage/www/index.html
Normal 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>
|
||||||
65
firmware/storage/www/js/app.js
Normal file
65
firmware/storage/www/js/app.js
Normal 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
94
firmware/storage/www/js/capabilities.js
Normal file
94
firmware/storage/www/js/capabilities.js
Normal 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 };
|
||||||
|
}
|
||||||
231
firmware/storage/www/js/devices.js
Normal file
231
firmware/storage/www/js/devices.js
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
491
firmware/storage/www/js/i18n.js
Normal file
491
firmware/storage/www/js/i18n.js
Normal 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();
|
||||||
|
}
|
||||||
212
firmware/storage/www/js/light.js
Normal file
212
firmware/storage/www/js/light.js
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
330
firmware/storage/www/js/scenes.js
Normal file
330
firmware/storage/www/js/scenes.js
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
208
firmware/storage/www/js/schema.js
Normal file
208
firmware/storage/www/js/schema.js
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
53
firmware/storage/www/js/ui.js
Normal file
53
firmware/storage/www/js/ui.js
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
// Theme management
|
||||||
|
function initTheme() {
|
||||||
|
const savedTheme = localStorage.getItem('theme') || 'dark';
|
||||||
|
setTheme(savedTheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTheme(theme) {
|
||||||
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
|
localStorage.setItem('theme', theme);
|
||||||
|
|
||||||
|
const icon = document.getElementById('theme-icon');
|
||||||
|
const label = document.getElementById('theme-label');
|
||||||
|
const metaTheme = document.querySelector('meta[name="theme-color"]');
|
||||||
|
|
||||||
|
if (theme === 'light') {
|
||||||
|
icon.textContent = '☀️';
|
||||||
|
label.textContent = 'Light';
|
||||||
|
metaTheme.content = '#f0f2f5';
|
||||||
|
} else {
|
||||||
|
icon.textContent = '🌙';
|
||||||
|
label.textContent = 'Dark';
|
||||||
|
metaTheme.content = '#1a1a2e';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTheme() {
|
||||||
|
const current = document.documentElement.getAttribute('data-theme') || 'dark';
|
||||||
|
setTheme(current === 'dark' ? 'light' : 'dark');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab switching
|
||||||
|
function switchTab(tabName) {
|
||||||
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||||||
|
|
||||||
|
document.querySelector(`.tab[onclick="switchTab('${tabName}')"]`).classList.add('active');
|
||||||
|
document.getElementById(`tab-${tabName}`).classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sub-tab switching
|
||||||
|
function switchSubTab(subTabName) {
|
||||||
|
document.querySelectorAll('.sub-tab').forEach(t => t.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.sub-tab-content').forEach(c => c.classList.remove('active'));
|
||||||
|
|
||||||
|
document.querySelector(`.sub-tab[onclick="switchSubTab('${subTabName}')"]`).classList.add('active');
|
||||||
|
document.getElementById(`subtab-${subTabName}`).classList.add('active');
|
||||||
|
|
||||||
|
if (subTabName === 'schema' && typeof schemaData !== 'undefined' && schemaData.length === 0) {
|
||||||
|
loadSchema();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: showStatus is defined in wifi-shared.js (loaded first)
|
||||||
152
firmware/storage/www/js/websocket.js
Normal file
152
firmware/storage/www/js/websocket.js
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
// WebSocket connection
|
||||||
|
function initWebSocket() {
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const wsUrl = `${protocol}//${window.location.host}/ws`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
ws = new WebSocket(wsUrl);
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
console.log('WebSocket connected');
|
||||||
|
clearTimeout(wsReconnectTimer);
|
||||||
|
// Request initial status
|
||||||
|
ws.send(JSON.stringify({ type: 'getStatus' }));
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
handleWebSocketMessage(data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('WebSocket message error:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
console.log('WebSocket disconnected, reconnecting in 3s...');
|
||||||
|
ws = null;
|
||||||
|
wsReconnectTimer = setTimeout(initWebSocket, 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = (error) => {
|
||||||
|
console.error('WebSocket error:', error);
|
||||||
|
ws.close();
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.log('WebSocket not available, using demo mode');
|
||||||
|
initDemoMode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWebSocketMessage(data) {
|
||||||
|
switch (data.type) {
|
||||||
|
case 'status':
|
||||||
|
updateStatusFromData(data);
|
||||||
|
break;
|
||||||
|
case 'color':
|
||||||
|
updateColorPreview(data.r, data.g, data.b);
|
||||||
|
break;
|
||||||
|
case 'wifi':
|
||||||
|
updateWifiStatus(data);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStatusFromData(status) {
|
||||||
|
if (status.on !== undefined) {
|
||||||
|
lightOn = status.on;
|
||||||
|
updateLightToggle();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.mode) {
|
||||||
|
currentMode = status.mode;
|
||||||
|
updateModeButtons();
|
||||||
|
updateSimulationOptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.schema) {
|
||||||
|
const activeSchemaEl = document.getElementById('active-schema');
|
||||||
|
if (activeSchemaEl) {
|
||||||
|
activeSchemaEl.value = status.schema;
|
||||||
|
}
|
||||||
|
const schemaNames = {
|
||||||
|
'schema_01.csv': 'Schema 1',
|
||||||
|
'schema_02.csv': 'Schema 2',
|
||||||
|
'schema_03.csv': 'Schema 3'
|
||||||
|
};
|
||||||
|
const currentSchemaEl = document.getElementById('current-schema');
|
||||||
|
if (currentSchemaEl) {
|
||||||
|
currentSchemaEl.textContent = schemaNames[status.schema] || status.schema;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.color) {
|
||||||
|
updateColorPreview(status.color.r, status.color.g, status.color.b);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update clock/time
|
||||||
|
if (status.clock) {
|
||||||
|
const clockEl = document.getElementById('current-clock');
|
||||||
|
if (clockEl) {
|
||||||
|
clockEl.textContent = status.clock + ' ' + (typeof t === 'function' ? t('clock.suffix') : '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateColorPreview(r, g, b) {
|
||||||
|
const colorPreview = document.getElementById('current-color');
|
||||||
|
colorPreview.style.background = `rgb(${r}, ${g}, ${b})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateWifiStatus(status) {
|
||||||
|
document.getElementById('conn-status').textContent = status.connected ? '✅ Verbunden' : '❌ Nicht verbunden';
|
||||||
|
document.getElementById('conn-ip').textContent = status.ip || '-';
|
||||||
|
document.getElementById('conn-rssi').textContent = status.rssi ? `${status.rssi} dBm` : '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send via WebSocket
|
||||||
|
function wsSend(data) {
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify(data));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Demo mode for local testing
|
||||||
|
function initDemoMode() {
|
||||||
|
updateSimulationOptions();
|
||||||
|
updateColorPreview(255, 240, 220);
|
||||||
|
|
||||||
|
// Simulate color changes in demo mode
|
||||||
|
let hue = 0;
|
||||||
|
setInterval(() => {
|
||||||
|
if (!ws) {
|
||||||
|
hue = (hue + 1) % 360;
|
||||||
|
const rgb = hslToRgb(hue / 360, 0.7, 0.6);
|
||||||
|
updateColorPreview(rgb.r, rgb.g, rgb.b);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hslToRgb(h, s, l) {
|
||||||
|
let r, g, b;
|
||||||
|
if (s === 0) {
|
||||||
|
r = g = b = l;
|
||||||
|
} else {
|
||||||
|
const hue2rgb = (p, q, t) => {
|
||||||
|
if (t < 0) t += 1;
|
||||||
|
if (t > 1) t -= 1;
|
||||||
|
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||||
|
if (t < 1 / 2) return q;
|
||||||
|
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||||
|
return p;
|
||||||
|
};
|
||||||
|
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||||
|
const p = 2 * l - q;
|
||||||
|
r = hue2rgb(p, q, h + 1 / 3);
|
||||||
|
g = hue2rgb(p, q, h);
|
||||||
|
b = hue2rgb(p, q, h - 1 / 3);
|
||||||
|
}
|
||||||
|
return { r: Math.round(r * 255), g: Math.round(g * 255), b: Math.round(b * 255) };
|
||||||
|
}
|
||||||
271
firmware/storage/www/js/wifi-shared.js
Normal file
271
firmware/storage/www/js/wifi-shared.js
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
/**
|
||||||
|
* Passwortfeld sichtbar/unsichtbar schalten (shared)
|
||||||
|
*/
|
||||||
|
function togglePassword() {
|
||||||
|
const input = document.getElementById('password');
|
||||||
|
const btn = document.getElementById('password-btn');
|
||||||
|
if (!input || !btn) return;
|
||||||
|
if (input.type === 'password') {
|
||||||
|
input.type = 'text';
|
||||||
|
btn.textContent = '🙈';
|
||||||
|
} else {
|
||||||
|
input.type = 'password';
|
||||||
|
btn.textContent = '👁️';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Shared WiFi configuration functions
|
||||||
|
// Used by both captive.html and index.html
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show status message
|
||||||
|
* @param {string} elementId - ID of the status element
|
||||||
|
* @param {string} message - Message to display
|
||||||
|
* @param {string} type - Type: 'success', 'error', or 'info'
|
||||||
|
*/
|
||||||
|
function showStatus(elementId, message, type) {
|
||||||
|
const status = document.getElementById(elementId);
|
||||||
|
if (!status) return;
|
||||||
|
|
||||||
|
status.textContent = message;
|
||||||
|
status.className = `status ${type}`;
|
||||||
|
|
||||||
|
if (type !== 'info') {
|
||||||
|
setTimeout(() => {
|
||||||
|
status.className = 'status';
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan for available WiFi networks
|
||||||
|
*/
|
||||||
|
async function scanNetworks() {
|
||||||
|
const loading = document.getElementById('loading');
|
||||||
|
const networkList = document.getElementById('network-list');
|
||||||
|
const select = document.getElementById('available-networks');
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
if (loading) {
|
||||||
|
loading.classList.add('active');
|
||||||
|
}
|
||||||
|
if (networkList) {
|
||||||
|
networkList.style.display = 'none';
|
||||||
|
networkList.innerHTML = '';
|
||||||
|
}
|
||||||
|
if (select) {
|
||||||
|
select.innerHTML = `<option value="">${t('wifi.searching')}</option>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/wifi/scan');
|
||||||
|
if (!response.ok) {
|
||||||
|
// Fehlerhafte API-Antwort, aber ESP32 ist erreichbar
|
||||||
|
const errorText = await response.text();
|
||||||
|
showStatus('wifi-status', t('wifi.error.scan') + ': ' + errorText, 'error');
|
||||||
|
if (loading) loading.classList.remove('active');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const networks = await response.json();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
loading.classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by signal strength
|
||||||
|
networks.sort((a, b) => b.rssi - a.rssi);
|
||||||
|
|
||||||
|
// Render for captive portal (network list)
|
||||||
|
if (networkList) {
|
||||||
|
if (networks.length === 0) {
|
||||||
|
networkList.innerHTML = `<div class="network-item"><span class="network-name">${t('devices.unpaired.empty')}</span></div>`;
|
||||||
|
} else {
|
||||||
|
networks.forEach(network => {
|
||||||
|
const signalIcon = getSignalIcon(network.rssi);
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'network-item';
|
||||||
|
item.onclick = () => selectNetwork(network.ssid, item);
|
||||||
|
item.innerHTML = `
|
||||||
|
<span class="network-name">
|
||||||
|
<span class="signal-icon">${signalIcon}</span>
|
||||||
|
${escapeHtml(network.ssid)}
|
||||||
|
</span>
|
||||||
|
<span class="network-signal">${network.rssi} dBm</span>
|
||||||
|
`;
|
||||||
|
networkList.appendChild(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
networkList.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render for main interface (select dropdown)
|
||||||
|
if (select) {
|
||||||
|
select.innerHTML = `<option value="">${t('wifi.scan.hint')}</option>`;
|
||||||
|
networks.forEach(network => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = network.ssid;
|
||||||
|
option.textContent = `${network.ssid} (${network.rssi} dBm)`;
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
// Note: onchange handler is set inline in HTML
|
||||||
|
}
|
||||||
|
|
||||||
|
showStatus('wifi-status', t('wifi.networks.found', { count: networks.length }), 'success');
|
||||||
|
} catch (error) {
|
||||||
|
if (loading) {
|
||||||
|
loading.classList.remove('active');
|
||||||
|
}
|
||||||
|
// Nur bei Netzwerkfehlern Demo-Daten anzeigen
|
||||||
|
if (error instanceof TypeError) {
|
||||||
|
const demoNetworks = [
|
||||||
|
{ ssid: 'Demo-Netzwerk', rssi: -45 },
|
||||||
|
{ ssid: 'Gast-WLAN', rssi: -67 },
|
||||||
|
{ ssid: 'Nachbar-WiFi', rssi: -82 }
|
||||||
|
];
|
||||||
|
if (networkList) {
|
||||||
|
demoNetworks.forEach(network => {
|
||||||
|
const signalIcon = getSignalIcon(network.rssi);
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'network-item';
|
||||||
|
item.onclick = () => selectNetwork(network.ssid, item);
|
||||||
|
item.innerHTML = `
|
||||||
|
<span class="network-name">
|
||||||
|
<span class="signal-icon">${signalIcon}</span>
|
||||||
|
${escapeHtml(network.ssid)}
|
||||||
|
</span>
|
||||||
|
<span class="network-signal">${network.rssi} dBm</span>
|
||||||
|
`;
|
||||||
|
networkList.appendChild(item);
|
||||||
|
});
|
||||||
|
networkList.style.display = 'block';
|
||||||
|
}
|
||||||
|
if (select) {
|
||||||
|
select.innerHTML = `<option value="">${t('wifi.scan.hint')}</option>`;
|
||||||
|
demoNetworks.forEach(network => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = network.ssid;
|
||||||
|
option.textContent = `${network.ssid} (${network.rssi} dBm)`;
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
showStatus('wifi-status', 'Demo: ' + t('wifi.networks.found', { count: demoNetworks.length }), 'info');
|
||||||
|
} else {
|
||||||
|
showStatus('wifi-status', t('wifi.error.scan') + ': ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select a network from the list (captive portal)
|
||||||
|
* @param {string} ssid - Network SSID
|
||||||
|
* @param {HTMLElement} element - Clicked element
|
||||||
|
*/
|
||||||
|
function selectNetwork(ssid, element) {
|
||||||
|
// Remove previous selection
|
||||||
|
document.querySelectorAll('.network-item').forEach(item => {
|
||||||
|
item.classList.remove('selected');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add selection to clicked item
|
||||||
|
element.classList.add('selected');
|
||||||
|
|
||||||
|
// Fill in SSID
|
||||||
|
document.getElementById('ssid').value = ssid;
|
||||||
|
|
||||||
|
// Focus password field
|
||||||
|
document.getElementById('password').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get signal strength icon
|
||||||
|
* @param {number} rssi - Signal strength in dBm
|
||||||
|
* @returns {string} Emoji icon
|
||||||
|
*/
|
||||||
|
function getSignalIcon(rssi) {
|
||||||
|
if (rssi >= -50) return '📶';
|
||||||
|
if (rssi >= -60) return '📶';
|
||||||
|
if (rssi >= -70) return '📶';
|
||||||
|
return '📶';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape HTML to prevent XSS
|
||||||
|
* @param {string} text - Text to escape
|
||||||
|
* @returns {string} Escaped text
|
||||||
|
*/
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save WiFi configuration
|
||||||
|
*/
|
||||||
|
async function saveWifi() {
|
||||||
|
const ssid = document.getElementById('ssid').value.trim();
|
||||||
|
const password = document.getElementById('password').value;
|
||||||
|
|
||||||
|
if (!ssid) {
|
||||||
|
showStatus('wifi-status', t('wifi.error.ssid'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showStatus('wifi-status', t('common.loading'), 'info');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/wifi/config', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ssid, password })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
showStatus('wifi-status', t('wifi.saved'), 'success');
|
||||||
|
|
||||||
|
// Show countdown for captive portal
|
||||||
|
if (document.querySelector('.info-box')) {
|
||||||
|
let countdown = 10;
|
||||||
|
const countdownInterval = setInterval(() => {
|
||||||
|
showStatus('wifi-status', t('captive.connecting', { seconds: countdown }), 'success');
|
||||||
|
countdown--;
|
||||||
|
if (countdown < 0) {
|
||||||
|
clearInterval(countdownInterval);
|
||||||
|
showStatus('wifi-status', t('captive.done'), 'success');
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const error = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(error.error || t('wifi.error.save'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message.includes('fetch')) {
|
||||||
|
// Demo mode
|
||||||
|
showStatus('wifi-status', 'Demo: ' + t('wifi.saved'), 'success');
|
||||||
|
} else {
|
||||||
|
showStatus('wifi-status', t('error') + ': ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update connection status (for main interface)
|
||||||
|
*/
|
||||||
|
async function updateConnectionStatus() {
|
||||||
|
const connStatus = document.getElementById('conn-status');
|
||||||
|
const connIp = document.getElementById('conn-ip');
|
||||||
|
const connRssi = document.getElementById('conn-rssi');
|
||||||
|
|
||||||
|
if (!connStatus) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/wifi/status');
|
||||||
|
const status = await response.json();
|
||||||
|
|
||||||
|
connStatus.textContent = status.connected ? t('wifi.connected') : t('wifi.disconnected');
|
||||||
|
if (connIp) connIp.textContent = status.ip || '-';
|
||||||
|
if (connRssi) connRssi.textContent = status.rssi ? `${status.rssi} dBm` : '-';
|
||||||
|
} catch (error) {
|
||||||
|
connStatus.textContent = t('wifi.unavailable');
|
||||||
|
}
|
||||||
|
}
|
||||||
172
firmware/storage/www/js/wled.js
Normal file
172
firmware/storage/www/js/wled.js
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
// LED Configuration Module
|
||||||
|
// Manages LED segments and configuration
|
||||||
|
|
||||||
|
let wledConfig = {
|
||||||
|
segments: []
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize WLED module
|
||||||
|
*/
|
||||||
|
function initWled() {
|
||||||
|
loadWledConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load WLED configuration from server
|
||||||
|
*/
|
||||||
|
async function loadWledConfig() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/wled/config');
|
||||||
|
if (response.ok) {
|
||||||
|
wledConfig = await response.json();
|
||||||
|
renderWledSegments();
|
||||||
|
showStatus('wled-status', t('wled.loaded'), 'success');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Using default LED config');
|
||||||
|
wledConfig = { segments: [] };
|
||||||
|
renderWledSegments();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render WLED segments list
|
||||||
|
*/
|
||||||
|
function renderWledSegments() {
|
||||||
|
const list = document.getElementById('wled-segments-list');
|
||||||
|
const emptyState = document.getElementById('no-wled-segments');
|
||||||
|
|
||||||
|
if (!list) return;
|
||||||
|
|
||||||
|
// Clear existing segments (keep empty state)
|
||||||
|
const existingItems = list.querySelectorAll('.wled-segment-item');
|
||||||
|
existingItems.forEach(item => item.remove());
|
||||||
|
|
||||||
|
if (wledConfig.segments.length === 0) {
|
||||||
|
if (emptyState) emptyState.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (emptyState) emptyState.style.display = 'none';
|
||||||
|
|
||||||
|
wledConfig.segments.forEach((segment, index) => {
|
||||||
|
const item = createSegmentElement(segment, index);
|
||||||
|
list.insertBefore(item, emptyState);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create segment DOM element
|
||||||
|
* @param {object} segment - Segment data
|
||||||
|
* @param {number} index - Segment index
|
||||||
|
* @returns {HTMLElement} Segment element
|
||||||
|
*/
|
||||||
|
function createSegmentElement(segment, index) {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'wled-segment-item';
|
||||||
|
item.dataset.index = index;
|
||||||
|
|
||||||
|
item.innerHTML = `
|
||||||
|
<div class="segment-name-field">
|
||||||
|
<label class="segment-number">${t('wled.segment.name', { num: index + 1 })}</label>
|
||||||
|
<input type="text" class="segment-name-input" value="${escapeHtml(segment.name || '')}"
|
||||||
|
placeholder="${t('wled.segment.name', { num: index + 1 })}"
|
||||||
|
onchange="updateSegment(${index}, 'name', this.value)">
|
||||||
|
</div>
|
||||||
|
<div class="segment-field">
|
||||||
|
<label data-i18n="wled.segment.start">${t('wled.segment.start')}</label>
|
||||||
|
<input type="number" min="0" value="${segment.start || 0}"
|
||||||
|
onchange="updateSegment(${index}, 'start', parseInt(this.value))">
|
||||||
|
</div>
|
||||||
|
<div class="segment-field">
|
||||||
|
<label data-i18n="wled.segment.leds">${t('wled.segment.leds')}</label>
|
||||||
|
<input type="number" min="1" value="${segment.leds || 1}"
|
||||||
|
onchange="updateSegment(${index}, 'leds', parseInt(this.value))">
|
||||||
|
</div>
|
||||||
|
<button class="segment-remove-btn" onclick="removeWledSegment(${index})" title="${t('wled.segment.remove')}">
|
||||||
|
🗑️
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new WLED segment
|
||||||
|
*/
|
||||||
|
function addWledSegment() {
|
||||||
|
// Calculate next start position
|
||||||
|
let nextStart = 0;
|
||||||
|
if (wledConfig.segments.length > 0) {
|
||||||
|
const lastSegment = wledConfig.segments[wledConfig.segments.length - 1];
|
||||||
|
nextStart = (lastSegment.start || 0) + (lastSegment.leds || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
wledConfig.segments.push({
|
||||||
|
name: '',
|
||||||
|
start: nextStart,
|
||||||
|
leds: 10
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWledSegments();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a segment property
|
||||||
|
* @param {number} index - Segment index
|
||||||
|
* @param {string} property - Property name
|
||||||
|
* @param {*} value - New value
|
||||||
|
*/
|
||||||
|
function updateSegment(index, property, value) {
|
||||||
|
if (index >= 0 && index < wledConfig.segments.length) {
|
||||||
|
wledConfig.segments[index][property] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a WLED segment
|
||||||
|
* @param {number} index - Segment index to remove
|
||||||
|
*/
|
||||||
|
function removeWledSegment(index) {
|
||||||
|
if (index >= 0 && index < wledConfig.segments.length) {
|
||||||
|
wledConfig.segments.splice(index, 1);
|
||||||
|
renderWledSegments();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save WLED configuration to server
|
||||||
|
*/
|
||||||
|
async function saveWledConfig() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/wled/config', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(wledConfig)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
showStatus('wled-status', t('wled.saved'), 'success');
|
||||||
|
} else {
|
||||||
|
throw new Error('Save failed');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving WLED config:', error);
|
||||||
|
showStatus('wled-status', t('wled.error.save'), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to escape HTML
|
||||||
|
* @param {string} text - Text to escape
|
||||||
|
* @returns {string} Escaped text
|
||||||
|
*/
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize when DOM is ready
|
||||||
|
document.addEventListener('DOMContentLoaded', initWled);
|
||||||
12
firmware/website/.run/dev.run.xml
Normal file
12
firmware/website/.run/dev.run.xml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="dev" type="js.build_tools.npm" nameIsGenerated="true">
|
||||||
|
<package-json value="$PROJECT_DIR$/package.json" />
|
||||||
|
<command value="run" />
|
||||||
|
<scripts>
|
||||||
|
<script value="dev" />
|
||||||
|
</scripts>
|
||||||
|
<node-interpreter value="project" />
|
||||||
|
<envs />
|
||||||
|
<method v="2" />
|
||||||
|
</configuration>
|
||||||
|
</component>
|
||||||
8
firmware/website/.vite/deps/_metadata.json
Normal file
8
firmware/website/.vite/deps/_metadata.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"hash": "15486339",
|
||||||
|
"configHash": "5ec1f82b",
|
||||||
|
"lockfileHash": "2bc40369",
|
||||||
|
"browserHash": "9efd6930",
|
||||||
|
"optimized": {},
|
||||||
|
"chunks": {}
|
||||||
|
}
|
||||||
3
firmware/website/.vite/deps/package.json
Normal file
3
firmware/website/.vite/deps/package.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
19
firmware/website/index.html
Normal file
19
firmware/website/index.html
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
|
<meta name="theme-color" content="#1a1a2e">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||||
|
<title>System Control</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
32
firmware/website/jsconfig.json
Normal file
32
firmware/website/jsconfig.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
/**
|
||||||
|
* svelte-preprocess cannot figure out whether you have
|
||||||
|
* a value or a type, so tell TypeScript to enforce using
|
||||||
|
* `import type` instead of `import` for Types.
|
||||||
|
*/
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
/**
|
||||||
|
* To have warnings / errors of the Svelte compiler at the
|
||||||
|
* correct position, enable source maps by default.
|
||||||
|
*/
|
||||||
|
"sourceMap": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
/**
|
||||||
|
* Typecheck JS in `.svelte` and `.js` files by default.
|
||||||
|
* Disable this if you'd like to use dynamic types.
|
||||||
|
*/
|
||||||
|
"checkJs": true
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Use global.d.ts instead of compilerOptions.types
|
||||||
|
* to avoid limiting type declarations.
|
||||||
|
*/
|
||||||
|
"include": ["src/**/*.d.ts", "src/**/*.js", "src/**/*.svelte"]
|
||||||
|
}
|
||||||
1560
firmware/website/package-lock.json
generated
Normal file
1560
firmware/website/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
firmware/website/package.json
Normal file
20
firmware/website/package.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "learn-svelte",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^6.1.1",
|
||||||
|
"svelte": "^5.38.1",
|
||||||
|
"vite": "^7.1.2",
|
||||||
|
"vite-plugin-compression": "^0.5.1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fontsource/atkinson-hyperlegible": "^5.2.6",
|
||||||
|
"@picocss/pico": "^2.1.1",
|
||||||
|
"gsap": "^3.13.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
firmware/website/public/favicon.svg
Normal file
1
firmware/website/public/favicon.svg
Normal 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 |
27
firmware/website/src/App.svelte
Normal file
27
firmware/website/src/App.svelte
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Header from "./compoents/Header.svelte";
|
||||||
|
import Index from "./Index.svelte";
|
||||||
|
import Captive from "./Captive.svelte";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { writable } from "svelte/store";
|
||||||
|
|
||||||
|
const isCaptive = writable(false);
|
||||||
|
|
||||||
|
function checkHash() {
|
||||||
|
isCaptive.set(window.location.hash === "#/captive");
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
checkHash();
|
||||||
|
window.addEventListener("hashchange", checkHash);
|
||||||
|
return () => window.removeEventListener("hashchange", checkHash);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
{#if $isCaptive}
|
||||||
|
<Captive />
|
||||||
|
{:else}
|
||||||
|
<Index />
|
||||||
|
{/if}
|
||||||
5
firmware/website/src/Captive.svelte
Normal file
5
firmware/website/src/Captive.svelte
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { t } from "./i18n/store";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h1>{$t("welcome")} - Captive Portal</h1>
|
||||||
5
firmware/website/src/Index.svelte
Normal file
5
firmware/website/src/Index.svelte
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { t } from "./i18n/store";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h1>{$t("welcome")}</h1>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user