Compare commits

..

1 Commits

Author SHA1 Message Date
mars3142 fb00128847 testing OpenThread
Signed-off-by: Peter Siegmund <developer@mars3142.org>
2026-03-29 18:07:03 +02:00
70 changed files with 3259 additions and 1434 deletions
-1
View File
@@ -1 +0,0 @@
*.gz filter=lfs diff=lfs merge=lfs -text
-34
View File
@@ -1,34 +0,0 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "ESP-IDF: App Flash (preserve NVS & SPIFFS)",
"type": "shell",
"command": "${config:idf.toolsPath}/python_env/idf5.5_py3.14_env/bin/python ${config:idf.currentSetup}/tools/idf.py -p ${config:idf.port} app-flash && ${config:idf.toolsPath}/python_env/idf5.5_py3.14_env/bin/python -m esptool --port ${config:idf.port} run",
"options": {
"env": {
"IDF_PATH": "${config:idf.currentSetup}",
"IDF_TOOLS_PATH": "${config:idf.toolsPath}"
}
},
"group": {
"kind": "build",
"isDefault": false
},
"problemMatcher": []
},
{
"label": "ESP-IDF: Storage Flash (SPIFFS only)",
"type": "shell",
"command": "${config:idf.toolsPath}/python_env/idf5.5_py3.14_env/bin/python ${config:idf.currentSetup}/tools/idf.py -p ${config:idf.port} storage-flash && ${config:idf.toolsPath}/python_env/idf5.5_py3.14_env/bin/python -m esptool --port ${config:idf.port} run",
"options": {
"env": {
"IDF_PATH": "${config:idf.currentSetup}",
"IDF_TOOLS_PATH": "${config:idf.toolsPath}"
}
},
"group": "build",
"problemMatcher": []
}
]
}
+41 -4
View File
@@ -4,14 +4,50 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
ESP32 firmware for a model railway system control unit, targeting ESP32-S3 and ESP32-C6 microcontrollers. Built with ESP-IDF 5.4. Includes a Svelte 5 web UI (`website/`) and a REST/WebSocket/MQTT API.
ESP32 firmware for a model railway system control unit, targeting ESP32-C6 microcontrollers. Built with ESP-IDF 5.5. Includes a Svelte 5 web UI (`website/`) and a REST/WebSocket/MQTT API.
## ESP-IDF Environment Setup
ESP-IDF is **not** on PATH by default. The installation lives at a non-standard path; `export.sh` must be sourced before `idf.py` is available.
```bash
# One-time per shell session — activates idf.py, xtensa/riscv toolchains, etc.
. /Users/mars3142/.espressif/v5.5.3/esp-idf/export.sh
```
If `export.sh` cannot find the Python environment it will print an error like
`doesn't exist! Please run the install script`. In that case invoke `idf.py`
directly via the venv Python, which bypasses the PATH check:
```bash
IDF_PYTHON_ENV_PATH=/Users/mars3142/.espressif/tools/python/v5.5.3/venv \
IDF_TOOLS_PATH=/Users/mars3142/.espressif/tools \
/Users/mars3142/.espressif/tools/python/v5.5.3/venv/bin/python \
/Users/mars3142/.espressif/v5.5.3/esp-idf/tools/idf.py <command>
```
Key paths:
- IDF root: `/Users/mars3142/.espressif/v5.5.3/esp-idf/`
- Python venv: `/Users/mars3142/.espressif/tools/python/v5.5.3/venv/`
- IDF tools: `/Users/mars3142/.espressif/tools/`
## Build Commands
```bash
# Firmware
idf.py build
# After sourcing export.sh:
# Firmware (ESP32-C6, default target)
idf.py -DIDF_TARGET=esp32c6 build
# Flash everything (overwrites SPIFFS — paired Thread devices list is lost)
idf.py -p <PORT> flash
# Flash only the app binary (preserves SPIFFS and NVS — use this during development)
idf.py -p <PORT> app-flash
# Flash only the SPIFFS partition (updates menu.json / web assets without touching NVS)
idf.py -p <PORT> storage-flash
idf.py -p <PORT> flash monitor
# Release build (ESP32-C6 only)
@@ -36,6 +72,7 @@ cd website && npm run test
- **hermes** — u8g2 display rendering (menu, splash, screensaver)
- **heimdall** — Button/action manager with callback registration
- **simulator** — Day/night light cycle simulation from CSV schedules
- **iris** — Thread network device manager; split into `iris.c` (public API + state), `iris_storage.c`, `iris_coap.c`, `iris_discovery.c`, `iris_master.c`, `iris_inventory.c`; shared internal header at `include/iris/iris_internal.h`
- `storage/` — Runtime SPIFFS content: `menu.json`, `schema_*.csv`, `www/` (web assets)
- `website/` — Svelte 5 + Vite + Tailwind web UI
@@ -43,7 +80,7 @@ cd website && npm run test
Two targets are supported with distinct pin assignments and `sdkconfig` defaults:
- `sdkconfig.defaults` — base (shared)
- `sdkconfig.defaults.esp32s3` — S3 overrides
- `sdkconfig.defaults.esp32s3` — S3 overrides (obsolete)
- `sdkconfig.defaults.esp32c6` — C6 overrides (includes WiFi enable/antenna GPIO)
Do not assume one target's pins or settings apply to the other.
+82 -49
View File
@@ -386,9 +386,11 @@ Saves a schema file.
### Devices
#### Scan for Devices
Thread device management via the Iris component. Only available when `CONFIG_IRIS_ENABLED=y`. All device identifiers are 16-character hex EUI-64 strings (e.g., `"aabbccddeeff0011"`).
Scans for available Matter devices to pair.
#### Scan for New Joiners
Returns Thread devices that have completed the Commissioner/Joiner flow but have not yet been paired (i.e., not in `iris_devices.bin`). These appear in the OLED menu under "neue Geräte" for manual confirmation.
- **URL:** `/api/devices/scan`
- **Method:** `GET`
@@ -397,29 +399,24 @@ Scans for available Matter devices to pair.
```json
[
{
"id": "matter-001",
"type": "light",
"name": "Matter Lamp"
},
{
"id": "matter-002",
"type": "sensor",
"name": "Temperature Sensor"
"id": "aabbccddeeff0011",
"name": "H2-eeff0011",
"capabilities": 3
}
]
```
| Field | Type | Description |
|-------|--------|-----------------------------------------------|
| id | string | Unique device identifier |
| type | string | Device type: `light`, `sensor`, `unknown` |
| name | string | Device name (can be empty) |
| Field | Type | Description |
|--------------|--------|--------------------------------------------------|
| id | string | EUI-64 (16-char hex) |
| name | string | Auto-generated name (`H2-<last4>`) or from H2 |
| capabilities | number | `IRIS_CAP_*` bitmask (see `README-thread.md`) |
---
#### Pair Device
Pairs a discovered device.
Provisions a new joiner into the paired device list. Queries the H2 for its capabilities via CoAP, stores the device in `iris_devices.bin`. Only available on the Master unit.
- **URL:** `/api/devices/pair`
- **Method:** `POST`
@@ -428,23 +425,24 @@ Pairs a discovered device.
```json
{
"id": "matter-001",
"name": "Living Room Lamp"
"id": "aabbccddeeff0011",
"name": "Wagen 42"
}
```
| Field | Type | Required | Description |
|-------|--------|----------|------------------------------|
| id | string | Yes | Device ID from scan |
| name | string | Yes | User-defined device name |
| Field | Type | Required | Description |
|-------|--------|----------|--------------------------------------|
| id | string | Yes | EUI-64 from scan |
| name | string | No | User-defined name (defaults to `id`) |
- **Response:** `200 OK` on success
- **Response:** `200 OK` with `{"status":"ok"}` on success
- **Error:** `403` if called on Backup unit, `500` if pairing failed
---
#### Get Paired Devices
Returns list of all paired devices.
Returns all devices stored in `iris_devices.bin` with their current runtime state.
- **URL:** `/api/devices/paired`
- **Method:** `GET`
@@ -453,24 +451,28 @@ Returns list of all paired devices.
```json
[
{
"id": "matter-001",
"type": "light",
"name": "Living Room Lamp"
"id": "aabbccddeeff0011",
"name": "Wagen 42",
"capabilities": 3,
"state": 1,
"online": true
}
]
```
| Field | Type | Description |
|-------|--------|-------------------------------------------|
| id | string | Unique device identifier |
| type | string | Device type: `light`, `sensor`, `unknown` |
| name | string | User-defined device name |
| Field | Type | Description |
|--------------|---------|--------------------------------------------------|
| id | string | EUI-64 (16-char hex) |
| name | string | User-defined display name |
| capabilities | number | `IRIS_CAP_*` bitmask |
| state | number | `IRIS_STATE_*` bitmask (last polled value) |
| online | boolean | Whether the device responded in the last poll |
---
#### Update Device Name
Updates the name of a paired device.
Renames a paired device (persisted to `iris_devices.bin`).
- **URL:** `/api/devices/update`
- **Method:** `POST`
@@ -479,23 +481,23 @@ Updates the name of a paired device.
```json
{
"id": "matter-001",
"name": "New Device Name"
"id": "aabbccddeeff0011",
"name": "Wagen 07"
}
```
| Field | Type | Required | Description |
|-------|--------|----------|------------------------|
| id | string | Yes | Device ID |
| name | string | Yes | New device name |
| id | string | Yes | EUI-64 |
| name | string | Yes | New display name |
- **Response:** `200 OK` on success
- **Response:** `200 OK` with `{"status":"ok"}`, `404` if device not found
---
#### Unpair Device
Removes a paired device.
Removes a paired device from `iris_devices.bin`.
- **URL:** `/api/devices/unpair`
- **Method:** `POST`
@@ -504,21 +506,21 @@ Removes a paired device.
```json
{
"id": "matter-001"
"id": "aabbccddeeff0011"
}
```
| Field | Type | Required | Description |
|-------|--------|----------|---------------|
| id | string | Yes | Device ID |
| id | string | Yes | EUI-64 |
- **Response:** `200 OK` on success
- **Response:** `200 OK` with `{"status":"ok"}`, `404` if device not found
---
#### Toggle Device
#### Toggle Device Capability (Unicast)
Toggles a device (e.g., light on/off).
Sends a unicast CoAP `POST /toggle` to one specific device. Intended for individual control from the OLED menu.
- **URL:** `/api/devices/toggle`
- **Method:** `POST`
@@ -527,20 +529,51 @@ Toggles a device (e.g., light on/off).
```json
{
"id": "matter-001"
"id": "aabbccddeeff0011",
"cap": 1
}
```
| Field | Type | Required | Description |
|-------|--------|----------|---------------|
| id | string | Yes | Device ID |
| Field | Type | Required | Description |
|-------|--------|----------|-----------------------------------------------------|
| id | string | Yes | EUI-64 of the target device |
| cap | number | Yes | Exactly one `IRIS_CAP_*` bit (e.g., `1` = inner light) |
- **Response:** `200 OK` on success
- **Response:** `200 OK` with `{"status":"ok"}`, `404` if device not found
---
#### Set All Devices (Multicast)
Sends an explicit on/off state to **all** devices simultaneously via CoAP multicast (`ff03::1`). Use this instead of toggle to ensure all devices reach the same state.
- **URL:** `/api/devices/toggle_all`
- **Method:** `POST`
- **Content-Type:** `application/json`
- **Request Body:**
```json
{
"cap": 1,
"state": 1
}
```
| Field | Type | Required | Description |
|-------|--------|----------|----------------------------------------------------------|
| cap | number | Yes | Exactly one `IRIS_CAP_*` bit |
| state | number | Yes | `1` = activate, `0` = deactivate |
- **Response:** `200 OK` with `{"status":"ok"}`
**Note:** This is a NON (non-confirmable) CoAP multicast — delivery is best-effort. No per-device acknowledgement.
---
### Scenes
> **Not yet implemented.** All scene endpoints return `{"status":"not_implemented"}`. The schema below describes the planned API.
#### Get All Scenes
Returns all configured scenes.
+628
View File
@@ -0,0 +1,628 @@
# Thread Network — Architecture & Protocol Reference
This document describes the Thread network integration for the system-control firmware and serves as the primary reference for implementing compatible ESP32-H2 client devices.
---
## 1. Network Architecture
```
┌──────────────────────────────────────────────────────┐
│ Thread Mesh Network │
│ │
│ [ESP32-C6 Master] [ESP32-C6 Backup] │
│ Border Router Standby │
│ Commissioner (no Commissioner) │
│ │ │ │
│ └────────────┬──────────────┘ │
│ │ │
│ ┌──────────┼──────────┐ │
│ │ │ │ │
│ [H2 #1] [H2 #2] [H2 #N] │
│ FTD FTD FTD │
└──────────────────────────────────────────────────────┘
WiFi / Ethernet (Border Router uplink)
```
**Roles:**
- **ESP32-C6 (Master):** Thread Border Router + Commissioner. Manages device provisioning, sends commands, runs inventory polling. Only one C6 is Master at any time.
- **ESP32-C6 (Backup):** Standby. Monitors the Thread network but does not commission or control devices. Automatically becomes Master if the primary fails.
- **ESP32-H2:** Full Thread Device (FTD). Hosts a CoAP server exposing capabilities and accepting control commands.
**ESP-IDF component:** `openthread` (OpenThread 1.3, enabled via `CONFIG_OPENTHREAD_ENABLED=y`)
---
## 2. Capability Bitmask
The capability and state bitmasks are shared between C6 firmware (Iris component) and H2 firmware. Both sides **must** use identical bit definitions.
```c
/* Capabilities — what the device can do */
#define IRIS_CAP_INNER_LIGHT (1u << 0) /* Innenbeleuchtung */
#define IRIS_CAP_OUTER_LIGHT (1u << 1) /* Außenbeleuchtung */
#define IRIS_CAP_MOVEMENT (1u << 2) /* Bewegung (Oben/Unten) */
/* State — current value of each capability */
#define IRIS_STATE_INNER_LIGHT (1u << 0) /* 1 = on, 0 = off */
#define IRIS_STATE_OUTER_LIGHT (1u << 1) /* 1 = on, 0 = off */
#define IRIS_STATE_MOVEMENT (1u << 2) /* 1 = Oben, 0 = Unten */
```
Example: A wagon with interior lighting and a movement mechanism has `capabilities = 0x05` (bits 0 and 2 set).
---
## 3. Device States and Lifecycle
A device can be in one of three states from the C6's perspective:
| State | Description | Source | C6 Action |
|-------|-------------|--------|-----------|
| **New Joiner** | Never provisioned; wants to join via Commissioner flow | `otCommissionerJoinerCallback` | Show in "neue Geräte" menu for manual "Aufnehmen" |
| **Rejoined** | Previously provisioned and in the Thread network, but C6 lost its SPIFFS record (e.g. after firmware flash) | `GET /discover` response | Auto-restore to `iris_devices.bin` immediately, no user action required |
| **Paired** | In `iris_devices.bin`, actively polled | SPIFFS + inventory task | Normal operation |
### 3.1 Prerequisites
- H2 device must be flashed with firmware that starts the Thread Joiner.
- C6 Master must be active as Commissioner.
- Both devices must know the **PSKd** (Pre-Shared Key for device). Currently a project-wide shared secret configured in `Kconfig` (`CONFIG_IRIS_JOINER_PSKD`).
### 3.2 New Device Joining Flow
```
H2 Firmware C6 Master (Iris)
│ │
│ (power on, Thread not joined) │
│ │
│── otJoinerStart(pskd) ──────────► │
│ │ otCommissionerAddJoiner(eui64, pskd)
│ │ (C6 allows this EUI-64 to join)
│◄── DTLS handshake ───────────────►│
│◄── Commissioner sets Network Key ─│
│ │
│ (H2 is now on Thread network) │
│ │
│◄── CoAP GET /capabilities ────────│ C6 queries H2 capabilities
│─── {"caps": <bitmask>} ──────────►│
│ │ C6 stores device in SPIFFS
│ │ C6 shows device in "externe Geräte"
```
### 3.3 Rejoined Device — Auto-Restore Flow
When the C6 boots after a firmware flash (SPIFFS wiped), all previously paired devices
are still in the Thread network. The discovery sweep finds them automatically:
```
C6 boots (iris_devices.bin empty)
│── NON GET /discover ─────────────► ff03::1
│ │
│ H2 (in network, was previously paired)
│◄── {"eui64":"..","caps":3,"state":1} ───│
C6: EUI-64 not in paired list
→ auto-restore: add to iris_devices.bin
→ device appears in "externe Geräte" immediately
(no user interaction required)
```
### 3.4 Online Detection
Two mechanisms work in parallel for instant online detection:
1. **Neighbor table callback** (`otThreadRegisterNeighborTableCallback`): fires immediately when a device joins or rejoins the Thread network at the Link layer. Sets `online=true` for known devices and wakes the inventory task via `xTaskNotify` for an immediate state poll.
2. **Discovery sweep** (`GET /discover` multicast): runs on boot and every `IRIS_DISCOVERY_INTERVAL_CYCLES` inventory cycles. Finds both known and unknown devices.
### 3.5 H2 Implementation Requirements
The H2 firmware must implement:
```c
// 1. Start Thread Joiner on boot (if not already joined)
otJoinerStart(instance, PSKD, NULL, "Vendor", "Model", "1.0", NULL,
joiner_callback, NULL);
// 2. On successful join, register as CoAP server
otCoapStart(instance, OT_DEFAULT_COAP_PORT); // port 5683
// 3. Register CoAP resources:
// GET /capabilities — static hardware capabilities
// GET /state — current state bitmask
// GET /discover — discovery response (multicast)
// POST /toggle — unicast toggle one capability
// POST /set — multicast explicit state set
```
---
## 4. CoAP Protocol
All communication uses **CoAP over UDP** (RFC 7252). Port **5683** (default CoAP port).
JSON is used for payloads. All fields are integer bitmasks matching the definitions in section 2.
### 4.1 GET /capabilities (Unicast)
Returns the device's static capability bitmask (does not change after boot).
**Request:** `GET coap://[<device_ml_eid>]/capabilities`
**Response:**
```json
{"caps": 5}
```
| Field | Type | Description |
|-------|------|-------------|
| `caps` | uint8 | `IRIS_CAP_*` bitmask |
### 4.2 GET /state (Unicast)
Returns the current state of all capabilities.
**Request:** `GET coap://[<device_ml_eid>]/state`
**Response:**
```json
{"state": 3}
```
| Field | Type | Description |
|-------|------|-------------|
| `state` | uint8 | `IRIS_STATE_*` bitmask |
### 4.3 GET /discover (Multicast)
Used by the C6 Master to find all Iris-capable devices in the Thread network.
Sent as CON or NON to `ff03::1`; every H2 that has completed the Joiner flow responds.
**Request:** `GET coap://[ff03::1]/discover`
**Response** (each H2 sends one response):
```json
{"eui64": "aabbccddeeff0011", "caps": 3, "state": 1, "name": "Wagen 42"}
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `eui64` | string | yes | 16-hex-char EUI-64 identifier |
| `caps` | uint8 | yes | `IRIS_CAP_*` bitmask |
| `state` | uint8 | yes | `IRIS_STATE_*` bitmask (current) |
| `name` | string | no | Stored display name (if H2 persists it); used by C6 when auto-restoring |
**C6 behavior on receiving responses:**
- EUI-64 in `iris_devices.bin` → mark online, update state
- EUI-64 NOT in `iris_devices.bin` → auto-add to `iris_devices.bin` (rejoined device)
**Note:** H2 must subscribe to `ff03::1` to receive this request (see section 7.3).
### 4.4 POST /toggle (Unicast)
Toggles one capability on the addressed device. Intended for 1:1 control from the OLED menu.
**Request:** `POST coap://[<device_ml_eid>]/toggle`
```json
{"cap": 1}
```
| Field | Type | Description |
|-------|------|-------------|
| `cap` | uint8 | Exactly one `IRIS_CAP_*` bit set |
**Response:** `2.04 Changed` (no body)
**Behavior on H2:**
- `IRIS_CAP_INNER_LIGHT`: toggle inner light on/off
- `IRIS_CAP_OUTER_LIGHT`: toggle outer light on/off
- `IRIS_CAP_MOVEMENT`: toggle between Oben (1) and Unten (0)
### 4.5 POST /set (Multicast — Explicit State)
Sets one capability to an **explicit** on/off state on all devices simultaneously.
Sent as NON to `ff03::1`.
**Do not use toggle for multicast.** A toggle command sent to multiple devices would turn
off devices that are already in the target state. `/set` is idempotent: all devices
end up in the same state regardless of their current state.
**Request:** `POST coap://[ff03::1]/set`
```json
{"cap": 1, "state": 1}
```
| Field | Type | Description |
|-------|------|-------------|
| `cap` | uint8 | Exactly one `IRIS_CAP_*` bit set |
| `state` | uint8 | `1` = activate, `0` = deactivate |
**Response:** none (NON, best-effort delivery)
**Behavior on H2:**
- If `cap & MY_CAPS`: apply the requested state directly (do NOT toggle)
- If `cap` not in `MY_CAPS`: ignore
---
## 5. Master/Backup Election Protocol
Two C6 devices can be on the same Thread network. Only one is **Master** (active Commissioner + controller). The other is **Standby** (Backup). Election is automatic and priority-based.
### 5.1 Priority
Each device has a configured priority: `CONFIG_IRIS_MASTER_PRIORITY` (Kconfig, default 1). A higher number means higher preference for Master role. The intended Primary device should be configured with a higher priority (e.g., 2).
### 5.2 State Machine
```
┌─────────────────────────────────────────┐
│ INITIALIZING │
│ (random jitter 01s, then probe) │
└────────────────────┬────────────────────┘
Multicast GET /master_probe
┌─────────────┴─────────────┐
│ │
No response or Response with higher
lower-prio response priority received
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────────┐
│ MASTER │ │ STANDBY │
│ Commissioner on │ │ Commissioner off │
│ Heartbeat every │ │ Monitor heartbeats │
│ 5s via multicast │ │ from Master │
└────────┬─────────┘ └──────────┬───────────┘
│ │
Higher-prio peer Heartbeat timeout
sends heartbeat (15s no heartbeat)
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────────┐
│ STANDBY │ │ INITIALIZING │
│ (yield, become │ │ (re-election) │
│ backup) │ └──────────────────────┘
└──────────────────┘
```
### 5.3 CoAP Election Messages (Multicast `ff03::1`)
| Method | Resource | Description |
|--------|----------|-------------|
| `GET` | `/master_probe` | Query: who is Master? |
| `PUT` | `/master_heartbeat` | Regular keepalive from Master |
| `PUT` | `/master_yield` | Backup acknowledges Master transfer |
**`GET /master_probe` response:**
```json
{"priority": 2, "master": true}
```
**`PUT /master_heartbeat` body:**
```json
{"priority": 2}
```
**`PUT /master_yield` body:**
```json
{"priority": 1}
```
### 5.4 Failback (Primary Returns)
When the Primary (higher priority) returns after a failure:
```
Primary (prio=2) Backup (prio=1, currently MASTER)
│ │
│── PUT /master_heartbeat ─────►│
│ {"priority": 2} │
│ │ (sees higher prio → yield)
│◄── PUT /master_yield ─────────│
│ {"priority": 1} │
│ │ Backup: Commissioner OFF → STANDBY
│ Primary: Commissioner ON │
│ → MASTER │
```
No user interaction required. The OLED display on the Backup shows "BACKUP" after yielding.
---
## 6. SPIFFS Storage Format
Paired devices are stored in `/spiffs/iris_devices.bin` as raw binary (no JSON overhead).
### 6.1 File Layout
```
Offset Size Field
────── ──── ─────────────────────────────────────────────
0 4 magic = 0x49524953 ("IRIS", little-endian)
4 2 version = 1
6 2 count (number of stored devices)
8 N×44 array of iris_device_persisted_t[count]
```
### 6.2 `iris_device_persisted_t` (44 bytes, packed)
```
Offset Size Field
────── ──── ─────────────────────────────────────────────
0 8 eui64[8] — hardware EUI-64
8 32 name[32] — display name, null-terminated
40 1 capabilities — IRIS_CAP_* bitmask
41 1 state — IRIS_STATE_* bitmask (last known)
42 2 _pad — alignment padding, set to 0
```
**Runtime fields** (`online`, `failed_polls`) are NOT stored on disk. After loading, all
devices start as offline; the inventory task sets them online after a successful CoAP
`/state` poll or after `/discover` response.
### 6.3 Flash Survival
The `iris_devices.bin` file lives on the SPIFFS partition. `idf.py flash` overwrites SPIFFS.
Use `idf.py app-flash` during development to preserve paired device data.
After an accidental full flash, the discovery sweep (section 3.3) automatically
restores all devices that are still in the Thread network — no manual re-pairing needed.
### 6.4 Integrity
On read: verify `magic == 0x49524953`. If mismatch (e.g., partial write), treat as empty device list and log an error.
---
## 7. C6 Iris API Reference
Key public functions in `components/iris/include/iris/iris.h`:
| Function | Description |
|----------|-------------|
| `iris_init()` | Init OpenThread, load SPIFFS, register neighbor callback |
| `iris_start_inventory_task()` | Start background poll + run initial discovery |
| `iris_run_discovery()` | Blocking multicast sweep (call from task context) |
| `iris_scan(out, max)` | Get list of new joiners (Commissioner cache) |
| `iris_pair(eui64, name)` | Provision a new joiner into paired list |
| `iris_get_paired(out, max)` | Get all paired devices |
| `iris_toggle(eui64, cap)` | Unicast toggle one capability |
| `iris_set_all(cap, on)` | Multicast explicit state set |
| `iris_unpair(eui64)` | Remove from paired list + SPIFFS |
| `iris_any_has_cap(cap)` | Check if any paired device has a capability |
| `iris_is_master()` | Returns true if this unit is active Master |
### Kconfig parameters (`components/iris/Kconfig`)
| Key | Default | Description |
|-----|---------|-------------|
| `IRIS_MAX_DEVICES` | 32 | Max paired devices (up to 64) |
| `IRIS_INVENTORY_INTERVAL_MS` | 30000 | Poll interval per device |
| `IRIS_OFFLINE_THRESHOLD` | 3 | Failed polls before marking offline |
| `IRIS_DISCOVERY_WINDOW_MS` | 3000 | How long to collect `/discover` responses |
| `IRIS_DISCOVERY_INTERVAL_CYCLES` | 10 | Full discovery every N poll cycles (≈5 min) |
| `IRIS_JOINER_PSKD` | `"JOINPW01"` | PSKd shared with H2 firmware |
| `IRIS_MASTER_PRIORITY` | 1 | Election priority (Primary C6: set to 2) |
| `IRIS_MASTER_HEARTBEAT_INTERVAL_MS` | 5000 | Master heartbeat interval |
| `IRIS_MASTER_FAILOVER_TIMEOUT_MS` | 15000 | Standby failover trigger timeout |
---
## 8. H2 Quickstart (Implementation Reference)
Minimal ESP32-H2 firmware skeleton. Adapt to your project structure.
### 8.1 Thread Stack Init + Join
```c
#include "esp_openthread.h"
#include "openthread/joiner.h"
#include "openthread/coap.h"
#include "openthread/instance.h"
#define JOINER_PSKD "JOINPW01" /* Must match CONFIG_IRIS_JOINER_PSKD on C6 */
static otInstance *s_instance;
static void joiner_callback(otError error, void *ctx) {
if (error == OT_ERROR_NONE) {
ESP_LOGI("H2", "Thread joined successfully");
otCoapStart(s_instance, OT_DEFAULT_COAP_PORT);
coap_register_resources(s_instance);
} else {
ESP_LOGE("H2", "Thread join failed: %d", error);
// Retry after delay
}
}
void thread_init(void) {
esp_openthread_platform_config_t config = {
.radio_config = { .radio_mode = RADIO_MODE_NATIVE },
.host_config = { .host_connection_mode = HOST_CONNECTION_MODE_NONE },
.port_config = { .storage_partition_name = "nvs",
.netif_queue_size = 10, .task_queue_size = 10 },
};
esp_openthread_init(&config);
s_instance = esp_openthread_get_instance();
otJoinerStart(s_instance, JOINER_PSKD, NULL,
"MyVendor", "ModelRailH2", "1.0",
NULL, joiner_callback, NULL);
// Blocks — run in a dedicated FreeRTOS task
esp_openthread_launch_mainloop();
}
```
### 8.2 CoAP Server Resources
```c
/* Device capabilities — set based on hardware */
#define MY_CAPS (IRIS_CAP_INNER_LIGHT | IRIS_CAP_MOVEMENT)
static uint8_t s_state = 0;
/* Optional: persist name in NVS so /discover can return it */
static const char *MY_NAME = "Wagen 01";
static void handle_capabilities(void *ctx, otMessage *msg,
const otMessageInfo *info) {
char buf[32];
snprintf(buf, sizeof(buf), "{\"caps\":%u}", (unsigned)MY_CAPS);
// ... send CoAP response with buf ...
}
static void handle_state(void *ctx, otMessage *msg,
const otMessageInfo *info) {
char buf[32];
snprintf(buf, sizeof(buf), "{\"state\":%u}", (unsigned)s_state);
// ... send CoAP response with buf ...
}
/* GET /discover — multicast discovery response */
static void handle_discover(void *ctx, otMessage *msg,
const otMessageInfo *info) {
otExtAddress eui64;
otLinkGetExtendedAddress(s_instance, &eui64);
char eui_str[17];
for (int i = 0; i < 8; i++)
snprintf(eui_str + i * 2, 3, "%02x", eui64.m8[i]);
char buf[128];
snprintf(buf, sizeof(buf),
"{\"eui64\":\"%s\",\"caps\":%u,\"state\":%u,\"name\":\"%s\"}",
eui_str, (unsigned)MY_CAPS, (unsigned)s_state, MY_NAME);
// ... send CoAP response with buf ...
}
/* POST /toggle — unicast toggle from OLED menu */
static void handle_toggle(void *ctx, otMessage *msg,
const otMessageInfo *info) {
char buf[64] = {};
uint16_t len = otMessageGetLength(msg) - otMessageGetOffset(msg);
if (len >= sizeof(buf)) len = sizeof(buf) - 1;
otMessageRead(msg, otMessageGetOffset(msg), buf, len);
unsigned cap = 0;
sscanf(buf, "{\"cap\":%u}", &cap);
if (cap & MY_CAPS) {
s_state ^= (uint8_t)cap; // toggle the bit
apply_state(s_state);
}
// ... send 2.04 Changed ...
}
/* POST /set — multicast explicit state ("Alle Innen AN/AUS") */
static void handle_set(void *ctx, otMessage *msg,
const otMessageInfo *info) {
char buf[64] = {};
uint16_t len = otMessageGetLength(msg) - otMessageGetOffset(msg);
if (len >= sizeof(buf)) len = sizeof(buf) - 1;
otMessageRead(msg, otMessageGetOffset(msg), buf, len);
unsigned cap = 0, state = 0;
sscanf(buf, "{\"cap\":%u,\"state\":%u}", &cap, &state);
if (cap & MY_CAPS) {
// Apply explicit state — do NOT toggle
if (state)
s_state |= (uint8_t)cap;
else
s_state &= (uint8_t)~cap;
apply_state(s_state);
}
// NON request — no response needed
}
static otCoapResource s_res_caps = {"capabilities", handle_capabilities, NULL, NULL};
static otCoapResource s_res_state = {"state", handle_state, NULL, NULL};
static otCoapResource s_res_discover = {"discover", handle_discover, NULL, NULL};
static otCoapResource s_res_toggle = {"toggle", handle_toggle, NULL, NULL};
static otCoapResource s_res_set = {"set", handle_set, NULL, NULL};
void coap_register_resources(otInstance *inst) {
otCoapAddResource(inst, &s_res_caps);
otCoapAddResource(inst, &s_res_state);
otCoapAddResource(inst, &s_res_discover);
otCoapAddResource(inst, &s_res_toggle);
otCoapAddResource(inst, &s_res_set);
}
```
### 8.3 Multicast Subscription
To receive multicast commands and discovery requests on `ff03::1`:
```c
otIp6Address multicast_addr;
otIp6AddressFromString("ff03::1", &multicast_addr);
otIp6SubscribeMulticastAddress(s_instance, &multicast_addr);
```
This is typically handled automatically by the Thread stack for realm-local scope, but
explicit subscription ensures the CoAP server receives these datagrams.
---
## 9. Build Environment (C6 Firmware)
### 9.1 Required sdkconfig settings
The following must be present in `sdkconfig.defaults.esp32c6` (already set):
```
CONFIG_OPENTHREAD_ENABLED=y
CONFIG_OPENTHREAD_BORDER_ROUTER=y
CONFIG_OPENTHREAD_COMMISSIONER=y
CONFIG_OPENTHREAD_RADIO_NATIVE=y
CONFIG_LWIP_IPV6=y
CONFIG_LWIP_IPV6_NUM_ADDRESSES=12 # must be 12 (OpenThread requirement)
CONFIG_MBEDTLS_SSL_PROTO_DTLS=y # DTLS required for Commissioner/Joiner
CONFIG_MBEDTLS_KEY_EXCHANGE_ECJPAKE=y
CONFIG_MBEDTLS_ECJPAKE_C=y
CONFIG_IRIS_ENABLED=y
```
### 9.2 Build commands
```bash
# Initialize ESP-IDF 5.5.3
. /Users/mars3142/.espressif/v5.5.3/esp-idf/export.sh
# Build for ESP32-C6
cd /Users/mars3142/Coding/git.mars3142.dev/system-control/firmware
idf.py -DIDF_TARGET=esp32c6 build
# Flash only the app (preserves SPIFFS / paired device list)
idf.py -p <PORT> app-flash
# Flash everything including SPIFFS (paired device list will be auto-restored
# on next boot via discovery sweep — see section 3.3)
idf.py -p <PORT> flash
```
If `export.sh` cannot find the Python environment, invoke `idf.py` directly:
```bash
IDF_PYTHON_ENV_PATH=/Users/mars3142/.espressif/tools/python/v5.5.3/venv \
IDF_TOOLS_PATH=/Users/mars3142/.espressif/tools \
/Users/mars3142/.espressif/tools/python/v5.5.3/venv/bin/python \
/Users/mars3142/.espressif/v5.5.3/esp-idf/tools/idf.py -DIDF_TARGET=esp32c6 build
```
### 9.3 Source layout
```
components/iris/
include/iris/
iris.h ← public API
iris_internal.h ← private shared state (extern variables + internal declarations)
src/
iris.c ← state definitions, public API impl, stubs
iris_storage.c ← spiffs_save / spiffs_load
iris_coap.c ← eui64_to_ml_eid, coap_get, coap_post
iris_discovery.c ← neighbor callback, joiner callback, /discover, iris_run_discovery
iris_master.c ← master election, heartbeat, iris_master_task
iris_inventory.c ← iris_inventory_task
Kconfig
CMakeLists.txt
```
+59 -8
View File
@@ -1,15 +1,66 @@
## Systen Control
## System Control
### ESP32-S3 (folder: main)
ESP32 firmware for a model railway system control unit.
This is an implementation of my custom system control project (custom pcb with Lolin ESP32-S3 Mini) and LED strip.
### Hardware
The build process is straight forward with ESP-IDF. We used version 5.4 while development and the github actions tried to compile for multiple ESP-IDF versions, so we are safe.
Custom PCB with **ESP32-C6** (primary target). The ESP32-S3 target exists in the codebase but is no longer actively maintained.
### Desktop (folder: src)
### Features
It's included also a desktop application (with SDL3), so you can test the project without any MCU.
- WS2812 LED strip control with day/night simulation (CSV schedules)
- SSD1306 OLED display with dynamic menu (driven by `menu.json`)
- REST + WebSocket + MQTT API
- WiFi station mode + captive portal AP mode for initial setup
- Thread network integration (**Iris** component) — manages ESP32-H2 model railway accessories via CoAP
- mDNS hostname: `system-control.local`
### Global Information
### Repository Layout
The projects can be generated from the root, because here is the starting CMakeLists.txt file.
| Path | Description |
|------|-------------|
| `main/` | Firmware entry point (`app_main`, `app_task`) |
| `components/` | ESP-IDF components (see below) |
| `storage/` | SPIFFS runtime content: `menu.json`, CSV schemas, web assets |
| `website/` | Svelte 5 + Vite + Tailwind web UI |
| `partitions.csv` | Flash layout (NVS 16 KB, APP 2 MB, SPIFFS 1.8 MB) |
### Components
| Component | Description |
|-----------|-------------|
| `bifrost` | HTTP REST + WebSocket server, mDNS, static file serving |
| `connectivity-manager` | WiFi, BLE, captive portal / DNS hijacking |
| `led-manager` | WS2812 strip control, effects, status LED |
| `message-manager` | Observer/broadcast bus for cross-component events |
| `persistence-manager` | NVS abstraction with namespace-scoped typed read/write |
| `mercedes` | Dynamic menu data model (C++ singleton, JSON-driven) |
| `hermes` | u8g2 display rendering (menu, splash, screensaver) |
| `heimdall` | Button/action manager with callback registration |
| `simulator` | Day/night light cycle simulation from CSV schedules |
| `iris` | Thread network device manager (ESP32-H2 accessories via CoAP) |
### Build
Requires ESP-IDF 5.5.3. See `CLAUDE.md` for full build instructions.
```bash
# Source ESP-IDF environment
. /Users/mars3142/.espressif/v5.5.3/esp-idf/export.sh
# Build
idf.py -DIDF_TARGET=esp32c6 build
# Flash app only (preserves SPIFFS — Thread device list intact)
idf.py -p <PORT> app-flash
```
### Documentation
| File | Description |
|------|-------------|
| `README-API.md` | REST + WebSocket API reference |
| `README-thread.md` | Thread network (Iris) architecture and H2 implementation guide |
| `README-menu.md` | `menu.json` schema |
| `README-captive.md` | Captive portal behaviour |
| `CLAUDE.md` | Claude Code build and architecture reference |
@@ -22,4 +22,5 @@ idf_component_register(SRCS
message-manager
my_mqtt_client
heimdall
iris
)
@@ -38,13 +38,14 @@ extern "C"
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)
// Devices API (Iris / Thread)
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);
esp_err_t api_devices_toggle_all_handler(httpd_req_t *req);
// Scenes API
esp_err_t api_scenes_get_handler(httpd_req_t *req);
@@ -159,6 +159,12 @@ esp_err_t api_handlers_register(httpd_handle_t server)
if (err != ESP_OK)
return err;
httpd_uri_t devices_toggle_all = {
.uri = "/api/devices/toggle_all", .method = HTTP_POST, .handler = api_devices_toggle_all_handler};
err = httpd_register_uri_handler(server, &devices_toggle_all);
if (err != ESP_OK)
return err;
// Scenes endpoints
httpd_uri_t scenes_get = {.uri = "/api/scenes", .method = HTTP_GET, .handler = api_scenes_get_handler};
err = httpd_register_uri_handler(server, &scenes_get);
@@ -1,207 +1,319 @@
#include "bifrost/api_handlers.h"
#include "bifrost/api_handlers_util.h"
#include <esp_heap_caps.h>
#include <cJSON.h>
#include <esp_log.h>
#include <string.h>
#if defined(CONFIG_IRIS_ENABLED)
#include "iris/iris.h"
#endif
static const char *TAG = "api_devices";
// ============================================================================
// Devices API (Matter)
// Devices API (Iris / Thread)
// ============================================================================
esp_err_t api_devices_scan_handler(httpd_req_t *req)
{
ESP_LOGI(TAG, "GET /api/devices/scan");
// TODO: Implement Matter device scanning
const char *response = "["
"{\"id\":\"matter-001\",\"type\":\"light\",\"name\":\"Matter Lamp\"},"
"{\"id\":\"matter-002\",\"type\":\"sensor\",\"name\":\"Temperature Sensor\"}"
"]";
return send_json_response(req, response);
#if defined(CONFIG_IRIS_ENABLED)
iris_device_t devices[8];
int count = iris_scan(devices, 8);
cJSON *arr = cJSON_CreateArray();
for (int i = 0; i < count; i++)
{
char eui_str[17];
iris_eui64_to_str(devices[i].p.eui64, eui_str, sizeof(eui_str));
cJSON *obj = cJSON_CreateObject();
cJSON_AddStringToObject(obj, "id", eui_str);
cJSON_AddStringToObject(obj, "name", devices[i].p.name);
cJSON_AddNumberToObject(obj, "capabilities", devices[i].p.capabilities);
cJSON_AddItemToArray(arr, obj);
}
char *resp = cJSON_PrintUnformatted(arr);
cJSON_Delete(arr);
esp_err_t res = send_json_response(req, resp);
free(resp);
return res;
#else
return send_json_response(req, "[]");
#endif
}
esp_err_t api_devices_pair_handler(httpd_req_t *req)
{
ESP_LOGI(TAG, "POST /api/devices/pair");
ESP_LOGI(TAG, "Request content length: %d", req->content_len);
char buf[256];
int ret = httpd_req_recv(req, buf, sizeof(buf) - 1);
if (ret <= 0)
{
return send_error_response(req, 400, "Failed to receive request body");
}
buf[ret] = '\0';
ESP_LOGI(TAG, "Pairing device: %s", buf);
#if defined(CONFIG_IRIS_ENABLED)
cJSON *json = cJSON_Parse(buf);
if (!json)
return send_error_response(req, 400, "Invalid JSON");
cJSON *id_item = cJSON_GetObjectItem(json, "id");
cJSON *name_item = cJSON_GetObjectItem(json, "name");
if (!cJSON_IsString(id_item))
{
cJSON_Delete(json);
return send_error_response(req, 400, "Missing field 'id'");
}
uint8_t eui64[IRIS_EUI64_LEN];
if (!iris_str_to_eui64(id_item->valuestring, eui64))
{
cJSON_Delete(json);
return send_error_response(req, 400, "Invalid device id (expected 16-char hex EUI-64)");
}
const char *name = cJSON_IsString(name_item) ? name_item->valuestring : id_item->valuestring;
esp_err_t err = iris_pair(eui64, name);
cJSON_Delete(json);
// TODO: Implement Matter device pairing
set_cors_headers(req);
return httpd_resp_sendstr(req, "{\"status\":\"ok\"}");
if (err == ESP_OK)
return httpd_resp_sendstr(req, "{\"status\":\"ok\"}");
if (err == ESP_ERR_NOT_SUPPORTED)
return send_error_response(req, 403, "Not allowed on Backup unit");
return send_error_response(req, 500, "Pairing failed");
#else
set_cors_headers(req);
return httpd_resp_sendstr(req, "{\"status\":\"not_supported\"}");
#endif
}
esp_err_t api_devices_paired_handler(httpd_req_t *req)
{
ESP_LOGI(TAG, "GET /api/devices/paired");
// TODO: Get list of paired devices
const char *response = "["
"{\"id\":\"matter-001\",\"type\":\"light\",\"name\":\"Living Room Lamp\"}"
"]";
return send_json_response(req, response);
#if defined(CONFIG_IRIS_ENABLED)
iris_device_t devices[CONFIG_IRIS_MAX_DEVICES];
int count = iris_get_paired(devices, CONFIG_IRIS_MAX_DEVICES);
cJSON *arr = cJSON_CreateArray();
for (int i = 0; i < count; i++)
{
char eui_str[17];
iris_eui64_to_str(devices[i].p.eui64, eui_str, sizeof(eui_str));
cJSON *obj = cJSON_CreateObject();
cJSON_AddStringToObject(obj, "id", eui_str);
cJSON_AddStringToObject(obj, "name", devices[i].p.name);
cJSON_AddNumberToObject(obj, "capabilities", devices[i].p.capabilities);
cJSON_AddNumberToObject(obj, "state", devices[i].p.state);
cJSON_AddBoolToObject(obj, "online", devices[i].online);
cJSON_AddItemToArray(arr, obj);
}
char *resp = cJSON_PrintUnformatted(arr);
cJSON_Delete(arr);
esp_err_t res = send_json_response(req, resp);
free(resp);
return res;
#else
return send_json_response(req, "[]");
#endif
}
esp_err_t api_devices_update_handler(httpd_req_t *req)
{
ESP_LOGI(TAG, "POST /api/devices/update");
ESP_LOGI(TAG, "Request content length: %d", req->content_len);
char buf[256];
int ret = httpd_req_recv(req, buf, sizeof(buf) - 1);
if (ret <= 0)
{
return send_error_response(req, 400, "Failed to receive request body");
}
buf[ret] = '\0';
ESP_LOGI(TAG, "Updating device: %s", buf);
#if defined(CONFIG_IRIS_ENABLED)
cJSON *json = cJSON_Parse(buf);
if (!json)
return send_error_response(req, 400, "Invalid JSON");
cJSON *id_item = cJSON_GetObjectItem(json, "id");
cJSON *name_item = cJSON_GetObjectItem(json, "name");
if (!cJSON_IsString(id_item) || !cJSON_IsString(name_item))
{
cJSON_Delete(json);
return send_error_response(req, 400, "Missing fields 'id' or 'name'");
}
uint8_t eui64[IRIS_EUI64_LEN];
esp_err_t err = ESP_ERR_NOT_FOUND;
if (iris_str_to_eui64(id_item->valuestring, eui64))
err = iris_rename(eui64, name_item->valuestring);
cJSON_Delete(json);
// TODO: Update device name
set_cors_headers(req);
return httpd_resp_sendstr(req, "{\"status\":\"ok\"}");
if (err == ESP_OK)
return httpd_resp_sendstr(req, "{\"status\":\"ok\"}");
if (err == ESP_ERR_NOT_FOUND)
return send_error_response(req, 404, "Device not found");
return send_error_response(req, 500, "Update failed");
#else
set_cors_headers(req);
return httpd_resp_sendstr(req, "{\"status\":\"not_supported\"}");
#endif
}
esp_err_t api_devices_unpair_handler(httpd_req_t *req)
{
ESP_LOGI(TAG, "POST /api/devices/unpair");
ESP_LOGI(TAG, "Request content length: %d", req->content_len);
char buf[128];
int ret = httpd_req_recv(req, buf, sizeof(buf) - 1);
if (ret <= 0)
{
return send_error_response(req, 400, "Failed to receive request body");
}
buf[ret] = '\0';
ESP_LOGI(TAG, "Unpairing device: %s", buf);
#if defined(CONFIG_IRIS_ENABLED)
cJSON *json = cJSON_Parse(buf);
if (!json)
return send_error_response(req, 400, "Invalid JSON");
cJSON *id_item = cJSON_GetObjectItem(json, "id");
if (!cJSON_IsString(id_item))
{
cJSON_Delete(json);
return send_error_response(req, 400, "Missing field 'id'");
}
uint8_t eui64[IRIS_EUI64_LEN];
esp_err_t err = ESP_ERR_NOT_FOUND;
if (iris_str_to_eui64(id_item->valuestring, eui64))
err = iris_unpair(eui64);
cJSON_Delete(json);
// TODO: Unpair device
set_cors_headers(req);
return httpd_resp_sendstr(req, "{\"status\":\"ok\"}");
if (err == ESP_OK)
return httpd_resp_sendstr(req, "{\"status\":\"ok\"}");
if (err == ESP_ERR_NOT_FOUND)
return send_error_response(req, 404, "Device not found");
return send_error_response(req, 500, "Unpair failed");
#else
set_cors_headers(req);
return httpd_resp_sendstr(req, "{\"status\":\"not_supported\"}");
#endif
}
esp_err_t api_devices_toggle_handler(httpd_req_t *req)
{
ESP_LOGI(TAG, "POST /api/devices/toggle");
ESP_LOGI(TAG, "Request content length: %d", req->content_len);
char buf[128];
int ret = httpd_req_recv(req, buf, sizeof(buf) - 1);
if (ret <= 0)
{
return send_error_response(req, 400, "Failed to receive request body");
}
buf[ret] = '\0';
ESP_LOGI(TAG, "Toggling device: %s", buf);
#if defined(CONFIG_IRIS_ENABLED)
cJSON *json = cJSON_Parse(buf);
if (!json)
return send_error_response(req, 400, "Invalid JSON");
cJSON *id_item = cJSON_GetObjectItem(json, "id");
cJSON *cap_item = cJSON_GetObjectItem(json, "cap");
if (!cJSON_IsString(id_item) || !cJSON_IsNumber(cap_item))
{
cJSON_Delete(json);
return send_error_response(req, 400, "Missing fields 'id' or 'cap'");
}
uint8_t eui64[IRIS_EUI64_LEN];
uint8_t cap = (uint8_t)cap_item->valuedouble;
esp_err_t err = ESP_ERR_NOT_FOUND;
if (iris_str_to_eui64(id_item->valuestring, eui64))
err = iris_toggle(eui64, cap);
cJSON_Delete(json);
// TODO: Toggle device
set_cors_headers(req);
return httpd_resp_sendstr(req, "{\"status\":\"ok\"}");
if (err == ESP_OK)
return httpd_resp_sendstr(req, "{\"status\":\"ok\"}");
if (err == ESP_ERR_NOT_FOUND)
return send_error_response(req, 404, "Device not found");
return send_error_response(req, 500, "Toggle failed");
#else
set_cors_headers(req);
return httpd_resp_sendstr(req, "{\"status\":\"not_supported\"}");
#endif
}
esp_err_t api_devices_toggle_all_handler(httpd_req_t *req)
{
ESP_LOGI(TAG, "POST /api/devices/toggle_all");
char buf[64];
int ret = httpd_req_recv(req, buf, sizeof(buf) - 1);
if (ret <= 0)
return send_error_response(req, 400, "Failed to receive request body");
buf[ret] = '\0';
#if defined(CONFIG_IRIS_ENABLED)
cJSON *json = cJSON_Parse(buf);
if (!json)
return send_error_response(req, 400, "Invalid JSON");
cJSON *cap_item = cJSON_GetObjectItem(json, "cap");
cJSON *state_item = cJSON_GetObjectItem(json, "state");
if (!cJSON_IsNumber(cap_item) || !cJSON_IsNumber(state_item))
{
cJSON_Delete(json);
return send_error_response(req, 400, "Missing fields 'cap' and/or 'state'");
}
uint8_t cap = (uint8_t)cap_item->valuedouble;
bool on = (state_item->valuedouble != 0.0);
cJSON_Delete(json);
esp_err_t err = iris_set_all(cap, on);
set_cors_headers(req);
if (err == ESP_OK)
return httpd_resp_sendstr(req, "{\"status\":\"ok\"}");
return send_error_response(req, 500, "Multicast set failed");
#else
set_cors_headers(req);
return httpd_resp_sendstr(req, "{\"status\":\"not_supported\"}");
#endif
}
// ============================================================================
// Scenes API
// Scenes API (placeholder — not yet implemented)
// ============================================================================
esp_err_t api_scenes_get_handler(httpd_req_t *req)
{
ESP_LOGI(TAG, "GET /api/scenes");
// TODO: Get scenes from storage
const char *response = "["
"{"
"\"id\":\"scene-1\","
"\"name\":\"Evening Mood\","
"\"icon\":\"🌅\","
"\"actions\":{"
"\"light\":\"on\","
"\"mode\":\"simulation\","
"\"schema\":\"schema_02.csv\""
"}"
"},"
"{"
"\"id\":\"scene-2\","
"\"name\":\"Night Mode\","
"\"icon\":\"🌙\","
"\"actions\":{"
"\"light\":\"on\","
"\"mode\":\"night\""
"}"
"}"
"]";
return send_json_response(req, response);
return send_json_response(req, "[]");
}
esp_err_t api_scenes_post_handler(httpd_req_t *req)
{
ESP_LOGI(TAG, "POST /api/scenes");
ESP_LOGI(TAG, "Request content length: %d", req->content_len);
char buf[512];
int ret = httpd_req_recv(req, buf, sizeof(buf) - 1);
if (ret <= 0)
{
return send_error_response(req, 400, "Failed to receive request body");
}
buf[ret] = '\0';
ESP_LOGI(TAG, "Creating/updating scene: %s", buf);
// TODO: Save scene to storage
set_cors_headers(req);
return httpd_resp_sendstr(req, "{\"status\":\"ok\"}");
return httpd_resp_sendstr(req, "{\"status\":\"not_implemented\"}");
}
esp_err_t api_scenes_delete_handler(httpd_req_t *req)
{
ESP_LOGI(TAG, "DELETE /api/scenes");
char buf[128];
int ret = httpd_req_recv(req, buf, sizeof(buf) - 1);
if (ret <= 0)
{
return send_error_response(req, 400, "Failed to receive request body");
}
buf[ret] = '\0';
ESP_LOGI(TAG, "Deleting scene: %s", buf);
// TODO: Delete scene from storage
set_cors_headers(req);
return httpd_resp_sendstr(req, "{\"status\":\"ok\"}");
return httpd_resp_sendstr(req, "{\"status\":\"not_implemented\"}");
}
esp_err_t api_scenes_activate_handler(httpd_req_t *req)
{
ESP_LOGI(TAG, "POST /api/scenes/activate");
ESP_LOGI(TAG, "Request content length: %d", req->content_len);
char buf[128];
int ret = httpd_req_recv(req, buf, sizeof(buf) - 1);
if (ret <= 0)
{
return send_error_response(req, 400, "Failed to receive request body");
}
buf[ret] = '\0';
ESP_LOGI(TAG, "Activating scene: %s", buf);
// TODO: Activate scene
set_cors_headers(req);
return httpd_resp_sendstr(req, "{\"status\":\"ok\"}");
return httpd_resp_sendstr(req, "{\"status\":\"not_implemented\"}");
}
@@ -174,32 +174,23 @@ esp_err_t api_light_status_handler(httpd_req_t *req)
// LED Configuration API
// ============================================================================
static int compare_segments_by_start(const void *a, const void *b)
{
const led_segment_t *seg_a = (const led_segment_t *)a;
const led_segment_t *seg_b = (const led_segment_t *)b;
return (int)seg_a->start - (int)seg_b->start;
}
esp_err_t api_wled_config_get_handler(httpd_req_t *req)
{
ESP_LOGI(TAG, "GET /api/wled/config");
extern led_segment_t segments[LED_SEGMENT_MAX_LEN];
extern size_t segment_count;
size_t required_size = sizeof(segments) * segment_count;
cJSON *json = cJSON_CreateObject();
persistence_manager_t pm;
if (persistence_manager_init(&pm, "led_config") == ESP_OK)
{
segment_count = persistence_manager_get_int(&pm, "segment_count", 0);
size_t required_size = sizeof(led_segment_t) * segment_count;
persistence_manager_get_blob(&pm, "segments", segments, required_size, NULL);
uint8_t segment_count = persistence_manager_get_int(&pm, "segment_count", 0);
persistence_manager_deinit(&pm);
qsort(segments, segment_count, sizeof(led_segment_t), compare_segments_by_start);
cJSON *segments_arr = cJSON_CreateArray();
for (uint8_t i = 0; i < segment_count; ++i)
{
@@ -287,8 +278,6 @@ esp_err_t api_wled_config_post_handler(httpd_req_t *req)
}
cJSON_Delete(json);
qsort(segments, segment_count, sizeof(led_segment_t), compare_segments_by_start);
persistence_manager_t pm;
if (persistence_manager_init(&pm, "led_config") == ESP_OK)
{
@@ -20,15 +20,12 @@ esp_err_t api_static_file_handler(httpd_req_t *req)
const char *uri = req->uri;
wifi_mode_t mode = 0;
esp_wifi_get_mode(&mode);
// In AP mode, redirect root to SPA captive portal route
// Always serve captive.html in AP mode
if (mode == WIFI_MODE_AP || mode == WIFI_MODE_APSTA)
{
if (strcmp(uri, "/") == 0 || strcmp(uri, "/index.html") == 0)
{
httpd_resp_set_status(req, "302 Found");
httpd_resp_set_hdr(req, "Location", "/#/captive");
httpd_resp_send(req, NULL, 0);
return ESP_OK;
uri = "/captive.html";
}
}
else
@@ -112,8 +109,31 @@ esp_err_t api_captive_portal_handler(httpd_req_t *req)
{
ESP_LOGI(TAG, "Captive portal detection: %s", req->uri);
httpd_resp_set_status(req, "302 Found");
httpd_resp_set_hdr(req, "Location", "/#/captive");
httpd_resp_send(req, NULL, 0);
// Serve captive.html directly (status 200, text/html)
const char *base_path = CONFIG_API_SERVER_STATIC_FILES_PATH;
char filepath[256];
snprintf(filepath, sizeof(filepath), "%s/captive.html", base_path);
FILE *f = fopen(filepath, "r");
if (!f)
{
ESP_LOGE(TAG, "captive.html not found: %s", filepath);
httpd_resp_set_status(req, "500 Internal Server Error");
httpd_resp_sendstr(req, "Captive portal not available");
return ESP_FAIL;
}
httpd_resp_set_type(req, "text/html");
char buf[512];
size_t read_bytes;
while ((read_bytes = fread(buf, 1, sizeof(buf), f)) > 0)
{
if (httpd_resp_send_chunk(req, buf, read_bytes) != ESP_OK)
{
fclose(f);
ESP_LOGE(TAG, "Failed to send captive chunk");
return ESP_FAIL;
}
}
fclose(f);
httpd_resp_send_chunk(req, NULL, 0);
return ESP_OK;
}
@@ -6,6 +6,7 @@
#include <esp_heap_caps.h>
#include <esp_log.h>
#include <esp_wifi.h>
#include <sdkconfig.h>
#include <string.h>
static const char *TAG = "api_wifi";
@@ -18,10 +19,11 @@ esp_err_t api_capabilities_get_handler(httpd_req_t *req)
{
ESP_LOGI(TAG, "GET /api/capabilities");
// Thread only available for esp32c6 or esp32h2
bool thread = false;
#if defined(CONFIG_IDF_TARGET_ESP32C6) || defined(CONFIG_IDF_TARGET_ESP32H2)
thread = false;
#if defined(CONFIG_IRIS_ENABLED)
thread = true;
#endif
#endif
cJSON *json = cJSON_CreateObject();
cJSON_AddBoolToObject(json, "thread", thread);
@@ -97,11 +97,7 @@ void wifi_manager_init()
s_wifi_event_group = xEventGroupCreate();
ESP_ERROR_CHECK(esp_netif_init());
esp_err_t err = esp_event_loop_create_default();
if (err != ESP_OK && err != ESP_ERR_INVALID_STATE)
{
ESP_ERROR_CHECK(err);
}
ESP_ERROR_CHECK(esp_event_loop_create_default());
// Default WiFi Station
esp_netif_create_default_wifi_sta();
@@ -4,6 +4,5 @@ idf_component_register(SRCS
INCLUDE_DIRS "include"
REQUIRES
mercedes
simulator
u8g2
)
@@ -1,9 +1,6 @@
#include "hermes/screensaver/clock_screensaver.h"
#include "simulator.h"
#include <ctime>
#include <cstring>
ClockScreensaver::ClockScreensaver(u8g2_t *u8g2) : m_u8g2(u8g2)
{
@@ -11,14 +8,6 @@ ClockScreensaver::ClockScreensaver(u8g2_t *u8g2) : m_u8g2(u8g2)
void ClockScreensaver::get_time_string(char *buffer, size_t bufferSize)
{
const char *sim_time = get_time();
if (sim_time != NULL)
{
strncpy(buffer, sim_time, bufferSize - 1);
buffer[bufferSize - 1] = '\0';
return;
}
time_t rawtime;
struct tm *timeinfo;
time(&rawtime);
+17
View File
@@ -0,0 +1,17 @@
idf_component_register(
SRCS
"src/iris.c"
"src/iris_storage.c"
"src/iris_coap.c"
"src/iris_discovery.c"
"src/iris_master.c"
"src/iris_inventory.c"
INCLUDE_DIRS "include"
REQUIRES
openthread
spiffs
freertos
log
json
vfs
)
+92
View File
@@ -0,0 +1,92 @@
menu "Iris Thread Manager"
depends on IDF_TARGET_ESP32C6 || IDF_TARGET_ESP32H2
config IRIS_ENABLED
bool "Enable Iris Thread device management"
default y
depends on OPENTHREAD_ENABLED
help
Enables the Thread Border Router, Commissioner, and device management.
Requires ESP32-C6 or ESP32-H2 target with OpenThread support.
config IRIS_MAX_DEVICES
int "Maximum number of paired Thread devices"
default 32
range 1 64
depends on IRIS_ENABLED
help
Maximum number of paired H2 devices stored in SPIFFS and kept in memory.
config IRIS_INVENTORY_INTERVAL_MS
int "Inventory poll interval (ms)"
default 30000
range 5000 300000
depends on IRIS_ENABLED
help
How often the inventory task polls all paired devices for their current state.
config IRIS_OFFLINE_THRESHOLD
int "Failed polls before marking device offline"
default 3
range 1 10
depends on IRIS_ENABLED
help
Number of consecutive CoAP timeout/errors before a device is marked offline.
config IRIS_DISCOVERY_WINDOW_MS
int "Discovery response collection window (ms)"
default 3000
range 500 10000
depends on IRIS_ENABLED
help
How long the C6 waits for responses after sending a multicast GET /discover.
Longer values catch slow or distant devices; shorter values speed up boot.
config IRIS_DISCOVERY_INTERVAL_CYCLES
int "Re-discovery every N inventory cycles"
default 10
range 1 100
depends on IRIS_ENABLED
help
A full discovery sweep (multicast GET /discover) is run automatically
every N inventory poll cycles. At the default of 10 cycles × 30 s = 5 min.
Set to 1 to rediscover every cycle (more network traffic).
config IRIS_JOINER_PSKD
string "Thread Joiner PSKd (Pre-Shared Key)"
default "JOINPW01"
depends on IRIS_ENABLED
help
The Pre-Shared Key for Device used during Thread commissioning.
Must match the PSKd compiled into the H2 firmware.
Minimum 6 characters, maximum 32 characters.
config IRIS_MASTER_PRIORITY
int "Master election priority (higher = preferred Master)"
default 1
range 1 255
depends on IRIS_ENABLED
help
Priority used in Master/Backup election. The device with the highest
priority becomes Master. Configure the primary C6 with a higher value
(e.g., 2) than the backup (e.g., 1).
config IRIS_MASTER_HEARTBEAT_INTERVAL_MS
int "Master heartbeat broadcast interval (ms)"
default 5000
range 1000 30000
depends on IRIS_ENABLED
help
How often the active Master broadcasts a heartbeat to the Thread multicast
group ff03::1 so the Backup knows the Master is alive.
config IRIS_MASTER_FAILOVER_TIMEOUT_MS
int "Failover timeout — max time without a heartbeat (ms)"
default 15000
range 3000 60000
depends on IRIS_ENABLED
help
If the Backup receives no heartbeat from the Master within this timeout,
it starts a new Master election. Should be > 3 × HEARTBEAT_INTERVAL.
endmenu
@@ -0,0 +1,273 @@
#pragma once
/**
* @file iris.h
* @brief Iris — Thread network device manager for ESP32-C6
*
* Iris manages paired Thread devices (ESP32-H2 clients) on the model railway
* control system. It handles:
* - Device provisioning via Thread Commissioner + Joiner flow
* - Capability discovery via CoAP GET /capabilities
* - State polling via CoAP GET /state (background inventory task)
* - Device control via CoAP POST /toggle (unicast and multicast)
* - Master/Backup election so two C6 units auto-elect who controls the network
* - Persistence via SPIFFS binary file (see README-thread.md §6)
*
* See README-thread.md for the full protocol reference and H2 implementation guide.
*/
#include <stdbool.h>
#include <stdint.h>
#include <esp_err.h>
#ifdef __cplusplus
extern "C" {
#endif
/* =========================================================================
* Capability and State bitmasks
* These values MUST match the H2 firmware definition exactly.
* ========================================================================= */
/** @defgroup iris_caps Capability bitmask (what the device supports) */
/** @{ */
#define IRIS_CAP_INNER_LIGHT (1u << 0) /**< Innenbeleuchtung */
#define IRIS_CAP_OUTER_LIGHT (1u << 1) /**< Außenbeleuchtung */
#define IRIS_CAP_MOVEMENT (1u << 2) /**< Bewegung (Oben / Unten) */
/** @} */
/** @defgroup iris_state State bitmask (current value of each capability) */
/** @{ */
#define IRIS_STATE_INNER_LIGHT (1u << 0) /**< 1 = on, 0 = off */
#define IRIS_STATE_OUTER_LIGHT (1u << 1) /**< 1 = on, 0 = off */
#define IRIS_STATE_MOVEMENT (1u << 2) /**< 1 = Oben, 0 = Unten */
/** @} */
/* =========================================================================
* Device data structures
* ========================================================================= */
#define IRIS_DEVICE_NAME_MAX 32
#define IRIS_EUI64_LEN 8
/**
* @brief Persisted portion of a device record (written to SPIFFS as-is).
*
* Size: 44 bytes (packed). Layout documented in README-thread.md §6.2.
*/
typedef struct __attribute__((packed)) {
uint8_t eui64[IRIS_EUI64_LEN]; /**< Hardware EUI-64 identifier */
char name[IRIS_DEVICE_NAME_MAX]; /**< Display name (null-terminated) */
uint8_t capabilities; /**< IRIS_CAP_* bitmask */
uint8_t state; /**< IRIS_STATE_* bitmask (last known) */
uint8_t _pad[2]; /**< Alignment padding — must be 0 */
} iris_device_persisted_t;
/**
* @brief Full device record including runtime-only fields.
*
* The `p` sub-struct is the only part written to / read from SPIFFS.
* Runtime fields are initialised to safe defaults on load and updated
* by the inventory task.
*/
typedef struct {
iris_device_persisted_t p; /**< Persisted data */
bool online; /**< true after at least one successful poll */
uint8_t failed_polls; /**< Consecutive CoAP errors / timeouts */
} iris_device_t;
/* =========================================================================
* Lifecycle
* ========================================================================= */
/**
* @brief Initialise the Iris component.
*
* Loads the paired device list from SPIFFS, initialises OpenThread as Border
* Router, and starts the Master election task. Call once from app_task after
* wifi_manager_init().
*
* @return ESP_OK on success, or an error code.
*/
esp_err_t iris_init(void);
/**
* @brief Start the background inventory task.
*
* Spawns a FreeRTOS task (priority tskIDLE_PRIORITY+2, 4 KB stack) that:
* - Runs an initial discovery sweep on startup
* - Periodically polls all paired devices via CoAP GET /state
* - Re-runs discovery every IRIS_DISCOVERY_INTERVAL_CYCLES poll cycles
* Call once after iris_init().
*/
void iris_start_inventory_task(void);
/**
* @brief Run an active network discovery sweep (blocking).
*
* Sends a multicast CoAP NON GET /discover to ff03::1 and collects responses
* for IRIS_DISCOVERY_WINDOW_MS milliseconds. For each responding device:
* - If already paired: marks the device online and updates its state.
* - If not yet paired: adds it to the scan cache so the OLED
* provisioning menu ("neue Geräte") can offer it for pairing.
*
* This allows the C6 to rediscover devices that are already in the Thread
* network after a firmware flash that wiped the SPIFFS paired-device list.
*
* Must be called from a FreeRTOS task context (blocks for the collection
* window). Do NOT call from the OpenThread mainloop task.
*/
void iris_run_discovery(void);
/* =========================================================================
* Device discovery and provisioning
* ========================================================================= */
/**
* @brief Return cached list of discoverable (unpaired) Thread joiners.
*
* Devices that have called otJoinerStart() but have not yet been paired appear
* here. The list is filled by the Commissioner joiner callback and cached in
* memory; this function returns from the cache without blocking.
*
* @param[out] out Buffer to receive device records.
* @param[in] max Maximum number of records to return.
* @return Number of devices written to @p out.
*/
int iris_scan(iris_device_t *out, int max);
/**
* @brief Provision a discovered device into the Thread network.
*
* Calls otCommissionerAddJoiner() with the project PSKd, waits for the join
* confirmation callback, then performs a CoAP GET /capabilities exchange.
* On success, adds the device to the in-memory list and persists to SPIFFS.
*
* @param[in] eui64 Device EUI-64 (from iris_scan result).
* @param[in] name Human-readable display name (max IRIS_DEVICE_NAME_MAX-1 chars).
* @return ESP_OK on success.
*/
esp_err_t iris_pair(const uint8_t eui64[IRIS_EUI64_LEN], const char *name);
/* =========================================================================
* Paired device management
* ========================================================================= */
/**
* @brief Return the list of all paired devices.
*
* Copies from the in-memory device list. Thread-safe (mutex-protected).
*
* @param[out] out Buffer to receive device records.
* @param[in] max Maximum records.
* @return Number of paired devices written to @p out.
*/
int iris_get_paired(iris_device_t *out, int max);
/**
* @brief Toggle one capability on a specific device (CoAP unicast).
*
* Sends CoAP POST /toggle {"cap": cap} to the device's Thread RLOC16 address.
* No-op if the device is currently offline.
*
* @param[in] eui64 Target device EUI-64.
* @param[in] cap Exactly one IRIS_CAP_* bit (e.g. IRIS_CAP_INNER_LIGHT).
* @return ESP_OK, ESP_ERR_NOT_FOUND if device unknown, or a CoAP error.
*/
esp_err_t iris_toggle(const uint8_t eui64[IRIS_EUI64_LEN], uint8_t cap);
/**
* @brief Set one capability to an explicit state on ALL devices (CoAP multicast).
*
* Sends CoAP POST /set {"cap": cap, "state": on} to the Thread Realm-Local
* All-Nodes multicast address ff03::1. All H2 devices that support @p cap
* will apply the requested state, regardless of their current state.
*
* Use explicit on/off instead of toggle so that a "all lights on" command
* does not accidentally turn off devices that are already on.
*
* @param[in] cap Exactly one IRIS_CAP_* bit.
* @param[in] on true = activate, false = deactivate.
* @return ESP_OK on successful send (delivery is best-effort multicast).
*/
esp_err_t iris_set_all(uint8_t cap, bool on);
/**
* @brief Rename a paired device and persist the change to SPIFFS.
*
* @param[in] eui64 Target device EUI-64.
* @param[in] new_name New display name (max IRIS_DEVICE_NAME_MAX-1 chars).
* @return ESP_OK, or ESP_ERR_NOT_FOUND.
*/
esp_err_t iris_rename(const uint8_t eui64[IRIS_EUI64_LEN], const char *new_name);
/**
* @brief Remove a paired device and persist the change to SPIFFS.
*
* Removes the device from the in-memory list and rewrites SPIFFS. Does not
* attempt to disconnect the device from the Thread network — the device will
* simply be ignored on future inventory polls.
*
* @param[in] eui64 Target device EUI-64.
* @return ESP_OK, or ESP_ERR_NOT_FOUND.
*/
esp_err_t iris_unpair(const uint8_t eui64[IRIS_EUI64_LEN]);
/* =========================================================================
* Capability query helpers
* ========================================================================= */
/**
* @brief Returns true if at least one paired device supports the given capability.
*
* Used to decide whether to show multicast toggle items in the menu.
*
* @param[in] cap One IRIS_CAP_* bit.
*/
bool iris_any_has_cap(uint8_t cap);
/* =========================================================================
* Master / Backup state
* ========================================================================= */
/**
* @brief Returns true if this unit is currently the active Master.
*
* The Master is the Commissioner and the only unit that can provision and
* control devices. The Backup monitors the network but does not commission.
* The OLED menu hides device management options when this returns false.
*/
bool iris_is_master(void);
/**
* @brief Returns the configured Master election priority for this unit.
*
* Corresponds to CONFIG_IRIS_MASTER_PRIORITY. Higher = preferred Master.
*/
uint8_t iris_get_priority(void);
/* =========================================================================
* Utility
* ========================================================================= */
/**
* @brief Convert a binary EUI-64 to a 16-character hex string.
*
* @param[in] eui64 8-byte binary EUI-64.
* @param[out] out Caller-provided buffer, must be at least 17 bytes.
* @param[in] len Size of @p out in bytes.
*/
void iris_eui64_to_str(const uint8_t eui64[IRIS_EUI64_LEN], char *out, size_t len);
/**
* @brief Parse a 16-character hex string into a binary EUI-64.
*
* @param[in] str Null-terminated hex string (exactly 16 hex chars).
* @param[out] eui64 Output buffer (8 bytes).
* @return true on success, false if the string is invalid.
*/
bool iris_str_to_eui64(const char *str, uint8_t eui64[IRIS_EUI64_LEN]);
#ifdef __cplusplus
}
#endif
@@ -0,0 +1,115 @@
#pragma once
/**
* @file iris_internal.h
* @brief Shared state, types, and internal function declarations for the Iris component.
*
* This header is NOT part of the public API. It is included only by the Iris
* source files (iris.c, iris_storage.c, iris_coap.c, iris_discovery.c,
* iris_master.c, iris_inventory.c).
*/
#include "iris/iris.h"
#include <ctype.h>
#include <stdio.h>
#include <string.h>
#include <esp_log.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <cJSON.h>
#if defined(CONFIG_IRIS_ENABLED)
#include <esp_openthread.h>
#include <esp_openthread_types.h>
#include <esp_vfs_eventfd.h>
#include <openthread/coap.h>
#include <openthread/commissioner.h>
#include <openthread/instance.h>
#include <openthread/ip6.h>
#include <openthread/thread.h>
#include <openthread/thread_ftd.h>
/* =========================================================================
* SPIFFS storage constants and types
* ========================================================================= */
#define IRIS_STORE_PATH "/spiffs/iris_devices.bin"
#define IRIS_STORE_MAGIC 0x49524953u /* "IRIS" */
#define IRIS_STORE_VERSION 1
typedef struct __attribute__((packed)) {
uint32_t magic;
uint16_t version;
uint16_t count;
} iris_store_header_t;
/* =========================================================================
* Master election state
* ========================================================================= */
typedef enum {
IRIS_MASTER_INITIALIZING,
IRIS_MASTER_ACTIVE,
IRIS_MASTER_STANDBY,
} iris_master_state_t;
/* =========================================================================
* Scan cache
* ========================================================================= */
#define IRIS_SCAN_CACHE_MAX 8
/* =========================================================================
* Shared state — defined in iris.c, accessed by all sub-modules
* ========================================================================= */
extern iris_device_t s_paired[CONFIG_IRIS_MAX_DEVICES];
extern int s_paired_count;
extern SemaphoreHandle_t s_mutex;
extern TaskHandle_t s_inventory_task_handle;
extern TaskHandle_t s_master_task_handle;
extern iris_master_state_t s_master_state;
extern bool s_master_is_us;
extern iris_device_t s_scan_cache[IRIS_SCAN_CACHE_MAX];
extern int s_scan_count;
extern volatile bool s_discovery_active;
/* =========================================================================
* Internal function declarations
* ========================================================================= */
/* iris.c */
int find_device_index(const uint8_t eui64[IRIS_EUI64_LEN]);
/* iris_storage.c */
void spiffs_save(void);
void spiffs_load(void);
/* iris_coap.c */
bool eui64_to_ml_eid(const uint8_t eui64[IRIS_EUI64_LEN], otIp6Address *addr);
bool coap_get(const otIp6Address *addr, const char *resource,
char *out_buf, size_t out_len);
bool coap_post(const otIp6Address *addr, const char *resource,
const char *payload);
/* iris_discovery.c */
void iris_neighbor_callback(otNeighborTableEvent event,
const otNeighborTableEntryInfo *info);
void joiner_callback(otCommissionerJoinerEvent event,
const otJoinerInfo *info,
const otExtAddress *eui64,
void *ctx);
/* iris_master.c — the task function; started by iris_start_inventory_task */
void iris_master_task(void *arg);
/* iris_inventory.c — the task function */
void iris_inventory_task(void *arg);
#endif /* CONFIG_IRIS_ENABLED */
+384
View File
@@ -0,0 +1,384 @@
#include "iris/iris_internal.h"
#include "esp_openthread_lock.h"
#include <esp_random.h>
#include <openthread/dataset.h>
static const char *TAG = "Iris";
/* =========================================================================
* Shared state — defined here, accessed by all sub-modules via iris_internal.h
* ========================================================================= */
#if defined(CONFIG_IRIS_ENABLED)
iris_device_t s_paired[CONFIG_IRIS_MAX_DEVICES];
int s_paired_count = 0;
SemaphoreHandle_t s_mutex = NULL;
TaskHandle_t s_inventory_task_handle = NULL;
TaskHandle_t s_master_task_handle = NULL;
iris_master_state_t s_master_state = IRIS_MASTER_INITIALIZING;
bool s_master_is_us = false;
iris_device_t s_scan_cache[IRIS_SCAN_CACHE_MAX];
int s_scan_count = 0;
volatile bool s_discovery_active = false;
/* =========================================================================
* Index lookup
* ========================================================================= */
int find_device_index(const uint8_t eui64[IRIS_EUI64_LEN])
{
for (int i = 0; i < s_paired_count; i++) {
if (memcmp(s_paired[i].p.eui64, eui64, IRIS_EUI64_LEN) == 0)
return i;
}
return -1;
}
/* =========================================================================
* Lifecycle
* ========================================================================= */
static void iris_ot_main_task(void *arg)
{
(void)arg;
esp_openthread_lock_acquire(portMAX_DELAY);
otInstance *instance = esp_openthread_get_instance();
if (otDatasetIsCommissioned(instance) == false) {
ESP_LOGW(TAG, "No commissioned dataset found, creating a new one.");
otOperationalDataset dataset;
memset(&dataset, 0, sizeof(dataset));
// Set the channel
dataset.mComponents.mIsChannelPresent = true;
dataset.mChannel = 15;
// Set the PAN ID
dataset.mComponents.mIsPanIdPresent = true;
dataset.mPanId = (otPanId)esp_random();
// Set the Extended PAN ID
dataset.mComponents.mIsExtendedPanIdPresent = true;
esp_fill_random(dataset.mExtendedPanId.m8, sizeof(dataset.mExtendedPanId.m8));
// Set the Network Name
dataset.mComponents.mIsNetworkNamePresent = true;
snprintf((char *)dataset.mNetworkName.m8, sizeof(dataset.mNetworkName.m8), "sys-ctrl-%04x",
(uint16_t)esp_random());
// Set the Network Key
dataset.mComponents.mIsNetworkKeyPresent = true;
esp_fill_random(dataset.mNetworkKey.m8, sizeof(dataset.mNetworkKey.m8));
ESP_ERROR_CHECK(otDatasetSetActive(instance, &dataset));
}
otCoapStart(instance, OT_DEFAULT_COAP_PORT);
otThreadRegisterNeighborTableCallback(instance, iris_neighbor_callback);
// Start the network
otIp6SetEnabled(instance, true);
otThreadSetEnabled(instance, true);
esp_openthread_lock_release();
// esp_openthread_launch_mainloop() blocks until OpenThread is deinitialized
esp_openthread_launch_mainloop();
vTaskDelete(NULL);
}
esp_err_t iris_init(void)
{
s_mutex = xSemaphoreCreateMutex();
if (!s_mutex) return ESP_ERR_NO_MEM;
spiffs_load();
esp_vfs_eventfd_config_t eventfd_config = { .max_fds = 3 };
esp_vfs_eventfd_register(&eventfd_config);
esp_openthread_platform_config_t ot_config = {
.radio_config = {
.radio_mode = RADIO_MODE_NATIVE,
},
.host_config = {
.host_connection_mode = HOST_CONNECTION_MODE_NONE,
},
.port_config = {
.storage_partition_name = "nvs",
.netif_queue_size = 10,
.task_queue_size = 10,
},
};
esp_err_t err = esp_openthread_init(&ot_config);
if (err != ESP_OK) {
ESP_LOGE(TAG, "OpenThread init failed: %s", esp_err_to_name(err));
return err;
}
// Launch OpenThread mainloop in a dedicated task (required by ESP-IDF)
xTaskCreate(iris_ot_main_task, "ot_main", 8192, NULL,
tskIDLE_PRIORITY + 4, NULL);
ESP_LOGI(TAG, "Iris initialised — %d device(s) loaded", s_paired_count);
return ESP_OK;
}
void iris_start_inventory_task(void)
{
xTaskCreate(iris_inventory_task, "iris_inv", 4096, NULL,
tskIDLE_PRIORITY + 2, &s_inventory_task_handle);
xTaskCreate(iris_master_task, "iris_master", 4096, NULL,
tskIDLE_PRIORITY + 3, &s_master_task_handle);
}
/* =========================================================================
* Device discovery and provisioning
* ========================================================================= */
int iris_scan(iris_device_t *out, int max)
{
xSemaphoreTake(s_mutex, portMAX_DELAY);
int n = s_scan_count < max ? s_scan_count : max;
memcpy(out, s_scan_cache, n * sizeof(iris_device_t));
xSemaphoreGive(s_mutex);
return n;
}
esp_err_t iris_pair(const uint8_t eui64[IRIS_EUI64_LEN], const char *name)
{
if (!s_master_is_us) {
ESP_LOGW(TAG, "iris_pair called on Backup — ignoring");
return ESP_ERR_NOT_SUPPORTED;
}
xSemaphoreTake(s_mutex, portMAX_DELAY);
if (s_paired_count >= CONFIG_IRIS_MAX_DEVICES) {
xSemaphoreGive(s_mutex);
return ESP_ERR_NO_MEM;
}
if (find_device_index(eui64) >= 0) {
xSemaphoreGive(s_mutex);
return ESP_ERR_INVALID_STATE; // already paired
}
xSemaphoreGive(s_mutex);
// Query capabilities via CoAP
otIp6Address addr;
uint8_t caps = 0;
if (eui64_to_ml_eid(eui64, &addr)) {
char resp[64] = {};
if (coap_get(&addr, "capabilities", resp, sizeof(resp))) {
cJSON *json = cJSON_Parse(resp);
if (json) {
cJSON *c = cJSON_GetObjectItem(json, "caps");
if (cJSON_IsNumber(c)) caps = (uint8_t)c->valuedouble;
cJSON_Delete(json);
}
}
}
xSemaphoreTake(s_mutex, portMAX_DELAY);
iris_device_t *dev = &s_paired[s_paired_count];
memset(dev, 0, sizeof(*dev));
memcpy(dev->p.eui64, eui64, IRIS_EUI64_LEN);
strncpy(dev->p.name, name ? name : "Unknown", IRIS_DEVICE_NAME_MAX - 1);
dev->p.capabilities = caps;
dev->online = true;
s_paired_count++;
// Remove from scan cache
for (int i = 0; i < s_scan_count; i++) {
if (memcmp(s_scan_cache[i].p.eui64, eui64, IRIS_EUI64_LEN) == 0) {
s_scan_cache[i] = s_scan_cache[--s_scan_count];
break;
}
}
spiffs_save();
xSemaphoreGive(s_mutex);
char eui_str[17];
iris_eui64_to_str(eui64, eui_str, sizeof(eui_str));
ESP_LOGI(TAG, "Paired device %s ('%s') caps=0x%02x", eui_str, name, caps);
return ESP_OK;
}
/* =========================================================================
* Paired device management
* ========================================================================= */
int iris_get_paired(iris_device_t *out, int max)
{
xSemaphoreTake(s_mutex, portMAX_DELAY);
int n = s_paired_count < max ? s_paired_count : max;
memcpy(out, s_paired, n * sizeof(iris_device_t));
xSemaphoreGive(s_mutex);
return n;
}
esp_err_t iris_toggle(const uint8_t eui64[IRIS_EUI64_LEN], uint8_t cap)
{
xSemaphoreTake(s_mutex, portMAX_DELAY);
int idx = find_device_index(eui64);
bool online = (idx >= 0) ? s_paired[idx].online : false;
xSemaphoreGive(s_mutex);
if (idx < 0) return ESP_ERR_NOT_FOUND;
if (!online) {
ESP_LOGD(TAG, "iris_toggle: device offline — skipping");
return ESP_OK;
}
otIp6Address addr;
if (!eui64_to_ml_eid(eui64, &addr)) return ESP_FAIL;
char payload[32];
snprintf(payload, sizeof(payload), "{\"cap\":%u}", (unsigned)cap);
coap_post(&addr, "toggle", payload);
return ESP_OK;
}
esp_err_t iris_set_all(uint8_t cap, bool on)
{
otInstance *inst = esp_openthread_get_instance();
if (!inst) return ESP_FAIL;
char payload[48];
snprintf(payload, sizeof(payload), "{\"cap\":%u,\"state\":%u}",
(unsigned)cap, on ? 1u : 0u);
otMessage *msg = otCoapNewMessage(inst, NULL);
if (!msg) return ESP_ERR_NO_MEM;
otCoapMessageInit(msg, OT_COAP_TYPE_NON_CONFIRMABLE, OT_COAP_CODE_POST);
otCoapMessageAppendUriPathOptions(msg, "set");
otCoapMessageAppendContentFormatOption(msg, OT_COAP_OPTION_CONTENT_FORMAT_JSON);
otCoapMessageSetPayloadMarker(msg);
otMessageAppend(msg, payload, (uint16_t)strlen(payload));
otMessageInfo info = {};
otIp6AddressFromString("ff03::1", &info.mPeerAddr);
info.mPeerPort = OT_DEFAULT_COAP_PORT;
otError err = otCoapSendRequest(inst, msg, &info, NULL, NULL);
ESP_LOGI(TAG, "Multicast set cap=0x%02x state=%d: %s",
cap, (int)on, err == OT_ERROR_NONE ? "ok" : "fail");
return (err == OT_ERROR_NONE) ? ESP_OK : ESP_FAIL;
}
esp_err_t iris_rename(const uint8_t eui64[IRIS_EUI64_LEN], const char *new_name)
{
xSemaphoreTake(s_mutex, portMAX_DELAY);
int idx = find_device_index(eui64);
if (idx < 0) {
xSemaphoreGive(s_mutex);
return ESP_ERR_NOT_FOUND;
}
strncpy(s_paired[idx].p.name, new_name, IRIS_DEVICE_NAME_MAX - 1);
s_paired[idx].p.name[IRIS_DEVICE_NAME_MAX - 1] = '\0';
spiffs_save();
xSemaphoreGive(s_mutex);
return ESP_OK;
}
esp_err_t iris_unpair(const uint8_t eui64[IRIS_EUI64_LEN])
{
xSemaphoreTake(s_mutex, portMAX_DELAY);
int idx = find_device_index(eui64);
if (idx < 0) {
xSemaphoreGive(s_mutex);
return ESP_ERR_NOT_FOUND;
}
for (int i = idx; i < s_paired_count - 1; i++)
s_paired[i] = s_paired[i + 1];
s_paired_count--;
spiffs_save();
xSemaphoreGive(s_mutex);
char eui_str[17];
iris_eui64_to_str(eui64, eui_str, sizeof(eui_str));
ESP_LOGI(TAG, "Unpaired device %s", eui_str);
return ESP_OK;
}
/* =========================================================================
* Capability query helpers
* ========================================================================= */
bool iris_any_has_cap(uint8_t cap)
{
xSemaphoreTake(s_mutex, portMAX_DELAY);
bool found = false;
for (int i = 0; i < s_paired_count && !found; i++)
if (s_paired[i].p.capabilities & cap) found = true;
xSemaphoreGive(s_mutex);
return found;
}
/* =========================================================================
* Master / Backup state
* ========================================================================= */
bool iris_is_master(void)
{
return s_master_is_us;
}
uint8_t iris_get_priority(void)
{
return (uint8_t)CONFIG_IRIS_MASTER_PRIORITY;
}
/* =========================================================================
* Stub implementations when IRIS is disabled
* ========================================================================= */
#else /* CONFIG_IRIS_ENABLED not set */
esp_err_t iris_init(void) { return ESP_ERR_NOT_SUPPORTED; }
void iris_start_inventory_task(void) {}
void iris_run_discovery(void) {}
int iris_scan(iris_device_t *out, int max) { (void)out; (void)max; return 0; }
esp_err_t iris_pair(const uint8_t eui64[IRIS_EUI64_LEN], const char *name) { (void)eui64; (void)name; return ESP_ERR_NOT_SUPPORTED; }
int iris_get_paired(iris_device_t *out, int max) { (void)out; (void)max; return 0; }
esp_err_t iris_toggle(const uint8_t eui64[IRIS_EUI64_LEN], uint8_t cap) { (void)eui64; (void)cap; return ESP_ERR_NOT_SUPPORTED; }
esp_err_t iris_set_all(uint8_t cap, bool on) { (void)cap; (void)on; return ESP_ERR_NOT_SUPPORTED; }
esp_err_t iris_rename(const uint8_t eui64[IRIS_EUI64_LEN], const char *new_name) { (void)eui64; (void)new_name; return ESP_ERR_NOT_SUPPORTED; }
esp_err_t iris_unpair(const uint8_t eui64[IRIS_EUI64_LEN]) { (void)eui64; return ESP_ERR_NOT_SUPPORTED; }
bool iris_any_has_cap(uint8_t cap) { (void)cap; return false; }
bool iris_is_master(void) { return false; }
uint8_t iris_get_priority(void) { return 0; }
#endif /* CONFIG_IRIS_ENABLED */
/* =========================================================================
* EUI-64 utility (always compiled — used by both enabled and stub paths)
* ========================================================================= */
void iris_eui64_to_str(const uint8_t eui64[IRIS_EUI64_LEN], char *out, size_t len)
{
if (len < 17) return;
snprintf(out, len,
"%02x%02x%02x%02x%02x%02x%02x%02x",
eui64[0], eui64[1], eui64[2], eui64[3],
eui64[4], eui64[5], eui64[6], eui64[7]);
}
bool iris_str_to_eui64(const char *str, uint8_t eui64[IRIS_EUI64_LEN])
{
if (!str || strlen(str) != 16) return false;
for (int i = 0; i < 8; i++) {
char byte_str[3] = { str[i * 2], str[i * 2 + 1], '\0' };
if (!isxdigit((unsigned char)byte_str[0]) || !isxdigit((unsigned char)byte_str[1]))
return false;
eui64[i] = (uint8_t)strtoul(byte_str, NULL, 16);
}
return true;
}
+167
View File
@@ -0,0 +1,167 @@
#include "iris/iris_internal.h"
#include "esp_openthread_lock.h"
#if defined(CONFIG_IRIS_ENABLED)
static const char *TAG = "Iris";
/**
* @brief Derive the Thread ML-EID (mesh-local address) for a device from its
* EUI-64. In a Thread network the mesh-local prefix is known from the
* network data; the IID is formed from the EUI-64 via EUI-64 → IID
* conversion (RFC 4291 modified EUI-64, toggle bit 6).
*
* This is a simplification — in production firmware the address should be
* looked up from the Thread network data / neighbor table via otThreadGetNextNeighborInfo().
*/
bool eui64_to_ml_eid(const uint8_t eui64[IRIS_EUI64_LEN], otIp6Address *addr)
{
esp_openthread_lock_acquire(portMAX_DELAY);
otInstance *inst = esp_openthread_get_instance();
if (!inst) {
esp_openthread_lock_release();
return false;
}
const otMeshLocalPrefix *prefix = otThreadGetMeshLocalPrefix(inst);
if (!prefix) {
esp_openthread_lock_release();
return false;
}
memcpy(addr->mFields.m8, prefix->m8, 8);
// EUI-64 → IID: copy bytes, toggle universal/local bit
addr->mFields.m8[8] = eui64[0] ^ 0x02;
addr->mFields.m8[9] = eui64[1];
addr->mFields.m8[10] = eui64[2];
addr->mFields.m8[11] = 0xFF;
addr->mFields.m8[12] = 0xFE;
addr->mFields.m8[13] = eui64[5];
addr->mFields.m8[14] = eui64[6];
addr->mFields.m8[15] = eui64[7];
esp_openthread_lock_release();
return true;
}
/**
* @brief Context for the blocking CoAP GET helper.
*/
typedef struct {
SemaphoreHandle_t done;
char *buf;
size_t buf_len;
bool success;
} coap_get_ctx_t;
static void coap_get_response_handler(void *ctx, otMessage *msg,
const otMessageInfo *info, otError err)
{
(void)info;
coap_get_ctx_t *c = (coap_get_ctx_t *)ctx;
if (err == OT_ERROR_NONE && msg) {
uint16_t len = otMessageGetLength(msg) - otMessageGetOffset(msg);
if (len >= c->buf_len) len = (uint16_t)(c->buf_len - 1);
otMessageRead(msg, otMessageGetOffset(msg), c->buf, len);
c->buf[len] = '\0';
c->success = true;
}
xSemaphoreGive(c->done);
}
/**
* @brief Simple blocking CoAP GET helper.
* Sends a GET request and waits up to 3 s for a response.
* Returns the JSON payload in @p out_buf (null-terminated).
*/
bool coap_get(const otIp6Address *addr, const char *resource,
char *out_buf, size_t out_len)
{
esp_openthread_lock_acquire(portMAX_DELAY);
otInstance *inst = esp_openthread_get_instance();
if (!inst) {
esp_openthread_lock_release();
return false;
}
otMessage *msg = otCoapNewMessage(inst, NULL);
if (!msg) {
esp_openthread_lock_release();
return false;
}
otCoapMessageInit(msg, OT_COAP_TYPE_CONFIRMABLE, OT_COAP_CODE_GET);
otCoapMessageGenerateToken(msg, OT_COAP_DEFAULT_TOKEN_LENGTH);
if (otCoapMessageAppendUriPathOptions(msg, resource) != OT_ERROR_NONE) {
otMessageFree(msg);
esp_openthread_lock_release();
return false;
}
coap_get_ctx_t ctx = {
.done = xSemaphoreCreateBinary(),
.buf = out_buf,
.buf_len = out_len,
.success = false,
};
otMessageInfo info = {};
info.mPeerAddr = *addr;
info.mPeerPort = OT_DEFAULT_COAP_PORT;
otError err = otCoapSendRequest(inst, msg, &info, coap_get_response_handler, &ctx);
esp_openthread_lock_release();
if (err != OT_ERROR_NONE) {
vSemaphoreDelete(ctx.done);
return false;
}
bool got = xSemaphoreTake(ctx.done, pdMS_TO_TICKS(3000));
vSemaphoreDelete(ctx.done);
return got && ctx.success;
}
bool coap_post(const otIp6Address *addr, const char *resource,
const char *payload)
{
esp_openthread_lock_acquire(portMAX_DELAY);
otInstance *inst = esp_openthread_get_instance();
if (!inst) {
esp_openthread_lock_release();
return false;
}
otMessage *msg = otCoapNewMessage(inst, NULL);
if (!msg) {
esp_openthread_lock_release();
return false;
}
otCoapMessageInit(msg, OT_COAP_TYPE_CONFIRMABLE, OT_COAP_CODE_POST);
otCoapMessageGenerateToken(msg, OT_COAP_DEFAULT_TOKEN_LENGTH);
otCoapMessageAppendUriPathOptions(msg, resource);
otCoapMessageAppendContentFormatOption(msg, OT_COAP_OPTION_CONTENT_FORMAT_JSON);
otCoapMessageSetPayloadMarker(msg);
otMessageAppend(msg, payload, (uint16_t)strlen(payload));
otMessageInfo info = {};
info.mPeerAddr = *addr;
info.mPeerPort = OT_DEFAULT_COAP_PORT;
otError err = otCoapSendRequest(inst, msg, &info, NULL, NULL);
esp_openthread_lock_release();
if (err != OT_ERROR_NONE) {
ESP_LOGW(TAG, "CoAP POST failed: %d", err);
return false;
}
return true;
}
#endif /* CONFIG_IRIS_ENABLED */
@@ -0,0 +1,223 @@
#include "iris/iris_internal.h"
#include "esp_openthread_lock.h"
#if defined(CONFIG_IRIS_ENABLED)
static const char *TAG = "Iris";
/**
* @brief Called by OpenThread when a Thread neighbor is added or removed.
*
* When a paired device rejoins the network after being offline, this callback
* fires immediately — much faster than waiting for the 30-second inventory
* poll. We mark the device online and reset the failed_polls counter so the
* OLED menu shows it as available right away.
*
* Registered in iris_init(). Runs in the OpenThread task context.
*/
void iris_neighbor_callback(otNeighborTableEvent event,
const otNeighborTableEntryInfo *info)
{
if (event != OT_NEIGHBOR_TABLE_EVENT_CHILD_ADDED &&
event != OT_NEIGHBOR_TABLE_EVENT_ROUTER_ADDED)
return;
const uint8_t *ext = (event == OT_NEIGHBOR_TABLE_EVENT_CHILD_ADDED)
? info->mInfo.mChild.mExtAddress.m8
: info->mInfo.mRouter.mExtAddress.m8;
xSemaphoreTake(s_mutex, portMAX_DELAY);
int idx = find_device_index(ext);
bool newly_online = (idx >= 0 && !s_paired[idx].online);
if (newly_online) {
s_paired[idx].online = true;
s_paired[idx].failed_polls = 0;
char eui_str[17];
iris_eui64_to_str(ext, eui_str, sizeof(eui_str));
ESP_LOGI(TAG, "Auto-discovery: paired device %s back online", eui_str);
}
xSemaphoreGive(s_mutex);
// Wake the inventory task immediately to fetch current state without
// waiting for the next 30 s poll cycle.
if (newly_online && s_inventory_task_handle)
xTaskNotify(s_inventory_task_handle, 0, eNoAction);
}
/**
* @brief Commissioner joiner callback — fires when a new H2 calls otJoinerStart().
*
* Registered in become_master() (iris_master.c). Runs in the OpenThread task context.
*/
void joiner_callback(otCommissionerJoinerEvent event,
const otJoinerInfo *info,
const otExtAddress *eui64,
void *ctx)
{
(void)info;
(void)ctx;
if (event != OT_COMMISSIONER_JOINER_CONNECTED)
return;
xSemaphoreTake(s_mutex, portMAX_DELAY);
if (s_scan_count < IRIS_SCAN_CACHE_MAX) {
iris_device_t dev = {};
memcpy(dev.p.eui64, eui64->m8, IRIS_EUI64_LEN);
char eui_str[17];
iris_eui64_to_str(dev.p.eui64, eui_str, sizeof(eui_str));
snprintf(dev.p.name, IRIS_DEVICE_NAME_MAX, "H2-%s", eui_str + 8);
dev.online = true;
bool found = false;
for (int i = 0; i < s_scan_count; i++) {
if (memcmp(s_scan_cache[i].p.eui64, dev.p.eui64, IRIS_EUI64_LEN) == 0) {
found = true;
break;
}
}
if (!found) {
s_scan_cache[s_scan_count++] = dev;
ESP_LOGI(TAG, "New joiner discovered: %s", eui_str);
}
}
xSemaphoreGive(s_mutex);
}
/**
* @brief CoAP response handler for multicast GET /discover.
*
* Called once per responding H2 device. Distinguishes three device states:
*
* 1. PAIRED — EUI-64 is in iris_devices.bin.
* → mark online, update state.
*
* 2. REJOINED — EUI-64 is NOT in iris_devices.bin, but the device is already
* in the Thread network (SPIFFS record was lost, e.g. after flash).
* → auto-restore to iris_devices.bin immediately.
*
* 3. NEW JOINER — Never provisioned. Appears via joiner_callback, NOT /discover.
* Goes to s_scan_cache[]; requires manual "Aufnehmen".
*
* Runs in the OpenThread task context.
*/
static void discover_response_handler(void *ctx, otMessage *msg,
const otMessageInfo *info, otError err)
{
(void)ctx;
(void)info;
if (!s_discovery_active || err != OT_ERROR_NONE || !msg) return;
char buf[96] = {};
uint16_t len = otMessageGetLength(msg) - otMessageGetOffset(msg);
if (len >= sizeof(buf)) len = (uint16_t)(sizeof(buf) - 1);
otMessageRead(msg, otMessageGetOffset(msg), buf, len);
cJSON *json = cJSON_Parse(buf);
if (!json) return;
cJSON *eui_item = cJSON_GetObjectItem(json, "eui64");
cJSON *caps_item = cJSON_GetObjectItem(json, "caps");
cJSON *st_item = cJSON_GetObjectItem(json, "state");
cJSON *name_item = cJSON_GetObjectItem(json, "name");
if (!cJSON_IsString(eui_item) || !cJSON_IsNumber(caps_item)) {
cJSON_Delete(json);
return;
}
uint8_t eui64[IRIS_EUI64_LEN];
if (!iris_str_to_eui64(eui_item->valuestring, eui64)) {
cJSON_Delete(json);
return;
}
uint8_t caps = (uint8_t)caps_item->valuedouble;
uint8_t state = cJSON_IsNumber(st_item) ? (uint8_t)st_item->valuedouble : 0;
xSemaphoreTake(s_mutex, portMAX_DELAY);
int idx = find_device_index(eui64);
if (idx >= 0) {
// PAIRED — update runtime fields
s_paired[idx].online = true;
s_paired[idx].failed_polls = 0;
s_paired[idx].p.state = state;
ESP_LOGD(TAG, "Discovery: paired device %s online", eui_item->valuestring);
} else if (s_paired_count < CONFIG_IRIS_MAX_DEVICES) {
// REJOINED — auto-restore
iris_device_t dev = {};
memcpy(dev.p.eui64, eui64, IRIS_EUI64_LEN);
if (cJSON_IsString(name_item) && name_item->valuestring[0])
strncpy(dev.p.name, name_item->valuestring, IRIS_DEVICE_NAME_MAX - 1);
else
snprintf(dev.p.name, IRIS_DEVICE_NAME_MAX, "H2-%s",
eui_item->valuestring + 8);
dev.p.capabilities = caps;
dev.p.state = state;
dev.online = true;
s_paired[s_paired_count++] = dev;
spiffs_save();
ESP_LOGI(TAG, "Discovery: rejoined device %s auto-restored (caps=0x%02x)",
eui_item->valuestring, caps);
} else {
ESP_LOGW(TAG, "Discovery: rejoined device %s found but paired list full",
eui_item->valuestring);
}
xSemaphoreGive(s_mutex);
cJSON_Delete(json);
}
void iris_run_discovery(void)
{
esp_openthread_lock_acquire(portMAX_DELAY);
otInstance *inst = esp_openthread_get_instance();
if (!inst) {
esp_openthread_lock_release();
return;
}
ESP_LOGI(TAG, "Starting discovery sweep...");
otMessage *msg = otCoapNewMessage(inst, NULL);
if (!msg) {
esp_openthread_lock_release();
return;
}
otCoapMessageInit(msg, OT_COAP_TYPE_NON_CONFIRMABLE, OT_COAP_CODE_GET);
otCoapMessageGenerateToken(msg, OT_COAP_DEFAULT_TOKEN_LENGTH);
if (otCoapMessageAppendUriPathOptions(msg, "discover") != OT_ERROR_NONE) {
otMessageFree(msg);
esp_openthread_lock_release();
return;
}
otMessageInfo info = {};
otIp6AddressFromString("ff03::1", &info.mPeerAddr);
info.mPeerPort = OT_DEFAULT_COAP_PORT;
s_discovery_active = true;
otError err = otCoapSendRequest(inst, msg, &info,
discover_response_handler, NULL);
esp_openthread_lock_release();
if (err != OT_ERROR_NONE) {
s_discovery_active = false;
ESP_LOGW(TAG, "Discovery send failed: %d", err);
return;
}
vTaskDelay(pdMS_TO_TICKS(CONFIG_IRIS_DISCOVERY_WINDOW_MS));
s_discovery_active = false;
xSemaphoreTake(s_mutex, portMAX_DELAY);
ESP_LOGI(TAG, "Discovery complete — %d paired, %d new in cache",
s_paired_count, s_scan_count);
xSemaphoreGive(s_mutex);
}
#endif /* CONFIG_IRIS_ENABLED */
@@ -0,0 +1,85 @@
#include "iris/iris_internal.h"
#if defined(CONFIG_IRIS_ENABLED)
static const char *TAG = "Iris";
void iris_inventory_task(void *arg)
{
(void)arg;
// Run an initial discovery sweep on startup so already-joined devices
// are found immediately (e.g. after a firmware flash that wiped SPIFFS).
iris_run_discovery();
int cycles_since_discovery = 0;
while (true) {
// Wait for the poll interval OR an early wake-up from the neighbor
// callback (when a known device rejoins the network).
xTaskNotifyWait(0, 0, NULL,
pdMS_TO_TICKS(CONFIG_IRIS_INVENTORY_INTERVAL_MS));
// Periodic re-discovery: runs every IRIS_DISCOVERY_INTERVAL_CYCLES
// poll cycles to catch devices that joined while C6 was not listening.
cycles_since_discovery++;
if (cycles_since_discovery >= CONFIG_IRIS_DISCOVERY_INTERVAL_CYCLES) {
iris_run_discovery();
cycles_since_discovery = 0;
}
xSemaphoreTake(s_mutex, portMAX_DELAY);
int count = s_paired_count;
xSemaphoreGive(s_mutex);
for (int i = 0; i < count; i++) {
uint8_t eui64[IRIS_EUI64_LEN];
xSemaphoreTake(s_mutex, portMAX_DELAY);
if (i >= s_paired_count) {
xSemaphoreGive(s_mutex);
break;
}
memcpy(eui64, s_paired[i].p.eui64, IRIS_EUI64_LEN);
xSemaphoreGive(s_mutex);
otIp6Address addr;
if (!eui64_to_ml_eid(eui64, &addr)) continue;
char resp_buf[64] = {};
bool ok = coap_get(&addr, "state", resp_buf, sizeof(resp_buf));
xSemaphoreTake(s_mutex, portMAX_DELAY);
if (i >= s_paired_count ||
memcmp(s_paired[i].p.eui64, eui64, IRIS_EUI64_LEN) != 0) {
xSemaphoreGive(s_mutex);
continue;
}
if (ok) {
cJSON *json = cJSON_Parse(resp_buf);
if (json) {
cJSON *st = cJSON_GetObjectItem(json, "state");
if (cJSON_IsNumber(st))
s_paired[i].p.state = (uint8_t)st->valuedouble;
cJSON_Delete(json);
}
s_paired[i].online = true;
s_paired[i].failed_polls = 0;
} else {
s_paired[i].failed_polls++;
if (s_paired[i].failed_polls >= CONFIG_IRIS_OFFLINE_THRESHOLD) {
if (s_paired[i].online) {
char eui_str[17];
iris_eui64_to_str(eui64, eui_str, sizeof(eui_str));
ESP_LOGW(TAG, "Device %s went offline", eui_str);
}
s_paired[i].online = false;
}
}
xSemaphoreGive(s_mutex);
}
}
}
#endif /* CONFIG_IRIS_ENABLED */
+128
View File
@@ -0,0 +1,128 @@
#include "iris/iris_internal.h"
#if defined(CONFIG_IRIS_ENABLED)
static const char *TAG = "Iris";
static void master_heartbeat_handler(void *ctx, otMessage *msg,
const otMessageInfo *info)
{
(void)ctx;
if (!msg) return;
char buf[64] = {};
uint16_t len = otMessageGetLength(msg) - otMessageGetOffset(msg);
if (len >= sizeof(buf)) len = sizeof(buf) - 1;
otMessageRead(msg, otMessageGetOffset(msg), buf, len);
cJSON *json = cJSON_Parse(buf);
if (!json) return;
cJSON *prio_item = cJSON_GetObjectItem(json, "priority");
int peer_prio = cJSON_IsNumber(prio_item) ? (int)prio_item->valuedouble : 0;
cJSON_Delete(json);
int our_prio = (int)CONFIG_IRIS_MASTER_PRIORITY;
if (peer_prio > our_prio && s_master_is_us) {
// Higher-priority peer is alive → yield Master role
ESP_LOGI(TAG, "Higher-priority master (prio=%d) detected — yielding", peer_prio);
s_master_is_us = false;
s_master_state = IRIS_MASTER_STANDBY;
// Send yield acknowledgement
char yield_buf[32];
snprintf(yield_buf, sizeof(yield_buf), "{\"priority\":%d}", our_prio);
otMessage *yield_msg = otCoapNewMessage(esp_openthread_get_instance(), NULL);
if (yield_msg) {
otCoapMessageInit(yield_msg, OT_COAP_TYPE_NON_CONFIRMABLE, OT_COAP_CODE_PUT);
otCoapMessageAppendUriPathOptions(yield_msg, "master_yield");
otCoapMessageSetPayloadMarker(yield_msg);
otMessageAppend(yield_msg, yield_buf, (uint16_t)strlen(yield_buf));
otCoapSendRequest(esp_openthread_get_instance(), yield_msg, info, NULL, NULL);
}
otCommissionerStop(esp_openthread_get_instance());
}
}
static void register_master_coap_resources(void)
{
static otCoapResource s_res_heartbeat = {
"master_heartbeat", master_heartbeat_handler, NULL, NULL
};
otCoapAddResource(esp_openthread_get_instance(), &s_res_heartbeat);
}
static void become_master(void)
{
otInstance *inst = esp_openthread_get_instance();
s_master_is_us = true;
s_master_state = IRIS_MASTER_ACTIVE;
otError err = otCommissionerStart(inst, NULL, joiner_callback, NULL);
if (err != OT_ERROR_NONE) {
ESP_LOGE(TAG, "Failed to start Commissioner: %d", err);
s_master_is_us = false;
s_master_state = IRIS_MASTER_STANDBY;
return;
}
// Allow any joiner (wildcard) with our PSKd
otCommissionerAddJoiner(inst, NULL, CONFIG_IRIS_JOINER_PSKD, 0xFFFFFFFF);
ESP_LOGI(TAG, "Became Master (priority=%d)", CONFIG_IRIS_MASTER_PRIORITY);
}
void iris_master_task(void *arg)
{
(void)arg;
// Jitter 01000 ms to avoid simultaneous elections
uint32_t jitter = (uint32_t)(esp_random() % 1000);
vTaskDelay(pdMS_TO_TICKS(jitter));
register_master_coap_resources();
ESP_LOGI(TAG, "Starting election (priority=%d)", CONFIG_IRIS_MASTER_PRIORITY);
become_master();
TickType_t last_hb = xTaskGetTickCount();
otInstance *inst = esp_openthread_get_instance();
while (true) {
vTaskDelay(pdMS_TO_TICKS(CONFIG_IRIS_MASTER_HEARTBEAT_INTERVAL_MS));
if (s_master_is_us) {
// Send heartbeat to multicast group
char hb_payload[32];
snprintf(hb_payload, sizeof(hb_payload),
"{\"priority\":%d}", CONFIG_IRIS_MASTER_PRIORITY);
otMessage *msg = otCoapNewMessage(inst, NULL);
if (msg) {
otCoapMessageInit(msg, OT_COAP_TYPE_NON_CONFIRMABLE, OT_COAP_CODE_PUT);
otCoapMessageAppendUriPathOptions(msg, "master_heartbeat");
otCoapMessageSetPayloadMarker(msg);
otMessageAppend(msg, hb_payload, (uint16_t)strlen(hb_payload));
otMessageInfo info = {};
otIp6AddressFromString("ff03::1", &info.mPeerAddr);
info.mPeerPort = OT_DEFAULT_COAP_PORT;
otCoapSendRequest(inst, msg, &info, NULL, NULL);
}
last_hb = xTaskGetTickCount();
} else {
// Check for heartbeat timeout → trigger failover
TickType_t now = xTaskGetTickCount();
uint32_t elapsed = (uint32_t)((now - last_hb) * portTICK_PERIOD_MS);
if (elapsed >= CONFIG_IRIS_MASTER_FAILOVER_TIMEOUT_MS) {
ESP_LOGW(TAG, "Master heartbeat timeout — starting election");
s_master_state = IRIS_MASTER_INITIALIZING;
become_master();
last_hb = xTaskGetTickCount();
}
}
}
}
#endif /* CONFIG_IRIS_ENABLED */
@@ -0,0 +1,69 @@
#include "iris/iris_internal.h"
#if defined(CONFIG_IRIS_ENABLED)
static const char *TAG = "Iris";
void spiffs_save(void)
{
FILE *f = fopen(IRIS_STORE_PATH, "wb");
if (!f) {
ESP_LOGE(TAG, "Failed to open %s for write", IRIS_STORE_PATH);
return;
}
iris_store_header_t hdr = {
.magic = IRIS_STORE_MAGIC,
.version = IRIS_STORE_VERSION,
.count = (uint16_t)s_paired_count,
};
fwrite(&hdr, sizeof(hdr), 1, f);
for (int i = 0; i < s_paired_count; i++) {
fwrite(&s_paired[i].p, sizeof(iris_device_persisted_t), 1, f);
}
fclose(f);
ESP_LOGD(TAG, "Saved %d device(s) to SPIFFS", s_paired_count);
}
void spiffs_load(void)
{
FILE *f = fopen(IRIS_STORE_PATH, "rb");
if (!f) {
ESP_LOGI(TAG, "No device store found — starting fresh");
return;
}
iris_store_header_t hdr = {};
if (fread(&hdr, sizeof(hdr), 1, f) != 1 || hdr.magic != IRIS_STORE_MAGIC) {
ESP_LOGW(TAG, "Invalid or corrupt device store — ignoring");
fclose(f);
return;
}
if (hdr.version != IRIS_STORE_VERSION) {
ESP_LOGW(TAG, "Unsupported store version %u — ignoring", hdr.version);
fclose(f);
return;
}
int count = hdr.count;
if (count > CONFIG_IRIS_MAX_DEVICES) {
ESP_LOGW(TAG, "Store has %d devices, capping at %d", count, CONFIG_IRIS_MAX_DEVICES);
count = CONFIG_IRIS_MAX_DEVICES;
}
s_paired_count = 0;
for (int i = 0; i < count; i++) {
iris_device_persisted_t p = {};
if (fread(&p, sizeof(p), 1, f) != 1)
break;
s_paired[s_paired_count].p = p;
s_paired[s_paired_count].online = false;
s_paired[s_paired_count].failed_polls = 0;
s_paired_count++;
}
fclose(f);
ESP_LOGI(TAG, "Loaded %d paired device(s) from SPIFFS", s_paired_count);
}
#endif /* CONFIG_IRIS_ENABLED */
@@ -29,6 +29,18 @@ using ItemValueProvider = std::function<void(const std::string &id, char *buf, s
*/
using MenuStateChangedCallback = std::function<void()>;
/**
* @brief Called when navigating to a screen with dynamic=true.
*
* The provider should call addOrReplaceScreen() to populate or refresh the
* screen's items (and any sub-screens it references) before navigation
* completes. The callback runs synchronously inside navigateToScreen(), so
* it must not block indefinitely.
*
* @param screenId The id of the screen being entered.
*/
using DynamicScreenProvider = std::function<void(const std::string &screenId)>;
struct MenuSelectionItemDef
{
std::string value;
@@ -56,6 +68,7 @@ struct MenuScreenDef
std::string id;
std::string title;
std::vector<MenuItemDef> items;
bool dynamic = false; /**< If true, DynamicScreenProvider is called before navigation completes */
};
/**
@@ -96,6 +109,39 @@ class Mercedes
*/
void setStateChangedCallback(MenuStateChangedCallback callback);
/**
* @brief Sets the provider called when navigating to a screen with dynamic=true.
*
* The provider receives the target screen id and should call
* addOrReplaceScreen() to populate it before the navigation completes.
*/
void setDynamicScreenProvider(DynamicScreenProvider provider);
/**
* @brief Insert or replace a screen in the internal screen map.
*
* Used by the DynamicScreenProvider to inject runtime-generated screens
* (e.g. per-device capability screens) that are not present in menu.json.
* If this is the currently displayed screen, triggers stateChangedCallback.
*/
void addOrReplaceScreen(const MenuScreenDef &screen);
/**
* @brief Add an item to an existing screen if no item with the same id exists.
*
* Used by dynamic providers to append runtime items (e.g. multicast toggle)
* to a screen without replacing items that already have in-memory state.
* No-op if the screen does not exist or the item id is already present.
*/
void ensureItemInScreen(const std::string &screenId, const MenuItemDef &item);
/**
* @brief Remove an item from a screen by id.
*
* No-op if the screen or item does not exist.
*/
void removeItemFromScreen(const std::string &screenId, const std::string &itemId);
// --- State accessors (used by hermes for rendering) ---
/**
@@ -138,6 +184,7 @@ class Mercedes
MenuActionCallback m_actionCallback;
ItemValueProvider m_valueProvider;
MenuStateChangedCallback m_stateChangedCallback;
DynamicScreenProvider m_dynamicScreenProvider;
std::map<std::string, MenuScreenDef> m_screens;
std::string m_currentScreenId;
+58 -2
View File
@@ -107,6 +107,51 @@ void Mercedes::setStateChangedCallback(MenuStateChangedCallback callback)
m_stateChangedCallback = callback;
}
void Mercedes::setDynamicScreenProvider(DynamicScreenProvider provider)
{
m_dynamicScreenProvider = provider;
}
void Mercedes::addOrReplaceScreen(const MenuScreenDef &screen)
{
m_screens[screen.id] = screen;
if (screen.id == m_currentScreenId && m_stateChangedCallback)
m_stateChangedCallback();
}
void Mercedes::ensureItemInScreen(const std::string &screenId, const MenuItemDef &item)
{
auto it = m_screens.find(screenId);
if (it == m_screens.end())
return;
for (const auto &existing : it->second.items)
{
if (existing.id == item.id)
return; // already present — preserve existing in-memory state
}
it->second.items.push_back(item);
if (screenId == m_currentScreenId && m_stateChangedCallback)
m_stateChangedCallback();
}
void Mercedes::removeItemFromScreen(const std::string &screenId, const std::string &itemId)
{
auto it = m_screens.find(screenId);
if (it == m_screens.end())
return;
auto &items = it->second.items;
for (auto jt = items.begin(); jt != items.end(); ++jt)
{
if (jt->id == itemId)
{
items.erase(jt);
if (screenId == m_currentScreenId && m_stateChangedCallback)
m_stateChangedCallback();
return;
}
}
}
bool Mercedes::buildFromJson(const std::string &jsonPayload)
{
cJSON *root = cJSON_Parse(jsonPayload.c_str());
@@ -134,13 +179,16 @@ bool Mercedes::buildFromJson(const std::string &jsonPayload)
continue;
MenuScreenDef screenDef;
cJSON *screenId = cJSON_GetObjectItem(screenItem, "id");
cJSON *screenTitle = cJSON_GetObjectItem(screenItem, "title");
cJSON *screenId = cJSON_GetObjectItem(screenItem, "id");
cJSON *screenTitle = cJSON_GetObjectItem(screenItem, "title");
cJSON *screenDynamic = cJSON_GetObjectItem(screenItem, "dynamic");
if (screenId && cJSON_IsString(screenId))
screenDef.id = screenId->valuestring;
if (screenTitle && cJSON_IsString(screenTitle))
screenDef.title = screenTitle->valuestring;
if (screenDynamic && cJSON_IsBool(screenDynamic))
screenDef.dynamic = cJSON_IsTrue(screenDynamic);
if (m_currentScreenId.empty() && !screenDef.id.empty())
{
@@ -340,6 +388,14 @@ void Mercedes::navigateToScreen(const std::string &screenId)
m_currentScreenId = screenId;
m_selectedIndex = 0;
// For dynamic screens, invoke the provider before rendering so it can
// populate or refresh the screen's items via addOrReplaceScreen().
{
auto it = m_screens.find(screenId);
if (it != m_screens.end() && it->second.dynamic && m_dynamicScreenProvider)
m_dynamicScreenProvider(screenId);
}
const MenuScreenDef &newScreen = m_screens[screenId];
for (size_t i = 0; i < newScreen.items.size(); i++)
{
@@ -399,7 +399,6 @@ void stop_simulation_task(void)
{
TaskHandle_t handle_to_delete = simulation_task_handle;
simulation_task_handle = NULL;
time = NULL;
xSemaphoreGive(simulation_mutex);
// Check if the task still exists before deleting it
+1
View File
@@ -23,6 +23,7 @@ idf_component_register(SRCS
app_update
driver
my_mqtt_client
iris
)
spiffs_create_partition_image(storage ../storage FLASH_IN_PROJECT)
+392
View File
@@ -14,6 +14,10 @@
#include "u8g2_mqtt.h"
#include "wifi_manager.h"
#if defined(CONFIG_IRIS_ENABLED)
#include "iris/iris.h"
#endif
#include <cstring>
#include <driver/i2c.h>
#include <esp_log.h>
@@ -174,6 +178,373 @@ static void on_message_received(const message_t *msg)
}
}
// =============================================================================
// Iris dynamic screen builders
// Called by the DynamicScreenProvider when navigating to dynamic screens.
// =============================================================================
#if defined(CONFIG_IRIS_ENABLED)
// Helper: build a capability detail screen for a paired device.
// Master + online → toggles/selection; offline or Backup → read-only labels.
static void build_paired_device_screen(const iris_device_t &dev)
{
char eui_str[17];
iris_eui64_to_str(dev.p.eui64, eui_str, sizeof(eui_str));
MenuScreenDef screen;
screen.id = std::string("iris_dev_") + eui_str;
screen.title = dev.p.name;
screen.dynamic = false;
bool interactive = iris_is_master() && dev.online;
if (dev.p.capabilities & IRIS_CAP_INNER_LIGHT)
{
MenuItemDef item;
item.id = std::string("tgl_inner_") + eui_str;
item.label = "Innenbeleuchtung";
if (interactive)
{
item.type = "toggle";
item.toggleValue = (dev.p.state & IRIS_STATE_INNER_LIGHT) != 0;
item.actionTopic = std::string("iris/toggle/") + eui_str + "/inner";
}
else
{
item.type = "label";
item.label += std::string(": ") + ((dev.p.state & IRIS_STATE_INNER_LIGHT) ? "an" : "aus");
}
screen.items.push_back(item);
}
if (dev.p.capabilities & IRIS_CAP_OUTER_LIGHT)
{
MenuItemDef item;
item.id = std::string("tgl_outer_") + eui_str;
item.label = "Aussenbeleuchtung";
if (interactive)
{
item.type = "toggle";
item.toggleValue = (dev.p.state & IRIS_STATE_OUTER_LIGHT) != 0;
item.actionTopic = std::string("iris/toggle/") + eui_str + "/outer";
}
else
{
item.type = "label";
item.label += std::string(": ") + ((dev.p.state & IRIS_STATE_OUTER_LIGHT) ? "an" : "aus");
}
screen.items.push_back(item);
}
if (dev.p.capabilities & IRIS_CAP_MOVEMENT)
{
MenuItemDef item;
item.id = std::string("sel_move_") + eui_str;
item.label = "Bewegung";
if (interactive)
{
item.type = "selection";
item.actionTopic = std::string("iris/toggle/") + eui_str + "/movement";
MenuSelectionItemDef oben, unten;
oben.value = "1"; oben.label = "Oben";
unten.value = "0"; unten.label = "Unten";
item.selectionItems = {oben, unten};
item.selectionIndex = (dev.p.state & IRIS_STATE_MOVEMENT) ? 0 : 1;
}
else
{
item.type = "label";
item.label += std::string(": ") + ((dev.p.state & IRIS_STATE_MOVEMENT) ? "Oben" : "Unten");
}
screen.items.push_back(item);
}
if (screen.items.empty())
{
MenuItemDef item;
item.id = std::string("no_cap_") + eui_str;
item.type = "label";
item.label = "Keine Funktionen";
screen.items.push_back(item);
}
// Delete item — available in all states (online/offline, master/backup)
{
MenuItemDef item;
item.id = std::string("del_dev_") + eui_str;
item.type = "action";
item.label = "Loeschen";
item.actionTopic = std::string("iris/unpair/") + eui_str;
screen.items.push_back(item);
}
Mercedes::getInstance().addOrReplaceScreen(screen);
}
// Helper: build the preview + "Aufnehmen" screen for a discovered (unpaired) device.
static void build_new_device_screen(const iris_device_t &dev)
{
char eui_str[17];
iris_eui64_to_str(dev.p.eui64, eui_str, sizeof(eui_str));
MenuScreenDef screen;
screen.id = std::string("iris_new_") + eui_str;
screen.title = dev.p.name;
screen.dynamic = false;
auto add_cap_label = [&](const char *label, bool has_cap) {
MenuItemDef item;
item.id = std::string("cap_") + label + "_" + eui_str;
item.type = "label";
item.label = std::string(label) + (has_cap ? ": ja" : ": nein");
screen.items.push_back(item);
};
add_cap_label("Innenbeleuchtung", dev.p.capabilities & IRIS_CAP_INNER_LIGHT);
add_cap_label("Aussenbeleuchtung", dev.p.capabilities & IRIS_CAP_OUTER_LIGHT);
add_cap_label("Bewegung", dev.p.capabilities & IRIS_CAP_MOVEMENT);
// "Aufnehmen" action
MenuItemDef pair_item;
pair_item.id = std::string("pair_") + eui_str;
pair_item.type = "action";
pair_item.label = "Aufnehmen";
pair_item.actionTopic = std::string("iris/pair/") + eui_str;
screen.items.push_back(pair_item);
Mercedes::getInstance().addOrReplaceScreen(screen);
}
// Dynamic provider: called when entering lights_menu.
// Adds explicit AN/AUS actions for multicast — not toggle, so that
// "Alle Innen AN" turns everything on regardless of current state.
static void on_dynamic_lights(void)
{
const char *inner_on_id = "iris_all_inner_on";
const char *inner_off_id = "iris_all_inner_off";
const char *outer_on_id = "iris_all_outer_on";
const char *outer_off_id = "iris_all_outer_off";
if (iris_any_has_cap(IRIS_CAP_INNER_LIGHT))
{
MenuItemDef on_item;
on_item.id = inner_on_id;
on_item.type = "action";
on_item.label = "Alle Innen AN";
on_item.actionTopic = "iris/set_all/inner/on";
Mercedes::getInstance().ensureItemInScreen("lights_menu", on_item);
MenuItemDef off_item;
off_item.id = inner_off_id;
off_item.type = "action";
off_item.label = "Alle Innen AUS";
off_item.actionTopic = "iris/set_all/inner/off";
Mercedes::getInstance().ensureItemInScreen("lights_menu", off_item);
}
else
{
Mercedes::getInstance().removeItemFromScreen("lights_menu", inner_on_id);
Mercedes::getInstance().removeItemFromScreen("lights_menu", inner_off_id);
}
if (iris_any_has_cap(IRIS_CAP_OUTER_LIGHT))
{
MenuItemDef on_item;
on_item.id = outer_on_id;
on_item.type = "action";
on_item.label = "Alle Aussen AN";
on_item.actionTopic = "iris/set_all/outer/on";
Mercedes::getInstance().ensureItemInScreen("lights_menu", on_item);
MenuItemDef off_item;
off_item.id = outer_off_id;
off_item.type = "action";
off_item.label = "Alle Aussen AUS";
off_item.actionTopic = "iris/set_all/outer/off";
Mercedes::getInstance().ensureItemInScreen("lights_menu", off_item);
}
else
{
Mercedes::getInstance().removeItemFromScreen("lights_menu", outer_on_id);
Mercedes::getInstance().removeItemFromScreen("lights_menu", outer_off_id);
}
}
// Dynamic provider: called when entering settings_menu.
// Adds "Geraet hinzufuegen" only when this unit is Master.
static void on_dynamic_settings(void)
{
const char *add_dev_id = "menu_add_device";
if (iris_is_master())
{
MenuItemDef item;
item.id = add_dev_id;
item.type = "submenu";
item.label = "Geraet hinzufuegen";
item.targetScreenId = "iris_new_devices_menu";
Mercedes::getInstance().ensureItemInScreen("settings_menu", item);
}
else
{
Mercedes::getInstance().removeItemFromScreen("settings_menu", add_dev_id);
}
}
// Dynamic provider: called when entering external_devices_menu.
static void on_dynamic_external_devices(void)
{
iris_device_t devices[CONFIG_IRIS_MAX_DEVICES];
int count = iris_get_paired(devices, CONFIG_IRIS_MAX_DEVICES);
MenuScreenDef screen;
screen.id = "external_devices_menu";
screen.title = "externe Geraete";
screen.dynamic = true;
if (count == 0)
{
MenuItemDef item;
item.id = "ext_empty";
item.type = "label";
item.label = "keine Eintraege";
screen.items.push_back(item);
}
else
{
for (int i = 0; i < count; i++)
{
char eui_str[17];
iris_eui64_to_str(devices[i].p.eui64, eui_str, sizeof(eui_str));
MenuItemDef item;
item.id = std::string("ext_dev_") + eui_str;
item.type = "submenu";
// Append [off] suffix for offline devices
item.label = std::string(devices[i].p.name) +
(devices[i].online ? "" : " [off]");
item.targetScreenId = std::string("iris_dev_") + eui_str;
screen.items.push_back(item);
// Pre-build capability screen for this device
build_paired_device_screen(devices[i]);
}
}
Mercedes::getInstance().addOrReplaceScreen(screen);
}
// Dynamic provider: called when entering iris_new_devices_menu.
static void on_dynamic_new_devices(void)
{
iris_device_t found[8];
int count = iris_scan(found, 8);
MenuScreenDef screen;
screen.id = "iris_new_devices_menu";
screen.title = "Geraet hinzufuegen";
screen.dynamic = true;
if (count == 0)
{
MenuItemDef item;
item.id = "scan_none";
item.type = "label";
item.label = "Keine Geraete";
screen.items.push_back(item);
}
else
{
for (int i = 0; i < count; i++)
{
char eui_str[17];
iris_eui64_to_str(found[i].p.eui64, eui_str, sizeof(eui_str));
MenuItemDef item;
item.id = std::string("new_dev_") + eui_str;
item.type = "submenu";
item.label = found[i].p.name;
item.targetScreenId = std::string("iris_new_") + eui_str;
screen.items.push_back(item);
build_new_device_screen(found[i]);
}
}
Mercedes::getInstance().addOrReplaceScreen(screen);
}
// Register the dynamic screen provider after buildFromJson().
static void register_iris_providers(void)
{
Mercedes::getInstance().setDynamicScreenProvider([](const std::string &screenId) {
if (screenId == "lights_menu")
on_dynamic_lights();
else if (screenId == "settings_menu")
on_dynamic_settings();
else if (screenId == "external_devices_menu")
on_dynamic_external_devices();
else if (screenId == "iris_new_devices_menu")
on_dynamic_new_devices();
});
Mercedes::getInstance().setActionCallback(
[](const std::string & /*id*/, const std::string &topic, const std::string &value) {
// iris/pair/<eui>
if (topic.rfind("iris/pair/", 0) == 0)
{
std::string eui_str = topic.substr(10);
uint8_t eui64[IRIS_EUI64_LEN];
if (iris_str_to_eui64(eui_str.c_str(), eui64))
{
const MenuScreenDef *screen = Mercedes::getInstance().getCurrentScreen();
const char *name = screen ? screen->title.c_str() : eui_str.c_str();
iris_pair(eui64, name);
// Refresh external devices screen on next navigation
}
return;
}
// iris/unpair/<eui>
if (topic.rfind("iris/unpair/", 0) == 0)
{
std::string eui_str = topic.substr(12);
uint8_t eui64[IRIS_EUI64_LEN];
if (iris_str_to_eui64(eui_str.c_str(), eui64))
iris_unpair(eui64);
// Navigate back
Mercedes::getInstance().handleInput(BTN_BACK);
return;
}
// iris/toggle/<eui>/<cap>
if (topic.rfind("iris/toggle/", 0) == 0)
{
std::string rest = topic.substr(12);
auto slash = rest.find('/');
if (slash != std::string::npos)
{
std::string eui_str = rest.substr(0, slash);
std::string cap_str = rest.substr(slash + 1);
uint8_t eui64[IRIS_EUI64_LEN];
uint8_t cap = 0;
if (cap_str == "inner") cap = IRIS_CAP_INNER_LIGHT;
else if (cap_str == "outer") cap = IRIS_CAP_OUTER_LIGHT;
else if (cap_str == "movement") cap = IRIS_CAP_MOVEMENT;
if (iris_str_to_eui64(eui_str.c_str(), eui64) && cap)
iris_toggle(eui64, cap);
}
return;
}
// iris/set_all/<cap>/on|off — explicit multicast state, not toggle
if (topic == "iris/set_all/inner/on") { iris_set_all(IRIS_CAP_INNER_LIGHT, true); return; }
if (topic == "iris/set_all/inner/off") { iris_set_all(IRIS_CAP_INNER_LIGHT, false); return; }
if (topic == "iris/set_all/outer/on") { iris_set_all(IRIS_CAP_OUTER_LIGHT, true); return; }
if (topic == "iris/set_all/outer/off") { iris_set_all(IRIS_CAP_OUTER_LIGHT, false); return; }
});
}
#endif // CONFIG_IRIS_ENABLED
// --- Main task ---
void app_task(void *args)
@@ -256,6 +627,19 @@ void app_task(void *args)
// Start network and services
wifi_manager_init();
#if defined(CONFIG_IRIS_ENABLED)
if (iris_init() == ESP_OK)
{
iris_start_inventory_task();
ESP_LOGI(TAG, "Iris Thread manager started (priority=%d)", iris_get_priority());
}
else
{
ESP_LOGE(TAG, "Iris Thread manager init failed");
}
#endif
mqtt_client_start();
message_manager_register_listener(on_message_received);
start_simulation();
@@ -297,6 +681,14 @@ void app_task(void *args)
{
ESP_LOGE(TAG, "Failed to parse menu.json");
}
#if defined(CONFIG_IRIS_ENABLED)
register_iris_providers();
// Set initial master/backup status label
Mercedes::getInstance().updateItemValue("master_status",
iris_is_master() ? "" : "BACKUP");
ESP_LOGI(TAG, "Iris dynamic screen providers registered");
#endif
}
else
{
-7
View File
@@ -11,7 +11,6 @@
#include <esp_log.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <esp_system.h>
#include <nvs_flash.h>
#include <sdkconfig.h>
@@ -40,12 +39,6 @@ void app_main(void)
gpio_set_level(WIFI_ANT_CONFIG, 1); // HIGH
#endif
esp_reset_reason_t reset_reason = esp_reset_reason();
if (reset_reason == ESP_RST_PANIC || reset_reason == ESP_RST_TASK_WDT || reset_reason == ESP_RST_INT_WDT)
{
ESP_LOGW("app_main", "Reboot after crash (reason: %d) — continuing normal init", reset_reason);
}
// Initialize NVS
esp_err_t err = nvs_flash_init();
if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND)
+7 -4
View File
@@ -24,6 +24,13 @@ CONFIG_LWIP_SNTP_UPDATE_DELAY=14400000
CONFIG_LWIP_SNTP_STARTUP_DELAY=y
CONFIG_LWIP_SNTP_MAXIMUM_STARTUP_DELAY=5000
# ESP PSRAM
CONFIG_SPIRAM=y
# SPI RAM config
CONFIG_SPIRAM_SPEED=80
CONFIG_SPIRAM_USE_CAPS_ALLOC=y
# HTTP Server WebSocket Support
CONFIG_HTTPD_WS_SUPPORT=y
@@ -32,12 +39,8 @@ CONFIG_MQTT_CLIENT_BROKER_URL="mqtts://mqtt.mars3142.dev:8883"
CONFIG_MQTT_CLIENT_USERNAME="system-control"
CONFIG_MQTT_CLIENT_PASSWORD="3jHLhNPLcn_dPrukrpMJ"
# System Event Task
CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE=4096
# Compiler Options
CONFIG_COMPILER_DISABLE_DEFAULT_ERRORS=y
CONFIG_ESP_SYSTEM_USE_FRAME_POINTER=y
# Certificate Bundle
CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=n
+34
View File
@@ -25,3 +25,37 @@ CONFIG_STATUS_WLED_PIN=16
CONFIG_API_SERVER_HOSTNAME="system-control"
CONFIG_LWIP_MAX_SOCKETS=20
#
# System event task stack size
#
CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE=4096
#
# OpenThread (Thread Border Router + Commissioner)
# Required for Iris Thread device management on ESP32-C6
#
CONFIG_OPENTHREAD_ENABLED=y
CONFIG_OPENTHREAD_BORDER_ROUTER=y
CONFIG_OPENTHREAD_COMMISSIONER=y
CONFIG_OPENTHREAD_JOINER=n
CONFIG_OPENTHREAD_RADIO_NATIVE=y
#
# IPv6 — required for Thread
#
CONFIG_LWIP_IPV6=y
CONFIG_LWIP_IPV6_NUM_ADDRESSES=12
#
# mbedTLS — DTLS + ECJPAKE required by OpenThread Commissioner/Joiner
#
CONFIG_MBEDTLS_SSL_PROTO_DTLS=y
CONFIG_MBEDTLS_KEY_EXCHANGE_ECJPAKE=y
CONFIG_MBEDTLS_ECJPAKE_C=y
#
# Iris — Thread device manager
#
CONFIG_IRIS_ENABLED=y
CONFIG_IRIS_MASTER_PRIORITY=1
-7
View File
@@ -2,10 +2,3 @@
CONFIG_IDF_TARGET="esp32s3"
CONFIG_API_SERVER_HOSTNAME="system-client"
# ESP PSRAM
CONFIG_SPIRAM=y
# SPI RAM config
CONFIG_SPIRAM_SPEED=80
CONFIG_SPIRAM_USE_CAPS_ALLOC=y
+24 -4
View File
@@ -4,6 +4,11 @@
"id": "main_menu",
"title": "Hauptmenü",
"items": [
{
"id": "master_status",
"type": "label",
"label": ""
},
{
"id": "menu_lights",
"type": "submenu",
@@ -27,6 +32,7 @@
{
"id": "lights_menu",
"title": "Lichtsteuerung",
"dynamic": true,
"items": [
{
"id": "light_active",
@@ -86,18 +92,20 @@
},
{
"id": "external_devices_menu",
"title": "externe Geräte",
"title": "externe Geraete",
"dynamic": true,
"items": [
{
"id": "empty",
"id": "ext_placeholder",
"type": "label",
"label": "keine Einträge"
"label": "Lade..."
}
]
},
{
"id": "settings_menu",
"title": "Einstellungen",
"dynamic": true,
"items": [
{
"id": "ota_update",
@@ -111,6 +119,18 @@
"label": "Device-ID"
}
]
},
{
"id": "iris_new_devices_menu",
"title": "Geraet hinzufuegen",
"dynamic": true,
"items": [
{
"id": "scan_placeholder",
"type": "label",
"label": "Suche..."
}
]
}
]
}
}
+6 -2
View File
@@ -63,5 +63,9 @@
# Abenddämmerung (Violett → Blau)
80,45,95,0,115,250
45,35,78,0,100,250
20,25,55,0,88,250
60,40,100,0,110,250
45,35,95,0,105,250
30,30,85,0,100,250
25,30,75,0,95,250
20,25,65,0,90,250
15,20,50,0,85,250
1 # Nacht (Tiefblau/Dunkelblau)
63
64
65
66
67
68
69
70
71
Binary file not shown.
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">🚂</text></svg>

Before

Width:  |  Height:  |  Size: 109 B

Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
-23
View File
@@ -1,23 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<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="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<title>System Control</title>
<script type="module" crossorigin src="/index-YHhIjoLo.js"></script>
<link rel="modulepreload" crossorigin href="/vendor-CwcuF_np.js">
<link rel="stylesheet" crossorigin href="/vendor-CbWpK_cD.css">
<link rel="stylesheet" crossorigin href="/index-BfY4NlvY.css">
</head>
<body>
<div id="app"></div>
</body>
</html>
-1
View File
@@ -1 +0,0 @@
<HTML><HEAD><TITLE>Success</TITLE></HEAD><BODY>Success</BODY></HTML>
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -1,11 +0,0 @@
{
"enableAllProjectMcpServers": true,
"enabledMcpjsonServers": [
"svelte"
],
"permissions": {
"allow": [
"mcp__svelte__svelte-autofixer"
]
}
}
-23
View File
@@ -1,23 +0,0 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "chrome",
"request": "launch",
"name": "Debug Svelte (Chrome)",
"url": "http://localhost:5173",
"webRoot": "${workspaceFolder}",
"sourceMaps": true,
"preLaunchTask": "npm: dev"
},
{
"type": "msedge",
"request": "launch",
"name": "Debug Svelte (Edge)",
"url": "http://localhost:5173",
"webRoot": "${workspaceFolder}",
"sourceMaps": true,
"preLaunchTask": "npm: dev"
}
]
}
-25
View File
@@ -1,25 +0,0 @@
{
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "dev",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": ".*VITE.*ready in.*",
"endsPattern": ".*VITE.*ready in.*"
}
},
"presentation": {
"focus": false,
"panel": "shared",
"showReuseMessage": false,
"clear": true
}
}
]
}
-16
View File
@@ -21,19 +21,3 @@ You MUST use this tool whenever writing Svelte code before sending it to the use
Generates a Svelte Playground link with the provided code.
After completing the code, ask the user if they want a playground link. Only call this tool after user confirmation and NEVER if code was written to files in their project.
# Rolle: Senior Software Architect & Developer
Du agierst ab sofort als erfahrener Senior Software Developer und Architekt. Dein primärer Fokus liegt stets auf sauberer Architektur, Wartbarkeit, Skalierbarkeit und Best Practices, bevor auch nur eine Zeile Code geschrieben wird.
## Deine Prinzipien:
1. **Denke in Systemen, nicht in Snippets:** Bevor du ein Problem löst oder Code generierst, analysiere, wie sich die Änderung in die bestehende Architektur (z. B. SvelteKit, Backend-Services) einfügt.
2. **Hinterfrage Anforderungen (Push Back):** Wenn eine vom Nutzer vorgeschlagene Lösung architektonisch unsauber ist (z. B. "Quick & Dirty" Workarounds, enge Kopplung, Verletzung von SOLID-Prinzipien), weise darauf hin und schlage eine bessere, nachhaltigere Alternative vor.
3. **Trennung von Verantwortlichkeiten (SoC):** Achte peinlich genau darauf, dass UI-Logik, State-Management und Business-Logik sauber getrennt bleiben.
4. **Zukunftssicherheit:** Schreibe Code, der auch in 6 Monaten von anderen Entwicklern noch leicht verstanden und erweitert werden kann.
5. **Erkläre das "Warum":** Wenn du Code refactorst oder vorschlägst, erkläre immer die architektonische Entscheidung dahinter (z.B. "Ich habe dies in einen eigenen Service ausgelagert, um eine zirkuläre Abhängigkeit zu vermeiden...").
## Workflow bei neuen Features:
- Skizziere zuerst kurz den architektonischen Ansatz (z.B. Datenfluss, State-Management-Strategie).
- Schreibe erst danach den eigentlichen Code.
- Berücksichtige Aspekte wie Performance, Fehlerbehandlung (Error Handling) und Edge Cases.
-45
View File
@@ -1,45 +0,0 @@
# Matter over Thread Architecture
This document outlines the architectural decision and technical implementation strategy for integrating external end devices (e.g., display-less components like lighthouses, signals) into the System Control ecosystem.
## 1. Architectural Decision: Why Matter over Thread?
We have decided to use **Matter over Thread** as the standard communication protocol between the main controller (ESP) and external end devices.
### Key Benefits
* **Standardization:** Matter provides a standardized application layer (Clusters and Endpoints). A "light" or "blinking" function is mapped to standard clusters (e.g., `OnOff Cluster`), meaning the UI and backend do not need custom JSON parsing for every new device type.
* **Ecosystem Compatibility (Multi-Admin):** Matter's Multi-Admin feature allows a single end device to be controlled by multiple controllers simultaneously. This means a device can be paired to the ESP's web UI **and** directly to Apple Home or Google Home at the same time.
* **Robust Infrastructure:** Thread provides a self-healing IPv6 mesh network. The ESP acts as the Thread Border Router and Matter Commissioner.
## 2. System Roles
### Main Controller (ESP)
1. **Thread Border Router / Commissioner:** The ESP creates and manages the Thread network. It exposes a "Permit Join" window (accessible via the Web UI) to allow new, display-less devices to join the network.
2. **Matter Controller:** The ESP acts as a Matter Controller. Once a device is on the Thread network, the ESP discovers its capabilities (Clusters) and exposes them to the Web UI for configuration and control.
### End Devices (e.g., Lighthouse)
* Act as standard **Matter Accessories**.
* Join the Thread network during the "Permit Join" window using their setup code (PSKd).
* Expose their capabilities as standard Matter endpoints (e.g., Endpoint 1: Light, Endpoint 2: Blinking feature).
## 3. UI Workflow
The web interface separates network infrastructure from application control:
1. **System Tab (Infrastructure):** Used to open the Thread network ("Pair Devices" / Permit Join) so that new devices can receive an IP address and join the mesh.
2. **Configuration Tab -> Devices (Discovery):** Used to discover Matter devices that have joined the network, authenticate them via their Setup PIN, and map them to the system database.
3. **Control Tab (Application):** Dynamically displays controls (buttons, toggles) based on the discovered Matter clusters of the paired devices.
## 4. Development and Certification Notes (DIY / Hobby)
For private, DIY, and development purposes, **no official CSA (Connectivity Standards Alliance) certification is required.**
We utilize **Test Vendor IDs (VID)** (e.g., `0xFFF1`) and **Test Product IDs (PID)**.
### Ecosystem Behavior with Test Certificates
* **Apple Home (iOS):** Apple allows pairing of uncertified Matter accessories. During the pairing process, iOS will display a warning ("This accessory is uncertified"). The user can simply click **"Add anyway"** to bypass this and use the device normally.
* **Google Home:** Google requires explicit permission to add uncertified devices. Developers must log into the free **Google Home Developer Console** and register their Test VID and PID. Once registered, the Google Home app on the developer's smartphone will allow pairing without errors.
* **Our ESP Controller:** The ESP Matter SDK will be configured to accept test certificates during the Attestation phase (PASE/CASE), ensuring seamless pairing without warnings.
---
*Documented to ensure a shared understanding of the IoT communication strategy within the project team.*
+13 -53
View File
@@ -10,12 +10,10 @@
"@picocss/pico": "^2.1.1",
"@tailwindcss/vite": "^4.1.18",
"gsap": "^3.13.0",
"svelte-french-toast": "^2.0.0-alpha.0",
"svelte-spa-router": "^4.0.2"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^6.1.1",
"@tailwindcss/typography": "^0.5.19",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.31",
"svelte": "^5.53.5",
@@ -881,6 +879,7 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz",
"integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"acorn": "^8.9.0"
@@ -1183,33 +1182,6 @@
"node": ">= 10"
}
},
"node_modules/@tailwindcss/typography": {
"version": "0.5.19",
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
"integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"postcss-selector-parser": "6.0.10"
},
"peerDependencies": {
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
}
},
"node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": {
"version": "6.0.10",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
"dev": true,
"license": "MIT",
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
},
"engines": {
"node": ">=4"
}
},
"node_modules/@tailwindcss/vite": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz",
@@ -1258,6 +1230,7 @@
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"dev": true,
"license": "MIT"
},
"node_modules/@vitest/expect": {
@@ -1379,6 +1352,7 @@
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
@@ -1448,6 +1422,7 @@
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
"integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
@@ -1504,6 +1479,7 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
@@ -1706,6 +1682,7 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -1805,6 +1782,7 @@
"version": "5.6.4",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz",
"integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==",
"dev": true,
"license": "MIT"
},
"node_modules/didyoumean": {
@@ -1903,12 +1881,14 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
"dev": true,
"license": "MIT"
},
"node_modules/esrap": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.2.tgz",
"integrity": "sha512-zA6497ha+qKvoWIK+WM9NAh5ni17sKZKhbS5B3PoYbBvaYHZWoS33zmFybmyqpn07RLUxSmn+RCls2/XF+d0oQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15"
@@ -2171,6 +2151,7 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.6"
@@ -2479,6 +2460,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
"dev": true,
"license": "MIT"
},
"node_modules/loupe": {
@@ -3105,6 +3087,7 @@
"version": "5.53.12",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.53.12.tgz",
"integrity": "sha512-4x/uk4rQe/d7RhfvS8wemTfNjQ0bJbKvamIzRBfTe2eHHjzBZ7PZicUQrC2ryj83xxEacfA1zHKd1ephD1tAxA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
@@ -3128,18 +3111,6 @@
"node": ">=18"
}
},
"node_modules/svelte-french-toast": {
"version": "2.0.0-alpha.0",
"resolved": "https://registry.npmjs.org/svelte-french-toast/-/svelte-french-toast-2.0.0-alpha.0.tgz",
"integrity": "sha512-81wcVaY9UZ/0JuzLEizMSoIXqNbX7yhfTZavBuw94T3cnT2HmJ9O+qXY/c91h9FkeMwboo0KHZVmzOEQVTXDFg==",
"license": "MIT",
"dependencies": {
"svelte-writable-derived": "^3.1.1"
},
"peerDependencies": {
"svelte": "^5.0.0"
}
},
"node_modules/svelte-spa-router": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/svelte-spa-router/-/svelte-spa-router-4.0.2.tgz",
@@ -3152,18 +3123,6 @@
"url": "https://github.com/sponsors/ItalyPaleAle"
}
},
"node_modules/svelte-writable-derived": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/svelte-writable-derived/-/svelte-writable-derived-3.1.1.tgz",
"integrity": "sha512-w4LR6/bYZEuCs7SGr+M54oipk/UQKtiMadyOhW0PTwAtJ/Ai12QS77sLngEcfBx2q4H8ZBQucc9ktSA5sUGZWw==",
"license": "MIT",
"funding": {
"url": "https://ko-fi.com/pixievoltno1"
},
"peerDependencies": {
"svelte": "^3.2.1 || ^4.0.0-next.1 || ^5.0.0-next.94"
}
},
"node_modules/tailwindcss": {
"version": "3.4.19",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
@@ -3592,6 +3551,7 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
"dev": true,
"license": "MIT"
}
}
-2
View File
@@ -9,7 +9,6 @@
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^6.1.1",
"@tailwindcss/typography": "^0.5.19",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.31",
"svelte": "^5.53.5",
@@ -23,7 +22,6 @@
"@picocss/pico": "^2.1.1",
"@tailwindcss/vite": "^4.1.18",
"gsap": "^3.13.0",
"svelte-french-toast": "^2.0.0-alpha.0",
"svelte-spa-router": "^4.0.2"
}
}
+4 -5
View File
@@ -3,13 +3,13 @@
import Footer from './components/footer.svelte';
import Index from './routes/index.svelte';
import Captive from './routes/captive.svelte';
import Router from 'svelte-spa-router';
import Router, { location } from 'svelte-spa-router';
import { lang } from './i18n/store';
import { Toaster } from 'svelte-french-toast';
const routes = {
'/': Index,
'/captive': Captive,
'/control': Index,
'/config': Index,
// Fallback route
'*': Index
};
@@ -19,8 +19,7 @@
});
</script>
<Toaster/>
<div class="container mx-auto">
<div class="container mx-auto lg:max-w-2xl">
<Header />
<main class="py-8">
@@ -7,4 +7,4 @@
<LedConfiguration />
<SchemaConfiguration />
</div>
</div>
@@ -1,118 +1,14 @@
<script lang="ts">
import { onMount } from 'svelte';
import Card from '../common/card.svelte';
import Button from '../common/button.svelte';
import { t } from '../../i18n/store';
import SegmentRow from './segmentRow.svelte';
import { segmentStore, type Segment } from '../../stores/configSegmentStore';
import toast from 'svelte-french-toast';
let segments = $state<Segment[]>([]);
let nextId = $state(0);
onMount(() => {
const unsub = segmentStore.subscribe((data) => {
segments = data.map((s) => ({ ...s }));
nextId = data.length;
});
segmentStore.fetchSegments();
return unsub;
});
function addSegment() {
const id = `new-${nextId++}`;
segments = [...segments, { id, name: $t('wled.segment.name').replace('{num}', String(nextId)), start: 0, leds: 1 }];
}
function findOverlap(): [string, string] | null {
const sorted = [...segments].sort((a, b) => a.start - b.start);
for (let i = 0; i < sorted.length - 1; i++) {
const a = sorted[i];
const b = sorted[i + 1];
if (a.start + a.leds > b.start) {
return [a.name || `#${i + 1}`, b.name || `#${i + 2}`];
}
}
return null;
}
async function saveSegments() {
const overlap = findOverlap();
if (overlap) {
toast.error(`"${overlap[0]}" & "${overlap[1]}" überschneiden sich`);
return;
}
try {
await segmentStore.updateSegments(segments);
toast.success($t('wled.saved'));
} catch {
toast.error($t('wled.error.save'));
}
}
</script>
<Card title="wled.config.title">
<p class="text-sm -mt-3 mb-4 text-text-muted">{$t('wled.config.desc')}</p>
<p class="text-sm -mt-3 mb-4 text-text-muted">{$t("wled.config.desc")}</p>
<div class="flex justify-center items-center mb-2">
<div class="flex-1 text-sm font-medium">{$t('wled.segments.title')}</div>
<Button label={$t('wled.segment.add')} ariaLabel={$t('wled.segment.add')} icon="" onClick={addSegment} />
</div>
<div class="overflow-y-auto max-h-64 scrollbar">
<table class="w-full table-fixed border-collapse text-left">
<colgroup>
<col />
<col class="w-24" />
<col class="w-24" />
<col class="w-12" />
</colgroup>
<thead class="sticky top-0 z-10 bg-card">
<tr class="border-b-2 border-border">
<th class="px-3 py-2 text-sm">{$t('wled.segment.name').replace('{num}', '')}</th>
<th class="px-3 py-2 text-sm text-center">{$t('wled.segment.start')}</th>
<th class="px-3 py-2 text-sm text-center">{$t('wled.segment.leds')}</th>
<th class="w-12"></th>
</tr>
</thead>
<tbody>
{#each segments as segment (segment.id)}
<SegmentRow
bind:name={segment.name}
bind:start={segment.start}
bind:leds={segment.leds}
onDelete={() => { segments = segments.filter((s) => s.id !== segment.id); }}
/>
{/each}
{#if segments.length === 0}
<tr>
<td colspan="3" class="px-3 py-4 text-sm text-center text-text-muted">
<div>{$t('wled.segments.empty.title')}</div>
<div class="text-xs mt-1">{$t('wled.segments.empty.hint')}</div>
</td>
</tr>
{/if}
</tbody>
</table>
</div>
<div class="flex justify-end mt-3">
<Button label={$t('btn.save')} ariaLabel={$t('btn.save')} icon="💾" onClick={saveSegments} />
<div class="flex justify-center items-center">
<div class="flex-1">Segmente</div>
<Button label={$t("wled.segment.add")} ariaLabel={$t("wled.segment.add")}></Button>
</div>
</Card>
<style>
.scrollbar {
scrollbar-color: var(--primary) var(--border);
scrollbar-width: thin;
}
.scrollbar::-webkit-scrollbar {
width: 6px;
}
.scrollbar::-webkit-scrollbar-track {
background: var(--border);
border-radius: 3px;
}
.scrollbar::-webkit-scrollbar-thumb {
background: var(--primary);
border-radius: 3px;
}
</style>
@@ -1,143 +1,7 @@
<script lang="ts">
import Card from '../common/card.svelte';
import { t } from '../../i18n/store';
import DropDown from '../common/dropDown.svelte';
import Button from '../common/button.svelte';
import toast from 'svelte-french-toast';
import SchemaRow from './schemaRow.svelte';
import { schemaStore, type SchemaRow as SchemaRowData } from '../../stores/configSchemaStore';
import { controlStore } from '../../stores/controlStore';
import { onMount } from 'svelte';
const schemas = [
{ value: 'schema_01.csv', label: $t('schema.name.1') },
{ value: 'schema_02.csv', label: $t('schema.name.2') },
{ value: 'schema_03.csv', label: $t('schema.name.3') }
];
let activeSchema = $state($controlStore.schema ?? 'schema_01.csv');
let activeLabel = $derived(schemas.find((s) => s.value === activeSchema)?.label ?? activeSchema);
let userSelected = $state(false);
let currentSchema = $derived($controlStore.schema);
$effect(() => {
if (currentSchema && !userSelected) {
activeSchema = currentSchema;
schemaStore.fetchSchema(currentSchema).catch(() => {});
}
});
let rows = $state<SchemaRowData[]>([]);
onMount(() => {
return schemaStore.subscribe((data) => {
rows = data.map((r) => ({ ...r }));
});
});
function rowToTime(index: number): string {
const total = index * 30;
const h = Math.floor(total / 60);
const m = total % 60;
return `${h < 10 ? '0' : ''}${h}:${m < 10 ? '0' : ''}${m}`;
}
async function loadClick() {
try {
await schemaStore.fetchSchema(activeSchema);
toast.success($t('schema.loaded').replace('{file}', activeLabel));
} catch {
toast.error($t('schema.demo'));
}
}
async function saveClick() {
try {
await schemaStore.saveSchema(activeSchema, rows);
toast.success($t('schema.saved').replace('{file}', activeLabel));
} catch {
toast.error($t('error'));
}
}
</script>
<Card title="schema.editor.title">
<div class="space-y-2">
<div class="flex items-center justify-center cursor-pointer space-x-2">
<DropDown
id="active-schema"
options={schemas}
bind:value={activeSchema}
onchange={() => (userSelected = true)}
/>
<Button
label={$t('btn.load')}
ariaLabel={$t('btn.load')}
onClick={loadClick}
icon="🔄" />
<Button
label={$t('btn.save')}
ariaLabel={$t('btn.save')}
onClick={saveClick}
icon="💾" />
</div>
<div class="overflow-auto max-h-80 scrollbar">
<table class="w-full table-fixed border-collapse text-left">
<colgroup>
<col class="w-14" />
<col class="w-14" />
<col />
<col />
<col />
<col />
</colgroup>
<thead class="sticky top-0 z-10 bg-card">
<tr class="border-b-2 border-border">
<th class="px-3 py-2 text-sm">{$t('schema.header.time')}</th>
<th class="px-3 py-2 text-sm text-center">{$t('schema.header.color')}</th>
<th class="px-3 py-2 text-sm text-center">{$t('schema.header.red')}</th>
<th class="px-3 py-2 text-sm text-center">{$t('schema.header.green')}</th>
<th class="px-3 py-2 text-sm text-center">{$t('schema.header.blue')}</th>
<th class="px-3 py-2 text-sm text-center">{$t('schema.header.brightness')}</th>
</tr>
</thead>
<tbody>
{#each rows as row, i}
<SchemaRow
time={rowToTime(i)}
bind:r={row.r}
bind:g={row.g}
bind:b={row.b}
bind:brightness={row.brightness}
/>
{/each}
{#if rows.length === 0}
<tr>
<td colspan="6" class="px-3 py-4 text-sm text-center text-text-muted">
{$t('schema.loading')}
</td>
</tr>
{/if}
</tbody>
</table>
</div>
</div>
</Card>
<style>
.scrollbar {
scrollbar-color: var(--primary) var(--border);
scrollbar-width: thin;
}
.scrollbar::-webkit-scrollbar {
width: 6px;
}
.scrollbar::-webkit-scrollbar-track {
background: var(--border);
border-radius: 3px;
}
.scrollbar::-webkit-scrollbar-thumb {
background: var(--primary);
border-radius: 3px;
}
</style>
</Card>
@@ -1,65 +0,0 @@
<script lang="ts">
interface Props {
time: string;
r: number;
g: number;
b: number;
brightness: number;
}
let { time, r = $bindable(), g = $bindable(), b = $bindable(), brightness = $bindable() }: Props = $props();
function clamp(v: number): number {
return Math.min(255, Math.max(0, Math.round(v)));
}
</script>
<tr class="group">
<td class="px-3 py-2 text-sm bg-bg-secondary group-hover:bg-accent border-y border-l border-border">{time}</td>
<td class="px-2 py-2 text-sm bg-bg-secondary group-hover:bg-accent border-y border-border">
<div class="h-6 rounded" style="background: rgb({r},{g},{b});"></div>
</td>
<td class="px-3 py-2 text-sm text-center bg-bg-secondary group-hover:bg-accent border-y border-border">
<input
type="number" min="0" max="255"
bind:value={r}
oninput={() => r = clamp(r)}
class="w-14 px-1 py-0.5 text-sm text-center rounded border border-border bg-input focus:outline-none focus:border-primary no-spinner"
/>
</td>
<td class="px-3 py-2 text-sm text-center bg-bg-secondary group-hover:bg-accent border-y border-border">
<input
type="number" min="0" max="255"
bind:value={g}
oninput={() => g = clamp(g)}
class="w-14 px-1 py-0.5 text-sm text-center rounded border border-border bg-input focus:outline-none focus:border-primary no-spinner"
/>
</td>
<td class="px-3 py-2 text-sm text-center bg-bg-secondary group-hover:bg-accent border-y border-border">
<input
type="number" min="0" max="255"
bind:value={b}
oninput={() => b = clamp(b)}
class="w-14 px-1 py-0.5 text-sm text-center rounded border border-border bg-input focus:outline-none focus:border-primary no-spinner"
/>
</td>
<td class="px-3 py-2 text-sm text-center bg-bg-secondary group-hover:bg-accent border-y border-r border-border">
<input
type="number" min="0" max="255"
bind:value={brightness}
oninput={() => brightness = clamp(brightness)}
class="w-14 px-1 py-0.5 text-sm text-center rounded border border-border bg-input focus:outline-none focus:border-primary no-spinner"
/>
</td>
</tr>
<style>
:global(input.no-spinner::-webkit-inner-spin-button),
:global(input.no-spinner::-webkit-outer-spin-button) {
-webkit-appearance: none;
margin: 0;
}
:global(input.no-spinner) {
-moz-appearance: textfield;
}
</style>
@@ -1,54 +0,0 @@
<script lang="ts">
interface Props {
name: string;
start: number;
leds: number;
onDelete: () => void;
}
let { name = $bindable(), start = $bindable(), leds = $bindable(), onDelete }: Props = $props();
</script>
<tr class="group">
<td class="px-3 py-2 text-sm bg-bg-secondary group-hover:bg-accent border-y border-l border-border">
<input
type="text"
bind:value={name}
class="w-full px-1 py-0.5 text-sm rounded border border-border bg-input focus:outline-none focus:border-primary"
/>
</td>
<td class="px-3 py-2 text-sm text-center bg-bg-secondary group-hover:bg-accent border-y border-border">
<input
type="number"
min="0"
bind:value={start}
class="w-16 px-1 py-0.5 text-sm text-right rounded border border-border bg-input focus:outline-none focus:border-primary no-spinner"
/>
</td>
<td class="px-3 py-2 text-sm text-center bg-bg-secondary group-hover:bg-accent border-y border-border">
<input
type="number"
min="1"
bind:value={leds}
class="w-16 px-1 py-0.5 text-sm text-right rounded border border-border bg-input focus:outline-none focus:border-primary no-spinner"
/>
</td>
<td class="px-1 py-2 text-sm text-center bg-bg-secondary group-hover:bg-accent border-y border-r border-border">
<button
onclick={onDelete}
aria-label="delete"
class="flex items-center justify-center cursor-pointer px-1 py-1 bg-card rounded-lg text-text-muted text-sm transition-all border-solid border-border border-x border-y hover:border-primary"
>🗑️</button>
</td>
</tr>
<style>
:global(input.no-spinner::-webkit-inner-spin-button),
:global(input.no-spinner::-webkit-outer-spin-button) {
-webkit-appearance: none;
margin: 0;
}
:global(input.no-spinner) {
-moz-appearance: textfield;
}
</style>
@@ -31,9 +31,9 @@
<footer class="px-0 py-4">
<hr class="border-0 mb-4 border-t-2 border-solid" />
<div class="flex justify-between items-center">
<div class="flex justify-center items-center relative">
<p class="m-0">© {romanYear} by mars3142</p>
<p class="m-0 text-sm text-gray-500">
<p class="absolute right-0 m-0 text-sm text-gray-500">
v{__APP_VERSION__} ({__COMMIT_HASH__})
</p>
</div>
@@ -1,7 +0,0 @@
<script lang="ts">
import ThreadProvisioning from "./threadProvisioning.svelte";
</script>
<div class="space-y-6">
<ThreadProvisioning />
</div>
@@ -1,66 +0,0 @@
<script lang="ts">
import { t } from '../../i18n/store';
import { threadStore } from '../../stores/threadStore';
import Card from '../common/card.svelte';
import Button from '../common/button.svelte';
function handlePermitJoin() {
threadStore.startPermitJoin(60); // 60 seconds
}
</script>
<Card title="thread.title">
<p class="text-sm text-text-muted mb-4">
{$t('thread.desc')}
</p>
<!-- Network Info Section -->
{#if $threadStore.networkInfo}
<div class="mb-4 p-4 bg-background rounded-lg border border-border flex flex-col gap-2">
<div class="flex justify-between items-center">
<span class="text-sm font-semibold">Status:</span>
<span class="text-sm text-success">
{$t(`thread.status.${$threadStore.networkInfo.status}`)}
</span>
</div>
<div class="flex justify-between items-center">
<span class="text-sm font-semibold">{$t('thread.network')}</span>
<span class="text-sm font-mono">{$threadStore.networkInfo.networkName}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-sm font-semibold">{$t('thread.panId')}</span>
<span class="text-sm font-mono">{$threadStore.networkInfo.panId}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-sm font-semibold">{$t('thread.channel')}</span>
<span class="text-sm font-mono">{$threadStore.networkInfo.channel}</span>
</div>
</div>
<!-- Commissioning Section -->
<div class="mt-6 flex flex-col gap-4">
<Button
label={$threadStore.isPermitJoinActive
? $t('thread.permitJoinActive').replace('{time}', $threadStore.permitJoinTimeLeft.toString())
: $t('thread.permitJoin')}
ariaLabel="Start Pairing"
onClick={handlePermitJoin}
/>
{#if $threadStore.isPermitJoinActive || $threadStore.joinedDevicesCount > 0}
<div class="p-3 bg-success/10 border border-success/30 rounded-lg flex items-center justify-center gap-2">
{#if $threadStore.isPermitJoinActive}
<span class="inline-block w-4 h-4 rounded-full border-2 border-success border-t-transparent animate-spin"></span>
{/if}
<span class="text-sm font-medium text-success">
{$t('thread.joinedCount').replace('{count}', $threadStore.joinedDevicesCount.toString())}
</span>
</div>
{/if}
</div>
{:else}
<div class="p-4 bg-background rounded-lg border border-border text-center text-sm text-text-muted italic">
{$t('thread.noNetwork')}
</div>
{/if}
</Card>
+19 -55
View File
@@ -22,9 +22,6 @@
"devices": "🔗 Geräte",
"scenes": "🎬 Szenen"
}
},
"system": {
"title": "🔧 System"
}
},
"wled": {
@@ -34,13 +31,11 @@
},
"segments": {
"title": "Segmente",
"empty": {
"title": "Keine Segmente konfiguriert",
"hint": "Klicke auf \"Segment hinzufügen\" um ein Segment zu erstellen"
}
"empty": "Keine Segmente konfiguriert",
"empty.hint": "Klicke auf \"Segment hinzufügen\" um ein Segment zu erstellen"
},
"segment": {
"add": "Segment hinzufügen",
"add": " Segment hinzufügen",
"name": "Segment {num}",
"leds": "Anzahl LEDs",
"start": "Start-LED",
@@ -91,11 +86,7 @@
"loading": "Schema wird geladen...",
"header": {
"time": "Zeit",
"color": "Farbe",
"red": "R",
"green": "G",
"blue": "B",
"brightness": "Helligkeit"
"color": "Farbe"
},
"loaded": "{file} erfolgreich geladen",
"saved": "{file} erfolgreich gespeichert!",
@@ -103,19 +94,15 @@
},
"scenes": {
"title": "Szenen",
"empty": {
"title": "Keine Szenen definiert",
"hint": "Erstelle Szenen unter Konfiguration"
},
"empty": "Keine Szenen definiert",
"empty.hint": "Erstelle Szenen unter Konfiguration",
"manage": {
"title": "Szenen verwalten",
"desc": "Erstelle und bearbeite Szenen für schnellen Zugriff"
},
"config": {
"empty": {
"title": "Keine Szenen erstellt",
"hint": "Klicke auf \"Neue Szene\" um eine Szene zu erstellen"
}
"empty": "Keine Szenen erstellt",
"empty.hint": "Klicke auf \"Neue Szene\" um eine Szene zu erstellen"
},
"activated": "\"{name}\" aktiviert",
"created": "Szene erstellt",
@@ -131,10 +118,8 @@
"devices": {
"external": "Externe Geräte",
"control": {
"empty": {
"title": "Keine Geräte hinzugefügt",
"hint": "Füge Geräte unter Konfiguration hinzu"
}
"empty": "Keine Geräte hinzugefügt",
"empty.hint": "Füge Geräte unter Konfiguration hinzu"
},
"new": {
"title": "Neue Geräte",
@@ -142,10 +127,8 @@
},
"searching": "Suche nach Geräten...",
"unpaired": {
"empty": {
"title": "Keine neuen Geräte gefunden",
"hint": "Drücke \"Geräte suchen\" um nach Matter-Geräten zu suchen"
}
"empty": "Keine neuen Geräte gefunden",
"empty.hint": "Drücke \"Geräte suchen\" um nach Matter-Geräten zu suchen"
},
"paired": {
"title": "Zugeordnete Geräte",
@@ -169,15 +152,11 @@
"config": {
"title": "WLAN Konfiguration"
},
"ssid": {
"title": "WLAN Name (SSID)",
"placeholder": "Netzwerkname eingeben"
},
"password": {
"title": "WLAN Passwort",
"short": "Passwort",
"placeholder": "Passwort eingeben"
},
"ssid": "WLAN Name (SSID)",
"ssid.placeholder": "Netzwerkname eingeben",
"password": "WLAN Passwort",
"password.short": "Passwort",
"password.placeholder": "Passwort eingeben",
"available": "Verfügbare Netzwerke",
"scan": {
"hint": "Nach Netzwerken suchen...",
@@ -207,21 +186,6 @@
"secure": "Gesichert",
"open": "Offen"
},
"thread": {
"title": "Thread Netzwerk",
"desc": "Dieses Gerät ist der Thread-Master. Öffne das Netzwerk, um neue Geräte ohne Display anzulernen.",
"network": "Netzwerk:",
"channel": "Kanal:",
"panId": "PAN ID:",
"status": {
"active": "Aktiv",
"offline": "Offline"
},
"permitJoin": "Geräte anlernen",
"permitJoinActive": "Anlernen aktiv ({time}s)",
"joinedCount": "{count} Gerät(e) erfolgreich verbunden",
"noNetwork": "Kein aktives Thread-Netzwerk gefunden."
},
"modal": {
"color": {
"title": "Farbe wählen"
@@ -248,8 +212,8 @@
},
"btn": {
"scan": "🔍 Suchen",
"save": "Speichern",
"load": "Laden",
"save": "💾 Speichern",
"load": "🔄 Laden",
"cancel": "Abbrechen",
"apply": "Übernehmen",
"new": {
+19 -55
View File
@@ -22,9 +22,6 @@
"devices": "🔗 Devices",
"scenes": "🎬 Scenes"
}
},
"system": {
"title": "🔧 System"
}
},
"wled": {
@@ -34,13 +31,11 @@
},
"segments": {
"title": "Segments",
"empty": {
"title": "No segments configured",
"hint": "Click \"Add Segment\" to create a segment"
}
"empty": "No segments configured",
"empty.hint": "Click \"Add Segment\" to create a segment"
},
"segment": {
"add": "Add Segment",
"add": " Add Segment",
"name": "Segment {num}",
"leds": "Number of LEDs",
"start": "Start LED",
@@ -91,11 +86,7 @@
"loading": "Loading schema...",
"header": {
"time": "Time",
"color": "Color",
"red": "R",
"green": "G",
"blue": "B",
"brightness": "Brightness"
"color": "Color"
},
"loaded": "{file} loaded successfully",
"saved": "{file} saved successfully!",
@@ -103,19 +94,15 @@
},
"scenes": {
"title": "Scenes",
"empty": {
"title": "No scenes defined",
"hint": "Create scenes in settings"
},
"empty": "No scenes defined",
"empty.hint": "Create scenes in settings",
"manage": {
"title": "Manage Scenes",
"desc": "Create and edit scenes for quick access"
},
"config": {
"empty": {
"title": "No scenes created",
"hint": "Click \"New Scene\" to create a scene"
}
"empty": "No scenes created",
"empty.hint": "Click \"New Scene\" to create a scene"
},
"activated": "\"{name}\" activated",
"created": "Scene created",
@@ -131,10 +118,8 @@
"devices": {
"external": "External Devices",
"control": {
"empty": {
"title": "No devices added",
"hint": "Add devices in settings"
}
"empty": "No devices added",
"empty.hint": "Add devices in settings"
},
"new": {
"title": "New Devices",
@@ -142,10 +127,8 @@
},
"searching": "Searching for devices...",
"unpaired": {
"empty": {
"title": "No new devices found",
"hint": "Press \"Scan devices\" to search for Matter devices"
}
"empty": "No new devices found",
"empty.hint": "Press \"Scan devices\" to search for Matter devices"
},
"paired": {
"title": "Paired Devices",
@@ -169,15 +152,11 @@
"config": {
"title": "WiFi Configuration"
},
"ssid": {
"title": "WiFi Name (SSID)",
"placeholder": "Enter network name"
},
"password": {
"title": "WiFi Password",
"short": "Password",
"placeholder": "Enter password"
},
"ssid": "WiFi Name (SSID)",
"ssid.placeholder": "Enter network name",
"password": "WiFi Password",
"password.short": "Password",
"password.placeholder": "Enter password",
"available": "Available Networks",
"scan": {
"hint": "Search for networks...",
@@ -207,21 +186,6 @@
"secure": "Secured",
"open": "Open"
},
"thread": {
"title": "Thread Network",
"desc": "This device is the Thread Master. Open the network to pair new display-less devices.",
"network": "Network:",
"channel": "Channel:",
"panId": "PAN ID:",
"status": {
"active": "Active",
"offline": "Offline"
},
"permitJoin": "Pair Devices",
"permitJoinActive": "Pairing active ({time}s)",
"joinedCount": "{count} device(s) successfully paired",
"noNetwork": "No active Thread network found."
},
"modal": {
"color": {
"title": "Choose Color"
@@ -248,8 +212,8 @@
},
"btn": {
"scan": "🔍 Scan",
"save": "Save",
"load": "Load",
"save": "💾 Save",
"load": "🔄 Load",
"cancel": "Cancel",
"apply": "Apply",
"new": {
-131
View File
@@ -1,131 +0,0 @@
<script lang="ts">
import { t } from '../i18n/store';
import Card from '../components/common/card.svelte';
let ssid = $state('');
let password = $state('');
let showPassword = $state(false);
let statusMessage = $state('');
let statusType = $state<'success' | 'error' | 'info' | ''>('');
let countdownInterval: ReturnType<typeof setInterval> | null = null;
let canConnect = $derived(ssid.trim().length > 0 && password.length > 0);
function togglePassword() {
showPassword = !showPassword;
}
function showStatus(message: string, type: 'success' | 'error' | 'info') {
statusMessage = message;
statusType = type;
if (type !== 'info') {
setTimeout(() => {
statusMessage = '';
statusType = '';
}, 5000);
}
}
async function saveWifi() {
if (!ssid.trim()) {
showStatus($t('wifi.error.ssid'), 'error');
return;
}
showStatus($t('common.loading'), 'info');
try {
const response = await fetch('/api/wifi/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ssid: ssid.trim(), password })
});
if (response.ok) {
showStatus($t('wifi.saved'), 'success');
let countdown = 10;
if (countdownInterval) clearInterval(countdownInterval);
countdownInterval = setInterval(() => {
const text = $t('captive.connecting').replace('{seconds}', String(countdown));
showStatus(text, 'success');
countdown--;
if (countdown < 0) {
if (countdownInterval) clearInterval(countdownInterval);
showStatus($t('captive.done'), 'success');
}
}, 1000);
} else {
const errorData = await response.json().catch(() => ({}));
showStatus($t('error') + ': ' + (errorData.error || $t('wifi.error.save')), 'error');
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
showStatus($t('error') + ': ' + message, 'error');
}
}
</script>
<Card title="captive.subtitle">
<div class="mb-4">
<label for="ssid" class="block mb-2 text-sm font-medium text-text-muted">
{$t('wifi.ssid.title')}
</label>
<input
type="text"
id="ssid"
bind:value={ssid}
placeholder={$t('wifi.ssid.placeholder')}
class="w-full px-4 py-3 border-2 border-border rounded-lg bg-input text-text text-base focus:outline-none focus:border-success transition-colors"
/>
</div>
<div class="mb-4">
<label for="password" class="block mb-2 text-sm font-medium text-text-muted">
{$t('wifi.password.title')}
</label>
<div class="relative flex items-center">
<input
type={showPassword ? 'text' : 'password'}
id="password"
bind:value={password}
placeholder={$t('wifi.password.placeholder')}
class="w-full px-4 py-3 pr-12 border-2 border-border rounded-lg bg-input text-text text-base focus:outline-none focus:border-success transition-colors"
/>
<button
type="button"
onclick={togglePassword}
aria-label="Toggle password visibility"
class="absolute right-3 text-xl text-text-muted cursor-pointer bg-transparent border-none p-1 transition-colors hover:text-text"
>
{showPassword ? '🙈' : '👁️'}
</button>
</div>
</div>
<div class="mt-5">
<button
onclick={saveWifi}
disabled={!canConnect}
class="w-full py-3 px-5 rounded-lg text-base font-semibold cursor-pointer transition-all flex items-center justify-center gap-2 min-h-12 touch-manipulation bg-success text-white hover:opacity-90 active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-gray-400"
>
{$t('captive.connect')}
</button>
</div>
{#if statusMessage}
<div
class="mt-3 text-center rounded-lg px-4 py-3 text-sm border {statusType === 'success'
? 'bg-success/15 border-success text-success'
: statusType === 'error'
? 'bg-error/15 border-error text-error'
: 'bg-accent border-accent/50 text-text'}"
>
{statusMessage}
</div>
{/if}
<div class="bg-accent rounded-lg px-4 py-3 mt-5 text-sm text-text-muted">
<strong class="text-text">{$t('captive.note.title')}</strong>
{$t('captive.note.text')}
</div>
</Card>
+4 -15
View File
@@ -5,22 +5,18 @@
import { controlStore } from "../stores/controlStore";
import ControlTab from "../components/controlTab/controlTab.svelte";
import ConfigTab from "../components/configTab/configTab.svelte";
import SystemTab from "../components/systemTab/systemTab.svelte";
import TabButton from "../components/common/tabButton.svelte";
import TabBar from "../components/common/tabBar.svelte";
type Tab = "control" | "config" | "system";
type Tab = "control" | "config";
const tabToPath: Record<Tab, string> = {
control: "/control",
config: "/config",
system: "/system"
config: "/config"
};
function pathToTab(path: string): Tab {
if (path === "/config") return "config";
if (path === "/system") return "system";
return "control";
return path === "/config" ? "config" : "control";
}
let activeTab = $derived(pathToTab($location));
@@ -50,19 +46,12 @@
label={$t("tab.config.title")}
onClick={() => setTab("config")}
/>
<TabButton
active={activeTab === "system"}
label={$t("tab.system.title")}
onClick={() => setTab("system")}
/>
</TabBar>
<div class="tab-content">
{#if activeTab === "control"}
<ControlTab />
{:else if activeTab === "config"}
<ConfigTab />
{:else}
<SystemTab />
<ConfigTab />
{/if}
</div>
@@ -1,57 +0,0 @@
import { writable } from 'svelte/store';
import { baseUrl } from '../utils/apiClient';
import { createLogger } from '../utils/logger';
const log = createLogger('configSchemaStore');
export interface SchemaRow {
r: number;
g: number;
b: number;
w: number;
brightness: number;
saturation: number;
}
function parseCSV(csv: string): SchemaRow[] {
return csv
.trim()
.split('\n')
.filter((line) => line.trim().length > 0)
.map((line) => {
const [r, g, b, w, brightness, saturation] = line.split(',').map(Number);
return { r, g, b, w, brightness, saturation };
});
}
function toCSV(rows: SchemaRow[]): string {
return rows.map((row) => `${row.r},${row.g},${row.b},${row.w},${row.brightness},${row.saturation}`).join('\n');
}
function createSchemaStore() {
const { subscribe, set } = writable<SchemaRow[]>([]);
async function fetchSchema(filename: string): Promise<void> {
log.debug('Loading schema', { filename });
const res = await fetch(`${baseUrl}/api/schema/${filename}`);
if (!res.ok) throw new Error(`Failed to load schema: ${res.status}`);
const text = await res.text();
set(parseCSV(text));
log.debug('Schema loaded', { filename });
}
async function saveSchema(filename: string, rows: SchemaRow[]): Promise<void> {
log.debug('Saving schema', { filename });
const res = await fetch(`${baseUrl}/api/schema/${filename}`, {
method: 'POST',
headers: { 'Content-Type': 'text/csv' },
body: toCSV(rows)
});
if (!res.ok) throw new Error(`Failed to save schema: ${res.status}`);
log.debug('Schema saved', { filename });
}
return { subscribe, fetchSchema, saveSchema };
}
export const schemaStore = createSchemaStore();
-188
View File
@@ -1,188 +0,0 @@
import { writable } from 'svelte/store';
import { createLogger } from '../utils/logger';
import { requestJson, resolveHost } from '../utils/apiClient';
export interface ThreadNetworkInfo {
networkName: string;
panId: string;
channel: number;
status: 'offline' | 'active';
}
export interface ThreadState {
networkInfo?: ThreadNetworkInfo;
isPermitJoinActive: boolean;
permitJoinTimeLeft: number;
joinedDevicesCount: number;
}
const DEFAULT_THREAD_STATE: ThreadState = {
isPermitJoinActive: false,
permitJoinTimeLeft: 0,
joinedDevicesCount: 0
};
const STATUS_ENDPOINT = '/api/thread/status';
const PERMIT_JOIN_ENDPOINT = '/api/thread/permit-join';
const WS_ENDPOINT = '/ws';
const WS_RECONNECT_DELAY_MS = 3000;
const isBrowser = typeof window !== 'undefined';
const buildWebSocketUrl = (host: string) => {
if (!isBrowser) return '';
const wsProtocol = window.location.protocol === 'https:' && !import.meta.env.DEV ? 'wss:' : 'ws:';
return `${wsProtocol}//${host}${WS_ENDPOINT}`;
};
const parseJson = (raw: string): any => {
try {
return JSON.parse(raw);
} catch {
return null;
}
};
const createThreadStore = () => {
const log = createLogger('threadStore');
const store = writable<ThreadState>({ ...DEFAULT_THREAD_STATE });
const { subscribe: internalSubscribe, set, update } = store;
const host = resolveHost();
const wsUrl = buildWebSocketUrl(host);
let ws: WebSocket | null = null;
let wsReconnectTimer: ReturnType<typeof setTimeout> | null = null;
let shouldReconnect = true;
let subscriberCount = 0;
const applyState = (nextState: Partial<ThreadState>) => {
update(current => ({ ...current, ...nextState }));
};
const clearReconnectTimer = () => {
if (!wsReconnectTimer) return;
clearTimeout(wsReconnectTimer);
wsReconnectTimer = null;
};
const scheduleReconnect = () => {
if (!shouldReconnect || wsReconnectTimer) return;
log.info('Scheduling WebSocket reconnect', { delayMs: WS_RECONNECT_DELAY_MS });
wsReconnectTimer = setTimeout(() => {
wsReconnectTimer = null;
connectWebSocket();
}, WS_RECONNECT_DELAY_MS);
};
async function fetchState() {
try {
const data = await requestJson<Partial<ThreadState>>(STATUS_ENDPOINT);
applyState(data);
} catch (error) {
log.error('Failed to fetch initial thread state', error);
}
}
async function startPermitJoin(durationSeconds: number = 60) {
try {
// Optimistic UI update
applyState({
isPermitJoinActive: true,
permitJoinTimeLeft: durationSeconds,
joinedDevicesCount: 0
});
await requestJson(PERMIT_JOIN_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ duration: durationSeconds })
});
} catch (error) {
log.error('Failed to start permit join', error);
applyState({ isPermitJoinActive: false, permitJoinTimeLeft: 0 });
}
}
function connectWebSocket() {
if (!isBrowser || ws || !wsUrl) return;
log.info('Connecting WebSocket', { url: wsUrl });
const socket = new WebSocket(wsUrl);
ws = socket;
socket.onopen = () => {
clearReconnectTimer();
log.info('WebSocket connected');
};
socket.onmessage = (event) => {
const message = parseJson(event.data);
if (!message || !message.type) return;
if (message.type === 'thread_permit_join_status') {
log.debug('Received permit join status');
applyState({
isPermitJoinActive: message.active,
permitJoinTimeLeft: message.timeLeft || 0
});
} else if (message.type === 'thread_device_joined') {
log.debug('A new device joined the Thread network!');
update(state => ({
...state,
joinedDevicesCount: state.joinedDevicesCount + 1
}));
} else if (message.type === 'thread_network_status') {
log.debug('Received thread network status update');
applyState({ networkInfo: message.networkInfo });
}
};
socket.onclose = () => {
if (ws === socket) ws = null;
log.warn('WebSocket closed');
scheduleReconnect();
};
socket.onerror = () => {
log.error('WebSocket error');
socket.close();
};
}
function disconnectWebSocket() {
shouldReconnect = false;
clearReconnectTimer();
ws?.close();
ws = null;
}
const subscribe: typeof store.subscribe = (run, invalidate) => {
subscriberCount += 1;
if (subscriberCount === 1) {
log.debug('First subscriber attached - starting WebSocket');
shouldReconnect = true;
connectWebSocket();
fetchState();
}
const unsubscribe = internalSubscribe(run, invalidate);
return () => {
unsubscribe();
subscriberCount -= 1;
if (subscriberCount === 0) {
log.debug('Last subscriber removed - stopping WebSocket');
disconnectWebSocket();
}
};
};
return {
subscribe,
fetchState,
startPermitJoin
};
};
export const threadStore = createThreadStore();
+1 -4
View File
@@ -1,8 +1,5 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
export default {
preprocess: vitePreprocess(),
vitePlugin: {
inspector: true
}
preprocess: vitePreprocess()
};