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

Add widget API #1503

Merged
merged 7 commits into from
Jan 13, 2025
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
65 changes: 65 additions & 0 deletions .github/workflows/publish-lib.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
name: Publish Library to NPM

# Trigger on tag push
on:
push:
tags:
- 'API-v*'

jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

# Setup Node.js environment
- uses: actions/setup-node@v4
with:
node-version: '20'
registry-url: 'https://registry.npmjs.org'

# Extract version from tag
- name: Extract API version
run: echo "API_VERSION=${GITHUB_REF_NAME#API-v}" >> $GITHUB_ENV

# Setup Bun
- uses: oven-sh/setup-bun@v1
with:
bun-version: latest

# Update version in source code
- name: Update API version constant
run: |
sed -i "s/COCKPIT_WIDGET_API_VERSION = '.*'/COCKPIT_WIDGET_API_VERSION = '${{ env.API_VERSION }}'/" src/libs/external-api/api.ts

# Install dependencies
- name: Install dependencies
run: bun install --frozen-lockfile

# Build library
- name: Build library
run: bun run build:lib
working-directory: .

# Create package.json for distribution
- name: Create package.json for library
run: |
cd dist/lib
{
echo '{
"name": "@bluerobotics/cockpit-api",
"version": "'${{ env.API_VERSION }}'",
"main": "cockpit-external-api.umd.js",
"module": "cockpit-external-api.es.js",
"publishConfig": {
"access": "public"
}
}'
} > package.json

# Publish to NPM
- name: Publish to NPM
run: npm publish
working-directory: dist/lib
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
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
25 changes: 21 additions & 4 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 @@ -54,18 +55,19 @@

<script setup lang="ts">
import { useWindowSize } from '@vueuse/core'
import { computed, defineProps, onBeforeMount, ref, toRefs, watch } from 'vue'
import { computed, defineProps, onBeforeMount, onBeforeUnmount, 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 @@ -96,17 +98,32 @@ const updateURL = (): void => {
openSnackbar.value = true
}

onBeforeMount(() => {
if (Object.keys(widget.value.options).length !== 0) {
const apiEventCallback = (event: MessageEvent): void => {
if (event.data.type !== 'cockpit:listenToDatalakeVariables') {
return
}
const { variable } = event.data
listenDataLakeVariable(variable, (value) => {
iframe.value.contentWindow.postMessage({ type: 'cockpit:datalakeVariable', variable, value }, '*')
})
}

onBeforeMount((): void => {
window.addEventListener('message', apiEventCallback, true)

if (Object.keys(widget.value.options).length !== 0) {
return
}
widget.value.options = {
source: defaultBlueOsAddress,
}
inputURL.value = defaultBlueOsAddress
})

onBeforeUnmount((): void => {
window.removeEventListener('message', apiEventCallback, true)
})

const { width: windowWidth, height: windowHeight } = useWindowSize()

const iframeStyle = computed<string>(() => {
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'
Williangalvani marked this conversation as resolved.
Show resolved Hide resolved

/**
* 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} variableId - 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('cockpit-memory-usage', (value) => {
* console.log('Memory Usage:', value);
* }, 5);
* ```
*/
export function listenToDatalakeVariable(variableId: 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: variableId,
maxRateHz: maxRateHz,
}
window.parent.postMessage(message, '*')

window.addEventListener('message', function handler(event) {
if (event.data.type === 'cockpit:datalakeVariable' && event.data.variable === variableId) {
// Only call callback if we haven't exceeded the rate limit
if (rateLimiter.canCall()) {
callback(event.data.value)
}
}
})
}
29 changes: 29 additions & 0 deletions src/libs/external-api/callback-rate-limiter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* A simple rate limiter for callbacks that ensures a minimum time interval between calls
*/
export class CallbackRateLimiter {
private lastCallTime: number

/**
* Creates a new CallbackRateLimiter
* @param {number} minIntervalMs - The minimum time (in milliseconds) that must pass between calls
*/
constructor(private minIntervalMs: number) {}

/**
* Checks if enough time has passed to allow another call
* @returns {boolean} true if enough time has passed since the last call, false otherwise
*/
public canCall(): boolean {
const now = Date.now()
const lastCall = this.lastCallTime || 0
const timeSinceLastCall = now - lastCall

if (timeSinceLastCall >= this.minIntervalMs) {
this.lastCallTime = now
return true
}

return false
}
}
34 changes: 34 additions & 0 deletions src/libs/external-api/examples/test.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<!--
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 root directory and create an iframe widget with the url
http://localhost:8000/src/libs/external-api/examples/test.html
This should be served independently of the cockpit app in order to test the "same-origin" policies
-->
<!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>
31 changes: 31 additions & 0 deletions src/libs/vehicle/ardupilot/ardupilot.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { differenceInMilliseconds } from 'date-fns'
import { unit } from 'mathjs'

import {
createDataLakeVariable,
DataLakeVariable,
getDataLakeVariableInfo,
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 @@ -45,6 +51,7 @@ import type { MetadataFile } from '@/types/ardupilot-metadata'
import { type MissionLoadingCallback, type Waypoint, defaultLoadingCallback } from '@/types/mission'

import * as Vehicle from '../vehicle'
import { flattenData } from './data-flattener'
import { defaultMessageFrequency } from './defaults'

// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down Expand Up @@ -294,6 +301,30 @@ export abstract class ArduPilotVehicle<Modes> extends Vehicle.AbstractVehicle<Mo
return
}

const messageType = mavlink_message.message.type

// Special handling for NAMED_VALUE_FLOAT messages
if (messageType === 'NAMED_VALUE_FLOAT') {
const name = (mavlink_message.message.name as string[]).join('').replace(/\0/g, '')
const path = `${messageType}/${name}`
if (getDataLakeVariableInfo(path) === undefined) {
createDataLakeVariable(new DataLakeVariable(path, path, 'number'))
}
setDataLakeVariableData(path, mavlink_message.message.value)
return
}

// For all other messages, use the flattener
const flattened = flattenData(mavlink_message.message)
flattened.forEach(({ path, value }) => {
if (value === null) return
if (typeof value !== 'string' && typeof value !== 'number') return
if (getDataLakeVariableInfo(path) === undefined) {
createDataLakeVariable(new DataLakeVariable(path, path, typeof value === 'string' ? 'string' : 'number'))
}
setDataLakeVariableData(path, value)
})

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

Expand Down
Loading
Loading