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>
199 lines
6.5 KiB
Python
199 lines
6.5 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
"""The node of the SPI radio, run for real: the radio library with a fake radio under it.
|
|
|
|
The companion client of MeshTerm talks to the node through the loopback port, as it does on
|
|
a uConsole. This is the only way to prove the purpose of the node: a channel that MeshTerm
|
|
writes is still there after the node restarts. The state directory of the node ended this
|
|
bug. The tests are skipped where ``openhop_core`` is not 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:
|
|
"""An SX1262 with only the parts that the node needs to start and listen."""
|
|
|
|
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 default of the library, which must never stay
|
|
**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. A request stops it, not 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() # show the failure of the node
|
|
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 that this node ends: the node forgets a channel that MeshTerm added."""
|
|
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 starts with the saved radio, not the seed. It uses SF11 and the preamble that
|
|
# MeshCore sends at that SF.
|
|
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:
|
|
"""If MeshTerm clears a slot, the node does not restore it."""
|
|
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 returns with the key that it had, so it is the same contact for 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]
|