Merge pull request #273 from dirwin31:StarPort-AutoSetup-20.2

StarPort: guided setup, a setup ladder, and wireless adapter fixes
This commit is contained in:
prabhaavp
2026-11-03 11:22:05 -05:00
committed by GitHub
72 changed files with 4546 additions and 257 deletions
+2
View File
@@ -31,6 +31,8 @@ Browser JavaScript tests use Node 24; set `STARPILOT_NODE` to an existing Node
24 executable when needed. A static preview can serve `web/` over loopback;
it labels its sample System Monitor data and cannot edit a device.
StarPort log uploads use the built-in developer Discord webhook.
## Pages and owners
| Page | Current source and operations |
@@ -6,7 +6,8 @@ from openpilot.starpilot.longitudinal.voacc import KEY, DESCRIPTION
from openpilot.starpilot.ui.calibration_reset import calibration_reset
from openpilot.starpilot.saved_document import commit_exact
from openpilot.starpilot.saved_source import read_saved
from openpilot.starpilot.ui.feature_settings_state import FeatureRow, FeatureSettingsState
from openpilot.starpilot.system.starport import new_setup
from openpilot.starpilot.ui.feature_settings_state import HEADING_PREFIX, FeatureRow, FeatureSettingsState
STARPORT_WIFI_KEY = "StarPortDedicatedWifi"
@@ -14,6 +15,13 @@ STARPORT_WIFI_DESCRIPTION = ("Experimental. Join the car's Wi-Fi on a second int
"phone hotspot during wireless StarPort. Applies from the next StarPort connection.")
CALIBRATION_RESET = "reset_calibration"
CALIBRATION_RESET_DESCRIPTION = "Clear camera and steering calibration. The device recalibrates on the next drive."
STARPORT_HEADING_KEY = HEADING_PREFIX + "starport"
STARPORT_NEW_SETUP_KEY = "StarPortNewSetup"
STARPORT_NEW_SETUP_DESCRIPTION = ("Guided StarPort setup with a live checklist of what is being tried. Turn off to use the classic " +
"setup screens. Applies the next time the setup screen opens.")
STARPORT_HEVC_KEY = "StarPortAllowHevc"
STARPORT_HEVC_DESCRIPTION = ("Experimental. Only for a car that offers H.265 and no H.264 video. Cars that offer H.264 are not " +
"affected. Applies from the next StarPort connection.")
DRIVER_MONITORING_RESET = "reset_driver_monitoring"
DRIVER_MONITORING_RESET_DESCRIPTION = "Clear saved wheel-side detection. Automatic detection restarts on the next drive."
@@ -25,14 +33,32 @@ class DeveloperSettingsOwner:
self.panda_wake = panda_wake
self.starport = starport # returns the StarPort daemon client
def _starport_enabled(self):
def _starport_status(self):
if self.starport is None:
return None
try:
return bool(self.starport().call("status")["status"].get("dedicated_wifi_interface"))
status = self.starport().call("status")["status"]
return status if isinstance(status, dict) else None
except (OSError, RuntimeError, ValueError, KeyError, TypeError):
return None # the service is off; there is nothing to configure
def _starport_rows(self, parked):
"""The StarPort section. The switch back to classic setup is always listed; the daemon's settings only while it runs."""
new_setup_on = new_setup.enabled()
held = "Park the vehicle to change this setting."
rows = [FeatureRow(STARPORT_HEADING_KEY, "StarPort", "", available=False),
FeatureRow(STARPORT_NEW_SETUP_KEY, "New pairing experience", "On" if new_setup_on else "Off",
b"1" if new_setup_on else b"0", ("Off", "On"), available=parked,
reason=STARPORT_NEW_SETUP_DESCRIPTION if parked else held, default_value="On")]
status = self._starport_status()
if status is not None:
enabled, hevc = bool(status.get("dedicated_wifi_interface")), bool(status.get("allow_hevc"))
rows.append(FeatureRow(STARPORT_WIFI_KEY, "Stay on Wi-Fi", "On" if enabled else "Off", b"1" if enabled else b"0",
("Off", "On"), available=parked, reason=STARPORT_WIFI_DESCRIPTION if parked else held, default_value="On"))
rows.append(FeatureRow(STARPORT_HEVC_KEY, "Allow H.265 video", "On" if hevc else "Off", b"1" if hevc else b"0",
("Off", "On"), available=parked, reason=STARPORT_HEVC_DESCRIPTION if parked else held, default_value="Off"))
return tuple(rows)
def _driver_monitoring_reset_allowed(self):
return self.authorized() and not self.params.get_bool("IsDriverViewEnabled")
@@ -62,11 +88,7 @@ class DeveloperSettingsOwner:
firmware_rows = (() if self.panda_wake is None else
tuple(replace(item, vehicle_fingerprint=item.capability[0])
for item in self.panda_wake.rows(parked) if item.key == "IgnoreIgnitionLine"))
enabled = self._starport_enabled()
starport_rows = () if enabled is None else (
FeatureRow(STARPORT_WIFI_KEY, "StarPort: Stay on Wi-Fi", "On" if enabled else "Off", b"1" if enabled else b"0", ("Off", "On"),
available=parked, reason=STARPORT_WIFI_DESCRIPTION if parked else "Park the vehicle to change this setting.",
default_value="On"),)
starport_rows = self._starport_rows(parked)
summary = "Changes apply next drive. Panda firmware changes require a device restart." if firmware_rows else "Changes apply next drive."
return FeatureSettingsState("developer", "Developer driving options", summary,
(row, reset_row, calibration_row, *firmware_rows, *starport_rows), parked)
@@ -95,20 +117,32 @@ class DeveloperSettingsOwner:
if request.key == "IgnoreIgnitionLine":
return self.panda_wake is not None and self.panda_wake.apply(request)
if request.key == STARPORT_WIFI_KEY:
return self._apply_starport(request)
return self._apply_starport(request, "dedicated_wifi_interface", "set_dedicated_wifi")
if request.key == STARPORT_HEVC_KEY:
return self._apply_starport(request, "allow_hevc", "set_allow_hevc")
if request.key == STARPORT_NEW_SETUP_KEY:
return self._apply_new_setup(request)
if request.key != KEY or request.value not in ("Off", "On") or request.expected not in (None, b"0", b"1"):
return False
result = commit_exact(self.params, key=request.key, max_bytes=8, raw=b"1" if request.value == "On" else b"0",
expected=request.expected, authorized=self.authorized, temp_prefix=".voacc-")
return result.committed and result.verified
def _apply_starport(self, request):
enabled, client = self._starport_enabled(), self.starport
def _apply_starport(self, request, field, command):
status, client = self._starport_status(), self.starport
enabled = None if status is None else bool(status.get(field))
if (enabled is None or client is None or request.value not in ("Off", "On") or request.expected != (b"1" if enabled else b"0") or
not self.authorized()):
return False
try:
client().call("set_dedicated_wifi", enabled=request.value == "On")
client().call(command, enabled=request.value == "On")
except (OSError, RuntimeError, ValueError):
return False
return self._starport_enabled() == (request.value == "On")
status = self._starport_status()
return status is not None and bool(status.get(field)) == (request.value == "On")
def _apply_new_setup(self, request):
current = new_setup.enabled()
if request.value not in ("Off", "On") or request.expected != (b"1" if current else b"0") or not self.authorized():
return False
return new_setup.set_enabled(request.value == "On")
+50 -1
View File
@@ -97,7 +97,8 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
sounds=None, software_operations=None, drive_stats=None, layout_preview_socket=None, controllers_socket=None,
remote_pairing=None, parked=None, camera_snapshot=None, clock=time.monotonic, starport_setup=None,
starport_client=None, navigation=None, offline_maps=None, drive_state=None, cloud_provider=None, cloud_offroad=None, projection_layout=None,
local_access=None, tmux_live=None, starport_logs=None, hotspot=None, toyota_tools=None, replay_client=None, replay_root=None,
local_access=None, tmux_live=None, starport_logs=None, starport_log_sender=None, hotspot=None, toyota_tools=None,
replay_client=None, replay_root=None,
spotify_setup=None, companion=None, phone_location=None, reverse_proxy=None):
from openpilot.starpilot.galaxy.companion import CompanionRegistry, APK_PATH
from openpilot.starpilot.gps.phone import PhoneGpsPublisher
@@ -139,6 +140,17 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
from openpilot.starpilot.galaxy.starport_logs import StarPortLogs
starport_logs_source = StarPortLogs()
return starport_logs_source
starport_log_sender_source = starport_log_sender
starport_log_sender_lock = threading.Lock()
def starport_log_sender():
nonlocal starport_log_sender_source
with starport_log_sender_lock:
if starport_log_sender_source is None:
from openpilot.starpilot.galaxy.starport_send import StarPortLogSender, device_summary
source = starport_logs()
starport_log_sender_source = StarPortLogSender(source.bundle, summary=lambda: device_summary(source.directory))
return starport_log_sender_source
software_source = software
software_operations_source = software_operations
software_lock = threading.Lock()
@@ -928,6 +940,9 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
# immediately before sending potentially sensitive content.
if self.require_session():
self.json(200, result)
elif path == '/api/starport/logs/send':
if self.require_session():
self.json(200, starport_log_sender().status())
elif path == '/api/starport/logs' or path.startswith('/api/starport/logs/'):
if not self.require_session():
return
@@ -1635,6 +1650,7 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
'/api/replay/export-start',
'/api/galaxy/pair', '/api/galaxy/unpair', '/api/galaxy/device-name', '/api/galaxy/hotspot', '/api/cameras/snapshot',
'/api/starport/layout', '/api/starport/enable', '/api/starport/control', '/api/starport/pairing',
'/api/starport/logs/send',
'/api/starport/pairing/response', '/api/starport/pairing/cancel', '/api/starport/pairing/select',
'/api/spotify/enable', '/api/spotify/key', '/api/spotify/preferences', '/api/spotify/control', '/api/spotify/maintenance',
'/api/ui/layout', '/api/ui/layout/preview', '/api/favorites/slots',
@@ -2314,6 +2330,27 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
else:
self.json(401, {'error': 'Sign in to Galaxy'})
return
if path == '/api/starport/logs/send':
if type(payload) is not dict or set(payload) != {'message'}:
self.json(400, {'error': 'Invalid StarPort log send request'})
return
identity = self.settings_session()
if identity is None:
self.json(401, {'error': 'Sign in to Galaxy'})
return
from openpilot.starpilot.galaxy.starport_send import SendBusy, SendRejected
try:
result = starport_log_sender().start(payload['message'])
except SendRejected as error:
self.json(400, {'error': str(error)})
except SendBusy as error:
self.json(409, {'error': str(error)})
else:
if self.settings_session() == identity:
self.json(202, result)
else:
self.json(401, {'error': 'Sign in to Galaxy'})
return
if path == '/api/starport/enable':
if type(payload) is not dict or set(payload) not in ({'enabled'}, {'enabled', 'enableBluetooth'}) or \
type(payload['enabled']) is not bool or type(payload.get('enableBluetooth', False)) is not bool or \
@@ -2363,6 +2400,18 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
else:
self.json(401, {'error': 'Sign in to Galaxy'})
return
if action == 'new_setup' and set(payload) == {'action', 'enabled'} and type(payload['enabled']) is bool:
# The way back to the classic screens, so it works with the StarPort service off or broken.
from openpilot.starpilot.system.starport import new_setup
if not configuration_allowed():
self.json(403, {'error': 'Park before changing the setup screens'})
elif not new_setup.set_enabled(payload['enabled']):
self.json(503, {'error': 'Could not save the setup screen choice'})
elif self.settings_session() == identity:
self.json(200, {'newSetup': new_setup.enabled()})
else:
self.json(401, {'error': 'Sign in to Galaxy'})
return
if not starport_setup_owner().enabled():
self.json(409, {'error': 'Enable StarPort first'})
return
@@ -90,6 +90,10 @@ DESCRIPTIONS = {
'RemoteStartBootsComma': 'Wakes comma during GM remote start.',
'HKGRemoteStartBootsComma': 'Wakes comma during Hyundai, Kia or Genesis remote climate operation.',
'IgnoreIgnitionLine': 'Uses GM CAN ignition instead of the harness ignition signal.',
'heading:starport': 'Settings for connecting Android Auto to the car through StarPort.',
'StarPortNewSetup': 'Shows the guided StarPort setup with a live checklist instead of the classic setup screens.',
'StarPortAllowHevc': 'Lets a car that offers only H.265 video receive H.265; a car with an H.264 mode keeps H.264.',
'StarPortDedicatedWifi': "Joins the car's Wi-Fi on a second interface so the comma stays on its home Wi-Fi or phone hotspot.",
'TeslaWakeOnCAN': 'Wakes comma when the Tesla wakes up, before you press the brake.',
'RadarRecoveryAlert': 'Play a chime and show a brief message when a radar fault clears. You must re-engage manually.',
'CameraView': 'Chooses the camera view shown on the driving screen.',
@@ -158,6 +162,9 @@ DESCRIPTIONS = {
# These are initialization explanations, not a second defaults table.
INITIALIZATION = {
'StarPortNewSetup': 'Applies the next time the setup screen opens.',
'StarPortAllowHevc': 'Applies from the next StarPort connection.',
'StarPortDedicatedWifi': 'Applies from the next StarPort connection.',
'LateralControllerSelection': ('Applies when driving processes next start: the controller class, vehicle policy and ' +
'PID/filter state are constructed together.'),
'UseStarPilotLongitudinalPlanner': 'Applies when the planner next starts: each choice runs a different planner process with its own subscriptions and state.',
+3 -2
View File
@@ -27,7 +27,7 @@ from openpilot.starpilot.audio.alert_volume import SPECS as SOUND_SPECS
from openpilot.starpilot.ui.presentation import Profile
from openpilot.common.hardware import HARDWARE
from openpilot.starpilot.ui.feature_settings_state import (
FeatureRow, FeatureSettingsRequest, FEATURE_CONFIRM_ACTIONS, is_long_confirm_action, row_change, row_default,
FeatureRow, FeatureSettingsRequest, FEATURE_CONFIRM_ACTIONS, is_heading, is_long_confirm_action, row_change, row_default,
)
from openpilot.starpilot.saved_source import read_saved
from openpilot.starpilot.galaxy.developer_settings import CALIBRATION_RESET, DRIVER_MONITORING_RESET, DeveloperSettingsOwner
@@ -392,7 +392,8 @@ def _projection(row: FeatureRow) -> dict:
"confirm": bool(row.key in CONFIRM_ACTIONS or is_long_confirm_action(row.key) or
row.key in (PIP_RESET, PIP_EDITOR, VASM_RESET, VASM_ANNOTATION) or row.key.startswith(PIP_FORMAT_PREFIX)),
"repairValue": row.repair_value, "defaultValue": row.default_value, "resetAvailable": row_default(row) is not None,
"displayScale": "lane-strength" if row.key == "LaneCenteringStrength" else None}
"displayScale": "lane-strength" if row.key == "LaneCenteringStrength" else None,
"heading": is_heading(row.key)}
def _question(row: FeatureRow, request: FeatureSettingsRequest) -> str:
+2 -2
View File
@@ -71,9 +71,9 @@ class StarPortLogs:
except OSError:
raise LogsUnavailable from None
def bundle(self, now: datetime | None = None) -> tuple[str, bytes]:
def bundle(self, now: datetime | None = None, *, system_snapshot: bool = True) -> tuple[str, bytes]:
try:
data = compat_report.bundle(self.directory, self.config_path)
data = compat_report.bundle(self.directory, self.config_path, **({} if system_snapshot else {"snapshot": None}))
except OSError:
raise LogsUnavailable from None
stamp = (now or datetime.now()).strftime("%Y%m%d-%H%M%S")
+271
View File
@@ -0,0 +1,271 @@
"""Upload logs on request to Discord, reusing a thread per device."""
from __future__ import annotations
import base64
import hashlib
import json
import re
import threading
import time
from collections.abc import Callable
from pathlib import Path
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from openpilot.common.utils import atomic_write
from openpilot.starpilot.system.starport import identity as identity_store
MESSAGE_LIMIT = 60
WEBHOOK_OVERRIDE_PATH = identity_store.DATA_DIR / "webhook_url"
THREAD_DIR = identity_store.DATA_DIR / "discord_threads"
# Obfuscation only; device-local configuration overrides this built-in webhook.
_WEBHOOK_KEY = b"starport-logs"
_WEBHOOK_DATA = "".join(("GwAVAgNVXVtJBRwEHAEQTxEfAl0VXQVAEBYRHA4dGxxdRRhaWlJLREBYRkZcS0UcXl1QQVwAEEIzAydBSA",
"4KIjQjDShfJj0ZRkA6OjEbMi4JRzE6Rh1sOxo/HCVCDRMlCzRBHyIhUgQFTDAfChwdAGkpWR0HES0ZJw=="))
DISCORD_FILE_LIMIT = 10 * 1024 * 1024 - 64 * 1024 # 10 MiB attachment limit, with room for the form fields
THREAD_CACHE_WARNING = "The forum thread could not be remembered on this device. The next upload may create another post."
CONTROL = re.compile(r"[\x00-\x1f\x7f]+")
class SendRejected(Exception):
pass
class SendBusy(Exception):
pass
def webhook_url(override_path: Path = WEBHOOK_OVERRIDE_PATH) -> str:
try:
override = override_path.read_text().strip()
except (OSError, UnicodeError):
override = ""
if override:
return override
if not _WEBHOOK_DATA:
return ""
data = base64.b64decode(_WEBHOOK_DATA)
return bytes(byte ^ _WEBHOOK_KEY[index % len(_WEBHOOK_KEY)] for index, byte in enumerate(data)).decode()
def clean_message(value) -> str:
if not isinstance(value, str):
raise SendRejected("The message must be text.")
message = CONTROL.sub(" ", value).strip()
if len(message) > MESSAGE_LIMIT:
raise SendRejected(f"Keep the message to {MESSAGE_LIMIT} characters.")
return message
def _car_fingerprint() -> str:
from openpilot.common.params import Params
from openpilot.starpilot.saved_source import read_saved
raw, readable = read_saved(Params(), "CarParamsPersistent", 1 << 20)
if not readable or not raw:
return ""
from openpilot.cereal import messaging
from opendbc.car.structs import car
from openpilot.starpilot.schema_cache import inspect_cache
inspected = inspect_cache("CarParamsPersistent", raw)
if inspected.status != "valid" or inspected.payload is None:
return ""
fingerprint = str(messaging.log_from_bytes(inspected.payload, car.CarParams).carFingerprint or "").strip()
return "" if fingerprint == "MOCK" else fingerprint
def _head_unit(log_dir: Path | None, logs_to_check: int = 3) -> str:
"""The car's head unit from the newest session log that identified it, or ''."""
from openpilot.starpilot.system.starport import compat_report
for path in compat_report.session_logs(log_dir or identity_store.LOG_DIR)[:logs_to_check]:
car = compat_report.summarize(compat_report.load_events(path))["car"]
name = " ".join(part for part in (car.get("head_unit_make"), car.get("head_unit_model")) if part)
name = name or car.get("display_name") or car.get("bluetooth_name") or ""
if name:
return name
return ""
def _setup(log_dir: Path | None, logs_to_check: int = 3) -> str:
"""The setup the owner chose (built-in / adapter / usb / app) and its profile, from the newest log that recorded one."""
from openpilot.starpilot.system.starport import compat_report
from openpilot.starpilot.system.starport.bugsink_report import session_setup
for path in compat_report.session_logs(log_dir or identity_store.LOG_DIR)[:logs_to_check]:
setup = session_setup(compat_report.load_events(path))
if setup:
return " / ".join(str(setup[key]) for key in ("type", "profile", "profile_source") if setup.get(key))
return ""
def _handshake(log_dir: Path | None, logs_to_check: int = 3) -> str:
"""What the setup ladder tried in the newest log that recorded it, e.g. "standard (advanced) > other_reply (worked)"."""
from openpilot.starpilot.system.starport import compat_report
from openpilot.starpilot.system.starport.bugsink_report import session_ladder
for path in compat_report.session_logs(log_dir or identity_store.LOG_DIR)[:logs_to_check]:
tried = session_ladder(compat_report.load_events(path))
if tried:
return " > ".join(f"{item['rung']}{' learned' if item.get('learned') else ''} ({item.get('outcome', 'tried')})" for item in tried[-6:])
return ""
def device_summary(log_dir: Path | None = None) -> dict:
from openpilot.starpilot.system.starport.bugsink_transport import device_metadata
summary = device_metadata()
for key, read in (("car_fingerprint", _car_fingerprint), ("head_unit", lambda: _head_unit(log_dir)), ("setup", lambda: _setup(log_dir)),
("handshake", lambda: _handshake(log_dir))):
try:
summary[key] = read()
except Exception as error:
summary[f"{key}_error"] = str(error)
return summary
def device_tag(summary: dict) -> str:
"""8 characters of a hash of the dongle ID: tells devices apart without showing the ID."""
dongle_id = summary.get("device_id") or ""
return hashlib.sha256(f"starpilot-auto:{dongle_id}".encode()).hexdigest()[:8]
def thread_title(summary: dict) -> str:
return f"{summary.get('car_fingerprint') or 'Device'} {device_tag(summary)}"[:100]
def _response_json(response) -> dict:
try:
value = response.json()
except (AttributeError, ValueError):
return {}
return value if isinstance(value, dict) else {}
def _save_thread_id(path: Path, key: str, thread_id: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
# A power loss during a write must not truncate the previous cache entry.
with atomic_write(str(path), overwrite=True) as handle:
json.dump({"key": key, "thread_id": thread_id}, handle)
def send(data: bytes, name: str, note: str, summary: dict, url: str | None = None, post=None,
thread_dir: Path | None = None) -> str | None:
"""Post the zip; return a cache warning after success, or raise SendRejected on an upload failure."""
url = webhook_url() if url is None else url
if not url:
raise SendRejected("Sending isn't set up on this build. Use Download all logs and send the file instead.")
if len(data) > DISCORD_FILE_LIMIT:
raise SendRejected(f"The logs are {len(data) / 1e6:.1f} MB, over Discord's 10 MB limit. Use Download all logs instead.")
if post is None:
import requests
post = requests.post
dongle_id = summary.get("device_id") or "device"
lines = [f"**StarPort logs from {device_tag(summary)}**",
f"Car: {summary.get('car_fingerprint') or 'unknown'}",
f"Head unit: {summary.get('head_unit') or 'unknown'}",
f"Setup: {summary.get('setup') or 'unknown'}",
f"Handshake: {summary.get('handshake') or 'unknown'}",
f"Branch {summary.get('branch', '?')} @ {str(summary.get('commit', '?'))[:10]}",
f"Note: {note.strip() or '(none)'}"]
payload = {"content": "\n".join(lines)[:1900], "username": "StarPilot diagnostics", "allowed_mentions": {"parse": []}}
parts = urlsplit(url)
query = dict(parse_qsl(parts.query, keep_blank_values=True))
explicit_thread = bool(query.get("thread_id"))
# Ignore query options when identifying a webhook, and never store its token in the cache.
cache_key = hashlib.sha256(json.dumps([parts.scheme, parts.netloc, parts.path.rstrip("/"), dongle_id]).encode()).hexdigest()
thread_path = (THREAD_DIR if thread_dir is None else thread_dir) / f"{cache_key}.json"
cached_id = None
if not explicit_thread:
query.pop("thread_id", None)
try:
entry = json.loads(thread_path.read_text())
if isinstance(entry, dict) and entry.get("key") == cache_key:
candidate = entry.get("thread_id")
if isinstance(candidate, str) and candidate.isascii() and candidate.isdigit():
cached_id = candidate
except (OSError, ValueError):
pass
# Always wait for confirmation, including uploads to an existing thread.
query["wait"] = "true"
for attempt in range(2):
if not explicit_thread and cached_id:
query["thread_id"] = cached_id
elif not explicit_thread:
query.pop("thread_id", None)
payload["thread_name"] = thread_title(summary)
post_url = urlunsplit(parts._replace(query=urlencode(query)))
try:
response = post(post_url, data={"payload_json": json.dumps(payload)}, files={"files[0]": (name, data, "application/zip")}, timeout=120)
except Exception as error:
raise SendRejected(f"Could not send the logs: {type(error).__name__}. Check the internet connection, or use Download all logs.") from error
# Unknown Channel means the cached thread was deleted (Discord answers 400 for webhooks, 404 elsewhere).
# Unknown Webhook does not.
if attempt == 0 and cached_id and response.status_code in (400, 404) and _response_json(response).get("code") == 10003:
cached_id = None # Retry from memory even if the cache cannot be removed.
try:
thread_path.unlink(missing_ok=True)
except OSError:
pass
continue
break
if response.status_code not in (200, 204):
reason = str(_response_json(response).get("message") or "")[:120]
detail = f"HTTP {response.status_code}{f': {reason}' if reason else ''}"
raise SendRejected(f"Discord refused the logs ({detail}). Use Download all logs instead.")
if not explicit_thread and not cached_id:
new_thread_id = _response_json(response).get("channel_id")
if not isinstance(new_thread_id, str) or not new_thread_id.isascii() or not new_thread_id.isdigit():
return THREAD_CACHE_WARNING
try:
_save_thread_id(thread_path, cache_key, new_thread_id)
except OSError:
return THREAD_CACHE_WARNING
return None
class StarPortLogSender:
"""One send at a time, off the request thread; the page polls status()."""
def __init__(self, bundle: Callable[..., tuple[str, bytes]], *, sender: Callable[..., str | None] = send,
summary: Callable[[], dict] = device_summary, url: Callable[[], str] = webhook_url):
self._bundle, self._sender, self._summary, self._url = bundle, sender, summary, url
self._lock = threading.Lock()
self._thread: threading.Thread | None = None
self._state = {"state": "idle", "message": ""}
def status(self) -> dict:
with self._lock:
return {**self._state, "available": bool(self._url()), "messageLimit": MESSAGE_LIMIT}
def start(self, message) -> dict:
note = clean_message(message)
with self._lock:
if self._thread is not None and self._thread.is_alive():
raise SendBusy("StarPort logs are already being sent.")
self._state = {"state": "sending", "message": "Collecting logs"}
self._thread = threading.Thread(target=self._run, args=(note,), name="starport_log_send", daemon=True)
self._thread.start()
return self.status()
def _set(self, **values) -> None:
with self._lock:
self._state.update(values)
def _run(self, note: str) -> None:
try:
summary = self._summary()
name, data = self._bundle()
left_out = ""
if len(data) > DISCORD_FILE_LIMIT:
# The device snapshot is the optional, bulky part; the session logs are what matter.
name, data = self._bundle(system_snapshot=False)
left_out = " The device snapshot was left out to fit Discord's 10 MB limit."
self._set(message="Sending logs")
warning = self._sender(data, name, note, summary)
self._set(state="sent", message=f"Logs sent.{left_out} {warning or 'Thanks!'}".strip(), sentAt=int(time.time())) # noqa: TID251 shown as a wall-clock time
except SendRejected as error:
self._set(state="failed", message=str(error)[:400])
except Exception as error:
self._set(state="failed", message=f"Could not send the logs: {type(error).__name__}. Use Download all logs instead.")
+2 -1
View File
@@ -13,7 +13,7 @@ from pathlib import Path
from collections.abc import Callable
from typing import BinaryIO
from openpilot.starpilot.system.starport import apk_identity, apkpure
from openpilot.starpilot.system.starport import apk_identity, apkpure, new_setup
# Left behind by the removed Google sign-in: a saved refresh token and the resolver binary.
LEGACY_GOOGLE_FILES = (Path('/data/starpilot/starport_token'), Path('/data/starpilot/google-play'))
@@ -65,6 +65,7 @@ class StarPortSetup:
ident = self.identity_status()
return {
'enabled': self.enabled(), 'bluetoothEnabled': self.bluetooth_enabled(), 'parked': self.parked(),
'newSetup': new_setup.enabled(),
'installReady': self.install_ready(), 'serviceReady': self.service_ready(),
'identity': {key: ident[key] for key in ('installed', 'expires', 'days_left', 'warning', 'message', 'expired', 'error', 'app_version') if key in ident},
'import': self.job.status(),
@@ -524,3 +524,29 @@ def test_on_device_browser_api_is_gone(api):
cookie = headers['Set-Cookie'].split(';', 1)[0]
start = {'operation': 'start', 'email': 'user@example.com', 'remember': False}
assert request('POST', '/api/starport/google-browser', json.dumps(start), cookie=cookie, content_type='application/json')[0] != 200
def test_classic_setup_switch_needs_a_session_and_park_but_not_the_service(api, tmp_path, monkeypatch):
from openpilot.starpilot.system.starport import new_setup
monkeypatch.setattr(new_setup, 'FLAG_PATH', tmp_path / 'new_setup')
request, _, state, client = api
body = json.dumps({'action': 'new_setup', 'enabled': False})
assert request('POST', '/api/starport/control', body, content_type='application/json')[0] == 401
code, _, headers = request('POST', '/api/auth/login', json.dumps({'password': 'password123'}), content_type='application/json')
assert code == 200
cookie = headers['Set-Cookie'].split(';', 1)[0]
assert new_setup.enabled()
state['parked'] = False
assert request('POST', '/api/starport/control', body, cookie=cookie, content_type='application/json')[0] == 403
assert new_setup.enabled()
state['parked'] = True
client.calls.clear()
code, result, _ = request('POST', '/api/starport/control', body, cookie=cookie, content_type='application/json')
assert (code, result) == (200, {'newSetup': False}) and not new_setup.enabled()
assert client.calls == [] # the daemon was never asked: this is the way back when it is down
assert request('GET', '/api/starport/setup', cookie=cookie)[1]['newSetup'] is False
code, result, _ = request('POST', '/api/starport/control', json.dumps({'action': 'new_setup', 'enabled': True}),
cookie=cookie, content_type='application/json')
assert (code, result) == (200, {'newSetup': True}) and new_setup.enabled()
for bad in ({'action': 'new_setup'}, {'action': 'new_setup', 'enabled': 'no'}, {'action': 'new_setup', 'enabled': True, 'x': 1}):
assert request('POST', '/api/starport/control', json.dumps(bad), cookie=cookie, content_type='application/json')[0] == 400
@@ -57,13 +57,6 @@
"LoudBlindspotAlert",
"LoudBlindspotAlertWhenDisengaged",
"NostalgiaMode",
"slc:offset:1",
"slc:offset:2",
"slc:offset:3",
"slc:offset:4",
"slc:offset:5",
"slc:offset:6",
"slc:offset:7",
"PIPPreviewEnabled",
"PIPPreviewInvert",
"PIPPreviewShowOnBSM",
@@ -106,6 +99,9 @@
"StandardJerkSpeed",
"StandardJerkSpeedDecrease",
"StarPilotDisplayPreferencesEnabled",
"StarPortAllowHevc",
"StarPortDedicatedWifi",
"StarPortNewSetup",
"SteerFriction",
"SteerLatAccel",
"StockConfidenceBallWidget",
@@ -166,6 +162,7 @@
"display:auto:ScreenBrightness",
"display:auto:ScreenBrightnessOnroad",
"enable_human_turn_detection_curv",
"heading:starport",
"lane_change:auto",
"lane_change:close_gap",
"lane_change:close_gap_seconds",
@@ -249,7 +246,6 @@
"profile:aggressive:following:8",
"profile:aggressive:following:9",
"profile:global_acceleration",
"profile:stopped_gap",
"profile:global_braking",
"profile:relaxed:acceleration",
"profile:relaxed:acceleration:0",
@@ -317,6 +313,7 @@
"profile:standard:following:7",
"profile:standard:following:8",
"profile:standard:following:9",
"profile:stopped_gap",
"profile:traffic:acceleration",
"profile:traffic:acceleration:0",
"profile:traffic:acceleration:1",
@@ -357,6 +354,13 @@
"sentry:reset",
"sentry:sensitivity",
"sentry:warning",
"slc:offset:1",
"slc:offset:2",
"slc:offset:3",
"slc:offset:4",
"slc:offset:5",
"slc:offset:6",
"slc:offset:7",
"slc_reset",
"sounds:auto:DisengageVolume",
"sounds:auto:EngageVolume",
@@ -3,14 +3,18 @@
from dataclasses import replace
from pathlib import Path
from types import SimpleNamespace
from unittest import mock
from typing import cast
import tempfile
import unittest
from unittest.mock import patch
from openpilot.starpilot.galaxy.developer_settings import CALIBRATION_RESET, DRIVER_MONITORING_RESET, STARPORT_WIFI_KEY, DeveloperSettingsOwner
from openpilot.starpilot.galaxy.developer_settings import (CALIBRATION_RESET, DRIVER_MONITORING_RESET, STARPORT_HEADING_KEY,
STARPORT_HEVC_KEY, STARPORT_NEW_SETUP_KEY, STARPORT_WIFI_KEY,
DeveloperSettingsOwner)
from openpilot.starpilot.galaxy.settings import AuthorityContext, ContextSource, SettingsChanged, SettingsGateway
from openpilot.starpilot.longitudinal.voacc import KEY, selected
from openpilot.starpilot.system.starport import new_setup
from openpilot.starpilot.ui.feature_settings_state import FeatureSettingsRequest
@@ -40,7 +44,7 @@ class FileParams:
class FakeStarPort:
def __init__(self, enabled=False, running=True):
self.enabled, self.running = enabled, running
self.enabled, self.running, self.hevc = enabled, running, False
def call(self, command, **payload):
if not self.running:
@@ -48,7 +52,10 @@ class FakeStarPort:
if command == "set_dedicated_wifi":
self.enabled = payload["enabled"]
return {"ok": True}
return {"ok": True, "status": {"dedicated_wifi_interface": self.enabled}}
if command == "set_allow_hevc":
self.hevc = payload["enabled"]
return {"ok": True}
return {"ok": True, "status": {"dedicated_wifi_interface": self.enabled, "allow_hevc": self.hevc}}
class DeveloperSettingsTest(unittest.TestCase):
@@ -60,6 +67,11 @@ class DeveloperSettingsTest(unittest.TestCase):
self.cp = SimpleNamespace(openpilotLongitudinalControl=True, notCar=False, dashcamOnly=False, passive=False)
self.parked = True
self.owner = DeveloperSettingsOwner(self.params, lambda: self.parked)
flag = Path(directory.name) / "new_setup"
patch = mock.patch.object(new_setup, "FLAG_PATH", flag)
patch.start()
self.addCleanup(patch.stop)
self.flag = flag
def test_default_off_strict_selection_and_vehicle_admission(self):
self.assertEqual(self.owner.snapshot().rows[0].value, "Off")
@@ -172,6 +184,52 @@ class DeveloperSettingsTest(unittest.TestCase):
self.assertFalse(self.owner.apply(FeatureSettingsRequest(DRIVER_MONITORING_RESET, b"1", "confirm", confirmation=True)))
self.assertTrue(Path(self.params.get_param_path("IsRhdDetected")).exists())
def test_starport_section_has_a_heading_and_the_escape_hatch_even_with_the_service_off(self):
rows = self.owner.snapshot().rows
keys = [row.key for row in rows]
heading = rows[keys.index(STARPORT_HEADING_KEY)]
self.assertEqual((heading.label, heading.value, heading.available, heading.choices), ("StarPort", "", False, ()))
self.assertEqual(keys[keys.index(STARPORT_HEADING_KEY) + 1], STARPORT_NEW_SETUP_KEY) # the heading groups what follows
self.assertNotIn(STARPORT_WIFI_KEY, keys)
self.assertNotIn(STARPORT_HEVC_KEY, keys)
self.assertFalse(self.owner.apply(FeatureSettingsRequest(STARPORT_HEADING_KEY, b"", "On"))) # a heading is never a control
def test_new_pairing_experience_defaults_on_and_the_classic_screens_are_one_switch_away(self):
row = next(row for row in self.owner.snapshot().rows if row.key == STARPORT_NEW_SETUP_KEY)
self.assertEqual((row.value, row.source, row.choices, row.default_value, row.available), ("On", b"1", ("Off", "On"), "On", True))
self.assertFalse(self.owner.apply(FeatureSettingsRequest(STARPORT_NEW_SETUP_KEY, b"0", "On"))) # stale view
self.assertFalse(self.owner.apply(FeatureSettingsRequest(STARPORT_NEW_SETUP_KEY, b"1", "maybe")))
self.assertTrue(self.owner.apply(FeatureSettingsRequest(STARPORT_NEW_SETUP_KEY, b"1", "Off")))
self.assertFalse(new_setup.enabled())
self.assertEqual(next(r for r in self.owner.snapshot().rows if r.key == STARPORT_NEW_SETUP_KEY).value, "Off")
self.parked = False
self.assertFalse(self.owner.apply(FeatureSettingsRequest(STARPORT_NEW_SETUP_KEY, b"0", "On")))
self.parked = True
self.assertTrue(self.owner.apply(FeatureSettingsRequest(STARPORT_NEW_SETUP_KEY, b"0", "On")))
self.assertTrue(new_setup.enabled())
def test_unreadable_or_odd_flag_means_on(self):
self.flag.write_text("garbage")
self.assertTrue(new_setup.enabled())
self.flag.unlink()
self.assertTrue(new_setup.enabled())
def test_allow_h265_toggle_defaults_off_and_follows_the_daemon(self):
service = FakeStarPort()
owner = DeveloperSettingsOwner(self.params, lambda: self.parked, starport=lambda: service)
rows = owner.snapshot().rows
keys = [row.key for row in rows]
self.assertEqual(keys[keys.index(STARPORT_HEADING_KEY):], [STARPORT_HEADING_KEY, STARPORT_NEW_SETUP_KEY, STARPORT_WIFI_KEY, STARPORT_HEVC_KEY])
row = rows[keys.index(STARPORT_HEVC_KEY)]
self.assertEqual((row.value, row.default_value, row.available), ("Off", "Off", True))
self.assertTrue(owner.apply(FeatureSettingsRequest(STARPORT_HEVC_KEY, row.source, "On")))
self.assertTrue(service.hevc)
self.assertFalse(owner.apply(FeatureSettingsRequest(STARPORT_HEVC_KEY, row.source, "Off"))) # stale view
self.parked = False
self.assertFalse(owner.apply(FeatureSettingsRequest(STARPORT_HEVC_KEY, b"1", "Off")))
self.parked = True
service.running = False
self.assertFalse(owner.apply(FeatureSettingsRequest(STARPORT_HEVC_KEY, b"1", "Off")))
class CalibrationResetSettingsTest(unittest.TestCase):
setUp = DeveloperSettingsTest.setUp
@@ -57,6 +57,8 @@ class TestOffroadConfigurationPages(unittest.TestCase):
current = self.editable_inventory(self.page(name))
expected = saved[name]
if name == 'developer':
# gated by Park, not by the vehicle
expected, current = ([row for row in rows if row[0] != 'New pairing experience'] for rows in (expected, current))
self.assertEqual([row[0] for row in expected], ['Use VOACC'])
self.assertEqual([row[1] for row in expected], [False])
self.assertEqual([row[1] for row in current], [True])
@@ -160,6 +160,17 @@ const filtered = SettingsPage.computed.visibleRows.call({ state: { query: "lane"
] } } })
assert.deepEqual(filtered.map(({ index }) => index), [1]) // Filter keeps the original owner row index.
const developerRows = [{ label: "Use VOACC", value: "Off", reason: "" }, { label: "StarPort", value: "", reason: "", heading: true },
{ label: "Stay on Wi-Fi", value: "On", reason: "" }, { label: "Allow H.265 video", value: "Off", reason: "" }]
const cardsFor = (query) => {
const view = { state: { query, data: { rows: developerRows } }, initialPage: "developer", activeSection: {} }
view.visibleRows = SettingsPage.computed.visibleRows.call(view)
return SettingsPage.computed.rowCards.call(view).map(({ title, entries }) => [title, entries.map(({ index }) => index)])
}
assert.deepEqual(cardsFor(""), [["", [0]], ["StarPort", [2, 3]]], "a heading row starts its own card")
assert.deepEqual(cardsFor("h.265"), [["", []], ["StarPort", [3]]], "search keeps a row in its card when the heading is filtered out")
assert.deepEqual(cardsFor("voacc"), [["", [0]]], "an emptied card is not shown")
const conditional = fixture()
conditional.feed.start()
await conditional.reply(0, { ...lane, page: "hub", rows: [{ label: "Conditional driving modes", page: "conditional",
@@ -227,3 +227,15 @@ for (const row of [{ value: "30", minimum: 1, maximum: 30, step: 1, unit: "h" },
{ value: "11.8", minimum: 11.8, maximum: 12.5, step: .1, unit: "V" }]) {
assert.equal(settingControl(row), "slider")
}
// A section heading (for example "StarPort" on the Developer page) is a label only: no control, no readout, no default button.
{
const heading = { label: "StarPort", value: "", heading: true, available: false, choices: [], key: "heading:starport" }
assert.equal(settingControl(heading), "readout")
const template = GalaxySettingRow.template
assert.match(template, /'gx-row--heading': row\.heading/)
assert.match(template, /v-else-if="!row\.heading" class="gx-row__value gx-row__readout"/)
assert.match(template, /showDefault && !row\.heading/)
assert.match(template, /presentedRow\.reason && !row\.heading/)
}
console.log("Settings controls: section headings render as labels only")
@@ -17,8 +17,10 @@ assert.equal(validPromptInput({ kind: "pin" }, "1234"), true)
assert.equal(validPromptInput({ kind: "pin" }, "\n"), false)
assert.equal(validPromptInput({ kind: "passkey" }, "1234567"), false)
assert.equal(validPromptInput(prompt, ""), true)
assert.match(StarPortPage.template, /Check that this code matches your car/)
assert.match(StarPortPage.template, /Check that this code matches your/)
assert.match(StarPortPage.template, /Cancel Search \/ Pairing/)
assert.equal(StarPortPage.computed.deviceChosen.call({ pairing: { ...pairing, state: "pairing" } }), true)
assert.equal(StarPortPage.computed.deviceChosen.call({ pairing: { ...pairing, state: "failed" } }), false)
assert.doesNotMatch(StarPortPage.template, /:row="wiredRow"/)
assert.match(StarPortPage.template, /Read the Android Auto troubleshooting guide/)
assert.doesNotMatch(StarPortPage.template, /v-html|autoaccept/i)
@@ -484,6 +486,9 @@ typed.runtime = { car_type: 'adapter' }
assert.equal(typed.adapterSetup, true)
assert.equal(typed.pairsHere, true)
assert.equal(typed.findLabel, 'adapter')
assert.equal(typed.deviceWord, 'adapter', 'the pairing prompts name the adapter, not the car')
typed.runtime = { car_type: 'builtin' }
assert.equal(typed.deviceWord, 'car')
typed.runtime = { car_type: 'usb' }
assert.equal(typed.pairsHere, false, 'a USB cable needs no Bluetooth pairing')
typed.runtime = { running: false, connection: 'wired' }
@@ -524,15 +529,16 @@ 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')
assert.deepEqual(dongle.adapterRow.choices, ['AAWireless', 'Other'])
assert.equal(dongle.adapterRow.value, 'Motorola MA1')
assert.deepEqual(dongle.adapterRow.choices, ['AAWireless', 'Motorola MA1', 'Unofficial dongle'])
dongle.runtime = { car_type: 'adapter', car_profile: 'aawireless', car_profile_source: 'override' }
assert.equal(dongle.adapterRow.value, 'AAWireless')
const dongleCalls = []
dongle.control = (action, extra) => dongleCalls.push([action, extra])
dongle.saveAdapter(0, 'Other')
dongle.saveAdapter(0, 'Motorola MA1')
dongle.saveAdapter(0, 'AAWireless')
assert.deepEqual(dongleCalls, [['car_profile', { profile: 'default' }], ['car_profile', { profile: 'aawireless' }]])
dongle.saveAdapter(0, 'Unofficial dongle')
assert.deepEqual(dongleCalls, [['car_profile', { profile: 'default' }], ['car_profile', { profile: 'aawireless' }], ['car_profile', { profile: 'unofficial' }]])
assert.match(StarPortPage.template, /adapterSetup" :row="adapterRow"/)
assert.match(StarPortPage.template, /What’s in your car\?|carTypeRow/)
assert.match(StarPortPage.template, /runtime\?\.pairing_note/)
@@ -0,0 +1,239 @@
import assert from "node:assert/strict"
import { StarPortFeed, StarPortPage } from "../web/js/starport.js"
import { guidedState, connectionStages, handshakeSteps, StarPortGuidedSetup, FIRST_RUN_NOTICE, EXHAUSTED_NOTICE } from "../web/js/starport-guided.js"
const setup = { identity: { installed: true } }
const adapterModels = { aawireless: "AAWireless", default: "Motorola MA1", unofficial: "Unofficial dongle" }
const appModels = { headunitpad: "HeadunitPad", openheadunit: "Open Headunit" }
const ladder = (over = {}) => ({ steps: [{ id: "standard", label: "Standard handshake", state: "current" }, { id: "other_reply", label: "The other version reply", state: "waiting" }],
learned: false, passes: 0, max_passes: 3, exhausted: false, successes: 0, ...over })
const running = (state, over = {}) => ({ running: true, state, connection: "wireless", setup_ladder: ladder(), ...over })
// Before anything is known the card stays out of the way.
assert.equal(guidedState({}).show, false)
assert.equal(guidedState({ setup: null, runtime: running("rfcomm") }).show, false)
// Without the Android Auto package the first thing to do is that step, nothing else.
{
const state = guidedState({ setup: { identity: { installed: false } }, runtime: null })
assert.equal(state.phase, "package")
assert.equal(state.show, true)
assert.deepEqual(state.choices, [])
}
// First run: one question, four answers, the current one marked, and only the steps for that answer.
{
const state = guidedState({ setup, runtime: { running: false, state: "idle" }, carType: "builtin" })
assert.equal(state.phase, "setup")
assert.deepEqual(state.choices.map((choice) => choice.key), ["builtin", "adapter", "usb", "app"])
assert.deepEqual(state.choices.map((choice) => choice.selected), [true, false, false, false])
assert.deepEqual(state.choices.map((choice) => choice.title), ["Built-in wireless", "Wireless adapter", "USB cable", "Tablet app"])
assert.equal(state.title, "Set up your car")
assert.match(state.detail, /pairing screen/)
assert.match(guidedState({ setup, runtime: null, carType: "adapter" }).detail, /pairing mode/)
assert.match(guidedState({ setup, runtime: null, carType: "usb" }).detail, /USB data port/)
assert.match(guidedState({ setup, runtime: null, carType: "app" }).detail, /tablet/)
}
// Cars to choose between: paired and offering Android Auto but missing from the page's own paired list; never the selected one,
// never twice, not while pairing. (The page lists the rest itself in the pairing step.)
{
const runtime = { running: false, state: "idle", car_candidates: [{ address: "AA:BB:CC:DD:EE:01", name: "Civic" }, { address: "AA:BB:CC:DD:EE:02", name: "Truck" }] }
const receivers = [{ address: "AA:BB:CC:DD:EE:02", name: "Truck" }, { address: "AA:BB:CC:DD:EE:03", name: "Wagon" }, null, { address: 5, name: "x" }]
const names = (state) => state.candidates.map((car) => car.name)
assert.deepEqual(names(guidedState({ setup, runtime, receivers, carType: "builtin" })), ["Civic"])
assert.deepEqual(names(guidedState({ setup, runtime, receivers: [], carType: "builtin" })), ["Civic", "Truck"])
assert.deepEqual(names(guidedState({ setup, runtime, receivers, carType: "builtin", selected: { address: "AA:BB:CC:DD:EE:01", name: "Civic" } })), [])
assert.deepEqual(names(guidedState({ setup, runtime, receivers, carType: "builtin", pairingActive: true })), [])
assert.deepEqual(names(guidedState({ setup, runtime, receivers, carType: "usb" })), []) // a cable has no cars to pair
}
// A finished setup shows nothing unless a connection is in progress.
{
assert.equal(guidedState({ setup, runtime: { running: false, state: "idle" }, setupDone: true }).show, false)
assert.equal(guidedState({ setup, runtime: running("streaming"), setupDone: true }).show, false)
const state = guidedState({ setup, runtime: running("wifi_start"), setupDone: true, carType: "builtin" })
assert.equal(state.show, true)
assert.equal(state.phase, "connecting")
}
// The checklist follows the stage the daemon reports.
{
const states = (runtime) => connectionStages(runtime).map((step) => step.state)
assert.deepEqual(states(running("connecting_bluetooth")), ["active", "waiting", "waiting", "waiting"])
assert.deepEqual(states(running("joining_wifi")), ["done", "active", "waiting", "waiting"])
assert.deepEqual(states(running("authenticating")), ["done", "done", "active", "waiting"])
assert.deepEqual(states(running("negotiating")), ["done", "done", "done", "active"])
assert.deepEqual(states(running("streaming")), ["done", "done", "done", "done"])
assert.deepEqual(states(running("backoff", { last_stage: "wifi_start" })), ["done", "retry", "waiting", "waiting"])
assert.deepEqual(states(running("backoff", {})), ["retry", "waiting", "waiting", "waiting"])
assert.deepEqual(connectionStages(running("waiting_for_usb", { connection: "wired" })).map((step) => step.id), ["cable", "secure", "dashboard"])
assert.deepEqual(connectionStages(running("waiting_for_ipad", { connection: "headunitpad" })).map((step) => step.id), ["app", "secure", "dashboard"])
assert.deepEqual(connectionStages(null).map((step) => step.state), ["active", "waiting", "waiting", "waiting"])
}
// The first-connection warning appears only to a wireless car with nothing learned, and not once it has worked.
{
const first = guidedState({ setup, runtime: running("rfcomm"), carType: "builtin" })
assert.equal(first.notice, FIRST_RUN_NOTICE)
assert.equal(guidedState({ setup, runtime: running("rfcomm", { setup_ladder: ladder({ learned: true }) }) }).notice, "")
assert.equal(guidedState({ setup, runtime: running("rfcomm", { setup_ladder: ladder({ successes: 2 }) }) }).notice, "")
assert.equal(guidedState({ setup, runtime: running("waiting_for_usb", { connection: "wired" }) }).notice, "")
const exhausted = guidedState({ setup, runtime: running("backoff", { setup_ladder: ladder({ exhausted: true }) }) })
assert.equal(exhausted.notice, EXHAUSTED_NOTICE)
assert.equal(exhausted.exhausted, true)
}
// What is being tried is listed only once something was tried and failed, or one option is remembered.
{
assert.deepEqual(handshakeSteps(ladder()), [])
assert.deepEqual(handshakeSteps(null), [])
const tried = ladder({ steps: [{ id: "standard", label: "Standard handshake", state: "failed" }, { id: "other_reply", label: "The other version reply", state: "current" }] })
assert.deepEqual(handshakeSteps(tried).map((step) => step.state), ["failed", "current"])
assert.equal(guidedState({ setup, runtime: running("wifi_start", { setup_ladder: tried }) }).handshake.length, 2)
assert.equal(handshakeSteps(ladder({ learned: true })).length, 2)
assert.deepEqual(handshakeSteps({ steps: [null, { label: 3 }, { id: "a", label: "A", state: "worked" }] }).map((step) => step.id), ["a"])
}
// The component emits the page's actions and never invents state.
{
assert.deepEqual(StarPortGuidedSetup.emits, ["choose-type", "choose-adapter", "choose-app", "use-car", "classic", "done"])
const template = StarPortGuidedSetup.template
assert.match(template, /\$emit\('choose-type', choice\.key\)/)
assert.match(template, /\$emit\('use-car', car\.address\)/)
assert.match(template, /\$emit\('classic'\)/)
assert.match(template, /Use classic setup/)
assert.doesNotMatch(template, /v-html/)
}
// The way back to the classic screens posts one narrow action, even when the StarPort service is off.
{
const calls = []
const reply = (body, code = 200) => ({ status: code, ok: code < 300, json: async () => structuredClone(body) })
const feed = new StarPortFeed({ publish: () => {}, fetcher: async (path, options) => {
calls.push([path, options])
return reply(path.endsWith("/setup") ? { enabled: false, bluetoothEnabled: false, parked: true, installReady: true, serviceReady: false,
identity: { installed: true }, import: {}, maxUploadBytes: 1000, newSetup: true } : { newSetup: false })
}, later: () => 1, cancelTimer: () => {} })
assert.equal(await feed.setNewSetup(false), false) // not started yet
feed.active = true
assert.equal(await feed.setNewSetup("no"), false)
assert.equal(await feed.setNewSetup(false), true)
const [path, options] = calls.find(([target]) => target.endsWith("/control"))
assert.equal(path, "./api/starport/control")
assert.deepEqual(JSON.parse(options.body), { action: "new_setup", enabled: false })
assert.equal(options.method, "POST")
}
// The page uses the guided card by default and only when the setup response does not say newSetup is off.
{
const template = StarPortPage.template
assert.match(template, /<StarPortGuidedSetup v-if="setup && newSetup && guided\.show"/)
assert.match(template, /@choose-type="chooseType" @choose-adapter="chooseAdapter" @choose-app="chooseApp" @use-car="useCar" @classic="useClassic"/)
assert.match(template, /Try the guided setup/)
const vm = { setup: { newSetup: false } }
assert.equal(StarPortPage.computed.newSetup.call(vm), false)
assert.equal(StarPortPage.computed.newSetup.call({ setup: {} }), true) // default on, including an older server
assert.equal(StarPortPage.computed.newSetup.call({ setup: null }), true)
}
console.log("StarPort guided setup: question, steps, candidates, live checklist, first-run notice and handshake list passed")
// The adapter's "connect your car in Bluetooth first" refusal becomes a step with a link, not a dead end.
{
const { BLUETOOTH_HINT } = await import("../web/js/starport-guided.js")
const refused = { running: false, state: "error", error: "Connect your paired car in Galaxy Bluetooth first to select it for this Android Auto adapter" }
const state = guidedState({ setup, runtime: refused, carType: "adapter" })
assert.equal(state.phase, "setup") // the usual question and steps stay, with the action first
assert.deepEqual(state.hint, BLUETOOTH_HINT)
assert.equal(BLUETOOTH_HINT.href, "#/bluetooth")
const done = guidedState({ setup, runtime: refused, setupDone: true })
assert.equal(done.phase, "bluetooth") // even a finished setup shows the thing to fix
assert.equal(guidedState({ setup, runtime: { running: false, state: "error", error: "something else" }, carType: "adapter" }).hint, null)
assert.match(StarPortGuidedSetup.template, /state\.hint\.href/)
}
console.log("StarPort guided setup: Bluetooth-first hint passed")
// The three steps are always shown, and follow the setup along.
{
const states = (input) => guidedState({ setup, ...input }).steps.map((step) => step.state)
const labels = (input) => guidedState({ setup, ...input }).steps.map((step) => step.label)
assert.deepEqual(guidedState({ setup: { identity: { installed: false } } }).steps.map((step) => step.state), ["current", "todo", "todo"])
assert.deepEqual(states({ runtime: null }), ["done", "current", "todo"])
assert.deepEqual(states({ runtime: null, setupDone: true, editing: true }), ["done", "done", "current"])
assert.deepEqual(states({ runtime: running("wifi_start"), setupDone: true }), ["done", "done", "current"])
assert.deepEqual(states({ runtime: running("streaming"), setupDone: true }), ["done", "done", "done"])
assert.deepEqual(labels({ carType: "builtin" }), ["Support", "Car", "Connect"])
assert.deepEqual(labels({ carType: "adapter" }), ["Support", "Adapter", "Connect"])
assert.equal(labels({ carType: "usb" })[1], "Cable")
assert.equal(labels({ carType: "app" })[1], "App")
// Every phase carries them, including the Bluetooth-first refusal and a connection in progress.
for (const input of [{ runtime: running("rfcomm") }, { runtime: null }, { runtime: { running: false, error: "Connect your paired car in Galaxy Bluetooth first" }, setupDone: true }]) {
assert.equal(guidedState({ setup, ...input }).steps.length, 3)
}
}
// The adapter model and the app are picked in the card, not from a dropdown.
{
const adapter = guidedState({ setup, runtime: null, carType: "adapter", adapterModels, adapterKey: "" })
assert.deepEqual(adapter.adapter.options.map((option) => option.label), ["AAWireless", "Motorola MA1", "Unofficial dongle"])
assert.equal(adapter.adapter.needsChoice, true)
const chosen = guidedState({ setup, runtime: null, carType: "adapter", adapterModels, adapterKey: "aawireless" })
assert.deepEqual(chosen.adapter.options.map((option) => option.selected), [true, false, false])
assert.equal(chosen.adapter.needsChoice, false)
assert.equal(guidedState({ setup, runtime: null, carType: "builtin", adapterModels }).adapter, null)
const apps = guidedState({ setup, runtime: null, carType: "app", appModels, app: "HeadunitPad" })
assert.deepEqual(apps.apps.options.map((option) => [option.key, option.selected]), [["headunitpad", true], ["openheadunit", false]])
assert.equal(guidedState({ setup, runtime: null, carType: "app", appModels, app: "" }).apps.needsChoice, true)
assert.equal(guidedState({ setup, runtime: null, carType: "usb", appModels }).apps, null)
}
// A finished setup can be reopened from the home card; Done closes it.
{
const reopened = guidedState({ setup, runtime: { running: false, state: "idle" }, setupDone: true, editing: true })
assert.equal(reopened.show, true)
assert.equal(reopened.editing, true)
assert.equal(reopened.title, "Change how your car connects")
assert.equal(guidedState({ setup, runtime: { running: false, state: "idle" }, setupDone: true, editing: false }).show, false)
assert.match(StarPortGuidedSetup.template, /\$emit\('done'\)/)
}
// Dropdowns belong to the classic screens only; the guided card replaces them and keeps pairing, steps and Connect.
{
const template = StarPortPage.template
const guidedBlock = template.slice(template.indexOf("<StarPortGuidedSetup"), template.indexOf("</StarPortGuidedSetup>"))
for (const dropdown of [":row=\"carTypeRow\"", ":row=\"adapterRow\"", ":row=\"appRow\""]) assert.ok(!guidedBlock.includes(dropdown), dropdown)
assert.match(guidedBlock, /<template #car>/)
assert.match(guidedBlock, /beginPairing/) // Find car / Find adapter
assert.match(guidedBlock, /@click="connect"/) // the Connect button
assert.match(guidedBlock, /Stuck pairing\?/) // the pairing help stays
assert.match(template, /<ol v-if="setup && !newSetup" id="gx-starport-steps"/) // the classic step 2, with its dropdown
const home = template.slice(template.indexOf("gx-starport-home\" aria-labelledby"))
assert.match(home, /<template v-if="!newSetup">\s*<GalaxySettingRow :row="carTypeRow"/)
assert.match(StarPortGuidedSetup.template, /gx-guided__steps/)
assert.match(StarPortGuidedSetup.template, /<slot name="car" \/>/)
assert.match(StarPortGuidedSetup.template, /<slot name="connect" \/>/)
}
console.log("StarPort guided setup: steps, adapter and app choices, reopening and classic-only dropdowns passed")
// Choosing a cable or an app finishes the setup at once; a choice made in the card must not close the card.
{
const calls = []
const vm = { showSteps: false, carTypeLabels: { app: "App", usb: "Wired AA" },
saveCarType: (index, label) => calls.push(["type", label]), saveAdapter: (index, label) => calls.push(["adapter", label]),
saveApp: (index, label) => calls.push(["app", label]) }
StarPortPage.methods.chooseType.call(vm, "app")
assert.equal(vm.showSteps, true)
vm.showSteps = false
StarPortPage.methods.chooseApp.call(vm, "headunitpad")
assert.equal(vm.showSteps, true)
vm.showSteps = false
StarPortPage.methods.chooseAdapter.call(vm, "aawireless")
assert.equal(vm.showSteps, true)
assert.deepEqual(calls, [["type", "App"], ["app", "HeadunitPad"], ["adapter", "AAWireless"]])
// With the card held open by a choice, the finished setup is shown for editing with its Done button, not hidden.
const held = guidedState({ setup, runtime: { running: false, state: "idle", connection: "headunitpad" }, setupDone: true, editing: true, carType: "app", appModels, app: "HeadunitPad" })
assert.equal(held.show, true)
assert.equal(held.editing, true)
assert.equal(held.choices.find((choice) => choice.selected).key, "app")
assert.equal(held.apps.options.find((option) => option.selected).key, "headunitpad")
}
console.log("StarPort guided setup: a choice keeps the card open passed")
@@ -0,0 +1,90 @@
import assert from "node:assert/strict"
import { StarPortLogSend, SEND_URL, MESSAGE_LIMIT } from "../web/js/starport-logs.js"
const flush = async () => { for (let i = 0; i < 8; i++) await Promise.resolve() }
function fixture() {
const requests = [], states = [], timers = []
let unauthorized = 0
const client = new StarPortLogSend({ publish: (state) => states.push(state), unauthorized: () => { unauthorized++ },
fetcher: (url, options) => new Promise((resolve, reject) => requests.push({ url, options, resolve, reject })),
schedule: (fn) => { timers.push(fn); return timers.length }, cancel: () => {} })
const reply = async (index, body, status = 200) => {
requests[index].resolve({ ok: status >= 200 && status < 300, status, json: async () => body })
await flush()
}
return { client, requests, states, timers, reply, get unauthorized() { return unauthorized } }
}
assert.equal(SEND_URL, "./api/starport/logs/send")
assert.equal(MESSAGE_LIMIT, 60)
// Status on open, then a send that is polled until it finishes.
const normal = fixture()
normal.client.start()
assert.equal(normal.requests[0].url, SEND_URL)
await normal.reply(0, { state: "idle", message: "", available: true })
assert.equal(normal.states.at(-1).state, "idle")
const sending = normal.client.send(" Black screen ")
assert.equal(normal.states.at(-1).state, "sending")
assert.equal(normal.requests[1].options.method, "POST")
assert.deepEqual(JSON.parse(normal.requests[1].options.body), { message: "Black screen" })
await normal.reply(1, { state: "sending", message: "Collecting logs" }, 202)
assert.equal(await sending, true)
assert.equal(normal.timers.length, 1)
normal.timers[0]()
await normal.reply(2, { state: "sent", message: "Logs sent. Thanks!" })
assert.equal(normal.states.at(-1).state, "sent")
assert.equal(normal.timers.length, 1)
// Too long is refused locally; a busy device reports its error.
const limited = fixture()
limited.client.start()
await limited.reply(0, { state: "idle" })
assert.equal(await limited.client.send("x".repeat(61)), false)
assert.equal(limited.requests.length, 1)
assert.match(limited.states.at(-1).error, /60 characters/)
const busy = limited.client.send("hi")
await limited.reply(1, { error: "StarPort logs are already being sent." }, 409)
assert.equal(await busy, false)
assert.equal(limited.states.at(-1).error, "StarPort logs are already being sent.")
// Signed out.
const signedOut = fixture()
signedOut.client.start()
await signedOut.reply(0, {}, 401)
assert.equal(signedOut.unauthorized, 1)
assert.equal(await signedOut.client.send("hi"), false)
// Sending unavailable on this build hides the card.
const unset = fixture()
unset.client.start()
await unset.reply(0, { state: "idle", available: false })
assert.equal(unset.states.at(-1).available, false)
// A lost Bluetooth response retries the status read without repeating the upload.
const interrupted = fixture()
interrupted.client.start()
await interrupted.reply(0, { state: "sending" })
interrupted.timers[0]()
interrupted.requests[1].reject(new TypeError("Connection lost"))
await flush()
assert.equal(interrupted.timers.length, 2)
interrupted.timers[1]()
await interrupted.reply(2, { state: "sent", message: "Logs sent" })
assert.equal(interrupted.states.at(-1).state, "sent")
assert.ok(interrupted.requests.every(({ options }) => options.method !== "POST"))
// An older status read cannot replace a send's newer status, even across navigation.
const stale = fixture()
stale.client.start()
const pendingSend = stale.client.send("hello")
await stale.reply(1, { state: "sending" }, 202)
assert.equal(await pendingSend, true)
await stale.reply(0, { state: "idle" })
assert.equal(stale.states.at(-1).state, "sending")
stale.client.start()
stale.client.start()
await stale.reply(2, {}, 401)
assert.equal(stale.unauthorized, 0)
await stale.reply(3, { state: "sent" })
assert.equal(stale.states.at(-1).state, "sent")
@@ -0,0 +1,215 @@
"""Send StarPort Logs: the Discord upload, its per-device thread cache, the background job and its routes."""
import http.client
import json
import tempfile
import threading
import time
import unittest
from pathlib import Path
from unittest import mock
from urllib.parse import parse_qs, urlsplit
from openpilot.starpilot.galaxy import starport_send
from openpilot.starpilot.galaxy.access import GalaxyAccessOwner
from openpilot.starpilot.galaxy.server import make_server
from openpilot.starpilot.galaxy.starport_send import (DISCORD_FILE_LIMIT, MESSAGE_LIMIT, SendBusy, SendRejected, StarPortLogSender,
clean_message, send, thread_title)
URL = "https://discord.example/api/webhooks/1/token"
SUMMARY = {"device_id": "0123456789abcdef", "car_fingerprint": "HONDA_CIVIC_2022", "head_unit": "Honda Display Audio",
"branch": "Dom", "commit": "abcdef1234567890"}
class Response:
def __init__(self, status_code=200, body=None):
self.status_code, self.body = status_code, body if body is not None else {"channel_id": "555"}
def json(self):
return self.body
class Poster:
def __init__(self, *responses):
self.responses, self.calls = list(responses), []
def __call__(self, url, data, files, timeout):
self.calls.append({"url": url, "payload": json.loads(data["payload_json"]), "files": files})
return self.responses.pop(0)
def query(call):
return {key: values[0] for key, values in parse_qs(urlsplit(call["url"]).query).items()}
class SendTest(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.threads = Path(temporary.name)
def send(self, post, note="Dropped at the light", data=b"zip"):
return send(data, "logs.zip", note, SUMMARY, url=URL, post=post, thread_dir=self.threads)
def test_first_send_opens_a_device_thread_and_later_sends_reuse_it(self):
first = Poster(Response(body={"channel_id": "555"}))
self.assertIsNone(self.send(first))
call = first.calls[0]
self.assertEqual(call["payload"]["thread_name"], thread_title(SUMMARY))
self.assertTrue(call["payload"]["thread_name"].startswith("HONDA_CIVIC_2022 "))
self.assertEqual(call["payload"]["allowed_mentions"], {"parse": []})
self.assertIn("Note: Dropped at the light", call["payload"]["content"])
self.assertIn("Head unit: Honda Display Audio", call["payload"]["content"])
self.assertIn("Setup: unknown", call["payload"]["content"]) # no session log recorded a setup in this fixture
self.assertIn("Handshake: unknown", call["payload"]["content"])
self.assertNotIn(SUMMARY["device_id"], call["payload"]["content"])
self.assertEqual(query(call), {"wait": "true"})
self.assertEqual(call["files"]["files[0]"], ("logs.zip", b"zip", "application/zip"))
self.assertNotIn("token", "".join(path.read_text() for path in self.threads.iterdir()))
second = Poster(Response())
self.assertIsNone(self.send(second))
self.assertEqual(query(second.calls[0])["thread_id"], "555")
self.assertNotIn("thread_name", second.calls[0]["payload"])
def test_deleted_thread_is_forgotten_and_a_new_one_opened(self):
self.send(Poster(Response(body={"channel_id": "555"})))
poster = Poster(Response(404, {"code": 10003}), Response(body={"channel_id": "777"}))
self.assertIsNone(self.send(poster))
self.assertEqual(query(poster.calls[0])["thread_id"], "555")
self.assertNotIn("thread_id", query(poster.calls[1]))
self.assertEqual(poster.calls[1]["payload"]["thread_name"], thread_title(SUMMARY))
third = Poster(Response())
self.send(third)
self.assertEqual(query(third.calls[0])["thread_id"], "777")
def test_refusals_and_oversize_raise_without_posting(self):
with self.assertRaisesRegex(SendRejected, "HTTP 401: Invalid Webhook Token"):
self.send(Poster(Response(401, {"message": "Invalid Webhook Token"})))
poster = Poster()
with self.assertRaisesRegex(SendRejected, "10 MB"):
self.send(poster, data=b"x" * (DISCORD_FILE_LIMIT + 1))
self.assertEqual(poster.calls, [])
with self.assertRaisesRegex(SendRejected, "isn't set up"):
send(b"zip", "logs.zip", "", SUMMARY, url="", post=poster, thread_dir=self.threads)
def test_message_is_limited_and_single_line(self):
self.assertEqual(MESSAGE_LIMIT, 60)
self.assertEqual(clean_message(" car\nwent black "), "car went black")
self.assertEqual(clean_message("x" * 60), "x" * 60)
for bad in ("x" * 61, None, 5):
with self.assertRaises(SendRejected):
clean_message(bad)
def test_built_in_webhook_and_override(self):
self.assertTrue(starport_send.webhook_url(self.threads / "absent").startswith("https://discord.com/api/webhooks/"))
override = self.threads / "webhook_url"
override.write_text(f"{URL}\n")
self.assertEqual(starport_send.webhook_url(override), URL)
override.write_text("")
self.assertEqual(starport_send.webhook_url(override), starport_send.webhook_url(self.threads / "absent"))
class SenderJobTest(unittest.TestCase):
def wait(self, job):
deadline = time.monotonic() + 2
while job.status()["state"] == "sending" and time.monotonic() < deadline:
time.sleep(0.01)
return job.status()
def test_sends_in_the_background_and_drops_the_snapshot_when_too_big(self):
bundles, sent = [], []
def bundle(system_snapshot=True):
bundles.append(system_snapshot)
return "logs.zip", b"x" * (DISCORD_FILE_LIMIT + 1 if system_snapshot else 10)
job = StarPortLogSender(bundle, sender=lambda *args: sent.append(args), summary=lambda: SUMMARY, url=lambda: URL)
self.assertEqual(job.start("hello")["state"], "sending")
status = self.wait(job)
self.assertEqual(status["state"], "sent")
self.assertIn("snapshot was left out", status["message"])
self.assertEqual(bundles, [True, False])
self.assertEqual(sent[0][2], "hello")
def test_one_send_at_a_time_and_failures_are_reported(self):
release = threading.Event()
def sender(*_):
release.wait(2)
raise SendRejected("Discord refused the logs (HTTP 500).")
job = StarPortLogSender(lambda **_: ("logs.zip", b"zip"), sender=sender, summary=lambda: SUMMARY, url=lambda: URL)
job.start("")
with self.assertRaises(SendBusy):
job.start("again")
release.set()
self.assertEqual(self.wait(job), {"state": "failed", "message": "Discord refused the logs (HTTP 500).", "available": True,
"messageLimit": 60})
class SendHTTPTest(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.owner = GalaxyAccessOwner(Path(temporary.name) / "access")
self.job = mock.Mock()
self.job.status.return_value = {"state": "idle", "message": "", "available": True, "messageLimit": 60}
self.job.start.return_value = {"state": "sending", "message": "Collecting logs", "available": True, "messageLimit": 60}
self.server = make_server(port=0, owner=self.owner, starport_log_sender=self.job)
self.thread = threading.Thread(target=self.server.serve_forever, kwargs={"poll_interval": 0.01}, daemon=True)
self.thread.start()
self.addCleanup(self.close_server)
self.assertTrue(self.owner.configure("password123", lambda: True))
def close_server(self):
self.server.shutdown()
self.thread.join(timeout=2)
self.server.server_close()
def request(self, path, *, method="GET", body=None, headers=None):
connection = http.client.HTTPConnection("127.0.0.1", self.server.server_port, timeout=2)
try:
origin = {"Origin": f"http://127.0.0.1:{self.server.server_port}"} if method == "POST" else {}
connection.request(method, path, body=body, headers={"Forwarded": "for=203.0.113.8", **origin, **(headers or {})})
response = connection.getresponse()
return response.status, response.read(), dict(response.getheaders())
finally:
connection.close()
def post(self, body, cookie=None):
headers = {"Content-Type": "application/json", **({"Cookie": cookie} if cookie else {})}
return self.request("/api/starport/logs/send", method="POST", body=json.dumps(body), headers=headers)
def login(self):
status, _, headers = self.post_login()
self.assertEqual(status, 200)
return headers["Set-Cookie"].split(";", 1)[0]
def post_login(self):
return self.request("/api/auth/login", method="POST", body=json.dumps({"password": "password123"}),
headers={"Content-Type": "application/json"})
def test_requires_a_session(self):
self.assertEqual(self.request("/api/starport/logs/send")[0], 401)
self.assertEqual(self.post({"message": "hi"})[0], 401)
self.job.start.assert_not_called()
def test_status_start_and_rejections(self):
cookie = self.login()
status, body, _ = self.request("/api/starport/logs/send", headers={"Cookie": cookie})
self.assertEqual((status, json.loads(body)["state"]), (200, "idle"))
status, body, _ = self.post({"message": "Black screen after unlock"}, cookie)
self.assertEqual((status, json.loads(body)["state"]), (202, "sending"))
self.job.start.assert_called_once_with("Black screen after unlock")
self.assertEqual(self.post({"message": "hi", "extra": 1}, cookie)[0], 400)
self.job.start.side_effect = SendRejected("Keep the message to 60 characters.")
self.assertEqual(self.post({"message": "x" * 61}, cookie)[0], 400)
self.job.start.side_effect = SendBusy("StarPort logs are already being sent.")
self.assertEqual(self.post({"message": "hi"}, cookie)[0], 409)
if __name__ == "__main__":
unittest.main()
@@ -1060,6 +1060,9 @@ button.gx-chip:hover {
.gx-starport-log__download { flex: none; width: var(--touch-target); height: var(--touch-target); border: 1px solid var(--glass-border); background: var(--primary-container); color: var(--on-primary-container); }
.gx-starport-log__download:hover { background: var(--primary); color: var(--on-primary); }
.gx-starport-logs__section-title { margin: var(--sp-6) 0 var(--sp-3); }
.gx-starport-send__row { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-3); margin-top: var(--sp-3); }
.gx-starport-send__row .gx-field { flex: 1 1 220px; min-width: 0; }
.gx-starport-send__count { font-size: var(--fs-xs); opacity: .7; font-variant-numeric: tabular-nums; }
@media (max-width: 700px) {
.gx-starport-log { gap: var(--sp-3); padding: var(--sp-3); }
@@ -1708,6 +1711,9 @@ input[type="checkbox"]:disabled { opacity: .4; cursor: not-allowed; }
.gx-inset { margin: var(--sp-4); max-width: calc(100% - 2 * var(--sp-4)); }
.gx-text-danger { color: var(--error); }
.gx-row.gx-row--borderless { border-top: 0; }
/* A section title inside a settings page: a label only, never dimmed like an unavailable control. */
.gx-row.gx-row--heading, .gx-row.gx-row--heading.disabled { opacity: 1; padding-top: var(--sp-5); }
.gx-row--heading .gx-row__label { font-weight: 700; letter-spacing: .04em; text-transform: uppercase; opacity: .75; }
.gx-row--wrap { flex-wrap: wrap; }
.gx-row__info.gx-row__info--wide { flex: 1 1 180px; }
.gx-filter-field { flex: 1 1 14rem; min-width: 0; }
@@ -1995,3 +2001,52 @@ progress { max-width: 100%; accent-color: var(--primary); }
}
.gx-tuner__manual .gx-row { padding-inline: 0; }
/* Guided setup layout and emphasis; shared rows, buttons, fields and notices keep Galaxy defaults. */
.gx-card.gx-guided { display: grid; gap: var(--sp-5); padding: var(--panel-padding); background-image: radial-gradient(120% 38% at 50% 0, var(--primary-container), transparent 72%); }
.gx-guided__steps { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(3, 1fr); }
.gx-guided__step { position: relative; display: grid; justify-items: center; gap: var(--sp-2); text-align: center; }
.gx-guided__step::before, .gx-guided__step::after { content: ""; position: absolute; top: 15px; height: 2px; width: calc(50% - 8px); background: var(--outline); }
.gx-guided__step::before { left: 0; }
.gx-guided__step::after { right: 0; }
.gx-guided__step:first-child::before, .gx-guided__step:last-child::after { display: none; }
.gx-guided__step.is-done::before, .gx-guided__step.is-done::after, .gx-guided__step.is-current::before,
.gx-guided__step.is-done + .gx-guided__step::before { background: var(--success); }
.gx-guided__node { position: relative; display: grid; place-items: center; width: 32px; height: 32px; border-radius: var(--radius-full); background: var(--surface-container-high); color: var(--on-surface-variant); font-weight: var(--fw-bold); }
.gx-guided__step.is-current .gx-guided__node { background: var(--primary); color: var(--on-primary); box-shadow: 0 0 0 var(--sp-1) var(--primary-container), 0 var(--sp-1) var(--sp-4) var(--primary-glow); }
.gx-guided__step.is-done .gx-guided__node { background: var(--success); color: var(--background); }
.gx-guided__step-label, .gx-guided__hint { font-size: var(--fs-sm); color: var(--on-surface-variant); }
.gx-guided__header, .gx-guided__section, .gx-guided__body { display: grid; gap: var(--sp-3); min-width: 0; }
.gx-guided__header > *, .gx-guided__subhead, .gx-guided__hint { margin: 0; }
.gx-guided__header { max-width: 34rem; gap: var(--sp-2); }
.gx-guided__title { font-size: clamp(1.625rem, 2.5vw, 1.875rem); line-height: 1.15; letter-spacing: -.02em; text-wrap: balance; }
.gx-guided__lede { color: var(--on-surface-variant); font-size: var(--fs-base); line-height: 1.5; }
.gx-guided__subhead { font-size: var(--fs-base); color: var(--on-surface-variant); }
.gx-guided__body:empty { display: none; }
.gx-guided__option { display: flex; align-items: center; gap: var(--sp-3); min-height: var(--touch-target); padding: var(--sp-3) var(--sp-4); cursor: pointer; }
.gx-guided__option:hover:not(:has(:disabled)) { background: var(--glass-bg-hover); }
.gx-guided__option:has(:checked) { background: var(--primary-container); color: var(--on-primary-container); }
.gx-guided__option:has(:disabled), .gx-guided__choice:has(:disabled) { opacity: .6; cursor: default; }
.gx-guided__option:focus-within, .gx-guided__choice:focus-within { outline: 2px solid var(--primary); outline-offset: -2px; }
.gx-guided__options { background: var(--surface-container); }
.gx-guided__options > li + li { border-top: 1px solid var(--outline); }
.gx-guided__option input { flex: none; order: 1; width: var(--sp-5); height: var(--sp-5); margin: 0; accent-color: var(--primary); }
.gx-guided__option:has(:checked) .gx-guided__tile { background: var(--primary); color: var(--on-primary); }
.gx-guided__segmented { display: flex; flex-wrap: wrap; gap: var(--sp-1); padding: var(--sp-1); border-radius: var(--radius-sm); background: var(--surface-container-high); }
.gx-guided__choice { flex: 1 1 7rem; min-width: 0; min-height: var(--touch-target); padding: var(--sp-2); border-radius: calc(var(--radius-sm) - var(--sp-1)); text-align: center; text-wrap: balance; box-shadow: none; background: transparent; color: var(--on-surface-variant); }
.gx-guided__choice:has(:checked) { background: var(--primary); color: var(--on-primary); }
.gx-guided__choice:has(:checked):hover { background: var(--primary-hover); }
.gx-guided__tile { flex: none; display: grid; place-items: center; width: 40px; height: 40px; border-radius: var(--radius-sm); background: var(--primary-container); color: var(--primary); font-size: var(--fs-lg); }
.gx-guided__mark { flex: none; display: grid; place-items: center; width: 28px; font-size: var(--fs-lg); color: var(--on-surface-variant); }
.gx-guided__checklist > li.is-done .gx-guided__mark, .gx-guided__checklist > li.is-worked .gx-guided__mark { color: var(--success); }
.gx-guided__checklist > li.is-active .gx-guided__mark, .gx-guided__checklist > li.is-current .gx-guided__mark, .gx-guided__checklist > li.is-retry .gx-guided__mark { color: var(--primary); }
.gx-guided__checklist > li.is-failed .gx-guided__mark { color: var(--error); }
.gx-guided__callout { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); flex-wrap: wrap; margin: 0; padding: var(--sp-4); border-radius: var(--radius-sm); background: var(--primary-container); }
.gx-guided__callout--quiet { background: var(--surface-container); color: var(--on-surface-variant); }
.gx-guided__footer { display: grid; gap: var(--sp-3); }
.gx-guided .gx-starport-cta { min-height: calc(var(--touch-target) + var(--sp-2)); border-radius: var(--radius-md); font-size: var(--fs-base); }
.gx-guided__footer .gx-guided__hint { text-align: center; }
.gx-guided__link { justify-self: center; padding: var(--sp-2) var(--sp-3); border: 0; border-radius: var(--radius-sm); background: none; color: var(--primary); font: inherit; cursor: pointer; }
.gx-guided__link:hover:not(:disabled) { text-decoration: underline; }
.gx-guided__link:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
.gx-guided__link:disabled { opacity: .5; cursor: default; }
@@ -215,14 +215,14 @@ export const GalaxySettingRow = {
},
beforeUnmount() { this.clearHoldTimer() },
template: `
<div class="gx-row" :class="{ disabled: dimmed, 'gx-row--stack': control === 'slider' }" :inert="busy || updating" :aria-busy="busy || updating || undefined">
<div class="gx-row" :class="{ disabled: dimmed, 'gx-row--stack': control === 'slider', 'gx-row--heading': row.heading }" :role="row.heading ? 'heading' : undefined" :aria-level="row.heading ? 3 : undefined" :inert="busy || updating" :aria-busy="busy || updating || undefined">
<div class="gx-row__info">
<span class="gx-row__label">{{ row.label }}</span>
<span v-if="presentedRow.reason" class="gx-row__desc">{{ ' ' + presentedRow.reason.replace('https://firestar.link/discord', '') }}</span>
<span v-if="presentedRow.reason && !row.heading" class="gx-row__desc">{{ ' ' + presentedRow.reason.replace('https://firestar.link/discord', '') }}</span>
<a v-if="presentedRow.reason?.includes('https://firestar.link/discord')" class="gx-btn gx-btn--tonal" href="https://firestar.link/discord" target="_blank" rel="noopener">StarPilot Discord</a>
<span v-if="control === 'group' || control === 'action'" class="gx-row__desc">{{ presentedRow.value }}</span>
</div>
<button v-if="control !== 'slider' && showDefault" type="button"
<button v-if="control !== 'slider' && showDefault && !row.heading" type="button"
class="gx-btn gx-btn--tonal gx-setting-default" :disabled="dimmed || !row.resetAvailable"
:aria-label="'Reset ' + row.label + ' to default'" :title="'Default: ' + presentedRow.defaultValue"
@click="$emit('reset-default', index)">Default</button>
@@ -249,6 +249,6 @@ export const GalaxySettingRow = {
</GalaxySelect>
<button v-else-if="control === 'group'" type="button" class="gx-btn gx-btn--tonal" :disabled="dimmed" @click="$emit('open', row.page)">Manage</button>
<button v-else-if="control === 'action' && row.action" type="button" class="gx-btn gx-btn--tonal" :disabled="dimmed" @click="$emit('review', index, row.confirm ? 0 : 1)">{{ row.repairValue === 'Reset' || /reset|default/i.test(row.label) ? 'Reset' : row.repairValue ? 'Set ' + row.repairValue : row.label }}</button>
<span v-else class="gx-row__value gx-row__readout">{{ displayValue }}</span>
<span v-else-if="!row.heading" class="gx-row__value gx-row__readout">{{ displayValue }}</span>
</div>`,
}
+20 -6
View File
@@ -5,7 +5,7 @@ import { navigate } from "./router.js"
import { SystemMonitor } from "./system-monitor.js"
import { CrashReportsFeed } from "./crash-reports.js"
import { storedSize } from "./record-deletion.js"
import { StarPortLogsFeed, BUNDLE_URL, fileUrl, outcomeLabel } from "./starport-logs.js"
import { StarPortLogsFeed, StarPortLogSend, BUNDLE_URL, MESSAGE_LIMIT, fileUrl, outcomeLabel } from "./starport-logs.js"
import { MenuTile } from "./menu-tile.js"
import { BluetoothNotice, bluetoothConnection } from "./bluetooth-notice.js"
@@ -15,25 +15,27 @@ export const Logs = {
props: { path: { type: String, required: true }, mode: { type: String, required: true }, unauthorized: { type: Function, required: true },
tools: { type: Array, required: true } },
data: () => ({ crashes: { reports: [], scanIncomplete: false, listLimited: false, status: "idle", error: "", selected: null, preview: null, previewStatus: "idle", deleting: false, storageBytes: 0, reportCount: 0 }, search: "",
starportLogs: { sessions: [], others: [], status: "idle", error: "" }, bluetooth: bluetoothConnection() }),
starportLogs: { sessions: [], others: [], status: "idle", error: "" }, bluetooth: bluetoothConnection(),
logSend: { state: "idle", message: "", available: true, error: "" }, sendNote: "", messageLimit: MESSAGE_LIMIT }),
computed: {
visibleReports() { return this.crashes.reports.filter((item) => item.name.toLowerCase().includes(this.search.toLowerCase())) },
},
created() {
this.crashFeed = new CrashReportsFeed({ publish: (update) => Object.assign(this.crashes, update), unauthorized: this.unauthorized })
this.starportFeed = new StarPortLogsFeed({ publish: (update) => Object.assign(this.starportLogs, update), unauthorized: this.unauthorized })
this.sendFeed = new StarPortLogSend({ publish: (update) => Object.assign(this.logSend, update), unauthorized: this.unauthorized })
},
mounted() {
if (this.path === "/logs/crashes" && this.mode === "local") this.crashFeed.start()
if (this.path === "/logs/starport" && this.mode === "local") this.starportFeed.start()
if (this.path === "/logs/starport" && this.mode === "local") { this.starportFeed.start(); this.sendFeed.start() }
},
beforeUnmount() { this.crashFeed.stop(); this.starportFeed.stop() },
beforeUnmount() { this.crashFeed.stop(); this.starportFeed.stop(); this.sendFeed.stop() },
watch: {
path(next, previous) {
if (next === "/logs/crashes" && this.mode === "local") this.crashFeed.start()
else if (previous === "/logs/crashes") { this.crashFeed.stop(); this.search = "" }
if (next === "/logs/starport" && this.mode === "local") this.starportFeed.start()
else if (previous === "/logs/starport") this.starportFeed.stop()
if (next === "/logs/starport" && this.mode === "local") { this.starportFeed.start(); this.sendFeed.start() }
else if (previous === "/logs/starport") { this.starportFeed.stop(); this.sendFeed.stop() }
},
},
methods: {
@@ -42,6 +44,9 @@ export const Logs = {
bundleUrl: () => BUNDLE_URL,
fileUrl,
outcomeLabel,
async sendLogs() {
if (await this.sendFeed.send(this.sendNote)) this.sendNote = ""
},
reportDate(seconds) { return new Date(seconds * 1000).toLocaleString() },
reportSize(bytes) { return `${(bytes / 1024).toFixed(1)} KiB` },
async copyPreview() {
@@ -94,6 +99,15 @@ export const Logs = {
<p>The last 20 connection sessions, newest first. The archive includes every session, a readable report for each, StarPort settings, the car view's renderer logs, and a snapshot of the device (Wi-Fi, Bluetooth and kernel logs, build, CPU load). Hardware addresses are masked.</p></div></header>
<BluetoothNotice v-if="bluetooth" feature="Log downloads">Log summaries remain available.</BluetoothNotice>
<div class="gx-settings__controls gx-actions"><a v-if="!bluetooth" class="gx-btn" :href="bundleUrl()" download><i class="bi bi-file-earmark-zip" aria-hidden="true"></i><span>Download all logs</span><span class="gx-starport-logs__format">ZIP</span></a><button type="button" class="gx-btn gx-btn--tonal" @click="starportFeed.load()"><i class="bi bi-arrow-clockwise" aria-hidden="true"></i><span>Refresh</span></button></div>
<section v-if="logSend.available" class="gx-card gx-panel gx-starport-send" aria-labelledby="gx-starport-send-title">
<div class="gx-section__header"><i class="bi bi-send" aria-hidden="true"></i><span id="gx-starport-send-title" class="gx-section__title">Send logs to the developer</span></div>
<p class="gx-note">Sends the same archive as Download all logs straight from the device. Add a short note about what happened.</p>
<div class="gx-starport-send__row"><input class="gx-field" type="text" v-model="sendNote" :maxlength="messageLimit" placeholder="What happened? (optional)" aria-label="Note for the developer" :disabled="logSend.state === 'sending'" @keydown.enter="sendLogs"><span class="gx-starport-send__count" aria-live="polite">{{ sendNote.length }}/{{ messageLimit }}</span><button type="button" class="gx-btn" :disabled="logSend.state === 'sending'" @click="sendLogs"><i class="bi bi-send" aria-hidden="true"></i><span>Send logs</span></button></div>
<GxState v-if="logSend.state === 'sending'" loading>{{ logSend.message || 'Sending logs' }}…</GxState>
<GxNotice v-else-if="logSend.state === 'sent'" tone="success">{{ logSend.message }}</GxNotice>
<GxNotice v-else-if="logSend.state === 'failed'" tone="danger">{{ logSend.message }}</GxNotice>
<GxNotice v-if="logSend.error" tone="danger">{{ logSend.error }}</GxNotice>
</section>
<GxState v-if="starportLogs.status === 'loading'" loading>Loading StarPort logs…</GxState>
<GxNotice v-else-if="starportLogs.status === 'unavailable'" tone="danger">{{ starportLogs.error || 'StarPort logs are unavailable.' }}</GxNotice>
+14 -5
View File
@@ -304,6 +304,14 @@ export const SettingsPage = {
}).filter(({ row }) =>
!needle || `${row.label} ${row.value} ${row.reason}`.toLocaleLowerCase().includes(needle))
},
// A heading row starts its own card; the rows after it belong to that card even when search hides the heading.
rowCards() {
const titles = []
const owner = (this.state.data?.rows || []).map((row) => (row.heading ? titles.push(row.label) : titles.length))
const cards = [{ title: "", entries: [] }, ...titles.map((title) => ({ title, entries: [] }))]
for (const entry of this.visibleRows) if (!entry.row.heading) cards[owner[entry.index] || 0].entries.push(entry)
return cards.filter((card, index) => !index || card.entries.length)
},
},
watch: {
"state.pending"() { this.flushSoundChoices() },
@@ -449,15 +457,16 @@ export const SettingsPage = {
<div v-if="state.data.page === 'pip'" class="gx-settings__actions gx-actions">
<button type="button" class="gx-btn gx-btn--tonal" @click="openCropEditor"><i class="bi bi-crop" aria-hidden="true"></i> Crop editor</button>
</div>
<section class="gx-card gx-settings__section">
<div class="gx-section__header"><i class="bi" :class="activeSection.icon" aria-hidden="true"></i><span class="gx-section__title">{{ state.data.page === 'hub' ? activeSection.label : state.data.title }}</span>
<section v-for="(card, cardIndex) in rowCards" :key="card.title || 'main'" class="gx-card gx-settings__section">
<div v-if="cardIndex" class="gx-section__header"><i class="bi bi-car-front" aria-hidden="true"></i><span class="gx-section__title">{{ card.title }}</span></div>
<div v-else class="gx-section__header"><i class="bi" :class="activeSection.icon" aria-hidden="true"></i><span class="gx-section__title">{{ state.data.page === 'hub' ? activeSection.label : state.data.title }}</span>
<button v-if="state.data.page !== 'hub' && state.data.rows.length" type="button" class="gx-icon-btn" :aria-label="state.searchOpen ? 'Close page search' : 'Search within this page'" :aria-pressed="state.searchOpen" @click="togglePageSearch"><i class="bi" :class="state.searchOpen ? 'bi-x-lg' : 'bi-search'" aria-hidden="true"></i></button>
</div>
<div v-if="state.searchOpen" class="gx-settings__search"><input ref="pageSearch" v-model="state.query" class="gx-field" type="search" aria-label="Search within this page" placeholder="Search within…"></div>
<GalaxySettingRow v-for="{ row, index } in visibleRows" :key="rowKey(row, index)" :row="row" :index="index"
<div v-if="state.searchOpen && !cardIndex" class="gx-settings__search"><input ref="pageSearch" v-model="state.query" class="gx-field" type="search" aria-label="Search within this page" placeholder="Search within…"></div>
<GalaxySettingRow v-for="{ row, index } in card.entries" :key="rowKey(row, index)" :row="row" :index="index"
:busy="busy" :disabled="!!state.error || !row.available" :save-value="(index, value) => feed.previewValue(index, value)"
@open="open" @review="(index, direction) => feed.preview(index, direction)" @reset-default="index => feed.resetDefault(index)" />
<div v-if="!visibleRows.length" class="gx-empty">No matching settings.</div>
<div v-if="!cardIndex && !visibleRows.length" class="gx-empty">No matching settings.</div>
</section>
<SoundPacks v-if="state.data.page === 'sounds'" :unauthorized="unauthorized" :busy="busy" @installed="refreshSoundChoices" />
</div>
@@ -0,0 +1,232 @@
// Guided setup shares the page’s pairing actions and slots.
export const FIRST_RUN_NOTICE = "The first connection can take a few minutes, and sometimes more than one drive, while we find what your car accepts. Keep the car on and its screen awake. Later connections are much faster because we remember what worked."
export const EXHAUSTED_NOTICE = "We tried every option we have this drive. We keep what works best and will try again the next time the car starts."
// Adapter setup may require connecting the companion car first.
export const BLUETOOTH_FIRST = /connect your paired car in galaxy bluetooth first/i
export const BLUETOOTH_HINT = { text: "Connect your car in Bluetooth first, then come back and tap Connect.", href: "#/bluetooth", label: "Open Bluetooth" }
const CHOICES = [
{ key: "builtin", title: "Built-in wireless", detail: "Your car has wireless Android Auto.", icon: "car-front" },
{ key: "adapter", title: "Wireless adapter", detail: "AAWireless, Motorola MA1 or similar, plugged into the car.", icon: "usb-symbol" },
{ key: "usb", title: "USB cable", detail: "A data cable from the car to your comma.", icon: "usb-c" },
{ key: "app", title: "Tablet app", detail: "An iPad or Android tablet shows the dashboard.", icon: "tablet" },
]
const INSTRUCTIONS = {
builtin: "Open your car’s phone pairing screen, then tap Find car below.",
adapter: "Choose your adapter, put it in pairing mode, then tap Find adapter.",
usb: "Plug the car’s Android Auto USB data port into the comma, then tap Connect.",
app: "Choose your app, open it on your tablet on the same Wi-Fi, then tap Connect.",
}
const CAR_STEP_LABEL = { builtin: "Car", adapter: "Adapter", usb: "Cable", app: "App" }
const STAGES = {
wireless: [
{ id: "bluetooth", label: "Reach the car over Bluetooth", states: ["connecting_bluetooth", "discovering", "rfcomm"] },
{ id: "wifi", label: "Join the car’s Wi-Fi", states: ["wifi_start", "wifi_info", "joining_wifi"] },
{ id: "secure", label: "Secure the connection", states: ["connecting_tcp", "authenticating"] },
{ id: "dashboard", label: "Bring up your dashboard", states: ["negotiating", "streaming", "suspended"] },
],
wired: [
{ id: "cable", label: "Wait for the USB cable", states: ["waiting_for_usb", "usb_accessory"] },
{ id: "secure", label: "Secure the connection", states: ["connecting_tcp", "authenticating"] },
{ id: "dashboard", label: "Bring up your dashboard", states: ["negotiating", "streaming", "suspended"] },
],
app: [
{ id: "app", label: "Wait for your app", states: ["waiting_for_ipad", "waiting_for_tablet"] },
{ id: "secure", label: "Secure the connection", states: ["connecting_tcp", "authenticating"] },
{ id: "dashboard", label: "Bring up your dashboard", states: ["negotiating", "streaming", "suspended"] },
],
}
const CONNECTING = new Set(["connecting_bluetooth", "discovering", "rfcomm", "wifi_start", "wifi_info", "joining_wifi", "connecting_tcp",
"authenticating", "negotiating", "waiting_for_usb", "usb_accessory", "waiting_for_ipad", "waiting_for_tablet", "backoff"])
export function connectionStages(runtime) {
const kind = runtime?.connection === "wired" ? "wired" : ["headunitpad", "openheadunit"].includes(runtime?.connection) ? "app" : "wireless"
const stages = STAGES[kind]
// While retrying the state is "backoff"; the stage that failed is the one to show as still working.
const at = runtime?.state === "backoff" ? runtime.last_stage : runtime?.state
const current = Math.max(0, stages.findIndex((stage) => stage.states.includes(at)))
const live = runtime?.state === "streaming"
return stages.map((stage, index) => ({
id: stage.id, label: stage.label,
state: live || index < current ? "done" : index === current ? (runtime?.state === "backoff" ? "retry" : "active") : "waiting",
}))
}
// Only worth showing once a handshake option has been tried and failed, or one is remembered for this car.
export function handshakeSteps(ladder) {
const steps = Array.isArray(ladder?.steps) ? ladder.steps.filter((step) => step && typeof step.label === "string") : []
const interesting = ladder?.learned || steps.some((step) => step.state === "failed" || step.state === "worked")
return interesting ? steps.map((step) => ({ id: String(step.id), label: step.label, state: String(step.state) })) : []
}
// The three steps always shown at the top: the package, the car (choice and pairing), and the connection.
export function setupSteps({ installed, carDone, live, carType }) {
const state = (done, current) => (done ? "done" : current ? "current" : "todo")
return [
{ id: "support", label: "Support", state: state(installed, !installed) },
{ id: "car", label: CAR_STEP_LABEL[carType] || "Car", state: state(installed && carDone, installed && !carDone) },
{ id: "connect", label: "Connect", state: state(live, installed && carDone && !live) },
]
}
function options(models, chosen) {
return Object.entries(models || {}).map(([key, label]) => ({ key, label: String(label), selected: key === chosen }))
}
export function guidedState({ setup = null, runtime = null, selected = null, receivers = [], wired = false, app = "", carType = "builtin",
setupDone = false, pairingActive = false, editing = false, adapterModels = {}, adapterKey = "", appModels = {} } = {}) {
const ladder = runtime?.setup_ladder || null
const state = runtime?.state || "idle"
const connecting = !!runtime?.running && CONNECTING.has(state)
const installed = !!setup?.identity?.installed
const steps = setupSteps({ installed, carDone: setupDone, live: state === "streaming", carType })
const base = { show: false, phase: "live", title: "", detail: "", choices: [], carType, candidates: [], progress: [], handshake: [],
notice: "", exhausted: false, hint: null, steps, adapter: null, apps: null, editing: false }
if (setup === null) return base
const hint = BLUETOOTH_FIRST.test(String(runtime?.error || "")) ? BLUETOOTH_HINT : null
if (connecting) {
const firstRun = !ladder?.learned && !(ladder?.successes > 0) && (runtime?.connection || "wireless") === "wireless"
return { ...base, show: true, phase: "connecting", title: runtime?.label || "Connecting to your car",
detail: runtime?.detail || "Keep your car’s screen on. We’ll move to the next step automatically.",
progress: connectionStages(runtime), handshake: handshakeSteps(ladder), exhausted: !!ladder?.exhausted,
notice: ladder?.exhausted ? EXHAUSTED_NOTICE : firstRun ? FIRST_RUN_NOTICE : "", hint }
}
if (setupDone && !editing) {
// A finished setup shows nothing, except a refusal that is the one thing to act on.
return hint ? { ...base, show: true, phase: "bluetooth", title: "Your car needs to be connected in Bluetooth first", detail: hint.text, hint } : base
}
if (!installed) {
return { ...base, show: true, phase: "package", title: "Add your Android Auto package",
detail: "Your car only talks to devices that present Google’s Android Auto certificate. Follow the support step above, then come back here." }
}
const choices = CHOICES.map((choice) => ({ ...choice, selected: choice.key === carType }))
const known = new Set([...(selected ? [selected.address] : []), ...(Array.isArray(receivers) ? receivers : []).map((car) => car?.address)])
const candidates = []
// Paired cars the page's own list does not show yet; the page lists the rest itself in the pairing step.
for (const car of Array.isArray(runtime?.car_candidates) ? runtime.car_candidates : []) {
if (car && typeof car.address === "string" && typeof car.name === "string" && !known.has(car.address)) {
known.add(car.address)
candidates.push({ address: car.address, name: car.name })
}
}
const pairs = carType === "builtin" || carType === "adapter"
return { ...base, show: true, phase: "setup", title: editing ? "Change how your car connects" : "Set up your car",
detail: INSTRUCTIONS[carType] || INSTRUCTIONS.builtin, choices, candidates: pairs && !pairingActive ? candidates : [], hint, editing,
adapter: carType === "adapter" ? { options: options(adapterModels, adapterKey), needsChoice: !adapterKey } : null,
apps: carType === "app" ? { options: options(appModels, Object.keys(appModels).find((key) => appModels[key] === app) || ""), needsChoice: !app } : null }
}
export const StarPortGuidedSetup = {
props: { state: { type: Object, required: true }, busy: { type: Boolean, default: false },
choicesDisabled: { type: Boolean, default: false } },
emits: ["choose-type", "choose-adapter", "choose-app", "use-car", "classic", "done"],
template: `
<section class="gx-card gx-guided" :data-phase="state.phase" aria-label="Guided StarPort setup">
<ol class="gx-guided__steps" aria-label="Setup progress">
<li v-for="(step, index) in state.steps" :key="step.id" class="gx-guided__step" :class="'is-' + step.state"
:aria-current="step.state === 'current' ? 'step' : undefined">
<span class="gx-guided__node" aria-hidden="true"><i v-if="step.state === 'done'" class="bi bi-check-lg"></i><template v-else>{{ index + 1 }}</template></span>
<span class="gx-guided__step-label">{{ step.label }}<span class="gx-sr-only">{{ step.state === 'done' ? ', done' : step.state === 'current' ? ', current step' : '' }}</span></span>
</li>
</ol>
<header class="gx-guided__header">
<h2 class="gx-guided__title">{{ state.title }}</h2>
<p class="gx-guided__lede">{{ state.detail }}</p>
</header>
<div v-if="state.choices.length" class="gx-guided__section">
<ul class="gx-starport-list gx-guided__options" role="radiogroup" aria-label="How your car connects">
<li v-for="choice in state.choices" :key="choice.key" role="none">
<label class="gx-guided__option">
<input type="radio" name="starport-connection" :checked="choice.selected" :disabled="busy || choicesDisabled" @change="$emit('choose-type', choice.key)">
<span class="gx-guided__tile" aria-hidden="true"><i :class="'bi bi-' + choice.icon"></i></span>
<span class="gx-starport-row__text"><strong>{{ choice.title }}</strong><small>{{ choice.detail }}</small></span>
</label>
</li>
</ul>
</div>
<div v-if="state.adapter" class="gx-guided__section">
<h3 class="gx-guided__subhead">Which adapter?</h3>
<div class="gx-guided__segmented" role="radiogroup" aria-label="Adapter model">
<label v-for="option in state.adapter.options" :key="option.key" class="gx-btn gx-btn--tonal gx-guided__choice">
<input class="gx-sr-only" type="radio" name="starport-adapter" :checked="option.selected" :disabled="busy || choicesDisabled" @change="$emit('choose-adapter', option.key)">{{ option.label }}
</label>
</div>
<p v-if="state.adapter.needsChoice" class="gx-guided__hint">Choose your adapter to continue.</p>
</div>
<div v-if="state.apps" class="gx-guided__section">
<h3 class="gx-guided__subhead">Which app?</h3>
<div class="gx-guided__segmented" role="radiogroup" aria-label="Tablet app">
<label v-for="option in state.apps.options" :key="option.key" class="gx-btn gx-btn--tonal gx-guided__choice">
<input class="gx-sr-only" type="radio" name="starport-apps" :checked="option.selected" :disabled="busy || choicesDisabled" @change="$emit('choose-app', option.key)">{{ option.label }}
</label>
</div>
<p v-if="state.apps.needsChoice" class="gx-guided__hint">Choose your app to continue.</p>
</div>
<div v-if="state.candidates.length" class="gx-guided__section">
<h3 class="gx-guided__subhead">Paired cars</h3>
<ul class="gx-starport-list gx-guided__cars" aria-label="Paired cars that offer Android Auto">
<li v-for="car in state.candidates" :key="car.address" class="gx-starport-row">
<span class="gx-guided__tile" aria-hidden="true"><i class="bi bi-car-front"></i></span>
<span class="gx-starport-row__text"><strong>{{ car.name }}</strong><small>Paired and offers Android Auto</small></span>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="busy || choicesDisabled" @click="$emit('use-car', car.address)">Use</button>
</li>
</ul>
</div>
<div v-if="state.hint" class="gx-guided__callout" role="status">
<span>{{ state.hint.text }}</span>
<a class="gx-btn gx-btn--tonal" :href="state.hint.href">{{ state.hint.label }}</a>
</div>
<div v-if="state.progress.length" class="gx-guided__section">
<ol class="gx-starport-list gx-guided__checklist" aria-label="Connection progress">
<li v-for="step in state.progress" :key="step.id" class="gx-starport-row" :class="'is-' + step.state" :aria-current="step.state === 'active' || step.state === 'retry' ? 'step' : undefined">
<span class="gx-guided__mark" aria-hidden="true">
<i v-if="step.state === 'done'" class="bi bi-check-circle-fill"></i>
<span v-else-if="step.state === 'active'" class="gx-spinner"></span>
<i v-else-if="step.state === 'retry'" class="bi bi-arrow-repeat"></i>
<i v-else class="bi bi-circle"></i>
</span>
<span class="gx-starport-row__text"><strong>{{ step.label }}</strong><small v-if="step.state === 'retry'">Trying again</small></span>
</li>
</ol>
</div>
<details v-if="state.handshake.length" class="gx-starport-wiki" open>
<summary>What we are trying</summary>
<ol class="gx-starport-list gx-guided__checklist" aria-label="Handshake options">
<li v-for="step in state.handshake" :key="step.id" class="gx-starport-row" :class="'is-' + step.state">
<span class="gx-guided__mark" aria-hidden="true">
<i v-if="step.state === 'worked'" class="bi bi-check-circle-fill"></i>
<i v-else-if="step.state === 'failed'" class="bi bi-x-circle"></i>
<i v-else-if="step.state === 'skipped'" class="bi bi-dash-circle"></i>
<span v-else-if="step.state === 'current'" class="gx-spinner"></span>
<i v-else class="bi bi-circle"></i>
</span>
<span class="gx-starport-row__text"><strong>{{ step.label }}</strong></span>
</li>
</ol>
</details>
<p v-if="state.notice" class="gx-guided__callout gx-guided__callout--quiet" role="status">{{ state.notice }}</p>
<div v-if="state.phase === 'setup'" class="gx-guided__body"><slot name="car" /></div>
<div class="gx-guided__footer">
<slot name="connect" />
<button v-if="state.editing" type="button" class="gx-guided__link" :disabled="busy" @click="$emit('done')">Done</button>
<button type="button" class="gx-guided__link" :disabled="busy" @click="$emit('classic')">Use classic setup</button>
</div>
</section>`,
}
@@ -68,3 +68,94 @@ export class StarPortLogsFeed {
}
}
}
export const SEND_URL = `${LOGS_URL}/send`
export const MESSAGE_LIMIT = 60
const POLL_MS = 1500
// "Send logs": the device builds the same zip as Download all logs and posts it to the developer's Discord.
// Only small JSON crosses the connection, so this also works over Bluetooth.
export class StarPortLogSend {
constructor({ publish, unauthorized = () => {}, fetcher = (...args) => fetch(...args), schedule = (fn, ms) => setTimeout(fn, ms),
cancel = (id) => clearTimeout(id) }) {
Object.assign(this, { publish, unauthorized, fetcher, schedule, cancel })
this.active = false
this.timer = null
this.generation = 0
}
stop() {
this.generation++
this.active = false
if (this.timer !== null) this.cancel(this.timer)
this.timer = null
}
start() {
this.stop()
this.active = true
return this.poll()
}
async call(options, generation) {
const response = await this.fetcher(SEND_URL, { cache: "no-store", credentials: "same-origin", ...options })
if (!this.active || generation !== this.generation) return null
if (response.status === 401) {
this.stop()
this.unauthorized()
return null
}
const body = await response.json().catch(() => null)
if (!this.active || generation !== this.generation) return null
if (!response.ok) throw new Error(body?.error || "Sending StarPort logs is unavailable")
return body
}
apply(body) {
if (!this.active || !body) return
this.publish({ state: body.state, message: body.message || "", available: body.available !== false, error: "" })
this.queuePoll(body.state === "sending")
}
queuePoll(pending = true) {
if (this.timer !== null) this.cancel(this.timer)
this.timer = this.active && pending ? this.schedule(() => { this.timer = null; this.poll() }, POLL_MS) : null
}
async poll() {
if (!this.active) return
const generation = this.generation
try {
this.apply(await this.call({}, generation))
} catch (error) {
if (this.active && generation === this.generation) {
this.publish({ error: error?.message || "Sending StarPort logs is unavailable" })
this.queuePoll()
}
}
}
async send(message) {
if (!this.active) return false
const text = String(message ?? "").trim()
if (text.length > MESSAGE_LIMIT) {
this.publish({ error: `Keep the message to ${MESSAGE_LIMIT} characters.` })
return false
}
const generation = ++this.generation
this.queuePoll(false)
this.publish({ state: "sending", message: "Starting", error: "" })
try {
const body = await this.call({ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message: text }) }, generation)
if (body === null) return false
this.apply(body)
return true
} catch (error) {
if (this.active && generation === this.generation) {
this.publish({ state: "idle", error: error?.message || "Sending StarPort logs is unavailable" })
this.queuePoll()
}
return false
}
}
}
+151 -88
View File
@@ -7,6 +7,7 @@ import { connectionError } from "./polling.js"
import { BluetoothDeviceList } from "./bluetooth-devices.js"
import { BluetoothNotice, bluetoothConnection } from "./bluetooth-notice.js"
import { GalaxySettingRow, switchRow } from "./galaxy-setting-row.js"
import { StarPortGuidedSetup, guidedState } from "./starport-guided.js"
export function uploadTimeout(bytes) {
return Math.max(180000, Math.ceil(bytes / (64 * 1024)) * 1000)
@@ -44,7 +45,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", default: "Other" }
const ADAPTER_MODELS = { aawireless: "AAWireless", default: "Motorola MA1", unofficial: "Unofficial dongle" }
const HEAD_UNIT_APPS = { headunitpad: "HeadunitPad", openheadunit: "Open Headunit" }
const HOTSPOT_LINK = `<a class="gx-link" href="#/galaxy">Galaxy Wi-Fi Hotspot</a>`
const WIRED_STATUS = `<div v-if="wired" class="gx-note">
@@ -89,6 +90,82 @@ const APP_SETUP = `
<li>Tap Connect here, then tap the WiFi button in Open Headunit.</li>
</ol>
</div>`
const PAIR_UI = `
<ol v-if="adapterSetup" class="gx-starport-list" aria-label="Adapter pairing">
<li class="gx-starport-row">
<i class="bi bi-usb-symbol" aria-hidden="true"></i>
<span class="gx-starport-row__text"><strong>Adapter</strong><small>{{ selected ? selected.name : 'Put it in pairing mode, then tap Find adapter.' }}</small></span>
<span class="gx-starport-pill" :class="selected ? 'gx-starport-pill--ready' : ''">{{ selected ? 'Paired' : 'Not paired' }}</span>
</li>
</ol>
<GxNotice v-if="!pairing?.active && runtime?.pairing_note" tone="warn">{{ runtime.pairing_note }}</GxNotice>
<ul v-if="!pairing?.active && receivers.length" class="gx-starport-list" aria-label="Paired cars">
<li v-for="car in receivers" :key="car.address" class="gx-starport-row">
<i class="bi bi-car-front" aria-hidden="true"></i>
<span class="gx-starport-row__text"><strong>{{ car.name }}</strong><small>{{ selected?.address === car.address ? 'Used for Android Auto' : 'Paired' }}</small></span>
<span v-if="selected?.address === car.address" class="gx-starport-pill gx-starport-pill--ready"><i class="bi bi-check-lg"></i> Selected</span>
<button v-else class="gx-btn gx-btn--tonal" :disabled="!setup.parked || linked" @click="useCar(car.address)">Use</button>
</li>
</ul>
<p v-else-if="!pairing?.active && selected" class="gx-starport-row"><i class="bi bi-car-front" aria-hidden="true"></i><span class="gx-starport-row__text"><strong>{{ selected.name }}</strong><small>Used for Android Auto</small></span></p>
<p v-if="!pairing?.active && endReason === 'canceled'" class="gx-note" role="status">Pairing canceled. Start again when your {{ deviceWord }} is ready.</p>
<p v-else-if="!pairing?.active && endReason" class="gx-note" role="status">The pairing window ended without choosing a {{ deviceWord }}. Try again when your {{ adapterSetup ? 'adapter is in pairing mode' : 'car’s pairing screen is open' }}.</p>
<ol v-if="showPairSteps" class="gx-starport-list" aria-label="Pairing progress">
<li v-for="step in pairSteps" :key="step.label" class="gx-starport-row" :class="'gx-starport-row--' + step.status" :aria-current="step.status === 'active' ? 'step' : undefined">
<i v-if="step.status === 'done'" class="bi bi-check-circle-fill gx-starport-ok" aria-hidden="true"></i>
<span v-else-if="step.status === 'active'" class="gx-spinner gx-starport-orbit gx-starport-orbit--small" aria-hidden="true"></span>
<i v-else class="bi bi-circle" aria-hidden="true"></i>
<span class="gx-starport-row__text"><strong>{{ step.label }}</strong></span>
</li>
</ol>
<p v-if="waitingForCar" role="status">{{ pairing.discovering ? "Searching for your " + deviceWord + "…" : "Choose your " + deviceWord + " below." }}</p>
<p v-else-if="pairing?.active && pairing.approved && (!pairing.state || pairing.state === 'idle')" role="status">Approved {{ pairing.receiver?.name }}. Finishing Bluetooth pairing…</p>
<p v-if="pairing?.state === 'pairing' && !pairing.prompt && !pairing.approved" role="status">Pairing with {{ pairing.receiver?.name || 'the selected device' }}…</p>
<p v-if="pairing?.state === 'connecting'" role="status">Connecting to {{ pairing.receiver?.name || 'the selected device' }}…</p>
<p v-if="['paired', 'connected'].includes(pairing?.state)" role="status"><i class="bi bi-check-circle-fill gx-starport-ok"></i> {{ pairing.receiver?.name || 'Device' }} {{ pairing.state === 'connected' ? 'connected' : 'paired' }}.</p>
<GxNotice tone="danger" v-if="pairing?.error">{{ pairing.error }}</GxNotice>
<ul v-if="pairing?.active && !deviceChosen" class="gx-starport-list" aria-label="Nearby devices">
<li v-if="!pairing.devices?.length && waitingForCar" class="gx-starport-row gx-note" role="status"><span class="gx-spinner" aria-hidden="true"></span>Nothing found yet. {{ adapterSetup ? 'Keep your adapter in pairing mode.' : 'Keep your car’s pairing screen open.' }}</li>
<li v-for="device in pairing.devices || []" :key="device.address" class="gx-starport-row">
<i :class="device.starport ? 'bi bi-car-front' : 'bi bi-bluetooth'" aria-hidden="true"></i>
<span class="gx-starport-row__text"><strong>{{ device.name }}</strong><small>{{ device.starport ? 'StarPort ready' : 'Bluetooth device' }} · {{ device.connected ? 'Connected' : device.paired ? 'Paired before' : 'Nearby' }}</small></span>
<button class="gx-btn gx-btn--tonal" :disabled="deviceSelectionBlocked" @click="selectDevice(device.address)">{{ device.paired ? 'Connect' : 'Pair' }}</button>
</li>
</ul>
<div v-if="pairing?.prompt" ref="pairPrompt" class="gx-starport-prompt" role="status"><strong>{{ pairing.receiver.name }}</strong>
<p>{{ pairing.prompt.kind === 'confirmation' ? 'Check that this code matches your ' + deviceWord + ', then confirm.' :
pairing.prompt.kind === 'pin' ? 'Enter the PIN shown by your ' + deviceWord + '.' :
pairing.prompt.kind === 'passkey' ? 'Enter your ' + deviceWord + '’s passkey.' :
pairing.prompt.kind === 'authorization' ? 'Allow this ' + deviceWord + ' to pair?' : 'Enter this code in your ' + deviceWord + '.' }}</p>
<strong v-if="pairing.prompt.value" class="gx-starport-prompt__code">{{ pairing.prompt.value }}</strong>
<input v-if="['pin','passkey'].includes(pairing.prompt.kind)" class="gx-field" type="text" maxlength="16" autocomplete="off"
:inputmode="pairing.prompt.kind === 'passkey' ? 'numeric' : 'text'" :aria-label="pairing.prompt.kind === 'pin' ? (adapterSetup ? 'Adapter' : 'Car') + ' PIN' : (adapterSetup ? 'Adapter' : 'Car') + ' passkey'" v-model="pairValue" />
<div v-if="!pairing.prompt.displayOnly" class="gx-driving__actions gx-actions">
<button class="gx-btn gx-btn--tonal" @click="respond(false)">Reject</button>
<button class="gx-btn" :disabled="!canRespond" @click="respond(true)">{{ pairing.prompt.kind === 'pin' ? 'Send PIN' : pairing.prompt.kind === 'passkey' ? 'Send Passkey' : 'Confirm' }}</button></div>
</div>
<p v-if="pairWhenReady" class="gx-note" role="status"><span class="gx-spinner" aria-hidden="true"></span> Turning on Android Auto, then searching…</p>
<p v-else-if="!pairing?.active && pairingReason" class="gx-note" role="status">{{ pairingReason }}</p>
<div class="gx-driving__actions gx-actions">
<button v-if="!pairing?.active" class="gx-btn" :class="{ 'gx-btn--tonal': !!selected }" :disabled="pairWhenReady || !!pairingReason" @click="beginPairing"><span v-if="starting" class="gx-spinner" aria-hidden="true"></span><i v-else class="bi bi-search"></i> {{ starting ? 'Starting search…' : selected ? 'Pair another ' + findLabel : 'Find ' + findLabel }}</button>
<button v-else class="gx-btn gx-btn--tonal" @click="cancelPairing">Cancel Search / Pairing</button>
</div>
<details class="gx-starport-wiki"><summary>Stuck pairing? Pairing order and wireless adapters</summary>
<ul>
<li><strong>Keep this page open while pairing</strong> so the car’s PIN or code doesn’t time out.</li>
<li><strong>Cars with built-in wireless Android Auto:</strong> open the car’s phone pairing screen, then tap <strong>Find car</strong> above. Choose your car, then confirm the code on both screens.</li>
<li><strong>Wireless adapter</strong> (AAWireless or Motorola MA1): 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.</li>
</ul>
<p><strong>Start fresh:</strong></p>
<ol>
<li>Forget the car on the comma: Bluetooth under Tools → Forget.</li>
<li>Delete the comma from the car’s Bluetooth list.</li>
<li>Turn the car off and on (or unplug the adapter for 5 seconds).</li>
<li>Pair again here.</li>
</ol>
<a class="gx-btn gx-btn--tonal" href="#/bluetooth">Open Bluetooth</a>
</details>
`
export const CAR_TYPES = {
builtin: "Built-in wireless", adapter: "Wireless adapter", usb: "Wired AA", app: "App"
}
@@ -319,6 +396,18 @@ export class StarPortFeed {
return true
}
// The guided setup's way back to the classic screens (and back). It never needs the StarPort service, so it is not a control().
async setNewSetup(enabled) {
if (!this.active || this.busy || typeof enabled !== "boolean") return false
const result = await this.request("./api/starport/control", {
method: "POST",
headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "new_setup", enabled })
})
if (result === null) return false
await this.refresh()
return true
}
async setEnabled(enabled, enableBluetooth = false) {
if (!this.active || this.busy || typeof enabled !== "boolean" ||
typeof enableBluetooth !== "boolean" || enableBluetooth && !enabled || enabled && !this.setup?.installReady) return false
@@ -481,7 +570,7 @@ export function connectionProgress(runtime, { wired = false, app = "", selected
}
export const StarPortPage = {
components: { GxState, GxIconButton, GxNotice, BluetoothNotice, GxDialog, GalaxySettingRow },
components: { GxState, GxIconButton, GxNotice, BluetoothNotice, GxDialog, GalaxySettingRow, StarPortGuidedSetup },
props: {
mode: { type: String, required: true }, localAccess: { type: Boolean, required: true },
unauthorized: { type: Function, required: true }
@@ -506,7 +595,11 @@ export const StarPortPage = {
watch: {
mode() { if (!document.hidden) this.begin(); else this.feed?.stop(true) },
localAccess() { if (!document.hidden) this.begin(); else this.feed?.stop(true) },
'pairing.prompt.id'() { this.pairValue = "" },
'pairing.prompt.id'(id) {
this.pairValue = ""
// Bring the code into view: the list above it can be long.
if (id !== undefined) this.$nextTick(() => this.$refs.pairPrompt?.scrollIntoView?.({ behavior: "smooth", block: "center" }))
},
'pairing.active'(active, was) { if (was && !active) this.loadReceivers() },
// Find car on a fresh device turns Android Auto on first, then searches once the service is up.
'setup.serviceReady'() { this.resumePairing() },
@@ -541,6 +634,13 @@ export const StarPortPage = {
return !!(this.supportReady && (this.selected || this.wired || this.app) && !this.pairing?.active &&
!(this.adapterSetup && this.runtime?.car_profile_source !== "override"))
},
newSetup() { return this.setup?.newSetup !== false },
guided() {
return guidedState({ setup: this.setup, runtime: this.runtime, selected: this.selected, receivers: this.receivers, wired: this.wired,
app: this.app, carType: this.carType, setupDone: this.setupDone, pairingActive: !!this.pairing?.active, editing: this.showSteps,
adapterModels: ADAPTER_MODELS, adapterKey: this.runtime?.car_profile_source === "override" ? this.runtime.car_profile : "",
appModels: HEAD_UNIT_APPS })
},
wiredStatus() {
if (this.runtime?.wired_blocked) return { tone: "expired", label: "Wired StarPort not enabled", detail: this.runtime.wired_blocked }
if (this.wiredJetlinkBusy) return { tone: "expired", label: "Wired StarPort not enabled", detail: "JetLink is using the USB-C port. Turn off JetLink, then return here." }
@@ -553,6 +653,7 @@ export const StarPortPage = {
},
carType() { return this.runtime?.car_type || (this.wired ? "usb" : this.app ? "app" : "builtin") },
adapterSetup() { return this.carType === "adapter" },
deviceWord() { return this.adapterSetup ? "adapter" : "car" },
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 } },
@@ -564,9 +665,9 @@ export const StarPortPage = {
},
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,
return { label: "Adapter", value: !chosen ? "Choose one" : ADAPTER_MODELS[this.runtime.car_profile] || ADAPTER_MODELS.default,
choices: [...(!chosen ? ["Choose one"] : []), ...Object.values(ADAPTER_MODELS)], available: true, action: true,
reason: "AAWireless 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. Motorola MA1 uses the regular pairing. Pick Unofficial dongle for other wireless dongles that will not connect; it tries alternate handshakes and logs more." }
},
appDisabled() {
return !this.setup?.enabled || !this.setup.parked || !!this.pairing?.active || this.linked
@@ -652,9 +753,9 @@ export const StarPortPage = {
const steps = [
`Start the search${this.starting ? "…" : ""}`,
`Put your ${what} in pairing mode, then pick it from the list`,
this.pairing?.prompt ? "Confirm the code on both screens" : "Pair with your device",
this.pairing?.prompt ? "Confirm the code on both screens" : `Pair with your ${what}`,
"Finish Bluetooth pairing",
"Finish connecting to your device"
`Finish connecting to your ${what}`
]
return steps.map((label, index) => ({ label, status: index < rank ? "done" : index === rank ? (failed ? "failed" : "active") : "pending" }))
},
@@ -663,6 +764,11 @@ export const StarPortPage = {
return this.pairing?.active && !this.pairing?.receiver && !this.pairing?.prompt && !this.pairing?.approved &&
!["pairing", "connecting", "paired", "connected", "failed"].includes(this.pairing?.state)
},
// Once a device is picked the list only gets in the way of the code; it returns if pairing fails.
deviceChosen() {
const p = this.pairing
return p?.state !== "failed" && !!(p?.receiver || p?.prompt || p?.approved || ["pairing", "connecting", "paired", "connected"].includes(p?.state))
},
deviceSelectionBlocked() {
return !!this.pairingReason || !this.pairing?.active ||
!!this.pairing?.prompt || ["pairing", "connecting"].includes(this.pairing?.state) || !!this.linked
@@ -676,6 +782,13 @@ export const StarPortPage = {
return stopping ? stopping.then((stopped) => stopped && next()) : next()
},
useCar(address) { return this.afterWaiting(() => this.control("select_receiver", { address })) },
// Choosing a cable or an app makes the setup count as done at once, which would close the card under the person's finger.
// A choice made in the card keeps it open (its Done button closes it), so the choice can still be changed.
chooseType(key) { this.showSteps = true; return this.saveCarType(0, this.carTypeLabels[key]) },
chooseAdapter(key) { this.showSteps = true; return this.saveAdapter(0, ADAPTER_MODELS[key]) },
chooseApp(key) { this.showSteps = true; return this.saveApp(0, HEAD_UNIT_APPS[key]) },
useClassic() { return this.feed.setNewSetup(false) },
useGuided() { return this.feed.setNewSetup(true) },
saveCarType(index, value) {
const labels = this.carTypeLabels
const carType = Object.keys(labels).find(key => labels[key] === value)
@@ -853,7 +966,28 @@ export const StarPortPage = {
</details>
</div>
</section>
<ol v-if="setup" id="gx-starport-steps" class="gx-starport__steps">
<StarPortGuidedSetup v-if="setup && newSetup && guided.show" id="gx-starport-guided" :state="guided" :busy="busy" :choices-disabled="appDisabled"
@choose-type="chooseType" @choose-adapter="chooseAdapter" @choose-app="chooseApp" @use-car="useCar" @classic="useClassic" @done="showSteps = false">
<template #car>
<GxNotice v-if="wiredUnavailable" tone="warn">Wired AA is unavailable: {{ runtime.wired_blocked }}.</GxNotice>
<template v-if="carType === 'usb'">
${WIRED_STATUS}
</template>
<template v-else-if="carType === 'app'">
${APP_SETUP}
</template>
<template v-if="pairsHere">
${PAIR_UI}
</template>
</template>
<template #connect>
${AUTO_CONNECT}
${CONNECT}
<p v-if="connectReason" class="gx-guided__hint" role="status">{{ connectReason }}</p>
</template>
</StarPortGuidedSetup>
<p v-else-if="setup && !newSetup && !setupDone" class="gx-note"><button type="button" class="gx-btn gx-btn--tonal" :disabled="busy" @click="useGuided">Try the guided setup</button></p>
<ol v-if="setup && !newSetup" id="gx-starport-steps" class="gx-starport__steps">
<li v-show="!setupDone || showSteps" class="gx-card gx-starport-step" :class="{ 'gx-starport-step--done': !!selected && !pairing?.active }">
<div class="gx-starport-step__head">
<span class="gx-starport-step__num" aria-hidden="true"><i v-if="selected && !pairing?.active" class="bi bi-check-lg"></i><template v-else>2</template></span>
@@ -873,80 +1007,7 @@ export const StarPortPage = {
${APP_SETUP}
</template>
<template v-if="pairsHere">
<ol v-if="adapterSetup" class="gx-starport-list" aria-label="Adapter pairing">
<li class="gx-starport-row">
<i class="bi bi-usb-symbol" aria-hidden="true"></i>
<span class="gx-starport-row__text"><strong>Adapter</strong><small>{{ selected ? selected.name : 'Put it in pairing mode, then tap Find adapter.' }}</small></span>
<span class="gx-starport-pill" :class="selected ? 'gx-starport-pill--ready' : ''">{{ selected ? 'Paired' : 'Not paired' }}</span>
</li>
</ol>
<GxNotice v-if="!pairing?.active && runtime?.pairing_note" tone="warn">{{ runtime.pairing_note }}</GxNotice>
<ul v-if="!pairing?.active && receivers.length" class="gx-starport-list" aria-label="Paired cars">
<li v-for="car in receivers" :key="car.address" class="gx-starport-row">
<i class="bi bi-car-front" aria-hidden="true"></i>
<span class="gx-starport-row__text"><strong>{{ car.name }}</strong><small>{{ selected?.address === car.address ? 'Used for Android Auto' : 'Paired' }}</small></span>
<span v-if="selected?.address === car.address" class="gx-starport-pill gx-starport-pill--ready"><i class="bi bi-check-lg"></i> Selected</span>
<button v-else class="gx-btn gx-btn--tonal" :disabled="!setup.parked || linked" @click="useCar(car.address)">Use</button>
</li>
</ul>
<p v-else-if="!pairing?.active && selected" class="gx-starport-row"><i class="bi bi-car-front" aria-hidden="true"></i><span class="gx-starport-row__text"><strong>{{ selected.name }}</strong><small>Used for Android Auto</small></span></p>
<p v-if="!pairing?.active && endReason === 'canceled'" class="gx-note" role="status">Pairing canceled. Start again when your car is ready.</p>
<p v-else-if="!pairing?.active && endReason" class="gx-note" role="status">The pairing window ended without choosing a car. Try again when your car’s pairing screen is open.</p>
<ol v-if="showPairSteps" class="gx-starport-list" aria-label="Pairing progress">
<li v-for="step in pairSteps" :key="step.label" class="gx-starport-row" :class="'gx-starport-row--' + step.status" :aria-current="step.status === 'active' ? 'step' : undefined">
<i v-if="step.status === 'done'" class="bi bi-check-circle-fill gx-starport-ok" aria-hidden="true"></i>
<span v-else-if="step.status === 'active'" class="gx-spinner gx-starport-orbit gx-starport-orbit--small" aria-hidden="true"></span>
<i v-else class="bi bi-circle" aria-hidden="true"></i>
<span class="gx-starport-row__text"><strong>{{ step.label }}</strong></span>
</li>
</ol>
<p v-if="waitingForCar" role="status">{{ pairing.discovering ? "Searching for nearby cars and adapters…" : "Choose your car or wireless adapter below." }}</p>
<p v-else-if="pairing?.active && pairing.approved && (!pairing.state || pairing.state === 'idle')" role="status">Approved {{ pairing.receiver?.name }}. Finishing Bluetooth pairing…</p>
<p v-if="pairing?.state === 'pairing' && !pairing.prompt && !pairing.approved" role="status">Pairing with {{ pairing.receiver?.name || 'the selected device' }}…</p>
<p v-if="pairing?.state === 'connecting'" role="status">Connecting to {{ pairing.receiver?.name || 'the selected device' }}…</p>
<p v-if="['paired', 'connected'].includes(pairing?.state)" role="status"><i class="bi bi-check-circle-fill gx-starport-ok"></i> {{ pairing.receiver?.name || 'Device' }} {{ pairing.state === 'connected' ? 'connected' : 'paired' }}.</p>
<GxNotice tone="danger" v-if="pairing?.error">{{ pairing.error }}</GxNotice>
<ul v-if="pairing?.active" class="gx-starport-list" aria-label="Nearby devices">
<li v-if="!pairing.devices?.length && waitingForCar" class="gx-starport-row gx-note" role="status"><span class="gx-spinner" aria-hidden="true"></span>Nothing found yet. Keep your car’s pairing screen open.</li>
<li v-for="device in pairing.devices || []" :key="device.address" class="gx-starport-row">
<i :class="device.starport ? 'bi bi-car-front' : 'bi bi-bluetooth'" aria-hidden="true"></i>
<span class="gx-starport-row__text"><strong>{{ device.name }}</strong><small>{{ device.starport ? 'StarPort ready' : 'Bluetooth device' }} · {{ device.connected ? 'Connected' : device.paired ? 'Paired before' : 'Nearby' }}</small></span>
<button class="gx-btn gx-btn--tonal" :disabled="deviceSelectionBlocked" @click="selectDevice(device.address)">{{ device.paired ? 'Connect' : 'Pair' }}</button>
</li>
</ul>
<div v-if="pairing?.prompt" class="gx-starport-prompt" role="status"><strong>{{ pairing.receiver.name }}</strong>
<p>{{ pairing.prompt.kind === 'confirmation' ? 'Check that this code matches your car, then confirm.' :
pairing.prompt.kind === 'pin' ? 'Enter the PIN shown by your car.' :
pairing.prompt.kind === 'passkey' ? 'Enter your car’s passkey.' :
pairing.prompt.kind === 'authorization' ? 'Allow this car to pair?' : 'Enter this code in your car.' }}</p>
<strong v-if="pairing.prompt.value" class="gx-starport-prompt__code">{{ pairing.prompt.value }}</strong>
<input v-if="['pin','passkey'].includes(pairing.prompt.kind)" class="gx-field" type="text" maxlength="16" autocomplete="off"
:inputmode="pairing.prompt.kind === 'passkey' ? 'numeric' : 'text'" :aria-label="pairing.prompt.kind === 'pin' ? 'Car PIN' : 'Car passkey'" v-model="pairValue" />
<div v-if="!pairing.prompt.displayOnly" class="gx-driving__actions gx-actions">
<button class="gx-btn gx-btn--tonal" @click="respond(false)">Reject</button>
<button class="gx-btn" :disabled="!canRespond" @click="respond(true)">{{ pairing.prompt.kind === 'pin' ? 'Send PIN' : pairing.prompt.kind === 'passkey' ? 'Send Passkey' : 'Confirm' }}</button></div>
</div>
<p v-if="pairWhenReady" class="gx-note" role="status"><span class="gx-spinner" aria-hidden="true"></span> Turning on Android Auto, then searching…</p>
<p v-else-if="!pairing?.active && pairingReason" class="gx-note" role="status">{{ pairingReason }}</p>
<div class="gx-driving__actions gx-actions">
<button v-if="!pairing?.active" class="gx-btn" :class="{ 'gx-btn--tonal': !!selected }" :disabled="pairWhenReady || !!pairingReason" @click="beginPairing"><span v-if="starting" class="gx-spinner" aria-hidden="true"></span><i v-else class="bi bi-search"></i> {{ starting ? 'Starting search…' : selected ? 'Pair another ' + findLabel : 'Find ' + findLabel }}</button>
<button v-else class="gx-btn gx-btn--tonal" @click="cancelPairing">Cancel Search / Pairing</button>
</div>
<details class="gx-starport-wiki"><summary>Stuck pairing? Pairing order and wireless adapters</summary>
<ul>
<li><strong>Keep this page open while pairing</strong> so the car’s PIN or code doesn’t time out.</li>
<li><strong>Cars with built-in wireless Android Auto:</strong> open the car’s phone pairing screen, then tap <strong>Find car</strong> above. Choose your car, then confirm the code on both screens.</li>
<li><strong>Wireless adapter</strong> (AAWireless or Motorola MA1): 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.</li>
</ul>
<p><strong>Start fresh:</strong></p>
<ol>
<li>Forget the car on the comma: Bluetooth under Tools → Forget.</li>
<li>Delete the comma from the car’s Bluetooth list.</li>
<li>Turn the car off and on (or unplug the adapter for 5 seconds).</li>
<li>Pair again here.</li>
</ol>
<a class="gx-btn gx-btn--tonal" href="#/bluetooth">Open Bluetooth</a>
</details>
${PAIR_UI}
</template>
${CONNECTION_PROGRESS}
${CONNECT}
@@ -965,7 +1026,7 @@ export const StarPortPage = {
</li>
</ol>
<section v-if="setup && setupDone && !showSteps" class="gx-card gx-starport-step gx-starport-home" aria-labelledby="gx-starport-home-title">
<section v-if="setup && setupDone && !showSteps && (!newSetup || !guided.show)" class="gx-card gx-starport-step gx-starport-home" aria-labelledby="gx-starport-home-title">
<div class="gx-starport-step__head">
<span class="gx-starport-step__num" :class="{ 'gx-starport-home__icon--live': projecting }" aria-hidden="true"><i class="bi bi-car-front"></i></span>
<div><h3 id="gx-starport-home-title">{{ homeTitle }}</h3>
@@ -973,9 +1034,11 @@ export const StarPortPage = {
</div>
<div class="gx-starport-step__body">
<p v-if="expiry.level === 'soon'" class="gx-starport-pill gx-starport-pill--soon" role="status">{{ expiry.label }}</p>
<GalaxySettingRow :row="carTypeRow" :index="0" :busy="busy" :disabled="appDisabled" :save-value="saveCarType" />
<GalaxySettingRow v-if="adapterSetup" :row="adapterRow" :index="0" :busy="busy" :disabled="appDisabled" :save-value="saveAdapter" />
<GalaxySettingRow v-if="app" :row="appRow" :index="0" :busy="busy" :disabled="appDisabled" :save-value="saveApp" />
<template v-if="!newSetup">
<GalaxySettingRow :row="carTypeRow" :index="0" :busy="busy" :disabled="appDisabled" :save-value="saveCarType" />
<GalaxySettingRow v-if="adapterSetup" :row="adapterRow" :index="0" :busy="busy" :disabled="appDisabled" :save-value="saveAdapter" />
<GalaxySettingRow v-if="app" :row="appRow" :index="0" :busy="busy" :disabled="appDisabled" :save-value="saveApp" />
</template>
${AUTO_CONNECT}
<ul v-if="wirelessHome" class="gx-starport-list" aria-label="Setup steps">
<li class="gx-starport-row"><i class="bi bi-shield-check" aria-hidden="true"></i>
@@ -994,8 +1057,8 @@ export const StarPortPage = {
${CONNECT}
<div class="gx-actions">
<a class="gx-btn gx-btn--tonal" href="#/theme_maker/starport"><i class="bi bi-layout-text-window" aria-hidden="true"></i> Customize Layout</a>
<button type="button" class="gx-btn gx-btn--tonal" :aria-expanded="showSteps" aria-controls="gx-starport-steps" @click="showSteps = !showSteps">
<i :class="showSteps ? 'bi bi-chevron-up' : 'bi bi-gear'" aria-hidden="true"></i> {{ showSteps ? 'Hide Setup' : 'Setup and Pairing' }}</button>
<button type="button" class="gx-btn gx-btn--tonal" :aria-expanded="showSteps" :aria-controls="newSetup ? 'gx-starport-guided' : 'gx-starport-steps'" @click="showSteps = !showSteps">
<i :class="showSteps ? 'bi bi-chevron-up' : 'bi bi-gear'" aria-hidden="true"></i> {{ showSteps ? 'Hide Setup' : newSetup ? 'Change how your car connects' : 'Setup and Pairing' }}</button>
</div>
</div>
</section>
@@ -12,6 +12,8 @@ DATA = Path('/data/galaxy-ble')
BRIDGE = Path(__file__).resolve().parent / 'bridge'
UNIT = 'galaxy-ble-fork.service'
LAN_UNIT = 'galaxy-lan-discovery.service'
RADIO_UNIT = 'starpilot-bluetooth-radio.service'
RADIO_WAIT_UNTIL = 45. # seconds since boot; past this, never hold setup for the radio
STATUS_HEARTBEAT = 30
STATUS_STALE = 90 # Longer than the slowest step interval (60 s) plus the heartbeat.
LOG = logging.getLogger('galaxy.bluetooth.setup')
@@ -75,6 +77,48 @@ class Bootstrap:
def systemctl(self, *args, **kwargs):
return self.command(['sudo', '-n', 'systemctl', *args], **kwargs)
def radio_starting(self):
"""True while the comma Bluetooth radio unit is still starting after boot."""
if time.monotonic() > RADIO_WAIT_UNTIL:
return False
try:
result = self.run(['systemctl', 'show', RADIO_UNIT, '--property=ActiveState', '--value'],
timeout=5, check=False, capture_output=True, text=True)
except (OSError, subprocess.SubprocessError):
return False
return result.stdout.strip() == 'activating'
def register_units(self):
"""Link the bridge and discovery units with a single systemd reload.
A reload stalls every other systemd job for 1-3 s at boot, including the Bluetooth radio unit's own
steps (starting btattach and bluetoothd), so wait for that unit first and reload once for both units.
A bridge failure raises; a discovery failure only leaves discovery unregistered."""
if self.registered and self.discovery_registered:
return
while self.radio_starting():
time.sleep(0.5)
linked = []
if not self.registered:
self.systemctl('link', '--runtime', '--force', '--no-reload', str(self.bridge / UNIT), stdout=subprocess.DEVNULL)
linked.append(UNIT)
if not self.discovery_registered:
try:
self.systemctl('link', '--runtime', '--force', '--no-reload', str(self.bridge / LAN_UNIT), stdout=subprocess.DEVNULL)
linked.append(LAN_UNIT)
except (OSError, subprocess.SubprocessError):
pass
if not linked:
return
try:
self.systemctl('daemon-reload', stdout=subprocess.DEVNULL)
except (OSError, subprocess.SubprocessError):
if UNIT in linked:
raise
return
self.registered = self.registered or UNIT in linked
self.discovery_registered = self.discovery_registered or LAN_UNIT in linked
def step(self):
wake = self.data / 'wake-setup.json'
if wake.exists():
@@ -93,20 +137,14 @@ class Bootstrap:
# The bridge runs before any phone is paired; an empty phone list accepts no one.
from openpilot.starpilot.galaxy_bluetooth.bridge.pairing import ensure_store
ensure_store(self.data / 'pairing.json')
if not self.registered:
self.systemctl('link', '--runtime', '--force', str(self.bridge / UNIT), stdout=subprocess.DEVNULL)
self.systemctl('daemon-reload', stdout=subprocess.DEVNULL)
self.registered = True
self.register_units()
# Discovery remains available when Bluetooth is off or the BLE service retries.
# An optional discovery failure must not prevent Bluetooth startup.
try:
if not self.discovery_registered:
self.systemctl('link', '--runtime', '--force', str(self.bridge / LAN_UNIT), stdout=subprocess.DEVNULL)
self.systemctl('daemon-reload', stdout=subprocess.DEVNULL)
self.discovery_registered = True
self.systemctl('start', LAN_UNIT, stdout=subprocess.DEVNULL)
except (OSError, subprocess.SubprocessError):
pass
if self.discovery_registered:
try:
self.systemctl('start', LAN_UNIT, stdout=subprocess.DEVNULL)
except (OSError, subprocess.SubprocessError):
pass
from openpilot.starpilot.bluetooth.radio_preference import RadioPreference
if not RadioPreference().enabled():
self.run(['sudo', '-n', 'systemctl', 'stop', UNIT], timeout=20, check=False,
@@ -1,10 +1,11 @@
"""Setup never downloads packages and never turns on the shared Bluetooth radio."""
from pathlib import Path
import subprocess
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import Mock, patch
from openpilot.starpilot.galaxy_bluetooth.bootstrap import Bootstrap, UNIT
from openpilot.starpilot.galaxy_bluetooth.bootstrap import Bootstrap, LAN_UNIT, RADIO_UNIT, RADIO_WAIT_UNTIL, UNIT
class BootstrapTest(unittest.TestCase):
@@ -106,5 +107,62 @@ class BootstrapTest(unittest.TestCase):
self.assertFalse(any('restart' in call for call in self.calls))
self.assertFalse(any('bluetooth.service' in call for call in self.calls))
def test_both_units_link_with_one_reload(self):
with patch('openpilot.starpilot.bluetooth.radio_preference.RadioPreference.enabled', return_value=True):
self.worker.step()
links = [call for call in self.calls if 'link' in call]
self.assertEqual(len(links), 2)
self.assertTrue(all('--no-reload' in call for call in links))
self.assertEqual(len([call for call in self.calls if 'daemon-reload' in call]), 1)
self.assertTrue(self.worker.registered and self.worker.discovery_registered)
def test_setup_waits_for_the_radio_unit_at_boot(self):
states = iter(['activating', 'activating', 'active'])
def run(args, **kwargs):
self.calls.append(args)
if 'show' in args and RADIO_UNIT in args:
return SimpleNamespace(stdout=next(states) + '\n', returncode=0)
self.assertNotIn('show', args)
return SimpleNamespace(stdout='1', returncode=0)
self.worker.run = run
with patch('openpilot.starpilot.bluetooth.radio_preference.RadioPreference.enabled', return_value=True), \
patch('openpilot.starpilot.galaxy_bluetooth.bootstrap.time.monotonic', return_value=12.0), \
patch('openpilot.starpilot.galaxy_bluetooth.bootstrap.time.sleep') as sleep:
self.worker.step()
self.assertEqual(sleep.call_count, 2)
first_link = next(i for i, call in enumerate(self.calls) if 'link' in call)
radio_checks = [i for i, call in enumerate(self.calls) if 'show' in call and RADIO_UNIT in call]
self.assertEqual(len(radio_checks), 3)
self.assertLess(max(radio_checks), first_link)
def test_setup_never_waits_for_the_radio_late_in_the_boot(self):
with patch('openpilot.starpilot.bluetooth.radio_preference.RadioPreference.enabled', return_value=True), \
patch('openpilot.starpilot.galaxy_bluetooth.bootstrap.time.monotonic', return_value=RADIO_WAIT_UNTIL + 1):
self.worker.step()
self.assertFalse(any('show' in call and RADIO_UNIT in call for call in self.calls))
self.assertTrue(self.worker.registered)
def test_discovery_link_failure_still_registers_the_bridge(self):
def run(args, **kwargs):
self.calls.append(args)
if 'link' in args and str(self.bridge / LAN_UNIT) in args:
raise subprocess.CalledProcessError(1, args)
return SimpleNamespace(stdout='1', returncode=0)
self.worker.run = run
with patch('openpilot.starpilot.bluetooth.radio_preference.RadioPreference.enabled', return_value=True):
self.worker.step()
self.assertTrue(self.worker.registered)
self.assertFalse(self.worker.discovery_registered)
self.assertEqual(len([call for call in self.calls if 'daemon-reload' in call]), 1)
self.assertFalse(any('start' in call and LAN_UNIT in call for call in self.calls))
self.assertTrue(any('start' in call and UNIT in call for call in self.calls))
def test_registered_units_never_reload_or_check_the_radio(self):
self.worker.registered = self.worker.discovery_registered = True
with patch('openpilot.starpilot.bluetooth.radio_preference.RadioPreference.enabled', return_value=True):
self.worker.step()
self.assertFalse(any('daemon-reload' in call or 'link' in call for call in self.calls))
self.assertFalse(any('show' in call and RADIO_UNIT in call for call in self.calls))
if __name__ == '__main__':
unittest.main()
@@ -36,15 +36,18 @@ class AutoConnectPolicy:
if running:
self.suppressed = True
def hold(self) -> None:
self.suppressed = True
def start_refused(self, now: float) -> None:
self._not_before = now + START_REFUSED_WAIT
def decide(self, now: float, *, enabled: bool, onroad: bool, car_link: bool, ready: bool, running: bool) -> str | None:
def decide(self, now: float, *, enabled: bool, onroad: bool, car_link: bool, ready: bool, running: bool, linked: bool = False) -> str | None:
"""Return "start", "stop" or None."""
if onroad and not self._was_onroad:
self.suppressed = False # a new drive
self._was_onroad = onroad
present = onroad or car_link
present = onroad or car_link or (running and linked)
if running:
if present:
@@ -830,6 +830,7 @@ class LeaseAwarePhone:
def devices(self): return self.phone.devices()
def device(self, address): return self.phone.device(address)
def snapshot(self, address): return self.phone.snapshot(address)
def adapter(self): return self.phone.adapter() # this comma's address, for the head unit's pairing request
def acquire(self, phone_class: bool = True) -> None:
if self.lease is None:
@@ -30,6 +30,7 @@ import re
import struct
import threading
import time
import uuid
from collections.abc import Callable
from dataclasses import dataclass, field as dataclass_field
@@ -53,6 +54,11 @@ NAMES = {1: "WifiStartRequest", 2: "WifiInfoRequest", 3: "WifiInfoResponse", 4:
STATUS_SUCCESS = 0
STATUS_NETWORK_UNAVAILABLE = -1
# Fallback for a v4+ head unit that goes silent after our usual reply: a phone-shaped WifiVersionResponse, where 1 is
# success (open-android-auto WifiVersionStatus) and the channel type and session info are filled in.
MODERN_MAJOR = 4
MODERN_VERSION_SUCCESS = 1
CHANNEL_5GHZ, CHANNEL_24GHZ, CHANNEL_DUAL = 1, 2, 3
SECURITY_OPEN = 1
SECURITY_NAMES = {0: "unknown", 1: "open", 2: "wep64", 3: "wep128", 4: "wpa", 8: "wpa2", 12: "wpa/wpa2",
@@ -61,9 +67,19 @@ SECURITY_NAMES = {0: "unknown", 1: "open", 2: "wep64", 3: "wep128", 4: "wpa", 8:
MAX_FRAME = 4096
MAX_FRAMES = 64
INITIAL_KICK_SECONDS = 2.5
# A car that keeps answering "not ready" gets up to this many stage timeouts before the setup is abandoned.
REFUSAL_PATIENCE = 3
# Phone-side WifiPing while joining the receiver's Wi-Fi, so an impatient receiver (a wireless adapter) keeps the
# idle RFCOMM link. Sent only to a receiver that pings us first: one that never pings may not expect them.
JOIN_PING_INTERVAL = 2.0
# Opt-in handshake variations for dongles that stall the standard exchange; "" is the standard behavior.
EXPERIMENTS = {
"start_response_first": ("Answer start request first", {"start_response_first"}),
"version_endpoint": ("Use version endpoint", {"version_endpoint"}),
"all": ("All of these", {"start_response_first", "version_endpoint"}),
}
# Frames that carry credentials or the vehicle identifier are never dumped as hex.
HEX_LOG_SKIP = (WIFI_INFO_RESPONSE, WIFI_SETUP_INFO, WIFI_VERSION_REQUEST)
MAC_RE = re.compile(r"^(?:[0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}$")
@@ -165,7 +181,25 @@ def parse_setup_info(payload: bytes) -> tuple[Endpoint | None, WifiCredentials |
return endpoint, credentials
def parse_version_request(payload: bytes) -> tuple[int, int, Endpoint | None, dict]:
def channel_type(request: bytes) -> int:
"""The WifiChannelType to select: the car's stated preference, else what its channel list allows."""
fields = parse_fields(request)
preference = one(fields, 3)
if isinstance(preference, int) and preference in (CHANNEL_5GHZ, CHANNEL_24GHZ, CHANNEL_DUAL):
return preference
channels = [value for value in fields.get(4, []) if isinstance(value, int) and value > 0]
if channels and all(value >= 5000 for value in channels):
return CHANNEL_5GHZ
if channels and all(value < 5000 for value in channels):
return CHANNEL_24GHZ
return CHANNEL_DUAL
def valid_experiment(value: str) -> bool:
return value == "" or value in EXPERIMENTS
def parse_version_request(payload: bytes, endpoint_fields: tuple[int, ...] = ()) -> tuple[int, int, Endpoint | None, dict]:
"""Major/minor, an optional projection endpoint, and head-unit identity strings."""
fields = parse_fields(payload)
major, minor = int(one(fields, 1, 1) or 1), int(one(fields, 2, 0) or 0)
@@ -185,6 +219,13 @@ def parse_version_request(payload: bytes) -> tuple[int, int, Endpoint | None, di
labels = {1: "car_make", 2: "car_model", 3: "car_year", 5: "head_unit_make", 6: "head_unit_model",
7: "head_unit_software_build", 8: "head_unit_software_version"}
head_unit.update({label: text(info, key) for key, label in labels.items() if text(info, key)})
for number in endpoint_fields:
for value in fields.get(number, []):
if isinstance(value, bytes):
try:
endpoint = parse_endpoint(value) or endpoint
except ValueError:
continue
return major, minor, endpoint, head_unit
@@ -224,8 +265,17 @@ class WirelessBootstrap:
def __init__(self, sock, log: Callable[..., None], *, device_serial: str = "starpilot",
version_status: int = STATUS_SUCCESS, stage_timeout: float = 20.0, start_request_delay: float = 5.0,
initial_kick_delay: float = INITIAL_KICK_SECONDS, join_ping_interval: float = JOIN_PING_INTERVAL):
initial_kick_delay: float = INITIAL_KICK_SECONDS, join_ping_interval: float = JOIN_PING_INTERVAL,
experiment: str = "", modern_reply: bool = False, patience: int = REFUSAL_PATIENCE,
channel_override: int | None = None):
self.sock = sock
self.experiment = experiment if valid_experiment(experiment) else ""
self.lifetime_id = uuid.uuid4().hex
self.modern_reply = modern_reply
self.patience = patience # stage timeouts a car that keeps saying "not ready" gets (setup ladder)
self.channel_override = channel_override # Wi-Fi band to answer with inside the phone-style reply; None keeps the car's own
self.version_major = 0
self.flags = EXPERIMENTS[self.experiment][1] if self.experiment else set()
self.initial_kick_delay = initial_kick_delay
self.join_ping_interval = join_ping_interval
self.peer_uses_pings = False
@@ -251,6 +301,11 @@ class WirelessBootstrap:
self.sock.sendall(encode_frame(message_id, payload))
if message_id not in (WIFI_PING_REQUEST, WIFI_PING_RESPONSE):
self.log("bootstrap_tx", message=NAMES.get(message_id, message_id), bytes=len(payload))
self._log_hex("tx", message_id, payload)
def _log_hex(self, direction: str, message_id: int, payload: bytes) -> None:
if self.experiment and message_id not in HEX_LOG_SKIP:
self.log("bootstrap_frame_hex", direction=direction, message=NAMES.get(message_id, message_id), hex=payload.hex())
def next_frame(self, timeout: float) -> tuple[int, bytes]:
deadline = time.monotonic() + timeout
@@ -277,8 +332,20 @@ class WirelessBootstrap:
if self.frames_seen > MAX_FRAMES:
raise BootstrapError(self.stage, "too many bootstrap frames")
self.log("bootstrap_rx", message=NAMES.get(message_id, message_id), bytes=len(payload))
self._log_hex("rx", message_id, payload)
return message_id, payload
def _version_response(self, major: int, minor: int, request: bytes) -> bytes:
self.version_major = major
if major < MODERN_MAJOR or not self.modern_reply:
return field(1, major) + field(2, minor) + field(3, self.device_serial) + field(4, self.version_status)
selected = self.channel_override or channel_type(request)
session = field(1, self.device_serial) + field(2, self.lifetime_id)
reply = (field(1, major) + field(2, minor) + field(3, self.device_serial) + field(4, MODERN_VERSION_SUCCESS) +
field(5, selected) + field(6, session))
self.log("bootstrap_version_reply", style="modern", channel_type=selected, hex=reply.hex())
return reply
def service(self, message_id: int, payload: bytes) -> bool:
"""Handle stage-independent frames; returns True when consumed."""
if message_id == WIFI_PING_REQUEST:
@@ -306,6 +373,7 @@ class WirelessBootstrap:
# exchange if the car has not started it; newer head units (2025 Honda) wait for it.
start_request_at: float | None = None
deadline = time.monotonic() + self.stage_timeout
latest_deadline = time.monotonic() + self.patience * self.stage_timeout
initial_kick_at: float | None = time.monotonic() + self.initial_kick_delay
hint_ready_at: float | None = None
while endpoint is None:
@@ -337,12 +405,12 @@ class WirelessBootstrap:
continue
initial_kick_at = None # setup messages drive the exchange; pings alone do not
if message_id == WIFI_VERSION_REQUEST:
major, minor, version_endpoint, info = parse_version_request(payload)
major, minor, version_endpoint, info = parse_version_request(
payload, (6,) if "version_endpoint" in self.flags else ())
version, head_unit = (major, minor), {**head_unit, **info}
self.log("bootstrap_version", major=major, minor=minor, head_unit=info,
endpoint=version_endpoint.__dict__ if version_endpoint else None, message=describe_version_request(payload))
self.send(WIFI_VERSION_RESPONSE, field(1, major) + field(2, minor) + field(3, self.device_serial) +
field(4, self.version_status))
self.send(WIFI_VERSION_RESPONSE, self._version_response(major, minor, payload))
hinted = version_endpoint or hinted
if hinted is not None and hint_ready_at is None:
hint_ready_at = time.monotonic() + 3.0
@@ -353,6 +421,8 @@ class WirelessBootstrap:
if parsed is None:
raise BootstrapError(self.stage, "WifiStartRequest has no valid IPv4 endpoint")
endpoint = parsed
if "start_response_first" in self.flags:
self.send(WIFI_START_RESPONSE, field(3, STATUS_SUCCESS))
elif message_id == WIFI_START_RESPONSE:
# The car's answer to our WifiStartRequest (2025 Honda): ip=1, port=2, status=3 once it is
# ready, or a negative status alone before then (it may follow up on its own; ask again anyway).
@@ -362,6 +432,7 @@ class WirelessBootstrap:
endpoint = parsed
else:
self.log("bootstrap_start_refused", status=status, endpoint=parsed is not None)
deadline = min(latest_deadline, max(deadline, time.monotonic() + self.stage_timeout)) # a car that answers is not gone
start_request_at = time.monotonic() + self.start_request_delay
elif message_id == WIFI_SETUP_INFO:
setup_endpoint, setup_credentials = parse_setup_info(payload)
@@ -86,7 +86,7 @@ def attempt_records(events):
if name == "attempt_failed":
current.update(failed_stage=str(event.get("stage", "")), error_kind=str(event.get("kind", "")),
error=sanitize(str(event.get("error", ""))), failed_at_s=elapsed(start, event),
where=sanitize(event.get("where", [])[-4:]))
where=sanitize(event.get("where", [])[-4:]), reach=str(event.get("reach", "")))
if name == "session_ended":
current["ended_by_car"] = str(event.get("reason", "")).startswith("Head unit ended projection")
shutdown = shutdown or current["ended_by_car"]
@@ -97,6 +97,27 @@ def attempt_records(events):
return attempts
STREAM_LOSS = {
"shared_radio": ("comma's own Wi-Fi", "The car stopped acknowledging video while the comma's own Wi-Fi joined or roamed; both share one radio.",
["Check the other_wifi_changed and ack_grace events for when it changed and how long acks stopped.",
"Repeated drops at home or on a hotspot mean the 8 s grace is too short or the car takes its screen back first."]),
"stalled": ("Video acknowledgement older than", "The car kept the link but stopped acknowledging video for too long, so the stream was ended.",
["Compare the Wi-Fi signal, frame age and encoder recoveries around the stall.",
"If it repeats at the same place or speed, look for the comma stalling (CPU, thermal) rather than the car."]),
"silent": ("Head unit stopped answering", "The head unit stopped answering protocol messages while the link looked up.",
["Check whether the car's screen turned off or switched source at that time.",
"Compare with other drives on the same head unit before changing timings."]),
"disconnected": ("Head unit disconnected", "The head unit closed the connection, which is what a car shutdown or a source change looks like.",
["Check whether the drive ended or the driver changed source on the head unit.",
"Only investigate further if it happened while the car stayed on and Android Auto was showing."]),
}
def stream_loss_kind(error: str) -> str:
"""Why a stream ended, from the logged error text: stalled, silent, disconnected or other."""
return next((kind for kind, (marker, _, _) in STREAM_LOSS.items() if marker.lower() in error.lower()), "other")
def diagnosis(session):
stage, error = session.get("failed_stage", ""), session.get("error", "").lower()
category, confidence = "unknown", "unknown"
@@ -125,16 +146,39 @@ def diagnosis(session):
explanation = "Authentication did not complete. A timeout alone does not prove a bad certificate."
actions = ["Check whether TLS completed and whether the head unit replied to authentication.",
"Retry with the head unit fully awake; compare with any successful attempt in this session."]
elif stage in ("connecting_bluetooth", "rfcomm", "bootstrap"):
elif stage in ("wifi_start", "wifi_info"):
category, confidence = "head_unit_bootstrap_stalled", "observed"
explanation = "Bluetooth connected and the version exchange ran, but the head unit stopped answering the Wi-Fi setup."
actions = ["Read the attached handshake trace for the last messages the head unit sent and the ones it ignored.",
"Compare the head unit make, model and protocol version with other reports before changing timings."]
elif stage in ("connecting_bluetooth", "discovering", "rfcomm", "bootstrap"):
category, confidence = "head_unit_unavailable", "suspected"
explanation = "The Bluetooth/bootstrap exchange did not complete; the head unit may still be waking or already off."
actions = ["Confirm the selected car is awake and inspect Bluetooth/bootstrap responses in the bundle.",
"Retry while parked before considering re-pairing; preserve existing bonds for diagnosis."]
reach = session.get("reach", "")
if reach == "asleep":
category = "car_asleep"
explanation = "The car did not answer the Bluetooth page at all. Its radio is off or still waking, so waiting is the fix."
actions = ["Wait for the car to finish waking and let the retry run; the saved pairing and RFCOMM channel were kept.",
"Only re-pair if the car is awake with its Bluetooth screen open and pages still go unanswered."]
elif reach == "not_ready":
category = "car_not_ready"
explanation = "The car answered Bluetooth but its Android Auto service refused or dropped the connection; it is not ready yet."
actions = ["Let the retry run; the car usually accepts a few seconds later.",
"If it repeats on every drive, compare the head unit make and model with other reports."]
elif reach == "local":
category = "bluetooth_stack_busy"
explanation = "This comma's own Bluetooth stack was busy or refused, which says nothing about the car."
actions = ["Check for another process or a second pairing attempt holding the controller.",
"Retry once the controller is idle; preserve existing bonds."]
elif stage == "streaming":
category, confidence = "projection_interrupted", "observed"
explanation = "Projection was interrupted. EOF or a timeout alone cannot distinguish a car shutdown from link loss."
actions = ["Compare video acknowledgments, Wi-Fi signal, stalls, encoder recoveries and head-unit shutdown events.",
"Retry while parked and verify sustained acknowledged frames, not just a streaming status."]
if (loss := stream_loss_kind(session.get("error", ""))) != "other":
explanation, actions = STREAM_LOSS[loss][1], STREAM_LOSS[loss][2]
return {"category": category, "confidence": confidence, "summary": explanation,
"evidence": session.get("error", ""), "recovered": session.get("outcome") == "recovered",
"next_steps": actions, "log_file": session.get("name", ""),
@@ -13,8 +13,8 @@ from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from pathlib import Path
from openpilot.starpilot.system.starport import identity as identity_store, system_slice
from openpilot.starpilot.system.starport.bugsink_diagnosis import attempt_records, diagnosis, elapsed, sanitize
from openpilot.starpilot.system.starport import identity as identity_store, reachability, system_slice
from openpilot.starpilot.system.starport.bugsink_diagnosis import attempt_records, diagnosis, elapsed, sanitize, stream_loss_kind
from openpilot.starpilot.system.starport.bugsink_summary import batch_summary, failure_summary, pairing_summary, session_timeline, software_tags
STATE_PATH = identity_store.DATA_DIR / "bugsink_report.json"
@@ -27,8 +27,8 @@ 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
TRACE_SESSION_BYTES = 64 << 10 # per failed session; the middle goes first
TRACE_TOTAL_BYTES = 200 << 10 # all traces in one report, well under the sender's event cap
EVENT_BUDGET = 400 << 10 # a report is shed down to this before sending; the sender refuses more than 512 KB
MAX_ATTENTION = 6 # sessions per report that get a trace and a device slice
SERIES_KEYS = ("fps", "encode_ms", "frame_age_p95_ms", "wifi_dbm", "wifi_link", "wifi_noise", "load1", "mem_available_mb",
@@ -103,6 +103,7 @@ def video_summary(events: list[dict]) -> dict:
"screen": f"{mode['width'] - (mode.get('margin_width') or 0)}x{mode['height'] - (mode.get('margin_height') or 0)}"
if mode.get("width") and mode.get("height") else None,
"density_dpi": mode.get("density") or None,
"codec": mode.get("codec"), # present only for H.265; H.264 reports are unchanged
"target_fps": pipeline.get("target_fps"),
"encoder": pipeline.get("encoder"),
"nv12": pipeline.get("nv12"),
@@ -150,15 +151,103 @@ def quiet_counts(events: list[dict]) -> dict:
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)}
"""The first quarter and the newest events that fit in ``limit`` bytes of JSON, with how many in between were left out."""
sizes = [len(json.dumps(event, default=str)) + 2 for event in trace]
if sum(sizes) <= limit:
return {"events": list(trace), "omitted": 0}
head_end, used = 0, 0
while head_end < len(trace) and used + sizes[head_end] <= limit // 4:
used += sizes[head_end]
head_end += 1
tail_start = len(trace)
while tail_start > head_end and used + sizes[tail_start - 1] <= limit:
tail_start -= 1
used += sizes[tail_start]
return {"events": trace[:head_end] + trace[tail_start:], "omitted": tail_start - head_end, "head": head_end}
MAX_OFFERED_MODES = 16
SETUP_TYPES = ("builtin", "adapter", "usb", "app")
def _config_field(config: dict, number: int, default=None):
values = config.get(str(number), config.get(number))
return values[0] if isinstance(values, list) and values and isinstance(values[0], int) else default
def offered_modes(events: list[dict]) -> list[str]:
"""Every video mode the head unit offered, e.g. "1280x720 H.264 margins 0x240", from its service discovery.
Kept for working pairings too, so the choice of mode can later be replayed against real cars."""
from openpilot.starpilot.system.starport.session import CODEC_H264_BP, CODEC_NAMES, RESOLUTION_NAMES
found = next((event for event in events if event["event"] == "discovered"), {})
modes: list[str] = []
for channel in found.get("channels") or []:
for config in (channel.get("video_configs") or []) if isinstance(channel, dict) else []:
if not isinstance(config, dict):
continue
resolution, codec = _config_field(config, 1), _config_field(config, 10, CODEC_H264_BP)
text = f"{RESOLUTION_NAMES.get(resolution, f'resolution {resolution}')} {CODEC_NAMES.get(codec, f'codec {codec}')}"
margins = _config_field(config, 3, 0), _config_field(config, 4, 0)
if any(margins):
text += f" margins {margins[0]}x{margins[1]}"
modes.append(text)
return modes[:MAX_OFFERED_MODES]
def session_sdp(events: list[dict]) -> dict:
"""How the car's Android Auto RFCOMM channel was found: where it came from, and how many SDP retries it took."""
found = next((event for event in events if event["event"] == "rfcomm_channel"), {})
retries = sum(1 for event in events if event["event"] == "sdp_retry")
if not found and not retries:
return {}
return {key: value for key, value in (("channel", found.get("channel") if isinstance(found.get("channel"), int) else None),
("source", str(found["source"])[:12] if found.get("source") else None),
("retries", retries or None)) if value is not None}
def session_setup(events: list[dict]) -> dict:
"""The setup type and profile the owner chose, as recorded when the session started."""
start = next((event for event in events if event["event"] == "session_start"), {})
setup = start.get("setup") if isinstance(start.get("setup"), dict) else {}
kind = setup.get("type")
return {key: value for key, value in (
("type", kind if kind in SETUP_TYPES else None),
("profile", scrub(setup.get("profile", ""), 40) or None),
("profile_source", setup.get("profile_source") if setup.get("profile_source") in ("override", "detected", "standard") else None),
) if value}
def session_ladder(events: list[dict]) -> list[dict]:
"""The handshake strategies tried in a session, in order, and how each ended (advanced, wrapped, exhausted, counted,
dropped, or worked). Kept for working pairings too: it is what shows which strategy a head unit likes."""
tried: list[dict] = []
for event in events:
name = event["event"]
if name == "ladder_strategy":
tried.append({"rung": str(event.get("rung", ""))[:24], "learned": bool(event.get("learned")),
"at_s": elapsed(events[0], event)})
elif name == "ladder_failed" and tried:
tried[-1]["outcome"] = str(event.get("outcome", ""))[:16]
elif name == "ladder_worked" and tried:
tried[-1].update(outcome="worked", streamed_s=event.get("streamed_s"), t_ready_s=event.get("t_ready_s"))
return [{key: value for key, value in item.items() if value is not None} for item in tried[-12:]]
def session_strategy(events: list[dict], setup: dict) -> dict:
"""What a streaming session ran with; the raw material for "this head unit usually likes this profile"."""
if not any(event["event"] == "video_acknowledged" for event in events):
return {}
ready = next((event for event in reversed(events) if event["event"] == "video_setup"), {})
reply = next((event for event in events if event["event"] == "bootstrap_version_reply"), {})
mode = ready.get("mode") if isinstance(ready.get("mode"), dict) else {}
# Only a wireless bootstrap has a version reply; the modern one is logged, the standard one is its absence.
style = reply.get("style") or ("standard" if any(event["event"] == "bootstrap_start_response" for event in events) else None)
strategy = {"profile": setup.get("profile"), "window_offered": ready.get("window"), "window_used": ready.get("used_window"),
"reply_style": style, "channel_type": reply.get("channel_type"),
"config_index": mode.get("config_index")}
return {key: value for key, value in strategy.items() if value is not None}
def session_record(path: Path) -> dict:
@@ -184,7 +273,16 @@ def session_record(path: Path) -> dict:
car = report["car"]
start = next((e for e in events if e["event"] == "session_start"), {})
software, settings = start.get("software", {}), start.get("settings")
setup = session_setup(events)
opened = next((e for e in events if e["event"] == "bluetooth_opened"), None)
record = {
"setup": setup,
"offered_modes": offered_modes(events),
# True when the comma's address went to the car in the pairing request; None when the car never opened that channel.
"phone_address_sent": bool(opened.get("phone_address")) if opened is not None else None,
"strategy": session_strategy(events, setup),
"ladder": session_ladder(events),
"sdp": session_sdp(events),
"software": {key: str(software.get(key) or "unknown") for key in ("commit", "branch")} if isinstance(software, dict) else {},
"settings": settings if isinstance(settings, dict) else {},
"seq": _session_number(path),
@@ -196,6 +294,9 @@ def session_record(path: Path) -> dict:
"furthest_stage": report["furthest_stage"],
"failed_stage": str(last_failure.get("failed_stage", "")),
"error_kind": str(last_failure.get("error_kind", "")),
"reach": str(last_failure.get("reach", "")) or (reachability.classify(
str(last_failure.get("failed_stage", "")), str(last_failure.get("error_kind", "")), str(last_failure.get("error", "")))
if last_failure else ""),
"error": scrub(last_failure.get("error", "")),
"failures": len(failures),
"drops": len(drops),
@@ -339,7 +440,8 @@ def failure_detail(session: dict) -> dict:
"title": f"{assessment['category']} at {stage}: {kind}",
"session": session["seq"],
"attempt": session.get("failed_attempt"),
"cause": assessment["category"], "stage": stage, "error_kind": kind,
"cause": assessment["category"], "stage": stage, "error_kind": kind, "reach": session.get("reach", ""),
"loss": stream_loss_kind(session.get("error", "")) if assessment["category"] == "projection_interrupted" else "",
"summary": sanitize(failure_summary(assessment), names),
"diagnosis": sanitize(assessment, names),
}
@@ -395,6 +497,10 @@ def drive_event(sessions: list[dict]) -> dict:
head_unit = next((s["head_unit"] for s in reversed(sessions) if s["head_unit"]), {})
video = next((s["video"] for s in reversed(sessions) if s["video"].get("resolution")), {})
label = _head_unit_label(head_unit)
setup = next((s["setup"] for s in reversed(sessions) if s.get("setup")), {})
sent = next((s["phone_address_sent"] for s in reversed(sessions) if s.get("phone_address_sent") is not None), None)
ladder = next((s["ladder"][-1] for s in reversed(sessions) if s.get("ladder")), {})
strategy = next((s["strategy"] for s in reversed(sessions) if s.get("strategy")), {})
failures = [failure_detail(failure) for failure in drive_failures(sessions)]
failed = [session for session in sessions if session["seq"] in {failure["session"] for failure in failures}]
heading = "failures" if failures else "sessions"
@@ -425,8 +531,18 @@ def drive_event(sessions: list[dict]) -> dict:
"aa.encoder": video.get("encoder", "unknown"),
"aa.fps": str(summary["fps_avg"]) if summary["fps_avg"] is not None else "none",
"aa.failures": str(len(failures)),
# Low-cardinality choices only; long values (offered modes, strategy) live in the contexts below.
**({"aa.setup_type": setup["type"]} if setup.get("type") else {}),
**({"aa.adapter_model": setup["profile"]} if setup.get("profile") else {}),
**({"aa.profile_source": setup["profile_source"]} if setup.get("profile_source") else {}),
**({"aa.phone_address_sent": "yes" if sent else "no"} if sent is not None else {}),
**({"aa.reply_style": strategy["reply_style"]} if strategy.get("reply_style") else {}),
**({"aa.wifi_channel_type": str(strategy["channel_type"])} if strategy.get("channel_type") else {}),
**({"aa.ladder_rung": ladder["rung"], "aa.ladder_learned": "yes" if ladder.get("learned") else "no"} if ladder else {}),
**({"aa.attention": ",".join(reasons)[:190]} if reasons else {}),
**({"aa.cause": latest["cause"], "aa.stage": latest["stage"], "aa.error_kind": latest["error_kind"]} if latest else {})},
**({"aa.cause": latest["cause"], "aa.stage": latest["stage"], "aa.error_kind": latest["error_kind"]} if latest else {}),
**({"aa.reach_class": latest["reach"]} if latest and latest.get("reach") else {}),
**({"aa.loss_kind": latest["loss"]} if latest and latest.get("loss") else {})},
"breadcrumbs": {"values": attempt_breadcrumbs(sessions)},
"contexts": {"starport_drive": summary, "starport_head_unit": head_unit,
**{f"starport_session_{index + 1}": _public(session) for index, session in enumerate(sessions)},
@@ -38,7 +38,7 @@ class Sender:
self.opener = opener or urllib.request.build_opener(NoRedirect())
self.active = active
self.logger, self.client = logger, client
self.schema_tags = {"aa.schema": "2"} if schema_tags is None else schema_tags
self.schema_tags = {"aa.schema": "3"} if schema_tags is None else schema_tags
def __call__(self, **event) -> bool:
if not self.active():
@@ -1,13 +1,15 @@
"""Android Auto tuning selected from the saved head-unit fingerprint or an override."""
import re
DEFAULTS = {"connect_delay_s": 0.0, "max_window": 2, "phone_services": False, "phone_frames": False}
DEFAULTS = {"connect_delay_s": 0.0, "max_window": 2, "phone_services": False, "phone_frames": False,
"bootstrap_experiments": False, "stay_while_projecting": False}
FINGERPRINT_KEYS = ("head_unit_make", "head_unit_model", "car_make", "car_model", "car_year")
RULES: list[dict] = [
{"id": "aawireless", "label": "AAWireless", "match": {},
"tuning": {"max_window": 30, "phone_services": True, "phone_frames": True}},
"tuning": {"max_window": 30, "phone_services": True, "phone_frames": True, "stay_while_projecting": True}},
{"id": "unofficial", "label": "Unofficial dongle", "match": {}, "tuning": {"bootstrap_experiments": True}},
]
@@ -293,6 +293,34 @@ def shareable_config(config: dict) -> dict:
return shared
RECENT_DIAGNOSIS_SESSIONS = 5
MAX_DIAGNOSIS_BYTES = 512 << 10
def _diagnosis_report(logs: list[Path]) -> bytes:
"""The same tags and per-session detail a Bugsink report carries (setup, offered modes, reach class, ladder, SDP...),
for the newest few sessions, sanitized. An addition to the bundle: if it cannot be built the bundle is unaffected."""
try:
from openpilot.starpilot.system.starport import bugsink_report
records = []
for path in logs[:RECENT_DIAGNOSIS_SESSIONS]:
try:
records.append(bugsink_report.session_record(path))
except (OSError, ValueError, KeyError, TypeError):
continue
if not records:
return b""
records.reverse() # oldest first, as the drive report expects
event = bugsink_report.drive_event(records)
report = {"tags": event["tags"], "message": event["message"], "sessions": [bugsink_report._public(record) for record in records]}
data = json.dumps(report, indent=1, default=str).encode()
if len(data) > MAX_DIAGNOSIS_BYTES:
data = json.dumps({key: report[key] for key in ("tags", "message")}, indent=1, default=str).encode()
return data
except Exception:
return b""
def bundle(log_dir: Path | None = None, config_path: Path | None = None, *,
snapshot: Callable[[float], dict[str, bytes]] | None = system_snapshot.collect) -> bytes:
"""A zip for a bug report: every session log with its report, the settings, the renderer logs and a snapshot of the
@@ -321,6 +349,9 @@ def bundle(log_dir: Path | None = None, config_path: Path | None = None, *,
add(f"logs/{path.name}", data)
add(f"reports/{path.stem}.json", json.dumps(report, indent=2, default=str).encode())
add("REPORT.txt", ("\n".join(summaries) or "No Android Auto sessions have been logged yet.\n").encode())
report = _diagnosis_report(logs)
if report:
add("report.json", report)
config = identity_store.load_config(config_path)
add("config.json", json.dumps(shareable_config(config), indent=2, default=str).encode())
for name in EXTRA_LOGS:
@@ -188,12 +188,14 @@ def run(frames_path: str, touch_path: str | None = None, control_path: str | Non
gui_app._width, gui_app._height = geometry.logical_width, geometry.logical_height
gui_app._scale = 1.0
gui_app._render_texture = None
from openpilot.starpilot.system.starport import reconnect_notice
from openpilot.starpilot.system.starport.identity import certificate_days_left
from openpilot.starpilot.system.starport.projection_layout_runtime import ProjectionLayoutWatcher, comma_display_seed
viewport = (geometry.logical_width, geometry.logical_height)
watcher = ProjectionLayoutWatcher(viewport, display_seed=comma_display_seed)
layout = ProjectionOnroad(viewport=viewport, customization=watcher.load(),
certificate_days=certificate_days_left(), layout_watcher=watcher)
certificate_days=certificate_days_left(), layout_watcher=watcher,
reconnect_notice=reconnect_notice.take())
resources.callback(layout.close)
watcher.start()
resources.callback(watcher.close)
@@ -118,6 +118,8 @@ def handle(supervisor: Supervisor, request: dict[str, Any], verifier: StarPortSo
supervisor.set_auto_connect(bool(request.get("enabled", True)))
elif command == "set_dedicated_wifi":
supervisor.set_dedicated_wifi(request.get("enabled") is True)
elif command == "set_allow_hevc":
supervisor.set_allow_hevc(request.get("enabled") is True)
elif command == "select_receiver":
supervisor.select_receiver(str(request.get("address", "")), str(request.get("name", "")))
elif command == "forget_receiver":
@@ -3,11 +3,12 @@
// Altering them can degrade video smoothness or introduce lag on Android Auto.
// Do not change them autonomously; require an explicit user request specifically
// for these video optimizations.
// Isolated Qualcomm H.264 session for Android Auto projection. No cereal publisher,
// Isolated Qualcomm H.264 (or, with AA_ENCODER_HEVC, H.265) session for Android Auto projection. No cereal publisher,
// camera buffers, realtime priority, or background thread. Exactly one input in flight.
// Input is RGBA (converted here with NEON) or NV12 already converted on the GPU by the
// car renderer (copied into the encoder's padded layout). ABI 3 adds the NV12 entry
// point and create() options (AA_ENCODER_CBR).
// point and create() options (AA_ENCODER_CBR). ABI 4 adds AA_ENCODER_HEVC for a head unit that offers only H.265;
// the H.264 path is untouched. HEVC controls follow system/loggerd/encoder/v4l_encoder.cc.
// Buffer/ioctl conventions follow system/loggerd/encoder/v4l_encoder.cc and
// msgq/visionipc/visionbuf_ion.cc. Built for comma_arm64 by this directory's SConscript.
//
@@ -38,6 +39,7 @@ namespace {
using Clock = std::chrono::steady_clock;
constexpr unsigned CODECCONFIG = 0x00020000;
constexpr int AA_ENCODER_CBR = 1; // hold the configured bitrate instead of letting it float with scene complexity
constexpr int AA_ENCODER_HEVC = 2; // encode H.265 (Main profile) instead of H.264 baseline
constexpr size_t MAX_PACKET = 2 * 1024 * 1024 - 10;
void check(bool ok, const char *what) {
@@ -142,8 +144,9 @@ struct Encoder {
strcmp(reinterpret_cast<char *>(cap.card), "msm_vidc_venc")) throw std::runtime_error("Unsupported encoder driver");
v4l2_format out = {}; out.type = V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE;
out.fmt.pix_mp.width = width; out.fmt.pix_mp.height = height;
out.fmt.pix_mp.pixelformat = V4L2_PIX_FMT_H264;
call(fd, VIDIOC_S_FMT, &out, "H264 format");
const bool hevc = options & AA_ENCODER_HEVC;
out.fmt.pix_mp.pixelformat = hevc ? V4L2_PIX_FMT_HEVC : V4L2_PIX_FMT_H264;
call(fd, VIDIOC_S_FMT, &out, hevc ? "HEVC format" : "H264 format");
v4l2_streamparm parm = {}; parm.type = V4L2_BUF_TYPE_VIDEO_OUTPUT_MPLANE;
parm.parm.output.timeperframe.numerator = 1; parm.parm.output.timeperframe.denominator = fps;
call(fd, VIDIOC_S_PARM, &parm, "frame rate");
@@ -167,10 +170,15 @@ struct Encoder {
: V4L2_CID_MPEG_VIDC_VIDEO_RATE_CONTROL_VBR_CFR);
control(V4L2_CID_MPEG_VIDC_VIDEO_PRIORITY, V4L2_MPEG_VIDC_VIDEO_PRIORITY_REALTIME_DISABLE);
control(V4L2_CID_MPEG_VIDC_VIDEO_IDR_PERIOD, 1);
control(V4L2_CID_MPEG_VIDEO_H264_PROFILE, V4L2_MPEG_VIDEO_H264_PROFILE_BASELINE);
control(V4L2_CID_MPEG_VIDEO_H264_LEVEL, V4L2_MPEG_VIDEO_H264_LEVEL_3_1);
control(V4L2_CID_MPEG_VIDEO_H264_ENTROPY_MODE, V4L2_MPEG_VIDEO_H264_ENTROPY_MODE_CAVLC);
control(V4L2_CID_MPEG_VIDEO_H264_LOOP_FILTER_MODE, V4L2_MPEG_VIDEO_H264_LOOP_FILTER_MODE_ENABLED);
if (hevc) {
control(V4L2_CID_MPEG_VIDC_VIDEO_HEVC_PROFILE, V4L2_MPEG_VIDC_VIDEO_HEVC_PROFILE_MAIN);
control(V4L2_CID_MPEG_VIDC_VIDEO_HEVC_TIER_LEVEL, V4L2_MPEG_VIDC_VIDEO_HEVC_LEVEL_HIGH_TIER_LEVEL_5);
} else {
control(V4L2_CID_MPEG_VIDEO_H264_PROFILE, V4L2_MPEG_VIDEO_H264_PROFILE_BASELINE);
control(V4L2_CID_MPEG_VIDEO_H264_LEVEL, V4L2_MPEG_VIDEO_H264_LEVEL_3_1);
control(V4L2_CID_MPEG_VIDEO_H264_ENTROPY_MODE, V4L2_MPEG_VIDEO_H264_ENTROPY_MODE_CAVLC);
control(V4L2_CID_MPEG_VIDEO_H264_LOOP_FILTER_MODE, V4L2_MPEG_VIDEO_H264_LOOP_FILTER_MODE_ENABLED);
}
control(V4L2_CID_MPEG_VIDEO_MULTI_SLICE_MODE, 0);
unsigned count = buffers(V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE, 6);
buffers(V4L2_BUF_TYPE_VIDEO_OUTPUT_MPLANE, 9);
@@ -257,7 +265,7 @@ struct Encoder {
auto &buffer = *outputs[b.index]; buffer.sync(true);
auto *bytes = static_cast<unsigned char *>(buffer.addr);
if (b.flags & CODECCONFIG) {
if (plane.bytesused > 65536) throw std::runtime_error("Oversized H264 header");
if (plane.bytesused > 65536) throw std::runtime_error("Oversized codec header");
header.assign(bytes, bytes + plane.bytesused);
} else {
if (b.timestamp.tv_sec * 1000000LL + b.timestamp.tv_usec != timestamp || frame_done)
@@ -281,15 +289,20 @@ struct Encoder {
}
extern "C" {
int starport_encoder_abi() { return 3; }
int starport_encoder_abi() { return 4; }
void starport_encoder_timing(void *ptr, double *output) {
if (ptr && output) std::copy_n(static_cast<Encoder *>(ptr)->timing, 3, output);
}
void *starport_encoder_create(int width, int height, int fps, int bitrate, int margin_height, int options, char *error, size_t size) {
try {
if (width < 2 || width > 1280 || height < 2 || height > 720 || width % 2 || height % 2 ||
// H.264 baseline level 3.1 is a 720p30 session; HEVC Main at high-tier level 5 carries 1080p30 for a head unit
// that offers nothing else.
const bool hevc = options & AA_ENCODER_HEVC;
const int max_width = hevc ? 1920 : 1280, max_height = hevc ? 1080 : 720;
if (width < 2 || width > max_width || height < 2 || height > max_height || width % 2 || height % 2 ||
fps != 30 || bitrate < 1000000 || bitrate > 20000000 || margin_height < 0 ||
margin_height >= height || margin_height % 4 || (options & ~AA_ENCODER_CBR)) throw std::runtime_error("Unsupported encoder configuration");
margin_height >= height || margin_height % 4 || (options & ~(AA_ENCODER_CBR | AA_ENCODER_HEVC)))
throw std::runtime_error("Unsupported encoder configuration");
auto encoder = std::make_unique<Encoder>(width, height, fps, margin_height, options);
encoder->init(bitrate); return encoder.release();
} catch (const std::exception &e) { error_text(error, size, e); return nullptr; }
@@ -23,11 +23,14 @@ from pathlib import Path
from openpilot.starpilot.system.starport.encoder import MAX_ACCESS_UNIT, START_CODE, H264Encoder, nal_types
LIBRARY = Path(__file__).with_name("hw") / "libstarport_encoder.so"
ABI = 3
SUPPORTED_ABIS = (2, 3)
ABI = 4
SUPPORTED_ABIS = (2, 3, 4)
OPTION_CBR = 1
OPTION_HEVC = 2 # ABI 4: H.265 Main profile instead of H.264 baseline
MAX_WIDTH, MAX_HEIGHT, HW_FPS = 1280, 720, 30
HEVC_MAX_WIDTH, HEVC_MAX_HEIGHT = 1920, 1080
AUD = b"\x00\x00\x00\x01\x09\xf0"
HEVC_AUD = b"\x00\x00\x00\x01\x46\x01\x50"
def normalize_access_unit(data: bytes, *, keyframe: bool) -> bytes:
@@ -52,16 +55,51 @@ def normalize_access_unit(data: bytes, *, keyframe: bool) -> bytes:
return result
def hevc_nal_kinds(data: bytes) -> list[int]:
return [(data[match.end()] >> 1) & 0x3F for match in START_CODE.finditer(data) if match.end() < len(data)]
def normalize_access_unit_hevc(data: bytes, *, keyframe: bool) -> bytes:
"""H.265 counterpart of normalize_access_unit: exactly one leading AUD, one picture, VPS/SPS/PPS on keyframes.
A picture NAL is a VCL unit (types 0-9 and 16-21); an IDR is type 19 or 20. The comma's encoder splits every H.265
picture into two slices (measured on the device: [19, 19] and [1, 1]), so one or more VCL units make a picture.
Separate from the H.264 function so that path is unchanged."""
if not data.startswith((b"\x00\x00\x01", b"\x00\x00\x00\x01")) or len(data) > MAX_ACCESS_UNIT:
raise RuntimeError("Expected a bounded Annex B hardware frame")
starts = list(START_CODE.finditer(data))
units = []
for index, match in enumerate(starts):
end = starts[index + 1].start() if index + 1 < len(starts) else len(data)
if match.end() + 1 >= end:
raise RuntimeError("Empty hardware NAL unit")
units.append(((data[match.end()] >> 1) & 0x3F, data[match.start():end]))
kinds = [kind for kind, _ in units]
pictures = [kind for kind in kinds if kind <= 9 or 16 <= kind <= 21]
if not pictures or len({kind in (19, 20) for kind in pictures}) != 1: # slices of one picture are all IDR or none are
raise RuntimeError("Hardware encoder did not produce one picture")
if keyframe and not ({19, 20} & set(kinds) and {32, 33, 34}.issubset(kinds)):
raise RuntimeError("Hardware encoder did not produce an independently decodable keyframe")
result = HEVC_AUD + b"".join(unit for kind, unit in units if kind != 35)
if len(result) > MAX_ACCESS_UNIT:
raise RuntimeError("Hardware access unit too large")
return result
# ANDROID AUTO VIDEO PERFORMANCE GUARDRAIL: Hardware encoding and bounded frame submission avoid CPU stalls and encoder backlog.
# Altering this can degrade video smoothness or introduce lag on Android Auto.
# Do not change it autonomously; require an explicit user request specifically for this video optimization.
class HardwareH264Encoder:
backend = "qcom-v4l2"
codec = "h264"
max_width, max_height = MAX_WIDTH, MAX_HEIGHT # level 3.1 baseline is a 720p30 session
codec_options = 0
required_abi = 2
def __init__(self, width: int, height: int, fps: int = HW_FPS, bitrate_kbps: int = 6000, margin_height: int = 0,
library: Path = LIBRARY, rate_control: str = "cbr"):
if not (0 < width <= MAX_WIDTH and 0 < height <= MAX_HEIGHT) or width % 2 or height % 2 or fps != HW_FPS:
raise ValueError("Hardware encoder supports even sizes up to 1280x720 at 30 fps")
if not (0 < width <= self.max_width and 0 < height <= self.max_height) or width % 2 or height % 2 or fps != HW_FPS:
raise ValueError(f"Hardware encoder supports even sizes up to {self.max_width}x{self.max_height} at 30 fps")
if margin_height % 4:
margin_height = 0 # only chroma-aligned black margins may be skipped
self.width, self.height, self.fps = width, height, fps
@@ -71,7 +109,7 @@ class HardwareH264Encoder:
self.lib = ctypes.CDLL(str(library))
self.lib.starport_encoder_abi.restype = ctypes.c_int
self.abi = self.lib.starport_encoder_abi()
if self.abi not in SUPPORTED_ABIS:
if self.abi not in SUPPORTED_ABIS or self.abi < self.required_abi:
raise RuntimeError("libstarport_encoder.so ABI mismatch; rebuild")
pointer = ctypes.c_void_p
encode_args = [pointer, pointer, ctypes.c_size_t, ctypes.c_int, pointer, ctypes.c_size_t, pointer, ctypes.c_size_t]
@@ -88,7 +126,7 @@ class HardwareH264Encoder:
self.lib.starport_encoder_destroy.restype = None
self.error = ctypes.create_string_buffer(512)
self.output = ctypes.create_string_buffer(MAX_ACCESS_UNIT)
options = (OPTION_CBR if self.rate_control == "cbr" else 0,) if self.abi >= 3 else ()
options = ((OPTION_CBR if self.rate_control == "cbr" else 0) | self.codec_options,) if self.abi >= 3 else ()
self._create_args = (width, height, fps, int(bitrate_kbps) * 1000, margin_height, *options)
self._open()
@@ -127,10 +165,16 @@ class HardwareH264Encoder:
if size > len(self.output):
raise RuntimeError("Hardware access unit too large")
# .raw copies the entire 2 MiB capacity, even for a small inter frame.
data = normalize_access_unit(ctypes.string_at(self.output, size), keyframe=force)
data = self.normalize(ctypes.string_at(self.output, size), keyframe=force)
self.frame_index += 1
self.last_encode_ms = (time.monotonic() - started) * 1000
return data, {5, 7, 8}.issubset(nal_types(data))
return data, self.is_keyframe(data)
def normalize(self, data: bytes, *, keyframe: bool) -> bytes:
return normalize_access_unit(data, keyframe=keyframe)
def is_keyframe(self, data: bytes) -> bool:
return {5, 7, 8}.issubset(nal_types(data))
def close(self) -> None:
handle, self.handle = self.handle, None
@@ -138,6 +182,42 @@ class HardwareH264Encoder:
self.lib.starport_encoder_destroy(handle)
class HardwareHevcEncoder(HardwareH264Encoder):
"""H.265 for a head unit that offers nothing else (Allow H.265 video). Hardware only: there is no software fallback."""
backend = "qcom-v4l2-hevc"
codec = "h265"
max_width, max_height = HEVC_MAX_WIDTH, HEVC_MAX_HEIGHT
codec_options = OPTION_HEVC
required_abi = 4
def normalize(self, data: bytes, *, keyframe: bool) -> bytes:
return normalize_access_unit_hevc(data, keyframe=keyframe)
def is_keyframe(self, data: bytes) -> bool:
kinds = set(hevc_nal_kinds(data))
return bool({19, 20} & kinds) and {32, 33, 34}.issubset(kinds)
def create_hevc_encoder(width: int, height: int, *, bitrate_kbps: int, margin_height: int, log,
rate_control: str = "cbr") -> tuple[object, int]:
"""Return ``(encoder, fps)`` for an H.265 session. The car was told H.265, so a stand-in codec would send it a stream it
cannot decode: if the hardware encoder cannot open, the session fails with the reason."""
encoder = None
try:
if not LIBRARY.is_file():
raise RuntimeError(f"{LIBRARY.name} not built")
encoder = HardwareHevcEncoder(width, height, bitrate_kbps=max(bitrate_kbps, 4000), margin_height=margin_height,
rate_control=rate_control)
encoder.encode_rgba(bytes(width * height * 4), keyframe=True) # exercise the driver and the keyframe contract
except Exception as error:
if encoder is not None:
encoder.close()
raise RuntimeError(f"The car needs H.265 and the hardware encoder could not start it: {error}") from error
log("encoder", backend=encoder.backend, codec=encoder.codec, fps=HW_FPS, abi=encoder.abi, rate_control=encoder.rate_control,
nv12=encoder.supports_nv12)
return encoder, HW_FPS
# ANDROID AUTO VIDEO PERFORMANCE GUARDRAIL: Backend selection and frame-rate budgets are part of the low-latency video pipeline.
# Altering this can degrade video smoothness or introduce lag on Android Auto.
# Do not change it autonomously; require an explicit user request specifically for this video optimization.
@@ -20,6 +20,14 @@ from datetime import UTC, datetime
from pathlib import Path
from typing import Any
def boot_id() -> str:
try:
return Path("/proc/sys/kernel/random/boot_id").read_text().strip()
except OSError:
return ""
def _data_dir() -> Path:
if override := os.environ.get("STARPORT_DIR"):
return Path(override)
@@ -141,11 +149,13 @@ DEFAULT_CONFIG = {
"render_profile_kb": 256, # size cap per render_profile file
"wifi_interface": "wlan0",
"dedicated_wifi_interface": True, # join the car on its own aa0 station so wlan0 stays on home Wi-Fi
"allow_hevc": False, # offer H.265 when the car offers no usable H.264 mode (Galaxy Developer > StarPort)
"device_name": "StarPilot",
"version_status": 0, # WifiVersionResponse status (0 = success) for receivers that negotiate a version
"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
"modern_reply_cache": {}, # car address -> True when its v4+ head unit only started after the phone-style version reply
"car_type": "", # wireless setup shown in Galaxy: "builtin" or "adapter"; "" = work it out from the saved companion car
"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
@@ -188,11 +198,14 @@ def load_config(path: Path | None = None) -> dict:
if config["usb_mode"] not in ("auto", "handshake", "direct"):
config["usb_mode"] = "auto"
config["bitrate_kbps"] = max(1000, min(12000, int(config["bitrate_kbps"])))
config["allow_hevc"] = config["allow_hevc"] is True
if config["rate_control"] not in ("cbr", "vbr"):
config["rate_control"] = "cbr"
config["render_profile_kb"] = max(16, min(4096, int(config["render_profile_kb"])))
config["rfcomm_cache"] = {str(address).upper(): channel for address, channel in config["rfcomm_cache"].items()
if type(channel) is int and 1 <= channel <= 30}
config["modern_reply_cache"] = {str(address).upper(): True for address, modern in config["modern_reply_cache"].items()
if modern is True}
return config
+23 -8
View File
@@ -26,7 +26,7 @@ from jeepney.low_level import MessageType
from jeepney.wrappers import Properties
from openpilot.starpilot.system.starport.bootstrap import WifiCredentials
from openpilot.starpilot.system.starport.identity import DATA_DIR
from openpilot.starpilot.system.starport.identity import DATA_DIR, boot_id as _boot_id
NM = "org.freedesktop.NetworkManager"
NM_PATH = "/org/freedesktop/NetworkManager"
@@ -39,7 +39,12 @@ NM_WIRELESS_IFACE = "org.freedesktop.NetworkManager.Device.Wireless"
NM_AP_IFACE = "org.freedesktop.NetworkManager.AccessPoint"
DEVICE_TYPE_WIFI = 2
DEVICE_STATE_DISCONNECTED = 30
DEVICE_STATES_NOT_MANAGED = (10, 20) # NM_DEVICE_STATE_UNMANAGED, NM_DEVICE_STATE_UNAVAILABLE
DEVICE_READY_TIMEOUT_S = 5.0
# A shared interface can be briefly unmanaged (for example while the Wi-Fi mode is being switched). Bugsink showed 19 joins
# refused with "device is strictly unmanaged"; waiting a few seconds turns a transient state into a normal join, and a
# device that stays unmanaged is reported as such instead of as an opaque D-Bus error.
UNMANAGED_WAIT_S = 8.0
ACTIVE_STATE_ACTIVATED = 2
ACTIVE_STATE_DEACTIVATED = 4
# The car sends its Wi-Fi details ~4 s after ignition, but a Honda's access point took ~55 s from ignition to appear
@@ -65,13 +70,6 @@ class JoinTimeout(NetworkError):
"""The car's network never came up in time."""
def _boot_id() -> str:
try:
return Path("/proc/sys/kernel/random/boot_id").read_text().strip()
except OSError:
return ""
def _paused_interface(marker: Path) -> str:
"""The interface a session paused this boot and never restored, or ""; NetworkManager resets autoconnect on reboot."""
try:
@@ -253,6 +251,7 @@ class NetworkLease:
self._prepare_device(cancelled)
else:
self.device_path = self._wifi_device()
self._wait_managed(cancelled)
self._pause_autoconnect()
active = self._get(self.device_path, NM_DEVICE_IFACE, "ActiveConnection")
if active and active != "/" and not self.previous_connection and not self.dedicated:
@@ -316,6 +315,22 @@ class NetworkLease:
raise DedicatedUnavailable(failure)
time.sleep(0.25)
def _wait_managed(self, cancelled: Callable[[], bool], timeout: float = UNMANAGED_WAIT_S) -> None:
"""Give NetworkManager a few seconds to manage the shared interface before joining on it."""
deadline = time.monotonic() + timeout
waited = False
while (state := self._get(self.device_path, NM_DEVICE_IFACE, "State")) in DEVICE_STATES_NOT_MANAGED:
if cancelled():
raise NetworkError("cancelled")
if time.monotonic() >= deadline:
raise NetworkError(f"{self.interface} is unmanaged by NetworkManager (state {state}) after waiting {timeout:.0f} s")
if not waited:
waited = True
self.log("wifi_waiting_managed", interface=self.interface, state=state)
time.sleep(0.25)
if waited:
self.log("wifi_became_managed", interface=self.interface)
def _prepare_device(self, cancelled: Callable[[], bool]) -> None:
if self.device_ready:
return
@@ -0,0 +1,26 @@
"""Persist the setup UI choice independently of the StarPort daemon."""
from pathlib import Path
from openpilot.common.utils import atomic_write
from openpilot.starpilot.system.starport.identity import DATA_DIR
FLAG_PATH = DATA_DIR / "new_setup"
def enabled(path: Path | None = None) -> bool:
"""Absent, unreadable or unrecognized means on; only an explicit 0 turns the new flow off."""
try:
return (path or FLAG_PATH).read_text().strip() != "0"
except (OSError, UnicodeError):
return True
def set_enabled(value: bool, path: Path | None = None) -> bool:
target = path or FLAG_PATH
try:
target.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
with atomic_write(str(target), overwrite=True) as handle:
handle.write("1" if value else "0")
except OSError:
return False
return enabled(target) == value
@@ -14,6 +14,7 @@ from openpilot.starpilot.system.starport.projection_map_lease import MapLeaseWri
CERTIFICATE_NOTICE_NS = 10_000_000_000 # how long the expiry heads-up stays at the start of a drive
BOOKMARK_NOTICE_NS = 2_000_000_000 # how long "Bookmark Saved" stays after the drive is bookmarked
MODE_NOTICE_NS = 2_000_000_000 # how long the driving-mode banner stays after the mode changes
RECONNECT_NOTICE_NS = 8_000_000_000 # how long "why it reconnected" stays once the car screen is back
DRIVER_MONITOR_HOLD_NS = 350_000_000
STANDBY_LOGO = Path(__file__).parents[3] / 'selfdrive/assets/images/starpilot_logo.png'
BUBBLE_REDUNDANT_ALERTS = frozenset(('preLaneChangeLeft', 'preLaneChangeRight', 'laneChange',
@@ -150,12 +151,14 @@ class ProjectionOnroad:
"""Separate renderer with projected actions; native UIState stays read-only."""
def __init__(self, *, dependencies=None, viewport=None, customization=None, certificate_days=None, native_focus=None,
layout_watcher=None):
layout_watcher=None, reconnect_notice=""):
viewport = FALLBACK_VIEWPORT if viewport is None else viewport
self.width, self.height = viewport
self.customization = customization
self._certificate_notice = certificate_notice(certificate_days)
self._certificate_notice_until_ns: int | None = None
self._reconnect_notice = reconnect_notice
self._reconnect_notice_until_ns: int | None = None
self._bookmark_notice_until_ns: int | None = None
self._mode_label: str | None = None # the drive's last known driving mode
self._mode_engaged: bool | None = None
@@ -424,6 +427,18 @@ class ProjectionOnroad:
return replace(state, alert=self.native.alert(size=sizes.SMALL, text1=self._certificate_notice,
alert_type='androidAutoCertificate/warning'))
def _with_reconnect_notice(self, state, now_ns):
"""Say why the car screen just came back (the comma's Wi-Fi switched networks); a real alert always wins."""
if not self._reconnect_notice:
return state
if self._reconnect_notice_until_ns is None:
self._reconnect_notice_until_ns = now_ns + RECONNECT_NOTICE_NS
sizes = self.native.alert_size
if now_ns >= self._reconnect_notice_until_ns or state.alert.size != sizes.NONE:
return state
return replace(state, alert=self.native.alert(size=sizes.SMALL, text1=self._reconnect_notice,
alert_type='androidAutoReconnected/info'))
def bookmark_saved(self, now_ns=None):
"""A bookmark was published for this drive (from any button): confirm it on the car screen."""
self._bookmark_notice_until_ns = (time.monotonic_ns() if now_ns is None else now_ns) + BOOKMARK_NOTICE_NS
@@ -516,7 +531,8 @@ class ProjectionOnroad:
# Touch hit-tests read viewport_width; the view draws at the car canvas width (1920 for a 1280x720 head unit).
if state.viewport_width != self.width:
state = replace(state, viewport_width=self.width)
state = self._with_bookmark_notice(self._with_certificate_notice(state, now_ns), now_ns)
state = self._with_certificate_notice(self._with_reconnect_notice(state, now_ns), now_ns)
state = self._with_bookmark_notice(state, now_ns)
self._state = self._with_mode_notice(state, now_ns)
if self._slc_input is not None:
self._state = self._slc_input.present(self._state)
@@ -81,17 +81,21 @@ def forget_car(address: str, client: StarPortClient | None = None) -> bool:
client = client or StarPortClient()
if client.available:
return bool(client.call("forget_receiver", address=address).get("cleared"))
from openpilot.starpilot.system.starport import identity
from openpilot.starpilot.system.starport import identity, setup_ladder
address = address.strip().upper()
config = identity.load_config()
chosen = config["receiver_address"].upper() == address
companion = config["companion_address"].upper() == address
cache = dict(config["rfcomm_cache"])
if cache.pop(address, None) is None and not (chosen or companion):
replies = dict(config["modern_reply_cache"])
cached = cache.pop(address, None) is not None
cached = replies.pop(address, None) is not None or cached
learned = setup_ladder.SetupLadder(identity.CONFIG_PATH.with_name("setup_state.json")).forget(address)
if not (chosen or companion or cached or learned):
return False
config["rfcomm_cache"] = cache
config.update(rfcomm_cache=cache, modern_reply_cache=replies)
if chosen:
config.update(receiver_address="", receiver_name="", companion_address="", companion_name="")
config.update(receiver_address="", receiver_name="", companion_address="", companion_name="", car_fingerprint={})
elif companion:
config.update(companion_address="", companion_name="")
identity.save_config(config)
@@ -0,0 +1,45 @@
"""Classify live and logged connection failures to select retry delays."""
ASLEEP = "asleep" # the car did not answer a page at all: off, or still waking
NOT_READY = "not_ready" # the car answered, but its Android Auto service or Wi-Fi handshake is not up yet
LOCAL = "local" # this comma's own Bluetooth stack refused or was busy; the car says nothing
OTHER = "other" # anything else, including every failure after the link is up
CLASSES = (ASLEEP, NOT_READY, LOCAL, OTHER)
_LOCAL = ("device or resource busy", "errno 16]", "operation not permitted", "no such device")
_ASLEEP = ("host is down", "errno 112]", "page-timeout", "page timeout", "br-connection-page")
_NOT_READY = ("connection refused", "errno 111]", "connection reset", "errno 104]", "sdp record missing",
"does not advertise android auto", "did not answer in time")
_EARLY_STAGES = ("connecting_bluetooth", "discovering", "rfcomm", "wifi_start", "wifi_info", "bootstrap")
# Waiting longer for a radio that is off does not wake it, and every page holds the controller (a page that crosses
# the car's own page can strand the link). These are floors: a class never waits less than the standard backoff.
ASLEEP_FLOOR = (6.0, 8.0, 12.0, 20.0, 30.0)
LOCAL_FLOOR = (8.0, 10.0, 15.0, 30.0)
def classify(stage: str, kind: str, message: str) -> str:
text = f"{kind} {message}".lower()
if kind == "BluetoothRejected" or any(word in text for word in _LOCAL):
return LOCAL
if stage not in _EARLY_STAGES:
return OTHER
if any(word in text for word in _ASLEEP) or (kind == "TimeoutError" and stage == "connecting_bluetooth"):
return ASLEEP
if any(word in text for word in _NOT_READY) or kind == "BootstrapTimeout":
return NOT_READY
return OTHER
def retry_delay(reach: str, attempt: int, standard: float) -> float:
"""The wait before the next attempt; never shorter than the standard backoff, and unchanged for other failures."""
floor = {ASLEEP: ASLEEP_FLOOR, LOCAL: LOCAL_FLOOR}.get(reach)
if floor is None:
return standard
return max(standard, floor[min(max(attempt, 0), len(floor) - 1)])
def keeps_cached_channel(reach: str) -> bool:
"""A sleeping car says nothing about which RFCOMM channel is right, so its cached channel is kept."""
return reach in (ASLEEP, LOCAL)
@@ -0,0 +1,43 @@
"""Why the last projection reconnected, handed from the supervisor to the next car-screen renderer."""
from __future__ import annotations
import json
import time
from pathlib import Path
from openpilot.common.utils import atomic_write
from openpilot.starpilot.system.starport import identity as identity_store
NOTICE_PATH = identity_store.DATA_DIR / "reconnect_notice.json"
NOTICE_FRESH_S = 120.0 # a notice older than this is about an earlier drive or reconnect (monotonic: same boot only)
SHARED_RADIO_TEXT = "Reconnected: comma Wi-Fi Was Switching Networks" # on the car screen after a drop caused by the comma's own Wi-Fi
def write(text: str, path: Path = NOTICE_PATH, now: float | None = None) -> None:
try:
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
with atomic_write(str(path), overwrite=True) as handle:
json.dump({"text": text, "written": time.monotonic() if now is None else now, "boot": identity_store.boot_id()}, handle)
except OSError:
pass # the notice is a courtesy; projection never depends on it
def take(path: Path = NOTICE_PATH, now: float | None = None) -> str:
"""Read and remove the pending notice; "" if there is none or it is stale."""
try:
record = json.loads(path.read_text())
except (OSError, ValueError):
return ""
try:
path.unlink()
except OSError:
pass
now = time.monotonic() if now is None else now
boot = identity_store.boot_id()
if not isinstance(record, dict) or not isinstance(record.get("text"), str) or not boot or record.get("boot") != boot:
return ""
written = record.get("written")
if not isinstance(written, (int, float)) or not 0 <= now - written <= NOTICE_FRESH_S:
return ""
return record["text"]
+4 -2
View File
@@ -30,7 +30,9 @@ ERRORS = {1: "invalid SDP version", 2: "invalid service record handle", 3: "inva
class SdpError(RuntimeError):
pass
def __init__(self, message: str, raw: bytes = b""):
super().__init__(message)
self.raw = raw # what the car sent, for the log when the record is not usable
def build_request(continuation: bytes, service: uuid.UUID = AA_WIRELESS_UUID, transaction_id: int = 0) -> bytes:
@@ -107,5 +109,5 @@ def query_channel(sock, timeout: float = 10.0) -> int:
raise SdpError("Head unit does not advertise Android Auto Wireless (SDP record missing)")
channel = rfcomm_channel(bytes(collected))
if channel is None:
raise SdpError("Android Auto Wireless SDP record has no RFCOMM channel")
raise SdpError("Android Auto Wireless SDP record has no RFCOMM channel", bytes(collected))
return channel
+86 -11
View File
@@ -80,6 +80,7 @@ BLUETOOTH_AUTH_DATA = 0x8003
SENSOR_NIGHT, SENSOR_DRIVING_STATUS = 10, 13
CODEC_H264_BP = 3
CODEC_H265 = 7 # MediaCodecType VIDEO_H265, offered by some head units instead of H.264
SETUP_STATUS_READY = 2
FOCUS_PROJECTED = 1
FOCUS_NATIVE = 2
@@ -115,6 +116,8 @@ class PeerRequestedStop(EOFError):
pass
class SharedRadioStall(TimeoutError):
"""The car stopped acknowledging video while the comma's other Wi-Fi network was joining or roaming."""
START_CODE = re.compile(b"\x00\x00(?:\x00)?\x01")
@@ -141,6 +144,39 @@ def wire_frame(access_unit: bytes, *, drop_config: bool) -> bytes:
return b"".join(units) or access_unit
# H.265 handling is kept apart from the H.264 functions above so the H.264 path stays byte-for-byte what it was.
# A NAL header is two bytes; the type is bits 1-6 of the first (ITU-T H.265 7.3.1.2).
HEVC_PARAMETER_SETS = (32, 33, 34) # VPS, SPS, PPS
HEVC_AUD = 35
def hevc_nal_type(first_byte: int) -> int:
return (first_byte >> 1) & 0x3F
def codec_config_hevc(access_unit: bytes) -> bytes:
"""The VPS, SPS and PPS NAL units of an H.265 keyframe, as the codec-config buffer carries them."""
starts = list(START_CODE.finditer(access_unit))
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 hevc_nal_type(access_unit[match.end()]) in HEVC_PARAMETER_SETS:
units.append(b"\x00\x00\x00\x01" + access_unit[match.end():end])
return b"".join(units)
def wire_frame_hevc(access_unit: bytes, *, drop_config: bool) -> bytes:
"""The H.265 picture NAL units without the access unit delimiter, and without parameter sets once they went separately."""
starts = list(START_CODE.finditer(access_unit))
drop = {HEVC_AUD, *HEVC_PARAMETER_SETS} if drop_config else {HEVC_AUD}
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 hevc_nal_type(access_unit[match.end()]) 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
@@ -153,6 +189,7 @@ class VideoMode:
content_left: int | None = None
content_top: int | None = None
density: int = 0 # dpi the head unit reports; 0 when it does not say
codec: int = CODEC_H264_BP
@property
def content_width(self) -> int:
@@ -166,6 +203,7 @@ class VideoMode:
return {"channel": self.channel, "config_index": self.config_index, "width": self.width, "height": self.height,
"fps": self.fps, "margin_width": self.margin_width, "margin_height": self.margin_height,
"density": self.density,
**({"codec": CODEC_NAMES[self.codec]} if self.codec != CODEC_H264_BP else {}),
**({"content_left": self.content_left, "content_top": self.content_top} if self.content_left is not None else {})}
@@ -188,14 +226,18 @@ def describe_video_configs(channels: list[dict]) -> str:
return ", ".join(offered) or "no video"
def choose_video_mode(channels: list[dict]) -> VideoMode:
"""Pick a negotiated H.264 mode the software encoder can sustain."""
def choose_video_mode(channels: list[dict], allow_hevc: bool = False) -> VideoMode:
"""Pick a negotiated H.264 mode the software encoder can sustain.
H.265 is considered only when the owner allows it and the car offered no usable H.264 mode, so a car that works on
H.264 is never changed."""
candidates = []
hevc_candidates = []
for channel in channels:
for index, config in enumerate(channel.get("video_configs", [])):
resolution = one(config, 1)
codec = one(config, 10, CODEC_H264_BP)
if resolution not in RESOLUTIONS or codec != CODEC_H264_BP:
if resolution not in RESOLUTIONS or codec not in (CODEC_H264_BP, CODEC_H265):
continue
width, height = RESOLUTIONS[resolution]
margin_width, margin_height = int(one(config, 3, 0) or 0), int(one(config, 4, 0) or 0)
@@ -215,10 +257,13 @@ def choose_video_mode(channels: list[dict]) -> VideoMode:
top_edge + bottom == margin_height and left_edge + right == margin_width):
left, top = left_edge, top_edge
mode = VideoMode(int(channel["id"]), index, width, height, fps, margin_width, margin_height, left, top,
int(one(config, 5, 0) or 0))
candidates.append((RESOLUTION_PREFERENCE.index(resolution), fps != 30, index, mode))
int(one(config, 5, 0) or 0), codec)
(candidates if codec == CODEC_H264_BP else hevc_candidates).append((RESOLUTION_PREFERENCE.index(resolution), fps != 30, index, mode))
if not candidates and allow_hevc:
candidates = hevc_candidates
if not candidates:
raise ValueError(f"Head unit did not advertise a supported H.264 video mode (offered {describe_video_configs(channels)})")
hint = "; this car offers H.265, which can be turned on in Developer > StarPort" if hevc_candidates else ""
raise ValueError(f"Head unit did not advertise a supported H.264 video mode (offered {describe_video_configs(channels)}){hint}")
return min(candidates, key=lambda item: item[:3])[3]
@@ -601,9 +646,18 @@ class ProjectionSession(Session):
# held it ~1 s on 2026-10-01 while the car kept acking in 27-219 ms. Stays under the 3 s after which a Honda takes
# its screen back without video.
ACK_TIMEOUT = 2.5
# The radio is shared with the comma's own Wi-Fi (aa0 + wlan0): joining or roaming there stalled the car's acks past
# 2.5 s on 2026-11-02 (hotspot join, home join, mesh roam). While that happens, wait this long before reconnecting.
ACK_GRACE_TIMEOUT = 8.0
ACK_DRAIN_SECONDS = 0.1 # before calling an ACK late, read what already arrived, for at most this long
MAX_WINDOW = 2
nav = None
grace_frame: float | None = None # send time of the frame whose late ack is being waited out
grace_focus_lost = False
# Replaced by the supervisor: whether the comma's other Wi-Fi joined or roamed in the last few seconds.
radio_change: Callable[..., bool] = staticmethod(lambda: False)
# HeadunitPad acknowledges every fragment (codec config included) rather than every frame.
fragment_acks = False
@@ -629,8 +683,10 @@ class ProjectionSession(Session):
self.config_ack_slack = 0 # codec-config messages a head unit may acknowledge like frames
self.epoch_acked = 0
self.video_confirmed = False
self.video_confirmed_at = 0.0
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.allow_hevc = False # the owner's Allow H.265 video setting; used only when the car offers no H.264 mode
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
@@ -652,7 +708,7 @@ class ProjectionSession(Session):
def start(self, device_name: str, device_brand: str) -> VideoMode:
self.channels = self.discover(device_name, device_brand)
self.mode = choose_video_mode(self.channels)
self.mode = choose_video_mode(self.channels, self.allow_hevc)
self.open_video()
self.open_input()
if self.phone_services:
@@ -667,7 +723,7 @@ class ProjectionSession(Session):
opened = parse_fields(self.wait_for(mode.channel, MSG_CHANNEL_OPEN_RESPONSE))
if signed(one(opened, 1)) != 0:
raise ValueError(f"Head unit rejected opening the video channel: {json_fields(opened)}")
self.send(mode.channel, AV_SETUP_REQUEST, field(1, CODEC_H264_BP))
self.send(mode.channel, AV_SETUP_REQUEST, field(1, mode.codec))
setup = parse_fields(self.wait_for(mode.channel, AV_SETUP_RESPONSE))
window = int(one(setup, 2, 0) or 0)
if one(setup, 1) != SETUP_STATUS_READY or window < 1: # the window is capped to MAX_WINDOW below; MIB3 offers 100
@@ -840,6 +896,8 @@ class ProjectionSession(Session):
granted = focus in (FOCUS_PROJECTED, FOCUS_PROJECTED_NO_INPUT)
self.focused = granted and self.allow_projection
self.event("video_focus", focus=focus, unsolicited=unsolicited, focused=self.focused)
if was_focused and not self.focused and self.grace_frame is not None:
self.grace_focus_lost = True # the car gave up on the stalled video: reconnect rather than wait unfocused
if was_focused and not self.focused and self.touch is not None:
self.touch_events.extend(self.touch.reset())
if self.focused and not was_focused:
@@ -898,6 +956,9 @@ class ProjectionSession(Session):
self.unacked -= count
self.acked += count
self.epoch_acked += count
if self.grace_frame is not None and (not self.pending or self.pending[0] != self.grace_frame):
self.event("ack_grace_recovered", gap_ms=round((now - self.grace_frame) * 1000))
self.grace_frame = None
self._confirm_video()
def _confirm_video(self) -> None:
@@ -906,6 +967,7 @@ class ProjectionSession(Session):
# bound on acknowledged frames without changing flow-control credit.
if not self.video_confirmed and self.epoch_acked > self.config_ack_slack:
self.video_confirmed = True
self.video_confirmed_at = time.monotonic()
self.event("video_acknowledged", session=self.session_id, ack_ms=round(self.max_ack_seconds * 1000))
# ANDROID AUTO VIDEO PERFORMANCE GUARDRAIL: Receiver ACK flow control bounds in-flight video; extra buffering increases latency.
@@ -920,6 +982,8 @@ class ProjectionSession(Session):
def check_progress(self, now: float | None = None) -> None:
measured = now is None
now = time.monotonic() if measured else now
if self.grace_focus_lost:
raise SharedRadioStall("The car took its screen back while the comma's Wi-Fi was switching networks")
if self._ack_overdue(now):
# ACKs that arrived while this loop was busy (a slow encode, a CPU burst) still sit in the socket; the
# streaming loop drains only a bounded batch per pass, so read what is there before calling the car silent.
@@ -929,12 +993,23 @@ class ProjectionSession(Session):
if measured:
now = time.monotonic()
if self._ack_overdue(now):
raise TimeoutError(f"Video acknowledgement older than {self.ACK_TIMEOUT:.1f} s")
self._wait_out_radio_change(now)
# Only a receiver known to answer pings can be declared dead by silence; for
# others a lost link surfaces as a socket error or a dropped Wi-Fi lease.
if self.ping_responses and now - self.last_rx > 15.0:
raise TimeoutError("Head unit stopped answering for 15 s")
def _wait_out_radio_change(self, now: float) -> None:
"""The oldest frame is past ACK_TIMEOUT: give up, unless the comma's other Wi-Fi just changed networks."""
oldest = self.pending[0]
if self.grace_frame != oldest:
if not self.radio_change():
raise TimeoutError(f"Video acknowledgement older than {self.ACK_TIMEOUT:.1f} s")
self.grace_frame = oldest
self.event("ack_grace", after_ms=round((now - oldest) * 1000))
if now - oldest > self.ACK_GRACE_TIMEOUT:
raise SharedRadioStall(f"Video acknowledgement older than {self.ACK_GRACE_TIMEOUT:.1f} s while the comma's Wi-Fi was switching networks")
def send_frame(self, data: bytes, timestamp_us: int, *, keyframe: bool) -> None:
if not self.can_send():
raise RuntimeError("Cannot send without video focus and window credit")
@@ -944,13 +1019,13 @@ class ProjectionSession(Session):
fragments = 0
config = b""
if self.needs_keyframe:
config = codec_config(data)
config = codec_config_hevc(data) if self.mode.codec == CODEC_H265 else 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))
data = (wire_frame_hevc if self.mode.codec == CODEC_H265 else 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)
@@ -0,0 +1,249 @@
"""Try wireless handshakes in order and remember a strategy after sustained video.
Only bootstrap timeouts after a version exchange advance the ladder; failures
before the car answers or after acknowledged video leave its strategy unchanged.
"""
from __future__ import annotations
import json
import time
from dataclasses import dataclass
from pathlib import Path
from openpilot.common.utils import atomic_write
from openpilot.starpilot.system.starport.bootstrap import CHANNEL_24GHZ, CHANNEL_DUAL, MODERN_MAJOR, REFUSAL_PATIENCE
from openpilot.starpilot.system.starport.identity import DATA_DIR, boot_id as _boot_id
STATE_PATH = DATA_DIR / "setup_state.json"
STATE_VERSION = 1
MAX_CARS = 16
MAX_PASSES = 3 # full runs through the ladder per ignition cycle; not yet confirmed against real cars
DROP_AFTER = 2 # failures at the remembered strategy before it is forgotten and the ladder starts over
CLEAN_SECONDS = 60.0 # a session that streamed this long without ending in a handshake failure counts as working
LONG_PATIENCE = 2 * REFUSAL_PATIENCE
RUNGS = ("standard", "other_reply", "modern_dual", "modern_24ghz", "patient", "experiments")
LABELS = {
"standard": "Standard handshake",
"other_reply": "The other version reply",
"modern_dual": "Phone-style reply, both Wi-Fi bands",
"modern_24ghz": "Phone-style reply, 2.4 GHz",
"patient": "Waiting longer for the car",
"experiments": "Adapter handshake variations",
}
MODERN_RUNGS = ("other_reply", "modern_dual", "modern_24ghz") # only mean something to a car that speaks protocol 4 or newer
@dataclass(frozen=True)
class Strategy:
rung: str
modern_reply: bool
channel_type: int | None = None # forces the Wi-Fi band reply inside the phone-style reply; None keeps the car's own preference
patience: int = REFUSAL_PATIENCE
experiments: bool = False
def as_dict(self) -> dict:
return {"rung": self.rung, "modern_reply": self.modern_reply, "channel_type": self.channel_type,
"patience": self.patience, "experiments": self.experiments}
@property
def label(self) -> str:
return LABELS.get(self.rung, self.rung)
def resolve(rung: str, base_modern: bool) -> Strategy:
"""The concrete strategy for a rung, relative to the car's remembered reply style (today's behavior)."""
if rung == "other_reply":
return Strategy(rung, not base_modern)
if rung == "modern_dual":
return Strategy(rung, True, CHANNEL_DUAL)
if rung == "modern_24ghz":
return Strategy(rung, True, CHANNEL_24GHZ)
if rung == "patient":
return Strategy(rung, base_modern, patience=LONG_PATIENCE)
if rung == "experiments":
return Strategy(rung, base_modern, experiments=True)
return Strategy("standard", base_modern)
def applicable(rung: str, version_major: int, adapter: bool) -> bool:
if rung in MODERN_RUNGS:
return version_major >= MODERN_MAJOR
if rung == "experiments":
return adapter
return True
def _valid_strategy(value) -> Strategy | None:
if not isinstance(value, dict) or value.get("rung") not in RUNGS:
return None
channel, patience = value.get("channel_type"), value.get("patience")
if type(value.get("modern_reply")) is not bool or type(value.get("experiments")) is not bool:
return None
if channel not in (None, CHANNEL_24GHZ, CHANNEL_DUAL, 1) or type(patience) is not int or not 1 <= patience <= 12:
return None
return Strategy(value["rung"], value["modern_reply"], channel, patience, value["experiments"])
def _fresh() -> dict:
return {"rung": "standard", "results": {}, "passes": 0, "boot": "", "working": None, "working_failures": 0,
"t_ready": [], "successes": 0, "updated": 0.0}
def _clean(entry) -> dict:
"""A saved entry, or a fresh one when anything about it is not what this module writes."""
fresh = _fresh()
if not isinstance(entry, dict):
return fresh
try:
rung = entry.get("rung")
results = entry.get("results")
if rung in RUNGS:
fresh["rung"] = rung
if isinstance(results, dict):
fresh["results"] = {key: value for key, value in results.items() if key in RUNGS and value in ("failed", "skipped", "worked")}
for key in ("passes", "working_failures", "successes"):
if type(entry.get(key)) is int and 0 <= entry[key] <= 10_000:
fresh[key] = entry[key]
fresh["boot"] = entry["boot"][:64] if isinstance(entry.get("boot"), str) else ""
working = _valid_strategy(entry.get("working"))
fresh["working"] = working.as_dict() if working else None
fresh["t_ready"] = [round(float(value), 1) for value in entry.get("t_ready", []) if isinstance(value, (int, float)) and 0 <= value <= 3600][-8:]
fresh["updated"] = float(entry["updated"]) if isinstance(entry.get("updated"), (int, float)) else 0.0
except (TypeError, ValueError, KeyError):
return _fresh()
return fresh
class SetupLadder:
def __init__(self, path: Path | None = None, boot_id=_boot_id, clock=time.time): # noqa: TID251 persisted timestamps
self.path = path or STATE_PATH
self.boot_id, self.clock = boot_id, clock
self.cars: dict[str, dict] = {}
self.load()
def load(self) -> None:
try:
data = json.loads(self.path.read_text())
cars = data["cars"] if isinstance(data, dict) and data.get("version") == STATE_VERSION and isinstance(data.get("cars"), dict) else {}
except (OSError, ValueError, KeyError, TypeError, UnicodeError):
cars = {}
self.cars = {str(address).upper(): _clean(entry) for address, entry in list(cars.items())[:MAX_CARS]}
def save(self) -> bool:
ordered = dict(sorted(self.cars.items(), key=lambda item: item[1]["updated"], reverse=True)[:MAX_CARS])
self.cars = ordered
try:
self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
with atomic_write(str(self.path), overwrite=True) as handle:
json.dump({"version": STATE_VERSION, "cars": ordered}, handle, separators=(",", ":"))
except OSError:
return False # progress is a convenience; a read-only disk must never stop a connection
return True
def _entry(self, address: str) -> dict:
entry = self.cars.setdefault(address.upper(), _fresh())
boot = self.boot_id()
if entry["boot"] != boot: # a reboot is also a new cycle, for a comma that was powered off between drives
entry["boot"], entry["passes"] = boot, 0
return entry
def begin_cycle(self, address: str) -> None:
"""A new ignition cycle: the passes are used up per drive, not per boot (the comma can stay powered between drives).
Resumes at the first untried rung. Does nothing for a car with a learned strategy or nothing tried."""
entry = self.cars.get(address.upper())
if entry and entry["passes"]:
entry["passes"] = 0
self.save()
def forget(self, address: str) -> bool:
"""A car's pairing was deleted: drop what was learned about it."""
removed = self.cars.pop(address.upper(), None) is not None
if removed:
self.save()
return removed
def exhausted(self, address: str) -> bool:
entry = self.cars.get(address.upper())
return bool(entry) and entry["boot"] == self.boot_id() and entry["passes"] >= MAX_PASSES and not entry["working"]
def strategy(self, address: str, base_modern: bool) -> Strategy:
"""What to try now: the strategy that worked, else the current rung (standard once the passes are used up)."""
entry = self._entry(address)
working = _valid_strategy(entry["working"])
if working:
return working
if entry["passes"] >= MAX_PASSES:
return resolve("standard", base_modern)
return resolve(entry["rung"], base_modern)
def failed(self, address: str, strategy: Strategy, *, ladder_failure: bool, video_seen: bool, version_major: int,
adapter: bool) -> str | None:
"""Record a failed attempt; returns what changed ("advanced", "wrapped", "exhausted", "counted", "dropped") or None.
Only a handshake the car answered, before any video this session, counts: see the module docstring."""
if not ladder_failure or video_seen:
return None
entry = self._entry(address)
entry["updated"] = self.clock()
working = _valid_strategy(entry["working"])
dropped = False
if working and strategy.rung == working.rung:
entry["working_failures"] += 1
if entry["working_failures"] < DROP_AFTER:
self.save()
return "counted"
# Forgotten, and what just failed twice is not tried again first: carry on from the next rung.
entry.update(working=None, working_failures=0, results={})
dropped = True
if entry["passes"] >= MAX_PASSES:
return None
entry["results"][strategy.rung] = "failed"
start = RUNGS.index(strategy.rung) if strategy.rung in RUNGS else 0
outcome = "wrapped"
for rung in RUNGS[start + 1:]:
if applicable(rung, version_major, adapter):
entry["rung"], outcome = rung, "advanced"
break
entry["results"][rung] = "skipped"
if outcome == "wrapped":
entry["passes"] += 1
entry["rung"], entry["results"] = "standard", {}
if entry["passes"] >= MAX_PASSES:
outcome = "exhausted"
self.save()
return "dropped" if dropped else outcome
def worked(self, address: str, strategy: Strategy, t_ready_s: float | None = None) -> None:
entry = self._entry(address)
entry.update(working=strategy.as_dict(), working_failures=0, rung=strategy.rung, results={strategy.rung: "worked"},
passes=0, successes=entry["successes"] + 1, updated=self.clock())
if t_ready_s is not None and 0 <= t_ready_s <= 3600:
entry["t_ready"] = (entry["t_ready"] + [round(float(t_ready_s), 1)])[-8:]
self.save()
def ready_patience(self, address: str, stage_timeout: float = 20.0) -> int:
"""Stage timeouts a slow car needs: it was ready after t_ready_s once, so the bootstrap must be willing to wait that long.
Never less than the standard patience, and 0 (no opinion) for a car nothing is known about."""
entry = self.cars.get(address.upper())
times = entry["t_ready"] if entry else []
return min(9, int(-(-max(times) // stage_timeout))) if times else 0
def snapshot(self, address: str, adapter: bool = False) -> dict:
"""What the setup screen and the reports show: every rung, its state, and whether this car has a learned strategy."""
entry = self.cars.get(address.upper()) if address else None
entry = entry or _fresh()
working = _valid_strategy(entry["working"])
current = working.rung if working else entry["rung"]
passes = entry["passes"] if entry["boot"] == self.boot_id() else 0 # passes belong to one ignition cycle
steps = []
for rung in RUNGS:
if rung == "experiments" and not adapter:
continue
state = entry["results"].get(rung) or ("current" if rung == current else "waiting")
steps.append({"id": rung, "label": LABELS[rung], "state": state})
return {"steps": steps, "current": current, "learned": bool(working), "passes": passes, "max_passes": MAX_PASSES,
"exhausted": passes >= MAX_PASSES and not working, "successes": entry["successes"],
"t_ready_s": entry["t_ready"][-1] if entry["t_ready"] else None}
+320 -24
View File
@@ -33,16 +33,17 @@ from pathlib import Path
from typing import Any
from openpilot.common.hardware import COMMA_HARDWARE
from openpilot.starpilot.system.starport import car_profiles, device_health, identity as identity_store, system_slice
from openpilot.starpilot.system.starport import car_profiles, device_health, identity as identity_store, new_setup, reachability, setup_ladder, system_slice
from openpilot.starpilot.system.starport import reconnect_notice
from openpilot.starpilot.system.starport.auto_connect import AutoConnectPolicy
from openpilot.starpilot.system.starport.bootstrap import NAMES, BootstrapError, WirelessBootstrap
from openpilot.starpilot.system.starport.bootstrap import MODERN_MAJOR, NAMES, BootstrapError, BootstrapTimeout, WirelessBootstrap
from openpilot.starpilot.system.starport.bugsink_report import PairingAttempt
from openpilot.starpilot.system.starport.frame_source import (DEFAULT_PATH as DEFAULT_FRAME_PATH, FLAG_ASYNC_READBACK, FLAG_NV12,
FORMAT_NV12, FrameRequest)
from openpilot.starpilot.system.starport.projection_control import DEFAULT_CONTROL_SOCKET, NATIVE_FOCUS
from openpilot.starpilot.system.starport.touch import DEFAULT_TOUCH_SOCKET
from openpilot.starpilot.system.starport.view import CAR_FRAME_PATH, ViewSource, renderer_available
from openpilot.starpilot.system.starport.session import AuthenticationRejected, PeerRequestedStop, ProjectionSession
from openpilot.starpilot.system.starport.session import CODEC_H265, AuthenticationRejected, PeerRequestedStop, ProjectionSession, SharedRadioStall
BACKOFF_SECONDS = (2.0, 4.0, 8.0, 15.0, 30.0)
STABLE_SESSION_SECONDS = 30.0
@@ -60,6 +61,8 @@ 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"
COMMA_PAGE_WINDOW = 6.0 # a hands-free link this soon after we paged the car is ours; later is the car's
RFCOMM_BUSY_WAITS = (1.0, 2.0) # the kernel can still hold the last session's channel for a moment
CAR_FIRST_WAIT = 8.0 # at ignition, give the car this long to open hands-free before the comma pages it
NO_STARPORT_WAIT = 8.0 # a paired device must offer wireless projection within this long
USB_HANDSHAKE_WAIT = 10.0 # the car connected but sent no AOA START this long -> present as an accessory directly
@@ -67,9 +70,11 @@ USB_CONFIGURE_WAIT = 15.0 # slower receivers need time to enumerate after the
LINK_CHECK_INTERVAL = 1.0 # how often the link watcher asks whether the car's Wi-Fi is still up
LINK_CHECK_SLOW = 1.0 # a link check slower than this is logged (NetworkManager answers over D-Bus)
STREAM_STALL_LOG = 1.0 # a streaming loop pass slower than this is logged
RADIO_CHECK_INTERVAL = 0.5 # how often the comma's own Wi-Fi is checked for a join or roam (a sysfs read)
RADIO_CHANGE_WINDOW = 15.0 # a join or roam this recent can still stall the car (11-02: stall came 7.5 s after a roam)
REPORTED_SETTINGS = ("connection", "usb_mode", "view", "encoder", "fps", "bitrate_kbps", "rate_control", "gpu_nv12",
"async_readback", "wifi_interface", "dedicated_wifi_interface", "verify_head_unit", "version_status",
"rfcomm_channel", "phone_class", "auto_connect")
"rfcomm_channel", "phone_class", "auto_connect", "allow_hevc")
def encoder_preference(configured: str) -> str:
@@ -177,6 +182,26 @@ def car_never_answered(error: BaseException) -> bool:
return not (isinstance(error, OSError) and error.errno in LOCAL_SOCKET_ERRORS)
ADOPT_INTERVAL = 20.0 # how often housekeeping looks for a paired car that was never chosen
def choose_only_car(devices: list[dict]) -> tuple[dict | None, list[dict]]:
"""(the car to adopt, every candidate). A car is adopted only when exactly one paired device offers Android Auto Wireless.
Reads the cached BlueZ service list, so it never pages a device. Two candidates are never guessed between."""
candidates = [device for device in devices if device.get("paired") and device.get("starport")]
return (candidates[0] if len(candidates) == 1 else None), candidates
def drops_cached_channel(stage: str, error: BaseException) -> bool:
"""Forget the cached RFCOMM channel only when the car answered nothing there for a reason about the channel.
A sleeping car (page timeout, host down) or this comma's own busy Bluetooth says nothing about which channel is right."""
if stage not in ("rfcomm", "wifi_start") or not car_never_answered(error):
return False
return not reachability.keeps_cached_channel(reachability.classify(stage, type(error).__name__, str(error)))
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:]
@@ -230,6 +255,50 @@ class LinkWatch:
self._stop.wait(self.interval)
class SharedRadioWatch:
"""Notices the comma's own Wi-Fi joining or roaming while the car is on aa0; both share one radio.
The link going down or up bumps carrier_changes, a plain sysfs read. The network's name is never read or logged.
"""
def __init__(self, log: Callable[..., None], interface: str = "wlan0", interval: float = RADIO_CHECK_INTERVAL,
counter: Path | None = None):
self.log, self.interface, self.interval = log, interface, interval
self.counter = counter or Path(f"/sys/class/net/{interface}/carrier_changes")
self.changed_at: float | None = None
self._stop = threading.Event()
self._thread = threading.Thread(target=self._run, name="aa_radio_watch", daemon=True)
def start(self) -> SharedRadioWatch:
self._thread.start()
return self
def stop(self) -> None:
self._stop.set()
def recent(self, now: float | None = None) -> bool:
"""Whether the other network joined or roamed within RADIO_CHANGE_WINDOW."""
changed_at = self.changed_at
now = time.monotonic() if now is None else now
return changed_at is not None and now - changed_at <= RADIO_CHANGE_WINDOW
def _read(self) -> str | None:
try:
return self.counter.read_text().strip()
except OSError:
return None
def _run(self) -> None:
last = self._read()
while not self._stop.wait(self.interval):
current = self._read()
if current is None or current == last:
continue
last = current
self.changed_at = time.monotonic()
self.log("other_wifi_changed", interface=self.interface)
class EventLog:
"""Sanitized JSONL session log under /data/starport/logs, plus the recent tail in memory.
@@ -352,7 +421,14 @@ class Supervisor:
self._pairing_attempt: PairingAttempt | None = None
self._pairing_seen: tuple = ()
self._car_seen_at = -CAR_LINK_HOLD
self._paged_at = -COMMA_PAGE_WINDOW # when this comma last asked BlueZ to connect to the car
self._attempt_ended_at: float | None = None
self._hfp_link = threading.Event() # set when the chosen car opens the hands-free link
self._modern_reply: dict[str, bool] = {} # car address -> answer its v4+ version request like a current phone
self._ladder = setup_ladder.SetupLadder(identity_store.CONFIG_PATH.with_name("setup_state.json")) # handshake to try per car
self._strategy: setup_ladder.Strategy | None = None # the one this attempt runs
self._video_seen = False # video was acknowledged in this session: later failures say nothing about the handshake
self._run_started = 0.0
self.auto = AutoConnectPolicy()
self._companion_retry_at = 0.0
self.log = EventLog()
@@ -395,6 +471,10 @@ class Supervisor:
status["pairing_ready"] = self._pairing_active()
status["auto_connect"] = bool(self.config["auto_connect"])
status["dedicated_wifi_interface"] = bool(self.config["dedicated_wifi_interface"])
status["allow_hevc"] = bool(self.config["allow_hevc"])
status["car_candidates"] = list(getattr(self, "_candidates", []))[:8] # paired Android Auto devices to choose between
status["new_setup"] = new_setup.enabled()
status["setup_ladder"] = self._ladder.snapshot(self.config["receiver_address"], self.car_type() == "adapter")
status["auto_paused"] = self.auto.suppressed
status["recent"] = list(self.log.recent)[-8:]
return status
@@ -423,6 +503,13 @@ class Supervisor:
identity_store.save_config(self.config)
self.log("dedicated_wifi_selected", enabled=bool(enabled))
def set_allow_hevc(self, enabled: bool) -> None:
"""Let a car that offers only H.265 get H.265 video; used from the next session. Cars with an H.264 mode never change."""
with self._lock:
self.config["allow_hevc"] = bool(enabled)
identity_store.save_config(self.config)
self.log("allow_hevc_selected", enabled=bool(enabled))
def _adapter_choice_needed(self) -> bool:
return self.car_type() == "adapter" and not self.config.get("car_profile_override")
@@ -497,11 +584,14 @@ class Supervisor:
self.stop()
with self._lock:
companion = self.config.get("companion_address", "").upper() == address
cache = dict(self.config["rfcomm_cache"])
cache, replies = dict(self.config["rfcomm_cache"]), dict(self.config.get("modern_reply_cache", {}))
cached = cache.pop(address, None) is not None
if not (chosen or companion or cached):
cached = replies.pop(address, None) is not None or cached
learned = self._ladder.forget(address)
if not (chosen or companion or cached or learned):
return False # nothing of this device was kept
self.config["rfcomm_cache"] = cache
self.config.update(rfcomm_cache=cache, modern_reply_cache=replies)
self._modern_reply.pop(address, None)
if chosen:
if self._bluez is not None:
self._bluez.release()
@@ -729,8 +819,62 @@ class Supervisor:
self._end_pairing("expired")
if not self._session_alive():
self._release_phone()
self._adopt_only_car(now)
self._auto_connect(now)
def _adopt_only_car(self, now: float) -> None:
"""A car that is paired and offers Android Auto but was never chosen (the pairing window closed first, or it was
paired in Bluetooth settings) becomes the chosen car when it is the only one. Never during a pairing window or session,
and never a car the owner uses as the Bluetooth audio output: choosing it as the receiver would unselect that, and this
runs with nobody at the screen (the setup screen's Use button is where that choice is made)."""
config = self.config
if (config["receiver_address"] or config["connection"] != "wireless" or self.car_type() == "adapter"
or not self._projection_enabled() or self._pairing_active() or self._session_alive()
or now - getattr(self, "_adopt_at", -ADOPT_INTERVAL) < ADOPT_INTERVAL):
if config["receiver_address"]:
self._candidates = []
return
self._adopt_at = now
try:
car, candidates = choose_only_car(self.devices())
except Exception as error:
self._adopt_note("car_adopt_unavailable", kind=type(error).__name__, error=str(error)[:120])
return
self._candidates = [{"address": device["address"], "name": device.get("name") or device["address"]} for device in candidates]
if car is None:
if len(candidates) > 1:
self._adopt_note("car_adopt_ambiguous", count=len(candidates))
return
if self._is_audio_car(car["address"]):
self._adopt_note("car_adopt_skipped", reason="selected as the Bluetooth audio output")
return
try:
self.select_receiver(car["address"], car.get("name") or "")
except Exception as error:
self._adopt_note("car_adopt_failed", error=str(error))
return
finally:
if not self._session_alive():
self._release_phone() # select_receiver took the projection role; nothing is connecting, so give it back
self._candidates = []
self._adopt_last = None
self.log("car_adopted", car=car.get("name") or "", reason="only paired Android Auto device")
def _adopt_note(self, name: str, **values) -> None:
"""Housekeeping runs every 20 s: say what is wrong once, and again only when it changes."""
signature = (name, tuple(sorted(values.items())))
if signature != getattr(self, "_adopt_last", None):
self._adopt_last = signature
self.log(name, **values)
def _is_audio_car(self, address: str) -> bool:
if self._bt_client is None:
return False
try:
return self._bt_client.status().selected_audio.upper() == address.upper()
except Exception:
return True # cannot tell, so leave the owner's audio setup alone
def _auto_connect(self, now: float) -> None:
config = self.config
if self._pairing_active():
@@ -776,7 +920,8 @@ class Supervisor:
self._set(error=f'Waiting for selected car: {error}'[:300])
car_link = bool(car_device and car_device["connected"]) or now - self._car_seen_at < CAR_LINK_HOLD
onroad = self._onroad()
action = self.auto.decide(now, enabled=True, onroad=onroad, car_link=car_link,
linked = car_profiles.tuning(self.config)["stay_while_projecting"] and self._status.get("state") in ("streaming", "suspended")
action = self.auto.decide(now, enabled=True, onroad=onroad, car_link=car_link, linked=linked,
ready=adapter_ready and bool(device and device["paired"]) and (not companion or car_link), running=running)
self._apply_auto_action(now, action, onroad)
@@ -910,6 +1055,8 @@ class Supervisor:
return None
if operation == 'forget' and self.config['receiver_address'].upper() == address.upper():
return self._forget_selected_car(owner, address, session)
if operation == 'disconnect' and self.config['receiver_address'].upper() == address.upper():
return self._disconnect_selected_car(owner, address, session)
# select_receiver takes the supervisor lock before releasing an owner lease.
# Keep that same order through connection, save and admission pruning so an
# old adapter's successful action cannot save its car under a new adapter.
@@ -942,6 +1089,26 @@ class Supervisor:
identity_store.save_config(self.config)
return result
def _disconnect_selected_car(self, owner, address: str, session: tuple):
"""Disconnect the chosen car and keep auto-connect from reconnecting it until the next drive or a manual Start."""
from openpilot.starpilot.bluetooth.owner import BluetoothRejected
lease = owner.phone_lease
if not owner.session_valid(session):
raise BluetoothRejected('Galaxy session expired', code='session_expired')
if lease is not None and not lease.released and lease.session is not None:
raise BluetoothRejected('Finish Android Auto pairing first', code='busy')
self.auto.hold()
self.stop()
if self._session_alive():
raise BluetoothRejected('Android Auto is still stopping', code='busy')
lease = owner.phone_lease
if lease is None or lease.released:
return None
device = lease.phone.device(address)
if device is not None and device['connected']:
lease.phone._call(device['path'], 'org.bluez.Device1', 'Disconnect', timeout=12.0)
return owner.snapshot(session=session)
def _forget_selected_car(self, owner, address: str, session: tuple):
"""Forget the chosen car even while auto-connect is retrying it: stop the attempt, then delete the pairing."""
from openpilot.starpilot.bluetooth.owner import BluetoothRejected
@@ -954,13 +1121,31 @@ class Supervisor:
self.stop()
if self._session_alive():
raise BluetoothRejected('Android Auto is still stopping', code='busy')
result = owner.request('forget', address=address)
try:
result = owner.request('forget', address=address)
except Exception as error:
self.log("forget_car_error", error=repr(error)[:300])
result = self._forgotten_snapshot(owner, address, session, error)
self.forget_receiver(address)
return result
def _forgotten_snapshot(self, owner, address: str, session: tuple, error: Exception):
"""The pairing may already be gone when a later step fails: report success then, else re-raise."""
from openpilot.starpilot.bluetooth.owner import BluetoothRejected
try:
owner._client()._device_path(address)
except BluetoothRejected:
return owner.snapshot(session=session)
except Exception:
pass
raise error
def _hfp_connected(self, address: str) -> None:
if address.upper() in (self.config["receiver_address"].upper(), self.config.get("companion_address", "").upper()):
self._car_seen_at = time.monotonic()
now = time.monotonic()
since_page = now - self._paged_at
self.log("hfp_initiator", initiator="comma" if since_page < COMMA_PAGE_WINDOW else "car", since_page_s=round(since_page, 1))
self._car_seen_at = now
self._hfp_link.set()
def _release_phone(self) -> None:
@@ -986,6 +1171,21 @@ class Supervisor:
else:
bluez.restore_class()
def _remember_reply(self, address: str, modern: bool) -> None:
"""Keep the version reply a v4+ head unit started with, so the next session leads with it."""
with self._lock:
self._modern_reply[address] = modern
replies = dict(self.config.get("modern_reply_cache", {}))
if modern == replies.get(address, False):
return
if modern:
replies[address] = True
else:
replies.pop(address, None)
self.config["modern_reply_cache"] = replies
identity_store.save_config(self.config)
self.log("bootstrap_reply_remembered", modern=modern)
def _remember_channel(self, address: str, channel: int | None) -> None:
with self._lock:
cache = dict(self.config["rfcomm_cache"])
@@ -1114,18 +1314,27 @@ class Supervisor:
connection = self.config["connection"]
wireless = connection == "wireless"
receiver = {"wired": "usb", "headunitpad": "headunitpad", "openheadunit": "openheadunit"}.get(connection, self.config["receiver_name"])
self._video_seen, self._strategy = False, None
if trigger == "onroad" and wireless: # a new drive: the ladder's passes are per ignition, and the comma can stay on between drives
self._ladder.begin_cycle(self.config["receiver_address"])
from openpilot.starpilot.system.starport.bugsink_transport import device_metadata
software = device_metadata()
profile, profile_source = car_profiles.resolve(self.config)
self.log("session_start", receiver=receiver, generation=generation, trigger=trigger,
software={key: software.get(key, "unknown") for key in ("commit", "branch")},
settings={key: self.config.get(key) for key in REPORTED_SETTINGS})
settings={key: self.config.get(key) for key in REPORTED_SETTINGS},
# What the owner chose, so a report can tell a built-in unit from a dongle and say which profile applied.
setup={"type": {"wired": "usb", "headunitpad": "app", "openheadunit": "app"}.get(connection) or self.car_type(),
"profile": profile, "profile_source": profile_source})
lease = self._lease() if wireless else NoLease()
attempt = 0
car_first = trigger == "onroad" and wireless
try:
while not self._stop.is_set():
started = time.monotonic()
self._run_started = started
peer_stopped = False
reach = reachability.OTHER # set by a failed attempt; a clean end or a car-ended session keeps the standard wait
self.log("attempt_start")
try:
if car_first:
@@ -1145,12 +1354,16 @@ class Supervisor:
stage = error.stage if isinstance(error, BootstrapError) else self._status["last_stage"]
message = self._describe_error(stage, error)
self._set(error=message)
self.log("attempt_failed", stage=stage, error=message, kind=type(error).__name__, where=_where(error))
reach = reachability.classify(stage, type(error).__name__, f"{message} {error}")
self.log("attempt_failed", stage=stage, error=message, kind=type(error).__name__, where=_where(error), reach=reach)
if isinstance(error, SharedRadioStall):
reconnect_notice.write(reconnect_notice.SHARED_RADIO_TEXT)
if isinstance(error, StrayLinkError):
self._clear_stray_link()
car_first = True # the car pages right back once its link drops; let it, rather than crossing it again
finally:
self._close_sockets()
self._attempt_ended_at = time.monotonic()
try:
lease.release(restore=False)
except Exception as error:
@@ -1159,6 +1372,7 @@ class Supervisor:
break
attempt = 0 if time.monotonic() - started > STABLE_SESSION_SECONDS else attempt + 1
delay = BACKOFF_SECONDS[min(attempt, len(BACKOFF_SECONDS) - 1)]
delay = reachability.retry_delay(reach, attempt, delay)
if peer_stopped:
delay = max(delay, PEER_STOP_RETRY_SECONDS) # the car ended projection itself; do not bounce straight back
self._set(state="backoff", attempt=attempt, retry_in=delay, mode=None)
@@ -1187,6 +1401,8 @@ class Supervisor:
def _describe_error(stage: str, error: Exception) -> str:
if isinstance(error, AuthenticationRejected):
return "The car rejected the Android Auto identity; it may have expired"
if isinstance(error, SharedRadioStall):
return "The car's Wi-Fi stalled while the comma's own Wi-Fi was switching networks; reconnecting"
text = str(error) or type(error).__name__
if stage == "authenticating" and "certificate" in text.lower():
text += " (set verify_head_unit false in config.json to test without verifying the car)"
@@ -1202,6 +1418,7 @@ class Supervisor:
config = self.config
address = config["receiver_address"]
ident = identity_store.load_identity()
self._strategy = None
self._stage("connecting_bluetooth", config["receiver_name"])
bluez = self._phone()
@@ -1221,10 +1438,10 @@ class Supervisor:
if delay := car_profiles.tuning(config)["connect_delay_s"]:
self.log("car_profile_connect_delay", seconds=delay)
self._wait(delay)
self._paged_at = time.monotonic()
bluez.connect_device(address)
self._wait(1.0)
from openpilot.starpilot.system.starport import bt_sockets
channel = int(config.get("rfcomm_channel") or 0)
source = "config"
if not channel:
@@ -1234,17 +1451,39 @@ class Supervisor:
channel, source = self._discover_channel(address), "sdp"
self.log("rfcomm_channel", channel=channel, source=source)
boot = None
adapter = self.car_type() == "adapter"
# The ladder decides how to answer this car: what worked before, else the first rung (today's behavior), moving on
# only when a car that answered the handshake keeps refusing it before any video.
base_modern = self._modern_reply.get(address, config.get("modern_reply_cache", {}).get(address, False))
strategy = self._strategy = self._ladder.strategy(address, base_modern)
learned = self._ladder.snapshot(address, adapter)["learned"]
self.log("ladder_strategy", rung=strategy.rung, label=strategy.label, learned=learned, modern_reply=strategy.modern_reply,
channel_type=strategy.channel_type, patience=strategy.patience, experiments=strategy.experiments)
try:
self._stage("rfcomm", f"channel {channel}")
rfcomm = self._track(bt_sockets.connect_rfcomm(address, channel))
rfcomm = self._connect_rfcomm(address, channel)
boot = WirelessBootstrap(rfcomm, self._bootstrap_log, device_serial=config["device_name"],
version_status=int(config.get("version_status", 0)))
version_status=int(config.get("version_status", 0)),
experiment="all" if car_profiles.tuning(config)["bootstrap_experiments"] or strategy.experiments else "",
modern_reply=strategy.modern_reply, channel_override=strategy.channel_type,
# a car that once took 70 s to be ready gets more than the standard wait for the Wi-Fi handshake
patience=max(strategy.patience, self._ladder.ready_patience(address, 20.0)))
self._set(state="wifi_start")
result = boot.run(lambda credentials: lease.acquire(credentials, cancelled=boot.join_is_cancelled), cancelled=self._stop.is_set)
except Exception as error:
if _stray_link(error):
raise StrayLinkError(f"Bluetooth link to the car is stuck: {error}") from error # says nothing about the cached channel
if source == "cache" and not self._stop.is_set() and self._status["last_stage"] in ("rfcomm", "wifi_start") and car_never_answered(error):
# A car that answered the version exchange and then went silent or refused the Wi-Fi setup says something about the
# handshake; every other failure (asleep, local Bluetooth trouble, after video) says nothing and leaves the ladder alone.
ladder_failure = (boot is not None and boot.version_major > 0 and isinstance(error, BootstrapTimeout)
and error.stage in ("wifi_start", "wifi_info") and not self._stop.is_set())
outcome = self._ladder.failed(address, strategy, ladder_failure=ladder_failure, video_seen=self._video_seen,
version_major=boot.version_major if boot is not None else 0, adapter=adapter)
if outcome:
self.log("ladder_failed", outcome=outcome, rung=strategy.rung, next=self._ladder.strategy(address, base_modern).rung,
version_major=boot.version_major)
if source == "cache" and not self._stop.is_set() and drops_cached_channel(self._status["last_stage"], 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
@@ -1259,6 +1498,35 @@ class Supervisor:
self._project(result, lease, ident)
finally:
keepalive_stop.set()
if boot.version_major >= MODERN_MAJOR and self._ladder.snapshot(address, adapter)["learned"]:
self._remember_reply(address, boot.modern_reply)
def _connect_rfcomm(self, address: str, channel: int):
from openpilot.starpilot.system.starport import bt_sockets
ended = self._attempt_ended_at
since_end = round(time.monotonic() - ended, 1) if ended is not None else None
waited = 0.0
for tries, delay in enumerate(RFCOMM_BUSY_WAITS, 1):
try:
sock = self._track(bt_sockets.connect_rfcomm(address, channel))
except OSError as error:
if error.errno != errno.EBUSY:
raise
self.log("rfcomm_busy_retry", channel=channel, wait_s=delay, since_last_attempt_s=since_end)
self._wait(delay)
waited += delay
continue
if tries > 1:
self.log("rfcomm_busy_cleared", channel=channel, tries=tries, waited_s=waited, since_last_attempt_s=since_end)
return sock
try:
sock = self._track(bt_sockets.connect_rfcomm(address, channel))
except OSError as error:
if error.errno == errno.EBUSY:
self.log("rfcomm_busy_gave_up", channel=channel, waited_s=waited, since_last_attempt_s=since_end)
raise
self.log("rfcomm_busy_cleared", channel=channel, tries=len(RFCOMM_BUSY_WAITS) + 1, waited_s=waited, since_last_attempt_s=since_end)
return sock
def _discover_channel(self, address: str) -> int:
from openpilot.starpilot.system.starport import bt_sockets, sdp
@@ -1273,7 +1541,7 @@ class Supervisor:
if _stray_link(error):
raise StrayLinkError(f"Bluetooth link to the car is stuck: {error}") from error # retrying cannot clear it
last_error = error
self.log("sdp_retry", error=str(error), settle=settle)
self.log("sdp_retry", error=str(error), settle=settle, **({"record_hex": error.raw.hex()[:400]} if getattr(error, "raw", b"") else {}))
self._wait(0.35)
raise RuntimeError(f"Could not find the car's Android Auto service: {last_error}")
@@ -1463,7 +1731,9 @@ class Supervisor:
"""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
except Exception as error: # DBus errors vary; the pairing request is optional
# Never silent: a missing adapter() once left every wireless session without the request (no address, no picture).
self.log("adapter_address_unavailable", kind=type(error).__name__, error=str(error)[:120])
return ""
def _project(self, result, lease, ident, connect=None, fragment_acks: bool = False) -> None:
@@ -1475,6 +1745,7 @@ class Supervisor:
tuning = car_profiles.tuning(config)
session.max_window = tuning["max_window"]
session.phone_frames = tuning["phone_frames"]
session.allow_hevc = bool(config["allow_hevc"])
if tuning["phone_services"]:
session.phone_services = True
session.phone_address = self._adapter_address()
@@ -1490,16 +1761,20 @@ class Supervisor:
except (OSError, ValueError, TypeError, KeyError) as error:
self.log("screen_profile_error", error=str(error))
from openpilot.starpilot.system.starport.hw_encoder import create_encoder
from openpilot.starpilot.system.starport.hw_encoder import create_encoder, create_hevc_encoder
# ANDROID AUTO VIDEO PERFORMANCE GUARDRAIL: Encoder cadence and pipeline flags are tuned together for Android Auto video.
# Altering this can degrade video smoothness or introduce lag on Android Auto.
# Do not change it autonomously; require an explicit user request specifically for this video optimization.
software_fps = min(SOFTWARE_FPS, config["fps"]) if config["fps"] else SOFTWARE_FPS
encoder, fps = create_encoder(mode.width, mode.height, preference=encoder_preference(config["encoder"]),
bitrate_kbps=config["bitrate_kbps"],
margin_height=mode.margin_height if mode.content_top in (None, mode.margin_height // 2) else 0,
software_fps=software_fps, log=self.log,
rate_control=config["rate_control"])
margin_height = mode.margin_height if mode.content_top in (None, mode.margin_height // 2) else 0
if mode.codec == CODEC_H265: # only a car with no H.264 mode, and only with Allow H.265 video on
encoder, fps = create_hevc_encoder(mode.width, mode.height, bitrate_kbps=config["bitrate_kbps"], margin_height=margin_height,
log=self.log, rate_control=config["rate_control"])
else:
encoder, fps = create_encoder(mode.width, mode.height, preference=encoder_preference(config["encoder"]),
bitrate_kbps=config["bitrate_kbps"], margin_height=margin_height,
software_fps=software_fps, log=self.log,
rate_control=config["rate_control"])
fps = min(fps, mode.fps, config["fps"] or fps)
interval = 1.0 / fps
flags = (FLAG_NV12 if config["gpu_nv12"] and getattr(encoder, "supports_nv12", False) else 0) | \
@@ -1517,6 +1792,10 @@ class Supervisor:
renderer_log=identity_store.LOG_DIR / "car_ui.log")
self._set(view=source.label, encoder=getattr(encoder, "backend", "libx264"), target_fps=fps)
watch = LinkWatch(lease, self.log).start()
radio = None
if getattr(lease, "dedicated", False) and getattr(lease, "interface", "wlan0") != "wlan0":
radio = SharedRadioWatch(self.log).start()
session.radio_change = radio.recent
try:
from openpilot.starpilot.system.starport.media_audio import MediaAudio, music_control
from openpilot.starpilot.system.starport.media_status import MediaStatus
@@ -1534,11 +1813,14 @@ class Supervisor:
try:
self._stream(session, encoder, source, watch, interval)
finally:
self._note_streamed(session)
if session.audio is not None:
session.audio.close()
if getattr(session, "nav", None) is not None:
session.nav.close()
watch.stop()
if radio is not None:
radio.stop()
source.close()
encoder.close()
if self._stop.is_set():
@@ -1547,6 +1829,20 @@ class Supervisor:
except Exception:
pass
def _note_streamed(self, session: ProjectionSession) -> None:
"""Video was acknowledged: later failures in this session say nothing about the handshake. A session that streamed for
a minute without a handshake failure makes the strategy it ran with the one this car starts with next time."""
if not session.video_confirmed:
return
self._video_seen = True
strategy, address = self._strategy, self.config["receiver_address"]
streamed = time.monotonic() - session.video_confirmed_at
if strategy is None or not address or streamed < setup_ladder.CLEAN_SECONDS:
return
self._ladder.worked(address, strategy, t_ready_s=session.video_confirmed_at - self._run_started)
self.log("ladder_worked", rung=strategy.rung, streamed_s=round(streamed, 1),
t_ready_s=round(session.video_confirmed_at - self._run_started, 1))
# ANDROID AUTO VIDEO PERFORMANCE GUARDRAIL: The bounded ACK/input drain and conditional waits keep video and touches responsive.
# Altering this can degrade video smoothness or introduce lag on Android Auto.
# Do not change it autonomously; require an explicit user request specifically for this video optimization.
@@ -0,0 +1,329 @@
{
"source": "Bugsink Android Auto project, failure reports pulled 2026-11-01 (sanitized by the reporter)",
"failures": [
{
"stage": "discovering",
"kind": "RuntimeError",
"evidence": "Could not find the car's Android Auto service: [Errno 112] Host is down",
"count": 116
},
{
"stage": "wifi_start",
"kind": "BootstrapTimeout",
"evidence": "wifi_start: head unit did not answer in time",
"count": 59
},
{
"stage": "rfcomm",
"kind": "ConnectionResetError",
"evidence": "[Errno 104] Connection reset by peer",
"count": 53
},
{
"stage": "streaming",
"kind": "TimeoutError",
"evidence": "Video acknowledgement older than 2.5 s",
"count": 41
},
{
"stage": "streaming",
"kind": "TimeoutError",
"evidence": "Head unit stopped answering for 15 s",
"count": 37
},
{
"stage": "connecting_bluetooth",
"kind": "RuntimeError",
"evidence": "org.bluez.Error.Failed: br-connection-page-timeout",
"count": 36
},
{
"stage": "rfcomm",
"kind": "OSError",
"evidence": "[Errno 112] Host is down",
"count": 35
},
{
"stage": "rfcomm",
"kind": "ConnectionRefusedError",
"evidence": "[Errno 111] Connection refused",
"count": 35
},
{
"stage": "connecting_bluetooth",
"kind": "TimeoutError",
"evidence": "TimeoutError",
"count": 27
},
{
"stage": "connecting_bluetooth",
"kind": "RuntimeError",
"evidence": "Connect your paired car in Galaxy Bluetooth first to select it for this Android Auto adapter",
"count": 20
},
{
"stage": "streaming",
"kind": "EOFError",
"evidence": "Head unit disconnected after 0/4 bytes",
"count": 18
},
{
"stage": "wifi_info",
"kind": "ConnectionResetError",
"evidence": "getting car wi-fi: [Errno 104] Connection reset by peer",
"count": 18
},
{
"stage": "joining_wifi",
"kind": "BootstrapError",
"evidence": "joining car wi-fi: joining_wifi: AddAndActivateConnection2 failed: Connection 'starpilot-android-auto' is not available on device wlan0 because device is strictly unmanaged",
"count": 14
},
{
"stage": "authenticating",
"kind": "EOFError",
"evidence": "Head unit disconnected after 0/4 bytes",
"count": 14
},
{
"stage": "discovering",
"kind": "RuntimeError",
"evidence": "Could not find the car's Android Auto service: [Errno 111] Connection refused",
"count": 13
},
{
"stage": "rfcomm",
"kind": "TimeoutError",
"evidence": "timed out",
"count": 12
},
{
"stage": "wifi_info",
"kind": "BootstrapTimeout",
"evidence": "getting car wi-fi: wifi_info: head unit did not answer in time",
"count": 11
},
{
"stage": "authenticating",
"kind": "TimeoutError",
"evidence": "timed out",
"count": 10
},
{
"stage": "rfcomm",
"kind": "OSError",
"evidence": "[Errno 16] Device or resource busy",
"count": 10
},
{
"stage": "negotiating",
"kind": "TimeoutError",
"evidence": "timed out",
"count": 10
},
{
"stage": "joining_wifi",
"kind": "BootstrapError",
"evidence": "joining car wi-fi: joining_wifi: ",
"count": 6
},
{
"stage": "discovering",
"kind": "RuntimeError",
"evidence": "Could not find the car's Android Auto service: Head unit does not advertise Android Auto Wireless (SDP record missing)",
"count": 6
},
{
"stage": "joining_wifi",
"kind": "BootstrapError",
"evidence": "joining car wi-fi: joining_wifi: The projection network connection was removed (wrong key or out of range?)",
"count": 5
},
{
"stage": "negotiating",
"kind": "EOFError",
"evidence": "Head unit disconnected after 0/4 bytes",
"count": 5
},
{
"stage": "streaming",
"kind": "ConnectionResetError",
"evidence": "[Errno 104] Connection reset by peer",
"count": 5
},
{
"stage": "rfcomm",
"kind": "OSError",
"evidence": "[Errno 38] Function not implemented",
"count": 5
},
{
"stage": "discovering",
"kind": "RuntimeError",
"evidence": "Could not find the car's Android Auto service: timed out",
"count": 5
},
{
"stage": "connecting_tcp",
"kind": "RuntimeError",
"evidence": "Car did not accept the projection connection: [Errno 111] Connection refused",
"count": 5
},
{
"stage": "joining_wifi",
"kind": "BootstrapError",
"evidence": "joining car wi-fi: joining_wifi: AddAndActivateConnection2 failed: Connection 'starpilot-starport' is not available on device wlan0 because device is strictly unmanaged",
"count": 5
},
{
"stage": "negotiating",
"kind": "ValueError",
"evidence": "Head unit did not advertise a supported H.264 video mode (offered 1440x2560 H.265 margins 288x1378, 1080x1920 H.265 margins 0x811, 720x1280 H.265 margins 0x541, 1920x1080 H.265 margins 0x360 (display 1))",
"count": 4
},
{
"stage": "streaming",
"kind": "NativeFocusHeld",
"evidence": "Car kept its own screen for 15 s; reconnecting",
"count": 4
},
{
"stage": "discovering",
"kind": "RuntimeError",
"evidence": "Could not find the car's Android Auto service: [Errno 104] Connection reset by peer",
"count": 3
},
{
"stage": "connecting_bluetooth",
"kind": "RuntimeError",
"evidence": "org.bluez.Error.Failed: br-connection-unknown",
"count": 3
},
{
"stage": "connecting_bluetooth",
"kind": "BluetoothRejected",
"evidence": "Bluetooth adapter is busy in another process",
"count": 3
},
{
"stage": "negotiating",
"kind": "ValueError",
"evidence": "Hardware encoder supports even sizes up to 1280x720 at 30 fps",
"count": 3
},
{
"stage": "rfcomm",
"kind": "TimeoutError",
"evidence": "[Errno 110] Connection timed out",
"count": 3
},
{
"stage": "connecting_tcp",
"kind": "RuntimeError",
"evidence": "Car did not accept the projection connection: [Errno 99] Cannot assign requested address",
"count": 2
},
{
"stage": "discovering",
"kind": "RuntimeError",
"evidence": "Could not find the car's Android Auto service: [Errno 114] Operation already in progress",
"count": 2
},
{
"stage": "authenticating",
"kind": "ValueError",
"evidence": "Expected authentication status; got 0/0x3",
"count": 2
},
{
"stage": "waiting_for_usb",
"kind": "RuntimeError",
"evidence": "plug into the car's usb: The car did not finish USB setup. Use its Android Auto data port and a data-capable cable",
"count": 2
},
{
"stage": "streaming",
"kind": "TimeoutError",
"evidence": "timed out",
"count": 2
},
{
"stage": "discovering",
"kind": "RuntimeError",
"evidence": "Could not find the car's Android Auto service: [Errno 38] Function not implemented",
"count": 2
},
{
"stage": "connecting_bluetooth",
"kind": "RuntimeError",
"evidence": "org.bluez.Error.Failed: br-connection-refused",
"count": 2
},
{
"stage": "joining_wifi",
"kind": "BootstrapError",
"evidence": "joining car wi-fi: joining_wifi: Timed out joining the projection network after 30 s",
"count": 2
},
{
"stage": "rfcomm",
"kind": "PermissionError",
"evidence": "[Errno 13] Permission denied",
"count": 2
},
{
"stage": "connecting_bluetooth",
"kind": "BluetoothRejected",
"evidence": "org.freedesktop.systemd1.NoSuchUnit",
"count": 1
},
{
"stage": "joining_wifi",
"kind": "BootstrapError",
"evidence": "joining car wi-fi: joining_wifi: Joined the projection network but received no IPv4 address",
"count": 1
},
{
"stage": "authenticating",
"kind": "SSLCertVerificationError",
"evidence": "[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: format error in certificate's notBefore field (_ssl.c:1000) (set verify_head_unit false in config.json to test without verifying the car)",
"count": 1
},
{
"stage": "streaming",
"kind": "RuntimeError",
"evidence": "Lost the car's Wi-Fi network",
"count": 1
},
{
"stage": "rfcomm",
"kind": "BlockingIOError",
"evidence": "[Errno 114] Operation already in progress",
"count": 1
},
{
"stage": "negotiating",
"kind": "TimeoutError",
"evidence": "Head unit did not send 0/0x6",
"count": 1
},
{
"stage": "streaming",
"kind": "TimeoutExpired",
"evidence": "Command '['/usr/local/venv/bin/python3', '-m', 'openpilot.starpilot.system.android_auto.current_car_ui', '--frames', '/dev/shm/starpilot_android_auto_car_frame', '--touch', '/tmp/starpilot-android-auto-touch.sock', '--control', '/tmp/starpi",
"count": 1
},
{
"stage": "authenticating",
"kind": "ConnectionResetError",
"evidence": "[Errno 104] Connection reset by peer",
"count": 1
},
{
"stage": "connecting_tcp",
"kind": "RuntimeError",
"evidence": "Car did not accept the projection connection: timed out",
"count": 1
}
]
}
@@ -19,8 +19,8 @@ def identity(tmp_path_factory):
return make_identity(tmp_path_factory.mktemp("identity"))
def decide(policy, now, onroad=False, car_link=False, ready=True, running=False, enabled=True):
return policy.decide(now, enabled=enabled, onroad=onroad, car_link=car_link, ready=ready, running=running)
def decide(policy, now, onroad=False, car_link=False, ready=True, running=False, enabled=True, linked=False):
return policy.decide(now, enabled=enabled, onroad=onroad, car_link=car_link, ready=ready, running=running, linked=linked)
# -------------------------------------------------------------------- policy
@@ -45,12 +45,29 @@ def test_policy_stops_after_car_gone():
assert decide(policy, 100) is None # stopped, car still gone: no restart
def test_policy_keeps_a_projecting_session_when_the_car_link_drops():
policy = AutoConnectPolicy()
assert decide(policy, 0, car_link=True) == "start"
assert decide(policy, 1, car_link=True, running=True) is None
for now in range(2, 400, 10):
assert decide(policy, now, running=True, linked=True) is None
assert decide(policy, 400, running=True) is None # stream ended: grace period starts
assert decide(policy, 400 + auto_connect.STOP_AFTER, running=True) == "stop"
def test_policy_leaves_sessions_that_never_saw_the_car():
policy = AutoConnectPolicy()
for now in range(0, 300, 10):
assert decide(policy, now, running=True) is None
def test_policy_hold_blocks_start_while_idle_until_next_drive():
policy = AutoConnectPolicy()
policy.hold()
assert decide(policy, 0, car_link=True) is None
assert decide(policy, 1, onroad=True, car_link=True) == "start"
def test_policy_manual_stop_holds_until_next_drive():
policy = AutoConnectPolicy()
assert decide(policy, 0, onroad=True) == "start"
@@ -261,8 +261,12 @@ def test_donor_phone_uses_same_lease_and_does_not_take_over_independently(tmp_pa
def devices(self):
return []
def adapter(self):
return '/org/bluez/hci0', {'Address': '39:90:21:64:07:00'}
phone = FakePhone()
wrapper = LeaseAwarePhone(shared, state['session'], lambda *a, **k: None, lambda log: phone)
assert wrapper.adapter()[1]['Address'] == '39:90:21:64:07:00' # read before any lease, for the pairing request
with pytest.raises(BluetoothAdmissionError, match='not active'):
wrapper.register_hfp()
wrapper.acquire_pairing()
@@ -14,9 +14,10 @@ INFO = field(1, "AAWireless") + field(2, "secret-key") + field(3, "AA:BB:CC:DD:E
SETUP_INFO = field(4, ENDPOINT) + field(5, field(1, "AAWireless") + field(3, "secret-key") + field(4, 8))
def scripted(frames, *, ping_interval=bs.JOIN_PING_INTERVAL):
def scripted(frames, *, ping_interval=bs.JOIN_PING_INTERVAL, experiment=""):
"""A bootstrap whose car sends `frames` in order, then stays silent; records what the comma sent."""
boot = bs.WirelessBootstrap(None, lambda *a, **k: None, stage_timeout=2, join_ping_interval=ping_interval)
boot = bs.WirelessBootstrap(None, lambda *a, **k: None, stage_timeout=2, join_ping_interval=ping_interval,
experiment=experiment)
sent = []
boot.send = Mock(side_effect=lambda message_id, payload=b"": sent.append(message_id))
queue = list(frames)
@@ -100,3 +101,87 @@ def test_stop_during_the_join_is_not_reported_as_connected():
with pytest.raises(bs.BootstrapError, match="cancelled"):
boot.run(join, cancelled=stop.is_set)
assert bs.WIFI_CONNECT_STATUS not in sent
VERSION_F6 = (field(1, 1) + field(2, 7) + field(4, 5180) + field(5, field(1, "Link") + field(5, "CarLetter")) +
field(6, field(1, "192.168.43.158") + field(2, 40707)))
def test_version_endpoint_in_field_six_is_read_only_when_asked():
assert bs.parse_version_request(VERSION_F6)[2] is None
assert bs.parse_version_request(VERSION_F6, (6,))[2] == bs.Endpoint("192.168.43.158", 40707)
def test_start_request_is_answered_early_only_by_the_experiment():
for experiment, early in (("", False), ("start_response_first", True), ("all", True)):
boot, sent = scripted([(bs.WIFI_START_REQUEST, ENDPOINT), (bs.WIFI_INFO_RESPONSE, INFO)], experiment=experiment)
boot.run(slow_join(0, []))
assert (sent[:2] == [bs.WIFI_START_RESPONSE, bs.WIFI_INFO_REQUEST]) is early
def test_experiment_logs_frames_but_never_credentials():
events = []
boot = bs.WirelessBootstrap(None, lambda name, **v: events.append((name, v)), experiment="all")
boot.sock = Mock()
boot.send(bs.WIFI_START_RESPONSE, b"\x18\x00")
boot.queue = [(bs.WIFI_INFO_RESPONSE, INFO), (bs.WIFI_START_REQUEST, ENDPOINT)]
boot.next_frame(1)
boot.next_frame(1)
dumped = [v for name, v in events if name == "bootstrap_frame_hex"]
assert [d["message"] for d in dumped] == ["WifiStartResponse", "WifiStartRequest"]
assert INFO.hex() not in str(events)
assert bs.WirelessBootstrap(None, lambda *a, **k: events.append(a), experiment="bogus").experiment == ""
def test_a_car_that_keeps_refusing_gets_more_time_but_not_forever():
refusal = field(3, 1)
boot, sent = scripted([(bs.WIFI_START_RESPONSE, refusal)] * 3 + [(bs.WIFI_START_REQUEST, ENDPOINT), (bs.WIFI_INFO_RESPONSE, INFO)])
boot.stage_timeout = 0.2
boot.start_request_delay = 0.01
assert boot.run(slow_join(0, [])).endpoint.port == 5288
silent, _ = scripted([])
silent.stage_timeout = 0.2
idle, late = silent.next_frame, [True]
def refuse_late(timeout):
if late:
late.clear()
time.sleep(0.15) # just before the original deadline
return bs.WIFI_START_RESPONSE, refusal
return idle(timeout)
silent.next_frame = refuse_late
started = time.monotonic()
with pytest.raises(bs.BootstrapTimeout):
silent.run(slow_join(0, []))
elapsed = time.monotonic() - started
assert 0.3 < elapsed < 0.2 * bs.REFUSAL_PATIENCE + 0.5
KIA_V41 = field(1, 4) + field(2, 1) + field(3, 0) + field(4, 5745) + field(5, field(1, "KIA") + field(5, "MOBIS"))
DHU_V42 = field(1, 4) + field(2, 2) + field(3, 2) + field(5, field(1, "Google") + field(6, "Desktop Head Unit"))
FORD_V16 = field(1, 1) + field(2, 6) + field(3, 0) + field(4, 5180) + field(4, 5745) + field(5, field(1, "Ford"))
def version_reply(request, modern=True):
boot, _ = scripted([])
boot.modern_reply = modern
sent = []
boot.send = Mock(side_effect=lambda message_id, payload=b"": sent.append(payload))
boot.next_frame = Mock(side_effect=[(bs.WIFI_VERSION_REQUEST, request), bs.BootstrapError("wifi_start", "stop")])
with pytest.raises(bs.BootstrapError):
boot.run(slow_join(0, []))
return bs.parse_fields(sent[0])
def test_version_four_head_units_get_a_phone_shaped_version_reply():
kia, dhu = version_reply(KIA_V41), version_reply(DHU_V42)
assert kia[1] == [4] and kia[2] == [1] and kia[4] == [1] and kia[5] == [bs.CHANNEL_5GHZ]
assert dhu[4] == [1] and dhu[5] == [bs.CHANNEL_24GHZ]
session = bs.parse_fields(kia[6][0])
assert bs.text(session, 1) == "starpilot" and len(bs.text(session, 2)) == 32
def test_older_head_units_and_the_first_try_keep_the_reply_that_works_today():
for reply in (version_reply(FORD_V16), version_reply(KIA_V41, modern=False)):
assert reply[4] == [0] and 5 not in reply and 6 not in reply
@@ -441,3 +441,89 @@ def test_pairing_that_failed_carries_a_device_slice_but_success_does_not(tmp_pat
assert event["contexts"]["starport_pairing_system"]["bluetooth"]["lines"] == ["bt '<name>' up"]
assert calls[0][1] - calls[0][0] == 30
assert "starport_pairing_system" not in br.pairing_event({**record, "result": "paired"}, collector)["contexts"]
OFFER = {"channels": [{"id": 3, "video_configs": [
{"1": [2], "10": [3], "3": [0], "4": [240]},
{"1": [8], "10": [7], "3": [288], "4": [1378]},
{"1": [7], "10": [7], "3": [0], "4": [811]}]}], "head_unit": {"2": ["Honda"]}}
def adapter_session():
events: list = [list(item) for item in STREAMED]
events[0] = (0, 0, "session_start", {"receiver": "AAWireless", "trigger": "auto",
"setup": {"type": "adapter", "profile": "aawireless", "profile_source": "override"}})
events[5] = (0, 10, "discovered", {**OFFER, "head_unit": STREAMED[5][3]["head_unit"]})
events.insert(6, (0, 10, "bootstrap_start_response", {}))
events.insert(7, (0, 10, "bootstrap_version_reply", {"style": "modern", "channel_type": 1}))
events.insert(8, (0, 10, "bluetooth_opened", {"channel": 6, "phone_address": "39:90:21:64:07:00"}))
events.insert(9, (0, 11, "video_setup", {"mode": {"config_index": 0}, "window": 30, "used_window": 30}))
return events
def test_report_keeps_setup_offered_modes_and_the_working_strategy(tmp_path):
record = br.session_record(write_session(tmp_path, 1, adapter_session()))
assert record["setup"] == {"type": "adapter", "profile": "aawireless", "profile_source": "override"}
assert record["offered_modes"] == ["1280x720 H.264 margins 0x240", "1440x2560 H.265 margins 288x1378", "1080x1920 H.265 margins 0x811"]
assert record["phone_address_sent"] is True
assert record["strategy"] == {"profile": "aawireless", "window_offered": 30, "window_used": 30,
"reply_style": "modern", "channel_type": 1, "config_index": 0}
event = br.drive_event([record])
tags = event["tags"]
assert (tags["aa.setup_type"], tags["aa.adapter_model"], tags["aa.profile_source"], tags["aa.phone_address_sent"]) == (
"adapter", "aawireless", "override", "yes")
assert (tags["aa.reply_style"], tags["aa.wifi_channel_type"]) == ("modern", "1")
context = event["contexts"]["starport_session_1"]
assert context["offered_modes"] == record["offered_modes"] and context["strategy"]["reply_style"] == "modern"
assert "39:90:21:64:07:00" not in json.dumps(event) # the comma's own address is sanitized from every field
def test_report_records_that_the_address_was_not_sent_and_old_logs_still_report(tmp_path):
events: list = [list(item) for item in STREAMED]
events.insert(6, (0, 10, "bluetooth_opened", {"channel": 6, "phone_address": ""}))
assert br.drive_event([br.session_record(write_session(tmp_path, 1, events))])["tags"]["aa.phone_address_sent"] == "no"
old = br.session_record(write_session(tmp_path, 2, STREAMED)) # a log written before these fields existed
assert old["setup"] == {} and old["offered_modes"] == [] and old["phone_address_sent"] is None and old["strategy"] == {}
tags = br.drive_event([old])["tags"]
assert not any(key in tags for key in ("aa.setup_type", "aa.adapter_model", "aa.profile_source", "aa.phone_address_sent"))
def test_unknown_setup_values_are_dropped(tmp_path):
events: list = [list(item) for item in STREAMED]
events[0] = (0, 0, "session_start", {"setup": {"type": "<script>", "profile": "x" * 90, "profile_source": "guess"}})
setup = br.session_record(write_session(tmp_path, 1, events))["setup"]
assert "type" not in setup and "profile_source" not in setup and len(setup["profile"]) <= 40
def test_report_shows_what_the_ladder_tried_and_what_worked(tmp_path):
events: list = [list(item) for item in STREAMED]
events.insert(1, (0, 1, "ladder_strategy", {"rung": "standard", "learned": False}))
events.insert(2, (0, 5, "ladder_failed", {"outcome": "advanced", "rung": "standard", "next": "other_reply"}))
events.insert(3, (0, 6, "ladder_strategy", {"rung": "other_reply", "learned": False}))
events.append((1, 50, "ladder_worked", {"rung": "other_reply", "streamed_s": 62.0, "t_ready_s": 14.5}))
record = br.session_record(write_session(tmp_path, 1, events))
assert record["ladder"] == [{"rung": "standard", "learned": False, "at_s": 1.0, "outcome": "advanced"},
{"rung": "other_reply", "learned": False, "at_s": 6.0, "outcome": "worked", "streamed_s": 62.0, "t_ready_s": 14.5}]
tags = br.drive_event([record])["tags"]
assert (tags["aa.ladder_rung"], tags["aa.ladder_learned"]) == ("other_reply", "no")
assert br.session_record(write_session(tmp_path, 2, STREAMED))["ladder"] == [] # older logs report nothing, without error
def test_report_says_how_the_rfcomm_channel_was_found(tmp_path):
events: list = [list(item) for item in STREAMED]
events.insert(1, (0, 1, "sdp_retry", {"error": "x"}))
events.insert(2, (0, 2, "rfcomm_channel", {"channel": 8, "source": "sdp"}))
assert br.session_record(write_session(tmp_path, 1, events))["sdp"] == {"channel": 8, "source": "sdp", "retries": 1}
assert br.session_record(write_session(tmp_path, 2, STREAMED))["sdp"] == {}
def test_trace_keeps_how_the_session_started_as_well_as_how_it_ended(tmp_path):
noisy = [(0, 0, "session_start", {}), (0, 1, "hfp_initiator", {"initiator": "car"})]
noisy += [(i // 60, i % 60, "sdp_retry", {"error": "x" * 250, "n": i}) for i in range(2, 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"]["starport_trace_session_1"]
names = [e["event"] for e in trace["events"]]
assert names[:2] == ["session_start", "hfp_initiator"] and names[-2:] == ["attempt_failed", "session_stop"]
assert trace["omitted"] > 0 and 0 < trace["head"] < len(names)
assert len(json.dumps(trace)) <= br.TRACE_SESSION_BYTES + 1024
@@ -0,0 +1,121 @@
"""A car that is the only paired Android Auto device is chosen by housekeeping, without touching anything the owner set up."""
import pytest
from openpilot.starpilot.system.starport import identity as identity_store
from openpilot.starpilot.system.starport.tests.fake_head_unit import make_identity
from openpilot.starpilot.system.starport.tests.test_starport import make_supervisor
CAR, OTHER = "AA:BB:CC:DD:EE:07", "AA:BB:CC:DD:EE:08"
@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):
supervisor, _ = make_supervisor(identity, tmp_path, monkeypatch, lambda *a, **k: None)
with supervisor._lock:
supervisor.config.update(receiver_address="", receiver_name="", car_type="builtin")
class Recorder:
recent = ()
def __init__(self):
self.events = []
def __call__(self, name, **values):
self.events.append((name, values))
supervisor.log = Recorder()
supervisor.events = supervisor.log.events
supervisor._projection_enabled = lambda: True
supervisor.baseline = supervisor._phone().acquired # make_supervisor chose a car once already
return supervisor
def names(sup):
return [name for name, _ in sup.events]
def test_the_only_candidate_is_chosen_trusted_and_the_projection_role_given_back(sup):
bluez = sup._phone()
bluez.paired_devices = [(CAR, "Civic", True), (OTHER, "Headphones", False)]
sup._adopt_only_car(1000.0)
assert sup.config["receiver_address"] == CAR and sup.config["receiver_name"] == "Civic"
assert identity_store.load_config()["receiver_address"] == CAR
# the phone look is given back; the standby gateway stays
assert bluez.trusted[-1] == CAR and bluez.acquired == sup.baseline + 1 and bluez.class_restored >= 1
assert "car_adopted" in names(sup) and sup._candidates == []
def test_two_candidates_are_never_guessed_and_nothing_is_acquired(sup):
bluez = sup._phone()
bluez.paired_devices = [(CAR, "Civic", True), (OTHER, "Wagon", True)]
for tick in range(5):
sup._adopt_only_car(1000.0 + 25 * tick)
assert sup.config["receiver_address"] == "" and bluez.acquired == sup.baseline
assert [car["address"] for car in sup.status()["car_candidates"]] == [CAR, OTHER]
assert names(sup).count("car_adopt_ambiguous") == 1 # said once, not every 20 s
def test_the_car_used_as_bluetooth_audio_is_left_to_the_owner(sup):
sup._bt_client = type("C", (), {"status": lambda s: type("St", (), {"selected_audio": CAR.lower()})()})()
sup._phone().paired_devices = [(CAR, "Civic", True)]
for tick in range(3):
sup._adopt_only_car(1000.0 + 25 * tick)
assert sup.config["receiver_address"] == "" and sup._phone().acquired == sup.baseline
assert names(sup).count("car_adopt_skipped") == 1
assert [car["address"] for car in sup.status()["car_candidates"]] == [CAR] # still offered through the setup screen's Use button
def test_when_the_audio_selection_cannot_be_read_the_owners_setup_is_left_alone(sup):
def broken():
raise RuntimeError("bluetooth service down")
sup._bt_client = type("C", (), {"status": lambda s: broken()})()
sup._phone().paired_devices = [(CAR, "Civic", True)]
sup._adopt_only_car(1000.0)
assert sup.config["receiver_address"] == ""
@pytest.mark.parametrize("change", [
{"receiver_address": "AA:00:00:00:00:99"}, {"connection": "wired"}, {"car_type": "adapter"},
])
def test_it_does_nothing_once_a_car_is_chosen_or_for_wired_and_adapter_setups(sup, change):
with sup._lock:
sup.config.update(change)
sup._phone().paired_devices = [(CAR, "Civic", True)]
sup._adopt_only_car(1000.0)
assert sup.config["receiver_address"] in ("", "AA:00:00:00:00:99") and sup._phone().acquired == sup.baseline
def test_it_does_nothing_while_a_session_runs_projection_is_off_or_it_ran_a_moment_ago(sup):
sup._phone().paired_devices = [(CAR, "Civic", True)]
sup._projection_enabled = lambda: False
sup._adopt_only_car(1000.0)
assert sup.config["receiver_address"] == ""
sup._projection_enabled = lambda: True
sup._adopt_only_car(2000.0)
assert sup.config["receiver_address"] == CAR
sup.config["receiver_address"] = ""
sup._adopt_only_car(2005.0) # inside the 20 s interval
assert sup.config["receiver_address"] == ""
def test_an_unreadable_bluetooth_service_is_reported_once(sup):
def broken():
raise RuntimeError("no shared owner")
sup._phone = broken
for tick in range(4):
sup._adopt_only_car(1000.0 + 25 * tick)
assert names(sup).count("car_adopt_unavailable") == 1
def test_a_bluetooth_error_while_choosing_the_car_is_reported_not_raised(sup):
sup._phone().paired_devices = [(CAR, "Civic", True)]
def rejected(address, name=""):
raise Exception("Bluetooth rejected")
sup.select_receiver = rejected
sup._adopt_only_car(1000.0)
assert "car_adopt_failed" in names(sup) and sup.config["receiver_address"] == ""
@@ -2,7 +2,7 @@ import errno
import unittest
from openpilot.starpilot.system.starport.bootstrap import BootstrapTimeout
from openpilot.starpilot.system.starport.supervisor import car_never_answered
from openpilot.starpilot.system.starport.supervisor import car_never_answered, drops_cached_channel
class TestCarNeverAnswered(unittest.TestCase):
@@ -14,3 +14,38 @@ class TestCarNeverAnswered(unittest.TestCase):
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))
class TestDropsCachedChannel(unittest.TestCase):
def test_a_sleeping_car_keeps_its_channel(self):
# The message is what the kernel prints on the device (the errno number differs between macOS and Linux).
self.assertFalse(drops_cached_channel('rfcomm', OSError(errno.EHOSTDOWN, 'Host is down')))
self.assertFalse(drops_cached_channel('rfcomm', OSError(112, 'Host is down')))
def test_a_car_that_refuses_or_resets_loses_it(self):
for error in (ConnectionRefusedError(errno.ECONNREFUSED, 'Connection refused'), ConnectionResetError(errno.ECONNRESET, 'reset'),
BootstrapTimeout('wifi_start', 'head unit did not answer in time')):
self.assertTrue(drops_cached_channel('rfcomm' if not isinstance(error, BootstrapTimeout) else 'wifi_start', error))
def test_other_stages_and_local_failures_never_drop_it(self):
self.assertFalse(drops_cached_channel('authenticating', ConnectionResetError(errno.ECONNRESET, 'reset')))
self.assertFalse(drops_cached_channel('rfcomm', OSError(errno.ENOSYS, 'local')))
self.assertFalse(drops_cached_channel('rfcomm', OSError(errno.EBUSY, 'Device or resource busy')))
class TestChooseOnlyCar(unittest.TestCase):
def car(self, address, paired=True, starport=True):
return {"address": address, "name": f"car {address}", "paired": paired, "starport": starport}
def test_exactly_one_paired_android_auto_device_is_adopted(self):
from openpilot.starpilot.system.starport.supervisor import choose_only_car
only, candidates = choose_only_car([self.car("A"), self.car("B", starport=False), self.car("C", paired=False)])
assert only is not None
self.assertEqual((only["address"], [c["address"] for c in candidates]), ("A", ["A"]))
def test_two_candidates_or_none_are_never_guessed(self):
from openpilot.starpilot.system.starport.supervisor import choose_only_car
self.assertEqual(choose_only_car([self.car("A"), self.car("B")])[0], None)
self.assertEqual([c["address"] for c in choose_only_car([self.car("A"), self.car("B")])[1]], ["A", "B"])
self.assertEqual(choose_only_car([self.car("A", starport=False)]), (None, []))
self.assertEqual(choose_only_car([]), (None, []))
@@ -39,7 +39,8 @@ def config(**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.tuning(chosen) == {"connect_delay_s": 0.0, "max_window": 30, "phone_services": True, "phone_frames": True,
"bootstrap_experiments": False, "stay_while_projecting": True}
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
@@ -221,3 +222,10 @@ 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"] == ""
def test_unofficial_dongle_turns_on_the_handshake_experiments_only_when_chosen():
assert car_profiles.valid_override("unofficial")
assert car_profiles.tuning(config(car_profile_override="unofficial"))["bootstrap_experiments"] is True
for other in (config(), config(car_profile_override="default"), config(car_profile_override="aawireless")):
assert car_profiles.tuning(other)["bootstrap_experiments"] is False
@@ -295,6 +295,9 @@ class CompanionTests(unittest.TestCase):
host.stop.assert_called_once_with()
self.owner.request.assert_called_once_with('forget', address=ADAPTER.lower())
host.forget_receiver.assert_called_once_with(ADAPTER.lower())
host.forget_receiver.side_effect = OSError('Cannot save config')
with self.assertRaisesRegex(OSError, 'Cannot save config'):
host.companion_bluetooth_action('forget', ADAPTER, SESSION)
def test_forgetting_the_selected_car_waits_out_pairing_and_expired_sessions(self):
from openpilot.starpilot.system.starport.supervisor import Supervisor
@@ -231,6 +231,23 @@ def test_bundle_has_logs_reports_and_settings_without_addresses(tmp_path):
assert "AA:BB:CC:DD:EE:FF" not in shared and json.loads(shared)["rfcomm_cache"] == [5]
assert "projected, then failed" in archive.read("REPORT.txt").decode()
assert not any(name.endswith((".pem", ".key")) for name in names)
# The bundle (and so the Discord send) carries what a Bugsink report carries: tags, and per-session detail.
report = json.loads(archive.read("report.json"))
assert report["tags"]["aa.report"] == "drive" and "aa.head_unit" in report["tags"]
assert report["sessions"][0]["offered_modes"] is not None and "setup" in report["sessions"][0]
assert "AA:BB:CC:DD:EE:FF" not in archive.read("report.json").decode()
def test_bundle_still_builds_when_the_report_cannot(tmp_path, monkeypatch):
from openpilot.starpilot.system.starport import bugsink_report
logs = tmp_path / "logs"
logs.mkdir()
write_log(logs, "session-000025-20260928-141351.jsonl", CIVIC_SESSION)
monkeypatch.setattr(bugsink_report, "session_record", lambda path: (_ for _ in ()).throw(RuntimeError("broken")))
config = tmp_path / "config.json"
config.write_text("{}")
with zipfile.ZipFile(io.BytesIO(compat_report.bundle(logs, config))) as archive:
assert "REPORT.txt" in archive.namelist() and "report.json" not in archive.namelist()
def test_report_tool_prints_a_log(tmp_path, monkeypatch, capsys):
@@ -67,3 +67,38 @@ def test_forget_car_edits_the_saved_config_when_the_daemon_is_not_running(tmp_pa
assert saved()["companion_address"] == "" and saved()["receiver_address"] == ADAPTER
assert protocol.forget_car(ADAPTER.lower(), client) is True
assert saved()["receiver_address"] == "" and saved()["rfcomm_cache"] == {}
def test_the_version_reply_a_car_started_with_is_kept_and_forgotten_with_it(sup):
sup._remember_reply(ADAPTER, True)
sup._remember_reply(OTHER, True)
assert saved()["modern_reply_cache"] == {ADAPTER: True, OTHER: True}
sup._remember_reply(OTHER, False)
assert saved()["modern_reply_cache"] == {ADAPTER: True}
daemon.handle(sup, {"command": "forget_receiver", "address": ADAPTER})
assert saved()["modern_reply_cache"] == {} and ADAPTER not in sup._modern_reply
def test_a_car_known_only_to_the_setup_ladder_is_still_forgotten(sup):
sup._ladder.strategy(OTHER, False)
sup._ladder.failed(OTHER, sup._ladder.strategy(OTHER, False), ladder_failure=True, video_seen=False, version_major=4, adapter=False)
assert OTHER.upper() in sup._ladder.cars
daemon.handle(sup, {"command": "forget_receiver", "address": OTHER})
assert OTHER.upper() not in sup._ladder.cars
def test_offline_forget_clears_reply_strategy_and_fingerprint(sup):
sup._remember_reply(ADAPTER, True)
sup._remember_reply(OTHER, True)
sup._ladder.worked(ADAPTER, sup._ladder.strategy(ADAPTER, True))
sup._ladder.worked(OTHER, sup._ladder.strategy(OTHER, True))
sup.config["car_fingerprint"] = {"head_unit_make": "Old car"}
identity_store.save_config(sup.config)
client = protocol.StarPortClient(socket_path=str(identity_store.CONFIG_PATH.with_name("missing.sock")))
assert protocol.forget_car(ADAPTER, client) is True
assert saved()["modern_reply_cache"] == {OTHER: True} and saved()["car_fingerprint"] == {}
from openpilot.starpilot.system.starport.setup_ladder import SetupLadder
ladder = SetupLadder(sup._ladder.path)
assert ADAPTER not in ladder.cars and OTHER in ladder.cars
assert protocol.forget_car(OTHER, client) is False
assert saved()["modern_reply_cache"] == {} and not SetupLadder(sup._ladder.path).cars
@@ -0,0 +1,113 @@
"""H.265 is an opt-in for a car that offers nothing else; every car with an H.264 mode is chosen exactly as before."""
import itertools
import pytest
from openpilot.starpilot.system.starport import hw_encoder
from openpilot.starpilot.system.starport.hw_encoder import HEVC_AUD, normalize_access_unit_hevc
from openpilot.starpilot.system.starport.session import (CODEC_H264_BP, CODEC_H265, START_CODE, VideoMode, choose_video_mode,
codec_config, codec_config_hevc, wire_frame, wire_frame_hevc)
SC = b"\x00\x00\x00\x01"
def config(resolution, codec, margin_w=0, margin_h=0, fps=2):
return {1: [resolution], 2: [fps], 3: [margin_w], 4: [margin_h], 10: [codec]}
def channels(*configs):
return [{"id": 3, "video_configs": list(configs), "display_type": 0}]
# What Bugsink showed one "AndroidAuto" unit offering: every mode H.265, none H.264 (enums 8, 7, 6 are portrait).
H265_ONLY = channels(config(8, 7, 288, 1378), config(7, 7, 0, 811), config(6, 7, 0, 541), config(3, 7, 0, 360))
H264_OFFERS = [
channels(config(2, 3, 0, 240)),
channels(config(1, 3), config(2, 3, 0, 240), config(3, 3, 0, 360)),
channels(config(3, 3, 0, 360), config(2, 3, 0, 240, fps=1)),
channels(config(1, 3)),
]
def test_without_the_setting_an_h265_only_car_is_refused_with_a_hint():
with pytest.raises(ValueError, match="supported H.264 video mode.*Developer > StarPort"):
choose_video_mode(H265_ONLY)
with pytest.raises(ValueError, match="supported H.264 video mode.*Developer > StarPort"):
choose_video_mode(H265_ONLY, False)
def test_with_the_setting_an_h265_only_car_gets_the_1080p_h265_mode_inside_the_known_resolutions():
mode = choose_video_mode(H265_ONLY, True)
assert (mode.codec, mode.width, mode.height, mode.margin_height) == (CODEC_H265, 1920, 1080, 360)
assert mode.as_dict()["codec"] == "H.265"
@pytest.mark.parametrize("offer", H264_OFFERS)
def test_a_car_with_an_h264_mode_is_chosen_identically_whatever_the_setting(offer):
without, with_setting = choose_video_mode(offer), choose_video_mode(offer, True)
assert without == with_setting and without.codec == CODEC_H264_BP
assert "codec" not in without.as_dict() # H.264 reports and screen files are unchanged
def test_mixed_offers_never_prefer_h265_over_h264():
for h264, h265 in itertools.product(H264_OFFERS, [H265_ONLY, channels(config(2, 7, 0, 240))]):
mixed = [{"id": 3, "video_configs": h264[0]["video_configs"] + h265[0]["video_configs"]}]
assert choose_video_mode(mixed, True) == choose_video_mode(h264)
def test_h265_prefers_the_smaller_known_resolution_like_h264():
mode = choose_video_mode(channels(config(3, 7, 0, 360), config(2, 7, 0, 240)), True)
assert (mode.codec, mode.width, mode.height) == (CODEC_H265, 1280, 720)
def au(*kinds):
"""An Annex B H.265 access unit with the given NAL types."""
return b"".join(SC + bytes([kind << 1, 1]) + b"\x42" * 6 for kind in kinds)
def test_hevc_codec_config_and_wire_frame_use_the_two_byte_header():
frame = au(35, 32, 33, 34, 19)
assert START_CODE.findall(codec_config_hevc(frame)) == [SC] * 3
assert b"".join(bytes([kind << 1, 1]) + b"\x42" * 6 for kind in (32, 33, 34)) == codec_config_hevc(frame).replace(SC, b"")
assert wire_frame_hevc(frame, drop_config=True) == SC + bytes([19 << 1, 1]) + b"\x42" * 6
assert wire_frame_hevc(frame, drop_config=False) == au(32, 33, 34, 19) # the AUD is always dropped
assert codec_config_hevc(au(35, 1)) == b""
def test_h264_functions_are_unchanged_by_the_h265_ones():
h264 = SC + b"\x09\xf0" + SC + b"\x67" + b"\x01" * 5 + SC + b"\x68" + b"\x02" * 3 + SC + b"\x65" + b"\x03" * 9
assert codec_config(h264) == SC + b"\x67" + b"\x01" * 5 + SC + b"\x68" + b"\x02" * 3
assert wire_frame(h264, drop_config=True) == SC + b"\x65" + b"\x03" * 9
def test_hevc_access_units_are_checked_like_the_h264_ones():
keyframe = normalize_access_unit_hevc(au(32, 33, 34, 20), keyframe=True)
assert keyframe.startswith(HEVC_AUD) and keyframe.count(SC) == 5
assert normalize_access_unit_hevc(au(35, 1), keyframe=False).startswith(HEVC_AUD)
# The comma's encoder emits two slices per picture; both belong to the same picture.
assert normalize_access_unit_hevc(au(32, 33, 34, 19, 19), keyframe=True).count(SC) == 6
assert normalize_access_unit_hevc(au(1, 1), keyframe=False).count(SC) == 3
for bad, key in ((au(32, 33, 34), True), (au(35), False), (au(33, 34, 19), True), (au(32, 33, 34, 1), True), (au(19, 1), False)):
with pytest.raises(RuntimeError):
normalize_access_unit_hevc(bad, keyframe=key)
with pytest.raises(RuntimeError):
normalize_access_unit_hevc(b"not annex b", keyframe=False)
def test_the_hevc_encoder_class_only_changes_what_h265_needs():
h264, hevc = hw_encoder.HardwareH264Encoder, hw_encoder.HardwareHevcEncoder
assert (h264.max_width, h264.max_height, h264.codec_options, h264.required_abi) == (1280, 720, 0, 2)
assert (hevc.max_width, hevc.max_height, hevc.codec_options, hevc.required_abi) == (1920, 1080, hw_encoder.OPTION_HEVC, 4)
assert hw_encoder.OPTION_HEVC & hw_encoder.OPTION_CBR == 0
def test_h265_never_falls_back_to_another_codec(monkeypatch, tmp_path):
monkeypatch.setattr(hw_encoder, "LIBRARY", tmp_path / "missing.so")
events = []
with pytest.raises(RuntimeError, match="needs H.265 and the hardware encoder could not start it"):
hw_encoder.create_hevc_encoder(1920, 1080, bitrate_kbps=6000, margin_height=360, log=lambda *a, **k: events.append(a))
assert events == [] # nothing was announced as started
def test_video_mode_defaults_to_h264():
assert VideoMode(3, 0, 1280, 720, 30, 0, 240).codec == CODEC_H264_BP
@@ -28,3 +28,11 @@ def test_existing_identity_survives_directory_migration(tmp_path, monkeypatch, o
assert directory == (legacy if outcome == 'denied' else current)
assert (directory / 'certificate').read_bytes() == b'existing identity'
def test_remembered_version_replies_load_only_as_true_per_address(tmp_path):
path = tmp_path / "config.json"
path.write_text('{"config_version": 7, "modern_reply_cache": {"aa:bb:cc:dd:ee:01": true, "AA:BB:CC:DD:EE:02": false, "x": 1}}')
assert identity.load_config(path)["modern_reply_cache"] == {"AA:BB:CC:DD:EE:01": True}
path.write_text('{"config_version": 7}')
assert identity.load_config(path)["modern_reply_cache"] == {}
@@ -0,0 +1,45 @@
"""A shared Wi-Fi interface that NetworkManager has not taken yet is waited for, then reported as unmanaged."""
import pytest
from openpilot.starpilot.system.starport import network
from openpilot.starpilot.system.starport.network import NetworkError, NetworkLease
class FakeLease(NetworkLease):
def __init__(self, states):
super().__init__(lambda *a, **k: self.events.append((a, k)), "wlan0")
self.states, self.events, self.device_path = list(states), [], "/dev/0"
def _get(self, path, interface, name):
assert name == "State"
return self.states.pop(0) if len(self.states) > 1 else self.states[0]
@pytest.fixture(autouse=True)
def no_sleep(monkeypatch):
monkeypatch.setattr(network.time, "sleep", lambda seconds: None)
def test_a_device_that_becomes_managed_is_joined_normally():
lease = FakeLease([10, 10, 20, 30]) # unmanaged, unmanaged, unavailable, disconnected
lease._wait_managed(lambda: False)
assert [event[0][0] for event in lease.events] == ["wifi_waiting_managed", "wifi_became_managed"]
def test_a_managed_device_does_not_wait_or_log():
lease = FakeLease([30])
lease._wait_managed(lambda: False)
assert lease.events == []
def test_a_device_that_stays_unmanaged_says_so(monkeypatch):
ticks = iter(range(0, 1000, 3))
monkeypatch.setattr(network.time, "monotonic", lambda: next(ticks))
lease = FakeLease([10])
with pytest.raises(NetworkError, match="unmanaged by NetworkManager"):
lease._wait_managed(lambda: False, timeout=8.0)
def test_cancelling_stops_the_wait():
with pytest.raises(NetworkError, match="cancelled"):
FakeLease([10])._wait_managed(lambda: True)
@@ -401,6 +401,40 @@ class TestProjectionOnroad(unittest.TestCase):
self.assertEqual(shown, [notice, notice, 'Lane Departure Detected', '', notice])
view.close()
def test_reconnect_notice_shows_eight_seconds_under_real_alerts_and_before_the_certificate(self):
native, _ = self.dependencies()
@dataclass(frozen=True)
class Alert:
size: str = 'none'
text1: str = ''
alert_type: str = ''
real = {'alert': Alert()}
native.alert, native.alert_size = Alert, SimpleNamespace(NONE='none', SMALL='small')
native.adapter = lambda *_: SimpleNamespace(build=lambda *_a, **_k: SimpleNamespace(
onroad=OnroadState(False, False, None, None, SpeedLimitObservation(), alert=cast(Any, real['alert']))))
reconnected = 'Reconnected: comma Wi-Fi Joined Home'
view = projection.ProjectionOnroad(dependencies=native, certificate_days=3, reconnect_notice=reconnected)
shown = []
view.onroad.render = lambda state: shown.append(state.alert.text1)
clock = [0]
original = projection.time
projection.time = SimpleNamespace(monotonic_ns=lambda: clock[0])
try:
native.ui_state.started = True
for clock[0] in (100, 7_000_000_000):
view.render()
real['alert'] = Alert('mid', 'Lane Departure Detected')
view.render() # a real alert always wins
real['alert'] = Alert()
clock[0] = 8_000_000_200
view.render() # past eight seconds: the certificate heads-up gets its remaining time
finally:
projection.time = original
self.assertEqual(shown, [reconnected, reconnected, 'Lane Departure Detected', 'Heads Up: AA Certificate Expires in 3 Days'])
view.close()
def test_no_certificate_notice_when_far_from_expiry(self):
native, _ = self.dependencies()
view = projection.ProjectionOnroad(dependencies=native, certificate_days=200)
@@ -0,0 +1,90 @@
"""Failures before video are told apart by why the car could not be reached, using the texts Bugsink actually showed."""
import pytest
from openpilot.starpilot.system.starport import bugsink_report as br, reachability as r
from openpilot.starpilot.system.starport.tests.test_bugsink_report import write_session
CASES = [
("discovering", "RuntimeError", "Could not find the car's Android Auto service: [Errno 112] Host is down", r.ASLEEP),
("connecting_bluetooth", "RuntimeError", "org.bluez.Error.Failed: br-connection-page-timeout", r.ASLEEP),
("connecting_bluetooth", "TimeoutError", "", r.ASLEEP),
("rfcomm", "OSError", "starting wireless setup: [Errno 112] Host is down", r.ASLEEP),
("rfcomm", "ConnectionRefusedError", "[Errno 111] Connection refused", r.NOT_READY),
("rfcomm", "ConnectionResetError", "starting wireless setup: [Errno 104] Connection reset by peer", r.NOT_READY),
("discovering", "RuntimeError", "Head unit does not advertise Android Auto Wireless (SDP record missing)", r.NOT_READY),
("wifi_start", "BootstrapTimeout", "waiting for car: wifi_start: head unit did not answer in time", r.NOT_READY),
("rfcomm", "OSError", "[Errno 16] Device or resource busy", r.LOCAL),
("connecting_bluetooth", "BluetoothRejected", "pairing rejected", r.LOCAL),
("streaming", "TimeoutError", "projecting: Video acknowledgement older than 2.5 s", r.OTHER), # after the link is up
("authenticating", "ConnectionResetError", "Connection reset by peer", r.OTHER),
("joining_wifi", "BootstrapError", "joining_wifi: ", r.OTHER),
]
@pytest.mark.parametrize("stage,kind,message,expected", CASES)
def test_classify_known_bugsink_texts(stage, kind, message, expected):
assert r.classify(stage, kind, message) == expected
def test_waits_are_never_shorter_than_the_standard_backoff_and_other_failures_are_unchanged():
standard = (2.0, 4.0, 8.0, 15.0, 30.0)
for attempt, base in enumerate(standard):
assert r.retry_delay(r.OTHER, attempt, base) == base and r.retry_delay(r.NOT_READY, attempt, base) == base
for reach in (r.ASLEEP, r.LOCAL):
assert r.retry_delay(reach, attempt, base) >= base
assert r.retry_delay(r.ASLEEP, 0, 2.0) == 6.0 and r.retry_delay(r.ASLEEP, 99, 30.0) == 30.0
def test_a_sleeping_car_keeps_its_cached_channel_but_a_refusing_one_does_not():
assert r.keeps_cached_channel(r.ASLEEP) and r.keeps_cached_channel(r.LOCAL)
assert not r.keeps_cached_channel(r.NOT_READY) and not r.keeps_cached_channel(r.OTHER)
def test_report_names_a_sleeping_car_instead_of_unknown(tmp_path):
events = [(0, 0, "session_start", {"receiver": "Car"}), (0, 1, "stage", {"state": "discovering"}),
(0, 9, "attempt_failed", {"stage": "discovering", "kind": "RuntimeError",
"error": "finding android auto: Could not find the car's Android Auto service: [Errno 112] Host is down"})]
record = br.session_record(write_session(tmp_path, 1, events))
assert record["reach"] == "asleep" # derived from the text for logs written before the supervisor recorded it
event = br.drive_event([record])
assert event["tags"]["aa.cause"] == "car_asleep" and event["tags"]["aa.reach_class"] == "asleep"
recorded = [(0, 0, "session_start", {}), (0, 9, "attempt_failed", {"stage": "rfcomm", "kind": "ConnectionRefusedError",
"error": "refused", "reach": "not_ready"})]
assert br.session_record(write_session(tmp_path, 2, recorded))["reach"] == "not_ready"
def test_stream_losses_are_told_apart_by_what_the_log_says(tmp_path):
from openpilot.starpilot.system.starport.bugsink_diagnosis import stream_loss_kind
assert stream_loss_kind("projecting: Video acknowledgement older than 2.5 s") == "stalled"
assert stream_loss_kind("projecting: Head unit stopped answering for 6 s") == "silent"
assert stream_loss_kind("projecting: Head unit disconnected after 4/8 bytes") == "disconnected"
assert stream_loss_kind("something else") == "other"
for text, kind in (("Video acknowledgement older than 2.5 s", "stalled"), ("Head unit disconnected after 4/8 bytes", "disconnected")):
events = [(0, 0, "session_start", {}), (0, 1, "stage", {"state": "streaming"}),
(0, 9, "attempt_failed", {"stage": "streaming", "kind": "EOFError", "error": text})]
event = br.drive_event([br.session_record(write_session(tmp_path, 3 if kind == "stalled" else 4, events))])
assert event["tags"]["aa.cause"] == "projection_interrupted" and event["tags"]["aa.loss_kind"] == kind
def test_every_real_bugsink_failure_text_is_classified_as_intended():
import json
from pathlib import Path
rows = json.loads((Path(__file__).parent / "fixtures" / "bugsink_failure_texts.json").read_text())["failures"]
assert sum(row["count"] for row in rows) > 600 and len(rows) > 40
seen = {r.ASLEEP: 0, r.NOT_READY: 0, r.LOCAL: 0, r.OTHER: 0}
for row in rows:
got = r.classify(row["stage"], row["kind"], row["evidence"])
text = row["evidence"].lower()
seen[got] += row["count"]
if row["stage"] in ("streaming", "authenticating", "negotiating", "connecting_tcp", "joining_wifi"):
assert got == r.OTHER, row # once the link is up, none of this is about reaching the car
continue
if "host is down" in text or "page-timeout" in text:
assert got == r.ASLEEP, row
if "connection refused" in text or "did not answer in time" in text:
assert got == r.NOT_READY, row
if "galaxy bluetooth first" in text:
assert got == r.OTHER, row # a setup step to take, not a reachability problem
early = seen[r.ASLEEP] + seen[r.NOT_READY] + seen[r.LOCAL]
assert early > 0.6 * sum(seen.values()), seen # most failures before video are now explained instead of "unknown"
@@ -0,0 +1,272 @@
"""The setup ladder moves only when a car that answered the handshake refuses it before any video; nothing else moves it."""
import json
import pytest
from openpilot.starpilot.system.starport import setup_ladder as sl
from openpilot.starpilot.system.starport.bootstrap import CHANNEL_24GHZ, CHANNEL_DUAL, REFUSAL_PATIENCE
CAR = "AA:BB:CC:DD:EE:FF"
@pytest.fixture
def ladder(tmp_path):
state = {"boot": "boot-1"}
instance = sl.SetupLadder(tmp_path / "setup_state.json", boot_id=lambda: state["boot"], clock=lambda: 1000.0)
instance.state = state
return instance
def refuse(ladder, strategy, **overrides):
values = {"ladder_failure": True, "video_seen": False, "version_major": 4, "adapter": False}
values.update(overrides)
return ladder.failed(CAR, strategy, **values)
def test_the_first_strategy_is_exactly_what_ran_before(ladder):
for base in (False, True):
strategy = ladder.strategy(CAR, base)
assert (strategy.rung, strategy.modern_reply, strategy.channel_type, strategy.patience, strategy.experiments) == (
"standard", base, None, REFUSAL_PATIENCE, False)
@pytest.mark.parametrize("name,overrides", [
("a car that never answered the handshake (asleep, refused, local trouble)", {"ladder_failure": False}),
("anything after video was acknowledged in the session (car-off teardown)", {"video_seen": True}),
])
def test_failures_that_say_nothing_about_the_handshake_never_move_the_ladder(ladder, name, overrides):
strategy = ladder.strategy(CAR, False)
for _ in range(50):
assert refuse(ladder, strategy, **overrides) is None
assert ladder.strategy(CAR, False).rung == "standard" and not ladder.exhausted(CAR)
assert not (ladder.path.exists() and json.loads(ladder.path.read_text())["cars"]) # nothing was even recorded
def test_a_silent_car_walks_the_rungs_in_order_and_each_is_a_knob_that_already_existed(ladder):
seen = []
for step in range(5):
strategy = ladder.strategy(CAR, False)
seen.append(strategy)
assert refuse(ladder, strategy) == ("advanced" if step < 4 else "wrapped") # the last rung ends the pass
assert [s.rung for s in seen] == ["standard", "other_reply", "modern_dual", "modern_24ghz", "patient"]
assert [s.modern_reply for s in seen] == [False, True, True, True, False]
assert [s.channel_type for s in seen] == [None, None, CHANNEL_DUAL, CHANNEL_24GHZ, None]
assert seen[4].patience == sl.LONG_PATIENCE and all(not s.experiments for s in seen)
def test_the_other_reply_is_relative_to_what_the_car_was_using(ladder):
assert ladder.strategy(CAR, True).modern_reply is True
refuse(ladder, ladder.strategy(CAR, True))
assert ladder.strategy(CAR, True).modern_reply is False
def test_a_car_older_than_protocol_four_skips_the_phone_style_rungs(ladder):
refuse(ladder, ladder.strategy(CAR, False), version_major=3)
assert ladder.strategy(CAR, False).rung == "patient"
assert ladder.snapshot(CAR)["steps"][1]["state"] == "skipped"
def test_the_adapter_experiments_run_last_and_only_for_adapters(ladder):
for _ in range(5):
refuse(ladder, ladder.strategy(CAR, False), adapter=True)
strategy = ladder.strategy(CAR, False)
assert strategy.rung == "experiments" and strategy.experiments
assert refuse(ladder, strategy, adapter=True) == "wrapped"
other = sl.SetupLadder(ladder.path.with_name("other.json"), boot_id=lambda: "b")
for _ in range(5):
refuse(other, other.strategy(CAR, False))
assert other.strategy(CAR, False).rung == "standard" # no adapter, no experiments rung: back to the start
assert "experiments" not in [step["id"] for step in other.snapshot(CAR)["steps"]]
def test_three_passes_per_boot_then_it_stops_trying_new_things(ladder):
results = []
for _ in range(5 * sl.MAX_PASSES):
results.append(refuse(ladder, ladder.strategy(CAR, False)))
assert results.count("exhausted") == 1 and ladder.exhausted(CAR)
assert ladder.strategy(CAR, False).rung == "standard" # settles on today's behavior
assert refuse(ladder, ladder.strategy(CAR, False)) is None
assert ladder.snapshot(CAR)["exhausted"]
def test_the_next_ignition_gets_its_passes_back_and_resumes_at_the_first_untried_rung(ladder):
for _ in range(2):
refuse(ladder, ladder.strategy(CAR, False))
ladder.state["boot"] = "boot-2"
assert ladder.strategy(CAR, False).rung == "modern_dual"
assert ladder.snapshot(CAR)["passes"] == 0
for _ in range(5 * sl.MAX_PASSES):
refuse(ladder, ladder.strategy(CAR, False))
assert ladder.exhausted(CAR)
ladder.state["boot"] = "boot-3"
assert not ladder.exhausted(CAR) and ladder.snapshot(CAR)["passes"] == 0
def test_what_worked_is_used_first_next_time_even_for_a_different_remembered_reply(ladder):
refuse(ladder, ladder.strategy(CAR, False))
worked = ladder.strategy(CAR, False)
assert worked.rung == "other_reply"
ladder.worked(CAR, worked, t_ready_s=18.4)
# The reply style is now remembered as the phone-style one; "the other reply" must not flip back to standard.
again = ladder.strategy(CAR, True)
assert (again.rung, again.modern_reply) == ("other_reply", True)
snapshot = ladder.snapshot(CAR)
assert snapshot["learned"] and snapshot["t_ready_s"] == 18.4 and snapshot["successes"] == 1
assert [step["state"] for step in snapshot["steps"]][1] == "worked"
def test_a_learned_strategy_is_forgotten_only_after_two_handshake_failures(ladder):
ladder.worked(CAR, sl.resolve("modern_dual", False))
learned = ladder.strategy(CAR, False)
assert refuse(ladder, learned) == "counted" and ladder.strategy(CAR, False).rung == "modern_dual"
assert refuse(ladder, learned, ladder_failure=False) is None and refuse(ladder, learned, video_seen=True) is None
assert refuse(ladder, learned) == "dropped"
# Not back to the start: the strategy that just failed twice is not tried first, the next rung is.
assert ladder.strategy(CAR, False).rung == "modern_24ghz" and not ladder.snapshot(CAR)["learned"]
assert ladder.snapshot(CAR)["steps"][2]["state"] == "failed"
def test_state_survives_a_restart_and_a_damaged_file_means_a_fresh_start(tmp_path):
path = tmp_path / "setup_state.json"
first = sl.SetupLadder(path, boot_id=lambda: "b")
refuse(first, first.strategy(CAR, False))
first.worked(CAR, sl.resolve("patient", False), 12.0)
second = sl.SetupLadder(path, boot_id=lambda: "b")
assert second.strategy(CAR.lower(), False).rung == "patient" and path.stat().st_mode & 0o777 == 0o600
for damage in ("not json", json.dumps({"version": 99, "cars": {}}), json.dumps({"version": 1, "cars": {CAR: {"working": {"rung": "x"}}}}),
json.dumps({"version": 1, "cars": [1]}), ""):
path.write_text(damage)
assert sl.SetupLadder(path, boot_id=lambda: "b").strategy(CAR, False).rung == "standard"
def test_a_read_only_disk_never_stops_a_connection(ladder):
ladder.path = ladder.path / "blocked" / "x" # parent is a missing path under a file below
ladder.path.parent.parent.parent.mkdir(exist_ok=True)
(ladder.path.parent.parent).write_text("a file where a directory should be")
refuse(ladder, ladder.strategy(CAR, False)) # must not raise
assert ladder.strategy(CAR, False).rung == "other_reply"
def test_only_sixteen_cars_are_kept_newest_first(tmp_path):
clock = iter(range(1000, 2000))
ladder = sl.SetupLadder(tmp_path / "s.json", boot_id=lambda: "b", clock=lambda: next(clock))
for number in range(20):
ladder.worked(f"AA:00:00:00:00:{number:02X}", sl.resolve("patient", False))
reloaded = sl.SetupLadder(tmp_path / "s.json", boot_id=lambda: "b")
assert len(reloaded.cars) == sl.MAX_CARS and "AA:00:00:00:00:13" in reloaded.cars and "AA:00:00:00:00:00" not in reloaded.cars
# ---- the supervisor's use of it -------------------------------------------------------------------------------------
import time as _time
from types import SimpleNamespace
from openpilot.starpilot.system.starport.tests.fake_head_unit import make_identity
from openpilot.starpilot.system.starport.tests.test_starport import make_supervisor
RECEIVER = "AA:BB:CC:DD:EE:01" # the car make_supervisor selects
@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):
supervisor, _ = make_supervisor(identity, tmp_path, monkeypatch, lambda *a, **k: None)
return supervisor
def streamed(seconds):
return SimpleNamespace(video_confirmed=True, video_confirmed_at=_time.monotonic() - seconds)
def test_the_supervisor_keeps_its_ladder_beside_the_config_so_tests_never_touch_real_state(sup):
from openpilot.starpilot.system.starport import identity as identity_store
assert sup._ladder.path == identity_store.CONFIG_PATH.with_name("setup_state.json")
def test_a_minute_of_video_makes_the_strategy_the_one_this_car_starts_with(sup):
strategy = sl.resolve("other_reply", False)
sup._strategy, sup._run_started = strategy, _time.monotonic() - 150
sup._note_streamed(streamed(30)) # too short to count, but video was seen
assert sup._video_seen and not sup._ladder.snapshot(RECEIVER)["learned"]
sup._note_streamed(streamed(90))
snapshot = sup._ladder.snapshot(RECEIVER)
assert snapshot["learned"] and snapshot["current"] == "other_reply" and 50 < snapshot["t_ready_s"] < 70
def test_a_session_that_never_got_video_learns_nothing(sup):
sup._strategy = sl.resolve("patient", False)
sup._note_streamed(SimpleNamespace(video_confirmed=False, video_confirmed_at=0.0))
assert not sup._video_seen and not sup._ladder.snapshot(RECEIVER)["learned"]
def test_status_shows_the_ladder_the_switch_and_the_cars_to_choose_between(sup):
status = sup.status()
assert status["new_setup"] in (True, False) and status["car_candidates"] == []
assert [step["id"] for step in status["setup_ladder"]["steps"]][:2] == ["standard", "other_reply"]
assert status["setup_ladder"]["max_passes"] == sl.MAX_PASSES and not status["setup_ladder"]["learned"]
def test_forgetting_a_car_forgets_what_was_learned_about_it(sup):
sup._ladder.worked(RECEIVER, sl.resolve("patient", False), 10.0)
assert sup.forget_receiver(RECEIVER) is True
assert not sup._ladder.snapshot(RECEIVER)["learned"] and RECEIVER not in sup._ladder.cars
def test_a_slow_car_gets_the_patience_it_once_needed_and_an_unknown_car_gets_no_opinion(ladder):
assert ladder.ready_patience(CAR) == 0
ladder.worked(CAR, sl.resolve("standard", False), 14.0)
assert ladder.ready_patience(CAR) == 1
ladder.worked(CAR, sl.resolve("standard", False), 70.0)
assert ladder.ready_patience(CAR) == 4 # 70 s at 20 s per stage timeout
ladder.worked(CAR, sl.resolve("standard", False), 3000.0)
assert ladder.ready_patience(CAR) == 9 # capped
def test_a_learned_standard_strategy_that_stops_working_moves_on_to_the_other_reply(ladder):
ladder.worked(CAR, sl.resolve("standard", False))
learned = ladder.strategy(CAR, False)
assert refuse(ladder, learned) == "counted"
assert refuse(ladder, learned) == "dropped"
assert ladder.strategy(CAR, False).rung == "other_reply" # one failure fewer than starting over, as the dongle flip did
def test_the_passes_come_back_with_each_ignition_even_without_a_reboot(ladder):
for _ in range(5 * sl.MAX_PASSES):
refuse(ladder, ladder.strategy(CAR, False))
assert ladder.exhausted(CAR) and ladder.snapshot(CAR)["passes"] == sl.MAX_PASSES
ladder.begin_cycle(CAR) # the next drive starts: same boot, same comma
assert not ladder.exhausted(CAR) and ladder.snapshot(CAR)["passes"] == 0
assert refuse(ladder, ladder.strategy(CAR, False)) == "advanced"
ladder.begin_cycle("AA:00:00:00:00:99") # an unknown car is a no-op
assert "AA:00:00:00:00:99" not in ladder.cars
def test_short_sessions_keep_the_same_other_reply_until_it_is_learned(sup, monkeypatch):
from openpilot.starpilot.system.starport import supervisor as module
sup._ladder.failed(RECEIVER, sup._ladder.strategy(RECEIVER, False), ladder_failure=True,
video_seen=False, version_major=4, adapter=False)
replies = []
def bootstrap(*args, **kwargs):
replies.append(kwargs["modern_reply"])
return SimpleNamespace(version_major=4, modern_reply=kwargs["modern_reply"], join_is_cancelled=lambda: False,
run=lambda *a, **k: SimpleNamespace(head_unit={}), keepalive=lambda stop: None)
monkeypatch.setattr(module, "WirelessBootstrap", bootstrap)
monkeypatch.setattr(sup, "_wait", lambda delay: None)
monkeypatch.setattr(sup, "_connect_rfcomm", lambda *args: None)
def short_session(*args):
raise RuntimeError("TCP disconnected before sustained video")
monkeypatch.setattr(sup, "_project", short_session)
for _ in range(2):
with pytest.raises(RuntimeError, match="TCP disconnected"):
sup._attempt(SimpleNamespace())
assert replies == [True, True] and not sup.config["modern_reply_cache"]
monkeypatch.setattr(sup, "_project", lambda *args: sup._ladder.worked(RECEIVER, sup._strategy))
sup._attempt(SimpleNamespace())
assert sup.config["modern_reply_cache"] == {RECEIVER: True}
@@ -0,0 +1,137 @@
"""2026-11-02: the comma's own Wi-Fi joining or roaming stalled the car's video acks (one shared radio)."""
import time
import pytest
from openpilot.starpilot.system.starport import reconnect_notice
from openpilot.starpilot.system.starport.bugsink_diagnosis import stream_loss_kind
from openpilot.starpilot.system.starport import supervisor as supervisor_module
from openpilot.starpilot.system.starport.session import ProjectionSession, SharedRadioStall
from openpilot.starpilot.system.starport.supervisor import SharedRadioWatch, Supervisor
from openpilot.starpilot.system.starport.tests.fake_head_unit import FakeHeadUnit, make_identity
from openpilot.starpilot.system.starport.tests.test_starport import end_session, pump_until, sent_frame, streaming_session
@pytest.fixture(scope="module")
def identity(tmp_path_factory):
return make_identity(tmp_path_factory.mktemp("identity"))
def stalled(identity, changed: bool):
hu = FakeHeadUnit(identity)
session = streaming_session(hu, identity)
session.radio_change = lambda: changed
hu.hold_acks.set()
return hu, session, sent_frame(hu, session)
def test_no_radio_change_keeps_the_standard_limit(identity):
hu, session, sent = stalled(identity, False)
with pytest.raises(TimeoutError, match="2.5 s") as raised:
session.check_progress(now=sent + ProjectionSession.ACK_TIMEOUT + 0.05)
assert not isinstance(raised.value, SharedRadioStall)
end_session(session, hu)
def test_radio_change_waits_up_to_the_grace_limit(identity):
hu, session, sent = stalled(identity, True)
session.check_progress(now=sent + ProjectionSession.ACK_TIMEOUT + 0.05)
session.check_progress(now=sent + ProjectionSession.ACK_GRACE_TIMEOUT - 0.1)
with pytest.raises(SharedRadioStall):
session.check_progress(now=sent + ProjectionSession.ACK_GRACE_TIMEOUT + 0.05)
end_session(session, hu)
def test_grace_started_by_a_change_survives_the_change_ageing_out(identity):
hu, session, sent = stalled(identity, True)
session.check_progress(now=sent + ProjectionSession.ACK_TIMEOUT + 0.05)
session.radio_change = lambda: False # same stall: the decision was made when it began
session.check_progress(now=sent + ProjectionSession.ACK_GRACE_TIMEOUT - 0.1)
end_session(session, hu)
def test_acks_resuming_during_the_grace_keep_the_session_and_log_the_gap(identity):
hu, session, sent = stalled(identity, True)
events = []
session.event = lambda name, **fields: events.append((name, fields))
session.check_progress(now=sent + ProjectionSession.ACK_TIMEOUT + 0.05)
hu.hold_acks.clear()
hu._ack(session.session_id)
pump_until(session, lambda: not session.pending)
assert session.grace_frame is None
assert [name for name, _ in events] == ["ack_grace", "ack_grace_recovered"]
session.check_progress(now=sent + ProjectionSession.ACK_GRACE_TIMEOUT + 1) # nothing pending: no timeout
end_session(session, hu)
def test_car_taking_its_screen_back_during_the_grace_reconnects(identity):
hu, session, sent = stalled(identity, True)
session.check_progress(now=sent + ProjectionSession.ACK_TIMEOUT + 0.05)
hu.set_focus(False)
pump_until(session, lambda: not session.focused)
with pytest.raises(SharedRadioStall, match="took its screen back"):
session.check_progress(now=sent + ProjectionSession.ACK_TIMEOUT + 0.5)
end_session(session, hu)
def test_unfocus_outside_a_grace_is_the_normal_reverse_camera(identity):
hu = FakeHeadUnit(identity)
session = streaming_session(hu, identity)
hu.set_focus(False)
pump_until(session, lambda: not session.focused)
session.check_progress()
end_session(session, hu)
def test_watch_reports_a_carrier_change_without_naming_the_network(tmp_path):
counter = tmp_path / "carrier_changes"
counter.write_text("4\n")
logged = []
watch = SharedRadioWatch(lambda name, **fields: logged.append((name, fields)), interval=0.01, counter=counter).start()
try:
time.sleep(0.05)
assert not watch.recent() # the starting value is not a change
counter.write_text("5\n")
deadline = time.monotonic() + 2
while not watch.recent():
assert time.monotonic() < deadline
time.sleep(0.01)
assert logged == [("other_wifi_changed", {"interface": "wlan0"})]
assert watch.changed_at is not None
assert not watch.recent(now=watch.changed_at + supervisor_module.RADIO_CHANGE_WINDOW + 0.1)
finally:
watch.stop()
def test_watch_without_the_counter_never_reports(tmp_path):
watch = SharedRadioWatch(lambda *a, **k: None, interval=0.01, counter=tmp_path / "missing").start()
try:
time.sleep(0.05)
assert not watch.recent()
finally:
watch.stop()
def test_notice_is_shown_once_and_only_while_fresh(tmp_path):
path = tmp_path / "notice.json"
reconnect_notice.write("Reconnected", path, now=1000.0)
assert reconnect_notice.take(path, now=1010.0) == "Reconnected"
assert reconnect_notice.take(path, now=1010.0) == "" # taken
reconnect_notice.write("Reconnected", path, now=1000.0)
assert reconnect_notice.take(path, now=1000.0 + reconnect_notice.NOTICE_FRESH_S + 1) == ""
assert not path.exists()
def test_notice_from_another_boot_is_discarded(tmp_path, monkeypatch):
monkeypatch.setattr(reconnect_notice.identity_store, "boot_id", lambda: "previous")
path = tmp_path / "notice.json"
reconnect_notice.write("Reconnected", path, now=20.0)
monkeypatch.setattr(reconnect_notice.identity_store, "boot_id", lambda: "current")
assert reconnect_notice.take(path, now=30.0) == "" and not path.exists()
def test_galaxy_status_says_what_happened():
text = Supervisor._describe_error("streaming", SharedRadioStall("stalled"))
assert text == "The car's Wi-Fi stalled while the comma's own Wi-Fi was switching networks; reconnecting"
assert stream_loss_kind(text) == "shared_radio" # bug reports file it apart from ordinary stalls
assert stream_loss_kind("projecting: Video acknowledgement older than 2.5 s") == "stalled"
@@ -179,3 +179,108 @@ def test_disconnect_stray_link_without_a_handle_only_logs(monkeypatch):
phone, commands, events = _phone_with(monkeypatch, {"journalctl": ("", 0)})
assert phone.disconnect_stray_link() is None
assert len(commands) == 1 and events == ["stray_link_not_found"]
def _rfcomm_attempts(sup, monkeypatch, outcomes):
from openpilot.starpilot.system.starport import bt_sockets
calls = []
def connect(*args, **kwargs):
calls.append(args)
outcome = outcomes[min(len(calls), len(outcomes)) - 1]
if isinstance(outcome, int):
raise OSError(outcome, "boom")
return outcome
monkeypatch.setattr(bt_sockets, "connect_rfcomm", connect)
return calls
def test_busy_rfcomm_channel_is_retried_in_the_same_attempt(identity, tmp_path, monkeypatch):
sup, _ = make_supervisor(identity, tmp_path, monkeypatch, lambda *a, **k: socket.socketpair()[0])
_virtual_time(sup, monkeypatch)
good = socket.socketpair()[0]
calls = _rfcomm_attempts(sup, monkeypatch, [errno.EBUSY, good])
assert sup._connect_rfcomm(CAR, 6) is good
assert len(calls) == 2 and len(_events(sup, "rfcomm_busy_retry")) == 1
def test_busy_rfcomm_channel_gives_up_after_the_waits(identity, tmp_path, monkeypatch):
from openpilot.starpilot.system.starport import supervisor
sup, _ = make_supervisor(identity, tmp_path, monkeypatch, lambda *a, **k: socket.socketpair()[0])
_virtual_time(sup, monkeypatch)
calls = _rfcomm_attempts(sup, monkeypatch, [errno.EBUSY])
with pytest.raises(OSError) as raised:
sup._connect_rfcomm(CAR, 6)
assert raised.value.errno == errno.EBUSY and len(calls) == len(supervisor.RFCOMM_BUSY_WAITS) + 1
def test_other_rfcomm_errors_are_not_retried(identity, tmp_path, monkeypatch):
sup, _ = make_supervisor(identity, tmp_path, monkeypatch, lambda *a, **k: socket.socketpair()[0])
_virtual_time(sup, monkeypatch)
calls = _rfcomm_attempts(sup, monkeypatch, [errno.ECONNRESET])
with pytest.raises(OSError):
sup._connect_rfcomm(CAR, 6)
assert len(calls) == 1 and not _events(sup, "rfcomm_busy_retry")
def test_a_retried_busy_channel_logs_how_it_ended(identity, tmp_path, monkeypatch):
sup, _ = make_supervisor(identity, tmp_path, monkeypatch, lambda *a, **k: socket.socketpair()[0])
now = _virtual_time(sup, monkeypatch)
sup._attempt_ended_at = now[0] - 4.0
_rfcomm_attempts(sup, monkeypatch, [errno.EBUSY, errno.EBUSY, socket.socketpair()[0]])
sup._connect_rfcomm(CAR, 6)
assert _events(sup, "rfcomm_busy_cleared")[0]["tries"] == 3
assert _events(sup, "rfcomm_busy_retry")[0]["since_last_attempt_s"] == 4.0
def test_a_channel_that_stays_busy_logs_that_it_gave_up(identity, tmp_path, monkeypatch):
sup, _ = make_supervisor(identity, tmp_path, monkeypatch, lambda *a, **k: socket.socketpair()[0])
_virtual_time(sup, monkeypatch)
_rfcomm_attempts(sup, monkeypatch, [errno.EBUSY])
with pytest.raises(OSError):
sup._connect_rfcomm(CAR, 6)
assert _events(sup, "rfcomm_busy_gave_up")[0]["waited_s"] == 3.0
def test_hands_free_logs_who_started_the_link(identity, tmp_path, monkeypatch):
sup, _ = make_supervisor(identity, tmp_path, monkeypatch, lambda *a, **k: socket.socketpair()[0])
now = _virtual_time(sup, monkeypatch)
sup._paged_at = now[0] - 1.0
sup._hfp_connected(CAR)
sup._paged_at = now[0] - 60.0
sup._hfp_connected(CAR)
assert [e["initiator"] for e in _events(sup, "hfp_initiator")] == ["comma", "car"]
def test_unusable_sdp_record_is_logged_with_what_the_car_sent(identity, tmp_path, monkeypatch):
from openpilot.starpilot.system.starport import bt_sockets, sdp
sup, _ = make_supervisor(identity, tmp_path, monkeypatch, lambda *a, **k: socket.socketpair()[0])
_virtual_time(sup, monkeypatch)
monkeypatch.setattr(bt_sockets, "connect_l2cap", lambda *a, **k: socket.socketpair()[0])
def query(_):
raise sdp.SdpError("Android Auto Wireless SDP record has no RFCOMM channel", b"\x35\x03\x19\x11\x1f")
monkeypatch.setattr(sdp, "query_channel", query)
with pytest.raises(RuntimeError):
sup._discover_channel(CAR)
assert _events(sup, "sdp_retry")[0]["record_hex"] == "350319111f"
def test_readiness_time_starts_over_after_a_failed_attempt(identity, tmp_path, monkeypatch):
sup, _ = make_supervisor(identity, tmp_path, monkeypatch, lambda *a, **k: None)
now = _virtual_time(sup, monkeypatch)
elapsed = []
def attempt(lease):
elapsed.append(now[0] - sup._run_started)
now[0] += 90.0
if len(elapsed) == 1:
raise RuntimeError("Car not ready")
sup._stop.set()
monkeypatch.setattr(sup, "_attempt", attempt)
monkeypatch.setattr(sup, "_wait_backoff", lambda delay, **kwargs: sup._wait(delay))
sup._run(1, trigger="manual")
assert elapsed == [0.0, 0.0]
@@ -75,6 +75,14 @@ FEATURE_CONFIRM_ACTIONS = (TORQUE_CONFIRM_ACTIONS | SLC_CONFIRM_ACTIONS | LANE_C
CURVE_CONFIRM_ACTIONS | CONDITIONAL_CONFIRM_ACTIONS | {"reset_profiles", "pip:reset", "sentry:reset"})
HEADING_PREFIX = "heading:"
def is_heading(key: str) -> bool:
"""A section title inside a page: shown like a row but never a control, and never a change request."""
return key.startswith(HEADING_PREFIX)
@dataclass(frozen=True)
class FeatureRow:
key: str
+1 -1
View File
@@ -1 +1 @@
{"protected_inventory_sha256": "60dd598c374244ab7b331b2ac56c7b3306420091663e5ad82aaec23804793594", "schema_version": 1}
{"protected_inventory_sha256": "11f88170e7196f24bf59ef548439a00c404e95ce101de8d9479c239e58184cef", "schema_version": 1}