"""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