diff --git a/openpilot/common/hardware/comma/hardware.py b/openpilot/common/hardware/comma/hardware.py index effb4d9dbee598..678875cc3301fb 100644 --- a/openpilot/common/hardware/comma/hardware.py +++ b/openpilot/common/hardware/comma/hardware.py @@ -4,10 +4,13 @@ import socket import subprocess import time +import uuid from functools import cached_property, lru_cache from pathlib import Path from openpilot.cereal import log +from openpilot.common.nm_keyfile import decode_nm_keyfile_ssid +from openpilot.common.wifi import WPA_CTRL_PATH, decode_wpa_ssid from openpilot.common.utils import sudo_read, sudo_write from openpilot.common.gpio import gpio_set, gpio_init, get_irqs_for_action from openpilot.common.esim.base import LPABase @@ -41,7 +44,7 @@ def wpa_supplicant_cmd(cmd: str, timeout: float = 0.2) -> dict[str, str]: with socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) as sock: sock.settimeout(timeout) sock.bind(f"\0openpilot-wpa-{os.getpid()}-{time.monotonic_ns()}") - sock.connect("/run/wpa_supplicant/wlan0") + sock.connect(WPA_CTRL_PATH) sock.send(cmd.encode()) while True: @@ -57,6 +60,12 @@ def get_default_route_iface(): routes = [(int(route[6]), route[0]) for line in f.readlines()[1:] if (route := line.split())[1] == "00000000" and int(route[3], 16) & 0x1] return min(routes)[1] if routes else None +def _normalize_uuid(value: str) -> str | None: + try: + return str(uuid.UUID(value)) + except ValueError: + return None + class HardwareComma(HardwareBase): """ This platform covers the Snapdragon 845-based comma devices: @@ -211,23 +220,28 @@ def get_network_metered(self, network_type) -> bool: return Params().get_bool("GsmMetered") try: if network_type == NetworkType.wifi: - ssid = wpa_supplicant_cmd("STATUS").get("ssid", "") - if ssid: - # wpa_supplicant escapes non-printable bytes as \xNN; NM keyfile stores ASCII SSIDs as a literal and others as a byte;byte; list - ssid_bytes = ssid.encode().decode('unicode_escape').encode('latin-1') - ssid_keyfile_list = ';'.join(str(b) for b in ssid_bytes) + ';' - - nm_dirs = ("/run/NetworkManager/system-connections", "/data/etc/NetworkManager/system-connections") - for fpath in (p for d in nm_dirs for p in Path(d).glob("*.nmconnection")): + status = wpa_supplicant_cmd("STATUS") + profile_uuid = status.get("id_str", "").strip('"') + normalized_profile_uuid = _normalize_uuid(profile_uuid) if profile_uuid else None + if profile_uuid and normalized_profile_uuid is None: + return super().get_network_metered(network_type) + ssid = decode_wpa_ssid(status.get("ssid", "")) + if profile_uuid or ssid: + nm_dirs = ("/data/etc/NetworkManager/system-connections", "/run/NetworkManager/system-connections") + for fpath in (path for directory in nm_dirs for path in Path(directory).glob("*.nmconnection")): raw = sudo_read(str(fpath)) if not raw: continue cp = configparser.ConfigParser(interpolation=None) try: cp.read_string(raw) - keyfile_ssid = cp.get("wifi", "ssid", fallback="") - if keyfile_ssid != ssid and keyfile_ssid != ssid_keyfile_list: - continue + if profile_uuid: + if _normalize_uuid(cp.get("connection", "uuid", fallback="")) != normalized_profile_uuid: + continue + else: + wifi_section = "wifi" if cp.has_section("wifi") else "802-11-wireless" + if decode_nm_keyfile_ssid(cp.get(wifi_section, "ssid", fallback="")) != ssid: + continue metered = cp.getint("connection", "metered", fallback=0) except (configparser.Error, ValueError): continue diff --git a/openpilot/common/hardware/comma/tests/test_hardware.py b/openpilot/common/hardware/comma/tests/test_hardware.py new file mode 100644 index 00000000000000..8aa47c8b205375 --- /dev/null +++ b/openpilot/common/hardware/comma/tests/test_hardware.py @@ -0,0 +1,124 @@ +from pathlib import Path +from unittest import TestCase +from unittest.mock import MagicMock, patch + +from openpilot.common.hardware.comma import hardware as hardware_module +from openpilot.common.hardware.comma.hardware import HardwareComma, NetworkType + + +class TestHardwareComma(TestCase): + def test_canonical_wifi_profile_metered(self): + profile = """\ +[connection] +metered=1 + +[802-11-wireless] +ssid=TestNet +""" + + with ( + patch.object(hardware_module, "wpa_supplicant_cmd", return_value={"ssid": "TestNet"}), + patch.object(Path, "glob", return_value=[Path("profile.nmconnection")]), + patch.object(hardware_module, "sudo_read", return_value=profile), + ): + assert HardwareComma().get_network_metered(NetworkType.wifi) + + def test_escaped_wifi_profile_metered(self): + profile = """\ +[connection] +metered=1 + +[wifi] +ssid=\\sGuest\\s +""" + + with ( + patch.object(hardware_module, "wpa_supplicant_cmd", return_value={"ssid": " Guest "}), + patch.object(Path, "glob", return_value=[Path("profile.nmconnection")]), + patch.object(hardware_module, "sudo_read", return_value=profile), + ): + assert HardwareComma().get_network_metered(NetworkType.wifi) + def test_selected_profile_uuid_controls_metering(self): + first_uuid = "11111111-1111-1111-1111-111111111111" + second_uuid = "22222222-2222-2222-2222-222222222222" + profiles = { + "first.nmconnection": f"""\\ +[connection] +uuid={first_uuid} +metered=1 + +[wifi] +ssid=Duplicate +""", + "second.nmconnection": f"""\\ +[connection] +uuid={second_uuid} +metered=2 + +[wifi] +ssid=Duplicate +""", + } + + with ( + patch.object(hardware_module, "wpa_supplicant_cmd", return_value={"ssid": "Duplicate", "id_str": second_uuid}), + patch.object(Path, "glob", return_value=[Path(name) for name in profiles]), + patch.object(hardware_module, "sudo_read", side_effect=lambda path: profiles[path]), + ): + assert not HardwareComma().get_network_metered(NetworkType.wifi) + + def test_selected_profile_uuid_is_normalized_for_metering(self): + active_uuid = "22222222-2222-4abc-8def-222222222222" + profiles = { + "first.nmconnection": """ +[connection] +uuid=11111111-1111-4111-8111-111111111111 +metered=2 + +[wifi] +ssid=Duplicate +""", + "second.nmconnection": "", + } + for raw_uuid in (active_uuid.upper(), f"{{{active_uuid.upper()}}}"): + with self.subTest(raw_uuid=raw_uuid): + profiles["second.nmconnection"] = f""" +[connection] +uuid={raw_uuid} +metered=1 + +[wifi] +ssid=Duplicate +""" + with ( + patch.object(hardware_module, "wpa_supplicant_cmd", return_value={"ssid": "Duplicate", "id_str": active_uuid}), + patch.object(Path, "glob", return_value=[Path(name) for name in profiles]), + patch.object(hardware_module, "sudo_read", side_effect=lambda path: profiles[path]), + ): + assert HardwareComma().get_network_metered(NetworkType.wifi) + + def test_malformed_active_profile_uuid_does_not_fall_back_to_ssid(self): + profile = """ +[connection] +uuid=22222222-2222-4abc-8def-222222222222 +metered=1 + +[wifi] +ssid=Duplicate +""" + with ( + patch.object(hardware_module, "wpa_supplicant_cmd", return_value={"ssid": "Duplicate", "id_str": "not-a-uuid"}), + patch.object(Path, "glob", return_value=[Path("profile.nmconnection")]), + patch.object(hardware_module, "sudo_read", return_value=profile), + ): + assert not HardwareComma().get_network_metered(NetworkType.wifi) + + def test_hardware_uses_owned_control_socket(self): + sock = MagicMock() + sock.__enter__.return_value = sock + sock.recv.return_value = b"FAIL\\n" + + with patch.object(hardware_module.socket, "socket", return_value=sock): + assert hardware_module.wpa_supplicant_cmd("STATUS") == {} + + sock.connect.assert_called_once_with("/run/openpilot-wpa/wlan0") diff --git a/openpilot/common/nm_keyfile.py b/openpilot/common/nm_keyfile.py new file mode 100644 index 00000000000000..6415473f7d6789 --- /dev/null +++ b/openpilot/common/nm_keyfile.py @@ -0,0 +1,47 @@ +NM_KEYFILE_ESCAPES = { + "\\": "\\", + "n": "\n", + "r": "\r", + "s": " ", + "t": "\t", +} + + +def _decode_nm_keyfile_string(value: str, *, semicolon_escape: bool) -> str: + decoded = [] + i = 0 + while i < len(value): + if value[i] == "\\" and i + 1 < len(value): + escaped = NM_KEYFILE_ESCAPES.get(value[i + 1]) + if escaped is None and semicolon_escape and value[i + 1] == ";": + escaped = ";" + if escaped is not None: + decoded.append(escaped) + i += 2 + continue + decoded.append(value[i]) + i += 1 + return "".join(decoded) + + +def decode_nm_keyfile_string(value: str) -> str: + return _decode_nm_keyfile_string(value, semicolon_escape=False) + + +def decode_nm_keyfile_ssid(ssid: str) -> str: + # Netplan uses a semicolon-terminated decimal byte list for SSIDs that do not + # round-trip as a plain keyfile string. Detect that representation before + # interpreting any keyfile escapes. + if ssid.endswith(";"): + parts = ssid[:-1].split(";") + if parts and all(part.isascii() and part.isdigit() for part in parts): + try: + ssid_bytes = bytes(int(part) for part in parts) + except ValueError: + pass + else: + if all(byte == 0 for byte in ssid_bytes): + return "" + return ssid_bytes.decode("utf-8", errors="surrogateescape") + + return _decode_nm_keyfile_string(ssid, semicolon_escape=True) diff --git a/openpilot/common/tests/test_nm_keyfile.py b/openpilot/common/tests/test_nm_keyfile.py new file mode 100644 index 00000000000000..2c82e953721550 --- /dev/null +++ b/openpilot/common/tests/test_nm_keyfile.py @@ -0,0 +1,33 @@ +import unittest + +from openpilot.common.nm_keyfile import decode_nm_keyfile_ssid + + +class TestNmKeyfile(unittest.TestCase): + def test_decodes_escaped_semicolon(self): + self.assertEqual(decode_nm_keyfile_ssid(r"Cafe\;Guest"), "Cafe;Guest") + + def test_decodes_escaped_backslash(self): + self.assertEqual(decode_nm_keyfile_ssid(r"Cafe\\Guest"), r"Cafe\Guest") + + def test_preserves_literal_backslash_before_semicolon(self): + self.assertEqual(decode_nm_keyfile_ssid(r"Cafe\\;Guest"), r"Cafe\;Guest") + + def test_decodes_boundary_spaces(self): + self.assertEqual(decode_nm_keyfile_ssid(r"\sCafe\s"), " Cafe ") + + def test_decodes_decimal_byte_list(self): + self.assertEqual(decode_nm_keyfile_ssid("65;66;67;"), "ABC") + + def test_preserves_non_utf8_decimal_byte_list(self): + self.assertEqual( + decode_nm_keyfile_ssid("255;65;"), + b"\xffA".decode("utf-8", errors="surrogateescape"), + ) + + def test_preserves_unknown_escape(self): + self.assertEqual(decode_nm_keyfile_ssid(r"Cafe\q"), r"Cafe\q") + + +if __name__ == "__main__": + unittest.main() diff --git a/openpilot/common/wifi.py b/openpilot/common/wifi.py new file mode 100644 index 00000000000000..ebf219a2c0be56 --- /dev/null +++ b/openpilot/common/wifi.py @@ -0,0 +1,67 @@ +import os + + +WPA_CTRL_DIR = "/run/openpilot-wpa" +WPA_CTRL_PATH = os.path.join(WPA_CTRL_DIR, "wlan0") +WPA_PID_FILE = os.path.join(WPA_CTRL_DIR, "wpa_supplicant.pid") + +_HEX = "0123456789abcdefABCDEF" + + +def decode_wpa_ssid(encoded: str) -> str: + """Decode a wpa_supplicant printf_encode'd SSID without losing byte identity.""" + out = bytearray() + i = 0 + while i < len(encoded): + char = encoded[i] + if char != "\\": + out.append(ord(char) & 0xff) + i += 1 + continue + + i += 1 + if i >= len(encoded): + break + + escaped = encoded[i] + if escaped == "\\": + out.append(ord("\\")) + i += 1 + elif escaped == '"': + out.append(ord('"')) + i += 1 + elif escaped == "n": + out.append(ord("\n")) + i += 1 + elif escaped == "r": + out.append(ord("\r")) + i += 1 + elif escaped == "t": + out.append(ord("\t")) + i += 1 + elif escaped == "e": + out.append(0x1b) + i += 1 + elif escaped == "x": + i += 1 + if i + 1 < len(encoded) and encoded[i] in _HEX and encoded[i + 1] in _HEX: + out.append(int(encoded[i:i + 2], 16)) + i += 2 + elif i < len(encoded) and encoded[i] in _HEX: + out.append(int(encoded[i], 16)) + i += 1 + elif "0" <= escaped <= "7": + value = ord(escaped) - ord("0") + i += 1 + if i < len(encoded) and "0" <= encoded[i] <= "7": + value = value * 8 + ord(encoded[i]) - ord("0") + i += 1 + if i < len(encoded) and "0" <= encoded[i] <= "7": + value = value * 8 + ord(encoded[i]) - ord("0") + i += 1 + out.append(value & 0xff) + # Unknown escapes consume only the backslash. + + if not out or all(byte == 0 for byte in out): + return "" + return out.decode("utf-8", errors="surrogateescape") diff --git a/openpilot/selfdrive/ui/layouts/settings/settings.py b/openpilot/selfdrive/ui/layouts/settings/settings.py index 48b75e5dbd97c8..fadc585d0dd100 100644 --- a/openpilot/selfdrive/ui/layouts/settings/settings.py +++ b/openpilot/selfdrive/ui/layouts/settings/settings.py @@ -53,7 +53,6 @@ def __init__(self): # Panel configuration wifi_manager = WifiManager() - wifi_manager.set_active(False) self._panels = { PanelType.DEVICE: PanelInfo(tr_noop("Device"), DeviceLayout()), diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/network/__init__.py b/openpilot/selfdrive/ui/mici/layouts/settings/network/__init__.py index ddbab4b478cb24..2224bb732767d9 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/network/__init__.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/network/__init__.py @@ -3,7 +3,8 @@ from openpilot.selfdrive.ui.mici.layouts.settings.network.wifi_ui import WifiIcon from openpilot.selfdrive.ui.mici.widgets.button import BigButton from openpilot.system.ui.lib.application import gui_app -from openpilot.system.ui.lib.wifi_manager import WifiManager, ConnectStatus, SecurityType, normalize_ssid +from openpilot.system.ui.lib.wifi_manager import WifiManager, ConnectStatus, SecurityType +from openpilot.system.ui.lib.wpa_ctrl import normalize_ssid class WifiNetworkButton(BigButton): diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/network/network_layout.py b/openpilot/selfdrive/ui/mici/layouts/settings/network/network_layout.py index 58b3d0c77dceeb..40bc9fa24f4040 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/network/network_layout.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/network/network_layout.py @@ -14,11 +14,12 @@ def __init__(self): super().__init__() self._wifi_manager = WifiManager() - self._wifi_manager.set_active(False) self._wifi_ui = WifiUIMici(self._wifi_manager) self._wifi_manager.add_callbacks( networks_updated=self._on_network_updated, + activated=lambda: self._on_tethering_finished(), + disconnected=lambda: self._on_tethering_finished(), ) # ******** Tethering ******** @@ -32,9 +33,13 @@ def tethering_toggle_callback(checked: bool): def tethering_password_callback(password: str): if password: - self._tethering_toggle_btn.set_enabled(False) - self._tethering_password_btn.set_enabled(False) self._wifi_manager.set_tethering_password(password) + if self._wifi_manager.is_tethering_active(): + self._tethering_toggle_btn.set_enabled(False) + self._tethering_password_btn.set_enabled(False) + else: + self._tethering_toggle_btn.set_enabled(True) + self._tethering_password_btn.set_enabled(True) def tethering_password_clicked(): tethering_password = self._wifi_manager.tethering_password @@ -123,14 +128,16 @@ def update_apn(apn: str): dlg = BigInputDialog("enter APN...", current_apn, minimum_length=0, confirm_callback=update_apn) gui_app.push_widget(dlg) - def _on_network_updated(self, networks: list[Network]): - # Update tethering state + def _on_tethering_finished(self): tethering_active = self._wifi_manager.is_tethering_active() - # TODO: use real signals (like activated/settings changed, etc.) to speed up re-enabling buttons self._tethering_toggle_btn.set_enabled(True) self._tethering_password_btn.set_enabled(True) - self._network_metered_btn.set_enabled(lambda: not tethering_active and bool(self._wifi_manager.ipv4_address)) self._tethering_toggle_btn.set_checked(tethering_active) + self._on_network_updated(self._wifi_manager.networks) + + def _on_network_updated(self, networks: list[Network]): + tethering_active = self._wifi_manager.is_tethering_active() + self._network_metered_btn.set_enabled(lambda: not tethering_active and bool(self._wifi_manager.ipv4_address)) # Update network metered self._network_metered_btn.set_value( diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/network/wifi_ui.py b/openpilot/selfdrive/ui/mici/layouts/settings/network/wifi_ui.py index 8404faf9f7e6cc..6f1345e7ba1948 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/network/wifi_ui.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/network/wifi_ui.py @@ -4,12 +4,14 @@ from collections.abc import Callable from openpilot.common.swaglog import cloudlog -from openpilot.selfdrive.ui.mici.widgets.dialog import BigInputDialog, BigConfirmationDialog +from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigInputDialog, BigConfirmationDialog from openpilot.selfdrive.ui.mici.widgets.button import BigButton, LABEL_COLOR from openpilot.system.ui.lib.application import gui_app, MousePos, FontWeight +from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.scroller import NavScroller -from openpilot.system.ui.lib.wifi_manager import WifiManager, Network, SecurityType, normalize_ssid +from openpilot.system.ui.lib.wifi_manager import WifiManager, Network, SecurityType +from openpilot.system.ui.lib.wpa_ctrl import normalize_ssid class LoadingAnimation(Widget): @@ -132,6 +134,10 @@ def set_wrong_password(self): self._wrong_password = True self.trigger_shake() + @property + def wrong_password(self) -> bool: + return self._wrong_password + @property def network(self) -> Network: return self._network @@ -220,7 +226,7 @@ def _update_state(self): elif self._is_connected: self.set_value("tethering" if self._network.is_tethering else "connected") elif self._network_missing: - # after connecting/connected since NM will still attempt to connect/stay connected for a while + # wpa_supplicant may keep an out-of-range connection alive briefly self.set_value("not in range") else: self.set_value("unsupported") @@ -282,10 +288,12 @@ def __init__(self, wifi_manager: WifiManager): self._wifi_manager = wifi_manager self._networks: dict[str, Network] = {} + self._shown = False self._wifi_manager.add_callbacks( need_auth=self._on_need_auth, forgotten=self._on_forgotten, + forget_failed=self._on_forget_failed, networks_updated=self._on_network_updated, ) @@ -297,9 +305,13 @@ def any_network_forgetting(self) -> bool: def show_event(self): # Re-sort scroller items and update from latest scan results super().show_event() - self._wifi_manager.set_active(True) self._networks = {n.ssid: n for n in self._wifi_manager.networks} self._update_buttons(re_sort=True) + self._shown = True + + def hide_event(self): + self._shown = False + super().hide_event() def _on_network_updated(self, networks: list[Network]): self._networks = {network.ssid: network for network in networks} @@ -339,12 +351,19 @@ def _connect_with_password(self, ssid: str, password: str): self._move_network_to_front(ssid, scroll=True) def _connect_to_network(self, ssid: str): + if self._wifi_manager.is_tethering_active(): + return + network = self._networks.get(ssid) if network is None: cloudlog.warning(f"Trying to connect to unknown network: {ssid}") return - if self._wifi_manager.is_connection_saved(network.ssid): + button = next((btn for btn in self._scroller.items if isinstance(btn, WifiButton) and btn.network.ssid == ssid), None) + if button is not None and button.wrong_password: + self._on_need_auth(network.ssid, False) + return + elif self._wifi_manager.is_connection_saved(network.ssid): self._wifi_manager.activate_connection(network.ssid) elif network.security_type == SecurityType.OPEN: self._wifi_manager.connect_to_network(network.ssid, "") @@ -360,6 +379,8 @@ def _on_need_auth(self, ssid, incorrect_password=True): if isinstance(btn, WifiButton) and btn.network.ssid == ssid: btn.set_wrong_password() break + + if not self._shown: return dlg = BigInputDialog("enter password...", "", minimum_length=8, @@ -372,6 +393,11 @@ def _on_forgotten(self, ssid): if isinstance(btn, WifiButton) and btn.network.ssid == ssid: btn.on_forgotten() + def _on_forget_failed(self, ssid): + self._on_forgotten(ssid) + if self._shown: + gui_app.push_widget(BigDialog("", tr("Failed to forget Wi-Fi network"))) + def _move_network_to_front(self, ssid: str | None, scroll: bool = False): # Move connecting/connected network to the front with animation front_btn_idx = next((i for i, btn in enumerate(self._scroller.items) diff --git a/openpilot/system/ui/lib/dhcp_client.py b/openpilot/system/ui/lib/dhcp_client.py new file mode 100644 index 00000000000000..b6a898ec8d147c --- /dev/null +++ b/openpilot/system/ui/lib/dhcp_client.py @@ -0,0 +1,214 @@ +import os +from pathlib import Path +import subprocess +import threading +import time + +from openpilot.common.swaglog import cloudlog + +DHCP_SCRIPT = os.path.join(os.path.dirname(__file__), "udhcpc.script") +DHCP_DEFAULT_SCRIPT = "/etc/udhcpc/default.script" +DHCP_RUNTIME_DIR = "/run/openpilot-wifi" + + +class DhcpClient: + """Manage udhcpc for DHCP on wlan0.""" + + # Match udhcpc's -T retry timeout + DISCOVER_TIMEOUT_SECONDS = 3 + DISCOVER_ATTEMPTS = 5 + STOP_TIMEOUT_SECONDS = 3 + STOP_POLL_SECONDS = 0.05 + + def __init__(self, iface: str = "wlan0"): + self._iface = iface + self._pid_file = os.path.join(DHCP_RUNTIME_DIR, f"udhcpc-{iface}.pid") + self._proc: subprocess.Popen | None = None + self._adopted = False + self._client_thread: threading.Thread | None = None + self._client_stop = threading.Event() + self._ipv6_enabled: bool | None = None + + def _start_client_thread(self): + self._client_stop.clear() + self._client_thread = threading.Thread(target=self._monitor_client, daemon=True) + self._client_thread.start() + + def _owned_pid(self) -> int | None: + try: + pid = int(Path(self._pid_file).read_text().strip()) + if pid <= 1: + return None + args = [os.fsdecode(arg) for arg in Path(f"/proc/{pid}/cmdline").read_bytes().split(b"\0") if arg] + except (OSError, ValueError): + return None + + def flag_value(flag: str) -> str | None: + try: + return args[args.index(flag) + 1] + except (ValueError, IndexError): + return None + + if ( + not args + or os.path.basename(args[0]) != "udhcpc" + or flag_value("-i") != self._iface + or flag_value("-p") != self._pid_file + or flag_value("-s") != DHCP_SCRIPT + ): + return None + return pid + + def _client_running(self) -> bool: + if self._proc is not None and self._proc.poll() is None: + return True + return self._owned_pid() is not None + + @staticmethod + def _process_group_running(pgid: int) -> bool: + try: + os.killpg(pgid, 0) + except ProcessLookupError: + return False + except PermissionError: + pass + return True + + def _owned_process_group(self, pid: int | None) -> int | None: + if pid is None: + return None + try: + pgid = os.getpgid(pid) + except OSError: + return None + if pgid <= 1 or pgid == os.getpgrp(): + return None + return pgid + + def _wait_for_process_group(self, pgid: int) -> bool: + deadline = time.monotonic() + self.STOP_TIMEOUT_SECONDS + wait = threading.Event() + while True: + if self._proc is not None: + self._proc.poll() + if not self._process_group_running(pgid): + return True + remaining = deadline - time.monotonic() + if remaining <= 0: + return False + wait.wait(min(self.STOP_POLL_SECONDS, remaining)) + + def _terminate_process_group(self, pgid: int): + subprocess.run(["sudo", "kill", "-TERM", "--", f"-{pgid}"], check=False) + if self._wait_for_process_group(pgid): + return + subprocess.run(["sudo", "kill", "-KILL", "--", f"-{pgid}"], check=False) + if not self._wait_for_process_group(pgid): + cloudlog.warning(f"Failed to stop udhcpc process group {pgid}") + + def _prepare_runtime(self): + subprocess.run(["sudo", "install", "-d", "-o", "root", "-g", "root", "-m", "755", DHCP_RUNTIME_DIR], check=True) + subprocess.run(["sudo", "rm", "-f", self._pid_file], check=False) + + def _flush_address(self): + subprocess.run(["sudo", "ip", "-4", "addr", "flush", "dev", self._iface], capture_output=True, check=False) + + def _flush_lease(self): + subprocess.run(["sudo", "ip", "-4", "route", "flush", "dev", self._iface], capture_output=True, check=False) + self._flush_address() + + def clear_ipv6_state(self): + delete_default_route = ["sudo", "ip", "-6", "route", "del", "default", "dev", self._iface] + for command in ( + ["sudo", "ip", "-6", "addr", "flush", "dev", self._iface, "scope", "global"], + # Delete router-advertised defaults before flushing routes + delete_default_route, + ["sudo", "ip", "-6", "route", "flush", "dev", self._iface], + ): + try: + result = subprocess.run(command, capture_output=True, check=False) + missing_default_route = ( + command == delete_default_route + and result.returncode == 2 + and b"No such process" in result.stderr + ) + if result.returncode != 0 and not missing_default_route: + cloudlog.warning(f"Failed to clear {self._iface} IPv6 state (rc={result.returncode})") + except OSError: + cloudlog.exception(f"Failed to clear {self._iface} IPv6 state") + + def set_ipv6_enabled(self, enabled: bool): + if self._ipv6_enabled == enabled: + return + disabled = "0" if enabled else "1" + subprocess.run(["sudo", "sysctl", f"net.ipv6.conf.{self._iface}.disable_ipv6={disabled}"], check=True) + actual = Path(f"/proc/sys/net/ipv6/conf/{self._iface}/disable_ipv6").read_text().strip() + if actual != disabled: + raise RuntimeError(f"Failed to set IPv6 enabled={enabled} on {self._iface} (actual={actual!r})") + self._ipv6_enabled = enabled + if not enabled: + self.clear_ipv6_state() + + def _spawn(self) -> bool: + if not os.access(DHCP_DEFAULT_SCRIPT, os.X_OK): + cloudlog.error(f"udhcpc default script is not executable: {DHCP_DEFAULT_SCRIPT}") + return False + try: + self._prepare_runtime() + self._proc = subprocess.Popen( + ["sudo", "udhcpc", "-i", self._iface, "-f", + "-t", str(self.DISCOVER_ATTEMPTS), "-T", str(self.DISCOVER_TIMEOUT_SECONDS), + "-p", self._pid_file, "-s", DHCP_SCRIPT], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + start_new_session=True, + ) + except Exception: + self._proc = None + cloudlog.exception("Failed to start udhcpc") + return False + return True + + def _monitor_client(self): + while not self._client_stop.wait(self.DISCOVER_TIMEOUT_SECONDS): + if not self._client_running(): + self._flush_lease() + self._spawn() + + def adopt(self) -> bool: + if not self._client_running(): + return False + self._adopted = True + self._start_client_thread() + return True + + def start(self): + self.stop() + self._spawn() + self._start_client_thread() + + def stop(self): + self._client_stop.set() + if self._client_thread is not None: + self._client_thread.join(timeout=self.DISCOVER_TIMEOUT_SECONDS) + self._client_thread = None + owned_pid = self._owned_pid() + process_pid = owned_pid + if self._proc is not None and self._proc.poll() is None: + process_pid = self._proc.pid + pgid = self._owned_process_group(process_pid) + if pgid is not None: + self._terminate_process_group(pgid) + elif self._proc is not None: + try: + self._proc.terminate() + self._proc.wait(timeout=3) + except Exception: + try: + self._proc.kill() + self._proc.wait() + except Exception: + pass + self._proc = None + subprocess.run(["sudo", "rm", "-f", self._pid_file], check=False) + self._adopted = False + self._flush_lease() diff --git a/openpilot/system/ui/lib/networkmanager.py b/openpilot/system/ui/lib/networkmanager.py deleted file mode 100644 index d2d6b30b1071da..00000000000000 --- a/openpilot/system/ui/lib/networkmanager.py +++ /dev/null @@ -1,64 +0,0 @@ -from enum import IntEnum - - -# NetworkManager device states -class NMDeviceState(IntEnum): - # https://networkmanager.dev/docs/api/1.46/nm-dbus-types.html#NMDeviceState - UNKNOWN = 0 - UNMANAGED = 10 - UNAVAILABLE = 20 - DISCONNECTED = 30 - PREPARE = 40 - CONFIG = 50 - NEED_AUTH = 60 - IP_CONFIG = 70 - IP_CHECK = 80 - SECONDARIES = 90 - ACTIVATED = 100 - DEACTIVATING = 110 - FAILED = 120 - - -class NMDeviceStateReason(IntEnum): - # https://networkmanager.dev/docs/api/1.46/nm-dbus-types.html#NMDeviceStateReason - NONE = 0 - UNKNOWN = 1 - IP_CONFIG_UNAVAILABLE = 5 - NO_SECRETS = 7 - SUPPLICANT_DISCONNECT = 8 - SUPPLICANT_TIMEOUT = 11 - CONNECTION_REMOVED = 38 - USER_REQUESTED = 39 - SSID_NOT_FOUND = 53 - NEW_ACTIVATION = 60 - - -# NetworkManager constants -NM = "org.freedesktop.NetworkManager" -NM_PATH = '/org/freedesktop/NetworkManager' -NM_IFACE = 'org.freedesktop.NetworkManager' -NM_ACCESS_POINT_IFACE = 'org.freedesktop.NetworkManager.AccessPoint' -NM_SETTINGS_PATH = '/org/freedesktop/NetworkManager/Settings' -NM_SETTINGS_IFACE = 'org.freedesktop.NetworkManager.Settings' -NM_CONNECTION_IFACE = 'org.freedesktop.NetworkManager.Settings.Connection' -NM_ACTIVE_CONNECTION_IFACE = 'org.freedesktop.NetworkManager.Connection.Active' -NM_WIRELESS_IFACE = 'org.freedesktop.NetworkManager.Device.Wireless' -NM_PROPERTIES_IFACE = 'org.freedesktop.DBus.Properties' -NM_DEVICE_IFACE = 'org.freedesktop.NetworkManager.Device' -NM_IP4_CONFIG_IFACE = 'org.freedesktop.NetworkManager.IP4Config' - -NM_DEVICE_TYPE_WIFI = 2 -NM_DEVICE_TYPE_MODEM = 8 - -# https://developer.gnome.org/NetworkManager/1.26/nm-dbus-types.html#NM80211ApFlags -NM_802_11_AP_FLAGS_NONE = 0x0 -NM_802_11_AP_FLAGS_PRIVACY = 0x1 -NM_802_11_AP_FLAGS_WPS = 0x2 - -# https://developer.gnome.org/NetworkManager/1.26/nm-dbus-types.html#NM80211ApSecurityFlags -NM_802_11_AP_SEC_PAIR_WEP40 = 0x00000001 -NM_802_11_AP_SEC_PAIR_WEP104 = 0x00000002 -NM_802_11_AP_SEC_GROUP_WEP40 = 0x00000010 -NM_802_11_AP_SEC_GROUP_WEP104 = 0x00000020 -NM_802_11_AP_SEC_KEY_MGMT_PSK = 0x00000100 -NM_802_11_AP_SEC_KEY_MGMT_802_1X = 0x00000200 diff --git a/openpilot/system/ui/lib/tests/test_dhcp_client.py b/openpilot/system/ui/lib/tests/test_dhcp_client.py new file mode 100644 index 00000000000000..d44031ad7a7e11 --- /dev/null +++ b/openpilot/system/ui/lib/tests/test_dhcp_client.py @@ -0,0 +1,302 @@ +import os +import subprocess +import tempfile +from pathlib import Path +from unittest import TestCase +from unittest.mock import MagicMock, patch + +from openpilot.system.ui.lib import dhcp_client as dhcp_client_module +from openpilot.system.ui.lib.dhcp_client import DhcpClient + + +class TestDhcpClient(TestCase): + def setUp(self): + self.enterContext(patch.object(dhcp_client_module.os, "access", return_value=True)) + + def test_adopt_existing_udhcpc_without_restarting_it(self): + client = DhcpClient() + with ( + patch.object(client, "_owned_pid", return_value=123), + patch.object(dhcp_client_module.subprocess, "Popen") as popen, + patch.object(dhcp_client_module.threading, "Thread") as thread, + ): + assert client.adopt() + + popen.assert_not_called() + thread.assert_called_once_with(target=client._monitor_client, daemon=True) + thread.return_value.start.assert_called_once() + + def test_pidfile_rejects_foreign_udhcpc_command(self): + client = DhcpClient() + command = b"udhcpc\0-i\0wlan1\0-p\0/run/foreign.pid\0-s\0/tmp/script\0" + with ( + patch.object(Path, "read_text", return_value="123"), + patch.object(Path, "read_bytes", return_value=command), + ): + assert client._owned_pid() is None + + def test_start_flushes_stale_lease_and_detaches_udhcpc_from_ui_session(self): + client = DhcpClient() + events = [] + with ( + patch.object(client, "_owned_pid", return_value=None), + patch.object(dhcp_client_module.subprocess, "run", side_effect=lambda command, **_: events.append(command)) as run, + patch.object(dhcp_client_module.subprocess, "Popen", side_effect=lambda *_, **__: events.append("spawn") or MagicMock()) as popen, + patch.object(dhcp_client_module.threading, "Thread") as thread, + ): + client.start() + + assert [call.args[0] for call in run.call_args_list] == [ + ["sudo", "rm", "-f", client._pid_file], + ["sudo", "ip", "-4", "route", "flush", "dev", "wlan0"], + ["sudo", "ip", "-4", "addr", "flush", "dev", "wlan0"], + ["sudo", "install", "-d", "-o", "root", "-g", "root", "-m", "755", dhcp_client_module.DHCP_RUNTIME_DIR], + ["sudo", "rm", "-f", client._pid_file], + ] + assert events == [*([call.args[0] for call in run.call_args_list]), "spawn"] + popen.assert_called_once_with( + [ + "sudo", "udhcpc", "-i", "wlan0", "-f", "-t", "5", "-T", "3", + "-p", client._pid_file, "-s", dhcp_client_module.DHCP_SCRIPT, + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + thread.assert_called_once_with(target=client._monitor_client, daemon=True) + thread.return_value.start.assert_called_once() + + def test_failed_launch_starts_client_monitor(self): + client = DhcpClient() + with ( + patch.object(client, "stop"), + patch.object(dhcp_client_module.subprocess, "run"), + patch.object(dhcp_client_module.subprocess, "Popen", side_effect=OSError("exec failed")), + patch.object(dhcp_client_module.threading, "Thread") as thread, + ): + client.start() + + targets = [item.kwargs["target"] for item in thread.call_args_list] + assert client._monitor_client in targets + + def test_missing_default_script_is_reported_before_launch(self): + client = DhcpClient() + with ( + patch.object(dhcp_client_module.os, "access", return_value=False), + patch.object(dhcp_client_module.cloudlog, "error") as error, + patch.object(dhcp_client_module.subprocess, "Popen") as popen, + ): + assert not client._spawn() + + error.assert_called_once_with(f"udhcpc default script is not executable: {dhcp_client_module.DHCP_DEFAULT_SCRIPT}") + popen.assert_not_called() + + def test_exited_client_is_restarted(self): + client = DhcpClient() + client._proc = MagicMock() + client._proc.poll.return_value = 1 + + with ( + patch.object(client._client_stop, "wait", side_effect=[False, True]), + patch.object(client, "_owned_pid", return_value=None), + patch.object( + dhcp_client_module.subprocess, + "run", + return_value=MagicMock(returncode=1), + ), + patch.object(client, "_flush_address") as flush_address, + patch.object(client, "_spawn", return_value=True) as spawn, + ): + client._monitor_client() + + flush_address.assert_called_once() + spawn.assert_called_once() + + def test_dhcp_script_applies_metric_after_default_script(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + trace = root / "trace" + default_script = root / "default.script" + default_script.write_text('#!/bin/sh\nprintf "default %s\\n" "$1" >> "$TRACE"\n') + default_script.chmod(0o755) + ip = root / "ip" + ip.write_text("""#!/bin/sh +printf "ip %s\\n" "$*" >> "$TRACE" +[ "$*" = "-4 route show default dev wlan0" ] && printf "default via 192.168.1.1 metric 600\\n" +exit 0 +""") + ip.chmod(0o755) + env = { + **os.environ, + "PATH": f"{root}:{os.environ['PATH']}", + "TRACE": str(trace), + "UDHCPC_DEFAULT_SCRIPT": str(default_script), + "interface": "wlan0", + "router": "192.168.1.1 192.168.1.2", + } + + subprocess.run([dhcp_client_module.DHCP_SCRIPT, "renew"], env=env, check=True) + + assert trace.read_text().splitlines() == [ + "default renew", + "ip -4 route flush default dev wlan0", + "ip -4 route replace default via 192.168.1.1 dev wlan0 metric 600", + "ip -4 route show default dev wlan0", + ] + def test_dhcp_script_propagates_route_install_failure(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + default_script = root / "default.script" + default_script.write_text("#!/bin/sh\nexit 0\n") + default_script.chmod(0o755) + ip = root / "ip" + ip.write_text("#!/bin/sh\nexit 1\n") + ip.chmod(0o755) + env = { + **os.environ, + "PATH": f"{root}:{os.environ['PATH']}", + "UDHCPC_DEFAULT_SCRIPT": str(default_script), + "interface": "wlan0", + "router": "192.168.1.1", + } + + result = subprocess.run([dhcp_client_module.DHCP_SCRIPT, "bound"], env=env, check=False) + assert result.returncode != 0 + + def test_dhcp_script_rejects_noncanonical_default_routes(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + default_script = root / "default.script" + default_script.write_text("#!/bin/sh\nexit 0\n") + default_script.chmod(0o755) + ip = root / "ip" + ip.write_text("""\ +#!/bin/sh +if [ "$*" = "-4 route show default dev wlan0" ]; then + printf "default via 192.168.1.1 dev wlan0 metric 600\\ndefault via 192.168.1.2 dev wlan0 metric 0\\n" +fi +""") + ip.chmod(0o755) + env = { + **os.environ, + "PATH": f"{root}:{os.environ['PATH']}", + "UDHCPC_DEFAULT_SCRIPT": str(default_script), + "interface": "wlan0", + "router": "192.168.1.1", + } + + result = subprocess.run([dhcp_client_module.DHCP_SCRIPT, "renew"], env=env, check=False) + assert result.returncode != 0 + + + def test_stop_cleans_wlan_dhcp_state_with_or_without_process_handle(self): + for proc in (MagicMock(), None): + with self.subTest(has_process_handle=proc is not None): + client = DhcpClient() + client._proc = proc + with ( + patch.object(client, "_owned_pid", return_value=None), + patch.object(dhcp_client_module.subprocess, "run") as run, + ): + client.stop() + + assert client._proc is None + assert [call.args[0] for call in run.call_args_list] == [ + ["sudo", "rm", "-f", client._pid_file], + ["sudo", "ip", "-4", "route", "flush", "dev", "wlan0"], + ["sudo", "ip", "-4", "addr", "flush", "dev", "wlan0"], + ] + + def test_stop_kills_only_pidfile_owned_adopted_client(self): + client = DhcpClient() + events = [] + with ( + patch.object(client, "_owned_pid", return_value=123), + patch.object(dhcp_client_module.os, "getpgid", return_value=456), + patch.object(client, "_terminate_process_group", side_effect=lambda pgid: events.append(("terminate", pgid))), + patch.object(dhcp_client_module.subprocess, "run", side_effect=lambda command, **_: events.append(command)) as run, + ): + client.stop() + + assert events[0] == ("terminate", 456) + assert [call.args[0] for call in run.call_args_list] == [ + ["sudo", "rm", "-f", client._pid_file], + ["sudo", "ip", "-4", "route", "flush", "dev", "wlan0"], + ["sudo", "ip", "-4", "addr", "flush", "dev", "wlan0"], + ] + + def test_stop_terminates_spawned_client_process_group(self): + client = DhcpClient() + client._proc = MagicMock(pid=123) + client._proc.poll.return_value = None + with ( + patch.object(client, "_owned_pid", return_value=456), + patch.object(dhcp_client_module.os, "getpgid", return_value=123), + patch.object(client, "_terminate_process_group") as terminate, + patch.object(dhcp_client_module.subprocess, "run"), + ): + client.stop() + + terminate.assert_called_once_with(123) + + def test_process_group_termination_escalates_after_timeout(self): + client = DhcpClient() + with ( + patch.object(client, "_wait_for_process_group", side_effect=[False, True]) as wait, + patch.object(dhcp_client_module.subprocess, "run", return_value=MagicMock(returncode=0)) as run, + ): + client._terminate_process_group(456) + + assert [call.args[0] for call in run.call_args_list] == [ + ["sudo", "kill", "-TERM", "--", "-456"], + ["sudo", "kill", "-KILL", "--", "-456"], + ] + assert wait.call_args_list == [((456,),), ((456,),)] + + def test_clear_ipv6_state_cleans_global_addresses_and_routes(self): + client = DhcpClient() + with patch.object(dhcp_client_module.subprocess, "run", return_value=MagicMock(returncode=0)) as run: + client.clear_ipv6_state() + + assert [item.args[0] for item in run.call_args_list] == [ + ["sudo", "ip", "-6", "addr", "flush", "dev", "wlan0", "scope", "global"], + ["sudo", "ip", "-6", "route", "del", "default", "dev", "wlan0"], + ["sudo", "ip", "-6", "route", "flush", "dev", "wlan0"], + ] + + def test_clear_ipv6_state_ignores_absent_default_route(self): + client = DhcpClient() + results = ( + MagicMock(returncode=0, stderr=b""), + MagicMock(returncode=2, stderr=b"RTNETLINK answers: No such process\n"), + MagicMock(returncode=0, stderr=b""), + ) + with ( + patch.object(dhcp_client_module.subprocess, "run", side_effect=results), + patch.object(dhcp_client_module.cloudlog, "warning") as warning, + ): + client.clear_ipv6_state() + + warning.assert_not_called() + + def test_ipv6_policy_is_applied_once_per_value(self): + client = DhcpClient() + with ( + patch.object(dhcp_client_module.subprocess, "run") as run, + patch.object(Path, "read_text", return_value="1\n"), + patch.object(client, "clear_ipv6_state") as clear_ipv6_state, + ): + client.set_ipv6_enabled(False) + client.set_ipv6_enabled(False) + + run.assert_called_once_with(["sudo", "sysctl", "net.ipv6.conf.wlan0.disable_ipv6=1"], check=True) + clear_ipv6_state.assert_called_once() + + def test_ipv6_policy_verifies_kernel_state(self): + client = DhcpClient() + with ( + patch.object(dhcp_client_module.subprocess, "run"), + patch.object(Path, "read_text", return_value="1\n"), + ): + with self.assertRaisesRegex(RuntimeError, "actual='1'"): + client.set_ipv6_enabled(True) diff --git a/openpilot/system/ui/lib/tests/test_handle_state_change.py b/openpilot/system/ui/lib/tests/test_handle_state_change.py index a7a33834cd6a4f..01eec708ddf9a7 100644 --- a/openpilot/system/ui/lib/tests/test_handle_state_change.py +++ b/openpilot/system/ui/lib/tests/test_handle_state_change.py @@ -1,907 +1,2593 @@ -"""Tests for WifiManager._handle_state_change. - -Tests the state machine in isolation by constructing a WifiManager with mocked -DBus, then calling _handle_state_change directly with NM state transitions. -""" -import unittest -from jeepney.low_level import MessageType - -from openpilot.common.parameterized import parameterized -from openpilot.common.test import Mocker, OpenpilotTestCase -from openpilot.system.ui.lib.networkmanager import NMDeviceState, NMDeviceStateReason -from openpilot.system.ui.lib.wifi_manager import WifiManager, WifiState, ConnectStatus - - -def _make_wm(mocker: Mocker, connections=None): - """Create a WifiManager with only the fields _handle_state_change touches.""" - mocker.patch.object(WifiManager, '_initialize') - wm = WifiManager.__new__(WifiManager) - wm._exit = True # prevent stop() from doing anything in __del__ - wm._conn_monitor = mocker.MagicMock() - wm._connections = dict(connections or {}) - wm._wifi_state = WifiState() - wm._user_epoch = 0 - wm._callback_queue = [] - wm._need_auth = [] - wm._activated = [] - wm._update_networks = mocker.MagicMock() - wm._update_active_connection_info = mocker.MagicMock() - wm._get_active_wifi_connection = mocker.MagicMock(return_value=(None, None)) - return wm - - -def fire(wm: WifiManager, new_state: int, prev_state: int = NMDeviceState.UNKNOWN, - reason: int = NMDeviceStateReason.NONE) -> None: - """Feed a state change into the handler.""" - wm._handle_state_change(new_state, prev_state, reason) - - -def fire_wpa_connect(wm: WifiManager) -> None: - """WPA handshake then IP negotiation through ACTIVATED, as seen on device.""" - fire(wm, NMDeviceState.NEED_AUTH) - fire(wm, NMDeviceState.PREPARE, prev_state=NMDeviceState.NEED_AUTH) - fire(wm, NMDeviceState.CONFIG) - fire(wm, NMDeviceState.IP_CONFIG) - fire(wm, NMDeviceState.IP_CHECK) - fire(wm, NMDeviceState.SECONDARIES) - fire(wm, NMDeviceState.ACTIVATED) - - -# --------------------------------------------------------------------------- -# Basic transitions -# --------------------------------------------------------------------------- - -class TestDisconnected(OpenpilotTestCase): - def test_generic_disconnect_clears_state(self, mocker): - wm = _make_wm(mocker) - wm._wifi_state = WifiState(ssid="Net", status=ConnectStatus.CONNECTED) - - fire(wm, NMDeviceState.DISCONNECTED, reason=NMDeviceStateReason.UNKNOWN) - - assert wm._wifi_state.ssid is None - assert wm._wifi_state.status == ConnectStatus.DISCONNECTED - wm._update_networks.assert_not_called() - - def test_new_activation_is_noop(self, mocker): - """NEW_ACTIVATION means NM is about to connect to another network — don't clear.""" - wm = _make_wm(mocker) - wm._wifi_state = WifiState(ssid="OldNet", status=ConnectStatus.CONNECTED) - - fire(wm, NMDeviceState.DISCONNECTED, reason=NMDeviceStateReason.NEW_ACTIVATION) - - assert wm._wifi_state.ssid == "OldNet" - assert wm._wifi_state.status == ConnectStatus.CONNECTED - - def test_connection_removed_keeps_other_connecting(self, mocker): - """Forget A while connecting to B: CONNECTION_REMOVED for A must not clear B.""" - wm = _make_wm(mocker, connections={"B": "/path/B"}) - wm._set_connecting("B") - - fire(wm, NMDeviceState.DISCONNECTED, reason=NMDeviceStateReason.CONNECTION_REMOVED) - - assert wm._wifi_state.ssid == "B" - assert wm._wifi_state.status == ConnectStatus.CONNECTING - - def test_connection_removed_clears_when_forgotten(self, mocker): - """Forget A: A is no longer in _connections, so state should clear.""" - wm = _make_wm(mocker, connections={}) - wm._wifi_state = WifiState(ssid="A", status=ConnectStatus.CONNECTED) - - fire(wm, NMDeviceState.DISCONNECTED, reason=NMDeviceStateReason.CONNECTION_REMOVED) - - assert wm._wifi_state.ssid is None - assert wm._wifi_state.status == ConnectStatus.DISCONNECTED - - -class TestDeactivating(OpenpilotTestCase): - def test_deactivating_noop_for_non_connection_removed(self, mocker): - """DEACTIVATING with non-CONNECTION_REMOVED reason is a no-op.""" - wm = _make_wm(mocker) - wm._wifi_state = WifiState(ssid="Net", status=ConnectStatus.CONNECTED) - - fire(wm, NMDeviceState.DEACTIVATING, reason=NMDeviceStateReason.USER_REQUESTED) - - assert wm._wifi_state.ssid == "Net" - assert wm._wifi_state.status == ConnectStatus.CONNECTED - - @parameterized.expand([ - (ConnectStatus.CONNECTED, True), - (ConnectStatus.CONNECTING, False), - ], names=("status", "expected_clears")) - def test_deactivating_connection_removed(self, mocker, status, expected_clears): - """DEACTIVATING(CONNECTION_REMOVED) clears CONNECTED but preserves CONNECTING. - - CONNECTED: forgetting the current network. The forgotten callback fires between - DEACTIVATING and DISCONNECTED — must clear here so the UI doesn't flash "connected" - after the eager _network_forgetting flag resets. - - CONNECTING: forget A while connecting to B. DEACTIVATING fires for A's removal, - but B's CONNECTING state must be preserved. - """ - wm = _make_wm(mocker, connections={"B": "/path/B"}) - wm._wifi_state = WifiState(ssid="B" if status == ConnectStatus.CONNECTING else "A", status=status) - - fire(wm, NMDeviceState.DEACTIVATING, reason=NMDeviceStateReason.CONNECTION_REMOVED) - - if expected_clears: - assert wm._wifi_state.ssid is None - assert wm._wifi_state.status == ConnectStatus.DISCONNECTED - else: - assert wm._wifi_state.ssid == "B" - assert wm._wifi_state.status == ConnectStatus.CONNECTING - - -class TestPrepareConfig(OpenpilotTestCase): - def test_user_initiated_skips_dbus_lookup(self, mocker): - """User called _set_connecting('B') — PREPARE must not overwrite via DBus. - - Reproduced on device: rapidly tap A then B. PREPARE's DBus lookup returns A's - stale conn_path, overwriting ssid to A for 1-2 frames. UI shows the "connecting" - indicator briefly jump to the wrong network row then back. - """ - wm = _make_wm(mocker, connections={"A": "/path/A", "B": "/path/B"}) - wm._set_connecting("B") - wm._get_active_wifi_connection.return_value = ("/path/A", {}) - - fire(wm, NMDeviceState.PREPARE) - - assert wm._wifi_state.ssid == "B" - assert wm._wifi_state.status == ConnectStatus.CONNECTING - wm._get_active_wifi_connection.assert_not_called() - - @parameterized.expand([NMDeviceState.PREPARE, NMDeviceState.CONFIG], names=("state",)) - def test_auto_connect_looks_up_ssid(self, mocker, state): - """Auto-connection (ssid=None): PREPARE and CONFIG must look up ssid from NM.""" - wm = _make_wm(mocker, connections={"AutoNet": "/path/auto"}) - wm._get_active_wifi_connection.return_value = ("/path/auto", {}) - - fire(wm, state) - - assert wm._wifi_state.ssid == "AutoNet" - assert wm._wifi_state.status == ConnectStatus.CONNECTING - - def test_auto_connect_dbus_fails(self, mocker): - """Auto-connection but DBus returns None: ssid stays None, status CONNECTING.""" - wm = _make_wm(mocker) - - fire(wm, NMDeviceState.PREPARE) - - assert wm._wifi_state.ssid is None - assert wm._wifi_state.status == ConnectStatus.CONNECTING - - def test_auto_connect_conn_path_not_in_connections(self, mocker): - """DBus returns a conn_path that doesn't match any known connection.""" - wm = _make_wm(mocker, connections={"Other": "/path/other"}) - wm._get_active_wifi_connection.return_value = ("/path/unknown", {}) - - fire(wm, NMDeviceState.PREPARE) - - assert wm._wifi_state.ssid is None - assert wm._wifi_state.status == ConnectStatus.CONNECTING - - -class TestNeedAuth(OpenpilotTestCase): - def test_wrong_password_fires_callback(self, mocker): - """NEED_AUTH+SUPPLICANT_DISCONNECT from CONFIG = real wrong password.""" - wm = _make_wm(mocker) - cb = mocker.MagicMock() - wm.add_callbacks(need_auth=cb) - wm._set_connecting("SecNet") - - fire(wm, NMDeviceState.NEED_AUTH, prev_state=NMDeviceState.CONFIG, - reason=NMDeviceStateReason.SUPPLICANT_DISCONNECT) - - assert wm._wifi_state.status == ConnectStatus.DISCONNECTED - assert len(wm._callback_queue) == 1 - wm.process_callbacks() - cb.assert_called_once_with("SecNet") - - def test_failed_no_secrets_fires_callback(self, mocker): - """FAILED+NO_SECRETS = wrong password (weak/gone network). - - Confirmed on device: also fires when a hotspot turns off during connection. - NM can't complete the WPA handshake (AP vanished) and reports NO_SECRETS - rather than SSID_NOT_FOUND. The need_auth callback fires, so the UI shows - "wrong password" — a false positive, but same signal path. - - Real device sequence (new connection, hotspot turned off immediately): - PREPARE → CONFIG → NEED_AUTH(CONFIG, NONE) → PREPARE(NEED_AUTH) → CONFIG - → NEED_AUTH(CONFIG, NONE) → FAILED(NEED_AUTH, NO_SECRETS) → DISCONNECTED(FAILED, NONE) - """ - wm = _make_wm(mocker) - cb = mocker.MagicMock() - wm.add_callbacks(need_auth=cb) - wm._set_connecting("WeakNet") - - fire(wm, NMDeviceState.FAILED, reason=NMDeviceStateReason.NO_SECRETS) - - assert wm._wifi_state.status == ConnectStatus.DISCONNECTED - assert len(wm._callback_queue) == 1 - wm.process_callbacks() - cb.assert_called_once_with("WeakNet") - - def test_need_auth_then_failed_no_double_fire(self, mocker): - """Real device sends NEED_AUTH(SUPPLICANT_DISCONNECT) then FAILED(NO_SECRETS) back-to-back. - - The first clears ssid, so the second must not fire a duplicate callback. - Real device sequence: NEED_AUTH(CONFIG, SUPPLICANT_DISCONNECT) → FAILED(NEED_AUTH, NO_SECRETS) - """ - wm = _make_wm(mocker) - cb = mocker.MagicMock() - wm.add_callbacks(need_auth=cb) - wm._set_connecting("BadPass") - - fire(wm, NMDeviceState.NEED_AUTH, prev_state=NMDeviceState.CONFIG, - reason=NMDeviceStateReason.SUPPLICANT_DISCONNECT) - assert len(wm._callback_queue) == 1 - - fire(wm, NMDeviceState.FAILED, prev_state=NMDeviceState.NEED_AUTH, - reason=NMDeviceStateReason.NO_SECRETS) - assert len(wm._callback_queue) == 1 # no duplicate - - wm.process_callbacks() - cb.assert_called_once_with("BadPass") - - def test_no_ssid_no_callback(self, mocker): - """If ssid is None when NEED_AUTH fires, no callback enqueued.""" - wm = _make_wm(mocker) - cb = mocker.MagicMock() - wm.add_callbacks(need_auth=cb) - - fire(wm, NMDeviceState.NEED_AUTH, reason=NMDeviceStateReason.SUPPLICANT_DISCONNECT) - - assert len(wm._callback_queue) == 0 - - def test_interrupted_auth_ignored(self, mocker): - """Switching A->B: NEED_AUTH from A (prev=DISCONNECTED) must not fire callback. - - Reproduced on device: rapidly switching between two saved networks can trigger a - rare false "wrong password" dialog for the previous network, even though both have - correct passwords. The stale NEED_AUTH has prev_state=DISCONNECTED (not CONFIG). - """ - wm = _make_wm(mocker) - cb = mocker.MagicMock() - wm.add_callbacks(need_auth=cb) - wm._set_connecting("A") - wm._set_connecting("B") - - fire(wm, NMDeviceState.NEED_AUTH, prev_state=NMDeviceState.DISCONNECTED, - reason=NMDeviceStateReason.SUPPLICANT_DISCONNECT) - - assert wm._wifi_state.ssid == "B" - assert wm._wifi_state.status == ConnectStatus.CONNECTING - assert len(wm._callback_queue) == 0 - - -class TestPassthroughStates(OpenpilotTestCase): - """NEED_AUTH (generic), IP_CONFIG, IP_CHECK, SECONDARIES, FAILED (generic) are no-ops.""" - - @parameterized.expand([ - NMDeviceState.NEED_AUTH, - NMDeviceState.IP_CONFIG, - NMDeviceState.IP_CHECK, - NMDeviceState.SECONDARIES, - NMDeviceState.FAILED, - ], names=("state",)) - def test_passthrough_is_noop(self, mocker, state): - wm = _make_wm(mocker) - wm._set_connecting("Net") - - fire(wm, state, reason=NMDeviceStateReason.NONE) - - assert wm._wifi_state.ssid == "Net" - assert wm._wifi_state.status == ConnectStatus.CONNECTING - assert len(wm._callback_queue) == 0 - - -class TestActivated(OpenpilotTestCase): - def test_sets_connected(self, mocker): - """ACTIVATED sets status to CONNECTED and fires callback.""" - wm = _make_wm(mocker, connections={"MyNet": "/path/mynet"}) - cb = mocker.MagicMock() - wm.add_callbacks(activated=cb) - wm._set_connecting("MyNet") - wm._get_active_wifi_connection.return_value = ("/path/mynet", {}) - - fire(wm, NMDeviceState.ACTIVATED) - - assert wm._wifi_state.status == ConnectStatus.CONNECTED - assert wm._wifi_state.ssid == "MyNet" - assert len(wm._callback_queue) == 1 - wm.process_callbacks() - cb.assert_called_once() - - def test_conn_path_none_still_connected(self, mocker): - """ACTIVATED but DBus returns None: status CONNECTED, ssid unchanged.""" - wm = _make_wm(mocker) - wm._set_connecting("MyNet") - - fire(wm, NMDeviceState.ACTIVATED) - - assert wm._wifi_state.status == ConnectStatus.CONNECTED - assert wm._wifi_state.ssid == "MyNet" - - def test_activated_side_effects(self, mocker): - """ACTIVATED persists the volatile connection to disk and updates active connection info.""" - wm = _make_wm(mocker, connections={"Net": "/path/net"}) - wm._set_connecting("Net") - wm._get_active_wifi_connection.return_value = ("/path/net", {}) - - fire(wm, NMDeviceState.ACTIVATED) - - wm._conn_monitor.send_and_get_reply.assert_called_once() - wm._update_active_connection_info.assert_called_once() - wm._update_networks.assert_not_called() - +import threading +import time +import uuid +from typing import cast +from unittest import TestCase +from unittest.mock import MagicMock, call, mock_open, patch + +from openpilot.system.ui.lib import wifi_manager as wifi_manager_module +from openpilot.system.ui.lib.wifi_manager import ( + CONNECTING_STALE_TIMEOUT_SECONDS, + ConnectStatus, + MeteredType, + SCAN_PERIOD_SECONDS, + SecurityType, + WifiManager, + WifiState, +) + + +def build_wifi_manager() -> WifiManager: + store = MagicMock() + store.get_metered.return_value = 0 + store.contains.return_value = False + dhcp = MagicMock() + with ( + patch.object(wifi_manager_module, "NetworkStore", return_value=store), + patch.object(wifi_manager_module, "DhcpClient", return_value=dhcp), + patch.object(wifi_manager_module, "Params", None), + patch.object(WifiManager, "_initialize"), + patch.object(wifi_manager_module.atexit, "register"), + ): + manager = WifiManager() + + manager._store = store + manager._exit = True + manager._ctrl = MagicMock() + manager._ipv4_forward = True + manager._apply_ipv4_forward = MagicMock() + manager._tethering_ssid = "Hotspot" + manager._tethering_psk = "hotspot-password" + manager._last_connecting_at = time.monotonic() - CONNECTING_STALE_TIMEOUT_SECONDS - 1 + manager._update_active_connection_info = MagicMock() + manager._poll_for_ip = MagicMock() + manager._wifi_default_route_ready = MagicMock(return_value=True) + manager._ctrl.request.return_value = "wpa_state=COMPLETED\nssid=TestNet\n" + return manager +def complete_station_connection(manager: WifiManager, ssid: str): + manager._ipv4_address = "192.168.1.20" + manager._complete_station_connection(ssid, manager._user_epoch) + + + + +class TestConnectionState(TestCase): + def setUp(self): + self.manager = build_wifi_manager() + + def test_connected_persists_after_auth_and_is_idempotent(self): + activated = MagicMock() + self.manager.add_callbacks(activated=activated) + self.manager._set_connecting("TestNet") + self.manager._set_pending_connection("TestNet", "password123", False, SecurityType.WPA) + profile_uuid = self.manager._pending_connection.profile_uuid + + with patch.object(wifi_manager_module, "generate_wpa_conf"): + self.manager._handle_connected("TestNet") + complete_station_connection(self.manager, "TestNet") + self.manager._handle_connected("TestNet") + + self.manager.process_callbacks() + assert self.manager.wifi_state == WifiState("TestNet", ConnectStatus.CONNECTED) + self.manager._store.save_network.assert_called_once_with( + "TestNet", psk="password123", hidden=False, security=SecurityType.WPA, profile_uuid=profile_uuid, + ) + self.manager._dhcp.start.assert_called_once() + activated.assert_called_once() + assert call("ENABLE_NETWORK all") in self.manager._ctrl.request.call_args_list + + def test_connected_waits_for_ip_before_activation(self): + activated = MagicMock() + self.manager.add_callbacks(activated=activated) + self.manager._set_connecting("TestNet") + self.manager._set_pending_connection("TestNet", "password123", False, SecurityType.WPA) + epoch = self.manager._user_epoch + + with patch.object(wifi_manager_module, "generate_wpa_conf"): + self.manager._handle_connected("TestNet", expected_epoch=epoch) + + self.manager.process_callbacks() + assert self.manager.wifi_state == WifiState("TestNet", ConnectStatus.CONNECTING) + assert self.manager.connected_ssid is None + self.manager._dhcp.start.assert_called_once() + activated.assert_not_called() + + self.manager._ipv4_address = "192.168.1.20" + self.manager._complete_station_connection("TestNet", epoch) + self.manager.process_callbacks() + + assert self.manager.wifi_state == WifiState("TestNet", ConnectStatus.CONNECTED) + assert self.manager.connected_ssid == "TestNet" + activated.assert_called_once() + + def test_connected_applies_active_profile_ipv6_policy(self): + for method, enabled in (("auto", True), ("ignore", False)): + with self.subTest(method=method): + manager = build_wifi_manager() + manager._set_connecting("TestNet") + profile_uuid = str(uuid.uuid4()) + manager._store.get_ipv6_method.return_value = method + + manager._handle_connected("TestNet", profile_uuid=profile_uuid) + + manager._store.get_ipv6_method.assert_called_once_with("TestNet", profile_uuid) + manager._dhcp.set_ipv6_enabled.assert_called_once_with(enabled) + manager._dhcp.start.assert_called_once() + + def test_connected_retries_after_ipv6_policy_failure(self): + self.manager._set_connecting("TestNet") + self.manager._dhcp.set_ipv6_enabled.side_effect = OSError("sysctl failed") + + self.manager._handle_connected("TestNet") + + assert self.manager._associated_ssid is None + assert self.manager.wifi_state == WifiState("TestNet", ConnectStatus.CONNECTING) + self.manager._dhcp.start.assert_not_called() + + def test_same_ssid_profile_switch_restarts_l3_state(self): + activated = MagicMock() + self.manager.add_callbacks(activated=activated) + first_uuid = str(uuid.uuid4()) + second_uuid = str(uuid.uuid4()) + self.manager._store.get_ipv6_method.side_effect = ["ignore", "auto"] + self.manager._set_connecting("TestNet") + + self.manager._handle_connected("TestNet", profile_uuid=first_uuid) + complete_station_connection(self.manager, "TestNet") + self.manager.process_callbacks() + activated.reset_mock() + self.manager._ipv4_address = "192.168.1.20" + self.manager._current_network_metered = MeteredType.YES + self.manager._dhcp.stop.reset_mock() + self.manager._dhcp.clear_ipv6_state.reset_mock() + self.manager._handle_connected("TestNet", profile_uuid=second_uuid) + + assert self.manager._store.get_ipv6_method.call_args_list == [ + call("TestNet", first_uuid), + call("TestNet", second_uuid), + ] + assert self.manager._dhcp.set_ipv6_enabled.call_args_list == [call(False), call(True)] + assert self.manager._station_operation is not None + assert self.manager._station_operation.profile_uuid == second_uuid + assert self.manager.wifi_state == WifiState("TestNet", ConnectStatus.CONNECTING) + assert self.manager.ipv4_address == "" + assert self.manager.current_network_metered == MeteredType.UNKNOWN + self.manager._dhcp.stop.assert_called_once() + self.manager._dhcp.clear_ipv6_state.assert_called_once() + assert self.manager._dhcp.start.call_count == 2 + assert self.manager._poll_for_ip.call_count == 2 + self.manager.process_callbacks() + activated.assert_not_called() + + self.manager._ipv4_address = "192.168.2.20" + self.manager._complete_station_connection("TestNet", self.manager._user_epoch) + self.manager.process_callbacks() + activated.assert_called_once() + + def test_connected_to_another_ssid_clears_stale_ipv6_state(self): + self.manager._wifi_state = WifiState("PreviousNet", ConnectStatus.CONNECTED) + self.manager._associated_ssid = "PreviousNet" + self.manager._associated_epoch = self.manager._user_epoch + self.manager._ctrl.request.return_value = "wpa_state=COMPLETED\nssid=TestNet\n" + + self.manager._handle_event("CTRL-EVENT-CONNECTED") + + self.manager._dhcp.clear_ipv6_state.assert_called_once() + self.manager._dhcp.start.assert_called_once() + assert self.manager._associated_ssid == "TestNet" + assert self.manager._associated_epoch == self.manager._user_epoch + + def test_connected_waits_for_metric_600_default_route(self): + activated = MagicMock() + self.manager.add_callbacks(activated=activated) + self.manager._set_connecting("TestNet") + epoch = self.manager._user_epoch + self.manager._associated_ssid = "TestNet" + self.manager._associated_epoch = epoch + self.manager._ipv4_address = "192.168.1.20" + self.manager._wifi_default_route_ready.return_value = False + + self.manager._complete_station_connection("TestNet", epoch) + self.manager.process_callbacks() + + assert self.manager.wifi_state == WifiState("TestNet", ConnectStatus.CONNECTING) + activated.assert_not_called() + + self.manager._wifi_default_route_ready.return_value = True + self.manager._complete_station_connection("TestNet", epoch) + self.manager.process_callbacks() + + assert self.manager.wifi_state == WifiState("TestNet", ConnectStatus.CONNECTED) + activated.assert_called_once() + + def test_ip_poll_continues_until_default_route_is_ready(self): + self.manager._set_connecting("TestNet") + epoch = self.manager._user_epoch + self.manager._associated_ssid = "TestNet" + self.manager._associated_epoch = epoch + self.manager._ipv4_address = "192.168.1.20" + self.manager._wifi_default_route_ready.side_effect = [False, True] + + with ( + patch.object(wifi_manager_module.threading, "Thread") as thread, + patch.object(wifi_manager_module.time, "sleep"), + ): + WifiManager._poll_for_ip(self.manager, "TestNet", epoch) + thread.call_args.kwargs["target"]() + + assert self.manager._update_active_connection_info.call_count == 2 + assert self.manager.wifi_state == WifiState("TestNet", ConnectStatus.CONNECTED) + + def test_wifi_default_route_ready_requires_one_metric_600_gateway(self): + cases = ( + ("default via 192.168.1.1 dev wlan0 metric 600\n", True), + ("default via 192.168.1.1 metric 600\n", True), + ("", False), + ("default via 192.168.1.1 dev wlan0 metric 0\n", False), + ("default dev wlan0 metric 600\n", False), + ( + "default via 192.168.1.1 dev wlan0 metric 600\ndefault via 192.168.1.2 dev wlan0 metric 600\n", + False, + ), + ) + for output, expected in cases: + with self.subTest(output=output): + result = MagicMock(returncode=0, stdout=output) + with patch.object(wifi_manager_module.subprocess, "run", return_value=result) as run: + assert WifiManager._wifi_default_route_ready(self.manager) is expected + run.assert_called_once_with( + ["ip", "-4", "route", "show", "default", "dev", "wlan0"], + capture_output=True, + check=False, + text=True, + timeout=2, + ) + + + def test_connected_transitions_are_serialized(self): + self.manager._set_connecting("TestNet") + self.manager._set_pending_connection("TestNet", "password123", False, SecurityType.WPA) + first_persist_started = threading.Event() + concurrent_persist_started = threading.Event() + release_persist = threading.Event() + active_persists = 0 + active_persists_lock = threading.Lock() + + def persist(_ssid): + nonlocal active_persists + with active_persists_lock: + active_persists += 1 + if active_persists == 1: + first_persist_started.set() + else: + concurrent_persist_started.set() + assert release_persist.wait(1) + with active_persists_lock: + active_persists -= 1 + + with patch.object(self.manager, "_persist_pending_connection", side_effect=persist): + first = threading.Thread(target=self.manager._handle_connected, args=("TestNet",)) + second = threading.Thread(target=self.manager._handle_connected, args=("TestNet",)) + first.start() + assert first_persist_started.wait(1) + second.start() + try: + assert not concurrent_persist_started.wait(0.1) + finally: + release_persist.set() + first.join(1) + second.join(1) + + assert not first.is_alive() + assert not second.is_alive() + + def test_connect_tap_does_not_wait_for_connected_transition(self): + self.manager._set_connecting("CurrentNet") + self.manager._set_pending_connection("CurrentNet", "current-password", False, SecurityType.WPA) + current_epoch = self.manager._user_epoch + persist_started = threading.Event() + release_persist = threading.Event() + connect_returned = threading.Event() + + def save_network(*_args, **_kwargs): + persist_started.set() + assert release_persist.wait(1) + + self.manager._store.save_network.side_effect = save_network + connected = threading.Thread(target=self.manager._handle_connected, args=("CurrentNet",), kwargs={"expected_epoch": current_epoch}) + connected.start() + assert persist_started.wait(1) + + def connect(): + self.manager.connect_to_network("NextNet", "next-password") + connect_returned.set() + + thread_class = threading.Thread + connector = thread_class(target=connect) + with patch.object(wifi_manager_module.threading, "Thread"): + connector.start() + returned_during_persist = connect_returned.wait(0.1) + release_persist.set() + connector.join(1) + connected.join(1) + + assert returned_during_persist + assert not connector.is_alive() + assert not connected.is_alive() + assert self.manager.wifi_state == WifiState("NextNet", ConnectStatus.CONNECTING) + assert self.manager._pending_connection is not None + assert self.manager._pending_connection.ssid == "NextNet" + self.manager._dhcp.start.assert_called_once() + + def test_connect_cleans_dhcp_when_superseding_an_association(self): + self.manager._wifi_state = WifiState("CurrentNet", ConnectStatus.CONNECTING) + self.manager._associated_ssid = "CurrentNet" + self.manager._associated_epoch = self.manager._user_epoch + + with ( + patch.object(wifi_manager_module.threading, "Thread") as thread, + patch.object(self.manager, "_list_network_ids", return_value=[]), + patch.object(self.manager, "_add_and_select_network", return_value="1"), + ): + self.manager.connect_to_network("NextNet", "next-password") + thread.call_args.kwargs["target"]() + + self.manager._dhcp.stop.assert_called_once() + + def test_activate_cleans_dhcp_when_superseding_an_association(self): + self.manager._wifi_state = WifiState("CurrentNet", ConnectStatus.CONNECTING) + self.manager._associated_ssid = "CurrentNet" + self.manager._associated_epoch = self.manager._user_epoch + + with ( + patch.object(self.manager, "_list_network_ids", return_value=["1"]), + patch.object(self.manager, "_select_network_ids"), + ): + self.manager.activate_connection("NextNet", block=True) + + self.manager._dhcp.stop.assert_called_once() + + def test_pending_persistence_is_retried_without_restarting_dhcp(self): + for retry in ("connected", "reconcile"): + with self.subTest(retry=retry): + manager = build_wifi_manager() + manager._set_connecting("TestNet") + manager._set_pending_connection("TestNet", "password123", False, SecurityType.WPA) + manager._store.save_network.side_effect = [OSError("read-only"), None] + + with patch.object(wifi_manager_module, "generate_wpa_conf"): + manager._handle_connected("TestNet") + if retry == "connected": + manager._handle_connected("TestNet") + else: + manager._last_connecting_at = time.monotonic() - CONNECTING_STALE_TIMEOUT_SECONDS - 1 + manager._reconcile_connecting_state() + + assert manager._store.save_network.call_count == 2 + assert manager._pending_connection is None + manager._dhcp.start.assert_called_once() + + def test_reconcile_completed_association_does_not_spawn_more_ip_pollers(self): + self.manager._set_connecting("TestNet") + self.manager._handle_connected("TestNet") + self.manager._last_connecting_at = time.monotonic() - CONNECTING_STALE_TIMEOUT_SECONDS - 1 + + for _ in range(3): + self.manager._reconcile_connecting_state() + + self.manager._dhcp.start.assert_called_once() + self.manager._poll_for_ip.assert_called_once() + + def test_disconnected_event_defers_station_cleanup(self): + self.manager._wifi_state = WifiState("TestNet", ConnectStatus.CONNECTED) + self.manager._ipv4_address = "192.168.1.20" + self.manager._current_network_metered = MeteredType.YES + + self.manager._handle_event("CTRL-EVENT-DISCONNECTED reason=3") + + assert self.manager.wifi_state == WifiState("TestNet", ConnectStatus.CONNECTING) + assert self.manager.ipv4_address == "192.168.1.20" + assert self.manager.current_network_metered == MeteredType.YES + self.manager._dhcp.stop.assert_not_called() + + def test_reconnect_after_disconnected_event_adopts_dhcp(self): + self.manager._wifi_state = WifiState("TestNet", ConnectStatus.CONNECTED) + self.manager._ipv4_address = "192.168.1.20" + self.manager._dhcp.adopt.return_value = True + + self.manager._handle_event("CTRL-EVENT-DISCONNECTED reason=3") + self.manager._ctrl.request.return_value = "wpa_state=COMPLETED\nssid=TestNet\n" + self.manager._handle_event("CTRL-EVENT-CONNECTED") + + complete_station_connection(self.manager, "TestNet") + assert self.manager.wifi_state == WifiState("TestNet", ConnectStatus.CONNECTED) + assert self.manager.ipv4_address == "192.168.1.20" + self.manager._dhcp.adopt.assert_called_once() + self.manager._dhcp.stop.assert_not_called() + self.manager._dhcp.start.assert_not_called() + + def test_disconnected_event_allows_fallback_to_different_saved_network(self): + self.manager._wifi_state = WifiState("TestNet", ConnectStatus.CONNECTED) + + self.manager._handle_event("CTRL-EVENT-DISCONNECTED reason=3") + self.manager._ctrl.request.return_value = "wpa_state=COMPLETED\nssid=NextNet\n" + self.manager._handle_event("CTRL-EVENT-CONNECTED") + + complete_station_connection(self.manager, "NextNet") + assert self.manager.wifi_state == WifiState("NextNet", ConnectStatus.CONNECTED) + self.manager._dhcp.adopt.assert_not_called() + self.manager._dhcp.clear_ipv6_state.assert_called_once() + self.manager._dhcp.start.assert_called_once() + + def test_disconnected_event_cleans_station_after_timeout(self): + self.manager._wifi_state = WifiState("TestNet", ConnectStatus.CONNECTED) + self.manager._ipv4_address = "192.168.1.20" + self.manager._current_network_metered = MeteredType.YES + + self.manager._handle_event("CTRL-EVENT-DISCONNECTED reason=3") + self.manager._last_connecting_at = time.monotonic() - CONNECTING_STALE_TIMEOUT_SECONDS - 1 + self.manager._ctrl.request.return_value = "wpa_state=DISCONNECTED\n" + self.manager._reconcile_connecting_state() + + assert self.manager.wifi_state == WifiState() + assert self.manager.ipv4_address == "" + assert self.manager.current_network_metered == MeteredType.UNKNOWN + self.manager._dhcp.stop.assert_called_once() + self.manager._dhcp.clear_ipv6_state.assert_called_once() + + def test_missed_disconnect_clears_association_before_reconnect(self): + self.manager._set_connecting("TestNet") + self.manager._handle_connected("TestNet") + complete_station_connection(self.manager, "TestNet") + self.manager._dhcp.start.reset_mock() + + self.manager._last_connected_recheck = 0.0 + self.manager._ctrl.request.return_value = "wpa_state=DISCONNECTED\n" + self.manager._reconcile_connecting_state() + + self.assertIsNone(self.manager._associated_ssid) + self.assertIsNone(self.manager._associated_epoch) + + self.manager._last_connected_recheck = 0.0 + self.manager._ctrl.request.return_value = "wpa_state=COMPLETED\nssid=TestNet\n" + self.manager._reconcile_connecting_state() + + self.manager._dhcp.start.assert_called_once() + + def test_connected_reconciliation_rechecks_status_before_cleanup(self): + self.manager._set_connecting("TestNet") + self.manager._handle_connected("TestNet") + complete_station_connection(self.manager, "TestNet") + self.manager._last_connected_recheck = 0.0 + self.manager._dhcp.stop.reset_mock() + self.manager._dhcp.clear_ipv6_state.reset_mock() + self.manager._request = MagicMock(return_value="wpa_state=DISCONNECTED\n") + + self.manager._reconcile_connecting_state() + + assert self.manager._request.call_args_list == [call("STATUS"), call("STATUS")] + assert self.manager.wifi_state == WifiState() + self.manager._dhcp.stop.assert_called_once() + self.manager._dhcp.clear_ipv6_state.assert_called_once() + + def test_connected_reconciliation_does_not_clear_recovered_station(self): + self.manager._set_connecting("TestNet") + self.manager._handle_connected("TestNet") + complete_station_connection(self.manager, "TestNet") + epoch = self.manager._user_epoch + self.manager._last_connected_recheck = 0.0 + self.manager._dhcp.stop.reset_mock() + self.manager._dhcp.clear_ipv6_state.reset_mock() + status_started = threading.Event() + release_status = threading.Event() + status_requests = 0 + + def request(command): + nonlocal status_requests + if command != "STATUS": + return "OK" + status_requests += 1 + if status_requests == 1: + status_started.set() + assert release_status.wait(1) + return "wpa_state=DISCONNECTED\n" + return "wpa_state=COMPLETED\nssid=TestNet\n" + + self.manager._request = MagicMock(side_effect=request) + worker = threading.Thread(target=self.manager._reconcile_connecting_state) + worker.start() + assert status_started.wait(1) + + self.manager._handle_connected("TestNet", expected_epoch=epoch) + release_status.set() + worker.join(1) + + assert not worker.is_alive() + assert status_requests == 2 + assert self.manager.wifi_state == WifiState("TestNet", ConnectStatus.CONNECTED) + assert self.manager._associated_ssid == "TestNet" + self.manager._dhcp.stop.assert_not_called() + self.manager._dhcp.clear_ipv6_state.assert_not_called() + + def test_disconnected_event_does_not_override_user_connection(self): + self.manager._set_connecting("NextNet") + + self.manager._handle_event("CTRL-EVENT-DISCONNECTED reason=3") + + assert self.manager.wifi_state == WifiState("NextNet", ConnectStatus.CONNECTING) + self.manager._dhcp.stop.assert_not_called() + + def test_disconnected_event_rechecks_epoch_before_committing(self): + self.manager._wifi_state = WifiState("PreviousNet", ConnectStatus.CONNECTED) + self.manager._associated_ssid = "PreviousNet" + self.manager._associated_epoch = self.manager._user_epoch + commit_started = threading.Event() + release_commit = threading.Event() + original_monotonic = time.monotonic + worker = None + + def monotonic(): + if threading.current_thread() is worker: + commit_started.set() + assert release_commit.wait(1) + return original_monotonic() + + with patch.object(wifi_manager_module.time, "monotonic", side_effect=monotonic): + worker = threading.Thread(target=self.manager._handle_event, args=("CTRL-EVENT-DISCONNECTED reason=3",)) + worker.start() + assert commit_started.wait(1) + + with patch.object(wifi_manager_module.threading.Thread, "start"): + self.manager.connect_to_network("NextNet", "next-password") + + release_commit.set() + worker.join(1) + + assert not worker.is_alive() + assert self.manager.wifi_state == WifiState("NextNet", ConnectStatus.CONNECTING) + assert self.manager._pending_connection is not None + assert self.manager._pending_connection.ssid == "NextNet" + assert self.manager._dhcp_adoption_ssid is None + self.manager._dhcp.stop.assert_not_called() + + def test_association_progress_rechecks_epoch_before_committing(self): + self.manager._ctrl.request.return_value = "wpa_state=ASSOCIATING\nssid=PreviousNet\n" + commit_started = threading.Event() + release_commit = threading.Event() + original_monotonic = time.monotonic + worker = None + + def monotonic(): + if threading.current_thread() is worker: + commit_started.set() + assert release_commit.wait(1) + return original_monotonic() + + with patch.object(wifi_manager_module.time, "monotonic", side_effect=monotonic): + worker = threading.Thread(target=self.manager._handle_event, args=("Trying to associate with 00:11:22:33:44:55",)) + worker.start() + assert commit_started.wait(1) + + with patch.object(wifi_manager_module.threading.Thread, "start"): + self.manager.connect_to_network("NextNet", "next-password") + + release_commit.set() + worker.join(1) + + assert not worker.is_alive() + assert self.manager.wifi_state == WifiState("NextNet", ConnectStatus.CONNECTING) + assert self.manager._pending_connection is not None + assert self.manager._pending_connection.ssid == "NextNet" + self.manager._dhcp.stop.assert_not_called() + + def test_connected_event_rejects_unconfirmed_or_previous_network(self): + cases = ( + ("wpa_state=ASSOCIATING\nssid=NextNet\n", "wrong-password"), + ("wpa_state=COMPLETED\nssid=PreviousNet\n", "password123"), + ) + for status, password in cases: + with self.subTest(status=status): + manager = build_wifi_manager() + manager._set_connecting("NextNet") + manager._set_pending_connection("NextNet", password, False, SecurityType.WPA) + manager._ctrl.request.return_value = status + + manager._handle_event("CTRL-EVENT-CONNECTED") + + assert manager.wifi_state == WifiState("NextNet", ConnectStatus.CONNECTING) + assert manager._pending_connection is not None + manager._store.save_network.assert_not_called() + manager._dhcp.start.assert_not_called() + + def test_connected_event_rechecks_epoch_inside_transition_lock(self): + self.manager._set_connecting("FirstNet") + self.manager._ctrl.request.return_value = "wpa_state=COMPLETED\nssid=FirstNet\n" + lock = threading.Lock() + waiting_for_lock = threading.Event() + lock.acquire() + + class SignalingLock: + def __enter__(self): + waiting_for_lock.set() + lock.acquire() + + def __exit__(self, *_): + lock.release() + + self.manager.__dict__["_radio_lock"] = SignalingLock() + worker = threading.Thread(target=self.manager._handle_event, args=("CTRL-EVENT-CONNECTED",)) + worker.start() + assert waiting_for_lock.wait(1) + + self.manager._set_connecting("NextNet") + lock.release() + worker.join(1) + + assert not worker.is_alive() + assert self.manager.wifi_state == WifiState("NextNet", ConnectStatus.CONNECTING) + self.manager._dhcp.start.assert_not_called() + + def test_activate_enables_every_profile_sharing_ssid(self): + self.manager._ctrl.request.return_value = "OK" + + with patch.object(self.manager, "_list_network_ids", return_value=["1", "2"]): + self.manager.activate_connection("Pinned", block=True) + + assert self.manager._ctrl.request.call_args_list == [ + call("DISABLE_NETWORK all"), + call("ENABLE_NETWORK 1"), + call("ENABLE_NETWORK 2"), + call("REASSOCIATE"), + ] + + def test_activate_restores_every_saved_profile(self): + profiles = ( + {"psk": "first-password", "hidden": False, "priority": 1, "bssid": "00:11:22:33:44:55", "uuid": "first-uuid", "security": SecurityType.WPA}, + {"psk": "second-password", "hidden": True, "priority": 2, "bssid": "66:77:88:99:aa:bb", "uuid": "second-uuid", "security": SecurityType.WPA}, + ) + self.manager._store.get_profiles.return_value = [("Pinned", profile) for profile in profiles] + + with ( + patch.object(self.manager, "_list_network_ids", return_value=[]), + patch.object(self.manager, "_add_and_select_network", side_effect=["1", "2"]) as add_and_select_network, + patch.object(self.manager, "_select_network_ids") as select_network_ids, + ): + self.manager.activate_connection("Pinned", block=True) + + assert add_and_select_network.call_args_list == [ + call("Pinned", "first-password", False, 1, bssid="00:11:22:33:44:55", profile_uuid="first-uuid", security=SecurityType.WPA), + call("Pinned", "second-password", True, 2, bssid="66:77:88:99:aa:bb", profile_uuid="second-uuid", security=SecurityType.WPA), + ] + select_network_ids.assert_called_once_with(["1", "2"]) + + def test_metered_worker_updates_requested_network_only(self): + self.manager._wifi_state = WifiState("FirstNet", ConnectStatus.CONNECTED) + self.manager._current_network_metered = MeteredType.NO + + with patch.object(wifi_manager_module.threading, "Thread") as thread: + self.manager.set_current_network_metered(MeteredType.YES) + + self.manager._wifi_state = WifiState("NextNet", ConnectStatus.CONNECTED) + thread.call_args.kwargs["target"]() + + self.manager._store.set_metered.assert_called_once_with("FirstNet", int(MeteredType.YES)) + assert self.manager.current_network_metered == MeteredType.NO + + def test_metered_worker_reports_persistence_failure(self): + self.manager._wifi_state = WifiState("TestNet", ConnectStatus.CONNECTED) + self.manager._current_network_metered = MeteredType.NO + self.manager._store.set_metered.side_effect = OSError("read-only") + + with ( + patch.object(wifi_manager_module.threading, "Thread") as thread, + patch.object(wifi_manager_module.cloudlog, "exception") as exception, + ): + self.manager.set_current_network_metered(MeteredType.YES) + thread.call_args.kwargs["target"]() + + exception.assert_called_once_with("Failed to update metered state for TestNet") + assert self.manager.current_network_metered == MeteredType.NO + + def test_active_profile_sets_metered_state(self): + self.manager._wifi_state = WifiState("Duplicate", ConnectStatus.CONNECTED) + self.manager._ctrl.request.return_value = "wpa_state=COMPLETED\nssid=Duplicate\nid_str=second-uuid\nip_address=192.168.1.20\n" + self.manager._store.get_metered.return_value = MeteredType.NO + + WifiManager._update_active_connection_info(self.manager) + + self.manager._store.get_metered.assert_called_once_with("Duplicate", "second-uuid") + assert self.manager.current_network_metered == MeteredType.NO + + def test_active_info_does_not_publish_after_station_teardown(self): + self.manager._set_connecting("TestNet") + epoch = self.manager._user_epoch + self.manager._associated_ssid = "TestNet" + self.manager._associated_epoch = epoch + self.manager._wifi_state = WifiState("TestNet", ConnectStatus.CONNECTED) + self.manager._ctrl.request.return_value = ( + "wpa_state=COMPLETED\nssid=TestNet\nid_str=test-uuid\nip_address=192.168.1.20\n" + ) -# --------------------------------------------------------------------------- -# Thread races: _set_connecting on main thread vs _handle_state_change on monitor thread. -# Uses side_effect on the DBus mock to simulate _set_connecting running mid-handler. -# The epoch counter detects that a user action occurred during the slow DBus call -# and discards the stale update. -# --------------------------------------------------------------------------- -# The deterministic fixes (skip DBus lookup when ssid already set, prev_state guard -# on NEED_AUTH, DEACTIVATING clears CONNECTED on CONNECTION_REMOVED, CONNECTION_REMOVED -# guard) shrink these race windows significantly. The epoch counter closes the -# remaining gaps. + def get_metered(*_): + self.manager._clear_station_state() + return MeteredType.YES + + self.manager._store.get_metered.side_effect = get_metered + WifiManager._update_active_connection_info(self.manager) + + assert self.manager.ipv4_address == "" + assert self.manager.current_network_metered == MeteredType.UNKNOWN + + def test_activate_restores_saved_profile_constraints(self): + cases = ( + ("Preferred", {"psk": "password123", "hidden": False, "priority": 42, "uuid": "preferred-uuid", "security": SecurityType.WPA}, 42, None), + ( + "Pinned", + {"psk": "password123", "hidden": False, "bssid": "00:11:22:33:44:55", "uuid": "pinned-uuid", "security": SecurityType.WPA}, + 0, + "00:11:22:33:44:55", + ), + ) + for ssid, profile, priority, bssid in cases: + with self.subTest(ssid=ssid): + manager = build_wifi_manager() + manager._store.get_profiles.return_value = [(ssid, profile)] + + with ( + patch.object(manager, "_list_network_ids", return_value=[]), + patch.object(manager, "_add_and_select_network") as add_and_select_network, + ): + manager.activate_connection(ssid, block=True) + + add_and_select_network.assert_called_once_with( + ssid, "password123", False, priority, bssid=bssid, profile_uuid=profile["uuid"], security=SecurityType.WPA, + ) + + def test_connection_changes_defer_dhcp_cleanup_to_worker(self): + for action in ("connect", "activate"): + with self.subTest(action=action): + manager = build_wifi_manager() + manager._wifi_state = WifiState("CurrentNet", ConnectStatus.CONNECTED) + with ( + patch.object(wifi_manager_module.threading, "Thread") as thread, + patch.object(manager, "_list_network_ids", return_value=["1"] if action == "activate" else []), + patch.object(manager, "_add_and_select_network"), + patch.object(manager, "_select_network_ids"), + ): + if action == "connect": + manager.connect_to_network("NextNet", "password123") + else: + manager.activate_connection("NextNet") + manager._dhcp.stop.assert_not_called() + thread.call_args.kwargs["target"]() + + manager._dhcp.stop.assert_called_once() + manager._dhcp.clear_ipv6_state.assert_called_once() + + def test_latest_connect_worker_owns_deferred_dhcp_cleanup(self): + self.manager._wifi_state = WifiState("CurrentNet", ConnectStatus.CONNECTED) + events = [] + self.manager._dhcp.stop.side_effect = lambda: events.append("cleanup") + + with ( + patch.object(wifi_manager_module.threading, "Thread") as thread, + patch.object(self.manager, "_list_network_ids", return_value=[]), + patch.object(self.manager, "_add_and_select_network", side_effect=lambda *_, **__: events.append("select") or "1"), + ): + self.manager.connect_to_network("FirstNet", "password123") + first_worker = thread.call_args.kwargs["target"] + self.manager.connect_to_network("NextNet", "password123") + second_worker = thread.call_args.kwargs["target"] + + second_worker() + first_worker() + + assert events == ["cleanup", "select"] + + def test_superseded_after_select_removes_exact_runtime_network(self): + first_selected = threading.Event() + release_first = threading.Event() + second_committed = threading.Event() + real_set_pending_network_id = self.manager._set_pending_network_id + + def add_network(ssid, *_, **__): + if ssid == "FirstNet": + first_selected.set() + assert release_first.wait(1) + return "1" + return "2" + + def set_pending_network_id(net_id, epoch): + real_set_pending_network_id(net_id, epoch) + if net_id == "2": + second_committed.set() + + self.manager._ctrl.request.return_value = "OK" + with ( + patch.object(self.manager, "_list_network_ids", return_value=[]), + patch.object(self.manager, "_add_and_select_network", side_effect=add_network), + patch.object(self.manager, "_set_pending_network_id", side_effect=set_pending_network_id), + patch.object(wifi_manager_module, "generate_wpa_conf"), + ): + self.manager.connect_to_network("FirstNet", "first-password") + assert first_selected.wait(1) + self.manager.connect_to_network("SecondNet", "second-password") + release_first.set() + assert second_committed.wait(1) + + assert call("REMOVE_NETWORK 1") in self.manager._ctrl.request.call_args_list + assert self.manager._pending_connection is not None + assert self.manager._pending_connection.ssid == "SecondNet" + assert self.manager._pending_connection.network_id == "2" + + def test_runtime_network_encodes_control_characters_in_ssid(self): + self.manager._ctrl.request.side_effect = ["0", "OK", "OK", "OK", "OK"] + + self.manager._add_and_select_network("Line\nBreak\r") + + ssid_hex = b"Line\nBreak\r".hex() + assert call(f"SET_NETWORK 0 ssid {ssid_hex}") in self.manager._ctrl.request.call_args_list + + def test_runtime_network_sets_saved_profile_identifier(self): + self.manager._ctrl.request.side_effect = ["0", "OK", "OK", "OK", "OK", "OK"] + + self.manager._add_and_select_network("TestNet", profile_uuid="profile-uuid") + + assert call('SET_NETWORK 0 id_str "profile-uuid"') in self.manager._ctrl.request.call_args_list + + def test_runtime_open_network_ignores_stale_psk(self): + self.manager._ctrl.request.side_effect = ["0", "OK", "OK", "OK", "OK"] + + self.manager._add_and_select_network( + "TestNet", psk="stale-password", security=SecurityType.OPEN, + ) -class TestThreadRaces(OpenpilotTestCase): - def test_prepare_race_user_tap_during_dbus(self, mocker): - """User taps B while PREPARE's DBus call is in flight for auto-connect. + requests = self.manager._ctrl.request.call_args_list + assert call("SET_NETWORK 0 key_mgmt NONE") in requests + assert not any(" psk " in item.args[0] for item in requests) - Monitor thread reads wifi_state (ssid=None), starts DBus call. - Main thread: _set_connecting("B"). Monitor thread writes back stale ssid from DBus. - """ - wm = _make_wm(mocker, connections={"A": "/path/A", "B": "/path/B"}) - - def user_taps_b_during_dbus(*args, **kwargs): - wm._set_connecting("B") - return ("/path/A", {}) - - wm._get_active_wifi_connection.side_effect = user_taps_b_during_dbus - - fire(wm, NMDeviceState.PREPARE) - - assert wm._wifi_state.ssid == "B" - assert wm._wifi_state.status == ConnectStatus.CONNECTING - - def test_activated_race_user_tap_during_dbus(self, mocker): - """User taps B right as A finishes connecting (ACTIVATED handler running). - - Monitor thread reads wifi_state (A, CONNECTING), starts DBus call. - Main thread: _set_connecting("B"). Monitor thread writes (A, CONNECTED), losing B. - """ - wm = _make_wm(mocker, connections={"A": "/path/A", "B": "/path/B"}) - wm._set_connecting("A") - - def user_taps_b_during_dbus(*args, **kwargs): - wm._set_connecting("B") - return ("/path/A", {}) - - wm._get_active_wifi_connection.side_effect = user_taps_b_during_dbus - - fire(wm, NMDeviceState.ACTIVATED) - - assert wm._wifi_state.ssid == "B" - assert wm._wifi_state.status == ConnectStatus.CONNECTING - - def test_init_wifi_state_race_user_tap_during_dbus(self, mocker): - """User taps B while _init_wifi_state's DBus calls are in flight. - - _init_wifi_state runs from set_active(True) or worker error paths. It does - 2 DBus calls (device State property + _get_active_wifi_connection) then - unconditionally writes _wifi_state. If the user taps a network during those - calls, _set_connecting("B") is overwritten with stale NM ground truth. - """ - wm = _make_wm(mocker, connections={"A": "/path/A", "B": "/path/B"}) - wm._wifi_device = "/dev/wifi0" - wm._router_main = mocker.MagicMock() - - state_reply = mocker.MagicMock() - state_reply.body = [('u', NMDeviceState.ACTIVATED)] - wm._router_main.send_and_get_reply.return_value = state_reply - - def user_taps_b_during_dbus(*args, **kwargs): - wm._set_connecting("B") - return ("/path/A", {}) - - wm._get_active_wifi_connection.side_effect = user_taps_b_during_dbus - - wm._init_wifi_state() - - assert wm._wifi_state.ssid == "B" - assert wm._wifi_state.status == ConnectStatus.CONNECTING - - -# --------------------------------------------------------------------------- -# Full sequences (NM signal order from real devices) -# --------------------------------------------------------------------------- - -class TestFullSequences(OpenpilotTestCase): - def test_normal_connect(self, mocker): - """User connects to saved network: full happy path. - - Real device sequence (switching from another connected network): - DEACTIVATING(ACTIVATED, NEW_ACTIVATION) → DISCONNECTED(DEACTIVATING, NEW_ACTIVATION) - PREPARE → CONFIG → NEED_AUTH(CONFIG, NONE) → PREPARE(NEED_AUTH, NONE) → CONFIG - → IP_CONFIG → IP_CHECK → SECONDARIES → ACTIVATED - """ - wm = _make_wm(mocker, connections={"Home": "/path/home"}) - wm._get_active_wifi_connection.return_value = ("/path/home", {}) - - wm._set_connecting("Home") - fire(wm, NMDeviceState.PREPARE) - fire(wm, NMDeviceState.CONFIG) - fire(wm, NMDeviceState.NEED_AUTH) # WPA handshake (reason=NONE) - fire(wm, NMDeviceState.PREPARE, prev_state=NMDeviceState.NEED_AUTH) - fire(wm, NMDeviceState.CONFIG) - assert wm._wifi_state.status == ConnectStatus.CONNECTING - - fire(wm, NMDeviceState.IP_CONFIG) - fire(wm, NMDeviceState.IP_CHECK) - fire(wm, NMDeviceState.SECONDARIES) - fire(wm, NMDeviceState.ACTIVATED) - - assert wm._wifi_state.status == ConnectStatus.CONNECTED - assert wm._wifi_state.ssid == "Home" - - def test_wrong_password_then_retry(self, mocker): - """Wrong password → NEED_AUTH → FAILED → NM auto-reconnects to saved network. - - Confirmed on device: wrong password for Shane's iPhone, NM auto-connected to unifi. - - Real device sequence (switching from a connected network): - DEACTIVATING(ACTIVATED, NEW_ACTIVATION) → DISCONNECTED(DEACTIVATING, NEW_ACTIVATION) - → PREPARE → CONFIG → NEED_AUTH(CONFIG, NONE) ← WPA handshake - → PREPARE(NEED_AUTH, NONE) → CONFIG - → NEED_AUTH(CONFIG, SUPPLICANT_DISCONNECT) ← wrong password - → FAILED(NEED_AUTH, NO_SECRETS) ← NM gives up - → DISCONNECTED(FAILED, NONE) - → PREPARE → CONFIG → NEED_AUTH(CONFIG, NONE) → PREPARE(NEED_AUTH) → CONFIG - → IP_CONFIG → IP_CHECK → SECONDARIES → ACTIVATED ← auto-reconnect to other saved network - """ - wm = _make_wm(mocker, connections={"Sec": "/path/sec"}) - cb = mocker.MagicMock() - wm.add_callbacks(need_auth=cb) - - wm._set_connecting("Sec") - fire(wm, NMDeviceState.PREPARE) - fire(wm, NMDeviceState.CONFIG) - fire(wm, NMDeviceState.NEED_AUTH) # WPA handshake (reason=NONE) - fire(wm, NMDeviceState.PREPARE, prev_state=NMDeviceState.NEED_AUTH) - fire(wm, NMDeviceState.CONFIG) - - fire(wm, NMDeviceState.NEED_AUTH, prev_state=NMDeviceState.CONFIG, - reason=NMDeviceStateReason.SUPPLICANT_DISCONNECT) - assert wm._wifi_state.status == ConnectStatus.DISCONNECTED - assert len(wm._callback_queue) == 1 - - # FAILED(NO_SECRETS) follows but ssid is already cleared — no double-fire - fire(wm, NMDeviceState.FAILED, reason=NMDeviceStateReason.NO_SECRETS) - assert len(wm._callback_queue) == 1 - - fire(wm, NMDeviceState.DISCONNECTED, prev_state=NMDeviceState.FAILED) - - # Retry - wm._callback_queue.clear() - wm._set_connecting("Sec") - wm._get_active_wifi_connection.return_value = ("/path/sec", {}) - fire(wm, NMDeviceState.PREPARE) - fire(wm, NMDeviceState.CONFIG) - fire_wpa_connect(wm) - assert wm._wifi_state.status == ConnectStatus.CONNECTED - - def test_switch_saved_networks(self, mocker): - """Switch from A to B (both saved): NM signal sequence from real device. - - Real device sequence: - DEACTIVATING(ACTIVATED, NEW_ACTIVATION) → DISCONNECTED(DEACTIVATING, NEW_ACTIVATION) - → PREPARE → CONFIG → NEED_AUTH(CONFIG, NONE) → PREPARE(NEED_AUTH, NONE) → CONFIG - → IP_CONFIG → IP_CHECK → SECONDARIES → ACTIVATED - """ - wm = _make_wm(mocker, connections={"A": "/path/A", "B": "/path/B"}) - wm._wifi_state = WifiState(ssid="A", status=ConnectStatus.CONNECTED) - wm._get_active_wifi_connection.return_value = ("/path/B", {}) - - wm._set_connecting("B") - - fire(wm, NMDeviceState.DEACTIVATING, prev_state=NMDeviceState.ACTIVATED, - reason=NMDeviceStateReason.NEW_ACTIVATION) - fire(wm, NMDeviceState.DISCONNECTED, prev_state=NMDeviceState.DEACTIVATING, - reason=NMDeviceStateReason.NEW_ACTIVATION) - assert wm._wifi_state.ssid == "B" - assert wm._wifi_state.status == ConnectStatus.CONNECTING - - fire(wm, NMDeviceState.PREPARE) - fire(wm, NMDeviceState.CONFIG) - fire_wpa_connect(wm) - assert wm._wifi_state.status == ConnectStatus.CONNECTED - assert wm._wifi_state.ssid == "B" - - def test_rapid_switch_no_false_wrong_password(self, mocker): - """Switch A→B quickly: A's interrupted NEED_AUTH must NOT show wrong password. - - NOTE: The late NEED_AUTH(DISCONNECTED, SUPPLICANT_DISCONNECT) is common when rapidly - switching between networks with wrong/new passwords. Less common when switching between - saved networks with correct passwords. Not guaranteed — some switches skip it and go - straight from DISCONNECTED to PREPARE. The prev_state is consistently DISCONNECTED - for stale signals, so the prev_state guard reliably distinguishes them. - - Worst-case signal sequence this protects against: - DEACTIVATING(NEW_ACTIVATION) → DISCONNECTED(NEW_ACTIVATION) - → NEED_AUTH(DISCONNECTED, SUPPLICANT_DISCONNECT) ← A's stale auth failure - → PREPARE → CONFIG → ... → ACTIVATED ← B connects - """ - wm = _make_wm(mocker, connections={"A": "/path/A", "B": "/path/B"}) - cb = mocker.MagicMock() - wm.add_callbacks(need_auth=cb) - wm._wifi_state = WifiState(ssid="A", status=ConnectStatus.CONNECTED) - wm._get_active_wifi_connection.return_value = ("/path/B", {}) - - wm._set_connecting("B") - - fire(wm, NMDeviceState.DEACTIVATING, prev_state=NMDeviceState.ACTIVATED, - reason=NMDeviceStateReason.NEW_ACTIVATION) - fire(wm, NMDeviceState.DISCONNECTED, prev_state=NMDeviceState.DEACTIVATING, - reason=NMDeviceStateReason.NEW_ACTIVATION) - fire(wm, NMDeviceState.NEED_AUTH, prev_state=NMDeviceState.DISCONNECTED, - reason=NMDeviceStateReason.SUPPLICANT_DISCONNECT) - - assert wm._wifi_state.ssid == "B" - assert wm._wifi_state.status == ConnectStatus.CONNECTING - assert len(wm._callback_queue) == 0 - - fire(wm, NMDeviceState.PREPARE) - fire(wm, NMDeviceState.CONFIG) - fire_wpa_connect(wm) - assert wm._wifi_state.status == ConnectStatus.CONNECTED - - def test_forget_while_connecting(self, mocker): - """Forget the network we're currently connecting to (not yet ACTIVATED). - - Confirmed on device: connected to unifi, tapped Shane's iPhone, then forgot - Shane's iPhone while at CONFIG. NM auto-connected to unifi afterward. - - Real device sequence (switching then forgetting mid-connection): - DEACTIVATING(ACTIVATED, NEW_ACTIVATION) → DISCONNECTED(DEACTIVATING, NEW_ACTIVATION) - → PREPARE → CONFIG → NEED_AUTH(CONFIG, NONE) → PREPARE(NEED_AUTH) → CONFIG - → DEACTIVATING(CONFIG, CONNECTION_REMOVED) ← forget at CONFIG - → DISCONNECTED(DEACTIVATING, CONNECTION_REMOVED) - → PREPARE → CONFIG → ... → ACTIVATED ← NM auto-connects to other saved network - - Note: DEACTIVATING fires from CONFIG (not ACTIVATED). wifi_state.status is - CONNECTING, so the DEACTIVATING handler is a no-op. DISCONNECTED clears state - (ssid removed from _connections by ConnectionRemoved), then PREPARE recovers - via DBus lookup for the auto-connect. - """ - wm = _make_wm(mocker, connections={"A": "/path/A", "Other": "/path/other"}) - wm._get_active_wifi_connection.return_value = ("/path/other", {}) - - wm._set_connecting("A") - - fire(wm, NMDeviceState.PREPARE) - fire(wm, NMDeviceState.CONFIG) - assert wm._wifi_state.ssid == "A" - assert wm._wifi_state.status == ConnectStatus.CONNECTING - - # User forgets A: ConnectionRemoved processed first, then state changes - del wm._connections["A"] - - fire(wm, NMDeviceState.DEACTIVATING, prev_state=NMDeviceState.CONFIG, - reason=NMDeviceStateReason.CONNECTION_REMOVED) - assert wm._wifi_state.ssid == "A" - assert wm._wifi_state.status == ConnectStatus.CONNECTING # DEACTIVATING preserves CONNECTING - - fire(wm, NMDeviceState.DISCONNECTED, prev_state=NMDeviceState.DEACTIVATING, - reason=NMDeviceStateReason.CONNECTION_REMOVED) - assert wm._wifi_state.ssid is None - assert wm._wifi_state.status == ConnectStatus.DISCONNECTED - - # NM auto-connects to another saved network - fire(wm, NMDeviceState.PREPARE) - assert wm._wifi_state.ssid == "Other" - assert wm._wifi_state.status == ConnectStatus.CONNECTING - - fire(wm, NMDeviceState.CONFIG) - fire_wpa_connect(wm) - assert wm._wifi_state.status == ConnectStatus.CONNECTED - assert wm._wifi_state.ssid == "Other" - - def test_forget_connected_network(self, mocker): - """Forget the currently connected network (not switching to another). - - Real device sequence: - DEACTIVATING(ACTIVATED, CONNECTION_REMOVED) → DISCONNECTED(DEACTIVATING, CONNECTION_REMOVED) - - ConnectionRemoved signal may or may not have been processed before state changes. - Either way, state must clear — we're forgetting what we're connected to, not switching. - """ - wm = _make_wm(mocker, connections={"A": "/path/A"}) - wm._wifi_state = WifiState(ssid="A", status=ConnectStatus.CONNECTED) - - fire(wm, NMDeviceState.DEACTIVATING, prev_state=NMDeviceState.ACTIVATED, - reason=NMDeviceStateReason.CONNECTION_REMOVED) - assert wm._wifi_state.ssid is None - assert wm._wifi_state.status == ConnectStatus.DISCONNECTED - - # DISCONNECTED follows — harmless since state is already cleared - fire(wm, NMDeviceState.DISCONNECTED, prev_state=NMDeviceState.DEACTIVATING, - reason=NMDeviceStateReason.CONNECTION_REMOVED) - assert wm._wifi_state.ssid is None - assert wm._wifi_state.status == ConnectStatus.DISCONNECTED - - def test_forget_A_connect_B(self, mocker): - """Forget A while connecting to B: full signal sequence. - - Real device sequence: - DEACTIVATING(ACTIVATED, CONNECTION_REMOVED) → DISCONNECTED(DEACTIVATING, CONNECTION_REMOVED) - → PREPARE → CONFIG → NEED_AUTH(CONFIG, NONE) → PREPARE(NEED_AUTH, NONE) → CONFIG - → IP_CONFIG → IP_CHECK → SECONDARIES → ACTIVATED - - Signal order: - 1. User: _set_connecting("B"), forget("A") removes A from _connections - 2. NewConnection for B arrives → _connections["B"] = ... - 3. DEACTIVATING(CONNECTION_REMOVED) — no-op - 4. DISCONNECTED(CONNECTION_REMOVED) — B is in _connections, must not clear - 5. PREPARE → CONFIG → NEED_AUTH → PREPARE → CONFIG → ... → ACTIVATED - """ - wm = _make_wm(mocker, connections={"A": "/path/A"}) - wm._wifi_state = WifiState(ssid="A", status=ConnectStatus.CONNECTED) - - wm._set_connecting("B") - del wm._connections["A"] - wm._connections["B"] = "/path/B" - - fire(wm, NMDeviceState.DEACTIVATING, prev_state=NMDeviceState.ACTIVATED, - reason=NMDeviceStateReason.CONNECTION_REMOVED) - assert wm._wifi_state.ssid == "B" - assert wm._wifi_state.status == ConnectStatus.CONNECTING - - fire(wm, NMDeviceState.DISCONNECTED, prev_state=NMDeviceState.DEACTIVATING, - reason=NMDeviceStateReason.CONNECTION_REMOVED) - assert wm._wifi_state.ssid == "B" - assert wm._wifi_state.status == ConnectStatus.CONNECTING - - wm._get_active_wifi_connection.return_value = ("/path/B", {}) - fire(wm, NMDeviceState.PREPARE) - fire(wm, NMDeviceState.CONFIG) - fire_wpa_connect(wm) - assert wm._wifi_state.status == ConnectStatus.CONNECTED - assert wm._wifi_state.ssid == "B" - - def test_forget_A_connect_B_late_new_connection(self, mocker): - """Forget A, connect B: NewConnection for B arrives AFTER DISCONNECTED. - - This is the worst-case race: B isn't in _connections when DISCONNECTED fires, - so the guard can't protect it and state clears. PREPARE must recover by doing - the DBus lookup (ssid is None at that point). - - Signal order: - 1. User: _set_connecting("B"), forget("A") removes A from _connections - 2. DEACTIVATING(CONNECTION_REMOVED) — B NOT in _connections, should be no-op - 3. DISCONNECTED(CONNECTION_REMOVED) — B STILL NOT in _connections, clears state - 4. NewConnection for B arrives late → _connections["B"] = ... - 5. PREPARE (ssid=None, so DBus lookup recovers) → CONFIG → ACTIVATED - """ - wm = _make_wm(mocker, connections={"A": "/path/A"}) - wm._wifi_state = WifiState(ssid="A", status=ConnectStatus.CONNECTED) - - wm._set_connecting("B") - del wm._connections["A"] - - fire(wm, NMDeviceState.DEACTIVATING, prev_state=NMDeviceState.ACTIVATED, - reason=NMDeviceStateReason.CONNECTION_REMOVED) - assert wm._wifi_state.ssid == "B" - assert wm._wifi_state.status == ConnectStatus.CONNECTING - - fire(wm, NMDeviceState.DISCONNECTED, prev_state=NMDeviceState.DEACTIVATING, - reason=NMDeviceStateReason.CONNECTION_REMOVED) - # B not in _connections yet, so state clears — this is the known edge case - assert wm._wifi_state.ssid is None - assert wm._wifi_state.status == ConnectStatus.DISCONNECTED - - # NewConnection arrives late - wm._connections["B"] = "/path/B" - wm._get_active_wifi_connection.return_value = ("/path/B", {}) - - # PREPARE recovers: ssid is None so it looks up from DBus - fire(wm, NMDeviceState.PREPARE) - assert wm._wifi_state.ssid == "B" - assert wm._wifi_state.status == ConnectStatus.CONNECTING - - fire(wm, NMDeviceState.CONFIG) - fire_wpa_connect(wm) - assert wm._wifi_state.status == ConnectStatus.CONNECTED - assert wm._wifi_state.ssid == "B" - - def test_auto_connect(self, mocker): - """NM auto-connects (no user action, ssid starts None).""" - wm = _make_wm(mocker, connections={"AutoNet": "/path/auto"}) - wm._get_active_wifi_connection.return_value = ("/path/auto", {}) - - fire(wm, NMDeviceState.PREPARE) - assert wm._wifi_state.ssid == "AutoNet" - assert wm._wifi_state.status == ConnectStatus.CONNECTING - - fire(wm, NMDeviceState.CONFIG) - fire_wpa_connect(wm) - assert wm._wifi_state.status == ConnectStatus.CONNECTED - assert wm._wifi_state.ssid == "AutoNet" - - def test_network_lost_during_connection(self, mocker): - """Hotspot turned off while connecting (before ACTIVATED). - - Confirmed on device: started new connection to Shane's iPhone, immediately - turned off the hotspot. NM can't complete WPA handshake and reports - FAILED(NO_SECRETS) — same signal as wrong password (false positive). - - Real device sequence: - PREPARE → CONFIG → NEED_AUTH(CONFIG, NONE) → PREPARE(NEED_AUTH) → CONFIG - → NEED_AUTH(CONFIG, NONE) → FAILED(NEED_AUTH, NO_SECRETS) → DISCONNECTED(FAILED, NONE) - - Note: no DEACTIVATING, no SUPPLICANT_DISCONNECT. The NEED_AUTH(CONFIG, NONE) is the - normal WPA handshake (not an error). NM gives up with NO_SECRETS because the AP - vanished mid-handshake. - """ - wm = _make_wm(mocker, connections={"Hotspot": "/path/hs"}) - cb = mocker.MagicMock() - wm.add_callbacks(need_auth=cb) - - wm._set_connecting("Hotspot") - fire(wm, NMDeviceState.PREPARE) - fire(wm, NMDeviceState.CONFIG) - fire(wm, NMDeviceState.NEED_AUTH) # WPA handshake (reason=NONE) - fire(wm, NMDeviceState.PREPARE, prev_state=NMDeviceState.NEED_AUTH) - fire(wm, NMDeviceState.CONFIG) - assert wm._wifi_state.status == ConnectStatus.CONNECTING - - # Second NEED_AUTH(CONFIG, NONE) — NM retries handshake, AP vanishing - fire(wm, NMDeviceState.NEED_AUTH) - assert wm._wifi_state.status == ConnectStatus.CONNECTING - - # NM gives up — reports NO_SECRETS (same as wrong password) - fire(wm, NMDeviceState.FAILED, prev_state=NMDeviceState.NEED_AUTH, - reason=NMDeviceStateReason.NO_SECRETS) - assert wm._wifi_state.status == ConnectStatus.DISCONNECTED - assert len(wm._callback_queue) == 1 - - fire(wm, NMDeviceState.DISCONNECTED, prev_state=NMDeviceState.FAILED) - assert wm._wifi_state.ssid is None - assert wm._wifi_state.status == ConnectStatus.DISCONNECTED - - wm.process_callbacks() - cb.assert_called_once_with("Hotspot") - - @unittest.expectedFailure # "TODO: FAILED(SSID_NOT_FOUND) should emit error for UI" - def test_ssid_not_found(self, mocker): - """Network drops off while connected — hotspot turned off. - - NM docs: SSID_NOT_FOUND (53) = "The WiFi network could not be found" - - Confirmed on device: connected to Shane's iPhone, then turned off the hotspot. - No DEACTIVATING fires — NM goes straight from ACTIVATED to FAILED(SSID_NOT_FOUND). - NM retries connecting (PREPARE → CONFIG → ... → FAILED(CONFIG, SSID_NOT_FOUND)) - before finally giving up with DISCONNECTED. - - NOTE: turning off a hotspot during initial connection (before ACTIVATED) typically - produces FAILED(NO_SECRETS) instead of SSID_NOT_FOUND (see test_failed_no_secrets). - - Real device sequence (hotspot turned off while connected): - FAILED(ACTIVATED, SSID_NOT_FOUND) → DISCONNECTED(FAILED, NONE) - → PREPARE → CONFIG → NEED_AUTH(CONFIG, NONE) → PREPARE(NEED_AUTH) → CONFIG - → NEED_AUTH(CONFIG, NONE) → PREPARE(NEED_AUTH) → CONFIG - → FAILED(CONFIG, SSID_NOT_FOUND) → DISCONNECTED(FAILED, NONE) - - The UI error callback mechanism is intentionally deferred — for now just clear state. - """ - wm = _make_wm(mocker, connections={"GoneNet": "/path/gone"}) - cb = mocker.MagicMock() - wm.add_callbacks(need_auth=cb) - - wm._set_connecting("GoneNet") - fire(wm, NMDeviceState.PREPARE) - fire(wm, NMDeviceState.CONFIG) - fire(wm, NMDeviceState.FAILED, reason=NMDeviceStateReason.SSID_NOT_FOUND) - - assert wm._wifi_state.status == ConnectStatus.DISCONNECTED - assert wm._wifi_state.ssid is None - - def test_failed_then_disconnected_clears_state(self, mocker): - """After FAILED, NM always transitions to DISCONNECTED to clean up. - - NM docs: FAILED (120) = "failed to connect, cleaning up the connection request" - Full sequence: ... → FAILED(reason) → DISCONNECTED(NONE) - """ - wm = _make_wm(mocker) - wm._set_connecting("Net") - - fire(wm, NMDeviceState.FAILED, reason=NMDeviceStateReason.NONE) - assert wm._wifi_state.status == ConnectStatus.CONNECTING # FAILED(NONE) is a no-op - - fire(wm, NMDeviceState.DISCONNECTED, reason=NMDeviceStateReason.NONE) - assert wm._wifi_state.ssid is None - assert wm._wifi_state.status == ConnectStatus.DISCONNECTED - - def test_user_requested_disconnect(self, mocker): - """User explicitly disconnects from the network. - - NM docs: USER_REQUESTED (39) = "Device disconnected by user or client" - Expected sequence: DEACTIVATING(USER_REQUESTED) → DISCONNECTED(USER_REQUESTED) - """ - wm = _make_wm(mocker) - wm._wifi_state = WifiState(ssid="MyNet", status=ConnectStatus.CONNECTED) - - fire(wm, NMDeviceState.DEACTIVATING, reason=NMDeviceStateReason.USER_REQUESTED) - fire(wm, NMDeviceState.DISCONNECTED, reason=NMDeviceStateReason.USER_REQUESTED) - - assert wm._wifi_state.ssid is None - assert wm._wifi_state.status == ConnectStatus.DISCONNECTED - - -# --------------------------------------------------------------------------- -# Worker error recovery: DBus errors in activate/connect re-sync with NM -# --------------------------------------------------------------------------- -# Verified on device: when ActivateConnection returns UnknownConnection error, -# NM emits no state signals. The worker error path is the only recovery point. - -class TestWorkerErrorRecovery(OpenpilotTestCase): - """Worker threads re-sync with NM via _init_wifi_state on DBus errors, - preserving actual NM state instead of blindly clearing to DISCONNECTED.""" - - def _mock_init_restores(self, wm, mocker, ssid, status): - """Replace _init_wifi_state with a mock that simulates NM reporting the given state.""" - mock = mocker.MagicMock( - side_effect=lambda: setattr(wm, '_wifi_state', WifiState(ssid=ssid, status=status)) + def test_scan_only_reselects_when_disconnected(self): + cases = ( + (WifiState("TestNet", ConnectStatus.CONNECTED), "SCAN TYPE=ONLY"), + (WifiState(), "SCAN"), + ) + for state, command in cases: + with self.subTest(state=state): + manager = build_wifi_manager() + manager._wifi_state = state + + manager._request_scan() + + manager._ctrl.request.assert_called_once_with(command) + + def test_scan_rejects_conflicting_security_variants(self): + self.manager._ctrl.request.return_value = "\n".join(( + "bssid / frequency / signal level / flags / ssid", + "00:11:22:33:44:55\t2437\t-40\t[ESS]\tMixed", + "66:77:88:99:aa:bb\t2437\t-60\t[WPA2-PSK-CCMP][ESS]\tMixed", + )) + + self.manager._update_networks() + + assert len(self.manager.networks) == 1 + assert self.manager.networks[0].ssid == "Mixed" + assert self.manager.networks[0].security_type == SecurityType.UNSUPPORTED + + def test_scan_accepts_psk_bss_among_unsupported_variants(self): + self.manager._ctrl.request.return_value = "\n".join(( + "bssid / frequency / signal level / flags / ssid", + "00:11:22:33:44:55\t2437\t-40\t[RSN-SAE-CCMP][ESS]\tMixed", + "66:77:88:99:aa:bb\t2437\t-60\t[WPA2-PSK-CCMP][ESS]\tMixed", + )) + + self.manager._update_networks() + + assert len(self.manager.networks) == 1 + assert self.manager.networks[0].ssid == "Mixed" + assert self.manager.networks[0].security_type == SecurityType.WPA + + def test_wrong_key_removes_runtime_credentials_and_clears_station_state(self): + need_auth = MagicMock() + self.manager.add_callbacks(need_auth=need_auth) + self.manager._set_connecting("TestNet") + self.manager._set_pending_connection("TestNet", "wrongpass", False, SecurityType.WPA) + self.manager._set_pending_network_id("0", self.manager._user_epoch) + self.manager._dhcp_adoption_ssid = "TestNet" + self.manager._last_wrong_key_dispatch[("OldNet", None)] = 0.0 + self.manager._ctrl.request.return_value = "OK" + + with ( + patch.object(self.manager, "_list_network_ids", return_value=["0"]), + patch.object(wifi_manager_module.time, "monotonic", return_value=100), + ): + self.manager._handle_event('CTRL-EVENT-SSID-TEMP-DISABLED id=0 ssid="TestNet" reason=WRONG_KEY') + + self.manager.process_callbacks() + assert self.manager._pending_connection is None + assert self.manager.wifi_state == WifiState() + assert call("REMOVE_NETWORK 0") in self.manager._ctrl.request.call_args_list + assert ("OldNet", None) not in self.manager._last_wrong_key_dispatch + self.manager._dhcp.stop.assert_called_once() + self.manager._dhcp.clear_ipv6_state.assert_called_once() + need_auth.assert_called_once_with("TestNet") + + def test_wrong_key_ignores_same_ssid_event_for_other_profile(self): + need_auth = MagicMock() + self.manager.add_callbacks(need_auth=need_auth) + self.manager._set_connecting("TestNet") + self.manager._set_pending_connection("TestNet", "correct-password", False, SecurityType.WPA) + self.manager._set_pending_network_id("1", self.manager._user_epoch) + + self.manager._handle_event('CTRL-EVENT-SSID-TEMP-DISABLED id=0 ssid="TestNet" reason=WRONG_KEY') + self.manager.process_callbacks() + + assert self.manager._pending_connection is not None + assert self.manager._pending_connection.password == "correct-password" + assert self.manager.wifi_state == WifiState("TestNet", ConnectStatus.CONNECTING) + self.manager._ctrl.request.assert_not_called() + self.manager._dhcp.stop.assert_not_called() + need_auth.assert_not_called() + + def test_wrong_key_does_not_cancel_newer_connection_request(self): + need_auth = MagicMock() + disconnected = MagicMock() + self.manager.add_callbacks(need_auth=need_auth, disconnected=disconnected) + self.manager._set_connecting("PreviousNet") + self.manager._set_pending_connection("PreviousNet", "wrong-password", False, SecurityType.WPA) + self.manager._set_pending_network_id("0", self.manager._user_epoch) + self.manager._ctrl.request.return_value = "OK" + remove_started = threading.Event() + release_remove = threading.Event() + + def remove_network(_): + remove_started.set() + assert release_remove.wait(1) + + with ( + patch.object(self.manager, "_list_network_ids", return_value=["0"]), + patch.object(self.manager, "_remove_wpa_network_id", side_effect=remove_network), + ): + worker = threading.Thread( + target=self.manager._handle_event, + args=('CTRL-EVENT-SSID-TEMP-DISABLED id=0 ssid="PreviousNet" reason=WRONG_KEY',), + ) + worker.start() + assert remove_started.wait(1) + + with patch.object(wifi_manager_module.threading.Thread, "start"): + self.manager.connect_to_network("NextNet", "next-password") + + release_remove.set() + worker.join(1) + + self.manager.process_callbacks() + assert not worker.is_alive() + assert self.manager.wifi_state == WifiState("NextNet", ConnectStatus.CONNECTING) + assert self.manager._pending_connection is not None + assert self.manager._pending_connection.ssid == "NextNet" + assert self.manager._pending_connection.password == "next-password" + self.manager._dhcp.stop.assert_not_called() + need_auth.assert_not_called() + disconnected.assert_not_called() + + def test_wrong_key_exhausts_same_ssid_profiles_before_auth_failure(self): + need_auth = MagicMock() + self.manager.add_callbacks(need_auth=need_auth) + self.manager._set_connecting("TestNet") + runtime_networks = {"0": "TestNet", "1": "TestNet", "2": "FallbackNet"} + enabled_networks = {"0"} + + def list_network_ids(ssid): + return [net_id for net_id, network_ssid in runtime_networks.items() if network_ssid == ssid] + + def remove_network(net_id): + runtime_networks.pop(net_id) + enabled_networks.discard(net_id) + + def select_networks(net_ids): + enabled_networks.clear() + enabled_networks.update(net_ids) + + def request(command): + if command == "ENABLE_NETWORK all": + enabled_networks.update(runtime_networks) + return "OK" + if command == "RECONFIGURE": + raise AssertionError("wrong-key recovery must not restore durable credentials") + return "OK" + + with ( + patch.object(self.manager, "_list_network_ids", side_effect=list_network_ids), + patch.object(self.manager, "_remove_wpa_network_id", side_effect=remove_network) as remove_network_mock, + patch.object(self.manager, "_select_network_ids", side_effect=select_networks) as select_networks_mock, + patch.object(self.manager, "_restore_station_runtime") as restore_runtime, + patch.object(wifi_manager_module.time, "monotonic", return_value=100), + ): + self.manager._request = MagicMock(side_effect=request) + self.manager._handle_event('CTRL-EVENT-SSID-TEMP-DISABLED id=0 ssid="TestNet" reason=WRONG_KEY') + self.manager.process_callbacks() + + assert self.manager.wifi_state == WifiState("TestNet", ConnectStatus.CONNECTING) + select_networks_mock.assert_called_once_with(["1"]) + assert enabled_networks == {"1"} + need_auth.assert_not_called() + + self.manager._handle_event('CTRL-EVENT-SSID-TEMP-DISABLED id=1 ssid="TestNet" reason=WRONG_KEY') + self.manager._handle_event('CTRL-EVENT-SSID-TEMP-DISABLED id=1 ssid="TestNet" reason=WRONG_KEY') + + self.manager.process_callbacks() + assert remove_network_mock.call_args_list == [call("0"), call("1")] + assert runtime_networks == {"2": "FallbackNet"} + assert enabled_networks == {"2"} + assert call("ENABLE_NETWORK all") in self.manager._request.call_args_list + restore_runtime.assert_not_called() + assert self.manager.wifi_state == WifiState() + self.manager._dhcp.stop.assert_called_once() + need_auth.assert_called_once_with("TestNet") + + def test_connect_allocates_profile_before_removing_matching_ids(self): + requests = [] + + def request(command): + requests.append(command) + if command == "LIST_NETWORKS": + return "network id / ssid / bssid / flags\n0\tTestNet\tany\n" + if command == "ADD_NETWORK": + return "1" + return "OK" + + self.manager._ctrl.request.side_effect = request + real_thread = threading.Thread + worker_threads = [] + + class CapturingThread: + def __init__(self, *args, **kwargs): + self.thread = real_thread(*args, **kwargs) + worker_threads.append(self.thread) + + def start(self): + self.thread.start() + + with patch.object(wifi_manager_module.threading, "Thread", CapturingThread): + self.manager.connect_to_network("TestNet", "correct-password") + worker_threads[0].join() + + assert requests.index("ADD_NETWORK") < requests.index("REMOVE_NETWORK 0") + assert self.manager._pending_connection is not None + assert self.manager._pending_connection.network_id == "1" + + def test_connect_rejects_invalid_passphrases(self): + invalid_passwords = ("short", "x" * 64, "é" * 32) + + for password in invalid_passwords: + with self.subTest(password=password): + manager = build_wifi_manager() + need_auth = MagicMock() + manager.add_callbacks(need_auth=need_auth) + + with patch.object(wifi_manager_module.threading.Thread, "start"): + manager.connect_to_network("TestNet", password) + + manager.process_callbacks() + assert manager.wifi_state == WifiState() + assert manager._pending_connection is None + need_auth.assert_called_once_with("TestNet") + + def test_connect_rejects_oversized_hidden_ssid(self): + ssid = "é" * 17 + + with patch.object(wifi_manager_module.threading.Thread, "start") as start: + self.manager.connect_to_network(ssid, "password123", hidden=True) + + assert self.manager.wifi_state == WifiState() + assert self.manager._pending_connection is None + start.assert_not_called() + + def test_network_not_found_clears_connecting_state_after_reconciliation(self): + disconnected = MagicMock() + self.manager.add_callbacks(disconnected=disconnected) + self.manager._set_connecting("MissingNet") + self.manager._set_pending_connection("MissingNet", "password123", True, SecurityType.WPA) + self.manager._set_pending_network_id("7", self.manager._user_epoch) + self.manager._ctrl.request.side_effect = lambda command: "wpa_state=SCANNING\n" if command == "STATUS" else "OK" + + with patch.object(self.manager, "_remove_wpa_network") as remove_wpa_network: + self.manager._last_connecting_at = time.monotonic() - CONNECTING_STALE_TIMEOUT_SECONDS - 1 + self.manager._handle_event("CTRL-EVENT-NETWORK-NOT-FOUND") + self.manager._handle_event("CTRL-EVENT-NETWORK-NOT-FOUND") + assert self.manager.wifi_state == WifiState("MissingNet", ConnectStatus.CONNECTING) + assert self.manager._pending_connection is not None + remove_wpa_network.assert_not_called() + + self.manager._reconcile_connecting_state() + + self.manager.process_callbacks() + assert self.manager.wifi_state == WifiState() + assert self.manager._pending_connection is None + remove_wpa_network.assert_not_called() + assert call("REMOVE_NETWORK 7") in self.manager._ctrl.request.call_args_list + assert call("RECONFIGURE") in self.manager._ctrl.request.call_args_list + assert call("ENABLE_NETWORK all") not in self.manager._ctrl.request.call_args_list + self.manager._dhcp.stop.assert_called_once() + disconnected.assert_called_once() + + def test_network_not_found_ends_scanning_after_reconciliation_deferred(self): + self.manager._set_connecting("MissingNet") + self.manager._set_pending_connection("MissingNet", "password123", True, SecurityType.WPA) + self.manager._ctrl.request.return_value = "wpa_state=SCANNING\n" + self.manager._last_connecting_at = time.monotonic() - CONNECTING_STALE_TIMEOUT_SECONDS - 1 + + self.manager._reconcile_connecting_state() + self.manager._handle_event("CTRL-EVENT-NETWORK-NOT-FOUND") + self.manager._handle_event("CTRL-EVENT-NETWORK-NOT-FOUND") + self.manager._reconcile_connecting_state() + + assert self.manager.wifi_state == WifiState() + assert self.manager._pending_connection is None + + def test_delayed_network_not_found_does_not_bind_to_fresh_attempt(self): + self.manager._set_connecting("PreviousNet") + self.manager._last_connecting_at = time.monotonic() - CONNECTING_STALE_TIMEOUT_SECONDS - 1 + self.manager._set_connecting("HiddenNet") + self.manager._set_pending_connection("HiddenNet", "password123", True, SecurityType.WPA) + self.manager._ctrl.request.return_value = "wpa_state=SCANNING\n" + + self.manager._last_connecting_at = time.monotonic() - CONNECTING_STALE_TIMEOUT_SECONDS - 1 + self.manager._handle_event("CTRL-EVENT-NETWORK-NOT-FOUND") + self.manager._reconcile_connecting_state() + + assert self.manager.wifi_state == WifiState("HiddenNet", ConnectStatus.CONNECTING) + assert self.manager._pending_connection is not None + self.manager._dhcp.stop.assert_not_called() + + def test_reconcile_keeps_saved_runtime_network_after_transient_failure(self): + self.manager._store.contains.return_value = True + self.manager._set_connecting("SavedNet") + self.manager._last_connecting_at = time.monotonic() - CONNECTING_STALE_TIMEOUT_SECONDS - 1 + self.manager._ctrl.request.side_effect = lambda command: "wpa_state=DISCONNECTED\n" if command == "STATUS" else "OK" + + with patch.object(self.manager, "_remove_wpa_network") as remove_wpa_network: + self.manager._reconcile_connecting_state() + + remove_wpa_network.assert_not_called() + assert call("RECONFIGURE") in self.manager._ctrl.request.call_args_list + assert call("ENABLE_NETWORK all") not in self.manager._ctrl.request.call_args_list + assert self.manager.wifi_state == WifiState() + + def test_reconcile_rechecks_epoch_before_cancelling_stale_connection(self): + self.manager._set_connecting("PreviousNet") + self.manager._set_pending_connection("PreviousNet", "password123", False, SecurityType.WPA) + self.manager._last_connecting_at = time.monotonic() - CONNECTING_STALE_TIMEOUT_SECONDS - 1 + self.manager._ctrl.request.return_value = "wpa_state=DISCONNECTED\n" + restore_started = threading.Event() + release_restore = threading.Event() + + def restore(*_): + restore_started.set() + assert release_restore.wait(1) + + self.manager._restore_station_runtime = MagicMock(side_effect=restore) + worker = threading.Thread(target=self.manager._reconcile_connecting_state) + worker.start() + assert restore_started.wait(1) + + with patch.object(wifi_manager_module.threading.Thread, "start"): + self.manager.connect_to_network("NextNet", "next-password") + release_restore.set() + worker.join(1) + + assert not worker.is_alive() + assert self.manager.wifi_state == WifiState("NextNet", ConnectStatus.CONNECTING) + assert self.manager._pending_connection is not None + assert self.manager._pending_connection.ssid == "NextNet" + self.manager._dhcp.stop.assert_not_called() + + def test_reconcile_does_not_timeout_connection_associated_after_status(self): + self.manager._set_connecting("TestNet") + self.manager._handle_connected("TestNet") + complete_station_connection(self.manager, "TestNet") + self.manager._handle_event("CTRL-EVENT-DISCONNECTED reason=3") + self.manager._last_connecting_at = time.monotonic() - CONNECTING_STALE_TIMEOUT_SECONDS - 1 + epoch = self.manager._user_epoch + operation = self.manager._station_operation + status_started = threading.Event() + release_status = threading.Event() + status_requests = 0 + + def request(command): + nonlocal status_requests + if command == "STATUS": + status_requests += 1 + if status_requests == 1: + status_started.set() + assert release_status.wait(1) + return "wpa_state=DISCONNECTED\n" + return "wpa_state=COMPLETED\nssid=TestNet\n" + return "OK" + + self.manager._request = MagicMock(side_effect=request) + self.manager._restore_station_runtime = MagicMock() + self.manager._dhcp.stop.reset_mock() + worker = threading.Thread(target=self.manager._reconcile_connecting_state) + worker.start() + assert status_started.wait(1) + + self.manager._handle_connected("TestNet", expected_epoch=epoch) + assert self.manager._station_operation is operation + release_status.set() + worker.join(1) + + assert not worker.is_alive() + assert status_requests == 2 + assert self.manager._associated_ssid == "TestNet" + assert self.manager._associated_epoch == epoch + assert self.manager.wifi_state == WifiState("TestNet", ConnectStatus.CONNECTED) + self.manager._dhcp.start.assert_called_once() + self.manager._dhcp.stop.assert_not_called() + self.manager._restore_station_runtime.assert_not_called() + + def test_reconcile_times_out_disconnected_established_connection(self): + self.manager._set_connecting("TestNet") + self.manager._handle_connected("TestNet") + complete_station_connection(self.manager, "TestNet") + self.manager._handle_event("CTRL-EVENT-DISCONNECTED reason=3") + self.manager._last_connecting_at = time.monotonic() - CONNECTING_STALE_TIMEOUT_SECONDS - 1 + self.manager._request = MagicMock(return_value="wpa_state=DISCONNECTED\n") + self.manager._restore_station_runtime = MagicMock() + self.manager._dhcp.stop.reset_mock() + self.manager._dhcp.clear_ipv6_state.reset_mock() + + self.manager._reconcile_connecting_state() + + assert self.manager.wifi_state == WifiState() + assert self.manager._associated_ssid is None + assert self.manager._associated_epoch is None + assert self.manager._dhcp_adoption_ssid is None + self.manager._dhcp.stop.assert_called_once() + self.manager._dhcp.clear_ipv6_state.assert_called_once() + + def test_reconcile_times_out_disconnected_ip_pending_connection(self): + self.manager._set_connecting("TestNet") + self.manager._handle_connected("TestNet") + self.manager._handle_event("CTRL-EVENT-DISCONNECTED reason=3") + self.manager._last_connecting_at = time.monotonic() - CONNECTING_STALE_TIMEOUT_SECONDS - 1 + self.manager._request = MagicMock(return_value="wpa_state=DISCONNECTED\n") + self.manager._restore_station_runtime = MagicMock() + self.manager._dhcp.stop.reset_mock() + self.manager._dhcp.clear_ipv6_state.reset_mock() + + self.manager._reconcile_connecting_state() + + assert self.manager.wifi_state == WifiState() + assert self.manager._associated_ssid is None + assert self.manager._associated_epoch is None + self.manager._dhcp.stop.assert_called_once() + self.manager._dhcp.clear_ipv6_state.assert_called_once() + + def test_reconcile_times_out_stalled_handshake(self): + for wpa_state in ("AUTHENTICATING", "ASSOCIATING", "ASSOCIATED", "4WAY_HANDSHAKE", "GROUP_HANDSHAKE"): + with self.subTest(wpa_state=wpa_state): + manager = build_wifi_manager() + manager._store.contains.return_value = True + manager._set_connecting("StalledNet") + manager._set_pending_connection("StalledNet", "password123", False, SecurityType.WPA) + manager._set_pending_network_id("7", manager._user_epoch) + manager._last_connecting_at = time.monotonic() - CONNECTING_STALE_TIMEOUT_SECONDS - 1 + def request(command, state=wpa_state): + return f"wpa_state={state}\nssid=StalledNet\n" if command == "STATUS" else "OK" + manager._ctrl.request.side_effect = request + + manager._reconcile_connecting_state() + + assert manager.wifi_state == WifiState() + assert manager._pending_connection is None + assert call("REMOVE_NETWORK 7") in manager._ctrl.request.call_args_list + assert call("RECONFIGURE") in manager._ctrl.request.call_args_list + assert call("ENABLE_NETWORK all") not in manager._ctrl.request.call_args_list + manager._dhcp.stop.assert_called_once() + + def test_reconcile_does_not_report_generic_disconnect_as_auth_failure(self): + need_auth = MagicMock() + self.manager.add_callbacks(need_auth=need_auth) + self.manager._store.contains.return_value = True + self.manager._networks = [wifi_manager_module.Network("SavedNet", 100, SecurityType.WPA, False)] + self.manager._set_connecting("SavedNet") + self.manager._last_connecting_at = time.monotonic() - CONNECTING_STALE_TIMEOUT_SECONDS - 1 + self.manager._ctrl.request.return_value = "wpa_state=DISCONNECTED\n" + + self.manager._reconcile_connecting_state() + self.manager.process_callbacks() + + assert self.manager.wifi_state == WifiState() + need_auth.assert_not_called() + + def test_reconcile_clears_ipv6_state_before_adopting_another_network(self): + previous_state = WifiState("PreviousNet", ConnectStatus.CONNECTED) + self.manager._wifi_state = previous_state + self.manager._ctrl.request.return_value = "wpa_state=COMPLETED\nmode=station\nssid=TestNet\n" + self.manager._dhcp.clear_ipv6_state.side_effect = lambda: self.assertEqual(self.manager.wifi_state, previous_state) + + self.manager._reconcile_connecting_state() + + complete_station_connection(self.manager, "TestNet") + assert self.manager.wifi_state == WifiState("TestNet", ConnectStatus.CONNECTED) + self.manager._dhcp.clear_ipv6_state.assert_called_once() + self.manager._dhcp.start.assert_called_once() + + def test_stale_network_not_found_does_not_clear_fresh_connection(self): + self.manager._set_connecting("TestNet") + self.manager._set_pending_connection("TestNet", "password123", False, SecurityType.WPA) + profile_uuid = self.manager._pending_connection.profile_uuid + + with ( + patch.object(self.manager, "_remove_wpa_network") as remove_wpa_network, + patch.object(wifi_manager_module, "generate_wpa_conf"), + ): + self.manager._handle_event("CTRL-EVENT-NETWORK-NOT-FOUND") + self.manager._handle_connected("TestNet") + + complete_station_connection(self.manager, "TestNet") + assert self.manager.wifi_state == WifiState("TestNet", ConnectStatus.CONNECTED) + self.manager._store.save_network.assert_called_once_with( + "TestNet", psk="password123", hidden=False, security=SecurityType.WPA, profile_uuid=profile_uuid, + ) + remove_wpa_network.assert_not_called() + + def test_forget_cancels_in_flight_connection(self): + runtime_networks = set() + connect_started = threading.Event() + release_connect = threading.Event() + connect_added = threading.Event() + forget_removed = threading.Event() + + def remove_network(ssid): + runtime_networks.discard(ssid) + if connect_added.is_set(): + forget_removed.set() + + def add_network(ssid, *_, **__): + connect_started.set() + assert release_connect.wait(1) + runtime_networks.add(ssid) + connect_added.set() + return "1" + + self.manager._store.contains.return_value = True + self.manager._store.remove.return_value = True + self.manager._ctrl.request.return_value = "OK\n" + + with ( + patch.object(self.manager, "_list_network_ids", return_value=[]), + patch.object(self.manager, "_remove_wpa_network", side_effect=remove_network), + patch.object(self.manager, "_add_and_select_network", side_effect=add_network), + patch.object(wifi_manager_module, "generate_wpa_conf"), + ): + self.manager.connect_to_network("TestNet", "password123") + assert connect_started.wait(1) + + self.manager.forget_connection("TestNet") + assert self.manager.wifi_state == WifiState("TestNet", ConnectStatus.CONNECTING) + + release_connect.set() + assert connect_added.wait(1) + assert forget_removed.wait(1) + + assert runtime_networks == set() + assert self.manager.wifi_state == WifiState() + + def test_forget_does_not_disconnect_fresh_connection(self): + forget_removing = threading.Event() + release_forget = threading.Event() + new_network_selected = threading.Event() + forget_finished = threading.Event() + + def remove_saved_network(ssid): + assert ssid == "TestNet" + forget_removing.set() + assert release_forget.wait(1) + return True + + def select_network(*_, **__): + new_network_selected.set() + return "1" + + self.manager._wifi_state = WifiState("TestNet", ConnectStatus.CONNECTED) + self.manager._store.contains.return_value = True + self.manager._store.remove.side_effect = remove_saved_network + self.manager._ctrl.request.return_value = "OK\n" + + with ( + patch.object(self.manager, "_list_network_ids", return_value=[]), + patch.object(self.manager, "_remove_wpa_network"), + patch.object(self.manager, "_add_and_select_network", side_effect=select_network), + patch.object(self.manager, "_enqueue_callbacks", side_effect=lambda *_: forget_finished.set()), + patch.object(wifi_manager_module, "generate_wpa_conf"), + ): + self.manager.forget_connection("TestNet") + assert forget_removing.wait(1) + + self.manager.connect_to_network("NextNet", "password123") + assert not new_network_selected.wait(0.1) + + release_forget.set() + assert forget_finished.wait(1) + assert new_network_selected.wait(1) + + assert self.manager.wifi_state == WifiState("NextNet", ConnectStatus.CONNECTING) + assert call("ENABLE_NETWORK all") not in self.manager._ctrl.request.call_args_list + assert call("DISCONNECT") not in self.manager._ctrl.request.call_args_list + assert call("REASSOCIATE") not in self.manager._ctrl.request.call_args_list + + def test_forget_reconfigures_when_targeted_runtime_removal_fails(self): + forgotten = MagicMock() + forget_failed = MagicMock() + self.manager.add_callbacks(forgotten=forgotten, forget_failed=forget_failed) + self.manager._store.contains.return_value = True + self.manager._store.remove.return_value = True + self.manager._ctrl.request.return_value = "OK\n" + + with ( + patch.object(wifi_manager_module, "generate_wpa_conf"), + patch.object(self.manager, "_remove_wpa_network", side_effect=RuntimeError("REMOVE_NETWORK failed")), + patch.object(self.manager, "_list_network_ids", return_value=[]), + patch.object(self.manager, "_restart_station_supplicant") as restart, + ): + self.manager.forget_connection("SavedNet", block=True) + + self.manager.process_callbacks() + assert call("RECONFIGURE") in self.manager._ctrl.request.call_args_list + restart.assert_not_called() + forgotten.assert_called_once_with("SavedNet") + forget_failed.assert_not_called() + + def test_forget_restarts_station_when_control_reconciliation_fails(self): + forgotten = MagicMock() + self.manager.add_callbacks(forgotten=forgotten) + self.manager._store.contains.return_value = True + self.manager._store.remove.return_value = True + self.manager._ctrl.request.return_value = "FAIL\n" + + with ( + patch.object(wifi_manager_module, "generate_wpa_conf"), + patch.object(self.manager, "_remove_wpa_network", side_effect=RuntimeError("REMOVE_NETWORK failed")), + patch.object(self.manager, "_restart_station_supplicant", return_value=True) as restart, + patch.object(self.manager, "_list_network_ids", return_value=[]), + ): + self.manager.forget_connection("SavedNet", block=True) + + self.manager.process_callbacks() + restart.assert_called_once() + forgotten.assert_called_once_with("SavedNet") + + def test_forget_defers_success_until_configuration_can_be_generated(self): + forgotten = MagicMock() + self.manager.add_callbacks(forgotten=forgotten) + self.manager._store.contains.return_value = True + self.manager._store.remove.return_value = True + self.manager._ctrl.request.return_value = "OK\n" + + with ( + patch.object(wifi_manager_module, "generate_wpa_conf", side_effect=[OSError("read-only"), None]), + patch.object(self.manager, "_remove_wpa_network"), + patch.object(self.manager, "_list_network_ids", return_value=[]), + ): + self.manager.forget_connection("SavedNet", block=True) + self.manager.process_callbacks() + forgotten.assert_not_called() + assert "SavedNet" in self.manager._pending_forget_reconciliations + + self.manager._retry_pending_forget_reconciliations(force=True) + + self.manager.process_callbacks() + forgotten.assert_called_once_with("SavedNet") + assert "SavedNet" not in self.manager._pending_forget_reconciliations + + def test_forget_defers_success_when_runtime_verification_fails(self): + forgotten = MagicMock() + self.manager.add_callbacks(forgotten=forgotten) + self.manager._store.contains.return_value = True + self.manager._store.remove.return_value = True + + with ( + patch.object(wifi_manager_module, "generate_wpa_conf"), + patch.object(self.manager, "_remove_wpa_network"), + patch.object(self.manager, "_list_network_ids", return_value=["0"]), + patch.object(self.manager, "_restart_station_supplicant", return_value=True) as restart, + ): + self.manager.forget_connection("SavedNet", block=True) + + self.manager.process_callbacks() + restart.assert_called_once() + forgotten.assert_not_called() + assert "SavedNet" in self.manager._pending_forget_reconciliations + + def test_forget_allows_fallback_connection_after_disconnect_event(self): + self.manager._wifi_state = WifiState("TestNet", ConnectStatus.CONNECTED) + self.manager._store.contains.return_value = True + self.manager._store.remove.return_value = True + + def request(command): + if command == "DISCONNECT": + self.manager._handle_event("CTRL-EVENT-DISCONNECTED reason=3") + return "OK" + + self.manager._ctrl.request.side_effect = request + with ( + patch.object(wifi_manager_module, "generate_wpa_conf"), + patch.object(self.manager, "_remove_wpa_network"), + patch.object(self.manager, "_list_network_ids", return_value=[]), + ): + self.manager.forget_connection("TestNet", block=True) + + self.manager._ctrl.request.side_effect = None + self.manager._ctrl.request.return_value = "wpa_state=COMPLETED\nssid=NextNet\n" + self.manager._handle_event("CTRL-EVENT-CONNECTED") + + complete_station_connection(self.manager, "NextNet") + assert self.manager.wifi_state == WifiState("NextNet", ConnectStatus.CONNECTED) + + def test_forget_during_reconnect_clears_retained_station_state(self): + self.manager._store.contains.return_value = True + self.manager._store.remove.return_value = True + self.manager._ctrl.request.return_value = "OK\n" + self.manager._set_connecting("TestNet") + self.manager._handle_connected("TestNet") + complete_station_connection(self.manager, "TestNet") + self.manager._handle_event("CTRL-EVENT-DISCONNECTED reason=3") + self.manager._dhcp.stop.reset_mock() + self.manager._dhcp.clear_ipv6_state.reset_mock() + + with ( + patch.object(wifi_manager_module, "generate_wpa_conf"), + patch.object(self.manager, "_remove_wpa_network"), + patch.object(self.manager, "_list_network_ids", return_value=[]), + ): + self.manager.forget_connection("TestNet", block=True) + + assert self.manager.wifi_state == WifiState() + assert self.manager.ipv4_address == "" + self.manager._dhcp.stop.assert_called_once() + self.manager._dhcp.clear_ipv6_state.assert_called_once() + + def test_forget_during_reconnect_cleans_before_fresh_connection(self): + real_thread = threading.Thread + worker_threads = [] + forget_removing = threading.Event() + release_forget = threading.Event() + + class CapturingThread: + def __init__(self, *args, **kwargs): + self.thread = real_thread(*args, **kwargs) + worker_threads.append(self.thread) + + def start(self): + self.thread.start() + + def remove_saved_network(ssid): + assert ssid == "TestNet" + forget_removing.set() + assert release_forget.wait(1) + return True + + def select_network(*_, **__): + self.manager._dhcp.stop.assert_called_once() + self.manager._dhcp.clear_ipv6_state.assert_called_once() + return "2" + + self.manager._store.contains.return_value = True + self.manager._store.remove.side_effect = remove_saved_network + self.manager._ctrl.request.return_value = "OK\n" + self.manager._set_connecting("TestNet") + self.manager._handle_connected("TestNet") + complete_station_connection(self.manager, "TestNet") + self.manager._handle_event("CTRL-EVENT-DISCONNECTED reason=3") + self.manager._dhcp.stop.reset_mock() + self.manager._dhcp.clear_ipv6_state.reset_mock() + self.manager._ctrl.request.reset_mock() + + with ( + patch.object(wifi_manager_module.threading, "Thread", CapturingThread), + patch.object(self.manager, "_list_network_ids", return_value=[]), + patch.object(self.manager, "_remove_wpa_network"), + patch.object(self.manager, "_add_and_select_network", side_effect=select_network), + patch.object(wifi_manager_module, "generate_wpa_conf"), + ): + self.manager.forget_connection("TestNet") + assert forget_removing.wait(1) + self.manager.connect_to_network("NextNet", "password123") + release_forget.set() + for worker_thread in worker_threads: + worker_thread.join(1) + assert not worker_thread.is_alive() + + assert self.manager.wifi_state == WifiState("NextNet", ConnectStatus.CONNECTING) + self.manager._dhcp.stop.assert_called_once() + self.manager._dhcp.clear_ipv6_state.assert_called_once() + assert call("DISCONNECT") not in self.manager._ctrl.request.call_args_list + assert call("REASSOCIATE") not in self.manager._ctrl.request.call_args_list + + def test_forget_failure_releases_caller_without_reporting_success(self): + forgotten = MagicMock() + forget_failed = MagicMock() + self.manager.add_callbacks(forgotten=forgotten, forget_failed=forget_failed) + self.manager._store.contains.return_value = True + self.manager._store.remove.return_value = False + + self.manager.forget_connection("SavedNet", block=True) + self.manager.process_callbacks() + + forgotten.assert_not_called() + forget_failed.assert_called_once_with("SavedNet") + + def test_forget_failure_preserves_connected_station_state(self): + self.manager._set_connecting("TestNet") + self.manager._handle_connected("TestNet") + complete_station_connection(self.manager, "TestNet") + epoch = self.manager._user_epoch + operation = self.manager._station_operation + self.manager._store.contains.return_value = True + self.manager._store.remove.return_value = False + self.manager._dhcp.stop.reset_mock() + self.manager._dhcp.clear_ipv6_state.reset_mock() + + self.manager.forget_connection("TestNet", block=True) + + assert self.manager.wifi_state == WifiState("TestNet", ConnectStatus.CONNECTED) + assert self.manager._user_epoch == epoch + assert self.manager._associated_ssid == "TestNet" + assert self.manager._associated_epoch == epoch + assert self.manager._station_operation is operation + assert not self.manager._station_cleanup_pending + self.manager._dhcp.stop.assert_not_called() + self.manager._dhcp.clear_ipv6_state.assert_not_called() + + def test_forget_failure_preserves_retained_reconnect_state(self): + self.manager._set_connecting("TestNet") + self.manager._handle_connected("TestNet") + complete_station_connection(self.manager, "TestNet") + self.manager._handle_event("CTRL-EVENT-DISCONNECTED reason=3") + epoch = self.manager._user_epoch + operation = self.manager._station_operation + self.manager._store.contains.return_value = True + self.manager._store.remove.return_value = False + self.manager._dhcp.stop.reset_mock() + self.manager._dhcp.clear_ipv6_state.reset_mock() + + self.manager.forget_connection("TestNet", block=True) + + assert self.manager.wifi_state == WifiState("TestNet", ConnectStatus.CONNECTING) + assert self.manager._user_epoch == epoch + assert self.manager._associated_ssid == "TestNet" + assert self.manager._associated_epoch == epoch + assert self.manager._dhcp_adoption_ssid == "TestNet" + assert self.manager._station_operation is operation + assert not self.manager._station_cleanup_pending + self.manager._dhcp.stop.assert_not_called() + self.manager._dhcp.clear_ipv6_state.assert_not_called() + + def test_failed_connect_worker_does_not_reset_fresh_selection(self): + real_thread = threading.Thread + worker_threads = [] + + class CapturingThread: + def __init__(self, *args, **kwargs): + self.thread = real_thread(*args, **kwargs) + worker_threads.append(self.thread) + + def start(self): + self.thread.start() + + def fail_after_fresh_selection(*_, **__): + self.manager._set_connecting("NextNet") + self.manager._set_pending_connection("NextNet", "new-password", False, SecurityType.WPA) + raise OSError("stale request failed") + + with ( + patch.object(wifi_manager_module.threading, "Thread", CapturingThread), + patch.object(self.manager, "_list_network_ids", return_value=[]), + patch.object(self.manager, "_remove_wpa_network"), + patch.object(self.manager, "_add_and_select_network", side_effect=fail_after_fresh_selection), + ): + self.manager.connect_to_network("OldNet", "old-password") + worker_threads[0].join() + + assert self.manager.wifi_state == WifiState("NextNet", ConnectStatus.CONNECTING) + assert self.manager._pending_connection is not None + assert self.manager._pending_connection.ssid == "NextNet" + + def test_failed_activate_worker_does_not_reset_fresh_selection(self): + def fail_after_fresh_selection(*_): + self.manager._set_connecting("NextNet") + raise OSError("stale request failed") + + with patch.object(self.manager, "_list_network_ids", side_effect=fail_after_fresh_selection): + self.manager.activate_connection("OldNet", block=True) + + assert self.manager.wifi_state == WifiState("NextNet", ConnectStatus.CONNECTING) + + def test_failed_connect_restores_previous_selection_exactly(self): + class ImmediateThread: + def __init__(self, target, **_): + self._target = target + + def start(self): + self._target() + + def request(command): + if command == "LIST_NETWORKS": + return "network id / ssid / bssid / flags\n1\tTestNet\tany\t\n" + if command == "ADD_NETWORK": + return "2" + if command == "REMOVE_NETWORK 1": + return "FAIL" + return "OK" + + self.manager._ctrl.request.side_effect = request + with patch.object(wifi_manager_module.threading, "Thread", ImmediateThread): + self.manager.connect_to_network("TestNet", "password123") + + requests = self.manager._ctrl.request.call_args_list + assert call("REMOVE_NETWORK 2") in requests + assert call("ENABLE_NETWORK 1") in requests + assert call("REASSOCIATE") in requests + assert call("ENABLE_NETWORK all") not in requests + assert self.manager.wifi_state == WifiState() + + def test_failed_replacement_removes_selected_network_before_restoring_profiles(self): + class ImmediateThread: + def __init__(self, target, **_): + self._target = target + + def start(self): + self._target() + + def request(command): + if command == "LIST_NETWORKS": + return "network id / ssid / bssid / flags\n1\tTestNet\tany\t\n2\tTestNet\tany\t\n" + if command == "ADD_NETWORK": + return "3" + if command == "REMOVE_NETWORK 1": + return "FAIL" + return "OK" + + self.manager._ctrl.request.side_effect = request + with patch.object(wifi_manager_module.threading, "Thread", ImmediateThread): + self.manager.connect_to_network("TestNet", "password123") + + requests = self.manager._ctrl.request.call_args_list + assert call("REMOVE_NETWORK 3") in requests + assert call("DISABLE_NETWORK all") in requests + assert call("ENABLE_NETWORK 1") in requests + assert call("ENABLE_NETWORK 2") in requests + assert call("REASSOCIATE") in requests + assert call("ENABLE_NETWORK all") not in requests + + def test_failed_second_old_id_removal_reconfigures_durable_profiles(self): + class ImmediateThread: + def __init__(self, target, **_): + self._target = target + + def start(self): + self._target() + + def request(command): + if command == "LIST_NETWORKS": + return "network id / ssid / bssid / flags\n1\tTestNet\tany\t\n2\tTestNet\tany\t\n" + if command == "ADD_NETWORK": + return "3" + if command == "REMOVE_NETWORK 2": + return "FAIL" + return "OK" + + self.manager._ctrl.request.side_effect = request + with patch.object(wifi_manager_module.threading, "Thread", ImmediateThread): + self.manager.connect_to_network("TestNet", "password123") + + requests = self.manager._ctrl.request.call_args_list + assert call("REMOVE_NETWORK 1") in requests + assert call("REMOVE_NETWORK 3") in requests + assert call("RECONFIGURE") in requests + assert call("ENABLE_NETWORK all") not in requests + + def test_saved_replacement_timeout_removes_exact_pending_network(self): + self.manager._store.contains.return_value = True + self.manager._set_connecting("TestNet") + self.manager._set_pending_connection("TestNet", "new-password", False, SecurityType.WPA) + epoch = self.manager._user_epoch + self.manager._set_pending_network_id("7", epoch) + self.manager._ctrl.request.side_effect = lambda command: "wpa_state=DISCONNECTED\n" if command == "STATUS" else "OK" + self.manager._last_connecting_at = time.monotonic() - CONNECTING_STALE_TIMEOUT_SECONDS - 1 + + self.manager._reconcile_connecting_state() + + assert call("REMOVE_NETWORK 7") in self.manager._ctrl.request.call_args_list + assert call("RECONFIGURE") in self.manager._ctrl.request.call_args_list + assert call("ENABLE_NETWORK all") not in self.manager._ctrl.request.call_args_list + + + def test_failed_activate_reconfigures_saved_networks(self): + self.manager._ctrl.request.side_effect = lambda command: "FAIL" if command == "REASSOCIATE" else "OK" + with patch.object(self.manager, "_list_network_ids", return_value=["1"]): + self.manager.activate_connection("TestNet", block=True) + + assert call("RECONFIGURE") in self.manager._ctrl.request.call_args_list + assert call("ENABLE_NETWORK all") not in self.manager._ctrl.request.call_args_list + assert self.manager.wifi_state == WifiState() + + def test_request_error_invalidates_control_socket(self): + self.manager._ctrl.request.side_effect = OSError("socket closed") + epoch = self.manager._monitor_epoch + + with self.assertRaises(OSError): + self.manager._request("SCAN") + + assert self.manager._ctrl is None + assert self.manager._monitor_epoch == epoch + 1 + + +class TestStartupAdoption(TestCase): + def setUp(self): + self.manager = build_wifi_manager() + + def test_station_dhcp_adoption(self): + cases = ( + ("connected", "TestNet", True, True, False, False), + ("missing-client", "TestNet", False, True, True, False), + ("reconnecting", "TestNet", True, True, False, False), + ("different-network", "PreviousNet", False, False, True, True), ) - wm._init_wifi_state = mock - return mock - - def test_activate_dbus_error_resyncs(self, mocker): - """ActivateConnection returns DBus error while A is connected. - NM rejects the request — no state signals emitted. Worker must re-read NM - state to discover A is still connected, not clear to DISCONNECTED. - """ - wm = _make_wm(mocker, connections={"A": "/path/A", "B": "/path/B"}) - wm._wifi_device = "/dev/wifi0" - wm._nm = mocker.MagicMock() - wm._wifi_state = WifiState(ssid="A", status=ConnectStatus.CONNECTED) - wm._router_main = mocker.MagicMock() - - error_reply = mocker.MagicMock() - error_reply.header.message_type = MessageType.error - wm._router_main.send_and_get_reply.return_value = error_reply - - mock_init = self._mock_init_restores(wm, mocker, "A", ConnectStatus.CONNECTED) - - wm.activate_connection("B", block=True) - - mock_init.assert_called_once() - assert wm._wifi_state.ssid == "A" - assert wm._wifi_state.status == ConnectStatus.CONNECTED - - def test_connect_to_network_dbus_error_resyncs(self, mocker): - """AddAndActivateConnection2 returns DBus error while A is connected.""" - wm = _make_wm(mocker, connections={"A": "/path/A"}) - wm._wifi_device = "/dev/wifi0" - wm._nm = mocker.MagicMock() - wm._wifi_state = WifiState(ssid="A", status=ConnectStatus.CONNECTED) - wm._router_main = mocker.MagicMock() - wm._forgotten = [] - - error_reply = mocker.MagicMock() - error_reply.header.message_type = MessageType.error - wm._router_main.send_and_get_reply.return_value = error_reply - - mock_init = self._mock_init_restores(wm, mocker, "A", ConnectStatus.CONNECTED) - - # Run worker thread synchronously - workers = [] - mocker.patch('openpilot.system.ui.lib.wifi_manager.threading.Thread', - side_effect=lambda target, **kw: type('T', (), {'start': lambda self: workers.append(target)})()) - - wm.connect_to_network("B", "password123") - workers[-1]() - - mock_init.assert_called_once() - assert wm._wifi_state.ssid == "A" - assert wm._wifi_state.status == ConnectStatus.CONNECTED + for state, adoption_ssid, adoption_result, expect_adopt, expect_start, expect_clear_ipv6 in cases: + with self.subTest(state=state): + manager = build_wifi_manager() + manager._dhcp_adoption_ssid = adoption_ssid + manager._dhcp.adopt.return_value = adoption_result + if state in ("reconnecting", "different-network"): + manager._ctrl.request.side_effect = ( + "wpa_state=ASSOCIATING\nmode=station\nssid=TestNet\n", + "wpa_state=COMPLETED\nmode=station\nssid=TestNet\n", + "OK", + ) + else: + manager._ctrl.request.return_value = "wpa_state=COMPLETED\nmode=station\nssid=TestNet\n" + + manager._init_wifi_state() + if state in ("reconnecting", "different-network"): + manager._handle_event("CTRL-EVENT-CONNECTED") + + complete_station_connection(manager, "TestNet") + assert manager.wifi_state == WifiState("TestNet", ConnectStatus.CONNECTED) + assert bool(manager._dhcp.adopt.call_count) == expect_adopt + assert bool(manager._dhcp.start.call_count) == expect_start + assert bool(manager._dhcp.clear_ipv6_state.call_count) == expect_clear_ipv6 + + def test_mid_association_adoption_starts_fresh_timeout(self): + self.manager._dhcp_adoption_ssid = "TestNet" + self.manager._last_connecting_at = 0.0 + self.manager._ctrl.request.return_value = "wpa_state=ASSOCIATING\nmode=station\nssid=TestNet\n" + + with patch.object(wifi_manager_module.time, "monotonic", return_value=100.0): + self.manager._init_wifi_state() + + assert self.manager.wifi_state == WifiState("TestNet", ConnectStatus.CONNECTING) + assert self.manager._last_connecting_at == 100.0 + assert self.manager._dhcp_adoption_ssid == "TestNet" + + self.manager._ctrl.request.reset_mock() + with patch.object(wifi_manager_module.time, "monotonic", return_value=104.0): + self.manager._reconcile_connecting_state() + + self.manager._ctrl.request.assert_not_called() + assert self.manager.wifi_state == WifiState("TestNet", ConnectStatus.CONNECTING) + + def test_disconnected_startup_cleans_station_state(self): + self.manager._dhcp_adoption_ssid = "TestNet" + self.manager._ctrl.request.return_value = "wpa_state=DISCONNECTED\nmode=station\n" + + self.manager._init_wifi_state() + + assert self.manager.wifi_state == WifiState() + assert self.manager._dhcp_adoption_ssid is None + self.manager._dhcp.stop.assert_called_once() + self.manager._dhcp.clear_ipv6_state.assert_called_once() + + def test_hotspot_adopts_with_dhcp_and_nat(self): + self.manager._ctrl.request.return_value = "wpa_state=COMPLETED\nmode=AP\nssid=Hotspot\n" + + with ( + patch.object(wifi_manager_module, "tethering_dnsmasq_running", return_value=True), + patch.object(wifi_manager_module, "_tethering_firewall_ready", return_value=True), + patch("builtins.open", mock_open(read_data=' psk="hotspot-password"\n')), + ): + self.manager._init_wifi_state() + + assert self.manager.is_tethering_active() + assert self.manager.wifi_state == WifiState("Hotspot", ConnectStatus.CONNECTED) + assert self.manager.ipv4_address == "192.168.43.1" + self.manager._dhcp.start.assert_not_called() + + def test_hotspot_adoption_notifies_callback_registered_after_startup(self): + with ( + patch.object(wifi_manager_module, "tethering_dnsmasq_running", return_value=True), + patch.object(wifi_manager_module, "_tethering_firewall_ready", return_value=True), + patch("builtins.open", mock_open(read_data=' psk="hotspot-password"\n')), + ): + assert self.manager._adopt_ap_state("Hotspot") + + activated = MagicMock() + self.manager.add_callbacks(activated=activated) + self.manager.process_callbacks() + + activated.assert_called_once() + + def test_hotspot_adoption_preserves_forwarding_until_policy_is_known(self): + self.manager._ipv4_forward = None + self.manager._apply_ipv4_forward.reset_mock() + + with ( + patch.object(wifi_manager_module, "tethering_dnsmasq_running", return_value=True), + patch.object(wifi_manager_module, "_tethering_firewall_ready", return_value=True), + patch("builtins.open", mock_open(read_data=' psk="hotspot-password"\n')), + ): + assert self.manager._adopt_ap_state("Hotspot") + + self.manager._apply_ipv4_forward.assert_not_called() + + self.manager.set_ipv4_forward(True) + + self.manager._apply_ipv4_forward.assert_called_once_with(True) + + def test_hotspot_password_mismatch_rebuilds_ap(self): + with ( + patch.object(wifi_manager_module, "tethering_dnsmasq_running", return_value=True), + patch.object(wifi_manager_module, "_tethering_firewall_ready", return_value=True), + patch("builtins.open", mock_open(read_data=' psk="old-password"\n')), + patch.object(self.manager, "_start_tethering") as start_tethering, + ): + assert self.manager._adopt_ap_state("Hotspot") + + start_tethering.assert_called_once() + assert self.manager.is_tethering_active() + + def test_incomplete_hotspot_is_removed(self): + for dnsmasq_running, nat_ready in ((False, True), (True, False)): + with self.subTest(dnsmasq_running=dnsmasq_running, nat_ready=nat_ready): + manager = build_wifi_manager() + manager._ctrl.request.return_value = "wpa_state=COMPLETED\nmode=AP\nssid=Hotspot\n" + with ( + patch.object(wifi_manager_module, "tethering_dnsmasq_running", return_value=dnsmasq_running), + patch.object(wifi_manager_module, "_tethering_firewall_ready", return_value=nat_ready), + patch.object(manager, "_stop_tethering") as stop_tethering, + ): + manager._init_wifi_state() + + assert manager.wifi_state == WifiState() + assert not manager.is_tethering_active() + stop_tethering.assert_called_once() + + def test_reconcile_adopts_missed_connection(self): + self.manager._ctrl.request.return_value = "wpa_state=COMPLETED\nmode=station\nssid=TestNet\n" + + self.manager._reconcile_connecting_state() + + complete_station_connection(self.manager, "TestNet") + assert self.manager.wifi_state == WifiState("TestNet", ConnectStatus.CONNECTED) + self.manager._dhcp.start.assert_called_once() + + def test_reconcile_stops_hotspot_without_responsive_control_socket(self): + for ctrl in ("unresponsive", None): + with self.subTest(ctrl=ctrl): + manager = build_wifi_manager() + manager._tethering_active = True + manager._wifi_state = WifiState("Hotspot", ConnectStatus.CONNECTED) + if ctrl is None: + manager._ctrl = None + else: + manager._ctrl.request.side_effect = OSError("socket closed") + + with patch.object(manager, "_stop_tethering") as stop_tethering: + manager._reconcile_connecting_state() + + stop_tethering.assert_called_once() + + def test_reconcile_keeps_healthy_hotspot(self): + self.manager._tethering_active = True + self.manager._wifi_state = WifiState("Hotspot", ConnectStatus.CONNECTED) + self.manager._ctrl.request.return_value = "wpa_state=COMPLETED\nmode=AP\nssid=Hotspot\n" + + with ( + patch.object(wifi_manager_module, "tethering_dnsmasq_running", return_value=True), + patch.object(wifi_manager_module, "_tethering_firewall_ready", return_value=True), + patch.object(self.manager, "_stop_tethering") as stop_tethering, + ): + self.manager._reconcile_connecting_state() + + stop_tethering.assert_not_called() + + def test_reconcile_stops_hotspot_without_nat(self): + self.manager._tethering_active = True + self.manager._wifi_state = WifiState("Hotspot", ConnectStatus.CONNECTED) + self.manager._ctrl.request.return_value = "wpa_state=COMPLETED\nmode=AP\nssid=Hotspot\n" + + with ( + patch.object(wifi_manager_module, "tethering_dnsmasq_running", return_value=True), + patch.object(wifi_manager_module.subprocess, "run", return_value=MagicMock(returncode=1)), + patch.object(self.manager, "_stop_tethering") as stop_tethering, + ): + self.manager._reconcile_connecting_state() + + stop_tethering.assert_called_once() + + def test_reconcile_clears_state_when_hotspot_cleanup_fails(self): + self.manager._tethering_active = True + self.manager._wifi_state = WifiState("Hotspot", ConnectStatus.CONNECTED) + self.manager._ctrl.request.side_effect = OSError("socket closed") + disconnected = MagicMock() + self.manager.add_callbacks(disconnected=disconnected) + + with patch.object(self.manager, "_stop_tethering", side_effect=OSError("cleanup failed")): + self.manager._reconcile_connecting_state() + + self.manager.process_callbacks() + assert not self.manager.is_tethering_active() + assert self.manager.wifi_state == WifiState() + disconnected.assert_called_once() + + +class TestLifecycle(TestCase): + def test_manager_starts_inactive_until_ui_is_shown(self): + with ( + patch.object(wifi_manager_module, "NetworkStore") as network_store, + patch.object(wifi_manager_module, "DhcpClient"), + patch.object(wifi_manager_module, "Params", None), + patch.object(WifiManager, "_initialize"), + ): + manager = WifiManager() + + assert not manager._active + assert manager._store is None + assert manager._ipv4_forward is None + network_store.assert_not_called() + + def test_initialization_loads_network_store_in_worker(self): + manager = build_wifi_manager() + manager._store = None + manager._scan_thread = MagicMock() + manager._state_thread = MagicMock() + store = MagicMock() + store.get_tethering_password.return_value = "custom-password" + + with ( + patch.object(wifi_manager_module, "NetworkStore", return_value=store) as network_store, + patch("builtins.open", side_effect=FileNotFoundError), + patch.object(wifi_manager_module, "generate_wpa_conf"), + patch.object(manager, "_ensure_wpa_supplicant"), + patch.object(manager, "_update_networks"), + patch.object(manager, "_init_wifi_state"), + patch.object(wifi_manager_module.threading, "Thread") as thread, + ): + manager._initialize() + thread.call_args.kwargs["target"]() + + network_store.assert_called_once() + assert manager._store is store + assert manager.tethering_password == "custom-password" + store.ensure_tethering_profile.assert_called_once_with("Hotspot", "custom-password") + + def test_initial_config_failure_recovers_without_restart(self): + manager = build_wifi_manager() + manager._tethering_ssid = "weedle" + manager._scan_thread = MagicMock() + manager._state_thread = MagicMock() + ctrl = MagicMock() + + with ( + patch("builtins.open", side_effect=FileNotFoundError), + patch.object(wifi_manager_module, "generate_wpa_conf", side_effect=[OSError("read-only"), None]) as generate, + patch.object(wifi_manager_module, "wpa_supplicant_running", return_value=False), + patch.object(wifi_manager_module, "ensure_wpa_supplicant", return_value=ctrl) as ensure, + patch.object(wifi_manager_module.threading, "Thread") as thread, + ): + manager._initialize() + thread.call_args.kwargs["target"]() + manager._ensure_wpa_supplicant() + + manager._scan_thread.start.assert_called_once() + manager._state_thread.start.assert_called_once() + assert generate.call_count == 2 + ensure.assert_called_once() + assert manager._ctrl is ctrl + + def test_station_recovery_cleans_abandoned_ap_services(self): + manager = build_wifi_manager() + ctrl = MagicMock() + + def ensure(_should_exit, _station_reconfigured, on_abandoned_ap): + on_abandoned_ap() + return ctrl + + with ( + patch.object(wifi_manager_module, "wpa_supplicant_running", return_value=True), + patch.object(wifi_manager_module, "ensure_wpa_supplicant", side_effect=ensure), + patch.object(wifi_manager_module, "stop_tethering_dnsmasq") as stop_dnsmasq, + patch.object(wifi_manager_module, "_delete_tethering_firewall_rules") as delete_firewall, + patch.object(wifi_manager_module.subprocess, "run"), + ): + manager._ensure_wpa_supplicant() + + manager._apply_ipv4_forward.assert_called_once_with(False) + stop_dnsmasq.assert_called_once() + delete_firewall.assert_called_once() + assert manager._ctrl is ctrl + + def test_hidden_manager_reconciles_without_scanning(self): + manager = build_wifi_manager() + manager._exit = False + manager._active = False + manager._last_network_scan = 0.0 + + with ( + patch.object(manager, "_reconcile_connecting_state") as reconcile, + patch.object(manager, "_request_scan") as request_scan, + patch.object(wifi_manager_module.time, "sleep", side_effect=lambda _: setattr(manager, "_exit", True)), + ): + manager._network_scanner() + + reconcile.assert_called_once() + request_scan.assert_not_called() + + def test_failed_station_bringup_uses_scan_period_retry(self): + manager = build_wifi_manager() + manager._exit = False + manager._ctrl = None + sleeps = [] + + def wait(duration): + sleeps.append(duration) + manager._exit = True + + with ( + patch.object(wifi_manager_module, "wpa_supplicant_running", return_value=False), + patch.object(manager, "_ensure_wpa_supplicant"), + patch.object(manager._exit_event, "wait", side_effect=wait), + ): + manager._monitor_state() + + assert sleeps == [SCAN_PERIOD_SECONDS] + + def test_monitor_exit_skips_retry_sleep(self): + manager = build_wifi_manager() + manager._exit = False + monitor = MagicMock() + monitor.recv.side_effect = lambda **_: setattr(manager, "_exit", True) + + with ( + patch.object(wifi_manager_module, "WpaCtrlMonitor", return_value=monitor), + patch.object(wifi_manager_module.time, "sleep") as sleep, + ): + manager._monitor_state() + + sleep.assert_not_called() + + def test_disconnected_reconciliation_is_rate_limited(self): + manager = build_wifi_manager() + manager._wifi_state = WifiState() + manager._last_connected_recheck = 0.0 + manager._ctrl.request.return_value = "wpa_state=DISCONNECTED\n" + + with patch.object(wifi_manager_module.time, "monotonic", return_value=100.0): + manager._reconcile_connecting_state() + manager._reconcile_connecting_state() + + manager._ctrl.request.assert_called_once_with("STATUS") + + def test_activating_manager_refreshes_state_and_networks(self): + manager = build_wifi_manager() + manager._active = False + + with ( + patch.object(manager, "_init_wifi_state") as init_wifi_state, + patch.object(manager, "_update_networks") as update_networks, + ): + manager.set_active(True) + + assert manager._active + init_wifi_state.assert_called_once_with(block=False) + update_networks.assert_called_once_with(block=False) + + def test_stop_leaves_network_data_plane_running(self): + manager = build_wifi_manager() + manager._exit = False + manager._tethering_active = True + ctrl = manager._ctrl + + with patch.object(manager, "_stop_tethering") as stop_tethering: + manager.stop() + + assert manager._exit + assert manager._exit_event.is_set() + assert ctrl is not None + ctrl.interrupt.assert_called_once() + ctrl.close.assert_called_once() + manager._dhcp.stop.assert_not_called() + stop_tethering.assert_not_called() + + def test_stop_interrupts_control_request_before_join(self): + manager = build_wifi_manager() + manager._exit = False + request_started = threading.Event() + interrupted = threading.Event() + request_finished = threading.Event() + closed = threading.Event() + + class BlockingCtrl: + def request(self, _command): + request_started.set() + interrupted.wait(0.2) + request_finished.set() + + def interrupt(self): + interrupted.set() + + def close(self): + closed.set() + + manager._ctrl = cast(wifi_manager_module.WpaCtrl, BlockingCtrl()) + manager._scan_thread = threading.Thread(target=manager._ctrl.request, args=("STATUS",)) + manager._state_thread = MagicMock() + manager._state_thread.is_alive.return_value = False + manager._scan_thread.start() + assert request_started.wait(1) + + manager.stop() + + assert interrupted.is_set() + assert request_finished.is_set() + assert closed.is_set() + assert not manager._scan_thread.is_alive() + + def test_callbacks_coalesce_network_updates(self): + manager = build_wifi_manager() + updated = MagicMock() + manager.add_callbacks(networks_updated=updated) + + for _ in range(100): + manager._mark_networks_updated() + manager.process_callbacks() + + updated.assert_called_once_with(manager.networks) + + +class TestTetheringTransitions(TestCase): + def test_station_transition_does_not_overlap_tethering_start(self): + manager = build_wifi_manager() + station_entered = threading.Event() + release_station = threading.Event() + tethering_entered = threading.Event() + + def add_station_network(*_args, **_kwargs): + station_entered.set() + assert release_station.wait(1) + return "1" + + with ( + patch.object(manager, "_list_network_ids", return_value=[]), + patch.object(manager, "_add_and_select_network", side_effect=add_station_network), + patch.object(manager, "_start_tethering", side_effect=lambda: tethering_entered.set()), + ): + manager.connect_to_network("Station", "station-password") + assert station_entered.wait(1) + + manager.set_tethering_active(True) + try: + assert not tethering_entered.wait(0.1) + finally: + release_station.set() + assert tethering_entered.wait(1) + + def test_hotspot_adoption_does_not_overlap_tethering_transition(self): + manager = build_wifi_manager() + start_entered = threading.Event() + release_start = threading.Event() + adoption_entered = threading.Event() + starts = 0 + + def start_tethering(): + nonlocal starts + starts += 1 + if starts == 1: + start_entered.set() + assert release_start.wait(1) + else: + adoption_entered.set() + + with ( + patch.object(manager, "_start_tethering", side_effect=start_tethering), + patch.object(manager, "_ap_config_matches_password", return_value=False), + patch.object(wifi_manager_module, "tethering_dnsmasq_running", return_value=True), + patch.object(wifi_manager_module, "_tethering_firewall_ready", return_value=True), + ): + manager.set_tethering_active(True) + assert start_entered.wait(1) + + adoption = threading.Thread(target=manager._adopt_ap_state, args=("Hotspot",)) + adoption.start() + try: + assert not adoption_entered.wait(0.1) + finally: + release_start.set() + + adoption.join(1) + assert not adoption.is_alive() + assert adoption_entered.is_set() + + def test_reconcile_waits_for_pending_tethering_start(self): + manager = build_wifi_manager() + manager._last_connected_recheck = 0.0 + + with ( + patch.object(manager, "_start_tethering") as start_tethering, + patch.object(manager, "_stop_tethering") as stop_tethering, + patch.object(wifi_manager_module.threading, "Thread") as thread, + ): + manager.set_tethering_active(True) + manager._reconcile_tethering_state() + + stop_tethering.assert_not_called() + thread.call_args.kwargs["target"]() + + start_tethering.assert_called_once() + assert manager.is_tethering_active() + + def test_startup_station_bringup_does_not_overlap_tethering(self): + manager = build_wifi_manager() + manager._tethering_ssid = "weedle" + station_entered = threading.Event() + release_station = threading.Event() + tethering_entered = threading.Event() + + def ensure_station(): + station_entered.set() + assert release_station.wait(1) + + with ( + patch.object(wifi_manager_module, "NetworkStore", return_value=manager._store), + patch("builtins.open", side_effect=FileNotFoundError), + patch.object(wifi_manager_module, "generate_wpa_conf"), + patch.object(manager, "_ensure_wpa_supplicant", side_effect=ensure_station), + patch.object(manager, "_update_networks"), + patch.object(manager, "_init_wifi_state"), + patch.object(manager, "_start_tethering", side_effect=lambda: tethering_entered.set()), + ): + with patch.object(wifi_manager_module.threading, "Thread") as thread: + manager._initialize() + initialize = thread.call_args.kwargs["target"] + + initialize_thread = threading.Thread(target=initialize) + initialize_thread.start() + assert station_entered.wait(1) + + manager.set_tethering_active(True) + try: + assert not tethering_entered.wait(0.1) + finally: + release_station.set() + + initialize_thread.join(1) + assert not initialize_thread.is_alive() + assert tethering_entered.wait(1) + + def test_tethering_transitions_do_not_overlap(self): + manager = build_wifi_manager() + start_entered = threading.Event() + release_start = threading.Event() + stop_entered = threading.Event() + + def start_tethering(): + manager._tethering_started = True + start_entered.set() + assert release_start.wait(1) + + with ( + patch.object(manager, "_start_tethering", side_effect=start_tethering), + patch.object(manager, "_stop_tethering", side_effect=lambda: stop_entered.set()), + ): + manager.set_tethering_active(True) + assert start_entered.wait(1) + + manager.set_tethering_active(False) + try: + assert not stop_entered.wait(0.1) + finally: + release_start.set() + assert stop_entered.wait(1) + + def test_latest_tethering_request_wins(self): + manager = build_wifi_manager() + manager._wifi_state = WifiState("Station", ConnectStatus.CONNECTED) + manager._ipv4_address = "192.168.1.20" + station_ctrl = manager._ctrl + transitions = [] + disconnected = MagicMock() + manager.add_callbacks(disconnected=disconnected) + + def start_tethering(): + transitions.append(True) + manager._tethering_active = True + + def stop_tethering(): + transitions.append(False) + manager._tethering_active = False + + with ( + patch.object(manager, "_start_tethering", side_effect=start_tethering), + patch.object(manager, "_stop_tethering", side_effect=stop_tethering), + patch.object(wifi_manager_module.threading, "Thread") as thread, + ): + manager.set_tethering_active(True) + manager.set_tethering_active(False) + + workers = [item.kwargs["target"] for item in thread.call_args_list] + workers[1]() + workers[0]() + + manager.process_callbacks() + assert transitions == [] + assert not manager.is_tethering_active() + assert manager.wifi_state == WifiState("Station", ConnectStatus.CONNECTED) + assert manager.ipv4_address == "192.168.1.20" + assert manager._ctrl is station_ctrl + disconnected.assert_called_once() + + def test_failed_tethering_stop_notifies_disconnected(self): + manager = build_wifi_manager() + manager._tethering_active = True + manager._tethering_started = True + disconnected = MagicMock() + manager.add_callbacks(disconnected=disconnected) + + with ( + patch.object(manager, "_stop_tethering", side_effect=OSError("cleanup failed")), + patch.object(wifi_manager_module.threading, "Thread") as thread, + ): + manager.set_tethering_active(False) + thread.call_args.kwargs["target"]() + + manager.process_callbacks() + assert not manager.is_tethering_active() + assert manager.wifi_state == WifiState() + assert manager.ipv4_address == "" + disconnected.assert_called_once() + + +class TestTetheringPassword(TestCase): + def test_latest_password_request_wins(self): + manager = build_wifi_manager() + manager._store.set_tethering_password.return_value = True + + with ( + patch.object(wifi_manager_module, "atomic_write") as legacy_write, + patch.object(wifi_manager_module.threading, "Thread") as thread, + ): + manager.set_tethering_password("first-password") + manager.set_tethering_password("second-password") + + workers = [item.kwargs["target"] for item in thread.call_args_list] + workers[1]() + workers[0]() + + assert manager.tethering_password == "second-password" + legacy_write.assert_not_called() + manager._store.set_tethering_password.assert_called_once_with("Hotspot", "second-password") + + def test_startup_prefers_keyfile_without_reading_legacy_password(self): + manager = build_wifi_manager() + manager._tethering_ssid = "weedle" + store = manager._store + assert store is not None + store.get_tethering_password.return_value = "custom-password" + manager._scan_thread = MagicMock() + manager._state_thread = MagicMock() + + with ( + patch.object(wifi_manager_module, "NetworkStore", return_value=store), + patch("builtins.open") as legacy_open, + patch.object(manager, "_ensure_wpa_supplicant"), + patch.object(manager, "_update_networks"), + patch.object(manager, "_init_wifi_state"), + patch.object(wifi_manager_module.threading, "Thread") as thread, + ): + manager._initialize() + thread.call_args.kwargs["target"]() + + assert manager.tethering_password == "custom-password" + legacy_open.assert_not_called() + + def test_startup_migrates_legacy_password_into_keyfile(self): + manager = build_wifi_manager() + manager._tethering_ssid = "weedle" + store = manager._store + assert store is not None + store.get_tethering_password.return_value = None + store.set_tethering_password.return_value = True + manager._scan_thread = MagicMock() + manager._state_thread = MagicMock() + + with ( + patch.object(wifi_manager_module, "NetworkStore", return_value=store), + patch("builtins.open", mock_open(read_data="legacy-password\n")), + patch.object(manager, "_ensure_wpa_supplicant"), + patch.object(manager, "_update_networks"), + patch.object(manager, "_init_wifi_state"), + patch.object(wifi_manager_module.threading, "Thread") as thread, + ): + manager._initialize() + thread.call_args.kwargs["target"]() + + assert manager.tethering_password == "legacy-password" + store.set_tethering_password.assert_called_once_with("weedle", "legacy-password") + + def test_persist_failure_reenables_active_tethering_controls(self): + manager = build_wifi_manager() + manager._tethering_active = True + manager._tethering_psk = "old-password" + activated = MagicMock() + manager.add_callbacks(activated=activated) + manager.process_callbacks() + activated.reset_mock() + + manager._store.set_tethering_password.return_value = False + with patch.object(wifi_manager_module.threading, "Thread") as thread: + manager.set_tethering_password("replacement-password") + thread.call_args.kwargs["target"]() + + manager.process_callbacks() + assert manager.tethering_password == "old-password" + activated.assert_called_once() + + def test_teardown_failure_reenables_active_tethering_controls(self): + manager = build_wifi_manager() + manager._tethering_active = True + manager._wifi_state = WifiState(manager._tethering_ssid, ConnectStatus.CONNECTED) + disconnected = MagicMock() + manager.add_callbacks(disconnected=disconnected) + manager._store.set_tethering_password.return_value = True + + with ( + patch.object(manager, "_stop_tethering", side_effect=OSError("cleanup failed")) as stop_tethering, + patch.object(manager, "_start_tethering") as start_tethering, + patch.object(wifi_manager_module.threading, "Thread") as thread, + ): + manager.set_tethering_password("replacement-password") + thread.call_args.kwargs["target"]() + + manager.process_callbacks() + assert manager.tethering_password == "replacement-password" + assert not manager.is_tethering_active() + assert manager.wifi_state == WifiState() + assert stop_tethering.call_count == 2 + start_tethering.assert_not_called() + disconnected.assert_called_once() + + def test_restart_failure_keeps_committed_password(self): + manager = build_wifi_manager() + manager._tethering_active = True + manager._wifi_state = WifiState(manager._tethering_ssid, ConnectStatus.CONNECTED) + manager._store.set_tethering_password.return_value = True + + def stop_tethering(): + manager._tethering_active = False + manager._wifi_state = WifiState() + + with ( + patch.object(manager, "_stop_tethering", side_effect=stop_tethering) as stop_tethering, + patch.object(manager, "_start_tethering", side_effect=OSError("restart failed")) as start_tethering, + patch.object(wifi_manager_module.threading, "Thread") as thread, + ): + manager.set_tethering_password("replacement-password") + thread.call_args.kwargs["target"]() + + assert manager.tethering_password == "replacement-password" + manager._store.set_tethering_password.assert_called_once_with("Hotspot", "replacement-password") + start_tethering.assert_called_once() + assert stop_tethering.call_count == 2 + assert not manager.is_tethering_active() diff --git a/openpilot/system/ui/lib/tests/test_network_store.py b/openpilot/system/ui/lib/tests/test_network_store.py new file mode 100644 index 00000000000000..6229468b8c8861 --- /dev/null +++ b/openpilot/system/ui/lib/tests/test_network_store.py @@ -0,0 +1,1444 @@ +import configparser +import os +import shutil +import tempfile +import threading +import uuid +from pathlib import Path +from unittest import TestCase +from unittest.mock import MagicMock, patch + +from openpilot.system.ui.lib import wifi_network_store as store_module +from openpilot.system.ui.lib.wifi_network_store import NetworkStore +from openpilot.system.ui.lib.wpa_ctrl import SecurityType, generate_wpa_conf + + +def profile_uuid(name: str) -> str: + return str(uuid.uuid5(uuid.NAMESPACE_DNS, name)) + + +def write_profile(directory: str, filename: str, ssid: str, *, + file_uuid: str | None = None, psk: str | None = "password123", + key_mgmt: str = "wpa-psk", mode: str = "infrastructure", + autoconnect: bool = True, autoconnect_priority: int = 0, + bssid: str | None = None, extra_wifi: str = "", + extra_security: str = "", extra_connection: str = "", valid_uuid: bool = True) -> str: + file_uuid = file_uuid or ssid + if valid_uuid: + file_uuid = profile_uuid(file_uuid) + security = "" + if psk is not None or extra_security: + security = f""" +[wifi-security] +key-mgmt={key_mgmt} +{f"psk={psk}" if psk is not None else ""} +{extra_security} +""" + content = f"""\ +[connection] +id={ssid} +uuid={file_uuid} +type=wifi +autoconnect={str(autoconnect).lower()} +autoconnect-priority={autoconnect_priority} +{extra_connection} + +[wifi] +ssid={ssid} +mode={mode} +{f"bssid={bssid}" if bssid is not None else ""} +{extra_wifi} +{security} +""" + path = os.path.join(directory, filename) + Path(path).write_text(content) + return path + + +def require_entry(store: NetworkStore, ssid: str) -> dict: + entry = store.get(ssid) + assert entry is not None + return entry + + +def write_netplan_profile(path: Path, file_uuid: str): + path.write_text(f"""\ +network: + version: 2 + wifis: + NM-{file_uuid}: + renderer: NetworkManager + match: {{}} + networkmanager: + uuid: "{file_uuid}" + name: "Test" +""") + + +class TestNetworkStore(TestCase): + def setUp(self): + self.root = tempfile.mkdtemp() + self.persistent = os.path.join(self.root, "persistent") + self.runtime = os.path.join(self.root, "runtime") + self.netplan = os.path.join(self.root, "netplan") + for directory in (self.persistent, self.runtime, self.netplan): + os.mkdir(directory) + + def tearDown(self): + shutil.rmtree(self.root) + + def patch_reads(self): + return patch.object(store_module, "sudo_read", side_effect=lambda path: Path(path).read_text()) + + def make_store(self) -> NetworkStore: + return NetworkStore(self.persistent, self.runtime, self.netplan) + + def run_file_command(self, command, **_): + if command[:2] == ["sudo", "install"] and "-d" not in command: + shutil.copyfile(command[-2], command[-1]) + elif command[:3] == ["sudo", "mv", "-f"]: + Path(command[-2]).replace(command[-1]) + elif command[:3] == ["sudo", "rm", "-f"]: + Path(command[-1]).unlink(missing_ok=True) + return MagicMock(returncode=0) + + def test_startup_restores_uncommitted_update_when_original_exists(self): + original = Path(write_profile( + self.persistent, "saved.nmconnection", "Saved", file_uuid="saved", psk="new-password", + )) + remnant = Path(write_profile( + self.persistent, + f"{original.name}.openpilot-update-{'a' * 32}", + "Saved", + file_uuid="saved", + psk="old-password", + )) + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + + assert original.exists() + assert not remnant.exists() + assert require_entry(store, "Saved")["psk"] == "old-password" + + def test_startup_rolls_back_partial_multi_profile_update(self): + token = "a" * 32 + first = Path(write_profile(self.persistent, "first.nmconnection", "Duplicate", file_uuid="first", psk="first-old")) + second = Path(write_profile(self.persistent, "second.nmconnection", "Duplicate", file_uuid="second", psk="second-old")) + for path in (first, second): + shutil.copyfile(path, f"{path}.openpilot-update-{token}") + write_profile(self.persistent, first.name, "Duplicate", file_uuid="first", psk="first-new") + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + + assert {entry["psk"] for ssid, entry in store.get_profiles() if ssid == "Duplicate"} == {"first-old", "second-old"} + assert not list(Path(self.persistent).glob("*.openpilot-update-*")) + + def test_startup_removes_created_path_from_uncommitted_update(self): + token = "a" * 32 + stored = Path(write_profile( + self.persistent, "saved.nmconnection", "Saved", file_uuid="saved", psk="old-password", + )) + backup = Path(f"{stored}.openpilot-update-{token}") + shutil.copyfile(stored, backup) + canonical = Path(write_profile( + self.persistent, f"{profile_uuid('saved')}-Saved.nmconnection", "Saved", file_uuid="saved", psk="new-password", + )) + created_marker = Path(f"{canonical}.openpilot-update-created-{token}") + created_marker.touch() + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + + assert stored.exists() + assert not backup.exists() + assert not canonical.exists() + assert not created_marker.exists() + assert require_entry(store, "Saved")["psk"] == "old-password" + + def test_startup_keeps_committed_update(self): + token = "a" * 32 + original = Path(write_profile( + self.persistent, "saved.nmconnection", "Saved", file_uuid="saved", psk="new-password", + )) + backup = Path(write_profile( + self.persistent, f"{original.name}.openpilot-update-{token}", "Saved", file_uuid="saved", psk="old-password", + )) + created = Path(write_profile( + self.persistent, f"{profile_uuid('created')}-Created.nmconnection", "Created", file_uuid="created", psk="created-password", + )) + created_marker = Path(f"{created}.openpilot-update-created-{token}") + created_marker.touch() + marker = Path(self.persistent, f".openpilot-update-committed-{token}") + marker.touch() + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + + assert original.exists() + assert not backup.exists() + assert created.exists() + assert not created_marker.exists() + assert not marker.exists() + assert require_entry(store, "Saved")["psk"] == "new-password" + assert require_entry(store, "Created")["psk"] == "created-password" + + def test_startup_rolls_back_interrupted_tethering_migration(self): + token = "a" * 32 + runtime = Path(write_profile( + self.runtime, "tether.nmconnection", "weedle", file_uuid="tether", psk="old-password", + )) + runtime_backup = Path(f"{runtime}.openpilot-update-{token}") + runtime.replace(runtime_backup) + persistent = Path(write_profile( + self.persistent, f"{profile_uuid('tether')}-weedle.nmconnection", "weedle", file_uuid="tether", psk="new-password", + )) + created_marker = Path(f"{persistent}.openpilot-update-created-{token}") + created_marker.touch() + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + + assert runtime.exists() + assert not runtime_backup.exists() + assert not persistent.exists() + assert not created_marker.exists() + assert require_entry(store, "weedle")["psk"] == "old-password" + + def test_startup_restores_interrupted_forget_stage(self): + original = Path(write_profile(self.persistent, "saved.nmconnection", "Saved", file_uuid="saved")) + remnant = Path(f"{original}.openpilot-forget-{'b' * 32}") + original.replace(remnant) + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + + assert original.exists() + assert not remnant.exists() + assert store.contains("Saved") + + def test_startup_deletes_committed_forget_stage(self): + original = Path(write_profile(self.persistent, "saved.nmconnection", "Saved", file_uuid="saved")) + token = "c" * 32 + remnant = Path(f"{original}.openpilot-forget-{token}") + marker = Path(self.persistent, f".openpilot-forget-committed-{token}") + original.replace(remnant) + marker.touch() + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + + assert not original.exists() + assert not remnant.exists() + assert not marker.exists() + assert not store.contains("Saved") + + def test_loads_persistent_and_open_profiles(self): + write_profile(self.persistent, "secure.nmconnection", "Secure") + write_profile(self.persistent, "open.nmconnection", "Open", psk=None) + + with self.patch_reads(): + store = self.make_store() + + assert require_entry(store, "Secure")["psk"] == "password123" + assert require_entry(store, "Open")["psk"] == "" + def test_explicit_open_security_ignores_stale_psk(self): + write_profile( + self.persistent, + "open.nmconnection", + "Open", + psk="stale-password", + key_mgmt="none", + ) + + with self.patch_reads(): + store = self.make_store() + + entry = require_entry(store, "Open") + assert entry["security"] == SecurityType.OPEN + assert entry["psk"] == "" + + + def test_loads_canonical_networkmanager_sections(self): + path = Path(write_profile(self.persistent, "canonical.nmconnection", "Canonical")) + raw = path.read_text().replace("[wifi]", "[802-11-wireless]").replace("[wifi-security]", "[802-11-wireless-security]") + path.write_text(raw) + + with self.patch_reads(): + store = self.make_store() + + assert require_entry(store, "Canonical")["psk"] == "password123" + + def test_loads_current_networkmanager_profile_defaults(self): + Path(self.persistent, "saved.nmconnection").write_text(f"""\ +[connection] +id=openpilot connection SavedNet +uuid={profile_uuid("SavedNet")} +type=wifi +autoconnect-retries=0 +timestamp=1775127802 + +[wifi] +mode=infrastructure +ssid=83;97;118;101;100;78;101;116; +hidden=false +mac-address-blacklist= + +[wifi-security] +auth-alg=open +key-mgmt=wpa-psk +psk=password123 + +[ipv4] +dns-priority=600 +dns-search= +method=auto + +[ipv6] +addr-gen-mode=default +dns-search= +method=ignore + +[proxy] +""") + + with self.patch_reads(): + store = self.make_store() + + assert require_entry(store, "SavedNet")["psk"] == "password123" + assert store.get_ipv6_method("SavedNet", profile_uuid("SavedNet")) == "ignore" + + def test_skips_profiles_with_invalid_uuids(self): + write_profile(self.persistent, "station.nmconnection", "Station", file_uuid="../../station", valid_uuid=False) + write_profile(self.runtime, "hotspot.nmconnection", "weedle", file_uuid="/tmp/hotspot", mode="ap", valid_uuid=False) + + with self.patch_reads(): + store = self.make_store() + + assert store.get("Station") is None + assert store.get_tethering_password("weedle") is None + + def test_enforces_connection_interface_constraint(self): + write_profile(self.persistent, "wlan0.nmconnection", "Wlan0", extra_connection="interface-name=wlan0") + write_profile(self.persistent, "wlan1.nmconnection", "Wlan1", extra_connection="interface-name=wlan1") + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + store.set_metered("Wlan0", 1) + + assert store.get("Wlan1") is None + raw = Path(self.persistent, f"{profile_uuid('Wlan0')}-Wlan0.nmconnection").read_text() + assert "interface-name = wlan0" in raw + + def test_skips_profiles_with_invalid_psks(self): + write_profile(self.persistent, "short.nmconnection", "Short", psk="short") + write_profile(self.persistent, "nonhex.nmconnection", "NonHex", psk="x" * 64) + write_profile(self.persistent, "oversized.nmconnection", "Oversized", psk="é" * 32) + write_profile(self.persistent, "raw.nmconnection", "Raw", psk="a" * 64) + + with self.patch_reads(): + store = self.make_store() + + assert store.get("Short") is None + assert store.get("NonHex") is None + assert store.get("Oversized") is None + assert require_entry(store, "Raw")["psk"] == "a" * 64 + + def test_enforces_ssid_byte_limit(self): + valid_ssid = "é" * 16 + oversized_ssid = "é" * 17 + write_profile(self.persistent, "valid.nmconnection", valid_ssid) + write_profile(self.persistent, "oversized.nmconnection", oversized_ssid) + + with self.patch_reads(): + store = self.make_store() + + assert require_entry(store, valid_ssid)["psk"] == "password123" + assert store.get(oversized_ssid) is None + + def test_loads_autoconnect_priority(self): + write_profile(self.persistent, "preferred.nmconnection", "Preferred", autoconnect_priority=42) + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + store.set_metered("Preferred", 1) + + assert require_entry(store, "Preferred")["priority"] == 42 + raw = Path(self.persistent, f"{profile_uuid('Preferred')}-Preferred.nmconnection").read_text() + assert "autoconnect-priority = 42" in raw + + def test_preserves_bssid_restriction(self): + write_profile(self.persistent, "pinned.nmconnection", "Pinned", bssid="00:11:22:33:44:55") + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + store.set_metered("Pinned", 1) + + assert require_entry(store, "Pinned")["bssid"] == "00:11:22:33:44:55" + raw = Path(self.persistent, f"{profile_uuid('Pinned')}-Pinned.nmconnection").read_text() + assert "bssid = 00:11:22:33:44:55" in raw + + def test_skips_profile_with_invalid_bssid(self): + write_profile(self.persistent, "valid.nmconnection", "Valid") + write_profile(self.persistent, "invalid.nmconnection", "Invalid", bssid="not-a-mac") + + with self.patch_reads(): + store = self.make_store() + + config_path = os.path.join(self.root, "wpa_supplicant.conf") + generate_wpa_conf(store, config_path) + + assert store.get("Invalid") is None + assert "ssid=56616c6964" in Path(config_path).read_text() + + def test_loads_printable_decimal_list_ssid(self): + path = write_profile(self.persistent, "decimal.nmconnection", "placeholder") + raw = Path(path).read_text().replace("ssid=placeholder", "ssid=65;66;67;") + Path(path).write_text(raw) + + with self.patch_reads(): + store = self.make_store() + + assert require_entry(store, "ABC")["psk"] == "password123" + + def test_preserves_non_utf8_decimal_list_ssid(self): + path = write_profile(self.persistent, "binary.nmconnection", "placeholder") + raw = Path(path).read_text().replace("ssid=placeholder", "ssid=255;65;") + Path(path).write_text(raw) + ssid = b"\xffA".decode("utf-8", errors="surrogateescape") + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + store.set_metered(ssid, 1) + config_path = os.path.join(self.root, "wpa_supplicant.conf") + generate_wpa_conf(store, config_path) + + assert require_entry(store, ssid)["psk"] == "password123" + assert "ssid=ff41" in Path(config_path).read_text() + keyfile = next(Path(self.persistent).glob("*.nmconnection")) + assert "ssid = 255;65;" in keyfile.read_text() + + def test_skips_profiles_that_cannot_be_reproduced_safely(self): + write_profile(self.persistent, "enterprise.nmconnection", "Enterprise", psk=None, key_mgmt="wpa-eap", extra_security="identity=user") + write_profile(self.persistent, "agent-secret.nmconnection", "AgentSecret", psk=None, extra_security="psk-flags=1") + write_profile(self.persistent, "wep.nmconnection", "Wep", psk=None, key_mgmt="none", extra_security="wep-key0=abcde") + write_profile(self.persistent, "disabled.nmconnection", "Disabled", autoconnect=False) + write_profile(self.persistent, "hotspot.nmconnection", "Hotspot", psk=None, mode="ap") + + with self.patch_reads(): + store = self.make_store() + + assert store.get_all() == {} + + def test_skips_non_infrastructure_profiles(self): + for mode in ("adhoc", "mesh"): + write_profile(self.persistent, f"{mode}.nmconnection", mode.title(), mode=mode) + + with self.patch_reads(): + store = self.make_store() + + assert store.get_all() == {} + + def test_skips_profile_with_unsupported_wifi_options(self): + write_profile( + self.persistent, + "randomized.nmconnection", + "Randomized", + extra_wifi="cloned-mac-address=stable", + ) + + with self.patch_reads(): + store = self.make_store() + + assert store.get("Randomized") is None + + def test_skips_profile_with_unsupported_security_constraints(self): + write_profile( + self.persistent, + "constrained.nmconnection", + "Constrained", + extra_security="pmf=3\nproto=rsn;\npairwise=ccmp;", + ) + + with self.patch_reads(): + store = self.make_store() + + assert store.get("Constrained") is None + + def test_reads_existing_tethering_password_without_importing_profile(self): + write_profile(self.persistent, "hotspot.nmconnection", "weedle", psk="custom-password", mode="ap") + + with self.patch_reads(): + store = self.make_store() + assert store.get_tethering_password("weedle") == "custom-password" + assert store.get("weedle") is None + + def test_tethering_password_creates_profile_in_empty_store(self): + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + assert store.set_tethering_password("weedle", "fresh-password") + + paths = list(Path(self.persistent).glob("*.nmconnection")) + assert len(paths) == 1 + cp = configparser.ConfigParser(interpolation=None) + cp.read(paths[0]) + assert cp.get("connection", "id") == "Hotspot" + assert cp.get("connection", "interface-name") == "wlan0" + assert not cp.getboolean("connection", "autoconnect") + assert cp.get("wifi", "ssid") == store_module._encode_keyfile_ssid("weedle") + assert cp.get("wifi", "mode") == "ap" + assert cp.get("wifi-security", "key-mgmt") == "wpa-psk" + assert cp.get("wifi-security", "psk") == "fresh-password" + assert cp.get("ipv4", "method") == "shared" + assert cp.get("ipv4", "address1") == "192.168.43.1/24" + assert cp.get("ipv6", "method") == "ignore" + assert store.get_tethering_password("weedle") == "fresh-password" + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + restarted = self.make_store() + + assert restarted.get_tethering_password("weedle") == "fresh-password" + + def test_updates_persistent_tethering_password(self): + path = Path(write_profile(self.persistent, "hotspot.nmconnection", "weedle", psk="old-password", mode="ap")) + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + assert store.set_tethering_password("weedle", "new-password") + + assert "psk = new-password" in path.read_text() + + def test_updates_every_persistent_tethering_profile(self): + paths = [ + Path(write_profile(self.persistent, "hotspot-a.nmconnection", "weedle", file_uuid="hotspot-a", psk="old-password", mode="ap")), + Path(write_profile(self.persistent, "hotspot-b.nmconnection", "weedle", file_uuid="hotspot-b", psk="old-password", mode="ap")), + ] + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + assert store.set_tethering_password("weedle", "new-password") + restarted = self.make_store() + + assert all("psk = new-password" in path.read_text() for path in paths) + assert restarted.get_tethering_password("weedle") == "new-password" + + def test_multi_profile_tethering_update_rolls_back_earlier_profiles(self): + first_path = Path(write_profile( + self.persistent, "hotspot-a.nmconnection", "weedle", file_uuid="hotspot-a", psk="first-password", mode="ap", + )) + second_path = Path(write_profile( + self.persistent, "hotspot-b.nmconnection", "weedle", file_uuid="hotspot-b", psk="second-password", mode="ap", + )) + originals = {path: path.read_text() for path in (first_path, second_path)} + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + install = store._install_keyfile + install_count = 0 + + def fail_second_install(cp, path): + nonlocal install_count + install_count += 1 + if install_count == 2: + raise OSError("write failed") + install(cp, path) + + with patch.object(store, "_install_keyfile", side_effect=fail_second_install), self.assertRaises(OSError): + store.set_tethering_password("weedle", "new-password") + + assert {path: path.read_text() for path in (first_path, second_path)} == originals + + def test_tethering_update_restores_existing_target_when_runtime_cleanup_fails(self): + shared_uuid = "shared-hotspot-uuid" + persistent_path = Path(write_profile( + self.persistent, "hotspot.nmconnection", "weedle", file_uuid=shared_uuid, psk="old-password", mode="ap", + )) + runtime_path = Path(write_profile( + self.runtime, "runtime-hotspot.nmconnection", "weedle", file_uuid=shared_uuid, psk="old-password", mode="ap", + )) + original = persistent_path.read_text() + + def run(command, **kwargs): + if command[:3] == ["sudo", "rm", "-f"] and command[-1] == str(runtime_path): + return MagicMock(returncode=1) + return self.run_file_command(command, **kwargs) + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=run): + store = self.make_store() + with self.assertRaises(OSError): + store.set_tethering_password("weedle", "new-password") + + assert persistent_path.read_text() == original + assert runtime_path.exists() + + def test_tethering_update_collapses_runtime_shadow_by_uuid(self): + shared_uuid = "shared-hotspot-uuid" + persistent_path = Path(write_profile( + self.persistent, "hotspot.nmconnection", "weedle", file_uuid=shared_uuid, psk="old-password", mode="ap", + )) + runtime_path = Path(write_profile( + self.runtime, "runtime-hotspot.nmconnection", "weedle", file_uuid=shared_uuid, psk="old-password", mode="ap", + )) + netplan_path = Path(self.netplan, f"90-NM-{profile_uuid(shared_uuid)}.yaml") + write_netplan_profile(netplan_path, profile_uuid(shared_uuid)) + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + with patch.object(store, "_install_keyfile", wraps=store._install_keyfile) as install: + assert store.set_tethering_password("weedle", "new-password") + + install.assert_called_once() + assert "psk = new-password" in persistent_path.read_text() + assert not runtime_path.exists() + assert not netplan_path.exists() + + def test_tethering_update_refuses_shared_netplan_source(self): + runtime_path = Path(write_profile( + self.runtime, "runtime-hotspot.nmconnection", "weedle", file_uuid="hotspot-uuid", psk="old-password", mode="ap", + )) + hotspot_uuid = profile_uuid("hotspot-uuid") + netplan_path = Path(self.netplan, f"90-NM-{hotspot_uuid}.yaml") + netplan_path.write_text(f"""\ +network: + version: 2 + wifis: + hotspot: + networkmanager: + uuid: {hotspot_uuid} + unrelated: + networkmanager: + uuid: {profile_uuid('other-hotspot-uuid')} +""") + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + assert not store.set_tethering_password("weedle", "new-password") + + assert runtime_path.exists() + assert netplan_path.exists() + assert not list(Path(self.persistent).glob("*.nmconnection")) + + def test_persists_runtime_tethering_profile_for_rollback(self): + runtime_path = Path(write_profile( + self.runtime, "netplan-hotspot.nmconnection", "weedle", file_uuid="hotspot-uuid", psk="old-password", mode="ap", + )) + hotspot_uuid = profile_uuid("hotspot-uuid") + netplan_path = Path(self.netplan, f"90-NM-{hotspot_uuid}.yaml") + write_netplan_profile(netplan_path, hotspot_uuid) + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + assert store.set_tethering_password("weedle", "new-password") + + persistent_path = Path(self.persistent, f"{hotspot_uuid}-weedle.nmconnection") + assert "psk = new-password" in persistent_path.read_text() + assert not runtime_path.exists() + assert not netplan_path.exists() + + def test_runtime_profiles_remain_live_sources_despite_stale_marker(self): + runtime_path = write_profile(self.runtime, "netplan.nmconnection", "Runtime") + Path(self.persistent, ".wpa_supplicant-import-complete").write_text("complete\n") + + with self.patch_reads(), patch.object(store_module.subprocess, "run") as run: + store = self.make_store() + + assert require_entry(store, "Runtime")["psk"] == "password123" + assert os.path.exists(runtime_path) + assert os.listdir(self.persistent) == [".wpa_supplicant-import-complete"] + run.assert_not_called() + + def test_persistent_profile_wins_runtime_duplicate(self): + write_profile(self.persistent, "persistent.nmconnection", "Duplicate", psk="persistent") + write_profile(self.runtime, "runtime.nmconnection", "Duplicate", psk="runtime-password") + + with self.patch_reads(): + store = self.make_store() + + assert require_entry(store, "Duplicate")["psk"] == "persistent" + + def test_edit_persistent_profile_removes_shadowed_runtime_copy(self): + write_profile(self.persistent, "persistent.nmconnection", "Duplicate", file_uuid="shared-uuid", psk="persistent") + runtime_path = Path(write_profile(self.runtime, "runtime.nmconnection", "Duplicate", file_uuid="shared-uuid", psk="runtime-password")) + shared_uuid = profile_uuid("shared-uuid") + netplan_path = Path(self.netplan, f"90-NM-{shared_uuid}.yaml") + write_netplan_profile(netplan_path, shared_uuid) + + with ( + self.patch_reads(), + patch.object(store_module.subprocess, "run", return_value=MagicMock(returncode=0)) as run, + ): + store = self.make_store() + assert require_entry(store, "Duplicate")["_runtime_filename"] == "runtime.nmconnection" + store.set_metered("Duplicate", 1) + + removed = [item.args[0][-1] for item in run.call_args_list + if item.args[0][:3] == ["sudo", "rm", "-f"]] + assert str(runtime_path) in removed + assert str(netplan_path) in removed + + def test_edit_persistent_profile_preserves_runtime_profile_with_different_uuid(self): + write_profile(self.persistent, "persistent.nmconnection", "Duplicate", file_uuid="persistent-uuid", psk="persistent") + runtime_path = Path(write_profile(self.runtime, "runtime.nmconnection", "Duplicate", file_uuid="runtime-uuid", psk="runtime")) + runtime_uuid = profile_uuid("runtime-uuid") + netplan_path = Path(self.netplan, f"90-NM-{runtime_uuid}.yaml") + write_netplan_profile(netplan_path, runtime_uuid) + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + store.set_metered("Duplicate", 1) + + assert runtime_path.exists() + assert netplan_path.exists() + + def test_failed_noncanonical_cleanup_rolls_back_profile_update(self): + stored_path = Path(write_profile(self.persistent, "stored.nmconnection", "Stored", file_uuid="stored-uuid")) + original = stored_path.read_text() + + def run(command, **kwargs): + if command[:3] == ["sudo", "rm", "-f"] and command[-1] == str(stored_path): + return MagicMock(returncode=1) + return self.run_file_command(command, **kwargs) + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=run): + store = self.make_store() + with self.assertRaises(OSError): + store.set_metered("Stored", 1) + + canonical_path = Path(self.persistent, f"{profile_uuid('stored-uuid')}-Stored.nmconnection") + assert not canonical_path.exists() + assert stored_path.read_text() == original + assert require_entry(store, "Stored")["metered"] == 0 + assert require_entry(store, "Stored")["_filename"] == "stored.nmconnection" + + def test_emits_multiple_profiles_with_the_same_ssid(self): + write_profile( + self.persistent, + "first.nmconnection", + "Pinned", + file_uuid="first-uuid", + bssid="00:11:22:33:44:55", + ) + write_profile( + self.persistent, + "second.nmconnection", + "Pinned", + file_uuid="second-uuid", + bssid="66:77:88:99:aa:bb", + ) + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + store.set_metered("Pinned", 1) + config_path = os.path.join(self.root, "wpa_supplicant.conf") + generate_wpa_conf(store, config_path) + config = Path(config_path).read_text() + + assert config.count("network={") == 2 + assert "bssid=00:11:22:33:44:55" in config + assert "bssid=66:77:88:99:aa:bb" in config + + def test_metered_updates_every_profile_with_the_same_ssid(self): + write_profile(self.persistent, "first.nmconnection", "Duplicate", file_uuid="first-uuid") + write_profile(self.persistent, "second.nmconnection", "Duplicate", file_uuid="second-uuid") + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + store.set_metered("Duplicate", 1) + + profiles = [entry for ssid, entry in store.get_profiles() if ssid == "Duplicate"] + assert len(profiles) == 2 + assert all(entry["metered"] == 1 for entry in profiles) + for file_uuid in (profile_uuid("first-uuid"), profile_uuid("second-uuid")): + raw = Path(self.persistent, f"{file_uuid}-Duplicate.nmconnection").read_text() + assert "metered = 1" in raw + + def test_gets_metering_from_selected_profile(self): + write_profile(self.persistent, "first.nmconnection", "Duplicate", file_uuid="first-uuid", extra_connection="metered=1") + write_profile(self.persistent, "second.nmconnection", "Duplicate", file_uuid="second-uuid", extra_connection="metered=2") + + with self.patch_reads(): + store = self.make_store() + + assert store.get_metered("Duplicate", profile_uuid("second-uuid")) == store_module.MeteredType.NO + + def test_replacement_psk_preserves_other_profile_credentials(self): + write_profile( + self.persistent, + "first.nmconnection", + "Duplicate", + file_uuid="first-uuid", + psk="stale-password", + bssid="00:11:22:33:44:55", + ) + write_profile( + self.persistent, + "second.nmconnection", + "Duplicate", + file_uuid="second-uuid", + psk="alternate-password", + bssid="66:77:88:99:aa:bb", + ) + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + store.save_network("Duplicate", psk="replacement-password") + + profiles = [entry for ssid, entry in store.get_profiles() if ssid == "Duplicate"] + assert len(profiles) == 2 + assert {entry["bssid"] for entry in profiles} == {"00:11:22:33:44:55", "66:77:88:99:aa:bb"} + assert {entry["bssid"]: entry["psk"] for entry in profiles} == { + "00:11:22:33:44:55": "replacement-password", + "66:77:88:99:aa:bb": "alternate-password", + } + assert "psk = replacement-password" in Path(self.persistent, f"{profile_uuid('first-uuid')}-Duplicate.nmconnection").read_text() + assert "psk=alternate-password" in Path(self.persistent, "second.nmconnection").read_text() + + def test_replacement_psk_updates_every_unpinned_profile(self): + write_profile(self.persistent, "first.nmconnection", "Duplicate", file_uuid="first-uuid", psk="stale-password") + write_profile(self.persistent, "second.nmconnection", "Duplicate", file_uuid="second-uuid", psk="alternate-password") + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + store.save_network("Duplicate", psk="replacement-password") + + profiles = [entry for ssid, entry in store.get_profiles() if ssid == "Duplicate"] + assert len(profiles) == 2 + assert all(entry["psk"] == "replacement-password" for entry in profiles) + for file_uuid in (profile_uuid("first-uuid"), profile_uuid("second-uuid")): + raw = Path(self.persistent, f"{file_uuid}-Duplicate.nmconnection").read_text() + assert "psk = replacement-password" in raw + + def test_readers_do_not_wait_for_profile_writes(self): + write_profile(self.persistent, "saved.nmconnection", "Saved") + render_started = threading.Event() + release_render = threading.Event() + read_finished = threading.Event() + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + render = store._render_nmconnection + + def blocking_render(*args, **kwargs): + render_started.set() + assert release_render.wait(1) + return render(*args, **kwargs) + + with patch.object(store, "_render_nmconnection", side_effect=blocking_render): + writer = threading.Thread(target=store.save_network, args=("Saved",), kwargs={"psk": "replacement-password"}) + reader = threading.Thread(target=lambda: (store.contains("Saved"), read_finished.set())) + writer.start() + assert render_started.wait(1) + reader.start() + read_completed_during_write = read_finished.wait(0.1) + release_render.set() + writer.join(1) + reader.join(1) + + assert read_completed_during_write + assert not writer.is_alive() + assert not reader.is_alive() + + def test_unsupported_persistent_profile_does_not_block_valid_runtime_profile(self): + write_profile(self.persistent, "persistent.nmconnection", "Enterprise", psk=None, key_mgmt="wpa-eap", extra_security="identity=user") + write_profile(self.runtime, "runtime.nmconnection", "Enterprise") + + with self.patch_reads(): + store = self.make_store() + + assert require_entry(store, "Enterprise")["psk"] == "password123" + + def test_invalid_persistent_wifi_options_do_not_block_valid_runtime_profile(self): + write_profile(self.persistent, "persistent.nmconnection", "Randomized", extra_wifi="cloned-mac-address=stable") + write_profile(self.runtime, "runtime.nmconnection", "Randomized") + + with self.patch_reads(): + store = self.make_store() + + assert require_entry(store, "Randomized")["psk"] == "password123" + + def test_runtime_shadow_matches_every_persistent_profile_uuid(self): + write_profile(self.persistent, "first.nmconnection", "Duplicate", file_uuid="first-uuid", psk="first-password") + write_profile(self.persistent, "second.nmconnection", "Duplicate", file_uuid="second-uuid", psk="second-password") + write_profile(self.runtime, "runtime.nmconnection", "Duplicate", file_uuid="second-uuid", psk="runtime-password") + + with self.patch_reads(): + store = self.make_store() + + profiles = [entry for ssid, entry in store.get_profiles() if ssid == "Duplicate"] + assert len(profiles) == 2 + second = next(entry for entry in profiles if entry["uuid"] == profile_uuid("second-uuid")) + assert second["psk"] == "second-password" + assert second["_runtime_filename"] == "runtime.nmconnection" + + def test_new_profile_uses_preallocated_uuid(self): + selected_uuid = profile_uuid("selected") + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + store.save_network("Selected", psk="password123", profile_uuid=selected_uuid) + + assert require_entry(store, "Selected")["uuid"] == selected_uuid + assert Path(self.persistent, f"{selected_uuid}-Selected.nmconnection").exists() + + def test_forget_runtime_profile_removes_netplan_source(self): + write_profile(self.runtime, "netplan.nmconnection", "Runtime", file_uuid="runtime-uuid") + runtime_uuid = profile_uuid("runtime-uuid") + netplan_path = Path(self.netplan, f"90-NM-{runtime_uuid}.yaml") + write_netplan_profile(netplan_path, runtime_uuid) + + with ( + self.patch_reads(), + patch.object(store_module.subprocess, "run", return_value=MagicMock(returncode=0)) as run, + ): + store = self.make_store() + + assert store.remove("Runtime") + + staged = [args.args[0][-2] for args in run.call_args_list if args.args[0][:3] == ["sudo", "mv", "-f"]] + assert str(netplan_path) in staged + assert str(Path(self.runtime, "netplan.nmconnection")) in staged + assert store.get("Runtime") is None + + def test_edit_runtime_profile_without_netplan_source(self): + write_profile(self.runtime, "runtime.nmconnection", "Runtime", file_uuid="runtime-uuid") + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + store.save_network("Runtime", psk="replacement-password") + + assert not Path(self.runtime, "runtime.nmconnection").exists() + assert Path(self.persistent, f"{profile_uuid('runtime-uuid')}-Runtime.nmconnection").exists() + assert require_entry(store, "Runtime")["_netplan_source"] is None + + def test_forget_runtime_profile_without_netplan_source(self): + write_profile(self.runtime, "runtime.nmconnection", "Runtime", file_uuid="runtime-uuid") + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + assert store.remove("Runtime") + + assert not Path(self.runtime, "runtime.nmconnection").exists() + assert not store.contains("Runtime") + + def test_forget_finds_renamed_netplan_source_by_uuid(self): + write_profile(self.runtime, "netplan.nmconnection", "Runtime", file_uuid="runtime-uuid") + netplan_path = Path(self.netplan, "provisioned-wifi.yaml") + write_netplan_profile(netplan_path, profile_uuid("runtime-uuid")) + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + assert store.remove("Runtime") + + assert not netplan_path.exists() + + def test_forget_refuses_shared_renamed_netplan_source(self): + runtime_path = Path(write_profile(self.runtime, "netplan.nmconnection", "Runtime", file_uuid="runtime-uuid")) + netplan_path = Path(self.netplan, "provisioned-wifi.yaml") + netplan_path.write_text(f"""\ +network: + version: 2 + wifis: + first: + networkmanager: + uuid: {profile_uuid('runtime-uuid')} + second: + networkmanager: + uuid: {profile_uuid('other-uuid')} +""") + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + assert not store.remove("Runtime") + + assert runtime_path.exists() + assert netplan_path.exists() + assert store.contains("Runtime") + + def test_forget_refuses_uuidless_sibling_in_renamed_netplan_source(self): + runtime_path = Path(write_profile(self.runtime, "netplan.nmconnection", "Runtime", file_uuid="runtime-uuid")) + runtime_uuid = profile_uuid("runtime-uuid") + netplan_path = Path(self.netplan, "provisioned-wifi.yaml") + original_netplan = f"""\ +network: + version: 2 + wifis: + NM-{runtime_uuid}: + renderer: NetworkManager + networkmanager: + uuid: {runtime_uuid} + unrelated: + dhcp4: true +""" + netplan_path.write_text(original_netplan) + original_runtime = runtime_path.read_text() + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + assert not store.remove("Runtime") + + assert runtime_path.read_text() == original_runtime + assert netplan_path.read_text() == original_netplan + assert store.contains("Runtime") + + def test_forget_refuses_shared_canonical_netplan_source(self): + runtime_path = Path(write_profile(self.runtime, "netplan.nmconnection", "Runtime", file_uuid="runtime-uuid")) + runtime_uuid = profile_uuid("runtime-uuid") + netplan_path = Path(self.netplan, f"90-NM-{runtime_uuid}.yaml") + netplan_path.write_text(f"""\ +network: + version: 2 + wifis: + first: + networkmanager: + uuid: {runtime_uuid} + second: + networkmanager: + uuid: {profile_uuid('other-uuid')} +""") + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + assert not store.remove("Runtime") + + assert runtime_path.exists() + assert netplan_path.exists() + assert store.contains("Runtime") + + def test_profile_updates_refuse_shared_netplan_source(self): + runtime_path = Path(write_profile(self.runtime, "netplan.nmconnection", "Runtime", file_uuid="runtime-uuid")) + runtime_uuid = profile_uuid("runtime-uuid") + netplan_path = Path(self.netplan, "shared.yaml") + netplan_path.write_text(f"""\ +network: + version: 2 + wifis: + first: + networkmanager: + uuid: {runtime_uuid} + second: + networkmanager: + uuid: {profile_uuid('other-uuid')} +""") + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + with self.assertRaises(OSError): + store.set_metered("Runtime", 1) + with self.assertRaises(OSError): + store.save_network("Runtime", psk="replacement-password") + + assert runtime_path.exists() + assert netplan_path.exists() + assert require_entry(store, "Runtime")["metered"] == 0 + assert require_entry(store, "Runtime")["psk"] == "password123" + + def test_profile_updates_refuse_uuidless_sibling_in_canonical_netplan_source(self): + runtime_path = Path(write_profile(self.runtime, "netplan.nmconnection", "Runtime", file_uuid="runtime-uuid")) + runtime_uuid = profile_uuid("runtime-uuid") + netplan_path = Path(self.netplan, f"90-NM-{runtime_uuid}.yaml") + original_netplan = f"""\ +network: + version: 2 + wifis: + NM-{runtime_uuid}: + renderer: NetworkManager + networkmanager: + uuid: {runtime_uuid} + unrelated: + dhcp4: true +""" + netplan_path.write_text(original_netplan) + original_runtime = runtime_path.read_text() + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + with self.assertRaises(OSError): + store.set_metered("Runtime", 1) + with self.assertRaises(OSError): + store.save_network("Runtime", psk="replacement-password") + + assert runtime_path.read_text() == original_runtime + assert netplan_path.read_text() == original_netplan + assert require_entry(store, "Runtime")["metered"] == 0 + assert require_entry(store, "Runtime")["psk"] == "password123" + + def test_forget_ignores_unrelated_netplan_source(self): + runtime_path = Path(write_profile(self.runtime, "netplan.nmconnection", "Runtime", file_uuid="runtime-uuid")) + netplan_path = Path(self.netplan, "provisioned-wifi.yaml") + netplan_path.write_text("network:\n version: 2\n wifis:\n wlan0:\n access-points:\n Runtime: {}\n") + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + assert store.remove("Runtime") + + assert not runtime_path.exists() + assert netplan_path.exists() + + def test_forget_refuses_unreadable_netplan_source(self): + runtime_path = Path(write_profile(self.runtime, "netplan.nmconnection", "Runtime", file_uuid="runtime-uuid")) + netplan_path = Path(self.netplan, "provisioned-wifi.yaml") + netplan_path.write_text("network:\n version: 2\n") + + def read(path): + return "" if path == str(netplan_path) else Path(path).read_text() + + with ( + patch.object(store_module, "sudo_read", side_effect=read), + patch.object(store_module.subprocess, "run", side_effect=self.run_file_command), + ): + store = self.make_store() + assert not store.remove("Runtime") + + assert runtime_path.exists() + assert netplan_path.exists() + + def test_forget_duplicate_runtime_profiles_removes_every_netplan_source(self): + write_profile(self.runtime, "first.nmconnection", "Duplicate", file_uuid="first-uuid") + write_profile(self.runtime, "second.nmconnection", "Duplicate", file_uuid="second-uuid") + netplan_paths = { + Path(self.netplan, f"90-NM-{profile_uuid('first-uuid')}.yaml"), + Path(self.netplan, f"90-NM-{profile_uuid('second-uuid')}.yaml"), + } + for path in netplan_paths: + source_uuid = path.stem.removeprefix("90-NM-") + write_netplan_profile(path, source_uuid) + + with ( + self.patch_reads(), + patch.object(store_module.subprocess, "run", return_value=MagicMock(returncode=0)) as run, + ): + store = self.make_store() + assert store.remove("Duplicate") + + staged = {Path(item.args[0][-2]) for item in run.call_args_list + if item.args[0][:3] == ["sudo", "mv", "-f"]} + assert netplan_paths <= staged + + def test_forget_preserves_unsupported_profile_with_same_ssid(self): + unsupported = Path(write_profile( + self.persistent, + "enterprise.nmconnection", + "Duplicate", + file_uuid="enterprise-uuid", + psk=None, + key_mgmt="wpa-eap", + extra_security="identity=user", + )) + managed = Path(write_profile(self.persistent, "managed.nmconnection", "Duplicate", file_uuid="managed-uuid")) + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + assert store.remove("Duplicate") + + assert unsupported.exists() + assert not managed.exists() + + def test_forget_keeps_profile_when_disk_removal_fails(self): + write_profile(self.runtime, "netplan.nmconnection", "Runtime", file_uuid="runtime-uuid") + + with ( + self.patch_reads(), + patch.object(store_module.subprocess, "run", return_value=MagicMock(returncode=1)), + ): + store = self.make_store() + + assert not store.remove("Runtime") + assert store.get("Runtime") is not None + + def test_forget_rolls_back_earlier_removals(self): + first_path = Path(write_profile(self.persistent, "a.nmconnection", "Duplicate", file_uuid="first-uuid")) + second_path = Path(write_profile(self.persistent, "b.nmconnection", "Duplicate", file_uuid="second-uuid")) + originals = {first_path, second_path} + mutations = 0 + + def run(command, **kwargs): + nonlocal mutations + if command[:3] == ["sudo", "rm", "-f"] and Path(command[-1]) in originals: + if mutations == 1: + return MagicMock(returncode=1) + mutations += 1 + if command[:3] == ["sudo", "mv", "-f"]: + source = Path(command[-2]) + if source in originals: + if mutations == 1: + return MagicMock(returncode=1) + mutations += 1 + source.replace(command[-1]) + return MagicMock(returncode=0) + return self.run_file_command(command, **kwargs) + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=run): + store = self.make_store() + assert not store.remove("Duplicate") + + assert first_path.exists() + assert second_path.exists() + assert store.contains("Duplicate") + + def test_forget_cleanup_failure_does_not_resurrect_profile(self): + first_path = Path(write_profile(self.persistent, "a.nmconnection", "Duplicate", file_uuid="first-uuid")) + second_path = Path(write_profile(self.persistent, "b.nmconnection", "Duplicate", file_uuid="second-uuid")) + cleanup_failed = False + + def run(command, **kwargs): + nonlocal cleanup_failed + if command[:3] == ["sudo", "rm", "-f"] and ".openpilot-forget-" in command[-1] and not cleanup_failed: + cleanup_failed = True + return MagicMock(returncode=1) + return self.run_file_command(command, **kwargs) + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=run): + store = self.make_store() + assert store.remove("Duplicate") + + assert cleanup_failed + assert not first_path.exists() + assert not second_path.exists() + assert not store.contains("Duplicate") + assert list(Path(self.persistent).glob("*.openpilot-forget-*")) + assert list(Path(self.persistent).glob(".openpilot-forget-committed-*")) + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + recovered = self.make_store() + + assert not recovered.contains("Duplicate") + assert not list(Path(self.persistent).glob("*.openpilot-forget-*")) + assert not list(Path(self.persistent).glob(".openpilot-forget-committed-*")) + + def test_edit_runtime_profile_installs_keyfile_before_removing_netplan(self): + write_profile(self.runtime, "netplan.nmconnection", "Runtime", file_uuid="runtime-uuid") + runtime_uuid = profile_uuid("runtime-uuid") + netplan_path = Path(self.netplan, f"90-NM-{runtime_uuid}.yaml") + write_netplan_profile(netplan_path, runtime_uuid) + + with ( + self.patch_reads(), + patch.object(store_module.subprocess, "run", return_value=MagicMock(returncode=0)) as run, + ): + store = self.make_store() + + store.save_network("Runtime", psk="replacement") + + commands = [item.args[0] for item in run.call_args_list] + install_index = next(i for i, command in enumerate(commands) + if command[:2] == ["sudo", "install"] and command[-1].endswith(f"{profile_uuid('runtime-uuid')}-Runtime.nmconnection")) + runtime_remove_index = next(i for i, command in enumerate(commands) + if command[:3] == ["sudo", "rm", "-f"] and command[-1] == str(Path(self.runtime, "netplan.nmconnection"))) + remove_index = next(i for i, command in enumerate(commands) + if command[:3] == ["sudo", "rm", "-f"] and command[-1] == str(netplan_path)) + assert install_index < runtime_remove_index < remove_index + assert require_entry(store, "Runtime")["psk"] == "replacement" + assert require_entry(store, "Runtime")["_runtime_filename"] is None + assert require_entry(store, "Runtime")["_netplan_source"] is None + + def test_edit_runtime_profile_rolls_back_when_runtime_remove_fails(self): + runtime_path = Path(write_profile(self.runtime, "netplan.nmconnection", "Runtime", file_uuid="runtime-uuid")) + runtime_uuid = profile_uuid("runtime-uuid") + netplan_path = Path(self.netplan, f"90-NM-{runtime_uuid}.yaml") + write_netplan_profile(netplan_path, runtime_uuid) + + def run(command, **_): + return MagicMock(returncode=1 if command[-1] == str(runtime_path) else 0) + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=run) as process: + store = self.make_store() + keyfile_path = os.path.join(self.persistent, f"{profile_uuid('runtime-uuid')}-Runtime.nmconnection") + + with self.assertRaises(OSError): + store.set_metered("Runtime", 1) + + commands = [item.args[0] for item in process.call_args_list] + assert ["sudo", "rm", "-f", keyfile_path] in commands + assert ["sudo", "rm", "-f", str(netplan_path)] not in commands + assert require_entry(store, "Runtime")["metered"] == 0 + assert require_entry(store, "Runtime")["_runtime_filename"] == "netplan.nmconnection" + + def test_runtime_cleanup_failure_preserves_existing_persistent_profile(self): + persistent_path = Path(write_profile( + self.persistent, f"{profile_uuid('shared-uuid')}-Duplicate.nmconnection", "Duplicate", file_uuid="shared-uuid", psk="original-password", + )) + original = persistent_path.read_text() + runtime_path = Path(write_profile(self.runtime, "runtime.nmconnection", "Duplicate", file_uuid="shared-uuid")) + + def run(command, **kwargs): + if command[:3] == ["sudo", "rm", "-f"] and command[-1] == str(runtime_path): + return MagicMock(returncode=1) + return self.run_file_command(command, **kwargs) + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=run): + store = self.make_store() + + with self.assertRaises(OSError): + store.save_network("Duplicate", psk="replacement-password") + + assert persistent_path.read_text() == original + + def test_multi_profile_password_update_rolls_back_earlier_profiles(self): + first_path = Path(write_profile(self.persistent, "a.nmconnection", "Duplicate", file_uuid="first-uuid", psk="first-password")) + second_path = Path(write_profile(self.persistent, "b.nmconnection", "Duplicate", file_uuid="second-uuid", psk="second-password")) + originals = {path: path.read_text() for path in (first_path, second_path)} + second_canonical = Path(self.persistent, f"{profile_uuid('second-uuid')}-Duplicate.nmconnection") + + def run(command, **kwargs): + if command[:2] == ["sudo", "install"] and command[-1] == str(second_canonical): + raise OSError("write failed") + return self.run_file_command(command, **kwargs) + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=run): + store = self.make_store() + + with self.assertRaises(OSError): + store.save_network("Duplicate", psk="replacement-password") + + assert {path: path.read_text() for path in (first_path, second_path)} == originals + assert {entry["psk"] for ssid, entry in store.get_profiles() if ssid == "Duplicate"} == {"first-password", "second-password"} + + def test_runtime_cleanup_failure_preserves_noncanonical_persistent_profile(self): + persistent_path = Path(write_profile( + self.persistent, "saved.nmconnection", "Duplicate", file_uuid="shared-uuid", psk="original-password", + )) + runtime_path = Path(write_profile(self.runtime, "runtime.nmconnection", "Duplicate", file_uuid="shared-uuid")) + + def run(command, **kwargs): + if command[:3] == ["sudo", "rm", "-f"] and command[-1] == str(runtime_path): + return MagicMock(returncode=1) + return self.run_file_command(command, **kwargs) + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=run): + store = self.make_store() + + with self.assertRaises(OSError): + store.set_metered("Duplicate", 1) + + assert persistent_path.exists() + + def test_edit_runtime_profile_restores_sources_when_netplan_remove_fails(self): + runtime_path = Path(write_profile(self.runtime, "netplan.nmconnection", "Runtime", file_uuid="runtime-uuid")) + original_runtime = runtime_path.read_text() + runtime_uuid = profile_uuid("runtime-uuid") + netplan_path = Path(self.netplan, f"90-NM-{runtime_uuid}.yaml") + write_netplan_profile(netplan_path, runtime_uuid) + original_netplan = netplan_path.read_text() + + def run(command, **kwargs): + if command[:3] == ["sudo", "rm", "-f"] and command[-1] == str(netplan_path): + return MagicMock(returncode=1) + return self.run_file_command(command, **kwargs) + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=run) as process: + store = self.make_store() + keyfile_path = os.path.join(self.persistent, f"{profile_uuid('runtime-uuid')}-Runtime.nmconnection") + + with self.assertRaises(OSError): + store.save_network("Runtime", psk="replacement") + + commands = [item.args[0] for item in process.call_args_list] + assert ["sudo", "rm", "-f", keyfile_path] in commands + assert runtime_path.read_text() == original_runtime + assert netplan_path.read_text() == original_netplan + assert not Path(keyfile_path).exists() + assert not list(Path(self.root).rglob("*.openpilot-update-*")) + assert require_entry(store, "Runtime")["psk"] == "password123" + assert require_entry(store, "Runtime")["_runtime_filename"] == "netplan.nmconnection" + assert require_entry(store, "Runtime")["_netplan_source"] == store_module.NetplanSource(str(netplan_path), True) + + def test_accepts_only_networkmanager_dns_priority(self): + rollback_path = write_profile(self.persistent, "rollback.nmconnection", "Rollback") + custom_path = write_profile(self.persistent, "custom.nmconnection", "Custom") + for path, priority in ((rollback_path, 600), (custom_path, 100)): + with Path(path).open("a") as f: + f.write(f"""\ +[ipv4] +method=auto +dns-priority={priority} + +[ipv6] +method=auto +""") + + with self.patch_reads(): + store = self.make_store() + + assert require_entry(store, "Rollback")["_ipv4"]["dns-priority"] == "600" + assert store.get("Custom") is None + + def test_skips_profile_with_unsupported_automatic_ip_options(self): + path = write_profile(self.persistent, "static.nmconnection", "Static") + with Path(path).open("a") as f: + f.write("""\ +[ipv4] +method=auto +dns=1.1.1.1;9.9.9.9; + +[ipv6] +method=auto +addr-gen-mode=stable-privacy +""") + + with self.patch_reads(): + store = self.make_store() + + assert store.get("Static") is None + assert "dns=1.1.1.1;9.9.9.9;" in Path(path).read_text() + assert "addr-gen-mode=stable-privacy" in Path(path).read_text() + + def test_skips_profile_with_unsupported_addressing(self): + path = write_profile(self.persistent, "static.nmconnection", "Static") + with Path(path).open("a") as f: + f.write("""\ +[ipv4] +method=manual +address1=192.168.50.10/24,192.168.50.1 + +[ipv6] +method=auto +""") + + with self.patch_reads(): + store = self.make_store() + + assert store.get("Static") is None + assert "address1=192.168.50.10/24,192.168.50.1" in Path(path).read_text() + + def test_saved_profile_uses_nm_keyfile_compatible_name_and_mode(self): + with patch.object(store_module.subprocess, "run", side_effect=self.run_file_command) as run: + store = self.make_store() + + store.save_network("Cafe/Wifi", psk="password123") + + install = next(item.args[0] for item in run.call_args_list + if item.args[0][:2] == ["sudo", "install"] and "-d" not in item.args[0]) + assert install[-1].endswith("-Cafe_Wifi.nmconnection") + assert install[install.index("-m") + 1] == "600" + raw = next(Path(self.persistent).glob("*.nmconnection")).read_text() + assert "dns-priority = 600" in raw + + def test_round_trips_boundary_whitespace_with_keyfile_escaping(self): + with patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + + store.save_network(" Cafe ", psk=" password123 ") + with self.patch_reads(): + reloaded = self.make_store() + + assert require_entry(reloaded, " Cafe ")["psk"] == " password123 " + raw = next(Path(self.persistent).glob("*.nmconnection")).read_text() + assert "ssid = 32;67;97;102;101;32;" in raw + assert r"psk = \spassword123\s" in raw + + def test_writes_non_ascii_ssid_as_utf8_byte_list(self): + with patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + store.save_network("café", psk="password123", metered=1) + + raw = next(Path(self.persistent).glob("*.nmconnection")).read_text() + assert "ssid = 99;97;102;195;169;" in raw + + def test_round_trips_decimal_list_like_literal_ssid(self): + with patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + store.save_network("65;66;67;", psk="password123") + with self.patch_reads(): + reloaded = self.make_store() + + assert require_entry(reloaded, "65;66;67;")["psk"] == "password123" diff --git a/openpilot/system/ui/lib/tests/test_standalone_wifi.py b/openpilot/system/ui/lib/tests/test_standalone_wifi.py new file mode 100644 index 00000000000000..56da6f7d7774b6 --- /dev/null +++ b/openpilot/system/ui/lib/tests/test_standalone_wifi.py @@ -0,0 +1,221 @@ +from importlib import import_module +import os +from unittest import SkipTest, skipUnless, TestCase +from unittest.mock import MagicMock, patch + +previous_scale = os.environ.get("SCALE") +os.environ["SCALE"] = "1" +try: + try: + import_module("pyray") + except ImportError: + pyray_available = False + else: + pyray_available = True + from openpilot.system.ui.widgets import network as network_module + from openpilot.system.ui.widgets.network import UIState, WifiManagerUI +finally: + if previous_scale is None: + del os.environ["SCALE"] + else: + os.environ["SCALE"] = previous_scale + + +@skipUnless(pyray_available, "pyray is unavailable") +class TestStandaloneWifi(TestCase): + def test_forget_failure_releases_wifi_controls(self): + wifi_ui = WifiManagerUI.__new__(WifiManagerUI) + wifi_ui.state = UIState.FORGETTING + wifi_ui._page_shown = True + wifi_ui._panel_active = True + dialog = MagicMock() + + with ( + patch.object(network_module, "alert_dialog", return_value=dialog), + patch.object(network_module.gui_app, "push_widget") as push_widget, + ): + wifi_ui._on_forget_failed("SavedNet") + + assert wifi_ui.state == UIState.IDLE + push_widget.assert_called_once_with(dialog) + + def test_forget_failure_on_advanced_panel_does_not_open_dialog(self): + wifi_ui = WifiManagerUI.__new__(WifiManagerUI) + wifi_ui.state = UIState.FORGETTING + wifi_ui._page_shown = True + wifi_ui._panel_active = False + + with ( + patch.object(network_module, "alert_dialog", return_value=MagicMock()), + patch.object(network_module.gui_app, "push_widget") as push_widget, + ): + wifi_ui._on_forget_failed("SavedNet") + + assert wifi_ui.state == UIState.IDLE + push_widget.assert_not_called() + + def test_network_ui_tracks_active_wifi_panel(self): + network_ui = network_module.NetworkUI.__new__(network_module.NetworkUI) + network_ui._wifi_panel = MagicMock() + + network_ui._set_current_panel(network_module.PanelType.ADVANCED) + + assert network_ui._current_panel == network_module.PanelType.ADVANCED + network_ui._wifi_panel.set_panel_active.assert_called_once_with(False) + + def test_mici_forget_failure_restores_button_and_opens_dialog(self): + try: + from openpilot.selfdrive.ui.mici.layouts.settings.network import wifi_ui as wifi_ui_module + from openpilot.selfdrive.ui.mici.layouts.settings.network.wifi_ui import WifiUIMici + except ImportError as e: + raise SkipTest("mici UI dependencies are unavailable") from e + + class WifiButton: + def __init__(self): + self.network = MagicMock(ssid="SavedNet") + self.on_forgotten = MagicMock() + + button = WifiButton() + wifi_ui = WifiUIMici.__new__(WifiUIMici) + wifi_ui._scroller = MagicMock(items=[button]) + wifi_ui._shown = True + dialog = MagicMock() + + with ( + patch.object(wifi_ui_module, "WifiButton", WifiButton), + patch.object(wifi_ui_module, "BigDialog", return_value=dialog), + patch.object(wifi_ui_module.gui_app, "push_widget") as push_widget, + ): + wifi_ui._on_forget_failed("SavedNet") + + button.on_forgotten.assert_called_once() + push_widget.assert_called_once_with(dialog) + + def test_mici_hidden_forget_failure_does_not_open_dialog(self): + try: + from openpilot.selfdrive.ui.mici.layouts.settings.network import wifi_ui as wifi_ui_module + from openpilot.selfdrive.ui.mici.layouts.settings.network.wifi_ui import WifiUIMici + except ImportError as e: + raise SkipTest("mici UI dependencies are unavailable") from e + + class WifiButton: + def __init__(self): + self.network = MagicMock(ssid="SavedNet") + self.on_forgotten = MagicMock() + + button = WifiButton() + wifi_ui = WifiUIMici.__new__(WifiUIMici) + wifi_ui._scroller = MagicMock(items=[button]) + wifi_ui._shown = False + + with ( + patch.object(wifi_ui_module, "WifiButton", WifiButton), + patch.object(wifi_ui_module, "BigDialog", return_value=MagicMock()), + patch.object(wifi_ui_module.gui_app, "push_widget") as push_widget, + ): + wifi_ui._on_forget_failed("SavedNet") + + button.on_forgotten.assert_called_once() + push_widget.assert_not_called() + + def test_mici_wrong_password_opens_password_dialog(self): + try: + from openpilot.selfdrive.ui.mici.layouts.settings.network import wifi_ui as wifi_ui_module + from openpilot.selfdrive.ui.mici.layouts.settings.network.wifi_ui import WifiUIMici + except ImportError as e: + raise SkipTest("mici UI dependencies are unavailable") from e + + class WifiButton: + def __init__(self): + self.network = MagicMock(ssid="SavedNet") + self.set_wrong_password = MagicMock() + + button = WifiButton() + wifi_ui = WifiUIMici.__new__(WifiUIMici) + wifi_ui._scroller = MagicMock(items=[button]) + wifi_ui._shown = True + dialog = MagicMock() + + with ( + patch.object(wifi_ui_module, "WifiButton", WifiButton), + patch.object(wifi_ui_module, "BigInputDialog", return_value=dialog), + patch.object(wifi_ui_module.gui_app, "push_widget") as push_widget, + ): + wifi_ui._on_need_auth("SavedNet") + + button.set_wrong_password.assert_called_once() + push_widget.assert_called_once_with(dialog) + + def test_mici_hidden_wrong_password_defers_password_dialog(self): + try: + from openpilot.selfdrive.ui.mici.layouts.settings.network import wifi_ui as wifi_ui_module + from openpilot.selfdrive.ui.mici.layouts.settings.network.wifi_ui import WifiUIMici + except ImportError as e: + raise SkipTest("mici UI dependencies are unavailable") from e + + class WifiButton: + def __init__(self): + self.network = MagicMock(ssid="SavedNet") + self.set_wrong_password = MagicMock() + + button = WifiButton() + wifi_ui = WifiUIMici.__new__(WifiUIMici) + wifi_ui._scroller = MagicMock(items=[button]) + wifi_ui._shown = False + + with ( + patch.object(wifi_ui_module, "WifiButton", WifiButton), + patch.object(wifi_ui_module.gui_app, "push_widget") as push_widget, + ): + wifi_ui._on_need_auth("SavedNet") + + button.set_wrong_password.assert_called_once() + push_widget.assert_not_called() + + def test_mici_wrong_password_opens_auth_on_network_tap(self): + try: + from openpilot.selfdrive.ui.mici.layouts.settings.network import wifi_ui as wifi_ui_module + from openpilot.selfdrive.ui.mici.layouts.settings.network.wifi_ui import WifiUIMici + except ImportError as e: + raise SkipTest("mici UI dependencies are unavailable") from e + + class WifiButton: + def __init__(self): + self.network = MagicMock(ssid="SavedNet") + self.wrong_password = True + + button = WifiButton() + wifi_ui = WifiUIMici.__new__(WifiUIMici) + wifi_ui._wifi_manager = MagicMock() + wifi_ui._wifi_manager.is_tethering_active.return_value = False + wifi_ui._wifi_manager.is_connection_saved.return_value = True + wifi_ui._networks = {"SavedNet": MagicMock(ssid="SavedNet")} + wifi_ui._scroller = MagicMock(items=[button]) + wifi_ui._on_need_auth = MagicMock() + wifi_ui._move_network_to_front = MagicMock() + + with patch.object(wifi_ui_module, "WifiButton", WifiButton): + wifi_ui._connect_to_network("SavedNet") + + wifi_ui._on_need_auth.assert_called_once_with("SavedNet", False) + wifi_ui._wifi_manager.activate_connection.assert_not_called() + + def test_mici_wifi_page_leaves_manager_active_for_parent(self): + try: + from openpilot.selfdrive.ui.mici.layouts.settings.network import wifi_ui as wifi_ui_module + from openpilot.selfdrive.ui.mici.layouts.settings.network.wifi_ui import WifiUIMici + except ImportError as e: + raise SkipTest("mici UI dependencies are unavailable") from e + + wifi_ui = WifiUIMici.__new__(WifiUIMici) + wifi_ui._wifi_manager = MagicMock(networks=[]) + wifi_ui._update_buttons = MagicMock() + + with ( + patch.object(wifi_ui_module.NavScroller, "show_event"), + patch.object(wifi_ui_module.NavScroller, "hide_event"), + ): + wifi_ui.show_event() + wifi_ui.hide_event() + + wifi_ui._wifi_manager.set_active.assert_not_called() diff --git a/openpilot/system/ui/lib/tests/test_wifi_manager_bringup.py b/openpilot/system/ui/lib/tests/test_wifi_manager_bringup.py new file mode 100644 index 00000000000000..c066c46a4ccd11 --- /dev/null +++ b/openpilot/system/ui/lib/tests/test_wifi_manager_bringup.py @@ -0,0 +1,242 @@ +import subprocess +from contextlib import contextmanager +from unittest import TestCase +from unittest.mock import MagicMock, mock_open, patch + +from openpilot.system.ui.lib import wifi_manager as wifi_manager_module +from openpilot.system.ui.lib.wifi_manager import ( + ConnectStatus, + TETHERING_FORWARD_CHAIN, + TETHERING_INPUT_CHAIN, + TETHERING_NAT_CHAIN, + TETHERING_NAT_COMMENT, + TETHERING_SUBNET, + WifiManager, + WifiState, +) + + +def build_tethering_manager() -> WifiManager: + store = MagicMock() + with ( + patch.object(wifi_manager_module, "NetworkStore", return_value=store), + patch.object(wifi_manager_module, "DhcpClient", return_value=MagicMock()), + patch.object(wifi_manager_module, "Params", None), + patch.object(WifiManager, "_initialize"), + patch.object(wifi_manager_module.atexit, "register"), + ): + manager = WifiManager() + + manager._store = store + manager._exit = True + manager._tethering_ssid = "weedle-test" + manager._tethering_psk = "hotspot-psk-1234" + manager._tethering_active = True + manager._ipv4_forward = True + return manager + + +@contextmanager +def tethering_side_effects(manager: WifiManager, mode: str = "AP"): + ctrl = MagicMock() + ctrl.request.return_value = f"wpa_state=COMPLETED\nmode={mode}\nssid={manager._tethering_ssid}\n" + dnsmasq = MagicMock() + dnsmasq.poll.return_value = None + ap_file = mock_open() + + with ( + patch.object(wifi_manager_module.time, "sleep"), + patch.object(wifi_manager_module, "atomic_write", return_value=ap_file()) as atomic_write, + patch.object(wifi_manager_module, "stop_wpa_supplicant"), + patch.object(wifi_manager_module, "wpa_supplicant_running", return_value=True), + patch.object(wifi_manager_module, "stop_tethering_dnsmasq"), + patch.object(wifi_manager_module, "WpaCtrl", return_value=ctrl), + patch.object(wifi_manager_module.subprocess, "Popen", return_value=dnsmasq), + patch.object(wifi_manager_module.subprocess, "run", return_value=MagicMock(returncode=1)) as run, + patch.object(manager, "_apply_ipv4_forward") as apply_ipv4_forward, + ): + yield run, ctrl, ap_file, atomic_write, apply_ipv4_forward + + +class TestTetheringFirewall(TestCase): + def test_selects_xtables_backend(self): + for discovered, expected in (("/usr/sbin/iptables-legacy", "iptables-legacy"), (None, "iptables")): + with self.subTest(discovered=discovered), patch.object(wifi_manager_module.shutil, "which", return_value=discovered): + assert all(rule[1] == expected for rule in wifi_manager_module._tethering_firewall_rules("-A")) + + def test_installs_uplink_independent_masquerade(self): + manager = build_tethering_manager() + manager._tethering_active = False + with ( + patch.object(wifi_manager_module.shutil, "which", return_value="/usr/sbin/iptables-legacy"), + tethering_side_effects(manager) as (run, ctrl, _, _, apply_ipv4_forward), + ): + manager._start_tethering() + + apply_ipv4_forward.assert_called_once_with(True) + commands = [item.args[0] for item in run.call_args_list] + nat_add = next(command for command in commands if "-A" in command and "MASQUERADE" in command) + assert nat_add[:2] == ["sudo", "iptables-legacy"] + assert "-s" in nat_add and TETHERING_SUBNET in nat_add + assert "!" in nat_add and "-d" in nat_add + assert "-o" not in nat_add + assert TETHERING_NAT_COMMENT in nat_add + assert TETHERING_NAT_CHAIN in nat_add + assert manager.is_tethering_active() + assert manager._ctrl is ctrl + assert manager._wifi_state == WifiState("weedle-test", ConnectStatus.CONNECTED) + + def test_installs_client_input_and_forward_rules(self): + manager = build_tethering_manager() + with ( + patch.object(wifi_manager_module.shutil, "which", return_value="/usr/sbin/iptables-legacy"), + tethering_side_effects(manager) as (run, _, _, _, _), + ): + manager._start_tethering() + + commands = [item.args[0] for item in run.call_args_list] + added = [command for command in commands if "-A" in command] + assert any(TETHERING_INPUT_CHAIN in command and "udp" in command and "67" in command and "ACCEPT" in command for command in added) + assert any(TETHERING_INPUT_CHAIN in command and "udp" in command and "53" in command and "ACCEPT" in command for command in added) + assert any(TETHERING_INPUT_CHAIN in command and "tcp" in command and "53" in command and "ACCEPT" in command for command in added) + assert any(TETHERING_FORWARD_CHAIN in command and "-i" in command and TETHERING_SUBNET in command and "ACCEPT" in command for command in added) + assert any(TETHERING_FORWARD_CHAIN in command and "-o" in command and "ESTABLISHED,RELATED" in command and "ACCEPT" in command for command in added) + assert all(TETHERING_NAT_COMMENT in command for command in added) + + def test_start_preserves_untagged_masquerade_rules(self): + manager = build_tethering_manager() + with tethering_side_effects(manager) as (run, _, _, _, _): + manager._start_tethering() + + commands = [item.args[0] for item in run.call_args_list] + assert not any("-D" in command and "-o" in command and "MASQUERADE" in command for command in commands) + + def test_nat_failure_aborts_bringup(self): + manager = build_tethering_manager() + + def fail_nat_add(command, **_): + if "-A" in command and "MASQUERADE" in command: + raise subprocess.CalledProcessError(1, command) + return MagicMock(returncode=1) + + with ( + patch.object(wifi_manager_module.shutil, "which", return_value="/usr/sbin/iptables-legacy"), + tethering_side_effects(manager) as (run, _, _, _, _), + ): + run.side_effect = fail_nat_add + with self.assertRaises(subprocess.CalledProcessError): + manager._start_tethering() + + assert manager._ctrl is None + assert manager._wifi_state.status != ConnectStatus.CONNECTED + + def test_non_ap_daemon_aborts_bringup(self): + manager = build_tethering_manager() + with tethering_side_effects(manager, mode="station") as (_, ctrl, _, _, _): + with self.assertRaisesRegex(RuntimeError, "did not take over wlan0"): + manager._start_tethering() + + ctrl.close.assert_called_once() + assert manager._ctrl is None + + def test_ap_config_uses_wpa2_with_ccmp(self): + manager = build_tethering_manager() + with tethering_side_effects(manager) as (_, _, ap_file, _, _): + manager._start_tethering() + + config = ap_file().write.call_args.args[0] + assert "ctrl_interface=DIR=/run/openpilot-wpa GROUP=netdev\n" in config + assert " proto=RSN\n" in config + assert " pairwise=CCMP\n" in config + assert " group=CCMP\n" in config + + def test_fresh_tethering_start_requires_forwarding_policy(self): + manager = build_tethering_manager() + manager._ipv4_forward = None + + with tethering_side_effects(manager): + with self.assertRaisesRegex(RuntimeError, "forwarding policy"): + manager._start_tethering() + + def test_ap_config_is_written_atomically(self): + manager = build_tethering_manager() + with tethering_side_effects(manager) as (_, _, ap_file, atomic_write, _): + manager._start_tethering() + + atomic_write.assert_called_once_with(wifi_manager_module.WPA_AP_CONF, overwrite=True) + ap_file().write.assert_called_once() + + def test_applies_ipv4_forwarding_changes_while_active(self): + manager = build_tethering_manager() + with patch.object(manager, "_apply_ipv4_forward") as apply_ipv4_forward: + manager.set_ipv4_forward(False) + + assert not manager._ipv4_forward + apply_ipv4_forward.assert_called_once_with(False) + + def test_skips_redundant_ipv4_forwarding_writes(self): + manager = build_tethering_manager() + with patch.object(manager, "_apply_ipv4_forward") as apply_ipv4_forward: + manager.set_ipv4_forward(False) + manager.set_ipv4_forward(False) + + apply_ipv4_forward.assert_called_once_with(False) + + def test_retries_ipv4_forwarding_after_failed_write(self): + manager = build_tethering_manager() + with patch.object(manager, "_apply_ipv4_forward", side_effect=(RuntimeError("sysctl failed"), None)) as apply_ipv4_forward: + with self.assertRaisesRegex(RuntimeError, "sysctl failed"): + manager.set_ipv4_forward(False) + + assert manager._ipv4_forward + manager.set_ipv4_forward(False) + + assert not manager._ipv4_forward + assert apply_ipv4_forward.call_count == 2 + + def test_ipv4_forwarding_write_has_kernel_postcondition(self): + manager = build_tethering_manager() + with ( + patch.object(wifi_manager_module.subprocess, "run") as run, + patch.object(wifi_manager_module.Path, "read_text", return_value="0\n"), + ): + manager._apply_ipv4_forward(False) + + run.assert_called_once_with(["sudo", "sysctl", "net.ipv4.ip_forward=0"], check=True) + + with self.assertRaisesRegex(RuntimeError, "actual='1'"): + with ( + patch.object(wifi_manager_module.subprocess, "run"), + patch.object(wifi_manager_module.Path, "read_text", return_value="1\n"), + ): + manager._apply_ipv4_forward(False) + + def test_stop_removes_nat_and_restores_station(self): + manager = build_tethering_manager() + manager._ctrl = MagicMock() + + with ( + patch.object(manager, "_ensure_wpa_supplicant") as ensure_wpa_supplicant, + patch.object(wifi_manager_module.shutil, "which", return_value="/usr/sbin/iptables-legacy"), + patch.object(wifi_manager_module, "stop_tethering_dnsmasq"), + patch.object(wifi_manager_module, "stop_wpa_supplicant"), + patch.object(wifi_manager_module, "generate_wpa_conf"), + patch.object(wifi_manager_module.time, "sleep"), + patch.object(wifi_manager_module.subprocess, "run", return_value=MagicMock(returncode=1)) as run, + patch.object(manager, "_apply_ipv4_forward") as apply_ipv4_forward, + ): + manager._stop_tethering() + expected_cleanup = wifi_manager_module._tethering_firewall_jumps("-D") + for command, chain in wifi_manager_module._tethering_firewall_chains(): + expected_cleanup.extend(( + [*command, "-F", chain], + [*command, "-X", chain], + )) + + commands = [item.args[0] for item in run.call_args_list] + for command in expected_cleanup: + assert command in commands + apply_ipv4_forward.assert_called_once_with(False) + ensure_wpa_supplicant.assert_called_once() + assert not manager._tethering_active + assert manager._wifi_state == WifiState() diff --git a/openpilot/system/ui/lib/tests/test_wpa_ctrl.py b/openpilot/system/ui/lib/tests/test_wpa_ctrl.py new file mode 100644 index 00000000000000..30c11ad442d99c --- /dev/null +++ b/openpilot/system/ui/lib/tests/test_wpa_ctrl.py @@ -0,0 +1,639 @@ +from pathlib import Path +import socket +import tempfile +import threading +import time +from typing import cast +from unittest import TestCase +from unittest.mock import MagicMock, call, patch + +from openpilot.system.ui.lib import wpa_ctrl as wpa_ctrl_module +from openpilot.system.ui.lib.wpa_ctrl import ( + SecurityType, + WpaCtrl, + decode_ssid, + normalize_ssid, + parse_event_network_id, + parse_event_ssid, + parse_scan_results, + parse_status, + flags_to_security_type, + dbm_to_percent, +) + + +class TestParseStatus(TestCase): + def test_values(self): + cases = ( + ("", {}), + ("ssid=My=Network\n", {"ssid": "My=Network"}), + ('ssid=My \\"Home\\"\n', {"ssid": 'My "Home"'}), + ( + "wpa_state=COMPLETED\nssid=MyNet\nip_address=10.0.0.5\n", + {"wpa_state": "COMPLETED", "ssid": "MyNet", "ip_address": "10.0.0.5"}, + ), + ( + "wpa_state=COMPLETED\nssid=caf\\xc3\\xa9\nip_address=10.0.0.5\n", # codespell:ignore caf + {"wpa_state": "COMPLETED", "ssid": "café", "ip_address": "10.0.0.5"}, + ), + ( + "bssid=00:11:22:33:44:55\nssid=\\x41\n", + {"bssid": "00:11:22:33:44:55", "ssid": "A"}, + ), + ) + for raw, expected in cases: + with self.subTest(raw=raw): + assert parse_status(raw) == expected + + +class TestParseEventSsid(TestCase): + def test_values(self): + cases = ( + ('id=0 ssid="MyNetwork" reason=WRONG_KEY', "MyNetwork"), + ("id=0 reason=WRONG_KEY", None), + (r'id=0 ssid="My \"Home\"" reason=WRONG_KEY', 'My "Home"'), + (r'id=0 ssid="caf\xc3\xa9" reason=WRONG_KEY', "café"), # codespell:ignore caf + ) + for event, expected in cases: + with self.subTest(event=event): + assert parse_event_ssid(event) == expected + + +class TestParseEventNetworkId(TestCase): + def test_values(self): + cases = ( + ('id=42 ssid="MyNetwork" reason=WRONG_KEY', "42"), + ('ssid="MyNetwork" reason=WRONG_KEY', None), + ) + for event, expected in cases: + with self.subTest(event=event): + assert parse_event_network_id(event) == expected + + +class TestFlagsToSecurityType(TestCase): + def test_security_types(self): + cases = ( + ("[WPA2-PSK-CCMP][ESS]", SecurityType.WPA), + ("[RSN-PSK-CCMP]", SecurityType.WPA), + ("[WPA-PSK-TKIP]", SecurityType.WPA), + ("[WPA2-PSK-CCMP][SAE]", SecurityType.WPA), + ("[RSN-PSK-CCMP][SAE-CCMP]", SecurityType.WPA), + ("[WPA2-PSK+SAE-CCMP][ESS]", SecurityType.WPA), + ("[WPA2-PSK+EAP-CCMP][ESS]", SecurityType.WPA), + ("[WPA2-PSK-SHA256+SAE-CCMP][ESS]", SecurityType.UNSUPPORTED), + ("[WPA2-PSK-SHA256-CCMP][ESS]", SecurityType.UNSUPPORTED), + ("[WPA-PSK-SHA256-TKIP][ESS]", SecurityType.UNSUPPORTED), + ("[WPA2-PSK-CCMP][WPA2-PSK-SHA256-CCMP][ESS]", SecurityType.WPA), + ("[SAE]", SecurityType.UNSUPPORTED), + ("[SAE-CCMP]", SecurityType.UNSUPPORTED), + ("[OWE-CCMP][ESS]", SecurityType.UNSUPPORTED), + ("[OWE-TRANSITION][ESS]", SecurityType.UNSUPPORTED), + ("[DPP][ESS]", SecurityType.UNSUPPORTED), + ("[OSEN][ESS]", SecurityType.UNSUPPORTED), + ("[FILS-SHA256][ESS]", SecurityType.UNSUPPORTED), + ("[ESS]", SecurityType.OPEN), + ("", SecurityType.OPEN), + ("[WPA2-EAP-CCMP]", SecurityType.UNSUPPORTED), + ("[802.1X]", SecurityType.UNSUPPORTED), + ) + for flags, expected in cases: + assert flags_to_security_type(flags) == expected + + +class TestDbmToPercent(TestCase): + def test_values(self): + cases = ((-120, 0), (-100, 0), (-92, 14), (-81, 32), (-74, 44), (-70, 50), (-40, 100), (-30, 100)) + for dbm, expected in cases: + with self.subTest(dbm=dbm): + assert dbm_to_percent(dbm) == expected + + +class TestParseScanResults(TestCase): + HEADER = "bssid / frequency / signal level / flags / ssid\n" + + def test_basic(self): + raw = self.HEADER + "00:11:22:33:44:55\t2437\t-65\t[WPA2-PSK-CCMP][ESS]\tMyNetwork\n" + results = parse_scan_results(raw) + assert len(results) == 1 + r = results[0] + assert r.bssid == "00:11:22:33:44:55" + assert r.freq == 2437 + assert r.signal == -65 + assert r.ssid == "MyNetwork" + + def test_ssid_values(self): + cases = ( + ("00:11:22:33:44:55\t2437\t-65\t[ESS]\t\n", ""), + (f"00:11:22:33:44:55\t2437\t-65\t[ESS]\t{'\\x00' * 32}\n", ""), + ('00:11:22:33:44:55\t2437\t-65\t[ESS]\tcaf\\xc3\\xa9 \\"home\\"\n', 'café "home"'), + ("00:11:22:33:44:55\t2437\t-65\t[ESS]\tMyNet \n", "MyNet "), + ("00:11:22:33:44:55\t2437\t-65\t[ESS]\n", ""), + ("garbage\n00:11:22:33:44:55\t2437\t-65\t[ESS]\tGood\n", "Good"), + ) + for body, expected in cases: + with self.subTest(body=body): + results = parse_scan_results(self.HEADER + body) + assert len(results) == 1 + assert results[0].ssid == expected + +class TestDecodeSsid(TestCase): + def test_values(self): + cases = ( + ("MyNetwork", "MyNetwork"), + ("", ""), + ("\\x41\\x42", "AB"), + ("caf\\xc3\\xa9", "café"), # codespell:ignore caf + ("\\xe6\\x97\\xa5\\xe6\\x9c\\xac", "日本"), + ("\\xf0\\x9f\\x9a\\x97", "🚗"), + ("\\x1Z", "\x01Z"), + ("\\xA", "\x0a"), + ("\\xGZ", "GZ"), + ("\\101", "A"), + ("\\0X", "\x00X"), + ("\\78", "\x078"), + ("\\\\", "\\"), + ('\\"', '"'), + ("\\n", "\n"), + ("\\r", "\r"), + ("\\t", "\t"), + ("\\e", "\x1b"), + ("a\\qb", "aqb"), + ("abc\\", "abc"), + ("\\x00" * 32, ""), + ("A\\x00B", "A\x00B"), + ) + for encoded, expected in cases: + with self.subTest(encoded=encoded): + assert decode_ssid(encoded) == expected + + def test_invalid_utf8_preserves_identity(self): + for encoded, expected in (("\\xFF", b"\xff"), ("\\x80", b"\x80")): + with self.subTest(encoded=encoded): + decoded = decode_ssid(encoded) + assert decoded.encode("utf-8", errors="surrogateescape") == expected + assert normalize_ssid(decoded) == "\ufffd" + + +class TestWpaConfig(TestCase): + def setUp(self): + self.path = Path(self.enterContext(tempfile.TemporaryDirectory())) / "wpa_supplicant.conf" + + def generate(self, ssid, profile): + store = MagicMock() + store.get_profiles.return_value = [(ssid, profile)] + wpa_ctrl_module.generate_wpa_conf(store, str(self.path)) + return self.path.read_text() + + def test_emits_saved_network_priority(self): + assert " priority=42\n" in self.generate("Preferred", {"psk": "password123", "hidden": False, "priority": 42}) + + def test_emits_saved_profile_identifier(self): + assert ' id_str="11111111-1111-1111-1111-111111111111"\n' in self.generate( + "Preferred", {"psk": "password123", "uuid": "11111111-1111-1111-1111-111111111111"}, + ) + + def test_grants_control_access_to_netdev_group(self): + assert "ctrl_interface=DIR=/run/openpilot-wpa GROUP=netdev\n" in self.generate("Test", {"psk": "password123"}) + + def test_emits_saved_bssid_restriction(self): + assert " bssid=00:11:22:33:44:55\n" in self.generate( + "Pinned", {"psk": "password123", "bssid": "00:11:22:33:44:55"}, + ) + + def test_encodes_control_characters_in_ssid_losslessly(self): + assert f" ssid={b'Line\nBreak\r'.hex()}\n" in self.generate("Line\nBreak\r", {"psk": "password123"}) + def test_explicit_open_security_does_not_render_stale_psk(self): + config = self.generate("Open", {"security": SecurityType.OPEN, "psk": "stale-password"}) + assert " key_mgmt=NONE\n" in config + assert " psk=" not in config + + def test_rejects_control_characters_in_passphrases(self): + for password in ("password\n", "password\r", "password\x00", "pass\tword"): + with self.subTest(password=password): + assert not wpa_ctrl_module.is_valid_psk(password) + + + +class _RacySock: + def __init__(self): + self._lock = threading.Lock() + self._last_sent: bytes = b"" + + def send(self, data: bytes): + with self._lock: + self._last_sent = data + + def recv(self, _bufsize: int) -> bytes: + time.sleep(0.005) + with self._lock: + return b"REPLY:" + self._last_sent + + +class TestWpaCtrlRequestSerialization(TestCase): + def test_request_pairs_reply_with_command_under_concurrency(self): + ctrl = WpaCtrl() + ctrl._sock = cast(socket.socket, _RacySock()) + + results: dict[str, str] = {} + errors: list[BaseException] = [] + + def worker(cmd: str): + try: + results[cmd] = ctrl.request(cmd) + except BaseException as exc: + errors.append(exc) + + threads = [threading.Thread(target=worker, args=(cmd,)) + for cmd in ("STATUS", "SCAN_RESULTS", "LIST_NETWORKS", "PING")] + for t in threads: + t.start() + for t in threads: + t.join(timeout=5) + + assert not errors, errors + for cmd in ("STATUS", "SCAN_RESULTS", "LIST_NETWORKS", "PING"): + assert results[cmd] == f"REPLY:{cmd}", \ + f"concurrent request mispaired reply for {cmd}: {results[cmd]}" + + ctrl._sock = None + + def test_interrupt_wakes_blocked_request(self): + request_started = threading.Event() + interrupted = threading.Event() + + class BlockingSock: + def send(self, _data): + request_started.set() + + def recv(self, _bufsize): + interrupted.wait(1) + raise OSError("interrupted") + + def shutdown(self, _how): + interrupted.set() + + def close(self): + pass + + ctrl = WpaCtrl() + ctrl._sock = cast(socket.socket, BlockingSock()) + errors = [] + + def run_request(): + try: + ctrl.request("STATUS") + except OSError as exc: + errors.append(str(exc)) + + request = threading.Thread(target=run_request) + request.start() + assert request_started.wait(1) + + ctrl.interrupt() + request.join(1) + + assert interrupted.is_set() + assert not request.is_alive() + assert errors == ["interrupted"] + ctrl._sock = None + + +class TestNetworkManagerCompatibility(TestCase): + def test_unmanage_skips_when_nmcli_is_absent(self): + with ( + patch.object(wpa_ctrl_module.shutil, "which", return_value=None), + patch.object(wpa_ctrl_module.subprocess, "run") as run, + ): + assert wpa_ctrl_module._unmanage_wlan0() + + run.assert_not_called() + + def test_unmanage_uses_discovered_nmcli(self): + with ( + patch.object(wpa_ctrl_module.shutil, "which", return_value="/usr/bin/nmcli"), + patch.object(wpa_ctrl_module.subprocess, "run") as run, + ): + run.return_value.returncode = 0 + + assert wpa_ctrl_module._unmanage_wlan0() + run.assert_called_once_with( + ["sudo", "/usr/bin/nmcli", "dev", "set", "wlan0", "managed", "no"], + capture_output=True, + ) + + def test_unmanage_failure_is_nonfatal(self): + with ( + patch.object(wpa_ctrl_module.shutil, "which", return_value="/usr/bin/nmcli"), + patch.object(wpa_ctrl_module.subprocess, "run") as run, + ): + run.return_value.returncode = 10 + + assert not wpa_ctrl_module._unmanage_wlan0() + + +class TestTetheringDnsmasqOwnership(TestCase): + def test_pidfile_proves_exact_supplicant_ownership(self): + pid = 123 + command = "\0".join(( + "/usr/sbin/wpa_supplicant", "-B", "-i", "wlan0", "-c", + wpa_ctrl_module.WPA_SUPPLICANT_CONF, "-P", wpa_ctrl_module.WPA_PID_FILE, "", + )).encode() + + with ( + patch.object(Path, "read_text", return_value=str(pid)), + patch.object(Path, "read_bytes", return_value=command), + ): + assert wpa_ctrl_module.wpa_supplicant_running(wpa_ctrl_module.WPA_SUPPLICANT_CONF) + assert not wpa_ctrl_module.wpa_supplicant_running(wpa_ctrl_module.WPA_AP_CONF) + + def test_stop_waits_for_owned_supplicant_before_releasing_paths(self): + with ( + patch.object(wpa_ctrl_module, "_owned_wpa_pid", return_value=123), + patch.object(wpa_ctrl_module, "_process_start_time", return_value="456"), + patch.object(wpa_ctrl_module, "_wait_for_process_exit", return_value=True) as wait_for_exit, + patch.object(wpa_ctrl_module.subprocess, "run") as run, + ): + wpa_ctrl_module.stop_wpa_supplicant(wpa_ctrl_module.WPA_SUPPLICANT_CONF) + + wait_for_exit.assert_called_once_with(123, "456") + assert [item.args[0] for item in run.call_args_list] == [ + ["sudo", "kill", "-TERM", "--", "123"], + ["sudo", "rm", "-f", wpa_ctrl_module.WPA_PID_FILE, wpa_ctrl_module.WPA_CTRL_PATH], + ] + + def test_stop_escalates_to_sigkill_after_timeout(self): + with ( + patch.object(wpa_ctrl_module, "_owned_wpa_pid", return_value=123), + patch.object(wpa_ctrl_module, "_process_start_time", return_value="456"), + patch.object(wpa_ctrl_module, "_wait_for_process_exit", side_effect=[False, True]), + patch.object(wpa_ctrl_module.subprocess, "run") as run, + ): + wpa_ctrl_module.stop_wpa_supplicant(wpa_ctrl_module.WPA_SUPPLICANT_CONF) + + assert [item.args[0] for item in run.call_args_list] == [ + ["sudo", "kill", "-TERM", "--", "123"], + ["sudo", "kill", "-KILL", "--", "123"], + ["sudo", "rm", "-f", wpa_ctrl_module.WPA_PID_FILE, wpa_ctrl_module.WPA_CTRL_PATH], + ] + + def test_stop_retains_ownership_when_process_survives_sigkill(self): + with ( + patch.object(wpa_ctrl_module, "_owned_wpa_pid", return_value=123), + patch.object(wpa_ctrl_module, "_process_start_time", return_value="456"), + patch.object(wpa_ctrl_module, "_wait_for_process_exit", return_value=False), + patch.object(wpa_ctrl_module.subprocess, "run") as run, + self.assertRaises(RuntimeError), + ): + wpa_ctrl_module.stop_wpa_supplicant(wpa_ctrl_module.WPA_SUPPLICANT_CONF) + + assert [item.args[0] for item in run.call_args_list] == [ + ["sudo", "kill", "-TERM", "--", "123"], + ["sudo", "kill", "-KILL", "--", "123"], + ] + + def test_wait_rejects_pid_reuse(self): + with ( + patch.object(wpa_ctrl_module, "_process_start_time", return_value="new-start"), + self.assertRaises(RuntimeError), + ): + wpa_ctrl_module._wait_for_process_exit(123, "old-start") + + def test_stop_targets_only_openpilot_tethering(self): + with patch.object(wpa_ctrl_module.subprocess, "run") as run: + wpa_ctrl_module.stop_tethering_dnsmasq() + + run.assert_called_once_with( + ["sudo", "pkill", "-f", wpa_ctrl_module.TETHERING_DNSMASQ_PATTERN], + check=False, + ) + + +class TestSupplicantBringup(TestCase): + def test_bringup_aborts_when_owned_daemon_cannot_stop(self): + with ( + patch.object(wpa_ctrl_module.os.path, "exists", return_value=True), + patch.object(wpa_ctrl_module, "wpa_supplicant_running", return_value=False), + patch.object(wpa_ctrl_module, "_unmanage_wlan0", return_value=True), + patch.object(wpa_ctrl_module, "stop_wpa_supplicant", side_effect=RuntimeError("still running")), + patch.object(wpa_ctrl_module, "prepare_wpa_runtime") as prepare_runtime, + patch.object(wpa_ctrl_module.subprocess, "run") as run, + self.assertRaises(RuntimeError), + ): + wpa_ctrl_module.ensure_wpa_supplicant(lambda: False) + + prepare_runtime.assert_not_called() + assert not any(item.args[0][:2] == ["sudo", "wpa_supplicant"] for item in run.call_args_list) + + def test_reconciles_existing_station_configuration(self): + ctrl = MagicMock() + ctrl.request.side_effect = lambda command: ( + "wpa_state=COMPLETED\nmode=station\nssid=TestNet\n" if command == "STATUS" else "OK" + ) + station_reconfigured = MagicMock() + with ( + patch.object(wpa_ctrl_module.os.path, "exists", return_value=True), + patch.object( + wpa_ctrl_module, + "wpa_supplicant_running", + side_effect=lambda conf: conf == wpa_ctrl_module.WPA_SUPPLICANT_CONF, + ), + patch.object(wpa_ctrl_module, "try_attach_ctrl", return_value=ctrl), + patch.object(wpa_ctrl_module, "_unmanage_wlan0") as unmanage, + patch.object(wpa_ctrl_module, "stop_wpa_supplicant") as kill, + patch.object(wpa_ctrl_module.subprocess, "run") as run, + ): + result = wpa_ctrl_module.ensure_wpa_supplicant(lambda: False, station_reconfigured) + + assert result is ctrl + assert call("RECONFIGURE") in ctrl.request.call_args_list + station_reconfigured.assert_called_once_with("TestNet") + unmanage.assert_not_called() + kill.assert_not_called() + run.assert_not_called() + + def test_failed_station_reconciliation_restarts_daemon(self): + stale_ctrl = MagicMock() + stale_ctrl.request.return_value = "FAIL" + fresh_ctrl = MagicMock() + station_checks = 0 + + def running(conf): + nonlocal station_checks + if conf == wpa_ctrl_module.WPA_AP_CONF: + return False + station_checks += 1 + return station_checks in (1, 3) + + with ( + patch.object( + wpa_ctrl_module.os.path, + "exists", + side_effect=lambda path: path == "/sys/class/net/wlan0", + ), + patch.object(wpa_ctrl_module, "wpa_supplicant_running", side_effect=running), + patch.object(wpa_ctrl_module, "try_attach_ctrl", side_effect=[stale_ctrl, fresh_ctrl]), + patch.object(wpa_ctrl_module, "_unmanage_wlan0", return_value=True), + patch.object(wpa_ctrl_module, "stop_wpa_supplicant") as kill, + patch.object(wpa_ctrl_module, "stop_tethering_dnsmasq"), + patch.object(wpa_ctrl_module.time, "sleep"), + patch.object(wpa_ctrl_module.subprocess, "run") as run, + ): + result = wpa_ctrl_module.ensure_wpa_supplicant(lambda: False) + + assert result is fresh_ctrl + stale_ctrl.close.assert_called_once() + kill.assert_called_once_with(wpa_ctrl_module.WPA_SUPPLICANT_CONF) + assert [ + "sudo", "wpa_supplicant", "-B", "-i", "wlan0", + "-c", wpa_ctrl_module.WPA_SUPPLICANT_CONF, "-P", wpa_ctrl_module.WPA_PID_FILE, "-D", "nl80211", + ] in [item.args[0] for item in run.call_args_list] + + def test_attaches_existing_hotspot_before_station_cleanup(self): + ctrl = MagicMock() + with ( + patch.object(wpa_ctrl_module.os.path, "exists", return_value=True), + patch.object( + wpa_ctrl_module, + "wpa_supplicant_running", + side_effect=lambda conf: conf == wpa_ctrl_module.WPA_AP_CONF, + ), + patch.object(wpa_ctrl_module, "try_attach_ctrl", return_value=ctrl), + patch.object(wpa_ctrl_module, "_unmanage_wlan0") as unmanage, + patch.object(wpa_ctrl_module, "stop_wpa_supplicant") as kill, + ): + result = wpa_ctrl_module.ensure_wpa_supplicant(lambda: False) + + assert result is ctrl + unmanage.assert_not_called() + kill.assert_not_called() + + def test_unreachable_hotspot_falls_back_to_station_bringup(self): + ctrl = MagicMock() + abandoned_ap = MagicMock() + ap_running = True + station_running = False + + def running(conf): + return ap_running if conf == wpa_ctrl_module.WPA_AP_CONF else station_running + + def stop(conf): + nonlocal ap_running, station_running + if conf == wpa_ctrl_module.WPA_AP_CONF: + ap_running = False + else: + station_running = False + + def run(command, **_kwargs): + nonlocal station_running + if command[:2] == ["sudo", "wpa_supplicant"]: + station_running = True + return MagicMock(returncode=0) + + with ( + patch.object( + wpa_ctrl_module.os.path, + "exists", + side_effect=lambda path: path == "/sys/class/net/wlan0", + ), + patch.object(wpa_ctrl_module, "wpa_supplicant_running", side_effect=running), + patch.object(wpa_ctrl_module, "try_attach_ctrl", side_effect=lambda: None if ap_running else ctrl), + patch.object(wpa_ctrl_module, "_unmanage_wlan0", return_value=True), + patch.object(wpa_ctrl_module, "stop_wpa_supplicant", side_effect=stop) as kill, + patch.object(wpa_ctrl_module, "stop_tethering_dnsmasq"), + patch.object(wpa_ctrl_module.time, "sleep"), + patch.object(wpa_ctrl_module.subprocess, "run", side_effect=run), + ): + result = wpa_ctrl_module.ensure_wpa_supplicant(lambda: False, on_abandoned_ap=abandoned_ap) + + assert result is ctrl + abandoned_ap.assert_called_once() + assert call(wpa_ctrl_module.WPA_AP_CONF) in kill.call_args_list + assert call(wpa_ctrl_module.WPA_SUPPLICANT_CONF) in kill.call_args_list + + def test_spawns_owned_station_daemon(self): + ctrl = MagicMock() + station_checks = 0 + + def running(conf): + nonlocal station_checks + if conf == wpa_ctrl_module.WPA_AP_CONF: + return False + station_checks += 1 + return station_checks > 1 + + with ( + patch.object( + wpa_ctrl_module.os.path, + "exists", + side_effect=lambda path: path == "/sys/class/net/wlan0", + ), + patch.object(wpa_ctrl_module, "wpa_supplicant_running", side_effect=running), + patch.object(wpa_ctrl_module, "try_attach_ctrl", return_value=ctrl), + patch.object(wpa_ctrl_module, "_unmanage_wlan0", return_value=True), + patch.object(wpa_ctrl_module, "stop_wpa_supplicant") as kill, + patch.object(wpa_ctrl_module, "stop_tethering_dnsmasq"), + patch.object(wpa_ctrl_module.time, "sleep"), + patch.object(wpa_ctrl_module.subprocess, "run") as run, + ): + result = wpa_ctrl_module.ensure_wpa_supplicant(lambda: False) + + assert result is ctrl + kill.assert_called_once_with(wpa_ctrl_module.WPA_SUPPLICANT_CONF) + assert [ + "sudo", "wpa_supplicant", "-B", "-i", "wlan0", + "-c", wpa_ctrl_module.WPA_SUPPLICANT_CONF, "-P", wpa_ctrl_module.WPA_PID_FILE, "-D", "nl80211", + ] in [item.args[0] for item in run.call_args_list] + + def test_failed_networkmanager_handoff_does_not_mutate_interface(self): + with ( + patch.object( + wpa_ctrl_module.os.path, + "exists", + side_effect=lambda path: path == "/sys/class/net/wlan0", + ), + patch.object(wpa_ctrl_module, "wpa_supplicant_running", return_value=False), + patch.object(wpa_ctrl_module, "_unmanage_wlan0", return_value=False), + patch.object(wpa_ctrl_module, "stop_wpa_supplicant") as kill, + patch.object(wpa_ctrl_module, "stop_tethering_dnsmasq") as stop_dnsmasq, + patch.object(wpa_ctrl_module.time, "sleep"), + patch.object(wpa_ctrl_module.subprocess, "run") as run, + ): + result = wpa_ctrl_module.ensure_wpa_supplicant(lambda: False) + + assert result is None + kill.assert_not_called() + stop_dnsmasq.assert_not_called() + run.assert_not_called() + + def test_foreign_control_socket_is_not_attached_after_handoff(self): + def exists(path): + return path in ("/sys/class/net/wlan0", "/var/run/wpa_supplicant/wlan0") + + with ( + patch.object(wpa_ctrl_module.os.path, "exists", side_effect=exists), + patch.object(wpa_ctrl_module, "wpa_supplicant_running", return_value=False), + patch.object(wpa_ctrl_module, "try_attach_ctrl") as attach, + patch.object(wpa_ctrl_module, "_unmanage_wlan0", return_value=True), + patch.object(wpa_ctrl_module, "stop_wpa_supplicant"), + patch.object(wpa_ctrl_module, "stop_tethering_dnsmasq"), + patch.object(wpa_ctrl_module.time, "sleep"), + patch.object(wpa_ctrl_module.subprocess, "run"), + ): + result = wpa_ctrl_module.ensure_wpa_supplicant(lambda: False) + + assert result is None + attach.assert_not_called() + + def test_exit_before_interface_mutation(self): + with ( + patch.object(wpa_ctrl_module.os.path, "exists", return_value=True), + patch.object(wpa_ctrl_module, "_unmanage_wlan0") as unmanage, + patch.object(wpa_ctrl_module, "stop_wpa_supplicant") as kill, + patch.object(wpa_ctrl_module.subprocess, "run") as run, + ): + result = wpa_ctrl_module.ensure_wpa_supplicant(lambda: True) + + assert result is None + unmanage.assert_not_called() + kill.assert_not_called() + run.assert_not_called() diff --git a/openpilot/system/ui/lib/udhcpc.script b/openpilot/system/ui/lib/udhcpc.script new file mode 100755 index 00000000000000..f6f1a164302229 --- /dev/null +++ b/openpilot/system/ui/lib/udhcpc.script @@ -0,0 +1,34 @@ +#!/bin/sh + +default_script=${UDHCPC_DEFAULT_SCRIPT:-/etc/udhcpc/default.script} +# Match NetworkManager's Wi-Fi route metric; AGNOS LTE uses 1000 +wifi_route_metric=600 +"$default_script" "$1" +status=$? + +if [ "$status" -ne 0 ]; then + exit "$status" +fi + +case "$1" in + bound|renew) + router=${router%% *} + [ -n "$router" ] || exit 1 + ip -4 route flush default dev "$interface" || exit $? + ip -4 route replace default via "$router" dev "$interface" metric "$wifi_route_metric" || exit $? + defaults=$(ip -4 route show default dev "$interface") || exit $? + printf '%s\n' "$defaults" | awk -v iface="$interface" -v router="$router" -v target="$wifi_route_metric" ' + NF { + count++ + dev = via = metric = "" + for (i = 1; i <= NF; i++) { + if ($i == "dev" && i < NF) dev = $(i + 1) + if ($i == "via" && i < NF) via = $(i + 1) + if ($i == "metric" && i < NF) metric = $(i + 1) + } + if ($1 != "default" || (dev != "" && dev != iface) || via != router || metric != target) exit 1 + } + END { if (count != 1) exit 1 } + ' || exit $? + ;; +esac diff --git a/openpilot/system/ui/lib/wifi_manager.py b/openpilot/system/ui/lib/wifi_manager.py index 26474be942c1d0..6b6db258740368 100644 --- a/openpilot/system/ui/lib/wifi_manager.py +++ b/openpilot/system/ui/lib/wifi_manager.py @@ -1,30 +1,29 @@ import atexit +import shutil +import subprocess import threading import time import uuid -import subprocess from collections.abc import Callable -from dataclasses import dataclass, replace +from dataclasses import dataclass from enum import IntEnum -from typing import TYPE_CHECKING, Any - -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 +from pathlib import Path +from typing import TYPE_CHECKING from openpilot.common.swaglog import cloudlog -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_ACTIVE_CONNECTION_IFACE, - NM_IP4_CONFIG_IFACE, NM_PROPERTIES_IFACE, NMDeviceState, NMDeviceStateReason) +from openpilot.common.utils import atomic_write +from openpilot.system.ui.lib.dhcp_client import DhcpClient +from openpilot.system.ui.lib.wifi_network_store import MeteredType, NetworkStore +from openpilot.system.ui.lib.wpa_ctrl import (WpaCtrl, WpaCtrlMonitor, SecurityType, + WPA_SUPPLICANT_CONF, WPA_AP_CONF, + WPA_CTRL_INTERFACE, WPA_PID_FILE, + stop_wpa_supplicant, wpa_supplicant_running, + sanitize_for_conf, format_psk_value, format_ssid_value, is_valid_psk, is_valid_ssid, + generate_wpa_conf, parse_event_network_id, parse_event_ssid, + parse_scan_results, flags_to_security_type, + parse_status, dbm_to_percent, decode_ssid, + ensure_wpa_supplicant, prepare_wpa_runtime, try_attach_ctrl, + stop_tethering_dnsmasq, tethering_dnsmasq_running) if TYPE_CHECKING: from openpilot.common.params import Params @@ -35,157 +34,206 @@ Params = None TETHERING_IP_ADDRESS = "192.168.43.1" +TETHERING_SUBNET = "192.168.43.0/24" +TETHERING_NAT_COMMENT = "openpilot-tethering" +TETHERING_NAT_CHAIN = "OPENPILOT_TETHERING_NAT" +TETHERING_INPUT_CHAIN = "OPENPILOT_TETHERING_INPUT" +TETHERING_FORWARD_CHAIN = "OPENPILOT_TETHERING_FORWARD" DEFAULT_TETHERING_PASSWORD = "swagswagcomma" -SIGNAL_QUEUE_SIZE = 10 +TETHERING_PASSWORD_FILE = "/data/tethering_password" SCAN_PERIOD_SECONDS = 5 +CONNECTING_STALE_TIMEOUT_SECONDS = 5 +NETWORK_NOT_FOUND_EVENTS_REQUIRED = 2 +FORGET_RECONCILIATION_RETRY_SECONDS = 5 +# Ignore stale WRONG_KEY events after a fast retry +WRONG_KEY_DEBOUNCE_SECONDS = 2.0 -DEBUG = False -_dbus_call_idx = 0 - -def normalize_ssid(ssid: str) -> str: - return ssid.replace("’", "'") # for iPhone hotspots +@dataclass(frozen=True) +class Network: + ssid: str + strength: int + security_type: SecurityType + is_tethering: bool -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) +def sort_networks(networks: list[Network], current_ssid: str | None, saved_ssids: set[str]) -> list[Network]: + """Sort networks: connected first, then saved, then by signal strength.""" + return sorted(networks, key=lambda n: (n.ssid != current_ssid, n.ssid not in saved_ssids, -n.strength, n.ssid.lower())) -class SecurityType(IntEnum): - OPEN = 0 - WPA = 1 - WPA2 = 2 - WPA3 = 3 - UNSUPPORTED = 4 +class ConnectStatus(IntEnum): + DISCONNECTED = 0 + CONNECTING = 1 + CONNECTED = 2 -class MeteredType(IntEnum): - UNKNOWN = 0 - YES = 1 - NO = 2 +@dataclass(frozen=True) +class WifiState: + ssid: str | None = None + status: ConnectStatus = ConnectStatus.DISCONNECTED -def get_security_type(flags: int, wpa_flags: int, rsn_flags: int) -> SecurityType: - wpa_props = wpa_flags | rsn_flags +class StationOperationKind(IntEnum): + CONNECT = 0 + ACTIVATE = 1 + ASSOCIATED = 2 + FORGET = 3 + AUTH_FAILURE = 4 + TIMEOUT = 5 - # 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 StationOperation: + epoch: int + kind: StationOperationKind + ssid: str | None + profile_uuid: str | None = None + runtime_network_id: str | None = None @dataclass(frozen=True) -class Network: +class PendingConnection: 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, - ) + password: str + hidden: bool + security: SecurityType + epoch: int + profile_uuid: str + network_id: str | None = None @dataclass(frozen=True) -class AccessPoint: +class PendingForgetReconciliation: 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, - ) + epoch: int + forget_active: bool + cleanup_required: bool + + +def _iptables_executable() -> str: + # AGNOS 18.7 requires iptables-legacy for NAT + return "iptables-legacy" if shutil.which("iptables-legacy") is not None else "iptables" + + +def _tethering_firewall_rules(op: str) -> list[list[str]]: + # Match NetworkManager's source-subnet MASQUERADE so NAT survives uplink changes + command = ["sudo", _iptables_executable()] + tagged = ["-m", "comment", "--comment", TETHERING_NAT_COMMENT] + return [ + [*command, "-t", "nat", op, TETHERING_NAT_CHAIN, + "-s", TETHERING_SUBNET, "!", "-d", TETHERING_SUBNET, + *tagged, "-j", "MASQUERADE"], + [*command, op, TETHERING_INPUT_CHAIN, "-i", "wlan0", "-p", "udp", "--dport", "67", *tagged, "-j", "ACCEPT"], + [*command, op, TETHERING_INPUT_CHAIN, "-i", "wlan0", "-p", "udp", "--dport", "53", *tagged, "-j", "ACCEPT"], + [*command, op, TETHERING_INPUT_CHAIN, "-i", "wlan0", "-p", "tcp", "--dport", "53", *tagged, "-j", "ACCEPT"], + [*command, op, TETHERING_FORWARD_CHAIN, "-i", "wlan0", "-s", TETHERING_SUBNET, *tagged, "-j", "ACCEPT"], + [*command, op, TETHERING_FORWARD_CHAIN, "-o", "wlan0", "-d", TETHERING_SUBNET, + "-m", "conntrack", "--ctstate", "ESTABLISHED,RELATED", *tagged, "-j", "ACCEPT"], + ] +def _tethering_firewall_chains() -> list[tuple[list[str], str]]: + command = ["sudo", _iptables_executable()] + return [ + ([*command, "-t", "nat"], TETHERING_NAT_CHAIN), + (command, TETHERING_INPUT_CHAIN), + (command, TETHERING_FORWARD_CHAIN), + ] + + +def _tethering_firewall_jumps(op: str) -> list[list[str]]: + command = ["sudo", _iptables_executable()] + tagged = ["-m", "comment", "--comment", TETHERING_NAT_COMMENT] + return [ + [*command, "-t", "nat", op, "POSTROUTING", *tagged, "-j", TETHERING_NAT_CHAIN], + [*command, op, "INPUT", *tagged, "-j", TETHERING_INPUT_CHAIN], + [*command, op, "FORWARD", *tagged, "-j", TETHERING_FORWARD_CHAIN], + ] + + +def _delete_firewall_jumps(): + for jump in _tethering_firewall_jumps("-D"): + while subprocess.run(jump, capture_output=True, check=False).returncode == 0: + pass -class ConnectStatus(IntEnum): - DISCONNECTED = 0 - CONNECTING = 1 - CONNECTED = 2 +def _install_tethering_firewall_rules(): + for command, chain in _tethering_firewall_chains(): + subprocess.run([*command, "-N", chain], capture_output=True, check=False) + subprocess.run([*command, "-F", chain], check=True) + for rule in _tethering_firewall_rules("-A"): + subprocess.run(rule, check=True) + # Exactly one jump from each shared built-in chain into our owned chains. + _delete_firewall_jumps() + for jump in _tethering_firewall_jumps("-A"): + subprocess.run(jump, check=True) -@dataclass(frozen=True) -class WifiState: - ssid: str | None = None - status: ConnectStatus = ConnectStatus.DISCONNECTED + + + +def _tethering_firewall_ready() -> bool: + try: + checks = [*_tethering_firewall_rules("-C"), *_tethering_firewall_jumps("-C")] + return all(subprocess.run(rule, capture_output=True, check=False).returncode == 0 + for rule in checks) + except OSError: + cloudlog.exception("Failed to verify tethering firewall rules") + return False + + +def _delete_tethering_firewall_rules(): + _delete_firewall_jumps() + for command, chain in _tethering_firewall_chains(): + subprocess.run([*command, "-F", chain], capture_output=True, check=False) + subprocess.run([*command, "-X", chain], capture_output=True, check=False) 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._networks: list[Network] = [] self._exit = False + self._exit_event = threading.Event() + self._active = False - # DBus connections - 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 FileNotFoundError: - cloudlog.exception("Failed to connect to system D-Bus") - self._router_main = None - self._conn_monitor = None - self._exit = True - - # Store wifi device path - self._wifi_device: str | None = None + self._store: NetworkStore | None = None + self._ctrl: WpaCtrl | None = None + self._dhcp = DhcpClient() - # 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._associated_ssid: str | None = None + self._associated_epoch: int | None = None + self._dhcp_adoption_ssid: str | None = None self._current_network_metered: MeteredType = MeteredType.UNKNOWN - self._tethering_password: str = "" - self._ipv4_forward = False + self._ipv4_forward: bool | None = None + self._tethering_active = False + self._tethering_psk = DEFAULT_TETHERING_PASSWORD + self._dnsmasq_proc: subprocess.Popen | None = None + self._station_operation: StationOperation | None = None + self._pending_connection: PendingConnection | None = None + self._requested_ssid: str | None = None + self._network_not_found_epoch: int | None = None + self._network_not_found_events = 0 self._last_network_scan: float = 0.0 + self._last_connecting_at: float = 0.0 + self._last_scanning_recheck: float = 0.0 + self._last_connected_recheck: float = 0.0 + self._last_wrong_key_dispatch: dict[tuple[str, str | None], float] = {} + self._pending_forget_reconciliations: dict[str, PendingForgetReconciliation] = {} + self._last_forget_reconciliation_attempt = 0.0 self._callback_queue: list[Callable] = [] + self._callback_lock = threading.Lock() + self._state_lock = threading.RLock() + # Serialize wlan0, wpa_supplicant, and DHCP lifecycle changes across STA and AP. + self._radio_lock = threading.RLock() + self._station_cleanup_pending = False + self._tethering_epoch = 0 + self._tethering_transition_pending = False + self._tethering_started = False + self._tethering_password_epoch = 0 + self._networks_updated_pending = False self._tethering_ssid = "weedle" if Params is not None: @@ -193,14 +241,15 @@ def __init__(self): 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._forget_failed: list[Callable[[str | None], None]] = [] self._networks_updated: list[Callable[[list[Network]], None]] = [] self._disconnected: list[Callable[[], None]] = [] self._scan_lock = threading.Lock() + self._monitor_epoch = 0 self._scan_thread = threading.Thread(target=self._network_scanner, daemon=True) self._state_thread = threading.Thread(target=self._monitor_state, daemon=True) self._initialize() @@ -208,50 +257,142 @@ def __init__(self): def _initialize(self): def worker(): - self._wait_for_wifi_device() + try: + store = NetworkStore() + self._store = store + persisted_password = store.get_tethering_password(self._tethering_ssid) + if persisted_password is not None and is_valid_psk(persisted_password): + self._tethering_psk = persisted_password + else: + if persisted_password is not None: + cloudlog.warning("Ignoring invalid tethering password in NetworkManager profile") + # The standalone file is migration input only; the keyfile remains the + # durable source after a successful import. + try: + with open(TETHERING_PASSWORD_FILE) as f: + raw = f.read() + legacy_password = raw[:-1] if raw.endswith("\n") else raw + if is_valid_psk(legacy_password) and store.set_tethering_password(self._tethering_ssid, legacy_password): + self._tethering_psk = legacy_password + else: + self._tethering_psk = DEFAULT_TETHERING_PASSWORD + except FileNotFoundError: + self._tethering_psk = DEFAULT_TETHERING_PASSWORD + except (OSError, UnicodeError): + cloudlog.exception("Failed to migrate legacy tethering password") + self._tethering_psk = DEFAULT_TETHERING_PASSWORD - # TODO: wait for state thread to start before adding tethering connection, tiny race currently - self._scan_thread.start() - self._state_thread.start() + try: + if not store.ensure_tethering_profile(self._tethering_ssid, self._tethering_psk): + cloudlog.warning("Failed to create durable tethering profile") + except Exception: + cloudlog.exception("Failed to create durable tethering profile") - self._init_connections() - if Params is not None and self._tethering_ssid not in self._connections: - self._add_tethering_connection() + with self._radio_lock: + self._ensure_wpa_supplicant() - self._init_wifi_state() + # Load signal strength before rendering the connected network + self._update_networks(block=True) - self._tethering_password = self._get_tethering_password() - cloudlog.debug("WifiManager initialized") + self._init_wifi_state() + + cloudlog.debug("WifiManager initialized") + except Exception: + cloudlog.exception("WifiManager initialization failed") + finally: + self._scan_thread.start() + self._state_thread.start() threading.Thread(target=worker, daemon=True).start() + def _require_store(self) -> NetworkStore: + if self._store is None: + raise RuntimeError("WifiManager is not initialized") + return self._store + + def _ensure_wpa_supplicant(self): + with self._radio_lock: + self._dhcp_adoption_ssid = None + if not wpa_supplicant_running(WPA_AP_CONF): + try: + generate_wpa_conf(self._require_store()) + except Exception: + cloudlog.exception("Failed to generate wpa_supplicant configuration") + return + + def station_reconfigured(ssid: str): + self._dhcp_adoption_ssid = ssid + ctrl = ensure_wpa_supplicant(lambda: self._exit, station_reconfigured, + on_abandoned_ap=self._clear_tethering_network_state) + if ctrl is not None: + self._ctrl = ctrl + + def _request(self, cmd: str) -> str: + with self._radio_lock: + ctrl = self._ctrl + if ctrl is None: + raise OSError("wpa_supplicant ctrl not attached") + try: + return ctrl.request(cmd) + except OSError: + # Restart the monitor because recv may survive daemon death + try: + ctrl.close() + except Exception: + pass + self._ctrl = None + self._monitor_epoch += 1 + raise + def _init_wifi_state(self, block: bool = True): def worker(): - if self._wifi_device is None: - cloudlog.warning("No WiFi device found") + if self._ctrl is None: 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] + try: + status = parse_status(self._request("STATUS")) + except Exception: + cloudlog.exception("Failed to get wpa_supplicant status") + return - 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 + wpa_state = status.get("wpa_state", "") + ssid = status.get("ssid") - 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) + if status.get("mode") == "AP": + # Adopt a surviving hotspot before station cleanup + if self._user_epoch != epoch: + return + if self._adopt_ap_state(ssid): + return + # Avoid treating an incomplete AP as a station connection + self._wifi_state = WifiState(ssid=None, status=ConnectStatus.DISCONNECTED) + return + + if wpa_state == "COMPLETED": + connection_status = ConnectStatus.CONNECTED + elif wpa_state in ("SCANNING", "AUTHENTICATING", "ASSOCIATING", "ASSOCIATED", "4WAY_HANDSHAKE", "GROUP_HANDSHAKE"): + # Preserve mid-connect state for WRONG_KEY validation + connection_status = ConnectStatus.CONNECTING + else: + connection_status = ConnectStatus.DISCONNECTED + ssid = None - # Discard if user acted during DBus calls if self._user_epoch != epoch: return - self._wifi_state = WifiState(ssid=ssid, status=status) + if connection_status == ConnectStatus.CONNECTED and ssid is not None: + self._handle_connected(ssid, expected_epoch=epoch, profile_uuid=status.get("id_str")) + else: + if connection_status == ConnectStatus.CONNECTING and self._last_connecting_at == 0.0: + self._last_connecting_at = time.monotonic() + elif connection_status == ConnectStatus.DISCONNECTED: + self._dhcp_adoption_ssid = None + self._clear_station_state() + if self._user_epoch != epoch: + return + self._wifi_state = WifiState(ssid=ssid, status=connection_status) if block: worker() @@ -261,14 +402,20 @@ def worker(): def add_callbacks(self, need_auth: Callable[[str], None] | None = None, activated: Callable[[], None] | None = None, forgotten: Callable[[str | None], None] | None = None, + forget_failed: Callable[[str | None], 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) + with self._callback_lock: + self._activated.append(activated) + if self._tethering_active: + self._callback_queue.append(activated) if forgotten is not None: self._forgotten.append(forgotten) + if forget_failed is not None: + self._forget_failed.append(forget_failed) if networks_updated is not None: self._networks_updated.append(networks_updated) if disconnected is not None: @@ -276,8 +423,8 @@ def add_callbacks(self, need_auth: Callable[[str], None] | None = None, @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())) + saved_ssids = self._store.saved_ssids() if self._store is not None else set() + return sort_networks(self._networks, self._wifi_state.ssid, saved_ssids) @property def wifi_state(self) -> WifiState: @@ -303,636 +450,1584 @@ def connected_ssid(self) -> str | None: @property def tethering_password(self) -> str: - return self._tethering_password + return self._tethering_psk + + def _set_connecting(self, ssid: str | None, requested: bool = True, + kind: StationOperationKind = StationOperationKind.CONNECT, + operation_ssid: str | None = None): + with self._state_lock: + self._dhcp_adoption_ssid = None + self._user_epoch += 1 + self._requested_ssid = ssid if requested else None + self._network_not_found_epoch = None + self._network_not_found_events = 0 + self._last_connecting_at = time.monotonic() if ssid is not None else 0.0 + self._last_scanning_recheck = 0.0 + self._associated_ssid = None + self._associated_epoch = None + self._station_operation = StationOperation(self._user_epoch, kind, operation_ssid if operation_ssid is not None else ssid) + self._wifi_state = WifiState(ssid=ssid, status=ConnectStatus.DISCONNECTED if ssid is None else ConnectStatus.CONNECTING) + + def _clear_station_l3_state(self): + with self._radio_lock: + self._dhcp.stop() + self._dhcp.clear_ipv6_state() + self._ipv4_address = "" + self._current_network_metered = MeteredType.UNKNOWN + + def _clear_station_state(self): + with self._radio_lock: + self._clear_station_l3_state() + with self._state_lock: + self._associated_ssid = None + self._associated_epoch = None + + def _prepare_connection(self, epoch: int) -> bool: + with self._radio_lock: + with self._state_lock: + if self._user_epoch != epoch: + return False + cleanup_pending = self._station_cleanup_pending + self._station_cleanup_pending = False + if cleanup_pending: + self._clear_station_state() + with self._state_lock: + return self._user_epoch == epoch + + def _set_pending_connection(self, ssid: str, password: str, hidden: bool, security: SecurityType): + profile_uuid = None + if self._store is not None: + entry = self._store.get(ssid) + if isinstance(entry, dict): + profile_uuid = entry.get("uuid") + try: + profile_uuid = str(uuid.UUID(profile_uuid)) if profile_uuid is not None else str(uuid.uuid4()) + except (AttributeError, TypeError, ValueError): + profile_uuid = str(uuid.uuid4()) + + with self._state_lock: + epoch = self._user_epoch + self._pending_connection = PendingConnection( + ssid=ssid, password=password, hidden=hidden, security=security, epoch=epoch, profile_uuid=profile_uuid, + ) + self._station_operation = StationOperation( + epoch, StationOperationKind.CONNECT, ssid, profile_uuid=profile_uuid, + ) + + def _set_pending_network_id(self, net_id: str, epoch: int): + with self._state_lock: + pending = self._pending_connection + if pending is None or pending.epoch != epoch or self._user_epoch != epoch: + return + self._pending_connection = PendingConnection( + ssid=pending.ssid, + password=pending.password, + hidden=pending.hidden, + security=pending.security, + epoch=pending.epoch, + profile_uuid=pending.profile_uuid, + network_id=net_id, + ) + self._station_operation = StationOperation( + epoch, StationOperationKind.CONNECT, pending.ssid, pending.profile_uuid, net_id, + ) + + def _clear_pending_connection(self, ssid: str | None = None, epoch: int | None = None): + with self._state_lock: + pending = self._pending_connection + if pending is None: + return + if epoch is not None and pending.epoch != epoch: + return + if ssid is None or pending.ssid == ssid: + self._pending_connection = None + + def _restore_station_runtime(self, selected_id: str | None, previous_ids: list[str], removed_ids: list[str]): + exact = True + if selected_id is not None: + try: + self._remove_wpa_network_id(selected_id) + except Exception: + exact = False + cloudlog.exception(f"Failed to remove selected runtime network {selected_id}") + + if exact and not removed_ids and previous_ids: + try: + self._select_network_ids(previous_ids) + return + except Exception: + cloudlog.exception("Failed to restore previous runtime network selection") - 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) + try: + store = self._require_store() + generate_wpa_conf(store) + if self._ctrl is not None: + response = self._request("RECONFIGURE").strip() + if not response.startswith("OK"): + raise RuntimeError(f"RECONFIGURE failed: {response}") + except Exception: + cloudlog.exception("Failed to restore runtime networks from durable profiles") + + def _persist_pending_connection(self, ssid: str | None): + with self._state_lock: + pending = self._pending_connection + if pending is None or ssid is None: + return + + if ssid != pending.ssid or pending.epoch != self._user_epoch: + return + + # Retain credentials after transient persistence failures + try: + store = self._require_store() + store.save_network( + ssid, + psk=pending.password, + hidden=pending.hidden, + security=pending.security, + profile_uuid=pending.profile_uuid, + ) + generate_wpa_conf(store) + except Exception: + cloudlog.exception("Failed to persist pending connection for %s", ssid) + return + with self._state_lock: + if self._pending_connection is pending: + self._pending_connection = None + + def _connected_transition_is_current(self, ssid: str, epoch: int) -> bool: + with self._state_lock: + return ( + self._user_epoch == epoch + and self._associated_ssid == ssid + and self._associated_epoch == epoch + ) + + def _wifi_default_route_ready(self) -> bool: + try: + result = subprocess.run( + ["ip", "-4", "route", "show", "default", "dev", "wlan0"], + capture_output=True, + check=False, + text=True, + timeout=2, + ) + except (OSError, subprocess.TimeoutExpired): + return False + if result.returncode != 0: + return False + + routes = [line.split() for line in result.stdout.splitlines() if line.strip()] + if len(routes) != 1: + return False + + route = routes[0] + try: + via_index = route.index("via") + metric_index = route.index("metric") + except ValueError: + return False + dev_index = route.index("dev") if "dev" in route else None + return ( + route[0] == "default" + and via_index + 1 < len(route) + and route[via_index + 1] not in ("dev", "metric") + and (dev_index is None or (dev_index + 1 < len(route) and route[dev_index + 1] == "wlan0")) + and metric_index + 1 < len(route) + and route[metric_index + 1] == "600" + ) + + def _complete_station_connection(self, ssid: str, epoch: int) -> bool: + with self._radio_lock, self._state_lock: + if not self._ipv4_address or not self._connected_transition_is_current(ssid, epoch): + return False + if not self._wifi_default_route_ready(): + return False + if self._wifi_state == WifiState(ssid, ConnectStatus.CONNECTED): + return True + self._requested_ssid = None + self._last_connecting_at = 0.0 + self._last_scanning_recheck = 0.0 + self._network_not_found_epoch = None + self._network_not_found_events = 0 + self._wifi_state = WifiState(ssid=ssid, status=ConnectStatus.CONNECTED) + self._enqueue_callbacks(self._activated) + return True def _enqueue_callbacks(self, cbs: list[Callable], *args): - for cb in cbs: - self._callback_queue.append(lambda _cb=cb: _cb(*args)) + with self._callback_lock: + for cb in cbs: + self._callback_queue.append(lambda _cb=cb: _cb(*args)) + + def _mark_networks_updated(self): + # Coalesce scan callbacks to keep the undrained queue bounded + with self._callback_lock: + self._networks_updated_pending = True def process_callbacks(self): - # Call from UI thread to run any pending callbacks - to_run, self._callback_queue = self._callback_queue, [] + with self._callback_lock: + to_run, self._callback_queue = self._callback_queue, [] + if self._networks_updated_pending: + self._networks_updated_pending = False + networks_cbs = list(self._networks_updated) + else: + networks_cbs = None for cb in to_run: cb() + if networks_cbs: + snapshot = self.networks + for cb in networks_cbs: + cb(snapshot) def set_active(self, active: bool): self._active = active - - # 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: + # Respawn an owned daemon whose control socket remains unreachable + ATTACH_FAILURES_BEFORE_RESPAWN = 3 + attach_failures = 0 + while not self._exit: + if self._ctrl is None: + with self._radio_lock: + # Avoid spawning STA while tethering is taking over wlan0 + if self._ctrl is None and not self._tethering_active: + daemon_alive = wpa_supplicant_running(WPA_SUPPLICANT_CONF) or wpa_supplicant_running(WPA_AP_CONF) + stale_daemon = daemon_alive and attach_failures >= ATTACH_FAILURES_BEFORE_RESPAWN + if daemon_alive and not stale_daemon: + ctrl = try_attach_ctrl() + if ctrl is None: + attach_failures += 1 + else: + self._ctrl = ctrl + attach_failures = 0 + else: + self._ensure_wpa_supplicant() + attach_failures = 0 + if self._ctrl is None: + self._exit_event.wait(SCAN_PERIOD_SECONDS) continue + monitor = None + try: + epoch = self._monitor_epoch + monitor = WpaCtrlMonitor() + monitor.open() + while not self._exit and self._monitor_epoch == epoch: + event = monitor.recv(timeout=1.0) + if event is None: + continue + self._handle_event(event) + except Exception: + cloudlog.exception("wpa_supplicant monitor error, reconnecting...") + # Reattach after control-socket failure + with self._radio_lock: + if self._ctrl is not None: + try: + self._ctrl.close() + except Exception: + pass + self._ctrl = None + finally: + if monitor is not None: + try: + monitor.close() + except Exception: + pass + if not self._exit: + self._exit_event.wait(SCAN_PERIOD_SECONDS) + + def _adopt_ap_state(self, ssid: str | None) -> bool: + """Adopt a hotspot only when its DHCP and NAT services are ready. On refusal, + tear down its network services so the monitor can recover station mode.""" + with self._radio_lock: + if not (tethering_dnsmasq_running() and _tethering_firewall_ready()): + cloudlog.warning("AP services are incomplete; refusing adoption and tearing down orphan AP") + self._stop_tethering() + return False + if self._ipv4_forward is not None: + try: + self._apply_ipv4_forward(self._ipv4_forward) + except Exception: + cloudlog.exception("Failed to enforce IPv4 forwarding policy while adopting AP") + self._stop_tethering() + return False + + if not self._ap_config_matches_password(): + cloudlog.warning("Persisted tethering password differs from the running AP; rebuilding hotspot") + self._tethering_active = True + try: + self._start_tethering() + return True + except Exception: + cloudlog.exception("Failed to rebuild hotspot with persisted password") + try: + self._stop_tethering() + except Exception: + cloudlog.exception("Hotspot password reconciliation rollback also failed") + self._tethering_active = False + self._wifi_state = WifiState() + self._ipv4_address = "" + self._enqueue_callbacks(self._disconnected) + return False + with self._callback_lock: + self._tethering_active = True + self._tethering_started = True + self._wifi_state = WifiState(ssid=ssid or self._tethering_ssid, status=ConnectStatus.CONNECTED) + self._ipv4_address = TETHERING_IP_ADDRESS + self._callback_queue.extend(self._activated) + return True + + def _ap_config_matches_password(self) -> bool: + try: + with open(WPA_AP_CONF) as f: + expected = f"psk={format_psk_value(self._tethering_psk)}" + return any(line.strip() == expected for line in f) + except OSError: + cloudlog.exception("Failed to read running AP configuration") + return False + + def _handle_connected(self, ssid: str, expected_epoch: int | None = None, + profile_uuid: str | None = None): + """Handle L2 association. CONNECTED and activation remain IP-ready states.""" + with self._radio_lock: + with self._state_lock: + if expected_epoch is not None and self._user_epoch != expected_epoch: + return + if self._requested_ssid is not None and self._requested_ssid != ssid: + return + transition_epoch = self._user_epoch + previous_ssid = self._associated_ssid + if previous_ssid is None and self._wifi_state.status == ConnectStatus.CONNECTED: + previous_ssid = self._wifi_state.ssid + adoption_ssid = self._dhcp_adoption_ssid + adopt_dhcp = adoption_ssid == ssid + if ( + (previous_ssid is not None and previous_ssid != ssid) + or (adoption_ssid is not None and adoption_ssid != ssid) + ): + self._dhcp.clear_ipv6_state() + self._dhcp_adoption_ssid = None + already_associated = self._connected_transition_is_current(ssid, transition_epoch) + already_connected = self._wifi_state == WifiState(ssid, ConnectStatus.CONNECTED) + previous_operation = self._station_operation + pending = self._pending_connection + active_profile_uuid = profile_uuid or (pending.profile_uuid if pending is not None and pending.ssid == ssid else None) + previous_profile_uuid = previous_operation.profile_uuid if previous_operation is not None else None + profile_changed = ( + already_associated + and active_profile_uuid is not None + and active_profile_uuid != previous_profile_uuid + ) + if not already_associated: + self._associated_ssid = ssid + self._associated_epoch = transition_epoch + self._station_operation = StationOperation( + transition_epoch, + StationOperationKind.ASSOCIATED, + ssid, + profile_uuid=active_profile_uuid, + runtime_network_id=pending.network_id if pending is not None and pending.ssid == ssid else None, + ) + self._wifi_state = WifiState(ssid=ssid, status=ConnectStatus.CONNECTING) + + if profile_changed: + self._clear_station_l3_state() + with self._state_lock: + if ( + not self._connected_transition_is_current(ssid, transition_epoch) + or self._station_operation is not previous_operation + ): + return + self._wifi_state = WifiState(ssid=ssid, status=ConnectStatus.CONNECTING) + + if not already_associated or profile_changed: + try: + ipv6_method = self._store.get_ipv6_method(ssid, active_profile_uuid) if self._store is not None else "auto" + self._dhcp.set_ipv6_enabled(ipv6_method != "ignore") + except Exception: + cloudlog.exception("Failed to apply IPv6 policy for %s", ssid) + if not already_associated: + with self._state_lock: + if self._connected_transition_is_current(ssid, transition_epoch): + self._associated_ssid = None + self._associated_epoch = None + self._station_operation = previous_operation + return - # 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 profile_changed: + with self._state_lock: + if ( + not self._connected_transition_is_current(ssid, transition_epoch) + or self._station_operation is not previous_operation + ): + return + self._station_operation = StationOperation( + transition_epoch, + StationOperationKind.ASSOCIATED, + ssid, + profile_uuid=active_profile_uuid, + runtime_network_id=previous_operation.runtime_network_id if previous_operation is not None else None, + ) + + if already_connected and not profile_changed: + # Retry persistence after transient filesystem failures. + pending = self._pending_connection + if pending is not None and pending.ssid == ssid: + self._persist_pending_connection(ssid) + return - if new_state == NMDeviceState.DISCONNECTED: - if change_reason == NMDeviceStateReason.NEW_ACTIVATION: + if already_associated and not profile_changed: + self._persist_pending_connection(ssid) + self._update_active_connection_info() + self._complete_station_connection(ssid, transition_epoch) 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): + if profile_changed or not adopt_dhcp or not self._dhcp.adopt(): + self._ipv4_address = "" + self._dhcp.start() + if not self._connected_transition_is_current(ssid, transition_epoch): return - self._set_connecting(None) + self._persist_pending_connection(ssid) + if self._ctrl is not None and self._connected_transition_is_current(ssid, transition_epoch): + try: + # SELECT_NETWORK disables other profiles; re-enable them for roaming. + self._request("ENABLE_NETWORK all") + except Exception: + cloudlog.exception("Failed to re-enable saved networks for auto-roam") + if self._connected_transition_is_current(ssid, transition_epoch): + self._poll_for_ip(ssid, transition_epoch) + + def _handle_event(self, event: str): + if "CTRL-EVENT-SCAN-RESULTS" in event: + self._update_networks(block=False) - elif new_state in (NMDeviceState.PREPARE, NMDeviceState.CONFIG): + elif "CTRL-EVENT-CONNECTED" in event: epoch = self._user_epoch - if self._wifi_state.ssid is not None: - self._wifi_state = replace(self._wifi_state, status=ConnectStatus.CONNECTING) + try: + status = parse_status(self._request("STATUS")) + except Exception: + cloudlog.exception("Failed to verify wpa_supplicant connected state") 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 + if status.get("wpa_state") != "COMPLETED": + return - 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) + ssid = status.get("ssid") + if ssid: + self._handle_connected(ssid, expected_epoch=epoch, profile_uuid=status.get("id_str")) + + elif "CTRL-EVENT-DISCONNECTED" in event: + with self._state_lock: + epoch = self._user_epoch + expected_state = self._wifi_state + now = time.monotonic() + + with self._radio_lock, self._state_lock: + if ( + self._tethering_active + or self._user_epoch != epoch + or self._wifi_state != expected_state + or self._wifi_state.status in (ConnectStatus.CONNECTING, ConnectStatus.DISCONNECTED) + ): + return - conn_path, _ = self._get_active_wifi_connection(self._conn_monitor) + ssid = self._wifi_state.ssid + if self._wifi_state.status == ConnectStatus.CONNECTED and ssid is not None: + self._dhcp_adoption_ssid = ssid + self._last_connecting_at = now + self._last_scanning_recheck = 0.0 + self._wifi_state = WifiState(ssid=ssid, status=ConnectStatus.CONNECTING) + return - # Discard if user acted during DBus call - if self._user_epoch != epoch: + self._wifi_state = WifiState() + self._clear_station_state() + self._enqueue_callbacks(self._disconnected) + + elif "TEMP-DISABLED" in event and "reason=WRONG_KEY" in event: + event_ssid = parse_event_ssid(event) + event_network_id = parse_event_network_id(event) + if event_ssid is not None: + with self._radio_lock: + with self._state_lock: + current_ssid = self._wifi_state.ssid + # The event SSID is authoritative for auto-connect + connecting_unknown = ( + self._wifi_state.status == ConnectStatus.CONNECTING + and current_ssid is None + ) + if not connecting_unknown and (not current_ssid or event_ssid != current_ssid): + return + + pending = self._pending_connection + if pending is not None and pending.ssid == event_ssid: + if (pending.epoch != self._user_epoch + or pending.network_id is None + or event_network_id != pending.network_id): + return + + failed_epoch = self._user_epoch + failed_operation = self._station_operation + failed_pending = pending + + # Debounce WRONG_KEY per profile, not per SSID + dispatch_key = (event_ssid, event_network_id) + now = time.monotonic() + self._last_wrong_key_dispatch = { + key: timestamp for key, timestamp in self._last_wrong_key_dispatch.items() + if now - timestamp < WRONG_KEY_DEBOUNCE_SECONDS + } + last_dispatch = self._last_wrong_key_dispatch.get(dispatch_key) + if last_dispatch is not None and now - last_dispatch < WRONG_KEY_DEBOUNCE_SECONDS: + return + self._last_wrong_key_dispatch[dispatch_key] = now + + # Try remaining profiles before requesting new credentials + if self._ctrl is not None: + try: + if event_network_id is not None: + matching_ids = self._list_network_ids(event_ssid) + if event_network_id not in matching_ids: + return + self._remove_wpa_network_id(event_network_id) + remaining_ids = [net_id for net_id in matching_ids if net_id != event_network_id] + else: + self._remove_wpa_network(event_ssid) + remaining_ids = [] + if remaining_ids: + self._select_network_ids(remaining_ids) + with self._state_lock: + if ( + self._user_epoch != failed_epoch + or self._station_operation is not failed_operation + or (failed_pending is not None and self._pending_connection is not failed_pending) + ): + return + self._last_connecting_at = now + self._last_scanning_recheck = 0.0 + self._network_not_found_epoch = None + self._network_not_found_events = 0 + return + response = self._request("ENABLE_NETWORK all").strip() + if not response.startswith("OK"): + raise RuntimeError(f"ENABLE_NETWORK all failed: {response}") + except Exception: + cloudlog.exception("Failed to update saved networks after WRONG_KEY") + + with self._state_lock: + if ( + self._user_epoch != failed_epoch + or self._station_operation is not failed_operation + or (failed_pending is not None and self._pending_connection is not failed_pending) + ): + return + self._clear_pending_connection(event_ssid, epoch=failed_epoch) + self._set_connecting(None, kind=StationOperationKind.AUTH_FAILURE, operation_ssid=event_ssid) + auth_epoch = self._user_epoch + auth_operation = self._station_operation + # DISCONNECTED may arrive while CONNECTING and skip cleanup + self._clear_station_state() + with self._state_lock: + if self._user_epoch != auth_epoch or self._station_operation is not auth_operation: + return + self._enqueue_callbacks(self._need_auth, event_ssid) + self._enqueue_callbacks(self._disconnected) + + elif "CTRL-EVENT-NETWORK-NOT-FOUND" in event: + now = time.monotonic() + with self._state_lock: + if self._wifi_state.status != ConnectStatus.CONNECTING: + return + # Reconciliation disambiguates delayed NETWORK-NOT-FOUND events + if now - self._last_connecting_at >= CONNECTING_STALE_TIMEOUT_SECONDS: + self._network_not_found_events += 1 + if self._network_not_found_events >= NETWORK_NOT_FOUND_EVENTS_REQUIRED: + self._network_not_found_epoch = self._user_epoch + + elif "Trying to associate with" in event or "Associated with" in event: + with self._state_lock: + epoch = self._user_epoch + expected_state = self._wifi_state + if expected_state.status != ConnectStatus.DISCONNECTED: 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) + ssid = None + if self._ctrl: + try: + status = parse_status(self._request("STATUS")) + ssid = status.get("ssid") + except Exception: + pass + now = time.monotonic() + with self._radio_lock, self._state_lock: + if self._user_epoch != epoch or self._wifi_state != expected_state: + return + self._last_connecting_at = now + self._last_scanning_recheck = 0.0 + self._wifi_state = WifiState(ssid=ssid, status=ConnectStatus.CONNECTING) def _network_scanner(self): while not self._exit: - if self._active: + self._retry_pending_forget_reconciliations() + self._reconcile_connecting_state() + if self._active and not self._tethering_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 + def _request_scan(self): + if self._ctrl is None: + return try: - reply = self._router_main.send_and_get_reply(new_method_call(self._nm, 'GetDevices')) - if reply.header.message_type == MessageType.error: - # NetworkManager is not available, body holds an error string instead of device paths - return None - for device_path in reply.body[0]: - 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): - 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): - 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) + associated = self._associated_ssid is not None + self._request("SCAN TYPE=ONLY" if associated or self._wifi_state.status == ConnectStatus.CONNECTED else "SCAN") + except Exception: + cloudlog.exception("Failed to request scan") + + def _reconcile_tethering_state(self): + now = time.monotonic() + if now - self._last_connected_recheck < SCAN_PERIOD_SECONDS: + return + self._last_connected_recheck = now - if block: - worker() - else: - threading.Thread(target=worker, daemon=True).start() + with self._radio_lock: + if self._tethering_transition_pending or not self._tethering_active: + return - def activate_connection(self, ssid: str, block: bool = False): - self._set_connecting(ssid) + try: + if self._ipv4_forward is not None: + self._apply_ipv4_forward(self._ipv4_forward) + status = parse_status(self._request("STATUS")) + if (status.get("mode") == "AP" and status.get("wpa_state") == "COMPLETED" + and tethering_dnsmasq_running() and _tethering_firewall_ready()): + return + except Exception: + cloudlog.exception("Failed to verify tethering state") + + cloudlog.warning("Tethering services stopped unexpectedly, restoring station mode") + try: + self._stop_tethering() + except Exception: + cloudlog.exception("Failed to restore station mode after tethering failure") + self._tethering_active = False + self._wifi_state = WifiState() + self._ipv4_address = "" + self._enqueue_callbacks(self._disconnected) + + def _reconcile_connecting_state(self): + current_state = self._wifi_state + if self._tethering_active: + self._reconcile_tethering_state() + return + if self._ctrl is None: + return - 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() + if current_state.status == ConnectStatus.DISCONNECTED: + now = time.monotonic() + if now - self._last_connected_recheck < SCAN_PERIOD_SECONDS: return + self._last_connected_recheck = now + epoch = self._user_epoch + try: + status = parse_status(self._request("STATUS")) + except Exception: + return + # Ignore STATUS results superseded by newer user action + if self._user_epoch != epoch: + return + # Re-adopt AP mode before station reconciliation + if status.get("mode") == "AP": + if self._adopt_ap_state(status.get("ssid")): + return + # Keep an incomplete AP disconnected so tethering can recover + return + if status.get("wpa_state") == "COMPLETED" and status.get("ssid"): + self._handle_connected(status["ssid"], expected_epoch=epoch, profile_uuid=status.get("id_str")) + 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() + # Rate-limit recovery from missed DISCONNECTED events + if current_state.status == ConnectStatus.CONNECTED: + now = time.monotonic() + if now - self._last_connected_recheck < SCAN_PERIOD_SECONDS: + return + self._last_connected_recheck = now + with self._state_lock: + epoch = self._user_epoch + expected_operation = self._station_operation + expected_association = (self._associated_ssid, self._associated_epoch) + try: + status = parse_status(self._request("STATUS")) + except Exception: + return + # Ignore STATUS results superseded by newer user action + if self._user_epoch != epoch: + return + wpa_state = status.get("wpa_state", "") + status_ssid = status.get("ssid") + if wpa_state == "COMPLETED" and status_ssid is not None and status_ssid == current_state.ssid: + self._handle_connected(status_ssid, expected_epoch=epoch, profile_uuid=status.get("id_str")) + return + if wpa_state == "COMPLETED" and status_ssid: + # Preserve the lease when adopting a roam missed by the monitor + self._handle_connected(status_ssid, expected_epoch=epoch, profile_uuid=status.get("id_str")) + return + # Preserve the lease during transient roam and rekey states + if wpa_state in ("SCANNING", "AUTHENTICATING", "ASSOCIATING", "ASSOCIATED", + "4WAY_HANDSHAKE", "GROUP_HANDSHAKE"): + return + with self._radio_lock: + with self._state_lock: + if ( + self._user_epoch != epoch + or self._station_operation is not expected_operation + or (self._associated_ssid, self._associated_epoch) != expected_association + ): + return - if block: - worker() - else: - threading.Thread(target=worker, daemon=True).start() + try: + latest_status = parse_status(self._request("STATUS")) + except Exception: + cloudlog.exception("Failed to confirm disconnected wifi state from STATUS") + return + latest_wpa_state = latest_status.get("wpa_state", "") + latest_ssid = latest_status.get("ssid") + if latest_wpa_state == "COMPLETED" and latest_ssid: + self._handle_connected( + latest_ssid, expected_epoch=epoch, profile_uuid=latest_status.get("id_str"), + ) + return + if latest_wpa_state in ("SCANNING", "AUTHENTICATING", "ASSOCIATING", "ASSOCIATED", + "4WAY_HANDSHAKE", "GROUP_HANDSHAKE"): + return - def _deactivate_connection(self, ssid: str): - 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) + with self._state_lock: + if ( + self._user_epoch != epoch + or self._station_operation is not expected_operation + or (self._associated_ssid, self._associated_epoch) != expected_association + ): + return + self._wifi_state = WifiState() + self._dhcp_adoption_ssid = None + self._clear_station_state() + self._enqueue_callbacks(self._disconnected) + return - specific_obj_path = reply.body[0][1] + if current_state.status != ConnectStatus.CONNECTING: + return + now = time.monotonic() + if now - self._last_connecting_at < CONNECTING_STALE_TIMEOUT_SECONDS: + return - 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) + # Snapshot the operation before the blocking STATUS request + with self._state_lock: + epoch = self._user_epoch + expected_operation = self._station_operation + if self._network_not_found_epoch != epoch and now - self._last_scanning_recheck < CONNECTING_STALE_TIMEOUT_SECONDS: + return + try: + status = parse_status(self._request("STATUS")) + except Exception: + cloudlog.exception("Failed to reconcile wifi state from STATUS") + return + if self._user_epoch != epoch: + return - ap_ssid = bytes(ap_reply.body[0][1]).decode("utf-8", "replace") + wpa_state = status.get("wpa_state", "") + status_ssid = status.get("ssid") + + if wpa_state == "COMPLETED" and status_ssid: + self._handle_connected(status_ssid, expected_epoch=epoch, profile_uuid=status.get("id_str")) + elif wpa_state == "SCANNING" and self._network_not_found_epoch != epoch: + # Hidden SSIDs may remain SCANNING beyond the stale timeout + self._last_scanning_recheck = time.monotonic() + elif wpa_state in ("DISCONNECTED", "INACTIVE", "SCANNING", "AUTHENTICATING", "ASSOCIATING", + "ASSOCIATED", "4WAY_HANDSHAKE", "GROUP_HANDSHAKE"): + with self._radio_lock: + with self._state_lock: + if ( + self._user_epoch != epoch + or self._station_operation is not expected_operation + ): + return - if ap_ssid == ssid: - self._router_main.send_and_get_reply(new_method_call(self._nm, 'DeactivateConnection', 'o', (active_conn,))) + try: + latest_status = parse_status(self._request("STATUS")) + except Exception: + cloudlog.exception("Failed to confirm terminal wifi state from STATUS") + return + if latest_status.get("wpa_state") == "COMPLETED" and latest_status.get("ssid"): + self._handle_connected( + latest_status["ssid"], expected_epoch=epoch, profile_uuid=latest_status.get("id_str"), + ) 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 + with self._state_lock: + if self._user_epoch != epoch or self._station_operation is not expected_operation: + return + ssid = current_state.ssid + pending = self._pending_connection + pending_network_id = pending.network_id if ( + pending is not None + and pending.epoch == epoch + and pending.ssid == ssid + ) else None + + self._restore_station_runtime( + pending_network_id, + [], + [pending_network_id] if pending_network_id is not None else [], + ) + + with self._state_lock: + if ( + self._user_epoch != epoch + or self._station_operation is not expected_operation + or self._pending_connection is not pending + ): + return + self._clear_pending_connection(ssid, epoch=epoch) + self._set_connecting(None, kind=StationOperationKind.TIMEOUT, operation_ssid=ssid) + self._clear_station_state() + self._enqueue_callbacks(self._disconnected) - def set_tethering_password(self, password: str): + def _update_networks(self, block: bool = True): 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 + with self._scan_lock: + if self._ctrl is None: + return - settings['802-11-wireless-security']['psk'] = ('s', password) + try: + raw = self._request("SCAN_RESULTS") + except Exception: + cloudlog.exception("Failed to get scan results") + return - 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 + results = parse_scan_results(raw) - self._tethering_password = password - if self.is_tethering_active(): - self.activate_connection(self._tethering_ssid, block=True) + ssid_map: dict[str, list] = {} + for r in results: + if not r.ssid: + continue + if r.ssid not in ssid_map: + ssid_map[r.ssid] = [] + ssid_map[r.ssid].append(r) + + networks = [] + for ssid, aps in ssid_map.items(): + strongest = max(aps, key=lambda a: a.signal) + security_types = {flags_to_security_type(ap.flags) for ap in aps} + if len(security_types) == 1: + security = security_types.pop() + elif SecurityType.WPA in security_types and SecurityType.OPEN not in security_types: + security = SecurityType.WPA + else: + security = SecurityType.UNSUPPORTED + is_tethering = ssid == self._tethering_ssid + strength = 100 if is_tethering else dbm_to_percent(strongest.signal) + networks.append(Network(ssid=ssid, strength=strength, security_type=security, is_tethering=is_tethering)) + + # A successful empty scan clears stale networks + self._networks = networks + self._update_active_connection_info() + self._mark_networks_updated() - threading.Thread(target=worker, daemon=True).start() + if block: + worker() + else: + threading.Thread(target=worker, daemon=True).start() - def _get_tethering_password(self) -> str: - conn_path = self._connections.get(self._tethering_ssid, None) - if conn_path is None: - cloudlog.warning('No tethering connection found') - return '' + def _poll_for_ip(self, ssid: str | None = None, epoch: int | None = None): + ssid = self._associated_ssid if ssid is None else ssid + epoch = self._user_epoch if epoch is None else epoch - 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',) - )) + def worker(): + for _ in range(50): # 10 seconds max + if ssid is None or not self._connected_transition_is_current(ssid, epoch): + return + self._update_active_connection_info() + if self._ipv4_address and self._complete_station_connection(ssid, epoch): + return + time.sleep(0.2) + threading.Thread(target=worker, daemon=True).start() - if reply.header.message_type == MessageType.error: - cloudlog.warning(f'Failed to get tethering password: {reply}') - return '' + def _update_active_connection_info(self): + ipv4_address = "" + metered = MeteredType.UNKNOWN + profile_uuid = None - secrets = reply.body[0] - if '802-11-wireless-security' not in secrets: - return '' + with self._state_lock: + station_epoch = self._user_epoch + station_ssid = self._associated_ssid + associated_epoch = self._associated_epoch + station_operation = self._station_operation + station_active = station_ssid is not None or self._wifi_state.status == ConnectStatus.CONNECTED - return str(secrets['802-11-wireless-security'].get('psk', ('s', ''))[1]) + if station_active: + if self._ctrl: + try: + status = parse_status(self._request("STATUS")) + ipv4_address = status.get("ip_address", "") + profile_uuid = status.get("id_str") + except Exception: + pass - def set_ipv4_forward(self, enabled: bool): - self._ipv4_forward = enabled + if not ipv4_address: + try: + result = subprocess.run(["ip", "-4", "-o", "addr", "show", "wlan0"], + capture_output=True, text=True, timeout=2) + for line in result.stdout.strip().split("\n"): + if "inet " in line: + parts = line.split() + inet_idx = parts.index("inet") + ipv4_address = parts[inet_idx + 1].split("/")[0] + break + except Exception: + pass + + ssid = station_ssid or self._wifi_state.ssid + if ssid and self._store is not None: + metered = self._store.get_metered(ssid, profile_uuid) + + with self._state_lock: + if ( + self._user_epoch != station_epoch + or self._associated_ssid != station_ssid + or self._associated_epoch != associated_epoch + or self._station_operation is not station_operation + ): + return + self._ipv4_address = ipv4_address + self._current_network_metered = metered + + def connect_to_network(self, ssid: str, password: str, hidden: bool = False, + security: SecurityType | None = None): + # Guard non-UI callers while tethering + if self._tethering_active: + cloudlog.warning(f"Ignoring connect to {ssid!r} while tethering is active") + return + if not is_valid_ssid(ssid): + cloudlog.warning(f"Ignoring connect to invalid SSID {ssid!r}") + return + security = security if security is not None else (SecurityType.WPA if password else SecurityType.OPEN) + if security not in (SecurityType.OPEN, SecurityType.WPA): + cloudlog.warning(f"Ignoring connect to {ssid!r} with unsupported security") + return + if security == SecurityType.WPA and not is_valid_psk(password): + cloudlog.warning(f"Ignoring connect to {ssid!r} with invalid passphrase") + self._enqueue_callbacks(self._need_auth, ssid) + return + if security == SecurityType.OPEN and password: + cloudlog.warning(f"Ignoring open-network connect to {ssid!r} with a passphrase") + return + with self._state_lock: + self._station_cleanup_pending |= ( + self._associated_ssid is not None + or self._wifi_state.status == ConnectStatus.CONNECTED + or self._dhcp_adoption_ssid is not None + ) + self._set_connecting(ssid) + self._set_pending_connection(ssid, password, hidden, security) + epoch = self._user_epoch - def set_tethering_active(self, active: bool): def worker(): - if active: - self.activate_connection(self._tethering_ssid, block=True) + with self._radio_lock: + if not self._prepare_connection(epoch): + return + if self._ctrl is None: + cloudlog.warning("No wpa_supplicant connection") + # Ignore failures superseded by a newer connection attempt + if self._user_epoch != epoch: + return + self._clear_pending_connection(ssid) + # Reset inline because _init_wifi_state ignores a missing control socket + self._set_connecting(None, operation_ssid=ssid) + self._enqueue_callbacks(self._disconnected) + return - 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) + # Serialize the epoch check with runtime-network replacement + if self._user_epoch != epoch: + return + existing_ids: list[str] = [] + removed_ids: list[str] = [] + net_id = None + try: + existing_ids = self._list_network_ids(ssid) + with self._state_lock: + pending = self._pending_connection + profile_uuid = pending.profile_uuid if pending is not None and pending.epoch == epoch else None + if profile_uuid is None: + return + net_id = self._add_and_select_network( + ssid, password, hidden, profile_uuid=profile_uuid, security=security, + ) + self._set_pending_network_id(net_id, epoch) + if self._user_epoch != epoch: + self._restore_station_runtime(net_id, existing_ids, removed_ids) + return + for existing_id in existing_ids: + self._remove_wpa_network_id(existing_id) + removed_ids.append(existing_id) + if self._user_epoch != epoch: + self._restore_station_runtime(net_id, existing_ids, removed_ids) + return + except Exception: + cloudlog.exception(f"Failed to connect to {ssid}") + if net_id is not None or removed_ids: + self._restore_station_runtime(net_id, existing_ids, removed_ids) + if self._user_epoch != epoch: + return + self._clear_pending_connection(ssid, epoch=epoch) + self._set_connecting(None, operation_ssid=ssid) + self._enqueue_callbacks(self._disconnected) threading.Thread(target=worker, daemon=True).start() - def set_current_network_metered(self, metered: MeteredType): - 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') + def forget_connection(self, ssid: str, block: bool = False): + with self._state_lock: + forget_active = ( + self._wifi_state.ssid == ssid and self._wifi_state.status in (ConnectStatus.CONNECTING, ConnectStatus.CONNECTED) + or self._associated_ssid == ssid + or self._dhcp_adoption_ssid == ssid + ) + cleanup_required = ( + self._wifi_state == WifiState(ssid, ConnectStatus.CONNECTED) + or self._associated_ssid == ssid + or self._dhcp_adoption_ssid == ssid + ) + forget_epoch = self._user_epoch + + def transition(): + try: + store = self._require_store() + except RuntimeError: + cloudlog.exception(f"forget_connection: manager not initialized for {ssid}") + self._enqueue_callbacks(self._forget_failed, ssid) return - settings = self._get_connection_settings(conn_path) - - if len(settings) == 0: - cloudlog.warning(f'Failed to get connection settings for {conn_path}') + existed = store.contains(ssid) + removed = store.remove(ssid) + if existed and not removed: + # Keep runtime state when persistent removal fails + cloudlog.warning(f"forget_connection: failed to remove {ssid} from disk; leaving runtime intact") + self._enqueue_callbacks(self._forget_failed, ssid) return + if not removed: + cloudlog.warning(f"Trying to forget unknown connection: {ssid}") - 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}') + with self._radio_lock: + pending = PendingForgetReconciliation(ssid, forget_epoch, forget_active, cleanup_required) + with self._state_lock: + self._pending_forget_reconciliations[ssid] = pending + self._last_forget_reconciliation_attempt = time.monotonic() + self._finish_forget_reconciliation(pending) - threading.Thread(target=worker, daemon=True).start() + def worker(): + with self._radio_lock: + transition() - def _request_scan(self): - if self._wifi_device is None: - cloudlog.warning("No WiFi device found") - return + if block: + worker() + else: + threading.Thread(target=worker, daemon=True).start() - 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}', ({},))) + def _restart_station_supplicant(self) -> bool: + with self._radio_lock: + ctrl, self._ctrl = self._ctrl, None + if ctrl is not None: + try: + ctrl.close() + except Exception: + cloudlog.exception("Failed to close station control socket before restart") + self._monitor_epoch += 1 + stop_wpa_supplicant(WPA_SUPPLICANT_CONF) + self._ensure_wpa_supplicant() + return self._ctrl is not None + + def _reconcile_forgotten_runtime(self, ssid: str) -> bool: + try: + generate_wpa_conf(self._require_store()) + except Exception: + cloudlog.exception(f"Failed to regenerate configuration after forgetting {ssid}") + return False + + restart_required = self._ctrl is None + if self._ctrl is not None: + try: + self._remove_wpa_network(ssid) + except Exception: + # RECONFIGURE below can still remove the profile from the running daemon. + cloudlog.exception(f"Targeted runtime removal failed after forgetting {ssid}") + + try: + response = self._request("RECONFIGURE").strip() + if not response.startswith("OK"): + raise RuntimeError(f"RECONFIGURE failed: {response}") + except Exception: + cloudlog.exception(f"Failed to reconfigure runtime profiles after forgetting {ssid}") + restart_required = True + + if not restart_required: + try: + if not self._list_network_ids(ssid): + return True + cloudlog.warning(f"Runtime profile remained after forgetting {ssid}; restarting station daemon") + except Exception: + cloudlog.exception(f"Failed to verify runtime profile removal after forgetting {ssid}") + restart_required = True + + if restart_required: + try: + if not self._restart_station_supplicant(): + return False + if self._list_network_ids(ssid): + cloudlog.error(f"Runtime profile remained after station restart for forgotten network {ssid}") + return False + return True + except Exception: + cloudlog.exception(f"Failed to restart station runtime after forgetting {ssid}") + return False + + def _finish_forget_reconciliation(self, pending: PendingForgetReconciliation) -> bool: + with self._radio_lock: + with self._state_lock: + if self._pending_forget_reconciliations.get(pending.ssid) is not pending: + return False + if not self._reconcile_forgotten_runtime(pending.ssid): + return False + + self._clear_pending_connection(pending.ssid, epoch=pending.epoch) + with self._state_lock: + if self._pending_forget_reconciliations.get(pending.ssid) is not pending: + return False + owns_epoch = self._user_epoch == pending.epoch + cleanup_epoch = None + if pending.forget_active and owns_epoch: + self._station_cleanup_pending |= pending.cleanup_required + self._set_connecting(None, kind=StationOperationKind.FORGET, operation_ssid=pending.ssid) + cleanup_epoch = self._user_epoch + del self._pending_forget_reconciliations[pending.ssid] + + if cleanup_epoch is not None: + self._prepare_connection(cleanup_epoch) + self._enqueue_callbacks(self._forgotten, pending.ssid) + return True + + def _retry_pending_forget_reconciliations(self, force: bool = False): + now = time.monotonic() + with self._state_lock: + if self._tethering_active or self._tethering_transition_pending or not self._pending_forget_reconciliations: + return + if not force and now - self._last_forget_reconciliation_attempt < FORGET_RECONCILIATION_RETRY_SECONDS: + return + self._last_forget_reconciliation_attempt = now + pending = list(self._pending_forget_reconciliations.values()) - if reply.header.message_type == MessageType.error: - cloudlog.warning(f"Failed to request scan: {reply}") + for operation in pending: + self._finish_forget_reconciliation(operation) - def _update_networks(self, block: bool = True): - if not self._active: + def activate_connection(self, ssid: str, block: bool = False): + if self._tethering_active: + cloudlog.warning(f"Ignoring activate {ssid!r} while tethering is active") return + with self._state_lock: + self._station_cleanup_pending |= ( + self._associated_ssid is not None + or self._wifi_state.status == ConnectStatus.CONNECTED + or self._dhcp_adoption_ssid is not None + ) + self._set_connecting(ssid, kind=StationOperationKind.ACTIVATE) + self._clear_pending_connection() + epoch = self._user_epoch def worker(): - with self._scan_lock: - if self._wifi_device is None: - cloudlog.warning("No WiFi device found") + with self._radio_lock: + if not self._prepare_connection(epoch): + return + if self._ctrl is None: + cloudlog.warning(f"No wpa_supplicant connection for activate {ssid}") + # Ignore failures superseded by a newer connection attempt + if self._user_epoch != epoch: + return + # Reset inline because _init_wifi_state ignores a missing control socket + self._set_connecting(None, kind=StationOperationKind.ACTIVATE, operation_ssid=ssid) + self._enqueue_callbacks(self._disconnected) 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}") + def reset_to_disconnected(): + if self._user_epoch != epoch: + return + self._restore_station_runtime(None, [], ["activation"]) + # Notify the UI when control-socket recovery fails + self._set_connecting(None, kind=StationOperationKind.ACTIVATE, operation_ssid=ssid) + self._enqueue_callbacks(self._disconnected) + + # Serialize the epoch check with saved-network activation + if self._user_epoch != epoch: return + try: + ids = self._list_network_ids(ssid) + if ids: + self._select_network_ids(ids) + else: + profiles = [entry for profile_ssid, entry in self._require_store().get_profiles() if profile_ssid == ssid] + if profiles: + ids = [ + self._add_and_select_network( + ssid, + entry.get("psk", ""), + entry.get("hidden", False), + entry.get("priority", 0), + bssid=entry.get("bssid") or None, + profile_uuid=entry.get("uuid"), + security=entry.get("security"), + ) + for entry in profiles + ] + if len(ids) > 1: + self._select_network_ids(ids) + else: + cloudlog.warning(f"Network {ssid} not found for activation") + reset_to_disconnected() + except Exception: + cloudlog.exception(f"Failed to activate {ssid}") + reset_to_disconnected() - ap_paths = wifi_props_reply.body[0].get('AccessPoints', ('ao', []))[1] + if block: + worker() + else: + threading.Thread(target=worker, daemon=True).start() - aps: dict[str, list[AccessPoint]] = {} + def _select_network_ids(self, net_ids: list[str]): + commands = ["DISABLE_NETWORK all", *(f"ENABLE_NETWORK {net_id}" for net_id in net_ids), "REASSOCIATE"] + for command in commands: + resp = self._request(command).strip() + if not resp.startswith("OK"): + raise RuntimeError(f"{command} failed: {resp}") + + def _add_and_select_network(self, ssid: str, psk: str = "", hidden: bool = False, priority: int = 0, + bssid: str | None = None, profile_uuid: str | None = None, + security: SecurityType | None = None) -> str: + security = security if security is not None else (SecurityType.WPA if psk else SecurityType.OPEN) + if security not in (SecurityType.OPEN, SecurityType.WPA): + raise ValueError(f"Unsupported security type: {security!r}") + if security == SecurityType.WPA and not is_valid_psk(psk): + raise ValueError("Invalid WPA passphrase") + + """Add a network and select it. Every SET_NETWORK is checked so a bad PSK/key_mgmt + surfaces an immediate error instead of a delayed WRONG_KEY; orphans get REMOVE_NETWORK'd.""" + net_id = self._request("ADD_NETWORK").strip() + if not net_id.isdigit(): + raise RuntimeError(f"ADD_NETWORK failed: {net_id}") - 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()) + try: + self._wpa_set_network(net_id, "ssid", format_ssid_value(ssid)) + if security == SecurityType.WPA: + self._wpa_set_network(net_id, "psk", format_psk_value(psk)) + else: + self._wpa_set_network(net_id, "key_mgmt", "NONE") + if hidden: + self._wpa_set_network(net_id, "scan_ssid", "1") + if bssid: + self._wpa_set_network(net_id, "bssid", bssid) + if profile_uuid: + self._wpa_set_network(net_id, "id_str", f'"{sanitize_for_conf(profile_uuid)}"') + self._wpa_set_network(net_id, "priority", str(priority)) + resp = self._request(f"SELECT_NETWORK {net_id}").strip() + if not resp.startswith("OK"): + raise RuntimeError(f"SELECT_NETWORK {net_id} failed: {resp}") + return net_id + except Exception: + try: + self._request(f"REMOVE_NETWORK {net_id}") + except Exception: + cloudlog.exception(f"Failed to clean up orphaned network {net_id}") + raise + + def _wpa_set_network(self, net_id: str, key: str, value: str): + resp = self._request(f"SET_NETWORK {net_id} {key} {value}").strip() + if not resp.startswith("OK"): + raise RuntimeError(f"SET_NETWORK {net_id} {key} failed: {resp}") + + def _list_network_ids(self, ssid: str) -> list[str]: + """Return all wpa_supplicant network ids matching SSID. LIST_NETWORKS emits + printf_encode'd SSIDs. Decode before comparing or non-ASCII SSIDs silently miss. + Don't .strip() the whole reply: SSIDs may end with spaces, so a trailing-space + SSID on the last line would be clipped and miss the match.""" + if self._ctrl is None: + raise OSError("wpa_supplicant ctrl not attached") + raw = self._request("LIST_NETWORKS") + lines = raw.splitlines() + if not lines or not lines[0].startswith("network id"): + raise RuntimeError(f"LIST_NETWORKS failed: {raw.strip()}") + return [parts[0] for line in lines[1:] + if len(parts := line.split("\t")) >= 2 and decode_ssid(parts[1]) == ssid] + + def _remove_wpa_network(self, ssid: str): + for net_id in self._list_network_ids(ssid): + self._remove_wpa_network_id(net_id) + + def _remove_wpa_network_id(self, net_id: str): + resp = self._request(f"REMOVE_NETWORK {net_id}").strip() + if not resp.startswith("OK"): + raise RuntimeError(f"REMOVE_NETWORK {net_id} failed: {resp}") - # 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 + def is_tethering_active(self) -> bool: + return self._tethering_active + def is_connection_saved(self, ssid: str) -> bool: + return self._store.contains(ssid) if self._store is not None else False + + def set_tethering_password(self, password: str): + # WPA PSKs use 8-63 UTF-8 bytes or 64 hexadecimal characters + pw_bytes = len(password.encode("utf-8")) + if not is_valid_psk(password): + cloudlog.warning(f"set_tethering_password: rejecting invalid password (bytes={pw_bytes})") + # Re-enable tethering controls after rejected input + self._enqueue_callbacks(self._activated if self._tethering_active else self._disconnected) + return + self._tethering_password_epoch += 1 + epoch = self._tethering_password_epoch + def transition(): + try: + store = self._require_store() + if not store.set_tethering_password(self._tethering_ssid, password): + raise OSError("no durable tethering profile") + except Exception: + cloudlog.exception("Failed to persist tethering password; runtime state unchanged") + self._enqueue_callbacks(self._activated if self._tethering_active else self._disconnected) + return + self._tethering_psk = password + if self._tethering_active: + try: + # Keep the hotspot active during the password restart + self._stop_tethering() + self._start_tethering() + except Exception: + cloudlog.exception("Failed to restart tethering after password change") try: - ap = AccessPoint.from_dbus(ap_props.body[0], ap_path) - if ap.ssid == "": - continue + self._stop_tethering() + except Exception: + cloudlog.exception("Tethering rollback also failed") + self._tethering_active = False + self._wifi_state = WifiState() + self._enqueue_callbacks(self._disconnected) - if ap.ssid not in aps: - aps[ap.ssid] = [] + def worker(): + with self._radio_lock: + if self._tethering_password_epoch == epoch: + transition() + threading.Thread(target=worker, daemon=True).start() - aps[ap.ssid].append(ap) + def _apply_ipv4_forward(self, enabled: bool): + value = "1" if enabled else "0" + subprocess.run(["sudo", "sysctl", f"net.ipv4.ip_forward={value}"], check=True) + actual = Path("/proc/sys/net/ipv4/ip_forward").read_text().strip() + if actual != value: + raise RuntimeError(f"Failed to set net.ipv4.ip_forward={value} (actual={actual!r})") + + def set_ipv4_forward(self, enabled: bool): + with self._radio_lock: + if self._ipv4_forward == enabled: + return + if self._tethering_active: + self._apply_ipv4_forward(enabled) + self._ipv4_forward = enabled + + def set_tethering_active(self, active: bool): + # Report enable immediately and disable after station recovery + self._tethering_epoch += 1 + epoch = self._tethering_epoch + self._tethering_transition_pending = True + if active: + self._tethering_active = True + def transition(): + if active: + try: + self._start_tethering() + except Exception: + cloudlog.exception("Failed to start tethering, rolling back") + try: + self._stop_tethering() except Exception: - # catch all for parsing errors - cloudlog.exception(f"Failed to parse AP properties for {ap_path}") + cloudlog.exception("Tethering rollback also failed") + self._tethering_active = False + self._wifi_state = WifiState() + self._enqueue_callbacks(self._disconnected) + else: + if not self._tethering_started: + self._tethering_active = False + self._enqueue_callbacks(self._disconnected) + return + try: + self._stop_tethering() + except Exception: + cloudlog.exception("Failed to stop tethering") + # Clear UI state even if teardown fails + self._tethering_active = False + self._wifi_state = WifiState() + self._ipv4_address = "" + self._enqueue_callbacks(self._disconnected) - 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 + def worker(): + with self._radio_lock: + if self._tethering_epoch == epoch: + try: + transition() + finally: + self._tethering_transition_pending = False + threading.Thread(target=worker, daemon=True).start() - if block: - worker() - else: - threading.Thread(target=worker, daemon=True).start() + def set_current_network_metered(self, metered: MeteredType): + if self._tethering_active: + return + ssid = self.connected_ssid + if ssid is None: + return - def _update_active_connection_info(self): - ipv4_address = "" - metered = MeteredType.UNKNOWN + def worker(): + try: + self._require_store().set_metered(ssid, int(metered)) + except Exception: + cloudlog.exception(f"Failed to update metered state for {ssid}") + return + if self.connected_ssid == ssid: + self._current_network_metered = metered + threading.Thread(target=worker, daemon=True).start() - conn_path, props = self._get_active_wifi_connection() + def _start_tethering(self): + if self._ipv4_forward is None: + raise RuntimeError("IPv4 forwarding policy is not initialized") + + self._tethering_active = True + self._tethering_started = True + self._set_connecting(self._tethering_ssid, requested=False) + + psk = self._tethering_psk + + if self._ctrl: + self._ctrl.close() + self._ctrl = None + + # Target only openpilot-owned daemons, including surviving AP instances + self._monitor_epoch += 1 + stop_wpa_supplicant(WPA_SUPPLICANT_CONF) + stop_wpa_supplicant(WPA_AP_CONF) + self._dhcp.stop() + prepare_wpa_runtime() + time.sleep(0.5) + self._apply_ipv4_forward(self._ipv4_forward) + + safe_tether_ssid = sanitize_for_conf(self._tethering_ssid) + lines = [WPA_CTRL_INTERFACE, "ap_scan=2", "", + "network={", f' ssid="{safe_tether_ssid}"', " mode=2", + " frequency=2437", " key_mgmt=WPA-PSK", " proto=RSN", + " pairwise=CCMP", " group=CCMP", f' psk={format_psk_value(psk)}', "}", ""] + ap_conf = "\n".join(lines) + with atomic_write(WPA_AP_CONF, overwrite=True) as f: + f.write(ap_conf) + + subprocess.run([ + "sudo", "wpa_supplicant", "-B", "-i", "wlan0", + "-c", WPA_AP_CONF, "-P", WPA_PID_FILE, "-D", "nl80211", + ], check=False) + time.sleep(1) + + subprocess.run(["sudo", "ip", "addr", "flush", "dev", "wlan0"], check=False) + subprocess.run(["sudo", "ip", "addr", "add", f"{TETHERING_IP_ADDRESS}/24", "dev", "wlan0"], check=True) + subprocess.run(["sudo", "ip", "link", "set", "wlan0", "up"], check=True) + + stop_tethering_dnsmasq() + self._dnsmasq_proc = subprocess.Popen([ + "sudo", "dnsmasq", + "--interface=wlan0", + "--bind-interfaces", + "--dhcp-range=192.168.43.2,192.168.43.254,24h", + "--dhcp-leasefile=/tmp/dnsmasq.leases", + "--no-daemon", + ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + start_new_session=True) + time.sleep(0.2) + if self._dnsmasq_proc.poll() is not None: + rc = self._dnsmasq_proc.returncode + self._dnsmasq_proc = None + raise RuntimeError(f"dnsmasq exited during tethering bringup (rc={rc})") + + _install_tethering_firewall_rules() + + # Verify that our daemon owns wlan0 in AP mode + if not wpa_supplicant_running(WPA_AP_CONF): + raise RuntimeError("AP wpa_supplicant did not start with our config; another daemon likely still owns wlan0") + try: + ctrl = WpaCtrl() + ctrl.open() + except Exception as e: + raise RuntimeError(f"AP wpa_supplicant bringup failed: {e}") from e + try: + status = parse_status(ctrl.request("STATUS")) + except Exception as e: + ctrl.close() + raise RuntimeError(f"AP wpa_supplicant STATUS failed: {e}") from e + if status.get("mode") != "AP": + actual_mode = status.get("mode") + ctrl.close() + raise RuntimeError(f"AP wpa_supplicant bringup did not take over wlan0 (mode={actual_mode!r}); another daemon likely owns the interface") + self._ctrl = ctrl + + self._wifi_state = WifiState(ssid=self._tethering_ssid, status=ConnectStatus.CONNECTED) + self._ipv4_address = TETHERING_IP_ADDRESS + self._enqueue_callbacks(self._activated) + + def _clear_tethering_network_state(self): + try: + self._apply_ipv4_forward(False) + except (OSError, RuntimeError, subprocess.CalledProcessError): + cloudlog.exception("Failed to disable IPv4 forwarding during tethering teardown") - if conn_path is not None and props is not None: - # IPv4 address - ip4config_path = props.get('Ip4Config', ('o', '/'))[1] + try: + stop_tethering_dnsmasq() + except OSError: + cloudlog.exception("Failed to stop tethering dnsmasq") + if self._dnsmasq_proc is not None: + try: + self._dnsmasq_proc.wait(timeout=3) + except Exception: + cloudlog.exception("Failed waiting for tethering dnsmasq to stop") + self._dnsmasq_proc = None + + try: + _delete_tethering_firewall_rules() + except OSError: + cloudlog.exception("Failed to remove tethering firewall rules") - 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] + def _stop_tethering(self): + self._clear_tethering_network_state() - for entry in address_data: - if 'address' in entry: - ipv4_address = entry['address'][1] - break + if self._ctrl: + self._ctrl.close() + self._ctrl = None - # Metered status - settings = self._get_connection_settings(conn_path) + self._monitor_epoch += 1 + stop_wpa_supplicant(WPA_AP_CONF) + time.sleep(0.5) - if len(settings) > 0: - metered_prop = settings['connection'].get('metered', ('i', 0))[1] + subprocess.run(["sudo", "ip", "addr", "flush", "dev", "wlan0"], check=False) - if metered_prop == MeteredType.YES: - metered = MeteredType.YES - elif metered_prop == MeteredType.NO: - metered = MeteredType.NO + generate_wpa_conf(self._require_store()) + self._ensure_wpa_supplicant() - self._ipv4_address = ipv4_address - self._current_network_metered = metered + self._tethering_active = False + self._tethering_started = False + self._wifi_state = WifiState(ssid=None, status=ConnectStatus.DISCONNECTED) + self._ipv4_address = "" + self._enqueue_callbacks(self._disconnected) def __del__(self): self.stop() @@ -940,13 +2035,14 @@ def __del__(self): def stop(self): if not self._exit: self._exit = True + self._exit_event.set() + ctrl, self._ctrl = self._ctrl, None + if ctrl is not None: + ctrl.interrupt() 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() + if ctrl is not None: + ctrl.close() + # Network daemons outlive the UI and are adopted by the next controller diff --git a/openpilot/system/ui/lib/wifi_network_store.py b/openpilot/system/ui/lib/wifi_network_store.py new file mode 100644 index 00000000000000..c8b915dc6cee76 --- /dev/null +++ b/openpilot/system/ui/lib/wifi_network_store.py @@ -0,0 +1,974 @@ +import configparser +import os +import re +import subprocess +import tempfile +import threading +import uuid +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import dataclass +from enum import IntEnum + +from openpilot.common.swaglog import cloudlog +from openpilot.common.nm_keyfile import decode_nm_keyfile_ssid, decode_nm_keyfile_string +from openpilot.common.utils import sudo_read +from openpilot.system.ui.lib.wpa_ctrl import SecurityType, is_valid_psk, is_valid_ssid + + +NM_CONNECTIONS_DIR = "/data/etc/NetworkManager/system-connections" +RUNTIME_CONNECTIONS_DIR = "/run/NetworkManager/system-connections" +NETPLAN_CONNECTIONS_DIR = "/data/etc/netplan" + +# Never reinterpret unsupported secured profiles as open +_SUPPORTED_KEY_MGMT = {"wpa-psk", "none"} +_SUPPORTED_CONNECTION_OPTIONS = { + "id", "uuid", "type", "autoconnect", "autoconnect-priority", "autoconnect-retries", "timestamp", "metered", "interface-name", +} +_SUPPORTED_WIFI_OPTIONS = {"ssid", "mode", "hidden", "bssid"} +_SUPPORTED_SECURITY_OPTIONS = {"key-mgmt", "psk", "psk-flags", "auth-alg"} +_SUPPORTED_IPV4_METHODS = {"auto"} +_SUPPORTED_IPV6_METHODS = {"auto", "ignore"} +_SUPPORTED_IPV4_OPTIONS = {"method", "dns-priority"} +_SUPPORTED_IPV6_OPTIONS = {"method", "addr-gen-mode"} +# Preserve NetworkManager's DNS priority for rollback compatibility +_OPENPILOT_DNS_PRIORITY = "600" +_UPDATE_REMNANT_RE = re.compile(r"^(?P.+)\.openpilot-update-(?P[0-9a-f]{32})$") +_UPDATE_CREATED_RE = re.compile(r"^(?P.+)\.openpilot-update-created-(?P[0-9a-f]{32})$") +_UPDATE_COMMIT_MARKER_RE = re.compile(r"^\.openpilot-update-committed-(?P[0-9a-f]{32})$") +_FORGET_REMNANT_RE = re.compile(r"^(?P.+)\.openpilot-forget-(?P[0-9a-f]{32})$") +_FORGET_COMMIT_MARKER_RE = re.compile(r"^\.openpilot-forget-committed-(?P[0-9a-f]{32})$") +_NETPLAN_UUID_RE = re.compile(r"^\s*uuid\s*:\s*['\"]?([^'\"\s#]+)['\"]?\s*(?:#.*)?$", re.MULTILINE) + + +class MeteredType(IntEnum): + UNKNOWN = 0 + YES = 1 + NO = 2 + + +@dataclass(frozen=True) +class NetplanSource: + path: str + exclusive: bool + + +def _canonical_filename(file_uuid: str, ssid: str) -> str: + """`-.nmconnection` matches netplan's runtime keyfile naming. UUID is the + stable handle; the SSID suffix is purely cosmetic, so it gets sanitized lossily.""" + ssid_safe = ssid.encode("utf-8", errors="surrogateescape").decode("utf-8", errors="replace") + ssid_safe = ssid_safe.replace("/", "_").replace("\0", "_") + return f"{file_uuid}-{ssid_safe}.nmconnection" + + +def _parse_uuid(value: str) -> str | None: + try: + return str(uuid.UUID(value)) + except ValueError: + return None + + +def _single_profile_netplan_uuid(raw: str) -> str | None: + """Return the UUID only when the YAML has one AGNOS-generated Wi-Fi profile. + + This intentionally recognizes a narrow structural subset instead of trying to + mutate arbitrary Netplan YAML. Any aliases, flow mappings, sibling profiles, + additional top-level sections, or malformed indentation fail closed. + """ + if "\t" in raw: + return None + + lines: list[tuple[int, str]] = [] + for line in raw.splitlines(): + if not line.strip() or line.lstrip().startswith("#"): + continue + indent = len(line) - len(line.lstrip(" ")) + if indent % 2: + return None + lines.append((indent, line.strip())) + + if [text for indent, text in lines if indent == 0] != ["network:"]: + return None + level_two = [text for indent, text in lines if indent == 2] + if level_two.count("version: 2") != 1 or level_two.count("wifis:") != 1 or len(level_two) != 2: + return None + + wifis_index = lines.index((2, "wifis:")) + wifis_end = next((i for i in range(wifis_index + 1, len(lines)) if lines[i][0] <= 2), len(lines)) + profiles = [(i, text) for i, (indent, text) in enumerate(lines[wifis_index + 1:wifis_end], wifis_index + 1) + if indent == 4] + if len(profiles) != 1: + return None + profile_index, profile_line = profiles[0] + profile_match = re.fullmatch(r"NM-([0-9A-Fa-f-]+):", profile_line) + if profile_match is None or (profile_uuid := _parse_uuid(profile_match.group(1))) is None: + return None + + profile_end = next((i for i in range(profile_index + 1, wifis_end) if lines[i][0] <= 4), wifis_end) + networkmanager = [i for i in range(profile_index + 1, profile_end) + if lines[i] == (6, "networkmanager:")] + if len(networkmanager) != 1: + return None + nm_index = networkmanager[0] + nm_end = next((i for i in range(nm_index + 1, profile_end) if lines[i][0] <= 6), profile_end) + direct_uuids = [match.group(1) for i in range(nm_index + 1, nm_end) + if lines[i][0] == 8 and (match := _NETPLAN_UUID_RE.fullmatch(lines[i][1])) is not None] + if len(direct_uuids) != 1 or _parse_uuid(direct_uuids[0]) != profile_uuid: + return None + return profile_uuid + + +def _encode_keyfile_string(value: str) -> str: + """Encode GLib keyfile string escapes, including boundary spaces.""" + leading_spaces = len(value) - len(value.lstrip(" ")) + trailing_spaces = len(value) - len(value.rstrip(" ")) + encoded = [] + for i, char in enumerate(value): + if char == "\\": + encoded.append("\\\\") + elif char == "\n": + encoded.append("\\n") + elif char == "\r": + encoded.append("\\r") + elif char == "\t": + encoded.append("\\t") + elif char == " " and (i < leading_spaces or i >= len(value) - trailing_spaces): + encoded.append("\\s") + else: + encoded.append(char) + return "".join(encoded) + + +def _encode_keyfile_ssid(ssid: str) -> str: + if not ssid: + return "" + return ";".join(str(b) for b in ssid.encode("utf-8", errors="surrogateescape")) + ";" + + +def _normalize_keyfile_sections(cp: configparser.ConfigParser): + for alias, canonical in ( + ("wifi", "802-11-wireless"), + ("wifi-security", "802-11-wireless-security"), + ): + if not cp.has_section(alias) and cp.has_section(canonical): + cp[alias] = dict(cp[canonical]) + + +def _keyfile_section(cp: configparser.ConfigParser, alias: str, canonical: str) -> str | None: + if cp.has_section(alias): + return alias + if cp.has_section(canonical): + return canonical + return None + + +class NetworkStore: + def __init__(self, directory: str = NM_CONNECTIONS_DIR, runtime_directory: str | None = None, netplan_directory: str | None = None): + self._directory = directory + # Import Netplan's runtime-only keyfiles on production stores + self._runtime_directory = RUNTIME_CONNECTIONS_DIR if directory == NM_CONNECTIONS_DIR else None + if runtime_directory is not None: + self._runtime_directory = runtime_directory + self._netplan_directory = NETPLAN_CONNECTIONS_DIR if directory == NM_CONNECTIONS_DIR else None + if netplan_directory is not None: + self._netplan_directory = netplan_directory + self._lock = threading.Lock() + self._mutation_lock = threading.Lock() + self._networks: dict[str, dict] = {} + self._profiles: dict[str, list[dict]] = {} + self._recover_transaction_remnants() + self._load() + + def _recover_transaction_remnants(self): + directories = list(dict.fromkeys(( + self._directory, + self._runtime_directory, + self._netplan_directory, + ))) + directory_filenames: dict[str, list[str]] = {} + recovery_complete = True + for directory in directories: + if directory is None: + continue + try: + directory_filenames[directory] = sorted(os.listdir(directory)) + except OSError: + if directory == self._directory: + return + recovery_complete = False + + update_committed_markers = { + match.group("token"): os.path.join(self._directory, filename) + for filename in directory_filenames.get(self._directory, []) + if (match := _UPDATE_COMMIT_MARKER_RE.fullmatch(filename)) is not None + } + forget_committed_markers = { + match.group("token"): os.path.join(self._directory, filename) + for filename in directory_filenames.get(self._directory, []) + if (match := _FORGET_COMMIT_MARKER_RE.fullmatch(filename)) is not None + } + update_cleanup_failed: set[str] = set() + committed_cleanup_failed: set[str] = set() + for directory, filenames in directory_filenames.items(): + for filename in filenames: + update_match = _UPDATE_REMNANT_RE.fullmatch(filename) + update_created_match = _UPDATE_CREATED_RE.fullmatch(filename) + forget_match = _FORGET_REMNANT_RE.fullmatch(filename) + if update_match is None and update_created_match is None and forget_match is None: + continue + remnant_path = os.path.join(directory, filename) + match = update_match or update_created_match or forget_match + assert match is not None + original_path = os.path.join(directory, match.group("original")) + token = match.group("token") + update_committed = (update_match is not None or update_created_match is not None) and token in update_committed_markers + forget_committed = forget_match is not None and token in forget_committed_markers + if update_created_match is not None and not update_committed: + result = subprocess.run(["sudo", "rm", "-f", original_path], check=False) + if result.returncode == 0: + result = subprocess.run(["sudo", "rm", "-f", remnant_path], check=False) + else: + command = ["sudo", "rm", "-f", remnant_path] if update_committed or forget_committed else [ + "sudo", "mv", "-f", remnant_path, original_path, + ] + result = subprocess.run(command, check=False) + if result.returncode != 0: + cloudlog.warning(f"NetworkStore: failed to recover transaction remnant {remnant_path} (rc={result.returncode})") + if update_committed: + update_cleanup_failed.add(token) + if forget_committed: + committed_cleanup_failed.add(token) + + if recovery_complete: + for token, marker_path in update_committed_markers.items(): + if token in update_cleanup_failed: + continue + result = subprocess.run(["sudo", "rm", "-f", marker_path], check=False) + if result.returncode != 0: + cloudlog.warning(f"NetworkStore: failed to clean up update commit marker {marker_path} (rc={result.returncode})") + for token, marker_path in forget_committed_markers.items(): + if token in committed_cleanup_failed: + continue + result = subprocess.run(["sudo", "rm", "-f", marker_path], check=False) + if result.returncode != 0: + cloudlog.warning(f"NetworkStore: failed to clean up forget commit marker {marker_path} (rc={result.returncode})") + + def _load(self): + self._networks = {} + self._profiles = {} + sources = [(self._directory, False)] + if self._runtime_directory is not None: + sources.append((self._runtime_directory, True)) + + persistent_uuids: dict[str, set[str]] = {} + for directory, imported in sources: + try: + filenames = sorted(os.listdir(directory)) + except OSError: + continue + for fname in filenames: + self._load_keyfile(directory, fname, imported, persistent_uuids) + + def _find_netplan_source(self, file_uuid: str) -> NetplanSource | None: + if self._netplan_directory is None or not file_uuid: + return None + expected_path = os.path.join(self._netplan_directory, f"90-NM-{file_uuid}.yaml") + try: + filenames = sorted(os.listdir(self._netplan_directory)) + except OSError: + return None + yaml_filenames = [fname for fname in filenames if fname.endswith(".yaml")] + read_failed = False + exclusive_matches: list[str] = [] + ambiguous_matches: list[str] = [] + for fname in yaml_filenames: + path = os.path.join(self._netplan_directory, fname) + try: + raw = sudo_read(path) + except OSError: + read_failed = True + continue + if not raw: + read_failed = True + continue + source_uuids = {_parse_uuid(value) for value in _NETPLAN_UUID_RE.findall(raw)} + if file_uuid in source_uuids: + exclusive_uuid = _single_profile_netplan_uuid(raw) + if exclusive_uuid == file_uuid and source_uuids == {file_uuid}: + exclusive_matches.append(path) + else: + ambiguous_matches.append(path) + + if exclusive_matches: + return NetplanSource( + exclusive_matches[0], + not read_failed and len(exclusive_matches) == 1 and not ambiguous_matches, + ) + if ambiguous_matches: + return NetplanSource(ambiguous_matches[0], False) + if os.path.exists(expected_path) or read_failed: + # A canonical filename or unreadable YAML is not proof of exclusive ownership. + return NetplanSource(expected_path, False) + return None + + def _load_keyfile(self, directory: str, fname: str, imported: bool, persistent_uuids: dict[str, set[str]]): + if not fname.endswith(".nmconnection"): + return + fpath = os.path.join(directory, fname) + try: + cp = configparser.ConfigParser(interpolation=None) + raw = sudo_read(fpath) + if raw: + cp.read_string(raw) + else: + cp.read(fpath) + + _normalize_keyfile_sections(cp) + if not cp.has_section("wifi"): + return + ssid = decode_nm_keyfile_ssid(cp.get("wifi", "ssid", fallback="")) + mode = cp.get("wifi", "mode", fallback="infrastructure") + if not is_valid_ssid(ssid) or mode != "infrastructure": + return + if {key for key, value in cp.items("wifi") if value} - _SUPPORTED_WIFI_OPTIONS: + cloudlog.warning(f"NetworkStore: skipping {ssid!r} with unsupported Wi-Fi options") + return + bssid = cp.get("wifi", "bssid", fallback="") + if bssid and re.fullmatch(r"(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}", bssid) is None: + cloudlog.warning(f"NetworkStore: skipping {ssid!r} with invalid bssid={bssid!r}") + return + raw_uuid = cp.get("connection", "uuid", fallback="") + file_uuid = _parse_uuid(raw_uuid) + if file_uuid is None: + cloudlog.warning(f"NetworkStore: skipping {ssid!r} with invalid uuid={raw_uuid!r}") + return + connection = dict(cp["connection"]) + connection_type = connection.get("type", "wifi").lower() + interface_name = connection.get("interface-name", "") + unsupported_connection_options = {key for key, value in connection.items() if value} - _SUPPORTED_CONNECTION_OPTIONS + if (connection_type not in ("wifi", "802-11-wireless") + or interface_name not in ("", "wlan0") + or cp.getint("connection", "autoconnect-retries", fallback=0) != 0 + or unsupported_connection_options): + cloudlog.warning(f"NetworkStore: skipping {ssid!r} with unsupported connection constraints") + return + # Skip secured profiles that cannot be reproduced safely. + security = SecurityType.OPEN + psk = "" + if cp.has_section("wifi-security"): + key_mgmt = cp.get("wifi-security", "key-mgmt", fallback="").lower() + if key_mgmt not in _SUPPORTED_KEY_MGMT: + cloudlog.warning(f"NetworkStore: skipping {ssid!r} with unsupported key-mgmt={key_mgmt!r}") + return + # key-mgmt=none can still represent WEP; never import it as open. + wep_keys = ("wep-key0", "wep-key1", "wep-key2", "wep-key3", "wep-key-type", "auth-alg") + if key_mgmt == "none" and any(cp.has_option("wifi-security", k) for k in wep_keys): + cloudlog.warning(f"NetworkStore: skipping {ssid!r} (WEP profile, unsupported)") + return + unsupported_security_options = {key for key, value in cp.items("wifi-security") if value} - _SUPPORTED_SECURITY_OPTIONS + auth_alg = cp.get("wifi-security", "auth-alg", fallback="").lower() + psk_flags = cp.getint("wifi-security", "psk-flags", fallback=0) + if unsupported_security_options or auth_alg not in ("", "open") or psk_flags != 0: + cloudlog.warning(f"NetworkStore: skipping {ssid!r} with unsupported security constraints") + return + if key_mgmt == "wpa-psk": + security = SecurityType.WPA + psk = decode_nm_keyfile_string(cp.get("wifi-security", "psk", fallback="")) + # Agent-managed secrets are unavailable to wpa_supplicant. + if not is_valid_psk(psk): + cloudlog.warning(f"NetworkStore: skipping {ssid!r} (wpa-psk with invalid inline secret)") + return + + # Respect disabled autoconnect profiles + if not cp.getboolean("connection", "autoconnect", fallback=True): + cloudlog.warning(f"NetworkStore: skipping {ssid!r} (connection.autoconnect=false)") + return + + ipv4 = dict(cp["ipv4"]) if cp.has_section("ipv4") else {"method": "auto"} + ipv6 = dict(cp["ipv6"]) if cp.has_section("ipv6") else {"method": "auto"} + ipv4_method = ipv4.get("method", "auto").lower() + ipv6_method = ipv6.get("method", "auto").lower() + unsupported_ipv4_options = {key for key, value in ipv4.items() if value} - _SUPPORTED_IPV4_OPTIONS + unsupported_ipv6_options = {key for key, value in ipv6.items() if value} - _SUPPORTED_IPV6_OPTIONS + ipv4_dns_priority = ipv4.get("dns-priority") or None + ipv6_addr_gen_mode = (ipv6.get("addr-gen-mode") or "default").lower() + if (ipv4_method not in _SUPPORTED_IPV4_METHODS + or ipv6_method not in _SUPPORTED_IPV6_METHODS + or unsupported_ipv4_options + or unsupported_ipv6_options + or ipv4_dns_priority not in (None, _OPENPILOT_DNS_PRIORITY) + or ipv6_addr_gen_mode != "default"): + cloudlog.warning(f"NetworkStore: skipping {ssid!r} with unsupported addressing configuration") + return + + # Skip profiles with malformed integer or boolean values + entry = { + "psk": psk, + "security": security, + "metered": cp.getint("connection", "metered", fallback=0), + "priority": cp.getint("connection", "autoconnect-priority", fallback=0), + "hidden": cp.getboolean("wifi", "hidden", fallback=False), + "bssid": bssid, + "uuid": file_uuid, + "_connection": connection, + "_ipv4": ipv4, + "_ipv6": ipv6, + # Track persistent, runtime, and Netplan representations separately. + "_filename": None if imported else fname, + "_runtime_filename": fname if imported else None, + "_netplan_source": self._find_netplan_source(file_uuid) if imported else None, + } + profiles = self._profiles.setdefault(ssid, []) + if imported and file_uuid in persistent_uuids.get(ssid, set()): + persistent = next((profile for profile in profiles if profile.get("uuid") == file_uuid), None) + if persistent is not None and persistent.get("_runtime_filename") is None: + persistent["_runtime_filename"] = fname + persistent["_netplan_source"] = self._find_netplan_source(file_uuid) + return + if any(profile.get("uuid") == file_uuid for profile in profiles): + return + profiles.append(entry) + if not imported: + persistent_uuids.setdefault(ssid, set()).add(file_uuid) + self._networks.setdefault(ssid, entry) + except (configparser.Error, ValueError): + return + + def _install_keyfile(self, cp: configparser.ConfigParser, path: str): + with tempfile.NamedTemporaryFile(mode="w", delete=False) as f: + cp.write(f) + temp_path = f.name + + try: + os.chmod(temp_path, 0o600) + subprocess.run(["sudo", "install", "-d", "-m", "755", self._directory], check=True) + subprocess.run(["sudo", "install", "-o", "root", "-g", "root", "-m", "600", temp_path, path], check=True) + finally: + try: + os.unlink(temp_path) + except FileNotFoundError: + pass + + @contextmanager + def _update_transaction(self, paths: set[str], description: str) -> Iterator[None]: + if not paths: + yield + return + + original_paths = {path for path in paths if os.path.exists(path)} + created_paths = paths - original_paths + token = uuid.uuid4().hex + backups: dict[str, str] = {} + created_markers = {path: f"{path}.openpilot-update-created-{token}" for path in created_paths} + commit_marker = os.path.join(self._directory, f".openpilot-update-committed-{token}") + try: + for path in sorted(original_paths): + backup_path = f"{path}.openpilot-update-{token}" + result = subprocess.run([ + "sudo", "install", "-o", "root", "-g", "root", "-m", "600", path, backup_path, + ], check=False) + if result.returncode != 0: + raise OSError(f"failed to back up {path}") + backups[path] = backup_path + for marker_path in created_markers.values(): + result = subprocess.run([ + "sudo", "install", "-o", "root", "-g", "root", "-m", "600", "/dev/null", marker_path, + ], check=False) + if result.returncode != 0: + raise OSError(f"failed to mark created path for {description}") + yield + result = subprocess.run([ + "sudo", "install", "-o", "root", "-g", "root", "-m", "600", "/dev/null", commit_marker, + ], check=False) + if result.returncode != 0: + raise OSError(f"failed to commit update for {description}") + except Exception as e: + rollback_failed = False + rollback_failed |= subprocess.run(["sudo", "rm", "-f", commit_marker], check=False).returncode != 0 + for path, marker_path in created_markers.items(): + removed = subprocess.run(["sudo", "rm", "-f", path], check=False).returncode == 0 + rollback_failed |= not removed + if removed: + rollback_failed |= subprocess.run(["sudo", "rm", "-f", marker_path], check=False).returncode != 0 + for path, backup_path in backups.items(): + rollback_failed |= subprocess.run(["sudo", "mv", "-f", backup_path, path], check=False).returncode != 0 + if rollback_failed: + raise OSError(f"failed to roll back update for {description}") from e + raise + else: + cleanup_failed = False + for backup_path in backups.values(): + result = subprocess.run(["sudo", "rm", "-f", backup_path], check=False) + if result.returncode != 0: + cleanup_failed = True + cloudlog.warning(f"NetworkStore: failed to clean up update backup {backup_path} (rc={result.returncode})") + for marker_path in created_markers.values(): + result = subprocess.run(["sudo", "rm", "-f", marker_path], check=False) + if result.returncode != 0: + cleanup_failed = True + cloudlog.warning(f"NetworkStore: failed to clean up created-path marker {marker_path} (rc={result.returncode})") + if not cleanup_failed: + result = subprocess.run(["sudo", "rm", "-f", commit_marker], check=False) + if result.returncode != 0: + cloudlog.warning(f"NetworkStore: failed to clean up update commit marker {commit_marker} (rc={result.returncode})") + + @contextmanager + def _profile_update(self, ssid: str, profiles: list[dict]) -> Iterator[None]: + paths: set[str] = set() + for profile in profiles: + file_uuid = profile.get("uuid") + if file_uuid: + paths.add(os.path.join(self._directory, _canonical_filename(file_uuid, ssid))) + filename = profile.get("_filename") + if filename: + paths.add(os.path.join(self._directory, filename)) + runtime_filename = profile.get("_runtime_filename") + if self._runtime_directory is not None and runtime_filename: + paths.add(os.path.join(self._runtime_directory, runtime_filename)) + netplan_source = profile.get("_netplan_source") + if isinstance(netplan_source, NetplanSource): + if not netplan_source.exclusive: + raise OSError(f"refusing to mutate shared Netplan source {netplan_source.path}") + paths.add(netplan_source.path) + + with self._update_transaction(paths, repr(ssid)): + yield + + def _render_nmconnection(self, ssid: str, entry: dict) -> tuple[str, dict]: + file_uuid = entry.get("uuid") + if not file_uuid: + try: + file_uuid = str(uuid.uuid5(uuid.NAMESPACE_DNS, ssid)) + except UnicodeEncodeError: + ssid_hex = ssid.encode("utf-8", errors="surrogateescape").hex() + file_uuid = str(uuid.uuid5(uuid.NAMESPACE_OID, ssid_hex)) + entry = dict(entry) + entry["uuid"] = file_uuid + + canonical_fname = _canonical_filename(file_uuid, ssid) + canonical_path = os.path.join(self._directory, canonical_fname) + stored_fname = entry.get("_filename") + entry["_filename"] = canonical_fname + + cp = configparser.ConfigParser(interpolation=None) + connection_id = ssid.encode("utf-8", errors="surrogateescape").decode("utf-8", errors="replace") + connection = dict(entry.get("_connection", {})) + connection.update({ + "id": _encode_keyfile_string(connection_id), + "uuid": file_uuid, + "type": "wifi", + "metered": str(entry.get("metered", 0)), + "autoconnect-priority": str(entry.get("priority", 0)), + }) + cp["connection"] = connection + wifi = { + "ssid": _encode_keyfile_ssid(ssid), + "mode": "infrastructure", + "hidden": str(entry.get("hidden", False)).lower(), + } + if entry.get("bssid"): + wifi["bssid"] = entry["bssid"] + cp["wifi"] = wifi + + psk = entry.get("psk", "") + security = entry.get("security", SecurityType.WPA if psk else SecurityType.OPEN) + if security == SecurityType.WPA: + cp["wifi-security"] = { + "key-mgmt": "wpa-psk", + "psk": _encode_keyfile_string(psk), + } + + ipv4 = dict(entry.get("_ipv4", {"method": "auto"})) + ipv4["dns-priority"] = _OPENPILOT_DNS_PRIORITY + cp["ipv4"] = ipv4 + cp["ipv6"] = entry.get("_ipv6", {"method": "auto"}) + + self._install_keyfile(cp, canonical_path) + + runtime_filename = entry.get("_runtime_filename") + if self._runtime_directory is not None and runtime_filename: + runtime_path = os.path.join(self._runtime_directory, runtime_filename) + result = subprocess.run(["sudo", "rm", "-f", runtime_path], check=False) + if result.returncode != 0: + raise OSError(f"failed to remove {runtime_path}") + entry["_runtime_filename"] = None + + netplan_source = entry.get("_netplan_source") + if isinstance(netplan_source, NetplanSource): + if not netplan_source.exclusive: + raise OSError(f"refusing to mutate shared Netplan source {netplan_source.path}") + netplan_path = netplan_source.path + if not os.path.exists(netplan_path): + raise OSError(f"failed to find {netplan_path}") + result = subprocess.run(["sudo", "rm", "-f", netplan_path], check=False) + if result.returncode != 0: + raise OSError(f"failed to remove {netplan_path}") + entry["_netplan_source"] = None + + # Keep one canonical filename for noncanonical profiles + if stored_fname and stored_fname != canonical_fname: + stored_path = os.path.join(self._directory, stored_fname) + result = subprocess.run(["sudo", "rm", "-f", stored_path], check=False) + if result.returncode != 0: + raise OSError(f"failed to remove noncanonical profile {stored_path}") + + return file_uuid, entry + + def get_all(self) -> dict[str, dict]: + with self._lock: + return {k: dict(v) for k, v in self._networks.items()} + + def get_profiles(self) -> list[tuple[str, dict]]: + with self._lock: + return [ + (ssid, dict(entry)) + for ssid, profiles in self._profiles.items() + for entry in profiles + ] + + def get(self, ssid: str) -> dict | None: + with self._lock: + entry = self._networks.get(ssid) + return dict(entry) if entry else None + + def get_tethering_password(self, ssid: str) -> str | None: + for cp, _, _, _ in self._tethering_profiles(ssid): + security_section = _keyfile_section(cp, "wifi-security", "802-11-wireless-security") + assert security_section is not None + password = decode_nm_keyfile_string(cp.get(security_section, "psk", fallback="")) + if password: + return password + return None + + def _tethering_profiles(self, ssid: str) -> list[tuple[configparser.ConfigParser, str, str, str]]: + profiles = [] + directories = [self._directory] + if self._runtime_directory is not None: + directories.append(self._runtime_directory) + + for directory in directories: + try: + filenames = os.listdir(directory) + except OSError: + continue + for fname in filenames: + if not fname.endswith(".nmconnection"): + continue + try: + raw = sudo_read(os.path.join(directory, fname)) + if not raw: + continue + cp = configparser.ConfigParser(interpolation=None) + cp.read_string(raw) + wifi_section = _keyfile_section(cp, "wifi", "802-11-wireless") + security_section = _keyfile_section(cp, "wifi-security", "802-11-wireless-security") + if wifi_section is None or security_section is None: + continue + profile_ssid = decode_nm_keyfile_ssid(cp.get(wifi_section, "ssid", fallback="")) + if cp.get(wifi_section, "mode", fallback="infrastructure") != "ap" or profile_ssid != ssid: + continue + if cp.get(security_section, "key-mgmt", fallback="").lower() != "wpa-psk": + continue + file_uuid = _parse_uuid(cp.get("connection", "uuid", fallback="")) + if file_uuid is None: + continue + profiles.append((cp, directory, fname, file_uuid)) + except (configparser.Error, OSError, ValueError): + continue + return profiles + + def _create_tethering_profile(self, ssid: str, password: str) -> bool: + if not is_valid_ssid(ssid) or not is_valid_psk(password): + return False + + file_uuid = str(uuid.uuid4()) + cp = configparser.ConfigParser(interpolation=None) + cp["connection"] = { + "id": "Hotspot", + "uuid": file_uuid, + "type": "wifi", + "interface-name": "wlan0", + "autoconnect": "false", + "autoconnect-retries": "0", + } + cp["wifi"] = { + "ssid": _encode_keyfile_ssid(ssid), + "mode": "ap", + "band": "bg", + } + cp["wifi-security"] = { + "key-mgmt": "wpa-psk", + "psk": _encode_keyfile_string(password), + "group": "ccmp;", + "pairwise": "ccmp;", + "proto": "rsn;", + } + cp["ipv4"] = { + "method": "shared", + "address1": "192.168.43.1/24", + "never-default": "true", + } + cp["ipv6"] = { + "method": "ignore", + "addr-gen-mode": "default", + } + cp["proxy"] = {} + + subprocess.run(["sudo", "install", "-d", "-o", "root", "-g", "root", "-m", "700", self._directory], check=True) + self._install_keyfile(cp, os.path.join(self._directory, _canonical_filename(file_uuid, ssid))) + return True + + def ensure_tethering_profile(self, ssid: str, password: str) -> bool: + with self._mutation_lock: + if self._tethering_profiles(ssid): + return True + return self._create_tethering_profile(ssid, password) + + def set_tethering_password(self, ssid: str, password: str) -> bool: + with self._mutation_lock: + profiles = self._tethering_profiles(ssid) + if not profiles: + return self._create_tethering_profile(ssid, password) + + profiles_by_uuid: dict[str, list[tuple[configparser.ConfigParser, str, str, str]]] = {} + for profile in profiles: + profiles_by_uuid.setdefault(profile[3], []).append(profile) + + updates: list[tuple[configparser.ConfigParser, str, set[str]]] = [] + paths: set[str] = set() + for file_uuid, representations in sorted(profiles_by_uuid.items()): + persistent = sorted( + (profile for profile in representations if profile[1] == self._directory), + key=lambda profile: profile[2], + ) + source = persistent[0] if persistent else min(representations, key=lambda profile: (profile[1], profile[2])) + cp, _, source_filename, _ = source + security_section = _keyfile_section(cp, "wifi-security", "802-11-wireless-security") + assert security_section is not None + cp[security_section]["psk"] = _encode_keyfile_string(password) + + target_path = ( + os.path.join(self._directory, source_filename) + if persistent + else os.path.join(self._directory, _canonical_filename(file_uuid, ssid)) + ) + obsolete_paths = { + os.path.join(directory, filename) + for _, directory, filename, _ in representations + if os.path.join(directory, filename) != target_path + } + if any(directory == self._runtime_directory for _, directory, _, _ in representations): + netplan_source = self._find_netplan_source(file_uuid) + if netplan_source is not None: + if not netplan_source.exclusive or not os.path.exists(netplan_source.path): + return False + obsolete_paths.add(netplan_source.path) + + updates.append((cp, target_path, obsolete_paths)) + paths.add(target_path) + paths.update(obsolete_paths) + + with self._update_transaction(paths, f"tethering profile {ssid!r}"): + for cp, target_path, obsolete_paths in updates: + self._install_keyfile(cp, target_path) + for source_path in sorted(obsolete_paths): + result = subprocess.run(["sudo", "rm", "-f", source_path], check=False) + if result.returncode != 0: + raise OSError(f"failed to remove {source_path}") + return True + + def save_network(self, ssid: str, psk: str | None = None, metered: int | None = None, hidden: bool | None = None, + security: SecurityType | None = None, profile_uuid: str | None = None): + with self._mutation_lock: + with self._lock: + current = self._networks.get(ssid) + profiles = list(self._profiles.get(ssid, [])) + existing = dict(current or {}) + if profile_uuid is not None: + canonical_uuid = _parse_uuid(profile_uuid) + if canonical_uuid is None: + raise ValueError(f"invalid profile UUID: {profile_uuid!r}") + if current is not None and current.get("uuid") != canonical_uuid: + raise ValueError(f"profile UUID changed for {ssid!r}") + existing["uuid"] = canonical_uuid + if psk is not None: + existing["psk"] = psk + existing["security"] = security if security is not None else (SecurityType.WPA if psk else SecurityType.OPEN) + else: + existing.setdefault("psk", "") + if security is not None: + existing["security"] = security + else: + existing.setdefault("security", SecurityType.WPA if existing["psk"] else SecurityType.OPEN) + if existing["security"] == SecurityType.OPEN: + existing["psk"] = "" + elif not is_valid_psk(existing["psk"]): + raise ValueError(f"invalid WPA PSK for {ssid!r}") + if metered is not None: + existing["metered"] = metered + elif "metered" not in existing: + existing["metered"] = 0 + if hidden is not None: + existing["hidden"] = hidden + elif "hidden" not in existing: + existing["hidden"] = False + + with self._profile_update(ssid, profiles): + file_uuid, updated = self._render_nmconnection(ssid, existing) + updated["uuid"] = file_uuid + if current is None: + profiles.append(updated) + else: + updated_profiles = [] + replaced_primary = False + for profile in profiles: + if profile is current: + updated_profiles.append(updated) + replaced_primary = True + elif psk is not None and not profile.get("bssid"): + duplicate = dict(profile) + duplicate["psk"] = psk + duplicate["security"] = SecurityType.WPA if psk else SecurityType.OPEN + duplicate_uuid, duplicate = self._render_nmconnection(ssid, duplicate) + duplicate["uuid"] = duplicate_uuid + updated_profiles.append(duplicate) + else: + updated_profiles.append(profile) + if not replaced_primary: + updated_profiles.append(updated) + profiles = updated_profiles + with self._lock: + self._profiles[ssid] = profiles + self._networks[ssid] = updated + + def remove(self, ssid: str) -> bool: + with self._mutation_lock: + with self._lock: + entry = self._networks.get(ssid) + if entry is None: + return False + profiles = list(self._profiles.get(ssid, [entry])) + + # Remove every representation so duplicates cannot restore the network + paths: set[str] = set() + netplan_paths: set[str] = set() + for profile in profiles: + paths.add(os.path.join(self._directory, _canonical_filename(profile.get("uuid", ""), ssid))) + tracked = profile.get("_filename") + if tracked: + paths.add(os.path.join(self._directory, tracked)) + runtime_filename = profile.get("_runtime_filename") + if self._runtime_directory is not None and runtime_filename: + paths.add(os.path.join(self._runtime_directory, runtime_filename)) + netplan_source = profile.get("_netplan_source") + if isinstance(netplan_source, NetplanSource): + if not netplan_source.exclusive: + cloudlog.warning(f"NetworkStore: refusing to remove shared Netplan source {netplan_source.path}") + return False + netplan_paths.add(netplan_source.path) + for p in netplan_paths: + if not os.path.exists(p): + cloudlog.warning(f"NetworkStore: failed to find netplan source {p}") + return False + paths.update(netplan_paths) + existing_paths = sorted(p for p in paths if os.path.exists(p)) + if len(existing_paths) > 1: + token = uuid.uuid4().hex + staged_paths = [] + for p in existing_paths: + staged_path = f"{p}.openpilot-forget-{token}" + result = subprocess.run(["sudo", "mv", "-f", p, staged_path], check=False) + if result.returncode != 0: + cloudlog.warning(f"NetworkStore: failed to stage {p} for removal (rc={result.returncode})") + for original_path, rollback_path in reversed(staged_paths): + rollback = subprocess.run(["sudo", "mv", "-f", rollback_path, original_path], check=False) + if rollback.returncode != 0: + cloudlog.warning(f"NetworkStore: failed to roll back {original_path} (rc={rollback.returncode})") + return False + staged_paths.append((p, staged_path)) + + commit_marker = os.path.join(self._directory, f".openpilot-forget-committed-{token}") + result = subprocess.run([ + "sudo", "install", "-o", "root", "-g", "root", "-m", "600", "/dev/null", commit_marker, + ], check=False) + if result.returncode != 0: + cloudlog.warning(f"NetworkStore: failed to commit removal of {ssid!r} (rc={result.returncode})") + for original_path, rollback_path in reversed(staged_paths): + rollback = subprocess.run(["sudo", "mv", "-f", rollback_path, original_path], check=False) + if rollback.returncode != 0: + cloudlog.warning(f"NetworkStore: failed to roll back {original_path} (rc={rollback.returncode})") + return False + + cleanup_failed = False + for _, staged_path in staged_paths: + result = subprocess.run(["sudo", "rm", "-f", staged_path], check=False) + if result.returncode != 0: + cleanup_failed = True + cloudlog.warning(f"NetworkStore: failed to clean up staged profile {staged_path} (rc={result.returncode})") + if not cleanup_failed: + result = subprocess.run(["sudo", "rm", "-f", commit_marker], check=False) + if result.returncode != 0: + cloudlog.warning(f"NetworkStore: failed to clean up forget commit marker {commit_marker} (rc={result.returncode})") + else: + for p in existing_paths: + result = subprocess.run(["sudo", "rm", "-f", p], check=False) + # Keep the in-memory profile when disk removal fails + if result.returncode != 0: + cloudlog.warning(f"NetworkStore: failed to remove {p} (rc={result.returncode})") + return False + with self._lock: + self._networks.pop(ssid, None) + self._profiles.pop(ssid, None) + return True + + def set_metered(self, ssid: str, metered: int): + with self._mutation_lock: + with self._lock: + profiles = list(self._profiles.get(ssid, [])) + if not profiles: + return + primary = self._networks.get(ssid) + with self._profile_update(ssid, profiles): + updated_profiles = [] + updated_primary = None + for current in profiles: + updated = dict(current) + updated["metered"] = metered + file_uuid, updated = self._render_nmconnection(ssid, updated) + updated["uuid"] = file_uuid + updated_profiles.append(updated) + if current is primary: + updated_primary = updated + with self._lock: + self._profiles[ssid] = updated_profiles + self._networks[ssid] = updated_primary or updated_profiles[0] + + def get_metered(self, ssid: str, profile_uuid: str | None = None) -> MeteredType: + with self._lock: + if profile_uuid is None: + entry = self._networks.get(ssid) + else: + entry = next((profile for profile in self._profiles.get(ssid, []) if profile.get("uuid") == profile_uuid), None) + if entry: + m = entry.get("metered", 0) + if m == MeteredType.YES: + return MeteredType.YES + elif m == MeteredType.NO: + return MeteredType.NO + return MeteredType.UNKNOWN + + def get_ipv6_method(self, ssid: str, profile_uuid: str | None = None) -> str: + with self._lock: + if profile_uuid is None: + entry = self._networks.get(ssid) + else: + entry = next((profile for profile in self._profiles.get(ssid, []) if profile.get("uuid") == profile_uuid), None) + return entry.get("_ipv6", {}).get("method", "auto").lower() if entry is not None else "auto" + + def contains(self, ssid: str) -> bool: + with self._lock: + return ssid in self._networks + + def saved_ssids(self) -> set[str]: + with self._lock: + return set(self._networks.keys()) diff --git a/openpilot/system/ui/lib/wpa_ctrl.py b/openpilot/system/ui/lib/wpa_ctrl.py new file mode 100644 index 00000000000000..5f8e9f1b9e3869 --- /dev/null +++ b/openpilot/system/ui/lib/wpa_ctrl.py @@ -0,0 +1,552 @@ +import os +import re +import shutil +import socket +import select +import subprocess +import threading +import time +import unicodedata +from collections.abc import Callable +from dataclasses import dataclass +from enum import IntEnum +from pathlib import Path + +from openpilot.common.swaglog import cloudlog +from openpilot.common.utils import atomic_write +from openpilot.common.wifi import WPA_CTRL_DIR, WPA_CTRL_PATH, WPA_PID_FILE, decode_wpa_ssid + + +RECV_BUF_SIZE = 32768 +IEEE80211_MAX_SSID_BYTES = 32 + +WPA_SUPPLICANT_CONF = "/tmp/wpa_supplicant.conf" +WPA_AP_CONF = "/tmp/wpa_supplicant_ap.conf" +WPA_CTRL_INTERFACE = f"ctrl_interface=DIR={WPA_CTRL_DIR} GROUP=netdev" +TETHERING_DNSMASQ_PATTERN = r"^dnsmasq .*--dhcp-range=192\.168\.43\.2" + + +class SecurityType(IntEnum): + OPEN = 0 + WPA = 1 + UNSUPPORTED = 2 + + +@dataclass(frozen=True) +class ScanResult: + bssid: str + freq: int + signal: int # dBm + flags: str + ssid: str + + +class _WpaCtrlBase: + """Shared socket lifecycle for wpa_supplicant control connections.""" + + _counter = 0 + _counter_lock = threading.Lock() + + def __init__(self, ctrl_path: str = WPA_CTRL_PATH): + self._ctrl_path = ctrl_path + self._sock: socket.socket | None = None + self._local_path: str = "" + + def _open_socket(self, prefix: str): + with _WpaCtrlBase._counter_lock: + _WpaCtrlBase._counter += 1 + idx = _WpaCtrlBase._counter + self._local_path = f"/tmp/{prefix}_{os.getpid()}_{idx}" + try: + os.unlink(self._local_path) + except OSError: + pass + sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) + try: + sock.bind(self._local_path) + sock.connect(self._ctrl_path) + except Exception: + sock.close() + try: + os.unlink(self._local_path) + except OSError: + pass + self._local_path = "" + raise + self._sock = sock + + def _ensure_sock(self) -> socket.socket: + if self._sock is None: + raise RuntimeError("not opened") + return self._sock + + def close(self): + if self._sock is not None: + try: + self._sock.close() + except OSError: + pass + self._sock = None + if self._local_path: + try: + os.unlink(self._local_path) + except OSError: + pass + self._local_path = "" + + def interrupt(self): + """Wake a thread blocked on this socket without racing normal cleanup.""" + sock = self._sock + if sock is not None: + try: + sock.shutdown(socket.SHUT_RDWR) + except OSError: + pass + + def __enter__(self): + self.open() + return self + + def __exit__(self, *_): + self.close() + + def __del__(self): + self.close() + + +class WpaCtrl(_WpaCtrlBase): + """Synchronous wpa_supplicant control socket command client.""" + + def __init__(self, ctrl_path: str = WPA_CTRL_PATH): + super().__init__(ctrl_path) + self._request_lock = threading.Lock() + + def open(self): + self._open_socket("wpa_ctrl") + self._sock.settimeout(10) + + def request(self, cmd: str) -> str: + """Send command, return response string.""" + with self._request_lock: + sock = self._ensure_sock() + sock.send(cmd.encode()) + return sock.recv(RECV_BUF_SIZE).decode("utf-8", "replace") + + def close(self): + # Let in-flight requests finish before closing the socket + with self._request_lock: + super().close() + + +class WpaCtrlMonitor(_WpaCtrlBase): + """Async event stream from wpa_supplicant (ATTACH/DETACH protocol).""" + + def open(self): + self._open_socket("wpa_mon") + self._sock.settimeout(10) + resp = self._raw_request("ATTACH") + if not resp.startswith("OK"): + self.close() + raise RuntimeError(f"ATTACH failed: {resp}") + + def _raw_request(self, cmd: str) -> str: + sock = self._ensure_sock() + sock.send(cmd.encode()) + return sock.recv(RECV_BUF_SIZE).decode("utf-8", "replace") + + def pending(self, timeout: float = 0) -> bool: + if self._sock is None: + return False + r, _, _ = select.select([self._sock], [], [], timeout) + return len(r) > 0 + + def recv(self, timeout: float = 1.0) -> str | None: + if self._sock is None: + return None + r, _, _ = select.select([self._sock], [], [], timeout) + if not r: + return None + data = self._sock.recv(RECV_BUF_SIZE).decode("utf-8", "replace") + # Strip priority prefix like <3> + if data.startswith("<") and ">" in data[:4]: + data = data[data.index(">") + 1:] + return data + + def close(self): + if self._sock is not None: + try: + self._raw_request("DETACH") + except (OSError, RuntimeError): + pass + super().close() + + +# Keep the public name while sharing one decoder with hardwared. +decode_ssid = decode_wpa_ssid + + +def parse_scan_results(raw: str) -> list[ScanResult]: + """Parse wpa_supplicant SCAN_RESULTS output (tab-separated, first line is header).""" + results = [] + # Preserve legal trailing spaces in the final SSID + lines = raw.splitlines() + if len(lines) < 2: + return results + for line in lines[1:]: + parts = line.split("\t") + if len(parts) < 4: + continue + try: + results.append(ScanResult( + bssid=parts[0], + freq=int(parts[1]), + signal=int(parts[2]), + flags=parts[3], + ssid=decode_ssid(parts[4]) if len(parts) > 4 else "", + )) + except (ValueError, IndexError): + continue + return results + + +def flags_to_security_type(flags: str) -> SecurityType: + """Convert wpa_supplicant flags string to SecurityType. + + Examples: [WPA2-PSK-CCMP][WPA-PSK-CCMP], [ESS], [WPA2-PSK-CCMP+TKIP] + """ + flags_upper = flags.upper() + flag_groups = re.findall(r"\[([^\]]+)\]", flags_upper) + + # WEP → unsupported + if "WEP" in flags_upper: + return SecurityType.UNSUPPORTED + + # Transitional PSK+SAE networks remain usable through WPA-PSK + if any(re.search(r"(?:^|\+)(?:(?:WPA2|RSN|WPA)-)?PSK(?!-SHA256)(?:[-+]|$)", group) for group in flag_groups): + return SecurityType.WPA + # Enterprise / 802.1X without a usable PSK suite → unsupported + if "EAP" in flags_upper or "802.1X" in flags_upper: + return SecurityType.UNSUPPORTED + # SAE-only is unsupported by the current AGNOS stack + if "SAE" in flags_upper: + return SecurityType.UNSUPPORTED + # Secured non-PSK modes must not fall through as open + if any(mode in flags_upper for mode in ("OWE", "DPP", "OSEN", "FILS")): # codespell:ignore fils + return SecurityType.UNSUPPORTED + + # No security flags → open + if "WPA" not in flags_upper and "RSN" not in flags_upper: + return SecurityType.OPEN + + return SecurityType.UNSUPPORTED + + +def parse_status(raw: str) -> dict[str, str]: + """Parse wpa_supplicant STATUS output (key=value lines). `ssid` is decoded.""" + result = {} + for line in raw.strip().split("\n"): + if "=" in line: + key, _, value = line.partition("=") + if key == "ssid": + value = decode_ssid(value) + result[key] = value + return result + + +def dbm_to_percent(dbm: int) -> int: + """Convert dBm to percentage [0, 100], matching NetworkManager's scale.""" + v = abs(max(-100, min(-40, dbm)) + 40) + return 100 - (100 * v) // 60 + + +TEMP_DISABLED_SSID_RE = re.compile(r'\bssid="((?:\\.|[^"])*)"') +EVENT_NETWORK_ID_RE = re.compile(r"\bid=(\d+)\b") +WPA_STOP_ATTEMPTS = 60 +WPA_STOP_POLL_INTERVAL_SECONDS = 0.05 + + +def normalize_ssid(ssid: str) -> str: + display_ssid = ssid.encode("utf-8", errors="surrogateescape").decode("utf-8", errors="replace") + return display_ssid.replace("\u2019", "'") # for iPhone hotspots + + +def parse_event_ssid(event: str) -> str | None: + """Extract ssid="…" from a wpa_supplicant control event (printf_encode'd), or None.""" + match = TEMP_DISABLED_SSID_RE.search(event) + if match is None: + return None + return decode_ssid(match.group(1)) + + +def parse_event_network_id(event: str) -> str | None: + """Extract a numeric network ID from a wpa_supplicant control event, or None.""" + match = EVENT_NETWORK_ID_RE.search(event) + return match.group(1) if match is not None else None + + +def _owned_wpa_pid(conf: str) -> int | None: + try: + pid = int(Path(WPA_PID_FILE).read_text().strip()) + if pid <= 1: + return None + args = [os.fsdecode(arg) for arg in Path(f"/proc/{pid}/cmdline").read_bytes().split(b"\0") if arg] + except (OSError, ValueError): + return None + + def flag_value(flag: str) -> str | None: + try: + return args[args.index(flag) + 1] + except (ValueError, IndexError): + return None + + if ( + not args + or os.path.basename(args[0]) != "wpa_supplicant" + or flag_value("-i") != "wlan0" + or flag_value("-c") != conf + or flag_value("-P") != WPA_PID_FILE + ): + return None + return pid + + +def wpa_supplicant_running(conf: str) -> bool: + return _owned_wpa_pid(conf) is not None + + +def _process_start_time(pid: int) -> str | None: + try: + stat = Path(f"/proc/{pid}/stat").read_text() + except OSError: + return None + end_comm = stat.rfind(")") + fields = stat[end_comm + 2:].split() if end_comm != -1 else [] + return fields[19] if len(fields) > 19 else None + + +def _wait_for_process_exit(pid: int, start_time: str) -> bool: + for _ in range(WPA_STOP_ATTEMPTS): + current_start_time = _process_start_time(pid) + if current_start_time is None: + return True + if current_start_time != start_time: + raise RuntimeError(f"wpa_supplicant PID {pid} was reused before teardown completed") + time.sleep(WPA_STOP_POLL_INTERVAL_SECONDS) + return False + + +def prepare_wpa_runtime() -> None: + subprocess.run(["sudo", "install", "-d", "-o", "root", "-g", "netdev", "-m", "775", WPA_CTRL_DIR], check=True) + subprocess.run(["sudo", "rm", "-f", WPA_PID_FILE, WPA_CTRL_PATH], check=False) + + +def stop_wpa_supplicant(conf: str) -> None: + pid = _owned_wpa_pid(conf) + if pid is None: + return + start_time = _process_start_time(pid) + if start_time is None: + if os.path.exists(f"/proc/{pid}"): + raise RuntimeError(f"failed to capture wpa_supplicant PID {pid} identity") + else: + subprocess.run(["sudo", "kill", "-TERM", "--", str(pid)], check=False) + if not _wait_for_process_exit(pid, start_time): + subprocess.run(["sudo", "kill", "-KILL", "--", str(pid)], check=False) + if not _wait_for_process_exit(pid, start_time): + raise RuntimeError(f"owned wpa_supplicant PID {pid} did not exit") + subprocess.run(["sudo", "rm", "-f", WPA_PID_FILE, WPA_CTRL_PATH], check=True) + + +def tethering_dnsmasq_running() -> bool: + return subprocess.run(["pgrep", "-f", TETHERING_DNSMASQ_PATTERN], capture_output=True).returncode == 0 + + +def stop_tethering_dnsmasq() -> None: + """Stop only the dnsmasq instance started for openpilot tethering.""" + subprocess.run(["sudo", "pkill", "-f", TETHERING_DNSMASQ_PATTERN], check=False) + + +def sanitize_for_conf(value: str) -> str: + """Escape characters that could break wpa_supplicant.conf quoting.""" + return value.replace('\\', '\\\\').replace('"', '\\"').replace('\n', '').replace('\r', '') + + +def format_ssid_value(ssid: str) -> str: + """Render an SSID as hexadecimal bytes for lossless wpa_supplicant parsing.""" + return ssid.encode("utf-8", errors="surrogateescape").hex() + + +def is_valid_ssid(ssid: str) -> bool: + try: + return 0 < len(ssid.encode("utf-8", errors="surrogateescape")) <= IEEE80211_MAX_SSID_BYTES + except UnicodeEncodeError: + return False + + +def _is_raw_psk(psk: str) -> bool: + """True if psk is a pre-hashed 64-hex WPA PSK. Quoted 64-char values fail as + too-long passphrases, so raw PSKs must be passed unquoted.""" + return len(psk) == 64 and all(c in "0123456789abcdefABCDEF" for c in psk) + + +def is_valid_psk(psk: str) -> bool: + try: + if any(unicodedata.category(char) == "Cc" for char in psk): + return False + return 8 <= len(psk.encode("utf-8")) <= 63 or _is_raw_psk(psk) + except UnicodeEncodeError: + return False + + +def format_psk_value(psk: str) -> str: + """Render a psk value for wpa_supplicant: raw 64-hex unquoted, else quoted.""" + if _is_raw_psk(psk): + return psk + return f'"{sanitize_for_conf(psk)}"' + + +def generate_wpa_conf(store, path: str = WPA_SUPPLICANT_CONF): + """Write wpa_supplicant.conf from a NetworkStore (STA networks only).""" + lines = [ + WPA_CTRL_INTERFACE, + "update_config=0", + "p2p_disabled=1", + "", + ] + + for ssid, entry in store.get_profiles(): + psk = entry.get("psk", "") + security = entry.get("security", SecurityType.WPA if psk else SecurityType.OPEN) + hidden = entry.get("hidden", False) + priority = entry.get("priority", 0) + bssid = entry.get("bssid", "") + ssid_value = format_ssid_value(ssid) + if not ssid_value: + continue + lines.append("network={") + lines.append(f" ssid={ssid_value}") + if security == SecurityType.WPA: + lines.append(f' psk={format_psk_value(psk)}') + lines.append(" key_mgmt=WPA-PSK") + else: + lines.append(" key_mgmt=NONE") + if hidden: + lines.append(" scan_ssid=1") + if bssid: + lines.append(f" bssid={bssid}") + if profile_uuid := entry.get("uuid"): + lines.append(f' id_str="{sanitize_for_conf(profile_uuid)}"') + lines.append(f" priority={priority}") + lines.append("}") + lines.append("") + + with atomic_write(path, overwrite=True) as f: + f.write("\n".join(lines)) + + +def try_attach_ctrl() -> WpaCtrl | None: + """Pure attach to a running wpa_supplicant ctrl socket. Never spawns, never kills.""" + try: + ctrl = WpaCtrl() + ctrl.open() + return ctrl + except OSError: + return None + + +def _unmanage_wlan0() -> bool: + """Tell NetworkManager to release wlan0 when it is present. + + An image without NetworkManager has no nmcli and wlan0 is already available, + so skip the compatibility handoff and continue bringup. + """ + nmcli = shutil.which("nmcli") + if nmcli is None: + cloudlog.info("nmcli not found; assuming NetworkManager is absent") + return True + result = subprocess.run(["sudo", nmcli, "dev", "set", "wlan0", "managed", "no"], capture_output=True) + cloudlog.info(f"nmcli dev set wlan0 managed no: rc={result.returncode}") + return result.returncode == 0 + + +def ensure_wpa_supplicant(should_exit: Callable[[], bool], station_reconfigured: Callable[[str], None] | None = None, + on_abandoned_ap: Callable[[], None] | None = None) -> WpaCtrl | None: + """Attach to a wpa_supplicant we own, or spawn one. Never attach to NM's daemon. + Returns the attached WpaCtrl, or None if ownership cannot be acquired.""" + # Wait for wlan0 without allowing teardown to mutate it afterward + while not os.path.exists("/sys/class/net/wlan0"): + if should_exit(): + return None + time.sleep(0.5) + + # Retry attaching to an adopted AP before tearing it down + if wpa_supplicant_running(WPA_AP_CONF): + for _ in range(3): + if should_exit(): + return None + ctrl = try_attach_ctrl() + if ctrl is not None: + return ctrl + time.sleep(0.5) + # Replace an owned AP whose control socket remains unreachable + cloudlog.warning("AP daemon present but ctrl attach failed; killing it so STA spawn can recover") + stop_wpa_supplicant(WPA_AP_CONF) + if on_abandoned_ap is not None: + try: + on_abandoned_ap() + except Exception: + cloudlog.exception("Failed to clean up abandoned AP services") + + # Reuse our station daemon without disturbing NetworkManager + if wpa_supplicant_running(WPA_SUPPLICANT_CONF): + if should_exit(): + return None + ctrl = try_attach_ctrl() + if ctrl is not None: + try: + status = parse_status(ctrl.request("STATUS")) + station_ssid = status.get("ssid") if status.get("wpa_state") == "COMPLETED" and status.get("mode") == "station" else None + response = ctrl.request("RECONFIGURE").strip() + if response.startswith("OK"): + if station_ssid is not None and station_reconfigured is not None: + station_reconfigured(station_ssid) + return ctrl + cloudlog.warning(f"Station configuration reconciliation failed: {response}") + except Exception: + cloudlog.exception("Failed to reconcile running station configuration") + ctrl.close() + + # Stop before mutating network state + if should_exit(): + return None + + if not _unmanage_wlan0(): + cloudlog.warning("NetworkManager handoff failed; deferring station bringup") + return None + + stop_wpa_supplicant(WPA_SUPPLICANT_CONF) + prepare_wpa_runtime() + stop_tethering_dnsmasq() + subprocess.run(["sudo", "ip", "addr", "flush", "dev", "wlan0"], check=False) + time.sleep(0.5) + + subprocess.run([ + "sudo", "wpa_supplicant", "-B", "-i", "wlan0", + "-c", WPA_SUPPLICANT_CONF, "-P", WPA_PID_FILE, "-D", "nl80211", + ], check=False) + + # Never attach to a daemon not using our config + for _ in range(30): + if should_exit(): + return None + if wpa_supplicant_running(WPA_SUPPLICANT_CONF): + ctrl = try_attach_ctrl() + if ctrl is not None: + try: + ctrl.request("ENABLE_NETWORK all") + except Exception: + pass + return ctrl + time.sleep(1) + cloudlog.error("wpa_supplicant did not start after 30 attempts") + return None diff --git a/openpilot/system/ui/mici_setup.py b/openpilot/system/ui/mici_setup.py index e4977ffdf430ca..63224b2131ed2d 100755 --- a/openpilot/system/ui/mici_setup.py +++ b/openpilot/system/ui/mici_setup.py @@ -296,7 +296,6 @@ def __init__(self, network_monitor: NetworkConnectivityMonitor, continue_callbac super().__init__() self._wifi_manager = WifiManager() - self._wifi_manager.set_active(True) self._network_monitor = network_monitor self._custom_software = False self._wifi_ui = WifiUIMici(self._wifi_manager) @@ -336,7 +335,6 @@ def on_waiting_click(): def show_event(self): super().show_event() - # make sure we populate strength and ip immediately if already have wifi self._wifi_manager.set_active(True) self._prev_has_internet = self._has_internet self._prev_wifi_connected = self._wifi_manager.wifi_state.status == ConnectStatus.CONNECTED @@ -347,6 +345,10 @@ def show_event(self): if self._prev_has_internet or self._prev_wifi_connected: self.set_shown_callback(lambda: self._scroll_to_end_and_grow()) + def hide_event(self): + super().hide_event() + self._wifi_manager.set_active(False) + @property def _has_internet(self) -> bool: network_changing = self._wifi_ui.any_network_forgetting or self._wifi_manager.wifi_state.status == ConnectStatus.CONNECTING diff --git a/openpilot/system/ui/tici_setup.py b/openpilot/system/ui/tici_setup.py index 36f47ccf1bba2b..9cfc32091ca843 100755 --- a/openpilot/system/ui/tici_setup.py +++ b/openpilot/system/ui/tici_setup.py @@ -61,7 +61,8 @@ def __init__(self): self.download_url = "" self.download_progress = 0 self.download_thread = None - self.wifi_ui = WifiManagerUI(WifiManager()) + self.wifi_manager = WifiManager() + self.wifi_ui = WifiManagerUI(self.wifi_manager) self.keyboard = Keyboard() self.selected_radio = None self.warning = gui_app.texture("icons/warning.png", 150, 150) @@ -151,43 +152,47 @@ def _render(self, rect: rl.Rectangle): elif self.state == SetupState.DOWNLOAD_FAILED: self.render_download_failed(rect) + def set_state(self, state: SetupState): + self.state = state + self.wifi_manager.set_active(state == SetupState.NETWORK_SETUP) + def _low_voltage_continue_button_callback(self): - self.state = SetupState.GETTING_STARTED + self.set_state(SetupState.GETTING_STARTED) def _custom_software_warning_back_button_callback(self): - self.state = SetupState.SOFTWARE_SELECTION + self.set_state(SetupState.SOFTWARE_SELECTION) def _custom_software_warning_continue_button_callback(self): - self.state = SetupState.NETWORK_SETUP + self.set_state(SetupState.NETWORK_SETUP) self.stop_network_check_thread.clear() self.start_network_check() def _getting_started_button_callback(self): - self.state = SetupState.SOFTWARE_SELECTION + self.set_state(SetupState.SOFTWARE_SELECTION) def _software_selection_back_button_callback(self): - self.state = SetupState.GETTING_STARTED + self.set_state(SetupState.GETTING_STARTED) def _software_selection_continue_button_callback(self): if self._software_selection_openpilot_button.selected: - self.state = SetupState.NETWORK_SETUP + self.set_state(SetupState.NETWORK_SETUP) self.stop_network_check_thread.clear() self.start_network_check() else: - self.state = SetupState.CUSTOM_SOFTWARE_WARNING + self.set_state(SetupState.CUSTOM_SOFTWARE_WARNING) def _download_failed_startover_button_callback(self): - self.state = SetupState.GETTING_STARTED + self.set_state(SetupState.GETTING_STARTED) def _network_setup_back_button_callback(self): - self.state = SetupState.SOFTWARE_SELECTION + self.set_state(SetupState.SOFTWARE_SELECTION) def _network_setup_continue_button_callback(self): self.stop_network_check_thread.set() if self._software_selection_openpilot_button.selected: self.download(OPENPILOT_URL) else: - self.state = SetupState.CUSTOM_SOFTWARE + self.set_state(SetupState.CUSTOM_SOFTWARE) def render_low_voltage(self, rect: rl.Rectangle): rl.draw_texture_ex(self.warning, rl.Vector2(rect.x + 150, rect.y + 110), 0.0, 1.0, rl.WHITE) @@ -336,7 +341,7 @@ def handle_keyboard_result(result): # Cancel pressed elif result == DialogResult.CANCEL: - self.state = SetupState.SOFTWARE_SELECTION + self.set_state(SetupState.SOFTWARE_SELECTION) self.keyboard.reset(min_text_size=1) self.keyboard.set_title("Enter URL", "for Custom Software") @@ -351,7 +356,7 @@ def download(self, url: str): parsed = urlparse(url, scheme='https') self.download_url = (urlparse(f"https://{url}") if not parsed.netloc else parsed).geturl() - self.state = SetupState.DOWNLOADING + self.set_state(SetupState.DOWNLOADING) self.download_thread = threading.Thread(target=self._download_thread, daemon=True) self.download_thread.start() @@ -415,7 +420,7 @@ def _download_thread(self): def download_failed(self, url: str, reason: str): self.failed_url = url self.failed_reason = reason - self.state = SetupState.DOWNLOAD_FAILED + self.set_state(SetupState.DOWNLOAD_FAILED) def main(): diff --git a/openpilot/system/ui/tici_updater.py b/openpilot/system/ui/tici_updater.py index 27cf6579a8b322..7847c454852d90 100755 --- a/openpilot/system/ui/tici_updater.py +++ b/openpilot/system/ui/tici_updater.py @@ -43,7 +43,8 @@ def __init__(self, updater_path, manifest_path): self.show_reboot_button = False self.process = None self.update_thread = None - self.wifi_manager_ui = WifiManagerUI(WifiManager()) + self.wifi_manager = WifiManager() + self.wifi_manager_ui = WifiManagerUI(self.wifi_manager) # Buttons self._wifi_button = Button("Connect to Wi-Fi", click_callback=lambda: self.set_current_screen(Screen.WIFI)) @@ -53,6 +54,7 @@ def __init__(self, updater_path, manifest_path): def set_current_screen(self, screen: Screen): self.current_screen = screen + self.wifi_manager.set_active(screen == Screen.WIFI) def install_update(self): self.set_current_screen(Screen.PROGRESS) diff --git a/openpilot/system/ui/widgets/network.py b/openpilot/system/ui/widgets/network.py index 4068d552b9bd22..15acaab66e8667 100644 --- a/openpilot/system/ui/widgets/network.py +++ b/openpilot/system/ui/widgets/network.py @@ -6,16 +6,16 @@ from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel -from openpilot.system.ui.lib.wifi_manager import WifiManager, SecurityType, Network, MeteredType, normalize_ssid +from openpilot.system.ui.lib.wifi_manager import WifiManager, SecurityType, Network, MeteredType +from openpilot.system.ui.lib.wpa_ctrl import normalize_ssid from openpilot.system.ui.widgets import DialogResult, Widget from openpilot.system.ui.widgets.button import ButtonStyle, Button -from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog +from openpilot.system.ui.widgets.confirm_dialog import alert_dialog, ConfirmDialog from openpilot.system.ui.widgets.keyboard import Keyboard from openpilot.system.ui.widgets.label import gui_label from openpilot.system.ui.widgets.scroller_tici import Scroller from openpilot.system.ui.widgets.list_view import ButtonAction, ListItem, MultipleButtonAction, ToggleAction, button_item, text_item -NM_DEVICE_STATE_NEED_AUTH = 60 MIN_PASSWORD_LENGTH = 8 MAX_PASSWORD_LENGTH = 64 ITEM_HEIGHT = 160 @@ -63,6 +63,7 @@ def __init__(self, wifi_manager: WifiManager): self._advanced_panel = self._child(AdvancedNetworkSettings(wifi_manager)) self._nav_button = self._child(NavButton(tr("Advanced"))) self._nav_button.set_click_callback(self._cycle_panel) + self._wifi_panel.set_panel_active(True) def show_event(self): super().show_event() @@ -91,6 +92,7 @@ def _render(self, _): def _set_current_panel(self, panel: PanelType): self._current_panel = panel + self._wifi_panel.set_panel_active(panel == PanelType.WIFI) class AdvancedNetworkSettings(Widget): @@ -101,7 +103,11 @@ def __init__(self, wifi_manager: WifiManager): from openpilot.selfdrive.ui.lib.prime_state import PrimeType super().__init__() self._wifi_manager = wifi_manager - self._wifi_manager.add_callbacks(networks_updated=self._on_network_updated) + self._wifi_manager.add_callbacks( + networks_updated=self._on_network_updated, + activated=lambda: self._on_tethering_finished(), + disconnected=lambda: self._on_tethering_finished(), + ) self._params = Params() self._prime_state = ui_state.prime_state self._cell_prime_types = (PrimeType.NONE, PrimeType.LITE) @@ -150,11 +156,13 @@ def __init__(self, wifi_manager: WifiManager): self._scroller = Scroller(items, line_separator=True, spacing=0) - def _on_network_updated(self, networks: list[Network]): + def _on_tethering_finished(self): self._tethering_action.set_enabled(True) self._tethering_action.set_state(self._wifi_manager.is_tethering_active()) self._tethering_password_action.set_enabled(True) + self._on_network_updated(self._wifi_manager.networks) + def _on_network_updated(self, networks: list[Network]): if self._wifi_manager.is_tethering_active() or self._wifi_manager.ipv4_address == "": self._wifi_metered_action.set_enabled(False) self._wifi_metered_action.selected_button = 0 @@ -215,10 +223,10 @@ def enter_password(result: DialogResult): password = self._keyboard.text if password == "": # connect without password - self._wifi_manager.connect_to_network(ssid, "", hidden=True) + self._wifi_manager.connect_to_network(ssid, "", hidden=True, security=SecurityType.OPEN) return - self._wifi_manager.connect_to_network(ssid, password, hidden=True) + self._wifi_manager.connect_to_network(ssid, password, hidden=True, security=SecurityType.WPA) self._keyboard.reset(min_text_size=0) self._keyboard.set_title(tr("Enter password"), tr("for \"{}\"").format(ssid)) @@ -237,7 +245,9 @@ def update_password(result: DialogResult): password = self._keyboard.text self._wifi_manager.set_tethering_password(password) - self._tethering_password_action.set_enabled(False) + # Debounce only while an active hotspot restarts + if self._wifi_manager.is_tethering_active(): + self._tethering_password_action.set_enabled(False) self._keyboard.reset(min_text_size=MIN_PASSWORD_LENGTH) self._keyboard.set_title(tr("Enter new tethering password"), "") @@ -264,6 +274,8 @@ def __init__(self, wifi_manager: WifiManager): super().__init__() self._wifi_manager = wifi_manager self.state: UIState = UIState.IDLE + self._page_shown = False + self._panel_active = True self._state_network: Network | None = None # for CONNECTING / NEEDS_AUTH / SHOW_FORGET_CONFIRM / FORGETTING self._password_retry: bool = False # for NEEDS_AUTH self.btn_width: int = 200 @@ -278,18 +290,23 @@ def __init__(self, wifi_manager: WifiManager): self._wifi_manager.add_callbacks(need_auth=self._on_need_auth, activated=self._on_activated, forgotten=self._on_forgotten, + forget_failed=self._on_forget_failed, networks_updated=self._on_network_updated, disconnected=self._on_disconnected) def show_event(self): super().show_event() - # start/stop scanning when widget is visible + self._page_shown = True self._wifi_manager.set_active(True) def hide_event(self): super().hide_event() + self._page_shown = False self._wifi_manager.set_active(False) + def set_panel_active(self, active: bool): + self._panel_active = active + def _load_icons(self): for icon in STRENGTH_ICONS + ["icons/checkmark.png", "icons/circled_slash.png", "icons/lock_closed.png"]: gui_app.texture(icon, ICON_SIZE, ICON_SIZE) @@ -389,6 +406,9 @@ def _draw_network_item(self, rect, network: Network): self._draw_signal_strength_icon(signal_icon_rect, network) def _networks_buttons_callback(self, network): + if self._wifi_manager.is_tethering_active(): + return + if not self._wifi_manager.is_connection_saved(network.ssid) and network.security_type != SecurityType.OPEN: self.state = UIState.NEEDS_AUTH self._state_network = network @@ -423,12 +443,15 @@ def _draw_signal_strength_icon(self, rect: rl.Rectangle, network: Network): rl.draw_texture_v(gui_app.texture(STRENGTH_ICONS[strength_level], ICON_SIZE, ICON_SIZE), rl.Vector2(rect.x, rect.y), rl.WHITE) def connect_to_network(self, network: Network, password=''): + if self._wifi_manager.is_tethering_active(): + return + self.state = UIState.CONNECTING self._state_network = network if self._wifi_manager.is_connection_saved(network.ssid) and not password: self._wifi_manager.activate_connection(network.ssid) else: - self._wifi_manager.connect_to_network(network.ssid, password) + self._wifi_manager.connect_to_network(network.ssid, password, security=network.security_type) def forget_network(self, network: Network): self.state = UIState.FORGETTING @@ -447,6 +470,8 @@ def _on_network_updated(self, networks: list[Network]): def _on_need_auth(self, ssid): network = next((n for n in self._networks if n.ssid == ssid), None) + if network is None and self._state_network is not None and self._state_network.ssid == ssid: + network = self._state_network if network: self.state = UIState.NEEDS_AUTH self._state_network = network @@ -460,6 +485,12 @@ def _on_forgotten(self, _): if self.state == UIState.FORGETTING: self.state = UIState.IDLE + def _on_forget_failed(self, _): + if self.state == UIState.FORGETTING: + self.state = UIState.IDLE + if self._page_shown and self._panel_active: + gui_app.push_widget(alert_dialog(tr("Failed to forget Wi-Fi network"))) + def _on_disconnected(self): if self.state == UIState.CONNECTING: self.state = UIState.IDLE diff --git a/pyproject.toml b/pyproject.toml index 78fb53ed1d7c25..c644d9cf134fdb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,6 @@ dependencies = [ "pyzmq", "sentry-sdk", "setproctitle", - "jeepney", "zstandard", # this can go once we're on Python 3.14+ # ui diff --git a/uv.lock b/uv.lock index b9af0d6adf9051..3ef5e466ff4430 100644 --- a/uv.lock +++ b/uv.lock @@ -398,15 +398,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d0/94/040a0d9c81f018c39bd887b7b825013b024deb0a6c795f9524797e2cd41b/inputs-0.5-py2.py3-none-any.whl", hash = "sha256:13f894564e52134cf1e3862b1811da034875eb1f2b62e6021e3776e9669a96ec", size = 33630, upload-time = "2018-10-05T22:38:28.28Z" }, ] -[[package]] -name = "jeepney" -version = "0.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, -] - [[package]] name = "kiwisolver" version = "1.5.0" @@ -602,7 +593,6 @@ dependencies = [ { name = "comma-deps-zeromq" }, { name = "comma-deps-zstd" }, { name = "inputs" }, - { name = "jeepney" }, { name = "numpy" }, { name = "pycapnp" }, { name = "pyjwt", extra = ["crypto"] }, @@ -663,7 +653,6 @@ requires-dist = [ { name = "comma-deps-zstd" }, { name = "coverage", marker = "extra == 'testing'" }, { name = "inputs" }, - { name = "jeepney" }, { name = "matplotlib", marker = "extra == 'tools'" }, { name = "msgq", marker = "extra == 'submodules'", editable = "msgq_repo" }, { name = "numpy", specifier = ">=2.0" },