diff --git a/src/App.vue b/src/App.vue index 9f4d4e22a..fc19580ca 100644 --- a/src/App.vue +++ b/src/App.vue @@ -343,6 +343,7 @@ import { useAppInterfaceStore } from './stores/appInterface' import { useMainVehicleStore } from './stores/mainVehicle' import { useWidgetManagerStore } from './stores/widgetManager' import { ConfigComponent } from './types/general' +import ConfigurationActionsView from './views/ConfigurationActionsView.vue' import ConfigurationAlertsView from './views/ConfigurationAlertsView.vue' import ConfigurationDevelopmentView from './views/ConfigurationDevelopmentView.vue' import ConfigurationGeneralView from './views/ConfigurationGeneralView.vue' @@ -410,6 +411,11 @@ const configMenu = [ title: 'Mission', component: markRaw(ConfigurationMissionView) as ConfigComponent, }, + { + icon: 'mdi-run-fast', + title: 'Actions', + component: markRaw(ConfigurationActionsView) as ConfigComponent, + }, ] watch( diff --git a/src/libs/actions/data-lake.ts b/src/libs/actions/data-lake.ts new file mode 100644 index 000000000..0cc437574 --- /dev/null +++ b/src/libs/actions/data-lake.ts @@ -0,0 +1,78 @@ +/** + * A variable to be used on a Cockpit action + * @param { string } id - The id of the variable + * @param { string } name - The name of the variable + * @param { 'string' | 'number' | 'boolean' } type - The type of the variable (string, number or boolean) + * @param { string } description - What the variable does or means + */ +class CockpitActionVariable { + id: string + name: string + type: 'string' | 'number' | 'boolean' + description?: string + // eslint-disable-next-line jsdoc/require-jsdoc + constructor(id: string, name: string, type: 'string' | 'number' | 'boolean', description?: string) { + this.id = id + this.name = name + this.type = type + this.description = description + } +} + +const cockpitActionVariableInfo: Record = {} +export const cockpitActionVariableData: Record = {} +const cockpitActionVariableListeners: Record void)[]> = {} + +export const getAllCockpitActionVariablesInfo = (): Record => { + return cockpitActionVariableInfo +} + +export const getCockpitActionVariableInfo = (id: string): CockpitActionVariable | undefined => { + return cockpitActionVariableInfo[id] +} + +export const createCockpitActionVariable = (variable: CockpitActionVariable): void => { + if (cockpitActionVariableInfo[variable.id]) { + throw new Error(`Cockpit action variable with id '${variable.id}' already exists. Update it instead.`) + } + cockpitActionVariableInfo[variable.id] = variable +} + +export const updateCockpitActionVariableInfo = (variable: CockpitActionVariable): void => { + if (!cockpitActionVariableInfo[variable.id]) { + throw new Error(`Cockpit action variable with id '${variable.id}' does not exist. Create it first.`) + } + cockpitActionVariableInfo[variable.id] = variable +} + +export const getCockpitActionVariableData = (id: string): string | number | boolean | undefined => { + return cockpitActionVariableData[id] +} + +export const setCockpitActionVariableData = (id: string, data: string | number | boolean): void => { + cockpitActionVariableData[id] = data + notifyCockpitActionVariableListeners(id) +} + +export const deleteCockpitActionVariable = (id: string): void => { + delete cockpitActionVariableInfo[id] + delete cockpitActionVariableData[id] +} + +export const listenCockpitActionVariable = (id: string, listener: (value: string | number | boolean) => void): void => { + if (!cockpitActionVariableListeners[id]) { + cockpitActionVariableListeners[id] = [] + } + cockpitActionVariableListeners[id].push(listener) +} + +export const unlistenCockpitActionVariable = (id: string): void => { + delete cockpitActionVariableListeners[id] +} + +const notifyCockpitActionVariableListeners = (id: string): void => { + if (cockpitActionVariableListeners[id]) { + const value = cockpitActionVariableData[id] + cockpitActionVariableListeners[id].forEach((listener) => listener(value)) + } +} diff --git a/src/libs/actions/http-request.ts b/src/libs/actions/http-request.ts new file mode 100644 index 000000000..a6d02f6e0 --- /dev/null +++ b/src/libs/actions/http-request.ts @@ -0,0 +1,160 @@ +import { + availableCockpitActions, + CockpitAction, + CockpitActionsFunction, + deleteAction, + registerActionCallback, + registerNewAction, +} from '../joystick/protocols/cockpit-actions' +import { getCockpitActionVariableData } from './data-lake' + +const httpRequestActionIdPrefix = 'http-request-action' + +/** + * The types of HTTP methods that can be used. + */ +export enum HttpRequestMethod { + GET = 'GET', + POST = 'POST', + PUT = 'PUT', + DELETE = 'DELETE', + PATCH = 'PATCH', +} +export const availableHttpRequestMethods: HttpRequestMethod[] = Object.values(HttpRequestMethod) + +export type HttpRequestActionConfig = { + /** + * The name of the action. + */ + name: string + /** + * The URL to send the request to. + */ + url: string + /** + * The HTTP method to use. + */ + method: HttpRequestMethod + /** + * The headers to send with the request. + */ + headers: Record + /** + * The URL parameters to send with the request. + */ + urlParams: Record + /** + * The body of the request. + */ + body: string +} + +let registeredHttpRequestActionConfigs: Record = {} + +export const registerHttpRequestActionConfig = (action: HttpRequestActionConfig): void => { + const id = `${httpRequestActionIdPrefix} (${action.name})` + registeredHttpRequestActionConfigs[id] = action + saveHttpRequestActionConfigs() + updateCockpitActions() +} + +export const getHttpRequestActionConfig = (id: string): HttpRequestActionConfig | undefined => { + return registeredHttpRequestActionConfigs[id] +} + +export const getAllHttpRequestActionConfigs = (): Record => { + return registeredHttpRequestActionConfigs +} + +export const deleteHttpRequestActionConfig = (id: string): void => { + delete registeredHttpRequestActionConfigs[id] + saveHttpRequestActionConfigs() + updateCockpitActions() +} + +export const updateHttpRequestActionConfig = (id: string, updatedAction: HttpRequestActionConfig): void => { + registeredHttpRequestActionConfigs[id] = updatedAction + saveHttpRequestActionConfigs() + updateCockpitActions() +} + +export const updateCockpitActions = (): void => { + Object.entries(availableCockpitActions).forEach(([id]) => { + if (id.includes(httpRequestActionIdPrefix)) { + deleteAction(id as CockpitActionsFunction) + } + }) + + const httpResquestActions = getAllHttpRequestActionConfigs() + for (const [id, action] of Object.entries(httpResquestActions)) { + try { + const cockpitAction = new CockpitAction(id as CockpitActionsFunction, action.name) + registerNewAction(cockpitAction) + registerActionCallback(cockpitAction, getHttpRequestActionCallback(id)) + } catch (error) { + console.error(`Error registering action ${id}: ${error}`) + } + } +} + +export const loadHttpRequestActionConfigs = (): void => { + const savedActions = localStorage.getItem('cockpit-http-request-actions') + if (savedActions) { + registeredHttpRequestActionConfigs = JSON.parse(savedActions) + } +} + +export const saveHttpRequestActionConfigs = (): void => { + localStorage.setItem('cockpit-http-request-actions', JSON.stringify(registeredHttpRequestActionConfigs)) +} + +export type HttpRequestActionCallback = () => void + +export const getHttpRequestActionCallback = (id: string): HttpRequestActionCallback => { + const action = getHttpRequestActionConfig(id) + if (!action) { + throw new Error(`Action with id ${id} not found.`) + } + + let parsedBody = action.body + const parsedUrlParams = action.urlParams + + const cockpitInputsInBody = action.body.match(/{{\s*([^{}\s]+)\s*}}/g) + if (cockpitInputsInBody) { + for (const input of cockpitInputsInBody) { + const parsedInput = input.replace('{{', '').replace('}}', '').trim() + const inputData = getCockpitActionVariableData(parsedInput) + if (inputData) { + parsedBody = parsedBody.replace(input, inputData.toString()) + } + } + } + + const cockpitInputsInUrlParams = Object.entries(action.urlParams).filter( + ([, value]) => typeof value === 'string' && value.startsWith('{{') && value.endsWith('}}') + ) + if (cockpitInputsInUrlParams) { + for (const [key, value] of cockpitInputsInUrlParams) { + const parsedInput = value.replace('{{', '').replace('}}', '').trim() + const inputData = getCockpitActionVariableData(parsedInput) + if (inputData) { + parsedUrlParams[key] = inputData.toString() + } + } + } + + const url = new URL(action.url) + + url.search = new URLSearchParams(parsedUrlParams).toString() + + return () => { + fetch(url, { + method: action.method, + headers: action.headers, + body: action.method === HttpRequestMethod.GET ? undefined : parsedBody, + }) + } +} + +loadHttpRequestActionConfigs() +updateCockpitActions() diff --git a/src/libs/joystick/protocols.ts b/src/libs/joystick/protocols.ts index e24ae286f..0ef876ec1 100644 --- a/src/libs/joystick/protocols.ts +++ b/src/libs/joystick/protocols.ts @@ -7,14 +7,15 @@ import { } from './protocols/mavlink-manual-control' import { modifierKeyActions, otherAvailableActions } from './protocols/other' -export const allAvailableAxes: ProtocolAction[] = [ - ...Object.values(mavlinkManualControlAxes), - ...Object.values(otherAvailableActions), -] +export const allAvailableAxes = (): ProtocolAction[] => { + return [...Object.values(mavlinkManualControlAxes), ...Object.values(otherAvailableActions)] +} -export const allAvailableButtons: ProtocolAction[] = [ - ...Object.values(availableCockpitActions), - ...Object.values(availableMavlinkManualControlButtonFunctions), - ...Object.values(otherAvailableActions), - ...Object.values(modifierKeyActions), -] +export const allAvailableButtons = (): ProtocolAction[] => { + return [ + ...Object.values(availableCockpitActions), + ...Object.values(availableMavlinkManualControlButtonFunctions), + ...Object.values(otherAvailableActions), + ...Object.values(modifierKeyActions), + ] +} diff --git a/src/libs/joystick/protocols/cockpit-actions.ts b/src/libs/joystick/protocols/cockpit-actions.ts index c6f97e73f..1163f997e 100644 --- a/src/libs/joystick/protocols/cockpit-actions.ts +++ b/src/libs/joystick/protocols/cockpit-actions.ts @@ -1,10 +1,7 @@ /* eslint-disable vue/max-len */ /* eslint-disable prettier/prettier */ /* eslint-disable max-len */ -import { v4 as uuid4 } from 'uuid' - -import { slideToConfirm } from '@/libs/slide-to-confirm' -import { type JoystickProtocolActionsMapping,type JoystickState, type ProtocolAction, JoystickProtocol } from '@/types/joystick' +import { type ProtocolAction,JoystickProtocol } from '@/types/joystick' /** * Possible functions in the MAVLink `MANUAL_CONTROL` message protocol @@ -37,8 +34,8 @@ export class CockpitAction implements ProtocolAction { } } -// Available actions -export const availableCockpitActions: { [key in CockpitActionsFunction]: CockpitAction } = { +// Predefined actions +export const predefinedCockpitActions: { [key in CockpitActionsFunction]: CockpitAction } = { [CockpitActionsFunction.go_to_next_view]: new CockpitAction(CockpitActionsFunction.go_to_next_view, 'Go to next view'), [CockpitActionsFunction.go_to_previous_view]: new CockpitAction(CockpitActionsFunction.go_to_previous_view, 'Go to previous view'), [CockpitActionsFunction.toggle_full_screen]: new CockpitAction(CockpitActionsFunction.toggle_full_screen, 'Toggle full screen'), @@ -67,54 +64,68 @@ interface CallbackEntry { callback: CockpitActionCallback } -// @ts-ignore: Typescript does not get that we are initializing the object dinamically -const actionsCallbacks: { [id in string]: CallbackEntry } = {} - -export const registerActionCallback = (action: CockpitAction, callback: CockpitActionCallback): string => { - const id = uuid4() - actionsCallbacks[id] = { action, callback } - return id -} -export const unregisterActionCallback = (id: string): void => { - delete actionsCallbacks[id] -} - /** * Responsible for routing cockpit actions */ export class CockpitActionsManager { - joystickState: JoystickState - currentActionsMapping: JoystickProtocolActionsMapping - activeButtonsActions: ProtocolAction[] - actionsJoystickConfirmRequired: Record - - updateControllerData = ( - state: JoystickState, - protocolActionsMapping: JoystickProtocolActionsMapping, - activeButtonsActions: ProtocolAction[], - actionsJoystickConfirmRequired: Record - ): void => { - this.joystickState = state - this.currentActionsMapping = protocolActionsMapping - this.activeButtonsActions = activeButtonsActions - this.actionsJoystickConfirmRequired = actionsJoystickConfirmRequired + availableActions: { [key in CockpitActionsFunction]: CockpitAction } = { ...predefinedCockpitActions } + actionsCallbacks: Record = {} + + registerNewAction = (action: CockpitAction): void => { + this.availableActions[action.id] = action } - sendCockpitActions = (): void => { - if (!this.joystickState || !this.currentActionsMapping || !this.activeButtonsActions) return - - const actionsToCallback = this.activeButtonsActions.filter((a) => a.protocol === JoystickProtocol.CockpitAction) - Object.values(actionsCallbacks).forEach((entry) => { - if (actionsToCallback.map((a) => a.id).includes(entry.action.id)) { - console.log('Sending action', entry.action.name, '/ Require confirm:', this.actionsJoystickConfirmRequired[entry.action.id]) - slideToConfirm( - entry.callback, - { - command: entry.action.name, - }, - !this.actionsJoystickConfirmRequired[entry.action.id] - ) - } - }) + unregisterAction = (id: CockpitActionsFunction): void => { + delete this.availableActions[id] + } + + registerActionCallback = (action: CockpitAction, callback: CockpitActionCallback): string => { + this.actionsCallbacks[action.id] = { action, callback } + return action.id + } + + unregisterActionCallback = (id: string): void => { + delete this.actionsCallbacks[id] + } + + executeActionCallback = (id: string): void => { + const callbackEntry = this.actionsCallbacks[id] + if (!callbackEntry) { + console.error(`Callback for action ${id} not found.`) + return + } + + console.debug(`Executing action callback for action ${id}.`) + try { + callbackEntry.callback() + } catch (error) { + console.error(`Error executing action callback for action ${id}.`, error) + } } } + +export const cockpitActionsManager = new CockpitActionsManager() + +export const registerNewAction = (action: CockpitAction): void => { + cockpitActionsManager.registerNewAction(action) + console.debug(`Registered new action ${action.name} with id (${action.id}).`) +} + +export const deleteAction = (id: CockpitActionsFunction): void => { + cockpitActionsManager.unregisterAction(id) + console.debug(`Unregistered action with id (${id}).`) +} + +export const registerActionCallback = (action: CockpitAction, callback: CockpitActionCallback): string => { + return cockpitActionsManager.registerActionCallback(action, callback) +} + +export const unregisterActionCallback = (id: string): void => { + cockpitActionsManager.unregisterActionCallback(id) +} + +export const executeActionCallback = (id: string): void => { + cockpitActionsManager.executeActionCallback(id) +} + +export const availableCockpitActions = cockpitActionsManager.availableActions diff --git a/src/stores/controller.ts b/src/stores/controller.ts index 70f53c037..96b19a7d9 100644 --- a/src/stores/controller.ts +++ b/src/stores/controller.ts @@ -14,7 +14,9 @@ import { useBlueOsStorage } from '@/composables/settingsSyncer' import { MavType } from '@/libs/connection/m2r/messages/mavlink2rest-enum' import { type JoystickEvent, EventType, joystickManager, JoystickModel } from '@/libs/joystick/manager' import { allAvailableAxes, allAvailableButtons } from '@/libs/joystick/protocols' +import { CockpitActionsFunction, executeActionCallback } from '@/libs/joystick/protocols/cockpit-actions' import { modifierKeyActions, otherAvailableActions } from '@/libs/joystick/protocols/other' +import { slideToConfirm } from '@/libs/slide-to-confirm' import { Alert, AlertLevel } from '@/types/alert' import { type JoystickProtocolActionsMapping, @@ -47,8 +49,8 @@ export const useControllerStore = defineStore('controller', () => { const protocolMappings = useBlueOsStorage(protocolMappingsKey, cockpitStandardToProtocols) const protocolMappingIndex = useBlueOsStorage(protocolMappingIndexKey, 0) const cockpitStdMappings = useBlueOsStorage(cockpitStdMappingsKey, availableGamepadToCockpitMaps) - const availableAxesActions = allAvailableAxes - const availableButtonActions = allAvailableButtons + const availableAxesActions = ref(allAvailableAxes()) + const availableButtonActions = ref(allAvailableButtons()) const enableForwarding = ref(false) const holdLastInputWhenWindowHidden = useBlueOsStorage('cockpit-hold-last-joystick-input-when-window-hidden', false) const vehicleTypeProtocolMappingCorrespondency = useBlueOsStorage( @@ -374,6 +376,36 @@ export const useControllerStore = defineStore('controller', () => { } } + const actionsToCallFromJoystick = ref([]) + const addActionToCallFromJoystick = (actionId: CockpitActionsFunction): void => { + if (!actionsToCallFromJoystick.value.includes(actionId)) { + actionsToCallFromJoystick.value.push(actionId) + } + } + + registerControllerUpdateCallback((joystickState, actionsMapping, activeActions, actionsConfirmRequired) => { + if (!joystickState || !actionsMapping || !activeActions || !actionsConfirmRequired) { + return + } + + actionsToCallFromJoystick.value = [] + + const actionsToCallback = activeActions.filter((a) => a.protocol === JoystickProtocol.CockpitAction) + Object.values(actionsToCallback).forEach((a) => { + const callback = (): void => addActionToCallFromJoystick(a.id as CockpitActionsFunction) + slideToConfirm(callback, { command: a.name }, !actionsConfirmRequired[a.id]) + }) + + if (enableForwarding.value) { + actionsToCallFromJoystick.value.forEach((a) => executeActionCallback(a as CockpitActionsFunction)) + } + }) + + setInterval(() => { + availableButtonActions.value = allAvailableButtons() + availableAxesActions.value = allAvailableAxes() + }, 1000) + return { registerControllerUpdateCallback, enableForwarding, diff --git a/src/stores/mainVehicle.ts b/src/stores/mainVehicle.ts index 0b80a982d..6faa044a2 100644 --- a/src/stores/mainVehicle.ts +++ b/src/stores/mainVehicle.ts @@ -18,11 +18,7 @@ import { ConnectionManager } from '@/libs/connection/connection-manager' import type { Package } from '@/libs/connection/m2r/messages/mavlink2rest' import { MavAutopilot, MAVLinkType, MavType } from '@/libs/connection/m2r/messages/mavlink2rest-enum' import type { Message } from '@/libs/connection/m2r/messages/mavlink2rest-message' -import { - availableCockpitActions, - CockpitActionsManager, - registerActionCallback, -} from '@/libs/joystick/protocols/cockpit-actions' +import { availableCockpitActions, registerActionCallback } from '@/libs/joystick/protocols/cockpit-actions' import { MavlinkManualControlManager } from '@/libs/joystick/protocols/mavlink-manual-control' import type { ArduPilot } from '@/libs/vehicle/ardupilot/ardupilot' import { CustomMode } from '@/libs/vehicle/ardupilot/ardurover' @@ -523,9 +519,7 @@ export const useMainVehicleStore = defineStore('main-vehicle', () => { }) const mavlinkManualControlManager = new MavlinkManualControlManager() - const cockpitActionsManager = new CockpitActionsManager() controllerStore.registerControllerUpdateCallback(mavlinkManualControlManager.updateControllerData) - controllerStore.registerControllerUpdateCallback(cockpitActionsManager.updateControllerData) // Loop to send MAVLink Manual Control messages setInterval(() => { @@ -541,13 +535,6 @@ export const useMainVehicleStore = defineStore('main-vehicle', () => { }, 40) setInterval(() => sendGcsHeartbeat(), 1000) - // Loop to send Cockpit Action messages - setInterval(() => { - if (controllerStore.enableForwarding) { - cockpitActionsManager.sendCockpitActions() - } - }, 10) - return { arm, takeoff, diff --git a/src/views/ConfigurationActionsView.vue b/src/views/ConfigurationActionsView.vue new file mode 100644 index 000000000..171fea4f7 --- /dev/null +++ b/src/views/ConfigurationActionsView.vue @@ -0,0 +1,675 @@ + + + + +