Files
MeshTerm/tests/test_radionode.py
T
Jean-Pierre MartineauandClaude Opus 5.5 49d3eff212 The Cap's GPS moves the node, and is no longer offered as a companion
The Cardputer Zero's Cap carries a GPS on the header's UART, which the
device screen listed as "~ n/a" (pyserial's name for a port with no USB
identity): a companion to connect to, and with nothing remembered, the
lone port a command-line connect would pick. A wiring now names its
board's GPS (`gps_port`, `gps_baud`; the Cap's is /dev/serial0 at
115200), and wherever that radio is listed, its GPS port is not: the
device screen, `meshterm devices`, and autodetect.

The radio node reads it the way MeshCore companion firmware reads a
board's GPS: `gps` (off until switched on, remembered in prefs.json)
and `gps_interval` (0, every fix, once a second), reported and set as
custom variables, which is where Device config already shows them. A
valid RMC or GGA fix becomes the node's position, the one its
self-info reports and, with Share location on, its adverts carry. The
last fix is saved as the node shuts down. Standard library only, as
the node's contract asks; NMEA checksums are checked.

On MeshTerm's side, our own self-info is now refreshed behind the
answer after a minute, like the contacts, so screens opened after the
node moves show where it is; and the connect-time settings offer no
longer flags a position the running GPS moved.

Checked on the Cardputer: the ttyS0 row is gone from `meshterm
devices`; `config custom gps 1` opens the port, is saved, and reads
back on; the real-terminal test passes there.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 22:02:05 -04:00

473 lines
18 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""The software node MeshTerm runs on an SPI radio (``meshterm/core/radionode.py``).
Two kinds of test. The first holds the node's contract with its parent and with the
interpreter it runs under — it imports nothing from MeshTerm, reports one status line, and
reads its state files back defensively. The second runs the node for real, on the actual
radio library with a fake radio underneath, and talks to it through MeshTerm's own
companion client: that is the only way to prove the thing this file exists for, which is
that a channel written through MeshTerm is still there after the node restarts.
"""
from __future__ import annotations
import ast
import asyncio
import dataclasses
import json
import os
import sys
from pathlib import Path
from types import SimpleNamespace
from typing import Any
import pytest
from meshterm.core import radionode
NODE_SOURCE = Path(radionode.__file__)
# --- the contract --------------------------------------------------------------------------
def test_node_imports_only_the_standard_library_and_the_radio_library() -> None:
"""The node runs under *another* Python, by path, so it can import nothing of ours."""
tree = ast.parse(NODE_SOURCE.read_text(encoding="utf-8"))
names: set[str] = set()
for node in ast.walk(tree):
if isinstance(node, ast.Import):
names |= {alias.name.split(".")[0] for alias in node.names}
elif isinstance(node, ast.ImportFrom):
assert node.level == 0, "a relative import only works inside the package"
names.add((node.module or "").split(".")[0])
foreign = {n for n in names if n not in sys.stdlib_module_names and n != "__future__"}
assert foreign == set(), f"the node imports {sorted(foreign)}"
# And the radio library only by name, at run time — importing it at the top would make
# a missing library an ImportError traceback instead of the "runtime" report.
assert radionode.RUNTIME == "openhop_core"
@pytest.mark.parametrize(
("lines", "kind"),
[
(["GPIO pin 25 is already in use by another process: [Errno 16]"], "busy"),
(["Failed to setup: Device or resource busy"], "busy"),
(["Permission denied for GPIO pin 24: ..."], "permission"),
(["radio.begin() returned False"], "failed"),
([], "failed"),
],
)
def test_a_failed_begin_is_classified_by_what_the_library_logged(
lines: list[str], kind: str
) -> None:
"""The library says why a pin failed only in a log line, so that is what is read."""
assert radionode.classify_begin_failure(lines) == kind
@dataclasses.dataclass
class _Prefs:
"""A stand-in for the library's ``NodePrefs``: one field of each type it has."""
node_name: str = "pyMC"
tx_power_dbm: int = 20
latitude: float = 0.0
default_scope_key: bytes = b""
def test_prefs_round_trip_through_json() -> None:
"""Every field type ``NodePrefs`` has comes back from ``prefs.json`` as it went in."""
prefs = _Prefs(node_name="Lakeside", tx_power_dbm=17, latitude=45.5, default_scope_key=b"\x01")
saved = json.loads(json.dumps(radionode.prefs_to_json(prefs)))
loaded = _Prefs()
radionode.apply_saved_prefs(loaded, saved)
assert loaded == prefs
def test_saved_prefs_take_only_known_fields_of_the_right_type() -> None:
"""A retired field is ignored, and a hand-edited wrong value never reaches the radio."""
prefs = _Prefs()
radionode.apply_saved_prefs(
prefs, {"node_name": "Ridge", "tx_power_dbm": "loud", "retired_knob": 3, "latitude": 1}
)
assert prefs == _Prefs(node_name="Ridge", latitude=1.0)
radionode.apply_saved_prefs(prefs, ["not", "a", "dict"]) # a corrupt file: no change
assert prefs.node_name == "Ridge"
def test_saved_channels_skip_what_cannot_be_a_channel() -> None:
"""A malformed or cleared entry in ``channels.json`` is skipped, never restored."""
entries = [
{"idx": 0, "name": "Public", "secret": "00" * 16},
{"idx": 1, "name": "", "secret": "00" * 16}, # a cleared slot is not a channel
{"idx": "two", "name": "Bad", "secret": "00"},
{"idx": 3, "name": "Hex", "secret": "zz"},
"garbage",
]
assert radionode.saved_channels(entries) == [(0, "Public", bytes(16))]
assert radionode.saved_channels(None) == []
def test_radio_kwargs_pass_only_what_the_constructor_accepts() -> None:
"""An older library missing a knob gets the call without it, not a refusal."""
accepted = {"bus_id", "reset_pin", "en_pins", "frequency", "tx_power"}
wiring = {"bus_id": 1, "reset_pin": 25, "en_pins": None, "gpio_chip": 0}
prefs = type(
"P",
(),
{
"frequency_hz": 869_525_000,
"bandwidth_hz": 250_000,
"spreading_factor": 11,
"coding_rate": 5,
"tx_power_dbm": 14,
},
)()
assert radionode.radio_kwargs(accepted, wiring, prefs) == {
"bus_id": 1,
"reset_pin": 25,
"frequency": 869_525_000,
"tx_power": 14,
}
def _switch(root: Path, name: str, state: str) -> Path:
"""A stand-in for ``/sys/class/leds/<name>``, reading ``state``."""
(root / name).mkdir(parents=True)
path = root / name / "brightness"
path.write_text(state, encoding="ascii")
return path
def test_board_switches_are_set_and_put_back_as_found(tmp_path: Path) -> None:
"""The Cap's power and pin routing go on for the node and back to how they were after."""
power = _switch(tmp_path, "ext_5v_out", "0")
routing = _switch(tmp_path, "ext_usb_gpio_fun", "255")
previous = radionode.set_leds(["ext_5v_out=1", "ext_usb_gpio_fun=0"], root=tmp_path)
assert (power.read_text(), routing.read_text()) == ("1", "0")
radionode.restore_leds(previous)
assert (power.read_text(), routing.read_text()) == ("0", "255")
def test_a_missing_switch_is_a_wiring_for_another_board(tmp_path: Path) -> None:
"""No such switch means the wiring describes a board this isn't — said, not ignored."""
with pytest.raises(radionode.NodeError, match="this board has no ext_5v_out switch"):
radionode.set_leds(["ext_5v_out=1"], root=tmp_path)
def test_a_failed_expander_puts_the_switches_back(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A Cap that isn't there leaves the header unpowered again, not switched on for nothing."""
power = _switch(tmp_path, "ext_5v_out", "0")
monkeypatch.setattr(radionode, "LEDS", tmp_path)
monkeypatch.setattr(radionode, "POWER_SETTLE_S", 0)
def absent(*_args: object) -> None:
raise radionode.NodeError("absent", "nothing answers at 0x43 on /dev/i2c-1")
monkeypatch.setattr(radionode, "drive_pi4io", absent)
wiring = {"leds": ["ext_5v_out=1"], "pi4io_bus": 1, "pi4io_high": [0]}
with pytest.raises(radionode.NodeError) as err:
radionode.prepare_board(wiring)
assert err.value.kind == "absent"
assert power.read_text() == "0"
def test_an_expander_on_a_missing_bus_is_named(tmp_path: Path) -> None:
"""No ``/dev/i2c-N`` is its own kind, with the fix the parent knows how to say."""
with pytest.raises(radionode.NodeError) as err:
radionode.drive_pi4io(1, 0x43, [0], dev=tmp_path)
assert err.value.kind == "no-i2c"
def test_a_board_with_no_switches_prepares_nothing() -> None:
"""The AIO's wiring has neither, and its node starts exactly as it always did."""
assert radionode.prepare_board({"bus_id": 1, "pi4io_bus": -1}) == []
def test_an_identity_is_minted_once_and_then_kept(tmp_path: Path) -> None:
"""A node's key is made the first time and read back every time after."""
minted = iter([b"\x11" * 32, b"\x22" * 32])
first = radionode.load_identity_seed(tmp_path, lambda: next(minted))
again = radionode.load_identity_seed(tmp_path, lambda: next(minted))
assert first == again == b"\x11" * 32
assert (tmp_path / "identity.key").read_bytes() == first
@pytest.mark.parametrize(("sf", "symbols"), [(5, 32), (7, 32), (8, 32), (9, 16), (12, 16)])
def test_the_preamble_follows_meshcore(sf: int, symbols: int) -> None:
"""MeshCore sends 32 symbols up to SF8 and 16 above, and the receiver must expect it."""
assert radionode.preamble_for_sf(sf) == symbols
def test_the_radio_is_built_with_the_meshcore_preamble() -> None:
"""The chip comes up expecting the preamble the mesh sends, never the library's 12."""
prefs = type(
"P",
(),
{
"frequency_hz": 910_525_000,
"bandwidth_hz": 62_500,
"spreading_factor": 7,
"coding_rate": 5,
"tx_power_dbm": 22,
},
)()
kwargs = radionode.radio_kwargs({"preamble_length", "spreading_factor"}, {}, prefs)
assert kwargs == {"preamble_length": 32, "spreading_factor": 7}
class _Chip:
"""The calls ``apply_preamble`` makes on the driver, recorded in order."""
STANDBY_RC, HEADER_EXPLICIT, CRC_ON, IQ_STANDARD, RX_CONTINUOUS = 0, 0, 1, 0, 0xFFFFFF
def __init__(self) -> None:
self.calls: list[tuple] = []
def __getattr__(self, name: str): # noqa: ANN204
return lambda *args: self.calls.append((name, *args))
def test_a_retune_resends_the_preamble_to_the_listening_chip() -> None:
"""Reception keeps its last packet parameters, so a new preamble must be sent to it."""
chip = _Chip()
radio = type("R", (), {"preamble_length": 32, "lora": chip})()
radionode.apply_preamble(radio, 16)
assert radio.preamble_length == 16
assert [c[0] for c in chip.calls] == ["setStandby", "setPacketParamsLoRa", "request"]
assert chip.calls[1][1] == 16
chip.calls.clear()
radionode.apply_preamble(radio, 16) # unchanged: the chip is left listening undisturbed
assert chip.calls == []
# --- the GPS beside the chip ---------------------------------------------------------------
def _sentence(body: str) -> str:
"""``body`` as a whole NMEA sentence, its checksum computed rather than copied."""
check = 0
for byte in body.encode("ascii"):
check ^= byte
return f"${body}*{check:02X}"
#: What the Cap's receiver sent on the Cardputer before it had a fix, byte for byte.
_NO_FIX = [
"$GNRMC,,V,,,,,,,,,,N,V*37",
"$GNGGA,,,,,,0,00,25.5,,,,,,*64",
"$GNGSA,A,1,,,,,,,,,,,,,25.5,25.5,25.5,1*01",
]
#: A fix in Montreal, as an RMC: the west longitude is the sign worth checking.
_MONTREAL = _sentence("GNRMC,010203.00,A,4532.1274,N,07342.5219,W,0.1,,051026,,,A,V")
@pytest.mark.parametrize(
("line", "fix"),
[
# NMEA's own textbook sentences, checksums as published.
(
"$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A",
(48.1173, 11.516667),
),
(
"$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47",
(48.1173, 11.516667),
),
(_MONTREAL, (45.535457, -73.708698)),
(
_sentence("GNGGA,010203.00,3352.0000,S,15112.0000,E,1,09,0.9,40,M,,M,,"),
(-33.866667, 151.2),
),
],
)
def test_a_valid_fix_is_read_from_either_sentence(line: str, fix: tuple) -> None:
"""RMC and GGA both carry a fix, from any talker, south and west negative."""
got = radionode.nmea_fix(line)
assert got is not None and got == pytest.approx(fix, abs=1e-6)
@pytest.mark.parametrize(
"line",
[
*_NO_FIX,
_MONTREAL.replace("4532", "4533"), # a byte changed on the wire: checksum fails
_MONTREAL[:-2], # cut off inside its checksum
_sentence("GNRMC,010203.00,A,4560.0000,N,07342.5219,W,,,051026,,,A,V"), # 60 minutes
_sentence("GNRMC,010203.00,A,4532.1274,X,07342.5219,W,,,051026,,,A,V"), # no hemisphere
_sentence("GNVTG,,,,,,,,,N"),
"",
"garbage",
],
)
def test_anything_but_a_valid_fix_is_none(line: str) -> None:
"""No fix yet, a damaged sentence, and every other sentence leave the position alone."""
assert radionode.nmea_fix(line) is None
def _gps_node(lat: float = 0.0, lon: float = 0.0) -> Any:
"""A stand-in companion: all a fix touches is its preferences' two coordinates."""
return SimpleNamespace(prefs=SimpleNamespace(latitude=lat, longitude=lon))
def test_a_fix_becomes_the_node_s_position_once_per_interval() -> None:
"""The firmware's pacing: the first fix at once, then one per ``gps_interval``."""
gps = radionode.Gps("/dev/serial0", 115200, {"gps_interval": 30})
gps.node = _gps_node()
north = _sentence("GNRMC,010204.00,A,4532.2274,N,07342.5219,W,,,051026,,,A,V")
gps.feed(("\r\n".join([*_NO_FIX, _MONTREAL]) + "\r\n").encode(), now=100.0)
assert (gps.node.prefs.latitude, gps.node.prefs.longitude) == pytest.approx(
(45.535457, -73.708698), abs=1e-6
)
assert gps.moved
gps.feed(f"{north}\r\n".encode(), now=129.0) # inside the interval: held
assert gps.node.prefs.latitude == pytest.approx(45.535457, abs=1e-6)
gps.feed(f"{north}\r\n".encode(), now=130.0)
assert gps.node.prefs.latitude == pytest.approx(45.537123, abs=1e-6)
def test_an_interval_of_zero_takes_every_fix_once_a_second() -> None:
"""``0`` is the firmware's default and its one second, not a flood of writes."""
gps = radionode.Gps("/dev/serial0", 115200)
gps.node = _gps_node()
gga = _sentence("GNGGA,010203.00,4532.1274,N,07342.5219,W,1,09,0.9,40,M,,M,,")
gps.feed(f"{_MONTREAL}\r\n".encode(), now=10.0)
gps.node.prefs.latitude = 0.0 # so a second take would show
gps.feed(f"{gga}\r\n".encode(), now=10.5) # the same second's other sentence
assert gps.node.prefs.latitude == 0.0
gps.feed(f"{gga}\r\n".encode(), now=11.0)
assert gps.node.prefs.latitude == pytest.approx(45.535457, abs=1e-6)
def test_a_sentence_split_across_reads_is_read_whole() -> None:
"""A read ends wherever the UART's buffer did, which is rarely at a newline."""
gps = radionode.Gps("/dev/serial0", 115200)
gps.node = _gps_node()
data = f"{_MONTREAL}\r\n".encode()
gps.feed(data[:20], now=1.0)
assert not gps.moved
gps.feed(data[20:], now=1.0)
assert gps.moved
def test_the_settings_come_and_go_by_the_firmware_s_names() -> None:
"""``gps_enabled`` and ``gps_interval`` in ``prefs.json``; ``gps`` says what *runs*."""
gps = radionode.Gps("/dev/serial0", 115200, {"gps_enabled": 1, "gps_interval": "45"})
assert (gps.enabled, gps.interval_s) == (True, 45)
assert gps.saved() == {"gps_enabled": 1, "gps_interval": 45}
# Switched on but not (yet) open: a port that failed at start reads off, honestly.
assert gps.custom_vars() == {"gps": "0", "gps_interval": "45"}
junk = radionode.Gps("/dev/serial0", 115200, {"gps_enabled": "yes", "gps_interval": None})
assert (junk.enabled, junk.interval_s) == (False, 0)
@pytest.mark.parametrize(
("value", "accepted", "interval"),
[("60", True, 60), ("0", True, 0), ("99999", True, 86400), ("-5", True, 0), ("soon", False, 7)],
)
def test_the_interval_is_bounded_as_the_firmware_bounds_it(
value: str, accepted: bool, interval: int
) -> None:
"""``constrain(…, 0, 86400)``, and a value that isn't a number refused outright."""
gps = radionode.Gps("/dev/serial0", 115200, {"gps_interval": 7})
assert gps.set_var("gps_interval", value) is accepted
assert gps.interval_s == interval
def test_switching_on_a_port_that_will_not_open_is_refused(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The companion answers an error, and the switch stays where it was."""
gps = radionode.Gps("/dev/serial0", 115200)
def refuse() -> None:
raise OSError("[Errno 13] Permission denied: '/dev/serial0'")
monkeypatch.setattr(gps, "start", refuse)
assert gps.set_var("gps", "1") is False
assert gps.enabled is False
assert gps.set_var("gps", "maybe") is False
assert gps.set_var("gps", "0") is True and gps.enabled is False
@dataclasses.dataclass
class _GpsPrefs:
"""Enough of ``NodePrefs`` for a position."""
node_name: str = "MeshTerm"
latitude: float = 0.0
longitude: float = 0.0
class _Library:
"""The two custom-variable methods of the library's companion, and its preferences."""
def __init__(self) -> None:
self.prefs = _GpsPrefs()
self._custom_vars: dict[str, str] = {"other": "x"}
def get_custom_vars(self) -> dict[str, str]:
return dict(self._custom_vars)
def set_custom_var(self, name: str, value: str) -> bool:
self._custom_vars[name] = value
return True
def test_a_gps_board_lists_its_two_variables_and_saves_them(tmp_path: Path) -> None:
"""Device config finds ``gps`` where firmware puts it, and a change outlives a restart."""
gps = radionode.Gps("/dev/serial0", 115200)
node = radionode._persistent_companion(_Library, tmp_path, gps)()
gps.node = node
assert node.get_custom_vars() == {"other": "x", "gps": "0", "gps_interval": "0"}
assert node.set_custom_var("gps_interval", "120") is True
saved = json.loads((tmp_path / "prefs.json").read_text(encoding="utf-8"))
assert saved == {
"node_name": "MeshTerm",
"latitude": 0.0,
"longitude": 0.0,
"gps_enabled": 0,
"gps_interval": 120,
}
assert node.set_custom_var("gps_interval", "soon") is False # refused: nothing saved
assert node.set_custom_var("other", "y") is True # not the GPS's: the library's own
assert node.get_custom_vars()["other"] == "y"
def test_a_board_with_no_gps_lists_none(tmp_path: Path) -> None:
"""Firmware lists ``gps`` only where it found a receiver; so does the node."""
node = radionode._persistent_companion(_Library, tmp_path)()
assert node.get_custom_vars() == {"other": "x"}
node._save_prefs()
assert "gps_enabled" not in json.loads((tmp_path / "prefs.json").read_text(encoding="utf-8"))
@pytest.mark.skipif(not hasattr(os, "openpty"), reason="needs a POSIX pseudo-terminal")
def test_the_receiver_is_read_from_a_real_terminal() -> None:
"""The port is opened raw at its speed and read on the loop, as the Cap's UART is."""
master, slave = os.openpty()
port = os.ttyname(slave)
gps = radionode.Gps(port, 115200)
gps.node = _gps_node()
async def run() -> None:
assert gps.set_var("gps", "1") is True
assert gps.custom_vars()["gps"] == "1"
os.write(master, f"{_NO_FIX[0]}\r\n{_MONTREAL}\r\n".encode())
for _ in range(50):
if gps.moved:
break
await asyncio.sleep(0.02)
gps.set_var("gps", "0")
try:
asyncio.run(run())
finally:
os.close(master)
os.close(slave)
assert gps.node.prefs.latitude == pytest.approx(45.535457, abs=1e-6)
assert not gps.running and gps.custom_vars()["gps"] == "0"