@mentra/engine 3.2.0-dev.248 → 3.2.0-dev.250

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mentra/engine",
3
- "version": "3.2.0-dev.248",
3
+ "version": "3.2.0-dev.250",
4
4
  "description": "Mentra Engine — miniapp registry, WebView bridge, and miniapp store",
5
5
  "main": "build/index.js",
6
6
  "react-native": "src/index.ts",
@@ -97,13 +97,13 @@
97
97
  "registry": "https://registry.npmjs.org/"
98
98
  },
99
99
  "dependencies": {
100
- "@mentra/acs-meeting": "3.2.0-dev.248",
101
- "@mentra/glasses-media": "3.2.0-dev.248",
102
- "@mentra/bluetooth-sdk": "3.2.0-dev.248",
103
- "@mentra/cloud-client": "3.2.0-dev.248",
104
- "@mentra/cloud-protocol": "3.2.0-dev.248",
105
- "@mentra/crust": "3.2.0-dev.248",
106
- "@mentra/miniapp": "3.2.0-dev.248",
100
+ "@mentra/acs-meeting": "3.2.0-dev.250",
101
+ "@mentra/glasses-media": "3.2.0-dev.250",
102
+ "@mentra/bluetooth-sdk": "3.2.0-dev.250",
103
+ "@mentra/cloud-client": "3.2.0-dev.250",
104
+ "@mentra/cloud-protocol": "3.2.0-dev.250",
105
+ "@mentra/crust": "3.2.0-dev.250",
106
+ "@mentra/miniapp": "3.2.0-dev.250",
107
107
  "buffer": "^6.0.3",
108
108
  "events": "^3.3.0",
109
109
  "react-native-marked": "8.1.1",
@@ -12,9 +12,9 @@ export interface EngineReleaseMetadata {
12
12
  export const ENGINE_RELEASE_METADATA: Readonly<EngineReleaseMetadata> = Object.freeze({
13
13
  "schemaVersion": 1,
14
14
  "familyBaseVersion": "3.2.0",
15
- "releaseIdentity": "3.2.0-dev.248",
16
- "releaseSetId": "mentra-3.2.0-dev.248",
17
- "sourceCommit": "8bc5eb096a0c404e1d599796727e66d973678575",
18
- "otaManifestUrl": "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.248.json",
19
- "otaManifestSha256": "7e649fb7601d70d6c235be79540fd6508d72932461b97118abc0cdbd5d36d268"
15
+ "releaseIdentity": "3.2.0-dev.250",
16
+ "releaseSetId": "mentra-3.2.0-dev.250",
17
+ "sourceCommit": "3707f9ec51f41355d71e1783e2783bd0e451c9e6",
18
+ "otaManifestUrl": "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.250.json",
19
+ "otaManifestSha256": "39fa2567b9b83b52200f266058ed23f44633fa3239a6f3659a1afbee820d4523"
20
20
  })
@@ -569,6 +569,8 @@ class AcsMeetingService {
569
569
  private lastMediaRestartAt = 0
570
570
  /** Callers parked in [waitForFirstFrame], woken by the next `mediaSource` verdict. */
571
571
  private readonly firstFrameWaiters = new Set<(error?: Error) => void>()
572
+ /** Mid-call republish waiters: live resolves true, leave/timeout resolves false. Failed is ignored. */
573
+ private readonly mediaLiveWaiters = new Set<(live: boolean) => void>()
572
574
  private scopedLostSub: {remove: () => void} | null = null
573
575
  private readonly scopedLostListeners = new Set<(error: {code: string; message: string}) => void>()
574
576
  /**
@@ -837,6 +839,42 @@ class AcsMeetingService {
837
839
  if (mediaSource !== "live" && mediaSource !== "failed") return
838
840
  const error = mediaSource === "failed" ? new Error("The glasses video feed failed") : undefined
839
841
  for (const settle of [...this.firstFrameWaiters]) settle(error)
842
+ if (mediaSource === "live") this.settleMediaLiveWaiters(true)
843
+ }
844
+
845
+ private settleMediaLiveWaiters(live: boolean): void {
846
+ for (const settle of [...this.mediaLiveWaiters]) settle(live)
847
+ }
848
+
849
+ /**
850
+ * Mid-call SoftAP camera recovery. Resolves `true` only when ingest is live again.
851
+ * A standing `failed` is why we republished, so it must not abort the wait the way
852
+ * [waitForFirstFrame] does on join.
853
+ */
854
+ waitUntilMediaLive(timeoutMs: number): Promise<boolean> {
855
+ if (this.lastState.mediaSource === "live") return Promise.resolve(true)
856
+ const startedAt = Date.now()
857
+ softapTrace("acs_media_live_wait", {
858
+ timeoutMs,
859
+ mediaSource: this.lastState.mediaSource ?? "unknown",
860
+ state: this.lastState.state,
861
+ })
862
+ return new Promise<boolean>((resolve) => {
863
+ const settle = (live: boolean) => {
864
+ if (done) return
865
+ done = true
866
+ clearTimeout(timer)
867
+ this.mediaLiveWaiters.delete(settle)
868
+ softapTrace("acs_media_live_wait_done", {
869
+ waitedMs: Date.now() - startedAt,
870
+ live,
871
+ })
872
+ resolve(live)
873
+ }
874
+ let done = false
875
+ const timer = setTimeout(() => settle(false), timeoutMs)
876
+ this.mediaLiveWaiters.add(settle)
877
+ })
840
878
  }
841
879
 
842
880
  /**
@@ -1164,6 +1202,8 @@ class AcsMeetingService {
1164
1202
  // rejected, or a leave mid-join leaves the orchestrator waiting out its whole timeout.
1165
1203
  for (const settle of [...this.firstFrameWaiters]) settle(new Error("The meeting ended"))
1166
1204
  this.firstFrameWaiters.clear()
1205
+ this.settleMediaLiveWaiters(false)
1206
+ this.mediaLiveWaiters.clear()
1167
1207
  this.stopGlassesMicUplink()
1168
1208
  this.unwatchPhoneNetwork()
1169
1209
  await this.stopPcm()
@@ -3748,6 +3748,9 @@ class LocalMiniappRuntime {
3748
3748
  type: MiniappResponseType.MEETING_STATE,
3749
3749
  ...state,
3750
3750
  })
3751
+ if (attempt?.ownsResources && attempt.transport?.shouldRepublish(state.mediaSource)) {
3752
+ void attempt.transport.republish(state.mediaSourceReason ?? "mediaSource failed")
3753
+ }
3751
3754
  })
3752
3755
  }
3753
3756
 
@@ -4102,6 +4105,7 @@ class LocalMiniappRuntime {
4102
4105
  displayName: args.displayName,
4103
4106
  video: args.video,
4104
4107
  awaitFirstFrame: () => acsMeetingService.waitForFirstFrame(SOFTAP_FIRST_FRAME_MS),
4108
+ waitUntilLive: (timeoutMs) => acsMeetingService.waitUntilMediaLive(timeoutMs),
4105
4109
  subsystems: {
4106
4110
  setHotspotState: async (enabled) => {
4107
4111
  const status = await this.setGlassesHotspotState(enabled)
@@ -205,6 +205,13 @@ export interface SoftapCallDeps {
205
205
  * that never delivers a frame reads as healthy behind a frozen tile.
206
206
  */
207
207
  awaitFirstFrame(report?: SoftapStepReporter): Promise<void>
208
+ /**
209
+ * Mid-call camera recovery. Resolves `true` only when ingest is live again.
210
+ * A standing `failed` is the reason we are republishing, so it must not abort the wait.
211
+ */
212
+ waitUntilLive?(timeoutMs: number): Promise<boolean>
213
+ /** Delay after a failed `start_stream` before the next republish attempt. Tests set 0. */
214
+ republishRetryDelayMs?: number
208
215
  }
209
216
 
210
217
  export interface SoftapCallOptions {
@@ -246,6 +253,15 @@ function deferred(): {promise: Promise<void>; resolve: () => void} {
246
253
  return {promise, resolve}
247
254
  }
248
255
 
256
+ function sleep(ms: number): Promise<void> {
257
+ if (ms <= 0) return Promise.resolve()
258
+ return new Promise((resolve) => setTimeout(resolve, ms))
259
+ }
260
+
261
+ /** How long a mid-call republish waits for a frame after re-issuing `start_stream`. */
262
+ export const SOFTAP_REPUBLISH_LIVE_MS = 20_000
263
+ const REPUBLISH_RETRY_DELAY_MS = 2_000
264
+
249
265
  /**
250
266
  * How a call is being taken down.
251
267
  *
@@ -322,6 +338,9 @@ export class SoftapCallTransport {
322
338
  private startedAt = 0
323
339
  private traceId = ""
324
340
  private onProgress: ((progress: SoftapProgress) => void) | undefined
341
+ private republishing: Promise<void> | null = null
342
+ /** Bumped by `stop` so an in-flight republish cannot start_stream after Leave. */
343
+ private republishGeneration = 0
325
344
 
326
345
  constructor(private readonly deps: SoftapCallDeps) {}
327
346
 
@@ -329,6 +348,71 @@ export class SoftapCallTransport {
329
348
  return this.phase
330
349
  }
331
350
 
351
+ /**
352
+ * The glasses camera died after this call was already live. Re-issue `start_stream` at the
353
+ * existing ingest URL — do not rebind the WHIP listener, or the glasses POST to a dead port.
354
+ */
355
+ shouldRepublish(mediaSource?: string): boolean {
356
+ return this.phase === "live" && !this.terminating && mediaSource === "failed"
357
+ }
358
+
359
+ /**
360
+ * Rebuild the glasses publisher onto the standing SoftAP ingest URL.
361
+ *
362
+ * ACS, BLE mic, and the WHIP listener stay up. The glasses process is what usually vanished
363
+ * (UVC/`system_server` crash); a new `start_stream` is what they need after ASG comes back.
364
+ */
365
+ republish(reason: string): Promise<void> {
366
+ if (this.phase !== "live" || this.terminating || !this.ingestUrl) return Promise.resolve()
367
+ if (this.republishing) return this.republishing
368
+ const generation = this.republishGeneration
369
+ this.republishing = this.runRepublish(reason, generation).finally(() => {
370
+ if (this.republishGeneration === generation) this.republishing = null
371
+ })
372
+ return this.republishing
373
+ }
374
+
375
+ private async runRepublish(reason: string, generation: number): Promise<void> {
376
+ const ingestUrl = this.ingestUrl
377
+ if (!ingestUrl) return
378
+ const retryDelayMs = this.deps.republishRetryDelayMs ?? REPUBLISH_RETRY_DELAY_MS
379
+ let attempt = 0
380
+ softapTrace("glasses_republish_begin", {reason, ingestUrl})
381
+ while (
382
+ generation === this.republishGeneration &&
383
+ !this.terminating &&
384
+ this.phase === "live" &&
385
+ this.ingestUrl === ingestUrl
386
+ ) {
387
+ attempt += 1
388
+ try {
389
+ await this.deps.stopPublishing()
390
+ if (generation !== this.republishGeneration || this.terminating) return
391
+ await this.deps.startPublishing({ingestUrl, traceId: this.traceId})
392
+ if (generation !== this.republishGeneration || this.terminating) {
393
+ await this.deps.stopPublishing().catch(() => undefined)
394
+ return
395
+ }
396
+ softapTrace("glasses_republish_sent", {attempt, ingestUrl})
397
+ if (!this.deps.waitUntilLive) return
398
+ const live = await this.deps.waitUntilLive(SOFTAP_REPUBLISH_LIVE_MS)
399
+ if (generation !== this.republishGeneration || this.terminating) return
400
+ if (live) {
401
+ softapTrace("glasses_republish_live", {attempt})
402
+ return
403
+ }
404
+ softapTraceFailure("glasses_republish_no_frame", {attempt})
405
+ } catch (error) {
406
+ if (generation !== this.republishGeneration || this.terminating) return
407
+ softapTraceFailure("glasses_republish_start_failed", {
408
+ attempt,
409
+ reason: error instanceof Error ? error.message : String(error),
410
+ })
411
+ await sleep(retryDelayMs)
412
+ }
413
+ }
414
+ }
415
+
332
416
  /**
333
417
  * True once a teardown has been decided. Anything that would otherwise report a failure — a lost
334
418
  * hotspot, a dropped ACS call — must check this first: after the wearer asks to leave, those are
@@ -577,6 +661,7 @@ export class SoftapCallTransport {
577
661
  // Intent before action, always: a watcher must be able to tell a deliberate teardown from a
578
662
  // failure even during the very first await below.
579
663
  this.terminating = true
664
+ this.republishGeneration++
580
665
  if (options.mode) this.teardownMode = options.mode
581
666
  if (this.stopping) {
582
667
  softapTrace("softap_stop_joined_in_flight", {mode: this.teardownMode})
@@ -833,6 +918,11 @@ export function createSoftapCallDeps(args: {
833
918
  video?: {width: number; height: number; fps: number; maxBitrateBps: number}
834
919
  /** Resolves when the meeting reports a frame reached ACS; rejects on a failed feed. */
835
920
  awaitFirstFrame: () => Promise<void>
921
+ /**
922
+ * Mid-call camera recovery. Resolves `true` only when ingest is live again.
923
+ * A standing `failed` is the reason we are republishing, so it must not abort the wait.
924
+ */
925
+ waitUntilLive?: (timeoutMs: number) => Promise<boolean>
836
926
  subsystems: {
837
927
  setHotspotState: (enabled: boolean) => Promise<{state: string; ssid?: string; password?: string; localIp?: string}>
838
928
  /** Whether this phone's Wi-Fi radio is on. Optional: only Android hosts can answer it. */
@@ -1080,5 +1170,6 @@ export function createSoftapCallDeps(args: {
1080
1170
  },
1081
1171
  stopPublishing: () => subsystems.stopPublishing(packageName),
1082
1172
  awaitFirstFrame: args.awaitFirstFrame,
1173
+ waitUntilLive: args.waitUntilLive,
1083
1174
  }
1084
1175
  }