Each test docstring states the behaviour that the test proves, in the simple present. No assertion message, test id, or other string literal changed. Four statements now agree with the code: the Trace highlight stays on the last row, a menu count, and two others. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
475 lines
19 KiB
Python
475 lines
19 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
"""The software node MeshTerm runs on an SPI radio (``meshterm/core/radionode.py``).
|
|
|
|
The tests are of two types. The first type tests the contract of the node with its parent
|
|
and with the interpreter that it runs under. The node imports nothing from MeshTerm,
|
|
reports one status line, and reads its state files with defensive checks. The second type
|
|
runs the node for real, on the actual radio library with a fake radio below it. These
|
|
tests talk to the node through the companion client of MeshTerm. This is the only way to
|
|
prove the purpose of this file: a channel that MeshTerm writes 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)}"
|
|
# The node imports the radio library only by name, at run time. If it imported the
|
|
# library at the top, a missing library gives an ImportError traceback and not 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 gives the reason for a pin failure only in a log line, so the node reads it."""
|
|
assert radionode.classify_begin_failure(lines) == kind
|
|
|
|
|
|
@dataclasses.dataclass
|
|
class _Prefs:
|
|
"""A stand-in for the ``NodePrefs`` of the library, with one field of each type."""
|
|
|
|
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:
|
|
"""Each field type of ``NodePrefs`` 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 wrong value from a hand edit 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 gives no change
|
|
assert prefs.node_name == "Ridge"
|
|
|
|
|
|
def test_saved_channels_skip_what_cannot_be_a_channel() -> None:
|
|
"""The node skips a malformed or cleared entry in ``channels.json`` and never restores it."""
|
|
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 that lacks a parameter 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>`` that reads ``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 power and the pin routing of the Cap go on for the node. After it, they go back."""
|
|
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 that the wiring is for another board. The node reports it."""
|
|
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:
|
|
"""If the Cap is not there, the header has no power again. It is not left 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:
|
|
"""A missing ``/dev/i2c-N`` is its own kind, and the parent knows the correction for it."""
|
|
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 wiring of the AIO has neither, and its node starts the same 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:
|
|
"""The node makes its key the first time, then reads it back each 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 SF8. The receiver must expect this."""
|
|
assert radionode.preamble_for_sf(sf) == symbols
|
|
|
|
|
|
def test_the_radio_is_built_with_the_meshcore_preamble() -> None:
|
|
"""The chip starts with the preamble that the mesh sends, never the 12 of the library."""
|
|
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 that ``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 MeshTerm must send it a new preamble."""
|
|
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 keeps its listen state
|
|
assert chip.calls == []
|
|
|
|
|
|
# --- the GPS beside the chip ---------------------------------------------------------------
|
|
|
|
|
|
def _sentence(body: str) -> str:
|
|
"""``body`` as a whole NMEA sentence, with a checksum that the function computes."""
|
|
check = 0
|
|
for byte in body.encode("ascii"):
|
|
check ^= byte
|
|
return f"${body}*{check:02X}"
|
|
|
|
|
|
#: The exact bytes that the receiver of the Cap sent on the Cardputer before it had a fix.
|
|
_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 sign of the west longitude is the important check.
|
|
_MONTREAL = _sentence("GNRMC,010203.00,A,4532.1274,N,07342.5219,W,0.1,,051026,,,A,V")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("line", "fix"),
|
|
[
|
|
# The textbook sentences of NMEA, with the published checksums.
|
|
(
|
|
"$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 are 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: the checksum fails
|
|
_MONTREAL[:-2], # cut off in the 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 each other sentence leave the position unchanged."""
|
|
assert radionode.nmea_fix(line) is None
|
|
|
|
|
|
def _gps_node(lat: float = 0.0, lon: float = 0.0) -> Any:
|
|
"""A stand-in companion. A fix changes only the two coordinates in its preferences."""
|
|
return SimpleNamespace(prefs=SimpleNamespace(latitude=lat, longitude=lon))
|
|
|
|
|
|
def test_a_fix_becomes_the_node_s_position_once_per_interval() -> None:
|
|
"""The pacing of the firmware: the first fix at once, then one fix for each ``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: the node holds it
|
|
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 default of the firmware and means one second. It does not flood the 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 that a second take shows
|
|
gps.feed(f"{gga}\r\n".encode(), now=10.5) # the other sentence of the same second
|
|
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 where the UART buffer ended, which is seldom 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`` are in ``prefs.json``. ``gps`` shows 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 as off. This is correct.
|
|
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)`` limits the value, and a value that is not a number is refused."""
|
|
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 with 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:
|
|
"""The part of ``NodePrefs`` that a position needs."""
|
|
|
|
node_name: str = "MeshTerm"
|
|
latitude: float = 0.0
|
|
longitude: float = 0.0
|
|
|
|
|
|
class _Library:
|
|
"""The two custom-variable methods of the companion of the library, 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 the firmware puts it, and a change survives 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 is saved
|
|
assert node.set_custom_var("other", "y") is True # not a GPS variable: it is the library's
|
|
assert node.get_custom_vars()["other"] == "y"
|
|
|
|
|
|
def test_a_board_with_no_gps_lists_none(tmp_path: Path) -> None:
|
|
"""The firmware lists ``gps`` only if it found a receiver. The node does the same."""
|
|
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 node opens the port raw at its speed and reads it on the loop, like the Cap UART."""
|
|
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"
|