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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user