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:
62
src/util/binary_driver_messages.py
Normal file
62
src/util/binary_driver_messages.py
Normal 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]
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
70
src/util/espnow_registry.py
Normal file
70
src/util/espnow_registry.py
Normal 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
291
src/util/espnow_wire.py
Normal 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
|
||||
23
src/util/groups_for_device.py
Normal file
23
src/util/groups_for_device.py
Normal 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
|
||||
Reference in New Issue
Block a user