Open your car’s phone pairing screen, then tap Find car. You can do this before step 1 is finished.
+
{{ adapterSetup ? 'Pair Your Adapter and Car' : 'Pair Your Car' }}
{{ adapterSetup ? 'An adapter needs two pairings: the adapter here, and your car on the Bluetooth page.' : pairsHere ? 'Open your car’s phone pairing screen, then tap Find car. You can do this before step 1 is finished.' : 'No Bluetooth pairing is needed for this setup.' }}
+
+
+ {{ runtime.car_profile_source === 'override' ? 'Using standard settings for this car.' : 'Tuned for ' + runtime.car_profile_label + ' (detected).' }}
+
+
Plug the car’s data USB port into the comma’s USB-C port, then turn on Wired USB Connection in step 3.
+
Pick your tablet app in step 3 and follow its setup steps.
+
+
+
+
+ Adapter{{ selected ? selected.name : 'Put it in pairing mode, then tap Find adapter.' }}
+ {{ selected ? 'Paired' : 'Not paired' }}
+
+
+
+ Your car{{ runtime?.companion_name ? runtime.companion_name : selected ? 'Pair your car on the Bluetooth page, then tap Connect there.' : 'Pair the adapter first.' }}
+ Paired
+ Open Bluetooth
+ Not paired
+
Stuck pairing? Pairing order and wireless adapters
Keep this page open while pairing so the car’s PIN or code doesn’t time out.
Cars with built-in wireless Android Auto: open the car’s phone pairing screen, then tap Find car above. Choose your car, then confirm the code on both screens.
-
Wireless adapter (AAWireless, Motorola MA1, Carlinkit…): pair your car in Bluetooth under Tools first so calls keep working, then put the adapter in pairing mode and pair it here. Adapter names often look like AndroidAuto-XXXX.
+
Wireless adapter (AAWireless, Motorola MA1, Carlinkit…): choose Wireless adapter above, put the adapter in pairing mode and pair it here, and pair your car in Bluetooth under Tools so calls keep working. Then connect the car there. Adapter names often look like AndroidAuto-XXXX.
Start fresh:
@@ -824,6 +863,7 @@ export const AndroidAutoPage = {
Open Bluetooth
+
diff --git a/openpilot/starpilot/galaxy/web/js/bluetooth.js b/openpilot/starpilot/galaxy/web/js/bluetooth.js
index 94904608fd..dfe912545f 100644
--- a/openpilot/starpilot/galaxy/web/js/bluetooth.js
+++ b/openpilot/starpilot/galaxy/web/js/bluetooth.js
@@ -217,6 +217,7 @@ export const BluetoothPage = {
{{ error }}Checking Bluetooth…
+ Using a wireless Android Auto adapter? Pair the adapter on the Android Auto page, then pair and connect your car here.
Adapter
diff --git a/openpilot/starpilot/system/android_auto/bugsink_report.py b/openpilot/starpilot/system/android_auto/bugsink_report.py
index dc63aecdc4..61ae72b5d2 100644
--- a/openpilot/starpilot/system/android_auto/bugsink_report.py
+++ b/openpilot/starpilot/system/android_auto/bugsink_report.py
@@ -387,6 +387,11 @@ class PairingAttempt:
self.record["steps"].pop(0)
self.record["steps_omitted"] = self.record.get("steps_omitted", 0) + 1
+ def reached_connection(self) -> bool:
+ """The car was approved or connected before the window ended."""
+ return any(step.get("step") == "status" and (step.get("approved") or step.get("state") in ("paired", "connected"))
+ for step in self.record["steps"])
+
def finish(self, result: str, **values) -> None:
self.step(result, **values)
self._sink({**self.record, "result": result, "duration_s": round(self._clock() - self._opened, 1)})
diff --git a/openpilot/starpilot/system/android_auto/car_profiles.py b/openpilot/starpilot/system/android_auto/car_profiles.py
new file mode 100644
index 0000000000..00a8bc1e49
--- /dev/null
+++ b/openpilot/starpilot/system/android_auto/car_profiles.py
@@ -0,0 +1,61 @@
+"""Per-car tuning, picked by fingerprint so nobody has to choose their car from a list.
+
+A car is recognised from what its head unit reported on a previous connection. Cars without a rule
+keep the standard behaviour exactly. Add a rule to ship a variation for one car; ``car_profile_override``
+in the config pins a profile (or "default") for a single comma while testing with someone.
+"""
+import re
+
+DEFAULTS = {"connect_delay_s": 0.0} # wait before the Bluetooth connect, for head units that need time to wake
+
+FINGERPRINT_KEYS = ("head_unit_make", "head_unit_model", "car_make", "car_model", "car_year")
+
+# {"id": "...", "label": "...", "match": {fingerprint key: regex, ...}, "tuning": {key from DEFAULTS: value}}
+RULES: list[dict] = []
+
+
+def profile_ids() -> list[str]:
+ return [rule["id"] for rule in RULES]
+
+
+def valid_override(value: str) -> bool:
+ return value in ("", "default", *profile_ids())
+
+
+def fingerprint_from(head_unit: dict, address: str) -> dict:
+ found = {key: str(head_unit[key])[:80] for key in FINGERPRINT_KEYS if head_unit.get(key)}
+ return {"address": address.upper(), **found} if found else {}
+
+
+def _matches(rule: dict, fingerprint: dict) -> bool:
+ match = rule.get("match") or {}
+ return bool(match) and all(re.search(pattern, str(fingerprint.get(key, "")), re.IGNORECASE) for key, pattern in match.items())
+
+
+def resolve(config: dict) -> tuple[str, str]:
+ """(profile id, source) where source is "override", "detected" or "standard"."""
+ override = config.get("car_profile_override", "")
+ if override == "default":
+ return "default", "override"
+ if override in profile_ids():
+ return override, "override"
+ fingerprint = config.get("car_fingerprint") or {}
+ if fingerprint.get("address") == str(config.get("receiver_address", "")).upper():
+ for rule in RULES:
+ if _matches(rule, fingerprint):
+ return rule["id"], "detected"
+ return "default", "standard"
+
+
+def tuning(config: dict) -> dict:
+ profile, _ = resolve(config)
+ rule = next((item for item in RULES if item["id"] == profile), {})
+ return {**DEFAULTS, **{key: value for key, value in (rule.get("tuning") or {}).items() if key in DEFAULTS}}
+
+
+def summary(config: dict) -> dict:
+ """What the UI may show: nothing at all while the standard behaviour applies."""
+ profile, source = resolve(config)
+ rule = next((item for item in RULES if item["id"] == profile), None)
+ return {"car_profile": profile, "car_profile_source": source, "car_profile_label": rule["label"] if rule else "",
+ "car_profile_ids": profile_ids()}
diff --git a/openpilot/starpilot/system/android_auto/daemon.py b/openpilot/starpilot/system/android_auto/daemon.py
index 39df0de9c3..f5ac3d2719 100644
--- a/openpilot/starpilot/system/android_auto/daemon.py
+++ b/openpilot/starpilot/system/android_auto/daemon.py
@@ -28,7 +28,7 @@ from openpilot.starpilot.system.android_auto.source_verifier import GalaxySource
from openpilot.starpilot.system.android_auto.supervisor import Supervisor
PAIRING_COMMANDS = {"prepare_pairing", "pairing_status", "pairing_response", "cancel_pairing", "pair_device"}
-AUTHENTICATED_COMMANDS = {"start", "set_auto_connect", "select_receiver", "set_view", "set_connection",
+AUTHENTICATED_COMMANDS = {"start", "set_auto_connect", "select_receiver", "set_view", "set_connection", "set_car_type", "set_car_profile",
"prepare_pairing", "pairing_status", "pairing_response", "cancel_pairing", "pair_device", "devices", "bluetooth_action",
"phone_window", "bluetooth_power"}
@@ -124,6 +124,10 @@ def handle(supervisor: Supervisor, request: dict[str, Any], verifier: GalaxySour
supervisor.set_view(str(request.get("view", "")))
elif command == "set_connection":
supervisor.set_connection(str(request.get("connection", "")))
+ elif command == "set_car_type":
+ supervisor.set_car_type(str(request.get("car_type", "")))
+ elif command == "set_car_profile":
+ supervisor.set_car_profile(str(request.get("profile", "")))
elif command == "prepare_pairing":
supervisor.prepare_pairing()
elif command == "pairing_status":
diff --git a/openpilot/starpilot/system/android_auto/identity.py b/openpilot/starpilot/system/android_auto/identity.py
index cdd0bccf85..f5d69244f7 100644
--- a/openpilot/starpilot/system/android_auto/identity.py
+++ b/openpilot/starpilot/system/android_auto/identity.py
@@ -131,6 +131,9 @@ DEFAULT_CONFIG = {
"phone_class": True, # while pairing/connecting, present as a phone: HFP gateway + smartphone Class of Device
"auto_connect": True, # start projection on its own when the chosen car is on (onroad, or it reaches the comma)
"rfcomm_cache": {}, # car address -> Android Auto RFCOMM channel learned over SDP, to skip discovery next time
+ "car_type": "", # wireless setup shown in Galaxy: "builtin" or "adapter"; "" = work it out from the saved companion car
+ "car_profile_override": "", # "" = pick by fingerprint; "default" = standard behaviour; otherwise a profile id from car_profiles
+ "car_fingerprint": {}, # what the chosen car's head unit reported last time, used to pick a profile before it connects
}
@@ -160,6 +163,9 @@ def load_config(path: Path | None = None) -> dict:
config["connection"] = "wireless"
if not 1024 <= int(config["headunitpad_port"]) <= 65535:
config["headunitpad_port"] = DEFAULT_CONFIG["headunitpad_port"]
+ if config["car_type"] not in ("", "builtin", "adapter"):
+ config["car_type"] = ""
+ config["car_fingerprint"] = {str(k): str(v)[:80] for k, v in config["car_fingerprint"].items()}
if config["usb_mode"] not in ("auto", "handshake", "direct"):
config["usb_mode"] = "auto"
config["bitrate_kbps"] = max(1000, min(12000, int(config["bitrate_kbps"])))
diff --git a/openpilot/starpilot/system/android_auto/supervisor.py b/openpilot/starpilot/system/android_auto/supervisor.py
index 2ea8e7550c..7fff019efc 100644
--- a/openpilot/starpilot/system/android_auto/supervisor.py
+++ b/openpilot/starpilot/system/android_auto/supervisor.py
@@ -32,7 +32,7 @@ from pathlib import Path
from typing import Any
from openpilot.common.hardware import COMMA_HARDWARE
-from openpilot.starpilot.system.android_auto import identity as identity_store
+from openpilot.starpilot.system.android_auto import car_profiles, identity as identity_store
from openpilot.starpilot.system.android_auto.auto_connect import AutoConnectPolicy
from openpilot.starpilot.system.android_auto.bootstrap import NAMES, BootstrapError, WirelessBootstrap
from openpilot.starpilot.system.android_auto.bugsink_report import PairingAttempt
@@ -59,6 +59,7 @@ ENCODER_RECOVERIES = 3 # hardware encoder reopens allowed per window befor
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_ANDROID_AUTO_WAIT = 8.0 # a paired device must offer Android Auto within this long, or we say it does not
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
@@ -286,6 +287,8 @@ class EventLog:
class Supervisor:
+ _pairing_note = "" # why a paired car was not chosen, kept after the pairing window closes
+ _no_aa_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,
@@ -347,6 +350,9 @@ class Supervisor:
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["configured_view"] = self.config["view"]
status["connection"] = self.config["connection"]
status["wired_blocked"] = wired_blocker()
@@ -430,7 +436,7 @@ class Supervisor:
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="")
+ 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)
@@ -458,7 +464,7 @@ class Supervisor:
if chosen:
if self._bluez is not None:
self._bluez.release()
- self.config.update(receiver_address="", receiver_name="", companion_address="", companion_name="")
+ 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)
@@ -487,6 +493,44 @@ class Supervisor:
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}")
+ 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
+ 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 behaviour) 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._bluez_factory is None and (self._source_session is None or self._shared_bluetooth_owner is None or
@@ -506,6 +550,7 @@ class Supervisor:
self._pairing_known = known
self._pairing_until = time.monotonic() + seconds
self._pairing_attempt, self._pairing_seen = PairingAttempt(seconds), ()
+ self._pairing_note, self._no_aa_since = "", 0.0
self.log("pairing_window", seconds=seconds)
def pair_device(self, address: str) -> None:
@@ -533,6 +578,8 @@ class Supervisor:
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:
@@ -715,6 +762,7 @@ class Supervisor:
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_android_auto(devices, receiver)
return
with self._lock:
attempt, self._pairing_attempt = getattr(self, '_pairing_attempt', None), None
@@ -729,6 +777,22 @@ class Supervisor:
if attempt is not None:
attempt.finish("select_failed", error=str(error))
+ def _note_missing_android_auto(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("android_auto")), None)
+ if device is None:
+ self._no_aa_since = 0.0
+ return
+ now = time.monotonic()
+ if not self._no_aa_since:
+ self._no_aa_since = now
+ elif now - self._no_aa_since >= NO_ANDROID_AUTO_WAIT and not self._pairing_note:
+ self._pairing_note = (f"{device['name'] or 'This device'} paired, but it doesn't offer wireless Android Auto. "
+ "Using a wireless adapter? Choose Wireless adapter, pair the adapter here, then connect your car on the Bluetooth page.")
+ self._pairing_step("no_android_auto")
+ self.log("pairing_no_android_auto", address=device["address"])
+
def close(self) -> None:
self.stop()
if self._bluez is not None:
@@ -1051,6 +1115,9 @@ class Supervisor:
self.log('companion_car_connected', address=companion)
elif device.get('android_auto') 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)
@@ -1079,6 +1146,7 @@ class Supervisor:
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()
diff --git a/openpilot/starpilot/system/android_auto/tests/test_car_profiles.py b/openpilot/starpilot/system/android_auto/tests/test_car_profiles.py
new file mode 100644
index 0000000000..64952f9a8a
--- /dev/null
+++ b/openpilot/starpilot/system/android_auto/tests/test_car_profiles.py
@@ -0,0 +1,173 @@
+"""Car profiles are picked by fingerprint; cars without a rule keep the standard behaviour."""
+
+import json
+import threading
+from unittest.mock import Mock
+
+import pytest
+
+from openpilot.starpilot.system.android_auto import bugsink_report as br, car_profiles, daemon, identity as identity_store
+from openpilot.starpilot.system.android_auto.supervisor import Supervisor
+from openpilot.starpilot.system.android_auto.tests.fake_head_unit import make_identity
+from openpilot.starpilot.system.android_auto.tests.test_android_auto import make_supervisor
+
+ADAPTER = "AA:BB:CC:DD:EE:01"
+PALISADE = {"id": "palisade", "label": "Hyundai Palisade", "match": {"car_make": "^hyundai$", "car_model": "palisade"},
+ "tuning": {"connect_delay_s": 10.0, "unknown_key": 1}}
+
+
+@pytest.fixture(scope="module")
+def identity(tmp_path_factory):
+ return make_identity(tmp_path_factory.mktemp("identity"))
+
+
+@pytest.fixture
+def sup(identity, tmp_path, monkeypatch):
+ sup, _ = make_supervisor(identity, tmp_path, monkeypatch, lambda *a, **k: None)
+ return sup
+
+
+@pytest.fixture
+def palisade(monkeypatch):
+ monkeypatch.setattr(car_profiles, "RULES", [PALISADE])
+
+
+def config(**values):
+ return {"receiver_address": ADAPTER, "car_profile_override": "", "car_fingerprint": {}, **values}
+
+
+def test_every_car_is_standard_without_rules():
+ assert car_profiles.resolve(config(car_fingerprint={"address": ADAPTER, "car_make": "Hyundai"})) == ("default", "standard")
+ assert car_profiles.tuning(config()) == car_profiles.DEFAULTS
+ assert car_profiles.summary(config())["car_profile_label"] == ""
+
+
+def test_fingerprint_picks_a_profile_only_for_the_chosen_receiver(palisade):
+ fingerprint = {"address": ADAPTER, "car_make": "Hyundai", "car_model": "Palisade"}
+ assert car_profiles.resolve(config(car_fingerprint=fingerprint)) == ("palisade", "detected")
+ assert car_profiles.resolve(config(car_fingerprint={**fingerprint, "address": "AA:BB:CC:DD:EE:09"})) == ("default", "standard")
+ assert car_profiles.resolve(config(car_fingerprint={"address": ADAPTER, "car_make": "Hyundai", "car_model": "Sonata"})) == ("default", "standard")
+
+
+def test_tuning_keeps_only_known_keys(palisade):
+ tuned = car_profiles.tuning(config(car_profile_override="palisade"))
+ assert tuned == {"connect_delay_s": 10.0}
+
+
+def test_override_pins_a_profile_or_forces_standard(palisade):
+ fingerprint = {"address": ADAPTER, "car_make": "Hyundai", "car_model": "Palisade"}
+ assert car_profiles.resolve(config(car_profile_override="default", car_fingerprint=fingerprint)) == ("default", "override")
+ assert car_profiles.resolve(config(car_profile_override="palisade")) == ("palisade", "override")
+ assert car_profiles.resolve(config(car_profile_override="gone")) == ("default", "standard")
+ assert car_profiles.valid_override("") and car_profiles.valid_override("default") and car_profiles.valid_override("palisade")
+ assert not car_profiles.valid_override("gone")
+
+
+def test_old_config_files_load_with_standard_defaults(tmp_path):
+ path = tmp_path / "config.json"
+ path.write_text(json.dumps({"config_version": 6, "connection": "wired", "car_type": "bogus"}))
+ loaded = identity_store.load_config(path)
+ assert loaded["car_type"] == "" and loaded["car_profile_override"] == "" and loaded["car_fingerprint"] == {}
+ assert loaded["connection"] == "wired"
+
+
+def test_car_type_follows_the_connection_and_companion(sup):
+ assert sup.status()["car_type"] == "builtin"
+ sup.config.update(companion_address="car")
+ assert sup.car_type() == "adapter"
+ sup.config.update(car_type="builtin")
+ assert sup.car_type() == "builtin"
+ sup.config.update(connection="headunitpad")
+ assert sup.car_type() == "app"
+
+
+def test_choosing_a_car_type_sets_the_connection(sup):
+ sup.set_car_type("adapter")
+ assert sup.car_type() == "adapter" and sup.config["connection"] == "wireless"
+ sup.set_car_type("app")
+ assert sup.config["connection"] == "headunitpad" and sup.car_type() == "app"
+ sup.set_car_type("builtin")
+ assert sup.config["connection"] == "wireless" and sup.car_type() == "builtin"
+ assert json.loads(identity_store.CONFIG_PATH.read_text())["car_type"] == "builtin"
+ with pytest.raises(RuntimeError):
+ sup.set_car_type("hovercraft")
+
+
+def test_profile_changes_are_pinned_and_validated(sup, palisade):
+ sup.set_car_profile("palisade")
+ status = sup.status()
+ assert status["car_profile"] == "palisade" and status["car_profile_source"] == "override" and status["car_profile_label"] == "Hyundai Palisade"
+ sup.set_car_profile("")
+ assert sup.status()["car_profile_source"] == "standard"
+ with pytest.raises(RuntimeError):
+ sup.set_car_profile("nope")
+
+
+def test_setup_commands_need_a_signed_in_galaxy_source():
+ assert {"set_car_type", "set_car_profile"} <= daemon.AUTHENTICATED_COMMANDS
+
+
+def test_head_unit_fingerprint_is_remembered_and_dropped_with_the_car(sup):
+ sup.config["receiver_address"] = ADAPTER
+ sup._remember_fingerprint({"car_make": "Hyundai", "car_model": "Palisade", "head_unit_make": "MOBIS", "ignored": "x"})
+ stored = json.loads(identity_store.CONFIG_PATH.read_text())["car_fingerprint"]
+ assert stored == {"address": ADAPTER, "head_unit_make": "MOBIS", "car_make": "Hyundai", "car_model": "Palisade"}
+ sup._remember_fingerprint({})
+ assert sup.config["car_fingerprint"] == stored
+
+
+def make_notifier():
+ sup = Supervisor.__new__(Supervisor)
+ sup._lock = threading.RLock()
+ sup._pairing_step = Mock()
+ sup.log = Mock()
+ return sup
+
+
+def test_a_paired_device_without_android_auto_is_explained_after_a_wait(monkeypatch):
+ sup = make_notifier()
+ receiver = {"address": ADAPTER, "name": "Civic"}
+ devices = [{"address": ADAPTER, "name": "Civic", "paired": True, "android_auto": False}]
+ clock = iter([100.0, 103.0, 109.0])
+ monkeypatch.setattr("time.monotonic", lambda: next(clock))
+ for _ in range(2):
+ sup._note_missing_android_auto(devices, receiver)
+ assert sup._pairing_note == ""
+ sup._note_missing_android_auto(devices, receiver)
+ assert "doesn't offer wireless Android Auto" in sup._pairing_note and "Wireless adapter" in sup._pairing_note
+ sup._pairing_step.assert_called_once_with("no_android_auto")
+
+
+def test_a_device_that_offers_android_auto_never_gets_a_note():
+ sup = make_notifier()
+ sup._no_aa_since = 5.0
+ sup._note_missing_android_auto([{"address": ADAPTER, "name": "Car", "paired": True, "android_auto": True}], {"address": ADAPTER})
+ assert sup._pairing_note == "" and sup._no_aa_since == 0.0
+
+
+@pytest.mark.parametrize("end", ["interrupted", "cancelled", "expired"])
+def test_a_pairing_that_connected_is_not_reported_as_interrupted(end):
+ records = []
+ attempt = br.PairingAttempt(180, sink=records.append)
+ attempt.step("status", state="connected", prompt=False, approved=True)
+ sup = make_notifier()
+ sup._pairing_attempt = attempt
+ sup._end_pairing(end, reason="Bluetooth lease released")
+ assert records[0]["result"] == "paired_not_selected" and records[0]["steps"][-1]["ended"] == end
+
+
+def test_a_pairing_that_never_connected_keeps_its_result():
+ records = []
+ attempt = br.PairingAttempt(180, sink=records.append)
+ attempt.step("status", state="idle", prompt=False, approved=False)
+ sup = make_notifier()
+ sup._pairing_attempt = attempt
+ sup._end_pairing("interrupted", reason="Galaxy session ended")
+ assert records[0]["result"] == "interrupted"
+
+
+def test_the_new_result_has_a_bugsink_report():
+ attempt = br.PairingAttempt(180, sink=lambda record: None)
+ attempt.step("status", state="connected", prompt=False, approved=True)
+ event = br.pairing_event({**attempt.record, "result": "paired_not_selected", "duration_s": 5.0})
+ assert event["fingerprint"] == ["android-auto-pairing", "paired_not_selected"] and event["level"] == "warning"
From 06fefc67686c10e88361f875fae452332030d9ad Mon Sep 17 00:00:00 2001
From: dirwin31 <83434411+dirwin31@users.noreply.github.com>
Date: Mon, 26 Oct 2026 13:00:28 -0700
Subject: [PATCH 02/23] Android Auto UI: Integrate wired and app settings
directly into step 2
---
.../galaxy/tests/test_android_auto.mjs | 2 +-
.../starpilot/galaxy/web/js/android-auto.js | 25 +++++++++++--------
2 files changed, 15 insertions(+), 12 deletions(-)
diff --git a/openpilot/starpilot/galaxy/tests/test_android_auto.mjs b/openpilot/starpilot/galaxy/tests/test_android_auto.mjs
index 07ab61ed40..20080c8da4 100644
--- a/openpilot/starpilot/galaxy/tests/test_android_auto.mjs
+++ b/openpilot/starpilot/galaxy/tests/test_android_auto.mjs
@@ -491,7 +491,7 @@ typed.control = (action, extra) => typedCalls.push([action, extra])
typed.saveCarType(0, 'Wireless adapter')
typed.saveCarType(0, 'Not a type')
assert.deepEqual(typedCalls, [['car_type', { carType: 'adapter' }]])
-assert.deepEqual(new Set(AndroidAutoPage.computed.carTypeRow.call(typed).choices), new Set(['Built-in wireless', 'Wireless adapter', 'USB cable', 'Tablet app']))
+assert.deepEqual(new Set(AndroidAutoPage.computed.carTypeRow.call(typed).choices), new Set(['Built-in wireless', 'Wireless adapter', 'Wired AA', 'App']))
// A tuned car profile only shows up when one is detected or pinned.
const profiled = page(setup)
assert.equal(profiled.showProfile, false, 'standard cars see nothing')
diff --git a/openpilot/starpilot/galaxy/web/js/android-auto.js b/openpilot/starpilot/galaxy/web/js/android-auto.js
index 35c71e1533..ca55b8fc35 100644
--- a/openpilot/starpilot/galaxy/web/js/android-auto.js
+++ b/openpilot/starpilot/galaxy/web/js/android-auto.js
@@ -67,7 +67,7 @@ const APP_SETUP = `
`
export const CAR_TYPES = {
- builtin: "Built-in wireless", adapter: "Wireless adapter", usb: "USB cable", app: "Tablet app"
+ builtin: "Built-in wireless", adapter: "Wireless adapter", usb: "Wired AA", app: "App"
}
const PROMPT = new Set(["pin", "passkey", "confirmation", "authorization", "display_pin", "display_passkey"])
@@ -492,8 +492,8 @@ export const AndroidAutoPage = {
connectionName() { return this.app === "HeadunitPad" ? "iPad" : this.app ? "tablet" : this.wired ? "car" : this.selected?.name || "car" },
setupDone() { return !!(this.supportReady && (this.selected || this.wired || this.app) && !this.pairing?.active) },
wiredRow() {
- const detail = "Project over a USB cable from the car to the comma’s USB-C port instead of Bluetooth and Wi-Fi."
- return switchRow("Wired USB Connection", this.wired, detail + (this.runtime?.wired_blocked ? ` ${this.runtime.wired_blocked}.` : ""))
+ const detail = "Turn this on for wired Android Auto. Connect the car’s Android Auto USB data port to the comma’s USB-C port with a data-capable cable."
+ return switchRow("Wired AA", this.wired, detail + (this.runtime?.wired_blocked ? ` ${this.runtime.wired_blocked}.` : ""))
},
appRow() {
return { label: "App", value: this.app || "Off", choices: ["Off", ...Object.values(HEAD_UNIT_APPS)], available: true, action: true,
@@ -779,16 +779,22 @@ export const AndroidAutoPage = {
2
-
{{ adapterSetup ? 'Pair Your Adapter and Car' : 'Pair Your Car' }}
{{ adapterSetup ? 'An adapter needs two pairings: the adapter here, and your car on the Bluetooth page.' : pairsHere ? 'Open your car’s phone pairing screen, then tap Find car. You can do this before step 1 is finished.' : 'No Bluetooth pairing is needed for this setup.' }}
+
{{ carType === 'usb' ? 'Set Up Wired AA' : carType === 'app' ? 'Set Up Your App' : adapterSetup ? 'Pair Your Adapter and Car' : 'Pair Your Car' }}
{{ adapterSetup ? 'An adapter needs two pairings: the adapter here, and your car on the Bluetooth page.' : pairsHere ? 'Open your car’s phone pairing screen, then tap Find car. You can do this before step 1 is finished.' : 'No Bluetooth pairing is needed for this setup.' }}
+
{{ runtime.car_profile_source === 'override' ? 'Using standard settings for this car.' : 'Tuned for ' + runtime.car_profile_label + ' (detected).' }}
-
Plug the car’s data USB port into the comma’s USB-C port, then turn on Wired USB Connection in step 3.
-
Pick your tablet app in step 3 and follow its setup steps.
First connection: stay in Park or offroad mode, and keep the car’s screen on.
Black or frozen screen: tap Disconnect, wait a few seconds, then connect again. If it keeps happening, turn the car off and on.
Car says the phone isn’t compatible: make sure wireless Android Auto (sometimes called “smartphone connection”) is turned on in the car’s settings, and that step 1 shows Ready.
-
Wired USB: turn on Wired USB Connection, plug the car’s data USB port into the comma’s USB-C port with a data-capable cable, and drive. Bluetooth pairing is not needed.
+
Wired AA: turn on Wired AA in step 2, plug the car’s data USB port into the comma’s USB-C port with a data-capable cable, and drive. Bluetooth pairing is not needed.
From 55a41e90268bb6b126df2d9120b817234653f6cb Mon Sep 17 00:00:00 2001
From: dirwin31 <83434411+dirwin31@users.noreply.github.com>
Date: Mon, 26 Oct 2026 13:00:30 -0700
Subject: [PATCH 03/23] AA UI: Fix Blocked Wired
---
.../galaxy/tests/test_android_auto.mjs | 23 ++++++++++++++++++-
.../starpilot/galaxy/web/js/android-auto.js | 15 ++++++++++--
2 files changed, 35 insertions(+), 3 deletions(-)
diff --git a/openpilot/starpilot/galaxy/tests/test_android_auto.mjs b/openpilot/starpilot/galaxy/tests/test_android_auto.mjs
index 20080c8da4..e7191629ef 100644
--- a/openpilot/starpilot/galaxy/tests/test_android_auto.mjs
+++ b/openpilot/starpilot/galaxy/tests/test_android_auto.mjs
@@ -491,7 +491,28 @@ typed.control = (action, extra) => typedCalls.push([action, extra])
typed.saveCarType(0, 'Wireless adapter')
typed.saveCarType(0, 'Not a type')
assert.deepEqual(typedCalls, [['car_type', { carType: 'adapter' }]])
-assert.deepEqual(new Set(AndroidAutoPage.computed.carTypeRow.call(typed).choices), new Set(['Built-in wireless', 'Wireless adapter', 'Wired AA', 'App']))
+assert.deepEqual(new Set(page(setup).carTypeRow.choices), new Set(['Built-in wireless', 'Wireless adapter', 'Wired AA', 'App']))
+// A blocked wired port explains itself instead of silently refusing the pick.
+const blocked = page(setup)
+blocked.feed = { error: '', emit() {} }
+blocked.runtime = { car_type: 'builtin', wired_blocked: 'Turn off Jetlink to use wired Android Auto' }
+assert.equal(blocked.wiredUnavailable, true)
+const blockedCalls = []
+blocked.control = (action, extra) => blockedCalls.push([action, extra])
+assert.equal(blocked.saveCarType(0, 'Wired AA'), false)
+assert.deepEqual(blockedCalls, [])
+assert.match(blocked.feed.error, /Turn off Jetlink/)
+blocked.runtime = { car_type: 'usb', connection: 'wired', wired_blocked: 'Turn off Jetlink to use wired Android Auto' }
+assert.equal(blocked.wiredUnavailable, false, 'already wired: the pick is not blocked')
+// The line names the chosen tablet app.
+const named = page(setup)
+named.runtime = { car_type: 'app', connection: 'openheadunit' }
+assert.equal(named.carTypeRow.value, 'App (Open Headunit)')
+assert.ok(named.carTypeRow.choices.includes('App (Open Headunit)'))
+const namedCalls = []
+named.control = (action, extra) => namedCalls.push([action, extra])
+named.saveCarType(0, 'Wireless adapter')
+assert.deepEqual(namedCalls, [['car_type', { carType: 'adapter' }]])
// A tuned car profile only shows up when one is detected or pinned.
const profiled = page(setup)
assert.equal(profiled.showProfile, false, 'standard cars see nothing')
diff --git a/openpilot/starpilot/galaxy/web/js/android-auto.js b/openpilot/starpilot/galaxy/web/js/android-auto.js
index ca55b8fc35..02ad0f6a4d 100644
--- a/openpilot/starpilot/galaxy/web/js/android-auto.js
+++ b/openpilot/starpilot/galaxy/web/js/android-auto.js
@@ -503,8 +503,11 @@ export const AndroidAutoPage = {
adapterSetup() { return this.carType === "adapter" },
pairsHere() { return this.carType === "builtin" || this.carType === "adapter" },
findLabel() { return this.adapterSetup ? "adapter" : "car" },
+ carTypeLabels() { return { ...CAR_TYPES, app: this.app ? `App (${this.app})` : CAR_TYPES.app } },
+ wiredUnavailable() { return !this.wired && !!this.runtime?.wired_blocked },
carTypeRow() {
- return { label: "What’s in your car?", value: CAR_TYPES[this.carType], choices: Object.values(CAR_TYPES), available: true, action: true,
+ const labels = this.carTypeLabels
+ return { label: "What’s in your car?", value: labels[this.carType], choices: Object.values(labels), available: true, action: true,
reason: "Built-in: the car supports wireless Android Auto. Adapter: a dongle such as AAWireless, Motorola MA1 or Carlinkit. Both the adapter and your car get paired." }
},
showProfile() { return !!(this.runtime?.car_profile_label || this.runtime?.car_profile_source === "override") },
@@ -593,7 +596,14 @@ export const AndroidAutoPage = {
methods: {
switchRow,
saveCarType(index, value) {
- const carType = Object.keys(CAR_TYPES).find(key => CAR_TYPES[key] === value)
+ const labels = this.carTypeLabels
+ const carType = Object.keys(labels).find(key => labels[key] === value)
+ if (carType === "usb" && this.wiredUnavailable) {
+ this.feed.error = `Wired AA is unavailable: ${this.runtime.wired_blocked}.`
+ this.feed.emit()
+ return false
+ }
+ if (carType === this.carType) return false
return carType ? this.control("car_type", { carType }) : false
},
saveApp(index, value) {
@@ -783,6 +793,7 @@ export const AndroidAutoPage = {
+ Wired AA is unavailable: {{ runtime.wired_blocked }}.
{{ runtime.car_profile_source === 'override' ? 'Using standard settings for this car.' : 'Tuned for ' + runtime.car_profile_label + ' (detected).' }}
From 7fea4f939d4ee0277a71edcc79233f50b334963e Mon Sep 17 00:00:00 2001
From: dirwin31 <83434411+dirwin31@users.noreply.github.com>
Date: Mon, 26 Oct 2026 13:00:31 -0700
Subject: [PATCH 04/23] Android Auto: AAWireless v1 adapter option
Co-authored-by: Peter Nguyen
---
.../galaxy/tests/test_android_auto.mjs | 25 ++--
.../starpilot/galaxy/web/js/android-auto.js | 19 ++-
.../system/android_auto/car_profiles.py | 10 +-
.../starpilot/system/android_auto/identity.py | 8 +-
.../starpilot/system/android_auto/session.py | 125 +++++++++++++++++-
.../system/android_auto/supervisor.py | 28 +++-
.../android_auto/tests/fake_head_unit.py | 32 ++++-
.../android_auto/tests/test_android_auto.py | 86 +++++++++++-
.../android_auto/tests/test_car_profiles.py | 49 ++++++-
9 files changed, 354 insertions(+), 28 deletions(-)
diff --git a/openpilot/starpilot/galaxy/tests/test_android_auto.mjs b/openpilot/starpilot/galaxy/tests/test_android_auto.mjs
index e7191629ef..9b71967c4e 100644
--- a/openpilot/starpilot/galaxy/tests/test_android_auto.mjs
+++ b/openpilot/starpilot/galaxy/tests/test_android_auto.mjs
@@ -513,14 +513,23 @@ const namedCalls = []
named.control = (action, extra) => namedCalls.push([action, extra])
named.saveCarType(0, 'Wireless adapter')
assert.deepEqual(namedCalls, [['car_type', { carType: 'adapter' }]])
-// A tuned car profile only shows up when one is detected or pinned.
-const profiled = page(setup)
-assert.equal(profiled.showProfile, false, 'standard cars see nothing')
-profiled.runtime = { car_profile_label: 'Hyundai Palisade', car_profile_source: 'detected' }
-assert.equal(profiled.showProfile, true)
-profiled.runtime = { car_profile_label: '', car_profile_source: 'override' }
-assert.equal(profiled.showProfile, true, 'a pinned standard profile can be undone')
+// The adapter model picks the AAWireless tuning; any other adapter keeps the regular pairing.
+const dongle = page(setup)
+dongle.runtime = { car_type: 'adapter', car_profile: 'default', car_profile_source: 'standard' }
+assert.equal(dongle.adapterRow.value, 'Choose one', 'no default: the owner picks')
+assert.match(dongle.pairingReason, /adapter model/)
+dongle.runtime = { car_type: 'adapter', car_profile: 'default', car_profile_source: 'override' }
+assert.equal(dongle.adapterRow.value, 'Other')
+assert.deepEqual(dongle.adapterRow.choices, ['AAWireless v1', 'Other'])
+dongle.runtime = { car_type: 'adapter', car_profile: 'aawireless', car_profile_source: 'override' }
+assert.equal(dongle.adapterRow.value, 'AAWireless v1')
+const dongleCalls = []
+dongle.control = (action, extra) => dongleCalls.push([action, extra])
+dongle.saveAdapter(0, 'Other')
+dongle.saveAdapter(0, 'AAWireless v1')
+assert.deepEqual(dongleCalls, [['car_profile', { profile: 'default' }], ['car_profile', { profile: 'aawireless' }]])
+assert.match(AndroidAutoPage.template, /adapterSetup" :row="adapterRow"/)
assert.match(AndroidAutoPage.template, /What’s in your car\?|carTypeRow/)
assert.match(AndroidAutoPage.template, /runtime\?\.pairing_note/)
assert.match(AndroidAutoPage.template, /'Find ' \+ findLabel/)
-console.log('Android Auto page: setup type, adapter checklist and car profile line passed')
+console.log('Android Auto page: setup type, adapter checklist and adapter model passed')
diff --git a/openpilot/starpilot/galaxy/web/js/android-auto.js b/openpilot/starpilot/galaxy/web/js/android-auto.js
index 02ad0f6a4d..20da1dfbee 100644
--- a/openpilot/starpilot/galaxy/web/js/android-auto.js
+++ b/openpilot/starpilot/galaxy/web/js/android-auto.js
@@ -44,6 +44,7 @@ export function uploadPackage(path, options, progress, makeRequest = () => new X
}
const ADDRESS = /^(?:[0-9A-F]{2}:){5}[0-9A-F]{2}$/
+const ADAPTER_MODELS = { aawireless: "AAWireless v1", default: "Other" }
const HEAD_UNIT_APPS = { headunitpad: "HeadunitPad", openheadunit: "Open Headunit" }
const HOTSPOT_LINK = `Galaxy Wi-Fi Hotspot`
const APP_SETUP = `
@@ -510,7 +511,12 @@ export const AndroidAutoPage = {
return { label: "What’s in your car?", value: labels[this.carType], choices: Object.values(labels), available: true, action: true,
reason: "Built-in: the car supports wireless Android Auto. Adapter: a dongle such as AAWireless, Motorola MA1 or Carlinkit. Both the adapter and your car get paired." }
},
- showProfile() { return !!(this.runtime?.car_profile_label || this.runtime?.car_profile_source === "override") },
+ adapterRow() {
+ const chosen = this.runtime?.car_profile_source === "override"
+ return { label: "Adapter", value: !chosen ? "Choose one" : this.runtime.car_profile === "aawireless" ? ADAPTER_MODELS.aawireless : ADAPTER_MODELS.default,
+ choices: Object.values(ADAPTER_MODELS), available: true, action: true,
+ reason: "AAWireless v1 gets video and car-data settings tuned for that dongle. Other adapters use the regular pairing." }
+ },
appDisabled() {
return !this.setup?.enabled || !this.setup.parked || !!this.pairing?.active || !!this.runtime?.running
},
@@ -573,6 +579,7 @@ export const AndroidAutoPage = {
if (!this.setup.installReady) return "This build is missing the Android Auto display or encoder."
if (this.setup.enabled && !this.setup.serviceReady) return "Waiting for the Android Auto service to start."
if (!this.setup.bluetoothEnabled && this.setup.enabled) return "Turn on Bluetooth on the Bluetooth page."
+ if (this.adapterSetup && this.runtime?.car_profile_source !== "override") return "Choose your adapter model first."
if (!this.setup.parked) return "Use offroad mode or Park before starting pairing."
if (this.runtime?.running) return "Disconnect from your car before pairing."
return ""
@@ -582,6 +589,7 @@ export const AndroidAutoPage = {
if (!this.setup?.enabled) return "Turn on Android Auto Support in step 1."
if (!this.setup.serviceReady) return "Waiting for the Android Auto service to start."
if (this.pairing?.active) return "Finish or cancel pairing first."
+ if (this.adapterSetup && this.runtime?.car_profile_source !== "override") return "Choose your adapter model in step 2 first."
return ""
},
waitingForCar() {
@@ -606,6 +614,10 @@ export const AndroidAutoPage = {
if (carType === this.carType) return false
return carType ? this.control("car_type", { carType }) : false
},
+ saveAdapter(index, value) {
+ const profile = Object.keys(ADAPTER_MODELS).find(key => ADAPTER_MODELS[key] === value)
+ return profile ? this.control("car_profile", { profile }) : false
+ },
saveApp(index, value) {
const connection = Object.keys(HEAD_UNIT_APPS).find(key => HEAD_UNIT_APPS[key] === value) || "wireless"
return this.control("connection", { connection })
@@ -795,10 +807,7 @@ export const AndroidAutoPage = {
Wired AA is unavailable: {{ runtime.wired_blocked }}.
-
- {{ runtime.car_profile_source === 'override' ? 'Using standard settings for this car.' : 'Tuned for ' + runtime.car_profile_label + ' (detected).' }}
-
+
diff --git a/openpilot/starpilot/system/android_auto/car_profiles.py b/openpilot/starpilot/system/android_auto/car_profiles.py
index 00a8bc1e49..97c0809812 100644
--- a/openpilot/starpilot/system/android_auto/car_profiles.py
+++ b/openpilot/starpilot/system/android_auto/car_profiles.py
@@ -6,12 +6,18 @@ in the config pins a profile (or "default") for a single comma while testing wit
"""
import re
-DEFAULTS = {"connect_delay_s": 0.0} # wait before the Bluetooth connect, for head units that need time to wake
+# connect_delay_s: wait before the Bluetooth connect, for head units that need time to wake
+# max_window: most unacknowledged video messages in flight; phone_services: open the car-data and Bluetooth channels
+# like a phone does; phone_frames: send picture NAL units only, without AUD or repeated SPS/PPS
+DEFAULTS = {"connect_delay_s": 0.0, "max_window": 2, "phone_services": False, "phone_frames": False}
FINGERPRINT_KEYS = ("head_unit_make", "head_unit_model", "car_make", "car_model", "car_year")
# {"id": "...", "label": "...", "match": {fingerprint key: regex, ...}, "tuning": {key from DEFAULTS: value}}
-RULES: list[dict] = []
+RULES: list[dict] = [
+ {"id": "aawireless", "label": "AAWireless v1", "match": {},
+ "tuning": {"max_window": 30, "phone_services": True, "phone_frames": True}},
+]
def profile_ids() -> list[str]:
diff --git a/openpilot/starpilot/system/android_auto/identity.py b/openpilot/starpilot/system/android_auto/identity.py
index f5d69244f7..7de1a3848c 100644
--- a/openpilot/starpilot/system/android_auto/identity.py
+++ b/openpilot/starpilot/system/android_auto/identity.py
@@ -83,7 +83,7 @@ def load_identity(directory: Path | None = None, now: datetime | None = None) ->
expires.isoformat() if expires is not None else "unknown", days_left)
-CONFIG_VERSION = 6
+CONFIG_VERSION = 7
# Before config versions, every save wrote these defaults, which pinned projection
# at 12 fps / 4000 kbps. Unversioned files holding exactly them get today's defaults.
LEGACY_DEFAULTS = {"fps": 12, "bitrate_kbps": 4000}
@@ -132,7 +132,7 @@ DEFAULT_CONFIG = {
"auto_connect": True, # start projection on its own when the chosen car is on (onroad, or it reaches the comma)
"rfcomm_cache": {}, # car address -> Android Auto RFCOMM channel learned over SDP, to skip discovery next time
"car_type": "", # wireless setup shown in Galaxy: "builtin" or "adapter"; "" = work it out from the saved companion car
- "car_profile_override": "", # "" = pick by fingerprint; "default" = standard behaviour; otherwise a profile id from car_profiles
+ "car_profile_override": "", # "" = pick by fingerprint (a wireless adapter must choose); "default" = standard; otherwise a car_profiles id
"car_fingerprint": {}, # what the chosen car's head unit reported last time, used to pick a profile before it connects
}
@@ -152,6 +152,10 @@ def load_config(path: Path | None = None) -> dict:
stored = {key: value for key, value in stored.items() if key not in VERIFY_RESET}
if not isinstance(version, int) or version < 6:
stored = {key: value for key, value in stored.items() if key not in DEDICATED_WIFI_RESET}
+ if not isinstance(version, int) or version < 7: # adapter owners from before the adapter model choice keep connecting
+ on_adapter = stored.get("car_type") == "adapter" or (not stored.get("car_type") and stored.get("companion_address"))
+ if on_adapter and not stored.get("car_profile_override"):
+ stored = {**stored, "car_profile_override": "default"}
config.update({key: value for key, value in stored.items() if key in DEFAULT_CONFIG and isinstance(value, type(DEFAULT_CONFIG[key]))})
except (OSError, ValueError):
pass
diff --git a/openpilot/starpilot/system/android_auto/session.py b/openpilot/starpilot/system/android_auto/session.py
index cf3f9398a4..89c56d1be3 100644
--- a/openpilot/starpilot/system/android_auto/session.py
+++ b/openpilot/starpilot/system/android_auto/session.py
@@ -68,6 +68,17 @@ INPUT_EVENT = 0x8001
INPUT_BINDING_REQUEST = 0x8002
INPUT_BINDING_RESPONSE = 0x8003
+# Car data (sensor) and Bluetooth channels, opened the way a phone does.
+SENSOR_SERVICE = 2
+BLUETOOTH_SERVICE = 6
+SENSOR_START_REQUEST = 0x8001
+SENSOR_START_RESPONSE = 0x8002
+SENSOR_EVENT = 0x8003
+BLUETOOTH_PAIRING_REQUEST = 0x8001
+BLUETOOTH_PAIRING_RESPONSE = 0x8002
+BLUETOOTH_AUTH_DATA = 0x8003
+SENSOR_NIGHT, SENSOR_DRIVING_STATUS = 10, 13
+
CODEC_H264_BP = 3
SETUP_STATUS_READY = 2
FOCUS_PROJECTED = 1
@@ -118,6 +129,18 @@ def codec_config(access_unit: bytes) -> bytes:
return b"".join(units)
+def wire_frame(access_unit: bytes, *, drop_config: bool) -> bytes:
+ """The picture NAL units as Android's encoder sends them: no AUD, and no SPS/PPS when the codec config went separately."""
+ starts = list(START_CODE.finditer(access_unit))
+ drop = {9, 7, 8} if drop_config else {9}
+ units = []
+ for index, match in enumerate(starts):
+ end = starts[index + 1].start() if index + 1 < len(starts) else len(access_unit)
+ if match.end() < end and access_unit[match.end()] & 31 not in drop:
+ units.append(b"\x00\x00\x00\x01" + access_unit[match.end():end])
+ return b"".join(units) or access_unit
+
+
@dataclass(frozen=True)
class VideoMode:
channel: int
@@ -469,6 +492,16 @@ class Session:
item["input_config"] = parse_input_config(one(fields, 4))
except ValueError:
item["input_config"] = InputConfig()
+ if isinstance(one(fields, SENSOR_SERVICE), bytes):
+ try:
+ item["sensors"] = [one(parse_fields(s), 1) for s in parse_fields(one(fields, SENSOR_SERVICE)).get(1, []) if isinstance(s, bytes)]
+ except ValueError:
+ item["sensors"] = []
+ if isinstance(one(fields, BLUETOOTH_SERVICE), bytes):
+ try:
+ item["bluetooth"] = parse_bluetooth(one(fields, BLUETOOTH_SERVICE))
+ except ValueError:
+ item["bluetooth"] = {}
channels.append(item)
# Everything but the channel list: make, model, year, software and, on newer units, headunit_info.
head_unit = redact_head_unit(describe_fields({number: values for number, values in response.items() if number != 1}))
@@ -499,6 +532,33 @@ HEAD_UNIT_INFO = 17 # ServiceDiscoveryResponse.headunit_info
HEAD_UNIT_INFO_VEHICLE_ID = 4
+def packed_varints(value) -> list[int]:
+ """A repeated enum: packed bytes on newer units, one varint per field on older ones."""
+ if isinstance(value, int):
+ return [value]
+ out, pos = [], 0
+ while pos < len(value):
+ number, shift = 0, 0
+ while True:
+ if pos >= len(value) or shift > 63:
+ raise ValueError("Truncated packed varint")
+ byte = value[pos]
+ pos += 1
+ number |= (byte & 127) << shift
+ shift += 7
+ if byte < 128:
+ break
+ out.append(number)
+ return out
+
+
+def parse_bluetooth(data: bytes) -> dict:
+ fields = parse_fields(data)
+ address = one(fields, 1)
+ methods = [m for value in fields.get(2, []) for m in packed_varints(value)]
+ return {"car_address": address.decode(errors="replace") if isinstance(address, bytes) else "", "pairing_methods": methods}
+
+
def redact_head_unit(described: dict) -> dict:
"""Drop the vehicle identifier from a described discovery response: logs are shared for diagnosis."""
if SDR_VEHICLE_ID in described:
@@ -549,6 +609,13 @@ class ProjectionSession(Session):
self.epoch_acked = 0
self.video_confirmed = False
self.input_channel: int | None = None
+ self.max_window = self.MAX_WINDOW # raised by the supervisor for adapters that need the car's full window
+ self.phone_services = False # open the car-data and Bluetooth channels like a phone (adapter profile)
+ self.phone_frames = False # send picture NAL units only (adapter profile)
+ self.phone_address = "" # this comma's Bluetooth address, for the pairing request
+ self.sensor_channel: int | None = None
+ self.sensor_events: dict[int, int] = {}
+ self.bluetooth_channel: int | None = None
self.nav = None # NavStatus, attached by the supervisor when the head unit offers turn-by-turn
self.audio = None # MediaAudio, attached by the supervisor when Spotify plays through Android Auto
self.input_events = 0
@@ -567,6 +634,8 @@ class ProjectionSession(Session):
self.mode = choose_video_mode(self.channels)
self.open_video()
self.open_input()
+ if self.phone_services:
+ self.open_phone_services()
self.request_projection()
return self.mode
@@ -585,7 +654,7 @@ class ProjectionSession(Session):
configs = setup.get(3, [])
if configs and mode.config_index not in configs:
raise ValueError(f"Head unit did not accept video configuration {mode.config_index}: {json_fields(setup)}")
- self.window = min(window, self.MAX_WINDOW)
+ self.window = min(window, self.max_window)
self.event("video_setup", mode=mode.as_dict(), window=window, used_window=self.window)
def open_input(self) -> None:
@@ -609,6 +678,36 @@ class ProjectionSession(Session):
except (TimeoutError, ValueError) as error:
self.event("input_unavailable", error=str(error))
+ def _open_optional(self, channel: dict, name: str) -> bool:
+ try:
+ self.send(channel["id"], MSG_CHANNEL_OPEN_REQUEST, field(1, 0) + field(2, channel["id"]), control=True)
+ opened = parse_fields(self.wait_for(channel["id"], MSG_CHANNEL_OPEN_RESPONSE, timeout=3.0))
+ if signed(one(opened, 1)) != 0:
+ raise ValueError(f"channel open rejected: {signed(one(opened, 1))}")
+ return True
+ except (TimeoutError, ValueError) as error:
+ self.event(f"{name}_unavailable", channel=channel["id"], error=str(error))
+ return False
+
+ def open_phone_services(self) -> None:
+ """Open the car-data and Bluetooth channels before asking for the screen, as a phone does. Failures are not fatal."""
+ sensors = next((ch for ch in self.channels if "sensors" in ch), None)
+ if sensors is not None and self._open_optional(sensors, "sensors"):
+ self.sensor_channel = sensors["id"]
+ # Only what decides whether the screen may project; location would put the car's position in the session log.
+ wanted = [kind for kind in (SENSOR_DRIVING_STATUS, SENSOR_NIGHT) if kind in sensors["sensors"]]
+ for kind in wanted:
+ self.send(sensors["id"], SENSOR_START_REQUEST, field(1, kind) + field(2, 0))
+ self.event("sensors_opened", channel=sensors["id"], requested=wanted)
+ bluetooth = next((ch for ch in self.channels if "bluetooth" in ch), None)
+ if bluetooth is not None and self._open_optional(bluetooth, "bluetooth"):
+ self.bluetooth_channel = bluetooth["id"]
+ methods = bluetooth["bluetooth"].get("pairing_methods") or []
+ if self.phone_address:
+ self.send(bluetooth["id"], BLUETOOTH_PAIRING_REQUEST, field(1, self.phone_address) + field(2, methods[0] if methods else 2))
+ self.event("bluetooth_opened", channel=bluetooth["id"], phone_address=self.phone_address,
+ car_address=bluetooth["bluetooth"].get("car_address", ""), pairing_methods=methods)
+
def request_projection(self) -> None:
"""Ask for display focus; the Mazda donor needed this, DHU grants it unsolicited."""
assert self.mode is not None
@@ -693,6 +792,24 @@ class ProjectionSession(Session):
self.event("input_bound", status=signed(one(fields, 1, 0)))
else:
self.ignored("input_ignored", channel, kind, data)
+ elif channel is not None and channel == self.sensor_channel:
+ if kind == SENSOR_START_RESPONSE:
+ self.event("sensor_started", status=signed(one(fields, 1, 0)))
+ elif kind == SENSOR_EVENT:
+ for number, values in fields.items():
+ first = number not in self.sensor_events
+ self.sensor_events[number] = self.sensor_events.get(number, 0) + len(values)
+ if first:
+ self.event("sensor_event", field=number, data=describe_fields(fields))
+ else:
+ self.ignored("sensor_ignored", channel, kind, data)
+ elif channel is not None and channel == self.bluetooth_channel:
+ if kind == BLUETOOTH_PAIRING_RESPONSE:
+ self.event("bluetooth_pairing_response", already_paired=one(fields, 1), status=signed(one(fields, 2, 0)))
+ elif kind == BLUETOOTH_AUTH_DATA:
+ self.event("bluetooth_auth_data", data=describe_fields(fields))
+ else:
+ self.ignored("bluetooth_ignored", channel, kind, data)
else:
self.ignored("channel_ignored", channel, kind, data)
@@ -803,12 +920,15 @@ class ProjectionSession(Session):
raise ValueError("A fresh media epoch must start with SPS/PPS and an IDR frame")
assert self.mode is not None
fragments = 0
+ config = b""
if self.needs_keyframe:
config = codec_config(data)
if config:
fragments += self.send(self.mode.channel, AV_MEDIA_CODEC_CONFIG, config)
if not self.fragment_acks:
self.config_ack_slack += 1
+ if self.phone_frames:
+ data = wire_frame(data, drop_config=bool(config))
fragments += self.send(self.mode.channel, AV_MEDIA_WITH_TIMESTAMP, struct.pack(">Q", timestamp_us) + data)
if self.fragment_acks:
self.frame_fragments.append(fragments)
@@ -839,4 +959,5 @@ class ProjectionSession(Session):
def stats(self) -> dict:
return {"frames_sent": self.frames_sent, "frames_acked": self.acked, "pending": self.unacked,
"focus_epoch": self.focus_epoch, "focused": self.focused, "max_ack_ms": round(self.max_ack_seconds * 1000),
- "input_events": self.input_events, "touch": self.touch is not None}
+ "input_events": self.input_events, "touch": self.touch is not None,
+ "sensor_events": sum(self.sensor_events.values())}
diff --git a/openpilot/starpilot/system/android_auto/supervisor.py b/openpilot/starpilot/system/android_auto/supervisor.py
index 7fff019efc..afb9680b77 100644
--- a/openpilot/starpilot/system/android_auto/supervisor.py
+++ b/openpilot/starpilot/system/android_auto/supervisor.py
@@ -353,6 +353,7 @@ class Supervisor:
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"] = wired_blocker()
@@ -387,10 +388,15 @@ class Supervisor:
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 v1 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():
@@ -512,6 +518,8 @@ class Supervisor:
if car_type in ("builtin", "adapter"):
with self._lock:
self.config["car_type"] = car_type
+ 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)
@@ -533,6 +541,8 @@ class Supervisor:
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 v1 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')
@@ -788,8 +798,9 @@ class Supervisor:
if not self._no_aa_since:
self._no_aa_since = now
elif now - self._no_aa_since >= NO_ANDROID_AUTO_WAIT and not self._pairing_note:
- self._pairing_note = (f"{device['name'] or 'This device'} paired, but it doesn't offer wireless Android Auto. "
- "Using a wireless adapter? Choose Wireless adapter, pair the adapter here, then connect your car on the Bluetooth page.")
+ 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_android_auto")
self.log("pairing_no_android_auto", address=device["address"])
@@ -1352,12 +1363,25 @@ class Supervisor:
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")
diff --git a/openpilot/starpilot/system/android_auto/tests/fake_head_unit.py b/openpilot/starpilot/system/android_auto/tests/fake_head_unit.py
index 24b137d212..0983940f1c 100644
--- a/openpilot/starpilot/system/android_auto/tests/fake_head_unit.py
+++ b/openpilot/starpilot/system/android_auto/tests/fake_head_unit.py
@@ -63,12 +63,21 @@ def video_config(resolution: int, margin_w: int = 0, margin_h: int = 0) -> bytes
return field(1, resolution) + field(2, 2) + field(3, margin_w) + field(4, margin_h) + field(5, 160)
-def discovery_response(video_channel: int = 3, input_channel: int = 1) -> bytes:
+SENSOR_CHANNEL, BLUETOOTH_CHANNEL = 7, 8
+
+
+def discovery_response(video_channel: int = 3, input_channel: int = 1, *, phone_services: bool = False) -> bytes:
+ """``phone_services`` adds the car-data and Bluetooth channels (sensor types one per entry, pairing method packed)."""
av = field(1, 3) + field(4, video_config(1)) + field(4, video_config(2, 0, 240))
video = field(1, video_channel) + field(3, av)
audio = field(1, 4) + field(3, field(1, 1))
touch = field(1, input_channel) + field(4, field(1, 84) + field(1, 4) + field(2, field(1, 1280) + field(2, 720)))
- return field(1, video) + field(1, audio) + field(1, touch) + field(2, "Honda") + field(3, "Civic")
+ extra = b""
+ if phone_services:
+ sensors = b"".join(field(1, field(1, kind)) for kind in (1, 3, 7, 8, 10, 13, 21))
+ extra = (field(1, field(1, SENSOR_CHANNEL) + field(2, sensors)) +
+ field(1, field(1, BLUETOOTH_CHANNEL) + field(6, field(1, "F8:36:9B:0A:7D:C8") + field(2, bytes([2])))))
+ return field(1, video) + field(1, audio) + field(1, touch) + extra + field(2, "Honda") + field(3, "Civic")
class FakeHeadUnit:
@@ -76,8 +85,13 @@ class FakeHeadUnit:
def __init__(self, identity: dict[str, Path], *, window: int = 4, reject_auth: bool = False, require_client_cert: bool = True,
unsolicited_focus: bool = False, version: tuple[int, int] = (1, 7), ack_codec_config: bool | int = True,
- connect_to: tuple[str, int] | None = None, ack_fragments: bool = False):
+ connect_to: tuple[str, int] | None = None, ack_fragments: bool = False,
+ phone_services: bool = False):
+ self.phone_services = phone_services
+ self.sensor_requests: list[int] = []
+ self.pairing_requests: list[tuple[str, int]] = []
self.unsolicited_focus = unsolicited_focus
+ self.opened: list[int] = []
self.connect_to = connect_to
self.ack_fragments = ack_fragments
self.fragments = 0
@@ -218,7 +232,7 @@ class FakeHeadUnit:
fields = parse_fields(data) if kind not in (0, 1) else {}
if channel == 0 and kind == 5:
self.device_name = one(fields, 4, b"").decode()
- self._send(0, 6, discovery_response())
+ self._send(0, 6, discovery_response(phone_services=self.phone_services))
elif channel == 0 and kind == 11:
self._send(0, 12, data)
elif channel == 0 and kind == 15:
@@ -226,7 +240,17 @@ class FakeHeadUnit:
self.shutdown_received.set()
return
elif kind == 7:
+ self.opened.append(channel)
self._send(channel, 8, field(1, 0), control=True)
+ elif self.phone_services and channel == SENSOR_CHANNEL and kind == 0x8001:
+ self.sensor_requests.append(int(one(fields, 1, 0) or 0))
+ self._send(SENSOR_CHANNEL, 0x8002, field(1, 0))
+ if one(fields, 1) == 13:
+ self._send(SENSOR_CHANNEL, 0x8003, field(13, field(1, 0))) # driving status: unrestricted
+ elif self.phone_services and channel == BLUETOOTH_CHANNEL and kind == 0x8001:
+ address = one(fields, 1, b"")
+ self.pairing_requests.append((address.decode() if isinstance(address, bytes) else "", int(one(fields, 2, 0) or 0)))
+ self._send(BLUETOOTH_CHANNEL, 0x8002, field(1, 1) + field(2, 0)) # not paired
elif channel == 3 and kind == 0x8000:
self._send(3, 0x8003, field(1, 2) + field(2, self.window) + field(3, 1))
if self.unsolicited_focus: # like the DHU: grant focus before the phone asks
diff --git a/openpilot/starpilot/system/android_auto/tests/test_android_auto.py b/openpilot/starpilot/system/android_auto/tests/test_android_auto.py
index 7bb601cd2a..c5bff76ef9 100644
--- a/openpilot/starpilot/system/android_auto/tests/test_android_auto.py
+++ b/openpilot/starpilot/system/android_auto/tests/test_android_auto.py
@@ -12,8 +12,10 @@ import pytest
from openpilot.starpilot.system.android_auto import bootstrap as bs
from openpilot.starpilot.system.android_auto import hfp, sdp
from openpilot.starpilot.system.android_auto.frame_source import FrameConsumer, FrameProducer, FrameRequest, fit_content
-from openpilot.starpilot.system.android_auto.session import (AuthenticationRejected, ProjectionSession, VideoMode, choose_video_mode)
-from openpilot.starpilot.system.android_auto.tests.fake_head_unit import FakeHeadUnit, make_identity, rfcomm_head_unit
+from openpilot.starpilot.system.android_auto.session import (AuthenticationRejected, ProjectionSession, START_CODE, VideoMode, choose_video_mode,
+ wire_frame)
+from openpilot.starpilot.system.android_auto.tests.fake_head_unit import (BLUETOOTH_CHANNEL, SENSOR_CHANNEL, FakeHeadUnit, make_identity,
+ rfcomm_head_unit)
from openpilot.starpilot.system.android_auto.wire import field, one, parse_fields, signed
@@ -569,6 +571,86 @@ def test_session_sends_codec_config_before_each_epoch(identity, ack_codec_config
assert hu.codec_configs == [(1, sps_pps, 0), (2, sps_pps, 4)] # one per epoch, before its keyframe
+def test_wire_frame_drops_aud_and_optionally_config():
+ aud, sps, pps, idr = b"\x00\x00\x00\x01\x09\xf0", b"\x00\x00\x00\x01\x67\x42", b"\x00\x00\x00\x01\x68\xce", b"\x00\x00\x00\x01\x65\x88"
+ assert wire_frame(aud + sps + pps + idr, drop_config=True) == idr
+ assert wire_frame(aud + sps + pps + idr, drop_config=False) == sps + pps + idr
+ assert wire_frame(b"junk", drop_config=True) == b"junk"
+
+
+def test_session_sends_picture_units_only_when_asked(identity):
+ hu = FakeHeadUnit(identity)
+ session = connect(hu, identity)
+ session.phone_frames = True
+ session.authenticate()
+ session.start("StarPilot", "comma.ai")
+ pump_until(session, lambda: session.focused)
+ session.send_frame(b"\x00\x00\x00\x01\x09\xf0\x00\x00\x00\x01\x67\x42\x00\x00\x00\x01\x68\xce\x00\x00\x00\x01\x65\x88", 1, keyframe=True)
+ pump_until(session, lambda: session.unacked == 0)
+ session.shutdown()
+ session.peer.close()
+ hu.thread.join(5)
+ assert hu.error is None, hu.error
+ for _, frame in hu.frames:
+ assert frame and all(frame[m.end()] & 31 not in (7, 8, 9) for m in START_CODE.finditer(frame))
+
+
+@pytest.mark.parametrize("max_window, offered, used", [(2, 30, 2), (30, 30, 30), (30, 100, 30), (30, 4, 4)])
+def test_session_streams_at_the_window_the_profile_allows(identity, max_window, offered, used):
+ hu = FakeHeadUnit(identity, window=offered)
+ session = connect(hu, identity)
+ session.max_window = max_window
+ session.authenticate()
+ session.start("StarPilot", "comma.ai")
+ assert session.window == used
+ session.shutdown()
+ session.peer.close()
+ hu.thread.join(5)
+
+
+def test_session_opens_car_data_and_bluetooth_like_a_phone(identity):
+ hu = FakeHeadUnit(identity, phone_services=True)
+ session = connect(hu, identity)
+ session.phone_services = True
+ session.phone_address = "39:90:21:64:07:00"
+ session.authenticate()
+ session.start("StarPilot", "comma.ai")
+ pump_until(session, lambda: session.focused and session.sensor_events)
+ session.shutdown()
+ session.peer.close()
+ hu.thread.join(5)
+ assert hu.error is None
+ assert {SENSOR_CHANNEL, BLUETOOTH_CHANNEL} <= set(hu.opened)
+ assert hu.sensor_requests == [13, 10] # driving status and night only; never location
+ assert hu.pairing_requests == [("39:90:21:64:07:00", 2)]
+ assert session.stats()["sensor_events"] == 1
+
+
+def test_session_leaves_car_data_alone_by_default(identity):
+ hu = FakeHeadUnit(identity, phone_services=True)
+ session = connect(hu, identity)
+ session.authenticate()
+ session.start("StarPilot", "comma.ai")
+ pump_until(session, lambda: session.focused)
+ session.shutdown()
+ session.peer.close()
+ hu.thread.join(5)
+ assert hu.error is None and SENSOR_CHANNEL not in hu.opened and BLUETOOTH_CHANNEL not in hu.opened and hu.pairing_requests == []
+
+
+def test_session_projects_without_a_bluetooth_address(identity):
+ hu = FakeHeadUnit(identity, phone_services=True)
+ session = connect(hu, identity)
+ session.phone_services = True
+ session.authenticate()
+ session.start("StarPilot", "comma.ai")
+ pump_until(session, lambda: session.focused)
+ session.shutdown()
+ session.peer.close()
+ hu.thread.join(5)
+ assert hu.error is None and BLUETOOTH_CHANNEL in hu.opened and hu.pairing_requests == []
+
+
def test_session_keeps_unsolicited_focus_grant(identity):
hu = FakeHeadUnit(identity, unsolicited_focus=True)
session = connect(hu, identity)
diff --git a/openpilot/starpilot/system/android_auto/tests/test_car_profiles.py b/openpilot/starpilot/system/android_auto/tests/test_car_profiles.py
index 64952f9a8a..b39e8731d2 100644
--- a/openpilot/starpilot/system/android_auto/tests/test_car_profiles.py
+++ b/openpilot/starpilot/system/android_auto/tests/test_car_profiles.py
@@ -36,6 +36,36 @@ def config(**values):
return {"receiver_address": ADAPTER, "car_profile_override": "", "car_fingerprint": {}, **values}
+def test_aawireless_tuning_applies_only_when_chosen():
+ chosen = config(car_profile_override="aawireless", receiver_name="AAWireless-4F2A")
+ assert car_profiles.resolve(chosen) == ("aawireless", "override")
+ assert car_profiles.tuning(chosen) == {"connect_delay_s": 0.0, "max_window": 30, "phone_services": True, "phone_frames": True}
+ assert car_profiles.summary(chosen)["car_profile_label"] == "AAWireless v1"
+ for other in (config(receiver_name="AAWireless-4F2A"), config(car_profile_override="default")):
+ assert car_profiles.tuning(other) == car_profiles.DEFAULTS
+
+
+def test_leaving_the_adapter_setup_clears_the_adapter_model(sup):
+ sup.config["car_type"] = "adapter"
+ sup.set_car_profile("aawireless")
+ sup.set_car_type("builtin")
+ assert sup.config["car_profile_override"] == ""
+
+
+def test_an_adapter_setup_must_choose_its_model_before_pairing_or_starting(sup):
+ sup.config["car_type"] = "adapter"
+ assert sup.status()["adapter_choice_needed"] is True
+ with pytest.raises(RuntimeError, match="adapter model"):
+ sup.prepare_pairing()
+ with pytest.raises(RuntimeError, match="adapter model"):
+ sup.start()
+ sup.set_car_profile("default")
+ assert sup.status()["adapter_choice_needed"] is False
+ sup.config["car_type"] = "builtin"
+ sup.config["car_profile_override"] = ""
+ assert sup.status()["adapter_choice_needed"] is False
+
+
def test_every_car_is_standard_without_rules():
assert car_profiles.resolve(config(car_fingerprint={"address": ADAPTER, "car_make": "Hyundai"})) == ("default", "standard")
assert car_profiles.tuning(config()) == car_profiles.DEFAULTS
@@ -51,7 +81,7 @@ def test_fingerprint_picks_a_profile_only_for_the_chosen_receiver(palisade):
def test_tuning_keeps_only_known_keys(palisade):
tuned = car_profiles.tuning(config(car_profile_override="palisade"))
- assert tuned == {"connect_delay_s": 10.0}
+ assert tuned == {**car_profiles.DEFAULTS, "connect_delay_s": 10.0}
def test_override_pins_a_profile_or_forces_standard(palisade):
@@ -171,3 +201,20 @@ def test_the_new_result_has_a_bugsink_report():
attempt.step("status", state="connected", prompt=False, approved=True)
event = br.pairing_event({**attempt.record, "result": "paired_not_selected", "duration_s": 5.0})
assert event["fingerprint"] == ["android-auto-pairing", "paired_not_selected"] and event["level"] == "warning"
+
+
+@pytest.mark.parametrize("stored, kept", [({"car_type": "adapter"}, True), ({"car_type": "", "companion_address": "AA:BB:CC:DD:EE:02"}, True),
+ ({"car_type": "builtin", "companion_address": "AA:BB:CC:DD:EE:02"}, False), ({}, False),
+ ({"car_type": "adapter", "car_profile_override": "aawireless"}, True)])
+def test_adapter_owners_from_before_the_model_choice_keep_connecting(tmp_path, stored, kept):
+ path = tmp_path / "config.json"
+ path.write_text(json.dumps({"config_version": 6, **stored}))
+ config = identity_store.load_config(path)
+ assert (config["car_profile_override"] != "") is kept
+ assert config["car_profile_override"] == stored.get("car_profile_override", "default" if kept else "")
+
+
+def test_a_new_adapter_setup_is_not_migrated(tmp_path):
+ path = tmp_path / "config.json"
+ path.write_text(json.dumps({"config_version": identity_store.CONFIG_VERSION, "car_type": "adapter"}))
+ assert identity_store.load_config(path)["car_profile_override"] == ""
From 2635773393a3889fa797ae4da750fc81b369d23e Mon Sep 17 00:00:00 2001
From: dirwin31 <83434411+dirwin31@users.noreply.github.com>
Date: Mon, 26 Oct 2026 13:00:33 -0700
Subject: [PATCH 05/23] Android Auto: Send failed session traces to Bugsink
---
.../system/android_auto/bugsink_report.py | 44 ++++++++++++++++++-
.../android_auto/tests/test_bugsink_report.py | 38 ++++++++++++++++
2 files changed, 81 insertions(+), 1 deletion(-)
diff --git a/openpilot/starpilot/system/android_auto/bugsink_report.py b/openpilot/starpilot/system/android_auto/bugsink_report.py
index 61ae72b5d2..d385318085 100644
--- a/openpilot/starpilot/system/android_auto/bugsink_report.py
+++ b/openpilot/starpilot/system/android_auto/bugsink_report.py
@@ -25,6 +25,10 @@ LOG_SETTLE_S = 120 # a log written this recently may still be a live ses
MAX_SESSIONS = 20 # per drive report; the rest go in the next one
MAX_FAILURES = 5 # distinct failures detailed per drive report
MAX_BREADCRUMBS = 100
+TRACE_QUIET = frozenset({"nav_guidance_sent", "nav_focus", "nav_focus_requested", "nav_status_state", "media_key", "media_audio_state",
+ "media_audio_focus", "media_status_state", "hfp_command", "video_focus"})
+TRACE_SESSION_BYTES = 40 << 10 # per failed session; the oldest events go first
+TRACE_TOTAL_BYTES = 160 << 10 # all traces in one report, well under the sender's event cap
FINISHED_EVENT = '"event": "session_stop"'
OUTCOME_RANK = ("streamed", "recovered", "stopped", "dropped", "failed")
@@ -92,6 +96,37 @@ def video_summary(events: list[dict]) -> dict:
return {key: value for key, value in summary.items() if value is not None}
+def _trim(value):
+ if isinstance(value, str):
+ return value[:300]
+ if isinstance(value, (dict, list)) and len(json.dumps(value, default=str)) > 600:
+ return json.dumps(value, default=str)[:600]
+ return value
+
+
+def trace_events(events: list[dict]) -> list[dict]:
+ """The session log without routine media and navigation chatter, each event stamped with seconds since the start."""
+ trace = []
+ for event in events:
+ if event["event"] in TRACE_QUIET:
+ continue
+ values = {key: _trim(value) for key, value in event.items() if key not in ("t", "mono_s", "event")}
+ trace.append({"at_s": elapsed(events[0], event), "event": event["event"], **values})
+ return trace
+
+
+def bounded_trace(trace: list[dict], limit: int) -> dict:
+ """The newest events that fit in ``limit`` bytes of JSON, with how many older ones were left out."""
+ kept, size = [], 0
+ for event in reversed(trace):
+ size += len(json.dumps(event, default=str)) + 2
+ if size > limit:
+ break
+ kept.append(event)
+ kept.reverse()
+ return {"events": kept, "omitted": len(trace) - len(kept)}
+
+
def session_record(path: Path) -> dict:
"""One finished session log reduced to its outcome, stage timings, video and the car it talked to."""
from openpilot.starpilot.system.android_auto import compat_report
@@ -144,6 +179,7 @@ def session_record(path: Path) -> dict:
"head_unit": {key: car[key] for key in ("head_unit_make", "head_unit_model", "head_unit_software_version",
"car_make", "car_model", "car_year") if car.get(key)},
"_report": report,
+ "_trace": trace_events(events),
"_names": [car.get("bluetooth_name", ""), car.get("display_name", "")],
}
@@ -309,6 +345,11 @@ def drive_event(sessions: list[dict]) -> dict:
message += "".join(f"\n\n--- Session {session['seq']} ---\n" + sanitize(session_timeline(session), session.get("_names", ()))
for session in failed)
latest = failures[0] if failures else {}
+ traces = {}
+ for session in failed[-4:]:
+ traces[f"android_auto_trace_session_{session['seq']}"] = sanitize(
+ bounded_trace(session.get("_trace", []), min(TRACE_SESSION_BYTES, TRACE_TOTAL_BYTES // max(1, len(failed[-4:])))),
+ session.get("_names", ()))
return {
"message": message,
"level": {"streamed": "info", "recovered": "info", "failed": "error"}.get(summary["outcome"], "warning"),
@@ -324,7 +365,8 @@ def drive_event(sessions: list[dict]) -> dict:
**{f"android_auto_session_{index + 1}": _public(session) for index, session in enumerate(sessions)},
**{f"android_auto_failure_{index + 1}": {key: value for key, value in failure.items() if key != "summary"}
for index, failure in enumerate(failures)},
- **{f"android_auto_compat_report_session_{session['seq']}": {"text": compat_text(session)} for session in failed}},
+ **{f"android_auto_compat_report_session_{session['seq']}": {"text": compat_text(session)} for session in failed},
+ **traces},
}
diff --git a/openpilot/starpilot/system/android_auto/tests/test_bugsink_report.py b/openpilot/starpilot/system/android_auto/tests/test_bugsink_report.py
index 87bc9e6a18..95898b6cdd 100644
--- a/openpilot/starpilot/system/android_auto/tests/test_bugsink_report.py
+++ b/openpilot/starpilot/system/android_auto/tests/test_bugsink_report.py
@@ -325,3 +325,41 @@ def test_supervisor_records_build_at_session_start(monkeypatch):
sup._release_phone = lambda: None # ty: ignore[invalid-assignment] # test double
sup._run(1)
assert records[0]["software"] == {"commit": "session-build", "branch": "session-branch"}
+
+
+HANDSHAKE_FAILED = [
+ (0, 0, "session_start", {"receiver": "Honda HFL", "trigger": "auto"}),
+ (0, 1, "stage", {"state": "rfcomm"}),
+ (0, 2, "bootstrap_tx", {"message": "WifiVersionRequest", "bytes": 4}),
+ (0, 3, "hfp_command", {"command": "AT+CIND"}),
+ (0, 3, "nav_guidance_sent", {}),
+ (0, 4, "bootstrap_rx", {"message": "WifiVersionResponse", "bytes": 6}),
+ (0, 5, "sdp_retry", {"error": "[Errno 112] Host is down to AA:BB:CC:DD:EE:FF", "settle": 1.5}),
+ (0, 25, "attempt_failed", {"stage": "wifi_start", "error": "head unit did not answer in time", "kind": "BootstrapTimeout"}),
+]
+
+
+def test_failed_sessions_carry_a_scrubbed_handshake_trace(tmp_path):
+ event = br.drive_event([br.session_record(write_session(tmp_path, 1, HANDSHAKE_FAILED))])
+ trace = event["contexts"]["android_auto_trace_session_1"]
+ names = [item["event"] for item in trace["events"]]
+ assert names[:3] == ["session_start", "stage", "bootstrap_tx"] and "bootstrap_rx" in names and "sdp_retry" in names
+ assert "hfp_command" not in names and "nav_guidance_sent" not in names
+ assert trace["omitted"] == 0 and trace["events"][2]["at_s"] == 2.0
+ text = json.dumps(event)
+ assert "AA:BB:CC:DD:EE:FF" not in text and "Honda HFL" not in text
+
+
+def test_trace_keeps_the_newest_events_within_its_budget(tmp_path):
+ noisy = [(0, 0, "session_start", {})] + [(i // 60, i % 60, "sdp_retry", {"error": "x" * 250, "n": i}) for i in range(1, 400)]
+ noisy.append((9, 0, "attempt_failed", {"stage": "discovering", "error": "gone", "kind": "RuntimeError"}))
+ event = br.drive_event([br.session_record(write_session(tmp_path, 1, noisy))])
+ trace = event["contexts"]["android_auto_trace_session_1"]
+ assert trace["omitted"] > 0 and [e["event"] for e in trace["events"]][-2:] == ["attempt_failed", "session_stop"]
+ assert len(json.dumps(trace)) <= br.TRACE_SESSION_BYTES + 1024
+ assert len(json.dumps(event)) < 400 << 10
+
+
+def test_clean_drives_send_no_trace(tmp_path):
+ event = br.drive_event([br.session_record(write_session(tmp_path, 1, STREAMED))])
+ assert not any(key.startswith("android_auto_trace_session_") for key in event["contexts"])
From 2c80f161a257b75334e99b88f0bcebe6c804efd9 Mon Sep 17 00:00:00 2001
From: dirwin31 <83434411+dirwin31@users.noreply.github.com>
Date: Mon, 26 Oct 2026 13:00:34 -0700
Subject: [PATCH 06/23] Android Auto: Retry Bluetooth connects that report
unimplemented
---
.../system/android_auto/bt_sockets.py | 15 ++++++++++-----
.../system/android_auto/supervisor.py | 13 +++++++++++--
.../android_auto/tests/test_bt_sockets.py | 19 +++++++++++++++----
.../tests/test_car_never_answered.py | 16 ++++++++++++++++
4 files changed, 52 insertions(+), 11 deletions(-)
create mode 100644 openpilot/starpilot/system/android_auto/tests/test_car_never_answered.py
diff --git a/openpilot/starpilot/system/android_auto/bt_sockets.py b/openpilot/starpilot/system/android_auto/bt_sockets.py
index deb078b8cb..760ba2983a 100644
--- a/openpilot/starpilot/system/android_auto/bt_sockets.py
+++ b/openpilot/starpilot/system/android_auto/bt_sockets.py
@@ -21,6 +21,8 @@ import time
AF_BLUETOOTH = 31
BTPROTO_L2CAP = 0
BTPROTO_RFCOMM = 3
+ENOSYS_RETRIES = 2 # some kernels report ENOSYS on a connect that succeeds moments later
+ENOSYS_RETRY_S = 0.5
ADDRESS_RE = re.compile(r"^(?:[0-9A-F]{2}:){5}[0-9A-F]{2}$")
@@ -88,11 +90,14 @@ def _libc_connect(proto: int, kind: int, address: str, port: int, timeout: float
def _connect(proto: int, kind: int, address: str, port: int, timeout: float) -> socket.socket:
address = normalize_address(address)
if hasattr(socket, "AF_BLUETOOTH") and hasattr(socket, "BTPROTO_RFCOMM"):
- try:
- return _native(proto, kind, address, port, timeout)
- except OSError as exc:
- if exc.errno != errno.ENOSYS:
- raise
+ for attempt in range(ENOSYS_RETRIES + 1):
+ try:
+ return _native(proto, kind, address, port, timeout)
+ except OSError as exc:
+ if exc.errno != errno.ENOSYS:
+ raise
+ if attempt < ENOSYS_RETRIES:
+ time.sleep(ENOSYS_RETRY_S)
return _libc_connect(proto, kind, address, port, timeout)
diff --git a/openpilot/starpilot/system/android_auto/supervisor.py b/openpilot/starpilot/system/android_auto/supervisor.py
index afb9680b77..9d46f1549f 100644
--- a/openpilot/starpilot/system/android_auto/supervisor.py
+++ b/openpilot/starpilot/system/android_auto/supervisor.py
@@ -16,6 +16,7 @@ on Stop. Nothing here runs as root or touches vehicle control.
from __future__ import annotations
+import errno
import ipaddress
import json
import math
@@ -152,6 +153,14 @@ def _wifi_dbm(interface: str = "wlan0") -> int | None:
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:]
@@ -1149,8 +1158,8 @@ class Supervisor:
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:
- if source == "cache" and not self._stop.is_set() and self._status["last_stage"] in ("rfcomm", "wifi_start"):
+ 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
diff --git a/openpilot/starpilot/system/android_auto/tests/test_bt_sockets.py b/openpilot/starpilot/system/android_auto/tests/test_bt_sockets.py
index 3c8af690ba..54aadc2afe 100644
--- a/openpilot/starpilot/system/android_auto/tests/test_bt_sockets.py
+++ b/openpilot/starpilot/system/android_auto/tests/test_bt_sockets.py
@@ -24,12 +24,21 @@ class TestBluetoothSocketFallback(unittest.TestCase):
def test_unimplemented_connect_uses_same_protocol_and_timeout(self):
with self.capabilities(), patch.object(bt_sockets, '_native', side_effect=OSError(errno.ENOSYS, 'Function not implemented')), \
- patch.object(bt_sockets, '_libc_connect') as libc:
+ patch.object(bt_sockets.time, 'sleep'), patch.object(bt_sockets, '_libc_connect') as libc:
for connect, port in self.routes():
self.assertIs(connect(self.ADDRESS.lower(), port, 4), libc.return_value)
self.assertEqual(libc.call_args_list[0].args, (3, socket.SOCK_STREAM, self.ADDRESS, 1, 4))
self.assertEqual(libc.call_args_list[1].args, (0, socket.SOCK_SEQPACKET, self.ADDRESS, 1, 4))
+ def test_unimplemented_connect_retries_natively_before_falling_back(self):
+ ok = Mock()
+ with self.capabilities(), patch.object(bt_sockets, '_native', side_effect=[OSError(errno.ENOSYS, 'x'), ok]) as native, \
+ patch.object(bt_sockets.time, 'sleep') as sleep, patch.object(bt_sockets, '_libc_connect') as libc:
+ self.assertIs(bt_sockets.connect_rfcomm(self.ADDRESS, 1), ok)
+ self.assertEqual(native.call_count, 2)
+ sleep.assert_called_once_with(bt_sockets.ENOSYS_RETRY_S)
+ libc.assert_not_called()
+
def test_real_connection_failures_do_not_retry(self):
for code in (errno.EHOSTDOWN, errno.ECONNREFUSED, errno.ETIMEDOUT, errno.EPERM, errno.EAFNOSUPPORT):
with self.subTest(errno=code), self.capabilities(), \
@@ -43,19 +52,21 @@ class TestBluetoothSocketFallback(unittest.TestCase):
def test_fallback_failure_propagates_without_loop(self):
with self.capabilities(), patch.object(bt_sockets, '_native', side_effect=OSError(errno.ENOSYS, 'unimplemented')) as native, \
+ patch.object(bt_sockets.time, 'sleep'), \
patch.object(bt_sockets, '_libc_connect', side_effect=OSError(errno.EHOSTDOWN, 'host down')) as libc:
with self.assertRaises(OSError) as caught:
bt_sockets.connect_rfcomm(self.ADDRESS, 1)
self.assertEqual(caught.exception.errno, errno.EHOSTDOWN)
- native.assert_called_once()
+ self.assertEqual(native.call_count, bt_sockets.ENOSYS_RETRIES + 1)
libc.assert_called_once()
def test_native_failure_closes_original_descriptor(self):
original = Mock()
original.connect.side_effect = OSError(errno.ENOSYS, 'unimplemented')
- with self.capabilities(), patch.object(socket, 'socket', return_value=original), patch.object(bt_sockets, '_libc_connect') as libc:
+ with self.capabilities(), patch.object(socket, 'socket', return_value=original), patch.object(bt_sockets.time, 'sleep'), \
+ patch.object(bt_sockets, '_libc_connect') as libc:
self.assertIs(bt_sockets.connect_rfcomm(self.ADDRESS, 1), libc.return_value)
- original.close.assert_called_once()
+ self.assertEqual(original.close.call_count, bt_sockets.ENOSYS_RETRIES + 1)
def test_missing_python_capability_uses_libc(self):
with patch.object(bt_sockets, 'socket') as python_socket, patch.object(bt_sockets, '_libc_connect') as libc:
diff --git a/openpilot/starpilot/system/android_auto/tests/test_car_never_answered.py b/openpilot/starpilot/system/android_auto/tests/test_car_never_answered.py
new file mode 100644
index 0000000000..9c4bb9ee31
--- /dev/null
+++ b/openpilot/starpilot/system/android_auto/tests/test_car_never_answered.py
@@ -0,0 +1,16 @@
+import errno
+import unittest
+
+from openpilot.starpilot.system.android_auto.bootstrap import BootstrapTimeout
+from openpilot.starpilot.system.android_auto.supervisor import car_never_answered
+
+
+class TestCarNeverAnswered(unittest.TestCase):
+ def test_local_bluetooth_failures_keep_the_cached_channel(self):
+ for code in (errno.ENOSYS, errno.EAFNOSUPPORT, errno.EPROTONOSUPPORT):
+ self.assertFalse(car_never_answered(OSError(code, 'local')))
+
+ def test_car_side_failures_drop_the_cached_channel(self):
+ for error in (OSError(errno.ECONNREFUSED, 'refused'), OSError(errno.EHOSTDOWN, 'down'), TimeoutError(),
+ BootstrapTimeout('wifi_start', 'head unit did not answer in time')):
+ self.assertTrue(car_never_answered(error))
From 8d9115f9e712d35e48e916fb002de725f217710e Mon Sep 17 00:00:00 2001
From: dirwin31 <83434411+dirwin31@users.noreply.github.com>
Date: Mon, 26 Oct 2026 13:00:36 -0700
Subject: [PATCH 07/23] Android Auto: Allow choosing wired AA with Jetlink on
---
.../galaxy/tests/test_android_auto.mjs | 9 ++++++++
.../starpilot/galaxy/web/js/android-auto.js | 6 +++++
.../system/android_auto/supervisor.py | 22 +++++++++++++------
.../android_auto/tests/test_android_auto.py | 21 ++++++++++++++++++
4 files changed, 51 insertions(+), 7 deletions(-)
diff --git a/openpilot/starpilot/galaxy/tests/test_android_auto.mjs b/openpilot/starpilot/galaxy/tests/test_android_auto.mjs
index 9b71967c4e..20052e0479 100644
--- a/openpilot/starpilot/galaxy/tests/test_android_auto.mjs
+++ b/openpilot/starpilot/galaxy/tests/test_android_auto.mjs
@@ -178,6 +178,15 @@ assert.equal(wiredSetup.wiredDisabled, true, "another USB owner blocks enabling
wiredSetup.runtime.connection = "wired"
assert.equal(wiredSetup.wiredDisabled, false, "the existing wired connection can still be disabled")
+// Jetlink only delays wired: it can be chosen, with a shortcut to switch Jetlink off.
+wiredSetup.runtime = { connection: "wireless", running: false, wired_busy: "Turn off Jetlink to use wired Android Auto" }
+assert.equal(wiredSetup.wiredDisabled, false, "Jetlink does not stop wired being chosen")
+assert.equal(wiredSetup.wiredJetlinkBusy, false, "no notice until wired is chosen")
+wiredSetup.runtime.connection = "wired"
+assert.equal(wiredSetup.wiredJetlinkBusy, true)
+assert.match(wiredSetup.connectReason, /Jetlink/)
+assert.match(AndroidAutoPage.template, /href="#\/manage_models"/)
+
const uploadPage = page({ ...setup, enabled: false })
const uploadCalls = []
uploadPage.feed = {
diff --git a/openpilot/starpilot/galaxy/web/js/android-auto.js b/openpilot/starpilot/galaxy/web/js/android-auto.js
index 20da1dfbee..ede2b08d2e 100644
--- a/openpilot/starpilot/galaxy/web/js/android-auto.js
+++ b/openpilot/starpilot/galaxy/web/js/android-auto.js
@@ -47,6 +47,8 @@ const ADDRESS = /^(?:[0-9A-F]{2}:){5}[0-9A-F]{2}$/
const ADAPTER_MODELS = { aawireless: "AAWireless v1", default: "Other" }
const HEAD_UNIT_APPS = { headunitpad: "HeadunitPad", openheadunit: "Open Headunit" }
const HOTSPOT_LINK = `Galaxy Wi-Fi Hotspot`
+const WIRED_JETLINK = `
Jetlink is using the USB-C port, so wired Android Auto can’t connect yet. Turn off Jetlink, then reconnect.
+ Open Jetlink settings
`
const APP_SETUP = `
HeadunitPad setup (iPad)
@@ -520,6 +522,7 @@ export const AndroidAutoPage = {
appDisabled() {
return !this.setup?.enabled || !this.setup.parked || !!this.pairing?.active || !!this.runtime?.running
},
+ wiredJetlinkBusy() { return this.wired && !!this.runtime?.wired_busy },
wiredDisabled() {
return !this.setup?.enabled || !this.setup.parked || !!this.pairing?.active || !!this.runtime?.running ||
(!!this.runtime?.wired_blocked && !this.wired)
@@ -590,6 +593,7 @@ export const AndroidAutoPage = {
if (!this.setup.serviceReady) return "Waiting for the Android Auto service to start."
if (this.pairing?.active) return "Finish or cancel pairing first."
if (this.adapterSetup && this.runtime?.car_profile_source !== "override") return "Choose your adapter model in step 2 first."
+ if (this.wiredJetlinkBusy) return "Turn off Jetlink first."
return ""
},
waitingForCar() {
@@ -748,6 +752,7 @@ export const AndroidAutoPage = {
:save-value="(index, value) => control('auto_connect', { enabled: value === 'On' })" />
+ ${WIRED_JETLINK}
${APP_SETUP}
@@ -811,6 +816,7 @@ export const AndroidAutoPage = {
+ ${WIRED_JETLINK}
${APP_SETUP}
diff --git a/openpilot/starpilot/system/android_auto/supervisor.py b/openpilot/starpilot/system/android_auto/supervisor.py
index 9d46f1549f..1aec09864a 100644
--- a/openpilot/starpilot/system/android_auto/supervisor.py
+++ b/openpilot/starpilot/system/android_auto/supervisor.py
@@ -95,11 +95,8 @@ def _is_onroad() -> bool:
return False
-def wired_blocker() -> str:
- """Why wired USB must not take the comma's USB-C port, or "" when it is free.
-
- Chestnut and Jetlink's computer link both use that port.
- """
+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")
@@ -107,6 +104,11 @@ def wired_blocker() -> str:
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():
@@ -116,6 +118,11 @@ def wired_blocker() -> str:
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
@@ -365,7 +372,8 @@ class Supervisor:
status["adapter_choice_needed"] = self._adapter_choice_needed()
status["configured_view"] = self.config["view"]
status["connection"] = self.config["connection"]
- status["wired_blocked"] = wired_blocker()
+ 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"])
@@ -499,7 +507,7 @@ class Supervisor:
"""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 := wired_blocker()):
+ if connection == "wired" and (reason := chestnut_blocker()):
raise RuntimeError(reason)
with self._lock:
if self._thread is not None and self._thread.is_alive():
diff --git a/openpilot/starpilot/system/android_auto/tests/test_android_auto.py b/openpilot/starpilot/system/android_auto/tests/test_android_auto.py
index c5bff76ef9..cc1ee0d6f6 100644
--- a/openpilot/starpilot/system/android_auto/tests/test_android_auto.py
+++ b/openpilot/starpilot/system/android_auto/tests/test_android_auto.py
@@ -1238,3 +1238,24 @@ def test_supervisor_retries_wifi_recovery_while_idle(monkeypatch):
sup._session_alive = lambda: True # ty: ignore[invalid-assignment] # test double
sup.recover_wifi(2000) # never while a session owns wlan0
assert len(calls) == 2
+
+
+def test_jetlink_delays_wired_but_does_not_stop_choosing_it(identity, tmp_path, monkeypatch):
+ from openpilot.starpilot.system.android_auto import supervisor as supervisor_module
+ sup, _ = make_supervisor(identity, tmp_path, monkeypatch, lambda *a, **k: None)
+ monkeypatch.setattr(supervisor_module, "jetlink_blocker", lambda: "Turn off Jetlink to use wired Android Auto")
+ monkeypatch.setattr(supervisor_module, "chestnut_blocker", lambda: "")
+ sup.set_connection("wired")
+ status = sup.status()
+ assert sup.config["connection"] == "wired" and status["wired_blocked"] == "" and "Jetlink" in status["wired_busy"]
+ with pytest.raises(RuntimeError, match="Turn off Jetlink"):
+ sup.start()
+
+
+def test_chestnut_still_stops_choosing_wired(identity, tmp_path, monkeypatch):
+ from openpilot.starpilot.system.android_auto import supervisor as supervisor_module
+ sup, _ = make_supervisor(identity, tmp_path, monkeypatch, lambda *a, **k: None)
+ monkeypatch.setattr(supervisor_module, "jetlink_blocker", lambda: "")
+ monkeypatch.setattr(supervisor_module, "chestnut_blocker", lambda: "Wired Android Auto is unavailable while Chestnut is connected")
+ with pytest.raises(RuntimeError, match="Chestnut"):
+ sup.set_connection("wired")
From 6b09da86761cf9a4087353de313886f12d0c23db Mon Sep 17 00:00:00 2001
From: dirwin31 <83434411+dirwin31@users.noreply.github.com>
Date: Mon, 26 Oct 2026 13:00:38 -0700
Subject: [PATCH 08/23] Android Auto: apply workflow fixes
Split Android Auto changes from 08fb4c3001fe95dbbe784af54e09ecc33043a8bd.
---
openpilot/starpilot/system/android_auto/car_profiles.py | 6 +++---
openpilot/starpilot/system/android_auto/supervisor.py | 2 +-
.../system/android_auto/tests/test_car_profiles.py | 2 +-
3 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/openpilot/starpilot/system/android_auto/car_profiles.py b/openpilot/starpilot/system/android_auto/car_profiles.py
index 97c0809812..5fc550b776 100644
--- a/openpilot/starpilot/system/android_auto/car_profiles.py
+++ b/openpilot/starpilot/system/android_auto/car_profiles.py
@@ -1,7 +1,7 @@
"""Per-car tuning, picked by fingerprint so nobody has to choose their car from a list.
-A car is recognised from what its head unit reported on a previous connection. Cars without a rule
-keep the standard behaviour exactly. Add a rule to ship a variation for one car; ``car_profile_override``
+A car is recognized from what its head unit reported on a previous connection. Cars without a rule
+keep the standard behavior exactly. Add a rule to ship a variation for one car; ``car_profile_override``
in the config pins a profile (or "default") for a single comma while testing with someone.
"""
import re
@@ -60,7 +60,7 @@ def tuning(config: dict) -> dict:
def summary(config: dict) -> dict:
- """What the UI may show: nothing at all while the standard behaviour applies."""
+ """What the UI may show: nothing at all while the standard behavior applies."""
profile, source = resolve(config)
rule = next((item for item in RULES if item["id"] == profile), None)
return {"car_profile": profile, "car_profile_source": source, "car_profile_label": rule["label"] if rule else "",
diff --git a/openpilot/starpilot/system/android_auto/supervisor.py b/openpilot/starpilot/system/android_auto/supervisor.py
index 1aec09864a..f75354e0b5 100644
--- a/openpilot/starpilot/system/android_auto/supervisor.py
+++ b/openpilot/starpilot/system/android_auto/supervisor.py
@@ -541,7 +541,7 @@ class Supervisor:
self.log("car_type_selected", car_type=car_type)
def set_car_profile(self, profile: str) -> None:
- """Pin a car profile ("default" for standard behaviour) or go back to picking it by fingerprint ("")."""
+ """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:
diff --git a/openpilot/starpilot/system/android_auto/tests/test_car_profiles.py b/openpilot/starpilot/system/android_auto/tests/test_car_profiles.py
index b39e8731d2..b6a0118296 100644
--- a/openpilot/starpilot/system/android_auto/tests/test_car_profiles.py
+++ b/openpilot/starpilot/system/android_auto/tests/test_car_profiles.py
@@ -1,4 +1,4 @@
-"""Car profiles are picked by fingerprint; cars without a rule keep the standard behaviour."""
+"""Car profiles are picked by fingerprint; cars without a rule keep the standard behavior."""
import json
import threading
From 4c07adab7d2bee2ccce529959a368dbfd43ffd2e Mon Sep 17 00:00:00 2001
From: dirwin31 <83434411+dirwin31@users.noreply.github.com>
Date: Mon, 26 Oct 2026 13:00:39 -0700
Subject: [PATCH 09/23] UI: Selector Fixes, Less Duplicate for AA Wireless.
---
.../galaxy/tests/test_android_auto.mjs | 42 ++++++++-----------
.../starpilot/galaxy/web/js/android-auto.js | 32 +++++++-------
2 files changed, 33 insertions(+), 41 deletions(-)
diff --git a/openpilot/starpilot/galaxy/tests/test_android_auto.mjs b/openpilot/starpilot/galaxy/tests/test_android_auto.mjs
index 20052e0479..8aa7ee1b45 100644
--- a/openpilot/starpilot/galaxy/tests/test_android_auto.mjs
+++ b/openpilot/starpilot/galaxy/tests/test_android_auto.mjs
@@ -1,3 +1,4 @@
+import { settingControl } from "../web/js/galaxy-setting-row.js"
import assert from "node:assert/strict"
import { readFileSync } from "node:fs"
import { AndroidAutoFeed, AndroidAutoPage, validPairingStatus, validPromptInput, uploadPackage, uploadTimeout,
@@ -18,7 +19,7 @@ assert.equal(validPromptInput({ kind: "passkey" }, "1234567"), false)
assert.equal(validPromptInput(prompt, ""), true)
assert.match(AndroidAutoPage.template, /Check that this code matches your car/)
assert.match(AndroidAutoPage.template, /Cancel Search \/ Pairing/)
-assert.match(AndroidAutoPage.template, /:row="wiredRow"[^>]*:disabled="wiredDisabled"/)
+assert.doesNotMatch(AndroidAutoPage.template, /:row="wiredRow"/)
assert.match(AndroidAutoPage.template, /Read the Android Auto troubleshooting guide/)
assert.doesNotMatch(AndroidAutoPage.template, /v-html|autoaccept/i)
const app = readFileSync(new URL("../web/js/app.js", import.meta.url), "utf8")
@@ -160,32 +161,24 @@ function page(selectedSetup = setup) {
for (const [key, method] of Object.entries(AndroidAutoPage.methods)) vm[key] = method.bind(vm)
return vm
}
-// Wired setup is available, while the existing parked/session/ownership gates remain enforced.
const wiredSetup = page()
-wiredSetup.runtime = { connection: "wireless", running: false }
-assert.equal(wiredSetup.wiredDisabled, false)
-for (const field of ["enabled", "parked"]) {
- const blocked = page({ ...setup, [field]: false })
- assert.equal(blocked.wiredDisabled, true, field)
-}
-wiredSetup.pairing = { active: true }
-assert.equal(wiredSetup.wiredDisabled, true, "pairing owns the connection")
-wiredSetup.pairing = null
-wiredSetup.runtime.running = true
-assert.equal(wiredSetup.wiredDisabled, true, "a running session cannot change connection")
-wiredSetup.runtime = { connection: "wireless", running: false, wired_blocked: "USB is busy" }
-assert.equal(wiredSetup.wiredDisabled, true, "another USB owner blocks enabling wired mode")
-wiredSetup.runtime.connection = "wired"
-assert.equal(wiredSetup.wiredDisabled, false, "the existing wired connection can still be disabled")
-
-// Jetlink only delays wired: it can be chosen, with a shortcut to switch Jetlink off.
-wiredSetup.runtime = { connection: "wireless", running: false, wired_busy: "Turn off Jetlink to use wired Android Auto" }
-assert.equal(wiredSetup.wiredDisabled, false, "Jetlink does not stop wired being chosen")
-assert.equal(wiredSetup.wiredJetlinkBusy, false, "no notice until wired is chosen")
-wiredSetup.runtime.connection = "wired"
+wiredSetup.runtime = { connection: "wired", running: false, wired_busy: "Turn off Jetlink to use wired Android Auto" }
assert.equal(wiredSetup.wiredJetlinkBusy, true)
+assert.equal(wiredSetup.wiredStatus.tone, "expired")
+assert.equal(wiredSetup.wiredStatus.label, "Wired Android Auto not enabled")
assert.match(wiredSetup.connectReason, /Jetlink/)
-assert.match(AndroidAutoPage.template, /href="#\/manage_models"/)
+assert.match(AndroidAutoPage.template, /class="gx-link" href="#\/manage_models"/)
+assert.match(AndroidAutoPage.template, /bi-arrow-right/)
+wiredSetup.runtime.wired_busy = ""
+assert.equal(wiredSetup.wiredStatus.tone, "ready")
+assert.equal(wiredSetup.wiredStatus.label, "Wired Android Auto enabled")
+assert.equal(wiredSetup.connectReason, "")
+wiredSetup.runtime.wired_blocked = "USB is unavailable"
+assert.equal(wiredSetup.wiredStatus.tone, "expired")
+assert.equal(wiredSetup.connectReason, "USB is unavailable")
+wiredSetup.runtime.wired_blocked = ""
+wiredSetup.setup.enabled = false
+assert.equal(wiredSetup.wiredStatus.tone, "expired")
const uploadPage = page({ ...setup, enabled: false })
const uploadCalls = []
@@ -526,6 +519,7 @@ assert.deepEqual(namedCalls, [['car_type', { carType: 'adapter' }]])
const dongle = page(setup)
dongle.runtime = { car_type: 'adapter', car_profile: 'default', car_profile_source: 'standard' }
assert.equal(dongle.adapterRow.value, 'Choose one', 'no default: the owner picks')
+assert.equal(settingControl(dongle.adapterRow), 'select', 'the initial adapter prompt must open a picker')
assert.match(dongle.pairingReason, /adapter model/)
dongle.runtime = { car_type: 'adapter', car_profile: 'default', car_profile_source: 'override' }
assert.equal(dongle.adapterRow.value, 'Other')
diff --git a/openpilot/starpilot/galaxy/web/js/android-auto.js b/openpilot/starpilot/galaxy/web/js/android-auto.js
index ede2b08d2e..d7e123d154 100644
--- a/openpilot/starpilot/galaxy/web/js/android-auto.js
+++ b/openpilot/starpilot/galaxy/web/js/android-auto.js
@@ -47,8 +47,11 @@ const ADDRESS = /^(?:[0-9A-F]{2}:){5}[0-9A-F]{2}$/
const ADAPTER_MODELS = { aawireless: "AAWireless v1", default: "Other" }
const HEAD_UNIT_APPS = { headunitpad: "HeadunitPad", openheadunit: "Open Headunit" }
const HOTSPOT_LINK = `Galaxy Wi-Fi Hotspot`
-const WIRED_JETLINK = `
Jetlink is using the USB-C port, so wired Android Auto can’t connect yet. Turn off Jetlink, then reconnect.
- Open Jetlink settings
{{ carType === 'usb' ? 'Set Up Wired AA' : carType === 'app' ? 'Set Up Your App' : adapterSetup ? 'Pair Your Adapter and Car' : 'Pair Your Car' }}
{{ adapterSetup ? 'An adapter needs two pairings: the adapter here, and your car on the Bluetooth page.' : pairsHere ? 'Open your car’s phone pairing screen, then tap Find car. You can do this before step 1 is finished.' : 'No Bluetooth pairing is needed for this setup.' }}
From f7fed1776dbd41f7e288a0342cee3259dee2707a Mon Sep 17 00:00:00 2001
From: dirwin31 <83434411+dirwin31@users.noreply.github.com>
Date: Mon, 26 Oct 2026 13:00:47 -0700
Subject: [PATCH 14/23] AA BT: Fix Pairing Window Request from Galaxy
---
.../starpilot/galaxy/web/js/android-auto.js | 61 +++++++++----------
.../system/android_auto/bluetooth_bridge.py | 24 +++++++-
2 files changed, 52 insertions(+), 33 deletions(-)
diff --git a/openpilot/starpilot/galaxy/web/js/android-auto.js b/openpilot/starpilot/galaxy/web/js/android-auto.js
index c78e41ee73..305ea2eda6 100644
--- a/openpilot/starpilot/galaxy/web/js/android-auto.js
+++ b/openpilot/starpilot/galaxy/web/js/android-auto.js
@@ -773,35 +773,7 @@ export const AndroidAutoPage = {
Android Auto uses the comma’s Wi-Fi to project to your car and disconnects it from your current Wi-Fi network. Use the Galaxy hotspot for local access during projection. Remote Galaxy access requires Internet on the comma.{{ error }}{{ error || "Checking Android Auto setup… Reconnecting automatically…" }}
-
-
-
-
{{ projecting ? 'Connected to ' + connectionName : runtime?.running ? 'Connecting to ' + connectionName : connectionName }}
-
{{ runtime?.running && runtime.state !== 'streaming' && runtime.label ? runtime.label : runtime?.running ? 'Showing StarPilot on your car’s screen.' : wired ? wiredStatus.label : runtime?.auto_connect ? 'Connects automatically when you start driving.' : 'Ready to connect.' }}
@@ -542,7 +542,7 @@ export const AndroidAutoPage = {
const chosen = this.runtime?.car_profile_source === "override"
return { label: "Adapter", value: !chosen ? "Choose one" : this.runtime.car_profile === "aawireless" ? ADAPTER_MODELS.aawireless : ADAPTER_MODELS.default,
choices: [...(!chosen ? ["Choose one"] : []), ...Object.values(ADAPTER_MODELS)], available: true, action: true,
- reason: "AAWireless v1 gets video and car-data settings tuned for that dongle. Other adapters use the regular pairing." }
+ reason: "AAWireless gets video and car-data settings tuned for that dongle. Other adapters use the regular pairing." }
},
appDisabled() {
return !this.setup?.enabled || !this.setup.parked || !!this.pairing?.active || this.linked
diff --git a/openpilot/starpilot/system/android_auto/car_profiles.py b/openpilot/starpilot/system/android_auto/car_profiles.py
index 5fc550b776..2a3d912372 100644
--- a/openpilot/starpilot/system/android_auto/car_profiles.py
+++ b/openpilot/starpilot/system/android_auto/car_profiles.py
@@ -15,7 +15,7 @@ FINGERPRINT_KEYS = ("head_unit_make", "head_unit_model", "car_make", "car_model"
# {"id": "...", "label": "...", "match": {fingerprint key: regex, ...}, "tuning": {key from DEFAULTS: value}}
RULES: list[dict] = [
- {"id": "aawireless", "label": "AAWireless v1", "match": {},
+ {"id": "aawireless", "label": "AAWireless", "match": {},
"tuning": {"max_window": 30, "phone_services": True, "phone_frames": True}},
]
diff --git a/openpilot/starpilot/system/android_auto/session.py b/openpilot/starpilot/system/android_auto/session.py
index 89c56d1be3..03fee8daf0 100644
--- a/openpilot/starpilot/system/android_auto/session.py
+++ b/openpilot/starpilot/system/android_auto/session.py
@@ -207,6 +207,9 @@ def choose_video_mode(channels: list[dict]) -> VideoMode:
class Session:
"""TLS, framing and control-channel handshake; one instance per TCP connection."""
+ # Wireless adapters relay to the car, which can take a while (or wait for the driver) before it answers.
+ DISCOVERY_TIMEOUT = 60.0
+
def __init__(self, peer, cert: str, key: str, log: Callable[..., None] | None = None, ca: str | None = None, *,
receive_timeout: float = 3.0, send_timeout: float = 3.0, handshake_timeout: float = 15.0):
self.peer = peer
@@ -459,7 +462,7 @@ class Session:
def discover(self, device_name: str, device_brand: str) -> list[dict]:
self.send(0, MSG_SERVICE_DISCOVERY_REQUEST, field(4, device_name) + field(5, device_brand))
- raw = self.wait_for(0, MSG_SERVICE_DISCOVERY_RESPONSE)
+ raw = self.wait_for(0, MSG_SERVICE_DISCOVERY_RESPONSE, timeout=self.DISCOVERY_TIMEOUT, patient=True)
response = parse_fields(raw)
channels = []
for descriptor in response.get(1, []):
@@ -510,15 +513,24 @@ class Session:
head_unit=head_unit)
return channels
- def wait_for(self, channel: int, kind: int, timeout: float = 10.0) -> bytes:
+ def wait_for(self, channel: int, kind: int, timeout: float = 10.0, patient: bool = False) -> bytes:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
+ if patient and not self._await_readable(deadline):
+ break
ch, message, data = self.receive()
if ch == channel and message == kind:
return data
self.dispatch(ch, message, data, expected=f"{channel}/{kind:#x}")
raise TimeoutError(f"Head unit did not send {channel}/{kind:#x}")
+ def _await_readable(self, deadline: float) -> bool:
+ """Wait for the first byte of the next message, so a slow adapter or car is not cut off by the per-message timeout."""
+ if not hasattr(self.peer, "fileno"):
+ return True
+ remaining = deadline - time.monotonic()
+ return remaining > 0 and bool(select.select([self.peer], [], [], remaining)[0])
+
def dispatch(self, channel: int, kind: int, data: bytes, expected: str) -> None:
"""Handle a message that arrived while waiting for another one."""
if channel == 0 and kind == MSG_PING_REQUEST:
diff --git a/openpilot/starpilot/system/android_auto/supervisor.py b/openpilot/starpilot/system/android_auto/supervisor.py
index 6ca7f81c4d..c65e9d7580 100644
--- a/openpilot/starpilot/system/android_auto/supervisor.py
+++ b/openpilot/starpilot/system/android_auto/supervisor.py
@@ -413,7 +413,7 @@ class Supervisor:
if not self._projection_enabled():
raise RuntimeError("Android Auto is disabled")
if self._adapter_choice_needed():
- raise RuntimeError("Choose your adapter model (AAWireless v1 or Other) in Galaxy first")
+ 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():
@@ -559,7 +559,7 @@ class Supervisor:
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 v1 or Other) in Galaxy first")
+ 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')
diff --git a/openpilot/starpilot/system/android_auto/tests/test_android_auto.py b/openpilot/starpilot/system/android_auto/tests/test_android_auto.py
index cc1ee0d6f6..49cc126d74 100644
--- a/openpilot/starpilot/system/android_auto/tests/test_android_auto.py
+++ b/openpilot/starpilot/system/android_auto/tests/test_android_auto.py
@@ -1259,3 +1259,15 @@ def test_chestnut_still_stops_choosing_wired(identity, tmp_path, monkeypatch):
monkeypatch.setattr(supervisor_module, "chestnut_blocker", lambda: "Wired Android Auto is unavailable while Chestnut is connected")
with pytest.raises(RuntimeError, match="Chestnut"):
sup.set_connection("wired")
+
+
+def test_discovery_waits_past_the_message_timeout_for_a_slow_adapter():
+ from openpilot.starpilot.system.android_auto.session import Session
+ a, b = socket.socketpair()
+ session = Session.__new__(Session)
+ session.peer = a
+ threading.Timer(0.3, b.send, args=(b"x",)).start()
+ assert session._await_readable(time.monotonic() + 5)
+ a.recv(1)
+ assert not session._await_readable(time.monotonic() + 0.1)
+ assert Session.DISCOVERY_TIMEOUT >= 30
diff --git a/openpilot/starpilot/system/android_auto/tests/test_car_profiles.py b/openpilot/starpilot/system/android_auto/tests/test_car_profiles.py
index b6a0118296..6f2abc5565 100644
--- a/openpilot/starpilot/system/android_auto/tests/test_car_profiles.py
+++ b/openpilot/starpilot/system/android_auto/tests/test_car_profiles.py
@@ -40,7 +40,7 @@ def test_aawireless_tuning_applies_only_when_chosen():
chosen = config(car_profile_override="aawireless", receiver_name="AAWireless-4F2A")
assert car_profiles.resolve(chosen) == ("aawireless", "override")
assert car_profiles.tuning(chosen) == {"connect_delay_s": 0.0, "max_window": 30, "phone_services": True, "phone_frames": True}
- assert car_profiles.summary(chosen)["car_profile_label"] == "AAWireless v1"
+ assert car_profiles.summary(chosen)["car_profile_label"] == "AAWireless"
for other in (config(receiver_name="AAWireless-4F2A"), config(car_profile_override="default")):
assert car_profiles.tuning(other) == car_profiles.DEFAULTS
From 54b2f060e72bb4d4a1891926ed13bbe63ebe892c Mon Sep 17 00:00:00 2001
From: dirwin31 <83434411+dirwin31@users.noreply.github.com>
Date: Mon, 26 Oct 2026 13:00:51 -0700
Subject: [PATCH 17/23] AA: Put Messages in Pairing Box, Hide Banner
---
.../starpilot/galaxy/web/js/android-auto.js | 90 ++++++++++---------
1 file changed, 48 insertions(+), 42 deletions(-)
diff --git a/openpilot/starpilot/galaxy/web/js/android-auto.js b/openpilot/starpilot/galaxy/web/js/android-auto.js
index 5e757131b7..114de42516 100644
--- a/openpilot/starpilot/galaxy/web/js/android-auto.js
+++ b/openpilot/starpilot/galaxy/web/js/android-auto.js
@@ -536,7 +536,7 @@ export const AndroidAutoPage = {
carTypeRow() {
const labels = this.carTypeLabels
return { label: "What’s in your car?", value: labels[this.carType], choices: Object.values(labels), available: true, action: true,
- reason: "Built-in: the car supports wireless Android Auto. Adapter: a dongle such as AAWireless, Motorola MA1 or Carlinkit. Both the adapter and your car get paired." }
+ reason: "Built-in: the car supports wireless Android Auto. Adapter: a dongle such as AAWireless, Motorola MA1 or Carlinkit. Only the adapter gets paired." }
},
adapterRow() {
const chosen = this.runtime?.car_profile_source === "override"
@@ -783,7 +783,7 @@ export const AndroidAutoPage = {
Android Auto
Show the StarPilot driving view on your car’s screen. Set up support, {{ wired ? 'plug in your car' : 'pair your car' }}, then connect.
- Android Auto uses the comma’s Wi-Fi to project to your car and disconnects it from your current Wi-Fi network. Use the Galaxy hotspot for local access during projection. Remote Galaxy access requires Internet on the comma.
+ Android Auto uses the comma’s Wi-Fi to project to your car and disconnects it from your current Wi-Fi network. Use the Galaxy hotspot for local access during projection. Remote Galaxy access requires Internet on the comma.{{ error }}{{ error || "Checking Android Auto setup… Reconnecting automatically…" }}
@@ -826,40 +826,11 @@ export const AndroidAutoPage = {
-
-
-
-
{{ homeTitle }}
-
{{ runtime?.running && runtime.state !== 'streaming' && runtime.label ? runtime.label : runtime?.running ? 'Showing StarPilot on your car’s screen.' : wired ? wiredStatus.label : runtime?.auto_connect ? 'Connects automatically when you start driving.' : 'Ready to connect.' }}
{{ carType === 'usb' ? 'Set Up Wired AA' : carType === 'app' ? 'Set Up Your App' : adapterSetup ? 'Pair Your Adapter and Car' : 'Pair Your Car' }}
{{ adapterSetup ? 'An adapter needs two pairings: the adapter here, and your car on the Bluetooth page.' : pairsHere ? 'Open your car’s phone pairing screen, then tap Find car. You can do this before step 1 is finished.' : 'No Bluetooth pairing is needed for this setup.' }}
+
{{ carType === 'usb' ? 'Set Up Wired AA' : carType === 'app' ? 'Set Up Your App' : adapterSetup ? 'Pair Your Adapter' : 'Pair Your Car' }}
{{ adapterSetup ? 'Put the adapter in pairing mode, then tap Find adapter. Your car doesn’t need to be paired.' : pairsHere ? 'Open your car’s phone pairing screen, then tap Find car. You can do this before step 1 is finished.' : 'No Bluetooth pairing is needed for this setup.' }}
Adapter{{ selected ? selected.name : 'Put it in pairing mode, then tap Find adapter.' }}{{ selected ? 'Paired' : 'Not paired' }}
-
-
- Your car{{ runtime?.companion_name ? runtime.companion_name : selected ? 'Pair your car on the Bluetooth page, then tap Connect there.' : 'Pair the adapter first.' }}
- Paired
- Open Bluetooth
- Not paired
-
Keep this page open while pairing so the car’s PIN or code doesn’t time out.
Cars with built-in wireless Android Auto: open the car’s phone pairing screen, then tap Find car above. Choose your car, then confirm the code on both screens.
-
Wireless adapter (AAWireless, Motorola MA1, Carlinkit…): choose Wireless adapter above, put the adapter in pairing mode and pair it here, and pair your car in Bluetooth under Tools so calls keep working. Then connect the car there. Adapter names often look like AndroidAuto-XXXX.
+
Wireless adapter (AAWireless, Motorola MA1, Carlinkit…): choose Wireless adapter above, put the adapter in pairing mode and pair it here Your car doesn’t need to be paired. Adapter names often look like AndroidAuto-XXXX.
@@ -457,6 +467,19 @@ export function installChecks(job, locale = undefined) {
})
}
+export function connectionProgress(runtime, { wired = false, app = "", selected = null } = {}) {
+ const state = runtime?.state || "idle"
+ const base = { active: false, tone: "ready", icon: "arrow-right-circle", eyebrow: "NEXT · CONNECT" }
+ if (state === "streaming") return { ...base, tone: "success", icon: "check-circle-fill", eyebrow: "CONNECTED", title: "Your dashboard is live", detail: "StarPilot is showing on your screen. You’re ready to drive." }
+ if (state === "suspended") return { ...base, title: "Connected · car screen in use", detail: "Open Android Auto on your car’s screen to return to StarPilot." }
+ if (state === "error" || state === "backoff") return { ...base, active: state === "backoff" && !!runtime?.running, tone: "warning", icon: "exclamation-circle", eyebrow: state === "backoff" ? "RECONNECTING" : "NEEDS ATTENTION", title: state === "backoff" ? "Trying your connection again" : "Connection needs attention", detail: runtime.error || runtime.detail || "Check your device, then try connecting again." }
+ if (runtime?.running && state !== "idle") {
+ const titles = { wifi_info: "Getting car Wi-Fi details", joining_wifi: "Joining car Wi-Fi", connecting_bluetooth: "Connecting over Bluetooth", discovering: "Finding Android Auto", rfcomm: "Starting wireless setup", wifi_start: "Waiting for car Wi-Fi", connecting_tcp: "Opening the connection", authenticating: "Securing your connection", negotiating: "Preparing your dashboard", waiting_for_usb: "Waiting for your USB cable", usb_accessory: "Starting USB connection", waiting_for_ipad: "Open HeadunitPad on your iPad", waiting_for_tablet: "Open Open Headunit on your tablet", stopping: "Disconnecting" }
+ return { ...base, active: true, tone: "active", eyebrow: "CONNECTION IN PROGRESS", title: titles[state] || runtime.label || "Connecting to your device", detail: runtime.detail || (wired ? "Keep the data cable connected and the car’s screen on." : app ? "Keep the app open and both devices on the same Wi-Fi." : "Keep your car’s screen on. We’ll move to the next step automatically.") }
+ }
+ return { ...base, title: wired ? "Connect your USB cable" : app ? `Ready for ${app}` : selected ? `Ready for ${selected.name}` : "Pair your device first", detail: wired ? "Use the car’s Android Auto data port, then tap Connect below." : app ? "Complete the app setup above, then tap Connect below." : selected ? "Tap Connect to bring StarPilot to your dashboard." : "Follow the pairing steps above, then connect to your dashboard." }
+}
+
export const AndroidAutoPage = {
components: { GxState, GxIconButton, GxNotice, BluetoothNotice, GxDialog, GalaxySettingRow },
props: {
@@ -491,6 +514,7 @@ export const AndroidAutoPage = {
'setup.enabled'(enabled) { if (!enabled) this.pairWhenReady = false },
},
computed: {
+ connectionProgress() { return connectionProgress(this.runtime, { wired: this.wired, app: this.app, selected: this.selected }) },
canRespond() { return validPromptInput(this.pairing?.prompt, this.pairValue) },
expiry() { return expiryInfo(this.setup?.identity) },
projecting() { return this.runtime?.state === "streaming" },
@@ -535,8 +559,8 @@ export const AndroidAutoPage = {
wiredUnavailable() { return !this.wired && !!this.runtime?.wired_blocked },
carTypeRow() {
const labels = this.carTypeLabels
- return { label: "What’s in your car?", value: labels[this.carType], choices: Object.values(labels), available: true, action: true,
- reason: "Built-in: the car supports wireless Android Auto. Adapter: a dongle such as AAWireless, Motorola MA1 or Carlinkit. Only the adapter gets paired." }
+ return { label: "What’s your device?", value: labels[this.carType], choices: Object.values(labels), available: true, action: true,
+ reason: this.wired ? "Use a data cable from the car to your comma." : this.app ? "Use a tablet on the same Wi-Fi as your comma." : this.adapterSetup ? "Pair the USB adapter, such as AAWireless or Motorola MA1." : "Pair directly with your car’s wireless Android Auto." }
},
adapterRow() {
const chosen = this.runtime?.car_profile_source === "override"
@@ -623,14 +647,16 @@ export const AndroidAutoPage = {
const paired = ["paired", "connected"].includes(state)
const picked = !!(this.pairing?.receiver || this.pairing?.approved || ["pairing", "connecting"].includes(state) || paired)
const what = this.adapterSetup ? "adapter" : "car"
- const rank = paired ? 4 : this.pairing?.prompt ? 3 : picked ? 2 : active ? 1 : 0
+ const failed = state === "failed" || !!this.pairing?.error
+ const rank = paired ? 5 : state === "connecting" ? 4 : this.pairing?.approved ? 3 : this.pairing?.prompt ? 2 : picked ? 2 : active ? 1 : 0
const steps = [
`Start the search${this.starting ? "…" : ""}`,
`Put your ${what} in pairing mode, then pick it from the list`,
- "Confirm the code on both screens",
- "Paired"
+ this.pairing?.prompt ? "Confirm the code on both screens" : "Pair with your device",
+ "Finish Bluetooth pairing",
+ "Finish connecting to your device"
]
- return steps.map((label, index) => ({ label, status: index < rank ? "done" : index === rank ? "active" : "pending" }))
+ return steps.map((label, index) => ({ label, status: index < rank ? "done" : index === rank ? (failed ? "failed" : "active") : "pending" }))
},
showPairSteps() { return this.starting || !!this.pairing?.active },
waitingForCar() {
@@ -781,7 +807,7 @@ export const AndroidAutoPage = {
Android Auto
-
Show the StarPilot driving view on your car’s screen. Set up support, {{ wired ? 'plug in your car' : 'pair your car' }}, then connect.
+
Show the StarPilot driving view on your car’s screen. Choose your device, follow the setup steps, then connect.
Android Auto uses the comma’s Wi-Fi to project to your car and disconnects it from your current Wi-Fi network. Use the Galaxy hotspot for local access during projection. Remote Galaxy access requires Internet on the comma.{{ error }}
@@ -833,10 +859,12 @@ export const AndroidAutoPage = {
{{ carType === 'usb' ? 'Set Up Wired AA' : carType === 'app' ? 'Set Up Your App' : adapterSetup ? 'Pair Your Adapter' : 'Pair Your Car' }}
{{ adapterSetup ? 'Put the adapter in pairing mode, then tap Find adapter. Your car doesn’t need to be paired.' : pairsHere ? 'Open your car’s phone pairing screen, then tap Find car. You can do this before step 1 is finished.' : 'No Bluetooth pairing is needed for this setup.' }}
- {{ selected ? 'Connect to ' + selected.name : adapterSetup ? 'Connect to adapter' : 'Connect to car' }}
- Disconnect
+ ${CONNECTION_PROGRESS}
+ ${CONNECT}
{{ connectReason }}
Finish step 1 first — Connect will walk you through it.
-
-
-
Car: {{ runtime.companion_name }}
Stuck connecting? Wi-Fi and screen troubleshooting
How it connects: after Bluetooth, the car (or adapter) creates its own hidden 5 GHz Wi-Fi network and tells the comma how to join. The picture streams over that Wi-Fi.
diff --git a/openpilot/starpilot/system/android_auto/car_profiles.py b/openpilot/starpilot/system/android_auto/car_profiles.py
index 2a3d912372..52ac24a729 100644
--- a/openpilot/starpilot/system/android_auto/car_profiles.py
+++ b/openpilot/starpilot/system/android_auto/car_profiles.py
@@ -1,19 +1,10 @@
-"""Per-car tuning, picked by fingerprint so nobody has to choose their car from a list.
-
-A car is recognized from what its head unit reported on a previous connection. Cars without a rule
-keep the standard behavior exactly. Add a rule to ship a variation for one car; ``car_profile_override``
-in the config pins a profile (or "default") for a single comma while testing with someone.
-"""
+"""Android Auto tuning selected from the saved head-unit fingerprint or an override."""
import re
-# connect_delay_s: wait before the Bluetooth connect, for head units that need time to wake
-# max_window: most unacknowledged video messages in flight; phone_services: open the car-data and Bluetooth channels
-# like a phone does; phone_frames: send picture NAL units only, without AUD or repeated SPS/PPS
DEFAULTS = {"connect_delay_s": 0.0, "max_window": 2, "phone_services": False, "phone_frames": False}
FINGERPRINT_KEYS = ("head_unit_make", "head_unit_model", "car_make", "car_model", "car_year")
-# {"id": "...", "label": "...", "match": {fingerprint key: regex, ...}, "tuning": {key from DEFAULTS: value}}
RULES: list[dict] = [
{"id": "aawireless", "label": "AAWireless", "match": {},
"tuning": {"max_window": 30, "phone_services": True, "phone_frames": True}},
diff --git a/openpilot/starpilot/system/android_auto/session.py b/openpilot/starpilot/system/android_auto/session.py
index 03fee8daf0..2c8f740dea 100644
--- a/openpilot/starpilot/system/android_auto/session.py
+++ b/openpilot/starpilot/system/android_auto/session.py
@@ -294,10 +294,10 @@ class Session:
self.peer.settimeout(previous_timeout)
return -(-len(data) // FRAGMENT_SIZE)
- def receive(self, *, deadline: float | None = None) -> tuple[int, int, bytes]:
+ def receive(self, *, deadline: float | None = None, patient: bool = False) -> tuple[int, int, bytes]:
"""Read one complete message within one deadline, tolerating partial reads."""
message_deadline = time.monotonic() + (self.receive_timeout if self.authenticated else self.handshake_timeout)
- deadline = message_deadline if deadline is None else min(deadline, message_deadline)
+ deadline = message_deadline if deadline is None else deadline if patient else min(deadline, message_deadline)
timed_peer = hasattr(self.peer, "gettimeout") and hasattr(self.peer, "settimeout")
previous_timeout = self.peer.gettimeout() if timed_peer else None
@@ -516,21 +516,12 @@ class Session:
def wait_for(self, channel: int, kind: int, timeout: float = 10.0, patient: bool = False) -> bytes:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
- if patient and not self._await_readable(deadline):
- break
- ch, message, data = self.receive()
+ ch, message, data = self.receive(deadline=deadline, patient=patient)
if ch == channel and message == kind:
return data
self.dispatch(ch, message, data, expected=f"{channel}/{kind:#x}")
raise TimeoutError(f"Head unit did not send {channel}/{kind:#x}")
- def _await_readable(self, deadline: float) -> bool:
- """Wait for the first byte of the next message, so a slow adapter or car is not cut off by the per-message timeout."""
- if not hasattr(self.peer, "fileno"):
- return True
- remaining = deadline - time.monotonic()
- return remaining > 0 and bool(select.select([self.peer], [], [], remaining)[0])
-
def dispatch(self, channel: int, kind: int, data: bytes, expected: str) -> None:
"""Handle a message that arrived while waiting for another one."""
if channel == 0 and kind == MSG_PING_REQUEST:
diff --git a/openpilot/starpilot/system/android_auto/supervisor.py b/openpilot/starpilot/system/android_auto/supervisor.py
index 2dbc75c165..d280134064 100644
--- a/openpilot/starpilot/system/android_auto/supervisor.py
+++ b/openpilot/starpilot/system/android_auto/supervisor.py
@@ -537,6 +537,7 @@ class Supervisor:
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"]:
@@ -544,6 +545,10 @@ class Supervisor:
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)
diff --git a/openpilot/starpilot/system/android_auto/system_slice.py b/openpilot/starpilot/system/android_auto/system_slice.py
index 6ab5e357f3..76098c946f 100644
--- a/openpilot/starpilot/system/android_auto/system_slice.py
+++ b/openpilot/starpilot/system/android_auto/system_slice.py
@@ -1,8 +1,4 @@
-"""What the rest of the device was doing during one Android Auto session or pairing window, as bounded line lists.
-
-The journal lives in RAM here, so a session's slice is captured when it ends and kept in a sidecar next to its log;
-by the time a drive report is sent, those lines may be gone. The device is only read, at low priority, under one deadline.
-"""
+"""Bounded device logs captured beside an Android Auto session log."""
from __future__ import annotations
diff --git a/openpilot/starpilot/system/android_auto/tests/test_android_auto.py b/openpilot/starpilot/system/android_auto/tests/test_android_auto.py
index 49cc126d74..c4c3d21bea 100644
--- a/openpilot/starpilot/system/android_auto/tests/test_android_auto.py
+++ b/openpilot/starpilot/system/android_auto/tests/test_android_auto.py
@@ -1266,8 +1266,20 @@ def test_discovery_waits_past_the_message_timeout_for_a_slow_adapter():
a, b = socket.socketpair()
session = Session.__new__(Session)
session.peer = a
- threading.Timer(0.3, b.send, args=(b"x",)).start()
- assert session._await_readable(time.monotonic() + 5)
- a.recv(1)
- assert not session._await_readable(time.monotonic() + 0.1)
- assert Session.DISCOVERY_TIMEOUT >= 30
+ session.authenticated = False
+ session.handshake_timeout = 0.1
+ session.fragments = {}
+ session.bytes_received = 0
+ def send_slowly():
+ time.sleep(0.2)
+ b.sendall(b"\x00\x03\x00\x02")
+ time.sleep(0.2)
+ b.sendall(b"\x00\x06")
+ sender = threading.Thread(target=send_slowly)
+ sender.start()
+ try:
+ assert session.wait_for(0, 6, timeout=1.0, patient=True) == b""
+ finally:
+ sender.join()
+ a.close()
+ b.close()
diff --git a/openpilot/starpilot/system/android_auto/tests/test_bugsink_report.py b/openpilot/starpilot/system/android_auto/tests/test_bugsink_report.py
index 32bf89b6dd..b759b5e8dc 100644
--- a/openpilot/starpilot/system/android_auto/tests/test_bugsink_report.py
+++ b/openpilot/starpilot/system/android_auto/tests/test_bugsink_report.py
@@ -312,6 +312,9 @@ def test_supervisor_records_build_at_session_start(monkeypatch):
def close(self):
pass
+ def capture_system(self):
+ pass
+
def __call__(self, name, **values):
records.append({"event": name, **values})
diff --git a/openpilot/starpilot/system/android_auto/tests/test_car_profiles.py b/openpilot/starpilot/system/android_auto/tests/test_car_profiles.py
index 6f2abc5565..e97d04f1a0 100644
--- a/openpilot/starpilot/system/android_auto/tests/test_car_profiles.py
+++ b/openpilot/starpilot/system/android_auto/tests/test_car_profiles.py
@@ -47,9 +47,12 @@ def test_aawireless_tuning_applies_only_when_chosen():
def test_leaving_the_adapter_setup_clears_the_adapter_model(sup):
sup.config["car_type"] = "adapter"
+ sup.config.update(receiver_address=ADAPTER, receiver_name="AAWireless", companion_address="car",
+ companion_name="Car", car_fingerprint={"address": ADAPTER})
sup.set_car_profile("aawireless")
sup.set_car_type("builtin")
assert sup.config["car_profile_override"] == ""
+ assert not sup.config["receiver_address"] and not sup.config["companion_address"] and not sup.config["car_fingerprint"]
def test_an_adapter_setup_must_choose_its_model_before_pairing_or_starting(sup):