Files
MeshTerm/tests/test_radionode_live.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

199 lines
6.6 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""The SPI radio's node, run for real: the radio library with a fake radio underneath.
MeshTerm's own companion client talks to it over the loopback port exactly as it would on a
uConsole. This is the only way to prove what the node exists for — that a channel written
through MeshTerm is still there after the node restarts, which is the bug the node's state
directory ended. Skipped where ``openhop_core`` isn't installed (it is in the dev extra).
"""
from __future__ import annotations
import asyncio
import json
from pathlib import Path
import pytest
from meshterm.core import radionode
openhop_core = pytest.importorskip("openhop_core")
class _FakeRadio:
"""Just enough of an SX1262 for the node to come up and sit listening."""
instances: list[_FakeRadio] = []
def __init__(self, **kwargs: object) -> None:
self.kwargs = kwargs
self.preamble_length = kwargs.get("preamble_length", 12)
self.cleaned = False
_FakeRadio.instances.append(self)
def begin(self) -> bool:
return True
def cleanup(self) -> None:
self.cleaned = True
async def send(self, data: bytes) -> None:
return None
async def wait_for_rx(self) -> bytes:
await asyncio.Event().wait()
return b""
def set_rx_callback(self, callback: object) -> None:
return None
def configure_radio(self, **kwargs: object) -> bool:
self.kwargs.update(kwargs)
return True
def set_tx_power(self, power: int) -> bool:
self.kwargs["tx_power"] = power
return True
def sleep(self) -> None:
return None
def get_last_rssi(self) -> int:
return -90
def get_last_snr(self) -> float:
return 5.0
def _fake_init( # noqa: ANN202
self, # noqa: ANN001
bus_id=1, # noqa: ANN001
reset_pin=25, # noqa: ANN001
frequency=0, # noqa: ANN001
tx_power=0, # noqa: ANN001
preamble_length=12, # noqa: ANN001 - the library's own default, which must never survive
**kwargs, # noqa: ANN003
):
_FakeRadio.__init__(
self,
bus_id=bus_id,
reset_pin=reset_pin,
frequency=frequency,
tx_power=tx_power,
preamble_length=preamble_length,
**kwargs,
)
class _Node:
"""One run of the node in this process, stopped on request instead of by stdin."""
def __init__(self, monkeypatch: pytest.MonkeyPatch, state: Path) -> None:
import openhop_core.hardware.sx1262_wrapper as sx1262
fake = type("SX1262Radio", (_FakeRadio,), {"__init__": _fake_init})
monkeypatch.setattr(sx1262, "SX1262Radio", fake)
monkeypatch.setattr(radionode, "_check_device", lambda *a: None)
self.stop = asyncio.Event()
async def until_stopped() -> None:
await self.stop.wait()
monkeypatch.setattr(radionode, "_until_told_to_stop", until_stopped)
self.state = state
self.status: dict = {}
self.task: asyncio.Task | None = None
async def __aenter__(self) -> int:
ready = asyncio.Event()
def report(message: dict) -> None:
self.status = message
ready.set()
config = {"state_dir": str(self.state), "wiring": {"bus_id": 1}, "seed": {}}
self.task = asyncio.create_task(radionode.run_node(config, report))
waiter = asyncio.create_task(ready.wait())
await asyncio.wait({self.task, waiter}, timeout=20, return_when=asyncio.FIRST_COMPLETED)
if self.task.done():
self.task.result() # surface the node's own failure
assert self.status.get("event") == "ready", self.status
return int(self.status["port"])
async def __aexit__(self, *exc: object) -> None:
self.stop.set()
assert self.task is not None
assert await asyncio.wait_for(self.task, timeout=20) == 0
async def _client(port: int): # noqa: ANN202
from meshterm.core.connection import MeshCoreDevice
device = MeshCoreDevice(transport="tcp", host="127.0.0.1", tcp_port=port)
await device.connect()
return device
@pytest.mark.asyncio
async def test_channels_name_and_radio_survive_a_restart(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The bug this node was built to end: a channel added through MeshTerm is forgotten."""
secret = bytes(range(16))
async with _Node(monkeypatch, tmp_path) as port:
device = await _client(port)
try:
await device.set_channel(2, "Lakeside ops", secret)
await device.set_name("Lakeside")
await device.set_radio(869.525, 250.0, 11, 5)
finally:
await device.disconnect()
assert _FakeRadio.instances[-1].cleaned, "the radio must be released on the way out"
async with _Node(monkeypatch, tmp_path) as port:
device = await _client(port)
try:
channel = await device.get_channel(2)
info = await device.get_self_info()
finally:
await device.disconnect()
assert channel is not None and channel.get("channel_name") == "Lakeside ops"
assert bytes(channel.get("channel_secret") or b"")[:16] == secret
assert info.get("name") == "Lakeside"
# The chip is brought up on the saved radio, not the seed — and at SF11 with the
# preamble MeshCore sends there.
assert _FakeRadio.instances[-1].kwargs["frequency"] == 869_525_000
assert _FakeRadio.instances[-1].preamble_length == 16
@pytest.mark.asyncio
async def test_a_cleared_channel_stays_cleared(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Clearing a slot through MeshTerm removes it from what the node restores."""
async with _Node(monkeypatch, tmp_path) as port:
device = await _client(port)
try:
await device.set_channel(1, "#gone", bytes(16))
await device.set_channel(1, "", None)
finally:
await device.disconnect()
saved = json.loads((tmp_path / "channels.json").read_text(encoding="utf-8"))
assert [c["idx"] for c in saved] == []
@pytest.mark.asyncio
async def test_the_identity_is_the_same_node_after_a_restart(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The node comes back with the key it had, so it is still the same contact to everyone."""
keys = []
for _ in range(2):
async with _Node(monkeypatch, tmp_path) as port:
device = await _client(port)
try:
keys.append((await device.get_self_info()).get("public_key"))
finally:
await device.disconnect()
assert keys[0] and keys[0] == keys[1]