Merge pull request #252 from dirwin31:AA-Wireless-Setup

Android Auto: improve wireless adapter setup and pairing
This commit is contained in:
prabhaavp
2026-10-26 17:44:33 -04:00
committed by GitHub
29 changed files with 1862 additions and 145 deletions
+15
View File
@@ -2273,6 +2273,21 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
self.json(403, {'error': 'Park before changing the connection type'})
return
command, arguments = 'set_connection', {'connection': payload['connection']}
elif (action == 'car_type' and set(payload) == {'action', 'carType'} and
payload['carType'] in ('builtin', 'adapter', 'usb', 'app')):
if not configuration_allowed():
self.json(403, {'error': 'Park before changing the setup type'})
return
command, arguments = 'set_car_type', {'car_type': payload['carType']}
elif action == 'car_profile' and set(payload) == {'action', 'profile'} and isinstance(payload['profile'], str):
from openpilot.starpilot.system.android_auto.car_profiles import valid_override
if not valid_override(payload['profile']):
self.json(400, {'error': 'Invalid car profile'})
return
if not configuration_allowed():
self.json(403, {'error': 'Park before changing car settings'})
return
command, arguments = 'set_car_profile', {'profile': payload['profile']}
elif action == 'select_receiver' and set(payload) == {'action', 'address'} and isinstance(payload['address'], str):
if not configuration_allowed():
self.json(403, {'error': 'Park before changing the car'})
@@ -73,6 +73,8 @@ def api(tmp_path):
self.running = False
self.auto_connect = False
self.connection = 'wireless'
self.car_type = 'builtin'
self.profile = ''
self.available = True
self.stop_error = False
self.stop_completes = True
@@ -82,7 +84,7 @@ def api(tmp_path):
if command == 'status':
return {'status': {'receiver_address': self.receiver, 'receiver_name': 'My car', 'state': 'streaming' if self.running else 'idle',
'running': self.running, 'auto_connect': self.auto_connect, 'pairing_ready': False,
'connection': self.connection}}
'connection': self.connection, 'car_type': self.car_type}}
if command == 'devices':
return {'devices': [{'address': 'AA:BB:CC:DD:EE:FF', 'name': 'Saved car', 'paired': True, 'android_auto': True}]}
if command == 'select_receiver':
@@ -91,6 +93,10 @@ def api(tmp_path):
self.auto_connect = kwargs['enabled']
if command == 'set_connection':
self.connection = kwargs['connection']
if command == 'set_car_type':
self.car_type = kwargs['car_type']
if command == 'set_car_profile':
self.profile = kwargs['profile']
if command == 'start':
self.running = True
if command == 'stop':
@@ -427,6 +433,29 @@ def test_wired_connection_requires_park_but_not_a_paired_receiver(api, connectio
assert code == 409 and result['error'] == 'Verify your Android Auto package first'
def test_setup_type_and_car_profile_need_park_and_valid_values(api):
request, _, state, client = api
code, _, headers = request('POST', '/api/auth/login', json.dumps({'password': 'password123'}),
content_type='application/json')
assert code == 200
cookie = headers['Set-Cookie'].split(';', 1)[0]
def post(body):
return request('POST', '/api/android-auto/control', json.dumps(body), cookie=cookie, content_type='application/json')
state['parked'] = False
assert post({'action': 'car_type', 'carType': 'adapter'})[0] == 403
assert post({'action': 'car_profile', 'profile': 'default'})[0] == 403
state['parked'] = True
code, result, _ = post({'action': 'car_type', 'carType': 'adapter'})
assert code == 200 and result['status']['car_type'] == 'adapter'
assert post({'action': 'car_type', 'carType': 'hovercraft'})[0] == 400
assert post({'action': 'car_type', 'carType': 'usb', 'extra': 1})[0] == 400
assert post({'action': 'car_profile', 'profile': 'default'})[0] == 200 and client.profile == 'default'
assert post({'action': 'car_profile', 'profile': ''})[0] == 200 and client.profile == ''
assert post({'action': 'car_profile', 'profile': 'not-a-profile'})[0] == 400
def test_local_enable_then_upload_bundle_preserves_binary_bytes(api):
import io
import zipfile
@@ -0,0 +1,40 @@
const {chromium}=require(process.env.PLAYWRIGHT_MODULE || 'playwright');
const assert=require('node:assert/strict');
(async()=>{
const browser=await chromium.launch({headless:true,executablePath:process.env.CHROMIUM_PATH || undefined}); const page=await browser.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message));
await page.route('**/js/boot.js',r=>r.fulfill({body:''}));
await page.goto(process.env.GALAXY_URL || 'http://127.0.0.1:8765/');
await page.evaluate(async()=>{
const {createApp,reactive}=await import('./vendor/vue/vue.esm-browser.js');const {AndroidAutoPage}=await import('./js/android-auto.js');
document.querySelector('#galaxy-boot').remove();
window.fixture=reactive({...AndroidAutoPage.data(),setup:{enabled:true,bluetoothEnabled:true,parked:true,installReady:true,serviceReady:true,identity:{installed:true,message:'Support ready'},import:{state:'idle'},maxUploadBytes:200000000},runtime:{connection:'wireless',car_type:'builtin',state:'wifi_info',running:true},selected:{name:'IONIQ 6',address:'AA:BB:CC:DD:EE:FF'}});
createApp({...AndroidAutoPage,data:()=>window.fixture,mounted(){},beforeUnmount(){},watch:{}},{mode:'local',localAccess:true,unauthorized(){}}).mount('#galaxy-app');
document.querySelector('#galaxy-app').className='gx-content';
});
for (const width of [390,1280]) {
await page.setViewportSize({width,height:1100});
for(const [car_type,connection] of [['builtin','wireless'],['adapter','wireless'],['usb','wired'],['app','headunitpad'],['app','openheadunit']]){
await page.evaluate(({car_type,connection})=>{Object.assign(fixture.runtime,{car_type,connection,car_profile:'aawireless',car_profile_source:'override',state:connection==='wired'?'waiting_for_usb':connection==='headunitpad'?'waiting_for_ipad':connection==='openheadunit'?'waiting_for_tablet':'wifi_info'});}, {car_type,connection});
assert.equal(await page.locator('.gx-aa-progress:visible').count(),1);
assert.equal(await page.getByRole('button',{name:'Disconnect',exact:true}).count(),1);
assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false,`${width} ${connection} overflow`);
await page.evaluate(()=>{fixture.showSteps=true;});
assert.equal(await page.locator('.gx-aa-progress:visible').count(),1,'expanded setup has one progress panel');
assert.equal(await page.getByRole('button',{name:'Disconnect',exact:true}).filter({visible:true}).count(),1);
await page.evaluate(()=>{fixture.showSteps=false;});
}
}
await page.evaluate(()=>{Object.assign(fixture.runtime,{car_type:'builtin',connection:'wireless',running:false,state:'idle'});fixture.pairing={active:true,state:'pairing',approved:false,receiver:{name:'IONIQ 6'},prompt:{kind:'confirmation',value:'123456',id:'a'.repeat(32)},devices:[]};});
assert.equal(await page.getByRole('button',{name:'Confirm',exact:true}).isEnabled(),true);
assert.equal(await page.locator('.gx-aa-row--active').count(),1);
await page.evaluate(()=>{fixture.pairing.state='failed';fixture.pairing.error='Pairing timed out';});
assert.equal(await page.locator('.gx-aa-row--active').count(),0);
await page.evaluate(()=>{fixture.pairing=null;fixture.selected=null;fixture.runtime.state='idle';fixture.runtime.running=false;});
assert.equal(await page.locator('button').filter({hasText: /^\s*Find car\s*$/}).isEnabled(),true);
assert.equal(await page.getByRole('button',{name:'Connect to car',exact:true}).isEnabled(),false);
await page.evaluate(()=>{fixture.runtime.state='wifi_info';fixture.runtime.running=true;});
await page.emulateMedia({reducedMotion:'reduce'});
assert.equal(await page.locator('.gx-aa-orbit:visible').evaluate(el=>getComputedStyle(el).animationName),'none');
assert.deepEqual(errors,[]);await browser.close();console.log('All five device options at phone/desktop sizes, confirmation, failure and single progress/action layout passed.');
})().catch(e=>{console.error(e);process.exit(1)});
@@ -1,3 +1,4 @@
import { settingControl } from "../web/js/galaxy-setting-row.js"
import assert from "node:assert/strict"
import { readFileSync } from "node:fs"
import { AndroidAutoFeed, AndroidAutoPage, validPairingStatus, validPromptInput, uploadPackage, uploadTimeout,
@@ -18,7 +19,7 @@ assert.equal(validPromptInput({ kind: "passkey" }, "1234567"), false)
assert.equal(validPromptInput(prompt, ""), true)
assert.match(AndroidAutoPage.template, /Check that this code matches your car/)
assert.match(AndroidAutoPage.template, /Cancel Search \/ Pairing/)
assert.match(AndroidAutoPage.template, /:row="wiredRow"[^>]*:disabled="wiredDisabled"/)
assert.doesNotMatch(AndroidAutoPage.template, /:row="wiredRow"/)
assert.match(AndroidAutoPage.template, /Read the Android Auto troubleshooting guide/)
assert.doesNotMatch(AndroidAutoPage.template, /v-html|autoaccept/i)
const app = readFileSync(new URL("../web/js/app.js", import.meta.url), "utf8")
@@ -160,23 +161,24 @@ function page(selectedSetup = setup) {
for (const [key, method] of Object.entries(AndroidAutoPage.methods)) vm[key] = method.bind(vm)
return vm
}
// Wired setup is available, while the existing parked/session/ownership gates remain enforced.
const wiredSetup = page()
wiredSetup.runtime = { connection: "wireless", running: false }
assert.equal(wiredSetup.wiredDisabled, false)
for (const field of ["enabled", "parked"]) {
const blocked = page({ ...setup, [field]: false })
assert.equal(blocked.wiredDisabled, true, field)
}
wiredSetup.pairing = { active: true }
assert.equal(wiredSetup.wiredDisabled, true, "pairing owns the connection")
wiredSetup.pairing = null
wiredSetup.runtime.running = true
assert.equal(wiredSetup.wiredDisabled, true, "a running session cannot change connection")
wiredSetup.runtime = { connection: "wireless", running: false, wired_blocked: "USB is busy" }
assert.equal(wiredSetup.wiredDisabled, true, "another USB owner blocks enabling wired mode")
wiredSetup.runtime.connection = "wired"
assert.equal(wiredSetup.wiredDisabled, false, "the existing wired connection can still be disabled")
wiredSetup.runtime = { connection: "wired", running: false, wired_busy: "Turn off Jetlink to use wired Android Auto" }
assert.equal(wiredSetup.wiredJetlinkBusy, true)
assert.equal(wiredSetup.wiredStatus.tone, "expired")
assert.equal(wiredSetup.wiredStatus.label, "Wired Android Auto not enabled")
assert.match(wiredSetup.connectReason, /Jetlink/)
assert.match(AndroidAutoPage.template, /class="gx-link" href="#\/manage_models"/)
assert.match(AndroidAutoPage.template, /bi-arrow-right/)
wiredSetup.runtime.wired_busy = ""
assert.equal(wiredSetup.wiredStatus.tone, "ready")
assert.equal(wiredSetup.wiredStatus.label, "Wired Android Auto enabled")
assert.equal(wiredSetup.connectReason, "")
wiredSetup.runtime.wired_blocked = "USB is unavailable"
assert.equal(wiredSetup.wiredStatus.tone, "expired")
assert.equal(wiredSetup.connectReason, "USB is unavailable")
wiredSetup.runtime.wired_blocked = ""
wiredSetup.setup.enabled = false
assert.equal(wiredSetup.wiredStatus.tone, "expired")
const uploadPage = page({ ...setup, enabled: false })
const uploadCalls = []
@@ -473,3 +475,79 @@ const audioTool = catalog.tools.find(tool => tool.path === "/android-auto").chil
assert.equal(audioTool.name, "Spotify and Phone Audio")
assert.match(AndroidAutoPage.template, /href="#\/android-auto\/audio"/)
assert.ok(app.includes(`SpotifyPage v-else-if="['/android-auto/audio', '/spotify'].includes(route.path)"`))
// Setup type: what is in the car decides which pairing steps appear.
const typed = page(setup)
assert.equal(typed.carType, 'builtin', 'built-in wireless is the default')
typed.runtime = { car_type: 'adapter' }
assert.equal(typed.adapterSetup, true)
assert.equal(typed.pairsHere, true)
assert.equal(typed.findLabel, 'adapter')
typed.runtime = { car_type: 'usb' }
assert.equal(typed.pairsHere, false, 'a USB cable needs no Bluetooth pairing')
typed.runtime = { running: false, connection: 'wired' }
assert.equal(typed.carType, 'usb', 'older daemons without car_type still follow the connection')
typed.runtime = { connection: 'headunitpad' }
assert.equal(typed.carType, 'app')
const typedCalls = []
typed.control = (action, extra) => typedCalls.push([action, extra])
typed.saveCarType(0, 'Wireless adapter')
typed.saveCarType(0, 'Not a type')
assert.deepEqual(typedCalls, [['car_type', { carType: 'adapter' }]])
assert.deepEqual(new Set(page(setup).carTypeRow.choices), new Set(['Built-in wireless', 'Wireless adapter', 'Wired AA', 'App']))
// A blocked wired port explains itself instead of silently refusing the pick.
const blocked = page(setup)
blocked.feed = { error: '', emit() {} }
blocked.runtime = { car_type: 'builtin', wired_blocked: 'Turn off Jetlink to use wired Android Auto' }
assert.equal(blocked.wiredUnavailable, true)
const blockedCalls = []
blocked.control = (action, extra) => blockedCalls.push([action, extra])
assert.equal(blocked.saveCarType(0, 'Wired AA'), false)
assert.deepEqual(blockedCalls, [])
assert.match(blocked.feed.error, /Turn off Jetlink/)
blocked.runtime = { car_type: 'usb', connection: 'wired', wired_blocked: 'Turn off Jetlink to use wired Android Auto' }
assert.equal(blocked.wiredUnavailable, false, 'already wired: the pick is not blocked')
// The line names the chosen tablet app.
const named = page(setup)
named.runtime = { car_type: 'app', connection: 'openheadunit' }
assert.equal(named.carTypeRow.value, 'App (Open Headunit)')
assert.ok(named.carTypeRow.choices.includes('App (Open Headunit)'))
const namedCalls = []
named.control = (action, extra) => namedCalls.push([action, extra])
named.saveCarType(0, 'Wireless adapter')
assert.deepEqual(namedCalls, [['car_type', { carType: 'adapter' }]])
// The adapter model picks the AAWireless tuning; any other adapter keeps the regular pairing.
const dongle = page(setup)
dongle.runtime = { car_type: 'adapter', car_profile: 'default', car_profile_source: 'standard' }
assert.equal(dongle.adapterRow.value, 'Choose one', 'no default: the owner picks')
assert.equal(settingControl(dongle.adapterRow), 'select', 'the initial adapter prompt must open a picker')
assert.match(dongle.pairingReason, /adapter model/)
dongle.runtime = { car_type: 'adapter', car_profile: 'default', car_profile_source: 'override' }
assert.equal(dongle.adapterRow.value, 'Other')
assert.deepEqual(dongle.adapterRow.choices, ['AAWireless', 'Other'])
dongle.runtime = { car_type: 'adapter', car_profile: 'aawireless', car_profile_source: 'override' }
assert.equal(dongle.adapterRow.value, 'AAWireless')
const dongleCalls = []
dongle.control = (action, extra) => dongleCalls.push([action, extra])
dongle.saveAdapter(0, 'Other')
dongle.saveAdapter(0, 'AAWireless')
assert.deepEqual(dongleCalls, [['car_profile', { profile: 'default' }], ['car_profile', { profile: 'aawireless' }]])
assert.match(AndroidAutoPage.template, /adapterSetup" :row="adapterRow"/)
assert.match(AndroidAutoPage.template, /What’s in your car\?|carTypeRow/)
assert.match(AndroidAutoPage.template, /runtime\?\.pairing_note/)
assert.match(AndroidAutoPage.template, /'Find ' \+ findLabel/)
// A session that is only waiting or retrying never locks the pickers or Find; it is stopped first.
const idle = page(setup)
idle.runtime = { car_type: 'builtin', running: true, state: 'backoff' }
assert.equal(idle.appDisabled, false)
assert.equal(idle.pairingReason, '')
idle.runtime = { car_type: 'builtin', running: true, state: 'streaming' }
assert.equal(idle.appDisabled, true)
assert.match(idle.pairingReason, /Disconnect/)
// The home card offers the setup type; the app picker belongs to App only.
assert.match(AndroidAutoPage.template, /v-if="app" :row="appRow"/)
assert.equal(page(setup).homeTitle, 'car')
const wiredHome = page(setup); wiredHome.runtime = { car_type: 'usb', connection: 'wired' }
assert.equal(wiredHome.homeTitle, 'Wired Android Auto')
const appHome = page(setup); appHome.runtime = { car_type: 'app', connection: 'headunitpad' }
assert.equal(appHome.homeTitle, 'Headunit Emulator')
console.log('Android Auto page: setup type, adapter checklist and adapter model passed')
@@ -24,6 +24,6 @@ test("select uses outgoing API without changing the selected AA receiver", async
assert.equal(await feed.selectDevice(car.address), false);
});
test("search remains blocked during projection and code verification stays available", () => {
const page = {pairingReason: "", runtime: {running: true}, feed: {action() {throw Error("unexpected request");}}};
const page = {pairingReason: "Disconnect from your car before pairing.", runtime: {running: true, state: "streaming"}, feed: {action() {throw Error("unexpected request");}}};
assert.equal(AndroidAutoPage.methods.startPairing.call(page), false);
});
@@ -0,0 +1,34 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { AndroidAutoPage, connectionProgress } from '../web/js/android-auto.js'
test('each transport exposes its actual active connection stage', () => {
for (const [state, options, title] of [
['wifi_info', {}, 'Getting car Wi-Fi details'],
['joining_wifi', {}, 'Joining car Wi-Fi'],
['waiting_for_usb', { wired: true }, 'Waiting for your USB cable'],
['waiting_for_ipad', { app: 'HeadunitPad' }, 'Open HeadunitPad on your iPad'],
['waiting_for_tablet', { app: 'Open Headunit' }, 'Open Open Headunit on your tablet'],
]) {
const result = connectionProgress({ state, running: true }, options)
assert.equal(result.title, title)
assert.equal(result.active, true)
}
})
test('connection success, suspension and errors do not masquerade as work in progress', () => {
for (const state of ['idle', 'streaming', 'suspended', 'error']) {
assert.equal(connectionProgress({ state, running: state !== 'idle' }).active, false)
}
assert.equal(connectionProgress({ state: 'backoff', running: true, error: 'Car unavailable' }).detail, 'Car unavailable')
assert.equal(connectionProgress({ state: 'backoff', running: false }).active, false)
assert.equal(connectionProgress({ state: 'streaming' }).tone, 'success')
})
test('pairing approval advances to finishing and failures stop the spinner', () => {
const steps = pairing => AndroidAutoPage.computed.pairSteps.call({ pairing, adapterSetup: false })
const confirming = steps({ active: true, state: 'pairing', receiver: {}, prompt: {} })
assert.equal(confirming.find(step => step.status === 'active').label, 'Confirm the code on both screens')
const approved = steps({ active: true, state: 'pairing', receiver: {}, approved: true })
assert.equal(approved.find(step => step.status === 'active').label, 'Finish Bluetooth pairing')
assert.equal(steps({ active: true, state: 'failed', error: 'Timed out' }).some(step => step.status === 'active'), false)
assert(steps({ active: false, state: 'paired' }).every(step => step.status === 'done'))
})
@@ -110,4 +110,11 @@ assert.equal(requests, 0)
window.StarPilotApp.bluetooth = () => false
assert.equal(bluetoothConnection(), false)
{
const { readFileSync } = await import('node:fs')
const lines = readFileSync(new URL('../web/js/bluetooth.js', import.meta.url), 'utf8').split('\n')
const chained = lines.findIndex((line) => line.trim() === '<template v-else-if="status">')
assert.ok(chained > 0)
assert.doesNotMatch(lines[chained - 1], /v-if="status"/, 'a notice before the device cards would swallow their v-else-if')
}
console.log('Bluetooth limits: blue notices, connection action, no blocked requests, supported settings and real errors verified')
@@ -1454,6 +1454,7 @@ input[type="checkbox"]:disabled { opacity: .4; cursor: not-allowed; }
.gx-aa-step__head { display: flex; align-items: flex-start; gap: var(--sp-4); min-width: 0; }
.gx-aa-step__head h3 { margin: 2px 0 var(--sp-1); font-size: var(--fs-lg); }
.gx-aa-step__head p { margin: 0; color: var(--text-muted); line-height: 1.45; }
.gx-aa-step__head > .gx-btn { margin-left: auto; flex: none; }
.gx-aa-step__num { flex: 0 0 auto; display: grid; place-items: center; width: 36px; height: 36px; border-radius: var(--radius-full);
background: var(--primary-container); color: var(--on-primary-container); font-weight: var(--fw-bold); }
.gx-aa-step--done .gx-aa-step__num { background: var(--success); color: var(--background); }
@@ -1804,3 +1805,34 @@ progress { max-width: 100%; accent-color: var(--primary); }
@media (max-width: 380px) {
.gx-flm__table td { grid-template-columns: minmax(0, 1fr); gap: 2px; }
}
.gx-aa-progress { display: grid; gap: var(--sp-3); padding: var(--sp-4); border: 1px solid color-mix(in srgb, var(--primary) 35%, var(--outline)); border-radius: var(--radius-md); background: linear-gradient(120deg, color-mix(in srgb, var(--primary) 12%, var(--surface-container)), var(--surface-container)); min-width: 0; }
.gx-aa-progress__heading { display: flex; align-items: center; gap: var(--sp-3); }
.gx-aa-progress__heading > div { display: grid; gap: var(--sp-1); min-width: 0; }
.gx-aa-progress__heading small { color: var(--primary); font-size: var(--fs-xs); font-weight: 700; letter-spacing: .08em; }
.gx-aa-progress__heading strong { font-size: var(--fs-lg); line-height: 1.35; overflow-wrap: anywhere; }
.gx-aa-progress__heading > i { color: var(--primary); font-size: 1.8rem; }
.gx-aa-progress p { margin: 0; color: var(--on-surface-variant); line-height: 1.5; overflow-wrap: anywhere; }
.gx-aa-progress--success { border-color: var(--success); }
.gx-aa-progress--success .gx-aa-progress__heading > i, .gx-aa-progress--success small { color: var(--success); }
.gx-aa-progress--warning { border-color: var(--warning); }
.gx-aa-progress--warning small, .gx-aa-progress--warning .gx-aa-progress__heading > i { color: var(--warning); }
.gx-aa-orbit { width: 34px; height: 34px; border-width: 3px; border-color: color-mix(in srgb, var(--primary) 20%, transparent); border-top-color: var(--primary); border-right-color: var(--secondary, var(--primary)); }
.gx-aa-orbit--small { width: 22px; height: 22px; }
.gx-aa-progress__track { height: 4px; overflow: hidden; border-radius: var(--radius-full); background: color-mix(in srgb, var(--primary) 15%, transparent); }
.gx-aa-progress__track > span { display: block; width: 40%; height: 100%; border-radius: inherit; background: linear-gradient(90deg, var(--primary), var(--secondary, var(--primary))); animation: gx-aa-travel 1.8s ease-in-out infinite alternate; }
@keyframes gx-aa-travel { from { transform: translateX(-100%); } to { transform: translateX(250%); } }
.gx-aa-list .gx-aa-row--active { background: color-mix(in srgb, var(--primary) 12%, var(--surface-container)); box-shadow: inset 3px 0 var(--primary); }
.gx-aa-row--active strong { color: var(--primary); }
.gx-aa-list .gx-aa-row--done > i { color: var(--success); }
.gx-aa-row--failed strong { color: var(--error); }
.gx-aa-guide { padding: var(--sp-4); border-radius: var(--radius-md); background: var(--surface-container); border: 1px solid var(--outline); line-height: 1.5; }
.gx-aa-guide > strong { color: var(--primary); }
.gx-aa-guide ol { display: grid; gap: var(--sp-3); padding-left: 1.5em; margin-bottom: 0; }
.gx-aa-guide li::marker { color: var(--primary); font-weight: 700; }
@media (prefers-reduced-motion: reduce) {
.gx-aa-progress__track > span { animation: none; }
.gx-aa-progress__track > span { width: 100%; opacity: .6; }
}
.gx-aa-checks .gx-aa-check--active { padding: var(--sp-3); border-radius: var(--radius-sm); color: var(--primary); background: color-mix(in srgb, var(--primary) 12%, transparent); font-weight: var(--fw-bold); }
.gx-aa-checks .gx-aa-check--active::before { border-right-color: var(--secondary, var(--primary)); }
+219 -79
View File
@@ -44,10 +44,33 @@ export function uploadPackage(path, options, progress, makeRequest = () => new X
}
const ADDRESS = /^(?:[0-9A-F]{2}:){5}[0-9A-F]{2}$/
const ADAPTER_MODELS = { aawireless: "AAWireless", default: "Other" }
const HEAD_UNIT_APPS = { headunitpad: "HeadunitPad", openheadunit: "Open Headunit" }
const HOTSPOT_LINK = `<a class="gx-link" href="#/galaxy">Galaxy Wi-Fi Hotspot</a>`
const WIRED_STATUS = `<div v-if="wired" class="gx-note">
<div class="gx-aa-status">
<span class="gx-aa-pill" :class="'gx-aa-pill--' + wiredStatus.tone" role="status">{{ wiredStatus.label }}</span>
<a v-if="wiredJetlinkBusy" class="gx-link" href="#/manage_models">Turn off JetLink <i class="bi bi-arrow-right" aria-hidden="true"></i></a>
</div>
<p>{{ wiredStatus.detail }}</p>
</div>`
const CONNECT = `<button v-if="!runtime?.running" class="gx-btn gx-aa-cta" :disabled="!!connectReason" @click="connect">{{ selected && pairsHere ? 'Connect to ' + selected.name : app ? 'Connect to ' + app : adapterSetup ? 'Connect to adapter' : 'Connect to car' }}</button>
<button v-else class="gx-btn gx-btn--tonal gx-aa-cta" @click="control('stop')">Disconnect</button>`
const CONNECTION_PROGRESS = `
<div v-if="!showPairSteps" class="gx-aa-progress" :class="'gx-aa-progress--' + connectionProgress.tone" role="status" aria-live="polite" aria-atomic="true">
<div class="gx-aa-progress__heading">
<span v-if="connectionProgress.active" class="gx-spinner gx-aa-orbit" aria-hidden="true"></span>
<i v-else :class="'bi bi-' + connectionProgress.icon" aria-hidden="true"></i>
<div><small>{{ connectionProgress.eyebrow }}</small><strong>{{ connectionProgress.title }}</strong></div>
</div>
<p>{{ connectionProgress.detail }}</p>
<div v-if="connectionProgress.active" class="gx-aa-progress__track" aria-hidden="true"><span></span></div>
</div>`
const AUTO_CONNECT = `<GalaxySettingRow :row="switchRow('Automatic Connection', runtime?.auto_connect, 'Connect to your car whenever you start driving.')"
:index="0" :busy="busy" :disabled="!setup.enabled || !setup.parked || !!pairing?.active"
:save-value="(index, value) => control('auto_connect', { enabled: value === 'On' })" />`
const APP_SETUP = `
<div v-if="app === 'HeadunitPad'" class="gx-note" role="note">
<div v-if="app === 'HeadunitPad'" class="gx-aa-guide" role="note">
<strong>HeadunitPad setup (iPad)</strong>
<ol>
<li>Install <a class="gx-link" href="https://github.com/andyching168/HeadunitPad" target="_blank" rel="noopener noreferrer">HeadunitPad from GitHub</a> on the iPad.</li>
@@ -56,7 +79,7 @@ const APP_SETUP = `
<li>Tap Connect here, then open HeadunitPad.</li>
</ol>
</div>
<div v-else-if="app === 'Open Headunit'" class="gx-note" role="note">
<div v-else-if="app === 'Open Headunit'" class="gx-aa-guide" role="note">
<strong>Open Headunit setup (Android tablet)</strong>
<ol>
<li>Install <a class="gx-link" href="https://github.com/andreknieriem/open-headunit/releases" target="_blank" rel="noopener noreferrer">Open Headunit from GitHub</a> on the tablet.</li>
@@ -66,6 +89,9 @@ const APP_SETUP = `
<li>Tap Connect here, then tap the WiFi button in Open Headunit.</li>
</ol>
</div>`
export const CAR_TYPES = {
builtin: "Built-in wireless", adapter: "Wireless adapter", usb: "Wired AA", app: "App"
}
const PROMPT = new Set(["pin", "passkey", "confirmation", "authorization", "display_pin", "display_passkey"])
export function validPromptInput(prompt, value) {
@@ -283,7 +309,7 @@ export class AndroidAutoFeed {
async control(action, extra = {}) {
if (!this.active || this.busy || !this.setup?.enabled || this.pairing?.active ||
!["start", "stop", "select_receiver", "auto_connect", "connection"].includes(action)) return false
!["start", "stop", "select_receiver", "auto_connect", "connection", "car_type", "car_profile"].includes(action)) return false
const result = await this.request("./api/android-auto/control", {
method: "POST",
headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action, ...extra })
@@ -441,6 +467,19 @@ export function installChecks(job, locale = undefined) {
})
}
export function connectionProgress(runtime, { wired = false, app = "", selected = null } = {}) {
const state = runtime?.state || "idle"
const base = { active: false, tone: "ready", icon: "arrow-right-circle", eyebrow: "NEXT · CONNECT" }
if (state === "streaming") return { ...base, tone: "success", icon: "check-circle-fill", eyebrow: "CONNECTED", title: "Your dashboard is live", detail: "StarPilot is showing on your screen. You’re ready to drive." }
if (state === "suspended") return { ...base, title: "Connected · car screen in use", detail: "Open Android Auto on your car’s screen to return to StarPilot." }
if (state === "error" || state === "backoff") return { ...base, active: state === "backoff" && !!runtime?.running, tone: "warning", icon: "exclamation-circle", eyebrow: state === "backoff" ? "RECONNECTING" : "NEEDS ATTENTION", title: state === "backoff" ? "Trying your connection again" : "Connection needs attention", detail: runtime.error || runtime.detail || "Check your device, then try connecting again." }
if (runtime?.running && state !== "idle") {
const titles = { wifi_info: "Getting car Wi-Fi details", joining_wifi: "Joining car Wi-Fi", connecting_bluetooth: "Connecting over Bluetooth", discovering: "Finding Android Auto", rfcomm: "Starting wireless setup", wifi_start: "Waiting for car Wi-Fi", connecting_tcp: "Opening the connection", authenticating: "Securing your connection", negotiating: "Preparing your dashboard", waiting_for_usb: "Waiting for your USB cable", usb_accessory: "Starting USB connection", waiting_for_ipad: "Open HeadunitPad on your iPad", waiting_for_tablet: "Open Open Headunit on your tablet", stopping: "Disconnecting" }
return { ...base, active: true, tone: "active", eyebrow: "CONNECTION IN PROGRESS", title: titles[state] || runtime.label || "Connecting to your device", detail: runtime.detail || (wired ? "Keep the data cable connected and the car’s screen on." : app ? "Keep the app open and both devices on the same Wi-Fi." : "Keep your car’s screen on. We’ll move to the next step automatically.") }
}
return { ...base, title: wired ? "Connect your USB cable" : app ? `Ready for ${app}` : selected ? `Ready for ${selected.name}` : "Pair your device first", detail: wired ? "Use the car’s Android Auto data port, then tap Connect below." : app ? "Complete the app setup above, then tap Connect below." : selected ? "Tap Connect to bring StarPilot to your dashboard." : "Follow the pairing steps above, then connect to your dashboard." }
}
export const AndroidAutoPage = {
components: { GxState, GxIconButton, GxNotice, BluetoothNotice, GxDialog, GalaxySettingRow },
props: {
@@ -452,7 +491,7 @@ export const AndroidAutoPage = {
setup: null, pairing: null, selected: null, runtime: null, receivers: [], endReason: "", busy: false, error: "",
pairValue: "", packageFile: null, uploadProgress: null, installOpen: false, removeOpen: false, installAttempted: false, installBaseline: null,
downloadStarted: false, packageMagicError: "", packageChecking: false, packageSelection: 0,
installFileName: "", pairWhenReady: false, enableOpen: false, showSteps: false
installFileName: "", pairWhenReady: false, enableOpen: false, showSteps: false, starting: false, supportOpen: false
}),
mounted() {
this.feed = new AndroidAutoFeed({ publish: (value) => Object.assign(this, value), unauthorized: this.unauthorized })
@@ -475,6 +514,7 @@ export const AndroidAutoPage = {
'setup.enabled'(enabled) { if (!enabled) this.pairWhenReady = false },
},
computed: {
connectionProgress() { return connectionProgress(this.runtime, { wired: this.wired, app: this.app, selected: this.selected }) },
canRespond() { return validPromptInput(this.pairing?.prompt, this.pairValue) },
expiry() { return expiryInfo(this.setup?.identity) },
projecting() { return this.runtime?.state === "streaming" },
@@ -486,23 +526,52 @@ export const AndroidAutoPage = {
// Returning drivers see a status card; the setup steps fold away below it.
wired() { return this.runtime?.connection === "wired" },
app() { return HEAD_UNIT_APPS[this.runtime?.connection] || "" },
homeTitle() {
if (this.wired) return "Wired Android Auto"
if (this.app) return "Headunit Emulator"
return this.projecting ? "Connected to " + this.connectionName : this.runtime?.running ? "Connecting to " + this.connectionName : this.connectionName
},
wirelessHome() { return !this.wired && !this.app },
connectionName() { return this.app === "HeadunitPad" ? "iPad" : this.app ? "tablet" : this.wired ? "car" : this.selected?.name || "car" },
setupDone() { return !!(this.supportReady && (this.selected || this.wired || this.app) && !this.pairing?.active) },
wiredRow() {
const detail = "Project over a USB cable from the car to the comma’s USB-C port instead of Bluetooth and Wi-Fi."
return switchRow("Wired USB Connection", this.wired, detail + (this.runtime?.wired_blocked ? ` ${this.runtime.wired_blocked}.` : ""))
// Waiting or retrying is not a connection: only a live link blocks changes, and those stop the wait first.
linked() { return ["streaming", "suspended"].includes(this.runtime?.state) },
waiting() { return !!this.runtime?.running && !this.linked },
supportCollapsed() { return this.supportReady && this.setup.installReady && !this.supportOpen },
setupDone() {
return !!(this.supportReady && (this.selected || this.wired || this.app) && !this.pairing?.active &&
!(this.adapterSetup && this.runtime?.car_profile_source !== "override"))
},
wiredStatus() {
if (this.runtime?.wired_blocked) return { tone: "expired", label: "Wired Android Auto not enabled", detail: this.runtime.wired_blocked }
if (this.wiredJetlinkBusy) return { tone: "expired", label: "Wired Android Auto not enabled", detail: "JetLink is using the USB-C port. Turn off JetLink, then return here." }
if (!this.setup?.enabled) return { tone: "expired", label: "Wired Android Auto not enabled", detail: "Turn on Android Auto Support in step 1." }
return { tone: "ready", label: "Wired Android Auto enabled", detail: "Connect the car’s Android Auto USB data port to the comma’s USB-C port with a data-capable cable." }
},
appRow() {
return { label: "App", value: this.app || "Off", choices: ["Off", ...Object.values(HEAD_UNIT_APPS)], available: true, action: true,
reason: "Project to a tablet app on the same Wi-Fi as the comma or the Galaxy Wi-Fi Hotspot instead of the car." }
},
carType() { return this.runtime?.car_type || (this.wired ? "usb" : this.app ? "app" : "builtin") },
adapterSetup() { return this.carType === "adapter" },
pairsHere() { return this.carType === "builtin" || this.carType === "adapter" },
findLabel() { return this.adapterSetup ? "adapter" : "car" },
carTypeLabels() { return { ...CAR_TYPES, app: this.app ? `App (${this.app})` : CAR_TYPES.app } },
wiredUnavailable() { return !this.wired && !!this.runtime?.wired_blocked },
carTypeRow() {
const labels = this.carTypeLabels
return { label: "What’s your device?", value: labels[this.carType], choices: Object.values(labels), available: true, action: true,
reason: this.wired ? "Use a data cable from the car to your comma." : this.app ? "Use a tablet on the same Wi-Fi as your comma." : this.adapterSetup ? "Pair the USB adapter, such as AAWireless or Motorola MA1." : "Pair directly with your car’s wireless Android Auto." }
},
adapterRow() {
const chosen = this.runtime?.car_profile_source === "override"
return { label: "Adapter", value: !chosen ? "Choose one" : this.runtime.car_profile === "aawireless" ? ADAPTER_MODELS.aawireless : ADAPTER_MODELS.default,
choices: [...(!chosen ? ["Choose one"] : []), ...Object.values(ADAPTER_MODELS)], available: true, action: true,
reason: "AAWireless gets video and car-data settings tuned for that dongle. Other adapters use the regular pairing." }
},
appDisabled() {
return !this.setup?.enabled || !this.setup.parked || !!this.pairing?.active || !!this.runtime?.running
},
wiredDisabled() {
return !this.setup?.enabled || !this.setup.parked || !!this.pairing?.active || !!this.runtime?.running ||
(!!this.runtime?.wired_blocked && !this.wired)
return !this.setup?.enabled || !this.setup.parked || !!this.pairing?.active || this.linked
},
wiredJetlinkBusy() { return this.wired && !!this.runtime?.wired_busy },
download() { return downloadLink(this.setup?.download, this.setup?.downloadPending === true) },
uploadPercent() {
const { loaded, total } = this.uploadProgress || {}
@@ -558,31 +627,73 @@ export const AndroidAutoPage = {
if (!this.setup.installReady) return "This build is missing the Android Auto display or encoder."
if (this.setup.enabled && !this.setup.serviceReady) return "Waiting for the Android Auto service to start."
if (!this.setup.bluetoothEnabled && this.setup.enabled) return "Turn on Bluetooth on the Bluetooth page."
if (this.adapterSetup && this.runtime?.car_profile_source !== "override") return "Choose your adapter model first."
if (!this.setup.parked) return "Use offroad mode or Park before starting pairing."
if (this.runtime?.running) return "Disconnect from your car before pairing."
if (this.linked) return "Disconnect from your car before pairing."
return ""
},
connectReason() {
if (!this.selected && !this.wired && !this.app) return "Pair your car in step 2 first."
if (!this.selected && !this.wired && !this.app) return this.adapterSetup ? "Pair your adapter in step 2 first." : "Pair your car in step 2 first."
if (!this.setup?.enabled) return "Turn on Android Auto Support in step 1."
if (!this.setup.serviceReady) return "Waiting for the Android Auto service to start."
if (this.pairing?.active) return "Finish or cancel pairing first."
if (this.adapterSetup && this.runtime?.car_profile_source !== "override") return "Choose your adapter model in step 2 first."
if (this.wired && this.runtime?.wired_blocked) return this.runtime.wired_blocked
if (this.wiredJetlinkBusy) return "Turn off Jetlink first."
return ""
},
pairSteps() {
const state = this.pairing?.state, active = !!this.pairing?.active
const paired = ["paired", "connected"].includes(state)
const picked = !!(this.pairing?.receiver || this.pairing?.approved || ["pairing", "connecting"].includes(state) || paired)
const what = this.adapterSetup ? "adapter" : "car"
const failed = state === "failed" || !!this.pairing?.error
const rank = paired ? 5 : state === "connecting" ? 4 : this.pairing?.approved ? 3 : this.pairing?.prompt ? 2 : picked ? 2 : active ? 1 : 0
const steps = [
`Start the search${this.starting ? "…" : ""}`,
`Put your ${what} in pairing mode, then pick it from the list`,
this.pairing?.prompt ? "Confirm the code on both screens" : "Pair with your device",
"Finish Bluetooth pairing",
"Finish connecting to your device"
]
return steps.map((label, index) => ({ label, status: index < rank ? "done" : index === rank ? (failed ? "failed" : "active") : "pending" }))
},
showPairSteps() { return this.starting || !!this.pairing?.active },
waitingForCar() {
return this.pairing?.active && !this.pairing?.receiver && !this.pairing?.prompt && !this.pairing?.approved &&
!["pairing", "connecting", "paired", "connected", "failed"].includes(this.pairing?.state)
},
deviceSelectionBlocked() {
return !!this.pairingReason || !this.pairing?.active ||
!!this.pairing?.prompt || ["pairing", "connecting"].includes(this.pairing?.state) || !!this.runtime?.running
!!this.pairing?.prompt || ["pairing", "connecting"].includes(this.pairing?.state) || !!this.linked
},
},
methods: {
switchRow,
stopWaiting() { return this.waiting ? this.control("stop") : null },
afterWaiting(next) {
const stopping = this.stopWaiting()
return stopping ? stopping.then((stopped) => stopped && next()) : next()
},
useCar(address) { return this.afterWaiting(() => this.control("select_receiver", { address })) },
saveCarType(index, value) {
const labels = this.carTypeLabels
const carType = Object.keys(labels).find(key => labels[key] === value)
if (carType === "usb" && this.wiredUnavailable) {
this.feed.error = `Wired AA is unavailable: ${this.runtime.wired_blocked}.`
this.feed.emit()
return false
}
if (carType === this.carType) return false
return carType ? this.afterWaiting(() => this.control("car_type", { carType })) : false
},
saveAdapter(index, value) {
const profile = Object.keys(ADAPTER_MODELS).find(key => ADAPTER_MODELS[key] === value)
return profile ? this.afterWaiting(() => this.control("car_profile", { profile })) : false
},
saveApp(index, value) {
const connection = Object.keys(HEAD_UNIT_APPS).find(key => HEAD_UNIT_APPS[key] === value) || "wireless"
return this.control("connection", { connection })
return this.afterWaiting(() => this.control("connection", { connection }))
},
async begin() {
await this.feed.start()
@@ -613,8 +724,14 @@ export const AndroidAutoPage = {
this.finishEnable(enabled)
return enabled
},
async beginPairing() {
this.starting = true
try { return await this.startPairing() } finally { this.starting = false }
},
startPairing() {
if (this.pairingReason || this.runtime?.running) return false
if (this.pairingReason) return false
const stopping = this.stopWaiting()
if (stopping) return stopping.then((stopped) => stopped && this.startPairing())
if (!this.setup.enabled) return this.setEnabled(true).then((enabled) => {
this.pairWhenReady = enabled
return this.resumePairing()
@@ -690,42 +807,18 @@ export const AndroidAutoPage = {
<section class="gx-driving" aria-label="Android Auto setup" :inert="busy" :aria-busy="busy || undefined">
<header class="gx-page-header">
<h2>Android Auto</h2>
<p>Show the StarPilot driving view on your car’s screen. Set up support, {{ wired ? 'plug in your car' : 'pair your car' }}, then connect.</p>
<p>Show the StarPilot driving view on your car’s screen. Choose your device, follow the setup steps, then connect.</p>
</header>
<GxNotice tone="warn" v-if="!wired && !app">Android Auto uses the comma’s Wi-Fi to project to your car and disconnects it from your current Wi-Fi network. Use the Galaxy hotspot for local access during projection. Remote Galaxy access requires Internet on the comma.</GxNotice>
<GxNotice tone="warn" v-if="!wired && !app && runtime?.dedicated_wifi_interface === false">Android Auto uses the comma’s Wi-Fi to project to your car and disconnects it from your current Wi-Fi network. Use the Galaxy hotspot for local access during projection. Remote Galaxy access requires Internet on the comma.</GxNotice>
<GxNotice tone="danger" v-if="error && setup">{{ error }}</GxNotice>
<GxNotice v-if="!setup" :busy="!error" :tone="error ? 'danger' : 'info'">{{ error || "Checking Android Auto setup… Reconnecting automatically…" }}</GxNotice>
<section v-if="setup && setupDone" class="gx-card gx-aa-step gx-aa-home" aria-labelledby="gx-aa-home-title">
<div class="gx-aa-step__head">
<span class="gx-aa-step__num" :class="{ 'gx-aa-home__icon--live': projecting }" aria-hidden="true"><i class="bi bi-car-front"></i></span>
<div><h3 id="gx-aa-home-title">{{ projecting ? 'Connected to ' + connectionName : runtime?.running ? 'Connecting to ' + connectionName : connectionName }}</h3>
<p role="status">{{ runtime?.running && runtime.state !== 'streaming' && runtime.label ? runtime.label : runtime?.running ? 'Showing StarPilot on your car’s screen.' : runtime?.auto_connect ? 'Connects automatically when you start driving.' : 'Ready to connect.' }}</p></div>
</div>
<div class="gx-aa-step__body">
<p v-if="expiry.level === 'soon'" class="gx-aa-pill gx-aa-pill--soon" role="status">{{ expiry.label }}</p>
<button v-if="!runtime?.running" class="gx-btn gx-aa-cta" :disabled="!!connectReason" @click="connect">Connect</button>
<button v-else class="gx-btn gx-btn--tonal gx-aa-cta" @click="control('stop')">Disconnect</button>
<GalaxySettingRow :row="switchRow('Automatic Connection', runtime?.auto_connect, 'Connect to your car whenever you start driving.')"
:index="0" :busy="busy" :disabled="!setup.enabled || !setup.parked || !!pairing?.active"
:save-value="(index, value) => control('auto_connect', { enabled: value === 'On' })" />
<GalaxySettingRow :row="wiredRow" :index="0" :busy="busy" :disabled="wiredDisabled"
:save-value="(index, value) => control('connection', { connection: value === 'On' ? 'wired' : 'wireless' })" />
<GalaxySettingRow :row="appRow" :index="0" :busy="busy" :disabled="appDisabled" :save-value="saveApp" />
${APP_SETUP}
<div class="gx-actions">
<a class="gx-btn gx-btn--tonal" href="#/theme_maker/android_auto"><i class="bi bi-layout-text-window" aria-hidden="true"></i> Customize Layout</a>
<button type="button" class="gx-btn gx-btn--tonal" :aria-expanded="showSteps" aria-controls="gx-aa-steps" @click="showSteps = !showSteps">
<i :class="showSteps ? 'bi bi-chevron-up' : 'bi bi-gear'" aria-hidden="true"></i> {{ showSteps ? 'Hide Setup' : 'Setup and Pairing' }}</button>
</div>
</div>
</section>
<ol v-if="setup" v-show="!setupDone || showSteps" id="gx-aa-steps" class="gx-aa__steps">
<li class="gx-card gx-aa-step" :class="{ 'gx-aa-step--done': supportReady }">
<section v-if="setup" class="gx-card gx-aa-step" :class="{ 'gx-aa-step--done': supportReady }">
<div class="gx-aa-step__head">
<span class="gx-aa-step__num" aria-hidden="true"><i v-if="supportReady" class="bi bi-check-lg"></i><template v-else>1</template></span>
<div><h3>Android Auto Support</h3><p>Turn on Android Auto and install support. Wireless pairing also needs Bluetooth on; Galaxy will offer to enable it.</p></div>
<div><h3>Android Auto Support</h3><p v-if="supportCollapsed" role="status">{{ expiry.label }}</p><p v-else>Turn on Android Auto and install support. Wireless pairing also needs Bluetooth on; Galaxy will offer to enable it.</p></div>
<button v-if="supportReady && setup.installReady" type="button" class="gx-btn gx-btn--tonal" :aria-expanded="!supportCollapsed" aria-controls="gx-aa-support" @click="supportOpen = !supportOpen">{{ supportCollapsed ? 'Manage' : 'Hide' }}</button>
</div>
<div class="gx-aa-step__body">
<div id="gx-aa-support" v-show="!supportCollapsed" class="gx-aa-step__body">
<p v-if="!setup.installReady" class="gx-aa-pill gx-aa-pill--expired" role="status">Not available on this build — the Android Auto display and encoder are missing.</p>
<template v-else>
<div class="gx-aa-status">
@@ -758,25 +851,54 @@ export const AndroidAutoPage = {
</ul>
</details>
</div>
</li>
<li class="gx-card gx-aa-step" :class="{ 'gx-aa-step--done': !!selected && !pairing?.active }">
</section>
<ol v-if="setup" id="gx-aa-steps" class="gx-aa__steps">
<li v-show="!setupDone || showSteps" class="gx-card gx-aa-step" :class="{ 'gx-aa-step--done': !!selected && !pairing?.active }">
<div class="gx-aa-step__head">
<span class="gx-aa-step__num" aria-hidden="true"><i v-if="selected && !pairing?.active" class="bi bi-check-lg"></i><template v-else>2</template></span>
<div><h3>Pair Your Car</h3><p>Open your car’s phone pairing screen, then tap Find car. You can do this before step 1 is finished.</p></div>
<div><h3>{{ carType === 'usb' ? 'Set Up Wired AA' : carType === 'app' ? 'Set Up Your App' : adapterSetup ? 'Pair Your Adapter' : 'Pair Your Car' }}</h3><p>{{ adapterSetup ? 'Put the adapter in pairing mode, then tap Find adapter. Your car doesn’t need to be paired.' : pairsHere ? 'Open your car’s phone pairing screen, then tap Find car. You can do this before step 1 is finished.' : 'No Bluetooth pairing is needed for this setup.' }}</p></div>
</div>
<div class="gx-aa-step__body">
<button v-if="setupDone && showSteps" class="gx-btn gx-btn--tonal" @click="showSteps = false">Hide Setup</button>
<GalaxySettingRow :row="carTypeRow" :index="0" :busy="busy" :disabled="appDisabled" :save-value="saveCarType" />
<GxNotice v-if="wiredUnavailable" tone="warn">Wired AA is unavailable: {{ runtime.wired_blocked }}.</GxNotice>
<GalaxySettingRow v-if="carType === 'app'" :row="appRow" :index="0" :busy="busy" :disabled="appDisabled" :save-value="saveApp" />
<GalaxySettingRow v-if="adapterSetup" :row="adapterRow" :index="0" :busy="busy" :disabled="appDisabled" :save-value="saveAdapter" />
${AUTO_CONNECT}
<template v-if="carType === 'usb'">
${WIRED_STATUS}
</template>
<template v-else-if="carType === 'app'">
${APP_SETUP}
</template>
<template v-if="pairsHere">
<ol v-if="adapterSetup" class="gx-aa-list" aria-label="Adapter pairing">
<li class="gx-aa-row">
<i class="bi bi-usb-symbol" aria-hidden="true"></i>
<span class="gx-aa-row__text"><strong>Adapter</strong><small>{{ selected ? selected.name : 'Put it in pairing mode, then tap Find adapter.' }}</small></span>
<span class="gx-aa-pill" :class="selected ? 'gx-aa-pill--ready' : ''">{{ selected ? 'Paired' : 'Not paired' }}</span>
</li>
</ol>
<GxNotice v-if="!pairing?.active && runtime?.pairing_note" tone="warn">{{ runtime.pairing_note }}</GxNotice>
<ul v-if="!pairing?.active && receivers.length" class="gx-aa-list" aria-label="Paired cars">
<li v-for="car in receivers" :key="car.address" class="gx-aa-row">
<i class="bi bi-car-front" aria-hidden="true"></i>
<span class="gx-aa-row__text"><strong>{{ car.name }}</strong><small>{{ selected?.address === car.address ? 'Used for Android Auto' : 'Paired' }}</small></span>
<span v-if="selected?.address === car.address" class="gx-aa-pill gx-aa-pill--ready"><i class="bi bi-check-lg"></i> Selected</span>
<button v-else class="gx-btn gx-btn--tonal" :disabled="!setup.parked || runtime?.running" @click="control('select_receiver', { address: car.address })">Use</button>
<button v-else class="gx-btn gx-btn--tonal" :disabled="!setup.parked || linked" @click="useCar(car.address)">Use</button>
</li>
</ul>
<p v-else-if="!pairing?.active && selected" class="gx-aa-row"><i class="bi bi-car-front" aria-hidden="true"></i><span class="gx-aa-row__text"><strong>{{ selected.name }}</strong><small>Used for Android Auto</small></span></p>
<p v-if="!pairing?.active && endReason === 'canceled'" class="gx-note" role="status">Pairing canceled. Start again when your car is ready.</p>
<p v-else-if="!pairing?.active && endReason" class="gx-note" role="status">The pairing window ended without choosing a car. Try again when your car’s pairing screen is open.</p>
<ol v-if="showPairSteps" class="gx-aa-list" aria-label="Pairing progress">
<li v-for="step in pairSteps" :key="step.label" class="gx-aa-row" :class="'gx-aa-row--' + step.status" :aria-current="step.status === 'active' ? 'step' : undefined">
<i v-if="step.status === 'done'" class="bi bi-check-circle-fill gx-aa-ok" aria-hidden="true"></i>
<span v-else-if="step.status === 'active'" class="gx-spinner gx-aa-orbit gx-aa-orbit--small" aria-hidden="true"></span>
<i v-else class="bi bi-circle" aria-hidden="true"></i>
<span class="gx-aa-row__text"><strong>{{ step.label }}</strong></span>
</li>
</ol>
<p v-if="waitingForCar" role="status">{{ pairing.discovering ? "Searching for nearby cars and adapters…" : "Choose your car or wireless adapter below." }}</p>
<p v-else-if="pairing?.active && pairing.approved && (!pairing.state || pairing.state === 'idle')" role="status">Approved {{ pairing.receiver?.name }}. Finishing Bluetooth pairing…</p>
<p v-if="pairing?.state === 'pairing' && !pairing.prompt && !pairing.approved" role="status">Pairing with {{ pairing.receiver?.name || 'the selected device' }}…</p>
@@ -806,14 +928,14 @@ export const AndroidAutoPage = {
<p v-if="pairWhenReady" class="gx-note" role="status"><span class="gx-spinner" aria-hidden="true"></span> Turning on Android Auto, then searching…</p>
<p v-else-if="!pairing?.active && pairingReason" class="gx-note" role="status">{{ pairingReason }}</p>
<div class="gx-driving__actions gx-actions">
<button v-if="!pairing?.active" class="gx-btn" :class="{ 'gx-btn--tonal': !!selected }" :disabled="pairWhenReady || !!pairingReason" @click="startPairing"><i class="bi bi-search"></i> {{ selected ? 'Pair another' : 'Find car' }}</button>
<button v-if="!pairing?.active" class="gx-btn" :class="{ 'gx-btn--tonal': !!selected }" :disabled="pairWhenReady || !!pairingReason" @click="beginPairing"><span v-if="starting" class="gx-spinner" aria-hidden="true"></span><i v-else class="bi bi-search"></i> {{ starting ? 'Starting search…' : selected ? 'Pair another ' + findLabel : 'Find ' + findLabel }}</button>
<button v-else class="gx-btn gx-btn--tonal" @click="cancelPairing">Cancel Search / Pairing</button>
</div>
<details class="gx-aa-wiki"><summary>Stuck pairing? Pairing order and wireless adapters</summary>
<ul>
<li><strong>Keep this page open while pairing</strong> so the car’s PIN or code doesn’t time out.</li>
<li><strong>Cars with built-in wireless Android Auto:</strong> open the car’s phone pairing screen, then tap <strong>Find car</strong> above. Choose your car, then confirm the code on both screens.</li>
<li><strong>Wireless adapter</strong> (AAWireless, Motorola MA1, Carlinkit…): pair your car in Bluetooth under Tools first so calls keep working, then put the adapter in pairing mode and pair it here. Adapter names often look like AndroidAuto-XXXX.</li>
<li><strong>Wireless adapter</strong> (AAWireless, Motorola MA1, Carlinkit…): choose Wireless adapter above, put the adapter in pairing mode and pair it here Your car doesn’t need to be paired. Adapter names often look like AndroidAuto-XXXX.</li>
</ul>
<p><strong>Start fresh:</strong></p>
<ol>
@@ -824,44 +946,62 @@ export const AndroidAutoPage = {
</ol>
<a class="gx-btn gx-btn--tonal" href="#/bluetooth">Open Bluetooth</a>
</details>
</div>
</li>
<li class="gx-card gx-aa-step" :class="{ 'gx-aa-step--done': projecting }">
<div class="gx-aa-step__head">
<span class="gx-aa-step__num" aria-hidden="true"><i v-if="projecting" class="bi bi-check-lg"></i><template v-else>3</template></span>
<div><h3>Connect &amp; Drive</h3><p>Start streaming to your dashboard.</p></div>
</div>
<div class="gx-aa-step__body">
<p v-if="projecting" class="gx-aa-pill gx-aa-pill--ready" role="status">Connected<span v-if="runtime.state"> · {{ runtime.label || runtime.state }}</span><span v-if="runtime.detail"> — {{ runtime.detail }}</span></p>
<p v-else-if="runtime?.state && runtime.state !== 'idle'" class="gx-note" role="status">Projection: {{ runtime.label || runtime.state }}<span v-if="runtime.detail"> — {{ runtime.detail }}</span>.</p>
<button v-if="!runtime?.running" class="gx-btn gx-aa-cta" :disabled="!!connectReason" @click="connect">{{ selected ? 'Connect to ' + selected.name : 'Connect to car' }}</button>
<button v-else class="gx-btn gx-btn--tonal gx-aa-cta" @click="control('stop')">Disconnect</button>
</template>
${CONNECTION_PROGRESS}
${CONNECT}
<p v-if="connectReason" class="gx-note" role="status">{{ connectReason }}</p>
<p v-else-if="!setup.identity.installed" class="gx-note">Finish step 1 first — Connect will walk you through it.</p>
<GalaxySettingRow :row="switchRow('Automatic Connection', runtime?.auto_connect, 'Connect to your car whenever you start driving.')"
:index="0" :busy="busy" :disabled="!setup.enabled || !setup.parked || !!pairing?.active"
:save-value="(index, value) => control('auto_connect', { enabled: value === 'On' })" />
<GalaxySettingRow :row="wiredRow" :index="0" :busy="busy" :disabled="wiredDisabled"
:save-value="(index, value) => control('connection', { connection: value === 'On' ? 'wired' : 'wireless' })" />
<GalaxySettingRow :row="appRow" :index="0" :busy="busy" :disabled="appDisabled" :save-value="saveApp" />
${APP_SETUP}
<p v-if="runtime?.companion_name" class="gx-note">Car: {{ runtime.companion_name }}</p>
<details class="gx-aa-wiki"><summary>Stuck connecting? Wi-Fi and screen troubleshooting</summary>
<ul>
<li v-if="!wired && !app"><strong>How it connects:</strong> after Bluetooth, the car (or adapter) creates its own hidden 5 GHz Wi-Fi network and tells the comma how to join. The picture streams over that Wi-Fi.</li>
<li><strong>First connection:</strong> stay in Park or offroad mode, and keep the car’s screen on.</li>
<li><strong>Black or frozen screen:</strong> tap Disconnect, wait a few seconds, then connect again. If it keeps happening, turn the car off and on.</li>
<li><strong>Car says the phone isn’t compatible:</strong> make sure wireless Android Auto (sometimes called “smartphone connection”) is turned on in the car’s settings, and that step 1 shows Ready.</li>
<li><strong>Wired USB:</strong> turn on Wired USB Connection, plug the car’s data USB port into the comma’s USB-C port with a data-capable cable, and drive. Bluetooth pairing is not needed.</li>
<li><strong>Wired AA:</strong> turn on Wired AA in step 2, plug the car’s data USB port into the comma’s USB-C port with a data-capable cable, and drive. Bluetooth pairing is not needed.</li>
</ul>
</details>
</div>
</li>
<li class="gx-card gx-aa-step">
</ol>
<section v-if="setup && setupDone && !showSteps" class="gx-card gx-aa-step gx-aa-home" aria-labelledby="gx-aa-home-title">
<div class="gx-aa-step__head">
<span class="gx-aa-step__num" :class="{ 'gx-aa-home__icon--live': projecting }" aria-hidden="true"><i class="bi bi-car-front"></i></span>
<div><h3 id="gx-aa-home-title">{{ homeTitle }}</h3>
<p role="status">{{ runtime?.running && runtime.state !== 'streaming' ? connectionProgress.title : projecting ? 'Showing StarPilot on your car’s screen.' : wired ? wiredStatus.label : runtime?.auto_connect ? 'Connects automatically when you start driving.' : 'Ready to connect.' }}</p></div>
</div>
<div class="gx-aa-step__body">
<p v-if="expiry.level === 'soon'" class="gx-aa-pill gx-aa-pill--soon" role="status">{{ expiry.label }}</p>
<GalaxySettingRow :row="carTypeRow" :index="0" :busy="busy" :disabled="appDisabled" :save-value="saveCarType" />
<GalaxySettingRow v-if="adapterSetup" :row="adapterRow" :index="0" :busy="busy" :disabled="appDisabled" :save-value="saveAdapter" />
<GalaxySettingRow v-if="app" :row="appRow" :index="0" :busy="busy" :disabled="appDisabled" :save-value="saveApp" />
${AUTO_CONNECT}
<ul v-if="wirelessHome" class="gx-aa-list" aria-label="Setup steps">
<li class="gx-aa-row"><i class="bi bi-shield-check" aria-hidden="true"></i>
<span class="gx-aa-row__text"><strong>1. Android Auto support</strong><small>{{ expiry.label }}</small></span>
<span class="gx-aa-pill" :class="supportReady ? 'gx-aa-pill--ready' : ''">{{ supportReady ? 'Ready' : 'Not ready' }}</span></li>
<li class="gx-aa-row"><i class="bi bi-bluetooth" aria-hidden="true"></i>
<span class="gx-aa-row__text"><strong>2. {{ adapterSetup ? 'Adapter paired' : 'Car paired' }}</strong><small>{{ selected ? selected.name : 'Not paired yet.' }}</small></span>
<span class="gx-aa-pill" :class="selected ? 'gx-aa-pill--ready' : ''">{{ selected ? 'Paired' : 'Not paired' }}</span></li>
<li class="gx-aa-row"><i class="bi bi-car-front" aria-hidden="true"></i>
<span class="gx-aa-row__text"><strong>3. Connect</strong><small>{{ projecting ? 'Showing on your car’s screen.' : runtime?.running ? connectionProgress.title : 'Tap Connect below.' }}</small></span>
<span class="gx-aa-pill" :class="projecting ? 'gx-aa-pill--ready' : ''">{{ projecting ? 'Connected' : runtime?.running ? 'In progress' : 'Waiting' }}</span></li>
</ul>
${WIRED_STATUS}
${APP_SETUP}
${CONNECTION_PROGRESS}
${CONNECT}
<div class="gx-actions">
<a class="gx-btn gx-btn--tonal" href="#/theme_maker/android_auto"><i class="bi bi-layout-text-window" aria-hidden="true"></i> Customize Layout</a>
<button type="button" class="gx-btn gx-btn--tonal" :aria-expanded="showSteps" aria-controls="gx-aa-steps" @click="showSteps = !showSteps">
<i :class="showSteps ? 'bi bi-chevron-up' : 'bi bi-gear'" aria-hidden="true"></i> {{ showSteps ? 'Hide Setup' : 'Setup and Pairing' }}</button>
</div>
</div>
</section>
<ol v-if="setup" class="gx-aa__steps">
<li v-show="!setupDone || showSteps" class="gx-card gx-aa-step">
<div class="gx-aa-step__head">
<span class="gx-aa-step__num" aria-hidden="true">4</span>
<span class="gx-aa-step__num" aria-hidden="true">3</span>
<div><h3>Customize Your Dashboard</h3><p>Once connected, customize your dashboard setup.</p></div>
</div>
<div class="gx-aa-step__body">
@@ -218,6 +218,7 @@ export const BluetoothPage = {
<GxNotice tone="danger" v-if="error">{{ error }}</GxNotice>
<GxState v-if="!status && !error" loading>Checking Bluetooth…</GxState>
<template v-else-if="status">
<GxNotice tone="info">Using a wireless Android Auto adapter? Pair the adapter on the <a class="gx-link" href="#/android-auto">Android Auto page</a>, then pair and connect your car here.</GxNotice>
<section class="gx-card"><div class="gx-section__header"><i class="bi bi-bluetooth" aria-hidden="true"></i><span class="gx-section__title">Adapter</span></div>
<div class="gx-panel gx-stack">
<GxNotice v-if="status.errorCode" tone="danger">{{ bluetoothError(status.errorCode) }}</GxNotice>
@@ -228,6 +228,27 @@ class SharedBluetoothOwner(BluetoothOwner):
def _admission_busy(self) -> bool:
return self.phone_lease is not None or super()._admission_busy()
@staticmethod
def _start_scan(client) -> bool:
"""BlueZ can hold a stale discovery request; reset it once, and still open the window (the car can pair to us) if it stays stuck."""
try:
client.operation('scan')
return True
except BluetoothRejected as error:
if 'InProgress' not in str(error):
raise
try:
client.operation('stop_scan')
except BluetoothRejected:
pass
try:
client.operation('scan')
return True
except BluetoothRejected as error:
if 'InProgress' not in str(error):
raise
return False
def acquire_phone_role(self, session: tuple, phone: PhoneProfile, *, seconds: float = 180.0) -> PhoneRoleLease:
if not session or not 0 < seconds <= 180:
raise ValueError('Invalid phone-role session or duration')
@@ -290,8 +311,7 @@ class SharedBluetoothOwner(BluetoothOwner):
self.phone_lease = lease
self.admission_file = process_lock
try:
client.operation('scan')
lease.discovering = True
lease.discovering = self._start_scan(client)
except BaseException:
self.release_phone_role(lease)
raise
@@ -21,6 +21,8 @@ import time
AF_BLUETOOTH = 31
BTPROTO_L2CAP = 0
BTPROTO_RFCOMM = 3
ENOSYS_RETRIES = 2 # some kernels report ENOSYS on a connect that succeeds moments later
ENOSYS_RETRY_S = 0.5
ADDRESS_RE = re.compile(r"^(?:[0-9A-F]{2}:){5}[0-9A-F]{2}$")
@@ -88,11 +90,14 @@ def _libc_connect(proto: int, kind: int, address: str, port: int, timeout: float
def _connect(proto: int, kind: int, address: str, port: int, timeout: float) -> socket.socket:
address = normalize_address(address)
if hasattr(socket, "AF_BLUETOOTH") and hasattr(socket, "BTPROTO_RFCOMM"):
try:
return _native(proto, kind, address, port, timeout)
except OSError as exc:
if exc.errno != errno.ENOSYS:
raise
for attempt in range(ENOSYS_RETRIES + 1):
try:
return _native(proto, kind, address, port, timeout)
except OSError as exc:
if exc.errno != errno.ENOSYS:
raise
if attempt < ENOSYS_RETRIES:
time.sleep(ENOSYS_RETRY_S)
return _libc_connect(proto, kind, address, port, timeout)
@@ -13,7 +13,7 @@ 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 import identity as identity_store, system_slice
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
@@ -25,6 +25,16 @@ LOG_SETTLE_S = 120 # a log written this recently may still be a live ses
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
TRACE_QUIET = frozenset({"nav_guidance_sent", "nav_focus", "nav_focus_requested", "nav_status_state", "media_key", "media_audio_state",
"media_audio_focus", "media_status_state", "hfp_command", "video_focus"})
TRACE_SESSION_BYTES = 40 << 10 # per failed session; the oldest events go first
TRACE_TOTAL_BYTES = 160 << 10 # all traces in one report, well under the sender's event cap
EVENT_BUDGET = 400 << 10 # a report is shed down to this before sending; the sender refuses more than 512 KB
MAX_ATTENTION = 6 # sessions per report that get a trace and a device slice
SERIES_KEYS = ("fps", "encode_ms", "frame_age_p95_ms", "wifi_dbm", "wifi_link", "wifi_noise", "load1", "mem_available_mb",
"temp_max_c", "cpu0_mhz", "cpu4_mhz", "frames_sent", "focused")
SERIES_MAX = 240
EXTRA_TAIL_LINES = 80
FINISHED_EVENT = '"event": "session_stop"'
OUTCOME_RANK = ("streamed", "recovered", "stopped", "dropped", "failed")
@@ -59,6 +69,21 @@ def stage_timings(events: list[dict]) -> list[dict]:
return [{"stage": stage, "at_s": at} for stage, at in seen.items()]
def stats_series(events: list[dict]) -> dict:
"""Every 30 s stats sample as columns (seconds since the start, then one list per reading), thinned to SERIES_MAX."""
stats = [event for event in events if event["event"] == "stats"]
stride = -(-len(stats) // SERIES_MAX) if stats else 1
stats = stats[::stride]
if not stats:
return {}
columns = {"at_s": [elapsed(events[0], event) for event in stats]}
for key in SERIES_KEYS:
values = [event.get(key) if isinstance(event.get(key), (int, float, bool)) else None for event in stats]
if any(value is not None for value in values):
columns[key] = values
return columns
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"), {})
@@ -88,10 +113,51 @@ def video_summary(events: list[dict]) -> dict:
"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),
"unfocused_samples": sum(1 for event in events if event["event"] == "stats" and event.get("focused") is False),
}
return {key: value for key, value in summary.items() if value is not None}
def _trim(value):
if isinstance(value, str):
return value[:300]
if isinstance(value, (dict, list)) and len(json.dumps(value, default=str)) > 600:
return json.dumps(value, default=str)[:600]
return value
def trace_events(events: list[dict]) -> list[dict]:
"""The session log without routine media and navigation chatter, each event stamped with seconds since the start."""
trace = []
for event in events:
if event["event"] in TRACE_QUIET:
continue
values = {key: _trim(value) for key, value in event.items() if key not in ("t", "mono_s", "event")}
trace.append({"at_s": elapsed(events[0], event), "event": event["event"], **values})
return trace
def quiet_counts(events: list[dict]) -> dict:
"""How often each routine event the trace leaves out happened, so nav and media activity stays visible."""
counts: dict[str, int] = {}
for event in events:
if event["event"] in TRACE_QUIET:
counts[event["event"]] = counts.get(event["event"], 0) + 1
return counts
def bounded_trace(trace: list[dict], limit: int) -> dict:
"""The newest events that fit in ``limit`` bytes of JSON, with how many older ones were left out."""
kept, size = [], 0
for event in reversed(trace):
size += len(json.dumps(event, default=str)) + 2
if size > limit:
break
kept.append(event)
kept.reverse()
return {"events": kept, "omitted": len(trace) - len(kept)}
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
@@ -115,7 +181,7 @@ def session_record(path: Path) -> dict:
car = report["car"]
start = next((e for e in events if e["event"] == "session_start"), {})
software, settings = start.get("software", {}), start.get("settings")
return {
record = {
"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),
@@ -143,9 +209,37 @@ def session_record(path: Path) -> dict:
"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)},
"series": stats_series(events),
"quiet_events": quiet_counts(events),
"_report": report,
"_trace": trace_events(events),
"_system": system_slice.read_sidecar(path),
"_dir": path.parent,
"_names": [car.get("bluetooth_name", ""), car.get("display_name", "")],
}
record["attention"] = attention_reasons(record)
return record
def attention_reasons(session: dict) -> list[str]:
"""Why a session needs its full trace and device slice; empty for one that streamed cleanly."""
video, reasons = session["video"], []
if session["outcome"] != "streamed":
reasons.append(session["outcome"])
if session["stalls"]:
reasons.append("stalls")
if session["focus_lost"]:
reasons.append("focus_lost")
if video.get("encoder_recoveries"):
reasons.append("encoder_recoveries")
target = video.get("target_fps") or video.get("car_fps")
if target and video.get("fps_p10") is not None and video["fps_p10"] < 0.5 * target:
reasons.append("low_fps")
if video.get("unfocused_samples"):
reasons.append("unfocused")
if video.get("wifi_dbm_min") is not None and video["wifi_dbm_min"] <= -75:
reasons.append("weak_wifi")
return reasons
def _finished(path: Path) -> bool:
@@ -309,7 +403,16 @@ def drive_event(sessions: list[dict]) -> dict:
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 {
watched = [session for session in sessions if session["attention"]][-MAX_ATTENTION:]
per_trace = min(TRACE_SESSION_BYTES, TRACE_TOTAL_BYTES // max(1, len(watched)))
traces = {f"android_auto_trace_session_{session['seq']}": sanitize(
{**bounded_trace(session.get("_trace", []), per_trace), "routine_events": session.get("quiet_events", {})},
session.get("_names", ())) for session in watched}
slices = {f"android_auto_system_session_{session['seq']}": slice_context(session["_system"], session.get("_names", ()))
for session in watched[-3:] if session.get("_system")}
extra = extra_logs(watched[-1]) if watched else {}
reasons = sorted({reason for session in watched for reason in session["attention"]})
event = {
"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 []),
@@ -318,14 +421,76 @@ def drive_event(sessions: list[dict]) -> dict:
"aa.encoder": video.get("encoder", "unknown"),
"aa.fps": str(summary["fps_avg"]) if summary["fps_avg"] is not None else "none",
"aa.failures": str(len(failures)),
**({"aa.attention": ",".join(reasons)[:190]} if reasons else {}),
**({"aa.cause": latest["cause"], "aa.stage": latest["stage"], "aa.error_kind": latest["error_kind"]} if latest else {})},
"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}},
**{f"android_auto_compat_report_session_{session['seq']}": {"text": compat_text(session)} for session in watched},
**traces, **slices, **extra},
}
return fit_event(event)
def slice_context(system: dict, names=()) -> dict:
"""A saved device slice with every line scrubbed of names and SSIDs."""
sources = system.get("sources", {})
return {"window": system.get("window", {}),
**{name: ({"lines": [system_slice.scrub_line(line, names) for line in source["lines"]]} if "lines" in source
else {"error": scrub(source.get("error", ""))}) for name, source in sources.items() if isinstance(source, dict)}}
def extra_logs(session: dict) -> dict:
"""The newest lines of the renderer profile and the car UI log, which sit beside the session logs."""
from openpilot.starpilot.system.android_auto import compat_report
contexts = {}
for name, key in (("render_profile.txt", "android_auto_render_profile"), ("car_ui.log", "android_auto_car_ui")):
data = compat_report._tail(session["_dir"] / name, 32 << 10)
if data:
lines = system_slice.system_snapshot.redact(data).decode(errors="replace").splitlines()[-EXTRA_TAIL_LINES:]
contexts[key] = {"lines": [system_slice.scrub_line(line, session.get("_names", ())) for line in lines]}
return contexts
def event_size(event: dict) -> int:
return len(json.dumps(sanitize(event), default=str, allow_nan=False))
def shed_order(contexts: dict) -> list[str]:
"""Context keys from the least to the most valuable, so an oversized report loses detail before it loses the story."""
def numbered(prefix):
return sorted((key for key in contexts if key.startswith(prefix)), key=lambda key: int(key.rsplit("_", 1)[-1]))
return [*(key for key in ("android_auto_render_profile", "android_auto_car_ui") if key in contexts),
*numbered("android_auto_system_session_"), *numbered("android_auto_compat_report_session_"),
*numbered("android_auto_trace_session_")]
def fit_event(event: dict, budget: int = EVENT_BUDGET) -> dict:
"""Drop optional detail, least valuable first, until the report fits; sessions then lose their series and attempts."""
shed = []
contexts = event["contexts"]
for key in shed_order(contexts):
if event_size(event) <= budget:
break
contexts.pop(key)
shed.append(key)
for key in [key for key in contexts if key.startswith("android_auto_session_")]:
if event_size(event) <= budget:
break
contexts[key] = {name: value for name, value in contexts[key].items() if name not in ("series", "attempts", "stages", "settings")}
shed.append(f"{key}:detail")
if shed:
event["tags"]["aa.truncated"] = "yes"
contexts["android_auto_shed"] = {"dropped": shed}
if event_size(event) > budget:
event["breadcrumbs"] = {"values": event["breadcrumbs"]["values"][-20:]}
if event_size(event) > budget:
keep = ("android_auto_drive", "android_auto_head_unit", "android_auto_shed")
event["contexts"] = {key: value for key, value in contexts.items() if key in keep or key.startswith("android_auto_failure_")}
event["contexts"]["android_auto_shed"] = {"dropped": ["everything but the totals and failures"]}
return event
def record_pairing(record: dict, path: Path = PAIRING_PATH) -> None:
@@ -358,14 +523,25 @@ def new_pairings(last_seq: int, path: Path = PAIRING_PATH) -> list[dict]:
return records
def pairing_event(record: dict) -> dict:
def pairing_system(record: dict, collector: Callable[..., dict]) -> dict:
"""The device's Wi-Fi and Bluetooth activity during a pairing window that did not end in a pairing."""
try:
start = datetime.fromisoformat(record["started"]).timestamp()
system = collector(start, start + float(record.get("duration_s") or record.get("window_s") or 0))
return {"android_auto_pairing_system": slice_context(system)}
except Exception:
return {}
def pairing_event(record: dict, collector: Callable[..., dict] | None = None) -> dict:
result = str(record.get("result", "unknown"))
system = pairing_system(record, collector) if collector is not None and result != "paired" else {}
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)},
"contexts": {"android_auto_pairing": sanitize(record), **system},
}
@@ -387,6 +563,11 @@ class PairingAttempt:
self.record["steps"].pop(0)
self.record["steps_omitted"] = self.record.get("steps_omitted", 0) + 1
def reached_connection(self) -> bool:
"""The car was approved or connected before the window ended."""
return any(step.get("step") == "status" and (step.get("approved") or step.get("state") in ("paired", "connected"))
for step in self.record["steps"])
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)})
@@ -414,7 +595,8 @@ def _write_atomic(path: Path, text: str) -> None:
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:
log_dir: Path | None = None, pairing_path: Path = PAIRING_PATH, now: float | None = None,
collector: Callable[..., dict] | 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."""
@@ -424,7 +606,7 @@ def send_reports(capture: Callable[..., bool], flush: Callable[[], None] = lambd
queued = 0
for record in new_pairings(pairing_seq, pairing_path):
if not capture(**pairing_event(record)):
if not capture(**pairing_event(record, collector or system_slice.collect)):
break
pairing_seq, queued = int(record["seq"]), queued + 1
@@ -0,0 +1,58 @@
"""Android Auto tuning selected from the saved head-unit fingerprint or an override."""
import re
DEFAULTS = {"connect_delay_s": 0.0, "max_window": 2, "phone_services": False, "phone_frames": False}
FINGERPRINT_KEYS = ("head_unit_make", "head_unit_model", "car_make", "car_model", "car_year")
RULES: list[dict] = [
{"id": "aawireless", "label": "AAWireless", "match": {},
"tuning": {"max_window": 30, "phone_services": True, "phone_frames": True}},
]
def profile_ids() -> list[str]:
return [rule["id"] for rule in RULES]
def valid_override(value: str) -> bool:
return value in ("", "default", *profile_ids())
def fingerprint_from(head_unit: dict, address: str) -> dict:
found = {key: str(head_unit[key])[:80] for key in FINGERPRINT_KEYS if head_unit.get(key)}
return {"address": address.upper(), **found} if found else {}
def _matches(rule: dict, fingerprint: dict) -> bool:
match = rule.get("match") or {}
return bool(match) and all(re.search(pattern, str(fingerprint.get(key, "")), re.IGNORECASE) for key, pattern in match.items())
def resolve(config: dict) -> tuple[str, str]:
"""(profile id, source) where source is "override", "detected" or "standard"."""
override = config.get("car_profile_override", "")
if override == "default":
return "default", "override"
if override in profile_ids():
return override, "override"
fingerprint = config.get("car_fingerprint") or {}
if fingerprint.get("address") == str(config.get("receiver_address", "")).upper():
for rule in RULES:
if _matches(rule, fingerprint):
return rule["id"], "detected"
return "default", "standard"
def tuning(config: dict) -> dict:
profile, _ = resolve(config)
rule = next((item for item in RULES if item["id"] == profile), {})
return {**DEFAULTS, **{key: value for key, value in (rule.get("tuning") or {}).items() if key in DEFAULTS}}
def summary(config: dict) -> dict:
"""What the UI may show: nothing at all while the standard behavior applies."""
profile, source = resolve(config)
rule = next((item for item in RULES if item["id"] == profile), None)
return {"car_profile": profile, "car_profile_source": source, "car_profile_label": rule["label"] if rule else "",
"car_profile_ids": profile_ids()}
@@ -28,7 +28,7 @@ from openpilot.starpilot.system.android_auto.source_verifier import GalaxySource
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",
AUTHENTICATED_COMMANDS = {"start", "set_auto_connect", "select_receiver", "set_view", "set_connection", "set_car_type", "set_car_profile",
"prepare_pairing", "pairing_status", "pairing_response", "cancel_pairing", "pair_device", "devices", "bluetooth_action",
"phone_window", "bluetooth_power"}
@@ -124,6 +124,10 @@ def handle(supervisor: Supervisor, request: dict[str, Any], verifier: GalaxySour
supervisor.set_view(str(request.get("view", "")))
elif command == "set_connection":
supervisor.set_connection(str(request.get("connection", "")))
elif command == "set_car_type":
supervisor.set_car_type(str(request.get("car_type", "")))
elif command == "set_car_profile":
supervisor.set_car_profile(str(request.get("profile", "")))
elif command == "prepare_pairing":
supervisor.prepare_pairing()
elif command == "pairing_status":
@@ -0,0 +1,57 @@
"""Cheap device load readings for the 30 s Android Auto stats line: plain file reads, no D-Bus and no subprocesses."""
from __future__ import annotations
import glob
def _read(path: str) -> str | None:
try:
with open(path) as handle:
return handle.read(4096)
except OSError:
return None
def _first_number(path: str) -> float | None:
text = _read(path)
try:
return float(text.split()[0]) if text else None
except (ValueError, IndexError):
return None
def _meminfo_available_mb() -> int | None:
for line in (_read("/proc/meminfo") or "").splitlines():
if line.startswith("MemAvailable:"):
try:
return int(line.split()[1]) // 1024
except (ValueError, IndexError):
return None
return None
def wireless_link(interface: str = "wlan0") -> dict:
"""Link quality, signal and noise of the joined network from /proc/net/wireless."""
for line in (_read("/proc/net/wireless") or "").splitlines():
name, _, rest = line.partition(":")
if name.strip() != interface:
continue
fields = rest.split()
try:
return {"wifi_link": int(float(fields[1].rstrip("."))), "wifi_dbm": int(float(fields[2].rstrip("."))),
"wifi_noise": int(float(fields[3].rstrip(".")))}
except (ValueError, IndexError):
return {}
return {}
def sample(interface: str = "wlan0") -> dict:
"""Load, free memory, CPU clocks, hottest thermal zone and Wi-Fi link; absent readings are left out."""
load = _read("/proc/loadavg")
temps = [value / 1000 for path in glob.glob("/sys/class/thermal/thermal_zone*/temp") if (value := _first_number(path)) is not None]
clocks = {f"cpu{core}_mhz": round(khz / 1000) for core in (0, 4)
if (khz := _first_number(f"/sys/devices/system/cpu/cpu{core}/cpufreq/scaling_cur_freq")) is not None}
values = {"load1": float(load.split()[0]) if load else None, "mem_available_mb": _meminfo_available_mb(),
"temp_max_c": round(max(temps), 1) if temps else None, **clocks, **wireless_link(interface)}
return {key: value for key, value in values.items() if value is not None}
@@ -83,7 +83,7 @@ def load_identity(directory: Path | None = None, now: datetime | None = None) ->
expires.isoformat() if expires is not None else "unknown", days_left)
CONFIG_VERSION = 6
CONFIG_VERSION = 7
# Before config versions, every save wrote these defaults, which pinned projection
# at 12 fps / 4000 kbps. Unversioned files holding exactly them get today's defaults.
LEGACY_DEFAULTS = {"fps": 12, "bitrate_kbps": 4000}
@@ -131,6 +131,9 @@ DEFAULT_CONFIG = {
"phone_class": True, # while pairing/connecting, present as a phone: HFP gateway + smartphone Class of Device
"auto_connect": True, # start projection on its own when the chosen car is on (onroad, or it reaches the comma)
"rfcomm_cache": {}, # car address -> Android Auto RFCOMM channel learned over SDP, to skip discovery next time
"car_type": "", # wireless setup shown in Galaxy: "builtin" or "adapter"; "" = work it out from the saved companion car
"car_profile_override": "", # "" = pick by fingerprint (a wireless adapter must choose); "default" = standard; otherwise a car_profiles id
"car_fingerprint": {}, # what the chosen car's head unit reported last time, used to pick a profile before it connects
}
@@ -149,6 +152,10 @@ def load_config(path: Path | None = None) -> dict:
stored = {key: value for key, value in stored.items() if key not in VERIFY_RESET}
if not isinstance(version, int) or version < 6:
stored = {key: value for key, value in stored.items() if key not in DEDICATED_WIFI_RESET}
if not isinstance(version, int) or version < 7: # adapter owners from before the adapter model choice keep connecting
on_adapter = stored.get("car_type") == "adapter" or (not stored.get("car_type") and stored.get("companion_address"))
if on_adapter and not stored.get("car_profile_override"):
stored = {**stored, "car_profile_override": "default"}
config.update({key: value for key, value in stored.items() if key in DEFAULT_CONFIG and isinstance(value, type(DEFAULT_CONFIG[key]))})
except (OSError, ValueError):
pass
@@ -160,6 +167,9 @@ def load_config(path: Path | None = None) -> dict:
config["connection"] = "wireless"
if not 1024 <= int(config["headunitpad_port"]) <= 65535:
config["headunitpad_port"] = DEFAULT_CONFIG["headunitpad_port"]
if config["car_type"] not in ("", "builtin", "adapter"):
config["car_type"] = ""
config["car_fingerprint"] = {str(k): str(v)[:80] for k, v in config["car_fingerprint"].items()}
if config["usb_mode"] not in ("auto", "handshake", "direct"):
config["usb_mode"] = "auto"
config["bitrate_kbps"] = max(1000, min(12000, int(config["bitrate_kbps"])))
@@ -68,6 +68,17 @@ INPUT_EVENT = 0x8001
INPUT_BINDING_REQUEST = 0x8002
INPUT_BINDING_RESPONSE = 0x8003
# Car data (sensor) and Bluetooth channels, opened the way a phone does.
SENSOR_SERVICE = 2
BLUETOOTH_SERVICE = 6
SENSOR_START_REQUEST = 0x8001
SENSOR_START_RESPONSE = 0x8002
SENSOR_EVENT = 0x8003
BLUETOOTH_PAIRING_REQUEST = 0x8001
BLUETOOTH_PAIRING_RESPONSE = 0x8002
BLUETOOTH_AUTH_DATA = 0x8003
SENSOR_NIGHT, SENSOR_DRIVING_STATUS = 10, 13
CODEC_H264_BP = 3
SETUP_STATUS_READY = 2
FOCUS_PROJECTED = 1
@@ -118,6 +129,18 @@ def codec_config(access_unit: bytes) -> bytes:
return b"".join(units)
def wire_frame(access_unit: bytes, *, drop_config: bool) -> bytes:
"""The picture NAL units as Android's encoder sends them: no AUD, and no SPS/PPS when the codec config went separately."""
starts = list(START_CODE.finditer(access_unit))
drop = {9, 7, 8} if drop_config else {9}
units = []
for index, match in enumerate(starts):
end = starts[index + 1].start() if index + 1 < len(starts) else len(access_unit)
if match.end() < end and access_unit[match.end()] & 31 not in drop:
units.append(b"\x00\x00\x00\x01" + access_unit[match.end():end])
return b"".join(units) or access_unit
@dataclass(frozen=True)
class VideoMode:
channel: int
@@ -184,6 +207,9 @@ def choose_video_mode(channels: list[dict]) -> VideoMode:
class Session:
"""TLS, framing and control-channel handshake; one instance per TCP connection."""
# Wireless adapters relay to the car, which can take a while (or wait for the driver) before it answers.
DISCOVERY_TIMEOUT = 60.0
def __init__(self, peer, cert: str, key: str, log: Callable[..., None] | None = None, ca: str | None = None, *,
receive_timeout: float = 3.0, send_timeout: float = 3.0, handshake_timeout: float = 15.0):
self.peer = peer
@@ -268,10 +294,10 @@ class Session:
self.peer.settimeout(previous_timeout)
return -(-len(data) // FRAGMENT_SIZE)
def receive(self, *, deadline: float | None = None) -> tuple[int, int, bytes]:
def receive(self, *, deadline: float | None = None, patient: bool = False) -> tuple[int, int, bytes]:
"""Read one complete message within one deadline, tolerating partial reads."""
message_deadline = time.monotonic() + (self.receive_timeout if self.authenticated else self.handshake_timeout)
deadline = message_deadline if deadline is None else min(deadline, message_deadline)
deadline = message_deadline if deadline is None else deadline if patient else min(deadline, message_deadline)
timed_peer = hasattr(self.peer, "gettimeout") and hasattr(self.peer, "settimeout")
previous_timeout = self.peer.gettimeout() if timed_peer else None
@@ -436,7 +462,7 @@ class Session:
def discover(self, device_name: str, device_brand: str) -> list[dict]:
self.send(0, MSG_SERVICE_DISCOVERY_REQUEST, field(4, device_name) + field(5, device_brand))
raw = self.wait_for(0, MSG_SERVICE_DISCOVERY_RESPONSE)
raw = self.wait_for(0, MSG_SERVICE_DISCOVERY_RESPONSE, timeout=self.DISCOVERY_TIMEOUT, patient=True)
response = parse_fields(raw)
channels = []
for descriptor in response.get(1, []):
@@ -469,6 +495,16 @@ class Session:
item["input_config"] = parse_input_config(one(fields, 4))
except ValueError:
item["input_config"] = InputConfig()
if isinstance(one(fields, SENSOR_SERVICE), bytes):
try:
item["sensors"] = [one(parse_fields(s), 1) for s in parse_fields(one(fields, SENSOR_SERVICE)).get(1, []) if isinstance(s, bytes)]
except ValueError:
item["sensors"] = []
if isinstance(one(fields, BLUETOOTH_SERVICE), bytes):
try:
item["bluetooth"] = parse_bluetooth(one(fields, BLUETOOTH_SERVICE))
except ValueError:
item["bluetooth"] = {}
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}))
@@ -477,10 +513,10 @@ class Session:
head_unit=head_unit)
return channels
def wait_for(self, channel: int, kind: int, timeout: float = 10.0) -> bytes:
def wait_for(self, channel: int, kind: int, timeout: float = 10.0, patient: bool = False) -> bytes:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
ch, message, data = self.receive()
ch, message, data = self.receive(deadline=deadline, patient=patient)
if ch == channel and message == kind:
return data
self.dispatch(ch, message, data, expected=f"{channel}/{kind:#x}")
@@ -499,6 +535,33 @@ HEAD_UNIT_INFO = 17 # ServiceDiscoveryResponse.headunit_info
HEAD_UNIT_INFO_VEHICLE_ID = 4
def packed_varints(value) -> list[int]:
"""A repeated enum: packed bytes on newer units, one varint per field on older ones."""
if isinstance(value, int):
return [value]
out, pos = [], 0
while pos < len(value):
number, shift = 0, 0
while True:
if pos >= len(value) or shift > 63:
raise ValueError("Truncated packed varint")
byte = value[pos]
pos += 1
number |= (byte & 127) << shift
shift += 7
if byte < 128:
break
out.append(number)
return out
def parse_bluetooth(data: bytes) -> dict:
fields = parse_fields(data)
address = one(fields, 1)
methods = [m for value in fields.get(2, []) for m in packed_varints(value)]
return {"car_address": address.decode(errors="replace") if isinstance(address, bytes) else "", "pairing_methods": methods}
def redact_head_unit(described: dict) -> dict:
"""Drop the vehicle identifier from a described discovery response: logs are shared for diagnosis."""
if SDR_VEHICLE_ID in described:
@@ -549,6 +612,13 @@ class ProjectionSession(Session):
self.epoch_acked = 0
self.video_confirmed = False
self.input_channel: int | None = None
self.max_window = self.MAX_WINDOW # raised by the supervisor for adapters that need the car's full window
self.phone_services = False # open the car-data and Bluetooth channels like a phone (adapter profile)
self.phone_frames = False # send picture NAL units only (adapter profile)
self.phone_address = "" # this comma's Bluetooth address, for the pairing request
self.sensor_channel: int | None = None
self.sensor_events: dict[int, int] = {}
self.bluetooth_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
@@ -567,6 +637,8 @@ class ProjectionSession(Session):
self.mode = choose_video_mode(self.channels)
self.open_video()
self.open_input()
if self.phone_services:
self.open_phone_services()
self.request_projection()
return self.mode
@@ -585,7 +657,7 @@ class ProjectionSession(Session):
configs = setup.get(3, [])
if configs and mode.config_index not in configs:
raise ValueError(f"Head unit did not accept video configuration {mode.config_index}: {json_fields(setup)}")
self.window = min(window, self.MAX_WINDOW)
self.window = min(window, self.max_window)
self.event("video_setup", mode=mode.as_dict(), window=window, used_window=self.window)
def open_input(self) -> None:
@@ -609,6 +681,36 @@ class ProjectionSession(Session):
except (TimeoutError, ValueError) as error:
self.event("input_unavailable", error=str(error))
def _open_optional(self, channel: dict, name: str) -> bool:
try:
self.send(channel["id"], MSG_CHANNEL_OPEN_REQUEST, field(1, 0) + field(2, channel["id"]), control=True)
opened = parse_fields(self.wait_for(channel["id"], MSG_CHANNEL_OPEN_RESPONSE, timeout=3.0))
if signed(one(opened, 1)) != 0:
raise ValueError(f"channel open rejected: {signed(one(opened, 1))}")
return True
except (TimeoutError, ValueError) as error:
self.event(f"{name}_unavailable", channel=channel["id"], error=str(error))
return False
def open_phone_services(self) -> None:
"""Open the car-data and Bluetooth channels before asking for the screen, as a phone does. Failures are not fatal."""
sensors = next((ch for ch in self.channels if "sensors" in ch), None)
if sensors is not None and self._open_optional(sensors, "sensors"):
self.sensor_channel = sensors["id"]
# Only what decides whether the screen may project; location would put the car's position in the session log.
wanted = [kind for kind in (SENSOR_DRIVING_STATUS, SENSOR_NIGHT) if kind in sensors["sensors"]]
for kind in wanted:
self.send(sensors["id"], SENSOR_START_REQUEST, field(1, kind) + field(2, 0))
self.event("sensors_opened", channel=sensors["id"], requested=wanted)
bluetooth = next((ch for ch in self.channels if "bluetooth" in ch), None)
if bluetooth is not None and self._open_optional(bluetooth, "bluetooth"):
self.bluetooth_channel = bluetooth["id"]
methods = bluetooth["bluetooth"].get("pairing_methods") or []
if self.phone_address:
self.send(bluetooth["id"], BLUETOOTH_PAIRING_REQUEST, field(1, self.phone_address) + field(2, methods[0] if methods else 2))
self.event("bluetooth_opened", channel=bluetooth["id"], phone_address=self.phone_address,
car_address=bluetooth["bluetooth"].get("car_address", ""), pairing_methods=methods)
def request_projection(self) -> None:
"""Ask for display focus; the Mazda donor needed this, DHU grants it unsolicited."""
assert self.mode is not None
@@ -693,6 +795,24 @@ class ProjectionSession(Session):
self.event("input_bound", status=signed(one(fields, 1, 0)))
else:
self.ignored("input_ignored", channel, kind, data)
elif channel is not None and channel == self.sensor_channel:
if kind == SENSOR_START_RESPONSE:
self.event("sensor_started", status=signed(one(fields, 1, 0)))
elif kind == SENSOR_EVENT:
for number, values in fields.items():
first = number not in self.sensor_events
self.sensor_events[number] = self.sensor_events.get(number, 0) + len(values)
if first:
self.event("sensor_event", field=number, data=describe_fields(fields))
else:
self.ignored("sensor_ignored", channel, kind, data)
elif channel is not None and channel == self.bluetooth_channel:
if kind == BLUETOOTH_PAIRING_RESPONSE:
self.event("bluetooth_pairing_response", already_paired=one(fields, 1), status=signed(one(fields, 2, 0)))
elif kind == BLUETOOTH_AUTH_DATA:
self.event("bluetooth_auth_data", data=describe_fields(fields))
else:
self.ignored("bluetooth_ignored", channel, kind, data)
else:
self.ignored("channel_ignored", channel, kind, data)
@@ -803,12 +923,15 @@ class ProjectionSession(Session):
raise ValueError("A fresh media epoch must start with SPS/PPS and an IDR frame")
assert self.mode is not None
fragments = 0
config = b""
if self.needs_keyframe:
config = codec_config(data)
if config:
fragments += self.send(self.mode.channel, AV_MEDIA_CODEC_CONFIG, config)
if not self.fragment_acks:
self.config_ack_slack += 1
if self.phone_frames:
data = wire_frame(data, drop_config=bool(config))
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)
@@ -839,4 +962,5 @@ class ProjectionSession(Session):
def stats(self) -> dict:
return {"frames_sent": self.frames_sent, "frames_acked": self.acked, "pending": self.unacked,
"focus_epoch": self.focus_epoch, "focused": self.focused, "max_ack_ms": round(self.max_ack_seconds * 1000),
"input_events": self.input_events, "touch": self.touch is not None}
"input_events": self.input_events, "touch": self.touch is not None,
"sensor_events": sum(self.sensor_events.values())}
@@ -16,6 +16,7 @@ on Stop. Nothing here runs as root or touches vehicle control.
from __future__ import annotations
import errno
import ipaddress
import json
import math
@@ -32,7 +33,7 @@ from pathlib import Path
from typing import Any
from openpilot.common.hardware import COMMA_HARDWARE
from openpilot.starpilot.system.android_auto import identity as identity_store
from openpilot.starpilot.system.android_auto import car_profiles, device_health, identity as identity_store, system_slice
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
@@ -59,6 +60,7 @@ ENCODER_RECOVERIES = 3 # hardware encoder reopens allowed per window befor
ENCODER_RECOVERY_WINDOW = 10.0
CAR_LINK_HOLD = 10.0 # a hands-free connection from the car counts as "car present" this long
HFP_FRESH = 3.0 # a hands-free link from the car this recent still means "the car is reaching out now"
NO_ANDROID_AUTO_WAIT = 8.0 # a paired device must offer Android Auto within this long, or we say it does not
USB_HANDSHAKE_WAIT = 10.0 # the car connected but sent no AOA START this long -> present as an accessory directly
USB_CONFIGURE_WAIT = 15.0 # slower receivers need time to enumerate after the AOA switch
LINK_CHECK_INTERVAL = 1.0 # how often the link watcher asks whether the car's Wi-Fi is still up
@@ -93,11 +95,8 @@ def _is_onroad() -> bool:
return False
def wired_blocker() -> str:
"""Why wired USB must not take the comma's USB-C port, or "" when it is free.
Chestnut and Jetlink's computer link both use that port.
"""
def jetlink_blocker() -> str:
"""Why wired USB cannot connect right now: Jetlink's computer link is using the USB-C port. Wired can still be chosen."""
try:
from openpilot.common.params import Params
mode = Params().get("JetlinkMode")
@@ -105,6 +104,11 @@ def wired_blocker() -> str:
return "Turn off Jetlink to use wired Android Auto"
except Exception:
pass
return ""
def chestnut_blocker() -> str:
"""Why wired USB cannot be chosen at all: a Chestnut is plugged into the comma's USB-C port."""
try:
from openpilot.starpilot.models.jetlink_adapter import adapter
if adapter().chestnut_present():
@@ -114,6 +118,11 @@ def wired_blocker() -> str:
return ""
def wired_blocker() -> str:
"""Why wired USB must not take the comma's USB-C port, or "" when it is free."""
return jetlink_blocker() or chestnut_blocker()
class Cancelled(Exception):
pass
@@ -151,6 +160,14 @@ def _wifi_dbm(interface: str = "wlan0") -> int | None:
return None
LOCAL_SOCKET_ERRORS = (errno.ENOSYS, errno.EAFNOSUPPORT, errno.EPROTONOSUPPORT)
def car_never_answered(error: BaseException) -> bool:
"""False when this device's own Bluetooth stack failed, so the car's cached channel says nothing about the car."""
return not (isinstance(error, OSError) and error.errno in LOCAL_SOCKET_ERRORS)
def _where(error: BaseException, depth: int = 4) -> list[str]:
"""The innermost frames of a failure, so a bare exception still says which call raised it."""
frames = traceback.extract_tb(error.__traceback__)[-depth:]
@@ -220,6 +237,8 @@ class EventLog:
self._writer: threading.Thread | None = None
self._writer_stop: threading.Event | None = None
self._dropped = 0
self.path: Path | None = None
self.started = 0.0
def open(self) -> None:
try:
@@ -227,6 +246,7 @@ class EventLog:
logs = sorted(self.directory.glob("session-*.jsonl"), key=identity_store.session_log_order)
for old in logs[:max(0, len(logs) - MAX_LOG_FILES + 1)]:
old.unlink(missing_ok=True)
system_slice.prune_sidecars(self.directory)
number = max([0, *(identity_store.session_log_order(log)[0] for log in logs)]) + 1
path = self.directory / f"session-{number:06d}-{identity_store.timestamp()}.jsonl"
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
@@ -234,6 +254,7 @@ class EventLog:
except OSError:
return
with self.lock:
self.path, self.started = path, datetime.now(UTC).timestamp()
self._queue = queue.Queue(maxsize=LOG_QUEUE_MAX)
self._dropped = 0
self._writer_stop = threading.Event()
@@ -267,6 +288,11 @@ class EventLog:
stopped.set()
writer.join(timeout=2.0)
def capture_system(self) -> None:
"""Save what the device was doing during this session beside its log, off the session's thread."""
if self.path is not None:
system_slice.write_sidecar_async(self.path, self.started, datetime.now(UTC).timestamp())
def _line(self, record: dict) -> str:
return json.dumps(record, default=str) + "\n"
@@ -286,6 +312,8 @@ class EventLog:
class Supervisor:
_pairing_note = "" # why a paired car was not chosen, kept after the pairing window closes
_no_aa_since = 0.0
def __init__(self, bluez_factory=None, lease_factory=None, bluetooth_client=None, frame_path: str | None = None,
synthetic: bool = False, car_frame_path: str = CAR_FRAME_PATH, touch_path: str = DEFAULT_TOUCH_SOCKET,
control_path: str = DEFAULT_CONTROL_SOCKET, renderer_command: list[str] | None = None, onroad=_is_onroad,
@@ -347,9 +375,14 @@ class Supervisor:
status["receiver_name"] = self.config["receiver_name"]
status["companion_address"] = self.config.get("companion_address", "")
status["companion_name"] = self.config.get("companion_name", "")
status["pairing_note"] = self._pairing_note
status["car_type"] = self.car_type()
status.update(car_profiles.summary(self.config))
status["adapter_choice_needed"] = self._adapter_choice_needed()
status["configured_view"] = self.config["view"]
status["connection"] = self.config["connection"]
status["wired_blocked"] = wired_blocker()
status["wired_blocked"] = chestnut_blocker()
status["wired_busy"] = jetlink_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"])
@@ -381,10 +414,15 @@ class Supervisor:
identity_store.save_config(self.config)
self.log("dedicated_wifi_selected", enabled=bool(enabled))
def _adapter_choice_needed(self) -> bool:
return self.car_type() == "adapter" and not self.config.get("car_profile_override")
def start(self, trigger: str = "manual") -> None:
with self._lock:
if not self._projection_enabled():
raise RuntimeError("Android Auto is disabled")
if self._adapter_choice_needed():
raise RuntimeError("Choose your adapter model (AAWireless or Other) in Galaxy first")
if self.config["connection"] == "wired" and (reason := wired_blocker()):
raise RuntimeError(reason)
if self._thread is not None and self._thread.is_alive():
@@ -430,7 +468,7 @@ class Supervisor:
if self._bluez is not None and self.config['receiver_address'] and self.config['receiver_address'] != address:
self._bluez.release()
if self.config["receiver_address"] != address:
self.config.update(companion_address="", companion_name="")
self.config.update(companion_address="", companion_name="", car_fingerprint={})
self.config.update(receiver_address=address, receiver_name=name or address)
identity_store.save_config(self.config)
self._unselect_car_audio(address)
@@ -458,7 +496,7 @@ class Supervisor:
if chosen:
if self._bluez is not None:
self._bluez.release()
self.config.update(receiver_address="", receiver_name="", companion_address="", companion_name="")
self.config.update(receiver_address="", receiver_name="", companion_address="", companion_name="", car_fingerprint={})
elif companion:
self.config.update(companion_address="", companion_name="")
identity_store.save_config(self.config)
@@ -478,7 +516,7 @@ class Supervisor:
"""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()):
if connection == "wired" and (reason := chestnut_blocker()):
raise RuntimeError(reason)
with self._lock:
if self._thread is not None and self._thread.is_alive():
@@ -487,8 +525,55 @@ class Supervisor:
identity_store.save_config(self.config)
self.log("connection_selected", connection=connection)
def car_type(self) -> str:
"""What is in the car, for the setup page: wired and tablet follow the connection, wireless is built-in or an adapter."""
connection = self.config["connection"]
if connection == "wired":
return "usb"
if connection in ("headunitpad", "openheadunit"):
return "app"
return self.config["car_type"] or ("adapter" if self.config.get("companion_address") else "builtin")
def set_car_type(self, car_type: str) -> None:
if car_type not in ("builtin", "adapter", "usb", "app"):
raise RuntimeError(f"Unknown car type {car_type!r}")
previous_wireless_type = self.config["car_type"] or ("adapter" if self.config.get("companion_address") else "builtin")
connection = {"usb": "wired", "app": "headunitpad" if self.config["connection"] not in ("headunitpad", "openheadunit")
else self.config["connection"]}.get(car_type, "wireless")
if connection != self.config["connection"]:
self.set_connection(connection)
if car_type in ("builtin", "adapter"):
with self._lock:
self.config["car_type"] = car_type
if previous_wireless_type != car_type:
if self._bluez is not None and self.config["receiver_address"]:
self._bluez.release()
self.config.update(receiver_address="", receiver_name="", companion_address="", companion_name="", car_fingerprint={})
if car_type != "adapter":
self.config["car_profile_override"] = "" # the adapter model only applies to a wireless adapter
identity_store.save_config(self.config)
self.log("car_type_selected", car_type=car_type)
def set_car_profile(self, profile: str) -> None:
"""Pin a car profile ("default" for standard behavior) or go back to picking it by fingerprint ("")."""
if not car_profiles.valid_override(profile):
raise RuntimeError(f"Unknown car profile {profile!r}")
with self._lock:
self.config["car_profile_override"] = profile
identity_store.save_config(self.config)
self.log("car_profile_selected", profile=profile or "auto")
def _remember_fingerprint(self, head_unit: dict) -> None:
fingerprint = car_profiles.fingerprint_from(head_unit, self.config["receiver_address"])
if fingerprint and fingerprint != self.config.get("car_fingerprint"):
with self._lock:
self.config["car_fingerprint"] = fingerprint
identity_store.save_config(self.config)
def prepare_pairing(self, seconds: float = 180.0) -> None:
"""Present as a phone (HFP gateway, smartphone class) while the car pairs."""
if self._adapter_choice_needed():
raise RuntimeError("Choose your adapter model (AAWireless or Other) in Galaxy first")
if self._bluez_factory is None and (self._source_session is None or self._shared_bluetooth_owner is None or
not self._shared_bluetooth_owner.session_valid(self._source_session)):
raise RuntimeError('Current Galaxy pairing authorization required')
@@ -506,6 +591,7 @@ class Supervisor:
self._pairing_known = known
self._pairing_until = time.monotonic() + seconds
self._pairing_attempt, self._pairing_seen = PairingAttempt(seconds), ()
self._pairing_note, self._no_aa_since = "", 0.0
self.log("pairing_window", seconds=seconds)
def pair_device(self, address: str) -> None:
@@ -533,6 +619,8 @@ class Supervisor:
with self._lock:
attempt, self._pairing_attempt = getattr(self, '_pairing_attempt', None), None
if attempt is not None:
if result in ("interrupted", "cancelled", "expired") and attempt.reached_connection():
values["ended"], result = result, "paired_not_selected"
attempt.finish(result, **values)
def pairing_response(self, prompt_id: str, accepted: bool, value: str = '') -> None:
@@ -715,6 +803,7 @@ class Supervisor:
self._shared_bluetooth_owner.phone_lease is not None and
self._shared_bluetooth_owner.phone_lease.receiver == device["address"])]
if not new:
self._note_missing_android_auto(devices, receiver)
return
with self._lock:
attempt, self._pairing_attempt = getattr(self, '_pairing_attempt', None), None
@@ -729,6 +818,23 @@ class Supervisor:
if attempt is not None:
attempt.finish("select_failed", error=str(error))
def _note_missing_android_auto(self, devices: list, receiver: dict | None) -> None:
"""An approved, paired device that still does not offer wireless Android Auto after a short wait is not a car we can use."""
device = next((item for item in devices if receiver is not None and item["address"] == receiver["address"] and
item["paired"] and not item.get("android_auto")), None)
if device is None:
self._no_aa_since = 0.0
return
now = time.monotonic()
if not self._no_aa_since:
self._no_aa_since = now
elif now - self._no_aa_since >= NO_ANDROID_AUTO_WAIT and not self._pairing_note:
name = device["name"] or "This device"
hint = "Using a wireless adapter? Choose Wireless adapter, pair the adapter here, then connect your car on the Bluetooth page."
self._pairing_note = f"{name} paired, but it doesn't offer wireless Android Auto. {hint}"
self._pairing_step("no_android_auto")
self.log("pairing_no_android_auto", address=device["address"])
def close(self) -> None:
self.stop()
if self._bluez is not None:
@@ -793,6 +899,8 @@ class Supervisor:
owner = self._shared_bluetooth_owner
if owner is None:
return None
if operation == 'forget' and self.config['receiver_address'].upper() == address.upper():
return self._forget_selected_car(owner, address, session)
# select_receiver takes the supervisor lock before releasing an owner lease.
# Keep that same order through connection, save and admission pruning so an
# old adapter's successful action cannot save its car under a new adapter.
@@ -825,6 +933,22 @@ class Supervisor:
identity_store.save_config(self.config)
return result
def _forget_selected_car(self, owner, address: str, session: tuple):
"""Forget the chosen car even while auto-connect is retrying it: stop the attempt, then delete the pairing."""
from openpilot.starpilot.bluetooth.owner import BluetoothRejected
lease = owner.phone_lease
if not owner.session_valid(session):
raise BluetoothRejected('Galaxy session expired', code='session_expired')
if lease is not None and not lease.released and lease.session is not None:
raise BluetoothRejected('Finish Android Auto pairing first', code='busy')
self.auto.manual_stop(self._session_alive())
self.stop()
if self._session_alive():
raise BluetoothRejected('Android Auto is still stopping', code='busy')
result = owner.request('forget', address=address)
self.forget_receiver(address)
return result
def _hfp_connected(self, address: str) -> None:
if address.upper() in (self.config["receiver_address"].upper(), self.config.get("companion_address", "").upper()):
self._car_seen_at = time.monotonic()
@@ -1014,6 +1138,7 @@ class Supervisor:
self.log("bluetooth_release_failed", error=str(error))
self.log("session_stop")
self.log.close()
self.log.capture_system()
self._set(state="idle", detail="", running=False, retry_in=0.0, mode=None)
@staticmethod
@@ -1051,6 +1176,9 @@ class Supervisor:
self.log('companion_car_connected', address=companion)
elif device.get('android_auto') and '0000111e-0000-1000-8000-00805f9b34fb' not in device.get('uuids', []):
raise RuntimeError('Connect your paired car in Galaxy Bluetooth first to select it for this Android Auto adapter')
if delay := car_profiles.tuning(config)["connect_delay_s"]:
self.log("car_profile_connect_delay", seconds=delay)
self._wait(delay)
bluez.connect_device(address)
self._wait(1.0)
@@ -1071,14 +1199,15 @@ class Supervisor:
version_status=int(config.get("version_status", 0)))
self._set(state="wifi_start")
result = boot.run(lambda credentials: lease.acquire(credentials, cancelled=boot.join_is_cancelled), cancelled=self._stop.is_set)
except Exception:
if source == "cache" and not self._stop.is_set() and self._status["last_stage"] in ("rfcomm", "wifi_start"):
except Exception as error:
if source == "cache" and not self._stop.is_set() and self._status["last_stage"] in ("rfcomm", "wifi_start") and car_never_answered(error):
self._remember_channel(address, None) # the car never answered there; ask it over SDP next time
self.log("rfcomm_cache_dropped", channel=channel)
raise
if source == "sdp":
self._remember_channel(address, channel)
self._set(head_unit=result.head_unit)
self._remember_fingerprint(result.head_unit)
bluez.restore_class() # the car has accepted the comma; stop looking like a phone to everything else
keepalive_stop = threading.Event()
threading.Thread(target=boot.keepalive, args=(keepalive_stop,), name="aa_rfcomm_keepalive", daemon=True).start()
@@ -1284,12 +1413,25 @@ class Supervisor:
self._wait(1.0 + attempt * 0.5)
raise RuntimeError(f"Car did not accept the projection connection: {last_error}")
def _adapter_address(self) -> str:
"""This comma's Bluetooth address for the head unit's pairing request; empty when BlueZ is not up."""
try:
return str(self._bluez.adapter()[1].get("Address", "")).upper() if self._bluez is not None else ""
except Exception: # DBus errors vary; the pairing request is optional
return ""
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
tuning = car_profiles.tuning(config)
session.max_window = tuning["max_window"]
session.phone_frames = tuning["phone_frames"]
if tuning["phone_services"]:
session.phone_services = True
session.phone_address = self._adapter_address()
self._stage("authenticating")
session.authenticate()
self._stage("negotiating")
@@ -1466,7 +1608,7 @@ class Supervisor:
"uptime_s": round(now - started), "frames_from_view": source.frames, "wifi_dbm": watch.wifi_dbm}
self._set(stats=stats)
if int(now - started) % 30 == 0:
self.log("stats", **stats)
self.log("stats", **{**device_health.sample(getattr(watch.lease, "interface", "wlan0")), **stats})
encode_peak = 0.0 # the peak covers each logged 30 s window
spent = time.monotonic() - pass_started
if spent > STREAM_STALL_LOG:
@@ -0,0 +1,118 @@
"""Bounded device logs captured beside an Android Auto session log."""
from __future__ import annotations
import json
import os
import re
import threading
from datetime import UTC, datetime
from pathlib import Path
from openpilot.starpilot.system.android_auto import identity as identity_store
from openpilot.starpilot.system.android_auto import system_snapshot
MARGIN_S = 30
DEADLINE_S = 6.0
MAX_LINES = 250 # per source; the newest lines of the window
MAX_LINE = 300
SIDECAR_SUFFIX = ".system.json"
QUOTED = re.compile(r"'[^']*'|\"[^\"]*\"")
SSID = re.compile(r"(?i)\b(e?ssid)\b[=: ]+\S+")
def _timestamp(value: float) -> str:
return datetime.fromtimestamp(value, UTC).strftime("%Y-%m-%d %H:%M:%S")
def commands(start: float, end: float) -> dict[str, list[str]]:
"""Window-limited journals plus point-in-time state; every command runs at the lowest priority."""
journal = ["nice", "-n", "19", "journalctl", "--no-pager", "-q", "-o", "short-iso-precise", "--utc",
"--since", _timestamp(start - MARGIN_S), "--until", _timestamp(end + MARGIN_S), "-n", "2000"]
return {
"network": [*journal, "-u", "NetworkManager", "-u", "wpa_supplicant"],
"bluetooth": [*journal, "-u", "bluetooth"],
"kernel": [*journal, "-k"],
"wifi_link": ["nice", "-n", "19", "nmcli", "-t", "-f", "DEVICE,ACTIVE,CHAN,FREQ,SIGNAL", "device", "wifi", "list", "--rescan", "no"],
"devices": ["nice", "-n", "19", "nmcli", "-t", "-f", "DEVICE,TYPE,STATE", "device"],
"processes": ["nice", "-n", "19", "ps", "-eo", "pid,psr,ni,pcpu,pmem,rss,etime,stat,comm", "--sort=-pcpu"],
}
def scrub_line(line: str, names=()) -> str:
"""One log line without hardware addresses' leading bytes, quoted names, SSIDs or any known car or phone name."""
for name in names:
if name:
line = line.replace(str(name), "<name>")
line = system_snapshot._MAC.sub("<address>", line)
return SSID.sub(lambda match: match[1] + " <ssid>", QUOTED.sub("'<name>'", line))[:MAX_LINE]
def _lines(data: bytes) -> list[str]:
text = system_snapshot.redact(data).decode("utf-8", errors="replace")
return [line for line in text.splitlines() if line.strip()][-MAX_LINES:]
def collect(start: float, end: float, *, collector=system_snapshot.collect) -> dict:
"""The slice for [start, end] (epoch seconds), each source as {"lines": [...]} or {"error": ...}."""
files = collector(start, DEADLINE_S, command_set=commands(start, end))
try:
manifest = json.loads(files.get("manifest.json", b"{}"))["commands"]
except (ValueError, KeyError, TypeError):
manifest = {}
result = {"window": {"start": _timestamp(start), "end": _timestamp(end)}, "sources": {}}
for name in commands(start, end):
entry = manifest.get(name, {})
if name in files:
result["sources"][name] = {"lines": _lines(files[name])}
else:
result["sources"][name] = {"error": entry.get("error") or f"no output (exit {entry.get('exit')})"}
if entry.get("error") and name in files:
result["sources"][name]["error"] = entry["error"]
proc = files.get("proc.txt")
if proc:
result["sources"]["proc"] = {"lines": _lines(proc)}
return result
def sidecar_path(log_path: Path) -> Path:
match = re.fullmatch(r"session-(\d{6})-.*\.jsonl", log_path.name)
return log_path.with_name(f"session-{match[1] if match else log_path.stem}{SIDECAR_SUFFIX}")
def write_sidecar(log_path: Path, start: float, end: float, *, collector=system_snapshot.collect) -> None:
"""Capture the window and save it beside the session log; never raises."""
try:
path = sidecar_path(log_path)
text = json.dumps(collect(start, end, collector=collector))
temporary = path.with_name(path.name + ".tmp")
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w") as handle:
handle.write(text)
os.replace(temporary, path)
except Exception:
pass
def write_sidecar_async(log_path: Path, start: float, end: float) -> threading.Thread:
thread = threading.Thread(target=write_sidecar, args=(log_path, start, end), name="aa_system_slice", daemon=True)
thread.start()
return thread
def read_sidecar(log_path: Path) -> dict:
try:
value = json.loads(sidecar_path(log_path).read_text())
return value if isinstance(value, dict) else {}
except (OSError, ValueError):
return {}
def prune_sidecars(directory: Path | None = None) -> None:
"""Remove sidecars whose session log has been pruned."""
directory = directory or identity_store.LOG_DIR
kept = {identity_store.session_log_order(path)[0] for path in directory.glob("session-*.jsonl")}
for path in directory.glob(f"session-*{SIDECAR_SUFFIX}*"):
match = re.match(r"session-(\d{6})\.", path.name)
if not match or int(match[1]) not in kept:
path.unlink(missing_ok=True)
@@ -63,12 +63,21 @@ def video_config(resolution: int, margin_w: int = 0, margin_h: int = 0) -> bytes
return field(1, resolution) + field(2, 2) + field(3, margin_w) + field(4, margin_h) + field(5, 160)
def discovery_response(video_channel: int = 3, input_channel: int = 1) -> bytes:
SENSOR_CHANNEL, BLUETOOTH_CHANNEL = 7, 8
def discovery_response(video_channel: int = 3, input_channel: int = 1, *, phone_services: bool = False) -> bytes:
"""``phone_services`` adds the car-data and Bluetooth channels (sensor types one per entry, pairing method packed)."""
av = field(1, 3) + field(4, video_config(1)) + field(4, video_config(2, 0, 240))
video = field(1, video_channel) + field(3, av)
audio = field(1, 4) + field(3, field(1, 1))
touch = field(1, input_channel) + field(4, field(1, 84) + field(1, 4) + field(2, field(1, 1280) + field(2, 720)))
return field(1, video) + field(1, audio) + field(1, touch) + field(2, "Honda") + field(3, "Civic")
extra = b""
if phone_services:
sensors = b"".join(field(1, field(1, kind)) for kind in (1, 3, 7, 8, 10, 13, 21))
extra = (field(1, field(1, SENSOR_CHANNEL) + field(2, sensors)) +
field(1, field(1, BLUETOOTH_CHANNEL) + field(6, field(1, "F8:36:9B:0A:7D:C8") + field(2, bytes([2])))))
return field(1, video) + field(1, audio) + field(1, touch) + extra + field(2, "Honda") + field(3, "Civic")
class FakeHeadUnit:
@@ -76,8 +85,13 @@ class FakeHeadUnit:
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,
connect_to: tuple[str, int] | None = None, ack_fragments: bool = False):
connect_to: tuple[str, int] | None = None, ack_fragments: bool = False,
phone_services: bool = False):
self.phone_services = phone_services
self.sensor_requests: list[int] = []
self.pairing_requests: list[tuple[str, int]] = []
self.unsolicited_focus = unsolicited_focus
self.opened: list[int] = []
self.connect_to = connect_to
self.ack_fragments = ack_fragments
self.fragments = 0
@@ -218,7 +232,7 @@ class FakeHeadUnit:
fields = parse_fields(data) if kind not in (0, 1) else {}
if channel == 0 and kind == 5:
self.device_name = one(fields, 4, b"").decode()
self._send(0, 6, discovery_response())
self._send(0, 6, discovery_response(phone_services=self.phone_services))
elif channel == 0 and kind == 11:
self._send(0, 12, data)
elif channel == 0 and kind == 15:
@@ -226,7 +240,17 @@ class FakeHeadUnit:
self.shutdown_received.set()
return
elif kind == 7:
self.opened.append(channel)
self._send(channel, 8, field(1, 0), control=True)
elif self.phone_services and channel == SENSOR_CHANNEL and kind == 0x8001:
self.sensor_requests.append(int(one(fields, 1, 0) or 0))
self._send(SENSOR_CHANNEL, 0x8002, field(1, 0))
if one(fields, 1) == 13:
self._send(SENSOR_CHANNEL, 0x8003, field(13, field(1, 0))) # driving status: unrestricted
elif self.phone_services and channel == BLUETOOTH_CHANNEL and kind == 0x8001:
address = one(fields, 1, b"")
self.pairing_requests.append((address.decode() if isinstance(address, bytes) else "", int(one(fields, 2, 0) or 0)))
self._send(BLUETOOTH_CHANNEL, 0x8002, field(1, 1) + field(2, 0)) # not paired
elif channel == 3 and kind == 0x8000:
self._send(3, 0x8003, field(1, 2) + field(2, self.window) + field(3, 1))
if self.unsolicited_focus: # like the DHU: grant focus before the phone asks
@@ -12,8 +12,10 @@ import pytest
from openpilot.starpilot.system.android_auto import bootstrap as bs
from openpilot.starpilot.system.android_auto import hfp, sdp
from openpilot.starpilot.system.android_auto.frame_source import FrameConsumer, FrameProducer, FrameRequest, fit_content
from openpilot.starpilot.system.android_auto.session import (AuthenticationRejected, ProjectionSession, VideoMode, choose_video_mode)
from openpilot.starpilot.system.android_auto.tests.fake_head_unit import FakeHeadUnit, make_identity, rfcomm_head_unit
from openpilot.starpilot.system.android_auto.session import (AuthenticationRejected, ProjectionSession, START_CODE, VideoMode, choose_video_mode,
wire_frame)
from openpilot.starpilot.system.android_auto.tests.fake_head_unit import (BLUETOOTH_CHANNEL, SENSOR_CHANNEL, FakeHeadUnit, make_identity,
rfcomm_head_unit)
from openpilot.starpilot.system.android_auto.wire import field, one, parse_fields, signed
@@ -569,6 +571,86 @@ def test_session_sends_codec_config_before_each_epoch(identity, ack_codec_config
assert hu.codec_configs == [(1, sps_pps, 0), (2, sps_pps, 4)] # one per epoch, before its keyframe
def test_wire_frame_drops_aud_and_optionally_config():
aud, sps, pps, idr = b"\x00\x00\x00\x01\x09\xf0", b"\x00\x00\x00\x01\x67\x42", b"\x00\x00\x00\x01\x68\xce", b"\x00\x00\x00\x01\x65\x88"
assert wire_frame(aud + sps + pps + idr, drop_config=True) == idr
assert wire_frame(aud + sps + pps + idr, drop_config=False) == sps + pps + idr
assert wire_frame(b"junk", drop_config=True) == b"junk"
def test_session_sends_picture_units_only_when_asked(identity):
hu = FakeHeadUnit(identity)
session = connect(hu, identity)
session.phone_frames = True
session.authenticate()
session.start("StarPilot", "comma.ai")
pump_until(session, lambda: session.focused)
session.send_frame(b"\x00\x00\x00\x01\x09\xf0\x00\x00\x00\x01\x67\x42\x00\x00\x00\x01\x68\xce\x00\x00\x00\x01\x65\x88", 1, keyframe=True)
pump_until(session, lambda: session.unacked == 0)
session.shutdown()
session.peer.close()
hu.thread.join(5)
assert hu.error is None, hu.error
for _, frame in hu.frames:
assert frame and all(frame[m.end()] & 31 not in (7, 8, 9) for m in START_CODE.finditer(frame))
@pytest.mark.parametrize("max_window, offered, used", [(2, 30, 2), (30, 30, 30), (30, 100, 30), (30, 4, 4)])
def test_session_streams_at_the_window_the_profile_allows(identity, max_window, offered, used):
hu = FakeHeadUnit(identity, window=offered)
session = connect(hu, identity)
session.max_window = max_window
session.authenticate()
session.start("StarPilot", "comma.ai")
assert session.window == used
session.shutdown()
session.peer.close()
hu.thread.join(5)
def test_session_opens_car_data_and_bluetooth_like_a_phone(identity):
hu = FakeHeadUnit(identity, phone_services=True)
session = connect(hu, identity)
session.phone_services = True
session.phone_address = "39:90:21:64:07:00"
session.authenticate()
session.start("StarPilot", "comma.ai")
pump_until(session, lambda: session.focused and session.sensor_events)
session.shutdown()
session.peer.close()
hu.thread.join(5)
assert hu.error is None
assert {SENSOR_CHANNEL, BLUETOOTH_CHANNEL} <= set(hu.opened)
assert hu.sensor_requests == [13, 10] # driving status and night only; never location
assert hu.pairing_requests == [("39:90:21:64:07:00", 2)]
assert session.stats()["sensor_events"] == 1
def test_session_leaves_car_data_alone_by_default(identity):
hu = FakeHeadUnit(identity, phone_services=True)
session = connect(hu, identity)
session.authenticate()
session.start("StarPilot", "comma.ai")
pump_until(session, lambda: session.focused)
session.shutdown()
session.peer.close()
hu.thread.join(5)
assert hu.error is None and SENSOR_CHANNEL not in hu.opened and BLUETOOTH_CHANNEL not in hu.opened and hu.pairing_requests == []
def test_session_projects_without_a_bluetooth_address(identity):
hu = FakeHeadUnit(identity, phone_services=True)
session = connect(hu, identity)
session.phone_services = True
session.authenticate()
session.start("StarPilot", "comma.ai")
pump_until(session, lambda: session.focused)
session.shutdown()
session.peer.close()
hu.thread.join(5)
assert hu.error is None and BLUETOOTH_CHANNEL in hu.opened and hu.pairing_requests == []
def test_session_keeps_unsolicited_focus_grant(identity):
hu = FakeHeadUnit(identity, unsolicited_focus=True)
session = connect(hu, identity)
@@ -1156,3 +1238,48 @@ def test_supervisor_retries_wifi_recovery_while_idle(monkeypatch):
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
def test_jetlink_delays_wired_but_does_not_stop_choosing_it(identity, tmp_path, monkeypatch):
from openpilot.starpilot.system.android_auto import supervisor as supervisor_module
sup, _ = make_supervisor(identity, tmp_path, monkeypatch, lambda *a, **k: None)
monkeypatch.setattr(supervisor_module, "jetlink_blocker", lambda: "Turn off Jetlink to use wired Android Auto")
monkeypatch.setattr(supervisor_module, "chestnut_blocker", lambda: "")
sup.set_connection("wired")
status = sup.status()
assert sup.config["connection"] == "wired" and status["wired_blocked"] == "" and "Jetlink" in status["wired_busy"]
with pytest.raises(RuntimeError, match="Turn off Jetlink"):
sup.start()
def test_chestnut_still_stops_choosing_wired(identity, tmp_path, monkeypatch):
from openpilot.starpilot.system.android_auto import supervisor as supervisor_module
sup, _ = make_supervisor(identity, tmp_path, monkeypatch, lambda *a, **k: None)
monkeypatch.setattr(supervisor_module, "jetlink_blocker", lambda: "")
monkeypatch.setattr(supervisor_module, "chestnut_blocker", lambda: "Wired Android Auto is unavailable while Chestnut is connected")
with pytest.raises(RuntimeError, match="Chestnut"):
sup.set_connection("wired")
def test_discovery_waits_past_the_message_timeout_for_a_slow_adapter():
from openpilot.starpilot.system.android_auto.session import Session
a, b = socket.socketpair()
session = Session.__new__(Session)
session.peer = a
session.authenticated = False
session.handshake_timeout = 0.1
session.fragments = {}
session.bytes_received = 0
def send_slowly():
time.sleep(0.2)
b.sendall(b"\x00\x03\x00\x02")
time.sleep(0.2)
b.sendall(b"\x00\x06")
sender = threading.Thread(target=send_slowly)
sender.start()
try:
assert session.wait_for(0, 6, timeout=1.0, patient=True) == b""
finally:
sender.join()
a.close()
b.close()
@@ -24,12 +24,21 @@ class TestBluetoothSocketFallback(unittest.TestCase):
def test_unimplemented_connect_uses_same_protocol_and_timeout(self):
with self.capabilities(), patch.object(bt_sockets, '_native', side_effect=OSError(errno.ENOSYS, 'Function not implemented')), \
patch.object(bt_sockets, '_libc_connect') as libc:
patch.object(bt_sockets.time, 'sleep'), patch.object(bt_sockets, '_libc_connect') as libc:
for connect, port in self.routes():
self.assertIs(connect(self.ADDRESS.lower(), port, 4), libc.return_value)
self.assertEqual(libc.call_args_list[0].args, (3, socket.SOCK_STREAM, self.ADDRESS, 1, 4))
self.assertEqual(libc.call_args_list[1].args, (0, socket.SOCK_SEQPACKET, self.ADDRESS, 1, 4))
def test_unimplemented_connect_retries_natively_before_falling_back(self):
ok = Mock()
with self.capabilities(), patch.object(bt_sockets, '_native', side_effect=[OSError(errno.ENOSYS, 'x'), ok]) as native, \
patch.object(bt_sockets.time, 'sleep') as sleep, patch.object(bt_sockets, '_libc_connect') as libc:
self.assertIs(bt_sockets.connect_rfcomm(self.ADDRESS, 1), ok)
self.assertEqual(native.call_count, 2)
sleep.assert_called_once_with(bt_sockets.ENOSYS_RETRY_S)
libc.assert_not_called()
def test_real_connection_failures_do_not_retry(self):
for code in (errno.EHOSTDOWN, errno.ECONNREFUSED, errno.ETIMEDOUT, errno.EPERM, errno.EAFNOSUPPORT):
with self.subTest(errno=code), self.capabilities(), \
@@ -43,19 +52,21 @@ class TestBluetoothSocketFallback(unittest.TestCase):
def test_fallback_failure_propagates_without_loop(self):
with self.capabilities(), patch.object(bt_sockets, '_native', side_effect=OSError(errno.ENOSYS, 'unimplemented')) as native, \
patch.object(bt_sockets.time, 'sleep'), \
patch.object(bt_sockets, '_libc_connect', side_effect=OSError(errno.EHOSTDOWN, 'host down')) as libc:
with self.assertRaises(OSError) as caught:
bt_sockets.connect_rfcomm(self.ADDRESS, 1)
self.assertEqual(caught.exception.errno, errno.EHOSTDOWN)
native.assert_called_once()
self.assertEqual(native.call_count, bt_sockets.ENOSYS_RETRIES + 1)
libc.assert_called_once()
def test_native_failure_closes_original_descriptor(self):
original = Mock()
original.connect.side_effect = OSError(errno.ENOSYS, 'unimplemented')
with self.capabilities(), patch.object(socket, 'socket', return_value=original), patch.object(bt_sockets, '_libc_connect') as libc:
with self.capabilities(), patch.object(socket, 'socket', return_value=original), patch.object(bt_sockets.time, 'sleep'), \
patch.object(bt_sockets, '_libc_connect') as libc:
self.assertIs(bt_sockets.connect_rfcomm(self.ADDRESS, 1), libc.return_value)
original.close.assert_called_once()
self.assertEqual(original.close.call_count, bt_sockets.ENOSYS_RETRIES + 1)
def test_missing_python_capability_uses_libc(self):
with patch.object(bt_sockets, 'socket') as python_socket, patch.object(bt_sockets, '_libc_connect') as libc:
@@ -312,6 +312,9 @@ def test_supervisor_records_build_at_session_start(monkeypatch):
def close(self):
pass
def capture_system(self):
pass
def __call__(self, name, **values):
records.append({"event": name, **values})
@@ -325,3 +328,115 @@ def test_supervisor_records_build_at_session_start(monkeypatch):
sup._release_phone = lambda: None # ty: ignore[invalid-assignment] # test double
sup._run(1)
assert records[0]["software"] == {"commit": "session-build", "branch": "session-branch"}
HANDSHAKE_FAILED = [
(0, 0, "session_start", {"receiver": "Honda HFL", "trigger": "auto"}),
(0, 1, "stage", {"state": "rfcomm"}),
(0, 2, "bootstrap_tx", {"message": "WifiVersionRequest", "bytes": 4}),
(0, 3, "hfp_command", {"command": "AT+CIND"}),
(0, 3, "nav_guidance_sent", {}),
(0, 4, "bootstrap_rx", {"message": "WifiVersionResponse", "bytes": 6}),
(0, 5, "sdp_retry", {"error": "[Errno 112] Host is down to AA:BB:CC:DD:EE:FF", "settle": 1.5}),
(0, 25, "attempt_failed", {"stage": "wifi_start", "error": "head unit did not answer in time", "kind": "BootstrapTimeout"}),
]
def test_failed_sessions_carry_a_scrubbed_handshake_trace(tmp_path):
event = br.drive_event([br.session_record(write_session(tmp_path, 1, HANDSHAKE_FAILED))])
trace = event["contexts"]["android_auto_trace_session_1"]
names = [item["event"] for item in trace["events"]]
assert names[:3] == ["session_start", "stage", "bootstrap_tx"] and "bootstrap_rx" in names and "sdp_retry" in names
assert "hfp_command" not in names and "nav_guidance_sent" not in names
assert trace["omitted"] == 0 and trace["events"][2]["at_s"] == 2.0
text = json.dumps(event)
assert "AA:BB:CC:DD:EE:FF" not in text and "Honda HFL" not in text
def test_trace_keeps_the_newest_events_within_its_budget(tmp_path):
noisy = [(0, 0, "session_start", {})] + [(i // 60, i % 60, "sdp_retry", {"error": "x" * 250, "n": i}) for i in range(1, 400)]
noisy.append((9, 0, "attempt_failed", {"stage": "discovering", "error": "gone", "kind": "RuntimeError"}))
event = br.drive_event([br.session_record(write_session(tmp_path, 1, noisy))])
trace = event["contexts"]["android_auto_trace_session_1"]
assert trace["omitted"] > 0 and [e["event"] for e in trace["events"]][-2:] == ["attempt_failed", "session_stop"]
assert len(json.dumps(trace)) <= br.TRACE_SESSION_BYTES + 1024
assert len(json.dumps(event)) < 400 << 10
CLEAN = [(0, 0, "session_start", {"receiver": "Honda HFL", "trigger": "auto"}),
(0, 11, "projection_ready", {"mode": {"width": 1280, "height": 720, "fps": 30, "margin_width": 0, "margin_height": 0}}),
(0, 11, "frame_pipeline", {"nv12": True, "target_fps": 30, "encoder": "v4l2"}),
(0, 12, "stage", {"state": "streaming"}),
(0, 12, "video_acknowledged", {}),
(0, 42, "stats", {"fps": 30.0, "wifi_dbm": -50, "focused": True, "frames_sent": 900, "load1": 1.5, "temp_max_c": 61.0}),
(1, 12, "stats", {"fps": 29.0, "wifi_dbm": -52, "focused": True, "frames_sent": 1800, "load1": 1.7, "temp_max_c": 63.0})]
def test_clean_drives_send_no_trace(tmp_path):
event = br.drive_event([br.session_record(write_session(tmp_path, 1, CLEAN))])
assert not any(key.startswith("android_auto_trace_session_") for key in event["contexts"])
assert "aa.attention" not in event["tags"]
def test_session_carries_stats_series_and_routine_event_counts(tmp_path):
events = [*CLEAN, (1, 20, "nav_guidance_sent", {}), (1, 21, "nav_guidance_sent", {}), (1, 22, "media_key", {})]
record = br.session_record(write_session(tmp_path, 1, events))
assert record["series"]["at_s"] == [42.0, 72.0] and record["series"]["load1"] == [1.5, 1.7]
assert record["series"]["temp_max_c"] == [61.0, 63.0] and "cpu0_mhz" not in record["series"]
assert record["quiet_events"] == {"nav_guidance_sent": 2, "media_key": 1}
def test_sessions_that_streamed_poorly_get_a_trace(tmp_path):
event = br.drive_event([br.session_record(write_session(tmp_path, 1, STREAMED))])
assert "android_auto_trace_session_1" in event["contexts"]
assert {"encoder_recoveries", "unfocused"} <= set(event["tags"]["aa.attention"].split(","))
assert event["contexts"]["android_auto_trace_session_1"]["routine_events"] == {}
def test_sessions_that_never_streamed_get_a_trace(tmp_path):
stopped = [(0, 0, "session_start", {"receiver": "Honda HFL"}), (0, 5, "stage", {"state": "negotiating_video"})]
event = br.drive_event([br.session_record(write_session(tmp_path, 1, stopped))])
assert "android_auto_trace_session_1" in event["contexts"] and event["tags"]["aa.attention"] == "stopped"
def sidecar(tmp_path, number, lines):
path = tmp_path / f"session-{number:06d}.system.json"
path.write_text(json.dumps({"window": {"start": "a", "end": "b"}, "sources": {"network": {"lines": lines}, "kernel": {"error": "timed out"}}}))
def test_device_slice_is_attached_scrubbed_for_sessions_that_need_attention(tmp_path):
path = write_session(tmp_path, 1, HANDSHAKE_FAILED)
sidecar(tmp_path, 1, ["wpa: Trying to associate with SSID 'HomeNet' bssid aa:bb:cc:dd:ee:ff", "wpa: ssid=Secret1 Honda HFL gone"])
(tmp_path / "car_ui.log").write_text("ui line Honda HFL\n" * 3)
event = br.drive_event([br.session_record(path)])
system = event["contexts"]["android_auto_system_session_1"]
assert system["kernel"] == {"error": "timed out"} and len(system["network"]["lines"]) == 2
text = json.dumps(event)
assert "HomeNet" not in text and "Secret1" not in text and "Honda HFL" not in text and "AA:BB:CC" not in text.upper()
assert len(event["contexts"]["android_auto_car_ui"]["lines"]) == 3
def test_clean_sessions_do_not_attach_a_device_slice(tmp_path):
path = write_session(tmp_path, 1, CLEAN)
sidecar(tmp_path, 1, ["line"])
assert not any(key.startswith("android_auto_system") for key in br.drive_event([br.session_record(path)])["contexts"])
def test_oversized_reports_shed_detail_and_still_fit(tmp_path):
path = write_session(tmp_path, 1, HANDSHAKE_FAILED)
big = br.drive_event([br.session_record(path)])
big["contexts"]["android_auto_system_session_1"] = {"network": {"lines": ["y" * 290] * 3000}}
fitted = br.fit_event(big)
assert br.event_size(fitted) <= br.EVENT_BUDGET
assert fitted["tags"]["aa.truncated"] == "yes" and "android_auto_system_session_1" in fitted["contexts"]["android_auto_shed"]["dropped"]
assert "android_auto_drive" in fitted["contexts"]
def test_pairing_that_failed_carries_a_device_slice_but_success_does_not(tmp_path):
record = {"seq": 1, "started": "2026-10-16T12:00:00+00:00", "window_s": 60, "duration_s": 30, "steps": [], "result": "timeout"}
calls = []
collector = lambda start, end: calls.append((start, end)) or {"window": {}, "sources": {"bluetooth": {"lines": ["bt 'Phone X' up"]}}} # noqa: E731
event = br.pairing_event(record, collector)
assert event["contexts"]["android_auto_pairing_system"]["bluetooth"]["lines"] == ["bt '<name>' up"]
assert calls[0][1] - calls[0][0] == 30
assert "android_auto_pairing_system" not in br.pairing_event({**record, "result": "paired"}, collector)["contexts"]
@@ -0,0 +1,16 @@
import errno
import unittest
from openpilot.starpilot.system.android_auto.bootstrap import BootstrapTimeout
from openpilot.starpilot.system.android_auto.supervisor import car_never_answered
class TestCarNeverAnswered(unittest.TestCase):
def test_local_bluetooth_failures_keep_the_cached_channel(self):
for code in (errno.ENOSYS, errno.EAFNOSUPPORT, errno.EPROTONOSUPPORT):
self.assertFalse(car_never_answered(OSError(code, 'local')))
def test_car_side_failures_drop_the_cached_channel(self):
for error in (OSError(errno.ECONNREFUSED, 'refused'), OSError(errno.EHOSTDOWN, 'down'), TimeoutError(),
BootstrapTimeout('wifi_start', 'head unit did not answer in time')):
self.assertTrue(car_never_answered(error))
@@ -0,0 +1,223 @@
"""Car profiles are picked by fingerprint; cars without a rule keep the standard behavior."""
import json
import threading
from unittest.mock import Mock
import pytest
from openpilot.starpilot.system.android_auto import bugsink_report as br, car_profiles, daemon, identity as identity_store
from openpilot.starpilot.system.android_auto.supervisor import Supervisor
from openpilot.starpilot.system.android_auto.tests.fake_head_unit import make_identity
from openpilot.starpilot.system.android_auto.tests.test_android_auto import make_supervisor
ADAPTER = "AA:BB:CC:DD:EE:01"
PALISADE = {"id": "palisade", "label": "Hyundai Palisade", "match": {"car_make": "^hyundai$", "car_model": "palisade"},
"tuning": {"connect_delay_s": 10.0, "unknown_key": 1}}
@pytest.fixture(scope="module")
def identity(tmp_path_factory):
return make_identity(tmp_path_factory.mktemp("identity"))
@pytest.fixture
def sup(identity, tmp_path, monkeypatch):
sup, _ = make_supervisor(identity, tmp_path, monkeypatch, lambda *a, **k: None)
return sup
@pytest.fixture
def palisade(monkeypatch):
monkeypatch.setattr(car_profiles, "RULES", [PALISADE])
def config(**values):
return {"receiver_address": ADAPTER, "car_profile_override": "", "car_fingerprint": {}, **values}
def test_aawireless_tuning_applies_only_when_chosen():
chosen = config(car_profile_override="aawireless", receiver_name="AAWireless-4F2A")
assert car_profiles.resolve(chosen) == ("aawireless", "override")
assert car_profiles.tuning(chosen) == {"connect_delay_s": 0.0, "max_window": 30, "phone_services": True, "phone_frames": True}
assert car_profiles.summary(chosen)["car_profile_label"] == "AAWireless"
for other in (config(receiver_name="AAWireless-4F2A"), config(car_profile_override="default")):
assert car_profiles.tuning(other) == car_profiles.DEFAULTS
def test_leaving_the_adapter_setup_clears_the_adapter_model(sup):
sup.config["car_type"] = "adapter"
sup.config.update(receiver_address=ADAPTER, receiver_name="AAWireless", companion_address="car",
companion_name="Car", car_fingerprint={"address": ADAPTER})
sup.set_car_profile("aawireless")
sup.set_car_type("builtin")
assert sup.config["car_profile_override"] == ""
assert not sup.config["receiver_address"] and not sup.config["companion_address"] and not sup.config["car_fingerprint"]
def test_an_adapter_setup_must_choose_its_model_before_pairing_or_starting(sup):
sup.config["car_type"] = "adapter"
assert sup.status()["adapter_choice_needed"] is True
with pytest.raises(RuntimeError, match="adapter model"):
sup.prepare_pairing()
with pytest.raises(RuntimeError, match="adapter model"):
sup.start()
sup.set_car_profile("default")
assert sup.status()["adapter_choice_needed"] is False
sup.config["car_type"] = "builtin"
sup.config["car_profile_override"] = ""
assert sup.status()["adapter_choice_needed"] is False
def test_every_car_is_standard_without_rules():
assert car_profiles.resolve(config(car_fingerprint={"address": ADAPTER, "car_make": "Hyundai"})) == ("default", "standard")
assert car_profiles.tuning(config()) == car_profiles.DEFAULTS
assert car_profiles.summary(config())["car_profile_label"] == ""
def test_fingerprint_picks_a_profile_only_for_the_chosen_receiver(palisade):
fingerprint = {"address": ADAPTER, "car_make": "Hyundai", "car_model": "Palisade"}
assert car_profiles.resolve(config(car_fingerprint=fingerprint)) == ("palisade", "detected")
assert car_profiles.resolve(config(car_fingerprint={**fingerprint, "address": "AA:BB:CC:DD:EE:09"})) == ("default", "standard")
assert car_profiles.resolve(config(car_fingerprint={"address": ADAPTER, "car_make": "Hyundai", "car_model": "Sonata"})) == ("default", "standard")
def test_tuning_keeps_only_known_keys(palisade):
tuned = car_profiles.tuning(config(car_profile_override="palisade"))
assert tuned == {**car_profiles.DEFAULTS, "connect_delay_s": 10.0}
def test_override_pins_a_profile_or_forces_standard(palisade):
fingerprint = {"address": ADAPTER, "car_make": "Hyundai", "car_model": "Palisade"}
assert car_profiles.resolve(config(car_profile_override="default", car_fingerprint=fingerprint)) == ("default", "override")
assert car_profiles.resolve(config(car_profile_override="palisade")) == ("palisade", "override")
assert car_profiles.resolve(config(car_profile_override="gone")) == ("default", "standard")
assert car_profiles.valid_override("") and car_profiles.valid_override("default") and car_profiles.valid_override("palisade")
assert not car_profiles.valid_override("gone")
def test_old_config_files_load_with_standard_defaults(tmp_path):
path = tmp_path / "config.json"
path.write_text(json.dumps({"config_version": 6, "connection": "wired", "car_type": "bogus"}))
loaded = identity_store.load_config(path)
assert loaded["car_type"] == "" and loaded["car_profile_override"] == "" and loaded["car_fingerprint"] == {}
assert loaded["connection"] == "wired"
def test_car_type_follows_the_connection_and_companion(sup):
assert sup.status()["car_type"] == "builtin"
sup.config.update(companion_address="car")
assert sup.car_type() == "adapter"
sup.config.update(car_type="builtin")
assert sup.car_type() == "builtin"
sup.config.update(connection="headunitpad")
assert sup.car_type() == "app"
def test_choosing_a_car_type_sets_the_connection(sup):
sup.set_car_type("adapter")
assert sup.car_type() == "adapter" and sup.config["connection"] == "wireless"
sup.set_car_type("app")
assert sup.config["connection"] == "headunitpad" and sup.car_type() == "app"
sup.set_car_type("builtin")
assert sup.config["connection"] == "wireless" and sup.car_type() == "builtin"
assert json.loads(identity_store.CONFIG_PATH.read_text())["car_type"] == "builtin"
with pytest.raises(RuntimeError):
sup.set_car_type("hovercraft")
def test_profile_changes_are_pinned_and_validated(sup, palisade):
sup.set_car_profile("palisade")
status = sup.status()
assert status["car_profile"] == "palisade" and status["car_profile_source"] == "override" and status["car_profile_label"] == "Hyundai Palisade"
sup.set_car_profile("")
assert sup.status()["car_profile_source"] == "standard"
with pytest.raises(RuntimeError):
sup.set_car_profile("nope")
def test_setup_commands_need_a_signed_in_galaxy_source():
assert {"set_car_type", "set_car_profile"} <= daemon.AUTHENTICATED_COMMANDS
def test_head_unit_fingerprint_is_remembered_and_dropped_with_the_car(sup):
sup.config["receiver_address"] = ADAPTER
sup._remember_fingerprint({"car_make": "Hyundai", "car_model": "Palisade", "head_unit_make": "MOBIS", "ignored": "x"})
stored = json.loads(identity_store.CONFIG_PATH.read_text())["car_fingerprint"]
assert stored == {"address": ADAPTER, "head_unit_make": "MOBIS", "car_make": "Hyundai", "car_model": "Palisade"}
sup._remember_fingerprint({})
assert sup.config["car_fingerprint"] == stored
def make_notifier():
sup = Supervisor.__new__(Supervisor)
sup._lock = threading.RLock()
sup._pairing_step = Mock()
sup.log = Mock()
return sup
def test_a_paired_device_without_android_auto_is_explained_after_a_wait(monkeypatch):
sup = make_notifier()
receiver = {"address": ADAPTER, "name": "Civic"}
devices = [{"address": ADAPTER, "name": "Civic", "paired": True, "android_auto": False}]
clock = iter([100.0, 103.0, 109.0])
monkeypatch.setattr("time.monotonic", lambda: next(clock))
for _ in range(2):
sup._note_missing_android_auto(devices, receiver)
assert sup._pairing_note == ""
sup._note_missing_android_auto(devices, receiver)
assert "doesn't offer wireless Android Auto" in sup._pairing_note and "Wireless adapter" in sup._pairing_note
sup._pairing_step.assert_called_once_with("no_android_auto")
def test_a_device_that_offers_android_auto_never_gets_a_note():
sup = make_notifier()
sup._no_aa_since = 5.0
sup._note_missing_android_auto([{"address": ADAPTER, "name": "Car", "paired": True, "android_auto": True}], {"address": ADAPTER})
assert sup._pairing_note == "" and sup._no_aa_since == 0.0
@pytest.mark.parametrize("end", ["interrupted", "cancelled", "expired"])
def test_a_pairing_that_connected_is_not_reported_as_interrupted(end):
records = []
attempt = br.PairingAttempt(180, sink=records.append)
attempt.step("status", state="connected", prompt=False, approved=True)
sup = make_notifier()
sup._pairing_attempt = attempt
sup._end_pairing(end, reason="Bluetooth lease released")
assert records[0]["result"] == "paired_not_selected" and records[0]["steps"][-1]["ended"] == end
def test_a_pairing_that_never_connected_keeps_its_result():
records = []
attempt = br.PairingAttempt(180, sink=records.append)
attempt.step("status", state="idle", prompt=False, approved=False)
sup = make_notifier()
sup._pairing_attempt = attempt
sup._end_pairing("interrupted", reason="Galaxy session ended")
assert records[0]["result"] == "interrupted"
def test_the_new_result_has_a_bugsink_report():
attempt = br.PairingAttempt(180, sink=lambda record: None)
attempt.step("status", state="connected", prompt=False, approved=True)
event = br.pairing_event({**attempt.record, "result": "paired_not_selected", "duration_s": 5.0})
assert event["fingerprint"] == ["android-auto-pairing", "paired_not_selected"] and event["level"] == "warning"
@pytest.mark.parametrize("stored, kept", [({"car_type": "adapter"}, True), ({"car_type": "", "companion_address": "AA:BB:CC:DD:EE:02"}, True),
({"car_type": "builtin", "companion_address": "AA:BB:CC:DD:EE:02"}, False), ({}, False),
({"car_type": "adapter", "car_profile_override": "aawireless"}, True)])
def test_adapter_owners_from_before_the_model_choice_keep_connecting(tmp_path, stored, kept):
path = tmp_path / "config.json"
path.write_text(json.dumps({"config_version": 6, **stored}))
config = identity_store.load_config(path)
assert (config["car_profile_override"] != "") is kept
assert config["car_profile_override"] == stored.get("car_profile_override", "default" if kept else "")
def test_a_new_adapter_setup_is_not_migrated(tmp_path):
path = tmp_path / "config.json"
path.write_text(json.dumps({"config_version": identity_store.CONFIG_VERSION, "car_type": "adapter"}))
assert identity_store.load_config(path)["car_profile_override"] == ""
@@ -278,6 +278,41 @@ class CompanionTests(unittest.TestCase):
save.assert_not_called()
self.assertEqual(host.config['companion_address'], '')
def test_forgetting_the_selected_car_stops_its_retry_then_deletes_it(self):
from openpilot.starpilot.system.android_auto.supervisor import Supervisor
host = Supervisor.__new__(Supervisor)
host._lock = threading.RLock()
host.config = {'receiver_address': ADAPTER}
host._shared_bluetooth_owner = self.owner
host.auto = Mock()
alive = {'value': True}
host._session_alive = lambda: alive['value']
host.stop = Mock(side_effect=lambda: alive.update(value=False))
host.forget_receiver = Mock(return_value=True)
self.owner.request = Mock(return_value={'devices': []}) # test double
self.assertEqual(host.companion_bluetooth_action('forget', ADAPTER.lower(), SESSION), {'devices': []})
host.auto.manual_stop.assert_called_once_with(True)
host.stop.assert_called_once_with()
self.owner.request.assert_called_once_with('forget', address=ADAPTER.lower())
host.forget_receiver.assert_called_once_with(ADAPTER.lower())
def test_forgetting_the_selected_car_waits_out_pairing_and_expired_sessions(self):
from openpilot.starpilot.system.android_auto.supervisor import Supervisor
host = Supervisor.__new__(Supervisor)
host.config = {'receiver_address': ADAPTER}
host._shared_bluetooth_owner = self.owner
host.stop = Mock()
self.lease.session = SESSION
with self.assertRaises(Rejected) as error:
host.companion_bluetooth_action('forget', ADAPTER, SESSION)
self.assertEqual(error.exception.code, 'busy')
self.lease.session = None
self.state['valid'] = False
with self.assertRaises(Rejected) as error:
host.companion_bluetooth_action('forget', ADAPTER, SESSION)
self.assertEqual(error.exception.code, 'session_expired')
host.stop.assert_not_called()
def daemon_call(self, operation='connect', address=CAR, valid=True, parked=True):
supervisor = SimpleNamespace(companion_bluetooth_action=self.owner.companion_action,
bind_source_session=lambda session: self.fail('Companion command must not rebind projection source'))
@@ -0,0 +1,14 @@
from openpilot.starpilot.system.android_auto import device_health
def test_wireless_link_reads_quality_signal_and_noise(monkeypatch):
text = "Inter-| sta-| Quality |\n face | tus | link level noise |\n wlan0: 0000 58. -52. -256. 0\n"
monkeypatch.setattr(device_health, "_read", lambda path: text if path == "/proc/net/wireless" else None)
assert device_health.wireless_link() == {"wifi_link": 58, "wifi_dbm": -52, "wifi_noise": -256}
assert device_health.wireless_link("wlan1") == {}
def test_sample_leaves_out_what_the_device_does_not_have(monkeypatch):
monkeypatch.setattr(device_health, "_read", lambda path: "0.75 0.5 0.4 1/200 99\n" if path == "/proc/loadavg" else None)
monkeypatch.setattr(device_health.glob, "glob", lambda pattern: [])
assert device_health.sample() == {"load1": 0.75}
@@ -0,0 +1,56 @@
"""The per-session device slice: bounded line lists from a collector, saved beside the session log."""
import json
from openpilot.starpilot.system.android_auto import system_slice
def fake_collector(since, deadline, *, command_set):
names = list(command_set)
files = {"network": b"line one aa:bb:cc:dd:ee:ff\nline two\n", "proc.txt": b"==== /proc/loadavg\n0.5\n"}
manifest = {name: {"command": "x", "exit": 0} for name in names}
manifest["kernel"]["error"] = "timed out after 6 s"
files["manifest.json"] = json.dumps({"commands": manifest}).encode()
return files
def test_collect_turns_command_output_into_line_lists():
result = system_slice.collect(1_000_000.0, 1_000_060.0, collector=fake_collector)
sources = result["sources"]
assert sources["network"]["lines"] == ["line one xx:xx:xx:xx:xx:ff", "line two"]
assert sources["kernel"]["error"].startswith("timed out") and "lines" not in sources["kernel"]
assert "error" in sources["bluetooth"] and sources["proc"]["lines"][0].startswith("====")
def test_commands_are_niced_and_cover_the_session_window():
command = system_slice.commands(1_000_000.0, 1_000_060.0)["network"]
assert command[:3] == ["nice", "-n", "19"] and "--utc" in command
assert command[command.index("--since") + 1] == "1970-01-12 13:46:10" and "NetworkManager" in command
def test_scrub_line_masks_names_ssids_and_addresses_and_caps_length():
line = system_slice.scrub_line("assoc ssid=Home bssid aa:bb:cc:dd:ee:ff 'My Car' Danny " + "z" * 500, names=["Danny"])
assert "Home" not in line and "aa:bb" not in line and "My Car" not in line and "Danny" not in line and len(line) <= 300
def test_sidecar_round_trip_and_pruning(tmp_path):
log = tmp_path / "session-000007-20261016-120000.jsonl"
log.write_text("")
system_slice.write_sidecar(log, 1_000_000.0, 1_000_060.0, collector=fake_collector)
assert system_slice.sidecar_path(log).name == "session-000007.system.json"
assert system_slice.read_sidecar(log)["sources"]["network"]["lines"][1] == "line two"
stale = tmp_path / "session-000002.system.json"
stale.write_text("{}")
system_slice.prune_sidecars(tmp_path)
assert not stale.exists() and system_slice.sidecar_path(log).exists()
def test_a_collector_that_raises_leaves_no_sidecar(tmp_path):
log = tmp_path / "session-000001-20261016-120000.jsonl"
log.write_text("")
def broken(*_args, **_kwargs):
raise RuntimeError("boom")
system_slice.write_sidecar(log, 0.0, 1.0, collector=broken)
assert system_slice.read_sidecar(log) == {}