Identify companion hardware by its firmware model

Query MeshCore's device-info frame (send_device_query) on connect to learn the
board's own model string (e.g. "Seeed Tracker T1000-E") — the only reliable source,
since BLE companions advertise no manufacturer data, use randomized addresses with
no IEEE OUI, and expose no GATT Device Information Service.

The probe folds the model into the identity dict, so it's captured during the
connection that already happens, then remembered in the device store (preserved
across reconnects like the node name). The device list and splash picker rename
their "Vendor" column to "Hardware": it shows the firmware model for confirmed
devices and falls back to the USB vendor name for merely-attached serial ports.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 4.8 committed 2026-07-09 19:17:43 -04:00
1 parent 6d51b4e2ab
commit d4fa056526
6 files changed
+143 -12

No files matched your search

+16 -1
View File
@@ -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:
+42 -1
View File
@@ -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:
+17 -1
View File
@@ -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()
},
+10 -2
View File
@@ -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]",
)
+34 -7
View File
@@ -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.
+24
View File
@@ -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"