Skip to content

Commit 99d1fda

Browse files
committed
feat(oob): Adds initial oob implementation with metrics
1 parent af72817 commit 99d1fda

12 files changed

Lines changed: 1359 additions & 65 deletions

File tree

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
/*
2+
* SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
/**
7+
* HeadsetControlChannel — WebSocket client that connects the XR headset to the
8+
* teleop control hub running in the WSS proxy.
9+
*
10+
* Protocol: docs/source/references/oob_teleop_control.rst (Sphinx build)
11+
* Hub WS URL: ``wss://<serverIP>:<port>/oob/v1/ws`` when the page URL includes ``oobEnable=1`` and
12+
* valid ``serverIP`` / ``port`` query parameters (see App.tsx). No connection is made without them.
13+
*
14+
* Usage (in App.tsx):
15+
*
16+
* const channel = new HeadsetControlChannel({
17+
* url: 'wss://host:48322/oob/v1/ws',
18+
* onConfig: (config, version) => { ... },
19+
* getMetricsSnapshot: () => [ { cadence: 'frame', metrics: { ... } } ],
20+
* });
21+
* channel.connect();
22+
* // on cleanup:
23+
* channel.dispose();
24+
*/
25+
26+
/**
27+
* Fields the hub merges into ``config`` on ``hello`` / ``config`` pushes.
28+
*
29+
* **Streaming target:** ``serverIP``, ``port``, ``proxyUrl``, ``mediaAddress``, ``mediaPort``.
30+
*
31+
* **Client UI (allowlist):** keys match HTML form element **ids** on the Teleop page—only these may be
32+
* set remotely today (see ``CloudXR2DUI``). The hub stores arbitrary top-level keys, but the web
33+
* client only applies this known set so coercion and validation stay explicit.
34+
*/
35+
export interface StreamConfig {
36+
serverIP?: string;
37+
port?: number;
38+
proxyUrl?: string | null;
39+
mediaAddress?: string;
40+
mediaPort?: number;
41+
/** Form id ``panelHiddenAtStart``: hide the in-XR control panel when the session starts. */
42+
panelHiddenAtStart?: boolean;
43+
/** Form id ``codec``: ``h264`` | ``h265`` | ``av1`` when supported. */
44+
codec?: string;
45+
/** Form id ``perEyeWidth``. */
46+
perEyeWidth?: number;
47+
/** Form id ``perEyeHeight``. */
48+
perEyeHeight?: number;
49+
}
50+
51+
export interface MetricsSnapshot {
52+
cadence: string;
53+
metrics: Record<string, number>;
54+
}
55+
56+
export interface ControlChannelOptions {
57+
/** Full WSS URL of the hub, e.g. wss://host:48322/oob/v1/ws */
58+
url: string;
59+
/** Sent in the register message. Must match CONTROL_TOKEN env var if set. */
60+
token?: string;
61+
/** Human-readable label in hub snapshots (optional). */
62+
deviceLabel?: string;
63+
/**
64+
* Called on hello (initial config) and on config push.
65+
* Apply the config to the CloudXR connection settings before connect.
66+
*/
67+
onConfig: (config: StreamConfig, configVersion: number) => void;
68+
/** Called when the WebSocket connection state changes. */
69+
onConnectionChange?: (connected: boolean) => void;
70+
/**
71+
* Optional: called periodically to get the latest metrics to report.
72+
* Return an empty array or null/undefined to skip a tick.
73+
*/
74+
getMetricsSnapshot?: () => MetricsSnapshot[] | null | undefined;
75+
/** How often to report metrics (ms). Default: 500. */
76+
metricsIntervalMs?: number;
77+
}
78+
79+
const RECONNECT_DELAY_MS = 3000;
80+
const DEFAULT_METRICS_INTERVAL_MS = 500;
81+
82+
export class HeadsetControlChannel {
83+
private ws: WebSocket | null = null;
84+
private disposed = false;
85+
private metricsTimer: ReturnType<typeof setInterval> | null = null;
86+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
87+
88+
constructor(private readonly opts: ControlChannelOptions) {}
89+
90+
/** Open the WebSocket and start the reconnection loop. */
91+
connect(): void {
92+
if (this.disposed) return;
93+
this._openWebSocket();
94+
}
95+
96+
/** Close the channel permanently. Safe to call multiple times. */
97+
dispose(): void {
98+
this.disposed = true;
99+
this._clearTimers();
100+
if (this.ws) {
101+
this.ws.onclose = null; // prevent reconnect on this close
102+
this.ws.close();
103+
this.ws = null;
104+
}
105+
}
106+
107+
// ---------------------------------------------------------------------------
108+
// Private
109+
// ---------------------------------------------------------------------------
110+
111+
private _openWebSocket(): void {
112+
if (this.disposed) return;
113+
114+
let ws: WebSocket;
115+
try {
116+
ws = new WebSocket(this.opts.url);
117+
} catch (err) {
118+
if (this.disposed) return;
119+
console.warn(
120+
'[ControlChannel] WebSocket constructor failed for',
121+
this.opts.url,
122+
err
123+
);
124+
this.ws = null;
125+
this._afterSocketClosed();
126+
return;
127+
}
128+
129+
this.ws = ws;
130+
131+
ws.onopen = () => {
132+
ws.send(
133+
JSON.stringify({
134+
type: 'register',
135+
payload: {
136+
role: 'headset',
137+
...(this.opts.token ? { token: this.opts.token } : {}),
138+
...(this.opts.deviceLabel ? { deviceLabel: this.opts.deviceLabel } : {}),
139+
},
140+
})
141+
);
142+
this.opts.onConnectionChange?.(true);
143+
this._startMetricsTimer();
144+
};
145+
146+
ws.onmessage = (ev) => {
147+
if (typeof ev.data !== 'string') return;
148+
let msg: { type?: string; payload?: unknown };
149+
try {
150+
msg = JSON.parse(ev.data);
151+
} catch {
152+
return;
153+
}
154+
this._handleMessage(msg);
155+
};
156+
157+
ws.onclose = () => {
158+
this.ws = null;
159+
this._afterSocketClosed();
160+
};
161+
162+
ws.onerror = () => {
163+
// onclose fires next; reconnect logic lives there
164+
};
165+
}
166+
167+
/** Clear timers, notify disconnected, schedule reconnect (same path as WebSocket onclose). */
168+
private _afterSocketClosed(): void {
169+
this._clearTimers();
170+
this.opts.onConnectionChange?.(false);
171+
if (!this.disposed) {
172+
this.reconnectTimer = setTimeout(() => this._openWebSocket(), RECONNECT_DELAY_MS);
173+
}
174+
}
175+
176+
private _handleMessage(msg: { type?: string; payload?: unknown }): void {
177+
const type = msg.type;
178+
const payload = (msg.payload ?? {}) as Record<string, unknown>;
179+
180+
if (type === 'hello') {
181+
// hello to headset includes initial config
182+
if (
183+
payload.config != null &&
184+
typeof payload.configVersion === 'number'
185+
) {
186+
this.opts.onConfig(payload.config as StreamConfig, payload.configVersion as number);
187+
}
188+
} else if (type === 'config') {
189+
if (
190+
payload.config != null &&
191+
typeof payload.configVersion === 'number'
192+
) {
193+
this.opts.onConfig(payload.config as StreamConfig, payload.configVersion as number);
194+
}
195+
} else if (type === 'error') {
196+
console.warn('[ControlChannel] Hub error:', payload);
197+
}
198+
}
199+
200+
private _startMetricsTimer(): void {
201+
if (!this.opts.getMetricsSnapshot) return;
202+
const interval = this.opts.metricsIntervalMs ?? DEFAULT_METRICS_INTERVAL_MS;
203+
this.metricsTimer = setInterval(() => {
204+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
205+
const snapshots = this.opts.getMetricsSnapshot?.();
206+
if (!snapshots || snapshots.length === 0) return;
207+
const t = Date.now();
208+
for (const { cadence, metrics } of snapshots) {
209+
if (Object.keys(metrics).length === 0) continue;
210+
this.ws.send(
211+
JSON.stringify({
212+
type: 'clientMetrics',
213+
payload: { t, cadence, metrics },
214+
})
215+
);
216+
}
217+
}, interval);
218+
}
219+
220+
private _clearTimers(): void {
221+
if (this.metricsTimer !== null) {
222+
clearInterval(this.metricsTimer);
223+
this.metricsTimer = null;
224+
}
225+
if (this.reconnectTimer !== null) {
226+
clearTimeout(this.reconnectTimer);
227+
this.reconnectTimer = null;
228+
}
229+
}
230+
}

0 commit comments

Comments
 (0)