feat(espnow): broadcast delivery with group-filtered routing

Send presets and select on broadcast with groups; unicast only for
per-device settings. V1 select as [preset_id, step?]. Sequence steps
use beat counts; manual presets get select each beat, auto only on
step change. Bridge downlink router, Pi envelope delivery, and tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-24 01:44:28 +12:00
parent 1a69fabd98
commit b87382d2be
35 changed files with 1802 additions and 591 deletions

View File

@@ -1,14 +1,92 @@
"""Handle ESP-NOW ANNOUNCE uplink and push GROUPS to drivers."""
"""Handle ESP-NOW uplink from bridge and push group membership."""
from __future__ import annotations
import json
from typing import Any, Dict, Optional
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 models.transport import get_current_sender
from util.bridge_envelope import build_groups_envelope
from util.espnow_wire import (
MSG_ANNOUNCE,
WIRE_MAGIC,
mac_bytes_to_hex,
parse_announce,
wire_msg_type,
)
from util.groups_for_device import groups_for_mac
async def handle_bridge_uplink(peer_mac: bytes, payload: bytes) -> None:
"""Dispatch binary wire or JSON v1 hello from bridge uplink."""
if not payload:
return
if payload[0] == WIRE_MAGIC:
if wire_msg_type(payload) == MSG_ANNOUNCE:
await handle_espnow_announce(peer_mac, payload)
return
if payload[:1] == b"{":
try:
data = json.loads(payload.decode("utf-8"))
except (UnicodeError, ValueError, TypeError):
return
if isinstance(data, dict):
await handle_json_hello(peer_mac, data)
async def _after_device_registered(mac_hex: str) -> None:
await push_groups_to_mac(mac_hex)
async def handle_json_hello(peer_mac: bytes, data: Dict[str, Any]) -> None:
"""Register device from driver JSON boot hello."""
if data.get("v") != "1":
return
mac_hex = mac_bytes_to_hex(peer_mac)
if not mac_hex:
return
name = data.get("name")
nested = data.get("settings")
if not name and isinstance(nested, dict):
name = nested.get("name")
name = str(name or mac_hex).strip() or mac_hex
num_leds = None
color_order = None
startup_mode = None
brightness = None
if isinstance(nested, dict):
try:
num_leds = int(nested.get("num_leds")) if nested.get("num_leds") is not None else None
except (TypeError, ValueError):
pass
color_order = nested.get("color_order")
startup_mode = nested.get("startup_mode")
try:
brightness = int(nested.get("brightness")) if nested.get("brightness") is not None else None
except (TypeError, ValueError):
pass
devices = Device()
did, persisted = devices.upsert_espnow_announced(
mac_hex,
name,
device_type=data.get("type", "led"),
num_leds=num_leds,
color_order=color_order,
startup_mode=startup_mode,
brightness=brightness,
)
if not did:
return
if persisted:
print(f"[espnow] registered mac={did} name={name!r} (json hello)")
await _after_device_registered(mac_hex)
async def handle_espnow_announce(peer_mac: bytes, packet: bytes) -> None:
info = parse_announce(packet)
if not info:
@@ -31,24 +109,13 @@ async def handle_espnow_announce(peer_mac: bytes, packet: bytes) -> None:
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}")
await _after_device_registered(mac_hex)
async def push_groups_for_group_devices(gdoc: dict) -> None:
"""Refresh GROUPS on every MAC listed on a group document."""
"""Push group membership to each device MAC listed on the group."""
if not isinstance(gdoc, dict):
return
mac_list = gdoc.get("devices") if isinstance(gdoc.get("devices"), list) else []
for mac in mac_list:
m = normalize_mac(str(mac))
@@ -56,15 +123,22 @@ async def push_groups_for_group_devices(gdoc: dict) -> None:
await push_groups_to_mac(m)
async def push_groups_broadcast() -> bool:
"""No aggregate broadcast for group assignment; use per-device push."""
return False
async def push_groups_to_mac(mac_hex: str) -> bool:
"""Re-send GROUPS packet to one device (after group membership change)."""
"""Unicast groups envelope to one driver (set_groups true)."""
mac = normalize_mac(mac_hex)
if not mac:
return False
client = get_bridge_client()
if client is None:
gids = groups_for_mac(mac, Group())
sender = get_current_sender()
if sender 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))
envelope = build_groups_envelope(mac, gids)
ok = await sender.send(envelope)
if ok:
print(f"[espnow] groups sent mac={mac} groups={gids!r}")
return bool(ok)