Files
MeshTerm/tests/test_spiradio.py
T
Jean-Pierre MartineauandClaude Opus 5.5 430f5b2daa --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>
2026-09-24 20:12:35 -04:00

510 lines
21 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""MeshTerm's half of the SPI radio: wiring, finding the node's Python, and its lifetime.
The node itself is :mod:`tests.test_radionode`. Here a stand-in node script plays its part —
one status line on stdout, then wait for stdin to close — so the protocol between the two
processes, the error wording and the shutdown ladder are exercised on any platform.
"""
from __future__ import annotations
import io
import json
import sys
import textwrap
from pathlib import Path
import pytest
from rich.console import Console
from meshterm.context import AppContext
from meshterm.core import spiradio
from meshterm.core.admin_store import AdminStore
from meshterm.core.config import DeviceProfile, Settings, SpiWiring
from meshterm.core.device_store import DeviceStore
from meshterm.core.discovery import TRANSPORT_SPI, spi_device
from meshterm.persistence.repository import Repository
# --- wiring --------------------------------------------------------------------------------
def test_the_default_wiring_is_the_uconsole_aio() -> None:
"""A profile with no ``spi`` table is the AIO v1, which is what the defaults describe."""
wiring = SpiWiring()
assert (wiring.bus_id, wiring.reset_pin, wiring.busy_pin, wiring.irq_pin) == (1, 25, 24, 26)
assert wiring.use_dio2_rf and wiring.use_dio3_tcxo and wiring.en_pins == ()
assert wiring.spidev == "/dev/spidev1.0"
def test_a_wiring_table_states_only_what_differs() -> None:
"""The AIO v2's one difference is its power-enable pin; nothing else moves."""
wiring = SpiWiring.from_toml({"en_pins": [27]})
assert wiring == SpiWiring(en_pins=(27,))
@pytest.mark.parametrize(
("table", "complaint"),
[
({"irq_pn": 22}, "unknown SPI wiring key(s): irq_pn"),
({"reset_pin": "25"}, "reset_pin = '25' is not a int"),
({"use_dio2_rf": 1}, "use_dio2_rf = 1 is not a bool"),
({"en_pins": 27}, "en_pins = 27 is not a tuple"),
({"busy_pin": True}, "busy_pin = True is not a int"),
],
)
def test_a_wiring_mistake_is_refused_not_ignored(table: dict, complaint: str) -> None:
"""A misspelt pin would otherwise leave a default in force and a deaf radio."""
with pytest.raises(ValueError, match=complaint.replace("(", r"\(").replace(")", r"\)")):
SpiWiring.from_toml(table)
def test_the_retired_preamble_key_is_refused_with_its_reason() -> None:
"""A config still carrying the old fixed preamble is told why, not left deaf."""
with pytest.raises(ValueError, match="preamble_length is no longer a wiring key"):
SpiWiring.from_toml({"preamble_length": 12})
def test_a_profile_with_an_spi_table_is_an_spi_profile(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The ``spi`` table implies the transport, the way ``host`` implies TCP."""
monkeypatch.setenv("MESHTERM_HOME", str(tmp_path))
(tmp_path / "config.toml").write_text(
textwrap.dedent(
"""
[profiles.aio2.spi]
en_pins = [27]
[profiles.aio]
transport = "spi"
"""
),
encoding="utf-8",
)
profiles = Settings.load().profiles
assert profiles["aio2"].is_spi and profiles["aio2"].spi == SpiWiring(en_pins=(27,))
assert profiles["aio"].is_spi and profiles["aio"].spi == SpiWiring()
def test_a_bad_wiring_table_names_its_profile(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A wiring error is reported against the profile it is in, not as a bare key."""
monkeypatch.setenv("MESHTERM_HOME", str(tmp_path))
(tmp_path / "config.toml").write_text("[profiles.aio.spi]\nirq = 1\n", encoding="utf-8")
with pytest.raises(ValueError, match=r"\[profiles\.aio\.spi\]"):
Settings.load()
def test_state_lives_under_the_spi_device_not_the_profile(tmp_path: Path) -> None:
"""One radio is one node however it was reached, so its key is filed by device node."""
assert spiradio.state_dir(tmp_path, SpiWiring()) == tmp_path / "radio" / "spidev1.0"
assert spiradio.state_dir(tmp_path, SpiWiring(bus_id=0, cs_id=1)).name == "spidev0.1"
def test_an_spi_radio_lists_like_a_device_on_its_node() -> None:
"""The picker row is named by the device node, which is also its remembered identity."""
device = spi_device(SpiWiring(), name="aio")
assert device.transport == TRANSPORT_SPI and device.is_spi
assert device.target == "/dev/spidev1.0"
assert device.stable_id == "spi:/dev/spidev1.0"
assert device.label == "aio (/dev/spidev1.0)"
assert spi_device(SpiWiring()).label == "SPI radio (/dev/spidev1.0)"
# --- choosing the radio --------------------------------------------------------------------
@pytest.fixture()
def contexts(tmp_path: Path): # noqa: ANN201 - a factory fixture
"""Build contexts over a scratch config directory, closing their databases after."""
made: list[AppContext] = []
def make(**kwargs: object) -> AppContext:
profiles = kwargs.pop("profiles", {})
settings = Settings(
config_dir=tmp_path,
db_path=tmp_path / "spi.db",
profiles=profiles, # type: ignore[arg-type]
)
ctx = AppContext(
console=Console(file=io.StringIO()),
settings=settings,
repo=Repository(settings.db_path),
device_store=DeviceStore(tmp_path / "devices.json"),
admin_store=AdminStore(tmp_path / "admin.json"),
**kwargs, # type: ignore[arg-type]
)
made.append(ctx)
return ctx
yield make
for ctx in made:
ctx.repo.close()
def test_spi_flag_takes_the_spi_profile_wiring(contexts) -> None: # noqa: ANN001
"""``--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).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 (
{"port_override": "COM7"},
{"tcp_override": "10.0.0.2"},
{"ble_override": "AA"},
):
assert contexts(**override).resolve_spi() is None
def test_a_listed_radio_is_wired_by_the_profile_on_its_node(contexts) -> None: # noqa: ANN001
"""A remembered or listed radio is known by its node, and its pins come from that."""
other = SpiWiring(bus_id=0, reset_pin=17)
profiles = {"hat": DeviceProfile(name="hat", transport="spi", spi=other)}
ctx = contexts(profiles=profiles)
assert ctx.spi_wiring_for("/dev/spidev0.0") == other
assert ctx.spi_wiring_for("/dev/spidev1.0") == SpiWiring()
# --- finding a Python for the node ---------------------------------------------------------
def test_a_named_python_is_the_only_candidate(monkeypatch: pytest.MonkeyPatch) -> None:
"""Naming an interpreter is a decision; falling back past it would hide that it fails."""
probed: list[str] = []
monkeypatch.setattr(spiradio, "_can_import_runtime", lambda p: probed.append(p) or False)
with pytest.raises(spiradio.SpiRadioError) as err:
spiradio.find_runtime(SpiWiring(python="/opt/radio/bin/python"))
assert err.value.kind == "runtime"
assert probed == ["/opt/radio/bin/python"]
def test_the_first_python_with_the_library_wins(monkeypatch: pytest.MonkeyPatch) -> None:
"""Candidates are tried in order, and the search stops at the first that can import it."""
monkeypatch.setattr(spiradio, "_runtime_candidates", lambda w: ["/a", "/b", "/c"])
monkeypatch.setattr(spiradio, "_can_import_runtime", lambda p: p != "/a")
assert spiradio.find_runtime(SpiWiring()) == "/b"
def test_a_frozen_build_hands_the_node_a_clean_environment(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Another Python must load its own libraries, not the one-file build's unpacked copies."""
monkeypatch.setattr(sys, "frozen", True, raising=False)
monkeypatch.setenv("_PYI_APPLICATION_HOME_DIR", "/tmp/_MEI1")
monkeypatch.setenv("_MEIPASS2", "/tmp/_MEI1")
monkeypatch.setenv("LD_LIBRARY_PATH", "/tmp/_MEI1")
monkeypatch.setenv("LD_LIBRARY_PATH_ORIG", "/usr/local/lib")
env = spiradio.child_env()
assert "_MEIPASS2" not in env and not any(k.startswith("_PYI") for k in env)
assert env["LD_LIBRARY_PATH"] == "/usr/local/lib"
assert "LD_LIBRARY_PATH_ORIG" not in env
monkeypatch.delenv("LD_LIBRARY_PATH_ORIG")
assert "LD_LIBRARY_PATH" not in spiradio.child_env()
# --- first use: the bridge's node carries over ---------------------------------------------
def _bridge_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""An XDG data home holding a bridge's state, with the service's radio settings stubbed."""
data = tmp_path / "share"
monkeypatch.setenv("XDG_DATA_HOME", str(data))
monkeypatch.setattr(spiradio, "_bridge_radio_seed", lambda: {"frequency_hz": 869_525_000})
bridge = data / "meshterm-spi-bridge"
bridge.mkdir(parents=True)
(bridge / "identity.key").write_bytes(b"\xbb" * 32)
(bridge / "contacts.json").write_text('[{"public_key": "ab", "name": "Ridge"}]')
return data
def test_the_bridge_node_carries_over_the_first_time(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Identity, contacts, name and radio: the node is the one everybody already knows."""
_bridge_home(tmp_path, monkeypatch)
state = tmp_path / "radio" / "spidev1.0"
carried = spiradio.import_bridge_state(state)
assert len(carried) == 3
assert (state / "identity.key").read_bytes() == b"\xbb" * 32
assert json.loads((state / "contacts.json").read_text())[0]["name"] == "Ridge"
prefs = json.loads((state / "prefs.json").read_text())
assert prefs == {"node_name": "uConsole", "frequency_hz": 869_525_000}
assert spiradio.import_bridge_state(state) == [], "never twice: the node has its own now"
def test_the_gui_key_wins_over_the_bridge_own(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The bridge shares the meshcore-uconsole GUI's key when there is one, so that is it."""
data = _bridge_home(tmp_path, monkeypatch)
(data / "meshcore-uconsole").mkdir()
(data / "meshcore-uconsole" / "identity.key").write_bytes(b"\xcc" * 32)
state = tmp_path / "radio" / "spidev1.0"
spiradio.import_bridge_state(state)
assert (state / "identity.key").read_bytes() == b"\xcc" * 32
def test_no_bridge_means_nothing_carried(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Without a key to inherit the node mints its own, and nothing else is copied either."""
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "empty"))
state = tmp_path / "radio" / "spidev1.0"
assert spiradio.import_bridge_state(state) == []
assert not state.exists()
# --- the error a person reads --------------------------------------------------------------
@pytest.mark.parametrize(
("kind", "holder", "says"),
[
("busy", "bridge", "systemctl --user stop meshterm-spi-bridge"),
("busy", "console", "meshcore-console has the radio open"),
("busy", None, "sudo lsof /dev/gpiochip0"),
("runtime", None, "pipx inject mesh-term 'openhop-core[hardware]'"),
("no-spi", None, "dtoverlay=spi1-1cs"),
("permission", None, "sudo usermod -aG spi,gpio $USER"),
],
)
def test_every_failure_says_what_to_do(kind: str, holder: str | None, says: str) -> None:
"""Each way the node can fail to start comes with the command that fixes it."""
assert says in spiradio.explain(kind, "detail", SpiWiring(), holder)
# --- the node process ----------------------------------------------------------------------
_READY = """
import json, sys
print(json.dumps({"event": "ready", "port": 4321, "public_key": "ab" * 32}), flush=True)
sys.stdin.buffer.read()
"""
_REFUSES = """
import json
message = "no access to /dev/spidev1.0"
print(json.dumps({"event": "error", "kind": "permission", "message": message}))
"""
_SILENT = "import sys\nsys.exit(3)\n"
_DEAF = """
import json, time
print(json.dumps({"event": "ready", "port": 4321}), flush=True)
while True:
time.sleep(1)
"""
def _node(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, source: str) -> None:
"""Stand ``source`` in for the node script, run by this interpreter on "Linux"."""
script = tmp_path / "node.py"
script.write_text(source, encoding="utf-8")
monkeypatch.setattr(spiradio, "node_script", lambda: script)
monkeypatch.setattr(spiradio, "_on_linux", lambda: True)
monkeypatch.setattr(spiradio, "radio_holder", lambda: None)
monkeypatch.setattr(spiradio, "find_runtime", lambda wiring: sys.executable)
monkeypatch.setattr(spiradio, "_bridge_radio_seed", dict)
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "no-bridge"))
@pytest.mark.asyncio
async def test_a_ready_node_reports_its_port_and_leaves_when_asked(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The node's one line is its port; closing its stdin is all it takes to end it."""
_node(monkeypatch, tmp_path, _READY)
node = await spiradio.start_node(SpiWiring(), tmp_path / "state", node_name="Test")
assert (node.port, node.public_key) == (4321, "ab" * 32) and node.alive
await node.stop()
assert not node.alive and node.process.returncode == 0
@pytest.mark.asyncio
async def test_a_refusal_arrives_as_its_kind_with_the_fix(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The node's error line becomes an actionable error, not "didn't answer"."""
_node(monkeypatch, tmp_path, _REFUSES)
with pytest.raises(spiradio.SpiRadioError) as err:
await spiradio.start_node(SpiWiring(), tmp_path / "state", node_name="Test")
assert err.value.kind == "permission"
assert "usermod -aG spi,gpio" in str(err.value)
@pytest.mark.asyncio
async def test_a_node_that_dies_silently_points_at_its_log(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""With nothing said, the log is the one place the reason can be."""
_node(monkeypatch, tmp_path, _SILENT)
with pytest.raises(spiradio.SpiRadioError) as err:
await spiradio.start_node(SpiWiring(), tmp_path / "state", node_name="Test")
assert err.value.kind == "failed" and "node.log" in str(err.value)
@pytest.mark.asyncio
async def test_a_node_deaf_to_its_stdin_is_ended_anyway(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A wedged node never holds the radio past a quit: the signals end it."""
_node(monkeypatch, tmp_path, _DEAF)
monkeypatch.setattr(spiradio, "STOP_GRACE_S", 0.3)
node = await spiradio.start_node(SpiWiring(), tmp_path / "state", node_name="Test")
await node.stop()
assert not node.alive
@pytest.mark.asyncio
async def test_a_known_holder_is_named_before_any_node_starts(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The bridge service holding the radio is said at once, with how to stop it."""
_node(monkeypatch, tmp_path, _READY)
monkeypatch.setattr(spiradio, "radio_holder", lambda: "bridge")
with pytest.raises(spiradio.SpiRadioError, match="systemctl --user stop"):
await spiradio.start_node(SpiWiring(), tmp_path / "state", node_name="Test")
@pytest.mark.asyncio
async def test_only_linux_drives_an_spi_radio(tmp_path: Path) -> None:
"""Anywhere else the radio can't be there, and the reason is the platform."""
if sys.platform.startswith("linux"):
pytest.skip("this is Linux")
with pytest.raises(spiradio.SpiRadioError) as err:
await spiradio.start_node(SpiWiring(), tmp_path, node_name="Test")
assert err.value.kind == "unsupported"
@pytest.mark.asyncio
async def test_the_device_ends_its_node_when_the_connection_fails(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A node whose companion never answers must not be left holding the radio."""
_node(monkeypatch, tmp_path, _READY)
started: list[spiradio.NodeProcess] = []
real_start = spiradio.start_node
async def start(*args: object, **kwargs: object) -> spiradio.NodeProcess:
node = await real_start(*args, **kwargs) # type: ignore[arg-type]
started.append(node)
return node
async def no_companion(self: object) -> None:
raise spiradio.DeviceCommandError("no answer")
monkeypatch.setattr(spiradio, "start_node", start)
monkeypatch.setattr(spiradio.MeshCoreDevice, "connect", no_companion)
device = spiradio.SpiRadioDevice(SpiWiring(), tmp_path / "state")
assert device.transport == "spi" and device.endpoint == "/dev/spidev1.0"
with pytest.raises(spiradio.DeviceCommandError):
await device.connect()
assert started and not started[0].alive
# --- where it shows up ---------------------------------------------------------------------
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."""
present = {"/dev/spidev1.0", "/dev/spidev0.0"}
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 = 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 = 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 spiradio.spi_radios(profiles, rows) == []
def test_spi_and_another_radio_on_one_command_line_is_a_usage_error() -> None:
"""The SPI radio is on this machine, and every other flag names one that isn't."""
from typer.testing import CliRunner
from meshterm.cli import app
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)
]