Files
led-controller/docs/espnow-architecture.md
2026-05-28 00:38:21 +12:00

6.7 KiB
Raw Blame History

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

Three-node ESP-NOW architecture

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

Boot and registration sequence

  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)

{
  "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

Packet layer stack

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)

Message types

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 111

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 0127 (→ 0255); 128255 = 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.

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