From 2d065309b05d5c1acd8386e01812db4a45ae1c4e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 11:31:51 +0000 Subject: [PATCH 1/3] Add system diagnostics, power controls, and WiFi radio toggle to Tools tab Expands the web UI Tools tab with safe, purpose-built controls so users can manage the Pi without SSHing in, instead of an arbitrary-command terminal (the web UI has no auth and CSRF is disabled, so a shell would be unsafe). - System Diagnostics card: renders the existing but previously-unused GET /api/v3/system/status endpoint (CPU, memory, temp, disk, uptime), with a manual refresh and a 10s poll. - System Power section: reboot/shutdown buttons wired to the existing reboot_system / shutdown_system actions, behind a confirm step, with a dedicated powerAction() helper that treats the dropped connection as the expected "going offline" outcome rather than an error. - Network Radio section: WiFi on/off toggle backed by new GET/POST /api/v3/wifi/radio endpoints and WiFiManager.set_wifi_radio() / get_wifi_radio_state(). Disabling WiFi is refused unless a wired connection is present (reusing the existing lockout guards), with an explicit force-off confirmation for advanced users. No new privileged commands: uses nmcli radio wifi on|off (already sudo-allowlisted) and the existing reboot/poweroff grants, so the sudoers-alignment guard test stays green. Bluetooth toggle intentionally omitted since the installer removes the BlueZ stack for LED timing. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019X3he87Ggr1qt8y7mnFeuE --- src/wifi_manager.py | 88 ++++- web_interface/blueprints/api_v3.py | 63 ++++ .../templates/v3/partials/tools.html | 321 ++++++++++++++++++ 3 files changed, 471 insertions(+), 1 deletion(-) diff --git a/src/wifi_manager.py b/src/wifi_manager.py index a4bb2f4eb..d3886e7c0 100644 --- a/src/wifi_manager.py +++ b/src/wifi_manager.py @@ -1883,7 +1883,93 @@ def _ensure_wifi_radio_enabled(self, max_retries: int = 3) -> bool: logger.warning(f"Failed to enable WiFi radio after {max_retries} attempts") return False - + + def get_wifi_radio_state(self) -> Dict: + """ + Report whether the WiFi radio is currently enabled, plus whether a wired + fallback exists. Used by the web UI's radio toggle so it can warn before + an action that could disconnect the browser. + + Returns: + { + 'enabled': Optional[bool], # True/False, or None if undeterminable + 'ethernet_connected': bool, # wired fallback present + 'available': bool, # nmcli present / radio state readable + } + """ + ethernet_connected = self._is_ethernet_connected() + enabled: Optional[bool] = None + available = False + try: + result = subprocess.run( + ["nmcli", "radio", "wifi"], + capture_output=True, + text=True, + timeout=5 + ) + if result.returncode == 0: + status = result.stdout.strip().lower() + if status in ("enabled", "disabled"): + enabled = status == "enabled" + available = True + except Exception as e: + logger.debug(f"Could not read WiFi radio state: {e}") + return { + 'enabled': enabled, + 'ethernet_connected': ethernet_connected, + 'available': available, + } + + def set_wifi_radio(self, enabled: bool, force: bool = False) -> Tuple[bool, str]: + """ + Turn the WiFi radio on or off. + + Turning the radio OFF from the web interface is dangerous: if the device + is reachable only over WiFi, disabling it disconnects the very page that + issued the request. To prevent that lockout, disabling is refused unless a + wired (Ethernet) fallback is present, or the caller explicitly passes + force=True to acknowledge the risk. + + Enabling reuses the hardened _ensure_wifi_radio_enabled() path (handles + rfkill soft-blocks + retries). Both directions rely only on + `nmcli radio wifi on|off`, which is already covered by the passwordless + sudoers allowlist (configure_wifi_permissions.sh) — no new privileged + command is introduced. + + Returns: + (success, human-readable message) + """ + if enabled: + if self._ensure_wifi_radio_enabled(): + return True, "WiFi radio enabled." + return False, "Failed to enable WiFi radio. Check logs for details." + + # Disabling — guard against locking the user out of the web interface. + if not force and not self._is_ethernet_connected(): + return False, ( + "Refusing to disable WiFi: no wired (Ethernet) connection was " + "detected, so turning off WiFi would disconnect you from this " + "page. Connect Ethernet first, or force it if you're sure." + ) + + try: + result = subprocess.run( + ["sudo", "nmcli", "radio", "wifi", "off"], + capture_output=True, + text=True, + timeout=10 + ) + if result.returncode == 0: + logger.info("WiFi radio disabled via web interface (force=%s)", force) + return True, "WiFi radio disabled." + logger.warning("Failed to disable WiFi radio: %s", result.stderr.strip()) + return False, "Failed to disable WiFi radio. Check logs for details." + except subprocess.TimeoutExpired: + return False, "Command timed out while disabling WiFi radio." + except Exception as e: + logger.error("Error disabling WiFi radio: %s", e) + return False, "An error occurred while disabling WiFi radio." + def enable_ap_mode(self, force: bool = False) -> Tuple[bool, str]: """ Enable access point mode diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index 0425590ee..0fd19b72a 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -7184,6 +7184,69 @@ def set_auto_enable_ap_mode(): 'message': 'An error occurred; see logs for details' }), 500 +@api_v3.route('/wifi/radio', methods=['GET']) +def get_wifi_radio(): + """Get current WiFi radio state (enabled/disabled) and wired-fallback status.""" + try: + from src.wifi_manager import WiFiManager + + wifi_manager = WiFiManager() + state = wifi_manager.get_wifi_radio_state() + + return jsonify({ + 'status': 'success', + 'data': state + }) + except Exception as e: + logger.error("Error getting WiFi radio state", exc_info=True) + return jsonify({ + 'status': 'error', + 'message': 'An error occurred; see logs for details' + }), 500 + +@api_v3.route('/wifi/radio', methods=['POST']) +def set_wifi_radio(): + """Turn the WiFi radio on or off. + + Body: {"enabled": bool, "force": bool (optional)}. Disabling is refused + unless Ethernet is connected or force=True, to avoid locking the user out + of this web interface. + """ + try: + from src.wifi_manager import WiFiManager + + data = request.get_json(silent=True) or {} + if 'enabled' not in data: + return jsonify({ + 'status': 'error', + 'message': 'enabled is required' + }), 400 + + enabled = bool(data['enabled']) + _force_raw = data.get('force', False) + force = _force_raw is True or (isinstance(_force_raw, str) and _force_raw.lower() in ('true', '1', 'yes')) + + wifi_manager = WiFiManager() + success, message = wifi_manager.set_wifi_radio(enabled, force=force) + + if success: + return jsonify({ + 'status': 'success', + 'message': message, + 'data': wifi_manager.get_wifi_radio_state() + }) + else: + return jsonify({ + 'status': 'error', + 'message': message + }), 400 + except Exception as e: + logger.error("Error setting WiFi radio state", exc_info=True) + return jsonify({ + 'status': 'error', + 'message': 'An error occurred; see logs for details' + }), 500 + @api_v3.route('/cache/list', methods=['GET']) def list_cache_files(): """List all cache files with metadata""" diff --git a/web_interface/templates/v3/partials/tools.html b/web_interface/templates/v3/partials/tools.html index 7ac8cf878..636d0353b 100644 --- a/web_interface/templates/v3/partials/tools.html +++ b/web_interface/templates/v3/partials/tools.html @@ -1,5 +1,23 @@
+ +
+
+
+

System Diagnostics

+

Live CPU, memory, temperature, disk, and uptime for this Raspberry Pi.

+
+ +
+ +
+
Loading diagnostics…
+
+
+
@@ -123,6 +141,46 @@

Power Supply

+ +
+
+

Network Radio

+

Turn the WiFi radio on or off. Full WiFi setup (scan, connect, hotspot) lives on the WiFi tab.

+
+ +
+
+

WiFi radio

+

Checking current state…

+
+
+ + + + + +
+
+ +
+
@@ -157,6 +215,68 @@

Services

+ +
+
+

System Power

+

Reboot or shut down the Raspberry Pi. The web interface will go offline.

+
+ +
+ +
+
+

Reboot

+

Runs sudo reboot. The Pi will restart and come back online in a minute or two.

+
+
+ + +
+
+ + + +
+
+

Shut down

+

Runs sudo poweroff. The Pi will power off and must be unplugged/replugged (or power-cycled) to turn back on.

+
+
+ + +
+
+ +
+
+
From b4fc65db4fe1f0f9af6ac602f5a61c9ba5ade32f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 11:43:45 +0000 Subject: [PATCH 2/3] Harden WiFi radio endpoint and diagnostics poll (review feedback) Addresses code review feedback on the Tools tab additions: - api_v3.py: parse the `enabled` POST field with the same string-aware coercion as `force`. A plain bool() cast turned {"enabled":"false"} into True (enabling instead of disabling) for any non-UI API caller. - wifi_manager.set_wifi_radio() now returns a reason code alongside (success, message); the /wifi/radio error response includes it. The Tools UI only shows the force-off confirmation when reason == 'no_ethernet', so a genuine nmcli failure surfaces its real error instead of a misleading "no wired connection" prompt that would just retry into the same failure. - tools.html: gate the 10s diagnostics poll on panel visibility (document.hidden / offsetParent), so switching to another tab stops the recurring /api/v3/system/status calls instead of churning the Pi off-screen. The initial load and manual Refresh remain unconditional. Verified: {"enabled":"false"} now disables (refused w/ reason:no_ethernet), {"enabled":"true"} enables; inline JS passes node --check; py_compile clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019X3he87Ggr1qt8y7mnFeuE --- src/wifi_manager.py | 21 +++++++++++-------- web_interface/blueprints/api_v3.py | 11 +++++++--- .../templates/v3/partials/tools.html | 15 +++++++++---- 3 files changed, 31 insertions(+), 16 deletions(-) diff --git a/src/wifi_manager.py b/src/wifi_manager.py index d3886e7c0..cc725906b 100644 --- a/src/wifi_manager.py +++ b/src/wifi_manager.py @@ -1920,7 +1920,7 @@ def get_wifi_radio_state(self) -> Dict: 'available': available, } - def set_wifi_radio(self, enabled: bool, force: bool = False) -> Tuple[bool, str]: + def set_wifi_radio(self, enabled: bool, force: bool = False) -> Tuple[bool, str, Optional[str]]: """ Turn the WiFi radio on or off. @@ -1937,12 +1937,15 @@ def set_wifi_radio(self, enabled: bool, force: bool = False) -> Tuple[bool, str] command is introduced. Returns: - (success, human-readable message) + (success, human-readable message, reason_code). reason_code is + 'no_ethernet' when a disable is refused for lockout safety, or a + short failure code otherwise; None on success. The web UI keys on + 'no_ethernet' to decide whether to offer a force-off prompt. """ if enabled: if self._ensure_wifi_radio_enabled(): - return True, "WiFi radio enabled." - return False, "Failed to enable WiFi radio. Check logs for details." + return True, "WiFi radio enabled.", None + return False, "Failed to enable WiFi radio. Check logs for details.", 'enable_failed' # Disabling — guard against locking the user out of the web interface. if not force and not self._is_ethernet_connected(): @@ -1950,7 +1953,7 @@ def set_wifi_radio(self, enabled: bool, force: bool = False) -> Tuple[bool, str] "Refusing to disable WiFi: no wired (Ethernet) connection was " "detected, so turning off WiFi would disconnect you from this " "page. Connect Ethernet first, or force it if you're sure." - ) + ), 'no_ethernet' try: result = subprocess.run( @@ -1961,14 +1964,14 @@ def set_wifi_radio(self, enabled: bool, force: bool = False) -> Tuple[bool, str] ) if result.returncode == 0: logger.info("WiFi radio disabled via web interface (force=%s)", force) - return True, "WiFi radio disabled." + return True, "WiFi radio disabled.", None logger.warning("Failed to disable WiFi radio: %s", result.stderr.strip()) - return False, "Failed to disable WiFi radio. Check logs for details." + return False, "Failed to disable WiFi radio. Check logs for details.", 'command_failed' except subprocess.TimeoutExpired: - return False, "Command timed out while disabling WiFi radio." + return False, "Command timed out while disabling WiFi radio.", 'timeout' except Exception as e: logger.error("Error disabling WiFi radio: %s", e) - return False, "An error occurred while disabling WiFi radio." + return False, "An error occurred while disabling WiFi radio.", 'error' def enable_ap_mode(self, force: bool = False) -> Tuple[bool, str]: """ diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index 0fd19b72a..78fed288e 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -7222,12 +7222,16 @@ def set_wifi_radio(): 'message': 'enabled is required' }), 400 - enabled = bool(data['enabled']) + # Parse defensively: bool("false") is True, so mirror the string-aware + # coercion used for `force` — the endpoint is a public contract, not just + # the shipped UI (which always sends real JSON booleans). + _enabled_raw = data['enabled'] + enabled = _enabled_raw is True or (isinstance(_enabled_raw, str) and _enabled_raw.lower() in ('true', '1', 'yes')) _force_raw = data.get('force', False) force = _force_raw is True or (isinstance(_force_raw, str) and _force_raw.lower() in ('true', '1', 'yes')) wifi_manager = WiFiManager() - success, message = wifi_manager.set_wifi_radio(enabled, force=force) + success, message, reason = wifi_manager.set_wifi_radio(enabled, force=force) if success: return jsonify({ @@ -7238,7 +7242,8 @@ def set_wifi_radio(): else: return jsonify({ 'status': 'error', - 'message': message + 'message': message, + 'reason': reason }), 400 except Exception as e: logger.error("Error setting WiFi radio state", exc_info=True) diff --git a/web_interface/templates/v3/partials/tools.html b/web_interface/templates/v3/partials/tools.html index 636d0353b..befd690e4 100644 --- a/web_interface/templates/v3/partials/tools.html +++ b/web_interface/templates/v3/partials/tools.html @@ -679,7 +679,7 @@

System Power

if (ok && d.status === 'success') { if (d.data) renderWifiRadio(d.data); else window.loadWifiRadio(); showResult('result-wifi-radio', true, d.message || 'Done'); - } else if (!enabled && !force) { + } else if (!enabled && !force && d.reason === 'no_ethernet') { // Disable refused for safety (no wired fallback) — offer the force path. showWifiForceRow(); window.loadWifiRadio(); @@ -712,11 +712,18 @@

System Power

// Load on first render; HTMX will have already swapped us in by this point. loadGitInfo(); - // System diagnostics: load now, then refresh every 10s while this tab is - // mounted. Clear any prior interval so re-swapping the partial doesn't stack. + // System diagnostics: load now, then refresh every 10s. Clear any prior + // interval so re-swapping the partial doesn't stack. The recurring poll is + // gated on visibility — the partial stays in the DOM (hidden via x-show) + // when another tab is active, so without this it would keep hitting + // /api/v3/system/status every 10s and churn the Pi while off-screen. if (window._diagPollInterval) clearInterval(window._diagPollInterval); window.loadSystemDiagnostics(); - window._diagPollInterval = setInterval(window.loadSystemDiagnostics, 10000); + window._diagPollInterval = setInterval(function () { + const panel = document.getElementById('diag-panel'); + if (!panel || document.hidden || panel.offsetParent === null) return; + window.loadSystemDiagnostics(); + }, 10000); // WiFi radio current state. window.loadWifiRadio(); From a75d349c21c2485595973e617c1b87160fb06a3f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 12:53:59 +0000 Subject: [PATCH 3/3] Narrow WiFi-disable fallback except and add stack trace (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses a CodeRabbit nitpick: the fallback handler in set_wifi_radio()'s disable path caught bare Exception and logged without a traceback. Narrow it to (OSError, subprocess.SubprocessError) — the errors subprocess.run realistically raises — and log with exc_info=True for full context on the Pi. Anything genuinely unexpected now propagates to the endpoint's outer handler (500), matching the codebase's specific-exception convention. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019X3he87Ggr1qt8y7mnFeuE --- src/wifi_manager.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wifi_manager.py b/src/wifi_manager.py index cc725906b..0983ff3f8 100644 --- a/src/wifi_manager.py +++ b/src/wifi_manager.py @@ -1969,8 +1969,8 @@ def set_wifi_radio(self, enabled: bool, force: bool = False) -> Tuple[bool, str, return False, "Failed to disable WiFi radio. Check logs for details.", 'command_failed' except subprocess.TimeoutExpired: return False, "Command timed out while disabling WiFi radio.", 'timeout' - except Exception as e: - logger.error("Error disabling WiFi radio: %s", e) + except (OSError, subprocess.SubprocessError) as e: + logger.error("Error disabling WiFi radio: %s", e, exc_info=True) return False, "An error occurred while disabling WiFi radio.", 'error' def enable_ap_mode(self, force: bool = False) -> Tuple[bool, str]: