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