From e11ce3071dc96ce33b0322dc6f3cbbe33cb39edf Mon Sep 17 00:00:00 2001 From: Danny Date: Thu, 22 Oct 2026 15:37:20 -0700 Subject: [PATCH] Galaxy: Add companion, Spotify and phone-aware web controls Add companion installation and updates, Spotify setup and playback, destination sharing, and Android Auto controls. Adapt Galaxy pages to phone transports and device type, improve Bluetooth pairing feedback, and cover the new web flows. --- .../starpilot/galaxy/tests/test_bluetooth.mjs | 18 + .../galaxy/tests/test_bluetooth_notices.mjs | 113 +++ .../galaxy/tests/test_bluetooth_stability.mjs | 8 + .../starpilot/galaxy/tests/test_companion.mjs | 29 + .../galaxy/tests/test_companion_update.mjs | 130 ++++ .../galaxy/tests/test_home_polish.mjs | 3 + .../galaxy/tests/test_install_app.mjs | 54 +- .../galaxy/tests/test_navigation.mjs | 8 +- .../galaxy/tests/test_projection_layout.mjs | 18 +- .../galaxy/tests/test_share_destination.mjs | 11 + .../starpilot/galaxy/tests/test_spotify.mjs | 118 +++ openpilot/starpilot/galaxy/web/css/galaxy.css | 27 +- .../starpilot/galaxy/web/css/navigation.css | 4 + .../starpilot/galaxy/web/data/catalog.json | 8 + .../starpilot/galaxy/web/js/android-auto.js | 63 +- openpilot/starpilot/galaxy/web/js/app.js | 4 +- .../galaxy/web/js/bluetooth-notice.js | 16 + .../starpilot/galaxy/web/js/bluetooth.js | 79 +- openpilot/starpilot/galaxy/web/js/cameras.js | 10 +- .../starpilot/galaxy/web/js/companion.js | 99 +++ openpilot/starpilot/galaxy/web/js/galaxy.js | 47 +- openpilot/starpilot/galaxy/web/js/hotspot.js | 6 +- .../starpilot/galaxy/web/js/install-app.js | 40 +- openpilot/starpilot/galaxy/web/js/logs.js | 12 +- .../starpilot/galaxy/web/js/navigation.js | 80 +- .../galaxy/web/js/offline-road-maps.js | 10 +- .../galaxy/web/js/onroad-layout-document.js | 7 +- .../galaxy/web/js/onroad-layout-template.js | 2 + .../starpilot/galaxy/web/js/onroad-layout.js | 41 +- openpilot/starpilot/galaxy/web/js/pip.js | 12 +- .../starpilot/galaxy/web/js/record-history.js | 16 +- openpilot/starpilot/galaxy/web/js/replay.js | 14 +- openpilot/starpilot/galaxy/web/js/sentry.js | 8 +- .../galaxy/web/js/share-destination.js | 21 + openpilot/starpilot/galaxy/web/js/spotify.js | 725 ++++++++++++++++++ openpilot/starpilot/galaxy/web/js/startup.js | 1 + openpilot/starpilot/galaxy/web/js/vasm.js | 12 +- 37 files changed, 1779 insertions(+), 95 deletions(-) create mode 100644 openpilot/starpilot/galaxy/tests/test_bluetooth_notices.mjs create mode 100644 openpilot/starpilot/galaxy/tests/test_companion.mjs create mode 100644 openpilot/starpilot/galaxy/tests/test_companion_update.mjs create mode 100644 openpilot/starpilot/galaxy/tests/test_share_destination.mjs create mode 100644 openpilot/starpilot/galaxy/tests/test_spotify.mjs create mode 100644 openpilot/starpilot/galaxy/web/js/bluetooth-notice.js create mode 100644 openpilot/starpilot/galaxy/web/js/companion.js create mode 100644 openpilot/starpilot/galaxy/web/js/share-destination.js create mode 100644 openpilot/starpilot/galaxy/web/js/spotify.js diff --git a/openpilot/starpilot/galaxy/tests/test_bluetooth.mjs b/openpilot/starpilot/galaxy/tests/test_bluetooth.mjs index 254fb1bc91..396152686b 100644 --- a/openpilot/starpilot/galaxy/tests/test_bluetooth.mjs +++ b/openpilot/starpilot/galaxy/tests/test_bluetooth.mjs @@ -18,6 +18,13 @@ assert.equal(validPairInput({ kind: "passkey" }, "123456"), true) assert.equal(validPairInput({ kind: "passkey" }, "1234567"), false) assert.equal(validPairInput({ kind: "confirmation" }, ""), true) assert.match(BluetoothPage.template, /!canAllowPair/) +// A phone running the StarPilot app is connected but not bonded: it lists under App Pairings, not Saved or Nearby devices. +const appPhone = { address: "11:22:33:44:55:66", name: "iPhone", paired: false, connected: true, trusted: false } +const listed = { status: { ...status, devices: [...status.devices, appPhone] } } +assert.deepEqual(BluetoothPage.computed.saved.call(listed).map((device) => device.name), ["Speaker"]) +assert.deepEqual(BluetoothPage.computed.appLinks.call(listed).map((device) => device.name), ["iPhone"]) +assert.deepEqual(BluetoothPage.computed.nearby.call(listed), []) +assert.match(BluetoothPage.template, /App Pairings/) const calls = [], states = [], timers = [] const feed = new BluetoothFeed({ publish: (state) => states.push(state), @@ -93,3 +100,14 @@ for (const [code, words] of [["busy", /another operation/], ["park_required", /P errorFeed.stop() } assert.equal(validBluetoothStatus({ ...status, errorCode: "service_unavailable" }), true) + +// A window opened for a phone has no address until the first phone asks, and only phone windows may omit it. +assert.equal(validBluetoothStatus({ ...status, adapterAddress: "AA:BB:CC:DD:EE:FF", adapterName: "comma", + pairing: { address: "", state: "pairing", prompt: null, incoming: true, name: "" } }), true) +assert.equal(validBluetoothStatus({ ...status, + pairing: { address: "11:22:33:44:55:66", state: "pairing", prompt, incoming: true, name: "Pixel" } }), true) +assert.equal(validBluetoothStatus({ ...status, pairing: { address: "", state: "pairing", prompt: null } }), false) +assert.equal(validBluetoothStatus({ ...status, pairing: { address: "not-an-address", state: "pairing", prompt: null, incoming: true, name: "" } }), false) +assert.equal(validBluetoothStatus({ ...status, adapterName: 5 }), false) +assert.match(BluetoothPage.template, /Pair phone/) +assert.match(BluetoothPage.template, /action\('accept_pair'\)/) diff --git a/openpilot/starpilot/galaxy/tests/test_bluetooth_notices.mjs b/openpilot/starpilot/galaxy/tests/test_bluetooth_notices.mjs new file mode 100644 index 0000000000..39585156d5 --- /dev/null +++ b/openpilot/starpilot/galaxy/tests/test_bluetooth_notices.mjs @@ -0,0 +1,113 @@ +import assert from 'node:assert/strict' +import { createRenderer, nextTick } from '../web/vendor/vue/vue.esm-browser.js' +import { BluetoothNotice, bluetoothConnection } from '../web/js/bluetooth-notice.js' +import { LocalHistoryFeed, LocalRecordingsPage } from '../web/js/record-history.js' +import { CamerasPage, CameraSnapshotFeed, LiveCameraPreview } from '../web/js/cameras.js' +import { PipPage } from '../web/js/pip.js' +import { VasmPage } from '../web/js/vasm.js' +import { NavigationPage } from '../web/js/navigation.js' +import { OfflineRoadMapsPanel } from '../web/js/offline-road-maps.js' +import { AndroidAutoPage } from '../web/js/android-auto.js' +import { GalaxyCompanion } from '../web/js/companion.js' + +globalThis.window = {} +assert.equal(bluetoothConnection(), false) +let connections = 0, requests = 0, timers = 0 +window.StarPilotApp = { bluetooth: () => true, connectionOptions: () => connections++ } +assert.equal(bluetoothConnection(), true) +globalThis.document = { hidden: false, activeElement: null, addEventListener() {}, removeEventListener() {} } +globalThis.fetch = () => { requests++; throw new Error('Blocked feature attempted a request') } + +const hostNode = (type, text = '') => ({ type, text, props: {}, children: [], parent: null, addEventListener() {}, removeEventListener() {} }) +const renderer = createRenderer({ + createElement: type => hostNode(type), createText: text => hostNode('text', text), createComment: text => hostNode('comment', text), + setText: (item, text) => { item.text = text }, setElementText: (item, text) => { item.text = text; item.children = [] }, + patchProp: (item, key, before, after) => { item.props[key] = after }, + insert: (item, parent, anchor = null) => { + if (item.parent) item.parent.children.splice(item.parent.children.indexOf(item), 1) + item.parent = parent + const index = parent.children.indexOf(anchor) + parent.children.splice(index < 0 ? parent.children.length : index, 0, item) + }, + remove: item => { const index = item.parent?.children.indexOf(item); if (index >= 0) item.parent.children.splice(index, 1) }, + parentNode: item => item.parent, nextSibling: item => item.parent?.children[item.parent.children.indexOf(item) + 1] ?? null, +}) +const all = item => [item, ...item.children.flatMap(all)] +const text = item => [item.text, ...item.children.map(text)].join(' ') +function mount(component, props = {}, overrides = {}) { + const host = hostNode('host') + const app = renderer.createApp({ ...component, ...overrides }, { mode: 'local', unauthorized() {}, go() {}, ...props }) + app.config.compilerOptions.decodeEntities = raw => raw.replaceAll('&', '&') + const vm = app.mount(host) + return { host, vm, unmount: () => app.unmount() } +} +function notice(host, feature) { + const notices = all(host).filter(item => item.props.class?.includes('gx-alert--bluetooth')) + const found = notices.find(item => text(item).includes(feature)) + assert.ok(found, feature) + assert.equal(found.props.role, 'status') + assert.ok(found.props.class.includes('gx-alert--info')) + assert.match(text(found), /Not available over Bluetooth.*Wi-Fi or LAN/s) + assert.ok(!all(host).some(item => item.props.class?.includes('gx-alert--danger')), 'Expected limitation is never a red error') + return found +} + +for (const [component, feature] of [[LocalRecordingsPage, 'Local recordings and replay'], [CamerasPage, 'Camera snapshots']]) { + const view = mount(component) + try { + const info = notice(view.host, feature) + all(info).find(item => item.type === 'button').props.onClick() + assert.ok(!all(view.host).some(item => item.type === 'img' || item.type === 'video')) + } finally { view.unmount() } +} +assert.equal(connections, 2) +assert.equal(requests, 0) + +const history = new LocalHistoryFeed({ publish() {}, later: () => { timers++; return 1 }, cancelTimer() {} }) +history.start() +assert.equal(history.active, false) +const camera = new CameraSnapshotFeed({ publish() {}, later: () => { timers++; return 1 }, cancelTimer() {} }) +await camera.capture('cabin') +const preview = new LiveCameraPreview({ publish() {}, redraw() {}, enabled: () => true, + later: () => { timers++; return 1 }, cancelTimer() {} }) +preview.start(); preview.refresh(); preview.stop() +assert.equal(timers, 0) +assert.equal(requests, 0, 'Neither initial loads nor retries send unsupported recording/camera requests') + +// Render the real editor templates without starting their supported settings feeds. +const presentation = { created() {}, mounted() {}, updated() {}, beforeUnmount() {} } +for (const component of [PipPage, VasmPage]) { + const view = mount(component, {}, presentation) + try { + notice(view.host, 'Cabin camera preview') + const refresh = all(view.host).find(item => item.type === 'button' && text(item).includes('Take new snapshot')) + assert.equal(refresh.props.disabled, true) + assert.match(text(view.host), /Saved settings remain available/) + } finally { view.unmount() } +} + +let mapMounts = 0 +const navigation = mount(NavigationPage, {}, { ...presentation, + components: { ...NavigationPage.components, NavigationMap: { mounted() { mapMounts++ }, template: '' } } }) +try { + notice(navigation.host, 'Map preview') + assert.equal(mapMounts, 0) + assert.doesNotMatch(text(navigation.host), /Set up navigation to show the map/) + navigation.vm.error = 'Server rejected the destination' + await nextTick() + assert.ok(all(navigation.host).some(item => item.props.class?.includes('gx-alert--danger')), 'Real navigation errors stay red') +} finally { navigation.unmount() } + +OfflineRoadMapsPanel.methods.ensureMap.call({ bluetooth: true, $refs: { canvas: {} }, hasKey: true }) +let uploaded = 0 +const aa = { bluetooth: true, packageFile: {}, busy: false, feed: { upload() { uploaded++ } } } +aa.uploadReason = AndroidAutoPage.computed.uploadReason.call(aa) +assert.match(aa.uploadReason, /not available over Bluetooth/) +await AndroidAutoPage.methods.upload.call(aa) +assert.equal(uploaded, 0) +await GalaxyCompanion.methods.toggle.call({ bluetooth: true }) +assert.equal(requests, 0) + +window.StarPilotApp.bluetooth = () => false +assert.equal(bluetoothConnection(), false) +console.log('Bluetooth limits: blue notices, connection action, no blocked requests, supported settings and real errors verified') diff --git a/openpilot/starpilot/galaxy/tests/test_bluetooth_stability.mjs b/openpilot/starpilot/galaxy/tests/test_bluetooth_stability.mjs index d924b50f60..02c8583a11 100644 --- a/openpilot/starpilot/galaxy/tests/test_bluetooth_stability.mjs +++ b/openpilot/starpilot/galaxy/tests/test_bluetooth_stability.mjs @@ -115,3 +115,11 @@ test('timed-out background polling preserves status and resumes even when fetch assert.equal(feed.status.devices[0].address, car.address); assert(states.every((s) => s.busy === false)); feed.stop(); }); + +test('hiding the page keeps a phone pairing window open so the phone can find the comma', async () => { + const calls = []; + const {feed} = harness(async (url, options) => {calls.push(options); return response(status([car], + {discovering: false, pairing: {address: '', state: 'pairing', prompt: null, incoming: true, name: ''}}))}); + await feed.start(); feed.stop(true); + assert(!calls.some((options) => options?.body && JSON.parse(options.body).operation === 'cancel_pair')); +}); diff --git a/openpilot/starpilot/galaxy/tests/test_companion.mjs b/openpilot/starpilot/galaxy/tests/test_companion.mjs new file mode 100644 index 0000000000..5348b8d223 --- /dev/null +++ b/openpilot/starpilot/galaxy/tests/test_companion.mjs @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict' +import { compile } from '../web/vendor/vue/vue.esm-browser.js' +import { GalaxyCompanion, validCompanion } from '../web/js/companion.js' +globalThis.window = {} +const value = { protocol: 1, enabled: false, connected: false, active: false, appAvailable: true, updateAvailable: false, + bytes: 0, shippedVersion: '1.0.0', state: 'disabled' } +assert.equal(validCompanion(value), true) +assert.equal(validCompanion({ ...value, bytes: -1 }), false) +compile(GalaxyCompanion.template, { decodeEntities: v => v }) +const vm = { ...GalaxyCompanion.data(), mode: 'sample', unauthorized() {}, $emit() {} } +for (const [key, fn] of Object.entries(GalaxyCompanion.methods)) vm[key] = fn.bind(vm) +let requests = 0, resolve +globalThis.fetch = () => { requests++; return new Promise(done => { resolve = done }) } +vm.start() +assert.equal(requests, 0) +vm.mode = 'local'; vm.alive = true +const reading = vm.refresh() +vm.stop() +resolve({ ok: true, json: async () => value }) +await reading +assert.equal(vm.status, null) +let auth = 0 +vm.unauthorized = () => auth++ +globalThis.fetch = async () => ({ ok: false, status: 401, json: async () => ({ error: 'Sign in' }) }) +vm.alive = true +await vm.refresh() +assert.equal(auth, 1) +assert.equal(vm.alive, false) +console.log('Companion UI: schema, template, preview exclusion, late response and authentication passed') diff --git a/openpilot/starpilot/galaxy/tests/test_companion_update.mjs b/openpilot/starpilot/galaxy/tests/test_companion_update.mjs new file mode 100644 index 0000000000..4bd1dc3947 --- /dev/null +++ b/openpilot/starpilot/galaxy/tests/test_companion_update.mjs @@ -0,0 +1,130 @@ +import assert from 'node:assert/strict' +import { createRenderer, nextTick } from '../web/vendor/vue/vue.esm-browser.js' +import { GalaxyPage } from '../web/js/galaxy.js' +import { newerCompanionVersion } from '../web/js/companion.js' + +assert.equal(newerCompanionVersion('1.1.5', '1.1.4'), true) +assert.equal(newerCompanionVersion('1.1.10', '1.1.9'), true) +assert.equal(newerCompanionVersion('1.2', '1.1.99'), true) +for (const [available, installed] of [['1.1.5', '1.1.5'], ['1.1.4', '1.1.5'], ['1.1', '1.1.0'], + ['', '1.1.4'], ['1.1.5', ''], ['unknown', '1.1.4'], ['1.1.5', null]]) { + assert.equal(newerCompanionVersion(available, installed), false) +} + +const storage = new Map() +globalThis.localStorage = { getItem: key => storage.get(key) ?? null, setItem: (key, value) => storage.set(key, value) } +globalThis.window = { StarPilotApp: { version: () => '1.1.4', uplinkState: () => '{}' } } +globalThis.document = { activeElement: null } +const status = { protocol: 1, enabled: false, connected: false, active: false, appAvailable: true, updateAvailable: false, + bytes: 0, shippedVersion: '1.1.5', version: null, state: 'disabled' } +globalThis.fetch = async url => ({ ok: true, json: async () => url.includes('/api/companion/status') ? status : { paired: false } }) + +const node = (type, text = '') => ({ type, text, props: {}, children: [], parent: null, + addEventListener() {}, removeEventListener() {} }) +const renderer = createRenderer({ + createElement: type => node(type), createText: text => node('text', text), createComment: text => node('comment', text), + setText: (item, text) => { item.text = text }, setElementText: (item, text) => { item.text = text; item.children = [] }, + patchProp: (item, key, before, after) => { item.props[key] = after }, + insert: (item, parent, anchor = null) => { + if (item.parent) item.parent.children.splice(item.parent.children.indexOf(item), 1) + item.parent = parent + const index = parent.children.indexOf(anchor) + parent.children.splice(index < 0 ? parent.children.length : index, 0, item) + }, + remove: item => { const index = item.parent?.children.indexOf(item); if (index >= 0) item.parent.children.splice(index, 1) }, + parentNode: item => item.parent, + nextSibling: item => item.parent?.children[item.parent.children.indexOf(item) + 1] ?? null, +}) +const all = item => [item, ...item.children.flatMap(all)] +const text = item => [item.text, ...item.children.map(text)].join(' ') +const banner = host => all(host).find(item => item.type === 'div' && text(item).includes('Android app update available') && item.props.role === 'status') +async function mount() { + const host = node('host') + const app = renderer.createApp({ ...GalaxyPage, + // The real companion child still polls and emits metadata to the real page. + components: { ...GalaxyPage.components, GalaxyHotspot: { template: '
' }, InstallApp: { props: ['updateAvailable'], template: '
{{ updateAvailable }}
' } }, + }, { mode: 'local', unauthorized() { assert.fail('Unexpected authentication failure') } }) + app.config.compilerOptions.decodeEntities = raw => raw.replaceAll('&', '&') + const vm = app.mount(host) + await new Promise(resolve => setImmediate(resolve)) + await nextTick() + return { host, app, vm } +} + +let mounted = await mount() +try { + const { host, vm } = mounted + const notice = banner(host) + assert.ok(notice, 'A native app without an internet-sharing lease still gets an update notice') + assert.match(text(notice), /Version 1.1.5 is available.*Installed: 1.1.4/s) + assert.ok(all(notice).some(item => item.type === 'a' && item.props.href === './app.apk' && text(item).includes('Update app'))) + assert.ok(all(host).some(item => item.type === 'div' && item.props.class === 'install-state' && text(item).includes('true')), + 'The main install action is told to render as an update') + const root = host.children.find(item => item.type === 'div') + assert.ok(root.children.indexOf(notice) < root.children.findIndex(item => item.type === 'ol'), 'Notice precedes the setup steps') + const install = all(host).find(item => item.type === 'li' && text(item).includes('Install Galaxy')) + assert.match(text(install), /Android offline app version:.*1.1.5.*Installed.*1.1.4/s) + const dismiss = all(notice).find(item => item.type === 'button' && item.props['aria-label'] === 'Dismiss app update notice') + dismiss.props.onClick() + await nextTick() + assert.equal(banner(host), undefined) + vm.companionStatus = { ...status } + await nextTick() + assert.equal(banner(host), undefined, 'Polling does not undo dismissal') +} finally { mounted.app.unmount() } + +let nativeInstalls = 0 +window.StarPilotApp.installUpdate = () => nativeInstalls++ +mounted = await mount() +try { + assert.equal(banner(mounted.host), undefined, 'Dismissal survives leaving and returning to the page') + mounted.vm.companionStatus = { ...status, shippedVersion: '1.1.6' } + await nextTick() + const nativeNotice = banner(mounted.host) + assert.ok(nativeNotice, 'A later available release gets a new notice') + const nativeUpdate = all(nativeNotice).find(item => item.type === 'button' && text(item).includes('Update app')) + assert.ok(nativeUpdate, 'A current native app uses the in-app installer') + nativeUpdate.props.onClick() + assert.equal(nativeInstalls, 1) + mounted.vm.companionStatus = { ...status, shippedVersion: '1.1.3', updateAvailable: true } + await nextTick() + assert.equal(banner(mounted.host), undefined, 'A server mismatch flag cannot suggest downgrading this phone') + mounted.vm.companionStatus = { ...status, shippedVersion: '1.1.6', appAvailable: false } + await nextTick() + assert.equal(banner(mounted.host), undefined, 'Missing APK never advertises a dead update link') + mounted.vm.companionStatus = { ...status, shippedVersion: '1.1.6' } + localStorage.setItem = () => { throw new Error('Storage unavailable') } + mounted.vm.dismissAppUpdate() + await nextTick() + assert.equal(banner(mounted.host), undefined, 'Dismiss still works when persistent storage is unavailable') +} finally { mounted.app.unmount() } + +delete window.StarPilotApp +storage.clear() +mounted = await mount() +try { + assert.equal(banner(mounted.host), undefined, 'A browser cannot infer an installed version without a connected phone') + const install = all(mounted.host).find(item => item.type === 'li' && text(item).includes('Install Galaxy')) + assert.match(text(install), /Android offline app version:.*1.1.5/s) + assert.doesNotMatch(text(install), /Installed:/) +} finally { mounted.app.unmount() } + +// iPhone users never mount or poll the Android companion section. +Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { userAgent: 'iPhone' } }) +let companionMounts = 0 +const iosHost = node('host') +const iosApp = renderer.createApp({ ...GalaxyPage, components: { ...GalaxyPage.components, + GalaxyHotspot: { template: '
' }, InstallApp: { template: '
Install as web app
' }, + GalaxyCompanion: { mounted() { companionMounts++ }, template: '
Android companion
' }, +} }, { mode: 'local', unauthorized() { assert.fail('Unexpected authentication failure') } }) +iosApp.config.compilerOptions.decodeEntities = raw => raw.replaceAll('&', '&') +const iosVm = iosApp.mount(iosHost) +await new Promise(resolve => setImmediate(resolve)) +await nextTick() +assert.equal(iosVm.ios, true) +assert.equal(companionMounts, 0) +assert.doesNotMatch(text(iosHost), /Android companion/) +assert.match(text(iosHost), /Install as web app/) +iosApp.unmount() + +console.log('Companion install version, in-app update, browser fallback, version ordering and persistent dismissal passed') diff --git a/openpilot/starpilot/galaxy/tests/test_home_polish.mjs b/openpilot/starpilot/galaxy/tests/test_home_polish.mjs index 11eb6543e0..d3799bb4fd 100644 --- a/openpilot/starpilot/galaxy/tests/test_home_polish.mjs +++ b/openpilot/starpilot/galaxy/tests/test_home_polish.mjs @@ -13,7 +13,10 @@ assert.ok(Home.template.includes('aria-hidden="true">Galaxy Access & Install')) assert.ok(!GalaxyPage.template.includes('Galaxy & App Install')) assert.ok(GalaxyPage.template.includes('Your remote link needs Internet and an active tunnel.')) +assert.ok(GalaxyPage.template.includes('Download Android APK')) +assert.ok(GalaxyPage.template.includes('Android offline app version:')) assert.ok(GalaxyPage.template.includes('Remote pairing') < GalaxyPage.template.indexOf(' prevented++, prompt: async () => prompted++, userChoice: Promise.resolve({ outcome: 'dismissed' }) }) assert.equal(prevented, 1) -await InstallApp.methods.install.call(state) +await InstallApp.methods.installWeb.call(state) assert.equal(prompted, 1) assert.equal(state.prompt, null) assert.equal(state.installed, false) -await InstallApp.methods.install.call(state) +await InstallApp.methods.installWeb.call(state) assert.equal(state.message, '') assert.match(InstallApp.template, /v-else-if="url" class="gx-btn" :href="url"/) window.location = { hostname: 'galaxy.firestar.link' } @@ -38,6 +40,48 @@ rememberInstallPrompt(early) const late = {...InstallApp.data()} InstallApp.mounted.call(late) assert.equal(late.prompt, early) -await InstallApp.methods.install.call(late) +await InstallApp.methods.installWeb.call(late) assert.equal(prompted, 2) InstallApp.beforeUnmount.call(late) + +// Android offers the local APK; tunnel pages explain how to connect locally. +navigator.userAgent = 'Android' +let apk +globalThis.location = { protocol: 'http:', assign: value => { apk = value } } +let nativeUpdates = 0 +window.StarPilotApp = { installUpdate: () => nativeUpdates++ } +const nativeUpdate = { ...InstallApp.data(), updateAvailable: true } +InstallApp.methods.installAndroid.call(nativeUpdate) +assert.equal(nativeUpdates, 1) +assert.equal(apk, undefined, 'Native updates never hand the APK to a browser') +delete window.StarPilotApp +const android = { ...InstallApp.data() } +InstallApp.methods.installAndroid.call(android) +assert.equal(apk, './app.apk') +assert.match(InstallApp.template, /Install web app/) +assert.match(InstallApp.template, /Download Android APK/) +assert.match(InstallApp.template, /Update offline app \(Android\)/) +assert.match(InstallApp.template, /android && !ios/) +location.protocol = 'https:' +InstallApp.methods.installAndroid.call(android) +assert.equal(android.help, true) +assert.match(android.message, /Android app.*locally|locally.*Android app/i) + +// Choosing the web app on Android always keeps web-install guidance. +const webHelp = InstallApp.methods.installHelp.call(android) +assert.match(webHelp, /Install app/) +assert.doesNotMatch(webHelp, /download the Android app/i) + +// iOS never sees the Android offline install; the web app install remains. +assert.equal(isIosDevice('iPhone'), true) +assert.equal(isIosDevice('iPad'), true) +assert.equal(isIosDevice('Android'), false) +assert.equal(isAndroidDevice('Android'), true) +assert.equal(isAndroidDevice('iPhone'), false) +navigator.userAgent = 'iPhone' +const ios = { ...InstallApp.data() } +assert.equal(ios.android, false) +assert.equal(ios.ios, true) +assert.match(InstallApp.template, /]+href="https:\/\/apps\.apple\.com\/ca\/app\/starpilot-bt\/id6821039119"/) +assert.match(InstallApp.methods.installHelp.call(ios), /Share.*Add to Home Screen/) +assert.match(InstallApp.template, /v-if="android && !ios/) diff --git a/openpilot/starpilot/galaxy/tests/test_navigation.mjs b/openpilot/starpilot/galaxy/tests/test_navigation.mjs index 1798e5135a..9e30757c04 100644 --- a/openpilot/starpilot/galaxy/tests/test_navigation.mjs +++ b/openpilot/starpilot/galaxy/tests/test_navigation.mjs @@ -13,6 +13,11 @@ for (const key of ['avoidTolls', 'avoidHighways', 'avoidFerries']) { for (const value of ['true', 1, null, []]) assert(!validNavigation({ ...snapshot(), [key]: value })) } assert(!validNavigation({ ...snapshot(), route: [{ latitude: 90, longitude: Infinity }] })) +for (const value of ['backup', 'comma', 'phone']) assert(validNavigation({ ...snapshot(), gpsSource: value })) +assert(!validNavigation({ ...snapshot(), gpsSource: 'tunnel' })) +const located = { latitude: 1, longitude: 2, validForMs: 1000 } +assert(validNavigation({ ...snapshot(), location: { ...located, source: 'phone' } })) +assert(!validNavigation({ ...snapshot(), location: { ...located, source: 'satellite' } })) assert(!validNavigation({ ...snapshot(), destination: { ...place, latitude: 120 } })) assert(!validNavigation({ ...snapshot(), instruction: { text: "Turn left" } })) for (const network of ['online', 'offline', 'unknown']) assert(validNavigation({ ...snapshot(), network })) @@ -249,6 +254,7 @@ console.log("Local HTTP search UUID fallback passed") assert.deepEqual(actions[1], ['configure', { patch: { token: 'pk.replacement' } }]) assert.ok(NavigationPage.template.includes("selectTab('setup')")) assert.ok(NavigationPage.template.includes('Save key')) - assert.ok(NavigationPage.template.includes(' JSON.parse(JSON.stringify(v)) const native = snapshot() @@ -20,7 +20,9 @@ metadata.widgets.car_exit = { label: "Exit to car", kind: "car_exit", width: 96, opacity: { min: 25, max: 100, default: 75 }, backgroundToggle: true, note: "Returns to the car's own screen without disconnecting Android Auto. Always enabled and above other widgets.", default: { x: 30, y: 954, enabled: true, opacity: 75, background: false } } -metadata.widgetOrder = [...(metadata.widgetOrder || Object.keys(native.metadata.profiles.large.widgets)), "car_exit"] +metadata.widgets.jetlink_status = { label: "Jetlink status", kind: "jetlink_status", width: 420, height: 84, colors: {}, + opacity: { min: 0, max: 100, default: 85 }, available: true, default: { x: 30, y: 650, enabled: false, opacity: 85 } } +metadata.widgetOrder = [...(metadata.widgetOrder || Object.keys(native.metadata.profiles.large.widgets)), "jetlink_status", "car_exit"] const widgets = Object.fromEntries(Object.entries(metadata.widgets).map(([id, widget]) => [id, { ...widget.default, ...(widget.resizable ? { size: widget.resizable.default } : {}) }])) const doc = { version: 4, clock24Hour: false, largeUiGammaTrial: false, blindSpotBorder: false, signalBorder: false, blindSpotCamera: false, hidePersonalityAlerts: false, wheelPedalFeedback: false, leadIndicator: false, leadInfo: "Off", speedSources: false, speedSourcesCard: false, speedAlign: "center", canvas: { width: 2880, height: 1080 }, widgets } @@ -32,6 +34,18 @@ const data = editorSnapshot(raw) assert.equal(validSnapshot(data), true) assert.equal(validDocument(data.document, data.metadata), true) assert.equal(data.document.speedAlign, "center") // Legacy preferences still load after removing the editor control. +assert.equal(data.document.layouts.large.jetlink_status.enabled, false) +const jetlinkVm = { profile: data.metadata.profiles.large, layout: copy(data.document.layouts.large) } +const widgetRows = () => OnroadLayoutPage.computed.widgets.call(jetlinkVm) +assert.ok(widgetRows().some(widget => widget.id === 'jetlink_status')) +jetlinkVm.profile.widgets.jetlink_status.available = false +assert.ok(!widgetRows().some(widget => widget.id === 'jetlink_status')) +jetlinkVm.layout.jetlink_status.enabled = true +assert.ok(widgetRows().some(widget => widget.id === 'jetlink_status'), 'turning Jetlink off retains the placed widget') +assert.equal(validDocument({...data.document, layouts: {large: jetlinkVm.layout}}, data.metadata), true) +jetlinkVm.profile.widgets.jetlink_status.available = true +assert.match(LayoutWidgetPreview.template, /24\.6/) +compile(LayoutWidgetPreview.template, { decodeEntities: value => value.replaceAll("&", "&") }) assert.equal(data.document.layouts.large.car_exit.opacity, 75) assert.equal(data.document.layouts.large.car_exit.background, false) const resizedMonitor = copy(data.document) diff --git a/openpilot/starpilot/galaxy/tests/test_share_destination.mjs b/openpilot/starpilot/galaxy/tests/test_share_destination.mjs new file mode 100644 index 0000000000..608203ad6c --- /dev/null +++ b/openpilot/starpilot/galaxy/tests/test_share_destination.mjs @@ -0,0 +1,11 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { parseSharedDestination as parse } from '../web/js/share-destination.js' +for (const text of ['https://maps.google.com/@12.3,-45.6,15z', 'https://maps.google.com/?q=12.3,-45.6', + 'geo:12.3,-45.6(Home)', 'google.navigation:q=12.3,-45.6', 'https://maps.google.com/?daddr=12.3,-45.6', + 'https://maps.google.com/@1,2,15z/data=!3d12.3!4d-45.6']) { + test(text, () => { const { destination } = parse(text); assert.equal(destination.latitude, 12.3); assert.equal(destination.longitude, -45.6) }) +} +test('address search', () => assert.equal(parse('geo:0,0?q=10+Main+Street').query, '10 Main Street')) +test('invalid coordinate does not route', () => assert.equal(parse('geo:123,456').destination, undefined)) +test('encoded coordinates', () => assert.equal(parse('https://maps.google.com/?q=12.3%2C-45.6').destination.longitude, -45.6)) diff --git a/openpilot/starpilot/galaxy/tests/test_spotify.mjs b/openpilot/starpilot/galaxy/tests/test_spotify.mjs new file mode 100644 index 0000000000..373af12237 --- /dev/null +++ b/openpilot/starpilot/galaxy/tests/test_spotify.mjs @@ -0,0 +1,118 @@ +import assert from "node:assert/strict" +import { SpotifyFeed, SpotifyPage, archivePreflight, importProblem, installChecks, releaseInfo, serviceLabel, + positionNow, formatTime, DOWNLOAD_PAGE } from "../web/js/spotify.js" + +const setup = { + enabled: true, parked: true, keyPresent: true, audioReady: true, + release: { installed: true, version: "1.3.9.32", expires: "2027-01-14T00:00:00+00:00", daysLeft: 80, level: "ok", expired: false }, + preferences: { deviceName: "StarPilot", cacheMb: 256, initialVolume: 60, output: "bluetooth", outputAddress: "AA:BB:CC:DD:EE:FF", outputName: "Car" }, + devices: [{ address: "AA:BB:CC:DD:EE:FF", name: "Car", connected: true }], service: null, + import: { state: "idle" }, maxUploadBytes: 64 * 1024 * 1024, archiveName: "soloist_release_arm64.tar.gz", cacheChoices: [100, 256], + links: { download: DOWNLOAD_PAGE, key: "https://developer.spotify.com/dashboard/soloist", help: "https://developer.spotify.com/" }, +} + +assert.equal(archivePreflight({ name: "soloist_release_arm64.tar.gz" }), "") +assert.equal(archivePreflight({ name: "soloist_release_arm64.tgz" }), "") +assert.match(archivePreflight({ name: "soloist_release_x86_64.tar.gz" }), /different computer/) +assert.match(archivePreflight({ name: "soloist_release_arm64.tar" }), /unpacked the download/) +assert.match(archivePreflight({ name: "soloist" }), /unpacked player/) +assert.match(archivePreflight({ name: "photo.jpg" }), /Choose the soloist_release_arm64/) + +assert.match(importProblem({ state: "failed", code: "NOT_OFFICIAL" }), /doesn’t match/) +assert.match(importProblem({ state: "failed", code: "OFFLINE" }), /internet/) +assert.equal(importProblem({ state: "done" }), "") +assert.deepEqual(installChecks({ state: "running", stage: "verifying" }).map((c) => c.status), ["done", "done", "active", "pending", "pending"]) +assert.deepEqual(installChecks({ state: "failed", stage: "verifying", code: "NOT_OFFICIAL" }).map((c) => c.status), + ["done", "done", "failed", "pending", "pending"]) +assert.ok(installChecks({ state: "done" }).every((c) => c.status === "done")) + +assert.equal(releaseInfo({ installed: false }).level, "none") +assert.equal(releaseInfo({ ...setup.release, level: "soon", daysLeft: 1 }, "en-US").label, `Version 1.3.9.32 · expires in 1 day (${new Date(setup.release.expires).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })})`) +assert.equal(releaseInfo({ ...setup.release, expired: true }, "en-US").level, "expired") +assert.match(releaseInfo(setup.release, "en-US").label, /expires on .*2027/) +assert.match(releaseInfo({ ...setup.release, level: "soon", daysLeft: 0 }).label, /expires within a day/) +assert.match(releaseInfo({ ...setup.release, expired: true }).label, /expired on/) +assert.match(releaseInfo({ installed: true }).label, /expiry date unavailable/) + +assert.equal(serviceLabel({ enabled: false }), "Off") +assert.equal(serviceLabel({ enabled: true, service: { state: "waiting_output", message: "Waiting for Car to connect over Bluetooth." } }), + "Waiting for Car to connect over Bluetooth.") +assert.match(serviceLabel({ enabled: true, service: { state: "waiting", message: "" } }), /pick this device/) +assert.equal(serviceLabel({ enabled: true, service: { state: "ready", player: { status: "paused" } } }), "Paused") + +assert.equal(positionNow({ ms: 1000, at: 10_000, speed: 1 }, 5000, 12_000), 3000) +assert.equal(positionNow({ ms: 1000, at: 10_000, speed: 0 }, 5000, 99_000), 1000) +assert.equal(positionNow({ ms: 4000, at: 10_000, speed: 1 }, 5000, 99_000), 5000) +assert.equal(formatTime(65_000), "1:05") + +// The key is only ever sent; status responses never carry it, and the page has no field that shows it. +assert.match(SpotifyPage.template, /type="password" v-model="keyValue"/) +assert.match(SpotifyPage.template, /rel="noopener noreferrer"/) +assert.doesNotMatch(SpotifyPage.template, /setup\.key\b/) + +const published = [] +const requests = [] +const feed = new SpotifyFeed({ + publish: (value) => published.push(value), + later: () => 1, cancelTimer: () => { }, + fetcher: async (path, options) => { + requests.push([path, options?.method || "GET", options?.body]) + return { ok: true, status: 200, json: async () => setup } + }, +}) +await feed.start() +assert.deepEqual(feed.setup, setup) +await feed.post("./api/spotify/key", { key: "sk_live_0123456789abcdef" }) +assert.equal(requests.at(-2)[0], "./api/spotify/key") +assert.equal(feed.error, "") +await feed.control("volume", { volume: 40 }) +assert.deepEqual(JSON.parse(requests.at(-1)[2]), { action: "volume", args: { volume: 40 } }) +feed.stop() +console.log("spotify ok") + +// Phone setup does not require a Soloist release or key, but does require a selected phone and output. +assert.equal(SpotifyPage.computed.setupDone.call({ phoneSource: true, outputChosen: true, + setup: { preferences: { phoneAddress: "11:22:33:44:55:66" } } }), true) +assert.equal(SpotifyPage.computed.setupDone.call({ phoneSource: true, outputChosen: true, + setup: { preferences: { phoneAddress: "" } } }), false) +const sourceRequests = [] +const musicPage = { feed: { post: (...args) => sourceRequests.push(args) } } +SpotifyPage.methods.chooseSource.call(musicPage, { target: { value: "phone" } }) +SpotifyPage.methods.choosePhone.call(musicPage, { target: { value: "11:22:33:44:55:66" } }) +assert.deepEqual(sourceRequests, [ + ["./api/spotify/preferences", { source: "phone" }], + ["./api/spotify/preferences", { phoneAddress: "11:22:33:44:55:66" }], +]) + +// Installing Soloist needs the LAN or tunnel: the page blocks it when the comma says so, and never sends the upload. +assert.equal(SpotifyPage.computed.installBlocked.call({ setup: { ...setup, uploadAllowed: false } }), true) +assert.equal(SpotifyPage.computed.installBlocked.call({ setup }), false) +assert.match(SpotifyPage.computed.uploadReason.call({ installBlocked: true }), /local network or the Galaxy tunnel/) +assert.match(SpotifyPage.template, /Installing Spotify needs your local network or the Galaxy tunnel\. Phone audio works over Bluetooth/) +const blockedRequests = [] +const blocked = new SpotifyFeed({ + publish: () => { }, later: () => 1, cancelTimer: () => { }, + fetcher: async (path, options) => { + blockedRequests.push([path, options?.method || "GET"]) + return { ok: true, status: 200, json: async () => ({ ...setup, uploadAllowed: false }) } + }, + uploader: async (path) => { blockedRequests.push([path, "UPLOAD"]); return { ok: true, status: 202, json: async () => ({}) } }, +}) +await blocked.start() +assert.equal(await blocked.upload({ name: "soloist_release_arm64.tar.gz", size: 1024 }), false) +assert.ok(!blockedRequests.some(([, method]) => method === "UPLOAD" || method === "POST")) +blocked.stop() + +// Over the phone Bluetooth link the page polls much less often. +const delays = [] +for (const [overBluetooth, expected] of [[false, 4000], [true, 15000]]) { + const slow = new SpotifyFeed({ + publish: () => { }, later: (_fn, ms) => { delays.push(ms); return 1 }, cancelTimer: () => { }, overBluetooth: () => overBluetooth, + fetcher: async () => ({ ok: true, status: 200, json: async () => setup }), + }) + delays.length = 0 + await slow.start() + assert.ok(delays.includes(expected), `${overBluetooth}: ${delays}`) + slow.stop() +} +assert.match(SpotifyPage.template, /Pair phone/) diff --git a/openpilot/starpilot/galaxy/web/css/galaxy.css b/openpilot/starpilot/galaxy/web/css/galaxy.css index b6ad5ec324..c82aa7e5fc 100644 --- a/openpilot/starpilot/galaxy/web/css/galaxy.css +++ b/openpilot/starpilot/galaxy/web/css/galaxy.css @@ -52,6 +52,7 @@ --on-error: #ffffff; --warning: #ffb865; --success: #4de8e8; + --bluetooth: #60a5fa; --background: #070712; --surface: rgba(18, 15, 32, 0.65); @@ -123,6 +124,7 @@ --on-error: #ffffff; --warning: #c9821f; --success: #0c9b9b; + --bluetooth: #2563eb; --background: #f2f4fc; --surface: rgba(255, 255, 255, 0.85); @@ -963,6 +965,7 @@ button.gx-crash-row:hover { background: var(--glass-bg-hover); transform: transl .gx-alert--warn { --notice-accent: var(--warning); } .gx-alert--danger { --notice-accent: var(--error); } .gx-alert--success { --notice-accent: var(--success); } +.gx-alert--bluetooth { --notice-accent: var(--bluetooth); } .gx-alert__icon { flex: none; display: inline-flex; align-items: center; justify-content: center; width: 1.5rem; height: 1.5rem; font-size: 1.25rem; line-height: 1; color: var(--notice-accent); } .gx-alert__body { flex: 1; min-width: 0; overflow-wrap: anywhere; } .gx-alert__body > strong { display: block; font-weight: var(--fw-bold); margin-bottom: var(--sp-1); } @@ -1388,6 +1391,8 @@ input[type="checkbox"]:disabled { opacity: .4; cursor: not-allowed; } .gx-field-group > .gx-select { width: 100%; } .gx-info-card { padding: var(--sp-4); display: grid; gap: var(--sp-3); } .gx-info-card > h3, .gx-info-card > p { margin: 0; } +.gx-install-app { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-4); } +.gx-install-app > .gx-note { flex-basis: 100%; margin: 0; } .gx-install-benefits { display: grid; gap: var(--sp-3); color: var(--text-muted); } .gx-install-benefits li { display: flex; align-items: center; gap: var(--sp-3); } .gx-install-benefits i { color: var(--primary); } @@ -1682,7 +1687,7 @@ input[type="checkbox"]:disabled { opacity: .4; cursor: not-allowed; } .gx-media { display: block; max-width: 100%; height: auto; } .gx-media--spaced { margin-top: var(--sp-4); } .gx-pre { white-space: pre-wrap; overflow-wrap: anywhere; } -.gx-truncate { overflow: hidden; text-overflow: ellipsis; } +.gx-truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .gx-device-picker__row { display: flex; align-items: center; gap: var(--sp-1); } .gx-device-picker__form { padding: var(--sp-2); display: grid; gap: var(--sp-2); } .gx-tunnel__qr { display: block; width: min(250px, 100%); background: white; padding: var(--sp-2); border-radius: var(--radius-sm); } @@ -1704,6 +1709,18 @@ progress { max-width: 100%; accent-color: var(--primary); } .gx-details dd { margin: 0; overflow-wrap: anywhere; } .gx-driving .gx-row { flex-wrap: wrap; } .gx-driving .gx-row > strong { flex: 1; min-width: 0; overflow-wrap: anywhere; } +.gx-bt-card > h3 { margin: 0 0 var(--sp-2); } +.gx-bt-adapter__actions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--sp-2); } +.gx-bt-adapter__actions > .gx-btn { padding-inline: var(--sp-2); white-space: nowrap; } +.gx-row__desc.gx-bt-device__state { display: flex; align-items: center; gap: 6px; margin-top: 2px; } +.gx-bt-device__state > .gx-status-dot { border: 1.5px solid currentColor; box-sizing: border-box; } +.gx-bt-device__state.is-connected { color: var(--success); } +.gx-bt-device__state.is-connected > .gx-status-dot { background: currentColor; } +.gx-bt-device__actions { display: grid; grid-template-columns: repeat(2, 7.5rem); gap: var(--sp-2); } +.gx-bt-device__actions > .gx-btn { padding-inline: var(--sp-2); } +@media (max-width: 520px) { + .gx-bt-device__actions { grid-template-columns: repeat(2, minmax(0, 1fr)); flex: 1 1 100%; } +} .gx-card-grid > .gx-card, .gx-settings__grid > .gx-card, .gx-favorites__slots > .gx-card, .gx-home__grid > .gx-card, .gx-home__pair > .gx-card { margin-bottom: 0; } @media (max-width: 600px) { .gx-details { grid-template-columns: 1fr; gap: var(--sp-1); } @@ -1735,6 +1752,14 @@ progress { max-width: 100%; accent-color: var(--primary); } .gx-replay__player:fullscreen .gx-replay__video { min-height: 0; flex: 1; } } +.gx-spotify-now { display: grid; gap: var(--sp-3); } +.gx-spotify-now__main { display: flex; gap: var(--sp-3); align-items: center; min-width: 0; } +.gx-spotify-now__text { min-width: 0; } +.gx-spotify-now__text h3, .gx-spotify-now__text p { margin: 0; } +.gx-spotify-now__progress { display: grid; gap: 2px; } +.gx-spotify-now__controls { align-items: center; flex-wrap: wrap; } +.gx-spotify-now__play { min-width: 64px; font-size: 1.4rem; } +.gx-spotify-now__volume { display: flex; flex: 1 1 160px; align-items: center; gap: var(--sp-2); min-width: 0; } /* Auto Learn results use existing cards and plot colors. */ .gx-flm__facts { display: flex; flex-wrap: wrap; gap: 1rem 2rem; } .gx-flm__facts dd { margin: .3rem 0 0; } diff --git a/openpilot/starpilot/galaxy/web/css/navigation.css b/openpilot/starpilot/galaxy/web/css/navigation.css index 3410c2ef8b..53609a8b63 100644 --- a/openpilot/starpilot/galaxy/web/css/navigation.css +++ b/openpilot/starpilot/galaxy/web/css/navigation.css @@ -121,3 +121,7 @@ } .gx-navigation__token-link { margin-bottom: var(--sp-4); } + +.gx-navigation__gps { scroll-margin-top: 100px; } +.gx-navigation__gps li { margin-block: var(--sp-2); line-height: 1.5; } +.gx-navigation__gps summary { cursor: pointer; min-height: 44px; display: list-item; align-content: center; } diff --git a/openpilot/starpilot/galaxy/web/data/catalog.json b/openpilot/starpilot/galaxy/web/data/catalog.json index b04d3f26a6..7b840c6ede 100644 --- a/openpilot/starpilot/galaxy/web/data/catalog.json +++ b/openpilot/starpilot/galaxy/web/data/catalog.json @@ -85,6 +85,14 @@ "description": "Show StarPilot on your car\u2019s screen", "availability": "local-only", "visibility": "authenticated" + }, + { + "name": "Spotify and Phone Audio", + "path": "/spotify", + "icon": "bi-spotify", + "description": "Play Spotify or forward phone music through your comma to your car", + "availability": "local-only", + "visibility": "authenticated" } ] } diff --git a/openpilot/starpilot/galaxy/web/js/android-auto.js b/openpilot/starpilot/galaxy/web/js/android-auto.js index 5af6d1c4e4..e96be23902 100644 --- a/openpilot/starpilot/galaxy/web/js/android-auto.js +++ b/openpilot/starpilot/galaxy/web/js/android-auto.js @@ -5,6 +5,7 @@ import { requestJson } from "./startup.js" import { GxNotice } from "./notice.js" import { connectionError } from "./polling.js" import { BluetoothDeviceList } from "./bluetooth-devices.js" +import { BluetoothNotice, bluetoothConnection } from "./bluetooth-notice.js" import { GalaxySettingRow, switchRow } from "./galaxy-setting-row.js" export function uploadTimeout(bytes) { @@ -43,6 +44,28 @@ export function uploadPackage(path, options, progress, makeRequest = () => new X } const ADDRESS = /^(?:[0-9A-F]{2}:){5}[0-9A-F]{2}$/ +const HEAD_UNIT_APPS = { headunitpad: "HeadunitPad", openheadunit: "Open Headunit" } +const HOTSPOT_LINK = `Galaxy Wi-Fi Hotspot` +const APP_SETUP = ` +
+ HeadunitPad setup (iPad) +
    +
  1. Install HeadunitPad from GitHub on the iPad.
  2. +
  3. Put the iPad on the same Wi-Fi as the comma, or turn on the ${HOTSPOT_LINK} and join its TheGalaxy network.
  4. +
  5. In HeadunitPad, choose Headunit Server or Auto mode, landscape, at 800x480, 1280x720 or 1920x1080.
  6. +
  7. Tap Connect here, then open HeadunitPad.
  8. +
+
+
+ Open Headunit setup (Android tablet) +
    +
  1. Install Open Headunit from GitHub on the tablet.
  2. +
  3. Put the tablet on the same Wi-Fi as the comma, or turn on the ${HOTSPOT_LINK} and join its TheGalaxy network.
  4. +
  5. In Open Headunit Settings, add WiFi to Connection mode. Under Wireless Connection, set Wireless Mode to Headunit Server and Server Mode to Auto.
  6. +
  7. Set Resolution to 480p, 720p or 1080p and Video Codec to H.264 or Auto.
  8. +
  9. Tap Connect here, then tap the WiFi button in Open Headunit.
  10. +
+
` const PROMPT = new Set(["pin", "passkey", "confirmation", "authorization", "display_pin", "display_passkey"]) export function validPromptInput(prompt, value) { @@ -292,13 +315,15 @@ export class AndroidAutoFeed { } this.uploadProgress = { loaded: 0, total: file.size } const generation = this.generation + 1 - const transport = (path, options) => this.uploader(path, options, (progress) => { + // Slow tunnel links need longer than three minutes for large packages. + const timeout = uploadTimeout(file.size) + const transport = (path, options) => this.uploader(path, { ...options, timeout }, (progress) => { if (this.active && generation === this.generation) { this.uploadProgress = progress; this.emit() } }) const result = await this.request("./api/android-auto/upload", { method: "POST", headers: { "Content-Type": "application/octet-stream" }, body: file - }, 180000, transport) + }, timeout, transport) this.uploadProgress = null this.emit() if (result === null) return false @@ -417,12 +442,13 @@ export function installChecks(job, locale = undefined) { } export const AndroidAutoPage = { - components: { GxState, GxIconButton, GxNotice, GxDialog, GalaxySettingRow }, + components: { GxState, GxIconButton, GxNotice, BluetoothNotice, GxDialog, GalaxySettingRow }, props: { mode: { type: String, required: true }, localAccess: { type: Boolean, required: true }, unauthorized: { type: Function, required: true } }, data: () => ({ + bluetooth: bluetoothConnection(), 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, @@ -459,12 +485,20 @@ export const AndroidAutoPage = { supportReady() { return !!(this.setup?.enabled && this.setup.identity.installed) }, // Returning drivers see a status card; the setup steps fold away below it. wired() { return this.runtime?.connection === "wired" }, - connectionName() { return this.wired ? "car" : this.selected?.name || "car" }, - setupDone() { return !!(this.supportReady && (this.selected || this.wired) && !this.pairing?.active) }, + app() { return HEAD_UNIT_APPS[this.runtime?.connection] || "" }, + 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}.` : "")) }, + 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." } + }, + 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) @@ -488,6 +522,7 @@ export const AndroidAutoPage = { return this.packageMagicError }, uploadReason() { + if (this.bluetooth) return "Package uploads are not available over Bluetooth. Connect using Galaxy Wi-Fi or LAN." if (!this.packageFile) return "Choose your Android Auto package." if (this.packageError) return this.packageError if (this.packageChecking) return "Checking downloaded file…" @@ -528,7 +563,7 @@ export const AndroidAutoPage = { return "" }, connectReason() { - if (!this.selected && this.runtime?.connection !== "wired") return "Pair your car in step 2 first." + if (!this.selected && !this.wired && !this.app) return "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." @@ -545,6 +580,10 @@ export const AndroidAutoPage = { }, methods: { switchRow, + saveApp(index, value) { + const connection = Object.keys(HEAD_UNIT_APPS).find(key => HEAD_UNIT_APPS[key] === value) || "wireless" + return this.control("connection", { connection }) + }, async begin() { await this.feed.start() if (this.setup?.enabled && this.setup.serviceReady && !this.pairing?.active) this.loadReceivers() @@ -653,7 +692,7 @@ export const AndroidAutoPage = {

Android Auto

Show the StarPilot driving view on your car’s screen. Set up support, {{ wired ? 'plug in your car' : 'pair your car' }}, then connect.

- 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. + 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. {{ error }} {{ error || "Checking Android Auto setup… Reconnecting automatically…" }}
@@ -671,6 +710,8 @@ export const AndroidAutoPage = { :save-value="(index, value) => control('auto_connect', { enabled: value === 'On' })" /> + + ${APP_SETUP}
Customize Layout
-
+

Android Auto package installation

+
+

You’re all set

{{ expiry.label }}

diff --git a/openpilot/starpilot/galaxy/web/js/app.js b/openpilot/starpilot/galaxy/web/js/app.js index 365bbe409d..039eb97ab6 100644 --- a/openpilot/starpilot/galaxy/web/js/app.js +++ b/openpilot/starpilot/galaxy/web/js/app.js @@ -35,6 +35,7 @@ const LongitudinalCurvesPage = page(() => import("./longitudinal-curves.js").the import { DevicePreferencesPage, devicePage } from "./device-preferences.js" const BluetoothPage = page(() => import("./bluetooth.js").then((module) => module.BluetoothPage)) const AndroidAutoPage = page(() => import("./android-auto.js").then((module) => module.AndroidAutoPage)) +const SpotifyPage = page(() => import("./spotify.js").then((module) => module.SpotifyPage)) const VehicleControlsPage = page(() => import("./vehicle-controls.js").then((module) => module.VehicleControlsPage)) const OnroadLayoutPage = page(() => import("./onroad-layout.js").then((module) => module.OnroadLayoutPage)) const GalaxyPage = page(() => import("./galaxy.js").then((module) => module.GalaxyPage)) @@ -85,7 +86,7 @@ const auth = new LocalAuth({ publish: (update) => { createApp({ - components: { BottomNav, Home, LocalAccess, Tools, Logs, SoftwarePage, NavigationPage, ModelsPage, LaboratoryPage, SettingsPage, SettingsRecoveryPage, ToggleSearch, PlotsPage, FlmPage, LocalRecordingsPage, CamerasPage, SentryEventsPage, VasmPage, PipPage, DrivingPage, DevicePreferencesPage, BluetoothPage, AndroidAutoPage, LongitudinalCurvesPage, VehicleControlsPage, OnroadLayoutPage, GalaxyPage, DevicePicker, DeviceState, MenuTile }, + components: { BottomNav, Home, LocalAccess, Tools, Logs, SoftwarePage, NavigationPage, ModelsPage, LaboratoryPage, SettingsPage, SettingsRecoveryPage, ToggleSearch, PlotsPage, FlmPage, LocalRecordingsPage, CamerasPage, SentryEventsPage, VasmPage, PipPage, DrivingPage, DevicePreferencesPage, BluetoothPage, AndroidAutoPage, SpotifyPage, LongitudinalCurvesPage, VehicleControlsPage, OnroadLayoutPage, GalaxyPage, DevicePicker, DeviceState, MenuTile }, data: () => ({ state, authState, authForm, route, NAV, routeDirection: 1, updateRestart, deviceRestart, nextLayoutLeavePresentation: "inline" }), computed: { visibleTools() { return state.tools.filter(tool => tool.visibility !== "authenticated" || @@ -249,6 +250,7 @@ createApp({ + diff --git a/openpilot/starpilot/galaxy/web/js/bluetooth-notice.js b/openpilot/starpilot/galaxy/web/js/bluetooth-notice.js new file mode 100644 index 0000000000..0f25219080 --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/bluetooth-notice.js @@ -0,0 +1,16 @@ +import { GxNotice } from './notice.js' + +export const bluetoothConnection = () => (globalThis.StarPilotApp || globalThis.window?.StarPilotApp)?.bluetooth?.() === true + +export const BluetoothNotice = { + name: 'BluetoothNotice', + components: { GxNotice }, + props: { feature: { type: String, default: 'This feature' } }, + computed: { canSwitch() { return typeof (globalThis.StarPilotApp || globalThis.window?.StarPilotApp)?.connectionOptions === 'function' } }, + methods: { connections() { (globalThis.StarPilotApp || globalThis.window?.StarPilotApp)?.connectionOptions?.() } }, + template: ` + Not available over Bluetooth. Connect using Galaxy Wi-Fi or LAN to use this feature. + + + `, +} diff --git a/openpilot/starpilot/galaxy/web/js/bluetooth.js b/openpilot/starpilot/galaxy/web/js/bluetooth.js index 0de145e120..25efaf5b84 100644 --- a/openpilot/starpilot/galaxy/web/js/bluetooth.js +++ b/openpilot/starpilot/galaxy/web/js/bluetooth.js @@ -3,9 +3,10 @@ import { GxNotice } from "./notice.js" import { connectionError } from "./polling.js" import { ControllersPage } from "./controllers.js" import { BluetoothDeviceList } from "./bluetooth-devices.js" +import { GxDialog } from "./dialog.js" const ADDRESS = /^(?:[0-9A-F]{2}:){5}[0-9A-F]{2}$/ -const OPERATIONS = new Set(["power", "scan", "stop_scan", "connect", "disconnect", "forget", "pair", "pairing_response", "cancel_pair"]) +const OPERATIONS = new Set(["power", "scan", "stop_scan", "connect", "disconnect", "forget", "pair", "accept_pair", "pairing_response", "cancel_pair"]) const PAIR_STATES = new Set(["pairing", "paired", "failed", "canceled"]) const BLUETOOTH_ERRORS = { radio_unavailable: "Bluetooth radio support is unavailable on this device.", @@ -14,6 +15,7 @@ const BLUETOOTH_ERRORS = { radio_preference_unavailable: "The Bluetooth power setting could not be read. Try again after restarting the device.", busy: "Bluetooth is being used by another operation. Finish pairing or stop searching, then try again.", park_required: "Park before changing Bluetooth connections.", + projection_active: "Android Auto is using this device. Stop Android Auto, then try again.", session_expired: "The pairing session expired. Refresh and start pairing again.", changed: "The Bluetooth device or pairing request changed. Refresh and try again.", } @@ -29,17 +31,26 @@ export function validPairInput(prompt, value) { function validPairing(value) { if (value === null) return true const prompt = value?.prompt - return ADDRESS.test(value?.address) && PAIR_STATES.has(value?.state) && + // A window opened for a phone has no address until the first phone asks to pair. + const incoming = value?.incoming === true + return (incoming ? value.address === "" || ADDRESS.test(value.address) : ADDRESS.test(value?.address)) && + (!incoming || typeof value.name === "string" && value.name.length <= 80) && PAIR_STATES.has(value?.state) && (prompt === null || (typeof prompt === "object" && /^[0-9a-f]{32}$/.test(prompt.id) && PROMPT_KINDS.has(prompt.kind) && typeof prompt.value === "string" && prompt.value.length <= 16 && typeof prompt.displayOnly === "boolean")) } +function validClaim(value) { + return value === undefined || typeof value?.open === "boolean" && Number.isInteger(value.left) && value.left >= 0 && value.left <= 600 && + typeof value.claimed === "string" && value.claimed.length <= 80 && typeof value.paired === "boolean" +} + export function validBluetoothStatus(value) { return value?.version === 1 && typeof value.available === "boolean" && typeof value.parked === "boolean" && typeof value.powered === "boolean" && typeof value.discovering === "boolean" && (value.errorCode === null || ["radio_unavailable", "adapter_unavailable", "service_unavailable", "radio_preference_unavailable"].includes(value.errorCode)) && - validPairing(value.pairing ?? null) && Array.isArray(value.devices) && value.devices.length <= 64 && value.devices.every((device) => + ["adapterAddress", "adapterName"].every((key) => value[key] === undefined || typeof value[key] === "string" && value[key].length <= 80) && + validClaim(value.claim) && validPairing(value.pairing ?? null) && Array.isArray(value.devices) && value.devices.length <= 64 && value.devices.every((device) => ADDRESS.test(device?.address) && typeof device.name === "string" && device.name.length <= 80 && [device.paired, device.connected, device.trusted].every((flag) => typeof flag === "boolean")) } @@ -61,7 +72,8 @@ export class BluetoothFeed { emit() { this.publish({ status: this.status, busy: this.busy, error: this.error }) } stop(cancelPair = false) { - if (cancelPair && this.active && this.status?.pairing?.state === "pairing") { + // A phone window has to survive the page being hidden: the phone is opened to tap the comma. + if (cancelPair && this.active && this.status?.pairing?.state === "pairing" && !this.status.pairing.incoming) { this.fetcher("./api/bluetooth/action", { method: "POST", credentials: "same-origin", keepalive: true, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ operation: "cancel_pair" }) }).catch(() => {}) } @@ -142,33 +154,39 @@ export class BluetoothFeed { } async action(operation, fields = {}) { - if (!OPERATIONS.has(operation) || !this.active || this.busy || (operation === 'power' && !this.status?.parked)) return + if (!OPERATIONS.has(operation) || !this.active || this.busy || (operation === 'power' && !this.status?.parked)) return false const payload = { operation, ...fields } const result = await this.run("./api/bluetooth/action", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }, operation === "power" ? 35000 : ["connect", "disconnect"].includes(operation) ? 30000 : 15000) if (result) await this.refresh() + return result !== null } } export const BluetoothPage = { - components: { GxState, GxNotice, ControllersPage }, + components: { GxState, GxNotice, ControllersPage, GxDialog }, props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } }, - data: () => ({ status: null, busy: false, error: "", pairValue: "" }), + data: () => ({ status: null, busy: false, error: "", pairValue: "", now: Date.now(), claimAt: Date.now(), forgetting: null }), mounted() { this.feed = new BluetoothFeed({ publish: (value) => Object.assign(this, value), unauthorized: this.unauthorized }) this.visibility = () => document.hidden ? this.feed.stop(true) : this.feed.start() document.addEventListener("visibilitychange", this.visibility) + this.clock = setInterval(() => { this.now = Date.now() }, 1000) if (this.mode === "local" && !document.hidden) this.feed.start() }, - beforeUnmount() { document.removeEventListener("visibilitychange", this.visibility); this.feed?.stop(true) }, + beforeUnmount() { clearInterval(this.clock); document.removeEventListener("visibilitychange", this.visibility); this.feed?.stop(true) }, watch: { mode(value) { if (value === "local" && !document.hidden) this.feed?.start(); else this.feed?.stop(true) }, 'status.pairing.prompt.id'() { this.pairValue = "" }, + 'status.claim'() { this.claimAt = Date.now(); this.now = this.claimAt }, }, computed: { - saved() { return this.status?.devices?.filter((device) => device.paired || device.trusted || device.connected) || [] }, + saved() { return this.status?.devices?.filter((device) => device.paired || device.trusted) || [] }, + // A phone running the StarPilot app is connected to comma's Bluetooth bridge but not bonded: list it apart from real pairings. + appLinks() { return this.status?.devices?.filter((device) => device.connected && !device.paired && !device.trusted) || [] }, nearby() { return this.status?.devices?.filter((device) => !device.paired && !device.trusted && !device.connected) || [] }, + claimLeft() { return Math.max(0, (this.status?.claim?.left ?? 0) - Math.floor((this.now - this.claimAt) / 1000)) }, canAllowPair() { return validPairInput(this.status?.pairing?.prompt, this.pairValue) }, }, methods: { @@ -181,6 +199,13 @@ export const BluetoothPage = { const value = accepted && ["pin", "passkey"].includes(prompt.kind) ? this.pairValue : "" return this.action("pairing_response", { promptId: prompt.id, accepted, value }) }, + async confirmForget() { + if (!["confirm", "failed"].includes(this.forgetting?.state) || this.busy) return + this.forgetting.state = "working" + const forgotten = await this.action("forget", { address: this.forgetting.address }) + if (this.forgetting) this.forgetting.state = forgotten ? "done" : "failed" + }, + closeForget() { if (this.forgetting?.state !== "working") this.forgetting = null }, }, template: `
@@ -197,11 +222,20 @@ export const BluetoothPage = { Park to restart the Bluetooth radio.
Power
+
-
Pairing{{ status.pairing.address }}
-

Waiting for the device and any confirmation below.

+

Pair phone

+

Open the StarPilot app and tap Pair with comma. No app? Tap {{ status.adapterName || 'this comma' }} in your phone’s Bluetooth settings.

+

App paired ({{ status.claim.claimed }}). iPhone: tap {{ status.adapterName || 'this comma' }} in Settings → Bluetooth to add music. Android pairs music on its own.

+

All set. {{ status.claim.claimed }} is paired for the app and music.

+

App: {{ status.claim.claimed || (status.claim.open ? 'waiting' : 'not paired') }} · Music: {{ status.claim.paired || status.pairing?.state === 'paired' ? 'paired' : (status.claim.open ? 'waiting' : 'not paired') }}

+

{{ Math.floor(claimLeft / 60) }}:{{ String(claimLeft % 60).padStart(2, '0') }} left. Accepts one phone.

+
Pairing{{ status.pairing.name || status.pairing.address }}
+

Open Bluetooth settings on your phone and tap {{ status.adapterName || 'this comma' }}.

+

Waiting for the device and any confirmation below.

{{ status.pairing.state === 'paired' ? 'Device paired.' : status.pairing.state === 'canceled' ? 'Pairing canceled.' : 'Pairing did not complete.' }} -

{{ status.pairing.prompt.kind === 'confirmation' ? 'Confirm this code matches the device:' : +

{{ status.pairing.incoming && status.pairing.prompt.displayOnly && status.pairing.prompt.kind === 'confirmation' ? 'Accepted automatically. Tap Pair on your phone if it shows this code:' : + status.pairing.prompt.kind === 'confirmation' ? 'Confirm this code matches the device:' : status.pairing.prompt.kind === 'pin' ? 'Enter the PIN shown by the device.' : status.pairing.prompt.kind === 'passkey' ? 'Enter the device passkey.' : status.pairing.prompt.kind === 'authorization' ? 'Allow this device to pair?' : 'Use this code on the device:' }}

@@ -214,7 +248,12 @@ export const BluetoothPage = {

No saved devices found.

{{ device.name || device.address }}{{ device.connected ? 'Connected' : 'Disconnected' }} · {{ device.address }}
-
+
+
App Pairings
+

Phones connected through the StarPilot app. These are not Bluetooth pairings.

+
{{ device.name }} + Connected through the app
+
Nearby Devices

{{ status.discovering ? 'Searching…' : 'No nearby devices found.' }}

@@ -223,5 +262,19 @@ export const BluetoothPage = { + + + +
`, } diff --git a/openpilot/starpilot/galaxy/web/js/cameras.js b/openpilot/starpilot/galaxy/web/js/cameras.js index 180bb1afec..0ae38f6020 100644 --- a/openpilot/starpilot/galaxy/web/js/cameras.js +++ b/openpilot/starpilot/galaxy/web/js/cameras.js @@ -1,6 +1,7 @@ import { MenuTile } from "./menu-tile.js" import { GxNotice } from "./notice.js" import { GalaxySelect } from "./galaxy-select.js" +import { BluetoothNotice, bluetoothConnection } from "./bluetooth-notice.js" export class CameraSnapshotFeed { constructor({ publish, unauthorized, fetcher = (...args) => fetch(...args), @@ -24,6 +25,7 @@ export class CameraSnapshotFeed { } async capture(camera) { this.stop() + if (bluetoothConnection()) return const request = new AbortController() this.request = request this.publish({ image: "", capturing: true, error: "" }) @@ -63,10 +65,10 @@ export class CameraSnapshotFeed { export const CamerasPage = { name: "CamerasPage", - components: { GxNotice, GalaxySelect, MenuTile }, + components: { GxNotice, BluetoothNotice, GalaxySelect, MenuTile }, props: { mode: { type: String, required: true }, go: { type: Function, required: true }, unauthorized: { type: Function, required: true } }, - data: () => ({ camera: "cabin", image: "", capturing: false, error: "" }), + data: () => ({ bluetooth: bluetoothConnection(), camera: "cabin", image: "", capturing: false, error: "" }), created() { this.snapshots = new CameraSnapshotFeed({ publish: (state) => Object.assign(this.$data, state), unauthorized: this.unauthorized }) }, methods: { chooseCamera(event) { this.camera = event.target.value; this.snapshots.stop() }, @@ -87,7 +89,8 @@ export const CamerasPage = {
Camera Snapshot

Turn off the vehicle, choose a camera, then take a snapshot.

- + + + + + + Midnight City + M83 + + + + + + + Link up + Jetlink • iPhone + 24.6 + ms +