@mentra/engine 3.2.0-dev.225 → 3.2.0-dev.227

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.
Files changed (33) hide show
  1. package/build/generated/releaseMetadata.js +5 -5
  2. package/build/generated/releaseMetadata.js.map +1 -1
  3. package/build/services/AcsMeetingService.d.ts +3 -1
  4. package/build/services/AcsMeetingService.d.ts.map +1 -1
  5. package/build/services/AcsMeetingService.js +9 -1
  6. package/build/services/AcsMeetingService.js.map +1 -1
  7. package/build/services/GlassesHotspotLease.d.ts +2 -0
  8. package/build/services/GlassesHotspotLease.d.ts.map +1 -0
  9. package/build/services/GlassesHotspotLease.js +13 -0
  10. package/build/services/GlassesHotspotLease.js.map +1 -0
  11. package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
  12. package/build/services/LocalMiniappRuntime.js +14 -5
  13. package/build/services/LocalMiniappRuntime.js.map +1 -1
  14. package/build/services/ManagedWebRtcRelay.d.ts +87 -0
  15. package/build/services/ManagedWebRtcRelay.d.ts.map +1 -0
  16. package/build/services/ManagedWebRtcRelay.js +239 -0
  17. package/build/services/ManagedWebRtcRelay.js.map +1 -0
  18. package/build/services/PhoneStreamCoordinator.d.ts +5 -0
  19. package/build/services/PhoneStreamCoordinator.d.ts.map +1 -1
  20. package/build/services/PhoneStreamCoordinator.js +131 -68
  21. package/build/services/PhoneStreamCoordinator.js.map +1 -1
  22. package/build/services/SoftapCallTransport.d.ts +2 -1
  23. package/build/services/SoftapCallTransport.d.ts.map +1 -1
  24. package/build/services/SoftapCallTransport.js +6 -1
  25. package/build/services/SoftapCallTransport.js.map +1 -1
  26. package/package.json +8 -7
  27. package/src/generated/releaseMetadata.ts +5 -5
  28. package/src/services/AcsMeetingService.ts +10 -1
  29. package/src/services/GlassesHotspotLease.ts +11 -0
  30. package/src/services/LocalMiniappRuntime.ts +27 -11
  31. package/src/services/ManagedWebRtcRelay.ts +278 -0
  32. package/src/services/PhoneStreamCoordinator.ts +152 -70
  33. package/src/services/SoftapCallTransport.ts +8 -3
@@ -0,0 +1,278 @@
1
+ import BluetoothSdk from "@mentra/bluetooth-sdk/internal"
2
+ import type {StreamStartRequest, StreamStatusEvent} from "@mentra/bluetooth-sdk/internal"
3
+ import {acquireGlassesHotspot} from "./GlassesHotspotLease"
4
+
5
+ export interface RelayOptions {
6
+ streamId: string
7
+ ingestUrl: string
8
+ video?: StreamStartRequest["video"]
9
+ audio?: StreamStartRequest["audio"]
10
+ captureAudio?: boolean
11
+ sound?: boolean
12
+ }
13
+
14
+ interface NativeRelayEvent {
15
+ attemptId: string
16
+ state: string
17
+ reason: string
18
+ }
19
+ interface NativeRelay {
20
+ prepare(options: {
21
+ attemptId: string
22
+ ingestUrl: string
23
+ ssid: string
24
+ password: string
25
+ gatewayAddress?: string
26
+ captureAudio: boolean
27
+ bitrate: number
28
+ }): Promise<string>
29
+ stop(attemptId: string): Promise<void>
30
+ addListener(event: "onRelayState", listener: (event: NativeRelayEvent) => void): {remove(): void}
31
+ }
32
+
33
+ export interface RelayDependencies {
34
+ native: NativeRelay
35
+ hotspot(enabled: boolean): Promise<{state: string; ssid?: string; password?: string; localIp?: string}>
36
+ startGlasses(request: StreamStartRequest): Promise<StreamStatusEvent | undefined>
37
+ stopGlasses(): Promise<unknown>
38
+ deferredStop(): void
39
+ connected(): boolean
40
+ sleep(ms: number): Promise<void>
41
+ now(): number
42
+ acquire(): () => void
43
+ }
44
+
45
+ export interface ManagedRelay {
46
+ start(): Promise<StreamStatusEvent | undefined>
47
+ cancel(): void
48
+ stop(): Promise<void>
49
+ owns(streamId: string): boolean
50
+ handleGlassesStatus(event: StreamStatusEvent): void
51
+ }
52
+
53
+ /** One attempt at a time; media stays native. All retries unwind both peers and the hotspot. */
54
+ export class ManagedWebRtcRelay implements ManagedRelay {
55
+ private cancelled = false
56
+ private started = false
57
+ private attempt = 0
58
+ private attemptId: string | null = null
59
+ private attemptError: Error | null = null
60
+ private hotspotTouched = false
61
+ private nativeTouched = false
62
+ private glassesTouched = false
63
+ private release: (() => void) | null = null
64
+ private listener: {remove(): void} | null = null
65
+ private operation: Promise<unknown> = Promise.resolve()
66
+ private restarting = false
67
+ private retries = 0
68
+ private readyAt: number | null = null
69
+ private stopping: Promise<void> | null = null
70
+
71
+ constructor(
72
+ private readonly options: RelayOptions,
73
+ private readonly onStatus: (status: string, reason: string) => void,
74
+ private readonly onFailure: (error: Error) => void,
75
+ private readonly deps: RelayDependencies,
76
+ ) {}
77
+
78
+ start(): Promise<StreamStatusEvent | undefined> {
79
+ if (this.started) return Promise.reject(new Error("Relay already started"))
80
+ this.started = true
81
+ this.release = this.deps.acquire()
82
+ this.listener = this.deps.native.addListener("onRelayState", (event) => {
83
+ if (event.attemptId !== this.attemptId || this.cancelled) return
84
+ if (event.state === "failed") this.failed(new Error(event.reason))
85
+ else this.onStatus(event.state, event.reason)
86
+ })
87
+ const start = this.startAttempt()
88
+ this.operation = start
89
+ return start
90
+ }
91
+
92
+ cancel(): void {
93
+ this.cancelled = true
94
+ }
95
+
96
+ owns(streamId: string): boolean {
97
+ // Consume late status from all attempts without allowing it to affect the current attempt.
98
+ return streamId.startsWith(`${this.options.streamId}-relay-`)
99
+ }
100
+
101
+ handleGlassesStatus(event: StreamStatusEvent): void {
102
+ if (event.streamId !== this.attemptId || this.cancelled) return
103
+ if (event.status === "stopped" || event.terminal) {
104
+ this.failed(new Error("Glasses publisher stopped"))
105
+ }
106
+ }
107
+
108
+ private checkpoint(): void {
109
+ if (this.cancelled) throw new Error("Relay cancelled")
110
+ if (this.attemptError) throw this.attemptError
111
+ }
112
+
113
+ private async startAttempt(): Promise<StreamStatusEvent | undefined> {
114
+ this.attemptId = `${this.options.streamId}-relay-${++this.attempt}`
115
+ this.attemptError = null
116
+ this.checkpoint()
117
+ this.hotspotTouched = true // A timed-out BLE command may still have enabled the AP.
118
+ const hotspot = await this.deps.hotspot(true)
119
+ this.checkpoint()
120
+ if (hotspot.state !== "enabled" || !hotspot.ssid || !hotspot.password)
121
+ throw new Error("Glasses hotspot did not start")
122
+ await this.deps.sleep(3_000) // Same beacon/DHCP startup allowance as ACS.
123
+ this.checkpoint()
124
+ this.nativeTouched = true
125
+ const url = await this.deps.native.prepare({
126
+ attemptId: this.attemptId,
127
+ ingestUrl: this.options.ingestUrl,
128
+ ssid: hotspot.ssid,
129
+ password: hotspot.password,
130
+ gatewayAddress: hotspot.localIp,
131
+ captureAudio: this.options.captureAudio !== false,
132
+ bitrate: this.options.video?.bitrate ?? 2_000_000,
133
+ })
134
+ this.checkpoint()
135
+ this.glassesTouched = true
136
+ const result = await this.deps.startGlasses({
137
+ type: "start_stream",
138
+ streamId: this.attemptId,
139
+ streamUrl: url,
140
+ ice: {stun: ""},
141
+ sound: this.options.sound ?? true,
142
+ captureAudio: this.options.captureAudio !== false,
143
+ ...(this.options.video !== undefined ? {video: this.options.video} : {}),
144
+ ...(this.options.audio !== undefined ? {audio: this.options.audio} : {}),
145
+ })
146
+ this.checkpoint()
147
+ this.readyAt = this.deps.now()
148
+ return result
149
+ }
150
+
151
+ private failed(error: Error): void {
152
+ if (this.cancelled || this.attemptError) return
153
+ this.attemptError = error
154
+ // Bound consecutive trouble, not the lifetime number of recoveries in a long stream.
155
+ if (this.readyAt !== null && this.deps.now() - this.readyAt >= 60_000) this.retries = 0
156
+ this.readyAt = null
157
+ if (this.restarting) return
158
+ this.restarting = true
159
+ // Await startup before cleanup: late native/BLE success still belongs to this attempt.
160
+ this.operation = this.operation
161
+ .catch(() => undefined)
162
+ .then(async () => {
163
+ while (!this.cancelled) {
164
+ try {
165
+ await this.cleanupAttempt()
166
+ if (this.cancelled) return
167
+ if (this.retries >= 3) throw error
168
+ this.onStatus("reconnecting", error.message)
169
+ await this.deps.sleep(1_000 * 2 ** this.retries++)
170
+ if (this.cancelled) return
171
+ await this.startAttempt()
172
+ this.onStatus("reconnected", "Glasses relay restarted")
173
+ return
174
+ } catch (failure) {
175
+ // Cleanup failure retains ownership; never create another stream on uncertain state.
176
+ if (this.hotspotTouched || this.nativeTouched || this.glassesTouched) {
177
+ try {
178
+ await this.cleanupAttempt()
179
+ } catch (cleanupError) {
180
+ if (!this.cancelled) this.onFailure(asError(cleanupError))
181
+ return
182
+ }
183
+ }
184
+ if (this.retries >= 3) {
185
+ if (!this.cancelled) this.onFailure(asError(failure))
186
+ return
187
+ }
188
+ }
189
+ }
190
+ })
191
+ .finally(() => {
192
+ this.restarting = false
193
+ })
194
+ }
195
+
196
+ stop(): Promise<void> {
197
+ this.cancel()
198
+ if (this.stopping) return this.stopping
199
+ this.listener?.remove()
200
+ this.listener = null
201
+ this.stopping = this.operation
202
+ .catch(() => undefined)
203
+ .then(async () => {
204
+ await this.cleanupAttempt()
205
+ this.release?.()
206
+ this.release = null
207
+ })
208
+ .finally(() => {
209
+ this.stopping = null
210
+ })
211
+ return this.stopping
212
+ }
213
+
214
+ private async cleanupAttempt(): Promise<void> {
215
+ const failures: unknown[] = []
216
+ const step = async (action: () => Promise<void>) => {
217
+ try {
218
+ await action()
219
+ } catch (error) {
220
+ failures.push(error)
221
+ }
222
+ }
223
+ // Invalidate callbacks before intentionally stopping the glasses or dropping the network.
224
+ const id = this.attemptId
225
+ this.attemptId = null
226
+ if (this.glassesTouched)
227
+ await step(async () => {
228
+ if (this.deps.connected()) await this.deps.stopGlasses()
229
+ else this.deps.deferredStop()
230
+ this.glassesTouched = false
231
+ })
232
+ if (this.nativeTouched && id)
233
+ await step(async () => {
234
+ await this.deps.native.stop(id)
235
+ this.nativeTouched = false
236
+ })
237
+ if (this.hotspotTouched)
238
+ await step(async () => {
239
+ if (this.deps.connected()) {
240
+ const result = await this.deps.hotspot(false)
241
+ if (result.state !== "disabled") throw new Error("Glasses hotspot shutdown was not confirmed")
242
+ } else this.deps.deferredStop()
243
+ this.hotspotTouched = false
244
+ })
245
+ if (failures.length) {
246
+ this.attemptId = id // Retain the native attempt token for a subsequent cleanup retry.
247
+ throw new Error(`Relay cleanup failed: ${failures.map((error) => asError(error).message).join("; ")}`)
248
+ }
249
+ }
250
+ }
251
+
252
+ function asError(error: unknown): Error {
253
+ return error instanceof Error ? error : new Error(String(error))
254
+ }
255
+
256
+ export function createManagedWebRtcRelay(
257
+ options: RelayOptions,
258
+ onStatus: (status: string, reason: string) => void,
259
+ onFailure: (error: Error) => void,
260
+ connected: () => boolean,
261
+ deferredStop: () => void,
262
+ ): ManagedRelay {
263
+ // Load only for managed WHIP. Hosts using direct SRT/RTMP need no relay module.
264
+ const {requireNativeModule} = require("expo-modules-core") as typeof import("expo-modules-core")
265
+ const {BgTimer} = require("../utils/timers") as typeof import("../utils/timers")
266
+ const native = requireNativeModule<NativeRelay>("MentraGlassesMediaRelay")
267
+ return new ManagedWebRtcRelay(options, onStatus, onFailure, {
268
+ native,
269
+ hotspot: (enabled) => BluetoothSdk.setHotspotState(enabled),
270
+ startGlasses: (request) => BluetoothSdk.startStream(request),
271
+ stopGlasses: () => BluetoothSdk.stopStream(),
272
+ connected,
273
+ deferredStop,
274
+ now: Date.now,
275
+ sleep: (ms) => new Promise((resolve) => BgTimer.setTimeout(resolve, ms)),
276
+ acquire: acquireGlassesHotspot,
277
+ })
278
+ }