manager.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  1. """
  2. WiFi management via NetworkManager (nmcli).
  3. Handles scanning, connecting, and managing WiFi connections.
  4. Supports both Docker (via nsenter) and direct (venv) execution.
  5. """
  6. import subprocess
  7. import os
  8. import logging
  9. import asyncio
  10. import socket
  11. logger = logging.getLogger(__name__)
  12. MODE_FILE = "/tmp/dw-wifi-mode"
  13. def is_docker() -> bool:
  14. """Check if running inside a Docker container."""
  15. return os.path.exists("/.dockerenv") or os.getenv("DOCKER_CONTAINER") == "1"
  16. def run_host_command(*args: str, timeout: int = 30) -> subprocess.CompletedProcess:
  17. """Run a command on the host, handling Docker vs venv.
  18. In Docker: uses nsenter to execute in the host's namespaces.
  19. In venv: executes directly.
  20. """
  21. if is_docker():
  22. # nsenter into PID 1 to run on the host.
  23. # We must use a host shell so that PATH resolution uses the host's filesystem.
  24. # Without a shell, nsenter tries to resolve the binary from the container's mount.
  25. shell_cmd = " ".join(f"'{a}'" if " " in a else a for a in args)
  26. cmd = ["nsenter", "-t", "1", "-m", "-u", "-i", "-n", "-p", "--", "/bin/sh", "-c", shell_cmd]
  27. else:
  28. cmd = list(args)
  29. logger.info(f"Running host command: {' '.join(cmd)}")
  30. result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
  31. if result.returncode != 0:
  32. logger.warning(f"Command failed (rc={result.returncode}): {' '.join(cmd)}")
  33. if result.stderr:
  34. logger.warning(f" stderr: {result.stderr.strip()}")
  35. if result.stdout:
  36. logger.warning(f" stdout: {result.stdout.strip()}")
  37. return result
  38. def run_nmcli(*args: str, timeout: int = 30) -> str:
  39. """Run nmcli on the host and return stdout."""
  40. result = run_host_command("nmcli", *args, timeout=timeout)
  41. return result.stdout
  42. def get_wifi_mode() -> str:
  43. """Get the current WiFi mode from the mode file."""
  44. try:
  45. if is_docker():
  46. result = run_host_command("cat", MODE_FILE)
  47. return result.stdout.strip() or "unknown"
  48. else:
  49. with open(MODE_FILE, "r") as f:
  50. return f.read().strip() or "unknown"
  51. except (FileNotFoundError, subprocess.TimeoutExpired):
  52. return "unknown"
  53. def get_current_ssid() -> str:
  54. """Get the SSID of the currently connected WiFi network."""
  55. try:
  56. output = run_nmcli("-t", "-f", "GENERAL.CONNECTION", "dev", "show", "wlan0")
  57. for line in output.strip().splitlines():
  58. if "GENERAL.CONNECTION" in line:
  59. ssid = line.split(":", 1)[1] if ":" in line else ""
  60. if ssid and ssid != "--" and ssid != "DuneWeaver-Hotspot":
  61. return ssid
  62. except (subprocess.TimeoutExpired, Exception) as e:
  63. logger.debug(f"Error getting SSID: {e}")
  64. return ""
  65. def get_current_ip() -> str:
  66. """Get the current IP address of the wlan0 interface."""
  67. try:
  68. output = run_nmcli("-t", "-f", "IP4.ADDRESS", "dev", "show", "wlan0")
  69. for line in output.strip().splitlines():
  70. if "IP4.ADDRESS" in line:
  71. addr = line.split(":", 1)[1] if ":" in line else ""
  72. if addr:
  73. return addr.split("/")[0]
  74. except (subprocess.TimeoutExpired, Exception) as e:
  75. logger.debug(f"Error getting IP: {e}")
  76. return ""
  77. def get_hostname() -> str:
  78. """Get the host system hostname (not the container hostname)."""
  79. try:
  80. if is_docker():
  81. result = run_host_command("hostname")
  82. name = result.stdout.strip()
  83. if name:
  84. return name
  85. return socket.gethostname()
  86. except Exception:
  87. return "duneweaver"
  88. def get_wifi_status() -> dict:
  89. """Get comprehensive WiFi status."""
  90. mode = get_wifi_mode()
  91. ssid = get_current_ssid()
  92. ip = get_current_ip()
  93. hostname = get_hostname()
  94. return {
  95. "mode": mode,
  96. "ssid": ssid,
  97. "ip": ip,
  98. "hostname": hostname,
  99. }
  100. def scan_networks() -> list[dict]:
  101. """Scan for available WiFi networks."""
  102. try:
  103. # Trigger rescan
  104. run_nmcli("dev", "wifi", "rescan", "ifname", "wlan0")
  105. except Exception:
  106. pass
  107. # Brief wait for scan results
  108. import time
  109. time.sleep(2)
  110. try:
  111. output = run_nmcli("-t", "-f", "SSID,SIGNAL,SECURITY,ACTIVE", "dev", "wifi", "list", "ifname", "wlan0")
  112. except subprocess.TimeoutExpired:
  113. logger.error("WiFi scan timed out")
  114. return []
  115. # Get saved connections for cross-reference
  116. saved = get_saved_connections()
  117. saved_ssids = {c["ssid"] for c in saved}
  118. networks = []
  119. seen_ssids = set()
  120. for line in output.strip().splitlines():
  121. if not line.strip():
  122. continue
  123. # nmcli -t uses : as delimiter, but SSID can contain colons
  124. # Format: SSID:SIGNAL:SECURITY:ACTIVE
  125. # Parse from the right since SSID is the only field that can contain ':'
  126. parts = line.rsplit(":", 3)
  127. if len(parts) < 4:
  128. continue
  129. ssid = parts[0].strip()
  130. if not ssid or ssid in seen_ssids:
  131. continue
  132. seen_ssids.add(ssid)
  133. try:
  134. signal = int(parts[1])
  135. except (ValueError, IndexError):
  136. signal = 0
  137. security = parts[2] if len(parts) > 2 else ""
  138. active = parts[3].strip().lower() == "yes" if len(parts) > 3 else False
  139. networks.append({
  140. "ssid": ssid,
  141. "signal": signal,
  142. "security": security if security and security != "--" else "Open",
  143. "saved": ssid in saved_ssids,
  144. "active": active,
  145. })
  146. # Sort by signal strength (strongest first)
  147. networks.sort(key=lambda n: n["signal"], reverse=True)
  148. return networks
  149. def get_saved_connections() -> list[dict]:
  150. """Get list of saved WiFi connections."""
  151. try:
  152. output = run_nmcli("-t", "-f", "NAME,TYPE", "con", "show")
  153. except subprocess.TimeoutExpired:
  154. return []
  155. connections = []
  156. for line in output.strip().splitlines():
  157. if "wireless" not in line:
  158. continue
  159. name = line.split(":")[0]
  160. if name == "DuneWeaver-Hotspot":
  161. continue
  162. # Get the SSID for this connection
  163. try:
  164. detail = run_nmcli("-t", "-f", "802-11-wireless.ssid", "con", "show", name)
  165. ssid = ""
  166. for detail_line in detail.strip().splitlines():
  167. if "802-11-wireless.ssid" in detail_line:
  168. ssid = detail_line.split(":", 1)[1] if ":" in detail_line else name
  169. break
  170. if not ssid:
  171. ssid = name
  172. except Exception:
  173. ssid = name
  174. connections.append({
  175. "name": name,
  176. "ssid": ssid,
  177. })
  178. return connections
  179. async def connect_to_network(ssid: str, password: str) -> dict:
  180. """Connect to a WiFi network and schedule reboot.
  181. Uses explicit connection profile creation (nmcli con add) instead of
  182. 'nmcli dev wifi connect' because the latter fails on Pi Trixie with
  183. 'key-mgmt: property is missing' for WPA networks.
  184. """
  185. try:
  186. # Delete any stale connection profile for this SSID
  187. run_host_command("nmcli", "con", "delete", ssid, timeout=10)
  188. # Create connection profile with explicit security settings
  189. if password:
  190. result = run_host_command(
  191. "nmcli", "con", "add",
  192. "type", "wifi",
  193. "ifname", "wlan0",
  194. "con-name", ssid,
  195. "ssid", ssid,
  196. "wifi-sec.key-mgmt", "wpa-psk",
  197. "wifi-sec.psk", password,
  198. timeout=15,
  199. )
  200. else:
  201. result = run_host_command(
  202. "nmcli", "con", "add",
  203. "type", "wifi",
  204. "ifname", "wlan0",
  205. "con-name", ssid,
  206. "ssid", ssid,
  207. timeout=15,
  208. )
  209. if result.returncode != 0:
  210. error_msg = result.stderr.strip() or "Failed to create connection"
  211. logger.error(f"WiFi connection add failed: {error_msg}")
  212. return {"success": False, "message": error_msg}
  213. # Activate the connection
  214. result = run_host_command(
  215. "nmcli", "con", "up", ssid,
  216. timeout=30,
  217. )
  218. if result.returncode != 0:
  219. error_msg = result.stderr.strip() or "Failed to connect"
  220. logger.error(f"WiFi connect failed: {error_msg}")
  221. # Clean up the failed connection profile
  222. run_host_command("nmcli", "con", "delete", ssid, timeout=10)
  223. return {"success": False, "message": error_msg}
  224. logger.info(f"WiFi connection to '{ssid}' successful, scheduling reboot...")
  225. # Schedule reboot so the response can be sent first
  226. asyncio.get_event_loop().call_later(3, _schedule_reboot)
  227. return {
  228. "success": True,
  229. "message": f"Connected to '{ssid}'. Rebooting in 3 seconds...",
  230. }
  231. except subprocess.TimeoutExpired:
  232. return {"success": False, "message": "Connection timed out"}
  233. except Exception as e:
  234. logger.error(f"WiFi connect error: {e}")
  235. return {"success": False, "message": str(e)}
  236. def _schedule_reboot():
  237. """Trigger a system reboot."""
  238. try:
  239. logger.info("Rebooting system for WiFi mode change...")
  240. run_host_command("reboot")
  241. except Exception as e:
  242. logger.error(f"Reboot failed: {e}")
  243. def forget_network(ssid: str) -> dict:
  244. """Delete a saved WiFi connection by SSID."""
  245. saved = get_saved_connections()
  246. con_name = None
  247. for con in saved:
  248. if con["ssid"] == ssid:
  249. con_name = con["name"]
  250. break
  251. if not con_name:
  252. return {"success": False, "message": f"No saved connection found for '{ssid}'"}
  253. try:
  254. result = run_host_command("nmcli", "con", "delete", con_name, timeout=15)
  255. if result.returncode == 0:
  256. logger.info(f"Forgot WiFi network '{ssid}' (connection: {con_name})")
  257. return {"success": True, "message": f"Forgot '{ssid}'"}
  258. else:
  259. error_msg = result.stderr.strip() or "Failed to delete connection"
  260. return {"success": False, "message": error_msg}
  261. except Exception as e:
  262. return {"success": False, "message": str(e)}