Skip to content

Commit f9c2c72

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

13 files changed

Lines changed: 1424 additions & 67 deletions

File tree

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
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 = (ev: CloseEvent) => {
158+
this.ws = null;
159+
this._afterSocketClosed(ev.code);
160+
};
161+
162+
ws.onerror = () => {
163+
// onclose fires next; reconnect logic lives there
164+
};
165+
}
166+
167+
/** Clear timers, notify disconnected, schedule reconnect unless terminal. */
168+
private _afterSocketClosed(closeCode?: number): void {
169+
this._clearTimers();
170+
this.opts.onConnectionChange?.(false);
171+
if (closeCode === 1008) {
172+
console.warn('[ControlChannel] Terminal close (1008 policy/auth); will not reconnect.');
173+
return;
174+
}
175+
if (!this.disposed) {
176+
this.reconnectTimer = setTimeout(() => this._openWebSocket(), RECONNECT_DELAY_MS);
177+
}
178+
}
179+
180+
private _handleMessage(msg: { type?: string; payload?: unknown }): void {
181+
const type = msg.type;
182+
const payload = (msg.payload ?? {}) as Record<string, unknown>;
183+
184+
if (type === 'hello') {
185+
// hello to headset includes initial config
186+
if (
187+
payload.config != null &&
188+
typeof payload.configVersion === 'number'
189+
) {
190+
this.opts.onConfig(payload.config as StreamConfig, payload.configVersion as number);
191+
}
192+
} else if (type === 'config') {
193+
if (
194+
payload.config != null &&
195+
typeof payload.configVersion === 'number'
196+
) {
197+
this.opts.onConfig(payload.config as StreamConfig, payload.configVersion as number);
198+
}
199+
} else if (type === 'error') {
200+
console.warn('[ControlChannel] Hub error:', payload);
201+
}
202+
}
203+
204+
private _startMetricsTimer(): void {
205+
if (!this.opts.getMetricsSnapshot) return;
206+
const interval = this.opts.metricsIntervalMs ?? DEFAULT_METRICS_INTERVAL_MS;
207+
this.metricsTimer = setInterval(() => {
208+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
209+
const snapshots = this.opts.getMetricsSnapshot?.();
210+
if (!snapshots || snapshots.length === 0) return;
211+
const t = Date.now();
212+
for (const { cadence, metrics } of snapshots) {
213+
if (Object.keys(metrics).length === 0) continue;
214+
this.ws.send(
215+
JSON.stringify({
216+
type: 'clientMetrics',
217+
payload: { t, cadence, metrics },
218+
})
219+
);
220+
}
221+
}, interval);
222+
}
223+
224+
private _clearTimers(): void {
225+
if (this.metricsTimer !== null) {
226+
clearInterval(this.metricsTimer);
227+
this.metricsTimer = null;
228+
}
229+
if (this.reconnectTimer !== null) {
230+
clearTimeout(this.reconnectTimer);
231+
this.reconnectTimer = null;
232+
}
233+
}
234+
}

0 commit comments

Comments
 (0)