diff --git a/repeater/config_manager.py b/repeater/config_manager.py index 914affd..53f981c 100644 --- a/repeater/config_manager.py +++ b/repeater/config_manager.py @@ -272,6 +272,15 @@ class ConfigManager: self.daemon.advert_helper.reload_config() logger.info("Reloaded AdvertHelper config") + # Re-apply the flood reception delay base when delays changed + if "delays" in sections and self.daemon and getattr(self.daemon, "dispatcher", None): + delays_cfg = self.daemon.config.get("delays", {}) + self.daemon.dispatcher.rx_delay_base = float(delays_cfg.get("rx_delay_base", 0.0)) + logger.info( + f"Reloaded flood RX delay base: delays.rx_delay_base=" + f"{self.daemon.dispatcher.rx_delay_base}" + ) + # Re-apply dispatcher path hash mode when mesh section changed if "mesh" in sections and self.daemon and hasattr(self.daemon, "dispatcher"): mesh_cfg = self.daemon.config.get("mesh", {}) diff --git a/repeater/engine.py b/repeater/engine.py index 19c2e47..eebfd3e 100644 --- a/repeater/engine.py +++ b/repeater/engine.py @@ -22,7 +22,7 @@ from openhop_core.protocol.constants import ( ROUTE_TYPE_TRANSPORT_DIRECT, ROUTE_TYPE_TRANSPORT_FLOOD, ) -from openhop_core.protocol.packet_utils import PacketHeaderUtils, PathUtils +from openhop_core.protocol.packet_utils import PacketHeaderUtils, PathUtils, packet_score from repeater.airtime import AirtimeManager from repeater.data_acquisition import StorageCollector @@ -1119,29 +1119,9 @@ class RepeaterHandler(BaseHandler): @staticmethod def calculate_packet_score(snr: float, packet_len: int, spreading_factor: int = 8) -> float: - - # SNR thresholds per SF (from MeshCore RadioLibWrappers.cpp) - snr_thresholds = {7: -7.5, 8: -10.0, 9: -12.5, 10: -15.0, 11: -17.5, 12: -20.0} - - if spreading_factor < 7: - return 0.0 - - threshold = snr_thresholds.get(spreading_factor, -10.0) - - # Below threshold = no chance of success - if snr < threshold: - return 0.0 - - # Success rate based on SNR above threshold - success_rate_based_on_snr = (snr - threshold) / 10.0 - - # Collision penalty: longer packets more likely to collide (max 256 bytes) - collision_penalty = 1.0 - (packet_len / 256.0) - - # Combined score - score = success_rate_based_on_snr * collision_penalty - - return max(0.0, min(1.0, score)) + """Reception-quality score in [0, 1] via the shared core scorer + (MeshCore RadioLibWrappers packetScoreInt).""" + return packet_score(snr, spreading_factor, packet_len) def _calculate_tx_delay(self, packet: Packet, snr: float = 0.0) -> float: diff --git a/repeater/handler_helpers/mesh_cli.py b/repeater/handler_helpers/mesh_cli.py index 8854168..4456c0a 100644 --- a/repeater/handler_helpers/mesh_cli.py +++ b/repeater/handler_helpers/mesh_cli.py @@ -570,7 +570,9 @@ class MeshCLI: return f"> {loop_detect}" elif param == "rxdelay": - delay = self.repeater_config.get("rx_delay_base", 0.0) + # The flood RX delay base lives in the delays section (the value + # the dispatcher and web API use), not the repeater section. + delay = self.config.get("delays", {}).get("rx_delay_base", 0.0) return f"> {delay}" elif param == "txdelay": @@ -741,7 +743,7 @@ class MeshCLI: delay = float(value) if delay < 0: return "Error: cannot be negative" - self.repeater_config["rx_delay_base"] = delay + self.config.setdefault("delays", {})["rx_delay_base"] = delay saved, _ = self.config_manager.save_to_file() self.config_manager.live_update_daemon(["repeater", "delays"]) return "OK" diff --git a/repeater/main.py b/repeater/main.py index b9704ce..c5b15e7 100644 --- a/repeater/main.py +++ b/repeater/main.py @@ -330,6 +330,11 @@ class RepeaterDaemon: self.config.get("repeater", {}).get("dispatcher_dedupe_enabled", False) ) self.dispatcher = Dispatcher(self.radio, dedupe_enabled=dedupe_enabled) + # Flood reception-quality delay base (MeshCore "set rxdelay"); + # 0 keeps flood processing immediate, the firmware default. + self.dispatcher.rx_delay_base = float( + self.config.get("delays", {}).get("rx_delay_base", 0.0) + ) logger.info("Dispatcher initialized") logger.info("Dispatcher dedupe enabled: %s", dedupe_enabled) diff --git a/tests/test_flood_rx_delay_wiring.py b/tests/test_flood_rx_delay_wiring.py new file mode 100644 index 0000000..3d69c16 --- /dev/null +++ b/tests/test_flood_rx_delay_wiring.py @@ -0,0 +1,109 @@ +"""Wiring for the flood reception-quality delay (MeshCore "set rxdelay"). + +The delay itself lives in the core Dispatcher; the repeater's job is to feed +``delays.rx_delay_base`` from its config into ``dispatcher.rx_delay_base`` at +startup and on live config updates, and to expose the same value through the +mesh CLI. The packet scorer the delay uses is shared with the engine's +TX-delay scoring. +""" + +from types import SimpleNamespace +from unittest.mock import MagicMock + +from openhop_core.protocol.packet_utils import packet_score + +from repeater.config_manager import ConfigManager +from repeater.engine import RepeaterHandler +from repeater.handler_helpers.mesh_cli import MeshCLI + + +class _DummyDispatcher: + def __init__(self): + self.rx_delay_base = 0.0 + + def set_default_path_hash_mode(self, mode): + pass + + +def _daemon_with_dispatcher(config): + return SimpleNamespace( + config=config, + radio=None, + repeater_handler=None, + advert_helper=None, + dispatcher=_DummyDispatcher(), + ) + + +def _cfg_mgr(): + return SimpleNamespace( + save_to_file=MagicMock(return_value=(True, None)), + live_update_daemon=MagicMock(), + ) + + +# --------------------------------------------------------------------------- +# Live config update -> dispatcher +# --------------------------------------------------------------------------- +def test_live_update_applies_rx_delay_base_to_dispatcher(): + config = {"delays": {"rx_delay_base": 10.0}} + daemon = _daemon_with_dispatcher(config) + manager = ConfigManager("/tmp/config.yaml", config, daemon) + + assert manager.live_update_daemon(["delays"]) + assert daemon.dispatcher.rx_delay_base == 10.0 + + +def test_live_update_clears_rx_delay_base_when_removed(): + config = {"delays": {}} + daemon = _daemon_with_dispatcher(config) + daemon.dispatcher.rx_delay_base = 10.0 + manager = ConfigManager("/tmp/config.yaml", config, daemon) + + assert manager.live_update_daemon(["delays"]) + assert daemon.dispatcher.rx_delay_base == 0.0 + + +def test_live_update_without_dispatcher_does_not_crash(): + config = {"delays": {"rx_delay_base": 5.0}} + daemon = _daemon_with_dispatcher(config) + daemon.dispatcher = None + manager = ConfigManager("/tmp/config.yaml", config, daemon) + + assert manager.live_update_daemon(["delays"]) + + +# --------------------------------------------------------------------------- +# Mesh CLI reads/writes the delays section (the one the dispatcher uses) +# --------------------------------------------------------------------------- +def test_cli_set_rxdelay_writes_delays_section(): + config = {"repeater": {}, "mesh": {}} + cli = MeshCLI("/tmp/cfg.yaml", config, _cfg_mgr()) + + assert cli._cmd_set("rxdelay 10") == "OK" + assert config["delays"]["rx_delay_base"] == 10.0 + assert "rx_delay_base" not in config["repeater"] + cli.config_manager.live_update_daemon.assert_called_with(["repeater", "delays"]) + + +def test_cli_get_rxdelay_reads_delays_section(): + config = {"repeater": {"rx_delay_base": 99.0}, "mesh": {}, "delays": {"rx_delay_base": 7.5}} + cli = MeshCLI("/tmp/cfg.yaml", config, _cfg_mgr()) + + assert cli._cmd_get("rxdelay") == "> 7.5" + + +def test_cli_get_rxdelay_defaults_to_disabled(): + cli = MeshCLI("/tmp/cfg.yaml", {"repeater": {}, "mesh": {}}, _cfg_mgr()) + + assert cli._cmd_get("rxdelay") == "> 0.0" + + +# --------------------------------------------------------------------------- +# Engine scoring delegates to the shared core scorer +# --------------------------------------------------------------------------- +def test_engine_score_matches_shared_packet_score(): + for snr, packet_len, sf in ((-5.0, 24, 7), (-10.0, 128, 12), (5.0, 50, 8), (0.0, 200, 10)): + assert RepeaterHandler.calculate_packet_score(snr, packet_len, sf) == packet_score( + snr, sf, packet_len + )