mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-11-08 06:12:34 +01:00
A bridge has one receiver per band, and until now one of them stood for the node: the background timer read the default radio and stored the answer with no radio attached. On the 910.100/910.525 pair that reported the wide radio's noise floor as the whole node's, and charged its CRC counter with both radios' errors. Every physical radio is now sampled, one after another, and stored against its own id. Migration 19 adds a nullable radio_id to noise_floor and crc_errors with a covering index each, so a per-radio history reads only its own rows. Rows written before this stay NULL: they were never measured per radio, and labelling them would claim an attribution nobody made. CRC baselines are per radio. A shared one charges every radio's errors to whichever was sampled last, which reports swings that never happened. Reads take an optional radio_id: /noise_floor_history, /noise_floor_stats, /crc_error_count and /crc_error_history. Naming a radio also makes offset paging stable, which the RF Health page depends on -- paging the merged series shifts a page's contents every time the other radio samples. History rows name their radio only on a node with two or more radios, so a single-radio response is byte-for-byte what it was. The default radio is sampled first and is the only one published. /stats, the MQTT status message and companion stats read its cached value, and publishing per radio doubles the observer-feed cadence -- its own change, not this one. The StorageCollector facade carries the new arguments too. The web endpoints reach the database through it, so the tests that drive them autospec it: a bare mock accepts any keyword and would have let a facade that had not kept up answer happily in tests and raise TypeError on a real node.
444 lines
16 KiB
Python
444 lines
16 KiB
Python
"""Per-radio noise floor and CRC error sampling.
|
|
|
|
A bridge has one receiver per band, so one noise floor and one CRC counter
|
|
describe neither. Every radio is now sampled and stored against its own id,
|
|
while a single-radio node stores NULL and gets byte-for-byte the answers it had.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sqlite3
|
|
import time
|
|
from collections import OrderedDict
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock, create_autospec, patch
|
|
|
|
import cherrypy
|
|
import pytest
|
|
|
|
from repeater.data_acquisition.sqlite_handler import SQLiteHandler
|
|
from repeater.data_acquisition.storage_collector import StorageCollector
|
|
from repeater.engine import RepeaterHandler
|
|
from repeater.web.api_endpoints import APIEndpoints
|
|
|
|
LOCAL = {
|
|
"radio_id": "local",
|
|
"frequency_hz": 910100000,
|
|
"bandwidth_hz": 500000,
|
|
"spreading_factor": 7,
|
|
"coding_rate": 5,
|
|
"preamble_length": 17,
|
|
}
|
|
LINK = dict(LOCAL, radio_id="link", frequency_hz=910525000, bandwidth_hz=62500)
|
|
BRIDGE = [LOCAL, LINK]
|
|
SINGLE = [dict(LOCAL, radio_id="radio0")]
|
|
|
|
LOCAL_HASH = 0xAB
|
|
|
|
|
|
@pytest.fixture
|
|
def handler(tmp_path) -> SQLiteHandler:
|
|
return SQLiteHandler(tmp_path)
|
|
|
|
|
|
def _noise(handler: SQLiteHandler, dbm: float, radio_id=None, ago: float = 60.0) -> None:
|
|
handler.store_noise_floor(
|
|
{"timestamp": time.time() - ago, "noise_floor_dbm": dbm, "radio_id": radio_id}
|
|
)
|
|
|
|
|
|
def _crc(handler: SQLiteHandler, count: int, radio_id=None, ago: float = 60.0) -> None:
|
|
handler.store_crc_errors({"timestamp": time.time() - ago, "count": count, "radio_id": radio_id})
|
|
|
|
|
|
@pytest.fixture
|
|
def bridge_db(handler) -> SQLiteHandler:
|
|
"""Both radios sampled, plus a row from before per-radio sampling."""
|
|
_noise(handler, -118.0, "local", ago=90)
|
|
_noise(handler, -103.0, "link", ago=80)
|
|
_noise(handler, -117.0, "local", ago=70)
|
|
_noise(handler, -95.0, None, ago=60)
|
|
_crc(handler, 2, "local", ago=90)
|
|
_crc(handler, 7, "link", ago=80)
|
|
_crc(handler, 1, None, ago=60)
|
|
return handler
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Schema
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_migration_adds_radio_id_to_an_existing_database(tmp_path):
|
|
"""A database written before this work gains the column and its indexes."""
|
|
db = tmp_path / "repeater.db"
|
|
with sqlite3.connect(db) as conn:
|
|
conn.execute(
|
|
"CREATE TABLE noise_floor (id INTEGER PRIMARY KEY AUTOINCREMENT, "
|
|
"timestamp REAL NOT NULL, noise_floor_dbm REAL NOT NULL)"
|
|
)
|
|
conn.execute(
|
|
"CREATE TABLE crc_errors (id INTEGER PRIMARY KEY AUTOINCREMENT, "
|
|
"timestamp REAL NOT NULL, count INTEGER NOT NULL DEFAULT 1)"
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO noise_floor (timestamp, noise_floor_dbm) VALUES (?, ?)", (1.0, -9)
|
|
)
|
|
conn.commit()
|
|
|
|
store = SQLiteHandler(tmp_path)
|
|
|
|
with sqlite3.connect(db) as conn:
|
|
for table in ("noise_floor", "crc_errors"):
|
|
columns = [row[1] for row in conn.execute(f"PRAGMA table_info({table})")]
|
|
assert "radio_id" in columns
|
|
indexes = {
|
|
row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='index'")
|
|
}
|
|
assert {"idx_noise_radio_time", "idx_crc_radio_time"} <= indexes
|
|
# The pre-existing sample is kept, unattributed rather than guessed.
|
|
assert conn.execute("SELECT radio_id FROM noise_floor").fetchone()[0] is None
|
|
|
|
_noise(store, -110.0, "link")
|
|
assert [row["noise_floor_dbm"] for row in store.get_noise_floor_history(radio_id="link")] == [
|
|
-110.0
|
|
]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Single radio: unchanged answers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_single_radio_history_carries_no_radio_id(bridge_db):
|
|
"""Without two profiles the rows are exactly what they always were."""
|
|
noise = bridge_db.get_noise_floor_history(radio_profiles=SINGLE)
|
|
crc = bridge_db.get_crc_error_history(radio_profiles=SINGLE)
|
|
|
|
assert all(set(row) == {"timestamp", "noise_floor_dbm"} for row in noise)
|
|
assert all(set(row) == {"timestamp", "count"} for row in crc)
|
|
assert bridge_db.get_noise_floor_history() == noise
|
|
assert bridge_db.get_crc_error_history() == crc
|
|
|
|
|
|
def test_unfiltered_reads_still_see_every_sample(bridge_db):
|
|
assert len(bridge_db.get_noise_floor_history()) == 4
|
|
assert bridge_db.get_crc_error_count() == 10
|
|
assert bridge_db.get_noise_floor_stats()["measurement_count"] == 4
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Two radios: attribution and filtering
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_bridge_history_names_the_sampled_radio(bridge_db):
|
|
noise = bridge_db.get_noise_floor_history(radio_profiles=BRIDGE)
|
|
crc = bridge_db.get_crc_error_history(radio_profiles=BRIDGE)
|
|
|
|
assert [row["radio_id"] for row in noise] == ["local", "link", "local", None]
|
|
assert [row["radio_id"] for row in crc] == ["local", "link", None]
|
|
|
|
|
|
def test_a_radio_no_longer_configured_reads_as_unattributed(bridge_db):
|
|
"""Renaming a radio must not silently re-attribute its history to another."""
|
|
renamed = [LOCAL, dict(LINK, radio_id="narrow")]
|
|
|
|
rows = bridge_db.get_noise_floor_history(radio_profiles=renamed)
|
|
|
|
assert [row["radio_id"] for row in rows] == ["local", None, "local", None]
|
|
|
|
|
|
def test_filtering_narrows_to_one_radio(bridge_db):
|
|
noise = bridge_db.get_noise_floor_history(radio_id="local", radio_profiles=BRIDGE)
|
|
crc = bridge_db.get_crc_error_history(radio_id="link", radio_profiles=BRIDGE)
|
|
|
|
assert [row["noise_floor_dbm"] for row in noise] == [-118.0, -117.0]
|
|
assert [row["count"] for row in crc] == [7]
|
|
assert bridge_db.get_crc_error_count(radio_id="local") == 2
|
|
|
|
|
|
def test_filtered_stats_describe_one_receiver(bridge_db):
|
|
"""Averaged across a bridge these numbers describe neither band."""
|
|
both = bridge_db.get_noise_floor_stats()
|
|
local = bridge_db.get_noise_floor_stats(radio_id="local")
|
|
|
|
assert local["measurement_count"] == 2
|
|
assert local["avg_noise_floor"] == -117.5
|
|
assert local["min_noise_floor"] == -118.0
|
|
assert both["avg_noise_floor"] != local["avg_noise_floor"]
|
|
|
|
|
|
def test_paging_one_radio_is_stable_while_the_other_samples(handler):
|
|
"""Offset paging over the merged series would reshuffle as the other radio writes."""
|
|
for index in range(6):
|
|
_noise(handler, -100.0 - index, "local", ago=600 - index * 10)
|
|
_noise(handler, -80.0, "link", ago=595 - index * 10)
|
|
|
|
first = handler.get_noise_floor_history(limit=3, offset=0, radio_id="local")
|
|
_noise(handler, -70.0, "link", ago=1)
|
|
second = handler.get_noise_floor_history(limit=3, offset=3, radio_id="local")
|
|
|
|
seen = [row["noise_floor_dbm"] for row in second + first]
|
|
assert seen == [-100.0, -101.0, -102.0, -103.0, -104.0, -105.0]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Sampling: one reading per radio, published for the default one only
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _PhysicalRadio:
|
|
def __init__(self, noise_floor, crc_error_count=0):
|
|
self._noise_floor = noise_floor
|
|
self.crc_error_count = crc_error_count
|
|
self.reads = 0
|
|
|
|
def get_noise_floor(self):
|
|
self.reads += 1
|
|
return self._noise_floor
|
|
|
|
|
|
class _Fabric:
|
|
def __init__(self, radios, default_radio_id):
|
|
self.radios = OrderedDict(radios)
|
|
self.default_radio_id = default_radio_id
|
|
|
|
def get_radio(self, radio_id):
|
|
return self.radios[radio_id]
|
|
|
|
|
|
def _make_handler(fabric=None):
|
|
config = {
|
|
"repeater": {"mode": "forward", "cache_ttl": 3600, "send_advert_interval_hours": 0},
|
|
"mesh": {"unscoped_flood_allow": True, "loop_detect": "off"},
|
|
"delays": {"tx_delay_factor": 1.0, "direct_tx_delay_factor": 0.5},
|
|
"duty_cycle": {"max_airtime_per_minute": 3600, "enforcement_enabled": True},
|
|
"radio": {
|
|
"spreading_factor": 7,
|
|
"bandwidth": 62500,
|
|
"coding_rate": 5,
|
|
"preamble_length": 17,
|
|
},
|
|
}
|
|
radio = MagicMock()
|
|
radio.fabric = fabric
|
|
radio.crc_error_count = 0
|
|
dispatcher = MagicMock()
|
|
dispatcher.radio = radio
|
|
dispatcher.local_identity = MagicMock()
|
|
dispatcher.send_packet = AsyncMock()
|
|
with (
|
|
patch("repeater.engine.StorageCollector"),
|
|
patch("repeater.engine.RepeaterHandler._start_background_tasks"),
|
|
):
|
|
handler = RepeaterHandler(
|
|
config, dispatcher, LOCAL_HASH, local_hash_bytes=bytes([LOCAL_HASH])
|
|
)
|
|
handler.storage = MagicMock()
|
|
return handler
|
|
|
|
|
|
def _bridge_handler(default_radio_id="local"):
|
|
local = _PhysicalRadio(-118.0, crc_error_count=4)
|
|
link = _PhysicalRadio(-101.0, crc_error_count=9)
|
|
fabric = _Fabric([("local", local), ("link", link)], default_radio_id)
|
|
return _make_handler(fabric), local, link
|
|
|
|
|
|
def test_single_radio_samples_once_with_no_radio_id():
|
|
handler = _make_handler()
|
|
|
|
assert handler._sampling_radios() == [(None, handler.dispatcher.radio)]
|
|
|
|
|
|
def test_the_default_radio_is_sampled_first():
|
|
"""Callers publish the first entry, and /stats reports the default radio."""
|
|
handler, _, _ = _bridge_handler(default_radio_id="link")
|
|
|
|
assert [radio_id for radio_id, _ in handler._sampling_radios()] == ["link", "local"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_noise_floor_is_sampled_and_stored_per_radio():
|
|
handler, local, link = _bridge_handler()
|
|
|
|
await handler._record_noise_floor_async()
|
|
|
|
assert handler.storage.record_noise_floor.call_args_list == [
|
|
(((-118.0), "local"), {"publish": True}),
|
|
(((-101.0), "link"), {"publish": False}),
|
|
]
|
|
assert (local.reads, link.reads) == (1, 1)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_only_the_default_radio_reaches_stats_and_the_observer_feed():
|
|
"""MQTT, Glass and /stats carry one noise floor per node; per-radio publishing is separate work."""
|
|
handler, _, _ = _bridge_handler(default_radio_id="link")
|
|
|
|
await handler._record_noise_floor_async()
|
|
|
|
published = [
|
|
call for call in handler.storage.record_noise_floor.call_args_list if call.kwargs["publish"]
|
|
]
|
|
assert [call.args for call in published] == [(-101.0, "link")]
|
|
assert handler._cached_noise_floor == -101.0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_radio_that_cannot_be_read_does_not_stop_the_others():
|
|
handler, local, link = _bridge_handler()
|
|
local.get_noise_floor = MagicMock(side_effect=RuntimeError("modem timeout"))
|
|
|
|
await handler._record_noise_floor_async()
|
|
|
|
assert [call.args for call in handler.storage.record_noise_floor.call_args_list] == [
|
|
(-101.0, "link")
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_crc_deltas_are_measured_against_each_radio_own_baseline():
|
|
handler, local, link = _bridge_handler()
|
|
|
|
await handler._record_crc_errors_async()
|
|
local.crc_error_count = 6
|
|
link.crc_error_count = 9
|
|
handler.storage.record_crc_errors.reset_mock()
|
|
await handler._record_crc_errors_async()
|
|
|
|
# Only local moved; a shared baseline would have charged link with the swing.
|
|
assert [call.args for call in handler.storage.record_crc_errors.call_args_list] == [
|
|
(2, "local")
|
|
]
|
|
assert handler._crc_error_baselines == {"local": 6, "link": 9}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_crc_errors_publish_only_the_default_radio():
|
|
handler, _, _ = _bridge_handler()
|
|
|
|
await handler._record_crc_errors_async()
|
|
|
|
published = {
|
|
call.args[1]
|
|
for call in handler.storage.record_crc_errors.call_args_list
|
|
if call.kwargs["publish"]
|
|
}
|
|
assert published == {"local"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# API boundary: the endpoints call the storage facade, not the SQLite handler
|
|
# ---------------------------------------------------------------------------
|
|
|
|
BRIDGE_CONFIG = {
|
|
"radios": [
|
|
{
|
|
"id": "local",
|
|
"radio_type": "sx1262",
|
|
"radio": {
|
|
"frequency": 910100000,
|
|
"bandwidth": 500000,
|
|
"spreading_factor": 7,
|
|
"coding_rate": 5,
|
|
"preamble_length": 17,
|
|
},
|
|
},
|
|
{
|
|
"id": "link",
|
|
"radio_type": "sx1262",
|
|
"radio": {
|
|
"frequency": 910525000,
|
|
"bandwidth": 62500,
|
|
"spreading_factor": 7,
|
|
"coding_rate": 5,
|
|
"preamble_length": 17,
|
|
},
|
|
},
|
|
],
|
|
"fabric": {"default_radio": "local", "tx_mode": "bridge"},
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def cherrypy_ctx(monkeypatch):
|
|
monkeypatch.setattr(
|
|
cherrypy, "request", SimpleNamespace(method="GET", params={}, json={}), raising=False
|
|
)
|
|
monkeypatch.setattr(
|
|
cherrypy, "response", SimpleNamespace(headers={}, status=200), raising=False
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def rf_api(cherrypy_ctx, tmp_path):
|
|
"""APIEndpoints over an autospecced StorageCollector.
|
|
|
|
Autospec, not a bare mock: the endpoints reach the database through the
|
|
collector facade, and a plain mock accepts any keyword, so a facade that had
|
|
not kept up with the handler would answer every call happily here and raise
|
|
TypeError on a real node.
|
|
"""
|
|
del cherrypy_ctx
|
|
api = APIEndpoints.__new__(APIEndpoints)
|
|
api.config = {}
|
|
api.send_advert_func = None
|
|
api.event_loop = None
|
|
api.stats_getter = None
|
|
api._config_path = str(tmp_path / "config.yaml")
|
|
api.config_manager = MagicMock()
|
|
|
|
storage = create_autospec(StorageCollector, instance=True)
|
|
storage.get_noise_floor_history.return_value = [
|
|
{"timestamp": 1.0, "noise_floor_dbm": -118.0, "radio_id": "local"}
|
|
]
|
|
storage.get_noise_floor_stats.return_value = {"measurement_count": 2}
|
|
storage.get_crc_error_count.return_value = 3
|
|
storage.get_crc_error_history.return_value = [{"timestamp": 1.0, "count": 3}]
|
|
api.daemon_instance = SimpleNamespace(repeater_handler=SimpleNamespace(storage=storage))
|
|
return api, storage
|
|
|
|
|
|
def test_endpoints_reach_the_storage_facade_without_a_radio(rf_api):
|
|
api, storage = rf_api
|
|
|
|
assert api.noise_floor_history(hours="24", limit="5")["success"] is True
|
|
assert api.noise_floor_stats(hours="12")["success"] is True
|
|
assert api.crc_error_count(hours="6")["success"] is True
|
|
assert api.crc_error_history(hours="6", limit="10")["success"] is True
|
|
|
|
assert storage.get_noise_floor_history.call_args.kwargs["radio_id"] is None
|
|
assert storage.get_crc_error_count.call_args.kwargs["radio_id"] is None
|
|
|
|
|
|
def test_endpoints_pass_the_named_radio_and_echo_it_back(rf_api):
|
|
api, storage = rf_api
|
|
api.config = BRIDGE_CONFIG
|
|
|
|
history = api.noise_floor_history(hours="24", radio_id="link")
|
|
stats = api.noise_floor_stats(hours="24", radio_id="link")
|
|
count = api.crc_error_count(hours="24", radio_id="link")
|
|
crc = api.crc_error_history(hours="24", radio_id="link")
|
|
|
|
assert history["data"]["radio_id"] == "link"
|
|
assert stats["data"]["radio_id"] == "link"
|
|
assert count["data"]["radio_id"] == "link"
|
|
assert crc["data"]["radio_id"] == "link"
|
|
assert storage.get_noise_floor_history.call_args.kwargs["radio_id"] == "link"
|
|
assert [
|
|
profile["radio_id"]
|
|
for profile in storage.get_noise_floor_history.call_args.kwargs["radio_profiles"]
|
|
] == ["local", "link"]
|
|
|
|
|
|
def test_a_single_radio_response_keeps_its_shape(rf_api):
|
|
api, _ = rf_api
|
|
|
|
payload = api.noise_floor_history(hours="24")["data"]
|
|
crc = api.crc_error_history(hours="24")["data"]
|
|
|
|
assert set(payload) == {"history", "hours", "count", "limit", "offset"}
|
|
assert set(crc) == {"history", "hours", "count"}
|