docs(espnow): update docs and tests for p2p merge

Align API, architecture, and help with devices envelope transport,
bridge wifi/serial settings, and MAC-keyed device registry. Fix
endpoint tests for envelope identify payloads; remove obsolete p2p.py.
Bump led-tool for --serial-usb bridge provisioning.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-06 21:10:06 +12:00
parent d682753e42
commit cfdd6de291
11 changed files with 190 additions and 183 deletions

View File

@@ -1,11 +1,12 @@
# led-controller # led-controller
LED controller web app for managing profiles, **zones**, presets, and colour palettes, and sending commands to LED devices over **ESP-NOW** (binary wire format). LED controller web app for managing profiles, **zones**, presets, and colour palettes, and sending commands to LED devices over **ESP-NOW** (peer-to-peer on 2.4 GHz WiFi radio).
- **Bridge ESP32**: runs a WebSocket server; the Pi connects as client (`bridge_ws_url` in `settings.json`, e.g. `ws://192.168.4.1/ws`). - **Bridge ESP32**: routes Pi traffic to drivers. The Pi connects over **WebSocket** (`bridge_transport`: `wifi`, `bridge_ws_url` e.g. `ws://192.168.4.1/ws`) or **USB serial** (`bridge_transport`: `serial`, `bridge_serial_port`).
- **LED drivers**: announce on boot via ESP-NOW broadcast; the controller registers them and pushes group membership. - **LED drivers**: announce on boot via ESP-NOW broadcast; the controller registers them (MAC-keyed) and pushes group membership.
- Optional **Wi-Fi drivers** on the LAN still work over UDP discovery + outbound WebSocket.
- Architecture (diagrams): [docs/espnow-architecture.md](docs/espnow-architecture.md) - Architecture (diagrams): [docs/espnow-architecture.md](docs/espnow-architecture.md)
- Wire format (byte layouts): [docs/espnow-binary-protocol.md](docs/espnow-binary-protocol.md) (≤250 bytes per frame, no JSON on the wire) - Wire format (byte layouts): [docs/espnow-binary-protocol.md](docs/espnow-binary-protocol.md) (≤250 bytes per ESP-NOW frame; Pi ↔ bridge uses JSON devices envelope)
## Run ## Run

View File

@@ -3,11 +3,13 @@
This document covers: This document covers:
1. **HTTP and WebSocket** exposed by the Raspberry Pi app (`src/main.py`) — profiles, zones, presets, transport send, pattern OTA helpers, and related resources. 1. **HTTP and WebSocket** exposed by the Raspberry Pi app (`src/main.py`) — profiles, zones, presets, transport send, pattern OTA helpers, and related resources.
2. **LED driver JSON** — the compact **v1** message format. It is sent over the **ESP-NOW bridge** (WebSocket) to ESP32 peers and as **single JSON text messages** over the **outbound WebSocket** to **Wi-Fi** drivers (same logical fields). 2. **LED driver JSON** — the compact **v1** message format. ESP-NOW traffic is wrapped in a **devices envelope** (`dv` map keyed by MAC) on the Pi ↔ bridge link (WebSocket or USB serial); drivers receive compact per-device bodies (≤250 bytes). **Wi-Fi** drivers still accept **single JSON text messages** over an outbound WebSocket (same logical fields).
Default HTTP listen address: `0.0.0.0`. Port defaults to **80**; override with the **`PORT`** environment variable (see `pipenv run run`). Default HTTP listen address: `0.0.0.0`. Port defaults to **80**; override with the **`PORT`** environment variable (see `pipenv run run`).
**Serial:** UART path and baud come from settings (defaults include `serial_port` such as `/dev/ttyS0` and `serial_baudrate`). **Wi-Fi drivers:** **UDP** on port **8766** is the **discovery** channel: each drivers JSON hello (**`device_name`**, **MAC**, optional **`type`**) **creates or updates** that device in **`db/device.json`** (keyed by MAC); the Pi echoes the datagram. After a valid hello with **`v`:** **`"1"`**, the Pi also opens an **outbound WebSocket** to that IP (**`wifi_driver_ws_port`**, default **80**; **`wifi_driver_ws_path`**, default **`/ws`**) for v1 commands; presets are not pushed automatically on connect (use **Send Presets** / profile apply). The Pi may send periodic UDP **hello** nudges to known WiFi device IPs when the WebSocket is down (**`wifi_driver_hello_interval_s`** in settings). **ESP-NOW bridge:** Set **`bridge_transport`** to **`wifi`** (default) or **`serial`**. WiFi mode uses **`bridge_ws_url`** (e.g. `ws://192.168.4.1/ws`) after joining the bridge AP; serial mode uses **`bridge_serial_port`** and **`bridge_serial_baudrate`** (default **921600**). Saved bridge profiles and connect helpers live under **`/settings/wifi/*`** (see below). Architecture: [espnow-architecture.md](espnow-architecture.md).
**Wi-Fi drivers (optional):** **UDP** on port **8766** is the **discovery** channel: each drivers JSON hello (**`device_name`**, **MAC**, optional **`type`**) **creates or updates** that device in **`db/device.json`** (keyed by MAC); the Pi echoes the datagram. After a valid hello with **`v`:** **`"1"`**, the Pi also opens an **outbound WebSocket** to that IP (**`wifi_driver_ws_port`**, default **80**; **`wifi_driver_ws_path`**, default **`/ws`**) for v1 commands; presets are not pushed automatically on connect (use **Send Presets** / profile apply). The Pi may send periodic UDP **hello** nudges to known WiFi device IPs when the WebSocket is down (**`wifi_driver_hello_interval_s`** in settings).
All JSON APIs use `Content-Type: application/json` for bodies and responses unless noted. All JSON APIs use `Content-Type: application/json` for bodies and responses unless noted.
@@ -52,7 +54,7 @@ Profiles are selected with **`POST /profiles/<id>/apply`**, which sets `current_
Connect to **`ws://<host>:<port>/ws`**. Connect to **`ws://<host>:<port>/ws`**.
- Send **JSON**: the object is forwarded through the **ESP-NOW bridge** (devices envelope or legacy MAC-prefixed payload). Optional key **`to`**: 12-character hex MAC address; if present it is removed from the object and the payload is sent to that peer; otherwise the default destination from settings is used. - Send **JSON**: the object is forwarded through the **ESP-NOW bridge** as a **devices envelope** (or legacy MAC-prefixed / binary payload). Optional key **`to`**: 12-character hex MAC address; if present it is removed from the object and the payload is sent to that peer; otherwise the default destination from settings is used. Example envelope: [msg.json](msg.json).
- Send **non-JSON text**: forwarded as raw bytes with the default address. - Send **non-JSON text**: forwarded as raw bytes with the default address.
- On send failure, the server may reply with `{"error": "Send failed"}`. - On send failure, the server may reply with `{"error": "Send failed"}`.
@@ -62,7 +64,7 @@ Wi-Fi devices are not targeted by `/ws` directly; use **`POST /presets/send`**,
## HTTP API by resource ## HTTP API by resource
Below, `<id>` values are string identifiers used by the JSON stores (numeric strings in practice). Below, `<id>` values are string identifiers used by the JSON stores. **Device** ids are **12-character lowercase hex MACs** (no colons); other resources typically use numeric string ids.
### Settings — `/settings` ### Settings — `/settings`
@@ -74,28 +76,46 @@ Below, `<id>` values are string identifiers used by the JSON stores (numeric str
| POST | `/settings/wifi/ap` | Body: `ssid` (required), `password`, `channel` (111). Persists AP-related settings. | | POST | `/settings/wifi/ap` | Body: `ssid` (required), `password`, `channel` (111). Persists AP-related settings. |
| GET | `/settings/page` | Serves `templates/settings.html`. | | GET | `/settings/page` | Serves `templates/settings.html`. |
### Devices — `/devices` ### Bridge — `/settings/wifi`
Registry in `db/device.json`: storage key **`<id>`** (string, e.g. `"1"`) maps to an object that always includes: Pi-side bridge configuration (ESP-NOW path to drivers). Mounted from `controllers/wifi_bridge.py`.
| Field | Description |
|-------|-------------|
| **`id`** | Same as the storage key (stable handle for URLs). |
| **`name`** | Shown in the UI and used in `select` keys. |
| **`type`** | `led` (only value today; extensible). |
| **`transport`** | `espnow` or `wifi`. |
| **`address`** | For **`espnow`**: optional 12-character lowercase hex MAC. For **`wifi`**: optional IP or hostname string. |
| **`default_pattern`**, **`zones`** | Optional. Legacy **`tabs`** may still appear in old files and is migrated away on load. |
Existing records without `type` / `transport` / `id` are backfilled on load (`led`, `espnow`, and `id` = key).
| Method | Path | Description | | Method | Path | Description |
|--------|------|-------------| |--------|------|-------------|
| GET | `/devices` | Map of device id → device object. | | GET | `/settings/wifi/interfaces` | List WiFi interfaces via NetworkManager (`nmcli`). |
| GET | `/settings/wifi/scan?device=<ifname>` | Scan SSIDs on the given interface. |
| GET | `/settings/wifi/bridges` | Bridge state: `bridge_transport`, `bridge_ws_url`, `bridge_connected`, `wifi_interface`, saved **`bridges`** profiles, serial port/baud. |
| PUT | `/settings/wifi/bridges` | Merge bridge settings and/or replace the **`bridges`** profile list. |
| DELETE | `/settings/wifi/bridges/<id>` | Remove a saved bridge profile. |
| POST | `/settings/wifi/bridges/<id>/connect` | Connect using a saved profile (`transport`: `wifi` or `serial`). |
| POST | `/settings/wifi/connect` | Join a bridge AP and open its WebSocket. Body: `device`, `ssid`, optional `password`, `ap_ip` (default `192.168.4.1`), `ws_port`, `label`, `save_profile`. |
| POST | `/settings/wifi/serial/connect` | Open the bridge over USB serial. Body: `port`, optional `baudrate`, `label`, `save_profile`. |
### Devices — `/devices`
Registry in `db/device.json`: storage key **`<id>`** is the device **MAC** (12 lowercase hex characters, no colons). Each record includes:
| Field | Description |
|-------|-------------|
| **`id`** | Same as the storage key (12-char hex MAC). |
| **`name`** | Shown in the UI; matched when building zone **`select`** lists. |
| **`type`** | `led` (only value today; extensible). |
| **`transport`** | `espnow` (default) or `wifi`. |
| **`address`** | For **`espnow`**: same as **`id`** (MAC). For **`wifi`**: IP or hostname used for outbound WebSocket / OTA. |
| **`connected`** | Response-only on GET list/detail: always **`null`** today (ESP-NOW has no live session flag on the Pi). |
| **`default_pattern`**, **`zones`** | Optional. Legacy **`tabs`** may still appear in old files and is migrated away on load. |
Drivers also **self-register** on ESP-NOW **ANNOUNCE** (bridge uplink) or WiFi UDP hello; manual **`POST /devices`** is optional.
| Method | Path | Description |
|--------|------|-------------|
| GET | `/devices` | Map of device id → device object (includes **`connected`**). |
| GET | `/devices/<id>` | One device, 404 if missing. | | GET | `/devices/<id>` | One device, 404 if missing. |
| POST | `/devices` | Create. Body: **`name`** (required), **`type`** (default `led`), **`transport`** (default `espnow`), optional **`address`**, **`default_pattern`**, **`zones`**. Returns `{ "<id>": { ... } }`, 201. | | POST | `/devices` | Create. Body: **`name`** (required), **`type`** (default `led`), **`transport`** (default `espnow`), optional **`address`**, **`mac`** (required for WiFi when address is set), **`default_pattern`**, **`zones`**. Returns `{ "<id>": { ... } }`, 201. |
| PUT | `/devices/<id>` | Partial update. **`name`** cannot be cleared. **`id`** in the body is ignored. **`type`** / **`transport`** validated; **`address`** normalised for the resulting transport. | | PUT | `/devices/<id>` | Partial update. **`name`** cannot be cleared. **`id`** in the body is ignored. **`type`** / **`transport`** validated; **`address`** normalised for the resulting transport. |
| DELETE | `/devices/<id>` | Remove device. | | DELETE | `/devices/<id>` | Remove device. |
| POST | `/devices/<id>/identify` | ESP-NOW: sends a short red **blink** preset (`__identify`, 10 Hz) via the bridge, then **`off`** after ~2 s. Not persisted on the Pi. |
| POST | `/groups/<id>/identify` | Same identify blink for every device in the group (broadcast envelope; drivers filter by group membership). |
### Profiles — `/profiles` ### Profiles — `/profiles`
@@ -228,26 +248,56 @@ Pattern metadata lives in **`db/pattern.json`**; driver source files live under
## LED driver message format (transport / ESP-NOW / Wi-Fi) ## LED driver message format (transport / ESP-NOW / Wi-Fi)
Messages are JSON objects. The Pi **`build_message()`** helper (`src/util/espnow_message.py`) produces the same shape sent over serial and forwarded by the ESP32 bridge, and the same logical object can be sent as a **single JSON text message** to a Wi-Fi driver over the **WebSocket**. Messages are JSON objects. The Pi **`build_message()`** helper (`src/util/espnow_message.py`) produces per-device bodies; **`build_devices_envelope()`** (`src/util/bridge_envelope.py`) wraps them for the bridge WebSocket or USB serial link. Wi-Fi drivers accept the same logical body as a **single JSON text message** over the outbound WebSocket.
### Top-level fields ### Devices envelope (Pi → bridge)
On the bridge link, traffic uses a top-level **`dv`** map (long name **`devices`** still accepted on receive):
```json ```json
{ {
"v": "1", "v": "1",
"presets": { }, "dv": {
"select": { }, "e8:f6:0a:16:ea:10": {
"save": true, "p": { "2": { "p": "on", "c": ["#FFFFFF"], "a": true } },
"default": "preset_id", "s": ["2", 0],
"b": 255 "g": ["5"],
"sg": false,
"sv": true
}
}
} }
``` ```
See [espnow-architecture.md](espnow-architecture.md) for routing (`sg`, broadcast MAC `ff:ff:ff:ff:ff:ff`, and group filtering).
### Per-device body fields (inside `dv` or Wi-Fi WebSocket)
Short wire keys are used on the bridge and over ESP-NOW (long names still accepted on receive):
```json
{
"v": "1",
"p": { },
"s": ["preset_id", 0],
"sv": true,
"df": "preset_id",
"b": 255,
"g": ["5"],
"sg": false
}
```
| Short | Long | Meaning |
|-------|------|---------|
| `p` | `presets` | Map of preset id → preset object (see below). |
| `s` | `select` | **`["preset_id"]`** or **`["preset_id", step]`** — routing is by MAC envelope / group membership, not by device name. |
| `sv` | `save` | If true, driver may persist presets to flash. |
| `df` | `default` | Startup default preset id. |
| `g` | `groups` | Group ids for membership updates or broadcast filtering. |
| `sg` | `set_groups` | If true, replace stored group list before applying the body. |
- **`v`** (required): Must be `"1"` or the driver ignores the message. - **`v`** (required): Must be `"1"` or the driver ignores the message.
- **`presets`**: Map of **preset id** (string) → preset object (see below). Optional **`name`** field on each value is accepted for display; the driver keys presets by map key.
- **`select`**: Map of **device name** (as in device settings) → `[ "preset_id" ]` or `[ "preset_id", step ]`.
- **`save`**: If present (e.g. true), the driver may persist presets to flash after applying.
- **`default`**: Preset id string to use as startup default on the device.
- **`b`**: Optional **global** brightness 0255 (driver applies this in addition to per-preset brightness). - **`b`**: Optional **global** brightness 0255 (driver applies this in addition to per-preset brightness).
### Preset object (wire / driver keys) ### Preset object (wire / driver keys)

View File

@@ -28,7 +28,7 @@
The LED Driver system is a MicroPython-based application for controlling LED strips via ESP32-C3 microcontrollers. The system uses a custom firmware image with usqlite and microdot built-in as frozen modules. The system provides: The LED Driver system is a MicroPython-based application for controlling LED strips via ESP32-C3 microcontrollers. The system uses a custom firmware image with usqlite and microdot built-in as frozen modules. The system provides:
- Real-time LED pattern control - Real-time LED pattern control
- Multi-device management via peer-to-peer communication (ESPNow) - Multi-device management via ESP-NOW (peer-to-peer on 2.4 GHz; Pi reaches drivers through a bridge ESP32)
- Group-based device control - Group-based device control
- Web-based configuration interface - Web-based configuration interface
- Binary message protocol for efficient communication - Binary message protocol for efficient communication
@@ -49,7 +49,7 @@ The LED Driver system is a MicroPython-based application for controlling LED str
- Preset system for saving and loading pattern configurations - Preset system for saving and loading pattern configurations
- Profile and Scene system for complex lighting setups - Profile and Scene system for complex lighting setups
- Preset sequencing within groups for time-based transitions - Preset sequencing within groups for time-based transitions
- Peer-to-peer communication via ESPNow - ESP-NOW peer-to-peer between bridge and led-driver devices; Pi ↔ bridge over WebSocket or USB serial
- Binary message protocol for bandwidth efficiency - Binary message protocol for bandwidth efficiency
- Persistent settings storage (usqlite database) - Persistent settings storage (usqlite database)
- Web-based configuration interface (Microdot web server) - Web-based configuration interface (Microdot web server)

View File

@@ -2,7 +2,7 @@
This document describes how **led-controller**, the **bridge ESP32**, and **led-driver** devices work together. Wire-level byte layouts are in [espnow-binary-protocol.md](espnow-binary-protocol.md). This document describes how **led-controller**, the **bridge ESP32**, and **led-driver** devices work together. Wire-level byte layouts are in [espnow-binary-protocol.md](espnow-binary-protocol.md).
**Pi ↔ bridge WebSocket:** v1 **devices envelope** (JSON) — see [espnow-sender/msg.json](../espnow-sender/msg.json). **ESP-NOW over the air:** JSON driver payloads (≤250 bytes) or legacy binary (`0x4C` wire). The Pi web UI and `db/*.json` use JSON internally. **Pi ↔ bridge:** v1 **devices envelope** (JSON) over **WebSocket** (`bridge_transport`: `wifi`) or **USB serial** (`bridge_transport`: `serial`) — example: [msg.json](msg.json). **ESP-NOW over the air:** JSON driver payloads (≤250 bytes) or legacy binary (`0x4C` wire). The Pi web UI and `db/*.json` use JSON internally.
## System overview ## System overview
@@ -11,19 +11,33 @@ This document describes how **led-controller**, the **bridge ESP32**, and **led-
| Component | Firmware / path | Role | | Component | Firmware / path | Role |
|-----------|-----------------|------| |-----------|-----------------|------|
| **led-controller** | Raspberry Pi, `src/` | Web app; WebSocket **client** to bridge (auto-reconnect); device registry; builds devices envelope | | **led-controller** | Raspberry Pi, `src/` | Web app; WebSocket **client** to bridge (auto-reconnect); device registry; builds devices envelope |
| **Bridge** | [`espnow-sender/`](../espnow-sender/) | WebSocket **server** `/ws`; routes envelope per MAC; max **20** peers (LRU) | | **Bridge** | [`espnow-sender/`](../espnow-sender/) (or [`bridge-serial/`](../bridge-serial/) for UART-only) | WebSocket **server** `/ws` and/or USB serial; routes envelope per MAC; max **20** ESP-NOW peers (LRU) |
| **led-driver** | [`led-driver/`](../led-driver/) submodule | Boot **ANNOUNCE** broadcast; applies **GROUPS**, **CMD**, **GROUP_CMD** | | **led-driver** | [`led-driver/`](../led-driver/) submodule | Boot **ANNOUNCE** broadcast; applies **GROUPS**, **CMD**, **GROUP_CMD** |
Configure the Pi in `settings.json`: Configure the Pi in `settings.json`:
```json ```json
{ {
"bridge_transport": "wifi",
"bridge_ws_url": "ws://192.168.4.1/ws", "bridge_ws_url": "ws://192.168.4.1/ws",
"wifi_channel": 5 "wifi_channel": 5
} }
``` ```
Connect the Pi to the **bridge access point** (SSID = bridge `name` in `/settings.json`, default IP **192.168.4.1**). All nodes must use the same 2.4 GHz **channel**: set `wifi_channel` in the bridge and each led-driver `/settings.json` (applied at boot on those devices). The Pi stores `wifi_channel` in its own `settings.json` for alignment (restart led-controller after changing). For **USB serial** to the bridge ESP32 instead of WiFi:
```json
{
"bridge_transport": "serial",
"bridge_serial_port": "/dev/ttyACM0",
"bridge_serial_baudrate": 921600,
"wifi_channel": 5
}
```
**WiFi mode:** connect the Pi to the **bridge access point** (SSID = bridge `name` in `/settings.json`, default IP **192.168.4.1**). Use **Help → Bridge** or **`POST /settings/wifi/connect`** to join and set `bridge_ws_url`. **Serial mode:** plug in the bridge and set `bridge_serial_port` (or use **`POST /settings/wifi/serial/connect`**).
All nodes must use the same 2.4 GHz **channel**: set `wifi_channel` in the bridge and each led-driver `/settings.json` (applied at boot on those devices). The Pi stores `wifi_channel` in its own `settings.json` for alignment (restart led-controller after changing).
--- ---
@@ -180,5 +194,5 @@ Driver applies only if `group_id` is in its stored list.
| Byte-level spec | [espnow-binary-protocol.md](espnow-binary-protocol.md) | | Byte-level spec | [espnow-binary-protocol.md](espnow-binary-protocol.md) |
| Pi wire codec | [`src/util/espnow_wire.py`](../src/util/espnow_wire.py) | | Pi wire codec | [`src/util/espnow_wire.py`](../src/util/espnow_wire.py) |
| Pi bridge client | [`src/models/bridge_ws_client.py`](../src/models/bridge_ws_client.py) | | Pi bridge client | [`src/models/bridge_ws_client.py`](../src/models/bridge_ws_client.py) |
| Bridge firmware | [`espnow-sender/main.py`](../espnow-sender/main.py) | | Bridge firmware | [`espnow-sender/src/main.py`](../espnow-sender/src/main.py) |
| Driver ESP-NOW | [`led-driver/src/espnow_transport.py`](../led-driver/src/espnow_transport.py) | | Driver ESP-NOW | [`led-driver/src/espnow_transport.py`](../led-driver/src/espnow_transport.py) |

View File

@@ -1,6 +1,6 @@
# LED controller — user guide # LED controller — user guide
This page describes the **main web UI** served from the Raspberry Pi app: profiles, **zones**, presets, colour palettes, and sending commands to LED devices. Traffic may go over the **ESP-NOW bridge** (WebSocket) or **Wi-Fi** (TCP to drivers on the LAN), depending on each devices transport. This page describes the **main web UI** served from the Raspberry Pi app: profiles, **zones**, presets, colour palettes, and sending commands to LED devices. ESP-NOW devices are reached through the **bridge** (Pi connects via **WiFi** to the bridge AP or **USB serial**). Optional **Wi-Fi** drivers on the LAN use a direct outbound WebSocket from the Pi.
For HTTP routes and the wire format the driver expects, see **[API.md](API.md)**. For running the app locally, see the project **README**. For HTTP routes and the wire format the driver expects, see **[API.md](API.md)**. For running the app locally, see the project **README**.
@@ -84,7 +84,9 @@ The **Presets** header button (Edit mode) opens a **profile-wide** list: **Add**
The **Patterns** dialog (Edit mode) lists pattern names and typical **delay** ranges from the pattern definitions. Choosing a pattern still happens inside the preset editor. The **Patterns** dialog (Edit mode) lists pattern names and typical **delay** ranges from the pattern definitions. Choosing a pattern still happens inside the preset editor.
**Wi-Fi drivers** can install new pattern modules over HTTP: the REST API exposes **`/patterns/ota/*`**, **`POST /patterns/<name>/send`**, **`POST /patterns/upload`**, and **`POST /patterns/driver`** (see [API.md](API.md)). ESP-NOW devices follow the bridge/serial path you configure for preset traffic. **Wi-Fi drivers** can install new pattern modules over HTTP: the REST API exposes **`/patterns/ota/*`**, **`POST /patterns/<name>/send`**, **`POST /patterns/upload`**, and **`POST /patterns/driver`** (see [API.md](API.md)). ESP-NOW devices follow the bridge path you configure (**Help → Bridge**: join the bridge AP or connect USB serial).
**Devices** (Edit mode): the registry lists drivers by **MAC**. New ESP-NOW devices appear automatically after **ANNOUNCE**; you can also add rows manually. **Identify** sends a short red blink (~2 s) so you can spot hardware on a wall or bench.
--- ---
@@ -110,5 +112,5 @@ On narrow screens, use **Menu** to reach the same actions as the desktop header
## Further reading ## Further reading
- **[API.md](API.md)** — REST routes, session scoping, WebSocket `/ws`, and LED driver JSON (`presets`, `select`, `save`, `default`, pattern keys, pattern **manifest**). - **[API.md](API.md)** — REST routes, bridge settings (`/settings/wifi/*`), session scoping, WebSocket `/ws`, and LED driver JSON (devices envelope `dv`, short keys `p`/`s`/`sv`, pattern **manifest**).
- **README** — `pipenv run run`, port 80 setup, and high-level behaviour. - **README** — `pipenv run run`, port 80 setup, and high-level behaviour.

View File

@@ -1,23 +1,18 @@
{ {
"g":{ "v": "1",
"df": { "dv": {
"pt": "on", "ff:ff:ff:ff:ff:ff": {
"cl": ["#ff0000"], "p": {
"br": 200, "2": {
"n1": 10, "p": "on",
"n2": 10, "c": ["#FFFFFF"],
"n3": 10, "a": true
"n4": 10,
"n5": 10,
"n6": 10,
"dl": 100
},
"dj": {
"pt": "blink",
"cl": ["#00ff00"],
"dl": 500
} }
}, },
"sv": true, "s": ["2", 0],
"st": 0 "g": ["5", "18"],
"sg": false,
"sv": true
}
}
} }

View File

@@ -7,13 +7,20 @@ Tests for the LED Controller project live under **`tests/`** (pytest + legacy sc
| Path | Role | | Path | Role |
|------|------| |------|------|
| `test_endpoints.py` | HTTP endpoint checks (**`LED_CONTROLLER_RUN_DEVICE_ENDPOINT_TESTS=1`**); **`test_zones`** / **`test_zone_edit_workflow`** hit **`/zones`** | | `test_endpoints.py` | HTTP endpoint checks (**`LED_CONTROLLER_RUN_DEVICE_ENDPOINT_TESTS=1`**); **`test_zones`** / **`test_zone_edit_workflow`** hit **`/zones`** |
| `test_endpoints_pytest.py` | Pytest-style endpoint coverage | | `test_endpoints_pytest.py` | Pytest-style endpoint coverage (devices envelope transport mock) |
| `test_bridge_ws_client.py` | Bridge WebSocket client reconnect / send behaviour |
| `test_bridge_envelope.py` | Devices envelope build/split/delivery |
| `test_bridge_serial_frame.py` | Pi↔bridge USB serial framing |
| `test_bridge_wifi_connect.py` | Saved bridge profile connect (serial path) |
| `test_espnow_wire.py`, `test_espnow_ping.py` | Binary wire codec and ping registration |
| `test_binary_envelope.py` | v2 binary envelope encode/decode |
| `test_browser.py` | Selenium UI flows (set **`LED_CONTROLLER_RUN_BROWSER_TESTS=1`** to run; uses **`test_zones_ui`** and legacy **`tabsManager`** JS aliases) | | `test_browser.py` | Selenium UI flows (set **`LED_CONTROLLER_RUN_BROWSER_TESTS=1`** to run; uses **`test_zones_ui`** and legacy **`tabsManager`** JS aliases) |
| `test_pattern_ota_send.py` | Pattern OTA / Wi-Fi send helpers | | `test_pattern_ota_send.py` | Pattern OTA / Wi-Fi send helpers |
| `test_pi_wifi_scan.py` | nmcli SSID scan helpers |
| `tcp_test_server.py`, `async_tcp_server.py` | TCP test doubles for driver protocol | | `tcp_test_server.py`, `async_tcp_server.py` | TCP test doubles for driver protocol |
| `udp_server.py` | UDP discovery / hello test listener (port **8766**) | | `udp_server.py` | UDP discovery / hello test listener (port **8766**) |
| `bridge_broadcast_test.py` | Manual bridge WebSocket broadcast script |
| `ws.py` | WebSocket client checks | | `ws.py` | WebSocket client checks |
| `p2p.py` | ESP-NOWrelated helpers / experiments |
| `web.py` | Local dev static server (not the main app) | | `web.py` | Local dev static server (not the main app) |
| `conftest.py` | Pytest fixtures | | `conftest.py` | Pytest fixtures |
| `models/` | Model unit tests (`run_all.py`, `test_zone.py`, …) | | `models/` | Model unit tests (`run_all.py`, `test_zone.py`, …) |

View File

@@ -1,105 +0,0 @@
#!/usr/bin/env python3
# MicroPython script to test LED bar patterns over ESP-NOW (no WebSocket)
import json
import uasyncio as asyncio
# Import P2P from src/p2p.py
# Note: When running on device, ensure src/p2p.py is in the path
try:
from p2p import P2P
except ImportError:
# Fallback: import from src directory
import sys
sys.path.insert(0, 'src')
from p2p import P2P
async def main():
p2p = P2P()
# Test cases following msg.json format:
# {"g": {"df": {...}, "group_name": {...}}, "sv": true, "st": 0}
# Note: led-bar device must have matching group in settings["groups"]
tests = [
# Example 1: Default format with df defaults and dj group (matches msg.json)
{
"g": {
"df": {
"pt": "on",
"cl": ["#ff0000"],
"br": 200,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10,
"n5": 10,
"n6": 10,
"dl": 100
},
"dj": {
"pt": "blink",
"cl": ["#00ff00"],
"dl": 500
}
},
"sv": True,
"st": 0
},
# Example 2: Different group with df defaults
{
"g": {
"df": {
"pt": "on",
"br": 150,
"dl": 100
},
"group1": {
"pt": "rainbow",
"dl": 50
}
},
"sv": False
},
# Example 3: Multiple groups
{
"g": {
"df": {
"br": 200,
"dl": 100
},
"group1": {
"pt": "on",
"cl": ["#0000ff"]
},
"group2": {
"pt": "blink",
"cl": ["#ff00ff"],
"dl": 300
}
},
"sv": True,
"st": 1
},
# Example 4: Single group without df
{
"g": {
"dj": {
"pt": "off"
}
},
"sv": False
}
]
for i, test in enumerate(tests, 1):
print(f"\n{'='*50}")
print(f"Test {i}/{len(tests)}")
print(f"Sending: {json.dumps(test, indent=2)}")
await p2p.send(json.dumps(test))
await asyncio.sleep_ms(2000)
print(f"\n{'='*50}")
print("All tests completed")
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -20,7 +20,7 @@ def test_send_returns_false_when_not_connected():
async def _run(): async def _run():
client = BridgeWsClient("ws://127.0.0.1/ws", reconnect_delay_s=0.01) client = BridgeWsClient("ws://127.0.0.1/ws", reconnect_delay_s=0.01)
async def _no_wait(_timeout=30.0): async def _no_wait(timeout=30.0):
return False return False
client.wait_connected = _no_wait # type: ignore[method-assign] client.wait_connected = _no_wait # type: ignore[method-assign]

View File

@@ -29,15 +29,56 @@ from microdot.websocket import with_websocket # noqa: E402
class DummyBridge: class DummyBridge:
def __init__(self): def __init__(self):
self.sent: list[tuple[str, Optional[str]]] = [] self.sent: list[tuple[Any, Optional[str]]] = []
async def send(self, data: Any, addr: Optional[str] = None): async def send(self, data: Any, addr: Optional[str] = None):
if isinstance(data, (bytes, bytearray)): if isinstance(data, dict):
from util.bridge_envelope import ( # noqa: E402
BROADCAST_MAC,
build_devices_envelope,
format_mac_key,
is_broadcast_mac,
normalize_mac_key,
)
from util.v1_wire import compact_envelope # noqa: E402
if data.get("v") == "1" and ("devices" in data or "dv" in data):
data = compact_envelope(data)
elif addr is not None:
s = str(addr).strip().lower()
if is_broadcast_mac(s):
mac_key = BROADCAST_MAC
else:
h = normalize_mac_key(s)
mac_key = format_mac_key(h) if h else None
if mac_key:
body = {k: v for k, v in data.items() if k != "v"}
data = build_devices_envelope({mac_key: body})
else:
data = json.dumps(data, separators=(",", ":"))
else:
data = json.dumps(data, separators=(",", ":"))
elif isinstance(data, (bytes, bytearray)):
data = bytes(data).decode(errors="ignore") data = bytes(data).decode(errors="ignore")
self.sent.append((data, addr)) self.sent.append((data, addr))
return True return True
def _bridge_sent_envelope(bridge: DummyBridge, index: int) -> Dict[str, Any]:
data, _addr = bridge.sent[index]
if isinstance(data, dict):
return data
return json.loads(data)
def _device_body_from_envelope(envelope: Dict[str, Any], mac: str) -> Dict[str, Any]:
from util.bridge_envelope import format_mac_key, normalize_mac_key # noqa: E402
devs = envelope.get("dv") or envelope.get("devices") or {}
key = format_mac_key(normalize_mac_key(mac))
return devs[key]
def _json(resp: requests.Response) -> Dict[str, Any]: def _json(resp: requests.Response) -> Dict[str, Any]:
# Many endpoints already set Content-Type; but be tolerant for now. # Many endpoints already set Content-Type; but be tolerant for now.
return resp.json() # pragma: no cover return resp.json() # pragma: no cover
@@ -672,17 +713,19 @@ def test_groups_sequences_scenes_palettes_patterns_endpoints(server):
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json().get("message") assert resp.json().get("message")
assert len(bridge.sent) >= 1 assert len(bridge.sent) >= 1
first = json.loads(bridge.sent[0][0]) first = _bridge_sent_envelope(bridge, 0)
assert "presets" in first and "select" in first assert first["v"] == "1"
assert first["presets"]["__identify"]["p"] == "blink" first_body = _device_body_from_envelope(first, dev_id)
assert first["presets"]["__identify"]["d"] == 50 assert first_body["p"]["__identify"]["p"] == "blink"
assert first["select"] == ["__identify"] assert first_body["p"]["__identify"]["d"] == 50
assert first_body["s"] == ["__identify"]
deadline = time.monotonic() + 2.0 deadline = time.monotonic() + 2.0
while len(bridge.sent) < 2 and time.monotonic() < deadline: while len(bridge.sent) < 2 and time.monotonic() < deadline:
time.sleep(0.02) time.sleep(0.02)
assert len(bridge.sent) >= 2 assert len(bridge.sent) >= 2
second = json.loads(bridge.sent[1][0]) second = _bridge_sent_envelope(bridge, 1)
assert second.get("select") == ["off"] second_body = _device_body_from_envelope(second, dev_id)
assert second_body["s"] == ["off"]
resp = c.post( resp = c.post(
f"{base_url}/devices", f"{base_url}/devices",
@@ -702,7 +745,7 @@ def test_groups_sequences_scenes_palettes_patterns_endpoints(server):
resp = c.get(f"{base_url}/devices/{wid}") resp = c.get(f"{base_url}/devices/{wid}")
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json().get("connected") is False assert resp.json().get("connected") is None
resp = c.post( resp = c.post(
f"{base_url}/devices", f"{base_url}/devices",