Files
MeshTerm/meshterm/tools/devices.py
T
Jean-Pierre MartineauandClaude Opus 5 92d31a3191 Every file is spelled the way the formatter spells it
`ruff format .` across the repo: 153 files, no behaviour change. This is the
second gate CONTRIBUTING.md promises, and until now it reported 154 files needing
reformat — so a first contributor ran the command the guide told them to run and
could not tell their own diff from the background.

Landed on its own, with nothing else in it, because a whole-repo reformat is only
safe to skip past in blame if it is provably nothing but layout. It was checked
that way rather than assumed: every changed file was formatted in memory and
compared by full `ast.dump()`, and 152 of 153 come out structurally identical
(the one exception was a docstring opening on a quote character, fixed in the
commit before this one). The string and comment token streams were diffed
separately, so a quote flip or an edited comment could not hide inside a
re-layout — five quote-style normalisations and sixteen implicit-concatenation
joins, all value-identical, and one commented-out line gaining the space after
its `#`. `meshterm specimen` is byte-identical on both platforms, and the suite
is 1888 green, gallery gate and `test_theme16` included.

What actually changed, in order of how much of the diff it is: wrapped calls and
signatures rejoin onto one line where they fit inside the hundred columns; the
magic trailing comma expands what it is there to expand; complex slices gain
their spaces (`[len(noise) :]`); a few redundant parens go. The tables that are
laid out to be read were fenced in the previous commit and are untouched here.

The aligned trailing-comment column is gone from about ninety lanes that were not
fenced — including the `heat.*` ladder in `MESH_THEME`, which cannot be fenced
where it sits. Every comment stays on the entry it describes, so nothing there
reads wrong; it just no longer lines up.

Add this commit to `.git-blame-ignore-revs`, which follows immediately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 01:07:19 -04:00

149 lines
6.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 Any
import typer
from rich.table import Table
from ..context import AppContext
from ..core.discovery import discover_all, discover_devices
from .base import Tool, ToolResult, register
#: "MeshCore?" table cell 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 (see ``_confirmed_cell``).
_MAYBE_CELL: dict[str, str] = {
"board": "[warn]maybe[/warn]",
"bridge": "[warn]maybe[/warn]",
"unknown": "[muted]—[/muted]",
}
#: "MeshCore?" cell for a device already confirmed to speak the protocol.
_CONFIRMED_CELL = "[ok]yes[/ok]"
@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 serial devices and render them as a table.
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 ctx.json_output:
import json
payload = [
{
"transport": d.transport,
"port": d.port,
"address": d.address,
"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,
"serial_number": d.serial_number,
"stable_id": d.stable_id,
"remembered": remembered is not None and remembered.matches(d),
"active": d.target == active_target,
}
for d in devices
]
ctx.console.print_json(json.dumps(payload))
return ToolResult(summary={"count": len(devices)})
if not devices:
ctx.ui.note(
"[warn]No companion devices detected.[/warn] "
"Connect one over USB or power on a Bluetooth companion nearby, or use "
"[accent]--mock[/accent] for the simulator."
)
return ToolResult(summary={"count": 0})
table = Table(title="Companion devices", border_style="muted", expand=False)
table.add_column("", style="ok", no_wrap=True) # active/confirmed markers
table.add_column("PORT / ADDRESS", style="brand")
table.add_column("DEVICE")
# "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:
confirmed = known.get(d.stable_id) # the remembered record, if ever confirmed
is_active = d.target == active_target
marker = ("●" if is_active else "") + ("★" if confirmed else "")
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,
hardware or "[muted]?[/muted]",
_CONFIRMED_CELL if confirmed else _MAYBE_CELL[d.confidence],
d.serial_number or "[muted]—[/muted]",
)
ctx.ui.show(table)
ctx.ui.note(
"[muted]● active ★ confirmed MeshCore device. "
"Pass --port <PORT> or --ble <ADDRESS> to select on the CLI.[/muted]"
)
return ToolResult(summary={"count": len(devices)})
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)
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})