"""Serial port discovery. Technicians connect through the ESP32-S3 native USB (USB-Serial-JTAG, VID 0x303A / PID 0x1001). Its USB serial number is the chip MAC, which is stable across resets and power cycles, so it is used to re-find the same box after the port disappears (power switch toggled). """ from __future__ import annotations import sys from dataclasses import dataclass from serial.tools import list_ports ESPRESSIF_VID = 0x303A USB_SERIAL_JTAG_PID = 0x1001 @dataclass(frozen=True) class PortInfo: device: str description: str vid: int | None pid: int | None serial_number: str | None @property def is_sensorbox(self) -> bool: return self.vid == ESPRESSIF_VID @property def mac(self) -> str | None: """USB-Serial-JTAG reports the MAC as 'AA:BB:CC:DD:EE:FF'.""" sn = self.serial_number or "" parts = sn.split(":") if len(parts) == 6 and all(len(p) == 2 for p in parts): return sn.upper() return None @property def label(self) -> str: if self.is_sensorbox: kind = "Sensorbox (native USB)" if self.pid == USB_SERIAL_JTAG_PID else "Espressif USB" return f"{self.device} - {kind}" + (f" [{self.mac}]" if self.mac else "") return f"{self.device} - {self.description}" def _from_pyserial(p) -> PortInfo: return PortInfo(p.device, p.description or "", p.vid, p.pid, p.serial_number) def list_serial_ports(include_all: bool = False) -> list[PortInfo]: ports = [_from_pyserial(p) for p in list_ports.comports()] if sys.platform == "darwin": # Each macOS device appears twice; /dev/tty.* blocks on open waiting # for carrier detect, /dev/cu.* is the right one for us. ports = [p for p in ports if not p.device.startswith("/dev/tty.")] if not include_all: ports = [p for p in ports if p.is_sensorbox] # Sensorboxes first, then by name. return sorted(ports, key=lambda p: (not p.is_sensorbox, p.device)) def find_port(serial_number: str | None, device: str | None) -> PortInfo | None: """Re-locate a board: prefer the USB serial number (MAC), fall back to name.""" ports = list_serial_ports(include_all=True) if serial_number: for p in ports: if p.serial_number == serial_number: return p if device: for p in ports: if p.device == device: return p return None