From cfdd6de2917b7168295601045e60d79fed007e47 Mon Sep 17 00:00:00 2001 From: Jimmy Date: Sat, 6 Jun 2026 21:10:06 +1200 Subject: [PATCH] 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 --- README.md | 9 +-- docs/API.md | 110 ++++++++++++++++++++++++--------- docs/SPECIFICATION.md | 4 +- docs/espnow-architecture.md | 22 +++++-- docs/help.md | 8 ++- docs/msg.json | 37 +++++------ led-tool | 2 +- tests/README.md | 11 +++- tests/p2p.py | 105 ------------------------------- tests/test_bridge_ws_client.py | 2 +- tests/test_endpoints_pytest.py | 63 ++++++++++++++++--- 11 files changed, 190 insertions(+), 183 deletions(-) delete mode 100644 tests/p2p.py diff --git a/README.md b/README.md index 9ef167f..3048d08 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,12 @@ # 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 Wi‑Fi 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`). -- **LED drivers**: announce on boot via ESP-NOW broadcast; the controller registers them and pushes group membership. +- **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 (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) -- 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 diff --git a/docs/API.md b/docs/API.md index e3a6ead..505e980 100644 --- a/docs/API.md +++ b/docs/API.md @@ -3,11 +3,13 @@ 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. -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`). -**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 driver’s 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 Wi‑Fi 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`**. Wi‑Fi 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 driver’s 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 Wi‑Fi 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. @@ -52,7 +54,7 @@ Profiles are selected with **`POST /profiles//apply`**, which sets `current_ Connect to **`ws://:/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. - 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 -Below, `` values are string identifiers used by the JSON stores (numeric strings in practice). +Below, `` 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` @@ -74,28 +76,46 @@ Below, `` values are string identifiers used by the JSON stores (numeric str | POST | `/settings/wifi/ap` | Body: `ssid` (required), `password`, `channel` (1–11). Persists AP-related settings. | | GET | `/settings/page` | Serves `templates/settings.html`. | -### Devices — `/devices` +### Bridge — `/settings/wifi` -Registry in `db/device.json`: storage key **``** (string, e.g. `"1"`) maps to an object that always includes: - -| 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). +Pi-side bridge configuration (ESP-NOW path to drivers). Mounted from `controllers/wifi_bridge.py`. | Method | Path | Description | |--------|------|-------------| -| GET | `/devices` | Map of device id → device object. | +| GET | `/settings/wifi/interfaces` | List Wi‑Fi interfaces via NetworkManager (`nmcli`). | +| GET | `/settings/wifi/scan?device=` | 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/` | Remove a saved bridge profile. | +| POST | `/settings/wifi/bridges//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 **``** 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 Wi‑Fi UDP hello; manual **`POST /devices`** is optional. + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/devices` | Map of device id → device object (includes **`connected`**). | | GET | `/devices/` | One device, 404 if missing. | -| POST | `/devices` | Create. Body: **`name`** (required), **`type`** (default `led`), **`transport`** (default `espnow`), optional **`address`**, **`default_pattern`**, **`zones`**. Returns `{ "": { ... } }`, 201. | +| POST | `/devices` | Create. Body: **`name`** (required), **`type`** (default `led`), **`transport`** (default `espnow`), optional **`address`**, **`mac`** (required for Wi‑Fi when address is set), **`default_pattern`**, **`zones`**. Returns `{ "": { ... } }`, 201. | | PUT | `/devices/` | Partial update. **`name`** cannot be cleared. **`id`** in the body is ignored. **`type`** / **`transport`** validated; **`address`** normalised for the resulting transport. | | DELETE | `/devices/` | Remove device. | +| POST | `/devices//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//identify` | Same identify blink for every device in the group (broadcast envelope; drivers filter by group membership). | ### 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) -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 { "v": "1", - "presets": { }, - "select": { }, - "save": true, - "default": "preset_id", - "b": 255 + "dv": { + "e8:f6:0a:16:ea:10": { + "p": { "2": { "p": "on", "c": ["#FFFFFF"], "a": true } }, + "s": ["2", 0], + "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. -- **`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 0–255 (driver applies this in addition to per-preset brightness). ### Preset object (wire / driver keys) diff --git a/docs/SPECIFICATION.md b/docs/SPECIFICATION.md index e522513..c657f57 100644 --- a/docs/SPECIFICATION.md +++ b/docs/SPECIFICATION.md @@ -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: - 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 - Web-based configuration interface - 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 - Profile and Scene system for complex lighting setups - 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 - Persistent settings storage (usqlite database) - Web-based configuration interface (Microdot web server) diff --git a/docs/espnow-architecture.md b/docs/espnow-architecture.md index 086dcb2..eb863f8 100644 --- a/docs/espnow-architecture.md +++ b/docs/espnow-architecture.md @@ -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). -**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 @@ -11,19 +11,33 @@ This document describes how **led-controller**, the **bridge ESP32**, and **led- | Component | Firmware / path | Role | |-----------|-----------------|------| | **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** | Configure the Pi in `settings.json`: ```json { + "bridge_transport": "wifi", "bridge_ws_url": "ws://192.168.4.1/ws", "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 Wi‑Fi: + +```json +{ + "bridge_transport": "serial", + "bridge_serial_port": "/dev/ttyACM0", + "bridge_serial_baudrate": 921600, + "wifi_channel": 5 +} +``` + +**Wi‑Fi 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) | | 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) | -| 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) | diff --git a/docs/help.md b/docs/help.md index 7aea1e1..c021e67 100644 --- a/docs/help.md +++ b/docs/help.md @@ -1,6 +1,6 @@ # 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 device’s 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 **Wi‑Fi** 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**. @@ -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. -**Wi-Fi drivers** can install new pattern modules over HTTP: the REST API exposes **`/patterns/ota/*`**, **`POST /patterns//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//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 -- **[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. diff --git a/docs/msg.json b/docs/msg.json index b31b800..83abe11 100644 --- a/docs/msg.json +++ b/docs/msg.json @@ -1,23 +1,18 @@ { - "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 + "v": "1", + "dv": { + "ff:ff:ff:ff:ff:ff": { + "p": { + "2": { + "p": "on", + "c": ["#FFFFFF"], + "a": true } - }, - "sv": true, - "st": 0 -} \ No newline at end of file + }, + "s": ["2", 0], + "g": ["5", "18"], + "sg": false, + "sv": true + } + } +} diff --git a/led-tool b/led-tool index 2961ad2..f5de993 160000 --- a/led-tool +++ b/led-tool @@ -1 +1 @@ -Subproject commit 2961ad2a290a76e2a38730a6e2716e3ddba3c7aa +Subproject commit f5de99386a383ac47a0b214b25d6797279e63041 diff --git a/tests/README.md b/tests/README.md index ec04e87..b13af3a 100644 --- a/tests/README.md +++ b/tests/README.md @@ -7,13 +7,20 @@ Tests for the LED Controller project live under **`tests/`** (pytest + legacy sc | 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_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_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 | | `udp_server.py` | UDP discovery / hello test listener (port **8766**) | +| `bridge_broadcast_test.py` | Manual bridge WebSocket broadcast script | | `ws.py` | WebSocket client checks | -| `p2p.py` | ESP-NOW–related helpers / experiments | | `web.py` | Local dev static server (not the main app) | | `conftest.py` | Pytest fixtures | | `models/` | Model unit tests (`run_all.py`, `test_zone.py`, …) | diff --git a/tests/p2p.py b/tests/p2p.py deleted file mode 100644 index 4b9b16e..0000000 --- a/tests/p2p.py +++ /dev/null @@ -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()) \ No newline at end of file diff --git a/tests/test_bridge_ws_client.py b/tests/test_bridge_ws_client.py index 927997a..e67f260 100644 --- a/tests/test_bridge_ws_client.py +++ b/tests/test_bridge_ws_client.py @@ -20,7 +20,7 @@ def test_send_returns_false_when_not_connected(): async def _run(): 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 client.wait_connected = _no_wait # type: ignore[method-assign] diff --git a/tests/test_endpoints_pytest.py b/tests/test_endpoints_pytest.py index 4eb7bbe..ea72fbf 100644 --- a/tests/test_endpoints_pytest.py +++ b/tests/test_endpoints_pytest.py @@ -29,15 +29,56 @@ from microdot.websocket import with_websocket # noqa: E402 class DummyBridge: 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): - 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") self.sent.append((data, addr)) 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]: # Many endpoints already set Content-Type; but be tolerant for now. 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.json().get("message") assert len(bridge.sent) >= 1 - first = json.loads(bridge.sent[0][0]) - assert "presets" in first and "select" in first - assert first["presets"]["__identify"]["p"] == "blink" - assert first["presets"]["__identify"]["d"] == 50 - assert first["select"] == ["__identify"] + first = _bridge_sent_envelope(bridge, 0) + assert first["v"] == "1" + first_body = _device_body_from_envelope(first, dev_id) + assert first_body["p"]["__identify"]["p"] == "blink" + assert first_body["p"]["__identify"]["d"] == 50 + assert first_body["s"] == ["__identify"] deadline = time.monotonic() + 2.0 while len(bridge.sent) < 2 and time.monotonic() < deadline: time.sleep(0.02) assert len(bridge.sent) >= 2 - second = json.loads(bridge.sent[1][0]) - assert second.get("select") == ["off"] + second = _bridge_sent_envelope(bridge, 1) + second_body = _device_body_from_envelope(second, dev_id) + assert second_body["s"] == ["off"] resp = c.post( 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}") assert resp.status_code == 200 - assert resp.json().get("connected") is False + assert resp.json().get("connected") is None resp = c.post( f"{base_url}/devices",