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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user