feat(bridge): add wifi/serial bridge runtime and UI

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-28 00:38:21 +12:00
parent 2cf019079e
commit 78dc8ffc77
92 changed files with 5679 additions and 1790 deletions

View File

@@ -0,0 +1,16 @@
from machine import UART, Pin
import time
#set baudrate closest to 1000000
baudrate = 921600
uart = UART(1, baudrate=baudrate, tx=Pin(2), rx=Pin(3))
print("Sending 'Hello, World!'")
uart.write(b'Hello, World!')
while True:
if uart.any():
data = uart.read()
print(data)
uart.write(data)
else:
time.sleep(0.01)

View File

@@ -0,0 +1,176 @@
"""Audio input device_select persistence (Pulse name must survive start)."""
import asyncio
import json
import os
import sys
import threading
import time
from pathlib import Path
from unittest.mock import MagicMock
import pytest
import requests
PROJECT_ROOT = Path(__file__).resolve().parents[1]
SRC_PATH = PROJECT_ROOT / "src"
if str(SRC_PATH) not in sys.path:
sys.path.insert(0, str(SRC_PATH))
from microdot import Microdot # noqa: E402
from util.audio_run_persist import read_audio_run_state, write_audio_run_state # noqa: E402
SNOWBALL = (
"alsa_input.usb-BLUE_MICROPHONE_Blue_Snowball_SUGA_2020_10_09_41646-00.mono-fallback"
)
def _start_app(app: Microdot, port: int = 0):
def runner():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(app.start_server(host="127.0.0.1", port=port))
finally:
loop.close()
thread = threading.Thread(target=runner, daemon=True)
thread.start()
deadline = time.time() + 5.0
while time.time() < deadline:
server = getattr(app, "server", None)
if server and getattr(server, "sockets", None):
sockets = server.sockets or []
if sockets:
return thread, sockets[0].getsockname()[1]
time.sleep(0.05)
raise RuntimeError("server failed to start")
@pytest.fixture
def audio_run_path(tmp_path, monkeypatch):
path = tmp_path / "audio_run.json"
monkeypatch.setattr(
"util.audio_run_persist._db_path",
lambda: str(path),
)
return path
def test_write_start_keeps_pulse_device_select_not_portaudio_index(audio_run_path):
write_audio_run_state(
enabled=True,
device=1,
device_select=SNOWBALL,
)
st = read_audio_run_state()
assert st["device_select"] == SNOWBALL
assert st["device"] == 1
def test_put_device_saves_pulse_name(audio_run_path):
app = Microdot()
@app.route("/api/audio/device", methods=["PUT"])
async def audio_set_device(request):
payload = request.json if isinstance(request.json, dict) else {}
device_select = str(payload.get("device_select") or "").strip()
from util.audio_run_persist import read_audio_run_state, write_audio_run_state
prev = read_audio_run_state()
write_audio_run_state(
enabled=bool(prev.get("enabled")),
device=device_select if device_select else None,
device_override="",
device_select=device_select,
)
return {"ok": True, "audio_run": read_audio_run_state()}
_, port = _start_app(app)
base = f"http://127.0.0.1:{port}"
resp = requests.put(
f"{base}/api/audio/device",
json={"device_select": SNOWBALL, "device_override": ""},
timeout=5,
)
assert resp.status_code == 200
body = resp.json()
assert body["audio_run"]["device_select"] == SNOWBALL
assert read_audio_run_state()["device_select"] == SNOWBALL
def test_start_preserves_device_select_in_status(audio_run_path, monkeypatch):
detector = MagicMock()
detector.status.return_value = {"running": True, "device": 2}
def fake_resolve(device):
assert device == SNOWBALL
return 2
monkeypatch.setattr(
"util.pulse_audio_devices.resolve_capture_device",
fake_resolve,
)
app = Microdot()
@app.route("/api/audio/start", methods=["POST"])
async def audio_start(request):
payload = request.json if isinstance(request.json, dict) else {}
device = payload.get("device", None)
if device in ("", None):
device = None
device_select = str(payload.get("device_select") or "").strip()
if not device_select and device not in ("", None):
device_select = str(device).strip()
from util.pulse_audio_devices import resolve_capture_device
from util.audio_run_persist import read_audio_run_state, write_audio_run_state
device = resolve_capture_device(device)
detector.start(device=device)
write_audio_run_state(
enabled=True,
device=device,
device_override="",
device_select=device_select,
)
st = detector.status()
st["audio_run"] = read_audio_run_state()
return {"ok": True, "status": st}
@app.route("/api/audio/status")
async def audio_status(request):
_ = request
from util.audio_run_persist import read_audio_run_state
st = detector.status()
st["audio_run"] = read_audio_run_state()
return {"status": st}
_, port = _start_app(app)
base = f"http://127.0.0.1:{port}"
start = requests.post(
f"{base}/api/audio/start",
json={"device": SNOWBALL, "device_select": SNOWBALL, "device_override": ""},
timeout=5,
)
assert start.status_code == 200, start.text
run = start.json()["status"]["audio_run"]
assert run["device_select"] == SNOWBALL
assert run["device"] == 2
status = requests.get(f"{base}/api/audio/status", timeout=5).json()["status"]
assert status["audio_run"]["device_select"] == SNOWBALL
def test_pulse_device_list_uses_stable_pulse_ids():
from util.pulse_audio_devices import list_pulse_matched_input_devices
devs = list_pulse_matched_input_devices()
if not devs:
pytest.skip("pactl not available")
snow = next((d for d in devs if "Snowball" in d.get("display_name", "")), None)
if snow is None:
pytest.skip("Blue Snowball not connected")
assert snow["id"] == snow["pulse_name"]
assert str(snow["id"]).startswith("alsa_input.")

View File

@@ -59,3 +59,47 @@ def test_status_keeps_bpm_during_holdover():
det._status["bpm"] = 128.0
det._status["last_beat_ts"] = time.time() - 10.0
assert det.status()["bpm"] == 128.0
class _FakeRuntimeGap:
def __init__(self):
self.reset_tempo_calls = 0
def reset_tempo_state(self):
self.reset_tempo_calls += 1
def test_silence_gap_starts_holdover_and_resets_tempo_once():
det = AudioBeatDetector()
rt = _FakeRuntimeGap()
with det._lock:
det._running = True
det._status["running"] = True
det._status["bpm"] = 120.0
det._last_real_beat_ts = time.time() - 10.0
det._maybe_recover_after_silence_gap(rt)
assert rt.reset_tempo_calls == 1
assert det._holdover_active is True
det._maybe_recover_after_silence_gap(rt)
assert rt.reset_tempo_calls == 1
det._record_beat(120.0)
assert det._holdover_active is False
def test_holdover_last_beat_does_not_block_tempo_retry():
"""Holdover refreshes last_beat_ts but recovery uses last real beat only."""
det = AudioBeatDetector()
rt = _FakeRuntimeGap()
with det._lock:
det._running = True
det._status["running"] = True
det._status["bpm"] = 120.0
det._last_real_beat_ts = time.time() - 10.0
det._maybe_recover_after_silence_gap(rt)
assert rt.reset_tempo_calls == 1
with det._lock:
det._status["last_beat_ts"] = time.time()
det._last_gap_tempo_reset_ts = time.time() - 10.0
det._maybe_recover_after_silence_gap(rt)
assert rt.reset_tempo_calls == 2
assert det._holdover_active is True

View File

@@ -28,6 +28,22 @@ def _patch_delivery(monkeypatch):
return delivered
def test_reset_manual_lane_strides_zeros_counters():
bdr.set_sequence_manual_lane_route(
0,
["desk"],
"5",
{"p": "chase", "a": False, "manual_beat_n": 1},
)
with bdr._route_lock:
bdr._lane_manual[0]["beat_counter"] = 4
bdr._preset_session_beats = 3
bdr.reset_manual_lane_strides()
with bdr._route_lock:
assert bdr._lane_manual[0]["beat_counter"] == 0
assert bdr._preset_session_beats == 0
def test_suppress_next_notify_skips_one_select(monkeypatch):
delivered = _patch_delivery(monkeypatch)

View File

@@ -39,7 +39,7 @@ def test_unicast_mac_keys_per_device():
def test_deliver_json_messages_defaults_broadcast():
from util.driver_delivery import deliver_json_messages
class _Sender:
class _Bridge:
def __init__(self):
self.keys = []
@@ -49,14 +49,14 @@ def test_deliver_json_messages_defaults_broadcast():
return True
async def _run():
sender = _Sender()
bridge = _Bridge()
await deliver_json_messages(
sender,
bridge,
[json.dumps({"v": "1", "select": ["2"]})],
["188b0e1560a8", "e8f60a16ea10"],
None,
)
return sender.keys
return bridge.keys
keys = __import__("asyncio").run(_run())
assert keys == [BROADCAST_MAC]

View File

@@ -0,0 +1,48 @@
#!/usr/bin/env python3
"""Tests for Pi↔bridge USB serial framing."""
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from util.bridge_serial_frame import feed_serial_buffer, pack_serial_frame # noqa: E402
def test_pack_and_parse_single_frame():
payload = b'{"v":"1","select":["1"]}'
frame = pack_serial_frame(payload)
buf = bytearray()
out = feed_serial_buffer(buf, frame)
assert out == [payload]
assert len(buf) == 0
def test_rejects_oversized_length_and_caps_buffer():
buf = bytearray(b"\x31\x23") # length 12579 — invalid on bridge
buf.extend(b"x" * 100)
assert feed_serial_buffer(buf, b"") == []
assert len(buf) < 100 # resync shifted past bad header
def test_serial_frame_carries_ws_uplink():
from util.espnow_wire import pack_ws_uplink, parse_ws_frame
peer = bytes.fromhex("e8f60a16ea10")
pkt = b'{"v":"1","name":"test"}'
inner = pack_ws_uplink(peer, pkt)
framed = pack_serial_frame(inner)
out = feed_serial_buffer(bytearray(), framed)
assert len(out) == 1
p2, pkt2, _ = parse_ws_frame(out[0])
assert p2 == peer
assert pkt2 == pkt
payload = b"\x4c\x03abc"
frame = pack_serial_frame(payload)
buf = bytearray()
assert feed_serial_buffer(buf, frame[:1]) == []
assert feed_serial_buffer(buf, frame[1:3]) == []
assert feed_serial_buffer(buf, frame[3:]) == [payload]

View File

@@ -0,0 +1,40 @@
#!/usr/bin/env python3
"""Tests for bridge serial connect profile handling."""
from __future__ import annotations
import asyncio
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
def test_connect_bridge_profile_serial(monkeypatch):
from util import bridge_runtime
calls = {}
async def fake_connect_serial(profile, settings):
calls["profile"] = profile
calls["settings"] = settings
return True, ""
monkeypatch.setattr(bridge_runtime, "connect_bridge_serial", fake_connect_serial)
class _Settings(dict):
def save(self):
pass
settings = _Settings({"bridge_serial_port": ""})
profile = {
"transport": "serial",
"serial_port": "/dev/ttyUSB0",
"serial_baudrate": 921600,
}
ok, err = asyncio.run(bridge_runtime.connect_bridge_profile(profile, settings))
assert ok is True
assert err == ""
assert calls["profile"]["serial_port"] == "/dev/ttyUSB0"

View File

@@ -27,7 +27,7 @@ from microdot.session import Session # noqa: E402
from microdot.websocket import with_websocket # noqa: E402
class DummySender:
class DummyBridge:
def __init__(self):
self.sent: list[tuple[str, Optional[str]]] = []
@@ -166,7 +166,7 @@ def server(monkeypatch, tmp_path_factory):
old_cwd = os.getcwd()
os.chdir(str(SRC_PATH))
dummy_sender = DummySender()
dummy_bridge = DummyBridge()
try:
# Ensure controllers are imported fresh after our patching.
@@ -196,10 +196,10 @@ def server(monkeypatch, tmp_path_factory):
import controllers.settings as settings_ctl # noqa: E402
import controllers.device as device_ctl # noqa: E402
# Configure transport sender used by /presets/send.
from models.transport import set_sender # noqa: E402
# Configure transport bridge used by /presets/send.
from models.transport import set_bridge # noqa: E402
set_sender(dummy_sender)
set_bridge(dummy_bridge)
app = Microdot()
@@ -244,7 +244,7 @@ def server(monkeypatch, tmp_path_factory):
@app.route("/ws")
@with_websocket
async def ws(request, ws):
# Minimal websocket handler: forward raw JSON/text payloads to dummy sender.
# Minimal websocket handler: forward raw JSON/text payloads to dummy bridge.
while True:
data = await ws.receive()
if not data:
@@ -253,9 +253,9 @@ def server(monkeypatch, tmp_path_factory):
parsed = json.loads(data)
addr = parsed.pop("to", None)
payload = json.dumps(parsed) if parsed else data
await dummy_sender.send(payload, addr=addr)
await dummy_bridge.send(payload, addr=addr)
except Exception:
await dummy_sender.send(data)
await dummy_bridge.send(data)
thread, chosen_port = _start_microdot_server(app, host="127.0.0.1", port=0)
base_url = f"http://127.0.0.1:{chosen_port}"
@@ -271,7 +271,7 @@ def server(monkeypatch, tmp_path_factory):
yield {
"base_url": base_url,
"client": client,
"sender": dummy_sender,
"bridge": dummy_bridge,
"thread": thread,
"app": app,
}
@@ -379,7 +379,7 @@ def test_settings_controller(server):
def test_profiles_presets_zones_endpoints(server, monkeypatch):
c: requests.Session = server["client"]
base_url: str = server["base_url"]
sender: DummySender = server["sender"]
bridge: DummyBridge = server["bridge"]
import controllers.device as device_ctl
@@ -436,7 +436,7 @@ def test_profiles_presets_zones_endpoints(server, monkeypatch):
assert resp.status_code == 200
assert resp.json()["brightness"] == 77
sender.sent.clear()
bridge.sent.clear()
resp = c.post(
f"{base_url}/presets/send",
json={"preset_ids": [new_preset_id], "save": False},
@@ -444,7 +444,7 @@ def test_profiles_presets_zones_endpoints(server, monkeypatch):
assert resp.status_code == 200
sent_result = resp.json()
assert sent_result["presets_sent"] >= 1
assert len(sender.sent) >= 1
assert len(bridge.sent) >= 1
resp = c.delete(f"{base_url}/presets/{new_preset_id}")
assert resp.status_code == 200
@@ -555,7 +555,7 @@ def test_profiles_presets_zones_endpoints(server, monkeypatch):
def test_groups_sequences_scenes_palettes_patterns_endpoints(server):
c: requests.Session = server["client"]
base_url: str = server["base_url"]
sender: DummySender = server["sender"]
bridge: DummyBridge = server["bridge"]
_create_and_apply_profile(c, base_url)
@@ -667,21 +667,21 @@ def test_groups_sequences_scenes_palettes_patterns_endpoints(server):
assert resp.status_code == 200
assert resp.json()[dev_id].get("connected") is None
sender.sent.clear()
bridge.sent.clear()
resp = c.post(f"{base_url}/devices/{dev_id}/identify")
assert resp.status_code == 200
assert resp.json().get("message")
assert len(sender.sent) >= 1
first = json.loads(sender.sent[0][0])
assert len(bridge.sent) >= 1
first = json.loads(bridge.sent[0][0])
assert "presets" in first and "select" in first
assert first["presets"]["__identify"]["p"] == "blink"
assert first["presets"]["__identify"]["d"] == 50
assert first["select"] == ["__identify"]
deadline = time.monotonic() + 2.0
while len(sender.sent) < 2 and time.monotonic() < deadline:
while len(bridge.sent) < 2 and time.monotonic() < deadline:
time.sleep(0.02)
assert len(sender.sent) >= 2
second = json.loads(sender.sent[1][0])
assert len(bridge.sent) >= 2
second = json.loads(bridge.sent[1][0])
assert second.get("select") == ["off"]
resp = c.post(

45
tests/test_espnow_ping.py Normal file
View File

@@ -0,0 +1,45 @@
"""ESP-NOW ping session collection."""
import asyncio
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PROJECT_ROOT / "src"))
from util import espnow_ping # noqa: E402
from util.espnow_wire import pack_ping_rsp, parse_ping_req # noqa: E402
class _FakeBridge:
def __init__(self):
self.packets = []
async def send(self, data, addr=None):
self.packets.append(data)
return True
def test_run_ping_collects_responses(monkeypatch):
bridge = _FakeBridge()
monkeypatch.setattr(espnow_ping, "get_current_bridge", lambda: bridge)
async def _run():
async def _inject():
await asyncio.sleep(0.05)
assert len(bridge.packets) == 1
ping_id = parse_ping_req(bridge.packets[0])
peer = bytes.fromhex("aabbccddeeff")
espnow_ping.record_ping_rsp(peer, pack_ping_rsp(ping_id, "led-1"))
task = asyncio.create_task(_inject())
result = await espnow_ping.run_ping(timeout_s=0.2)
await task
return result
result = asyncio.run(_run())
assert result["ok"] is True
assert "aabbccddeeff" in result["responses"]
assert result["responses"]["aabbccddeeff"]["name"] == "led-1"
assert bridge.packets[0][0:2] == bytes([0x4C, 0x05])

View File

@@ -13,6 +13,8 @@ from util.espnow_wire import ( # noqa: E402
MSG_ANNOUNCE,
MSG_CMD,
MSG_GROUPS,
MSG_PING_REQ,
MSG_PING_RSP,
WIRE_MAGIC,
pack_announce,
pack_bridge_channel,
@@ -20,12 +22,16 @@ from util.espnow_wire import ( # noqa: E402
pack_cmd_from_kwargs,
pack_group_cmd_from_kwargs,
pack_groups,
pack_ping_req,
pack_ping_rsp,
pack_ws_downlink,
pack_ws_uplink,
parse_announce,
parse_cmd_as_v1_dict,
parse_group_cmd,
parse_groups,
parse_ping_req,
parse_ping_rsp,
parse_ws_frame,
wire_msg_type,
)
@@ -104,6 +110,19 @@ def test_ws_frame_round_trip():
assert bcast4
def test_ping_round_trip():
ping_id = 0xA1B2C3D4
req = pack_ping_req(ping_id)
assert wire_msg_type(req) == MSG_PING_REQ
assert parse_ping_req(req) == ping_id
rsp = pack_ping_rsp(ping_id, "led-test")
assert wire_msg_type(rsp) == MSG_PING_RSP
assert len(rsp) <= MAX_ESPNOW_PAYLOAD
info = parse_ping_rsp(rsp)
assert info["ping_id"] == ping_id
assert info["name"] == "led-test"
def test_bridge_channel():
raw = pack_bridge_channel(6)
assert len(raw) == 3

View File

@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Tests for nmcli WiFi scan parsing."""
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from util.pi_wifi import _unescape_nmcli # noqa: E402
def test_unescape_nmcli():
assert _unescape_nmcli("bridge\\:abc") == "bridge:abc"
assert _unescape_nmcli("plain") == "plain"
def test_interface_display_name_fallback(monkeypatch):
from util import pi_wifi
monkeypatch.setattr(
pi_wifi,
"_interface_display_name",
lambda device: "Test WiFi" if device == "wlan0" else device,
)
import subprocess
def fake_run(*args, **kwargs):
class _R:
stdout = "wlan0:wifi:connected:HomeNet\neth0:ethernet:connected:\n"
return _R()
monkeypatch.setattr(subprocess, "run", fake_run)
ifaces = pi_wifi.list_wifi_interfaces()
assert len(ifaces) == 1
assert ifaces[0]["device"] == "wlan0"
assert ifaces[0]["label"] == "Test WiFi"
assert ifaces[0]["connection"] == "HomeNet"
def test_scan_wifi_parses_terse_nmcli(monkeypatch):
import asyncio
from util import pi_wifi
sample = "\n".join(
[
"bridge-588c81a2fc18:84:",
"My Network:72:WPA2",
":50:WPA2",
"led:100:WPA2",
]
)
async def fake_run(*args, **kwargs):
return 0, sample, ""
monkeypatch.setattr(pi_wifi, "_run_nmcli", fake_run)
networks = asyncio.run(pi_wifi.scan_wifi("wlan0"))
ssids = [n["ssid"] for n in networks]
assert ssids == ["led", "bridge-588c81a2fc18", "My Network"]
assert networks[0]["signal"] == 100

View File

@@ -1,6 +1,7 @@
"""Deferred sequence start on beat / downbeat."""
import asyncio
import json
import os
import sys
@@ -86,3 +87,176 @@ def test_downbeat_start_counts_trigger_beat(monkeypatch):
assert sp._beat_run["lane_states"][0]["beatCount"] == 1
sp.stop()
def _prime_all_lanes_test_mocks(monkeypatch, deliver_log, route_log, reset_log):
import types
async def fake_deliver(_bridge, messages, _macs, _devices, **_kw):
for raw in messages:
deliver_log.append(json.loads(raw))
return ([], 0)
def fake_clear(lane_index):
route_log.append(("clear", lane_index))
monkeypatch.setattr(sp, "_resolve_lane_device_names", lambda _i, _c: ["dev-a"])
monkeypatch.setattr(
"util.driver_delivery.deliver_json_messages",
fake_deliver,
)
fake_transport = types.ModuleType("models.transport")
fake_transport.get_current_bridge = lambda: object()
monkeypatch.setitem(sys.modules, "models.transport", fake_transport)
monkeypatch.setattr(
sp,
"_reset_after_sequence_change",
lambda: reset_log.extend(["route", "audio"]),
)
monkeypatch.setattr(
"util.beat_driver_route.clear_sequence_manual_lane_route",
fake_clear,
)
def test_prime_all_lanes_delivery_order(monkeypatch):
"""Sequence start: step-0 presets, select, rest presets, then beat/route reset."""
deliver_log: list = []
route_log: list = []
reset_log: list = []
_prime_all_lanes_test_mocks(monkeypatch, deliver_log, route_log, reset_log)
ctx = {
"lanes": [
[
{"preset_id": "p1", "beats": 2},
{"preset_id": "p2", "beats": 2},
]
],
"lane_states": [{"stepIdx": 0, "beatCount": 0, "done": False}],
"num_lanes": 1,
"sequence_doc": {
"lanes": [
[
{"preset_id": "p1", "beats": 2},
{"preset_id": "p2", "beats": 2},
]
],
"group_ids": ["g1"],
},
"zone_doc": {"group_ids": ["g1"], "brightness": 200},
"presets_map": {
"p1": {
"pattern": "solid",
"colors": ["#FF0000"],
"auto": True,
},
"p2": {
"pattern": "solid",
"colors": ["#00FF00"],
"auto": True,
},
},
"devices": object(),
"groups": object(),
"settings": {},
"palette_colors": [],
}
asyncio.run(sp._prime_all_lanes(ctx))
assert len(deliver_log) == 3
step0_msg = deliver_log[0]
select_msg = deliver_log[1]
rest_msg = deliver_log[2]
assert set(step0_msg["presets"]) == {"p1"}
assert select_msg["select"] == ["p1"]
assert set(rest_msg["presets"]) == {"p2"}
for body in deliver_log:
assert not ("presets" in body and "select" in body)
assert route_log == [("clear", 0)]
assert reset_log == ["route", "audio"]
assert ctx.get("_sequence_primed") is True
def test_prime_all_lanes_single_preset(monkeypatch):
"""One preset in the lane: step-0 presets, select, reset (no rest phase)."""
deliver_log: list = []
route_log: list = []
reset_log: list = []
_prime_all_lanes_test_mocks(monkeypatch, deliver_log, route_log, reset_log)
ctx = {
"lanes": [[{"preset_id": "p1", "beats": 2}]],
"lane_states": [{"stepIdx": 0, "beatCount": 0, "done": False}],
"num_lanes": 1,
"sequence_doc": {
"lanes": [[{"preset_id": "p1", "beats": 2}]],
"group_ids": ["g1"],
},
"zone_doc": {"group_ids": ["g1"], "brightness": 200},
"presets_map": {
"p1": {
"pattern": "solid",
"colors": ["#FF0000"],
"auto": True,
}
},
"devices": object(),
"groups": object(),
"settings": {},
"palette_colors": [],
}
asyncio.run(sp._prime_all_lanes(ctx))
assert len(deliver_log) == 2
assert set(deliver_log[0]["presets"]) == {"p1"}
assert deliver_log[1]["select"] == ["p1"]
assert route_log == [("clear", 0)]
assert reset_log == ["route", "audio"]
def test_prime_all_lanes_merges_same_groups(monkeypatch):
"""Lanes sharing group ids get one step-0 broadcast, not one per lane."""
deliver_log: list = []
route_log: list = []
reset_log: list = []
_prime_all_lanes_test_mocks(monkeypatch, deliver_log, route_log, reset_log)
ctx = {
"lanes": [
[{"preset_id": "p1", "beats": 2}],
[{"preset_id": "p2", "beats": 2}],
],
"lane_states": [
{"stepIdx": 0, "beatCount": 0, "done": False},
{"stepIdx": 0, "beatCount": 0, "done": False},
],
"num_lanes": 2,
"sequence_doc": {
"lanes": [
[{"preset_id": "p1", "beats": 2}],
[{"preset_id": "p2", "beats": 2}],
],
"lanes_group_ids": [["g1"], ["g1"]],
},
"zone_doc": {"group_ids": ["g1"], "brightness": 200},
"presets_map": {
"p1": {"pattern": "solid", "colors": ["#FF0000"], "auto": True},
"p2": {"pattern": "solid", "colors": ["#00FF00"], "auto": True},
},
"devices": object(),
"groups": object(),
"settings": {},
"palette_colors": [],
}
asyncio.run(sp._prime_all_lanes(ctx))
preset_msgs = [b for b in deliver_log if "presets" in b]
select_msgs = [b for b in deliver_log if "select" in b]
assert len(preset_msgs) == 1
assert set(preset_msgs[0]["presets"]) == {"p1", "p2"}
assert preset_msgs[0]["groups"] == ["g1"]
assert len(select_msgs) == 2
assert route_log == [("clear", 0), ("clear", 1)]

View File

@@ -0,0 +1,35 @@
#!/usr/bin/env python3
"""Tests for pushing group membership to all ESP-NOW devices."""
import asyncio
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
def test_push_groups_all_espnow_devices(monkeypatch):
from util import espnow_registry
class _Devices:
def items(self):
return [
("aabbccddeeff", {"transport": "espnow"}),
("wifi-1", {"transport": "wifi", "address": "192.168.1.1"}),
]
pushed = []
async def fake_push(mac):
pushed.append(mac)
return True
monkeypatch.setattr(espnow_registry, "Device", _Devices)
monkeypatch.setattr(espnow_registry, "push_groups_to_mac", fake_push)
result = asyncio.run(espnow_registry.push_groups_all_espnow_devices())
assert result["ok"] is True
assert result["sent"] == 1
assert result["total"] == 1
assert pushed == ["aabbccddeeff"]