mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-09 22:13:43 +08:00
1713 lines
79 KiB
Python
1713 lines
79 KiB
Python
"""Android Auto session supervisor: owns every stage, deadline, retry and cleanup.
|
|
|
|
One session, started by the user or by auto-connect (see auto_connect.py), runs
|
|
in one worker thread:
|
|
|
|
idle -> connecting_bluetooth -> discovering -> rfcomm -> wifi_start -> wifi_info
|
|
-> joining_wifi -> connecting_tcp -> authenticating -> negotiating -> streaming
|
|
streaming <-> suspended (head unit showing its own screen)
|
|
failure -> cleanup -> backoff -> retry stop -> cleanup -> idle
|
|
|
|
Each attempt has a generation number; Stop cancels immediately (sockets are
|
|
closed from the caller's thread to unblock I/O). Between retries the projection
|
|
network is released without restoring the previous Wi-Fi, which happens once,
|
|
on Stop. Nothing here runs as root or touches vehicle control.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import errno
|
|
import ipaddress
|
|
import json
|
|
import math
|
|
import os
|
|
import queue
|
|
import socket
|
|
import threading
|
|
import time
|
|
import traceback
|
|
from collections import deque
|
|
from collections.abc import Callable
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from openpilot.common.hardware import COMMA_HARDWARE
|
|
from openpilot.starpilot.system.starport import car_profiles, device_health, identity as identity_store, system_slice
|
|
from openpilot.starpilot.system.starport.auto_connect import AutoConnectPolicy
|
|
from openpilot.starpilot.system.starport.bootstrap import NAMES, BootstrapError, WirelessBootstrap
|
|
from openpilot.starpilot.system.starport.bugsink_report import PairingAttempt
|
|
from openpilot.starpilot.system.starport.frame_source import (DEFAULT_PATH as DEFAULT_FRAME_PATH, FLAG_ASYNC_READBACK, FLAG_NV12,
|
|
FORMAT_NV12, FrameRequest)
|
|
from openpilot.starpilot.system.starport.projection_control import DEFAULT_CONTROL_SOCKET, NATIVE_FOCUS
|
|
from openpilot.starpilot.system.starport.touch import DEFAULT_TOUCH_SOCKET
|
|
from openpilot.starpilot.system.starport.view import CAR_FRAME_PATH, ViewSource, renderer_available
|
|
from openpilot.starpilot.system.starport.session import AuthenticationRejected, PeerRequestedStop, ProjectionSession
|
|
|
|
BACKOFF_SECONDS = (2.0, 4.0, 8.0, 15.0, 30.0)
|
|
STABLE_SESSION_SECONDS = 30.0
|
|
WIFI_RECOVER_RETRY_S = 30.0
|
|
DEDICATED_STRIKES = 2 # sessions where aa0 failed but wlan0 joined, before a car stays on wlan0
|
|
PEER_STOP_RETRY_SECONDS = 10.0
|
|
FRAME_MAX_AGE = 0.5 # never send a UI frame older than this
|
|
SOFTWARE_FPS = 15 # libx264 cadence; the hardware encoder runs at 30
|
|
UNAVAILABLE_AFTER = 1.0 # focused but no fresh UI frame for this long -> "unavailable" card; the Honda gives the screen back after 3 s without video
|
|
SDP_SETTLE = (1.5, 2.2, 3.0)
|
|
TCP_ATTEMPTS = 6
|
|
MAX_LOG_FILES = 20
|
|
LOG_QUEUE_MAX = 1000 # session log records waiting for the writer; past this they are dropped and counted
|
|
ENCODER_RECOVERIES = 3 # hardware encoder reopens allowed per window before the session is torn down
|
|
ENCODER_RECOVERY_WINDOW = 10.0
|
|
CAR_LINK_HOLD = 10.0 # a hands-free connection from the car counts as "car present" this long
|
|
HFP_FRESH = 3.0 # a hands-free link from the car this recent still means "the car is reaching out now"
|
|
NO_STARPORT_WAIT = 8.0 # a paired device must offer wireless projection within this long
|
|
USB_HANDSHAKE_WAIT = 10.0 # the car connected but sent no AOA START this long -> present as an accessory directly
|
|
USB_CONFIGURE_WAIT = 15.0 # slower receivers need time to enumerate after the AOA switch
|
|
LINK_CHECK_INTERVAL = 1.0 # how often the link watcher asks whether the car's Wi-Fi is still up
|
|
LINK_CHECK_SLOW = 1.0 # a link check slower than this is logged (NetworkManager answers over D-Bus)
|
|
STREAM_STALL_LOG = 1.0 # a streaming loop pass slower than this is logged
|
|
REPORTED_SETTINGS = ("connection", "usb_mode", "view", "encoder", "fps", "bitrate_kbps", "rate_control", "gpu_nv12",
|
|
"async_readback", "wifi_interface", "dedicated_wifi_interface", "verify_head_unit", "version_status",
|
|
"rfcomm_channel", "phone_class", "auto_connect")
|
|
|
|
|
|
def encoder_preference(configured: str) -> str:
|
|
return 'hardware' if COMMA_HARDWARE else configured
|
|
|
|
STATE_LABELS = {
|
|
"idle": "off", "connecting_bluetooth": "connecting to car", "discovering": "finding android auto",
|
|
"rfcomm": "starting wireless setup", "wifi_start": "waiting for car", "wifi_info": "getting car wi-fi",
|
|
"joining_wifi": "joining car wi-fi", "connecting_tcp": "connecting", "authenticating": "authenticating",
|
|
"negotiating": "negotiating video", "streaming": "projecting", "suspended": "car showing its own screen",
|
|
"backoff": "retrying", "stopping": "stopping", "error": "error",
|
|
"waiting_for_usb": "plug into the car's usb", "usb_accessory": "starting usb",
|
|
"waiting_for_ipad": "open headunitpad on the ipad", "waiting_for_tablet": "open open headunit on the tablet",
|
|
}
|
|
|
|
|
|
def _is_onroad() -> bool:
|
|
try:
|
|
from openpilot.common.params import Params
|
|
from openpilot.starpilot.saved_source import read_saved
|
|
raw, readable = read_saved(Params(), "IsOffroad", 1)
|
|
return readable and raw == b"0"
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def jetlink_blocker() -> str:
|
|
"""Why wired USB cannot connect right now: Jetlink's computer link is using the USB-C port. Wired can still be chosen."""
|
|
try:
|
|
from openpilot.common.params import Params
|
|
mode = Params().get("JetlinkMode")
|
|
if type(mode) is int and mode in (1, 2, 3):
|
|
return "Turn off Jetlink to use wired Android Auto"
|
|
except Exception:
|
|
pass
|
|
return ""
|
|
|
|
|
|
def chestnut_blocker() -> str:
|
|
"""Why wired USB cannot be chosen at all: a Chestnut is plugged into the comma's USB-C port."""
|
|
try:
|
|
from openpilot.starpilot.models.jetlink_adapter import adapter
|
|
if adapter().chestnut_present():
|
|
return "Wired Android Auto is unavailable while Chestnut is connected"
|
|
except Exception:
|
|
pass
|
|
return ""
|
|
|
|
|
|
def wired_blocker() -> str:
|
|
"""Why wired USB must not take the comma's USB-C port, or "" when it is free."""
|
|
return jetlink_blocker() or chestnut_blocker()
|
|
|
|
|
|
class Cancelled(Exception):
|
|
pass
|
|
|
|
|
|
class NoLease:
|
|
"""Wired sessions use no car Wi-Fi network."""
|
|
local_ip = None
|
|
|
|
def release(self, restore: bool = False) -> None:
|
|
pass
|
|
|
|
|
|
class UsbLease:
|
|
"""The link of a wired session: the USB cable, via the accessory bridge."""
|
|
local_ip = None
|
|
lost = "The car's USB connection ended"
|
|
|
|
def __init__(self, bridge):
|
|
self.bridge = bridge
|
|
|
|
def still_connected(self) -> bool:
|
|
return not self.bridge.closed.is_set()
|
|
|
|
|
|
def _wifi_dbm(interface: str = "wlan0") -> int | None:
|
|
"""Signal level of the joined network from /proc/net/wireless (a plain file read, never D-Bus)."""
|
|
try:
|
|
with open("/proc/net/wireless") as handle:
|
|
for line in handle:
|
|
name, _, rest = line.partition(":")
|
|
if name.strip() == interface:
|
|
return int(float(rest.split()[2].rstrip(".")))
|
|
except (OSError, ValueError, IndexError):
|
|
pass
|
|
return None
|
|
|
|
|
|
LOCAL_SOCKET_ERRORS = (errno.ENOSYS, errno.EAFNOSUPPORT, errno.EPROTONOSUPPORT)
|
|
|
|
|
|
def car_never_answered(error: BaseException) -> bool:
|
|
"""False when this device's own Bluetooth stack failed, so the car's cached channel says nothing about the car."""
|
|
return not (isinstance(error, OSError) and error.errno in LOCAL_SOCKET_ERRORS)
|
|
|
|
|
|
def _where(error: BaseException, depth: int = 4) -> list[str]:
|
|
"""The innermost frames of a failure, so a bare exception still says which call raised it."""
|
|
frames = traceback.extract_tb(error.__traceback__)[-depth:]
|
|
return [f"{os.path.basename(frame.filename)}:{frame.lineno} {frame.name}" for frame in frames]
|
|
|
|
|
|
class LinkWatch:
|
|
"""Checks the lease off the streaming thread.
|
|
|
|
NetworkManager can take seconds to answer over D-Bus (a 5 s timeout ended three Toyota sessions on
|
|
2026-10-08 before the car granted focus). A slow or failed check must neither stall video nor end the
|
|
session; only a definite "not connected" does. The socket itself still fails fast on a real loss.
|
|
"""
|
|
|
|
def __init__(self, lease, log: Callable[..., None], interval: float = LINK_CHECK_INTERVAL):
|
|
self.lease, self.log, self.interval = lease, log, interval
|
|
self.lost = ""
|
|
self.wifi_dbm: int | None = None
|
|
self._stop = threading.Event()
|
|
self._thread = threading.Thread(target=self._run, name="aa_link_watch", daemon=True)
|
|
|
|
def start(self) -> LinkWatch:
|
|
self._thread.start()
|
|
return self
|
|
|
|
def stop(self) -> None:
|
|
self._stop.set() # a check stuck in D-Bus finishes on its own timeout; nothing waits for it
|
|
|
|
def _run(self) -> None:
|
|
check = getattr(self.lease, "still_connected", None)
|
|
wireless = not isinstance(self.lease, (NoLease, UsbLease))
|
|
while not self._stop.is_set():
|
|
if wireless:
|
|
self.wifi_dbm = _wifi_dbm(getattr(self.lease, "interface", "wlan0"))
|
|
if check is not None:
|
|
started = time.monotonic()
|
|
try:
|
|
connected = check()
|
|
except Exception as error:
|
|
connected = True # unknown is not lost
|
|
self.log("link_check_failed", error=str(error) or type(error).__name__, kind=type(error).__name__,
|
|
ms=round((time.monotonic() - started) * 1000), where=_where(error))
|
|
else:
|
|
spent = time.monotonic() - started
|
|
if spent > LINK_CHECK_SLOW:
|
|
self.log("link_check_slow", ms=round(spent * 1000), connected=connected)
|
|
if not connected and not self._stop.is_set():
|
|
self.lost = getattr(self.lease, "lost", "Lost the car's Wi-Fi network")
|
|
self.log("link_lost", reason=self.lost, wifi_dbm=self.wifi_dbm)
|
|
return
|
|
self._stop.wait(self.interval)
|
|
|
|
|
|
class EventLog:
|
|
"""Sanitized JSONL session log under /data/starport/logs, plus the recent tail in memory.
|
|
|
|
The streaming loop logs too, so records are queued and a background thread writes them: a slow
|
|
or full disk never stalls projection. If the writer falls behind, records are dropped and the
|
|
next one that fits is preceded by a ``log_dropped`` count.
|
|
"""
|
|
|
|
def __init__(self, directory: Path | None = None):
|
|
self.directory = directory or identity_store.LOG_DIR
|
|
self.recent: deque[dict] = deque(maxlen=40)
|
|
self.lock = threading.Lock()
|
|
self._queue: queue.Queue = queue.Queue(maxsize=LOG_QUEUE_MAX)
|
|
self._writer: threading.Thread | None = None
|
|
self._writer_stop: threading.Event | None = None
|
|
self._dropped = 0
|
|
self.path: Path | None = None
|
|
self.started = 0.0
|
|
|
|
def open(self) -> None:
|
|
try:
|
|
self.directory.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
logs = sorted(self.directory.glob("session-*.jsonl"), key=identity_store.session_log_order)
|
|
for old in logs[:max(0, len(logs) - MAX_LOG_FILES + 1)]:
|
|
old.unlink(missing_ok=True)
|
|
system_slice.prune_sidecars(self.directory)
|
|
number = max([0, *(identity_store.session_log_order(log)[0] for log in logs)]) + 1
|
|
path = self.directory / f"session-{number:06d}-{identity_store.timestamp()}.jsonl"
|
|
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
|
handle = os.fdopen(fd, "a")
|
|
except OSError:
|
|
return
|
|
with self.lock:
|
|
self.path, self.started = path, datetime.now(UTC).timestamp()
|
|
self._queue = queue.Queue(maxsize=LOG_QUEUE_MAX)
|
|
self._dropped = 0
|
|
self._writer_stop = threading.Event()
|
|
self._writer = threading.Thread(target=self._write, args=(handle, self._queue, self._writer_stop),
|
|
name="aa_session_log", daemon=True)
|
|
self._writer.start()
|
|
|
|
@staticmethod
|
|
def _write(handle, records: queue.Queue, stopped: threading.Event) -> None:
|
|
with handle:
|
|
while not stopped.is_set() or not records.empty():
|
|
try:
|
|
line = records.get(timeout=0.1)
|
|
except queue.Empty:
|
|
continue
|
|
try:
|
|
handle.write(line)
|
|
if records.empty():
|
|
handle.flush()
|
|
except OSError:
|
|
pass
|
|
|
|
def close(self) -> None:
|
|
"""Write what is queued, then stop the writer (bounded: a stuck disk is left to the daemon thread)."""
|
|
with self.lock:
|
|
writer, stopped = self._writer, self._writer_stop
|
|
self._writer = self._writer_stop = None
|
|
if writer is None:
|
|
return
|
|
assert stopped is not None
|
|
stopped.set()
|
|
writer.join(timeout=2.0)
|
|
|
|
def capture_system(self) -> None:
|
|
"""Save what the device was doing during this session beside its log, off the session's thread."""
|
|
if self.path is not None:
|
|
system_slice.write_sidecar_async(self.path, self.started, datetime.now(UTC).timestamp())
|
|
|
|
def _line(self, record: dict) -> str:
|
|
return json.dumps(record, default=str) + "\n"
|
|
|
|
def __call__(self, name: str, **values) -> None:
|
|
record = {"t": datetime.now(UTC).isoformat(timespec="milliseconds"), "event": name, "mono_s": time.monotonic(), **values}
|
|
with self.lock:
|
|
self.recent.append(record)
|
|
if self._writer is None:
|
|
return
|
|
try:
|
|
if self._dropped:
|
|
self._queue.put_nowait(self._line({"t": record["t"], "event": "log_dropped", "count": self._dropped}))
|
|
self._dropped = 0
|
|
self._queue.put_nowait(self._line(record))
|
|
except queue.Full:
|
|
self._dropped += 1
|
|
|
|
|
|
class Supervisor:
|
|
_pairing_note = "" # why a paired car was not chosen, kept after the pairing window closes
|
|
_no_starport_since = 0.0
|
|
def __init__(self, bluez_factory=None, lease_factory=None, bluetooth_client=None, frame_path: str | None = None,
|
|
synthetic: bool = False, car_frame_path: str = CAR_FRAME_PATH, touch_path: str = DEFAULT_TOUCH_SOCKET,
|
|
control_path: str = DEFAULT_CONTROL_SOCKET, renderer_command: list[str] | None = None, onroad=_is_onroad,
|
|
shared_bluetooth_owner=None, source_session: tuple | None = None,
|
|
projection_enabled: Callable[[], bool] = lambda: True):
|
|
self._synthetic = synthetic
|
|
self._onroad = onroad
|
|
self._car_frame_path, self._touch_path, self._control_path = car_frame_path, touch_path, control_path
|
|
self._renderer_command = renderer_command
|
|
self._bluez_factory = bluez_factory
|
|
self._shared_bluetooth_owner = shared_bluetooth_owner
|
|
self._source_session = source_session
|
|
self._projection_enabled = projection_enabled
|
|
self._lease_factory = lease_factory
|
|
self._dedicated_strikes: dict[str, int] = {}
|
|
self._wifi_recover_at = 0.0
|
|
self._bt_client = bluetooth_client
|
|
self._frame_path = frame_path
|
|
self._lock = threading.RLock()
|
|
self._stop = threading.Event()
|
|
self._thread: threading.Thread | None = None
|
|
self._generation = 0
|
|
self._sockets: set[socket.socket] = set()
|
|
self._bluez: Any = None
|
|
self._pairing_until = 0.0
|
|
self._pairing_known: set[str] | None = None # Android Auto cars already paired when the pairing window opened
|
|
self._pairing_attempt: PairingAttempt | None = None
|
|
self._pairing_seen: tuple = ()
|
|
self._car_seen_at = -CAR_LINK_HOLD
|
|
self._hfp_link = threading.Event() # set when the chosen car opens the hands-free link
|
|
self.auto = AutoConnectPolicy()
|
|
self._companion_retry_at = 0.0
|
|
self.log = EventLog()
|
|
self.config = identity_store.load_config()
|
|
self._status: dict = {"state": "idle", "detail": "", "error": "", "last_stage": "", "attempt": 0,
|
|
"retry_in": 0.0, "mode": None, "stats": {}, "head_unit": {}, "identity": "", "running": False,
|
|
"view": "", "encoder": "", "target_fps": 0}
|
|
|
|
# ------------------------------------------------------------------ status
|
|
|
|
def _set(self, **values) -> None:
|
|
with self._lock:
|
|
self._status.update(values)
|
|
|
|
def _stage(self, state: str, detail: str = "") -> None:
|
|
self._check_cancel()
|
|
self._set(state=state, detail=detail, last_stage=state)
|
|
self.log("stage", state=state, detail=detail)
|
|
|
|
def _check_cancel(self) -> None:
|
|
if self._stop.is_set():
|
|
raise Cancelled()
|
|
|
|
def status(self) -> dict:
|
|
with self._lock:
|
|
status = dict(self._status)
|
|
status["label"] = STATE_LABELS.get(status["state"], status["state"])
|
|
status["receiver_address"] = self.config["receiver_address"]
|
|
status["receiver_name"] = self.config["receiver_name"]
|
|
status["companion_address"] = self.config.get("companion_address", "")
|
|
status["companion_name"] = self.config.get("companion_name", "")
|
|
status["pairing_note"] = self._pairing_note
|
|
status["car_type"] = self.car_type()
|
|
status.update(car_profiles.summary(self.config))
|
|
status["adapter_choice_needed"] = self._adapter_choice_needed()
|
|
status["configured_view"] = self.config["view"]
|
|
status["connection"] = self.config["connection"]
|
|
status["wired_blocked"] = chestnut_blocker()
|
|
status["wired_busy"] = jetlink_blocker()
|
|
status["pairing_ready"] = self._pairing_active()
|
|
status["auto_connect"] = bool(self.config["auto_connect"])
|
|
status["dedicated_wifi_interface"] = bool(self.config["dedicated_wifi_interface"])
|
|
status["auto_paused"] = self.auto.suppressed
|
|
status["recent"] = list(self.log.recent)[-8:]
|
|
return status
|
|
|
|
# ---------------------------------------------------------------- commands
|
|
|
|
def user_start(self) -> None:
|
|
self.auto.manual_start()
|
|
self.start()
|
|
|
|
def user_stop(self) -> None:
|
|
"""Stop pressed: also holds auto-connect off until the next drive."""
|
|
self.auto.manual_stop(self._session_alive())
|
|
self.stop()
|
|
|
|
def set_auto_connect(self, enabled: bool) -> None:
|
|
with self._lock:
|
|
self.config["auto_connect"] = bool(enabled)
|
|
identity_store.save_config(self.config)
|
|
self.log("auto_connect_selected", enabled=bool(enabled))
|
|
|
|
def set_dedicated_wifi(self, enabled: bool) -> None:
|
|
"""Join the car on aa0 so wlan0 keeps its network; used from the next session."""
|
|
with self._lock:
|
|
self.config["dedicated_wifi_interface"] = bool(enabled)
|
|
identity_store.save_config(self.config)
|
|
self.log("dedicated_wifi_selected", enabled=bool(enabled))
|
|
|
|
def _adapter_choice_needed(self) -> bool:
|
|
return self.car_type() == "adapter" and not self.config.get("car_profile_override")
|
|
|
|
def start(self, trigger: str = "manual") -> None:
|
|
with self._lock:
|
|
if not self._projection_enabled():
|
|
raise RuntimeError("Android Auto is disabled")
|
|
if self._adapter_choice_needed():
|
|
raise RuntimeError("Choose your adapter model (AAWireless or Other) in Galaxy first")
|
|
if self.config["connection"] == "wired" and (reason := wired_blocker()):
|
|
raise RuntimeError(reason)
|
|
if self._thread is not None and self._thread.is_alive():
|
|
if self._stop.is_set():
|
|
raise RuntimeError("Android Auto is still stopping; try again in a moment")
|
|
return
|
|
if self.config["connection"] == "wireless" and not self.config["receiver_address"]:
|
|
raise RuntimeError("Choose your car first")
|
|
identity = identity_store.load_identity() # fail fast with a clear message
|
|
self._stop.clear()
|
|
self._generation += 1
|
|
self._set(state="connecting_bluetooth", detail="", error="", attempt=0, retry_in=0.0, running=True,
|
|
identity=identity_store.expiry_warning(identity))
|
|
self._thread = threading.Thread(target=self._run, args=(self._generation, trigger), name="starport_session", daemon=True)
|
|
self._thread.start()
|
|
|
|
def stop(self, timeout: float = 8.0, graceful: float = 3.0) -> None:
|
|
"""Cancel the session: let a streaming session say goodbye to the car, then force it."""
|
|
thread = self._thread
|
|
self._stop.set()
|
|
if thread is not None:
|
|
thread.join(timeout=graceful)
|
|
self._close_sockets() # unblocks any stage still waiting on Bluetooth or the network
|
|
if thread is not None:
|
|
thread.join(timeout=max(0.0, timeout - graceful))
|
|
with self._lock:
|
|
if thread is not None and thread.is_alive():
|
|
return # still cleaning up; start() refuses until it finishes
|
|
self._thread = None
|
|
if self._status["state"] != "error":
|
|
self._set(state="idle", detail="")
|
|
self._set(running=False, retry_in=0.0)
|
|
|
|
def select_receiver(self, address: str, name: str = "") -> None:
|
|
from openpilot.starpilot.system.starport.bt_sockets import normalize_address
|
|
address = normalize_address(address)
|
|
device = self._phone().device(address)
|
|
if device is None or not device['paired'] or not device.get('starport'):
|
|
raise RuntimeError('Choose a paired Android Auto receiver')
|
|
with self._lock:
|
|
if self._thread is not None and self._thread.is_alive():
|
|
raise RuntimeError("Stop Android Auto before changing the car")
|
|
if self._bluez is not None and self.config['receiver_address'] and self.config['receiver_address'] != address:
|
|
self._bluez.release()
|
|
if self.config["receiver_address"] != address:
|
|
self.config.update(companion_address="", companion_name="", car_fingerprint={})
|
|
self.config.update(receiver_address=address, receiver_name=name or address)
|
|
identity_store.save_config(self.config)
|
|
self._unselect_car_audio(address)
|
|
try:
|
|
self._phone().acquire()
|
|
self._phone().set_trusted(address) # the car's own connections are accepted onroad, like a phone's
|
|
except Exception as error:
|
|
self.log("trust_failed", error=str(error))
|
|
|
|
def forget_receiver(self, address: str) -> bool:
|
|
"""A Bluetooth pairing was deleted: drop it as the chosen car or companion, and its RFCOMM channel,
|
|
so it leaves the car pickers and auto-connect stops paging it. True if it was the chosen car."""
|
|
from openpilot.starpilot.system.starport.bt_sockets import normalize_address
|
|
address = normalize_address(address)
|
|
chosen = self.config["receiver_address"].upper() == address
|
|
if chosen and self._session_alive():
|
|
self.stop()
|
|
with self._lock:
|
|
companion = self.config.get("companion_address", "").upper() == address
|
|
cache = dict(self.config["rfcomm_cache"])
|
|
cached = cache.pop(address, None) is not None
|
|
if not (chosen or companion or cached):
|
|
return False # nothing of this device was kept
|
|
self.config["rfcomm_cache"] = cache
|
|
if chosen:
|
|
if self._bluez is not None:
|
|
self._bluez.release()
|
|
self.config.update(receiver_address="", receiver_name="", companion_address="", companion_name="", car_fingerprint={})
|
|
elif companion:
|
|
self.config.update(companion_address="", companion_name="")
|
|
identity_store.save_config(self.config)
|
|
self.log("car_forgotten", address=address, receiver=chosen, companion=companion)
|
|
return chosen
|
|
|
|
def set_view(self, view: str) -> None:
|
|
"""Choose what the car shows; applies from the next projection session."""
|
|
if view not in ("car", "mirror"):
|
|
raise RuntimeError(f"Unknown view {view!r}")
|
|
with self._lock:
|
|
self.config["view"] = view
|
|
identity_store.save_config(self.config)
|
|
self.log("view_selected", view=view)
|
|
|
|
def set_connection(self, connection: str) -> None:
|
|
"""Choose wireless (Bluetooth + car Wi-Fi), wired (USB), or HeadunitPad or Open Headunit (on the Galaxy hotspot) projection."""
|
|
if connection not in identity_store.CONNECTIONS:
|
|
raise RuntimeError(f"Unknown connection {connection!r}")
|
|
if connection == "wired" and (reason := chestnut_blocker()):
|
|
raise RuntimeError(reason)
|
|
with self._lock:
|
|
if self._thread is not None and self._thread.is_alive():
|
|
raise RuntimeError("Stop Android Auto before changing the connection")
|
|
self.config["connection"] = connection
|
|
identity_store.save_config(self.config)
|
|
self.log("connection_selected", connection=connection)
|
|
|
|
def car_type(self) -> str:
|
|
"""What is in the car, for the setup page: wired and tablet follow the connection, wireless is built-in or an adapter."""
|
|
connection = self.config["connection"]
|
|
if connection == "wired":
|
|
return "usb"
|
|
if connection in ("headunitpad", "openheadunit"):
|
|
return "app"
|
|
return self.config["car_type"] or ("adapter" if self.config.get("companion_address") else "builtin")
|
|
|
|
def set_car_type(self, car_type: str) -> None:
|
|
if car_type not in ("builtin", "adapter", "usb", "app"):
|
|
raise RuntimeError(f"Unknown car type {car_type!r}")
|
|
previous_wireless_type = self.config["car_type"] or ("adapter" if self.config.get("companion_address") else "builtin")
|
|
connection = {"usb": "wired", "app": "headunitpad" if self.config["connection"] not in ("headunitpad", "openheadunit")
|
|
else self.config["connection"]}.get(car_type, "wireless")
|
|
if connection != self.config["connection"]:
|
|
self.set_connection(connection)
|
|
if car_type in ("builtin", "adapter"):
|
|
with self._lock:
|
|
self.config["car_type"] = car_type
|
|
if previous_wireless_type != car_type:
|
|
if self._bluez is not None and self.config["receiver_address"]:
|
|
self._bluez.release()
|
|
self.config.update(receiver_address="", receiver_name="", companion_address="", companion_name="", car_fingerprint={})
|
|
if car_type != "adapter":
|
|
self.config["car_profile_override"] = "" # the adapter model only applies to a wireless adapter
|
|
identity_store.save_config(self.config)
|
|
self.log("car_type_selected", car_type=car_type)
|
|
|
|
def set_car_profile(self, profile: str) -> None:
|
|
"""Pin a car profile ("default" for standard behavior) or go back to picking it by fingerprint ("")."""
|
|
if not car_profiles.valid_override(profile):
|
|
raise RuntimeError(f"Unknown car profile {profile!r}")
|
|
with self._lock:
|
|
self.config["car_profile_override"] = profile
|
|
identity_store.save_config(self.config)
|
|
self.log("car_profile_selected", profile=profile or "auto")
|
|
|
|
def _remember_fingerprint(self, head_unit: dict) -> None:
|
|
fingerprint = car_profiles.fingerprint_from(head_unit, self.config["receiver_address"])
|
|
if fingerprint and fingerprint != self.config.get("car_fingerprint"):
|
|
with self._lock:
|
|
self.config["car_fingerprint"] = fingerprint
|
|
identity_store.save_config(self.config)
|
|
|
|
def prepare_pairing(self, seconds: float = 180.0) -> None:
|
|
"""Present as a phone (HFP gateway, smartphone class) while the car pairs."""
|
|
if self._adapter_choice_needed():
|
|
raise RuntimeError("Choose your adapter model (AAWireless or Other) in Galaxy first")
|
|
if self._bluez_factory is None and (self._source_session is None or self._shared_bluetooth_owner is None or
|
|
not self._shared_bluetooth_owner.session_valid(self._source_session)):
|
|
raise RuntimeError('Current Galaxy pairing authorization required')
|
|
if self._session_alive():
|
|
raise RuntimeError('Stop projection before pairing another receiver')
|
|
bluez = self._phone()
|
|
known = {device["address"] for device in bluez.devices() if device["paired"] and device.get("starport")}
|
|
if self._bluez_factory is None:
|
|
bluez.release()
|
|
bluez.acquire_pairing()
|
|
elif self.config.get('phone_class', True):
|
|
bluez.acquire()
|
|
self._end_pairing("reopened")
|
|
with self._lock:
|
|
self._pairing_known = known
|
|
self._pairing_until = time.monotonic() + seconds
|
|
self._pairing_attempt, self._pairing_seen = PairingAttempt(seconds), ()
|
|
self._pairing_note, self._no_starport_since = "", 0.0
|
|
self.log("pairing_window", seconds=seconds)
|
|
|
|
def pair_device(self, address: str) -> None:
|
|
if self._source_session is None or self._shared_bluetooth_owner is None or not self._pairing_active():
|
|
raise RuntimeError('Current Galaxy pairing authorization required')
|
|
self._pairing_step("pair_requested")
|
|
self._shared_bluetooth_owner.pair_device(self._source_session, address)
|
|
|
|
def pairing_status(self) -> dict:
|
|
if self._source_session is None or self._shared_bluetooth_owner is None:
|
|
return {'active': False, 'receiver': None, 'prompt': None, 'approved': False}
|
|
status = self._shared_bluetooth_owner.incoming_status(self._source_session)
|
|
seen = (status.get('state'), bool(status.get('prompt')), bool(status.get('approved')), status.get('error') or '')
|
|
if seen != getattr(self, '_pairing_seen', ()):
|
|
self._pairing_seen = seen
|
|
self._pairing_step("status", state=seen[0], prompt=seen[1], approved=seen[2], **({'error': seen[3]} if seen[3] else {}))
|
|
return status
|
|
|
|
def _pairing_step(self, name: str, **values) -> None:
|
|
attempt = getattr(self, '_pairing_attempt', None)
|
|
if attempt is not None:
|
|
attempt.step(name, **values)
|
|
|
|
def _end_pairing(self, result: str, **values) -> None:
|
|
with self._lock:
|
|
attempt, self._pairing_attempt = getattr(self, '_pairing_attempt', None), None
|
|
if attempt is not None:
|
|
if result in ("interrupted", "cancelled", "expired") and attempt.reached_connection():
|
|
values["ended"], result = result, "paired_not_selected"
|
|
attempt.finish(result, **values)
|
|
|
|
def pairing_response(self, prompt_id: str, accepted: bool, value: str = '') -> None:
|
|
if not isinstance(prompt_id, str) or len(prompt_id) != 32 or type(accepted) is not bool or not isinstance(value, str):
|
|
raise ValueError('Invalid pairing response')
|
|
if self._source_session is None or self._shared_bluetooth_owner is None or not self._pairing_active() or \
|
|
not self._shared_bluetooth_owner.incoming_response(self._source_session, prompt_id, accepted, value):
|
|
raise RuntimeError('Pairing prompt expired or changed')
|
|
self._pairing_step("prompt_answered", accepted=accepted)
|
|
|
|
def cancel_pairing(self) -> None:
|
|
self._end_pairing("cancelled")
|
|
with self._lock:
|
|
self._pairing_until, self._pairing_known = 0.0, None
|
|
phone = self._bluez if not self._session_alive() else None
|
|
if phone is not None:
|
|
self._bluez = None
|
|
if phone is not None:
|
|
phone.close()
|
|
|
|
def bind_source_session(self, session: tuple) -> None:
|
|
"""Bind a Galaxy-minted, externally revalidated pairing source."""
|
|
if self._shared_bluetooth_owner is None or not self._shared_bluetooth_owner.session_valid(session):
|
|
raise RuntimeError('Galaxy Android Auto pairing source is unavailable')
|
|
with self._lock:
|
|
if self._source_session == session:
|
|
return
|
|
old_phone = self._bluez if not self._session_alive() else None
|
|
if old_phone is not None:
|
|
self._bluez = None
|
|
elif self._bluez is not None and hasattr(self._bluez, 'session'):
|
|
self._bluez.session = session
|
|
self._source_session = session
|
|
if old_phone is not None:
|
|
self._pairing_until, self._pairing_known = 0.0, None
|
|
if old_phone is not None:
|
|
old_phone.close()
|
|
|
|
def devices(self) -> list[dict]:
|
|
try:
|
|
devices = self._phone().devices()
|
|
except Exception as error:
|
|
raise RuntimeError(f"Bluetooth unavailable: {error}") from error
|
|
return [{key: device[key] for key in ("address", "name", "paired", "connected", "starport")}
|
|
for device in devices if device["paired"]]
|
|
|
|
def maintain(self, now: float | None = None) -> None:
|
|
"""Periodic housekeeping from the daemon thread: pairing window, auto-connect."""
|
|
now = time.monotonic() if now is None else now
|
|
self.recover_wifi(now)
|
|
owner = self._shared_bluetooth_owner
|
|
if not self._projection_enabled():
|
|
self._set(error='Android Auto was disabled')
|
|
self.stop(timeout=0.0, graceful=0.0)
|
|
if self._bluez is not None:
|
|
self._bluez.close()
|
|
self._bluez = None
|
|
return
|
|
if owner is not None:
|
|
try:
|
|
owner.maintain_phone_role()
|
|
except Exception:
|
|
self._set(error='Bluetooth receiver identity is unavailable')
|
|
self.stop(timeout=0.0, graceful=0.0)
|
|
if self._bluez is not None:
|
|
self._bluez.close()
|
|
self._bluez = None
|
|
return
|
|
lease = getattr(self._bluez, 'lease', None)
|
|
if lease is not None and lease.released:
|
|
if lease.session is None:
|
|
self._set(error='Selected Bluetooth receiver changed or was unpaired')
|
|
self.stop(timeout=0.0, graceful=0.0)
|
|
else:
|
|
self._pairing_until, self._pairing_known = 0.0, None
|
|
self._end_pairing("interrupted", reason="Bluetooth lease released")
|
|
self._bluez.close()
|
|
self._bluez = None
|
|
return
|
|
if self._source_session is not None and not owner.session_valid(self._source_session):
|
|
with self._lock:
|
|
old_phone = self._bluez if self._bluez is not None and \
|
|
getattr(getattr(self._bluez, 'lease', None), 'session', None) is not None else None
|
|
if old_phone is not None:
|
|
self._bluez = None
|
|
self._source_session = None
|
|
self._pairing_until, self._pairing_known = 0.0, None
|
|
self._end_pairing("interrupted", reason="Galaxy session ended")
|
|
if old_phone is not None:
|
|
old_phone.close()
|
|
if self._pairing_until:
|
|
if self._pairing_active():
|
|
self._select_new_car()
|
|
else:
|
|
with self._lock:
|
|
self._pairing_until, self._pairing_known = 0.0, None
|
|
self._end_pairing("expired")
|
|
if not self._session_alive():
|
|
self._release_phone()
|
|
self._auto_connect(now)
|
|
|
|
def _auto_connect(self, now: float) -> None:
|
|
config = self.config
|
|
if self._pairing_active():
|
|
return
|
|
address = config["receiver_address"]
|
|
if config["connection"] == "wired" and self._projection_enabled() and config["auto_connect"]:
|
|
if wired_blocker():
|
|
return
|
|
if self._bluez is not None and not self._session_alive():
|
|
self._release_phone() # wired needs no Bluetooth
|
|
# The drive is the only sign of the car: the session then waits for its USB handshake,
|
|
# whether the car was on before the comma booted or is switched on after.
|
|
onroad = self._onroad()
|
|
action = self.auto.decide(now, enabled=True, onroad=onroad, car_link=False, ready=True, running=self._session_alive())
|
|
self._apply_auto_action(now, action, onroad)
|
|
return
|
|
if not (self._projection_enabled() and config["auto_connect"] and config["connection"] == "wireless" and address):
|
|
if self._bluez is not None and not self._session_alive() and not self._pairing_active():
|
|
self._release_phone() # drop a standby gateway left from before auto-connect was turned off
|
|
return
|
|
running = self._session_alive()
|
|
try:
|
|
bluez = self._phone()
|
|
adapter_ready, device = bluez.snapshot(address)
|
|
if adapter_ready and device and device['paired']:
|
|
if self._bluez_factory is None:
|
|
bluez.acquire()
|
|
elif config.get('phone_class', True):
|
|
bluez.register_hfp()
|
|
except Exception:
|
|
adapter_ready, device = False, None # Bluetooth still starting (or restarting)
|
|
car_device = device
|
|
companion = config.get('companion_address', '')
|
|
if companion and adapter_ready and device and device['paired'] and self._shared_bluetooth_owner is not None:
|
|
try:
|
|
car_device = self._shared_bluetooth_owner.prepare_companion(companion, lambda: self.config.get('companion_address', ''))
|
|
if not running and not car_device['connected'] and now >= self._companion_retry_at:
|
|
self._companion_retry_at = now + 15.0
|
|
car_device = self._shared_bluetooth_owner.prepare_companion(
|
|
companion, lambda: self.config.get('companion_address', ''), connect=True)
|
|
except Exception as error:
|
|
car_device = None
|
|
self._set(error=f'Waiting for selected car: {error}'[:300])
|
|
car_link = bool(car_device and car_device["connected"]) or now - self._car_seen_at < CAR_LINK_HOLD
|
|
onroad = self._onroad()
|
|
action = self.auto.decide(now, enabled=True, onroad=onroad, car_link=car_link,
|
|
ready=adapter_ready and bool(device and device["paired"]) and (not companion or car_link), running=running)
|
|
self._apply_auto_action(now, action, onroad)
|
|
|
|
def _apply_auto_action(self, now: float, action: str | None, onroad: bool) -> None:
|
|
if action == "start":
|
|
trigger = "onroad" if onroad else "car_connected"
|
|
try:
|
|
self.start(trigger=trigger)
|
|
except Exception as error:
|
|
self.auto.start_refused(now)
|
|
self._set(error=f"auto-connect: {error}"[:300])
|
|
self.log("auto_connect_refused", error=str(error))
|
|
elif action == "stop":
|
|
self.log("auto_connect_stop", reason="car gone")
|
|
self.stop()
|
|
|
|
def _select_new_car(self) -> None:
|
|
"""A car that paired during the pairing window and offers Android Auto becomes the chosen car."""
|
|
if self._session_alive():
|
|
return
|
|
try:
|
|
devices = self._phone().devices()
|
|
except Exception:
|
|
return
|
|
approved = self.pairing_status()
|
|
receiver = approved['receiver'] if approved['active'] and approved['approved'] else None
|
|
with self._lock:
|
|
known = self._pairing_known
|
|
if known is None:
|
|
return
|
|
new = [device for device in devices if receiver is not None and device['address'] == receiver['address'] and
|
|
device["paired"] and device.get("starport") and (device["address"] not in known or self._shared_bluetooth_owner is not None and
|
|
self._shared_bluetooth_owner.phone_lease is not None and
|
|
self._shared_bluetooth_owner.phone_lease.receiver == device["address"])]
|
|
if not new:
|
|
self._note_missing_starport(devices, receiver)
|
|
return
|
|
with self._lock:
|
|
attempt, self._pairing_attempt = getattr(self, '_pairing_attempt', None), None
|
|
try:
|
|
self.cancel_pairing()
|
|
self.select_receiver(new[0]["address"], new[0]["name"])
|
|
self.log("car_selected_after_pairing", car=new[0]["name"])
|
|
if attempt is not None:
|
|
attempt.finish("paired")
|
|
except (RuntimeError, ValueError) as error:
|
|
self.log("car_select_failed", error=str(error))
|
|
if attempt is not None:
|
|
attempt.finish("select_failed", error=str(error))
|
|
|
|
def _note_missing_starport(self, devices: list, receiver: dict | None) -> None:
|
|
"""An approved, paired device that still does not offer wireless Android Auto after a short wait is not a car we can use."""
|
|
device = next((item for item in devices if receiver is not None and item["address"] == receiver["address"] and
|
|
item["paired"] and not item.get("starport")), None)
|
|
if device is None:
|
|
self._no_starport_since = 0.0
|
|
return
|
|
now = time.monotonic()
|
|
if not self._no_starport_since:
|
|
self._no_starport_since = now
|
|
elif now - self._no_starport_since >= NO_STARPORT_WAIT and not self._pairing_note:
|
|
name = device["name"] or "This device"
|
|
hint = "Using a wireless adapter? Choose Wireless adapter, pair the adapter here, then connect your car on the Bluetooth page."
|
|
self._pairing_note = f"{name} paired, but it doesn't offer wireless Android Auto. {hint}"
|
|
self._pairing_step("no_starport")
|
|
self.log("pairing_no_starport", address=device["address"])
|
|
|
|
def close(self) -> None:
|
|
self.stop()
|
|
if self._bluez is not None:
|
|
try:
|
|
self._bluez.close()
|
|
except Exception:
|
|
pass
|
|
self._bluez = None
|
|
|
|
# ----------------------------------------------------------------- helpers
|
|
|
|
def _phone(self):
|
|
with self._lock:
|
|
if self._bluez is None:
|
|
if self._bluez_factory is not None:
|
|
bluez = self._bluez_factory(self.log) # test injection only
|
|
elif self._shared_bluetooth_owner is not None:
|
|
from openpilot.starpilot.system.starport.bluetooth_bridge import LeaseAwarePhone
|
|
bluez = LeaseAwarePhone(self._shared_bluetooth_owner, self._source_session, self.log,
|
|
enabled=self._projection_enabled,
|
|
selected_receiver=lambda: self.config['receiver_address'])
|
|
else:
|
|
raise RuntimeError('Shared StarPilot Bluetooth owner is required for wireless Android Auto')
|
|
bluez.accepts = self._hfp_accepts
|
|
bluez.on_connection = self._hfp_connected
|
|
self._bluez = bluez
|
|
return self._bluez
|
|
|
|
def _pairing_active(self) -> bool:
|
|
return time.monotonic() < self._pairing_until
|
|
|
|
def _session_alive(self) -> bool:
|
|
thread = self._thread
|
|
return thread is not None and thread.is_alive()
|
|
|
|
def _hfp_accepts(self, address: str) -> bool:
|
|
receiver = self.config["receiver_address"]
|
|
if self._pairing_active() and self._source_session is not None and self._shared_bluetooth_owner is not None:
|
|
lease = self._shared_bluetooth_owner.phone_lease
|
|
return bool(lease is not None and not lease.released and
|
|
(lease.receiver == address.upper() or lease.incoming is not None and
|
|
lease.incoming.approved_address == address.upper()))
|
|
return bool(receiver) and (address.upper() == receiver.upper() or
|
|
self._shared_bluetooth_owner is not None and self._shared_bluetooth_owner.companion_accepts(address))
|
|
|
|
def phone_window(self, operation: str, session: tuple, **arguments):
|
|
owner = self._shared_bluetooth_owner
|
|
return None if owner is None else owner.phone_window(operation, session, **arguments)
|
|
|
|
def companion_power(self, enabled: bool, session: tuple):
|
|
owner = self._shared_bluetooth_owner
|
|
if owner is None:
|
|
return None
|
|
if not enabled and self._session_alive():
|
|
from openpilot.starpilot.bluetooth.owner import BluetoothRejected
|
|
raise BluetoothRejected('Stop Android Auto before turning Bluetooth off', code='projection_active')
|
|
# Same lock order as select_receiver: supervisor first, then the owner releases its lease.
|
|
with self._lock:
|
|
return owner.companion_power(enabled, session)
|
|
|
|
def companion_bluetooth_action(self, operation: str, address: str, session: tuple):
|
|
owner = self._shared_bluetooth_owner
|
|
if owner is None:
|
|
return None
|
|
if operation == 'forget' and self.config['receiver_address'].upper() == address.upper():
|
|
return self._forget_selected_car(owner, address, session)
|
|
# select_receiver takes the supervisor lock before releasing an owner lease.
|
|
# Keep that same order through connection, save and admission pruning so an
|
|
# old adapter's successful action cannot save its car under a new adapter.
|
|
with self._lock, owner.lock:
|
|
lease = owner.phone_lease
|
|
receiver = self.config['receiver_address']
|
|
result = owner.companion_action(operation, address, session)
|
|
if result is None:
|
|
return None
|
|
if (lease is None or owner.phone_lease is not lease or lease.released or lease.session is not None or
|
|
lease.receiver != receiver.upper() or self.config['receiver_address'] != receiver or
|
|
not owner.session_valid(session) or lease.enabled is None or not lease.enabled() or
|
|
lease.selected_receiver is None or lease.selected_receiver().upper() != lease.receiver):
|
|
raise RuntimeError('Android Auto receiver or pairing authorization changed')
|
|
if operation == 'connect':
|
|
device = self._phone().device(address)
|
|
if (device is None or not device['paired'] or not device['connected'] or
|
|
owner.phone_lease is not lease or lease.released or
|
|
not owner.session_valid(session) or not lease.enabled()):
|
|
raise RuntimeError('Selected car connection was not confirmed')
|
|
if '0000111e-0000-1000-8000-00805f9b34fb' not in device.get('uuids', []):
|
|
return result # An accessory must never replace the dongle's selected car.
|
|
self.config.update(companion_address=device['address'], companion_name=device['name'])
|
|
identity_store.save_config(self.config)
|
|
for previous in list(owner.companion_devices):
|
|
if previous != device['address']:
|
|
owner.companion_devices.pop(previous, None)
|
|
elif self.config.get('companion_address', '').upper() == address.upper():
|
|
self.config.update(companion_address='', companion_name='')
|
|
identity_store.save_config(self.config)
|
|
return result
|
|
|
|
def _forget_selected_car(self, owner, address: str, session: tuple):
|
|
"""Forget the chosen car even while auto-connect is retrying it: stop the attempt, then delete the pairing."""
|
|
from openpilot.starpilot.bluetooth.owner import BluetoothRejected
|
|
lease = owner.phone_lease
|
|
if not owner.session_valid(session):
|
|
raise BluetoothRejected('Galaxy session expired', code='session_expired')
|
|
if lease is not None and not lease.released and lease.session is not None:
|
|
raise BluetoothRejected('Finish Android Auto pairing first', code='busy')
|
|
self.auto.manual_stop(self._session_alive())
|
|
self.stop()
|
|
if self._session_alive():
|
|
raise BluetoothRejected('Android Auto is still stopping', code='busy')
|
|
result = owner.request('forget', address=address)
|
|
self.forget_receiver(address)
|
|
return result
|
|
|
|
def _hfp_connected(self, address: str) -> None:
|
|
if address.upper() in (self.config["receiver_address"].upper(), self.config.get("companion_address", "").upper()):
|
|
self._car_seen_at = time.monotonic()
|
|
self._hfp_link.set()
|
|
|
|
def _release_phone(self) -> None:
|
|
"""Stop looking like a phone; keep only the standby gateway auto-connect needs."""
|
|
bluez = self._bluez
|
|
if bluez is None:
|
|
return
|
|
config = self.config
|
|
standby = config["auto_connect"] and config["connection"] == "wireless" and bool(config["receiver_address"]) \
|
|
and self._projection_enabled()
|
|
if self._pairing_active():
|
|
return
|
|
if self._bluez_factory is not None:
|
|
if standby:
|
|
bluez.restore_class()
|
|
else:
|
|
bluez.release()
|
|
return
|
|
if not standby:
|
|
bluez.release()
|
|
elif getattr(bluez, 'lease', None) is None or bluez.lease.released:
|
|
bluez.acquire()
|
|
else:
|
|
bluez.restore_class()
|
|
|
|
def _remember_channel(self, address: str, channel: int | None) -> None:
|
|
with self._lock:
|
|
cache = dict(self.config["rfcomm_cache"])
|
|
if channel is None:
|
|
if cache.pop(address, None) is None:
|
|
return
|
|
elif cache.get(address) == channel:
|
|
return
|
|
else:
|
|
cache[address] = channel
|
|
self.config["rfcomm_cache"] = cache
|
|
identity_store.save_config(self.config)
|
|
|
|
def _lease(self):
|
|
factory = self._lease_factory
|
|
if factory is None:
|
|
from openpilot.starpilot.system.starport.network import NetworkLease
|
|
factory = NetworkLease
|
|
if self.config["dedicated_wifi_interface"]:
|
|
from openpilot.starpilot.system.starport.car_interface import DedicatedLease
|
|
strikes = self._dedicated_strikes.get(self.config["receiver_address"], 0)
|
|
if strikes >= DEDICATED_STRIKES:
|
|
self.log("wifi_dedicated_skipped", strikes=strikes)
|
|
lease = DedicatedLease(self.log, self.config["wifi_interface"], factory, enabled=strikes < DEDICATED_STRIKES)
|
|
lease.prepare()
|
|
return lease
|
|
return factory(self.log, self.config["wifi_interface"])
|
|
|
|
def _count_dedicated(self, lease) -> None:
|
|
"""A car whose network keeps failing on aa0 but joining on wlan0 stays on wlan0 until the daemon restarts."""
|
|
address = self.config["receiver_address"]
|
|
if getattr(lease, "aa0_joined", False):
|
|
self._dedicated_strikes.pop(address, None)
|
|
elif getattr(lease, "aa0_failed", False):
|
|
self._dedicated_strikes[address] = self._dedicated_strikes.get(address, 0) + 1
|
|
|
|
def _unselect_car_audio(self, address: str) -> None:
|
|
if self._bt_client is None:
|
|
return
|
|
try:
|
|
if self._bt_client.status().selected_audio.upper() == address.upper():
|
|
self._bt_client.select_audio("")
|
|
self.log("car_audio_unselected")
|
|
except Exception:
|
|
pass
|
|
|
|
def _track(self, sock: socket.socket) -> socket.socket:
|
|
with self._lock:
|
|
self._sockets.add(sock)
|
|
if self._stop.is_set():
|
|
self._close_sockets()
|
|
raise Cancelled()
|
|
return sock
|
|
|
|
def _close_sockets(self) -> None:
|
|
with self._lock:
|
|
sockets, self._sockets = self._sockets, set()
|
|
for sock in sockets:
|
|
try:
|
|
sock.shutdown(socket.SHUT_RDWR)
|
|
except OSError:
|
|
pass
|
|
try:
|
|
sock.close()
|
|
except OSError:
|
|
pass
|
|
|
|
def _wait(self, seconds: float) -> None:
|
|
if self._stop.wait(seconds):
|
|
raise Cancelled()
|
|
|
|
def _car_link_fresh(self) -> bool:
|
|
return time.monotonic() - self._car_seen_at < HFP_FRESH
|
|
|
|
def _wait_backoff(self, delay: float, car_can_wake: bool) -> None:
|
|
"""Sleep out a retry delay, but retry at once when the car opens hands-free to us.
|
|
|
|
That is the car reaching out (at startup, or when the driver taps Android Auto); it hangs
|
|
up again within a second, so waiting out a 30 s backoff would miss it.
|
|
"""
|
|
if not car_can_wake:
|
|
self._wait(delay)
|
|
return
|
|
if not self._car_link_fresh():
|
|
self._hfp_link.clear()
|
|
deadline = time.monotonic() + delay
|
|
while (remaining := deadline - time.monotonic()) > 0:
|
|
if self._hfp_link.is_set():
|
|
# One early retry per connection from the car: if that attempt also fails fast,
|
|
# the next backoff must not end at once just because the link is still fresh.
|
|
self._hfp_link.clear()
|
|
self.log("retry_early", reason="car opened hands-free", skipped_s=round(remaining, 1))
|
|
return
|
|
self._wait(min(0.1, remaining))
|
|
|
|
# ---------------------------------------------------------------- session
|
|
|
|
def _run(self, generation: int, trigger: str = "manual") -> None:
|
|
self.log.open()
|
|
connection = self.config["connection"]
|
|
wireless = connection == "wireless"
|
|
receiver = {"wired": "usb", "headunitpad": "headunitpad", "openheadunit": "openheadunit"}.get(connection, self.config["receiver_name"])
|
|
from openpilot.starpilot.system.starport.bugsink_transport import device_metadata
|
|
software = device_metadata()
|
|
self.log("session_start", receiver=receiver, generation=generation, trigger=trigger,
|
|
software={key: software.get(key, "unknown") for key in ("commit", "branch")},
|
|
settings={key: self.config.get(key) for key in REPORTED_SETTINGS})
|
|
lease = self._lease() if wireless else NoLease()
|
|
attempt = 0
|
|
try:
|
|
while not self._stop.is_set():
|
|
started = time.monotonic()
|
|
peer_stopped = False
|
|
self.log("attempt_start")
|
|
try:
|
|
self._attempt(lease)
|
|
self.log("session_ended")
|
|
except Cancelled:
|
|
break
|
|
except PeerRequestedStop as error:
|
|
self._set(error="", detail=str(error))
|
|
self.log("session_ended", reason=str(error))
|
|
peer_stopped = True
|
|
except Exception as error:
|
|
if self._stop.is_set():
|
|
break # I/O torn down by Stop; not a failure to report
|
|
stage = error.stage if isinstance(error, BootstrapError) else self._status["last_stage"]
|
|
message = self._describe_error(stage, error)
|
|
self._set(error=message)
|
|
self.log("attempt_failed", stage=stage, error=message, kind=type(error).__name__, where=_where(error))
|
|
finally:
|
|
self._close_sockets()
|
|
try:
|
|
lease.release(restore=False)
|
|
except Exception as error:
|
|
self.log("wifi_release_failed", error=str(error))
|
|
if self._stop.is_set():
|
|
break
|
|
attempt = 0 if time.monotonic() - started > STABLE_SESSION_SECONDS else attempt + 1
|
|
delay = BACKOFF_SECONDS[min(attempt, len(BACKOFF_SECONDS) - 1)]
|
|
if peer_stopped:
|
|
delay = max(delay, PEER_STOP_RETRY_SECONDS) # the car ended projection itself; do not bounce straight back
|
|
self._set(state="backoff", attempt=attempt, retry_in=delay, mode=None)
|
|
try:
|
|
self._wait_backoff(delay, car_can_wake=wireless and not peer_stopped)
|
|
except Cancelled:
|
|
break
|
|
finally:
|
|
self._set(state="stopping", detail="")
|
|
try:
|
|
lease.release(restore=True)
|
|
except Exception as error:
|
|
self.log("wifi_release_failed", error=str(error))
|
|
self._count_dedicated(lease)
|
|
self._wifi_recover_at = 0.0 # if the restore above failed, housekeeping retries it
|
|
try:
|
|
self._release_phone()
|
|
except Exception as error:
|
|
self.log("bluetooth_release_failed", error=str(error))
|
|
self.log("session_stop")
|
|
self.log.close()
|
|
self.log.capture_system()
|
|
self._set(state="idle", detail="", running=False, retry_in=0.0, mode=None)
|
|
|
|
@staticmethod
|
|
def _describe_error(stage: str, error: Exception) -> str:
|
|
if isinstance(error, AuthenticationRejected):
|
|
return "The car rejected the Android Auto identity; it may have expired"
|
|
text = str(error) or type(error).__name__
|
|
if stage == "authenticating" and "certificate" in text.lower():
|
|
text += " (set verify_head_unit false in config.json to test without verifying the car)"
|
|
return f"{STATE_LABELS.get(stage, stage)}: {text}"[:300]
|
|
|
|
def _attempt(self, lease) -> None:
|
|
if self.config["connection"] == "wired":
|
|
self._attempt_usb()
|
|
return
|
|
if self.config["connection"] in ("headunitpad", "openheadunit"):
|
|
self._attempt_headunitpad()
|
|
return
|
|
config = self.config
|
|
address = config["receiver_address"]
|
|
ident = identity_store.load_identity()
|
|
|
|
self._stage("connecting_bluetooth", config["receiver_name"])
|
|
bluez = self._phone()
|
|
bluez.acquire()
|
|
device = bluez.device(address)
|
|
if device is None or not device["paired"]:
|
|
raise RuntimeError("The car is not paired with this comma; pair it in Bluetooth settings")
|
|
companion = config.get('companion_address', '')
|
|
if companion:
|
|
if self._shared_bluetooth_owner is None:
|
|
raise RuntimeError('Shared Bluetooth owner required for the selected car')
|
|
self._shared_bluetooth_owner.prepare_companion(companion, lambda: self.config.get('companion_address', ''), connect=True)
|
|
self._check_cancel()
|
|
self.log('companion_car_connected', address=companion)
|
|
elif device.get('starport') and '0000111e-0000-1000-8000-00805f9b34fb' not in device.get('uuids', []):
|
|
raise RuntimeError('Connect your paired car in Galaxy Bluetooth first to select it for this Android Auto adapter')
|
|
if delay := car_profiles.tuning(config)["connect_delay_s"]:
|
|
self.log("car_profile_connect_delay", seconds=delay)
|
|
self._wait(delay)
|
|
bluez.connect_device(address)
|
|
self._wait(1.0)
|
|
|
|
from openpilot.starpilot.system.starport import bt_sockets
|
|
channel = int(config.get("rfcomm_channel") or 0)
|
|
source = "config"
|
|
if not channel:
|
|
channel, source = config["rfcomm_cache"].get(address, 0), "cache"
|
|
self._stage("discovering")
|
|
if not channel:
|
|
channel, source = self._discover_channel(address), "sdp"
|
|
self.log("rfcomm_channel", channel=channel, source=source)
|
|
|
|
try:
|
|
self._stage("rfcomm", f"channel {channel}")
|
|
rfcomm = self._track(bt_sockets.connect_rfcomm(address, channel))
|
|
boot = WirelessBootstrap(rfcomm, self._bootstrap_log, device_serial=config["device_name"],
|
|
version_status=int(config.get("version_status", 0)))
|
|
self._set(state="wifi_start")
|
|
result = boot.run(lambda credentials: lease.acquire(credentials, cancelled=boot.join_is_cancelled), cancelled=self._stop.is_set)
|
|
except Exception as error:
|
|
if source == "cache" and not self._stop.is_set() and self._status["last_stage"] in ("rfcomm", "wifi_start") and car_never_answered(error):
|
|
self._remember_channel(address, None) # the car never answered there; ask it over SDP next time
|
|
self.log("rfcomm_cache_dropped", channel=channel)
|
|
raise
|
|
if source == "sdp":
|
|
self._remember_channel(address, channel)
|
|
self._set(head_unit=result.head_unit)
|
|
self._remember_fingerprint(result.head_unit)
|
|
bluez.restore_class() # the car has accepted the comma; stop looking like a phone to everything else
|
|
keepalive_stop = threading.Event()
|
|
threading.Thread(target=boot.keepalive, args=(keepalive_stop,), name="aa_rfcomm_keepalive", daemon=True).start()
|
|
try:
|
|
self._project(result, lease, ident)
|
|
finally:
|
|
keepalive_stop.set()
|
|
|
|
def _discover_channel(self, address: str) -> int:
|
|
from openpilot.starpilot.system.starport import bt_sockets, sdp
|
|
last_error: Exception | None = None
|
|
for settle in SDP_SETTLE:
|
|
self._check_cancel()
|
|
try:
|
|
with self._track(bt_sockets.connect_l2cap(address, sdp.SDP_PSM)) as sdp_sock:
|
|
self._wait(settle)
|
|
return sdp.query_channel(sdp_sock)
|
|
except (OSError, sdp.SdpError) as error:
|
|
last_error = error
|
|
self.log("sdp_retry", error=str(error), settle=settle)
|
|
self._wait(0.35)
|
|
raise RuntimeError(f"Could not find the car's Android Auto service: {last_error}")
|
|
|
|
def _attempt_usb(self) -> None:
|
|
"""Wired: wait for the car's accessory handshake on USB (or skip it), then project over the cable."""
|
|
if reason := wired_blocker():
|
|
raise RuntimeError(reason)
|
|
from openpilot.starpilot.system.starport import usb_accessory as usb
|
|
ident = identity_store.load_identity()
|
|
mode = self.config["usb_mode"]
|
|
gadget = usb.AccessoryGadget(self.log)
|
|
listener = usb.UeventListener()
|
|
bridge = None
|
|
failed = False
|
|
try:
|
|
direct = mode == "direct"
|
|
gadget.prepare(direct=direct)
|
|
self._stage("waiting_for_usb")
|
|
if direct:
|
|
self._await_usb_configured(listener, None, gadget.connection_state)
|
|
else:
|
|
direct = self._await_accessory_start(listener, fallback=mode == "auto", state_reader=gadget.connection_state)
|
|
gadget.switch_to_accessory()
|
|
if not self._await_usb_configured(listener, USB_CONFIGURE_WAIT, gadget.connection_state):
|
|
raise RuntimeError("The car did not finish USB setup. Use its Android Auto data port and a data-capable cable")
|
|
method = "direct" if direct else "handshake"
|
|
self._stage("usb_accessory", method)
|
|
bridge = usb.AccessoryBridge(log=self.log)
|
|
self.log("usb_accessory_ready", method=method, strings=getattr(bridge, "strings", {}))
|
|
self._project(None, UsbLease(bridge), ident, connect=lambda: self._track(bridge.socket))
|
|
except BaseException:
|
|
failed = True
|
|
raise
|
|
finally:
|
|
self._release_usb(gadget, bridge, listener, failed)
|
|
|
|
def _attempt_headunitpad(self) -> None:
|
|
"""HeadunitPad or Open Headunit: the tablet, on the Galaxy hotspot or the same LAN, finds this port and connects like a head unit."""
|
|
ident = identity_store.load_identity()
|
|
headunitpad = self.config["connection"] == "headunitpad"
|
|
port = int(self.config["headunitpad_port"])
|
|
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
self._track(listener)
|
|
try:
|
|
listener.bind(("", port))
|
|
except OSError as error:
|
|
raise RuntimeError(f"Cannot listen on port {port} ({error})") from error
|
|
listener.listen(1)
|
|
listener.settimeout(1.0)
|
|
self._stage("waiting_for_ipad" if headunitpad else "waiting_for_tablet", f"port {port}")
|
|
sock = self._accept_headunitpad(listener)
|
|
listener.close()
|
|
self._project(None, NoLease(), ident, connect=lambda: sock, fragment_acks=headunitpad)
|
|
|
|
def _accept_headunitpad(self, listener: socket.socket) -> socket.socket:
|
|
while True:
|
|
self._check_cancel()
|
|
try:
|
|
sock, (address, _) = listener.accept()
|
|
except TimeoutError:
|
|
continue
|
|
if not ipaddress.ip_address(address).is_private:
|
|
self.log("headunitpad_rejected", address=address)
|
|
sock.close()
|
|
continue
|
|
sock.settimeout(None)
|
|
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
|
self.log("headunitpad_connected", address=address)
|
|
return self._track(sock)
|
|
|
|
def _release_usb(self, gadget, bridge, listener, failed: bool) -> None:
|
|
"""Undo a wired attempt, running every step even when one fails.
|
|
|
|
Closing an fd in another thread does not cancel a Linux blocking read, so the gadget
|
|
detaches first and the bridge workers are joined before the fd can be reused. A cleanup
|
|
error is logged, and raised only when the attempt itself ended cleanly: it must never
|
|
replace the real reason (a car-requested stop keeps its longer retry delay).
|
|
"""
|
|
errors = []
|
|
steps = [("detach", gadget.detach), ("bridge", bridge.close if bridge is not None else None),
|
|
("listener", listener.close), ("restore", gadget.restore)]
|
|
for name, step in steps:
|
|
if step is None:
|
|
continue
|
|
try:
|
|
step()
|
|
except Exception as error:
|
|
errors.append(error)
|
|
self.log("usb_cleanup_failed", step=name, error=str(error))
|
|
if errors and not failed:
|
|
raise errors[0]
|
|
|
|
def recover_wifi(self, now: float | None = None) -> None:
|
|
"""Turn wlan0 autoconnect back on if a session paused it and crashed. Retried while idle until it works."""
|
|
now = time.monotonic() if now is None else now
|
|
if now < self._wifi_recover_at or self._session_alive():
|
|
return
|
|
from openpilot.starpilot.system.starport.network import recover_autoconnect
|
|
try:
|
|
done = recover_autoconnect(self.log)
|
|
except Exception as error:
|
|
self.log("wifi_autoconnect_recover_failed", error=str(error))
|
|
done = False
|
|
self._wifi_recover_at = float("inf") if done else now + WIFI_RECOVER_RETRY_S
|
|
|
|
def recover_usb(self) -> None:
|
|
"""At startup: undo a wired session a crash interrupted, so the car stops seeing a dead accessory
|
|
and ADB comes back if it is on. Nothing happens unless our gadget was left holding the USB port."""
|
|
try:
|
|
from openpilot.starpilot.system.starport import usb_accessory as usb
|
|
usb.AccessoryGadget(self.log).recover()
|
|
except Exception as error:
|
|
self.log("usb_recover_failed", error=str(error))
|
|
|
|
def _usb_event(self, listener) -> dict | None:
|
|
self._check_cancel()
|
|
event = listener.next(0.5)
|
|
if event is not None and "USB_STATE" in event:
|
|
self.log("usb_state", state=event["USB_STATE"])
|
|
return event
|
|
|
|
def _await_accessory_start(self, listener, fallback: bool, state_reader=lambda: "") -> bool:
|
|
"""Wait for the car's AOA START; True when it never came and the comma should present as an accessory itself.
|
|
|
|
The wait starts when the car first connects, not when it configures the comma: a head unit
|
|
that reads the descriptors of a device it does not recognize may never configure it at all.
|
|
"""
|
|
connected_at = None
|
|
while True:
|
|
event = self._usb_event(listener)
|
|
if event is not None:
|
|
if event.get("ACCESSORY") == "START":
|
|
return False
|
|
state = event.get("USB_STATE")
|
|
if state in ("CONNECTED", "CONFIGURED"):
|
|
connected_at = connected_at or time.monotonic()
|
|
elif state == "DISCONNECTED":
|
|
connected_at = None
|
|
state = state_reader()
|
|
if state in ("CONNECTED", "CONFIGURED") and connected_at is None:
|
|
connected_at = time.monotonic()
|
|
elif state == "DISCONNECTED":
|
|
connected_at = None
|
|
if fallback and connected_at is not None and time.monotonic() - connected_at >= USB_HANDSHAKE_WAIT:
|
|
# Some head units only send the handshake to devices they recognize as phones.
|
|
self.log("usb_no_accessory_start", waited=USB_HANDSHAKE_WAIT)
|
|
return True
|
|
|
|
def _await_usb_configured(self, listener, timeout: float | None, state_reader=lambda: "") -> bool:
|
|
"""Wait until the car has configured the accessory, so reads do not fail on a link that is not up yet."""
|
|
started = time.monotonic()
|
|
while timeout is None or time.monotonic() - started < timeout:
|
|
self._check_cancel()
|
|
if state_reader() == "CONFIGURED":
|
|
return True # kernels may coalesce or omit the android_usb state uevent
|
|
event = self._usb_event(listener)
|
|
if event is not None and event.get("USB_STATE") == "CONFIGURED" and state_reader() in ("", "CONFIGURED"):
|
|
return True
|
|
self.log("usb_configure_timeout", waited=timeout)
|
|
return False
|
|
|
|
def _bootstrap_log(self, name: str, **values) -> None:
|
|
self.log(name, **values)
|
|
if name == "bootstrap_tx" and values.get("message") == NAMES[2]:
|
|
self._set(state="wifi_info", last_stage="wifi_info")
|
|
elif name == "wifi_joining":
|
|
self._set(state="joining_wifi", last_stage="joining_wifi", detail=str(values.get("ssid", "")))
|
|
|
|
def _connect_tcp(self, result, lease) -> socket.socket:
|
|
self._stage("connecting_tcp", f"{result.endpoint.ip}:{result.endpoint.port}")
|
|
last_error: Exception | None = None
|
|
for attempt in range(TCP_ATTEMPTS):
|
|
self._check_cancel()
|
|
try:
|
|
sock = socket.create_connection((result.endpoint.ip, result.endpoint.port), timeout=5.0,
|
|
source_address=(lease.local_ip, 0) if lease.local_ip else None)
|
|
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
|
return self._track(sock)
|
|
except OSError as error:
|
|
last_error = error
|
|
self.log("tcp_retry", attempt=attempt + 1, error=str(error))
|
|
self._wait(1.0 + attempt * 0.5)
|
|
raise RuntimeError(f"Car did not accept the projection connection: {last_error}")
|
|
|
|
def _adapter_address(self) -> str:
|
|
"""This comma's Bluetooth address for the head unit's pairing request; empty when BlueZ is not up."""
|
|
try:
|
|
return str(self._bluez.adapter()[1].get("Address", "")).upper() if self._bluez is not None else ""
|
|
except Exception: # DBus errors vary; the pairing request is optional
|
|
return ""
|
|
|
|
def _project(self, result, lease, ident, connect=None, fragment_acks: bool = False) -> None:
|
|
config = self.config
|
|
sock = connect() if connect is not None else self._connect_tcp(result, lease)
|
|
ca = ident.root if config.get("verify_head_unit", False) else None
|
|
session = ProjectionSession(sock, ident.cert, ident.key, self.log, ca)
|
|
session.fragment_acks = fragment_acks
|
|
tuning = car_profiles.tuning(config)
|
|
session.max_window = tuning["max_window"]
|
|
session.phone_frames = tuning["phone_frames"]
|
|
if tuning["phone_services"]:
|
|
session.phone_services = True
|
|
session.phone_address = self._adapter_address()
|
|
self._stage("authenticating")
|
|
session.authenticate()
|
|
self._stage("negotiating")
|
|
mode = session.start(config["device_name"], "comma.ai")
|
|
self._set(mode=mode.as_dict(), error="")
|
|
self.log("projection_ready", mode=mode.as_dict(), head_unit_subject=session.head_unit_subject)
|
|
try:
|
|
from openpilot.starpilot.system.starport.display_profile import record_screen
|
|
record_screen(mode.as_dict())
|
|
except (OSError, ValueError, TypeError, KeyError) as error:
|
|
self.log("screen_profile_error", error=str(error))
|
|
|
|
from openpilot.starpilot.system.starport.hw_encoder import create_encoder
|
|
# ANDROID AUTO VIDEO PERFORMANCE GUARDRAIL: Encoder cadence and pipeline flags are tuned together for Android Auto video.
|
|
# Altering this can degrade video smoothness or introduce lag on Android Auto.
|
|
# Do not change it autonomously; require an explicit user request specifically for this video optimization.
|
|
software_fps = min(SOFTWARE_FPS, config["fps"]) if config["fps"] else SOFTWARE_FPS
|
|
encoder, fps = create_encoder(mode.width, mode.height, preference=encoder_preference(config["encoder"]),
|
|
bitrate_kbps=config["bitrate_kbps"],
|
|
margin_height=mode.margin_height, software_fps=software_fps, log=self.log,
|
|
rate_control=config["rate_control"])
|
|
fps = min(fps, mode.fps, config["fps"] or fps)
|
|
interval = 1.0 / fps
|
|
flags = (FLAG_NV12 if config["gpu_nv12"] and getattr(encoder, "supports_nv12", False) else 0) | \
|
|
(FLAG_ASYNC_READBACK if config["async_readback"] else 0)
|
|
request = FrameRequest(mode.width, mode.height, mode.margin_width, mode.margin_height, int(interval * 1e6), flags)
|
|
self.log("frame_pipeline", nv12=bool(flags & FLAG_NV12), async_readback=bool(flags & FLAG_ASYNC_READBACK),
|
|
target_fps=fps, encoder=getattr(encoder, "backend", "libx264"))
|
|
view = config["view"]
|
|
if view == "car" and not self._synthetic and self._renderer_command is None and not renderer_available():
|
|
view = "unavailable"
|
|
source = ViewSource(view, request, self.log, synthetic=self._synthetic,
|
|
mirror_path=self._frame_path or DEFAULT_FRAME_PATH, car_path=self._car_frame_path,
|
|
touch_path=self._touch_path, control_path=self._control_path, renderer_command=self._renderer_command,
|
|
renderer_log=identity_store.LOG_DIR / "car_ui.log")
|
|
self._set(view=source.label, encoder=getattr(encoder, "backend", "libx264"), target_fps=fps)
|
|
watch = LinkWatch(lease, self.log).start()
|
|
try:
|
|
from openpilot.starpilot.system.starport.media_audio import MediaAudio, music_control
|
|
from openpilot.starpilot.system.starport.media_status import MediaStatus
|
|
session.media_control = music_control
|
|
session.audio = MediaAudio.create(session, self.log)
|
|
if session.audio is not None:
|
|
session.audio.track_info = MediaStatus.create(session, self.log)
|
|
except Exception as error: # music is optional; projection must never depend on it
|
|
self.log("media_audio_unavailable", error=str(error))
|
|
try:
|
|
from openpilot.starpilot.system.starport.nav_status import NavStatus
|
|
session.nav = NavStatus.create(session, self.log)
|
|
except Exception as error: # turn-by-turn is optional; projection must never depend on it
|
|
self.log("nav_status_unavailable", error=str(error))
|
|
try:
|
|
self._stream(session, encoder, source, watch, interval)
|
|
finally:
|
|
if session.audio is not None:
|
|
session.audio.close()
|
|
if getattr(session, "nav", None) is not None:
|
|
session.nav.close()
|
|
watch.stop()
|
|
source.close()
|
|
encoder.close()
|
|
if self._stop.is_set():
|
|
try:
|
|
session.shutdown()
|
|
except Exception:
|
|
pass
|
|
|
|
# ANDROID AUTO VIDEO PERFORMANCE GUARDRAIL: The bounded ACK/input drain and conditional waits keep video and touches responsive.
|
|
# Altering this can degrade video smoothness or introduce lag on Android Auto.
|
|
# Do not change it autonomously; require an explicit user request specifically for this video optimization.
|
|
def _stream(self, session: ProjectionSession, encoder, source: ViewSource, watch: LinkWatch, interval: float) -> None:
|
|
started = time.monotonic()
|
|
last_fresh = time.monotonic()
|
|
last_unavailable = 0.0
|
|
next_check = 0.0
|
|
ages: deque[float] = deque(maxlen=120)
|
|
sent_times: deque[float] = deque(maxlen=200)
|
|
unavailable: dict[str, bytes] = {}
|
|
recoveries: list[float] = [] # when the hardware encoder was reopened, this session
|
|
encode_peak = 0.0
|
|
wait_for_frame = False
|
|
audio = getattr(session, "audio", None)
|
|
nav = getattr(session, "nav", None)
|
|
self._stage("streaming")
|
|
while not self._stop.is_set():
|
|
pass_started = time.monotonic()
|
|
# Wait only when there was no frame or the receiver's window is full.
|
|
# select wakes immediately for touches/ACKs; sleeping after every encode
|
|
# used to add dead time even when the next frame was already available.
|
|
timeout = min(interval / 4, 0.01) if wait_for_frame else 0.0
|
|
handled = session.pump(timeout if session.can_send() else 0.05)
|
|
# Bound the drain so a burst of input cannot starve video, but do not
|
|
# make each queued touch or ACK wait for another encode/poll cycle.
|
|
for _ in range(15):
|
|
if not handled:
|
|
break
|
|
handled = session.pump(0.0)
|
|
session.check_progress()
|
|
if audio is not None:
|
|
try:
|
|
audio.service() # non-blocking: drains the Spotify pipe and sends within the car's audio credit
|
|
except (OSError, TimeoutError):
|
|
raise # the link itself failed; video would fail the same way
|
|
except Exception as error:
|
|
self.log("media_audio_failed", error=f"{type(error).__name__}: {error}")
|
|
audio.close()
|
|
audio = session.audio = None
|
|
if nav is not None:
|
|
try:
|
|
nav.service() # non-blocking: compares the route's next turn and sends at most a few small messages
|
|
except (OSError, TimeoutError):
|
|
raise
|
|
except Exception as error:
|
|
self.log("nav_status_failed", error=f"{type(error).__name__}: {error}")
|
|
nav.close()
|
|
nav = session.nav = None
|
|
now = time.monotonic()
|
|
wait_for_frame = True
|
|
if session.focused:
|
|
source.demand(1.0)
|
|
else:
|
|
source.release_demand()
|
|
if session.touch_events:
|
|
source.send_touches(list(session.touch_events))
|
|
session.touch_events.clear()
|
|
for action in source.drain_controls():
|
|
if action == NATIVE_FOCUS:
|
|
session.request_native()
|
|
state = "streaming" if session.focused else "suspended"
|
|
if self._status["state"] != state:
|
|
self._set(state=state)
|
|
if session.can_send():
|
|
frame = source.latest()
|
|
if frame is not None:
|
|
age = now - frame.captured_ns / 1e9
|
|
encode = encoder.encode_nv12 if frame.pixel_format == FORMAT_NV12 else encoder.encode_rgba
|
|
encoded = self._encode(encoder, encode, frame.data, session.needs_keyframe, recoveries) if age <= FRAME_MAX_AGE else None
|
|
if encoded is not None:
|
|
data, keyframe = encoded
|
|
encode_peak = max(encode_peak, encoder.last_encode_ms)
|
|
session.send_frame(data, frame.captured_ns // 1000, keyframe=keyframe)
|
|
if source.view == "car":
|
|
source.source.mark_sent(frame.captured_ns)
|
|
sent_at = time.monotonic()
|
|
ages.append(sent_at - frame.captured_ns / 1e9)
|
|
sent_times.append(sent_at)
|
|
last_fresh = sent_at
|
|
wait_for_frame = False
|
|
elif now - last_fresh > UNAVAILABLE_AFTER and now - last_unavailable > 1.0:
|
|
# The UI stopped producing frames (e.g. the offroad render budget ran
|
|
# out). Say so on the car instead of freezing on an old image.
|
|
text = "Starting StarPilot" if source.waiting_for_first_frame else "StarPilot display unavailable"
|
|
if text not in unavailable:
|
|
unavailable[text] = self._unavailable_frame(source.request, text)
|
|
encoded = self._encode(encoder, encoder.encode_rgba, unavailable[text], True, recoveries)
|
|
if encoded is not None:
|
|
session.send_frame(encoded[0], time.monotonic_ns() // 1000, keyframe=encoded[1])
|
|
last_unavailable = now
|
|
now = time.monotonic()
|
|
if now >= next_check:
|
|
next_check = now + 1.0
|
|
if watch.lost:
|
|
raise RuntimeError(watch.lost)
|
|
label = source.label
|
|
source.check(now, focused=session.focused)
|
|
if source.label != label:
|
|
self._set(view=source.label)
|
|
window = [t for t in sent_times if now - t <= 5.0]
|
|
ordered = sorted(ages)
|
|
audio_stats = {"media_audio": audio.status()} if audio is not None else {}
|
|
stats = {**session.stats(), **audio_stats, "fps": round(len(window) / 5.0, 1), "encode_ms": round(encoder.last_encode_ms, 1),
|
|
"encode_peak_ms": round(encode_peak, 1), "encoder_recoveries": len(recoveries),
|
|
"frame_age_p95_ms": round(ordered[int(len(ordered) * 0.95) - 1] * 1000) if ordered else None,
|
|
"uptime_s": round(now - started), "frames_from_view": source.frames, "wifi_dbm": watch.wifi_dbm}
|
|
self._set(stats=stats)
|
|
if int(now - started) % 30 == 0:
|
|
self.log("stats", **{**device_health.sample(getattr(watch.lease, "interface", "wlan0")), **stats})
|
|
encode_peak = 0.0 # the peak covers each logged 30 s window
|
|
spent = time.monotonic() - pass_started
|
|
if spent > STREAM_STALL_LOG:
|
|
# Nothing was read from the car meanwhile: pings, focus and ACKs all waited on this pass.
|
|
self.log("stream_stall", ms=round(spent * 1000), focused=session.focused, pending=session.unacked)
|
|
|
|
def _encode(self, encoder, encode, data: bytes, keyframe: bool, recoveries: list[float]) -> tuple[bytes, bool] | None:
|
|
"""Encode one frame. When the hardware encoder fails (the shared VPU stalled past the
|
|
deadline), reopen it and drop this frame instead of tearing down the whole session:
|
|
the next frame is an IDR, and the car waits 3 s for video before taking its screen back.
|
|
Repeated failures still end the attempt, which reconnects from scratch."""
|
|
started = time.monotonic()
|
|
try:
|
|
return encode(data, keyframe=keyframe)
|
|
except Exception as error:
|
|
failed = time.monotonic()
|
|
recent = sum(1 for at in recoveries if failed - at <= ENCODER_RECOVERY_WINDOW)
|
|
reopen = getattr(encoder, "reopen", None) # libx264 has nothing to reopen
|
|
if reopen is None or recent >= ENCODER_RECOVERIES:
|
|
self.log("encoder_failed", error=str(error), encode_ms=round((failed - started) * 1000), recent_recoveries=recent)
|
|
raise
|
|
try:
|
|
reopen()
|
|
except Exception as reopen_error:
|
|
self.log("encoder_reopen_failed", error=str(error), reopen_error=str(reopen_error))
|
|
raise error from reopen_error
|
|
recoveries.append(failed)
|
|
self.log("encoder_recovered", error=str(error), encode_ms=round((failed - started) * 1000),
|
|
reopen_ms=round((time.monotonic() - failed) * 1000), recent_recoveries=recent + 1, total=len(recoveries))
|
|
return None
|
|
|
|
@staticmethod
|
|
def _unavailable_frame(request: FrameRequest | None, text: str) -> bytes:
|
|
assert request is not None
|
|
try:
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
from openpilot.common.basedir import BASEDIR
|
|
|
|
base = Path(BASEDIR) / "openpilot"
|
|
logo_path = base / "selfdrive" / "assets" / "images" / "starpilot_logo.png"
|
|
brand_font_path = base / "starpilot" / "ui" / "assets" / "fonts" / "Sora[wght].ttf" # the logo wordmark's font
|
|
fallback_font_path = base / "selfdrive" / "assets" / "fonts" / "Inter-Black.ttf" # if Sora cannot be set to weight 800
|
|
|
|
bg_color = (10, 10, 22, 255) # Cosmic void #0a0a16
|
|
canvas = Image.new("RGBA", (request.width, request.height), bg_color)
|
|
|
|
# Text is drawn at 4x and shrunk: at car-screen sizes Sora's round letters (S, a, o) dip about half a
|
|
# pixel below the baseline, which renders as a 1 px step and makes the bottoms look crooked.
|
|
supersample = 4
|
|
font_size = max(18, min(64, int(request.height * 0.06)))
|
|
try:
|
|
font = ImageFont.truetype(str(brand_font_path), font_size * supersample)
|
|
font.set_variation_by_axes([800])
|
|
except Exception:
|
|
font = None
|
|
try:
|
|
font = font or ImageFont.truetype(str(fallback_font_path), font_size * supersample)
|
|
except Exception:
|
|
font, supersample = ImageFont.load_default(), 1
|
|
|
|
bbox = font.getbbox(text)
|
|
text_w = math.ceil((bbox[2] - bbox[0]) / supersample)
|
|
text_h = math.ceil((bbox[3] - bbox[1]) / supersample)
|
|
|
|
gap = max(12, int(request.height * 0.04))
|
|
target_logo_h = int(min(request.height * 0.42, request.width * 0.35))
|
|
|
|
if logo_path.exists() and target_logo_h > 20:
|
|
logo = Image.open(logo_path).convert("RGBA")
|
|
logo_w = int(logo.width * (target_logo_h / logo.height))
|
|
logo_resized = logo.resize((logo_w, target_logo_h), Image.Resampling.LANCZOS)
|
|
total_h = target_logo_h + gap + text_h
|
|
start_y = max(8, (request.height - total_h) // 2)
|
|
logo_x = (request.width - logo_w) // 2
|
|
canvas.paste(logo_resized, (logo_x, int(start_y)), logo_resized)
|
|
text_y = start_y + target_logo_h + gap
|
|
else:
|
|
text_y = (request.height - text_h) // 2
|
|
|
|
text_x = (request.width - text_w) // 2
|
|
layer = Image.new("RGBA", ((text_w + 1) * supersample, (text_h + 1) * supersample), (0, 0, 0, 0))
|
|
draw = ImageDraw.Draw(layer)
|
|
origin = (-bbox[0], -bbox[1])
|
|
draw.text((origin[0] + supersample, origin[1] + supersample), text, font=font, fill=(30, 20, 50, 180)) # 1 px shadow
|
|
draw.text(origin, text, font=font, fill=(250, 248, 255, 255))
|
|
layer = layer.resize((text_w + 1, text_h + 1), Image.Resampling.LANCZOS)
|
|
canvas.alpha_composite(layer, (int(text_x), int(text_y)))
|
|
return canvas.tobytes()
|
|
except Exception:
|
|
try:
|
|
import cv2
|
|
import numpy as np
|
|
image = np.zeros((request.height, request.width, 4), np.uint8)
|
|
image[..., 3] = 255
|
|
scale = request.height / 480
|
|
size = cv2.getTextSize(text, cv2.FONT_HERSHEY_SIMPLEX, scale, max(1, int(2 * scale)))[0]
|
|
cv2.putText(image, text, ((request.width - size[0]) // 2, (request.height + size[1]) // 2), cv2.FONT_HERSHEY_SIMPLEX,
|
|
scale, (255, 255, 255, 255), max(1, int(2 * scale)), cv2.LINE_AA)
|
|
return image.tobytes()
|
|
except Exception:
|
|
return bytes(request.width * request.height * 4)
|