Files
pyMC_Repeater/tests/test_per_radio_rf_samples.py
agessaman a439c2427c fix(stats): count CRC errors for the node, not the default radio
The three readers of the CRC scalar all answered for one receiver. On a bridge
that is half the node: a backhaul going deaf moved no number an operator or an
observer could see, while the sum of radios[].errors in the same status message
already said so.

All three now read RepeaterHandler.get_crc_error_count(), which sums the
radios -- stats.errors in the MQTT status message, crc_error_count in /stats,
and n_recv_errors in the MeshCore wire response, which previously read
self.radio directly and so described the default endpoint. stats.errors and the
per-radio entries beside it now reconcile.

This matches what node_stats() already does with the airtime counters that sit
next to it: a node-level counter describes the node, and which radio
contributed what stays available per radio, in radios[] and in the crc_errors
table.

The sum prefers each radio's live hardware counter and falls back to the last
count the sampler read from a radio the fabric cannot hand back. These are
lifetime counters, so dropping a radio would walk the node's total backwards
and hand an observer reading deltas a negative interval. A single-radio node
still reports its one unlabelled counter, read live, exactly as before.
2026-09-21 16:31:30 -07:00

555 lines
20 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_the_observer_feed():
"""MQTT and Glass carry one noise floor per node; per-radio publishing is separate work.
/stats keeps the same scalar for the same reason, and reports the other
radios beside it rather than in it.
"""
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_stats_reports_every_radio_noise_floor_beside_the_scalar():
"""Without this the sidebar can only refresh the default radio's curve live."""
handler, _, _ = _bridge_handler()
await handler._record_noise_floor_async()
stats = handler.get_stats()
assert stats["noise_floor_dbm"] == -118.0
assert stats["noise_floor_radios"] == [
{"radio_id": "local", "noise_floor_dbm": -118.0},
{"radio_id": "link", "noise_floor_dbm": -101.0},
]
@pytest.mark.asyncio
async def test_a_single_radio_node_stats_gains_no_per_radio_noise_floor():
handler = _make_handler()
await handler._record_noise_floor_async()
stats = handler.get_stats()
assert "noise_floor_radios" not in stats
assert handler.get_cached_noise_floor_by_radio() == {}
@pytest.mark.asyncio
async def test_a_radio_that_leaves_the_fabric_leaves_no_stale_figure():
handler, local, link = _bridge_handler()
await handler._record_noise_floor_async()
assert set(handler.get_cached_noise_floor_by_radio()) == {"local", "link"}
handler.dispatcher.radio.fabric.radios.pop("link")
await handler._record_noise_floor_async()
assert handler.get_cached_noise_floor_by_radio() == {}
assert "noise_floor_radios" not in handler.get_stats()
@pytest.mark.asyncio
async def test_a_radio_that_cannot_be_read_keeps_its_last_figure():
"""One timed-out read should not blank a curve that was drawing fine."""
handler, local, link = _bridge_handler()
await handler._record_noise_floor_async()
link.get_noise_floor = MagicMock(side_effect=RuntimeError("modem timeout"))
await handler._record_noise_floor_async()
assert handler.get_cached_noise_floor_by_radio() == {"local": -118.0, "link": -101.0}
@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"}
def test_node_crc_count_sums_every_radio():
# The scalar every reader shares -- stats.errors, /stats crc_error_count and
# the wire response's n_recv_errors. Reading the default radio alone would
# leave a deaf backhaul invisible in all three.
handler, _, _ = _bridge_handler()
assert handler.get_crc_error_count() == 13
assert handler.get_crc_error_count_by_radio() == {"local": 4, "link": 9}
def test_node_crc_count_reconciles_with_the_per_radio_figures():
handler, _, _ = _bridge_handler()
by_radio = handler.get_crc_error_count_by_radio()
assert handler.get_crc_error_count() == sum(by_radio.values())
def test_a_radio_the_fabric_cannot_hand_back_keeps_its_last_count():
# Lifetime counters: a radio going missing does not un-drop the packets it
# dropped, and an observer reading these as deltas must never see a negative
# interval. The sampler's last reading stands in until the radio is back.
handler, local, link = _bridge_handler()
handler._crc_error_baselines = {"local": 4, "link": 9}
def no_link(radio_id):
if radio_id == "link":
raise KeyError(radio_id)
return local
handler.dispatcher.radio.fabric.get_radio = no_link
assert handler.get_crc_error_count_by_radio() == {"local": 4}
assert handler.get_crc_error_count() == 13
def test_a_fabric_down_to_one_radio_still_counts_the_one_that_left():
handler, local, _ = _bridge_handler()
handler._crc_error_baselines = {"local": 4, "link": 9}
handler.dispatcher.radio.fabric = _Fabric([("local", local)], "local")
# One radio needs no label, so it reports unattributed again ...
assert handler.get_crc_error_count_by_radio() == {}
# ... but the node total does not drop the departed radio's 9.
assert handler.get_crc_error_count() == 13
def test_single_radio_node_still_reports_its_one_counter():
handler = _make_handler()
handler.dispatcher.radio.crc_error_count = 5
assert handler.get_crc_error_count_by_radio() == {}
assert handler.get_crc_error_count() == 5
# ---------------------------------------------------------------------------
# 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"}