Files
Jean-Pierre MartineauandClaude Opus 5.5 6b90e0505b Docstrings and comments of meshterm/tools are in ASD-STE100
The same rewrite as for the core package. No help= string, epilog, or
other UX text changed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 03:48:44 -04:00

238 lines
10 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""The ``devices`` tool: list the connected serial, SPI, and in-range Bluetooth companions.
Discovery never opens the device. It only lists the devices that are connected or that
send a BLE advertisement. This tool is a read-only inventory, for the CLI only
(``menu_visible = False``). When the interactive menu opens, a device is already selected,
so the listing has no purpose there. It is a diagnostic on the command line at startup,
for the question "which port/address is my radio?". It marks the devices that MeshTerm
already confirmed as MeshCore companions, and it marks the active device. The user selects
a companion only at startup. Give ``--port`` or ``--ble`` on the CLI (MeshTerm remembers
it after it connects), or select from the prompt that shows when the menu starts. That
prompt does a smoke test of the selection before it confirms 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, without_radio_ports
from .base import Tool, ToolResult, register
if TYPE_CHECKING: # pragma: no cover - typing only
from ..ui.report import Listing
#: The ``MESHCORE`` verdict for each discovery confidence tier, for the devices that
#: MeshTerm did not confirm yet. The USB vendor ID is only a hint: a native-USB board or a
#: bare bridge chip is a "maybe", never a "yes". Thus no device is called MeshCore until a
#: connection proves it.
_MAYBE: dict[str, str] = {"board": "maybe", "bridge": "maybe", "unknown": "no"}
@register
class DevicesTool(Tool):
"""List the serial, SPI, and in-range Bluetooth companions, and mark likely and active ones."""
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:
"""Find the connected devices and the devices that send BLE advertisements, as a listing.
Args:
ctx: The shared application context.
params: Not used, except for the selection bookkeeping of the menu.
Returns:
A :class:`ToolResult` with the number of devices found.
"""
if ctx.mock:
# `--mock` promises a session with no real hardware, and a scan is the only
# thing that this tool does. Before, the tool examined the serial ports and
# switched the Bluetooth radio on, also with `--mock`. Thus a new user without
# a companion saw that promise broken on the first command that they tried.
# Now, with --mock, the simulator is the inventory: one row, and the tool does
# not touch the machine.
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 connected as a serial port is, but nothing lists it
# automatically. Thus the tool lists it from its device file, as the device
# screen lists it. The port on which the GPS of its board answers is listed as
# part of the radio, not next to it.
devices = without_radio_ports(devices, ctx.settings.profiles)
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 shows the user which one
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 scan that found nothing. The
# listing does not show the difference, because both have no BLE rows. Thus tell
# the user, also if the scan found other devices. A serial board that is present
# does not change the fact that a companion that the user expected over Bluetooth
# is 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. The message goes to stderr, so
# that a caller that redirects stdout still gets it. The exit status is
# NO_RESULT, so that a script can branch on it without a match on the text.
# The empty listing still goes to the machine face as `[]`, which is a document
# that a consumer can read. An empty stdout is a parse error, and the consumer
# must then tell it apart from a real parse error.
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),),
# Before, this returned 0 with --json. We changed it, because a rendering flag
# must not change the report, and the plain path always gave 5 here.
exit_code=exitcodes.OK if devices else exitcodes.NO_RESULT,
)
def register_cli(self, app: typer.Typer) -> None:
"""Register the ``devices`` subcommand (only an inventory, with no connection).
Args:
app: The Typer application.
"""
from ..cli import run_tool_command
@app.command(
name=self.name,
help=self.help,
# Before, the listing ended with a line that explained its markers and how to
# use a row. That text is help, and help goes here.
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, given one time for both faces.
``TARGET`` is first, because a caller uses that field: ``--port`` and ``--ble`` take it
exactly as it is. The two markers of the menu become columns (``ACTIVE``, and
``MESHCORE`` for the confirmed star), because a person can look at a glyph in a
margin, but a program cannot test it.
``MESHCORE`` has three values, and it keeps them:
- ``yes`` only after a connection proved that the device uses the protocol.
- ``maybe`` for a USB vendor ID that suggests a LoRa board or a bridge chip.
- ``no`` for all other devices.
A vendor ID is a hint. If the column changes a hint to ``yes``, the column gives false
information. For this reason, the machine face has the same three words, and not
the boolean that it had before.
Args:
devices: The discovered devices.
known: The remembered device records, keyed by stable id.
remembered: The remembered default device, if there is one.
active_target: The target that this run uses (or would use).
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 possible forms of `target`, kept separate. Thus a caller knows which
# transport it has, and it does not have to parse the target for a colon.
fields.hidden("port"),
fields.hidden("address"),
fields.name("label", "NAME"),
fields.name("hardware", "HARDWARE"),
fields.word("meshcore", "MESHCORE"),
# The source of the "maybe". A person who reads the column sees the vendor
# label next to it, but 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,
)