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

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
Open
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
49 changes: 49 additions & 0 deletions .github/workflows/publish-lib.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
name: Publish Library to NPM

on:
release:
types: [created]

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

- uses: actions/setup-node@v4
with:
node-version: '20'
registry-url: 'https://registry.npmjs.org'

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

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

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

- name: Create package.json for library
run: |
cd dist/lib
{
echo '{
"name": "@bluerobotics/cockpit-api",
"version": "'${{ github.ref_name }}'",
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should not use the cockpit version here. thoughts?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We shouldn't. Cockpit has new releases every week, while the API will probably be updated every few months.

"main": "cockpit-external-api.umd.js",
"module": "cockpit-external-api.es.js",
"types": "types/index.d.ts",
"publishConfig": {
"access": "public"
}
}'
} > package.json

- name: Publish to NPM
run: npm publish
working-directory: dist/lib
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM }}
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
12 changes: 11 additions & 1 deletion src/libs/actions/data-lake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,17 @@ export const getDataLakeVariableData = (id: string): string | number | boolean |
return dataLakeVariableData[id]
}

export const setDataLakeVariableData = (id: string, data: string | number | boolean): void => {
export const setDataLakeVariableData = (
id: string,
data: object | string | number | boolean | Array<string | number>
): void => {
if (data === null) return
if (typeof data !== 'string' && typeof data !== 'number') return

if (dataLakeVariableInfo[id] === undefined) {
createDataLakeVariable(new DataLakeVariable(id, id, typeof data === 'string' ? 'string' : 'number'))
}

dataLakeVariableData[id] = data
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'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be injected in build time, right?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes. where should the "source of truth" live, though?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Difficult.
If it was in its own repo, we could track the git release, but in this case we maybe have to hardcode it in the packages.json?

Copy link
Member Author

@Williangalvani Williangalvani Dec 19, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

idk. we need it in 3 places: packages.json, publish-lib.yml, and api.ts 🤔
is it easier to move from code to packages.json or from packages.json to code?


/**
* 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>
17 changes: 17 additions & 0 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 @@ -45,6 +46,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 +296,21 @@ 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, '')
setDataLakeVariableData(`${messageType}/${name}`, mavlink_message.message.value)
return
}

// For all other messages, use the flattener
const flattened = flattenData(mavlink_message.message)
flattened.forEach(({ path, value }) => {
setDataLakeVariableData(path, value)
})

Williangalvani marked this conversation as resolved.
Show resolved Hide resolved
// Update our internal messages
this._messages.set(mavlink_message.message.type, { ...mavlink_message.message, epoch: new Date().getTime() })

Expand Down
104 changes: 104 additions & 0 deletions src/libs/vehicle/ardupilot/data-flattener.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* Result of flattening a data structure
*/
export type FlattenedPair = {
/**
* Full path to the data, including message type and field name
* e.g. "ATTITUDE/roll" or "NAMED_VALUE_FLOAT/GpsHDOP"
*/
path: string
/**
* The actual value of the field after flattening
* - Primitive values are kept as-is
* - String arrays are joined
* - Number arrays create multiple entries
*/
value: string | number | boolean
/**
* The type of the flattened value
* Used to create the appropriate DataLakeVariable
*/
type: 'string' | 'number' | 'boolean'
}

/**
* Type guard to check if a value is an array of numbers
* @param {unknown[]} data The data to check
* @returns {data is number[]} True if the array contains numbers
*/
function isNumberArray(data: unknown[]): data is number[] {
return typeof data[0] === 'number'
}

/**
* Type guard to check if a value is an array of strings
* @param {unknown[]} data The data to check
* @returns {data is string[]} True if the array contains strings
*/
function isStringArray(data: unknown[]): data is string[] {
return typeof data[0] === 'string'
}

/**
* Flattens complex data structures into simple types that can be stored in the data lake
* @param {Record<string, unknown>} data The data to flatten
* @returns {FlattenedPair[]} Array of flattened path/value pairs
*/
export function flattenData(data: Record<string, unknown>): FlattenedPair[] {
if (!('type' in data)) return []
const messageName = data.type as string

// Special handling for NAMED_VALUE_FLOAT messages
if (messageName === 'NAMED_VALUE_FLOAT') {
const name = (data.name as string[]).join('').replace(/\0/g, '')
return [
{
path: `${messageName}/${name}`,
type: 'number',
value: data.value as number,
},
...Object.entries(data)
.filter(([key]) => !['name', 'value', 'type'].includes(key))
.map(([key, value]) => ({
path: `${messageName}/${key}`,
type: typeof value as 'string' | 'number' | 'boolean',
value: value as string | number | boolean,
})),
]
}

// For all other messages
return Object.entries(data)
.filter(([key]) => key !== 'type')
.flatMap(([key, value]) => {
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return [
{
path: `${messageName}/${key}`,
type: typeof value as 'string' | 'number' | 'boolean',
value,
},
]
}
if (Array.isArray(value)) {
if (value.length === 0) return []
if (isNumberArray(value)) {
return value.map((item, index) => ({
path: `${messageName}/${key}/${index}`,
type: 'number',
value: item,
}))
}
if (isStringArray(value)) {
return [
{
path: `${messageName}/${key}`,
type: 'string',
value: value.join(''),
},
]
}
}
return []
})
}
Loading
Loading