-
Notifications
You must be signed in to change notification settings - Fork 22
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
base: master
Are you sure you want to change the base?
Add widget api #1503
Changes from all commits
dc28e4e
8cf7946
a1180de
86d4e4b
976fe11
2274812
13312b8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 }}'", | ||
"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 }} |
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' | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This should be injected in build time, right? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yes. where should the "source of truth" live, though? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Difficult. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 🤔 |
||
|
||
/** | ||
* 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) | ||
} | ||
} | ||
}) | ||
} |
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 | ||
} | ||
} |
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> |
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 [] | ||
}) | ||
} |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.