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:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
153
espnow-sender/src/downlink_router.py
Normal file
153
espnow-sender/src/downlink_router.py
Normal 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)
|
||||
@@ -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]
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
43
espnow-sender/src/peer_table.py
Normal file
43
espnow-sender/src/peer_table.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
81
espnow-sender/src/v1_wire.py
Normal file
81
espnow-sender/src/v1_wire.py
Normal 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
|
||||
Reference in New Issue
Block a user