feat(espnow): broadcast delivery with group-filtered routing

Send presets and select on broadcast with groups; unicast only for
per-device settings. V1 select as [preset_id, step?]. Sequence steps
use beat counts; manual presets get select each beat, auto only on
step change. Bridge downlink router, Pi envelope delivery, and tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-24 01:44:28 +12:00
parent 1a69fabd98
commit b87382d2be
35 changed files with 1802 additions and 591 deletions

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

View File

@@ -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). 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 ## System overview
@@ -10,8 +10,8 @@ This document describes how **led-controller**, the **bridge ESP32**, and **led-
| Component | Firmware / path | Role | | Component | Firmware / path | Role |
|-----------|-----------------|------| |-----------|-----------------|------|
| **led-controller** | Raspberry Pi, `src/` | Web app; WebSocket **client** to bridge; device registry; builds binary commands | | **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`; relays binary ↔ ESP-NOW; max **20** peers (LRU) | | **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** | | **led-driver** | [`led-driver/`](../led-driver/) submodule | Boot **ANNOUNCE** broadcast; applies **GROUPS**, **CMD**, **GROUP_CMD** |
Configure the Pi in `settings.json`: 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`. 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). 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). 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. 4. Pi scans `db/group.json` and sends a **groups** envelope (`set_groups: true`) unicast to that MAC.
5. Pi sends **GROUPS** unicast to that MAC via the bridge. 5. Driver stores group ids in RAM (`device_groups`) for filtering.
6. Driver stores group ids in RAM for **GROUP_CMD** 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. 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 ## Sending presets and commands
![Command delivery flow](images/espnow/command-flow.svg) 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.
1. UI or API triggers a send (e.g. `POST /presets/send`). 3. Bridge routes each MAC entry to unicast or ESP-NOW broadcast per `set_groups`.
2. Pi builds one or more **CMD** packets (v2 binary envelope, chunked to ≤250 bytes). 4. Driver `process_data` applies presets, select (`[preset_id, step?]`; legacy name map still accepted), brightness, etc.
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.
--- ---

View File

@@ -1,24 +1,22 @@
{ {
"v": "1", "v": "1",
"devices": { "dv": {
"ff:ff:ff:ff:ff:ff": { "ff:ff:ff:ff:ff:ff": {
"presets": { "p": {
"preset_id": { "preset_id": {
"pattern": "on", "p": "on",
"colors": ["#FF0000"], "c": ["#FF0000"],
"delay": 100, "d": 100,
"brightness": 255, "b": 255,
"auto": true "a": true
} }
}, },
"select": { "s": ["preset_id", 0],
"preset": "preset_id", "sv": true,
"step": 0 "df": "preset_id",
}, "b": 255,
"save": true, "g": ["5", "18"],
"default": "preset_id", "sg": true
"b": 255
} }
} }
} }

View 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)

View File

@@ -22,6 +22,17 @@ def pack_ws_uplink(peer, espnow_packet):
return bytes([0]) + 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): def parse_bridge_channel(pkt):
if len(pkt) >= 3 and pkt[0] == WIRE_MAGIC and pkt[1] == MSG_BRIDGE_CH: if len(pkt) >= 3 and pkt[0] == WIRE_MAGIC and pkt[1] == MSG_BRIDGE_CH:
return pkt[2] return pkt[2]

View File

@@ -1,4 +1,5 @@
import asyncio import asyncio
import json
from microdot import Microdot from microdot import Microdot
from microdot.websocket import WebSocketError, with_websocket from microdot.websocket import WebSocketError, with_websocket
@@ -8,6 +9,8 @@ import machine
import network import network
from settings import Settings from settings import Settings
from espnow_wire import BROADCAST_MAC, pack_ws_uplink 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 = machine.WDT(timeout=10000)
wdt.feed() wdt.feed()
@@ -16,11 +19,11 @@ print(settings)
app = Microdot() app = Microdot()
ch = settings.get("wifi_channel", 6) ch = settings.get("wifi_channel", 1)
try: try:
ch = max(1, min(11, int(ch))) ch = max(1, min(11, int(ch)))
except (TypeError, ValueError): except (TypeError, ValueError):
ch = 6 ch = 1
ap_if = network.WLAN(network.AP_IF) ap_if = network.WLAN(network.AP_IF)
ap_if.active(True) ap_if.active(True)
@@ -39,9 +42,23 @@ esp = aioespnow.AIOESPNow()
esp.active(True) esp.active(True)
esp.add_peer(BROADCAST_MAC) esp.add_peer(BROADCAST_MAC)
peer_table = PeerTable(load_max_peers())
clients = set() 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") @app.route("/ws")
@with_websocket @with_websocket
async def ws(request, ws): async def ws(request, ws):
@@ -55,8 +72,15 @@ async def ws(request, ws):
break break
if not raw: if not raw:
break break
if isinstance(raw, str):
raw = raw.encode("utf-8")
try: try:
if is_devices_envelope(raw):
await route_envelope(esp, peer_table, raw)
else:
await esp.asend(BROADCAST_MAC, raw) await esp.asend(BROADCAST_MAC, raw)
print(raw)
print("ws tx", len(raw), "B")
except Exception as err: except Exception as err:
print(err) print(err)
break break
@@ -68,6 +92,7 @@ async def _espnow_receive_loop():
async for host, msg in esp: async for host, msg in esp:
if not host or not msg: if not host or not msg:
continue continue
_note_uplink_peer(host, msg)
print("espnow rx", len(msg), "B") print("espnow rx", len(msg), "B")
frame = pack_ws_uplink(host, msg) frame = pack_ws_uplink(host, msg)
dead = [] dead = []

View 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

View File

@@ -40,7 +40,7 @@ class Settings(dict):
def set_defaults(self): def set_defaults(self):
mac = _sta_mac_hex() mac = _sta_mac_hex()
self["name"] = "bridge-" + mac self["name"] = "bridge-" + mac
self["wifi_channel"] = 6 self["wifi_channel"] = 1
self["ap_password"] = "" self["ap_password"] = ""
self["ap_ip"] = "192.168.4.1" self["ap_ip"] = "192.168.4.1"
self["ws_port"] = 80 self["ws_port"] = 80

View 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

View File

@@ -11,7 +11,6 @@ from models.transport import get_current_sender
from settings import get_settings from settings import get_settings
from util.brightness_combine import effective_brightness_for_mac from util.brightness_combine import effective_brightness_for_mac
from util.driver_patterns import driver_patterns_dir 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 from util.espnow_message import build_message
import asyncio import asyncio
import json 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 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: try:
await asyncio.sleep(IDENTIFY_OFF_DELAY_S) await asyncio.sleep(IDENTIFY_OFF_DELAY_S)
pkt = v1_dict_to_cmd_packet( await sender.send(
{"v": "1", "select": {name: ["off"]}}, {"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: except Exception:
pass pass
@@ -166,36 +176,35 @@ async def send_identify_to_device(dev_id: str) -> tuple[int, str]:
sender = get_current_sender() sender = get_current_sender()
if not sender: if not sender:
return 503, "Transport not configured" 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: try:
pkt = v1_dict_to_cmd_packet( ok = await sender.send(
{ {
"v": "1", "v": "1",
"presets": {_IDENTIFY_PRESET_KEY: dict(_IDENTIFY_DRIVER_PRESET)}, "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: if not ok:
return 503, "Send failed" return 503, "Send failed"
asyncio.create_task( asyncio.create_task(
_identify_send_off_after_delay(sender, dev_id, name) _identify_send_off_after_delay(sender, dev_id)
) )
except Exception as e: except Exception as e:
return 503, str(e) return 503, str(e)
return 200, "" 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 Identify all drivers in ``group_ids`` via broadcast; members filter on ``groups``.
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 ``macs`` is only used for the API ``sent`` count (group member list), not for addressing.
``deliver_json_messages``.
""" """
from util.driver_delivery import deliver_json_messages 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: if not sender:
return 0, [{"mac": "*", "error": "Transport not configured"}] return 0, [{"mac": "*", "error": "Transport not configured"}]
merged_select: dict[str, list[str]] = {} body = {
valid_macs: list[str] = [] "v": "1",
for dev_id in macs: "presets": {_IDENTIFY_PRESET_KEY: dict(_IDENTIFY_DRIVER_PRESET)},
dev = devices.read(dev_id) "select": [_IDENTIFY_PRESET_KEY],
if not dev: }
errors.append({"mac": dev_id, "error": "Device not found"}) gids = [str(g).strip() for g in (group_ids or []) if str(g).strip()]
continue if gids:
name = str(dev.get("name") or "").strip() body["groups"] = gids
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
try: try:
msg = _compact_v1_json( deliveries, _chunks = await deliver_json_messages(
presets={_IDENTIFY_PRESET_KEY: dict(_IDENTIFY_DRIVER_PRESET)}, sender,
select=merged_select, [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: except Exception as e:
return 0, errors + [{"mac": "*", "error": str(e)}] return 0, errors + [{"mac": "*", "error": str(e)}]
for dev_id in valid_macs: if deliveries < 1:
dev = devices.read(dev_id) or {} return 0, errors + [{"mac": "*", "error": "Send failed"}]
name = str(dev.get("name") or "").strip()
asyncio.create_task(
_identify_send_off_after_delay(sender, dev_id, name)
)
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("") @controller.get("")
@@ -448,14 +454,13 @@ async def push_device_output_brightness(request, id):
zone_brightness=zb, zone_brightness=zb,
) )
pkt = v1_dict_to_cmd_packet({"v": "1", "b": b_val, "save": True})
sender = get_current_sender() sender = get_current_sender()
if not sender: if not sender:
return json.dumps({"error": "Transport not configured"}), 503, { return json.dumps({"error": "Transport not configured"}), 503, {
"Content-Type": "application/json", "Content-Type": "application/json",
} }
try: try:
ok = await sender.send(pkt, addr=id) ok = await sender.send({"v": "1", "b": b_val, "save": True}, addr=id)
if not ok: if not ok:
return json.dumps({"error": "Send failed"}), 503, { return json.dumps({"error": "Send failed"}), 503, {
"Content-Type": "application/json", "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" "error": "Provide at least one of name, num_leds, color_order, startup_mode"
} }
), 400, {"Content-Type": "application/json"} ), 400, {"Content-Type": "application/json"}
pkt = v1_dict_to_cmd_packet({"v": "1", "device_config": dc, "save": True}) ok = await sender.send({"v": "1", "device_config": dc, "save": True}, addr=id)
ok = await sender.send(pkt, addr=id)
if not ok: if not ok:
return json.dumps({"error": "Send failed"}), 503, { return json.dumps({"error": "Send failed"}), 503, {
"Content-Type": "application/json", "Content-Type": "application/json",

View File

@@ -4,7 +4,6 @@ import asyncio
from models.group import Group from models.group import Group
from models.device import Device from models.device import Device
from models.transport import get_current_sender 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 util.espnow_registry import push_groups_for_group_devices
from settings import get_settings from settings import get_settings
from util.brightness_combine import effective_brightness_for_mac 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() sender = get_current_sender()
if not sender: if not sender:
return json.dumps({"error": "Transport not configured"}), 503 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: for mac in mac_list:
m = str(mac).strip().lower().replace(":", "").replace("-", "") m = str(mac).strip().lower().replace(":", "").replace("-", "")
if len(m) != 12: 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"}) errors.append({"mac": m, "error": "not in registry"})
continue continue
try: try:
if await sender.send(pkt, addr=m): if await sender.send(body, addr=m):
sent += 1 sent += 1
else: else:
errors.append({"mac": m, "error": "send failed"}) errors.append({"mac": m, "error": "send failed"})
@@ -271,13 +270,10 @@ async def push_group_output_brightness(request, session, id):
m, m,
zone_brightness=None, zone_brightness=None,
) )
pkt = v1_dict_to_cmd_packet(
{"v": "1", "b": b_val, "save": True},
)
if not sender: if not sender:
return m, False, "transport not configured" return m, False, "transport not configured"
try: 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" return m, bool(ok), None if ok else "send failed"
except Exception as e: except Exception as e:
return m, False, str(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} {"message": "identify group done", "sent": 0, "errors": errors}
), 200, {"Content-Type": "application/json"} ), 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) errors.extend(batch_errors)
return json.dumps( return json.dumps(

View File

@@ -5,9 +5,11 @@ from models.profile import Profile
from models.pallet import Palette from models.pallet import Palette
from models.device import Device, normalize_mac from models.device import Device, normalize_mac
from models.transport import get_current_sender 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.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 from util.profile_bundle import export_preset_bundle, import_preset_bundle
import json import json
@@ -228,7 +230,7 @@ async def send_presets(request, session):
send_delay_s = 0.1 send_delay_s = 0.1
total_presets = len(presets_by_name) total_presets = len(presets_by_name)
chunk_messages = build_preset_cmd_chunks( chunk_messages = build_preset_json_chunks(
presets_by_name, presets_by_name,
save=save_flag, save=save_flag,
default=str(default_id) if default_id is not None else None, 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)) dm = normalize_mac(str(destination_mac))
target_list = [dm] if dm else None 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: try:
if target_list: if unicast and target_list:
deliveries = await deliver_preset_broadcast_then_per_device( deliveries = 0
for msg in chunk_messages:
d, _chunks = await deliver_json_messages(
sender, sender,
chunk_messages, [msg],
target_list, target_list,
Device(), Device(),
str(default_id) if default_id is not None else None,
delay_s=send_delay_s, 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: 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( deliveries, _chunks = await deliver_json_messages(
sender, sender,
chunk_messages, wire_messages,
None, None,
Device(), Device(),
delay_s=send_delay_s, 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'} return json.dumps({"error": "Transport not configured"}), 503, {'Content-Type': 'application/json'}
messages = [] messages = []
for item in seq: i = 0
if isinstance(item, dict): while i < len(seq):
messages.append(json.dumps(item)) item = seq[i]
elif isinstance(item, str): if not isinstance(item, dict):
if isinstance(item, str):
messages.append(item) messages.append(item)
else: i += 1
continue
return json.dumps({"error": "sequence items must be objects or strings"}), 400, {'Content-Type': 'application/json'} 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) delay_s = data.get("delay_s", 0.05)
try: try:
@@ -329,6 +381,8 @@ async def push_driver_messages(request, session):
except (TypeError, ValueError): except (TypeError, ValueError):
delay_s = 0.05 delay_s = 0.05
unicast = bool(data.get("unicast"))
try: try:
deliveries, _chunks = await deliver_json_messages( deliveries, _chunks = await deliver_json_messages(
sender, sender,
@@ -336,6 +390,7 @@ async def push_driver_messages(request, session):
target_list, target_list,
Device(), Device(),
delay_s=delay_s, delay_s=delay_s,
unicast=unicast,
) )
except Exception: except Exception:
return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'} return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'}

View File

@@ -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.transport import get_sender, set_sender, get_current_sender
from models.device import Device from models.device import Device
from models.bridge_ws_client import init_bridge_client from models.bridge_ws_client import init_bridge_client
from util.espnow_registry import handle_espnow_announce from util.espnow_registry import handle_bridge_uplink
from util.binary_driver_messages import v1_dict_to_cmd_packet
from util.audio_detector import AudioBeatDetector 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() bridge_url = str(settings.get("bridge_ws_url") or "").strip()
if bridge_url: if bridge_url:
try: try:
ch = int(settings.get("wifi_channel", 6)) ch = int(settings.get("wifi_channel", 1))
except (TypeError, ValueError): except (TypeError, ValueError):
ch = 6 ch = 1
bridge = init_bridge_client(bridge_url, wifi_channel=ch) 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() bridge.start()
app = Microdot() app = Microdot()
@@ -278,8 +277,7 @@ async def main(port=80):
continue continue
parsed = json.loads(data) parsed = json.loads(data)
addr = parsed.pop("to", None) addr = parsed.pop("to", None)
pkt = v1_dict_to_cmd_packet(parsed) await sender.send(parsed, addr=addr)
await sender.send(pkt, addr=addr)
except json.JSONDecodeError: except json.JSONDecodeError:
pass pass
except Exception: except Exception:

View File

@@ -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 from __future__ import annotations
import asyncio import asyncio
from typing import Awaitable, Callable, Optional import json
from typing import Awaitable, Callable, Optional, Union
import websockets import websockets
from websockets.exceptions import ConnectionClosed from websockets.exceptions import ConnectionClosed
from util.espnow_wire import ( from util.espnow_wire import parse_ws_frame
MSG_ANNOUNCE,
WIRE_MAGIC,
pack_bridge_channel,
pack_ws_downlink,
parse_ws_frame,
wire_msg_type,
)
UplinkHandler = Callable[[bytes, bytes], Awaitable[None]] UplinkHandler = Callable[[bytes, bytes], Awaitable[None]]
class BridgeWsClient: 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._url = url.strip()
self._wifi_channel = wifi_channel self._wifi_channel = wifi_channel
self._reconnect_delay_s = reconnect_delay_s
self._ws: Optional[websockets.WebSocketClientProtocol] = None self._ws: Optional[websockets.WebSocketClientProtocol] = None
self._send_lock = asyncio.Lock() self._send_lock = asyncio.Lock()
self._uplink_handler: Optional[UplinkHandler] = None self._uplink_handler: Optional[UplinkHandler] = None
self._task: Optional[asyncio.Task] = None self._task: Optional[asyncio.Task] = None
self._connected = asyncio.Event() 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: def set_uplink_handler(self, handler: Optional[UplinkHandler]) -> None:
self._uplink_handler = handler 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: async def run_forever(self) -> None:
while True: while True:
try: try:
@@ -42,9 +50,11 @@ class BridgeWsClient:
raise raise
except Exception as e: except Exception as e:
print(f"[bridge] connection error: {e!r}") print(f"[bridge] connection error: {e!r}")
self._connected.clear() self._signal_disconnect()
self._ws = None self._disconnect_event.clear()
await asyncio.sleep(2.0) await self._close_ws()
print("[bridge] disconnected, reconnecting...")
await asyncio.sleep(self._reconnect_delay_s)
async def _reader_loop(self) -> None: async def _reader_loop(self) -> None:
ws = self._ws ws = self._ws
@@ -52,40 +62,41 @@ class BridgeWsClient:
return return
try: try:
async for message in ws: async for message in ws:
if isinstance(message, str): if self._uplink_handler is None:
continue continue
if len(message) == 1: if isinstance(message, str):
fut = self._ack_waiter message = message.encode("utf-8")
if fut is not None and not fut.done(): if not message:
fut.set_result(message[0] == 0x01)
continue continue
try: try:
peer, pkt, _bcast = parse_ws_frame(message) peer, pkt, _bcast = parse_ws_frame(message)
except ValueError: except ValueError:
continue 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: except ConnectionClosed:
pass pass
finally:
self._signal_disconnect()
async def _connect_once(self) -> None: 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: async with websockets.connect(self._url, ping_interval=20, ping_timeout=20) as ws:
self._ws = 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._connected.set()
self._disconnect_event.clear()
print("[bridge] connected") print("[bridge] connected")
reader = asyncio.create_task(self._reader_loop()) reader = asyncio.create_task(self._reader_loop())
try: try:
while True: while not self._disconnect_event.is_set():
await asyncio.sleep(3600) await asyncio.sleep(0.5)
finally: finally:
reader.cancel() reader.cancel()
try: try:
await reader await reader
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
except Exception:
pass
async def wait_connected(self, timeout: float = 30.0) -> bool: async def wait_connected(self, timeout: float = 30.0) -> bool:
try: try:
@@ -94,34 +105,35 @@ class BridgeWsClient:
except asyncio.TimeoutError: except asyncio.TimeoutError:
return False return False
async def send_frame(self, frame: bytes) -> bool: async def send_packet(self, packet: Union[bytes, str, dict]) -> bool:
await self._connected.wait() 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 ws = self._ws
if ws is None: if ws is None:
return False return False
async with self._send_lock: async with self._send_lock:
loop = asyncio.get_running_loop()
self._ack_waiter = loop.create_future()
try: try:
await ws.send(frame) await ws.send(packet)
return bool(await asyncio.wait_for(self._ack_waiter, timeout=5.0)) return True
except (ConnectionClosed, asyncio.TimeoutError, OSError) as e: except (ConnectionClosed, OSError) as e:
print(f"[bridge] send failed: {e!r}") print(f"[bridge] send failed: {e!r}")
self._signal_disconnect()
await self._close_ws()
return False return False
finally:
self._ack_waiter = None
async def send_espnow( async def send_espnow(
self, self,
packet: bytes, packet: bytes,
*, *,
peer_mac: Optional[str] = None, peer_mac: Optional[bytes] = None,
broadcast: bool = False, broadcast: bool = False,
) -> bool: ) -> bool:
if not packet or packet[0] != WIRE_MAGIC: del peer_mac, broadcast
raise ValueError("packet must be espnow wire format") return await self.send_packet(packet)
frame = pack_ws_downlink(packet, peer_mac=peer_mac, broadcast=broadcast)
return await self.send_frame(frame)
def start(self) -> asyncio.Task: def start(self) -> asyncio.Task:
if self._task is None or self._task.done(): if self._task is None or self._task.done():
@@ -136,7 +148,7 @@ def get_bridge_client() -> Optional[BridgeWsClient]:
return _client 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 global _client
_client = BridgeWsClient(url, wifi_channel=wifi_channel) _client = BridgeWsClient(url, wifi_channel=wifi_channel)
return _client return _client

View File

@@ -1,24 +1,18 @@
"""Transport to LED drivers via ESP-NOW bridge WebSocket.""" """Transport to LED drivers via ESP-NOW bridge WebSocket."""
import asyncio import json
from typing import Optional, Union from typing import Any, Dict, List, Optional, Union
from models.bridge_ws_client import get_bridge_client from models.bridge_ws_client import get_bridge_client
from util.espnow_wire import WIRE_MAGIC, pack_ws_downlink from util.bridge_envelope import (
BROADCAST_HEX,
BROADCAST_MAC_HEX = "ffffffffffff" BROADCAST_MAC,
build_devices_envelope,
format_mac_key,
def _parse_mac(addr) -> Optional[bytes]: is_broadcast_mac,
if addr is None or addr == "": normalize_mac_key,
return None )
if isinstance(addr, bytes) and len(addr) == 6: from util.espnow_wire import WIRE_MAGIC
return addr
if isinstance(addr, str):
s = addr.strip().lower().replace(":", "").replace("-", "")
if len(s) == 12:
return bytes.fromhex(s)
return None
class NullSender: class NullSender:
@@ -29,25 +23,69 @@ class NullSender:
class BridgeWsSender: 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() client = get_bridge_client()
if client is None: if client is None:
return False 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) packet = bytes(data)
else: else:
return False return False
if not packet or packet[0] != WIRE_MAGIC:
if not packet:
return False return False
peer = _parse_mac(addr)
broadcast = peer is None or addr == BROADCAST_MAC_HEX if packet[0] == WIRE_MAGIC:
return await client.send_espnow( return await client.send_packet(packet)
packet,
peer_mac=peer, if packet[0:1] != b"{":
broadcast=broadcast, 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 _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)" "[startup] bridge disabled (set bridge_ws_url in settings.json, e.g. ws://192.168.4.1/ws)"
) )
return NullSender() 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() return BridgeWsSender()

View File

@@ -51,7 +51,7 @@ class Settings(dict):
self.save() self.save()
# ESP-NOW STA channel (2.4 GHz) for LED drivers / bridge alignment; 111 # ESP-NOW STA channel (2.4 GHz) for LED drivers / bridge alignment; 111
if 'wifi_channel' not in self: 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 # WebSocket URL of ESP-NOW bridge (Pi is client), e.g. ws://192.168.4.1/ws
if 'bridge_ws_url' not in self: if 'bridge_ws_url' not in self:
self['bridge_ws_url'] = '' self['bridge_ws_url'] = ''

View File

@@ -98,12 +98,17 @@ document.addEventListener('DOMContentLoaded', () => {
: []; : [];
}; };
const postDriverSequence = async (sequence, targetMacs, delayS = 0.05) => { const postDriverSequence = async (sequence, targetMacs, delayS = 0.05, pushOptions = {}) => {
const body = { if (typeof window.postDriverSequence === 'function') {
sequence, return window.postDriverSequence(sequence, targetMacs, delayS, pushOptions);
targets: Array.isArray(targetMacs) && targetMacs.length ? [...new Set(targetMacs)] : undefined, }
delay_s: delayS, 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', { const res = await fetch('/presets/push', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
@@ -586,26 +591,28 @@ document.addEventListener('DOMContentLoaded', () => {
return; return;
} }
const select = {}; const groupIds =
deviceNames.forEach((name) => { typeof window.zonesManager !== 'undefined' &&
if (name) { typeof window.zonesManager.effectiveGroupIdsForZonePreset === 'function'
select[name] = zonePresetIds.slice(); ? window.zonesManager.effectiveGroupIdsForZonePreset(zoneData)
} : Array.isArray(zoneData.group_ids)
}); ? zoneData.group_ids.map((g) => String(g).trim()).filter((g) => g.length > 0)
const targetMacs =
typeof window.tabsManager !== 'undefined' &&
typeof window.tabsManager.resolveTabDeviceMacs === 'function'
? await window.tabsManager.resolveTabDeviceMacs(deviceNames)
: []; : [];
const sequence = [ const sequence = [
{ v: '1', clear_presets: true, save: true }, { v: '1', clear_presets: true, save: true },
{ v: '1', presets: wirePresets, save: true }, { v: '1', presets: wirePresets, save: true },
]; ];
if (Object.keys(select).length) { if (groupIds.length) {
sequence.push({ v: '1', select }); 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) { } catch (error) {
console.error('Send all patterns failed:', error); console.error('Send all patterns failed:', error);
alert('Failed to send all patterns.'); alert('Failed to send all patterns.');

View File

@@ -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). */ /** Device names for ``presetId`` on the current zone tab (zone ``group_ids`` for presets, else tab devices). */
async function deviceNamesForPresetOnCurrentZone(presetId) { async function deviceNamesForPresetOnCurrentZone(presetId) {
const section = document.querySelector('.presets-section[data-zone-id]'); const section = document.querySelector('.presets-section[data-zone-id]');
@@ -216,8 +227,13 @@ function formatPresetTargetGroupsLine(zoneDoc, groupsMap) {
async function postDriverSequence(sequence, targetMacs, delayS, pushOptions) { async function postDriverSequence(sequence, targetMacs, delayS, pushOptions) {
const body = { const body = {
sequence, 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) { if (delayS != null && delayS >= 0) {
body.delay_s = delayS; body.delay_s = delayS;
} }
@@ -1361,12 +1377,17 @@ document.addEventListener('DOMContentLoaded', () => {
return; return;
} }
try { try {
const targetMacs = const zoneId = section && section.dataset.zoneId;
typeof window.tabsManager !== 'undefined' && let groupIds = [];
typeof window.tabsManager.resolveTabDeviceMacs === 'function' if (zoneId) {
? await window.tabsManager.resolveTabDeviceMacs(deviceNames) const zr = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } });
: []; if (zr.ok) {
await postDriverSequence([{ v: '1', clear_presets: true, save: true }], targetMacs); 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) { } catch (error) {
console.error('Clear device presets failed:', error); console.error('Clear device presets failed:', error);
alert('Failed to clear presets on devices.'); alert('Failed to clear presets on devices.');
@@ -2040,29 +2061,23 @@ const sendPresetViaEspNow = async (
presetMessage.default = wirePresetId; presetMessage.default = wirePresetId;
} }
const names = Array.isArray(deviceNames) ? deviceNames : []; const forceSelect = pushOptions && pushOptions.select === true;
const targetMacs = const shouldSelect =
names.length > 0 && forceSelect || (pushOptions && pushOptions.select === false ? false : presetAuto);
typeof window.tabsManager !== 'undefined' && // Apply on driver in the same message as presets (split on bridge keeps presets before select).
typeof window.tabsManager.resolveTabDeviceMacs === 'function' if (shouldSelect) {
? await window.tabsManager.resolveTabDeviceMacs(names) presetMessage.select = [wirePresetId];
}
const groupIds =
pushOptions && Array.isArray(pushOptions.groupIds)
? pushOptions.groupIds.map((g) => String(g).trim()).filter((g) => g.length > 0)
: []; : [];
if (groupIds.length > 0) {
const sequence = [presetMessage]; presetMessage.groups = groupIds;
// 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 });
}
} }
await postDriverSequence(sequence, targetMacs, 0.05, pushOptions); await postDriverSequence([presetMessage], [], 0.05, pushOptions);
} catch (error) { } catch (error) {
console.error('Failed to send preset to devices:', error); console.error('Failed to send preset to devices:', error);
alert('Failed to send preset to devices.'); alert('Failed to send preset to devices.');
@@ -2106,17 +2121,13 @@ const sendPresetSelectViaEspNow = async (presetId, deviceNames) => {
if (!nameTargets.length) { if (!nameTargets.length) {
return; return;
} }
const select = {};
nameTargets.forEach((name) => {
select[name] = [String(presetId)];
});
const macTargets = const macTargets =
nameTargets.length > 0 && nameTargets.length > 0 &&
typeof window.tabsManager !== 'undefined' && typeof window.tabsManager !== 'undefined' &&
typeof window.tabsManager.resolveTabDeviceMacs === 'function' typeof window.tabsManager.resolveTabDeviceMacs === 'function'
? await window.tabsManager.resolveTabDeviceMacs(nameTargets) ? 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. // 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 pid = String(presetId);
const body = (allPresets && allPresets[pid]) || preset; const body = (allPresets && allPresets[pid]) || preset;
if (!body) return; if (!body) return;
const zm = window.zonesManager;
const names = const names =
window.zonesManager && typeof window.zonesManager.resolveDeviceNamesForZonePreset === 'function' zm && typeof zm.resolveDeviceNamesForZonePreset === 'function'
? await window.zonesManager.resolveDeviceNamesForZonePreset(tabData, pid) ? 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 // Store selected preset per zone

View File

@@ -107,6 +107,7 @@ function sendZoneBrightness(zoneId, value) {
[{ v: '1', b: bv, save: true }], [{ v: '1', b: bv, save: true }],
[mac], [mac],
0, 0,
{ unicast: true },
); );
} }
return; return;
@@ -304,6 +305,18 @@ async function resolveDeviceNamesForZonePreset(zoneDoc, presetId) {
return Array.isArray(zt.names) ? zt.names.slice() : []; 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). */ /** Union of devices targeted by standalone presets on the zone (same as zone preset targeting). */
async function computeZonePresetUnionTargets(zoneDoc) { async function computeZonePresetUnionTargets(zoneDoc) {
return await computeZoneTargets(zoneDoc); return await computeZoneTargets(zoneDoc);
@@ -951,13 +964,15 @@ async function sendProfilePresets() {
continue; continue;
} }
zonesWithPresets += 1; zonesWithPresets += 1;
const targets = await resolveZoneDeviceMacsFromZoneData(tabData);
const payload = { preset_ids: presetIds }; const payload = { preset_ids: presetIds };
if (tabData.default_preset) { if (tabData.default_preset) {
payload.default = tabData.default_preset; payload.default = tabData.default_preset;
} }
if (targets.length > 0) { const gids = Array.isArray(tabData.group_ids)
payload.targets = targets; ? 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', { const response = await fetch('/presets/send', {
method: 'POST', method: 'POST',
@@ -1425,6 +1440,7 @@ window.zonesManager = {
computeZonePresetUnionTargets, computeZonePresetUnionTargets,
effectiveGroupIdsForZonePreset, effectiveGroupIdsForZonePreset,
resolveDeviceNamesForZonePreset, resolveDeviceNamesForZonePreset,
resolveMacsForZonePreset,
resolveSequenceStepDeviceNames, resolveSequenceStepDeviceNames,
fetchGroupsMap, fetchGroupsMap,
renderZoneGroupsEditor, renderZoneGroupsEditor,

View File

@@ -232,10 +232,12 @@ def _apply_manual_beat_route(
device_names: List[str], device_names: List[str],
wire_preset_id: str, wire_preset_id: str,
preset_body: Any, preset_body: Any,
group_ids: Optional[List[str]] = None,
) -> None: ) -> None:
"""Enable audio→driver routing for one manual preset (clears all lanes, including sequence).""" """Enable audio→driver routing for one manual preset (clears all lanes, including sequence)."""
global _lane_manual 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: with _route_lock:
_lane_manual.clear() _lane_manual.clear()
_sync_public_beat_route_from_lane_table() _sync_public_beat_route_from_lane_table()
@@ -265,6 +267,7 @@ def _apply_manual_beat_route(
"pattern": pattern, "pattern": pattern,
"manual_beat_n": _coerce_manual_beat_n(preset_body), "manual_beat_n": _coerce_manual_beat_n(preset_body),
"beat_counter": 0, "beat_counter": 0,
"group_ids": gids,
} }
_sync_public_beat_route_from_lane_table() _sync_public_beat_route_from_lane_table()
@@ -273,10 +276,12 @@ def _apply_manual_beat_route_standalone_overlay(
device_names: List[str], device_names: List[str],
wire_preset_id: str, wire_preset_id: str,
preset_body: Any, preset_body: Any,
group_ids: Optional[List[str]] = None,
) -> None: ) -> None:
"""Register manual beat routing on lane ``-1`` only, keeping sequence lanes ``0..n`` intact.""" """Register manual beat routing on lane ``-1`` only, keeping sequence lanes ``0..n`` intact."""
global _lane_manual 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: with _route_lock:
_lane_manual.pop(-1, None) _lane_manual.pop(-1, None)
_sync_public_beat_route_from_lane_table() _sync_public_beat_route_from_lane_table()
@@ -309,6 +314,7 @@ def _apply_manual_beat_route_standalone_overlay(
"pattern": pattern, "pattern": pattern,
"manual_beat_n": _coerce_manual_beat_n(preset_body), "manual_beat_n": _coerce_manual_beat_n(preset_body),
"beat_counter": 0, "beat_counter": 0,
"group_ids": gids,
} }
_sync_public_beat_route_from_lane_table() _sync_public_beat_route_from_lane_table()
@@ -318,11 +324,13 @@ def set_sequence_manual_lane_route(
device_names: List[str], device_names: List[str],
wire_preset_id: str, wire_preset_id: str,
preset_body: Any, preset_body: Any,
group_ids: Optional[List[str]] = None,
) -> None: ) -> None:
"""Register or update one sequence lane's manual beat route (parallel lanes, independent strides).""" """Register or update one sequence lane's manual beat route (parallel lanes, independent strides)."""
global _lane_manual global _lane_manual
names = [str(n).strip() for n in (device_names or []) if str(n).strip()] 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: with _route_lock:
if lane_index in _lane_manual: if lane_index in _lane_manual:
del _lane_manual[lane_index] del _lane_manual[lane_index]
@@ -353,6 +361,7 @@ def set_sequence_manual_lane_route(
"pattern": pattern, "pattern": pattern,
"manual_beat_n": mn, "manual_beat_n": mn,
"beat_counter": bc, "beat_counter": bc,
"group_ids": gids,
} }
overlay = _lane_manual.get(-1) overlay = _lane_manual.get(-1)
if overlay and _lane_route_targets_key(names, wid) == _lane_route_targets_key( 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). 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`` 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 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. sequence lanes ``0..n`` keep their stride counters and wire ids.
""" """
merged_presets: Dict[str, Any] = {} 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: for item in sequence:
if isinstance(item, str): if isinstance(item, str):
try: try:
@@ -448,11 +460,27 @@ def sync_beat_route_from_push_sequence(
if isinstance(pr, dict): if isinstance(pr, dict):
merged_presets.update(pr) merged_presets.update(pr)
sel = item.get("select") sel = item.get("select")
if isinstance(sel, dict) and sel: if isinstance(sel, list) and sel:
last_select = 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: if last_select_list:
device_names = [str(k).strip() for k in last_select.keys() if str(k).strip()] 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 device_names:
if not preserve_parallel_lane_routes: if not preserve_parallel_lane_routes:
update_beat_route({"enabled": False}) update_beat_route({"enabled": False})
@@ -460,7 +488,7 @@ def sync_beat_route_from_push_sequence(
wire_ids: Set[str] = set() wire_ids: Set[str] = set()
for name in device_names: for name in device_names:
val = last_select.get(name) val = last_select_map.get(name)
if isinstance(val, list) and val: if isinstance(val, list) and val:
wire_ids.add(str(val[0]).strip()) wire_ids.add(str(val[0]).strip())
elif val is not None: elif val is not None:
@@ -470,6 +498,10 @@ def sync_beat_route_from_push_sequence(
update_beat_route({"enabled": False}) update_beat_route({"enabled": False})
return return
wire_preset_id = wire_ids.pop() 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) preset_body = merged_presets.get(wire_preset_id)
if preset_body is None: if preset_body is None:
for k, v in merged_presets.items(): for k, v in merged_presets.items():
@@ -486,10 +518,12 @@ def sync_beat_route_from_push_sequence(
return return
if preserve_parallel_lane_routes: if preserve_parallel_lane_routes:
_apply_manual_beat_route_standalone_overlay( _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: 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) mark_manual_select_sent_for_targets(device_names, wire_preset_id)
return return
@@ -497,9 +531,11 @@ def sync_beat_route_from_push_sequence(
if wire_id and body is not None: if wire_id and body is not None:
names = _registry_names_for_macs(target_macs) names = _registry_names_for_macs(target_macs)
if preserve_parallel_lane_routes: 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: else:
_apply_manual_beat_route(names, wire_id, body) _apply_manual_beat_route(names, wire_id, body, group_ids=last_group_ids)
return return
if not preserve_parallel_lane_routes: 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() _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 Device
from models.device import resolve_device_mac_for_select_routing
from models.transport import get_current_sender from models.transport import get_current_sender
from util.driver_delivery import deliver_json_messages 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: if not sender:
return return
devices = Device() devices = Device()
seen_macs: List[str] = [] gids = [str(g).strip() for g in (group_ids or []) if str(g).strip()]
seen_set: Set[str] = set() body: Dict[str, Any] = {"v": "1", "select": [wire_preset_id]}
for n in device_names: if gids:
mac = resolve_device_mac_for_select_routing(devices, n) body["groups"] = gids
if mac and mac not in seen_set: msg = json.dumps(body, separators=(",", ":"))
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=(",", ":"))
try: 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: except Exception as e:
print(f"[beat-route] deliver failed: {e}") print(f"[beat-route] deliver failed: {e}")
async def _deliver_select_batch(pairs: List[Tuple[List[str], str]]) -> None: async def _deliver_select_batch(pairs: List[Tuple[str, Optional[List[str]]]]) -> None:
for names, pid in pairs: for pid, gids in pairs:
await _deliver_select(names, pid) await _deliver_select(pid, gids)
def notify_beat_detected() -> None: 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 global _preset_session_beats
work: List[Tuple[List[str], str]] = [] work: List[Tuple[str, Optional[List[str]]]] = []
with _route_lock: with _route_lock:
if not _lane_manual: if not _lane_manual:
return return
@@ -604,7 +633,15 @@ def notify_beat_detected() -> None:
for key in sorted(_lane_manual.keys()): for key in sorted(_lane_manual.keys()):
e = _lane_manual[key] e = _lane_manual[key]
names = e.get("device_names") or [] 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 continue
pattern = str(e.get("pattern") or "") pattern = str(e.get("pattern") or "")
if pattern and not _pattern_supports_manual(pattern): if pattern and not _pattern_supports_manual(pattern):
@@ -621,11 +658,13 @@ def notify_beat_detected() -> None:
if (c - 1) % n != 0: if (c - 1) % n != 0:
continue continue
wire = str(e.get("wire_preset_id") or "2") 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: if target_key in seen_targets:
continue continue
seen_targets.add(target_key) seen_targets.add(target_key)
work.append((list(names), wire)) work.append((wire, gids or None))
if work: if work:
_preset_session_beats += 1 _preset_session_beats += 1
if not work: if not work:

151
src/util/bridge_envelope.py Normal file
View 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

View File

@@ -1,13 +1,97 @@
"""Deliver binary ESP-NOW messages via bridge WebSocket.""" """Deliver v1 JSON to drivers via bridge devices envelope."""
import asyncio 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.bridge_envelope import (
from util.binary_driver_messages import build_preset_cmd_chunks, v1_dict_to_cmd_packet BROADCAST_MAC,
from util.espnow_wire import BROADCAST_MAC, pack_group_cmd 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( async def deliver_binary_packets(
@@ -16,33 +100,11 @@ async def deliver_binary_packets(
target_macs: Optional[List[str]] = None, target_macs: Optional[List[str]] = None,
*, *,
delay_s: float = 0.1, delay_s: float = 0.1,
unicast: bool = False,
) -> int: ) -> int:
"""Send binary CMD packets unicast per MAC or broadcast when no targets.""" return await deliver_packets(
if not packets: sender, packets, delay_s=delay_s, target_macs=target_macs, unicast=unicast
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
async def deliver_group_binary_packets( async def deliver_group_binary_packets(
@@ -52,7 +114,7 @@ async def deliver_group_binary_packets(
*, *,
delay_s: float = 0.1, delay_s: float = 0.1,
) -> int: ) -> 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 from util.espnow_wire import parse_cmd
deliveries = 0 deliveries = 0
@@ -64,12 +126,54 @@ async def deliver_group_binary_packets(
g_pkt = pack_group_cmd(str(group_id), env, save=save) g_pkt = pack_group_cmd(str(group_id), env, save=save)
except ValueError: except ValueError:
continue continue
if await sender.send(g_pkt, addr=_BROADCAST_HEX): if await sender.send(g_pkt):
deliveries += 1 deliveries += 1
await asyncio.sleep(delay_s) await asyncio.sleep(delay_s)
return deliveries 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( async def deliver_preset_broadcast_then_per_device(
sender, sender,
chunk_messages, chunk_messages,
@@ -78,88 +182,59 @@ async def deliver_preset_broadcast_then_per_device(
default_id, default_id,
delay_s=0.1, delay_s=0.1,
): ):
""" del devices_model, target_macs
chunk_messages: list of v1 JSON strings OR binary CMD bytes. deliveries = 0
Converts JSON strings to binary when needed.
"""
packets: List[bytes] = []
for msg in chunk_messages: for msg in chunk_messages:
if isinstance(msg, (bytes, bytearray)): body = _body_from_message(msg)
packets.append(bytes(msg)) if not body:
else:
import json
try:
body = json.loads(msg)
except Exception:
continue continue
if isinstance(body, dict): deliveries += await _deliver_v1_body(sender, BROADCAST_MAC, body, delay_s)
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:
continue
seen.add(m)
ordered.append(m)
deliveries = await deliver_binary_packets(
sender, packets, ordered, delay_s=delay_s
)
if default_id: if default_id:
did = str(default_id) body = {"default": str(default_id), "save": True}
for mac in ordered: deliveries += await _deliver_v1_body(sender, BROADCAST_MAC, body, delay_s)
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)
return deliveries return deliveries
async def deliver_json_messages(sender, messages, target_macs, devices_model, delay_s=0.1): def _unicast_mac_keys(target_macs: Optional[List[str]]) -> List[str]:
""" """One formatted MAC per target; empty list means broadcast."""
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
if not target_macs: if not target_macs:
n = await deliver_binary_packets(sender, packets, None, delay_s=delay_s) return [BROADCAST_MAC]
return n, len(packets) keys: List[str] = []
seen: set = set()
seen = set()
ordered_macs = []
for raw in target_macs: for raw in target_macs:
m = normalize_mac(str(raw)) if raw else None h = normalize_mac_key(raw)
if not m or m in seen: if h and h not in seen:
continue seen.add(h)
seen.add(m) keys.append(format_mac_key(h))
ordered_macs.append(m) 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)

View File

@@ -55,27 +55,22 @@ def build_message(presets=None, select=None, save=False, default=None):
return json.dumps(message) 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. Build a select list for one driver (unicast / per-MAC envelope).
Args: Wire shape: ``["preset_id"]`` or ``["preset_id", step]`` — no device name.
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)
""" """
select_list = [preset_name] select_list = [str(preset_name)]
if step is not None: if step is not None:
select_list.append(step) select_list.append(step)
return select_list
return {device_name: 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): def _hex_from_background_raw(bg_raw):

View File

@@ -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 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.device import Device, normalize_mac # noqa: F401 — re-export for callers
from models.group import Group from models.group import Group
from models.bridge_ws_client import get_bridge_client from models.transport import get_current_sender
from util.espnow_wire import mac_bytes_to_hex, pack_groups, parse_announce 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 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: async def handle_espnow_announce(peer_mac: bytes, packet: bytes) -> None:
info = parse_announce(packet) info = parse_announce(packet)
if not info: if not info:
@@ -31,24 +109,13 @@ async def handle_espnow_announce(peer_mac: bytes, packet: bytes) -> None:
return return
if persisted: if persisted:
print(f"[espnow] registered mac={did} name={info['name']!r}") print(f"[espnow] registered mac={did} name={info['name']!r}")
await _after_device_registered(mac_hex)
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}")
async def push_groups_for_group_devices(gdoc: dict) -> None: 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 [] mac_list = gdoc.get("devices") if isinstance(gdoc.get("devices"), list) else []
for mac in mac_list: for mac in mac_list:
m = normalize_mac(str(mac)) 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) 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: 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) mac = normalize_mac(mac_hex)
if not mac: if not mac:
return False return False
client = get_bridge_client() gids = groups_for_mac(mac, Group())
if client is None: sender = get_current_sender()
if sender is None:
return False return False
groups = Group() envelope = build_groups_envelope(mac, gids)
gids = groups_for_mac(mac, groups) ok = await sender.send(envelope)
pkt = pack_groups(gids) if ok:
return await client.send_espnow(pkt, peer_mac=bytes.fromhex(mac)) print(f"[espnow] groups sent mac={mac} groups={gids!r}")
return bool(ok)

View File

@@ -452,8 +452,7 @@ async def _prime_lane(lane_index: int, ctx: Dict[str, Any]) -> None:
return return
device_names = _resolve_lane_device_names(lane_index, ctx) device_names = _resolve_lane_device_names(lane_index, ctx)
macs = _device_names_to_macs(device_names, ctx["devices"]) if not device_names:
if not macs:
return return
sender = get_current_sender() 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 {} zone_doc = ctx.get("zone_doc") if isinstance(ctx.get("zone_doc"), dict) else {}
devices_model = ctx["devices"] 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) wire = str(preset_id)
auto = _coerce_auto(display_preset) auto = _coerce_auto(display_preset)
sel: Dict[str, Any] = {}
for n in device_names:
if n:
sel[str(n)] = [wire]
delay_s = 0.05 delay_s = 0.05
for mac in macs:
body: Dict[str, Any] = {"v": "1", "presets": dict(inner_by_wire)} body: Dict[str, Any] = {"v": "1", "presets": dict(inner_by_wire)}
if sel: if gids:
body["select"] = sel body["groups"] = list(gids)
if auto:
body["select"] = [wire]
msg = json.dumps(body, separators=(",", ":")) msg = json.dumps(body, separators=(",", ":"))
await deliver_json_messages(sender, [msg], [mac], devices_model, delay_s=delay_s) await deliver_json_messages(sender, [msg], None, devices_model, delay_s=delay_s)
if auto: if auto:
clear_sequence_manual_lane_route(lane_index) clear_sequence_manual_lane_route(lane_index)
else: else:
inner = _preset_inner_from_display_preset(display_preset) 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) 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, zone_brightness=zb,
) )
msg = json.dumps({"v": "1", "b": eff, "save": True}, separators=(",", ":")) 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]: def _device_names_to_macs(device_names: List[str], devices: Any) -> List[str]:
@@ -700,32 +708,24 @@ async def _send_lane(
if not sender: if not sender:
raise RuntimeError("Transport not configured") raise RuntimeError("Transport not configured")
macs = _device_names_to_macs(device_names, devices) if not device_names and not gids:
if not macs:
return return
wire = str(preset_id) wire = str(preset_id)
auto = _coerce_auto(display_preset) 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: if auto:
clear_sequence_manual_lane_route(lane_index) clear_sequence_manual_lane_route(lane_index)
sel: Dict[str, Any] = {} await deliver_json_messages(sender, [msg], None, devices, delay_s=0.05)
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)
else: else:
inner = _preset_inner_from_display_preset(display_preset) inner = _preset_inner_from_display_preset(display_preset)
set_sequence_manual_lane_route(lane_index, device_names, wire, inner) set_sequence_manual_lane_route(
sel: Dict[str, Any] = {} lane_index, device_names, wire, inner, group_ids=gids or None
for n in device_names: )
if n: await deliver_json_messages(sender, [msg], None, devices, delay_s=0.05)
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) mark_sequence_manual_lane_select_sent(lane_index)
@@ -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]: def playback_status() -> Dict[str, Any]:
"""Snapshot for UI (e.g. audio status poll): lane 0 step + beats within step, total steps sum.""" """Snapshot for UI (e.g. audio status poll): lane 0 step + beats within step, total steps sum."""
with _beat_run_lock: with _beat_run_lock:
@@ -917,11 +923,20 @@ async def _clear_devices_after_sequence(ctx: Dict[str, Any]) -> None:
if not sender: if not sender:
return return
devices = ctx.get("devices") devices = ctx.get("devices")
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) macs = _union_macs_for_sequence(ctx)
if not macs: if not macs:
return return
msg = json.dumps({"v": "1", "clear_presets": True, "save": True}, separators=(",", ":")) body: Dict[str, Any] = {"v": "1", "clear_presets": True, "save": True}
await deliver_json_messages(sender, [msg], macs, devices, delay_s=0.05) 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]]: def _halt_playback_state() -> Optional[Dict[str, Any]]:

123
src/util/v1_wire.py Normal file
View 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"))

View File

@@ -1,13 +1,9 @@
#!/usr/bin/env python3 #!/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 The simplified ``espnow-sender`` forwards each WebSocket message unchanged to
unchanged to ESP-NOW ``ff:ff:ff:ff:ff:ff``. No ``pack_ws_downlink`` wrapper ESP-NOW ``ff:ff:ff:ff:ff:ff``. Drivers accept JSON when the payload starts with
and no 1-byte ack — raw wire packets only (see ``docs/espnow-binary-protocol.md``). ``{`` (see ``led-driver/src/main.py``).
Group membership is expected to be configured on each **led-driver**; this
script only broadcasts **CMD** (and optional **GROUPS** / **GROUP_CMD** for
manual testing).
Examples:: Examples::
@@ -18,8 +14,6 @@ Examples::
pipenv run python tests/bridge_broadcast_test.py --brightness 200 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 --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 from __future__ import annotations
@@ -33,20 +27,7 @@ from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1] PROJECT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PROJECT_ROOT / "src")) sys.path.insert(0, str(PROJECT_ROOT / "src"))
from util.espnow_wire import ( # noqa: E402 from util.espnow_message import build_message # 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",
}
def _load_bridge_url(explicit: str | None) -> str: 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" return "ws://192.168.4.1/ws"
def _describe_packet(pkt: bytes) -> str: async def _send_messages(url: str, messages: list[bytes], delay_s: float) -> None:
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:
import websockets import websockets
print(f"connecting to {url}") print(f"connecting to {url}")
async with websockets.connect(url, ping_interval=20, ping_timeout=20) as ws: async with websockets.connect(url, ping_interval=20, ping_timeout=20) as ws:
print("connected (broadcast passthrough)") print("connected (broadcast JSON passthrough)")
for i, pkt in enumerate(packets): for i, pkt in enumerate(messages):
print(f" send [{i + 1}/{len(packets)}] {_describe_packet(pkt)}") 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) 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) await asyncio.sleep(delay_s)
print("done") print("done")
def _build_packets(args: argparse.Namespace) -> list[bytes]: def _build_messages(args: argparse.Namespace) -> list[bytes]:
packets: list[bytes] = [] messages: list[bytes] = []
if args.groups: if args.brightness is not None:
gids = [g.strip() for g in args.groups.split(",") if g.strip()] body: dict = {
if gids: "v": "1",
packets.append(pack_groups(gids)) "b": max(0, min(255, int(args.brightness))),
}
if args.group_cmd: if args.save:
packets.append( body["save"] = True
pack_group_cmd_from_kwargs( messages.append(json.dumps(body, separators=(",", ":")).encode("utf-8"))
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.select: if args.select:
packets.append( messages.append(
pack_cmd_from_kwargs( build_message(
select={args.select: [args.state]}, select={args.select: [args.state]},
save=args.save, save=args.save,
) ).encode("utf-8")
) )
if args.off: if args.off:
if args.select: if args.select:
packets.append( messages.append(
pack_cmd_from_kwargs(select={args.select: ["off"]}, save=args.save) build_message(select={args.select: ["off"]}, save=args.save).encode("utf-8")
) )
else: 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: if not messages:
packets.append(pack_cmd_from_kwargs(brightness_0_255=128)) messages.append(
packets.append(pack_cmd_from_kwargs(select={"all": ["on"]})) json.dumps({"v": "1", "b": 128}, separators=(",", ":")).encode("utf-8")
packets.append(pack_cmd_from_kwargs(select={"all": ["off"]})) )
messages.append(
build_message(select={"all": ["on"]}).encode("utf-8")
)
messages.append(
build_message(select={"all": ["off"]}).encode("utf-8")
)
for pkt in packets: for pkt in messages:
if wire_msg_type(pkt) is None: if not pkt or pkt[0:1] != b"{":
raise ValueError("built packet is not valid wire format") raise ValueError("built message is not v1 JSON")
return packets return messages
def main() -> int: def main() -> int:
parser = argparse.ArgumentParser( 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( parser.add_argument(
"--url", "--url",
@@ -148,7 +122,7 @@ def main() -> int:
"--delay", "--delay",
type=float, type=float,
default=0.5, default=0.5,
help="Seconds between packets (default: 0.5)", help="Seconds between messages (default: 0.5)",
) )
parser.add_argument( parser.add_argument(
"--brightness", "--brightness",
@@ -156,12 +130,12 @@ def main() -> int:
type=int, type=int,
default=None, default=None,
metavar="0-255", metavar="0-255",
help="Broadcast CMD: global brightness", help="Global brightness (b field)",
) )
parser.add_argument( parser.add_argument(
"--select", "--select",
metavar="DEVICE_NAME", 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( parser.add_argument(
"--state", "--state",
@@ -171,46 +145,36 @@ def main() -> int:
parser.add_argument( parser.add_argument(
"--off", "--off",
action="store_true", action="store_true",
help="After other commands, send select off (all devices if --select omitted)", help="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)",
) )
parser.add_argument( parser.add_argument(
"--save", "--save",
action="store_true", action="store_true",
help="Set save flag on CMD / GROUP_CMD envelopes", help="Set save flag on messages",
) )
parser.add_argument( parser.add_argument(
"--dry-run", "--dry-run",
action="store_true", action="store_true",
help="Print packets only; do not connect", help="Print messages only; do not connect",
) )
args = parser.parse_args() args = parser.parse_args()
url = _load_bridge_url(args.url) url = _load_bridge_url(args.url)
try: try:
packets = _build_packets(args) messages = _build_messages(args)
except ValueError as e: except ValueError as e:
print(f"error: {e}", file=sys.stderr) print(f"error: {e}", file=sys.stderr)
return 1 return 1
print(f"url={url!r} packets={len(packets)}") print(f"url={url!r} messages={len(messages)}")
for pkt in packets: for pkt in messages:
print(f" {_describe_packet(pkt)} hex={pkt.hex()}") print(f" {pkt.decode('utf-8')}")
if args.dry_run: if args.dry_run:
return 0 return 0
try: try:
asyncio.run(_send_packets(url, packets, args.delay)) asyncio.run(_send_messages(url, messages, args.delay))
except KeyboardInterrupt: except KeyboardInterrupt:
print("interrupted") print("interrupted")
return 130 return 130

View File

@@ -43,7 +43,7 @@ def test_suppress_next_notify_skips_one_select(monkeypatch):
assert delivered == [] assert delivered == []
bdr.notify_beat_detected() bdr.notify_beat_detected()
assert delivered == [(["desk"], "5")] assert delivered == [("5", None)]
def test_suppress_does_not_advance_beat_counter(monkeypatch): 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( bdr.set_sequence_manual_lane_route(
0, 0,
["desk"], ["desk"],
"42", "5",
{"p": "radiate", "a": False, "manual_beat_n": 2}, {"p": "chase", "a": False, "manual_beat_n": 2},
) )
bdr.mark_sequence_manual_lane_select_sent(0) bdr.mark_sequence_manual_lane_select_sent(0)
@@ -61,14 +61,14 @@ def test_suppress_does_not_advance_beat_counter(monkeypatch):
assert delivered == [] assert delivered == []
bdr.notify_beat_detected() bdr.notify_beat_detected()
assert delivered == [(["desk"], "42")] assert delivered == [("5", None)]
delivered.clear() delivered.clear()
bdr.notify_beat_detected() bdr.notify_beat_detected()
assert delivered == [] assert delivered == []
bdr.notify_beat_detected() bdr.notify_beat_detected()
assert delivered == [(["desk"], "42")] assert delivered == [("5", None)]
def test_duplicate_lanes_dedupe_to_one_select_per_beat(monkeypatch): 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._lane_manual[0] = dict(entry)
bdr.notify_beat_detected() 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): def test_standalone_overlay_skipped_when_sequence_lane_covers(monkeypatch):
delivered = _patch_delivery(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.set_sequence_manual_lane_route(1, ["desk"], "5", body)
bdr._apply_manual_beat_route_standalone_overlay(["desk"], "42", body) bdr._apply_manual_beat_route_standalone_overlay(["desk"], "5", body)
with bdr._route_lock: with bdr._route_lock:
assert -1 not in bdr._lane_manual assert -1 not in bdr._lane_manual
assert 1 in bdr._lane_manual assert 1 in bdr._lane_manual
bdr.notify_beat_detected() bdr.notify_beat_detected()
assert delivered == [(["desk"], "42")] assert delivered == [("5", None)]

View File

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

View File

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

View File

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

View File

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