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:
1 parent
6d51b4e2ab
commit
d4fa056526
6 files changed
+143
-12
No files matched your search
+16
-1
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
|
||||
@@ -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]",
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user