185 lines
6.7 KiB
Markdown
185 lines
6.7 KiB
Markdown
# ESP-NOW transport architecture
|
||
|
||
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.
|
||
|
||
## System overview
|
||
|
||

|
||
|
||
| 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) |
|
||
| **led-driver** | [`led-driver/`](../led-driver/) submodule | Boot **ANNOUNCE** broadcast; applies **GROUPS**, **CMD**, **GROUP_CMD** |
|
||
|
||
Configure the Pi in `settings.json`:
|
||
|
||
```json
|
||
{
|
||
"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).
|
||
|
||
---
|
||
|
||
## Boot and registration
|
||
|
||

|
||
|
||
1. Driver powers on and sends **ANNOUNCE** to broadcast MAC `ff:ff:ff:ff:ff:ff`.
|
||
2. Bridge receives it and forwards a **WebSocket uplink** frame to the Pi (peer MAC + packet).
|
||
3. Pi **upserts** the device in `db/device.json` (key = 12-char hex MAC).
|
||
4. Pi scans `db/group.json` and sends a **groups** envelope (`set_groups: true`) unicast to that MAC.
|
||
5. Driver stores group ids in RAM (`device_groups`) for filtering.
|
||
6. Pi bridge client **reconnects** automatically if the WebSocket drops (2 s backoff).
|
||
|
||
If the Pi or bridge is not up yet, the driver re-sends **ANNOUNCE** periodically until **GROUPS** arrives.
|
||
|
||
---
|
||
|
||
## Devices envelope (Pi → bridge)
|
||
|
||
```json
|
||
{
|
||
"v": "1",
|
||
"dv": {
|
||
"ff:ff:ff:ff:ff:ff": {
|
||
"p": { "2": { "p": "on", "c": ["#FFFFFF"], "a": true } },
|
||
"s": ["2", 0],
|
||
"g": ["5", "18"],
|
||
"sg": false,
|
||
"sv": true
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
Short wire names (long names still accepted on receive): `dv`=devices, `p`=presets, `s`=select (`["preset_id", step?]` — no device name), `g`=groups, `sg`=set_groups, `sv`=save, `df`=default; preset fields `p/c/d/b/a/bg/n1…`.
|
||
|
||
| `set_groups` | Destination | Bridge | Driver |
|
||
|--------------|-------------|--------|--------|
|
||
| `true` | any | Unicast only (expand `ff:ff:…` to all known peers) | `groups_replace`, then apply body |
|
||
| `false` | `ff:ff:ff:ff:ff:ff` | ESP-NOW air broadcast | Apply only if device is in `groups` |
|
||
| `false` | specific MAC | Unicast | Same group filter |
|
||
|
||
Legacy raw payloads (binary wire or plain v1 JSON without `devices`) are still **broadcast** by the bridge.
|
||
|
||
## Sending presets and commands
|
||
|
||
1. UI or API triggers a send (e.g. `POST /presets/push`).
|
||
2. Pi builds a **devices envelope** (or legacy binary) and sends it on the bridge WebSocket.
|
||
3. Bridge routes each MAC entry to unicast or ESP-NOW broadcast per `set_groups`.
|
||
4. Driver `process_data` applies presets, select (`[preset_id, step?]`; legacy name map still accepted), brightness, etc.
|
||
|
||
---
|
||
|
||
## Packet layers
|
||
|
||

|
||
|
||
### Layer A — WebSocket frame (Pi ↔ bridge)
|
||
|
||
| Offset | Size | Field |
|
||
|--------|------|--------|
|
||
| 0 | 1 | `flags` — bit0 = broadcast (`ff:ff:…`); peer ignored if set |
|
||
| 1 | 6 | `peer` — destination MAC (raw bytes) |
|
||
| 7 | … | Full ESP-NOW packet (layer B) |
|
||
|
||
**Uplink** (bridge → Pi): same layout; `flags = 0`, `peer` = sender.
|
||
|
||
**Ack** (bridge → Pi after downlink): 1 byte — `0x01` ok, `0x00` error.
|
||
|
||
### Layer B — ESP-NOW packet (on air)
|
||
|
||
| Offset | Size | Field |
|
||
|--------|------|--------|
|
||
| 0 | 1 | Magic `0x4C` (`'L'`) |
|
||
| 1 | 1 | Message type |
|
||
| 2 | … | Body (≤248 bytes so total ≤250) |
|
||
|
||

|
||
|
||
| Type | Value | Direction | Purpose |
|
||
|------|-------|-------------|---------|
|
||
| ANNOUNCE | `0x01` | Driver → broadcast | Boot settings |
|
||
| GROUPS | `0x02` | Pi → driver | Group membership |
|
||
| CMD | `0x03` | Pi → driver | Command (v2 envelope) |
|
||
| GROUP_CMD | `0x04` | Pi → broadcast | Command scoped to one group |
|
||
| BRIDGE_CH | `0x10` | Pi → bridge | Set STA channel 1–11 |
|
||
|
||
### Layer C — v2 command envelope (inside CMD / GROUP_CMD)
|
||
|
||
Used for presets, select, default, brightness. **No JSON.**
|
||
|
||
| Byte | Field |
|
||
|------|--------|
|
||
| 0 | Version `2` |
|
||
| 1 | Brightness wire 0–127 (→ 0–255); `128–255` = unchanged |
|
||
| 2 | `lp` — presets section length |
|
||
| 3 | `ls` — select section length |
|
||
| 4 | `ld` — default section length |
|
||
| 5… | Presets blob (`lp` bytes) |
|
||
| … | Select blob (`ls` bytes) |
|
||
| … | Default blob (`ld` bytes) |
|
||
|
||
Optional trailing `0x01` after the envelope in **CMD** means `save` (persist to flash).
|
||
|
||
Implementation: [`src/util/binary_envelope.py`](../src/util/binary_envelope.py), [`src/util/espnow_wire.py`](../src/util/espnow_wire.py).
|
||
|
||
---
|
||
|
||
## Message body reference
|
||
|
||
### ANNOUNCE (`0x01`)
|
||
|
||
Sender MAC comes from ESP-NOW headers, not the body.
|
||
|
||
```
|
||
name_len (u8) | name (utf-8) | num_leds (u16 LE) | color_order (u8) | startup_mode (u8) | brightness (u8) | device_type (u8)
|
||
```
|
||
|
||
| `color_order` | `startup_mode` |
|
||
|---------------|----------------|
|
||
| 0=rgb, 1=rbg, 2=grb, 3=gbr, 4=brg, 5=bgr | 0=default, 1=last, 2=off |
|
||
|
||
### GROUPS (`0x02`)
|
||
|
||
```
|
||
count (u8) | repeat: id_len (u8) | group_id (utf-8)
|
||
```
|
||
|
||
Group ids match keys in `db/group.json` (e.g. `"5"`, `"18"`).
|
||
|
||
### GROUP_CMD (`0x04`)
|
||
|
||
```
|
||
group_id_len (u8) | group_id (utf-8) | v2 envelope | [optional 0x01 save]
|
||
```
|
||
|
||
Driver applies only if `group_id` is in its stored list.
|
||
|
||
---
|
||
|
||
## Size limits and chunking
|
||
|
||
- **250 bytes** max per ESP-NOW datagram.
|
||
- Large preset libraries → multiple **CMD** packets from the Pi.
|
||
- Bridge stores at most **20** peer MACs; oldest peer evicted (LRU) when full.
|
||
|
||
---
|
||
|
||
## Related files
|
||
|
||
| Topic | Location |
|
||
|-------|----------|
|
||
| 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) |
|
||
| Driver ESP-NOW | [`led-driver/src/espnow_transport.py`](../led-driver/src/espnow_transport.py) |
|