fix(cli): clamp delay factors to the firmware ranges

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.
This commit is contained in:
agessaman
2026-07-19 07:11:24 -07:00
parent 6db8ac97f5
commit f19421ee6c
2 changed files with 74 additions and 13 deletions
+10 -10
View File
@@ -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 <hops> 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 <off|minimal|moderate|strict> Flood loop detection\n"
" set rxdelay <val> RX delay base (>=0)\n"
" set txdelay <val> TX delay factor (>=0)\n"
" set direct.txdelay <val> Direct TX delay (>=0)\n"
" set rxdelay <val> RX delay base (0-20)\n"
" set txdelay <val> TX delay factor (0-2)\n"
" set direct.txdelay <val> Direct TX delay (0-2)\n"
" set multi.acks <n> Multi-ack count\n"
" set int.thresh <dbm> Interference threshold\n"
" set agc.reset.interval <n> 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"
+64 -3
View File
@@ -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