diff --git a/docs/cli.md b/docs/cli.md index bb82daa..afd2092 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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. diff --git a/meshterm/context.py b/meshterm/context.py index b6544c6..6af1a1c 100644 --- a/meshterm/context.py +++ b/meshterm/context.py @@ -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`` diff --git a/meshterm/core/spiradio.py b/meshterm/core/spiradio.py index cb870fd..fd5b883 100644 --- a/meshterm/core/spiradio.py +++ b/meshterm/core/spiradio.py @@ -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. diff --git a/meshterm/tools/devices.py b/meshterm/tools/devices.py index 1a2f137..f8ddc33 100644 --- a/meshterm/tools/devices.py +++ b/meshterm/tools/devices.py @@ -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( diff --git a/meshterm/ui/device_picker.py b/meshterm/ui/device_picker.py index 32ef3b5..710999c 100644 --- a/meshterm/ui/device_picker.py +++ b/meshterm/ui/device_picker.py @@ -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, diff --git a/tests/test_spiradio.py b/tests/test_spiradio.py index 57a84f3..db7f42e 100644 --- a/tests/test_spiradio.py +++ b/tests/test_spiradio.py @@ -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) + ]