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

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

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

View File

@@ -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
View File

@@ -0,0 +1,151 @@
"""Build v1 devices envelope for Pi → bridge WebSocket (short wire keys)."""
from __future__ import annotations
import json
from typing import Any, Dict, List, Optional, Union
from util.v1_wire import (
ENV_DEVICES,
K_GROUPS,
K_SAVE,
K_SET_GROUPS,
compact_body,
compact_envelope,
wire_json_size,
)
BROADCAST_MAC = "ff:ff:ff:ff:ff:ff"
BROADCAST_HEX = "ffffffffffff"
MAX_ESPNOW_PAYLOAD = 250
def normalize_mac_key(mac: Optional[str]) -> Optional[str]:
if mac is None:
return None
s = str(mac).strip().lower().replace(":", "").replace("-", "")
if len(s) == 12 and all(c in "0123456789abcdef" for c in s):
return s
return None
def format_mac_key(mac_hex: str) -> str:
h = normalize_mac_key(mac_hex)
if not h:
raise ValueError("invalid mac")
return ":".join(h[i : i + 2] for i in range(0, 12, 2))
def is_broadcast_mac(mac: Optional[str]) -> bool:
h = normalize_mac_key(mac)
return h == BROADCAST_HEX
def build_devices_envelope(devices: Dict[str, Dict[str, Any]]) -> Dict[str, Any]:
"""Wrap per-MAC bodies in a v1 envelope (short ``dv`` key)."""
compact_devices = {
mac: compact_body(body) for mac, body in devices.items() if isinstance(body, dict)
}
return {"v": "1", ENV_DEVICES: compact_devices}
def build_groups_envelope(mac_hex: str, group_ids: List[str]) -> Dict[str, Any]:
key = format_mac_key(mac_hex)
return build_devices_envelope(
{
key: {
K_GROUPS: [str(g) for g in group_ids],
K_SET_GROUPS: True,
}
}
)
def build_v1_body(
*,
presets: Optional[Dict[str, Any]] = None,
select: Optional[Union[List[Any], Dict[str, Any], str]] = None,
save: bool = False,
default: Optional[str] = None,
brightness: Optional[int] = None,
groups: Optional[List[str]] = None,
set_groups: bool = False,
) -> Dict[str, Any]:
body: Dict[str, Any] = {}
if presets:
body["presets"] = presets
if select is not None:
body["select"] = select
if save:
body["save"] = True
if default is not None:
body["default"] = str(default)
if brightness is not None:
body["b"] = max(0, min(255, int(brightness)))
if groups is not None:
body["groups"] = [str(g) for g in groups]
if set_groups:
body["set_groups"] = True
return compact_body(body)
def v1_body_size(body: Dict[str, Any]) -> int:
return wire_json_size({"v": "1", **compact_body(body)})
def envelope_payload_size(envelope: Dict[str, Any]) -> int:
return wire_json_size(compact_envelope(envelope))
def split_v1_body_for_espnow(body: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Split a device body into chunks each <= MAX_ESPNOW_PAYLOAD bytes on the wire."""
from util.v1_wire import K_PRESETS, K_SAVE, K_SELECT, expand_body
long_body = expand_body(body)
compact = compact_body(long_body)
if v1_body_size(long_body) <= MAX_ESPNOW_PAYLOAD:
return [compact]
chunks: List[Dict[str, Any]] = []
meta = {k: v for k, v in compact.items() if k not in (K_PRESETS, K_SELECT)}
presets = compact.get(K_PRESETS)
select = compact.get(K_SELECT)
if presets and isinstance(presets, dict):
preset_msg = {**meta, K_PRESETS: presets}
if wire_json_size({"v": "1", **preset_msg}) <= MAX_ESPNOW_PAYLOAD:
chunks.append(preset_msg)
else:
for pid, pdata in presets.items():
one = {**meta, K_PRESETS: {pid: pdata}}
if wire_json_size({"v": "1", **one}) > MAX_ESPNOW_PAYLOAD:
raise ValueError(f"preset {pid!r} too large for ESP-NOW")
chunks.append(one)
if select is not None:
sel_meta = {k: v for k, v in meta.items() if k != K_SAVE}
sel_msg = {**sel_meta, K_SELECT: select}
if wire_json_size({"v": "1", **sel_msg}) > MAX_ESPNOW_PAYLOAD:
raise ValueError("select payload too large for ESP-NOW")
chunks.append(sel_msg)
if not chunks:
raise ValueError("device body too large to split for ESP-NOW")
return chunks
def merge_preset_and_select(
preset_body: Dict[str, Any],
select_body: Dict[str, Any],
) -> Optional[Dict[str, Any]]:
"""Merge preset + select bodies if combined envelope fits ESP-NOW limit."""
merged = dict(preset_body)
if "select" in select_body:
merged["select"] = select_body["select"]
for key in ("groups", "set_groups"):
if key in select_body and key not in merged:
merged[key] = select_body[key]
env = build_devices_envelope({BROADCAST_MAC: merged})
if envelope_payload_size(env) <= MAX_ESPNOW_PAYLOAD:
return compact_body(merged)
return None

View File

@@ -1,13 +1,97 @@
"""Deliver binary ESP-NOW messages via bridge WebSocket."""
"""Deliver v1 JSON to drivers via bridge devices envelope."""
import asyncio
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)

View File

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

View File

@@ -1,14 +1,92 @@
"""Handle ESP-NOW ANNOUNCE uplink and push GROUPS to drivers."""
"""Handle ESP-NOW uplink from bridge and push group membership."""
from __future__ import annotations
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)

View File

@@ -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
View File

@@ -0,0 +1,123 @@
"""Short v1 field names for ESP-NOW JSON (≤250 B). Long names still accepted on receive."""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Union
# Envelope: devices map
ENV_DEVICES = "dv"
# Device body
K_PRESETS = "p"
K_SELECT = "s"
K_GROUPS = "g"
K_SET_GROUPS = "sg"
K_SAVE = "sv"
K_DEFAULT = "df"
K_DEVICE_CONFIG = "dc"
K_CLEAR_PRESETS = "cp"
K_MANIFEST = "mf"
_BODY_LONG_TO_SHORT = {
"presets": K_PRESETS,
"select": K_SELECT,
"groups": K_GROUPS,
"set_groups": K_SET_GROUPS,
"save": K_SAVE,
"default": K_DEFAULT,
"device_config": K_DEVICE_CONFIG,
"clear_presets": K_CLEAR_PRESETS,
"manifest": K_MANIFEST,
}
_BODY_SHORT_TO_LONG = {v: k for k, v in _BODY_LONG_TO_SHORT.items()}
def wire_select_list(preset_id: Union[str, int], step: Optional[Union[int, str]] = None) -> List[Any]:
"""Preset id (+ optional step) for ``select`` on unicast/broadcast to one driver."""
out: List[Any] = [str(preset_id)]
if step is not None:
out.append(step)
return out
def normalize_select_for_wire(select: Any) -> Any:
"""Long or legacy shapes → wire list ``[preset_id, step?]``."""
if isinstance(select, list):
return select
if isinstance(select, str) and select.strip():
return [select.strip()]
if not isinstance(select, dict):
return select
if "preset" in select:
out: List[Any] = [str(select["preset"])]
if "step" in select:
out.append(select["step"])
return out
# Legacy {device_name: [preset, step?]} — unicast only; keep dict for expand on driver
if len(select) == 1:
val = next(iter(select.values()))
if isinstance(val, list) and val:
return list(val)
return select
def compact_body(body: Dict[str, Any]) -> Dict[str, Any]:
"""Long-key device body → short keys for the wire."""
out: Dict[str, Any] = {}
for long_key, short_key in _BODY_LONG_TO_SHORT.items():
if long_key in body:
val = body[long_key]
if long_key == "select":
val = normalize_select_for_wire(val)
out[short_key] = val
for short_key in _BODY_SHORT_TO_LONG:
if short_key in body and short_key not in out:
val = body[short_key]
if short_key == K_SELECT:
val = normalize_select_for_wire(val)
out[short_key] = val
if "b" in body:
out["b"] = body["b"]
return out
def expand_body(body: Dict[str, Any]) -> Dict[str, Any]:
"""Short or long device body → long keys for driver logic."""
out: Dict[str, Any] = dict(body)
for short_key, long_key in _BODY_SHORT_TO_LONG.items():
if short_key in body and long_key not in out:
out[long_key] = body[short_key]
if short_key in out:
del out[short_key]
return out
def compact_envelope(envelope: Dict[str, Any]) -> Dict[str, Any]:
if envelope.get("v") != "1":
return envelope
devices = envelope.get("devices")
if devices is None:
devices = envelope.get(ENV_DEVICES)
if not isinstance(devices, dict):
return envelope
compact_devices = {mac: compact_body(body) for mac, body in devices.items() if isinstance(body, dict)}
return {"v": "1", ENV_DEVICES: compact_devices}
def expand_envelope(envelope: Dict[str, Any]) -> Dict[str, Any]:
if envelope.get("v") != "1":
return envelope
devices = envelope.get("devices")
if devices is None:
devices = envelope.get(ENV_DEVICES)
if not isinstance(devices, dict):
return envelope
expanded = {mac: expand_body(body) for mac, body in devices.items() if isinstance(body, dict)}
return {"v": "1", "devices": expanded}
def wire_json_size(obj: Dict[str, Any]) -> int:
import json
return len(json.dumps(obj, separators=(",", ":")).encode("utf-8"))