mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-10-28 00:53:45 +08:00
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:
@@ -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."""
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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'
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
@@ -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,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)
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user