1
0

manager.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447
  1. """
  2. WiFi management via NetworkManager (nmcli).
  3. Handles scanning, connecting, and managing WiFi connections.
  4. """
  5. import subprocess
  6. import os
  7. import logging
  8. import asyncio
  9. logger = logging.getLogger(__name__)
  10. HOTSPOT_CON_NAME = "DuneWeaver-Hotspot"
  11. def run_nmcli(*args: str, timeout: int = 30) -> str:
  12. """Run nmcli and return stdout."""
  13. cmd = ["nmcli"] + list(args)
  14. logger.debug(f"Running nmcli: {' '.join(cmd)}")
  15. result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
  16. if result.returncode != 0:
  17. logger.warning(f"nmcli failed (rc={result.returncode}): {' '.join(cmd)}")
  18. if result.stderr:
  19. logger.warning(f" stderr: {result.stderr.strip()}")
  20. return result.stdout
  21. def run_nmcli_check(*args: str, timeout: int = 30) -> subprocess.CompletedProcess:
  22. """Run nmcli and return the full CompletedProcess (for checking returncode)."""
  23. cmd = ["nmcli"] + list(args)
  24. logger.debug(f"Running nmcli: {' '.join(cmd)}")
  25. result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
  26. if result.returncode != 0:
  27. logger.warning(f"nmcli failed (rc={result.returncode}): {' '.join(cmd)}")
  28. if result.stderr:
  29. logger.warning(f" stderr: {result.stderr.strip()}")
  30. return result
  31. def get_wifi_mode() -> str:
  32. """Detect WiFi mode by querying NetworkManager active connections.
  33. Uses 'nmcli con show --active' instead of 'nmcli dev show wlan0'
  34. because the latter can behave differently in AP (hotspot) mode
  35. across NM versions.
  36. """
  37. try:
  38. output = run_nmcli("-t", "-f", "NAME,TYPE,DEVICE", "con", "show", "--active")
  39. logger.info(f"Active connections: {output.strip()}")
  40. for line in output.strip().splitlines():
  41. parts = line.split(":")
  42. if len(parts) >= 3 and parts[2] == "wlan0":
  43. con_name = parts[0]
  44. if con_name == HOTSPOT_CON_NAME:
  45. return "hotspot"
  46. return "client"
  47. except (subprocess.TimeoutExpired, Exception) as e:
  48. logger.warning(f"Error detecting WiFi mode: {e}")
  49. return "unknown"
  50. def get_current_ssid() -> str:
  51. """Get the SSID of the currently connected WiFi network."""
  52. try:
  53. # Use active connections to find the wifi connection on wlan0
  54. output = run_nmcli("-t", "-f", "NAME,TYPE,DEVICE", "con", "show", "--active")
  55. for line in output.strip().splitlines():
  56. parts = line.split(":")
  57. if len(parts) >= 3 and parts[2] == "wlan0":
  58. con_name = parts[0]
  59. if con_name and con_name != HOTSPOT_CON_NAME:
  60. return con_name
  61. except (subprocess.TimeoutExpired, Exception) as e:
  62. logger.debug(f"Error getting SSID: {e}")
  63. return ""
  64. def get_current_ip() -> str:
  65. """Get the current IP address of the wlan0 interface."""
  66. try:
  67. output = run_nmcli("-t", "-f", "IP4.ADDRESS", "dev", "show", "wlan0")
  68. logger.debug(f"IP output: {output.strip()}")
  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 system hostname via NetworkManager."""
  79. try:
  80. output = run_nmcli("general", "hostname")
  81. name = output.strip()
  82. if name:
  83. return name
  84. except Exception:
  85. pass
  86. return "duneweaver"
  87. def get_wifi_status() -> dict:
  88. """Get comprehensive WiFi status."""
  89. mode = get_wifi_mode()
  90. ssid = get_current_ssid()
  91. ip = get_current_ip()
  92. hostname = get_hostname()
  93. return {
  94. "mode": mode,
  95. "ssid": ssid,
  96. "ip": ip,
  97. "hostname": hostname,
  98. }
  99. def scan_networks() -> list[dict]:
  100. """Scan for available WiFi networks."""
  101. try:
  102. # Trigger rescan
  103. run_nmcli("dev", "wifi", "rescan", "ifname", "wlan0")
  104. except Exception:
  105. pass
  106. # Brief wait for scan results
  107. import time
  108. time.sleep(2)
  109. try:
  110. output = run_nmcli("-t", "-f", "SSID,SIGNAL,SECURITY,ACTIVE", "dev", "wifi", "list", "ifname", "wlan0")
  111. except subprocess.TimeoutExpired:
  112. logger.error("WiFi scan timed out")
  113. return []
  114. # Get saved connections for cross-reference
  115. saved = get_saved_connections()
  116. saved_ssids = {c["ssid"] for c in saved}
  117. networks = []
  118. seen_ssids = set()
  119. for line in output.strip().splitlines():
  120. if not line.strip():
  121. continue
  122. # nmcli -t uses : as delimiter, but SSID can contain colons
  123. # Format: SSID:SIGNAL:SECURITY:ACTIVE
  124. # Parse from the right since SSID is the only field that can contain ':'
  125. parts = line.rsplit(":", 3)
  126. if len(parts) < 4:
  127. continue
  128. ssid = parts[0].strip()
  129. if not ssid or ssid in seen_ssids:
  130. continue
  131. seen_ssids.add(ssid)
  132. try:
  133. signal = int(parts[1])
  134. except (ValueError, IndexError):
  135. signal = 0
  136. security = parts[2] if len(parts) > 2 else ""
  137. active = parts[3].strip().lower() == "yes" if len(parts) > 3 else False
  138. networks.append({
  139. "ssid": ssid,
  140. "signal": signal,
  141. "security": security if security and security != "--" else "Open",
  142. "saved": ssid in saved_ssids,
  143. "active": active,
  144. })
  145. # Sort by signal strength (strongest first)
  146. networks.sort(key=lambda n: n["signal"], reverse=True)
  147. return networks
  148. def get_saved_connections() -> list[dict]:
  149. """Get list of saved WiFi connections."""
  150. try:
  151. output = run_nmcli("-t", "-f", "NAME,TYPE", "con", "show")
  152. except subprocess.TimeoutExpired:
  153. return []
  154. connections = []
  155. for line in output.strip().splitlines():
  156. if "wireless" not in line:
  157. continue
  158. name = line.split(":")[0]
  159. if name == HOTSPOT_CON_NAME:
  160. continue
  161. # Get the SSID for this connection
  162. try:
  163. detail = run_nmcli("-t", "-f", "802-11-wireless.ssid", "con", "show", name)
  164. ssid = ""
  165. for detail_line in detail.strip().splitlines():
  166. if "802-11-wireless.ssid" in detail_line:
  167. ssid = detail_line.split(":", 1)[1] if ":" in detail_line else name
  168. break
  169. if not ssid:
  170. ssid = name
  171. except Exception:
  172. ssid = name
  173. connections.append({
  174. "name": name,
  175. "ssid": ssid,
  176. })
  177. return connections
  178. async def connect_to_network(ssid: str, password: str) -> dict:
  179. """Connect to a WiFi network.
  180. Uses explicit connection profile creation (nmcli con add) instead of
  181. 'nmcli dev wifi connect' because the latter fails on Pi Trixie with
  182. 'key-mgmt: property is missing' for WPA networks.
  183. """
  184. try:
  185. # Delete any stale connection profile for this SSID (ignore if not found)
  186. subprocess.run(
  187. ["nmcli", "con", "delete", ssid],
  188. capture_output=True, text=True, timeout=10,
  189. )
  190. # Create connection profile with explicit security settings
  191. if password:
  192. result = run_nmcli_check(
  193. "con", "add",
  194. "type", "wifi",
  195. "ifname", "wlan0",
  196. "con-name", ssid,
  197. "ssid", ssid,
  198. "wifi-sec.key-mgmt", "wpa-psk",
  199. "wifi-sec.psk", password,
  200. timeout=15,
  201. )
  202. else:
  203. result = run_nmcli_check(
  204. "con", "add",
  205. "type", "wifi",
  206. "ifname", "wlan0",
  207. "con-name", ssid,
  208. "ssid", ssid,
  209. timeout=15,
  210. )
  211. if result.returncode != 0:
  212. error_msg = result.stderr.strip() or "Failed to create connection"
  213. logger.error(f"WiFi connection add failed: {error_msg}")
  214. return {"success": False, "message": error_msg}
  215. # Activate the connection
  216. result = run_nmcli_check("con", "up", ssid, timeout=30)
  217. if result.returncode != 0:
  218. error_msg = result.stderr.strip() or "Failed to connect"
  219. logger.error(f"WiFi connect failed: {error_msg}")
  220. # Clean up the failed connection profile
  221. run_nmcli_check("con", "delete", ssid, timeout=10)
  222. return {"success": False, "message": error_msg}
  223. logger.info(f"WiFi connection to '{ssid}' successful")
  224. return {
  225. "success": True,
  226. "message": f"Connected to '{ssid}'.",
  227. }
  228. except subprocess.TimeoutExpired:
  229. return {"success": False, "message": "Connection timed out"}
  230. except Exception as e:
  231. logger.error(f"WiFi connect error: {e}")
  232. return {"success": False, "message": str(e)}
  233. def save_network(ssid: str, password: str) -> dict:
  234. """Save a WiFi network profile without connecting.
  235. Creates the connection profile so autohotspot can use it on next check/boot.
  236. """
  237. try:
  238. # Delete any stale connection profile for this SSID (ignore if not found)
  239. subprocess.run(
  240. ["nmcli", "con", "delete", ssid],
  241. capture_output=True, text=True, timeout=10,
  242. )
  243. if password:
  244. result = run_nmcli_check(
  245. "con", "add",
  246. "type", "wifi",
  247. "ifname", "wlan0",
  248. "con-name", ssid,
  249. "ssid", ssid,
  250. "wifi-sec.key-mgmt", "wpa-psk",
  251. "wifi-sec.psk", password,
  252. timeout=15,
  253. )
  254. else:
  255. result = run_nmcli_check(
  256. "con", "add",
  257. "type", "wifi",
  258. "ifname", "wlan0",
  259. "con-name", ssid,
  260. "ssid", ssid,
  261. timeout=15,
  262. )
  263. if result.returncode != 0:
  264. error_msg = result.stderr.strip() or "Failed to save network"
  265. logger.error(f"WiFi save failed: {error_msg}")
  266. return {"success": False, "message": error_msg}
  267. logger.info(f"Saved WiFi network '{ssid}' (not connecting)")
  268. return {"success": True, "message": f"Saved '{ssid}'. Will connect automatically when in range."}
  269. except subprocess.TimeoutExpired:
  270. return {"success": False, "message": "Operation timed out"}
  271. except Exception as e:
  272. logger.error(f"WiFi save error: {e}")
  273. return {"success": False, "message": str(e)}
  274. def forget_network(ssid: str) -> dict:
  275. """Delete a saved WiFi connection by SSID.
  276. If the forgotten network was the active connection, triggers the
  277. autohotspot script to re-evaluate and fall back to hotspot mode.
  278. """
  279. # Check if this is the currently active connection
  280. current_ssid = get_current_ssid()
  281. was_active = current_ssid == ssid or current_ssid == ssid.replace(" ", "")
  282. saved = get_saved_connections()
  283. con_name = None
  284. for con in saved:
  285. if con["ssid"] == ssid:
  286. con_name = con["name"]
  287. break
  288. if not con_name:
  289. return {"success": False, "message": f"No saved connection found for '{ssid}'"}
  290. try:
  291. result = run_nmcli_check("con", "delete", con_name, timeout=15)
  292. if result.returncode == 0:
  293. logger.info(f"Forgot WiFi network '{ssid}' (connection: {con_name})")
  294. # If we just forgot the active connection, re-run autohotspot
  295. # so it can fall back to hotspot mode
  296. if was_active:
  297. _trigger_autohotspot()
  298. return {"success": True, "message": f"Forgot '{ssid}'"}
  299. else:
  300. error_msg = result.stderr.strip() or "Failed to delete connection"
  301. return {"success": False, "message": error_msg}
  302. except Exception as e:
  303. return {"success": False, "message": str(e)}
  304. def get_hotspot_password() -> dict:
  305. """Get the current hotspot password (empty string if open network)."""
  306. try:
  307. output = run_nmcli("-s", "-t", "-f", "802-11-wireless-security.psk",
  308. "con", "show", HOTSPOT_CON_NAME)
  309. for line in output.strip().splitlines():
  310. if "802-11-wireless-security.psk" in line:
  311. psk = line.split(":", 1)[1] if ":" in line else ""
  312. return {"password": psk}
  313. return {"password": ""}
  314. except Exception as e:
  315. logger.error(f"Error getting hotspot password: {e}")
  316. return {"password": ""}
  317. def set_hotspot_password(password: str) -> dict:
  318. """Set or remove the hotspot password.
  319. If password is non-empty, enables WPA-PSK. If empty, removes security
  320. (open network). Restarts the hotspot if it's currently active.
  321. """
  322. try:
  323. if password:
  324. if len(password) < 8:
  325. return {"success": False, "message": "Password must be at least 8 characters"}
  326. result = run_nmcli_check(
  327. "con", "modify", HOTSPOT_CON_NAME,
  328. "wifi-sec.key-mgmt", "wpa-psk",
  329. "wifi-sec.psk", password,
  330. )
  331. else:
  332. # Remove security settings entirely to make it an open network
  333. result = run_nmcli_check(
  334. "con", "modify", HOTSPOT_CON_NAME,
  335. "remove", "802-11-wireless-security",
  336. )
  337. if result.returncode != 0:
  338. error_msg = result.stderr.strip() or "Failed to update hotspot password"
  339. return {"success": False, "message": error_msg}
  340. # Restart hotspot if currently active so changes take effect
  341. if get_wifi_mode() == "hotspot":
  342. run_nmcli_check("con", "down", HOTSPOT_CON_NAME, timeout=10)
  343. run_nmcli_check("con", "up", HOTSPOT_CON_NAME, timeout=10)
  344. msg = "Hotspot password updated" if password else "Hotspot password removed (open network)"
  345. logger.info(msg)
  346. return {"success": True, "message": msg}
  347. except Exception as e:
  348. logger.error(f"Error setting hotspot password: {e}")
  349. return {"success": False, "message": str(e)}
  350. def _trigger_autohotspot():
  351. """Trigger an immediate autohotspot check.
  352. Called when the active network is forgotten so the user doesn't have
  353. to wait for the next 60s timer tick.
  354. """
  355. autohotspot_path = "/usr/local/bin/autohotspot"
  356. try:
  357. if os.path.exists(autohotspot_path):
  358. logger.info("Triggering autohotspot --check after forgetting active network...")
  359. subprocess.Popen(
  360. [autohotspot_path, "--check"],
  361. stdout=subprocess.DEVNULL,
  362. stderr=subprocess.DEVNULL,
  363. )
  364. else:
  365. logger.info("Autohotspot script not found, activating hotspot directly...")
  366. run_nmcli("dev", "disconnect", "wlan0")
  367. run_nmcli("con", "up", HOTSPOT_CON_NAME)
  368. except Exception as e:
  369. logger.error(f"Failed to trigger autohotspot: {e}")