diff --git a/meshterm/context.py b/meshterm/context.py index 4848585..a97ba1e 100644 --- a/meshterm/context.py +++ b/meshterm/context.py @@ -312,7 +312,9 @@ class AppContext: """ if self.selected_device is not None: self.device_store.remember( - self.selected_device, node_name=await self._node_name() + self.selected_device, + node_name=await self._node_name(), + hardware_model=await self._hardware_model(), ) async def reconnect(self) -> None: @@ -377,6 +379,19 @@ class AppContext: return "" return str(info.get("adv_name") or info.get("name") or "") + async def _hardware_model(self) -> str: + """Return the connected device's firmware model string, or ``""`` if unavailable. + + Sourced from the device-query frame — the only place the model is exposed — so a + reconnect keeps the remembered hardware column fresh. Best-effort: a probe failure or + older firmware just leaves any previously-remembered model untouched. + """ + try: + info = await self._device.get_device_info() + except Exception: # noqa: BLE001 - model lookup is best-effort, never fatal + return "" + return str(info.get("model") 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/meshterm/core/connection.py b/meshterm/core/connection.py index c5fab0d..2379e72 100644 --- a/meshterm/core/connection.py +++ b/meshterm/core/connection.py @@ -222,6 +222,25 @@ class Device(ABC): parameters (``freq``, ``bw``, ``sf``, ``cr``). """ + @abstractmethod + async def get_device_info(self) -> dict: + """Return the firmware's hardware/build identity for the connected device. + + This is a *different* protocol frame from :meth:`get_self_info`: where self-info + reports the node's identity and radio tuning, this reports what the box actually is — + a ``model`` string (e.g. ``"Seeed Tracker T1000-E"``, matching a MeshCore firmware + ``variant``), plus firmware ``ver``/``fw_build``. It is the *only* place the vendor and + model surface: BLE adverts carry no manufacturer data for these boards, most use a + randomized address with no IEEE OUI to look up, and no GATT Device Information Service + is exposed — so the model has to come from MeshCore's own application layer. + + Best-effort: firmware predating the device-query frame answers with an empty payload + rather than erroring, so callers must treat a missing ``model`` as simply unknown. + + Returns: + A dict with, when available, ``model``, ``ver``, and ``fw_build``; possibly empty. + """ + @abstractmethod async def get_contacts(self) -> list[Contact]: """Return the device's known contacts. @@ -682,6 +701,14 @@ class MeshCoreDevice(Device): result = await mc.commands.send_appstart() return dict(getattr(result, "payload", {}) or {}) + async def get_device_info(self) -> dict: # noqa: D102 - inherited docstring + mc = self._require() + try: + result = await mc.commands.send_device_query() + except Exception: # noqa: BLE001 - older firmware lacks the query; unknown, not fatal + return {} + return dict(getattr(result, "payload", {}) or {}) + async def _contacts_payload( self, mc, *, retries: int = 3, delay: float = 0.5 ) -> dict: # noqa: ANN001 @@ -1327,6 +1354,11 @@ class MockDevice(Device): async def get_self_info(self) -> dict: # noqa: D102 - inherited docstring return {**self._info, "tx_power": self._tx_power} + async def get_device_info(self) -> dict: # noqa: D102 - inherited docstring + # The simulator reports a stable, obviously-synthetic model so the hardware column + # renders identically to a real board without pretending to be one. + return {"model": "MeshCore Simulator", "ver": "mock", "fw_build": "mock"} + async def get_contacts(self) -> list[Contact]: # noqa: D102 - inherited docstring return list(self._contacts) @@ -1996,7 +2028,16 @@ async def _probe( if not info: await _safe_disconnect(device) return None - return device, dict(info) + # The connection is already open, so learn the hardware model now (its own protocol frame) + # and fold it into the identity dict — this is the one moment the model is obtainable, and + # it lets the caller remember "Seeed Tracker T1000-E" without a second connect. It's purely + # additive: self-info fields win on the (currently non-overlapping) keys, and a firmware + # that can't answer the query simply contributes nothing. + try: + device_info = await asyncio.wait_for(device.get_device_info(), timeout) + except Exception: # noqa: BLE001 - model is a nicety; never fail a good probe over it + device_info = {} + return device, {**device_info, **info} async def _safe_disconnect(device: Device) -> None: diff --git a/meshterm/core/device_store.py b/meshterm/core/device_store.py index 2ad5099..5b94711 100644 --- a/meshterm/core/device_store.py +++ b/meshterm/core/device_store.py @@ -38,6 +38,11 @@ class RememberedDevice: transport: ``"serial"`` or ``"ble"`` — how this device was last reached. address: The Bluetooth address, for a BLE device (blank otherwise), so it can be reconnected directly without re-scanning. + hardware_model: The firmware's own model string (e.g. ``"Seeed Tracker T1000-E"``), + learned from the device-query at connect time. This is the only reliable source of + the model — a BLE companion advertises none — so it's remembered here to fill the + hardware column even when the device is merely attached, not connected. May be empty + for a serial device confirmed by an older firmware that predates the query. """ stable_id: str @@ -47,6 +52,7 @@ class RememberedDevice: node_name: str = "" transport: str = TRANSPORT_SERIAL address: str = "" + hardware_model: str = "" @property def is_ble(self) -> bool: @@ -120,6 +126,7 @@ class DeviceStore: node_name=entry.get("node_name", ""), transport=entry.get("transport", TRANSPORT_SERIAL), address=entry.get("address", ""), + hardware_model=entry.get("hardware_model", ""), ) except (KeyError, TypeError): return None @@ -146,7 +153,9 @@ class DeviceStore: registry, _ = self._read() return device.stable_id in registry - def remember(self, device: DiscoveredDevice, *, node_name: str = "") -> None: + def remember( + self, device: DiscoveredDevice, *, node_name: str = "", hardware_model: str = "" + ) -> None: """Record ``device`` as a confirmed connection and the new default. Upserts the device into the registry (so it is remembered forever) and marks it as @@ -157,11 +166,16 @@ class DeviceStore: 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 for this device rather than erasing it. + hardware_model: The firmware's model string, if known; preserved the same way, so + a reconnect on firmware that couldn't answer the query doesn't erase a model + learned earlier. """ registry, _ = self._read() existing = registry.get(device.stable_id) if not node_name and existing is not None: node_name = existing.node_name + if not hardware_model and existing is not None: + hardware_model = existing.hardware_model registry[device.stable_id] = RememberedDevice( stable_id=device.stable_id, port=device.port, @@ -170,6 +184,7 @@ class DeviceStore: node_name=node_name, transport=device.transport, address=device.address or "", + hardware_model=hardware_model, ) self._write(registry, device.stable_id) @@ -186,6 +201,7 @@ class DeviceStore: "node_name": record.node_name, "transport": record.transport, "address": record.address, + "hardware_model": record.hardware_model, } for stable_id, record in registry.items() }, diff --git a/meshterm/tools/devices.py b/meshterm/tools/devices.py index 73b7cee..f0e97ce 100644 --- a/meshterm/tools/devices.py +++ b/meshterm/tools/devices.py @@ -74,6 +74,7 @@ class DevicesTool(Tool): "target": d.target, "label": d.label, "vendor": d.vendor_label, + "model": (known[d.stable_id].hardware_model if d.stable_id in known else ""), "likely_lora": d.is_likely_lora, "confidence": d.confidence, "confirmed": d.stable_id in known, @@ -99,7 +100,12 @@ class DevicesTool(Tool): table.add_column("", style="ok", no_wrap=True) # active/confirmed markers table.add_column("Port / Address", style="brand") table.add_column("Device") - table.add_column("Vendor", style="muted") + # "Hardware" (not "Vendor"): for a confirmed device this holds the firmware's own model + # string ("Seeed Tracker T1000-E") — the only reliable source of what the box is — and + # for a merely-attached serial port it falls back to the USB vendor name ("Espressif"). + # One column spans both because a maker name and a model name are the same question: + # "what hardware is this?". A BLE device that's never connected has neither, so it's "?". + table.add_column("Hardware", style="muted") table.add_column("MeshCore?", justify="center") table.add_column("Serial", style="muted") for d in devices: @@ -109,11 +115,13 @@ class DevicesTool(Tool): device_name = d.name or d.product or d.description or "[muted]?[/muted]" if confirmed and confirmed.node_name: # the mesh name learned on connect device_name = f"{confirmed.node_name} [muted]({device_name})[/muted]" + # Prefer the connected-time model; fall back to the USB vendor for unconnected ports. + hardware = (confirmed.hardware_model if confirmed else "") or d.vendor_label table.add_row( marker, d.target, device_name, - d.vendor_label or "[muted]?[/muted]", + hardware or "[muted]?[/muted]", _CONFIRMED_CELL if confirmed else _MAYBE_CELL[d.confidence], d.serial_number or "[muted]—[/muted]", ) diff --git a/meshterm/ui/device_picker.py b/meshterm/ui/device_picker.py index 0259901..574676d 100644 --- a/meshterm/ui/device_picker.py +++ b/meshterm/ui/device_picker.py @@ -118,11 +118,36 @@ def _where(device: DiscoveredDevice) -> str: return device.target +def _hardware_label( + device: DiscoveredDevice, registry: dict[str, "RememberedDevice"] +) -> str: + """The HARDWARE column text: the remembered firmware model, else the USB vendor. + + A confirmed device shows what it actually is ("Seeed Tracker T1000-E", learned from the + device-query at connect time) — the only reliable source, since a BLE companion advertises + no maker. A device we've never connected has no model on file, so it falls back to the USB + vendor name (blank for an unconnected BLE advert, which genuinely tells us nothing yet). + """ + record = registry.get(device.stable_id) + if record is not None and record.hardware_model: + return record.hardware_model + return device.vendor_label + + def _node_name_from(info: dict) -> str: """Return a device's own mesh node name from its self-info payload, or ``""``.""" return str(info.get("adv_name") or info.get("name") or "") +def _model_from(info: dict) -> str: + """Return the firmware's hardware model from the probe payload, or ``""``. + + The smoke-test probe folds the device-query's ``model`` into the identity dict, so this + is the one chance to learn (and then remember) what the box actually is. + """ + return str(info.get("model") or "") + + async def prompt_device( ui: "Ui", devices: list[DiscoveredDevice], @@ -223,7 +248,9 @@ async def prompt_device( # Confirmed: remember it forever. We return straight away, so there's no need to # fold it back into the local registry for a re-render. - store.remember(chosen, node_name=_node_name_from(info)) + store.remember( + chosen, node_name=_node_name_from(info), hardware_model=_model_from(info) + ) return chosen @@ -251,8 +278,8 @@ def _build_items( The row for each device leads with its display name (the remembered node's name when known, else the hardware name), followed by its connection target, a TYPE glyph marking - the transport, and the hardware vendor; columns are padded to a shared width so they - align. Confirmed companions sort to the top (most-recent first), wear their name in white + the transport, and the HARDWARE column (the remembered firmware model, else the USB + vendor); columns are padded to a shared width so they align. Confirmed companions sort to the top (most-recent first), wear their name in white and a bright tag; the remembered default is starred. A trailing Quit row (like the menu's) lets the user exit from here. """ @@ -271,8 +298,8 @@ def _build_items( # The TYPE column holds a small transport badge (at most 3 cells); its heading is wider, # so the four-cell "TYPE" label sets the column width and every badge pads out to it. type_w = len("TYPE") - vendor_w = max(cell_len(d.vendor_label) for d in devices) - vendor_w = max(vendor_w, len("VENDOR")) + hardware_w = max(cell_len(_hardware_label(d, registry)) for d in devices) + hardware_w = max(hardware_w, len("HARDWARE")) # A muted, aligned header. The leading spaces mirror the row pointer (2) and the star # column (2) so the labels sit above their columns. @@ -281,7 +308,7 @@ def _build_items( + _pad("DEVICE", name_w) + " " + _pad(port_label, port_w) + " " + _pad("TYPE", type_w) - + " " + "VENDOR" + + " " + "HARDWARE" ) items: list = [header] @@ -305,7 +332,7 @@ def _build_items( row.append_text(cell) row.append(" " * max(0, type_w - cell.cell_len)) row.append(" ") - row.append(_pad(device.vendor_label, vendor_w), style="muted") + row.append(_pad(_hardware_label(device, registry), hardware_w), style="muted") # Only devices we've actually confirmed are billed as MeshCore companions; a USB # vendor ID (or a BLE advert) is a sort hint, not a claim. A bare serial bridge earns # an honest label; a MeshCore-named BLE advert is flagged as a likely companion. diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 249e6c1..90db7c2 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -230,6 +230,30 @@ def test_remember_keeps_known_node_name_when_none_supplied(tmp_path: Path) -> No assert loaded is not None and loaded.node_name == "BaseStation" +def test_device_store_round_trips_hardware_model(tmp_path: Path) -> None: + """The firmware model learned at connect time writes and reads back.""" + store = DeviceStore(tmp_path / "devices.json") + dev = DiscoveredDevice(transport="ble", address="AA:BB:CC:DD:EE:FF", name="MeshCore-Waymarker") + store.remember(dev, node_name="Waymarker", hardware_model="Seeed Tracker T1000-E") + + loaded = store.load() + assert loaded is not None + assert loaded.hardware_model == "Seeed Tracker T1000-E" # the model the Hardware column shows + + +def test_remember_keeps_known_model_when_none_supplied(tmp_path: Path) -> None: + """A reconnect on firmware that can't answer the query keeps the earlier model.""" + store = DeviceStore(tmp_path / "devices.json") + dev = DiscoveredDevice("COM5", serial_number="SN1") + store.remember(dev, hardware_model="Seeed Tracker T1000-E") + + store.remember(dev, node_name="Base") # a later connect that learned no model + loaded = store.load() + assert loaded is not None + assert loaded.node_name == "Base" + assert loaded.hardware_model == "Seeed Tracker T1000-E" # not erased + + 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"