From a725dfbe85c3a26b6981dfffb8552d8871c04d2c Mon Sep 17 00:00:00 2001 From: Thibault VINCENT Date: Fri, 21 Aug 2026 22:16:22 +0200 Subject: [PATCH] fix(api): persist the mode selected through set_mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mode selector applied its choice to the shared config dict — which the engine reads live, so the switch felt effective — but nothing ever wrote it to disk: a restart silently reverted the node to its previous mode. The web UI is blameless; it calls /api/set_mode expecting it to persist (SetCommand.ts even documents that expectation), and the RF admin path already persists the same change through _apply_config_update. set_mode now saves through config_manager.update_and_save, the same path the settings endpoints use, and reports persisted: true. If the save fails the live switch stands — matching the old behaviour — but the response is a loud error instead of a silent future loss. Signed-off-by: Thibault VINCENT --- repeater/web/api_endpoints.py | 12 +++++++++++- tests/test_api_endpoints_core_coverage.py | 21 ++++++++++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/repeater/web/api_endpoints.py b/repeater/web/api_endpoints.py index fa6b480..59641a7 100644 --- a/repeater/web/api_endpoints.py +++ b/repeater/web/api_endpoints.py @@ -1983,8 +1983,18 @@ class APIEndpoints: if "repeater" not in self.config: self.config["repeater"] = {} self.config["repeater"]["mode"] = new_mode + + # The engine reads the mode live from the shared config dict; + # update_and_save writes it to disk so the choice survives a + # restart. + result = self.config_manager.update_and_save( + updates={}, live_update=True, live_update_sections=["repeater"] + ) + if not result.get("saved", False): + return self._error(result.get("error", "Failed to save configuration to file")) + logger.info(f"Mode changed to: {new_mode}") - return {"success": True, "mode": new_mode} + return {"success": True, "mode": new_mode, "persisted": True} except cherrypy.HTTPError: # Re-raise HTTP errors (like 405 Method Not Allowed) without logging raise diff --git a/tests/test_api_endpoints_core_coverage.py b/tests/test_api_endpoints_core_coverage.py index 9aed860..f84a267 100644 --- a/tests/test_api_endpoints_core_coverage.py +++ b/tests/test_api_endpoints_core_coverage.py @@ -1726,10 +1726,14 @@ def test_set_mode_and_set_duty_cycle_paths(cherrypy_ctx): invalid = api.set_mode() assert invalid["success"] is False + api.config_manager.update_and_save.return_value = {"saved": True} request.json = {"mode": "monitor"} ok = api.set_mode() - assert ok == {"success": True, "mode": "monitor"} + assert ok == {"success": True, "mode": "monitor", "persisted": True} assert api.config["repeater"]["mode"] == "monitor" + api.config_manager.update_and_save.assert_called_once_with( + updates={}, live_update=True, live_update_sections=["repeater"] + ) request.json = {"enabled": False} duty = api.set_duty_cycle() @@ -1737,6 +1741,21 @@ def test_set_mode_and_set_duty_cycle_paths(cherrypy_ctx): assert api.config["duty_cycle"]["enforcement_enabled"] is False +def test_set_mode_save_failure_reports_error(cherrypy_ctx): + request, _ = cherrypy_ctx + api = _make_api({"repeater": {}}) + api.config_manager.update_and_save.return_value = {"saved": False, "error": "disk full"} + + request.method = "POST" + request.json = {"mode": "no_tx"} + resp = api.set_mode() + assert resp["success"] is False + assert "disk full" in resp["error"] + # The live switch already happened on the shared dict; only the + # persistence failed, and the error says so instead of a silent loss. + assert api.config["repeater"]["mode"] == "no_tx" + + def test_update_duty_cycle_config_branches(cherrypy_ctx): request, _ = cherrypy_ctx api = _make_api({"duty_cycle": {}})