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