Skip to content

Commit

Permalink
Iframe: add widget api
Browse files Browse the repository at this point in the history
  • Loading branch information
Williangalvani committed Dec 16, 2024
1 parent fa20d62 commit 02bada7
Show file tree
Hide file tree
Showing 7 changed files with 180 additions and 13 deletions.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
"serve": "vite preview",
"test:ci": "vitest --coverage --run",
"test:unit": "vitest",
"typecheck": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false"
"typecheck": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false",
"build:lib": "BUILD_MODE=library vite build"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.4.0",
Expand Down
21 changes: 19 additions & 2 deletions src/components/widgets/IFrame.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
<teleport to=".widgets-view">
<iframe
v-show="iframe_loaded"
ref="iframe"
:src="widget.options.source"
:style="iframeStyle"
frameborder="0"
Expand Down Expand Up @@ -58,14 +59,16 @@ import { computed, defineProps, onBeforeMount, ref, toRefs, watch } from 'vue'
import { defaultBlueOsAddress } from '@/assets/defaults'
import Snackbar from '@/components/Snackbar.vue'
import { listenDataLakeVariable } from '@/libs/actions/data-lake'
import { isValidURL } from '@/libs/utils'
import { useAppInterfaceStore } from '@/stores/appInterface'
import { useWidgetManagerStore } from '@/stores/widgetManager'
import type { Widget } from '@/types/widgets'
const interfaceStore = useAppInterfaceStore()
const widgetStore = useWidgetManagerStore()
const iframe = ref()
const props = defineProps<{
/**
* Widget reference
Expand Down Expand Up @@ -97,10 +100,24 @@ const updateURL = (): void => {
}
onBeforeMount(() => {
window.addEventListener(
'message',
(event) => {
if (event.data.type !== 'cockpit:listenToDatalakeVariables') {
return
}
const { variable } = event.data
console.log('asked to listen to ', variable)
listenDataLakeVariable(variable, (value) => {
iframe.value.contentWindow.postMessage({ type: 'cockpit:datalakeVariable', variable, value }, '*')
})
},
false
)
if (Object.keys(widget.value.options).length !== 0) {
return
}
widget.value.options = {
source: defaultBlueOsAddress,
}
Expand Down
26 changes: 23 additions & 3 deletions src/libs/actions/data-lake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export const getDataLakeVariableInfo = (id: string): DataLakeVariable | undefine

export const createDataLakeVariable = (variable: DataLakeVariable, initialValue?: string | number | boolean): void => {
if (dataLakeVariableInfo[variable.id]) {
throw new Error(`Cockpit action variable with id '${variable.id}' already exists. Update it instead.`)
console.warn(`Cockpit action variable with id '${variable.id}' already exists. Update it instead.`)
}
dataLakeVariableInfo[variable.id] = variable
dataLakeVariableData[variable.id] = initialValue
Expand All @@ -52,8 +52,28 @@ export const getDataLakeVariableData = (id: string): string | number | boolean |
return dataLakeVariableData[id]
}

export const setDataLakeVariableData = (id: string, data: string | number | boolean): void => {
dataLakeVariableData[id] = data
export const setDataLakeVariableData = (id: string, data: object | string | number | boolean): void => {
// console.log(`Setting cockpit action variable ${id} to ${data}`)
const newData = data
if (data === null) {
return
}
if (dataLakeVariableData[id] === undefined) {
// console.warn(`Cockpit action variable with id '${id}' does not exist. Creating it.`)
const type_of_variable = typeof data
if (type_of_variable === 'object') {
// TODO: support strings
}
if (type_of_variable !== 'string' && type_of_variable !== 'number') {
// console.warn(`attempting to create a variable with type ${type_of_variable}. Skipping`)
return
}
createDataLakeVariable(new DataLakeVariable(id, id, typeof data))
}
if (newData === undefined || typeof newData === 'object') {
return
}
dataLakeVariableData[id] = newData
notifyDataLakeVariableListeners(id)
}

Expand Down
43 changes: 43 additions & 0 deletions src/libs/external-api/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { CallbackRateLimiter } from './callback-rate-limiter'

/**
* Current version of the Cockpit Widget API
*/
export const COCKPIT_WIDGET_API_VERSION = '0.0.0'

/**
* Listens to updates for a specific datalake variable.
* This function sets up a message listener that receives updates from the parent window
* and forwards them to the callback function, respecting the specified rate limit.
* @param {string} variable - The name of the datalake variable to listen to
* @param {Function} callback - The function to call when the variable is updated
* @param {number} maxRateHz - The maximum rate (in Hz) at which updates should be received. Default is 10 Hz
* @example
* ```typescript
* // Listen to updates at 5Hz
* listenToDatalakeVariable('temperature', (value) => {
* console.log('Temperature:', value);
* }, 5);
* ```
*/
export function listenToDatalakeVariable(variable: string, callback: (data: any) => void, maxRateHz = 10): void {
// Convert Hz to minimum interval in milliseconds
const minIntervalMs = 1000 / maxRateHz
const rateLimiter = new CallbackRateLimiter(minIntervalMs)

const message = {
type: 'cockpit:listenToDatalakeVariables',
variable: variable,
maxRateHz: maxRateHz,
}
window.parent.postMessage(message, '*')

window.addEventListener('message', function handler(event) {
if (event.data.type === 'cockpit:datalakeVariable' && event.data.variable === variable) {
// Only call callback if we haven't exceeded the rate limit
if (rateLimiter.canCall(variable)) {
callback(event.data.value)
}
}
})
}
19 changes: 13 additions & 6 deletions src/libs/vehicle/ardupilot/ardupilot.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { differenceInMilliseconds } from 'date-fns'
import { unit } from 'mathjs'

import { setDataLakeVariableData } from '@/libs/actions/data-lake'
import { sendMavlinkMessage } from '@/libs/communication/mavlink'
import type { MAVLinkMessageDictionary, Package, Type } from '@/libs/connection/m2r/messages/mavlink2rest'
import {
Expand Down Expand Up @@ -294,6 +295,12 @@ export abstract class ArduPilotVehicle<Modes> extends Vehicle.AbstractVehicle<Mo
return
}

const messageName = mavlink_message.message.type
Object.entries(mavlink_message.message).forEach(([key, value]) => {
const fullPath = `${messageName}/${key}`
setDataLakeVariableData(fullPath, value)
})

// Update our internal messages
this._messages.set(mavlink_message.message.type, { ...mavlink_message.message, epoch: new Date().getTime() })

Expand Down Expand Up @@ -523,19 +530,19 @@ export abstract class ArduPilotVehicle<Modes> extends Vehicle.AbstractVehicle<Mo

case MAVLinkType.NAMED_VALUE_FLOAT: {
const namedValueFloatMessage = mavlink_message.message as Message.NamedValueFloat
const messageName = namedValueFloatMessage.name.join('').replaceAll('\x00', '')
if (!this._availableGenericVariablesdMessagePaths.includes(messageName)) {
this._availableGenericVariablesdMessagePaths.push(messageName)
const msgName = namedValueFloatMessage.name.join('').replaceAll('\x00', '')
if (!this._availableGenericVariablesdMessagePaths.includes(msgName)) {
this._availableGenericVariablesdMessagePaths.push(msgName)
}
this._genericVariables[messageName] = namedValueFloatMessage.value
this._genericVariables[msgName] = namedValueFloatMessage.value
this.onGenericVariables.emit()
break
}

case MAVLinkType.NAMED_VALUE_INT: {
const namedValueIntMessage = mavlink_message.message as Message.NamedValueInt
const messageName = namedValueIntMessage.name.join('').replaceAll('\x00', '')
this._genericVariables[messageName] = namedValueIntMessage.value
const msgName = namedValueIntMessage.name.join('').replaceAll('\x00', '')
this._genericVariables[msgName] = namedValueIntMessage.value
this.onGenericVariables.emit()
break
}
Expand Down
33 changes: 33 additions & 0 deletions test/test.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<!--
This is a test page to test the datalake consumption of the external-api library, in a widget
to test, run "python -m http.server" in the cockpit directory and create an iframe widget with the url
http://localhost:8000/test/test.html
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background-color: white;
}
</style>
</head>
<body>
<h1>Datalake consumption test</h1>
<div id="pitchDisplay"></div>
<div id="rollDisplay"></div>

<script src="../dist/lib/cockpit-external-api.browser.js"></script>
<script>
CockpitAPI.listenToDatalakeVariable('ATTITUDE/pitch', function(data) {
document.getElementById('pitchDisplay').innerText = 'Pitch (1Hz): ' + data;
}, 1);
CockpitAPI.listenToDatalakeVariable('ATTITUDE/roll', function(data) {

document.getElementById('rollDisplay').innerText = 'Roll (10Hz): ' + data;
}, 10);
</script>
</body>
</html>
48 changes: 47 additions & 1 deletion vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ import { getVersion } from './src/libs/non-browser-utils'
// Check if we're running in Electron mode or building the application
const isElectron = process.env.ELECTRON === 'true'
const isBuilding = process.argv.includes('build')
const isLibrary = process.env.BUILD_MODE === 'library'

export default defineConfig({
// Base configuration that will be merged
const baseConfig = {
plugins: [
(isElectron || isBuilding) &&
electron([
Expand Down Expand Up @@ -70,4 +72,48 @@ export default defineConfig({
server: {
host: '0.0.0.0',
},
}

// Library-specific configuration
const libraryConfig = {
build: {
lib: {
entry: path.resolve(__dirname, 'src/libs/external-api/api.ts'),
name: 'CockpitAPI',
formats: ['es', 'umd', 'iife'],
fileName: (format: string) => {
switch (format) {
case 'iife':
return 'cockpit-external-api.browser.js'
case 'umd':
return 'cockpit-external-api.umd.js'
default:
return `cockpit-external-api.${format}.js`
}
},
},
rollupOptions: {
external: ['vue', 'vuetify'],
output: {
globals: {
vue: 'Vue',
vuetify: 'Vuetify',
},
},
},
outDir: 'dist/lib', // Separate output directory for library builds
minify: false, // Disable minification for now
},
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
export default defineConfig((_configEnv) => {
if (isLibrary) {
// For library builds, merge the base config with library-specific settings
return {
...baseConfig,
...libraryConfig,
} as any
}
return baseConfig as any
})

0 comments on commit 02bada7

Please sign in to comment.