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,24 +1,18 @@
"""Transport to LED drivers via ESP-NOW bridge WebSocket."""
import asyncio
from typing import Optional, Union
import json
from typing import Any, Dict, List, Optional, Union
from models.bridge_ws_client import get_bridge_client
from util.espnow_wire import WIRE_MAGIC, pack_ws_downlink
BROADCAST_MAC_HEX = "ffffffffffff"
def _parse_mac(addr) -> Optional[bytes]:
if addr is None or addr == "":
return None
if isinstance(addr, bytes) and len(addr) == 6:
return addr
if isinstance(addr, str):
s = addr.strip().lower().replace(":", "").replace("-", "")
if len(s) == 12:
return bytes.fromhex(s)
return None
from util.bridge_envelope import (
BROADCAST_HEX,
BROADCAST_MAC,
build_devices_envelope,
format_mac_key,
is_broadcast_mac,
normalize_mac_key,
)
from util.espnow_wire import WIRE_MAGIC
class NullSender:
@@ -29,25 +23,69 @@ class NullSender:
class BridgeWsSender:
"""Send binary ESP-NOW packets via bridge WebSocket client."""
"""Send v1 JSON or devices envelope via bridge WebSocket."""
async def send(self, data: Union[bytes, str, dict], addr=None) -> bool:
async def send(self, data: Union[bytes, str, Dict[str, Any]], addr=None) -> bool:
client = get_bridge_client()
if client is None:
return False
if isinstance(data, (bytes, bytearray)):
if isinstance(data, dict):
if data.get("v") == "1" and ("devices" in data or "dv" in data):
from util.v1_wire import compact_envelope
return await client.send_packet(compact_envelope(data))
packet = json.dumps(data, separators=(",", ":")).encode("utf-8")
elif isinstance(data, str):
packet = data.encode("utf-8")
elif isinstance(data, (bytes, bytearray)):
packet = bytes(data)
else:
return False
if not packet or packet[0] != WIRE_MAGIC:
if not packet:
return False
peer = _parse_mac(addr)
broadcast = peer is None or addr == BROADCAST_MAC_HEX
return await client.send_espnow(
packet,
peer_mac=peer,
broadcast=broadcast,
)
if packet[0] == WIRE_MAGIC:
return await client.send_packet(packet)
if packet[0:1] != b"{":
return False
mac_key = _addr_to_envelope_key(addr)
if mac_key is None:
return await client.send_packet(packet)
try:
body = json.loads(packet.decode("utf-8"))
except (UnicodeError, ValueError, TypeError):
return False
if not isinstance(body, dict) or body.get("v") != "1":
return False
envelope = build_devices_envelope({mac_key: body})
return await client.send_packet(envelope)
async def send_envelope(self, envelope: Dict[str, Any]) -> bool:
client = get_bridge_client()
if client is None:
return False
return await client.send_packet(envelope)
def _addr_to_envelope_key(addr) -> Optional[str]:
if addr is None:
return BROADCAST_MAC
s = str(addr).strip().lower()
if is_broadcast_mac(s):
return BROADCAST_MAC
h = normalize_mac_key(s)
if h:
try:
return format_mac_key(h)
except ValueError:
return None
return None
_current_sender = None
@@ -69,5 +107,5 @@ def get_sender(settings):
"[startup] bridge disabled (set bridge_ws_url in settings.json, e.g. ws://192.168.4.1/ws)"
)
return NullSender()
print(f"[startup] ESP-NOW via bridge WebSocket {url!r}")
print(f"[startup] ESP-NOW via bridge WebSocket {url!r} (devices envelope)")
return BridgeWsSender()