Compare commits

..

1 Commits

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

Before

Width:  |  Height:  |  Size: 109 B

Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
-23
View File
@@ -1,23 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#1a1a2e">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<title>System Control</title>
<script type="module" crossorigin src="/index-YHhIjoLo.js"></script>
<link rel="modulepreload" crossorigin href="/vendor-CwcuF_np.js">
<link rel="stylesheet" crossorigin href="/vendor-CbWpK_cD.css">
<link rel="stylesheet" crossorigin href="/index-BfY4NlvY.css">
</head>
<body>
<div id="app"></div>
</body>
</html>
-1
View File
@@ -1 +0,0 @@
<HTML><HEAD><TITLE>Success</TITLE></HEAD><BODY>Success</BODY></HTML>
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -1,11 +0,0 @@
{
"enableAllProjectMcpServers": true,
"enabledMcpjsonServers": [
"svelte"
],
"permissions": {
"allow": [
"mcp__svelte__svelte-autofixer"
]
}
}
+186 -319
View File
@@ -10,12 +10,10 @@
"@picocss/pico": "^2.1.1",
"@tailwindcss/vite": "^4.1.18",
"gsap": "^3.13.0",
"svelte-french-toast": "^2.0.0-alpha.0",
"svelte-spa-router": "^4.0.2"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^6.1.1",
"@tailwindcss/typography": "^0.5.19",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.31",
"svelte": "^5.53.5",
@@ -881,6 +879,7 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz",
"integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"acorn": "^8.9.0"
@@ -926,18 +925,18 @@
}
},
"node_modules/@tailwindcss/node": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz",
"integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==",
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz",
"integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==",
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.5",
"enhanced-resolve": "^5.19.0",
"@jridgewell/remapping": "^2.3.4",
"enhanced-resolve": "^5.18.3",
"jiti": "^2.6.1",
"lightningcss": "1.32.0",
"lightningcss": "1.30.2",
"magic-string": "^0.30.21",
"source-map-js": "^1.2.1",
"tailwindcss": "4.2.2"
"tailwindcss": "4.1.18"
}
},
"node_modules/@tailwindcss/node/node_modules/jiti": {
@@ -950,38 +949,38 @@
}
},
"node_modules/@tailwindcss/node/node_modules/tailwindcss": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==",
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
"license": "MIT"
},
"node_modules/@tailwindcss/oxide": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz",
"integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==",
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz",
"integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==",
"license": "MIT",
"engines": {
"node": ">= 20"
"node": ">= 10"
},
"optionalDependencies": {
"@tailwindcss/oxide-android-arm64": "4.2.2",
"@tailwindcss/oxide-darwin-arm64": "4.2.2",
"@tailwindcss/oxide-darwin-x64": "4.2.2",
"@tailwindcss/oxide-freebsd-x64": "4.2.2",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2",
"@tailwindcss/oxide-linux-arm64-gnu": "4.2.2",
"@tailwindcss/oxide-linux-arm64-musl": "4.2.2",
"@tailwindcss/oxide-linux-x64-gnu": "4.2.2",
"@tailwindcss/oxide-linux-x64-musl": "4.2.2",
"@tailwindcss/oxide-wasm32-wasi": "4.2.2",
"@tailwindcss/oxide-win32-arm64-msvc": "4.2.2",
"@tailwindcss/oxide-win32-x64-msvc": "4.2.2"
"@tailwindcss/oxide-android-arm64": "4.1.18",
"@tailwindcss/oxide-darwin-arm64": "4.1.18",
"@tailwindcss/oxide-darwin-x64": "4.1.18",
"@tailwindcss/oxide-freebsd-x64": "4.1.18",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18",
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.18",
"@tailwindcss/oxide-linux-arm64-musl": "4.1.18",
"@tailwindcss/oxide-linux-x64-gnu": "4.1.18",
"@tailwindcss/oxide-linux-x64-musl": "4.1.18",
"@tailwindcss/oxide-wasm32-wasi": "4.1.18",
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.18",
"@tailwindcss/oxide-win32-x64-msvc": "4.1.18"
}
},
"node_modules/@tailwindcss/oxide-android-arm64": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz",
"integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==",
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz",
"integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==",
"cpu": [
"arm64"
],
@@ -991,13 +990,13 @@
"android"
],
"engines": {
"node": ">= 20"
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-darwin-arm64": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz",
"integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==",
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz",
"integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==",
"cpu": [
"arm64"
],
@@ -1007,13 +1006,13 @@
"darwin"
],
"engines": {
"node": ">= 20"
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-darwin-x64": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz",
"integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==",
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz",
"integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==",
"cpu": [
"x64"
],
@@ -1023,13 +1022,13 @@
"darwin"
],
"engines": {
"node": ">= 20"
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-freebsd-x64": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz",
"integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==",
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz",
"integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==",
"cpu": [
"x64"
],
@@ -1039,13 +1038,13 @@
"freebsd"
],
"engines": {
"node": ">= 20"
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz",
"integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==",
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz",
"integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==",
"cpu": [
"arm"
],
@@ -1055,89 +1054,77 @@
"linux"
],
"engines": {
"node": ">= 20"
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz",
"integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==",
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz",
"integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz",
"integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==",
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz",
"integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz",
"integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==",
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz",
"integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz",
"integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==",
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz",
"integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz",
"integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==",
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz",
"integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==",
"bundleDependencies": [
"@napi-rs/wasm-runtime",
"@emnapi/core",
@@ -1152,79 +1139,21 @@
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.8.1",
"@emnapi/runtime": "^1.8.1",
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1",
"@emnapi/wasi-threads": "^1.1.0",
"@napi-rs/wasm-runtime": "^1.1.1",
"@napi-rs/wasm-runtime": "^1.1.0",
"@tybys/wasm-util": "^0.10.1",
"tslib": "^2.8.1"
"tslib": "^2.4.0"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.8.1",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.1.0",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.8.1",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.1.0",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.1",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1",
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz",
"integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==",
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",
"integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==",
"cpu": [
"arm64"
],
@@ -1234,13 +1163,13 @@
"win32"
],
"engines": {
"node": ">= 20"
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz",
"integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==",
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz",
"integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==",
"cpu": [
"x64"
],
@@ -1250,54 +1179,27 @@
"win32"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/typography": {
"version": "0.5.19",
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
"integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"postcss-selector-parser": "6.0.10"
},
"peerDependencies": {
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
}
},
"node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": {
"version": "6.0.10",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
"dev": true,
"license": "MIT",
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
},
"engines": {
"node": ">=4"
"node": ">= 10"
}
},
"node_modules/@tailwindcss/vite": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz",
"integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==",
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz",
"integrity": "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==",
"license": "MIT",
"dependencies": {
"@tailwindcss/node": "4.2.2",
"@tailwindcss/oxide": "4.2.2",
"tailwindcss": "4.2.2"
"@tailwindcss/node": "4.1.18",
"@tailwindcss/oxide": "4.1.18",
"tailwindcss": "4.1.18"
},
"peerDependencies": {
"vite": "^5.2.0 || ^6 || ^7 || ^8"
"vite": "^5.2.0 || ^6 || ^7"
}
},
"node_modules/@tailwindcss/vite/node_modules/tailwindcss": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==",
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
"license": "MIT"
},
"node_modules/@types/chai": {
@@ -1328,6 +1230,7 @@
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"dev": true,
"license": "MIT"
},
"node_modules/@vitest/expect": {
@@ -1449,6 +1352,7 @@
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
@@ -1518,6 +1422,7 @@
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
"integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
@@ -1534,9 +1439,9 @@
}
},
"node_modules/autoprefixer": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz",
"integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==",
"version": "10.4.24",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz",
"integrity": "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==",
"dev": true,
"funding": [
{
@@ -1554,8 +1459,8 @@
],
"license": "MIT",
"dependencies": {
"browserslist": "^4.28.2",
"caniuse-lite": "^1.0.30001787",
"browserslist": "^4.28.1",
"caniuse-lite": "^1.0.30001766",
"fraction.js": "^5.3.4",
"picocolors": "^1.1.1",
"postcss-value-parser": "^4.2.0"
@@ -1574,22 +1479,20 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.20",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.20.tgz",
"integrity": "sha512-1AaXxEPfXT+GvTBJFuy4yXVHWJBXa4OdbIebGN/wX5DlsIkU0+wzGnd2lOzokSk51d5LUmqjgBLRLlypLUqInQ==",
"version": "2.9.19",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
"integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.cjs"
},
"engines": {
"node": ">=6.0.0"
"baseline-browser-mapping": "dist/cli.js"
}
},
"node_modules/binary-extensions": {
@@ -1619,9 +1522,9 @@
}
},
"node_modules/browserslist": {
"version": "4.28.2",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
"integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
"version": "4.28.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
"dev": true,
"funding": [
{
@@ -1639,11 +1542,11 @@
],
"license": "MIT",
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
"electron-to-chromium": "^1.5.328",
"node-releases": "^2.0.36",
"update-browserslist-db": "^1.2.3"
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
"electron-to-chromium": "^1.5.263",
"node-releases": "^2.0.27",
"update-browserslist-db": "^1.2.0"
},
"bin": {
"browserslist": "cli.js"
@@ -1673,9 +1576,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001788",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz",
"integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==",
"version": "1.0.30001770",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz",
"integrity": "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==",
"dev": true,
"funding": [
{
@@ -1779,6 +1682,7 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -1878,6 +1782,7 @@
"version": "5.6.4",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz",
"integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==",
"dev": true,
"license": "MIT"
},
"node_modules/didyoumean": {
@@ -1895,9 +1800,9 @@
"license": "MIT"
},
"node_modules/electron-to-chromium": {
"version": "1.5.340",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.340.tgz",
"integrity": "sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA==",
"version": "1.5.286",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz",
"integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==",
"dev": true,
"license": "ISC"
},
@@ -1976,23 +1881,17 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
"dev": true,
"license": "MIT"
},
"node_modules/esrap": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.5.tgz",
"integrity": "sha512-/yLB1538mag+dn0wsePTe8C0rDIjUOaJpMs2McodSzmM2msWcZsBSdRtg6HOBt0A/r82BN+Md3pgwSc/uWt2Ig==",
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.2.tgz",
"integrity": "sha512-zA6497ha+qKvoWIK+WM9NAh5ni17sKZKhbS5B3PoYbBvaYHZWoS33zmFybmyqpn07RLUxSmn+RCls2/XF+d0oQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15"
},
"peerDependencies": {
"@typescript-eslint/types": "^8.2.0"
},
"peerDependenciesMeta": {
"@typescript-eslint/types": {
"optional": true
}
}
},
"node_modules/estree-walker": {
@@ -2252,6 +2151,7 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.6"
@@ -2288,9 +2188,9 @@
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
"integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
"license": "MPL-2.0",
"dependencies": {
"detect-libc": "^2.0.3"
@@ -2303,23 +2203,23 @@
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-android-arm64": "1.32.0",
"lightningcss-darwin-arm64": "1.32.0",
"lightningcss-darwin-x64": "1.32.0",
"lightningcss-freebsd-x64": "1.32.0",
"lightningcss-linux-arm-gnueabihf": "1.32.0",
"lightningcss-linux-arm64-gnu": "1.32.0",
"lightningcss-linux-arm64-musl": "1.32.0",
"lightningcss-linux-x64-gnu": "1.32.0",
"lightningcss-linux-x64-musl": "1.32.0",
"lightningcss-win32-arm64-msvc": "1.32.0",
"lightningcss-win32-x64-msvc": "1.32.0"
"lightningcss-android-arm64": "1.30.2",
"lightningcss-darwin-arm64": "1.30.2",
"lightningcss-darwin-x64": "1.30.2",
"lightningcss-freebsd-x64": "1.30.2",
"lightningcss-linux-arm-gnueabihf": "1.30.2",
"lightningcss-linux-arm64-gnu": "1.30.2",
"lightningcss-linux-arm64-musl": "1.30.2",
"lightningcss-linux-x64-gnu": "1.30.2",
"lightningcss-linux-x64-musl": "1.30.2",
"lightningcss-win32-arm64-msvc": "1.30.2",
"lightningcss-win32-x64-msvc": "1.30.2"
}
},
"node_modules/lightningcss-android-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz",
"integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==",
"cpu": [
"arm64"
],
@@ -2337,9 +2237,9 @@
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz",
"integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==",
"cpu": [
"arm64"
],
@@ -2357,9 +2257,9 @@
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz",
"integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==",
"cpu": [
"x64"
],
@@ -2377,9 +2277,9 @@
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz",
"integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==",
"cpu": [
"x64"
],
@@ -2397,9 +2297,9 @@
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz",
"integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==",
"cpu": [
"arm"
],
@@ -2417,15 +2317,12 @@
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz",
"integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2440,15 +2337,12 @@
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz",
"integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2463,15 +2357,12 @@
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz",
"integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2486,15 +2377,12 @@
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz",
"integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2509,9 +2397,9 @@
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz",
"integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==",
"cpu": [
"arm64"
],
@@ -2529,9 +2417,9 @@
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz",
"integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==",
"cpu": [
"x64"
],
@@ -2572,6 +2460,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
"dev": true,
"license": "MIT"
},
"node_modules/loupe": {
@@ -2665,9 +2554,9 @@
}
},
"node_modules/node-releases": {
"version": "2.0.37",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz",
"integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==",
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
"dev": true,
"license": "MIT"
},
@@ -2775,9 +2664,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.10",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"funding": [
{
"type": "opencollective",
@@ -3195,9 +3084,10 @@
}
},
"node_modules/svelte": {
"version": "5.55.4",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.4.tgz",
"integrity": "sha512-q8DFohk6vUswSng95IZb9nzWJnbINZsK7OiM1snAa3qCjJBL0ZQpvMyAaVXjUukdM75J/m8UE8xwqat8Ors/zQ==",
"version": "5.53.12",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.53.12.tgz",
"integrity": "sha512-4x/uk4rQe/d7RhfvS8wemTfNjQ0bJbKvamIzRBfTe2eHHjzBZ7PZicUQrC2ryj83xxEacfA1zHKd1ephD1tAxA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
@@ -3211,7 +3101,7 @@
"clsx": "^2.1.1",
"devalue": "^5.6.4",
"esm-env": "^1.2.1",
"esrap": "^2.2.4",
"esrap": "^2.2.2",
"is-reference": "^3.0.3",
"locate-character": "^3.0.0",
"magic-string": "^0.30.11",
@@ -3221,18 +3111,6 @@
"node": ">=18"
}
},
"node_modules/svelte-french-toast": {
"version": "2.0.0-alpha.0",
"resolved": "https://registry.npmjs.org/svelte-french-toast/-/svelte-french-toast-2.0.0-alpha.0.tgz",
"integrity": "sha512-81wcVaY9UZ/0JuzLEizMSoIXqNbX7yhfTZavBuw94T3cnT2HmJ9O+qXY/c91h9FkeMwboo0KHZVmzOEQVTXDFg==",
"license": "MIT",
"dependencies": {
"svelte-writable-derived": "^3.1.1"
},
"peerDependencies": {
"svelte": "^5.0.0"
}
},
"node_modules/svelte-spa-router": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/svelte-spa-router/-/svelte-spa-router-4.0.2.tgz",
@@ -3245,18 +3123,6 @@
"url": "https://github.com/sponsors/ItalyPaleAle"
}
},
"node_modules/svelte-writable-derived": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/svelte-writable-derived/-/svelte-writable-derived-3.1.1.tgz",
"integrity": "sha512-w4LR6/bYZEuCs7SGr+M54oipk/UQKtiMadyOhW0PTwAtJ/Ai12QS77sLngEcfBx2q4H8ZBQucc9ktSA5sUGZWw==",
"license": "MIT",
"funding": {
"url": "https://ko-fi.com/pixievoltno1"
},
"peerDependencies": {
"svelte": "^3.2.1 || ^4.0.0-next.1 || ^5.0.0-next.94"
}
},
"node_modules/tailwindcss": {
"version": "3.4.19",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
@@ -3460,9 +3326,9 @@
"license": "MIT"
},
"node_modules/vite": {
"version": "7.3.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"license": "MIT",
"dependencies": {
"esbuild": "^0.27.0",
@@ -3685,6 +3551,7 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
"dev": true,
"license": "MIT"
}
}
-2
View File
@@ -9,7 +9,6 @@
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^6.1.1",
"@tailwindcss/typography": "^0.5.19",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.31",
"svelte": "^5.53.5",
@@ -23,7 +22,6 @@
"@picocss/pico": "^2.1.1",
"@tailwindcss/vite": "^4.1.18",
"gsap": "^3.13.0",
"svelte-french-toast": "^2.0.0-alpha.0",
"svelte-spa-router": "^4.0.2"
}
}
+4 -5
View File
@@ -3,13 +3,13 @@
import Footer from './components/footer.svelte';
import Index from './routes/index.svelte';
import Captive from './routes/captive.svelte';
import Router from 'svelte-spa-router';
import Router, { location } from 'svelte-spa-router';
import { lang } from './i18n/store';
import { Toaster } from 'svelte-french-toast';
const routes = {
'/': Index,
'/captive': Captive,
'/control': Index,
'/config': Index,
// Fallback route
'*': Index
};
@@ -19,8 +19,7 @@
});
</script>
<Toaster/>
<div class="container mx-auto">
<div class="container mx-auto lg:max-w-2xl">
<Header />
<main class="py-8">
@@ -7,4 +7,4 @@
<LedConfiguration />
<SchemaConfiguration />
</div>
</div>
@@ -1,118 +1,14 @@
<script lang="ts">
import { onMount } from 'svelte';
import Card from '../common/card.svelte';
import Button from '../common/button.svelte';
import { t } from '../../i18n/store';
import SegmentRow from './segmentRow.svelte';
import { segmentStore, type Segment } from '../../stores/configSegmentStore';
import toast from 'svelte-french-toast';
let segments = $state<Segment[]>([]);
let nextId = $state(0);
onMount(() => {
const unsub = segmentStore.subscribe((data) => {
segments = data.map((s) => ({ ...s }));
nextId = data.length;
});
segmentStore.fetchSegments();
return unsub;
});
function addSegment() {
const id = `new-${nextId++}`;
segments = [...segments, { id, name: $t('wled.segment.name').replace('{num}', String(nextId)), start: 0, leds: 1 }];
}
function findOverlap(): [string, string] | null {
const sorted = [...segments].sort((a, b) => a.start - b.start);
for (let i = 0; i < sorted.length - 1; i++) {
const a = sorted[i];
const b = sorted[i + 1];
if (a.start + a.leds > b.start) {
return [a.name || `#${i + 1}`, b.name || `#${i + 2}`];
}
}
return null;
}
async function saveSegments() {
const overlap = findOverlap();
if (overlap) {
toast.error(`"${overlap[0]}" & "${overlap[1]}" überschneiden sich`);
return;
}
try {
await segmentStore.updateSegments(segments);
toast.success($t('wled.saved'));
} catch {
toast.error($t('wled.error.save'));
}
}
</script>
<Card title="wled.config.title">
<p class="text-sm -mt-3 mb-4 text-text-muted">{$t('wled.config.desc')}</p>
<p class="text-sm -mt-3 mb-4 text-text-muted">{$t("wled.config.desc")}</p>
<div class="flex justify-center items-center mb-2">
<div class="flex-1 text-sm font-medium">{$t('wled.segments.title')}</div>
<Button label={$t('wled.segment.add')} ariaLabel={$t('wled.segment.add')} icon="" onClick={addSegment} />
</div>
<div class="overflow-y-auto max-h-64 scrollbar">
<table class="w-full table-fixed border-collapse text-left">
<colgroup>
<col />
<col class="w-24" />
<col class="w-24" />
<col class="w-12" />
</colgroup>
<thead class="sticky top-0 z-10 bg-card">
<tr class="border-b-2 border-border">
<th class="px-3 py-2 text-sm">{$t('wled.segment.name').replace('{num}', '')}</th>
<th class="px-3 py-2 text-sm text-center">{$t('wled.segment.start')}</th>
<th class="px-3 py-2 text-sm text-center">{$t('wled.segment.leds')}</th>
<th class="w-12"></th>
</tr>
</thead>
<tbody>
{#each segments as segment (segment.id)}
<SegmentRow
bind:name={segment.name}
bind:start={segment.start}
bind:leds={segment.leds}
onDelete={() => { segments = segments.filter((s) => s.id !== segment.id); }}
/>
{/each}
{#if segments.length === 0}
<tr>
<td colspan="3" class="px-3 py-4 text-sm text-center text-text-muted">
<div>{$t('wled.segments.empty.title')}</div>
<div class="text-xs mt-1">{$t('wled.segments.empty.hint')}</div>
</td>
</tr>
{/if}
</tbody>
</table>
</div>
<div class="flex justify-end mt-3">
<Button label={$t('btn.save')} ariaLabel={$t('btn.save')} icon="💾" onClick={saveSegments} />
<div class="flex justify-center items-center">
<div class="flex-1">Segmente</div>
<Button label={$t("wled.segment.add")} ariaLabel={$t("wled.segment.add")}></Button>
</div>
</Card>
<style>
.scrollbar {
scrollbar-color: var(--primary) var(--border);
scrollbar-width: thin;
}
.scrollbar::-webkit-scrollbar {
width: 6px;
}
.scrollbar::-webkit-scrollbar-track {
background: var(--border);
border-radius: 3px;
}
.scrollbar::-webkit-scrollbar-thumb {
background: var(--primary);
border-radius: 3px;
}
</style>
@@ -1,143 +1,7 @@
<script lang="ts">
import Card from '../common/card.svelte';
import { t } from '../../i18n/store';
import DropDown from '../common/dropDown.svelte';
import Button from '../common/button.svelte';
import toast from 'svelte-french-toast';
import SchemaRow from './schemaRow.svelte';
import { schemaStore, type SchemaRow as SchemaRowData } from '../../stores/configSchemaStore';
import { controlStore } from '../../stores/controlStore';
import { onMount } from 'svelte';
const schemas = [
{ value: 'schema_01.csv', label: $t('schema.name.1') },
{ value: 'schema_02.csv', label: $t('schema.name.2') },
{ value: 'schema_03.csv', label: $t('schema.name.3') }
];
let activeSchema = $state($controlStore.schema ?? 'schema_01.csv');
let activeLabel = $derived(schemas.find((s) => s.value === activeSchema)?.label ?? activeSchema);
let userSelected = $state(false);
let currentSchema = $derived($controlStore.schema);
$effect(() => {
if (currentSchema && !userSelected) {
activeSchema = currentSchema;
schemaStore.fetchSchema(currentSchema).catch(() => {});
}
});
let rows = $state<SchemaRowData[]>([]);
onMount(() => {
return schemaStore.subscribe((data) => {
rows = data.map((r) => ({ ...r }));
});
});
function rowToTime(index: number): string {
const total = index * 30;
const h = Math.floor(total / 60);
const m = total % 60;
return `${h < 10 ? '0' : ''}${h}:${m < 10 ? '0' : ''}${m}`;
}
async function loadClick() {
try {
await schemaStore.fetchSchema(activeSchema);
toast.success($t('schema.loaded').replace('{file}', activeLabel));
} catch {
toast.error($t('schema.demo'));
}
}
async function saveClick() {
try {
await schemaStore.saveSchema(activeSchema, rows);
toast.success($t('schema.saved').replace('{file}', activeLabel));
} catch {
toast.error($t('error'));
}
}
</script>
<Card title="schema.editor.title">
<div class="space-y-2">
<div class="flex items-center justify-center cursor-pointer space-x-2">
<DropDown
id="active-schema"
options={schemas}
bind:value={activeSchema}
onchange={() => (userSelected = true)}
/>
<Button
label={$t('btn.load')}
ariaLabel={$t('btn.load')}
onClick={loadClick}
icon="🔄" />
<Button
label={$t('btn.save')}
ariaLabel={$t('btn.save')}
onClick={saveClick}
icon="💾" />
</div>
<div class="overflow-auto max-h-80 scrollbar">
<table class="w-full table-fixed border-collapse text-left">
<colgroup>
<col class="w-14" />
<col class="w-14" />
<col />
<col />
<col />
<col />
</colgroup>
<thead class="sticky top-0 z-10 bg-card">
<tr class="border-b-2 border-border">
<th class="px-3 py-2 text-sm">{$t('schema.header.time')}</th>
<th class="px-3 py-2 text-sm text-center">{$t('schema.header.color')}</th>
<th class="px-3 py-2 text-sm text-center">{$t('schema.header.red')}</th>
<th class="px-3 py-2 text-sm text-center">{$t('schema.header.green')}</th>
<th class="px-3 py-2 text-sm text-center">{$t('schema.header.blue')}</th>
<th class="px-3 py-2 text-sm text-center">{$t('schema.header.brightness')}</th>
</tr>
</thead>
<tbody>
{#each rows as row, i}
<SchemaRow
time={rowToTime(i)}
bind:r={row.r}
bind:g={row.g}
bind:b={row.b}
bind:brightness={row.brightness}
/>
{/each}
{#if rows.length === 0}
<tr>
<td colspan="6" class="px-3 py-4 text-sm text-center text-text-muted">
{$t('schema.loading')}
</td>
</tr>
{/if}
</tbody>
</table>
</div>
</div>
</Card>
<style>
.scrollbar {
scrollbar-color: var(--primary) var(--border);
scrollbar-width: thin;
}
.scrollbar::-webkit-scrollbar {
width: 6px;
}
.scrollbar::-webkit-scrollbar-track {
background: var(--border);
border-radius: 3px;
}
.scrollbar::-webkit-scrollbar-thumb {
background: var(--primary);
border-radius: 3px;
}
</style>
</Card>
@@ -1,65 +0,0 @@
<script lang="ts">
interface Props {
time: string;
r: number;
g: number;
b: number;
brightness: number;
}
let { time, r = $bindable(), g = $bindable(), b = $bindable(), brightness = $bindable() }: Props = $props();
function clamp(v: number): number {
return Math.min(255, Math.max(0, Math.round(v)));
}
</script>
<tr class="group">
<td class="px-3 py-2 text-sm bg-bg-secondary group-hover:bg-accent border-y border-l border-border">{time}</td>
<td class="px-2 py-2 text-sm bg-bg-secondary group-hover:bg-accent border-y border-border">
<div class="h-6 rounded" style="background: rgb({r},{g},{b});"></div>
</td>
<td class="px-3 py-2 text-sm text-center bg-bg-secondary group-hover:bg-accent border-y border-border">
<input
type="number" min="0" max="255"
bind:value={r}
oninput={() => r = clamp(r)}
class="w-14 px-1 py-0.5 text-sm text-center rounded border border-border bg-input focus:outline-none focus:border-primary no-spinner"
/>
</td>
<td class="px-3 py-2 text-sm text-center bg-bg-secondary group-hover:bg-accent border-y border-border">
<input
type="number" min="0" max="255"
bind:value={g}
oninput={() => g = clamp(g)}
class="w-14 px-1 py-0.5 text-sm text-center rounded border border-border bg-input focus:outline-none focus:border-primary no-spinner"
/>
</td>
<td class="px-3 py-2 text-sm text-center bg-bg-secondary group-hover:bg-accent border-y border-border">
<input
type="number" min="0" max="255"
bind:value={b}
oninput={() => b = clamp(b)}
class="w-14 px-1 py-0.5 text-sm text-center rounded border border-border bg-input focus:outline-none focus:border-primary no-spinner"
/>
</td>
<td class="px-3 py-2 text-sm text-center bg-bg-secondary group-hover:bg-accent border-y border-r border-border">
<input
type="number" min="0" max="255"
bind:value={brightness}
oninput={() => brightness = clamp(brightness)}
class="w-14 px-1 py-0.5 text-sm text-center rounded border border-border bg-input focus:outline-none focus:border-primary no-spinner"
/>
</td>
</tr>
<style>
:global(input.no-spinner::-webkit-inner-spin-button),
:global(input.no-spinner::-webkit-outer-spin-button) {
-webkit-appearance: none;
margin: 0;
}
:global(input.no-spinner) {
-moz-appearance: textfield;
}
</style>
@@ -1,54 +0,0 @@
<script lang="ts">
interface Props {
name: string;
start: number;
leds: number;
onDelete: () => void;
}
let { name = $bindable(), start = $bindable(), leds = $bindable(), onDelete }: Props = $props();
</script>
<tr class="group">
<td class="px-3 py-2 text-sm bg-bg-secondary group-hover:bg-accent border-y border-l border-border">
<input
type="text"
bind:value={name}
class="w-full px-1 py-0.5 text-sm rounded border border-border bg-input focus:outline-none focus:border-primary"
/>
</td>
<td class="px-3 py-2 text-sm text-center bg-bg-secondary group-hover:bg-accent border-y border-border">
<input
type="number"
min="0"
bind:value={start}
class="w-16 px-1 py-0.5 text-sm text-right rounded border border-border bg-input focus:outline-none focus:border-primary no-spinner"
/>
</td>
<td class="px-3 py-2 text-sm text-center bg-bg-secondary group-hover:bg-accent border-y border-border">
<input
type="number"
min="1"
bind:value={leds}
class="w-16 px-1 py-0.5 text-sm text-right rounded border border-border bg-input focus:outline-none focus:border-primary no-spinner"
/>
</td>
<td class="px-1 py-2 text-sm text-center bg-bg-secondary group-hover:bg-accent border-y border-r border-border">
<button
onclick={onDelete}
aria-label="delete"
class="flex items-center justify-center cursor-pointer px-1 py-1 bg-card rounded-lg text-text-muted text-sm transition-all border-solid border-border border-x border-y hover:border-primary"
>🗑️</button>
</td>
</tr>
<style>
:global(input.no-spinner::-webkit-inner-spin-button),
:global(input.no-spinner::-webkit-outer-spin-button) {
-webkit-appearance: none;
margin: 0;
}
:global(input.no-spinner) {
-moz-appearance: textfield;
}
</style>
@@ -31,9 +31,9 @@
<footer class="px-0 py-4">
<hr class="border-0 mb-4 border-t-2 border-solid" />
<div class="flex justify-between items-center">
<div class="flex justify-center items-center relative">
<p class="m-0">© {romanYear} by mars3142</p>
<p class="m-0 text-sm text-gray-500">
<p class="absolute right-0 m-0 text-sm text-gray-500">
v{__APP_VERSION__} ({__COMMIT_HASH__})
</p>
</div>
+19 -37
View File
@@ -31,13 +31,11 @@
},
"segments": {
"title": "Segmente",
"empty": {
"title": "Keine Segmente konfiguriert",
"hint": "Klicke auf \"Segment hinzufügen\" um ein Segment zu erstellen"
}
"empty": "Keine Segmente konfiguriert",
"empty.hint": "Klicke auf \"Segment hinzufügen\" um ein Segment zu erstellen"
},
"segment": {
"add": "Segment hinzufügen",
"add": " Segment hinzufügen",
"name": "Segment {num}",
"leds": "Anzahl LEDs",
"start": "Start-LED",
@@ -88,11 +86,7 @@
"loading": "Schema wird geladen...",
"header": {
"time": "Zeit",
"color": "Farbe",
"red": "R",
"green": "G",
"blue": "B",
"brightness": "Helligkeit"
"color": "Farbe"
},
"loaded": "{file} erfolgreich geladen",
"saved": "{file} erfolgreich gespeichert!",
@@ -100,19 +94,15 @@
},
"scenes": {
"title": "Szenen",
"empty": {
"title": "Keine Szenen definiert",
"hint": "Erstelle Szenen unter Konfiguration"
},
"empty": "Keine Szenen definiert",
"empty.hint": "Erstelle Szenen unter Konfiguration",
"manage": {
"title": "Szenen verwalten",
"desc": "Erstelle und bearbeite Szenen für schnellen Zugriff"
},
"config": {
"empty": {
"title": "Keine Szenen erstellt",
"hint": "Klicke auf \"Neue Szene\" um eine Szene zu erstellen"
}
"empty": "Keine Szenen erstellt",
"empty.hint": "Klicke auf \"Neue Szene\" um eine Szene zu erstellen"
},
"activated": "\"{name}\" aktiviert",
"created": "Szene erstellt",
@@ -128,10 +118,8 @@
"devices": {
"external": "Externe Geräte",
"control": {
"empty": {
"title": "Keine Geräte hinzugefügt",
"hint": "Füge Geräte unter Konfiguration hinzu"
}
"empty": "Keine Geräte hinzugefügt",
"empty.hint": "Füge Geräte unter Konfiguration hinzu"
},
"new": {
"title": "Neue Geräte",
@@ -139,10 +127,8 @@
},
"searching": "Suche nach Geräten...",
"unpaired": {
"empty": {
"title": "Keine neuen Geräte gefunden",
"hint": "Drücke \"Geräte suchen\" um nach Matter-Geräten zu suchen"
}
"empty": "Keine neuen Geräte gefunden",
"empty.hint": "Drücke \"Geräte suchen\" um nach Matter-Geräten zu suchen"
},
"paired": {
"title": "Zugeordnete Geräte",
@@ -166,15 +152,11 @@
"config": {
"title": "WLAN Konfiguration"
},
"ssid": {
"title": "WLAN Name (SSID)",
"placeholder": "Netzwerkname eingeben"
},
"password": {
"title": "WLAN Passwort",
"short": "Passwort",
"placeholder": "Passwort eingeben"
},
"ssid": "WLAN Name (SSID)",
"ssid.placeholder": "Netzwerkname eingeben",
"password": "WLAN Passwort",
"password.short": "Passwort",
"password.placeholder": "Passwort eingeben",
"available": "Verfügbare Netzwerke",
"scan": {
"hint": "Nach Netzwerken suchen...",
@@ -230,8 +212,8 @@
},
"btn": {
"scan": "🔍 Suchen",
"save": "Speichern",
"load": "Laden",
"save": "💾 Speichern",
"load": "🔄 Laden",
"cancel": "Abbrechen",
"apply": "Übernehmen",
"new": {
+19 -37
View File
@@ -31,13 +31,11 @@
},
"segments": {
"title": "Segments",
"empty": {
"title": "No segments configured",
"hint": "Click \"Add Segment\" to create a segment"
}
"empty": "No segments configured",
"empty.hint": "Click \"Add Segment\" to create a segment"
},
"segment": {
"add": "Add Segment",
"add": " Add Segment",
"name": "Segment {num}",
"leds": "Number of LEDs",
"start": "Start LED",
@@ -88,11 +86,7 @@
"loading": "Loading schema...",
"header": {
"time": "Time",
"color": "Color",
"red": "R",
"green": "G",
"blue": "B",
"brightness": "Brightness"
"color": "Color"
},
"loaded": "{file} loaded successfully",
"saved": "{file} saved successfully!",
@@ -100,19 +94,15 @@
},
"scenes": {
"title": "Scenes",
"empty": {
"title": "No scenes defined",
"hint": "Create scenes in settings"
},
"empty": "No scenes defined",
"empty.hint": "Create scenes in settings",
"manage": {
"title": "Manage Scenes",
"desc": "Create and edit scenes for quick access"
},
"config": {
"empty": {
"title": "No scenes created",
"hint": "Click \"New Scene\" to create a scene"
}
"empty": "No scenes created",
"empty.hint": "Click \"New Scene\" to create a scene"
},
"activated": "\"{name}\" activated",
"created": "Scene created",
@@ -128,10 +118,8 @@
"devices": {
"external": "External Devices",
"control": {
"empty": {
"title": "No devices added",
"hint": "Add devices in settings"
}
"empty": "No devices added",
"empty.hint": "Add devices in settings"
},
"new": {
"title": "New Devices",
@@ -139,10 +127,8 @@
},
"searching": "Searching for devices...",
"unpaired": {
"empty": {
"title": "No new devices found",
"hint": "Press \"Scan devices\" to search for Matter devices"
}
"empty": "No new devices found",
"empty.hint": "Press \"Scan devices\" to search for Matter devices"
},
"paired": {
"title": "Paired Devices",
@@ -166,15 +152,11 @@
"config": {
"title": "WiFi Configuration"
},
"ssid": {
"title": "WiFi Name (SSID)",
"placeholder": "Enter network name"
},
"password": {
"title": "WiFi Password",
"short": "Password",
"placeholder": "Enter password"
},
"ssid": "WiFi Name (SSID)",
"ssid.placeholder": "Enter network name",
"password": "WiFi Password",
"password.short": "Password",
"password.placeholder": "Enter password",
"available": "Available Networks",
"scan": {
"hint": "Search for networks...",
@@ -230,8 +212,8 @@
},
"btn": {
"scan": "🔍 Scan",
"save": "Save",
"load": "Load",
"save": "💾 Save",
"load": "🔄 Load",
"cancel": "Cancel",
"apply": "Apply",
"new": {
-131
View File
@@ -1,131 +0,0 @@
<script lang="ts">
import { t } from '../i18n/store';
import Card from '../components/common/card.svelte';
let ssid = $state('');
let password = $state('');
let showPassword = $state(false);
let statusMessage = $state('');
let statusType = $state<'success' | 'error' | 'info' | ''>('');
let countdownInterval: ReturnType<typeof setInterval> | null = null;
let canConnect = $derived(ssid.trim().length > 0 && password.length > 0);
function togglePassword() {
showPassword = !showPassword;
}
function showStatus(message: string, type: 'success' | 'error' | 'info') {
statusMessage = message;
statusType = type;
if (type !== 'info') {
setTimeout(() => {
statusMessage = '';
statusType = '';
}, 5000);
}
}
async function saveWifi() {
if (!ssid.trim()) {
showStatus($t('wifi.error.ssid'), 'error');
return;
}
showStatus($t('common.loading'), 'info');
try {
const response = await fetch('/api/wifi/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ssid: ssid.trim(), password })
});
if (response.ok) {
showStatus($t('wifi.saved'), 'success');
let countdown = 10;
if (countdownInterval) clearInterval(countdownInterval);
countdownInterval = setInterval(() => {
const text = $t('captive.connecting').replace('{seconds}', String(countdown));
showStatus(text, 'success');
countdown--;
if (countdown < 0) {
if (countdownInterval) clearInterval(countdownInterval);
showStatus($t('captive.done'), 'success');
}
}, 1000);
} else {
const errorData = await response.json().catch(() => ({}));
showStatus($t('error') + ': ' + (errorData.error || $t('wifi.error.save')), 'error');
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
showStatus($t('error') + ': ' + message, 'error');
}
}
</script>
<Card title="captive.subtitle">
<div class="mb-4">
<label for="ssid" class="block mb-2 text-sm font-medium text-text-muted">
{$t('wifi.ssid.title')}
</label>
<input
type="text"
id="ssid"
bind:value={ssid}
placeholder={$t('wifi.ssid.placeholder')}
class="w-full px-4 py-3 border-2 border-border rounded-lg bg-input text-text text-base focus:outline-none focus:border-success transition-colors"
/>
</div>
<div class="mb-4">
<label for="password" class="block mb-2 text-sm font-medium text-text-muted">
{$t('wifi.password.title')}
</label>
<div class="relative flex items-center">
<input
type={showPassword ? 'text' : 'password'}
id="password"
bind:value={password}
placeholder={$t('wifi.password.placeholder')}
class="w-full px-4 py-3 pr-12 border-2 border-border rounded-lg bg-input text-text text-base focus:outline-none focus:border-success transition-colors"
/>
<button
type="button"
onclick={togglePassword}
aria-label="Toggle password visibility"
class="absolute right-3 text-xl text-text-muted cursor-pointer bg-transparent border-none p-1 transition-colors hover:text-text"
>
{showPassword ? '🙈' : '👁️'}
</button>
</div>
</div>
<div class="mt-5">
<button
onclick={saveWifi}
disabled={!canConnect}
class="w-full py-3 px-5 rounded-lg text-base font-semibold cursor-pointer transition-all flex items-center justify-center gap-2 min-h-12 touch-manipulation bg-success text-white hover:opacity-90 active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-gray-400"
>
{$t('captive.connect')}
</button>
</div>
{#if statusMessage}
<div
class="mt-3 text-center rounded-lg px-4 py-3 text-sm border {statusType === 'success'
? 'bg-success/15 border-success text-success'
: statusType === 'error'
? 'bg-error/15 border-error text-error'
: 'bg-accent border-accent/50 text-text'}"
>
{statusMessage}
</div>
{/if}
<div class="bg-accent rounded-lg px-4 py-3 mt-5 text-sm text-text-muted">
<strong class="text-text">{$t('captive.note.title')}</strong>
{$t('captive.note.text')}
</div>
</Card>
@@ -1,57 +0,0 @@
import { writable } from 'svelte/store';
import { baseUrl } from '../utils/apiClient';
import { createLogger } from '../utils/logger';
const log = createLogger('configSchemaStore');
export interface SchemaRow {
r: number;
g: number;
b: number;
w: number;
brightness: number;
saturation: number;
}
function parseCSV(csv: string): SchemaRow[] {
return csv
.trim()
.split('\n')
.filter((line) => line.trim().length > 0)
.map((line) => {
const [r, g, b, w, brightness, saturation] = line.split(',').map(Number);
return { r, g, b, w, brightness, saturation };
});
}
function toCSV(rows: SchemaRow[]): string {
return rows.map((row) => `${row.r},${row.g},${row.b},${row.w},${row.brightness},${row.saturation}`).join('\n');
}
function createSchemaStore() {
const { subscribe, set } = writable<SchemaRow[]>([]);
async function fetchSchema(filename: string): Promise<void> {
log.debug('Loading schema', { filename });
const res = await fetch(`${baseUrl}/api/schema/${filename}`);
if (!res.ok) throw new Error(`Failed to load schema: ${res.status}`);
const text = await res.text();
set(parseCSV(text));
log.debug('Schema loaded', { filename });
}
async function saveSchema(filename: string, rows: SchemaRow[]): Promise<void> {
log.debug('Saving schema', { filename });
const res = await fetch(`${baseUrl}/api/schema/${filename}`, {
method: 'POST',
headers: { 'Content-Type': 'text/csv' },
body: toCSV(rows)
});
if (!res.ok) throw new Error(`Failed to save schema: ${res.status}`);
log.debug('Schema saved', { filename });
}
return { subscribe, fetchSchema, saveSchema };
}
export const schemaStore = createSchemaStore();
+1 -4
View File
@@ -1,8 +1,5 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
export default {
preprocess: vitePreprocess(),
vitePlugin: {
inspector: true
}
preprocess: vitePreprocess()
};
-3
View File
@@ -1,3 +0,0 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
}