feat(bridge): add wifi/serial bridge runtime and UI
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
16
tests/bridge_uart_sample.py
Normal file
16
tests/bridge_uart_sample.py
Normal 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)
|
||||
176
tests/test_audio_device_select.py
Normal file
176
tests/test_audio_device_select.py
Normal 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.")
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
48
tests/test_bridge_serial_frame.py
Normal file
48
tests/test_bridge_serial_frame.py
Normal 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]
|
||||
40
tests/test_bridge_wifi_connect.py
Normal file
40
tests/test_bridge_wifi_connect.py
Normal 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"
|
||||
@@ -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
45
tests/test_espnow_ping.py
Normal 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])
|
||||
@@ -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
|
||||
|
||||
66
tests/test_pi_wifi_scan.py
Normal file
66
tests/test_pi_wifi_scan.py
Normal file
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for nmcli Wi‑Fi 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 Wi‑Fi" 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 Wi‑Fi"
|
||||
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
|
||||
@@ -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)]
|
||||
|
||||
35
tests/test_update_groups.py
Normal file
35
tests/test_update_groups.py
Normal 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"]
|
||||
Reference in New Issue
Block a user