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,70 @@
"""Handle ESP-NOW ANNOUNCE uplink and push GROUPS to drivers."""
from __future__ import annotations
from models.device import Device, normalize_mac # noqa: F401 — re-export for callers
from models.group import Group
from models.bridge_ws_client import get_bridge_client
from util.espnow_wire import mac_bytes_to_hex, pack_groups, parse_announce
from util.groups_for_device import groups_for_mac
async def handle_espnow_announce(peer_mac: bytes, packet: bytes) -> None:
info = parse_announce(packet)
if not info:
return
mac_hex = mac_bytes_to_hex(peer_mac)
if not mac_hex:
return
devices = Device()
did, persisted = devices.upsert_espnow_announced(
mac_hex,
info["name"],
device_type=info.get("device_type", "led"),
num_leds=info.get("num_leds"),
color_order=info.get("color_order"),
startup_mode=info.get("startup_mode"),
brightness=info.get("brightness"),
)
if not did:
return
if persisted:
print(f"[espnow] registered mac={did} name={info['name']!r}")
groups = Group()
gids = groups_for_mac(did, groups)
groups_pkt = pack_groups(gids)
client = get_bridge_client()
if client is None:
print("[espnow] bridge client not configured; groups not sent")
return
ok = await client.send_espnow(groups_pkt, peer_mac=peer_mac)
if ok:
print(f"[espnow] groups -> {did}: {gids}")
else:
print(f"[espnow] groups send failed for {did}")
async def push_groups_for_group_devices(gdoc: dict) -> None:
"""Refresh GROUPS on every MAC listed on a group document."""
mac_list = gdoc.get("devices") if isinstance(gdoc.get("devices"), list) else []
for mac in mac_list:
m = normalize_mac(str(mac))
if m:
await push_groups_to_mac(m)
async def push_groups_to_mac(mac_hex: str) -> bool:
"""Re-send GROUPS packet to one device (after group membership change)."""
mac = normalize_mac(mac_hex)
if not mac:
return False
client = get_bridge_client()
if client is None:
return False
groups = Group()
gids = groups_for_mac(mac, groups)
pkt = pack_groups(gids)
return await client.send_espnow(pkt, peer_mac=bytes.fromhex(mac))