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,13 +1,9 @@
#!/usr/bin/env python3
"""Send binary ESP-NOW packets via the bridge (broadcast passthrough).
"""Send v1 JSON to drivers 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).
The simplified ``espnow-sender`` forwards each WebSocket message unchanged to
ESP-NOW ``ff:ff:ff:ff:ff:ff``. Drivers accept JSON when the payload starts with
``{`` (see ``led-driver/src/main.py``).
Examples::
@@ -18,8 +14,6 @@ Examples::
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
@@ -33,20 +27,7 @@ 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",
}
from util.espnow_message import build_message # noqa: E402
def _load_bridge_url(explicit: str | None) -> str:
@@ -64,80 +45,73 @@ def _load_bridge_url(explicit: str | None) -> str:
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:
async def _send_messages(url: str, messages: 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)}")
print("connected (broadcast JSON passthrough)")
for i, pkt in enumerate(messages):
preview = pkt[:80].decode("utf-8", errors="replace")
if len(pkt) > 80:
preview += ""
print(f" send [{i + 1}/{len(messages)}] {len(pkt)} B {preview!r}")
await ws.send(pkt)
if delay_s > 0 and i + 1 < len(packets):
if delay_s > 0 and i + 1 < len(messages):
await asyncio.sleep(delay_s)
print("done")
def _build_packets(args: argparse.Namespace) -> list[bytes]:
packets: list[bytes] = []
def _build_messages(args: argparse.Namespace) -> list[bytes]:
messages: 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.brightness is not None:
body: dict = {
"v": "1",
"b": max(0, min(255, int(args.brightness))),
}
if args.save:
body["save"] = True
messages.append(json.dumps(body, separators=(",", ":")).encode("utf-8"))
if args.select:
packets.append(
pack_cmd_from_kwargs(
messages.append(
build_message(
select={args.select: [args.state]},
save=args.save,
)
).encode("utf-8")
)
if args.off:
if args.select:
packets.append(
pack_cmd_from_kwargs(select={args.select: ["off"]}, save=args.save)
messages.append(
build_message(select={args.select: ["off"]}, save=args.save).encode("utf-8")
)
else:
packets.append(pack_cmd_from_kwargs(select={"all": ["off"]}, save=args.save))
messages.append(
build_message(select={"all": ["off"]}, save=args.save).encode("utf-8")
)
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"]}))
if not messages:
messages.append(
json.dumps({"v": "1", "b": 128}, separators=(",", ":")).encode("utf-8")
)
messages.append(
build_message(select={"all": ["on"]}).encode("utf-8")
)
messages.append(
build_message(select={"all": ["off"]}).encode("utf-8")
)
for pkt in packets:
if wire_msg_type(pkt) is None:
raise ValueError("built packet is not valid wire format")
return packets
for pkt in messages:
if not pkt or pkt[0:1] != b"{":
raise ValueError("built message is not v1 JSON")
return messages
def main() -> int:
parser = argparse.ArgumentParser(
description="Broadcast binary ESP-NOW packets through the bridge WebSocket.",
description="Broadcast v1 JSON to LED drivers through the bridge WebSocket.",
)
parser.add_argument(
"--url",
@@ -148,7 +122,7 @@ def main() -> int:
"--delay",
type=float,
default=0.5,
help="Seconds between packets (default: 0.5)",
help="Seconds between messages (default: 0.5)",
)
parser.add_argument(
"--brightness",
@@ -156,12 +130,12 @@ def main() -> int:
type=int,
default=None,
metavar="0-255",
help="Broadcast CMD: global brightness",
help="Global brightness (b field)",
)
parser.add_argument(
"--select",
metavar="DEVICE_NAME",
help="Broadcast CMD: device name in select map (must match driver settings name)",
help="Device name in select map (must match driver settings name)",
)
parser.add_argument(
"--state",
@@ -171,46 +145,36 @@ def main() -> int:
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)",
help="Send select off (all devices if --select omitted)",
)
parser.add_argument(
"--save",
action="store_true",
help="Set save flag on CMD / GROUP_CMD envelopes",
help="Set save flag on messages",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Print packets only; do not connect",
help="Print messages only; do not connect",
)
args = parser.parse_args()
url = _load_bridge_url(args.url)
try:
packets = _build_packets(args)
messages = _build_messages(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()}")
print(f"url={url!r} messages={len(messages)}")
for pkt in messages:
print(f" {pkt.decode('utf-8')}")
if args.dry_run:
return 0
try:
asyncio.run(_send_packets(url, packets, args.delay))
asyncio.run(_send_messages(url, messages, args.delay))
except KeyboardInterrupt:
print("interrupted")
return 130

View File

@@ -43,7 +43,7 @@ def test_suppress_next_notify_skips_one_select(monkeypatch):
assert delivered == []
bdr.notify_beat_detected()
assert delivered == [(["desk"], "5")]
assert delivered == [("5", None)]
def test_suppress_does_not_advance_beat_counter(monkeypatch):
@@ -52,8 +52,8 @@ def test_suppress_does_not_advance_beat_counter(monkeypatch):
bdr.set_sequence_manual_lane_route(
0,
["desk"],
"42",
{"p": "radiate", "a": False, "manual_beat_n": 2},
"5",
{"p": "chase", "a": False, "manual_beat_n": 2},
)
bdr.mark_sequence_manual_lane_select_sent(0)
@@ -61,14 +61,14 @@ def test_suppress_does_not_advance_beat_counter(monkeypatch):
assert delivered == []
bdr.notify_beat_detected()
assert delivered == [(["desk"], "42")]
assert delivered == [("5", None)]
delivered.clear()
bdr.notify_beat_detected()
assert delivered == []
bdr.notify_beat_detected()
assert delivered == [(["desk"], "42")]
assert delivered == [("5", None)]
def test_duplicate_lanes_dedupe_to_one_select_per_beat(monkeypatch):
@@ -87,19 +87,57 @@ def test_duplicate_lanes_dedupe_to_one_select_per_beat(monkeypatch):
bdr._lane_manual[0] = dict(entry)
bdr.notify_beat_detected()
assert delivered == [(["desk"], "42")]
assert delivered == [("42", None)]
def test_sequence_lane_manual_delivers_per_beat_select(monkeypatch):
delivered = _patch_delivery(monkeypatch)
bdr.set_sequence_manual_lane_route(
0,
["desk"],
"42",
{"p": "radiate", "a": False, "manual_beat_n": 1},
)
bdr.notify_beat_detected()
assert delivered == [("42", None)]
def test_sequence_auto_lane_skips_per_beat_select(monkeypatch):
delivered = _patch_delivery(monkeypatch)
bdr.set_sequence_manual_lane_route(
0,
["desk"],
"3",
{"p": "colour_cycle", "a": True, "manual_beat_n": 1},
)
with bdr._route_lock:
assert 0 not in bdr._lane_manual
bdr.notify_beat_detected()
assert delivered == []
def test_sequence_lane_chase_delivers_per_beat_select(monkeypatch):
delivered = _patch_delivery(monkeypatch)
bdr.set_sequence_manual_lane_route(
0,
["desk"],
"5",
{"p": "chase", "a": False, "manual_beat_n": 1},
)
bdr.notify_beat_detected()
assert delivered == [("5", None)]
def test_standalone_overlay_skipped_when_sequence_lane_covers(monkeypatch):
delivered = _patch_delivery(monkeypatch)
body = {"p": "radiate", "a": False, "manual_beat_n": 1}
body = {"p": "chase", "a": False, "manual_beat_n": 1}
bdr.set_sequence_manual_lane_route(1, ["desk"], "42", body)
bdr._apply_manual_beat_route_standalone_overlay(["desk"], "42", body)
bdr.set_sequence_manual_lane_route(1, ["desk"], "5", body)
bdr._apply_manual_beat_route_standalone_overlay(["desk"], "5", body)
with bdr._route_lock:
assert -1 not in bdr._lane_manual
assert 1 in bdr._lane_manual
bdr.notify_beat_detected()
assert delivered == [(["desk"], "42")]
assert delivered == [("5", None)]

View File

@@ -0,0 +1,173 @@
#!/usr/bin/env python3
"""Tests for bridge devices envelope (Pi + espnow-sender downlink)."""
from __future__ import annotations
import json
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from util.bridge_envelope import ( # noqa: E402
BROADCAST_MAC,
build_devices_envelope,
build_groups_envelope,
build_v1_body,
envelope_payload_size,
format_mac_key,
is_broadcast_mac,
split_v1_body_for_espnow,
v1_body_size,
)
def test_unicast_mac_keys_per_device():
from util.driver_delivery import _unicast_mac_keys
keys = _unicast_mac_keys(["188b0e1560a8", "e8f60a16ea10"])
assert len(keys) == 2
assert keys[0] == "18:8b:0e:15:60:a8"
assert keys[1] == "e8:f6:0a:16:ea:10"
assert _unicast_mac_keys(["188b0e1560a8"]) == ["18:8b:0e:15:60:a8"]
assert _unicast_mac_keys(None) == [BROADCAST_MAC]
def test_deliver_json_messages_defaults_broadcast():
from util.driver_delivery import deliver_json_messages
class _Sender:
def __init__(self):
self.keys = []
async def send(self, envelope):
devs = envelope.get("dv") or envelope.get("devices") or {}
self.keys.extend(devs.keys())
return True
async def _run():
sender = _Sender()
await deliver_json_messages(
sender,
[json.dumps({"v": "1", "select": ["2"]})],
["188b0e1560a8", "e8f60a16ea10"],
None,
)
return sender.keys
keys = __import__("asyncio").run(_run())
assert keys == [BROADCAST_MAC]
def is_devices_envelope(raw: bytes) -> bool:
if not raw or raw[0:1] != b"{":
return False
try:
data = json.loads(raw)
except (ValueError, TypeError):
return False
devs = data.get("devices") if isinstance(data, dict) else None
if devs is None and isinstance(data, dict):
devs = data.get("dv")
return isinstance(data, dict) and data.get("v") == "1" and isinstance(devs, dict)
def build_driver_payload(body: dict) -> bytes:
out = {"v": "1", **{k: body[k] for k in body if k != "v"}}
raw = json.dumps(out)
if len(raw) > 250:
raise ValueError("too large")
return raw.encode("utf-8")
def test_build_groups_envelope():
env = build_groups_envelope("e8f60a16ea10", ["5", "18"])
assert env["v"] == "1"
key = format_mac_key("e8f60a16ea10")
devs = env.get("dv") or env.get("devices")
body = devs[key]
assert body["sg"] is True
assert body["g"] == ["5", "18"]
def test_is_broadcast_mac():
assert is_broadcast_mac("ff:ff:ff:ff:ff:ff")
assert is_broadcast_mac("ffffffffffff")
assert not is_broadcast_mac("e8f60a16ea10")
def test_is_devices_envelope():
env = build_devices_envelope(
{
BROADCAST_MAC: build_v1_body(
presets={"1": {"p": "on", "c": ["#FFFFFF"], "a": True}},
groups=["5"],
set_groups=False,
)
}
)
raw = json.dumps(env).encode("utf-8")
assert is_devices_envelope(raw)
assert not is_devices_envelope(b'{"v":"1","s":{}}')
def test_build_driver_payload_size():
body = build_v1_body(
presets={"x": {"pattern": "on", "colors": ["#FF0000"], "auto": True}},
select=["x", 0],
save=True,
)
payload = build_driver_payload(body)
assert len(payload) <= 250
data = json.loads(payload)
assert data["v"] == "1"
assert data["s"] == ["x", 0]
def test_split_preset_and_select():
body = build_v1_body(
presets={
"2": {
"p": "on",
"c": ["#FFFFFF"],
"bg": "#000000",
"d": 100,
"b": 255,
"a": True,
"n1": 0,
"n2": 0,
}
},
select=["2", 0],
save=True,
)
if v1_body_size(body) <= 250:
chunks = split_v1_body_for_espnow(body)
assert len(chunks) == 1
else:
chunks = split_v1_body_for_espnow(body)
assert len(chunks) >= 2
assert all(v1_body_size(c) <= 250 for c in chunks)
assert "p" in chunks[0]
assert any("s" in c for c in chunks)
def test_envelope_fits_espnow_limit():
env = build_devices_envelope(
{
BROADCAST_MAC: build_v1_body(
presets={
"2": {
"pattern": "on",
"colors": ["#FFFFFF"],
"auto": True,
}
},
select=["2"],
)
}
)
assert envelope_payload_size(env) <= 250

View File

@@ -0,0 +1,36 @@
#!/usr/bin/env python3
"""Tests for bridge WebSocket client reconnect behaviour."""
from __future__ import annotations
import asyncio
import sys
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from models.bridge_ws_client import BridgeWsClient # noqa: E402
def test_send_returns_false_when_not_connected():
async def _run():
client = BridgeWsClient("ws://127.0.0.1/ws", reconnect_delay_s=0.01)
async def _no_wait(_timeout=30.0):
return False
client.wait_connected = _no_wait # type: ignore[method-assign]
return await client.send_packet({"v": "1", "devices": {}})
assert asyncio.run(_run()) is False
def test_disconnect_clears_connected_event():
client = BridgeWsClient("ws://127.0.0.1/ws", reconnect_delay_s=0.01)
client._connected.set()
client._signal_disconnect()
assert not client._connected.is_set()

View File

@@ -676,13 +676,13 @@ def test_groups_sequences_scenes_palettes_patterns_endpoints(server):
assert "presets" in first and "select" in first
assert first["presets"]["__identify"]["p"] == "blink"
assert first["presets"]["__identify"]["d"] == 50
assert first["select"]["pytest-dev"] == ["__identify"]
assert first["select"] == ["__identify"]
deadline = time.monotonic() + 2.0
while len(sender.sent) < 2 and time.monotonic() < deadline:
time.sleep(0.02)
assert len(sender.sent) >= 2
second = json.loads(sender.sent[1][0])
assert second.get("select") == {"pytest-dev": ["off"]}
assert second.get("select") == ["off"]
resp = c.post(
f"{base_url}/devices",

View File

@@ -0,0 +1,51 @@
"""Sequence step ``beats`` hold (e.g. 12 beats on preset 42, then 4 on preset 5)."""
import asyncio
import os
import sys
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SRC_PATH = os.path.join(PROJECT_ROOT, "src")
if SRC_PATH not in sys.path:
sys.path.insert(0, SRC_PATH)
from util import sequence_playback as sp # noqa: E402
def test_step_holds_beats_before_lane_send(monkeypatch):
sent = []
async def fake_send_lane(i, st, ctx):
sent.append((int(st.get("stepIdx", 0)), int(st.get("beatCount", 0))))
monkeypatch.setattr(sp, "_send_lane", fake_send_lane)
async def noop_stop(**_kwargs):
with sp._beat_run_lock:
sp._beat_run = None
monkeypatch.setattr(sp, "stop_playback", noop_stop)
ctx = {
"num_lanes": 1,
"loop": False,
"lanes": [[{"preset_id": "42", "beats": 12}, {"preset_id": "5", "beats": 4}]],
"lane_states": [{"stepIdx": 0, "beatCount": 0, "done": False}],
"sequence_loop_beat": 0,
}
with sp._beat_run_lock:
sp._beat_run = ctx
async def run():
for _ in range(11):
await sp.process_active_beat_advance()
await sp.process_active_beat_advance()
for _ in range(3):
await sp.process_active_beat_advance()
await sp.process_active_beat_advance()
asyncio.run(run())
assert sent == [(1, 0)]
assert ctx["lane_states"][0]["done"] is True
with sp._beat_run_lock:
sp._beat_run = None