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

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 (84) 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/index.d.ts +1 -0
  7. package/build/index.d.ts.map +1 -1
  8. package/build/index.js +1 -0
  9. package/build/index.js.map +1 -1
  10. package/build/runtime/streamConfig.d.ts +7 -0
  11. package/build/runtime/streamConfig.d.ts.map +1 -1
  12. package/build/runtime/streamConfig.js +7 -0
  13. package/build/runtime/streamConfig.js.map +1 -1
  14. package/build/services/AcsMeetingService.d.ts +154 -0
  15. package/build/services/AcsMeetingService.d.ts.map +1 -0
  16. package/build/services/AcsMeetingService.js +477 -0
  17. package/build/services/AcsMeetingService.js.map +1 -0
  18. package/build/services/AppRegistry.d.ts +9 -0
  19. package/build/services/AppRegistry.d.ts.map +1 -1
  20. package/build/services/AppRegistry.js +22 -0
  21. package/build/services/AppRegistry.js.map +1 -1
  22. package/build/services/DeviceEventRouter.d.ts.map +1 -1
  23. package/build/services/DeviceEventRouter.js +12 -3
  24. package/build/services/DeviceEventRouter.js.map +1 -1
  25. package/build/services/GlassesSettingsSync.d.ts.map +1 -1
  26. package/build/services/GlassesSettingsSync.js +11 -1
  27. package/build/services/GlassesSettingsSync.js.map +1 -1
  28. package/build/services/GlassesStatusProjection.d.ts +12 -0
  29. package/build/services/GlassesStatusProjection.d.ts.map +1 -1
  30. package/build/services/GlassesStatusProjection.js +23 -0
  31. package/build/services/GlassesStatusProjection.js.map +1 -1
  32. package/build/services/LocalMiniappRuntime.d.ts +12 -0
  33. package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
  34. package/build/services/LocalMiniappRuntime.js +184 -2
  35. package/build/services/LocalMiniappRuntime.js.map +1 -1
  36. package/build/services/MentraJSRouter.d.ts.map +1 -1
  37. package/build/services/MentraJSRouter.js +8 -1
  38. package/build/services/MentraJSRouter.js.map +1 -1
  39. package/build/services/MiniappLauncher.d.ts +3 -1
  40. package/build/services/MiniappLauncher.d.ts.map +1 -1
  41. package/build/services/MiniappLauncher.js +66 -50
  42. package/build/services/MiniappLauncher.js.map +1 -1
  43. package/build/services/PhoneCameraFovCoordinator.d.ts +18 -0
  44. package/build/services/PhoneCameraFovCoordinator.d.ts.map +1 -1
  45. package/build/services/PhoneCameraFovCoordinator.js +36 -0
  46. package/build/services/PhoneCameraFovCoordinator.js.map +1 -1
  47. package/build/services/PhoneStreamCoordinator.d.ts +67 -1
  48. package/build/services/PhoneStreamCoordinator.d.ts.map +1 -1
  49. package/build/services/PhoneStreamCoordinator.js +204 -15
  50. package/build/services/PhoneStreamCoordinator.js.map +1 -1
  51. package/build/services/StreamLifecycleController.d.ts +23 -5
  52. package/build/services/StreamLifecycleController.d.ts.map +1 -1
  53. package/build/services/StreamLifecycleController.js +31 -10
  54. package/build/services/StreamLifecycleController.js.map +1 -1
  55. package/build/services/acsAudioSource.d.ts +24 -0
  56. package/build/services/acsAudioSource.d.ts.map +1 -0
  57. package/build/services/acsAudioSource.js +37 -0
  58. package/build/services/acsAudioSource.js.map +1 -0
  59. package/build/utils/devMiniappLaunch.d.ts.map +1 -1
  60. package/build/utils/devMiniappLaunch.js +4 -0
  61. package/build/utils/devMiniappLaunch.js.map +1 -1
  62. package/build/utils/devMiniappSnapshot.d.ts +46 -0
  63. package/build/utils/devMiniappSnapshot.d.ts.map +1 -0
  64. package/build/utils/devMiniappSnapshot.js +94 -0
  65. package/build/utils/devMiniappSnapshot.js.map +1 -0
  66. package/package.json +7 -6
  67. package/src/engine.ts +10 -2
  68. package/src/generated/releaseMetadata.ts +5 -5
  69. package/src/index.ts +4 -0
  70. package/src/runtime/streamConfig.ts +13 -0
  71. package/src/services/AcsMeetingService.ts +570 -0
  72. package/src/services/AppRegistry.ts +23 -0
  73. package/src/services/DeviceEventRouter.ts +12 -3
  74. package/src/services/GlassesSettingsSync.ts +13 -3
  75. package/src/services/GlassesStatusProjection.ts +28 -0
  76. package/src/services/LocalMiniappRuntime.ts +214 -2
  77. package/src/services/MentraJSRouter.ts +7 -1
  78. package/src/services/MiniappLauncher.ts +73 -50
  79. package/src/services/PhoneCameraFovCoordinator.ts +34 -0
  80. package/src/services/PhoneStreamCoordinator.ts +232 -19
  81. package/src/services/StreamLifecycleController.ts +42 -12
  82. package/src/services/acsAudioSource.ts +53 -0
  83. package/src/utils/devMiniappLaunch.ts +4 -0
  84. package/src/utils/devMiniappSnapshot.ts +119 -0
@@ -1 +1 @@
1
- {"version":3,"file":"PhoneCameraFovCoordinator.js","sourceRoot":"","sources":["../../src/services/PhoneCameraFovCoordinator.ts"],"names":[],"mappings":"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","sourcesContent":["import BluetoothSdk, {type CameraFovRequest, type CameraFovResult} from \"@mentra/bluetooth-sdk/internal\"\n\nexport const CAMERA_FOV_OVERRIDE_TTL_MS = 300_000\nexport const CAMERA_FOV_OVERRIDE_REFRESH_MS = 120_000\n// Legacy ASG clients restart the camera HAL for an FOV update but cannot acknowledge\n// when it is usable again. The ASG cooldown is 5s; retain a small settling margin so\n// miniapps cannot submit a photo against the stale camera service.\nconst LEGACY_CAMERA_RESTART_SETTLE_MS = 7_000\n\ninterface OverrideEntry {\n leaseId: string\n fov: number\n roiPosition: \"center\" | \"bottom\" | \"top\"\n order: number\n}\n\nlet leaseCounter = 0\n\nfunction mintLeaseId(): string {\n leaseCounter = (leaseCounter + 1) & 0xffff\n return `fov-${leaseCounter.toString(16).padStart(4, \"0\")}`\n}\n\nfunction isLegacyFovCompatibilityError(error: unknown): boolean {\n const message = error instanceof Error ? error.message : String(error)\n return /timed out waiting for glasses response|not available|unknown method/i.test(message)\n}\n\n/**\n * Serializes miniapp-owned FOV/ROI overrides. The last live owner wins; releasing it applies the\n * previous owner or asks ASG to restore its persistent base. ASG refreshes are no-restart TTL\n * extensions for crash safety.\n */\nexport class PhoneCameraFovCoordinator {\n private readonly overrides = new Map<string, OverrideEntry>()\n private effectiveLeaseId: string | undefined\n private sequence = 0\n private queue: Promise<unknown> = Promise.resolve()\n private refreshTimer: ReturnType<typeof setTimeout> | undefined\n private legacyMode = false\n\n constructor(private readonly legacyRestartSettleMs = LEGACY_CAMERA_RESTART_SETTLE_MS) {}\n\n /** Report-safe FOV ownership snapshot for incident diagnostics. */\n getDiagnosticSnapshot(): Record<string, unknown> {\n return {\n owners: [...this.overrides.entries()]\n .sort(([, a], [, b]) => b.order - a.order)\n .map(([packageName, entry]) => ({\n packageName,\n effective: entry.leaseId === this.effectiveLeaseId,\n fov: entry.fov,\n roiPosition: entry.roiPosition,\n })),\n legacyMode: this.legacyMode,\n }\n }\n\n setOverride(packageName: string, request: CameraFovRequest): Promise<CameraFovResult> {\n return this.enqueue(async () => {\n const previous = this.overrides.get(packageName)\n const leaseId = previous?.leaseId ?? mintLeaseId()\n const order = ++this.sequence\n try {\n if (this.legacyMode) return this.setLegacyOverride(packageName, request, leaseId, order)\n const result = await BluetoothSdk.setCameraFovOverride({\n ...request,\n leaseId,\n ttlMs: CAMERA_FOV_OVERRIDE_TTL_MS,\n })\n this.overrides.set(packageName, {\n leaseId,\n fov: result.fov,\n roiPosition: result.roiPosition,\n order,\n })\n this.effectiveLeaseId = leaseId\n this.scheduleRefresh()\n return result\n } catch (error) {\n if (!isLegacyFovCompatibilityError(error)) throw error\n console.warn(\"[PhoneCameraFovCoordinator] override unavailable; using legacy FOV command\", error)\n const result = await this.setLegacyOverride(packageName, request, leaseId, order)\n // Only cache compatibility mode after the fallback itself succeeds. A\n // disconnect or missing native bridge must not permanently downgrade\n // future requests on a healthy/newer glasses connection.\n this.legacyMode = true\n return result\n }\n })\n }\n\n releaseForApp(packageName: string): Promise<void> {\n return this.enqueue(async () => {\n const removed = this.overrides.get(packageName)\n if (!removed) return\n if (removed.leaseId !== this.effectiveLeaseId) {\n this.overrides.delete(packageName)\n return\n }\n\n const next = [...this.overrides.entries()]\n .filter(([candidatePackage]) => candidatePackage !== packageName)\n .map(([, entry]) => entry)\n .sort((a, b) => b.order - a.order)[0]\n if (this.legacyMode) {\n if (next) {\n await this.applyLegacyFov({fov: next.fov, roiPosition: next.roiPosition})\n this.overrides.delete(packageName)\n this.effectiveLeaseId = next.leaseId\n } else {\n // Legacy commands are one-way and have no lease-release protocol.\n // Ask native to replay its unchanged persistent base setting, then\n // probe the modern override path again for the next ownership cycle.\n await BluetoothSdk.restoreLegacyCameraFov()\n await this.waitForLegacyCameraRestart()\n this.overrides.delete(packageName)\n this.clearRefresh()\n this.effectiveLeaseId = undefined\n this.legacyMode = false\n }\n return\n }\n if (next) {\n await BluetoothSdk.setCameraFovOverride({\n leaseId: next.leaseId,\n fov: next.fov,\n roiPosition: next.roiPosition,\n ttlMs: CAMERA_FOV_OVERRIDE_TTL_MS,\n })\n // Commit phone-side ownership only after ASG accepted the replacement. On failure the\n // closing app remains effective here, allowing unregister/reconnect cleanup to retry.\n this.overrides.delete(packageName)\n this.effectiveLeaseId = next.leaseId\n this.scheduleRefresh()\n } else {\n await BluetoothSdk.releaseCameraFovOverride(removed.leaseId)\n this.overrides.delete(packageName)\n this.clearRefresh()\n this.effectiveLeaseId = undefined\n }\n })\n }\n\n private scheduleRefresh(): void {\n if (this.legacyMode) return\n this.clearRefresh()\n const leaseId = this.effectiveLeaseId\n if (!leaseId) return\n this.refreshTimer = setTimeout(() => {\n void this.enqueue(async () => {\n if (this.effectiveLeaseId !== leaseId) return\n const entry = [...this.overrides.values()].find((candidate) => candidate.leaseId === leaseId)\n if (!entry) return\n await BluetoothSdk.setCameraFovOverride({\n leaseId,\n fov: entry.fov,\n roiPosition: entry.roiPosition,\n ttlMs: CAMERA_FOV_OVERRIDE_TTL_MS,\n })\n this.scheduleRefresh()\n }).catch((error) => console.warn(\"[PhoneCameraFovCoordinator] failed to refresh override\", error))\n }, CAMERA_FOV_OVERRIDE_REFRESH_MS)\n }\n\n private clearRefresh(): void {\n if (this.refreshTimer) clearTimeout(this.refreshTimer)\n this.refreshTimer = undefined\n }\n\n private async setLegacyOverride(\n packageName: string,\n request: CameraFovRequest,\n leaseId: string,\n order: number,\n ): Promise<CameraFovResult> {\n const result = await this.applyLegacyFov(request)\n this.overrides.set(packageName, {\n leaseId,\n fov: result.fov,\n roiPosition: result.roiPosition,\n order,\n })\n this.effectiveLeaseId = leaseId\n this.clearRefresh()\n return result\n }\n\n private async applyLegacyFov(request: CameraFovRequest): Promise<CameraFovResult> {\n const result = await BluetoothSdk.setLegacyCameraFov(request)\n await this.waitForLegacyCameraRestart()\n return result\n }\n\n private waitForLegacyCameraRestart(): Promise<void> {\n if (this.legacyRestartSettleMs <= 0) return Promise.resolve()\n return new Promise<void>((resolve) => setTimeout(resolve, this.legacyRestartSettleMs))\n }\n\n private enqueue<T>(operation: () => Promise<T>): Promise<T> {\n const result = this.queue.then(operation, operation)\n this.queue = result.then(\n () => undefined,\n () => undefined,\n )\n return result\n }\n}\n\nexport const phoneCameraFovCoordinator = new PhoneCameraFovCoordinator()\n"]}
1
+ {"version":3,"file":"PhoneCameraFovCoordinator.js","sourceRoot":"","sources":["../../src/services/PhoneCameraFovCoordinator.ts"],"names":[],"mappings":"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","sourcesContent":["import BluetoothSdk, {type CameraFovRequest, type CameraFovResult} from \"@mentra/bluetooth-sdk/internal\"\n\nexport const CAMERA_FOV_OVERRIDE_TTL_MS = 300_000\nexport const CAMERA_FOV_OVERRIDE_REFRESH_MS = 120_000\n// Legacy ASG clients restart the camera HAL for an FOV update but cannot acknowledge\n// when it is usable again. The ASG cooldown is 5s; retain a small settling margin so\n// miniapps cannot submit a photo against the stale camera service.\nconst LEGACY_CAMERA_RESTART_SETTLE_MS = 7_000\n\ninterface OverrideEntry {\n leaseId: string\n fov: number\n roiPosition: \"center\" | \"bottom\" | \"top\"\n order: number\n}\n\nlet leaseCounter = 0\n\nfunction mintLeaseId(): string {\n leaseCounter = (leaseCounter + 1) & 0xffff\n return `fov-${leaseCounter.toString(16).padStart(4, \"0\")}`\n}\n\nfunction isLegacyFovCompatibilityError(error: unknown): boolean {\n const message = error instanceof Error ? error.message : String(error)\n return /timed out waiting for glasses response|not available|unknown method/i.test(message)\n}\n\n/**\n * Serializes miniapp-owned FOV/ROI overrides. The last live owner wins; releasing it applies the\n * previous owner or asks ASG to restore its persistent base. ASG refreshes are no-restart TTL\n * extensions for crash safety.\n */\nexport class PhoneCameraFovCoordinator {\n private readonly overrides = new Map<string, OverrideEntry>()\n private effectiveLeaseId: string | undefined\n private sequence = 0\n private queue: Promise<unknown> = Promise.resolve()\n private refreshTimer: ReturnType<typeof setTimeout> | undefined\n private legacyMode = false\n\n constructor(private readonly legacyRestartSettleMs = LEGACY_CAMERA_RESTART_SETTLE_MS) {}\n\n /** Report-safe FOV ownership snapshot for incident diagnostics. */\n getDiagnosticSnapshot(): Record<string, unknown> {\n return {\n owners: [...this.overrides.entries()]\n .sort(([, a], [, b]) => b.order - a.order)\n .map(([packageName, entry]) => ({\n packageName,\n effective: entry.leaseId === this.effectiveLeaseId,\n fov: entry.fov,\n roiPosition: entry.roiPosition,\n })),\n legacyMode: this.legacyMode,\n }\n }\n\n setOverride(packageName: string, request: CameraFovRequest): Promise<CameraFovResult> {\n return this.enqueue(async () => {\n const previous = this.overrides.get(packageName)\n const leaseId = previous?.leaseId ?? mintLeaseId()\n const order = ++this.sequence\n try {\n if (this.legacyMode) return this.setLegacyOverride(packageName, request, leaseId, order)\n const result = await BluetoothSdk.setCameraFovOverride({\n ...request,\n leaseId,\n ttlMs: CAMERA_FOV_OVERRIDE_TTL_MS,\n })\n this.overrides.set(packageName, {\n leaseId,\n fov: result.fov,\n roiPosition: result.roiPosition,\n order,\n })\n this.effectiveLeaseId = leaseId\n this.scheduleRefresh()\n return result\n } catch (error) {\n if (!isLegacyFovCompatibilityError(error)) throw error\n console.warn(\"[PhoneCameraFovCoordinator] override unavailable; using legacy FOV command\", error)\n const result = await this.setLegacyOverride(packageName, request, leaseId, order)\n // Only cache compatibility mode after the fallback itself succeeds. A\n // disconnect or missing native bridge must not permanently downgrade\n // future requests on a healthy/newer glasses connection.\n this.legacyMode = true\n return result\n }\n })\n }\n\n /**\n * Re-assert the current override on the glasses.\n *\n * The on-connect device-settings push replays the persistent `camera_fov`,\n * so glasses that connect mid-call come up at the user's saved value and\n * silently drop a miniapp's override — a Mentra Call wearer loses the 102°\n * bottom crop by connecting their glasses after joining.\n *\n * The effective lease is recomputed inside [enqueue], not captured at call\n * time: a `setOverride` or `releaseForApp` already queued ahead of this must\n * win, and it is this coordinator that has to be the authoritative last\n * writer. Sends the complete override — `fov` *and* `roiPosition` — because\n * the scalar alone leaves the ROI wherever the persistent setting put it.\n *\n * No-op in legacy mode: those commands restart the camera HAL, which is far\n * too expensive to fire on every reconnect.\n */\n reapplyEffectiveOverride(): Promise<void> {\n return this.enqueue(async () => {\n if (this.legacyMode) return\n const leaseId = this.effectiveLeaseId\n if (!leaseId) return\n const entry = [...this.overrides.values()].find((candidate) => candidate.leaseId === leaseId)\n if (!entry) return\n await BluetoothSdk.setCameraFovOverride({\n leaseId,\n fov: entry.fov,\n roiPosition: entry.roiPosition,\n ttlMs: CAMERA_FOV_OVERRIDE_TTL_MS,\n })\n this.scheduleRefresh()\n })\n }\n\n releaseForApp(packageName: string): Promise<void> {\n return this.enqueue(async () => {\n const removed = this.overrides.get(packageName)\n if (!removed) return\n if (removed.leaseId !== this.effectiveLeaseId) {\n this.overrides.delete(packageName)\n return\n }\n\n const next = [...this.overrides.entries()]\n .filter(([candidatePackage]) => candidatePackage !== packageName)\n .map(([, entry]) => entry)\n .sort((a, b) => b.order - a.order)[0]\n if (this.legacyMode) {\n if (next) {\n await this.applyLegacyFov({fov: next.fov, roiPosition: next.roiPosition})\n this.overrides.delete(packageName)\n this.effectiveLeaseId = next.leaseId\n } else {\n // Legacy commands are one-way and have no lease-release protocol.\n // Ask native to replay its unchanged persistent base setting, then\n // probe the modern override path again for the next ownership cycle.\n await BluetoothSdk.restoreLegacyCameraFov()\n await this.waitForLegacyCameraRestart()\n this.overrides.delete(packageName)\n this.clearRefresh()\n this.effectiveLeaseId = undefined\n this.legacyMode = false\n }\n return\n }\n if (next) {\n await BluetoothSdk.setCameraFovOverride({\n leaseId: next.leaseId,\n fov: next.fov,\n roiPosition: next.roiPosition,\n ttlMs: CAMERA_FOV_OVERRIDE_TTL_MS,\n })\n // Commit phone-side ownership only after ASG accepted the replacement. On failure the\n // closing app remains effective here, allowing unregister/reconnect cleanup to retry.\n this.overrides.delete(packageName)\n this.effectiveLeaseId = next.leaseId\n this.scheduleRefresh()\n } else {\n await BluetoothSdk.releaseCameraFovOverride(removed.leaseId)\n this.overrides.delete(packageName)\n this.clearRefresh()\n this.effectiveLeaseId = undefined\n }\n })\n }\n\n private scheduleRefresh(): void {\n if (this.legacyMode) return\n this.clearRefresh()\n const leaseId = this.effectiveLeaseId\n if (!leaseId) return\n this.refreshTimer = setTimeout(() => {\n void this.enqueue(async () => {\n if (this.effectiveLeaseId !== leaseId) return\n const entry = [...this.overrides.values()].find((candidate) => candidate.leaseId === leaseId)\n if (!entry) return\n await BluetoothSdk.setCameraFovOverride({\n leaseId,\n fov: entry.fov,\n roiPosition: entry.roiPosition,\n ttlMs: CAMERA_FOV_OVERRIDE_TTL_MS,\n })\n this.scheduleRefresh()\n }).catch((error) => console.warn(\"[PhoneCameraFovCoordinator] failed to refresh override\", error))\n }, CAMERA_FOV_OVERRIDE_REFRESH_MS)\n }\n\n private clearRefresh(): void {\n if (this.refreshTimer) clearTimeout(this.refreshTimer)\n this.refreshTimer = undefined\n }\n\n private async setLegacyOverride(\n packageName: string,\n request: CameraFovRequest,\n leaseId: string,\n order: number,\n ): Promise<CameraFovResult> {\n const result = await this.applyLegacyFov(request)\n this.overrides.set(packageName, {\n leaseId,\n fov: result.fov,\n roiPosition: result.roiPosition,\n order,\n })\n this.effectiveLeaseId = leaseId\n this.clearRefresh()\n return result\n }\n\n private async applyLegacyFov(request: CameraFovRequest): Promise<CameraFovResult> {\n const result = await BluetoothSdk.setLegacyCameraFov(request)\n await this.waitForLegacyCameraRestart()\n return result\n }\n\n private waitForLegacyCameraRestart(): Promise<void> {\n if (this.legacyRestartSettleMs <= 0) return Promise.resolve()\n return new Promise<void>((resolve) => setTimeout(resolve, this.legacyRestartSettleMs))\n }\n\n private enqueue<T>(operation: () => Promise<T>): Promise<T> {\n const result = this.queue.then(operation, operation)\n this.queue = result.then(\n () => undefined,\n () => undefined,\n )\n return result\n }\n}\n\nexport const phoneCameraFovCoordinator = new PhoneCameraFovCoordinator()\n"]}
@@ -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 type { KeepAliveAckEvent, StreamResolvedConfig, StreamStartRequest, StreamStatusEvent } from "@mentra/bluetooth-sdk/internal";
31
43
  import { type RestreamDestinationInput } from "./cloudStreamApi";
@@ -42,11 +54,28 @@ declare const DEFAULT_TIMINGS: {
42
54
  readonly hlsReadinessInitialDelayMs: 5000;
43
55
  readonly hlsReadinessPollMs: 2000;
44
56
  readonly hlsReadinessMaxAttempts: 30;
57
+ readonly glassesGraceMs: 45000;
58
+ readonly suspendedPublisherGoneProbes: 2;
45
59
  };
46
60
  type TimingConfig = {
47
61
  [K in keyof typeof DEFAULT_TIMINGS]: number;
48
62
  };
49
63
  export type CoordinatorTimings = Partial<TimingConfig>;
64
+ /**
65
+ * Where the coordinator learns whether the phone↔glasses BLE link is up.
66
+ * Injected so tests can drive link transitions without the zustand store.
67
+ */
68
+ export interface GlassesLinkSource {
69
+ isConnected(): boolean;
70
+ /** Fires on every connected↔disconnected transition. */
71
+ subscribe(listener: (connected: boolean) => void): () => void;
72
+ }
73
+ /** Reasons carried on coordinator-sourced `stream_status` fanouts for link events. */
74
+ export declare const LINK_STATUS: {
75
+ readonly suspended: "suspended";
76
+ readonly resumed: "resumed";
77
+ readonly reason: "glasses_disconnected";
78
+ };
50
79
  export interface StartUnmanagedOptions {
51
80
  streamUrl: string;
52
81
  video?: StreamStartRequest["video"];
@@ -54,6 +83,7 @@ export interface StartUnmanagedOptions {
54
83
  sound?: boolean;
55
84
  /** Optional Bearer token for WHIP Authorization (custom authenticated endpoints). */
56
85
  authToken?: string;
86
+ captureAudio?: boolean;
57
87
  }
58
88
  export interface StartManagedOptions {
59
89
  restreamDestinations?: RestreamDestinationInput[];
@@ -71,6 +101,8 @@ export interface StartManagedOptions {
71
101
  * that drop WHIP/SRT UDP.
72
102
  */
73
103
  ingest?: "srt" | "whip" | "rtmp";
104
+ /** When false, glasses skip encoding their mic for this WHIP session. */
105
+ captureAudio?: boolean;
74
106
  }
75
107
  export interface StreamPublisherStartResult {
76
108
  streamId: string;
@@ -112,6 +144,16 @@ export declare class PhoneStreamCoordinator {
112
144
  private statusSubscriber;
113
145
  private idCounter;
114
146
  private readonly timings;
147
+ private readonly linkSource;
148
+ private unsubscribeLink;
149
+ private suspended;
150
+ /**
151
+ * A stream was torn down while the BLE link was down, so the glasses never
152
+ * got `stopStream`. Sent on the next reconnect (if no new stream has claimed
153
+ * the slot) so a publisher that outlived its input does not keep pushing
154
+ * until its own watchdog fires.
155
+ */
156
+ private pendingBleStop;
115
157
  /**
116
158
  * Serializes state transitions (start, stop, teardown). Without it, a
117
159
  * second `start*` racing with the first can pass the `this.current === null`
@@ -125,7 +167,17 @@ export declare class PhoneStreamCoordinator {
125
167
  private lastFanoutSignature;
126
168
  /** Send full resolvedConfig only once per stream session. */
127
169
  private resolvedConfigForwarded;
128
- constructor(timings?: CoordinatorTimings);
170
+ constructor(timings?: CoordinatorTimings, deps?: {
171
+ linkSource?: GlassesLinkSource;
172
+ });
173
+ /**
174
+ * Fail fast if glasses aren't connected — BEFORE provisioning. Without this a
175
+ * managed start would create a provider live input, fail the BLE command, and
176
+ * tear the input down again: a slow, billable no-op with a confusing error.
177
+ */
178
+ private assertGlassesConnected;
179
+ /** True while the active stream is parked on a dropped BLE link. */
180
+ isSuspended(): boolean;
129
181
  /**
130
182
  * Run `work` under the transition lock. Each call awaits the previous
131
183
  * transition before re-evaluating preconditions, so e.g. two concurrent
@@ -156,7 +208,21 @@ export declare class PhoneStreamCoordinator {
156
208
  handleGlassesStatus(event: StreamStatusEvent): void;
157
209
  /** Called by MantleManager when a phone-owned keep_alive_ack arrives. */
158
210
  handleKeepAliveAck(event: KeepAliveAckEvent): void;
211
+ private attachLink;
212
+ private detachLinkIfIdle;
213
+ private handleLinkChange;
214
+ private suspend;
215
+ private resumeLocked;
216
+ private onGraceExpired;
217
+ /**
218
+ * End a suspended stream. `publisherGone` distinguishes "glasses are off and
219
+ * Cloudflare confirms nothing is publishing" from "grace ran out with the
220
+ * publisher possibly still alive" — miniapps word the two differently.
221
+ */
222
+ private failSuspended;
223
+ private flushPendingBleStop;
159
224
  private mintId;
225
+ /** BLE keep-alives. StreamLifecycleController uses BgTimer so they survive MentraOS backgrounding. */
160
226
  private startLifecycle;
161
227
  private startCloudflareStatusPoll;
162
228
  private startHlsReadinessPoll;
@@ -1 +1 @@
1
- {"version":3,"file":"PhoneStreamCoordinator.d.ts","sourceRoot":"","sources":["../../src/services/PhoneStreamCoordinator.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"PhoneStreamCoordinator.d.ts","sourceRoot":"","sources":["../../src/services/PhoneStreamCoordinator.ts"],"names":[],"mappings":"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"}
@@ -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