Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
71 commits
Select commit Hold shift + click to select a range
74bc4cc
wifi: add direct wpa_supplicant backend
andiradulescu Aug 1, 2026
53e2dd2
wifi: replace NetworkManager WiFi and tethering
andiradulescu Aug 1, 2026
e9299c4
wifi: test profile interoperability
andiradulescu Aug 1, 2026
f76390d
wifi: validate imported profile UUIDs
andiradulescu Aug 2, 2026
c8c372e
wifi: enforce imported connection constraints
andiradulescu Aug 2, 2026
5b3af2a
wifi: match runtime profile shadows by UUID
andiradulescu Aug 2, 2026
53a8c81
wifi: adopt automatic fallback connections
andiradulescu Aug 2, 2026
d703805
wifi: time adopted connection attempts
andiradulescu Aug 2, 2026
753e098
wifi: reject malformed BSSID constraints
andiradulescu Aug 2, 2026
de47498
hardwared: read canonical WiFi keyfiles
andiradulescu Aug 2, 2026
a5279e5
wifi: make multi-profile forget atomic
andiradulescu Aug 2, 2026
8f21918
ui: keep parent WiFi scanning active
andiradulescu Aug 2, 2026
f81ae0a
wifi: clear IPv6 state across fallback networks
andiradulescu Aug 2, 2026
d5e5fd4
wifi: reject oversized saved SSIDs
andiradulescu Aug 2, 2026
20fa28f
wifi: clean stale DHCP state on startup
andiradulescu Aug 2, 2026
5061a55
wifi: reject invalid interactive SSIDs
andiradulescu Aug 2, 2026
daf65e2
wifi: restore every saved profile on activation
andiradulescu Aug 2, 2026
cb9e520
hardwared: decode keyfile SSIDs for metering
andiradulescu Aug 2, 2026
d1d9911
wifi: restore keyfiles after aborted updates
andiradulescu Aug 2, 2026
3b471f7
wifi: preserve newer connections during reconciliation
andiradulescu Aug 2, 2026
31cf521
wifi: accept mixed-mode networks with PSK
andiradulescu Aug 2, 2026
19b3480
wifi: roll back multi-profile updates atomically
andiradulescu Aug 2, 2026
4d9a3f7
wifi: preserve shared netplan files
andiradulescu Aug 2, 2026
0b43b09
wifi: clear IPv6 after reconnect timeout
andiradulescu Aug 2, 2026
bf8aca2
wifi: report completed persistent forgets
andiradulescu Aug 2, 2026
f348091
wifi: resolve metering from active profile
andiradulescu Aug 2, 2026
d6c52f0
wifi: preserve station on cancelled tethering start
andiradulescu Aug 2, 2026
3a4b0a1
wifi: consolidate backend tests
andiradulescu Aug 10, 2026
17edd0c
wifi: restore imported profiles on update failure
andiradulescu Aug 10, 2026
ce82098
wifi: clear IPv6 after wrong-key recovery
andiradulescu Aug 10, 2026
28b62b1
wifi: trim implementation comments
andiradulescu Aug 10, 2026
322737a
wifi: make profile persistence transactional
andiradulescu Aug 18, 2026
844e6f7
wifi: make daemon and route ownership explicit
andiradulescu Aug 18, 2026
c27670d
wifi: make network transitions transactional
andiradulescu Aug 18, 2026
131dc30
wifi: serialize radio mode transitions
andiradulescu Aug 19, 2026
aa32a0a
wifi: require route-ready station activation
andiradulescu Aug 19, 2026
e5bac41
wifi: preserve tethering state across UI recovery
andiradulescu Aug 19, 2026
29df36e
wifi: commit forget transactions before cleanup
andiradulescu Aug 20, 2026
973f1d2
wifi: create missing tethering profile
andiradulescu Aug 20, 2026
c8bf140
wifi: honor ignored IPv6 profiles
andiradulescu Aug 20, 2026
626da7a
wifi: avoid redundant forwarding writes
andiradulescu Aug 20, 2026
c7516bf
wifi: defer hidden mici auth prompts
andiradulescu Aug 20, 2026
25f09da
wifi: retry failed forwarding policy writes
andiradulescu Aug 20, 2026
0d9e391
wifi: test fresh tether profile persistence
andiradulescu Aug 20, 2026
9121c18
wifi: reuse mici wrong-password state
andiradulescu Aug 20, 2026
3c69779
wifi: clear state after missed disconnect
andiradulescu Aug 21, 2026
f5c4df1
wifi: clear retained lease when forgetting
andiradulescu Aug 21, 2026
9d3fcb7
wifi: make association reconciliation idempotent
andiradulescu Aug 21, 2026
7390774
wifi: invalidate stale connection timeouts
andiradulescu Aug 22, 2026
04ace8b
wifi: preserve state when forget fails
andiradulescu Aug 22, 2026
cb19dd0
wifi: keep exhausted profiles disabled
andiradulescu Aug 22, 2026
782efbf
wifi: guard active info publication
andiradulescu Aug 22, 2026
3745070
wifi: track active profile identity
andiradulescu Aug 22, 2026
3f82e48
wifi: confirm terminal state before timeout
andiradulescu Aug 22, 2026
428b6cd
wifi: re-enable fallback after wrong key
andiradulescu Aug 22, 2026
01b49d1
wifi: make profile updates crash consistent
andiradulescu Aug 22, 2026
0af31ee
wifi: wait for DHCP teardown
andiradulescu Aug 22, 2026
154d4e2
wifi: normalize metering profile UUIDs
andiradulescu Aug 22, 2026
ba156c3
wifi: suppress hidden forget errors
andiradulescu Aug 22, 2026
03d9f2e
wifi: clear IPv6 state across SSIDs
andiradulescu Aug 22, 2026
73e343a
wifi: update all tethering profiles atomically
andiradulescu Aug 22, 2026
c65e090
ui: suppress off-panel Wi-Fi errors
andiradulescu Aug 22, 2026
c502f3d
wifi: accept scoped route output
andiradulescu Aug 22, 2026
1d39588
wifi: guard stale supplicant events
andiradulescu Aug 22, 2026
a90024c
wifi: confirm disconnect before teardown
andiradulescu Aug 22, 2026
a9698fe
wifi: restart L3 on profile changes
andiradulescu Aug 22, 2026
b1d58db
wifi: reject shared Netplan mutations
andiradulescu Aug 22, 2026
1eac756
wifi: wait for supplicant teardown
andiradulescu Aug 22, 2026
ba6647d
wifi: verify forget runtime cleanup
andiradulescu Aug 22, 2026
dcafb2b
wifi: prove netplan profile ownership
andiradulescu Aug 22, 2026
9f0356c
common: decode keyfile ssids losslessly
andiradulescu Aug 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 26 additions & 12 deletions openpilot/common/hardware/comma/hardware.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down
124 changes: 124 additions & 0 deletions openpilot/common/hardware/comma/tests/test_hardware.py
Original file line number Diff line number Diff line change
@@ -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")
47 changes: 47 additions & 0 deletions openpilot/common/nm_keyfile.py
Original file line number Diff line number Diff line change
@@ -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)
33 changes: 33 additions & 0 deletions openpilot/common/tests/test_nm_keyfile.py
Original file line number Diff line number Diff line change
@@ -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()
67 changes: 67 additions & 0 deletions openpilot/common/wifi.py
Original file line number Diff line number Diff line change
@@ -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")
1 change: 0 additions & 1 deletion openpilot/selfdrive/ui/layouts/settings/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading
Loading