Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@
<UpdateNotification v-if="isElectron()" />
<ArchitectureWarning v-if="isElectron()" />
<SnackbarContainer />
<MultipleJoysticksDialog />
<FloatingWrapper v-model="devStore.showConsole" title="Console">
<ConsoleViewer :logging-enabled="devStore.enableSystemLogging" />
</FloatingWrapper>
Expand Down Expand Up @@ -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'
Expand Down
127 changes: 127 additions & 0 deletions src/components/MultipleJoysticksDialog.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
<template>
<v-dialog
:model-value="controllerStore.multipleJoysticksDialogOpen"
:width="interfaceStore.isOnPhoneScreen ? '92vw' : '620px'"
@update:model-value="onDialogVisibilityChange"
>
<v-card class="main-dialog px-2 py-1 rounded-lg" :style="interfaceStore.globalGlassMenuStyles">
<v-card-title>
<div class="flex items-center justify-center gap-3 pt-3 pb-1 text-center">
<v-icon size="32" color="yellow">mdi-alert-rhombus</v-icon>
<span class="font-bold" :class="interfaceStore.isOnPhoneScreen ? 'text-[16px]' : 'text-[20px]'">
Multiple joysticks detected
</span>
</div>
</v-card-title>

<v-card-text class="pb-2">
<p class="text-center opacity-90 px-2 mb-1" :class="interfaceStore.isOnPhoneScreen ? 'text-xs' : 'text-sm'">
More than one joystick is connected and all of them are enabled, but Cockpit can only use one at a time.
</p>
<p class="text-center opacity-90 px-2 mb-5" :class="interfaceStore.isOnPhoneScreen ? 'text-xs' : 'text-sm'">
Choose which one to use. The others will be disabled, and you can re-enable them anytime on the joystick
configuration page.
</p>

<div class="flex flex-col gap-3 px-1 pb-1">
<button
v-for="option in joystickOptions"
:key="option.model"
class="joystick-option flex items-center gap-4 w-full px-4 py-3 rounded-lg text-left"
@click="selectJoystick(option.model)"
>
<v-icon size="28" class="opacity-90">mdi-gamepad-variant</v-icon>
<div class="flex flex-col">
<span class="text-[15px] font-semibold">{{ option.model }}</span>
<span class="text-xs opacity-60">{{ option.axesCount }} axes · {{ option.buttonsCount }} buttons</span>
</div>
<v-icon size="22" class="ml-auto opacity-70">mdi-chevron-right</v-icon>
</button>
</div>
</v-card-text>

<div class="flex justify-center w-full px-10">
<v-divider class="opacity-10 border-[#fafafa]" />
</div>

<v-card-actions>
<div class="flex w-full justify-end px-1 py-1">
<v-btn size="small" variant="text" @click="dismiss">Cancel</v-btn>
</div>
</v-card-actions>
</v-card>
</v-dialog>
</template>

<script setup lang="ts">
import { computed } from 'vue'

import { useAppInterfaceStore } from '@/stores/appInterface'
import { useControllerStore } from '@/stores/controller'
import { JoystickModel } from '@/types/joystick-model-defs'

const controllerStore = useControllerStore()
const interfaceStore = useAppInterfaceStore()

/**
* One selectable entry per distinct connected joystick model, with its input counts for context.
*/
const joystickOptions = computed(() => {
const optionsByModel = new Map<
JoystickModel,
{
/**
* Model of the connected joystick.
*/
model: JoystickModel
/**
* Number of axes the device reports.
*/
axesCount: number
/**
* Number of buttons the device reports.
*/
buttonsCount: number
}
>()
for (const joystick of controllerStore.joysticks.values()) {
if (optionsByModel.has(joystick.model)) continue
optionsByModel.set(joystick.model, {
model: joystick.model,
axesCount: joystick.gamepad.axes.length,
buttonsCount: joystick.gamepad.buttons.length,
})
}
return Array.from(optionsByModel.values())
})

const selectJoystick = (model: JoystickModel): void => {
controllerStore.selectActiveJoystick(model)
}

const dismiss = (): void => {
controllerStore.dismissMultipleJoysticksDialog()
}

const onDialogVisibilityChange = (value: boolean): void => {
if (!value) dismiss()
}
</script>

<style scoped>
.main-dialog {
box-shadow: 0px 4px 4px 0px #0000004c, 0px 8px 12px 6px #00000026;
}

.joystick-option {
border: 1px solid #ffffff22;
background-color: #ffffff12;
transition: background-color 0.15s ease, border-color 0.15s ease;
cursor: pointer;
}

.joystick-option:hover {
border-color: #ffffff55;
background-color: #ffffff22;
}
</style>
2 changes: 1 addition & 1 deletion src/components/joysticks/JoystickCalibration.vue
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
<span>Exponential Scaling</span>
</div>
</div>
<v-btn variant="text" class="text-blue-400" @click="openCalibrationModal"> Calibrate </v-btn>
<v-btn variant="text" class="text-white" @click="openCalibrationModal"> Calibrate </v-btn>
</div>
</div>
</div>
Expand Down
6 changes: 5 additions & 1 deletion src/libs/blueos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions src/libs/joystick/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
67 changes: 66 additions & 1 deletion src/stores/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -109,6 +110,8 @@ export const useControllerStore = defineStore('controller', () => {

const currentMainJoystick = ref<Joystick | undefined>(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',
Expand Down Expand Up @@ -176,7 +179,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
Expand Down Expand Up @@ -242,8 +245,67 @@ 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
}

// 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) => {
Expand Down Expand Up @@ -520,6 +582,9 @@ export const useControllerStore = defineStore('controller', () => {
joystickCalibrationOptions,
currentMainJoystick,
disabledJoysticks,
multipleJoysticksDialogOpen,
selectActiveJoystick,
dismissMultipleJoysticksDialog,
checkForOtherManualControlSources,
}
})
7 changes: 7 additions & 0 deletions src/types/joystick-model-defs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -26,6 +27,7 @@ export const JoystickMapVidPid: Map<string, JoystickModel> = new Map([
['054c:0ce6', JoystickModel.DualSense],
['054c:09cc', JoystickModel.DualShock4],
['045e:02ea', JoystickModel.XboxOne_Wired],
['045e:02ff', JoystickModel.XboxController_XInput],
['045e:02e0', JoystickModel.XboxOne_Wireless],
['045e:02fd', JoystickModel.XboxOneS_Bluetooth],
['045e:0b13', JoystickModel.XboxController_Bluetooth],
Expand Down Expand Up @@ -111,6 +113,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],
Expand Down
48 changes: 33 additions & 15 deletions src/views/ConfigurationJoystickView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
</div>
</template>
<template #content>
<div class="flex flex-col items-center h-[280px] overflow-auto">
<div class="flex flex-col items-center h-[200px] overflow-auto">
<div class="flex flex-col items-center">
<div
v-if="
Expand Down Expand Up @@ -162,12 +162,12 @@
Connect a joystick to see its visual layout and live input.
</div>
<div
v-for="[key, joystick] in controllerStore.joysticks"
v-for="[key, joystick] in joysticksEnabledFirst"
:key="key"
class="w-[95%] h-full mx-auto flex-centered flex-column position-relative"
>
<p class="text-md font-semibold -mt-8">{{ joystick.model }} controller</p>
<div class="flex items-center gap-2 -mb-8">
<p class="text-md font-semibold mt-4">{{ joystick.model }} controller</p>
<div class="flex items-center gap-2">
<v-switch
:model-value="!controllerStore.disabledJoysticks.includes(joystick.model)"
:label="controllerStore.disabledJoysticks.includes(joystick.model) ? 'Disabled' : 'Enabled'"
Expand All @@ -178,7 +178,7 @@
</div>
<div
v-if="showJoystickLayout"
class="flex flex-col items-center justify-center"
class="flex flex-col items-center justify-center -mt-8"
:class="interfaceStore.isOnSmallScreen ? 'w-[90%]' : 'w-[80%]'"
>
<JoystickPS
Expand Down Expand Up @@ -284,7 +284,7 @@
:key="key"
class="w-full flex-centered flex-column"
>
<span class="text-md font-semibold w-full text-center -mt-8">{{ joystick.model }} controller</span>
<span class="text-md font-semibold w-full text-center">{{ joystick.model }} controller</span>
<div class="flex items-center gap-2">
<v-switch
:model-value="!controllerStore.disabledJoysticks.includes(joystick.model)"
Expand Down Expand Up @@ -988,15 +988,33 @@ const updateButtonAction = (input: JoystickButtonInput, action: ProtocolAction):
openSnackbar({ message: `Button ${input.id} remapped to function '${action.name}'.`, variant: 'success' })
}

// Automatically set the current joystick when it changes for the first time
watch(controllerStore.joysticks, () => {
if (currentJoystick.value === undefined) {
if (controllerStore.joysticks.size <= 0) return
const firstEntry = controllerStore.joysticks.entries().next().value
if (firstEntry) {
currentJoystick.value = firstEntry[1]
}
}
// Picks the joystick previewed in the table/visual, preferring an enabled one so its live input is actually
// visible (a disabled joystick has its state events dropped, so it would look frozen).
const pickPreviewJoystick = (): Joystick | undefined => {
const connectedJoysticks = Array.from(controllerStore.joysticks.values())
if (connectedJoysticks.length === 0) return undefined
return connectedJoysticks.find((j) => !controllerStore.disabledJoysticks.includes(j.model)) ?? connectedJoysticks[0]
}

// Re-pick when there's no valid selection yet, or the selected joystick got disconnected or disabled.
watch(
[() => controllerStore.joysticks, () => controllerStore.disabledJoysticks],
() => {
const current = currentJoystick.value
const currentIsUsable =
current !== undefined &&
Array.from(controllerStore.joysticks.values()).includes(current) &&
!controllerStore.disabledJoysticks.includes(current.model)
if (!currentIsUsable) currentJoystick.value = pickPreviewJoystick()
},
{ immediate: true, deep: true }
)

// Enabled joysticks first, so the active one is shown at the top of the visual list.
const joysticksEnabledFirst = computed<[number, Joystick][]>(() => {
const disabledScore = (joystick: Joystick): number =>
controllerStore.disabledJoysticks.includes(joystick.model) ? 1 : 0
return Array.from(controllerStore.joysticks.entries()).sort(([, a], [, b]) => disabledScore(a) - disabledScore(b))
})

let lastModTabChange = new Date().getTime()
Expand Down
Loading