"""Serial link to the ESP-NOW mesh hub. Owns the COM port while the BT Keyboard window is open. The hub device (the USB-attached M5Stack the app switched into hub mode) speaks a mixed stream on its CDC port: * Binary frames — 0xC8 0x35 | htype | len u16LE | payload | crc16 — carrying mesh traffic (D2H_RX = forwarded node frames, D2H_ACKTAB = periodic per-node delivery table). Host-to-device H2D_SEND frames carry complete, host-encrypted mesh frames for the hub to broadcast. * JSON lines (``{"rsp": ...}``) — responses to control commands (hub_ping, mesh_poll, espnow_hub off). A reader thread demultiplexes the stream; callbacks fire ON THAT THREAD (callers marshal to Tk with ``after``). A 1 Hz hub_ping keeps the hub's host-activity watchdog fed (the hub reverts to a normal node ~5 s after the host goes quiet, so a crashed app never leaves an orphaned hub). """ from __future__ import annotations import json import threading import time from live_protocol import ( HUB_MAGIC0, HUB_MAGIC1, HUB_H2D_SEND, HUB_MAX_FRAME, crc16_ccitt, frame_hub_message, ) PING_INTERVAL_S = 1.0 class MeshLink: """Framed transport over the hub's serial port. The caller hands over an OPEN pyserial handle (borrowed from SerialManager after the espnow_hub handshake) and gets it back untouched after stop(). """ def __init__(self, ser): self._ser = ser self._running = False self._reader: threading.Thread | None = None self._pinger: threading.Thread | None = None self._wlock = threading.Lock() self.on_rx = None # (src_mac_str, mesh_frame_bytes) self.on_acktab = None # (list of dict) self.on_json = None # (dict) self.bytes_sent = 0 self.frames_sent = 0 def start(self, on_rx=None, on_acktab=None, on_json=None) -> None: self.on_rx = on_rx self.on_acktab = on_acktab self.on_json = on_json self._running = True # Short timeout so the reader notices shutdown promptly. try: self._ser.timeout = 0.05 except Exception: pass self._reader = threading.Thread(target=self._reader_main, daemon=True, name="MeshLinkReader") self._reader.start() self._pinger = threading.Thread(target=self._pinger_main, daemon=True, name="MeshLinkPinger") self._pinger.start() def stop(self, timeout: float = 2.0) -> None: self._running = False for t in (self._reader, self._pinger): if t is not None and t.is_alive(): t.join(timeout=timeout) self._reader = None self._pinger = None # ---- TX ---- def send_mesh_frame(self, frame: bytes) -> bool: """Ship one complete (transport header + encrypted payload) mesh frame to the hub for broadcast/caching.""" return self._write(frame_hub_message(HUB_H2D_SEND, frame)) def send_json(self, cmd: dict) -> bool: """Fire-and-forget JSON control command; any response surfaces via on_json on the reader thread.""" try: data = (json.dumps(cmd) + "\n").encode("utf-8") except (TypeError, ValueError): return False return self._write(data) def _write(self, data: bytes) -> bool: with self._wlock: try: self._ser.write(data) self.bytes_sent += len(data) self.frames_sent += 1 return True except Exception: return False # ---- RX ---- def _reader_main(self) -> None: buf = bytearray() while self._running: try: chunk = self._ser.read(256) except Exception: time.sleep(0.2) continue if chunk: buf.extend(chunk) self._drain_buffer(buf) def _drain_buffer(self, buf: bytearray) -> None: while True: if not buf: return b0 = buf[0] if b0 == HUB_MAGIC0: # Binary frame: need full header before length is known. if len(buf) < 5: return if buf[1] != HUB_MAGIC1: del buf[0] # false magic; resync continue length = buf[3] | (buf[4] << 8) if length > HUB_MAX_FRAME: del buf[0] continue total = 5 + length + 2 if len(buf) < total: return htype = buf[2] payload = bytes(buf[5:5 + length]) want = buf[5 + length] | (buf[6 + length] << 8) got = crc16_ccitt(payload, crc16_ccitt(bytes(buf[2:5]))) del buf[:total] if want == got: self._dispatch_binary(htype, payload) continue # JSON / stray text line: consume up to newline. nl = buf.find(b"\n") if nl < 0: # No newline yet. If a binary magic appears later in the # buffer, drop the leading garbage up to it. m = buf.find(bytes([HUB_MAGIC0])) if m > 0: del buf[:m] continue return line = bytes(buf[:nl]).strip() del buf[:nl + 1] if not line: continue try: doc = json.loads(line.decode("utf-8", errors="ignore")) except (json.JSONDecodeError, ValueError): continue if self.on_json: try: self.on_json(doc) except Exception: pass def _dispatch_binary(self, htype: int, payload: bytes) -> None: from live_protocol import HUB_D2H_RX, HUB_D2H_ACKTAB, mac_to_str if htype == HUB_D2H_RX and len(payload) > 6: src = mac_to_str(payload[:6]) if self.on_rx: try: self.on_rx(src, payload[6:]) except Exception: pass elif htype == HUB_D2H_ACKTAB and len(payload) >= 1: n = payload[0] entries = [] off = 1 for _ in range(n): if off + 17 > len(payload): break mac = mac_to_str(payload[off:off + 6]) cum = int.from_bytes(payload[off + 6:off + 10], "little") move = int.from_bytes(payload[off + 10:off + 14], "little") age = payload[off + 14] | (payload[off + 15] << 8) flags = payload[off + 16] entries.append({"mac": mac, "cum": cum, "move": move, "age_ms": age, "flags": flags}) off += 17 if self.on_acktab: try: self.on_acktab(entries) except Exception: pass # ---- Keepalive ---- def _pinger_main(self) -> None: while self._running: self.send_json({"cmd": "hub_ping"}) # Sleep in small steps so stop() returns promptly. for _ in range(int(PING_INTERVAL_S / 0.1)): if not self._running: return time.sleep(0.1)