Files
StarPilot/openpilot/starpilot/system/starport/car_interface.py
T

253 lines
8.8 KiB
Python

"""A second Wi-Fi station, aa0, for the car's projection network so wlan0 keeps home Wi-Fi.
The unprivileged daemon leases aa0 from a root worker over a pipe, like the Galaxy hotspot:
EOF, ten seconds without a heartbeat, or SIGTERM deletes the interface. The worker only ever
creates or deletes aa0; it never touches wlan0, p2p0 or galaxy0.
"""
from __future__ import annotations
import json
import os
import select
import subprocess
import sys
import threading
import time
from collections.abc import Callable
from pathlib import Path
from typing import Any
from openpilot.starpilot.system.starport.network import DedicatedUnavailable, JoinTimeout, NetworkError
from openpilot.starpilot.system.pipe_worker import claim, owned, spawn, stop_event
INTERFACE = "aa0"
ALIAS = "starpilot-aa-wifi"
RUNTIME = Path("/run/starpilot-aa-wifi")
HEARTBEAT_S = 2.0
LEASE_TIMEOUT_S = 10.0
READY_TIMEOUT_S = 5.0
# The car's network never appeared this many times on aa0; finish the session on wlan0 in case aa0 is the reason.
DEDICATED_TIMEOUTS = 2
class DedicatedInterface:
"""Holds aa0 while open."""
def __init__(self, log: Callable[..., None], popen=subprocess.Popen, ready_timeout: float = READY_TIMEOUT_S):
self.log, self.popen, self.ready_timeout = log, popen, ready_timeout
self.child = None
self._stop = threading.Event()
self._thread: threading.Thread | None = None
def open(self) -> str:
self.child = spawn("starpilot-aa-wifi", "openpilot.starpilot.system.starport.car_interface", popen=self.popen)
try:
if not select.select([self.child.stdout], [], [], self.ready_timeout)[0]:
raise DedicatedUnavailable(f"{INTERFACE} was not ready in time")
line = self.child.stdout.readline()
reply = json.loads(line) if line else {}
if not reply.get("ready"):
raise DedicatedUnavailable(reply.get("error") or f"Could not create {INTERFACE}")
except (OSError, ValueError) as error:
self.close()
raise DedicatedUnavailable(f"Could not create {INTERFACE}: {error}") from error
except BaseException:
self.close()
raise
self._thread = threading.Thread(target=self._heartbeat, args=(self.child,), name="aa_wifi_lease", daemon=True)
self._thread.start()
return INTERFACE
def _heartbeat(self, child) -> None:
while not self._stop.wait(HEARTBEAT_S):
try:
child.stdin.write(b"\n")
child.stdin.flush()
except (OSError, ValueError):
return
def close(self) -> None:
self._stop.set()
child, self.child = self.child, None
if child is None:
return
try:
child.stdin.close()
except OSError:
pass
try:
child.wait(timeout=12)
except subprocess.TimeoutExpired:
child.kill() # the worker still deletes aa0 once its lease runs out
if child.stdout is not None:
child.stdout.close()
class DedicatedLease:
"""The car's Wi-Fi on aa0, falling back to the legacy lease on wlan0 for a session that cannot use it."""
def __init__(self, log: Callable[..., None], fallback_interface: str, lease_factory, interface_factory=None,
enabled: bool = True):
self.log, self.fallback_interface = log, fallback_interface
self.lease_factory, self.interface_factory = lease_factory, interface_factory or DedicatedInterface
self.enabled = enabled
self.lease = None
self.iface = None
self.legacy = not enabled
self.timeouts = 0
self.aa0_joined = False
self.aa0_failed = False # aa0 could not join but wlan0 then did; the supervisor counts these per car
self._opening: threading.Thread | None = None
self._open_error: Exception | None = None
self._cancel = threading.Event()
@property
def local_ip(self):
return self.lease.local_ip if self.lease is not None else ""
@property
def interface(self) -> str:
return getattr(self.lease, "interface", self.fallback_interface if self.legacy else INTERFACE)
def prepare(self) -> None:
"""Create aa0 while Bluetooth connects, so the car's credentials do not wait on it."""
if self.legacy or self.lease is not None or self._opening is not None:
return
self._cancel.clear()
self._opening = threading.Thread(target=self._open, name="aa_wifi_open", daemon=True)
self._opening.start()
def _open(self) -> None:
try:
self.iface = self.interface_factory(self.log)
self.iface.open()
lease = self.lease_factory(self.log, INTERFACE, dedicated=True)
try:
lease.prepare_device(self._cancel.is_set)
except BaseException:
lease.release(restore=True)
raise
self.lease = lease
self.log("wifi_dedicated", interface=INTERFACE)
except Exception as error:
self._open_error = error
if self._cancel.is_set() and self.iface is not None:
self.iface.close() # the session already ended; nothing else will close it
def _finish_opening(self) -> None:
if self._opening is not None:
self._opening.join()
self._opening = None
error, self._open_error = self._open_error, None
if error is not None and not self._cancel.is_set():
self._fall_back(error)
def _ensure(self) -> None:
self.prepare()
self._finish_opening()
if self.lease is None:
self.legacy = True
self.lease = self.lease_factory(self.log, self.fallback_interface)
def _fall_back(self, error: Exception) -> None:
self.log("wifi_dedicated_unavailable", error=str(error) or type(error).__name__)
if self.lease is not None:
try:
self.lease.release(restore=True)
except Exception:
pass
if self.iface is not None:
self.iface.close()
self.lease = self.iface = None
self.legacy = True
def acquire(self, credentials, cancelled: Callable[[], bool] = lambda: False, **kwargs):
self._ensure()
try:
local_ip = self.lease.acquire(credentials, cancelled=cancelled, **kwargs)
except NetworkError as error:
if self.legacy or cancelled():
raise
if isinstance(error, JoinTimeout):
# The car's network never showed up, which wlan0 would not change; give aa0 another attempt first.
self.timeouts += 1
if self.timeouts >= DEDICATED_TIMEOUTS:
self._fall_back(error)
raise
# aa0 itself failed; finish this same join on wlan0 rather than report a failure to the car.
self._fall_back(error)
self._ensure()
local_ip = self.lease.acquire(credentials, cancelled=cancelled, **kwargs)
self.aa0_failed = not isinstance(error, DedicatedUnavailable)
return local_ip
if not self.legacy:
self.aa0_joined = True
self.timeouts = 0
return local_ip
def still_connected(self) -> bool:
return self.lease is not None and self.lease.still_connected()
def release(self, restore: bool = True) -> None:
if restore:
self._cancel.set()
self._finish_opening()
if self.lease is not None:
self.lease.release(restore=restore)
if restore:
if self.iface is not None:
self.iface.close()
self.lease = self.iface = None
self.legacy = not self.enabled
self.timeouts = 0
def serve(wireless: Callable[..., Any], stdin: int, stop: threading.Event, run=subprocess.run, is_owned=owned,
lease_timeout: float = LEASE_TIMEOUT_S) -> None:
"""Create aa0, report on stdout, and hold it until stdin closes, heartbeats stop or ``stop`` is set."""
created = False
try:
if is_owned(INTERFACE, ALIAS):
wireless("delete", INTERFACE) # left behind by a crashed worker
try:
if INTERFACE in wireless("interfaces"):
raise RuntimeError(f"{INTERFACE} exists and is not ours")
wireless("create", INTERFACE, iftype=2)
created = True
run(["ip", "link", "set", "dev", INTERFACE, "alias", ALIAS], check=True, capture_output=True, timeout=5)
except (OSError, RuntimeError, ValueError, KeyError, subprocess.SubprocessError) as error:
print(json.dumps({"ready": False, "error": str(error) or type(error).__name__}), flush=True)
return
print(json.dumps({"ready": True, "interface": INTERFACE}), flush=True)
last = time.monotonic()
while not stop.is_set() and time.monotonic() - last < lease_timeout:
if select.select([stdin], [], [], 0.5)[0]:
if not os.read(stdin, 64):
break
last = time.monotonic()
finally:
if created or is_owned(INTERFACE, ALIAS):
wireless("delete", INTERFACE)
def main() -> None:
from openpilot.starpilot.galaxy.hotspot_netlink import WirelessInterfaces
fd = claim(RUNTIME, "The car interface worker")
stop = stop_event()
def wireless(method: str, *args, **kwargs):
client = WirelessInterfaces()
try:
return getattr(client, method)(*args, **kwargs)
finally:
client.close()
try:
serve(wireless, sys.stdin.fileno(), stop)
finally:
os.close(fd)
if __name__ == "__main__":
main()