mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-08 13:33:42 +08:00
Merge pull request #273 from dirwin31:StarPort-AutoSetup-20.2
StarPort: guided setup, a setup ladder, and wireless adapter fixes
This commit is contained in:
@@ -31,6 +31,8 @@ Browser JavaScript tests use Node 24; set `STARPILOT_NODE` to an existing Node
|
||||
24 executable when needed. A static preview can serve `web/` over loopback;
|
||||
it labels its sample System Monitor data and cannot edit a device.
|
||||
|
||||
StarPort log uploads use the built-in developer Discord webhook.
|
||||
|
||||
## Pages and owners
|
||||
|
||||
| Page | Current source and operations |
|
||||
|
||||
@@ -6,7 +6,8 @@ from openpilot.starpilot.longitudinal.voacc import KEY, DESCRIPTION
|
||||
from openpilot.starpilot.ui.calibration_reset import calibration_reset
|
||||
from openpilot.starpilot.saved_document import commit_exact
|
||||
from openpilot.starpilot.saved_source import read_saved
|
||||
from openpilot.starpilot.ui.feature_settings_state import FeatureRow, FeatureSettingsState
|
||||
from openpilot.starpilot.system.starport import new_setup
|
||||
from openpilot.starpilot.ui.feature_settings_state import HEADING_PREFIX, FeatureRow, FeatureSettingsState
|
||||
|
||||
|
||||
STARPORT_WIFI_KEY = "StarPortDedicatedWifi"
|
||||
@@ -14,6 +15,13 @@ STARPORT_WIFI_DESCRIPTION = ("Experimental. Join the car's Wi-Fi on a second int
|
||||
"phone hotspot during wireless StarPort. Applies from the next StarPort connection.")
|
||||
CALIBRATION_RESET = "reset_calibration"
|
||||
CALIBRATION_RESET_DESCRIPTION = "Clear camera and steering calibration. The device recalibrates on the next drive."
|
||||
STARPORT_HEADING_KEY = HEADING_PREFIX + "starport"
|
||||
STARPORT_NEW_SETUP_KEY = "StarPortNewSetup"
|
||||
STARPORT_NEW_SETUP_DESCRIPTION = ("Guided StarPort setup with a live checklist of what is being tried. Turn off to use the classic " +
|
||||
"setup screens. Applies the next time the setup screen opens.")
|
||||
STARPORT_HEVC_KEY = "StarPortAllowHevc"
|
||||
STARPORT_HEVC_DESCRIPTION = ("Experimental. Only for a car that offers H.265 and no H.264 video. Cars that offer H.264 are not " +
|
||||
"affected. Applies from the next StarPort connection.")
|
||||
DRIVER_MONITORING_RESET = "reset_driver_monitoring"
|
||||
DRIVER_MONITORING_RESET_DESCRIPTION = "Clear saved wheel-side detection. Automatic detection restarts on the next drive."
|
||||
|
||||
@@ -25,14 +33,32 @@ class DeveloperSettingsOwner:
|
||||
self.panda_wake = panda_wake
|
||||
self.starport = starport # returns the StarPort daemon client
|
||||
|
||||
def _starport_enabled(self):
|
||||
def _starport_status(self):
|
||||
if self.starport is None:
|
||||
return None
|
||||
try:
|
||||
return bool(self.starport().call("status")["status"].get("dedicated_wifi_interface"))
|
||||
status = self.starport().call("status")["status"]
|
||||
return status if isinstance(status, dict) else None
|
||||
except (OSError, RuntimeError, ValueError, KeyError, TypeError):
|
||||
return None # the service is off; there is nothing to configure
|
||||
|
||||
def _starport_rows(self, parked):
|
||||
"""The StarPort section. The switch back to classic setup is always listed; the daemon's settings only while it runs."""
|
||||
new_setup_on = new_setup.enabled()
|
||||
held = "Park the vehicle to change this setting."
|
||||
rows = [FeatureRow(STARPORT_HEADING_KEY, "StarPort", "", available=False),
|
||||
FeatureRow(STARPORT_NEW_SETUP_KEY, "New pairing experience", "On" if new_setup_on else "Off",
|
||||
b"1" if new_setup_on else b"0", ("Off", "On"), available=parked,
|
||||
reason=STARPORT_NEW_SETUP_DESCRIPTION if parked else held, default_value="On")]
|
||||
status = self._starport_status()
|
||||
if status is not None:
|
||||
enabled, hevc = bool(status.get("dedicated_wifi_interface")), bool(status.get("allow_hevc"))
|
||||
rows.append(FeatureRow(STARPORT_WIFI_KEY, "Stay on Wi-Fi", "On" if enabled else "Off", b"1" if enabled else b"0",
|
||||
("Off", "On"), available=parked, reason=STARPORT_WIFI_DESCRIPTION if parked else held, default_value="On"))
|
||||
rows.append(FeatureRow(STARPORT_HEVC_KEY, "Allow H.265 video", "On" if hevc else "Off", b"1" if hevc else b"0",
|
||||
("Off", "On"), available=parked, reason=STARPORT_HEVC_DESCRIPTION if parked else held, default_value="Off"))
|
||||
return tuple(rows)
|
||||
|
||||
def _driver_monitoring_reset_allowed(self):
|
||||
return self.authorized() and not self.params.get_bool("IsDriverViewEnabled")
|
||||
|
||||
@@ -62,11 +88,7 @@ class DeveloperSettingsOwner:
|
||||
firmware_rows = (() if self.panda_wake is None else
|
||||
tuple(replace(item, vehicle_fingerprint=item.capability[0])
|
||||
for item in self.panda_wake.rows(parked) if item.key == "IgnoreIgnitionLine"))
|
||||
enabled = self._starport_enabled()
|
||||
starport_rows = () if enabled is None else (
|
||||
FeatureRow(STARPORT_WIFI_KEY, "StarPort: Stay on Wi-Fi", "On" if enabled else "Off", b"1" if enabled else b"0", ("Off", "On"),
|
||||
available=parked, reason=STARPORT_WIFI_DESCRIPTION if parked else "Park the vehicle to change this setting.",
|
||||
default_value="On"),)
|
||||
starport_rows = self._starport_rows(parked)
|
||||
summary = "Changes apply next drive. Panda firmware changes require a device restart." if firmware_rows else "Changes apply next drive."
|
||||
return FeatureSettingsState("developer", "Developer driving options", summary,
|
||||
(row, reset_row, calibration_row, *firmware_rows, *starport_rows), parked)
|
||||
@@ -95,20 +117,32 @@ class DeveloperSettingsOwner:
|
||||
if request.key == "IgnoreIgnitionLine":
|
||||
return self.panda_wake is not None and self.panda_wake.apply(request)
|
||||
if request.key == STARPORT_WIFI_KEY:
|
||||
return self._apply_starport(request)
|
||||
return self._apply_starport(request, "dedicated_wifi_interface", "set_dedicated_wifi")
|
||||
if request.key == STARPORT_HEVC_KEY:
|
||||
return self._apply_starport(request, "allow_hevc", "set_allow_hevc")
|
||||
if request.key == STARPORT_NEW_SETUP_KEY:
|
||||
return self._apply_new_setup(request)
|
||||
if request.key != KEY or request.value not in ("Off", "On") or request.expected not in (None, b"0", b"1"):
|
||||
return False
|
||||
result = commit_exact(self.params, key=request.key, max_bytes=8, raw=b"1" if request.value == "On" else b"0",
|
||||
expected=request.expected, authorized=self.authorized, temp_prefix=".voacc-")
|
||||
return result.committed and result.verified
|
||||
|
||||
def _apply_starport(self, request):
|
||||
enabled, client = self._starport_enabled(), self.starport
|
||||
def _apply_starport(self, request, field, command):
|
||||
status, client = self._starport_status(), self.starport
|
||||
enabled = None if status is None else bool(status.get(field))
|
||||
if (enabled is None or client is None or request.value not in ("Off", "On") or request.expected != (b"1" if enabled else b"0") or
|
||||
not self.authorized()):
|
||||
return False
|
||||
try:
|
||||
client().call("set_dedicated_wifi", enabled=request.value == "On")
|
||||
client().call(command, enabled=request.value == "On")
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
return False
|
||||
return self._starport_enabled() == (request.value == "On")
|
||||
status = self._starport_status()
|
||||
return status is not None and bool(status.get(field)) == (request.value == "On")
|
||||
|
||||
def _apply_new_setup(self, request):
|
||||
current = new_setup.enabled()
|
||||
if request.value not in ("Off", "On") or request.expected != (b"1" if current else b"0") or not self.authorized():
|
||||
return False
|
||||
return new_setup.set_enabled(request.value == "On")
|
||||
|
||||
@@ -97,7 +97,8 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
|
||||
sounds=None, software_operations=None, drive_stats=None, layout_preview_socket=None, controllers_socket=None,
|
||||
remote_pairing=None, parked=None, camera_snapshot=None, clock=time.monotonic, starport_setup=None,
|
||||
starport_client=None, navigation=None, offline_maps=None, drive_state=None, cloud_provider=None, cloud_offroad=None, projection_layout=None,
|
||||
local_access=None, tmux_live=None, starport_logs=None, hotspot=None, toyota_tools=None, replay_client=None, replay_root=None,
|
||||
local_access=None, tmux_live=None, starport_logs=None, starport_log_sender=None, hotspot=None, toyota_tools=None,
|
||||
replay_client=None, replay_root=None,
|
||||
spotify_setup=None, companion=None, phone_location=None, reverse_proxy=None):
|
||||
from openpilot.starpilot.galaxy.companion import CompanionRegistry, APK_PATH
|
||||
from openpilot.starpilot.gps.phone import PhoneGpsPublisher
|
||||
@@ -139,6 +140,17 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
|
||||
from openpilot.starpilot.galaxy.starport_logs import StarPortLogs
|
||||
starport_logs_source = StarPortLogs()
|
||||
return starport_logs_source
|
||||
starport_log_sender_source = starport_log_sender
|
||||
starport_log_sender_lock = threading.Lock()
|
||||
|
||||
def starport_log_sender():
|
||||
nonlocal starport_log_sender_source
|
||||
with starport_log_sender_lock:
|
||||
if starport_log_sender_source is None:
|
||||
from openpilot.starpilot.galaxy.starport_send import StarPortLogSender, device_summary
|
||||
source = starport_logs()
|
||||
starport_log_sender_source = StarPortLogSender(source.bundle, summary=lambda: device_summary(source.directory))
|
||||
return starport_log_sender_source
|
||||
software_source = software
|
||||
software_operations_source = software_operations
|
||||
software_lock = threading.Lock()
|
||||
@@ -928,6 +940,9 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
|
||||
# immediately before sending potentially sensitive content.
|
||||
if self.require_session():
|
||||
self.json(200, result)
|
||||
elif path == '/api/starport/logs/send':
|
||||
if self.require_session():
|
||||
self.json(200, starport_log_sender().status())
|
||||
elif path == '/api/starport/logs' or path.startswith('/api/starport/logs/'):
|
||||
if not self.require_session():
|
||||
return
|
||||
@@ -1635,6 +1650,7 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
|
||||
'/api/replay/export-start',
|
||||
'/api/galaxy/pair', '/api/galaxy/unpair', '/api/galaxy/device-name', '/api/galaxy/hotspot', '/api/cameras/snapshot',
|
||||
'/api/starport/layout', '/api/starport/enable', '/api/starport/control', '/api/starport/pairing',
|
||||
'/api/starport/logs/send',
|
||||
'/api/starport/pairing/response', '/api/starport/pairing/cancel', '/api/starport/pairing/select',
|
||||
'/api/spotify/enable', '/api/spotify/key', '/api/spotify/preferences', '/api/spotify/control', '/api/spotify/maintenance',
|
||||
'/api/ui/layout', '/api/ui/layout/preview', '/api/favorites/slots',
|
||||
@@ -2314,6 +2330,27 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
|
||||
else:
|
||||
self.json(401, {'error': 'Sign in to Galaxy'})
|
||||
return
|
||||
if path == '/api/starport/logs/send':
|
||||
if type(payload) is not dict or set(payload) != {'message'}:
|
||||
self.json(400, {'error': 'Invalid StarPort log send request'})
|
||||
return
|
||||
identity = self.settings_session()
|
||||
if identity is None:
|
||||
self.json(401, {'error': 'Sign in to Galaxy'})
|
||||
return
|
||||
from openpilot.starpilot.galaxy.starport_send import SendBusy, SendRejected
|
||||
try:
|
||||
result = starport_log_sender().start(payload['message'])
|
||||
except SendRejected as error:
|
||||
self.json(400, {'error': str(error)})
|
||||
except SendBusy as error:
|
||||
self.json(409, {'error': str(error)})
|
||||
else:
|
||||
if self.settings_session() == identity:
|
||||
self.json(202, result)
|
||||
else:
|
||||
self.json(401, {'error': 'Sign in to Galaxy'})
|
||||
return
|
||||
if path == '/api/starport/enable':
|
||||
if type(payload) is not dict or set(payload) not in ({'enabled'}, {'enabled', 'enableBluetooth'}) or \
|
||||
type(payload['enabled']) is not bool or type(payload.get('enableBluetooth', False)) is not bool or \
|
||||
@@ -2363,6 +2400,18 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
|
||||
else:
|
||||
self.json(401, {'error': 'Sign in to Galaxy'})
|
||||
return
|
||||
if action == 'new_setup' and set(payload) == {'action', 'enabled'} and type(payload['enabled']) is bool:
|
||||
# The way back to the classic screens, so it works with the StarPort service off or broken.
|
||||
from openpilot.starpilot.system.starport import new_setup
|
||||
if not configuration_allowed():
|
||||
self.json(403, {'error': 'Park before changing the setup screens'})
|
||||
elif not new_setup.set_enabled(payload['enabled']):
|
||||
self.json(503, {'error': 'Could not save the setup screen choice'})
|
||||
elif self.settings_session() == identity:
|
||||
self.json(200, {'newSetup': new_setup.enabled()})
|
||||
else:
|
||||
self.json(401, {'error': 'Sign in to Galaxy'})
|
||||
return
|
||||
if not starport_setup_owner().enabled():
|
||||
self.json(409, {'error': 'Enable StarPort first'})
|
||||
return
|
||||
|
||||
@@ -90,6 +90,10 @@ DESCRIPTIONS = {
|
||||
'RemoteStartBootsComma': 'Wakes comma during GM remote start.',
|
||||
'HKGRemoteStartBootsComma': 'Wakes comma during Hyundai, Kia or Genesis remote climate operation.',
|
||||
'IgnoreIgnitionLine': 'Uses GM CAN ignition instead of the harness ignition signal.',
|
||||
'heading:starport': 'Settings for connecting Android Auto to the car through StarPort.',
|
||||
'StarPortNewSetup': 'Shows the guided StarPort setup with a live checklist instead of the classic setup screens.',
|
||||
'StarPortAllowHevc': 'Lets a car that offers only H.265 video receive H.265; a car with an H.264 mode keeps H.264.',
|
||||
'StarPortDedicatedWifi': "Joins the car's Wi-Fi on a second interface so the comma stays on its home Wi-Fi or phone hotspot.",
|
||||
'TeslaWakeOnCAN': 'Wakes comma when the Tesla wakes up, before you press the brake.',
|
||||
'RadarRecoveryAlert': 'Play a chime and show a brief message when a radar fault clears. You must re-engage manually.',
|
||||
'CameraView': 'Chooses the camera view shown on the driving screen.',
|
||||
@@ -158,6 +162,9 @@ DESCRIPTIONS = {
|
||||
|
||||
# These are initialization explanations, not a second defaults table.
|
||||
INITIALIZATION = {
|
||||
'StarPortNewSetup': 'Applies the next time the setup screen opens.',
|
||||
'StarPortAllowHevc': 'Applies from the next StarPort connection.',
|
||||
'StarPortDedicatedWifi': 'Applies from the next StarPort connection.',
|
||||
'LateralControllerSelection': ('Applies when driving processes next start: the controller class, vehicle policy and ' +
|
||||
'PID/filter state are constructed together.'),
|
||||
'UseStarPilotLongitudinalPlanner': 'Applies when the planner next starts: each choice runs a different planner process with its own subscriptions and state.',
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.")
|
||||
@@ -13,7 +13,7 @@ from pathlib import Path
|
||||
from collections.abc import Callable
|
||||
from typing import BinaryIO
|
||||
|
||||
from openpilot.starpilot.system.starport import apk_identity, apkpure
|
||||
from openpilot.starpilot.system.starport import apk_identity, apkpure, new_setup
|
||||
|
||||
# Left behind by the removed Google sign-in: a saved refresh token and the resolver binary.
|
||||
LEGACY_GOOGLE_FILES = (Path('/data/starpilot/starport_token'), Path('/data/starpilot/google-play'))
|
||||
@@ -65,6 +65,7 @@ class StarPortSetup:
|
||||
ident = self.identity_status()
|
||||
return {
|
||||
'enabled': self.enabled(), 'bluetoothEnabled': self.bluetooth_enabled(), 'parked': self.parked(),
|
||||
'newSetup': new_setup.enabled(),
|
||||
'installReady': self.install_ready(), 'serviceReady': self.service_ready(),
|
||||
'identity': {key: ident[key] for key in ('installed', 'expires', 'days_left', 'warning', 'message', 'expired', 'error', 'app_version') if key in ident},
|
||||
'import': self.job.status(),
|
||||
|
||||
@@ -524,3 +524,29 @@ def test_on_device_browser_api_is_gone(api):
|
||||
cookie = headers['Set-Cookie'].split(';', 1)[0]
|
||||
start = {'operation': 'start', 'email': 'user@example.com', 'remember': False}
|
||||
assert request('POST', '/api/starport/google-browser', json.dumps(start), cookie=cookie, content_type='application/json')[0] != 200
|
||||
|
||||
|
||||
def test_classic_setup_switch_needs_a_session_and_park_but_not_the_service(api, tmp_path, monkeypatch):
|
||||
from openpilot.starpilot.system.starport import new_setup
|
||||
monkeypatch.setattr(new_setup, 'FLAG_PATH', tmp_path / 'new_setup')
|
||||
request, _, state, client = api
|
||||
body = json.dumps({'action': 'new_setup', 'enabled': False})
|
||||
assert request('POST', '/api/starport/control', body, content_type='application/json')[0] == 401
|
||||
code, _, headers = request('POST', '/api/auth/login', json.dumps({'password': 'password123'}), content_type='application/json')
|
||||
assert code == 200
|
||||
cookie = headers['Set-Cookie'].split(';', 1)[0]
|
||||
assert new_setup.enabled()
|
||||
state['parked'] = False
|
||||
assert request('POST', '/api/starport/control', body, cookie=cookie, content_type='application/json')[0] == 403
|
||||
assert new_setup.enabled()
|
||||
state['parked'] = True
|
||||
client.calls.clear()
|
||||
code, result, _ = request('POST', '/api/starport/control', body, cookie=cookie, content_type='application/json')
|
||||
assert (code, result) == (200, {'newSetup': False}) and not new_setup.enabled()
|
||||
assert client.calls == [] # the daemon was never asked: this is the way back when it is down
|
||||
assert request('GET', '/api/starport/setup', cookie=cookie)[1]['newSetup'] is False
|
||||
code, result, _ = request('POST', '/api/starport/control', json.dumps({'action': 'new_setup', 'enabled': True}),
|
||||
cookie=cookie, content_type='application/json')
|
||||
assert (code, result) == (200, {'newSetup': True}) and new_setup.enabled()
|
||||
for bad in ({'action': 'new_setup'}, {'action': 'new_setup', 'enabled': 'no'}, {'action': 'new_setup', 'enabled': True, 'x': 1}):
|
||||
assert request('POST', '/api/starport/control', json.dumps(bad), cookie=cookie, content_type='application/json')[0] == 400
|
||||
|
||||
@@ -57,13 +57,6 @@
|
||||
"LoudBlindspotAlert",
|
||||
"LoudBlindspotAlertWhenDisengaged",
|
||||
"NostalgiaMode",
|
||||
"slc:offset:1",
|
||||
"slc:offset:2",
|
||||
"slc:offset:3",
|
||||
"slc:offset:4",
|
||||
"slc:offset:5",
|
||||
"slc:offset:6",
|
||||
"slc:offset:7",
|
||||
"PIPPreviewEnabled",
|
||||
"PIPPreviewInvert",
|
||||
"PIPPreviewShowOnBSM",
|
||||
@@ -106,6 +99,9 @@
|
||||
"StandardJerkSpeed",
|
||||
"StandardJerkSpeedDecrease",
|
||||
"StarPilotDisplayPreferencesEnabled",
|
||||
"StarPortAllowHevc",
|
||||
"StarPortDedicatedWifi",
|
||||
"StarPortNewSetup",
|
||||
"SteerFriction",
|
||||
"SteerLatAccel",
|
||||
"StockConfidenceBallWidget",
|
||||
@@ -166,6 +162,7 @@
|
||||
"display:auto:ScreenBrightness",
|
||||
"display:auto:ScreenBrightnessOnroad",
|
||||
"enable_human_turn_detection_curv",
|
||||
"heading:starport",
|
||||
"lane_change:auto",
|
||||
"lane_change:close_gap",
|
||||
"lane_change:close_gap_seconds",
|
||||
@@ -249,7 +246,6 @@
|
||||
"profile:aggressive:following:8",
|
||||
"profile:aggressive:following:9",
|
||||
"profile:global_acceleration",
|
||||
"profile:stopped_gap",
|
||||
"profile:global_braking",
|
||||
"profile:relaxed:acceleration",
|
||||
"profile:relaxed:acceleration:0",
|
||||
@@ -317,6 +313,7 @@
|
||||
"profile:standard:following:7",
|
||||
"profile:standard:following:8",
|
||||
"profile:standard:following:9",
|
||||
"profile:stopped_gap",
|
||||
"profile:traffic:acceleration",
|
||||
"profile:traffic:acceleration:0",
|
||||
"profile:traffic:acceleration:1",
|
||||
@@ -357,6 +354,13 @@
|
||||
"sentry:reset",
|
||||
"sentry:sensitivity",
|
||||
"sentry:warning",
|
||||
"slc:offset:1",
|
||||
"slc:offset:2",
|
||||
"slc:offset:3",
|
||||
"slc:offset:4",
|
||||
"slc:offset:5",
|
||||
"slc:offset:6",
|
||||
"slc:offset:7",
|
||||
"slc_reset",
|
||||
"sounds:auto:DisengageVolume",
|
||||
"sounds:auto:EngageVolume",
|
||||
|
||||
@@ -3,14 +3,18 @@
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
from typing import cast
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from openpilot.starpilot.galaxy.developer_settings import CALIBRATION_RESET, DRIVER_MONITORING_RESET, STARPORT_WIFI_KEY, DeveloperSettingsOwner
|
||||
from openpilot.starpilot.galaxy.developer_settings import (CALIBRATION_RESET, DRIVER_MONITORING_RESET, STARPORT_HEADING_KEY,
|
||||
STARPORT_HEVC_KEY, STARPORT_NEW_SETUP_KEY, STARPORT_WIFI_KEY,
|
||||
DeveloperSettingsOwner)
|
||||
from openpilot.starpilot.galaxy.settings import AuthorityContext, ContextSource, SettingsChanged, SettingsGateway
|
||||
from openpilot.starpilot.longitudinal.voacc import KEY, selected
|
||||
from openpilot.starpilot.system.starport import new_setup
|
||||
from openpilot.starpilot.ui.feature_settings_state import FeatureSettingsRequest
|
||||
|
||||
|
||||
@@ -40,7 +44,7 @@ class FileParams:
|
||||
|
||||
class FakeStarPort:
|
||||
def __init__(self, enabled=False, running=True):
|
||||
self.enabled, self.running = enabled, running
|
||||
self.enabled, self.running, self.hevc = enabled, running, False
|
||||
|
||||
def call(self, command, **payload):
|
||||
if not self.running:
|
||||
@@ -48,7 +52,10 @@ class FakeStarPort:
|
||||
if command == "set_dedicated_wifi":
|
||||
self.enabled = payload["enabled"]
|
||||
return {"ok": True}
|
||||
return {"ok": True, "status": {"dedicated_wifi_interface": self.enabled}}
|
||||
if command == "set_allow_hevc":
|
||||
self.hevc = payload["enabled"]
|
||||
return {"ok": True}
|
||||
return {"ok": True, "status": {"dedicated_wifi_interface": self.enabled, "allow_hevc": self.hevc}}
|
||||
|
||||
|
||||
class DeveloperSettingsTest(unittest.TestCase):
|
||||
@@ -60,6 +67,11 @@ class DeveloperSettingsTest(unittest.TestCase):
|
||||
self.cp = SimpleNamespace(openpilotLongitudinalControl=True, notCar=False, dashcamOnly=False, passive=False)
|
||||
self.parked = True
|
||||
self.owner = DeveloperSettingsOwner(self.params, lambda: self.parked)
|
||||
flag = Path(directory.name) / "new_setup"
|
||||
patch = mock.patch.object(new_setup, "FLAG_PATH", flag)
|
||||
patch.start()
|
||||
self.addCleanup(patch.stop)
|
||||
self.flag = flag
|
||||
|
||||
def test_default_off_strict_selection_and_vehicle_admission(self):
|
||||
self.assertEqual(self.owner.snapshot().rows[0].value, "Off")
|
||||
@@ -172,6 +184,52 @@ class DeveloperSettingsTest(unittest.TestCase):
|
||||
self.assertFalse(self.owner.apply(FeatureSettingsRequest(DRIVER_MONITORING_RESET, b"1", "confirm", confirmation=True)))
|
||||
self.assertTrue(Path(self.params.get_param_path("IsRhdDetected")).exists())
|
||||
|
||||
def test_starport_section_has_a_heading_and_the_escape_hatch_even_with_the_service_off(self):
|
||||
rows = self.owner.snapshot().rows
|
||||
keys = [row.key for row in rows]
|
||||
heading = rows[keys.index(STARPORT_HEADING_KEY)]
|
||||
self.assertEqual((heading.label, heading.value, heading.available, heading.choices), ("StarPort", "", False, ()))
|
||||
self.assertEqual(keys[keys.index(STARPORT_HEADING_KEY) + 1], STARPORT_NEW_SETUP_KEY) # the heading groups what follows
|
||||
self.assertNotIn(STARPORT_WIFI_KEY, keys)
|
||||
self.assertNotIn(STARPORT_HEVC_KEY, keys)
|
||||
self.assertFalse(self.owner.apply(FeatureSettingsRequest(STARPORT_HEADING_KEY, b"", "On"))) # a heading is never a control
|
||||
|
||||
def test_new_pairing_experience_defaults_on_and_the_classic_screens_are_one_switch_away(self):
|
||||
row = next(row for row in self.owner.snapshot().rows if row.key == STARPORT_NEW_SETUP_KEY)
|
||||
self.assertEqual((row.value, row.source, row.choices, row.default_value, row.available), ("On", b"1", ("Off", "On"), "On", True))
|
||||
self.assertFalse(self.owner.apply(FeatureSettingsRequest(STARPORT_NEW_SETUP_KEY, b"0", "On"))) # stale view
|
||||
self.assertFalse(self.owner.apply(FeatureSettingsRequest(STARPORT_NEW_SETUP_KEY, b"1", "maybe")))
|
||||
self.assertTrue(self.owner.apply(FeatureSettingsRequest(STARPORT_NEW_SETUP_KEY, b"1", "Off")))
|
||||
self.assertFalse(new_setup.enabled())
|
||||
self.assertEqual(next(r for r in self.owner.snapshot().rows if r.key == STARPORT_NEW_SETUP_KEY).value, "Off")
|
||||
self.parked = False
|
||||
self.assertFalse(self.owner.apply(FeatureSettingsRequest(STARPORT_NEW_SETUP_KEY, b"0", "On")))
|
||||
self.parked = True
|
||||
self.assertTrue(self.owner.apply(FeatureSettingsRequest(STARPORT_NEW_SETUP_KEY, b"0", "On")))
|
||||
self.assertTrue(new_setup.enabled())
|
||||
|
||||
def test_unreadable_or_odd_flag_means_on(self):
|
||||
self.flag.write_text("garbage")
|
||||
self.assertTrue(new_setup.enabled())
|
||||
self.flag.unlink()
|
||||
self.assertTrue(new_setup.enabled())
|
||||
|
||||
def test_allow_h265_toggle_defaults_off_and_follows_the_daemon(self):
|
||||
service = FakeStarPort()
|
||||
owner = DeveloperSettingsOwner(self.params, lambda: self.parked, starport=lambda: service)
|
||||
rows = owner.snapshot().rows
|
||||
keys = [row.key for row in rows]
|
||||
self.assertEqual(keys[keys.index(STARPORT_HEADING_KEY):], [STARPORT_HEADING_KEY, STARPORT_NEW_SETUP_KEY, STARPORT_WIFI_KEY, STARPORT_HEVC_KEY])
|
||||
row = rows[keys.index(STARPORT_HEVC_KEY)]
|
||||
self.assertEqual((row.value, row.default_value, row.available), ("Off", "Off", True))
|
||||
self.assertTrue(owner.apply(FeatureSettingsRequest(STARPORT_HEVC_KEY, row.source, "On")))
|
||||
self.assertTrue(service.hevc)
|
||||
self.assertFalse(owner.apply(FeatureSettingsRequest(STARPORT_HEVC_KEY, row.source, "Off"))) # stale view
|
||||
self.parked = False
|
||||
self.assertFalse(owner.apply(FeatureSettingsRequest(STARPORT_HEVC_KEY, b"1", "Off")))
|
||||
self.parked = True
|
||||
service.running = False
|
||||
self.assertFalse(owner.apply(FeatureSettingsRequest(STARPORT_HEVC_KEY, b"1", "Off")))
|
||||
|
||||
class CalibrationResetSettingsTest(unittest.TestCase):
|
||||
setUp = DeveloperSettingsTest.setUp
|
||||
|
||||
@@ -57,6 +57,8 @@ class TestOffroadConfigurationPages(unittest.TestCase):
|
||||
current = self.editable_inventory(self.page(name))
|
||||
expected = saved[name]
|
||||
if name == 'developer':
|
||||
# gated by Park, not by the vehicle
|
||||
expected, current = ([row for row in rows if row[0] != 'New pairing experience'] for rows in (expected, current))
|
||||
self.assertEqual([row[0] for row in expected], ['Use VOACC'])
|
||||
self.assertEqual([row[1] for row in expected], [False])
|
||||
self.assertEqual([row[1] for row in current], [True])
|
||||
|
||||
@@ -160,6 +160,17 @@ const filtered = SettingsPage.computed.visibleRows.call({ state: { query: "lane"
|
||||
] } } })
|
||||
assert.deepEqual(filtered.map(({ index }) => index), [1]) // Filter keeps the original owner row index.
|
||||
|
||||
const developerRows = [{ label: "Use VOACC", value: "Off", reason: "" }, { label: "StarPort", value: "", reason: "", heading: true },
|
||||
{ label: "Stay on Wi-Fi", value: "On", reason: "" }, { label: "Allow H.265 video", value: "Off", reason: "" }]
|
||||
const cardsFor = (query) => {
|
||||
const view = { state: { query, data: { rows: developerRows } }, initialPage: "developer", activeSection: {} }
|
||||
view.visibleRows = SettingsPage.computed.visibleRows.call(view)
|
||||
return SettingsPage.computed.rowCards.call(view).map(({ title, entries }) => [title, entries.map(({ index }) => index)])
|
||||
}
|
||||
assert.deepEqual(cardsFor(""), [["", [0]], ["StarPort", [2, 3]]], "a heading row starts its own card")
|
||||
assert.deepEqual(cardsFor("h.265"), [["", []], ["StarPort", [3]]], "search keeps a row in its card when the heading is filtered out")
|
||||
assert.deepEqual(cardsFor("voacc"), [["", [0]]], "an emptied card is not shown")
|
||||
|
||||
const conditional = fixture()
|
||||
conditional.feed.start()
|
||||
await conditional.reply(0, { ...lane, page: "hub", rows: [{ label: "Conditional driving modes", page: "conditional",
|
||||
|
||||
@@ -227,3 +227,15 @@ for (const row of [{ value: "30", minimum: 1, maximum: 30, step: 1, unit: "h" },
|
||||
{ value: "11.8", minimum: 11.8, maximum: 12.5, step: .1, unit: "V" }]) {
|
||||
assert.equal(settingControl(row), "slider")
|
||||
}
|
||||
|
||||
// A section heading (for example "StarPort" on the Developer page) is a label only: no control, no readout, no default button.
|
||||
{
|
||||
const heading = { label: "StarPort", value: "", heading: true, available: false, choices: [], key: "heading:starport" }
|
||||
assert.equal(settingControl(heading), "readout")
|
||||
const template = GalaxySettingRow.template
|
||||
assert.match(template, /'gx-row--heading': row\.heading/)
|
||||
assert.match(template, /v-else-if="!row\.heading" class="gx-row__value gx-row__readout"/)
|
||||
assert.match(template, /showDefault && !row\.heading/)
|
||||
assert.match(template, /presentedRow\.reason && !row\.heading/)
|
||||
}
|
||||
console.log("Settings controls: section headings render as labels only")
|
||||
|
||||
@@ -17,8 +17,10 @@ assert.equal(validPromptInput({ kind: "pin" }, "1234"), true)
|
||||
assert.equal(validPromptInput({ kind: "pin" }, "\n"), false)
|
||||
assert.equal(validPromptInput({ kind: "passkey" }, "1234567"), false)
|
||||
assert.equal(validPromptInput(prompt, ""), true)
|
||||
assert.match(StarPortPage.template, /Check that this code matches your car/)
|
||||
assert.match(StarPortPage.template, /Check that this code matches your/)
|
||||
assert.match(StarPortPage.template, /Cancel Search \/ Pairing/)
|
||||
assert.equal(StarPortPage.computed.deviceChosen.call({ pairing: { ...pairing, state: "pairing" } }), true)
|
||||
assert.equal(StarPortPage.computed.deviceChosen.call({ pairing: { ...pairing, state: "failed" } }), false)
|
||||
assert.doesNotMatch(StarPortPage.template, /:row="wiredRow"/)
|
||||
assert.match(StarPortPage.template, /Read the Android Auto troubleshooting guide/)
|
||||
assert.doesNotMatch(StarPortPage.template, /v-html|autoaccept/i)
|
||||
@@ -484,6 +486,9 @@ typed.runtime = { car_type: 'adapter' }
|
||||
assert.equal(typed.adapterSetup, true)
|
||||
assert.equal(typed.pairsHere, true)
|
||||
assert.equal(typed.findLabel, 'adapter')
|
||||
assert.equal(typed.deviceWord, 'adapter', 'the pairing prompts name the adapter, not the car')
|
||||
typed.runtime = { car_type: 'builtin' }
|
||||
assert.equal(typed.deviceWord, 'car')
|
||||
typed.runtime = { car_type: 'usb' }
|
||||
assert.equal(typed.pairsHere, false, 'a USB cable needs no Bluetooth pairing')
|
||||
typed.runtime = { running: false, connection: 'wired' }
|
||||
@@ -524,15 +529,16 @@ assert.equal(dongle.adapterRow.value, 'Choose one', 'no default: the owner picks
|
||||
assert.equal(settingControl(dongle.adapterRow), 'select', 'the initial adapter prompt must open a picker')
|
||||
assert.match(dongle.pairingReason, /adapter model/)
|
||||
dongle.runtime = { car_type: 'adapter', car_profile: 'default', car_profile_source: 'override' }
|
||||
assert.equal(dongle.adapterRow.value, 'Other')
|
||||
assert.deepEqual(dongle.adapterRow.choices, ['AAWireless', 'Other'])
|
||||
assert.equal(dongle.adapterRow.value, 'Motorola MA1')
|
||||
assert.deepEqual(dongle.adapterRow.choices, ['AAWireless', 'Motorola MA1', 'Unofficial dongle'])
|
||||
dongle.runtime = { car_type: 'adapter', car_profile: 'aawireless', car_profile_source: 'override' }
|
||||
assert.equal(dongle.adapterRow.value, 'AAWireless')
|
||||
const dongleCalls = []
|
||||
dongle.control = (action, extra) => dongleCalls.push([action, extra])
|
||||
dongle.saveAdapter(0, 'Other')
|
||||
dongle.saveAdapter(0, 'Motorola MA1')
|
||||
dongle.saveAdapter(0, 'AAWireless')
|
||||
assert.deepEqual(dongleCalls, [['car_profile', { profile: 'default' }], ['car_profile', { profile: 'aawireless' }]])
|
||||
dongle.saveAdapter(0, 'Unofficial dongle')
|
||||
assert.deepEqual(dongleCalls, [['car_profile', { profile: 'default' }], ['car_profile', { profile: 'aawireless' }], ['car_profile', { profile: 'unofficial' }]])
|
||||
assert.match(StarPortPage.template, /adapterSetup" :row="adapterRow"/)
|
||||
assert.match(StarPortPage.template, /What’s in your car\?|carTypeRow/)
|
||||
assert.match(StarPortPage.template, /runtime\?\.pairing_note/)
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { StarPortFeed, StarPortPage } from "../web/js/starport.js"
|
||||
import { guidedState, connectionStages, handshakeSteps, StarPortGuidedSetup, FIRST_RUN_NOTICE, EXHAUSTED_NOTICE } from "../web/js/starport-guided.js"
|
||||
|
||||
const setup = { identity: { installed: true } }
|
||||
const adapterModels = { aawireless: "AAWireless", default: "Motorola MA1", unofficial: "Unofficial dongle" }
|
||||
const appModels = { headunitpad: "HeadunitPad", openheadunit: "Open Headunit" }
|
||||
const ladder = (over = {}) => ({ steps: [{ id: "standard", label: "Standard handshake", state: "current" }, { id: "other_reply", label: "The other version reply", state: "waiting" }],
|
||||
learned: false, passes: 0, max_passes: 3, exhausted: false, successes: 0, ...over })
|
||||
const running = (state, over = {}) => ({ running: true, state, connection: "wireless", setup_ladder: ladder(), ...over })
|
||||
|
||||
// Before anything is known the card stays out of the way.
|
||||
assert.equal(guidedState({}).show, false)
|
||||
assert.equal(guidedState({ setup: null, runtime: running("rfcomm") }).show, false)
|
||||
|
||||
// Without the Android Auto package the first thing to do is that step, nothing else.
|
||||
{
|
||||
const state = guidedState({ setup: { identity: { installed: false } }, runtime: null })
|
||||
assert.equal(state.phase, "package")
|
||||
assert.equal(state.show, true)
|
||||
assert.deepEqual(state.choices, [])
|
||||
}
|
||||
|
||||
// First run: one question, four answers, the current one marked, and only the steps for that answer.
|
||||
{
|
||||
const state = guidedState({ setup, runtime: { running: false, state: "idle" }, carType: "builtin" })
|
||||
assert.equal(state.phase, "setup")
|
||||
assert.deepEqual(state.choices.map((choice) => choice.key), ["builtin", "adapter", "usb", "app"])
|
||||
assert.deepEqual(state.choices.map((choice) => choice.selected), [true, false, false, false])
|
||||
assert.deepEqual(state.choices.map((choice) => choice.title), ["Built-in wireless", "Wireless adapter", "USB cable", "Tablet app"])
|
||||
assert.equal(state.title, "Set up your car")
|
||||
assert.match(state.detail, /pairing screen/)
|
||||
assert.match(guidedState({ setup, runtime: null, carType: "adapter" }).detail, /pairing mode/)
|
||||
assert.match(guidedState({ setup, runtime: null, carType: "usb" }).detail, /USB data port/)
|
||||
assert.match(guidedState({ setup, runtime: null, carType: "app" }).detail, /tablet/)
|
||||
}
|
||||
|
||||
// Cars to choose between: paired and offering Android Auto but missing from the page's own paired list; never the selected one,
|
||||
// never twice, not while pairing. (The page lists the rest itself in the pairing step.)
|
||||
{
|
||||
const runtime = { running: false, state: "idle", car_candidates: [{ address: "AA:BB:CC:DD:EE:01", name: "Civic" }, { address: "AA:BB:CC:DD:EE:02", name: "Truck" }] }
|
||||
const receivers = [{ address: "AA:BB:CC:DD:EE:02", name: "Truck" }, { address: "AA:BB:CC:DD:EE:03", name: "Wagon" }, null, { address: 5, name: "x" }]
|
||||
const names = (state) => state.candidates.map((car) => car.name)
|
||||
assert.deepEqual(names(guidedState({ setup, runtime, receivers, carType: "builtin" })), ["Civic"])
|
||||
assert.deepEqual(names(guidedState({ setup, runtime, receivers: [], carType: "builtin" })), ["Civic", "Truck"])
|
||||
assert.deepEqual(names(guidedState({ setup, runtime, receivers, carType: "builtin", selected: { address: "AA:BB:CC:DD:EE:01", name: "Civic" } })), [])
|
||||
assert.deepEqual(names(guidedState({ setup, runtime, receivers, carType: "builtin", pairingActive: true })), [])
|
||||
assert.deepEqual(names(guidedState({ setup, runtime, receivers, carType: "usb" })), []) // a cable has no cars to pair
|
||||
}
|
||||
|
||||
// A finished setup shows nothing unless a connection is in progress.
|
||||
{
|
||||
assert.equal(guidedState({ setup, runtime: { running: false, state: "idle" }, setupDone: true }).show, false)
|
||||
assert.equal(guidedState({ setup, runtime: running("streaming"), setupDone: true }).show, false)
|
||||
const state = guidedState({ setup, runtime: running("wifi_start"), setupDone: true, carType: "builtin" })
|
||||
assert.equal(state.show, true)
|
||||
assert.equal(state.phase, "connecting")
|
||||
}
|
||||
|
||||
// The checklist follows the stage the daemon reports.
|
||||
{
|
||||
const states = (runtime) => connectionStages(runtime).map((step) => step.state)
|
||||
assert.deepEqual(states(running("connecting_bluetooth")), ["active", "waiting", "waiting", "waiting"])
|
||||
assert.deepEqual(states(running("joining_wifi")), ["done", "active", "waiting", "waiting"])
|
||||
assert.deepEqual(states(running("authenticating")), ["done", "done", "active", "waiting"])
|
||||
assert.deepEqual(states(running("negotiating")), ["done", "done", "done", "active"])
|
||||
assert.deepEqual(states(running("streaming")), ["done", "done", "done", "done"])
|
||||
assert.deepEqual(states(running("backoff", { last_stage: "wifi_start" })), ["done", "retry", "waiting", "waiting"])
|
||||
assert.deepEqual(states(running("backoff", {})), ["retry", "waiting", "waiting", "waiting"])
|
||||
assert.deepEqual(connectionStages(running("waiting_for_usb", { connection: "wired" })).map((step) => step.id), ["cable", "secure", "dashboard"])
|
||||
assert.deepEqual(connectionStages(running("waiting_for_ipad", { connection: "headunitpad" })).map((step) => step.id), ["app", "secure", "dashboard"])
|
||||
assert.deepEqual(connectionStages(null).map((step) => step.state), ["active", "waiting", "waiting", "waiting"])
|
||||
}
|
||||
|
||||
// The first-connection warning appears only to a wireless car with nothing learned, and not once it has worked.
|
||||
{
|
||||
const first = guidedState({ setup, runtime: running("rfcomm"), carType: "builtin" })
|
||||
assert.equal(first.notice, FIRST_RUN_NOTICE)
|
||||
assert.equal(guidedState({ setup, runtime: running("rfcomm", { setup_ladder: ladder({ learned: true }) }) }).notice, "")
|
||||
assert.equal(guidedState({ setup, runtime: running("rfcomm", { setup_ladder: ladder({ successes: 2 }) }) }).notice, "")
|
||||
assert.equal(guidedState({ setup, runtime: running("waiting_for_usb", { connection: "wired" }) }).notice, "")
|
||||
const exhausted = guidedState({ setup, runtime: running("backoff", { setup_ladder: ladder({ exhausted: true }) }) })
|
||||
assert.equal(exhausted.notice, EXHAUSTED_NOTICE)
|
||||
assert.equal(exhausted.exhausted, true)
|
||||
}
|
||||
|
||||
// What is being tried is listed only once something was tried and failed, or one option is remembered.
|
||||
{
|
||||
assert.deepEqual(handshakeSteps(ladder()), [])
|
||||
assert.deepEqual(handshakeSteps(null), [])
|
||||
const tried = ladder({ steps: [{ id: "standard", label: "Standard handshake", state: "failed" }, { id: "other_reply", label: "The other version reply", state: "current" }] })
|
||||
assert.deepEqual(handshakeSteps(tried).map((step) => step.state), ["failed", "current"])
|
||||
assert.equal(guidedState({ setup, runtime: running("wifi_start", { setup_ladder: tried }) }).handshake.length, 2)
|
||||
assert.equal(handshakeSteps(ladder({ learned: true })).length, 2)
|
||||
assert.deepEqual(handshakeSteps({ steps: [null, { label: 3 }, { id: "a", label: "A", state: "worked" }] }).map((step) => step.id), ["a"])
|
||||
}
|
||||
|
||||
// The component emits the page's actions and never invents state.
|
||||
{
|
||||
assert.deepEqual(StarPortGuidedSetup.emits, ["choose-type", "choose-adapter", "choose-app", "use-car", "classic", "done"])
|
||||
const template = StarPortGuidedSetup.template
|
||||
assert.match(template, /\$emit\('choose-type', choice\.key\)/)
|
||||
assert.match(template, /\$emit\('use-car', car\.address\)/)
|
||||
assert.match(template, /\$emit\('classic'\)/)
|
||||
assert.match(template, /Use classic setup/)
|
||||
assert.doesNotMatch(template, /v-html/)
|
||||
}
|
||||
// The way back to the classic screens posts one narrow action, even when the StarPort service is off.
|
||||
{
|
||||
const calls = []
|
||||
const reply = (body, code = 200) => ({ status: code, ok: code < 300, json: async () => structuredClone(body) })
|
||||
const feed = new StarPortFeed({ publish: () => {}, fetcher: async (path, options) => {
|
||||
calls.push([path, options])
|
||||
return reply(path.endsWith("/setup") ? { enabled: false, bluetoothEnabled: false, parked: true, installReady: true, serviceReady: false,
|
||||
identity: { installed: true }, import: {}, maxUploadBytes: 1000, newSetup: true } : { newSetup: false })
|
||||
}, later: () => 1, cancelTimer: () => {} })
|
||||
assert.equal(await feed.setNewSetup(false), false) // not started yet
|
||||
feed.active = true
|
||||
assert.equal(await feed.setNewSetup("no"), false)
|
||||
assert.equal(await feed.setNewSetup(false), true)
|
||||
const [path, options] = calls.find(([target]) => target.endsWith("/control"))
|
||||
assert.equal(path, "./api/starport/control")
|
||||
assert.deepEqual(JSON.parse(options.body), { action: "new_setup", enabled: false })
|
||||
assert.equal(options.method, "POST")
|
||||
}
|
||||
|
||||
// The page uses the guided card by default and only when the setup response does not say newSetup is off.
|
||||
{
|
||||
const template = StarPortPage.template
|
||||
assert.match(template, /<StarPortGuidedSetup v-if="setup && newSetup && guided\.show"/)
|
||||
assert.match(template, /@choose-type="chooseType" @choose-adapter="chooseAdapter" @choose-app="chooseApp" @use-car="useCar" @classic="useClassic"/)
|
||||
assert.match(template, /Try the guided setup/)
|
||||
const vm = { setup: { newSetup: false } }
|
||||
assert.equal(StarPortPage.computed.newSetup.call(vm), false)
|
||||
assert.equal(StarPortPage.computed.newSetup.call({ setup: {} }), true) // default on, including an older server
|
||||
assert.equal(StarPortPage.computed.newSetup.call({ setup: null }), true)
|
||||
}
|
||||
console.log("StarPort guided setup: question, steps, candidates, live checklist, first-run notice and handshake list passed")
|
||||
|
||||
// The adapter's "connect your car in Bluetooth first" refusal becomes a step with a link, not a dead end.
|
||||
{
|
||||
const { BLUETOOTH_HINT } = await import("../web/js/starport-guided.js")
|
||||
const refused = { running: false, state: "error", error: "Connect your paired car in Galaxy Bluetooth first to select it for this Android Auto adapter" }
|
||||
const state = guidedState({ setup, runtime: refused, carType: "adapter" })
|
||||
assert.equal(state.phase, "setup") // the usual question and steps stay, with the action first
|
||||
assert.deepEqual(state.hint, BLUETOOTH_HINT)
|
||||
assert.equal(BLUETOOTH_HINT.href, "#/bluetooth")
|
||||
const done = guidedState({ setup, runtime: refused, setupDone: true })
|
||||
assert.equal(done.phase, "bluetooth") // even a finished setup shows the thing to fix
|
||||
assert.equal(guidedState({ setup, runtime: { running: false, state: "error", error: "something else" }, carType: "adapter" }).hint, null)
|
||||
assert.match(StarPortGuidedSetup.template, /state\.hint\.href/)
|
||||
}
|
||||
console.log("StarPort guided setup: Bluetooth-first hint passed")
|
||||
|
||||
// The three steps are always shown, and follow the setup along.
|
||||
{
|
||||
const states = (input) => guidedState({ setup, ...input }).steps.map((step) => step.state)
|
||||
const labels = (input) => guidedState({ setup, ...input }).steps.map((step) => step.label)
|
||||
assert.deepEqual(guidedState({ setup: { identity: { installed: false } } }).steps.map((step) => step.state), ["current", "todo", "todo"])
|
||||
assert.deepEqual(states({ runtime: null }), ["done", "current", "todo"])
|
||||
assert.deepEqual(states({ runtime: null, setupDone: true, editing: true }), ["done", "done", "current"])
|
||||
assert.deepEqual(states({ runtime: running("wifi_start"), setupDone: true }), ["done", "done", "current"])
|
||||
assert.deepEqual(states({ runtime: running("streaming"), setupDone: true }), ["done", "done", "done"])
|
||||
assert.deepEqual(labels({ carType: "builtin" }), ["Support", "Car", "Connect"])
|
||||
assert.deepEqual(labels({ carType: "adapter" }), ["Support", "Adapter", "Connect"])
|
||||
assert.equal(labels({ carType: "usb" })[1], "Cable")
|
||||
assert.equal(labels({ carType: "app" })[1], "App")
|
||||
// Every phase carries them, including the Bluetooth-first refusal and a connection in progress.
|
||||
for (const input of [{ runtime: running("rfcomm") }, { runtime: null }, { runtime: { running: false, error: "Connect your paired car in Galaxy Bluetooth first" }, setupDone: true }]) {
|
||||
assert.equal(guidedState({ setup, ...input }).steps.length, 3)
|
||||
}
|
||||
}
|
||||
|
||||
// The adapter model and the app are picked in the card, not from a dropdown.
|
||||
{
|
||||
const adapter = guidedState({ setup, runtime: null, carType: "adapter", adapterModels, adapterKey: "" })
|
||||
assert.deepEqual(adapter.adapter.options.map((option) => option.label), ["AAWireless", "Motorola MA1", "Unofficial dongle"])
|
||||
assert.equal(adapter.adapter.needsChoice, true)
|
||||
const chosen = guidedState({ setup, runtime: null, carType: "adapter", adapterModels, adapterKey: "aawireless" })
|
||||
assert.deepEqual(chosen.adapter.options.map((option) => option.selected), [true, false, false])
|
||||
assert.equal(chosen.adapter.needsChoice, false)
|
||||
assert.equal(guidedState({ setup, runtime: null, carType: "builtin", adapterModels }).adapter, null)
|
||||
const apps = guidedState({ setup, runtime: null, carType: "app", appModels, app: "HeadunitPad" })
|
||||
assert.deepEqual(apps.apps.options.map((option) => [option.key, option.selected]), [["headunitpad", true], ["openheadunit", false]])
|
||||
assert.equal(guidedState({ setup, runtime: null, carType: "app", appModels, app: "" }).apps.needsChoice, true)
|
||||
assert.equal(guidedState({ setup, runtime: null, carType: "usb", appModels }).apps, null)
|
||||
}
|
||||
|
||||
// A finished setup can be reopened from the home card; Done closes it.
|
||||
{
|
||||
const reopened = guidedState({ setup, runtime: { running: false, state: "idle" }, setupDone: true, editing: true })
|
||||
assert.equal(reopened.show, true)
|
||||
assert.equal(reopened.editing, true)
|
||||
assert.equal(reopened.title, "Change how your car connects")
|
||||
assert.equal(guidedState({ setup, runtime: { running: false, state: "idle" }, setupDone: true, editing: false }).show, false)
|
||||
assert.match(StarPortGuidedSetup.template, /\$emit\('done'\)/)
|
||||
}
|
||||
|
||||
// Dropdowns belong to the classic screens only; the guided card replaces them and keeps pairing, steps and Connect.
|
||||
{
|
||||
const template = StarPortPage.template
|
||||
const guidedBlock = template.slice(template.indexOf("<StarPortGuidedSetup"), template.indexOf("</StarPortGuidedSetup>"))
|
||||
for (const dropdown of [":row=\"carTypeRow\"", ":row=\"adapterRow\"", ":row=\"appRow\""]) assert.ok(!guidedBlock.includes(dropdown), dropdown)
|
||||
assert.match(guidedBlock, /<template #car>/)
|
||||
assert.match(guidedBlock, /beginPairing/) // Find car / Find adapter
|
||||
assert.match(guidedBlock, /@click="connect"/) // the Connect button
|
||||
assert.match(guidedBlock, /Stuck pairing\?/) // the pairing help stays
|
||||
assert.match(template, /<ol v-if="setup && !newSetup" id="gx-starport-steps"/) // the classic step 2, with its dropdown
|
||||
const home = template.slice(template.indexOf("gx-starport-home\" aria-labelledby"))
|
||||
assert.match(home, /<template v-if="!newSetup">\s*<GalaxySettingRow :row="carTypeRow"/)
|
||||
assert.match(StarPortGuidedSetup.template, /gx-guided__steps/)
|
||||
assert.match(StarPortGuidedSetup.template, /<slot name="car" \/>/)
|
||||
assert.match(StarPortGuidedSetup.template, /<slot name="connect" \/>/)
|
||||
}
|
||||
console.log("StarPort guided setup: steps, adapter and app choices, reopening and classic-only dropdowns passed")
|
||||
|
||||
// Choosing a cable or an app finishes the setup at once; a choice made in the card must not close the card.
|
||||
{
|
||||
const calls = []
|
||||
const vm = { showSteps: false, carTypeLabels: { app: "App", usb: "Wired AA" },
|
||||
saveCarType: (index, label) => calls.push(["type", label]), saveAdapter: (index, label) => calls.push(["adapter", label]),
|
||||
saveApp: (index, label) => calls.push(["app", label]) }
|
||||
StarPortPage.methods.chooseType.call(vm, "app")
|
||||
assert.equal(vm.showSteps, true)
|
||||
vm.showSteps = false
|
||||
StarPortPage.methods.chooseApp.call(vm, "headunitpad")
|
||||
assert.equal(vm.showSteps, true)
|
||||
vm.showSteps = false
|
||||
StarPortPage.methods.chooseAdapter.call(vm, "aawireless")
|
||||
assert.equal(vm.showSteps, true)
|
||||
assert.deepEqual(calls, [["type", "App"], ["app", "HeadunitPad"], ["adapter", "AAWireless"]])
|
||||
// With the card held open by a choice, the finished setup is shown for editing with its Done button, not hidden.
|
||||
const held = guidedState({ setup, runtime: { running: false, state: "idle", connection: "headunitpad" }, setupDone: true, editing: true, carType: "app", appModels, app: "HeadunitPad" })
|
||||
assert.equal(held.show, true)
|
||||
assert.equal(held.editing, true)
|
||||
assert.equal(held.choices.find((choice) => choice.selected).key, "app")
|
||||
assert.equal(held.apps.options.find((option) => option.selected).key, "headunitpad")
|
||||
}
|
||||
console.log("StarPort guided setup: a choice keeps the card open passed")
|
||||
@@ -0,0 +1,90 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { StarPortLogSend, SEND_URL, MESSAGE_LIMIT } from "../web/js/starport-logs.js"
|
||||
|
||||
const flush = async () => { for (let i = 0; i < 8; i++) await Promise.resolve() }
|
||||
function fixture() {
|
||||
const requests = [], states = [], timers = []
|
||||
let unauthorized = 0
|
||||
const client = new StarPortLogSend({ publish: (state) => states.push(state), unauthorized: () => { unauthorized++ },
|
||||
fetcher: (url, options) => new Promise((resolve, reject) => requests.push({ url, options, resolve, reject })),
|
||||
schedule: (fn) => { timers.push(fn); return timers.length }, cancel: () => {} })
|
||||
const reply = async (index, body, status = 200) => {
|
||||
requests[index].resolve({ ok: status >= 200 && status < 300, status, json: async () => body })
|
||||
await flush()
|
||||
}
|
||||
return { client, requests, states, timers, reply, get unauthorized() { return unauthorized } }
|
||||
}
|
||||
|
||||
assert.equal(SEND_URL, "./api/starport/logs/send")
|
||||
assert.equal(MESSAGE_LIMIT, 60)
|
||||
|
||||
// Status on open, then a send that is polled until it finishes.
|
||||
const normal = fixture()
|
||||
normal.client.start()
|
||||
assert.equal(normal.requests[0].url, SEND_URL)
|
||||
await normal.reply(0, { state: "idle", message: "", available: true })
|
||||
assert.equal(normal.states.at(-1).state, "idle")
|
||||
const sending = normal.client.send(" Black screen ")
|
||||
assert.equal(normal.states.at(-1).state, "sending")
|
||||
assert.equal(normal.requests[1].options.method, "POST")
|
||||
assert.deepEqual(JSON.parse(normal.requests[1].options.body), { message: "Black screen" })
|
||||
await normal.reply(1, { state: "sending", message: "Collecting logs" }, 202)
|
||||
assert.equal(await sending, true)
|
||||
assert.equal(normal.timers.length, 1)
|
||||
normal.timers[0]()
|
||||
await normal.reply(2, { state: "sent", message: "Logs sent. Thanks!" })
|
||||
assert.equal(normal.states.at(-1).state, "sent")
|
||||
assert.equal(normal.timers.length, 1)
|
||||
|
||||
// Too long is refused locally; a busy device reports its error.
|
||||
const limited = fixture()
|
||||
limited.client.start()
|
||||
await limited.reply(0, { state: "idle" })
|
||||
assert.equal(await limited.client.send("x".repeat(61)), false)
|
||||
assert.equal(limited.requests.length, 1)
|
||||
assert.match(limited.states.at(-1).error, /60 characters/)
|
||||
const busy = limited.client.send("hi")
|
||||
await limited.reply(1, { error: "StarPort logs are already being sent." }, 409)
|
||||
assert.equal(await busy, false)
|
||||
assert.equal(limited.states.at(-1).error, "StarPort logs are already being sent.")
|
||||
|
||||
// Signed out.
|
||||
const signedOut = fixture()
|
||||
signedOut.client.start()
|
||||
await signedOut.reply(0, {}, 401)
|
||||
assert.equal(signedOut.unauthorized, 1)
|
||||
assert.equal(await signedOut.client.send("hi"), false)
|
||||
|
||||
// Sending unavailable on this build hides the card.
|
||||
const unset = fixture()
|
||||
unset.client.start()
|
||||
await unset.reply(0, { state: "idle", available: false })
|
||||
assert.equal(unset.states.at(-1).available, false)
|
||||
|
||||
// A lost Bluetooth response retries the status read without repeating the upload.
|
||||
const interrupted = fixture()
|
||||
interrupted.client.start()
|
||||
await interrupted.reply(0, { state: "sending" })
|
||||
interrupted.timers[0]()
|
||||
interrupted.requests[1].reject(new TypeError("Connection lost"))
|
||||
await flush()
|
||||
assert.equal(interrupted.timers.length, 2)
|
||||
interrupted.timers[1]()
|
||||
await interrupted.reply(2, { state: "sent", message: "Logs sent" })
|
||||
assert.equal(interrupted.states.at(-1).state, "sent")
|
||||
assert.ok(interrupted.requests.every(({ options }) => options.method !== "POST"))
|
||||
|
||||
// An older status read cannot replace a send's newer status, even across navigation.
|
||||
const stale = fixture()
|
||||
stale.client.start()
|
||||
const pendingSend = stale.client.send("hello")
|
||||
await stale.reply(1, { state: "sending" }, 202)
|
||||
assert.equal(await pendingSend, true)
|
||||
await stale.reply(0, { state: "idle" })
|
||||
assert.equal(stale.states.at(-1).state, "sending")
|
||||
stale.client.start()
|
||||
stale.client.start()
|
||||
await stale.reply(2, {}, 401)
|
||||
assert.equal(stale.unauthorized, 0)
|
||||
await stale.reply(3, { state: "sent" })
|
||||
assert.equal(stale.states.at(-1).state, "sent")
|
||||
@@ -0,0 +1,215 @@
|
||||
"""Send StarPort Logs: the Discord upload, its per-device thread cache, the background job and its routes."""
|
||||
|
||||
import http.client
|
||||
import json
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
from openpilot.starpilot.galaxy import starport_send
|
||||
from openpilot.starpilot.galaxy.access import GalaxyAccessOwner
|
||||
from openpilot.starpilot.galaxy.server import make_server
|
||||
from openpilot.starpilot.galaxy.starport_send import (DISCORD_FILE_LIMIT, MESSAGE_LIMIT, SendBusy, SendRejected, StarPortLogSender,
|
||||
clean_message, send, thread_title)
|
||||
|
||||
URL = "https://discord.example/api/webhooks/1/token"
|
||||
SUMMARY = {"device_id": "0123456789abcdef", "car_fingerprint": "HONDA_CIVIC_2022", "head_unit": "Honda Display Audio",
|
||||
"branch": "Dom", "commit": "abcdef1234567890"}
|
||||
|
||||
|
||||
class Response:
|
||||
def __init__(self, status_code=200, body=None):
|
||||
self.status_code, self.body = status_code, body if body is not None else {"channel_id": "555"}
|
||||
|
||||
def json(self):
|
||||
return self.body
|
||||
|
||||
|
||||
class Poster:
|
||||
def __init__(self, *responses):
|
||||
self.responses, self.calls = list(responses), []
|
||||
|
||||
def __call__(self, url, data, files, timeout):
|
||||
self.calls.append({"url": url, "payload": json.loads(data["payload_json"]), "files": files})
|
||||
return self.responses.pop(0)
|
||||
|
||||
|
||||
def query(call):
|
||||
return {key: values[0] for key, values in parse_qs(urlsplit(call["url"]).query).items()}
|
||||
|
||||
|
||||
class SendTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
self.threads = Path(temporary.name)
|
||||
|
||||
def send(self, post, note="Dropped at the light", data=b"zip"):
|
||||
return send(data, "logs.zip", note, SUMMARY, url=URL, post=post, thread_dir=self.threads)
|
||||
|
||||
def test_first_send_opens_a_device_thread_and_later_sends_reuse_it(self):
|
||||
first = Poster(Response(body={"channel_id": "555"}))
|
||||
self.assertIsNone(self.send(first))
|
||||
call = first.calls[0]
|
||||
self.assertEqual(call["payload"]["thread_name"], thread_title(SUMMARY))
|
||||
self.assertTrue(call["payload"]["thread_name"].startswith("HONDA_CIVIC_2022 "))
|
||||
self.assertEqual(call["payload"]["allowed_mentions"], {"parse": []})
|
||||
self.assertIn("Note: Dropped at the light", call["payload"]["content"])
|
||||
self.assertIn("Head unit: Honda Display Audio", call["payload"]["content"])
|
||||
self.assertIn("Setup: unknown", call["payload"]["content"]) # no session log recorded a setup in this fixture
|
||||
self.assertIn("Handshake: unknown", call["payload"]["content"])
|
||||
self.assertNotIn(SUMMARY["device_id"], call["payload"]["content"])
|
||||
self.assertEqual(query(call), {"wait": "true"})
|
||||
self.assertEqual(call["files"]["files[0]"], ("logs.zip", b"zip", "application/zip"))
|
||||
self.assertNotIn("token", "".join(path.read_text() for path in self.threads.iterdir()))
|
||||
|
||||
second = Poster(Response())
|
||||
self.assertIsNone(self.send(second))
|
||||
self.assertEqual(query(second.calls[0])["thread_id"], "555")
|
||||
self.assertNotIn("thread_name", second.calls[0]["payload"])
|
||||
|
||||
def test_deleted_thread_is_forgotten_and_a_new_one_opened(self):
|
||||
self.send(Poster(Response(body={"channel_id": "555"})))
|
||||
poster = Poster(Response(404, {"code": 10003}), Response(body={"channel_id": "777"}))
|
||||
self.assertIsNone(self.send(poster))
|
||||
self.assertEqual(query(poster.calls[0])["thread_id"], "555")
|
||||
self.assertNotIn("thread_id", query(poster.calls[1]))
|
||||
self.assertEqual(poster.calls[1]["payload"]["thread_name"], thread_title(SUMMARY))
|
||||
third = Poster(Response())
|
||||
self.send(third)
|
||||
self.assertEqual(query(third.calls[0])["thread_id"], "777")
|
||||
|
||||
def test_refusals_and_oversize_raise_without_posting(self):
|
||||
with self.assertRaisesRegex(SendRejected, "HTTP 401: Invalid Webhook Token"):
|
||||
self.send(Poster(Response(401, {"message": "Invalid Webhook Token"})))
|
||||
poster = Poster()
|
||||
with self.assertRaisesRegex(SendRejected, "10 MB"):
|
||||
self.send(poster, data=b"x" * (DISCORD_FILE_LIMIT + 1))
|
||||
self.assertEqual(poster.calls, [])
|
||||
with self.assertRaisesRegex(SendRejected, "isn't set up"):
|
||||
send(b"zip", "logs.zip", "", SUMMARY, url="", post=poster, thread_dir=self.threads)
|
||||
|
||||
def test_message_is_limited_and_single_line(self):
|
||||
self.assertEqual(MESSAGE_LIMIT, 60)
|
||||
self.assertEqual(clean_message(" car\nwent black "), "car went black")
|
||||
self.assertEqual(clean_message("x" * 60), "x" * 60)
|
||||
for bad in ("x" * 61, None, 5):
|
||||
with self.assertRaises(SendRejected):
|
||||
clean_message(bad)
|
||||
|
||||
def test_built_in_webhook_and_override(self):
|
||||
self.assertTrue(starport_send.webhook_url(self.threads / "absent").startswith("https://discord.com/api/webhooks/"))
|
||||
override = self.threads / "webhook_url"
|
||||
override.write_text(f"{URL}\n")
|
||||
self.assertEqual(starport_send.webhook_url(override), URL)
|
||||
override.write_text("")
|
||||
self.assertEqual(starport_send.webhook_url(override), starport_send.webhook_url(self.threads / "absent"))
|
||||
|
||||
|
||||
class SenderJobTest(unittest.TestCase):
|
||||
def wait(self, job):
|
||||
deadline = time.monotonic() + 2
|
||||
while job.status()["state"] == "sending" and time.monotonic() < deadline:
|
||||
time.sleep(0.01)
|
||||
return job.status()
|
||||
|
||||
def test_sends_in_the_background_and_drops_the_snapshot_when_too_big(self):
|
||||
bundles, sent = [], []
|
||||
|
||||
def bundle(system_snapshot=True):
|
||||
bundles.append(system_snapshot)
|
||||
return "logs.zip", b"x" * (DISCORD_FILE_LIMIT + 1 if system_snapshot else 10)
|
||||
|
||||
job = StarPortLogSender(bundle, sender=lambda *args: sent.append(args), summary=lambda: SUMMARY, url=lambda: URL)
|
||||
self.assertEqual(job.start("hello")["state"], "sending")
|
||||
status = self.wait(job)
|
||||
self.assertEqual(status["state"], "sent")
|
||||
self.assertIn("snapshot was left out", status["message"])
|
||||
self.assertEqual(bundles, [True, False])
|
||||
self.assertEqual(sent[0][2], "hello")
|
||||
|
||||
def test_one_send_at_a_time_and_failures_are_reported(self):
|
||||
release = threading.Event()
|
||||
|
||||
def sender(*_):
|
||||
release.wait(2)
|
||||
raise SendRejected("Discord refused the logs (HTTP 500).")
|
||||
|
||||
job = StarPortLogSender(lambda **_: ("logs.zip", b"zip"), sender=sender, summary=lambda: SUMMARY, url=lambda: URL)
|
||||
job.start("")
|
||||
with self.assertRaises(SendBusy):
|
||||
job.start("again")
|
||||
release.set()
|
||||
self.assertEqual(self.wait(job), {"state": "failed", "message": "Discord refused the logs (HTTP 500).", "available": True,
|
||||
"messageLimit": 60})
|
||||
|
||||
|
||||
class SendHTTPTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
self.owner = GalaxyAccessOwner(Path(temporary.name) / "access")
|
||||
self.job = mock.Mock()
|
||||
self.job.status.return_value = {"state": "idle", "message": "", "available": True, "messageLimit": 60}
|
||||
self.job.start.return_value = {"state": "sending", "message": "Collecting logs", "available": True, "messageLimit": 60}
|
||||
self.server = make_server(port=0, owner=self.owner, starport_log_sender=self.job)
|
||||
self.thread = threading.Thread(target=self.server.serve_forever, kwargs={"poll_interval": 0.01}, daemon=True)
|
||||
self.thread.start()
|
||||
self.addCleanup(self.close_server)
|
||||
self.assertTrue(self.owner.configure("password123", lambda: True))
|
||||
|
||||
def close_server(self):
|
||||
self.server.shutdown()
|
||||
self.thread.join(timeout=2)
|
||||
self.server.server_close()
|
||||
|
||||
def request(self, path, *, method="GET", body=None, headers=None):
|
||||
connection = http.client.HTTPConnection("127.0.0.1", self.server.server_port, timeout=2)
|
||||
try:
|
||||
origin = {"Origin": f"http://127.0.0.1:{self.server.server_port}"} if method == "POST" else {}
|
||||
connection.request(method, path, body=body, headers={"Forwarded": "for=203.0.113.8", **origin, **(headers or {})})
|
||||
response = connection.getresponse()
|
||||
return response.status, response.read(), dict(response.getheaders())
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def post(self, body, cookie=None):
|
||||
headers = {"Content-Type": "application/json", **({"Cookie": cookie} if cookie else {})}
|
||||
return self.request("/api/starport/logs/send", method="POST", body=json.dumps(body), headers=headers)
|
||||
|
||||
def login(self):
|
||||
status, _, headers = self.post_login()
|
||||
self.assertEqual(status, 200)
|
||||
return headers["Set-Cookie"].split(";", 1)[0]
|
||||
|
||||
def post_login(self):
|
||||
return self.request("/api/auth/login", method="POST", body=json.dumps({"password": "password123"}),
|
||||
headers={"Content-Type": "application/json"})
|
||||
|
||||
def test_requires_a_session(self):
|
||||
self.assertEqual(self.request("/api/starport/logs/send")[0], 401)
|
||||
self.assertEqual(self.post({"message": "hi"})[0], 401)
|
||||
self.job.start.assert_not_called()
|
||||
|
||||
def test_status_start_and_rejections(self):
|
||||
cookie = self.login()
|
||||
status, body, _ = self.request("/api/starport/logs/send", headers={"Cookie": cookie})
|
||||
self.assertEqual((status, json.loads(body)["state"]), (200, "idle"))
|
||||
|
||||
status, body, _ = self.post({"message": "Black screen after unlock"}, cookie)
|
||||
self.assertEqual((status, json.loads(body)["state"]), (202, "sending"))
|
||||
self.job.start.assert_called_once_with("Black screen after unlock")
|
||||
|
||||
self.assertEqual(self.post({"message": "hi", "extra": 1}, cookie)[0], 400)
|
||||
self.job.start.side_effect = SendRejected("Keep the message to 60 characters.")
|
||||
self.assertEqual(self.post({"message": "x" * 61}, cookie)[0], 400)
|
||||
self.job.start.side_effect = SendBusy("StarPort logs are already being sent.")
|
||||
self.assertEqual(self.post({"message": "hi"}, cookie)[0], 409)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1060,6 +1060,9 @@ button.gx-chip:hover {
|
||||
.gx-starport-log__download { flex: none; width: var(--touch-target); height: var(--touch-target); border: 1px solid var(--glass-border); background: var(--primary-container); color: var(--on-primary-container); }
|
||||
.gx-starport-log__download:hover { background: var(--primary); color: var(--on-primary); }
|
||||
.gx-starport-logs__section-title { margin: var(--sp-6) 0 var(--sp-3); }
|
||||
.gx-starport-send__row { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-3); margin-top: var(--sp-3); }
|
||||
.gx-starport-send__row .gx-field { flex: 1 1 220px; min-width: 0; }
|
||||
.gx-starport-send__count { font-size: var(--fs-xs); opacity: .7; font-variant-numeric: tabular-nums; }
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.gx-starport-log { gap: var(--sp-3); padding: var(--sp-3); }
|
||||
@@ -1708,6 +1711,9 @@ input[type="checkbox"]:disabled { opacity: .4; cursor: not-allowed; }
|
||||
.gx-inset { margin: var(--sp-4); max-width: calc(100% - 2 * var(--sp-4)); }
|
||||
.gx-text-danger { color: var(--error); }
|
||||
.gx-row.gx-row--borderless { border-top: 0; }
|
||||
/* A section title inside a settings page: a label only, never dimmed like an unavailable control. */
|
||||
.gx-row.gx-row--heading, .gx-row.gx-row--heading.disabled { opacity: 1; padding-top: var(--sp-5); }
|
||||
.gx-row--heading .gx-row__label { font-weight: 700; letter-spacing: .04em; text-transform: uppercase; opacity: .75; }
|
||||
.gx-row--wrap { flex-wrap: wrap; }
|
||||
.gx-row__info.gx-row__info--wide { flex: 1 1 180px; }
|
||||
.gx-filter-field { flex: 1 1 14rem; min-width: 0; }
|
||||
@@ -1995,3 +2001,52 @@ progress { max-width: 100%; accent-color: var(--primary); }
|
||||
}
|
||||
|
||||
.gx-tuner__manual .gx-row { padding-inline: 0; }
|
||||
|
||||
/* Guided setup layout and emphasis; shared rows, buttons, fields and notices keep Galaxy defaults. */
|
||||
.gx-card.gx-guided { display: grid; gap: var(--sp-5); padding: var(--panel-padding); background-image: radial-gradient(120% 38% at 50% 0, var(--primary-container), transparent 72%); }
|
||||
.gx-guided__steps { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(3, 1fr); }
|
||||
.gx-guided__step { position: relative; display: grid; justify-items: center; gap: var(--sp-2); text-align: center; }
|
||||
.gx-guided__step::before, .gx-guided__step::after { content: ""; position: absolute; top: 15px; height: 2px; width: calc(50% - 8px); background: var(--outline); }
|
||||
.gx-guided__step::before { left: 0; }
|
||||
.gx-guided__step::after { right: 0; }
|
||||
.gx-guided__step:first-child::before, .gx-guided__step:last-child::after { display: none; }
|
||||
.gx-guided__step.is-done::before, .gx-guided__step.is-done::after, .gx-guided__step.is-current::before,
|
||||
.gx-guided__step.is-done + .gx-guided__step::before { background: var(--success); }
|
||||
.gx-guided__node { position: relative; display: grid; place-items: center; width: 32px; height: 32px; border-radius: var(--radius-full); background: var(--surface-container-high); color: var(--on-surface-variant); font-weight: var(--fw-bold); }
|
||||
.gx-guided__step.is-current .gx-guided__node { background: var(--primary); color: var(--on-primary); box-shadow: 0 0 0 var(--sp-1) var(--primary-container), 0 var(--sp-1) var(--sp-4) var(--primary-glow); }
|
||||
.gx-guided__step.is-done .gx-guided__node { background: var(--success); color: var(--background); }
|
||||
.gx-guided__step-label, .gx-guided__hint { font-size: var(--fs-sm); color: var(--on-surface-variant); }
|
||||
.gx-guided__header, .gx-guided__section, .gx-guided__body { display: grid; gap: var(--sp-3); min-width: 0; }
|
||||
.gx-guided__header > *, .gx-guided__subhead, .gx-guided__hint { margin: 0; }
|
||||
.gx-guided__header { max-width: 34rem; gap: var(--sp-2); }
|
||||
.gx-guided__title { font-size: clamp(1.625rem, 2.5vw, 1.875rem); line-height: 1.15; letter-spacing: -.02em; text-wrap: balance; }
|
||||
.gx-guided__lede { color: var(--on-surface-variant); font-size: var(--fs-base); line-height: 1.5; }
|
||||
.gx-guided__subhead { font-size: var(--fs-base); color: var(--on-surface-variant); }
|
||||
.gx-guided__body:empty { display: none; }
|
||||
.gx-guided__option { display: flex; align-items: center; gap: var(--sp-3); min-height: var(--touch-target); padding: var(--sp-3) var(--sp-4); cursor: pointer; }
|
||||
.gx-guided__option:hover:not(:has(:disabled)) { background: var(--glass-bg-hover); }
|
||||
.gx-guided__option:has(:checked) { background: var(--primary-container); color: var(--on-primary-container); }
|
||||
.gx-guided__option:has(:disabled), .gx-guided__choice:has(:disabled) { opacity: .6; cursor: default; }
|
||||
.gx-guided__option:focus-within, .gx-guided__choice:focus-within { outline: 2px solid var(--primary); outline-offset: -2px; }
|
||||
.gx-guided__options { background: var(--surface-container); }
|
||||
.gx-guided__options > li + li { border-top: 1px solid var(--outline); }
|
||||
.gx-guided__option input { flex: none; order: 1; width: var(--sp-5); height: var(--sp-5); margin: 0; accent-color: var(--primary); }
|
||||
.gx-guided__option:has(:checked) .gx-guided__tile { background: var(--primary); color: var(--on-primary); }
|
||||
.gx-guided__segmented { display: flex; flex-wrap: wrap; gap: var(--sp-1); padding: var(--sp-1); border-radius: var(--radius-sm); background: var(--surface-container-high); }
|
||||
.gx-guided__choice { flex: 1 1 7rem; min-width: 0; min-height: var(--touch-target); padding: var(--sp-2); border-radius: calc(var(--radius-sm) - var(--sp-1)); text-align: center; text-wrap: balance; box-shadow: none; background: transparent; color: var(--on-surface-variant); }
|
||||
.gx-guided__choice:has(:checked) { background: var(--primary); color: var(--on-primary); }
|
||||
.gx-guided__choice:has(:checked):hover { background: var(--primary-hover); }
|
||||
.gx-guided__tile { flex: none; display: grid; place-items: center; width: 40px; height: 40px; border-radius: var(--radius-sm); background: var(--primary-container); color: var(--primary); font-size: var(--fs-lg); }
|
||||
.gx-guided__mark { flex: none; display: grid; place-items: center; width: 28px; font-size: var(--fs-lg); color: var(--on-surface-variant); }
|
||||
.gx-guided__checklist > li.is-done .gx-guided__mark, .gx-guided__checklist > li.is-worked .gx-guided__mark { color: var(--success); }
|
||||
.gx-guided__checklist > li.is-active .gx-guided__mark, .gx-guided__checklist > li.is-current .gx-guided__mark, .gx-guided__checklist > li.is-retry .gx-guided__mark { color: var(--primary); }
|
||||
.gx-guided__checklist > li.is-failed .gx-guided__mark { color: var(--error); }
|
||||
.gx-guided__callout { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); flex-wrap: wrap; margin: 0; padding: var(--sp-4); border-radius: var(--radius-sm); background: var(--primary-container); }
|
||||
.gx-guided__callout--quiet { background: var(--surface-container); color: var(--on-surface-variant); }
|
||||
.gx-guided__footer { display: grid; gap: var(--sp-3); }
|
||||
.gx-guided .gx-starport-cta { min-height: calc(var(--touch-target) + var(--sp-2)); border-radius: var(--radius-md); font-size: var(--fs-base); }
|
||||
.gx-guided__footer .gx-guided__hint { text-align: center; }
|
||||
.gx-guided__link { justify-self: center; padding: var(--sp-2) var(--sp-3); border: 0; border-radius: var(--radius-sm); background: none; color: var(--primary); font: inherit; cursor: pointer; }
|
||||
.gx-guided__link:hover:not(:disabled) { text-decoration: underline; }
|
||||
.gx-guided__link:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
|
||||
.gx-guided__link:disabled { opacity: .5; cursor: default; }
|
||||
|
||||
@@ -215,14 +215,14 @@ export const GalaxySettingRow = {
|
||||
},
|
||||
beforeUnmount() { this.clearHoldTimer() },
|
||||
template: `
|
||||
<div class="gx-row" :class="{ disabled: dimmed, 'gx-row--stack': control === 'slider' }" :inert="busy || updating" :aria-busy="busy || updating || undefined">
|
||||
<div class="gx-row" :class="{ disabled: dimmed, 'gx-row--stack': control === 'slider', 'gx-row--heading': row.heading }" :role="row.heading ? 'heading' : undefined" :aria-level="row.heading ? 3 : undefined" :inert="busy || updating" :aria-busy="busy || updating || undefined">
|
||||
<div class="gx-row__info">
|
||||
<span class="gx-row__label">{{ row.label }}</span>
|
||||
<span v-if="presentedRow.reason" class="gx-row__desc">{{ ' ' + presentedRow.reason.replace('https://firestar.link/discord', '') }}</span>
|
||||
<span v-if="presentedRow.reason && !row.heading" class="gx-row__desc">{{ ' ' + presentedRow.reason.replace('https://firestar.link/discord', '') }}</span>
|
||||
<a v-if="presentedRow.reason?.includes('https://firestar.link/discord')" class="gx-btn gx-btn--tonal" href="https://firestar.link/discord" target="_blank" rel="noopener">StarPilot Discord</a>
|
||||
<span v-if="control === 'group' || control === 'action'" class="gx-row__desc">{{ presentedRow.value }}</span>
|
||||
</div>
|
||||
<button v-if="control !== 'slider' && showDefault" type="button"
|
||||
<button v-if="control !== 'slider' && showDefault && !row.heading" type="button"
|
||||
class="gx-btn gx-btn--tonal gx-setting-default" :disabled="dimmed || !row.resetAvailable"
|
||||
:aria-label="'Reset ' + row.label + ' to default'" :title="'Default: ' + presentedRow.defaultValue"
|
||||
@click="$emit('reset-default', index)">Default</button>
|
||||
@@ -249,6 +249,6 @@ export const GalaxySettingRow = {
|
||||
</GalaxySelect>
|
||||
<button v-else-if="control === 'group'" type="button" class="gx-btn gx-btn--tonal" :disabled="dimmed" @click="$emit('open', row.page)">Manage</button>
|
||||
<button v-else-if="control === 'action' && row.action" type="button" class="gx-btn gx-btn--tonal" :disabled="dimmed" @click="$emit('review', index, row.confirm ? 0 : 1)">{{ row.repairValue === 'Reset' || /reset|default/i.test(row.label) ? 'Reset' : row.repairValue ? 'Set ' + row.repairValue : row.label }}</button>
|
||||
<span v-else class="gx-row__value gx-row__readout">{{ displayValue }}</span>
|
||||
<span v-else-if="!row.heading" class="gx-row__value gx-row__readout">{{ displayValue }}</span>
|
||||
</div>`,
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { navigate } from "./router.js"
|
||||
import { SystemMonitor } from "./system-monitor.js"
|
||||
import { CrashReportsFeed } from "./crash-reports.js"
|
||||
import { storedSize } from "./record-deletion.js"
|
||||
import { StarPortLogsFeed, BUNDLE_URL, fileUrl, outcomeLabel } from "./starport-logs.js"
|
||||
import { StarPortLogsFeed, StarPortLogSend, BUNDLE_URL, MESSAGE_LIMIT, fileUrl, outcomeLabel } from "./starport-logs.js"
|
||||
import { MenuTile } from "./menu-tile.js"
|
||||
import { BluetoothNotice, bluetoothConnection } from "./bluetooth-notice.js"
|
||||
|
||||
@@ -15,25 +15,27 @@ export const Logs = {
|
||||
props: { path: { type: String, required: true }, mode: { type: String, required: true }, unauthorized: { type: Function, required: true },
|
||||
tools: { type: Array, required: true } },
|
||||
data: () => ({ crashes: { reports: [], scanIncomplete: false, listLimited: false, status: "idle", error: "", selected: null, preview: null, previewStatus: "idle", deleting: false, storageBytes: 0, reportCount: 0 }, search: "",
|
||||
starportLogs: { sessions: [], others: [], status: "idle", error: "" }, bluetooth: bluetoothConnection() }),
|
||||
starportLogs: { sessions: [], others: [], status: "idle", error: "" }, bluetooth: bluetoothConnection(),
|
||||
logSend: { state: "idle", message: "", available: true, error: "" }, sendNote: "", messageLimit: MESSAGE_LIMIT }),
|
||||
computed: {
|
||||
visibleReports() { return this.crashes.reports.filter((item) => item.name.toLowerCase().includes(this.search.toLowerCase())) },
|
||||
},
|
||||
created() {
|
||||
this.crashFeed = new CrashReportsFeed({ publish: (update) => Object.assign(this.crashes, update), unauthorized: this.unauthorized })
|
||||
this.starportFeed = new StarPortLogsFeed({ publish: (update) => Object.assign(this.starportLogs, update), unauthorized: this.unauthorized })
|
||||
this.sendFeed = new StarPortLogSend({ publish: (update) => Object.assign(this.logSend, update), unauthorized: this.unauthorized })
|
||||
},
|
||||
mounted() {
|
||||
if (this.path === "/logs/crashes" && this.mode === "local") this.crashFeed.start()
|
||||
if (this.path === "/logs/starport" && this.mode === "local") this.starportFeed.start()
|
||||
if (this.path === "/logs/starport" && this.mode === "local") { this.starportFeed.start(); this.sendFeed.start() }
|
||||
},
|
||||
beforeUnmount() { this.crashFeed.stop(); this.starportFeed.stop() },
|
||||
beforeUnmount() { this.crashFeed.stop(); this.starportFeed.stop(); this.sendFeed.stop() },
|
||||
watch: {
|
||||
path(next, previous) {
|
||||
if (next === "/logs/crashes" && this.mode === "local") this.crashFeed.start()
|
||||
else if (previous === "/logs/crashes") { this.crashFeed.stop(); this.search = "" }
|
||||
if (next === "/logs/starport" && this.mode === "local") this.starportFeed.start()
|
||||
else if (previous === "/logs/starport") this.starportFeed.stop()
|
||||
if (next === "/logs/starport" && this.mode === "local") { this.starportFeed.start(); this.sendFeed.start() }
|
||||
else if (previous === "/logs/starport") { this.starportFeed.stop(); this.sendFeed.stop() }
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
@@ -42,6 +44,9 @@ export const Logs = {
|
||||
bundleUrl: () => BUNDLE_URL,
|
||||
fileUrl,
|
||||
outcomeLabel,
|
||||
async sendLogs() {
|
||||
if (await this.sendFeed.send(this.sendNote)) this.sendNote = ""
|
||||
},
|
||||
reportDate(seconds) { return new Date(seconds * 1000).toLocaleString() },
|
||||
reportSize(bytes) { return `${(bytes / 1024).toFixed(1)} KiB` },
|
||||
async copyPreview() {
|
||||
@@ -94,6 +99,15 @@ export const Logs = {
|
||||
<p>The last 20 connection sessions, newest first. The archive includes every session, a readable report for each, StarPort settings, the car view's renderer logs, and a snapshot of the device (Wi-Fi, Bluetooth and kernel logs, build, CPU load). Hardware addresses are masked.</p></div></header>
|
||||
<BluetoothNotice v-if="bluetooth" feature="Log downloads">Log summaries remain available.</BluetoothNotice>
|
||||
<div class="gx-settings__controls gx-actions"><a v-if="!bluetooth" class="gx-btn" :href="bundleUrl()" download><i class="bi bi-file-earmark-zip" aria-hidden="true"></i><span>Download all logs</span><span class="gx-starport-logs__format">ZIP</span></a><button type="button" class="gx-btn gx-btn--tonal" @click="starportFeed.load()"><i class="bi bi-arrow-clockwise" aria-hidden="true"></i><span>Refresh</span></button></div>
|
||||
<section v-if="logSend.available" class="gx-card gx-panel gx-starport-send" aria-labelledby="gx-starport-send-title">
|
||||
<div class="gx-section__header"><i class="bi bi-send" aria-hidden="true"></i><span id="gx-starport-send-title" class="gx-section__title">Send logs to the developer</span></div>
|
||||
<p class="gx-note">Sends the same archive as Download all logs straight from the device. Add a short note about what happened.</p>
|
||||
<div class="gx-starport-send__row"><input class="gx-field" type="text" v-model="sendNote" :maxlength="messageLimit" placeholder="What happened? (optional)" aria-label="Note for the developer" :disabled="logSend.state === 'sending'" @keydown.enter="sendLogs"><span class="gx-starport-send__count" aria-live="polite">{{ sendNote.length }}/{{ messageLimit }}</span><button type="button" class="gx-btn" :disabled="logSend.state === 'sending'" @click="sendLogs"><i class="bi bi-send" aria-hidden="true"></i><span>Send logs</span></button></div>
|
||||
<GxState v-if="logSend.state === 'sending'" loading>{{ logSend.message || 'Sending logs' }}…</GxState>
|
||||
<GxNotice v-else-if="logSend.state === 'sent'" tone="success">{{ logSend.message }}</GxNotice>
|
||||
<GxNotice v-else-if="logSend.state === 'failed'" tone="danger">{{ logSend.message }}</GxNotice>
|
||||
<GxNotice v-if="logSend.error" tone="danger">{{ logSend.error }}</GxNotice>
|
||||
</section>
|
||||
|
||||
<GxState v-if="starportLogs.status === 'loading'" loading>Loading StarPort logs…</GxState>
|
||||
<GxNotice v-else-if="starportLogs.status === 'unavailable'" tone="danger">{{ starportLogs.error || 'StarPort logs are unavailable.' }}</GxNotice>
|
||||
|
||||
@@ -304,6 +304,14 @@ export const SettingsPage = {
|
||||
}).filter(({ row }) =>
|
||||
!needle || `${row.label} ${row.value} ${row.reason}`.toLocaleLowerCase().includes(needle))
|
||||
},
|
||||
// A heading row starts its own card; the rows after it belong to that card even when search hides the heading.
|
||||
rowCards() {
|
||||
const titles = []
|
||||
const owner = (this.state.data?.rows || []).map((row) => (row.heading ? titles.push(row.label) : titles.length))
|
||||
const cards = [{ title: "", entries: [] }, ...titles.map((title) => ({ title, entries: [] }))]
|
||||
for (const entry of this.visibleRows) if (!entry.row.heading) cards[owner[entry.index] || 0].entries.push(entry)
|
||||
return cards.filter((card, index) => !index || card.entries.length)
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
"state.pending"() { this.flushSoundChoices() },
|
||||
@@ -449,15 +457,16 @@ export const SettingsPage = {
|
||||
<div v-if="state.data.page === 'pip'" class="gx-settings__actions gx-actions">
|
||||
<button type="button" class="gx-btn gx-btn--tonal" @click="openCropEditor"><i class="bi bi-crop" aria-hidden="true"></i> Crop editor</button>
|
||||
</div>
|
||||
<section class="gx-card gx-settings__section">
|
||||
<div class="gx-section__header"><i class="bi" :class="activeSection.icon" aria-hidden="true"></i><span class="gx-section__title">{{ state.data.page === 'hub' ? activeSection.label : state.data.title }}</span>
|
||||
<section v-for="(card, cardIndex) in rowCards" :key="card.title || 'main'" class="gx-card gx-settings__section">
|
||||
<div v-if="cardIndex" class="gx-section__header"><i class="bi bi-car-front" aria-hidden="true"></i><span class="gx-section__title">{{ card.title }}</span></div>
|
||||
<div v-else class="gx-section__header"><i class="bi" :class="activeSection.icon" aria-hidden="true"></i><span class="gx-section__title">{{ state.data.page === 'hub' ? activeSection.label : state.data.title }}</span>
|
||||
<button v-if="state.data.page !== 'hub' && state.data.rows.length" type="button" class="gx-icon-btn" :aria-label="state.searchOpen ? 'Close page search' : 'Search within this page'" :aria-pressed="state.searchOpen" @click="togglePageSearch"><i class="bi" :class="state.searchOpen ? 'bi-x-lg' : 'bi-search'" aria-hidden="true"></i></button>
|
||||
</div>
|
||||
<div v-if="state.searchOpen" class="gx-settings__search"><input ref="pageSearch" v-model="state.query" class="gx-field" type="search" aria-label="Search within this page" placeholder="Search within…"></div>
|
||||
<GalaxySettingRow v-for="{ row, index } in visibleRows" :key="rowKey(row, index)" :row="row" :index="index"
|
||||
<div v-if="state.searchOpen && !cardIndex" class="gx-settings__search"><input ref="pageSearch" v-model="state.query" class="gx-field" type="search" aria-label="Search within this page" placeholder="Search within…"></div>
|
||||
<GalaxySettingRow v-for="{ row, index } in card.entries" :key="rowKey(row, index)" :row="row" :index="index"
|
||||
:busy="busy" :disabled="!!state.error || !row.available" :save-value="(index, value) => feed.previewValue(index, value)"
|
||||
@open="open" @review="(index, direction) => feed.preview(index, direction)" @reset-default="index => feed.resetDefault(index)" />
|
||||
<div v-if="!visibleRows.length" class="gx-empty">No matching settings.</div>
|
||||
<div v-if="!cardIndex && !visibleRows.length" class="gx-empty">No matching settings.</div>
|
||||
</section>
|
||||
<SoundPacks v-if="state.data.page === 'sounds'" :unauthorized="unauthorized" :busy="busy" @installed="refreshSoundChoices" />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
// Guided setup shares the page’s pairing actions and slots.
|
||||
|
||||
export const FIRST_RUN_NOTICE = "The first connection can take a few minutes, and sometimes more than one drive, while we find what your car accepts. Keep the car on and its screen awake. Later connections are much faster because we remember what worked."
|
||||
export const EXHAUSTED_NOTICE = "We tried every option we have this drive. We keep what works best and will try again the next time the car starts."
|
||||
|
||||
// Adapter setup may require connecting the companion car first.
|
||||
export const BLUETOOTH_FIRST = /connect your paired car in galaxy bluetooth first/i
|
||||
export const BLUETOOTH_HINT = { text: "Connect your car in Bluetooth first, then come back and tap Connect.", href: "#/bluetooth", label: "Open Bluetooth" }
|
||||
|
||||
const CHOICES = [
|
||||
{ key: "builtin", title: "Built-in wireless", detail: "Your car has wireless Android Auto.", icon: "car-front" },
|
||||
{ key: "adapter", title: "Wireless adapter", detail: "AAWireless, Motorola MA1 or similar, plugged into the car.", icon: "usb-symbol" },
|
||||
{ key: "usb", title: "USB cable", detail: "A data cable from the car to your comma.", icon: "usb-c" },
|
||||
{ key: "app", title: "Tablet app", detail: "An iPad or Android tablet shows the dashboard.", icon: "tablet" },
|
||||
]
|
||||
|
||||
const INSTRUCTIONS = {
|
||||
builtin: "Open your car’s phone pairing screen, then tap Find car below.",
|
||||
adapter: "Choose your adapter, put it in pairing mode, then tap Find adapter.",
|
||||
usb: "Plug the car’s Android Auto USB data port into the comma, then tap Connect.",
|
||||
app: "Choose your app, open it on your tablet on the same Wi-Fi, then tap Connect.",
|
||||
}
|
||||
|
||||
const CAR_STEP_LABEL = { builtin: "Car", adapter: "Adapter", usb: "Cable", app: "App" }
|
||||
|
||||
const STAGES = {
|
||||
wireless: [
|
||||
{ id: "bluetooth", label: "Reach the car over Bluetooth", states: ["connecting_bluetooth", "discovering", "rfcomm"] },
|
||||
{ id: "wifi", label: "Join the car’s Wi-Fi", states: ["wifi_start", "wifi_info", "joining_wifi"] },
|
||||
{ id: "secure", label: "Secure the connection", states: ["connecting_tcp", "authenticating"] },
|
||||
{ id: "dashboard", label: "Bring up your dashboard", states: ["negotiating", "streaming", "suspended"] },
|
||||
],
|
||||
wired: [
|
||||
{ id: "cable", label: "Wait for the USB cable", states: ["waiting_for_usb", "usb_accessory"] },
|
||||
{ id: "secure", label: "Secure the connection", states: ["connecting_tcp", "authenticating"] },
|
||||
{ id: "dashboard", label: "Bring up your dashboard", states: ["negotiating", "streaming", "suspended"] },
|
||||
],
|
||||
app: [
|
||||
{ id: "app", label: "Wait for your app", states: ["waiting_for_ipad", "waiting_for_tablet"] },
|
||||
{ id: "secure", label: "Secure the connection", states: ["connecting_tcp", "authenticating"] },
|
||||
{ id: "dashboard", label: "Bring up your dashboard", states: ["negotiating", "streaming", "suspended"] },
|
||||
],
|
||||
}
|
||||
|
||||
const CONNECTING = new Set(["connecting_bluetooth", "discovering", "rfcomm", "wifi_start", "wifi_info", "joining_wifi", "connecting_tcp",
|
||||
"authenticating", "negotiating", "waiting_for_usb", "usb_accessory", "waiting_for_ipad", "waiting_for_tablet", "backoff"])
|
||||
|
||||
export function connectionStages(runtime) {
|
||||
const kind = runtime?.connection === "wired" ? "wired" : ["headunitpad", "openheadunit"].includes(runtime?.connection) ? "app" : "wireless"
|
||||
const stages = STAGES[kind]
|
||||
// While retrying the state is "backoff"; the stage that failed is the one to show as still working.
|
||||
const at = runtime?.state === "backoff" ? runtime.last_stage : runtime?.state
|
||||
const current = Math.max(0, stages.findIndex((stage) => stage.states.includes(at)))
|
||||
const live = runtime?.state === "streaming"
|
||||
return stages.map((stage, index) => ({
|
||||
id: stage.id, label: stage.label,
|
||||
state: live || index < current ? "done" : index === current ? (runtime?.state === "backoff" ? "retry" : "active") : "waiting",
|
||||
}))
|
||||
}
|
||||
|
||||
// Only worth showing once a handshake option has been tried and failed, or one is remembered for this car.
|
||||
export function handshakeSteps(ladder) {
|
||||
const steps = Array.isArray(ladder?.steps) ? ladder.steps.filter((step) => step && typeof step.label === "string") : []
|
||||
const interesting = ladder?.learned || steps.some((step) => step.state === "failed" || step.state === "worked")
|
||||
return interesting ? steps.map((step) => ({ id: String(step.id), label: step.label, state: String(step.state) })) : []
|
||||
}
|
||||
|
||||
// The three steps always shown at the top: the package, the car (choice and pairing), and the connection.
|
||||
export function setupSteps({ installed, carDone, live, carType }) {
|
||||
const state = (done, current) => (done ? "done" : current ? "current" : "todo")
|
||||
return [
|
||||
{ id: "support", label: "Support", state: state(installed, !installed) },
|
||||
{ id: "car", label: CAR_STEP_LABEL[carType] || "Car", state: state(installed && carDone, installed && !carDone) },
|
||||
{ id: "connect", label: "Connect", state: state(live, installed && carDone && !live) },
|
||||
]
|
||||
}
|
||||
|
||||
function options(models, chosen) {
|
||||
return Object.entries(models || {}).map(([key, label]) => ({ key, label: String(label), selected: key === chosen }))
|
||||
}
|
||||
|
||||
export function guidedState({ setup = null, runtime = null, selected = null, receivers = [], wired = false, app = "", carType = "builtin",
|
||||
setupDone = false, pairingActive = false, editing = false, adapterModels = {}, adapterKey = "", appModels = {} } = {}) {
|
||||
const ladder = runtime?.setup_ladder || null
|
||||
const state = runtime?.state || "idle"
|
||||
const connecting = !!runtime?.running && CONNECTING.has(state)
|
||||
const installed = !!setup?.identity?.installed
|
||||
const steps = setupSteps({ installed, carDone: setupDone, live: state === "streaming", carType })
|
||||
const base = { show: false, phase: "live", title: "", detail: "", choices: [], carType, candidates: [], progress: [], handshake: [],
|
||||
notice: "", exhausted: false, hint: null, steps, adapter: null, apps: null, editing: false }
|
||||
if (setup === null) return base
|
||||
const hint = BLUETOOTH_FIRST.test(String(runtime?.error || "")) ? BLUETOOTH_HINT : null
|
||||
if (connecting) {
|
||||
const firstRun = !ladder?.learned && !(ladder?.successes > 0) && (runtime?.connection || "wireless") === "wireless"
|
||||
return { ...base, show: true, phase: "connecting", title: runtime?.label || "Connecting to your car",
|
||||
detail: runtime?.detail || "Keep your car’s screen on. We’ll move to the next step automatically.",
|
||||
progress: connectionStages(runtime), handshake: handshakeSteps(ladder), exhausted: !!ladder?.exhausted,
|
||||
notice: ladder?.exhausted ? EXHAUSTED_NOTICE : firstRun ? FIRST_RUN_NOTICE : "", hint }
|
||||
}
|
||||
if (setupDone && !editing) {
|
||||
// A finished setup shows nothing, except a refusal that is the one thing to act on.
|
||||
return hint ? { ...base, show: true, phase: "bluetooth", title: "Your car needs to be connected in Bluetooth first", detail: hint.text, hint } : base
|
||||
}
|
||||
if (!installed) {
|
||||
return { ...base, show: true, phase: "package", title: "Add your Android Auto package",
|
||||
detail: "Your car only talks to devices that present Google’s Android Auto certificate. Follow the support step above, then come back here." }
|
||||
}
|
||||
const choices = CHOICES.map((choice) => ({ ...choice, selected: choice.key === carType }))
|
||||
const known = new Set([...(selected ? [selected.address] : []), ...(Array.isArray(receivers) ? receivers : []).map((car) => car?.address)])
|
||||
const candidates = []
|
||||
// Paired cars the page's own list does not show yet; the page lists the rest itself in the pairing step.
|
||||
for (const car of Array.isArray(runtime?.car_candidates) ? runtime.car_candidates : []) {
|
||||
if (car && typeof car.address === "string" && typeof car.name === "string" && !known.has(car.address)) {
|
||||
known.add(car.address)
|
||||
candidates.push({ address: car.address, name: car.name })
|
||||
}
|
||||
}
|
||||
const pairs = carType === "builtin" || carType === "adapter"
|
||||
return { ...base, show: true, phase: "setup", title: editing ? "Change how your car connects" : "Set up your car",
|
||||
detail: INSTRUCTIONS[carType] || INSTRUCTIONS.builtin, choices, candidates: pairs && !pairingActive ? candidates : [], hint, editing,
|
||||
adapter: carType === "adapter" ? { options: options(adapterModels, adapterKey), needsChoice: !adapterKey } : null,
|
||||
apps: carType === "app" ? { options: options(appModels, Object.keys(appModels).find((key) => appModels[key] === app) || ""), needsChoice: !app } : null }
|
||||
}
|
||||
|
||||
export const StarPortGuidedSetup = {
|
||||
props: { state: { type: Object, required: true }, busy: { type: Boolean, default: false },
|
||||
choicesDisabled: { type: Boolean, default: false } },
|
||||
emits: ["choose-type", "choose-adapter", "choose-app", "use-car", "classic", "done"],
|
||||
template: `
|
||||
<section class="gx-card gx-guided" :data-phase="state.phase" aria-label="Guided StarPort setup">
|
||||
<ol class="gx-guided__steps" aria-label="Setup progress">
|
||||
<li v-for="(step, index) in state.steps" :key="step.id" class="gx-guided__step" :class="'is-' + step.state"
|
||||
:aria-current="step.state === 'current' ? 'step' : undefined">
|
||||
<span class="gx-guided__node" aria-hidden="true"><i v-if="step.state === 'done'" class="bi bi-check-lg"></i><template v-else>{{ index + 1 }}</template></span>
|
||||
<span class="gx-guided__step-label">{{ step.label }}<span class="gx-sr-only">{{ step.state === 'done' ? ', done' : step.state === 'current' ? ', current step' : '' }}</span></span>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<header class="gx-guided__header">
|
||||
<h2 class="gx-guided__title">{{ state.title }}</h2>
|
||||
<p class="gx-guided__lede">{{ state.detail }}</p>
|
||||
</header>
|
||||
|
||||
<div v-if="state.choices.length" class="gx-guided__section">
|
||||
<ul class="gx-starport-list gx-guided__options" role="radiogroup" aria-label="How your car connects">
|
||||
<li v-for="choice in state.choices" :key="choice.key" role="none">
|
||||
<label class="gx-guided__option">
|
||||
<input type="radio" name="starport-connection" :checked="choice.selected" :disabled="busy || choicesDisabled" @change="$emit('choose-type', choice.key)">
|
||||
<span class="gx-guided__tile" aria-hidden="true"><i :class="'bi bi-' + choice.icon"></i></span>
|
||||
<span class="gx-starport-row__text"><strong>{{ choice.title }}</strong><small>{{ choice.detail }}</small></span>
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="state.adapter" class="gx-guided__section">
|
||||
<h3 class="gx-guided__subhead">Which adapter?</h3>
|
||||
<div class="gx-guided__segmented" role="radiogroup" aria-label="Adapter model">
|
||||
<label v-for="option in state.adapter.options" :key="option.key" class="gx-btn gx-btn--tonal gx-guided__choice">
|
||||
<input class="gx-sr-only" type="radio" name="starport-adapter" :checked="option.selected" :disabled="busy || choicesDisabled" @change="$emit('choose-adapter', option.key)">{{ option.label }}
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="state.adapter.needsChoice" class="gx-guided__hint">Choose your adapter to continue.</p>
|
||||
</div>
|
||||
|
||||
<div v-if="state.apps" class="gx-guided__section">
|
||||
<h3 class="gx-guided__subhead">Which app?</h3>
|
||||
<div class="gx-guided__segmented" role="radiogroup" aria-label="Tablet app">
|
||||
<label v-for="option in state.apps.options" :key="option.key" class="gx-btn gx-btn--tonal gx-guided__choice">
|
||||
<input class="gx-sr-only" type="radio" name="starport-apps" :checked="option.selected" :disabled="busy || choicesDisabled" @change="$emit('choose-app', option.key)">{{ option.label }}
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="state.apps.needsChoice" class="gx-guided__hint">Choose your app to continue.</p>
|
||||
</div>
|
||||
|
||||
<div v-if="state.candidates.length" class="gx-guided__section">
|
||||
<h3 class="gx-guided__subhead">Paired cars</h3>
|
||||
<ul class="gx-starport-list gx-guided__cars" aria-label="Paired cars that offer Android Auto">
|
||||
<li v-for="car in state.candidates" :key="car.address" class="gx-starport-row">
|
||||
<span class="gx-guided__tile" aria-hidden="true"><i class="bi bi-car-front"></i></span>
|
||||
<span class="gx-starport-row__text"><strong>{{ car.name }}</strong><small>Paired and offers Android Auto</small></span>
|
||||
<button type="button" class="gx-btn gx-btn--tonal" :disabled="busy || choicesDisabled" @click="$emit('use-car', car.address)">Use</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="state.hint" class="gx-guided__callout" role="status">
|
||||
<span>{{ state.hint.text }}</span>
|
||||
<a class="gx-btn gx-btn--tonal" :href="state.hint.href">{{ state.hint.label }}</a>
|
||||
</div>
|
||||
|
||||
<div v-if="state.progress.length" class="gx-guided__section">
|
||||
<ol class="gx-starport-list gx-guided__checklist" aria-label="Connection progress">
|
||||
<li v-for="step in state.progress" :key="step.id" class="gx-starport-row" :class="'is-' + step.state" :aria-current="step.state === 'active' || step.state === 'retry' ? 'step' : undefined">
|
||||
<span class="gx-guided__mark" aria-hidden="true">
|
||||
<i v-if="step.state === 'done'" class="bi bi-check-circle-fill"></i>
|
||||
<span v-else-if="step.state === 'active'" class="gx-spinner"></span>
|
||||
<i v-else-if="step.state === 'retry'" class="bi bi-arrow-repeat"></i>
|
||||
<i v-else class="bi bi-circle"></i>
|
||||
</span>
|
||||
<span class="gx-starport-row__text"><strong>{{ step.label }}</strong><small v-if="step.state === 'retry'">Trying again</small></span>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<details v-if="state.handshake.length" class="gx-starport-wiki" open>
|
||||
<summary>What we are trying</summary>
|
||||
<ol class="gx-starport-list gx-guided__checklist" aria-label="Handshake options">
|
||||
<li v-for="step in state.handshake" :key="step.id" class="gx-starport-row" :class="'is-' + step.state">
|
||||
<span class="gx-guided__mark" aria-hidden="true">
|
||||
<i v-if="step.state === 'worked'" class="bi bi-check-circle-fill"></i>
|
||||
<i v-else-if="step.state === 'failed'" class="bi bi-x-circle"></i>
|
||||
<i v-else-if="step.state === 'skipped'" class="bi bi-dash-circle"></i>
|
||||
<span v-else-if="step.state === 'current'" class="gx-spinner"></span>
|
||||
<i v-else class="bi bi-circle"></i>
|
||||
</span>
|
||||
<span class="gx-starport-row__text"><strong>{{ step.label }}</strong></span>
|
||||
</li>
|
||||
</ol>
|
||||
</details>
|
||||
|
||||
<p v-if="state.notice" class="gx-guided__callout gx-guided__callout--quiet" role="status">{{ state.notice }}</p>
|
||||
|
||||
<div v-if="state.phase === 'setup'" class="gx-guided__body"><slot name="car" /></div>
|
||||
|
||||
<div class="gx-guided__footer">
|
||||
<slot name="connect" />
|
||||
<button v-if="state.editing" type="button" class="gx-guided__link" :disabled="busy" @click="$emit('done')">Done</button>
|
||||
<button type="button" class="gx-guided__link" :disabled="busy" @click="$emit('classic')">Use classic setup</button>
|
||||
</div>
|
||||
</section>`,
|
||||
}
|
||||
@@ -68,3 +68,94 @@ export class StarPortLogsFeed {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const SEND_URL = `${LOGS_URL}/send`
|
||||
export const MESSAGE_LIMIT = 60
|
||||
const POLL_MS = 1500
|
||||
|
||||
// "Send logs": the device builds the same zip as Download all logs and posts it to the developer's Discord.
|
||||
// Only small JSON crosses the connection, so this also works over Bluetooth.
|
||||
export class StarPortLogSend {
|
||||
constructor({ publish, unauthorized = () => {}, fetcher = (...args) => fetch(...args), schedule = (fn, ms) => setTimeout(fn, ms),
|
||||
cancel = (id) => clearTimeout(id) }) {
|
||||
Object.assign(this, { publish, unauthorized, fetcher, schedule, cancel })
|
||||
this.active = false
|
||||
this.timer = null
|
||||
this.generation = 0
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.generation++
|
||||
this.active = false
|
||||
if (this.timer !== null) this.cancel(this.timer)
|
||||
this.timer = null
|
||||
}
|
||||
|
||||
start() {
|
||||
this.stop()
|
||||
this.active = true
|
||||
return this.poll()
|
||||
}
|
||||
|
||||
async call(options, generation) {
|
||||
const response = await this.fetcher(SEND_URL, { cache: "no-store", credentials: "same-origin", ...options })
|
||||
if (!this.active || generation !== this.generation) return null
|
||||
if (response.status === 401) {
|
||||
this.stop()
|
||||
this.unauthorized()
|
||||
return null
|
||||
}
|
||||
const body = await response.json().catch(() => null)
|
||||
if (!this.active || generation !== this.generation) return null
|
||||
if (!response.ok) throw new Error(body?.error || "Sending StarPort logs is unavailable")
|
||||
return body
|
||||
}
|
||||
|
||||
apply(body) {
|
||||
if (!this.active || !body) return
|
||||
this.publish({ state: body.state, message: body.message || "", available: body.available !== false, error: "" })
|
||||
this.queuePoll(body.state === "sending")
|
||||
}
|
||||
|
||||
queuePoll(pending = true) {
|
||||
if (this.timer !== null) this.cancel(this.timer)
|
||||
this.timer = this.active && pending ? this.schedule(() => { this.timer = null; this.poll() }, POLL_MS) : null
|
||||
}
|
||||
|
||||
async poll() {
|
||||
if (!this.active) return
|
||||
const generation = this.generation
|
||||
try {
|
||||
this.apply(await this.call({}, generation))
|
||||
} catch (error) {
|
||||
if (this.active && generation === this.generation) {
|
||||
this.publish({ error: error?.message || "Sending StarPort logs is unavailable" })
|
||||
this.queuePoll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async send(message) {
|
||||
if (!this.active) return false
|
||||
const text = String(message ?? "").trim()
|
||||
if (text.length > MESSAGE_LIMIT) {
|
||||
this.publish({ error: `Keep the message to ${MESSAGE_LIMIT} characters.` })
|
||||
return false
|
||||
}
|
||||
const generation = ++this.generation
|
||||
this.queuePoll(false)
|
||||
this.publish({ state: "sending", message: "Starting", error: "" })
|
||||
try {
|
||||
const body = await this.call({ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message: text }) }, generation)
|
||||
if (body === null) return false
|
||||
this.apply(body)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (this.active && generation === this.generation) {
|
||||
this.publish({ state: "idle", error: error?.message || "Sending StarPort logs is unavailable" })
|
||||
this.queuePoll()
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { connectionError } from "./polling.js"
|
||||
import { BluetoothDeviceList } from "./bluetooth-devices.js"
|
||||
import { BluetoothNotice, bluetoothConnection } from "./bluetooth-notice.js"
|
||||
import { GalaxySettingRow, switchRow } from "./galaxy-setting-row.js"
|
||||
import { StarPortGuidedSetup, guidedState } from "./starport-guided.js"
|
||||
|
||||
export function uploadTimeout(bytes) {
|
||||
return Math.max(180000, Math.ceil(bytes / (64 * 1024)) * 1000)
|
||||
@@ -44,7 +45,7 @@ export function uploadPackage(path, options, progress, makeRequest = () => new X
|
||||
}
|
||||
|
||||
const ADDRESS = /^(?:[0-9A-F]{2}:){5}[0-9A-F]{2}$/
|
||||
const ADAPTER_MODELS = { aawireless: "AAWireless", default: "Other" }
|
||||
const ADAPTER_MODELS = { aawireless: "AAWireless", default: "Motorola MA1", unofficial: "Unofficial dongle" }
|
||||
const HEAD_UNIT_APPS = { headunitpad: "HeadunitPad", openheadunit: "Open Headunit" }
|
||||
const HOTSPOT_LINK = `<a class="gx-link" href="#/galaxy">Galaxy Wi-Fi Hotspot</a>`
|
||||
const WIRED_STATUS = `<div v-if="wired" class="gx-note">
|
||||
@@ -89,6 +90,82 @@ const APP_SETUP = `
|
||||
<li>Tap Connect here, then tap the WiFi button in Open Headunit.</li>
|
||||
</ol>
|
||||
</div>`
|
||||
const PAIR_UI = `
|
||||
<ol v-if="adapterSetup" class="gx-starport-list" aria-label="Adapter pairing">
|
||||
<li class="gx-starport-row">
|
||||
<i class="bi bi-usb-symbol" aria-hidden="true"></i>
|
||||
<span class="gx-starport-row__text"><strong>Adapter</strong><small>{{ selected ? selected.name : 'Put it in pairing mode, then tap Find adapter.' }}</small></span>
|
||||
<span class="gx-starport-pill" :class="selected ? 'gx-starport-pill--ready' : ''">{{ selected ? 'Paired' : 'Not paired' }}</span>
|
||||
</li>
|
||||
</ol>
|
||||
<GxNotice v-if="!pairing?.active && runtime?.pairing_note" tone="warn">{{ runtime.pairing_note }}</GxNotice>
|
||||
<ul v-if="!pairing?.active && receivers.length" class="gx-starport-list" aria-label="Paired cars">
|
||||
<li v-for="car in receivers" :key="car.address" class="gx-starport-row">
|
||||
<i class="bi bi-car-front" aria-hidden="true"></i>
|
||||
<span class="gx-starport-row__text"><strong>{{ car.name }}</strong><small>{{ selected?.address === car.address ? 'Used for Android Auto' : 'Paired' }}</small></span>
|
||||
<span v-if="selected?.address === car.address" class="gx-starport-pill gx-starport-pill--ready"><i class="bi bi-check-lg"></i> Selected</span>
|
||||
<button v-else class="gx-btn gx-btn--tonal" :disabled="!setup.parked || linked" @click="useCar(car.address)">Use</button>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else-if="!pairing?.active && selected" class="gx-starport-row"><i class="bi bi-car-front" aria-hidden="true"></i><span class="gx-starport-row__text"><strong>{{ selected.name }}</strong><small>Used for Android Auto</small></span></p>
|
||||
<p v-if="!pairing?.active && endReason === 'canceled'" class="gx-note" role="status">Pairing canceled. Start again when your {{ deviceWord }} is ready.</p>
|
||||
<p v-else-if="!pairing?.active && endReason" class="gx-note" role="status">The pairing window ended without choosing a {{ deviceWord }}. Try again when your {{ adapterSetup ? 'adapter is in pairing mode' : 'car’s pairing screen is open' }}.</p>
|
||||
<ol v-if="showPairSteps" class="gx-starport-list" aria-label="Pairing progress">
|
||||
<li v-for="step in pairSteps" :key="step.label" class="gx-starport-row" :class="'gx-starport-row--' + step.status" :aria-current="step.status === 'active' ? 'step' : undefined">
|
||||
<i v-if="step.status === 'done'" class="bi bi-check-circle-fill gx-starport-ok" aria-hidden="true"></i>
|
||||
<span v-else-if="step.status === 'active'" class="gx-spinner gx-starport-orbit gx-starport-orbit--small" aria-hidden="true"></span>
|
||||
<i v-else class="bi bi-circle" aria-hidden="true"></i>
|
||||
<span class="gx-starport-row__text"><strong>{{ step.label }}</strong></span>
|
||||
</li>
|
||||
</ol>
|
||||
<p v-if="waitingForCar" role="status">{{ pairing.discovering ? "Searching for your " + deviceWord + "…" : "Choose your " + deviceWord + " below." }}</p>
|
||||
<p v-else-if="pairing?.active && pairing.approved && (!pairing.state || pairing.state === 'idle')" role="status">Approved {{ pairing.receiver?.name }}. Finishing Bluetooth pairing…</p>
|
||||
<p v-if="pairing?.state === 'pairing' && !pairing.prompt && !pairing.approved" role="status">Pairing with {{ pairing.receiver?.name || 'the selected device' }}…</p>
|
||||
<p v-if="pairing?.state === 'connecting'" role="status">Connecting to {{ pairing.receiver?.name || 'the selected device' }}…</p>
|
||||
<p v-if="['paired', 'connected'].includes(pairing?.state)" role="status"><i class="bi bi-check-circle-fill gx-starport-ok"></i> {{ pairing.receiver?.name || 'Device' }} {{ pairing.state === 'connected' ? 'connected' : 'paired' }}.</p>
|
||||
<GxNotice tone="danger" v-if="pairing?.error">{{ pairing.error }}</GxNotice>
|
||||
<ul v-if="pairing?.active && !deviceChosen" class="gx-starport-list" aria-label="Nearby devices">
|
||||
<li v-if="!pairing.devices?.length && waitingForCar" class="gx-starport-row gx-note" role="status"><span class="gx-spinner" aria-hidden="true"></span>Nothing found yet. {{ adapterSetup ? 'Keep your adapter in pairing mode.' : 'Keep your car’s pairing screen open.' }}</li>
|
||||
<li v-for="device in pairing.devices || []" :key="device.address" class="gx-starport-row">
|
||||
<i :class="device.starport ? 'bi bi-car-front' : 'bi bi-bluetooth'" aria-hidden="true"></i>
|
||||
<span class="gx-starport-row__text"><strong>{{ device.name }}</strong><small>{{ device.starport ? 'StarPort ready' : 'Bluetooth device' }} · {{ device.connected ? 'Connected' : device.paired ? 'Paired before' : 'Nearby' }}</small></span>
|
||||
<button class="gx-btn gx-btn--tonal" :disabled="deviceSelectionBlocked" @click="selectDevice(device.address)">{{ device.paired ? 'Connect' : 'Pair' }}</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-if="pairing?.prompt" ref="pairPrompt" class="gx-starport-prompt" role="status"><strong>{{ pairing.receiver.name }}</strong>
|
||||
<p>{{ pairing.prompt.kind === 'confirmation' ? 'Check that this code matches your ' + deviceWord + ', then confirm.' :
|
||||
pairing.prompt.kind === 'pin' ? 'Enter the PIN shown by your ' + deviceWord + '.' :
|
||||
pairing.prompt.kind === 'passkey' ? 'Enter your ' + deviceWord + '’s passkey.' :
|
||||
pairing.prompt.kind === 'authorization' ? 'Allow this ' + deviceWord + ' to pair?' : 'Enter this code in your ' + deviceWord + '.' }}</p>
|
||||
<strong v-if="pairing.prompt.value" class="gx-starport-prompt__code">{{ pairing.prompt.value }}</strong>
|
||||
<input v-if="['pin','passkey'].includes(pairing.prompt.kind)" class="gx-field" type="text" maxlength="16" autocomplete="off"
|
||||
:inputmode="pairing.prompt.kind === 'passkey' ? 'numeric' : 'text'" :aria-label="pairing.prompt.kind === 'pin' ? (adapterSetup ? 'Adapter' : 'Car') + ' PIN' : (adapterSetup ? 'Adapter' : 'Car') + ' passkey'" v-model="pairValue" />
|
||||
<div v-if="!pairing.prompt.displayOnly" class="gx-driving__actions gx-actions">
|
||||
<button class="gx-btn gx-btn--tonal" @click="respond(false)">Reject</button>
|
||||
<button class="gx-btn" :disabled="!canRespond" @click="respond(true)">{{ pairing.prompt.kind === 'pin' ? 'Send PIN' : pairing.prompt.kind === 'passkey' ? 'Send Passkey' : 'Confirm' }}</button></div>
|
||||
</div>
|
||||
<p v-if="pairWhenReady" class="gx-note" role="status"><span class="gx-spinner" aria-hidden="true"></span> Turning on Android Auto, then searching…</p>
|
||||
<p v-else-if="!pairing?.active && pairingReason" class="gx-note" role="status">{{ pairingReason }}</p>
|
||||
<div class="gx-driving__actions gx-actions">
|
||||
<button v-if="!pairing?.active" class="gx-btn" :class="{ 'gx-btn--tonal': !!selected }" :disabled="pairWhenReady || !!pairingReason" @click="beginPairing"><span v-if="starting" class="gx-spinner" aria-hidden="true"></span><i v-else class="bi bi-search"></i> {{ starting ? 'Starting search…' : selected ? 'Pair another ' + findLabel : 'Find ' + findLabel }}</button>
|
||||
<button v-else class="gx-btn gx-btn--tonal" @click="cancelPairing">Cancel Search / Pairing</button>
|
||||
</div>
|
||||
<details class="gx-starport-wiki"><summary>Stuck pairing? Pairing order and wireless adapters</summary>
|
||||
<ul>
|
||||
<li><strong>Keep this page open while pairing</strong> so the car’s PIN or code doesn’t time out.</li>
|
||||
<li><strong>Cars with built-in wireless Android Auto:</strong> open the car’s phone pairing screen, then tap <strong>Find car</strong> above. Choose your car, then confirm the code on both screens.</li>
|
||||
<li><strong>Wireless adapter</strong> (AAWireless or Motorola MA1): choose Wireless adapter above, put the adapter in pairing mode and pair it here Your car doesn’t need to be paired. Adapter names often look like AndroidAuto-XXXX.</li>
|
||||
</ul>
|
||||
<p><strong>Start fresh:</strong></p>
|
||||
<ol>
|
||||
<li>Forget the car on the comma: Bluetooth under Tools → Forget.</li>
|
||||
<li>Delete the comma from the car’s Bluetooth list.</li>
|
||||
<li>Turn the car off and on (or unplug the adapter for 5 seconds).</li>
|
||||
<li>Pair again here.</li>
|
||||
</ol>
|
||||
<a class="gx-btn gx-btn--tonal" href="#/bluetooth">Open Bluetooth</a>
|
||||
</details>
|
||||
`
|
||||
export const CAR_TYPES = {
|
||||
builtin: "Built-in wireless", adapter: "Wireless adapter", usb: "Wired AA", app: "App"
|
||||
}
|
||||
@@ -319,6 +396,18 @@ export class StarPortFeed {
|
||||
return true
|
||||
}
|
||||
|
||||
// The guided setup's way back to the classic screens (and back). It never needs the StarPort service, so it is not a control().
|
||||
async setNewSetup(enabled) {
|
||||
if (!this.active || this.busy || typeof enabled !== "boolean") return false
|
||||
const result = await this.request("./api/starport/control", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "new_setup", enabled })
|
||||
})
|
||||
if (result === null) return false
|
||||
await this.refresh()
|
||||
return true
|
||||
}
|
||||
|
||||
async setEnabled(enabled, enableBluetooth = false) {
|
||||
if (!this.active || this.busy || typeof enabled !== "boolean" ||
|
||||
typeof enableBluetooth !== "boolean" || enableBluetooth && !enabled || enabled && !this.setup?.installReady) return false
|
||||
@@ -481,7 +570,7 @@ export function connectionProgress(runtime, { wired = false, app = "", selected
|
||||
}
|
||||
|
||||
export const StarPortPage = {
|
||||
components: { GxState, GxIconButton, GxNotice, BluetoothNotice, GxDialog, GalaxySettingRow },
|
||||
components: { GxState, GxIconButton, GxNotice, BluetoothNotice, GxDialog, GalaxySettingRow, StarPortGuidedSetup },
|
||||
props: {
|
||||
mode: { type: String, required: true }, localAccess: { type: Boolean, required: true },
|
||||
unauthorized: { type: Function, required: true }
|
||||
@@ -506,7 +595,11 @@ export const StarPortPage = {
|
||||
watch: {
|
||||
mode() { if (!document.hidden) this.begin(); else this.feed?.stop(true) },
|
||||
localAccess() { if (!document.hidden) this.begin(); else this.feed?.stop(true) },
|
||||
'pairing.prompt.id'() { this.pairValue = "" },
|
||||
'pairing.prompt.id'(id) {
|
||||
this.pairValue = ""
|
||||
// Bring the code into view: the list above it can be long.
|
||||
if (id !== undefined) this.$nextTick(() => this.$refs.pairPrompt?.scrollIntoView?.({ behavior: "smooth", block: "center" }))
|
||||
},
|
||||
'pairing.active'(active, was) { if (was && !active) this.loadReceivers() },
|
||||
// Find car on a fresh device turns Android Auto on first, then searches once the service is up.
|
||||
'setup.serviceReady'() { this.resumePairing() },
|
||||
@@ -541,6 +634,13 @@ export const StarPortPage = {
|
||||
return !!(this.supportReady && (this.selected || this.wired || this.app) && !this.pairing?.active &&
|
||||
!(this.adapterSetup && this.runtime?.car_profile_source !== "override"))
|
||||
},
|
||||
newSetup() { return this.setup?.newSetup !== false },
|
||||
guided() {
|
||||
return guidedState({ setup: this.setup, runtime: this.runtime, selected: this.selected, receivers: this.receivers, wired: this.wired,
|
||||
app: this.app, carType: this.carType, setupDone: this.setupDone, pairingActive: !!this.pairing?.active, editing: this.showSteps,
|
||||
adapterModels: ADAPTER_MODELS, adapterKey: this.runtime?.car_profile_source === "override" ? this.runtime.car_profile : "",
|
||||
appModels: HEAD_UNIT_APPS })
|
||||
},
|
||||
wiredStatus() {
|
||||
if (this.runtime?.wired_blocked) return { tone: "expired", label: "Wired StarPort not enabled", detail: this.runtime.wired_blocked }
|
||||
if (this.wiredJetlinkBusy) return { tone: "expired", label: "Wired StarPort not enabled", detail: "JetLink is using the USB-C port. Turn off JetLink, then return here." }
|
||||
@@ -553,6 +653,7 @@ export const StarPortPage = {
|
||||
},
|
||||
carType() { return this.runtime?.car_type || (this.wired ? "usb" : this.app ? "app" : "builtin") },
|
||||
adapterSetup() { return this.carType === "adapter" },
|
||||
deviceWord() { return this.adapterSetup ? "adapter" : "car" },
|
||||
pairsHere() { return this.carType === "builtin" || this.carType === "adapter" },
|
||||
findLabel() { return this.adapterSetup ? "adapter" : "car" },
|
||||
carTypeLabels() { return { ...CAR_TYPES, app: this.app ? `App (${this.app})` : CAR_TYPES.app } },
|
||||
@@ -564,9 +665,9 @@ export const StarPortPage = {
|
||||
},
|
||||
adapterRow() {
|
||||
const chosen = this.runtime?.car_profile_source === "override"
|
||||
return { label: "Adapter", value: !chosen ? "Choose one" : this.runtime.car_profile === "aawireless" ? ADAPTER_MODELS.aawireless : ADAPTER_MODELS.default,
|
||||
return { label: "Adapter", value: !chosen ? "Choose one" : ADAPTER_MODELS[this.runtime.car_profile] || ADAPTER_MODELS.default,
|
||||
choices: [...(!chosen ? ["Choose one"] : []), ...Object.values(ADAPTER_MODELS)], available: true, action: true,
|
||||
reason: "AAWireless gets video and car-data settings tuned for that dongle. Other adapters use the regular pairing." }
|
||||
reason: "AAWireless gets video and car-data settings tuned for that dongle. Motorola MA1 uses the regular pairing. Pick Unofficial dongle for other wireless dongles that will not connect; it tries alternate handshakes and logs more." }
|
||||
},
|
||||
appDisabled() {
|
||||
return !this.setup?.enabled || !this.setup.parked || !!this.pairing?.active || this.linked
|
||||
@@ -652,9 +753,9 @@ export const StarPortPage = {
|
||||
const steps = [
|
||||
`Start the search${this.starting ? "…" : ""}`,
|
||||
`Put your ${what} in pairing mode, then pick it from the list`,
|
||||
this.pairing?.prompt ? "Confirm the code on both screens" : "Pair with your device",
|
||||
this.pairing?.prompt ? "Confirm the code on both screens" : `Pair with your ${what}`,
|
||||
"Finish Bluetooth pairing",
|
||||
"Finish connecting to your device"
|
||||
`Finish connecting to your ${what}`
|
||||
]
|
||||
return steps.map((label, index) => ({ label, status: index < rank ? "done" : index === rank ? (failed ? "failed" : "active") : "pending" }))
|
||||
},
|
||||
@@ -663,6 +764,11 @@ export const StarPortPage = {
|
||||
return this.pairing?.active && !this.pairing?.receiver && !this.pairing?.prompt && !this.pairing?.approved &&
|
||||
!["pairing", "connecting", "paired", "connected", "failed"].includes(this.pairing?.state)
|
||||
},
|
||||
// Once a device is picked the list only gets in the way of the code; it returns if pairing fails.
|
||||
deviceChosen() {
|
||||
const p = this.pairing
|
||||
return p?.state !== "failed" && !!(p?.receiver || p?.prompt || p?.approved || ["pairing", "connecting", "paired", "connected"].includes(p?.state))
|
||||
},
|
||||
deviceSelectionBlocked() {
|
||||
return !!this.pairingReason || !this.pairing?.active ||
|
||||
!!this.pairing?.prompt || ["pairing", "connecting"].includes(this.pairing?.state) || !!this.linked
|
||||
@@ -676,6 +782,13 @@ export const StarPortPage = {
|
||||
return stopping ? stopping.then((stopped) => stopped && next()) : next()
|
||||
},
|
||||
useCar(address) { return this.afterWaiting(() => this.control("select_receiver", { address })) },
|
||||
// Choosing a cable or an app makes the setup count as done at once, which would close the card under the person's finger.
|
||||
// A choice made in the card keeps it open (its Done button closes it), so the choice can still be changed.
|
||||
chooseType(key) { this.showSteps = true; return this.saveCarType(0, this.carTypeLabels[key]) },
|
||||
chooseAdapter(key) { this.showSteps = true; return this.saveAdapter(0, ADAPTER_MODELS[key]) },
|
||||
chooseApp(key) { this.showSteps = true; return this.saveApp(0, HEAD_UNIT_APPS[key]) },
|
||||
useClassic() { return this.feed.setNewSetup(false) },
|
||||
useGuided() { return this.feed.setNewSetup(true) },
|
||||
saveCarType(index, value) {
|
||||
const labels = this.carTypeLabels
|
||||
const carType = Object.keys(labels).find(key => labels[key] === value)
|
||||
@@ -853,7 +966,28 @@ export const StarPortPage = {
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
<ol v-if="setup" id="gx-starport-steps" class="gx-starport__steps">
|
||||
<StarPortGuidedSetup v-if="setup && newSetup && guided.show" id="gx-starport-guided" :state="guided" :busy="busy" :choices-disabled="appDisabled"
|
||||
@choose-type="chooseType" @choose-adapter="chooseAdapter" @choose-app="chooseApp" @use-car="useCar" @classic="useClassic" @done="showSteps = false">
|
||||
<template #car>
|
||||
<GxNotice v-if="wiredUnavailable" tone="warn">Wired AA is unavailable: {{ runtime.wired_blocked }}.</GxNotice>
|
||||
<template v-if="carType === 'usb'">
|
||||
${WIRED_STATUS}
|
||||
</template>
|
||||
<template v-else-if="carType === 'app'">
|
||||
${APP_SETUP}
|
||||
</template>
|
||||
<template v-if="pairsHere">
|
||||
${PAIR_UI}
|
||||
</template>
|
||||
</template>
|
||||
<template #connect>
|
||||
${AUTO_CONNECT}
|
||||
${CONNECT}
|
||||
<p v-if="connectReason" class="gx-guided__hint" role="status">{{ connectReason }}</p>
|
||||
</template>
|
||||
</StarPortGuidedSetup>
|
||||
<p v-else-if="setup && !newSetup && !setupDone" class="gx-note"><button type="button" class="gx-btn gx-btn--tonal" :disabled="busy" @click="useGuided">Try the guided setup</button></p>
|
||||
<ol v-if="setup && !newSetup" id="gx-starport-steps" class="gx-starport__steps">
|
||||
<li v-show="!setupDone || showSteps" class="gx-card gx-starport-step" :class="{ 'gx-starport-step--done': !!selected && !pairing?.active }">
|
||||
<div class="gx-starport-step__head">
|
||||
<span class="gx-starport-step__num" aria-hidden="true"><i v-if="selected && !pairing?.active" class="bi bi-check-lg"></i><template v-else>2</template></span>
|
||||
@@ -873,80 +1007,7 @@ export const StarPortPage = {
|
||||
${APP_SETUP}
|
||||
</template>
|
||||
<template v-if="pairsHere">
|
||||
<ol v-if="adapterSetup" class="gx-starport-list" aria-label="Adapter pairing">
|
||||
<li class="gx-starport-row">
|
||||
<i class="bi bi-usb-symbol" aria-hidden="true"></i>
|
||||
<span class="gx-starport-row__text"><strong>Adapter</strong><small>{{ selected ? selected.name : 'Put it in pairing mode, then tap Find adapter.' }}</small></span>
|
||||
<span class="gx-starport-pill" :class="selected ? 'gx-starport-pill--ready' : ''">{{ selected ? 'Paired' : 'Not paired' }}</span>
|
||||
</li>
|
||||
</ol>
|
||||
<GxNotice v-if="!pairing?.active && runtime?.pairing_note" tone="warn">{{ runtime.pairing_note }}</GxNotice>
|
||||
<ul v-if="!pairing?.active && receivers.length" class="gx-starport-list" aria-label="Paired cars">
|
||||
<li v-for="car in receivers" :key="car.address" class="gx-starport-row">
|
||||
<i class="bi bi-car-front" aria-hidden="true"></i>
|
||||
<span class="gx-starport-row__text"><strong>{{ car.name }}</strong><small>{{ selected?.address === car.address ? 'Used for Android Auto' : 'Paired' }}</small></span>
|
||||
<span v-if="selected?.address === car.address" class="gx-starport-pill gx-starport-pill--ready"><i class="bi bi-check-lg"></i> Selected</span>
|
||||
<button v-else class="gx-btn gx-btn--tonal" :disabled="!setup.parked || linked" @click="useCar(car.address)">Use</button>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else-if="!pairing?.active && selected" class="gx-starport-row"><i class="bi bi-car-front" aria-hidden="true"></i><span class="gx-starport-row__text"><strong>{{ selected.name }}</strong><small>Used for Android Auto</small></span></p>
|
||||
<p v-if="!pairing?.active && endReason === 'canceled'" class="gx-note" role="status">Pairing canceled. Start again when your car is ready.</p>
|
||||
<p v-else-if="!pairing?.active && endReason" class="gx-note" role="status">The pairing window ended without choosing a car. Try again when your car’s pairing screen is open.</p>
|
||||
<ol v-if="showPairSteps" class="gx-starport-list" aria-label="Pairing progress">
|
||||
<li v-for="step in pairSteps" :key="step.label" class="gx-starport-row" :class="'gx-starport-row--' + step.status" :aria-current="step.status === 'active' ? 'step' : undefined">
|
||||
<i v-if="step.status === 'done'" class="bi bi-check-circle-fill gx-starport-ok" aria-hidden="true"></i>
|
||||
<span v-else-if="step.status === 'active'" class="gx-spinner gx-starport-orbit gx-starport-orbit--small" aria-hidden="true"></span>
|
||||
<i v-else class="bi bi-circle" aria-hidden="true"></i>
|
||||
<span class="gx-starport-row__text"><strong>{{ step.label }}</strong></span>
|
||||
</li>
|
||||
</ol>
|
||||
<p v-if="waitingForCar" role="status">{{ pairing.discovering ? "Searching for nearby cars and adapters…" : "Choose your car or wireless adapter below." }}</p>
|
||||
<p v-else-if="pairing?.active && pairing.approved && (!pairing.state || pairing.state === 'idle')" role="status">Approved {{ pairing.receiver?.name }}. Finishing Bluetooth pairing…</p>
|
||||
<p v-if="pairing?.state === 'pairing' && !pairing.prompt && !pairing.approved" role="status">Pairing with {{ pairing.receiver?.name || 'the selected device' }}…</p>
|
||||
<p v-if="pairing?.state === 'connecting'" role="status">Connecting to {{ pairing.receiver?.name || 'the selected device' }}…</p>
|
||||
<p v-if="['paired', 'connected'].includes(pairing?.state)" role="status"><i class="bi bi-check-circle-fill gx-starport-ok"></i> {{ pairing.receiver?.name || 'Device' }} {{ pairing.state === 'connected' ? 'connected' : 'paired' }}.</p>
|
||||
<GxNotice tone="danger" v-if="pairing?.error">{{ pairing.error }}</GxNotice>
|
||||
<ul v-if="pairing?.active" class="gx-starport-list" aria-label="Nearby devices">
|
||||
<li v-if="!pairing.devices?.length && waitingForCar" class="gx-starport-row gx-note" role="status"><span class="gx-spinner" aria-hidden="true"></span>Nothing found yet. Keep your car’s pairing screen open.</li>
|
||||
<li v-for="device in pairing.devices || []" :key="device.address" class="gx-starport-row">
|
||||
<i :class="device.starport ? 'bi bi-car-front' : 'bi bi-bluetooth'" aria-hidden="true"></i>
|
||||
<span class="gx-starport-row__text"><strong>{{ device.name }}</strong><small>{{ device.starport ? 'StarPort ready' : 'Bluetooth device' }} · {{ device.connected ? 'Connected' : device.paired ? 'Paired before' : 'Nearby' }}</small></span>
|
||||
<button class="gx-btn gx-btn--tonal" :disabled="deviceSelectionBlocked" @click="selectDevice(device.address)">{{ device.paired ? 'Connect' : 'Pair' }}</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-if="pairing?.prompt" class="gx-starport-prompt" role="status"><strong>{{ pairing.receiver.name }}</strong>
|
||||
<p>{{ pairing.prompt.kind === 'confirmation' ? 'Check that this code matches your car, then confirm.' :
|
||||
pairing.prompt.kind === 'pin' ? 'Enter the PIN shown by your car.' :
|
||||
pairing.prompt.kind === 'passkey' ? 'Enter your car’s passkey.' :
|
||||
pairing.prompt.kind === 'authorization' ? 'Allow this car to pair?' : 'Enter this code in your car.' }}</p>
|
||||
<strong v-if="pairing.prompt.value" class="gx-starport-prompt__code">{{ pairing.prompt.value }}</strong>
|
||||
<input v-if="['pin','passkey'].includes(pairing.prompt.kind)" class="gx-field" type="text" maxlength="16" autocomplete="off"
|
||||
:inputmode="pairing.prompt.kind === 'passkey' ? 'numeric' : 'text'" :aria-label="pairing.prompt.kind === 'pin' ? 'Car PIN' : 'Car passkey'" v-model="pairValue" />
|
||||
<div v-if="!pairing.prompt.displayOnly" class="gx-driving__actions gx-actions">
|
||||
<button class="gx-btn gx-btn--tonal" @click="respond(false)">Reject</button>
|
||||
<button class="gx-btn" :disabled="!canRespond" @click="respond(true)">{{ pairing.prompt.kind === 'pin' ? 'Send PIN' : pairing.prompt.kind === 'passkey' ? 'Send Passkey' : 'Confirm' }}</button></div>
|
||||
</div>
|
||||
<p v-if="pairWhenReady" class="gx-note" role="status"><span class="gx-spinner" aria-hidden="true"></span> Turning on Android Auto, then searching…</p>
|
||||
<p v-else-if="!pairing?.active && pairingReason" class="gx-note" role="status">{{ pairingReason }}</p>
|
||||
<div class="gx-driving__actions gx-actions">
|
||||
<button v-if="!pairing?.active" class="gx-btn" :class="{ 'gx-btn--tonal': !!selected }" :disabled="pairWhenReady || !!pairingReason" @click="beginPairing"><span v-if="starting" class="gx-spinner" aria-hidden="true"></span><i v-else class="bi bi-search"></i> {{ starting ? 'Starting search…' : selected ? 'Pair another ' + findLabel : 'Find ' + findLabel }}</button>
|
||||
<button v-else class="gx-btn gx-btn--tonal" @click="cancelPairing">Cancel Search / Pairing</button>
|
||||
</div>
|
||||
<details class="gx-starport-wiki"><summary>Stuck pairing? Pairing order and wireless adapters</summary>
|
||||
<ul>
|
||||
<li><strong>Keep this page open while pairing</strong> so the car’s PIN or code doesn’t time out.</li>
|
||||
<li><strong>Cars with built-in wireless Android Auto:</strong> open the car’s phone pairing screen, then tap <strong>Find car</strong> above. Choose your car, then confirm the code on both screens.</li>
|
||||
<li><strong>Wireless adapter</strong> (AAWireless or Motorola MA1): choose Wireless adapter above, put the adapter in pairing mode and pair it here Your car doesn’t need to be paired. Adapter names often look like AndroidAuto-XXXX.</li>
|
||||
</ul>
|
||||
<p><strong>Start fresh:</strong></p>
|
||||
<ol>
|
||||
<li>Forget the car on the comma: Bluetooth under Tools → Forget.</li>
|
||||
<li>Delete the comma from the car’s Bluetooth list.</li>
|
||||
<li>Turn the car off and on (or unplug the adapter for 5 seconds).</li>
|
||||
<li>Pair again here.</li>
|
||||
</ol>
|
||||
<a class="gx-btn gx-btn--tonal" href="#/bluetooth">Open Bluetooth</a>
|
||||
</details>
|
||||
${PAIR_UI}
|
||||
</template>
|
||||
${CONNECTION_PROGRESS}
|
||||
${CONNECT}
|
||||
@@ -965,7 +1026,7 @@ export const StarPortPage = {
|
||||
</li>
|
||||
|
||||
</ol>
|
||||
<section v-if="setup && setupDone && !showSteps" class="gx-card gx-starport-step gx-starport-home" aria-labelledby="gx-starport-home-title">
|
||||
<section v-if="setup && setupDone && !showSteps && (!newSetup || !guided.show)" class="gx-card gx-starport-step gx-starport-home" aria-labelledby="gx-starport-home-title">
|
||||
<div class="gx-starport-step__head">
|
||||
<span class="gx-starport-step__num" :class="{ 'gx-starport-home__icon--live': projecting }" aria-hidden="true"><i class="bi bi-car-front"></i></span>
|
||||
<div><h3 id="gx-starport-home-title">{{ homeTitle }}</h3>
|
||||
@@ -973,9 +1034,11 @@ export const StarPortPage = {
|
||||
</div>
|
||||
<div class="gx-starport-step__body">
|
||||
<p v-if="expiry.level === 'soon'" class="gx-starport-pill gx-starport-pill--soon" role="status">{{ expiry.label }}</p>
|
||||
<GalaxySettingRow :row="carTypeRow" :index="0" :busy="busy" :disabled="appDisabled" :save-value="saveCarType" />
|
||||
<GalaxySettingRow v-if="adapterSetup" :row="adapterRow" :index="0" :busy="busy" :disabled="appDisabled" :save-value="saveAdapter" />
|
||||
<GalaxySettingRow v-if="app" :row="appRow" :index="0" :busy="busy" :disabled="appDisabled" :save-value="saveApp" />
|
||||
<template v-if="!newSetup">
|
||||
<GalaxySettingRow :row="carTypeRow" :index="0" :busy="busy" :disabled="appDisabled" :save-value="saveCarType" />
|
||||
<GalaxySettingRow v-if="adapterSetup" :row="adapterRow" :index="0" :busy="busy" :disabled="appDisabled" :save-value="saveAdapter" />
|
||||
<GalaxySettingRow v-if="app" :row="appRow" :index="0" :busy="busy" :disabled="appDisabled" :save-value="saveApp" />
|
||||
</template>
|
||||
${AUTO_CONNECT}
|
||||
<ul v-if="wirelessHome" class="gx-starport-list" aria-label="Setup steps">
|
||||
<li class="gx-starport-row"><i class="bi bi-shield-check" aria-hidden="true"></i>
|
||||
@@ -994,8 +1057,8 @@ export const StarPortPage = {
|
||||
${CONNECT}
|
||||
<div class="gx-actions">
|
||||
<a class="gx-btn gx-btn--tonal" href="#/theme_maker/starport"><i class="bi bi-layout-text-window" aria-hidden="true"></i> Customize Layout</a>
|
||||
<button type="button" class="gx-btn gx-btn--tonal" :aria-expanded="showSteps" aria-controls="gx-starport-steps" @click="showSteps = !showSteps">
|
||||
<i :class="showSteps ? 'bi bi-chevron-up' : 'bi bi-gear'" aria-hidden="true"></i> {{ showSteps ? 'Hide Setup' : 'Setup and Pairing' }}</button>
|
||||
<button type="button" class="gx-btn gx-btn--tonal" :aria-expanded="showSteps" :aria-controls="newSetup ? 'gx-starport-guided' : 'gx-starport-steps'" @click="showSteps = !showSteps">
|
||||
<i :class="showSteps ? 'bi bi-chevron-up' : 'bi bi-gear'" aria-hidden="true"></i> {{ showSteps ? 'Hide Setup' : newSetup ? 'Change how your car connects' : 'Setup and Pairing' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -12,6 +12,8 @@ DATA = Path('/data/galaxy-ble')
|
||||
BRIDGE = Path(__file__).resolve().parent / 'bridge'
|
||||
UNIT = 'galaxy-ble-fork.service'
|
||||
LAN_UNIT = 'galaxy-lan-discovery.service'
|
||||
RADIO_UNIT = 'starpilot-bluetooth-radio.service'
|
||||
RADIO_WAIT_UNTIL = 45. # seconds since boot; past this, never hold setup for the radio
|
||||
STATUS_HEARTBEAT = 30
|
||||
STATUS_STALE = 90 # Longer than the slowest step interval (60 s) plus the heartbeat.
|
||||
LOG = logging.getLogger('galaxy.bluetooth.setup')
|
||||
@@ -75,6 +77,48 @@ class Bootstrap:
|
||||
def systemctl(self, *args, **kwargs):
|
||||
return self.command(['sudo', '-n', 'systemctl', *args], **kwargs)
|
||||
|
||||
def radio_starting(self):
|
||||
"""True while the comma Bluetooth radio unit is still starting after boot."""
|
||||
if time.monotonic() > RADIO_WAIT_UNTIL:
|
||||
return False
|
||||
try:
|
||||
result = self.run(['systemctl', 'show', RADIO_UNIT, '--property=ActiveState', '--value'],
|
||||
timeout=5, check=False, capture_output=True, text=True)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return False
|
||||
return result.stdout.strip() == 'activating'
|
||||
|
||||
def register_units(self):
|
||||
"""Link the bridge and discovery units with a single systemd reload.
|
||||
|
||||
A reload stalls every other systemd job for 1-3 s at boot, including the Bluetooth radio unit's own
|
||||
steps (starting btattach and bluetoothd), so wait for that unit first and reload once for both units.
|
||||
A bridge failure raises; a discovery failure only leaves discovery unregistered."""
|
||||
if self.registered and self.discovery_registered:
|
||||
return
|
||||
while self.radio_starting():
|
||||
time.sleep(0.5)
|
||||
linked = []
|
||||
if not self.registered:
|
||||
self.systemctl('link', '--runtime', '--force', '--no-reload', str(self.bridge / UNIT), stdout=subprocess.DEVNULL)
|
||||
linked.append(UNIT)
|
||||
if not self.discovery_registered:
|
||||
try:
|
||||
self.systemctl('link', '--runtime', '--force', '--no-reload', str(self.bridge / LAN_UNIT), stdout=subprocess.DEVNULL)
|
||||
linked.append(LAN_UNIT)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
pass
|
||||
if not linked:
|
||||
return
|
||||
try:
|
||||
self.systemctl('daemon-reload', stdout=subprocess.DEVNULL)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
if UNIT in linked:
|
||||
raise
|
||||
return
|
||||
self.registered = self.registered or UNIT in linked
|
||||
self.discovery_registered = self.discovery_registered or LAN_UNIT in linked
|
||||
|
||||
def step(self):
|
||||
wake = self.data / 'wake-setup.json'
|
||||
if wake.exists():
|
||||
@@ -93,20 +137,14 @@ class Bootstrap:
|
||||
# The bridge runs before any phone is paired; an empty phone list accepts no one.
|
||||
from openpilot.starpilot.galaxy_bluetooth.bridge.pairing import ensure_store
|
||||
ensure_store(self.data / 'pairing.json')
|
||||
if not self.registered:
|
||||
self.systemctl('link', '--runtime', '--force', str(self.bridge / UNIT), stdout=subprocess.DEVNULL)
|
||||
self.systemctl('daemon-reload', stdout=subprocess.DEVNULL)
|
||||
self.registered = True
|
||||
self.register_units()
|
||||
# Discovery remains available when Bluetooth is off or the BLE service retries.
|
||||
# An optional discovery failure must not prevent Bluetooth startup.
|
||||
try:
|
||||
if not self.discovery_registered:
|
||||
self.systemctl('link', '--runtime', '--force', str(self.bridge / LAN_UNIT), stdout=subprocess.DEVNULL)
|
||||
self.systemctl('daemon-reload', stdout=subprocess.DEVNULL)
|
||||
self.discovery_registered = True
|
||||
self.systemctl('start', LAN_UNIT, stdout=subprocess.DEVNULL)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
pass
|
||||
if self.discovery_registered:
|
||||
try:
|
||||
self.systemctl('start', LAN_UNIT, stdout=subprocess.DEVNULL)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
pass
|
||||
from openpilot.starpilot.bluetooth.radio_preference import RadioPreference
|
||||
if not RadioPreference().enabled():
|
||||
self.run(['sudo', '-n', 'systemctl', 'stop', UNIT], timeout=20, check=False,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"""Setup never downloads packages and never turns on the shared Bluetooth radio."""
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
from openpilot.starpilot.galaxy_bluetooth.bootstrap import Bootstrap, UNIT
|
||||
from openpilot.starpilot.galaxy_bluetooth.bootstrap import Bootstrap, LAN_UNIT, RADIO_UNIT, RADIO_WAIT_UNTIL, UNIT
|
||||
|
||||
|
||||
class BootstrapTest(unittest.TestCase):
|
||||
@@ -106,5 +107,62 @@ class BootstrapTest(unittest.TestCase):
|
||||
self.assertFalse(any('restart' in call for call in self.calls))
|
||||
self.assertFalse(any('bluetooth.service' in call for call in self.calls))
|
||||
|
||||
def test_both_units_link_with_one_reload(self):
|
||||
with patch('openpilot.starpilot.bluetooth.radio_preference.RadioPreference.enabled', return_value=True):
|
||||
self.worker.step()
|
||||
links = [call for call in self.calls if 'link' in call]
|
||||
self.assertEqual(len(links), 2)
|
||||
self.assertTrue(all('--no-reload' in call for call in links))
|
||||
self.assertEqual(len([call for call in self.calls if 'daemon-reload' in call]), 1)
|
||||
self.assertTrue(self.worker.registered and self.worker.discovery_registered)
|
||||
|
||||
def test_setup_waits_for_the_radio_unit_at_boot(self):
|
||||
states = iter(['activating', 'activating', 'active'])
|
||||
def run(args, **kwargs):
|
||||
self.calls.append(args)
|
||||
if 'show' in args and RADIO_UNIT in args:
|
||||
return SimpleNamespace(stdout=next(states) + '\n', returncode=0)
|
||||
self.assertNotIn('show', args)
|
||||
return SimpleNamespace(stdout='1', returncode=0)
|
||||
self.worker.run = run
|
||||
with patch('openpilot.starpilot.bluetooth.radio_preference.RadioPreference.enabled', return_value=True), \
|
||||
patch('openpilot.starpilot.galaxy_bluetooth.bootstrap.time.monotonic', return_value=12.0), \
|
||||
patch('openpilot.starpilot.galaxy_bluetooth.bootstrap.time.sleep') as sleep:
|
||||
self.worker.step()
|
||||
self.assertEqual(sleep.call_count, 2)
|
||||
first_link = next(i for i, call in enumerate(self.calls) if 'link' in call)
|
||||
radio_checks = [i for i, call in enumerate(self.calls) if 'show' in call and RADIO_UNIT in call]
|
||||
self.assertEqual(len(radio_checks), 3)
|
||||
self.assertLess(max(radio_checks), first_link)
|
||||
|
||||
def test_setup_never_waits_for_the_radio_late_in_the_boot(self):
|
||||
with patch('openpilot.starpilot.bluetooth.radio_preference.RadioPreference.enabled', return_value=True), \
|
||||
patch('openpilot.starpilot.galaxy_bluetooth.bootstrap.time.monotonic', return_value=RADIO_WAIT_UNTIL + 1):
|
||||
self.worker.step()
|
||||
self.assertFalse(any('show' in call and RADIO_UNIT in call for call in self.calls))
|
||||
self.assertTrue(self.worker.registered)
|
||||
|
||||
def test_discovery_link_failure_still_registers_the_bridge(self):
|
||||
def run(args, **kwargs):
|
||||
self.calls.append(args)
|
||||
if 'link' in args and str(self.bridge / LAN_UNIT) in args:
|
||||
raise subprocess.CalledProcessError(1, args)
|
||||
return SimpleNamespace(stdout='1', returncode=0)
|
||||
self.worker.run = run
|
||||
with patch('openpilot.starpilot.bluetooth.radio_preference.RadioPreference.enabled', return_value=True):
|
||||
self.worker.step()
|
||||
self.assertTrue(self.worker.registered)
|
||||
self.assertFalse(self.worker.discovery_registered)
|
||||
self.assertEqual(len([call for call in self.calls if 'daemon-reload' in call]), 1)
|
||||
self.assertFalse(any('start' in call and LAN_UNIT in call for call in self.calls))
|
||||
self.assertTrue(any('start' in call and UNIT in call for call in self.calls))
|
||||
|
||||
def test_registered_units_never_reload_or_check_the_radio(self):
|
||||
self.worker.registered = self.worker.discovery_registered = True
|
||||
with patch('openpilot.starpilot.bluetooth.radio_preference.RadioPreference.enabled', return_value=True):
|
||||
self.worker.step()
|
||||
self.assertFalse(any('daemon-reload' in call or 'link' in call for call in self.calls))
|
||||
self.assertFalse(any('show' in call and RADIO_UNIT in call for call in self.calls))
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
@@ -36,15 +36,18 @@ class AutoConnectPolicy:
|
||||
if running:
|
||||
self.suppressed = True
|
||||
|
||||
def hold(self) -> None:
|
||||
self.suppressed = True
|
||||
|
||||
def start_refused(self, now: float) -> None:
|
||||
self._not_before = now + START_REFUSED_WAIT
|
||||
|
||||
def decide(self, now: float, *, enabled: bool, onroad: bool, car_link: bool, ready: bool, running: bool) -> str | None:
|
||||
def decide(self, now: float, *, enabled: bool, onroad: bool, car_link: bool, ready: bool, running: bool, linked: bool = False) -> str | None:
|
||||
"""Return "start", "stop" or None."""
|
||||
if onroad and not self._was_onroad:
|
||||
self.suppressed = False # a new drive
|
||||
self._was_onroad = onroad
|
||||
present = onroad or car_link
|
||||
present = onroad or car_link or (running and linked)
|
||||
|
||||
if running:
|
||||
if present:
|
||||
|
||||
@@ -830,6 +830,7 @@ class LeaseAwarePhone:
|
||||
def devices(self): return self.phone.devices()
|
||||
def device(self, address): return self.phone.device(address)
|
||||
def snapshot(self, address): return self.phone.snapshot(address)
|
||||
def adapter(self): return self.phone.adapter() # this comma's address, for the head unit's pairing request
|
||||
|
||||
def acquire(self, phone_class: bool = True) -> None:
|
||||
if self.lease is None:
|
||||
|
||||
@@ -30,6 +30,7 @@ import re
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field as dataclass_field
|
||||
|
||||
@@ -53,6 +54,11 @@ NAMES = {1: "WifiStartRequest", 2: "WifiInfoRequest", 3: "WifiInfoResponse", 4:
|
||||
|
||||
STATUS_SUCCESS = 0
|
||||
STATUS_NETWORK_UNAVAILABLE = -1
|
||||
# Fallback for a v4+ head unit that goes silent after our usual reply: a phone-shaped WifiVersionResponse, where 1 is
|
||||
# success (open-android-auto WifiVersionStatus) and the channel type and session info are filled in.
|
||||
MODERN_MAJOR = 4
|
||||
MODERN_VERSION_SUCCESS = 1
|
||||
CHANNEL_5GHZ, CHANNEL_24GHZ, CHANNEL_DUAL = 1, 2, 3
|
||||
|
||||
SECURITY_OPEN = 1
|
||||
SECURITY_NAMES = {0: "unknown", 1: "open", 2: "wep64", 3: "wep128", 4: "wpa", 8: "wpa2", 12: "wpa/wpa2",
|
||||
@@ -61,9 +67,19 @@ SECURITY_NAMES = {0: "unknown", 1: "open", 2: "wep64", 3: "wep128", 4: "wpa", 8:
|
||||
MAX_FRAME = 4096
|
||||
MAX_FRAMES = 64
|
||||
INITIAL_KICK_SECONDS = 2.5
|
||||
# A car that keeps answering "not ready" gets up to this many stage timeouts before the setup is abandoned.
|
||||
REFUSAL_PATIENCE = 3
|
||||
# Phone-side WifiPing while joining the receiver's Wi-Fi, so an impatient receiver (a wireless adapter) keeps the
|
||||
# idle RFCOMM link. Sent only to a receiver that pings us first: one that never pings may not expect them.
|
||||
JOIN_PING_INTERVAL = 2.0
|
||||
# Opt-in handshake variations for dongles that stall the standard exchange; "" is the standard behavior.
|
||||
EXPERIMENTS = {
|
||||
"start_response_first": ("Answer start request first", {"start_response_first"}),
|
||||
"version_endpoint": ("Use version endpoint", {"version_endpoint"}),
|
||||
"all": ("All of these", {"start_response_first", "version_endpoint"}),
|
||||
}
|
||||
# Frames that carry credentials or the vehicle identifier are never dumped as hex.
|
||||
HEX_LOG_SKIP = (WIFI_INFO_RESPONSE, WIFI_SETUP_INFO, WIFI_VERSION_REQUEST)
|
||||
MAC_RE = re.compile(r"^(?:[0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}$")
|
||||
|
||||
|
||||
@@ -165,7 +181,25 @@ def parse_setup_info(payload: bytes) -> tuple[Endpoint | None, WifiCredentials |
|
||||
return endpoint, credentials
|
||||
|
||||
|
||||
def parse_version_request(payload: bytes) -> tuple[int, int, Endpoint | None, dict]:
|
||||
def channel_type(request: bytes) -> int:
|
||||
"""The WifiChannelType to select: the car's stated preference, else what its channel list allows."""
|
||||
fields = parse_fields(request)
|
||||
preference = one(fields, 3)
|
||||
if isinstance(preference, int) and preference in (CHANNEL_5GHZ, CHANNEL_24GHZ, CHANNEL_DUAL):
|
||||
return preference
|
||||
channels = [value for value in fields.get(4, []) if isinstance(value, int) and value > 0]
|
||||
if channels and all(value >= 5000 for value in channels):
|
||||
return CHANNEL_5GHZ
|
||||
if channels and all(value < 5000 for value in channels):
|
||||
return CHANNEL_24GHZ
|
||||
return CHANNEL_DUAL
|
||||
|
||||
|
||||
def valid_experiment(value: str) -> bool:
|
||||
return value == "" or value in EXPERIMENTS
|
||||
|
||||
|
||||
def parse_version_request(payload: bytes, endpoint_fields: tuple[int, ...] = ()) -> tuple[int, int, Endpoint | None, dict]:
|
||||
"""Major/minor, an optional projection endpoint, and head-unit identity strings."""
|
||||
fields = parse_fields(payload)
|
||||
major, minor = int(one(fields, 1, 1) or 1), int(one(fields, 2, 0) or 0)
|
||||
@@ -185,6 +219,13 @@ def parse_version_request(payload: bytes) -> tuple[int, int, Endpoint | None, di
|
||||
labels = {1: "car_make", 2: "car_model", 3: "car_year", 5: "head_unit_make", 6: "head_unit_model",
|
||||
7: "head_unit_software_build", 8: "head_unit_software_version"}
|
||||
head_unit.update({label: text(info, key) for key, label in labels.items() if text(info, key)})
|
||||
for number in endpoint_fields:
|
||||
for value in fields.get(number, []):
|
||||
if isinstance(value, bytes):
|
||||
try:
|
||||
endpoint = parse_endpoint(value) or endpoint
|
||||
except ValueError:
|
||||
continue
|
||||
return major, minor, endpoint, head_unit
|
||||
|
||||
|
||||
@@ -224,8 +265,17 @@ class WirelessBootstrap:
|
||||
|
||||
def __init__(self, sock, log: Callable[..., None], *, device_serial: str = "starpilot",
|
||||
version_status: int = STATUS_SUCCESS, stage_timeout: float = 20.0, start_request_delay: float = 5.0,
|
||||
initial_kick_delay: float = INITIAL_KICK_SECONDS, join_ping_interval: float = JOIN_PING_INTERVAL):
|
||||
initial_kick_delay: float = INITIAL_KICK_SECONDS, join_ping_interval: float = JOIN_PING_INTERVAL,
|
||||
experiment: str = "", modern_reply: bool = False, patience: int = REFUSAL_PATIENCE,
|
||||
channel_override: int | None = None):
|
||||
self.sock = sock
|
||||
self.experiment = experiment if valid_experiment(experiment) else ""
|
||||
self.lifetime_id = uuid.uuid4().hex
|
||||
self.modern_reply = modern_reply
|
||||
self.patience = patience # stage timeouts a car that keeps saying "not ready" gets (setup ladder)
|
||||
self.channel_override = channel_override # Wi-Fi band to answer with inside the phone-style reply; None keeps the car's own
|
||||
self.version_major = 0
|
||||
self.flags = EXPERIMENTS[self.experiment][1] if self.experiment else set()
|
||||
self.initial_kick_delay = initial_kick_delay
|
||||
self.join_ping_interval = join_ping_interval
|
||||
self.peer_uses_pings = False
|
||||
@@ -251,6 +301,11 @@ class WirelessBootstrap:
|
||||
self.sock.sendall(encode_frame(message_id, payload))
|
||||
if message_id not in (WIFI_PING_REQUEST, WIFI_PING_RESPONSE):
|
||||
self.log("bootstrap_tx", message=NAMES.get(message_id, message_id), bytes=len(payload))
|
||||
self._log_hex("tx", message_id, payload)
|
||||
|
||||
def _log_hex(self, direction: str, message_id: int, payload: bytes) -> None:
|
||||
if self.experiment and message_id not in HEX_LOG_SKIP:
|
||||
self.log("bootstrap_frame_hex", direction=direction, message=NAMES.get(message_id, message_id), hex=payload.hex())
|
||||
|
||||
def next_frame(self, timeout: float) -> tuple[int, bytes]:
|
||||
deadline = time.monotonic() + timeout
|
||||
@@ -277,8 +332,20 @@ class WirelessBootstrap:
|
||||
if self.frames_seen > MAX_FRAMES:
|
||||
raise BootstrapError(self.stage, "too many bootstrap frames")
|
||||
self.log("bootstrap_rx", message=NAMES.get(message_id, message_id), bytes=len(payload))
|
||||
self._log_hex("rx", message_id, payload)
|
||||
return message_id, payload
|
||||
|
||||
def _version_response(self, major: int, minor: int, request: bytes) -> bytes:
|
||||
self.version_major = major
|
||||
if major < MODERN_MAJOR or not self.modern_reply:
|
||||
return field(1, major) + field(2, minor) + field(3, self.device_serial) + field(4, self.version_status)
|
||||
selected = self.channel_override or channel_type(request)
|
||||
session = field(1, self.device_serial) + field(2, self.lifetime_id)
|
||||
reply = (field(1, major) + field(2, minor) + field(3, self.device_serial) + field(4, MODERN_VERSION_SUCCESS) +
|
||||
field(5, selected) + field(6, session))
|
||||
self.log("bootstrap_version_reply", style="modern", channel_type=selected, hex=reply.hex())
|
||||
return reply
|
||||
|
||||
def service(self, message_id: int, payload: bytes) -> bool:
|
||||
"""Handle stage-independent frames; returns True when consumed."""
|
||||
if message_id == WIFI_PING_REQUEST:
|
||||
@@ -306,6 +373,7 @@ class WirelessBootstrap:
|
||||
# exchange if the car has not started it; newer head units (2025 Honda) wait for it.
|
||||
start_request_at: float | None = None
|
||||
deadline = time.monotonic() + self.stage_timeout
|
||||
latest_deadline = time.monotonic() + self.patience * self.stage_timeout
|
||||
initial_kick_at: float | None = time.monotonic() + self.initial_kick_delay
|
||||
hint_ready_at: float | None = None
|
||||
while endpoint is None:
|
||||
@@ -337,12 +405,12 @@ class WirelessBootstrap:
|
||||
continue
|
||||
initial_kick_at = None # setup messages drive the exchange; pings alone do not
|
||||
if message_id == WIFI_VERSION_REQUEST:
|
||||
major, minor, version_endpoint, info = parse_version_request(payload)
|
||||
major, minor, version_endpoint, info = parse_version_request(
|
||||
payload, (6,) if "version_endpoint" in self.flags else ())
|
||||
version, head_unit = (major, minor), {**head_unit, **info}
|
||||
self.log("bootstrap_version", major=major, minor=minor, head_unit=info,
|
||||
endpoint=version_endpoint.__dict__ if version_endpoint else None, message=describe_version_request(payload))
|
||||
self.send(WIFI_VERSION_RESPONSE, field(1, major) + field(2, minor) + field(3, self.device_serial) +
|
||||
field(4, self.version_status))
|
||||
self.send(WIFI_VERSION_RESPONSE, self._version_response(major, minor, payload))
|
||||
hinted = version_endpoint or hinted
|
||||
if hinted is not None and hint_ready_at is None:
|
||||
hint_ready_at = time.monotonic() + 3.0
|
||||
@@ -353,6 +421,8 @@ class WirelessBootstrap:
|
||||
if parsed is None:
|
||||
raise BootstrapError(self.stage, "WifiStartRequest has no valid IPv4 endpoint")
|
||||
endpoint = parsed
|
||||
if "start_response_first" in self.flags:
|
||||
self.send(WIFI_START_RESPONSE, field(3, STATUS_SUCCESS))
|
||||
elif message_id == WIFI_START_RESPONSE:
|
||||
# The car's answer to our WifiStartRequest (2025 Honda): ip=1, port=2, status=3 once it is
|
||||
# ready, or a negative status alone before then (it may follow up on its own; ask again anyway).
|
||||
@@ -362,6 +432,7 @@ class WirelessBootstrap:
|
||||
endpoint = parsed
|
||||
else:
|
||||
self.log("bootstrap_start_refused", status=status, endpoint=parsed is not None)
|
||||
deadline = min(latest_deadline, max(deadline, time.monotonic() + self.stage_timeout)) # a car that answers is not gone
|
||||
start_request_at = time.monotonic() + self.start_request_delay
|
||||
elif message_id == WIFI_SETUP_INFO:
|
||||
setup_endpoint, setup_credentials = parse_setup_info(payload)
|
||||
|
||||
@@ -86,7 +86,7 @@ def attempt_records(events):
|
||||
if name == "attempt_failed":
|
||||
current.update(failed_stage=str(event.get("stage", "")), error_kind=str(event.get("kind", "")),
|
||||
error=sanitize(str(event.get("error", ""))), failed_at_s=elapsed(start, event),
|
||||
where=sanitize(event.get("where", [])[-4:]))
|
||||
where=sanitize(event.get("where", [])[-4:]), reach=str(event.get("reach", "")))
|
||||
if name == "session_ended":
|
||||
current["ended_by_car"] = str(event.get("reason", "")).startswith("Head unit ended projection")
|
||||
shutdown = shutdown or current["ended_by_car"]
|
||||
@@ -97,6 +97,27 @@ def attempt_records(events):
|
||||
return attempts
|
||||
|
||||
|
||||
STREAM_LOSS = {
|
||||
"shared_radio": ("comma's own Wi-Fi", "The car stopped acknowledging video while the comma's own Wi-Fi joined or roamed; both share one radio.",
|
||||
["Check the other_wifi_changed and ack_grace events for when it changed and how long acks stopped.",
|
||||
"Repeated drops at home or on a hotspot mean the 8 s grace is too short or the car takes its screen back first."]),
|
||||
"stalled": ("Video acknowledgement older than", "The car kept the link but stopped acknowledging video for too long, so the stream was ended.",
|
||||
["Compare the Wi-Fi signal, frame age and encoder recoveries around the stall.",
|
||||
"If it repeats at the same place or speed, look for the comma stalling (CPU, thermal) rather than the car."]),
|
||||
"silent": ("Head unit stopped answering", "The head unit stopped answering protocol messages while the link looked up.",
|
||||
["Check whether the car's screen turned off or switched source at that time.",
|
||||
"Compare with other drives on the same head unit before changing timings."]),
|
||||
"disconnected": ("Head unit disconnected", "The head unit closed the connection, which is what a car shutdown or a source change looks like.",
|
||||
["Check whether the drive ended or the driver changed source on the head unit.",
|
||||
"Only investigate further if it happened while the car stayed on and Android Auto was showing."]),
|
||||
}
|
||||
|
||||
|
||||
def stream_loss_kind(error: str) -> str:
|
||||
"""Why a stream ended, from the logged error text: stalled, silent, disconnected or other."""
|
||||
return next((kind for kind, (marker, _, _) in STREAM_LOSS.items() if marker.lower() in error.lower()), "other")
|
||||
|
||||
|
||||
def diagnosis(session):
|
||||
stage, error = session.get("failed_stage", ""), session.get("error", "").lower()
|
||||
category, confidence = "unknown", "unknown"
|
||||
@@ -125,16 +146,39 @@ def diagnosis(session):
|
||||
explanation = "Authentication did not complete. A timeout alone does not prove a bad certificate."
|
||||
actions = ["Check whether TLS completed and whether the head unit replied to authentication.",
|
||||
"Retry with the head unit fully awake; compare with any successful attempt in this session."]
|
||||
elif stage in ("connecting_bluetooth", "rfcomm", "bootstrap"):
|
||||
elif stage in ("wifi_start", "wifi_info"):
|
||||
category, confidence = "head_unit_bootstrap_stalled", "observed"
|
||||
explanation = "Bluetooth connected and the version exchange ran, but the head unit stopped answering the Wi-Fi setup."
|
||||
actions = ["Read the attached handshake trace for the last messages the head unit sent and the ones it ignored.",
|
||||
"Compare the head unit make, model and protocol version with other reports before changing timings."]
|
||||
elif stage in ("connecting_bluetooth", "discovering", "rfcomm", "bootstrap"):
|
||||
category, confidence = "head_unit_unavailable", "suspected"
|
||||
explanation = "The Bluetooth/bootstrap exchange did not complete; the head unit may still be waking or already off."
|
||||
actions = ["Confirm the selected car is awake and inspect Bluetooth/bootstrap responses in the bundle.",
|
||||
"Retry while parked before considering re-pairing; preserve existing bonds for diagnosis."]
|
||||
reach = session.get("reach", "")
|
||||
if reach == "asleep":
|
||||
category = "car_asleep"
|
||||
explanation = "The car did not answer the Bluetooth page at all. Its radio is off or still waking, so waiting is the fix."
|
||||
actions = ["Wait for the car to finish waking and let the retry run; the saved pairing and RFCOMM channel were kept.",
|
||||
"Only re-pair if the car is awake with its Bluetooth screen open and pages still go unanswered."]
|
||||
elif reach == "not_ready":
|
||||
category = "car_not_ready"
|
||||
explanation = "The car answered Bluetooth but its Android Auto service refused or dropped the connection; it is not ready yet."
|
||||
actions = ["Let the retry run; the car usually accepts a few seconds later.",
|
||||
"If it repeats on every drive, compare the head unit make and model with other reports."]
|
||||
elif reach == "local":
|
||||
category = "bluetooth_stack_busy"
|
||||
explanation = "This comma's own Bluetooth stack was busy or refused, which says nothing about the car."
|
||||
actions = ["Check for another process or a second pairing attempt holding the controller.",
|
||||
"Retry once the controller is idle; preserve existing bonds."]
|
||||
elif stage == "streaming":
|
||||
category, confidence = "projection_interrupted", "observed"
|
||||
explanation = "Projection was interrupted. EOF or a timeout alone cannot distinguish a car shutdown from link loss."
|
||||
actions = ["Compare video acknowledgments, Wi-Fi signal, stalls, encoder recoveries and head-unit shutdown events.",
|
||||
"Retry while parked and verify sustained acknowledged frames, not just a streaming status."]
|
||||
if (loss := stream_loss_kind(session.get("error", ""))) != "other":
|
||||
explanation, actions = STREAM_LOSS[loss][1], STREAM_LOSS[loss][2]
|
||||
return {"category": category, "confidence": confidence, "summary": explanation,
|
||||
"evidence": session.get("error", ""), "recovered": session.get("outcome") == "recovered",
|
||||
"next_steps": actions, "log_file": session.get("name", ""),
|
||||
|
||||
@@ -13,8 +13,8 @@ from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from openpilot.starpilot.system.starport import identity as identity_store, system_slice
|
||||
from openpilot.starpilot.system.starport.bugsink_diagnosis import attempt_records, diagnosis, elapsed, sanitize
|
||||
from openpilot.starpilot.system.starport import identity as identity_store, reachability, system_slice
|
||||
from openpilot.starpilot.system.starport.bugsink_diagnosis import attempt_records, diagnosis, elapsed, sanitize, stream_loss_kind
|
||||
from openpilot.starpilot.system.starport.bugsink_summary import batch_summary, failure_summary, pairing_summary, session_timeline, software_tags
|
||||
|
||||
STATE_PATH = identity_store.DATA_DIR / "bugsink_report.json"
|
||||
@@ -27,8 +27,8 @@ MAX_FAILURES = 5 # distinct failures detailed per drive report
|
||||
MAX_BREADCRUMBS = 100
|
||||
TRACE_QUIET = frozenset({"nav_guidance_sent", "nav_focus", "nav_focus_requested", "nav_status_state", "media_key", "media_audio_state",
|
||||
"media_audio_focus", "media_status_state", "hfp_command", "video_focus"})
|
||||
TRACE_SESSION_BYTES = 40 << 10 # per failed session; the oldest events go first
|
||||
TRACE_TOTAL_BYTES = 160 << 10 # all traces in one report, well under the sender's event cap
|
||||
TRACE_SESSION_BYTES = 64 << 10 # per failed session; the middle goes first
|
||||
TRACE_TOTAL_BYTES = 200 << 10 # all traces in one report, well under the sender's event cap
|
||||
EVENT_BUDGET = 400 << 10 # a report is shed down to this before sending; the sender refuses more than 512 KB
|
||||
MAX_ATTENTION = 6 # sessions per report that get a trace and a device slice
|
||||
SERIES_KEYS = ("fps", "encode_ms", "frame_age_p95_ms", "wifi_dbm", "wifi_link", "wifi_noise", "load1", "mem_available_mb",
|
||||
@@ -103,6 +103,7 @@ def video_summary(events: list[dict]) -> dict:
|
||||
"screen": f"{mode['width'] - (mode.get('margin_width') or 0)}x{mode['height'] - (mode.get('margin_height') or 0)}"
|
||||
if mode.get("width") and mode.get("height") else None,
|
||||
"density_dpi": mode.get("density") or None,
|
||||
"codec": mode.get("codec"), # present only for H.265; H.264 reports are unchanged
|
||||
"target_fps": pipeline.get("target_fps"),
|
||||
"encoder": pipeline.get("encoder"),
|
||||
"nv12": pipeline.get("nv12"),
|
||||
@@ -150,15 +151,103 @@ def quiet_counts(events: list[dict]) -> dict:
|
||||
|
||||
|
||||
def bounded_trace(trace: list[dict], limit: int) -> dict:
|
||||
"""The newest events that fit in ``limit`` bytes of JSON, with how many older ones were left out."""
|
||||
kept, size = [], 0
|
||||
for event in reversed(trace):
|
||||
size += len(json.dumps(event, default=str)) + 2
|
||||
if size > limit:
|
||||
break
|
||||
kept.append(event)
|
||||
kept.reverse()
|
||||
return {"events": kept, "omitted": len(trace) - len(kept)}
|
||||
"""The first quarter and the newest events that fit in ``limit`` bytes of JSON, with how many in between were left out."""
|
||||
sizes = [len(json.dumps(event, default=str)) + 2 for event in trace]
|
||||
if sum(sizes) <= limit:
|
||||
return {"events": list(trace), "omitted": 0}
|
||||
head_end, used = 0, 0
|
||||
while head_end < len(trace) and used + sizes[head_end] <= limit // 4:
|
||||
used += sizes[head_end]
|
||||
head_end += 1
|
||||
tail_start = len(trace)
|
||||
while tail_start > head_end and used + sizes[tail_start - 1] <= limit:
|
||||
tail_start -= 1
|
||||
used += sizes[tail_start]
|
||||
return {"events": trace[:head_end] + trace[tail_start:], "omitted": tail_start - head_end, "head": head_end}
|
||||
|
||||
|
||||
MAX_OFFERED_MODES = 16
|
||||
SETUP_TYPES = ("builtin", "adapter", "usb", "app")
|
||||
|
||||
|
||||
def _config_field(config: dict, number: int, default=None):
|
||||
values = config.get(str(number), config.get(number))
|
||||
return values[0] if isinstance(values, list) and values and isinstance(values[0], int) else default
|
||||
|
||||
|
||||
def offered_modes(events: list[dict]) -> list[str]:
|
||||
"""Every video mode the head unit offered, e.g. "1280x720 H.264 margins 0x240", from its service discovery.
|
||||
|
||||
Kept for working pairings too, so the choice of mode can later be replayed against real cars."""
|
||||
from openpilot.starpilot.system.starport.session import CODEC_H264_BP, CODEC_NAMES, RESOLUTION_NAMES
|
||||
found = next((event for event in events if event["event"] == "discovered"), {})
|
||||
modes: list[str] = []
|
||||
for channel in found.get("channels") or []:
|
||||
for config in (channel.get("video_configs") or []) if isinstance(channel, dict) else []:
|
||||
if not isinstance(config, dict):
|
||||
continue
|
||||
|
||||
resolution, codec = _config_field(config, 1), _config_field(config, 10, CODEC_H264_BP)
|
||||
text = f"{RESOLUTION_NAMES.get(resolution, f'resolution {resolution}')} {CODEC_NAMES.get(codec, f'codec {codec}')}"
|
||||
margins = _config_field(config, 3, 0), _config_field(config, 4, 0)
|
||||
if any(margins):
|
||||
text += f" margins {margins[0]}x{margins[1]}"
|
||||
modes.append(text)
|
||||
return modes[:MAX_OFFERED_MODES]
|
||||
|
||||
|
||||
def session_sdp(events: list[dict]) -> dict:
|
||||
"""How the car's Android Auto RFCOMM channel was found: where it came from, and how many SDP retries it took."""
|
||||
found = next((event for event in events if event["event"] == "rfcomm_channel"), {})
|
||||
retries = sum(1 for event in events if event["event"] == "sdp_retry")
|
||||
if not found and not retries:
|
||||
return {}
|
||||
return {key: value for key, value in (("channel", found.get("channel") if isinstance(found.get("channel"), int) else None),
|
||||
("source", str(found["source"])[:12] if found.get("source") else None),
|
||||
("retries", retries or None)) if value is not None}
|
||||
|
||||
|
||||
def session_setup(events: list[dict]) -> dict:
|
||||
"""The setup type and profile the owner chose, as recorded when the session started."""
|
||||
start = next((event for event in events if event["event"] == "session_start"), {})
|
||||
setup = start.get("setup") if isinstance(start.get("setup"), dict) else {}
|
||||
kind = setup.get("type")
|
||||
return {key: value for key, value in (
|
||||
("type", kind if kind in SETUP_TYPES else None),
|
||||
("profile", scrub(setup.get("profile", ""), 40) or None),
|
||||
("profile_source", setup.get("profile_source") if setup.get("profile_source") in ("override", "detected", "standard") else None),
|
||||
) if value}
|
||||
|
||||
|
||||
def session_ladder(events: list[dict]) -> list[dict]:
|
||||
"""The handshake strategies tried in a session, in order, and how each ended (advanced, wrapped, exhausted, counted,
|
||||
dropped, or worked). Kept for working pairings too: it is what shows which strategy a head unit likes."""
|
||||
tried: list[dict] = []
|
||||
for event in events:
|
||||
name = event["event"]
|
||||
if name == "ladder_strategy":
|
||||
tried.append({"rung": str(event.get("rung", ""))[:24], "learned": bool(event.get("learned")),
|
||||
"at_s": elapsed(events[0], event)})
|
||||
elif name == "ladder_failed" and tried:
|
||||
tried[-1]["outcome"] = str(event.get("outcome", ""))[:16]
|
||||
elif name == "ladder_worked" and tried:
|
||||
tried[-1].update(outcome="worked", streamed_s=event.get("streamed_s"), t_ready_s=event.get("t_ready_s"))
|
||||
return [{key: value for key, value in item.items() if value is not None} for item in tried[-12:]]
|
||||
|
||||
|
||||
def session_strategy(events: list[dict], setup: dict) -> dict:
|
||||
"""What a streaming session ran with; the raw material for "this head unit usually likes this profile"."""
|
||||
if not any(event["event"] == "video_acknowledged" for event in events):
|
||||
return {}
|
||||
ready = next((event for event in reversed(events) if event["event"] == "video_setup"), {})
|
||||
reply = next((event for event in events if event["event"] == "bootstrap_version_reply"), {})
|
||||
mode = ready.get("mode") if isinstance(ready.get("mode"), dict) else {}
|
||||
# Only a wireless bootstrap has a version reply; the modern one is logged, the standard one is its absence.
|
||||
style = reply.get("style") or ("standard" if any(event["event"] == "bootstrap_start_response" for event in events) else None)
|
||||
strategy = {"profile": setup.get("profile"), "window_offered": ready.get("window"), "window_used": ready.get("used_window"),
|
||||
"reply_style": style, "channel_type": reply.get("channel_type"),
|
||||
"config_index": mode.get("config_index")}
|
||||
return {key: value for key, value in strategy.items() if value is not None}
|
||||
|
||||
|
||||
def session_record(path: Path) -> dict:
|
||||
@@ -184,7 +273,16 @@ def session_record(path: Path) -> dict:
|
||||
car = report["car"]
|
||||
start = next((e for e in events if e["event"] == "session_start"), {})
|
||||
software, settings = start.get("software", {}), start.get("settings")
|
||||
setup = session_setup(events)
|
||||
opened = next((e for e in events if e["event"] == "bluetooth_opened"), None)
|
||||
record = {
|
||||
"setup": setup,
|
||||
"offered_modes": offered_modes(events),
|
||||
# True when the comma's address went to the car in the pairing request; None when the car never opened that channel.
|
||||
"phone_address_sent": bool(opened.get("phone_address")) if opened is not None else None,
|
||||
"strategy": session_strategy(events, setup),
|
||||
"ladder": session_ladder(events),
|
||||
"sdp": session_sdp(events),
|
||||
"software": {key: str(software.get(key) or "unknown") for key in ("commit", "branch")} if isinstance(software, dict) else {},
|
||||
"settings": settings if isinstance(settings, dict) else {},
|
||||
"seq": _session_number(path),
|
||||
@@ -196,6 +294,9 @@ def session_record(path: Path) -> dict:
|
||||
"furthest_stage": report["furthest_stage"],
|
||||
"failed_stage": str(last_failure.get("failed_stage", "")),
|
||||
"error_kind": str(last_failure.get("error_kind", "")),
|
||||
"reach": str(last_failure.get("reach", "")) or (reachability.classify(
|
||||
str(last_failure.get("failed_stage", "")), str(last_failure.get("error_kind", "")), str(last_failure.get("error", "")))
|
||||
if last_failure else ""),
|
||||
"error": scrub(last_failure.get("error", "")),
|
||||
"failures": len(failures),
|
||||
"drops": len(drops),
|
||||
@@ -339,7 +440,8 @@ def failure_detail(session: dict) -> dict:
|
||||
"title": f"{assessment['category']} at {stage}: {kind}",
|
||||
"session": session["seq"],
|
||||
"attempt": session.get("failed_attempt"),
|
||||
"cause": assessment["category"], "stage": stage, "error_kind": kind,
|
||||
"cause": assessment["category"], "stage": stage, "error_kind": kind, "reach": session.get("reach", ""),
|
||||
"loss": stream_loss_kind(session.get("error", "")) if assessment["category"] == "projection_interrupted" else "",
|
||||
"summary": sanitize(failure_summary(assessment), names),
|
||||
"diagnosis": sanitize(assessment, names),
|
||||
}
|
||||
@@ -395,6 +497,10 @@ def drive_event(sessions: list[dict]) -> dict:
|
||||
head_unit = next((s["head_unit"] for s in reversed(sessions) if s["head_unit"]), {})
|
||||
video = next((s["video"] for s in reversed(sessions) if s["video"].get("resolution")), {})
|
||||
label = _head_unit_label(head_unit)
|
||||
setup = next((s["setup"] for s in reversed(sessions) if s.get("setup")), {})
|
||||
sent = next((s["phone_address_sent"] for s in reversed(sessions) if s.get("phone_address_sent") is not None), None)
|
||||
ladder = next((s["ladder"][-1] for s in reversed(sessions) if s.get("ladder")), {})
|
||||
strategy = next((s["strategy"] for s in reversed(sessions) if s.get("strategy")), {})
|
||||
failures = [failure_detail(failure) for failure in drive_failures(sessions)]
|
||||
failed = [session for session in sessions if session["seq"] in {failure["session"] for failure in failures}]
|
||||
heading = "failures" if failures else "sessions"
|
||||
@@ -425,8 +531,18 @@ def drive_event(sessions: list[dict]) -> dict:
|
||||
"aa.encoder": video.get("encoder", "unknown"),
|
||||
"aa.fps": str(summary["fps_avg"]) if summary["fps_avg"] is not None else "none",
|
||||
"aa.failures": str(len(failures)),
|
||||
# Low-cardinality choices only; long values (offered modes, strategy) live in the contexts below.
|
||||
**({"aa.setup_type": setup["type"]} if setup.get("type") else {}),
|
||||
**({"aa.adapter_model": setup["profile"]} if setup.get("profile") else {}),
|
||||
**({"aa.profile_source": setup["profile_source"]} if setup.get("profile_source") else {}),
|
||||
**({"aa.phone_address_sent": "yes" if sent else "no"} if sent is not None else {}),
|
||||
**({"aa.reply_style": strategy["reply_style"]} if strategy.get("reply_style") else {}),
|
||||
**({"aa.wifi_channel_type": str(strategy["channel_type"])} if strategy.get("channel_type") else {}),
|
||||
**({"aa.ladder_rung": ladder["rung"], "aa.ladder_learned": "yes" if ladder.get("learned") else "no"} if ladder else {}),
|
||||
**({"aa.attention": ",".join(reasons)[:190]} if reasons else {}),
|
||||
**({"aa.cause": latest["cause"], "aa.stage": latest["stage"], "aa.error_kind": latest["error_kind"]} if latest else {})},
|
||||
**({"aa.cause": latest["cause"], "aa.stage": latest["stage"], "aa.error_kind": latest["error_kind"]} if latest else {}),
|
||||
**({"aa.reach_class": latest["reach"]} if latest and latest.get("reach") else {}),
|
||||
**({"aa.loss_kind": latest["loss"]} if latest and latest.get("loss") else {})},
|
||||
"breadcrumbs": {"values": attempt_breadcrumbs(sessions)},
|
||||
"contexts": {"starport_drive": summary, "starport_head_unit": head_unit,
|
||||
**{f"starport_session_{index + 1}": _public(session) for index, session in enumerate(sessions)},
|
||||
|
||||
@@ -38,7 +38,7 @@ class Sender:
|
||||
self.opener = opener or urllib.request.build_opener(NoRedirect())
|
||||
self.active = active
|
||||
self.logger, self.client = logger, client
|
||||
self.schema_tags = {"aa.schema": "2"} if schema_tags is None else schema_tags
|
||||
self.schema_tags = {"aa.schema": "3"} if schema_tags is None else schema_tags
|
||||
|
||||
def __call__(self, **event) -> bool:
|
||||
if not self.active():
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
"""Android Auto tuning selected from the saved head-unit fingerprint or an override."""
|
||||
import re
|
||||
|
||||
DEFAULTS = {"connect_delay_s": 0.0, "max_window": 2, "phone_services": False, "phone_frames": False}
|
||||
DEFAULTS = {"connect_delay_s": 0.0, "max_window": 2, "phone_services": False, "phone_frames": False,
|
||||
"bootstrap_experiments": False, "stay_while_projecting": False}
|
||||
|
||||
FINGERPRINT_KEYS = ("head_unit_make", "head_unit_model", "car_make", "car_model", "car_year")
|
||||
|
||||
RULES: list[dict] = [
|
||||
{"id": "aawireless", "label": "AAWireless", "match": {},
|
||||
"tuning": {"max_window": 30, "phone_services": True, "phone_frames": True}},
|
||||
"tuning": {"max_window": 30, "phone_services": True, "phone_frames": True, "stay_while_projecting": True}},
|
||||
{"id": "unofficial", "label": "Unofficial dongle", "match": {}, "tuning": {"bootstrap_experiments": True}},
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -293,6 +293,34 @@ def shareable_config(config: dict) -> dict:
|
||||
return shared
|
||||
|
||||
|
||||
RECENT_DIAGNOSIS_SESSIONS = 5
|
||||
MAX_DIAGNOSIS_BYTES = 512 << 10
|
||||
|
||||
|
||||
def _diagnosis_report(logs: list[Path]) -> bytes:
|
||||
"""The same tags and per-session detail a Bugsink report carries (setup, offered modes, reach class, ladder, SDP...),
|
||||
for the newest few sessions, sanitized. An addition to the bundle: if it cannot be built the bundle is unaffected."""
|
||||
try:
|
||||
from openpilot.starpilot.system.starport import bugsink_report
|
||||
records = []
|
||||
for path in logs[:RECENT_DIAGNOSIS_SESSIONS]:
|
||||
try:
|
||||
records.append(bugsink_report.session_record(path))
|
||||
except (OSError, ValueError, KeyError, TypeError):
|
||||
continue
|
||||
if not records:
|
||||
return b""
|
||||
records.reverse() # oldest first, as the drive report expects
|
||||
event = bugsink_report.drive_event(records)
|
||||
report = {"tags": event["tags"], "message": event["message"], "sessions": [bugsink_report._public(record) for record in records]}
|
||||
data = json.dumps(report, indent=1, default=str).encode()
|
||||
if len(data) > MAX_DIAGNOSIS_BYTES:
|
||||
data = json.dumps({key: report[key] for key in ("tags", "message")}, indent=1, default=str).encode()
|
||||
return data
|
||||
except Exception:
|
||||
return b""
|
||||
|
||||
|
||||
def bundle(log_dir: Path | None = None, config_path: Path | None = None, *,
|
||||
snapshot: Callable[[float], dict[str, bytes]] | None = system_snapshot.collect) -> bytes:
|
||||
"""A zip for a bug report: every session log with its report, the settings, the renderer logs and a snapshot of the
|
||||
@@ -321,6 +349,9 @@ def bundle(log_dir: Path | None = None, config_path: Path | None = None, *,
|
||||
add(f"logs/{path.name}", data)
|
||||
add(f"reports/{path.stem}.json", json.dumps(report, indent=2, default=str).encode())
|
||||
add("REPORT.txt", ("\n".join(summaries) or "No Android Auto sessions have been logged yet.\n").encode())
|
||||
report = _diagnosis_report(logs)
|
||||
if report:
|
||||
add("report.json", report)
|
||||
config = identity_store.load_config(config_path)
|
||||
add("config.json", json.dumps(shareable_config(config), indent=2, default=str).encode())
|
||||
for name in EXTRA_LOGS:
|
||||
|
||||
@@ -188,12 +188,14 @@ def run(frames_path: str, touch_path: str | None = None, control_path: str | Non
|
||||
gui_app._width, gui_app._height = geometry.logical_width, geometry.logical_height
|
||||
gui_app._scale = 1.0
|
||||
gui_app._render_texture = None
|
||||
from openpilot.starpilot.system.starport import reconnect_notice
|
||||
from openpilot.starpilot.system.starport.identity import certificate_days_left
|
||||
from openpilot.starpilot.system.starport.projection_layout_runtime import ProjectionLayoutWatcher, comma_display_seed
|
||||
viewport = (geometry.logical_width, geometry.logical_height)
|
||||
watcher = ProjectionLayoutWatcher(viewport, display_seed=comma_display_seed)
|
||||
layout = ProjectionOnroad(viewport=viewport, customization=watcher.load(),
|
||||
certificate_days=certificate_days_left(), layout_watcher=watcher)
|
||||
certificate_days=certificate_days_left(), layout_watcher=watcher,
|
||||
reconnect_notice=reconnect_notice.take())
|
||||
resources.callback(layout.close)
|
||||
watcher.start()
|
||||
resources.callback(watcher.close)
|
||||
|
||||
@@ -118,6 +118,8 @@ def handle(supervisor: Supervisor, request: dict[str, Any], verifier: StarPortSo
|
||||
supervisor.set_auto_connect(bool(request.get("enabled", True)))
|
||||
elif command == "set_dedicated_wifi":
|
||||
supervisor.set_dedicated_wifi(request.get("enabled") is True)
|
||||
elif command == "set_allow_hevc":
|
||||
supervisor.set_allow_hevc(request.get("enabled") is True)
|
||||
elif command == "select_receiver":
|
||||
supervisor.select_receiver(str(request.get("address", "")), str(request.get("name", "")))
|
||||
elif command == "forget_receiver":
|
||||
|
||||
Binary file not shown.
@@ -3,11 +3,12 @@
|
||||
// Altering them can degrade video smoothness or introduce lag on Android Auto.
|
||||
// Do not change them autonomously; require an explicit user request specifically
|
||||
// for these video optimizations.
|
||||
// Isolated Qualcomm H.264 session for Android Auto projection. No cereal publisher,
|
||||
// Isolated Qualcomm H.264 (or, with AA_ENCODER_HEVC, H.265) session for Android Auto projection. No cereal publisher,
|
||||
// camera buffers, realtime priority, or background thread. Exactly one input in flight.
|
||||
// Input is RGBA (converted here with NEON) or NV12 already converted on the GPU by the
|
||||
// car renderer (copied into the encoder's padded layout). ABI 3 adds the NV12 entry
|
||||
// point and create() options (AA_ENCODER_CBR).
|
||||
// point and create() options (AA_ENCODER_CBR). ABI 4 adds AA_ENCODER_HEVC for a head unit that offers only H.265;
|
||||
// the H.264 path is untouched. HEVC controls follow system/loggerd/encoder/v4l_encoder.cc.
|
||||
// Buffer/ioctl conventions follow system/loggerd/encoder/v4l_encoder.cc and
|
||||
// msgq/visionipc/visionbuf_ion.cc. Built for comma_arm64 by this directory's SConscript.
|
||||
//
|
||||
@@ -38,6 +39,7 @@ namespace {
|
||||
using Clock = std::chrono::steady_clock;
|
||||
constexpr unsigned CODECCONFIG = 0x00020000;
|
||||
constexpr int AA_ENCODER_CBR = 1; // hold the configured bitrate instead of letting it float with scene complexity
|
||||
constexpr int AA_ENCODER_HEVC = 2; // encode H.265 (Main profile) instead of H.264 baseline
|
||||
constexpr size_t MAX_PACKET = 2 * 1024 * 1024 - 10;
|
||||
|
||||
void check(bool ok, const char *what) {
|
||||
@@ -142,8 +144,9 @@ struct Encoder {
|
||||
strcmp(reinterpret_cast<char *>(cap.card), "msm_vidc_venc")) throw std::runtime_error("Unsupported encoder driver");
|
||||
v4l2_format out = {}; out.type = V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE;
|
||||
out.fmt.pix_mp.width = width; out.fmt.pix_mp.height = height;
|
||||
out.fmt.pix_mp.pixelformat = V4L2_PIX_FMT_H264;
|
||||
call(fd, VIDIOC_S_FMT, &out, "H264 format");
|
||||
const bool hevc = options & AA_ENCODER_HEVC;
|
||||
out.fmt.pix_mp.pixelformat = hevc ? V4L2_PIX_FMT_HEVC : V4L2_PIX_FMT_H264;
|
||||
call(fd, VIDIOC_S_FMT, &out, hevc ? "HEVC format" : "H264 format");
|
||||
v4l2_streamparm parm = {}; parm.type = V4L2_BUF_TYPE_VIDEO_OUTPUT_MPLANE;
|
||||
parm.parm.output.timeperframe.numerator = 1; parm.parm.output.timeperframe.denominator = fps;
|
||||
call(fd, VIDIOC_S_PARM, &parm, "frame rate");
|
||||
@@ -167,10 +170,15 @@ struct Encoder {
|
||||
: V4L2_CID_MPEG_VIDC_VIDEO_RATE_CONTROL_VBR_CFR);
|
||||
control(V4L2_CID_MPEG_VIDC_VIDEO_PRIORITY, V4L2_MPEG_VIDC_VIDEO_PRIORITY_REALTIME_DISABLE);
|
||||
control(V4L2_CID_MPEG_VIDC_VIDEO_IDR_PERIOD, 1);
|
||||
control(V4L2_CID_MPEG_VIDEO_H264_PROFILE, V4L2_MPEG_VIDEO_H264_PROFILE_BASELINE);
|
||||
control(V4L2_CID_MPEG_VIDEO_H264_LEVEL, V4L2_MPEG_VIDEO_H264_LEVEL_3_1);
|
||||
control(V4L2_CID_MPEG_VIDEO_H264_ENTROPY_MODE, V4L2_MPEG_VIDEO_H264_ENTROPY_MODE_CAVLC);
|
||||
control(V4L2_CID_MPEG_VIDEO_H264_LOOP_FILTER_MODE, V4L2_MPEG_VIDEO_H264_LOOP_FILTER_MODE_ENABLED);
|
||||
if (hevc) {
|
||||
control(V4L2_CID_MPEG_VIDC_VIDEO_HEVC_PROFILE, V4L2_MPEG_VIDC_VIDEO_HEVC_PROFILE_MAIN);
|
||||
control(V4L2_CID_MPEG_VIDC_VIDEO_HEVC_TIER_LEVEL, V4L2_MPEG_VIDC_VIDEO_HEVC_LEVEL_HIGH_TIER_LEVEL_5);
|
||||
} else {
|
||||
control(V4L2_CID_MPEG_VIDEO_H264_PROFILE, V4L2_MPEG_VIDEO_H264_PROFILE_BASELINE);
|
||||
control(V4L2_CID_MPEG_VIDEO_H264_LEVEL, V4L2_MPEG_VIDEO_H264_LEVEL_3_1);
|
||||
control(V4L2_CID_MPEG_VIDEO_H264_ENTROPY_MODE, V4L2_MPEG_VIDEO_H264_ENTROPY_MODE_CAVLC);
|
||||
control(V4L2_CID_MPEG_VIDEO_H264_LOOP_FILTER_MODE, V4L2_MPEG_VIDEO_H264_LOOP_FILTER_MODE_ENABLED);
|
||||
}
|
||||
control(V4L2_CID_MPEG_VIDEO_MULTI_SLICE_MODE, 0);
|
||||
unsigned count = buffers(V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE, 6);
|
||||
buffers(V4L2_BUF_TYPE_VIDEO_OUTPUT_MPLANE, 9);
|
||||
@@ -257,7 +265,7 @@ struct Encoder {
|
||||
auto &buffer = *outputs[b.index]; buffer.sync(true);
|
||||
auto *bytes = static_cast<unsigned char *>(buffer.addr);
|
||||
if (b.flags & CODECCONFIG) {
|
||||
if (plane.bytesused > 65536) throw std::runtime_error("Oversized H264 header");
|
||||
if (plane.bytesused > 65536) throw std::runtime_error("Oversized codec header");
|
||||
header.assign(bytes, bytes + plane.bytesused);
|
||||
} else {
|
||||
if (b.timestamp.tv_sec * 1000000LL + b.timestamp.tv_usec != timestamp || frame_done)
|
||||
@@ -281,15 +289,20 @@ struct Encoder {
|
||||
}
|
||||
|
||||
extern "C" {
|
||||
int starport_encoder_abi() { return 3; }
|
||||
int starport_encoder_abi() { return 4; }
|
||||
void starport_encoder_timing(void *ptr, double *output) {
|
||||
if (ptr && output) std::copy_n(static_cast<Encoder *>(ptr)->timing, 3, output);
|
||||
}
|
||||
void *starport_encoder_create(int width, int height, int fps, int bitrate, int margin_height, int options, char *error, size_t size) {
|
||||
try {
|
||||
if (width < 2 || width > 1280 || height < 2 || height > 720 || width % 2 || height % 2 ||
|
||||
// H.264 baseline level 3.1 is a 720p30 session; HEVC Main at high-tier level 5 carries 1080p30 for a head unit
|
||||
// that offers nothing else.
|
||||
const bool hevc = options & AA_ENCODER_HEVC;
|
||||
const int max_width = hevc ? 1920 : 1280, max_height = hevc ? 1080 : 720;
|
||||
if (width < 2 || width > max_width || height < 2 || height > max_height || width % 2 || height % 2 ||
|
||||
fps != 30 || bitrate < 1000000 || bitrate > 20000000 || margin_height < 0 ||
|
||||
margin_height >= height || margin_height % 4 || (options & ~AA_ENCODER_CBR)) throw std::runtime_error("Unsupported encoder configuration");
|
||||
margin_height >= height || margin_height % 4 || (options & ~(AA_ENCODER_CBR | AA_ENCODER_HEVC)))
|
||||
throw std::runtime_error("Unsupported encoder configuration");
|
||||
auto encoder = std::make_unique<Encoder>(width, height, fps, margin_height, options);
|
||||
encoder->init(bitrate); return encoder.release();
|
||||
} catch (const std::exception &e) { error_text(error, size, e); return nullptr; }
|
||||
|
||||
@@ -23,11 +23,14 @@ from pathlib import Path
|
||||
from openpilot.starpilot.system.starport.encoder import MAX_ACCESS_UNIT, START_CODE, H264Encoder, nal_types
|
||||
|
||||
LIBRARY = Path(__file__).with_name("hw") / "libstarport_encoder.so"
|
||||
ABI = 3
|
||||
SUPPORTED_ABIS = (2, 3)
|
||||
ABI = 4
|
||||
SUPPORTED_ABIS = (2, 3, 4)
|
||||
OPTION_CBR = 1
|
||||
OPTION_HEVC = 2 # ABI 4: H.265 Main profile instead of H.264 baseline
|
||||
MAX_WIDTH, MAX_HEIGHT, HW_FPS = 1280, 720, 30
|
||||
HEVC_MAX_WIDTH, HEVC_MAX_HEIGHT = 1920, 1080
|
||||
AUD = b"\x00\x00\x00\x01\x09\xf0"
|
||||
HEVC_AUD = b"\x00\x00\x00\x01\x46\x01\x50"
|
||||
|
||||
|
||||
def normalize_access_unit(data: bytes, *, keyframe: bool) -> bytes:
|
||||
@@ -52,16 +55,51 @@ def normalize_access_unit(data: bytes, *, keyframe: bool) -> bytes:
|
||||
return result
|
||||
|
||||
|
||||
def hevc_nal_kinds(data: bytes) -> list[int]:
|
||||
return [(data[match.end()] >> 1) & 0x3F for match in START_CODE.finditer(data) if match.end() < len(data)]
|
||||
|
||||
|
||||
def normalize_access_unit_hevc(data: bytes, *, keyframe: bool) -> bytes:
|
||||
"""H.265 counterpart of normalize_access_unit: exactly one leading AUD, one picture, VPS/SPS/PPS on keyframes.
|
||||
|
||||
A picture NAL is a VCL unit (types 0-9 and 16-21); an IDR is type 19 or 20. The comma's encoder splits every H.265
|
||||
picture into two slices (measured on the device: [19, 19] and [1, 1]), so one or more VCL units make a picture.
|
||||
Separate from the H.264 function so that path is unchanged."""
|
||||
if not data.startswith((b"\x00\x00\x01", b"\x00\x00\x00\x01")) or len(data) > MAX_ACCESS_UNIT:
|
||||
raise RuntimeError("Expected a bounded Annex B hardware frame")
|
||||
starts = list(START_CODE.finditer(data))
|
||||
units = []
|
||||
for index, match in enumerate(starts):
|
||||
end = starts[index + 1].start() if index + 1 < len(starts) else len(data)
|
||||
if match.end() + 1 >= end:
|
||||
raise RuntimeError("Empty hardware NAL unit")
|
||||
units.append(((data[match.end()] >> 1) & 0x3F, data[match.start():end]))
|
||||
kinds = [kind for kind, _ in units]
|
||||
pictures = [kind for kind in kinds if kind <= 9 or 16 <= kind <= 21]
|
||||
if not pictures or len({kind in (19, 20) for kind in pictures}) != 1: # slices of one picture are all IDR or none are
|
||||
raise RuntimeError("Hardware encoder did not produce one picture")
|
||||
if keyframe and not ({19, 20} & set(kinds) and {32, 33, 34}.issubset(kinds)):
|
||||
raise RuntimeError("Hardware encoder did not produce an independently decodable keyframe")
|
||||
result = HEVC_AUD + b"".join(unit for kind, unit in units if kind != 35)
|
||||
if len(result) > MAX_ACCESS_UNIT:
|
||||
raise RuntimeError("Hardware access unit too large")
|
||||
return result
|
||||
|
||||
|
||||
# ANDROID AUTO VIDEO PERFORMANCE GUARDRAIL: Hardware encoding and bounded frame submission avoid CPU stalls and encoder backlog.
|
||||
# Altering this can degrade video smoothness or introduce lag on Android Auto.
|
||||
# Do not change it autonomously; require an explicit user request specifically for this video optimization.
|
||||
class HardwareH264Encoder:
|
||||
backend = "qcom-v4l2"
|
||||
codec = "h264"
|
||||
max_width, max_height = MAX_WIDTH, MAX_HEIGHT # level 3.1 baseline is a 720p30 session
|
||||
codec_options = 0
|
||||
required_abi = 2
|
||||
|
||||
def __init__(self, width: int, height: int, fps: int = HW_FPS, bitrate_kbps: int = 6000, margin_height: int = 0,
|
||||
library: Path = LIBRARY, rate_control: str = "cbr"):
|
||||
if not (0 < width <= MAX_WIDTH and 0 < height <= MAX_HEIGHT) or width % 2 or height % 2 or fps != HW_FPS:
|
||||
raise ValueError("Hardware encoder supports even sizes up to 1280x720 at 30 fps")
|
||||
if not (0 < width <= self.max_width and 0 < height <= self.max_height) or width % 2 or height % 2 or fps != HW_FPS:
|
||||
raise ValueError(f"Hardware encoder supports even sizes up to {self.max_width}x{self.max_height} at 30 fps")
|
||||
if margin_height % 4:
|
||||
margin_height = 0 # only chroma-aligned black margins may be skipped
|
||||
self.width, self.height, self.fps = width, height, fps
|
||||
@@ -71,7 +109,7 @@ class HardwareH264Encoder:
|
||||
self.lib = ctypes.CDLL(str(library))
|
||||
self.lib.starport_encoder_abi.restype = ctypes.c_int
|
||||
self.abi = self.lib.starport_encoder_abi()
|
||||
if self.abi not in SUPPORTED_ABIS:
|
||||
if self.abi not in SUPPORTED_ABIS or self.abi < self.required_abi:
|
||||
raise RuntimeError("libstarport_encoder.so ABI mismatch; rebuild")
|
||||
pointer = ctypes.c_void_p
|
||||
encode_args = [pointer, pointer, ctypes.c_size_t, ctypes.c_int, pointer, ctypes.c_size_t, pointer, ctypes.c_size_t]
|
||||
@@ -88,7 +126,7 @@ class HardwareH264Encoder:
|
||||
self.lib.starport_encoder_destroy.restype = None
|
||||
self.error = ctypes.create_string_buffer(512)
|
||||
self.output = ctypes.create_string_buffer(MAX_ACCESS_UNIT)
|
||||
options = (OPTION_CBR if self.rate_control == "cbr" else 0,) if self.abi >= 3 else ()
|
||||
options = ((OPTION_CBR if self.rate_control == "cbr" else 0) | self.codec_options,) if self.abi >= 3 else ()
|
||||
self._create_args = (width, height, fps, int(bitrate_kbps) * 1000, margin_height, *options)
|
||||
self._open()
|
||||
|
||||
@@ -127,10 +165,16 @@ class HardwareH264Encoder:
|
||||
if size > len(self.output):
|
||||
raise RuntimeError("Hardware access unit too large")
|
||||
# .raw copies the entire 2 MiB capacity, even for a small inter frame.
|
||||
data = normalize_access_unit(ctypes.string_at(self.output, size), keyframe=force)
|
||||
data = self.normalize(ctypes.string_at(self.output, size), keyframe=force)
|
||||
self.frame_index += 1
|
||||
self.last_encode_ms = (time.monotonic() - started) * 1000
|
||||
return data, {5, 7, 8}.issubset(nal_types(data))
|
||||
return data, self.is_keyframe(data)
|
||||
|
||||
def normalize(self, data: bytes, *, keyframe: bool) -> bytes:
|
||||
return normalize_access_unit(data, keyframe=keyframe)
|
||||
|
||||
def is_keyframe(self, data: bytes) -> bool:
|
||||
return {5, 7, 8}.issubset(nal_types(data))
|
||||
|
||||
def close(self) -> None:
|
||||
handle, self.handle = self.handle, None
|
||||
@@ -138,6 +182,42 @@ class HardwareH264Encoder:
|
||||
self.lib.starport_encoder_destroy(handle)
|
||||
|
||||
|
||||
class HardwareHevcEncoder(HardwareH264Encoder):
|
||||
"""H.265 for a head unit that offers nothing else (Allow H.265 video). Hardware only: there is no software fallback."""
|
||||
backend = "qcom-v4l2-hevc"
|
||||
codec = "h265"
|
||||
max_width, max_height = HEVC_MAX_WIDTH, HEVC_MAX_HEIGHT
|
||||
codec_options = OPTION_HEVC
|
||||
required_abi = 4
|
||||
|
||||
def normalize(self, data: bytes, *, keyframe: bool) -> bytes:
|
||||
return normalize_access_unit_hevc(data, keyframe=keyframe)
|
||||
|
||||
def is_keyframe(self, data: bytes) -> bool:
|
||||
kinds = set(hevc_nal_kinds(data))
|
||||
return bool({19, 20} & kinds) and {32, 33, 34}.issubset(kinds)
|
||||
|
||||
|
||||
def create_hevc_encoder(width: int, height: int, *, bitrate_kbps: int, margin_height: int, log,
|
||||
rate_control: str = "cbr") -> tuple[object, int]:
|
||||
"""Return ``(encoder, fps)`` for an H.265 session. The car was told H.265, so a stand-in codec would send it a stream it
|
||||
cannot decode: if the hardware encoder cannot open, the session fails with the reason."""
|
||||
encoder = None
|
||||
try:
|
||||
if not LIBRARY.is_file():
|
||||
raise RuntimeError(f"{LIBRARY.name} not built")
|
||||
encoder = HardwareHevcEncoder(width, height, bitrate_kbps=max(bitrate_kbps, 4000), margin_height=margin_height,
|
||||
rate_control=rate_control)
|
||||
encoder.encode_rgba(bytes(width * height * 4), keyframe=True) # exercise the driver and the keyframe contract
|
||||
except Exception as error:
|
||||
if encoder is not None:
|
||||
encoder.close()
|
||||
raise RuntimeError(f"The car needs H.265 and the hardware encoder could not start it: {error}") from error
|
||||
log("encoder", backend=encoder.backend, codec=encoder.codec, fps=HW_FPS, abi=encoder.abi, rate_control=encoder.rate_control,
|
||||
nv12=encoder.supports_nv12)
|
||||
return encoder, HW_FPS
|
||||
|
||||
|
||||
# ANDROID AUTO VIDEO PERFORMANCE GUARDRAIL: Backend selection and frame-rate budgets are part of the low-latency video pipeline.
|
||||
# Altering this can degrade video smoothness or introduce lag on Android Auto.
|
||||
# Do not change it autonomously; require an explicit user request specifically for this video optimization.
|
||||
|
||||
@@ -20,6 +20,14 @@ from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def boot_id() -> str:
|
||||
try:
|
||||
return Path("/proc/sys/kernel/random/boot_id").read_text().strip()
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def _data_dir() -> Path:
|
||||
if override := os.environ.get("STARPORT_DIR"):
|
||||
return Path(override)
|
||||
@@ -141,11 +149,13 @@ DEFAULT_CONFIG = {
|
||||
"render_profile_kb": 256, # size cap per render_profile file
|
||||
"wifi_interface": "wlan0",
|
||||
"dedicated_wifi_interface": True, # join the car on its own aa0 station so wlan0 stays on home Wi-Fi
|
||||
"allow_hevc": False, # offer H.265 when the car offers no usable H.264 mode (Galaxy Developer > StarPort)
|
||||
"device_name": "StarPilot",
|
||||
"version_status": 0, # WifiVersionResponse status (0 = success) for receivers that negotiate a version
|
||||
"phone_class": True, # while pairing/connecting, present as a phone: HFP gateway + smartphone Class of Device
|
||||
"auto_connect": True, # start projection on its own when the chosen car is on (onroad, or it reaches the comma)
|
||||
"rfcomm_cache": {}, # car address -> Android Auto RFCOMM channel learned over SDP, to skip discovery next time
|
||||
"modern_reply_cache": {}, # car address -> True when its v4+ head unit only started after the phone-style version reply
|
||||
"car_type": "", # wireless setup shown in Galaxy: "builtin" or "adapter"; "" = work it out from the saved companion car
|
||||
"car_profile_override": "", # "" = pick by fingerprint (a wireless adapter must choose); "default" = standard; otherwise a car_profiles id
|
||||
"car_fingerprint": {}, # what the chosen car's head unit reported last time, used to pick a profile before it connects
|
||||
@@ -188,11 +198,14 @@ def load_config(path: Path | None = None) -> dict:
|
||||
if config["usb_mode"] not in ("auto", "handshake", "direct"):
|
||||
config["usb_mode"] = "auto"
|
||||
config["bitrate_kbps"] = max(1000, min(12000, int(config["bitrate_kbps"])))
|
||||
config["allow_hevc"] = config["allow_hevc"] is True
|
||||
if config["rate_control"] not in ("cbr", "vbr"):
|
||||
config["rate_control"] = "cbr"
|
||||
config["render_profile_kb"] = max(16, min(4096, int(config["render_profile_kb"])))
|
||||
config["rfcomm_cache"] = {str(address).upper(): channel for address, channel in config["rfcomm_cache"].items()
|
||||
if type(channel) is int and 1 <= channel <= 30}
|
||||
config["modern_reply_cache"] = {str(address).upper(): True for address, modern in config["modern_reply_cache"].items()
|
||||
if modern is True}
|
||||
return config
|
||||
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ from jeepney.low_level import MessageType
|
||||
from jeepney.wrappers import Properties
|
||||
|
||||
from openpilot.starpilot.system.starport.bootstrap import WifiCredentials
|
||||
from openpilot.starpilot.system.starport.identity import DATA_DIR
|
||||
from openpilot.starpilot.system.starport.identity import DATA_DIR, boot_id as _boot_id
|
||||
|
||||
NM = "org.freedesktop.NetworkManager"
|
||||
NM_PATH = "/org/freedesktop/NetworkManager"
|
||||
@@ -39,7 +39,12 @@ NM_WIRELESS_IFACE = "org.freedesktop.NetworkManager.Device.Wireless"
|
||||
NM_AP_IFACE = "org.freedesktop.NetworkManager.AccessPoint"
|
||||
DEVICE_TYPE_WIFI = 2
|
||||
DEVICE_STATE_DISCONNECTED = 30
|
||||
DEVICE_STATES_NOT_MANAGED = (10, 20) # NM_DEVICE_STATE_UNMANAGED, NM_DEVICE_STATE_UNAVAILABLE
|
||||
DEVICE_READY_TIMEOUT_S = 5.0
|
||||
# A shared interface can be briefly unmanaged (for example while the Wi-Fi mode is being switched). Bugsink showed 19 joins
|
||||
# refused with "device is strictly unmanaged"; waiting a few seconds turns a transient state into a normal join, and a
|
||||
# device that stays unmanaged is reported as such instead of as an opaque D-Bus error.
|
||||
UNMANAGED_WAIT_S = 8.0
|
||||
ACTIVE_STATE_ACTIVATED = 2
|
||||
ACTIVE_STATE_DEACTIVATED = 4
|
||||
# The car sends its Wi-Fi details ~4 s after ignition, but a Honda's access point took ~55 s from ignition to appear
|
||||
@@ -65,13 +70,6 @@ class JoinTimeout(NetworkError):
|
||||
"""The car's network never came up in time."""
|
||||
|
||||
|
||||
def _boot_id() -> str:
|
||||
try:
|
||||
return Path("/proc/sys/kernel/random/boot_id").read_text().strip()
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def _paused_interface(marker: Path) -> str:
|
||||
"""The interface a session paused this boot and never restored, or ""; NetworkManager resets autoconnect on reboot."""
|
||||
try:
|
||||
@@ -253,6 +251,7 @@ class NetworkLease:
|
||||
self._prepare_device(cancelled)
|
||||
else:
|
||||
self.device_path = self._wifi_device()
|
||||
self._wait_managed(cancelled)
|
||||
self._pause_autoconnect()
|
||||
active = self._get(self.device_path, NM_DEVICE_IFACE, "ActiveConnection")
|
||||
if active and active != "/" and not self.previous_connection and not self.dedicated:
|
||||
@@ -316,6 +315,22 @@ class NetworkLease:
|
||||
raise DedicatedUnavailable(failure)
|
||||
time.sleep(0.25)
|
||||
|
||||
def _wait_managed(self, cancelled: Callable[[], bool], timeout: float = UNMANAGED_WAIT_S) -> None:
|
||||
"""Give NetworkManager a few seconds to manage the shared interface before joining on it."""
|
||||
deadline = time.monotonic() + timeout
|
||||
waited = False
|
||||
while (state := self._get(self.device_path, NM_DEVICE_IFACE, "State")) in DEVICE_STATES_NOT_MANAGED:
|
||||
if cancelled():
|
||||
raise NetworkError("cancelled")
|
||||
if time.monotonic() >= deadline:
|
||||
raise NetworkError(f"{self.interface} is unmanaged by NetworkManager (state {state}) after waiting {timeout:.0f} s")
|
||||
if not waited:
|
||||
waited = True
|
||||
self.log("wifi_waiting_managed", interface=self.interface, state=state)
|
||||
time.sleep(0.25)
|
||||
if waited:
|
||||
self.log("wifi_became_managed", interface=self.interface)
|
||||
|
||||
def _prepare_device(self, cancelled: Callable[[], bool]) -> None:
|
||||
if self.device_ready:
|
||||
return
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Persist the setup UI choice independently of the StarPort daemon."""
|
||||
from pathlib import Path
|
||||
|
||||
from openpilot.common.utils import atomic_write
|
||||
from openpilot.starpilot.system.starport.identity import DATA_DIR
|
||||
|
||||
FLAG_PATH = DATA_DIR / "new_setup"
|
||||
|
||||
|
||||
def enabled(path: Path | None = None) -> bool:
|
||||
"""Absent, unreadable or unrecognized means on; only an explicit 0 turns the new flow off."""
|
||||
try:
|
||||
return (path or FLAG_PATH).read_text().strip() != "0"
|
||||
except (OSError, UnicodeError):
|
||||
return True
|
||||
|
||||
|
||||
def set_enabled(value: bool, path: Path | None = None) -> bool:
|
||||
target = path or FLAG_PATH
|
||||
try:
|
||||
target.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
with atomic_write(str(target), overwrite=True) as handle:
|
||||
handle.write("1" if value else "0")
|
||||
except OSError:
|
||||
return False
|
||||
return enabled(target) == value
|
||||
@@ -14,6 +14,7 @@ from openpilot.starpilot.system.starport.projection_map_lease import MapLeaseWri
|
||||
CERTIFICATE_NOTICE_NS = 10_000_000_000 # how long the expiry heads-up stays at the start of a drive
|
||||
BOOKMARK_NOTICE_NS = 2_000_000_000 # how long "Bookmark Saved" stays after the drive is bookmarked
|
||||
MODE_NOTICE_NS = 2_000_000_000 # how long the driving-mode banner stays after the mode changes
|
||||
RECONNECT_NOTICE_NS = 8_000_000_000 # how long "why it reconnected" stays once the car screen is back
|
||||
DRIVER_MONITOR_HOLD_NS = 350_000_000
|
||||
STANDBY_LOGO = Path(__file__).parents[3] / 'selfdrive/assets/images/starpilot_logo.png'
|
||||
BUBBLE_REDUNDANT_ALERTS = frozenset(('preLaneChangeLeft', 'preLaneChangeRight', 'laneChange',
|
||||
@@ -150,12 +151,14 @@ class ProjectionOnroad:
|
||||
"""Separate renderer with projected actions; native UIState stays read-only."""
|
||||
|
||||
def __init__(self, *, dependencies=None, viewport=None, customization=None, certificate_days=None, native_focus=None,
|
||||
layout_watcher=None):
|
||||
layout_watcher=None, reconnect_notice=""):
|
||||
viewport = FALLBACK_VIEWPORT if viewport is None else viewport
|
||||
self.width, self.height = viewport
|
||||
self.customization = customization
|
||||
self._certificate_notice = certificate_notice(certificate_days)
|
||||
self._certificate_notice_until_ns: int | None = None
|
||||
self._reconnect_notice = reconnect_notice
|
||||
self._reconnect_notice_until_ns: int | None = None
|
||||
self._bookmark_notice_until_ns: int | None = None
|
||||
self._mode_label: str | None = None # the drive's last known driving mode
|
||||
self._mode_engaged: bool | None = None
|
||||
@@ -424,6 +427,18 @@ class ProjectionOnroad:
|
||||
return replace(state, alert=self.native.alert(size=sizes.SMALL, text1=self._certificate_notice,
|
||||
alert_type='androidAutoCertificate/warning'))
|
||||
|
||||
def _with_reconnect_notice(self, state, now_ns):
|
||||
"""Say why the car screen just came back (the comma's Wi-Fi switched networks); a real alert always wins."""
|
||||
if not self._reconnect_notice:
|
||||
return state
|
||||
if self._reconnect_notice_until_ns is None:
|
||||
self._reconnect_notice_until_ns = now_ns + RECONNECT_NOTICE_NS
|
||||
sizes = self.native.alert_size
|
||||
if now_ns >= self._reconnect_notice_until_ns or state.alert.size != sizes.NONE:
|
||||
return state
|
||||
return replace(state, alert=self.native.alert(size=sizes.SMALL, text1=self._reconnect_notice,
|
||||
alert_type='androidAutoReconnected/info'))
|
||||
|
||||
def bookmark_saved(self, now_ns=None):
|
||||
"""A bookmark was published for this drive (from any button): confirm it on the car screen."""
|
||||
self._bookmark_notice_until_ns = (time.monotonic_ns() if now_ns is None else now_ns) + BOOKMARK_NOTICE_NS
|
||||
@@ -516,7 +531,8 @@ class ProjectionOnroad:
|
||||
# Touch hit-tests read viewport_width; the view draws at the car canvas width (1920 for a 1280x720 head unit).
|
||||
if state.viewport_width != self.width:
|
||||
state = replace(state, viewport_width=self.width)
|
||||
state = self._with_bookmark_notice(self._with_certificate_notice(state, now_ns), now_ns)
|
||||
state = self._with_certificate_notice(self._with_reconnect_notice(state, now_ns), now_ns)
|
||||
state = self._with_bookmark_notice(state, now_ns)
|
||||
self._state = self._with_mode_notice(state, now_ns)
|
||||
if self._slc_input is not None:
|
||||
self._state = self._slc_input.present(self._state)
|
||||
|
||||
@@ -81,17 +81,21 @@ def forget_car(address: str, client: StarPortClient | None = None) -> bool:
|
||||
client = client or StarPortClient()
|
||||
if client.available:
|
||||
return bool(client.call("forget_receiver", address=address).get("cleared"))
|
||||
from openpilot.starpilot.system.starport import identity
|
||||
from openpilot.starpilot.system.starport import identity, setup_ladder
|
||||
address = address.strip().upper()
|
||||
config = identity.load_config()
|
||||
chosen = config["receiver_address"].upper() == address
|
||||
companion = config["companion_address"].upper() == address
|
||||
cache = dict(config["rfcomm_cache"])
|
||||
if cache.pop(address, None) is None and not (chosen or companion):
|
||||
replies = dict(config["modern_reply_cache"])
|
||||
cached = cache.pop(address, None) is not None
|
||||
cached = replies.pop(address, None) is not None or cached
|
||||
learned = setup_ladder.SetupLadder(identity.CONFIG_PATH.with_name("setup_state.json")).forget(address)
|
||||
if not (chosen or companion or cached or learned):
|
||||
return False
|
||||
config["rfcomm_cache"] = cache
|
||||
config.update(rfcomm_cache=cache, modern_reply_cache=replies)
|
||||
if chosen:
|
||||
config.update(receiver_address="", receiver_name="", companion_address="", companion_name="")
|
||||
config.update(receiver_address="", receiver_name="", companion_address="", companion_name="", car_fingerprint={})
|
||||
elif companion:
|
||||
config.update(companion_address="", companion_name="")
|
||||
identity.save_config(config)
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Classify live and logged connection failures to select retry delays."""
|
||||
|
||||
ASLEEP = "asleep" # the car did not answer a page at all: off, or still waking
|
||||
NOT_READY = "not_ready" # the car answered, but its Android Auto service or Wi-Fi handshake is not up yet
|
||||
LOCAL = "local" # this comma's own Bluetooth stack refused or was busy; the car says nothing
|
||||
OTHER = "other" # anything else, including every failure after the link is up
|
||||
|
||||
CLASSES = (ASLEEP, NOT_READY, LOCAL, OTHER)
|
||||
|
||||
_LOCAL = ("device or resource busy", "errno 16]", "operation not permitted", "no such device")
|
||||
_ASLEEP = ("host is down", "errno 112]", "page-timeout", "page timeout", "br-connection-page")
|
||||
_NOT_READY = ("connection refused", "errno 111]", "connection reset", "errno 104]", "sdp record missing",
|
||||
"does not advertise android auto", "did not answer in time")
|
||||
_EARLY_STAGES = ("connecting_bluetooth", "discovering", "rfcomm", "wifi_start", "wifi_info", "bootstrap")
|
||||
|
||||
# Waiting longer for a radio that is off does not wake it, and every page holds the controller (a page that crosses
|
||||
# the car's own page can strand the link). These are floors: a class never waits less than the standard backoff.
|
||||
ASLEEP_FLOOR = (6.0, 8.0, 12.0, 20.0, 30.0)
|
||||
LOCAL_FLOOR = (8.0, 10.0, 15.0, 30.0)
|
||||
|
||||
|
||||
def classify(stage: str, kind: str, message: str) -> str:
|
||||
text = f"{kind} {message}".lower()
|
||||
if kind == "BluetoothRejected" or any(word in text for word in _LOCAL):
|
||||
return LOCAL
|
||||
if stage not in _EARLY_STAGES:
|
||||
return OTHER
|
||||
if any(word in text for word in _ASLEEP) or (kind == "TimeoutError" and stage == "connecting_bluetooth"):
|
||||
return ASLEEP
|
||||
if any(word in text for word in _NOT_READY) or kind == "BootstrapTimeout":
|
||||
return NOT_READY
|
||||
return OTHER
|
||||
|
||||
|
||||
def retry_delay(reach: str, attempt: int, standard: float) -> float:
|
||||
"""The wait before the next attempt; never shorter than the standard backoff, and unchanged for other failures."""
|
||||
floor = {ASLEEP: ASLEEP_FLOOR, LOCAL: LOCAL_FLOOR}.get(reach)
|
||||
if floor is None:
|
||||
return standard
|
||||
return max(standard, floor[min(max(attempt, 0), len(floor) - 1)])
|
||||
|
||||
|
||||
def keeps_cached_channel(reach: str) -> bool:
|
||||
"""A sleeping car says nothing about which RFCOMM channel is right, so its cached channel is kept."""
|
||||
return reach in (ASLEEP, LOCAL)
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Why the last projection reconnected, handed from the supervisor to the next car-screen renderer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from openpilot.common.utils import atomic_write
|
||||
from openpilot.starpilot.system.starport import identity as identity_store
|
||||
|
||||
NOTICE_PATH = identity_store.DATA_DIR / "reconnect_notice.json"
|
||||
NOTICE_FRESH_S = 120.0 # a notice older than this is about an earlier drive or reconnect (monotonic: same boot only)
|
||||
SHARED_RADIO_TEXT = "Reconnected: comma Wi-Fi Was Switching Networks" # on the car screen after a drop caused by the comma's own Wi-Fi
|
||||
|
||||
|
||||
def write(text: str, path: Path = NOTICE_PATH, now: float | None = None) -> None:
|
||||
try:
|
||||
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
with atomic_write(str(path), overwrite=True) as handle:
|
||||
json.dump({"text": text, "written": time.monotonic() if now is None else now, "boot": identity_store.boot_id()}, handle)
|
||||
except OSError:
|
||||
pass # the notice is a courtesy; projection never depends on it
|
||||
|
||||
|
||||
def take(path: Path = NOTICE_PATH, now: float | None = None) -> str:
|
||||
"""Read and remove the pending notice; "" if there is none or it is stale."""
|
||||
try:
|
||||
record = json.loads(path.read_text())
|
||||
except (OSError, ValueError):
|
||||
return ""
|
||||
try:
|
||||
path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
now = time.monotonic() if now is None else now
|
||||
boot = identity_store.boot_id()
|
||||
if not isinstance(record, dict) or not isinstance(record.get("text"), str) or not boot or record.get("boot") != boot:
|
||||
return ""
|
||||
written = record.get("written")
|
||||
if not isinstance(written, (int, float)) or not 0 <= now - written <= NOTICE_FRESH_S:
|
||||
return ""
|
||||
return record["text"]
|
||||
@@ -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
|
||||
|
||||
@@ -80,6 +80,7 @@ BLUETOOTH_AUTH_DATA = 0x8003
|
||||
SENSOR_NIGHT, SENSOR_DRIVING_STATUS = 10, 13
|
||||
|
||||
CODEC_H264_BP = 3
|
||||
CODEC_H265 = 7 # MediaCodecType VIDEO_H265, offered by some head units instead of H.264
|
||||
SETUP_STATUS_READY = 2
|
||||
FOCUS_PROJECTED = 1
|
||||
FOCUS_NATIVE = 2
|
||||
@@ -115,6 +116,8 @@ class PeerRequestedStop(EOFError):
|
||||
pass
|
||||
|
||||
|
||||
class SharedRadioStall(TimeoutError):
|
||||
"""The car stopped acknowledging video while the comma's other Wi-Fi network was joining or roaming."""
|
||||
START_CODE = re.compile(b"\x00\x00(?:\x00)?\x01")
|
||||
|
||||
|
||||
@@ -141,6 +144,39 @@ def wire_frame(access_unit: bytes, *, drop_config: bool) -> bytes:
|
||||
return b"".join(units) or access_unit
|
||||
|
||||
|
||||
# H.265 handling is kept apart from the H.264 functions above so the H.264 path stays byte-for-byte what it was.
|
||||
# A NAL header is two bytes; the type is bits 1-6 of the first (ITU-T H.265 7.3.1.2).
|
||||
HEVC_PARAMETER_SETS = (32, 33, 34) # VPS, SPS, PPS
|
||||
HEVC_AUD = 35
|
||||
|
||||
|
||||
def hevc_nal_type(first_byte: int) -> int:
|
||||
return (first_byte >> 1) & 0x3F
|
||||
|
||||
|
||||
def codec_config_hevc(access_unit: bytes) -> bytes:
|
||||
"""The VPS, SPS and PPS NAL units of an H.265 keyframe, as the codec-config buffer carries them."""
|
||||
starts = list(START_CODE.finditer(access_unit))
|
||||
units = []
|
||||
for index, match in enumerate(starts):
|
||||
end = starts[index + 1].start() if index + 1 < len(starts) else len(access_unit)
|
||||
if match.end() < end and hevc_nal_type(access_unit[match.end()]) in HEVC_PARAMETER_SETS:
|
||||
units.append(b"\x00\x00\x00\x01" + access_unit[match.end():end])
|
||||
return b"".join(units)
|
||||
|
||||
|
||||
def wire_frame_hevc(access_unit: bytes, *, drop_config: bool) -> bytes:
|
||||
"""The H.265 picture NAL units without the access unit delimiter, and without parameter sets once they went separately."""
|
||||
starts = list(START_CODE.finditer(access_unit))
|
||||
drop = {HEVC_AUD, *HEVC_PARAMETER_SETS} if drop_config else {HEVC_AUD}
|
||||
units = []
|
||||
for index, match in enumerate(starts):
|
||||
end = starts[index + 1].start() if index + 1 < len(starts) else len(access_unit)
|
||||
if match.end() < end and hevc_nal_type(access_unit[match.end()]) not in drop:
|
||||
units.append(b"\x00\x00\x00\x01" + access_unit[match.end():end])
|
||||
return b"".join(units) or access_unit
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VideoMode:
|
||||
channel: int
|
||||
@@ -153,6 +189,7 @@ class VideoMode:
|
||||
content_left: int | None = None
|
||||
content_top: int | None = None
|
||||
density: int = 0 # dpi the head unit reports; 0 when it does not say
|
||||
codec: int = CODEC_H264_BP
|
||||
|
||||
@property
|
||||
def content_width(self) -> int:
|
||||
@@ -166,6 +203,7 @@ class VideoMode:
|
||||
return {"channel": self.channel, "config_index": self.config_index, "width": self.width, "height": self.height,
|
||||
"fps": self.fps, "margin_width": self.margin_width, "margin_height": self.margin_height,
|
||||
"density": self.density,
|
||||
**({"codec": CODEC_NAMES[self.codec]} if self.codec != CODEC_H264_BP else {}),
|
||||
**({"content_left": self.content_left, "content_top": self.content_top} if self.content_left is not None else {})}
|
||||
|
||||
|
||||
@@ -188,14 +226,18 @@ def describe_video_configs(channels: list[dict]) -> str:
|
||||
return ", ".join(offered) or "no video"
|
||||
|
||||
|
||||
def choose_video_mode(channels: list[dict]) -> VideoMode:
|
||||
"""Pick a negotiated H.264 mode the software encoder can sustain."""
|
||||
def choose_video_mode(channels: list[dict], allow_hevc: bool = False) -> VideoMode:
|
||||
"""Pick a negotiated H.264 mode the software encoder can sustain.
|
||||
|
||||
H.265 is considered only when the owner allows it and the car offered no usable H.264 mode, so a car that works on
|
||||
H.264 is never changed."""
|
||||
candidates = []
|
||||
hevc_candidates = []
|
||||
for channel in channels:
|
||||
for index, config in enumerate(channel.get("video_configs", [])):
|
||||
resolution = one(config, 1)
|
||||
codec = one(config, 10, CODEC_H264_BP)
|
||||
if resolution not in RESOLUTIONS or codec != CODEC_H264_BP:
|
||||
if resolution not in RESOLUTIONS or codec not in (CODEC_H264_BP, CODEC_H265):
|
||||
continue
|
||||
width, height = RESOLUTIONS[resolution]
|
||||
margin_width, margin_height = int(one(config, 3, 0) or 0), int(one(config, 4, 0) or 0)
|
||||
@@ -215,10 +257,13 @@ def choose_video_mode(channels: list[dict]) -> VideoMode:
|
||||
top_edge + bottom == margin_height and left_edge + right == margin_width):
|
||||
left, top = left_edge, top_edge
|
||||
mode = VideoMode(int(channel["id"]), index, width, height, fps, margin_width, margin_height, left, top,
|
||||
int(one(config, 5, 0) or 0))
|
||||
candidates.append((RESOLUTION_PREFERENCE.index(resolution), fps != 30, index, mode))
|
||||
int(one(config, 5, 0) or 0), codec)
|
||||
(candidates if codec == CODEC_H264_BP else hevc_candidates).append((RESOLUTION_PREFERENCE.index(resolution), fps != 30, index, mode))
|
||||
if not candidates and allow_hevc:
|
||||
candidates = hevc_candidates
|
||||
if not candidates:
|
||||
raise ValueError(f"Head unit did not advertise a supported H.264 video mode (offered {describe_video_configs(channels)})")
|
||||
hint = "; this car offers H.265, which can be turned on in Developer > StarPort" if hevc_candidates else ""
|
||||
raise ValueError(f"Head unit did not advertise a supported H.264 video mode (offered {describe_video_configs(channels)}){hint}")
|
||||
return min(candidates, key=lambda item: item[:3])[3]
|
||||
|
||||
|
||||
@@ -601,9 +646,18 @@ class ProjectionSession(Session):
|
||||
# held it ~1 s on 2026-10-01 while the car kept acking in 27-219 ms. Stays under the 3 s after which a Honda takes
|
||||
# its screen back without video.
|
||||
ACK_TIMEOUT = 2.5
|
||||
# The radio is shared with the comma's own Wi-Fi (aa0 + wlan0): joining or roaming there stalled the car's acks past
|
||||
# 2.5 s on 2026-11-02 (hotspot join, home join, mesh roam). While that happens, wait this long before reconnecting.
|
||||
ACK_GRACE_TIMEOUT = 8.0
|
||||
ACK_DRAIN_SECONDS = 0.1 # before calling an ACK late, read what already arrived, for at most this long
|
||||
MAX_WINDOW = 2
|
||||
nav = None
|
||||
grace_frame: float | None = None # send time of the frame whose late ack is being waited out
|
||||
grace_focus_lost = False
|
||||
|
||||
# Replaced by the supervisor: whether the comma's other Wi-Fi joined or roamed in the last few seconds.
|
||||
radio_change: Callable[..., bool] = staticmethod(lambda: False)
|
||||
|
||||
# HeadunitPad acknowledges every fragment (codec config included) rather than every frame.
|
||||
fragment_acks = False
|
||||
|
||||
@@ -629,8 +683,10 @@ class ProjectionSession(Session):
|
||||
self.config_ack_slack = 0 # codec-config messages a head unit may acknowledge like frames
|
||||
self.epoch_acked = 0
|
||||
self.video_confirmed = False
|
||||
self.video_confirmed_at = 0.0
|
||||
self.input_channel: int | None = None
|
||||
self.max_window = self.MAX_WINDOW # raised by the supervisor for adapters that need the car's full window
|
||||
self.allow_hevc = False # the owner's Allow H.265 video setting; used only when the car offers no H.264 mode
|
||||
self.phone_services = False # open the car-data and Bluetooth channels like a phone (adapter profile)
|
||||
self.phone_frames = False # send picture NAL units only (adapter profile)
|
||||
self.phone_address = "" # this comma's Bluetooth address, for the pairing request
|
||||
@@ -652,7 +708,7 @@ class ProjectionSession(Session):
|
||||
|
||||
def start(self, device_name: str, device_brand: str) -> VideoMode:
|
||||
self.channels = self.discover(device_name, device_brand)
|
||||
self.mode = choose_video_mode(self.channels)
|
||||
self.mode = choose_video_mode(self.channels, self.allow_hevc)
|
||||
self.open_video()
|
||||
self.open_input()
|
||||
if self.phone_services:
|
||||
@@ -667,7 +723,7 @@ class ProjectionSession(Session):
|
||||
opened = parse_fields(self.wait_for(mode.channel, MSG_CHANNEL_OPEN_RESPONSE))
|
||||
if signed(one(opened, 1)) != 0:
|
||||
raise ValueError(f"Head unit rejected opening the video channel: {json_fields(opened)}")
|
||||
self.send(mode.channel, AV_SETUP_REQUEST, field(1, CODEC_H264_BP))
|
||||
self.send(mode.channel, AV_SETUP_REQUEST, field(1, mode.codec))
|
||||
setup = parse_fields(self.wait_for(mode.channel, AV_SETUP_RESPONSE))
|
||||
window = int(one(setup, 2, 0) or 0)
|
||||
if one(setup, 1) != SETUP_STATUS_READY or window < 1: # the window is capped to MAX_WINDOW below; MIB3 offers 100
|
||||
@@ -840,6 +896,8 @@ class ProjectionSession(Session):
|
||||
granted = focus in (FOCUS_PROJECTED, FOCUS_PROJECTED_NO_INPUT)
|
||||
self.focused = granted and self.allow_projection
|
||||
self.event("video_focus", focus=focus, unsolicited=unsolicited, focused=self.focused)
|
||||
if was_focused and not self.focused and self.grace_frame is not None:
|
||||
self.grace_focus_lost = True # the car gave up on the stalled video: reconnect rather than wait unfocused
|
||||
if was_focused and not self.focused and self.touch is not None:
|
||||
self.touch_events.extend(self.touch.reset())
|
||||
if self.focused and not was_focused:
|
||||
@@ -898,6 +956,9 @@ class ProjectionSession(Session):
|
||||
self.unacked -= count
|
||||
self.acked += count
|
||||
self.epoch_acked += count
|
||||
if self.grace_frame is not None and (not self.pending or self.pending[0] != self.grace_frame):
|
||||
self.event("ack_grace_recovered", gap_ms=round((now - self.grace_frame) * 1000))
|
||||
self.grace_frame = None
|
||||
self._confirm_video()
|
||||
|
||||
def _confirm_video(self) -> None:
|
||||
@@ -906,6 +967,7 @@ class ProjectionSession(Session):
|
||||
# bound on acknowledged frames without changing flow-control credit.
|
||||
if not self.video_confirmed and self.epoch_acked > self.config_ack_slack:
|
||||
self.video_confirmed = True
|
||||
self.video_confirmed_at = time.monotonic()
|
||||
self.event("video_acknowledged", session=self.session_id, ack_ms=round(self.max_ack_seconds * 1000))
|
||||
|
||||
# ANDROID AUTO VIDEO PERFORMANCE GUARDRAIL: Receiver ACK flow control bounds in-flight video; extra buffering increases latency.
|
||||
@@ -920,6 +982,8 @@ class ProjectionSession(Session):
|
||||
def check_progress(self, now: float | None = None) -> None:
|
||||
measured = now is None
|
||||
now = time.monotonic() if measured else now
|
||||
if self.grace_focus_lost:
|
||||
raise SharedRadioStall("The car took its screen back while the comma's Wi-Fi was switching networks")
|
||||
if self._ack_overdue(now):
|
||||
# ACKs that arrived while this loop was busy (a slow encode, a CPU burst) still sit in the socket; the
|
||||
# streaming loop drains only a bounded batch per pass, so read what is there before calling the car silent.
|
||||
@@ -929,12 +993,23 @@ class ProjectionSession(Session):
|
||||
if measured:
|
||||
now = time.monotonic()
|
||||
if self._ack_overdue(now):
|
||||
raise TimeoutError(f"Video acknowledgement older than {self.ACK_TIMEOUT:.1f} s")
|
||||
self._wait_out_radio_change(now)
|
||||
# Only a receiver known to answer pings can be declared dead by silence; for
|
||||
# others a lost link surfaces as a socket error or a dropped Wi-Fi lease.
|
||||
if self.ping_responses and now - self.last_rx > 15.0:
|
||||
raise TimeoutError("Head unit stopped answering for 15 s")
|
||||
|
||||
def _wait_out_radio_change(self, now: float) -> None:
|
||||
"""The oldest frame is past ACK_TIMEOUT: give up, unless the comma's other Wi-Fi just changed networks."""
|
||||
oldest = self.pending[0]
|
||||
if self.grace_frame != oldest:
|
||||
if not self.radio_change():
|
||||
raise TimeoutError(f"Video acknowledgement older than {self.ACK_TIMEOUT:.1f} s")
|
||||
self.grace_frame = oldest
|
||||
self.event("ack_grace", after_ms=round((now - oldest) * 1000))
|
||||
if now - oldest > self.ACK_GRACE_TIMEOUT:
|
||||
raise SharedRadioStall(f"Video acknowledgement older than {self.ACK_GRACE_TIMEOUT:.1f} s while the comma's Wi-Fi was switching networks")
|
||||
|
||||
def send_frame(self, data: bytes, timestamp_us: int, *, keyframe: bool) -> None:
|
||||
if not self.can_send():
|
||||
raise RuntimeError("Cannot send without video focus and window credit")
|
||||
@@ -944,13 +1019,13 @@ class ProjectionSession(Session):
|
||||
fragments = 0
|
||||
config = b""
|
||||
if self.needs_keyframe:
|
||||
config = codec_config(data)
|
||||
config = codec_config_hevc(data) if self.mode.codec == CODEC_H265 else codec_config(data)
|
||||
if config:
|
||||
fragments += self.send(self.mode.channel, AV_MEDIA_CODEC_CONFIG, config)
|
||||
if not self.fragment_acks:
|
||||
self.config_ack_slack += 1
|
||||
if self.phone_frames:
|
||||
data = wire_frame(data, drop_config=bool(config))
|
||||
data = (wire_frame_hevc if self.mode.codec == CODEC_H265 else wire_frame)(data, drop_config=bool(config))
|
||||
fragments += self.send(self.mode.channel, AV_MEDIA_WITH_TIMESTAMP, struct.pack(">Q", timestamp_us) + data)
|
||||
if self.fragment_acks:
|
||||
self.frame_fragments.append(fragments)
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
"""Try wireless handshakes in order and remember a strategy after sustained video.
|
||||
|
||||
Only bootstrap timeouts after a version exchange advance the ladder; failures
|
||||
before the car answers or after acknowledged video leave its strategy unchanged.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from openpilot.common.utils import atomic_write
|
||||
from openpilot.starpilot.system.starport.bootstrap import CHANNEL_24GHZ, CHANNEL_DUAL, MODERN_MAJOR, REFUSAL_PATIENCE
|
||||
from openpilot.starpilot.system.starport.identity import DATA_DIR, boot_id as _boot_id
|
||||
|
||||
STATE_PATH = DATA_DIR / "setup_state.json"
|
||||
STATE_VERSION = 1
|
||||
MAX_CARS = 16
|
||||
MAX_PASSES = 3 # full runs through the ladder per ignition cycle; not yet confirmed against real cars
|
||||
DROP_AFTER = 2 # failures at the remembered strategy before it is forgotten and the ladder starts over
|
||||
CLEAN_SECONDS = 60.0 # a session that streamed this long without ending in a handshake failure counts as working
|
||||
LONG_PATIENCE = 2 * REFUSAL_PATIENCE
|
||||
|
||||
RUNGS = ("standard", "other_reply", "modern_dual", "modern_24ghz", "patient", "experiments")
|
||||
LABELS = {
|
||||
"standard": "Standard handshake",
|
||||
"other_reply": "The other version reply",
|
||||
"modern_dual": "Phone-style reply, both Wi-Fi bands",
|
||||
"modern_24ghz": "Phone-style reply, 2.4 GHz",
|
||||
"patient": "Waiting longer for the car",
|
||||
"experiments": "Adapter handshake variations",
|
||||
}
|
||||
MODERN_RUNGS = ("other_reply", "modern_dual", "modern_24ghz") # only mean something to a car that speaks protocol 4 or newer
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Strategy:
|
||||
rung: str
|
||||
modern_reply: bool
|
||||
channel_type: int | None = None # forces the Wi-Fi band reply inside the phone-style reply; None keeps the car's own preference
|
||||
patience: int = REFUSAL_PATIENCE
|
||||
experiments: bool = False
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {"rung": self.rung, "modern_reply": self.modern_reply, "channel_type": self.channel_type,
|
||||
"patience": self.patience, "experiments": self.experiments}
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
return LABELS.get(self.rung, self.rung)
|
||||
|
||||
|
||||
def resolve(rung: str, base_modern: bool) -> Strategy:
|
||||
"""The concrete strategy for a rung, relative to the car's remembered reply style (today's behavior)."""
|
||||
if rung == "other_reply":
|
||||
return Strategy(rung, not base_modern)
|
||||
if rung == "modern_dual":
|
||||
return Strategy(rung, True, CHANNEL_DUAL)
|
||||
if rung == "modern_24ghz":
|
||||
return Strategy(rung, True, CHANNEL_24GHZ)
|
||||
if rung == "patient":
|
||||
return Strategy(rung, base_modern, patience=LONG_PATIENCE)
|
||||
if rung == "experiments":
|
||||
return Strategy(rung, base_modern, experiments=True)
|
||||
return Strategy("standard", base_modern)
|
||||
|
||||
|
||||
def applicable(rung: str, version_major: int, adapter: bool) -> bool:
|
||||
if rung in MODERN_RUNGS:
|
||||
return version_major >= MODERN_MAJOR
|
||||
if rung == "experiments":
|
||||
return adapter
|
||||
return True
|
||||
|
||||
|
||||
def _valid_strategy(value) -> Strategy | None:
|
||||
if not isinstance(value, dict) or value.get("rung") not in RUNGS:
|
||||
return None
|
||||
channel, patience = value.get("channel_type"), value.get("patience")
|
||||
if type(value.get("modern_reply")) is not bool or type(value.get("experiments")) is not bool:
|
||||
return None
|
||||
if channel not in (None, CHANNEL_24GHZ, CHANNEL_DUAL, 1) or type(patience) is not int or not 1 <= patience <= 12:
|
||||
return None
|
||||
return Strategy(value["rung"], value["modern_reply"], channel, patience, value["experiments"])
|
||||
|
||||
|
||||
def _fresh() -> dict:
|
||||
return {"rung": "standard", "results": {}, "passes": 0, "boot": "", "working": None, "working_failures": 0,
|
||||
"t_ready": [], "successes": 0, "updated": 0.0}
|
||||
|
||||
|
||||
def _clean(entry) -> dict:
|
||||
"""A saved entry, or a fresh one when anything about it is not what this module writes."""
|
||||
fresh = _fresh()
|
||||
if not isinstance(entry, dict):
|
||||
return fresh
|
||||
try:
|
||||
rung = entry.get("rung")
|
||||
results = entry.get("results")
|
||||
if rung in RUNGS:
|
||||
fresh["rung"] = rung
|
||||
if isinstance(results, dict):
|
||||
fresh["results"] = {key: value for key, value in results.items() if key in RUNGS and value in ("failed", "skipped", "worked")}
|
||||
for key in ("passes", "working_failures", "successes"):
|
||||
if type(entry.get(key)) is int and 0 <= entry[key] <= 10_000:
|
||||
fresh[key] = entry[key]
|
||||
fresh["boot"] = entry["boot"][:64] if isinstance(entry.get("boot"), str) else ""
|
||||
working = _valid_strategy(entry.get("working"))
|
||||
fresh["working"] = working.as_dict() if working else None
|
||||
fresh["t_ready"] = [round(float(value), 1) for value in entry.get("t_ready", []) if isinstance(value, (int, float)) and 0 <= value <= 3600][-8:]
|
||||
fresh["updated"] = float(entry["updated"]) if isinstance(entry.get("updated"), (int, float)) else 0.0
|
||||
except (TypeError, ValueError, KeyError):
|
||||
return _fresh()
|
||||
return fresh
|
||||
|
||||
|
||||
class SetupLadder:
|
||||
def __init__(self, path: Path | None = None, boot_id=_boot_id, clock=time.time): # noqa: TID251 persisted timestamps
|
||||
self.path = path or STATE_PATH
|
||||
self.boot_id, self.clock = boot_id, clock
|
||||
self.cars: dict[str, dict] = {}
|
||||
self.load()
|
||||
|
||||
def load(self) -> None:
|
||||
try:
|
||||
data = json.loads(self.path.read_text())
|
||||
cars = data["cars"] if isinstance(data, dict) and data.get("version") == STATE_VERSION and isinstance(data.get("cars"), dict) else {}
|
||||
except (OSError, ValueError, KeyError, TypeError, UnicodeError):
|
||||
cars = {}
|
||||
self.cars = {str(address).upper(): _clean(entry) for address, entry in list(cars.items())[:MAX_CARS]}
|
||||
|
||||
def save(self) -> bool:
|
||||
ordered = dict(sorted(self.cars.items(), key=lambda item: item[1]["updated"], reverse=True)[:MAX_CARS])
|
||||
self.cars = ordered
|
||||
try:
|
||||
self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
with atomic_write(str(self.path), overwrite=True) as handle:
|
||||
json.dump({"version": STATE_VERSION, "cars": ordered}, handle, separators=(",", ":"))
|
||||
except OSError:
|
||||
return False # progress is a convenience; a read-only disk must never stop a connection
|
||||
return True
|
||||
|
||||
def _entry(self, address: str) -> dict:
|
||||
entry = self.cars.setdefault(address.upper(), _fresh())
|
||||
boot = self.boot_id()
|
||||
if entry["boot"] != boot: # a reboot is also a new cycle, for a comma that was powered off between drives
|
||||
entry["boot"], entry["passes"] = boot, 0
|
||||
return entry
|
||||
|
||||
def begin_cycle(self, address: str) -> None:
|
||||
"""A new ignition cycle: the passes are used up per drive, not per boot (the comma can stay powered between drives).
|
||||
|
||||
Resumes at the first untried rung. Does nothing for a car with a learned strategy or nothing tried."""
|
||||
entry = self.cars.get(address.upper())
|
||||
if entry and entry["passes"]:
|
||||
entry["passes"] = 0
|
||||
self.save()
|
||||
|
||||
def forget(self, address: str) -> bool:
|
||||
"""A car's pairing was deleted: drop what was learned about it."""
|
||||
removed = self.cars.pop(address.upper(), None) is not None
|
||||
if removed:
|
||||
self.save()
|
||||
return removed
|
||||
|
||||
def exhausted(self, address: str) -> bool:
|
||||
entry = self.cars.get(address.upper())
|
||||
return bool(entry) and entry["boot"] == self.boot_id() and entry["passes"] >= MAX_PASSES and not entry["working"]
|
||||
|
||||
def strategy(self, address: str, base_modern: bool) -> Strategy:
|
||||
"""What to try now: the strategy that worked, else the current rung (standard once the passes are used up)."""
|
||||
entry = self._entry(address)
|
||||
working = _valid_strategy(entry["working"])
|
||||
if working:
|
||||
return working
|
||||
if entry["passes"] >= MAX_PASSES:
|
||||
return resolve("standard", base_modern)
|
||||
return resolve(entry["rung"], base_modern)
|
||||
|
||||
def failed(self, address: str, strategy: Strategy, *, ladder_failure: bool, video_seen: bool, version_major: int,
|
||||
adapter: bool) -> str | None:
|
||||
"""Record a failed attempt; returns what changed ("advanced", "wrapped", "exhausted", "counted", "dropped") or None.
|
||||
|
||||
Only a handshake the car answered, before any video this session, counts: see the module docstring."""
|
||||
if not ladder_failure or video_seen:
|
||||
return None
|
||||
entry = self._entry(address)
|
||||
entry["updated"] = self.clock()
|
||||
working = _valid_strategy(entry["working"])
|
||||
dropped = False
|
||||
if working and strategy.rung == working.rung:
|
||||
entry["working_failures"] += 1
|
||||
if entry["working_failures"] < DROP_AFTER:
|
||||
self.save()
|
||||
return "counted"
|
||||
# Forgotten, and what just failed twice is not tried again first: carry on from the next rung.
|
||||
entry.update(working=None, working_failures=0, results={})
|
||||
dropped = True
|
||||
if entry["passes"] >= MAX_PASSES:
|
||||
return None
|
||||
entry["results"][strategy.rung] = "failed"
|
||||
start = RUNGS.index(strategy.rung) if strategy.rung in RUNGS else 0
|
||||
outcome = "wrapped"
|
||||
for rung in RUNGS[start + 1:]:
|
||||
if applicable(rung, version_major, adapter):
|
||||
entry["rung"], outcome = rung, "advanced"
|
||||
break
|
||||
entry["results"][rung] = "skipped"
|
||||
if outcome == "wrapped":
|
||||
entry["passes"] += 1
|
||||
entry["rung"], entry["results"] = "standard", {}
|
||||
if entry["passes"] >= MAX_PASSES:
|
||||
outcome = "exhausted"
|
||||
self.save()
|
||||
return "dropped" if dropped else outcome
|
||||
|
||||
def worked(self, address: str, strategy: Strategy, t_ready_s: float | None = None) -> None:
|
||||
entry = self._entry(address)
|
||||
entry.update(working=strategy.as_dict(), working_failures=0, rung=strategy.rung, results={strategy.rung: "worked"},
|
||||
passes=0, successes=entry["successes"] + 1, updated=self.clock())
|
||||
if t_ready_s is not None and 0 <= t_ready_s <= 3600:
|
||||
entry["t_ready"] = (entry["t_ready"] + [round(float(t_ready_s), 1)])[-8:]
|
||||
self.save()
|
||||
|
||||
def ready_patience(self, address: str, stage_timeout: float = 20.0) -> int:
|
||||
"""Stage timeouts a slow car needs: it was ready after t_ready_s once, so the bootstrap must be willing to wait that long.
|
||||
|
||||
Never less than the standard patience, and 0 (no opinion) for a car nothing is known about."""
|
||||
entry = self.cars.get(address.upper())
|
||||
times = entry["t_ready"] if entry else []
|
||||
return min(9, int(-(-max(times) // stage_timeout))) if times else 0
|
||||
|
||||
def snapshot(self, address: str, adapter: bool = False) -> dict:
|
||||
"""What the setup screen and the reports show: every rung, its state, and whether this car has a learned strategy."""
|
||||
entry = self.cars.get(address.upper()) if address else None
|
||||
entry = entry or _fresh()
|
||||
working = _valid_strategy(entry["working"])
|
||||
current = working.rung if working else entry["rung"]
|
||||
passes = entry["passes"] if entry["boot"] == self.boot_id() else 0 # passes belong to one ignition cycle
|
||||
steps = []
|
||||
for rung in RUNGS:
|
||||
if rung == "experiments" and not adapter:
|
||||
continue
|
||||
state = entry["results"].get(rung) or ("current" if rung == current else "waiting")
|
||||
steps.append({"id": rung, "label": LABELS[rung], "state": state})
|
||||
return {"steps": steps, "current": current, "learned": bool(working), "passes": passes, "max_passes": MAX_PASSES,
|
||||
"exhausted": passes >= MAX_PASSES and not working, "successes": entry["successes"],
|
||||
"t_ready_s": entry["t_ready"][-1] if entry["t_ready"] else None}
|
||||
@@ -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.
|
||||
|
||||
+329
@@ -0,0 +1,329 @@
|
||||
{
|
||||
"source": "Bugsink Android Auto project, failure reports pulled 2026-11-01 (sanitized by the reporter)",
|
||||
"failures": [
|
||||
{
|
||||
"stage": "discovering",
|
||||
"kind": "RuntimeError",
|
||||
"evidence": "Could not find the car's Android Auto service: [Errno 112] Host is down",
|
||||
"count": 116
|
||||
},
|
||||
{
|
||||
"stage": "wifi_start",
|
||||
"kind": "BootstrapTimeout",
|
||||
"evidence": "wifi_start: head unit did not answer in time",
|
||||
"count": 59
|
||||
},
|
||||
{
|
||||
"stage": "rfcomm",
|
||||
"kind": "ConnectionResetError",
|
||||
"evidence": "[Errno 104] Connection reset by peer",
|
||||
"count": 53
|
||||
},
|
||||
{
|
||||
"stage": "streaming",
|
||||
"kind": "TimeoutError",
|
||||
"evidence": "Video acknowledgement older than 2.5 s",
|
||||
"count": 41
|
||||
},
|
||||
{
|
||||
"stage": "streaming",
|
||||
"kind": "TimeoutError",
|
||||
"evidence": "Head unit stopped answering for 15 s",
|
||||
"count": 37
|
||||
},
|
||||
{
|
||||
"stage": "connecting_bluetooth",
|
||||
"kind": "RuntimeError",
|
||||
"evidence": "org.bluez.Error.Failed: br-connection-page-timeout",
|
||||
"count": 36
|
||||
},
|
||||
{
|
||||
"stage": "rfcomm",
|
||||
"kind": "OSError",
|
||||
"evidence": "[Errno 112] Host is down",
|
||||
"count": 35
|
||||
},
|
||||
{
|
||||
"stage": "rfcomm",
|
||||
"kind": "ConnectionRefusedError",
|
||||
"evidence": "[Errno 111] Connection refused",
|
||||
"count": 35
|
||||
},
|
||||
{
|
||||
"stage": "connecting_bluetooth",
|
||||
"kind": "TimeoutError",
|
||||
"evidence": "TimeoutError",
|
||||
"count": 27
|
||||
},
|
||||
{
|
||||
"stage": "connecting_bluetooth",
|
||||
"kind": "RuntimeError",
|
||||
"evidence": "Connect your paired car in Galaxy Bluetooth first to select it for this Android Auto adapter",
|
||||
"count": 20
|
||||
},
|
||||
{
|
||||
"stage": "streaming",
|
||||
"kind": "EOFError",
|
||||
"evidence": "Head unit disconnected after 0/4 bytes",
|
||||
"count": 18
|
||||
},
|
||||
{
|
||||
"stage": "wifi_info",
|
||||
"kind": "ConnectionResetError",
|
||||
"evidence": "getting car wi-fi: [Errno 104] Connection reset by peer",
|
||||
"count": 18
|
||||
},
|
||||
{
|
||||
"stage": "joining_wifi",
|
||||
"kind": "BootstrapError",
|
||||
"evidence": "joining car wi-fi: joining_wifi: AddAndActivateConnection2 failed: Connection 'starpilot-android-auto' is not available on device wlan0 because device is strictly unmanaged",
|
||||
"count": 14
|
||||
},
|
||||
{
|
||||
"stage": "authenticating",
|
||||
"kind": "EOFError",
|
||||
"evidence": "Head unit disconnected after 0/4 bytes",
|
||||
"count": 14
|
||||
},
|
||||
{
|
||||
"stage": "discovering",
|
||||
"kind": "RuntimeError",
|
||||
"evidence": "Could not find the car's Android Auto service: [Errno 111] Connection refused",
|
||||
"count": 13
|
||||
},
|
||||
{
|
||||
"stage": "rfcomm",
|
||||
"kind": "TimeoutError",
|
||||
"evidence": "timed out",
|
||||
"count": 12
|
||||
},
|
||||
{
|
||||
"stage": "wifi_info",
|
||||
"kind": "BootstrapTimeout",
|
||||
"evidence": "getting car wi-fi: wifi_info: head unit did not answer in time",
|
||||
"count": 11
|
||||
},
|
||||
{
|
||||
"stage": "authenticating",
|
||||
"kind": "TimeoutError",
|
||||
"evidence": "timed out",
|
||||
"count": 10
|
||||
},
|
||||
{
|
||||
"stage": "rfcomm",
|
||||
"kind": "OSError",
|
||||
"evidence": "[Errno 16] Device or resource busy",
|
||||
"count": 10
|
||||
},
|
||||
{
|
||||
"stage": "negotiating",
|
||||
"kind": "TimeoutError",
|
||||
"evidence": "timed out",
|
||||
"count": 10
|
||||
},
|
||||
{
|
||||
"stage": "joining_wifi",
|
||||
"kind": "BootstrapError",
|
||||
"evidence": "joining car wi-fi: joining_wifi: ",
|
||||
"count": 6
|
||||
},
|
||||
{
|
||||
"stage": "discovering",
|
||||
"kind": "RuntimeError",
|
||||
"evidence": "Could not find the car's Android Auto service: Head unit does not advertise Android Auto Wireless (SDP record missing)",
|
||||
"count": 6
|
||||
},
|
||||
{
|
||||
"stage": "joining_wifi",
|
||||
"kind": "BootstrapError",
|
||||
"evidence": "joining car wi-fi: joining_wifi: The projection network connection was removed (wrong key or out of range?)",
|
||||
"count": 5
|
||||
},
|
||||
{
|
||||
"stage": "negotiating",
|
||||
"kind": "EOFError",
|
||||
"evidence": "Head unit disconnected after 0/4 bytes",
|
||||
"count": 5
|
||||
},
|
||||
{
|
||||
"stage": "streaming",
|
||||
"kind": "ConnectionResetError",
|
||||
"evidence": "[Errno 104] Connection reset by peer",
|
||||
"count": 5
|
||||
},
|
||||
{
|
||||
"stage": "rfcomm",
|
||||
"kind": "OSError",
|
||||
"evidence": "[Errno 38] Function not implemented",
|
||||
"count": 5
|
||||
},
|
||||
{
|
||||
"stage": "discovering",
|
||||
"kind": "RuntimeError",
|
||||
"evidence": "Could not find the car's Android Auto service: timed out",
|
||||
"count": 5
|
||||
},
|
||||
{
|
||||
"stage": "connecting_tcp",
|
||||
"kind": "RuntimeError",
|
||||
"evidence": "Car did not accept the projection connection: [Errno 111] Connection refused",
|
||||
"count": 5
|
||||
},
|
||||
{
|
||||
"stage": "joining_wifi",
|
||||
"kind": "BootstrapError",
|
||||
"evidence": "joining car wi-fi: joining_wifi: AddAndActivateConnection2 failed: Connection 'starpilot-starport' is not available on device wlan0 because device is strictly unmanaged",
|
||||
"count": 5
|
||||
},
|
||||
{
|
||||
"stage": "negotiating",
|
||||
"kind": "ValueError",
|
||||
"evidence": "Head unit did not advertise a supported H.264 video mode (offered 1440x2560 H.265 margins 288x1378, 1080x1920 H.265 margins 0x811, 720x1280 H.265 margins 0x541, 1920x1080 H.265 margins 0x360 (display 1))",
|
||||
"count": 4
|
||||
},
|
||||
{
|
||||
"stage": "streaming",
|
||||
"kind": "NativeFocusHeld",
|
||||
"evidence": "Car kept its own screen for 15 s; reconnecting",
|
||||
"count": 4
|
||||
},
|
||||
{
|
||||
"stage": "discovering",
|
||||
"kind": "RuntimeError",
|
||||
"evidence": "Could not find the car's Android Auto service: [Errno 104] Connection reset by peer",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"stage": "connecting_bluetooth",
|
||||
"kind": "RuntimeError",
|
||||
"evidence": "org.bluez.Error.Failed: br-connection-unknown",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"stage": "connecting_bluetooth",
|
||||
"kind": "BluetoothRejected",
|
||||
"evidence": "Bluetooth adapter is busy in another process",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"stage": "negotiating",
|
||||
"kind": "ValueError",
|
||||
"evidence": "Hardware encoder supports even sizes up to 1280x720 at 30 fps",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"stage": "rfcomm",
|
||||
"kind": "TimeoutError",
|
||||
"evidence": "[Errno 110] Connection timed out",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"stage": "connecting_tcp",
|
||||
"kind": "RuntimeError",
|
||||
"evidence": "Car did not accept the projection connection: [Errno 99] Cannot assign requested address",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"stage": "discovering",
|
||||
"kind": "RuntimeError",
|
||||
"evidence": "Could not find the car's Android Auto service: [Errno 114] Operation already in progress",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"stage": "authenticating",
|
||||
"kind": "ValueError",
|
||||
"evidence": "Expected authentication status; got 0/0x3",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"stage": "waiting_for_usb",
|
||||
"kind": "RuntimeError",
|
||||
"evidence": "plug into the car's usb: The car did not finish USB setup. Use its Android Auto data port and a data-capable cable",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"stage": "streaming",
|
||||
"kind": "TimeoutError",
|
||||
"evidence": "timed out",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"stage": "discovering",
|
||||
"kind": "RuntimeError",
|
||||
"evidence": "Could not find the car's Android Auto service: [Errno 38] Function not implemented",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"stage": "connecting_bluetooth",
|
||||
"kind": "RuntimeError",
|
||||
"evidence": "org.bluez.Error.Failed: br-connection-refused",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"stage": "joining_wifi",
|
||||
"kind": "BootstrapError",
|
||||
"evidence": "joining car wi-fi: joining_wifi: Timed out joining the projection network after 30 s",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"stage": "rfcomm",
|
||||
"kind": "PermissionError",
|
||||
"evidence": "[Errno 13] Permission denied",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"stage": "connecting_bluetooth",
|
||||
"kind": "BluetoothRejected",
|
||||
"evidence": "org.freedesktop.systemd1.NoSuchUnit",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"stage": "joining_wifi",
|
||||
"kind": "BootstrapError",
|
||||
"evidence": "joining car wi-fi: joining_wifi: Joined the projection network but received no IPv4 address",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"stage": "authenticating",
|
||||
"kind": "SSLCertVerificationError",
|
||||
"evidence": "[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: format error in certificate's notBefore field (_ssl.c:1000) (set verify_head_unit false in config.json to test without verifying the car)",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"stage": "streaming",
|
||||
"kind": "RuntimeError",
|
||||
"evidence": "Lost the car's Wi-Fi network",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"stage": "rfcomm",
|
||||
"kind": "BlockingIOError",
|
||||
"evidence": "[Errno 114] Operation already in progress",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"stage": "negotiating",
|
||||
"kind": "TimeoutError",
|
||||
"evidence": "Head unit did not send 0/0x6",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"stage": "streaming",
|
||||
"kind": "TimeoutExpired",
|
||||
"evidence": "Command '['/usr/local/venv/bin/python3', '-m', 'openpilot.starpilot.system.android_auto.current_car_ui', '--frames', '/dev/shm/starpilot_android_auto_car_frame', '--touch', '/tmp/starpilot-android-auto-touch.sock', '--control', '/tmp/starpi",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"stage": "authenticating",
|
||||
"kind": "ConnectionResetError",
|
||||
"evidence": "[Errno 104] Connection reset by peer",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"stage": "connecting_tcp",
|
||||
"kind": "RuntimeError",
|
||||
"evidence": "Car did not accept the projection connection: timed out",
|
||||
"count": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -19,8 +19,8 @@ def identity(tmp_path_factory):
|
||||
return make_identity(tmp_path_factory.mktemp("identity"))
|
||||
|
||||
|
||||
def decide(policy, now, onroad=False, car_link=False, ready=True, running=False, enabled=True):
|
||||
return policy.decide(now, enabled=enabled, onroad=onroad, car_link=car_link, ready=ready, running=running)
|
||||
def decide(policy, now, onroad=False, car_link=False, ready=True, running=False, enabled=True, linked=False):
|
||||
return policy.decide(now, enabled=enabled, onroad=onroad, car_link=car_link, ready=ready, running=running, linked=linked)
|
||||
|
||||
|
||||
# -------------------------------------------------------------------- policy
|
||||
@@ -45,12 +45,29 @@ def test_policy_stops_after_car_gone():
|
||||
assert decide(policy, 100) is None # stopped, car still gone: no restart
|
||||
|
||||
|
||||
def test_policy_keeps_a_projecting_session_when_the_car_link_drops():
|
||||
policy = AutoConnectPolicy()
|
||||
assert decide(policy, 0, car_link=True) == "start"
|
||||
assert decide(policy, 1, car_link=True, running=True) is None
|
||||
for now in range(2, 400, 10):
|
||||
assert decide(policy, now, running=True, linked=True) is None
|
||||
assert decide(policy, 400, running=True) is None # stream ended: grace period starts
|
||||
assert decide(policy, 400 + auto_connect.STOP_AFTER, running=True) == "stop"
|
||||
|
||||
|
||||
def test_policy_leaves_sessions_that_never_saw_the_car():
|
||||
policy = AutoConnectPolicy()
|
||||
for now in range(0, 300, 10):
|
||||
assert decide(policy, now, running=True) is None
|
||||
|
||||
|
||||
def test_policy_hold_blocks_start_while_idle_until_next_drive():
|
||||
policy = AutoConnectPolicy()
|
||||
policy.hold()
|
||||
assert decide(policy, 0, car_link=True) is None
|
||||
assert decide(policy, 1, onroad=True, car_link=True) == "start"
|
||||
|
||||
|
||||
def test_policy_manual_stop_holds_until_next_drive():
|
||||
policy = AutoConnectPolicy()
|
||||
assert decide(policy, 0, onroad=True) == "start"
|
||||
|
||||
@@ -261,8 +261,12 @@ def test_donor_phone_uses_same_lease_and_does_not_take_over_independently(tmp_pa
|
||||
def devices(self):
|
||||
return []
|
||||
|
||||
def adapter(self):
|
||||
return '/org/bluez/hci0', {'Address': '39:90:21:64:07:00'}
|
||||
|
||||
phone = FakePhone()
|
||||
wrapper = LeaseAwarePhone(shared, state['session'], lambda *a, **k: None, lambda log: phone)
|
||||
assert wrapper.adapter()[1]['Address'] == '39:90:21:64:07:00' # read before any lease, for the pairing request
|
||||
with pytest.raises(BluetoothAdmissionError, match='not active'):
|
||||
wrapper.register_hfp()
|
||||
wrapper.acquire_pairing()
|
||||
|
||||
@@ -14,9 +14,10 @@ INFO = field(1, "AAWireless") + field(2, "secret-key") + field(3, "AA:BB:CC:DD:E
|
||||
SETUP_INFO = field(4, ENDPOINT) + field(5, field(1, "AAWireless") + field(3, "secret-key") + field(4, 8))
|
||||
|
||||
|
||||
def scripted(frames, *, ping_interval=bs.JOIN_PING_INTERVAL):
|
||||
def scripted(frames, *, ping_interval=bs.JOIN_PING_INTERVAL, experiment=""):
|
||||
"""A bootstrap whose car sends `frames` in order, then stays silent; records what the comma sent."""
|
||||
boot = bs.WirelessBootstrap(None, lambda *a, **k: None, stage_timeout=2, join_ping_interval=ping_interval)
|
||||
boot = bs.WirelessBootstrap(None, lambda *a, **k: None, stage_timeout=2, join_ping_interval=ping_interval,
|
||||
experiment=experiment)
|
||||
sent = []
|
||||
boot.send = Mock(side_effect=lambda message_id, payload=b"": sent.append(message_id))
|
||||
queue = list(frames)
|
||||
@@ -100,3 +101,87 @@ def test_stop_during_the_join_is_not_reported_as_connected():
|
||||
with pytest.raises(bs.BootstrapError, match="cancelled"):
|
||||
boot.run(join, cancelled=stop.is_set)
|
||||
assert bs.WIFI_CONNECT_STATUS not in sent
|
||||
|
||||
|
||||
VERSION_F6 = (field(1, 1) + field(2, 7) + field(4, 5180) + field(5, field(1, "Link") + field(5, "CarLetter")) +
|
||||
field(6, field(1, "192.168.43.158") + field(2, 40707)))
|
||||
|
||||
|
||||
def test_version_endpoint_in_field_six_is_read_only_when_asked():
|
||||
assert bs.parse_version_request(VERSION_F6)[2] is None
|
||||
assert bs.parse_version_request(VERSION_F6, (6,))[2] == bs.Endpoint("192.168.43.158", 40707)
|
||||
|
||||
|
||||
def test_start_request_is_answered_early_only_by_the_experiment():
|
||||
for experiment, early in (("", False), ("start_response_first", True), ("all", True)):
|
||||
boot, sent = scripted([(bs.WIFI_START_REQUEST, ENDPOINT), (bs.WIFI_INFO_RESPONSE, INFO)], experiment=experiment)
|
||||
boot.run(slow_join(0, []))
|
||||
assert (sent[:2] == [bs.WIFI_START_RESPONSE, bs.WIFI_INFO_REQUEST]) is early
|
||||
|
||||
|
||||
def test_experiment_logs_frames_but_never_credentials():
|
||||
events = []
|
||||
boot = bs.WirelessBootstrap(None, lambda name, **v: events.append((name, v)), experiment="all")
|
||||
boot.sock = Mock()
|
||||
boot.send(bs.WIFI_START_RESPONSE, b"\x18\x00")
|
||||
boot.queue = [(bs.WIFI_INFO_RESPONSE, INFO), (bs.WIFI_START_REQUEST, ENDPOINT)]
|
||||
boot.next_frame(1)
|
||||
boot.next_frame(1)
|
||||
dumped = [v for name, v in events if name == "bootstrap_frame_hex"]
|
||||
assert [d["message"] for d in dumped] == ["WifiStartResponse", "WifiStartRequest"]
|
||||
assert INFO.hex() not in str(events)
|
||||
assert bs.WirelessBootstrap(None, lambda *a, **k: events.append(a), experiment="bogus").experiment == ""
|
||||
|
||||
|
||||
def test_a_car_that_keeps_refusing_gets_more_time_but_not_forever():
|
||||
refusal = field(3, 1)
|
||||
boot, sent = scripted([(bs.WIFI_START_RESPONSE, refusal)] * 3 + [(bs.WIFI_START_REQUEST, ENDPOINT), (bs.WIFI_INFO_RESPONSE, INFO)])
|
||||
boot.stage_timeout = 0.2
|
||||
boot.start_request_delay = 0.01
|
||||
assert boot.run(slow_join(0, [])).endpoint.port == 5288
|
||||
silent, _ = scripted([])
|
||||
silent.stage_timeout = 0.2
|
||||
idle, late = silent.next_frame, [True]
|
||||
|
||||
def refuse_late(timeout):
|
||||
if late:
|
||||
late.clear()
|
||||
time.sleep(0.15) # just before the original deadline
|
||||
return bs.WIFI_START_RESPONSE, refusal
|
||||
return idle(timeout)
|
||||
|
||||
silent.next_frame = refuse_late
|
||||
started = time.monotonic()
|
||||
with pytest.raises(bs.BootstrapTimeout):
|
||||
silent.run(slow_join(0, []))
|
||||
elapsed = time.monotonic() - started
|
||||
assert 0.3 < elapsed < 0.2 * bs.REFUSAL_PATIENCE + 0.5
|
||||
|
||||
|
||||
KIA_V41 = field(1, 4) + field(2, 1) + field(3, 0) + field(4, 5745) + field(5, field(1, "KIA") + field(5, "MOBIS"))
|
||||
DHU_V42 = field(1, 4) + field(2, 2) + field(3, 2) + field(5, field(1, "Google") + field(6, "Desktop Head Unit"))
|
||||
FORD_V16 = field(1, 1) + field(2, 6) + field(3, 0) + field(4, 5180) + field(4, 5745) + field(5, field(1, "Ford"))
|
||||
|
||||
|
||||
def version_reply(request, modern=True):
|
||||
boot, _ = scripted([])
|
||||
boot.modern_reply = modern
|
||||
sent = []
|
||||
boot.send = Mock(side_effect=lambda message_id, payload=b"": sent.append(payload))
|
||||
boot.next_frame = Mock(side_effect=[(bs.WIFI_VERSION_REQUEST, request), bs.BootstrapError("wifi_start", "stop")])
|
||||
with pytest.raises(bs.BootstrapError):
|
||||
boot.run(slow_join(0, []))
|
||||
return bs.parse_fields(sent[0])
|
||||
|
||||
|
||||
def test_version_four_head_units_get_a_phone_shaped_version_reply():
|
||||
kia, dhu = version_reply(KIA_V41), version_reply(DHU_V42)
|
||||
assert kia[1] == [4] and kia[2] == [1] and kia[4] == [1] and kia[5] == [bs.CHANNEL_5GHZ]
|
||||
assert dhu[4] == [1] and dhu[5] == [bs.CHANNEL_24GHZ]
|
||||
session = bs.parse_fields(kia[6][0])
|
||||
assert bs.text(session, 1) == "starpilot" and len(bs.text(session, 2)) == 32
|
||||
|
||||
|
||||
def test_older_head_units_and_the_first_try_keep_the_reply_that_works_today():
|
||||
for reply in (version_reply(FORD_V16), version_reply(KIA_V41, modern=False)):
|
||||
assert reply[4] == [0] and 5 not in reply and 6 not in reply
|
||||
|
||||
@@ -441,3 +441,89 @@ def test_pairing_that_failed_carries_a_device_slice_but_success_does_not(tmp_pat
|
||||
assert event["contexts"]["starport_pairing_system"]["bluetooth"]["lines"] == ["bt '<name>' up"]
|
||||
assert calls[0][1] - calls[0][0] == 30
|
||||
assert "starport_pairing_system" not in br.pairing_event({**record, "result": "paired"}, collector)["contexts"]
|
||||
|
||||
|
||||
OFFER = {"channels": [{"id": 3, "video_configs": [
|
||||
{"1": [2], "10": [3], "3": [0], "4": [240]},
|
||||
{"1": [8], "10": [7], "3": [288], "4": [1378]},
|
||||
{"1": [7], "10": [7], "3": [0], "4": [811]}]}], "head_unit": {"2": ["Honda"]}}
|
||||
|
||||
|
||||
def adapter_session():
|
||||
events: list = [list(item) for item in STREAMED]
|
||||
events[0] = (0, 0, "session_start", {"receiver": "AAWireless", "trigger": "auto",
|
||||
"setup": {"type": "adapter", "profile": "aawireless", "profile_source": "override"}})
|
||||
events[5] = (0, 10, "discovered", {**OFFER, "head_unit": STREAMED[5][3]["head_unit"]})
|
||||
events.insert(6, (0, 10, "bootstrap_start_response", {}))
|
||||
events.insert(7, (0, 10, "bootstrap_version_reply", {"style": "modern", "channel_type": 1}))
|
||||
events.insert(8, (0, 10, "bluetooth_opened", {"channel": 6, "phone_address": "39:90:21:64:07:00"}))
|
||||
events.insert(9, (0, 11, "video_setup", {"mode": {"config_index": 0}, "window": 30, "used_window": 30}))
|
||||
return events
|
||||
|
||||
|
||||
def test_report_keeps_setup_offered_modes_and_the_working_strategy(tmp_path):
|
||||
record = br.session_record(write_session(tmp_path, 1, adapter_session()))
|
||||
assert record["setup"] == {"type": "adapter", "profile": "aawireless", "profile_source": "override"}
|
||||
assert record["offered_modes"] == ["1280x720 H.264 margins 0x240", "1440x2560 H.265 margins 288x1378", "1080x1920 H.265 margins 0x811"]
|
||||
assert record["phone_address_sent"] is True
|
||||
assert record["strategy"] == {"profile": "aawireless", "window_offered": 30, "window_used": 30,
|
||||
"reply_style": "modern", "channel_type": 1, "config_index": 0}
|
||||
event = br.drive_event([record])
|
||||
tags = event["tags"]
|
||||
assert (tags["aa.setup_type"], tags["aa.adapter_model"], tags["aa.profile_source"], tags["aa.phone_address_sent"]) == (
|
||||
"adapter", "aawireless", "override", "yes")
|
||||
assert (tags["aa.reply_style"], tags["aa.wifi_channel_type"]) == ("modern", "1")
|
||||
context = event["contexts"]["starport_session_1"]
|
||||
assert context["offered_modes"] == record["offered_modes"] and context["strategy"]["reply_style"] == "modern"
|
||||
assert "39:90:21:64:07:00" not in json.dumps(event) # the comma's own address is sanitized from every field
|
||||
|
||||
|
||||
def test_report_records_that_the_address_was_not_sent_and_old_logs_still_report(tmp_path):
|
||||
events: list = [list(item) for item in STREAMED]
|
||||
events.insert(6, (0, 10, "bluetooth_opened", {"channel": 6, "phone_address": ""}))
|
||||
assert br.drive_event([br.session_record(write_session(tmp_path, 1, events))])["tags"]["aa.phone_address_sent"] == "no"
|
||||
old = br.session_record(write_session(tmp_path, 2, STREAMED)) # a log written before these fields existed
|
||||
assert old["setup"] == {} and old["offered_modes"] == [] and old["phone_address_sent"] is None and old["strategy"] == {}
|
||||
tags = br.drive_event([old])["tags"]
|
||||
assert not any(key in tags for key in ("aa.setup_type", "aa.adapter_model", "aa.profile_source", "aa.phone_address_sent"))
|
||||
|
||||
|
||||
def test_unknown_setup_values_are_dropped(tmp_path):
|
||||
events: list = [list(item) for item in STREAMED]
|
||||
events[0] = (0, 0, "session_start", {"setup": {"type": "<script>", "profile": "x" * 90, "profile_source": "guess"}})
|
||||
setup = br.session_record(write_session(tmp_path, 1, events))["setup"]
|
||||
assert "type" not in setup and "profile_source" not in setup and len(setup["profile"]) <= 40
|
||||
|
||||
|
||||
def test_report_shows_what_the_ladder_tried_and_what_worked(tmp_path):
|
||||
events: list = [list(item) for item in STREAMED]
|
||||
events.insert(1, (0, 1, "ladder_strategy", {"rung": "standard", "learned": False}))
|
||||
events.insert(2, (0, 5, "ladder_failed", {"outcome": "advanced", "rung": "standard", "next": "other_reply"}))
|
||||
events.insert(3, (0, 6, "ladder_strategy", {"rung": "other_reply", "learned": False}))
|
||||
events.append((1, 50, "ladder_worked", {"rung": "other_reply", "streamed_s": 62.0, "t_ready_s": 14.5}))
|
||||
record = br.session_record(write_session(tmp_path, 1, events))
|
||||
assert record["ladder"] == [{"rung": "standard", "learned": False, "at_s": 1.0, "outcome": "advanced"},
|
||||
{"rung": "other_reply", "learned": False, "at_s": 6.0, "outcome": "worked", "streamed_s": 62.0, "t_ready_s": 14.5}]
|
||||
tags = br.drive_event([record])["tags"]
|
||||
assert (tags["aa.ladder_rung"], tags["aa.ladder_learned"]) == ("other_reply", "no")
|
||||
assert br.session_record(write_session(tmp_path, 2, STREAMED))["ladder"] == [] # older logs report nothing, without error
|
||||
|
||||
|
||||
def test_report_says_how_the_rfcomm_channel_was_found(tmp_path):
|
||||
events: list = [list(item) for item in STREAMED]
|
||||
events.insert(1, (0, 1, "sdp_retry", {"error": "x"}))
|
||||
events.insert(2, (0, 2, "rfcomm_channel", {"channel": 8, "source": "sdp"}))
|
||||
assert br.session_record(write_session(tmp_path, 1, events))["sdp"] == {"channel": 8, "source": "sdp", "retries": 1}
|
||||
assert br.session_record(write_session(tmp_path, 2, STREAMED))["sdp"] == {}
|
||||
|
||||
|
||||
def test_trace_keeps_how_the_session_started_as_well_as_how_it_ended(tmp_path):
|
||||
noisy = [(0, 0, "session_start", {}), (0, 1, "hfp_initiator", {"initiator": "car"})]
|
||||
noisy += [(i // 60, i % 60, "sdp_retry", {"error": "x" * 250, "n": i}) for i in range(2, 400)]
|
||||
noisy.append((9, 0, "attempt_failed", {"stage": "discovering", "error": "gone", "kind": "RuntimeError"}))
|
||||
event = br.drive_event([br.session_record(write_session(tmp_path, 1, noisy))])
|
||||
trace = event["contexts"]["starport_trace_session_1"]
|
||||
names = [e["event"] for e in trace["events"]]
|
||||
assert names[:2] == ["session_start", "hfp_initiator"] and names[-2:] == ["attempt_failed", "session_stop"]
|
||||
assert trace["omitted"] > 0 and 0 < trace["head"] < len(names)
|
||||
assert len(json.dumps(trace)) <= br.TRACE_SESSION_BYTES + 1024
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"""A car that is the only paired Android Auto device is chosen by housekeeping, without touching anything the owner set up."""
|
||||
import pytest
|
||||
|
||||
from openpilot.starpilot.system.starport import identity as identity_store
|
||||
from openpilot.starpilot.system.starport.tests.fake_head_unit import make_identity
|
||||
from openpilot.starpilot.system.starport.tests.test_starport import make_supervisor
|
||||
|
||||
CAR, OTHER = "AA:BB:CC:DD:EE:07", "AA:BB:CC:DD:EE:08"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def identity(tmp_path_factory):
|
||||
return make_identity(tmp_path_factory.mktemp("identity"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sup(identity, tmp_path, monkeypatch):
|
||||
supervisor, _ = make_supervisor(identity, tmp_path, monkeypatch, lambda *a, **k: None)
|
||||
with supervisor._lock:
|
||||
supervisor.config.update(receiver_address="", receiver_name="", car_type="builtin")
|
||||
class Recorder:
|
||||
recent = ()
|
||||
|
||||
def __init__(self):
|
||||
self.events = []
|
||||
|
||||
def __call__(self, name, **values):
|
||||
self.events.append((name, values))
|
||||
|
||||
supervisor.log = Recorder()
|
||||
supervisor.events = supervisor.log.events
|
||||
supervisor._projection_enabled = lambda: True
|
||||
supervisor.baseline = supervisor._phone().acquired # make_supervisor chose a car once already
|
||||
return supervisor
|
||||
|
||||
|
||||
def names(sup):
|
||||
return [name for name, _ in sup.events]
|
||||
|
||||
|
||||
def test_the_only_candidate_is_chosen_trusted_and_the_projection_role_given_back(sup):
|
||||
bluez = sup._phone()
|
||||
bluez.paired_devices = [(CAR, "Civic", True), (OTHER, "Headphones", False)]
|
||||
sup._adopt_only_car(1000.0)
|
||||
assert sup.config["receiver_address"] == CAR and sup.config["receiver_name"] == "Civic"
|
||||
assert identity_store.load_config()["receiver_address"] == CAR
|
||||
# the phone look is given back; the standby gateway stays
|
||||
assert bluez.trusted[-1] == CAR and bluez.acquired == sup.baseline + 1 and bluez.class_restored >= 1
|
||||
assert "car_adopted" in names(sup) and sup._candidates == []
|
||||
|
||||
|
||||
def test_two_candidates_are_never_guessed_and_nothing_is_acquired(sup):
|
||||
bluez = sup._phone()
|
||||
bluez.paired_devices = [(CAR, "Civic", True), (OTHER, "Wagon", True)]
|
||||
for tick in range(5):
|
||||
sup._adopt_only_car(1000.0 + 25 * tick)
|
||||
assert sup.config["receiver_address"] == "" and bluez.acquired == sup.baseline
|
||||
assert [car["address"] for car in sup.status()["car_candidates"]] == [CAR, OTHER]
|
||||
assert names(sup).count("car_adopt_ambiguous") == 1 # said once, not every 20 s
|
||||
|
||||
|
||||
def test_the_car_used_as_bluetooth_audio_is_left_to_the_owner(sup):
|
||||
sup._bt_client = type("C", (), {"status": lambda s: type("St", (), {"selected_audio": CAR.lower()})()})()
|
||||
sup._phone().paired_devices = [(CAR, "Civic", True)]
|
||||
for tick in range(3):
|
||||
sup._adopt_only_car(1000.0 + 25 * tick)
|
||||
assert sup.config["receiver_address"] == "" and sup._phone().acquired == sup.baseline
|
||||
assert names(sup).count("car_adopt_skipped") == 1
|
||||
assert [car["address"] for car in sup.status()["car_candidates"]] == [CAR] # still offered through the setup screen's Use button
|
||||
|
||||
|
||||
def test_when_the_audio_selection_cannot_be_read_the_owners_setup_is_left_alone(sup):
|
||||
def broken():
|
||||
raise RuntimeError("bluetooth service down")
|
||||
sup._bt_client = type("C", (), {"status": lambda s: broken()})()
|
||||
sup._phone().paired_devices = [(CAR, "Civic", True)]
|
||||
sup._adopt_only_car(1000.0)
|
||||
assert sup.config["receiver_address"] == ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize("change", [
|
||||
{"receiver_address": "AA:00:00:00:00:99"}, {"connection": "wired"}, {"car_type": "adapter"},
|
||||
])
|
||||
def test_it_does_nothing_once_a_car_is_chosen_or_for_wired_and_adapter_setups(sup, change):
|
||||
with sup._lock:
|
||||
sup.config.update(change)
|
||||
sup._phone().paired_devices = [(CAR, "Civic", True)]
|
||||
sup._adopt_only_car(1000.0)
|
||||
assert sup.config["receiver_address"] in ("", "AA:00:00:00:00:99") and sup._phone().acquired == sup.baseline
|
||||
|
||||
|
||||
def test_it_does_nothing_while_a_session_runs_projection_is_off_or_it_ran_a_moment_ago(sup):
|
||||
sup._phone().paired_devices = [(CAR, "Civic", True)]
|
||||
sup._projection_enabled = lambda: False
|
||||
sup._adopt_only_car(1000.0)
|
||||
assert sup.config["receiver_address"] == ""
|
||||
sup._projection_enabled = lambda: True
|
||||
sup._adopt_only_car(2000.0)
|
||||
assert sup.config["receiver_address"] == CAR
|
||||
sup.config["receiver_address"] = ""
|
||||
sup._adopt_only_car(2005.0) # inside the 20 s interval
|
||||
assert sup.config["receiver_address"] == ""
|
||||
|
||||
|
||||
def test_an_unreadable_bluetooth_service_is_reported_once(sup):
|
||||
def broken():
|
||||
raise RuntimeError("no shared owner")
|
||||
sup._phone = broken
|
||||
for tick in range(4):
|
||||
sup._adopt_only_car(1000.0 + 25 * tick)
|
||||
assert names(sup).count("car_adopt_unavailable") == 1
|
||||
|
||||
|
||||
def test_a_bluetooth_error_while_choosing_the_car_is_reported_not_raised(sup):
|
||||
sup._phone().paired_devices = [(CAR, "Civic", True)]
|
||||
|
||||
def rejected(address, name=""):
|
||||
raise Exception("Bluetooth rejected")
|
||||
sup.select_receiver = rejected
|
||||
sup._adopt_only_car(1000.0)
|
||||
assert "car_adopt_failed" in names(sup) and sup.config["receiver_address"] == ""
|
||||
@@ -2,7 +2,7 @@ import errno
|
||||
import unittest
|
||||
|
||||
from openpilot.starpilot.system.starport.bootstrap import BootstrapTimeout
|
||||
from openpilot.starpilot.system.starport.supervisor import car_never_answered
|
||||
from openpilot.starpilot.system.starport.supervisor import car_never_answered, drops_cached_channel
|
||||
|
||||
|
||||
class TestCarNeverAnswered(unittest.TestCase):
|
||||
@@ -14,3 +14,38 @@ class TestCarNeverAnswered(unittest.TestCase):
|
||||
for error in (OSError(errno.ECONNREFUSED, 'refused'), OSError(errno.EHOSTDOWN, 'down'), TimeoutError(),
|
||||
BootstrapTimeout('wifi_start', 'head unit did not answer in time')):
|
||||
self.assertTrue(car_never_answered(error))
|
||||
|
||||
|
||||
class TestDropsCachedChannel(unittest.TestCase):
|
||||
def test_a_sleeping_car_keeps_its_channel(self):
|
||||
# The message is what the kernel prints on the device (the errno number differs between macOS and Linux).
|
||||
self.assertFalse(drops_cached_channel('rfcomm', OSError(errno.EHOSTDOWN, 'Host is down')))
|
||||
self.assertFalse(drops_cached_channel('rfcomm', OSError(112, 'Host is down')))
|
||||
|
||||
def test_a_car_that_refuses_or_resets_loses_it(self):
|
||||
for error in (ConnectionRefusedError(errno.ECONNREFUSED, 'Connection refused'), ConnectionResetError(errno.ECONNRESET, 'reset'),
|
||||
BootstrapTimeout('wifi_start', 'head unit did not answer in time')):
|
||||
self.assertTrue(drops_cached_channel('rfcomm' if not isinstance(error, BootstrapTimeout) else 'wifi_start', error))
|
||||
|
||||
def test_other_stages_and_local_failures_never_drop_it(self):
|
||||
self.assertFalse(drops_cached_channel('authenticating', ConnectionResetError(errno.ECONNRESET, 'reset')))
|
||||
self.assertFalse(drops_cached_channel('rfcomm', OSError(errno.ENOSYS, 'local')))
|
||||
self.assertFalse(drops_cached_channel('rfcomm', OSError(errno.EBUSY, 'Device or resource busy')))
|
||||
|
||||
|
||||
class TestChooseOnlyCar(unittest.TestCase):
|
||||
def car(self, address, paired=True, starport=True):
|
||||
return {"address": address, "name": f"car {address}", "paired": paired, "starport": starport}
|
||||
|
||||
def test_exactly_one_paired_android_auto_device_is_adopted(self):
|
||||
from openpilot.starpilot.system.starport.supervisor import choose_only_car
|
||||
only, candidates = choose_only_car([self.car("A"), self.car("B", starport=False), self.car("C", paired=False)])
|
||||
assert only is not None
|
||||
self.assertEqual((only["address"], [c["address"] for c in candidates]), ("A", ["A"]))
|
||||
|
||||
def test_two_candidates_or_none_are_never_guessed(self):
|
||||
from openpilot.starpilot.system.starport.supervisor import choose_only_car
|
||||
self.assertEqual(choose_only_car([self.car("A"), self.car("B")])[0], None)
|
||||
self.assertEqual([c["address"] for c in choose_only_car([self.car("A"), self.car("B")])[1]], ["A", "B"])
|
||||
self.assertEqual(choose_only_car([self.car("A", starport=False)]), (None, []))
|
||||
self.assertEqual(choose_only_car([]), (None, []))
|
||||
|
||||
@@ -39,7 +39,8 @@ def config(**values):
|
||||
def test_aawireless_tuning_applies_only_when_chosen():
|
||||
chosen = config(car_profile_override="aawireless", receiver_name="AAWireless-4F2A")
|
||||
assert car_profiles.resolve(chosen) == ("aawireless", "override")
|
||||
assert car_profiles.tuning(chosen) == {"connect_delay_s": 0.0, "max_window": 30, "phone_services": True, "phone_frames": True}
|
||||
assert car_profiles.tuning(chosen) == {"connect_delay_s": 0.0, "max_window": 30, "phone_services": True, "phone_frames": True,
|
||||
"bootstrap_experiments": False, "stay_while_projecting": True}
|
||||
assert car_profiles.summary(chosen)["car_profile_label"] == "AAWireless"
|
||||
for other in (config(receiver_name="AAWireless-4F2A"), config(car_profile_override="default")):
|
||||
assert car_profiles.tuning(other) == car_profiles.DEFAULTS
|
||||
@@ -221,3 +222,10 @@ def test_a_new_adapter_setup_is_not_migrated(tmp_path):
|
||||
path = tmp_path / "config.json"
|
||||
path.write_text(json.dumps({"config_version": identity_store.CONFIG_VERSION, "car_type": "adapter"}))
|
||||
assert identity_store.load_config(path)["car_profile_override"] == ""
|
||||
|
||||
|
||||
def test_unofficial_dongle_turns_on_the_handshake_experiments_only_when_chosen():
|
||||
assert car_profiles.valid_override("unofficial")
|
||||
assert car_profiles.tuning(config(car_profile_override="unofficial"))["bootstrap_experiments"] is True
|
||||
for other in (config(), config(car_profile_override="default"), config(car_profile_override="aawireless")):
|
||||
assert car_profiles.tuning(other)["bootstrap_experiments"] is False
|
||||
|
||||
@@ -295,6 +295,9 @@ class CompanionTests(unittest.TestCase):
|
||||
host.stop.assert_called_once_with()
|
||||
self.owner.request.assert_called_once_with('forget', address=ADAPTER.lower())
|
||||
host.forget_receiver.assert_called_once_with(ADAPTER.lower())
|
||||
host.forget_receiver.side_effect = OSError('Cannot save config')
|
||||
with self.assertRaisesRegex(OSError, 'Cannot save config'):
|
||||
host.companion_bluetooth_action('forget', ADAPTER, SESSION)
|
||||
|
||||
def test_forgetting_the_selected_car_waits_out_pairing_and_expired_sessions(self):
|
||||
from openpilot.starpilot.system.starport.supervisor import Supervisor
|
||||
|
||||
@@ -231,6 +231,23 @@ def test_bundle_has_logs_reports_and_settings_without_addresses(tmp_path):
|
||||
assert "AA:BB:CC:DD:EE:FF" not in shared and json.loads(shared)["rfcomm_cache"] == [5]
|
||||
assert "projected, then failed" in archive.read("REPORT.txt").decode()
|
||||
assert not any(name.endswith((".pem", ".key")) for name in names)
|
||||
# The bundle (and so the Discord send) carries what a Bugsink report carries: tags, and per-session detail.
|
||||
report = json.loads(archive.read("report.json"))
|
||||
assert report["tags"]["aa.report"] == "drive" and "aa.head_unit" in report["tags"]
|
||||
assert report["sessions"][0]["offered_modes"] is not None and "setup" in report["sessions"][0]
|
||||
assert "AA:BB:CC:DD:EE:FF" not in archive.read("report.json").decode()
|
||||
|
||||
|
||||
def test_bundle_still_builds_when_the_report_cannot(tmp_path, monkeypatch):
|
||||
from openpilot.starpilot.system.starport import bugsink_report
|
||||
logs = tmp_path / "logs"
|
||||
logs.mkdir()
|
||||
write_log(logs, "session-000025-20260928-141351.jsonl", CIVIC_SESSION)
|
||||
monkeypatch.setattr(bugsink_report, "session_record", lambda path: (_ for _ in ()).throw(RuntimeError("broken")))
|
||||
config = tmp_path / "config.json"
|
||||
config.write_text("{}")
|
||||
with zipfile.ZipFile(io.BytesIO(compat_report.bundle(logs, config))) as archive:
|
||||
assert "REPORT.txt" in archive.namelist() and "report.json" not in archive.namelist()
|
||||
|
||||
|
||||
def test_report_tool_prints_a_log(tmp_path, monkeypatch, capsys):
|
||||
|
||||
@@ -67,3 +67,38 @@ def test_forget_car_edits_the_saved_config_when_the_daemon_is_not_running(tmp_pa
|
||||
assert saved()["companion_address"] == "" and saved()["receiver_address"] == ADAPTER
|
||||
assert protocol.forget_car(ADAPTER.lower(), client) is True
|
||||
assert saved()["receiver_address"] == "" and saved()["rfcomm_cache"] == {}
|
||||
|
||||
|
||||
def test_the_version_reply_a_car_started_with_is_kept_and_forgotten_with_it(sup):
|
||||
sup._remember_reply(ADAPTER, True)
|
||||
sup._remember_reply(OTHER, True)
|
||||
assert saved()["modern_reply_cache"] == {ADAPTER: True, OTHER: True}
|
||||
sup._remember_reply(OTHER, False)
|
||||
assert saved()["modern_reply_cache"] == {ADAPTER: True}
|
||||
daemon.handle(sup, {"command": "forget_receiver", "address": ADAPTER})
|
||||
assert saved()["modern_reply_cache"] == {} and ADAPTER not in sup._modern_reply
|
||||
|
||||
|
||||
def test_a_car_known_only_to_the_setup_ladder_is_still_forgotten(sup):
|
||||
sup._ladder.strategy(OTHER, False)
|
||||
sup._ladder.failed(OTHER, sup._ladder.strategy(OTHER, False), ladder_failure=True, video_seen=False, version_major=4, adapter=False)
|
||||
assert OTHER.upper() in sup._ladder.cars
|
||||
daemon.handle(sup, {"command": "forget_receiver", "address": OTHER})
|
||||
assert OTHER.upper() not in sup._ladder.cars
|
||||
|
||||
|
||||
def test_offline_forget_clears_reply_strategy_and_fingerprint(sup):
|
||||
sup._remember_reply(ADAPTER, True)
|
||||
sup._remember_reply(OTHER, True)
|
||||
sup._ladder.worked(ADAPTER, sup._ladder.strategy(ADAPTER, True))
|
||||
sup._ladder.worked(OTHER, sup._ladder.strategy(OTHER, True))
|
||||
sup.config["car_fingerprint"] = {"head_unit_make": "Old car"}
|
||||
identity_store.save_config(sup.config)
|
||||
client = protocol.StarPortClient(socket_path=str(identity_store.CONFIG_PATH.with_name("missing.sock")))
|
||||
assert protocol.forget_car(ADAPTER, client) is True
|
||||
assert saved()["modern_reply_cache"] == {OTHER: True} and saved()["car_fingerprint"] == {}
|
||||
from openpilot.starpilot.system.starport.setup_ladder import SetupLadder
|
||||
ladder = SetupLadder(sup._ladder.path)
|
||||
assert ADAPTER not in ladder.cars and OTHER in ladder.cars
|
||||
assert protocol.forget_car(OTHER, client) is False
|
||||
assert saved()["modern_reply_cache"] == {} and not SetupLadder(sup._ladder.path).cars
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""H.265 is an opt-in for a car that offers nothing else; every car with an H.264 mode is chosen exactly as before."""
|
||||
import itertools
|
||||
|
||||
import pytest
|
||||
|
||||
from openpilot.starpilot.system.starport import hw_encoder
|
||||
from openpilot.starpilot.system.starport.hw_encoder import HEVC_AUD, normalize_access_unit_hevc
|
||||
from openpilot.starpilot.system.starport.session import (CODEC_H264_BP, CODEC_H265, START_CODE, VideoMode, choose_video_mode,
|
||||
codec_config, codec_config_hevc, wire_frame, wire_frame_hevc)
|
||||
|
||||
SC = b"\x00\x00\x00\x01"
|
||||
|
||||
|
||||
def config(resolution, codec, margin_w=0, margin_h=0, fps=2):
|
||||
return {1: [resolution], 2: [fps], 3: [margin_w], 4: [margin_h], 10: [codec]}
|
||||
|
||||
|
||||
def channels(*configs):
|
||||
return [{"id": 3, "video_configs": list(configs), "display_type": 0}]
|
||||
|
||||
|
||||
# What Bugsink showed one "AndroidAuto" unit offering: every mode H.265, none H.264 (enums 8, 7, 6 are portrait).
|
||||
H265_ONLY = channels(config(8, 7, 288, 1378), config(7, 7, 0, 811), config(6, 7, 0, 541), config(3, 7, 0, 360))
|
||||
H264_OFFERS = [
|
||||
channels(config(2, 3, 0, 240)),
|
||||
channels(config(1, 3), config(2, 3, 0, 240), config(3, 3, 0, 360)),
|
||||
channels(config(3, 3, 0, 360), config(2, 3, 0, 240, fps=1)),
|
||||
channels(config(1, 3)),
|
||||
]
|
||||
|
||||
|
||||
def test_without_the_setting_an_h265_only_car_is_refused_with_a_hint():
|
||||
with pytest.raises(ValueError, match="supported H.264 video mode.*Developer > StarPort"):
|
||||
choose_video_mode(H265_ONLY)
|
||||
with pytest.raises(ValueError, match="supported H.264 video mode.*Developer > StarPort"):
|
||||
choose_video_mode(H265_ONLY, False)
|
||||
|
||||
|
||||
def test_with_the_setting_an_h265_only_car_gets_the_1080p_h265_mode_inside_the_known_resolutions():
|
||||
mode = choose_video_mode(H265_ONLY, True)
|
||||
assert (mode.codec, mode.width, mode.height, mode.margin_height) == (CODEC_H265, 1920, 1080, 360)
|
||||
assert mode.as_dict()["codec"] == "H.265"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("offer", H264_OFFERS)
|
||||
def test_a_car_with_an_h264_mode_is_chosen_identically_whatever_the_setting(offer):
|
||||
without, with_setting = choose_video_mode(offer), choose_video_mode(offer, True)
|
||||
assert without == with_setting and without.codec == CODEC_H264_BP
|
||||
assert "codec" not in without.as_dict() # H.264 reports and screen files are unchanged
|
||||
|
||||
|
||||
def test_mixed_offers_never_prefer_h265_over_h264():
|
||||
for h264, h265 in itertools.product(H264_OFFERS, [H265_ONLY, channels(config(2, 7, 0, 240))]):
|
||||
mixed = [{"id": 3, "video_configs": h264[0]["video_configs"] + h265[0]["video_configs"]}]
|
||||
assert choose_video_mode(mixed, True) == choose_video_mode(h264)
|
||||
|
||||
|
||||
def test_h265_prefers_the_smaller_known_resolution_like_h264():
|
||||
mode = choose_video_mode(channels(config(3, 7, 0, 360), config(2, 7, 0, 240)), True)
|
||||
assert (mode.codec, mode.width, mode.height) == (CODEC_H265, 1280, 720)
|
||||
|
||||
|
||||
def au(*kinds):
|
||||
"""An Annex B H.265 access unit with the given NAL types."""
|
||||
return b"".join(SC + bytes([kind << 1, 1]) + b"\x42" * 6 for kind in kinds)
|
||||
|
||||
|
||||
def test_hevc_codec_config_and_wire_frame_use_the_two_byte_header():
|
||||
frame = au(35, 32, 33, 34, 19)
|
||||
assert START_CODE.findall(codec_config_hevc(frame)) == [SC] * 3
|
||||
assert b"".join(bytes([kind << 1, 1]) + b"\x42" * 6 for kind in (32, 33, 34)) == codec_config_hevc(frame).replace(SC, b"")
|
||||
assert wire_frame_hevc(frame, drop_config=True) == SC + bytes([19 << 1, 1]) + b"\x42" * 6
|
||||
assert wire_frame_hevc(frame, drop_config=False) == au(32, 33, 34, 19) # the AUD is always dropped
|
||||
assert codec_config_hevc(au(35, 1)) == b""
|
||||
|
||||
|
||||
def test_h264_functions_are_unchanged_by_the_h265_ones():
|
||||
h264 = SC + b"\x09\xf0" + SC + b"\x67" + b"\x01" * 5 + SC + b"\x68" + b"\x02" * 3 + SC + b"\x65" + b"\x03" * 9
|
||||
assert codec_config(h264) == SC + b"\x67" + b"\x01" * 5 + SC + b"\x68" + b"\x02" * 3
|
||||
assert wire_frame(h264, drop_config=True) == SC + b"\x65" + b"\x03" * 9
|
||||
|
||||
|
||||
def test_hevc_access_units_are_checked_like_the_h264_ones():
|
||||
keyframe = normalize_access_unit_hevc(au(32, 33, 34, 20), keyframe=True)
|
||||
assert keyframe.startswith(HEVC_AUD) and keyframe.count(SC) == 5
|
||||
assert normalize_access_unit_hevc(au(35, 1), keyframe=False).startswith(HEVC_AUD)
|
||||
# The comma's encoder emits two slices per picture; both belong to the same picture.
|
||||
assert normalize_access_unit_hevc(au(32, 33, 34, 19, 19), keyframe=True).count(SC) == 6
|
||||
assert normalize_access_unit_hevc(au(1, 1), keyframe=False).count(SC) == 3
|
||||
for bad, key in ((au(32, 33, 34), True), (au(35), False), (au(33, 34, 19), True), (au(32, 33, 34, 1), True), (au(19, 1), False)):
|
||||
with pytest.raises(RuntimeError):
|
||||
normalize_access_unit_hevc(bad, keyframe=key)
|
||||
with pytest.raises(RuntimeError):
|
||||
normalize_access_unit_hevc(b"not annex b", keyframe=False)
|
||||
|
||||
|
||||
def test_the_hevc_encoder_class_only_changes_what_h265_needs():
|
||||
h264, hevc = hw_encoder.HardwareH264Encoder, hw_encoder.HardwareHevcEncoder
|
||||
assert (h264.max_width, h264.max_height, h264.codec_options, h264.required_abi) == (1280, 720, 0, 2)
|
||||
assert (hevc.max_width, hevc.max_height, hevc.codec_options, hevc.required_abi) == (1920, 1080, hw_encoder.OPTION_HEVC, 4)
|
||||
assert hw_encoder.OPTION_HEVC & hw_encoder.OPTION_CBR == 0
|
||||
|
||||
|
||||
def test_h265_never_falls_back_to_another_codec(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(hw_encoder, "LIBRARY", tmp_path / "missing.so")
|
||||
events = []
|
||||
with pytest.raises(RuntimeError, match="needs H.265 and the hardware encoder could not start it"):
|
||||
hw_encoder.create_hevc_encoder(1920, 1080, bitrate_kbps=6000, margin_height=360, log=lambda *a, **k: events.append(a))
|
||||
assert events == [] # nothing was announced as started
|
||||
|
||||
|
||||
def test_video_mode_defaults_to_h264():
|
||||
assert VideoMode(3, 0, 1280, 720, 30, 0, 240).codec == CODEC_H264_BP
|
||||
@@ -28,3 +28,11 @@ def test_existing_identity_survives_directory_migration(tmp_path, monkeypatch, o
|
||||
|
||||
assert directory == (legacy if outcome == 'denied' else current)
|
||||
assert (directory / 'certificate').read_bytes() == b'existing identity'
|
||||
|
||||
|
||||
def test_remembered_version_replies_load_only_as_true_per_address(tmp_path):
|
||||
path = tmp_path / "config.json"
|
||||
path.write_text('{"config_version": 7, "modern_reply_cache": {"aa:bb:cc:dd:ee:01": true, "AA:BB:CC:DD:EE:02": false, "x": 1}}')
|
||||
assert identity.load_config(path)["modern_reply_cache"] == {"AA:BB:CC:DD:EE:01": True}
|
||||
path.write_text('{"config_version": 7}')
|
||||
assert identity.load_config(path)["modern_reply_cache"] == {}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""A shared Wi-Fi interface that NetworkManager has not taken yet is waited for, then reported as unmanaged."""
|
||||
import pytest
|
||||
|
||||
from openpilot.starpilot.system.starport import network
|
||||
from openpilot.starpilot.system.starport.network import NetworkError, NetworkLease
|
||||
|
||||
|
||||
class FakeLease(NetworkLease):
|
||||
def __init__(self, states):
|
||||
super().__init__(lambda *a, **k: self.events.append((a, k)), "wlan0")
|
||||
self.states, self.events, self.device_path = list(states), [], "/dev/0"
|
||||
|
||||
def _get(self, path, interface, name):
|
||||
assert name == "State"
|
||||
return self.states.pop(0) if len(self.states) > 1 else self.states[0]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def no_sleep(monkeypatch):
|
||||
monkeypatch.setattr(network.time, "sleep", lambda seconds: None)
|
||||
|
||||
|
||||
def test_a_device_that_becomes_managed_is_joined_normally():
|
||||
lease = FakeLease([10, 10, 20, 30]) # unmanaged, unmanaged, unavailable, disconnected
|
||||
lease._wait_managed(lambda: False)
|
||||
assert [event[0][0] for event in lease.events] == ["wifi_waiting_managed", "wifi_became_managed"]
|
||||
|
||||
|
||||
def test_a_managed_device_does_not_wait_or_log():
|
||||
lease = FakeLease([30])
|
||||
lease._wait_managed(lambda: False)
|
||||
assert lease.events == []
|
||||
|
||||
|
||||
def test_a_device_that_stays_unmanaged_says_so(monkeypatch):
|
||||
ticks = iter(range(0, 1000, 3))
|
||||
monkeypatch.setattr(network.time, "monotonic", lambda: next(ticks))
|
||||
lease = FakeLease([10])
|
||||
with pytest.raises(NetworkError, match="unmanaged by NetworkManager"):
|
||||
lease._wait_managed(lambda: False, timeout=8.0)
|
||||
|
||||
|
||||
def test_cancelling_stops_the_wait():
|
||||
with pytest.raises(NetworkError, match="cancelled"):
|
||||
FakeLease([10])._wait_managed(lambda: True)
|
||||
@@ -401,6 +401,40 @@ class TestProjectionOnroad(unittest.TestCase):
|
||||
self.assertEqual(shown, [notice, notice, 'Lane Departure Detected', '', notice])
|
||||
view.close()
|
||||
|
||||
def test_reconnect_notice_shows_eight_seconds_under_real_alerts_and_before_the_certificate(self):
|
||||
native, _ = self.dependencies()
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Alert:
|
||||
size: str = 'none'
|
||||
text1: str = ''
|
||||
alert_type: str = ''
|
||||
|
||||
real = {'alert': Alert()}
|
||||
native.alert, native.alert_size = Alert, SimpleNamespace(NONE='none', SMALL='small')
|
||||
native.adapter = lambda *_: SimpleNamespace(build=lambda *_a, **_k: SimpleNamespace(
|
||||
onroad=OnroadState(False, False, None, None, SpeedLimitObservation(), alert=cast(Any, real['alert']))))
|
||||
reconnected = 'Reconnected: comma Wi-Fi Joined Home'
|
||||
view = projection.ProjectionOnroad(dependencies=native, certificate_days=3, reconnect_notice=reconnected)
|
||||
shown = []
|
||||
view.onroad.render = lambda state: shown.append(state.alert.text1)
|
||||
clock = [0]
|
||||
original = projection.time
|
||||
projection.time = SimpleNamespace(monotonic_ns=lambda: clock[0])
|
||||
try:
|
||||
native.ui_state.started = True
|
||||
for clock[0] in (100, 7_000_000_000):
|
||||
view.render()
|
||||
real['alert'] = Alert('mid', 'Lane Departure Detected')
|
||||
view.render() # a real alert always wins
|
||||
real['alert'] = Alert()
|
||||
clock[0] = 8_000_000_200
|
||||
view.render() # past eight seconds: the certificate heads-up gets its remaining time
|
||||
finally:
|
||||
projection.time = original
|
||||
self.assertEqual(shown, [reconnected, reconnected, 'Lane Departure Detected', 'Heads Up: AA Certificate Expires in 3 Days'])
|
||||
view.close()
|
||||
|
||||
def test_no_certificate_notice_when_far_from_expiry(self):
|
||||
native, _ = self.dependencies()
|
||||
view = projection.ProjectionOnroad(dependencies=native, certificate_days=200)
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Failures before video are told apart by why the car could not be reached, using the texts Bugsink actually showed."""
|
||||
|
||||
import pytest
|
||||
|
||||
from openpilot.starpilot.system.starport import bugsink_report as br, reachability as r
|
||||
from openpilot.starpilot.system.starport.tests.test_bugsink_report import write_session
|
||||
|
||||
CASES = [
|
||||
("discovering", "RuntimeError", "Could not find the car's Android Auto service: [Errno 112] Host is down", r.ASLEEP),
|
||||
("connecting_bluetooth", "RuntimeError", "org.bluez.Error.Failed: br-connection-page-timeout", r.ASLEEP),
|
||||
("connecting_bluetooth", "TimeoutError", "", r.ASLEEP),
|
||||
("rfcomm", "OSError", "starting wireless setup: [Errno 112] Host is down", r.ASLEEP),
|
||||
("rfcomm", "ConnectionRefusedError", "[Errno 111] Connection refused", r.NOT_READY),
|
||||
("rfcomm", "ConnectionResetError", "starting wireless setup: [Errno 104] Connection reset by peer", r.NOT_READY),
|
||||
("discovering", "RuntimeError", "Head unit does not advertise Android Auto Wireless (SDP record missing)", r.NOT_READY),
|
||||
("wifi_start", "BootstrapTimeout", "waiting for car: wifi_start: head unit did not answer in time", r.NOT_READY),
|
||||
("rfcomm", "OSError", "[Errno 16] Device or resource busy", r.LOCAL),
|
||||
("connecting_bluetooth", "BluetoothRejected", "pairing rejected", r.LOCAL),
|
||||
("streaming", "TimeoutError", "projecting: Video acknowledgement older than 2.5 s", r.OTHER), # after the link is up
|
||||
("authenticating", "ConnectionResetError", "Connection reset by peer", r.OTHER),
|
||||
("joining_wifi", "BootstrapError", "joining_wifi: ", r.OTHER),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stage,kind,message,expected", CASES)
|
||||
def test_classify_known_bugsink_texts(stage, kind, message, expected):
|
||||
assert r.classify(stage, kind, message) == expected
|
||||
|
||||
|
||||
def test_waits_are_never_shorter_than_the_standard_backoff_and_other_failures_are_unchanged():
|
||||
standard = (2.0, 4.0, 8.0, 15.0, 30.0)
|
||||
for attempt, base in enumerate(standard):
|
||||
assert r.retry_delay(r.OTHER, attempt, base) == base and r.retry_delay(r.NOT_READY, attempt, base) == base
|
||||
for reach in (r.ASLEEP, r.LOCAL):
|
||||
assert r.retry_delay(reach, attempt, base) >= base
|
||||
assert r.retry_delay(r.ASLEEP, 0, 2.0) == 6.0 and r.retry_delay(r.ASLEEP, 99, 30.0) == 30.0
|
||||
|
||||
|
||||
def test_a_sleeping_car_keeps_its_cached_channel_but_a_refusing_one_does_not():
|
||||
assert r.keeps_cached_channel(r.ASLEEP) and r.keeps_cached_channel(r.LOCAL)
|
||||
assert not r.keeps_cached_channel(r.NOT_READY) and not r.keeps_cached_channel(r.OTHER)
|
||||
|
||||
|
||||
def test_report_names_a_sleeping_car_instead_of_unknown(tmp_path):
|
||||
events = [(0, 0, "session_start", {"receiver": "Car"}), (0, 1, "stage", {"state": "discovering"}),
|
||||
(0, 9, "attempt_failed", {"stage": "discovering", "kind": "RuntimeError",
|
||||
"error": "finding android auto: Could not find the car's Android Auto service: [Errno 112] Host is down"})]
|
||||
record = br.session_record(write_session(tmp_path, 1, events))
|
||||
assert record["reach"] == "asleep" # derived from the text for logs written before the supervisor recorded it
|
||||
event = br.drive_event([record])
|
||||
assert event["tags"]["aa.cause"] == "car_asleep" and event["tags"]["aa.reach_class"] == "asleep"
|
||||
recorded = [(0, 0, "session_start", {}), (0, 9, "attempt_failed", {"stage": "rfcomm", "kind": "ConnectionRefusedError",
|
||||
"error": "refused", "reach": "not_ready"})]
|
||||
assert br.session_record(write_session(tmp_path, 2, recorded))["reach"] == "not_ready"
|
||||
|
||||
|
||||
def test_stream_losses_are_told_apart_by_what_the_log_says(tmp_path):
|
||||
from openpilot.starpilot.system.starport.bugsink_diagnosis import stream_loss_kind
|
||||
assert stream_loss_kind("projecting: Video acknowledgement older than 2.5 s") == "stalled"
|
||||
assert stream_loss_kind("projecting: Head unit stopped answering for 6 s") == "silent"
|
||||
assert stream_loss_kind("projecting: Head unit disconnected after 4/8 bytes") == "disconnected"
|
||||
assert stream_loss_kind("something else") == "other"
|
||||
for text, kind in (("Video acknowledgement older than 2.5 s", "stalled"), ("Head unit disconnected after 4/8 bytes", "disconnected")):
|
||||
events = [(0, 0, "session_start", {}), (0, 1, "stage", {"state": "streaming"}),
|
||||
(0, 9, "attempt_failed", {"stage": "streaming", "kind": "EOFError", "error": text})]
|
||||
event = br.drive_event([br.session_record(write_session(tmp_path, 3 if kind == "stalled" else 4, events))])
|
||||
assert event["tags"]["aa.cause"] == "projection_interrupted" and event["tags"]["aa.loss_kind"] == kind
|
||||
|
||||
|
||||
def test_every_real_bugsink_failure_text_is_classified_as_intended():
|
||||
import json
|
||||
from pathlib import Path
|
||||
rows = json.loads((Path(__file__).parent / "fixtures" / "bugsink_failure_texts.json").read_text())["failures"]
|
||||
assert sum(row["count"] for row in rows) > 600 and len(rows) > 40
|
||||
seen = {r.ASLEEP: 0, r.NOT_READY: 0, r.LOCAL: 0, r.OTHER: 0}
|
||||
for row in rows:
|
||||
got = r.classify(row["stage"], row["kind"], row["evidence"])
|
||||
text = row["evidence"].lower()
|
||||
seen[got] += row["count"]
|
||||
if row["stage"] in ("streaming", "authenticating", "negotiating", "connecting_tcp", "joining_wifi"):
|
||||
assert got == r.OTHER, row # once the link is up, none of this is about reaching the car
|
||||
continue
|
||||
if "host is down" in text or "page-timeout" in text:
|
||||
assert got == r.ASLEEP, row
|
||||
if "connection refused" in text or "did not answer in time" in text:
|
||||
assert got == r.NOT_READY, row
|
||||
if "galaxy bluetooth first" in text:
|
||||
assert got == r.OTHER, row # a setup step to take, not a reachability problem
|
||||
early = seen[r.ASLEEP] + seen[r.NOT_READY] + seen[r.LOCAL]
|
||||
assert early > 0.6 * sum(seen.values()), seen # most failures before video are now explained instead of "unknown"
|
||||
@@ -0,0 +1,272 @@
|
||||
"""The setup ladder moves only when a car that answered the handshake refuses it before any video; nothing else moves it."""
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from openpilot.starpilot.system.starport import setup_ladder as sl
|
||||
from openpilot.starpilot.system.starport.bootstrap import CHANNEL_24GHZ, CHANNEL_DUAL, REFUSAL_PATIENCE
|
||||
|
||||
CAR = "AA:BB:CC:DD:EE:FF"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ladder(tmp_path):
|
||||
state = {"boot": "boot-1"}
|
||||
instance = sl.SetupLadder(tmp_path / "setup_state.json", boot_id=lambda: state["boot"], clock=lambda: 1000.0)
|
||||
instance.state = state
|
||||
return instance
|
||||
|
||||
|
||||
def refuse(ladder, strategy, **overrides):
|
||||
values = {"ladder_failure": True, "video_seen": False, "version_major": 4, "adapter": False}
|
||||
values.update(overrides)
|
||||
return ladder.failed(CAR, strategy, **values)
|
||||
|
||||
|
||||
def test_the_first_strategy_is_exactly_what_ran_before(ladder):
|
||||
for base in (False, True):
|
||||
strategy = ladder.strategy(CAR, base)
|
||||
assert (strategy.rung, strategy.modern_reply, strategy.channel_type, strategy.patience, strategy.experiments) == (
|
||||
"standard", base, None, REFUSAL_PATIENCE, False)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,overrides", [
|
||||
("a car that never answered the handshake (asleep, refused, local trouble)", {"ladder_failure": False}),
|
||||
("anything after video was acknowledged in the session (car-off teardown)", {"video_seen": True}),
|
||||
])
|
||||
def test_failures_that_say_nothing_about_the_handshake_never_move_the_ladder(ladder, name, overrides):
|
||||
strategy = ladder.strategy(CAR, False)
|
||||
for _ in range(50):
|
||||
assert refuse(ladder, strategy, **overrides) is None
|
||||
assert ladder.strategy(CAR, False).rung == "standard" and not ladder.exhausted(CAR)
|
||||
assert not (ladder.path.exists() and json.loads(ladder.path.read_text())["cars"]) # nothing was even recorded
|
||||
|
||||
|
||||
def test_a_silent_car_walks_the_rungs_in_order_and_each_is_a_knob_that_already_existed(ladder):
|
||||
seen = []
|
||||
for step in range(5):
|
||||
strategy = ladder.strategy(CAR, False)
|
||||
seen.append(strategy)
|
||||
assert refuse(ladder, strategy) == ("advanced" if step < 4 else "wrapped") # the last rung ends the pass
|
||||
assert [s.rung for s in seen] == ["standard", "other_reply", "modern_dual", "modern_24ghz", "patient"]
|
||||
assert [s.modern_reply for s in seen] == [False, True, True, True, False]
|
||||
assert [s.channel_type for s in seen] == [None, None, CHANNEL_DUAL, CHANNEL_24GHZ, None]
|
||||
assert seen[4].patience == sl.LONG_PATIENCE and all(not s.experiments for s in seen)
|
||||
|
||||
|
||||
def test_the_other_reply_is_relative_to_what_the_car_was_using(ladder):
|
||||
assert ladder.strategy(CAR, True).modern_reply is True
|
||||
refuse(ladder, ladder.strategy(CAR, True))
|
||||
assert ladder.strategy(CAR, True).modern_reply is False
|
||||
|
||||
|
||||
def test_a_car_older_than_protocol_four_skips_the_phone_style_rungs(ladder):
|
||||
refuse(ladder, ladder.strategy(CAR, False), version_major=3)
|
||||
assert ladder.strategy(CAR, False).rung == "patient"
|
||||
assert ladder.snapshot(CAR)["steps"][1]["state"] == "skipped"
|
||||
|
||||
|
||||
def test_the_adapter_experiments_run_last_and_only_for_adapters(ladder):
|
||||
for _ in range(5):
|
||||
refuse(ladder, ladder.strategy(CAR, False), adapter=True)
|
||||
strategy = ladder.strategy(CAR, False)
|
||||
assert strategy.rung == "experiments" and strategy.experiments
|
||||
assert refuse(ladder, strategy, adapter=True) == "wrapped"
|
||||
other = sl.SetupLadder(ladder.path.with_name("other.json"), boot_id=lambda: "b")
|
||||
for _ in range(5):
|
||||
refuse(other, other.strategy(CAR, False))
|
||||
assert other.strategy(CAR, False).rung == "standard" # no adapter, no experiments rung: back to the start
|
||||
assert "experiments" not in [step["id"] for step in other.snapshot(CAR)["steps"]]
|
||||
|
||||
|
||||
def test_three_passes_per_boot_then_it_stops_trying_new_things(ladder):
|
||||
results = []
|
||||
for _ in range(5 * sl.MAX_PASSES):
|
||||
results.append(refuse(ladder, ladder.strategy(CAR, False)))
|
||||
assert results.count("exhausted") == 1 and ladder.exhausted(CAR)
|
||||
assert ladder.strategy(CAR, False).rung == "standard" # settles on today's behavior
|
||||
assert refuse(ladder, ladder.strategy(CAR, False)) is None
|
||||
assert ladder.snapshot(CAR)["exhausted"]
|
||||
|
||||
|
||||
def test_the_next_ignition_gets_its_passes_back_and_resumes_at_the_first_untried_rung(ladder):
|
||||
for _ in range(2):
|
||||
refuse(ladder, ladder.strategy(CAR, False))
|
||||
ladder.state["boot"] = "boot-2"
|
||||
assert ladder.strategy(CAR, False).rung == "modern_dual"
|
||||
assert ladder.snapshot(CAR)["passes"] == 0
|
||||
for _ in range(5 * sl.MAX_PASSES):
|
||||
refuse(ladder, ladder.strategy(CAR, False))
|
||||
assert ladder.exhausted(CAR)
|
||||
ladder.state["boot"] = "boot-3"
|
||||
assert not ladder.exhausted(CAR) and ladder.snapshot(CAR)["passes"] == 0
|
||||
|
||||
|
||||
def test_what_worked_is_used_first_next_time_even_for_a_different_remembered_reply(ladder):
|
||||
refuse(ladder, ladder.strategy(CAR, False))
|
||||
worked = ladder.strategy(CAR, False)
|
||||
assert worked.rung == "other_reply"
|
||||
ladder.worked(CAR, worked, t_ready_s=18.4)
|
||||
# The reply style is now remembered as the phone-style one; "the other reply" must not flip back to standard.
|
||||
again = ladder.strategy(CAR, True)
|
||||
assert (again.rung, again.modern_reply) == ("other_reply", True)
|
||||
snapshot = ladder.snapshot(CAR)
|
||||
assert snapshot["learned"] and snapshot["t_ready_s"] == 18.4 and snapshot["successes"] == 1
|
||||
assert [step["state"] for step in snapshot["steps"]][1] == "worked"
|
||||
|
||||
|
||||
def test_a_learned_strategy_is_forgotten_only_after_two_handshake_failures(ladder):
|
||||
ladder.worked(CAR, sl.resolve("modern_dual", False))
|
||||
learned = ladder.strategy(CAR, False)
|
||||
assert refuse(ladder, learned) == "counted" and ladder.strategy(CAR, False).rung == "modern_dual"
|
||||
assert refuse(ladder, learned, ladder_failure=False) is None and refuse(ladder, learned, video_seen=True) is None
|
||||
assert refuse(ladder, learned) == "dropped"
|
||||
# Not back to the start: the strategy that just failed twice is not tried first, the next rung is.
|
||||
assert ladder.strategy(CAR, False).rung == "modern_24ghz" and not ladder.snapshot(CAR)["learned"]
|
||||
assert ladder.snapshot(CAR)["steps"][2]["state"] == "failed"
|
||||
|
||||
|
||||
def test_state_survives_a_restart_and_a_damaged_file_means_a_fresh_start(tmp_path):
|
||||
path = tmp_path / "setup_state.json"
|
||||
first = sl.SetupLadder(path, boot_id=lambda: "b")
|
||||
refuse(first, first.strategy(CAR, False))
|
||||
first.worked(CAR, sl.resolve("patient", False), 12.0)
|
||||
second = sl.SetupLadder(path, boot_id=lambda: "b")
|
||||
assert second.strategy(CAR.lower(), False).rung == "patient" and path.stat().st_mode & 0o777 == 0o600
|
||||
for damage in ("not json", json.dumps({"version": 99, "cars": {}}), json.dumps({"version": 1, "cars": {CAR: {"working": {"rung": "x"}}}}),
|
||||
json.dumps({"version": 1, "cars": [1]}), ""):
|
||||
path.write_text(damage)
|
||||
assert sl.SetupLadder(path, boot_id=lambda: "b").strategy(CAR, False).rung == "standard"
|
||||
|
||||
|
||||
def test_a_read_only_disk_never_stops_a_connection(ladder):
|
||||
ladder.path = ladder.path / "blocked" / "x" # parent is a missing path under a file below
|
||||
ladder.path.parent.parent.parent.mkdir(exist_ok=True)
|
||||
(ladder.path.parent.parent).write_text("a file where a directory should be")
|
||||
refuse(ladder, ladder.strategy(CAR, False)) # must not raise
|
||||
assert ladder.strategy(CAR, False).rung == "other_reply"
|
||||
|
||||
|
||||
def test_only_sixteen_cars_are_kept_newest_first(tmp_path):
|
||||
clock = iter(range(1000, 2000))
|
||||
ladder = sl.SetupLadder(tmp_path / "s.json", boot_id=lambda: "b", clock=lambda: next(clock))
|
||||
for number in range(20):
|
||||
ladder.worked(f"AA:00:00:00:00:{number:02X}", sl.resolve("patient", False))
|
||||
reloaded = sl.SetupLadder(tmp_path / "s.json", boot_id=lambda: "b")
|
||||
assert len(reloaded.cars) == sl.MAX_CARS and "AA:00:00:00:00:13" in reloaded.cars and "AA:00:00:00:00:00" not in reloaded.cars
|
||||
|
||||
|
||||
# ---- the supervisor's use of it -------------------------------------------------------------------------------------
|
||||
import time as _time
|
||||
from types import SimpleNamespace
|
||||
|
||||
from openpilot.starpilot.system.starport.tests.fake_head_unit import make_identity
|
||||
from openpilot.starpilot.system.starport.tests.test_starport import make_supervisor
|
||||
|
||||
RECEIVER = "AA:BB:CC:DD:EE:01" # the car make_supervisor selects
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def identity(tmp_path_factory):
|
||||
return make_identity(tmp_path_factory.mktemp("identity"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sup(identity, tmp_path, monkeypatch):
|
||||
supervisor, _ = make_supervisor(identity, tmp_path, monkeypatch, lambda *a, **k: None)
|
||||
return supervisor
|
||||
|
||||
|
||||
def streamed(seconds):
|
||||
return SimpleNamespace(video_confirmed=True, video_confirmed_at=_time.monotonic() - seconds)
|
||||
|
||||
|
||||
def test_the_supervisor_keeps_its_ladder_beside_the_config_so_tests_never_touch_real_state(sup):
|
||||
from openpilot.starpilot.system.starport import identity as identity_store
|
||||
assert sup._ladder.path == identity_store.CONFIG_PATH.with_name("setup_state.json")
|
||||
|
||||
|
||||
def test_a_minute_of_video_makes_the_strategy_the_one_this_car_starts_with(sup):
|
||||
strategy = sl.resolve("other_reply", False)
|
||||
sup._strategy, sup._run_started = strategy, _time.monotonic() - 150
|
||||
sup._note_streamed(streamed(30)) # too short to count, but video was seen
|
||||
assert sup._video_seen and not sup._ladder.snapshot(RECEIVER)["learned"]
|
||||
sup._note_streamed(streamed(90))
|
||||
snapshot = sup._ladder.snapshot(RECEIVER)
|
||||
assert snapshot["learned"] and snapshot["current"] == "other_reply" and 50 < snapshot["t_ready_s"] < 70
|
||||
|
||||
|
||||
def test_a_session_that_never_got_video_learns_nothing(sup):
|
||||
sup._strategy = sl.resolve("patient", False)
|
||||
sup._note_streamed(SimpleNamespace(video_confirmed=False, video_confirmed_at=0.0))
|
||||
assert not sup._video_seen and not sup._ladder.snapshot(RECEIVER)["learned"]
|
||||
|
||||
|
||||
def test_status_shows_the_ladder_the_switch_and_the_cars_to_choose_between(sup):
|
||||
status = sup.status()
|
||||
assert status["new_setup"] in (True, False) and status["car_candidates"] == []
|
||||
assert [step["id"] for step in status["setup_ladder"]["steps"]][:2] == ["standard", "other_reply"]
|
||||
assert status["setup_ladder"]["max_passes"] == sl.MAX_PASSES and not status["setup_ladder"]["learned"]
|
||||
|
||||
|
||||
def test_forgetting_a_car_forgets_what_was_learned_about_it(sup):
|
||||
sup._ladder.worked(RECEIVER, sl.resolve("patient", False), 10.0)
|
||||
assert sup.forget_receiver(RECEIVER) is True
|
||||
assert not sup._ladder.snapshot(RECEIVER)["learned"] and RECEIVER not in sup._ladder.cars
|
||||
|
||||
|
||||
def test_a_slow_car_gets_the_patience_it_once_needed_and_an_unknown_car_gets_no_opinion(ladder):
|
||||
assert ladder.ready_patience(CAR) == 0
|
||||
ladder.worked(CAR, sl.resolve("standard", False), 14.0)
|
||||
assert ladder.ready_patience(CAR) == 1
|
||||
ladder.worked(CAR, sl.resolve("standard", False), 70.0)
|
||||
assert ladder.ready_patience(CAR) == 4 # 70 s at 20 s per stage timeout
|
||||
ladder.worked(CAR, sl.resolve("standard", False), 3000.0)
|
||||
assert ladder.ready_patience(CAR) == 9 # capped
|
||||
|
||||
|
||||
def test_a_learned_standard_strategy_that_stops_working_moves_on_to_the_other_reply(ladder):
|
||||
ladder.worked(CAR, sl.resolve("standard", False))
|
||||
learned = ladder.strategy(CAR, False)
|
||||
assert refuse(ladder, learned) == "counted"
|
||||
assert refuse(ladder, learned) == "dropped"
|
||||
assert ladder.strategy(CAR, False).rung == "other_reply" # one failure fewer than starting over, as the dongle flip did
|
||||
|
||||
|
||||
def test_the_passes_come_back_with_each_ignition_even_without_a_reboot(ladder):
|
||||
for _ in range(5 * sl.MAX_PASSES):
|
||||
refuse(ladder, ladder.strategy(CAR, False))
|
||||
assert ladder.exhausted(CAR) and ladder.snapshot(CAR)["passes"] == sl.MAX_PASSES
|
||||
ladder.begin_cycle(CAR) # the next drive starts: same boot, same comma
|
||||
assert not ladder.exhausted(CAR) and ladder.snapshot(CAR)["passes"] == 0
|
||||
assert refuse(ladder, ladder.strategy(CAR, False)) == "advanced"
|
||||
ladder.begin_cycle("AA:00:00:00:00:99") # an unknown car is a no-op
|
||||
assert "AA:00:00:00:00:99" not in ladder.cars
|
||||
|
||||
|
||||
def test_short_sessions_keep_the_same_other_reply_until_it_is_learned(sup, monkeypatch):
|
||||
from openpilot.starpilot.system.starport import supervisor as module
|
||||
sup._ladder.failed(RECEIVER, sup._ladder.strategy(RECEIVER, False), ladder_failure=True,
|
||||
video_seen=False, version_major=4, adapter=False)
|
||||
replies = []
|
||||
|
||||
def bootstrap(*args, **kwargs):
|
||||
replies.append(kwargs["modern_reply"])
|
||||
return SimpleNamespace(version_major=4, modern_reply=kwargs["modern_reply"], join_is_cancelled=lambda: False,
|
||||
run=lambda *a, **k: SimpleNamespace(head_unit={}), keepalive=lambda stop: None)
|
||||
|
||||
monkeypatch.setattr(module, "WirelessBootstrap", bootstrap)
|
||||
monkeypatch.setattr(sup, "_wait", lambda delay: None)
|
||||
monkeypatch.setattr(sup, "_connect_rfcomm", lambda *args: None)
|
||||
|
||||
def short_session(*args):
|
||||
raise RuntimeError("TCP disconnected before sustained video")
|
||||
|
||||
monkeypatch.setattr(sup, "_project", short_session)
|
||||
for _ in range(2):
|
||||
with pytest.raises(RuntimeError, match="TCP disconnected"):
|
||||
sup._attempt(SimpleNamespace())
|
||||
assert replies == [True, True] and not sup.config["modern_reply_cache"]
|
||||
monkeypatch.setattr(sup, "_project", lambda *args: sup._ladder.worked(RECEIVER, sup._strategy))
|
||||
sup._attempt(SimpleNamespace())
|
||||
assert sup.config["modern_reply_cache"] == {RECEIVER: True}
|
||||
@@ -0,0 +1,137 @@
|
||||
"""2026-11-02: the comma's own Wi-Fi joining or roaming stalled the car's video acks (one shared radio)."""
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from openpilot.starpilot.system.starport import reconnect_notice
|
||||
from openpilot.starpilot.system.starport.bugsink_diagnosis import stream_loss_kind
|
||||
from openpilot.starpilot.system.starport import supervisor as supervisor_module
|
||||
from openpilot.starpilot.system.starport.session import ProjectionSession, SharedRadioStall
|
||||
from openpilot.starpilot.system.starport.supervisor import SharedRadioWatch, Supervisor
|
||||
from openpilot.starpilot.system.starport.tests.fake_head_unit import FakeHeadUnit, make_identity
|
||||
from openpilot.starpilot.system.starport.tests.test_starport import end_session, pump_until, sent_frame, streaming_session
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def identity(tmp_path_factory):
|
||||
return make_identity(tmp_path_factory.mktemp("identity"))
|
||||
|
||||
|
||||
def stalled(identity, changed: bool):
|
||||
hu = FakeHeadUnit(identity)
|
||||
session = streaming_session(hu, identity)
|
||||
session.radio_change = lambda: changed
|
||||
hu.hold_acks.set()
|
||||
return hu, session, sent_frame(hu, session)
|
||||
|
||||
|
||||
def test_no_radio_change_keeps_the_standard_limit(identity):
|
||||
hu, session, sent = stalled(identity, False)
|
||||
with pytest.raises(TimeoutError, match="2.5 s") as raised:
|
||||
session.check_progress(now=sent + ProjectionSession.ACK_TIMEOUT + 0.05)
|
||||
assert not isinstance(raised.value, SharedRadioStall)
|
||||
end_session(session, hu)
|
||||
|
||||
|
||||
def test_radio_change_waits_up_to_the_grace_limit(identity):
|
||||
hu, session, sent = stalled(identity, True)
|
||||
session.check_progress(now=sent + ProjectionSession.ACK_TIMEOUT + 0.05)
|
||||
session.check_progress(now=sent + ProjectionSession.ACK_GRACE_TIMEOUT - 0.1)
|
||||
with pytest.raises(SharedRadioStall):
|
||||
session.check_progress(now=sent + ProjectionSession.ACK_GRACE_TIMEOUT + 0.05)
|
||||
end_session(session, hu)
|
||||
|
||||
|
||||
def test_grace_started_by_a_change_survives_the_change_ageing_out(identity):
|
||||
hu, session, sent = stalled(identity, True)
|
||||
session.check_progress(now=sent + ProjectionSession.ACK_TIMEOUT + 0.05)
|
||||
session.radio_change = lambda: False # same stall: the decision was made when it began
|
||||
session.check_progress(now=sent + ProjectionSession.ACK_GRACE_TIMEOUT - 0.1)
|
||||
end_session(session, hu)
|
||||
|
||||
|
||||
def test_acks_resuming_during_the_grace_keep_the_session_and_log_the_gap(identity):
|
||||
hu, session, sent = stalled(identity, True)
|
||||
events = []
|
||||
session.event = lambda name, **fields: events.append((name, fields))
|
||||
session.check_progress(now=sent + ProjectionSession.ACK_TIMEOUT + 0.05)
|
||||
hu.hold_acks.clear()
|
||||
hu._ack(session.session_id)
|
||||
pump_until(session, lambda: not session.pending)
|
||||
assert session.grace_frame is None
|
||||
assert [name for name, _ in events] == ["ack_grace", "ack_grace_recovered"]
|
||||
session.check_progress(now=sent + ProjectionSession.ACK_GRACE_TIMEOUT + 1) # nothing pending: no timeout
|
||||
end_session(session, hu)
|
||||
|
||||
|
||||
def test_car_taking_its_screen_back_during_the_grace_reconnects(identity):
|
||||
hu, session, sent = stalled(identity, True)
|
||||
session.check_progress(now=sent + ProjectionSession.ACK_TIMEOUT + 0.05)
|
||||
hu.set_focus(False)
|
||||
pump_until(session, lambda: not session.focused)
|
||||
with pytest.raises(SharedRadioStall, match="took its screen back"):
|
||||
session.check_progress(now=sent + ProjectionSession.ACK_TIMEOUT + 0.5)
|
||||
end_session(session, hu)
|
||||
|
||||
|
||||
def test_unfocus_outside_a_grace_is_the_normal_reverse_camera(identity):
|
||||
hu = FakeHeadUnit(identity)
|
||||
session = streaming_session(hu, identity)
|
||||
hu.set_focus(False)
|
||||
pump_until(session, lambda: not session.focused)
|
||||
session.check_progress()
|
||||
end_session(session, hu)
|
||||
|
||||
|
||||
def test_watch_reports_a_carrier_change_without_naming_the_network(tmp_path):
|
||||
counter = tmp_path / "carrier_changes"
|
||||
counter.write_text("4\n")
|
||||
logged = []
|
||||
watch = SharedRadioWatch(lambda name, **fields: logged.append((name, fields)), interval=0.01, counter=counter).start()
|
||||
try:
|
||||
time.sleep(0.05)
|
||||
assert not watch.recent() # the starting value is not a change
|
||||
counter.write_text("5\n")
|
||||
deadline = time.monotonic() + 2
|
||||
while not watch.recent():
|
||||
assert time.monotonic() < deadline
|
||||
time.sleep(0.01)
|
||||
assert logged == [("other_wifi_changed", {"interface": "wlan0"})]
|
||||
assert watch.changed_at is not None
|
||||
assert not watch.recent(now=watch.changed_at + supervisor_module.RADIO_CHANGE_WINDOW + 0.1)
|
||||
finally:
|
||||
watch.stop()
|
||||
|
||||
|
||||
def test_watch_without_the_counter_never_reports(tmp_path):
|
||||
watch = SharedRadioWatch(lambda *a, **k: None, interval=0.01, counter=tmp_path / "missing").start()
|
||||
try:
|
||||
time.sleep(0.05)
|
||||
assert not watch.recent()
|
||||
finally:
|
||||
watch.stop()
|
||||
|
||||
|
||||
def test_notice_is_shown_once_and_only_while_fresh(tmp_path):
|
||||
path = tmp_path / "notice.json"
|
||||
reconnect_notice.write("Reconnected", path, now=1000.0)
|
||||
assert reconnect_notice.take(path, now=1010.0) == "Reconnected"
|
||||
assert reconnect_notice.take(path, now=1010.0) == "" # taken
|
||||
reconnect_notice.write("Reconnected", path, now=1000.0)
|
||||
assert reconnect_notice.take(path, now=1000.0 + reconnect_notice.NOTICE_FRESH_S + 1) == ""
|
||||
assert not path.exists()
|
||||
|
||||
|
||||
def test_notice_from_another_boot_is_discarded(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(reconnect_notice.identity_store, "boot_id", lambda: "previous")
|
||||
path = tmp_path / "notice.json"
|
||||
reconnect_notice.write("Reconnected", path, now=20.0)
|
||||
monkeypatch.setattr(reconnect_notice.identity_store, "boot_id", lambda: "current")
|
||||
assert reconnect_notice.take(path, now=30.0) == "" and not path.exists()
|
||||
|
||||
|
||||
def test_galaxy_status_says_what_happened():
|
||||
text = Supervisor._describe_error("streaming", SharedRadioStall("stalled"))
|
||||
assert text == "The car's Wi-Fi stalled while the comma's own Wi-Fi was switching networks; reconnecting"
|
||||
assert stream_loss_kind(text) == "shared_radio" # bug reports file it apart from ordinary stalls
|
||||
assert stream_loss_kind("projecting: Video acknowledgement older than 2.5 s") == "stalled"
|
||||
@@ -179,3 +179,108 @@ def test_disconnect_stray_link_without_a_handle_only_logs(monkeypatch):
|
||||
phone, commands, events = _phone_with(monkeypatch, {"journalctl": ("", 0)})
|
||||
assert phone.disconnect_stray_link() is None
|
||||
assert len(commands) == 1 and events == ["stray_link_not_found"]
|
||||
|
||||
|
||||
def _rfcomm_attempts(sup, monkeypatch, outcomes):
|
||||
from openpilot.starpilot.system.starport import bt_sockets
|
||||
calls = []
|
||||
|
||||
def connect(*args, **kwargs):
|
||||
calls.append(args)
|
||||
outcome = outcomes[min(len(calls), len(outcomes)) - 1]
|
||||
if isinstance(outcome, int):
|
||||
raise OSError(outcome, "boom")
|
||||
return outcome
|
||||
|
||||
monkeypatch.setattr(bt_sockets, "connect_rfcomm", connect)
|
||||
return calls
|
||||
|
||||
|
||||
def test_busy_rfcomm_channel_is_retried_in_the_same_attempt(identity, tmp_path, monkeypatch):
|
||||
sup, _ = make_supervisor(identity, tmp_path, monkeypatch, lambda *a, **k: socket.socketpair()[0])
|
||||
_virtual_time(sup, monkeypatch)
|
||||
good = socket.socketpair()[0]
|
||||
calls = _rfcomm_attempts(sup, monkeypatch, [errno.EBUSY, good])
|
||||
assert sup._connect_rfcomm(CAR, 6) is good
|
||||
assert len(calls) == 2 and len(_events(sup, "rfcomm_busy_retry")) == 1
|
||||
|
||||
|
||||
def test_busy_rfcomm_channel_gives_up_after_the_waits(identity, tmp_path, monkeypatch):
|
||||
from openpilot.starpilot.system.starport import supervisor
|
||||
sup, _ = make_supervisor(identity, tmp_path, monkeypatch, lambda *a, **k: socket.socketpair()[0])
|
||||
_virtual_time(sup, monkeypatch)
|
||||
calls = _rfcomm_attempts(sup, monkeypatch, [errno.EBUSY])
|
||||
with pytest.raises(OSError) as raised:
|
||||
sup._connect_rfcomm(CAR, 6)
|
||||
assert raised.value.errno == errno.EBUSY and len(calls) == len(supervisor.RFCOMM_BUSY_WAITS) + 1
|
||||
|
||||
|
||||
def test_other_rfcomm_errors_are_not_retried(identity, tmp_path, monkeypatch):
|
||||
sup, _ = make_supervisor(identity, tmp_path, monkeypatch, lambda *a, **k: socket.socketpair()[0])
|
||||
_virtual_time(sup, monkeypatch)
|
||||
calls = _rfcomm_attempts(sup, monkeypatch, [errno.ECONNRESET])
|
||||
with pytest.raises(OSError):
|
||||
sup._connect_rfcomm(CAR, 6)
|
||||
assert len(calls) == 1 and not _events(sup, "rfcomm_busy_retry")
|
||||
|
||||
|
||||
def test_a_retried_busy_channel_logs_how_it_ended(identity, tmp_path, monkeypatch):
|
||||
sup, _ = make_supervisor(identity, tmp_path, monkeypatch, lambda *a, **k: socket.socketpair()[0])
|
||||
now = _virtual_time(sup, monkeypatch)
|
||||
sup._attempt_ended_at = now[0] - 4.0
|
||||
_rfcomm_attempts(sup, monkeypatch, [errno.EBUSY, errno.EBUSY, socket.socketpair()[0]])
|
||||
sup._connect_rfcomm(CAR, 6)
|
||||
assert _events(sup, "rfcomm_busy_cleared")[0]["tries"] == 3
|
||||
assert _events(sup, "rfcomm_busy_retry")[0]["since_last_attempt_s"] == 4.0
|
||||
|
||||
|
||||
def test_a_channel_that_stays_busy_logs_that_it_gave_up(identity, tmp_path, monkeypatch):
|
||||
sup, _ = make_supervisor(identity, tmp_path, monkeypatch, lambda *a, **k: socket.socketpair()[0])
|
||||
_virtual_time(sup, monkeypatch)
|
||||
_rfcomm_attempts(sup, monkeypatch, [errno.EBUSY])
|
||||
with pytest.raises(OSError):
|
||||
sup._connect_rfcomm(CAR, 6)
|
||||
assert _events(sup, "rfcomm_busy_gave_up")[0]["waited_s"] == 3.0
|
||||
|
||||
|
||||
def test_hands_free_logs_who_started_the_link(identity, tmp_path, monkeypatch):
|
||||
sup, _ = make_supervisor(identity, tmp_path, monkeypatch, lambda *a, **k: socket.socketpair()[0])
|
||||
now = _virtual_time(sup, monkeypatch)
|
||||
sup._paged_at = now[0] - 1.0
|
||||
sup._hfp_connected(CAR)
|
||||
sup._paged_at = now[0] - 60.0
|
||||
sup._hfp_connected(CAR)
|
||||
assert [e["initiator"] for e in _events(sup, "hfp_initiator")] == ["comma", "car"]
|
||||
|
||||
|
||||
def test_unusable_sdp_record_is_logged_with_what_the_car_sent(identity, tmp_path, monkeypatch):
|
||||
from openpilot.starpilot.system.starport import bt_sockets, sdp
|
||||
sup, _ = make_supervisor(identity, tmp_path, monkeypatch, lambda *a, **k: socket.socketpair()[0])
|
||||
_virtual_time(sup, monkeypatch)
|
||||
monkeypatch.setattr(bt_sockets, "connect_l2cap", lambda *a, **k: socket.socketpair()[0])
|
||||
|
||||
def query(_):
|
||||
raise sdp.SdpError("Android Auto Wireless SDP record has no RFCOMM channel", b"\x35\x03\x19\x11\x1f")
|
||||
|
||||
monkeypatch.setattr(sdp, "query_channel", query)
|
||||
with pytest.raises(RuntimeError):
|
||||
sup._discover_channel(CAR)
|
||||
assert _events(sup, "sdp_retry")[0]["record_hex"] == "350319111f"
|
||||
|
||||
|
||||
def test_readiness_time_starts_over_after_a_failed_attempt(identity, tmp_path, monkeypatch):
|
||||
sup, _ = make_supervisor(identity, tmp_path, monkeypatch, lambda *a, **k: None)
|
||||
now = _virtual_time(sup, monkeypatch)
|
||||
elapsed = []
|
||||
|
||||
def attempt(lease):
|
||||
elapsed.append(now[0] - sup._run_started)
|
||||
now[0] += 90.0
|
||||
if len(elapsed) == 1:
|
||||
raise RuntimeError("Car not ready")
|
||||
sup._stop.set()
|
||||
|
||||
monkeypatch.setattr(sup, "_attempt", attempt)
|
||||
monkeypatch.setattr(sup, "_wait_backoff", lambda delay, **kwargs: sup._wait(delay))
|
||||
sup._run(1, trigger="manual")
|
||||
assert elapsed == [0.0, 0.0]
|
||||
|
||||
@@ -75,6 +75,14 @@ FEATURE_CONFIRM_ACTIONS = (TORQUE_CONFIRM_ACTIONS | SLC_CONFIRM_ACTIONS | LANE_C
|
||||
CURVE_CONFIRM_ACTIONS | CONDITIONAL_CONFIRM_ACTIONS | {"reset_profiles", "pip:reset", "sentry:reset"})
|
||||
|
||||
|
||||
HEADING_PREFIX = "heading:"
|
||||
|
||||
|
||||
def is_heading(key: str) -> bool:
|
||||
"""A section title inside a page: shown like a row but never a control, and never a change request."""
|
||||
return key.startswith(HEADING_PREFIX)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FeatureRow:
|
||||
key: str
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"protected_inventory_sha256": "60dd598c374244ab7b331b2ac56c7b3306420091663e5ad82aaec23804793594", "schema_version": 1}
|
||||
{"protected_inventory_sha256": "11f88170e7196f24bf59ef548439a00c404e95ce101de8d9479c239e58184cef", "schema_version": 1}
|
||||
|
||||
Reference in New Issue
Block a user