Compare commits
45 Commits
016734a4db
...
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
|
|||
|
dfad7cfb76
|
|||
|
5d78572481
|
|||
|
9e9fb15f86
|
|||
|
e7af663bc3
|
|||
|
e81fc62645
|
|||
|
aa10eb55f4
|
|||
|
c259a3f2c8
|
|||
|
fa05783fb9
|
|||
|
8ba2a5be1d
|
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");
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ void analytics_init(void)
|
|||||||
.log_type = ESP_DIAG_LOG_TYPE_ERROR | ESP_DIAG_LOG_TYPE_EVENT | ESP_DIAG_LOG_TYPE_WARNING,
|
.log_type = ESP_DIAG_LOG_TYPE_ERROR | ESP_DIAG_LOG_TYPE_EVENT | ESP_DIAG_LOG_TYPE_WARNING,
|
||||||
.node_id = NULL,
|
.node_id = NULL,
|
||||||
.auth_key = insights_auth_key_start,
|
.auth_key = insights_auth_key_start,
|
||||||
.alloc_ext_ram = false,
|
.alloc_ext_ram = true,
|
||||||
};
|
};
|
||||||
|
|
||||||
esp_insights_init(&config);
|
esp_insights_init(&config);
|
||||||
|
|||||||
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,132 +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>
|
||||||
|
|
||||||
// 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 void event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data)
|
|
||||||
{
|
{
|
||||||
#if CONFIG_WIFI_ENABLED
|
led_behavior_t led_behavior = {
|
||||||
if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START)
|
.on_time_ms = 250,
|
||||||
{
|
.off_time_ms = 100,
|
||||||
led_behavior_t led0_behavior = {
|
.color = {.red = 50, .green = 50, .blue = 0},
|
||||||
.index = 0,
|
.index = 0,
|
||||||
.mode = LED_MODE_BLINK,
|
.mode = LED_MODE_BLINK,
|
||||||
.color = {.red = 50, .green = 50, .blue = 0},
|
|
||||||
.on_time_ms = 200,
|
|
||||||
.off_time_ms = 200,
|
|
||||||
};
|
};
|
||||||
led_status_set_behavior(led0_behavior);
|
led_status_set_behavior(led_behavior);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void wifi_event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data)
|
||||||
|
{
|
||||||
|
if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START)
|
||||||
|
{
|
||||||
|
ESP_LOGI(TAG, "WIFI_EVENT_STA_START: Connecting to AP...");
|
||||||
esp_wifi_connect();
|
esp_wifi_connect();
|
||||||
}
|
}
|
||||||
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...");
|
||||||
{
|
|
||||||
led_behavior_t led0_behavior = {
|
|
||||||
.index = 0,
|
|
||||||
.mode = LED_MODE_BLINK,
|
|
||||||
.color = {.red = 50, .green = 50, .blue = 0},
|
|
||||||
.on_time_ms = 200,
|
|
||||||
.off_time_ms = 200,
|
|
||||||
};
|
|
||||||
led_status_set_behavior(led0_behavior);
|
|
||||||
|
|
||||||
esp_wifi_connect();
|
|
||||||
s_retry_num++;
|
|
||||||
ESP_DIAG_EVENT(TAG, "Retrying to connect to the AP");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
led_behavior_t led0_behavior = {
|
|
||||||
.index = 0,
|
|
||||||
.mode = LED_MODE_BLINK,
|
|
||||||
.color = {.red = 50, .green = 0, .blue = 0},
|
|
||||||
.on_time_ms = 1000,
|
|
||||||
.off_time_ms = 500,
|
|
||||||
};
|
|
||||||
led_status_set_behavior(led0_behavior);
|
|
||||||
|
|
||||||
xEventGroupSetBits(s_wifi_event_group, WIFI_FAIL_BIT);
|
xEventGroupSetBits(s_wifi_event_group, WIFI_FAIL_BIT);
|
||||||
|
led_status_reconnect();
|
||||||
|
esp_wifi_connect();
|
||||||
}
|
}
|
||||||
else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP)
|
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, IP2STR(&event->ip_info.ip));
|
ESP_LOGI(TAG, "IP_EVENT_STA_GOT_IP: IP-Adresse erhalten: " IPSTR, IP2STR(&event->ip_info.ip));
|
||||||
s_retry_num = 0;
|
analytics_init();
|
||||||
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();
|
||||||
|
|
||||||
ESP_ERROR_CHECK(esp_netif_init());
|
ESP_ERROR_CHECK(esp_netif_init());
|
||||||
|
|
||||||
ESP_ERROR_CHECK(esp_event_loop_create_default());
|
ESP_ERROR_CHECK(esp_event_loop_create_default());
|
||||||
esp_netif_create_default_wifi_sta();
|
|
||||||
|
|
||||||
|
// Default WiFi Station
|
||||||
|
esp_netif_t *sta_netif = esp_netif_create_default_wifi_sta();
|
||||||
|
|
||||||
|
// Event Handler registrieren
|
||||||
|
ESP_ERROR_CHECK(esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &wifi_event_handler, NULL, NULL));
|
||||||
|
ESP_ERROR_CHECK(esp_event_handler_instance_register(IP_EVENT, ESP_EVENT_ANY_ID, &wifi_event_handler, NULL, NULL));
|
||||||
|
|
||||||
|
// Try to load stored WiFi configuration
|
||||||
|
persistence_manager_t pm;
|
||||||
|
char ssid[33] = {0};
|
||||||
|
char password[65] = {0};
|
||||||
|
bool have_ssid = false, have_password = false;
|
||||||
|
if (persistence_manager_init(&pm, "wifi_config") == ESP_OK)
|
||||||
|
{
|
||||||
|
persistence_manager_get_string(&pm, "ssid", ssid, sizeof(ssid), "");
|
||||||
|
persistence_manager_get_string(&pm, "password", password, sizeof(password), "");
|
||||||
|
have_ssid = strlen(ssid) > 0;
|
||||||
|
have_password = strlen(password) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (have_ssid && have_password)
|
||||||
|
{
|
||||||
|
led_status_reconnect();
|
||||||
|
|
||||||
|
ESP_LOGI(TAG, "Found WiFi configuration: SSID='%s'", ssid);
|
||||||
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
|
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
|
||||||
ESP_ERROR_CHECK(esp_wifi_init(&cfg));
|
ESP_ERROR_CHECK(esp_wifi_init(&cfg));
|
||||||
|
|
||||||
esp_event_handler_instance_t instance_any_id;
|
|
||||||
esp_event_handler_instance_t instance_got_ip;
|
|
||||||
ESP_ERROR_CHECK(
|
|
||||||
esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &event_handler, NULL, &instance_any_id));
|
|
||||||
ESP_ERROR_CHECK(
|
|
||||||
esp_event_handler_instance_register(IP_EVENT, IP_EVENT_STA_GOT_IP, &event_handler, NULL, &instance_got_ip));
|
|
||||||
|
|
||||||
wifi_config_t wifi_config = {
|
|
||||||
.sta =
|
|
||||||
{
|
|
||||||
.ssid = CONFIG_WIFI_SSID,
|
|
||||||
.password = CONFIG_WIFI_PASSWORD,
|
|
||||||
.threshold.authmode = WIFI_AUTH_WPA2_PSK,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
|
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_set_config(WIFI_IF_STA, &wifi_config));
|
||||||
ESP_ERROR_CHECK(esp_wifi_start());
|
ESP_ERROR_CHECK(esp_wifi_start());
|
||||||
|
|
||||||
ESP_DIAG_EVENT(TAG, "waiting for wifi connection...");
|
int retries = 0;
|
||||||
|
EventBits_t bits;
|
||||||
/* Waiting until either the connection is established (WIFI_CONNECTED_BIT) or
|
do
|
||||||
connection failed for the maximum number of retries (WIFI_FAIL_BIT). The bits are set by event_handler() */
|
{
|
||||||
EventBits_t bits =
|
ESP_LOGI(TAG, "Warte auf IP-Adresse (DHCP)...");
|
||||||
xEventGroupWaitBits(s_wifi_event_group, WIFI_CONNECTED_BIT | WIFI_FAIL_BIT, pdFALSE, pdFALSE, portMAX_DELAY);
|
bits = xEventGroupWaitBits(s_wifi_event_group, WIFI_CONNECTED_BIT | WIFI_FAIL_BIT, pdFALSE, pdFALSE,
|
||||||
|
10000 / portTICK_PERIOD_MS);
|
||||||
if (bits & WIFI_CONNECTED_BIT)
|
if (bits & WIFI_CONNECTED_BIT)
|
||||||
{
|
{
|
||||||
ESP_DIAG_EVENT(TAG, "Connected to AP SSID:%s", CONFIG_WIFI_SSID);
|
led_behavior_t led_behavior = {
|
||||||
|
.index = 0,
|
||||||
|
.color = {.red = 0, .green = 50, .blue = 0},
|
||||||
|
.mode = LED_MODE_SOLID,
|
||||||
|
};
|
||||||
|
led_status_set_behavior(led_behavior);
|
||||||
|
ESP_LOGI(TAG, "WiFi connection established successfully (mit IP)");
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
else if (bits & WIFI_FAIL_BIT)
|
retries++;
|
||||||
|
} while (!(bits & WIFI_CONNECTED_BIT) && retries < CONFIG_WIFI_CONNECT_RETRIES);
|
||||||
|
|
||||||
|
if (!(bits & WIFI_CONNECTED_BIT))
|
||||||
{
|
{
|
||||||
ESP_LOGE(TAG, "Failed to connect to SSID:%s", CONFIG_WIFI_SSID);
|
ESP_LOGW(TAG, "WiFi connection failed (keine IP?), switching to Access Point mode");
|
||||||
|
// AP-Netzwerkschnittstelle initialisieren, falls noch nicht geschehen
|
||||||
|
esp_netif_create_default_wifi_ap();
|
||||||
|
wifi_create_ap();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
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;
|
||||||
@@ -59,5 +59,37 @@ class MenuItem;
|
|||||||
*/
|
*/
|
||||||
typedef std::function<void(MenuItem, ButtonType)> ButtonCallback;
|
typedef std::function<void(MenuItem, ButtonType)> ButtonCallback;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @def IMPLEMENT_GET_NAME
|
||||||
|
* @brief Macro to implement getName() method using __FILE__
|
||||||
|
* @details Extracts the class name from the source filename automatically.
|
||||||
|
* Use this macro in .cpp files to implement Widget::getName().
|
||||||
|
* @param ClassName The class name for the method scope (e.g., MainMenu)
|
||||||
|
*/
|
||||||
|
#define IMPLEMENT_GET_NAME(ClassName) \
|
||||||
|
const char *ClassName::getName() const \
|
||||||
|
{ \
|
||||||
|
static const char *cachedName = nullptr; \
|
||||||
|
if (!cachedName) \
|
||||||
|
{ \
|
||||||
|
const char *file = __FILE__; \
|
||||||
|
const char *lastSlash = file; \
|
||||||
|
for (const char *p = file; *p; ++p) \
|
||||||
|
{ \
|
||||||
|
if (*p == '/' || *p == '\\') \
|
||||||
|
lastSlash = p + 1; \
|
||||||
|
} \
|
||||||
|
static char buffer[64]; \
|
||||||
|
size_t i = 0; \
|
||||||
|
for (; lastSlash[i] && lastSlash[i] != '.' && i < sizeof(buffer) - 1; ++i) \
|
||||||
|
{ \
|
||||||
|
buffer[i] = lastSlash[i]; \
|
||||||
|
} \
|
||||||
|
buffer[i] = '\0'; \
|
||||||
|
cachedName = buffer; \
|
||||||
|
} \
|
||||||
|
return cachedName; \
|
||||||
|
}
|
||||||
|
|
||||||
// Include MenuItem.h after the typedef to avoid circular dependency
|
// Include MenuItem.h after the typedef to avoid circular dependency
|
||||||
#include "data/MenuItem.h"
|
#include "data/MenuItem.h"
|
||||||
@@ -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
|
||||||
@@ -150,6 +153,19 @@ class Widget
|
|||||||
*/
|
*/
|
||||||
virtual void OnButtonClicked(ButtonType button);
|
virtual void OnButtonClicked(ButtonType button);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Returns the name of this widget for diagnostic purposes
|
||||||
|
* @return A string identifying the widget type
|
||||||
|
*
|
||||||
|
* @details This method returns a human-readable name for the widget which
|
||||||
|
* is used for logging and diagnostic events. Derived classes should
|
||||||
|
* override this method to return their specific screen/widget name.
|
||||||
|
*
|
||||||
|
* @note The base implementation returns "Widget". Override in derived classes
|
||||||
|
* to provide meaningful screen names for diagnostics.
|
||||||
|
*/
|
||||||
|
virtual const char *getName() const;
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
/**
|
/**
|
||||||
* @brief Pointer to the u8g2 display context used for rendering operations
|
* @brief Pointer to the u8g2 display context used for rendering operations
|
||||||
@@ -165,4 +181,8 @@ class Widget
|
|||||||
* the u8g2 context and assumes it is managed externally.
|
* 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);
|
||||||
};
|
};
|
||||||
@@ -24,6 +24,7 @@ class ClockScreenSaver final : public Widget
|
|||||||
void Update(uint64_t dt) override;
|
void Update(uint64_t dt) override;
|
||||||
void Render() override;
|
void Render() override;
|
||||||
void OnButtonClicked(ButtonType button) override;
|
void OnButtonClicked(ButtonType button) override;
|
||||||
|
const char *getName() const override;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
static constexpr int MOVE_INTERVAL = 50; // milliseconds between movements
|
static constexpr int MOVE_INTERVAL = 50; // milliseconds between movements
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ class ExternalDevices final : public Menu
|
|||||||
public:
|
public:
|
||||||
explicit ExternalDevices(menu_options_t *options);
|
explicit ExternalDevices(menu_options_t *options);
|
||||||
|
|
||||||
|
const char *getName() const override;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
void onButtonPressed(const MenuItem &menuItem, ButtonType button) override;
|
void onButtonPressed(const MenuItem &menuItem, ButtonType button) override;
|
||||||
menu_options_t *m_options;
|
menu_options_t *m_options;
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ class LightMenu final : public Menu
|
|||||||
*/
|
*/
|
||||||
explicit LightMenu(menu_options_t *options);
|
explicit LightMenu(menu_options_t *options);
|
||||||
|
|
||||||
|
const char *getName() const override;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
/**
|
/**
|
||||||
* @brief Handles button press events specific to light control menu items
|
* @brief Handles button press events specific to light control menu items
|
||||||
@@ -118,6 +120,8 @@ class LightMenu final : public Menu
|
|||||||
*/
|
*/
|
||||||
void onButtonPressed(const MenuItem &menuItem, ButtonType button) override;
|
void 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.
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ class MainMenu final : public Menu
|
|||||||
*/
|
*/
|
||||||
explicit MainMenu(menu_options_t *options);
|
explicit MainMenu(menu_options_t *options);
|
||||||
|
|
||||||
|
const char *getName() const override;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
/**
|
/**
|
||||||
* @brief Handles button press events specific to main menu items
|
* @brief Handles button press events specific to main menu items
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class ScreenSaver final : public Widget
|
|||||||
void Update(uint64_t dt) override;
|
void Update(uint64_t dt) override;
|
||||||
void Render() override;
|
void Render() override;
|
||||||
void OnButtonClicked(ButtonType button) override;
|
void OnButtonClicked(ButtonType button) override;
|
||||||
|
const char *getName() const override;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
*/
|
*/
|
||||||
class SettingsMenu final : public Menu
|
class SettingsMenu final : public Menu
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
/**
|
/**
|
||||||
* @brief Constructs the settings menu with the specified configuration
|
* @brief Constructs the settings menu with the specified configuration
|
||||||
* @param options Pointer to menu options configuration structure
|
* @param options Pointer to menu options configuration structure
|
||||||
@@ -74,4 +74,6 @@ public:
|
|||||||
* @see Menu::Menu for base class construction details
|
* @see Menu::Menu for base class construction details
|
||||||
*/
|
*/
|
||||||
explicit SettingsMenu(menu_options_t *options);
|
explicit SettingsMenu(menu_options_t *options);
|
||||||
|
|
||||||
|
const char *getName() const override;
|
||||||
};
|
};
|
||||||
@@ -151,6 +151,8 @@ class SplashScreen final : public Widget
|
|||||||
*/
|
*/
|
||||||
void Render() override;
|
void Render() override;
|
||||||
|
|
||||||
|
const char *getName() const override;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
/**
|
/**
|
||||||
* @brief Pointer to menu options configuration structure
|
* @brief Pointer to menu options configuration structure
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -31,3 +44,20 @@ void Widget::Render()
|
|||||||
void Widget::OnButtonClicked(ButtonType button)
|
void Widget::OnButtonClicked(ButtonType button)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const char *Widget::getName() const
|
||||||
|
{
|
||||||
|
return "Widget";
|
||||||
|
}
|
||||||
|
|
||||||
|
void Widget::onMessageReceived(const message_t *msg)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
void Widget::globalMessageCallback(const message_t *msg)
|
||||||
|
{
|
||||||
|
for (auto *w : s_instances)
|
||||||
|
{
|
||||||
|
w->onMessageReceived(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,12 +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 &&
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -131,3 +133,5 @@ void ClockScreenSaver::OnButtonClicked(ButtonType button)
|
|||||||
m_options->popScreen();
|
m_options->popScreen();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
IMPLEMENT_GET_NAME(ClockScreenSaver)
|
||||||
|
|||||||
@@ -23,3 +23,5 @@ void ExternalDevices::onButtonPressed(const MenuItem &menuItem, const ButtonType
|
|||||||
ble_connect_to_device(menuItem.getId());
|
ble_connect_to_device(menuItem.getId());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
IMPLEMENT_GET_NAME(ExternalDevices)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -11,12 +12,14 @@ namespace LightMenuItem
|
|||||||
{
|
{
|
||||||
constexpr auto ACTIVATE = 0; ///< ID for the light activation toggle
|
constexpr auto ACTIVATE = 0; ///< ID for the light activation toggle
|
||||||
constexpr auto MODE = 1; ///< ID for the light mode selection
|
constexpr auto MODE = 1; ///< ID for the light mode selection
|
||||||
|
constexpr auto VARIANT = 2; ///< ID for the light variant selection
|
||||||
} // namespace LightMenuItem
|
} // namespace LightMenuItem
|
||||||
|
|
||||||
namespace LightMenuOptions
|
namespace LightMenuOptions
|
||||||
{
|
{
|
||||||
constexpr auto LIGHT_ACTIVE = "light_active";
|
constexpr auto LIGHT_ACTIVE = "light_active";
|
||||||
constexpr auto LIGHT_MODE = "light_mode";
|
constexpr auto LIGHT_MODE = "light_mode";
|
||||||
|
constexpr auto LIGHT_VARIANT = "light_variant";
|
||||||
} // namespace LightMenuOptions
|
} // namespace LightMenuOptions
|
||||||
|
|
||||||
LightMenu::LightMenu(menu_options_t *options) : Menu(options), m_options(options)
|
LightMenu::LightMenu(menu_options_t *options) : Menu(options), m_options(options)
|
||||||
@@ -25,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);
|
||||||
|
|
||||||
@@ -37,9 +40,23 @@ 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);
|
||||||
|
|
||||||
|
std::vector<std::string> variants;
|
||||||
|
variants.emplace_back("1");
|
||||||
|
variants.emplace_back("2");
|
||||||
|
variants.emplace_back("3");
|
||||||
|
int variant_value = 3;
|
||||||
|
if (m_options && m_options->persistenceManager)
|
||||||
|
{
|
||||||
|
variant_value =
|
||||||
|
persistence_manager_get_int(m_options->persistenceManager, LightMenuOptions::LIGHT_VARIANT, variant_value) -
|
||||||
|
1;
|
||||||
|
}
|
||||||
|
addSelection(LightMenuItem::VARIANT, "Variante", variants, variant_value);
|
||||||
}
|
}
|
||||||
|
|
||||||
void LightMenu::onButtonPressed(const MenuItem &menuItem, const ButtonType button)
|
void LightMenu::onButtonPressed(const MenuItem &menuItem, const ButtonType button)
|
||||||
@@ -55,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;
|
||||||
}
|
}
|
||||||
@@ -73,11 +91,34 @@ 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
start_simulation();
|
case LightMenuItem::VARIANT: {
|
||||||
|
// Change light variant using left/right buttons
|
||||||
|
const auto item = switchValue(menuItem, button);
|
||||||
|
if (button == ButtonType::LEFT || button == ButtonType::RIGHT)
|
||||||
|
{
|
||||||
|
const auto value = getItem(item.getId()).getIndex() + 1;
|
||||||
|
if (m_options && m_options->persistenceManager)
|
||||||
|
{
|
||||||
|
// Post change via message_manager
|
||||||
|
message_t msg = {};
|
||||||
|
msg.type = MESSAGE_TYPE_SETTINGS;
|
||||||
|
msg.data.settings.type = SETTINGS_TYPE_INT;
|
||||||
|
strncpy(msg.data.settings.key, LightMenuOptions::LIGHT_VARIANT, sizeof(msg.data.settings.key) - 1);
|
||||||
|
msg.data.settings.value.int_value = value;
|
||||||
|
message_manager_post(&msg, pdMS_TO_TICKS(100));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -93,3 +134,26 @@ void LightMenu::onButtonPressed(const MenuItem &menuItem, const ButtonType butto
|
|||||||
m_options->pushScreen(widget);
|
m_options->pushScreen(widget);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void LightMenu::onMessageReceived(const message_t *msg)
|
||||||
|
{
|
||||||
|
// Here you can react to messages, e.g. set toggle status
|
||||||
|
// Example: If light_active was changed, synchronize toggle
|
||||||
|
if (msg && msg->type == MESSAGE_TYPE_SETTINGS)
|
||||||
|
{
|
||||||
|
if (std::strcmp(msg->data.settings.key, LightMenuOptions::LIGHT_ACTIVE) == 0)
|
||||||
|
{
|
||||||
|
setToggle(getItem(LightMenuItem::ACTIVATE), msg->data.settings.value.bool_value);
|
||||||
|
}
|
||||||
|
if (std::strcmp(msg->data.settings.key, LightMenuOptions::LIGHT_MODE) == 0)
|
||||||
|
{
|
||||||
|
setSelectionIndex(getItem(LightMenuItem::MODE), msg->data.settings.value.int_value);
|
||||||
|
}
|
||||||
|
if (std::strcmp(msg->data.settings.key, LightMenuOptions::LIGHT_VARIANT) == 0)
|
||||||
|
{
|
||||||
|
setSelectionIndex(getItem(LightMenuItem::VARIANT), msg->data.settings.value.int_value - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IMPLEMENT_GET_NAME(LightMenu)
|
||||||
|
|||||||
@@ -48,3 +48,5 @@ void MainMenu::onButtonPressed(const MenuItem &menuItem, const ButtonType button
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
IMPLEMENT_GET_NAME(MainMenu)
|
||||||
|
|||||||
@@ -327,3 +327,5 @@ void ScreenSaver::OnButtonClicked(ButtonType button)
|
|||||||
m_options->popScreen();
|
m_options->popScreen();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
IMPLEMENT_GET_NAME(ScreenSaver)
|
||||||
|
|||||||
@@ -9,3 +9,5 @@ SettingsMenu::SettingsMenu(menu_options_t *options) : Menu(options)
|
|||||||
{
|
{
|
||||||
addText(SettingsMenuItem::OTA_UPLOAD, "OTA Einspielen");
|
addText(SettingsMenuItem::OTA_UPLOAD, "OTA Einspielen");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
IMPLEMENT_GET_NAME(SettingsMenu)
|
||||||
|
|||||||
@@ -28,3 +28,5 @@ void SplashScreen::Render()
|
|||||||
u8g2_SetFont(u8g2, u8g2_font_haxrcorp4089_tr);
|
u8g2_SetFont(u8g2, u8g2_font_haxrcorp4089_tr);
|
||||||
u8g2_DrawStr(u8g2, 35, 50, "Initialisierung...");
|
u8g2_DrawStr(u8g2, 35, 50, "Initialisierung...");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
IMPLEMENT_GET_NAME(SplashScreen)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
{
|
||||||
|
#endif
|
||||||
|
|
||||||
|
char *get_time(void);
|
||||||
|
esp_err_t add_light_item(const char time[5], uint8_t red, uint8_t green, uint8_t blue, uint8_t white,
|
||||||
uint8_t brightness, uint8_t saturation);
|
uint8_t brightness, uint8_t saturation);
|
||||||
void cleanup_light_items(void);
|
void cleanup_light_items(void);
|
||||||
void start_simulate_day(void);
|
void start_simulate_day(void);
|
||||||
void start_simulate_night(void);
|
void start_simulate_night(void);
|
||||||
void start_simulation_task(void);
|
void start_simulation_task(void);
|
||||||
void stop_simulation_task(void);
|
void stop_simulation_task(void);
|
||||||
void start_simulation(void);
|
void start_simulation(void);
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -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,12 +1,14 @@
|
|||||||
#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>
|
||||||
#include <freertos/FreeRTOS.h>
|
#include <freertos/FreeRTOS.h>
|
||||||
|
#include <freertos/semphr.h>
|
||||||
#include <freertos/task.h>
|
#include <freertos/task.h>
|
||||||
#include <math.h>
|
#include <math.h>
|
||||||
#include <stdint.h>
|
#include <stdint.h>
|
||||||
@@ -14,16 +16,25 @@
|
|||||||
#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;
|
||||||
}
|
}
|
||||||
|
|
||||||
static TaskHandle_t simulation_task_handle = NULL;
|
static TaskHandle_t simulation_task_handle = NULL;
|
||||||
|
static SemaphoreHandle_t simulation_mutex = NULL;
|
||||||
|
|
||||||
|
static void ensure_mutex_initialized(void)
|
||||||
|
{
|
||||||
|
if (simulation_mutex == NULL)
|
||||||
|
{
|
||||||
|
simulation_mutex = xSemaphoreCreateMutex();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The struct is extended with a 'next' pointer to form a linked list.
|
// The struct is extended with a 'next' pointer to form a linked list.
|
||||||
typedef struct light_item_node_t
|
typedef struct light_item_node_t
|
||||||
@@ -71,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,14 +142,16 @@ void cleanup_light_items(void)
|
|||||||
|
|
||||||
static void initialize_light_items(void)
|
static void initialize_light_items(void)
|
||||||
{
|
{
|
||||||
if (head != NULL)
|
cleanup_light_items();
|
||||||
{
|
|
||||||
ESP_LOGI(TAG, "Light schedule already initialized.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
initialize_storage();
|
initialize_storage();
|
||||||
load_file("/spiffs/schema_03.csv");
|
|
||||||
|
static char filename[30];
|
||||||
|
persistence_manager_t persistence;
|
||||||
|
persistence_manager_init(&persistence, "config");
|
||||||
|
int variant = persistence_manager_get_int(&persistence, "light_variant", 1);
|
||||||
|
snprintf(filename, sizeof(filename), "schema_%02d.csv", variant);
|
||||||
|
load_file(filename);
|
||||||
|
persistence_manager_deinit(&persistence);
|
||||||
|
|
||||||
if (head == NULL)
|
if (head == NULL)
|
||||||
{
|
{
|
||||||
@@ -223,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();
|
||||||
@@ -230,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,6 +282,11 @@ void simulate_cycle(void *args)
|
|||||||
if (cycle_duration_minutes <= 0)
|
if (cycle_duration_minutes <= 0)
|
||||||
{
|
{
|
||||||
ESP_LOGE(TAG, "Invalid cycle duration: %d minutes. Must be positive.", cycle_duration_minutes);
|
ESP_LOGE(TAG, "Invalid cycle duration: %d minutes. Must be positive.", cycle_duration_minutes);
|
||||||
|
if (simulation_mutex != NULL && xSemaphoreTake(simulation_mutex, portMAX_DELAY) == pdTRUE)
|
||||||
|
{
|
||||||
|
simulation_task_handle = NULL;
|
||||||
|
xSemaphoreGive(simulation_mutex);
|
||||||
|
}
|
||||||
vTaskDelete(NULL);
|
vTaskDelete(NULL);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -279,7 +310,13 @@ void simulate_cycle(void *args)
|
|||||||
light_item_node_t *current_item = find_best_light_item_for_time(hhmm);
|
light_item_node_t *current_item = find_best_light_item_for_time(hhmm);
|
||||||
light_item_node_t *next_item = find_next_light_item_for_time(hhmm);
|
light_item_node_t *next_item = find_next_light_item_for_time(hhmm);
|
||||||
|
|
||||||
if (current_item != NULL && next_item != NULL)
|
if (current_item != NULL)
|
||||||
|
{
|
||||||
|
rgb_t color = {0, 0, 0};
|
||||||
|
|
||||||
|
// Use head as fallback if next_item is NULL
|
||||||
|
next_item = next_item ? next_item : head;
|
||||||
|
if (next_item != NULL)
|
||||||
{
|
{
|
||||||
int current_item_time_min = (atoi(current_item->time) / 100) * 60 + (atoi(current_item->time) % 100);
|
int current_item_time_min = (atoi(current_item->time) / 100) * 60 + (atoi(current_item->time) % 100);
|
||||||
int next_item_time_min = (atoi(next_item->time) / 100) * 60 + (atoi(next_item->time) % 100);
|
int next_item_time_min = (atoi(next_item->time) / 100) * 60 + (atoi(next_item->time) % 100);
|
||||||
@@ -308,16 +345,16 @@ void simulate_cycle(void *args)
|
|||||||
rgb_t end_rgb = {.red = next_item->red, .green = next_item->green, .blue = next_item->blue};
|
rgb_t end_rgb = {.red = next_item->red, .green = next_item->green, .blue = next_item->blue};
|
||||||
|
|
||||||
// Use the interpolation function
|
// Use the interpolation function
|
||||||
rgb_t final_rgb = interpolate_color(start_rgb, end_rgb, interpolation_factor);
|
color = interpolate_color(start_rgb, end_rgb, interpolation_factor);
|
||||||
|
led_strip_update(LED_STATE_SIMULATION, color);
|
||||||
led_strip_update(LED_STATE_SIMULATION, final_rgb);
|
|
||||||
}
|
}
|
||||||
else if (current_item != NULL)
|
else
|
||||||
{
|
{
|
||||||
// No next item, just use current
|
// No next_item and no head, use only current
|
||||||
led_strip_update(
|
color = (rgb_t){.red = current_item->red, .green = current_item->green, .blue = current_item->blue};
|
||||||
LED_STATE_SIMULATION,
|
led_strip_update(LED_STATE_SIMULATION, color);
|
||||||
(rgb_t){.red = current_item->red, .green = current_item->green, .blue = current_item->blue});
|
}
|
||||||
|
send_simulation_message(time, color);
|
||||||
}
|
}
|
||||||
|
|
||||||
vTaskDelay(pdMS_TO_TICKS(delay_ms));
|
vTaskDelay(pdMS_TO_TICKS(delay_ms));
|
||||||
@@ -336,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.");
|
||||||
@@ -355,36 +392,50 @@ void start_simulation_task(void)
|
|||||||
|
|
||||||
void stop_simulation_task(void)
|
void stop_simulation_task(void)
|
||||||
{
|
{
|
||||||
|
ensure_mutex_initialized();
|
||||||
|
|
||||||
|
if (xSemaphoreTake(simulation_mutex, portMAX_DELAY) == pdTRUE)
|
||||||
|
{
|
||||||
if (simulation_task_handle != NULL)
|
if (simulation_task_handle != NULL)
|
||||||
{
|
{
|
||||||
vTaskDelete(simulation_task_handle);
|
TaskHandle_t handle_to_delete = simulation_task_handle;
|
||||||
simulation_task_handle = NULL;
|
simulation_task_handle = NULL;
|
||||||
|
xSemaphoreGive(simulation_mutex);
|
||||||
|
|
||||||
|
// Prüfe ob der Task noch existiert bevor er gelöscht wird
|
||||||
|
eTaskState state = eTaskGetState(handle_to_delete);
|
||||||
|
if (state != eDeleted && state != eInvalid)
|
||||||
|
{
|
||||||
|
vTaskDelete(handle_to_delete);
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
xSemaphoreGive(simulation_mutex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void start_simulation(void)
|
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;
|
||||||
@@ -394,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
|
||||||
@@ -13,6 +13,7 @@ idf_component_register(SRCS
|
|||||||
persistence-manager
|
persistence-manager
|
||||||
simulator
|
simulator
|
||||||
u8g2
|
u8g2
|
||||||
|
hal
|
||||||
nvs_flash
|
nvs_flash
|
||||||
esp_timer
|
esp_timer
|
||||||
esp_event
|
esp_event
|
||||||
@@ -20,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)
|
||||||
|
|||||||
@@ -6,26 +6,90 @@ menu "System Control"
|
|||||||
help
|
help
|
||||||
Enable or disable WiFi connectivity.
|
Enable or disable WiFi connectivity.
|
||||||
|
|
||||||
config WIFI_SSID
|
config WIFI_NETWORK_COUNT
|
||||||
depends on WIFI_ENABLED
|
depends on WIFI_ENABLED
|
||||||
string "WiFi SSID"
|
int "Number of WiFi Networks"
|
||||||
default "YourSSID"
|
default 1
|
||||||
|
range 1 5
|
||||||
help
|
help
|
||||||
The SSID of the WiFi network to connect to.
|
Number of WiFi networks to configure (1-5).
|
||||||
|
|
||||||
config WIFI_PASSWORD
|
config WIFI_SSID_1
|
||||||
depends on WIFI_ENABLED
|
depends on WIFI_ENABLED
|
||||||
string "WiFi Password"
|
string "WiFi SSID 1"
|
||||||
default "YourPassword"
|
default "YourSSID1"
|
||||||
help
|
help
|
||||||
The password of the WiFi network to connect to.
|
The SSID of the first WiFi network.
|
||||||
|
|
||||||
|
config WIFI_PASSWORD_1
|
||||||
|
depends on WIFI_ENABLED
|
||||||
|
string "WiFi Password 1"
|
||||||
|
default "YourPassword1"
|
||||||
|
help
|
||||||
|
The password of the first WiFi network.
|
||||||
|
|
||||||
|
config WIFI_SSID_2
|
||||||
|
depends on WIFI_ENABLED && WIFI_NETWORK_COUNT >= 2
|
||||||
|
string "WiFi SSID 2"
|
||||||
|
default ""
|
||||||
|
help
|
||||||
|
The SSID of the second WiFi network.
|
||||||
|
|
||||||
|
config WIFI_PASSWORD_2
|
||||||
|
depends on WIFI_ENABLED && WIFI_NETWORK_COUNT >= 2
|
||||||
|
string "WiFi Password 2"
|
||||||
|
default ""
|
||||||
|
help
|
||||||
|
The password of the second WiFi network.
|
||||||
|
|
||||||
|
config WIFI_SSID_3
|
||||||
|
depends on WIFI_ENABLED && WIFI_NETWORK_COUNT >= 3
|
||||||
|
string "WiFi SSID 3"
|
||||||
|
default ""
|
||||||
|
help
|
||||||
|
The SSID of the third WiFi network.
|
||||||
|
|
||||||
|
config WIFI_PASSWORD_3
|
||||||
|
depends on WIFI_ENABLED && WIFI_NETWORK_COUNT >= 3
|
||||||
|
string "WiFi Password 3"
|
||||||
|
default ""
|
||||||
|
help
|
||||||
|
The password of the third WiFi network.
|
||||||
|
|
||||||
|
config WIFI_SSID_4
|
||||||
|
depends on WIFI_ENABLED && WIFI_NETWORK_COUNT >= 4
|
||||||
|
string "WiFi SSID 4"
|
||||||
|
default ""
|
||||||
|
help
|
||||||
|
The SSID of the fourth WiFi network.
|
||||||
|
|
||||||
|
config WIFI_PASSWORD_4
|
||||||
|
depends on WIFI_ENABLED && WIFI_NETWORK_COUNT >= 4
|
||||||
|
string "WiFi Password 4"
|
||||||
|
default ""
|
||||||
|
help
|
||||||
|
The password of the fourth WiFi network.
|
||||||
|
|
||||||
|
config WIFI_SSID_5
|
||||||
|
depends on WIFI_ENABLED && WIFI_NETWORK_COUNT >= 5
|
||||||
|
string "WiFi SSID 5"
|
||||||
|
default ""
|
||||||
|
help
|
||||||
|
The SSID of the fifth WiFi network.
|
||||||
|
|
||||||
|
config WIFI_PASSWORD_5
|
||||||
|
depends on WIFI_ENABLED && WIFI_NETWORK_COUNT >= 5
|
||||||
|
string "WiFi Password 5"
|
||||||
|
default ""
|
||||||
|
help
|
||||||
|
The password of the fifth WiFi network.
|
||||||
|
|
||||||
config WIFI_CONNECT_RETRIES
|
config WIFI_CONNECT_RETRIES
|
||||||
depends on WIFI_ENABLED
|
depends on WIFI_ENABLED
|
||||||
int "WiFi Connection Retry Attempts"
|
int "WiFi Connection Retry Attempts per Network"
|
||||||
default 5
|
default 3
|
||||||
help
|
help
|
||||||
Number of times to retry connecting to the WiFi network before giving up.
|
Number of times to retry connecting to each WiFi network before trying the next one.
|
||||||
endmenu
|
endmenu
|
||||||
|
|
||||||
menu "Display Settings"
|
menu "Display Settings"
|
||||||
|
|||||||
@@ -1,262 +0,0 @@
|
|||||||
#include <stdio.h>
|
|
||||||
#include <string.h>
|
|
||||||
|
|
||||||
#include "esp_log.h"
|
|
||||||
#include "sdkconfig.h"
|
|
||||||
|
|
||||||
#include "freertos/FreeRTOS.h"
|
|
||||||
#include "freertos/task.h"
|
|
||||||
|
|
||||||
#include "u8g2_esp32_hal.h"
|
|
||||||
|
|
||||||
static const char* TAG = "u8g2_hal";
|
|
||||||
static const unsigned int I2C_TIMEOUT_MS = 1000;
|
|
||||||
|
|
||||||
static spi_device_handle_t handle_spi; // SPI handle.
|
|
||||||
static i2c_cmd_handle_t handle_i2c; // I2C handle.
|
|
||||||
static u8g2_esp32_hal_t u8g2_esp32_hal; // HAL state data.
|
|
||||||
|
|
||||||
#define HOST SPI2_HOST
|
|
||||||
|
|
||||||
#undef ESP_ERROR_CHECK
|
|
||||||
#define ESP_ERROR_CHECK(x) \
|
|
||||||
do { \
|
|
||||||
esp_err_t rc = (x); \
|
|
||||||
if (rc != ESP_OK) { \
|
|
||||||
ESP_LOGE("err", "esp_err_t = %d", rc); \
|
|
||||||
assert(0 && #x); \
|
|
||||||
} \
|
|
||||||
} while (0);
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Initialze the ESP32 HAL.
|
|
||||||
*/
|
|
||||||
void u8g2_esp32_hal_init(u8g2_esp32_hal_t u8g2_esp32_hal_param) {
|
|
||||||
u8g2_esp32_hal = u8g2_esp32_hal_param;
|
|
||||||
} // u8g2_esp32_hal_init
|
|
||||||
|
|
||||||
/*
|
|
||||||
* HAL callback function as prescribed by the U8G2 library. This callback is
|
|
||||||
* invoked to handle SPI communications.
|
|
||||||
*/
|
|
||||||
uint8_t u8g2_esp32_spi_byte_cb(u8x8_t* u8x8,
|
|
||||||
uint8_t msg,
|
|
||||||
uint8_t arg_int,
|
|
||||||
void* arg_ptr) {
|
|
||||||
ESP_LOGD(TAG, "spi_byte_cb: Received a msg: %d, arg_int: %d, arg_ptr: %p",
|
|
||||||
msg, arg_int, arg_ptr);
|
|
||||||
switch (msg) {
|
|
||||||
case U8X8_MSG_BYTE_SET_DC:
|
|
||||||
if (u8g2_esp32_hal.dc != U8G2_ESP32_HAL_UNDEFINED) {
|
|
||||||
gpio_set_level(u8g2_esp32_hal.dc, arg_int);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case U8X8_MSG_BYTE_INIT: {
|
|
||||||
if (u8g2_esp32_hal.bus.spi.clk == U8G2_ESP32_HAL_UNDEFINED ||
|
|
||||||
u8g2_esp32_hal.bus.spi.mosi == U8G2_ESP32_HAL_UNDEFINED ||
|
|
||||||
u8g2_esp32_hal.bus.spi.cs == U8G2_ESP32_HAL_UNDEFINED) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
spi_bus_config_t bus_config;
|
|
||||||
memset(&bus_config, 0, sizeof(spi_bus_config_t));
|
|
||||||
bus_config.sclk_io_num = u8g2_esp32_hal.bus.spi.clk; // CLK
|
|
||||||
bus_config.mosi_io_num = u8g2_esp32_hal.bus.spi.mosi; // MOSI
|
|
||||||
bus_config.miso_io_num = GPIO_NUM_NC; // MISO
|
|
||||||
bus_config.quadwp_io_num = GPIO_NUM_NC; // Not used
|
|
||||||
bus_config.quadhd_io_num = GPIO_NUM_NC; // Not used
|
|
||||||
// ESP_LOGI(TAG, "... Initializing bus.");
|
|
||||||
ESP_ERROR_CHECK(spi_bus_initialize(HOST, &bus_config, 1));
|
|
||||||
|
|
||||||
spi_device_interface_config_t dev_config;
|
|
||||||
dev_config.address_bits = 0;
|
|
||||||
dev_config.command_bits = 0;
|
|
||||||
dev_config.dummy_bits = 0;
|
|
||||||
dev_config.mode = 0;
|
|
||||||
dev_config.duty_cycle_pos = 0;
|
|
||||||
dev_config.cs_ena_posttrans = 0;
|
|
||||||
dev_config.cs_ena_pretrans = 0;
|
|
||||||
dev_config.clock_speed_hz = 10000;
|
|
||||||
dev_config.spics_io_num = u8g2_esp32_hal.bus.spi.cs;
|
|
||||||
dev_config.flags = 0;
|
|
||||||
dev_config.queue_size = 200;
|
|
||||||
dev_config.pre_cb = NULL;
|
|
||||||
dev_config.post_cb = NULL;
|
|
||||||
// ESP_LOGI(TAG, "... Adding device bus.");
|
|
||||||
ESP_ERROR_CHECK(spi_bus_add_device(HOST, &dev_config, &handle_spi));
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case U8X8_MSG_BYTE_SEND: {
|
|
||||||
spi_transaction_t trans_desc;
|
|
||||||
trans_desc.addr = 0;
|
|
||||||
trans_desc.cmd = 0;
|
|
||||||
trans_desc.flags = 0;
|
|
||||||
trans_desc.length = 8 * arg_int; // Number of bits NOT number of bytes.
|
|
||||||
trans_desc.rxlength = 0;
|
|
||||||
trans_desc.tx_buffer = arg_ptr;
|
|
||||||
trans_desc.rx_buffer = NULL;
|
|
||||||
|
|
||||||
// ESP_LOGI(TAG, "... Transmitting %d bytes.", arg_int);
|
|
||||||
ESP_ERROR_CHECK(spi_device_transmit(handle_spi, &trans_desc));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
} // u8g2_esp32_spi_byte_cb
|
|
||||||
|
|
||||||
/*
|
|
||||||
* HAL callback function as prescribed by the U8G2 library. This callback is
|
|
||||||
* invoked to handle I2C communications.
|
|
||||||
*/
|
|
||||||
uint8_t u8g2_esp32_i2c_byte_cb(u8x8_t* u8x8,
|
|
||||||
uint8_t msg,
|
|
||||||
uint8_t arg_int,
|
|
||||||
void* arg_ptr) {
|
|
||||||
ESP_LOGD(TAG, "i2c_cb: Received a msg: %d, arg_int: %d, arg_ptr: %p", msg,
|
|
||||||
arg_int, arg_ptr);
|
|
||||||
|
|
||||||
switch (msg) {
|
|
||||||
case U8X8_MSG_BYTE_SET_DC: {
|
|
||||||
if (u8g2_esp32_hal.dc != U8G2_ESP32_HAL_UNDEFINED) {
|
|
||||||
gpio_set_level(u8g2_esp32_hal.dc, arg_int);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case U8X8_MSG_BYTE_INIT: {
|
|
||||||
if (u8g2_esp32_hal.bus.i2c.sda == U8G2_ESP32_HAL_UNDEFINED ||
|
|
||||||
u8g2_esp32_hal.bus.i2c.scl == U8G2_ESP32_HAL_UNDEFINED) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
i2c_config_t conf = {0};
|
|
||||||
conf.mode = I2C_MODE_MASTER;
|
|
||||||
ESP_LOGI(TAG, "sda_io_num %d", u8g2_esp32_hal.bus.i2c.sda);
|
|
||||||
conf.sda_io_num = u8g2_esp32_hal.bus.i2c.sda;
|
|
||||||
conf.sda_pullup_en = GPIO_PULLUP_ENABLE;
|
|
||||||
ESP_LOGI(TAG, "scl_io_num %d", u8g2_esp32_hal.bus.i2c.scl);
|
|
||||||
conf.scl_io_num = u8g2_esp32_hal.bus.i2c.scl;
|
|
||||||
conf.scl_pullup_en = GPIO_PULLUP_ENABLE;
|
|
||||||
ESP_LOGI(TAG, "clk_speed %d", I2C_MASTER_FREQ_HZ);
|
|
||||||
conf.master.clk_speed = I2C_MASTER_FREQ_HZ;
|
|
||||||
ESP_LOGI(TAG, "i2c_param_config %d", conf.mode);
|
|
||||||
ESP_ERROR_CHECK(i2c_param_config(I2C_MASTER_NUM, &conf));
|
|
||||||
ESP_LOGI(TAG, "i2c_driver_install %d", I2C_MASTER_NUM);
|
|
||||||
ESP_ERROR_CHECK(i2c_driver_install(I2C_MASTER_NUM, conf.mode,
|
|
||||||
I2C_MASTER_RX_BUF_DISABLE,
|
|
||||||
I2C_MASTER_TX_BUF_DISABLE, 0));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case U8X8_MSG_BYTE_SEND: {
|
|
||||||
uint8_t* data_ptr = (uint8_t*)arg_ptr;
|
|
||||||
ESP_LOG_BUFFER_HEXDUMP(TAG, data_ptr, arg_int, ESP_LOG_VERBOSE);
|
|
||||||
|
|
||||||
while (arg_int > 0) {
|
|
||||||
ESP_ERROR_CHECK(
|
|
||||||
i2c_master_write_byte(handle_i2c, *data_ptr, ACK_CHECK_EN));
|
|
||||||
data_ptr++;
|
|
||||||
arg_int--;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case U8X8_MSG_BYTE_START_TRANSFER: {
|
|
||||||
uint8_t i2c_address = u8x8_GetI2CAddress(u8x8);
|
|
||||||
handle_i2c = i2c_cmd_link_create();
|
|
||||||
ESP_LOGD(TAG, "Start I2C transfer to %02X.", i2c_address >> 1);
|
|
||||||
ESP_ERROR_CHECK(i2c_master_start(handle_i2c));
|
|
||||||
ESP_ERROR_CHECK(i2c_master_write_byte(
|
|
||||||
handle_i2c, i2c_address | I2C_MASTER_WRITE, ACK_CHECK_EN));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case U8X8_MSG_BYTE_END_TRANSFER: {
|
|
||||||
ESP_LOGD(TAG, "End I2C transfer.");
|
|
||||||
ESP_ERROR_CHECK(i2c_master_stop(handle_i2c));
|
|
||||||
ESP_ERROR_CHECK(i2c_master_cmd_begin(I2C_MASTER_NUM, handle_i2c,
|
|
||||||
pdMS_TO_TICKS(I2C_TIMEOUT_MS)));
|
|
||||||
i2c_cmd_link_delete(handle_i2c);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
} // u8g2_esp32_i2c_byte_cb
|
|
||||||
|
|
||||||
/*
|
|
||||||
* HAL callback function as prescribed by the U8G2 library. This callback is
|
|
||||||
* invoked to handle callbacks for GPIO and delay functions.
|
|
||||||
*/
|
|
||||||
uint8_t u8g2_esp32_gpio_and_delay_cb(u8x8_t* u8x8,
|
|
||||||
uint8_t msg,
|
|
||||||
uint8_t arg_int,
|
|
||||||
void* arg_ptr) {
|
|
||||||
ESP_LOGD(TAG,
|
|
||||||
"gpio_and_delay_cb: Received a msg: %d, arg_int: %d, arg_ptr: %p",
|
|
||||||
msg, arg_int, arg_ptr);
|
|
||||||
|
|
||||||
switch (msg) {
|
|
||||||
// Initialize the GPIO and DELAY HAL functions. If the pins for DC and
|
|
||||||
// RESET have been specified then we define those pins as GPIO outputs.
|
|
||||||
case U8X8_MSG_GPIO_AND_DELAY_INIT: {
|
|
||||||
uint64_t bitmask = 0;
|
|
||||||
if (u8g2_esp32_hal.dc != U8G2_ESP32_HAL_UNDEFINED) {
|
|
||||||
bitmask = bitmask | (1ull << u8g2_esp32_hal.dc);
|
|
||||||
}
|
|
||||||
if (u8g2_esp32_hal.reset != U8G2_ESP32_HAL_UNDEFINED) {
|
|
||||||
bitmask = bitmask | (1ull << u8g2_esp32_hal.reset);
|
|
||||||
}
|
|
||||||
if (u8g2_esp32_hal.bus.spi.cs != U8G2_ESP32_HAL_UNDEFINED) {
|
|
||||||
bitmask = bitmask | (1ull << u8g2_esp32_hal.bus.spi.cs);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bitmask == 0) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
gpio_config_t gpioConfig;
|
|
||||||
gpioConfig.pin_bit_mask = bitmask;
|
|
||||||
gpioConfig.mode = GPIO_MODE_OUTPUT;
|
|
||||||
gpioConfig.pull_up_en = GPIO_PULLUP_DISABLE;
|
|
||||||
gpioConfig.pull_down_en = GPIO_PULLDOWN_ENABLE;
|
|
||||||
gpioConfig.intr_type = GPIO_INTR_DISABLE;
|
|
||||||
gpio_config(&gpioConfig);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set the GPIO reset pin to the value passed in through arg_int.
|
|
||||||
case U8X8_MSG_GPIO_RESET:
|
|
||||||
if (u8g2_esp32_hal.reset != U8G2_ESP32_HAL_UNDEFINED) {
|
|
||||||
gpio_set_level(u8g2_esp32_hal.reset, arg_int);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
// Set the GPIO client select pin to the value passed in through arg_int.
|
|
||||||
case U8X8_MSG_GPIO_CS:
|
|
||||||
if (u8g2_esp32_hal.bus.spi.cs != U8G2_ESP32_HAL_UNDEFINED) {
|
|
||||||
gpio_set_level(u8g2_esp32_hal.bus.spi.cs, arg_int);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
// Set the Software I²C pin to the value passed in through arg_int.
|
|
||||||
case U8X8_MSG_GPIO_I2C_CLOCK:
|
|
||||||
if (u8g2_esp32_hal.bus.i2c.scl != U8G2_ESP32_HAL_UNDEFINED) {
|
|
||||||
gpio_set_level(u8g2_esp32_hal.bus.i2c.scl, arg_int);
|
|
||||||
// printf("%c",(arg_int==1?'C':'c'));
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
// Set the Software I²C pin to the value passed in through arg_int.
|
|
||||||
case U8X8_MSG_GPIO_I2C_DATA:
|
|
||||||
if (u8g2_esp32_hal.bus.i2c.sda != U8G2_ESP32_HAL_UNDEFINED) {
|
|
||||||
gpio_set_level(u8g2_esp32_hal.bus.i2c.sda, arg_int);
|
|
||||||
// printf("%c",(arg_int==1?'D':'d'));
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
// Delay for the number of milliseconds passed in through arg_int.
|
|
||||||
case U8X8_MSG_DELAY_MILLI:
|
|
||||||
vTaskDelay(arg_int / portTICK_PERIOD_MS);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
} // u8g2_esp32_gpio_and_delay_cb
|
|
||||||
@@ -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
|
|
||||||
|
|||||||
@@ -9,19 +9,17 @@
|
|||||||
|
|
||||||
#ifndef U8G2_ESP32_HAL_H_
|
#ifndef U8G2_ESP32_HAL_H_
|
||||||
#define U8G2_ESP32_HAL_H_
|
#define U8G2_ESP32_HAL_H_
|
||||||
|
|
||||||
#include "u8g2.h"
|
#include "u8g2.h"
|
||||||
|
|
||||||
#include "driver/gpio.h"
|
#include "driver/gpio.h"
|
||||||
#include "driver/i2c.h"
|
#include "driver/i2c.h"
|
||||||
#include "driver/spi_master.h"
|
#include "driver/spi_master.h"
|
||||||
|
#include "hal/i2c_types.h"
|
||||||
|
|
||||||
#define U8G2_ESP32_HAL_UNDEFINED GPIO_NUM_NC
|
#define U8G2_ESP32_HAL_UNDEFINED GPIO_NUM_NC
|
||||||
|
|
||||||
#if SOC_I2C_NUM > 1
|
|
||||||
#define I2C_MASTER_NUM I2C_NUM_1 // I2C port number for master dev
|
|
||||||
#else
|
|
||||||
#define I2C_MASTER_NUM I2C_NUM_0 // I2C port number for master dev
|
#define I2C_MASTER_NUM I2C_NUM_0 // I2C port number for master dev
|
||||||
#endif
|
|
||||||
|
|
||||||
#define I2C_MASTER_TX_BUF_DISABLE 0 // I2C master do not need buffer
|
#define I2C_MASTER_TX_BUF_DISABLE 0 // I2C master do not need buffer
|
||||||
#define I2C_MASTER_RX_BUF_DISABLE 0 // I2C master do not need buffer
|
#define I2C_MASTER_RX_BUF_DISABLE 0 // I2C master do not need buffer
|
||||||
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,18 +2,23 @@
|
|||||||
|
|
||||||
#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_log.h>
|
#include <esp_log.h>
|
||||||
#include <esp_task_wdt.h>
|
#include <esp_task_wdt.h>
|
||||||
#include <esp_timer.h>
|
#include <esp_timer.h>
|
||||||
@@ -32,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;
|
||||||
|
|
||||||
@@ -62,6 +68,7 @@ void setScreen(const std::shared_ptr<Widget> &screen)
|
|||||||
{
|
{
|
||||||
if (screen != nullptr)
|
if (screen != nullptr)
|
||||||
{
|
{
|
||||||
|
ESP_DIAG_EVENT(TAG, "Screen set: %s", screen->getName());
|
||||||
m_widget = screen;
|
m_widget = screen;
|
||||||
m_history.clear();
|
m_history.clear();
|
||||||
m_history.emplace_back(m_widget);
|
m_history.emplace_back(m_widget);
|
||||||
@@ -77,6 +84,7 @@ void pushScreen(const std::shared_ptr<Widget> &screen)
|
|||||||
{
|
{
|
||||||
m_widget->onPause();
|
m_widget->onPause();
|
||||||
}
|
}
|
||||||
|
ESP_DIAG_EVENT(TAG, "Screen pushed: %s", screen->getName());
|
||||||
m_widget = screen;
|
m_widget = screen;
|
||||||
m_widget->onEnter();
|
m_widget->onEnter();
|
||||||
m_history.emplace_back(m_widget);
|
m_history.emplace_back(m_widget);
|
||||||
@@ -90,27 +98,25 @@ 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();
|
||||||
|
ESP_DIAG_EVENT(TAG, "Screen popped, now: %s", m_widget->getName());
|
||||||
m_widget->onResume();
|
m_widget->onResume();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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, []() {
|
||||||
@@ -123,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();
|
||||||
@@ -180,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();
|
||||||
|
|
||||||
@@ -16,10 +16,12 @@
|
|||||||
static const char *TAG = "button_handling";
|
static const char *TAG = "button_handling";
|
||||||
|
|
||||||
const uint8_t gpios[] = {BUTTON_DOWN, BUTTON_UP, BUTTON_LEFT, BUTTON_RIGHT, BUTTON_SELECT, BUTTON_BACK};
|
const uint8_t gpios[] = {BUTTON_DOWN, BUTTON_UP, BUTTON_LEFT, BUTTON_RIGHT, BUTTON_SELECT, BUTTON_BACK};
|
||||||
|
static const char *button_names[] = {"DOWN", "UP", "LEFT", "RIGHT", "SELECT", "BACK"};
|
||||||
|
|
||||||
typedef struct
|
typedef struct
|
||||||
{
|
{
|
||||||
uint8_t gpio;
|
uint8_t gpio;
|
||||||
|
uint8_t index;
|
||||||
} button_user_data_t;
|
} button_user_data_t;
|
||||||
|
|
||||||
static button_user_data_t button_data[6];
|
static button_user_data_t button_data[6];
|
||||||
@@ -35,8 +37,9 @@ static void button_event_cb(void *arg, void *usr_data)
|
|||||||
}
|
}
|
||||||
button_user_data_t *data = (button_user_data_t *)usr_data;
|
button_user_data_t *data = (button_user_data_t *)usr_data;
|
||||||
uint8_t gpio_num = data->gpio;
|
uint8_t gpio_num = data->gpio;
|
||||||
|
const char *button_name = button_names[data->index];
|
||||||
|
|
||||||
ESP_LOGI(TAG, "Button pressed on GPIO %d", gpio_num);
|
ESP_DIAG_EVENT(TAG, "Button %s pressed (GPIO %d)", button_name, gpio_num);
|
||||||
|
|
||||||
if (xQueueSend(buttonQueue, &gpio_num, 0) != pdTRUE)
|
if (xQueueSend(buttonQueue, &gpio_num, 0) != pdTRUE)
|
||||||
{
|
{
|
||||||
@@ -60,6 +63,7 @@ static void init_button(uint8_t gpio, int index)
|
|||||||
}
|
}
|
||||||
|
|
||||||
button_data[index].gpio = gpio;
|
button_data[index].gpio = gpio;
|
||||||
|
button_data[index].index = index;
|
||||||
iot_button_register_cb(gpio_btn, BUTTON_SINGLE_CLICK, NULL, button_event_cb, &button_data[index]);
|
iot_button_register_cb(gpio_btn, BUTTON_SINGLE_CLICK, NULL, button_event_cb, &button_data[index]);
|
||||||
}
|
}
|
||||||
|
|
||||||
292
firmware/main/src/hal/u8g2_esp32_hal.c
Normal file
292
firmware/main/src/hal/u8g2_esp32_hal.c
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#include "esp_log.h"
|
||||||
|
#include "sdkconfig.h"
|
||||||
|
|
||||||
|
#include "freertos/FreeRTOS.h"
|
||||||
|
#include "freertos/task.h"
|
||||||
|
|
||||||
|
#include "hal/u8g2_esp32_hal.h"
|
||||||
|
|
||||||
|
static const char *TAG = "u8g2_hal";
|
||||||
|
static const unsigned int I2C_TIMEOUT_MS = 1000;
|
||||||
|
|
||||||
|
static spi_device_handle_t handle_spi; // SPI handle.
|
||||||
|
static i2c_cmd_handle_t handle_i2c; // I2C handle.
|
||||||
|
static u8g2_esp32_hal_t u8g2_esp32_hal; // HAL state data.
|
||||||
|
static bool i2c_transfer_failed = false; // Flag to track I2C transfer errors
|
||||||
|
|
||||||
|
#define HOST SPI2_HOST
|
||||||
|
|
||||||
|
#undef ESP_ERROR_CHECK
|
||||||
|
#define ESP_ERROR_CHECK(x) \
|
||||||
|
do \
|
||||||
|
{ \
|
||||||
|
esp_err_t rc = (x); \
|
||||||
|
if (rc != ESP_OK) \
|
||||||
|
{ \
|
||||||
|
ESP_LOGE("err", "esp_err_t = %d", rc); \
|
||||||
|
assert(0 && #x); \
|
||||||
|
} \
|
||||||
|
} while (0);
|
||||||
|
|
||||||
|
// Softer error handling for I2C operations that may fail temporarily
|
||||||
|
#define I2C_ERROR_CHECK(x) \
|
||||||
|
do \
|
||||||
|
{ \
|
||||||
|
esp_err_t rc = (x); \
|
||||||
|
if (rc != ESP_OK) \
|
||||||
|
{ \
|
||||||
|
ESP_LOGW(TAG, "I2C error: %s = %d", #x, rc); \
|
||||||
|
i2c_transfer_failed = true; \
|
||||||
|
} \
|
||||||
|
} while (0);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Initialze the ESP32 HAL.
|
||||||
|
*/
|
||||||
|
void u8g2_esp32_hal_init(u8g2_esp32_hal_t u8g2_esp32_hal_param)
|
||||||
|
{
|
||||||
|
u8g2_esp32_hal = u8g2_esp32_hal_param;
|
||||||
|
} // u8g2_esp32_hal_init
|
||||||
|
|
||||||
|
/*
|
||||||
|
* HAL callback function as prescribed by the U8G2 library. This callback is
|
||||||
|
* invoked to handle SPI communications.
|
||||||
|
*/
|
||||||
|
uint8_t u8g2_esp32_spi_byte_cb(u8x8_t *u8x8, uint8_t msg, uint8_t arg_int, void *arg_ptr)
|
||||||
|
{
|
||||||
|
ESP_LOGD(TAG, "spi_byte_cb: Received a msg: %d, arg_int: %d, arg_ptr: %p", msg, arg_int, arg_ptr);
|
||||||
|
switch (msg)
|
||||||
|
{
|
||||||
|
case U8X8_MSG_BYTE_SET_DC:
|
||||||
|
if (u8g2_esp32_hal.dc != U8G2_ESP32_HAL_UNDEFINED)
|
||||||
|
{
|
||||||
|
gpio_set_level(u8g2_esp32_hal.dc, arg_int);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case U8X8_MSG_BYTE_INIT: {
|
||||||
|
if (u8g2_esp32_hal.bus.spi.clk == U8G2_ESP32_HAL_UNDEFINED ||
|
||||||
|
u8g2_esp32_hal.bus.spi.mosi == U8G2_ESP32_HAL_UNDEFINED ||
|
||||||
|
u8g2_esp32_hal.bus.spi.cs == U8G2_ESP32_HAL_UNDEFINED)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
spi_bus_config_t bus_config;
|
||||||
|
memset(&bus_config, 0, sizeof(spi_bus_config_t));
|
||||||
|
bus_config.sclk_io_num = u8g2_esp32_hal.bus.spi.clk; // CLK
|
||||||
|
bus_config.mosi_io_num = u8g2_esp32_hal.bus.spi.mosi; // MOSI
|
||||||
|
bus_config.miso_io_num = GPIO_NUM_NC; // MISO
|
||||||
|
bus_config.quadwp_io_num = GPIO_NUM_NC; // Not used
|
||||||
|
bus_config.quadhd_io_num = GPIO_NUM_NC; // Not used
|
||||||
|
// ESP_LOGI(TAG, "... Initializing bus.");
|
||||||
|
ESP_ERROR_CHECK(spi_bus_initialize(HOST, &bus_config, 1));
|
||||||
|
|
||||||
|
spi_device_interface_config_t dev_config;
|
||||||
|
dev_config.address_bits = 0;
|
||||||
|
dev_config.command_bits = 0;
|
||||||
|
dev_config.dummy_bits = 0;
|
||||||
|
dev_config.mode = 0;
|
||||||
|
dev_config.duty_cycle_pos = 0;
|
||||||
|
dev_config.cs_ena_posttrans = 0;
|
||||||
|
dev_config.cs_ena_pretrans = 0;
|
||||||
|
dev_config.clock_speed_hz = 10000;
|
||||||
|
dev_config.spics_io_num = u8g2_esp32_hal.bus.spi.cs;
|
||||||
|
dev_config.flags = 0;
|
||||||
|
dev_config.queue_size = 200;
|
||||||
|
dev_config.pre_cb = NULL;
|
||||||
|
dev_config.post_cb = NULL;
|
||||||
|
// ESP_LOGI(TAG, "... Adding device bus.");
|
||||||
|
ESP_ERROR_CHECK(spi_bus_add_device(HOST, &dev_config, &handle_spi));
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case U8X8_MSG_BYTE_SEND: {
|
||||||
|
spi_transaction_t trans_desc;
|
||||||
|
trans_desc.addr = 0;
|
||||||
|
trans_desc.cmd = 0;
|
||||||
|
trans_desc.flags = 0;
|
||||||
|
trans_desc.length = 8 * arg_int; // Number of bits NOT number of bytes.
|
||||||
|
trans_desc.rxlength = 0;
|
||||||
|
trans_desc.tx_buffer = arg_ptr;
|
||||||
|
trans_desc.rx_buffer = NULL;
|
||||||
|
|
||||||
|
// ESP_LOGI(TAG, "... Transmitting %d bytes.", arg_int);
|
||||||
|
ESP_ERROR_CHECK(spi_device_transmit(handle_spi, &trans_desc));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
} // u8g2_esp32_spi_byte_cb
|
||||||
|
|
||||||
|
/*
|
||||||
|
* HAL callback function as prescribed by the U8G2 library. This callback is
|
||||||
|
* invoked to handle I2C communications.
|
||||||
|
*/
|
||||||
|
uint8_t u8g2_esp32_i2c_byte_cb(u8x8_t *u8x8, uint8_t msg, uint8_t arg_int, void *arg_ptr)
|
||||||
|
{
|
||||||
|
ESP_LOGD(TAG, "i2c_cb: Received a msg: %d, arg_int: %d, arg_ptr: %p", msg, arg_int, arg_ptr);
|
||||||
|
|
||||||
|
switch (msg)
|
||||||
|
{
|
||||||
|
case U8X8_MSG_BYTE_SET_DC: {
|
||||||
|
if (u8g2_esp32_hal.dc != U8G2_ESP32_HAL_UNDEFINED)
|
||||||
|
{
|
||||||
|
gpio_set_level(u8g2_esp32_hal.dc, arg_int);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case U8X8_MSG_BYTE_INIT: {
|
||||||
|
if (u8g2_esp32_hal.bus.i2c.sda == U8G2_ESP32_HAL_UNDEFINED ||
|
||||||
|
u8g2_esp32_hal.bus.i2c.scl == U8G2_ESP32_HAL_UNDEFINED)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
i2c_config_t conf = {0};
|
||||||
|
conf.mode = I2C_MODE_MASTER;
|
||||||
|
ESP_LOGI(TAG, "sda_io_num %d", u8g2_esp32_hal.bus.i2c.sda);
|
||||||
|
conf.sda_io_num = u8g2_esp32_hal.bus.i2c.sda;
|
||||||
|
conf.sda_pullup_en = GPIO_PULLUP_ENABLE;
|
||||||
|
ESP_LOGI(TAG, "scl_io_num %d", u8g2_esp32_hal.bus.i2c.scl);
|
||||||
|
conf.scl_io_num = u8g2_esp32_hal.bus.i2c.scl;
|
||||||
|
conf.scl_pullup_en = GPIO_PULLUP_ENABLE;
|
||||||
|
ESP_LOGI(TAG, "clk_speed %d", I2C_MASTER_FREQ_HZ);
|
||||||
|
conf.master.clk_speed = I2C_MASTER_FREQ_HZ;
|
||||||
|
ESP_LOGI(TAG, "i2c_param_config %d", conf.mode);
|
||||||
|
ESP_ERROR_CHECK(i2c_param_config(I2C_MASTER_NUM, &conf));
|
||||||
|
ESP_LOGI(TAG, "i2c_driver_install %d", I2C_MASTER_NUM);
|
||||||
|
ESP_ERROR_CHECK(
|
||||||
|
i2c_driver_install(I2C_MASTER_NUM, conf.mode, I2C_MASTER_RX_BUF_DISABLE, I2C_MASTER_TX_BUF_DISABLE, 0));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case U8X8_MSG_BYTE_SEND: {
|
||||||
|
if (i2c_transfer_failed)
|
||||||
|
{
|
||||||
|
break; // Skip sending if transfer already failed
|
||||||
|
}
|
||||||
|
uint8_t *data_ptr = (uint8_t *)arg_ptr;
|
||||||
|
ESP_LOG_BUFFER_HEXDUMP(TAG, data_ptr, arg_int, ESP_LOG_VERBOSE);
|
||||||
|
|
||||||
|
while (arg_int > 0)
|
||||||
|
{
|
||||||
|
I2C_ERROR_CHECK(i2c_master_write_byte(handle_i2c, *data_ptr, ACK_CHECK_EN));
|
||||||
|
if (i2c_transfer_failed)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
data_ptr++;
|
||||||
|
arg_int--;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case U8X8_MSG_BYTE_START_TRANSFER: {
|
||||||
|
uint8_t i2c_address = u8x8_GetI2CAddress(u8x8);
|
||||||
|
handle_i2c = i2c_cmd_link_create();
|
||||||
|
i2c_transfer_failed = false; // Reset error flag at start of transfer
|
||||||
|
ESP_LOGD(TAG, "Start I2C transfer to %02X.", i2c_address >> 1);
|
||||||
|
I2C_ERROR_CHECK(i2c_master_start(handle_i2c));
|
||||||
|
I2C_ERROR_CHECK(i2c_master_write_byte(handle_i2c, i2c_address | I2C_MASTER_WRITE, ACK_CHECK_EN));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case U8X8_MSG_BYTE_END_TRANSFER: {
|
||||||
|
ESP_LOGD(TAG, "End I2C transfer.");
|
||||||
|
if (!i2c_transfer_failed)
|
||||||
|
{
|
||||||
|
I2C_ERROR_CHECK(i2c_master_stop(handle_i2c));
|
||||||
|
I2C_ERROR_CHECK(i2c_master_cmd_begin(I2C_MASTER_NUM, handle_i2c, pdMS_TO_TICKS(I2C_TIMEOUT_MS)));
|
||||||
|
}
|
||||||
|
i2c_cmd_link_delete(handle_i2c);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
} // u8g2_esp32_i2c_byte_cb
|
||||||
|
|
||||||
|
/*
|
||||||
|
* HAL callback function as prescribed by the U8G2 library. This callback is
|
||||||
|
* invoked to handle callbacks for GPIO and delay functions.
|
||||||
|
*/
|
||||||
|
uint8_t u8g2_esp32_gpio_and_delay_cb(u8x8_t *u8x8, uint8_t msg, uint8_t arg_int, void *arg_ptr)
|
||||||
|
{
|
||||||
|
ESP_LOGD(TAG, "gpio_and_delay_cb: Received a msg: %d, arg_int: %d, arg_ptr: %p", msg, arg_int, arg_ptr);
|
||||||
|
|
||||||
|
switch (msg)
|
||||||
|
{
|
||||||
|
// Initialize the GPIO and DELAY HAL functions. If the pins for DC and
|
||||||
|
// RESET have been specified then we define those pins as GPIO outputs.
|
||||||
|
case U8X8_MSG_GPIO_AND_DELAY_INIT: {
|
||||||
|
uint64_t bitmask = 0;
|
||||||
|
if (u8g2_esp32_hal.dc != U8G2_ESP32_HAL_UNDEFINED)
|
||||||
|
{
|
||||||
|
bitmask = bitmask | (1ull << u8g2_esp32_hal.dc);
|
||||||
|
}
|
||||||
|
if (u8g2_esp32_hal.reset != U8G2_ESP32_HAL_UNDEFINED)
|
||||||
|
{
|
||||||
|
bitmask = bitmask | (1ull << u8g2_esp32_hal.reset);
|
||||||
|
}
|
||||||
|
if (u8g2_esp32_hal.bus.spi.cs != U8G2_ESP32_HAL_UNDEFINED)
|
||||||
|
{
|
||||||
|
bitmask = bitmask | (1ull << u8g2_esp32_hal.bus.spi.cs);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bitmask == 0)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
gpio_config_t gpioConfig;
|
||||||
|
gpioConfig.pin_bit_mask = bitmask;
|
||||||
|
gpioConfig.mode = GPIO_MODE_OUTPUT;
|
||||||
|
gpioConfig.pull_up_en = GPIO_PULLUP_DISABLE;
|
||||||
|
gpioConfig.pull_down_en = GPIO_PULLDOWN_ENABLE;
|
||||||
|
gpioConfig.intr_type = GPIO_INTR_DISABLE;
|
||||||
|
gpio_config(&gpioConfig);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the GPIO reset pin to the value passed in through arg_int.
|
||||||
|
case U8X8_MSG_GPIO_RESET:
|
||||||
|
if (u8g2_esp32_hal.reset != U8G2_ESP32_HAL_UNDEFINED)
|
||||||
|
{
|
||||||
|
gpio_set_level(u8g2_esp32_hal.reset, arg_int);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
// Set the GPIO client select pin to the value passed in through arg_int.
|
||||||
|
case U8X8_MSG_GPIO_CS:
|
||||||
|
if (u8g2_esp32_hal.bus.spi.cs != U8G2_ESP32_HAL_UNDEFINED)
|
||||||
|
{
|
||||||
|
gpio_set_level(u8g2_esp32_hal.bus.spi.cs, arg_int);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
// Set the Software I²C pin to the value passed in through arg_int.
|
||||||
|
case U8X8_MSG_GPIO_I2C_CLOCK:
|
||||||
|
if (u8g2_esp32_hal.bus.i2c.scl != U8G2_ESP32_HAL_UNDEFINED)
|
||||||
|
{
|
||||||
|
gpio_set_level(u8g2_esp32_hal.bus.i2c.scl, arg_int);
|
||||||
|
// printf("%c",(arg_int==1?'C':'c'));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
// Set the Software I²C pin to the value passed in through arg_int.
|
||||||
|
case U8X8_MSG_GPIO_I2C_DATA:
|
||||||
|
if (u8g2_esp32_hal.bus.i2c.sda != U8G2_ESP32_HAL_UNDEFINED)
|
||||||
|
{
|
||||||
|
gpio_set_level(u8g2_esp32_hal.bus.i2c.sda, arg_int);
|
||||||
|
// printf("%c",(arg_int==1?'D':'d'));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
// Delay for the number of milliseconds passed in through arg_int.
|
||||||
|
case U8X8_MSG_DELAY_MILLI:
|
||||||
|
vTaskDelay(arg_int / portTICK_PERIOD_MS);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
} // u8g2_esp32_gpio_and_delay_cb
|
||||||
@@ -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);
|
||||||
|
|
||||||
@@ -33,7 +34,7 @@ void app_main(void)
|
|||||||
|
|
||||||
xTaskCreatePinnedToCore(app_task, "app_task", 8192, NULL, tskIDLE_PRIORITY + 5, NULL,
|
xTaskCreatePinnedToCore(app_task, "app_task", 8192, NULL, tskIDLE_PRIORITY + 5, NULL,
|
||||||
CONFIG_FREERTOS_NUMBER_OF_CORES - 1);
|
CONFIG_FREERTOS_NUMBER_OF_CORES - 1);
|
||||||
xTaskCreatePinnedToCore(ble_manager_task, "ble_manager", 4096, NULL, tskIDLE_PRIORITY + 1, NULL,
|
// xTaskCreatePinnedToCore(ble_manager_task, "ble_manager", 4096, NULL, tskIDLE_PRIORITY + 1, NULL,
|
||||||
CONFIG_FREERTOS_NUMBER_OF_CORES - 1);
|
// CONFIG_FREERTOS_NUMBER_OF_CORES - 1);
|
||||||
}
|
}
|
||||||
__END_DECLS
|
__END_DECLS
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user