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:
1 parent
2d5e8bb67d
commit
f50db48074
6 files changed
+98
-61
No files matched your search
+7
-3
@@ -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
@@ -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,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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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)
|
||||
]
|
||||
Reference in new issue
Block a user