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>
143 lines
4.6 KiB
Python
143 lines
4.6 KiB
Python
"""Persistent WebSocket client to the ESP-NOW bridge (binary frames)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from typing import Awaitable, Callable, Optional
|
|
|
|
import websockets
|
|
from websockets.exceptions import ConnectionClosed
|
|
|
|
from util.espnow_wire import (
|
|
MSG_ANNOUNCE,
|
|
WIRE_MAGIC,
|
|
pack_bridge_channel,
|
|
pack_ws_downlink,
|
|
parse_ws_frame,
|
|
wire_msg_type,
|
|
)
|
|
|
|
UplinkHandler = Callable[[bytes, bytes], Awaitable[None]]
|
|
|
|
|
|
class BridgeWsClient:
|
|
def __init__(self, url: str, *, wifi_channel: int = 6):
|
|
self._url = url.strip()
|
|
self._wifi_channel = wifi_channel
|
|
self._ws: Optional[websockets.WebSocketClientProtocol] = None
|
|
self._send_lock = asyncio.Lock()
|
|
self._uplink_handler: Optional[UplinkHandler] = None
|
|
self._task: Optional[asyncio.Task] = None
|
|
self._connected = asyncio.Event()
|
|
self._ack_waiter: Optional[asyncio.Future] = None
|
|
|
|
def set_uplink_handler(self, handler: Optional[UplinkHandler]) -> None:
|
|
self._uplink_handler = handler
|
|
|
|
async def run_forever(self) -> None:
|
|
while True:
|
|
try:
|
|
await self._connect_once()
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception as e:
|
|
print(f"[bridge] connection error: {e!r}")
|
|
self._connected.clear()
|
|
self._ws = None
|
|
await asyncio.sleep(2.0)
|
|
|
|
async def _reader_loop(self) -> None:
|
|
ws = self._ws
|
|
if ws is None:
|
|
return
|
|
try:
|
|
async for message in ws:
|
|
if isinstance(message, str):
|
|
continue
|
|
if len(message) == 1:
|
|
fut = self._ack_waiter
|
|
if fut is not None and not fut.done():
|
|
fut.set_result(message[0] == 0x01)
|
|
continue
|
|
try:
|
|
peer, pkt, _bcast = parse_ws_frame(message)
|
|
except ValueError:
|
|
continue
|
|
if wire_msg_type(pkt) == MSG_ANNOUNCE and self._uplink_handler:
|
|
await self._uplink_handler(peer, pkt)
|
|
except ConnectionClosed:
|
|
pass
|
|
|
|
async def _connect_once(self) -> None:
|
|
print(f"[bridge] connecting to {self._url}")
|
|
async with websockets.connect(self._url, ping_interval=20, ping_timeout=20) as ws:
|
|
self._ws = ws
|
|
ch_pkt = pack_bridge_channel(self._wifi_channel)
|
|
await ws.send(pack_ws_downlink(ch_pkt, broadcast=True))
|
|
self._connected.set()
|
|
print("[bridge] connected")
|
|
reader = asyncio.create_task(self._reader_loop())
|
|
try:
|
|
while True:
|
|
await asyncio.sleep(3600)
|
|
finally:
|
|
reader.cancel()
|
|
try:
|
|
await reader
|
|
except asyncio.CancelledError:
|
|
pass
|
|
|
|
async def wait_connected(self, timeout: float = 30.0) -> bool:
|
|
try:
|
|
await asyncio.wait_for(self._connected.wait(), timeout=timeout)
|
|
return True
|
|
except asyncio.TimeoutError:
|
|
return False
|
|
|
|
async def send_frame(self, frame: bytes) -> bool:
|
|
await self._connected.wait()
|
|
ws = self._ws
|
|
if ws is None:
|
|
return False
|
|
async with self._send_lock:
|
|
loop = asyncio.get_running_loop()
|
|
self._ack_waiter = loop.create_future()
|
|
try:
|
|
await ws.send(frame)
|
|
return bool(await asyncio.wait_for(self._ack_waiter, timeout=5.0))
|
|
except (ConnectionClosed, asyncio.TimeoutError, OSError) as e:
|
|
print(f"[bridge] send failed: {e!r}")
|
|
return False
|
|
finally:
|
|
self._ack_waiter = None
|
|
|
|
async def send_espnow(
|
|
self,
|
|
packet: bytes,
|
|
*,
|
|
peer_mac: Optional[str] = None,
|
|
broadcast: bool = False,
|
|
) -> bool:
|
|
if not packet or packet[0] != WIRE_MAGIC:
|
|
raise ValueError("packet must be espnow wire format")
|
|
frame = pack_ws_downlink(packet, peer_mac=peer_mac, broadcast=broadcast)
|
|
return await self.send_frame(frame)
|
|
|
|
def start(self) -> asyncio.Task:
|
|
if self._task is None or self._task.done():
|
|
self._task = asyncio.create_task(self.run_forever())
|
|
return self._task
|
|
|
|
|
|
_client: Optional[BridgeWsClient] = None
|
|
|
|
|
|
def get_bridge_client() -> Optional[BridgeWsClient]:
|
|
return _client
|
|
|
|
|
|
def init_bridge_client(url: str, *, wifi_channel: int = 6) -> BridgeWsClient:
|
|
global _client
|
|
_client = BridgeWsClient(url, wifi_channel=wifi_channel)
|
|
return _client
|