feat(espnow): Pi bridge client, binary wire, and espnow-sender firmware

Replace serial/Wi-Fi driver transport paths with WebSocket bridge client,
binary espnow_wire delivery, device announce registry, and restructured
espnow-sender (AP + broadcast passthrough). Includes docs and tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-23 22:44:44 +12:00
parent f4ef85c182
commit 4fc3f46866
42 changed files with 4167 additions and 848 deletions

View File

@@ -0,0 +1,62 @@
"""Build binary ESP-NOW CMD / GROUP_CMD packets from preset/select data."""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from util.binary_envelope import pack_binary_envelope_v2
from util.espnow_wire import MAX_ESPNOW_PAYLOAD, pack_cmd, pack_group_cmd
def v1_dict_to_cmd_packet(body: Dict[str, Any]) -> bytes:
save = bool(body.get("save"))
kw: Dict[str, Any] = {}
if "presets" in body:
kw["presets"] = body["presets"]
if "select" in body:
kw["select"] = body["select"]
if "default" in body:
kw["default"] = body["default"]
kw["default_targets"] = body.get("targets")
if "b" in body:
kw["brightness_0_255"] = int(body["b"])
return pack_cmd(pack_binary_envelope_v2(**kw), save=save)
def build_preset_cmd_chunks(
presets_by_name: Dict[str, Any],
*,
save: bool = False,
default: Optional[str] = None,
max_payload: int = MAX_ESPNOW_PAYLOAD,
) -> List[bytes]:
"""Chunk presets into CMD packets each ≤ max_payload bytes."""
entries = list(presets_by_name.items())
chunks: List[bytes] = []
batch: Dict[str, Any] = {}
def _packet_for(presets_map: Dict[str, Any], *, final_save: bool, def_id: Optional[str]):
kw: Dict[str, Any] = {"presets": presets_map}
if def_id is not None:
kw["default"] = def_id
return pack_cmd(pack_binary_envelope_v2(**kw), save=final_save)
for name, preset_obj in entries:
trial = dict(batch)
trial[name] = preset_obj
try:
pkt = _packet_for(trial, final_save=False, def_id=None)
except ValueError:
pkt = b"\xff\xff"
if len(pkt) <= max_payload or not batch:
batch = trial
else:
chunks.append(_packet_for(batch, final_save=False, def_id=None))
batch = {name: preset_obj}
if batch:
chunks.append(
_packet_for(batch, final_save=save, def_id=str(default) if default else None),
)
return [c for c in chunks if c and c[0] == 0x4C]

View File

@@ -1,52 +1,22 @@
"""Push Wi-Fi driver connect/disconnect updates to browser WebSocket clients."""
"""Device status WebSocket broadcasts (ESP-NOW has no live TCP session)."""
import asyncio
import json
import threading
from typing import Any, Set
# Threading lock: safe across asyncio tasks and avoids binding asyncio.Lock to the wrong loop.
_clients_lock = threading.Lock()
_clients: Set[Any] = set()
_ws_clients: set = set()
async def register_device_status_ws(ws: Any) -> None:
with _clients_lock:
_clients.add(ws)
async def register_device_status_ws(ws):
_ws_clients.add(ws)
async def unregister_device_status_ws(ws: Any) -> None:
with _clients_lock:
_clients.discard(ws)
async def unregister_device_status_ws(ws):
_ws_clients.discard(ws)
async def broadcast_device_tcp_status(ip: str, connected: bool) -> None:
from models.wifi_ws_clients import normalize_tcp_peer_ip
ip = normalize_tcp_peer_ip(ip)
if not ip:
return
msg = json.dumps({"type": "device_tcp", "ip": ip, "connected": bool(connected)})
with _clients_lock:
targets = list(_clients)
dead = []
for ws in targets:
try:
await ws.send(msg)
except Exception as exc:
dead.append(ws)
print(f"[device_status_broadcaster] ws.send failed: {exc!r}")
if dead:
with _clients_lock:
for ws in dead:
_clients.discard(ws)
async def broadcast_device_tcp_snapshot_to(ws):
await ws.send(json.dumps({"type": "device_tcp_snapshot", "devices": {}}))
async def broadcast_device_tcp_snapshot_to(ws: Any) -> None:
from models import wifi_ws_clients as tcp
ips = tcp.list_connected_ips()
msg = json.dumps({"type": "device_tcp_snapshot", "connected_ips": ips})
try:
await ws.send(msg)
except Exception as exc:
print(f"[device_status_broadcaster] snapshot send failed: {exc!r}")
async def broadcast_device_tcp_status(mac: str, connected: bool):
pass

View File

@@ -1,70 +1,73 @@
"""Deliver driver JSON messages over serial (ESP-NOW) and/or WebSocket (Wi-Fi drivers)."""
"""Deliver binary ESP-NOW messages via bridge WebSocket."""
import asyncio
import json
from typing import List, Optional, Union
from models.device import normalize_mac
from models.wifi_ws_clients import send_json_line_to_ip
from util.binary_driver_messages import build_preset_cmd_chunks, v1_dict_to_cmd_packet
from util.espnow_wire import BROADCAST_MAC, pack_group_cmd
# Serial bridge (ESP32): broadcast MAC + this envelope → firmware unicasts ``body`` to each peer.
_SPLIT_MODE = "split"
_BROADCAST_MAC_HEX = "ffffffffffff"
_BROADCAST_HEX = "ffffffffffff"
def _split_serial_envelope(inner_json_str, peer_hex_list):
"""One UART frame: broadcast dest + JSON {m:split, peers:[hex,...], body:<object>}."""
body = json.loads(inner_json_str)
env = {"m": _SPLIT_MODE, "peers": list(peer_hex_list), "body": body}
return json.dumps(env, separators=(",", ":"))
async def deliver_binary_packets(
sender,
packets: List[bytes],
target_macs: Optional[List[str]] = None,
*,
delay_s: float = 0.1,
) -> int:
"""Send binary CMD packets unicast per MAC or broadcast when no targets."""
if not packets:
return 0
deliveries = 0
if not target_macs:
for pkt in packets:
if await sender.send(pkt, addr=_BROADCAST_HEX):
deliveries += 1
await asyncio.sleep(delay_s)
return deliveries
seen = set()
ordered: List[str] = []
for raw in target_macs:
m = normalize_mac(str(raw)) if raw else None
if not m or m in seen:
continue
seen.add(m)
ordered.append(m)
for pkt in packets:
for mac in ordered:
if await sender.send(pkt, addr=mac):
deliveries += 1
await asyncio.sleep(delay_s)
return deliveries
def _wifi_message_for_device(msg, device_name):
"""
For Wi-Fi WebSocket fanout, narrow a v1 select map to a single device name.
Returns the original message when no narrowing applies.
"""
if not device_name:
return msg
try:
body = json.loads(msg)
except Exception:
return msg
if not isinstance(body, dict):
return msg
select = body.get("select")
if not isinstance(select, dict):
return msg
if device_name not in select:
return msg
body["select"] = {device_name: select[device_name]}
return json.dumps(body, separators=(",", ":"))
async def deliver_group_binary_packets(
sender,
group_id: str,
packets: List[bytes],
*,
delay_s: float = 0.1,
) -> int:
"""Broadcast GROUP_CMD packets (one ESP-NOW send per packet)."""
from util.espnow_wire import parse_cmd
def _combine_preset_chunks_for_wifi(chunk_messages):
"""Merge chunked v1 preset messages into one v1 JSON string for Wi-Fi."""
merged_presets = {}
save_flag = False
default_id = None
for msg in chunk_messages:
deliveries = 0
for pkt in packets:
env, save = parse_cmd(pkt)
if env is None:
continue
try:
body = json.loads(msg)
except Exception:
g_pkt = pack_group_cmd(str(group_id), env, save=save)
except ValueError:
continue
if not isinstance(body, dict):
continue
presets = body.get("presets")
if isinstance(presets, dict):
merged_presets.update(presets)
if body.get("save"):
save_flag = True
if body.get("default") is not None:
default_id = body.get("default")
out = {"v": "1", "presets": merged_presets}
if save_flag:
out["save"] = True
if default_id is not None:
out["default"] = default_id
return json.dumps(out, separators=(",", ":"))
if await sender.send(g_pkt, addr=_BROADCAST_HEX):
deliveries += 1
await asyncio.sleep(delay_s)
return deliveries
async def deliver_preset_broadcast_then_per_device(
@@ -76,11 +79,24 @@ async def deliver_preset_broadcast_then_per_device(
delay_s=0.1,
):
"""
Send preset definition chunks: ESP-NOW broadcast once per chunk; same chunk to each
Wi-Fi driver over WebSocket. If default_id is set, send a per-target default message
(unicast serial or WebSocket) with targets=[device name] for each registry entry.
chunk_messages: list of v1 JSON strings OR binary CMD bytes.
Converts JSON strings to binary when needed.
"""
if not chunk_messages:
packets: List[bytes] = []
for msg in chunk_messages:
if isinstance(msg, (bytes, bytearray)):
packets.append(bytes(msg))
else:
import json
try:
body = json.loads(msg)
except Exception:
continue
if isinstance(body, dict):
packets.append(v1_dict_to_cmd_packet(body))
if not packets:
return 0
seen = set()
@@ -92,30 +108,9 @@ async def deliver_preset_broadcast_then_per_device(
seen.add(m)
ordered.append(m)
wifi_ips = []
for mac in ordered:
doc = devices_model.read(mac)
if doc and doc.get("transport") == "wifi" and doc.get("address"):
wifi_ips.append(str(doc["address"]).strip())
deliveries = 0
wifi_combined_msg = _combine_preset_chunks_for_wifi(chunk_messages)
for msg in chunk_messages:
tasks = [sender.send(msg, addr=_BROADCAST_MAC_HEX)]
results = await asyncio.gather(*tasks, return_exceptions=True)
if results and results[0] is True:
deliveries += 1
await asyncio.sleep(delay_s)
for ip in wifi_ips:
if not ip:
continue
try:
if await send_json_line_to_ip(ip, wifi_combined_msg):
deliveries += 1
except Exception as e:
print(f"[driver_delivery] Wi-Fi preset send failed: {e!r}")
await asyncio.sleep(delay_s)
deliveries = await deliver_binary_packets(
sender, packets, ordered, delay_s=delay_s
)
if default_id:
did = str(default_id)
@@ -123,20 +118,9 @@ async def deliver_preset_broadcast_then_per_device(
doc = devices_model.read(mac) or {}
name = str(doc.get("name") or "").strip() or mac
body = {"v": "1", "default": did, "save": True, "targets": [name]}
out = json.dumps(body, separators=(",", ":"))
if doc.get("transport") == "wifi" and doc.get("address"):
ip = str(doc["address"]).strip()
try:
if await send_json_line_to_ip(ip, out):
deliveries += 1
except Exception as e:
print(f"[driver_delivery] default Wi-Fi send failed: {e!r}")
else:
try:
await sender.send(out, addr=mac)
deliveries += 1
except Exception as e:
print(f"[driver_delivery] default serial failed: {e!r}")
pkt = v1_dict_to_cmd_packet(body)
if await sender.send(pkt, addr=mac):
deliveries += 1
await asyncio.sleep(delay_s)
return deliveries
@@ -144,26 +128,29 @@ async def deliver_preset_broadcast_then_per_device(
async def deliver_json_messages(sender, messages, target_macs, devices_model, delay_s=0.1):
"""
Send each message string to the bridge and/or Wi-Fi WebSocket clients.
If target_macs is None or empty: one serial send per message (default/broadcast address).
Otherwise: Wi-Fi uses WebSocket in parallel. Multiple ESP-NOW peers are sent in **one** serial
write to the ESP32 (broadcast + split envelope); the bridge unicasts ``body`` to each
peer. A single ESP-NOW peer still uses one unicast serial frame. Wi-Fi and serial
tasks run together in one asyncio.gather.
Returns (delivery_count, chunk_count) where chunk_count is len(messages).
Convert v1 JSON message strings to binary CMD packets and deliver.
Returns (delivery_count, chunk_count).
"""
if not messages:
packets: List[bytes] = []
import json
for msg in messages:
if isinstance(msg, (bytes, bytearray)):
packets.append(bytes(msg))
continue
try:
body = json.loads(msg)
except Exception:
continue
if isinstance(body, dict):
packets.append(v1_dict_to_cmd_packet(body))
if not packets:
return 0, 0
if not target_macs:
deliveries = 0
for msg in messages:
await sender.send(msg)
deliveries += 1
await asyncio.sleep(delay_s)
return deliveries, len(messages)
n = await deliver_binary_packets(sender, packets, None, delay_s=delay_s)
return n, len(packets)
seen = set()
ordered_macs = []
@@ -174,51 +161,5 @@ async def deliver_json_messages(sender, messages, target_macs, devices_model, de
seen.add(m)
ordered_macs.append(m)
deliveries = 0
for msg in messages:
wifi_tasks = []
espnow_hex = []
for mac in ordered_macs:
doc = devices_model.read(mac)
if doc and doc.get("transport") == "wifi":
ip = doc.get("address")
if ip:
name = str(doc.get("name") or "").strip()
wifi_msg = _wifi_message_for_device(msg, name)
wifi_tasks.append(send_json_line_to_ip(ip, wifi_msg))
else:
espnow_hex.append(mac)
tasks = []
espnow_peer_count = 0
if len(espnow_hex) > 1:
tasks.append(
sender.send(
_split_serial_envelope(msg, espnow_hex),
addr=_BROADCAST_MAC_HEX,
)
)
espnow_peer_count = len(espnow_hex)
elif len(espnow_hex) == 1:
tasks.append(sender.send(msg, addr=espnow_hex[0]))
espnow_peer_count = 1
tasks.extend(wifi_tasks)
if tasks:
results = await asyncio.gather(*tasks, return_exceptions=True)
n_serial = len(tasks) - len(wifi_tasks)
for i, r in enumerate(results):
if i < n_serial:
if r is True:
deliveries += espnow_peer_count
elif isinstance(r, Exception):
print(f"[driver_delivery] serial delivery failed: {r!r}")
else:
if r is True:
deliveries += 1
elif isinstance(r, Exception):
print(f"[driver_delivery] Wi-Fi delivery failed: {r!r}")
await asyncio.sleep(delay_s)
return deliveries, len(messages)
n = await deliver_binary_packets(sender, packets, ordered_macs, delay_s=delay_s)
return n, len(packets)

View File

@@ -0,0 +1,70 @@
"""Handle ESP-NOW ANNOUNCE uplink and push GROUPS to drivers."""
from __future__ import annotations
from models.device import Device, normalize_mac # noqa: F401 — re-export for callers
from models.group import Group
from models.bridge_ws_client import get_bridge_client
from util.espnow_wire import mac_bytes_to_hex, pack_groups, parse_announce
from util.groups_for_device import groups_for_mac
async def handle_espnow_announce(peer_mac: bytes, packet: bytes) -> None:
info = parse_announce(packet)
if not info:
return
mac_hex = mac_bytes_to_hex(peer_mac)
if not mac_hex:
return
devices = Device()
did, persisted = devices.upsert_espnow_announced(
mac_hex,
info["name"],
device_type=info.get("device_type", "led"),
num_leds=info.get("num_leds"),
color_order=info.get("color_order"),
startup_mode=info.get("startup_mode"),
brightness=info.get("brightness"),
)
if not did:
return
if persisted:
print(f"[espnow] registered mac={did} name={info['name']!r}")
groups = Group()
gids = groups_for_mac(did, groups)
groups_pkt = pack_groups(gids)
client = get_bridge_client()
if client is None:
print("[espnow] bridge client not configured; groups not sent")
return
ok = await client.send_espnow(groups_pkt, peer_mac=peer_mac)
if ok:
print(f"[espnow] groups -> {did}: {gids}")
else:
print(f"[espnow] groups send failed for {did}")
async def push_groups_for_group_devices(gdoc: dict) -> None:
"""Refresh GROUPS on every MAC listed on a group document."""
mac_list = gdoc.get("devices") if isinstance(gdoc.get("devices"), list) else []
for mac in mac_list:
m = normalize_mac(str(mac))
if m:
await push_groups_to_mac(m)
async def push_groups_to_mac(mac_hex: str) -> bool:
"""Re-send GROUPS packet to one device (after group membership change)."""
mac = normalize_mac(mac_hex)
if not mac:
return False
client = get_bridge_client()
if client is None:
return False
groups = Group()
gids = groups_for_mac(mac, groups)
pkt = pack_groups(gids)
return await client.send_espnow(pkt, peer_mac=bytes.fromhex(mac))

291
src/util/espnow_wire.py Normal file
View File

@@ -0,0 +1,291 @@
"""
ESP-NOW wire format: magic header + message types, Pi↔bridge WebSocket framing.
See docs/espnow-binary-protocol.md.
"""
from __future__ import annotations
import struct
from typing import Any, Dict, List, Optional, Tuple
from util.binary_envelope import (
BINARY_ENVELOPE_VERSION_2,
pack_binary_envelope_v2,
parse_binary_envelope_v2,
)
WIRE_MAGIC = 0x4C
MAX_ESPNOW_PAYLOAD = 250
MSG_ANNOUNCE = 0x01
MSG_GROUPS = 0x02
MSG_CMD = 0x03
MSG_GROUP_CMD = 0x04
MSG_BRIDGE_CH = 0x10
BROADCAST_MAC = bytes.fromhex("ffffffffffff")
WS_FLAG_BROADCAST = 0x01
COLOR_ORDER_TO_ENUM = {
"rgb": 0,
"rbg": 1,
"grb": 2,
"gbr": 3,
"brg": 4,
"bgr": 5,
}
ENUM_TO_COLOR_ORDER = {v: k for k, v in COLOR_ORDER_TO_ENUM.items()}
STARTUP_MODE_TO_ENUM = {"default": 0, "last": 1, "off": 2}
ENUM_TO_STARTUP_MODE = {v: k for k, v in STARTUP_MODE_TO_ENUM.items()}
def normalize_mac_bytes(mac: Any) -> Optional[bytes]:
if mac is None:
return None
if isinstance(mac, (bytes, bytearray)) and len(mac) == 6:
return bytes(mac)
s = str(mac).strip().lower().replace(":", "").replace("-", "")
if len(s) == 12 and all(c in "0123456789abcdef" for c in s):
return bytes.fromhex(s)
return None
def mac_bytes_to_hex(mac: bytes) -> str:
return mac.hex()
def _pack_header(msg_type: int, body: bytes) -> bytes:
pkt = bytes([WIRE_MAGIC, msg_type]) + body
if len(pkt) > MAX_ESPNOW_PAYLOAD:
raise ValueError(f"ESP-NOW packet {len(pkt)} exceeds {MAX_ESPNOW_PAYLOAD}")
return pkt
def pack_announce(
*,
name: str,
num_leds: int,
color_order: str = "rgb",
startup_mode: str = "default",
brightness: int = 32,
device_type: int = 0,
) -> bytes:
name_b = name.encode("utf-8")
if len(name_b) > 250:
raise ValueError("name too long")
co = COLOR_ORDER_TO_ENUM.get(str(color_order).lower(), 0)
sm = STARTUP_MODE_TO_ENUM.get(str(startup_mode).lower(), 0)
body = (
bytes([len(name_b)])
+ name_b
+ struct.pack("<H", max(0, min(65535, int(num_leds))))
+ bytes([co & 7, sm & 3, max(0, min(255, int(brightness))), device_type & 255])
)
return _pack_header(MSG_ANNOUNCE, body)
def parse_announce(payload: bytes) -> Optional[Dict[str, Any]]:
"""Parse full ESP-NOW packet or body-only after type byte."""
if len(payload) >= 2 and payload[0] == WIRE_MAGIC:
if payload[1] != MSG_ANNOUNCE:
return None
body = payload[2:]
else:
body = payload
off = 0
if off + 1 > len(body):
return None
nl = body[off]
off += 1
if off + nl + 5 > len(body):
return None
name = body[off : off + nl].decode("utf-8")
off += nl
num_leds = struct.unpack_from("<H", body, off)[0]
off += 2
co, sm, br, dt = body[off], body[off + 1], body[off + 2], body[off + 3]
return {
"name": name,
"num_leds": num_leds,
"color_order": ENUM_TO_COLOR_ORDER.get(co, "rgb"),
"startup_mode": ENUM_TO_STARTUP_MODE.get(sm, "default"),
"brightness": br,
"device_type": "led" if dt == 0 else str(dt),
}
def pack_groups(group_ids: List[str]) -> bytes:
parts = [bytes([min(255, len(group_ids))])]
for gid in group_ids[:255]:
gb = str(gid).encode("utf-8")
if len(gb) > 250:
raise ValueError("group id too long")
parts.append(bytes([len(gb)]))
parts.append(gb)
return _pack_header(MSG_GROUPS, b"".join(parts))
def parse_groups(payload: bytes) -> Optional[List[str]]:
if len(payload) >= 2 and payload[0] == WIRE_MAGIC:
if payload[1] != MSG_GROUPS:
return None
body = payload[2:]
else:
body = payload
if not body:
return []
off = 0
if off + 1 > len(body):
return None
count = body[off]
off += 1
out: List[str] = []
for _ in range(count):
if off + 1 > len(body):
return None
gl = body[off]
off += 1
if off + gl > len(body):
return None
out.append(body[off : off + gl].decode("utf-8"))
off += gl
return out
def cmd_envelope_size(envelope: bytes) -> int:
from util.binary_envelope import HEADER_LEN
if len(envelope) < HEADER_LEN:
return len(envelope)
lp, ls, ld = envelope[2], envelope[3], envelope[4]
return HEADER_LEN + lp + ls + ld
def pack_cmd(envelope: bytes, *, save: bool = False) -> bytes:
if envelope and envelope[0] != BINARY_ENVELOPE_VERSION_2:
raise ValueError("CMD envelope must be v2 binary")
need = cmd_envelope_size(envelope)
body = envelope[:need]
if save:
body = body + bytes([1])
if len(body) + 2 > MAX_ESPNOW_PAYLOAD:
raise ValueError("CMD envelope too large for ESP-NOW")
return _pack_header(MSG_CMD, body)
def pack_cmd_from_kwargs(*, save: bool = False, **kwargs: Any) -> bytes:
return pack_cmd(pack_binary_envelope_v2(**kwargs), save=save)
def parse_cmd(payload: bytes) -> Tuple[Optional[bytes], bool]:
"""Return (v2 envelope bytes, save_flag) inside CMD packet."""
if len(payload) < 2 or payload[0] != WIRE_MAGIC or payload[1] != MSG_CMD:
return None, False
env = payload[2:]
if not env:
return None, False
need = cmd_envelope_size(env)
if need > len(env):
return None, False
save = len(env) > need and env[need] == 1
return bytes(env[:need]), save
def parse_cmd_as_v1_dict(payload: bytes) -> Optional[Dict[str, Any]]:
env, save = parse_cmd(payload)
if env is None:
return None
data = parse_binary_envelope_v2(env)
if data is None:
return None
if save:
data["save"] = True
return data
def pack_group_cmd(group_id: str, envelope: bytes, *, save: bool = False) -> bytes:
if envelope and envelope[0] != BINARY_ENVELOPE_VERSION_2:
raise ValueError("GROUP_CMD envelope must be v2 binary")
gid_b = str(group_id).encode("utf-8")
if len(gid_b) > 250:
raise ValueError("group id too long")
need = cmd_envelope_size(envelope)
env = envelope[:need]
if save:
env = env + bytes([1])
body = bytes([len(gid_b)]) + gid_b + env
return _pack_header(MSG_GROUP_CMD, body)
def pack_group_cmd_from_kwargs(group_id: str, **kwargs: Any) -> bytes:
return pack_group_cmd(group_id, pack_binary_envelope_v2(**kwargs))
def parse_group_cmd(payload: bytes) -> Optional[Tuple[str, bytes]]:
if len(payload) < 2 or payload[0] != WIRE_MAGIC or payload[1] != MSG_GROUP_CMD:
return None
body = payload[2:]
if not body:
return None
gl = body[0]
if 1 + gl > len(body):
return None
gid = body[1 : 1 + gl].decode("utf-8")
env = body[1 + gl :]
return gid, bytes(env)
def pack_bridge_channel(channel: int) -> bytes:
ch = max(1, min(11, int(channel)))
return _pack_header(MSG_BRIDGE_CH, bytes([ch]))
def parse_bridge_channel(payload: bytes) -> Optional[int]:
if len(payload) < 3 or payload[0] != WIRE_MAGIC or payload[1] != MSG_BRIDGE_CH:
return None
return int(payload[2])
def wire_msg_type(payload: bytes) -> Optional[int]:
if len(payload) >= 2 and payload[0] == WIRE_MAGIC:
return int(payload[1])
return None
def pack_ws_downlink(
espnow_packet: bytes,
*,
peer_mac: Optional[Any] = None,
broadcast: bool = False,
) -> bytes:
flags = WS_FLAG_BROADCAST if broadcast else 0
if broadcast:
peer = BROADCAST_MAC
else:
peer = normalize_mac_bytes(peer_mac)
if peer is None:
raise ValueError("peer MAC required for unicast downlink")
return bytes([flags]) + peer + espnow_packet
def pack_ws_uplink(peer_mac: bytes, espnow_packet: bytes) -> bytes:
peer = normalize_mac_bytes(peer_mac)
if peer is None:
raise ValueError("invalid peer MAC")
return bytes([0]) + peer + espnow_packet
def parse_ws_frame(frame: bytes) -> Tuple[bytes, bytes, bool]:
"""
Returns (peer_mac_6bytes, espnow_packet, is_broadcast_dest).
"""
if len(frame) < 8:
raise ValueError("WS frame too short")
flags = frame[0]
peer = frame[1:7]
pkt = frame[7:]
broadcast = bool(flags & WS_FLAG_BROADCAST) or peer == BROADCAST_MAC
return peer, pkt, broadcast

View File

@@ -0,0 +1,23 @@
"""Resolve group membership for a device MAC."""
from models.device import normalize_mac
def groups_for_mac(mac_hex: str, groups_model) -> list[str]:
"""Return group ids (string keys) that list this device MAC."""
mac = normalize_mac(mac_hex)
if not mac:
return []
out: list[str] = []
for gid in groups_model.list():
doc = groups_model.read(gid)
if not isinstance(doc, dict):
continue
devs = doc.get("devices")
if not isinstance(devs, list):
continue
for d in devs:
if normalize_mac(str(d)) == mac:
out.append(str(gid))
break
return out