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: