meshterm devices lists the radio on the SPI bus

The device screen listed an SPI radio but the CLI inventory didn't, so
the one command for "what is attached?" was silent about it. The listing
moves into spiradio.spi_radios, which both now use, so the two can never
disagree; the row is marked active under --spi or an SPI profile.
resolve_spi becomes public for that.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 5.5 committed 2026-09-24 19:59:05 -04:00
1 parent 2d5e8bb67d
commit f50db48074
6 files changed
+98 -61

No files matched your search

+7 -3
View File
@@ -585,8 +585,11 @@ repeat](#the-shapes-that-repeat), the table names the shape rather than restatin
#### `meshterm devices`
List every attached serial device and in-range Bluetooth companion. Opens no radio — it
only enumerates what is attached or advertising. Under `--mock` it enumerates nothing at
List every attached serial device, every radio on this machine's SPI bus, and every
in-range Bluetooth companion. Opens no radio — it only enumerates what is attached or
advertising. An SPI radio is listed when its `/dev/spidev*` node exists, exactly as the
device screen lists it: one per SPI profile, plus the uConsole AIO's `/dev/spidev1.0` when
no profile covers it. Under `--mock` it enumerates nothing at
all: the simulator is the one device, listed with `--mock` as its target, and neither the
serial ports nor the Bluetooth radio are touched — a session that promised no real
hardware keeps the promise here too.
@@ -604,7 +607,8 @@ COM26 serial USB Serial Device (COM26) Espressif maybe
COM1 serial Communications Port (COM1) (Standard port types) no - no
```
`TARGET` is what `--port` and `--ble` take, verbatim. `MESHCORE` is three-valued and stays
`TARGET` is what `--port` and `--ble` take, verbatim; an SPI radio's is its device node,
reached with `--spi` or its profile's `-p`. `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.
+2 -2
View File
@@ -525,7 +525,7 @@ class AppContext:
# A radio on the host's own SPI bus (``--spi``, an SPI profile, or the remembered
# default) has nothing to find: MeshTerm starts the node that answers on it.
spi = self._resolve_spi()
spi = self.resolve_spi()
if spi is not None:
from .core.spiradio import state_dir
@@ -657,7 +657,7 @@ class AppContext:
return host, port
return None, None
def _resolve_spi(self) -> SpiWiring | None:
def resolve_spi(self) -> SpiWiring | None:
"""The wiring of the SPI radio to open, or ``None`` when another transport is meant.
In priority order: an SPI profile, the radio picked on the startup splash, ``--spi``
+39 -1
View File
@@ -39,11 +39,13 @@ import os
import shutil
import subprocess
import sys
from collections.abc import Iterable, Mapping
from dataclasses import dataclass
from pathlib import Path
from .config import SpiWiring
from .config import DeviceProfile, SpiWiring
from .connection import DeviceCommandError, MeshCoreDevice
from .discovery import DiscoveredDevice, spi_device
_log = logging.getLogger(__name__)
@@ -137,6 +139,42 @@ def spi_present(wiring: SpiWiring) -> bool:
return _on_linux() and os.path.exists(wiring.spidev)
def spi_radios(
profiles: Mapping[str, DeviceProfile] | None, listed: Iterable[DiscoveredDevice] = ()
) -> list[DiscoveredDevice]:
"""The radios on this machine's own SPI bus, as devices — THE one answer to "which are here".
One per ``transport = "spi"`` profile whose ``/dev/spidev*`` node exists, named by the
profile; and, where no profile covers it, one for the uConsole AIO's device node when it
exists, wired with the defaults. Nothing is probed — a radio on the bus has no node
running until one is chosen — so a radio is listed exactly when there is a device node
to open. The device screen and ``meshterm devices`` both list from here, so the two can
never disagree about what is attached.
Args:
profiles: The configured profiles, or ``None`` when none are loaded.
listed: Devices already gathered; a radio among them is not listed twice.
Returns:
One SPI :class:`~meshterm.core.discovery.DiscoveredDevice` per radio not in ``listed``.
"""
seen = {d.stable_id for d in listed}
wirings = [(p.spi or SpiWiring(), p.name) for p in (profiles or {}).values() if p.is_spi]
covered = {wiring.spidev for wiring, _name in wirings}
if SpiWiring().spidev not in covered:
wirings.append((SpiWiring(), ""))
radios: list[DiscoveredDevice] = []
for wiring, name in wirings:
if not spi_present(wiring):
continue
device = spi_device(wiring, name=name)
if device.stable_id in seen:
continue
seen.add(device.stable_id)
radios.append(device)
return radios
# --- finding a Python with the radio library -----------------------------------------------
#: Each interpreter probed this session: path -> whether it can import the radio library.
+19 -5
View File
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
"""The ``devices`` tool: enumerate attached serial and in-range Bluetooth companions.
"""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
@@ -25,6 +25,7 @@ from ..core.discovery import (
discover_all,
discover_devices,
)
from ..core.spiradio import spi_radios
from .base import Tool, ToolResult, register
if TYPE_CHECKING: # pragma: no cover - typing only
@@ -39,11 +40,11 @@ _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."""
"""List attached serial, SPI, and in-range Bluetooth companions; mark likely and active."""
name = "devices"
title = "Devices"
help = "List attached serial and Bluetooth devices, flagging likely companions"
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
@@ -74,10 +75,23 @@ class DevicesTool(Tool):
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
active_target = ctx.ble_override or ctx.port_override or (active.target if active else None)
spi = (
ctx.resolve_spi()
if ctx.spi_override or (ctx.profile is not None and ctx.profile.is_spi)
else None
)
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
@@ -125,7 +139,7 @@ class DevicesTool(Tool):
# 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.",
"TARGET column verbatim; an SPI radio with --spi, or -p and its profile.",
)
def _devices(
ble: bool = typer.Option(
+3 -40
View File
@@ -35,7 +35,7 @@ from typing import TYPE_CHECKING
from rich.cells import cell_len
from rich.text import Text
from ..core.config import DeviceProfile, SpiWiring
from ..core.config import DeviceProfile
from ..core.connection import DeviceAuthenticationError, DeviceCommandError
from ..core.device_store import DeviceStore, RememberedDevice
from ..core.discovery import (
@@ -45,10 +45,9 @@ from ..core.discovery import (
discover_devices,
parse_tcp_endpoint,
serial_device,
spi_device,
tcp_device,
)
from ..core.spiradio import spi_present
from ..core.spiradio import spi_radios
from ..platforms import get_platform
from .logo import load_logo
from .menus import Lane, align_icons, column_header, fit_cells
@@ -402,7 +401,7 @@ async def prompt_device(
listed += _profile_serial_devices(listed, profiles)
# A radio on the SPI bus answers nothing until MeshTerm starts its node, so it is
# listed from the device node's presence, the way a serial port is.
listed += _spi_devices(listed, profiles)
listed += spi_radios(profiles, listed)
# Devices the user has told this splash to stop showing (h). Read each pass, so a
# row hidden a moment ago is gone the next time the list is drawn — which is the
# only feedback hiding needs.
@@ -696,42 +695,6 @@ def _profile_serial_devices(
return rebuilt
def _spi_devices(
listed: list[DiscoveredDevice],
profiles: Mapping[str, DeviceProfile] | None,
) -> list[DiscoveredDevice]:
"""The radios on this machine's own SPI bus, as picker rows.
One row per ``transport = "spi"`` profile whose ``/dev/spidev*`` node exists, named by
the profile; and, where no profile covers it, one for the uConsole AIO's device node when
it exists, wired with the defaults. Nothing is probed to list them — a radio on the bus
has no node running until one is chosen — so a row appears exactly when there is a device
node to open. A device node already listed is not listed twice.
Args:
listed: The devices already gathered, for de-duplication.
profiles: The configured profiles, or ``None`` when none are loaded.
Returns:
One SPI :class:`DiscoveredDevice` per radio not already listed.
"""
seen = {d.stable_id for d in listed}
rows: list[DiscoveredDevice] = []
wirings = [(p.spi or SpiWiring(), p.name) for p in (profiles or {}).values() if p.is_spi]
covered = {wiring.spidev for wiring, _name in wirings}
if SpiWiring().spidev not in covered:
wirings.append((SpiWiring(), ""))
for wiring, name in wirings:
if not spi_present(wiring):
continue
device = spi_device(wiring, name=name)
if device.stable_id in seen:
continue
seen.add(device.stable_id)
rows.append(device)
return rows
async def _add_network_device(
ui: Ui,
store: DeviceStore,
+28 -10
View File
@@ -147,8 +147,8 @@ def test_spi_flag_takes_the_spi_profile_wiring(contexts) -> None: # noqa: ANN00
"""``--spi`` means the radio on this machine, wired as its profile says when there is one."""
wired = SpiWiring(en_pins=(27,))
profiles = {"aio2": DeviceProfile(name="aio2", transport="spi", spi=wired)}
assert contexts(spi_override=True, profiles=profiles)._resolve_spi() == wired
assert contexts(spi_override=True)._resolve_spi() == SpiWiring()
assert contexts(spi_override=True, profiles=profiles).resolve_spi() == wired
assert contexts(spi_override=True).resolve_spi() == SpiWiring()
assert contexts(spi_override=True).active_transport == "spi"
@@ -159,7 +159,7 @@ def test_another_named_radio_is_not_the_spi_radio(contexts) -> None: # noqa: AN
{"tcp_override": "10.0.0.2"},
{"ble_override": "AA"},
):
assert contexts(**override)._resolve_spi() is None
assert contexts(**override).resolve_spi() is None
def test_a_listed_radio_is_wired_by_the_profile_on_its_node(contexts) -> None: # noqa: ANN001
@@ -410,27 +410,25 @@ async def test_the_device_ends_its_node_when_the_connection_fails(
# --- where it shows up ---------------------------------------------------------------------
def test_the_picker_lists_each_radio_whose_device_node_exists(
def test_each_radio_whose_device_node_exists_is_listed(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A profile's radio by its name; the AIO's node on its own when no profile covers it."""
from meshterm.ui import device_picker
present = {"/dev/spidev1.0", "/dev/spidev0.0"}
monkeypatch.setattr(device_picker, "spi_present", lambda w: w.spidev in present)
monkeypatch.setattr(spiradio, "spi_present", lambda w: w.spidev in present)
hat = SpiWiring(bus_id=0, reset_pin=17)
profiles = {
"hat": DeviceProfile(name="hat", transport="spi", spi=hat),
"gone": DeviceProfile(name="gone", transport="spi", spi=SpiWiring(bus_id=3)),
}
rows = device_picker._spi_devices([], profiles)
rows = spiradio.spi_radios(profiles)
assert [(r.port, r.name) for r in rows] == [("/dev/spidev0.0", "hat"), ("/dev/spidev1.0", None)]
# A profile on the AIO's own node takes that row instead of a nameless one.
profiles["aio"] = DeviceProfile(name="aio", transport="spi")
rows = device_picker._spi_devices([], profiles)
rows = spiradio.spi_radios(profiles)
assert [r.name for r in rows] == ["hat", "aio"]
# And a radio already in the list is not listed twice.
assert device_picker._spi_devices(rows, profiles) == []
assert spiradio.spi_radios(profiles, rows) == []
def test_spi_and_another_radio_on_one_command_line_is_a_usage_error() -> None:
@@ -442,3 +440,23 @@ def test_spi_and_another_radio_on_one_command_line_is_a_usage_error() -> None:
result = CliRunner().invoke(app, ["--spi", "--port", "COM7", "info"])
assert result.exit_code == 2
assert "--spi and --port name different devices" in result.output
def test_meshterm_devices_lists_the_spi_radio(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""The CLI inventory shows the radio the device screen shows, marked active under --spi."""
from typer.testing import CliRunner
from meshterm.cli import app
from meshterm.tools import devices
monkeypatch.setenv("MESHTERM_HOME", str(tmp_path))
monkeypatch.setattr(devices, "discover_devices", list)
monkeypatch.setattr(spiradio, "spi_present", lambda w: w.spidev == "/dev/spidev1.0")
result = CliRunner().invoke(app, ["--spi", "--json", "devices", "--no-ble"])
assert result.exit_code == 0, result.output
rows = json.loads(result.stdout)
assert [(r["target"], r["transport"], r["active"]) for r in rows] == [
("/dev/spidev1.0", "spi", True)
]