fix(cli): accept radio parameters in MHz and stage them until restart

set freq and set radio stored the CLI's MHz/kHz inputs directly into
radio.frequency and radio.bandwidth, which the rest of the stack treats
as Hz — a freq change tuned the radio to a few hundred hertz. Convert to
Hz on write and validate set radio with the firmware gate (freq 150-2500,
bw 7-500, sf 5-12, cr 5-8, same error string; set freq stays unvalidated
like firmware). Radio changes are now saved without a live apply so the
reply's restart-to-apply contract is real: a live retune would cut off
the remote admin mid-session, and the all-or-nothing live radio path
would also have dragged staged frequency changes along with a tx tweak.
This commit is contained in:
agessaman
2026-07-19 07:10:20 -07:00
parent eae42d9e19
commit 101681fad4
2 changed files with 54 additions and 22 deletions
+27 -18
View File
@@ -45,7 +45,8 @@ class MeshCLI:
``ConfigManager.save_to_file`` returns a bare bool; the tuple form is
tolerated for older manager doubles. Live update only runs after a
successful save so a failed write never half-applies a change. Pass
no sections to stage a change: saved to disk, applied on restart.
no sections to stage a change: saved to disk, applied on restart
(radio parameters, matching firmware's reboot-to-apply).
"""
result = self.config_manager.save_to_file()
saved = result[0] if isinstance(result, tuple) else bool(result)
@@ -660,37 +661,45 @@ class MeshCLI:
return "OK"
elif key == "radio":
# Format: freq bw sf cr
# Format: freq(MHz) bw(kHz) sf cr — the config stores Hz.
radio_parts = value.split()
if len(radio_parts) != 4:
return "Error: Expected freq bw sf cr"
if "radio" not in self.config:
self.config["radio"] = {}
freq_mhz = float(radio_parts[0])
bw_khz = float(radio_parts[1])
sf = int(radio_parts[2])
cr = int(radio_parts[3])
if not (
150.0 <= freq_mhz <= 2500.0
and 7.0 <= bw_khz <= 500.0
and 5 <= sf <= 12
and 5 <= cr <= 8
):
return "Error, invalid radio params"
self.config["radio"]["frequency"] = float(radio_parts[0])
self.config["radio"]["bandwidth"] = float(radio_parts[1])
self.config["radio"]["spreading_factor"] = int(radio_parts[2])
self.config["radio"]["coding_rate"] = int(radio_parts[3])
if not self._save_config_and_apply(["radio"]):
radio_config = self.config.setdefault("radio", {})
radio_config["frequency"] = int(round(freq_mhz * 1_000_000))
radio_config["bandwidth"] = int(round(bw_khz * 1_000))
radio_config["spreading_factor"] = sf
radio_config["coding_rate"] = cr
if not self._save_config_and_apply():
return "Error: Failed to save config"
return "OK - restart repeater to apply"
elif key == "freq":
if "radio" not in self.config:
self.config["radio"] = {}
self.config["radio"]["frequency"] = float(value)
if not self._save_config_and_apply(["radio"]):
# CLI input is MHz (firmware parity); the config stores Hz.
freq_mhz = float(value)
self.config.setdefault("radio", {})["frequency"] = int(round(freq_mhz * 1_000_000))
if not self._save_config_and_apply():
return "Error: Failed to save config"
return "OK - restart repeater to apply"
elif key == "tx":
if "radio" not in self.config:
self.config["radio"] = {}
self.config["radio"]["tx_power"] = int(value)
if not self._save_config_and_apply(["radio"]):
self.config.setdefault("radio", {})["tx_power"] = int(value)
if not self._save_config_and_apply():
return "Error: Failed to save config"
return "OK"
return "OK - restart repeater to apply"
elif key == "guest.password":
# LoginHelper authenticates from repeater.security.guest_password.
+27 -4
View File
@@ -189,11 +189,19 @@ def test_cmd_set_updates_and_validation_errors():
assert cli._cmd_set("repeat off").endswith("OFF")
assert cfg["repeater"]["mode"] == "monitor"
assert cli._cmd_set("radio 900000000 250000 9 6").startswith("OK")
assert cfg["radio"]["frequency"] == 900000000.0
# CLI input is MHz/kHz (firmware parity); the config stores Hz.
assert cli._cmd_set("radio 900 250 9 6").startswith("OK")
assert cfg["radio"]["frequency"] == 900000000
assert cfg["radio"]["bandwidth"] == 250000
assert cfg["radio"]["spreading_factor"] == 9
assert cfg["radio"]["coding_rate"] == 6
assert cli._cmd_set("radio 100 250 9 6") == "Error, invalid radio params"
assert cli._cmd_set("radio 900 250 4 6") == "Error, invalid radio params"
assert cli._cmd_set("freq 868000000").startswith("OK")
assert cli._cmd_set("tx 17") == "OK"
assert cli._cmd_set("freq 868.5").startswith("OK")
assert cfg["radio"]["frequency"] == 868500000
assert cli._cmd_set("tx 17") == "OK - restart repeater to apply"
assert cfg["radio"]["tx_power"] == 17
assert cli._cmd_set("guest.password g") == "OK"
assert cfg["repeater"]["security"]["guest_password"] == "g"
assert cfg["security"]["guest_password"] == "stale-top-level"
@@ -505,3 +513,18 @@ def test_cli_password_change_applies_to_live_repeater_acl(tmp_path):
assert cli._cmd_set("allow.read.only off") == "OK"
assert acl.allow_read_only is False
def test_cmd_set_radio_commands_stage_without_live_apply():
"""Radio changes match firmware reboot-to-apply: saved to disk, never
live-applied (a live retune would cut off the admin mid-session)."""
cfg = _base_config()
mgr = _cfg_mgr()
cli = MeshCLI("/tmp/cfg.yaml", cfg, mgr)
assert cli._cmd_set("radio 900 250 9 6") == "OK - restart repeater to apply"
assert cli._cmd_set("freq 868.5") == "OK - restart repeater to apply"
assert cli._cmd_set("tx 17") == "OK - restart repeater to apply"
assert mgr.save_to_file.call_count == 3
mgr.live_update_daemon.assert_not_called()