Every one of the region presets named a modulation no mesh runs any more, and two of them named a frequency nobody was ever on. MeshTerm's list was three regions on 250 kHz / SF11 — the shape MeshCore shipped with — plus two trade-off rows built on the EU frequency. Through 2025 most regions left that modulation for a narrow 62.5 kHz one, so the EU row is what MeshCore now calls "EU/UK (Deprecated)", the US row had the right frequency with the wrong modem (910.525 is SF7/BW62.5/CR5), and "AU / NZ 915" at 915.525 was not a preset at all: Australia is 915.800 wide, 916.575 narrow, 923.125 in SA/WA and QLD, and New Zealand is 917.375. Four parameters have to match for two nodes to hear each other, so a stale table is a radio staged onto an empty channel. The list is now MeshCore's own suggested settings, mirrored verbatim — names, order and values — from what its apps, the web flasher and config.meshcore.dev all read at api.meshcore.nz/api/v1/config. Twenty-three regions, including the two MeshCore still lists under its own "(Deprecated)" names, because nodes that never re-tuned are on them and picking one is how you go and listen. The names are spelled as MeshCore spells them so the row can be read against the phone app. Four regions also name a path-hash size, which the preset now stages alongside the radio fields as the firmware's mode (size - 1), exactly as MeshCore's own config GUI converts it. The rows moved to the two-lane menu shape — name, then the parameters in MeshCore's print order — which fits 53 columns where the old one-line title ran to 85, and the list opens on the preset the radio is already tuned to, staged values included, so picking a neighbour is a comparison rather than a guess. The trade-off pair is gone with them. They were ours rather than MeshCore's, they pinned a deprecated EU frequency onto whoever picked them, and the wide / mid / narrow choice they were reaching for is one the region list now makes properly per region. The simulator boots on the firmware's build defaults too (869.618, BW 62.5, SF8, CR5 per MeshCore's platformio.ini), rather than on the settings the EU mesh left behind. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
232 lines
9.3 KiB
Python
232 lines
9.3 KiB
Python
"""Tests for remembering device settings across sessions (``core.settings_store``).
|
|
|
|
Covers the durable per-device store, the drift detection that compares remembered values to a
|
|
device's live snapshot, and the ``restore``/``adopt`` reconcile actions the startup offer drives.
|
|
The reconcile tests run against the :class:`MockDevice` simulator, whose configuration resets to
|
|
firmware defaults each construction — standing in for the firmware-less radio bridge that forgets
|
|
its settings on restart.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from rich.console import Console
|
|
|
|
from meshterm.context import AppContext
|
|
from meshterm.core.admin_store import AdminStore
|
|
from meshterm.core.config import Settings
|
|
from meshterm.core.connection import make_device
|
|
from meshterm.core.device_config import build_snapshot
|
|
from meshterm.core.device_store import DeviceStore
|
|
from meshterm.core.settings_store import (
|
|
SettingsStore,
|
|
adopt,
|
|
restore,
|
|
settings_drift,
|
|
)
|
|
from meshterm.persistence.repository import Repository
|
|
from meshterm.tools.config import apply_ops
|
|
|
|
PUB_A = "aa" * 32
|
|
PUB_B = "bb" * 32
|
|
|
|
|
|
@pytest.fixture()
|
|
def ctx(tmp_path: Path) -> AppContext:
|
|
"""A mock-backed application context with the plain (console) UI surface."""
|
|
settings = Settings(config_dir=tmp_path, db_path=tmp_path / "cfg.db")
|
|
context = AppContext(
|
|
console=Console(file=io.StringIO()),
|
|
settings=settings,
|
|
repo=Repository(settings.db_path),
|
|
device_store=DeviceStore(tmp_path / "devices.json"),
|
|
admin_store=AdminStore(tmp_path / "admin.json"),
|
|
mock=True,
|
|
)
|
|
yield context
|
|
context.repo.close()
|
|
|
|
|
|
# --- the store ---------------------------------------------------------------
|
|
|
|
|
|
def test_store_round_trips_and_persists(tmp_path: Path) -> None:
|
|
"""Remembered settings survive a fresh store instance, kept per device."""
|
|
path = tmp_path / "settings.json"
|
|
store = SettingsStore(path)
|
|
store.remember(PUB_A, "name", "Ops-Node")
|
|
store.remember(PUB_A, "radio_freq", 915.0)
|
|
store.remember(PUB_B, "tx_power", 20)
|
|
|
|
reloaded = SettingsStore(path) # a new process reads the same file
|
|
assert reloaded.settings(PUB_A) == {"name": "Ops-Node", "radio_freq": 915.0}
|
|
assert reloaded.settings(PUB_B) == {"tx_power": 20} # a second device keeps its own set
|
|
|
|
|
|
def test_store_replace_forget_and_forget_all(tmp_path: Path) -> None:
|
|
"""Re-remembering a key replaces it; forget drops one; forget_all clears the device."""
|
|
store = SettingsStore(tmp_path / "settings.json")
|
|
store.remember(PUB_A, "name", "First")
|
|
store.remember(PUB_A, "name", "Second") # same key, new value
|
|
store.remember(PUB_A, "tx_power", 20)
|
|
assert store.settings(PUB_A) == {"name": "Second", "tx_power": 20}
|
|
|
|
store.forget(PUB_A, "name")
|
|
assert store.settings(PUB_A) == {"tx_power": 20}
|
|
|
|
store.forget_all(PUB_A)
|
|
assert store.settings(PUB_A) == {}
|
|
# The device drops out of the persisted file entirely once it has nothing remembered.
|
|
assert json.loads((tmp_path / "settings.json").read_text())["devices"] == {}
|
|
|
|
|
|
def test_store_normalises_device_key(tmp_path: Path) -> None:
|
|
"""A device key is matched case-insensitively and with an optional 0x prefix stripped."""
|
|
store = SettingsStore(tmp_path / "settings.json")
|
|
store.remember("AABB", "name", "Ops")
|
|
assert store.settings("0xaabb") == {"name": "Ops"}
|
|
|
|
|
|
def test_store_ignores_corrupt_file(tmp_path: Path) -> None:
|
|
"""A garbage file reads as empty rather than raising, and stays writable."""
|
|
path = tmp_path / "settings.json"
|
|
path.write_text("not json at all", encoding="utf-8")
|
|
store = SettingsStore(path)
|
|
assert store.settings(PUB_A) == {}
|
|
store.remember(PUB_A, "name", "Ops") # recovers and persists
|
|
assert SettingsStore(path).settings(PUB_A) == {"name": "Ops"}
|
|
|
|
|
|
def test_store_drops_malformed_values(tmp_path: Path) -> None:
|
|
"""Non-scalar values (a container, a null) are skipped; scalar ones are kept."""
|
|
path = tmp_path / "settings.json"
|
|
path.write_text(
|
|
json.dumps(
|
|
{
|
|
"devices": {
|
|
PUB_A: {"name": "Good", "bad_list": [1, 2], "bad_null": None, "tx_power": 20}
|
|
}
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
assert SettingsStore(path).settings(PUB_A) == {"name": "Good", "tx_power": 20}
|
|
|
|
|
|
def test_store_ignores_non_scalar_remember(tmp_path: Path) -> None:
|
|
"""Remembering a non-scalar value is a no-op — only strings, numbers, and bools persist."""
|
|
store = SettingsStore(tmp_path / "settings.json")
|
|
store.remember(PUB_A, "name", ["not", "a", "scalar"])
|
|
assert store.settings(PUB_A) == {}
|
|
|
|
|
|
# --- drift -------------------------------------------------------------------
|
|
|
|
|
|
async def _mock_device():
|
|
device = make_device(mock=True, port=None)
|
|
await device.connect()
|
|
pubkey = (await device.get_self_info())["public_key"]
|
|
return device, pubkey
|
|
|
|
|
|
async def test_drift_reports_only_changed_settings(tmp_path: Path) -> None:
|
|
"""Drift lists remembered settings that differ from the device, in registry order."""
|
|
device, pubkey = await _mock_device()
|
|
store = SettingsStore(tmp_path / "settings.json")
|
|
store.remember(pubkey, "radio_freq", 915.0) # device default is 869.618 — drifts
|
|
store.remember(pubkey, "name", "MockCompanion") # equals the device default — no drift
|
|
|
|
snapshot = await build_snapshot(device)
|
|
drifted = settings_drift(store, pubkey, snapshot)
|
|
assert [d.key for d in drifted] == ["radio_freq"]
|
|
assert drifted[0].remembered == 915.0 and drifted[0].current == 869.618
|
|
|
|
|
|
async def test_drift_empty_when_nothing_remembered(tmp_path: Path) -> None:
|
|
"""A device with no remembered settings never shows drift (firmware radios pay nothing)."""
|
|
device, pubkey = await _mock_device()
|
|
store = SettingsStore(tmp_path / "settings.json")
|
|
snapshot = await build_snapshot(device)
|
|
assert settings_drift(store, pubkey, snapshot) == []
|
|
|
|
|
|
async def test_drift_skips_unknown_keys(tmp_path: Path) -> None:
|
|
"""A remembered key the registry no longer defines is ignored, not reported as drift."""
|
|
device, pubkey = await _mock_device()
|
|
store = SettingsStore(tmp_path / "settings.json")
|
|
store.remember(pubkey, "gone_from_registry", "whatever")
|
|
snapshot = await build_snapshot(device)
|
|
assert settings_drift(store, pubkey, snapshot) == []
|
|
|
|
|
|
# --- restore / adopt ---------------------------------------------------------
|
|
|
|
|
|
async def test_restore_writes_remembered_values_including_coupled(tmp_path: Path) -> None:
|
|
"""Restore replays saved values onto the device, coupled radio fields rebuilt together."""
|
|
device, pubkey = await _mock_device()
|
|
store = SettingsStore(tmp_path / "settings.json")
|
|
store.remember(pubkey, "name", "Ops-Node")
|
|
store.remember(pubkey, "radio_freq", 915.0)
|
|
store.remember(pubkey, "radio_sf", 12) # a second radio field: exercises the coupled apply
|
|
|
|
snapshot = await build_snapshot(device)
|
|
drifted = settings_drift(store, pubkey, snapshot)
|
|
written = await restore(store, device, snapshot, [d.key for d in drifted])
|
|
assert written == 3
|
|
|
|
after = await build_snapshot(device)
|
|
assert after["name"] == "Ops-Node"
|
|
assert after["radio_freq"] == 915.0
|
|
assert after["radio_sf"] == 12
|
|
assert after["radio_bw"] == 62.5 # untouched radio field preserved by the coupled rebuild
|
|
# Idempotent: with the device now matching, there is nothing left to restore.
|
|
assert settings_drift(store, pubkey, after) == []
|
|
|
|
|
|
async def test_adopt_updates_store_to_device_values(tmp_path: Path) -> None:
|
|
"""Adopt takes the device's current values as the new saved truth, clearing the drift."""
|
|
device, pubkey = await _mock_device()
|
|
store = SettingsStore(tmp_path / "settings.json")
|
|
store.remember(pubkey, "radio_freq", 915.0) # differs from the device's 869.618
|
|
|
|
snapshot = await build_snapshot(device)
|
|
drifted = settings_drift(store, pubkey, snapshot)
|
|
adopt(store, pubkey, snapshot, [d.key for d in drifted])
|
|
|
|
assert store.settings(pubkey)["radio_freq"] == 869.618 # now matches the device
|
|
assert settings_drift(store, pubkey, snapshot) == []
|
|
|
|
|
|
async def test_adopt_forgets_a_setting_the_device_no_longer_reports(tmp_path: Path) -> None:
|
|
"""Adopting a key the device reports no value for forgets it rather than storing a null."""
|
|
device, pubkey = await _mock_device()
|
|
store = SettingsStore(tmp_path / "settings.json")
|
|
store.remember(pubkey, "flood_scope", "#ops")
|
|
|
|
snapshot = await build_snapshot(device)
|
|
snapshot.pop("flood_scope", None) # simulate firmware that doesn't report this field
|
|
adopt(store, pubkey, snapshot, ["flood_scope"])
|
|
assert "flood_scope" not in store.settings(pubkey)
|
|
|
|
|
|
# --- write-through -----------------------------------------------------------
|
|
|
|
|
|
async def test_apply_setting_remembers_through_the_config_executor(ctx) -> None:
|
|
"""Every setting changed through the config executor is recorded under the device key."""
|
|
device = await ctx.device()
|
|
pubkey = (await device.get_self_info())["public_key"]
|
|
snapshot = await build_snapshot(device)
|
|
|
|
changes, _ = await apply_ops(
|
|
ctx, device, snapshot, [("set", "name", "Ops-Node"), ("set", "tx_power", "18")]
|
|
)
|
|
assert changes == 2
|
|
assert ctx.settings_store.settings(pubkey) == {"name": "Ops-Node", "tx_power": 18}
|