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