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 @@
Live CPU, memory, temperature, disk, and uptime for this Raspberry Pi.
+Turn the WiFi radio on or off. Full WiFi setup (scan, connect, hotspot) lives on the WiFi tab.
+WiFi radio
+Checking current state…
+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.