"""WiFi manager for network configuration and mode switching.""" import asyncio import logging import re import subprocess from dataclasses import dataclass from enum import Enum from typing import List, Optional, Dict from pathlib import Path from .. import config logger = logging.getLogger(__name__) # Persistent storage for WiFi mode WIFI_MODE_FILE = Path("/opt/dangerous-pi/data/wifi_mode") WIFI_DATA_DIR = Path("/opt/dangerous-pi/data") class WiFiMode(str, Enum): """WiFi operation modes.""" AP = "ap" # Access Point only CLIENT = "client" # Client mode only DUAL = "dual" # AP + Client (requires USB WiFi) AUTO = "auto" # Automatically choose best mode OFF = "off" # WiFi disabled @dataclass class WiFiInterface: """WiFi interface information.""" name: str mac: str is_usb: bool is_up: bool connected: bool ssid: Optional[str] = None ip_address: Optional[str] = None mode: str = "managed" # "AP" or "managed" (client) @dataclass class WiFiNetwork: """Available WiFi network.""" ssid: str bssid: str signal_strength: int frequency: int encrypted: bool in_use: bool = False @dataclass class WiFiStatus: """Current WiFi status.""" mode: WiFiMode interfaces: List[WiFiInterface] current_ssid: Optional[str] current_ip: Optional[str] ap_ssid: Optional[str] ap_ip: Optional[str] supports_dual: bool class WiFiManager: """Manages WiFi configuration and mode switching.""" def __init__(self): """Initialize WiFi manager.""" self._current_mode = WiFiMode.AUTO self._interfaces: List[WiFiInterface] = [] self._supports_dual = False self._startup_applied = False def _save_mode(self, mode: WiFiMode) -> bool: """Save WiFi mode to persistent storage. Args: mode: WiFi mode to save Returns: True if saved successfully """ try: # Ensure data directory exists WIFI_DATA_DIR.mkdir(parents=True, exist_ok=True) # Write mode to file WIFI_MODE_FILE.write_text(mode.value) logger.info(f"WiFi mode saved: {mode.value}") return True except Exception as e: logger.error(f"Failed to save WiFi mode: {e}") return False def _load_mode(self) -> Optional[WiFiMode]: """Load WiFi mode from persistent storage. Returns: Saved WiFi mode or None if not found """ try: if WIFI_MODE_FILE.exists(): mode_str = WIFI_MODE_FILE.read_text().strip() mode = WiFiMode(mode_str) logger.info(f"WiFi mode loaded: {mode.value}") return mode except Exception as e: logger.warning(f"Failed to load WiFi mode: {e}") return None async def apply_saved_mode(self) -> bool: """Apply the saved WiFi mode on startup. This should be called once during service startup to restore the previously configured WiFi mode. Returns: True if mode was applied successfully """ if self._startup_applied: logger.debug("Saved mode already applied, skipping") return True saved_mode = self._load_mode() if saved_mode: logger.info(f"Applying saved WiFi mode: {saved_mode.value}") success = await self.set_mode(saved_mode, persist=False) # Don't re-save self._startup_applied = True return success else: logger.info("No saved WiFi mode found, using default (AUTO)") self._startup_applied = True return True async def detect_interfaces(self) -> List[WiFiInterface]: """Detect available WiFi interfaces. Returns: List of WiFi interfaces found """ interfaces = [] try: # Get all wireless interfaces using iw result = await self._run_command("iw dev") if not result: return interfaces # Parse iw dev output # Example output: # phy#0 # Interface wlan0 # ifindex 2 # wdev 0x1 # addr 2c:cf:67:87:db:13 # ssid Dangerous-Pi # type AP # channel 6 (2437 MHz), width: 20 MHz current_interface = None for line in result.split('\n'): line = line.strip() if line.startswith('Interface '): if current_interface: interfaces.append(current_interface) iface_name = line.split()[1] current_interface = { 'name': iface_name, 'mac': '', 'is_usb': self._is_usb_interface(iface_name), 'is_up': False, 'connected': False, 'ssid': None, 'ip_address': None, 'mode': 'managed' # Default to managed (client) mode } elif current_interface and line.startswith('addr '): current_interface['mac'] = line.split()[1] elif current_interface and line.startswith('ssid '): current_interface['ssid'] = ' '.join(line.split()[1:]) elif current_interface and line.startswith('type '): # Parse interface type: "AP" or "managed" iface_type = line.split()[1] current_interface['mode'] = iface_type # Only mark as "connected" if in managed (client) mode with SSID if iface_type == 'managed' and current_interface.get('ssid'): current_interface['connected'] = True if current_interface: interfaces.append(current_interface) # Get interface status and IP addresses for iface_dict in interfaces: # Check if interface is up iface_dict['is_up'] = await self._is_interface_up(iface_dict['name']) # Get IP address if interface is up if iface_dict['is_up']: iface_dict['ip_address'] = await self._get_interface_ip(iface_dict['name']) # For AP mode, mark connected if we have IP (AP is "active") if iface_dict['mode'] == 'AP' and iface_dict['ip_address']: iface_dict['connected'] = True # Convert dicts to WiFiInterface objects self._interfaces = [WiFiInterface(**iface) for iface in interfaces] # Check if dual mode is supported (requires at least 2 interfaces) self._supports_dual = len(self._interfaces) >= 2 return self._interfaces except Exception as e: print(f"Error detecting WiFi interfaces: {e}") return [] def _is_usb_interface(self, iface_name: str) -> bool: """Check if interface is USB-based. Args: iface_name: Interface name (e.g., wlan0) Returns: True if USB interface, False otherwise """ try: # Check if device is USB by looking at sysfs device_path = Path(f"/sys/class/net/{iface_name}/device") if not device_path.exists(): return False # Read uevent to check for USB uevent_path = device_path / "uevent" if uevent_path.exists(): content = uevent_path.read_text() return "usb" in content.lower() return False except Exception: return False async def _is_interface_up(self, iface_name: str) -> bool: """Check if interface is up. Args: iface_name: Interface name Returns: True if interface is up """ try: result = await self._run_command(f"ip link show {iface_name}") return "UP" in result except Exception: return False async def _get_interface_ip(self, iface_name: str) -> Optional[str]: """Get IP address of interface. Args: iface_name: Interface name Returns: IP address or None """ try: result = await self._run_command(f"ip -4 addr show {iface_name}") match = re.search(r'inet (\d+\.\d+\.\d+\.\d+)', result) return match.group(1) if match else None except Exception: return None async def get_status(self) -> WiFiStatus: """Get current WiFi status. Returns: WiFiStatus object with current state """ await self.detect_interfaces() # Determine current mode current_mode = await self._detect_current_mode() # Find client and AP interfaces client_ssid = None client_ip = None ap_ssid = None ap_ip = None for iface in self._interfaces: # Check for AP mode interface if iface.mode == 'AP': ap_ip = iface.ip_address ap_ssid = iface.ssid # SSID from iw dev output # If no SSID from iw, try hostapd config if not ap_ssid: ap_ssid = await self._get_ap_ssid() # Check for client mode interface that's connected elif iface.mode == 'managed' and iface.connected and iface.ssid: client_ssid = iface.ssid client_ip = iface.ip_address # Fallback: try to get AP SSID from hostapd if we detected AP mode if current_mode == WiFiMode.AP and not ap_ssid: ap_ssid = await self._get_ap_ssid() return WiFiStatus( mode=current_mode, interfaces=self._interfaces, current_ssid=client_ssid, current_ip=client_ip, ap_ssid=ap_ssid or "Dangerous-Pi", # Default ap_ip=ap_ip, supports_dual=self._supports_dual ) async def _detect_current_mode(self) -> WiFiMode: """Detect current WiFi mode based on interface types. Returns: Current WiFi mode """ if not self._interfaces: return WiFiMode.OFF # Check interface modes from iw dev output has_ap = any(iface.mode == 'AP' for iface in self._interfaces) has_client = any( iface.mode == 'managed' and iface.connected for iface in self._interfaces ) # Also check if hostapd is running as backup detection if not has_ap: has_ap = await self._is_hostapd_running() if has_ap and has_client: return WiFiMode.DUAL elif has_ap: return WiFiMode.AP elif has_client: return WiFiMode.CLIENT elif any(iface.is_up for iface in self._interfaces): return WiFiMode.AUTO # Interface up but not in AP or connected else: return WiFiMode.OFF async def _is_hostapd_running(self) -> bool: """Check if hostapd service is running. Returns: True if hostapd is active """ try: result = await self._run_command( "systemctl is-active hostapd", check=False ) return result.strip() == "active" except Exception: return False async def _get_ap_ssid(self) -> Optional[str]: """Get AP SSID from hostapd config. Returns: AP SSID or None """ try: config_path = Path("/etc/hostapd/hostapd.conf") if config_path.exists(): content = config_path.read_text() match = re.search(r'ssid=(.+)', content) return match.group(1) if match else None except Exception: return None async def scan_networks(self, interface: Optional[str] = None) -> List[WiFiNetwork]: """Scan for available WiFi networks. Args: interface: Interface to scan with (default: first available) Returns: List of available networks """ # Ensure interfaces are detected if not self._interfaces: await self.detect_interfaces() if not interface: # Use first non-USB interface, or first available for iface in self._interfaces: if not iface.is_usb: interface = iface.name break if not interface and self._interfaces: interface = self._interfaces[0].name if not interface: return [] try: # Request scan # Note: Requires CAP_NET_ADMIN capability (set via systemd service) await self._run_command(f"iw dev {interface} scan trigger", check=False) # Wait for scan to complete await asyncio.sleep(2) # Get scan results result = await self._run_command(f"iw dev {interface} scan") networks = [] current_network = None for line in result.split('\n'): line = line.strip() if line.startswith('BSS ') and line.split()[1].count(':') >= 5: # Match BSS lines with MAC addresses (xx:xx:xx:xx:xx:xx), not "BSS Load:" etc. if current_network: networks.append(current_network) # Handle format: BSS aa:bb:cc:dd:ee:ff(on wlan0) bssid = line.split()[1].split('(')[0] current_network = { 'ssid': '', 'bssid': bssid, 'signal_strength': 0, 'frequency': 0, 'encrypted': False, 'in_use': False } elif current_network: if line.startswith('SSID: '): current_network['ssid'] = line[6:] elif line.startswith('freq: '): # freq can be "2437" or "2437.0" depending on iw version current_network['frequency'] = int(float(line[6:])) elif line.startswith('signal: '): # Parse signal strength (e.g., "-50.00 dBm") signal = line[8:].split()[0] current_network['signal_strength'] = int(float(signal)) elif 'RSN:' in line or 'WPA:' in line: current_network['encrypted'] = True if current_network: networks.append(current_network) # Convert to WiFiNetwork objects and filter out empty SSIDs return [ WiFiNetwork(**net) for net in networks if net['ssid'] ] except Exception as e: print(f"Error scanning networks: {e}") return [] async def set_mode(self, mode: WiFiMode, persist: bool = True) -> bool: """Set WiFi mode. Args: mode: Desired WiFi mode persist: If True, save mode to persistent storage for boot persistence Returns: True if mode was set successfully """ if mode == WiFiMode.DUAL and not self._supports_dual: raise ValueError("Dual mode requires USB WiFi adapter") try: if mode == WiFiMode.AP: await self._enable_ap_mode() elif mode == WiFiMode.CLIENT: await self._enable_client_mode() elif mode == WiFiMode.DUAL: await self._enable_dual_mode() elif mode == WiFiMode.OFF: await self._disable_wifi() elif mode == WiFiMode.AUTO: await self._enable_auto_mode() self._current_mode = mode # Persist mode for boot persistence if persist: self._save_mode(mode) return True except Exception as e: print(f"Error setting WiFi mode: {e}") return False async def _systemctl(self, action: str, service: str): """Control systemd service via dbus (no sudo required with polkit rule). Args: action: "start", "stop", or "restart" service: Service name (e.g., "hostapd.service") """ if not service.endswith(".service"): service = f"{service}.service" method = { "start": "StartUnit", "stop": "StopUnit", "restart": "RestartUnit" }.get(action, "StartUnit") cmd = ( f'busctl call org.freedesktop.systemd1 ' f'/org/freedesktop/systemd1 org.freedesktop.systemd1.Manager ' f'{method} ss "{service}" "replace"' ) await self._run_command(cmd, check=False) async def _enable_nm_management(self): """Enable NetworkManager to manage wlan0 for client mode. Uses nmcli to set wlan0 as managed (no file permissions needed). """ import logging logger = logging.getLogger(__name__) # Check current device status status = await self._run_command("nmcli device status", check=False) logger.warning(f"[NM Management] Current status:\n{status}") # Use nmcli to set device as managed (works without root) logger.warning("[NM Management] Setting wlan0 as managed...") result = await self._run_command("nmcli device set wlan0 managed yes", check=False) logger.warning(f"[NM Management] nmcli set managed result: '{result}'") # Give NM time to pick up the device logger.warning("[NM Management] Waiting 2s...") await asyncio.sleep(2) # Verify device is now managed status = await self._run_command("nmcli device status", check=False) logger.warning(f"[NM Management] Status after:\n{status}") async def _disable_nm_management(self): """Disable NetworkManager management of wlan0 for AP mode. Uses nmcli to set wlan0 as unmanaged (for hostapd). """ import logging logger = logging.getLogger(__name__) # Use nmcli to set device as unmanaged logger.warning("[NM Management] Setting wlan0 as unmanaged...") result = await self._run_command("nmcli device set wlan0 managed no", check=False) logger.warning(f"[NM Management] nmcli set unmanaged result: '{result}'") await asyncio.sleep(1) async def _enable_ap_mode(self): """Enable Access Point mode.""" # Ensure NM doesn't manage wlan0 await self._disable_nm_management() # Start hostapd and dnsmasq for AP await self._systemctl("start", "hostapd") await self._systemctl("start", "dnsmasq") # Set static IP for AP await self._run_command("ip addr add 192.168.4.1/24 dev wlan0", check=False) print("AP mode enabled") async def _enable_client_mode(self): """Enable Client mode using NetworkManager.""" import logging logger = logging.getLogger(__name__) # Stop AP services logger.warning("[Client Mode] Stopping hostapd...") await self._systemctl("stop", "hostapd") logger.warning("[Client Mode] Stopping dnsmasq...") await self._systemctl("stop", "dnsmasq") # Check hostapd status hostapd_status = await self._run_command("systemctl is-active hostapd", check=False) logger.warning(f"[Client Mode] hostapd status after stop: {hostapd_status.strip()}") # Enable NetworkManager to manage wlan0 logger.warning("[Client Mode] Enabling NM management...") await self._enable_nm_management() logger.warning("[Client Mode] Client mode enabled") async def _enable_dual_mode(self): """Enable Dual mode (AP + Client).""" # Enable both AP and client await self._systemctl("start", "hostapd") await self._systemctl("start", "dnsmasq") await self._systemctl("start", "wpa_supplicant") print("Dual mode enabled") async def _disable_wifi(self): """Disable all WiFi.""" await self._systemctl("stop", "hostapd") await self._systemctl("stop", "dnsmasq") await self._systemctl("stop", "wpa_supplicant") print("WiFi disabled") async def _enable_auto_mode(self): """Enable Auto mode.""" # Auto-detect best mode based on saved networks and hardware if self._supports_dual: await self._enable_dual_mode() else: # Check if we have saved networks saved_networks = await self.get_saved_networks() if saved_networks: await self._enable_client_mode() else: await self._enable_ap_mode() print("Auto mode enabled") async def connect_to_network( self, ssid: str, password: Optional[str] = None, interface: Optional[str] = None, hidden: bool = False, fallback_to_ap: bool = True ) -> bool: """Connect to a WiFi network using NetworkManager. Args: ssid: Network SSID password: Network password (if encrypted) interface: Interface to use (default: first available) hidden: Whether network is hidden fallback_to_ap: If True, revert to AP mode on connection failure Returns: True if connection successful """ # Ensure interfaces are detected before trying to connect if not self._interfaces: await self.detect_interfaces() if not interface: # Use first non-USB interface, or first available for iface in self._interfaces: if not iface.is_usb: interface = iface.name break if not interface and self._interfaces: interface = self._interfaces[0].name if not interface: import logging logger = logging.getLogger(__name__) logger.warning("[WiFi Connect] No interface found!") return False try: import logging logger = logging.getLogger(__name__) logger.warning(f"[WiFi Connect] Starting connect to {ssid}") # Switch to client mode (stops hostapd, enables NM management) logger.warning("[WiFi Connect] Step 1: Enabling client mode...") await self._enable_client_mode() logger.warning("[WiFi Connect] Step 1: Client mode enabled") # Wait for NM to be ready and detect wlan0 logger.warning("[WiFi Connect] Step 2: Waiting 2s for NM...") await asyncio.sleep(2) # Check device status dev_status = await self._run_command("nmcli device status", check=False) logger.warning(f"[WiFi Connect] Device status after wait:\n{dev_status}") # Delete any existing saved connection for this SSID # (handles corrupted connections with missing key-mgmt property) ssid_escaped = ssid.replace("'", "'\\''") logger.warning(f"[WiFi Connect] Step 3: Deleting existing connection for {ssid}...") del_result = await self._run_command( f"nmcli connection delete '{ssid_escaped}'", check=False ) logger.warning(f"[WiFi Connect] Delete result: {del_result}") await asyncio.sleep(1) # Build nmcli connect command cmd = f"nmcli device wifi connect '{ssid_escaped}'" if password: password_escaped = password.replace("'", "'\\''") cmd += f" password '{password_escaped}'" if hidden: cmd += " hidden yes" # Attempt connection logger.warning(f"[WiFi Connect] Step 4: Running nmcli connect...") result = await self._run_command(cmd, check=False) logger.warning(f"[WiFi Connect] Connect result: {result}") # Check if connection was successful if "successfully activated" in result.lower(): logger.warning(f"[WiFi Connect] SUCCESS - connected to {ssid}") # Verify we have an IP address await asyncio.sleep(2) ip_result = await self._run_command(f"ip addr show {interface}") logger.warning(f"[WiFi Connect] IP result: {ip_result}") if "inet " in ip_result and "192.168.4." not in ip_result: # Save client mode for boot persistence self._current_mode = WiFiMode.CLIENT self._save_mode(WiFiMode.CLIENT) logger.info(f"WiFi client mode saved for boot persistence") return True # Connection failed logger.warning(f"[WiFi Connect] FAILED - {result}") if fallback_to_ap: print("Falling back to AP mode...") await self._enable_ap_mode() return False except Exception as e: print(f"Error connecting to network: {e}") return False async def _add_network_via_wpa_cli(self, ssid: str, password: Optional[str], hidden: bool): """Add network using wpa_cli commands.""" try: # Add network result = await self._run_command("sudo wpa_cli add_network") network_id = result.strip().split('\n')[-1] # Set SSID await self._run_command(f'sudo wpa_cli set_network {network_id} ssid \\"{ssid}\\"') # Set password or open network if password: await self._run_command(f'sudo wpa_cli set_network {network_id} psk \\"{password}\\"') else: await self._run_command(f'sudo wpa_cli set_network {network_id} key_mgmt NONE') # Set scan_ssid for hidden networks if hidden: await self._run_command(f'sudo wpa_cli set_network {network_id} scan_ssid 1') # Enable network await self._run_command(f'sudo wpa_cli enable_network {network_id}') # Save configuration await self._run_command('sudo wpa_cli save_config') return True except Exception as e: print(f"Error adding network via wpa_cli: {e}") return False async def disconnect_from_network(self, interface: Optional[str] = None) -> bool: """Disconnect from current network and return to AP mode. Args: interface: Interface to disconnect (default: first connected) Returns: True if disconnection successful """ if not interface: for iface in self._interfaces: if iface.connected: interface = iface.name break if not interface: return False try: # Disconnect using nmcli await self._run_command(f"nmcli device disconnect {interface}", check=False) # Return to AP mode await self._enable_ap_mode() return True except Exception as e: print(f"Error disconnecting: {e}") return False async def get_saved_networks(self) -> List[Dict[str, str]]: """Get list of saved WiFi networks from NetworkManager. Returns: List of saved networks with SSID and other info """ try: result = await self._run_command( "nmcli -t -f NAME,TYPE connection show", check=False ) networks = [] for line in result.split('\n'): if line.strip(): parts = line.split(':') if len(parts) >= 2 and parts[1] == '802-11-wireless': networks.append({ 'ssid': parts[0], 'type': 'wifi', 'enabled': True }) return networks except Exception as e: print(f"Error getting saved networks: {e}") return [] async def forget_network(self, ssid: str) -> bool: """Forget a saved network. Args: ssid: SSID of network to forget Returns: True if network was forgotten """ try: # Delete connection using nmcli ssid_escaped = ssid.replace("'", "'\\''") result = await self._run_command( f"nmcli connection delete '{ssid_escaped}'", check=False ) return "successfully deleted" in result.lower() except Exception as e: print(f"Error forgetting network: {e}") return False async def set_static_ip( self, interface: str, ip_address: str, netmask: str = "255.255.255.0", gateway: Optional[str] = None, dns: Optional[List[str]] = None ) -> bool: """Set static IP for interface. Args: interface: Interface name ip_address: Static IP address netmask: Network mask gateway: Gateway IP (optional) dns: DNS servers (optional) Returns: True if configuration successful """ try: # Configure static IP using dhcpcd config_path = Path("/etc/dhcpcd.conf") # Read existing config if config_path.exists(): content = config_path.read_text() else: content = "" # Remove existing config for this interface lines = content.split('\n') new_lines = [] skip_interface = False for line in lines: if line.startswith(f'interface {interface}'): skip_interface = True continue if skip_interface and (line.startswith('interface ') or line.strip() == ''): skip_interface = False if not skip_interface: new_lines.append(line) # Add new configuration new_config = f"\ninterface {interface}\n" new_config += f"static ip_address={ip_address}/{self._netmask_to_cidr(netmask)}\n" if gateway: new_config += f"static routers={gateway}\n" if dns: new_config += f"static domain_name_servers={' '.join(dns)}\n" new_content = '\n'.join(new_lines) + new_config # Write configuration (would need sudo) # In production, this should use a proper mechanism print(f"Would write to {config_path}:\n{new_config}") # Restart interface await self._run_command(f"sudo ip link set {interface} down") await self._run_command(f"sudo ip link set {interface} up") await self._run_command("sudo systemctl restart dhcpcd") return True except Exception as e: print(f"Error setting static IP: {e}") return False def _netmask_to_cidr(self, netmask: str) -> int: """Convert netmask to CIDR notation. Args: netmask: Netmask (e.g., 255.255.255.0) Returns: CIDR prefix length (e.g., 24) """ return sum([bin(int(x)).count('1') for x in netmask.split('.')]) async def enable_dhcp(self, interface: str) -> bool: """Enable DHCP for interface. Args: interface: Interface name Returns: True if DHCP enabled """ try: # Remove static IP configuration config_path = Path("/etc/dhcpcd.conf") if config_path.exists(): content = config_path.read_text() lines = content.split('\n') new_lines = [] skip_interface = False for line in lines: if line.startswith(f'interface {interface}'): skip_interface = True continue if skip_interface and (line.startswith('interface ') or line.strip() == ''): skip_interface = False if not skip_interface: new_lines.append(line) # Write back print(f"Would update {config_path} to enable DHCP") # Restart interface await self._run_command(f"sudo ip link set {interface} down") await self._run_command(f"sudo ip link set {interface} up") await self._run_command("sudo systemctl restart dhcpcd") return True except Exception as e: print(f"Error enabling DHCP: {e}") return False async def _run_command(self, command: str, check: bool = True) -> str: """Run a shell command asynchronously. Args: command: Command to run check: Raise exception on non-zero exit code Returns: Command output Raises: subprocess.CalledProcessError: If command fails and check=True """ loop = asyncio.get_event_loop() def _execute(): result = subprocess.run( command, shell=True, capture_output=True, text=True, check=check ) return result.stdout return await loop.run_in_executor(None, _execute) # Global instance wifi_manager = WiFiManager()