fix(repeater): wire the flood reception delay base into the dispatcher

The delays.rx_delay_base config value was stored and reported but never
applied. Now that the core dispatcher implements MeshCore's flood
reception-quality delay, feed it through:

- set dispatcher.rx_delay_base from delays.rx_delay_base at startup and
  re-apply it on live config updates of the delays section (web API
  saves already pass that section)
- point the mesh CLI get/set rxdelay at the delays section; it read and
  wrote repeater.rx_delay_base, which nothing consumes, so the CLI knob
  was silently dead
- delegate RepeaterHandler.calculate_packet_score to the shared core
  packet_score (same firmware packetScoreInt formula, one impl)

Suppression works through the existing engine seen-cache because it
dedupes at process time, after the dispatcher hold. Default remains 0
(delay disabled), matching firmware.
This commit is contained in:
agessaman
2026-07-15 21:22:32 -07:00
parent b9579b6c15
commit 3e2231f2bc
5 changed files with 131 additions and 26 deletions
+9
View File
@@ -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", {})
+4 -24
View File
@@ -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:
+4 -2
View File
@@ -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"
+5
View File
@@ -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)
+109
View File
@@ -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
)