Apache-2.0 does not require per-file headers, but a file lifted out of the tree carried no marking at all. One comment line, first (or right after a shebang), on every .py under meshterm/, tests/, packaging/ and scripts/, plus the extensionless SPI bridge; tests/test_spdx.py keeps a new file from landing without one. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
388 lines
14 KiB
Python
388 lines
14 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
"""Tests for settings that share one device command, and for the PIN's readback.
|
|
|
|
Both were found restoring a reflashed companion from its own backup.
|
|
|
|
Latitude and longitude go to the firmware together, and so do the four radio parameters,
|
|
so changing one means re-sending its siblings unchanged. Those siblings come from the
|
|
caller's snapshot — read once, before the first write. A restore that differed in both
|
|
coordinates therefore applied ``adv_lat`` (preserving the stale longitude), then
|
|
``adv_lon`` (preserving the stale *latitude*), and the second write silently undid the
|
|
first. On the real device that left latitude at 0.0 and the frequency on the factory
|
|
default while every op reported success.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
|
|
import pytest
|
|
|
|
from meshterm.core.device_config import (
|
|
DEVICE_SETTINGS,
|
|
DeviceConfigError,
|
|
build_snapshot,
|
|
get_spec,
|
|
parse_value,
|
|
)
|
|
|
|
|
|
class _RecordingDevice:
|
|
"""Captures the coupled commands, and models the firmware: last write wins."""
|
|
|
|
def __init__(self, **state) -> None:
|
|
self.state = dict(state)
|
|
self.radio_calls: list[tuple] = []
|
|
self.coord_calls: list[tuple] = []
|
|
self.tuning_calls: list[tuple] = []
|
|
self.repeat_calls: list = []
|
|
|
|
async def set_radio(self, freq, bw, sf, cr, repeat=None) -> None:
|
|
self.radio_calls.append((freq, bw, sf, cr))
|
|
self.repeat_calls.append(repeat)
|
|
self.state.update(radio_freq=freq, radio_bw=bw, radio_sf=sf, radio_cr=cr)
|
|
if repeat is not None:
|
|
self.state["repeat"] = repeat
|
|
|
|
async def set_coords(self, lat, lon) -> None:
|
|
self.coord_calls.append((lat, lon))
|
|
self.state.update(adv_lat=lat, adv_lon=lon)
|
|
|
|
async def set_tuning(self, rx_delay, airtime_factor) -> None:
|
|
self.tuning_calls.append((rx_delay, airtime_factor))
|
|
self.state.update(rx_delay=rx_delay, airtime_factor=airtime_factor)
|
|
|
|
|
|
async def _apply_all(device, snapshot, changes):
|
|
"""Apply each change the way a restore or a staged-edit batch does: in sequence."""
|
|
for key, raw in changes:
|
|
spec = get_spec(key)
|
|
await spec.apply(device, parse_value(spec, raw, snapshot), snapshot)
|
|
|
|
|
|
def test_both_coordinates_survive_being_applied_together() -> None:
|
|
"""THE bug: the second coordinate must not re-send the first one's stale value.
|
|
|
|
Restoring a backup onto a factory-fresh node changes both at once. Before the fix the
|
|
device ended at ``(0.0, -73.708724)`` — longitude right, latitude silently reverted.
|
|
"""
|
|
device = _RecordingDevice(adv_lat=0.0, adv_lon=0.0)
|
|
snapshot = dict(device.state)
|
|
|
|
asyncio.run(
|
|
_apply_all(
|
|
device,
|
|
snapshot,
|
|
[
|
|
("adv_lat", "45.535445"),
|
|
("adv_lon", "-73.708724"),
|
|
],
|
|
)
|
|
)
|
|
|
|
assert device.state["adv_lat"] == pytest.approx(45.535445)
|
|
assert device.state["adv_lon"] == pytest.approx(-73.708724)
|
|
# The last write must carry both, not one real value and one stale zero.
|
|
assert device.coord_calls[-1] == pytest.approx((45.535445, -73.708724))
|
|
|
|
|
|
def test_the_order_the_coordinates_are_applied_in_does_not_matter() -> None:
|
|
"""Neither ordering may lose a value — a plan's op order is not something to rely on."""
|
|
for changes in (
|
|
[("adv_lat", "45.5"), ("adv_lon", "-73.7")],
|
|
[("adv_lon", "-73.7"), ("adv_lat", "45.5")],
|
|
):
|
|
device = _RecordingDevice(adv_lat=0.0, adv_lon=0.0)
|
|
asyncio.run(_apply_all(device, dict(device.state), changes))
|
|
assert (device.state["adv_lat"], device.state["adv_lon"]) == pytest.approx((45.5, -73.7)), (
|
|
changes
|
|
)
|
|
|
|
|
|
def test_two_radio_fields_applied_together_both_stick() -> None:
|
|
"""The frequency reverted to the factory default when the spreading factor followed it."""
|
|
device = _RecordingDevice(radio_freq=869.618, radio_bw=62.5, radio_sf=8, radio_cr=5)
|
|
snapshot = dict(device.state)
|
|
|
|
asyncio.run(
|
|
_apply_all(
|
|
device,
|
|
snapshot,
|
|
[
|
|
("radio_freq", "910.525"),
|
|
("radio_sf", "7"),
|
|
],
|
|
)
|
|
)
|
|
|
|
assert device.state["radio_freq"] == pytest.approx(910.525)
|
|
assert device.state["radio_sf"] == 7
|
|
assert device.state["radio_bw"] == pytest.approx(62.5) # untouched siblings preserved
|
|
assert device.state["radio_cr"] == 5
|
|
|
|
|
|
def test_all_four_radio_fields_at_once() -> None:
|
|
"""A full radio change is the worst case: three chances for a sibling to go stale."""
|
|
device = _RecordingDevice(radio_freq=869.618, radio_bw=250.0, radio_sf=8, radio_cr=8)
|
|
|
|
asyncio.run(
|
|
_apply_all(
|
|
device,
|
|
dict(device.state),
|
|
[
|
|
("radio_freq", "910.525"),
|
|
("radio_bw", "62.5"),
|
|
("radio_sf", "7"),
|
|
("radio_cr", "5"),
|
|
],
|
|
)
|
|
)
|
|
|
|
assert device.radio_calls[-1] == pytest.approx((910.525, 62.5, 7, 5))
|
|
|
|
|
|
def test_both_tuning_fields_applied_together_both_stick() -> None:
|
|
"""rx_delay and airtime_factor share a command on the same terms."""
|
|
device = _RecordingDevice(rx_delay=0.0, airtime_factor=0.0)
|
|
|
|
asyncio.run(
|
|
_apply_all(
|
|
device,
|
|
dict(device.state),
|
|
[
|
|
("rx_delay", "1.5"),
|
|
("airtime_factor", "2.0"),
|
|
],
|
|
)
|
|
)
|
|
|
|
assert device.tuning_calls[-1] == pytest.approx((1.5, 2.0))
|
|
|
|
|
|
def test_a_coupled_apply_leaves_the_snapshot_describing_the_device() -> None:
|
|
"""The write-back is the mechanism, so state it: the snapshot tracks what was set."""
|
|
device = _RecordingDevice(adv_lat=0.0, adv_lon=0.0)
|
|
snapshot = dict(device.state)
|
|
|
|
asyncio.run(_apply_all(device, snapshot, [("adv_lat", "45.5")]))
|
|
|
|
assert snapshot["adv_lat"] == pytest.approx(45.5)
|
|
|
|
|
|
# --- the Device PIN's readback ----------------------------------------------------
|
|
|
|
|
|
class _InfoDevice:
|
|
"""A device whose two info frames carry different keys, as real firmware's do."""
|
|
|
|
def __init__(self, self_info: dict, device_info: dict) -> None:
|
|
self._self_info = self_info
|
|
self._device_info = device_info
|
|
|
|
async def get_self_info(self) -> dict:
|
|
return dict(self._self_info)
|
|
|
|
async def get_device_info(self) -> dict:
|
|
return dict(self._device_info)
|
|
|
|
async def get_tuning(self):
|
|
raise NotImplementedError
|
|
|
|
async def get_path_hash_mode(self):
|
|
raise NotImplementedError
|
|
|
|
async def get_autoadd_config(self):
|
|
raise NotImplementedError
|
|
|
|
async def get_default_flood_scope(self):
|
|
raise NotImplementedError
|
|
|
|
|
|
def test_the_snapshot_folds_in_the_device_query_frame() -> None:
|
|
"""``ble_pin`` lives only in the device-query payload, so the snapshot must read it."""
|
|
device = _InfoDevice({"name": "Homestead"}, {"ble_pin": 701307, "model": "T1000-E"})
|
|
|
|
snapshot = asyncio.run(build_snapshot(device))
|
|
|
|
assert snapshot["ble_pin"] == 701307
|
|
assert snapshot["model"] == "T1000-E"
|
|
assert snapshot["name"] == "Homestead"
|
|
|
|
|
|
def test_self_info_wins_where_the_two_frames_overlap() -> None:
|
|
"""SELF_INFO is the authority on anything it reports; device-query only fills gaps."""
|
|
device = _InfoDevice({"name": "from-self-info"}, {"name": "from-device-query"})
|
|
|
|
assert asyncio.run(build_snapshot(device))["name"] == "from-self-info"
|
|
|
|
|
|
def test_the_device_pin_row_reads_the_firmware_s_own_name() -> None:
|
|
"""It rendered "?" on every real companion: written as device_pin, reported as ble_pin."""
|
|
spec = get_spec("device_pin")
|
|
|
|
assert spec.getter({"ble_pin": 701307}) == 701307
|
|
assert spec.getter({"device_pin": 424242}) == 424242 # canonical key still honoured
|
|
assert spec.getter({}) is None # genuinely unknown stays unknown
|
|
|
|
|
|
def test_a_snapshot_read_failure_still_yields_the_rest() -> None:
|
|
"""The device-query frame is optional: older firmware simply contributes nothing."""
|
|
|
|
class _NoQuery(_InfoDevice):
|
|
async def get_device_info(self):
|
|
raise RuntimeError("firmware predates the device query")
|
|
|
|
snapshot = asyncio.run(build_snapshot(_NoQuery({"name": "Homestead"}, {})))
|
|
assert snapshot["name"] == "Homestead"
|
|
assert get_spec("device_pin").getter(snapshot) is None
|
|
|
|
|
|
def test_every_coupled_setting_is_covered_here() -> None:
|
|
"""A new setting sharing a coupled command must be added to these tests, not forgotten."""
|
|
coupled = {
|
|
"adv_lat",
|
|
"adv_lon",
|
|
"radio_freq",
|
|
"radio_bw",
|
|
"radio_sf",
|
|
"radio_cr",
|
|
"rx_delay",
|
|
"airtime_factor",
|
|
"client_repeat",
|
|
"autoadd_max_hops",
|
|
}
|
|
known = {spec.key for spec in DEVICE_SETTINGS}
|
|
assert coupled <= known, coupled - known
|
|
|
|
|
|
# -- client repeat: the byte on the end of the radio command ------------------------------
|
|
|
|
|
|
def test_retuning_the_radio_restates_client_repeat() -> None:
|
|
"""THE trap: firmware reads a radio command without its repeat byte as "stop relaying".
|
|
|
|
So a companion relaying for its neighbours stopped the moment anyone touched its
|
|
spreading factor, and nothing said so.
|
|
"""
|
|
device = _RecordingDevice(
|
|
radio_freq=869.495, radio_bw=62.5, radio_sf=8, radio_cr=5, repeat=True
|
|
)
|
|
snapshot = dict(device.state)
|
|
asyncio.run(_apply_all(device, snapshot, [("radio_sf", "9")]))
|
|
assert device.repeat_calls == [True]
|
|
|
|
|
|
def test_firmware_without_client_repeat_is_sent_no_repeat_byte() -> None:
|
|
"""Firmware that never reported the setting predates the byte, so none is sent."""
|
|
device = _RecordingDevice(radio_freq=869.525, radio_bw=250.0, radio_sf=11, radio_cr=5)
|
|
snapshot = dict(device.state)
|
|
asyncio.run(_apply_all(device, snapshot, [("radio_sf", "10")]))
|
|
assert device.repeat_calls == [None]
|
|
|
|
|
|
def test_the_simulator_models_the_trap_the_restating_avoids() -> None:
|
|
"""A bare radio command turns the simulator's relaying off; the registry's keeps it on."""
|
|
from meshterm.core.connection import MockDevice
|
|
|
|
async def scenario() -> tuple[bool, bool]:
|
|
device = MockDevice()
|
|
await device.connect()
|
|
await device.set_radio(869.495, 62.5, 8, 5, repeat=True)
|
|
await device.set_radio(869.495, 62.5, 9, 5) # the byte left off
|
|
dropped = (await device.get_device_info())["repeat"]
|
|
await device.set_radio(869.495, 62.5, 8, 5, repeat=True)
|
|
snapshot = await build_snapshot(device)
|
|
spec = get_spec("radio_sf")
|
|
await spec.apply(device, parse_value(spec, "9", snapshot), snapshot)
|
|
return dropped, (await device.get_device_info())["repeat"]
|
|
|
|
dropped, kept = asyncio.run(scenario())
|
|
assert dropped is False
|
|
assert kept is True
|
|
|
|
|
|
def test_client_repeat_restates_the_radio_it_rides_with() -> None:
|
|
"""Switching relaying resends the four radio fields unchanged beside it."""
|
|
device = _RecordingDevice(
|
|
radio_freq=869.495,
|
|
radio_bw=62.5,
|
|
radio_sf=8,
|
|
radio_cr=5,
|
|
repeat=False,
|
|
repeat_freqs=[(869.495, 869.495)],
|
|
)
|
|
snapshot = dict(device.state)
|
|
asyncio.run(_apply_all(device, snapshot, [("client_repeat", "on")]))
|
|
assert device.radio_calls == [(869.495, 62.5, 8, 5)]
|
|
assert device.repeat_calls == [True]
|
|
|
|
|
|
def test_client_repeat_is_refused_off_an_allowed_frequency() -> None:
|
|
"""The firmware relays only on its allowed frequencies; turning it off is always fine."""
|
|
snapshot = {"radio_freq": 869.618, "repeat_freqs": [(433.0, 433.0), (869.495, 869.495)]}
|
|
with pytest.raises(DeviceConfigError, match="relays only on 433, 869.495 MHz"):
|
|
parse_value(get_spec("client_repeat"), "on", snapshot)
|
|
assert parse_value(get_spec("client_repeat"), "off", snapshot) is False
|
|
|
|
|
|
# -- the auto-add hop limit: the byte after the bitmask -----------------------------------
|
|
|
|
|
|
def test_the_auto_add_hop_limit_restates_the_bitmask() -> None:
|
|
"""The limit travels behind the bitmask, so setting it resends the bitmask unchanged."""
|
|
|
|
class _AutoAdd:
|
|
def __init__(self) -> None:
|
|
self.calls: list[tuple] = []
|
|
|
|
async def set_autoadd_config(self, flags, max_hops=None) -> None:
|
|
self.calls.append((flags, max_hops))
|
|
|
|
device = _AutoAdd()
|
|
asyncio.run(_apply_all(device, {"autoadd_config": 0x1E}, [("autoadd_max_hops", "3")]))
|
|
assert device.calls == [(0x1E, 3)]
|
|
|
|
|
|
def test_the_hop_limit_is_read_from_the_frame_the_library_half_parses() -> None:
|
|
"""The library keeps the reply's first byte; the second is taken off the raw frame."""
|
|
from types import SimpleNamespace
|
|
|
|
from meshterm.core.connection import MeshCoreDevice
|
|
|
|
class _Reader:
|
|
async def handle_rx(self, data) -> None:
|
|
self.last = bytes(data)
|
|
|
|
reader = _Reader()
|
|
|
|
class _Commands:
|
|
async def get_autoadd_config(self):
|
|
# What a transport does with a frame: hand it to the reader, by attribute.
|
|
await reader.handle_rx(bytearray([25, 0x1E, 3]))
|
|
return SimpleNamespace(payload={"config": 0x1E}, is_error=lambda: False)
|
|
|
|
device = object.__new__(MeshCoreDevice)
|
|
device._mc = SimpleNamespace(_reader=reader, commands=_Commands())
|
|
|
|
async def read() -> tuple:
|
|
return await device.get_autoadd_config(), await device.get_autoadd_max_hops()
|
|
|
|
assert asyncio.run(read()) == (0x1E, 3)
|
|
assert reader.last == bytes([25, 0x1E, 3]) # the library still saw its frame
|
|
assert "handle_rx" not in vars(reader) # and the tap came off again
|
|
|
|
|
|
def test_a_negative_tx_power_reads_back_signed() -> None:
|
|
"""The firmware takes -9 dBm and reports a signed byte the library reads unsigned."""
|
|
from types import SimpleNamespace
|
|
|
|
from meshterm.core.connection import MeshCoreDevice
|
|
|
|
class _Commands:
|
|
async def send_appstart(self):
|
|
return SimpleNamespace(payload={"tx_power": 247})
|
|
|
|
device = object.__new__(MeshCoreDevice)
|
|
device._mc = SimpleNamespace(commands=_Commands())
|
|
assert asyncio.run(device.get_self_info())["tx_power"] == -9
|