diff --git a/openpilot/starpilot/galaxy/server.py b/openpilot/starpilot/galaxy/server.py index 68e174f2f6..c8baed2459 100644 --- a/openpilot/starpilot/galaxy/server.py +++ b/openpilot/starpilot/galaxy/server.py @@ -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'}) diff --git a/openpilot/starpilot/galaxy/test_android_auto_http.py b/openpilot/starpilot/galaxy/test_android_auto_http.py index a32896d7b1..94be2511e9 100644 --- a/openpilot/starpilot/galaxy/test_android_auto_http.py +++ b/openpilot/starpilot/galaxy/test_android_auto_http.py @@ -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 diff --git a/openpilot/starpilot/galaxy/tests/browser/android-auto-setup.cjs b/openpilot/starpilot/galaxy/tests/browser/android-auto-setup.cjs new file mode 100644 index 0000000000..a0eccd882a --- /dev/null +++ b/openpilot/starpilot/galaxy/tests/browser/android-auto-setup.cjs @@ -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)}); diff --git a/openpilot/starpilot/galaxy/tests/test_android_auto.mjs b/openpilot/starpilot/galaxy/tests/test_android_auto.mjs index 6a44cac104..77df038725 100644 --- a/openpilot/starpilot/galaxy/tests/test_android_auto.mjs +++ b/openpilot/starpilot/galaxy/tests/test_android_auto.mjs @@ -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') diff --git a/openpilot/starpilot/galaxy/tests/test_android_auto_outgoing.mjs b/openpilot/starpilot/galaxy/tests/test_android_auto_outgoing.mjs index 3243552e13..2e3bc226e3 100644 --- a/openpilot/starpilot/galaxy/tests/test_android_auto_outgoing.mjs +++ b/openpilot/starpilot/galaxy/tests/test_android_auto_outgoing.mjs @@ -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); }); diff --git a/openpilot/starpilot/galaxy/tests/test_android_auto_progress.mjs b/openpilot/starpilot/galaxy/tests/test_android_auto_progress.mjs new file mode 100644 index 0000000000..13e69979d3 --- /dev/null +++ b/openpilot/starpilot/galaxy/tests/test_android_auto_progress.mjs @@ -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')) +}) diff --git a/openpilot/starpilot/galaxy/tests/test_bluetooth_notices.mjs b/openpilot/starpilot/galaxy/tests/test_bluetooth_notices.mjs index 39585156d5..391e1161a6 100644 --- a/openpilot/starpilot/galaxy/tests/test_bluetooth_notices.mjs +++ b/openpilot/starpilot/galaxy/tests/test_bluetooth_notices.mjs @@ -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() === '