import { guardUnload } from "./unload-guard.js" import { GxNotice } from "./notice.js" import { reportSave } from "./save-feedback.js" import { reactive } from "../vendor/vue/vue.esm-browser.js" const HEX = /^[0-9a-f]{64}$/ const string = (value, limit = 128) => typeof value === "string" && value.length <= limit && !/[\x00-\x1f\x7f]/.test(value) const key = (value) => value === null || (string(value) && value.length > 0) const integer = (value, max) => Number.isInteger(value) && value >= 0 && value <= max const draftFrom = (status) => ({ revision: status.revision, enabled: status.enabled, slots: status.slots.slice(3).map((slot) => slot.key) }) export function validControllersStatus(data) { if (data?.version !== 1 || typeof data.available !== "boolean" || typeof data.editable !== "boolean" || typeof data.enabled !== "boolean" || typeof data.valid !== "boolean" || !HEX.test(data.revision) || !Array.isArray(data.devices) || data.devices.length > 32 || !Array.isArray(data.slots) || data.slots.length !== 13 || !Array.isArray(data.options) || data.options.length > 128 || !Array.isArray(data.bindings) || data.bindings.length > 128 || typeof data.testing !== "boolean") return false if (!data.devices.every((device) => string(device?.id, 256) && device.id && string(device.name, 80) && [3, 5].includes(device.bus))) return false if (!data.slots.every((slot, index) => slot?.index === index && string(slot.label, 80) && key(slot.key) && typeof slot.available === "boolean")) return false if (!data.options.every((option) => key(option?.key) && option.key !== null && string(option.label, 80) && string(option.section, 80))) return false if (new Set(data.options.map((option) => option.key)).size !== data.options.length) return false if (!data.bindings.every((binding) => string(binding?.deviceId, 256) && binding.deviceId && string(binding.name, 80) && integer(binding.code, 65551) && integer(binding.slot, 12))) return false if (data.learning !== null && (!integer(data.learning?.slot, 12) || !Number.isFinite(data.learning.expiresIn) || data.learning.expiresIn < 0 || data.learning.expiresIn > 20)) return false if (data.lastPress !== null && (!string(data.lastPress?.deviceId, 256) || !integer(data.lastPress.code, 65551) || (data.lastPress.slot !== null && !integer(data.lastPress.slot, 12)) || typeof data.lastPress.executed !== "boolean" || !string(data.lastPress.message, 200))) return false return true } export class ControllersFeed { 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 = this.timeout = this.poll = null this.status = null this.busy = false this.error = "" } emit(replaceDraft = false) { this.publish({ status: this.status, busy: this.busy, error: this.error, ...(replaceDraft && this.status ? { draft: draftFrom(this.status) } : {}) }) } stop() { if (this.active && this.status?.editable && (this.status.learning || this.status.testing)) { this.fetcher("./api/controllers/action", { method: "POST", credentials: "same-origin", keepalive: true, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ operation: "cancel" }) }).catch(() => {}) } this.active = false this.generation++ this.request?.abort() if (this.timeout !== null) this.cancelTimer(this.timeout) if (this.poll !== null) this.cancelTimer(this.poll) this.request = this.timeout = this.poll = null this.status = null this.busy = false this.error = "" this.emit() } start() { this.stop(); this.active = true; return this.refresh() } async run(payload = null, replaceDraft = false) { if (!this.active) return null if (this.request) { if (payload === null || this.busy) return null this.request.abort() this.cancelTimer(this.timeout) this.generation++ } if (this.poll !== null) this.cancelTimer(this.poll) this.poll = null const generation = this.generation, request = new AbortController() this.request = request this.busy = payload !== null || this.status === null this.emit() this.timeout = this.later(() => request.abort(), 6000) let accepted = false let failure = "Controller Buttons are unavailable. Reconnecting automatically…" try { const response = await this.fetcher(payload === null ? "./api/controllers/status" : "./api/controllers/action", { credentials: "same-origin", cache: "no-store", signal: request.signal, ...(payload === null ? {} : { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }), }) if (!this.active || generation !== this.generation || request.signal.aborted) return null if (response.status === 401) { this.stop(); this.unauthorized(); return null } if (response.status === 403) { failure = "Turn off the vehicle to change controller buttons."; throw new Error() } if (response.status === 409) { failure = "Controller settings changed. Reload them before saving."; throw new Error() } if (response.status === 429) { failure = "Controller is busy. Try again shortly."; throw new Error() } if (!response.ok) throw new Error() const data = await response.json() if (!this.active || generation !== this.generation || request.signal.aborted) return null if (!validControllersStatus(data)) { failure = "Controller Buttons returned an unsupported status. Try again."; throw new Error() } this.status = data accepted = true this.error = "" return data } catch { if (this.active && generation === this.generation) this.error = failure return null } finally { if (this.request === request) { if (this.timeout !== null) this.cancelTimer(this.timeout) this.request = this.timeout = null this.busy = false this.emit(accepted && (replaceDraft || payload?.operation === "save")) if (this.active && this.poll === null) this.poll = this.later(() => { this.poll = null; this.refresh() }, 2000) } } } refresh(replaceDraft = false) { return this.run(null, replaceDraft) } action(payload) { return this.run(payload) } } export const ControllersPage = { components: { GxNotice }, props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } }, setup(props) { const state = reactive({ status: null, busy: false, error: "", draft: null }) const feed = new ControllersFeed({ unauthorized: props.unauthorized, publish(update) { const prior = state.status Object.assign(state, update) if (update.status === null) state.draft = null else if (update.status && (!state.draft || (prior && state.draft.revision === prior.revision && state.draft.enabled === prior.enabled && state.draft.slots.every((value, index) => value === prior.slots[index + 3].key)))) { state.draft = draftFrom(update.status) } } }) return { state, feed } }, computed: { dirty() { return !!this.state.status && !!this.state.draft && (this.state.draft.enabled !== this.state.status.enabled || this.state.draft.slots.some((value, index) => value !== this.state.status.slots[index + 3].key)) }, changed() { return !!this.state.status && !!this.state.draft && this.state.draft.revision !== this.state.status.revision }, editable() { return !!this.state.status?.editable && !this.changed }, canEdit() { return this.editable && !this.state.busy }, favorites() { return this.state.status?.slots.slice(0, 3) || [] }, actions() { return this.state.status?.slots.slice(3) || [] }, }, mounted() { this._stopUnloadGuard = guardUnload(() => this.dirty || this.state.busy) this.visibility = () => { if (this.mode === "local" && !document.hidden) this.feed.start(); else this.feed.stop() } document.addEventListener("visibilitychange", this.visibility) this.visibility() }, beforeUnmount() { this._stopUnloadGuard(); document.removeEventListener("visibilitychange", this.visibility); this.feed.stop() }, watch: { mode() { this.visibility() } }, methods: { reload() { return this.feed.refresh(true) }, async save() { if (!this.canEdit || !this.dirty) return const saved = await reportSave(() => this.feed.action({ operation: "save", revision: this.state.draft.revision, enabled: this.state.draft.enabled, slots: this.state.draft.slots.map((value) => value || null) })) if (!saved && this.state.status) this.state.draft = draftFrom(this.state.status) return saved }, learn(slot) { if (this.canEdit && !this.dirty && integer(slot, 12)) return this.feed.action({ operation: "learn", revision: this.state.status.revision, slot }) }, cancel() { if (this.state.status?.learning) return this.feed.action({ operation: "cancel" }) }, test() { if (this.canEdit) return this.feed.action({ operation: "test", enabled: !this.state.status.testing }) }, remove(binding) { if (this.canEdit && !this.dirty) return this.feed.action({ operation: "remove", revision: this.state.status.revision, deviceId: binding.deviceId, code: binding.code }) }, }, template: `
Controller Buttons

Assign USB or Bluetooth controller buttons to Quick Select and driving screen actions. This does not control steering, gas, or brakes. Turn off the vehicle to edit.

Connect to local Galaxy to manage controller buttons.

`, }