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

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"))