Files
StarPilot/openpilot/starpilot/system/android_auto/network.py
T
Prabhaav Pillai 8df85fa083 Updated Android Auto page to clarify Wi-Fi usage and connection requirements.
Renamed button text in app.js for better user understanding.
Enhanced Galaxy page layout and messaging for remote access and installation instructions.
Improved hotspot settings interface with clearer instructions and error handling.
Streamlined local access messaging for better user guidance.
Adjusted navigation status handling to reflect accurate network states.
Updated tests to align with changes in network status reporting and command execution.
Refined system snapshot collection to ensure proper output handling and error reporting.
2026-10-10 01:29:42 -04:00

282 lines
12 KiB
Python

"""NetworkManager lease for the head unit's projection Wi-Fi.
The lease creates one volatile, never-default connection profile named
``starpilot-android-auto``, activates it on the Wi-Fi device, and on release
deactivates and deletes only that profile and reactivates whatever Wi-Fi
connection was active before -- unless the user switched networks meanwhile.
Credentials travel over D-Bus only; they never appear in argv or logs. The
cellular default route and DNS are left alone (``never-default``,
``ignore-auto-dns``) so the car's local network cannot take over Internet traffic.
"""
from __future__ import annotations
import threading
import time
import uuid
from collections.abc import Callable
from dataclasses import replace
from jeepney import DBusAddress, new_method_call
from jeepney.io.threading import DBusRouter, open_dbus_connection
from jeepney.low_level import MessageType
from jeepney.wrappers import Properties
from openpilot.starpilot.system.android_auto.bootstrap import WifiCredentials
NM = "org.freedesktop.NetworkManager"
NM_PATH = "/org/freedesktop/NetworkManager"
NM_IFACE = "org.freedesktop.NetworkManager"
NM_DEVICE_IFACE = "org.freedesktop.NetworkManager.Device"
NM_ACTIVE_IFACE = "org.freedesktop.NetworkManager.Connection.Active"
NM_SETTINGS_CONNECTION_IFACE = "org.freedesktop.NetworkManager.Settings.Connection"
NM_IP4_CONFIG_IFACE = "org.freedesktop.NetworkManager.IP4Config"
DEVICE_TYPE_WIFI = 2
ACTIVE_STATE_ACTIVATED = 2
ACTIVE_STATE_DEACTIVATED = 4
# The car sends its Wi-Fi details ~4 s after ignition, but a Honda's access point took ~55 s from ignition to appear
# (2026-09-30 drives). After a reported failed join that car stopped auto-launching projection until the driver tapped
# Android Auto, so wait for a slow access point rather than give up. The car's reported BSSID gets most of the time;
# the SSID-only retry covers head units that report the wrong radio.
JOIN_TIMEOUT_S = 120.0
BSSID_SHARE = 0.75
CONNECTION_ID = "starpilot-android-auto"
SECURITY_WPA3 = (32,)
class NetworkError(RuntimeError):
pass
def connection_settings(credentials: WifiCredentials, interface: str) -> dict:
wireless = {
"ssid": ("ay", credentials.ssid.encode("utf-8")),
"mode": ("s", "infrastructure"),
"hidden": ("b", True), # projection networks are often hidden; harmless when broadcast
}
if credentials.bssid:
wireless["bssid"] = ("ay", bytes.fromhex(credentials.bssid.replace(":", "")))
settings = {
"connection": {
"type": ("s", "802-11-wireless"),
"uuid": ("s", str(uuid.uuid4())),
"id": ("s", CONNECTION_ID),
"interface-name": ("s", interface),
"autoconnect": ("b", False),
"autoconnect-retries": ("i", 1),
},
"802-11-wireless": wireless,
"ipv4": {
"method": ("s", "auto"),
"never-default": ("b", True),
"ignore-auto-dns": ("b", True),
"route-metric": ("x", 3000),
},
"ipv6": {"method": ("s", "ignore")},
}
if not credentials.open:
settings["802-11-wireless-security"] = {
"key-mgmt": ("s", "sae" if credentials.security in SECURITY_WPA3 else "wpa-psk"),
"psk": ("s", credentials.key),
}
return settings
class NetworkLease:
def __init__(self, log: Callable[..., None], interface: str = "wlan0"):
self.log = log
self.interface = interface
self.router: DBusRouter | None = None
self.device_path = ""
self.connection_path = ""
self.active_path = ""
self.previous_connection = ""
self.local_ip = ""
self._lock = threading.Lock()
def _open(self) -> DBusRouter:
if self.router is None:
self.router = DBusRouter(open_dbus_connection(bus="SYSTEM"))
return self.router
def _call(self, path: str, interface: str, member: str, signature: str | None = None, body: tuple = (), timeout: float = 10.0):
address = DBusAddress(path, bus_name=NM, interface=interface)
message = new_method_call(address, member, signature, body) if signature else new_method_call(address, member)
reply = self._open().send_and_get_reply(message, timeout=timeout)
if reply.header.message_type == MessageType.error:
raise NetworkError(f"{member} failed: {reply.body[0] if reply.body else reply.header}")
return reply.body
def _get(self, path: str, interface: str, name: str):
address = DBusAddress(path, bus_name=NM, interface=interface)
reply = self._open().send_and_get_reply(Properties(address).get(name), timeout=5.0)
if reply.header.message_type == MessageType.error:
raise NetworkError(f"Cannot read {name}: {reply.body[0] if reply.body else ''}")
return reply.body[0][1]
def _wifi_device(self) -> str:
fallback = ""
for path in self._call(NM_PATH, NM_IFACE, "GetDevices")[0]:
if self._get(path, NM_DEVICE_IFACE, "DeviceType") != DEVICE_TYPE_WIFI:
continue
if self._get(path, NM_DEVICE_IFACE, "Interface") == self.interface:
return path
fallback = fallback or path
if not fallback:
raise NetworkError("No Wi-Fi device found")
self.interface = self._get(fallback, NM_DEVICE_IFACE, "Interface")
return fallback
def acquire(self, credentials: WifiCredentials, timeout: float = JOIN_TIMEOUT_S, cancelled: Callable[[], bool] = lambda: False) -> str:
"""Join the projection network and return the comma's IPv4 address on it.
The BSSID the car reports is tried first (fast, unambiguous); if that
attempt fails, one retry matches the SSID alone in case the reported BSSID
belongs to a different radio of the head unit.
"""
attempts = [credentials]
if credentials.bssid:
attempts.append(replace(credentials, bssid=""))
last_error: NetworkError | None = None
for index, attempt in enumerate(attempts):
try:
share = 1.0 if len(attempts) == 1 else (BSSID_SHARE if index == 0 else 1.0 - BSSID_SHARE)
return self._acquire_once(attempt, timeout * share, cancelled)
except NetworkError as error:
last_error = error
if cancelled() or index == len(attempts) - 1:
raise
self.log("wifi_retry_without_bssid", error=str(error))
self._drop_attempt()
raise last_error or NetworkError("Could not join the projection network")
def _drop_attempt(self) -> None:
for path, interface, member in ((self.active_path, NM_IFACE, "DeactivateConnection"),
(self.connection_path, NM_SETTINGS_CONNECTION_IFACE, "Delete")):
if not path:
continue
try:
if member == "DeactivateConnection":
self._call(NM_PATH, NM_IFACE, member, "o", (path,))
else:
self._call(path, interface, member)
except NetworkError:
pass
self.active_path = self.connection_path = ""
def _acquire_once(self, credentials: WifiCredentials, timeout: float, cancelled: Callable[[], bool]) -> str:
with self._lock:
if not self._get(NM_PATH, NM_IFACE, "WirelessEnabled"):
raise NetworkError("Wi-Fi is turned off on the comma")
self.device_path = self._wifi_device()
active = self._get(self.device_path, NM_DEVICE_IFACE, "ActiveConnection")
if active and active != "/" and not self.previous_connection:
try:
previous = self._get(active, NM_ACTIVE_IFACE, "Connection")
if self._connection_id(previous) != CONNECTION_ID:
self.previous_connection = previous
except NetworkError:
pass
self._delete_stale_profiles()
self.log("wifi_joining", interface=self.interface, **{k: v for k, v in credentials.describe().items() if k != "key_length"},
had_previous=bool(self.previous_connection))
body = self._call(NM_PATH, NM_IFACE, "AddAndActivateConnection2", "a{sa{sv}}ooa{sv}",
(connection_settings(credentials, self.interface), self.device_path, "/",
{"persist": ("s", "volatile")}), timeout=15.0)
self.connection_path, self.active_path = body[0], body[1]
deadline = time.monotonic() + timeout
while True:
if cancelled():
raise NetworkError("cancelled")
try:
state = self._get(self.active_path, NM_ACTIVE_IFACE, "State")
except NetworkError as error:
raise NetworkError("The projection network connection was removed (wrong key or out of range?)") from error
if state == ACTIVE_STATE_ACTIVATED:
break
if state == ACTIVE_STATE_DEACTIVATED:
raise NetworkError("NetworkManager could not join the projection network")
if time.monotonic() >= deadline:
raise NetworkError(f"Timed out joining the projection network after {timeout:.0f} s")
time.sleep(0.25)
self.local_ip = self._ipv4()
self.log("wifi_joined", interface=self.interface, local_ip=self.local_ip)
return self.local_ip
def _connection_id(self, path: str) -> str:
try:
return self._call(path, NM_SETTINGS_CONNECTION_IFACE, "GetSettings")[0].get("connection", {}).get("id", ("s", ""))[1]
except NetworkError:
return ""
def _ipv4(self) -> str:
config = self._get(self.active_path, NM_ACTIVE_IFACE, "Ip4Config")
if config and config != "/":
for entry in self._get(config, NM_IP4_CONFIG_IFACE, "AddressData"):
address = entry.get("address", ("s", ""))[1]
if address:
return address
raise NetworkError("Joined the projection network but received no IPv4 address")
def _delete_stale_profiles(self) -> None:
"""Remove profiles left by a crashed earlier session (volatile ones vanish on their own)."""
settings = DBusAddress("/org/freedesktop/NetworkManager/Settings", bus_name=NM, interface="org.freedesktop.NetworkManager.Settings")
reply = self._open().send_and_get_reply(new_method_call(settings, "ListConnections"), timeout=5.0)
if reply.header.message_type == MessageType.error:
return
for path in reply.body[0]:
try:
body = self._call(path, NM_SETTINGS_CONNECTION_IFACE, "GetSettings")
if body[0].get("connection", {}).get("id", ("s", ""))[1] == CONNECTION_ID:
self._call(path, NM_SETTINGS_CONNECTION_IFACE, "Delete")
self.log("wifi_stale_profile_removed")
except NetworkError:
continue
def still_connected(self) -> bool:
with self._lock:
if not self.active_path:
return False
return self._get(self.active_path, NM_ACTIVE_IFACE, "State") == ACTIVE_STATE_ACTIVATED
def release(self, restore: bool = True) -> None:
"""Undo only what this lease owns. Safe to call repeatedly.
``restore=False`` is for a retry: the earlier network is remembered and
reactivated only when the session finally stops, avoiding churn between attempts.
"""
with self._lock:
if self.router is None:
return
ours_active = False
try:
if self.device_path:
current = self._get(self.device_path, NM_DEVICE_IFACE, "ActiveConnection")
ours_active = current == self.active_path or current in ("", "/")
except NetworkError:
ours_active = True
if self.active_path:
try:
self._call(NM_PATH, NM_IFACE, "DeactivateConnection", "o", (self.active_path,))
except NetworkError:
pass
if self.connection_path:
try:
self._call(self.connection_path, NM_SETTINGS_CONNECTION_IFACE, "Delete")
except NetworkError:
pass # volatile profiles delete themselves on deactivation
if restore and ours_active and self.previous_connection and self.device_path:
try:
self._call(NM_PATH, NM_IFACE, "ActivateConnection", "ooo", (self.previous_connection, self.device_path, "/"))
self.log("wifi_restored_previous")
except NetworkError as error:
self.log("wifi_restore_failed", error=str(error))
self.connection_path = self.active_path = self.local_ip = ""
if restore:
self.previous_connection = ""
try:
self.router.close()
finally:
self.router = None