@mentra/engine 3.2.0-dev.226 → 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.
- package/build/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/build/services/AcsMeetingService.d.ts +3 -1
- package/build/services/AcsMeetingService.d.ts.map +1 -1
- package/build/services/AcsMeetingService.js +9 -1
- package/build/services/AcsMeetingService.js.map +1 -1
- package/build/services/GlassesHotspotLease.d.ts +2 -0
- package/build/services/GlassesHotspotLease.d.ts.map +1 -0
- package/build/services/GlassesHotspotLease.js +13 -0
- package/build/services/GlassesHotspotLease.js.map +1 -0
- package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
- package/build/services/LocalMiniappRuntime.js +14 -5
- package/build/services/LocalMiniappRuntime.js.map +1 -1
- package/build/services/ManagedWebRtcRelay.d.ts +87 -0
- package/build/services/ManagedWebRtcRelay.d.ts.map +1 -0
- package/build/services/ManagedWebRtcRelay.js +239 -0
- package/build/services/ManagedWebRtcRelay.js.map +1 -0
- package/build/services/PhoneStreamCoordinator.d.ts +5 -0
- package/build/services/PhoneStreamCoordinator.d.ts.map +1 -1
- package/build/services/PhoneStreamCoordinator.js +131 -68
- package/build/services/PhoneStreamCoordinator.js.map +1 -1
- package/build/services/SoftapCallTransport.d.ts +2 -1
- package/build/services/SoftapCallTransport.d.ts.map +1 -1
- package/build/services/SoftapCallTransport.js +6 -1
- package/build/services/SoftapCallTransport.js.map +1 -1
- package/package.json +8 -7
- package/src/generated/releaseMetadata.ts +5 -5
- package/src/services/AcsMeetingService.ts +10 -1
- package/src/services/GlassesHotspotLease.ts +11 -0
- package/src/services/LocalMiniappRuntime.ts +27 -11
- package/src/services/ManagedWebRtcRelay.ts +278 -0
- package/src/services/PhoneStreamCoordinator.ts +152 -70
- 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
|
+
}
|