Skip to content

Commit 7e3bdae

Browse files
feat(oob): Adds initial oob implementation with metrics (#388)
1 parent 86c5ad7 commit 7e3bdae

13 files changed

Lines changed: 1480 additions & 67 deletions

File tree

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
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+
* Keys match the supported URL query parameter overrides (see ``CloudXR2DUI.applyUrlSeeds``).
29+
*/
30+
export interface StreamConfig {
31+
serverIP?: string;
32+
port?: number;
33+
panelHiddenAtStart?: boolean;
34+
codec?: string;
35+
}
36+
37+
export interface MetricsSnapshot {
38+
cadence: string;
39+
metrics: Record<string, number>;
40+
}
41+
42+
export interface ControlChannelOptions {
43+
/** Full WSS URL of the hub, e.g. wss://host:48322/oob/v1/ws */
44+
url: string;
45+
/** Sent in the register message. Must match CONTROL_TOKEN env var if set. */
46+
token?: string;
47+
/** Human-readable label in hub snapshots (optional). */
48+
deviceLabel?: string;
49+
/**
50+
* Called on hello (initial config) and on config push.
51+
* Apply the config to the CloudXR connection settings before connect.
52+
*/
53+
onConfig: (config: StreamConfig, configVersion: number) => void;
54+
/** Called when the WebSocket connection state changes. */
55+
onConnectionChange?: (connected: boolean) => void;
56+
/**
57+
* Optional: called periodically to get the latest metrics to report.
58+
* Return an empty array or null/undefined to skip a tick.
59+
*/
60+
getMetricsSnapshot?: () => MetricsSnapshot[] | null | undefined;
61+
/** How often to report metrics (ms). Default: 500. */
62+
metricsIntervalMs?: number;
63+
}
64+
65+
const RECONNECT_DELAY_MS = 3000;
66+
const DEFAULT_METRICS_INTERVAL_MS = 500;
67+
68+
export class HeadsetControlChannel {
69+
private ws: WebSocket | null = null;
70+
private disposed = false;
71+
private metricsTimer: ReturnType<typeof setInterval> | null = null;
72+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
73+
74+
constructor(private readonly opts: ControlChannelOptions) {}
75+
76+
/** Open the WebSocket and start the reconnection loop. */
77+
connect(): void {
78+
if (this.disposed) return;
79+
this._openWebSocket();
80+
}
81+
82+
/** Close the channel permanently. Safe to call multiple times. */
83+
dispose(): void {
84+
this.disposed = true;
85+
this._clearTimers();
86+
if (this.ws) {
87+
this.ws.onclose = null; // prevent reconnect on this close
88+
this.ws.close();
89+
this.ws = null;
90+
}
91+
}
92+
93+
// ---------------------------------------------------------------------------
94+
// Private
95+
// ---------------------------------------------------------------------------
96+
97+
private _openWebSocket(): void {
98+
if (this.disposed) return;
99+
100+
let ws: WebSocket;
101+
try {
102+
ws = new WebSocket(this.opts.url);
103+
} catch (err) {
104+
if (this.disposed) return;
105+
console.warn(
106+
'[ControlChannel] WebSocket constructor failed for',
107+
this.opts.url,
108+
err
109+
);
110+
this.ws = null;
111+
this._afterSocketClosed();
112+
return;
113+
}
114+
115+
this.ws = ws;
116+
117+
ws.onopen = () => {
118+
ws.send(
119+
JSON.stringify({
120+
type: 'register',
121+
payload: {
122+
role: 'headset',
123+
...(this.opts.token ? { token: this.opts.token } : {}),
124+
...(this.opts.deviceLabel ? { deviceLabel: this.opts.deviceLabel } : {}),
125+
},
126+
})
127+
);
128+
this.opts.onConnectionChange?.(true);
129+
this._startMetricsTimer();
130+
};
131+
132+
ws.onmessage = (ev) => {
133+
if (typeof ev.data !== 'string') return;
134+
let msg: { type?: string; payload?: unknown };
135+
try {
136+
msg = JSON.parse(ev.data);
137+
} catch {
138+
return;
139+
}
140+
this._handleMessage(msg);
141+
};
142+
143+
ws.onclose = (ev: CloseEvent) => {
144+
this.ws = null;
145+
this._afterSocketClosed(ev.code);
146+
};
147+
148+
ws.onerror = () => {
149+
// onclose fires next; reconnect logic lives there
150+
};
151+
}
152+
153+
/** Clear timers, notify disconnected, schedule reconnect unless terminal. */
154+
private _afterSocketClosed(closeCode?: number): void {
155+
this._clearTimers();
156+
this.opts.onConnectionChange?.(false);
157+
if (closeCode === 1008) {
158+
console.warn('[ControlChannel] Terminal close (1008 policy/auth); will not reconnect.');
159+
return;
160+
}
161+
if (!this.disposed) {
162+
this.reconnectTimer = setTimeout(() => this._openWebSocket(), RECONNECT_DELAY_MS);
163+
}
164+
}
165+
166+
private _handleMessage(msg: { type?: string; payload?: unknown }): void {
167+
const type = msg.type;
168+
const payload = (msg.payload ?? {}) as Record<string, unknown>;
169+
170+
if (type === 'hello') {
171+
// hello to headset includes initial config
172+
if (
173+
payload.config != null &&
174+
typeof payload.configVersion === 'number'
175+
) {
176+
this.opts.onConfig(payload.config as StreamConfig, payload.configVersion as number);
177+
}
178+
} else if (type === 'config') {
179+
if (
180+
payload.config != null &&
181+
typeof payload.configVersion === 'number'
182+
) {
183+
this.opts.onConfig(payload.config as StreamConfig, payload.configVersion as number);
184+
}
185+
} else if (type === 'error') {
186+
console.warn('[ControlChannel] Hub error:', payload);
187+
}
188+
}
189+
190+
private _startMetricsTimer(): void {
191+
if (!this.opts.getMetricsSnapshot) return;
192+
const interval = this.opts.metricsIntervalMs ?? DEFAULT_METRICS_INTERVAL_MS;
193+
this.metricsTimer = setInterval(() => {
194+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
195+
const snapshots = this.opts.getMetricsSnapshot?.();
196+
if (!snapshots || snapshots.length === 0) return;
197+
const t = Date.now();
198+
for (const { cadence, metrics } of snapshots) {
199+
if (Object.keys(metrics).length === 0) continue;
200+
this.ws.send(
201+
JSON.stringify({
202+
type: 'clientMetrics',
203+
payload: { t, cadence, metrics },
204+
})
205+
);
206+
}
207+
}, interval);
208+
}
209+
210+
private _clearTimers(): void {
211+
if (this.metricsTimer !== null) {
212+
clearInterval(this.metricsTimer);
213+
this.metricsTimer = null;
214+
}
215+
if (this.reconnectTimer !== null) {
216+
clearTimeout(this.reconnectTimer);
217+
this.reconnectTimer = null;
218+
}
219+
}
220+
}

0 commit comments

Comments
 (0)