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/test_android_auto.mjs b/openpilot/starpilot/galaxy/tests/test_android_auto.mjs index 6a44cac104..07ab61ed40 100644 --- a/openpilot/starpilot/galaxy/tests/test_android_auto.mjs +++ b/openpilot/starpilot/galaxy/tests/test_android_auto.mjs @@ -473,3 +473,33 @@ 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(AndroidAutoPage.computed.carTypeRow.call(typed).choices), new Set(['Built-in wireless', 'Wireless adapter', 'USB cable', 'Tablet app'])) +// A tuned car profile only shows up when one is detected or pinned. +const profiled = page(setup) +assert.equal(profiled.showProfile, false, 'standard cars see nothing') +profiled.runtime = { car_profile_label: 'Hyundai Palisade', car_profile_source: 'detected' } +assert.equal(profiled.showProfile, true) +profiled.runtime = { car_profile_label: '', car_profile_source: 'override' } +assert.equal(profiled.showProfile, true, 'a pinned standard profile can be undone') +assert.match(AndroidAutoPage.template, /What’s in your car\?|carTypeRow/) +assert.match(AndroidAutoPage.template, /runtime\?\.pairing_note/) +assert.match(AndroidAutoPage.template, /'Find ' \+ findLabel/) +console.log('Android Auto page: setup type, adapter checklist and car profile line passed') diff --git a/openpilot/starpilot/galaxy/web/js/android-auto.js b/openpilot/starpilot/galaxy/web/js/android-auto.js index 5cb9df075f..35c71e1533 100644 --- a/openpilot/starpilot/galaxy/web/js/android-auto.js +++ b/openpilot/starpilot/galaxy/web/js/android-auto.js @@ -66,6 +66,9 @@ const APP_SETUP = `
  • Tap Connect here, then tap the WiFi button in Open Headunit.
  • ` +export const CAR_TYPES = { + builtin: "Built-in wireless", adapter: "Wireless adapter", usb: "USB cable", app: "Tablet app" +} const PROMPT = new Set(["pin", "passkey", "confirmation", "authorization", "display_pin", "display_passkey"]) export function validPromptInput(prompt, value) { @@ -283,7 +286,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 }) @@ -496,6 +499,15 @@ export const AndroidAutoPage = { 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" }, + carTypeRow() { + return { label: "What’s in your car?", value: CAR_TYPES[this.carType], choices: Object.values(CAR_TYPES), available: true, action: true, + reason: "Built-in: the car supports wireless Android Auto. Adapter: a dongle such as AAWireless, Motorola MA1 or Carlinkit. Both the adapter and your car get paired." } + }, + showProfile() { return !!(this.runtime?.car_profile_label || this.runtime?.car_profile_source === "override") }, appDisabled() { return !this.setup?.enabled || !this.setup.parked || !!this.pairing?.active || !!this.runtime?.running }, @@ -580,6 +592,10 @@ export const AndroidAutoPage = { }, methods: { switchRow, + saveCarType(index, value) { + const carType = Object.keys(CAR_TYPES).find(key => CAR_TYPES[key] === value) + return carType ? this.control("car_type", { carType }) : false + }, saveApp(index, value) { const connection = Object.keys(HEAD_UNIT_APPS).find(key => HEAD_UNIT_APPS[key] === value) || "wireless" return this.control("connection", { connection }) @@ -763,9 +779,32 @@ export const AndroidAutoPage = {
  • -

    Pair Your Car

    Open your car’s phone pairing screen, then tap Find car. You can do this before step 1 is finished.

    +

    {{ adapterSetup ? 'Pair Your Adapter and Car' : 'Pair Your Car' }}

    {{ adapterSetup ? 'An adapter needs two pairings: the adapter here, and your car on the Bluetooth page.' : 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.' }}

    + +

    + {{ runtime.car_profile_source === 'override' ? 'Using standard settings for this car.' : 'Tuned for ' + runtime.car_profile_label + ' (detected).' }} +

    +

    Plug the car’s data USB port into the comma’s USB-C port, then turn on Wired USB Connection in step 3.

    +

    Pick your tablet app in step 3 and follow its setup steps.

    +
  • diff --git a/openpilot/starpilot/galaxy/web/js/bluetooth.js b/openpilot/starpilot/galaxy/web/js/bluetooth.js index 94904608fd..dfe912545f 100644 --- a/openpilot/starpilot/galaxy/web/js/bluetooth.js +++ b/openpilot/starpilot/galaxy/web/js/bluetooth.js @@ -217,6 +217,7 @@ export const BluetoothPage = {