feat(espnow): broadcast delivery with group-filtered routing
Send presets and select on broadcast with groups; unicast only for per-device settings. V1 select as [preset_id, step?]. Sequence steps use beat counts; manual presets get select each beat, auto only on step change. Bridge downlink router, Pi envelope delivery, and tests. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+37
-15
@@ -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
|
||||||
|
|
||||||

|
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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+14
-16
@@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""Route Pi v1 devices envelope to ESP-NOW unicast or broadcast."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import utime
|
||||||
|
|
||||||
|
from espnow_wire import BROADCAST_MAC
|
||||||
|
from util import parse_mac
|
||||||
|
from v1_wire import (
|
||||||
|
ENV_DEVICES,
|
||||||
|
K_PRESETS,
|
||||||
|
K_SELECT,
|
||||||
|
K_SET_GROUPS,
|
||||||
|
_WIRE_KEYS,
|
||||||
|
envelope_devices,
|
||||||
|
normalize_body,
|
||||||
|
)
|
||||||
|
|
||||||
|
MAX_ESPNOW_PAYLOAD = 250
|
||||||
|
_CHUNK_DELAY_MS = 50
|
||||||
|
|
||||||
|
|
||||||
|
def is_devices_envelope(raw):
|
||||||
|
if not raw:
|
||||||
|
return False
|
||||||
|
if isinstance(raw, str):
|
||||||
|
raw = raw.encode("utf-8")
|
||||||
|
if raw[0:1] != b"{":
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return False
|
||||||
|
return (
|
||||||
|
isinstance(data, dict)
|
||||||
|
and data.get("v") == "1"
|
||||||
|
and envelope_devices(data) is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_v1(fields):
|
||||||
|
out = {"v": "1"}
|
||||||
|
short = normalize_body(fields)
|
||||||
|
for key in _WIRE_KEYS:
|
||||||
|
if key in short:
|
||||||
|
out[key] = short[key]
|
||||||
|
return json.dumps(out, separators=(",", ":")).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _payload_len(fields):
|
||||||
|
return len(_encode_v1(fields))
|
||||||
|
|
||||||
|
|
||||||
|
def payloads_from_body(body):
|
||||||
|
"""One or more ESP-NOW payloads (each <= MAX_ESPNOW_PAYLOAD)."""
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
raise ValueError("device body must be object")
|
||||||
|
short = normalize_body(body)
|
||||||
|
if _payload_len(short) <= MAX_ESPNOW_PAYLOAD:
|
||||||
|
return [_encode_v1(short)]
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
meta = {}
|
||||||
|
for key in _WIRE_KEYS:
|
||||||
|
if key in short and key not in (K_PRESETS, K_SELECT):
|
||||||
|
meta[key] = short[key]
|
||||||
|
|
||||||
|
presets = short.get(K_PRESETS)
|
||||||
|
select = short.get(K_SELECT)
|
||||||
|
|
||||||
|
if presets and isinstance(presets, dict):
|
||||||
|
one = dict(meta)
|
||||||
|
one[K_PRESETS] = presets
|
||||||
|
if _payload_len(one) <= MAX_ESPNOW_PAYLOAD:
|
||||||
|
parts.append(_encode_v1(one))
|
||||||
|
else:
|
||||||
|
for pid, pdata in presets.items():
|
||||||
|
chunk = dict(meta)
|
||||||
|
chunk[K_PRESETS] = {pid: pdata}
|
||||||
|
if _payload_len(chunk) > MAX_ESPNOW_PAYLOAD:
|
||||||
|
raise ValueError(
|
||||||
|
"single preset too large (%d B)" % _payload_len(chunk)
|
||||||
|
)
|
||||||
|
parts.append(_encode_v1(chunk))
|
||||||
|
|
||||||
|
if select is not None:
|
||||||
|
sel = dict(meta)
|
||||||
|
sel.pop(K_SAVE, None)
|
||||||
|
sel[K_SELECT] = select
|
||||||
|
if _payload_len(sel) > MAX_ESPNOW_PAYLOAD:
|
||||||
|
raise ValueError("select too large (%d B)" % _payload_len(sel))
|
||||||
|
parts.append(_encode_v1(sel))
|
||||||
|
|
||||||
|
if not parts:
|
||||||
|
raise ValueError("driver payload too large (%d B)" % _payload_len(short))
|
||||||
|
return parts
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_peer(esp, mac_bytes):
|
||||||
|
try:
|
||||||
|
esp.add_peer(mac_bytes)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def send_unicast(esp, peer_table, mac_bytes, payload):
|
||||||
|
await ensure_peer(esp, mac_bytes)
|
||||||
|
peer_table.touch(mac_bytes)
|
||||||
|
await esp.asend(mac_bytes, payload)
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_payloads(esp, peer_table, dest, payloads):
|
||||||
|
for i, payload in enumerate(payloads):
|
||||||
|
if peer_table.is_broadcast_mac(dest):
|
||||||
|
await ensure_peer(esp, BROADCAST_MAC)
|
||||||
|
await esp.asend(BROADCAST_MAC, payload)
|
||||||
|
else:
|
||||||
|
await send_unicast(esp, peer_table, dest, payload)
|
||||||
|
if i + 1 < len(payloads):
|
||||||
|
utime.sleep_ms(_CHUNK_DELAY_MS)
|
||||||
|
|
||||||
|
|
||||||
|
async def send_device_body(esp, peer_table, mac_str, body):
|
||||||
|
dest = parse_mac(mac_str)
|
||||||
|
payloads = payloads_from_body(body)
|
||||||
|
set_groups = bool(body.get("set_groups") or body.get("sg"))
|
||||||
|
|
||||||
|
if set_groups:
|
||||||
|
if peer_table.is_broadcast_mac(dest):
|
||||||
|
targets = peer_table.peers()
|
||||||
|
if not targets:
|
||||||
|
print("set_groups: no peers yet")
|
||||||
|
return
|
||||||
|
for peer in targets:
|
||||||
|
await _send_payloads(esp, peer_table, peer, payloads)
|
||||||
|
else:
|
||||||
|
await _send_payloads(esp, peer_table, dest, payloads)
|
||||||
|
return
|
||||||
|
|
||||||
|
await _send_payloads(esp, peer_table, dest, payloads)
|
||||||
|
|
||||||
|
|
||||||
|
async def route_envelope(esp, peer_table, raw):
|
||||||
|
if isinstance(raw, str):
|
||||||
|
raw = raw.encode("utf-8")
|
||||||
|
data = json.loads(raw)
|
||||||
|
devices = envelope_devices(data) or {}
|
||||||
|
for mac_str, body in devices.items():
|
||||||
|
try:
|
||||||
|
await send_device_body(esp, peer_table, mac_str, body)
|
||||||
|
except ValueError as err:
|
||||||
|
print("downlink skip", mac_str, err)
|
||||||
|
except Exception as err:
|
||||||
|
print("downlink err", mac_str, err)
|
||||||
@@ -22,6 +22,17 @@ def pack_ws_uplink(peer, espnow_packet):
|
|||||||
return bytes([0]) + peer + espnow_packet
|
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]
|
||||||
|
|||||||
@@ -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:
|
||||||
await esp.asend(BROADCAST_MAC, raw)
|
if is_devices_envelope(raw):
|
||||||
|
await route_envelope(esp, peer_table, raw)
|
||||||
|
else:
|
||||||
|
await esp.asend(BROADCAST_MAC, raw)
|
||||||
|
print(raw)
|
||||||
|
print("ws tx", len(raw), "B")
|
||||||
except Exception as err:
|
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 = []
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""LRU table of ESP-NOW peer MACs seen on uplink."""
|
||||||
|
|
||||||
|
from espnow_wire import BROADCAST_MAC
|
||||||
|
|
||||||
|
try:
|
||||||
|
from settings import Settings
|
||||||
|
except ImportError:
|
||||||
|
Settings = None
|
||||||
|
|
||||||
|
|
||||||
|
class PeerTable:
|
||||||
|
def __init__(self, max_peers=20):
|
||||||
|
self._max = max(1, int(max_peers))
|
||||||
|
self._order = []
|
||||||
|
self._names = {}
|
||||||
|
|
||||||
|
def touch(self, mac_bytes, name=None):
|
||||||
|
if not mac_bytes or len(mac_bytes) != 6:
|
||||||
|
return
|
||||||
|
if mac_bytes in self._order:
|
||||||
|
self._order.remove(mac_bytes)
|
||||||
|
elif len(self._order) >= self._max:
|
||||||
|
old = self._order.pop(0)
|
||||||
|
self._names.pop(old, None)
|
||||||
|
self._order.append(mac_bytes)
|
||||||
|
if name:
|
||||||
|
self._names[mac_bytes] = str(name)
|
||||||
|
|
||||||
|
def peers(self):
|
||||||
|
return list(self._order)
|
||||||
|
|
||||||
|
def is_broadcast_mac(self, mac_bytes):
|
||||||
|
return mac_bytes == BROADCAST_MAC
|
||||||
|
|
||||||
|
|
||||||
|
def load_max_peers():
|
||||||
|
if Settings is None:
|
||||||
|
return 20
|
||||||
|
try:
|
||||||
|
s = Settings()
|
||||||
|
return int(s.get("max_peers", 20))
|
||||||
|
except Exception:
|
||||||
|
return 20
|
||||||
@@ -40,7 +40,7 @@ class Settings(dict):
|
|||||||
def set_defaults(self):
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
+1
-1
Submodule led-driver updated: 1fdb2c9441...a97f6c7c2c
+54
-50
@@ -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",
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
+74
-19
@@ -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
|
||||||
sender,
|
for msg in chunk_messages:
|
||||||
chunk_messages,
|
d, _chunks = await deliver_json_messages(
|
||||||
target_list,
|
sender,
|
||||||
Device(),
|
[msg],
|
||||||
str(default_id) if default_id is not None else None,
|
target_list,
|
||||||
delay_s=send_delay_s,
|
Device(),
|
||||||
)
|
delay_s=send_delay_s,
|
||||||
|
unicast=True,
|
||||||
|
)
|
||||||
|
deliveries += d
|
||||||
|
if default_id is not None:
|
||||||
|
def_msg = json.dumps(
|
||||||
|
{"v": "1", "default": str(default_id), "save": True},
|
||||||
|
separators=(",", ":"),
|
||||||
|
)
|
||||||
|
d, _chunks = await deliver_json_messages(
|
||||||
|
sender,
|
||||||
|
[def_msg],
|
||||||
|
target_list,
|
||||||
|
Device(),
|
||||||
|
delay_s=send_delay_s,
|
||||||
|
unicast=True,
|
||||||
|
)
|
||||||
|
deliveries += d
|
||||||
else:
|
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):
|
||||||
messages.append(item)
|
if isinstance(item, str):
|
||||||
else:
|
messages.append(item)
|
||||||
|
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'}
|
||||||
|
|||||||
+5
-7
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+67
-29
@@ -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()
|
||||||
|
|||||||
+1
-1
@@ -51,7 +51,7 @@ class Settings(dict):
|
|||||||
self.save()
|
self.save()
|
||||||
# ESP-NOW STA channel (2.4 GHz) for LED drivers / bridge alignment; 1–11
|
# ESP-NOW STA channel (2.4 GHz) for LED drivers / bridge alignment; 1–11
|
||||||
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'] = ''
|
||||||
|
|||||||
+27
-20
@@ -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.');
|
||||||
|
|||||||
+52
-36
@@ -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 sequence = [presetMessage];
|
|
||||||
// Auto: apply preset immediately via select. Manual: load definition only — first step is on the next audio beat.
|
|
||||||
if (names.length > 0 && presetAuto) {
|
|
||||||
const select = {};
|
|
||||||
names.forEach((name) => {
|
|
||||||
if (name) {
|
|
||||||
select[name] = [wirePresetId];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (Object.keys(select).length > 0) {
|
|
||||||
sequence.push({ v: '1', select });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await postDriverSequence(sequence, targetMacs, 0.05, pushOptions);
|
const groupIds =
|
||||||
|
pushOptions && Array.isArray(pushOptions.groupIds)
|
||||||
|
? pushOptions.groupIds.map((g) => String(g).trim()).filter((g) => g.length > 0)
|
||||||
|
: [];
|
||||||
|
if (groupIds.length > 0) {
|
||||||
|
presetMessage.groups = groupIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
await postDriverSequence([presetMessage], [], 0.05, pushOptions);
|
||||||
} catch (error) {
|
} 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
|
||||||
|
|||||||
+19
-3
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
+183
-108
@@ -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
|
|
||||||
if isinstance(body, dict):
|
|
||||||
packets.append(v1_dict_to_cmd_packet(body))
|
|
||||||
|
|
||||||
if not packets:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
seen = set()
|
|
||||||
ordered = []
|
|
||||||
for raw in target_macs:
|
|
||||||
m = normalize_mac(str(raw)) if raw else None
|
|
||||||
if not m or m in seen:
|
|
||||||
continue
|
continue
|
||||||
seen.add(m)
|
deliveries += await _deliver_v1_body(sender, BROADCAST_MAC, body, delay_s)
|
||||||
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)
|
||||||
|
|||||||
+12
-17
@@ -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):
|
||||||
|
|||||||
+99
-25
@@ -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)
|
||||||
|
|||||||
@@ -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 gids:
|
||||||
if sel:
|
body["groups"] = list(gids)
|
||||||
body["select"] = sel
|
if auto:
|
||||||
msg = json.dumps(body, separators=(",", ":"))
|
body["select"] = [wire]
|
||||||
await deliver_json_messages(sender, [msg], [mac], devices_model, delay_s=delay_s)
|
msg = json.dumps(body, separators=(",", ":"))
|
||||||
|
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,33 +708,25 @@ 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]
|
mark_sequence_manual_lane_select_sent(lane_index)
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
async def _send_all_lanes(ctx: Dict[str, Any]) -> None:
|
async def _send_all_lanes(ctx: Dict[str, Any]) -> None:
|
||||||
@@ -772,6 +772,12 @@ def _build_ctx(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def playback_active() -> bool:
|
||||||
|
"""True while a zone sequence run is active (step timing owned by ``process_active_beat_advance``)."""
|
||||||
|
with _beat_run_lock:
|
||||||
|
return _beat_run is not None
|
||||||
|
|
||||||
|
|
||||||
def playback_status() -> Dict[str, Any]:
|
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")
|
||||||
macs = _union_macs_for_sequence(ctx)
|
zone_doc = ctx.get("zone_doc") if isinstance(ctx.get("zone_doc"), dict) else {}
|
||||||
if not macs:
|
gids: List[str] = []
|
||||||
return
|
zg = zone_doc.get("group_ids") if isinstance(zone_doc, dict) else None
|
||||||
msg = json.dumps({"v": "1", "clear_presets": True, "save": True}, separators=(",", ":"))
|
if isinstance(zg, list):
|
||||||
await deliver_json_messages(sender, [msg], macs, devices, delay_s=0.05)
|
gids = [str(g).strip() for g in zg if str(g).strip()]
|
||||||
|
if not gids:
|
||||||
|
macs = _union_macs_for_sequence(ctx)
|
||||||
|
if not macs:
|
||||||
|
return
|
||||||
|
body: Dict[str, Any] = {"v": "1", "clear_presets": True, "save": True}
|
||||||
|
if gids:
|
||||||
|
body["groups"] = gids
|
||||||
|
msg = json.dumps(body, separators=(",", ":"))
|
||||||
|
await deliver_json_messages(sender, [msg], None, devices, delay_s=0.05)
|
||||||
|
|
||||||
|
|
||||||
def _halt_playback_state() -> Optional[Dict[str, Any]]:
|
def _halt_playback_state() -> Optional[Dict[str, Any]]:
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"""Short v1 field names for ESP-NOW JSON (≤250 B). Long names still accepted on receive."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Optional, Union
|
||||||
|
|
||||||
|
# Envelope: devices map
|
||||||
|
ENV_DEVICES = "dv"
|
||||||
|
|
||||||
|
# Device body
|
||||||
|
K_PRESETS = "p"
|
||||||
|
K_SELECT = "s"
|
||||||
|
K_GROUPS = "g"
|
||||||
|
K_SET_GROUPS = "sg"
|
||||||
|
K_SAVE = "sv"
|
||||||
|
K_DEFAULT = "df"
|
||||||
|
K_DEVICE_CONFIG = "dc"
|
||||||
|
K_CLEAR_PRESETS = "cp"
|
||||||
|
K_MANIFEST = "mf"
|
||||||
|
|
||||||
|
_BODY_LONG_TO_SHORT = {
|
||||||
|
"presets": K_PRESETS,
|
||||||
|
"select": K_SELECT,
|
||||||
|
"groups": K_GROUPS,
|
||||||
|
"set_groups": K_SET_GROUPS,
|
||||||
|
"save": K_SAVE,
|
||||||
|
"default": K_DEFAULT,
|
||||||
|
"device_config": K_DEVICE_CONFIG,
|
||||||
|
"clear_presets": K_CLEAR_PRESETS,
|
||||||
|
"manifest": K_MANIFEST,
|
||||||
|
}
|
||||||
|
|
||||||
|
_BODY_SHORT_TO_LONG = {v: k for k, v in _BODY_LONG_TO_SHORT.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def wire_select_list(preset_id: Union[str, int], step: Optional[Union[int, str]] = None) -> List[Any]:
|
||||||
|
"""Preset id (+ optional step) for ``select`` on unicast/broadcast to one driver."""
|
||||||
|
out: List[Any] = [str(preset_id)]
|
||||||
|
if step is not None:
|
||||||
|
out.append(step)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_select_for_wire(select: Any) -> Any:
|
||||||
|
"""Long or legacy shapes → wire list ``[preset_id, step?]``."""
|
||||||
|
if isinstance(select, list):
|
||||||
|
return select
|
||||||
|
if isinstance(select, str) and select.strip():
|
||||||
|
return [select.strip()]
|
||||||
|
if not isinstance(select, dict):
|
||||||
|
return select
|
||||||
|
if "preset" in select:
|
||||||
|
out: List[Any] = [str(select["preset"])]
|
||||||
|
if "step" in select:
|
||||||
|
out.append(select["step"])
|
||||||
|
return out
|
||||||
|
# Legacy {device_name: [preset, step?]} — unicast only; keep dict for expand on driver
|
||||||
|
if len(select) == 1:
|
||||||
|
val = next(iter(select.values()))
|
||||||
|
if isinstance(val, list) and val:
|
||||||
|
return list(val)
|
||||||
|
return select
|
||||||
|
|
||||||
|
|
||||||
|
def compact_body(body: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Long-key device body → short keys for the wire."""
|
||||||
|
out: Dict[str, Any] = {}
|
||||||
|
for long_key, short_key in _BODY_LONG_TO_SHORT.items():
|
||||||
|
if long_key in body:
|
||||||
|
val = body[long_key]
|
||||||
|
if long_key == "select":
|
||||||
|
val = normalize_select_for_wire(val)
|
||||||
|
out[short_key] = val
|
||||||
|
for short_key in _BODY_SHORT_TO_LONG:
|
||||||
|
if short_key in body and short_key not in out:
|
||||||
|
val = body[short_key]
|
||||||
|
if short_key == K_SELECT:
|
||||||
|
val = normalize_select_for_wire(val)
|
||||||
|
out[short_key] = val
|
||||||
|
if "b" in body:
|
||||||
|
out["b"] = body["b"]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def expand_body(body: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Short or long device body → long keys for driver logic."""
|
||||||
|
out: Dict[str, Any] = dict(body)
|
||||||
|
for short_key, long_key in _BODY_SHORT_TO_LONG.items():
|
||||||
|
if short_key in body and long_key not in out:
|
||||||
|
out[long_key] = body[short_key]
|
||||||
|
if short_key in out:
|
||||||
|
del out[short_key]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def compact_envelope(envelope: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
if envelope.get("v") != "1":
|
||||||
|
return envelope
|
||||||
|
devices = envelope.get("devices")
|
||||||
|
if devices is None:
|
||||||
|
devices = envelope.get(ENV_DEVICES)
|
||||||
|
if not isinstance(devices, dict):
|
||||||
|
return envelope
|
||||||
|
compact_devices = {mac: compact_body(body) for mac, body in devices.items() if isinstance(body, dict)}
|
||||||
|
return {"v": "1", ENV_DEVICES: compact_devices}
|
||||||
|
|
||||||
|
|
||||||
|
def expand_envelope(envelope: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
if envelope.get("v") != "1":
|
||||||
|
return envelope
|
||||||
|
devices = envelope.get("devices")
|
||||||
|
if devices is None:
|
||||||
|
devices = envelope.get(ENV_DEVICES)
|
||||||
|
if not isinstance(devices, dict):
|
||||||
|
return envelope
|
||||||
|
expanded = {mac: expand_body(body) for mac, body in devices.items() if isinstance(body, dict)}
|
||||||
|
return {"v": "1", "devices": expanded}
|
||||||
|
|
||||||
|
|
||||||
|
def wire_json_size(obj: Dict[str, Any]) -> int:
|
||||||
|
import json
|
||||||
|
|
||||||
|
return len(json.dumps(obj, separators=(",", ":")).encode("utf-8"))
|
||||||
@@ -1,13 +1,9 @@
|
|||||||
#!/usr/bin/env python3
|
#!/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
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Tests for bridge WebSocket client reconnect behaviour."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT / "src"))
|
||||||
|
|
||||||
|
from models.bridge_ws_client import BridgeWsClient # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_returns_false_when_not_connected():
|
||||||
|
async def _run():
|
||||||
|
client = BridgeWsClient("ws://127.0.0.1/ws", reconnect_delay_s=0.01)
|
||||||
|
|
||||||
|
async def _no_wait(_timeout=30.0):
|
||||||
|
return False
|
||||||
|
|
||||||
|
client.wait_connected = _no_wait # type: ignore[method-assign]
|
||||||
|
return await client.send_packet({"v": "1", "devices": {}})
|
||||||
|
|
||||||
|
assert asyncio.run(_run()) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_disconnect_clears_connected_event():
|
||||||
|
client = BridgeWsClient("ws://127.0.0.1/ws", reconnect_delay_s=0.01)
|
||||||
|
client._connected.set()
|
||||||
|
client._signal_disconnect()
|
||||||
|
assert not client._connected.is_set()
|
||||||
@@ -676,13 +676,13 @@ def test_groups_sequences_scenes_palettes_patterns_endpoints(server):
|
|||||||
assert "presets" in first and "select" in first
|
assert "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",
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Sequence step ``beats`` hold (e.g. 12 beats on preset 42, then 4 on preset 5)."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
SRC_PATH = os.path.join(PROJECT_ROOT, "src")
|
||||||
|
if SRC_PATH not in sys.path:
|
||||||
|
sys.path.insert(0, SRC_PATH)
|
||||||
|
|
||||||
|
from util import sequence_playback as sp # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def test_step_holds_beats_before_lane_send(monkeypatch):
|
||||||
|
sent = []
|
||||||
|
|
||||||
|
async def fake_send_lane(i, st, ctx):
|
||||||
|
sent.append((int(st.get("stepIdx", 0)), int(st.get("beatCount", 0))))
|
||||||
|
|
||||||
|
monkeypatch.setattr(sp, "_send_lane", fake_send_lane)
|
||||||
|
|
||||||
|
async def noop_stop(**_kwargs):
|
||||||
|
with sp._beat_run_lock:
|
||||||
|
sp._beat_run = None
|
||||||
|
|
||||||
|
monkeypatch.setattr(sp, "stop_playback", noop_stop)
|
||||||
|
|
||||||
|
ctx = {
|
||||||
|
"num_lanes": 1,
|
||||||
|
"loop": False,
|
||||||
|
"lanes": [[{"preset_id": "42", "beats": 12}, {"preset_id": "5", "beats": 4}]],
|
||||||
|
"lane_states": [{"stepIdx": 0, "beatCount": 0, "done": False}],
|
||||||
|
"sequence_loop_beat": 0,
|
||||||
|
}
|
||||||
|
with sp._beat_run_lock:
|
||||||
|
sp._beat_run = ctx
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
for _ in range(11):
|
||||||
|
await sp.process_active_beat_advance()
|
||||||
|
await sp.process_active_beat_advance()
|
||||||
|
for _ in range(3):
|
||||||
|
await sp.process_active_beat_advance()
|
||||||
|
await sp.process_active_beat_advance()
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
assert sent == [(1, 0)]
|
||||||
|
assert ctx["lane_states"][0]["done"] is True
|
||||||
|
with sp._beat_run_lock:
|
||||||
|
sp._beat_run = None
|
||||||
Reference in New Issue
Block a user