Files
MeshTerm/tests/test_radionode.py
T
Jean-Pierre MartineauandClaude Opus 5.5 dee4674942 The SPI radio listens for the preamble the mesh actually sends
MeshCore transmits a 32-symbol preamble up to SF8 (16 above), but the
node and the bridge configured the SX1262 for the library's fixed 12.
The chip waits for the sync word only about as long as the preamble it
expects, so it locked onto each packet, gave up, and locked on again
further along the same preamble - decoding a packet only when it
happened to catch the tail. On the uConsole that meant 0 of 5 detected
packets decoded after a send; with 32 symbols, 4 of 4.

The node now derives the preamble from the spreading factor as MeshCore
does, and re-sends it to the listening chip when Device config changes
the SF. preamble_length leaves the SPI wiring table (it was protocol,
never wiring) and is retired; the bridge defaults to the same rule.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 03:12:52 -04:00

185 lines
7.1 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 dataclasses
import json
import sys
from pathlib import Path
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 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 == []