6.7 KiB
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.
Pi ↔ bridge WebSocket: v1 devices envelope (JSON) — see 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/ |
WebSocket server /ws; routes envelope per MAC; max 20 peers (LRU) |
| led-driver | led-driver/ submodule |
Boot ANNOUNCE broadcast; applies GROUPS, CMD, GROUP_CMD |
Configure the Pi in settings.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
- Driver powers on and sends ANNOUNCE to broadcast MAC
ff:ff:ff:ff:ff:ff. - Bridge receives it and forwards a WebSocket uplink frame to the Pi (peer MAC + packet).
- Pi upserts the device in
db/device.json(key = 12-char hex MAC). - Pi scans
db/group.jsonand sends a groups envelope (set_groups: true) unicast to that MAC. - Driver stores group ids in RAM (
device_groups) for filtering. - 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)
{
"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
- UI or API triggers a send (e.g.
POST /presets/push). - Pi builds a devices envelope (or legacy binary) and sends it on the bridge WebSocket.
- Bridge routes each MAC entry to unicast or ESP-NOW broadcast per
set_groups. - Driver
process_dataapplies 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/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 |
| Pi wire codec | src/util/espnow_wire.py |
| Pi bridge client | src/models/bridge_ws_client.py |
| Bridge firmware | espnow-sender/main.py |
| Driver ESP-NOW | led-driver/src/espnow_transport.py |