From b289565d9667fd66402e3e7a419ee9155abf015d Mon Sep 17 00:00:00 2001 From: Jean-Pierre Martineau Date: Mon, 6 Jul 2026 19:15:33 -0400 Subject: [PATCH] Sharpen device discovery: node names, tiered confidence, no double port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Improve the device picker and inventory in three ways: - Remember a device's mesh node name. On a successful connect, learn the radio's own name (adv_name/name, best-effort) and persist it with the remembered device; the picker leads its row with it and the devices table shows it. A failed probe never blocks the connection and preserves any name already on file. - Qualify devices in tiers instead of one over-generous "likely LoRa" flag. A native-USB dev board is a strong "LoRa device"; a bare USB-UART bridge (CP210x/CH340/FTDI) is only a "serial adapter" / "maybe", since the same chip shows up on plenty of non-LoRa hardware. Sorting and the table follow the board > bridge > unknown ranking; JSON gains a `confidence` field. - Stop repeating the COM port. Windows descriptions often already end with "(COM11)", so the label no longer renders "… (COM11) (COM11)". Co-Authored-By: Claude Opus 4.8 --- meshtools/context.py | 15 +++++++++-- meshtools/core/device_store.py | 14 +++++++++- meshtools/core/discovery.py | 45 +++++++++++++++++++++++++++----- meshtools/tools/devices.py | 16 ++++++++++-- meshtools/ui/device_picker.py | 16 ++++++++++-- tests/test_discovery.py | 47 +++++++++++++++++++++++++++++++++- 6 files changed, 139 insertions(+), 14 deletions(-) diff --git a/meshtools/context.py b/meshtools/context.py index 7894e15..41a06db 100644 --- a/meshtools/context.py +++ b/meshtools/context.py @@ -174,11 +174,22 @@ class AppContext: baudrate = self.profile.baudrate if self.profile else 115200 self._device = make_device(mock=False, port=resolution.port, baudrate=baudrate) await self._device.connect() - # Connection succeeded: this is now the last known good device. + # Connection succeeded: this is now the last known good device. Learn its mesh node + # name (best-effort — a probe failure must not block a good connection). if self.selected_device is not None: - self.device_store.remember(self.selected_device) + self.device_store.remember( + self.selected_device, node_name=await self._node_name() + ) return self._device + async def _node_name(self) -> str: + """Return the connected device's own mesh node name, or ``""`` if unavailable.""" + try: + info = await self._device.get_self_info() + except Exception: # noqa: BLE001 - identity probe is best-effort, never fatal + return "" + return str(info.get("adv_name") or info.get("name") or "") + async def aclose(self) -> None: """Stop monitoring and chat, stop the event hub, disconnect, and close the repo.""" if self._monitor is not None: diff --git a/meshtools/core/device_store.py b/meshtools/core/device_store.py index f5d1ebf..68188d3 100644 --- a/meshtools/core/device_store.py +++ b/meshtools/core/device_store.py @@ -26,6 +26,7 @@ class RememberedDevice: stable_id: The device's :attr:`DiscoveredDevice.stable_id` at connect time. port: The serial port it was last seen on (informational; may have changed). label: A friendly label for display in prompts and tables. + node_name: The device's own mesh node name, learned at connect time (may be empty). last_connected: ISO-8601 timestamp of the last successful connection. """ @@ -33,6 +34,7 @@ class RememberedDevice: port: str label: str last_connected: str + node_name: str = "" def matches(self, device: DiscoveredDevice) -> bool: """Return whether ``device`` is the same hardware as this record.""" @@ -69,21 +71,30 @@ class DeviceStore: port=data["port"], label=data.get("label", data["port"]), last_connected=data.get("last_connected", ""), + node_name=data.get("node_name", ""), ) except (KeyError, TypeError): return None - def remember(self, device: DiscoveredDevice) -> None: + def remember(self, device: DiscoveredDevice, *, node_name: str = "") -> None: """Record ``device`` as the last known good connection. Args: device: The device that just connected successfully. + node_name: The device's own mesh node name, if known; preserved across + reconnects and shown in the picker. A blank value keeps any name already + on file rather than erasing it. """ + if not node_name: + existing = self.load() + if existing is not None and existing.matches(device): + node_name = existing.node_name record = RememberedDevice( stable_id=device.stable_id, port=device.port, label=device.label, last_connected=utcnow().isoformat(), + node_name=node_name, ) self._path.parent.mkdir(parents=True, exist_ok=True) payload = { @@ -91,6 +102,7 @@ class DeviceStore: "port": record.port, "label": record.label, "last_connected": record.last_connected, + "node_name": record.node_name, } # Atomic-ish replace so a crash mid-write can't truncate the existing record. tmp = self._path.with_suffix(self._path.suffix + ".tmp") diff --git a/meshtools/core/discovery.py b/meshtools/core/discovery.py index aac689d..a0aab12 100644 --- a/meshtools/core/discovery.py +++ b/meshtools/core/discovery.py @@ -16,7 +16,7 @@ from dataclasses import dataclass from typing import Optional # USB vendor IDs frequently seen on MeshCore/Meshtastic companion hardware and the -# USB-UART bridges they ship with. Used only to flag and sort likely devices. +# USB-UART bridges they ship with. Used only to name, flag, and sort likely devices. KNOWN_LORA_VIDS: dict[int, str] = { 0x303A: "Espressif", # ESP32-S3 / native USB (e.g. XIAO ESP32-S3) 0x10C4: "Silicon Labs", # CP210x UART bridge @@ -27,6 +27,17 @@ KNOWN_LORA_VIDS: dict[int, str] = { 0x1915: "Nordic", # nRF52 native USB } +# Native-USB VIDs of LoRa companion dev boards — the chip *is* the board, so seeing one is +# a strong signal the port is companion hardware. +NATIVE_LORA_VIDS: frozenset[int] = frozenset({0x303A, 0x239A, 0x2886, 0x1915}) + +# Generic USB-UART bridge chips. LoRa boards often use them, but so do countless unrelated +# gadgets (Arduinos, GPS pucks, 3D printers…), so their presence is only a weak hint. +UART_BRIDGE_VIDS: frozenset[int] = frozenset({0x10C4, 0x1A86, 0x0403}) + +# Sort rank per confidence tier: boards first, then bare serial bridges, then everything else. +_CONFIDENCE_RANK: dict[str, int] = {"board": 0, "bridge": 1, "unknown": 2} + @dataclass(slots=True) class DiscoveredDevice: @@ -68,10 +79,25 @@ class DiscoveredDevice: return f"vidpid:{self.vid:04x}:{self.pid:04x}" return f"port:{self.port}" + @property + def confidence(self) -> str: + """How likely this port is a LoRa companion, by USB vendor ID. + + Returns: + ``"board"`` for a LoRa dev board's native USB (a strong signal), ``"bridge"`` + for a generic USB-UART chip (a weak hint — could be anything), or ``"unknown"`` + for an unrecognized adapter. + """ + if self.vid in NATIVE_LORA_VIDS: + return "board" + if self.vid in UART_BRIDGE_VIDS: + return "bridge" + return "unknown" + @property def is_likely_lora(self) -> bool: - """Whether this device's USB vendor ID matches known companion hardware.""" - return self.vid in KNOWN_LORA_VIDS + """Whether the VID belongs to a LoRa board or a serial bridge one commonly uses.""" + return self.confidence != "unknown" @property def vendor_label(self) -> str: @@ -82,14 +108,21 @@ class DiscoveredDevice: @property def label(self) -> str: - """A concise human-friendly label, e.g. ``"Wio SX1262 (COM5)"``.""" + """A concise human-friendly label, e.g. ``"Wio SX1262 (COM5)"``. + + The OS description often already ends with the port (Windows reports + ``"USB Serial Device (COM11)"``), so the port is not appended a second time. + """ name = self.product or self.description or self.vendor_label or "Serial device" + suffix = f"({self.port})" + if name.endswith(suffix): # avoid "… (COM11) (COM11)" + name = name[: -len(suffix)].rstrip() return f"{name} ({self.port})" def _sort_key(device: DiscoveredDevice) -> tuple[int, str]: - """Sort likely-LoRa devices first, then by port name for stable ordering.""" - return (0 if device.is_likely_lora else 1, device.port) + """Sort LoRa boards first, then serial bridges, then unknown adapters; ties by port.""" + return (_CONFIDENCE_RANK[device.confidence], device.port) def discover_devices() -> list[DiscoveredDevice]: diff --git a/meshtools/tools/devices.py b/meshtools/tools/devices.py index 3d05164..ac53012 100644 --- a/meshtools/tools/devices.py +++ b/meshtools/tools/devices.py @@ -17,6 +17,14 @@ from ..context import AppContext from ..core.discovery import discover_devices from .base import Tool, ToolResult, register +#: "LoRa?" table cell per discovery confidence tier: a bare serial bridge is only a "maybe", +#: since the same chip shows up on plenty of non-LoRa hardware. +_LORA_CELL: dict[str, str] = { + "board": "[ok]yes[/ok]", + "bridge": "[warn]maybe[/warn]", + "unknown": "[muted]—[/muted]", +} + @register class DevicesTool(Tool): @@ -79,6 +87,7 @@ class DevicesTool(Tool): "label": d.label, "vendor": d.vendor_label, "likely_lora": d.is_likely_lora, + "confidence": d.confidence, "serial_number": d.serial_number, "stable_id": d.stable_id, "remembered": remembered is not None and remembered.matches(d), @@ -107,12 +116,15 @@ class DevicesTool(Tool): is_remembered = remembered is not None and remembered.matches(d) is_active = d.port == active_port marker = ("●" if is_active else "") + ("★" if is_remembered else "") + device_name = d.product or d.description or "[muted]?[/muted]" + if is_remembered and remembered.node_name: # the mesh name learned on connect + device_name = f"{remembered.node_name} [muted]({device_name})[/muted]" table.add_row( marker, d.port, - d.product or d.description or "[muted]?[/muted]", + device_name, d.vendor_label or "[muted]?[/muted]", - "[ok]yes[/ok]" if d.is_likely_lora else "[muted]—[/muted]", + _LORA_CELL[d.confidence], d.serial_number or "[muted]—[/muted]", ) ctx.ui.show(table) diff --git a/meshtools/ui/device_picker.py b/meshtools/ui/device_picker.py index 8554380..4c80413 100644 --- a/meshtools/ui/device_picker.py +++ b/meshtools/ui/device_picker.py @@ -19,6 +19,14 @@ from .tui import Choice, Separator if TYPE_CHECKING: from .surface import Ui +#: Trailing hint per discovery confidence tier (see :attr:`DiscoveredDevice.confidence`). +#: A bare serial bridge is only a weak hint, so it is not billed as a LoRa device. +_QUALIFIER: dict[str, str] = { + "board": " · LoRa device", + "bridge": " · serial adapter", + "unknown": "", +} + async def prompt_device( ui: "Ui", @@ -51,9 +59,13 @@ async def prompt_device( for device in devices: is_remembered = remembered is not None and remembered.matches(device) star = "★ " if is_remembered else " " + # Lead with the remembered node name so a known radio is recognizable at a glance. + name = f"{remembered.node_name} — " if is_remembered and remembered.node_name else "" vendor = f" [{device.vendor_label}]" if device.vendor_label else "" - lora = " · likely LoRa" if device.is_likely_lora else "" - items.append(Choice(title=f"{star}{device.label}{vendor}{lora}", value=device)) + items.append( + Choice(title=f"{star}{name}{device.label}{vendor}{_QUALIFIER[device.confidence]}", + value=device) + ) if is_remembered: default = device diff --git a/tests/test_discovery.py b/tests/test_discovery.py index bdeec79..7df5b82 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -79,6 +79,39 @@ def test_discover_sorts_likely_lora_first(monkeypatch: pytest.MonkeyPatch) -> No assert devices[0].is_likely_lora and not devices[1].is_likely_lora +def test_confidence_tiers_distinguish_boards_from_bridges() -> None: + """A native-USB board is 'board'; a bare UART bridge is only 'bridge'; else 'unknown'.""" + board = DiscoveredDevice("COM5", vid=0x303A, pid=0x1001) # Espressif native USB + bridge = DiscoveredDevice("COM6", vid=0x10C4, pid=0xEA60) # CP210x UART bridge + unknown = DiscoveredDevice("COM7", vid=0x1234, pid=0x0001) + assert board.confidence == "board" and board.is_likely_lora + assert bridge.confidence == "bridge" and bridge.is_likely_lora + assert unknown.confidence == "unknown" and not unknown.is_likely_lora + + +def test_sort_orders_boards_then_bridges_then_unknown( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Discovery sorts LoRa boards ahead of bare serial bridges, and both ahead of unknown.""" + _patch_ports( + monkeypatch, + [ + FakePortInfo(device="COM9", vid=0x1234, pid=0x0001), # unknown + FakePortInfo(device="COM3", vid=0x10C4, pid=0xEA60), # bridge + FakePortInfo(device="COM1", vid=0x303A, pid=0x1001), # board + ], + ) + assert [d.port for d in discover_devices()] == ["COM1", "COM3", "COM9"] + + +def test_label_does_not_repeat_the_port() -> None: + """A Windows description already ending in '(COM11)' is not suffixed with it again.""" + dev = DiscoveredDevice("COM11", description="USB Serial Device (COM11)") + assert dev.label == "USB Serial Device (COM11)" + # A product name without the port still gets one appended. + assert DiscoveredDevice("COM5", product="Wio SX1262").label == "Wio SX1262 (COM5)" + + def test_stable_id_precedence() -> None: """stable_id prefers serial number, then vid:pid, then the port name.""" assert DiscoveredDevice("COM5", serial_number="SN1", vid=1, pid=2).stable_id == "sn:SN1" @@ -95,15 +128,27 @@ def test_device_store_round_trip(tmp_path: Path) -> None: assert store.load() is None # nothing remembered yet dev = DiscoveredDevice("COM5", serial_number="SN1", product="Wio SX1262") - store.remember(dev) + store.remember(dev, node_name="BaseStation") loaded = store.load() assert loaded is not None assert loaded.stable_id == "sn:SN1" + assert loaded.node_name == "BaseStation" # the mesh name learned on connect assert loaded.matches(dev) assert not loaded.matches(DiscoveredDevice("COM6", serial_number="OTHER")) +def test_remember_keeps_known_node_name_when_none_supplied(tmp_path: Path) -> None: + """A later connect without a node name preserves the previously remembered one.""" + store = DeviceStore(tmp_path / "devices.json") + dev = DiscoveredDevice("COM5", serial_number="SN1") + store.remember(dev, node_name="BaseStation") + + store.remember(dev) # e.g. the identity probe failed this time + loaded = store.load() + assert loaded is not None and loaded.node_name == "BaseStation" + + def test_device_store_tolerates_corrupt_file(tmp_path: Path) -> None: """A corrupt state file is treated as 'nothing remembered'.""" path = tmp_path / "devices.json"