Files
MeshTerm/tests/test_discovery.py
T
Jean-Pierre MartineauandClaude Opus 4.8 b289565d96 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>
2026-07-06 19:15:33 -04:00

216 lines
8.1 KiB
Python

"""Tests for device discovery, the remembered-device store, and selection.
All run without hardware: serial enumeration is monkeypatched, and the store/selection
logic is pure.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
import pytest
from meshtools.core.config import DeviceProfile
from meshtools.core.device_store import DeviceStore
from meshtools.core.discovery import DiscoveredDevice, discover_devices
from meshtools.core.selection import DeviceSelectionError, resolve_device
@dataclass
class FakePortInfo:
"""Stand-in for ``serial.tools.list_ports_common.ListPortInfo``."""
device: str
description: Optional[str] = None
hwid: Optional[str] = None
vid: Optional[int] = None
pid: Optional[int] = None
serial_number: Optional[str] = None
manufacturer: Optional[str] = None
product: Optional[str] = None
def _patch_ports(monkeypatch: pytest.MonkeyPatch, ports: list[FakePortInfo]) -> None:
"""Make ``discover_devices`` see exactly ``ports``."""
from serial.tools import list_ports
monkeypatch.setattr(list_ports, "comports", lambda: list(ports))
# -- discovery -----------------------------------------------------------------
def test_discover_maps_fields_and_flags_lora(monkeypatch: pytest.MonkeyPatch) -> None:
"""USB metadata is mapped through and known vendors are flagged as likely LoRa."""
_patch_ports(
monkeypatch,
[
FakePortInfo(
device="COM5",
description="USB Serial",
vid=0x303A, # Espressif → likely LoRa
pid=0x1001,
serial_number="ABC123",
product="Wio SX1262",
)
],
)
[dev] = discover_devices()
assert dev.port == "COM5"
assert dev.product == "Wio SX1262"
assert dev.is_likely_lora
assert dev.vendor_label == "Espressif"
assert "COM5" in dev.label
def test_discover_sorts_likely_lora_first(monkeypatch: pytest.MonkeyPatch) -> None:
"""Likely-LoRa devices sort ahead of unrecognized adapters."""
_patch_ports(
monkeypatch,
[
FakePortInfo(device="COM9", vid=0x1234, pid=0x0001), # unknown vendor
FakePortInfo(device="COM3", vid=0x10C4, pid=0xEA60), # Silabs → LoRa
],
)
devices = discover_devices()
assert [d.port for d in devices] == ["COM3", "COM9"]
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"
assert DiscoveredDevice("COM5", vid=0x303A, pid=0x1001).stable_id == "vidpid:303a:1001"
assert DiscoveredDevice("COM5").stable_id == "port:COM5"
# -- device store --------------------------------------------------------------
def test_device_store_round_trip(tmp_path: Path) -> None:
"""A remembered device writes and reads back, matched by stable_id."""
store = DeviceStore(tmp_path / "devices.json")
assert store.load() is None # nothing remembered yet
dev = DiscoveredDevice("COM5", serial_number="SN1", product="Wio SX1262")
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"
path.write_text("{not valid json", encoding="utf-8")
assert DeviceStore(path).load() is None
# -- selection -----------------------------------------------------------------
def test_resolve_prefers_explicit_port() -> None:
"""An explicit --port wins over everything else."""
devices = [DiscoveredDevice("COM3", serial_number="SN1")]
res = resolve_device(devices, None, explicit_port="COM9")
assert res.port == "COM9" and res.source == "port"
def test_resolve_uses_profile_port() -> None:
"""A profile with a port is used when no --port is given."""
profile = DeviceProfile(name="yagi", port="COM6")
res = resolve_device([], None, profile=profile)
assert res.port == "COM6" and res.source == "profile"
def test_resolve_uses_remembered_when_present() -> None:
"""The remembered default is used when that device is still attached."""
dev = DiscoveredDevice("COM3", serial_number="SN1")
store_dev = DiscoveredDevice("COM7", serial_number="SN1") # same hardware, new port
store = _remember(store_dev)
res = resolve_device([dev], store, profile=None)
assert res.port == "COM3" and res.source == "remembered"
assert res.device is dev
def test_resolve_single_device_auto() -> None:
"""With exactly one device and no default, it is chosen automatically."""
dev = DiscoveredDevice("COM3", serial_number="SN1")
res = resolve_device([dev], None)
assert res.port == "COM3" and res.source == "only"
def test_resolve_ambiguous_raises(tmp_path: Path) -> None:
"""Multiple devices with no usable default raise a guidance error."""
devices = [
DiscoveredDevice("COM3", serial_number="SN1"),
DiscoveredDevice("COM4", serial_number="SN2"),
]
with pytest.raises(DeviceSelectionError, match="Multiple serial devices"):
resolve_device(devices, None)
def test_resolve_no_devices_raises() -> None:
"""No devices at all raises a clear error mentioning --mock."""
with pytest.raises(DeviceSelectionError, match="No serial devices"):
resolve_device([], None)
def _remember(device: DiscoveredDevice):
"""Build a RememberedDevice for ``device`` without touching disk."""
from meshtools.core.device_store import RememberedDevice
return RememberedDevice(
stable_id=device.stable_id, port=device.port, label=device.label, last_connected=""
)