-
Notifications
You must be signed in to change notification settings - Fork 14
Adds out of band control via adb to simplify wearing headset on neck scenario #312
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
yanziz-nvidia
wants to merge
1
commit into
main
Choose a base branch
from
yanziz/operator-dashboard
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,230 @@ | ||
| /* | ||
| * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| /** | ||
| * HeadsetControlChannel — WebSocket client that connects the XR headset to the | ||
| * teleop control hub running in the WSS proxy. | ||
| * | ||
| * Protocol: docs/source/references/oob_teleop_control.rst (Sphinx build) | ||
| * Hub WS URL: ``wss://<serverIP>:<port>/oob/v1/ws`` when the page URL includes ``oobEnable=1`` and | ||
| * valid ``serverIP`` / ``port`` query parameters (see App.tsx). No connection is made without them. | ||
| * | ||
| * Usage (in App.tsx): | ||
| * | ||
| * const channel = new HeadsetControlChannel({ | ||
| * url: 'wss://host:48322/oob/v1/ws', | ||
| * onConfig: (config, version) => { ... }, | ||
| * getMetricsSnapshot: () => [ { cadence: 'frame', metrics: { ... } } ], | ||
| * }); | ||
| * channel.connect(); | ||
| * // on cleanup: | ||
| * channel.dispose(); | ||
| */ | ||
|
|
||
| /** | ||
| * Fields the hub merges into ``config`` on ``hello`` / ``config`` pushes. | ||
| * | ||
| * **Streaming target:** ``serverIP``, ``port``, ``proxyUrl``, ``mediaAddress``, ``mediaPort``. | ||
| * | ||
| * **Client UI (allowlist):** keys match HTML form element **ids** on the Teleop page—only these may be | ||
| * set remotely today (see ``CloudXR2DUI``). The hub stores arbitrary top-level keys, but the web | ||
| * client only applies this known set so coercion and validation stay explicit. | ||
| */ | ||
| export interface StreamConfig { | ||
| serverIP?: string; | ||
| port?: number; | ||
| proxyUrl?: string | null; | ||
| mediaAddress?: string; | ||
| mediaPort?: number; | ||
| /** Form id ``panelHiddenAtStart``: hide the in-XR control panel when the session starts. */ | ||
| panelHiddenAtStart?: boolean; | ||
| /** Form id ``codec``: ``h264`` | ``h265`` | ``av1`` when supported. */ | ||
| codec?: string; | ||
| /** Form id ``perEyeWidth``. */ | ||
| perEyeWidth?: number; | ||
| /** Form id ``perEyeHeight``. */ | ||
| perEyeHeight?: number; | ||
| } | ||
|
|
||
| export interface MetricsSnapshot { | ||
| cadence: string; | ||
| metrics: Record<string, number>; | ||
| } | ||
|
|
||
| export interface ControlChannelOptions { | ||
| /** Full WSS URL of the hub, e.g. wss://host:48322/oob/v1/ws */ | ||
| url: string; | ||
| /** Sent in the register message. Must match CONTROL_TOKEN env var if set. */ | ||
| token?: string; | ||
| /** Human-readable label in hub snapshots (optional). */ | ||
| deviceLabel?: string; | ||
| /** | ||
| * Called on hello (initial config) and on config push. | ||
| * Apply the config to the CloudXR connection settings before connect. | ||
| */ | ||
| onConfig: (config: StreamConfig, configVersion: number) => void; | ||
| /** Called when the WebSocket connection state changes. */ | ||
| onConnectionChange?: (connected: boolean) => void; | ||
| /** | ||
| * Optional: called periodically to get the latest metrics to report. | ||
| * Return an empty array or null/undefined to skip a tick. | ||
| */ | ||
| getMetricsSnapshot?: () => MetricsSnapshot[] | null | undefined; | ||
| /** How often to report metrics (ms). Default: 500. */ | ||
| metricsIntervalMs?: number; | ||
| } | ||
|
|
||
| const RECONNECT_DELAY_MS = 3000; | ||
| const DEFAULT_METRICS_INTERVAL_MS = 500; | ||
|
|
||
| export class HeadsetControlChannel { | ||
| private ws: WebSocket | null = null; | ||
| private disposed = false; | ||
| private metricsTimer: ReturnType<typeof setInterval> | null = null; | ||
| private reconnectTimer: ReturnType<typeof setTimeout> | null = null; | ||
|
|
||
| constructor(private readonly opts: ControlChannelOptions) {} | ||
|
|
||
| /** Open the WebSocket and start the reconnection loop. */ | ||
| connect(): void { | ||
| if (this.disposed) return; | ||
| this._openWebSocket(); | ||
| } | ||
|
|
||
| /** Close the channel permanently. Safe to call multiple times. */ | ||
| dispose(): void { | ||
| this.disposed = true; | ||
| this._clearTimers(); | ||
| if (this.ws) { | ||
| this.ws.onclose = null; // prevent reconnect on this close | ||
| this.ws.close(); | ||
| this.ws = null; | ||
| } | ||
| } | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Private | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| private _openWebSocket(): void { | ||
| if (this.disposed) return; | ||
|
|
||
| let ws: WebSocket; | ||
| try { | ||
| ws = new WebSocket(this.opts.url); | ||
| } catch (err) { | ||
| if (this.disposed) return; | ||
| console.warn( | ||
| '[ControlChannel] WebSocket constructor failed for', | ||
| this.opts.url, | ||
| err | ||
| ); | ||
| this.ws = null; | ||
| this._afterSocketClosed(); | ||
| return; | ||
| } | ||
|
|
||
| this.ws = ws; | ||
|
|
||
| ws.onopen = () => { | ||
| ws.send( | ||
| JSON.stringify({ | ||
| type: 'register', | ||
| payload: { | ||
| role: 'headset', | ||
| ...(this.opts.token ? { token: this.opts.token } : {}), | ||
| ...(this.opts.deviceLabel ? { deviceLabel: this.opts.deviceLabel } : {}), | ||
| }, | ||
| }) | ||
| ); | ||
| this.opts.onConnectionChange?.(true); | ||
| this._startMetricsTimer(); | ||
| }; | ||
|
|
||
| ws.onmessage = (ev) => { | ||
| if (typeof ev.data !== 'string') return; | ||
| let msg: { type?: string; payload?: unknown }; | ||
| try { | ||
| msg = JSON.parse(ev.data); | ||
| } catch { | ||
| return; | ||
| } | ||
| this._handleMessage(msg); | ||
| }; | ||
|
|
||
| ws.onclose = () => { | ||
| this.ws = null; | ||
| this._afterSocketClosed(); | ||
| }; | ||
|
|
||
| ws.onerror = () => { | ||
| // onclose fires next; reconnect logic lives there | ||
| }; | ||
| } | ||
|
|
||
| /** Clear timers, notify disconnected, schedule reconnect (same path as WebSocket onclose). */ | ||
| private _afterSocketClosed(): void { | ||
| this._clearTimers(); | ||
| this.opts.onConnectionChange?.(false); | ||
| if (!this.disposed) { | ||
| this.reconnectTimer = setTimeout(() => this._openWebSocket(), RECONNECT_DELAY_MS); | ||
| } | ||
| } | ||
|
|
||
| private _handleMessage(msg: { type?: string; payload?: unknown }): void { | ||
| const type = msg.type; | ||
| const payload = (msg.payload ?? {}) as Record<string, unknown>; | ||
|
|
||
| if (type === 'hello') { | ||
| // hello to headset includes initial config | ||
| if ( | ||
| payload.config != null && | ||
| typeof payload.configVersion === 'number' | ||
| ) { | ||
| this.opts.onConfig(payload.config as StreamConfig, payload.configVersion as number); | ||
| } | ||
| } else if (type === 'config') { | ||
| if ( | ||
| payload.config != null && | ||
| typeof payload.configVersion === 'number' | ||
| ) { | ||
| this.opts.onConfig(payload.config as StreamConfig, payload.configVersion as number); | ||
| } | ||
| } else if (type === 'error') { | ||
| console.warn('[ControlChannel] Hub error:', payload); | ||
| } | ||
| } | ||
|
|
||
| private _startMetricsTimer(): void { | ||
| if (!this.opts.getMetricsSnapshot) return; | ||
| const interval = this.opts.metricsIntervalMs ?? DEFAULT_METRICS_INTERVAL_MS; | ||
| this.metricsTimer = setInterval(() => { | ||
| if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return; | ||
| const snapshots = this.opts.getMetricsSnapshot?.(); | ||
| if (!snapshots || snapshots.length === 0) return; | ||
| const t = Date.now(); | ||
| for (const { cadence, metrics } of snapshots) { | ||
| if (Object.keys(metrics).length === 0) continue; | ||
| this.ws.send( | ||
| JSON.stringify({ | ||
| type: 'clientMetrics', | ||
| payload: { t, cadence, metrics }, | ||
| }) | ||
| ); | ||
| } | ||
| }, interval); | ||
| } | ||
|
|
||
| private _clearTimers(): void { | ||
| if (this.metricsTimer !== null) { | ||
| clearInterval(this.metricsTimer); | ||
| this.metricsTimer = null; | ||
| } | ||
| if (this.reconnectTimer !== null) { | ||
| clearTimeout(this.reconnectTimer); | ||
| this.reconnectTimer = null; | ||
| } | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.