--spi picks the one radio there is, and refuses to guess between two

--spi used to take whichever SPI profile config.toml listed first, a
coin toss that transmits once two radios are configured. It now counts
the radios actually attached: exactly one is used whether or not a
profile names it, which keeps the common single-radio case to a bare
--spi. Two attached, or two SPI profiles with nothing attached to count,
is a selection error (exit 3) that lists them and asks for -p, the same
way several serial devices with no default already are.

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 20:12:35 -04:00
1 parent f50db48074
commit 430f5b2daa
4 files changed
+97 -11

No files matched your search

+1 -1
View File
@@ -55,7 +55,7 @@ With no command, MeshTerm launches the interactive menu instead.
| `--ble ADDRESS` | Bluetooth address of a companion; selects the BLE transport. |
| `--ble-pin PIN` | Pairing PIN, if the Bluetooth companion asks for one. |
| `--tcp HOST[:PORT]` | Network address of a TCP companion; selects the TCP transport. Default port 5000. |
| `--spi` | The LoRa radio on this machine's own SPI bus; selects the SPI transport. MeshTerm runs the node for the length of the command. Uses the first SPI profile's wiring, else the uConsole AIO's — see [Adding a radio on the SPI bus](configuration.md#adding-a-radio-on-the-spi-bus). Linux only. |
| `--spi` | The LoRa radio on this machine's own SPI bus; selects the SPI transport. MeshTerm runs the node for the length of the command. With one radio attached, that's the one, profile or not; with none attached, the one SPI profile, else the uConsole AIO's wiring. With two, it refuses (exit `3`) and lists them — pick one with `-p`. See [Adding a radio on the SPI bus](configuration.md#adding-a-radio-on-the-spi-bus). Linux only. |
| `--mock` | Use the built-in simulator instead of real hardware. Nothing transmits, and the run records to `meshterm-mock.db` rather than your real history. |
| `--db PATH` | Use this SQLite database instead of `~/.meshterm/meshterm.db` (or `meshterm-mock.db`, under `--mock`). **It moves the database and nothing else** — see [Where MeshTerm keeps its state](#where-meshterm-keeps-its-state). |
| `--json` | Print the answer as JSON instead of aligned text. |
+39 -5
View File
@@ -661,7 +661,7 @@ class AppContext:
"""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``
(the first SPI profile's wiring, else the AIO's), then the remembered default when it
(the one radio attached — see :meth:`_spi_for_flag`), then the remembered default when it
was an SPI radio and nothing else was named. Anything else named explicitly — a port,
an address, a host, another kind of profile — means this session is not about the SPI
radio.
@@ -672,10 +672,7 @@ class AppContext:
if chosen is not None and chosen.is_spi:
return chosen.spi or self.spi_wiring_for(chosen.port) # type: ignore[return-value]
if self.spi_override:
return next(
(p.spi or SpiWiring() for p in self.settings.profiles.values() if p.is_spi),
SpiWiring(),
)
return self._spi_for_flag()
if self.port_override or self.ble_override or self.tcp_override or self.profile:
return None
remembered = self.device_store.load()
@@ -684,6 +681,43 @@ class AppContext:
self.selected_device = None # remembered, not freshly discovered this session
return self.spi_wiring_for(remembered.target)
def _spi_for_flag(self) -> SpiWiring:
"""The radio a bare ``--spi`` means: the one there is, and never a guess between two.
Nearly everyone with a radio on the SPI bus has exactly one, and for them ``--spi``
needs nothing more — attached radios are counted first, so a single one is used
whether or not a profile names it. Only when two are attached, or (with none
attached to count) two SPI profiles are configured, is the flag ambiguous; it used
to take whichever profile ``config.toml`` listed first, which is a coin toss that
transmits. With none attached and one profile or none, the choice is made from the
configuration, so the connect attempt can say what is missing.
Raises:
DeviceSelectionError: When more than one radio could be meant.
"""
from .core.selection import DeviceSelectionError
from .core.spiradio import spi_radios
profiles = self.settings.profiles
attached = spi_radios(profiles)
if len(attached) == 1:
return attached[0].spi # type: ignore[return-value]
configured = [p for p in profiles.values() if p.is_spi]
if not attached and len(configured) <= 1:
return (configured[0].spi or SpiWiring()) if configured else SpiWiring()
if attached:
rows = [f" {d.name or '(no profile)':<16} {d.port}" for d in attached]
what = "Several radios are attached to the SPI bus"
else:
rows = [f" {p.name:<16} {(p.spi or SpiWiring()).spidev}" for p in configured]
what = "Several SPI profiles are configured"
raise DeviceSelectionError(
f"{what}, and --spi doesn't say which.\n"
+ "\n".join(rows)
+ "\nChoose one with -p <PROFILE>. A radio with no profile needs one first — see "
"'Adding a radio on the SPI bus' in docs/configuration.md."
)
def spi_wiring_for(self, spidev: str) -> SpiWiring:
"""The wiring for the radio on ``spidev``: a profile's that names it, else the AIO's.
+10 -5
View File
@@ -25,6 +25,7 @@ from ..core.discovery import (
discover_all,
discover_devices,
)
from ..core.selection import DeviceSelectionError
from ..core.spiradio import spi_radios
from .base import Tool, ToolResult, register
@@ -81,11 +82,15 @@ class DevicesTool(Tool):
known = ctx.device_store.load_all()
remembered = ctx.device_store.load()
active = ctx.selected_device
spi = (
ctx.resolve_spi()
if ctx.spi_override or (ctx.profile is not None and ctx.profile.is_spi)
else None
)
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 is how the reader finds out which
active_target = (
ctx.ble_override
or ctx.port_override
+47
View File
@@ -152,6 +152,53 @@ def test_spi_flag_takes_the_spi_profile_wiring(contexts) -> None: # noqa: ANN00
assert contexts(spi_override=True).active_transport == "spi"
def test_spi_flag_takes_the_one_radio_attached(contexts, monkeypatch) -> None: # noqa: ANN001
"""The common case: one radio on the bus, and --spi is all it takes, profile or not."""
monkeypatch.setattr(spiradio, "spi_present", lambda w: w.spidev == "/dev/spidev1.0")
elsewhere = SpiWiring(bus_id=0, reset_pin=17) # a profile for a radio not plugged in
profiles = {"hat": DeviceProfile(name="hat", transport="spi", spi=elsewhere)}
assert contexts(spi_override=True, profiles=profiles).resolve_spi() == SpiWiring()
assert contexts(spi_override=True).resolve_spi() == SpiWiring()
def test_spi_flag_refuses_to_guess_between_two_attached_radios(
contexts, # noqa: ANN001
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Two radios on the bus: --spi names neither, so it lists them and asks for -p."""
from meshterm.core.selection import DeviceSelectionError
monkeypatch.setattr(spiradio, "spi_present", lambda w: True)
hat = SpiWiring(bus_id=0, reset_pin=17)
profiles = {"hat": DeviceProfile(name="hat", transport="spi", spi=hat)}
with pytest.raises(DeviceSelectionError) as err:
contexts(spi_override=True, profiles=profiles).resolve_spi()
message = str(err.value)
assert "Several radios are attached to the SPI bus" in message
assert "hat" in message and "/dev/spidev0.0" in message
assert "(no profile)" in message and "/dev/spidev1.0" in message
assert "-p <PROFILE>" in message
# A profile still says exactly which, however many there are.
ctx = contexts(profile=profiles["hat"], profiles=profiles)
assert ctx.resolve_spi() == hat
def test_spi_flag_refuses_two_profiles_with_nothing_attached(
contexts, # noqa: ANN001
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""With no radio to count, two SPI profiles leave --spi as ambiguous as two radios."""
from meshterm.core.selection import DeviceSelectionError
monkeypatch.setattr(spiradio, "spi_present", lambda w: False)
profiles = {
"aio": DeviceProfile(name="aio", transport="spi"),
"hat": DeviceProfile(name="hat", transport="spi", spi=SpiWiring(bus_id=0)),
}
with pytest.raises(DeviceSelectionError, match="Several SPI profiles are configured"):
contexts(spi_override=True, profiles=profiles).resolve_spi()
def test_another_named_radio_is_not_the_spi_radio(contexts) -> None: # noqa: ANN001
"""A port, an address or a host named outright means the session is about that radio."""
for override in (