Files
StarPilot/openpilot/starpilot/galaxy/web/js/bluetooth.js
T

284 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { GxState } from "./state.js"
import { GxNotice } from "./notice.js"
import { reportSave } from "./save-feedback.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", "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.",
adapter_unavailable: "No Bluetooth adapter was detected. Turn Bluetooth on, then refresh.",
service_unavailable: "The device's Bluetooth service could not be reached. Refresh to reconnect.",
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: "StarPort is using this device. Stop StarPort, 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.",
}
export const bluetoothError = (code) => BLUETOOTH_ERRORS[code] || "Bluetooth could not complete the request. Refresh to try again."
const PROMPT_KINDS = new Set(["pin", "passkey", "confirmation", "authorization", "display_pin", "display_passkey"])
export function validPairInput(prompt, value) {
if (prompt?.kind === "pin") return /^[\x20-\x7E]{1,16}$/.test(value)
if (prompt?.kind === "passkey") return /^[0-9]{1,6}$/.test(value)
return ["confirmation", "authorization"].includes(prompt?.kind)
}
function validPairing(value) {
if (value === null) return true
const prompt = value?.prompt
// 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)) &&
["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"))
}
export class BluetoothFeed {
constructor({ publish, unauthorized = () => {}, fetcher = (...args) => fetch(...args),
later = (fn, ms) => setTimeout(fn, ms), cancelTimer = (id) => clearTimeout(id) }) {
Object.assign(this, { publish, unauthorized, fetcher, later, cancelTimer })
this.active = false
this.generation = 0
this.request = null
this.timer = null
this.pollTimer = null
this.busy = false
this.status = null
this.deviceList = new BluetoothDeviceList()
this.error = ""
}
emit() { this.publish({ status: this.status, busy: this.busy, error: this.error }) }
stop(cancelPair = false) {
// 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(() => {})
}
this.active = false
this.generation++
this.request?.abort()
if (this.timer !== null) this.cancelTimer(this.timer)
if (this.pollTimer !== null) this.cancelTimer(this.pollTimer)
this.request = this.timer = this.pollTimer = null
this.busy = false
this.status = null
this.deviceList.reset()
this.error = ""
this.emit()
}
start() { this.stop(); this.active = true; return this.refresh() }
retire(generation, request) {
if (!this.active || this.generation !== generation || this.request !== request) return false
if (this.timer !== null) this.cancelTimer(this.timer)
this.timer = null
this.request = null
this.busy = false
if (this.pollTimer === null) this.pollTimer = this.later(() => this.refresh(), 2000)
this.emit()
return true
}
async run(url, options, timeout, background = false) {
if (!this.active || this.busy || background && this.request !== null) return null
if (!background) {
// The user action supersedes any in-flight poll, including its JSON body.
this.generation++
this.request?.abort()
if (this.timer !== null) this.cancelTimer(this.timer)
if (this.pollTimer !== null) this.cancelTimer(this.pollTimer)
this.request = this.timer = this.pollTimer = null
}
const generation = this.generation
const request = new AbortController()
this.request = request
if (!background) { this.busy = true; this.emit() }
this.timer = this.later(() => {
if (!this.retire(generation, request)) return
request.abort()
this.generation++
this.error = "Bluetooth took too long to respond. Refresh to check its current state."
this.emit()
}, timeout)
try {
const response = await this.fetcher(url, { ...options, signal: request.signal, credentials: "same-origin", cache: "no-store" })
if (!this.active || this.generation !== generation || this.request !== request || request.signal.aborted) return null
if (response.status === 401) { this.stop(); this.unauthorized(); return null }
const data = await response.json().catch(() => { throw new Error("Galaxy could not reach Bluetooth. Refresh to reconnect.") })
if (!this.active || this.generation !== generation || this.request !== request || request.signal.aborted) return null
if (response.status === 503 && ["setup_required", "access_unavailable"].includes(data?.code)) {
this.stop(); this.unauthorized(); return null
}
if (!response.ok) throw new Error(bluetoothError(data?.code))
if (!validBluetoothStatus(data)) throw new Error("Galaxy received an invalid Bluetooth response. Refresh to reconnect.")
this.status = { ...data, devices: this.deviceList.update(data.devices) }
this.error = ""
return data
} catch (error) {
if (this.active && this.generation === generation && this.request === request) {
this.error = connectionError(error)
}
return null
} finally {
this.retire(generation, request)
}
}
async refresh() {
if (!this.active || this.busy || this.request !== null) return
if (this.pollTimer !== null) { this.cancelTimer(this.pollTimer); this.pollTimer = null }
await this.run("./api/bluetooth/status", {}, 12000, true)
}
async action(operation, fields = {}) {
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
}
}
export const BluetoothPage = {
components: { GxState, GxNotice, ControllersPage, GxDialog },
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } },
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() { 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) || [] },
// 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: {
reportSave,
bluetoothError,
refresh() { return this.feed.refresh() },
action(operation, fields) { return this.feed.action(operation, fields) },
respond(accepted) {
const prompt = this.status?.pairing?.prompt
if (!prompt || prompt.displayOnly || accepted && !validPairInput(prompt, this.pairValue)) return
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: `
<section class="gx-driving" aria-label="Bluetooth" :inert="busy" :aria-busy="busy || undefined">
<header class="gx-settings__header gx-page-header"><div><h2>Bluetooth</h2>
<p>View, pair and manage nearby and saved devices.</p></div></header>
<GxState v-if="mode !== 'local'">Bluetooth requires the device. No radio was checked in preview.</GxState>
<template v-else>
<GxNotice tone="danger" v-if="error">{{ error }}</GxNotice>
<GxState v-if="!status && !error" loading>Checking Bluetooth…</GxState>
<template v-else-if="status">
<GxNotice tone="info">Using a wireless Android Auto adapter? Set up StarPort on the <a class="gx-link" href="#/starport">StarPort page</a>, then pair and connect your car here.</GxNotice>
<section class="gx-card"><div class="gx-section__header"><i class="bi bi-bluetooth" aria-hidden="true"></i><span class="gx-section__title">Adapter</span></div>
<div class="gx-panel gx-stack">
<GxNotice v-if="status.errorCode" tone="danger">{{ bluetoothError(status.errorCode) }}</GxNotice>
<GxNotice v-else-if="!status.parked" tone="warn">Park to restart the Bluetooth radio.</GxNotice>
<div class="gx-row gx-row--borderless"><div class="gx-row__info"><span class="gx-row__label">Power</span></div>
<label class="gx-switch"><input type="checkbox" role="switch" aria-label="Bluetooth power" :checked="status.powered" :disabled="!status.parked || !status.available" @change="$event.target.checked=status.powered; reportSave(() => action('power', {enabled: !status.powered}))"><span class="gx-switch__track"></span><span class="gx-switch__thumb"></span></label></div>
<button class="gx-btn gx-btn--tonal" :disabled="!status.powered || !status.parked || status.discovering || status.pairing?.state === 'pairing'" @click="action('accept_pair')">Pair phone</button>
</div></section>
<div v-if="status.claim?.open || status.claim?.claimed" class="gx-card gx-driving__intro gx-bt-card" role="status"><h3>Pair phone</h3>
<p v-if="status.claim.open && !status.claim.claimed">Open the StarPilot app and tap <strong>Pair with comma</strong>. No app? Tap <strong>{{ status.adapterName || 'this comma' }}</strong> in your phone’s Bluetooth settings.</p>
<p v-else-if="status.claim.claimed && !status.claim.paired">App paired ({{ status.claim.claimed }}). iPhone: tap <strong>{{ status.adapterName || 'this comma' }}</strong> in Settings → Bluetooth to add music. Android pairs music on its own.</p>
<p v-else-if="status.claim.claimed">All set. {{ status.claim.claimed }} is paired for the app and music.</p>
<p>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') }}</p>
<p v-if="status.claim.open && claimLeft > 0"><strong>{{ Math.floor(claimLeft / 60) }}:{{ String(claimLeft % 60).padStart(2, '0') }}</strong> left. Accepts one phone.</p></div>
<section v-if="status.pairing" class="gx-card" role="status"><div class="gx-section__header"><i class="bi bi-link-45deg" aria-hidden="true"></i><span class="gx-section__title">Pairing</span><span class="gx-chip">{{ status.pairing.name || status.pairing.address }}</span></div>
<div class="gx-panel gx-stack"><p v-if="status.pairing.incoming && status.pairing.state === 'pairing' && !status.pairing.address">Open Bluetooth settings on your phone and tap <strong>{{ status.adapterName || 'this comma' }}</strong>.</p>
<p v-else-if="status.pairing.state === 'pairing'">Waiting for the device and any confirmation below.</p>
<GxNotice v-else :tone="status.pairing.state === 'paired' ? 'success' : status.pairing.state === 'failed' ? 'danger' : 'info'">{{ status.pairing.state === 'paired' ? 'Device paired.' : status.pairing.state === 'canceled' ? 'Pairing canceled.' : 'Pairing did not complete.' }}</GxNotice>
<div v-if="status.pairing.prompt" class="gx-stack"><p>{{ 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:' }}</p>
<strong v-if="status.pairing.prompt.value">{{ status.pairing.prompt.value }}</strong>
<input v-if="['pin', 'passkey'].includes(status.pairing.prompt.kind)" v-model="pairValue" class="gx-field" type="text" maxlength="16" autocomplete="off" placeholder="PIN or passkey" />
<div v-if="!status.pairing.prompt.displayOnly" class="gx-driving__actions gx-actions"><button class="gx-btn gx-btn--tonal" @click="respond(false)">Reject</button>
<button class="gx-btn" :disabled="!canAllowPair" @click="respond(true)">{{ status.pairing.prompt.kind === 'pin' ? 'Submit PIN' : status.pairing.prompt.kind === 'passkey' ? 'Submit Passkey' : 'Allow' }}</button></div></div>
<button v-if="status.pairing.state === 'pairing'" class="gx-btn gx-btn--tonal" @click="action('cancel_pair')">Cancel Pairing</button></div></section>
<section class="gx-card"><div class="gx-section__header"><i class="bi bi-hdd-stack" aria-hidden="true"></i><span class="gx-section__title">Saved Devices</span></div>
<p v-if="!saved.length" class="gx-note gx-inset">No saved devices found.</p>
<div v-for="device in saved" :key="device.address" class="gx-row"><div class="gx-row__info"><span class="gx-row__label"><i class="bi bi-bluetooth gx-bluetooth__device-icon" aria-hidden="true"></i>{{ device.name || device.address }}</span><span class="gx-row__desc">{{ device.connected ? 'Connected' : 'Disconnected' }} · {{ device.address }}</span></div>
<div class="gx-actions"><button class="gx-btn gx-btn--tonal" :disabled="!status.powered" @click="action(device.connected ? 'disconnect' : 'connect', {address:device.address})">{{ device.connected ? 'Disconnect' : 'Connect' }}</button>
<button class="gx-btn gx-btn--tonal" @click="forgetting = { address: device.address, name: device.name, state: 'confirm' }">Forget</button></div></div></section>
<section v-if="appLinks.length" class="gx-card"><div class="gx-section__header"><i class="bi bi-phone" aria-hidden="true"></i><span class="gx-section__title">App Pairings</span></div>
<p class="gx-note gx-inset">Phones connected through the StarPilot app. These are not Bluetooth pairings.</p>
<div v-for="device in appLinks" :key="device.address" class="gx-row"><div class="gx-row__info"><span class="gx-row__label">{{ device.name }}</span>
<span class="gx-row__desc">Connected through the app</span></div>
<div class="gx-actions"><button class="gx-btn gx-btn--tonal" @click="forgetting = { address: device.address, name: device.name, state: 'confirm' }">Forget</button></div></div></section>
<section class="gx-card"><div class="gx-section__header"><i class="bi bi-broadcast" aria-hidden="true"></i><span class="gx-section__title">Nearby Devices</span>
<button class="gx-btn gx-btn--tonal" :disabled="!status.powered" @click="action(status.discovering ? 'stop_scan' : 'scan')"><i class="bi" :class="status.discovering ? 'bi-x-circle' : 'bi-search'" aria-hidden="true"></i>{{ status.discovering ? 'Stop Search' : 'Search' }}</button></div>
<p v-if="!nearby.length" class="gx-note gx-inset">{{ status.discovering ? 'Searching…' : 'No nearby devices found.' }}</p>
<div v-for="device in nearby" :key="device.address" class="gx-row"><div class="gx-row__info"><span class="gx-row__label"><i class="bi bi-bluetooth gx-bluetooth__device-icon" aria-hidden="true"></i>{{ device.name || device.address }}</span><span class="gx-row__desc">{{ device.address }}</span></div>
<button class="gx-btn" :disabled="!status.powered || status.pairing?.state === 'pairing'" @click="action('pair', {address:device.address})">Pair</button></div></section>
</template>
</template>
<ControllersPage :mode="mode" :unauthorized="unauthorized" />
<GxDialog v-if="forgetting" labelledby="gx-bt-forget-title" describedby="gx-bt-forget-body" alert @close="closeForget">
<template v-if="forgetting.state === 'done'">
<h3 id="gx-bt-forget-title">{{ forgetting.name }} forgotten</h3>
<p id="gx-bt-forget-body">Also remove the comma from the device's Bluetooth list, or it may keep trying to reconnect or fail to pair again later.</p>
<div class="gx-settings__controls gx-actions"><button class="gx-btn" @click="closeForget">Done</button></div>
</template>
<template v-else>
<h3 id="gx-bt-forget-title">Forget {{ forgetting.name }}?</h3>
<p id="gx-bt-forget-body">The comma will remove it and stop connecting to it. To use it again, you'll need to pair it again.</p>
<GxNotice v-if="forgetting.state === 'failed'" tone="danger">{{ error || 'The device could not be forgotten. Try again.' }}</GxNotice>
<div class="gx-settings__controls gx-actions"><button class="gx-btn gx-btn--tonal" :disabled="forgetting.state === 'working'" @click="closeForget">Cancel</button>
<button class="gx-btn gx-btn--danger" :disabled="forgetting.state === 'working'" @click="confirmForget">{{ forgetting.state === 'working' ? 'Forgetting…' : forgetting.state === 'failed' ? 'Try again' : 'Forget' }}</button></div>
</template>
</GxDialog>
</section>`,
}