mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-10-28 00:53:45 +08:00
Merge pull request #252 from dirwin31:AA-Wireless-Setup
Android Auto: improve wireless adapter setup and pairing
This commit is contained in:
@@ -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)); }
|
||||
|
||||
@@ -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 & 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) == {}
|
||||
Reference in New Issue
Block a user