From f19421ee6ce773a80a76ce7b5a835422e2f26df5 Mon Sep 17 00:00:00 2001 From: agessaman Date: Sun, 19 Jul 2026 07:11:24 -0700 Subject: [PATCH] fix(cli): clamp delay factors to the firmware ranges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Firmware rejects rxdelay outside 0-20 and txdelay/direct.txdelay outside 0-2.0; the CLI only rejected negatives, so a remote admin could set delay factors far beyond what any firmware node would accept. Apply the firmware ranges with the firmware error strings and state the ranges in the help text. Existing configs with out-of-range values are untouched — only new CLI sets are gated. Also adds an end-to-end regression test running every set command against the real ConfigManager on a temp config file. --- repeater/handler_helpers/mesh_cli.py | 20 ++++---- tests/test_handler_helpers_mesh_cli.py | 67 ++++++++++++++++++++++++-- 2 files changed, 74 insertions(+), 13 deletions(-) diff --git a/repeater/handler_helpers/mesh_cli.py b/repeater/handler_helpers/mesh_cli.py index f01bd32..de34220 100644 --- a/repeater/handler_helpers/mesh_cli.py +++ b/repeater/handler_helpers/mesh_cli.py @@ -56,7 +56,7 @@ class MeshCLI: self.config_manager.live_update_daemon(sections) return True - def _get_security_config(self): + def _get_security_config(self) -> Dict[str, Any]: """Return the repeater login security section (the one LoginHelper reads).""" security = self.repeater_config.get("security") return security if isinstance(security, dict) else {} @@ -357,9 +357,9 @@ class MeshCLI: " set flood.max Max flood hops (max 64)\n" " set path.hash.mode <0-2> Path hash mode (0=1B,1=2B,2=3B)\n" " set loop.detect Flood loop detection\n" - " set rxdelay RX delay base (>=0)\n" - " set txdelay TX delay factor (>=0)\n" - " set direct.txdelay Direct TX delay (>=0)\n" + " set rxdelay RX delay base (0-20)\n" + " set txdelay TX delay factor (0-2)\n" + " set direct.txdelay Direct TX delay (0-2)\n" " set multi.acks Multi-ack count\n" " set int.thresh Interference threshold\n" " set agc.reset.interval AGC reset (rounded to x4)" @@ -772,8 +772,8 @@ class MeshCLI: elif key == "rxdelay": delay = float(value) - if delay < 0: - return "Error: cannot be negative" + if delay < 0 or delay > 20.0: + return "Error, must be 0-20" self.config.setdefault("delays", {})["rx_delay_base"] = delay if not self._save_config_and_apply(["repeater", "delays"]): return "Error: Failed to save config" @@ -781,8 +781,8 @@ class MeshCLI: elif key == "txdelay": delay = float(value) - if delay < 0: - return "Error: cannot be negative" + if delay < 0 or delay > 2.0: + return "Error, must be 0-2" self.config.setdefault("delays", {})["tx_delay_factor"] = delay if not self._save_config_and_apply(["repeater", "delays"]): return "Error: Failed to save config" @@ -790,8 +790,8 @@ class MeshCLI: elif key == "direct.txdelay": delay = float(value) - if delay < 0: - return "Error: cannot be negative" + if delay < 0 or delay > 2.0: + return "Error, must be 0-2" self.config.setdefault("delays", {})["direct_tx_delay_factor"] = delay if not self._save_config_and_apply(["repeater", "delays"]): return "Error: Failed to save config" diff --git a/tests/test_handler_helpers_mesh_cli.py b/tests/test_handler_helpers_mesh_cli.py index 9970af1..6504176 100644 --- a/tests/test_handler_helpers_mesh_cli.py +++ b/tests/test_handler_helpers_mesh_cli.py @@ -227,9 +227,13 @@ def test_cmd_set_updates_and_validation_errors(): assert cfg["repeater"]["send_advert_interval_hours"] == 12 assert cfg["repeater"]["flood_advert_interval_hours"] == 24 assert cli._cmd_set("flood.max 100") == "Error: max 64" - assert cli._cmd_set("rxdelay -1") == "Error: cannot be negative" - assert cli._cmd_set("txdelay -1") == "Error: cannot be negative" - assert cli._cmd_set("direct.txdelay -1") == "Error: cannot be negative" + assert cli._cmd_set("rxdelay -1") == "Error, must be 0-20" + assert cli._cmd_set("rxdelay 20.5") == "Error, must be 0-20" + assert cli._cmd_set("rxdelay 20") == "OK" + assert cli._cmd_set("txdelay -1") == "Error, must be 0-2" + assert cli._cmd_set("txdelay 2.5") == "Error, must be 0-2" + assert cli._cmd_set("direct.txdelay -1") == "Error, must be 0-2" + assert cli._cmd_set("direct.txdelay 2.5") == "Error, must be 0-2" assert cli._cmd_set("agc.reset.interval 10") == "OK - interval rounded to 8" assert cli._cmd_set("bad") == "Error: Missing value" @@ -545,3 +549,60 @@ def test_cmd_get_flood_advert_interval_reads_engine_key(): cfg["repeater"]["send_advert_interval_hours"] = 6 assert cli._cmd_get("flood.advert.interval") == "> 6" + + +def test_cli_set_commands_persist_with_real_config_manager(tmp_path): + """Every CLI set command must work against the real ConfigManager bool save + contract: reply OK and leave the change on disk.""" + import yaml + + from repeater.config_manager import ConfigManager + + config_path = tmp_path / "config.yaml" + cfg = _base_config() + manager = ConfigManager(config_path=str(config_path), config=cfg, daemon_instance=None) + cli = MeshCLI(str(config_path), cfg, manager) + + commands = [ + "af 1.5", + "name node-b", + "repeat off", + "lat 10.5", + "lon -3.25", + "radio 869.618 250 9 6", + "freq 915.125", + "tx 17", + "guest.password gpw", + "owner.info Bob|Lab", + "allow.read.only on", + "advert.interval 90", + "flood.advert.interval 12", + "flood.max 8", + "path.hash.mode 1", + "loop.detect moderate", + "rxdelay 4.5", + "txdelay 1.5", + "direct.txdelay 0.25", + "multi.acks 1", + "int.thresh -110", + "agc.reset.interval 8", + ] + for command in commands: + reply = cli._cmd_set(command) + assert reply.startswith("OK"), f"set {command!r} replied {reply!r}" + + assert cli._cmd_password("password newadminpw") == "password now: newadminpw" + + saved = yaml.safe_load(config_path.read_text()) + assert saved["repeater"]["airtime_factor"] == 1.5 + assert saved["repeater"]["node_name"] == "node-b" + assert saved["repeater"]["mode"] == "monitor" + assert saved["radio"]["frequency"] == 915125000 + assert saved["radio"]["bandwidth"] == 250000 + assert saved["repeater"]["security"]["guest_password"] == "gpw" + assert saved["repeater"]["security"]["admin_password"] == "newadminpw" + assert saved["repeater"]["security"]["allow_read_only"] is True + assert saved["repeater"]["send_advert_interval_hours"] == 12 + assert saved["delays"]["rx_delay_base"] == 4.5 + assert saved["delays"]["tx_delay_factor"] == 1.5 + assert saved["delays"]["direct_tx_delay_factor"] == 0.25