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 +