fix: move modem CRC baseline into radio wrapper

This commit is contained in:
Yellowcooln
2026-06-16 11:22:34 -04:00
parent 1881ad9b93
commit b557116fdf
4 changed files with 67 additions and 105 deletions
+37 -2
View File
@@ -101,6 +101,41 @@ class NullRadio:
return True
class BaselineCrcCounterRadio:
"""Radio proxy that exposes CRC errors relative to repeater startup.
pyMC modem transports report the modem firmware's cumulative CRC counter.
The SX1262 wrapper's counter starts at process startup, which lets the engine
persist deltas without knowing the radio backend. Mirror that wrapper flow
here by normalizing the modem's raw counter at the transport boundary.
"""
def __init__(self, radio):
self._radio = radio
self._crc_baseline = self._read_raw_crc_count()
def __getattr__(self, name: str):
return getattr(self._radio, name)
@property
def crc_error_count(self) -> int:
current = self._read_raw_crc_count()
if self._crc_baseline <= 0 and current > 0:
self._crc_baseline = current
return 0
return max(0, current - self._crc_baseline)
@crc_error_count.setter
def crc_error_count(self, value: Any) -> None:
setattr(self._radio, "crc_error_count", value)
def _read_raw_crc_count(self) -> int:
try:
return int(getattr(self._radio, "crc_error_count", 0) or 0)
except (TypeError, ValueError):
return 0
def resolve_storage_dir(
config: Dict[str, Any],
*,
@@ -618,7 +653,7 @@ def get_radio_for_board(board_config: dict):
except Exception as e:
raise RuntimeError(f"Failed to initialize pymc_tcp radio: {e}") from e
return radio
return BaselineCrcCounterRadio(radio)
elif radio_type == "pymc_usb":
try:
@@ -660,7 +695,7 @@ def get_radio_for_board(board_config: dict):
except Exception as e:
raise RuntimeError(f"Failed to initialize pymc_usb radio: {e}") from e
return radio
return BaselineCrcCounterRadio(radio)
raise RuntimeError(
f"Unknown radio type: {radio_type}. "
+2 -24
View File
@@ -135,9 +135,7 @@ class RepeaterHandler(BaseHandler):
self.noise_floor_interval = NOISE_FLOOR_INTERVAL # 30 seconds
self._background_task = None
self._cached_noise_floor = None
self._last_crc_error_count = (
None if self._is_pymc_modem_radio() else 0
) # Track radio counter for delta persistence
self._last_crc_error_count = 0 # Track radio counter for delta persistence
# Cache transport keys for efficient lookup
self._transport_keys_cache = None
@@ -1408,18 +1406,6 @@ class RepeaterHandler(BaseHandler):
except Exception as e:
logger.error(f"Error recording noise floor: {e}")
def _is_pymc_modem_radio(self) -> bool:
"""Return True when using PyMC modem transports with device-owned stats."""
radio_type = str(self.config.get("radio_type", "")).lower()
return radio_type in {"pymc_usb", "pymc_tcp"}
@staticmethod
def _get_radio_crc_error_count(radio) -> int:
try:
return int(getattr(radio, "crc_error_count", 0) or 0) if radio else 0
except (TypeError, ValueError):
return 0
async def _record_crc_errors_async(self):
"""Persist CRC error delta from the radio hardware counter."""
if not self.storage:
@@ -1427,15 +1413,7 @@ class RepeaterHandler(BaseHandler):
try:
radio = self.dispatcher.radio if self.dispatcher else None
current = self._get_radio_crc_error_count(radio)
if self._last_crc_error_count is None:
if current <= 0:
logger.debug("Waiting for PyMC modem CRC baseline")
return
self._last_crc_error_count = current
logger.debug(f"Baselined PyMC modem CRC errors at {current}")
return
current = getattr(radio, "crc_error_count", 0) if radio else 0
delta = current - self._last_crc_error_count
if delta > 0:
self.storage.record_crc_errors(delta)
+28
View File
@@ -0,0 +1,28 @@
from types import SimpleNamespace
from repeater.config import BaselineCrcCounterRadio
def test_baseline_crc_counter_radio_reports_delta_from_initial_raw_count():
raw = SimpleNamespace(crc_error_count=20_000, frequency=869_618_000)
radio = BaselineCrcCounterRadio(raw)
assert radio.frequency == 869_618_000
assert radio.crc_error_count == 0
raw.crc_error_count = 20_003
assert radio.crc_error_count == 3
def test_baseline_crc_counter_radio_handles_delayed_modem_counter():
raw = SimpleNamespace(crc_error_count=0)
radio = BaselineCrcCounterRadio(raw)
assert radio.crc_error_count == 0
raw.crc_error_count = 145
assert radio.crc_error_count == 0
raw.crc_error_count = 148
assert radio.crc_error_count == 3
-79
View File
@@ -1790,85 +1790,6 @@ class TestMissedEngineBranches:
handler.storage.record_crc_errors.assert_called_once_with(5)
assert handler._last_crc_error_count == 9
@pytest.mark.parametrize("radio_type", ["sx1262", "sx1262_ch341", "kiss"])
@pytest.mark.asyncio
async def test_record_crc_errors_async_non_pymc_radios_keep_startup_delta_behavior(
self, radio_type
):
config = _make_config(radio_type=radio_type)
dispatcher = _make_dispatcher()
dispatcher.radio.crc_error_count = 145
with (
patch("repeater.engine.StorageCollector"),
patch("repeater.engine.RepeaterHandler._start_background_tasks"),
):
from repeater.engine import RepeaterHandler
handler = RepeaterHandler(config, dispatcher, LOCAL_HASH)
assert handler._last_crc_error_count == 0
await handler._record_crc_errors_async()
handler.storage.record_crc_errors.assert_called_once_with(145)
assert handler._last_crc_error_count == 145
@pytest.mark.parametrize("radio_type", ["pymc_usb", "pymc_tcp"])
@pytest.mark.asyncio
async def test_record_crc_errors_async_pymc_modem_baselines_existing_device_stats(
self, radio_type
):
config = _make_config(radio_type=radio_type)
dispatcher = _make_dispatcher()
dispatcher.radio.crc_error_count = 20_000
with (
patch("repeater.engine.StorageCollector"),
patch("repeater.engine.RepeaterHandler._start_background_tasks"),
):
from repeater.engine import RepeaterHandler
handler = RepeaterHandler(config, dispatcher, LOCAL_HASH)
await handler._record_crc_errors_async()
handler.storage.record_crc_errors.assert_not_called()
assert handler._last_crc_error_count == 20_000
dispatcher.radio.crc_error_count = 20_003
await handler._record_crc_errors_async()
handler.storage.record_crc_errors.assert_called_once_with(3)
assert handler._last_crc_error_count == 20_003
@pytest.mark.asyncio
async def test_record_crc_errors_async_pymc_modem_baselines_delayed_device_stats(self):
config = _make_config(radio_type="pymc_tcp")
dispatcher = _make_dispatcher()
dispatcher.radio.crc_error_count = 0
with (
patch("repeater.engine.StorageCollector"),
patch("repeater.engine.RepeaterHandler._start_background_tasks"),
):
from repeater.engine import RepeaterHandler
handler = RepeaterHandler(config, dispatcher, LOCAL_HASH)
await handler._record_crc_errors_async()
handler.storage.record_crc_errors.assert_not_called()
assert handler._last_crc_error_count is None
dispatcher.radio.crc_error_count = 145
await handler._record_crc_errors_async()
handler.storage.record_crc_errors.assert_not_called()
assert handler._last_crc_error_count == 145
dispatcher.radio.crc_error_count = 148
await handler._record_crc_errors_async()
handler.storage.record_crc_errors.assert_called_once_with(3)
assert handler._last_crc_error_count == 148
@pytest.mark.asyncio
async def test_record_noise_floor_async_caches_and_persists(self, handler):
with patch.object(handler, "get_noise_floor", return_value=-117.5):