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

@@ -5,9 +5,11 @@ from models.profile import Profile
from models.pallet import Palette
from models.device import Device, normalize_mac
from models.transport import get_current_sender
from util.driver_delivery import deliver_json_messages, deliver_preset_broadcast_then_per_device
from util.driver_delivery import (
build_preset_json_chunks,
deliver_json_messages,
)
from util.espnow_message import build_message, build_preset_dict
from util.binary_driver_messages import build_preset_cmd_chunks
from util.profile_bundle import export_preset_bundle, import_preset_bundle
import json
@@ -228,7 +230,7 @@ async def send_presets(request, session):
send_delay_s = 0.1
total_presets = len(presets_by_name)
chunk_messages = build_preset_cmd_chunks(
chunk_messages = build_preset_json_chunks(
presets_by_name,
save=save_flag,
default=str(default_id) if default_id is not None else None,
@@ -249,20 +251,51 @@ async def send_presets(request, session):
dm = normalize_mac(str(destination_mac))
target_list = [dm] if dm else None
group_ids = data.get("group_ids") or data.get("groups")
if isinstance(group_ids, list):
group_ids = [str(g).strip() for g in group_ids if str(g).strip()]
else:
group_ids = None
unicast = bool(data.get("unicast")) or bool(destination_mac)
try:
if target_list:
deliveries = await deliver_preset_broadcast_then_per_device(
sender,
chunk_messages,
target_list,
Device(),
str(default_id) if default_id is not None else None,
delay_s=send_delay_s,
)
if unicast and target_list:
deliveries = 0
for msg in chunk_messages:
d, _chunks = await deliver_json_messages(
sender,
[msg],
target_list,
Device(),
delay_s=send_delay_s,
unicast=True,
)
deliveries += d
if default_id is not None:
def_msg = json.dumps(
{"v": "1", "default": str(default_id), "save": True},
separators=(",", ":"),
)
d, _chunks = await deliver_json_messages(
sender,
[def_msg],
target_list,
Device(),
delay_s=send_delay_s,
unicast=True,
)
deliveries += d
else:
wire_messages = []
for msg in chunk_messages:
body = json.loads(msg)
if group_ids:
body["groups"] = list(group_ids)
wire_messages.append(json.dumps(body, separators=(",", ":")))
deliveries, _chunks = await deliver_json_messages(
sender,
chunk_messages,
wire_messages,
None,
Device(),
delay_s=send_delay_s,
@@ -315,13 +348,32 @@ async def push_driver_messages(request, session):
return json.dumps({"error": "Transport not configured"}), 503, {'Content-Type': 'application/json'}
messages = []
for item in seq:
if isinstance(item, dict):
messages.append(json.dumps(item))
elif isinstance(item, str):
messages.append(item)
else:
i = 0
while i < len(seq):
item = seq[i]
if not isinstance(item, dict):
if isinstance(item, str):
messages.append(item)
i += 1
continue
return json.dumps({"error": "sequence items must be objects or strings"}), 400, {'Content-Type': 'application/json'}
nxt = seq[i + 1] if i + 1 < len(seq) else None
if (
isinstance(nxt, dict)
and "presets" in item
and "select" not in item
and "select" in nxt
and "presets" not in nxt
):
combined = dict(item)
combined["select"] = nxt["select"]
combined_str = json.dumps(combined, separators=(",", ":"))
if len(combined_str.encode("utf-8")) <= 248:
messages.append(combined_str)
i += 2
continue
messages.append(json.dumps(item, separators=(",", ":")))
i += 1
delay_s = data.get("delay_s", 0.05)
try:
@@ -329,6 +381,8 @@ async def push_driver_messages(request, session):
except (TypeError, ValueError):
delay_s = 0.05
unicast = bool(data.get("unicast"))
try:
deliveries, _chunks = await deliver_json_messages(
sender,
@@ -336,6 +390,7 @@ async def push_driver_messages(request, session):
target_list,
Device(),
delay_s=delay_s,
unicast=unicast,
)
except Exception:
return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'}