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

@@ -1,59 +1,53 @@
"""Transport to LED drivers via ESP-NOW bridge WebSocket."""
import asyncio
import json
from typing import Optional, Union
from models.bridge_ws_client import get_bridge_client
from util.espnow_wire import WIRE_MAGIC, pack_ws_downlink
BROADCAST_MAC_HEX = "ffffffffffff"
# Default: broadcast (6 bytes). Pi always sends 6-byte address + payload to ESP32.
BROADCAST_MAC = bytes.fromhex("ffffffffffff")
def _encode_payload(data):
if isinstance(data, str):
return data.encode()
if isinstance(data, dict):
return json.dumps(data).encode()
return data
def _parse_mac(addr):
"""Convert 12-char hex string or 6-byte bytes to 6-byte MAC."""
if addr is None or addr == b"":
return BROADCAST_MAC
def _parse_mac(addr) -> Optional[bytes]:
if addr is None or addr == "":
return None
if isinstance(addr, bytes) and len(addr) == 6:
return addr
if isinstance(addr, str) and len(addr) == 12:
return bytes.fromhex(addr)
return BROADCAST_MAC
async def _to_thread(func, *args):
to_thread = getattr(asyncio, "to_thread", None)
if to_thread:
return await to_thread(func, *args)
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, func, *args)
if isinstance(addr, str):
s = addr.strip().lower().replace(":", "").replace("-", "")
if len(s) == 12:
return bytes.fromhex(s)
return None
class NullSender:
"""Used when no ESP-NOW UART bridge is configured or the port cannot be opened."""
"""No bridge configured."""
async def send(self, data, addr=None):
return True
class SerialSender:
def __init__(self, port, baudrate, default_addr=None):
import serial
class BridgeWsSender:
"""Send binary ESP-NOW packets via bridge WebSocket client."""
self._serial = serial.Serial(port, baudrate=baudrate, timeout=1)
self._default_addr = _parse_mac(default_addr)
self._write_lock = asyncio.Lock()
async def send(self, data, addr=None):
mac = _parse_mac(addr) if addr is not None else self._default_addr
payload = _encode_payload(data)
async with self._write_lock:
await _to_thread(self._serial.write, mac + payload)
return True
async def send(self, data: Union[bytes, str, dict], addr=None) -> bool:
client = get_bridge_client()
if client is None:
return False
if isinstance(data, (bytes, bytearray)):
packet = bytes(data)
else:
return False
if not packet or packet[0] != WIRE_MAGIC:
return False
peer = _parse_mac(addr)
broadcast = peer is None or addr == BROADCAST_MAC_HEX
return await client.send_espnow(
packet,
peer_mac=peer,
broadcast=broadcast,
)
_current_sender = None
@@ -69,22 +63,11 @@ def get_current_sender():
def get_sender(settings):
# Serial ESP-NOW bridge is opt-in (serial_enabled true); default off for dev / Wi-Fi-only.
if not settings.get("serial_enabled"):
print("[startup] serial bridge disabled (set serial_enabled true in settings.json to enable)")
return NullSender()
port = settings.get("serial_port", "/dev/ttyS0")
raw_port = str(port).strip() if port is not None else ""
if not raw_port or raw_port.lower() in ("none", "off"):
print("[startup] serial bridge disabled (empty serial_port)")
return NullSender()
baudrate = settings.get("serial_baudrate", 912000)
default_addr = settings.get("serial_destination_mac", "ffffffffffff")
try:
return SerialSender(raw_port, baudrate, default_addr=default_addr)
except Exception as e:
url = str(settings.get("bridge_ws_url") or "").strip()
if not url:
print(
f"[startup] serial open failed ({raw_port!r}): {e}; "
"continuing without ESP-NOW bridge (Wi-Fi drivers unchanged)"
"[startup] bridge disabled (set bridge_ws_url in settings.json, e.g. ws://192.168.4.1/ws)"
)
return NullSender()
print(f"[startup] ESP-NOW via bridge WebSocket {url!r}")
return BridgeWsSender()