testing OpenThread

Signed-off-by: Peter Siegmund <developer@mars3142.org>
This commit is contained in:
2026-03-29 18:07:03 +02:00
parent 11814545d9
commit fb00128847
25 changed files with 3147 additions and 173 deletions
+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\"}");
}
@@ -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);
+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++)
{
+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
{
+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
+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..."
}
]
}
]
}
}