From fb00128847b4971c17d44901a3b8b6679ccecf6f Mon Sep 17 00:00:00 2001 From: Peter Siegmund Date: Sun, 29 Mar 2026 18:07:03 +0200 Subject: [PATCH] testing OpenThread Signed-off-by: Peter Siegmund --- firmware/CLAUDE.md | 45 +- firmware/README-API.md | 131 ++-- firmware/README-thread.md | 628 ++++++++++++++++++ firmware/README.md | 67 +- firmware/components/bifrost/CMakeLists.txt | 1 + .../bifrost/include/bifrost/api_handlers.h | 3 +- .../components/bifrost/src/api_handlers.c | 6 + .../bifrost/src/api_handlers_devices.c | 318 ++++++--- .../bifrost/src/api_handlers_wifi.c | 6 +- firmware/components/iris/CMakeLists.txt | 17 + firmware/components/iris/Kconfig | 92 +++ firmware/components/iris/include/iris/iris.h | 273 ++++++++ .../iris/include/iris/iris_internal.h | 115 ++++ firmware/components/iris/src/iris.c | 384 +++++++++++ firmware/components/iris/src/iris_coap.c | 167 +++++ firmware/components/iris/src/iris_discovery.c | 223 +++++++ firmware/components/iris/src/iris_inventory.c | 85 +++ firmware/components/iris/src/iris_master.c | 128 ++++ firmware/components/iris/src/iris_storage.c | 69 ++ .../mercedes/include/mercedes/mercedes.h | 47 ++ firmware/components/mercedes/src/mercedes.cpp | 60 +- firmware/main/CMakeLists.txt | 1 + firmware/main/src/app_task.cpp | 392 +++++++++++ firmware/sdkconfig.defaults.esp32c6 | 34 + firmware/storage/menu.json | 28 +- 25 files changed, 3147 insertions(+), 173 deletions(-) create mode 100644 firmware/README-thread.md create mode 100644 firmware/components/iris/CMakeLists.txt create mode 100644 firmware/components/iris/Kconfig create mode 100644 firmware/components/iris/include/iris/iris.h create mode 100644 firmware/components/iris/include/iris/iris_internal.h create mode 100644 firmware/components/iris/src/iris.c create mode 100644 firmware/components/iris/src/iris_coap.c create mode 100644 firmware/components/iris/src/iris_discovery.c create mode 100644 firmware/components/iris/src/iris_inventory.c create mode 100644 firmware/components/iris/src/iris_master.c create mode 100644 firmware/components/iris/src/iris_storage.c diff --git a/firmware/CLAUDE.md b/firmware/CLAUDE.md index 08bd1d1..4218fda 100644 --- a/firmware/CLAUDE.md +++ b/firmware/CLAUDE.md @@ -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 +``` + +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 flash + +# Flash only the app binary (preserves SPIFFS and NVS — use this during development) +idf.py -p app-flash + +# Flash only the SPIFFS partition (updates menu.json / web assets without touching NVS) +idf.py -p storage-flash + idf.py -p 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. diff --git a/firmware/README-API.md b/firmware/README-API.md index d523dc6..7456ae4 100644 --- a/firmware/README-API.md +++ b/firmware/README-API.md @@ -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-`) 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. diff --git a/firmware/README-thread.md b/firmware/README-thread.md new file mode 100644 index 0000000..e50626a --- /dev/null +++ b/firmware/README-thread.md @@ -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": } ──────────►│ + │ │ 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://[]/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://[]/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://[]/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 0–1s, 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 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 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 +``` diff --git a/firmware/README.md b/firmware/README.md index 17b3fc9..8ecd1c9 100755 --- a/firmware/README.md +++ b/firmware/README.md @@ -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 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 | diff --git a/firmware/components/bifrost/CMakeLists.txt b/firmware/components/bifrost/CMakeLists.txt index 09a2c8a..8200359 100644 --- a/firmware/components/bifrost/CMakeLists.txt +++ b/firmware/components/bifrost/CMakeLists.txt @@ -22,4 +22,5 @@ idf_component_register(SRCS message-manager my_mqtt_client heimdall + iris ) diff --git a/firmware/components/bifrost/include/bifrost/api_handlers.h b/firmware/components/bifrost/include/bifrost/api_handlers.h index 03635e1..c488f28 100644 --- a/firmware/components/bifrost/include/bifrost/api_handlers.h +++ b/firmware/components/bifrost/include/bifrost/api_handlers.h @@ -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); diff --git a/firmware/components/bifrost/src/api_handlers.c b/firmware/components/bifrost/src/api_handlers.c index b31550e..30599c3 100644 --- a/firmware/components/bifrost/src/api_handlers.c +++ b/firmware/components/bifrost/src/api_handlers.c @@ -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); diff --git a/firmware/components/bifrost/src/api_handlers_devices.c b/firmware/components/bifrost/src/api_handlers_devices.c index 0bd073a..ce59de5 100644 --- a/firmware/components/bifrost/src/api_handlers_devices.c +++ b/firmware/components/bifrost/src/api_handlers_devices.c @@ -1,207 +1,319 @@ #include "bifrost/api_handlers.h" #include "bifrost/api_handlers_util.h" -#include +#include #include #include +#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\"}"); } diff --git a/firmware/components/bifrost/src/api_handlers_wifi.c b/firmware/components/bifrost/src/api_handlers_wifi.c index db56f80..5a08694 100644 --- a/firmware/components/bifrost/src/api_handlers_wifi.c +++ b/firmware/components/bifrost/src/api_handlers_wifi.c @@ -6,6 +6,7 @@ #include #include #include +#include #include 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); diff --git a/firmware/components/iris/CMakeLists.txt b/firmware/components/iris/CMakeLists.txt new file mode 100644 index 0000000..d1f965d --- /dev/null +++ b/firmware/components/iris/CMakeLists.txt @@ -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 +) diff --git a/firmware/components/iris/Kconfig b/firmware/components/iris/Kconfig new file mode 100644 index 0000000..bcc2341 --- /dev/null +++ b/firmware/components/iris/Kconfig @@ -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 diff --git a/firmware/components/iris/include/iris/iris.h b/firmware/components/iris/include/iris/iris.h new file mode 100644 index 0000000..7934405 --- /dev/null +++ b/firmware/components/iris/include/iris/iris.h @@ -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 +#include +#include + +#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 diff --git a/firmware/components/iris/include/iris/iris_internal.h b/firmware/components/iris/include/iris/iris_internal.h new file mode 100644 index 0000000..632e5b6 --- /dev/null +++ b/firmware/components/iris/include/iris/iris_internal.h @@ -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 +#include +#include + +#include +#include +#include +#include + +#include + +#if defined(CONFIG_IRIS_ENABLED) +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* ========================================================================= + * 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 */ diff --git a/firmware/components/iris/src/iris.c b/firmware/components/iris/src/iris.c new file mode 100644 index 0000000..df4a122 --- /dev/null +++ b/firmware/components/iris/src/iris.c @@ -0,0 +1,384 @@ +#include "iris/iris_internal.h" +#include "esp_openthread_lock.h" +#include +#include + +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; +} diff --git a/firmware/components/iris/src/iris_coap.c b/firmware/components/iris/src/iris_coap.c new file mode 100644 index 0000000..6dade99 --- /dev/null +++ b/firmware/components/iris/src/iris_coap.c @@ -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 */ diff --git a/firmware/components/iris/src/iris_discovery.c b/firmware/components/iris/src/iris_discovery.c new file mode 100644 index 0000000..8cd5f42 --- /dev/null +++ b/firmware/components/iris/src/iris_discovery.c @@ -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 */ diff --git a/firmware/components/iris/src/iris_inventory.c b/firmware/components/iris/src/iris_inventory.c new file mode 100644 index 0000000..852a3d9 --- /dev/null +++ b/firmware/components/iris/src/iris_inventory.c @@ -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 */ diff --git a/firmware/components/iris/src/iris_master.c b/firmware/components/iris/src/iris_master.c new file mode 100644 index 0000000..4c5ce50 --- /dev/null +++ b/firmware/components/iris/src/iris_master.c @@ -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 0–1000 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 */ diff --git a/firmware/components/iris/src/iris_storage.c b/firmware/components/iris/src/iris_storage.c new file mode 100644 index 0000000..158ed16 --- /dev/null +++ b/firmware/components/iris/src/iris_storage.c @@ -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 */ diff --git a/firmware/components/mercedes/include/mercedes/mercedes.h b/firmware/components/mercedes/include/mercedes/mercedes.h index c5f7f4e..2b45150 100644 --- a/firmware/components/mercedes/include/mercedes/mercedes.h +++ b/firmware/components/mercedes/include/mercedes/mercedes.h @@ -29,6 +29,18 @@ using ItemValueProvider = std::function; +/** + * @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; + struct MenuSelectionItemDef { std::string value; @@ -56,6 +68,7 @@ struct MenuScreenDef std::string id; std::string title; std::vector 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 m_screens; std::string m_currentScreenId; diff --git a/firmware/components/mercedes/src/mercedes.cpp b/firmware/components/mercedes/src/mercedes.cpp index 942fcc7..c18afa2 100644 --- a/firmware/components/mercedes/src/mercedes.cpp +++ b/firmware/components/mercedes/src/mercedes.cpp @@ -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++) { diff --git a/firmware/main/CMakeLists.txt b/firmware/main/CMakeLists.txt index 4738dbc..bf2b46e 100755 --- a/firmware/main/CMakeLists.txt +++ b/firmware/main/CMakeLists.txt @@ -23,6 +23,7 @@ idf_component_register(SRCS app_update driver my_mqtt_client + iris ) spiffs_create_partition_image(storage ../storage FLASH_IN_PROJECT) diff --git a/firmware/main/src/app_task.cpp b/firmware/main/src/app_task.cpp index aefe2b7..4c90a6c 100644 --- a/firmware/main/src/app_task.cpp +++ b/firmware/main/src/app_task.cpp @@ -14,6 +14,10 @@ #include "u8g2_mqtt.h" #include "wifi_manager.h" +#if defined(CONFIG_IRIS_ENABLED) +#include "iris/iris.h" +#endif + #include #include #include @@ -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/ + 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/ + 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// + 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//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 { diff --git a/firmware/sdkconfig.defaults.esp32c6 b/firmware/sdkconfig.defaults.esp32c6 index 1b92cb3..cb35841 100644 --- a/firmware/sdkconfig.defaults.esp32c6 +++ b/firmware/sdkconfig.defaults.esp32c6 @@ -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 diff --git a/firmware/storage/menu.json b/firmware/storage/menu.json index 817d700..df82625 100644 --- a/firmware/storage/menu.json +++ b/firmware/storage/menu.json @@ -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..." + } + ] } ] -} \ No newline at end of file +}