Skip to content
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
8 changes: 8 additions & 0 deletions web/cockpit/deno.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"tasks": {
"dev": "deno run -A npm:vite",
"build": "deno run -A npm:vite build",
"test": "deno run -A npm:vitest run",
"check": "deno run -A npm:typescript/tsc --noEmit"
}
}
20 changes: 20 additions & 0 deletions web/cockpit/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "@dimos/cockpit",
"version": "0.0.0",
"private": true,
"type": "module",
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@types/node": "^24.10.1",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.1.0",
"happy-dom": "^20.11.1",
"typescript": "~5.9.2",
"vite": "^7.1.0",
"vitest": "^3.2.4"
}
}
54 changes: 54 additions & 0 deletions web/cockpit/src/session/decoders/decoders.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import type { FrameHeader } from "@dimos/shared";
import { getDecoder, registerDecoder } from "./index.ts";
import { JSON_PREVIEW_MAX_CHARS, MAX_JSON_PAYLOAD_BYTES } from "./json.ts";

const HEADER: FrameHeader = { ch: "x", seq: 1, ts: 0, delivery: "latest" };

describe("decoder registry", () => {
it("resolves any *.json.vN encoding to the JSON decoder", () => {
const decode = getDecoder("pose.json.v1");
expect(decode).toBeDefined();
const payload = new TextEncoder().encode('{"x": 1.5, "yaw": -0.25}');
expect(decode!(payload, HEADER)).toEqual({
value: { x: 1.5, yaw: -0.25 },
preview: '{"x": 1.5, "yaw": -0.25}',
});
expect(getDecoder("future.json.v7")).toBeDefined();
});

it("returns undefined for unknown encodings (unsupported, not an error)", () => {
expect(getDecoder("jpeg.v1")).toBeUndefined();
expect(getDecoder("costmap.zlib.v1")).toBeUndefined();
expect(getDecoder(undefined)).toBeUndefined();
});

it("prefers an exact registration over the JSON fallback", () => {
registerDecoder("special.json.v1", () => ({ value: "exact" }));
expect(getDecoder("special.json.v1")!(new Uint8Array(), HEADER).value).toBe("exact");
});

it("throws on invalid UTF-8 so the caller can count a decode error", () => {
const decode = getDecoder("pose.json.v1")!;
expect(() => decode(new Uint8Array([0xff, 0xfe, 0x22]), HEADER)).toThrow();
});

it("reports oversized json instead of parsing it", () => {
const decode = getDecoder("pose.json.v1")!;
// 0x31 = "1": would be valid JSON, but must never reach the parser.
const payload = new Uint8Array(MAX_JSON_PAYLOAD_BYTES + 1).fill(0x31);
const { value, preview } = decode(payload, HEADER);
expect(value).toBeUndefined();
expect(preview).toContain("oversized");
expect(preview!.length).toBeLessThan(200);
});

it("bounds the preview of large-but-valid json", () => {
const decode = getDecoder("pose.json.v1")!;
const long = JSON.stringify({ data: "x".repeat(10_000) });
const { value, preview } = decode(new TextEncoder().encode(long), HEADER);
expect(value).toEqual({ data: "x".repeat(10_000) });
expect(preview).toContain("truncated");
expect(preview!.length).toBeLessThan(JSON_PREVIEW_MAX_CHARS + 50);
});
});
29 changes: 29 additions & 0 deletions web/cockpit/src/session/decoders/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Payload decoder registry, keyed by the manifest's encoding id. An encoding
// without a decoder is not an error: the channel renders as "unsupported"
// (forward compatibility with newer bridges). Binary decoders (jpeg.v1,
// costmap.zlib.v1, ...) arrive with their panels from T4 on.

import type { FrameHeader } from "@dimos/shared";
import { jsonDecoder } from "./json.ts";

export interface Decoded {
value: unknown;
/** Bounded text form of `value` for the raw channel UI; binary decoders leave it unset. */
preview?: string;
}

export type Decoder = (payload: Uint8Array, header: FrameHeader) => Decoded;

const registry = new Map<string, Decoder>();

export function registerDecoder(encoding: string, decoder: Decoder): void {
registry.set(encoding, decoder);
}

export function getDecoder(encoding: string | undefined): Decoder | undefined {
if (encoding === undefined) return undefined;
const exact = registry.get(encoding);
if (exact !== undefined) return exact;
if (/\.json\.v\d+$/.test(encoding)) return jsonDecoder;
return undefined;
}
30 changes: 30 additions & 0 deletions web/cockpit/src/session/decoders/json.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { FrameHeader } from "@dimos/shared";
import type { Decoded } from "./index.ts";

// fatal: corrupted bytes must fail decode, not U+FFFD their way onto screen.
const utf8 = new TextDecoder("utf-8", { fatal: true });

// Parsing and previewing run on the main thread: a payload above this cap is
// reported instead of parsed, so a huge but valid frame cannot freeze the tab
// (the outer MAX_DATA_FRAME_BYTES is 64 MiB). Reference scale: the Python
// viewer caps pose.json.v1 at 64 KiB.
export const MAX_JSON_PAYLOAD_BYTES = 256 * 1024;

// Longest preview the raw-value UI ever mounts in the DOM.
export const JSON_PREVIEW_MAX_CHARS = 2048;

/** Decoder for every `*.json.vN` encoding (pose.json.v1 and friends). */
export function jsonDecoder(payload: Uint8Array, _header: FrameHeader): Decoded {
if (payload.byteLength > MAX_JSON_PAYLOAD_BYTES) {
return {
value: undefined,
preview: `(oversized json payload: ${payload.byteLength} B, cap ${MAX_JSON_PAYLOAD_BYTES} B)`,
};
}
const text = utf8.decode(payload);
const value: unknown = JSON.parse(text);
const preview = text.length > JSON_PREVIEW_MAX_CHARS
? `${text.slice(0, JSON_PREVIEW_MAX_CHARS)} ... (truncated, ${text.length} chars)`
: text;
return { value, preview };
}
16 changes: 16 additions & 0 deletions web/cockpit/src/session/hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// The React-facing edge of the session layer (the only React import under
// session/). Both hooks ride useSyncExternalStore; channel snapshots only
// change on the store's UI tick, so channel rate never sets render rate.

import { useCallback, useSyncExternalStore } from "react";
import type { ChannelSnapshot, ChannelStore, SessionStatus, StatusStore } from "./store.ts";

export function useStatus(store: StatusStore): SessionStatus {
return useSyncExternalStore(store.subscribe, store.get);
}

export function useChannel(store: ChannelStore, ch: string): ChannelSnapshot {
const subscribe = useCallback((cb: () => void) => store.subscribeUi(ch, cb), [store, ch]);
const getSnapshot = useCallback(() => store.getUiSnapshot(ch), [store, ch]);
return useSyncExternalStore(subscribe, getSnapshot);
}
120 changes: 120 additions & 0 deletions web/cockpit/src/session/protocol_fixtures.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// The cockpit consumes shared/protocol.ts through the vite/vitest pipeline
// (alias + bundler resolution) instead of Deno's. Running the golden vectors
// here proves that pipeline yields byte-identical framing.

import { describe, expect, it } from "vitest";
import {
ControlFrameReader,
DataFrameStreamReader,
decodeDatagram,
encodeControlFrame,
encodeDataFrame,
encodeDatagram,
type FrameHeader,
type Msg,
msgFromUnknown,
} from "@dimos/shared";
import { ManifestError, parseManifest } from "@dimos/shared/manifest";
import controlFrames from "../../../shared/fixtures/control_frames.json";
import dataFrames from "../../../shared/fixtures/data_frames.json";
import datagrams from "../../../shared/fixtures/datagrams.json";
import manifests from "../../../shared/fixtures/manifests.json";

function b64ToBytes(b64: string): Uint8Array {
return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
}

describe("control frame golden vectors", () => {
for (const vector of controlFrames.vectors) {
it(`encodes ${vector.name} byte-exactly`, () => {
expect(encodeControlFrame(vector.message as Msg)).toEqual(b64ToBytes(vector.b64));
});
}

it("decodes every vector whole and byte-at-a-time", () => {
const whole = new ControlFrameReader();
const trickle = new ControlFrameReader();
const expected = controlFrames.vectors.map((v) => v.message);

const all = controlFrames.vectors.flatMap((v) => [...b64ToBytes(v.b64)]);
expect(whole.push(Uint8Array.from(all))).toEqual(expected);

const decoded: unknown[] = [];
for (const byte of all) decoded.push(...trickle.push(Uint8Array.of(byte)));
expect(decoded).toEqual(expected);
});
});

describe("datagram golden vectors", () => {
for (const vector of datagrams.vectors) {
it(`round-trips ${vector.name}`, () => {
expect(encodeDatagram(vector.message as Msg)).toEqual(b64ToBytes(vector.b64));
expect(decodeDatagram(b64ToBytes(vector.b64))).toEqual(vector.message);
});
}
});

describe("data frame golden vectors", () => {
for (const vector of dataFrames.vectors) {
it(`encodes and chunk-decodes ${vector.name}`, () => {
const payload = b64ToBytes(vector.payload_b64);
const frame = b64ToBytes(vector.frame_b64);
expect(encodeDataFrame(vector.header as FrameHeader, payload)).toEqual(frame);

const reader = new DataFrameStreamReader();
const decoded = [];
for (let i = 0; i < frame.length; i += 3) {
decoded.push(...reader.push(frame.subarray(i, Math.min(i + 3, frame.length))));
}
expect(decoded).toHaveLength(1);
expect(decoded[0].header).toEqual(vector.header);
expect(decoded[0].payload).toEqual(payload);
});
}
});

describe("manifest golden vectors", () => {
type Vector = { name: string; data: unknown; manifest?: unknown; error?: string };
for (const vector of manifests.vectors as Vector[]) {
it(`handles ${vector.name}`, () => {
if (vector.error !== undefined) {
let code: string | null = null;
try {
parseManifest(vector.data);
} catch (e) {
if (e instanceof ManifestError) code = e.code;
}
expect(code).toBe(vector.error);
} else {
expect(parseManifest(vector.data)).toEqual(vector.manifest);
}
});
}
});

describe("manifest/session message validation", () => {
const manifest = {
t: "manifest",
robotId: "go2",
channels: [{ ch: "odom", encoding: "pose.json.v1", delivery: "reliable", maxHz: 20 }],
};

it("accepts well-formed messages", () => {
expect(msgFromUnknown(manifest)).toEqual(manifest);
expect(msgFromUnknown({ t: "robots", robots: [{ id: "a", name: "A", model: "go2" }] }))
.not.toBeNull();
});

it("rejects malformed or unknown messages", () => {
expect(msgFromUnknown({ t: "nope" })).toBeNull();
expect(msgFromUnknown({ t: "toString" })).toBeNull();
expect(msgFromUnknown({ t: "manifest", robotId: "go2" })).toBeNull();
expect(
msgFromUnknown({
...manifest,
channels: [{ ch: "odom", encoding: "pose.json.v1", delivery: "sometimes", maxHz: 20 }],
}),
).toBeNull();
expect(msgFromUnknown({ t: "watch" })).toBeNull();
});
});
Loading
Loading