Initial commit - Phase 3/4

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
michael
2026-01-06 13:45:29 -08:00
parent 1da6730735
commit 4f35df1781
323 changed files with 98287 additions and 1195 deletions

View File

@@ -1,5 +1,6 @@
"""WiFi manager for network configuration and mode switching."""
import asyncio
import logging
import re
import subprocess
from dataclasses import dataclass
@@ -9,6 +10,12 @@ 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."""
@@ -29,6 +36,7 @@ class WiFiInterface:
connected: bool
ssid: Optional[str] = None
ip_address: Optional[str] = None
mode: str = "managed" # "AP" or "managed" (client)
@dataclass
@@ -62,6 +70,68 @@ class WiFiManager:
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.
@@ -79,6 +149,15 @@ class WiFiManager:
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()
@@ -95,7 +174,8 @@ class WiFiManager:
'is_up': False,
'connected': False,
'ssid': None,
'ip_address': None
'ip_address': None,
'mode': 'managed' # Default to managed (client) mode
}
elif current_interface and line.startswith('addr '):
@@ -103,7 +183,14 @@ class WiFiManager:
elif current_interface and line.startswith('ssid '):
current_interface['ssid'] = ' '.join(line.split()[1:])
current_interface['connected'] = True
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)
@@ -117,8 +204,9 @@ class WiFiManager:
if iface_dict['is_up']:
iface_dict['ip_address'] = await self._get_interface_ip(iface_dict['name'])
# Create WiFiInterface object
wifi_iface = WiFiInterface(**iface_dict)
# 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]
@@ -206,28 +294,35 @@ class WiFiManager:
ap_ip = None
for iface in self._interfaces:
if iface.connected and iface.ssid:
# 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
# Check if running as AP (typically 10.3.141.1)
if iface.ip_address and iface.ip_address.startswith("10.3.141"):
ap_ip = iface.ip_address
# Try to get AP SSID from hostapd
ap_ssid = await self._get_ap_ssid()
# 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 "raspi-webgui", # Default from existing setup
ap_ip=ap_ip or "10.3.141.1",
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.
"""Detect current WiFi mode based on interface types.
Returns:
Current WiFi mode
@@ -235,14 +330,16 @@ class WiFiManager:
if not self._interfaces:
return WiFiMode.OFF
# Check if any interface is in AP mode
has_ap = any(
iface.ip_address and iface.ip_address.startswith("10.3.141")
# 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
)
# Check if any interface is connected as client
has_client = any(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
@@ -250,8 +347,25 @@ class WiFiManager:
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.AUTO
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.
@@ -277,6 +391,10 @@ class WiFiManager:
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:
@@ -292,13 +410,14 @@ class WiFiManager:
try:
# Request scan
await self._run_command(f"sudo iw dev {interface} scan trigger", check=False)
# 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"sudo iw dev {interface} scan")
result = await self._run_command(f"iw dev {interface} scan")
networks = []
current_network = None
@@ -306,11 +425,13 @@ class WiFiManager:
for line in result.split('\n'):
line = line.strip()
if line.startswith('BSS '):
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)
bssid = line.split()[1].rstrip('(')
# Handle format: BSS aa:bb:cc:dd:ee:ff(on wlan0)
bssid = line.split()[1].split('(')[0]
current_network = {
'ssid': '',
'bssid': bssid,
@@ -324,7 +445,8 @@ class WiFiManager:
if line.startswith('SSID: '):
current_network['ssid'] = line[6:]
elif line.startswith('freq: '):
current_network['frequency'] = int(line[6:])
# 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]
@@ -346,11 +468,12 @@ class WiFiManager:
print(f"Error scanning networks: {e}")
return []
async def set_mode(self, mode: WiFiMode) -> bool:
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
@@ -371,40 +494,122 @@ class WiFiManager:
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._run_command("sudo systemctl start hostapd")
await self._run_command("sudo systemctl start dnsmasq")
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."""
"""Enable Client mode using NetworkManager."""
import logging
logger = logging.getLogger(__name__)
# Stop AP services
await self._run_command("sudo systemctl stop hostapd", check=False)
await self._run_command("sudo systemctl stop dnsmasq", check=False)
# Start wpa_supplicant
await self._run_command("sudo systemctl start wpa_supplicant")
print("Client mode enabled")
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._run_command("sudo systemctl start hostapd")
await self._run_command("sudo systemctl start dnsmasq")
await self._run_command("sudo systemctl start wpa_supplicant")
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._run_command("sudo systemctl stop hostapd", check=False)
await self._run_command("sudo systemctl stop dnsmasq", check=False)
await self._run_command("sudo systemctl stop wpa_supplicant", check=False)
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):
@@ -426,19 +631,25 @@ class WiFiManager:
ssid: str,
password: Optional[str] = None,
interface: Optional[str] = None,
hidden: bool = False
hidden: bool = False,
fallback_to_ap: bool = True
) -> bool:
"""Connect to a WiFi network.
"""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:
@@ -449,52 +660,79 @@ class WiFiManager:
interface = self._interfaces[0].name
if not interface:
import logging
logger = logging.getLogger(__name__)
logger.warning("[WiFi Connect] No interface found!")
return False
try:
# Add network to wpa_supplicant configuration
config_path = Path("/etc/wpa_supplicant/wpa_supplicant.conf")
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}'"
# Create network block
if password:
# Encrypted network
network_block = f"""
network={{
ssid="{ssid}"
psk="{password}"
scan_ssid={1 if hidden else 0}
priority=1
}}
"""
else:
# Open network
network_block = f"""
network={{
ssid="{ssid}"
key_mgmt=NONE
scan_ssid={1 if hidden else 0}
priority=1
}}
"""
password_escaped = password.replace("'", "'\\''")
cmd += f" password '{password_escaped}'"
# Append to config (or use wpa_cli)
# Using wpa_cli for immediate connection
await self._add_network_via_wpa_cli(ssid, password, hidden)
if hidden:
cmd += " hidden yes"
# Reconnect wpa_supplicant
await self._run_command(f"sudo wpa_cli -i {interface} reconfigure")
# 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}")
# Wait for connection
await asyncio.sleep(3)
# Check if connection was successful
if "successfully activated" in result.lower():
logger.warning(f"[WiFi Connect] SUCCESS - connected to {ssid}")
# Verify connection
result = await self._run_command(f"sudo wpa_cli -i {interface} status")
if f'ssid={ssid}' in result and 'wpa_state=COMPLETED' in result:
print(f"Successfully connected to {ssid}")
return True
else:
print(f"Connection to {ssid} failed")
return False
# 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}")
@@ -532,7 +770,7 @@ network={{
return False
async def disconnect_from_network(self, interface: Optional[str] = None) -> bool:
"""Disconnect from current network.
"""Disconnect from current network and return to AP mode.
Args:
interface: Interface to disconnect (default: first connected)
@@ -550,30 +788,36 @@ network={{
return False
try:
await self._run_command(f"sudo wpa_cli -i {interface} disconnect")
# 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 networks from wpa_supplicant.
"""Get list of saved WiFi networks from NetworkManager.
Returns:
List of saved networks with SSID and other info
"""
try:
result = await self._run_command("sudo wpa_cli list_networks")
result = await self._run_command(
"nmcli -t -f NAME,TYPE connection show",
check=False
)
networks = []
for line in result.split('\n')[1:]: # Skip header
for line in result.split('\n'):
if line.strip():
parts = line.split('\t')
if len(parts) >= 2:
parts = line.split(':')
if len(parts) >= 2 and parts[1] == '802-11-wireless':
networks.append({
'id': parts[0].strip(),
'ssid': parts[1].strip(),
'enabled': 'CURRENT' in line or 'ENABLED' in line
'ssid': parts[0],
'type': 'wifi',
'enabled': True
})
return networks
@@ -591,23 +835,14 @@ network={{
True if network was forgotten
"""
try:
# Get network ID
networks = await self.get_saved_networks()
network_id = None
# Delete connection using nmcli
ssid_escaped = ssid.replace("'", "'\\''")
result = await self._run_command(
f"nmcli connection delete '{ssid_escaped}'",
check=False
)
for net in networks:
if net['ssid'] == ssid:
network_id = net['id']
break
if network_id is None:
return False
# Remove network
await self._run_command(f"sudo wpa_cli remove_network {network_id}")
await self._run_command("sudo wpa_cli save_config")
return True
return "successfully deleted" in result.lower()
except Exception as e:
print(f"Error forgetting network: {e}")
return False