""" 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_PING_REQ = 0x05 MSG_PING_RSP = 0x06 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(" 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(" 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_ping_req(ping_id: int) -> bytes: body = struct.pack(" Optional[int]: """Return ping_id from a PING_REQ packet or body.""" if len(payload) >= 2 and payload[0] == WIRE_MAGIC: if payload[1] != MSG_PING_REQ: return None body = payload[2:] else: body = payload if len(body) < 4: return None return int(struct.unpack_from(" bytes: name_b = name.encode("utf-8") if len(name_b) > 250: raise ValueError("name too long") body = struct.pack(" Optional[Dict[str, Any]]: if len(payload) >= 2 and payload[0] == WIRE_MAGIC: if payload[1] != MSG_PING_RSP: return None body = payload[2:] else: body = payload if len(body) < 5: return None ping_id = int(struct.unpack_from(" 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