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,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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
173
tests/test_bridge_envelope.py
Normal file
173
tests/test_bridge_envelope.py
Normal 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
|
||||
36
tests/test_bridge_ws_client.py
Normal file
36
tests/test_bridge_ws_client.py
Normal 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()
|
||||
@@ -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",
|
||||
|
||||
51
tests/test_sequence_step_beats.py
Normal file
51
tests/test_sequence_step_beats.py
Normal 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
|
||||
Reference in New Issue
Block a user