Files
MeshTerm/meshterm/tools/devices.py
T
Jean-Pierre MartineauandClaude Opus 5.5 430f5b2daa --spi picks the one radio there is, and refuses to guess between two
--spi used to take whichever SPI profile config.toml listed first, a
coin toss that transmits once two radios are configured. It now counts
the radios actually attached: exactly one is used whether or not a
profile names it, which keeps the common single-radio case to a bare
--spi. Two attached, or two SPI profiles with nothing attached to count,
is a selection error (exit 3) that lists them and asks for -p, the same
way several serial devices with no default already are.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 20:12:35 -04:00

226 lines
9.7 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""The ``devices`` tool: enumerate attached serial, SPI, and in-range Bluetooth companions.
Discovery never opens the radio — it only lists what is attached or advertising. This is a
CLI-only, read-only inventory (``menu_visible = False``): by the time the interactive menu is
up a device is already selected, so the listing has no job there — it belongs on the command
line as a startup-time "which port/address is my radio?" diagnostic. It marks devices already
confirmed as MeshCore companions (and the active one). Selecting a companion is a startup-only
concern: pass ``--port`` or ``--ble`` on the CLI (remembered after it connects), or pick from
the prompt shown when the menu launches (which smoke-tests the choice before confirming it).
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import typer
from ..context import AppContext
from ..core import exitcodes
from ..core.discovery import (
TRANSPORT_MOCK,
DiscoveredDevice,
ble_unavailable_reason,
discover_all,
discover_devices,
)
from ..core.selection import DeviceSelectionError
from ..core.spiradio import spi_radios
from .base import Tool, ToolResult, register
if TYPE_CHECKING: # pragma: no cover - typing only
from ..ui.report import Listing
#: The ``MESHCORE`` verdict per discovery confidence tier, for devices we have *not* yet
#: confirmed. The USB vendor ID is only a hint — a native-USB board or a bare bridge chip
#: is a "maybe", never a "yes" — so nothing is billed as MeshCore until a connection proves
#: it.
_MAYBE: dict[str, str] = {"board": "maybe", "bridge": "maybe", "unknown": "no"}
@register
class DevicesTool(Tool):
"""List attached serial, SPI, and in-range Bluetooth companions; mark likely and active."""
name = "devices"
title = "Devices"
help = "List attached serial, SPI, and Bluetooth devices, flagging likely companions"
category = "This node"
order = 5
menu_visible = False # CLI-only: a startup diagnostic with no place in a connected session
async def run(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
"""Enumerate attached and advertising devices, and state them as a listing.
Args:
ctx: Shared application context.
params: Unused beyond the menu's selection bookkeeping.
Returns:
A :class:`ToolResult` summarizing how many devices were found.
"""
if ctx.mock:
# `--mock` promises a session with no real hardware, and a scan is the one
# thing this tool does — it walked the serial ports and switched the Bluetooth
# radio on regardless, which is a promise broken on the first command a
# stranger without a radio would try. So under --mock the simulator *is* the
# inventory: one row, and nothing on the machine touched.
devices = [
DiscoveredDevice(
transport=TRANSPORT_MOCK,
description="the built-in simulator (nothing transmits)",
name="simulator",
)
]
else:
scan_ble = params.get("ble", True)
devices = await discover_all(ble=scan_ble) if scan_ble else discover_devices()
# A radio on the SPI bus is attached the way a serial port is, but nothing
# enumerates it: it is listed from its device node, as the device screen lists it.
devices += spi_radios(ctx.settings.profiles, devices)
known = ctx.device_store.load_all()
remembered = ctx.device_store.load()
active = ctx.selected_device
try:
spi = (
ctx.resolve_spi()
if ctx.spi_override or (ctx.profile is not None and ctx.profile.is_spi)
else None
)
except DeviceSelectionError:
spi = None # an ambiguous --spi: the listing is how the reader finds out which
active_target = (
ctx.ble_override
or ctx.port_override
or (spi.spidev if spi is not None else None)
or (active.target if active else None)
)
# A refused Bluetooth scan is not the same as a quiet one, and the difference is
# invisible in the listing — both simply lack BLE rows. Say so whether or not
# anything else was found: a serial board being present does not make a companion
# the reader expected over Bluetooth any less missing.
blocked = ble_unavailable_reason()
if blocked:
from ..ui import script
script.stderr_console().print(
f"meshterm: Bluetooth not scanned — {blocked}", style="warn", highlight=False
)
if not devices:
# Not an error: the scan ran and found nothing. Said on stderr so a caller
# redirecting stdout still hears it, and reported as NO_RESULT so a script can
# branch on it without matching prose. The empty listing still reaches the
# machine face as `[]`, which is a document a consumer can read — where an
# empty stdout is a parse error it would have to tell apart from a real one.
from ..ui import script
script.stderr_console().print(
"meshterm: no companion devices detected", style="warn", highlight=False
)
return ToolResult(
summary={"count": len(devices)},
report=(_listing(devices, known, remembered, active_target),),
# Changed from the 0 this used to return under --json: a rendering flag has no
# business changing the report, and the plain path has always said 5 here.
exit_code=exitcodes.OK if devices else exitcodes.NO_RESULT,
)
def register_cli(self, app: typer.Typer) -> None:
"""Register the ``devices`` subcommand (inventory only; no connection).
Args:
app: The Typer application.
"""
from ..cli import run_tool_command
@app.command(
name=self.name,
help=self.help,
# The listing used to close with a line explaining its own markers and how to
# act on a row. That is help, and this is where help goes.
epilog="Select a device with --port TARGET or --ble TARGET, using the "
"TARGET column verbatim; an SPI radio with --spi, or -p and its profile.",
)
def _devices(
ble: bool = typer.Option(
True, "--ble/--no-ble", help="Include a Bluetooth LE scan (adds a few seconds)"
),
) -> None:
run_tool_command(self, {"ble": ble})
def _listing(devices: list, known: dict, remembered: object, active_target: str | None) -> Listing:
"""The device inventory, stated once for both faces.
``TARGET`` leads because it is the field a caller acts on — it is what ``--port`` and
``--ble`` take, verbatim. The menu's two markers become columns of their own
(``ACTIVE``, and ``MESHCORE`` for the confirmed star), because a glyph in a margin is
something to look at rather than something to test.
``MESHCORE`` is three-valued and stays that way: ``yes`` only once a connection has
proved the device speaks the protocol, ``maybe`` for a USB vendor ID that suggests a
LoRa board or a bridge chip, ``no`` for anything else. A vendor ID is a hint, and the
column would be lying if it rounded one up — which is why the machine face carries the
same three words rather than the boolean it used to.
Args:
devices: The discovered devices.
known: Remembered device records, keyed by stable id.
remembered: The remembered default device, if there is one.
active_target: The target this invocation is (or would be) using.
Returns:
The listing.
"""
from ..ui import fields
from ..ui.report import Listing
rows = []
for device in devices:
confirmed = known.get(device.stable_id)
rows.append(
{
"target": device.target,
"transport": device.transport,
"port": device.port,
"address": device.address,
"label": (confirmed.node_name if confirmed else "") or device.label,
"hardware": (confirmed.hardware_model if confirmed else "") or device.vendor_label,
"meshcore": "yes" if confirmed else _MAYBE.get(device.confidence, "no"),
"confidence": device.confidence,
"serial_number": device.serial_number,
"stable_id": device.stable_id,
"confirmed": confirmed is not None,
"remembered": remembered is not None and remembered.matches(device),
"active": device.target == active_target,
}
)
return Listing(
key="devices",
columns=(
fields.word("target", "TARGET"),
fields.word("transport", "TRANSPORT"),
# The two halves `target` is one of, kept apart for a caller that has to know
# which transport it is holding without parsing the target for a colon.
fields.hidden("port"),
fields.hidden("address"),
fields.name("label", "NAME"),
fields.name("hardware", "HARDWARE"),
fields.word("meshcore", "MESHCORE"),
# What the "maybe" was derived from. A person reading the column has the
# vendor label beside it and can see for themselves; a program cannot.
fields.hidden("confidence"),
fields.word("serial_number", "SERIAL"),
fields.hidden("stable_id"),
fields.hidden("confirmed"),
fields.hidden("remembered"),
fields.flag("active", "ACTIVE"),
),
rows=rows,
)