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

@@ -1,24 +1,22 @@
{
"v": "1",
"devices": {
"dv": {
"ff:ff:ff:ff:ff:ff": {
"presets": {
"p": {
"preset_id": {
"pattern": "on",
"colors": ["#FF0000"],
"delay": 100,
"brightness": 255,
"auto": true
"p": "on",
"c": ["#FF0000"],
"d": 100,
"b": 255,
"a": true
}
},
"select": {
"preset": "preset_id",
"step": 0
},
"save": true,
"default": "preset_id",
"b": 255
},
"s": ["preset_id", 0],
"sv": true,
"df": "preset_id",
"b": 255,
"g": ["5", "18"],
"sg": true
}
}
}

View File

@@ -0,0 +1,153 @@
"""Route Pi v1 devices envelope to ESP-NOW unicast or broadcast."""
import json
import utime
from espnow_wire import BROADCAST_MAC
from util import parse_mac
from v1_wire import (
ENV_DEVICES,
K_PRESETS,
K_SELECT,
K_SET_GROUPS,
_WIRE_KEYS,
envelope_devices,
normalize_body,
)
MAX_ESPNOW_PAYLOAD = 250
_CHUNK_DELAY_MS = 50
def is_devices_envelope(raw):
if not raw:
return False
if isinstance(raw, str):
raw = raw.encode("utf-8")
if raw[0:1] != b"{":
return False
try:
data = json.loads(raw)
except (ValueError, TypeError):
return False
return (
isinstance(data, dict)
and data.get("v") == "1"
and envelope_devices(data) is not None
)
def _encode_v1(fields):
out = {"v": "1"}
short = normalize_body(fields)
for key in _WIRE_KEYS:
if key in short:
out[key] = short[key]
return json.dumps(out, separators=(",", ":")).encode("utf-8")
def _payload_len(fields):
return len(_encode_v1(fields))
def payloads_from_body(body):
"""One or more ESP-NOW payloads (each <= MAX_ESPNOW_PAYLOAD)."""
if not isinstance(body, dict):
raise ValueError("device body must be object")
short = normalize_body(body)
if _payload_len(short) <= MAX_ESPNOW_PAYLOAD:
return [_encode_v1(short)]
parts = []
meta = {}
for key in _WIRE_KEYS:
if key in short and key not in (K_PRESETS, K_SELECT):
meta[key] = short[key]
presets = short.get(K_PRESETS)
select = short.get(K_SELECT)
if presets and isinstance(presets, dict):
one = dict(meta)
one[K_PRESETS] = presets
if _payload_len(one) <= MAX_ESPNOW_PAYLOAD:
parts.append(_encode_v1(one))
else:
for pid, pdata in presets.items():
chunk = dict(meta)
chunk[K_PRESETS] = {pid: pdata}
if _payload_len(chunk) > MAX_ESPNOW_PAYLOAD:
raise ValueError(
"single preset too large (%d B)" % _payload_len(chunk)
)
parts.append(_encode_v1(chunk))
if select is not None:
sel = dict(meta)
sel.pop(K_SAVE, None)
sel[K_SELECT] = select
if _payload_len(sel) > MAX_ESPNOW_PAYLOAD:
raise ValueError("select too large (%d B)" % _payload_len(sel))
parts.append(_encode_v1(sel))
if not parts:
raise ValueError("driver payload too large (%d B)" % _payload_len(short))
return parts
async def ensure_peer(esp, mac_bytes):
try:
esp.add_peer(mac_bytes)
except Exception:
pass
async def send_unicast(esp, peer_table, mac_bytes, payload):
await ensure_peer(esp, mac_bytes)
peer_table.touch(mac_bytes)
await esp.asend(mac_bytes, payload)
async def _send_payloads(esp, peer_table, dest, payloads):
for i, payload in enumerate(payloads):
if peer_table.is_broadcast_mac(dest):
await ensure_peer(esp, BROADCAST_MAC)
await esp.asend(BROADCAST_MAC, payload)
else:
await send_unicast(esp, peer_table, dest, payload)
if i + 1 < len(payloads):
utime.sleep_ms(_CHUNK_DELAY_MS)
async def send_device_body(esp, peer_table, mac_str, body):
dest = parse_mac(mac_str)
payloads = payloads_from_body(body)
set_groups = bool(body.get("set_groups") or body.get("sg"))
if set_groups:
if peer_table.is_broadcast_mac(dest):
targets = peer_table.peers()
if not targets:
print("set_groups: no peers yet")
return
for peer in targets:
await _send_payloads(esp, peer_table, peer, payloads)
else:
await _send_payloads(esp, peer_table, dest, payloads)
return
await _send_payloads(esp, peer_table, dest, payloads)
async def route_envelope(esp, peer_table, raw):
if isinstance(raw, str):
raw = raw.encode("utf-8")
data = json.loads(raw)
devices = envelope_devices(data) or {}
for mac_str, body in devices.items():
try:
await send_device_body(esp, peer_table, mac_str, body)
except ValueError as err:
print("downlink skip", mac_str, err)
except Exception as err:
print("downlink err", mac_str, err)

View File

@@ -22,6 +22,17 @@ def pack_ws_uplink(peer, espnow_packet):
return bytes([0]) + peer + espnow_packet
def pack_ws_downlink(espnow_packet, peer_mac=None, broadcast=False):
flags = WS_FLAG_BROADCAST if broadcast else 0
if broadcast:
peer = BROADCAST_MAC
else:
if peer_mac is None or len(peer_mac) != 6:
raise ValueError("peer MAC required for unicast downlink")
peer = peer_mac
return bytes([flags]) + peer + espnow_packet
def parse_bridge_channel(pkt):
if len(pkt) >= 3 and pkt[0] == WIRE_MAGIC and pkt[1] == MSG_BRIDGE_CH:
return pkt[2]

View File

@@ -1,4 +1,5 @@
import asyncio
import json
from microdot import Microdot
from microdot.websocket import WebSocketError, with_websocket
@@ -8,6 +9,8 @@ import machine
import network
from settings import Settings
from espnow_wire import BROADCAST_MAC, pack_ws_uplink
from peer_table import PeerTable, load_max_peers
from downlink_router import is_devices_envelope, route_envelope
wdt = machine.WDT(timeout=10000)
wdt.feed()
@@ -16,11 +19,11 @@ print(settings)
app = Microdot()
ch = settings.get("wifi_channel", 6)
ch = settings.get("wifi_channel", 1)
try:
ch = max(1, min(11, int(ch)))
except (TypeError, ValueError):
ch = 6
ch = 1
ap_if = network.WLAN(network.AP_IF)
ap_if.active(True)
@@ -39,9 +42,23 @@ esp = aioespnow.AIOESPNow()
esp.active(True)
esp.add_peer(BROADCAST_MAC)
peer_table = PeerTable(load_max_peers())
clients = set()
def _note_uplink_peer(host, msg):
if host and len(host) == 6:
name = None
if msg and msg[0:1] == b"{":
try:
data = json.loads(msg)
if isinstance(data, dict):
name = data.get("name")
except (ValueError, TypeError):
pass
peer_table.touch(host, name)
@app.route("/ws")
@with_websocket
async def ws(request, ws):
@@ -55,8 +72,15 @@ async def ws(request, ws):
break
if not raw:
break
if isinstance(raw, str):
raw = raw.encode("utf-8")
try:
await esp.asend(BROADCAST_MAC, raw)
if is_devices_envelope(raw):
await route_envelope(esp, peer_table, raw)
else:
await esp.asend(BROADCAST_MAC, raw)
print(raw)
print("ws tx", len(raw), "B")
except Exception as err:
print(err)
break
@@ -68,6 +92,7 @@ async def _espnow_receive_loop():
async for host, msg in esp:
if not host or not msg:
continue
_note_uplink_peer(host, msg)
print("espnow rx", len(msg), "B")
frame = pack_ws_uplink(host, msg)
dead = []

View File

@@ -0,0 +1,43 @@
"""LRU table of ESP-NOW peer MACs seen on uplink."""
from espnow_wire import BROADCAST_MAC
try:
from settings import Settings
except ImportError:
Settings = None
class PeerTable:
def __init__(self, max_peers=20):
self._max = max(1, int(max_peers))
self._order = []
self._names = {}
def touch(self, mac_bytes, name=None):
if not mac_bytes or len(mac_bytes) != 6:
return
if mac_bytes in self._order:
self._order.remove(mac_bytes)
elif len(self._order) >= self._max:
old = self._order.pop(0)
self._names.pop(old, None)
self._order.append(mac_bytes)
if name:
self._names[mac_bytes] = str(name)
def peers(self):
return list(self._order)
def is_broadcast_mac(self, mac_bytes):
return mac_bytes == BROADCAST_MAC
def load_max_peers():
if Settings is None:
return 20
try:
s = Settings()
return int(s.get("max_peers", 20))
except Exception:
return 20

View File

@@ -40,7 +40,7 @@ class Settings(dict):
def set_defaults(self):
mac = _sta_mac_hex()
self["name"] = "bridge-" + mac
self["wifi_channel"] = 6
self["wifi_channel"] = 1
self["ap_password"] = ""
self["ap_ip"] = "192.168.4.1"
self["ws_port"] = 80

View File

@@ -0,0 +1,81 @@
"""Short v1 wire keys (MicroPython)."""
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"
ENV_DEVICES = "dv"
_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,
}
def _normalize_select(val):
if isinstance(val, list):
return val
if isinstance(val, str) and val.strip():
return [val.strip()]
if isinstance(val, dict) and "preset" in val:
out = [val["preset"]]
if "step" in val:
out.append(val["step"])
return out
if isinstance(val, dict) and len(val) == 1:
one = next(iter(val.values()))
if isinstance(one, list):
return one
return val
_WIRE_KEYS = (
K_PRESETS,
K_SELECT,
K_SAVE,
K_DEFAULT,
"b",
K_GROUPS,
K_SET_GROUPS,
K_DEVICE_CONFIG,
K_CLEAR_PRESETS,
K_MANIFEST,
)
def normalize_body(body):
"""Long or short body → short keys for encoding."""
if not isinstance(body, dict):
return body
out = {}
for long_key, short_key in _LONG_TO_SHORT.items():
if long_key in body:
val = body[long_key]
if long_key == "select":
val = _normalize_select(val)
out[short_key] = val
elif short_key in body:
out[short_key] = body[short_key]
if "b" in body:
out["b"] = body["b"]
return out
def envelope_devices(data):
if not isinstance(data, dict):
return None
devs = data.get("devices")
if devs is None:
devs = data.get(ENV_DEVICES)
return devs if isinstance(devs, dict) else None