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:
@@ -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