mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-08 13:33:42 +08:00
284 lines
12 KiB
Python
284 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:
|
|
if not self.active_path:
|
|
return False
|
|
try:
|
|
return self._get(self.active_path, NM_ACTIVE_IFACE, "State") == ACTIVE_STATE_ACTIVATED
|
|
except NetworkError:
|
|
return False
|
|
|
|
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
|