mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-09 14:03:42 +08:00
8df85fa083
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.
282 lines
12 KiB
Python
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
|