From 0de19eaa3e542071717f63d7b82550f14bb0b66e Mon Sep 17 00:00:00 2001 From: Rafael Araujo Lehmkuhl Date: Thu, 2 Jul 2026 18:41:16 -0300 Subject: [PATCH 01/10] joystick: recognize XInput Xbox controllers Xbox controllers connected through XInput on Windows are exposed by the browser without VID/PID (e.g. "Xbox 360 Controller (XInput STANDARD GAMEPAD)"), so they resolved to the generic Unknown model. Match them by name to a dedicated XboxController_XInput model with the standard gamepad layout. --- src/libs/joystick/manager.ts | 4 ++++ src/types/joystick-model-defs.ts | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/src/libs/joystick/manager.ts b/src/libs/joystick/manager.ts index 80b81c1c4e..04342ac494 100644 --- a/src/libs/joystick/manager.ts +++ b/src/libs/joystick/manager.ts @@ -287,6 +287,10 @@ class JoystickManager { const { vendor_id, product_id } = this.getVidPid(gamepadId) if (vendor_id == undefined || product_id == undefined) { + // Xbox controllers connected through XInput on Windows are exposed by the browser without VID/PID + // (e.g. "Xbox 360 Controller (XInput STANDARD GAMEPAD)"), so match them by name instead of falling + // back to the generic Unknown model. + if (/xinput|xbox/i.test(gamepadId)) return JoystickModel.XboxController_XInput return JoystickModel.Unknown } return JoystickMapVidPid.get(`${vendor_id}:${product_id}`) ?? JoystickModel.Unknown diff --git a/src/types/joystick-model-defs.ts b/src/types/joystick-model-defs.ts index 6692b54cba..d71221f5cf 100644 --- a/src/types/joystick-model-defs.ts +++ b/src/types/joystick-model-defs.ts @@ -12,6 +12,7 @@ export enum JoystickModel { XboxController_Bluetooth = 'Xbox controller (bluetooth)', XboxController_Wired = 'Xbox controller (wired)', XboxController_360 = 'Xbox 360 controller', + XboxController_XInput = 'Xbox controller (XInput)', LogitechExtreme3DPro = 'Logitech Extreme 3D Pro', IpegaPG9023 = 'Ipega PG-9023', SteamDeckLCD = 'Steam Deck LCD', @@ -111,6 +112,11 @@ export const availableGamepadToCockpitMaps: { [key in JoystickModel]: GamepadToC axes: [0, 1, 2, 3], buttons: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], }, + [JoystickModel.XboxController_XInput]: { + name: 'Xbox Controller (XInput)', + axes: [0, 1, 2, 3], + buttons: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], + }, [JoystickModel.LogitechExtreme3DPro]: { name: JoystickModel.XboxController_360, axes: [0, 1, 5, 6, 7, 2, 3, 8, 9, 4], From 0d64e7bfabb4682bac1c325b1701862b2244d963 Mon Sep 17 00:00:00 2001 From: Rafael Araujo Lehmkuhl Date: Thu, 2 Jul 2026 19:07:45 -0300 Subject: [PATCH 02/10] joystick: keep processing connection updates when a second joystick joins When a second joystick connected while forwarding was already active, the handler returned early, skipping the main-joystick selection and the disconnected-joystick cleanup for the rest of the event. Use `continue` so only the redundant conflict check is skipped and the handler still finishes. --- src/stores/controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/stores/controller.ts b/src/stores/controller.ts index f5405e53eb..6e95d3e765 100644 --- a/src/stores/controller.ts +++ b/src/stores/controller.ts @@ -176,7 +176,7 @@ export const useControllerStore = defineStore('controller', () => { if (thereWereJoysticksBefore && enableForwarding.value) { console.warn('There are joysticks connected and forwarding already. Skipping joystick conflict check.') - return + continue } // Check if other GCS is sending MANUAL_CONTROL messages From 9005b4307f9b2c4055d911f1f21a822e166983a6 Mon Sep 17 00:00:00 2001 From: Rafael Araujo Lehmkuhl Date: Thu, 2 Jul 2026 19:31:49 -0300 Subject: [PATCH 03/10] joystick: prompt to pick the active joystick when several are connected Cockpit forwards input from every connected joystick into a single shared manual-control state, so multiple controllers fight each other. When more than one joystick is connected and none is disabled yet, open a dialog asking the user which one to keep and disable the others through the existing per-model disabling mechanism. Guard against re-opening while the dialog is already shown. --- src/App.vue | 2 + src/components/MultipleJoysticksDialog.vue | 127 +++++++++++++++++++++ src/stores/controller.ts | 39 +++++++ 3 files changed, 168 insertions(+) create mode 100644 src/components/MultipleJoysticksDialog.vue diff --git a/src/App.vue b/src/App.vue index cb97092120..a39b85d0ae 100644 --- a/src/App.vue +++ b/src/App.vue @@ -101,6 +101,7 @@ + @@ -128,6 +129,7 @@ import DataPrivacyModal from '@/components/DataPrivacyModal.vue' import ExternalFeaturesDiscoveryModal from '@/components/ExternalFeaturesDiscoveryModal.vue' import FloatingWrapper from '@/components/FloatingWrapper.vue' import GlassModal from '@/components/GlassModal.vue' +import MultipleJoysticksDialog from '@/components/MultipleJoysticksDialog.vue' import SkullAnimation from '@/components/SkullAnimation.vue' import SnackbarContainer from '@/components/SnackbarContainer.vue' import Tutorial from '@/components/Tutorial.vue' diff --git a/src/components/MultipleJoysticksDialog.vue b/src/components/MultipleJoysticksDialog.vue new file mode 100644 index 0000000000..97fb183908 --- /dev/null +++ b/src/components/MultipleJoysticksDialog.vue @@ -0,0 +1,127 @@ + + + + + diff --git a/src/stores/controller.ts b/src/stores/controller.ts index 6e95d3e765..1d86f23fdc 100644 --- a/src/stores/controller.ts +++ b/src/stores/controller.ts @@ -109,6 +109,8 @@ export const useControllerStore = defineStore('controller', () => { const currentMainJoystick = ref(undefined) + const multipleJoysticksDialogOpen = ref(false) + // Confirmation per joystick action required currently is only available for cockpit actions const actionsJoystickConfirmRequired = useBlueOsStorage( 'cockpit-actions-joystick-confirm-required', @@ -242,6 +244,40 @@ export const useControllerStore = defineStore('controller', () => { joystickCalibrationOptions.value[currentMainJoystick.value.model] = newCalibration } } + + promptToSelectActiveJoystickIfNeeded() + } + + // Cockpit can only use one joystick at a time. When more than one is connected and none has been disabled yet, + // ask the user which one to keep and disable the others through the regular per-model disabling mechanism. + const promptToSelectActiveJoystickIfNeeded = (): void => { + if (multipleJoysticksDialogOpen.value) return + + const connectedJoysticks = Array.from(joysticks.value.values()) + const noneDisabled = connectedJoysticks.every((j) => !disabledJoysticks.value.includes(j.model)) + const distinctModels = [...new Set(connectedJoysticks.map((j) => j.model))] + + if (connectedJoysticks.length < 2 || distinctModels.length < 2 || !noneDisabled) return + + multipleJoysticksDialogOpen.value = true + logUserAction('Opened the multiple-joysticks selection dialog') + } + + // Keep the chosen joystick model active and disable every other connected model, then close the dialog. + const selectActiveJoystick = (model: JoystickModel): void => { + const distinctModels = [...new Set(Array.from(joysticks.value.values()).map((j) => j.model))] + distinctModels + .filter((otherModel) => otherModel !== model) + .forEach((otherModel) => { + if (!disabledJoysticks.value.includes(otherModel)) disabledJoysticks.value.push(otherModel) + }) + logUserAction(`Selected '${model}' as the active joystick and disabled the others`) + multipleJoysticksDialogOpen.value = false + } + + const dismissMultipleJoysticksDialog = (): void => { + logUserAction('Dismissed the multiple-joysticks selection dialog without selecting') + multipleJoysticksDialogOpen.value = false } // Disable joystick forwarding if the window/tab is not visible (except on Electron) @@ -520,6 +556,9 @@ export const useControllerStore = defineStore('controller', () => { joystickCalibrationOptions, currentMainJoystick, disabledJoysticks, + multipleJoysticksDialogOpen, + selectActiveJoystick, + dismissMultipleJoysticksDialog, checkForOtherManualControlSources, } }) From a74d58a4cc8b1ef1bd53edbec5fd74b8b266dab8 Mon Sep 17 00:00:00 2001 From: Rafael Araujo Lehmkuhl Date: Thu, 2 Jul 2026 19:32:00 -0300 Subject: [PATCH 04/10] joystick: warn when the only connected joystick is disabled When a single joystick is connected but its model is in the disabled list, its input is silently dropped and there is no visible hint about why. Show a persistent snackbar pointing the user to the joystick configuration page to re-enable it, and dismiss it automatically once the condition clears. --- src/stores/controller.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/stores/controller.ts b/src/stores/controller.ts index 1d86f23fdc..649edc7b1f 100644 --- a/src/stores/controller.ts +++ b/src/stores/controller.ts @@ -7,6 +7,7 @@ import { defaultJoystickCalibration } from '@/assets/defaults' import { blankMapping } from '@/assets/joystick-profiles' import { useInteractionDialog } from '@/composables/interactionDialog' import { useBlueOsStorage } from '@/composables/settingsSyncer' +import { closeSnackbar, openSnackbar } from '@/composables/snackbar' import { checkForOtherManualControlSources } from '@/libs/blueos' import { joystickCalibrationOptionsKey, @@ -280,6 +281,31 @@ export const useControllerStore = defineStore('controller', () => { multipleJoysticksDialogOpen.value = false } + // Warn the user when the single connected joystick is disabled, since its input is silently dropped and the + // situation is easy to miss (the setting persists and syncs through the vehicle). + const singleConnectedJoystickIsDisabled = computed(() => { + const connectedJoysticks = Array.from(joysticks.value.values()) + return connectedJoysticks.length === 1 && disabledJoysticks.value.includes(connectedJoysticks[0].model) + }) + + let disabledJoystickSnackbarId: number | null = null + watch( + singleConnectedJoystickIsDisabled, + (isDisabled) => { + if (isDisabled && disabledJoystickSnackbarId === null) { + disabledJoystickSnackbarId = openSnackbar({ + message: 'A joystick is connected but disabled. Go to the joystick configuration page to enable it back.', + variant: 'warning', + persistent: true, + }) + } else if (!isDisabled && disabledJoystickSnackbarId !== null) { + closeSnackbar(disabledJoystickSnackbarId) + disabledJoystickSnackbarId = null + } + }, + { immediate: true } + ) + // Disable joystick forwarding if the window/tab is not visible (except on Electron) const windowVisibility = useDocumentVisibility() watch(windowVisibility, (value) => { From 6842c0431ad837a217b8148206b6f6faf12f872a Mon Sep 17 00:00:00 2001 From: Rafael Araujo Lehmkuhl Date: Thu, 2 Jul 2026 20:07:28 -0300 Subject: [PATCH 05/10] joystick: time out the other-GCS check so forwarding isn't stuck disabled checkForOtherManualControlSources awaited plain fetches with no timeout, so a reachable-but-unresponsive BlueOS could keep joystick forwarding disabled indefinitely while the connection handler waited. Bound each request with a 2s AbortSignal timeout; on timeout it is handled like any other failure and falls through to enabling forwarding. --- src/libs/blueos.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/libs/blueos.ts b/src/libs/blueos.ts index 92fb4202d4..73c8b87259 100644 --- a/src/libs/blueos.ts +++ b/src/libs/blueos.ts @@ -418,11 +418,15 @@ export const checkForOtherManualControlSources = async (vehicleAddress: string): // Try both available manual control / joystick protocols const messageNames = ['MANUAL_CONTROL', 'RC_CHANNELS_OVERRIDE'] + // Bound each request so a slow or unresponsive BlueOS doesn't keep the joystick disabled indefinitely. On + // timeout the request throws and is handled like any other failure, falling through to "no other source found". + const requestTimeout = 2000 + for (const componentId of componentIds) { for (const messageName of messageNames) { try { const endpoint = `${protocol}//${vehicleAddress}:6040/v1/mavlink/vehicles/255/components/${componentId}/messages/${messageName}` - const response = await fetch(endpoint) + const response = await fetch(endpoint, { signal: AbortSignal.timeout(requestTimeout) }) if (!response.ok) continue From da8b730bddefd01cc189b3297802616f6cf4fb99 Mon Sep 17 00:00:00 2001 From: Rafael Araujo Lehmkuhl Date: Thu, 2 Jul 2026 20:16:57 -0300 Subject: [PATCH 06/10] joystick: fix strange spacing on the joystick configuration page --- src/views/ConfigurationJoystickView.vue | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/views/ConfigurationJoystickView.vue b/src/views/ConfigurationJoystickView.vue index 7a8383aeac..e6d6cf55b2 100644 --- a/src/views/ConfigurationJoystickView.vue +++ b/src/views/ConfigurationJoystickView.vue @@ -48,7 +48,7 @@