@mentra/engine 3.2.0-dev.116 → 3.2.0-dev.120

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 (64) hide show
  1. package/build/engine.d.ts.map +1 -1
  2. package/build/engine.js +7 -2
  3. package/build/engine.js.map +1 -1
  4. package/build/generated/releaseMetadata.js +5 -5
  5. package/build/generated/releaseMetadata.js.map +1 -1
  6. package/build/runtime/streamConfig.d.ts +7 -0
  7. package/build/runtime/streamConfig.d.ts.map +1 -1
  8. package/build/runtime/streamConfig.js +7 -0
  9. package/build/runtime/streamConfig.js.map +1 -1
  10. package/build/services/AcsMeetingService.d.ts +154 -0
  11. package/build/services/AcsMeetingService.d.ts.map +1 -0
  12. package/build/services/AcsMeetingService.js +477 -0
  13. package/build/services/AcsMeetingService.js.map +1 -0
  14. package/build/services/DeviceEventRouter.d.ts.map +1 -1
  15. package/build/services/DeviceEventRouter.js +12 -3
  16. package/build/services/DeviceEventRouter.js.map +1 -1
  17. package/build/services/GlassesSettingsSync.d.ts.map +1 -1
  18. package/build/services/GlassesSettingsSync.js +11 -1
  19. package/build/services/GlassesSettingsSync.js.map +1 -1
  20. package/build/services/GlassesStatusProjection.d.ts +12 -0
  21. package/build/services/GlassesStatusProjection.d.ts.map +1 -1
  22. package/build/services/GlassesStatusProjection.js +23 -0
  23. package/build/services/GlassesStatusProjection.js.map +1 -1
  24. package/build/services/LocalMiniappRuntime.d.ts +6 -0
  25. package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
  26. package/build/services/LocalMiniappRuntime.js +130 -1
  27. package/build/services/LocalMiniappRuntime.js.map +1 -1
  28. package/build/services/MentraJSRouter.d.ts.map +1 -1
  29. package/build/services/MentraJSRouter.js +8 -1
  30. package/build/services/MentraJSRouter.js.map +1 -1
  31. package/build/services/PhoneCameraFovCoordinator.d.ts +18 -0
  32. package/build/services/PhoneCameraFovCoordinator.d.ts.map +1 -1
  33. package/build/services/PhoneCameraFovCoordinator.js +36 -0
  34. package/build/services/PhoneCameraFovCoordinator.js.map +1 -1
  35. package/build/services/PhoneStreamCoordinator.d.ts +67 -1
  36. package/build/services/PhoneStreamCoordinator.d.ts.map +1 -1
  37. package/build/services/PhoneStreamCoordinator.js +204 -15
  38. package/build/services/PhoneStreamCoordinator.js.map +1 -1
  39. package/build/services/StreamLifecycleController.d.ts +23 -5
  40. package/build/services/StreamLifecycleController.d.ts.map +1 -1
  41. package/build/services/StreamLifecycleController.js +31 -10
  42. package/build/services/StreamLifecycleController.js.map +1 -1
  43. package/build/services/acsAudioSource.d.ts +24 -0
  44. package/build/services/acsAudioSource.d.ts.map +1 -0
  45. package/build/services/acsAudioSource.js +37 -0
  46. package/build/services/acsAudioSource.js.map +1 -0
  47. package/build/utils/devMiniappLaunch.d.ts.map +1 -1
  48. package/build/utils/devMiniappLaunch.js +4 -0
  49. package/build/utils/devMiniappLaunch.js.map +1 -1
  50. package/package.json +7 -6
  51. package/src/engine.ts +10 -2
  52. package/src/generated/releaseMetadata.ts +5 -5
  53. package/src/runtime/streamConfig.ts +13 -0
  54. package/src/services/AcsMeetingService.ts +570 -0
  55. package/src/services/DeviceEventRouter.ts +12 -3
  56. package/src/services/GlassesSettingsSync.ts +13 -3
  57. package/src/services/GlassesStatusProjection.ts +28 -0
  58. package/src/services/LocalMiniappRuntime.ts +142 -1
  59. package/src/services/MentraJSRouter.ts +7 -1
  60. package/src/services/PhoneCameraFovCoordinator.ts +34 -0
  61. package/src/services/PhoneStreamCoordinator.ts +232 -19
  62. package/src/services/StreamLifecycleController.ts +42 -12
  63. package/src/services/acsAudioSource.ts +53 -0
  64. package/src/utils/devMiniappLaunch.ts +4 -0
@@ -26,6 +26,18 @@
26
26
  * streams. We use a `phone-` prefix so they're trivially distinguishable from
27
27
  * cloud-minted IDs in logs and from cloud-SDK app streams that flow through
28
28
  * the legacy path.
29
+ *
30
+ * BLE link loss is a SUSPENDED state, not a failure:
31
+ * The glasses publisher keeps pushing over Wi-Fi when the phone's Bluetooth
32
+ * link drops; only our keep-alives stop reaching it. While the store says
33
+ * the glasses are disconnected we pause the heartbeat (so we don't count
34
+ * misses we caused ourselves), fan out `suspended`, and give the link
35
+ * `glassesGraceMs` to come back. On reconnect we resume, heartbeat at once,
36
+ * and fan out `resumed` with the SAME streamId — no re-provision. The grace
37
+ * ends early when Cloudflare reports the publisher gone for two probes in
38
+ * a row (glasses powered off, not a BLE hiccup). If the grace expires the
39
+ * stream is torn down with reason `glasses_disconnected`, and the BLE stop
40
+ * the glasses never received is sent on the next reconnect.
29
41
  */
30
42
  import BluetoothSdk from "@mentra/bluetooth-sdk/internal";
31
43
  import { isGlassesConnected } from "./GlassesReadiness";
@@ -49,6 +61,23 @@ const DEFAULT_TIMINGS = {
49
61
  hlsReadinessInitialDelayMs: 5_000,
50
62
  hlsReadinessPollMs: 2_000,
51
63
  hlsReadinessMaxAttempts: 30,
64
+ // How long a stream survives a BLE link drop before we give up on it. Must
65
+ // stay under the glasses publisher's own 60s no-keep-alive watchdog with
66
+ // margin for the resume heartbeat's round trip.
67
+ glassesGraceMs: 45_000,
68
+ // Consecutive Cloudflare "publisher disconnected" probes while suspended
69
+ // before we conclude the glasses are off (not just out of BLE range).
70
+ suspendedPublisherGoneProbes: 2,
71
+ };
72
+ const storeLinkSource = {
73
+ isConnected: () => isGlassesConnected(useGlassesStore.getState().connection),
74
+ subscribe: (listener) => useGlassesStore.subscribe((s) => isGlassesConnected(s.connection), listener),
75
+ };
76
+ /** Reasons carried on coordinator-sourced `stream_status` fanouts for link events. */
77
+ export const LINK_STATUS = {
78
+ suspended: "suspended",
79
+ resumed: "resumed",
80
+ reason: "glasses_disconnected",
52
81
  };
53
82
  // Console-backed minimal logger; replaces pino on the phone.
54
83
  const consoleLogger = {
@@ -78,22 +107,22 @@ export class StreamConflictError extends Error {
78
107
  this.name = "StreamConflictError";
79
108
  }
80
109
  }
81
- /**
82
- * Fail fast if glasses aren't connected — BEFORE provisioning. Without this a
83
- * managed start would create a provider live input, fail the BLE command, and
84
- * tear the input down again: a slow, billable no-op with a confusing error.
85
- */
86
- function assertGlassesConnected() {
87
- if (!isGlassesConnected(useGlassesStore.getState().connection)) {
88
- throw new StreamConflictError("GLASSES_NOT_CONNECTED", "Glasses are not connected", "command", "ble");
89
- }
90
- }
91
110
  export class PhoneStreamCoordinator {
92
111
  current = null;
93
112
  lifecycle = null;
94
113
  statusSubscriber = null;
95
114
  idCounter = 0;
96
115
  timings;
116
+ linkSource;
117
+ unsubscribeLink = null;
118
+ suspended = null;
119
+ /**
120
+ * A stream was torn down while the BLE link was down, so the glasses never
121
+ * got `stopStream`. Sent on the next reconnect (if no new stream has claimed
122
+ * the slot) so a publisher that outlived its input does not keep pushing
123
+ * until its own watchdog fires.
124
+ */
125
+ pendingBleStop = null;
97
126
  /**
98
127
  * Serializes state transitions (start, stop, teardown). Without it, a
99
128
  * second `start*` racing with the first can pass the `this.current === null`
@@ -107,8 +136,23 @@ export class PhoneStreamCoordinator {
107
136
  lastFanoutSignature = null;
108
137
  /** Send full resolvedConfig only once per stream session. */
109
138
  resolvedConfigForwarded = false;
110
- constructor(timings = {}) {
139
+ constructor(timings = {}, deps = {}) {
111
140
  this.timings = { ...DEFAULT_TIMINGS, ...timings };
141
+ this.linkSource = deps.linkSource ?? storeLinkSource;
142
+ }
143
+ /**
144
+ * Fail fast if glasses aren't connected — BEFORE provisioning. Without this a
145
+ * managed start would create a provider live input, fail the BLE command, and
146
+ * tear the input down again: a slow, billable no-op with a confusing error.
147
+ */
148
+ assertGlassesConnected() {
149
+ if (!this.linkSource.isConnected()) {
150
+ throw new StreamConflictError("GLASSES_NOT_CONNECTED", "Glasses are not connected", "command", "ble");
151
+ }
152
+ }
153
+ /** True while the active stream is parked on a dropped BLE link. */
154
+ isSuspended() {
155
+ return this.suspended !== null;
112
156
  }
113
157
  /**
114
158
  * Run `work` under the transition lock. Each call awaits the previous
@@ -146,7 +190,11 @@ export class PhoneStreamCoordinator {
146
190
  /** Report-safe stream ownership snapshot for incident diagnostics. */
147
191
  getDiagnosticSnapshot() {
148
192
  if (!this.current)
149
- return { active: false };
193
+ return { active: false, pendingBleStop: this.pendingBleStop?.streamId ?? null };
194
+ const link = {
195
+ suspended: this.suspended !== null,
196
+ ...(this.suspended ? { suspendedForMs: Date.now() - this.suspended.since } : {}),
197
+ };
150
198
  return this.current.kind === "managed"
151
199
  ? {
152
200
  active: true,
@@ -155,12 +203,14 @@ export class PhoneStreamCoordinator {
155
203
  subscribers: [...this.current.subscribers].sort(),
156
204
  mode: this.current.mode,
157
205
  playbackReady: this.current.hlsReady,
206
+ ...link,
158
207
  }
159
208
  : {
160
209
  active: true,
161
210
  kind: this.current.kind,
162
211
  streamId: this.current.streamId,
163
212
  ownerPackageName: this.current.packageName,
213
+ ...link,
164
214
  };
165
215
  }
166
216
  async startUnmanaged(packageName, opts) {
@@ -169,7 +219,7 @@ export class PhoneStreamCoordinator {
169
219
  if (!opts.streamUrl || typeof opts.streamUrl !== "string") {
170
220
  throw new StreamConflictError("STREAM_URL_REQUIRED", "streamUrl is required");
171
221
  }
172
- assertGlassesConnected();
222
+ this.assertGlassesConnected();
173
223
  return this.runExclusive(async () => {
174
224
  if (this.current) {
175
225
  throw new StreamConflictError("STREAM_ALREADY_ACTIVE", `A ${this.current.kind} stream is already active. Stop it before starting a new one.`);
@@ -195,6 +245,7 @@ export class PhoneStreamCoordinator {
195
245
  ...(opts.video !== undefined ? { video: opts.video } : {}),
196
246
  ...(opts.audio !== undefined ? { audio: opts.audio } : {}),
197
247
  ...(opts.authToken ? { authToken: opts.authToken } : {}),
248
+ ...(typeof opts.captureAudio === "boolean" ? { captureAudio: opts.captureAudio } : {}),
198
249
  });
199
250
  const result = publisherStartResult(streamId, event);
200
251
  this.startLifecycle(streamId);
@@ -219,7 +270,7 @@ export class PhoneStreamCoordinator {
219
270
  // Two-phase: the entry-claim runs under the transition lock; the wait for
220
271
  // HLS readiness happens AFTER the lock releases so a long warm-up doesn't
221
272
  // block subsequent start/stop transitions on this coordinator.
222
- assertGlassesConnected();
273
+ this.assertGlassesConnected();
223
274
  const decision = await this.runExclusive(async () => {
224
275
  if (this.current && this.current.kind === "unmanaged") {
225
276
  throw new StreamConflictError("STREAM_ALREADY_ACTIVE", "An unmanaged stream is already active. Stop it before starting a managed stream.");
@@ -279,6 +330,7 @@ export class PhoneStreamCoordinator {
279
330
  // See startUnmanaged: the native bridge rejects explicit `undefined`.
280
331
  ...(opts.video !== undefined ? { video: opts.video } : {}),
281
332
  ...(opts.audio !== undefined ? { audio: opts.audio } : {}),
333
+ ...(typeof opts.captureAudio === "boolean" ? { captureAudio: opts.captureAudio } : {}),
282
334
  });
283
335
  entry.publisherStart = publisherStartResult(streamId, event);
284
336
  console.info("[STREAM_STARTUP]", {
@@ -396,13 +448,120 @@ export class PhoneStreamCoordinator {
396
448
  this.lifecycle?.handleAck(event.ackId);
397
449
  }
398
450
  // ===========================================================================
451
+ // BLE link suspension
452
+ // ===========================================================================
453
+ attachLink() {
454
+ if (this.unsubscribeLink)
455
+ return;
456
+ this.unsubscribeLink = this.linkSource.subscribe((connected) => this.handleLinkChange(connected));
457
+ }
458
+ detachLinkIfIdle() {
459
+ if (this.current || this.pendingBleStop || !this.unsubscribeLink)
460
+ return;
461
+ this.unsubscribeLink();
462
+ this.unsubscribeLink = null;
463
+ }
464
+ handleLinkChange(connected) {
465
+ if (connected) {
466
+ if (this.current && this.suspended) {
467
+ this.resumeLocked();
468
+ }
469
+ else if (!this.current && this.pendingBleStop) {
470
+ this.flushPendingBleStop();
471
+ }
472
+ return;
473
+ }
474
+ if (this.current && !this.suspended)
475
+ this.suspend();
476
+ }
477
+ suspend() {
478
+ const entry = this.current;
479
+ if (!entry)
480
+ return;
481
+ const since = Date.now();
482
+ // Pause the heartbeat: misses now would be our own fault, not the
483
+ // publisher's, and counting them would reap a stream that is still live.
484
+ this.lifecycle?.setActive(false);
485
+ const graceTimer = setTimeout(() => this.onGraceExpired(entry.streamId), this.timings.glassesGraceMs);
486
+ this.suspended = { since, graceTimer, publisherGoneProbes: 0 };
487
+ console.warn("[STREAM] BLE link lost; stream suspended", {
488
+ streamId: entry.streamId,
489
+ graceMs: this.timings.glassesGraceMs,
490
+ });
491
+ this.fanout({
492
+ streamId: entry.streamId,
493
+ source: "coordinator",
494
+ status: LINK_STATUS.suspended,
495
+ data: { reason: LINK_STATUS.reason, graceMs: this.timings.glassesGraceMs, since },
496
+ });
497
+ }
498
+ resumeLocked() {
499
+ const entry = this.current;
500
+ const suspended = this.suspended;
501
+ if (!entry || !suspended)
502
+ return;
503
+ clearTimeout(suspended.graceTimer);
504
+ this.suspended = null;
505
+ const suspendedMs = Date.now() - suspended.since;
506
+ console.info("[STREAM] BLE link back; stream resumed", { streamId: entry.streamId, suspendedMs });
507
+ // Resume and heartbeat immediately: the glasses watchdog has been running
508
+ // the whole time, so the first keep-alive must not wait another interval.
509
+ this.lifecycle?.setActive(true);
510
+ this.lifecycle?.tickNow();
511
+ // A resumed session is a fresh status baseline for subscribers.
512
+ this.lastFanoutSignature = null;
513
+ this.fanout({
514
+ streamId: entry.streamId,
515
+ source: "coordinator",
516
+ status: LINK_STATUS.resumed,
517
+ data: { reason: LINK_STATUS.reason, suspendedMs },
518
+ });
519
+ }
520
+ onGraceExpired(streamId) {
521
+ if (this.current?.streamId !== streamId || !this.suspended)
522
+ return;
523
+ this.failSuspended(streamId, "glasses_disconnected", { publisherGone: false });
524
+ }
525
+ /**
526
+ * End a suspended stream. `publisherGone` distinguishes "glasses are off and
527
+ * Cloudflare confirms nothing is publishing" from "grace ran out with the
528
+ * publisher possibly still alive" — miniapps word the two differently.
529
+ */
530
+ failSuspended(streamId, reason, detail) {
531
+ this.fanout({
532
+ streamId,
533
+ source: "coordinator",
534
+ status: "error",
535
+ data: { reason: LINK_STATUS.reason, teardownReason: reason, ...detail },
536
+ });
537
+ void this.runExclusive(async () => {
538
+ if (this.current?.streamId !== streamId)
539
+ return;
540
+ await this.teardownLocked(reason);
541
+ });
542
+ }
543
+ flushPendingBleStop() {
544
+ const pending = this.pendingBleStop;
545
+ if (!pending)
546
+ return;
547
+ this.pendingBleStop = null;
548
+ console.info("[STREAM] BLE link back; sending deferred stopStream", pending);
549
+ void BluetoothSdk.stopStream()
550
+ .catch((err) => console.warn("[STREAM] deferred stopStream failed:", err))
551
+ .finally(() => this.detachLinkIfIdle());
552
+ }
553
+ // ===========================================================================
399
554
  // Internal
400
555
  // ===========================================================================
401
556
  mintId(prefix) {
402
557
  this.idCounter += 1;
403
558
  return `phone-${prefix}-${Date.now().toString(36)}-${this.idCounter}`;
404
559
  }
560
+ /** BLE keep-alives. StreamLifecycleController uses BgTimer so they survive MentraOS backgrounding. */
405
561
  startLifecycle(streamId) {
562
+ // A new stream supersedes any deferred stop for the previous one.
563
+ this.pendingBleStop = null;
564
+ this.attachLink();
406
565
  this.lifecycle?.dispose();
407
566
  const ctrl = new StreamLifecycleController({
408
567
  logger: consoleLogger,
@@ -475,6 +634,21 @@ export class PhoneStreamCoordinator {
475
634
  status: status.isConnected ? "connected" : "disconnected",
476
635
  data: status,
477
636
  });
637
+ // While the BLE link is down, Cloudflare is the only witness to the
638
+ // publisher. Two consecutive "nobody is publishing" probes mean the
639
+ // glasses are off, not merely out of Bluetooth range — stop waiting.
640
+ if (this.suspended) {
641
+ if (status.isConnected) {
642
+ this.suspended.publisherGoneProbes = 0;
643
+ }
644
+ else {
645
+ this.suspended.publisherGoneProbes += 1;
646
+ if (this.suspended.publisherGoneProbes >= this.timings.suspendedPublisherGoneProbes) {
647
+ this.failSuspended(entry.streamId, "glasses_disconnected_publisher_gone", { publisherGone: true });
648
+ keepPolling = false;
649
+ }
650
+ }
651
+ }
478
652
  // webrtc mode readiness: first "connected" means WHEP playback is
479
653
  // available (WebRTC playback follows the ingest directly; there is no
480
654
  // manifest to probe).
@@ -652,11 +826,25 @@ export class PhoneStreamCoordinator {
652
826
  const sendBleStop = options.sendBleStop !== false;
653
827
  this.lastFanoutSignature = null;
654
828
  this.resolvedConfigForwarded = false;
829
+ if (this.suspended) {
830
+ clearTimeout(this.suspended.graceTimer);
831
+ this.suspended = null;
832
+ }
655
833
  // Dispose the lifecycle controller immediately so it doesn't fire one
656
834
  // more keep-alive against a stream we're tearing down. The transition
657
835
  // lock guarantees no new lifecycle is started concurrently.
658
836
  this.lifecycle?.dispose();
659
837
  this.lifecycle = null;
838
+ // With the link down a BLE write can only fail (and hold the transition
839
+ // lock for the native timeout). Defer it to the next reconnect instead.
840
+ const linkUp = this.linkSource.isConnected();
841
+ if (sendBleStop && !linkUp) {
842
+ this.pendingBleStop = { streamId: entry.streamId };
843
+ console.warn("[STREAM] BLE link down during teardown; stopStream deferred", {
844
+ streamId: entry.streamId,
845
+ reason,
846
+ });
847
+ }
660
848
  if (entry.kind === "managed") {
661
849
  if (entry.cloudflareTimer)
662
850
  clearTimeout(entry.cloudflareTimer);
@@ -670,7 +858,7 @@ export class PhoneStreamCoordinator {
670
858
  entry.hlsReadyRejecters = [];
671
859
  }
672
860
  try {
673
- if (sendBleStop) {
861
+ if (sendBleStop && linkUp) {
674
862
  await BluetoothSdk.stopStream();
675
863
  }
676
864
  }
@@ -684,6 +872,7 @@ export class PhoneStreamCoordinator {
684
872
  // serializes us, so this should always be true).
685
873
  if (this.current === entry)
686
874
  this.current = null;
875
+ this.detachLinkIfIdle();
687
876
  if (entry.kind === "managed") {
688
877
  // Start remote cleanup only after the publisher has stopped, but do not
689
878
  // hold the local transition lock on an unbounded network request. The