From 101681fad493657d40600cfeab3508b834c924eb Mon Sep 17 00:00:00 2001 From: agessaman Date: Sun, 19 Jul 2026 07:10:20 -0700 Subject: [PATCH] fix(cli): accept radio parameters in MHz and stage them until restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- repeater/handler_helpers/mesh_cli.py | 45 +++++++++++++++----------- tests/test_handler_helpers_mesh_cli.py | 31 +++++++++++++++--- 2 files changed, 54 insertions(+), 22 deletions(-) diff --git a/repeater/handler_helpers/mesh_cli.py b/repeater/handler_helpers/mesh_cli.py index 4537676..5d289d2 100644 --- a/repeater/handler_helpers/mesh_cli.py +++ b/repeater/handler_helpers/mesh_cli.py @@ -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. diff --git a/tests/test_handler_helpers_mesh_cli.py b/tests/test_handler_helpers_mesh_cli.py index febebc1..881a816 100644 --- a/tests/test_handler_helpers_mesh_cli.py +++ b/tests/test_handler_helpers_mesh_cli.py @@ -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()