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:
1 parent
7330796829
commit
b289565d96
6 files changed
+139
-14
No files matched your search
+13
-2
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user