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:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -2,7 +2,7 @@
|
||||
|
||||
This document describes how **led-controller**, the **bridge ESP32**, and **led-driver** devices work together. Wire-level byte layouts are in [espnow-binary-protocol.md](espnow-binary-protocol.md).
|
||||
|
||||
**On the wire:** binary only (no JSON) for ESP-NOW and Pi↔bridge WebSocket. The Pi web UI and `db/*.json` still use JSON internally.
|
||||
**Pi ↔ bridge WebSocket:** v1 **devices envelope** (JSON) — see [espnow-sender/msg.json](../espnow-sender/msg.json). **ESP-NOW over the air:** JSON driver payloads (≤250 bytes) or legacy binary (`0x4C` wire). The Pi web UI and `db/*.json` use JSON internally.
|
||||
|
||||
## System overview
|
||||
|
||||
@@ -10,8 +10,8 @@ This document describes how **led-controller**, the **bridge ESP32**, and **led-
|
||||
|
||||
| Component | Firmware / path | Role |
|
||||
|-----------|-----------------|------|
|
||||
| **led-controller** | Raspberry Pi, `src/` | Web app; WebSocket **client** to bridge; device registry; builds binary commands |
|
||||
| **Bridge** | [`espnow-sender/`](../espnow-sender/) | WebSocket **server** `/ws`; relays binary ↔ ESP-NOW; max **20** peers (LRU) |
|
||||
| **led-controller** | Raspberry Pi, `src/` | Web app; WebSocket **client** to bridge (auto-reconnect); device registry; builds devices envelope |
|
||||
| **Bridge** | [`espnow-sender/`](../espnow-sender/) | WebSocket **server** `/ws`; routes envelope per MAC; max **20** peers (LRU) |
|
||||
| **led-driver** | [`led-driver/`](../led-driver/) submodule | Boot **ANNOUNCE** broadcast; applies **GROUPS**, **CMD**, **GROUP_CMD** |
|
||||
|
||||
Configure the Pi in `settings.json`:
|
||||
@@ -34,25 +34,47 @@ Connect the Pi to the **bridge access point** (SSID = bridge `name` in `/setting
|
||||
1. Driver powers on and sends **ANNOUNCE** to broadcast MAC `ff:ff:ff:ff:ff:ff`.
|
||||
2. Bridge receives it and forwards a **WebSocket uplink** frame to the Pi (peer MAC + packet).
|
||||
3. Pi **upserts** the device in `db/device.json` (key = 12-char hex MAC).
|
||||
4. Pi scans `db/group.json` and builds a **GROUPS** packet.
|
||||
5. Pi sends **GROUPS** unicast to that MAC via the bridge.
|
||||
6. Driver stores group ids in RAM for **GROUP_CMD** filtering.
|
||||
4. Pi scans `db/group.json` and sends a **groups** envelope (`set_groups: true`) unicast to that MAC.
|
||||
5. Driver stores group ids in RAM (`device_groups`) for filtering.
|
||||
6. Pi bridge client **reconnects** automatically if the WebSocket drops (2 s backoff).
|
||||
|
||||
If the Pi or bridge is not up yet, the driver re-sends **ANNOUNCE** periodically until **GROUPS** arrives.
|
||||
|
||||
---
|
||||
|
||||
## Devices envelope (Pi → bridge)
|
||||
|
||||
```json
|
||||
{
|
||||
"v": "1",
|
||||
"dv": {
|
||||
"ff:ff:ff:ff:ff:ff": {
|
||||
"p": { "2": { "p": "on", "c": ["#FFFFFF"], "a": true } },
|
||||
"s": ["2", 0],
|
||||
"g": ["5", "18"],
|
||||
"sg": false,
|
||||
"sv": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Short wire names (long names still accepted on receive): `dv`=devices, `p`=presets, `s`=select (`["preset_id", step?]` — no device name), `g`=groups, `sg`=set_groups, `sv`=save, `df`=default; preset fields `p/c/d/b/a/bg/n1…`.
|
||||
|
||||
| `set_groups` | Destination | Bridge | Driver |
|
||||
|--------------|-------------|--------|--------|
|
||||
| `true` | any | Unicast only (expand `ff:ff:…` to all known peers) | `groups_replace`, then apply body |
|
||||
| `false` | `ff:ff:ff:ff:ff:ff` | ESP-NOW air broadcast | Apply only if device is in `groups` |
|
||||
| `false` | specific MAC | Unicast | Same group filter |
|
||||
|
||||
Legacy raw payloads (binary wire or plain v1 JSON without `devices`) are still **broadcast** by the bridge.
|
||||
|
||||
## Sending presets and commands
|
||||
|
||||

|
||||
|
||||
1. UI or API triggers a send (e.g. `POST /presets/send`).
|
||||
2. Pi builds one or more **CMD** packets (v2 binary envelope, chunked to ≤250 bytes).
|
||||
3. Each packet is wrapped in a **WebSocket downlink** frame (unicast MAC or broadcast).
|
||||
4. Bridge forwards on ESP-NOW.
|
||||
5. Driver parses and applies (presets, select, brightness, device_config, etc.).
|
||||
|
||||
For a **group**, Pi may send **GROUP_CMD** on broadcast once per chunk; only drivers that belong to that group apply the payload.
|
||||
1. UI or API triggers a send (e.g. `POST /presets/push`).
|
||||
2. Pi builds a **devices envelope** (or legacy binary) and sends it on the bridge WebSocket.
|
||||
3. Bridge routes each MAC entry to unicast or ESP-NOW broadcast per `set_groups`.
|
||||
4. Driver `process_data` applies presets, select (`[preset_id, step?]`; legacy name map still accepted), brightness, etc.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
{
|
||||
"v": "1",
|
||||
"devices": {
|
||||
"dv": {
|
||||
"ff:ff:ff:ff:ff:ff": {
|
||||
"presets": {
|
||||
"p": {
|
||||
"preset_id": {
|
||||
"pattern": "on",
|
||||
"colors": ["#FF0000"],
|
||||
"delay": 100,
|
||||
"brightness": 255,
|
||||
"auto": true
|
||||
"p": "on",
|
||||
"c": ["#FF0000"],
|
||||
"d": 100,
|
||||
"b": 255,
|
||||
"a": true
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"preset": "preset_id",
|
||||
"step": 0
|
||||
},
|
||||
"save": true,
|
||||
"default": "preset_id",
|
||||
"b": 255
|
||||
},
|
||||
"s": ["preset_id", 0],
|
||||
"sv": true,
|
||||
"df": "preset_id",
|
||||
"b": 255,
|
||||
"g": ["5", "18"],
|
||||
"sg": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
153
espnow-sender/src/downlink_router.py
Normal file
153
espnow-sender/src/downlink_router.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""Route Pi v1 devices envelope to ESP-NOW unicast or broadcast."""
|
||||
|
||||
import json
|
||||
import utime
|
||||
|
||||
from espnow_wire import BROADCAST_MAC
|
||||
from util import parse_mac
|
||||
from v1_wire import (
|
||||
ENV_DEVICES,
|
||||
K_PRESETS,
|
||||
K_SELECT,
|
||||
K_SET_GROUPS,
|
||||
_WIRE_KEYS,
|
||||
envelope_devices,
|
||||
normalize_body,
|
||||
)
|
||||
|
||||
MAX_ESPNOW_PAYLOAD = 250
|
||||
_CHUNK_DELAY_MS = 50
|
||||
|
||||
|
||||
def is_devices_envelope(raw):
|
||||
if not raw:
|
||||
return False
|
||||
if isinstance(raw, str):
|
||||
raw = raw.encode("utf-8")
|
||||
if raw[0:1] != b"{":
|
||||
return False
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
return (
|
||||
isinstance(data, dict)
|
||||
and data.get("v") == "1"
|
||||
and envelope_devices(data) is not None
|
||||
)
|
||||
|
||||
|
||||
def _encode_v1(fields):
|
||||
out = {"v": "1"}
|
||||
short = normalize_body(fields)
|
||||
for key in _WIRE_KEYS:
|
||||
if key in short:
|
||||
out[key] = short[key]
|
||||
return json.dumps(out, separators=(",", ":")).encode("utf-8")
|
||||
|
||||
|
||||
def _payload_len(fields):
|
||||
return len(_encode_v1(fields))
|
||||
|
||||
|
||||
def payloads_from_body(body):
|
||||
"""One or more ESP-NOW payloads (each <= MAX_ESPNOW_PAYLOAD)."""
|
||||
if not isinstance(body, dict):
|
||||
raise ValueError("device body must be object")
|
||||
short = normalize_body(body)
|
||||
if _payload_len(short) <= MAX_ESPNOW_PAYLOAD:
|
||||
return [_encode_v1(short)]
|
||||
|
||||
parts = []
|
||||
meta = {}
|
||||
for key in _WIRE_KEYS:
|
||||
if key in short and key not in (K_PRESETS, K_SELECT):
|
||||
meta[key] = short[key]
|
||||
|
||||
presets = short.get(K_PRESETS)
|
||||
select = short.get(K_SELECT)
|
||||
|
||||
if presets and isinstance(presets, dict):
|
||||
one = dict(meta)
|
||||
one[K_PRESETS] = presets
|
||||
if _payload_len(one) <= MAX_ESPNOW_PAYLOAD:
|
||||
parts.append(_encode_v1(one))
|
||||
else:
|
||||
for pid, pdata in presets.items():
|
||||
chunk = dict(meta)
|
||||
chunk[K_PRESETS] = {pid: pdata}
|
||||
if _payload_len(chunk) > MAX_ESPNOW_PAYLOAD:
|
||||
raise ValueError(
|
||||
"single preset too large (%d B)" % _payload_len(chunk)
|
||||
)
|
||||
parts.append(_encode_v1(chunk))
|
||||
|
||||
if select is not None:
|
||||
sel = dict(meta)
|
||||
sel.pop(K_SAVE, None)
|
||||
sel[K_SELECT] = select
|
||||
if _payload_len(sel) > MAX_ESPNOW_PAYLOAD:
|
||||
raise ValueError("select too large (%d B)" % _payload_len(sel))
|
||||
parts.append(_encode_v1(sel))
|
||||
|
||||
if not parts:
|
||||
raise ValueError("driver payload too large (%d B)" % _payload_len(short))
|
||||
return parts
|
||||
|
||||
|
||||
async def ensure_peer(esp, mac_bytes):
|
||||
try:
|
||||
esp.add_peer(mac_bytes)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def send_unicast(esp, peer_table, mac_bytes, payload):
|
||||
await ensure_peer(esp, mac_bytes)
|
||||
peer_table.touch(mac_bytes)
|
||||
await esp.asend(mac_bytes, payload)
|
||||
|
||||
|
||||
async def _send_payloads(esp, peer_table, dest, payloads):
|
||||
for i, payload in enumerate(payloads):
|
||||
if peer_table.is_broadcast_mac(dest):
|
||||
await ensure_peer(esp, BROADCAST_MAC)
|
||||
await esp.asend(BROADCAST_MAC, payload)
|
||||
else:
|
||||
await send_unicast(esp, peer_table, dest, payload)
|
||||
if i + 1 < len(payloads):
|
||||
utime.sleep_ms(_CHUNK_DELAY_MS)
|
||||
|
||||
|
||||
async def send_device_body(esp, peer_table, mac_str, body):
|
||||
dest = parse_mac(mac_str)
|
||||
payloads = payloads_from_body(body)
|
||||
set_groups = bool(body.get("set_groups") or body.get("sg"))
|
||||
|
||||
if set_groups:
|
||||
if peer_table.is_broadcast_mac(dest):
|
||||
targets = peer_table.peers()
|
||||
if not targets:
|
||||
print("set_groups: no peers yet")
|
||||
return
|
||||
for peer in targets:
|
||||
await _send_payloads(esp, peer_table, peer, payloads)
|
||||
else:
|
||||
await _send_payloads(esp, peer_table, dest, payloads)
|
||||
return
|
||||
|
||||
await _send_payloads(esp, peer_table, dest, payloads)
|
||||
|
||||
|
||||
async def route_envelope(esp, peer_table, raw):
|
||||
if isinstance(raw, str):
|
||||
raw = raw.encode("utf-8")
|
||||
data = json.loads(raw)
|
||||
devices = envelope_devices(data) or {}
|
||||
for mac_str, body in devices.items():
|
||||
try:
|
||||
await send_device_body(esp, peer_table, mac_str, body)
|
||||
except ValueError as err:
|
||||
print("downlink skip", mac_str, err)
|
||||
except Exception as err:
|
||||
print("downlink err", mac_str, err)
|
||||
@@ -22,6 +22,17 @@ def pack_ws_uplink(peer, espnow_packet):
|
||||
return bytes([0]) + peer + espnow_packet
|
||||
|
||||
|
||||
def pack_ws_downlink(espnow_packet, peer_mac=None, broadcast=False):
|
||||
flags = WS_FLAG_BROADCAST if broadcast else 0
|
||||
if broadcast:
|
||||
peer = BROADCAST_MAC
|
||||
else:
|
||||
if peer_mac is None or len(peer_mac) != 6:
|
||||
raise ValueError("peer MAC required for unicast downlink")
|
||||
peer = peer_mac
|
||||
return bytes([flags]) + peer + espnow_packet
|
||||
|
||||
|
||||
def parse_bridge_channel(pkt):
|
||||
if len(pkt) >= 3 and pkt[0] == WIRE_MAGIC and pkt[1] == MSG_BRIDGE_CH:
|
||||
return pkt[2]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from microdot import Microdot
|
||||
from microdot.websocket import WebSocketError, with_websocket
|
||||
@@ -8,6 +9,8 @@ import machine
|
||||
import network
|
||||
from settings import Settings
|
||||
from espnow_wire import BROADCAST_MAC, pack_ws_uplink
|
||||
from peer_table import PeerTable, load_max_peers
|
||||
from downlink_router import is_devices_envelope, route_envelope
|
||||
|
||||
wdt = machine.WDT(timeout=10000)
|
||||
wdt.feed()
|
||||
@@ -16,11 +19,11 @@ print(settings)
|
||||
|
||||
app = Microdot()
|
||||
|
||||
ch = settings.get("wifi_channel", 6)
|
||||
ch = settings.get("wifi_channel", 1)
|
||||
try:
|
||||
ch = max(1, min(11, int(ch)))
|
||||
except (TypeError, ValueError):
|
||||
ch = 6
|
||||
ch = 1
|
||||
|
||||
ap_if = network.WLAN(network.AP_IF)
|
||||
ap_if.active(True)
|
||||
@@ -39,9 +42,23 @@ esp = aioespnow.AIOESPNow()
|
||||
esp.active(True)
|
||||
esp.add_peer(BROADCAST_MAC)
|
||||
|
||||
peer_table = PeerTable(load_max_peers())
|
||||
clients = set()
|
||||
|
||||
|
||||
def _note_uplink_peer(host, msg):
|
||||
if host and len(host) == 6:
|
||||
name = None
|
||||
if msg and msg[0:1] == b"{":
|
||||
try:
|
||||
data = json.loads(msg)
|
||||
if isinstance(data, dict):
|
||||
name = data.get("name")
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
peer_table.touch(host, name)
|
||||
|
||||
|
||||
@app.route("/ws")
|
||||
@with_websocket
|
||||
async def ws(request, ws):
|
||||
@@ -55,8 +72,15 @@ async def ws(request, ws):
|
||||
break
|
||||
if not raw:
|
||||
break
|
||||
if isinstance(raw, str):
|
||||
raw = raw.encode("utf-8")
|
||||
try:
|
||||
await esp.asend(BROADCAST_MAC, raw)
|
||||
if is_devices_envelope(raw):
|
||||
await route_envelope(esp, peer_table, raw)
|
||||
else:
|
||||
await esp.asend(BROADCAST_MAC, raw)
|
||||
print(raw)
|
||||
print("ws tx", len(raw), "B")
|
||||
except Exception as err:
|
||||
print(err)
|
||||
break
|
||||
@@ -68,6 +92,7 @@ async def _espnow_receive_loop():
|
||||
async for host, msg in esp:
|
||||
if not host or not msg:
|
||||
continue
|
||||
_note_uplink_peer(host, msg)
|
||||
print("espnow rx", len(msg), "B")
|
||||
frame = pack_ws_uplink(host, msg)
|
||||
dead = []
|
||||
|
||||
43
espnow-sender/src/peer_table.py
Normal file
43
espnow-sender/src/peer_table.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""LRU table of ESP-NOW peer MACs seen on uplink."""
|
||||
|
||||
from espnow_wire import BROADCAST_MAC
|
||||
|
||||
try:
|
||||
from settings import Settings
|
||||
except ImportError:
|
||||
Settings = None
|
||||
|
||||
|
||||
class PeerTable:
|
||||
def __init__(self, max_peers=20):
|
||||
self._max = max(1, int(max_peers))
|
||||
self._order = []
|
||||
self._names = {}
|
||||
|
||||
def touch(self, mac_bytes, name=None):
|
||||
if not mac_bytes or len(mac_bytes) != 6:
|
||||
return
|
||||
if mac_bytes in self._order:
|
||||
self._order.remove(mac_bytes)
|
||||
elif len(self._order) >= self._max:
|
||||
old = self._order.pop(0)
|
||||
self._names.pop(old, None)
|
||||
self._order.append(mac_bytes)
|
||||
if name:
|
||||
self._names[mac_bytes] = str(name)
|
||||
|
||||
def peers(self):
|
||||
return list(self._order)
|
||||
|
||||
def is_broadcast_mac(self, mac_bytes):
|
||||
return mac_bytes == BROADCAST_MAC
|
||||
|
||||
|
||||
def load_max_peers():
|
||||
if Settings is None:
|
||||
return 20
|
||||
try:
|
||||
s = Settings()
|
||||
return int(s.get("max_peers", 20))
|
||||
except Exception:
|
||||
return 20
|
||||
@@ -40,7 +40,7 @@ class Settings(dict):
|
||||
def set_defaults(self):
|
||||
mac = _sta_mac_hex()
|
||||
self["name"] = "bridge-" + mac
|
||||
self["wifi_channel"] = 6
|
||||
self["wifi_channel"] = 1
|
||||
self["ap_password"] = ""
|
||||
self["ap_ip"] = "192.168.4.1"
|
||||
self["ws_port"] = 80
|
||||
|
||||
81
espnow-sender/src/v1_wire.py
Normal file
81
espnow-sender/src/v1_wire.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""Short v1 wire keys (MicroPython)."""
|
||||
|
||||
K_PRESETS = "p"
|
||||
K_SELECT = "s"
|
||||
K_GROUPS = "g"
|
||||
K_SET_GROUPS = "sg"
|
||||
K_SAVE = "sv"
|
||||
K_DEFAULT = "df"
|
||||
K_DEVICE_CONFIG = "dc"
|
||||
K_CLEAR_PRESETS = "cp"
|
||||
K_MANIFEST = "mf"
|
||||
ENV_DEVICES = "dv"
|
||||
|
||||
_LONG_TO_SHORT = {
|
||||
"presets": K_PRESETS,
|
||||
"select": K_SELECT,
|
||||
"groups": K_GROUPS,
|
||||
"set_groups": K_SET_GROUPS,
|
||||
"save": K_SAVE,
|
||||
"default": K_DEFAULT,
|
||||
"device_config": K_DEVICE_CONFIG,
|
||||
"clear_presets": K_CLEAR_PRESETS,
|
||||
"manifest": K_MANIFEST,
|
||||
}
|
||||
|
||||
def _normalize_select(val):
|
||||
if isinstance(val, list):
|
||||
return val
|
||||
if isinstance(val, str) and val.strip():
|
||||
return [val.strip()]
|
||||
if isinstance(val, dict) and "preset" in val:
|
||||
out = [val["preset"]]
|
||||
if "step" in val:
|
||||
out.append(val["step"])
|
||||
return out
|
||||
if isinstance(val, dict) and len(val) == 1:
|
||||
one = next(iter(val.values()))
|
||||
if isinstance(one, list):
|
||||
return one
|
||||
return val
|
||||
|
||||
|
||||
_WIRE_KEYS = (
|
||||
K_PRESETS,
|
||||
K_SELECT,
|
||||
K_SAVE,
|
||||
K_DEFAULT,
|
||||
"b",
|
||||
K_GROUPS,
|
||||
K_SET_GROUPS,
|
||||
K_DEVICE_CONFIG,
|
||||
K_CLEAR_PRESETS,
|
||||
K_MANIFEST,
|
||||
)
|
||||
|
||||
|
||||
def normalize_body(body):
|
||||
"""Long or short body → short keys for encoding."""
|
||||
if not isinstance(body, dict):
|
||||
return body
|
||||
out = {}
|
||||
for long_key, short_key in _LONG_TO_SHORT.items():
|
||||
if long_key in body:
|
||||
val = body[long_key]
|
||||
if long_key == "select":
|
||||
val = _normalize_select(val)
|
||||
out[short_key] = val
|
||||
elif short_key in body:
|
||||
out[short_key] = body[short_key]
|
||||
if "b" in body:
|
||||
out["b"] = body["b"]
|
||||
return out
|
||||
|
||||
|
||||
def envelope_devices(data):
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
devs = data.get("devices")
|
||||
if devs is None:
|
||||
devs = data.get(ENV_DEVICES)
|
||||
return devs if isinstance(devs, dict) else None
|
||||
Submodule led-driver updated: 1fdb2c9441...a97f6c7c2c
@@ -11,7 +11,6 @@ from models.transport import get_current_sender
|
||||
from settings import get_settings
|
||||
from util.brightness_combine import effective_brightness_for_mac
|
||||
from util.driver_patterns import driver_patterns_dir
|
||||
from util.binary_driver_messages import v1_dict_to_cmd_packet
|
||||
from util.espnow_message import build_message
|
||||
import asyncio
|
||||
import json
|
||||
@@ -142,13 +141,24 @@ def _http_post_pattern_source(ip, filename, code_text, reload_patterns=True, tim
|
||||
return b" 2" in first_line
|
||||
|
||||
|
||||
async def _identify_send_off_after_delay(sender, dev_id, name):
|
||||
async def _identify_send_off_after_delay(sender, dev_id):
|
||||
try:
|
||||
await asyncio.sleep(IDENTIFY_OFF_DELAY_S)
|
||||
pkt = v1_dict_to_cmd_packet(
|
||||
{"v": "1", "select": {name: ["off"]}},
|
||||
await sender.send(
|
||||
{"v": "1", "select": ["off"]},
|
||||
addr=dev_id,
|
||||
)
|
||||
await sender.send(pkt, addr=dev_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def _identify_send_off_after_delay_broadcast(sender, group_ids=None):
|
||||
try:
|
||||
await asyncio.sleep(IDENTIFY_OFF_DELAY_S)
|
||||
body = {"v": "1", "select": ["off"]}
|
||||
if group_ids:
|
||||
body["groups"] = [str(g) for g in group_ids if str(g).strip()]
|
||||
await sender.send(body)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -166,36 +176,35 @@ async def send_identify_to_device(dev_id: str) -> tuple[int, str]:
|
||||
sender = get_current_sender()
|
||||
if not sender:
|
||||
return 503, "Transport not configured"
|
||||
name = str(dev.get("name") or "").strip()
|
||||
if not name:
|
||||
return 400, "Device must have a name to identify"
|
||||
|
||||
try:
|
||||
pkt = v1_dict_to_cmd_packet(
|
||||
ok = await sender.send(
|
||||
{
|
||||
"v": "1",
|
||||
"presets": {_IDENTIFY_PRESET_KEY: dict(_IDENTIFY_DRIVER_PRESET)},
|
||||
"select": {name: [_IDENTIFY_PRESET_KEY]},
|
||||
}
|
||||
"select": [_IDENTIFY_PRESET_KEY],
|
||||
},
|
||||
addr=dev_id,
|
||||
)
|
||||
ok = await sender.send(pkt, addr=dev_id)
|
||||
if not ok:
|
||||
return 503, "Send failed"
|
||||
|
||||
asyncio.create_task(
|
||||
_identify_send_off_after_delay(sender, dev_id, name)
|
||||
_identify_send_off_after_delay(sender, dev_id)
|
||||
)
|
||||
except Exception as e:
|
||||
return 503, str(e)
|
||||
return 200, ""
|
||||
|
||||
|
||||
async def send_identify_to_group_devices(macs: list[str]) -> tuple[int, list[dict]]:
|
||||
async def send_identify_to_group_devices(
|
||||
macs: list[str],
|
||||
*,
|
||||
group_ids: list[str] | None = None,
|
||||
) -> tuple[int, list[dict]]:
|
||||
"""
|
||||
Identify every listed registry MAC in one delivery round: merged ``select`` and a single
|
||||
ESP-NOW split envelope when multiple peers share the serial bridge (avoids per-device
|
||||
``SerialSender`` lock serialisation). Wi-Fi peers are sent in parallel as in
|
||||
``deliver_json_messages``.
|
||||
Identify all drivers in ``group_ids`` via broadcast; members filter on ``groups``.
|
||||
|
||||
``macs`` is only used for the API ``sent`` count (group member list), not for addressing.
|
||||
"""
|
||||
from util.driver_delivery import deliver_json_messages
|
||||
|
||||
@@ -204,40 +213,37 @@ async def send_identify_to_group_devices(macs: list[str]) -> tuple[int, list[dic
|
||||
if not sender:
|
||||
return 0, [{"mac": "*", "error": "Transport not configured"}]
|
||||
|
||||
merged_select: dict[str, list[str]] = {}
|
||||
valid_macs: list[str] = []
|
||||
for dev_id in macs:
|
||||
dev = devices.read(dev_id)
|
||||
if not dev:
|
||||
errors.append({"mac": dev_id, "error": "Device not found"})
|
||||
continue
|
||||
name = str(dev.get("name") or "").strip()
|
||||
if not name:
|
||||
errors.append({"mac": dev_id, "error": "Device must have a name to identify"})
|
||||
continue
|
||||
merged_select[name] = [_IDENTIFY_PRESET_KEY]
|
||||
valid_macs.append(dev_id)
|
||||
|
||||
if not merged_select:
|
||||
return 0, errors
|
||||
body = {
|
||||
"v": "1",
|
||||
"presets": {_IDENTIFY_PRESET_KEY: dict(_IDENTIFY_DRIVER_PRESET)},
|
||||
"select": [_IDENTIFY_PRESET_KEY],
|
||||
}
|
||||
gids = [str(g).strip() for g in (group_ids or []) if str(g).strip()]
|
||||
if gids:
|
||||
body["groups"] = gids
|
||||
|
||||
try:
|
||||
msg = _compact_v1_json(
|
||||
presets={_IDENTIFY_PRESET_KEY: dict(_IDENTIFY_DRIVER_PRESET)},
|
||||
select=merged_select,
|
||||
deliveries, _chunks = await deliver_json_messages(
|
||||
sender,
|
||||
[json.dumps(body, separators=(",", ":"))],
|
||||
None,
|
||||
devices,
|
||||
delay_s=0,
|
||||
)
|
||||
await deliver_json_messages(sender, [msg], valid_macs, devices, delay_s=0)
|
||||
except Exception as e:
|
||||
return 0, errors + [{"mac": "*", "error": str(e)}]
|
||||
|
||||
for dev_id in valid_macs:
|
||||
dev = devices.read(dev_id) or {}
|
||||
name = str(dev.get("name") or "").strip()
|
||||
asyncio.create_task(
|
||||
_identify_send_off_after_delay(sender, dev_id, name)
|
||||
)
|
||||
if deliveries < 1:
|
||||
return 0, errors + [{"mac": "*", "error": "Send failed"}]
|
||||
|
||||
return len(valid_macs), errors
|
||||
asyncio.create_task(_identify_send_off_after_delay_broadcast(sender, gids))
|
||||
|
||||
seen: set[str] = set()
|
||||
for raw in macs:
|
||||
m = normalize_mac(str(raw))
|
||||
if m and m not in seen:
|
||||
seen.add(m)
|
||||
return len(seen), errors
|
||||
|
||||
|
||||
@controller.get("")
|
||||
@@ -448,14 +454,13 @@ async def push_device_output_brightness(request, id):
|
||||
zone_brightness=zb,
|
||||
)
|
||||
|
||||
pkt = v1_dict_to_cmd_packet({"v": "1", "b": b_val, "save": True})
|
||||
sender = get_current_sender()
|
||||
if not sender:
|
||||
return json.dumps({"error": "Transport not configured"}), 503, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
try:
|
||||
ok = await sender.send(pkt, addr=id)
|
||||
ok = await sender.send({"v": "1", "b": b_val, "save": True}, addr=id)
|
||||
if not ok:
|
||||
return json.dumps({"error": "Send failed"}), 503, {
|
||||
"Content-Type": "application/json",
|
||||
@@ -509,8 +514,7 @@ async def push_driver_config(request, id):
|
||||
"error": "Provide at least one of name, num_leds, color_order, startup_mode"
|
||||
}
|
||||
), 400, {"Content-Type": "application/json"}
|
||||
pkt = v1_dict_to_cmd_packet({"v": "1", "device_config": dc, "save": True})
|
||||
ok = await sender.send(pkt, addr=id)
|
||||
ok = await sender.send({"v": "1", "device_config": dc, "save": True}, addr=id)
|
||||
if not ok:
|
||||
return json.dumps({"error": "Send failed"}), 503, {
|
||||
"Content-Type": "application/json",
|
||||
|
||||
@@ -4,7 +4,6 @@ import asyncio
|
||||
from models.group import Group
|
||||
from models.device import Device
|
||||
from models.transport import get_current_sender
|
||||
from util.binary_driver_messages import v1_dict_to_cmd_packet
|
||||
from util.espnow_registry import push_groups_for_group_devices
|
||||
from settings import get_settings
|
||||
from util.brightness_combine import effective_brightness_for_mac
|
||||
@@ -221,7 +220,7 @@ async def push_group_driver_config(request, session, id):
|
||||
sender = get_current_sender()
|
||||
if not sender:
|
||||
return json.dumps({"error": "Transport not configured"}), 503
|
||||
pkt = v1_dict_to_cmd_packet({"v": "1", "device_config": dc, "save": True})
|
||||
body = {"v": "1", "device_config": dc, "save": True}
|
||||
for mac in mac_list:
|
||||
m = str(mac).strip().lower().replace(":", "").replace("-", "")
|
||||
if len(m) != 12:
|
||||
@@ -231,7 +230,7 @@ async def push_group_driver_config(request, session, id):
|
||||
errors.append({"mac": m, "error": "not in registry"})
|
||||
continue
|
||||
try:
|
||||
if await sender.send(pkt, addr=m):
|
||||
if await sender.send(body, addr=m):
|
||||
sent += 1
|
||||
else:
|
||||
errors.append({"mac": m, "error": "send failed"})
|
||||
@@ -271,13 +270,10 @@ async def push_group_output_brightness(request, session, id):
|
||||
m,
|
||||
zone_brightness=None,
|
||||
)
|
||||
pkt = v1_dict_to_cmd_packet(
|
||||
{"v": "1", "b": b_val, "save": True},
|
||||
)
|
||||
if not sender:
|
||||
return m, False, "transport not configured"
|
||||
try:
|
||||
ok = await sender.send(pkt, addr=m)
|
||||
ok = await sender.send({"v": "1", "b": b_val, "save": True}, addr=m)
|
||||
return m, bool(ok), None if ok else "send failed"
|
||||
except Exception as e:
|
||||
return m, False, str(e)
|
||||
@@ -342,7 +338,9 @@ async def identify_group_devices(request, session, id):
|
||||
{"message": "identify group done", "sent": 0, "errors": errors}
|
||||
), 200, {"Content-Type": "application/json"}
|
||||
|
||||
sent, batch_errors = await send_identify_to_group_devices(normalized)
|
||||
sent, batch_errors = await send_identify_to_group_devices(
|
||||
normalized, group_ids=[str(id)]
|
||||
)
|
||||
errors.extend(batch_errors)
|
||||
|
||||
return json.dumps(
|
||||
|
||||
@@ -5,9 +5,11 @@ from models.profile import Profile
|
||||
from models.pallet import Palette
|
||||
from models.device import Device, normalize_mac
|
||||
from models.transport import get_current_sender
|
||||
from util.driver_delivery import deliver_json_messages, deliver_preset_broadcast_then_per_device
|
||||
from util.driver_delivery import (
|
||||
build_preset_json_chunks,
|
||||
deliver_json_messages,
|
||||
)
|
||||
from util.espnow_message import build_message, build_preset_dict
|
||||
from util.binary_driver_messages import build_preset_cmd_chunks
|
||||
from util.profile_bundle import export_preset_bundle, import_preset_bundle
|
||||
import json
|
||||
|
||||
@@ -228,7 +230,7 @@ async def send_presets(request, session):
|
||||
|
||||
send_delay_s = 0.1
|
||||
total_presets = len(presets_by_name)
|
||||
chunk_messages = build_preset_cmd_chunks(
|
||||
chunk_messages = build_preset_json_chunks(
|
||||
presets_by_name,
|
||||
save=save_flag,
|
||||
default=str(default_id) if default_id is not None else None,
|
||||
@@ -249,20 +251,51 @@ async def send_presets(request, session):
|
||||
dm = normalize_mac(str(destination_mac))
|
||||
target_list = [dm] if dm else None
|
||||
|
||||
group_ids = data.get("group_ids") or data.get("groups")
|
||||
if isinstance(group_ids, list):
|
||||
group_ids = [str(g).strip() for g in group_ids if str(g).strip()]
|
||||
else:
|
||||
group_ids = None
|
||||
|
||||
unicast = bool(data.get("unicast")) or bool(destination_mac)
|
||||
|
||||
try:
|
||||
if target_list:
|
||||
deliveries = await deliver_preset_broadcast_then_per_device(
|
||||
sender,
|
||||
chunk_messages,
|
||||
target_list,
|
||||
Device(),
|
||||
str(default_id) if default_id is not None else None,
|
||||
delay_s=send_delay_s,
|
||||
)
|
||||
if unicast and target_list:
|
||||
deliveries = 0
|
||||
for msg in chunk_messages:
|
||||
d, _chunks = await deliver_json_messages(
|
||||
sender,
|
||||
[msg],
|
||||
target_list,
|
||||
Device(),
|
||||
delay_s=send_delay_s,
|
||||
unicast=True,
|
||||
)
|
||||
deliveries += d
|
||||
if default_id is not None:
|
||||
def_msg = json.dumps(
|
||||
{"v": "1", "default": str(default_id), "save": True},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
d, _chunks = await deliver_json_messages(
|
||||
sender,
|
||||
[def_msg],
|
||||
target_list,
|
||||
Device(),
|
||||
delay_s=send_delay_s,
|
||||
unicast=True,
|
||||
)
|
||||
deliveries += d
|
||||
else:
|
||||
wire_messages = []
|
||||
for msg in chunk_messages:
|
||||
body = json.loads(msg)
|
||||
if group_ids:
|
||||
body["groups"] = list(group_ids)
|
||||
wire_messages.append(json.dumps(body, separators=(",", ":")))
|
||||
deliveries, _chunks = await deliver_json_messages(
|
||||
sender,
|
||||
chunk_messages,
|
||||
wire_messages,
|
||||
None,
|
||||
Device(),
|
||||
delay_s=send_delay_s,
|
||||
@@ -315,13 +348,32 @@ async def push_driver_messages(request, session):
|
||||
return json.dumps({"error": "Transport not configured"}), 503, {'Content-Type': 'application/json'}
|
||||
|
||||
messages = []
|
||||
for item in seq:
|
||||
if isinstance(item, dict):
|
||||
messages.append(json.dumps(item))
|
||||
elif isinstance(item, str):
|
||||
messages.append(item)
|
||||
else:
|
||||
i = 0
|
||||
while i < len(seq):
|
||||
item = seq[i]
|
||||
if not isinstance(item, dict):
|
||||
if isinstance(item, str):
|
||||
messages.append(item)
|
||||
i += 1
|
||||
continue
|
||||
return json.dumps({"error": "sequence items must be objects or strings"}), 400, {'Content-Type': 'application/json'}
|
||||
nxt = seq[i + 1] if i + 1 < len(seq) else None
|
||||
if (
|
||||
isinstance(nxt, dict)
|
||||
and "presets" in item
|
||||
and "select" not in item
|
||||
and "select" in nxt
|
||||
and "presets" not in nxt
|
||||
):
|
||||
combined = dict(item)
|
||||
combined["select"] = nxt["select"]
|
||||
combined_str = json.dumps(combined, separators=(",", ":"))
|
||||
if len(combined_str.encode("utf-8")) <= 248:
|
||||
messages.append(combined_str)
|
||||
i += 2
|
||||
continue
|
||||
messages.append(json.dumps(item, separators=(",", ":")))
|
||||
i += 1
|
||||
|
||||
delay_s = data.get("delay_s", 0.05)
|
||||
try:
|
||||
@@ -329,6 +381,8 @@ async def push_driver_messages(request, session):
|
||||
except (TypeError, ValueError):
|
||||
delay_s = 0.05
|
||||
|
||||
unicast = bool(data.get("unicast"))
|
||||
|
||||
try:
|
||||
deliveries, _chunks = await deliver_json_messages(
|
||||
sender,
|
||||
@@ -336,6 +390,7 @@ async def push_driver_messages(request, session):
|
||||
target_list,
|
||||
Device(),
|
||||
delay_s=delay_s,
|
||||
unicast=unicast,
|
||||
)
|
||||
except Exception:
|
||||
return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'}
|
||||
|
||||
12
src/main.py
12
src/main.py
@@ -23,8 +23,7 @@ import controllers.led_tool as led_tool_controller
|
||||
from models.transport import get_sender, set_sender, get_current_sender
|
||||
from models.device import Device
|
||||
from models.bridge_ws_client import init_bridge_client
|
||||
from util.espnow_registry import handle_espnow_announce
|
||||
from util.binary_driver_messages import v1_dict_to_cmd_packet
|
||||
from util.espnow_registry import handle_bridge_uplink
|
||||
from util.audio_detector import AudioBeatDetector
|
||||
|
||||
|
||||
@@ -44,11 +43,11 @@ async def main(port=80):
|
||||
bridge_url = str(settings.get("bridge_ws_url") or "").strip()
|
||||
if bridge_url:
|
||||
try:
|
||||
ch = int(settings.get("wifi_channel", 6))
|
||||
ch = int(settings.get("wifi_channel", 1))
|
||||
except (TypeError, ValueError):
|
||||
ch = 6
|
||||
ch = 1
|
||||
bridge = init_bridge_client(bridge_url, wifi_channel=ch)
|
||||
bridge.set_uplink_handler(handle_espnow_announce)
|
||||
bridge.set_uplink_handler(handle_bridge_uplink)
|
||||
bridge.start()
|
||||
|
||||
app = Microdot()
|
||||
@@ -278,8 +277,7 @@ async def main(port=80):
|
||||
continue
|
||||
parsed = json.loads(data)
|
||||
addr = parsed.pop("to", None)
|
||||
pkt = v1_dict_to_cmd_packet(parsed)
|
||||
await sender.send(pkt, addr=addr)
|
||||
await sender.send(parsed, addr=addr)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
except Exception:
|
||||
|
||||
@@ -1,39 +1,47 @@
|
||||
"""Persistent WebSocket client to the ESP-NOW bridge (binary frames)."""
|
||||
"""Persistent WebSocket client to the ESP-NOW bridge."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Awaitable, Callable, Optional
|
||||
import json
|
||||
from typing import Awaitable, Callable, Optional, Union
|
||||
|
||||
import websockets
|
||||
from websockets.exceptions import ConnectionClosed
|
||||
|
||||
from util.espnow_wire import (
|
||||
MSG_ANNOUNCE,
|
||||
WIRE_MAGIC,
|
||||
pack_bridge_channel,
|
||||
pack_ws_downlink,
|
||||
parse_ws_frame,
|
||||
wire_msg_type,
|
||||
)
|
||||
from util.espnow_wire import parse_ws_frame
|
||||
|
||||
UplinkHandler = Callable[[bytes, bytes], Awaitable[None]]
|
||||
|
||||
|
||||
class BridgeWsClient:
|
||||
def __init__(self, url: str, *, wifi_channel: int = 6):
|
||||
def __init__(self, url: str, *, wifi_channel: int = 1, reconnect_delay_s: float = 2.0):
|
||||
self._url = url.strip()
|
||||
self._wifi_channel = wifi_channel
|
||||
self._reconnect_delay_s = reconnect_delay_s
|
||||
self._ws: Optional[websockets.WebSocketClientProtocol] = None
|
||||
self._send_lock = asyncio.Lock()
|
||||
self._uplink_handler: Optional[UplinkHandler] = None
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
self._connected = asyncio.Event()
|
||||
self._ack_waiter: Optional[asyncio.Future] = None
|
||||
self._disconnect_event = asyncio.Event()
|
||||
|
||||
def set_uplink_handler(self, handler: Optional[UplinkHandler]) -> None:
|
||||
self._uplink_handler = handler
|
||||
|
||||
def _signal_disconnect(self) -> None:
|
||||
self._connected.clear()
|
||||
self._disconnect_event.set()
|
||||
|
||||
async def _close_ws(self) -> None:
|
||||
ws = self._ws
|
||||
self._ws = None
|
||||
if ws is not None:
|
||||
try:
|
||||
await ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def run_forever(self) -> None:
|
||||
while True:
|
||||
try:
|
||||
@@ -42,9 +50,11 @@ class BridgeWsClient:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"[bridge] connection error: {e!r}")
|
||||
self._connected.clear()
|
||||
self._ws = None
|
||||
await asyncio.sleep(2.0)
|
||||
self._signal_disconnect()
|
||||
self._disconnect_event.clear()
|
||||
await self._close_ws()
|
||||
print("[bridge] disconnected, reconnecting...")
|
||||
await asyncio.sleep(self._reconnect_delay_s)
|
||||
|
||||
async def _reader_loop(self) -> None:
|
||||
ws = self._ws
|
||||
@@ -52,40 +62,41 @@ class BridgeWsClient:
|
||||
return
|
||||
try:
|
||||
async for message in ws:
|
||||
if isinstance(message, str):
|
||||
if self._uplink_handler is None:
|
||||
continue
|
||||
if len(message) == 1:
|
||||
fut = self._ack_waiter
|
||||
if fut is not None and not fut.done():
|
||||
fut.set_result(message[0] == 0x01)
|
||||
if isinstance(message, str):
|
||||
message = message.encode("utf-8")
|
||||
if not message:
|
||||
continue
|
||||
try:
|
||||
peer, pkt, _bcast = parse_ws_frame(message)
|
||||
except ValueError:
|
||||
continue
|
||||
if wire_msg_type(pkt) == MSG_ANNOUNCE and self._uplink_handler:
|
||||
await self._uplink_handler(peer, pkt)
|
||||
await self._uplink_handler(peer, pkt)
|
||||
except ConnectionClosed:
|
||||
pass
|
||||
finally:
|
||||
self._signal_disconnect()
|
||||
|
||||
async def _connect_once(self) -> None:
|
||||
print(f"[bridge] connecting to {self._url}")
|
||||
print(f"[bridge] connecting to {self._url} (channel {self._wifi_channel} on bridge)")
|
||||
async with websockets.connect(self._url, ping_interval=20, ping_timeout=20) as ws:
|
||||
self._ws = ws
|
||||
ch_pkt = pack_bridge_channel(self._wifi_channel)
|
||||
await ws.send(pack_ws_downlink(ch_pkt, broadcast=True))
|
||||
self._connected.set()
|
||||
self._disconnect_event.clear()
|
||||
print("[bridge] connected")
|
||||
reader = asyncio.create_task(self._reader_loop())
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(3600)
|
||||
while not self._disconnect_event.is_set():
|
||||
await asyncio.sleep(0.5)
|
||||
finally:
|
||||
reader.cancel()
|
||||
try:
|
||||
await reader
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def wait_connected(self, timeout: float = 30.0) -> bool:
|
||||
try:
|
||||
@@ -94,34 +105,35 @@ class BridgeWsClient:
|
||||
except asyncio.TimeoutError:
|
||||
return False
|
||||
|
||||
async def send_frame(self, frame: bytes) -> bool:
|
||||
await self._connected.wait()
|
||||
async def send_packet(self, packet: Union[bytes, str, dict]) -> bool:
|
||||
if isinstance(packet, dict):
|
||||
packet = json.dumps(packet, separators=(",", ":"))
|
||||
if isinstance(packet, str):
|
||||
packet = packet.encode("utf-8")
|
||||
if not await self.wait_connected(timeout=30.0):
|
||||
return False
|
||||
ws = self._ws
|
||||
if ws is None:
|
||||
return False
|
||||
async with self._send_lock:
|
||||
loop = asyncio.get_running_loop()
|
||||
self._ack_waiter = loop.create_future()
|
||||
try:
|
||||
await ws.send(frame)
|
||||
return bool(await asyncio.wait_for(self._ack_waiter, timeout=5.0))
|
||||
except (ConnectionClosed, asyncio.TimeoutError, OSError) as e:
|
||||
await ws.send(packet)
|
||||
return True
|
||||
except (ConnectionClosed, OSError) as e:
|
||||
print(f"[bridge] send failed: {e!r}")
|
||||
self._signal_disconnect()
|
||||
await self._close_ws()
|
||||
return False
|
||||
finally:
|
||||
self._ack_waiter = None
|
||||
|
||||
async def send_espnow(
|
||||
self,
|
||||
packet: bytes,
|
||||
*,
|
||||
peer_mac: Optional[str] = None,
|
||||
peer_mac: Optional[bytes] = None,
|
||||
broadcast: bool = False,
|
||||
) -> bool:
|
||||
if not packet or packet[0] != WIRE_MAGIC:
|
||||
raise ValueError("packet must be espnow wire format")
|
||||
frame = pack_ws_downlink(packet, peer_mac=peer_mac, broadcast=broadcast)
|
||||
return await self.send_frame(frame)
|
||||
del peer_mac, broadcast
|
||||
return await self.send_packet(packet)
|
||||
|
||||
def start(self) -> asyncio.Task:
|
||||
if self._task is None or self._task.done():
|
||||
@@ -136,7 +148,7 @@ def get_bridge_client() -> Optional[BridgeWsClient]:
|
||||
return _client
|
||||
|
||||
|
||||
def init_bridge_client(url: str, *, wifi_channel: int = 6) -> BridgeWsClient:
|
||||
def init_bridge_client(url: str, *, wifi_channel: int = 1) -> BridgeWsClient:
|
||||
global _client
|
||||
_client = BridgeWsClient(url, wifi_channel=wifi_channel)
|
||||
return _client
|
||||
|
||||
@@ -1,24 +1,18 @@
|
||||
"""Transport to LED drivers via ESP-NOW bridge WebSocket."""
|
||||
|
||||
import asyncio
|
||||
from typing import Optional, Union
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
from models.bridge_ws_client import get_bridge_client
|
||||
from util.espnow_wire import WIRE_MAGIC, pack_ws_downlink
|
||||
|
||||
BROADCAST_MAC_HEX = "ffffffffffff"
|
||||
|
||||
|
||||
def _parse_mac(addr) -> Optional[bytes]:
|
||||
if addr is None or addr == "":
|
||||
return None
|
||||
if isinstance(addr, bytes) and len(addr) == 6:
|
||||
return addr
|
||||
if isinstance(addr, str):
|
||||
s = addr.strip().lower().replace(":", "").replace("-", "")
|
||||
if len(s) == 12:
|
||||
return bytes.fromhex(s)
|
||||
return None
|
||||
from util.bridge_envelope import (
|
||||
BROADCAST_HEX,
|
||||
BROADCAST_MAC,
|
||||
build_devices_envelope,
|
||||
format_mac_key,
|
||||
is_broadcast_mac,
|
||||
normalize_mac_key,
|
||||
)
|
||||
from util.espnow_wire import WIRE_MAGIC
|
||||
|
||||
|
||||
class NullSender:
|
||||
@@ -29,25 +23,69 @@ class NullSender:
|
||||
|
||||
|
||||
class BridgeWsSender:
|
||||
"""Send binary ESP-NOW packets via bridge WebSocket client."""
|
||||
"""Send v1 JSON or devices envelope via bridge WebSocket."""
|
||||
|
||||
async def send(self, data: Union[bytes, str, dict], addr=None) -> bool:
|
||||
async def send(self, data: Union[bytes, str, Dict[str, Any]], addr=None) -> bool:
|
||||
client = get_bridge_client()
|
||||
if client is None:
|
||||
return False
|
||||
if isinstance(data, (bytes, bytearray)):
|
||||
|
||||
if isinstance(data, dict):
|
||||
if data.get("v") == "1" and ("devices" in data or "dv" in data):
|
||||
from util.v1_wire import compact_envelope
|
||||
|
||||
return await client.send_packet(compact_envelope(data))
|
||||
packet = json.dumps(data, separators=(",", ":")).encode("utf-8")
|
||||
elif isinstance(data, str):
|
||||
packet = data.encode("utf-8")
|
||||
elif isinstance(data, (bytes, bytearray)):
|
||||
packet = bytes(data)
|
||||
else:
|
||||
return False
|
||||
if not packet or packet[0] != WIRE_MAGIC:
|
||||
|
||||
if not packet:
|
||||
return False
|
||||
peer = _parse_mac(addr)
|
||||
broadcast = peer is None or addr == BROADCAST_MAC_HEX
|
||||
return await client.send_espnow(
|
||||
packet,
|
||||
peer_mac=peer,
|
||||
broadcast=broadcast,
|
||||
)
|
||||
|
||||
if packet[0] == WIRE_MAGIC:
|
||||
return await client.send_packet(packet)
|
||||
|
||||
if packet[0:1] != b"{":
|
||||
return False
|
||||
|
||||
mac_key = _addr_to_envelope_key(addr)
|
||||
if mac_key is None:
|
||||
return await client.send_packet(packet)
|
||||
|
||||
try:
|
||||
body = json.loads(packet.decode("utf-8"))
|
||||
except (UnicodeError, ValueError, TypeError):
|
||||
return False
|
||||
if not isinstance(body, dict) or body.get("v") != "1":
|
||||
return False
|
||||
|
||||
envelope = build_devices_envelope({mac_key: body})
|
||||
return await client.send_packet(envelope)
|
||||
|
||||
async def send_envelope(self, envelope: Dict[str, Any]) -> bool:
|
||||
client = get_bridge_client()
|
||||
if client is None:
|
||||
return False
|
||||
return await client.send_packet(envelope)
|
||||
|
||||
|
||||
def _addr_to_envelope_key(addr) -> Optional[str]:
|
||||
if addr is None:
|
||||
return BROADCAST_MAC
|
||||
s = str(addr).strip().lower()
|
||||
if is_broadcast_mac(s):
|
||||
return BROADCAST_MAC
|
||||
h = normalize_mac_key(s)
|
||||
if h:
|
||||
try:
|
||||
return format_mac_key(h)
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
_current_sender = None
|
||||
@@ -69,5 +107,5 @@ def get_sender(settings):
|
||||
"[startup] bridge disabled (set bridge_ws_url in settings.json, e.g. ws://192.168.4.1/ws)"
|
||||
)
|
||||
return NullSender()
|
||||
print(f"[startup] ESP-NOW via bridge WebSocket {url!r}")
|
||||
print(f"[startup] ESP-NOW via bridge WebSocket {url!r} (devices envelope)")
|
||||
return BridgeWsSender()
|
||||
|
||||
@@ -51,7 +51,7 @@ class Settings(dict):
|
||||
self.save()
|
||||
# ESP-NOW STA channel (2.4 GHz) for LED drivers / bridge alignment; 1–11
|
||||
if 'wifi_channel' not in self:
|
||||
self['wifi_channel'] = 6
|
||||
self['wifi_channel'] = 1
|
||||
# WebSocket URL of ESP-NOW bridge (Pi is client), e.g. ws://192.168.4.1/ws
|
||||
if 'bridge_ws_url' not in self:
|
||||
self['bridge_ws_url'] = ''
|
||||
|
||||
@@ -98,12 +98,17 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
: [];
|
||||
};
|
||||
|
||||
const postDriverSequence = async (sequence, targetMacs, delayS = 0.05) => {
|
||||
const body = {
|
||||
sequence,
|
||||
targets: Array.isArray(targetMacs) && targetMacs.length ? [...new Set(targetMacs)] : undefined,
|
||||
delay_s: delayS,
|
||||
};
|
||||
const postDriverSequence = async (sequence, targetMacs, delayS = 0.05, pushOptions = {}) => {
|
||||
if (typeof window.postDriverSequence === 'function') {
|
||||
return window.postDriverSequence(sequence, targetMacs, delayS, pushOptions);
|
||||
}
|
||||
const body = { sequence, delay_s: delayS };
|
||||
if (pushOptions && pushOptions.unicast === true) {
|
||||
body.unicast = true;
|
||||
if (Array.isArray(targetMacs) && targetMacs.length) {
|
||||
body.targets = [...new Set(targetMacs)];
|
||||
}
|
||||
}
|
||||
const res = await fetch('/presets/push', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
@@ -586,26 +591,28 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const select = {};
|
||||
deviceNames.forEach((name) => {
|
||||
if (name) {
|
||||
select[name] = zonePresetIds.slice();
|
||||
}
|
||||
});
|
||||
const targetMacs =
|
||||
typeof window.tabsManager !== 'undefined' &&
|
||||
typeof window.tabsManager.resolveTabDeviceMacs === 'function'
|
||||
? await window.tabsManager.resolveTabDeviceMacs(deviceNames)
|
||||
: [];
|
||||
const groupIds =
|
||||
typeof window.zonesManager !== 'undefined' &&
|
||||
typeof window.zonesManager.effectiveGroupIdsForZonePreset === 'function'
|
||||
? window.zonesManager.effectiveGroupIdsForZonePreset(zoneData)
|
||||
: Array.isArray(zoneData.group_ids)
|
||||
? zoneData.group_ids.map((g) => String(g).trim()).filter((g) => g.length > 0)
|
||||
: [];
|
||||
|
||||
const sequence = [
|
||||
{ v: '1', clear_presets: true, save: true },
|
||||
{ v: '1', presets: wirePresets, save: true },
|
||||
];
|
||||
if (Object.keys(select).length) {
|
||||
sequence.push({ v: '1', select });
|
||||
if (groupIds.length) {
|
||||
sequence[0].groups = groupIds;
|
||||
sequence[1].groups = groupIds;
|
||||
}
|
||||
await postDriverSequence(sequence, targetMacs, 0.05);
|
||||
if (deviceNames.length > 0 && zonePresetIds.length > 0) {
|
||||
const sel = { v: '1', select: zonePresetIds.slice() };
|
||||
if (groupIds.length) sel.groups = groupIds;
|
||||
sequence.push(sel);
|
||||
}
|
||||
await postDriverSequence(sequence, [], 0.05, { groupIds });
|
||||
} catch (error) {
|
||||
console.error('Send all patterns failed:', error);
|
||||
alert('Failed to send all patterns.');
|
||||
|
||||
@@ -176,6 +176,17 @@ function tabDeviceNamesFromSection(section) {
|
||||
: [];
|
||||
}
|
||||
|
||||
/** Group ids for preset broadcast targeting on a zone tab. */
|
||||
function zoneGroupIdsFromTabData(tabData) {
|
||||
const zm = window.zonesManager;
|
||||
if (zm && typeof zm.effectiveGroupIdsForZonePreset === 'function') {
|
||||
return zm.effectiveGroupIdsForZonePreset(tabData || {});
|
||||
}
|
||||
return Array.isArray(tabData && tabData.group_ids)
|
||||
? tabData.group_ids.map((g) => String(g).trim()).filter((g) => g.length > 0)
|
||||
: [];
|
||||
}
|
||||
|
||||
/** Device names for ``presetId`` on the current zone tab (zone ``group_ids`` for presets, else tab devices). */
|
||||
async function deviceNamesForPresetOnCurrentZone(presetId) {
|
||||
const section = document.querySelector('.presets-section[data-zone-id]');
|
||||
@@ -216,8 +227,13 @@ function formatPresetTargetGroupsLine(zoneDoc, groupsMap) {
|
||||
async function postDriverSequence(sequence, targetMacs, delayS, pushOptions) {
|
||||
const body = {
|
||||
sequence,
|
||||
targets: Array.isArray(targetMacs) && targetMacs.length ? [...new Set(targetMacs)] : undefined,
|
||||
};
|
||||
if (pushOptions && pushOptions.unicast === true) {
|
||||
body.unicast = true;
|
||||
if (Array.isArray(targetMacs) && targetMacs.length) {
|
||||
body.targets = [...new Set(targetMacs)];
|
||||
}
|
||||
}
|
||||
if (delayS != null && delayS >= 0) {
|
||||
body.delay_s = delayS;
|
||||
}
|
||||
@@ -1361,12 +1377,17 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const targetMacs =
|
||||
typeof window.tabsManager !== 'undefined' &&
|
||||
typeof window.tabsManager.resolveTabDeviceMacs === 'function'
|
||||
? await window.tabsManager.resolveTabDeviceMacs(deviceNames)
|
||||
: [];
|
||||
await postDriverSequence([{ v: '1', clear_presets: true, save: true }], targetMacs);
|
||||
const zoneId = section && section.dataset.zoneId;
|
||||
let groupIds = [];
|
||||
if (zoneId) {
|
||||
const zr = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } });
|
||||
if (zr.ok) {
|
||||
groupIds = zoneGroupIdsFromTabData(await zr.json());
|
||||
}
|
||||
}
|
||||
const clearMsg = { v: '1', clear_presets: true, save: true };
|
||||
if (groupIds.length) clearMsg.groups = groupIds;
|
||||
await postDriverSequence([clearMsg], [], 0.05, { groupIds });
|
||||
} catch (error) {
|
||||
console.error('Clear device presets failed:', error);
|
||||
alert('Failed to clear presets on devices.');
|
||||
@@ -2040,29 +2061,23 @@ const sendPresetViaEspNow = async (
|
||||
presetMessage.default = wirePresetId;
|
||||
}
|
||||
|
||||
const names = Array.isArray(deviceNames) ? deviceNames : [];
|
||||
const targetMacs =
|
||||
names.length > 0 &&
|
||||
typeof window.tabsManager !== 'undefined' &&
|
||||
typeof window.tabsManager.resolveTabDeviceMacs === 'function'
|
||||
? await window.tabsManager.resolveTabDeviceMacs(names)
|
||||
: [];
|
||||
|
||||
const sequence = [presetMessage];
|
||||
// Auto: apply preset immediately via select. Manual: load definition only — first step is on the next audio beat.
|
||||
if (names.length > 0 && presetAuto) {
|
||||
const select = {};
|
||||
names.forEach((name) => {
|
||||
if (name) {
|
||||
select[name] = [wirePresetId];
|
||||
}
|
||||
});
|
||||
if (Object.keys(select).length > 0) {
|
||||
sequence.push({ v: '1', select });
|
||||
}
|
||||
const forceSelect = pushOptions && pushOptions.select === true;
|
||||
const shouldSelect =
|
||||
forceSelect || (pushOptions && pushOptions.select === false ? false : presetAuto);
|
||||
// Apply on driver in the same message as presets (split on bridge keeps presets before select).
|
||||
if (shouldSelect) {
|
||||
presetMessage.select = [wirePresetId];
|
||||
}
|
||||
|
||||
await postDriverSequence(sequence, targetMacs, 0.05, pushOptions);
|
||||
const groupIds =
|
||||
pushOptions && Array.isArray(pushOptions.groupIds)
|
||||
? pushOptions.groupIds.map((g) => String(g).trim()).filter((g) => g.length > 0)
|
||||
: [];
|
||||
if (groupIds.length > 0) {
|
||||
presetMessage.groups = groupIds;
|
||||
}
|
||||
|
||||
await postDriverSequence([presetMessage], [], 0.05, pushOptions);
|
||||
} catch (error) {
|
||||
console.error('Failed to send preset to devices:', error);
|
||||
alert('Failed to send preset to devices.');
|
||||
@@ -2106,17 +2121,13 @@ const sendPresetSelectViaEspNow = async (presetId, deviceNames) => {
|
||||
if (!nameTargets.length) {
|
||||
return;
|
||||
}
|
||||
const select = {};
|
||||
nameTargets.forEach((name) => {
|
||||
select[name] = [String(presetId)];
|
||||
});
|
||||
const macTargets =
|
||||
nameTargets.length > 0 &&
|
||||
typeof window.tabsManager !== 'undefined' &&
|
||||
typeof window.tabsManager.resolveTabDeviceMacs === 'function'
|
||||
? await window.tabsManager.resolveTabDeviceMacs(nameTargets)
|
||||
: [];
|
||||
await postDriverSequence([{ v: '1', select }], macTargets);
|
||||
await postDriverSequence([{ v: '1', select: [String(presetId)] }], macTargets);
|
||||
};
|
||||
|
||||
// Expose for other scripts (zones.js) so they can reuse the shared WebSocket.
|
||||
@@ -2168,11 +2179,16 @@ async function sendZonePresetSelection(zoneId, tabData, presetId, preset, allPre
|
||||
const pid = String(presetId);
|
||||
const body = (allPresets && allPresets[pid]) || preset;
|
||||
if (!body) return;
|
||||
const zm = window.zonesManager;
|
||||
const names =
|
||||
window.zonesManager && typeof window.zonesManager.resolveDeviceNamesForZonePreset === 'function'
|
||||
? await window.zonesManager.resolveDeviceNamesForZonePreset(tabData, pid)
|
||||
zm && typeof zm.resolveDeviceNamesForZonePreset === 'function'
|
||||
? await zm.resolveDeviceNamesForZonePreset(tabData, pid)
|
||||
: [];
|
||||
await sendPresetViaEspNow(pid, body, names, false, false, '2');
|
||||
const groupIds = zoneGroupIdsFromTabData(tabData);
|
||||
await sendPresetViaEspNow(pid, body, names, false, false, '2', {
|
||||
select: true,
|
||||
groupIds,
|
||||
});
|
||||
}
|
||||
|
||||
// Store selected preset per zone
|
||||
|
||||
@@ -107,6 +107,7 @@ function sendZoneBrightness(zoneId, value) {
|
||||
[{ v: '1', b: bv, save: true }],
|
||||
[mac],
|
||||
0,
|
||||
{ unicast: true },
|
||||
);
|
||||
}
|
||||
return;
|
||||
@@ -304,6 +305,18 @@ async function resolveDeviceNamesForZonePreset(zoneDoc, presetId) {
|
||||
return Array.isArray(zt.names) ? zt.names.slice() : [];
|
||||
}
|
||||
|
||||
/** Registry MACs for preset push (same targeting as ``resolveDeviceNamesForZonePreset``). */
|
||||
async function resolveMacsForZonePreset(zoneDoc, presetId) {
|
||||
void presetId;
|
||||
const gids = effectiveGroupIdsForZonePreset(zoneDoc);
|
||||
if (gids.length) {
|
||||
const t = await resolveTargetsFromGroupIds(gids);
|
||||
if (t.macs.length) return [...new Set(t.macs)];
|
||||
}
|
||||
const zt = await computeZoneTargets(zoneDoc);
|
||||
return Array.isArray(zt.macs) ? [...new Set(zt.macs.filter(Boolean))] : [];
|
||||
}
|
||||
|
||||
/** Union of devices targeted by standalone presets on the zone (same as zone preset targeting). */
|
||||
async function computeZonePresetUnionTargets(zoneDoc) {
|
||||
return await computeZoneTargets(zoneDoc);
|
||||
@@ -951,13 +964,15 @@ async function sendProfilePresets() {
|
||||
continue;
|
||||
}
|
||||
zonesWithPresets += 1;
|
||||
const targets = await resolveZoneDeviceMacsFromZoneData(tabData);
|
||||
const payload = { preset_ids: presetIds };
|
||||
if (tabData.default_preset) {
|
||||
payload.default = tabData.default_preset;
|
||||
}
|
||||
if (targets.length > 0) {
|
||||
payload.targets = targets;
|
||||
const gids = Array.isArray(tabData.group_ids)
|
||||
? tabData.group_ids.map((g) => String(g).trim()).filter((g) => g.length > 0)
|
||||
: [];
|
||||
if (gids.length > 0) {
|
||||
payload.group_ids = gids;
|
||||
}
|
||||
const response = await fetch('/presets/send', {
|
||||
method: 'POST',
|
||||
@@ -1425,6 +1440,7 @@ window.zonesManager = {
|
||||
computeZonePresetUnionTargets,
|
||||
effectiveGroupIdsForZonePreset,
|
||||
resolveDeviceNamesForZonePreset,
|
||||
resolveMacsForZonePreset,
|
||||
resolveSequenceStepDeviceNames,
|
||||
fetchGroupsMap,
|
||||
renderZoneGroupsEditor,
|
||||
|
||||
@@ -232,10 +232,12 @@ def _apply_manual_beat_route(
|
||||
device_names: List[str],
|
||||
wire_preset_id: str,
|
||||
preset_body: Any,
|
||||
group_ids: Optional[List[str]] = None,
|
||||
) -> None:
|
||||
"""Enable audio→driver routing for one manual preset (clears all lanes, including sequence)."""
|
||||
global _lane_manual
|
||||
if not device_names:
|
||||
gids = [str(g).strip() for g in (group_ids or []) if str(g).strip()]
|
||||
if not device_names and not gids:
|
||||
with _route_lock:
|
||||
_lane_manual.clear()
|
||||
_sync_public_beat_route_from_lane_table()
|
||||
@@ -265,6 +267,7 @@ def _apply_manual_beat_route(
|
||||
"pattern": pattern,
|
||||
"manual_beat_n": _coerce_manual_beat_n(preset_body),
|
||||
"beat_counter": 0,
|
||||
"group_ids": gids,
|
||||
}
|
||||
_sync_public_beat_route_from_lane_table()
|
||||
|
||||
@@ -273,10 +276,12 @@ def _apply_manual_beat_route_standalone_overlay(
|
||||
device_names: List[str],
|
||||
wire_preset_id: str,
|
||||
preset_body: Any,
|
||||
group_ids: Optional[List[str]] = None,
|
||||
) -> None:
|
||||
"""Register manual beat routing on lane ``-1`` only, keeping sequence lanes ``0..n`` intact."""
|
||||
global _lane_manual
|
||||
if not device_names:
|
||||
gids = [str(g).strip() for g in (group_ids or []) if str(g).strip()]
|
||||
if not device_names and not gids:
|
||||
with _route_lock:
|
||||
_lane_manual.pop(-1, None)
|
||||
_sync_public_beat_route_from_lane_table()
|
||||
@@ -309,6 +314,7 @@ def _apply_manual_beat_route_standalone_overlay(
|
||||
"pattern": pattern,
|
||||
"manual_beat_n": _coerce_manual_beat_n(preset_body),
|
||||
"beat_counter": 0,
|
||||
"group_ids": gids,
|
||||
}
|
||||
_sync_public_beat_route_from_lane_table()
|
||||
|
||||
@@ -318,11 +324,13 @@ def set_sequence_manual_lane_route(
|
||||
device_names: List[str],
|
||||
wire_preset_id: str,
|
||||
preset_body: Any,
|
||||
group_ids: Optional[List[str]] = None,
|
||||
) -> None:
|
||||
"""Register or update one sequence lane's manual beat route (parallel lanes, independent strides)."""
|
||||
global _lane_manual
|
||||
names = [str(n).strip() for n in (device_names or []) if str(n).strip()]
|
||||
if not names or not isinstance(preset_body, dict) or _coerce_auto_from_body(preset_body):
|
||||
gids = [str(g).strip() for g in (group_ids or []) if str(g).strip()]
|
||||
if (not names and not gids) or not isinstance(preset_body, dict) or _coerce_auto_from_body(preset_body):
|
||||
with _route_lock:
|
||||
if lane_index in _lane_manual:
|
||||
del _lane_manual[lane_index]
|
||||
@@ -353,6 +361,7 @@ def set_sequence_manual_lane_route(
|
||||
"pattern": pattern,
|
||||
"manual_beat_n": mn,
|
||||
"beat_counter": bc,
|
||||
"group_ids": gids,
|
||||
}
|
||||
overlay = _lane_manual.get(-1)
|
||||
if overlay and _lane_route_targets_key(names, wid) == _lane_route_targets_key(
|
||||
@@ -423,7 +432,8 @@ def sync_beat_route_from_push_sequence(
|
||||
"""
|
||||
Update beat routing from a ``/presets/push`` body ``sequence`` (list of v1 dicts).
|
||||
|
||||
With a ``select`` map: use its keys as device names (existing behaviour).
|
||||
With ``select`` as ``[preset_id, step?]``: use ``target_macs`` for device names.
|
||||
Legacy name-map ``select`` still uses map keys as device names.
|
||||
|
||||
Without ``select`` (e.g. manual preset loaded without immediate select): if ``target_macs``
|
||||
is set and the merged ``presets`` contain exactly one manual preset, enable routing using
|
||||
@@ -435,7 +445,9 @@ def sync_beat_route_from_push_sequence(
|
||||
sequence lanes ``0..n`` keep their stride counters and wire ids.
|
||||
"""
|
||||
merged_presets: Dict[str, Any] = {}
|
||||
last_select: Optional[Dict[str, Any]] = None
|
||||
last_select_list: Optional[List[Any]] = None
|
||||
last_select_map: Optional[Dict[str, Any]] = None
|
||||
last_group_ids: Optional[List[str]] = None
|
||||
for item in sequence:
|
||||
if isinstance(item, str):
|
||||
try:
|
||||
@@ -448,11 +460,27 @@ def sync_beat_route_from_push_sequence(
|
||||
if isinstance(pr, dict):
|
||||
merged_presets.update(pr)
|
||||
sel = item.get("select")
|
||||
if isinstance(sel, dict) and sel:
|
||||
last_select = sel
|
||||
if isinstance(sel, list) and sel:
|
||||
last_select_list = sel
|
||||
elif isinstance(sel, dict) and sel:
|
||||
last_select_map = sel
|
||||
gr = item.get("groups")
|
||||
if isinstance(gr, list) and gr:
|
||||
last_group_ids = [str(g).strip() for g in gr if str(g).strip()]
|
||||
|
||||
if last_select:
|
||||
device_names = [str(k).strip() for k in last_select.keys() if str(k).strip()]
|
||||
if last_select_list:
|
||||
device_names = _registry_names_for_macs(target_macs)
|
||||
if not device_names and not last_group_ids:
|
||||
if not preserve_parallel_lane_routes:
|
||||
update_beat_route({"enabled": False})
|
||||
return
|
||||
wire_preset_id = str(last_select_list[0]).strip()
|
||||
if not wire_preset_id:
|
||||
if not preserve_parallel_lane_routes:
|
||||
update_beat_route({"enabled": False})
|
||||
return
|
||||
elif last_select_map:
|
||||
device_names = [str(k).strip() for k in last_select_map.keys() if str(k).strip()]
|
||||
if not device_names:
|
||||
if not preserve_parallel_lane_routes:
|
||||
update_beat_route({"enabled": False})
|
||||
@@ -460,7 +488,7 @@ def sync_beat_route_from_push_sequence(
|
||||
|
||||
wire_ids: Set[str] = set()
|
||||
for name in device_names:
|
||||
val = last_select.get(name)
|
||||
val = last_select_map.get(name)
|
||||
if isinstance(val, list) and val:
|
||||
wire_ids.add(str(val[0]).strip())
|
||||
elif val is not None:
|
||||
@@ -470,6 +498,10 @@ def sync_beat_route_from_push_sequence(
|
||||
update_beat_route({"enabled": False})
|
||||
return
|
||||
wire_preset_id = wire_ids.pop()
|
||||
else:
|
||||
wire_preset_id = None
|
||||
|
||||
if wire_preset_id is not None:
|
||||
preset_body = merged_presets.get(wire_preset_id)
|
||||
if preset_body is None:
|
||||
for k, v in merged_presets.items():
|
||||
@@ -486,10 +518,12 @@ def sync_beat_route_from_push_sequence(
|
||||
return
|
||||
if preserve_parallel_lane_routes:
|
||||
_apply_manual_beat_route_standalone_overlay(
|
||||
device_names, wire_preset_id, preset_body
|
||||
device_names, wire_preset_id, preset_body, group_ids=last_group_ids
|
||||
)
|
||||
else:
|
||||
_apply_manual_beat_route(device_names, wire_preset_id, preset_body)
|
||||
_apply_manual_beat_route(
|
||||
device_names, wire_preset_id, preset_body, group_ids=last_group_ids
|
||||
)
|
||||
mark_manual_select_sent_for_targets(device_names, wire_preset_id)
|
||||
return
|
||||
|
||||
@@ -497,9 +531,11 @@ def sync_beat_route_from_push_sequence(
|
||||
if wire_id and body is not None:
|
||||
names = _registry_names_for_macs(target_macs)
|
||||
if preserve_parallel_lane_routes:
|
||||
_apply_manual_beat_route_standalone_overlay(names, wire_id, body)
|
||||
_apply_manual_beat_route_standalone_overlay(
|
||||
names, wire_id, body, group_ids=last_group_ids
|
||||
)
|
||||
else:
|
||||
_apply_manual_beat_route(names, wire_id, body)
|
||||
_apply_manual_beat_route(names, wire_id, body, group_ids=last_group_ids)
|
||||
return
|
||||
|
||||
if not preserve_parallel_lane_routes:
|
||||
@@ -553,9 +589,11 @@ def remap_beat_route_device_name(old_name: str, new_name: str) -> None:
|
||||
_sync_public_beat_route_from_lane_table()
|
||||
|
||||
|
||||
async def _deliver_select(device_names: List[str], wire_preset_id: str) -> None:
|
||||
async def _deliver_select(
|
||||
wire_preset_id: str,
|
||||
group_ids: Optional[List[str]] = None,
|
||||
) -> None:
|
||||
from models.device import Device
|
||||
from models.device import resolve_device_mac_for_select_routing
|
||||
from models.transport import get_current_sender
|
||||
from util.driver_delivery import deliver_json_messages
|
||||
|
||||
@@ -563,39 +601,30 @@ async def _deliver_select(device_names: List[str], wire_preset_id: str) -> None:
|
||||
if not sender:
|
||||
return
|
||||
devices = Device()
|
||||
seen_macs: List[str] = []
|
||||
seen_set: Set[str] = set()
|
||||
for n in device_names:
|
||||
mac = resolve_device_mac_for_select_routing(devices, n)
|
||||
if mac and mac not in seen_set:
|
||||
seen_set.add(mac)
|
||||
seen_macs.append(mac)
|
||||
if not seen_macs:
|
||||
return
|
||||
select: Dict[str, Any] = {}
|
||||
for mac in seen_macs:
|
||||
doc = devices.read(mac) or {}
|
||||
nm = str(doc.get("name") or "").strip()
|
||||
if nm:
|
||||
select[nm] = [wire_preset_id]
|
||||
if not select:
|
||||
return
|
||||
msg = json.dumps({"v": "1", "select": select}, separators=(",", ":"))
|
||||
gids = [str(g).strip() for g in (group_ids or []) if str(g).strip()]
|
||||
body: Dict[str, Any] = {"v": "1", "select": [wire_preset_id]}
|
||||
if gids:
|
||||
body["groups"] = gids
|
||||
msg = json.dumps(body, separators=(",", ":"))
|
||||
try:
|
||||
await deliver_json_messages(sender, [msg], seen_macs, devices, delay_s=0.05)
|
||||
await deliver_json_messages(sender, [msg], None, devices, delay_s=0.05)
|
||||
except Exception as e:
|
||||
print(f"[beat-route] deliver failed: {e}")
|
||||
|
||||
|
||||
async def _deliver_select_batch(pairs: List[Tuple[List[str], str]]) -> None:
|
||||
for names, pid in pairs:
|
||||
await _deliver_select(names, pid)
|
||||
async def _deliver_select_batch(pairs: List[Tuple[str, Optional[List[str]]]]) -> None:
|
||||
for pid, gids in pairs:
|
||||
await _deliver_select(pid, gids)
|
||||
|
||||
|
||||
def notify_beat_detected() -> None:
|
||||
"""Invoked from the audio thread when a beat is detected."""
|
||||
"""Invoked from the audio thread when a beat is detected.
|
||||
|
||||
Only manual presets are registered in ``_lane_manual`` (auto presets are cleared on step
|
||||
change and get ``select`` from sequence/UI only when the preset changes).
|
||||
"""
|
||||
global _preset_session_beats
|
||||
work: List[Tuple[List[str], str]] = []
|
||||
work: List[Tuple[str, Optional[List[str]]]] = []
|
||||
with _route_lock:
|
||||
if not _lane_manual:
|
||||
return
|
||||
@@ -604,7 +633,15 @@ def notify_beat_detected() -> None:
|
||||
for key in sorted(_lane_manual.keys()):
|
||||
e = _lane_manual[key]
|
||||
names = e.get("device_names") or []
|
||||
if not isinstance(names, list) or not names:
|
||||
if not isinstance(names, list):
|
||||
names = []
|
||||
gids_raw = e.get("group_ids") or []
|
||||
gids = (
|
||||
[str(g).strip() for g in gids_raw if str(g).strip()]
|
||||
if isinstance(gids_raw, list)
|
||||
else []
|
||||
)
|
||||
if not names and not gids:
|
||||
continue
|
||||
pattern = str(e.get("pattern") or "")
|
||||
if pattern and not _pattern_supports_manual(pattern):
|
||||
@@ -621,11 +658,13 @@ def notify_beat_detected() -> None:
|
||||
if (c - 1) % n != 0:
|
||||
continue
|
||||
wire = str(e.get("wire_preset_id") or "2")
|
||||
target_key = _lane_route_targets_key(names, wire)
|
||||
target_key = (
|
||||
(tuple(sorted(gids)), wire) if gids else _lane_route_targets_key(names, wire)
|
||||
)
|
||||
if target_key in seen_targets:
|
||||
continue
|
||||
seen_targets.add(target_key)
|
||||
work.append((list(names), wire))
|
||||
work.append((wire, gids or None))
|
||||
if work:
|
||||
_preset_session_beats += 1
|
||||
if not work:
|
||||
|
||||
151
src/util/bridge_envelope.py
Normal file
151
src/util/bridge_envelope.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""Build v1 devices envelope for Pi → bridge WebSocket (short wire keys)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
from util.v1_wire import (
|
||||
ENV_DEVICES,
|
||||
K_GROUPS,
|
||||
K_SAVE,
|
||||
K_SET_GROUPS,
|
||||
compact_body,
|
||||
compact_envelope,
|
||||
wire_json_size,
|
||||
)
|
||||
|
||||
BROADCAST_MAC = "ff:ff:ff:ff:ff:ff"
|
||||
BROADCAST_HEX = "ffffffffffff"
|
||||
MAX_ESPNOW_PAYLOAD = 250
|
||||
|
||||
|
||||
def normalize_mac_key(mac: Optional[str]) -> Optional[str]:
|
||||
if mac is None:
|
||||
return None
|
||||
s = str(mac).strip().lower().replace(":", "").replace("-", "")
|
||||
if len(s) == 12 and all(c in "0123456789abcdef" for c in s):
|
||||
return s
|
||||
return None
|
||||
|
||||
|
||||
def format_mac_key(mac_hex: str) -> str:
|
||||
h = normalize_mac_key(mac_hex)
|
||||
if not h:
|
||||
raise ValueError("invalid mac")
|
||||
return ":".join(h[i : i + 2] for i in range(0, 12, 2))
|
||||
|
||||
|
||||
def is_broadcast_mac(mac: Optional[str]) -> bool:
|
||||
h = normalize_mac_key(mac)
|
||||
return h == BROADCAST_HEX
|
||||
|
||||
|
||||
def build_devices_envelope(devices: Dict[str, Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""Wrap per-MAC bodies in a v1 envelope (short ``dv`` key)."""
|
||||
compact_devices = {
|
||||
mac: compact_body(body) for mac, body in devices.items() if isinstance(body, dict)
|
||||
}
|
||||
return {"v": "1", ENV_DEVICES: compact_devices}
|
||||
|
||||
|
||||
def build_groups_envelope(mac_hex: str, group_ids: List[str]) -> Dict[str, Any]:
|
||||
key = format_mac_key(mac_hex)
|
||||
return build_devices_envelope(
|
||||
{
|
||||
key: {
|
||||
K_GROUPS: [str(g) for g in group_ids],
|
||||
K_SET_GROUPS: True,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def build_v1_body(
|
||||
*,
|
||||
presets: Optional[Dict[str, Any]] = None,
|
||||
select: Optional[Union[List[Any], Dict[str, Any], str]] = None,
|
||||
save: bool = False,
|
||||
default: Optional[str] = None,
|
||||
brightness: Optional[int] = None,
|
||||
groups: Optional[List[str]] = None,
|
||||
set_groups: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
body: Dict[str, Any] = {}
|
||||
if presets:
|
||||
body["presets"] = presets
|
||||
if select is not None:
|
||||
body["select"] = select
|
||||
if save:
|
||||
body["save"] = True
|
||||
if default is not None:
|
||||
body["default"] = str(default)
|
||||
if brightness is not None:
|
||||
body["b"] = max(0, min(255, int(brightness)))
|
||||
if groups is not None:
|
||||
body["groups"] = [str(g) for g in groups]
|
||||
if set_groups:
|
||||
body["set_groups"] = True
|
||||
return compact_body(body)
|
||||
|
||||
|
||||
def v1_body_size(body: Dict[str, Any]) -> int:
|
||||
return wire_json_size({"v": "1", **compact_body(body)})
|
||||
|
||||
|
||||
def envelope_payload_size(envelope: Dict[str, Any]) -> int:
|
||||
return wire_json_size(compact_envelope(envelope))
|
||||
|
||||
|
||||
def split_v1_body_for_espnow(body: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
"""Split a device body into chunks each <= MAX_ESPNOW_PAYLOAD bytes on the wire."""
|
||||
from util.v1_wire import K_PRESETS, K_SAVE, K_SELECT, expand_body
|
||||
|
||||
long_body = expand_body(body)
|
||||
compact = compact_body(long_body)
|
||||
if v1_body_size(long_body) <= MAX_ESPNOW_PAYLOAD:
|
||||
return [compact]
|
||||
|
||||
chunks: List[Dict[str, Any]] = []
|
||||
meta = {k: v for k, v in compact.items() if k not in (K_PRESETS, K_SELECT)}
|
||||
presets = compact.get(K_PRESETS)
|
||||
select = compact.get(K_SELECT)
|
||||
|
||||
if presets and isinstance(presets, dict):
|
||||
preset_msg = {**meta, K_PRESETS: presets}
|
||||
if wire_json_size({"v": "1", **preset_msg}) <= MAX_ESPNOW_PAYLOAD:
|
||||
chunks.append(preset_msg)
|
||||
else:
|
||||
for pid, pdata in presets.items():
|
||||
one = {**meta, K_PRESETS: {pid: pdata}}
|
||||
if wire_json_size({"v": "1", **one}) > MAX_ESPNOW_PAYLOAD:
|
||||
raise ValueError(f"preset {pid!r} too large for ESP-NOW")
|
||||
chunks.append(one)
|
||||
|
||||
if select is not None:
|
||||
sel_meta = {k: v for k, v in meta.items() if k != K_SAVE}
|
||||
sel_msg = {**sel_meta, K_SELECT: select}
|
||||
if wire_json_size({"v": "1", **sel_msg}) > MAX_ESPNOW_PAYLOAD:
|
||||
raise ValueError("select payload too large for ESP-NOW")
|
||||
chunks.append(sel_msg)
|
||||
|
||||
if not chunks:
|
||||
raise ValueError("device body too large to split for ESP-NOW")
|
||||
return chunks
|
||||
|
||||
|
||||
def merge_preset_and_select(
|
||||
preset_body: Dict[str, Any],
|
||||
select_body: Dict[str, Any],
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Merge preset + select bodies if combined envelope fits ESP-NOW limit."""
|
||||
merged = dict(preset_body)
|
||||
if "select" in select_body:
|
||||
merged["select"] = select_body["select"]
|
||||
for key in ("groups", "set_groups"):
|
||||
if key in select_body and key not in merged:
|
||||
merged[key] = select_body[key]
|
||||
env = build_devices_envelope({BROADCAST_MAC: merged})
|
||||
if envelope_payload_size(env) <= MAX_ESPNOW_PAYLOAD:
|
||||
return compact_body(merged)
|
||||
return None
|
||||
@@ -1,13 +1,97 @@
|
||||
"""Deliver binary ESP-NOW messages via bridge WebSocket."""
|
||||
"""Deliver v1 JSON to drivers via bridge devices envelope."""
|
||||
|
||||
import asyncio
|
||||
from typing import List, Optional, Union
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
from models.device import normalize_mac
|
||||
from util.binary_driver_messages import build_preset_cmd_chunks, v1_dict_to_cmd_packet
|
||||
from util.espnow_wire import BROADCAST_MAC, pack_group_cmd
|
||||
from util.bridge_envelope import (
|
||||
BROADCAST_MAC,
|
||||
build_devices_envelope,
|
||||
format_mac_key,
|
||||
normalize_mac_key,
|
||||
split_v1_body_for_espnow,
|
||||
)
|
||||
from util.espnow_message import build_message
|
||||
from util.espnow_wire import WIRE_MAGIC, pack_group_cmd
|
||||
|
||||
_BROADCAST_HEX = "ffffffffffff"
|
||||
_MAX_JSON_ESPNOW = 240
|
||||
|
||||
|
||||
def v1_message_bytes(body: Dict[str, Any]) -> bytes:
|
||||
return json.dumps(body, separators=(",", ":")).encode("utf-8")
|
||||
|
||||
|
||||
def _body_from_message(msg: Union[str, bytes, bytearray, Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
||||
if isinstance(msg, dict):
|
||||
if msg.get("v") == "1" and "devices" not in msg:
|
||||
return dict(msg)
|
||||
return None
|
||||
if isinstance(msg, str):
|
||||
try:
|
||||
data = json.loads(msg)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
if isinstance(msg, (bytes, bytearray)):
|
||||
raw = bytes(msg)
|
||||
if not raw or raw[0] != ord("{"):
|
||||
return None
|
||||
try:
|
||||
data = json.loads(raw.decode("utf-8"))
|
||||
except (UnicodeError, ValueError, TypeError):
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
return None
|
||||
|
||||
|
||||
async def deliver_envelope(sender, envelope: Dict[str, Any], delay_s: float = 0.1) -> int:
|
||||
if not envelope or not isinstance(envelope.get("devices"), dict):
|
||||
return 0
|
||||
if await sender.send(envelope):
|
||||
if delay_s > 0:
|
||||
await asyncio.sleep(delay_s)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
async def _deliver_v1_body(sender, mac_key: str, body: Dict[str, Any], delay_s: float) -> int:
|
||||
deliveries = 0
|
||||
try:
|
||||
chunks = split_v1_body_for_espnow(body)
|
||||
except ValueError:
|
||||
return 0
|
||||
for chunk in chunks:
|
||||
env = build_devices_envelope({mac_key: chunk})
|
||||
if await sender.send(env):
|
||||
deliveries += 1
|
||||
if delay_s > 0:
|
||||
await asyncio.sleep(delay_s)
|
||||
return deliveries
|
||||
|
||||
|
||||
async def deliver_packets(
|
||||
sender,
|
||||
packets: List[bytes],
|
||||
*,
|
||||
delay_s: float = 0.1,
|
||||
target_macs: Optional[List[str]] = None,
|
||||
unicast: bool = False,
|
||||
) -> int:
|
||||
if not packets:
|
||||
return 0
|
||||
deliveries = 0
|
||||
mac_keys = _unicast_mac_keys(target_macs) if unicast and target_macs else [BROADCAST_MAC]
|
||||
for mac_key in mac_keys:
|
||||
for pkt in packets:
|
||||
body = _body_from_message(pkt)
|
||||
if body:
|
||||
deliveries += await _deliver_v1_body(sender, mac_key, body, delay_s)
|
||||
else:
|
||||
if await sender.send(pkt):
|
||||
deliveries += 1
|
||||
if delay_s > 0:
|
||||
await asyncio.sleep(delay_s)
|
||||
return deliveries
|
||||
|
||||
|
||||
async def deliver_binary_packets(
|
||||
@@ -16,33 +100,11 @@ async def deliver_binary_packets(
|
||||
target_macs: Optional[List[str]] = None,
|
||||
*,
|
||||
delay_s: float = 0.1,
|
||||
unicast: bool = False,
|
||||
) -> int:
|
||||
"""Send binary CMD packets unicast per MAC or broadcast when no targets."""
|
||||
if not packets:
|
||||
return 0
|
||||
deliveries = 0
|
||||
if not target_macs:
|
||||
for pkt in packets:
|
||||
if await sender.send(pkt, addr=_BROADCAST_HEX):
|
||||
deliveries += 1
|
||||
await asyncio.sleep(delay_s)
|
||||
return deliveries
|
||||
|
||||
seen = set()
|
||||
ordered: List[str] = []
|
||||
for raw in target_macs:
|
||||
m = normalize_mac(str(raw)) if raw else None
|
||||
if not m or m in seen:
|
||||
continue
|
||||
seen.add(m)
|
||||
ordered.append(m)
|
||||
|
||||
for pkt in packets:
|
||||
for mac in ordered:
|
||||
if await sender.send(pkt, addr=mac):
|
||||
deliveries += 1
|
||||
await asyncio.sleep(delay_s)
|
||||
return deliveries
|
||||
return await deliver_packets(
|
||||
sender, packets, delay_s=delay_s, target_macs=target_macs, unicast=unicast
|
||||
)
|
||||
|
||||
|
||||
async def deliver_group_binary_packets(
|
||||
@@ -52,7 +114,7 @@ async def deliver_group_binary_packets(
|
||||
*,
|
||||
delay_s: float = 0.1,
|
||||
) -> int:
|
||||
"""Broadcast GROUP_CMD packets (one ESP-NOW send per packet)."""
|
||||
"""Broadcast GROUP_CMD wire packets (legacy binary passthrough on bridge)."""
|
||||
from util.espnow_wire import parse_cmd
|
||||
|
||||
deliveries = 0
|
||||
@@ -64,12 +126,54 @@ async def deliver_group_binary_packets(
|
||||
g_pkt = pack_group_cmd(str(group_id), env, save=save)
|
||||
except ValueError:
|
||||
continue
|
||||
if await sender.send(g_pkt, addr=_BROADCAST_HEX):
|
||||
if await sender.send(g_pkt):
|
||||
deliveries += 1
|
||||
await asyncio.sleep(delay_s)
|
||||
return deliveries
|
||||
|
||||
|
||||
def build_preset_json_chunks(
|
||||
presets_by_name: Dict[str, Any],
|
||||
*,
|
||||
save: bool = False,
|
||||
default: Optional[str] = None,
|
||||
max_payload: int = _MAX_JSON_ESPNOW,
|
||||
) -> List[str]:
|
||||
entries = list(presets_by_name.items())
|
||||
chunks: List[str] = []
|
||||
batch: Dict[str, Any] = {}
|
||||
|
||||
def _msg_for(presets_map: Dict[str, Any], *, final_save: bool, def_id: Optional[str]) -> str:
|
||||
return build_message(
|
||||
presets=presets_map,
|
||||
save=final_save,
|
||||
default=def_id,
|
||||
)
|
||||
|
||||
for name, preset_obj in entries:
|
||||
trial = dict(batch)
|
||||
trial[name] = preset_obj
|
||||
try:
|
||||
msg = _msg_for(trial, final_save=False, def_id=None)
|
||||
except (TypeError, ValueError):
|
||||
msg = ""
|
||||
if len(msg.encode("utf-8")) <= max_payload or not batch:
|
||||
batch = trial
|
||||
else:
|
||||
chunks.append(_msg_for(batch, final_save=False, def_id=None))
|
||||
batch = {name: preset_obj}
|
||||
|
||||
if batch:
|
||||
chunks.append(
|
||||
_msg_for(
|
||||
batch,
|
||||
final_save=save,
|
||||
def_id=str(default) if default else None,
|
||||
)
|
||||
)
|
||||
return [c for c in chunks if c]
|
||||
|
||||
|
||||
async def deliver_preset_broadcast_then_per_device(
|
||||
sender,
|
||||
chunk_messages,
|
||||
@@ -78,88 +182,59 @@ async def deliver_preset_broadcast_then_per_device(
|
||||
default_id,
|
||||
delay_s=0.1,
|
||||
):
|
||||
"""
|
||||
chunk_messages: list of v1 JSON strings OR binary CMD bytes.
|
||||
Converts JSON strings to binary when needed.
|
||||
"""
|
||||
packets: List[bytes] = []
|
||||
del devices_model, target_macs
|
||||
deliveries = 0
|
||||
for msg in chunk_messages:
|
||||
if isinstance(msg, (bytes, bytearray)):
|
||||
packets.append(bytes(msg))
|
||||
else:
|
||||
import json
|
||||
|
||||
try:
|
||||
body = json.loads(msg)
|
||||
except Exception:
|
||||
continue
|
||||
if isinstance(body, dict):
|
||||
packets.append(v1_dict_to_cmd_packet(body))
|
||||
|
||||
if not packets:
|
||||
return 0
|
||||
|
||||
seen = set()
|
||||
ordered = []
|
||||
for raw in target_macs:
|
||||
m = normalize_mac(str(raw)) if raw else None
|
||||
if not m or m in seen:
|
||||
body = _body_from_message(msg)
|
||||
if not body:
|
||||
continue
|
||||
seen.add(m)
|
||||
ordered.append(m)
|
||||
|
||||
deliveries = await deliver_binary_packets(
|
||||
sender, packets, ordered, delay_s=delay_s
|
||||
)
|
||||
deliveries += await _deliver_v1_body(sender, BROADCAST_MAC, body, delay_s)
|
||||
|
||||
if default_id:
|
||||
did = str(default_id)
|
||||
for mac in ordered:
|
||||
doc = devices_model.read(mac) or {}
|
||||
name = str(doc.get("name") or "").strip() or mac
|
||||
body = {"v": "1", "default": did, "save": True, "targets": [name]}
|
||||
pkt = v1_dict_to_cmd_packet(body)
|
||||
if await sender.send(pkt, addr=mac):
|
||||
deliveries += 1
|
||||
await asyncio.sleep(delay_s)
|
||||
body = {"default": str(default_id), "save": True}
|
||||
deliveries += await _deliver_v1_body(sender, BROADCAST_MAC, body, delay_s)
|
||||
|
||||
return deliveries
|
||||
|
||||
|
||||
async def deliver_json_messages(sender, messages, target_macs, devices_model, delay_s=0.1):
|
||||
"""
|
||||
Convert v1 JSON message strings to binary CMD packets and deliver.
|
||||
Returns (delivery_count, chunk_count).
|
||||
"""
|
||||
packets: List[bytes] = []
|
||||
import json
|
||||
|
||||
for msg in messages:
|
||||
if isinstance(msg, (bytes, bytearray)):
|
||||
packets.append(bytes(msg))
|
||||
continue
|
||||
try:
|
||||
body = json.loads(msg)
|
||||
except Exception:
|
||||
continue
|
||||
if isinstance(body, dict):
|
||||
packets.append(v1_dict_to_cmd_packet(body))
|
||||
|
||||
if not packets:
|
||||
return 0, 0
|
||||
|
||||
def _unicast_mac_keys(target_macs: Optional[List[str]]) -> List[str]:
|
||||
"""One formatted MAC per target; empty list means broadcast."""
|
||||
if not target_macs:
|
||||
n = await deliver_binary_packets(sender, packets, None, delay_s=delay_s)
|
||||
return n, len(packets)
|
||||
|
||||
seen = set()
|
||||
ordered_macs = []
|
||||
return [BROADCAST_MAC]
|
||||
keys: List[str] = []
|
||||
seen: set = set()
|
||||
for raw in target_macs:
|
||||
m = normalize_mac(str(raw)) if raw else None
|
||||
if not m or m in seen:
|
||||
continue
|
||||
seen.add(m)
|
||||
ordered_macs.append(m)
|
||||
h = normalize_mac_key(raw)
|
||||
if h and h not in seen:
|
||||
seen.add(h)
|
||||
keys.append(format_mac_key(h))
|
||||
return keys if keys else [BROADCAST_MAC]
|
||||
|
||||
n = await deliver_binary_packets(sender, packets, ordered_macs, delay_s=delay_s)
|
||||
return n, len(packets)
|
||||
|
||||
async def deliver_json_messages(
|
||||
sender,
|
||||
messages,
|
||||
target_macs,
|
||||
devices_model,
|
||||
delay_s=0.1,
|
||||
*,
|
||||
unicast: bool = False,
|
||||
):
|
||||
"""
|
||||
Deliver v1 JSON to drivers. Default: ESP-NOW broadcast (``ff:ff:…``); drivers
|
||||
filter on ``groups`` in the body. Set ``unicast=True`` only for per-device settings
|
||||
or single-device identify.
|
||||
"""
|
||||
del devices_model
|
||||
deliveries = 0
|
||||
if unicast and target_macs:
|
||||
mac_keys = _unicast_mac_keys(target_macs)
|
||||
else:
|
||||
mac_keys = [BROADCAST_MAC]
|
||||
for mac_key in mac_keys:
|
||||
for msg in messages:
|
||||
body = _body_from_message(msg)
|
||||
if not body:
|
||||
continue
|
||||
deliveries += await _deliver_v1_body(sender, mac_key, body, delay_s)
|
||||
return deliveries, len(messages)
|
||||
|
||||
@@ -55,27 +55,22 @@ def build_message(presets=None, select=None, save=False, default=None):
|
||||
return json.dumps(message)
|
||||
|
||||
|
||||
def build_select_message(device_name, preset_name, step=None):
|
||||
def build_select_list(preset_name, step=None):
|
||||
"""
|
||||
Build a select message for a single device.
|
||||
|
||||
Args:
|
||||
device_name: Name of the device
|
||||
preset_name: Name of the preset to select
|
||||
step: Optional step value for synchronization
|
||||
|
||||
Returns:
|
||||
Dictionary with select field ready to use in build_message
|
||||
|
||||
Example:
|
||||
select = build_select_message("device1", "rainbow_preset", step=10)
|
||||
message = build_message(select=select)
|
||||
Build a select list for one driver (unicast / per-MAC envelope).
|
||||
|
||||
Wire shape: ``["preset_id"]`` or ``["preset_id", step]`` — no device name.
|
||||
"""
|
||||
select_list = [preset_name]
|
||||
select_list = [str(preset_name)]
|
||||
if step is not None:
|
||||
select_list.append(step)
|
||||
|
||||
return {device_name: select_list}
|
||||
return select_list
|
||||
|
||||
|
||||
def build_select_message(device_name, preset_name, step=None):
|
||||
"""Legacy name-map select; prefer :func:`build_select_list` for ESP-NOW."""
|
||||
del device_name
|
||||
return build_select_list(preset_name, step=step)
|
||||
|
||||
|
||||
def _hex_from_background_raw(bg_raw):
|
||||
|
||||
@@ -1,14 +1,92 @@
|
||||
"""Handle ESP-NOW ANNOUNCE uplink and push GROUPS to drivers."""
|
||||
"""Handle ESP-NOW uplink from bridge and push group membership."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from models.device import Device, normalize_mac # noqa: F401 — re-export for callers
|
||||
from models.group import Group
|
||||
from models.bridge_ws_client import get_bridge_client
|
||||
from util.espnow_wire import mac_bytes_to_hex, pack_groups, parse_announce
|
||||
from models.transport import get_current_sender
|
||||
from util.bridge_envelope import build_groups_envelope
|
||||
from util.espnow_wire import (
|
||||
MSG_ANNOUNCE,
|
||||
WIRE_MAGIC,
|
||||
mac_bytes_to_hex,
|
||||
parse_announce,
|
||||
wire_msg_type,
|
||||
)
|
||||
from util.groups_for_device import groups_for_mac
|
||||
|
||||
|
||||
async def handle_bridge_uplink(peer_mac: bytes, payload: bytes) -> None:
|
||||
"""Dispatch binary wire or JSON v1 hello from bridge uplink."""
|
||||
if not payload:
|
||||
return
|
||||
if payload[0] == WIRE_MAGIC:
|
||||
if wire_msg_type(payload) == MSG_ANNOUNCE:
|
||||
await handle_espnow_announce(peer_mac, payload)
|
||||
return
|
||||
if payload[:1] == b"{":
|
||||
try:
|
||||
data = json.loads(payload.decode("utf-8"))
|
||||
except (UnicodeError, ValueError, TypeError):
|
||||
return
|
||||
if isinstance(data, dict):
|
||||
await handle_json_hello(peer_mac, data)
|
||||
|
||||
|
||||
async def _after_device_registered(mac_hex: str) -> None:
|
||||
await push_groups_to_mac(mac_hex)
|
||||
|
||||
|
||||
async def handle_json_hello(peer_mac: bytes, data: Dict[str, Any]) -> None:
|
||||
"""Register device from driver JSON boot hello."""
|
||||
if data.get("v") != "1":
|
||||
return
|
||||
mac_hex = mac_bytes_to_hex(peer_mac)
|
||||
if not mac_hex:
|
||||
return
|
||||
|
||||
name = data.get("name")
|
||||
nested = data.get("settings")
|
||||
if not name and isinstance(nested, dict):
|
||||
name = nested.get("name")
|
||||
name = str(name or mac_hex).strip() or mac_hex
|
||||
|
||||
num_leds = None
|
||||
color_order = None
|
||||
startup_mode = None
|
||||
brightness = None
|
||||
if isinstance(nested, dict):
|
||||
try:
|
||||
num_leds = int(nested.get("num_leds")) if nested.get("num_leds") is not None else None
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
color_order = nested.get("color_order")
|
||||
startup_mode = nested.get("startup_mode")
|
||||
try:
|
||||
brightness = int(nested.get("brightness")) if nested.get("brightness") is not None else None
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
devices = Device()
|
||||
did, persisted = devices.upsert_espnow_announced(
|
||||
mac_hex,
|
||||
name,
|
||||
device_type=data.get("type", "led"),
|
||||
num_leds=num_leds,
|
||||
color_order=color_order,
|
||||
startup_mode=startup_mode,
|
||||
brightness=brightness,
|
||||
)
|
||||
if not did:
|
||||
return
|
||||
if persisted:
|
||||
print(f"[espnow] registered mac={did} name={name!r} (json hello)")
|
||||
await _after_device_registered(mac_hex)
|
||||
|
||||
|
||||
async def handle_espnow_announce(peer_mac: bytes, packet: bytes) -> None:
|
||||
info = parse_announce(packet)
|
||||
if not info:
|
||||
@@ -31,24 +109,13 @@ async def handle_espnow_announce(peer_mac: bytes, packet: bytes) -> None:
|
||||
return
|
||||
if persisted:
|
||||
print(f"[espnow] registered mac={did} name={info['name']!r}")
|
||||
|
||||
groups = Group()
|
||||
gids = groups_for_mac(did, groups)
|
||||
groups_pkt = pack_groups(gids)
|
||||
|
||||
client = get_bridge_client()
|
||||
if client is None:
|
||||
print("[espnow] bridge client not configured; groups not sent")
|
||||
return
|
||||
ok = await client.send_espnow(groups_pkt, peer_mac=peer_mac)
|
||||
if ok:
|
||||
print(f"[espnow] groups -> {did}: {gids}")
|
||||
else:
|
||||
print(f"[espnow] groups send failed for {did}")
|
||||
await _after_device_registered(mac_hex)
|
||||
|
||||
|
||||
async def push_groups_for_group_devices(gdoc: dict) -> None:
|
||||
"""Refresh GROUPS on every MAC listed on a group document."""
|
||||
"""Push group membership to each device MAC listed on the group."""
|
||||
if not isinstance(gdoc, dict):
|
||||
return
|
||||
mac_list = gdoc.get("devices") if isinstance(gdoc.get("devices"), list) else []
|
||||
for mac in mac_list:
|
||||
m = normalize_mac(str(mac))
|
||||
@@ -56,15 +123,22 @@ async def push_groups_for_group_devices(gdoc: dict) -> None:
|
||||
await push_groups_to_mac(m)
|
||||
|
||||
|
||||
async def push_groups_broadcast() -> bool:
|
||||
"""No aggregate broadcast for group assignment; use per-device push."""
|
||||
return False
|
||||
|
||||
|
||||
async def push_groups_to_mac(mac_hex: str) -> bool:
|
||||
"""Re-send GROUPS packet to one device (after group membership change)."""
|
||||
"""Unicast groups envelope to one driver (set_groups true)."""
|
||||
mac = normalize_mac(mac_hex)
|
||||
if not mac:
|
||||
return False
|
||||
client = get_bridge_client()
|
||||
if client is None:
|
||||
gids = groups_for_mac(mac, Group())
|
||||
sender = get_current_sender()
|
||||
if sender is None:
|
||||
return False
|
||||
groups = Group()
|
||||
gids = groups_for_mac(mac, groups)
|
||||
pkt = pack_groups(gids)
|
||||
return await client.send_espnow(pkt, peer_mac=bytes.fromhex(mac))
|
||||
envelope = build_groups_envelope(mac, gids)
|
||||
ok = await sender.send(envelope)
|
||||
if ok:
|
||||
print(f"[espnow] groups sent mac={mac} groups={gids!r}")
|
||||
return bool(ok)
|
||||
|
||||
@@ -452,8 +452,7 @@ async def _prime_lane(lane_index: int, ctx: Dict[str, Any]) -> None:
|
||||
return
|
||||
|
||||
device_names = _resolve_lane_device_names(lane_index, ctx)
|
||||
macs = _device_names_to_macs(device_names, ctx["devices"])
|
||||
if not macs:
|
||||
if not device_names:
|
||||
return
|
||||
|
||||
sender = get_current_sender()
|
||||
@@ -462,26 +461,33 @@ async def _prime_lane(lane_index: int, ctx: Dict[str, Any]) -> None:
|
||||
|
||||
zone_doc = ctx.get("zone_doc") if isinstance(ctx.get("zone_doc"), dict) else {}
|
||||
devices_model = ctx["devices"]
|
||||
num_lanes = int(ctx["num_lanes"])
|
||||
sequence_doc = ctx["sequence_doc"]
|
||||
gids = _group_ids_for_lane_step(
|
||||
sequence_doc, step0, lane_index, num_lanes, zone_doc=zone_doc
|
||||
)
|
||||
if not gids and isinstance(zone_doc, dict):
|
||||
zg = zone_doc.get("group_ids")
|
||||
if isinstance(zg, list):
|
||||
gids = [str(g).strip() for g in zg if str(g).strip()]
|
||||
wire = str(preset_id)
|
||||
auto = _coerce_auto(display_preset)
|
||||
sel: Dict[str, Any] = {}
|
||||
for n in device_names:
|
||||
if n:
|
||||
sel[str(n)] = [wire]
|
||||
|
||||
delay_s = 0.05
|
||||
for mac in macs:
|
||||
body: Dict[str, Any] = {"v": "1", "presets": dict(inner_by_wire)}
|
||||
if sel:
|
||||
body["select"] = sel
|
||||
msg = json.dumps(body, separators=(",", ":"))
|
||||
await deliver_json_messages(sender, [msg], [mac], devices_model, delay_s=delay_s)
|
||||
body: Dict[str, Any] = {"v": "1", "presets": dict(inner_by_wire)}
|
||||
if gids:
|
||||
body["groups"] = list(gids)
|
||||
if auto:
|
||||
body["select"] = [wire]
|
||||
msg = json.dumps(body, separators=(",", ":"))
|
||||
await deliver_json_messages(sender, [msg], None, devices_model, delay_s=delay_s)
|
||||
|
||||
if auto:
|
||||
clear_sequence_manual_lane_route(lane_index)
|
||||
else:
|
||||
inner = _preset_inner_from_display_preset(display_preset)
|
||||
set_sequence_manual_lane_route(lane_index, device_names, wire, inner)
|
||||
set_sequence_manual_lane_route(
|
||||
lane_index, device_names, wire, inner, group_ids=gids or None
|
||||
)
|
||||
mark_sequence_manual_lane_select_sent(lane_index)
|
||||
|
||||
|
||||
@@ -534,7 +540,9 @@ async def _deliver_zone_brightness_for_sequence(ctx: Dict[str, Any]) -> None:
|
||||
zone_brightness=zb,
|
||||
)
|
||||
msg = json.dumps({"v": "1", "b": eff, "save": True}, separators=(",", ":"))
|
||||
await deliver_json_messages(sender, [msg], [mac], devices_model, delay_s=0.05)
|
||||
await deliver_json_messages(
|
||||
sender, [msg], [mac], devices_model, delay_s=0.05, unicast=True
|
||||
)
|
||||
|
||||
|
||||
def _device_names_to_macs(device_names: List[str], devices: Any) -> List[str]:
|
||||
@@ -700,33 +708,25 @@ async def _send_lane(
|
||||
if not sender:
|
||||
raise RuntimeError("Transport not configured")
|
||||
|
||||
macs = _device_names_to_macs(device_names, devices)
|
||||
if not macs:
|
||||
if not device_names and not gids:
|
||||
return
|
||||
|
||||
wire = str(preset_id)
|
||||
auto = _coerce_auto(display_preset)
|
||||
body: Dict[str, Any] = {"v": "1", "select": [wire]}
|
||||
if gids:
|
||||
body["groups"] = [str(g) for g in gids]
|
||||
msg = json.dumps(body, separators=(",", ":"))
|
||||
if auto:
|
||||
clear_sequence_manual_lane_route(lane_index)
|
||||
sel: Dict[str, Any] = {}
|
||||
for n in device_names:
|
||||
if n:
|
||||
sel[str(n)] = [wire]
|
||||
if not sel:
|
||||
return
|
||||
msg = json.dumps({"v": "1", "select": sel}, separators=(",", ":"))
|
||||
await deliver_json_messages(sender, [msg], macs, devices, delay_s=0.05)
|
||||
await deliver_json_messages(sender, [msg], None, devices, delay_s=0.05)
|
||||
else:
|
||||
inner = _preset_inner_from_display_preset(display_preset)
|
||||
set_sequence_manual_lane_route(lane_index, device_names, wire, inner)
|
||||
sel: Dict[str, Any] = {}
|
||||
for n in device_names:
|
||||
if n:
|
||||
sel[str(n)] = [wire]
|
||||
if sel:
|
||||
msg = json.dumps({"v": "1", "select": sel}, separators=(",", ":"))
|
||||
await deliver_json_messages(sender, [msg], macs, devices, delay_s=0.05)
|
||||
mark_sequence_manual_lane_select_sent(lane_index)
|
||||
set_sequence_manual_lane_route(
|
||||
lane_index, device_names, wire, inner, group_ids=gids or None
|
||||
)
|
||||
await deliver_json_messages(sender, [msg], None, devices, delay_s=0.05)
|
||||
mark_sequence_manual_lane_select_sent(lane_index)
|
||||
|
||||
|
||||
async def _send_all_lanes(ctx: Dict[str, Any]) -> None:
|
||||
@@ -772,6 +772,12 @@ def _build_ctx(
|
||||
}
|
||||
|
||||
|
||||
def playback_active() -> bool:
|
||||
"""True while a zone sequence run is active (step timing owned by ``process_active_beat_advance``)."""
|
||||
with _beat_run_lock:
|
||||
return _beat_run is not None
|
||||
|
||||
|
||||
def playback_status() -> Dict[str, Any]:
|
||||
"""Snapshot for UI (e.g. audio status poll): lane 0 step + beats within step, total steps sum."""
|
||||
with _beat_run_lock:
|
||||
@@ -917,11 +923,20 @@ async def _clear_devices_after_sequence(ctx: Dict[str, Any]) -> None:
|
||||
if not sender:
|
||||
return
|
||||
devices = ctx.get("devices")
|
||||
macs = _union_macs_for_sequence(ctx)
|
||||
if not macs:
|
||||
return
|
||||
msg = json.dumps({"v": "1", "clear_presets": True, "save": True}, separators=(",", ":"))
|
||||
await deliver_json_messages(sender, [msg], macs, devices, delay_s=0.05)
|
||||
zone_doc = ctx.get("zone_doc") if isinstance(ctx.get("zone_doc"), dict) else {}
|
||||
gids: List[str] = []
|
||||
zg = zone_doc.get("group_ids") if isinstance(zone_doc, dict) else None
|
||||
if isinstance(zg, list):
|
||||
gids = [str(g).strip() for g in zg if str(g).strip()]
|
||||
if not gids:
|
||||
macs = _union_macs_for_sequence(ctx)
|
||||
if not macs:
|
||||
return
|
||||
body: Dict[str, Any] = {"v": "1", "clear_presets": True, "save": True}
|
||||
if gids:
|
||||
body["groups"] = gids
|
||||
msg = json.dumps(body, separators=(",", ":"))
|
||||
await deliver_json_messages(sender, [msg], None, devices, delay_s=0.05)
|
||||
|
||||
|
||||
def _halt_playback_state() -> Optional[Dict[str, Any]]:
|
||||
|
||||
123
src/util/v1_wire.py
Normal file
123
src/util/v1_wire.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""Short v1 field names for ESP-NOW JSON (≤250 B). Long names still accepted on receive."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
# Envelope: devices map
|
||||
ENV_DEVICES = "dv"
|
||||
|
||||
# Device body
|
||||
K_PRESETS = "p"
|
||||
K_SELECT = "s"
|
||||
K_GROUPS = "g"
|
||||
K_SET_GROUPS = "sg"
|
||||
K_SAVE = "sv"
|
||||
K_DEFAULT = "df"
|
||||
K_DEVICE_CONFIG = "dc"
|
||||
K_CLEAR_PRESETS = "cp"
|
||||
K_MANIFEST = "mf"
|
||||
|
||||
_BODY_LONG_TO_SHORT = {
|
||||
"presets": K_PRESETS,
|
||||
"select": K_SELECT,
|
||||
"groups": K_GROUPS,
|
||||
"set_groups": K_SET_GROUPS,
|
||||
"save": K_SAVE,
|
||||
"default": K_DEFAULT,
|
||||
"device_config": K_DEVICE_CONFIG,
|
||||
"clear_presets": K_CLEAR_PRESETS,
|
||||
"manifest": K_MANIFEST,
|
||||
}
|
||||
|
||||
_BODY_SHORT_TO_LONG = {v: k for k, v in _BODY_LONG_TO_SHORT.items()}
|
||||
|
||||
|
||||
def wire_select_list(preset_id: Union[str, int], step: Optional[Union[int, str]] = None) -> List[Any]:
|
||||
"""Preset id (+ optional step) for ``select`` on unicast/broadcast to one driver."""
|
||||
out: List[Any] = [str(preset_id)]
|
||||
if step is not None:
|
||||
out.append(step)
|
||||
return out
|
||||
|
||||
|
||||
def normalize_select_for_wire(select: Any) -> Any:
|
||||
"""Long or legacy shapes → wire list ``[preset_id, step?]``."""
|
||||
if isinstance(select, list):
|
||||
return select
|
||||
if isinstance(select, str) and select.strip():
|
||||
return [select.strip()]
|
||||
if not isinstance(select, dict):
|
||||
return select
|
||||
if "preset" in select:
|
||||
out: List[Any] = [str(select["preset"])]
|
||||
if "step" in select:
|
||||
out.append(select["step"])
|
||||
return out
|
||||
# Legacy {device_name: [preset, step?]} — unicast only; keep dict for expand on driver
|
||||
if len(select) == 1:
|
||||
val = next(iter(select.values()))
|
||||
if isinstance(val, list) and val:
|
||||
return list(val)
|
||||
return select
|
||||
|
||||
|
||||
def compact_body(body: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Long-key device body → short keys for the wire."""
|
||||
out: Dict[str, Any] = {}
|
||||
for long_key, short_key in _BODY_LONG_TO_SHORT.items():
|
||||
if long_key in body:
|
||||
val = body[long_key]
|
||||
if long_key == "select":
|
||||
val = normalize_select_for_wire(val)
|
||||
out[short_key] = val
|
||||
for short_key in _BODY_SHORT_TO_LONG:
|
||||
if short_key in body and short_key not in out:
|
||||
val = body[short_key]
|
||||
if short_key == K_SELECT:
|
||||
val = normalize_select_for_wire(val)
|
||||
out[short_key] = val
|
||||
if "b" in body:
|
||||
out["b"] = body["b"]
|
||||
return out
|
||||
|
||||
|
||||
def expand_body(body: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Short or long device body → long keys for driver logic."""
|
||||
out: Dict[str, Any] = dict(body)
|
||||
for short_key, long_key in _BODY_SHORT_TO_LONG.items():
|
||||
if short_key in body and long_key not in out:
|
||||
out[long_key] = body[short_key]
|
||||
if short_key in out:
|
||||
del out[short_key]
|
||||
return out
|
||||
|
||||
|
||||
def compact_envelope(envelope: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if envelope.get("v") != "1":
|
||||
return envelope
|
||||
devices = envelope.get("devices")
|
||||
if devices is None:
|
||||
devices = envelope.get(ENV_DEVICES)
|
||||
if not isinstance(devices, dict):
|
||||
return envelope
|
||||
compact_devices = {mac: compact_body(body) for mac, body in devices.items() if isinstance(body, dict)}
|
||||
return {"v": "1", ENV_DEVICES: compact_devices}
|
||||
|
||||
|
||||
def expand_envelope(envelope: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if envelope.get("v") != "1":
|
||||
return envelope
|
||||
devices = envelope.get("devices")
|
||||
if devices is None:
|
||||
devices = envelope.get(ENV_DEVICES)
|
||||
if not isinstance(devices, dict):
|
||||
return envelope
|
||||
expanded = {mac: expand_body(body) for mac, body in devices.items() if isinstance(body, dict)}
|
||||
return {"v": "1", "devices": expanded}
|
||||
|
||||
|
||||
def wire_json_size(obj: Dict[str, Any]) -> int:
|
||||
import json
|
||||
|
||||
return len(json.dumps(obj, separators=(",", ":")).encode("utf-8"))
|
||||
@@ -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