import atexit import threading import time import uuid import subprocess import os import shutil from collections.abc import Callable from dataclasses import dataclass, replace from enum import IntEnum from typing import Any JEEPNY_AVAILABLE = True try: from jeepney import DBusAddress, new_method_call from jeepney.bus_messages import MatchRule, message_bus from jeepney.io.blocking import DBusConnection, open_dbus_connection as open_dbus_connection_blocking from jeepney.io.threading import DBusRouter, open_dbus_connection as open_dbus_connection_threading from jeepney.low_level import MessageType from jeepney.wrappers import Properties except ImportError: JEEPNY_AVAILABLE = False DBusAddress = DBusConnection = DBusRouter = MatchRule = MessageType = Properties = Any message_bus = None def new_method_call(*_args, **_kwargs): raise RuntimeError("jeepney unavailable") def open_dbus_connection_blocking(*_args, **_kwargs): raise RuntimeError("jeepney unavailable") def open_dbus_connection_threading(*_args, **_kwargs): raise RuntimeError("jeepney unavailable") from openpilot.common.swaglog import cloudlog from openpilot.system.hardware import PC from openpilot.system.ui.lib.networkmanager import (NM, NM_WIRELESS_IFACE, NM_802_11_AP_SEC_PAIR_WEP40, NM_802_11_AP_SEC_PAIR_WEP104, NM_802_11_AP_SEC_GROUP_WEP40, NM_802_11_AP_SEC_GROUP_WEP104, NM_802_11_AP_SEC_KEY_MGMT_PSK, NM_802_11_AP_SEC_KEY_MGMT_802_1X, NM_802_11_AP_FLAGS_NONE, NM_802_11_AP_FLAGS_PRIVACY, NM_802_11_AP_FLAGS_WPS, NM_PATH, NM_IFACE, NM_ACCESS_POINT_IFACE, NM_SETTINGS_PATH, NM_SETTINGS_IFACE, NM_CONNECTION_IFACE, NM_DEVICE_IFACE, NM_DEVICE_TYPE_WIFI, NM_DEVICE_TYPE_MODEM, NM_ACTIVE_CONNECTION_IFACE, NM_IP4_CONFIG_IFACE, NM_PROPERTIES_IFACE, NMDeviceState, NMDeviceStateReason) try: from openpilot.common.params import Params except Exception: Params = None TETHERING_IP_ADDRESS = "192.168.43.1" DEFAULT_TETHERING_PASSWORD = "swagswagcomma" SIGNAL_QUEUE_SIZE = 10 SCAN_PERIOD_SECONDS = 5 DESKTOP_FAKE_IP = "192.168.1.42" TRUE_VALUES = {"1", "true", "yes", "on"} DEBUG = False _dbus_call_idx = 0 def normalize_ssid(ssid: str) -> str: return ssid.replace("’", "'") # for iPhone hotspots def _wrap_router(router): def _wrap(orig): def wrapper(msg, **kw): global _dbus_call_idx _dbus_call_idx += 1 if DEBUG: h = msg.header.fields print(f"[DBUS #{_dbus_call_idx}] {h.get(6, '?')} {h.get(3, '?')} {msg.body}") return orig(msg, **kw) return wrapper router.send_and_get_reply = _wrap(router.send_and_get_reply) router.send = _wrap(router.send) class SecurityType(IntEnum): OPEN = 0 WPA = 1 WPA2 = 2 WPA3 = 3 UNSUPPORTED = 4 class MeteredType(IntEnum): UNKNOWN = 0 YES = 1 NO = 2 def get_security_type(flags: int, wpa_flags: int, rsn_flags: int) -> SecurityType: wpa_props = wpa_flags | rsn_flags # obtained by looking at flags of networks in the office as reported by an Android phone supports_wpa = (NM_802_11_AP_SEC_PAIR_WEP40 | NM_802_11_AP_SEC_PAIR_WEP104 | NM_802_11_AP_SEC_GROUP_WEP40 | NM_802_11_AP_SEC_GROUP_WEP104 | NM_802_11_AP_SEC_KEY_MGMT_PSK) if (flags == NM_802_11_AP_FLAGS_NONE) or ((flags & NM_802_11_AP_FLAGS_WPS) and not (wpa_props & supports_wpa)): return SecurityType.OPEN elif (flags & NM_802_11_AP_FLAGS_PRIVACY) and (wpa_props & supports_wpa) and not (wpa_props & NM_802_11_AP_SEC_KEY_MGMT_802_1X): return SecurityType.WPA else: cloudlog.warning(f"Unsupported network! flags: {flags}, wpa_flags: {wpa_flags}, rsn_flags: {rsn_flags}") return SecurityType.UNSUPPORTED @dataclass(frozen=True) class Network: ssid: str strength: int security_type: SecurityType is_tethering: bool @classmethod def from_dbus(cls, ssid: str, aps: list["AccessPoint"], is_tethering: bool) -> "Network": # we only want to show the strongest AP for each Network/SSID strongest_ap = max(aps, key=lambda ap: ap.strength) security_type = get_security_type(strongest_ap.flags, strongest_ap.wpa_flags, strongest_ap.rsn_flags) return cls( ssid=ssid, strength=100 if is_tethering else strongest_ap.strength, security_type=security_type, is_tethering=is_tethering, ) @dataclass(frozen=True) class AccessPoint: ssid: str bssid: str strength: int flags: int wpa_flags: int rsn_flags: int ap_path: str @classmethod def from_dbus(cls, ap_props: dict[str, tuple[str, Any]], ap_path: str) -> "AccessPoint": ssid = bytes(ap_props['Ssid'][1]).decode("utf-8", "replace") bssid = str(ap_props['HwAddress'][1]) strength = int(ap_props['Strength'][1]) flags = int(ap_props['Flags'][1]) wpa_flags = int(ap_props['WpaFlags'][1]) rsn_flags = int(ap_props['RsnFlags'][1]) return cls( ssid=ssid, bssid=bssid, strength=strength, flags=flags, wpa_flags=wpa_flags, rsn_flags=rsn_flags, ap_path=ap_path, ) class ConnectStatus(IntEnum): DISCONNECTED = 0 CONNECTING = 1 CONNECTED = 2 @dataclass(frozen=True) class WifiState: ssid: str | None = None status: ConnectStatus = ConnectStatus.DISCONNECTED class WifiManager: def __init__(self): self._networks: list[Network] = [] # an unsorted list of available Networks. a Network can be comprised of multiple APs self._active = True # used to not run when not in settings self._exit = False self._fake_networking = False self._nmcli_networking = False self._backend_unavailable = False allow_desktop_fake = PC and os.getenv("SP_ALLOW_DESKTOP_FAKE_WIFI", "0").lower() in TRUE_VALUES has_nmcli = shutil.which("nmcli") is not None # DBus connections if not JEEPNY_AVAILABLE: cloudlog.warning("jeepney unavailable") self._router_main = None self._conn_monitor = None self._nm = None if allow_desktop_fake: cloudlog.warning("Using desktop fake Wi-Fi backend") self._fake_networking = True elif has_nmcli: cloudlog.warning("Using nmcli Wi-Fi backend") self._nmcli_networking = True else: cloudlog.warning("Wi-Fi backend disabled") self._backend_unavailable = True self._exit = True else: try: self._router_main = DBusRouter(open_dbus_connection_threading(bus="SYSTEM")) # used by scanner / general method calls _wrap_router(self._router_main) self._conn_monitor = open_dbus_connection_blocking(bus="SYSTEM") # used by state monitor thread self._nm = DBusAddress(NM_PATH, bus_name=NM, interface=NM_IFACE) except Exception: if allow_desktop_fake: cloudlog.warning("Failed to connect to system D-Bus; using desktop fake Wi-Fi backend") self._fake_networking = True elif has_nmcli: cloudlog.warning("Failed to connect to system D-Bus; using nmcli Wi-Fi backend") self._nmcli_networking = True else: cloudlog.exception("Failed to connect to system D-Bus") self._backend_unavailable = True self._exit = True self._router_main = None self._conn_monitor = None self._nm = None # Store wifi device path self._wifi_device: str | None = None # State self._connections: dict[str, str] = {} # ssid -> connection path, updated via NM signals self._wifi_state: WifiState = WifiState() self._user_epoch: int = 0 self._ipv4_address: str = "" self._current_network_metered: MeteredType = MeteredType.UNKNOWN self._tethering_password: str = "" self._ipv4_forward = False self._last_network_scan: float = 0.0 self._callback_queue: list[Callable] = [] self._tethering_ssid = "weedle" if Params is not None: dongle_id = Params().get("DongleId") if dongle_id: self._tethering_ssid += "-" + dongle_id[:4] # Callbacks self._need_auth: list[Callable[[str], None]] = [] self._activated: list[Callable[[], None]] = [] self._forgotten: list[Callable[[str | None], None]] = [] self._networks_updated: list[Callable[[list[Network]], None]] = [] self._disconnected: list[Callable[[], None]] = [] self._scan_lock = threading.Lock() self._scan_thread = threading.Thread(target=self._network_scanner, daemon=True) self._state_thread = threading.Thread(target=self._monitor_state, daemon=True) self._initialize() atexit.register(self.stop) def _initialize(self): def worker(): if self._backend_unavailable: cloudlog.warning("WifiManager initialized without D-Bus backend") return if self._fake_networking: self._tethering_password = DEFAULT_TETHERING_PASSWORD self._enqueue_callbacks(self._networks_updated, self.networks) cloudlog.debug("WifiManager initialized in desktop fake mode") return if self._nmcli_networking: self._tethering_password = DEFAULT_TETHERING_PASSWORD self._update_networks() self._scan_thread.start() cloudlog.debug("WifiManager initialized in nmcli mode") return self._wait_for_wifi_device() self._init_connections() if Params is not None and self._tethering_ssid not in self._connections: self._add_tethering_connection() self._init_wifi_state() self._scan_thread.start() self._state_thread.start() self._tethering_password = self._get_tethering_password() cloudlog.debug("WifiManager initialized") threading.Thread(target=worker, daemon=True).start() def _init_wifi_state(self, block: bool = True): def worker(): if self._wifi_device is None: cloudlog.warning("No WiFi device found") return epoch = self._user_epoch dev_addr = DBusAddress(self._wifi_device, bus_name=NM, interface=NM_DEVICE_IFACE) dev_state = self._router_main.send_and_get_reply(Properties(dev_addr).get('State')).body[0][1] ssid: str | None = None status = ConnectStatus.DISCONNECTED if NMDeviceState.PREPARE <= dev_state <= NMDeviceState.SECONDARIES and dev_state != NMDeviceState.NEED_AUTH: status = ConnectStatus.CONNECTING elif dev_state == NMDeviceState.ACTIVATED: status = ConnectStatus.CONNECTED conn_path, _ = self._get_active_wifi_connection() if conn_path: ssid = next((s for s, p in self._connections.items() if p == conn_path), None) # Discard if user acted during DBus calls if self._user_epoch != epoch: return self._wifi_state = WifiState(ssid=ssid, status=status) if block: worker() else: threading.Thread(target=worker, daemon=True).start() def add_callbacks(self, need_auth: Callable[[str], None] | None = None, activated: Callable[[], None] | None = None, forgotten: Callable[[str], None] | None = None, networks_updated: Callable[[list[Network]], None] | None = None, disconnected: Callable[[], None] | None = None): if need_auth is not None: self._need_auth.append(need_auth) if activated is not None: self._activated.append(activated) if forgotten is not None: self._forgotten.append(forgotten) if networks_updated is not None: self._networks_updated.append(networks_updated) if disconnected is not None: self._disconnected.append(disconnected) @property def networks(self) -> list[Network]: # Sort by connected/connecting, then known, then strength, then alphabetically. This is a pure UI ordering and should not affect underlying state. return sorted(self._networks, key=lambda n: (n.ssid != self._wifi_state.ssid, not self.is_connection_saved(n.ssid), -n.strength, n.ssid.lower())) @property def wifi_state(self) -> WifiState: return self._wifi_state @property def ipv4_address(self) -> str: return self._ipv4_address @property def current_network_metered(self) -> MeteredType: return self._current_network_metered @property def connecting_to_ssid(self) -> str | None: wifi_state = self._wifi_state return wifi_state.ssid if wifi_state.status == ConnectStatus.CONNECTING else None @property def connected_ssid(self) -> str | None: wifi_state = self._wifi_state return wifi_state.ssid if wifi_state.status == ConnectStatus.CONNECTED else None @property def tethering_password(self) -> str: return self._tethering_password def _set_connecting(self, ssid: str | None): # Called by user action, or sequentially from state change handler self._user_epoch += 1 self._wifi_state = WifiState(ssid=ssid, status=ConnectStatus.DISCONNECTED if ssid is None else ConnectStatus.CONNECTING) def _enqueue_callbacks(self, cbs: list[Callable], *args): for cb in cbs: self._callback_queue.append(lambda _cb=cb: _cb(*args)) def process_callbacks(self): # Call from UI thread to run any pending callbacks to_run, self._callback_queue = self._callback_queue, [] for cb in to_run: cb() def set_active(self, active: bool): self._active = active if self._backend_unavailable: return if self._nmcli_networking: if active: self._update_networks(block=False) return if self._fake_networking: if active: self._enqueue_callbacks(self._networks_updated, self.networks) return # Update networks and WiFi state (to self-heal) immediately when activating for UI if active: self._init_wifi_state(block=False) self._update_networks(block=False) def _monitor_state(self): # Filter for signals rules = ( MatchRule( type="signal", interface=NM_DEVICE_IFACE, member="StateChanged", path=self._wifi_device, ), MatchRule( type="signal", interface=NM_SETTINGS_IFACE, member="NewConnection", path=NM_SETTINGS_PATH, ), MatchRule( type="signal", interface=NM_SETTINGS_IFACE, member="ConnectionRemoved", path=NM_SETTINGS_PATH, ), MatchRule( type="signal", interface=NM_PROPERTIES_IFACE, member="PropertiesChanged", path=self._wifi_device, ), ) for rule in rules: self._conn_monitor.send_and_get_reply(message_bus.AddMatch(rule)) with (self._conn_monitor.filter(rules[0], bufsize=SIGNAL_QUEUE_SIZE) as state_q, self._conn_monitor.filter(rules[1], bufsize=SIGNAL_QUEUE_SIZE) as new_conn_q, self._conn_monitor.filter(rules[2], bufsize=SIGNAL_QUEUE_SIZE) as removed_conn_q, self._conn_monitor.filter(rules[3], bufsize=SIGNAL_QUEUE_SIZE) as props_q): while not self._exit: try: self._conn_monitor.recv_messages(timeout=1) except TimeoutError: continue # Connection added/removed while len(removed_conn_q): conn_path = removed_conn_q.popleft().body[0] self._connection_removed(conn_path) while len(new_conn_q): conn_path = new_conn_q.popleft().body[0] self._new_connection(conn_path) # PropertiesChanged on wifi device (LastScan = scan complete) while len(props_q): iface, changed, _ = props_q.popleft().body if iface == NM_WIRELESS_IFACE and 'LastScan' in changed: self._update_networks() # Device state changes while len(state_q): new_state, previous_state, change_reason = state_q.popleft().body self._handle_state_change(new_state, previous_state, change_reason) def _handle_state_change(self, new_state: int, prev_state: int, change_reason: int): # Thread safety: _wifi_state is read/written by both the monitor thread (this handler) # and the main thread (_set_connecting via connect/activate). PREPARE/CONFIG and ACTIVATED # have a read-then-write pattern with a slow DBus call in between — if _set_connecting # runs mid-call, the handler would overwrite the user's newer state with stale data. # # The _user_epoch counter solves this without locks. _set_connecting increments the epoch # on every user action. Handlers snapshot the epoch before their DBus call and compare # after: if it changed, a user action occurred during the call and the stale result is # discarded. Combined with deterministic fixes (skip DBus lookup when ssid already set, # DEACTIVATING clears CONNECTED on CONNECTION_REMOVED, CONNECTION_REMOVED guard), # all known race windows are closed. # TODO: Handle (FAILED, SSID_NOT_FOUND) and emit for UI to show error # Happens when network drops off after starting connection if new_state == NMDeviceState.DISCONNECTED: if change_reason == NMDeviceStateReason.NEW_ACTIVATION: return # Guard: forget A while connecting to B fires CONNECTION_REMOVED. Don't clear B's state # if B is still a known connection. If B hasn't arrived in _connections yet (late # NewConnection), state clears here but PREPARE recovers via DBus lookup. if (change_reason == NMDeviceStateReason.CONNECTION_REMOVED and self._wifi_state.ssid and self._wifi_state.ssid in self._connections): return self._set_connecting(None) elif new_state in (NMDeviceState.PREPARE, NMDeviceState.CONFIG): epoch = self._user_epoch if self._wifi_state.ssid is not None: self._wifi_state = replace(self._wifi_state, status=ConnectStatus.CONNECTING) return # Auto-connection when NetworkManager connects to known networks on its own (ssid=None): look up ssid from NM wifi_state = replace(self._wifi_state, status=ConnectStatus.CONNECTING) conn_path, _ = self._get_active_wifi_connection(self._conn_monitor) # Discard if user acted during DBus call if self._user_epoch != epoch: return if conn_path is None: cloudlog.warning("Failed to get active wifi connection during PREPARE/CONFIG state") else: wifi_state = replace(wifi_state, ssid=next((s for s, p in self._connections.items() if p == conn_path), None)) self._wifi_state = wifi_state # BAD PASSWORD # - strong network rejects with NEED_AUTH+SUPPLICANT_DISCONNECT # - weak/gone network fails with FAILED+NO_SECRETS # TODO: sometimes on PC it's observed no future signals are fired if mouse is held down blocking wrong password dialog elif ((new_state == NMDeviceState.NEED_AUTH and change_reason == NMDeviceStateReason.SUPPLICANT_DISCONNECT and prev_state == NMDeviceState.CONFIG) or (new_state == NMDeviceState.FAILED and change_reason == NMDeviceStateReason.NO_SECRETS)): # prev_state guard: real auth failures come from CONFIG (supplicant handshake). # Stale NEED_AUTH from a prior connection during network switching arrives with # prev_state=DISCONNECTED and must be ignored to avoid a false wrong-password callback. if self._wifi_state.ssid: self._enqueue_callbacks(self._need_auth, self._wifi_state.ssid) self._set_connecting(None) elif new_state in (NMDeviceState.NEED_AUTH, NMDeviceState.IP_CONFIG, NMDeviceState.IP_CHECK, NMDeviceState.SECONDARIES, NMDeviceState.FAILED): pass elif new_state == NMDeviceState.ACTIVATED: # Note that IP address from Ip4Config may not be propagated immediately and could take until the next scan results epoch = self._user_epoch wifi_state = replace(self._wifi_state, status=ConnectStatus.CONNECTED) conn_path, _ = self._get_active_wifi_connection(self._conn_monitor) # Discard if user acted during DBus call if self._user_epoch != epoch: return if conn_path is None: cloudlog.warning("Failed to get active wifi connection during ACTIVATED state") else: wifi_state = replace(wifi_state, ssid=next((s for s, p in self._connections.items() if p == conn_path), None)) self._wifi_state = wifi_state self._enqueue_callbacks(self._activated) self._update_active_connection_info() # Persist volatile connections (created by AddAndActivateConnection2) to disk if conn_path is not None: conn_addr = DBusAddress(conn_path, bus_name=NM, interface=NM_CONNECTION_IFACE) save_reply = self._conn_monitor.send_and_get_reply(new_method_call(conn_addr, 'Save')) if save_reply.header.message_type == MessageType.error: cloudlog.warning(f"Failed to persist connection to disk: {save_reply}") elif new_state == NMDeviceState.DEACTIVATING: # Must clear state when forgetting the currently connected network so the UI # doesn't flash "connected" after the eager "forgetting..." state resets # (the forgotten callback fires between DEACTIVATING and DISCONNECTED). # Only clear CONNECTED — CONNECTING must be preserved for forget-A-connect-B. if change_reason == NMDeviceStateReason.CONNECTION_REMOVED and self._wifi_state.status == ConnectStatus.CONNECTED: self._set_connecting(None) def _network_scanner(self): while not self._exit: if self._active: if time.monotonic() - self._last_network_scan > SCAN_PERIOD_SECONDS: self._request_scan() self._last_network_scan = time.monotonic() time.sleep(1 / 2.) def _wait_for_wifi_device(self): while not self._exit: device_path = self._get_adapter(NM_DEVICE_TYPE_WIFI) if device_path is not None: self._wifi_device = device_path break time.sleep(1) def _get_adapter(self, adapter_type: int) -> str | None: # Return the first NetworkManager device path matching adapter_type try: device_paths = self._router_main.send_and_get_reply(new_method_call(self._nm, 'GetDevices')).body[0] for device_path in device_paths: dev_addr = DBusAddress(device_path, bus_name=NM, interface=NM_DEVICE_IFACE) dev_type = self._router_main.send_and_get_reply(Properties(dev_addr).get('DeviceType')).body[0][1] if dev_type == adapter_type: return str(device_path) except Exception as e: cloudlog.exception(f"Error getting adapter type {adapter_type}: {e}") return None def _init_connections(self) -> None: settings_addr = DBusAddress(NM_SETTINGS_PATH, bus_name=NM, interface=NM_SETTINGS_IFACE) known_connections = self._router_main.send_and_get_reply(new_method_call(settings_addr, 'ListConnections')).body[0] conns: dict[str, str] = {} for conn_path in known_connections: settings = self._get_connection_settings(conn_path) if len(settings) == 0: cloudlog.warning(f'Failed to get connection settings for {conn_path}') continue if "802-11-wireless" in settings: ssid = settings['802-11-wireless']['ssid'][1].decode("utf-8", "replace") if ssid != "": conns[ssid] = conn_path self._connections = conns def _new_connection(self, conn_path: str): settings = self._get_connection_settings(conn_path) if "802-11-wireless" in settings: ssid = settings['802-11-wireless']['ssid'][1].decode("utf-8", "replace") if ssid != "": self._connections[ssid] = conn_path def _connection_removed(self, conn_path: str): self._connections = {ssid: path for ssid, path in self._connections.items() if path != conn_path} def _get_active_connections(self, router: DBusConnection | DBusRouter | None = None): # Returns list of ActiveConnection if router is None: router = self._router_main return router.send_and_get_reply(Properties(self._nm).get('ActiveConnections')).body[0][1] def _get_active_wifi_connection(self, router: DBusConnection | DBusRouter | None = None) -> tuple[str | None, dict | None]: # Returns first Connection settings path and ActiveConnection props from ActiveConnections with Type 802-11-wireless if router is None: router = self._router_main for active_conn in self._get_active_connections(router): conn_addr = DBusAddress(active_conn, bus_name=NM, interface=NM_ACTIVE_CONNECTION_IFACE) reply = router.send_and_get_reply(Properties(conn_addr).get_all()) if reply.header.message_type == MessageType.error: cloudlog.warning(f"Failed to get active connection properties for {active_conn}: {reply}") continue props = reply.body[0] conn_path = props.get('Connection', ('o', '/'))[1] if props.get('Type', ('s', ''))[1] == '802-11-wireless' and conn_path != '/': return conn_path, props return None, None def _get_connection_settings(self, conn_path: str) -> dict: conn_addr = DBusAddress(conn_path, bus_name=NM, interface=NM_CONNECTION_IFACE) reply = self._router_main.send_and_get_reply(new_method_call(conn_addr, 'GetSettings')) if reply.header.message_type == MessageType.error: cloudlog.warning(f'Failed to get connection settings: {reply}') return {} return dict(reply.body[0]) def _add_tethering_connection(self): connection = { 'connection': { 'type': ('s', '802-11-wireless'), 'uuid': ('s', str(uuid.uuid4())), 'id': ('s', 'Hotspot'), 'autoconnect-retries': ('i', 0), 'interface-name': ('s', 'wlan0'), 'autoconnect': ('b', False), }, '802-11-wireless': { 'band': ('s', 'bg'), 'mode': ('s', 'ap'), 'ssid': ('ay', self._tethering_ssid.encode("utf-8")), }, '802-11-wireless-security': { 'group': ('as', ['ccmp']), 'key-mgmt': ('s', 'wpa-psk'), 'pairwise': ('as', ['ccmp']), 'proto': ('as', ['rsn']), 'psk': ('s', DEFAULT_TETHERING_PASSWORD), }, 'ipv4': { 'method': ('s', 'shared'), 'address-data': ('aa{sv}', [[ ('address', ('s', TETHERING_IP_ADDRESS)), ('prefix', ('u', 24)), ]]), 'gateway': ('s', TETHERING_IP_ADDRESS), 'never-default': ('b', True), }, 'ipv6': {'method': ('s', 'ignore')}, } settings_addr = DBusAddress(NM_SETTINGS_PATH, bus_name=NM, interface=NM_SETTINGS_IFACE) self._router_main.send_and_get_reply(new_method_call(settings_addr, 'AddConnection', 'a{sa{sv}}', (connection,))) def connect_to_network(self, ssid: str, password: str, hidden: bool = False): if self._backend_unavailable: cloudlog.warning(f"Ignoring connect_to_network({ssid!r}); Wi-Fi backend unavailable") return if self._nmcli_networking: self._set_connecting(ssid) def worker(): cmd = ["nmcli", "device", "wifi", "connect", ssid] if password: cmd += ["password", password] if hidden: cmd += ["hidden", "yes"] result = subprocess.run(cmd, check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) if result.returncode != 0: self._set_connecting(None) self._update_networks() if result.returncode == 0: self._enqueue_callbacks(self._activated) else: self._enqueue_callbacks(self._need_auth, ssid) threading.Thread(target=worker, daemon=True).start() return if self._fake_networking: self._set_connecting(ssid) if not self.is_connection_saved(ssid): self._connections[ssid] = ssid if not any(network.ssid == ssid for network in self._networks): self._networks.append(Network(ssid=ssid, strength=100, security_type=SecurityType.WPA if password else SecurityType.OPEN, is_tethering=False)) self._wifi_state = WifiState(ssid=ssid, status=ConnectStatus.CONNECTED) self._ipv4_address = DESKTOP_FAKE_IP self._enqueue_callbacks(self._activated) self._enqueue_callbacks(self._networks_updated, self.networks) return self._set_connecting(ssid) def worker(): # Clear all connections that may already exist to the network we are connecting to self.forget_connection(ssid, block=True) connection = { 'connection': { 'type': ('s', '802-11-wireless'), 'uuid': ('s', str(uuid.uuid4())), 'id': ('s', f'openpilot connection {ssid}'), 'autoconnect-retries': ('i', 0), }, '802-11-wireless': { 'ssid': ('ay', ssid.encode("utf-8")), 'hidden': ('b', hidden), 'mode': ('s', 'infrastructure'), }, 'ipv4': { 'method': ('s', 'auto'), 'dns-priority': ('i', 600), }, 'ipv6': {'method': ('s', 'ignore')}, } if password: connection['802-11-wireless-security'] = { 'key-mgmt': ('s', 'wpa-psk'), 'auth-alg': ('s', 'open'), 'psk': ('s', password), } # Volatile connection auto-deletes on disconnect (wrong password, user switches networks) # Persisted to disk on ACTIVATED via Save() if self._wifi_device is None: cloudlog.warning("No WiFi device found") # TODO: expose a failed connection state in the UI self._init_wifi_state() return reply = self._router_main.send_and_get_reply(new_method_call(self._nm, 'AddAndActivateConnection2', 'a{sa{sv}}ooa{sv}', (connection, self._wifi_device, "/", {'persist': ('s', 'volatile')}))) if reply.header.message_type == MessageType.error: cloudlog.warning(f"Failed to add and activate connection for {ssid}: {reply}") # TODO: expose a failed connection state in the UI self._init_wifi_state() threading.Thread(target=worker, daemon=True).start() def forget_connection(self, ssid: str, block: bool = False): if self._backend_unavailable: cloudlog.warning(f"Ignoring forget_connection({ssid!r}); Wi-Fi backend unavailable") return if self._nmcli_networking: def worker(): try: conns = subprocess.run( ["nmcli", "-t", "-f", "NAME,TYPE,802-11-wireless.ssid", "connection", "show"], check=False, capture_output=True, text=True, ) deleted = False for line in conns.stdout.splitlines(): parts = self._parse_nmcli_line(line) if len(parts) >= 3 and parts[1] == "802-11-wireless" and (parts[0] == ssid or parts[2] == ssid): subprocess.run(["nmcli", "connection", "delete", "id", parts[0]], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) deleted = True if not deleted: subprocess.run(["nmcli", "connection", "delete", "id", ssid], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) except Exception as e: cloudlog.warning(f"nmcli forget failed for {ssid}: {e}") if self._wifi_state.ssid == ssid: self._set_connecting(None) self._update_networks() self._enqueue_callbacks(self._forgotten, ssid) if block: worker() else: threading.Thread(target=worker, daemon=True).start() return if self._fake_networking: self._connections.pop(ssid, None) if self._wifi_state.ssid == ssid: self._wifi_state = WifiState() self._ipv4_address = "" self._enqueue_callbacks(self._forgotten, ssid) self._enqueue_callbacks(self._networks_updated, self.networks) return def worker(): conn_path = self._connections.get(ssid, None) if conn_path is None: cloudlog.warning(f"Trying to forget unknown connection: {ssid}") else: conn_addr = DBusAddress(conn_path, bus_name=NM, interface=NM_CONNECTION_IFACE) self._router_main.send_and_get_reply(new_method_call(conn_addr, 'Delete')) self._enqueue_callbacks(self._forgotten, ssid) if block: worker() else: threading.Thread(target=worker, daemon=True).start() def activate_connection(self, ssid: str, block: bool = False): if self._backend_unavailable: cloudlog.warning(f"Ignoring activate_connection({ssid!r}); Wi-Fi backend unavailable") return if self._nmcli_networking: self._set_connecting(ssid) def worker(): conn_id = self._connections.get(ssid, ssid) result = subprocess.run(["nmcli", "connection", "up", "id", conn_id], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) if result.returncode != 0: self._set_connecting(None) self._update_networks() if result.returncode == 0: self._enqueue_callbacks(self._activated) if block: worker() else: threading.Thread(target=worker, daemon=True).start() return if self._fake_networking: self._set_connecting(ssid) if not self.is_connection_saved(ssid): self._connections[ssid] = ssid self._wifi_state = WifiState(ssid=ssid, status=ConnectStatus.CONNECTED) self._ipv4_address = DESKTOP_FAKE_IP if ssid != self._tethering_ssid else TETHERING_IP_ADDRESS self._enqueue_callbacks(self._activated) self._enqueue_callbacks(self._networks_updated, self.networks) return self._set_connecting(ssid) def worker(): conn_path = self._connections.get(ssid, None) if conn_path is None or self._wifi_device is None: cloudlog.warning(f"Failed to activate connection for {ssid}: conn_path={conn_path}, wifi_device={self._wifi_device}") # TODO: expose a failed connection state in the UI self._init_wifi_state() return reply = self._router_main.send_and_get_reply(new_method_call(self._nm, 'ActivateConnection', 'ooo', (conn_path, self._wifi_device, "/"))) if reply.header.message_type == MessageType.error: cloudlog.warning(f"Failed to activate connection for {ssid}: {reply}") # TODO: expose a failed connection state in the UI self._init_wifi_state() if block: worker() else: threading.Thread(target=worker, daemon=True).start() def _deactivate_connection(self, ssid: str): if self._nmcli_networking: conn_id = self._connections.get(ssid, ssid) subprocess.run(["nmcli", "connection", "down", "id", conn_id], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) self._set_connecting(None) self._update_networks() self._enqueue_callbacks(self._disconnected) return if self._fake_networking: if self._wifi_state.ssid == ssid: self._wifi_state = WifiState() self._ipv4_address = "" self._enqueue_callbacks(self._disconnected) self._enqueue_callbacks(self._networks_updated, self.networks) return for active_conn in self._get_active_connections(): conn_addr = DBusAddress(active_conn, bus_name=NM, interface=NM_ACTIVE_CONNECTION_IFACE) reply = self._router_main.send_and_get_reply(Properties(conn_addr).get('SpecificObject')) if reply.header.message_type == MessageType.error: continue # object gone (e.g. rapid connect/disconnect) specific_obj_path = reply.body[0][1] if specific_obj_path != "/": ap_addr = DBusAddress(specific_obj_path, bus_name=NM, interface=NM_ACCESS_POINT_IFACE) ap_reply = self._router_main.send_and_get_reply(Properties(ap_addr).get('Ssid')) if ap_reply.header.message_type == MessageType.error: continue # AP gone (e.g. mode switch) ap_ssid = bytes(ap_reply.body[0][1]).decode("utf-8", "replace") if ap_ssid == ssid: self._router_main.send_and_get_reply(new_method_call(self._nm, 'DeactivateConnection', 'o', (active_conn,))) return def is_tethering_active(self) -> bool: # Check ssid, not connected_ssid, to also catch connecting state return self._wifi_state.ssid == self._tethering_ssid def is_connection_saved(self, ssid: str) -> bool: return ssid in self._connections def set_tethering_password(self, password: str): if self._backend_unavailable: cloudlog.warning("Ignoring set_tethering_password(); Wi-Fi backend unavailable") return if self._nmcli_networking: self._tethering_password = password return if self._fake_networking: self._tethering_password = password return def worker(): conn_path = self._connections.get(self._tethering_ssid, None) if conn_path is None: cloudlog.warning('No tethering connection found') return settings = self._get_connection_settings(conn_path) if len(settings) == 0: cloudlog.warning(f'Failed to get tethering settings for {conn_path}') return settings['802-11-wireless-security']['psk'] = ('s', password) conn_addr = DBusAddress(conn_path, bus_name=NM, interface=NM_CONNECTION_IFACE) reply = self._router_main.send_and_get_reply(new_method_call(conn_addr, 'Update', 'a{sa{sv}}', (settings,))) if reply.header.message_type == MessageType.error: cloudlog.warning(f'Failed to update tethering settings: {reply}') return self._tethering_password = password if self.is_tethering_active(): self.activate_connection(self._tethering_ssid, block=True) threading.Thread(target=worker, daemon=True).start() def _get_tethering_password(self) -> str: if self._backend_unavailable: return "" if self._nmcli_networking: return self._tethering_password or DEFAULT_TETHERING_PASSWORD conn_path = self._connections.get(self._tethering_ssid, None) if conn_path is None: cloudlog.warning('No tethering connection found') return '' reply = self._router_main.send_and_get_reply(new_method_call( DBusAddress(conn_path, bus_name=NM, interface=NM_CONNECTION_IFACE), 'GetSecrets', 's', ('802-11-wireless-security',) )) if reply.header.message_type == MessageType.error: cloudlog.warning(f'Failed to get tethering password: {reply}') return '' secrets = reply.body[0] if '802-11-wireless-security' not in secrets: return '' return str(secrets['802-11-wireless-security'].get('psk', ('s', ''))[1]) def set_ipv4_forward(self, enabled: bool): self._ipv4_forward = enabled def set_tethering_active(self, active: bool): if self._backend_unavailable: cloudlog.warning(f"Ignoring set_tethering_active({active}); Wi-Fi backend unavailable") return if self._nmcli_networking: cloudlog.warning("Tethering control is not supported via nmcli fallback backend") return if self._fake_networking: if active: if self._tethering_ssid not in self._connections: self._connections[self._tethering_ssid] = self._tethering_ssid if not any(network.ssid == self._tethering_ssid for network in self._networks): self._networks.append(Network(ssid=self._tethering_ssid, strength=100, security_type=SecurityType.WPA, is_tethering=True)) self._wifi_state = WifiState(ssid=self._tethering_ssid, status=ConnectStatus.CONNECTED) self._ipv4_address = TETHERING_IP_ADDRESS else: self._deactivate_connection(self._tethering_ssid) self._enqueue_callbacks(self._networks_updated, self.networks) return def worker(): if active: self.activate_connection(self._tethering_ssid, block=True) if not self._ipv4_forward: time.sleep(5) cloudlog.warning("net.ipv4.ip_forward = 0") subprocess.run(["sudo", "sysctl", "net.ipv4.ip_forward=0"], check=False) else: self._deactivate_connection(self._tethering_ssid) threading.Thread(target=worker, daemon=True).start() def set_current_network_metered(self, metered: MeteredType): if self._backend_unavailable: cloudlog.warning(f"Ignoring set_current_network_metered({metered}); Wi-Fi backend unavailable") return if self._nmcli_networking: self._current_network_metered = metered self._enqueue_callbacks(self._networks_updated, self.networks) return if self._fake_networking: self._current_network_metered = metered self._enqueue_callbacks(self._networks_updated, self.networks) return def worker(): if self.is_tethering_active(): return conn_path, _ = self._get_active_wifi_connection() if conn_path is None: cloudlog.warning('No active WiFi connection found') return settings = self._get_connection_settings(conn_path) if len(settings) == 0: cloudlog.warning(f'Failed to get connection settings for {conn_path}') return settings['connection']['metered'] = ('i', int(metered)) conn_addr = DBusAddress(conn_path, bus_name=NM, interface=NM_CONNECTION_IFACE) reply = self._router_main.send_and_get_reply(new_method_call(conn_addr, 'Update', 'a{sa{sv}}', (settings,))) if reply.header.message_type == MessageType.error: cloudlog.warning(f'Failed to update metered settings: {reply}') threading.Thread(target=worker, daemon=True).start() def _request_scan(self): if self._nmcli_networking: subprocess.run(["nmcli", "device", "wifi", "rescan"], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) return if self._wifi_device is None: cloudlog.warning("No WiFi device found") return wifi_addr = DBusAddress(self._wifi_device, bus_name=NM, interface=NM_WIRELESS_IFACE) reply = self._router_main.send_and_get_reply(new_method_call(wifi_addr, 'RequestScan', 'a{sv}', ({},))) if reply.header.message_type == MessageType.error: cloudlog.warning(f"Failed to request scan: {reply}") def _update_networks(self, block: bool = True): if not self._active: return def worker(): with self._scan_lock: if self._nmcli_networking: self._update_networks_nmcli_locked() return if self._wifi_device is None: cloudlog.warning("No WiFi device found") return # NOTE: AccessPoints property may exclude hidden APs (use GetAllAccessPoints method if needed) wifi_addr = DBusAddress(self._wifi_device, NM, interface=NM_WIRELESS_IFACE) wifi_props_reply = self._router_main.send_and_get_reply(Properties(wifi_addr).get_all()) if wifi_props_reply.header.message_type == MessageType.error: cloudlog.warning(f"Failed to get WiFi properties: {wifi_props_reply}") return ap_paths = wifi_props_reply.body[0].get('AccessPoints', ('ao', []))[1] aps: dict[str, list[AccessPoint]] = {} for ap_path in ap_paths: ap_addr = DBusAddress(ap_path, NM, interface=NM_ACCESS_POINT_IFACE) ap_props = self._router_main.send_and_get_reply(Properties(ap_addr).get_all()) # some APs have been seen dropping off during iteration if ap_props.header.message_type == MessageType.error: cloudlog.warning(f"Failed to get AP properties for {ap_path}") continue try: ap = AccessPoint.from_dbus(ap_props.body[0], ap_path) if ap.ssid == "": continue if ap.ssid not in aps: aps[ap.ssid] = [] aps[ap.ssid].append(ap) except Exception: # catch all for parsing errors cloudlog.exception(f"Failed to parse AP properties for {ap_path}") self._networks = [Network.from_dbus(ssid, ap_list, ssid == self._tethering_ssid) for ssid, ap_list in aps.items()] self._update_active_connection_info() self._enqueue_callbacks(self._networks_updated, self.networks) # sorted if block: worker() else: threading.Thread(target=worker, daemon=True).start() def _update_active_connection_info(self): if self._nmcli_networking: self._current_network_metered = MeteredType.UNKNOWN return ipv4_address = "" metered = MeteredType.UNKNOWN conn_path, props = self._get_active_wifi_connection() if conn_path is not None and props is not None: # IPv4 address ip4config_path = props.get('Ip4Config', ('o', '/'))[1] if ip4config_path != "/": ip4config_addr = DBusAddress(ip4config_path, bus_name=NM, interface=NM_IP4_CONFIG_IFACE) address_data = self._router_main.send_and_get_reply(Properties(ip4config_addr).get('AddressData')).body[0][1] for entry in address_data: if 'address' in entry: ipv4_address = entry['address'][1] break # Metered status settings = self._get_connection_settings(conn_path) if len(settings) > 0: metered_prop = settings['connection'].get('metered', ('i', 0))[1] if metered_prop == MeteredType.YES: metered = MeteredType.YES elif metered_prop == MeteredType.NO: metered = MeteredType.NO self._ipv4_address = ipv4_address self._current_network_metered = metered def __del__(self): self.stop() def update_gsm_settings(self, roaming: bool, apn: str, metered: bool): """Update GSM settings for cellular connection""" if self._backend_unavailable: cloudlog.warning("Ignoring update_gsm_settings(); Wi-Fi backend unavailable") return if self._nmcli_networking: cloudlog.warning("Ignoring update_gsm_settings(); nmcli backend unavailable for GSM settings") return if self._fake_networking: return def worker(): try: lte_connection_path = self._get_lte_connection_path() if not lte_connection_path: cloudlog.warning("No LTE connection found") return settings = self._get_connection_settings(lte_connection_path) if len(settings) == 0: cloudlog.warning(f"Failed to get connection settings for {lte_connection_path}") return # Ensure dicts exist if 'gsm' not in settings: settings['gsm'] = {} if 'connection' not in settings: settings['connection'] = {} changes = False auto_config = apn == "" if settings['gsm'].get('auto-config', ('b', False))[1] != auto_config: cloudlog.warning(f'Changing gsm.auto-config to {auto_config}') settings['gsm']['auto-config'] = ('b', auto_config) changes = True if settings['gsm'].get('apn', ('s', ''))[1] != apn: cloudlog.warning(f'Changing gsm.apn to {apn}') settings['gsm']['apn'] = ('s', apn) changes = True if settings['gsm'].get('home-only', ('b', False))[1] == roaming: cloudlog.warning(f'Changing gsm.home-only to {not roaming}') settings['gsm']['home-only'] = ('b', not roaming) changes = True # Unknown means NetworkManager decides metered_int = int(MeteredType.UNKNOWN if metered else MeteredType.NO) if settings['connection'].get('metered', ('i', 0))[1] != metered_int: cloudlog.warning(f'Changing connection.metered to {metered_int}') settings['connection']['metered'] = ('i', metered_int) changes = True if changes: # Update the connection settings (temporary update) conn_addr = DBusAddress(lte_connection_path, bus_name=NM, interface=NM_CONNECTION_IFACE) reply = self._router_main.send_and_get_reply(new_method_call(conn_addr, 'UpdateUnsaved', 'a{sa{sv}}', (settings,))) if reply.header.message_type == MessageType.error: cloudlog.warning(f"Failed to update GSM settings: {reply}") return self._activate_modem_connection(lte_connection_path) except Exception as e: cloudlog.exception(f"Error updating GSM settings: {e}") threading.Thread(target=worker, daemon=True).start() def _get_lte_connection_path(self) -> str | None: try: settings_addr = DBusAddress(NM_SETTINGS_PATH, bus_name=NM, interface=NM_SETTINGS_IFACE) known_connections = self._router_main.send_and_get_reply(new_method_call(settings_addr, 'ListConnections')).body[0] for conn_path in known_connections: settings = self._get_connection_settings(conn_path) if settings and settings.get('connection', {}).get('id', ('s', ''))[1] == 'lte': return str(conn_path) except Exception as e: cloudlog.exception(f"Error finding LTE connection: {e}") return None def _activate_modem_connection(self, connection_path: str): try: modem_device = self._get_adapter(NM_DEVICE_TYPE_MODEM) if modem_device and connection_path: self._router_main.send_and_get_reply(new_method_call(self._nm, 'ActivateConnection', 'ooo', (connection_path, modem_device, "/"))) except Exception as e: cloudlog.exception(f"Error activating modem connection: {e}") def stop(self): if not self._exit: self._exit = True if self._scan_thread.is_alive(): self._scan_thread.join() if self._state_thread.is_alive(): self._state_thread.join() if self._router_main is not None: self._router_main.close() self._router_main.conn.close() if self._conn_monitor is not None: self._conn_monitor.close() def _parse_nmcli_line(self, line: str) -> list[str]: out: list[str] = [] cur = [] escaped = False for ch in line: if escaped: cur.append(ch) escaped = False elif ch == "\\": escaped = True elif ch == ":": out.append("".join(cur)) cur = [] else: cur.append(ch) out.append("".join(cur)) return out def _update_networks_nmcli_locked(self): networks_by_ssid: dict[str, Network] = {} saved_connections: dict[str, str] = {} active_ssid: str | None = None active_device: str | None = None try: saved = subprocess.run( ["nmcli", "-t", "-f", "NAME,TYPE", "connection", "show"], check=False, capture_output=True, text=True, ) for line in saved.stdout.splitlines(): parts = self._parse_nmcli_line(line) if len(parts) < 2 or parts[1] != "802-11-wireless": continue conn_id = parts[0] ssid_lookup = subprocess.run( ["nmcli", "-g", "802-11-wireless.ssid", "connection", "show", conn_id], check=False, capture_output=True, text=True, ) ssid = ssid_lookup.stdout.strip() if ssid_lookup.returncode == 0 else "" if ssid: saved_connections[ssid] = conn_id elif conn_id: # Fallback for older/odd NetworkManager profiles where the connection # name is the best identifier available. saved_connections[conn_id] = conn_id except Exception as e: cloudlog.warning(f"nmcli saved networks query failed: {e}") self._connections = saved_connections try: status = subprocess.run( ["nmcli", "-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device", "status"], check=False, capture_output=True, text=True, ) for line in status.stdout.splitlines(): parts = self._parse_nmcli_line(line) if len(parts) >= 4 and parts[1] == "wifi": device, _dev_type, state, connection = parts[:4] if state.startswith("connected"): active_device = device active_ssid = next((ssid for ssid, conn_id in saved_connections.items() if conn_id == connection), connection if connection not in ("", "--") else None) break except Exception as e: cloudlog.warning(f"nmcli device status query failed: {e}") try: result = subprocess.run( ["nmcli", "-t", "-f", "IN-USE,SSID,SIGNAL,SECURITY", "device", "wifi", "list", "--rescan", "no"], check=False, capture_output=True, text=True, ) for line in result.stdout.splitlines(): parts = self._parse_nmcli_line(line) if len(parts) < 4: continue in_use, ssid, signal, security = parts[:4] if not ssid: continue try: strength = int(signal or 0) except ValueError: strength = 0 is_tethering = ssid == self._tethering_ssid security_type = SecurityType.OPEN if security in ("", "--") else SecurityType.WPA existing = networks_by_ssid.get(ssid) if existing is None or strength > existing.strength or in_use.startswith("*"): networks_by_ssid[ssid] = Network( ssid=ssid, strength=100 if is_tethering else strength, security_type=security_type, is_tethering=is_tethering, ) except Exception as e: cloudlog.warning(f"nmcli scan failed: {e}") if active_ssid and active_ssid not in networks_by_ssid: previous = next((network for network in self._networks if network.ssid == active_ssid), None) networks_by_ssid[active_ssid] = Network( ssid=active_ssid, strength=previous.strength if previous is not None else 100, security_type=previous.security_type if previous is not None else SecurityType.WPA, is_tethering=active_ssid == self._tethering_ssid, ) self._networks = list(networks_by_ssid.values()) if active_ssid is not None: self._wifi_state = WifiState(ssid=active_ssid, status=ConnectStatus.CONNECTED) elif self._wifi_state.status != ConnectStatus.CONNECTING: self._wifi_state = WifiState() self._ipv4_address = "" if active_device: try: addr = subprocess.run( ["nmcli", "-t", "-f", "IP4.ADDRESS", "device", "show", active_device], check=False, capture_output=True, text=True, ) for row in addr.stdout.splitlines(): if row: self._ipv4_address = row.split(":", 1)[-1].split("/", 1)[0] break except Exception as e: cloudlog.warning(f"nmcli ipv4 lookup failed: {e}") self._current_network_metered = MeteredType.UNKNOWN self._enqueue_callbacks(self._networks_updated, self.networks)