Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Widgets: add support for Widget discovery from BlueOS #1499

Merged
merged 2 commits into from
Dec 18, 2024
Merged
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
86 changes: 72 additions & 14 deletions src/components/EditMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -472,7 +472,7 @@
<v-btn
type="flat"
class="bg-[#FFFFFF33] text-white w-[95%]"
@click="store.addWidget(WidgetType.CustomWidgetBase, store.currentView)"
@click="store.addWidget(makeNewWidget(WidgetType.CustomWidgetBase), store.currentView)"
>Add widget base
</v-btn>
</div>
Expand All @@ -485,27 +485,22 @@
class="flex items-center justify-between w-full h-full gap-3 overflow-x-auto text-white -mb-1 pr-2 cursor-pointer"
>
<div
v-for="widgetType in availableWidgetTypes"
:key="widgetType"
v-for="widget in allAvailableWidgets"
:key="widget.name"
class="flex flex-col items-center justify-between rounded-md bg-[#273842] hover:brightness-125 h-[90%] aspect-square cursor-pointer elevation-4"
draggable="true"
@dragstart="onRegularWidgetDragStart"
@dragend="onRegularWidgetDragEnd(widgetType)"
@dragend="onRegularWidgetDragEnd(widget)"
>
<v-tooltip text="Drag to add" location="top" theme="light">
<template #activator="{ props: tooltipProps }">
<div />
<img
v-bind="tooltipProps"
:src="widgetImages[widgetType]"
alt="widget-icon"
class="p-4 max-h-[75%] max-w-[95%]"
/>
<img v-bind="tooltipProps" :src="widget.icon" alt="widget-icon" class="p-4 max-h-[75%] max-w-[95%]" />
<div
class="flex items-center justify-center w-full p-1 transition-all bg-[#3B78A8] rounded-b-md text-white"
>
<span class="whitespace-normal text-center">{{
widgetType.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, (str) => str.toUpperCase())
widget.name.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, (str) => str.toUpperCase())
}}</span>
</div>
</template>
Expand Down Expand Up @@ -648,6 +643,7 @@ import URLVideoPlayerImg from '@/assets/widgets/URLVideoPlayer.png'
import VideoPlayerImg from '@/assets/widgets/VideoPlayer.png'
import VirtualHorizonImg from '@/assets/widgets/VirtualHorizon.png'
import { useInteractionDialog } from '@/composables/interactionDialog'
import { getWidgetsFromBlueOS } from '@/libs/blueos'
import { MavType } from '@/libs/connection/m2r/messages/mavlink2rest-enum'
import { isHorizontalScroll } from '@/libs/utils'
import { useAppInterfaceStore } from '@/stores/appInterface'
Expand All @@ -657,6 +653,8 @@ import {
type View,
type Widget,
CustomWidgetElementType,
ExternalWidgetSetupInfo,
InternalWidgetSetupInfo,
MiniWidgetType,
WidgetType,
} from '@/types/widgets'
Expand Down Expand Up @@ -685,6 +683,8 @@ const toggleDial = (): void => {

const forceUpdate = ref(0)

const ExternalWidgetSetupInfos = ref<ExternalWidgetSetupInfo[]>([])

watch(
() => store.currentView.widgets,
() => {
Expand Down Expand Up @@ -712,7 +712,60 @@ watch(
}
)

const availableWidgetTypes = computed(() => Object.values(WidgetType))
const findUniqueName = (name: string): string => {
let newName = name
let i = 1
const existingNames = store.currentView.widgets.map((widget) => widget.name)
while (existingNames.includes(newName)) {
newName = `${name} ${i}`
i++
}
return newName
}
/*
* Makes a new widget with an unique name
*/
const makeNewWidget = (widget: WidgetType, name?: string, options?: Record<string, any>): InternalWidgetSetupInfo => {
const newName = name || widget
return {
name: findUniqueName(newName),
component: widget,
options: options || {},
}
}

const makeWidgetUnique = (widget: InternalWidgetSetupInfo): InternalWidgetSetupInfo => {
return {
...widget,
name: findUniqueName(widget.name),
}
}

const availableInternalWidgets = computed(() =>
Object.values(WidgetType).map((widgetType) => {
return {
component: widgetType,
name: widgetType,
icon: widgetImages[widgetType] as string,
options: {},
}
})
)

const allAvailableWidgets = computed(() => {
return [
...ExternalWidgetSetupInfos.value.map((widget) => ({
component: WidgetType.IFrame,
icon: widget.iframe_icon,
name: widget.name,
options: {
source: widget.iframe_url,
},
})),
...availableInternalWidgets.value,
]
})

const availableMiniWidgetTypes = computed(() =>
Object.values(MiniWidgetType).map((widgetType) => ({
component: widgetType,
Expand Down Expand Up @@ -925,6 +978,10 @@ const miniWidgetsContainerOptions = ref<UseDraggableOptions>({
})
useDraggable(availableMiniWidgetsContainer, availableMiniWidgetTypes, miniWidgetsContainerOptions)

const getExternalWidgetSetupInfos = async (): Promise<void> => {
ExternalWidgetSetupInfos.value = await getWidgetsFromBlueOS()
}

// @ts-ignore: Documentation is not clear on what generic should be passed to 'UseDraggableOptions'
const customWidgetElementContainerOptions = ref<UseDraggableOptions>({
animation: '150',
Expand All @@ -938,6 +995,7 @@ useDraggable(
)

onMounted(() => {
getExternalWidgetSetupInfos()
const widgetContainers = [
availableWidgetsContainer.value,
availableMiniWidgetsContainer.value,
Expand Down Expand Up @@ -989,8 +1047,8 @@ const onRegularWidgetDragStart = (event: DragEvent): void => {
}
}

const onRegularWidgetDragEnd = (widgetType: WidgetType): void => {
store.addWidget(widgetType, store.currentView)
const onRegularWidgetDragEnd = (widget: InternalWidgetSetupInfo): void => {
store.addWidget(makeWidgetUnique(widget), store.currentView)

const widgetCards = document.querySelectorAll('[draggable="true"]')
widgetCards.forEach((card) => {
Expand Down
61 changes: 61 additions & 0 deletions src/libs/blueos.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,26 @@
import ky, { HTTPError } from 'ky'

import { useMainVehicleStore } from '@/stores/mainVehicle'
import { ExternalWidgetSetupInfo } from '@/types/widgets'

/**
* Cockpits extra json format. Taken from extensions in BlueOS and (eventually) other places
*/
interface ExtrasJson {
/**
* The version of the cockpit API that the extra json is compatible with
*/
target_cockpit_api_version: string
/**
* The target system that the extra json is compatible with, in our case, "cockpit"
*/
target_system: string
/**
* A list of widgets that the extra json contains. src/types/widgets.ts
*/
widgets: ExternalWidgetSetupInfo[]
}

export const NoPathInBlueOsErrorName = 'NoPathInBlueOS'

const defaultTimeout = 10000
Expand Down Expand Up @@ -30,6 +51,46 @@ export const getKeyDataFromCockpitVehicleStorage = async (
return await getBagOfHoldingFromVehicle(vehicleAddress, `cockpit/${storageKey}`)
}

export const getWidgetsFromBlueOS = async (): Promise<ExternalWidgetSetupInfo[]> => {
const vehicleStore = useMainVehicleStore()

// Wait until we have a global address
while (vehicleStore.globalAddress === undefined) {
await new Promise((r) => setTimeout(r, 1000))
}
const options = { timeout: defaultTimeout, retry: 0 }
const services = (await ky
.get(`http://${vehicleStore.globalAddress}/helper/v1.0/web_services`, options)
.json()) as Record<string, any>
// first we gather all the extra jsons with the cockpit key
const extraWidgets = await services.reduce(
async (accPromise: Promise<ExternalWidgetSetupInfo[]>, service: Record<string, any>) => {
const acc = await accPromise
const worksInRelativePaths = service.metadata?.works_in_relative_paths
if (service.metadata?.extras?.cockpit === undefined) {
return acc
}
const baseUrl = worksInRelativePaths
? `http://${vehicleStore.globalAddress}/extensionv2/${service.metadata.sanitized_name}`
: `http://${vehicleStore.globalAddress}:${service.port}`
const fullUrl = baseUrl + service.metadata?.extras?.cockpit

const extraJson: ExtrasJson = await ky.get(fullUrl, options).json()
const widgets: ExternalWidgetSetupInfo[] = extraJson.widgets.map((widget) => {
return {
...widget,
iframe_url: baseUrl + widget.iframe_url,
iframe_icon: baseUrl + widget.iframe_icon,
}
})
return acc.concat(widgets)
},
Promise.resolve([] as ExternalWidgetSetupInfo[])
)

return extraWidgets
}

export const setBagOfHoldingOnVehicle = async (
vehicleAddress: string,
bagName: string,
Expand Down
19 changes: 10 additions & 9 deletions src/stores/widgetManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
type Widget,
CustomWidgetElement,
CustomWidgetElementContainer,
InternalWidgetSetupInfo,
MiniWidgetManagerVars,
validateProfile,
validateView,
Expand Down Expand Up @@ -555,22 +556,22 @@ export const useWidgetManagerStore = defineStore('widget-manager', () => {

/**
* Add widget with given type to given view
* @param { WidgetType } widgetType - Type of the widget
* @param { WidgetType } widget - Type of the widget
* @param { View } view - View
*/
function addWidget(widgetType: WidgetType, view: View): void {
function addWidget(widget: InternalWidgetSetupInfo, view: View): void {
const widgetHash = uuid4()

const widget = {
const newWidget = {
hash: widgetHash,
name: widgetType,
component: widgetType,
name: widget.name,
component: widget.component,
position: { x: 0.4, y: 0.32 },
size: { width: 0.2, height: 0.36 },
options: {},
options: widget.options,
}

if (widgetType === WidgetType.CustomWidgetBase) {
if (widget.component === WidgetType.CustomWidgetBase) {
widget.options = {
elementContainers: defaultCustomWidgetContainers,
columns: 1,
Expand All @@ -581,8 +582,8 @@ export const useWidgetManagerStore = defineStore('widget-manager', () => {
}
}

view.widgets.unshift(widget)
Object.assign(widgetManagerVars(widget.hash), {
view.widgets.unshift(newWidget)
Object.assign(widgetManagerVars(newWidget.hash), {
...defaultWidgetManagerVars,
...{ allowMoving: true },
})
Expand Down
41 changes: 41 additions & 0 deletions src/types/widgets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,47 @@ import { CockpitAction } from '@/libs/joystick/protocols/cockpit-actions'

import type { Point2D, SizeRect2D } from './general'

/**
* Widget configuration object as received from BlueOS or another external source
*/
export interface ExternalWidgetSetupInfo {
/**
* Name of the widget, this is displayed on edit mode widget browser
*/
name: string
/**
* The URL at which the widget is located
* This is expected to be an absolute url
*/
iframe_url: string

/**
* The icon of the widget, this is displayed on the widget browser
*/
iframe_icon: string
}

/**
* Internal data used for setting up a new widget. This includes WidgetType, a custom name, options, and icon
*/ export interface InternalWidgetSetupInfo {
/**
* Widget type
*/
component: WidgetType
/**
* Widget name, this will be displayed on edit mode
*/
name: string
/**
* Widget options, this is the configuration that will be passed to the widget when it is created
*/
options: Record<string, unknown>
/**
* Widget icon, this is the icon that will be displayed on the widget browser
*/
icon: string
}

/**
* Available components to be used in the Widget system
* The enum value is equal to the component's filename, without the '.vue' extension
Expand Down
Loading