Skip to content

Commit adcacbf

Browse files
committed
feat: support generic vendor middleware in data item transforms
Extend `RawDataItem` / `transformDataResponse` to treat the SOVD `x-medkit` vendor extension as generic metadata rather than a ROS-2-only construct: - accept `middleware`, `access`, `type`, `direction`, `encoding` as optional vendor fields on the data item - when the gateway inlines `value`, mark the resulting topic as `status: 'data'` instead of always `metadata_only`, so non-streaming middlewares render their current value immediately - use `x-medkit.type` as the type label when no ROS 2 message type is available - recognise `input` / `output` as alternative direction terms alongside `publish` / `subscribe` / `both` - fall back to `x-medkit.direction` when `ros2.direction` is absent ROS 2 behaviour is unchanged; everything new is additive.
1 parent 9522384 commit adcacbf

2 files changed

Lines changed: 111 additions & 8 deletions

File tree

src/lib/transforms.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,76 @@ describe('transformDataResponse', () => {
489489
const result = transformDataResponse({ items: [] });
490490
expect(result).toEqual([]);
491491
});
492+
493+
describe('generic vendor middleware extensions', () => {
494+
it('marks status="data" and passes value through when gateway inlines value', () => {
495+
const raw = {
496+
id: 'sensor/temperature',
497+
name: 'sensor/temperature',
498+
value: 42.5,
499+
'x-medkit': { middleware: 'generic', access: 'read', type: 'float32' },
500+
};
501+
const result = transformDataResponse({ items: [raw] });
502+
expect(result[0]?.status).toBe('data');
503+
expect(result[0]?.data).toBe(42.5);
504+
});
505+
506+
it('keeps status="metadata_only" when value is absent', () => {
507+
const raw = { id: 'x', name: 'x', 'x-medkit': { middleware: 'generic' } };
508+
const result = transformDataResponse({ items: [raw] });
509+
expect(result[0]?.status).toBe('metadata_only');
510+
expect(result[0]?.data).toBeNull();
511+
});
512+
513+
it('preserves null value with status="data"', () => {
514+
const raw = { id: 'x', name: 'x', value: null };
515+
const result = transformDataResponse({ items: [raw] });
516+
expect(result[0]?.status).toBe('data');
517+
expect(result[0]?.data).toBeNull();
518+
});
519+
520+
it('uses x-medkit.type as type label when ros2.type is absent', () => {
521+
const raw = {
522+
id: 'payload',
523+
name: 'payload',
524+
'x-medkit': { middleware: 'generic', type: 'u16' },
525+
};
526+
const result = transformDataResponse({ items: [raw] });
527+
expect(result[0]?.type).toBe('u16');
528+
});
529+
530+
it('prefers ros2.type over x-medkit.type when both present', () => {
531+
const raw = {
532+
id: 'x',
533+
name: 'x',
534+
'x-medkit': { type: 'generic-label', ros2: { type: 'std_msgs/msg/Int32' } },
535+
};
536+
const result = transformDataResponse({ items: [raw] });
537+
// ROS 2 type is preferred so canonical topics stay recognisable.
538+
// Keeps precedence consistent with `direction`.
539+
expect(result[0]?.type).toBe('std_msgs/msg/Int32');
540+
});
541+
542+
it('treats direction "output" as publish', () => {
543+
const raw = { id: 'x', name: 'x', 'x-medkit': { direction: 'output' } };
544+
const result = transformDataResponse({ items: [raw] });
545+
expect(result[0]?.isPublisher).toBe(true);
546+
expect(result[0]?.isSubscriber).toBe(false);
547+
});
548+
549+
it('treats direction "input" as subscribe', () => {
550+
const raw = { id: 'x', name: 'x', 'x-medkit': { direction: 'input' } };
551+
const result = transformDataResponse({ items: [raw] });
552+
expect(result[0]?.isPublisher).toBe(false);
553+
expect(result[0]?.isSubscriber).toBe(true);
554+
});
555+
556+
it('reads direction from x-medkit.direction when ros2.direction is absent', () => {
557+
const raw = { id: 'x', name: 'x', 'x-medkit': { direction: 'publish' } };
558+
const result = transformDataResponse({ items: [raw] });
559+
expect(result[0]?.uniqueKey).toBe('x:publish');
560+
});
561+
});
492562
});
493563

494564
// =============================================================================

src/lib/transforms.ts

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,10 @@ interface RawOperation {
214214
* Transform the raw operations list response into `Operation[]`.
215215
*
216216
* Extracts `kind`, `path`, and `type` from the `x-medkit` vendor extension.
217+
*
218+
* NOTE: currently only reads `x-medkit.ros2.*`. Extending the generic
219+
* middleware fallback here (parity with `transformDataResponse`) is tracked
220+
* separately.
217221
*/
218222
export function transformOperationsResponse(rawData: unknown): Operation[] {
219223
if (!rawData || typeof rawData !== 'object') return [];
@@ -275,12 +279,27 @@ export function transformOperationsResponse(rawData: unknown): Operation[] {
275279

276280
/**
277281
* Raw data item shape from the gateway data endpoint.
282+
*
283+
* Fields under `x-medkit` are generic SOVD vendor extensions. Gateways may
284+
* populate any subset depending on the underlying middleware; the UI treats
285+
* them as optional metadata and falls back to ROS 2 semantics when they are
286+
* absent.
278287
*/
279288
interface RawDataItem {
280289
id: string;
281290
name?: string;
282291
category?: string;
292+
/** Current value inlined by the gateway when available. */
293+
value?: unknown;
283294
'x-medkit'?: {
295+
/** Middleware identifier (e.g. 'ros2'); consumers treat any other value as non-ROS 2. */
296+
middleware?: string;
297+
/** Access mode ('read' | 'write' | 'readwrite'). */
298+
access?: string;
299+
/** Vendor-provided type label, used when no ROS 2 message type is available. */
300+
type?: string;
301+
/** Direction: 'publish'/'subscribe'/'both' or 'input'/'output' as alternative terms. */
302+
direction?: string;
284303
ros2?: { topic?: string; type?: string; direction?: string };
285304
type_info?: { schema?: unknown; default_value?: unknown };
286305
};
@@ -290,30 +309,40 @@ interface RawDataItem {
290309
* Transform the raw data list response into `ComponentTopic[]`.
291310
*
292311
* Extracts topic metadata (type, direction, schema) from the `x-medkit` extension.
312+
* When the gateway inlines a `value`, the resulting topic is marked as `status:
313+
* 'data'` so that non-streaming middlewares render their current value immediately.
293314
*/
294315
export function transformDataResponse(rawData: unknown): ComponentTopic[] {
295316
if (!rawData || typeof rawData !== 'object') return [];
296317
const dataItems = unwrapItems<RawDataItem>(rawData);
297318
return dataItems.map((item) => {
298-
const rawTypeInfo = item['x-medkit']?.type_info;
319+
const xm = item['x-medkit'];
320+
const rawTypeInfo = xm?.type_info;
299321
const convertedSchema = rawTypeInfo?.schema ? convertJsonSchemaToTopicSchema(rawTypeInfo.schema) : undefined;
300-
const direction = item['x-medkit']?.ros2?.direction;
301-
const topicName = item.name || item['x-medkit']?.ros2?.topic || item.id;
322+
// `input`/`output` are alternative direction terms used by non-ROS 2 middlewares.
323+
const direction = xm?.ros2?.direction ?? xm?.direction;
324+
const topicName = item.name || xm?.ros2?.topic || item.id;
325+
// Prefer the ROS 2 message type when present so canonical topics stay
326+
// recognisable; the generic vendor label only fills the gap when no
327+
// ROS 2 type was published. This keeps precedence consistent with
328+
// `direction` above.
329+
const typeLabel = xm?.ros2?.type ?? xm?.type;
330+
const hasValue = item.value !== undefined;
302331
return {
303332
topic: topicName,
304333
timestamp: Date.now(),
305-
data: null,
306-
status: 'metadata_only' as const,
307-
type: item['x-medkit']?.ros2?.type,
334+
data: hasValue ? item.value : null,
335+
status: hasValue ? ('data' as const) : ('metadata_only' as const),
336+
type: typeLabel,
308337
type_info: convertedSchema
309338
? {
310339
schema: convertedSchema,
311340
default_value: rawTypeInfo?.default_value as Record<string, unknown>,
312341
}
313342
: undefined,
314343
// Direction-based fields for apps/functions.
315-
isPublisher: direction === 'publish' || direction === 'both',
316-
isSubscriber: direction === 'subscribe' || direction === 'both',
344+
isPublisher: direction === 'publish' || direction === 'both' || direction === 'output',
345+
isSubscriber: direction === 'subscribe' || direction === 'both' || direction === 'input',
317346
uniqueKey: direction ? `${topicName}:${direction}` : topicName,
318347
};
319348
});
@@ -339,6 +368,10 @@ interface RawConfigurationsResponse {
339368
*
340369
* All meaningful data lives in the `x-medkit` extension. The `entityId` parameter
341370
* is used as a fallback when `x-medkit` fields are absent.
371+
*
372+
* NOTE: currently only reads `x-medkit.ros2.*`. Extending the generic
373+
* middleware fallback here (parity with `transformDataResponse`) is tracked
374+
* separately.
342375
*/
343376
export function transformConfigurationsResponse(rawData: unknown, entityId: string): ComponentConfigurations {
344377
if (!rawData || typeof rawData !== 'object') {

0 commit comments

Comments
 (0)