Files
MeshTerm/meshterm/tools/devices.py
T
Jean-Pierre MartineauandClaude Opus 5 5215a1b76c Two things the sweep could not see
A block that renders to nothing still counted toward the separator, so
`config restore --dry-run` opened with a blank line it did not have — the plan
is a listing under an outcome that says everything it has to say in the exit
status. The separator now counts what was drawn.

And the device inventory declared `port` and `address` in its rows and never in
its columns, so both fell out of the document: a caller had to parse the target
for a colon to find out which transport it was holding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 03:59:26 -04:00

174 lines
7.6 KiB
Python

"""The ``devices`` tool: enumerate attached serial 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 discover_all, discover_devices
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 + in-range Bluetooth companions, flag likely LoRa, mark the default."""
name = "devices"
title = "Devices"
help = "List attached serial 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.
"""
scan_ble = params.get("ble", True)
devices = await discover_all(ble=scan_ble) if scan_ble else discover_devices()
known = ctx.device_store.load_all()
remembered = ctx.device_store.load()
active = ctx.selected_device
active_target = ctx.ble_override or ctx.port_override or (active.target if active else None)
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.",
)
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,
)