diff --git a/openpilot/starpilot/galaxy/README.md b/openpilot/starpilot/galaxy/README.md index d7f4a9e17d..6ed226e277 100644 --- a/openpilot/starpilot/galaxy/README.md +++ b/openpilot/starpilot/galaxy/README.md @@ -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 | diff --git a/openpilot/starpilot/galaxy/developer_settings.py b/openpilot/starpilot/galaxy/developer_settings.py index a81232caff..4a72605247 100644 --- a/openpilot/starpilot/galaxy/developer_settings.py +++ b/openpilot/starpilot/galaxy/developer_settings.py @@ -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") diff --git a/openpilot/starpilot/galaxy/server.py b/openpilot/starpilot/galaxy/server.py index 9de5434177..360aca1521 100644 --- a/openpilot/starpilot/galaxy/server.py +++ b/openpilot/starpilot/galaxy/server.py @@ -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 diff --git a/openpilot/starpilot/galaxy/setting_descriptions.py b/openpilot/starpilot/galaxy/setting_descriptions.py index 28715a2be0..6e3f72b586 100644 --- a/openpilot/starpilot/galaxy/setting_descriptions.py +++ b/openpilot/starpilot/galaxy/setting_descriptions.py @@ -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.', diff --git a/openpilot/starpilot/galaxy/settings.py b/openpilot/starpilot/galaxy/settings.py index 627e035796..f2b6ae3f0a 100644 --- a/openpilot/starpilot/galaxy/settings.py +++ b/openpilot/starpilot/galaxy/settings.py @@ -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: diff --git a/openpilot/starpilot/galaxy/starport_logs.py b/openpilot/starpilot/galaxy/starport_logs.py index 0da2ecb800..eab7fc67e1 100644 --- a/openpilot/starpilot/galaxy/starport_logs.py +++ b/openpilot/starpilot/galaxy/starport_logs.py @@ -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") diff --git a/openpilot/starpilot/galaxy/starport_send.py b/openpilot/starpilot/galaxy/starport_send.py new file mode 100644 index 0000000000..e0e3989584 --- /dev/null +++ b/openpilot/starpilot/galaxy/starport_send.py @@ -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.") diff --git a/openpilot/starpilot/galaxy/starport_setup.py b/openpilot/starpilot/galaxy/starport_setup.py index 4b4ee93e6a..5e9b19d98d 100644 --- a/openpilot/starpilot/galaxy/starport_setup.py +++ b/openpilot/starpilot/galaxy/starport_setup.py @@ -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(), diff --git a/openpilot/starpilot/galaxy/test_starport_http.py b/openpilot/starpilot/galaxy/test_starport_http.py index f8feb37c38..56eb617e7c 100644 --- a/openpilot/starpilot/galaxy/test_starport_http.py +++ b/openpilot/starpilot/galaxy/test_starport_http.py @@ -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 diff --git a/openpilot/starpilot/galaxy/tests/description-key-inventory.json b/openpilot/starpilot/galaxy/tests/description-key-inventory.json index 4451cdbba3..26a99b379d 100644 --- a/openpilot/starpilot/galaxy/tests/description-key-inventory.json +++ b/openpilot/starpilot/galaxy/tests/description-key-inventory.json @@ -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", diff --git a/openpilot/starpilot/galaxy/tests/test_developer_settings.py b/openpilot/starpilot/galaxy/tests/test_developer_settings.py index e8d283de14..89fddb28c8 100644 --- a/openpilot/starpilot/galaxy/tests/test_developer_settings.py +++ b/openpilot/starpilot/galaxy/tests/test_developer_settings.py @@ -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 diff --git a/openpilot/starpilot/galaxy/tests/test_offroad_configuration_pages.py b/openpilot/starpilot/galaxy/tests/test_offroad_configuration_pages.py index 675b9f05ca..ca748938c6 100644 --- a/openpilot/starpilot/galaxy/tests/test_offroad_configuration_pages.py +++ b/openpilot/starpilot/galaxy/tests/test_offroad_configuration_pages.py @@ -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]) diff --git a/openpilot/starpilot/galaxy/tests/test_settings.mjs b/openpilot/starpilot/galaxy/tests/test_settings.mjs index c76112702c..889073a827 100644 --- a/openpilot/starpilot/galaxy/tests/test_settings.mjs +++ b/openpilot/starpilot/galaxy/tests/test_settings.mjs @@ -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", diff --git a/openpilot/starpilot/galaxy/tests/test_settings_controls.mjs b/openpilot/starpilot/galaxy/tests/test_settings_controls.mjs index 93da10fb80..f05c83dee6 100644 --- a/openpilot/starpilot/galaxy/tests/test_settings_controls.mjs +++ b/openpilot/starpilot/galaxy/tests/test_settings_controls.mjs @@ -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") diff --git a/openpilot/starpilot/galaxy/tests/test_starport.mjs b/openpilot/starpilot/galaxy/tests/test_starport.mjs index 275679293b..3158856246 100644 --- a/openpilot/starpilot/galaxy/tests/test_starport.mjs +++ b/openpilot/starpilot/galaxy/tests/test_starport.mjs @@ -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/) diff --git a/openpilot/starpilot/galaxy/tests/test_starport_guided.mjs b/openpilot/starpilot/galaxy/tests/test_starport_guided.mjs new file mode 100644 index 0000000000..77240d19c1 --- /dev/null +++ b/openpilot/starpilot/galaxy/tests/test_starport_guided.mjs @@ -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, / 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("")) + for (const dropdown of [":row=\"carTypeRow\"", ":row=\"adapterRow\"", ":row=\"appRow\""]) assert.ok(!guidedBlock.includes(dropdown), dropdown) + assert.match(guidedBlock, / ${CONNECTION_PROGRESS} ${CONNECT} @@ -965,7 +1026,7 @@ export const StarPortPage = { -
+

{{ homeTitle }}

@@ -973,9 +1034,11 @@ export const StarPortPage = {

{{ expiry.label }}

- - - + ${AUTO_CONNECT}
  • @@ -994,8 +1057,8 @@ export const StarPortPage = { ${CONNECT}
    Customize Layout - +
diff --git a/openpilot/starpilot/galaxy_bluetooth/bootstrap.py b/openpilot/starpilot/galaxy_bluetooth/bootstrap.py index 7439dcc4e2..19892f34b7 100644 --- a/openpilot/starpilot/galaxy_bluetooth/bootstrap.py +++ b/openpilot/starpilot/galaxy_bluetooth/bootstrap.py @@ -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, diff --git a/openpilot/starpilot/galaxy_bluetooth/tests/test_bootstrap.py b/openpilot/starpilot/galaxy_bluetooth/tests/test_bootstrap.py index 5f9cb4b5e5..11683a0110 100644 --- a/openpilot/starpilot/galaxy_bluetooth/tests/test_bootstrap.py +++ b/openpilot/starpilot/galaxy_bluetooth/tests/test_bootstrap.py @@ -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() diff --git a/openpilot/starpilot/system/starport/auto_connect.py b/openpilot/starpilot/system/starport/auto_connect.py index ca6598896e..51355b52d8 100644 --- a/openpilot/starpilot/system/starport/auto_connect.py +++ b/openpilot/starpilot/system/starport/auto_connect.py @@ -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: diff --git a/openpilot/starpilot/system/starport/bluetooth_bridge.py b/openpilot/starpilot/system/starport/bluetooth_bridge.py index 4a8dc08883..7d09cb748b 100644 --- a/openpilot/starpilot/system/starport/bluetooth_bridge.py +++ b/openpilot/starpilot/system/starport/bluetooth_bridge.py @@ -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: diff --git a/openpilot/starpilot/system/starport/bootstrap.py b/openpilot/starpilot/system/starport/bootstrap.py index 4ae9c37513..2836bb6089 100644 --- a/openpilot/starpilot/system/starport/bootstrap.py +++ b/openpilot/starpilot/system/starport/bootstrap.py @@ -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) diff --git a/openpilot/starpilot/system/starport/bugsink_diagnosis.py b/openpilot/starpilot/system/starport/bugsink_diagnosis.py index 69f1f172c5..b7630d0187 100644 --- a/openpilot/starpilot/system/starport/bugsink_diagnosis.py +++ b/openpilot/starpilot/system/starport/bugsink_diagnosis.py @@ -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", ""), diff --git a/openpilot/starpilot/system/starport/bugsink_report.py b/openpilot/starpilot/system/starport/bugsink_report.py index 4f341c8202..887faf3e51 100644 --- a/openpilot/starpilot/system/starport/bugsink_report.py +++ b/openpilot/starpilot/system/starport/bugsink_report.py @@ -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)}, diff --git a/openpilot/starpilot/system/starport/bugsink_transport.py b/openpilot/starpilot/system/starport/bugsink_transport.py index 0f5acf7364..969ba752ef 100644 --- a/openpilot/starpilot/system/starport/bugsink_transport.py +++ b/openpilot/starpilot/system/starport/bugsink_transport.py @@ -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(): diff --git a/openpilot/starpilot/system/starport/car_profiles.py b/openpilot/starpilot/system/starport/car_profiles.py index 52ac24a729..f04b7b4a9d 100644 --- a/openpilot/starpilot/system/starport/car_profiles.py +++ b/openpilot/starpilot/system/starport/car_profiles.py @@ -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}}, ] diff --git a/openpilot/starpilot/system/starport/compat_report.py b/openpilot/starpilot/system/starport/compat_report.py index 64ede3ae94..f6692fc70f 100644 --- a/openpilot/starpilot/system/starport/compat_report.py +++ b/openpilot/starpilot/system/starport/compat_report.py @@ -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: diff --git a/openpilot/starpilot/system/starport/current_car_ui.py b/openpilot/starpilot/system/starport/current_car_ui.py index fee76b7488..55230853a0 100644 --- a/openpilot/starpilot/system/starport/current_car_ui.py +++ b/openpilot/starpilot/system/starport/current_car_ui.py @@ -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) diff --git a/openpilot/starpilot/system/starport/daemon.py b/openpilot/starpilot/system/starport/daemon.py index 3482e58b33..fda09c5f3a 100644 --- a/openpilot/starpilot/system/starport/daemon.py +++ b/openpilot/starpilot/system/starport/daemon.py @@ -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": diff --git a/openpilot/starpilot/system/starport/hw/libstarport_encoder.so b/openpilot/starpilot/system/starport/hw/libstarport_encoder.so index 1b8e37108c..791e7ae260 100755 Binary files a/openpilot/starpilot/system/starport/hw/libstarport_encoder.so and b/openpilot/starpilot/system/starport/hw/libstarport_encoder.so differ diff --git a/openpilot/starpilot/system/starport/hw/starport_encoder.cc b/openpilot/starpilot/system/starport/hw/starport_encoder.cc index 2001e43a0c..e28c322f66 100644 --- a/openpilot/starpilot/system/starport/hw/starport_encoder.cc +++ b/openpilot/starpilot/system/starport/hw/starport_encoder.cc @@ -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(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(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(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(width, height, fps, margin_height, options); encoder->init(bitrate); return encoder.release(); } catch (const std::exception &e) { error_text(error, size, e); return nullptr; } diff --git a/openpilot/starpilot/system/starport/hw_encoder.py b/openpilot/starpilot/system/starport/hw_encoder.py index ad79cc4b3c..279fd56d1d 100644 --- a/openpilot/starpilot/system/starport/hw_encoder.py +++ b/openpilot/starpilot/system/starport/hw_encoder.py @@ -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. diff --git a/openpilot/starpilot/system/starport/identity.py b/openpilot/starpilot/system/starport/identity.py index 425ea86308..ecddea8e78 100644 --- a/openpilot/starpilot/system/starport/identity.py +++ b/openpilot/starpilot/system/starport/identity.py @@ -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 diff --git a/openpilot/starpilot/system/starport/network.py b/openpilot/starpilot/system/starport/network.py index a28017390c..4bd4357166 100644 --- a/openpilot/starpilot/system/starport/network.py +++ b/openpilot/starpilot/system/starport/network.py @@ -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 diff --git a/openpilot/starpilot/system/starport/new_setup.py b/openpilot/starpilot/system/starport/new_setup.py new file mode 100644 index 0000000000..00a359d49b --- /dev/null +++ b/openpilot/starpilot/system/starport/new_setup.py @@ -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 diff --git a/openpilot/starpilot/system/starport/projection_onroad.py b/openpilot/starpilot/system/starport/projection_onroad.py index 72e2499a4c..52a8a8181d 100644 --- a/openpilot/starpilot/system/starport/projection_onroad.py +++ b/openpilot/starpilot/system/starport/projection_onroad.py @@ -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) diff --git a/openpilot/starpilot/system/starport/protocol.py b/openpilot/starpilot/system/starport/protocol.py index e75333ad2e..46b60c07b9 100644 --- a/openpilot/starpilot/system/starport/protocol.py +++ b/openpilot/starpilot/system/starport/protocol.py @@ -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) diff --git a/openpilot/starpilot/system/starport/reachability.py b/openpilot/starpilot/system/starport/reachability.py new file mode 100644 index 0000000000..e28e2767d9 --- /dev/null +++ b/openpilot/starpilot/system/starport/reachability.py @@ -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) diff --git a/openpilot/starpilot/system/starport/reconnect_notice.py b/openpilot/starpilot/system/starport/reconnect_notice.py new file mode 100644 index 0000000000..f4e7f982cb --- /dev/null +++ b/openpilot/starpilot/system/starport/reconnect_notice.py @@ -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"] diff --git a/openpilot/starpilot/system/starport/sdp.py b/openpilot/starpilot/system/starport/sdp.py index 7fcbc79150..85a2d66807 100644 --- a/openpilot/starpilot/system/starport/sdp.py +++ b/openpilot/starpilot/system/starport/sdp.py @@ -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 diff --git a/openpilot/starpilot/system/starport/session.py b/openpilot/starpilot/system/starport/session.py index c1002d2e49..f577caca8f 100644 --- a/openpilot/starpilot/system/starport/session.py +++ b/openpilot/starpilot/system/starport/session.py @@ -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) diff --git a/openpilot/starpilot/system/starport/setup_ladder.py b/openpilot/starpilot/system/starport/setup_ladder.py new file mode 100644 index 0000000000..a69dfff7ca --- /dev/null +++ b/openpilot/starpilot/system/starport/setup_ladder.py @@ -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} diff --git a/openpilot/starpilot/system/starport/supervisor.py b/openpilot/starpilot/system/starport/supervisor.py index 0c161d4e2c..6d92bd9688 100644 --- a/openpilot/starpilot/system/starport/supervisor.py +++ b/openpilot/starpilot/system/starport/supervisor.py @@ -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. diff --git a/openpilot/starpilot/system/starport/tests/fixtures/bugsink_failure_texts.json b/openpilot/starpilot/system/starport/tests/fixtures/bugsink_failure_texts.json new file mode 100644 index 0000000000..c562ddba95 --- /dev/null +++ b/openpilot/starpilot/system/starport/tests/fixtures/bugsink_failure_texts.json @@ -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 + } + ] +} diff --git a/openpilot/starpilot/system/starport/tests/test_auto_connect.py b/openpilot/starpilot/system/starport/tests/test_auto_connect.py index 1cdb884ff5..73b88f451b 100644 --- a/openpilot/starpilot/system/starport/tests/test_auto_connect.py +++ b/openpilot/starpilot/system/starport/tests/test_auto_connect.py @@ -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" diff --git a/openpilot/starpilot/system/starport/tests/test_bluetooth_bridge.py b/openpilot/starpilot/system/starport/tests/test_bluetooth_bridge.py index 893f630b30..f65e913621 100644 --- a/openpilot/starpilot/system/starport/tests/test_bluetooth_bridge.py +++ b/openpilot/starpilot/system/starport/tests/test_bluetooth_bridge.py @@ -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() diff --git a/openpilot/starpilot/system/starport/tests/test_bootstrap_receivers.py b/openpilot/starpilot/system/starport/tests/test_bootstrap_receivers.py index c61051d162..4622c9bcd2 100644 --- a/openpilot/starpilot/system/starport/tests/test_bootstrap_receivers.py +++ b/openpilot/starpilot/system/starport/tests/test_bootstrap_receivers.py @@ -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 diff --git a/openpilot/starpilot/system/starport/tests/test_bugsink_report.py b/openpilot/starpilot/system/starport/tests/test_bugsink_report.py index d03e3e2e63..475b4b22e8 100644 --- a/openpilot/starpilot/system/starport/tests/test_bugsink_report.py +++ b/openpilot/starpilot/system/starport/tests/test_bugsink_report.py @@ -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 '' 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": "