Merge pull request #237 from dirwin31:PR-Smaller-3

Android Auto: Car screen widgets, navigation overlay, now playing and diagnostics
This commit is contained in:
prabhaavp
2026-10-21 13:11:34 -04:00
committed by GitHub
63 changed files with 5203 additions and 154 deletions
@@ -11,7 +11,7 @@ from openpilot.starpilot.system.android_auto.display_profile import (
SCREEN_PATH, MAX_BYTES as SCREEN_MAX_BYTES, record_screen, validate_screen,
)
from openpilot.starpilot.system.android_auto.projection_layout import (
DOCUMENT_KEY, MAX_BYTES, ProjectionLayoutSource, comma_display, decode_layout, default_layout, layout_metadata, validate_layout,
DOCUMENT_KEY, JETLINK_WIDGET, MAX_BYTES, ProjectionLayoutSource, comma_display, decode_layout, default_layout, layout_metadata, validate_layout,
)
from openpilot.starpilot.ui.appearance_preferences import onroad_appearance
from openpilot.starpilot.ui.onroad_customization import read_customization
@@ -81,8 +81,12 @@ class ProjectionLayoutOwner:
'Connect Android Auto once to obtain the actual screen' if screen is None else
'Saved projection layout is unavailable' if not readable else None)
base = read_customization(self.params)
metadata = layout_metadata(screen) if screen is not None else None
if metadata is not None:
mode = self.params.get('JetlinkMode')
metadata['widgets'][JETLINK_WIDGET]['available'] = type(mode) is int and mode in (1, 2)
return {'version': 1, 'document': document, 'defaults': defaults,
'metadata': layout_metadata(screen) if screen is not None else None,
'metadata': metadata,
'screen': screen, 'revision': _revision(screen_raw, raw),
'available': available, 'editable': bool(available and self.parked()), 'valid': valid,
'reason': reason, 'retainedLayout': raw is not None,
@@ -22,6 +22,10 @@ class Params:
def __init__(self, root):
self.root = root
self.enabled = True
self.jetlink_mode = 0
def get(self, key):
assert key == 'JetlinkMode'
return self.jetlink_mode
def get_param_path(self, key):
return str(self.root / key)
def get_bool(self, key):
@@ -57,6 +61,20 @@ class TestProjectionLayoutOwner(unittest.TestCase):
self.assertIsNone(value['defaults'])
self.assertIn('actual screen', value['reason'])
def test_jetlink_widget_available_when_selected_and_placement_retained_when_off(self):
self.screen()
self.assertFalse(self.owner.snapshot()['metadata']['widgets']['jetlink_status']['available'])
for mode in (1, 2):
self.params.jetlink_mode = mode
self.assertTrue(self.owner.snapshot()['metadata']['widgets']['jetlink_status']['available'])
payload = self.payload()
payload['document']['widgets']['jetlink_status'].update(enabled=True, x=700, y=500)
self.owner.save(payload, session_valid=lambda: True)
self.params.jetlink_mode = 0
snapshot = self.owner.snapshot()
self.assertFalse(snapshot['metadata']['widgets']['jetlink_status']['available'])
self.assertEqual(snapshot['document'], payload['document'])
def test_defaults_valid_false_then_exact_saved_document(self):
self.screen()
value = self.owner.snapshot()
@@ -228,7 +246,7 @@ from types import SimpleNamespace
from openpilot.starpilot.galaxy.projection_layout import host_owner
from openpilot.starpilot.system.android_auto.projection_layout_runtime import load_projection_layout
root = Path(os.environ['ANDROID_AUTO_DIR'])
params = SimpleNamespace(get_param_path=lambda key: str(root / 'params' / key), get_bool=lambda key: False)
params = SimpleNamespace(get_param_path=lambda key: str(root / 'params' / key), get_bool=lambda key: False, get=lambda key: None)
owner = host_owner(params, lambda: True)
snapshot = owner.snapshot()
payload = {'revision': snapshot['revision'], 'document': copy.deepcopy(snapshot['document'])}
@@ -1,6 +1,6 @@
import assert from "node:assert/strict"
import { readFileSync } from "node:fs"
import { AndroidAutoFeed, AndroidAutoPage, validPairingStatus, validPromptInput, uploadPackage,
import { AndroidAutoFeed, AndroidAutoPage, validPairingStatus, validPromptInput, uploadPackage, uploadTimeout,
expiryInfo, packagePreflight, importProblem, installChecks } from "../web/js/android-auto.js"
const setup = { enabled: true, bluetoothEnabled: true, parked: true, installReady: true, serviceReady: true,
@@ -304,6 +304,11 @@ const alreadyAborted = new AbortController(); alreadyAborted.abort()
const neverSent = new UploadRequest()
await assert.rejects(uploadPackage('upload', { signal: alreadyAborted.signal }, () => {}, () => neverSent), /canceled/)
assert.equal(neverSent.sent, null)
assert.equal(uploadTimeout(5 * 1024 * 1024), 180000, "small packages keep the three-minute floor")
assert.equal(uploadTimeout(100 * 1024 * 1024), 1600000, "large packages get time for a slow tunnel")
const slowLink = new UploadRequest()
uploadPackage('upload', { body: {}, timeout: 1600000 }, () => {}, () => slowLink)
assert.equal(slowLink.timeout, 1600000)
assert.doesNotMatch(AndroidAutoPage.template, /v-if="!localAccess"|Open this comma directly/)
console.log('Remote-capable binary uploader: progress, credentials, failure, timeout and cancellation passed')
@@ -916,7 +916,20 @@ export const AndroidAutoPage = {
<p>Pair each car in step 2 with <strong>Pair another</strong>. All your paired cars appear in the list. Tap <strong>Use</strong> next to the one you’re driving. Disconnect first if Android Auto is running.</p>
</details>
<details class="gx-aa-wiki"><summary>Is my information private?</summary>
<p>Yes. Your extracted Android Auto identity stays on your comma. The app downloads straight to your phone or computer, and the copy you upload is deleted after import. Connection logs also stay on the comma until you download them yourself. They leave out Wi‑Fi passwords, Bluetooth addresses, and your vehicle ID.</p>
<p>Your extracted Android Auto identity stays on your comma. The app downloads straight to your phone or computer, and the copy you upload is deleted after import. Full connection logs stay on the comma until you download them. When parked and Android Auto is stopped, StarPilot may send connection errors.</p>
</details>
<h4>Wireless Dongle Woes</h4>
<details class="gx-aa-wiki"><summary>My AAWireless (or other wireless adapter) won’t connect</summary>
<p><strong>The comma needs to be paired to two things:</strong> the adapter <em>and</em> the car. The adapter shows Android Auto on the car’s screen. The car’s own Bluetooth connection is how StarPilot remembers which car you’re in.</p>
<p><strong>Do this while parked:</strong></p>
<ol>
<li><strong>Pair AAWireless</strong> and select it as the Android Auto receiver in Galaxy. Keep it selected.</li>
<li>Open <a href="#/bluetooth">Bluetooth</a> in Galaxy and <strong>pair the comma with the car’s actual Bluetooth entry</strong>, then tap <strong>Connect</strong>. If it’s already paired, just tap Connect.</li>
<li><strong>Retry Android Auto.</strong></li>
</ol>
<p><strong>Why connect the car in Galaxy?</strong> Connecting from Galaxy’s Bluetooth page saves the car as your companion car. Connecting from the car’s own menu doesn’t.</p>
<p><strong>Don’t switch the receiver to the car.</strong> AAWireless stays selected as the Android Auto receiver. The car is a separate hands-free connection.</p>
</details>
<h4>Still stuck?</h4>
@@ -13,6 +13,8 @@ Every request counts toward the month's Mapbox usage shown in Galaxy.
from __future__ import annotations
from openpilot.starpilot.galaxy.companion import phone_uplink_active
import math
import os
import time
@@ -462,6 +464,8 @@ def _environment(sm, owner, map_enabled: bool, now_ns: int) -> Environment:
kind = str(device.networkType)
env.network = ("offline" if kind == "none" else
"wifi" if kind in ("wifi", "ethernet") and not device.networkMetered else "cellular")
if phone_uplink_active():
env.network = "cellular"
env.onroad = bool(device.started)
fix = location(sm, now_ns)
if fix is not None:
@@ -0,0 +1,70 @@
import json
from openpilot.starpilot.soloist import now_playing
from openpilot.starpilot.system.android_auto.projection_now_playing import ProjectionNowPlayingFeed
PLAYER = {'loggedIn': True, 'active': True, 'status': 'playing',
'track': {'title': 'Midnight City', 'artists': 'M83', 'album': 'Hurry Up', 'durationMs': 243000},
'position': {'ms': 10000, 'at': 1000, 'speed': 1.0}}
def present(value):
assert value is not None
return value
def test_spotify_snapshot_needs_an_active_logged_in_player():
found = present(now_playing.from_spotify(PLAYER))
assert found['source'] == 'spotify' and found['title'] == 'Midnight City' and found['artist'] == 'M83'
for change in ({'loggedIn': False}, {'active': False}, {'status': 'idle'}, {'track': None}):
assert now_playing.from_spotify({**PLAYER, **change}) is None
assert now_playing.from_spotify(None) is None
def test_phone_snapshot_reads_bluez_media_player():
props = {'Status': 'playing', 'Position': 5000, 'Track': {'Title': 'Song', 'Artist': 'Band', 'Album': 'LP', 'Duration': 200000}}
found = present(now_playing.from_phone(props, now_ms=2000))
assert (found['source'], found['status'], found['title'], found['artist']) == ('phone', 'playing', 'Song', 'Band')
assert found['durationMs'] == 200000 and found['position'] == {'ms': 5000, 'at': 2000, 'speed': 1.0}
assert present(now_playing.from_phone({**props, 'Status': 'paused'}, now_ms=2000))['position']['speed'] == 0.0
for bad in (None, {}, {'Status': 'stopped', 'Track': props['Track']}, {'Status': 'playing', 'Track': {}},
{'Status': 'playing', 'Track': {'Title': '\x00\x01'}}):
assert now_playing.from_phone(bad) is None
def test_untrusted_text_is_cleaned_and_bounded():
found = present(now_playing.from_phone({'Status': 'playing', 'Track': {'Title': 'A\x00B' + 'x' * 500, 'Artist': 7}}))
assert found['title'].startswith('AB') and len(found['title']) == now_playing.MAX_TEXT and found['artist'] == ''
def test_elapsed_follows_the_anchor_and_clamps_to_the_duration():
found = present(now_playing.from_spotify(PLAYER))
assert now_playing.elapsed_ms(found, 4000) == 13000
assert now_playing.elapsed_ms(found, 10 ** 12) == 243000
assert now_playing.elapsed_ms({**found, 'position': None}, 4000) == 0
def test_published_snapshot_is_ignored_when_stale_or_broken(tmp_path):
path = tmp_path / 'status.json'
assert now_playing.read(path) is None
path.write_text(json.dumps({'nowPlaying': now_playing.from_spotify(PLAYER)}))
mtime = path.stat().st_mtime
assert present(now_playing.read(path, now=mtime + 1))['title'] == 'Midnight City'
assert now_playing.read(path, now=mtime + 60) is None
path.write_text('{broken')
assert now_playing.read(path, now=mtime + 1) is None
path.write_text(json.dumps({'nowPlaying': {'title': 'x', 'status': 'idle'}}))
assert now_playing.read(path, now=mtime + 1) is None
def test_feed_only_reads_while_enabled():
reads = []
feed = ProjectionNowPlayingFeed(read=lambda: reads.append(1) or {'title': 'x'})
feed.poll()
assert reads == [] and feed.snapshot() is None
feed._enabled = True
feed.poll()
assert feed.snapshot() == {'title': 'x'}
feed.set_enabled(False)
assert feed.snapshot() is None
feed.close()
@@ -16,12 +16,11 @@ from collections.abc import Callable
from pathlib import Path
from typing import Any, Protocol
from jeepney import DBusAddress, MatchRule, new_error, new_method_return
from jeepney.low_level import HeaderFields, MessageType
from jeepney.wrappers import Properties
from jeepney import MatchRule, new_error, new_method_return
from jeepney.low_level import HeaderFields
from openpilot.starpilot.bluetooth.owner import (
ADAPTER, AGENT, BLUEZ, BlueZ, BluetoothOwner, BluetoothRejected, BluetoothUnavailable, PairingSession, normalized_address,
AGENT, BlueZ, BluetoothOwner, BluetoothRejected, BluetoothUnavailable, OpenPairingSession, PairingSession, normalized_address,
)
from openpilot.starpilot.system.android_auto.incoming_pairing import IncomingPairing
@@ -40,19 +39,6 @@ class PhoneProfile(Protocol):
class PhoneRoleBlueZ(BlueZ):
"""The existing owner's D-Bus connection is the sole adapter-property writer."""
def adapter_identity(self) -> tuple[str, dict[str, Any], str]:
path, props = self._adapter(self._objects())
if not path:
raise BluetoothUnavailable('Bluetooth adapter is unavailable')
return path, props, self._bluez_sender()
def set_pairing_visibility(self, path: str, *, pairable: bool, discoverable: bool) -> None:
address = DBusAddress(path, bus_name=BLUEZ, interface=ADAPTER)
for key, value in (('Pairable', pairable), ('Discoverable', discoverable)):
reply = self.router.send_and_get_reply(Properties(address).set(key, 'b', value), timeout=10.0)
if reply.header.message_type == MessageType.error:
raise BluetoothRejected(f'Bluetooth {key} change failed')
def register_incoming_agent(self, agent: IncomingPairing, sender: str) -> Callable[[], None]:
agent_filter = self.router.filter(MatchRule(type='method_call', interface=AGENT, path=AA_AGENT_PATH), bufsize=16)
queue = agent_filter.__enter__()
@@ -190,6 +176,9 @@ class SharedBluetoothOwner(BluetoothOwner):
self.lock_path = lock_path
self.companion_devices: dict[str, str] = {}
self.journal_path = lock_path.with_suffix('.recovery.json')
self.window_paired: dict | None = None
if self.on_paired is None:
self.on_paired = lambda address, name, uuids: setattr(self, 'window_paired', {'address': address, 'name': name, 'uuids': list(uuids)})
def _process_lock(self):
self.lock_path.parent.mkdir(parents=True, exist_ok=True)
@@ -329,6 +318,10 @@ class SharedBluetoothOwner(BluetoothOwner):
lease.released = True
self.companion_devices.clear()
self.phone_lease = None
window = self.pairing
if isinstance(window, OpenPairingSession) and not window.done.is_set():
window.cancel()
window.done.wait(5.0)
if lease.session is not None:
self.cancel_session(lease.session)
try:
@@ -619,7 +612,7 @@ class SharedBluetoothOwner(BluetoothOwner):
None means this process holds no AA lease. A pairing lease or any invalid
target is rejected without releasing the controller or falling back.
"""
if operation not in ('connect', 'disconnect'):
if operation not in ('connect', 'disconnect', 'forget'):
raise ValueError('Unsupported companion Bluetooth operation')
address = normalized_address(address)
if not self.lock.acquire(blocking=False):
@@ -642,8 +635,16 @@ class SharedBluetoothOwner(BluetoothOwner):
if receiver is None or not receiver['paired'] or receiver['path'] != lease.device_path:
raise BluetoothRejected('Projection receiver changed', code='changed')
if address == lease.receiver:
raise BluetoothRejected('Stop projection before changing its adapter', code='busy')
raise BluetoothRejected('Stop projection before changing its adapter', code='projection_active')
device = lease.phone.device(address)
if operation == 'forget':
if device is None or not device['path'].startswith(lease.adapter_path + '/'):
raise BluetoothRejected('Device is unavailable', code='changed')
client.operation('forget', address)
if self.phone_lease is not lease or lease.released or not self.session_valid(session):
raise BluetoothRejected('Bluetooth owner changed', code='changed')
self.companion_devices.pop(address, None)
return self.snapshot(session=session)
hfp = '0000111e-0000-1000-8000-00805f9b34fb'
if device is None or not device['paired'] or hfp not in device.get('uuids', []):
raise BluetoothRejected('Choose an already paired hands-free car', code='changed')
@@ -678,6 +679,44 @@ class SharedBluetoothOwner(BluetoothOwner):
finally:
self.lock.release()
def phone_window(self, operation: str, session: tuple, *, prompt_id: str | None = None,
accepted: bool | None = None, value: str = '') -> dict | None:
"""Open, answer or read a phone pairing window inside the projection lease; None means no projection holds the adapter."""
if operation not in ('accept_pair', 'pairing_response', 'cancel_pair', 'status'):
raise ValueError('Unsupported phone window operation')
if not self.lock.acquire(blocking=False):
raise BluetoothRejected('Bluetooth is busy', code='busy')
try:
lease = self.phone_lease
window = self.pairing if isinstance(self.pairing, OpenPairingSession) else None
if lease is None or lease.released:
return None
if lease.session is not None:
raise BluetoothRejected('Finish Android Auto pairing first', code='busy')
if not self.session_valid(session):
raise BluetoothRejected('Galaxy session expired', code='session_expired')
if operation != 'accept_pair' and (window is None or window.identity != session):
return None
if operation == 'accept_pair':
if not self.parked():
raise BluetoothRejected('Park before pairing a phone', code='park_required')
if self.pairing is not None and not self.pairing.done.is_set():
raise BluetoothRejected('Pairing is in progress', code='busy')
window = OpenPairingSession(session, lambda: self.session_valid(session) and self.parked(), self.clock)
self.pairing = window
self.window_paired = None
threading.Thread(target=self._accept_worker, args=(window, lambda *_: None, lambda: None), daemon=True).start()
threading.Thread(target=self._watch_pair, args=(window,), daemon=True).start()
elif operation == 'cancel_pair':
window.cancel()
elif operation == 'pairing_response' and (prompt_id is None or type(accepted) is not bool or not window.respond(session, prompt_id, accepted, value)):
raise BluetoothRejected('Pairing prompt changed')
result = self.snapshot(session=session)
result['windowPaired'] = self.window_paired
return result
finally:
self.lock.release()
def request(self, operation: str, **kwargs) -> dict:
lease = self.phone_lease
if lease is not None and lease.session is None and operation in ('forget', 'power'):
@@ -688,7 +727,7 @@ class SharedBluetoothOwner(BluetoothOwner):
lease = None
if lease is not None and operation in ('power', 'scan', 'stop_scan', 'forget', 'connect', 'disconnect'):
raise BluetoothRejected('Android Auto pairing has the Bluetooth adapter')
if lease is not None and operation in ('pair', 'cancel_pair', 'pairing_response'):
if lease is not None and operation in ('pair', 'accept_pair', 'cancel_pair', 'pairing_response'):
raise BluetoothRejected('Use the Android Auto pairing operation for this lease')
return super().request(operation, **kwargs)
@@ -0,0 +1,466 @@
"""Bounded Android Auto diagnostics for a separately configured Bugsink project."""
from __future__ import annotations
import json
import math
import os
import re
import tempfile
import threading
import time
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from pathlib import Path
from openpilot.starpilot.system.android_auto import identity as identity_store
from openpilot.starpilot.system.android_auto.bugsink_diagnosis import attempt_records, diagnosis, elapsed, sanitize
from openpilot.starpilot.system.android_auto.bugsink_summary import batch_summary, failure_summary, pairing_summary, session_timeline, software_tags
STATE_PATH = identity_store.DATA_DIR / "bugsink_report.json"
PAIRING_PATH = identity_store.LOG_DIR / "pairing.jsonl"
PAIRING_MAX_BYTES = 64 << 10
INTERVAL_S = 60.0
LOG_SETTLE_S = 120 # a log written this recently may still be a live session, unless it already ended
MAX_SESSIONS = 20 # per drive report; the rest go in the next one
MAX_FAILURES = 5 # distinct failures detailed per drive report
MAX_BREADCRUMBS = 100
FINISHED_EVENT = '"event": "session_stop"'
OUTCOME_RANK = ("streamed", "recovered", "stopped", "dropped", "failed")
def scrub(text: str, limit: int = 300) -> str:
return sanitize(str(text))[:limit]
def _number(value) -> float | None:
return float(value) if isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) else None
def _session_number(path: Path) -> int:
match = re.fullmatch(r"session-(\d{6})-.*\.jsonl", path.name)
return int(match[1]) if match else 0
def _percentile(values: list[float], fraction: float) -> float | None:
if not values:
return None
ordered = sorted(values)
return ordered[min(len(ordered) - 1, int(len(ordered) * fraction))]
def stage_timings(events: list[dict]) -> list[dict]:
"""Each connection stage in the order it was first entered, with seconds since the session started."""
from openpilot.starpilot.system.android_auto.compat_report import STAGE_ORDER
seen: dict[str, float | None] = {}
for event in events:
state = event.get("state")
if event["event"] == "stage" and state in STAGE_ORDER and state not in seen:
seen[state] = elapsed(events[0], event)
return [{"stage": stage, "at_s": at} for stage, at in seen.items()]
def video_summary(events: list[dict]) -> dict:
"""The video mode and the frame rate delivered while the car showed StarPilot, from the 30 s stats lines."""
ready = next((event for event in reversed(events) if event["event"] == "projection_ready"), {})
pipeline = next((event for event in reversed(events) if event["event"] == "frame_pipeline"), {})
mode = ready.get("mode")
mode = mode if isinstance(mode, dict) else {}
stats = [event for event in events if event["event"] == "stats" and event.get("focused") is not False]
fps = [value for event in stats if (value := _number(event.get("fps"))) is not None]
encode = [value for event in stats if (value := _number(event.get("encode_ms"))) is not None]
peaks = [value for event in stats if (value := _number(event.get("encode_peak_ms"))) is not None]
ages = [value for event in stats if (value := _number(event.get("frame_age_p95_ms"))) is not None]
wifi = [value for event in stats if (value := _number(event.get("wifi_dbm"))) is not None]
summary = {
"resolution": f"{mode['width']}x{mode['height']}" if mode.get("width") and mode.get("height") else None,
"car_fps": mode.get("fps"),
"margins": f"{mode.get('margin_width')}x{mode.get('margin_height')}" if mode else None,
"target_fps": pipeline.get("target_fps"),
"encoder": pipeline.get("encoder"),
"nv12": pipeline.get("nv12"),
"samples": len(fps),
"fps_avg": round(sum(fps) / len(fps), 1) if fps else None,
"fps_min": min(fps) if fps else None,
"fps_p10": _percentile(fps, 0.1),
"encode_ms_avg": round(sum(encode) / len(encode), 1) if encode else None,
"encode_peak_ms": max(peaks) if peaks else None,
"frame_age_p95_ms_max": max(ages) if ages else None,
"wifi_dbm_min": min(wifi) if wifi else None,
"encoder_recoveries": max((int(event.get("encoder_recoveries") or 0) for event in stats), default=0),
"frames_sent": max((int(event.get("frames_sent") or 0) for event in stats), default=0),
}
return {key: value for key, value in summary.items() if value is not None}
def session_record(path: Path) -> dict:
"""One finished session log reduced to its outcome, stage timings, video and the car it talked to."""
from openpilot.starpilot.system.android_auto import compat_report
events = compat_report.load_events(path)
report = compat_report.summarize(events)
started = events[0].get("t", "") if events else ""
attempts = attempt_records(events)
relevant = [a for a in attempts if not a["after_car_shutdown"]]
failures = [a for a in relevant if a.get("error_kind") or a.get("error")]
streamed = [a for a in relevant if a["video_acknowledged"]]
drops = [a for a in failures if a["video_acknowledged"]]
if streamed:
outcome = "recovered" if failures and relevant[-1]["video_acknowledged"] and relevant[-1] not in failures else "streamed"
if failures and relevant[-1] in failures:
outcome = "dropped"
else:
outcome = "failed" if failures else "stopped"
last_failure = failures[-1] if failures else {}
first_frame = next((e for e in events if e["event"] == "video_acknowledged"), None)
car_ended = any(a["ended_by_car"] for a in attempts)
car = report["car"]
start = next((e for e in events if e["event"] == "session_start"), {})
software, settings = start.get("software", {}), start.get("settings")
return {
"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),
"name": path.name,
"started": started,
"transport": report["transport"],
"trigger": report["trigger"],
"outcome": outcome,
"furthest_stage": report["furthest_stage"],
"failed_stage": str(last_failure.get("failed_stage", "")),
"error_kind": str(last_failure.get("error_kind", "")),
"error": scrub(last_failure.get("error", "")),
"failures": len(failures),
"drops": len(drops),
"ended_by_car": car_ended,
"attempts": attempts,
"timing_source": "monotonic" if events and all("mono_s" in e for e in events) else "wall clock (legacy log)",
"time_to_first_frame_s": elapsed(events[0], first_frame) if first_frame else None,
"streamed_s": round(sum(a["streamed_s"] or 0 for a in attempts), 1) if first_frame else None,
"stages": stage_timings(events),
"video": video_summary(events),
"focus_lost": report["focus"]["lost"],
"stalls": report["health"],
"wifi_security": report["wifi"].get("security") or None,
"tls": report["tls"].get("version") or None,
"head_unit": {key: car[key] for key in ("head_unit_make", "head_unit_model", "head_unit_software_version",
"car_make", "car_model", "car_year") if car.get(key)},
"_report": report,
"_names": [car.get("bluetooth_name", ""), car.get("display_name", "")],
}
def _finished(path: Path) -> bool:
with path.open("rb") as log:
log.seek(0, os.SEEK_END)
log.seek(max(0, log.tell() - 512))
lines = log.read().decode(errors="replace").strip().splitlines()
return bool(lines) and FINISHED_EVENT in lines[-1]
def new_sessions(log_dir: Path, last_seq: int, now: float) -> list[dict]:
"""Finished sessions newer than ``last_seq``, oldest first; a log still being written stops the scan."""
sessions = []
for path in sorted(log_dir.glob("session-*.jsonl"), key=identity_store.session_log_order):
if _session_number(path) <= last_seq:
continue
if len(sessions) >= MAX_SESSIONS:
break
try:
if now - path.stat().st_mtime < LOG_SETTLE_S and not _finished(path):
break
sessions.append(session_record(path))
except (OSError, ValueError, TypeError, KeyError):
continue
return sessions
def drive_summary(sessions: list[dict]) -> dict:
"""Totals across a batch of completed sessions, with sample-weighted frame rate."""
weighted = [(s["video"]["fps_avg"], s["video"]["samples"]) for s in sessions if s["video"].get("fps_avg") is not None]
samples = sum(count for _, count in weighted)
outcome = max((s["outcome"] for s in sessions), key=OUTCOME_RANK.index)
return {
"sessions": len(sessions),
"outcome": outcome,
"streamed_sessions": sum(1 for s in sessions if s["time_to_first_frame_s"] is not None),
"streamed_s": round(sum(s["streamed_s"] or 0 for s in sessions), 1),
"failures": sum(s["failures"] for s in sessions),
"drops": sum(s["drops"] for s in sessions),
"fps_avg": round(sum(fps * count for fps, count in weighted) / samples, 1) if samples else None,
"fps_min": min((s["video"]["fps_min"] for s in sessions if "fps_min" in s["video"]), default=None),
"resolutions": sorted({s["video"]["resolution"] for s in sessions if s["video"].get("resolution")}),
"time_to_first_frame_s": next((s["time_to_first_frame_s"] for s in sessions if s["time_to_first_frame_s"] is not None), None),
"transport": sessions[-1]["transport"] if len({s["transport"] for s in sessions}) == 1 else "mixed",
}
def _head_unit_label(head_unit: dict) -> str:
return " ".join(head_unit.get(key, "") for key in ("head_unit_make", "head_unit_model")).strip() or "unknown head unit"
def _car_label(head_unit: dict) -> str:
return " ".join(head_unit.get(key, "") for key in ("car_year", "car_make", "car_model")).strip() or "unknown car"
def _public(session: dict) -> dict:
result = {key: value for key, value in session.items() if not key.startswith("_")}
result["attempt_count"] = len(result.get("attempts", []))
result["attempts_omitted"] = max(0, result["attempt_count"] - 20)
result["attempts"] = result.get("attempts", [])[-20:]
return sanitize(result, session.get("_names", ()))
def failure_signature(session: dict) -> str:
return f"{diagnosis(session)['category']}:{session['failed_stage'] or 'start'}:{session['error_kind'] or 'none'}"
def failure_records(sessions):
for session in reversed(sessions):
for attempt in reversed(session["attempts"]):
if attempt["after_car_shutdown"] or not (attempt.get("error") or attempt.get("error_kind")):
continue
yield {**session, "failed_stage": attempt.get("failed_stage", ""), "error_kind": attempt.get("error_kind", ""),
"error": attempt.get("error", ""), "failed_attempt": attempt["attempt"]}
def drive_failures(sessions: list[dict]) -> list[dict]:
"""Distinct failures across the batch, newest first."""
seen, failures = set(), []
for failure in failure_records(sessions):
signature = failure_signature(failure)
if signature not in seen:
seen.add(signature)
failures.append(failure)
return failures[:MAX_FAILURES]
def failure_detail(session: dict) -> dict:
stage = session["failed_stage"] or session["furthest_stage"] or "start"
kind = session["error_kind"] or "no error"
assessment = diagnosis(session)
names = session.get("_names", ())
return {
"title": f"{assessment['category']} at {stage}: {kind}",
"session": session["seq"],
"attempt": session.get("failed_attempt"),
"cause": assessment["category"], "stage": stage, "error_kind": kind,
"summary": sanitize(failure_summary(assessment), names),
"diagnosis": sanitize(assessment, names),
}
def compat_text(session: dict) -> str:
from openpilot.starpilot.system.android_auto import compat_report
names = session.get("_names", ())
report = dict(session["_report"])
report["car"] = {key: value for key, value in report["car"].items() if key not in ("bluetooth_name", "display_name")}
report["errors"] = [{**error, "error": scrub(error.get("error", ""))} for error in report["errors"]]
report["ended"] = [scrub(reason) for reason in report["ended"]]
report["tls"] = {key: value for key, value in report["tls"].items() if key != "head_unit"}
return sanitize(compat_report.render_text(report), names)
def _attempt_time(session: dict, at_s) -> str | None:
try:
return (datetime.fromisoformat(session["started"]) + timedelta(seconds=at_s or 0)).isoformat()
except (TypeError, ValueError):
return None
def attempt_breadcrumbs(sessions: list[dict]) -> list[dict]:
"""Every attempt in the batch, oldest first; failures are errors so they stand out in the timeline."""
crumbs = []
for session in sessions:
for attempt in session["attempts"]:
failed = bool(attempt.get("error") or attempt.get("error_kind"))
if failed:
failure = {**session, "failed_stage": attempt.get("failed_stage", ""), "error_kind": attempt.get("error_kind", "")}
message = f"{diagnosis(failure)['category']} at {attempt.get('failed_stage') or 'start'}: {attempt.get('error_kind') or 'no error'}"
if attempt["video_acknowledged"]:
message = (f"streamed {attempt['streamed_s']}s, then " if attempt["streamed_s"] is not None else "streamed, then ") + message
elif attempt["video_acknowledged"]:
message = f"streamed {attempt['streamed_s']}s" if attempt["streamed_s"] is not None else "streamed"
else:
message = f"stopped at {attempt['stages'][-1]['stage'] if attempt['stages'] else 'start'}"
if attempt["after_car_shutdown"]:
message += " (after car shutdown)"
level = "info" if not failed else "debug" if attempt["after_car_shutdown"] else "error"
crumb = {"category": f"session {session['seq']}", "message": f"Attempt {attempt['attempt']}: {message}", "level": level,
"data": {key: attempt[key] for key in ("error", "failed_at_s") if attempt.get(key)}}
if (timestamp := _attempt_time(session, attempt.get("failed_at_s") if failed else attempt.get("at_s"))) is not None:
crumb["timestamp"] = timestamp
crumbs.append(sanitize(crumb, session.get("_names", ())))
return crumbs[-MAX_BREADCRUMBS:]
def drive_event(sessions: list[dict]) -> dict:
"""One report per batch: totals, every session, and each distinct failure, grouped by head unit."""
summary = drive_summary(sessions)
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)
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"
message = f"Android Auto {heading}: {label} ({summary['transport'] or 'unknown'})\n\n" + batch_summary(sessions, summary)
if failures:
message += "\n\nFailures:\n" + "\n".join(f" {index}. {failure['title']} (session {failure['session']}, attempt {failure['attempt']})"
for index, failure in enumerate(failures, 1))
message += "".join(f"\n\n--- {index}. {failure['title']} ---\n{failure['summary']}" for index, failure in enumerate(failures, 1))
message += "".join(f"\n\n--- Session {session['seq']} ---\n" + sanitize(session_timeline(session), session.get("_names", ()))
for session in failed)
latest = failures[0] if failures else {}
return {
"message": message,
"level": {"streamed": "info", "recovered": "info", "failed": "error"}.get(summary["outcome"], "warning"),
"fingerprint": ["android-auto-drive", summary["transport"] or "unknown", label] + (["failures"] if failures else []),
"tags": {**software_tags(sessions), "aa.report": "drive", "aa.outcome": summary["outcome"], "aa.transport": summary["transport"] or "unknown",
"aa.head_unit": label, "aa.car": _car_label(head_unit), "aa.resolution": video.get("resolution", "none"),
"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)),
**({"aa.cause": latest["cause"], "aa.stage": latest["stage"], "aa.error_kind": latest["error_kind"]} if latest else {})},
"breadcrumbs": {"values": attempt_breadcrumbs(sessions)},
"contexts": {"android_auto_drive": summary, "android_auto_head_unit": head_unit,
**{f"android_auto_session_{index + 1}": _public(session) for index, session in enumerate(sessions)},
**{f"android_auto_failure_{index + 1}": {key: value for key, value in failure.items() if key != "summary"}
for index, failure in enumerate(failures)},
**{f"android_auto_compat_report_session_{session['seq']}": {"text": compat_text(session)} for session in failed}},
}
def record_pairing(record: dict, path: Path = PAIRING_PATH) -> None:
"""Append one finished pairing attempt; the journal keeps only its newest lines."""
try:
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
lines = path.read_text().splitlines() if path.exists() else []
seq = 1 + max((json.loads(line).get("seq", 0) for line in lines if line.strip()), default=0)
lines.append(json.dumps({"seq": seq, **record}, default=str))
while len(lines) > 1 and sum(len(line) + 1 for line in lines) > PAIRING_MAX_BYTES:
lines.pop(0)
_write_atomic(path, "\n".join(lines) + "\n")
except (OSError, ValueError):
pass
def new_pairings(last_seq: int, path: Path = PAIRING_PATH) -> list[dict]:
try:
lines = path.read_text().splitlines()
except OSError:
return []
records = []
for line in lines:
try:
record = json.loads(line)
except ValueError:
continue
if isinstance(record, dict) and int(record.get("seq", 0)) > last_seq:
records.append(record)
return records
def pairing_event(record: dict) -> dict:
result = str(record.get("result", "unknown"))
return {
"message": f"Android Auto pairing {result}\n\n" + sanitize(pairing_summary(record)),
"level": "info" if result == "paired" else "warning",
"fingerprint": ["android-auto-pairing", result],
"tags": {**software_tags([record]), "aa.report": "pairing", "aa.pairing": result, "aa.transport": record.get("transport", "unknown")},
"contexts": {"android_auto_pairing": sanitize(record)},
}
class PairingAttempt:
"""The steps of one pairing window, with seconds since it opened; ``finish`` writes it to the journal."""
def __init__(self, seconds: float, clock: Callable[[], float] = time.monotonic, sink: Callable[[dict], None] = record_pairing):
from openpilot.starpilot.system.android_auto.bugsink_transport import device_metadata
software = device_metadata()
self._clock, self._sink = clock, sink
self._opened = clock()
self.record: dict = {"started": datetime.now().astimezone().isoformat(timespec="seconds"), "window_s": seconds, "steps": [],
"transport": "wireless", "software": {key: software.get(key, "unknown") for key in ("commit", "branch")}}
self.step("opened")
def step(self, name: str, **values) -> None:
self.record["steps"].append({"step": name, "at_s": round(self._clock() - self._opened, 1), **values})
if len(self.record["steps"]) > 100:
self.record["steps"].pop(0)
self.record["steps_omitted"] = self.record.get("steps_omitted", 0) + 1
def finish(self, result: str, **values) -> None:
self.step(result, **values)
self._sink({**self.record, "result": result, "duration_s": round(self._clock() - self._opened, 1)})
def _load_state(path: Path) -> dict:
try:
state = json.loads(path.read_text())
return state if isinstance(state, dict) else {}
except (OSError, ValueError):
return {}
def _write_atomic(path: Path, text: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(mode="w", dir=path.parent, delete=False) as temporary:
temporary_path = Path(temporary.name)
try:
temporary.write(text)
temporary.flush()
os.fsync(temporary.fileno())
os.replace(temporary_path, path)
finally:
temporary_path.unlink(missing_ok=True)
def send_reports(capture: Callable[..., bool], flush: Callable[[], None] = lambda: None, *, state_path: Path = STATE_PATH,
log_dir: Path | None = None, pairing_path: Path = PAIRING_PATH, now: float | None = None) -> int:
"""Send what is new since the last call; returns how many reports were queued.
Capture must return True only after the server accepts the event."""
now = datetime.now(UTC).timestamp() if now is None else now
state = _load_state(state_path)
last_seq, pairing_seq = int(state.get("last_seq", 0)), int(state.get("pairing_seq", 0))
queued = 0
for record in new_pairings(pairing_seq, pairing_path):
if not capture(**pairing_event(record)):
break
pairing_seq, queued = int(record["seq"]), queued + 1
sessions = new_sessions(log_dir or identity_store.LOG_DIR, last_seq, now)
if sessions and capture(**drive_event(sessions)):
last_seq, queued = sessions[-1]["seq"], queued + 1
if queued:
flush()
_write_atomic(state_path, json.dumps({"last_seq": last_seq, "pairing_seq": pairing_seq}))
return queued
class Reporter:
"""Background thread in android_autod: every minute, while parked, sends what is new."""
def __init__(self, parked: Callable[[], bool], interval: float = INTERVAL_S):
self._parked, self._interval = parked, interval
self._stop = threading.Event()
self._thread: threading.Thread | None = None
def start(self) -> Reporter:
self._thread = threading.Thread(target=self._run, name="aa_bugsink_report", daemon=True)
self._thread.start()
return self
def close(self) -> None:
self._stop.set()
def _run(self) -> None:
from openpilot.starpilot.system.android_auto.bugsink_transport import configured_sender
while not self._stop.wait(self._interval):
try:
if self._parked():
capture = configured_sender(active=lambda: not self._stop.is_set() and self._parked())
if capture is not None:
send_reports(capture)
except Exception as error:
print(json.dumps({"event": "bugsink_report_failed", "error_kind": type(error).__name__}), flush=True)
@@ -0,0 +1,63 @@
"""Readable report text and session-owned software identity."""
TRANSPORTS = {"wired": "Wired USB", "wireless": "Wireless", "headunitpad": "HeadunitPad (Wi-Fi)",
"openheadunit": "Open Headunit (Wi-Fi)", "unknown": "Unknown"}
def connection_label(value):
return TRANSPORTS.get(value, value.replace("_", " ") if value else "Unknown")
def software_tags(records):
software = [record.get("software") or {} for record in records]
result = {}
for field in ("commit", "branch"):
values = {str(item.get(field) or "unknown") for item in software}
result[field] = next(iter(values)) if len(values) == 1 else "mixed"
return result
def overview(records):
software = software_tags(records)
transports = sorted({connection_label(record.get("transport", "")) for record in records})
return [f"Connection: {', '.join(transports)}", f"Session commit: {software['commit']}", f"Branch: {software['branch']}"]
def failure_summary(assessment):
return "\n".join([f"Assessment ({assessment['confidence']}): {assessment['summary']}", f"Evidence: {assessment['evidence']}",
"Next steps:", *(f" {index}. {step}" for index, step in enumerate(assessment["next_steps"], 1))])
def session_timeline(session):
lines = [f"Outcome: {session['outcome'].capitalize()} | Failures: {session['failures']} | Drops: {session['drops']}"]
if session.get("settings"):
lines.append("Settings: " + ", ".join(f"{key}={value}" for key, value in session["settings"].items()))
attempts = session.get("attempts", [])
lines.append("Attempt timeline:")
for attempt in attempts[-5:]:
stages = " → ".join(f"{stage['stage'].replace('_', ' ')} ({stage['at_s']}s)" for stage in attempt["stages"][-6:])
result = "video acknowledged" if attempt["video_acknowledged"] else "no acknowledged video"
if attempt.get("error_kind"):
result += f"; {attempt['error_kind']} at {attempt.get('failed_at_s')}s"
if attempt["after_car_shutdown"]:
result += "; retry after car shutdown"
lines.append(f" {attempt['attempt']}. {stages or 'No stage recorded'} — {result}")
if len(attempts) > 5:
lines.append(f" Showing last 5 of {len(attempts)} attempts; see session details.")
lines.append(f"Log: {session['name']}")
return "\n".join(lines)
def batch_summary(sessions, summary):
lines = overview(sessions) + [f"Sessions: {len(sessions)} | Failures: {summary['failures']} | Drops: {summary['drops']}",
f"Outcome: {summary['outcome'].capitalize()} | Average FPS: {summary['fps_avg'] if summary['fps_avg'] is not None else 'unknown'}"]
for session in sessions:
commit = (session.get("software") or {}).get("commit") or "unknown"
lines.append(f" Session {session['seq']}: {connection_label(session['transport'])}, {session['outcome']}, commit {commit}")
return "\n".join(lines)
def pairing_summary(record):
return "\n".join(overview([record]) + [f"Pairing: {record.get('result', 'unknown')}",
f"Duration: {record.get('duration_s', 'unknown')} seconds",
"Steps: " + " → ".join(step.get("step", "unknown").replace("_", " ") for step in record.get("steps", [])[-10:])])
@@ -0,0 +1,252 @@
"""A second Wi-Fi station, aa0, for the car's projection network so wlan0 keeps home Wi-Fi.
The unprivileged daemon leases aa0 from a root worker over a pipe, like the Galaxy hotspot:
EOF, ten seconds without a heartbeat, or SIGTERM deletes the interface. The worker only ever
creates or deletes aa0; it never touches wlan0, p2p0 or galaxy0.
"""
from __future__ import annotations
import json
import os
import select
import subprocess
import sys
import threading
import time
from collections.abc import Callable
from pathlib import Path
from typing import Any
from openpilot.starpilot.system.android_auto.network import DedicatedUnavailable, JoinTimeout, NetworkError
from openpilot.starpilot.system.pipe_worker import claim, owned, spawn, stop_event
INTERFACE = "aa0"
ALIAS = "starpilot-aa-wifi"
RUNTIME = Path("/run/starpilot-aa-wifi")
HEARTBEAT_S = 2.0
LEASE_TIMEOUT_S = 10.0
READY_TIMEOUT_S = 5.0
# The car's network never appeared this many times on aa0; finish the session on wlan0 in case aa0 is the reason.
DEDICATED_TIMEOUTS = 2
class DedicatedInterface:
"""Holds aa0 while open."""
def __init__(self, log: Callable[..., None], popen=subprocess.Popen, ready_timeout: float = READY_TIMEOUT_S):
self.log, self.popen, self.ready_timeout = log, popen, ready_timeout
self.child = None
self._stop = threading.Event()
self._thread: threading.Thread | None = None
def open(self) -> str:
self.child = spawn("starpilot-aa-wifi", "openpilot.starpilot.system.android_auto.car_interface", popen=self.popen)
try:
if not select.select([self.child.stdout], [], [], self.ready_timeout)[0]:
raise DedicatedUnavailable(f"{INTERFACE} was not ready in time")
line = self.child.stdout.readline()
reply = json.loads(line) if line else {}
if not reply.get("ready"):
raise DedicatedUnavailable(reply.get("error") or f"Could not create {INTERFACE}")
except (OSError, ValueError) as error:
self.close()
raise DedicatedUnavailable(f"Could not create {INTERFACE}: {error}") from error
except BaseException:
self.close()
raise
self._thread = threading.Thread(target=self._heartbeat, args=(self.child,), name="aa_wifi_lease", daemon=True)
self._thread.start()
return INTERFACE
def _heartbeat(self, child) -> None:
while not self._stop.wait(HEARTBEAT_S):
try:
child.stdin.write(b"\n")
child.stdin.flush()
except (OSError, ValueError):
return
def close(self) -> None:
self._stop.set()
child, self.child = self.child, None
if child is None:
return
try:
child.stdin.close()
except OSError:
pass
try:
child.wait(timeout=12)
except subprocess.TimeoutExpired:
child.kill() # the worker still deletes aa0 once its lease runs out
if child.stdout is not None:
child.stdout.close()
class DedicatedLease:
"""The car's Wi-Fi on aa0, falling back to the legacy lease on wlan0 for a session that cannot use it."""
def __init__(self, log: Callable[..., None], fallback_interface: str, lease_factory, interface_factory=None,
enabled: bool = True):
self.log, self.fallback_interface = log, fallback_interface
self.lease_factory, self.interface_factory = lease_factory, interface_factory or DedicatedInterface
self.enabled = enabled
self.lease = None
self.iface = None
self.legacy = not enabled
self.timeouts = 0
self.aa0_joined = False
self.aa0_failed = False # aa0 could not join but wlan0 then did; the supervisor counts these per car
self._opening: threading.Thread | None = None
self._open_error: Exception | None = None
self._cancel = threading.Event()
@property
def local_ip(self):
return self.lease.local_ip if self.lease is not None else ""
@property
def interface(self) -> str:
return getattr(self.lease, "interface", self.fallback_interface if self.legacy else INTERFACE)
def prepare(self) -> None:
"""Create aa0 while Bluetooth connects, so the car's credentials do not wait on it."""
if self.legacy or self.lease is not None or self._opening is not None:
return
self._cancel.clear()
self._opening = threading.Thread(target=self._open, name="aa_wifi_open", daemon=True)
self._opening.start()
def _open(self) -> None:
try:
self.iface = self.interface_factory(self.log)
self.iface.open()
lease = self.lease_factory(self.log, INTERFACE, dedicated=True)
try:
lease.prepare_device(self._cancel.is_set)
except BaseException:
lease.release(restore=True)
raise
self.lease = lease
self.log("wifi_dedicated", interface=INTERFACE)
except Exception as error:
self._open_error = error
if self._cancel.is_set() and self.iface is not None:
self.iface.close() # the session already ended; nothing else will close it
def _finish_opening(self) -> None:
if self._opening is not None:
self._opening.join()
self._opening = None
error, self._open_error = self._open_error, None
if error is not None and not self._cancel.is_set():
self._fall_back(error)
def _ensure(self) -> None:
self.prepare()
self._finish_opening()
if self.lease is None:
self.legacy = True
self.lease = self.lease_factory(self.log, self.fallback_interface)
def _fall_back(self, error: Exception) -> None:
self.log("wifi_dedicated_unavailable", error=str(error) or type(error).__name__)
if self.lease is not None:
try:
self.lease.release(restore=True)
except Exception:
pass
if self.iface is not None:
self.iface.close()
self.lease = self.iface = None
self.legacy = True
def acquire(self, credentials, cancelled: Callable[[], bool] = lambda: False, **kwargs):
self._ensure()
try:
local_ip = self.lease.acquire(credentials, cancelled=cancelled, **kwargs)
except NetworkError as error:
if self.legacy or cancelled():
raise
if isinstance(error, JoinTimeout):
# The car's network never showed up, which wlan0 would not change; give aa0 another attempt first.
self.timeouts += 1
if self.timeouts >= DEDICATED_TIMEOUTS:
self._fall_back(error)
raise
# aa0 itself failed; finish this same join on wlan0 rather than report a failure to the car.
self._fall_back(error)
self._ensure()
local_ip = self.lease.acquire(credentials, cancelled=cancelled, **kwargs)
self.aa0_failed = not isinstance(error, DedicatedUnavailable)
return local_ip
if not self.legacy:
self.aa0_joined = True
self.timeouts = 0
return local_ip
def still_connected(self) -> bool:
return self.lease is not None and self.lease.still_connected()
def release(self, restore: bool = True) -> None:
if restore:
self._cancel.set()
self._finish_opening()
if self.lease is not None:
self.lease.release(restore=restore)
if restore:
if self.iface is not None:
self.iface.close()
self.lease = self.iface = None
self.legacy = not self.enabled
self.timeouts = 0
def serve(wireless: Callable[..., Any], stdin: int, stop: threading.Event, run=subprocess.run, is_owned=owned,
lease_timeout: float = LEASE_TIMEOUT_S) -> None:
"""Create aa0, report on stdout, and hold it until stdin closes, heartbeats stop or ``stop`` is set."""
created = False
try:
if is_owned(INTERFACE, ALIAS):
wireless("delete", INTERFACE) # left behind by a crashed worker
try:
if INTERFACE in wireless("interfaces"):
raise RuntimeError(f"{INTERFACE} exists and is not ours")
wireless("create", INTERFACE, iftype=2)
created = True
run(["ip", "link", "set", "dev", INTERFACE, "alias", ALIAS], check=True, capture_output=True, timeout=5)
except (OSError, RuntimeError, ValueError, KeyError, subprocess.SubprocessError) as error:
print(json.dumps({"ready": False, "error": str(error) or type(error).__name__}), flush=True)
return
print(json.dumps({"ready": True, "interface": INTERFACE}), flush=True)
last = time.monotonic()
while not stop.is_set() and time.monotonic() - last < lease_timeout:
if select.select([stdin], [], [], 0.5)[0]:
if not os.read(stdin, 64):
break
last = time.monotonic()
finally:
if created or is_owned(INTERFACE, ALIAS):
wireless("delete", INTERFACE)
def main() -> None:
from openpilot.starpilot.galaxy.hotspot_netlink import WirelessInterfaces
fd = claim(RUNTIME, "The car interface worker")
stop = stop_event()
def wireless(method: str, *args, **kwargs):
client = WirelessInterfaces()
try:
return getattr(client, method)(*args, **kwargs)
finally:
client.close()
try:
serve(wireless, sys.stdin.fileno(), stop)
finally:
os.close(fd)
if __name__ == "__main__":
main()
@@ -124,10 +124,11 @@ def summarize(events: list[dict]) -> dict:
ignored: dict[tuple, dict] = {}
projected = False # the car acknowledged a frame; focus or the "streaming" stage alone do not show that
failed_after = None
car_off = False
for record in events:
name = record["event"]
if name == "session_start":
report["transport"] = "wired" if record.get("receiver") == "usb" else "wireless"
report["transport"] = {"usb": "wired", "headunitpad": "headunitpad", "openheadunit": "openheadunit"}.get(record.get("receiver"), "wireless")
report["trigger"] = record.get("trigger", "")
if record.get("receiver") and record.get("receiver") != "usb":
report["car"].setdefault("bluetooth_name", record["receiver"])
@@ -148,6 +149,13 @@ def summarize(events: list[dict]) -> dict:
report["wifi"].update(security=record.get("security", ""), ap_type=record.get("ap_type"))
elif name == "wifi_joined":
report["wifi"]["joined"] = True
report["wifi"]["interface"] = record.get("interface", "")
if record.get("freq"):
report["wifi"]["car_freq"] = record["freq"]
if record.get("wlan0_freq"):
report["wifi"]["wlan0_freq"] = record["wlan0_freq"]
if record.get("freq") and record["freq"] != record["wlan0_freq"]:
report["wifi"]["radio"] = "MCC (2 channels)"
elif name == "version":
report["tls"]["protocol"] = f"{record.get('major')}.{record.get('minor')} (replied {record.get('reply')})"
elif name == "tls_established":
@@ -167,6 +175,7 @@ def summarize(events: list[dict]) -> dict:
elif name == "video_acknowledged":
projected = True
failed_after = None
car_off = False
elif name == "usb_gadget_prepared":
report["usb"]["started_as"] = record.get("mode", "")
elif name == "usb_no_accessory_start":
@@ -189,12 +198,15 @@ def summarize(events: list[dict]) -> dict:
error = {"stage": record.get("stage", ""), "error": record.get("error", "")}
if record.get("where"):
error["where"] = record["where"][-1] # the innermost frame: which call actually raised
if car_off:
error["after_car_shutdown"] = True
if error not in report["errors"]:
report["errors"].append(error)
if projected:
if projected and not car_off:
failed_after = error["error"]
elif name == "session_ended" and record.get("reason"):
report["ended"].append(record["reason"])
car_off = car_off or str(record["reason"]).startswith("Head unit ended projection")
elif name in ("stream_stall", "link_check_slow", "link_check_failed", "link_lost"):
entry = report["health"].setdefault(name, {"count": 0, "max_ms": 0})
entry["count"] += 1
@@ -212,8 +224,8 @@ def summarize(events: list[dict]) -> dict:
report["furthest_stage"] = max(reached, key=STAGE_ORDER.index) if reached else ""
if projected:
report["outcome"] = f"projected, then failed: {failed_after}" if failed_after else "projected"
elif report["errors"]:
report["outcome"] = f"failed: {report['errors'][-1]['error']}"
elif errors := [error for error in report["errors"] if not error.get("after_car_shutdown")]:
report["outcome"] = f"failed: {errors[-1]['error']}"
elif events:
report["outcome"] = f"stopped at {report['furthest_stage'] or 'start'}"
return report
@@ -229,7 +241,8 @@ def render_text(report: dict, name: str = "") -> str:
lines.append("Car: " + ", ".join(f"{key.replace('_', ' ')}: {value}" for key, value in car.items()))
lines.append("Stages: " + (" > ".join(report["stages"]) or "none"))
for error in report["errors"][-5:]:
lines.append(f"Error ({error['stage']}): {error['error']}" + (f" [at {error['where']}]" if error.get("where") else ""))
lines.append(f"Error ({error['stage']}{', after car shutdown' if error.get('after_car_shutdown') else ''}): {error['error']}" +
(f" [at {error['where']}]" if error.get("where") else ""))
for reason in report["ended"][-3:]:
lines.append(f"Ended by car: {reason}")
for title, section in (("Wi-Fi", report["wifi"]), ("Bluetooth", report["bluetooth"]), ("TLS", report["tls"]),
@@ -164,7 +164,7 @@ def run(frames_path: str, touch_path: str | None = None, control_path: str | Non
from openpilot.starpilot.system.android_auto.headless_egl import FrameReadback, HeadlessContext
from openpilot.starpilot.system.android_auto import gpu_nv12
from openpilot.starpilot.system.android_auto.projection_control import DEFAULT_CONTROL_SOCKET, NATIVE_FOCUS, ProjectionControlSender
from openpilot.starpilot.system.android_auto.projection_bookmark import BookmarkSender
from openpilot.starpilot.system.android_auto.projection_bookmark import BookmarkSender, request_bookmark
from openpilot.starpilot.system.android_auto.projection_onroad import ProjectionOnroad
from openpilot.starpilot.system.android_auto.touch import TouchReceiver, DEFAULT_TOUCH_SOCKET
import pyray as rl
@@ -205,13 +205,10 @@ def run(frames_path: str, touch_path: str | None = None, control_path: str | Non
bookmark_sender = BookmarkSender()
resources.callback(bookmark_sender.close)
def bookmark():
if (not ui_state.started or layout.native.current_message(ui_state.sm, 'carState', time.monotonic_ns(),
after_frame=ui_state.started_frame) is None):
return False
return bookmark_sender.send(int(ui_state.sm.logMonoTime['carState']))
layout.bookmark = bookmark
layout.bookmark = lambda: request_bookmark(bookmark_sender, ui_state, layout.native.display_message, time.monotonic_ns())
# Confirm only what was really saved: the bookmark the native UI publishes, whichever button asked for it.
from openpilot.cereal import messaging
saved_bookmarks = messaging.sub_sock('userBookmark', conflate=True)
from openpilot.starpilot.system.android_auto.projection_slc_action import SlcActionSender
slc_sender = SlcActionSender()
resources.callback(slc_sender.close)
@@ -352,6 +349,8 @@ def run(frames_path: str, touch_path: str | None = None, control_path: str | Non
frame_began = time.monotonic()
context.begin_frame(now)
ui_state.update()
if messaging.recv_one_or_none(saved_bookmarks) is not None and ui_state.started:
layout.bookmark_saved()
if sampler is not None:
sampler.onroad = ui_state.started
layout.prepare() # offscreen passes (the map) must not run inside the content target
@@ -29,7 +29,8 @@ from openpilot.starpilot.system.android_auto.supervisor import Supervisor
PAIRING_COMMANDS = {"prepare_pairing", "pairing_status", "pairing_response", "cancel_pairing", "pair_device"}
AUTHENTICATED_COMMANDS = {"start", "set_auto_connect", "select_receiver", "set_view", "set_connection",
"prepare_pairing", "pairing_status", "pairing_response", "cancel_pairing", "pair_device", "devices", "bluetooth_action"}
"prepare_pairing", "pairing_status", "pairing_response", "cancel_pairing", "pair_device", "devices", "bluetooth_action",
"phone_window"}
def _offroad() -> bool:
@@ -58,7 +59,7 @@ def handle(supervisor: Supervisor, request: dict[str, Any], verifier: GalaxySour
source = request.get('source')
if not isinstance(source, str) or verifier is None or not verifier.valid(source):
raise RuntimeError('Authenticated Galaxy Android Auto source required')
if command != 'bluetooth_action':
if command not in ('bluetooth_action', 'phone_window'):
supervisor.bind_source_session(('galaxy', source))
if command in PAIRING_COMMANDS and not (parked or _offroad)():
raise RuntimeError("Disengage and shift into Park before pairing")
@@ -75,6 +76,22 @@ def handle(supervisor: Supervisor, request: dict[str, Any], verifier: GalaxySour
except (OSError, RuntimeError):
return {'handled': True, 'error_code': 'service_unavailable'}
return {'handled': result is not None, **({'bluetooth': result} if result is not None else {})}
if command == 'phone_window':
from openpilot.starpilot.bluetooth.owner import BluetoothRejected, BluetoothUnavailable
operation = request.get('operation')
fields = {'command', 'source', 'operation'} | ({'prompt_id', 'accepted', 'value'} if operation == 'pairing_response' else set())
if set(request) != fields or operation not in ('accept_pair', 'pairing_response', 'cancel_pair', 'status'):
raise ValueError('Invalid phone pairing request')
if operation != 'status' and not (parked or _offroad)():
return {'handled': True, 'error_code': 'park_required'}
answer = {key: request[key] for key in ('prompt_id', 'accepted', 'value') if key in request}
try:
result = supervisor.phone_window(operation, ('galaxy', request['source']), **answer)
except (BluetoothRejected, BluetoothUnavailable) as error:
return {'handled': True, 'error_code': error.code}
except (OSError, RuntimeError):
return {'handled': True, 'error_code': 'service_unavailable'}
return {'handled': result is not None, **({'bluetooth': result} if result is not None else {})}
if command == "status":
return {"status": supervisor.status()}
if command == "start":
@@ -83,6 +100,8 @@ def handle(supervisor: Supervisor, request: dict[str, Any], verifier: GalaxySour
supervisor.user_stop()
elif command == "set_auto_connect":
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 == "select_receiver":
supervisor.select_receiver(str(request.get("address", "")), str(request.get("name", "")))
elif command == "forget_receiver":
@@ -213,6 +232,8 @@ def main() -> int:
pass
threading.Thread(target=housekeeping, daemon=True).start()
from openpilot.starpilot.system.android_auto.bugsink_report import Reporter
cleanup.callback(Reporter(parked=lambda: _offroad() and not supervisor.status()["running"]).start().close)
server = Server(ANDROID_AUTO_SOCKET_PATH, supervisor, source_verifier, authority.configuration_allowed)
cleanup.callback(server.server_close)
@@ -0,0 +1,333 @@
"""Music from Spotify or a Bluetooth phone over the head unit's media audio channel, as a phone sends it.
The channel is opened only when music is set to play through Android Auto. Every step is driven
by messages the session loop already reads, so nothing here waits on the car and the video path
keeps its timing: open → setup (PCM) → audio focus → start → timestamped PCM with ACK credit.
PCM comes from the Spotify supervisor's real-time pipe (48 kHz, 16-bit, stereo), which runs on the
system clock and carries silence while paused. Focus is requested when sound starts and released
after a stretch of silence, the way a phone treats a paused player. If the car takes focus for good
(the driver picks the radio), the selected source is paused instead of fighting for the speakers.
"""
from __future__ import annotations
import array
import errno
import os
import stat
import struct
import threading
import time
from collections.abc import Callable
from pathlib import Path
from typing import Any
from openpilot.starpilot.system.android_auto.wire import field, one, signed
MSG_CHANNEL_OPEN_REQUEST = 0x07
MSG_CHANNEL_OPEN_RESPONSE = 0x08
AUDIO_FOCUS_REQUEST = 0x12
AUDIO_FOCUS_NOTIFICATION = 0x13
AV_MEDIA_WITH_TIMESTAMP = 0x0000
AV_SETUP_REQUEST = 0x8000
AV_START_INDICATION = 0x8001
AV_STOP_INDICATION = 0x8002
AV_SETUP_RESPONSE = 0x8003
AV_MEDIA_ACK = 0x8004
CODEC_PCM = 1
AUDIO_TYPE_MEDIA = 3
SETUP_STATUS_READY = 2
FOCUS_GAIN, FOCUS_RELEASE = 1, 4
STATE_GAIN, STATE_GAIN_TRANSIENT, STATE_LOSS, STATE_LOSS_DUCK, STATE_LOSS_TRANSIENT, STATE_GAIN_MEDIA_ONLY = 1, 2, 3, 4, 5, 6
GAINED = (STATE_GAIN, STATE_GAIN_TRANSIENT, STATE_GAIN_MEDIA_ONLY)
RATE, CHANNELS, BITS = 48000, 2, 16
FRAME_BYTES = CHANNELS * BITS // 8
BYTES_PER_SECOND = RATE * FRAME_BYTES
MIN_CHUNK = BYTES_PER_SECOND // 50 # 20 ms
MAX_CHUNK = 32768 # ~170 ms, so a one-packet ACK window still keeps up
BUFFER_PLAYING = BYTES_PER_SECOND # cap while streaming; older audio is dropped, never delayed further
BUFFER_IDLE = BYTES_PER_SECOND // 2 # pre-roll kept while asking for focus
MAX_WINDOW = 16
SILENCE_LEVEL = 16 # peak sample magnitude treated as silence
RELEASE_AFTER = 8.0 # seconds of silence before giving the speakers back
STEP_TIMEOUT = 3.0
ACK_STALL = 3.0
RETRY_AFTER = 10.0
def media_channel(channels: list[dict]) -> tuple[int, int] | None:
"""(channel id, configuration index) of the head unit's PCM media channel at 48 kHz/16-bit/stereo."""
for channel in channels:
if channel.get('media_type') != CODEC_PCM or channel.get('audio_type') != AUDIO_TYPE_MEDIA:
continue
for index, config in enumerate(channel.get('audio_configs', [])):
if (one(config, 1), one(config, 2), one(config, 3)) == (RATE, BITS, CHANNELS):
return int(channel['id']), index
return None
class PipeSource:
"""Non-blocking reader of the Spotify pipe; reopens when the audio server restarts."""
def __init__(self, path: Path, routed: Callable[[], bool]):
self.path, self.routed = path, routed
self.fd: int | None = None
self.checked = 0.0
self.active_value = False
self.generation = None
def active(self, now: float) -> bool:
if now - self.checked >= 1.0:
self.checked = now
try:
info = self.path.stat()
identity = (info.st_dev, info.st_ino)
if identity != self.generation:
self.close()
self.generation = identity
self.active_value = self.routed() and stat.S_ISFIFO(info.st_mode)
except OSError:
self.active_value = False
if not self.active_value:
self.close()
return self.active_value
def read(self, limit: int) -> bytes:
if self.fd is None:
try:
self.fd = os.open(self.path, os.O_RDONLY | os.O_NONBLOCK)
except OSError:
return b''
chunks, total = [], 0
while total < limit:
try:
data = os.read(self.fd, min(65536, limit - total))
except OSError as error:
if error.errno not in (errno.EAGAIN, errno.EWOULDBLOCK):
self.close()
break
if not data: # writer gone: the audio server stopped
self.close()
break
chunks.append(data)
total += len(data)
return b''.join(chunks)
def close(self) -> None:
if self.fd is not None:
os.close(self.fd)
self.fd = None
def music_routed() -> bool:
from openpilot.starpilot.soloist import paths, preferences
return paths.enabled() and preferences.load().output == 'android_auto'
def music_control(action: str) -> None:
def worker():
try:
from openpilot.starpilot.galaxy.spotify_setup import SpotifyClient
SpotifyClient().call('control', action=action, args={})
except Exception:
pass
threading.Thread(target=worker, name=f'aa_music_{action}', daemon=True).start()
def pause_music() -> None:
music_control('pause')
class MediaAudio:
def __init__(self, session, channel: int, config_index: int, source, log: Callable[..., None],
pause: Callable[[], None] = pause_music, clock: Callable[[], float] = time.monotonic):
self.session, self.channel, self.config_index = session, channel, config_index
self.source, self.log, self.pause, self.clock = source, log, pause, clock
self.state = 'closed' # closed, opening, setup, ready, focus_wait, playing, suspended, unavailable
self.deadline = 0.0
self.retry_at = 0.0
self.window = 1
self.unacked = 0
self.pending: list[float] = []
self.session_id = 0
self.buffer = bytearray()
self.frames_sent = 0
self.base_us = 0
self.last_sound = -1e9
self.car_paused = False
self.source_generation = getattr(source, 'generation', None)
self.stats: dict[str, Any] = {'chunks_sent': 0, 'acked': 0, 'dropped_ms': 0, 'focus': None}
self.track_info = None # MediaStatus for the car's own displays, when the head unit offers it
@classmethod
def create(cls, session, log, *, source=None, routed: Callable[[], bool] = music_routed, **kwargs):
found = media_channel(session.channels)
if found is None:
return None
if source is None:
from openpilot.starpilot.soloist.backend import android_auto_pipe
source = PipeSource(android_auto_pipe(), routed)
return cls(session, *found, source, log, **kwargs)
def _event(self, name: str, **values) -> None:
self.log(f'media_audio_{name}', channel=self.channel, **values)
def _set(self, state: str, timeout: float | None = None) -> None:
if state != self.state:
self._event('state', state=state, previous=self.state)
self.state = state
self.deadline = self.clock() + timeout if timeout else 0.0
# ------------------------------------------------------------------ loop
def service(self) -> None:
if self.track_info is not None:
self.track_info.service()
now = self.clock()
active = self.source.active(now)
if not active:
self.buffer.clear()
if self.state in ('playing', 'focus_wait'):
self._give_back('music_not_routed')
return
generation = getattr(self.source, 'generation', None)
if generation != self.source_generation:
self.source_generation = generation
self.buffer.clear()
self.last_sound = -1e9
self.car_paused = False
if self.state in ('playing', 'focus_wait', 'suspended'):
self._give_back('source_changed')
playing = self.state == 'playing'
data = self.source.read(BUFFER_PLAYING)
if data:
self._take(data, now, BUFFER_PLAYING if playing else BUFFER_IDLE)
sounding = now - self.last_sound < 0.5
if self.deadline and now > self.deadline:
self._timeout(now)
if self.state == 'closed' and now >= self.retry_at:
self.session.send(self.channel, MSG_CHANNEL_OPEN_REQUEST, field(1, 0) + field(2, self.channel), control=True)
self._set('opening', STEP_TIMEOUT)
elif self.state == 'ready' and sounding and not self.car_paused and now >= self.retry_at:
self.session.send(0, AUDIO_FOCUS_REQUEST, field(1, FOCUS_GAIN))
self._set('focus_wait', STEP_TIMEOUT)
elif self.state == 'playing':
if now - self.last_sound > RELEASE_AFTER:
self._give_back('silence')
else:
self._send(now)
def _take(self, data: bytes, now: float, cap: int) -> None:
usable = len(data) - len(data) % 2
samples = array.array('h', data[:usable])
if samples and (max(samples) > SILENCE_LEVEL or min(samples) < -SILENCE_LEVEL):
self.last_sound = now
elif now - self.last_sound > 2.0:
self.car_paused = False # the player went quiet; a new press of play may ask for focus again
self.buffer.extend(data)
excess = len(self.buffer) - cap
if excess > 0:
excess += (-excess) % FRAME_BYTES
del self.buffer[:excess]
if self.state == 'playing':
self.stats['dropped_ms'] += excess * 1000 // BYTES_PER_SECOND
def _send(self, now: float) -> None:
if self.pending and now - self.pending[0] > ACK_STALL:
self._event('ack_stall', pending=self.unacked)
self._give_back('ack_stall')
return
sent = 0
while self.unacked < self.window and len(self.buffer) >= MIN_CHUNK and sent < 2:
size = min(len(self.buffer), MAX_CHUNK)
size -= size % FRAME_BYTES
chunk = bytes(self.buffer[:size])
del self.buffer[:size]
timestamp = self.base_us + self.frames_sent * 1_000_000 // RATE
self.session.send(self.channel, AV_MEDIA_WITH_TIMESTAMP, struct.pack('>Q', timestamp) + chunk)
self.frames_sent += size // FRAME_BYTES
self.unacked += 1
self.pending.append(now)
self.stats['chunks_sent'] += 1
sent += 1
def _timeout(self, now: float) -> None:
state = self.state
self.deadline = 0.0
self._event('timeout', state=state)
if state in ('opening', 'setup'):
self._set('unavailable')
elif state == 'focus_wait':
self.retry_at = now + RETRY_AFTER
self._set('ready')
def _give_back(self, reason: str) -> None:
if self.state == 'playing':
self.session.send(self.channel, AV_STOP_INDICATION)
if self.state in ('playing', 'focus_wait', 'suspended'):
self.session.send(0, AUDIO_FOCUS_REQUEST, field(1, FOCUS_RELEASE))
self.unacked, self.pending = 0, []
self._event('released', reason=reason)
self._set('ready')
# -------------------------------------------------------------- messages
def handle(self, channel: int, kind: int, fields: dict) -> bool:
if self.track_info is not None and self.track_info.handle(channel, kind, fields):
return True
if channel == 0 and kind == AUDIO_FOCUS_NOTIFICATION:
self._focus(one(fields, 1), one(fields, 2, 0))
return True
if channel != self.channel:
return False
if kind == MSG_CHANNEL_OPEN_RESPONSE and self.state == 'opening':
if signed(one(fields, 1)) != 0:
self._event('open_rejected', status=signed(one(fields, 1)))
self._set('unavailable')
else:
self.session.send(self.channel, AV_SETUP_REQUEST, field(1, CODEC_PCM))
self._set('setup', STEP_TIMEOUT)
elif kind == AV_SETUP_RESPONSE and self.state == 'setup':
window, configs = int(one(fields, 2, 0) or 0), fields.get(3, [])
if one(fields, 1) != SETUP_STATUS_READY or window < 1 or configs and self.config_index not in configs:
self._event('setup_rejected', status=one(fields, 1), window=window)
self._set('unavailable')
else:
self.window = min(window, MAX_WINDOW)
self._event('setup', window=window, used_window=self.window)
self._set('ready')
elif kind == AV_MEDIA_ACK:
count = int(one(fields, 2, 0) or 0)
if one(fields, 1) == self.session_id and 0 < count <= self.unacked:
self.unacked -= count
del self.pending[:count]
self.stats['acked'] += count
return True
def _focus(self, focus, unsolicited) -> None:
self.stats['focus'] = focus
self._event('focus', focus=focus, unsolicited=unsolicited)
if focus in GAINED and self.state in ('focus_wait', 'suspended'):
self.session_id += 1
self.unacked, self.pending, self.frames_sent = 0, [], 0
self.base_us = time.monotonic_ns() // 1000
self.session.send(self.channel, AV_START_INDICATION, field(1, self.session_id) + field(2, self.config_index))
self._set('playing')
elif focus == STATE_LOSS_TRANSIENT and self.state == 'playing':
self.session.send(self.channel, AV_STOP_INDICATION)
self._set('suspended') # a call or the car's own prompt; the car gives focus back afterwards
elif focus == STATE_LOSS and self.state in ('playing', 'focus_wait', 'suspended'):
if self.state == 'playing':
self.session.send(self.channel, AV_STOP_INDICATION)
self.car_paused = True
self.unacked, self.pending = 0, []
self._set('ready')
self.pause()
def status(self) -> dict:
return {'state': self.state, 'window': self.window, 'pending': self.unacked, **self.stats}
def close(self) -> None:
self.source.close()
if self.track_info is not None:
self.track_info.close()
@@ -0,0 +1,117 @@
"""Song, artist and progress for the car's own displays (such as the instrument cluster), as a phone sends them.
Only head units that advertise the media playback status service receive it, and only while music
plays through Android Auto. The snapshot is polled on its own thread; the session loop just compares
and sends, once a second at most.
Wire facts: mrmees/open-android-auto ``oaa/media`` and ``oaa/control/ChannelDescriptorData`` (reference only).
"""
from __future__ import annotations
import time
from collections.abc import Callable
from openpilot.starpilot.system.android_auto.wire import field, signed, one
MSG_CHANNEL_OPEN_REQUEST = 0x07
MSG_CHANNEL_OPEN_RESPONSE = 0x08
PLAYBACK_STATUS = 0x8001
PLAYBACK_METADATA = 0x8003
MEDIA_INFO_SERVICE = 9 # ChannelDescriptor.media_info_channel
STATE_STOPPED, STATE_PLAYING, STATE_PAUSED = 1, 2, 3
SOURCE_NAMES = {'spotify': 'Spotify', 'phone': 'Phone'}
STEP_TIMEOUT = 3.0
UPDATE_EVERY = 1.0
def media_info_channel(channels: list[dict]) -> int | None:
return next((int(channel['id']) for channel in channels if MEDIA_INFO_SERVICE in channel.get('services', [])), None)
def status_message(track: dict | None, now_ms: float | None = None) -> bytes:
if track is None:
return field(1, STATE_STOPPED) + field(2, '') + field(3, 0) + field(4, 0) + field(5, 0) + field(6, 0)
from openpilot.starpilot.soloist.now_playing import elapsed_ms
state = STATE_PLAYING if track['status'] == 'playing' else STATE_PAUSED
return (field(1, state) + field(2, SOURCE_NAMES.get(track.get('source'), 'StarPilot')) +
field(3, elapsed_ms(track, now_ms) // 1000) + field(4, 0) + field(5, 0) + field(6, 0))
def metadata_message(track: dict) -> bytes:
return (field(1, track['title']) + field(2, track.get('artist') or '') + field(3, track.get('album') or '') +
field(6, (track.get('durationMs') or 0) // 1000) + field(7, 0))
def routed_snapshot() -> dict | None:
from openpilot.starpilot.soloist import now_playing
from openpilot.starpilot.soloist.paths import Layout
from openpilot.starpilot.system.android_auto.media_audio import music_routed
return now_playing.read(Layout.default().status) if music_routed() else None
class MediaStatus:
def __init__(self, session, channel: int, feed, log: Callable[..., None], clock: Callable[[], float] = time.monotonic):
self.session, self.channel, self.feed, self.log, self.clock = session, channel, feed, log, clock
self.state = 'closed' # closed, opening, open, unavailable
self.deadline = 0.0
self.next_update = 0.0
self.shown: tuple | None = None
self.last_status: str | None = None
self.stopped = True
@classmethod
def create(cls, session, log, *, feed=None, **kwargs):
channel = media_info_channel(session.channels)
if channel is None:
return None
if feed is None:
from openpilot.starpilot.system.android_auto.projection_now_playing import ProjectionNowPlayingFeed
feed = ProjectionNowPlayingFeed(read=routed_snapshot)
feed.set_enabled(True)
return cls(session, channel, feed, log, **kwargs)
def _set(self, state: str) -> None:
if state != self.state:
self.log('media_status_state', channel=self.channel, state=state, previous=self.state)
self.state = state
def service(self) -> None:
now = self.clock()
if self.state == 'closed':
self.session.send(self.channel, MSG_CHANNEL_OPEN_REQUEST, field(1, 0) + field(2, self.channel), control=True)
self.deadline = now + STEP_TIMEOUT
self._set('opening')
elif self.state == 'opening' and now > self.deadline:
self._set('unavailable')
elif self.state == 'open' and now >= self.next_update:
self.next_update = now + UPDATE_EVERY
self._update(self.feed.snapshot())
def _update(self, track: dict | None) -> None:
if track is None:
if not self.stopped:
self.session.send(self.channel, PLAYBACK_STATUS, status_message(None))
self.stopped, self.shown, self.last_status = True, None, None
return
shown = (track.get('source'), track['title'], track.get('artist'), track.get('album'), track.get('durationMs'))
if shown != self.shown:
self.session.send(self.channel, PLAYBACK_METADATA, metadata_message(track))
self.shown = shown
if track['status'] == 'playing' or track['status'] != self.last_status:
self.session.send(self.channel, PLAYBACK_STATUS, status_message(track))
self.last_status, self.stopped = track['status'], False
def handle(self, channel: int, kind: int, fields: dict) -> bool:
if channel != self.channel:
return False
if kind == MSG_CHANNEL_OPEN_RESPONSE and self.state == 'opening':
if signed(one(fields, 1)) != 0:
self.log('media_status_rejected', channel=self.channel, status=signed(one(fields, 1)))
self._set('unavailable')
else:
self._set('open')
return True # the car's own media button events (0x8002) are not used
def close(self) -> None:
self.feed.close()
@@ -0,0 +1,356 @@
"""Mapbox turn-by-turn for the car's own displays (instrument cluster, HUD), as a phone sends it.
Only head units that advertise the navigation status service receive it, and only while a route is
guiding. The route is polled on its own thread; the session loop just compares and sends.
Wire facts: mrmees/open-android-auto ``oaa/navigation`` and ``oaa/control/ChannelDescriptorData`` (reference only).
"""
from __future__ import annotations
import threading
import time
from collections.abc import Callable
from functools import lru_cache
from openpilot.starpilot.system.android_auto.wire import field, one, parse_fields, signed
MSG_CHANNEL_OPEN_REQUEST = 0x07
MSG_CHANNEL_OPEN_RESPONSE = 0x08
NAV_FOCUS_REQUEST = 0x0d
NAV_FOCUS_NOTIFICATION = 0x0e
FOCUS_NATIVE, FOCUS_PROJECTED = 1, 2
CLUSTER_START = 0x8001
CLUSTER_STOP = 0x8002
NAV_STATUS = 0x8003
NAV_TURN = 0x8004
NAV_DISTANCE = 0x8005
NAV_NOTIFICATION = 0x8006
NAV_CURRENT_POSITION = 0x8007
NAVIGATION_SERVICE = 8 # ChannelDescriptor.navigation_channel
TYPE_ENUM, TYPE_IMAGE = 1, 2
STATUS_ACTIVE, STATUS_INACTIVE, STATUS_REROUTING = 1, 2, 3
SIDE_LEFT, SIDE_RIGHT, SIDE_UNSPECIFIED = 1, 2, 0
(EVENT_DEPART, EVENT_NAME_CHANGE, EVENT_SLIGHT_TURN, EVENT_TURN, EVENT_SHARP_TURN, EVENT_U_TURN, EVENT_ON_RAMP,
EVENT_OFF_RAMP, EVENT_FORK, EVENT_MERGE, EVENT_ROUNDABOUT_ENTER, EVENT_ROUNDABOUT_EXIT,
EVENT_ROUNDABOUT_ENTER_AND_EXIT, EVENT_STRAIGHT) = range(1, 15)
EVENT_DESTINATION = 19
UNIT_METERS, UNIT_KILOMETERS, UNIT_KILOMETERS_P1, UNIT_MILES, UNIT_MILES_P1, UNIT_FEET = 1, 2, 3, 4, 5, 6
STEP_TIMEOUT = 3.0
START_GRACE = 2.0 # some head units never send the start request
CHECK_EVERY = 0.25
MIN_INTERVAL = 1.0
STALE_AFTER = 3.0
FALLBACK_SPEED = 13.4 # m/s, for the time to the turn when the route gives no pace
def navigation_channel(channels: list[dict]) -> dict | None:
return next((channel for channel in channels if NAVIGATION_SERVICE in channel.get('services', [])), None)
def parse_navigation(raw: bytes) -> dict:
"""NavigationChannel: minimum interval, whether the car wants an image or an enum, and the image size."""
fields = parse_fields(raw)
options = parse_fields(one(fields, 3)) if isinstance(one(fields, 3), bytes) else {}
return {'min_interval_ms': int(one(fields, 1, 0) or 0), 'type': one(fields, 2), 'image_width': one(options, 2),
'image_height': one(options, 1), 'image_depth': one(options, 3)}
def _normal(value: str) -> str:
return (value or '').replace(' ', '').replace('_', '').lower()
def maneuver_event(maneuver_type: str, modifier: str) -> tuple[int, int]:
"""Mapbox maneuver -> (event, turn side)."""
kind, mod = _normal(maneuver_type), _normal(modifier)
side = SIDE_LEFT if 'left' in mod else SIDE_RIGHT if 'right' in mod else SIDE_UNSPECIFIED
if kind in ('roundabout', 'rotary', 'roundaboutturn'):
return EVENT_ROUNDABOUT_ENTER_AND_EXIT, side
if kind in ('exitroundabout', 'exitrotary'):
return EVENT_ROUNDABOUT_EXIT, side
fixed = {'depart': EVENT_DEPART, 'arrive': EVENT_DESTINATION, 'merge': EVENT_MERGE, 'onramp': EVENT_ON_RAMP,
'offramp': EVENT_OFF_RAMP, 'fork': EVENT_FORK}
if kind in fixed:
return fixed[kind], side
if mod == 'uturn':
return EVENT_U_TURN, side
if 'slight' in mod:
return EVENT_SLIGHT_TURN, side
if 'sharp' in mod:
return EVENT_SHARP_TURN, side
if 'left' in mod or 'right' in mod:
return EVENT_TURN, side
return (EVENT_NAME_CHANGE if kind == 'newname' else EVENT_STRAIGHT), SIDE_UNSPECIFIED
def display_distance(meters: float, metric: bool) -> tuple[int, int]:
"""(value x 1000, unit), rounded the way the comma's own navigation card shows it."""
if metric:
if meters < 1000:
step = 25 if meters < 500 else 50
return round(meters / step) * step * 1000, UNIT_METERS
kilometers = meters / 1000
return (round(kilometers) * 1000, UNIT_KILOMETERS) if kilometers >= 10 else (round(kilometers * 10) * 100, UNIT_KILOMETERS_P1)
feet = meters * 3.28084
if feet < 1000:
step = 10 if feet <= 100 else 50
return round(feet / step) * step * 1000, UNIT_FEET
miles = feet / 5280
return (round(miles) * 1000, UNIT_MILES) if miles >= 10 else (round(miles * 10) * 100, UNIT_MILES_P1)
@lru_cache(maxsize=64)
def turn_image(maneuver_type: str, modifier: str, width: int, height: int) -> bytes | None:
"""The comma's own turn arrow as a PNG of the size the car asks for."""
try:
import io
from PIL import Image
from openpilot.starpilot.ui.navigation_icons import ASSETS, icon_name
with Image.open(ASSETS / icon_name(maneuver_type, modifier)) as icon:
icon = icon.convert('RGBA')
icon.thumbnail((width, height))
canvas = Image.new('RGBA', (width, height), (0, 0, 0, 0))
canvas.paste(icon, ((width - icon.width) // 2, (height - icon.height) // 2), icon)
out = io.BytesIO()
canvas.save(out, 'PNG')
return out.getvalue()
except Exception:
return None
def status_message(status: int) -> bytes:
return field(1, status)
def turn_message(road: str, event: int, side: int, image: bytes | None = None) -> bytes:
return (field(1, road[:128]) + field(2, event) + field(3, side) + (field(4, image) if image else b''))
def distance_message(meters: float, seconds: float, metric: bool) -> bytes:
value, unit = display_distance(meters, metric)
return field(1, max(0, round(meters))) + field(2, max(0, round(seconds))) + field(3, value) + field(4, unit)
def modern_maneuver(maneuver_type: str, modifier: str) -> int:
"""Android Maneuver.TYPE_* wire values, which include direction in the enum."""
kind, mod = _normal(maneuver_type), _normal(modifier)
right = 'right' in mod
directional = right or 'left' in mod
if kind == 'depart':
return 1
if kind == 'arrive':
return (42 if right else 41) if directional else 39
if kind == 'merge':
return (28 if right else 27) if directional else 29
if kind == 'fork':
return (26 if right else 25) if directional else 36
if kind in ('roundabout', 'rotary', 'roundaboutturn', 'exitroundabout', 'exitrotary'):
# Mapbox's modifier describes the exit direction, not traffic circulation.
# Do not invent a clockwise/counterclockwise arrow without that information.
return 0
if kind == 'onramp' and directional:
return (13 if 'slight' in mod else 17 if 'sharp' in mod else 15) + int(right)
if kind == 'offramp' and directional:
return (21 if 'slight' in mod else 23) + int(right)
if mod == 'uturn':
return 0 # Mapbox does not specify the U-turn side.
if directional:
return (5 if 'slight' in mod else 9 if 'sharp' in mod else 7) + int(right)
return 2 if kind == 'newname' else 36
def notification_message(road: str, maneuver: int) -> bytes:
# NavigationNotification.steps[].{maneuver.type, instruction.text}
return field(1, field(1, field(1, maneuver)) + field(2, field(1, road[:128])))
def current_position_message(meters: float, seconds: float, metric: bool) -> bytes:
value, unit = display_distance(meters, metric)
text = f'{value / 1000:.1f}' if unit in (UNIT_KILOMETERS_P1, UNIT_MILES_P1) else str(value // 1000)
distance = field(1, max(0, round(meters))) + field(2, text) + field(3, unit)
# NavigationNextTurnDistanceEvent.step_distance.{distance, time_to_step_seconds}
return field(1, field(1, distance) + field(2, max(0, round(seconds))))
class NavFeed:
"""Guidance from the Mapbox route owner, read off the Android Auto loop."""
def __init__(self, read: Callable[[], dict | None] | None = None):
self.read = read or self._read_cereal
self._lock = threading.Lock()
self._stop = threading.Event()
self._value: dict | None = None
self._sm = None
self._thread = threading.Thread(target=self._run, name='aa-nav-status', daemon=True)
self._thread.start()
def _read_cereal(self) -> dict | None:
from openpilot.starpilot.navigation.wire import navigation_state
if self._sm is None:
from openpilot.cereal import messaging
self._sm = messaging.SubMaster(['starpilotNavigation'])
self._sm.update(0)
stamp = self._sm.logMonoTime['starpilotNavigation']
if not self._sm.valid['starpilotNavigation'] or not 0 < stamp <= time.monotonic_ns() <= stamp + int(STALE_AFTER * 1e9):
return None
state = navigation_state(self._sm['starpilotNavigation'])
if state is None or state.status not in ('guiding', 'routing'):
return None
from openpilot.common.params import Params
snapshot = {'state': state.status, 'metric': Params().get_bool('IsMetric')}
if state.status == 'guiding':
instruction = state.instruction
snapshot.update(road=instruction.text, type=instruction.maneuverType, modifier=instruction.maneuverModifier,
distance=float(instruction.distanceMeters), remaining=float(instruction.remainingDistanceMeters),
duration=float(instruction.remainingDurationSeconds))
return snapshot
def _run(self) -> None:
while not self._stop.is_set():
try:
value = self.read()
except Exception:
value = None
with self._lock:
self._value = value
self._stop.wait(0.25)
def snapshot(self) -> dict | None:
with self._lock:
return self._value
def close(self) -> None:
self._stop.set()
self._thread.join(timeout=2)
class NavStatus:
def __init__(self, session, channel: dict, feed, log: Callable[..., None], clock: Callable[[], float] = time.monotonic):
self.session, self.channel_id, self.feed, self.log, self.clock = session, int(channel['id']), feed, log, clock
self.options = channel.get('navigation') or {}
self.image_size = None
if self.options.get('type') == TYPE_IMAGE and self.options.get('image_width') and self.options.get('image_height'):
self.image_size = (int(self.options['image_width']), int(self.options['image_height']))
self.interval = max(MIN_INTERVAL, (self.options.get('min_interval_ms') or 0) / 1000)
self.state = 'closed' # closed, opening, open, unavailable
self.deadline = 0.0
self.started = False
self.heard = False # the car has said start or stop at least once
self.next_check = 0.0
self.next_distance = 0.0
self.focus_requested = False
self.focus = None
self._reset()
def _reset(self) -> None:
self.sent_status: int | None = None
self.shown_turn: tuple | None = None
self.shown_distance: tuple | None = None
@classmethod
def create(cls, session, log, *, feed=None, **kwargs):
channel = navigation_channel(session.channels)
if channel is None:
return None
return cls(session, channel, feed if feed is not None else NavFeed(), log, **kwargs)
def _set(self, state: str) -> None:
if state != self.state:
self.log('nav_status_state', channel=self.channel_id, state=state, previous=self.state)
self.state = state
def service(self) -> None:
now = self.clock()
if self.state == 'closed':
self.session.send(self.channel_id, MSG_CHANNEL_OPEN_REQUEST, field(1, 0) + field(2, self.channel_id), control=True)
self.deadline = now + STEP_TIMEOUT
self._set('opening')
elif self.state == 'opening' and now > self.deadline:
self._set('unavailable')
elif self.state == 'open' and now >= self.next_check:
self.next_check = now + CHECK_EVERY
snap = self.feed.snapshot()
active = snap is not None
if active and not self.focus_requested:
self.session.send(0, NAV_FOCUS_REQUEST, field(1, FOCUS_PROJECTED))
self.focus_requested = True
self.focus = None
self.log('nav_focus_requested', focus=FOCUS_PROJECTED)
if not self.started and not self.heard and now >= self.deadline:
self.started = True
self.log('nav_status_started', requested=False)
if self.started and self.focus != FOCUS_NATIVE:
self._update(snap, now)
if not active and self.focus_requested:
self.session.send(0, NAV_FOCUS_REQUEST, field(1, FOCUS_NATIVE))
self.focus_requested = False
self.log('nav_focus_requested', focus=FOCUS_NATIVE)
def _send(self, kind: int, body: bytes) -> None:
self.session.send(self.channel_id, kind, body)
def _update(self, snap: dict | None, now: float) -> None:
if snap is None:
if self.sent_status != STATUS_INACTIVE:
self._send(NAV_STATUS, status_message(STATUS_INACTIVE))
self.sent_status, self.shown_turn, self.shown_distance = STATUS_INACTIVE, None, None
return
if snap['state'] != 'guiding':
if self.sent_status != STATUS_REROUTING:
self._send(NAV_STATUS, status_message(STATUS_REROUTING))
self.sent_status = STATUS_REROUTING
return
if self.sent_status != STATUS_ACTIVE:
self.shown_turn, self.shown_distance = None, None
self._send(NAV_STATUS, status_message(STATUS_ACTIVE))
self.sent_status = STATUS_ACTIVE
turn = (snap['road'], snap['type'], snap['modifier'])
if turn != self.shown_turn:
_, side = maneuver_event(snap['type'], snap['modifier'])
event = modern_maneuver(snap['type'], snap['modifier'])
image = turn_image(snap['type'], snap['modifier'], *self.image_size) if self.image_size else None
# Older receivers use the flat messages; newer ones consume 0x8006/0x8007.
# Discovery does not expose a reliable PDK version, so publish both formats.
self._send(NAV_TURN, turn_message(snap['road'], event, side, image))
self._send(NAV_NOTIFICATION, notification_message(snap['road'], event))
self.log('nav_guidance_sent', maneuver=event, legacy=True, modern=True, image=image is not None)
self.shown_turn, self.shown_distance = turn, None
metric = bool(snap.get('metric'))
shown = display_distance(snap['distance'], metric)
if shown != self.shown_distance and (self.shown_distance is None or now >= self.next_distance):
pace = snap['remaining'] / snap['duration'] if snap.get('duration', 0) > 0 and snap.get('remaining', 0) > 0 else FALLBACK_SPEED
seconds = snap['distance'] / max(pace, 1.0)
self._send(NAV_DISTANCE, distance_message(snap['distance'], seconds, metric))
self._send(NAV_CURRENT_POSITION, current_position_message(snap['distance'], seconds, metric))
self.shown_distance, self.next_distance = shown, now + self.interval
def handle(self, channel: int, kind: int, fields: dict) -> bool:
if channel == 0 and kind == NAV_FOCUS_NOTIFICATION:
self.focus = one(fields, 1)
self.log('nav_focus', focus=self.focus)
if self.focus == FOCUS_PROJECTED:
self._reset()
self.next_check = 0.0
return True
if channel != self.channel_id:
return False
if kind == MSG_CHANNEL_OPEN_RESPONSE and self.state == 'opening':
if signed(one(fields, 1)) != 0:
self.log('nav_status_rejected', channel=self.channel_id, status=signed(one(fields, 1)))
self._set('unavailable')
else:
self._set('open')
self.deadline = self.clock() + START_GRACE
elif kind == CLUSTER_START:
self.started, self.heard = True, True
self._reset()
self.next_check = 0.0
self.log('nav_status_started', requested=True)
elif kind == CLUSTER_STOP:
self.started, self.heard = False, True
self._reset()
return True
def close(self) -> None:
if hasattr(self.feed, 'close'):
self.feed.close()
@@ -11,11 +11,14 @@ cellular default route and DNS are left alone (``never-default``,
from __future__ import annotations
import ipaddress
import json
import threading
import time
import uuid
from collections.abc import Callable
from dataclasses import replace
from pathlib import Path
from jeepney import DBusAddress, new_method_call
from jeepney.io.threading import DBusRouter, open_dbus_connection
@@ -23,6 +26,7 @@ from jeepney.low_level import MessageType
from jeepney.wrappers import Properties
from openpilot.starpilot.system.android_auto.bootstrap import WifiCredentials
from openpilot.starpilot.system.android_auto.identity import DATA_DIR
NM = "org.freedesktop.NetworkManager"
NM_PATH = "/org/freedesktop/NetworkManager"
@@ -31,7 +35,11 @@ NM_DEVICE_IFACE = "org.freedesktop.NetworkManager.Device"
NM_ACTIVE_IFACE = "org.freedesktop.NetworkManager.Connection.Active"
NM_SETTINGS_CONNECTION_IFACE = "org.freedesktop.NetworkManager.Settings.Connection"
NM_IP4_CONFIG_IFACE = "org.freedesktop.NetworkManager.IP4Config"
NM_WIRELESS_IFACE = "org.freedesktop.NetworkManager.Device.Wireless"
NM_AP_IFACE = "org.freedesktop.NetworkManager.AccessPoint"
DEVICE_TYPE_WIFI = 2
DEVICE_STATE_DISCONNECTED = 30
DEVICE_READY_TIMEOUT_S = 5.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
@@ -42,12 +50,60 @@ JOIN_TIMEOUT_S = 120.0
BSSID_SHARE = 0.75
CONNECTION_ID = "starpilot-android-auto"
SECURITY_WPA3 = (32,)
AUTOCONNECT_MARKER = DATA_DIR / "wifi_autoconnect_paused.json"
class NetworkError(RuntimeError):
pass
class DedicatedUnavailable(NetworkError):
"""The dedicated car interface cannot be used this session; projection falls back to wlan0."""
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:
state = json.loads(marker.read_text())
except (OSError, ValueError):
return ""
return state.get("interface", "") if isinstance(state, dict) and state.get("boot") == _boot_id() else ""
def recover_autoconnect(log: Callable[..., None], marker: Path = AUTOCONNECT_MARKER) -> bool:
"""Turn wlan0 autoconnect back on after a session that paused it ended without restoring it. True when done."""
if not marker.exists():
return True
interface = _paused_interface(marker)
if interface:
lease = NetworkLease(log, interface)
try:
path = next((path for path, name in lease._wifi_devices() if name == interface), "")
if not path:
return False
lease._set(path, NM_DEVICE_IFACE, "Autoconnect", "b", True)
log("wifi_autoconnect_recovered", interface=interface)
except NetworkError as error:
log("wifi_autoconnect_recover_failed", error=str(error))
return False
finally:
if lease.router is not None:
lease.router.close()
marker.unlink(missing_ok=True)
return True
def connection_settings(credentials: WifiCredentials, interface: str) -> dict:
wireless = {
"ssid": ("ay", credentials.ssid.encode("utf-8")),
@@ -83,15 +139,20 @@ def connection_settings(credentials: WifiCredentials, interface: str) -> dict:
class NetworkLease:
def __init__(self, log: Callable[..., None], interface: str = "wlan0"):
def __init__(self, log: Callable[..., None], interface: str = "wlan0", dedicated: bool = False):
"""``dedicated``: the interface exists only for the car, so never fall back to another one or touch its previous network."""
self.log = log
self.interface = interface
self.dedicated = dedicated
self.router: DBusRouter | None = None
self.device_path = ""
self.connection_path = ""
self.active_path = ""
self.previous_connection = ""
self.local_ip = ""
self.device_ready = False
self.autoconnect_paused = False
self.marker = AUTOCONNECT_MARKER
self._lock = threading.Lock()
def _open(self) -> DBusRouter:
@@ -114,18 +175,36 @@ class NetworkLease:
raise NetworkError(f"Cannot read {name}: {reply.body[0] if reply.body else ''}")
return reply.body[0][1]
def _wifi_device(self) -> str:
fallback = ""
def _set(self, path: str, interface: str, name: str, signature: str, value) -> None:
address = DBusAddress(path, bus_name=NM, interface=interface)
reply = self._open().send_and_get_reply(Properties(address).set(name, signature, value), timeout=5.0)
if reply.header.message_type == MessageType.error:
raise NetworkError(f"Cannot set {name}: {reply.body[0] if reply.body else ''}")
def _wifi_devices(self) -> list[tuple[str, str]]:
devices = []
for path in self._call(NM_PATH, NM_IFACE, "GetDevices")[0]:
if self._get(path, NM_DEVICE_IFACE, "DeviceType") != DEVICE_TYPE_WIFI:
try:
if self._get(path, NM_DEVICE_IFACE, "DeviceType") == DEVICE_TYPE_WIFI:
devices.append((path, self._get(path, NM_DEVICE_IFACE, "Interface")))
except NetworkError:
continue
if self._get(path, NM_DEVICE_IFACE, "Interface") == self.interface:
return devices
def _wifi_device(self) -> str:
devices = self._wifi_devices()
for path, name in devices:
if name == self.interface:
return path
fallback = fallback or path
if not fallback:
if not devices:
raise NetworkError("No Wi-Fi device found")
self.interface = self._get(fallback, NM_DEVICE_IFACE, "Interface")
return fallback
self.interface = devices[0][1]
return devices[0][0]
def prepare_device(self, cancelled: Callable[[], bool] = lambda: False) -> None:
"""Dedicated mode, ahead of the join: wait for NetworkManager to take the new interface and keep saved networks off it."""
with self._lock:
self._prepare_device(cancelled)
def acquire(self, credentials: WifiCredentials, timeout: float = JOIN_TIMEOUT_S, cancelled: Callable[[], bool] = lambda: False) -> str:
"""Join the projection network and return the comma's IPv4 address on it.
@@ -142,6 +221,8 @@ class NetworkLease:
try:
share = 1.0 if len(attempts) == 1 else (BSSID_SHARE if index == 0 else 1.0 - BSSID_SHARE)
return self._acquire_once(attempt, timeout * share, cancelled)
except DedicatedUnavailable:
raise
except NetworkError as error:
last_error = error
if cancelled() or index == len(attempts) - 1:
@@ -168,9 +249,13 @@ class NetworkLease:
with self._lock:
if not self._get(NM_PATH, NM_IFACE, "WirelessEnabled"):
raise NetworkError("Wi-Fi is turned off on the comma")
self.device_path = self._wifi_device()
if self.dedicated:
self._prepare_device(cancelled)
else:
self.device_path = self._wifi_device()
self._pause_autoconnect()
active = self._get(self.device_path, NM_DEVICE_IFACE, "ActiveConnection")
if active and active != "/" and not self.previous_connection:
if active and active != "/" and not self.previous_connection and not self.dedicated:
try:
previous = self._get(active, NM_ACTIVE_IFACE, "Connection")
if self._connection_id(previous) != CONNECTION_ID:
@@ -198,12 +283,108 @@ class NetworkLease:
if state == ACTIVE_STATE_DEACTIVATED:
raise NetworkError("NetworkManager could not join the projection network")
if time.monotonic() >= deadline:
raise NetworkError(f"Timed out joining the projection network after {timeout:.0f} s")
raise JoinTimeout(f"Timed out joining the projection network after {timeout:.0f} s")
time.sleep(0.25)
self.local_ip = self._ipv4()
self.log("wifi_joined", interface=self.interface, local_ip=self.local_ip)
if self.dedicated:
self._check_overlap()
self.log("wifi_joined", interface=self.interface, local_ip=self.local_ip, **self._radio())
return self.local_ip
def _frequency(self, path: str) -> int:
access_point = self._get(path, NM_WIRELESS_IFACE, "ActiveAccessPoint")
return int(self._get(access_point, NM_AP_IFACE, "Frequency")) if access_point and access_point != "/" else 0
def _radio(self) -> dict:
"""Channels at join time; a car and home Wi-Fi on different channels share the radio's airtime."""
radio = {}
try:
radio["freq"] = self._frequency(self.device_path)
if self.dedicated:
for path, name in self._wifi_devices():
if name == "wlan0":
radio["wlan0_state"] = int(self._get(path, NM_DEVICE_IFACE, "State"))
radio["wlan0_freq"] = self._frequency(path)
except NetworkError:
pass
return radio
def _wait_step(self, deadline: float, cancelled: Callable[[], bool], failure: str) -> None:
if cancelled():
raise NetworkError("cancelled")
if time.monotonic() >= deadline:
raise DedicatedUnavailable(failure)
time.sleep(0.25)
def _prepare_device(self, cancelled: Callable[[], bool]) -> None:
if self.device_ready:
return
deadline = time.monotonic() + DEVICE_READY_TIMEOUT_S
while not (path := next((path for path, name in self._wifi_devices() if name == self.interface), "")):
self._wait_step(deadline, cancelled, f"{self.interface} is not a NetworkManager Wi-Fi device")
self.device_path = path
# Before the device is ready, so a saved network can never autoconnect on it.
if self._get(path, NM_DEVICE_IFACE, "Autoconnect"):
try:
self._set(path, NM_DEVICE_IFACE, "Autoconnect", "b", False)
except NetworkError as error:
raise DedicatedUnavailable(f"Cannot stop saved networks joining {self.interface}") from error
while self._get(path, NM_DEVICE_IFACE, "State") < DEVICE_STATE_DISCONNECTED:
self._wait_step(deadline, cancelled, f"NetworkManager did not take {self.interface}")
self.device_ready = True
# A new interface has never scanned, so the car's BSSID would be rejected until a scan finds it.
try:
self._call(path, NM_WIRELESS_IFACE, "RequestScan", "a{sv}", ({},))
self.log("wifi_scan_requested", interface=self.interface)
except NetworkError as error:
self.log("wifi_scan_request_failed", error=str(error))
def _pause_autoconnect(self) -> None:
"""Keep NetworkManager from rejoining home Wi-Fi between attempts; release(restore=True) turns it back on."""
if self.autoconnect_paused:
return
try:
if self._get(self.device_path, NM_DEVICE_IFACE, "Autoconnect"):
if not self._write_marker():
return
self._set(self.device_path, NM_DEVICE_IFACE, "Autoconnect", "b", False)
self.autoconnect_paused = True
elif _paused_interface(self.marker) == self.interface:
self.autoconnect_paused = True
except NetworkError as error:
self.log("wifi_autoconnect_pause_failed", error=str(error))
def _write_marker(self) -> bool:
try:
self.marker.parent.mkdir(parents=True, exist_ok=True)
self.marker.write_text(json.dumps({"interface": self.interface, "boot": _boot_id()}))
return True
except OSError as error:
self.log("wifi_autoconnect_marker_failed", error=str(error))
return False
def _networks(self, path: str) -> list:
config = self._get(path, NM_DEVICE_IFACE, "Ip4Config")
if not config or config == "/":
return []
return [ipaddress.ip_network(f"{entry['address'][1]}/{entry['prefix'][1]}", strict=False)
for entry in self._get(config, NM_IP4_CONFIG_IFACE, "AddressData") if "address" in entry and "prefix" in entry]
def _check_overlap(self) -> None:
"""The car's subnet must not also be reachable through home Wi-Fi, or replies to the head unit leave on wlan0."""
ours = self._networks(self.device_path)
for path in self._call(NM_PATH, NM_IFACE, "GetDevices")[0]:
if path == self.device_path:
continue
try:
others = self._networks(path)
except NetworkError:
continue
for other in others:
if any(network.overlaps(other) for network in ours):
self.log("wifi_subnet_overlap", car=str(ours[0]), other=str(other))
raise DedicatedUnavailable(f"The car's network {ours[0]} overlaps {other}")
def _connection_id(self, path: str) -> str:
try:
return self._call(path, NM_SETTINGS_CONNECTION_IFACE, "GetSettings")[0].get("connection", {}).get("id", ("s", ""))[1]
@@ -247,7 +428,7 @@ class NetworkLease:
reactivated only when the session finally stops, avoiding churn between attempts.
"""
with self._lock:
if self.router is None:
if self.router is None and not (restore and (self.previous_connection or self.autoconnect_paused)):
return
ours_active = False
try:
@@ -266,6 +447,13 @@ class NetworkLease:
self._call(self.connection_path, NM_SETTINGS_CONNECTION_IFACE, "Delete")
except NetworkError:
pass # volatile profiles delete themselves on deactivation
if restore and self.autoconnect_paused:
try:
self._set(self.device_path, NM_DEVICE_IFACE, "Autoconnect", "b", True)
self.marker.unlink(missing_ok=True)
except (NetworkError, OSError) as error:
self.log("wifi_autoconnect_restore_failed", error=str(error))
self.autoconnect_paused = False
if restore and ours_active and self.previous_connection and self.device_path:
try:
self._call(NM_PATH, NM_IFACE, "ActivateConnection", "ooo", (self.previous_connection, self.device_path, "/"))
@@ -276,6 +464,7 @@ class NetworkLease:
if restore:
self.previous_connection = ""
try:
self.router.close()
if self.router is not None:
self.router.close()
finally:
self.router = None
@@ -26,6 +26,14 @@ class BookmarkSender:
self.sock.close()
def request_bookmark(sender, ui_state, display_message, now_ns):
"""Send a tap while car state is live. Taps are handled after a ~25 ms draw, past current_message's
20 ms carState limit, so allow display_message's frame gap; the native UI re-checks before bookmarking."""
if not ui_state.started or display_message(ui_state.sm, 'carState', now_ns, after_frame=ui_state.started_frame) is None:
return False
return sender.send(int(ui_state.sm.logMonoTime['carState']))
class BookmarkReceiver:
def __init__(self, path=DEFAULT_SOCKET):
self.path = str(path)
@@ -7,6 +7,17 @@ from openpilot.starpilot.ui.onroad_customization import widget_order
from openpilot.starpilot.ui.navigation_favorites_state import favorite_visible
def destination_label(document):
""""Home" or "Work" when the active destination is that favorite, else its place name; '' when not navigating."""
destination = document['destination'] if document is not None else None
if destination is None:
return ''
place = next((row for row in document['favorites'] if row.get('id') == destination['id']), None)
if place is not None and place.get('label') in ('home', 'work'):
return place['label'].title()
return destination.get('name', '')
class ProjectionFavorites:
def __init__(self, authorized, owner=None):
self.authorized = authorized
@@ -28,7 +28,14 @@ FAVORITE_SIZE = (320, 110)
FAVORITE_ICON_SIZE = 110
CAR_EXIT_SIZE = (96, 96)
CAR_EXIT_MIN_OPACITY = 25
PROJECTION_WIDGETS = (NAV_CARD, NAV_MAP, *DISPLAY_WIDGETS, STOPPED_TIMER_WIDGET, CAR_EXIT)
NOW_PLAYING_WIDGET = 'now_playing'
NOW_PLAYING_SIZE = (520, 96)
NOW_PLAYING_OPACITY = (0, 100, 85) # percent: min, max, default
JETLINK_WIDGET = 'jetlink_status'
JETLINK_SIZE = (420, 84)
JETLINK_OPACITY = (0, 100, 85) # percent: min, max, default
CARD_WIDGETS = {NOW_PLAYING_WIDGET: NOW_PLAYING_OPACITY, JETLINK_WIDGET: JETLINK_OPACITY} # background opacity only
PROJECTION_WIDGETS = (NAV_CARD, NAV_MAP, *DISPLAY_WIDGETS, STOPPED_TIMER_WIDGET, NOW_PLAYING_WIDGET, JETLINK_WIDGET, CAR_EXIT)
NAV_CARD_SIZE = (560, 195)
MAP_MIN_SIZE = (280, 200)
MAP_OPACITY = (15, 100, 50) # percent: min, max, default
@@ -105,6 +112,18 @@ def layout_metadata_for_viewport(viewport):
'iconSize': FAVORITE_ICON_SIZE,
'note': 'Save a bookmark in the current drive recording.',
'default': {'x': width - 30 - FAVORITE_SIZE[0], 'y': 155, 'enabled': False, 'display': 'words'}}
widgets[NOW_PLAYING_WIDGET] = {'label': 'Now playing', 'kind': NOW_PLAYING_WIDGET,
'width': NOW_PLAYING_SIZE[0], 'height': NOW_PLAYING_SIZE[1], 'colors': {},
'note': 'The song playing from Spotify or your phone over Bluetooth. Opacity sets the card background.',
'opacity': {'min': NOW_PLAYING_OPACITY[0], 'max': NOW_PLAYING_OPACITY[1], 'default': NOW_PLAYING_OPACITY[2]},
'collapseToggle': True,
'default': {'x': width - 30 - NOW_PLAYING_SIZE[0], 'y': height - 30 - NOW_PLAYING_SIZE[1],
'enabled': True, 'opacity': NOW_PLAYING_OPACITY[2], 'collapse': False}}
widgets[JETLINK_WIDGET] = {'label': 'Jetlink status', 'kind': JETLINK_WIDGET,
'width': JETLINK_SIZE[0], 'height': JETLINK_SIZE[1], 'colors': {},
'note': 'Shown while Jetlink is on: connection, device and host processing time. Opacity sets the card background.',
'opacity': {'min': JETLINK_OPACITY[0], 'max': JETLINK_OPACITY[1], 'default': JETLINK_OPACITY[2]},
'default': {'x': 30, 'y': 650, 'enabled': False, 'opacity': JETLINK_OPACITY[2]}}
exit_w, exit_h = CAR_EXIT_SIZE
widgets[CAR_EXIT] = {'label': 'Exit to car', 'kind': CAR_EXIT, 'width': exit_w, 'height': exit_h, 'colors': {},
'required': True, 'frontmost': True,
@@ -117,7 +136,7 @@ def layout_metadata_for_viewport(viewport):
# Background opacity for the cruise and speed-limit card; its text stays solid.
widgets['cruise_limits'].update(opacity={'min': 0, 'max': 100, 'default': 100})
widgets['cruise_limits']['default'].update(opacity=100)
profile["widgetOrder"] = [NAV_MAP, *profile["widgetOrder"], STOPPED_TIMER_WIDGET, NAV_CARD, *DISPLAY_WIDGETS, CAR_EXIT]
profile["widgetOrder"] = [NAV_MAP, *profile["widgetOrder"], STOPPED_TIMER_WIDGET, NAV_CARD, *DISPLAY_WIDGETS, NOW_PLAYING_WIDGET, JETLINK_WIDGET, CAR_EXIT]
return profile
@@ -241,6 +260,10 @@ def validate_layout_for_viewport(value, viewport, *, _legacy_geometry=False):
placement.setdefault('background', True)
if key == 'cruise_limits' and type(placement) is dict:
placement.setdefault('opacity', 100)
if key in CARD_WIDGETS and type(placement) is dict:
placement.setdefault('opacity', CARD_WIDGETS[key][2])
if key == NOW_PLAYING_WIDGET and type(placement) is dict:
placement.setdefault('collapse', False)
if key == 'driver_monitor' and type(placement) is dict and 'size' not in placement:
for axis in ('x', 'y'):
if type(placement.get(axis)) in (int, float):
@@ -249,7 +272,8 @@ def validate_layout_for_viewport(value, viewport, *, _legacy_geometry=False):
fields = ({'x', 'y', 'enabled'} | ({'size'} if widget.get('resizable') else set()) |
({'display'} if key in DISPLAY_WIDGETS else set()) |
({'opacity', 'background'} if key in ACTION_WIDGETS else set()) |
({'opacity'} if key == 'cruise_limits' else set()) |
({'opacity'} if key == 'cruise_limits' or key in CARD_WIDGETS else set()) |
({'collapse'} if key == NOW_PLAYING_WIDGET else set()) |
({'width', 'height', 'opacity'} if key == NAV_MAP else set()))
if type(placement) is not dict or set(placement) != fields or type(placement['enabled']) is not bool:
raise ValueError('Invalid projection widget')
@@ -262,6 +286,11 @@ def validate_layout_for_viewport(value, viewport, *, _legacy_geometry=False):
raise ValueError('Invalid action widget appearance')
if key == 'cruise_limits' and (type(placement['opacity']) is not int or not 0 <= placement['opacity'] <= 100):
raise ValueError('Invalid cruise card opacity')
if key in CARD_WIDGETS and (type(placement['opacity']) is not int or
not CARD_WIDGETS[key][0] <= placement['opacity'] <= CARD_WIDGETS[key][1]):
raise ValueError('Invalid card opacity')
if key == NOW_PLAYING_WIDGET and type(placement['collapse']) is not bool:
raise ValueError('Invalid now playing collapse setting')
if key == CAR_EXIT and placement['opacity'] < CAR_EXIT_MIN_OPACITY:
raise ValueError('Exit to car opacity must be at least 25%')
if widget.get('resizable'):
@@ -0,0 +1,51 @@
"""Read-only now-playing snapshot for the car screen, polled away from Android Auto's drawing thread."""
import threading
from openpilot.starpilot.soloist import now_playing
from openpilot.starpilot.soloist.paths import Layout
class ProjectionNowPlayingFeed:
"""Only poll while placed. The renderer takes immutable snapshots without file IO."""
def __init__(self, read=None):
self.read = read or (lambda: now_playing.read(Layout.default().status))
self._lock = threading.Lock()
self._stop = threading.Event()
self._thread = None
self._enabled = False
self._value = None
def set_enabled(self, enabled):
with self._lock:
self._enabled = enabled
if not enabled:
self._value = None
if enabled and self._thread is None:
self._thread = threading.Thread(target=self._run, name='aa-now-playing', daemon=True)
self._thread.start()
def poll(self):
with self._lock:
if not self._enabled:
return
try:
value = self.read()
except Exception:
value = None
with self._lock:
if self._enabled:
self._value = value
def snapshot(self):
with self._lock:
return self._value
def _run(self):
while not self._stop.is_set():
self.poll()
self._stop.wait(1.0)
def close(self):
self._stop.set()
if self._thread is not None:
self._thread.join(timeout=2)
@@ -11,12 +11,40 @@ from openpilot.starpilot.system.android_auto.projection_exit import ProjectionEx
from openpilot.starpilot.system.android_auto.projection_geometry import FALLBACK_VIEWPORT
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
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',
'laneChangeBlocked', 'laneChangeBlockedLoud'))
def driving_mode_label(state) -> str | None:
"""The driving mode the driver chose, or None while it cannot be read.
Conditional mode's own automatic switching is not a change of mode, so it all reads as one label;
only a driver override (a wheel or screen tap) changes it."""
from openpilot.starpilot.conditional_mode.policy import ModeChoice
choice = getattr(state, 'conditional_configured', None)
if choice in (ModeChoice.CEM, ModeChoice.CCM):
effective = getattr(state, 'conditional_effective', None)
if effective is None:
if getattr(state, 'engaged', True):
return None # not reported between planner updates
# Disengaged, nothing is reported and nothing switches by itself: the flag is the driver's choice.
experimental = getattr(state, 'experimental_enabled', None)
return None if not isinstance(experimental, bool) else 'Experimental Mode' if experimental else 'Chill Mode'
if effective.reason == 'manual_experimental':
return 'Experimental Mode'
if effective.reason == 'manual_chill':
return 'Chill Mode'
return 'Conditional Experimental' if choice is ModeChoice.CEM else 'Conditional Chill'
experimental = getattr(state, 'experimental_enabled', None)
if not isinstance(experimental, bool):
return None
return 'Experimental Mode' if experimental else 'Chill Mode'
def certificate_notice(days_left: int | None) -> str:
"""The car-screen heads-up for a certificate in its last two weeks; empty otherwise."""
if days_left is None or not 0 <= days_left <= EXPIRY_WARNING_DAYS:
@@ -45,6 +73,8 @@ def native_dependencies():
from openpilot.starpilot.ui.runtime_snapshot import RuntimeSnapshotAdapter, current_message, display_message
from openpilot.starpilot.ui.shell import ShellMode
from openpilot.starpilot.system.android_auto.projection_favorites import ProjectionFavorites
from openpilot.starpilot.system.android_auto.projection_now_playing import ProjectionNowPlayingFeed
from openpilot.starpilot.models.jetlink_status import JetlinkStatusFeed
class ProjectionRoadCamera(AugmentedRoadView):
"""Current native calibrated camera/model without stock action widgets."""
@@ -105,7 +135,8 @@ def native_dependencies():
shell_mode=ShellMode, pip_renderer=PiPRenderer, read_pip=read_pip, pip_signals=Signals,
pip_rect=Rect, pip_widgets=CAMERA_WIDGETS, placement=placement, widget_size=widget_size,
alert=OnroadAlert, alert_size=AlertSize, map_overlay=MapOverlay, map_feed=MapFeed,
favorites=ProjectionFavorites, standby_logo=standby_logo)
favorites=ProjectionFavorites, standby_logo=standby_logo, now_playing_feed=ProjectionNowPlayingFeed,
jetlink_feed=JetlinkStatusFeed)
class ProjectionOnroad:
@@ -118,6 +149,10 @@ class ProjectionOnroad:
self.customization = customization
self._certificate_notice = certificate_notice(certificate_days)
self._certificate_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
self._mode_notice: tuple[str, int] | None = None # (banner text, shown until)
self._base_customization = None
self._projection_customization = None
self._base_appearance = None
@@ -144,6 +179,14 @@ class ProjectionOnroad:
self._resources.callback(self._close_camera)
self.onroad = self.create_view(native.onroad, self.fonts, camera_layer=self._camera_layer, viewport=viewport)
self._resources.callback(self._close_onroad)
self.now_playing_feed = None
if getattr(native, 'now_playing_feed', None) is not None:
self.now_playing_feed = native.now_playing_feed()
self._resources.callback(self.now_playing_feed.close)
self.jetlink_feed = None
if getattr(native, 'jetlink_feed', None) is not None:
self.jetlink_feed = native.jetlink_feed()
self._resources.callback(self.jetlink_feed.close)
self.exit = ProjectionExit(native.rl, lambda: self.native_focus())
self.onroad.projection_exit_layer = self.exit.render
self.monitor = native.monitor(native.profile.LARGE)
@@ -153,7 +196,7 @@ class ProjectionOnroad:
from openpilot.starpilot.ui.onroad_state import OnroadInput
def emit_slc(request) -> None:
self.slc_action(request)
self._slc_input = OnroadInput(emit_slc, native.profile.LARGE)
self._slc_input = OnroadInput(emit_slc, native.profile.LARGE, slop=30)
self.onroad.pressed_action = lambda: self._slc_input.pressed_action
self.pip = None
self._pip_saved = None
@@ -284,6 +327,14 @@ class ProjectionOnroad:
def prepare(self):
"""Before the frame's render target is bound: offscreen map work happens here."""
self._check_layout()
if self.now_playing_feed is not None:
placed = (self.customization or {}).get('widgets', {}).get('now_playing', {})
self.now_playing_feed.set_enabled(bool(placed.get('enabled')))
self.onroad.now_playing.track = self.now_playing_feed.snapshot()
if self.jetlink_feed is not None:
placed = (self.customization or {}).get('widgets', {}).get('jetlink_status', {})
self.jetlink_feed.set_enabled(bool(placed.get('enabled') and self.native.ui_state.started))
self.onroad.jetlink_status.status = self.jetlink_feed.snapshot()
if self.favorites is not None:
self.favorites.refresh()
self.onroad.navigation_favorites.document = self.favorites.document
@@ -293,7 +344,9 @@ class ProjectionOnroad:
return
document = self.favorites.document if self.favorites is not None else None
requested = bool(document['destination']) if document is not None else None
data = self.map_feed.read(self.native.ui_state.sm, navigation_requested=requested)
from openpilot.starpilot.system.android_auto.projection_favorites import destination_label
data = self.map_feed.read(self.native.ui_state.sm, navigation_requested=requested,
destination=destination_label(document) if document is not None else None)
self.map.prepare(data, placed['width'], placed['height'])
def _map_layer(self, rect, state):
@@ -353,6 +406,41 @@ class ProjectionOnroad:
return replace(state, alert=self.native.alert(size=sizes.SMALL, text1=self._certificate_notice,
alert_type='androidAutoCertificate/warning'))
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
def _with_bookmark_notice(self, state, now_ns):
"""Show "Bookmark Saved" briefly after a bookmark; a real alert always wins."""
until = self._bookmark_notice_until_ns
if until is None or now_ns >= until:
return state
sizes = self.native.alert_size
if state.alert.size != sizes.NONE:
return state
return replace(state, alert=self.native.alert(size=sizes.SMALL, text1='Bookmark Saved',
alert_type='androidAutoBookmark/saved'))
def _with_mode_notice(self, state, now_ns):
"""Show the new driving mode briefly when it changes during a drive; a real alert always wins."""
label = driving_mode_label(state)
engaged = bool(getattr(state, 'engaged', True))
if engaged != self._mode_engaged:
self._mode_engaged = engaged
self._mode_label = None # engaging or disengaging is not a mode change
if label is not None:
# Only the driver's own choice is announced, never the planner handing back to conditional mode.
if self._mode_label is not None and label != self._mode_label and not label.startswith('Conditional'):
self._mode_notice = (label, now_ns + MODE_NOTICE_NS)
self._mode_label = label
if self._mode_notice is None or now_ns >= self._mode_notice[1]:
return state
sizes = self.native.alert_size
if state.alert.size != sizes.NONE:
return state
return replace(state, alert=self.native.alert(size=sizes.SMALL, text1=self._mode_notice[0],
alert_type='androidAutoDrivingMode/changed'))
def _standby(self):
rl = self.native.rl
purple = rl.Color(188, 132, 255, 255) # StarPilot purple, as on the comma's moon
@@ -408,8 +496,13 @@ class ProjectionOnroad:
# The car screen's display options live in the AA layout, not the comma's settings.
self._projection_appearance = self._own_appearance(state.appearance)
self._base_appearance = state.appearance
state = replace(state, customization=self._projection_customization, appearance=self._projection_appearance)
self._state = self._with_certificate_notice(state, now_ns)
state = replace(state, customization=self._projection_customization, appearance=self._projection_appearance,
reverse_driver_camera=False)
# 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)
self._state = self._with_mode_notice(state, now_ns)
self.onroad.render(self._state)
self.fonts.draw('StarPilot', self.native.font_role.BRAND, 30, self.width - 210, self.height - 90)
else:
@@ -417,6 +510,7 @@ class ProjectionOnroad:
if self.favorites is not None:
self.favorites.cancel()
self._certificate_notice_until_ns = None # the next drive shows the heads-up again
self._mode_label = self._mode_engaged = self._mode_notice = None # a drive's starting mode is not a change
self._monitor_pair = None
self._monitor_pair_ns = None
if self.pip is not None:
@@ -24,7 +24,8 @@ from collections import deque
from collections.abc import Callable
from dataclasses import dataclass
from openpilot.starpilot.system.android_auto.touch import InputConfig, TouchEvent, TouchMapper, parse_input_config
from openpilot.starpilot.system.android_auto.nav_status import NAVIGATION_SERVICE, parse_navigation
from openpilot.starpilot.system.android_auto.touch import InputConfig, KeyTracker, TouchEvent, TouchMapper, parse_input_config
from openpilot.starpilot.system.android_auto.wire import describe, describe_fields, field, json_fields, one, parse_fields, signed
MAX_MESSAGE = 2 * 1024 * 1024
@@ -229,8 +230,8 @@ class Session:
# ----------------------------------------------------------------- framing
def send(self, channel: int, kind: int, body: bytes = b"", encrypted: bool = True, control: bool = False, *,
deadline: float | None = None) -> None:
"""Send one whole message within one deadline; the session is invalid on failure."""
deadline: float | None = None) -> int:
"""Send one whole message within one deadline and return its fragment count; the session is invalid on failure."""
if self.authenticated and not encrypted:
raise ValueError("Plaintext is forbidden after authentication")
data = struct.pack(">H", kind) + body
@@ -265,6 +266,7 @@ class Session:
finally:
if timed_peer:
self.peer.settimeout(previous_timeout)
return -(-len(data) // FRAGMENT_SIZE)
def receive(self, *, deadline: float | None = None) -> tuple[int, int, bytes]:
"""Read one complete message within one deadline, tolerating partial reads."""
@@ -410,8 +412,19 @@ class Session:
self.head_unit_subject = ", ".join(f"{name}={value}" for rdn in peer_cert.get("subject", ()) for name, value in rdn)
self.event("head_unit_verified", subject=self.head_unit_subject, expires=peer_cert.get("notAfter", ""))
channel, kind, data = self.receive()
# Some head units send a trailing TLS record before the status; feed it to the engine.
for _ in range(4):
if channel != 0 or kind != MSG_SSL_HANDSHAKE:
break
self.event("late_tls_record", bytes=len(data), head=data[:24].hex())
self.incoming.write(data)
try:
self.tls.read(1)
except ssl.SSLWantReadError:
pass
channel, kind, data = self.receive()
if channel != 0 or kind != MSG_AUTH_COMPLETE:
raise ValueError(f"Expected authentication status; got {channel}/{kind:#x}")
raise ValueError(f"Expected authentication status; got {channel}/{kind:#x} ({len(data)} bytes {data[:24].hex()})")
status = signed(one(parse_fields(data), 1))
if status is None or not isinstance(status, int):
raise ValueError("Missing or invalid authentication status")
@@ -438,6 +451,14 @@ class Session:
item["media_type"] = one(media, 1)
item["display_id"], item["display_type"] = one(media, 6), one(media, 7)
item["video_configs"] = [parse_fields(c) for c in media.get(4, []) if isinstance(c, bytes)]
if one(media, 2) is not None:
item["audio_type"] = one(media, 2)
item["audio_configs"] = [parse_fields(c) for c in media.get(3, []) if isinstance(c, bytes)]
if isinstance(one(fields, NAVIGATION_SERVICE), bytes):
try:
item["navigation"] = parse_navigation(one(fields, NAVIGATION_SERVICE))
except ValueError:
item["navigation"] = {}
if isinstance(one(fields, 4), bytes):
item["input"] = True
try:
@@ -451,7 +472,7 @@ class Session:
channels.append(item)
# Everything but the channel list: make, model, year, software and, on newer units, headunit_info.
head_unit = redact_head_unit(describe_fields({number: values for number, values in response.items() if number != 1}))
self.event("discovered", channels=[{k: (json_fields_list(v) if k == "video_configs" else str(v) if k == "input_config" else v)
self.event("discovered", channels=[{k: (json_fields_list(v) if k in ("video_configs", "audio_configs") else str(v) if k == "input_config" else v)
for k, v in ch.items()} for ch in channels],
head_unit=head_unit)
return channels
@@ -501,9 +522,14 @@ class ProjectionSession(Session):
ACK_TIMEOUT = 2.5
ACK_DRAIN_SECONDS = 0.1 # before calling an ACK late, read what already arrived, for at most this long
MAX_WINDOW = 2
nav = None
# HeadunitPad acknowledges every fragment (codec config included) rather than every frame.
fragment_acks = False
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.frame_fragments: deque[int] = deque()
self.fragment_credit = 0
self.channels: list[dict] = []
self.mode: VideoMode | None = None
self.window = 1
@@ -523,9 +549,13 @@ class ProjectionSession(Session):
self.epoch_acked = 0
self.video_confirmed = False
self.input_channel: int | None = None
self.nav = None # NavStatus, attached by the supervisor when the head unit offers turn-by-turn
self.audio = None # MediaAudio, attached by the supervisor when Spotify plays through Android Auto
self.input_events = 0
self.touch: TouchMapper | None = None
self.touch_events: deque[TouchEvent] = deque(maxlen=128)
self.keys = KeyTracker()
self.media_control: Callable[[str], None] | None = None # non-blocking; set by the supervisor
self.last_ping_sent = 0.0
self.last_rx = time.monotonic()
self.ping_responses = 0
@@ -574,7 +604,7 @@ class ProjectionSession(Session):
self.send(channel["id"], INPUT_BINDING_REQUEST, b"".join(field(1, code) for code in config.keycodes))
if self.mode is not None:
self.touch = TouchMapper(config, self.mode.width, self.mode.height, self.mode.margin_width, self.mode.margin_height)
self.event("input_opened", channel=channel["id"], keycodes=len(config.keycodes),
self.event("input_opened", channel=channel["id"], keycodes=list(config.keycodes),
touch=f"{config.touch_width}x{config.touch_height}")
except (TimeoutError, ValueError) as error:
self.event("input_unavailable", error=str(error))
@@ -621,6 +651,10 @@ class ProjectionSession(Session):
except ValueError:
fields = {} # non-protobuf payload on a channel this sender never opened
mode = self.mode
if self.audio is not None and self.audio.handle(channel, kind, fields):
return
if self.nav is not None and self.nav.handle(channel, kind, fields):
return
if channel == 0:
if kind == MSG_PING_REQUEST:
self.send(0, MSG_PING_RESPONSE, field(1, one(fields, 1, 0)))
@@ -647,6 +681,14 @@ class ProjectionSession(Session):
self.touch_events.extend(self.touch.decode(data))
except ValueError as error:
self.event("input_invalid", error=str(error))
# The driver may be on the car's own screen while Android Auto plays the music.
try:
for action in self.keys.decode(data):
self.event("media_key", action=action)
if self.media_control is not None:
self.media_control(action)
except ValueError as error:
self.event("input_invalid", error=str(error))
elif kind == INPUT_BINDING_RESPONSE:
self.event("input_bound", status=signed(one(fields, 1, 0)))
else:
@@ -670,6 +712,9 @@ class ProjectionSession(Session):
self.session_id += 1
self.unacked = 0
self.pending.clear()
if self.fragment_acks:
self.frame_fragments.clear()
self.fragment_credit = 0
self.config_ack_slack = 0
self.epoch_acked = 0
self.media_started = True
@@ -680,6 +725,19 @@ class ProjectionSession(Session):
def _handle_ack(self, sid, count: int) -> None:
if sid in self.retired_sessions:
return
if self.fragment_acks:
if sid != self.session_id:
raise ValueError(f"Invalid video acknowledgement session={sid} count={count} pending={self.unacked}")
self.fragment_credit += count
frames = 0
while self.frame_fragments and self.fragment_credit >= self.frame_fragments[0]:
self.fragment_credit -= self.frame_fragments.popleft()
frames += 1
if self.fragment_credit and not self.frame_fragments:
raise ValueError(f"Invalid video acknowledgement session={sid} count={count} pending={self.unacked}")
if not frames:
return
count = frames
# Head units may acknowledge codec config like a frame: under the current
# session (as an extra count) or, like the DHU, under session 0.
if sid != self.session_id and 0 < count <= self.config_ack_slack:
@@ -744,12 +802,16 @@ class ProjectionSession(Session):
if self.needs_keyframe and not keyframe:
raise ValueError("A fresh media epoch must start with SPS/PPS and an IDR frame")
assert self.mode is not None
fragments = 0
if self.needs_keyframe:
config = codec_config(data)
if config:
self.send(self.mode.channel, AV_MEDIA_CODEC_CONFIG, config)
self.config_ack_slack += 1
self.send(self.mode.channel, AV_MEDIA_WITH_TIMESTAMP, struct.pack(">Q", timestamp_us) + data)
fragments += self.send(self.mode.channel, AV_MEDIA_CODEC_CONFIG, config)
if not self.fragment_acks:
self.config_ack_slack += 1
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)
self.needs_keyframe = False
self.pending.append(time.monotonic())
self.unacked += 1
@@ -16,6 +16,7 @@ on Stop. Nothing here runs as root or touches vehicle control.
from __future__ import annotations
import ipaddress
import json
import math
import os
@@ -34,6 +35,7 @@ from openpilot.common.hardware import COMMA_HARDWARE
from openpilot.starpilot.system.android_auto import identity as identity_store
from openpilot.starpilot.system.android_auto.auto_connect import AutoConnectPolicy
from openpilot.starpilot.system.android_auto.bootstrap import NAMES, BootstrapError, WirelessBootstrap
from openpilot.starpilot.system.android_auto.bugsink_report import PairingAttempt
from openpilot.starpilot.system.android_auto.frame_source import (DEFAULT_PATH as DEFAULT_FRAME_PATH, FLAG_ASYNC_READBACK, FLAG_NV12,
FORMAT_NV12, FrameRequest)
from openpilot.starpilot.system.android_auto.projection_control import DEFAULT_CONTROL_SOCKET, NATIVE_FOCUS
@@ -43,6 +45,8 @@ from openpilot.starpilot.system.android_auto.session import AuthenticationReject
BACKOFF_SECONDS = (2.0, 4.0, 8.0, 15.0, 30.0)
STABLE_SESSION_SECONDS = 30.0
WIFI_RECOVER_RETRY_S = 30.0
DEDICATED_STRIKES = 2 # sessions where aa0 failed but wlan0 joined, before a car stays on wlan0
PEER_STOP_RETRY_SECONDS = 10.0
FRAME_MAX_AGE = 0.5 # never send a UI frame older than this
SOFTWARE_FPS = 15 # libx264 cadence; the hardware encoder runs at 30
@@ -60,6 +64,9 @@ 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
REPORTED_SETTINGS = ("connection", "usb_mode", "view", "encoder", "fps", "bitrate_kbps", "rate_control", "gpu_nv12",
"async_readback", "wifi_interface", "dedicated_wifi_interface", "verify_head_unit", "version_status",
"rfcomm_channel", "phone_class", "auto_connect")
def encoder_preference(configured: str) -> str:
@@ -72,6 +79,7 @@ STATE_LABELS = {
"negotiating": "negotiating video", "streaming": "projecting", "suspended": "car showing its own screen",
"backoff": "retrying", "stopping": "stopping", "error": "error",
"waiting_for_usb": "plug into the car's usb", "usb_accessory": "starting usb",
"waiting_for_ipad": "open headunitpad on the ipad", "waiting_for_tablet": "open open headunit on the tablet",
}
@@ -176,7 +184,7 @@ class LinkWatch:
wireless = not isinstance(self.lease, (NoLease, UsbLease))
while not self._stop.is_set():
if wireless:
self.wifi_dbm = _wifi_dbm()
self.wifi_dbm = _wifi_dbm(getattr(self.lease, "interface", "wlan0"))
if check is not None:
started = time.monotonic()
try:
@@ -263,7 +271,7 @@ class EventLog:
return json.dumps(record, default=str) + "\n"
def __call__(self, name: str, **values) -> None:
record = {"t": datetime.now(UTC).isoformat(timespec="milliseconds"), "event": name, **values}
record = {"t": datetime.now(UTC).isoformat(timespec="milliseconds"), "event": name, "mono_s": time.monotonic(), **values}
with self.lock:
self.recent.append(record)
if self._writer is None:
@@ -292,6 +300,8 @@ class Supervisor:
self._source_session = source_session
self._projection_enabled = projection_enabled
self._lease_factory = lease_factory
self._dedicated_strikes: dict[str, int] = {}
self._wifi_recover_at = 0.0
self._bt_client = bluetooth_client
self._frame_path = frame_path
self._lock = threading.RLock()
@@ -302,6 +312,8 @@ class Supervisor:
self._bluez: Any = None
self._pairing_until = 0.0
self._pairing_known: set[str] | None = None # Android Auto cars already paired when the pairing window opened
self._pairing_attempt: PairingAttempt | None = None
self._pairing_seen: tuple = ()
self._car_seen_at = -CAR_LINK_HOLD
self._hfp_link = threading.Event() # set when the chosen car opens the hands-free link
self.auto = AutoConnectPolicy()
@@ -340,6 +352,7 @@ class Supervisor:
status["wired_blocked"] = wired_blocker()
status["pairing_ready"] = self._pairing_active()
status["auto_connect"] = bool(self.config["auto_connect"])
status["dedicated_wifi_interface"] = bool(self.config["dedicated_wifi_interface"])
status["auto_paused"] = self.auto.suppressed
status["recent"] = list(self.log.recent)[-8:]
return status
@@ -361,6 +374,13 @@ class Supervisor:
identity_store.save_config(self.config)
self.log("auto_connect_selected", enabled=bool(enabled))
def set_dedicated_wifi(self, enabled: bool) -> None:
"""Join the car on aa0 so wlan0 keeps its network; used from the next session."""
with self._lock:
self.config["dedicated_wifi_interface"] = bool(enabled)
identity_store.save_config(self.config)
self.log("dedicated_wifi_selected", enabled=bool(enabled))
def start(self, trigger: str = "manual") -> None:
with self._lock:
if not self._projection_enabled():
@@ -371,7 +391,7 @@ class Supervisor:
if self._stop.is_set():
raise RuntimeError("Android Auto is still stopping; try again in a moment")
return
if self.config["connection"] != "wired" and not self.config["receiver_address"]:
if self.config["connection"] == "wireless" and not self.config["receiver_address"]:
raise RuntimeError("Choose your car first")
identity = identity_store.load_identity() # fail fast with a clear message
self._stop.clear()
@@ -455,8 +475,8 @@ class Supervisor:
self.log("view_selected", view=view)
def set_connection(self, connection: str) -> None:
"""Choose wireless (Bluetooth + car Wi-Fi) or wired (USB) projection."""
if connection not in ("wireless", "wired"):
"""Choose wireless (Bluetooth + car Wi-Fi), wired (USB), or HeadunitPad or Open Headunit (on the Galaxy hotspot) projection."""
if connection not in identity_store.CONNECTIONS:
raise RuntimeError(f"Unknown connection {connection!r}")
if connection == "wired" and (reason := wired_blocker()):
raise RuntimeError(reason)
@@ -481,20 +501,39 @@ class Supervisor:
bluez.acquire_pairing()
elif self.config.get('phone_class', True):
bluez.acquire()
self._end_pairing("reopened")
with self._lock:
self._pairing_known = known
self._pairing_until = time.monotonic() + seconds
self._pairing_attempt, self._pairing_seen = PairingAttempt(seconds), ()
self.log("pairing_window", seconds=seconds)
def pair_device(self, address: str) -> None:
if self._source_session is None or self._shared_bluetooth_owner is None or not self._pairing_active():
raise RuntimeError('Current Galaxy pairing authorization required')
self._pairing_step("pair_requested")
self._shared_bluetooth_owner.pair_device(self._source_session, address)
def pairing_status(self) -> dict:
if self._source_session is None or self._shared_bluetooth_owner is None:
return {'active': False, 'receiver': None, 'prompt': None, 'approved': False}
return self._shared_bluetooth_owner.incoming_status(self._source_session)
status = self._shared_bluetooth_owner.incoming_status(self._source_session)
seen = (status.get('state'), bool(status.get('prompt')), bool(status.get('approved')), status.get('error') or '')
if seen != getattr(self, '_pairing_seen', ()):
self._pairing_seen = seen
self._pairing_step("status", state=seen[0], prompt=seen[1], approved=seen[2], **({'error': seen[3]} if seen[3] else {}))
return status
def _pairing_step(self, name: str, **values) -> None:
attempt = getattr(self, '_pairing_attempt', None)
if attempt is not None:
attempt.step(name, **values)
def _end_pairing(self, result: str, **values) -> None:
with self._lock:
attempt, self._pairing_attempt = getattr(self, '_pairing_attempt', None), None
if attempt is not None:
attempt.finish(result, **values)
def pairing_response(self, prompt_id: str, accepted: bool, value: str = '') -> None:
if not isinstance(prompt_id, str) or len(prompt_id) != 32 or type(accepted) is not bool or not isinstance(value, str):
@@ -502,8 +541,10 @@ class Supervisor:
if self._source_session is None or self._shared_bluetooth_owner is None or not self._pairing_active() or \
not self._shared_bluetooth_owner.incoming_response(self._source_session, prompt_id, accepted, value):
raise RuntimeError('Pairing prompt expired or changed')
self._pairing_step("prompt_answered", accepted=accepted)
def cancel_pairing(self) -> None:
self._end_pairing("cancelled")
with self._lock:
self._pairing_until, self._pairing_known = 0.0, None
phone = self._bluez if not self._session_alive() else None
@@ -541,6 +582,7 @@ class Supervisor:
def maintain(self, now: float | None = None) -> None:
"""Periodic housekeeping from the daemon thread: pairing window, auto-connect."""
now = time.monotonic() if now is None else now
self.recover_wifi(now)
owner = self._shared_bluetooth_owner
if not self._projection_enabled():
self._set(error='Android Auto was disabled')
@@ -566,6 +608,7 @@ class Supervisor:
self.stop(timeout=0.0, graceful=0.0)
else:
self._pairing_until, self._pairing_known = 0.0, None
self._end_pairing("interrupted", reason="Bluetooth lease released")
self._bluez.close()
self._bluez = None
return
@@ -577,6 +620,7 @@ class Supervisor:
self._bluez = None
self._source_session = None
self._pairing_until, self._pairing_known = 0.0, None
self._end_pairing("interrupted", reason="Galaxy session ended")
if old_phone is not None:
old_phone.close()
if self._pairing_until:
@@ -585,6 +629,7 @@ class Supervisor:
else:
with self._lock:
self._pairing_until, self._pairing_known = 0.0, None
self._end_pairing("expired")
if not self._session_alive():
self._release_phone()
self._auto_connect(now)
@@ -671,12 +716,18 @@ class Supervisor:
self._shared_bluetooth_owner.phone_lease.receiver == device["address"])]
if not new:
return
with self._lock:
attempt, self._pairing_attempt = getattr(self, '_pairing_attempt', None), None
try:
self.cancel_pairing()
self.select_receiver(new[0]["address"], new[0]["name"])
self.log("car_selected_after_pairing", car=new[0]["name"])
if attempt is not None:
attempt.finish("paired")
except (RuntimeError, ValueError) as error:
self.log("car_select_failed", error=str(error))
if attempt is not None:
attempt.finish("select_failed", error=str(error))
def close(self) -> None:
self.stop()
@@ -723,6 +774,10 @@ class Supervisor:
return bool(receiver) and (address.upper() == receiver.upper() or
self._shared_bluetooth_owner is not None and self._shared_bluetooth_owner.companion_accepts(address))
def phone_window(self, operation: str, session: tuple, **arguments):
owner = self._shared_bluetooth_owner
return None if owner is None else owner.phone_window(operation, session, **arguments)
def companion_bluetooth_action(self, operation: str, address: str, session: tuple):
owner = self._shared_bluetooth_owner
if owner is None:
@@ -799,10 +854,27 @@ class Supervisor:
identity_store.save_config(self.config)
def _lease(self):
if self._lease_factory is None:
factory = self._lease_factory
if factory is None:
from openpilot.starpilot.system.android_auto.network import NetworkLease
return NetworkLease(self.log, self.config["wifi_interface"])
return self._lease_factory(self.log, self.config["wifi_interface"])
factory = NetworkLease
if self.config["dedicated_wifi_interface"]:
from openpilot.starpilot.system.android_auto.car_interface import DedicatedLease
strikes = self._dedicated_strikes.get(self.config["receiver_address"], 0)
if strikes >= DEDICATED_STRIKES:
self.log("wifi_dedicated_skipped", strikes=strikes)
lease = DedicatedLease(self.log, self.config["wifi_interface"], factory, enabled=strikes < DEDICATED_STRIKES)
lease.prepare()
return lease
return factory(self.log, self.config["wifi_interface"])
def _count_dedicated(self, lease) -> None:
"""A car whose network keeps failing on aa0 but joining on wlan0 stays on wlan0 until the daemon restarts."""
address = self.config["receiver_address"]
if getattr(lease, "aa0_joined", False):
self._dedicated_strikes.pop(address, None)
elif getattr(lease, "aa0_failed", False):
self._dedicated_strikes[address] = self._dedicated_strikes.get(address, 0) + 1
def _unselect_car_audio(self, address: str) -> None:
if self._bt_client is None:
@@ -867,14 +939,21 @@ class Supervisor:
def _run(self, generation: int, trigger: str = "manual") -> None:
self.log.open()
wired = self.config["connection"] == "wired"
self.log("session_start", receiver="usb" if wired else self.config["receiver_name"], generation=generation, trigger=trigger)
lease = NoLease() if wired else self._lease()
connection = self.config["connection"]
wireless = connection == "wireless"
receiver = {"wired": "usb", "headunitpad": "headunitpad", "openheadunit": "openheadunit"}.get(connection, self.config["receiver_name"])
from openpilot.starpilot.system.android_auto.bugsink_transport import device_metadata
software = device_metadata()
self.log("session_start", receiver=receiver, generation=generation, trigger=trigger,
software={key: software.get(key, "unknown") for key in ("commit", "branch")},
settings={key: self.config.get(key) for key in REPORTED_SETTINGS})
lease = self._lease() if wireless else NoLease()
attempt = 0
try:
while not self._stop.is_set():
started = time.monotonic()
peer_stopped = False
self.log("attempt_start")
try:
self._attempt(lease)
self.log("session_ended")
@@ -905,7 +984,7 @@ class Supervisor:
delay = max(delay, PEER_STOP_RETRY_SECONDS) # the car ended projection itself; do not bounce straight back
self._set(state="backoff", attempt=attempt, retry_in=delay, mode=None)
try:
self._wait_backoff(delay, car_can_wake=not wired and not peer_stopped)
self._wait_backoff(delay, car_can_wake=wireless and not peer_stopped)
except Cancelled:
break
finally:
@@ -914,6 +993,8 @@ class Supervisor:
lease.release(restore=True)
except Exception as error:
self.log("wifi_release_failed", error=str(error))
self._count_dedicated(lease)
self._wifi_recover_at = 0.0 # if the restore above failed, housekeeping retries it
try:
self._release_phone()
except Exception as error:
@@ -935,6 +1016,9 @@ class Supervisor:
if self.config["connection"] == "wired":
self._attempt_usb()
return
if self.config["connection"] in ("headunitpad", "openheadunit"):
self._attempt_headunitpad()
return
config = self.config
address = config["receiver_address"]
ident = identity_store.load_identity()
@@ -1038,6 +1122,41 @@ class Supervisor:
finally:
self._release_usb(gadget, bridge, listener, failed)
def _attempt_headunitpad(self) -> None:
"""HeadunitPad or Open Headunit: the tablet, on the Galaxy hotspot or the same LAN, finds this port and connects like a head unit."""
ident = identity_store.load_identity()
headunitpad = self.config["connection"] == "headunitpad"
port = int(self.config["headunitpad_port"])
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self._track(listener)
try:
listener.bind(("", port))
except OSError as error:
raise RuntimeError(f"Cannot listen on port {port} ({error})") from error
listener.listen(1)
listener.settimeout(1.0)
self._stage("waiting_for_ipad" if headunitpad else "waiting_for_tablet", f"port {port}")
sock = self._accept_headunitpad(listener)
listener.close()
self._project(None, NoLease(), ident, connect=lambda: sock, fragment_acks=headunitpad)
def _accept_headunitpad(self, listener: socket.socket) -> socket.socket:
while True:
self._check_cancel()
try:
sock, (address, _) = listener.accept()
except TimeoutError:
continue
if not ipaddress.ip_address(address).is_private:
self.log("headunitpad_rejected", address=address)
sock.close()
continue
sock.settimeout(None)
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
self.log("headunitpad_connected", address=address)
return self._track(sock)
def _release_usb(self, gadget, bridge, listener, failed: bool) -> None:
"""Undo a wired attempt, running every step even when one fails.
@@ -1060,6 +1179,19 @@ class Supervisor:
if errors and not failed:
raise errors[0]
def recover_wifi(self, now: float | None = None) -> None:
"""Turn wlan0 autoconnect back on if a session paused it and crashed. Retried while idle until it works."""
now = time.monotonic() if now is None else now
if now < self._wifi_recover_at or self._session_alive():
return
from openpilot.starpilot.system.android_auto.network import recover_autoconnect
try:
done = recover_autoconnect(self.log)
except Exception as error:
self.log("wifi_autoconnect_recover_failed", error=str(error))
done = False
self._wifi_recover_at = float("inf") if done else now + WIFI_RECOVER_RETRY_S
def recover_usb(self) -> None:
"""At startup: undo a wired session a crash interrupted, so the car stops seeing a dead accessory
and ADB comes back if it is on. Nothing happens unless our gadget was left holding the USB port."""
@@ -1139,11 +1271,12 @@ class Supervisor:
self._wait(1.0 + attempt * 0.5)
raise RuntimeError(f"Car did not accept the projection connection: {last_error}")
def _project(self, result, lease, ident, connect=None) -> None:
def _project(self, result, lease, ident, connect=None, fragment_acks: bool = False) -> None:
config = self.config
sock = connect() if connect is not None else self._connect_tcp(result, lease)
ca = ident.root if config.get("verify_head_unit", False) else None
session = ProjectionSession(sock, ident.cert, ident.key, self.log, ca)
session.fragment_acks = fragment_acks
self._stage("authenticating")
session.authenticate()
self._stage("negotiating")
@@ -1170,7 +1303,8 @@ class Supervisor:
flags = (FLAG_NV12 if config["gpu_nv12"] and getattr(encoder, "supports_nv12", False) else 0) | \
(FLAG_ASYNC_READBACK if config["async_readback"] else 0)
request = FrameRequest(mode.width, mode.height, mode.margin_width, mode.margin_height, int(interval * 1e6), flags)
self.log("frame_pipeline", nv12=bool(flags & FLAG_NV12), async_readback=bool(flags & FLAG_ASYNC_READBACK))
self.log("frame_pipeline", nv12=bool(flags & FLAG_NV12), async_readback=bool(flags & FLAG_ASYNC_READBACK),
target_fps=fps, encoder=getattr(encoder, "backend", "libx264"))
view = config["view"]
if view == "car" and not self._synthetic and self._renderer_command is None and not renderer_available():
view = "unavailable"
@@ -1180,9 +1314,27 @@ 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()
try:
from openpilot.starpilot.system.android_auto.media_audio import MediaAudio, music_control
from openpilot.starpilot.system.android_auto.media_status import MediaStatus
session.media_control = music_control
session.audio = MediaAudio.create(session, self.log)
if session.audio is not None:
session.audio.track_info = MediaStatus.create(session, self.log)
except Exception as error: # music is optional; projection must never depend on it
self.log("media_audio_unavailable", error=str(error))
try:
from openpilot.starpilot.system.android_auto.nav_status import NavStatus
session.nav = NavStatus.create(session, self.log)
except Exception as error: # turn-by-turn is optional; projection must never depend on it
self.log("nav_status_unavailable", error=str(error))
try:
self._stream(session, encoder, source, watch, interval)
finally:
if session.audio is not None:
session.audio.close()
if getattr(session, "nav", None) is not None:
session.nav.close()
watch.stop()
source.close()
encoder.close()
@@ -1206,6 +1358,8 @@ class Supervisor:
recoveries: list[float] = [] # when the hardware encoder was reopened, this session
encode_peak = 0.0
wait_for_frame = False
audio = getattr(session, "audio", None)
nav = getattr(session, "nav", None)
self._stage("streaming")
while not self._stop.is_set():
pass_started = time.monotonic()
@@ -1221,6 +1375,24 @@ class Supervisor:
break
handled = session.pump(0.0)
session.check_progress()
if audio is not None:
try:
audio.service() # non-blocking: drains the Spotify pipe and sends within the car's audio credit
except (OSError, TimeoutError):
raise # the link itself failed; video would fail the same way
except Exception as error:
self.log("media_audio_failed", error=f"{type(error).__name__}: {error}")
audio.close()
audio = session.audio = None
if nav is not None:
try:
nav.service() # non-blocking: compares the route's next turn and sends at most a few small messages
except (OSError, TimeoutError):
raise
except Exception as error:
self.log("nav_status_failed", error=f"{type(error).__name__}: {error}")
nav.close()
nav = session.nav = None
now = time.monotonic()
wait_for_frame = True
if session.focused:
@@ -1274,7 +1446,8 @@ class Supervisor:
self._set(view=source.label)
window = [t for t in sent_times if now - t <= 5.0]
ordered = sorted(ages)
stats = {**session.stats(), "fps": round(len(window) / 5.0, 1), "encode_ms": round(encoder.last_encode_ms, 1),
audio_stats = {"media_audio": audio.status()} if audio is not None else {}
stats = {**session.stats(), **audio_stats, "fps": round(len(window) / 5.0, 1), "encode_ms": round(encoder.last_encode_ms, 1),
"encode_peak_ms": round(encode_peak, 1), "encoder_recoveries": len(recoveries),
"frame_age_p95_ms": round(ordered[int(len(ordered) * 0.95) - 1] * 1000) if ordered else None,
"uptime_s": round(now - started), "frames_from_view": source.frames, "wifi_dbm": watch.wifi_dbm}
@@ -69,7 +69,7 @@ def commands(since: float) -> dict[str, list[str]]:
"journal_kernel.log": [*journal, "-k"],
"dmesg.log": ["dmesg", "-T"],
"processes.txt": ["ps", "-eo", "pid,psr,ni,pcpu,pmem,rss,etime,stat,comm", "--sort=-pcpu"],
"wifi_link.txt": ["iw", "dev", "wlan0", "link"],
"wifi_link.txt": ["nmcli", "-t", "-f", "DEVICE,ACTIVE,CHAN,FREQ,SIGNAL", "device", "wifi", "list", "--rescan", "no"],
"network_devices.txt": ["nmcli", "-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device"],
"disk.txt": ["df", "-h", "/data"],
"build.txt": ["git", "-C", basedir, "log", "-1", "--format=%H%n%D%n%ci%n%s"],
@@ -11,6 +11,7 @@ import socket
import ssl
import struct
import threading
import time
from pathlib import Path
from openpilot.starpilot.system.android_auto import bootstrap as bs
@@ -74,8 +75,12 @@ class FakeHeadUnit:
"""Car side of one AA TCP session. Runs in a thread; records what the phone sent."""
def __init__(self, identity: dict[str, Path], *, window: int = 4, reject_auth: bool = False, require_client_cert: bool = True,
unsolicited_focus: bool = False, version: tuple[int, int] = (1, 7), ack_codec_config: bool | int = True):
unsolicited_focus: bool = False, version: tuple[int, int] = (1, 7), ack_codec_config: bool | int = True,
connect_to: tuple[str, int] | None = None, ack_fragments: bool = False):
self.unsolicited_focus = unsolicited_focus
self.connect_to = connect_to
self.ack_fragments = ack_fragments
self.fragments = 0
self.ack_codec_config = ack_codec_config
self.codec_configs: list[tuple[int | None, bytes, int]] = [] # (session, SPS/PPS, frames received before it)
self.version = version
@@ -123,7 +128,9 @@ class FakeHeadUnit:
def _receive(self) -> tuple[int, int, bytes]:
assembled = bytearray()
self.fragments = 0
while True:
self.fragments += 1
channel, flags, size = struct.unpack(">BBH", self._read_exact(4))
if flags & 3 == 1:
self._read_exact(4)
@@ -143,8 +150,11 @@ class FakeHeadUnit:
def _run(self) -> None:
try:
self.listener.settimeout(20)
self.sock, _ = self.listener.accept()
if self.connect_to is not None:
self.sock = self._dial(self.connect_to)
else:
self.listener.settimeout(20)
self.sock, _ = self.listener.accept()
self.sock.settimeout(20)
self._session()
except BaseException as error: # surfaced to the test
@@ -155,6 +165,20 @@ class FakeHeadUnit:
self.sock.close()
self.listener.close()
def _dial(self, address: tuple[str, int]) -> socket.socket:
deadline = time.monotonic() + 20
while True:
try:
return socket.create_connection(address, timeout=1)
except OSError:
if self.stop.is_set() or time.monotonic() > deadline:
raise
time.sleep(0.05)
def _ack(self, session_id: int) -> None:
for _ in range(self.fragments if self.ack_fragments else 1):
self._send(3, 0x8004, field(1, session_id) + field(2, 1))
def _session(self) -> None:
self._send(0, 1, struct.pack(">HH", *self.version), encrypted=False)
channel, kind, data = self._receive()
@@ -228,14 +252,14 @@ class FakeHeadUnit:
if self.ack_codec_config is not False: # True: current session; an int: that session id (the DHU uses 0)
ack_session = session_id if self.ack_codec_config is True else self.ack_codec_config
assert isinstance(ack_session, int)
self._send(3, 0x8004, field(1, ack_session) + field(2, 1))
self._ack(ack_session)
elif channel == 3 and kind == 0:
assert isinstance(session_id, int)
self.frames.append((session_id, data[8:]))
unacked += 1
self.streaming.set()
if not self.hold_acks.is_set():
self._send(3, 0x8004, field(1, session_id) + field(2, 1))
self._ack(session_id)
unacked -= 1
elif channel == 1 and kind == 0x8002:
self._send(1, 0x8003, field(1, 0))
@@ -1,4 +1,5 @@
from unittest.mock import Mock
import json
import socket
import ssl
import struct
@@ -785,6 +786,7 @@ def test_supervisor_full_wireless_session(identity, tmp_path, monkeypatch):
sup = supervisor_module.Supervisor(bluez_factory=FakeBluez, lease_factory=FakeLease,
bluetooth_client=type("C", (), {"status": lambda s: type("St", (), {"selected_audio": ""})()})(),
frame_path=frame_path)
sup.config["dedicated_wifi_interface"] = False # this session uses the fake wlan0 lease
sup.select_receiver("AA:BB:CC:DD:EE:01", "Civic")
lease_holder = {}
original_lease = sup._lease
@@ -850,6 +852,7 @@ def make_supervisor(identity, tmp_path, monkeypatch, rfcomm):
client = type("C", (), {"status": lambda s: type("St", (), {"selected_audio": ""})()})()
sup = supervisor_module.Supervisor(bluez_factory=FakeBluez, lease_factory=lease_factory, bluetooth_client=client,
frame_path=str(tmp_path / "frames"))
sup.config["dedicated_wifi_interface"] = False # these sessions use the fake wlan0 lease
sup.select_receiver("AA:BB:CC:DD:EE:01", "Civic")
return sup, leases
@@ -929,6 +932,7 @@ class FakeNetworkManager:
self.states = {}
self.added = []
self.calls = []
self.autoconnect = True
def call(self, path, interface, member, signature=None, body: tuple=(), timeout=10.0):
from openpilot.starpilot.system.android_auto import network
@@ -951,7 +955,8 @@ class FakeNetworkManager:
def get(self, path, interface, name):
values = {"WirelessEnabled": True, "DeviceType": 2, "Interface": "wlan0", "ActiveConnection": self.active_device_connection,
"Connection": "/settings/home", "Ip4Config": "/ip4/1"}
"Connection": "/settings/home", "Ip4Config": "/ip4/1", "Autoconnect": self.autoconnect,
"ActiveAccessPoint": "/ap/1", "Frequency": 5745}
if name == "State":
return self.states.get(path, 4)
if name == "AddressData":
@@ -975,7 +980,12 @@ def make_lease(nm):
reopen()
return nm.get(*args, **kwargs)
lease._call, lease._get = Mock(side_effect=call), Mock(side_effect=get)
def set_property(path, interface, name, signature, value):
reopen()
nm.calls.append(f"Set{name}={value}")
nm.autoconnect = value
lease._call, lease._get, lease._set = Mock(side_effect=call), Mock(side_effect=get), Mock(side_effect=set_property)
lease._delete_stale_profiles = Mock(side_effect=lambda: None)
return lease
@@ -1026,6 +1036,21 @@ def test_network_lease_does_not_undo_user_network_change():
assert "ActivateConnection" not in nm.calls
def test_network_lease_session_stop_after_a_failed_attempt_restores_home_wifi():
nm = FakeNetworkManager()
lease = make_lease(nm)
lease.acquire(credentials(), timeout=2.0)
assert "SetAutoconnect=False" in nm.calls # home Wi-Fi does not rejoin between attempts
lease.release(restore=False)
nm.active_device_connection = "/" # NetworkManager left wlan0 disconnected
assert lease.router is None and not nm.autoconnect
lease.release(restore=True) # the session's last attempt failed; D-Bus is already closed
assert nm.calls[-2:] == ["SetAutoconnect=True", "ActivateConnection"] and nm.autoconnect
calls = len(nm.calls)
lease.release(restore=True)
assert len(nm.calls) == calls
def test_network_lease_retry_keeps_previous_for_final_stop():
nm = FakeNetworkManager()
lease = make_lease(nm)
@@ -1035,3 +1060,96 @@ def test_network_lease_retry_keeps_previous_for_final_stop():
lease.acquire(credentials(), timeout=2.0)
lease.release(restore=True)
assert nm.calls.count("ActivateConnection") == 1
def test_paused_autoconnect_leaves_a_marker_until_restored(tmp_path):
nm = FakeNetworkManager()
lease = make_lease(nm)
lease.marker = tmp_path / "paused.json"
lease.acquire(credentials(), timeout=2.0)
assert lease.marker.exists() and json.loads(lease.marker.read_text())["interface"] == "wlan0"
lease.release(restore=True)
assert not lease.marker.exists() and nm.autoconnect
def test_next_session_takes_over_a_pause_a_crash_left_behind(tmp_path):
nm = FakeNetworkManager()
crashed = make_lease(nm)
crashed.marker = tmp_path / "paused.json"
crashed.acquire(credentials(), timeout=2.0) # the daemon dies here: no release
assert not nm.autoconnect
lease = make_lease(nm)
lease.marker = crashed.marker
lease.acquire(credentials(), timeout=2.0)
assert lease.autoconnect_paused
lease.release(restore=True)
assert nm.autoconnect and not lease.marker.exists()
def test_recover_autoconnect_after_a_crash(tmp_path, monkeypatch):
from openpilot.starpilot.system.android_auto import network
marker = tmp_path / "paused.json"
log, sets = [], []
monkeypatch.setattr(network.NetworkLease, "_wifi_devices", lambda self: [("/dev/wlan0", "wlan0")])
monkeypatch.setattr(network.NetworkLease, "_set", lambda self, *args: sets.append(args))
assert network.recover_autoconnect(lambda event, **fields: log.append(event), marker) # nothing to do
marker.write_text(json.dumps({"interface": "wlan0", "boot": network._boot_id()}))
assert network.recover_autoconnect(lambda event, **fields: log.append(event), marker)
assert sets == [("/dev/wlan0", network.NM_DEVICE_IFACE, "Autoconnect", "b", True)] and not marker.exists()
assert log == ["wifi_autoconnect_recovered"]
marker.write_text(json.dumps({"interface": "wlan0", "boot": "an earlier boot"}))
assert network.recover_autoconnect(lambda event, **fields: None, marker) # a reboot already reset it
assert len(sets) == 1 and not marker.exists()
def test_recover_autoconnect_keeps_the_marker_when_networkmanager_fails(tmp_path, monkeypatch):
from openpilot.starpilot.system.android_auto import network
marker = tmp_path / "paused.json"
marker.write_text(json.dumps({"interface": "wlan0", "boot": network._boot_id()}))
def unavailable(self):
raise network.NetworkError("NetworkManager is not running")
monkeypatch.setattr(network.NetworkLease, "_wifi_devices", unavailable)
assert not network.recover_autoconnect(lambda event, **fields: None, marker)
assert marker.exists()
def test_recover_autoconnect_waits_for_missing_wifi_device(tmp_path, monkeypatch):
from openpilot.starpilot.system.android_auto import network
marker = tmp_path / "paused.json"
marker.write_text(json.dumps({"interface": "wlan0", "boot": network._boot_id()}))
monkeypatch.setattr(network.NetworkLease, "_wifi_devices", lambda self: [])
assert not network.recover_autoconnect(lambda event, **fields: None, marker)
assert marker.exists()
def test_autoconnect_stays_on_when_recovery_marker_cannot_be_written(tmp_path, monkeypatch):
from openpilot.starpilot.system.android_auto import network
lease = network.NetworkLease(lambda *args, **kwargs: None)
lease.device_path = "/dev/wlan0"
lease.marker = tmp_path / "missing" / "paused.json"
monkeypatch.setattr(lease, "_get", lambda *args: True)
monkeypatch.setattr(lease, "_write_marker", lambda: False)
changes = []
monkeypatch.setattr(lease, "_set", lambda *args: changes.append(args))
lease._pause_autoconnect()
assert not lease.autoconnect_paused and changes == []
def test_supervisor_retries_wifi_recovery_while_idle(monkeypatch):
from openpilot.starpilot.system.android_auto import network, supervisor
results = [False, True]
calls = []
monkeypatch.setattr(network, "recover_autoconnect", lambda log: calls.append(1) or results.pop(0))
sup = supervisor.Supervisor.__new__(supervisor.Supervisor)
sup.log, sup._wifi_recover_at = (lambda *a, **k: None), 0.0 # ty: ignore[invalid-assignment] # test double
sup._session_alive = lambda: False # ty: ignore[invalid-assignment] # test double
sup.recover_wifi(100)
sup.recover_wifi(110) # waits before retrying
sup.recover_wifi(100 + supervisor.WIFI_RECOVER_RETRY_S)
sup.recover_wifi(1000) # done
assert len(calls) == 2
sup._wifi_recover_at = 0.0
sup._session_alive = lambda: True # ty: ignore[invalid-assignment] # test double
sup.recover_wifi(2000) # never while a session owns wlan0
assert len(calls) == 2
@@ -279,12 +279,15 @@ def test_config_v4_resets_head_unit_verification(tmp_path):
assert identity_store.load_config(path)["verify_head_unit"] is True
def test_config_v5_resets_dedicated_wifi_default(tmp_path):
def test_config_v5_turns_on_staying_on_home_wifi(tmp_path):
from openpilot.starpilot.system.android_auto import identity as identity_store
path = tmp_path / "config.json"
# Every v5 save wrote the old default (off); it was rarely chosen.
path.write_text(json.dumps({"config_version": 5, "dedicated_wifi_interface": False}))
assert identity_store.load_config(path)["dedicated_wifi_interface"] is True
path.write_text(json.dumps({"config_version": 6, "dedicated_wifi_interface": False}))
config = identity_store.load_config(path)
assert config["dedicated_wifi_interface"] is True
identity_store.save_config({**config, "dedicated_wifi_interface": False}, path)
# From v6 on it is a real choice and stays as saved.
assert identity_store.load_config(path)["dedicated_wifi_interface"] is False
@@ -92,6 +92,8 @@ def test_phone_role_temporarily_changes_adapter_and_restores(tmp_path):
shared.request('scan')
with pytest.raises(BluetoothRejected, match='Android Auto pairing operation'):
shared.request('pair', address='AA:BB:CC:DD:EE:FF', session=('old', 'session'))
with pytest.raises(BluetoothRejected, match='Android Auto pairing operation'):
shared.request('accept_pair', session=state['session'])
lease.release()
lease.release()
assert bluez.writes == [(True, True), (False, False)] and phone.released == 1 and bluez.agent_closed == 1
@@ -0,0 +1,327 @@
"""Android Auto drive and pairing reports for Bugsink, built from session logs without a network."""
import json
import os
from datetime import UTC, datetime
from openpilot.starpilot.system.android_auto import bugsink_report as br
def write_session(log_dir, number, events, finished=True):
log_dir.mkdir(parents=True, exist_ok=True)
path = log_dir / f"session-{number:06d}-20261016-120000.jsonl"
lines = [{"t": f"2026-10-16T12:{minute:02d}:{second:02d}.000+00:00", "event": name, **values}
for minute, second, name, values in events]
if finished:
lines.append({"t": "2026-10-16T12:59:00.000+00:00", "event": "session_stop"})
path.write_text("".join(json.dumps(line) + "\n" for line in lines))
return path
STREAMED = [
(0, 0, "session_start", {"receiver": "Honda HFL", "trigger": "auto"}),
(0, 1, "stage", {"state": "connecting_bluetooth"}),
(0, 3, "stage", {"state": "rfcomm"}),
(0, 6, "stage", {"state": "joining_wifi"}),
(0, 9, "stage", {"state": "authenticating"}),
(0, 10, "discovered", {"head_unit": {"2": ["Honda"], "3": ["Civic"], "4": ["2022"], "7": ["Alpine"], "8": ["HU-1"],
"14": ["Danny's Civic"]}, "channels": []}),
(0, 11, "projection_ready", {"mode": {"width": 1280, "height": 720, "fps": 30, "margin_width": 0, "margin_height": 0}}),
(0, 11, "frame_pipeline", {"nv12": True, "async_readback": True, "target_fps": 30, "encoder": "v4l2"}),
(0, 12, "stage", {"state": "streaming"}),
(0, 12, "video_acknowledged", {}),
(0, 42, "stats", {"fps": 30.0, "encode_ms": 6.0, "encode_peak_ms": 9.0, "frame_age_p95_ms": 40, "wifi_dbm": -50,
"focused": True, "frames_sent": 900, "encoder_recoveries": 0}),
(1, 12, "stats", {"fps": 20.0, "encode_ms": 8.0, "encode_peak_ms": 15.0, "frame_age_p95_ms": 90, "wifi_dbm": -70,
"focused": True, "frames_sent": 1500, "encoder_recoveries": 1}),
(1, 42, "stats", {"fps": 0.0, "focused": False}),
]
FAILED = [
(0, 0, "session_start", {"receiver": "Honda HFL", "trigger": "manual"}),
(0, 1, "stage", {"state": "connecting_bluetooth"}),
(0, 4, "stage", {"state": "joining_wifi"}),
(0, 30, "attempt_failed", {"stage": "joining_wifi", "error": "Joining Wi-Fi: no route to 192.168.1.4 from AA:BB:CC:DD:EE:FF",
"kind": "TimeoutError"}),
]
def test_session_record_reports_stages_resolution_and_fps(tmp_path):
record = br.session_record(write_session(tmp_path, 1, STREAMED))
assert record["outcome"] == "streamed" and record["transport"] == "wireless"
assert record["time_to_first_frame_s"] == 12.0
assert [stage["stage"] for stage in record["stages"]] == ["connecting_bluetooth", "rfcomm", "joining_wifi", "authenticating", "streaming"]
assert record["stages"][2]["at_s"] == 6.0
video = record["video"]
assert video["resolution"] == "1280x720" and video["car_fps"] == 30 and video["target_fps"] == 30 and video["encoder"] == "v4l2"
assert video["samples"] == 2 and video["fps_avg"] == 25.0 and video["fps_min"] == 20.0 # the unfocused sample is left out
assert video["encode_peak_ms"] == 15.0 and video["wifi_dbm_min"] == -70 and video["encoder_recoveries"] == 1
assert record["head_unit"] == {"head_unit_make": "Alpine", "head_unit_model": "HU-1", "car_make": "Honda",
"car_model": "Civic", "car_year": "2022"}
def test_drive_event_groups_sessions_by_head_unit_and_weights_fps(tmp_path):
sessions = [br.session_record(write_session(tmp_path, 1, STREAMED)), br.session_record(write_session(tmp_path, 2, FAILED))]
event = br.drive_event(sessions)
assert event["fingerprint"] == ["android-auto-drive", "wireless", "Alpine HU-1", "failures"]
assert event["message"].startswith("Android Auto failures: Alpine HU-1 (wireless)")
crumbs = event["breadcrumbs"]["values"]
assert [crumb["level"] for crumb in crumbs] == ["info", "error"]
assert crumbs[1]["message"] == "Attempt 1: wifi_join_failed at joining_wifi: TimeoutError" and crumbs[1]["category"] == "session 2"
assert crumbs[1]["timestamp"] == "2026-10-16T12:00:30+00:00"
assert event["level"] == "error" and event["tags"]["aa.outcome"] == "failed"
assert event["tags"]["aa.resolution"] == "1280x720" and event["tags"]["aa.fps"] == "25.0"
drive = event["contexts"]["android_auto_drive"]
assert drive["sessions"] == 2 and drive["failures"] == 1 and drive["fps_avg"] == 25.0 and drive["resolutions"] == ["1280x720"]
assert "_report" not in event["contexts"]["android_auto_session_1"]
assert "Danny's Civic" not in json.dumps(event)
def test_failures_are_part_of_the_drive_report_and_hide_names(tmp_path):
event = br.drive_event([br.session_record(write_session(tmp_path, 1, FAILED))])
assert event["fingerprint"] == ["android-auto-drive", "wireless", "unknown head unit", "failures"]
assert "1. wifi_join_failed at joining_wifi: TimeoutError (session 1, attempt 1)" in event["message"]
assert event["tags"]["aa.cause"] == "wifi_join_failed" and event["tags"]["aa.failures"] == "1"
text = json.dumps(event)
for secret in ("AA:BB:CC:DD:EE:FF", "192.168.1.4", "Honda HFL"):
assert secret not in text
assert "Stages: connecting_bluetooth > joining_wifi" in event["contexts"]["android_auto_compat_report_session_1"]["text"]
def test_send_reports_once_as_one_event_per_batch(tmp_path):
logs, state = tmp_path / "logs", tmp_path / "state.json"
write_session(logs, 1, STREAMED)
for number in range(2, 4):
write_session(logs, number, FAILED)
captured, flushes = [], []
def capture(**event):
captured.append(event)
return True
queued = br.send_reports(capture, lambda: flushes.append(1), state_path=state, log_dir=logs, pairing_path=tmp_path / "p.jsonl")
assert queued == 1 and len(flushes) == 1
assert [event["tags"]["aa.report"] for event in captured] == ["drive"]
assert captured[0]["tags"]["aa.failures"] == "1" # two failures of the same kind are detailed once
assert json.loads(state.read_text())["last_seq"] == 3
assert br.send_reports(capture, state_path=state, log_dir=logs, pairing_path=tmp_path / "p.jsonl") == 0
def test_dropped_stream_breadcrumb_says_it_streamed_first(tmp_path):
session = br.session_record(write_session(tmp_path, 1, STREAMED + [
(2, 0, "attempt_failed", {"stage": "streaming", "kind": "EOFError", "error": "peer closed"}),
]))
crumb = br.drive_event([session])["breadcrumbs"]["values"][0]
assert crumb["level"] == "error"
assert crumb["message"] == "Attempt 1: streamed 108.0s, then projection_interrupted at streaming: EOFError"
def test_session_timeline_and_compat_report_appear_once(tmp_path):
path = write_session(tmp_path / "logs", 1, FAILED + [
(1, 0, "stage", {"state": "authenticating"}),
(1, 20, "attempt_failed", {"stage": "authenticating", "kind": "TimeoutError", "error": "timed out"}),
])
event = br.drive_event([br.session_record(path)])
assert event["message"].count("Attempt timeline:") == 1 and event["message"].count("Next steps:") == 2
assert [key for key in event["contexts"] if key.startswith("android_auto_compat_report")] == ["android_auto_compat_report_session_1"]
assert "compat_report" not in event["contexts"]["android_auto_failure_1"]
def test_clean_drives_keep_their_own_issue(tmp_path):
event = br.drive_event([br.session_record(write_session(tmp_path, 1, STREAMED))])
assert event["fingerprint"] == ["android-auto-drive", "wireless", "Alpine HU-1"]
assert event["message"].startswith("Android Auto sessions: Alpine HU-1 (wireless)")
assert [crumb["level"] for crumb in event["breadcrumbs"]["values"]] == ["info"]
def test_unaccepted_reports_are_retried(tmp_path):
logs, state = tmp_path / "logs", tmp_path / "state.json"
write_session(logs, 1, STREAMED)
assert br.send_reports(lambda **_: False, state_path=state, log_dir=logs, pairing_path=tmp_path / "p.jsonl") == 0
assert not state.exists()
assert br.send_reports(lambda **_: True, state_path=state, log_dir=logs, pairing_path=tmp_path / "p.jsonl") == 1
def test_live_session_waits_until_finished(tmp_path):
logs = tmp_path / "logs"
path = write_session(logs, 1, STREAMED, finished=False)
assert br.new_sessions(logs, 0, datetime.now(UTC).timestamp()) == []
old = datetime.now(UTC).timestamp() - br.LOG_SETTLE_S - 1
os.utime(path, (old, old))
assert len(br.new_sessions(logs, 0, datetime.now(UTC).timestamp())) == 1
def test_pairing_attempts_are_journaled_and_reported(tmp_path):
journal = tmp_path / "pairing.jsonl"
clock = iter([0.0, 0.0, 4.0, 9.5, 12.0, 12.0])
attempt = br.PairingAttempt(180.0, clock=lambda: next(clock), sink=lambda record: br.record_pairing(record, journal))
attempt.step("pair_requested")
attempt.step("status", state="paired", prompt=False, approved=True)
attempt.finish("paired")
records = br.new_pairings(0, journal)
assert len(records) == 1 and records[0]["result"] == "paired" and records[0]["duration_s"] == 12.0
assert [step["step"] for step in records[0]["steps"]] == ["opened", "pair_requested", "status", "paired"]
captured = []
assert br.send_reports(lambda **event: captured.append(event) or True, state_path=tmp_path / "s.json",
log_dir=tmp_path / "logs", pairing_path=journal) == 1
assert captured[0]["fingerprint"] == ["android-auto-pairing", "paired"] and captured[0]["level"] == "info"
assert br.new_pairings(json.loads((tmp_path / "s.json").read_text())["pairing_seq"], journal) == []
def test_pairing_journal_stays_bounded(tmp_path):
journal = tmp_path / "pairing.jsonl"
for index in range(400):
br.record_pairing({"result": "expired", "steps": [{"step": "opened", "at_s": 0}] * 4, "index": index}, journal)
assert journal.stat().st_size <= br.PAIRING_MAX_BYTES
assert br.new_pairings(0, journal)[-1]["seq"] == 400
def test_recovered_attempts_still_report_the_original_failure(tmp_path):
path = write_session(tmp_path / "logs", 1, FAILED + [(1, 0, "attempt_start", {})] + STREAMED[1:])
session = br.session_record(path)
assert session["outcome"] == "recovered"
assert len(session["attempts"]) == 2
events = []
br.send_reports(lambda **e: events.append(e) or True, state_path=tmp_path / "state.json", log_dir=path.parent,
pairing_path=tmp_path / "pairing.jsonl")
assert len(events) == 1
diagnosis = events[0]["contexts"]["android_auto_failure_1"]["diagnosis"]
assert diagnosis["recovered"] is True and diagnosis["confidence"] == "suspected"
def test_shutdown_does_not_turn_later_retries_into_stream_drops(tmp_path):
session = br.session_record(write_session(tmp_path, 1, STREAMED + [
(2, 0, "session_ended", {"reason": "Head unit ended projection: shutdown"}),
(2, 2, "stage", {"state": "connecting_bluetooth"}),
(2, 10, "attempt_failed", {"stage": "connecting_bluetooth", "kind": "OSError", "error": "Host is down"}),
]))
assert session["outcome"] == "streamed" and session["ended_by_car"]
assert session["failures"] == session["drops"] == 0
assert session["streamed_s"] == 108
assert session["attempts"][-1]["after_car_shutdown"]
def test_clock_correction_uses_monotonic_order_and_durations(tmp_path):
path = write_session(tmp_path, 1, STREAMED)
events = [json.loads(line) for line in path.read_text().splitlines()]
for i, event in enumerate(events):
event["mono_s"] = 20.0 + i
event["t"] = "2026-07-01T00:00:00+00:00" if i < 5 else "2026-10-19T00:00:00+00:00"
path.write_text("".join(json.dumps(event) + "\n" for event in events))
session = br.session_record(path)
assert session["time_to_first_frame_s"] == 9
assert session["streamed_s"] == 4
assert session["timing_source"] == "monotonic"
def test_distinct_failures_within_one_session_are_actionable(tmp_path):
path = write_session(tmp_path / "logs", 1, FAILED + [
(1, 0, "stage", {"state": "authenticating"}),
(1, 20, "attempt_failed", {"stage": "authenticating", "kind": "TimeoutError", "error": "timed out"}),
])
events = []
br.send_reports(lambda **e: events.append(e) or True, state_path=tmp_path / "state.json", log_dir=path.parent,
pairing_path=tmp_path / "pairing.jsonl")
assert len(events) == 1
causes = {context["diagnosis"]["category"] for key, context in events[0]["contexts"].items() if key.startswith("android_auto_failure_")}
assert causes == {"wifi_join_failed", "authentication_incomplete"}
def test_nested_pairing_and_failure_fields_are_scrubbed(tmp_path):
event = br.pairing_event({"result": "failed", "steps": [{"error": "peer fe80::1234 password=secret ssid='My Car'"}]})
assert "fe80::1234" not in json.dumps(event) and "secret" not in json.dumps(event) and "My Car" not in json.dumps(event)
path = write_session(tmp_path, 1, FAILED + [(0, 31, "attempt_failed", {
"stage": "joining_wifi", "kind": "OSError", "error": "Honda HFL password=secret",
})])
event = br.drive_event([br.session_record(path)])
assert "Honda HFL" not in json.dumps(event) and "secret" not in json.dumps(event)
def test_unmanaged_is_observed_but_scan_contention_is_only_suspected(tmp_path):
session = br.session_record(write_session(tmp_path, 1, FAILED))
session["error"] = "wlan0 is strictly unmanaged"
observed = br.diagnosis(session)
assert observed["category"] == "wifi_not_ready" and observed["confidence"] == "observed"
session["error"] = "Wi-Fi join timed out"
suspected = br.diagnosis(session)
assert suspected["category"] == "wifi_join_failed" and suspected["confidence"] == "suspected"
def test_companion_tablet_modes_keep_their_transport(tmp_path):
for index, transport in enumerate(("headunitpad", "openheadunit"), 1):
events = [(0, 0, "session_start", {"receiver": transport})] + FAILED[1:]
session = br.session_record(write_session(tmp_path, index, events))
assert session["transport"] == transport
assert br.drive_event([session])["tags"]["aa.transport"] == transport
def test_readable_summary_uses_session_commit_and_wired_connection(tmp_path):
events = [(0, 0, "session_start", {"receiver": "usb", "software": {"commit": "abc123-session", "branch": "session-branch"}})] + FAILED[1:]
session = br.session_record(write_session(tmp_path, 1, events))
event = br.drive_event([session])
assert "Connection: Wired USB" in event["message"]
assert "Session commit: abc123-session" in event["message"]
assert "Branch: session-branch" in event["message"]
assert "Attempt timeline:" in event["message"] and "Next steps:" in event["message"]
assert event["tags"]["commit"] == "abc123-session"
def test_legacy_summary_does_not_guess_commit(tmp_path):
event = br.drive_event([br.session_record(write_session(tmp_path, 1, FAILED))])
assert "Connection: Wireless" in event["message"]
assert "Session commit: unknown" in event["message"]
assert event["tags"]["commit"] == "unknown"
def test_batch_with_different_builds_and_connections_is_explicit(tmp_path):
sessions = []
for number, receiver, commit in ((1, "usb", "old-build"), (2, "Car", "new-build")):
events = [(0, 0, "session_start", {"receiver": receiver, "software": {"commit": commit}})] + FAILED[1:]
sessions.append(br.session_record(write_session(tmp_path, number, events)))
event = br.drive_event(sessions)
assert event["tags"]["aa.transport"] == "mixed" and event["tags"]["commit"] == "mixed"
assert "Session commit: mixed" in event["message"]
assert "Wired USB" in event["message"] and "Wireless" in event["message"]
assert "old-build" in event["message"] and "new-build" in event["message"]
def test_pairing_records_software_when_window_opens(monkeypatch):
from openpilot.starpilot.system.android_auto import bugsink_transport
monkeypatch.setattr(bugsink_transport, "device_metadata", lambda: {"commit": "pairing-build", "branch": "pairing-branch"})
records = []
attempt = br.PairingAttempt(180, sink=records.append)
attempt.finish("paired")
event = br.pairing_event(records[0])
assert "Session commit: pairing-build" in event["message"]
assert "Connection: Wireless" in event["message"]
assert event["tags"]["commit"] == "pairing-build"
def test_supervisor_records_build_at_session_start(monkeypatch):
import threading
from openpilot.starpilot.system.android_auto import supervisor, bugsink_transport
monkeypatch.setattr(bugsink_transport, "device_metadata", lambda: {"commit": "session-build", "branch": "session-branch"})
records = []
class Log:
def open(self):
pass
def close(self):
pass
def __call__(self, name, **values):
records.append({"event": name, **values})
sup = object.__new__(supervisor.Supervisor)
sup.log = Log() # ty: ignore[invalid-assignment] # test double
sup.config = {"connection": "wired", "receiver_name": ""}
sup._stop = threading.Event()
sup._stop.set()
sup._set = lambda **_: None # ty: ignore[invalid-assignment] # test double
sup._count_dedicated = lambda _: None # ty: ignore[invalid-assignment] # test double
sup._release_phone = lambda: None # ty: ignore[invalid-assignment] # test double
sup._run(1)
assert records[0]["software"] == {"commit": "session-build", "branch": "session-branch"}
@@ -0,0 +1,103 @@
import json
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import pytest
from openpilot.starpilot.system.android_auto.bugsink_transport import Sender, configured_sender
@pytest.fixture
def endpoint():
received = []
status = [200]
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
event = json.loads(self.rfile.read(int(self.headers['Content-Length'])))
received.append((self.path, dict(self.headers), event))
self.send_response(status[0])
self.end_headers()
self.wfile.write(json.dumps({'id': event['event_id']}).encode())
def log_message(self, format, *args): # noqa: A002 - Match BaseHTTPRequestHandler.
pass
server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
thread = threading.Thread(target=server.serve_forever)
thread.start()
try:
yield f'http://testkey@127.0.0.1:{server.server_port}/1', received, status
finally:
server.shutdown()
thread.join()
server.server_close()
def test_server_acknowledgement_and_stable_retry_id(endpoint):
dsn, received, status = endpoint
send = Sender(dsn, metadata={'device_id': 'test-device', 'commit': 'test-commit'})
event = {'message': 'Android Auto failed at joining_wifi', 'contexts': {'diagnosis': {'password': 'never-send'}}}
status[0] = 503
assert not send(**event)
status[0] = 200
assert send(**event)
assert len(received) == 2
assert received[0][2]['event_id'] == received[1][2]['event_id']
assert received[1][0] == '/api/1/store/'
assert received[1][2]['user']['id'] == 'test-device'
assert 'never-send' not in json.dumps(received)
def test_default_project_unless_disabled_or_overridden(tmp_path):
path = tmp_path / 'bugsink.json'
sender = configured_sender(path)
assert sender.endpoint == 'https://bugsink.firestar.link/api/3/store/'
path.write_text(json.dumps({'dsn': 'https://key@example.com/7'}))
assert configured_sender(path).endpoint == 'https://example.com/api/7/store/'
path.write_text(json.dumps({'enabled': False}))
assert configured_sender(path) is None
def test_reporting_stops_if_projection_starts(endpoint):
dsn, received, _ = endpoint
assert not Sender(dsn, active=lambda: False)(message='test')
assert received == []
@pytest.mark.parametrize('dsn', ['ftp://key@example.com/1', 'https://example.com/1', 'http://key@example.com/1',
'https://key@example.com/no-project', 'https://key@example.com/1?secret=true'])
def test_invalid_or_insecure_remote_destinations_are_rejected(dsn):
with pytest.raises(ValueError):
Sender(dsn)
def test_session_release_is_not_overwritten_by_uploading_build(endpoint):
dsn, received, _ = endpoint
sender = Sender(dsn, metadata={"device_id": "device", "commit": "uploader-build", "branch": "uploader-branch"})
assert sender(message="Historical failure", tags={"commit": "session-build", "branch": "session-branch"})
event = received[-1][2]
assert event["release"] == event["tags"]["commit"] == "session-build"
assert event["tags"]["reporter.commit"] == "uploader-build"
assert event["tags"]["branch"] == "session-branch"
assert sender(message="Legacy failure", tags={"commit": "unknown"})
assert received[-1][2]["release"] == "unknown"
def test_sender_logger_and_tags_can_be_overridden_for_other_reporters():
from openpilot.starpilot.system.android_auto.bugsink_transport import Sender, DEFAULT_DSN
seen = []
class Response:
status = 200
def __enter__(self): return self
def __exit__(self, *_): return False
def read(self, _): return json.dumps({"id": seen[0]["id"]}).encode()
class Opener:
def open(self, request, timeout):
seen.append({"body": json.loads(request.data), "ua": request.headers["User-agent"]})
seen[0]["id"] = seen[0]["body"]["event_id"]
return Response()
assert Sender(DEFAULT_DSN, opener=Opener(), logger="phone_audio", client="starpilot-pa/1", schema_tags={"pa.schema": "1"})(message="x")
body = seen[0]["body"]
assert body["logger"] == "phone_audio" and seen[0]["ua"] == "starpilot-pa/1"
assert body["tags"]["pa.schema"] == "1" and "aa.schema" not in body["tags"]
@@ -0,0 +1,426 @@
import os
import subprocess
import sys
import threading
import time
import pytest
from openpilot.starpilot.system.android_auto import car_interface, network
from openpilot.starpilot.system.android_auto.bootstrap import WifiCredentials
from openpilot.starpilot.system.android_auto.car_interface import DedicatedInterface, DedicatedLease
from openpilot.starpilot.system.android_auto.network import DedicatedUnavailable, JoinTimeout, NetworkError, NetworkLease
class Log:
def __init__(self):
self.events = []
def __call__(self, event, **fields):
self.events.append((event, fields))
def names(self):
return [event for event, _ in self.events]
class FakeInterface:
instances: list = []
def __init__(self, log, fail=False):
self.opened = self.closed = False
self.fail = fail
FakeInterface.instances.append(self)
def open(self):
if self.fail:
raise DedicatedUnavailable("no aa0")
self.opened = True
return "aa0"
def close(self):
self.closed = True
class FakeLease:
def __init__(self, log, interface, dedicated=False):
self.interface, self.dedicated = interface, dedicated
self.local_ip = ""
self.errors: list = []
self.releases: list = []
def prepare_device(self, cancelled):
self.prepared = True
def acquire(self, credentials, cancelled=lambda: False, timeout=40.0):
if self.errors:
raise self.errors.pop(0)
self.local_ip = "192.168.180.183"
return self.local_ip
def still_connected(self):
return True
def release(self, restore=True):
self.releases.append(restore)
def credentials():
return WifiCredentials(ssid="Car-4E91", key="secret-key", bssid="C6:B7:57:F7:4E:91", security=8, ap_type=0)
def make(fail=False, errors=()):
leases = []
def factory(log, interface, dedicated=False):
lease = FakeLease(log, interface, dedicated)
if dedicated:
lease.errors = list(errors)
leases.append(lease)
return lease
FakeInterface.instances = []
log = Log()
return DedicatedLease(log, "wlan0", factory, interface_factory=lambda log: FakeInterface(log, fail)), leases, log
def test_dedicated_session_keeps_aa0_between_attempts():
lease, leases, log = make()
lease.prepare() # at session start, while Bluetooth connects
assert lease._opening is not None
assert lease.acquire(credentials()) == "192.168.180.183"
assert leases[0].prepared
assert (leases[0].interface, leases[0].dedicated) == ("aa0", True) and lease.interface == "aa0"
lease.release(restore=False)
assert not FakeInterface.instances[0].closed
lease.acquire(credentials())
assert len(leases) == 1 and len(FakeInterface.instances) == 1
lease.release(restore=True)
assert FakeInterface.instances[0].closed and leases[0].releases == [False, True]
assert "wifi_dedicated" in log.names()
def test_falls_back_to_wlan0_when_aa0_cannot_be_created():
lease, leases, log = make(fail=True)
assert lease.acquire(credentials()) == "192.168.180.183"
assert [(item.interface, item.dedicated) for item in leases] == [("wlan0", False)]
assert "wifi_dedicated_unavailable" in log.names()
lease.release(restore=True)
assert not lease.legacy # the next session tries aa0 again
def test_overlapping_car_subnet_finishes_join_on_wlan0():
lease, leases, log = make(errors=[DedicatedUnavailable("overlaps")])
assert lease.acquire(credentials()) == "192.168.180.183"
assert [(item.interface, item.dedicated) for item in leases] == [("aa0", True), ("wlan0", False)]
assert leases[0].releases == [True] and FakeInterface.instances[0].closed
def test_failed_aa0_join_finishes_the_same_join_on_wlan0():
lease, leases, log = make(errors=[NetworkError("NetworkManager could not join the projection network")])
assert lease.acquire(credentials()) == "192.168.180.183"
assert [(item.interface, item.dedicated) for item in leases] == [("aa0", True), ("wlan0", False)]
assert lease.aa0_failed and not lease.aa0_joined and FakeInterface.instances[0].closed
def test_repeated_aa0_timeouts_fall_back_for_the_session():
lease, leases, log = make(errors=[JoinTimeout("timed out"), JoinTimeout("timed out")])
for _ in range(2):
with pytest.raises(JoinTimeout):
lease.acquire(credentials())
lease.release(restore=False)
assert lease.legacy and FakeInterface.instances[0].closed and not lease.aa0_failed
lease.acquire(credentials())
assert leases[-1].interface == "wlan0"
def test_skipped_car_uses_wlan0_without_creating_aa0():
log = Log()
leases = []
def no_aa0(log):
raise AssertionError("aa0 created")
lease = DedicatedLease(log, "wlan0", lambda log, interface, dedicated=False: leases.append(FakeLease(log, interface)) or leases[-1],
interface_factory=no_aa0, enabled=False)
lease.prepare()
lease.acquire(credentials())
lease.release(restore=True)
assert [item.interface for item in leases] == ["wlan0"] and lease.legacy
def test_cancelled_join_is_not_counted():
lease, leases, log = make(errors=[NetworkError("cancelled"), NetworkError("cancelled")])
for _ in range(2):
with pytest.raises(NetworkError):
lease.acquire(credentials(), cancelled=lambda: True)
assert not lease.legacy and lease.timeouts == 0 and not lease.aa0_failed
def test_session_stop_cancels_a_slow_aa0_setup():
started, finish = threading.Event(), threading.Event()
class SlowLease(FakeLease):
def prepare_device(self, cancelled):
started.set()
while not cancelled():
time.sleep(0.01)
finish.set()
raise NetworkError("cancelled")
log = Log()
FakeInterface.instances = []
lease = DedicatedLease(log, "wlan0", SlowLease, interface_factory=lambda log: FakeInterface(log))
lease.prepare()
assert started.wait(2)
lease.release(restore=True)
assert finish.is_set() and FakeInterface.instances[0].closed and lease.lease is None
assert "wifi_dedicated_unavailable" not in log.names()
class FakeNM:
def __init__(self, devices=None, autoconnect=True, overlap=False, appears_after=0):
self.devices = devices if devices is not None else {"/dev/wlan0": "wlan0", "/dev/aa0": "aa0"}
self.autoconnect = autoconnect
self.overlap = overlap
self.appears_after = appears_after # GetDevices calls before NetworkManager lists aa0
self.calls = []
self.set = []
def call(self, path, interface, member, signature=None, body=(), timeout=10.0):
self.calls.append(member)
if member == "GetDevices":
if self.appears_after:
self.appears_after -= 1
return [[path for path, name in self.devices.items() if name != "aa0"]]
return [list(self.devices)]
if member == "AddAndActivateConnection2":
return ["/settings/aa", "/active/aa"]
if member == "GetSettings":
return [{"connection": {"id": ("s", "home")}}]
return []
def get(self, path, interface, name):
if name == "Interface":
return self.devices[path]
if name == "Ip4Config":
return {"/active/aa": "/ip4/aa", "/dev/aa0": "/ip4/aa", "/dev/wlan0": "/ip4/home"}.get(path, "/")
if name == "AddressData":
if path == "/ip4/home":
return [{"address": ("s", "192.168.180.40" if self.overlap else "192.168.35.249"), "prefix": ("u", 24)}]
return [{"address": ("s", "192.168.180.183"), "prefix": ("u", 24)}]
if name == "Frequency":
return 5745 if path == "/ap/aa0" else 2437
return {"WirelessEnabled": True, "DeviceType": 2, "ActiveConnection": "/active/home", "State": 30 if path.startswith("/dev") else 2,
"Autoconnect": self.autoconnect, "Connection": "/settings/home", "ActiveAccessPoint": "/ap/" + path.rsplit("/", 1)[-1]}[name]
def set_property(self, path, interface, name, signature, value):
self.set.append((path, name, value))
self.autoconnect = value
def nm_lease(nm, dedicated=True, monkeypatch=None):
lease = NetworkLease(Log(), "aa0", dedicated=dedicated)
lease._call, lease._get, lease._set = nm.call, nm.get, nm.set_property
lease._delete_stale_profiles = lambda: None # ty: ignore[invalid-assignment] # test double
lease.router = type("R", (), {"close": lambda self: None})()
return lease
def test_dedicated_network_lease_leaves_home_wifi_alone(monkeypatch):
nm = FakeNM(autoconnect=False)
lease = nm_lease(nm)
assert lease.acquire(credentials(), timeout=2.0) == "192.168.180.183"
assert lease.previous_connection == ""
lease.release(restore=True)
assert "ActivateConnection" not in nm.calls
def test_dedicated_network_lease_turns_off_autoconnect_before_joining():
nm = FakeNM(autoconnect=True)
lease = nm_lease(nm)
lease.acquire(credentials(), timeout=2.0)
assert nm.set == [("/dev/aa0", "Autoconnect", False)]
assert nm.calls.index("AddAndActivateConnection2") > 0
joined = dict(lease.log.events)["wifi_joined"]
assert joined["freq"] == 5745 and joined["wlan0_freq"] == 2437 and joined["wlan0_state"] == 30
def test_dedicated_network_lease_waits_for_networkmanager_to_list_aa0(monkeypatch):
monkeypatch.setattr(time, "sleep", lambda _: None)
nm = FakeNM(autoconnect=False, appears_after=3)
lease = nm_lease(nm)
lease.prepare_device()
assert lease.device_path == "/dev/aa0" and lease.device_ready
assert "wifi_scan_requested" in lease.log.names() # aa0 learns the car's access point before the join
lease.acquire(credentials(), timeout=2.0)
assert nm.calls.count("AddAndActivateConnection2") == nm.calls.count("RequestScan") == 1
assert nm.calls.index("RequestScan") < nm.calls.index("AddAndActivateConnection2")
def test_dedicated_network_lease_never_falls_back_to_another_device(monkeypatch):
monkeypatch.setattr(network, "DEVICE_READY_TIMEOUT_S", 0.3)
nm = FakeNM(devices={"/dev/wlan0": "wlan0"}, autoconnect=False)
lease = nm_lease(nm)
with pytest.raises(DedicatedUnavailable):
lease.acquire(credentials(), timeout=2.0)
assert lease.interface == "aa0" and "wifi_retry_without_bssid" not in lease.log.names()
def test_dedicated_network_lease_rejects_overlapping_subnet_without_a_second_join():
nm = FakeNM(autoconnect=False, overlap=True)
lease = nm_lease(nm)
with pytest.raises(DedicatedUnavailable, match="overlaps"):
lease.acquire(credentials(), timeout=2.0)
assert "wifi_subnet_overlap" in lease.log.names()
assert nm.calls.count("AddAndActivateConnection2") == 1
def test_legacy_lease_still_falls_back():
lease = nm_lease(FakeNM(devices={"/dev/wlan0": "wlan0"}), dedicated=False)
lease.acquire(credentials(), timeout=2.0)
assert lease.interface == "wlan0" and lease.previous_connection == "/settings/home"
def worker(script):
def popen(args, **kwargs):
assert args[:2] == ["sudo", "-n"] and args[-1] == "openpilot.starpilot.system.android_auto.car_interface"
return subprocess.Popen([sys.executable, "-c", script], **kwargs)
return popen
def test_interface_lease_ready_heartbeat_and_close(monkeypatch):
monkeypatch.setattr(car_interface, "HEARTBEAT_S", 0.05)
script = "\n".join(["import sys", "print('{\"ready\": true, \"interface\": \"aa0\"}', flush=True)", "beats = 0",
"while sys.stdin.buffer.read(1): beats += 1", "sys.exit(0 if beats else 3)"])
iface = DedicatedInterface(Log(), popen=worker(script))
assert iface.open() == "aa0"
child = iface.child
time.sleep(0.3)
iface.close()
assert child.returncode == 0
@pytest.mark.parametrize("script,match", [("print('{\"ready\": false, \"error\": \"busy\"}', flush=True)", "busy"),
("import time; time.sleep(5)", "not ready"), ("pass", "Could not create")])
def test_interface_lease_failures(script, match):
iface = DedicatedInterface(Log(), popen=worker(script), ready_timeout=0.5)
with pytest.raises(DedicatedUnavailable, match=match):
iface.open()
assert iface.child is None
def test_supervisor_uses_dedicated_lease_only_when_enabled(monkeypatch, tmp_path):
from openpilot.starpilot.system.android_auto import identity, supervisor
monkeypatch.setattr(car_interface, "DedicatedInterface", FakeInterface)
config = identity.load_config(tmp_path / "missing.json")
assert config["dedicated_wifi_interface"] is True
sup = supervisor.Supervisor.__new__(supervisor.Supervisor)
sup.log, sup._lease_factory, sup.config, sup._dedicated_strikes = Log(), FakeLease, config, {} # ty: ignore[invalid-assignment] # test double
assert isinstance(sup._lease(), DedicatedLease)
sup.config = {**config, "dedicated_wifi_interface": False}
assert isinstance(sup._lease(), FakeLease)
def test_supervisor_keeps_a_car_on_wlan0_after_repeated_aa0_failures(monkeypatch):
from openpilot.starpilot.system.android_auto import identity, supervisor
monkeypatch.setattr(car_interface, "DedicatedInterface", FakeInterface)
config = {**identity.load_config(None), "dedicated_wifi_interface": True, "receiver_address": "AA:BB:CC:DD:EE:FF"}
sup = supervisor.Supervisor.__new__(supervisor.Supervisor)
sup.log, sup._lease_factory, sup.config, sup._dedicated_strikes = Log(), FakeLease, config, {} # ty: ignore[invalid-assignment] # test double
for _ in range(supervisor.DEDICATED_STRIKES):
lease = sup._lease()
assert lease.enabled
lease.release(restore=True)
lease.aa0_failed = True
sup._count_dedicated(lease)
lease = sup._lease()
assert not lease.enabled and "wifi_dedicated_skipped" in sup.log.names()
lease.aa0_joined = True
sup._count_dedicated(lease)
assert sup._dedicated_strikes == {}
class FakeWireless:
def __init__(self, existing=()):
self.existing = set(existing)
self.calls = []
def __call__(self, method, *args, **kwargs):
self.calls.append((method, args, kwargs))
if method == "interfaces":
return {name: {} for name in self.existing}
if method == "create":
self.existing.add(args[0])
if method == "delete":
self.existing.discard(args[0])
return None
def serve(wireless, stdin, stop=None, is_owned=lambda interface, alias: False, timeout=10.0):
run = []
thread = threading.Thread(target=car_interface.serve, args=(wireless, stdin, stop or threading.Event()),
kwargs={"run": lambda args, **kwargs: run.append(args), "is_owned": is_owned, "lease_timeout": timeout})
thread.start()
return thread, run
@pytest.mark.parametrize("ending", ["eof", "heartbeat", "stop"])
def test_worker_deletes_aa0_when_the_lease_ends(ending, capsys):
read, write = os.pipe()
wireless, stop = FakeWireless(existing=["wlan0", "p2p0", "galaxy0"]), threading.Event()
thread, run = serve(wireless, read, stop, timeout=0.3 if ending == "heartbeat" else 10.0)
time.sleep(0.1)
if ending == "eof":
os.close(write)
elif ending == "stop":
stop.set()
thread.join(3)
assert not thread.is_alive()
assert '"ready": true' in capsys.readouterr().out
assert ("create", ("aa0",), {"iftype": 2}) in wireless.calls and wireless.calls[-1] == ("delete", ("aa0",), {})
assert run == [["ip", "link", "set", "dev", "aa0", "alias", "starpilot-aa-wifi"]]
touched = [args[0] for method, args, _ in wireless.calls if args] + [word for command in run for word in command]
assert not {"wlan0", "p2p0", "galaxy0"} & set(touched)
os.close(read)
if ending != "eof":
os.close(write)
def test_worker_leaves_an_aa0_it_does_not_own(capsys):
read, write = os.pipe()
wireless = FakeWireless(existing=["wlan0", "aa0"])
thread, run = serve(wireless, read)
thread.join(3)
assert '"ready": false' in capsys.readouterr().out
assert [method for method, _, _ in wireless.calls] == ["interfaces"]
os.close(read)
os.close(write)
def test_worker_replaces_its_own_stale_aa0(capsys):
read, write = os.pipe()
wireless = FakeWireless(existing=["wlan0", "aa0"])
thread, run = serve(wireless, read, is_owned=lambda interface, alias: interface == "aa0" and alias == "starpilot-aa-wifi")
time.sleep(0.1)
os.close(write)
thread.join(3)
assert [method for method, _, _ in wireless.calls][:3] == ["delete", "interfaces", "create"]
os.close(read)
@pytest.fixture(scope="module")
def identity(tmp_path_factory):
from openpilot.starpilot.system.android_auto.tests.fake_head_unit import make_identity
return make_identity(tmp_path_factory.mktemp("identity"))
def test_daemon_saves_and_reports_the_dedicated_wifi_choice(identity, tmp_path, monkeypatch):
import json
from openpilot.starpilot.system.android_auto import daemon, identity as identity_store
from openpilot.starpilot.system.android_auto.tests.test_android_auto import make_supervisor
import openpilot.common.params as params
monkeypatch.setattr(params, "Params", lambda: type("P", (), {"get_bool": lambda self, key: key == "AndroidAutoEnabled"})())
sup, _ = make_supervisor(identity, tmp_path, monkeypatch, lambda *a, **k: None)
assert daemon.handle(sup, {"command": "status"})["status"]["dedicated_wifi_interface"] is False
daemon.handle(sup, {"command": "set_dedicated_wifi", "enabled": True})
assert sup.status()["dedicated_wifi_interface"] is True
assert json.loads(identity_store.CONFIG_PATH.read_text())["dedicated_wifi_interface"] is True
daemon.handle(sup, {"command": "set_dedicated_wifi", "enabled": "yes"}) # only a real true turns it on
assert sup.status()["dedicated_wifi_interface"] is False
@@ -16,7 +16,7 @@ SESSION = ('galaxy', 'live-proof')
class CompanionTests(unittest.TestCase):
def setUp(self):
self.state: dict = {'parked': True, 'valid': True, 'enabled': True, 'selected': ADAPTER}
self.devices = {CAR: {'address': CAR, 'path': '/org/bluez/hci0/dev_00_92_A5_2F_68_32',
self.devices: dict[str, dict] = {CAR: {'address': CAR, 'path': '/org/bluez/hci0/dev_00_92_A5_2F_68_32',
'paired': True, 'connected': False, 'uuids': [HFP]},
ADAPTER: {'address': ADAPTER, 'path': '/org/bluez/hci0/dev_50_5A_65_8B_8B_82',
'paired': True, 'connected': True, 'uuids': []}}
@@ -27,8 +27,10 @@ class CompanionTests(unittest.TestCase):
self.owner.parked = lambda: self.state['parked']
self.owner.session_valid = lambda session: session == SESSION and self.state['valid']
self.owner.companion_devices = {}
self.forgotten = []
self.client = SimpleNamespace(adapter_identity=lambda: ('/org/bluez/hci0', {'Powered': True}, ':1.5'),
_objects=dict, _devices=lambda objects: list(self.devices.values()))
_objects=dict, _devices=lambda objects: list(self.devices.values()),
operation=lambda operation, address: self.forgotten.append((operation, address)))
self.owner._client = Mock(side_effect=lambda: self.client)
def call(*args, **kwargs):
self.calls.append((args, kwargs))
@@ -87,11 +89,44 @@ class CompanionTests(unittest.TestCase):
def test_no_owner_is_only_fallback_case_and_unsupported_operations_stay_denied(self):
self.owner.phone_lease = None
self.assertIsNone(self.owner.companion_action('connect', CAR, SESSION))
for operation in ('scan', 'pair', 'forget', 'power', 'stop_scan'):
for operation in ('scan', 'pair', 'power', 'stop_scan'):
with self.assertRaises(ValueError):
self.owner.companion_action(operation, CAR, SESSION)
self.assertEqual(self.calls, [])
def test_forget_removes_another_device_while_projection_keeps_its_lease(self):
other = '02:00:00:00:00:09'
self.devices[other] = {'address': other, 'path': '/org/bluez/hci0/dev_old', 'paired': True, 'connected': False, 'uuids': []}
self.owner.companion_devices[other] = self.devices[other]['path']
result = self.owner.companion_action('forget', other, SESSION)
self.assertIsNotNone(result)
self.assertEqual(self.forgotten, [('forget', other)])
self.assertNotIn(other, self.owner.companion_devices)
self.assertFalse(self.lease.released)
self.assertIs(self.owner.phone_lease, self.lease)
def test_forget_never_removes_the_projection_receiver_or_foreign_devices(self):
with self.assertRaises(Rejected) as error:
self.owner.companion_action('forget', ADAPTER, SESSION)
self.assertEqual(error.exception.code, 'projection_active')
self.devices[CAR]['path'] = '/org/bluez/hci1/dev_car'
with self.assertRaises(Rejected):
self.owner.companion_action('forget', CAR, SESSION)
del self.devices[CAR]
with self.assertRaises(Rejected):
self.owner.companion_action('forget', CAR, SESSION)
self.assertEqual(self.forgotten, [])
def test_forget_is_fail_closed_during_pairing_and_for_expired_sessions(self):
self.state['valid'] = False
with self.assertRaises(Rejected):
self.owner.companion_action('forget', CAR, SESSION)
self.state['valid'] = True
self.lease.session = SESSION
with self.assertRaises(Rejected):
self.owner.companion_action('forget', CAR, SESSION)
self.assertEqual(self.forgotten, [])
def test_failed_connect_revokes_new_admission_without_releasing_projection(self):
def failure(): raise TimeoutError('D-Bus operation timed out')
self.hook = failure
@@ -240,7 +275,7 @@ class CompanionTests(unittest.TestCase):
self.assertEqual(self.daemon_call(), {'handled': False})
def test_daemon_denials_and_timeouts_never_produce_normal_owner_fallback(self):
self.assertEqual(self.daemon_call(address=ADAPTER), {'handled': True, 'error_code': 'busy'})
self.assertEqual(self.daemon_call(address=ADAPTER), {'handled': True, 'error_code': 'projection_active'})
self.lease.session = SESSION
self.assertEqual(self.daemon_call(), {'handled': True, 'error_code': 'busy'})
self.lease.session = None
@@ -196,6 +196,15 @@ def write_log(directory, name, events):
return path
def test_retry_after_car_shutdown_is_not_the_outcome():
events = [{"event": "video_acknowledged"}, {"event": "session_ended", "reason": "Head unit ended projection: shutdown"},
{"event": "attempt_failed", "stage": "rfcomm", "error": "Host is down"}]
report = compat_report.summarize(events)
assert report["outcome"] == "projected"
assert report["errors"] == [{"stage": "rfcomm", "error": "Host is down", "after_car_shutdown": True}]
assert "Error (rfcomm, after car shutdown): Host is down" in compat_report.render_text(report)
def test_session_logs_are_newest_first_by_number(tmp_path):
write_log(tmp_path, "session-000009-20260927-121412.jsonl", [])
write_log(tmp_path, "session-000010-20260728-080505.jsonl", []) # the clock had not synced yet
@@ -0,0 +1,140 @@
import json
import socket
import time
from pathlib import Path
import pytest
from openpilot.starpilot.system.android_auto import hw_encoder, identity as identity_store, supervisor as supervisor_module
from openpilot.starpilot.system.android_auto.session import FRAGMENT_SIZE, ProjectionSession
from openpilot.starpilot.system.android_auto.tests.fake_head_unit import FakeHeadUnit, make_identity
@pytest.fixture(scope="module")
def identity(tmp_path_factory):
return make_identity(tmp_path_factory.mktemp("identity"))
@pytest.fixture
def store(tmp_path, monkeypatch, identity):
data = tmp_path / "android_auto"
(data / "identity").mkdir(parents=True)
for src, name in ((identity["phone_cert"], "phone-cert.pem"), (identity["phone_key"], "phone-key.pem"), (identity["root"], "root-cert.pem")):
(data / "identity" / name).write_bytes(Path(src).read_bytes())
(data / "identity" / "phone-key.pem").chmod(0o600)
monkeypatch.setattr(identity_store, "IDENTITY_DIR", data / "identity")
monkeypatch.setattr(identity_store, "CONFIG_PATH", data / "config.json")
monkeypatch.setattr(identity_store, "LOG_DIR", data / "logs")
monkeypatch.setattr(hw_encoder, "LIBRARY", tmp_path / "missing.so")
return data
def free_port() -> int:
with socket.socket() as probe:
probe.bind(("127.0.0.1", 0))
return probe.getsockname()[1]
def pump_until(session, predicate, timeout=5.0):
deadline = time.monotonic() + timeout
while not predicate():
assert time.monotonic() < deadline, "condition not reached"
session.pump(0.05)
def keyframe(size: int) -> bytes:
return b"\x00\x00\x00\x01\x67\x42" + b"\x00\x00\x00\x01\x68\xce" + b"\x00\x00\x00\x01\x65" + bytes(size)
def test_config_keeps_headunitpad_and_rejects_a_bad_port(store):
(store / "config.json").write_text(json.dumps({"connection": "headunitpad", "headunitpad_port": 80}))
config = identity_store.load_config()
assert config["connection"] == "headunitpad" and config["headunitpad_port"] == 5277
def test_fragment_acks_credit_whole_frames(identity):
hu = FakeHeadUnit(identity, ack_fragments=True)
sock = socket.create_connection(("127.0.0.1", hu.port), timeout=5)
session = ProjectionSession(sock, str(identity["phone_cert"]), str(identity["phone_key"]), None, None)
session.fragment_acks = True
session.authenticate()
session.start("StarPilot", "comma.ai")
pump_until(session, lambda: session.focused)
session.send_frame(keyframe(3 * FRAGMENT_SIZE), 1, keyframe=True) # codec config + four fragments: five ACKs
pump_until(session, lambda: session.acked == 1)
assert session.video_confirmed and not session.pending and session.fragment_credit == 0
for i in range(2, 6):
pump_until(session, session.can_send)
session.send_frame(b"\x00\x00\x00\x01\x41" + bytes(FRAGMENT_SIZE if i % 2 else 10), i, keyframe=False)
pump_until(session, lambda: session.acked == 5)
assert not session.frame_fragments and session.fragment_credit == 0
session.shutdown()
session.peer.close()
hu.thread.join(5)
assert hu.error is None, hu.error
assert len(hu.frames) == 5
def test_per_frame_acks_unchanged_for_cars(identity):
hu = FakeHeadUnit(identity)
sock = socket.create_connection(("127.0.0.1", hu.port), timeout=5)
session = ProjectionSession(sock, str(identity["phone_cert"]), str(identity["phone_key"]), None, None)
session.authenticate()
session.start("StarPilot", "comma.ai")
pump_until(session, lambda: session.focused)
session.send_frame(keyframe(3 * FRAGMENT_SIZE), 1, keyframe=True)
pump_until(session, lambda: session.acked == 1)
assert not session.frame_fragments
session.shutdown()
session.peer.close()
hu.thread.join(5)
assert hu.error is None, hu.error
@pytest.mark.parametrize("connection", ["headunitpad", "openheadunit"])
def test_supervisor_headunitpad_session_end_to_end(store, identity, connection):
port = free_port()
sup = supervisor_module.Supervisor(synthetic=True)
sup.set_connection(connection)
sup.config["headunitpad_port"] = port
hu = FakeHeadUnit(identity, connect_to=("127.0.0.1", port), ack_fragments=connection == "headunitpad")
try:
sup.start() # no car chosen: HeadunitPad needs none
deadline = time.monotonic() + 20
while len(hu.frames) < 3:
assert time.monotonic() < deadline, sup.status()
time.sleep(0.05)
status = sup.status()
assert status["connection"] == connection and status["state"] == "streaming", status
finally:
sup.stop()
hu.close()
assert hu.error is None, hu.error
assert identity_store.load_config()["connection"] == connection
def test_stop_while_waiting_for_the_ipad_closes_the_port(store):
port = free_port()
sup = supervisor_module.Supervisor(synthetic=True)
sup.set_connection("headunitpad")
sup.config["headunitpad_port"] = port
sup.start()
deadline = time.monotonic() + 5
while sup.status()["state"] != "waiting_for_ipad":
assert time.monotonic() < deadline, sup.status()
time.sleep(0.05)
sup.stop()
assert not sup.status()["running"]
with pytest.raises(OSError):
socket.create_connection(("127.0.0.1", port), timeout=1).close()
def test_port_in_use_reports_a_clear_error(store):
port = free_port()
sup = supervisor_module.Supervisor(synthetic=True)
sup.set_connection("headunitpad")
sup.config["headunitpad_port"] = port
with socket.socket() as busy:
busy.bind(("", port))
with pytest.raises(RuntimeError, match=f"port {port}"):
sup._attempt_headunitpad()
@@ -0,0 +1,222 @@
import os
import struct
import pytest
from openpilot.starpilot.system.android_auto import media_audio as ma
from openpilot.starpilot.system.android_auto.wire import field, parse_fields
# The media channels a 2025 Honda Civic (Alpine head unit) advertised, as session.discover parses them.
CIVIC = [
{'id': 4, 'media_type': 1, 'audio_type': 3, 'audio_configs': [{1: [48000], 2: [16], 3: [2]}]},
{'id': 5, 'media_type': 1, 'audio_type': 1, 'audio_configs': [{1: [48000], 2: [16], 3: [1]}]},
{'id': 18, 'media_type': 2, 'audio_type': 3, 'audio_configs': [{1: [48000], 2: [16], 3: [2]}]},
{'id': 22, 'media_type': None, 'video_configs': [{1: [2]}]},
]
TONE = struct.pack('<hh', 3000, -3000) * 4800 # 100 ms of sound
SILENCE = b'\x00' * len(TONE)
class Session:
def __init__(self):
self.channels = CIVIC
self.sent = []
def send(self, channel, kind, body=b'', control=False):
self.sent.append((channel, kind, body))
def kinds(self):
return [(channel, kind) for channel, kind, _ in self.sent]
class Source:
def __init__(self):
self.routed = True
self.data = bytearray()
self.closed = False
def active(self, now): return self.routed
def read(self, limit):
out = bytes(self.data[:limit])
del self.data[:limit]
return out
def close(self): self.closed = True
@pytest.fixture
def audio():
session, source, now, paused = Session(), Source(), [100.0], []
stream = ma.MediaAudio(session, 4, 0, source, lambda *a, **k: None, pause=lambda: paused.append(True), clock=lambda: now[0])
return stream, session, source, now, paused
def ready(stream, session, window=2):
stream.service()
assert session.kinds()[-1] == (4, ma.MSG_CHANNEL_OPEN_REQUEST)
stream.handle(4, ma.MSG_CHANNEL_OPEN_RESPONSE, {1: [0]})
assert session.sent[-1][:2] == (4, ma.AV_SETUP_REQUEST) and parse_fields(session.sent[-1][2]) == {1: [ma.CODEC_PCM]}
stream.handle(4, ma.AV_SETUP_RESPONSE, {1: [2], 2: [window], 3: [0]})
assert stream.state == 'ready'
def play(stream, session, source, now):
source.data += TONE
stream.service()
assert session.sent[-1] == (0, ma.AUDIO_FOCUS_REQUEST, field(1, ma.FOCUS_GAIN))
assert stream.handle(0, ma.AUDIO_FOCUS_NOTIFICATION, {1: [ma.STATE_GAIN]})
assert session.sent[-1] == (4, ma.AV_START_INDICATION, field(1, 1) + field(2, 0))
def test_media_channel_picks_pcm_media_not_guidance_or_aac():
assert ma.media_channel(CIVIC) == (4, 0)
assert ma.media_channel([c for c in CIVIC if c['id'] != 4]) is None
def test_stays_closed_until_spotify_is_routed(audio):
stream, session, source, _, _ = audio
source.routed = False
source.data += TONE
stream.service()
assert session.sent == [] and stream.state == 'closed'
def test_open_setup_focus_and_stream_within_ack_credit(audio):
stream, session, source, now, _ = audio
ready(stream, session, window=1)
play(stream, session, source, now)
stream.service()
media = [body for channel, kind, body in session.sent if kind == ma.AV_MEDIA_WITH_TIMESTAMP]
assert len(media) == 1 # one-packet window: wait for the car's ACK
timestamp, = struct.unpack('>Q', media[0][:8])
assert len(media[0]) - 8 == len(TONE) and timestamp == stream.base_us
source.data += TONE
stream.service()
assert sum(kind == ma.AV_MEDIA_WITH_TIMESTAMP for _, kind, _ in session.sent) == 1
stream.handle(4, ma.AV_MEDIA_ACK, {1: [1], 2: [1]})
stream.service()
media = [body for channel, kind, body in session.sent if kind == ma.AV_MEDIA_WITH_TIMESTAMP]
assert len(media) == 2
assert struct.unpack('>Q', media[1][:8])[0] == stream.base_us + 100_000 # sample-accurate timestamps
assert stream.status()['acked'] == 1
def test_silence_releases_focus_and_sound_asks_again(audio):
stream, session, source, now, _ = audio
ready(stream, session)
play(stream, session, source, now)
for _ in range(10):
now[0] += 1
source.data += SILENCE
stream.service()
assert (4, ma.AV_STOP_INDICATION) in session.kinds()
assert session.sent[-1] == (0, ma.AUDIO_FOCUS_REQUEST, field(1, ma.FOCUS_RELEASE))
assert stream.state == 'ready'
source.data += TONE
stream.service()
assert stream.state == 'focus_wait'
def test_car_taking_focus_pauses_spotify_and_does_not_fight_back(audio):
stream, session, source, now, paused = audio
ready(stream, session)
play(stream, session, source, now)
stream.handle(0, ma.AUDIO_FOCUS_NOTIFICATION, {1: [ma.STATE_LOSS], 2: [1]})
assert paused == [True] and stream.state == 'ready' and session.kinds()[-1] == (4, ma.AV_STOP_INDICATION)
source.data += TONE # the pause takes a moment to reach Spotify
stream.service()
assert stream.state == 'ready'
now[0] += 3
source.data += SILENCE
stream.service()
source.data += TONE # the driver pressed play again
stream.service()
assert stream.state == 'focus_wait'
def test_transient_loss_suspends_and_resumes(audio):
stream, session, source, now, paused = audio
ready(stream, session)
play(stream, session, source, now)
stream.handle(0, ma.AUDIO_FOCUS_NOTIFICATION, {1: [ma.STATE_LOSS_TRANSIENT]})
assert stream.state == 'suspended' and paused == []
stream.handle(0, ma.AUDIO_FOCUS_NOTIFICATION, {1: [ma.STATE_GAIN]})
assert stream.state == 'playing' and stream.session_id == 2
def test_rejections_and_timeouts_leave_projection_alone(audio):
stream, session, source, now, _ = audio
stream.service()
stream.handle(4, ma.MSG_CHANNEL_OPEN_RESPONSE, {1: [0xFFFFFFFFFFFFFFFF]}) # -1 as a varint
assert stream.state == 'unavailable'
other = ma.MediaAudio(Session(), 4, 0, Source(), lambda *a, **k: None, clock=lambda: now[0])
other.service()
now[0] += ma.STEP_TIMEOUT + 1
other.service()
assert other.state == 'unavailable'
def test_ack_stall_gives_focus_back(audio):
stream, session, source, now, _ = audio
ready(stream, session, window=1)
play(stream, session, source, now)
stream.service()
now[0] += ma.ACK_STALL + 0.5
source.data += TONE
stream.service()
assert stream.state == 'ready' and session.sent[-1] == (0, ma.AUDIO_FOCUS_REQUEST, field(1, ma.FOCUS_RELEASE))
def test_buffer_is_bounded_and_drops_oldest(audio):
stream, session, source, now, _ = audio
ready(stream, session, window=1)
play(stream, session, source, now)
stream.service()
for _ in range(30):
source.data += TONE
stream.service()
assert len(stream.buffer) <= ma.BUFFER_PLAYING and stream.status()['dropped_ms'] > 0
def test_pipe_source_reads_a_fifo_without_blocking(tmp_path):
path = tmp_path / 'aa.pcm'
os.mkfifo(path)
source = ma.PipeSource(path, lambda: True)
assert source.active(10.0)
writer = os.open(path, os.O_RDWR | os.O_NONBLOCK)
assert source.read(1024) == b''
os.write(writer, TONE[:4000])
assert source.read(65536) == TONE[:4000]
os.close(writer)
source.close()
assert not ma.PipeSource(tmp_path / 'missing', lambda: True).active(10.0)
def test_source_switch_discards_old_audio_and_releases_focus(audio):
stream, session, source, now, _ = audio
source.generation = 1
ready(stream, session)
play(stream, session, source, now)
stream.buffer.extend(TONE)
source.generation = 2
stream.service()
assert not stream.buffer and stream.state == 'ready'
assert session.sent[-1] == (0, ma.AUDIO_FOCUS_REQUEST, field(1, ma.FOCUS_RELEASE))
def test_replaced_pipe_reopens_instead_of_reading_previous_source(tmp_path):
path = tmp_path / 'audio.pcm'
os.mkfifo(path)
source = ma.PipeSource(path, lambda: True)
assert source.active(10)
old = os.open(path, os.O_RDWR | os.O_NONBLOCK)
source.read(16)
generation = source.generation
path.unlink()
os.mkfifo(path)
new = os.open(path, os.O_RDWR | os.O_NONBLOCK)
try:
os.write(old, b'old!')
os.write(new, b'new!')
assert source.active(12) and source.generation != generation
assert source.read(16) == b'new!'
finally:
source.close()
os.close(old)
os.close(new)
@@ -0,0 +1,165 @@
"""Car media buttons skip tracks, and the playing song reaches the car's own displays."""
import pytest
from openpilot.starpilot.system.android_auto import media_status as ms
from openpilot.starpilot.system.android_auto.session import INPUT_EVENT, ProjectionSession
from openpilot.starpilot.system.android_auto.touch import KeyTracker
from openpilot.starpilot.system.android_auto.wire import field, parse_fields
TRACK = {'source': 'spotify', 'status': 'playing', 'title': 'Midnight City', 'artist': 'M83', 'album': 'Hurry Up',
'durationMs': 243000, 'position': {'ms': 12000, 'at': 1000, 'speed': 1.0}}
def key(code, down, longpress=False):
return field(1, code) + field(2, int(down)) + field(3, 0) + field(4, int(longpress))
def keys(*pressed):
return field(1, 123456) + field(4, b''.join(field(1, k) for k in pressed))
def test_one_action_per_press_even_while_held():
tracker = KeyTracker()
assert tracker.decode(keys(key(87, True))) == ['next']
assert tracker.decode(keys(key(87, True, longpress=True))) == []
assert tracker.decode(keys(key(87, False))) == []
assert tracker.decode(keys(key(88, True), key(85, True))) == ['previous', 'play_pause']
assert tracker.decode(keys(key(87, True))) == ['next']
def test_non_media_buttons_and_touches_are_ignored():
tracker = KeyTracker()
assert tracker.decode(keys(key(3, True), key(84, True))) == []
assert tracker.decode(field(1, 1) + field(3, field(3, 0))) == []
with pytest.raises(ValueError):
tracker.decode(b'\x00' * (17 * 1024))
def session_with_input(focused):
session = ProjectionSession.__new__(ProjectionSession)
session.audio, session.mode, session.input_channel, session.input_events = None, None, 3, 0
session.touch, session.focused, session.keys = None, focused, KeyTracker()
session.touch_events, session.events, session.actions = [], [], [] # ty: ignore[invalid-assignment] # test double
session.event = lambda name, **values: session.events.append((name, values)) # ty: ignore[invalid-assignment] # test double
session.media_control = session.actions.append
return session
@pytest.mark.parametrize('focused', [True, False])
def test_session_sends_media_keys_even_on_the_cars_own_screen(focused):
session = session_with_input(focused)
session.handle(3, INPUT_EVENT, keys(key(87, True)))
session.handle(3, INPUT_EVENT, keys(key(87, False)))
session.handle(3, INPUT_EVENT, keys(key(88, True)))
assert session.actions == ['next', 'previous']
assert ('media_key', {'action': 'next'}) in session.events
class Session:
def __init__(self, services):
self.channels = [{'id': 4, 'services': [3]}, {'id': 11, 'services': services}]
self.sent = []
def send(self, channel, kind, body=b'', control=False):
self.sent.append((channel, kind, body))
class Feed:
def __init__(self): self.value, self.closed = None, False
def snapshot(self): return self.value
def close(self): self.closed = True
@pytest.fixture
def status():
session, feed, now = Session([ms.MEDIA_INFO_SERVICE]), Feed(), [100.0]
stream = ms.MediaStatus.create(session, lambda *a, **k: None, feed=feed, clock=lambda: now[0])
return stream, session, feed, now
def opened(stream, session):
stream.service()
assert session.sent[-1][:2] == (11, ms.MSG_CHANNEL_OPEN_REQUEST)
assert stream.handle(11, ms.MSG_CHANNEL_OPEN_RESPONSE, {1: [0]})
assert stream.state == 'open'
session.sent.clear()
def test_unknown_duration_is_still_sent_as_zero():
assert parse_fields(ms.metadata_message({**TRACK, 'durationMs': None}))[6] == [0]
def test_only_head_units_offering_the_service_get_it():
assert ms.MediaStatus.create(Session([3]), lambda *a, **k: None, feed=Feed()) is None
assert ms.media_info_channel(Session([ms.MEDIA_INFO_SERVICE]).channels) == 11
def test_rejected_or_silent_channel_stays_quiet(status):
stream, session, feed, now = status
stream.service()
now[0] += ms.STEP_TIMEOUT + 1
stream.service()
assert stream.state == 'unavailable'
feed.value = TRACK
stream.service()
assert len(session.sent) == 1
def test_song_then_progress_once_a_second_then_stop(status, monkeypatch):
stream, session, feed, now = status
opened(stream, session)
stream.service()
assert session.sent == [] # nothing playing, nothing to clear
feed.value = TRACK
monkeypatch.setattr('time.time', lambda: 5.0) # 4 s after the position anchor
now[0] += 1
stream.service()
(_, kind, meta), (_, kind2, playing) = session.sent
assert kind == ms.PLAYBACK_METADATA and kind2 == ms.PLAYBACK_STATUS
assert parse_fields(meta) == {1: [b'Midnight City'], 2: [b'M83'], 3: [b'Hurry Up'], 6: [243], 7: [0]}
assert parse_fields(playing) == {1: [ms.STATE_PLAYING], 2: [b'Spotify'], 3: [16], 4: [0], 5: [0], 6: [0]}
stream.service()
assert len(session.sent) == 2 # within the same second
now[0] += 1
stream.service()
assert [k for _, k, _ in session.sent[2:]] == [ms.PLAYBACK_STATUS]
feed.value = {**TRACK, 'status': 'paused'}
now[0] += 1
stream.service()
now[0] += 1
stream.service()
assert [parse_fields(b)[1] for _, _, b in session.sent[3:]] == [[ms.STATE_PAUSED]]
feed.value = {**TRACK, 'title': 'Wait'}
now[0] += 1
stream.service()
assert parse_fields(session.sent[-2][2])[1] == [b'Wait']
feed.value = None
now[0] += 1
stream.service()
now[0] += 1
stream.service()
assert parse_fields(session.sent[-1][2])[1] == [ms.STATE_STOPPED]
assert session.sent[-2][1] != ms.PLAYBACK_STATUS or parse_fields(session.sent[-2][2])[1] != [ms.STATE_STOPPED]
stream.close()
assert feed.closed
def test_messages_on_other_channels_pass_through(status):
stream, _, _, _ = status
assert not stream.handle(4, ms.MSG_CHANNEL_OPEN_RESPONSE, {1: [0]})
assert stream.handle(11, 0x8002, {1: [3]})
def test_audio_step_drives_track_info_without_touching_audio_state():
from openpilot.starpilot.system.android_auto import media_audio as ma
class Source:
def active(self, now): return False
def close(self): pass
session, feed = Session([ms.MEDIA_INFO_SERVICE]), Feed()
audio = ma.MediaAudio(session, 4, 0, Source(), lambda *a, **k: None, pause=lambda: None, clock=lambda: 100.0)
audio.track_info = ms.MediaStatus.create(session, lambda *a, **k: None, feed=feed, clock=lambda: 100.0)
audio.service()
assert session.sent[-1][:2] == (11, ms.MSG_CHANNEL_OPEN_REQUEST) and audio.state == 'closed'
assert audio.handle(11, ms.MSG_CHANNEL_OPEN_RESPONSE, {1: [0]}) and audio.track_info.state == 'open'
audio.close()
assert feed.closed
@@ -0,0 +1,317 @@
"""Mapbox turn-by-turn reaches the car's own displays when the head unit offers the navigation service."""
import pytest
from openpilot.starpilot.system.android_auto import nav_status as ns
from openpilot.starpilot.system.android_auto.session import ProjectionSession
from openpilot.starpilot.system.android_auto.wire import field, parse_fields
GUIDING = {'state': 'guiding', 'metric': False, 'road': 'Main Street', 'type': 'turn', 'modifier': 'left',
'distance': 400.0, 'remaining': 8000.0, 'duration': 800.0}
class Session:
def __init__(self, services=(ns.NAVIGATION_SERVICE,), navigation=None):
self.channels = [{'id': 4, 'services': [3]}, {'id': 12, 'services': list(services), 'navigation': navigation or {}}]
self.sent = []
self.focus_sent = []
def send(self, channel, kind, body=b'', control=False):
if channel == 0:
self.focus_sent.append((channel, kind, body))
return
self.sent.append((channel, kind, body))
class Feed:
def __init__(self): self.value, self.closed = None, False
def snapshot(self): return self.value
def close(self): self.closed = True
@pytest.fixture
def nav():
session, feed, now = Session(), Feed(), [100.0]
stream = ns.NavStatus.create(session, lambda *a, **k: None, feed=feed, clock=lambda: now[0])
return stream, session, feed, now
def start(stream, session, now):
stream.service()
assert session.sent[-1][:2] == (12, ns.MSG_CHANNEL_OPEN_REQUEST)
assert stream.handle(12, ns.MSG_CHANNEL_OPEN_RESPONSE, {1: [0]})
assert stream.handle(12, ns.CLUSTER_START, {})
session.sent.clear()
def tick(stream, now, seconds=1.0):
now[0] += seconds
stream.service()
@pytest.mark.parametrize('kind,modifier,expected', [
('turn', 'left', (ns.EVENT_TURN, ns.SIDE_LEFT)),
('turn', 'slightRight', (ns.EVENT_SLIGHT_TURN, ns.SIDE_RIGHT)),
('end of road', 'sharp left', (ns.EVENT_SHARP_TURN, ns.SIDE_LEFT)),
('turn', 'uturn', (ns.EVENT_U_TURN, ns.SIDE_UNSPECIFIED)),
('continue', 'straight', (ns.EVENT_STRAIGHT, ns.SIDE_UNSPECIFIED)),
('new name', 'none', (ns.EVENT_NAME_CHANGE, ns.SIDE_UNSPECIFIED)),
('off ramp', 'right', (ns.EVENT_OFF_RAMP, ns.SIDE_RIGHT)),
('on ramp', 'slight left', (ns.EVENT_ON_RAMP, ns.SIDE_LEFT)),
('merge', 'left', (ns.EVENT_MERGE, ns.SIDE_LEFT)),
('fork', 'right', (ns.EVENT_FORK, ns.SIDE_RIGHT)),
('roundabout', 'right', (ns.EVENT_ROUNDABOUT_ENTER_AND_EXIT, ns.SIDE_RIGHT)),
('exit roundabout', 'left', (ns.EVENT_ROUNDABOUT_EXIT, ns.SIDE_LEFT)),
('depart', 'none', (ns.EVENT_DEPART, ns.SIDE_UNSPECIFIED)),
('arrive', 'right', (ns.EVENT_DESTINATION, ns.SIDE_RIGHT)),
])
def test_mapbox_maneuvers_map_to_the_cars_events(kind, modifier, expected):
assert ns.maneuver_event(kind, modifier) == expected
@pytest.mark.parametrize('meters,metric,expected', [
(180, True, (175000, ns.UNIT_METERS)),
(740, True, (750000, ns.UNIT_METERS)),
(1540, True, (1500, ns.UNIT_KILOMETERS_P1)),
(23000, True, (23000, ns.UNIT_KILOMETERS)),
(60, False, (200000, ns.UNIT_FEET)),
(4000, False, (2500, ns.UNIT_MILES_P1)),
(40000, False, (25000, ns.UNIT_MILES)),
])
def test_distance_is_rounded_like_the_comma_card(meters, metric, expected):
assert ns.display_distance(meters, metric) == expected
def test_head_unit_descriptor_is_parsed():
raw = field(1, 500) + field(2, ns.TYPE_IMAGE) + field(3, field(1, 128) + field(2, 64) + field(3, 16))
assert ns.parse_navigation(raw) == {'min_interval_ms': 500, 'type': ns.TYPE_IMAGE, 'image_width': 64,
'image_height': 128, 'image_depth': 16}
def test_only_head_units_offering_the_service_get_it():
assert ns.NavStatus.create(Session([3]), lambda *a, **k: None, feed=Feed()) is None
def test_rejected_or_silent_channel_stays_quiet(nav):
stream, session, feed, now = nav
stream.service()
now[0] += ns.STEP_TIMEOUT + 1
stream.service()
assert stream.state == 'unavailable'
feed.value = GUIDING
tick(stream, now)
assert len(session.sent) == 1
def test_nothing_is_sent_until_the_car_asks_or_the_grace_passes(nav):
stream, session, feed, now = nav
feed.value = GUIDING
stream.service()
stream.handle(12, ns.MSG_CHANNEL_OPEN_RESPONSE, {1: [0]})
session.sent.clear()
tick(stream, now, 0.5)
assert session.sent == []
tick(stream, now, ns.START_GRACE)
assert [kind for _, kind, _ in session.sent] == [ns.NAV_STATUS, ns.NAV_TURN, ns.NAV_NOTIFICATION, ns.NAV_DISTANCE, ns.NAV_CURRENT_POSITION]
def test_a_car_that_stopped_the_cluster_is_not_fed_again(nav):
stream, session, feed, now = nav
start(stream, session, now)
stream.handle(12, ns.CLUSTER_STOP, {})
feed.value = GUIDING
tick(stream, now, ns.START_GRACE + 1)
assert session.sent == []
stream.handle(12, ns.CLUSTER_START, {})
tick(stream, now)
assert [kind for _, kind, _ in session.sent] == [ns.NAV_STATUS, ns.NAV_TURN, ns.NAV_NOTIFICATION, ns.NAV_DISTANCE, ns.NAV_CURRENT_POSITION]
def test_route_guidance_then_updates_then_end(nav):
stream, session, feed, now = nav
start(stream, session, now)
tick(stream, now)
assert [(kind, parse_fields(body)) for _, kind, body in session.sent] == [(ns.NAV_STATUS, {1: [ns.STATUS_INACTIVE]})]
session.sent.clear()
feed.value = GUIDING
tick(stream, now)
status, turn, notification, distance, position = session.sent
assert (status[1], parse_fields(status[2])) == (ns.NAV_STATUS, {1: [ns.STATUS_ACTIVE]})
assert turn[1] == ns.NAV_TURN
assert parse_fields(turn[2]) == {1: [b'Main Street'], 2: [7], 3: [ns.SIDE_LEFT]}
assert distance[1] == ns.NAV_DISTANCE
assert parse_fields(distance[2]) == {1: [400], 2: [40], 3: [200], 4: [ns.UNIT_MILES_P1]}
session.sent.clear()
tick(stream, now)
assert session.sent == [] # nothing changed
feed.value = {**GUIDING, 'distance': 300.0}
tick(stream, now)
assert [kind for _, kind, _ in session.sent] == [ns.NAV_DISTANCE, ns.NAV_CURRENT_POSITION]
session.sent.clear()
feed.value = {**GUIDING, 'distance': 20.0, 'road': 'Elm Street', 'type': 'arrive', 'modifier': 'right'}
tick(stream, now)
assert [kind for _, kind, _ in session.sent] == [ns.NAV_TURN, ns.NAV_NOTIFICATION, ns.NAV_DISTANCE, ns.NAV_CURRENT_POSITION]
session.sent.clear()
feed.value = {'state': 'routing'}
tick(stream, now)
assert parse_fields(session.sent[-1][2]) == {1: [ns.STATUS_REROUTING]}
feed.value = None
tick(stream, now)
tick(stream, now)
assert parse_fields(session.sent[-1][2]) == {1: [ns.STATUS_INACTIVE]}
assert [kind for _, kind, _ in session.sent].count(ns.NAV_STATUS) == 2
def test_distance_updates_respect_the_cars_minimum_interval():
session, feed, now = Session(navigation={'min_interval_ms': 5000, 'type': ns.TYPE_ENUM}), Feed(), [100.0]
stream = ns.NavStatus.create(session, lambda *a, **k: None, feed=feed, clock=lambda: now[0])
start(stream, session, now)
feed.value = GUIDING
tick(stream, now)
session.sent.clear()
feed.value = {**GUIDING, 'distance': 300.0}
tick(stream, now, 1.0)
assert session.sent == []
tick(stream, now, 5.0)
assert [kind for _, kind, _ in session.sent] == [ns.NAV_DISTANCE, ns.NAV_CURRENT_POSITION]
def test_image_head_units_get_the_turn_arrow_at_their_size():
session, feed, now = Session(navigation={'type': ns.TYPE_IMAGE, 'image_width': 64, 'image_height': 64}), Feed(), [100.0]
stream = ns.NavStatus.create(session, lambda *a, **k: None, feed=feed, clock=lambda: now[0])
start(stream, session, now)
feed.value = GUIDING
tick(stream, now)
turn = parse_fields(next(body for _, kind, body in session.sent if kind == ns.NAV_TURN))
assert turn[4][0].startswith(b'\x89PNG')
def test_enum_head_units_get_no_image(nav):
stream, session, feed, now = nav
start(stream, session, now)
feed.value = GUIDING
tick(stream, now)
assert 4 not in parse_fields(next(body for _, kind, body in session.sent if kind == ns.NAV_TURN))
def test_session_hands_the_nav_channel_to_nav_status():
session = ProjectionSession.__new__(ProjectionSession)
session.audio, session.mode, session.input_channel = None, None, 3
got = []
session.nav = type('N', (), {'handle': lambda self, c, k, f: got.append((c, k)) or c == 12})()
session.handle(12, ns.CLUSTER_START, b'')
assert got == [(12, ns.CLUSTER_START)]
def test_focus_is_requested_before_cluster_start_and_released_at_route_end(nav):
stream, session, feed, now = nav
stream.service()
stream.handle(12, ns.MSG_CHANNEL_OPEN_RESPONSE, {1: [0]})
feed.value = GUIDING
tick(stream, now, 0.25)
assert session.focus_sent == [(0, 0x0d, b'\x08\x02')]
assert not any(kind == ns.NAV_TURN for _, kind, _ in session.sent)
stream.handle(0, 0x0e, {1: [2]})
stream.handle(12, ns.CLUSTER_START, {})
tick(stream, now)
tick(stream, now)
assert len(session.focus_sent) == 1
feed.value = {'state': 'routing'}
tick(stream, now)
assert len(session.focus_sent) == 1
feed.value = None
tick(stream, now)
assert session.focus_sent[-1] == (0, 0x0d, b'\x08\x01')
feed.value = GUIDING
tick(stream, now)
assert session.focus_sent[-1] == (0, 0x0d, b'\x08\x02')
def test_native_focus_pauses_updates_and_projected_focus_resends(nav):
stream, session, feed, now = nav
start(stream, session, now)
feed.value = GUIDING
tick(stream, now)
session.sent.clear()
assert stream.handle(0, 0x0e, {1: [1]})
feed.value = {**GUIDING, 'distance': 100.0}
tick(stream, now)
assert session.sent == []
assert stream.handle(0, 0x0e, {1: [2]})
tick(stream, now)
assert [kind for _, kind, _ in session.sent] == [ns.NAV_STATUS, ns.NAV_TURN, ns.NAV_NOTIFICATION, ns.NAV_DISTANCE, ns.NAV_CURRENT_POSITION]
def test_repeated_cluster_start_resends_current_guidance(nav):
stream, session, feed, now = nav
start(stream, session, now)
feed.value = GUIDING
tick(stream, now)
session.sent.clear()
stream.handle(12, ns.CLUSTER_START, {})
tick(stream, now)
assert [kind for _, kind, _ in session.sent] == [ns.NAV_STATUS, ns.NAV_TURN, ns.NAV_NOTIFICATION, ns.NAV_DISTANCE, ns.NAV_CURRENT_POSITION]
def test_legacy_distance_matches_captured_phone_packet():
assert ns.distance_message(13925, 517, True) == bytes.fromhex('08 e5 6c 10 85 04 18 b0 6d 20 02')
def test_non_square_cluster_image_uses_descriptor_width_and_height():
import io
from PIL import Image
options = ns.parse_navigation(bytes.fromhex('08 f4 03 10 02 1a 05 08 40 10 80 01'))
image = ns.turn_image('turn', 'left', options['image_width'], options['image_height'])
assert image is not None
with Image.open(io.BytesIO(image)) as icon:
assert icon.size == (128, 64)
def test_projection_session_dispatches_navigation_focus(nav):
stream, _, _, _ = nav
session = ProjectionSession.__new__(ProjectionSession)
session.audio, session.mode, session.nav = None, None, stream
session.handle(0, 0x0e, b'\x08\x02')
assert stream.focus == ns.FOCUS_PROJECTED
@pytest.mark.parametrize('kind,modifier,wire_value', [
('turn', 'left', 7), ('turn', 'right', 8), ('turn', 'slightRight', 6),
('turn', 'sharpLeft', 9), ('arrive', 'right', 42), ('arrive', '', 39),
('merge', '', 29), ('fork', 'left', 25), ('continue', 'straight', 36),
('on ramp', 'slight right', 14), ('off ramp', 'left', 23),
('roundabout', 'right', 0), ('turn', 'uturn', 0),
])
def test_modern_maneuver_wire_values(kind, modifier, wire_value):
assert ns.modern_maneuver(kind, modifier) == wire_value
def test_navigation_wire_schema_golden_bytes():
# Independent literals from the proto field numbers: left turn = Maneuver.TYPE_TURN_NORMAL_LEFT (7).
assert ns.turn_message('Main', 7, 1) == bytes.fromhex('0a044d61696e10071801')
assert ns.notification_message('Main', 7) == bytes.fromhex('0a0c0a02080712060a044d61696e')
assert ns.current_position_message(400, 40, False) == bytes.fromhex('0a0e0a0a0890031203302e3218051028')
# Zero-distance display is valid; it must not disappear at the actual turn.
assert ns.current_position_message(0, 0, False) == bytes.fromhex('0a0b0a07080012013018061000')
def test_rerouting_resends_unchanged_instruction(nav):
stream, session, feed, now = nav
start(stream, session, now)
feed.value = GUIDING
tick(stream, now)
feed.value = {'state': 'routing'}
tick(stream, now)
session.sent.clear()
feed.value = GUIDING
tick(stream, now)
assert [kind for _, kind, _ in session.sent] == [0x8003, 0x8004, 0x8006, 0x8005, 0x8007]
def test_honda_descriptor_uses_next_turn_image_without_dimensions():
# Honda T20 discovery from session 000066: channel 9, interval 1000, type 2.
options = ns.parse_navigation(bytes.fromhex('08e8071002'))
assert options['type'] == ns.TYPE_IMAGE
assert ns.TYPE_ENUM == 1
session = Session(navigation=options)
stream = ns.NavStatus.create(session, lambda *a, **k: None, feed=Feed())
assert stream.image_size is None # no invented size; semantic guidance is still sent
@@ -0,0 +1,108 @@
"""A phone pairing window opened inside Android Auto's projection lease."""
import tempfile
import threading
import time
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock
from openpilot.starpilot.bluetooth.owner import BluetoothRejected, OpenPairingSession
from openpilot.starpilot.bluetooth.radio_preference import RadioPreference
from openpilot.starpilot.bluetooth.tests.test_accept_pair import A2DP_SOURCE, SENDER, FakeWindowBlueZ
from openpilot.starpilot.system.android_auto.bluetooth_bridge import SharedBluetoothOwner
SESSION = ('galaxy', 'source-a')
CAR = '02:00:00:00:00:01'
class PhoneWindowTest(unittest.TestCase):
def setUp(self):
temp = tempfile.TemporaryDirectory()
self.addCleanup(temp.cleanup)
root = Path(temp.name)
(root / 'bluetooth-radio').write_bytes(b'helper')
FakeWindowBlueZ.instances = []
FakeWindowBlueZ.adapter = ('/org/bluez/hci0', {}, SENDER)
FakeWindowBlueZ.bonded = threading.Event()
self.state = {'parked': True, 'valid': True}
self.owner = SharedBluetoothOwner(lambda: self.state['parked'], session_valid=lambda s: s == SESSION and self.state['valid'],
bluez_factory=FakeWindowBlueZ, lock_path=root / 'owner.lock', radio_helper=root / 'bluetooth-radio',
systemctl=lambda *a, **k: SimpleNamespace(stdout='active\n'),
radio_preference=RadioPreference(root / 'BluetoothEnabled'))
self.owner.journal_path.write_text('{"lease": "original visibility"}')
self.lease = SimpleNamespace(released=False, session=None, discovering=False, close_agent=None, phone=Mock(),
adapter_path='/org/bluez/hci0', bluez_sender=SENDER, was_pairable=True, was_discoverable=False,
process_lock=Mock(), receiver=CAR)
self.owner.phone_lease = self.lease # ty: ignore[invalid-assignment] # test double
def claimed(self):
for _ in range(300):
result = self.owner.phone_window('status', SESSION)
if result and result['pairing'] and result['pairing']['prompt']:
return result['pairing']
time.sleep(0.01)
self.fail('phone never claimed the window')
def test_window_pairs_a_phone_without_touching_the_lease_or_its_journal(self):
started = self.owner.phone_window('accept_pair', SESSION)
assert started is not None
self.assertTrue(started['pairing']['incoming'])
prompt = self.claimed()
self.assertEqual(prompt['address'], '11:22:33:44:55:66')
self.assertTrue(prompt['prompt']['displayOnly'], 'the code is shown, not waited on')
FakeWindowBlueZ.bonded.set()
self.assertTrue(self.owner.pairing.done.wait(2))
final = self.owner.phone_window('status', SESSION)
assert final is not None
self.assertEqual(final['pairing']['state'], 'paired')
self.assertEqual(final['windowPaired'], {'address': '11:22:33:44:55:66', 'name': 'Pixel', 'uuids': [A2DP_SOURCE]})
self.assertIs(self.owner.phone_lease, self.lease)
self.assertFalse(self.lease.released)
self.assertEqual(self.owner.journal_path.read_text(), '{"lease": "original visibility"}')
def test_no_projection_means_not_handled(self):
self.owner.phone_lease = None
for operation in ('accept_pair', 'status', 'cancel_pair'):
self.assertIsNone(self.owner.phone_window(operation, SESSION))
def test_refused_while_unparked_mid_pairing_or_with_an_expired_session(self):
self.state['parked'] = False
with self.assertRaises(BluetoothRejected) as error:
self.owner.phone_window('accept_pair', SESSION)
self.assertEqual(error.exception.code, 'park_required')
self.state['parked'] = True
self.lease.session = ('aa', 'car-pairing')
with self.assertRaises(BluetoothRejected) as error:
self.owner.phone_window('accept_pair', SESSION)
self.assertEqual(error.exception.code, 'busy')
self.lease.session = None
self.state['valid'] = False
with self.assertRaises(BluetoothRejected):
self.owner.phone_window('accept_pair', SESSION)
self.assertIsNone(self.owner.pairing)
def test_other_sessions_cannot_answer_or_read_the_window(self):
self.owner.phone_window('accept_pair', SESSION)
prompt = self.claimed()
other = ('galaxy', 'source-b')
self.owner.session_valid = lambda s: s in (SESSION, other)
self.assertIsNone(self.owner.phone_window('status', other))
self.assertIsNone(self.owner.phone_window('pairing_response', other, prompt_id=prompt['prompt']['id'], accepted=True))
self.owner.phone_window('cancel_pair', SESSION)
self.assertTrue(self.owner.pairing.done.wait(2))
self.assertNotEqual(self.owner.pairing.state, 'paired')
def test_ending_projection_closes_the_window_before_the_lease_restores_visibility(self):
self.owner.phone_window('accept_pair', SESSION)
self.claimed()
window = self.owner.pairing
self.assertIsInstance(window, OpenPairingSession)
self.owner.release_phone_role(self.lease) # ty: ignore[invalid-argument-type] # test double
self.assertTrue(window.done.is_set())
self.assertEqual(FakeWindowBlueZ.instances[-1].visibility[-1], ('/org/bluez/hci0', True, False))
self.assertFalse(self.owner.journal_path.exists())
if __name__ == '__main__':
unittest.main()
@@ -98,3 +98,26 @@ def test_sender_is_best_effort_when_native_ui_unavailable(tmp_path):
assert not sender.send(50)
finally:
sender.close()
def test_tap_after_a_frame_draw_still_sends(transport):
import time
from openpilot.starpilot.system.android_auto.projection_bookmark import request_bookmark
from openpilot.starpilot.ui.runtime_snapshot import current_message, display_message
receiver, sender = transport
now = time.monotonic_ns()
class SubMaster(NS):
def __getitem__(self, service):
return f'{service} message'
def ui(age_ns, *, started=True, valid=True):
sm = SubMaster(logMonoTime={'carState': now - age_ns}, valid={'carState': valid}, alive={'carState': True},
recv_frame={'carState': 5})
return NS(started=started, started_frame=1, sm=sm)
assert current_message(ui(30_000_000).sm, 'carState', now, after_frame=1) is None, 'the old check dropped every tap'
assert request_bookmark(sender, ui(30_000_000), display_message, now)
assert receiver.drain(time.monotonic_ns(), now - 30_000_000, authorized=True) == 1
for state in (ui(300_000_000), ui(30_000_000, started=False), ui(30_000_000, valid=False)):
assert not request_bookmark(sender, state, display_message, now)
assert receiver.drain(time.monotonic_ns(), now, authorized=True) == 0
@@ -10,7 +10,7 @@ import pytest
from openpilot.starpilot.navigation.owner import NavigationOwner, destination
from openpilot.starpilot.system.android_auto.current_car_ui import projected_touches
from openpilot.starpilot.system.android_auto.projection_favorites import ProjectionFavorites
from openpilot.starpilot.system.android_auto.projection_favorites import ProjectionFavorites, destination_label
from openpilot.starpilot.system.android_auto.projection_geometry import projection_geometry
from openpilot.starpilot.system.android_auto.projection_layout import (
BOOKMARK_WIDGET, CAR_EXIT, FAVORITE_WIDGETS, default_layout_for_viewport, layout_metadata_for_viewport, projection_customization,
@@ -37,6 +37,17 @@ def favorites(tmp_path, monkeypatch):
control.close()
def test_destination_label_names_home_and_work(favorites):
document = favorites.document
home, work = document['favorites']
assert destination_label(None) == ''
assert destination_label(document) == ''
assert destination_label({**document, 'destination': home}) == 'Home'
assert destination_label({**document, 'destination': work}) == 'Work'
other = destination({'name': 'Costco', 'latitude': 36.3, 'longitude': -115.3})
assert destination_label({**document, 'destination': other}) == 'Costco'
def state(*, ordered=False):
layout = default_layout_for_viewport(VIEWPORT)
for key in FAVORITE_WIDGETS:
@@ -18,6 +18,25 @@ SCREEN = {'version': 1, 'width': 1280, 'height': 720, 'margin_width': 0,
class TestProjectionLayout(unittest.TestCase):
def test_jetlink_widget_migrates_and_preserves_placement_and_order(self):
document = default_layout(SCREEN)
expected = document['widgets'].pop('jetlink_status')
self.assertFalse(expected['enabled'])
document['widgetOrder'] = [key for key in layout_metadata(SCREEN)['widgetOrder'] if key != 'jetlink_status']
document['widgets']['current_speed']['x'] += 20
before = copy.deepcopy(document)
migrated = validate_layout(document, SCREEN)
self.assertEqual(document, before)
self.assertEqual(migrated['widgets']['jetlink_status'], expected)
self.assertEqual(migrated['widgetOrder'], [*before['widgetOrder'][:-1], 'jetlink_status', CAR_EXIT])
migrated['widgets']['jetlink_status'].update(enabled=True, x=700, y=500)
decoded = decode_layout(json.dumps(migrated).encode(), SCREEN)
base = default_document()
converted = projection_customization(decoded, base)
self.assertEqual(converted['layouts']['large']['jetlink_status'], migrated['widgets']['jetlink_status'])
self.assertNotIn('jetlink_status', base['layouts']['large'])
self.assertNotIn('jetlink_status', converted['layouts']['compact'])
def test_exit_defaults_and_monitor_scaling_preserve_saved_appearance(self):
document = default_layout(SCREEN)
metadata = layout_metadata(SCREEN)
@@ -379,3 +398,43 @@ class TestProjectionLayout(unittest.TestCase):
self.assertGreaterEqual(overlay['y'], card['y'] + 195)
self.assertLessEqual(overlay['y'] + overlay['height'], document['canvas']['height'] - 30)
self.assertLess(small['widgets'][NAV_MAP]['width'], wide['widgets'][NAV_MAP]['width'])
class TestNowPlayingWidget(unittest.TestCase):
def test_default_card_is_on_with_a_background_opacity(self):
placement = default_layout(SCREEN)['widgets']['now_playing']
self.assertTrue(placement['enabled'])
self.assertEqual(placement['opacity'], 85)
self.assertFalse(placement['collapse'])
self.assertEqual(set(placement), {'x', 'y', 'enabled', 'opacity', 'collapse'})
def test_opacity_is_validated_and_old_layouts_gain_the_card(self):
document = default_layout(SCREEN)
self.assertEqual(validate_layout(copy.deepcopy(document), SCREEN)['widgets']['now_playing']['opacity'], 85)
for bad in (101, -1, 50.5, '50'):
broken = copy.deepcopy(document)
broken['widgets']['now_playing']['opacity'] = bad
with self.assertRaises(ValueError):
validate_layout(broken, SCREEN)
old = copy.deepcopy(document)
del old['widgets']['now_playing']
self.assertEqual(validate_layout(old, SCREEN)['widgets']['now_playing'], document['widgets']['now_playing'])
def test_collapse_on_pause_is_a_validated_option_older_layouts_default_off(self):
document = default_layout(SCREEN)
document['widgets']['now_playing']['collapse'] = True
self.assertTrue(validate_layout(copy.deepcopy(document), SCREEN)['widgets']['now_playing']['collapse'])
self.assertTrue(layout_metadata(SCREEN)['widgets']['now_playing']['collapseToggle'])
for bad in (1, 'on', None):
broken = copy.deepcopy(document)
broken['widgets']['now_playing']['collapse'] = bad
with self.assertRaises(ValueError):
validate_layout(broken, SCREEN)
old = copy.deepcopy(document)
del old['widgets']['now_playing']['collapse']
self.assertFalse(validate_layout(old, SCREEN)['widgets']['now_playing']['collapse'])
def test_card_stays_below_the_exit_control(self):
order = layout_metadata(SCREEN)['widgetOrder']
self.assertIn('now_playing', order)
self.assertEqual(order[-1], CAR_EXIT)
@@ -302,7 +302,7 @@ class TestProjectionLayoutRuntime(unittest.TestCase):
appearance = rendered[-1].appearance
self.assertEqual((appearance.hide_personality_alerts, appearance.wheel_pedal_feedback, appearance.camera_view,
appearance.driver_camera_on_reverse, rendered[-1].reverse_driver_camera),
(False, False, CameraViewChoice.DRIVER, True, True))
(False, False, CameraViewChoice.DRIVER, True, False), 'the car screen never switches to the driver camera')
self.assertIs(adapter.appearance_override(comma).hide_personality_alerts, False, 'the adapter hides notices by AA choice')
view.apply_layout({**layout, 'hidePersonalityAlerts': True, 'wheelPedalFeedback': True})
self.assertEqual(invalidated, [1])
@@ -25,9 +25,11 @@ class FakeMap:
class FakeFeed:
def __init__(self):
self.reads = []
self.destinations = []
def read(self, sm, *, navigation_requested=None):
def read(self, sm, *, navigation_requested=None, destination=None):
self.reads.append(sm)
self.destinations.append(destination)
return "map input"
@@ -62,6 +64,7 @@ class TestProjectionMap(unittest.TestCase):
view.prepare()
self.assertEqual(view.map.prepared, [("map input", 640, 480)])
self.assertEqual(view.map_feed.reads, [native.ui_state.sm])
self.assertEqual(view.map_feed.destinations, [None], "no favorites: the feed keeps navigation's own name")
view._map_layer(None, None)
self.assertEqual(view.map.drawn, [((100, 200, 640, 480), 0.45)])
overlay = view.map
@@ -55,6 +55,31 @@ class FakeGraphics:
class TestProjectionOnroad(unittest.TestCase):
def test_jetlink_feed_follows_placement_and_drive_and_closes(self):
from openpilot.starpilot.system.android_auto.projection_layout import default_layout_for_viewport
from openpilot.starpilot.models.jetlink_status import JetlinkStatus
native, _ = self.dependencies()
native.ui_state.params = object()
feed = Mock()
feed.snapshot.return_value = JetlinkStatus('iPhone', 'active', 24.6)
native.jetlink_feed = Mock(return_value=feed)
layout = default_layout_for_viewport((2880, 1080))
view = projection.ProjectionOnroad(dependencies=native, viewport=(2880, 1080), customization=layout)
self.addCleanup(view.close)
view.onroad.jetlink_status = SimpleNamespace()
view.prepare()
feed.set_enabled.assert_called_with(False)
layout['widgets']['jetlink_status']['enabled'] = True
native.ui_state.started = True
view.prepare()
feed.set_enabled.assert_called_with(True)
self.assertEqual(view.onroad.jetlink_status.status, feed.snapshot.return_value)
native.ui_state.started = False
view.prepare()
feed.set_enabled.assert_called_with(False)
view.close()
feed.close.assert_called_once()
def test_navigation_touch_owner_receives_coordinates_and_cancels_offroad(self):
native, _ = self.dependencies()
owner = Mock(document={'favorites': []}, error='')
@@ -388,6 +413,89 @@ class TestProjectionOnroad(unittest.TestCase):
self.assertIs(view._with_certificate_notice(state, 0), state)
view.close()
def test_bookmark_saved_shows_two_seconds_under_real_alerts(self):
native, _ = self.dependencies()
@dataclass(frozen=True)
class Alert:
size: str = 'none'
text1: str = ''
alert_type: str = ''
@dataclass(frozen=True)
class State:
alert: Alert = Alert()
native.alert, native.alert_size = Alert, SimpleNamespace(NONE='none', SMALL='small')
view = projection.ProjectionOnroad(dependencies=native)
quiet = State()
self.assertIs(view._with_bookmark_notice(quiet, 0), quiet, 'nothing until a bookmark is saved')
view.bookmark_saved(now_ns=1_000)
shown = view._with_bookmark_notice(quiet, 1_000)
self.assertEqual((shown.alert.size, shown.alert.text1), ('small', 'Bookmark Saved'))
self.assertEqual(view._with_bookmark_notice(quiet, 1_000 + projection.BOOKMARK_NOTICE_NS - 1).alert.text1, 'Bookmark Saved')
real = State(Alert(size='full', text1='Take Control'))
self.assertIs(view._with_bookmark_notice(real, 2_000), real, 'a real alert wins')
self.assertIs(view._with_bookmark_notice(quiet, 1_000 + projection.BOOKMARK_NOTICE_NS), quiet)
view.close()
def test_mode_change_shows_a_brief_banner(self):
from openpilot.starpilot.conditional_mode.policy import ModeChoice
native, _ = self.dependencies()
@dataclass(frozen=True)
class Alert:
size: str = 'none'
text1: str = ''
alert_type: str = ''
@dataclass(frozen=True)
class State:
alert: Alert = Alert()
experimental_enabled: bool = False
conditional_configured: object = None
conditional_effective: object = None
engaged: bool = True
native.alert, native.alert_size = Alert, SimpleNamespace(NONE='none', SMALL='small')
view = projection.ProjectionOnroad(dependencies=native)
chill, experimental = State(), State(experimental_enabled=True)
self.assertIs(view._with_mode_notice(chill, 0), chill, "the drive's starting mode is not announced")
shown = view._with_mode_notice(experimental, 1_000)
self.assertEqual((shown.alert.size, shown.alert.text1), ('small', 'Experimental Mode'))
real = State(Alert(size='full', text1='Take Control'), experimental_enabled=True)
self.assertIs(view._with_mode_notice(real, 2_000), real, 'a real alert wins')
self.assertIs(view._with_mode_notice(experimental, 1_000 + projection.MODE_NOTICE_NS), experimental)
self.assertEqual(view._with_mode_notice(chill, 5_000_000_000).alert.text1, 'Chill Mode')
view = projection.ProjectionOnroad(dependencies=native)
def conditional(reason, effective=True):
return State(experimental_enabled=effective, conditional_configured=ModeChoice.CEM,
conditional_effective=SimpleNamespace(reason=reason, effective_experimental=effective))
view._with_mode_notice(conditional('lead', True), 0)
for now, frame in enumerate((conditional('cruising', False), State(conditional_configured=ModeChoice.CEM),
conditional('lead', True)), start=1):
self.assertIs(view._with_mode_notice(frame, now), frame)
self.assertEqual(view._with_mode_notice(conditional('manual_chill', False), 10).alert.text1, 'Chill Mode')
view = projection.ProjectionOnroad(dependencies=native)
def parked(experimental):
return State(experimental_enabled=experimental, conditional_configured=ModeChoice.CCM, engaged=False)
view._with_mode_notice(parked(False), 0)
self.assertEqual(view._with_mode_notice(parked(True), 1).alert.text1, 'Experimental Mode')
self.assertEqual(view._with_mode_notice(parked(False), 3_000_000_000).alert.text1, 'Chill Mode')
view = projection.ProjectionOnroad(dependencies=native)
view._with_mode_notice(conditional('manual_chill', False), 0)
for now, frame in enumerate((conditional('lead', True), conditional('manual_experimental', True)), start=1):
shown = view._with_mode_notice(frame, now)
self.assertEqual(shown.alert.text1, 'Experimental Mode')
quiet = view._with_mode_notice(conditional('cruising', False), 10_000_000_000)
self.assertEqual(quiet.alert.text1, '')
stopped = State(experimental_enabled=True, conditional_configured=ModeChoice.CEM, engaged=False)
self.assertIs(view._with_mode_notice(stopped, 20_000_000_000), stopped)
view.close()
def test_negotiated_viewport_overrides_landscape_fallback(self):
native, _ = self.dependencies()
view = projection.ProjectionOnroad(dependencies=native, viewport=(2880, 1080))
@@ -158,3 +158,28 @@ def test_projection_experimental_tap_is_not_gated_by_the_comma_screen(monkeypatc
session._projection_experimental(True)
invoke.assert_called_once_with()
session._unavailable.assert_called_once()
def test_projected_taps_tolerate_touchscreen_drift():
from dataclasses import replace
from openpilot.starpilot.system.android_auto.projection_geometry import projection_geometry
from openpilot.starpilot.system.android_auto.projection_layout import default_layout_for_viewport, projection_customization
from openpilot.starpilot.ui.onroad_customization import default_document, offset, widget_size
from openpilot.starpilot.ui.onroad_state import OnroadInput, OnroadRequest, OnroadState, SpeedLimitObservation
geometry = projection_geometry(1280, 720)
width = geometry.logical_width
customization = projection_customization(default_layout_for_viewport((width, geometry.logical_height)), default_document())
state = replace(OnroadState(True, True, 15, 80, SpeedLimitObservation()), customization=customization,
experimental_available=True, viewport_width=width)
dx, dy = offset(customization, 'large', 'steering_wheel')
size, _ = widget_size(customization, 'large', 'steering_wheel')
x, y = width - 146 - 96 + dx + size / 2, 45 + dy + size / 2
emitted = []
gesture = OnroadInput(emitted.append, slop=30)
gesture.press(x, y, state)
gesture.move(x + 12, y - 9, state)
gesture.release(x + 15, y - 12, state)
assert emitted == [OnroadRequest('set_experimental', True, '')]
gesture.press(x, y, state)
gesture.release(x + 40, y, state)
assert len(emitted) == 1
@@ -27,6 +27,7 @@ ACTION_POINTER_UP = 6
MAX_INPUT_BYTES = 16 * 1024
MAX_KEYCODES = 256
DEFAULT_TOUCH_SOCKET = "/tmp/starpilot-android-auto-touch.sock"
MEDIA_KEYS = {85: "play_pause", 87: "next", 88: "previous", 126: "play", 127: "pause"}
@dataclass(frozen=True)
@@ -145,6 +146,34 @@ class TouchMapper:
return events
class KeyTracker:
"""Media button presses, one action per press however long the button is held."""
def __init__(self):
self.held: set[int] = set()
def decode(self, data: bytes) -> list[str]:
if len(data) > MAX_INPUT_BYTES:
raise ValueError("Input message exceeds limit")
buttons = one(parse_fields(data), 4)
if not isinstance(buttons, bytes):
return []
actions = []
for raw in parse_fields(buttons).get(1, [])[:16]:
if not isinstance(raw, bytes):
continue
key = parse_fields(raw)
code = int(one(key, 1, 0) or 0)
if code not in MEDIA_KEYS:
continue
if not one(key, 2, 0):
self.held.discard(code)
elif code not in self.held:
self.held.add(code)
actions.append(MEDIA_KEYS[code])
return actions
class TouchSender:
"""android_autod side: best-effort datagrams to the car-UI renderer."""
+29 -1
View File
@@ -21,6 +21,20 @@ from openpilot.starpilot.ui.home_stats import DriveStatsDashboard
from openpilot.starpilot.ui.presentation import BitmapFonts, FontRole, Profile
BLUETOOTH_COLORS = {"off": rl.Color(255, 255, 255, 102), "on": rl.Color(255, 255, 255, 230), "connected": rl.Color(0, 130, 252, 230)}
JETLINK_COLORS = {"plugged": rl.Color(160, 160, 160, 255), "connected": rl.Color(70, 200, 110, 255)}
def draw_jetlink(rect: rl.Rectangle, state: str) -> None:
"""Jetlink's home icon: a tablet outline with a link dot."""
color = JETLINK_COLORS.get(state)
if color is None:
return
body = rl.Rectangle(rect.x + rect.width * 0.18, rect.y, rect.width * 0.64, rect.height)
rl.draw_rectangle_rounded_lines_ex(body, 0.3, 8, max(2.0, rect.width * 0.08), color)
rl.draw_circle_v(rl.Vector2(body.x + body.width / 2, body.y + body.height * 0.8), max(2.0, rect.width * 0.07), color)
def validate_home_asset(directory: Path, filename: str) -> Path:
manifest = json.loads(Path(__file__).with_name("home-assets.json").read_text())
entry = next((row for row in manifest["files"] if row["file"] == filename), None)
@@ -70,6 +84,17 @@ class HomeView:
texture = self._texture(filename, width, height)
rl.draw_texture_ex(texture, rl.Vector2(x, y), 0.0, 1.0, rl.Color(255, 255, 255, int(opacity * 255)))
def _bluetooth(self, state, x, y):
"""Like the Wi-Fi icon: dimmed with a slash when off, white when on, blue with a device connected."""
texture = self._texture("icons_mici/settings/bluetooth.png", 38, 38)
color = BLUETOOTH_COLORS.get(state, BLUETOOTH_COLORS["off"])
rl.draw_texture_ex(texture, rl.Vector2(x, y), 0.0, 1.0, color)
if state == "off":
pad = 38 * 0.08
start, end = rl.Vector2(x + pad, y + pad), rl.Vector2(x + 38 - pad, y + 38 - pad)
rl.draw_line_ex(start, end, 9.0, rl.BLACK)
rl.draw_line_ex(start, end, 4.0, color)
def _text(self, text, role, size, x, y, color=rl.WHITE):
self.fonts.draw(text, role, size, x, y, color)
@@ -219,7 +244,7 @@ class HomeView:
self._icon("icons_mici/settings/network/wifi_strength_slash.png", 50, 44, 76, 190.5, 0.9)
x = 146
if state.bluetooth:
self._icon("icons_mici/settings/bluetooth.png", 38, 38, x, 197, 0.9)
self._bluetooth(state.bluetooth, x, 197)
x += 56
if state.mode == HomeMode.CHILL:
draw_moon(rl.Rectangle(x + 5, 197, 38, 38))
@@ -233,6 +258,9 @@ class HomeView:
opacity = 0.35 + 0.65 * (0.5 - 0.5 * math.cos(rl.get_time() * 6)) if loading else 1.0
self._icon("icons_mici/" + icon, 68 if failed else 54, 40, x, 197, opacity)
x += 76
if state.jetlink in JETLINK_COLORS:
draw_jetlink(rl.Rectangle(x, 195, 40, 42), state.jetlink)
x += 58
if state.recording_audio:
self._icon("icons_mici/microphone.png", 32, 46, x, 193)
+2 -1
View File
@@ -70,7 +70,7 @@ class HomeState:
temperature_c: float | None = None
vehicle_online: bool = True
connection: str = "OFFLINE"
bluetooth: bool = False
bluetooth: str = ""
gpu_present: bool = False
gpu_active: bool = False
recording_audio: bool = False
@@ -78,6 +78,7 @@ class HomeState:
gpu_state: str = "disconnected"
metric: bool = False
temperature_high: bool = False
jetlink: str = "off"
@dataclass(frozen=True)
@@ -214,6 +214,10 @@ class _Canvas:
self.view = ProjectionOnroad.create_view(OnroadView, self.fonts, viewport=viewport, **layers)
from openpilot.starpilot.system.android_auto.projection_exit import ProjectionExit
self.view.projection_exit_layer = ProjectionExit(rl).render
self.view.now_playing.track = {'source': 'spotify', 'status': 'playing', 'title': 'Midnight City', 'artist': 'M83', 'album': '',
'durationMs': 243000, 'position': {'ms': 92000, 'at': 0, 'speed': 0.0}}
from openpilot.starpilot.models.jetlink_status import JetlinkStatus
self.view.jetlink_status.status = JetlinkStatus('iPhone', 'active', 24.6, 'connected')
from openpilot.starpilot.ui.onroad_map import MapOverlay, SampleTileReader
self.map = MapOverlay(reader=SampleTileReader(), fonts=self.fonts)
self.view.map_layer = self._render_map
@@ -0,0 +1,14 @@
import json
from pathlib import Path
ASSETS = Path(__file__).parent / 'assets/navigation'
MANIFEST = {row['file']: row for row in json.loads(Path(__file__).with_name('navigation-assets.json').read_text())['files']}
def icon_name(maneuver_type: str, modifier: str) -> str:
kind = {'rotary': 'roundabout', 'new name': 'turn', 'continue': 'turn'}.get(maneuver_type, maneuver_type).replace(' ', '_')
suffix = {'slightLeft': 'slight_left', 'slightRight': 'slight_right', 'sharpLeft': 'sharp_left',
'sharpRight': 'sharp_right'}.get(modifier, modifier).replace(' ', '_')
candidate = 'direction_uturn.png' if suffix == 'uturn' else f'direction_{kind}{"_" + suffix if suffix else ""}.png'
return candidate if candidate in MANIFEST else 'direction_turn_straight.png'
+15
View File
@@ -119,6 +119,11 @@ class OnroadView:
self.alert = AlertRenderer(fonts)
self.navigation = NavigationCard(fonts, clock=self.wall_clock)
self.navigation_favorites = NavigationFavorites(fonts)
if projection_viewport is not None:
from openpilot.starpilot.ui.onroad_now_playing import NowPlayingWidget
self.now_playing = NowPlayingWidget(fonts)
from openpilot.starpilot.ui.onroad_jetlink import JetlinkStatusWidget
self.jetlink_status = JetlinkStatusWidget(fonts)
self.torque_bar = TorqueBarWidget()
if fonts.profile == Profile.LARGE:
self.unified_speed = UnifiedSpeedWidget(fonts)
@@ -293,6 +298,12 @@ class OnroadView:
for key in ("nav_home", "nav_work", "bookmark"):
if key in state.customization["layouts"]["large"]:
submit(key, lambda key=key: self.navigation_favorites.render(key, state))
if "now_playing" in state.customization["layouts"]["large"]:
if now_playing := getattr(self, "now_playing", None):
submit("now_playing", lambda: now_playing.render(state))
if "jetlink_status" in state.customization["layouts"]["large"]:
if jetlink := getattr(self, "jetlink_status", None):
submit("jetlink_status", lambda: jetlink.render(state))
if "car_exit" in state.customization["layouts"]["large"]:
if exit_layer := getattr(self, "projection_exit_layer", None):
submit("car_exit", lambda: exit_layer(state))
@@ -400,6 +411,10 @@ class OnroadView:
if not ordered:
for key in ("nav_home", "nav_work", "bookmark"):
self.navigation_favorites.render(key, state)
if now_playing := getattr(self, "now_playing", None):
now_playing.render(state)
if jetlink := getattr(self, "jetlink_status", None):
jetlink.render(state)
self._driving_mode(state, right_shift / 2)
self._clock(state, right_shift / 2)
if exit_layer := getattr(self, "projection_exit_layer", None):
+51
View File
@@ -0,0 +1,51 @@
"""Jetlink's Android Auto card; both live rendering and previews use this drawing."""
import pyray as rl
from openpilot.starpilot.models.jetlink_status import JetlinkStatus
from openpilot.starpilot.system.android_auto.projection_layout import JETLINK_SIZE, JETLINK_WIDGET
from openpilot.starpilot.ui.presentation import FontRole
LABELS = {'active': 'Link up', 'connected': 'Host connected', 'ready': 'Ready to switch',
'connecting': 'Connecting', 'disconnected': 'Disconnected', 'unavailable': 'Unavailable'}
GREEN = rl.Color(70, 200, 110, 255)
ORANGE = rl.Color(255, 150, 60, 255)
ACCENT = rl.Color(199, 174, 247, 255)
GRAY = rl.Color(140, 136, 152, 255)
TEXT = rl.Color(245, 242, 252, 255)
MUTED = rl.Color(184, 178, 200, 255)
DOT = {'active': GREEN, 'ready': GREEN, 'unavailable': ORANGE, 'disconnected': GRAY}
class JetlinkStatusWidget:
def __init__(self, fonts):
self.fonts = fonts
self.status = JetlinkStatus()
def _fit(self, text, role, size, width):
measured = self.fonts.measure(text, role, size)
return size * width / measured.width if measured.width > width else size
def render(self, state):
placed = state.customization['layouts']['large'].get(JETLINK_WIDGET)
status = self.status
if not placed or not placed['enabled'] or status.state == 'off' or state.alert.size != 'none':
return
rect = rl.Rectangle(placed['x'], placed['y'], *JETLINK_SIZE)
h = rect.height
rl.draw_rectangle_rounded(rect, 1.0, 16, rl.Color(15, 13, 23, round(235 * placed.get('opacity', 85) / 100)))
rl.draw_circle_v(rl.Vector2(rect.x + h / 2, rect.y + h / 2), 9, DOT.get(status.state, ACCENT))
right = rect.x + rect.width - 30
if status.state == 'active' and status.latency_ms is not None:
value, unit = f'{status.latency_ms:.1f}', 'ms'
unit_w = self.fonts.measure(unit, FontRole.NORMAL, 22).width
value_w = self.fonts.measure(value, FontRole.SEMI_BOLD, 34).width
self.fonts.draw(unit, FontRole.NORMAL, 22, right - unit_w, rect.y + h / 2 - 6, MUTED)
right -= unit_w + 8
self.fonts.draw(value, FontRole.SEMI_BOLD, 34, right - value_w, rect.y + h / 2 - 18, TEXT)
right -= value_w + 16
left = rect.x + h - 6
width = right - left
title = LABELS.get(status.state, 'Unavailable')
detail = f'Jetlink • {status.device}' if status.device else 'Jetlink'
self.fonts.draw(title, FontRole.SEMI_BOLD, self._fit(title, FontRole.SEMI_BOLD, 28, width), left, rect.y + 12, TEXT)
self.fonts.draw(detail, FontRole.NORMAL, self._fit(detail, FontRole.NORMAL, 22, width), left, rect.y + 46, MUTED)
+74 -18
View File
@@ -29,7 +29,7 @@ import numpy as np
import pyray as rl
from openpilot.starpilot.gps.acquisition import GpsAcquisition, acquisition_progress
from openpilot.starpilot.gps.source import GPS_MAX_AGE_NS
from openpilot.starpilot.gps.source import GPS_MAX_AGE_NS, PHONE_MAX_AGE_NS
from openpilot.starpilot.navigation.road_tiles import (
DATA_ZOOM, EXTENT, LINK, MAJOR, MINOR, MOTORWAY, STREET, RoadTile, TileFormatError, TileKey, decode_road_tile,
encode_road_tile, meters_per_tile, world_xy,
@@ -41,7 +41,10 @@ MAX_CANVAS = 2560
TILE_CACHE = 48
MISSING_RETRY_S = 3.0
DEAD_RECKON_S = 1.0
PHONE_DEAD_RECKON_S = GPS_MAX_AGE_NS / 1e9 # cover phone measurement/transport gaps, independently of the 10 s hold
ROUTE_STALE_OVERLAY_SECONDS = 10.0 # a route lost mid-guidance only brings back "Starting navigation" after this long
STARTING_OVERLAY_MIN_SECONDS = 5.0 # "Starting navigation" stays up at least this long, even when the route loads sooner
DESTINATION_MAX_CHARS = 32
CAR_ANCHOR = 0.68 # the car sits below center so more road ahead is visible
ZOOM_SLOW_M_PER_PX = 0.9 # parking-lot and city detail
ZOOM_FAST_M_PER_PX = 3.4 # highway overview
@@ -70,6 +73,7 @@ ROUTE_GLOW = (150, 110, 255, 90)
ROUTE_CORE = (178, 150, 255, 255)
ROUTE_DONE = (118, 104, 150, 200)
GLASS = (8, 12, 20, 132)
SOURCE_LABELS = {"comma": "Comma GPS", "car": "Car GPS", "phone": "Phone GPS"}
REFERENCE_M_PER_PX = 1.4
@@ -276,6 +280,8 @@ class MapInput:
navigation_active: bool = False
satellites: int | None = None
gps_wait_seconds: float = 0.0
destination: str = '' # where navigation is going: "Home", "Work" or the place name
source: str = '' # where the fix comes from: comma, car or phone
class TileReader:
@@ -424,6 +430,7 @@ class MapOverlay:
self._route_key: tuple | None = None
self._route_world: np.ndarray | None = None
self._route_last_shown: float | None = None # clock time the route was last available this navigation session
self._starting_since: float | None = None # clock time "Starting navigation" went up
self._generation_built = -1
# Smoothed display state.
self._world: tuple[float, float] | None = None
@@ -439,14 +446,14 @@ class MapOverlay:
def _advance(self, data: MapInput, now: float) -> None:
self._input = data
self.status = "live" if data.fix is not None and 0 <= now - data.fix.monotonic <= GPS_MAX_AGE_NS / 1e9 else "waiting"
self.status = "live" if data.fix is not None and 0 <= now - data.fix.monotonic <= fix_hold_seconds(data.source) else "waiting"
fix = data.fix
dt = 0.0 if self._last_prepare is None else min(0.2, max(0.0, now - self._last_prepare))
self._last_prepare = now
if fix is None:
return
target_world = world_xy(fix.latitude, fix.longitude)
age = min(DEAD_RECKON_S, max(0.0, now - fix.monotonic))
age = min(PHONE_DEAD_RECKON_S if data.source == "phone" else DEAD_RECKON_S, max(0.0, now - fix.monotonic))
if fix.bearing is not None and fix.speed > MIN_HEADING_SPEED:
units = fix.speed * age / meters_per_tile(fix.latitude)
heading = math.radians(fix.bearing)
@@ -477,11 +484,24 @@ class MapOverlay:
self._route_last_shown = now
elif not data.navigation_active:
self._route_last_shown = None
if not data.navigation_active:
self._starting_since = None
elif self._waiting_for_route(data, now):
if self._starting_since is None:
self._starting_since = now
elif self._starting_since is not None and now - self._starting_since >= STARTING_OVERLAY_MIN_SECONDS:
self._starting_since = None
def _waiting_for_route(self, data: MapInput, now: float) -> bool:
"""Navigation is active without a route: at once on a fresh start, after a grace period once a route was lost."""
return (data.navigation_active and self._route_world is None and
(self._route_last_shown is None or now - self._route_last_shown >= ROUTE_STALE_OVERLAY_SECONDS))
def _starting_navigation(self) -> bool:
"""Navigation is active without a route: at once on a fresh start, after a grace period once a route was lost."""
return (self._input.navigation_active and self._route_world is None and
(self._route_last_shown is None or self.clock() - self._route_last_shown >= ROUTE_STALE_OVERLAY_SECONDS))
"""While waiting for the route, and for a short minimum so the card can be read when the route is quick."""
now = self.clock()
return self._waiting_for_route(self._input, now) or (
self._input.navigation_active and self._starting_since is not None and now - self._starting_since < STARTING_OVERLAY_MIN_SECONDS)
# ---- canvas planning
@@ -706,8 +726,9 @@ class MapOverlay:
if self.status == "waiting":
self._draw_gps_status(rect)
return
self._draw_source_chip(rect)
if self._starting_navigation():
self._draw_starting_navigation(rect, opacity)
self._draw_starting_navigation(rect)
return
text = "" if self.has_roads or self._canvas_request is None else "No map data here yet"
if text and self.fonts is not None:
@@ -716,25 +737,50 @@ class MapOverlay:
self.fonts.draw(text, FontRole.MEDIUM, 26, rect.x + (rect.width - measured.width) / 2,
rect.y + rect.height * CAR_ANCHOR + 40, rl.Color(225, 228, 238, round(200 * opacity)))
def _draw_starting_navigation(self, rect: rl.Rectangle, opacity: float) -> None:
"""Shown while navigation is starting but its route has not arrived to draw yet."""
def _draw_source_chip(self, rect: rl.Rectangle) -> None:
"""Where the fix comes from, left of the compass; solid whatever the map's opacity so it always reads."""
label = SOURCE_LABELS.get(self._input.source)
if label is None or self.fonts is None:
return
from openpilot.starpilot.ui.presentation import FontRole
scale = max(.7, min(1.0, rect.width / 560, rect.height / 420))
size, pad_x, height = 17 * scale, 10 * scale, 26 * scale
inset = max(22.0, min(rect.width, rect.height) * 0.09)
measured = self.fonts.measure(label, FontRole.SEMI_BOLD, size)
width = measured.width + 2 * pad_x
chip = rl.Rectangle(rect.x + rect.width - inset - 13 - 8 * scale - width, rect.y + inset - height / 2, width, height)
if chip.x < rect.x + 4:
return
rl.draw_rectangle_rounded(chip, 1.0, 10, rl.Color(15, 13, 23, 255))
rl.draw_rectangle_rounded_lines_ex(chip, 1.0, 10, 1.5, rl.Color(150, 140, 200, 255))
self.fonts.draw(label, FontRole.SEMI_BOLD, size, chip.x + pad_x, chip.y + (height - measured.height) / 2, rl.Color(240, 242, 250, 255))
def _draw_starting_navigation(self, rect: rl.Rectangle) -> None:
"""Shown while navigation is starting; solid whatever the map's opacity so it always reads."""
if self.fonts is None:
return
from openpilot.starpilot.ui.presentation import FontRole
scale = max(.7, min(1.0, rect.width / 560, rect.height / 420))
inset, padding = 20 * scale, 14 * scale
width = rect.width - 2 * inset
rows = [('Starting navigation', FontRole.SEMI_BOLD, 26), ('Finding your route…', FontRole.NORMAL, 18)]
destination = self._input.destination.strip()
if len(destination) > DESTINATION_MAX_CHARS:
destination = destination[:DESTINATION_MAX_CHARS - 1].rstrip() + '…'
rows = [('Starting navigation', FontRole.SEMI_BOLD, 26)]
if destination:
rows.append((f'to {destination}', FontRole.MEDIUM, 22))
if self._route_world is None:
rows.append(('Finding your route…', FontRole.NORMAL, 18))
scale = min(scale, *(max(1, width - 2 * padding) / self.fonts.measure(text, role, size).width
for text, role, size in rows))
height = (len(rows) * 32 + 22) * scale
card = rl.Rectangle(rect.x + inset, rect.y + rect.height - inset - height, width, height)
rl.draw_rectangle_rounded(card, .2, 8, rl.Color(15, 13, 23, round(235 * opacity)))
rl.draw_rectangle_rounded(card, .2, 8, rl.Color(15, 13, 23, 255))
y = card.y + 10 * scale
for text, role, size in rows:
measured = self.fonts.measure(text, role, size * scale)
self.fonts.draw(text, role, size * scale, card.x + (card.width - measured.width) / 2, y,
rl.Color(225, 228, 238, round(255 * opacity)))
rl.Color(225, 228, 238, 255))
y += 32 * scale
def _draw_gps_status(self, rect: rl.Rectangle) -> None:
@@ -783,6 +829,10 @@ class MapOverlay:
# ---------------------------------------------------------------- live input
def fix_hold_seconds(source: str) -> float:
return (PHONE_MAX_AGE_NS if source == "phone" else GPS_MAX_AGE_NS) / 1e9
class MapFeed:
"""The same GPS choice as navigation (either receiver, then the car's own), and the active route."""
@@ -793,28 +843,32 @@ class MapFeed:
sm = messaging.SubMaster(list(GPS_SERVICES))
self.sm = sm
self._fix: MapFix | None = None
self._source = ''
self._route_stamp = None
self._route_valid_until = 0
self._route: tuple[tuple[float, float], ...] = ()
self._navigation_active = False
self._destination = ''
self._acquisition = acquisition if acquisition is not None else GpsAcquisition()
self._acquisition_polled = -math.inf
def read(self, navigation_sm=None, *, navigation_requested=None) -> MapInput:
from openpilot.starpilot.gps.source import bearing, select_location
def read(self, navigation_sm=None, *, navigation_requested=None, destination=None) -> MapInput:
from openpilot.starpilot.gps.source import bearing, select_source
self.sm.update(0)
now = time.monotonic()
now_ns = time.monotonic_ns()
selected = select_location(self.sm, now_ns)
chosen = select_source(self.sm, now_ns)
selected = chosen[1] if chosen is not None else None
if selected is not None:
stamp, gps = selected
speed = float(getattr(gps, "speed", float("nan")))
if math.isfinite(speed) and -85 < gps.latitude < 85:
# The fix's own timestamp, so dead reckoning starts from when it was measured.
self._fix = MapFix(gps.latitude, gps.longitude, bearing(gps), max(0.0, speed), stamp / 1e9)
if self._fix is not None and now - self._fix.monotonic > 5.0:
self._source = chosen[0]
if self._fix is not None and now - self._fix.monotonic > max(5.0, fix_hold_seconds(self._source)):
self._fix = None
fresh = self._fix is not None and 0 <= now - self._fix.monotonic <= GPS_MAX_AGE_NS / 1e9
fresh = self._fix is not None and 0 <= now - self._fix.monotonic <= fix_hold_seconds(self._source)
if fresh:
self._acquisition.reset()
self._acquisition_polled = -math.inf
@@ -836,6 +890,7 @@ class MapFeed:
from openpilot.starpilot.navigation.wire import navigation_state
nav = navigation_state(navigation_sm["starpilotNavigation"]) if navigation_sm.valid["starpilotNavigation"] else None
active = bool(nav is not None and nav.enabled and nav.destinationName)
self._destination = nav.destinationName if active else ''
self._route_valid_until = 0
if (nav is not None and nav.enabled and nav.status in ("guiding", "arrived") and
0 < nav.frameMonoTime <= now_ns <= nav.frameMonoTime + 3_000_000_000):
@@ -856,7 +911,8 @@ class MapFeed:
acquisition_elapsed = now - acquisition_since
return MapInput(self._fix, route, active if navigation_requested is None else navigation_requested,
None if fresh else self._acquisition.satellites(now),
acquisition_elapsed)
acquisition_elapsed, self._destination if destination is None else destination,
self._source if self._fix is not None else '')
# ---------------------------------------------------------------- parked preview
+1 -14
View File
@@ -1,28 +1,15 @@
from collections import OrderedDict
from datetime import datetime, timedelta
import hashlib
import json
from pathlib import Path
import pyray as rl
from openpilot.starpilot.ui.appearance_preferences import CameraViewChoice
from openpilot.starpilot.ui.navigation_icons import ASSETS, MANIFEST, icon_name
from openpilot.starpilot.ui.navigation_state import distance_text
from openpilot.starpilot.ui.presentation import FontRole, Profile
ASSETS = Path(__file__).parent / 'assets/navigation'
MANIFEST = {row['file']: row for row in json.loads(Path(__file__).with_name('navigation-assets.json').read_text())['files']}
def icon_name(maneuver_type: str, modifier: str) -> str:
kind = {'rotary': 'roundabout', 'new name': 'turn', 'continue': 'turn'}.get(maneuver_type, maneuver_type).replace(' ', '_')
suffix = {'slightLeft': 'slight_left', 'slightRight': 'slight_right', 'sharpLeft': 'sharp_left',
'sharpRight': 'sharp_right'}.get(modifier, modifier).replace(' ', '_')
candidate = 'direction_uturn.png' if suffix == 'uturn' else f'direction_{kind}{"_" + suffix if suffix else ""}.png'
return candidate if candidate in MANIFEST else 'direction_turn_straight.png'
def _overlaps_slc_actions(rect, profile, state) -> bool:
from openpilot.starpilot.ui.large_speed_geometry import ACTION_HEADER_HEIGHT
from openpilot.starpilot.ui.onroad_state import slc_controls
@@ -0,0 +1,67 @@
"""The Android Auto now-playing card; both live rendering and previews use this drawing."""
import time
import pyray as rl
from openpilot.starpilot.soloist.now_playing import elapsed_ms
from openpilot.starpilot.system.android_auto.projection_layout import NOW_PLAYING_SIZE, NOW_PLAYING_WIDGET
from openpilot.starpilot.ui.presentation import FontRole
ACCENT = rl.Color(199, 174, 247, 255)
INK = rl.Color(15, 13, 23, 255)
TEXT = rl.Color(245, 242, 252, 255)
MUTED = rl.Color(184, 178, 200, 255)
BUTTON_R = 30
def _play_pause(center: rl.Vector2, playing: bool) -> None:
"""An accent disc with the pause bars while playing, else the play triangle."""
rl.draw_circle_v(center, BUTTON_R, ACCENT)
if playing:
for dx in (-9, 3):
rl.draw_rectangle_rounded(rl.Rectangle(center.x + dx, center.y - 12, 6, 24), .5, 4, INK)
else:
rl.draw_triangle(rl.Vector2(center.x - 7, center.y - 13), rl.Vector2(center.x - 7, center.y + 13), rl.Vector2(center.x + 13, center.y), INK)
class NowPlayingWidget:
def __init__(self, fonts, clock=time.time): # noqa: TID251 epoch-ms track anchors
self.fonts = fonts
self.clock = clock
self.track = None
def _fit(self, text, role, size, width):
measured = self.fonts.measure(text, role, size)
return size * width / measured.width if measured.width > width else size
def render(self, state):
placed = state.customization['layouts']['large'].get(NOW_PLAYING_WIDGET)
track = self.track
if not placed or not placed['enabled'] or not track or state.alert.size != 'none':
return
rect = rl.Rectangle(placed['x'], placed['y'], *NOW_PLAYING_SIZE)
h = rect.height
background = rl.Color(15, 13, 23, round(235 * placed.get('opacity', 85) / 100))
playing = track['status'] == 'playing'
if not playing and placed.get('collapse'):
# Paused: just the play button, at whichever end of the card sits nearer the screen edge.
right = rect.x + rect.width / 2 > getattr(state, 'viewport_width', 0) / 2
button = rl.Rectangle(rect.x + rect.width - h if right else rect.x, rect.y, h, h)
rl.draw_rectangle_rounded(button, 1.0, 16, background)
_play_pause(rl.Vector2(button.x + h / 2, button.y + h / 2), False)
return
rl.draw_rectangle_rounded(rect, 1.0, 16, background)
_play_pause(rl.Vector2(rect.x + h / 2, rect.y + h / 2), playing)
left = rect.x + h + 4
width = rect.x + rect.width - h / 2 - left
rows = ((track['title'], FontRole.SEMI_BOLD, 30, 12, TEXT), (track['artist'] or track['album'], FontRole.NORMAL, 22, 46, MUTED))
for text, role, size, y, color in rows:
if text:
self.fonts.draw(text, role, self._fit(text, role, size, width), left, rect.y + y, color)
duration = track.get('durationMs')
if duration and track.get('position'):
bar = rl.Rectangle(left, rect.y + h - 16, width, 4)
rl.draw_rectangle_rounded(bar, 1.0, 4, rl.Color(255, 255, 255, 50))
done = min(1.0, elapsed_ms(track, self.clock() * 1000) / duration)
if done > 0:
rl.draw_rectangle_rounded(rl.Rectangle(bar.x, bar.y, max(bar.height, bar.width * done), bar.height), 1.0, 4, ACCENT)
+4 -3
View File
@@ -332,9 +332,10 @@ def compact_sign_obscured_by_actions(state: OnroadState) -> bool:
class OnroadInput:
"""The onroad wheel emits intent only; the supplied state must be refreshed."""
def __init__(self, emit: Callable[[OnroadRequest | SlcUiRequest], None], profile: Profile = Profile.LARGE):
def __init__(self, emit: Callable[[OnroadRequest | SlcUiRequest], None], profile: Profile = Profile.LARGE, slop: float = 5):
self.emit = emit
self.profile = profile
self.slop = slop
self._press: tuple[float, float, bool, str] | None = None
self._slc_press: tuple[float, float, SlcUiRequest] | None = None
@@ -366,12 +367,12 @@ class OnroadInput:
px, py, request = self._slc_press
current = next((item for item in slc_controls(self.profile, state) if item.request == request), None)
in_control = current is not None and current.bounds[0] <= x <= current.bounds[2] and current.bounds[1] <= y <= current.bounds[3]
if current is None or abs(x - px) > 5 or abs(y - py) > 5 or not in_control:
if current is None or abs(x - px) > self.slop or abs(y - py) > self.slop or not in_control:
self.cancel()
return
if self._press:
px, py, previous, token = self._press
if (abs(x - px) > 5 or abs(y - py) > 5 or not (_inside_widget(x, y, state, "steering_wheel", (1588, 75, 1780, 267)))
if (abs(x - px) > self.slop or abs(y - py) > self.slop or not (_inside_widget(x, y, state, "steering_wheel", (1588, 75, 1780, 267)))
or previous != state.experimental_enabled or token != state.experimental_action_token or not state.experimental_available):
self.cancel()
+11 -4
View File
@@ -40,6 +40,7 @@ from openpilot.tools.replay.display_clock import BOOT_TIME_SERVICES
from opendbc.car.structs import car as car_schema
from openpilot.starpilot.ui.device_state import DeviceRequest, DeviceState
from openpilot.starpilot.ui.home_state import HomeMode, HomeState
from openpilot.starpilot.models.jetlink_status import JetlinkStatusFeed
from openpilot.starpilot.ui.onroad_state import conditional_lead_from_message
from openpilot.starpilot.ui.onroad_state import (AlertSize, BorderSignals, ObservationKind, OnroadAlert,
OnroadState, SpeedLimitObservation, is_personality_notice, speed_limit_from_message)
@@ -291,7 +292,7 @@ class RuntimeSnapshotAdapter:
"""Build one coherent display snapshot after each UIState.update()."""
def __init__(self, ui_state: Any, galaxy_access: GalaxyAccessOwner | None = None,
bluetooth_powered: Callable[[], bool] | None = None,
bluetooth_powered: Callable[[], str | bool] | None = None,
*, mono_clock: Callable[[], int] = time.monotonic_ns,
boot_clock: Callable[[], int] = _boot_time_ns):
self.ui_state = ui_state
@@ -303,6 +304,7 @@ class RuntimeSnapshotAdapter:
# A second screen (Android Auto) swaps in its own display choices before anything uses them.
self.appearance_override: Callable[[OnroadAppearance], OnroadAppearance] | None = None
self._last_mode: ShellMode | None = None
self._jetlink = JetlinkStatusFeed()
self._onroad_ancillary: tuple[int, tuple[bool, int, bool], ShellSnapshot] | None = None
self._onroad_view_ancillary: tuple[int, tuple[bool, int, bool], ShellSnapshot] | None = None
self._conditional = ConditionalDisplayProjector()
@@ -702,9 +704,13 @@ class RuntimeSnapshotAdapter:
ping = int(device.lastAthenaPingTime)
connection = "ONLINE" if 0 < ping <= now_ns and now_ns - ping < 80_000_000_000 else "OFFLINE"
try:
bluetooth = self.bluetooth_powered() is True if mode == ShellMode.HOME and self.bluetooth_powered is not None else False
value = self.bluetooth_powered() if mode == ShellMode.HOME and self.bluetooth_powered is not None else ""
bluetooth = value if value in ("off", "on", "connected") else "on" if value is True else "off" if value is False else ""
except (OSError, RuntimeError, ValueError):
bluetooth = False
bluetooth = ""
self._jetlink.set_enabled(mode == ShellMode.HOME)
jetlink_port = self._jetlink.snapshot().port
jetlink = "connected" if jetlink_port == "connected" else "plugged" if jetlink_port == "device" else "off"
gpu_present = bool(device is not None and getattr(device, "chestnutPresent", False) and
not getattr(ui, "usb_unknown", False))
gpu_active = bool(gpu_present and getattr(getattr(ui, "chestnut_state", None), "value", None) == "active")
@@ -722,7 +728,8 @@ class RuntimeSnapshotAdapter:
vehicle_online=panda_connected, connection=connection,
recording_audio=bool(ui.recording_audio), bluetooth=bluetooth,
gpu_present=gpu_present, gpu_active=gpu_active, branch=_text(params, "GitBranch"),
gpu_state=getattr(getattr(ui, "chestnut_state", None), "value", "disconnected"))
gpu_state=getattr(getattr(ui, "chestnut_state", None), "value", "disconnected"),
jetlink=jetlink)
settings_pages = (Destination.STAR, Destination.DEVICE, Destination.NETWORK, Destination.SOFTWARE, Destination.TOGGLES,
Destination.DRIVING_CONTROLS, Destination.SOUNDS, Destination.APPEARANCE,
+111 -2
View File
@@ -170,6 +170,49 @@ def test_lost_gps_marks_previous_position_stale(tmp_path):
overlay.close()
def test_phone_map_keeps_moving_between_delayed_fixes(tmp_path):
overlay = MapOverlay(tmp_path)
try:
data = MapInput(MapFix(*CENTER, 90., 20., 100.), source='phone')
positions = []
for frame in range(101):
overlay._advance(data, 100. + frame / 50)
positions.append(overlay._world[0])
assert all(b > a for a, b in zip(positions, positions[1:], strict=False)), 'no one-second freeze between phone fixes'
assert overlay.status == 'live'
finally:
overlay.close()
@pytest.mark.parametrize('speed, bearing', [(0., 90.), (20., None)])
def test_phone_map_hold_does_not_invent_stationary_or_unknown_heading_movement(tmp_path, speed, bearing):
overlay = MapOverlay(tmp_path)
try:
data = MapInput(MapFix(*CENTER, bearing, speed, 100.), source='phone')
overlay._advance(data, 100.)
initial = overlay._world
for frame in range(1, 501):
overlay._advance(data, 100. + frame / 50)
assert overlay._world == initial
assert overlay.status == 'live'
overlay._advance(data, 110.001)
assert overlay.status == 'waiting'
finally:
overlay.close()
@pytest.mark.parametrize('source, limit', [('phone', 2.5), ('comma', 1.), ('car', 1.)])
def test_map_prediction_is_bounded_separately_from_fix_hold(tmp_path, source, limit):
from openpilot.starpilot.navigation.road_tiles import meters_per_tile
overlay = MapOverlay(tmp_path)
try:
overlay._advance(MapInput(MapFix(*CENTER, 90., 20., 100.), source=source), 109.)
x, y = world_xy(*CENTER)
assert overlay._world == pytest.approx((x + 20. * limit / meters_per_tile(CENTER[0]), y))
finally:
overlay.close()
# ---------------------------------------------------------------- GL
def render_overlay(rl, tmp_path, bearing, width=560, height=420, opacity=1.0, route=True, steps=40):
@@ -329,6 +372,8 @@ def test_map_feed_waiting_tracks_satellites_and_keeps_navigation_requested(monke
clock[0] += 75
data = feed.read(navigation_sm)
assert (data.navigation_active, data.satellites, data.gps_wait_seconds) == (True, 3, 75)
assert data.destination == 'Home'
assert feed.read(navigation_sm, destination='Work').destination == 'Work'
assert feed.read(navigation_sm, navigation_requested=False).navigation_active is False
sm.put('starpilotCarState', int(clock[0] * 1e9))
data = feed.read(navigation_sm)
@@ -373,6 +418,30 @@ def test_gps_status_card_is_inside_bottom_of_map(tmp_path, active, satellites, s
overlay.close()
def test_gps_source_chip_is_solid_and_inside_the_widget(tmp_path):
import pyray as rl
fonts = Mock()
fonts.measure.side_effect = lambda text, _role, size: NS(width=len(text) * size * .65, height=size * 1.25)
overlay = MapOverlay(tmp_path, fonts=fonts)
try:
rect = rl.Rectangle(100, 200, 560, 420)
for source, label in (("comma", "Comma GPS"), ("car", "Car GPS"), ("phone", "Phone GPS")):
overlay._input = MapInput(MapFix(*CENTER, 0, 0, 100), source=source)
fonts.draw.reset_mock()
with patch('pyray.draw_rectangle_rounded') as chip, patch('pyray.draw_rectangle_rounded_lines_ex'):
overlay._draw_source_chip(rect)
assert [call.args[0] for call in fonts.draw.call_args_list] == [label]
assert chip.call_args.args[3].a == 255 and fonts.draw.call_args.args[5].a == 255
box = chip.call_args.args[0]
assert rect.x < box.x and box.x + box.width < rect.x + rect.width and box.y >= rect.y
fonts.draw.reset_mock()
overlay._input = MapInput(MapFix(*CENTER, 0, 0, 100))
overlay._draw_source_chip(rect)
assert not fonts.draw.called
finally:
overlay.close()
def test_starting_navigation_overlay_until_route_is_drawn(tmp_path):
import pyray as rl
fonts = Mock()
@@ -381,9 +450,22 @@ def test_starting_navigation_overlay_until_route_is_drawn(tmp_path):
try:
rect = rl.Rectangle(100, 200, 560, 420)
overlay._input = MapInput(MapFix(*CENTER, 0, 0, 100), navigation_active=True)
with patch('pyray.draw_rectangle_rounded'):
overlay._draw_starting_navigation(rect, 1.0)
with patch('pyray.draw_rectangle_rounded') as card:
overlay._draw_starting_navigation(rect)
assert [call.args[0] for call in fonts.draw.call_args_list] == ['Starting navigation', 'Finding your route…']
assert card.call_args.args[3].a == 255, 'solid whatever the map opacity'
assert all(call.args[5].a == 255 for call in fonts.draw.call_args_list)
fonts.draw.reset_mock()
overlay._input = MapInput(MapFix(*CENTER, 0, 0, 100), navigation_active=True, destination='Work')
with patch('pyray.draw_rectangle_rounded'):
overlay._draw_starting_navigation(rect)
assert [call.args[0] for call in fonts.draw.call_args_list] == ['Starting navigation', 'to Work', 'Finding your route…']
fonts.draw.reset_mock()
overlay._input = MapInput(MapFix(*CENTER, 0, 0, 100), navigation_active=True, destination='A' * 40)
overlay._route_world = np.zeros((2, 2))
with patch('pyray.draw_rectangle_rounded'):
overlay._draw_starting_navigation(rect)
assert [call.args[0] for call in fonts.draw.call_args_list] == ['Starting navigation', 'to ' + 'A' * 31 + '…']
finally:
overlay.close()
@@ -400,6 +482,7 @@ def test_lost_route_only_restores_starting_overlay_after_ten_seconds(tmp_path):
overlay._track_route(data, clock[0])
return overlay._starting_navigation()
assert step(MapInput(fix, (), True)) # never had a route: show immediately
clock[0] += 5
assert not step(MapInput(fix, route, True))
clock[0] += 5
assert not step(MapInput(fix, (), True)) # brief gap: no flash
@@ -409,6 +492,32 @@ def test_lost_route_only_restores_starting_overlay_after_ten_seconds(tmp_path):
overlay.close()
def test_starting_overlay_stays_up_briefly_when_the_route_loads_fast(tmp_path):
clock = [100.0]
overlay = MapOverlay(tmp_path, clock=lambda: clock[0])
try:
route = tuple((CENTER[0] + i * 1e-4, CENTER[1]) for i in range(4))
fix = MapFix(*CENTER, 0, 0, 100)
def step(data):
overlay._input = data
overlay._route(data.route)
overlay._track_route(data, clock[0])
return overlay._starting_navigation()
assert step(MapInput(fix, (), True))
clock[0] += .5
assert step(MapInput(fix, route, True)), 'route already loaded, still readable'
clock[0] += 4.4
assert step(MapInput(fix, route, True))
clock[0] += .2
assert not step(MapInput(fix, route, True))
assert not step(MapInput(fix, (), False))
clock[0] += 1
assert step(MapInput(fix, (), True)), 'a new navigation starts the card again'
assert not step(MapInput(fix, (), False)), 'ending navigation drops it at once'
finally:
overlay.close()
def test_losing_gps_shows_waiting_even_with_a_cached_map_position(tmp_path):
overlay = MapOverlay(tmp_path)
try:
@@ -0,0 +1,57 @@
from types import SimpleNamespace
import pytest
TRACK = {'source': 'spotify', 'status': 'playing', 'title': 'Midnight City', 'artist': 'M83', 'album': '',
'durationMs': 200000, 'position': {'ms': 50000, 'at': 0, 'speed': 0.0}}
@pytest.fixture
def widget(monkeypatch):
from openpilot.starpilot.ui import onroad_now_playing
from openpilot.starpilot.ui.onroad_now_playing import NowPlayingWidget
drawn = []
for name in ('draw_rectangle_rounded', 'draw_triangle', 'draw_circle_v'):
monkeypatch.setattr(onroad_now_playing.rl, name, lambda *args, name=name: drawn.append((name, args)))
texts = []
fonts = SimpleNamespace(measure=lambda text, role, size: SimpleNamespace(width=len(text) * size * .5),
draw=lambda text, *args: texts.append(text))
return NowPlayingWidget(fonts, clock=lambda: 0.0), drawn, texts
def state(enabled=True, opacity=85, alert='none'):
return SimpleNamespace(alert=SimpleNamespace(size=alert),
customization={'layouts': {'large': {'now_playing': {'x': 10, 'y': 20, 'enabled': enabled, 'opacity': opacity}}}})
def test_draws_text_and_background_with_the_chosen_opacity(widget):
card, drawn, texts = widget
card.track = TRACK
card.render(state(opacity=50))
assert texts == ['Midnight City', 'M83']
assert drawn[0][0] == 'draw_rectangle_rounded' and drawn[0][1][3].a == round(235 * .5)
def test_paused_shows_a_play_mark_and_playing_shows_pause_bars(widget):
card, drawn, _ = widget
card.track = {**TRACK, 'status': 'paused'}
card.render(state())
assert any(name == 'draw_triangle' for name, _ in drawn)
drawn.clear()
card.track = TRACK
card.render(state())
assert not any(name == 'draw_triangle' for name, _ in drawn)
@pytest.mark.parametrize('current', [state(enabled=False), state(alert='full')])
def test_hidden_when_disabled_or_an_alert_is_up(widget, current):
card, drawn, texts = widget
card.track = TRACK
card.render(current)
assert drawn == [] and texts == []
def test_nothing_drawn_without_a_track(widget):
card, drawn, texts = widget
card.render(state())
assert drawn == [] and texts == []
@@ -635,7 +635,9 @@ class TestRuntimeSnapshot(unittest.TestCase):
self.assertFalse(RuntimeSnapshotAdapter(ui, bluetooth_powered=lambda: True).build(ShellMode.ONROAD, now_ns=NOW).home.bluetooth)
ui.sm.valid["deviceState"] = False
state = RuntimeSnapshotAdapter(ui, bluetooth_powered=lambda: False).build(ShellMode.HOME, now_ns=NOW).home
self.assertFalse(state.bluetooth)
self.assertEqual(state.bluetooth, "off")
for value in ("off", "on", "connected"):
self.assertEqual(RuntimeSnapshotAdapter(ui, bluetooth_powered=lambda value=value: value).build(ShellMode.HOME, now_ns=NOW).home.bluetooth, value)
self.assertFalse(state.gpu_present)
self.assertFalse(state.gpu_active)
ui.sm.valid["deviceState"] = True
@@ -62,7 +62,7 @@ def test_legacy_projection_order_adds_upstream_mode_widget():
migrated = validate_layout_for_viewport(projection, (2880, 1080))
assert migrated['widgets'][MODE_WIDGET]['enabled'] is False
assert migrated['widgetOrder'] == [*projection['widgetOrder'], MODE_WIDGET, 'nav_card', 'nav_map', 'nav_home', 'nav_work',
'bookmark', 'stopped_timer', CAR_EXIT]
'bookmark', 'stopped_timer', 'now_playing', 'jetlink_status', CAR_EXIT]
def test_old_documents_keep_their_original_shape():
@@ -142,6 +142,8 @@ def test_renderer_dispatches_each_widget_in_saved_order(profile, viewport):
view.navigation = NS(render=lambda *args: seen.append('nav_card'))
view.navigation_favorites = NS(render=lambda key, state: seen.append(key))
view.projection_exit_layer = lambda state: seen.append(CAR_EXIT)
view.now_playing = NS(render=lambda state: seen.append('now_playing'))
view.jetlink_status = NS(render=lambda state: seen.append('jetlink_status'))
document['widgetOrder'] = {profile: order}
state = NS(customization=document, camera_available=True, viewport_width=1860, alert=NS(size='none'), stopped_duration_s=65,
appearance=NS(camera_view='road'))
@@ -164,12 +164,13 @@ def test_ui_poll_precedes_every_frame_including_wake(frame, monkeypatch, awake):
monkeypatch.setattr(ui, "ui_state", SimpleNamespace(update=lambda: events.append("messages")))
controllers = SimpleNamespace(poll=lambda: events.append("controls"))
preview = SimpleNamespace(poll=lambda: events.append("preview"))
star = SimpleNamespace(_poll_projection_bookmark=lambda now: events.append("bookmark"))
star = SimpleNamespace(_poll_projection_bookmark=lambda now: events.append("bookmark"),
_poll_projection_slc=lambda now: events.append("slc"))
app._nav_stack_ticks = [lambda: events.append("transition")]
app._nav_stack[0].render.side_effect = lambda _: events.append("draw")
loop = app.render(before_frame=lambda: ui.update_frame(preview, controllers, star))
assert next(loop)[0] is awake
assert events == ["messages", "bookmark", "controls", "preview"] + (["transition", "draw"] if awake else [])
assert events == ["messages", "bookmark", "slc", "controls", "preview"] + (["transition", "draw"] if awake else [])
with pytest.raises(StopIteration):
next(loop)
@@ -206,7 +207,7 @@ def test_projection_bookmark_is_saved_once_without_rendering_starpilot(frame, mo
path = Path(directory) / 'bookmark.sock'
receiver, sender = BookmarkReceiver(path), BookmarkSender(path)
publish = Mock()
star = SimpleNamespace(_projection_bookmarks=receiver, _publish_bookmark=publish)
star = SimpleNamespace(_projection_bookmarks=receiver, _publish_bookmark=publish, _poll_projection_slc=Mock())
star._poll_projection_bookmark = lambda now: namespace['_poll_projection_bookmark'](star, now)
loop = app.render(before_frame=lambda: ui.update_frame(star=star))
try: