@myerscarpenter/quest-dev 1.4.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +7 -0
- package/.github/workflows/docs.yml +45 -0
- package/.github/workflows/publish.yml +11 -1
- package/README.md +27 -0
- package/build/cast/decoder.d.ts +48 -0
- package/build/cast/decoder.d.ts.map +1 -0
- package/build/cast/decoder.js +152 -0
- package/build/cast/decoder.js.map +1 -0
- package/build/cast/session.d.ts +87 -0
- package/build/cast/session.d.ts.map +1 -0
- package/build/cast/session.js +565 -0
- package/build/cast/session.js.map +1 -0
- package/build/commands/logcat.d.ts.map +1 -1
- package/build/commands/logcat.js +7 -6
- package/build/commands/logcat.js.map +1 -1
- package/build/commands/open.d.ts.map +1 -1
- package/build/commands/open.js +9 -4
- package/build/commands/open.js.map +1 -1
- package/build/commands/screenshot.d.ts.map +1 -1
- package/build/commands/screenshot.js +17 -20
- package/build/commands/screenshot.js.map +1 -1
- package/build/commands/stay-awake.d.ts +2 -15
- package/build/commands/stay-awake.d.ts.map +1 -1
- package/build/commands/stay-awake.js +14 -77
- package/build/commands/stay-awake.js.map +1 -1
- package/build/daemon/cast-manager.d.ts +42 -0
- package/build/daemon/cast-manager.d.ts.map +1 -0
- package/build/daemon/cast-manager.js +243 -0
- package/build/daemon/cast-manager.js.map +1 -0
- package/build/daemon/client.d.ts +40 -0
- package/build/daemon/client.d.ts.map +1 -0
- package/build/daemon/client.js +133 -0
- package/build/daemon/client.js.map +1 -0
- package/build/daemon/daemon.d.ts +20 -0
- package/build/daemon/daemon.d.ts.map +1 -0
- package/build/daemon/daemon.js +130 -0
- package/build/daemon/daemon.js.map +1 -0
- package/build/daemon/deploy.d.ts +44 -0
- package/build/daemon/deploy.d.ts.map +1 -0
- package/build/daemon/deploy.js +230 -0
- package/build/daemon/deploy.js.map +1 -0
- package/build/daemon/logcat-manager.d.ts +39 -0
- package/build/daemon/logcat-manager.d.ts.map +1 -0
- package/build/daemon/logcat-manager.js +194 -0
- package/build/daemon/logcat-manager.js.map +1 -0
- package/build/daemon/server.d.ts +19 -0
- package/build/daemon/server.d.ts.map +1 -0
- package/build/daemon/server.js +482 -0
- package/build/daemon/server.js.map +1 -0
- package/build/daemon/stay-awake-manager.d.ts +22 -0
- package/build/daemon/stay-awake-manager.d.ts.map +1 -0
- package/build/daemon/stay-awake-manager.js +74 -0
- package/build/daemon/stay-awake-manager.js.map +1 -0
- package/build/index.js +285 -45
- package/build/index.js.map +1 -1
- package/build/public/dashboard.js +749 -0
- package/build/public/index.html +12 -0
- package/build/public/style.css +106 -0
- package/build/utils/adb.d.ts +12 -0
- package/build/utils/adb.d.ts.map +1 -1
- package/build/utils/adb.js +116 -51
- package/build/utils/adb.js.map +1 -1
- package/build/utils/casting-apk.d.ts +40 -0
- package/build/utils/casting-apk.d.ts.map +1 -0
- package/build/utils/casting-apk.js +252 -0
- package/build/utils/casting-apk.js.map +1 -0
- package/build/utils/config.d.ts +5 -3
- package/build/utils/config.d.ts.map +1 -1
- package/build/utils/config.js +18 -38
- package/build/utils/config.js.map +1 -1
- package/build/utils/exec.d.ts +5 -0
- package/build/utils/exec.d.ts.map +1 -1
- package/build/utils/exec.js +17 -0
- package/build/utils/exec.js.map +1 -1
- package/build/utils/filename.d.ts +7 -1
- package/build/utils/filename.d.ts.map +1 -1
- package/build/utils/filename.js +17 -2
- package/build/utils/filename.js.map +1 -1
- package/build/utils/filename.test.js +33 -1
- package/build/utils/filename.test.js.map +1 -1
- package/build/utils/jpeg-comment.d.ts +14 -0
- package/build/utils/jpeg-comment.d.ts.map +1 -0
- package/build/utils/jpeg-comment.js +28 -0
- package/build/utils/jpeg-comment.js.map +1 -0
- package/build/utils/test-properties.d.ts +34 -0
- package/build/utils/test-properties.d.ts.map +1 -0
- package/build/utils/test-properties.js +73 -0
- package/build/utils/test-properties.js.map +1 -0
- package/build/utils/verbose.d.ts +3 -0
- package/build/utils/verbose.d.ts.map +1 -0
- package/build/utils/verbose.js +13 -0
- package/build/utils/verbose.js.map +1 -0
- package/package.json +11 -5
- package/packages/cast2-protocol/README.md +86 -0
- package/packages/cast2-protocol/docs/_config.yml +4 -0
- package/packages/cast2-protocol/docs/feature-flags.md +102 -0
- package/packages/cast2-protocol/docs/index.md +24 -0
- package/packages/cast2-protocol/docs/open-investigations.md +149 -0
- package/packages/cast2-protocol/docs/protocol.md +602 -0
- package/packages/cast2-protocol/package.json +46 -0
- package/packages/cast2-protocol/src/constants.ts +65 -0
- package/packages/cast2-protocol/src/index.ts +7 -0
- package/packages/cast2-protocol/src/mgik.ts +69 -0
- package/packages/cast2-protocol/src/mud.ts +294 -0
- package/packages/cast2-protocol/src/pose.ts +99 -0
- package/packages/cast2-protocol/src/resolutions.ts +34 -0
- package/packages/cast2-protocol/src/types.ts +64 -0
- package/packages/cast2-protocol/src/xrsp.ts +73 -0
- package/packages/cast2-protocol/tests/mgik.test.ts +80 -0
- package/packages/cast2-protocol/tests/mud.test.ts +295 -0
- package/packages/cast2-protocol/tests/pose.test.ts +173 -0
- package/packages/cast2-protocol/tests/xrsp.test.ts +90 -0
- package/packages/cast2-protocol/tsconfig.json +20 -0
- package/pnpm-workspace.yaml +2 -0
- package/src/cast/decoder.ts +178 -0
- package/src/cast/session.ts +708 -0
- package/src/commands/logcat.ts +6 -5
- package/src/commands/open.ts +10 -3
- package/src/commands/screenshot.ts +19 -13
- package/src/commands/stay-awake.ts +22 -91
- package/src/daemon/adbkit-apkreader.d.ts +14 -0
- package/src/daemon/cast-manager.ts +282 -0
- package/src/daemon/client.ts +166 -0
- package/src/daemon/daemon.ts +169 -0
- package/src/daemon/deploy.ts +307 -0
- package/src/daemon/logcat-manager.ts +229 -0
- package/src/daemon/server.ts +595 -0
- package/src/daemon/stay-awake-manager.ts +83 -0
- package/src/index.ts +340 -56
- package/src/public/dashboard.js +288 -0
- package/src/public/index.html +12 -0
- package/src/public/style.css +106 -0
- package/src/utils/adb.ts +129 -42
- package/src/utils/casting-apk.ts +276 -0
- package/src/utils/config.ts +18 -36
- package/src/utils/exec.ts +20 -0
- package/src/utils/filename.test.ts +41 -1
- package/src/utils/filename.ts +18 -2
- package/src/utils/jpeg-comment.ts +30 -0
- package/src/utils/test-properties.ts +94 -0
- package/src/utils/verbose.ts +14 -0
- package/tests/cast/auto-layer.test.ts +87 -0
- package/tests/cast/decoder.test.ts +82 -0
- package/tests/cast/session-restart.test.ts +107 -0
- package/tests/config.test.ts +17 -22
- package/tests/daemon/api-status.test.ts +82 -0
- package/tests/daemon/cast-manager.test.ts +69 -0
- package/tests/daemon/mjpeg-stream.test.ts +144 -0
- package/tests/daemon/pose-endpoint.test.ts +63 -0
- package/tests/daemon/start-guard.test.ts +77 -0
- package/vitest.config.ts +10 -0
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CastSession: manages TCP connections to Quest, protocol handshake,
|
|
3
|
+
* H.264 video decoding, pose control, and stay-awake lifecycle.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createServer, type Server, type Socket } from "node:net";
|
|
7
|
+
import { EventEmitter } from "node:events";
|
|
8
|
+
import { randomUUID } from "node:crypto";
|
|
9
|
+
import { FrameDecoder } from "./decoder.js";
|
|
10
|
+
import {
|
|
11
|
+
type LayerInfo,
|
|
12
|
+
type PoseOffset,
|
|
13
|
+
type PoseDelta,
|
|
14
|
+
type PoseState,
|
|
15
|
+
type XrspHeader,
|
|
16
|
+
createPoseState,
|
|
17
|
+
eulerToQuat,
|
|
18
|
+
updatePose,
|
|
19
|
+
setPoseOffset,
|
|
20
|
+
parseXrspHeader,
|
|
21
|
+
xrspPayloadSize,
|
|
22
|
+
packXrsp,
|
|
23
|
+
XRSP_HEADER_SIZE,
|
|
24
|
+
ACK_MARKER,
|
|
25
|
+
CONFIG_MARKER,
|
|
26
|
+
VIDEO_META_MARKER,
|
|
27
|
+
MGIK_MAGIC,
|
|
28
|
+
CAST_PORT,
|
|
29
|
+
QUEST_CAST_PORT,
|
|
30
|
+
CMD_SHORT_ACK_65,
|
|
31
|
+
CMD_SHORT_ACK_CD,
|
|
32
|
+
CMD_SHORT_ACK_12D,
|
|
33
|
+
KEEPALIVE_ACK_INCREMENT,
|
|
34
|
+
LAYER_PANEL_APP,
|
|
35
|
+
packMgikSub,
|
|
36
|
+
detectSubMagic,
|
|
37
|
+
isMgikMagic,
|
|
38
|
+
buildInit,
|
|
39
|
+
buildConfig,
|
|
40
|
+
buildKeepalive,
|
|
41
|
+
buildShortAck,
|
|
42
|
+
buildPose,
|
|
43
|
+
buildDisplayConfig,
|
|
44
|
+
buildDisconnect,
|
|
45
|
+
buildSetProperty,
|
|
46
|
+
buildVirtualMouse,
|
|
47
|
+
buildInputForwardingState,
|
|
48
|
+
buildStartInputForwarding,
|
|
49
|
+
buildActivateLayer,
|
|
50
|
+
buildMud,
|
|
51
|
+
parseLayerConfiguration,
|
|
52
|
+
RESOLUTIONS,
|
|
53
|
+
DEFAULT_RESOLUTION,
|
|
54
|
+
} from "@myerscarpenter/cast2-protocol";
|
|
55
|
+
import { execCommand } from "../utils/exec.js";
|
|
56
|
+
import { verbose } from "../utils/verbose.js";
|
|
57
|
+
|
|
58
|
+
export interface CastSessionOptions {
|
|
59
|
+
listenPort?: number;
|
|
60
|
+
width?: number;
|
|
61
|
+
height?: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export class CastSession extends EventEmitter {
|
|
65
|
+
// Configuration
|
|
66
|
+
private _listenPort: number;
|
|
67
|
+
|
|
68
|
+
// Connection state
|
|
69
|
+
private _connected = false;
|
|
70
|
+
private _running = false;
|
|
71
|
+
private server: Server | null = null;
|
|
72
|
+
private controlSocket: Socket | null = null;
|
|
73
|
+
private videoSocket: Socket | null = null;
|
|
74
|
+
private controlSeq = 0;
|
|
75
|
+
|
|
76
|
+
// Protocol state
|
|
77
|
+
private subMagic: number | null = null;
|
|
78
|
+
private controlMsgSeq = 0;
|
|
79
|
+
private sessionUuid: string;
|
|
80
|
+
private sessionTimestamp: string;
|
|
81
|
+
private questMsgSeq = 0;
|
|
82
|
+
|
|
83
|
+
// Video state
|
|
84
|
+
private decoder: FrameDecoder;
|
|
85
|
+
private currentSpsPps: Buffer | null = null;
|
|
86
|
+
private currentIdr: Buffer | null = null;
|
|
87
|
+
private _frameCount = 0;
|
|
88
|
+
private _byteCount = 0;
|
|
89
|
+
private startTime = 0;
|
|
90
|
+
|
|
91
|
+
// Display
|
|
92
|
+
private _width: number;
|
|
93
|
+
private _height: number;
|
|
94
|
+
|
|
95
|
+
// Pose
|
|
96
|
+
private _pose: PoseState;
|
|
97
|
+
private _poseLoopTimer: ReturnType<typeof setInterval> | null = null;
|
|
98
|
+
private _poseLoopActive = false;
|
|
99
|
+
|
|
100
|
+
// Eye mode
|
|
101
|
+
private _eye = 1; // EYE_LEFT default
|
|
102
|
+
|
|
103
|
+
// Input forwarding
|
|
104
|
+
private inputForwardingStarted = false;
|
|
105
|
+
private _layerId = 0;
|
|
106
|
+
private _layerAutoSelected = false;
|
|
107
|
+
private _layers = new Map<number, LayerInfo>();
|
|
108
|
+
|
|
109
|
+
constructor(options: CastSessionOptions = {}) {
|
|
110
|
+
super();
|
|
111
|
+
this._listenPort = options.listenPort ?? CAST_PORT;
|
|
112
|
+
const def = RESOLUTIONS[DEFAULT_RESOLUTION];
|
|
113
|
+
this._width = options.width ?? def.width;
|
|
114
|
+
this._height = options.height ?? def.height;
|
|
115
|
+
this.sessionUuid = randomUUID();
|
|
116
|
+
this.sessionTimestamp = String(Date.now());
|
|
117
|
+
this._pose = createPoseState();
|
|
118
|
+
this.decoder = new FrameDecoder();
|
|
119
|
+
|
|
120
|
+
this.decoder.on("frame", () => {
|
|
121
|
+
this.emit("frame");
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// --- Public getters ---
|
|
126
|
+
|
|
127
|
+
get listenPort(): number { return this._listenPort; }
|
|
128
|
+
get connected(): boolean { return this._connected; }
|
|
129
|
+
get running(): boolean { return this._running; }
|
|
130
|
+
get frameCount(): number { return this._frameCount; }
|
|
131
|
+
get byteCount(): number { return this._byteCount; }
|
|
132
|
+
get width(): number { return this._width; }
|
|
133
|
+
get height(): number { return this._height; }
|
|
134
|
+
get pose(): PoseState { return this._pose; }
|
|
135
|
+
get layers(): Map<number, LayerInfo> { return this._layers; }
|
|
136
|
+
get layerId(): number { return this._layerId; }
|
|
137
|
+
|
|
138
|
+
get poseLoopActive(): boolean { return this._poseLoopActive; }
|
|
139
|
+
get eye(): number { return this._eye; }
|
|
140
|
+
get elapsedSeconds(): number {
|
|
141
|
+
return this.startTime > 0 ? Math.round((Date.now() - this.startTime) / 100) / 10 : 0;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
get fps(): number {
|
|
145
|
+
const elapsed = this.startTime > 0 ? (Date.now() - this.startTime) / 1000 : 0;
|
|
146
|
+
return elapsed > 0 ? Math.round((this._frameCount / elapsed) * 10) / 10 : 0;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// --- Lifecycle ---
|
|
150
|
+
|
|
151
|
+
/** Bind the TCP server, trying successive ports on EADDRINUSE. */
|
|
152
|
+
async bind(): Promise<void> {
|
|
153
|
+
FrameDecoder.checkFfmpeg();
|
|
154
|
+
|
|
155
|
+
const maxAttempts = 10;
|
|
156
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
157
|
+
try {
|
|
158
|
+
await this.tryBind(this._listenPort);
|
|
159
|
+
verbose(`TCP server listening on port ${this._listenPort}`);
|
|
160
|
+
return;
|
|
161
|
+
} catch (err: unknown) {
|
|
162
|
+
if ((err as NodeJS.ErrnoException).code === "EADDRINUSE") {
|
|
163
|
+
verbose(`Port ${this._listenPort} in use, trying ${this._listenPort + 1}`);
|
|
164
|
+
this._listenPort++;
|
|
165
|
+
} else {
|
|
166
|
+
throw err;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
throw new Error(`No available port found (tried ${maxAttempts} ports)`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private tryBind(port: number): Promise<void> {
|
|
174
|
+
return new Promise((resolve, reject) => {
|
|
175
|
+
this.server = createServer();
|
|
176
|
+
this.server.listen(port, "0.0.0.0", () => resolve());
|
|
177
|
+
this.server.on("error", reject);
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async start(questIp?: string): Promise<void> {
|
|
182
|
+
this._running = true;
|
|
183
|
+
this.startTime = Date.now();
|
|
184
|
+
|
|
185
|
+
await this.waitForConnections(questIp);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async stop(): Promise<void> {
|
|
189
|
+
this._running = false;
|
|
190
|
+
|
|
191
|
+
if (this._connected && this.subMagic) {
|
|
192
|
+
try {
|
|
193
|
+
this.sendXrsp(buildDisconnect(this.subMagic, this.nextSeq()));
|
|
194
|
+
} catch {
|
|
195
|
+
// Best effort
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
this.stopPoseLoop();
|
|
200
|
+
this.decoder.stop();
|
|
201
|
+
this.controlSocket?.destroy();
|
|
202
|
+
this.videoSocket?.destroy();
|
|
203
|
+
this.server?.close();
|
|
204
|
+
this._connected = false;
|
|
205
|
+
this.emit("disconnected");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async restart(): Promise<void> {
|
|
209
|
+
verbose("Restarting cast session...");
|
|
210
|
+
await this.stop();
|
|
211
|
+
|
|
212
|
+
// Reset protocol state
|
|
213
|
+
this.sessionUuid = randomUUID();
|
|
214
|
+
this.sessionTimestamp = String(Date.now());
|
|
215
|
+
this.subMagic = null;
|
|
216
|
+
this.controlMsgSeq = 0;
|
|
217
|
+
this.controlSeq = 0;
|
|
218
|
+
this._frameCount = 0;
|
|
219
|
+
this._byteCount = 0;
|
|
220
|
+
this.currentSpsPps = null;
|
|
221
|
+
this.currentIdr = null;
|
|
222
|
+
this._pose = createPoseState();
|
|
223
|
+
this.inputForwardingStarted = false;
|
|
224
|
+
this._layerAutoSelected = false;
|
|
225
|
+
this._layers.clear();
|
|
226
|
+
|
|
227
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
228
|
+
|
|
229
|
+
// Re-bind the TCP server (stop() closed it)
|
|
230
|
+
if (this.server) {
|
|
231
|
+
await new Promise<void>((resolve, reject) => {
|
|
232
|
+
this.server!.listen(this._listenPort, "0.0.0.0", () => resolve());
|
|
233
|
+
this.server!.once("error", reject);
|
|
234
|
+
});
|
|
235
|
+
verbose(`TCP server re-listening on port ${this._listenPort}`);
|
|
236
|
+
} else {
|
|
237
|
+
await this.bind();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
await this.start();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// --- ADB Setup ---
|
|
244
|
+
|
|
245
|
+
async adbSetup(questIp: string): Promise<void> {
|
|
246
|
+
verbose("Setting up ADB for Quest at", questIp);
|
|
247
|
+
const device = `${questIp}:5555`;
|
|
248
|
+
|
|
249
|
+
await execCommand("adb", ["connect", device]);
|
|
250
|
+
await execCommand("adb", [
|
|
251
|
+
"-s", device, "shell",
|
|
252
|
+
"setprop debug.oculus.command_line_media_capture true",
|
|
253
|
+
]);
|
|
254
|
+
// Set the port the Quest casting service will connect to
|
|
255
|
+
await execCommand("adb", [
|
|
256
|
+
"-s", device, "shell",
|
|
257
|
+
`setprop debug.oculus.magic.port ${QUEST_CAST_PORT}`,
|
|
258
|
+
]);
|
|
259
|
+
// Set up reverse mappings for both ports (matching MQDH)
|
|
260
|
+
verbose("Setting up ADB reverse port forward...");
|
|
261
|
+
for (const port of [CAST_PORT, QUEST_CAST_PORT]) {
|
|
262
|
+
try {
|
|
263
|
+
await execCommand("adb", ["-s", device, "reverse", "--remove", `tcp:${port}`]);
|
|
264
|
+
} catch {
|
|
265
|
+
// No existing mapping — that's fine
|
|
266
|
+
}
|
|
267
|
+
await execCommand("adb", [
|
|
268
|
+
"-s", device, "reverse",
|
|
269
|
+
`tcp:${port}`, `tcp:${this.listenPort}`,
|
|
270
|
+
]);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async startCastService(questIp: string): Promise<void> {
|
|
275
|
+
const device = `${questIp}:5555`;
|
|
276
|
+
verbose("Starting cast service on Quest...");
|
|
277
|
+
await execCommand("adb", [
|
|
278
|
+
"-s", device, "shell",
|
|
279
|
+
"am start-foreground-service -n com.oculus.magicislandcastingservice/.CastingService",
|
|
280
|
+
]);
|
|
281
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
282
|
+
await execCommand("adb", [
|
|
283
|
+
"-s", device, "shell",
|
|
284
|
+
"am broadcast -a com.oculus.magicislandcastingservice.CONNECT",
|
|
285
|
+
]);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// --- TCP ---
|
|
289
|
+
|
|
290
|
+
private waitForConnections(questIp?: string): Promise<void> {
|
|
291
|
+
return new Promise((resolve, reject) => {
|
|
292
|
+
if (!this.server) {
|
|
293
|
+
reject(new Error("TCP server not bound — call bind() first"));
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const connections: Socket[] = [];
|
|
298
|
+
let resolved = false;
|
|
299
|
+
|
|
300
|
+
this.server.on("connection", (socket: Socket) => {
|
|
301
|
+
const idx = connections.length + 1;
|
|
302
|
+
verbose(`Connection #${idx} from ${socket.remoteAddress}`);
|
|
303
|
+
connections.push(socket);
|
|
304
|
+
|
|
305
|
+
if (connections.length === 2) {
|
|
306
|
+
this.controlSocket = connections[0];
|
|
307
|
+
this.videoSocket = connections[1];
|
|
308
|
+
this._connected = true;
|
|
309
|
+
verbose("Both connections established");
|
|
310
|
+
this.emit("connected");
|
|
311
|
+
this.handleVideo();
|
|
312
|
+
if (!resolved) {
|
|
313
|
+
resolved = true;
|
|
314
|
+
resolve();
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
// Trigger the cast service now that we're listening
|
|
320
|
+
if (questIp) {
|
|
321
|
+
this.startCastService(questIp).catch((err) => {
|
|
322
|
+
if (!resolved) {
|
|
323
|
+
resolved = true;
|
|
324
|
+
reject(err);
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Timeout after 15 seconds
|
|
330
|
+
const timeout = setTimeout(() => {
|
|
331
|
+
if (!resolved) {
|
|
332
|
+
resolved = true;
|
|
333
|
+
if (connections.length < 2) {
|
|
334
|
+
verbose(`Timed out waiting for Quest connections (got ${connections.length})`);
|
|
335
|
+
this.server?.close();
|
|
336
|
+
reject(new Error(`Timed out waiting for Quest connections (got ${connections.length}/2)`));
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}, 15000);
|
|
340
|
+
|
|
341
|
+
this.server.on("error", (err) => {
|
|
342
|
+
clearTimeout(timeout);
|
|
343
|
+
if (!resolved) {
|
|
344
|
+
resolved = true;
|
|
345
|
+
reject(err);
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// Clear timeout once we get both connections
|
|
350
|
+
this.once("connected", () => clearTimeout(timeout));
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// --- XRSP I/O ---
|
|
355
|
+
|
|
356
|
+
private sendXrsp(payload: Buffer, flags?: number): void {
|
|
357
|
+
if (!this.controlSocket || this.controlSocket.destroyed) return;
|
|
358
|
+
const pkt = packXrsp(this.controlSeq, payload, flags);
|
|
359
|
+
this.controlSeq++;
|
|
360
|
+
this.controlSocket.write(pkt);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
private nextSeq(): number {
|
|
364
|
+
return this.controlMsgSeq++;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// --- Video handling (main protocol loop) ---
|
|
368
|
+
|
|
369
|
+
private handleVideo(): void {
|
|
370
|
+
if (!this.videoSocket) return;
|
|
371
|
+
|
|
372
|
+
let buffer = Buffer.alloc(0);
|
|
373
|
+
let handshakeStage = 0;
|
|
374
|
+
let ackVal = 0;
|
|
375
|
+
|
|
376
|
+
this.videoSocket.on("data", (chunk: Buffer) => {
|
|
377
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
378
|
+
|
|
379
|
+
while (buffer.length >= XRSP_HEADER_SIZE) {
|
|
380
|
+
const header = parseXrspHeader(buffer);
|
|
381
|
+
const payloadSize = xrspPayloadSize(header);
|
|
382
|
+
const totalSize = XRSP_HEADER_SIZE + payloadSize;
|
|
383
|
+
|
|
384
|
+
if (buffer.length < totalSize) break; // Need more data
|
|
385
|
+
|
|
386
|
+
const payload = buffer.subarray(XRSP_HEADER_SIZE, totalSize);
|
|
387
|
+
buffer = buffer.subarray(totalSize);
|
|
388
|
+
|
|
389
|
+
this.processVideoPayload(payload, header, handshakeStage, ackVal, (newStage, newAck) => {
|
|
390
|
+
handshakeStage = newStage;
|
|
391
|
+
ackVal = newAck;
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
this.videoSocket.on("end", () => {
|
|
397
|
+
verbose("Video connection EOF");
|
|
398
|
+
this._running = false;
|
|
399
|
+
this.emit("disconnected");
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
this.videoSocket.on("error", (err) => {
|
|
403
|
+
verbose("Video connection error:", err.message);
|
|
404
|
+
this._running = false;
|
|
405
|
+
this.emit("error", err);
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
private processVideoPayload(
|
|
410
|
+
payload: Buffer,
|
|
411
|
+
_header: XrspHeader,
|
|
412
|
+
handshakeStage: number,
|
|
413
|
+
ackVal: number,
|
|
414
|
+
setState: (stage: number, ack: number) => void,
|
|
415
|
+
): void {
|
|
416
|
+
if (payload.length < 4) return;
|
|
417
|
+
|
|
418
|
+
const first4 = payload.readUInt32BE(0);
|
|
419
|
+
|
|
420
|
+
// Skip MGIK magic header
|
|
421
|
+
if (isMgikMagic(payload)) return;
|
|
422
|
+
|
|
423
|
+
// Learn sub-magic from first Quest message
|
|
424
|
+
if (this.subMagic === null && payload.length === 24) {
|
|
425
|
+
const detected = detectSubMagic(payload);
|
|
426
|
+
if (detected !== null) {
|
|
427
|
+
this.subMagic = detected;
|
|
428
|
+
this.questMsgSeq = payload.readUInt32BE(4);
|
|
429
|
+
verbose(`Learned sub-magic: 0x${this.subMagic.toString(16).padStart(8, "0")}`);
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Sub-magic echo (update quest msg seq)
|
|
435
|
+
if (this.subMagic && first4 === this.subMagic) {
|
|
436
|
+
this.questMsgSeq = payload.readUInt32BE(4);
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Transport string
|
|
441
|
+
if (first4 === 0x00000001 && payload.length >= 12) {
|
|
442
|
+
const strLen = payload.readUInt32BE(8);
|
|
443
|
+
if (12 + strLen <= payload.length && strLen > 4) {
|
|
444
|
+
const candidate = payload.subarray(12, 12 + strLen);
|
|
445
|
+
if (candidate.every((b) => b >= 32 && b < 127)) {
|
|
446
|
+
verbose("Quest transport:", candidate.toString("ascii"));
|
|
447
|
+
if (this.subMagic && handshakeStage === 0) {
|
|
448
|
+
this.sendXrsp(buildInit(this.subMagic, this.nextSeq()));
|
|
449
|
+
setState(1, ackVal);
|
|
450
|
+
}
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// H.264 NAL unit
|
|
456
|
+
const nalType = payload[4] & 0x1f;
|
|
457
|
+
if (nalType === 7 || nalType === 5 || nalType === 1 || nalType === 6 || nalType === 8 ||
|
|
458
|
+
(nalType > 0 && payload.length > 50)) {
|
|
459
|
+
if (nalType === 7) {
|
|
460
|
+
this.currentSpsPps = Buffer.from(payload);
|
|
461
|
+
verbose(`SPS+PPS (${payload.length} bytes)`);
|
|
462
|
+
if (!this.decoder.isRunning) {
|
|
463
|
+
this.decoder.start();
|
|
464
|
+
}
|
|
465
|
+
} else if (nalType === 5) {
|
|
466
|
+
this.currentIdr = Buffer.from(payload);
|
|
467
|
+
verbose(`IDR keyframe (${payload.length} bytes)`);
|
|
468
|
+
}
|
|
469
|
+
this._frameCount++;
|
|
470
|
+
this._byteCount += payload.length;
|
|
471
|
+
this.decoder.feed(payload);
|
|
472
|
+
}
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Quest ACK
|
|
477
|
+
if (first4 === ACK_MARKER && payload.length === 8) {
|
|
478
|
+
const val = payload.readUInt32BE(4);
|
|
479
|
+
verbose(`Quest ACK: ${val} (stage=${handshakeStage})`);
|
|
480
|
+
|
|
481
|
+
if (this.subMagic) {
|
|
482
|
+
if (handshakeStage === 1) {
|
|
483
|
+
this.sendXrsp(
|
|
484
|
+
buildConfig(this.subMagic, this.nextSeq(),
|
|
485
|
+
this._width, this._height,
|
|
486
|
+
this.sessionUuid, this.sessionTimestamp),
|
|
487
|
+
);
|
|
488
|
+
setState(2, ackVal);
|
|
489
|
+
} else if (handshakeStage === 3) {
|
|
490
|
+
this.sendXrsp(buildShortAck(this.subMagic, this.nextSeq(), CMD_SHORT_ACK_65));
|
|
491
|
+
ackVal = 0x195;
|
|
492
|
+
this.sendXrsp(
|
|
493
|
+
buildKeepalive(this.subMagic, this.questMsgSeq, ackVal,
|
|
494
|
+
this.sessionUuid, this.sessionTimestamp),
|
|
495
|
+
);
|
|
496
|
+
setState(4, ackVal);
|
|
497
|
+
} else if (handshakeStage === 4) {
|
|
498
|
+
ackVal = 0x25d;
|
|
499
|
+
this.sendXrsp(
|
|
500
|
+
buildKeepalive(this.subMagic, this.questMsgSeq, ackVal,
|
|
501
|
+
this.sessionUuid, this.sessionTimestamp),
|
|
502
|
+
);
|
|
503
|
+
setState(5, ackVal);
|
|
504
|
+
} else if (handshakeStage >= 5) {
|
|
505
|
+
ackVal += KEEPALIVE_ACK_INCREMENT;
|
|
506
|
+
this.sendXrsp(
|
|
507
|
+
buildKeepalive(this.subMagic, this.questMsgSeq, ackVal,
|
|
508
|
+
this.sessionUuid, this.sessionTimestamp),
|
|
509
|
+
);
|
|
510
|
+
setState(handshakeStage, ackVal);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// LayerConfiguration (type 300 = 0x12C)
|
|
517
|
+
if (first4 === CONFIG_MARKER) {
|
|
518
|
+
const layer = parseLayerConfiguration(payload);
|
|
519
|
+
if (layer) {
|
|
520
|
+
verbose(`LayerConfig: id=${layer.id} ${layer.width}x${layer.height} type=${layer.typeName} app=${layer.app}`);
|
|
521
|
+
this._layers.set(layer.id, layer);
|
|
522
|
+
this._width = layer.width;
|
|
523
|
+
this._height = layer.height;
|
|
524
|
+
if (layer.type === LAYER_PANEL_APP && !this._layerAutoSelected) {
|
|
525
|
+
this._layerId = layer.id;
|
|
526
|
+
this._layerAutoSelected = true;
|
|
527
|
+
verbose(`Auto-selected PANEL_APP layer ${layer.id} for input`);
|
|
528
|
+
}
|
|
529
|
+
this.emit("layer", layer);
|
|
530
|
+
} else if (payload.length >= 16) {
|
|
531
|
+
this._width = payload.readUInt32BE(8);
|
|
532
|
+
this._height = payload.readUInt32BE(12);
|
|
533
|
+
verbose(`Quest config: ${this._width}x${this._height} (short payload)`);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if (this.subMagic && handshakeStage === 2) {
|
|
537
|
+
ackVal = KEEPALIVE_ACK_INCREMENT; // 0xC8
|
|
538
|
+
this.sendXrsp(
|
|
539
|
+
buildKeepalive(this.subMagic, this.questMsgSeq, ackVal,
|
|
540
|
+
this.sessionUuid, this.sessionTimestamp),
|
|
541
|
+
);
|
|
542
|
+
for (let i = 0; i < 3; i++) {
|
|
543
|
+
this.sendXrsp(buildShortAck(this.subMagic, this.nextSeq(), CMD_SHORT_ACK_CD));
|
|
544
|
+
}
|
|
545
|
+
this.sendXrsp(buildShortAck(this.subMagic, this.nextSeq(), CMD_SHORT_ACK_12D));
|
|
546
|
+
setState(3, ackVal);
|
|
547
|
+
}
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// VideoMeta
|
|
552
|
+
if (first4 === VIDEO_META_MARKER && payload.length === 16) {
|
|
553
|
+
if (this._frameCount > 0 && this._frameCount % 6 === 0 && this.subMagic) {
|
|
554
|
+
ackVal += KEEPALIVE_ACK_INCREMENT;
|
|
555
|
+
this.sendXrsp(
|
|
556
|
+
buildKeepalive(this.subMagic, this.questMsgSeq, ackVal,
|
|
557
|
+
this.sessionUuid, this.sessionTimestamp),
|
|
558
|
+
);
|
|
559
|
+
setState(handshakeStage, ackVal);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// --- Public control methods ---
|
|
565
|
+
|
|
566
|
+
getScreenshot(): Buffer | null {
|
|
567
|
+
return this.decoder.getFrame();
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
sendPose(pose: PoseState): void {
|
|
571
|
+
this._pose = pose;
|
|
572
|
+
if (this._connected && this.subMagic) {
|
|
573
|
+
this.sendXrsp(buildPose(this.subMagic, this.nextSeq(), this._pose));
|
|
574
|
+
}
|
|
575
|
+
// Auto-start periodic pose loop on first pose send so the Quest
|
|
576
|
+
// accepts our camera override (requires continuous ~27 Hz updates).
|
|
577
|
+
if (!this._poseLoopActive) {
|
|
578
|
+
this.startPoseLoop();
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
applyPoseDelta(delta: PoseDelta): void {
|
|
583
|
+
this._pose = updatePose(this._pose, delta);
|
|
584
|
+
this.sendPose(this._pose);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
setPoseOffset(abs: PoseOffset): void {
|
|
588
|
+
this._pose = setPoseOffset(this._pose, abs);
|
|
589
|
+
this.sendPose(this._pose);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** Start periodic pose refresh at ~27 Hz (matching MQDH cadence). */
|
|
593
|
+
startPoseLoop(): void {
|
|
594
|
+
if (this._poseLoopActive) return;
|
|
595
|
+
this._poseLoopActive = true;
|
|
596
|
+
verbose("Pose loop started (~27 Hz)");
|
|
597
|
+
this._poseLoopTimer = setInterval(() => {
|
|
598
|
+
if (this._connected && this.subMagic) {
|
|
599
|
+
this.sendXrsp(buildPose(this.subMagic, this.nextSeq(), this._pose));
|
|
600
|
+
}
|
|
601
|
+
}, 37);
|
|
602
|
+
this.emit("pose-loop", true);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** Stop periodic pose refresh. */
|
|
606
|
+
stopPoseLoop(): void {
|
|
607
|
+
if (!this._poseLoopActive) return;
|
|
608
|
+
this._poseLoopActive = false;
|
|
609
|
+
if (this._poseLoopTimer) {
|
|
610
|
+
clearInterval(this._poseLoopTimer);
|
|
611
|
+
this._poseLoopTimer = null;
|
|
612
|
+
}
|
|
613
|
+
verbose("Pose loop stopped");
|
|
614
|
+
this.emit("pose-loop", false);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
sendDisplayConfig(width: number, height: number, eye?: number): void {
|
|
618
|
+
if (this._connected && this.subMagic) {
|
|
619
|
+
// Reset first, then config (matching Python behavior)
|
|
620
|
+
this.sendXrsp(buildInit(this.subMagic, this.nextSeq()));
|
|
621
|
+
this.sendXrsp(buildDisplayConfig(this.subMagic, this.nextSeq(), width, height, eye));
|
|
622
|
+
this._width = width;
|
|
623
|
+
this._height = height;
|
|
624
|
+
if (eye !== undefined) this._eye = eye;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
async sendClick(x = 0.5, y = 0.5, layerId?: number, holdMs = 50): Promise<void> {
|
|
629
|
+
if (!this._connected || !this.subMagic) return;
|
|
630
|
+
|
|
631
|
+
// Auto-select layer
|
|
632
|
+
const lid = layerId ?? this.autoSelectLayer();
|
|
633
|
+
|
|
634
|
+
// MOVE events to position virtual cursor
|
|
635
|
+
for (let i = 0; i < 3; i++) {
|
|
636
|
+
this.sendXrsp(buildVirtualMouse(this.subMagic, this.nextSeq(), lid, 1, x, y));
|
|
637
|
+
await new Promise((r) => setTimeout(r, 16));
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// DOWN
|
|
641
|
+
this.sendXrsp(buildVirtualMouse(this.subMagic, this.nextSeq(), lid, 3, x, y));
|
|
642
|
+
await new Promise((r) => setTimeout(r, holdMs));
|
|
643
|
+
|
|
644
|
+
// UP
|
|
645
|
+
this.sendXrsp(buildVirtualMouse(this.subMagic, this.nextSeq(), lid, 4, x, y));
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
ensureInputForwarding(): void {
|
|
649
|
+
if (this.inputForwardingStarted) return;
|
|
650
|
+
if (!this._connected || !this.subMagic) return;
|
|
651
|
+
|
|
652
|
+
this.sendXrsp(buildSetProperty(this.subMagic, this.nextSeq(),
|
|
653
|
+
"input_forwarding_gaze_click", "true"));
|
|
654
|
+
this.sendXrsp(buildInputForwardingState(this.subMagic, this.nextSeq(), 1));
|
|
655
|
+
this.sendXrsp(buildStartInputForwarding(this.subMagic, this.nextSeq()));
|
|
656
|
+
this.sendXrsp(buildActivateLayer(this.subMagic, this.nextSeq(), this._layerId));
|
|
657
|
+
this.inputForwardingStarted = true;
|
|
658
|
+
verbose("Input forwarding enabled (CAMERA mode, gaze_click=true)");
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
sendInputForwardingState(state: number): void {
|
|
662
|
+
if (this._connected && this.subMagic) {
|
|
663
|
+
this.sendXrsp(buildInputForwardingState(this.subMagic, this.nextSeq(), state));
|
|
664
|
+
if (state === 0) {
|
|
665
|
+
this.inputForwardingStarted = false;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
resetView(): void {
|
|
671
|
+
this.stopPoseLoop();
|
|
672
|
+
this.sendInputForwardingState(0);
|
|
673
|
+
this._pose = createPoseState();
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
sendSetProperty(name: string, value: string): void {
|
|
677
|
+
if (this._connected && this.subMagic) {
|
|
678
|
+
this.sendXrsp(buildSetProperty(this.subMagic, this.nextSeq(), name, value));
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
sendMud(typeId: number, payload?: Buffer): void {
|
|
683
|
+
if (this._connected && this.subMagic) {
|
|
684
|
+
this.sendXrsp(buildMud(this.subMagic, this.nextSeq(), typeId, payload));
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
sendRotation(pitch = 0, yaw = 0, forward = 0, strafe = 0): void {
|
|
689
|
+
this.applyPoseDelta({ dYaw: yaw, dPitch: pitch, dForward: forward, dStrafe: strafe });
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// --- Helpers ---
|
|
693
|
+
|
|
694
|
+
private autoSelectLayer(): number {
|
|
695
|
+
let bestId = this._layerId;
|
|
696
|
+
let bestArea = 0;
|
|
697
|
+
for (const [id, info] of this._layers) {
|
|
698
|
+
if (info.type === 0 || info.type === 3) { // PANEL_APP or VOLUMETRIC_WINDOW
|
|
699
|
+
const area = info.width * info.height;
|
|
700
|
+
if (area > bestArea) {
|
|
701
|
+
bestId = id;
|
|
702
|
+
bestArea = area;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
return bestId;
|
|
707
|
+
}
|
|
708
|
+
}
|