diff --git a/src/wifi_manager.py b/src/wifi_manager.py index a4bb2f4e..0983ff3f 100644 --- a/src/wifi_manager.py +++ b/src/wifi_manager.py @@ -1883,7 +1883,96 @@ 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, Optional[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, 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.", 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(): + 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." + ), 'no_ethernet' + + 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.", None + logger.warning("Failed to disable WiFi radio: %s", result.stderr.strip()) + 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 (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]: """ Enable access point mode diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index e0603766..1cf9d185 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -7252,6 +7252,74 @@ 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 + + # 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, reason = 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, + 'reason': reason + }), 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 83cf51b7..b552e93f 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.

+
+
+ + +
+
+ +
+
+
@@ -429,6 +549,198 @@

Plugin Health

}); } + // ── system diagnostics panel ────────────────────────────────────────────── + // Reads the existing /api/v3/system/status JSON endpoint (10s cached) for + // richer metrics (disk, uptime, memory MB) than the SSE stats stream carries. + + function diagTile(icon, iconColor, label, value, sub) { + return ` +
+
+
+
+
${escHtml(label)}
+
${escHtml(value)}
+ ${sub ? `
${escHtml(sub)}
` : ''} +
+
+
`; + } + + window.loadSystemDiagnostics = function() { + const panel = document.getElementById('diag-panel'); + if (!panel) return; + + fetch('/api/v3/system/status') + .then(r => { + if (!r.ok) return r.json() + .then(d => Promise.reject(d.message || `HTTP ${r.status}`)) + .catch(() => Promise.reject(`HTTP ${r.status}`)); + return r.json(); + }) + .then(res => { + const d = (res && res.data) || {}; + const mUsedGb = d.memory_used_mb != null ? (d.memory_used_mb / 1024).toFixed(1) : null; + const mTotGb = d.memory_total_mb != null ? (d.memory_total_mb / 1024).toFixed(1) : null; + const temp = d.cpu_temp != null ? d.cpu_temp + '°C' : 'N/A'; + panel.innerHTML = + diagTile('fa-microchip', 'text-blue-600', 'CPU Usage', + (d.cpu_percent != null ? d.cpu_percent : '--') + '%', null) + + diagTile('fa-memory', 'text-green-600', 'Memory', + (d.memory_used_percent != null ? d.memory_used_percent : '--') + '%', + (mUsedGb && mTotGb) ? `${mUsedGb} / ${mTotGb} GB` : null) + + diagTile('fa-thermometer-half', 'text-red-600', 'CPU Temp', temp, null) + + diagTile('fa-hdd', 'text-indigo-600', 'Disk', + (d.disk_used_percent != null ? d.disk_used_percent : '--') + '%', + (d.disk_used_gb != null && d.disk_total_gb != null) ? `${d.disk_used_gb} / ${d.disk_total_gb} GB` : null) + + diagTile('fa-clock', 'text-purple-600', 'Uptime', d.uptime || '--', null) + + diagTile('fa-desktop', d.service_active ? 'text-green-600' : 'text-gray-400', + 'Display Service', d.service_active ? 'Active' : 'Inactive', null); + }) + .catch(err => { + panel.innerHTML = `
Diagnostics unavailable: ${escHtml(String(err))}
`; + }); + }; + + // ── system power (reboot / shutdown) ────────────────────────────────────── + // Like toolsAction, but a dropped connection is the expected, successful + // outcome (the Pi is going down), so it is reported as info, not an error. + + window.powerAction = function(action, btnId, resultId, offlineMsg) { + setBusy(btnId, true); + const el = document.getElementById(resultId); + if (el) el.classList.add('hidden'); + + fetch('/api/v3/system/action', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({action}) + }) + .then(r => r.json().catch(() => ({status: 'success'}))) + .then(data => { + if (data.status === 'error') { + showResult(resultId, false, data.message || 'Command failed'); + } else { + showResult(resultId, true, offlineMsg); + } + }) + .catch(() => { + // Connection dropped mid-request — expected when the Pi reboots/powers off. + showResult(resultId, true, offlineMsg); + }) + .finally(() => setBusy(btnId, false)); + }; + + window.showPowerConfirm = function(kind) { + document.getElementById(kind + '-confirm-row').classList.remove('hidden'); + document.getElementById('btn-' + kind).classList.add('hidden'); + }; + window.hidePowerConfirm = function(kind) { + document.getElementById(kind + '-confirm-row').classList.add('hidden'); + document.getElementById('btn-' + kind).classList.remove('hidden'); + }; + + // ── WiFi radio toggle ───────────────────────────────────────────────────── + + function renderWifiRadio(state) { + const toggle = document.getElementById('wifi-radio-toggle'); + const knob = document.getElementById('wifi-radio-knob'); + const note = document.getElementById('wifi-radio-note'); + if (!toggle || !knob || !note) return; + + const setKnob = (on) => { + if (on) { + knob.classList.remove('translate-x-0'); knob.classList.add('translate-x-5'); + toggle.classList.remove('bg-gray-200'); toggle.classList.add('bg-blue-600'); + } else { + knob.classList.remove('translate-x-5'); knob.classList.add('translate-x-0'); + toggle.classList.remove('bg-blue-600'); toggle.classList.add('bg-gray-200'); + } + }; + + if (!state || state.available === false) { + toggle.disabled = true; + toggle.classList.add('opacity-60'); + toggle.setAttribute('aria-checked', 'false'); + setKnob(false); + note.textContent = 'WiFi radio control is not available on this system (nmcli not found).'; + note.className = 'text-xs text-gray-500 mt-0.5'; + return; + } + + const on = state.enabled === true; + toggle.disabled = false; + toggle.classList.remove('opacity-60'); + toggle.setAttribute('aria-checked', on ? 'true' : 'false'); + setKnob(on); + const eth = state.ethernet_connected + ? 'Wired connection detected — safe to turn WiFi off.' + : 'No wired connection — turning WiFi off will disconnect this page.'; + note.textContent = `Radio is ${on ? 'on' : 'off'}. ${eth}`; + note.className = 'text-xs mt-0.5 ' + (state.ethernet_connected ? 'text-gray-500' : 'text-amber-600'); + } + + window.loadWifiRadio = function() { + fetch('/api/v3/wifi/radio') + .then(r => r.json()) + .then(res => renderWifiRadio(res && res.data)) + .catch(() => renderWifiRadio(null)); + }; + + window.onWifiRadioToggleClick = function() { + const toggle = document.getElementById('wifi-radio-toggle'); + if (!toggle || toggle.disabled) return; + const currentlyOn = toggle.getAttribute('aria-checked') === 'true'; + setWifiRadio(!currentlyOn, false); + }; + + function setWifiRadio(enabled, force) { + const toggle = document.getElementById('wifi-radio-toggle'); + const resultEl = document.getElementById('result-wifi-radio'); + if (toggle) toggle.disabled = true; + if (resultEl) resultEl.classList.add('hidden'); + hideWifiForceRow(); + + fetch('/api/v3/wifi/radio', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({enabled, force}) + }) + .then(r => r.json().then(d => ({ok: r.ok, d}))) + .then(({ok, d}) => { + 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 && d.reason === 'no_ethernet') { + // Disable refused for safety (no wired fallback) — offer the force path. + showWifiForceRow(); + window.loadWifiRadio(); + } else { + showResult('result-wifi-radio', false, d.message || 'Failed to change WiFi radio.'); + window.loadWifiRadio(); + } + }) + .catch(err => { + showResult('result-wifi-radio', false, 'Request failed: ' + err.message); + window.loadWifiRadio(); + }) + .finally(() => { if (toggle) toggle.disabled = false; }); + } + + window.forceDisableWifiRadio = function() { + hideWifiForceRow(); + setWifiRadio(false, true); + }; + + function showWifiForceRow() { + const row = document.getElementById('wifi-radio-force-row'); + if (row) { row.classList.remove('hidden'); row.classList.add('flex'); } + } + window.hideWifiForceRow = function() { + const row = document.getElementById('wifi-radio-force-row'); + if (row) { row.classList.add('hidden'); row.classList.remove('flex'); } + }; + // ── plugin health panel ────────────────────────────────────────────────── function phEscape(s) { return String(s).replace(/[&<>"']/g, function (c) { @@ -498,11 +810,29 @@

Plugin Health

// Load on first render; HTMX will have already swapped us in by this point. loadGitInfo(); + + // Plugin health: initial load + periodic refresh. Guard against duplicate + // timers if this partial is re-swapped in by HTMX; the handler re-resolves + // DOM nodes by id each tick. refreshPluginHealth(false); - // Refresh periodically. Guard against duplicate timers if this partial is - // re-swapped in by HTMX; the handler re-resolves DOM nodes by id each tick. if (!window._pluginHealthTimer) { window._pluginHealthTimer = setInterval(function () { refreshPluginHealth(true); }, 15000); } + + // 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(function () { + const panel = document.getElementById('diag-panel'); + if (!panel || document.hidden || panel.offsetParent === null) return; + window.loadSystemDiagnostics(); + }, 10000); + + // WiFi radio current state. + window.loadWifiRadio(); })();