feat(espnow): Pi bridge client, binary wire, and espnow-sender firmware

Replace serial/Wi-Fi driver transport paths with WebSocket bridge client,
binary espnow_wire delivery, device announce registry, and restructured
espnow-sender (AP + broadcast passthrough). Includes docs and tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-23 22:44:44 +12:00
parent f4ef85c182
commit 4fc3f46866
42 changed files with 4167 additions and 848 deletions

View File

@@ -0,0 +1,224 @@
#!/usr/bin/env python3
"""Send binary ESP-NOW packets via the bridge (broadcast passthrough).
The simplified ``espnow-sender`` forwards each WebSocket **binary** message
unchanged to ESP-NOW ``ff:ff:ff:ff:ff:ff``. No ``pack_ws_downlink`` wrapper
and no 1-byte ack — raw wire packets only (see ``docs/espnow-binary-protocol.md``).
Group membership is expected to be configured on each **led-driver**; this
script only broadcasts **CMD** (and optional **GROUPS** / **GROUP_CMD** for
manual testing).
Examples::
pipenv run python tests/bridge_broadcast_test.py
pipenv run python tests/bridge_broadcast_test.py --url ws://192.168.4.1/ws
pipenv run python tests/bridge_broadcast_test.py --brightness 200
pipenv run python tests/bridge_broadcast_test.py --select led-abc --state on
pipenv run python tests/bridge_broadcast_test.py --groups 5,18 --group-cmd 18 --brightness 64
"""
from __future__ import annotations
import argparse
import asyncio
import json
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PROJECT_ROOT / "src"))
from util.espnow_wire import ( # noqa: E402
pack_cmd_from_kwargs,
pack_group_cmd_from_kwargs,
pack_groups,
wire_msg_type,
)
MSG_TYPE_NAMES = {
0x01: "ANNOUNCE",
0x02: "GROUPS",
0x03: "CMD",
0x04: "GROUP_CMD",
0x10: "BRIDGE_CH",
}
def _load_bridge_url(explicit: str | None) -> str:
if explicit and explicit.strip():
return explicit.strip()
path = PROJECT_ROOT / "settings.json"
if path.is_file():
try:
data = json.loads(path.read_text(encoding="utf-8"))
url = str(data.get("bridge_ws_url") or "").strip()
if url:
return url
except (OSError, json.JSONDecodeError, TypeError):
pass
return "ws://192.168.4.1/ws"
def _describe_packet(pkt: bytes) -> str:
if len(pkt) < 2:
return f"{len(pkt)} B"
name = MSG_TYPE_NAMES.get(pkt[1], f"0x{pkt[1]:02x}")
return f"{name} {len(pkt)} B"
async def _send_packets(url: str, packets: list[bytes], delay_s: float) -> None:
import websockets
print(f"connecting to {url}")
async with websockets.connect(url, ping_interval=20, ping_timeout=20) as ws:
print("connected (broadcast passthrough)")
for i, pkt in enumerate(packets):
print(f" send [{i + 1}/{len(packets)}] {_describe_packet(pkt)}")
await ws.send(pkt)
if delay_s > 0 and i + 1 < len(packets):
await asyncio.sleep(delay_s)
print("done")
def _build_packets(args: argparse.Namespace) -> list[bytes]:
packets: list[bytes] = []
if args.groups:
gids = [g.strip() for g in args.groups.split(",") if g.strip()]
if gids:
packets.append(pack_groups(gids))
if args.group_cmd:
packets.append(
pack_group_cmd_from_kwargs(
args.group_cmd,
brightness_0_255=args.brightness,
select={args.select: [args.state]} if args.select else None,
save=args.save,
)
)
if args.brightness is not None and not args.group_cmd:
packets.append(
pack_cmd_from_kwargs(brightness_0_255=args.brightness, save=args.save)
)
if args.select:
packets.append(
pack_cmd_from_kwargs(
select={args.select: [args.state]},
save=args.save,
)
)
if args.off:
if args.select:
packets.append(
pack_cmd_from_kwargs(select={args.select: ["off"]}, save=args.save)
)
else:
packets.append(pack_cmd_from_kwargs(select={"all": ["off"]}, save=args.save))
if not packets:
packets.append(pack_cmd_from_kwargs(brightness_0_255=128))
packets.append(pack_cmd_from_kwargs(select={"all": ["on"]}))
packets.append(pack_cmd_from_kwargs(select={"all": ["off"]}))
for pkt in packets:
if wire_msg_type(pkt) is None:
raise ValueError("built packet is not valid wire format")
return packets
def main() -> int:
parser = argparse.ArgumentParser(
description="Broadcast binary ESP-NOW packets through the bridge WebSocket.",
)
parser.add_argument(
"--url",
default=None,
help="Bridge WebSocket URL (default: settings.json bridge_ws_url or ws://192.168.4.1/ws)",
)
parser.add_argument(
"--delay",
type=float,
default=0.5,
help="Seconds between packets (default: 0.5)",
)
parser.add_argument(
"--brightness",
"-b",
type=int,
default=None,
metavar="0-255",
help="Broadcast CMD: global brightness",
)
parser.add_argument(
"--select",
metavar="DEVICE_NAME",
help="Broadcast CMD: device name in select map (must match driver settings name)",
)
parser.add_argument(
"--state",
default="on",
help="Pattern/state for --select (default: on)",
)
parser.add_argument(
"--off",
action="store_true",
help="After other commands, send select off (all devices if --select omitted)",
)
parser.add_argument(
"--groups",
metavar="ID,ID",
help="Optional GROUPS broadcast (normally configured on device instead)",
)
parser.add_argument(
"--group-cmd",
metavar="GROUP_ID",
help="Optional GROUP_CMD broadcast (driver must list this group locally)",
)
parser.add_argument(
"--save",
action="store_true",
help="Set save flag on CMD / GROUP_CMD envelopes",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Print packets only; do not connect",
)
args = parser.parse_args()
url = _load_bridge_url(args.url)
try:
packets = _build_packets(args)
except ValueError as e:
print(f"error: {e}", file=sys.stderr)
return 1
print(f"url={url!r} packets={len(packets)}")
for pkt in packets:
print(f" {_describe_packet(pkt)} hex={pkt.hex()}")
if args.dry_run:
return 0
try:
asyncio.run(_send_packets(url, packets, args.delay))
except KeyboardInterrupt:
print("interrupted")
return 130
except Exception as e:
print(f"failed: {e!r}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -150,6 +150,27 @@ def test_device_duplicate_names_allowed():
assert devices.read(a1)["name"] == devices.read(a2)["name"] == "alpha"
def test_upsert_espnow_announced():
devices = _fresh_device()
m = "e8f60a16dad0"
i1, p1 = devices.upsert_espnow_announced(
m,
"led-test",
num_leds=120,
color_order="grb",
startup_mode="last",
brightness=70,
)
assert i1 == m and p1 is True
d = devices.read(m)
assert d["transport"] == "espnow"
assert d["address"] == m
assert d["name"] == "led-test"
assert d["num_leds"] == 120
i2, p2 = devices.upsert_espnow_announced(m, "led-test")
assert i2 == m and p2 is False
def test_device_duplicate_mac_rejected():
devices = _fresh_device()
devices.create("one", address="aa:bb:cc:dd:ee:ff")
@@ -163,6 +184,7 @@ def test_device_duplicate_mac_rejected():
if __name__ == "__main__":
test_device()
test_upsert_wifi_tcp_client()
test_upsert_espnow_announced()
test_device_can_change_address()
test_device_duplicate_names_allowed()
test_device_duplicate_mac_rejected()

110
tests/test_espnow_wire.py Normal file
View File

@@ -0,0 +1,110 @@
"""Tests for ESP-NOW binary wire format."""
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PROJECT_ROOT / "src"))
from util.binary_envelope import pack_binary_envelope_v2 # noqa: E402
from util.espnow_wire import ( # noqa: E402
BROADCAST_MAC,
MAX_ESPNOW_PAYLOAD,
MSG_ANNOUNCE,
MSG_CMD,
MSG_GROUPS,
WIRE_MAGIC,
pack_announce,
pack_bridge_channel,
pack_cmd,
pack_cmd_from_kwargs,
pack_group_cmd_from_kwargs,
pack_groups,
pack_ws_downlink,
pack_ws_uplink,
parse_announce,
parse_cmd_as_v1_dict,
parse_group_cmd,
parse_groups,
parse_ws_frame,
wire_msg_type,
)
def test_announce_round_trip():
raw = pack_announce(
name="led-abc123",
num_leds=119,
color_order="grb",
startup_mode="last",
brightness=70,
)
assert len(raw) <= MAX_ESPNOW_PAYLOAD
assert raw[0] == WIRE_MAGIC
assert raw[1] == MSG_ANNOUNCE
d = parse_announce(raw)
assert d["name"] == "led-abc123"
assert d["num_leds"] == 119
assert d["color_order"] == "grb"
assert d["startup_mode"] == "last"
assert d["brightness"] == 70
def test_groups_round_trip():
raw = pack_groups(["5", "18", "test"])
assert wire_msg_type(raw) == MSG_GROUPS
assert parse_groups(raw) == ["5", "18", "test"]
def test_cmd_envelope_round_trip():
env = pack_binary_envelope_v2(brightness_0_255=128)
raw = pack_cmd(env, save=True)
assert wire_msg_type(raw) == MSG_CMD
assert len(raw) <= MAX_ESPNOW_PAYLOAD
d = parse_cmd_as_v1_dict(raw)
assert d == {"v": "1", "b": 128, "save": True}
def test_cmd_from_kwargs():
raw = pack_cmd_from_kwargs(
select={"dev1": ["on"]},
brightness_0_255=64,
)
d = parse_cmd_as_v1_dict(raw)
assert d["select"]["dev1"] == ["on"]
assert d["b"] == 64
def test_group_cmd_round_trip():
raw = pack_group_cmd_from_kwargs("18", brightness_0_255=32)
gid, env = parse_group_cmd(raw)
assert gid == "18"
d = parse_cmd_as_v1_dict(bytes([WIRE_MAGIC, MSG_CMD]) + env)
assert d["b"] == 32
def test_ws_frame_round_trip():
pkt = pack_announce(name="led-x", num_leds=10)
peer = bytes.fromhex("e8f60a16dad0")
up = pack_ws_uplink(peer, pkt)
p2, pkt2, bcast = parse_ws_frame(up)
assert p2 == peer
assert pkt2 == pkt
assert not bcast
down = pack_ws_downlink(pkt, peer_mac=peer)
p3, pkt3, bcast3 = parse_ws_frame(down)
assert p3 == peer
assert pkt3 == pkt
assert not bcast3
bdown = pack_ws_downlink(pkt, broadcast=True)
_, pkt4, bcast4 = parse_ws_frame(bdown)
assert pkt4 == pkt
assert bcast4
def test_bridge_channel():
raw = pack_bridge_channel(6)
assert len(raw) == 3
assert raw[1] == 0x10