Sharpen device discovery: node names, tiered confidence, no double port

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 <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 4.8 committed 2026-07-06 19:15:33 -04:00
1 parent 7330796829
commit b289565d96
6 files changed
+139 -14

No files matched your search

+13 -2
View File
@@ -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:
+13 -1
View File
@@ -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")
+39 -6
View File
@@ -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]:
+14 -2
View File
@@ -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)
+14 -2
View File
@@ -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
+46 -1
View File
@@ -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"