diff --git a/repeater/config.py b/repeater/config.py index 141e31b..ffea57f 100644 --- a/repeater/config.py +++ b/repeater/config.py @@ -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}. " diff --git a/repeater/engine.py b/repeater/engine.py index 182be1f..8e4fb6c 100644 --- a/repeater/engine.py +++ b/repeater/engine.py @@ -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) diff --git a/tests/test_config_radio.py b/tests/test_config_radio.py new file mode 100644 index 0000000..79a7ad3 --- /dev/null +++ b/tests/test_config_radio.py @@ -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 diff --git a/tests/test_engine.py b/tests/test_engine.py index 7f60d8d..2560ef4 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -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):