| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449 |
- """
- WiFi management via NetworkManager (nmcli).
- Handles scanning, connecting, and managing WiFi connections.
- """
- import subprocess
- import shutil
- import os
- import logging
- import asyncio
- logger = logging.getLogger(__name__)
- HOTSPOT_CON_NAME = "DuneWeaver-Hotspot"
- NMCLI = shutil.which("nmcli") or "/usr/bin/nmcli"
- def run_nmcli(*args: str, timeout: int = 30) -> str:
- """Run nmcli and return stdout."""
- cmd = [NMCLI] + list(args)
- logger.debug(f"Running nmcli: {' '.join(cmd)}")
- result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
- if result.returncode != 0:
- logger.warning(f"nmcli failed (rc={result.returncode}): {' '.join(cmd)}")
- if result.stderr:
- logger.warning(f" stderr: {result.stderr.strip()}")
- return result.stdout
- def run_nmcli_check(*args: str, timeout: int = 30) -> subprocess.CompletedProcess:
- """Run nmcli and return the full CompletedProcess (for checking returncode)."""
- cmd = [NMCLI] + list(args)
- logger.debug(f"Running nmcli: {' '.join(cmd)}")
- result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
- if result.returncode != 0:
- logger.warning(f"nmcli failed (rc={result.returncode}): {' '.join(cmd)}")
- if result.stderr:
- logger.warning(f" stderr: {result.stderr.strip()}")
- return result
- def get_wifi_mode() -> str:
- """Detect WiFi mode by querying NetworkManager active connections.
- Uses 'nmcli con show --active' instead of 'nmcli dev show wlan0'
- because the latter can behave differently in AP (hotspot) mode
- across NM versions.
- """
- try:
- output = run_nmcli("-t", "-f", "NAME,TYPE,DEVICE", "con", "show", "--active")
- logger.info(f"Active connections: {output.strip()}")
- for line in output.strip().splitlines():
- parts = line.split(":")
- if len(parts) >= 3 and parts[2] == "wlan0":
- con_name = parts[0]
- if con_name == HOTSPOT_CON_NAME:
- return "hotspot"
- return "client"
- except (subprocess.TimeoutExpired, Exception) as e:
- logger.warning(f"Error detecting WiFi mode: {e}")
- return "unknown"
- def get_current_ssid() -> str:
- """Get the SSID of the currently connected WiFi network."""
- try:
- # Use active connections to find the wifi connection on wlan0
- output = run_nmcli("-t", "-f", "NAME,TYPE,DEVICE", "con", "show", "--active")
- for line in output.strip().splitlines():
- parts = line.split(":")
- if len(parts) >= 3 and parts[2] == "wlan0":
- con_name = parts[0]
- if con_name and con_name != HOTSPOT_CON_NAME:
- return con_name
- except (subprocess.TimeoutExpired, Exception) as e:
- logger.debug(f"Error getting SSID: {e}")
- return ""
- def get_current_ip() -> str:
- """Get the current IP address of the wlan0 interface."""
- try:
- output = run_nmcli("-t", "-f", "IP4.ADDRESS", "dev", "show", "wlan0")
- logger.debug(f"IP output: {output.strip()}")
- for line in output.strip().splitlines():
- if "IP4.ADDRESS" in line:
- addr = line.split(":", 1)[1] if ":" in line else ""
- if addr:
- return addr.split("/")[0]
- except (subprocess.TimeoutExpired, Exception) as e:
- logger.debug(f"Error getting IP: {e}")
- return ""
- def get_hostname() -> str:
- """Get the system hostname via NetworkManager."""
- try:
- output = run_nmcli("general", "hostname")
- name = output.strip()
- if name:
- return name
- except Exception:
- pass
- return "duneweaver"
- def get_wifi_status() -> dict:
- """Get comprehensive WiFi status."""
- mode = get_wifi_mode()
- ssid = get_current_ssid()
- ip = get_current_ip()
- hostname = get_hostname()
- return {
- "mode": mode,
- "ssid": ssid,
- "ip": ip,
- "hostname": hostname,
- }
- def scan_networks() -> list[dict]:
- """Scan for available WiFi networks."""
- try:
- # Trigger rescan
- run_nmcli("dev", "wifi", "rescan", "ifname", "wlan0")
- except Exception:
- pass
- # Brief wait for scan results
- import time
- time.sleep(2)
- try:
- output = run_nmcli("-t", "-f", "SSID,SIGNAL,SECURITY,ACTIVE", "dev", "wifi", "list", "ifname", "wlan0")
- except subprocess.TimeoutExpired:
- logger.error("WiFi scan timed out")
- return []
- # Get saved connections for cross-reference
- saved = get_saved_connections()
- saved_ssids = {c["ssid"] for c in saved}
- networks = []
- seen_ssids = set()
- for line in output.strip().splitlines():
- if not line.strip():
- continue
- # nmcli -t uses : as delimiter, but SSID can contain colons
- # Format: SSID:SIGNAL:SECURITY:ACTIVE
- # Parse from the right since SSID is the only field that can contain ':'
- parts = line.rsplit(":", 3)
- if len(parts) < 4:
- continue
- ssid = parts[0].strip()
- if not ssid or ssid in seen_ssids:
- continue
- seen_ssids.add(ssid)
- try:
- signal = int(parts[1])
- except (ValueError, IndexError):
- signal = 0
- security = parts[2] if len(parts) > 2 else ""
- active = parts[3].strip().lower() == "yes" if len(parts) > 3 else False
- networks.append({
- "ssid": ssid,
- "signal": signal,
- "security": security if security and security != "--" else "Open",
- "saved": ssid in saved_ssids,
- "active": active,
- })
- # Sort by signal strength (strongest first)
- networks.sort(key=lambda n: n["signal"], reverse=True)
- return networks
- def get_saved_connections() -> list[dict]:
- """Get list of saved WiFi connections."""
- try:
- output = run_nmcli("-t", "-f", "NAME,TYPE", "con", "show")
- except subprocess.TimeoutExpired:
- return []
- connections = []
- for line in output.strip().splitlines():
- if "wireless" not in line:
- continue
- name = line.split(":")[0]
- if name == HOTSPOT_CON_NAME:
- continue
- # Get the SSID for this connection
- try:
- detail = run_nmcli("-t", "-f", "802-11-wireless.ssid", "con", "show", name)
- ssid = ""
- for detail_line in detail.strip().splitlines():
- if "802-11-wireless.ssid" in detail_line:
- ssid = detail_line.split(":", 1)[1] if ":" in detail_line else name
- break
- if not ssid:
- ssid = name
- except Exception:
- ssid = name
- connections.append({
- "name": name,
- "ssid": ssid,
- })
- return connections
- async def connect_to_network(ssid: str, password: str) -> dict:
- """Connect to a WiFi network.
- Uses explicit connection profile creation (nmcli con add) instead of
- 'nmcli dev wifi connect' because the latter fails on Pi Trixie with
- 'key-mgmt: property is missing' for WPA networks.
- """
- try:
- # Delete any stale connection profile for this SSID (ignore if not found)
- subprocess.run(
- [NMCLI, "con", "delete", ssid],
- capture_output=True, text=True, timeout=10,
- )
- # Create connection profile with explicit security settings
- if password:
- result = run_nmcli_check(
- "con", "add",
- "type", "wifi",
- "ifname", "wlan0",
- "con-name", ssid,
- "ssid", ssid,
- "wifi-sec.key-mgmt", "wpa-psk",
- "wifi-sec.psk", password,
- timeout=15,
- )
- else:
- result = run_nmcli_check(
- "con", "add",
- "type", "wifi",
- "ifname", "wlan0",
- "con-name", ssid,
- "ssid", ssid,
- timeout=15,
- )
- if result.returncode != 0:
- error_msg = result.stderr.strip() or "Failed to create connection"
- logger.error(f"WiFi connection add failed: {error_msg}")
- return {"success": False, "message": error_msg}
- # Activate the connection
- result = run_nmcli_check("con", "up", ssid, timeout=30)
- if result.returncode != 0:
- error_msg = result.stderr.strip() or "Failed to connect"
- logger.error(f"WiFi connect failed: {error_msg}")
- # Clean up the failed connection profile
- run_nmcli_check("con", "delete", ssid, timeout=10)
- return {"success": False, "message": error_msg}
- logger.info(f"WiFi connection to '{ssid}' successful")
- return {
- "success": True,
- "message": f"Connected to '{ssid}'.",
- }
- except subprocess.TimeoutExpired:
- return {"success": False, "message": "Connection timed out"}
- except Exception as e:
- logger.error(f"WiFi connect error: {e}")
- return {"success": False, "message": str(e)}
- def save_network(ssid: str, password: str) -> dict:
- """Save a WiFi network profile without connecting.
- Creates the connection profile so autohotspot can use it on next check/boot.
- """
- try:
- # Delete any stale connection profile for this SSID (ignore if not found)
- subprocess.run(
- [NMCLI, "con", "delete", ssid],
- capture_output=True, text=True, timeout=10,
- )
- if password:
- result = run_nmcli_check(
- "con", "add",
- "type", "wifi",
- "ifname", "wlan0",
- "con-name", ssid,
- "ssid", ssid,
- "wifi-sec.key-mgmt", "wpa-psk",
- "wifi-sec.psk", password,
- timeout=15,
- )
- else:
- result = run_nmcli_check(
- "con", "add",
- "type", "wifi",
- "ifname", "wlan0",
- "con-name", ssid,
- "ssid", ssid,
- timeout=15,
- )
- if result.returncode != 0:
- error_msg = result.stderr.strip() or "Failed to save network"
- logger.error(f"WiFi save failed: {error_msg}")
- return {"success": False, "message": error_msg}
- logger.info(f"Saved WiFi network '{ssid}' (not connecting)")
- return {"success": True, "message": f"Saved '{ssid}'. Will connect automatically when in range."}
- except subprocess.TimeoutExpired:
- return {"success": False, "message": "Operation timed out"}
- except Exception as e:
- logger.error(f"WiFi save error: {e}")
- return {"success": False, "message": str(e)}
- def forget_network(ssid: str) -> dict:
- """Delete a saved WiFi connection by SSID.
- If the forgotten network was the active connection, triggers the
- autohotspot script to re-evaluate and fall back to hotspot mode.
- """
- # Check if this is the currently active connection
- current_ssid = get_current_ssid()
- was_active = current_ssid == ssid or current_ssid == ssid.replace(" ", "")
- saved = get_saved_connections()
- con_name = None
- for con in saved:
- if con["ssid"] == ssid:
- con_name = con["name"]
- break
- if not con_name:
- return {"success": False, "message": f"No saved connection found for '{ssid}'"}
- try:
- result = run_nmcli_check("con", "delete", con_name, timeout=15)
- if result.returncode == 0:
- logger.info(f"Forgot WiFi network '{ssid}' (connection: {con_name})")
- # If we just forgot the active connection, re-run autohotspot
- # so it can fall back to hotspot mode
- if was_active:
- _trigger_autohotspot()
- return {"success": True, "message": f"Forgot '{ssid}'"}
- else:
- error_msg = result.stderr.strip() or "Failed to delete connection"
- return {"success": False, "message": error_msg}
- except Exception as e:
- return {"success": False, "message": str(e)}
- def get_hotspot_password() -> dict:
- """Get the current hotspot password (empty string if open network)."""
- try:
- output = run_nmcli("-s", "-t", "-f", "802-11-wireless-security.psk",
- "con", "show", HOTSPOT_CON_NAME)
- for line in output.strip().splitlines():
- if "802-11-wireless-security.psk" in line:
- psk = line.split(":", 1)[1] if ":" in line else ""
- return {"password": psk}
- return {"password": ""}
- except Exception as e:
- logger.error(f"Error getting hotspot password: {e}")
- return {"password": ""}
- def set_hotspot_password(password: str) -> dict:
- """Set or remove the hotspot password.
- If password is non-empty, enables WPA-PSK. If empty, removes security
- (open network). Restarts the hotspot if it's currently active.
- """
- try:
- if password:
- if len(password) < 8:
- return {"success": False, "message": "Password must be at least 8 characters"}
- result = run_nmcli_check(
- "con", "modify", HOTSPOT_CON_NAME,
- "wifi-sec.key-mgmt", "wpa-psk",
- "wifi-sec.psk", password,
- )
- else:
- # Remove security settings entirely to make it an open network
- result = run_nmcli_check(
- "con", "modify", HOTSPOT_CON_NAME,
- "remove", "802-11-wireless-security",
- )
- if result.returncode != 0:
- error_msg = result.stderr.strip() or "Failed to update hotspot password"
- return {"success": False, "message": error_msg}
- # Restart hotspot if currently active so changes take effect
- if get_wifi_mode() == "hotspot":
- run_nmcli_check("con", "down", HOTSPOT_CON_NAME, timeout=10)
- run_nmcli_check("con", "up", HOTSPOT_CON_NAME, timeout=10)
- msg = "Hotspot password updated" if password else "Hotspot password removed (open network)"
- logger.info(msg)
- return {"success": True, "message": msg}
- except Exception as e:
- logger.error(f"Error setting hotspot password: {e}")
- return {"success": False, "message": str(e)}
- def _trigger_autohotspot():
- """Trigger an immediate autohotspot check.
- Called when the active network is forgotten so the user doesn't have
- to wait for the next 60s timer tick.
- """
- autohotspot_path = "/usr/local/bin/autohotspot"
- try:
- if os.path.exists(autohotspot_path):
- logger.info("Triggering autohotspot --check after forgetting active network...")
- subprocess.Popen(
- [autohotspot_path, "--check"],
- stdout=subprocess.DEVNULL,
- stderr=subprocess.DEVNULL,
- )
- else:
- logger.info("Autohotspot script not found, activating hotspot directly...")
- run_nmcli("dev", "disconnect", "wlan0")
- run_nmcli("con", "up", HOTSPOT_CON_NAME)
- except Exception as e:
- logger.error(f"Failed to trigger autohotspot: {e}")
|