Naming the AIO rested on the uConsole's panel alone. It now takes every mark, from two independent sources: ClockworkPi's uConsole overlay describing the hardware in the device tree -- the cwu50 screen, the AXP228 power chip, the OCP8178 backlight -- and the hardware answering for itself on USB, the built-in keyboard (1eaf:0024, made by ClockworkPI, named uConsole; the ids alone are any LeafLabs Maple). An overlay on the wrong machine has no uConsole keyboard, and the keyboard on another machine has no uConsole overlay. Any one missing, and the radio is listed unnamed on the AIO's wiring, as before. `BuiltinRadio.compatible` gives way to `board`, a test only its machine passes (`is_uconsole`). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
772 lines
33 KiB
Python
772 lines
33 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 dataclasses import replace
|
|
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, DiscoveredDevice, 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_a_board_s_switches_and_expander_come_from_the_table() -> None:
|
|
"""``leds`` holds switch strings and ``pi4io_high`` pins: each list is checked as its own."""
|
|
wiring = SpiWiring.from_toml(
|
|
{"leds": ["ext_5v_out=1"], "pi4io_bus": 1, "pi4io_address": 0x44, "pi4io_high": [0, 3]}
|
|
)
|
|
assert wiring == SpiWiring(
|
|
leds=("ext_5v_out=1",), pi4io_bus=1, pi4io_address=0x44, pi4io_high=(0, 3)
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("table", "complaint"),
|
|
[
|
|
({"leds": [1]}, "leds = [1] is not a tuple"),
|
|
({"pi4io_high": ["0"]}, "pi4io_high = ['0'] is not a tuple"),
|
|
({"leds": ["ext_5v_out"]}, "leds entry 'ext_5v_out' is not name=brightness"),
|
|
({"leds": ["ext_5v_out=on"]}, "leds entry 'ext_5v_out=on' is not name=brightness"),
|
|
],
|
|
)
|
|
def test_a_malformed_switch_is_refused(table: dict, complaint: str) -> None:
|
|
"""A switch the node couldn't set would fail at connect; the file is where to say so."""
|
|
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()
|
|
|
|
|
|
# --- radios that need no profile -----------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture()
|
|
def cardputer(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|
"""A machine with the Cardputer Zero's SPI node, its board marker under ``tmp_path``.
|
|
|
|
Returns the marker, which a test removes to make the machine some other Raspberry Pi
|
|
with the same ``/dev/spidev0.1``.
|
|
"""
|
|
marker = tmp_path / "ext_5v_out"
|
|
marker.mkdir()
|
|
monkeypatch.setattr(spiradio, "spi_present", lambda w: w.spidev == "/dev/spidev0.1")
|
|
monkeypatch.setattr(
|
|
spiradio,
|
|
"BUILTIN_RADIOS",
|
|
(
|
|
spiradio.BuiltinRadio("", SpiWiring()),
|
|
spiradio.BuiltinRadio("Cap LoRa-1262", spiradio.CARDPUTER_ZERO_CAP, marker=str(marker)),
|
|
),
|
|
)
|
|
return marker
|
|
|
|
|
|
def test_the_cardputer_cap_is_listed_with_no_profile(cardputer: Path) -> None:
|
|
"""On a Cardputer Zero the Cap is a radio like the AIO: there, named, and wired."""
|
|
radios = spiradio.spi_radios({})
|
|
assert [(d.port, d.name) for d in radios] == [("/dev/spidev0.1", "Cap LoRa-1262")]
|
|
assert radios[0].spi == spiradio.CARDPUTER_ZERO_CAP
|
|
|
|
|
|
def test_a_bare_spidev_node_is_not_a_cardputer(cardputer: Path) -> None:
|
|
"""``spidev0.1`` is on every Pi with SPI on; only the board's own switch says Cardputer."""
|
|
cardputer.rmdir()
|
|
assert spiradio.spi_radios({}) == []
|
|
assert spiradio.builtin_wiring("/dev/spidev0.1") is None
|
|
|
|
|
|
def test_the_cap_reports_itself_by_name(cardputer: Path) -> None:
|
|
"""The node's model is the board's name: the Cap, even under a profile that tweaks it."""
|
|
assert spiradio.board_name(spiradio.CARDPUTER_ZERO_CAP) == "Cap LoRa-1262"
|
|
tweaked = replace(spiradio.CARDPUTER_ZERO_CAP, use_dio3_tcxo=False)
|
|
assert spiradio.board_name(tweaked) == "Cap LoRa-1262"
|
|
assert spiradio.board_name(SpiWiring()) == "" # the AIO's node: no board name to give
|
|
cardputer.rmdir()
|
|
assert spiradio.board_name(spiradio.CARDPUTER_ZERO_CAP) == "" # some other Pi
|
|
|
|
|
|
#: Every mark of a uConsole, as JP's CM5 uConsole showed them.
|
|
_UCONSOLE_MARKS = (
|
|
*sorted(spiradio.UCONSOLE_TREE),
|
|
spiradio.UCONSOLE_KEYBOARD,
|
|
)
|
|
|
|
|
|
@pytest.fixture()
|
|
def uconsole(monkeypatch: pytest.MonkeyPatch) -> set:
|
|
"""A uConsole with the AIO's SPI node: every mark present, for a test to take one away."""
|
|
marks: set = set(_UCONSOLE_MARKS)
|
|
tree = {"raspberrypi,5-compute-module", "brcm,bcm2712"}
|
|
usb = {("1d6b", "0002", "Linux", "xHCI Host Controller")}
|
|
monkeypatch.setattr(spiradio, "spi_present", lambda w: w.spidev == "/dev/spidev1.0")
|
|
monkeypatch.setattr(
|
|
spiradio,
|
|
"device_tree_compatibles",
|
|
lambda: frozenset(tree | {m for m in marks if isinstance(m, str)}),
|
|
)
|
|
monkeypatch.setattr(
|
|
spiradio,
|
|
"usb_devices",
|
|
lambda: frozenset(usb | {m for m in marks if isinstance(m, tuple)}),
|
|
)
|
|
return marks
|
|
|
|
|
|
def test_the_aio_is_named_on_a_uconsole(uconsole: set) -> None:
|
|
"""With every mark of a uConsole present, the AIO is the uConsole AIO."""
|
|
radios = spiradio.spi_radios({})
|
|
assert [(d.port, d.name) for d in radios] == [("/dev/spidev1.0", "uConsole AIO")]
|
|
assert spiradio.board_name(SpiWiring()) == "uConsole AIO"
|
|
|
|
|
|
@pytest.mark.parametrize("missing", _UCONSOLE_MARKS, ids=str)
|
|
def test_one_missing_mark_is_enough_to_doubt_it(uconsole: set, missing: object) -> None:
|
|
"""Any one mark missing and the radio is listed on the AIO's wiring, but never named."""
|
|
uconsole.discard(missing)
|
|
radios = spiradio.spi_radios({})
|
|
assert [(d.port, d.name) for d in radios] == [("/dev/spidev1.0", None)]
|
|
assert spiradio.board_name(SpiWiring()) == ""
|
|
|
|
|
|
def test_the_keyboard_s_names_are_part_of_its_mark(uconsole: set) -> None:
|
|
"""1eaf:0024 is any LeafLabs Maple; only ClockworkPi's uConsole keyboard counts."""
|
|
uconsole.discard(spiradio.UCONSOLE_KEYBOARD)
|
|
uconsole.add(("1eaf", "0024", "LeafLabs", "Maple"))
|
|
assert spiradio.board_name(SpiWiring()) == ""
|
|
|
|
|
|
def test_usb_devices_are_read_from_sysfs(tmp_path: Path) -> None:
|
|
"""Ids, maker and product per device; a device naming none reads as empty there."""
|
|
keyboard = tmp_path / "1-1.1"
|
|
keyboard.mkdir()
|
|
for name, value in (
|
|
("idVendor", "1eaf"),
|
|
("idProduct", "0024"),
|
|
("manufacturer", "ClockworkPI"),
|
|
("product", "uConsole"),
|
|
):
|
|
(keyboard / name).write_text(value + "\n", encoding="utf-8")
|
|
hub = tmp_path / "1-1.4"
|
|
hub.mkdir()
|
|
(hub / "idVendor").write_text("1a86\n", encoding="utf-8")
|
|
(hub / "idProduct").write_text("8091\n", encoding="utf-8")
|
|
(tmp_path / "1-1.1-if0").mkdir() # an interface, not a device: no idVendor
|
|
assert spiradio.usb_devices(tmp_path) == {
|
|
("1eaf", "0024", "ClockworkPI", "uConsole"),
|
|
("1a86", "8091", "", ""),
|
|
}
|
|
assert spiradio.usb_devices(tmp_path / "nowhere") == frozenset()
|
|
|
|
|
|
def test_the_device_tree_is_read_whole(tmp_path: Path) -> None:
|
|
"""Every compatible, at any depth, each of a node's NUL-separated strings."""
|
|
panel = tmp_path / "axi" / "dsi@128000" / "panel@0"
|
|
panel.mkdir(parents=True)
|
|
(panel / "compatible").write_bytes(b"cw,cwu50\0")
|
|
(tmp_path / "compatible").write_bytes(b"raspberrypi,5-compute-module\0brcm,bcm2712\0")
|
|
assert spiradio.device_tree_compatibles(tmp_path) == {
|
|
"cw,cwu50",
|
|
"raspberrypi,5-compute-module",
|
|
"brcm,bcm2712",
|
|
}
|
|
assert spiradio.device_tree_compatibles(tmp_path / "nowhere") == frozenset()
|
|
|
|
|
|
def test_a_profile_on_the_cap_s_node_wins(cardputer: Path) -> None:
|
|
"""A profile on the same node is the owner's word about it, and replaces the shipped one."""
|
|
mine = replace(spiradio.CARDPUTER_ZERO_CAP, use_dio3_tcxo=False)
|
|
profiles = {"cap": DeviceProfile(name="cap", transport="spi", spi=mine)}
|
|
radios = spiradio.spi_radios(profiles)
|
|
assert [(d.name, d.spi) for d in radios] == [("cap", mine)]
|
|
|
|
|
|
def test_the_cap_is_what_spi_and_a_remembered_node_reach(contexts, cardputer: Path) -> None: # noqa: ANN001
|
|
"""``--spi``, and a radio remembered by its node, both resolve to the Cap's wiring."""
|
|
assert contexts(spi_override=True).resolve_spi() == spiradio.CARDPUTER_ZERO_CAP
|
|
assert contexts().spi_wiring_for("/dev/spidev0.1") == spiradio.CARDPUTER_ZERO_CAP
|
|
|
|
|
|
def test_the_cardputer_cap_wiring_is_the_one_the_hardware_answered_on() -> None:
|
|
"""The lines M5's factory test drives, on the node the panel shares, power from the LED."""
|
|
cap = spiradio.CARDPUTER_ZERO_CAP
|
|
assert cap.spidev == "/dev/spidev0.1"
|
|
assert (cap.reset_pin, cap.busy_pin, cap.irq_pin) == (26, 22, 23)
|
|
assert cap.leds == ("ext_5v_out=1", "ext_usb_gpio_fun=0")
|
|
assert (cap.pi4io_bus, cap.pi4io_address, cap.pi4io_high) == (1, 0x43, (0,))
|
|
assert cap.use_dio2_rf and cap.use_dio3_tcxo
|
|
# Its GPS, read at 115200 on the header's UART while the Cap was powered.
|
|
assert (cap.gps_port, cap.gps_baud) == ("/dev/serial0", 115200)
|
|
|
|
|
|
def test_a_board_s_gps_comes_from_the_table() -> None:
|
|
"""A GPS beside the radio is wiring too: a port and the speed it talks at."""
|
|
wiring = SpiWiring.from_toml({"gps_port": "/dev/ttyAMA0", "gps_baud": 38400})
|
|
assert (wiring.gps_port, wiring.gps_baud) == ("/dev/ttyAMA0", 38400)
|
|
assert (SpiWiring().gps_port, SpiWiring().gps_baud) == ("", 9600) # none, NMEA's speed
|
|
with pytest.raises(ValueError, match="gps_baud = 'fast' is not a int"):
|
|
SpiWiring.from_toml({"gps_baud": "fast"})
|
|
|
|
|
|
@pytest.fixture()
|
|
def serial0(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""``/dev/serial0`` as Raspberry Pi OS makes it: a link to the UART the scan reports."""
|
|
links = {"/dev/serial0": "/dev/ttyS0"}
|
|
monkeypatch.setattr(spiradio.os.path, "realpath", lambda p: links.get(p, p))
|
|
|
|
|
|
#: The Cap's GPS as pyserial reports it: a header UART with no USB identity, so "n/a".
|
|
_GPS_PORT = DiscoveredDevice(port="/dev/ttyS0", description="n/a", hwid="n/a")
|
|
_USB_BOARD = DiscoveredDevice(port="/dev/ttyACM0", description="T1000-E", vid=0x239A)
|
|
|
|
|
|
def test_the_cap_s_gps_is_part_of_the_radio_not_a_companion(cardputer: Path, serial0) -> None: # noqa: ANN001
|
|
"""The port the Cap's GPS answers on is left out wherever the Cap is listed."""
|
|
assert spiradio.without_radio_ports([_GPS_PORT, _USB_BOARD], {}) == [_USB_BOARD]
|
|
|
|
|
|
def test_the_same_port_on_another_pi_is_left_listed(cardputer: Path, serial0) -> None: # noqa: ANN001
|
|
"""No Cardputer, no Cap: whatever answers on that UART is somebody else's to choose."""
|
|
cardputer.rmdir()
|
|
assert spiradio.without_radio_ports([_GPS_PORT, _USB_BOARD], {}) == [_GPS_PORT, _USB_BOARD]
|
|
|
|
|
|
def test_a_profile_s_gps_port_is_its_radio_s(cardputer: Path, serial0) -> None: # noqa: ANN001
|
|
"""A profile naming its own GPS claims that port, and the shipped Cap's is not assumed."""
|
|
mine = replace(spiradio.CARDPUTER_ZERO_CAP, gps_port="/dev/ttyACM0")
|
|
profiles = {"cap": DeviceProfile(name="cap", transport="spi", spi=mine)}
|
|
assert spiradio.without_radio_ports([_GPS_PORT, _USB_BOARD], profiles) == [_GPS_PORT]
|
|
|
|
|
|
# --- 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(
|
|
("labels", "chip"),
|
|
[
|
|
({0: "pinctrl-bcm2711", 1: "raspberrypi-exp-gpio"}, 0), # a CM4
|
|
({0: "gpio-brcmstb@107d508500", 4: "pinctrl-rp1"}, 4), # a CM5 on its first kernels
|
|
({0: "pinctrl-rp1", 10: "gpio-brcmstb@107d508500"}, 0), # a CM5 after the alias
|
|
({0: "gpio-brcmstb@107d508500", 15: "pinctrl-rp1"}, 15), # a CM5 whose chip moved
|
|
({0: "gpio-mockup-A"}, None), # no Raspberry Pi header at all
|
|
],
|
|
)
|
|
def test_the_header_s_gpio_chip_is_found_by_its_label(labels: dict, chip: int | None) -> None:
|
|
"""The header's controller is known by its label, whatever number this kernel gave it.
|
|
|
|
A CM4 and a CM5 put their 40-pin header on chips numbered differently, and on the CM5
|
|
the number has moved between kernel releases, so the AIO's defaults find it by name.
|
|
"""
|
|
assert spiradio.header_gpio_chip(labels) == chip
|
|
|
|
|
|
def test_a_profile_s_gpio_chip_pins_it_and_the_default_looks(monkeypatch) -> None:
|
|
"""A number in the profile wins; left out, the label decides, and chip 0 is the fallback."""
|
|
monkeypatch.setattr(spiradio, "gpio_chip_labels", lambda: {15: "pinctrl-rp1"})
|
|
assert SpiWiring().gpio_chip == -1
|
|
assert spiradio.gpio_chip(SpiWiring()) == 15
|
|
assert spiradio.gpio_chip(SpiWiring(gpio_chip=4)) == 4
|
|
assert "sudo lsof /dev/gpiochip15" in spiradio.explain("busy", "", SpiWiring())
|
|
monkeypatch.setattr(spiradio, "gpio_chip_labels", lambda: {})
|
|
assert spiradio.gpio_chip(SpiWiring()) == 0
|
|
|
|
|
|
def test_reading_the_chips_labels_never_fails(tmp_path: Path) -> None:
|
|
"""Off Linux, or where a chip can't be asked, there is simply nothing to report."""
|
|
(tmp_path / "gpiochip0").write_bytes(b"") # a plain file: the ioctl can't answer it
|
|
assert spiradio.gpio_chip_labels(tmp_path) == {}
|
|
|
|
|
|
@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)
|
|
]
|