diff --git a/docs/cli.md b/docs/cli.md index afd2092..5c3c54e 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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. | diff --git a/meshterm/context.py b/meshterm/context.py index 6af1a1c..3ea1217 100644 --- a/meshterm/context.py +++ b/meshterm/context.py @@ -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 . 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. diff --git a/meshterm/tools/devices.py b/meshterm/tools/devices.py index f8ddc33..aa60103 100644 --- a/meshterm/tools/devices.py +++ b/meshterm/tools/devices.py @@ -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 diff --git a/tests/test_spiradio.py b/tests/test_spiradio.py index db7f42e..f5042be 100644 --- a/tests/test_spiradio.py +++ b/tests/test_spiradio.py @@ -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 " 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 (