@mentra/engine 3.2.0-dev.195 → 3.2.0-dev.206

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 (31) hide show
  1. package/build/generated/releaseMetadata.js +5 -5
  2. package/build/generated/releaseMetadata.js.map +1 -1
  3. package/build/services/DeviceEventRouter.d.ts.map +1 -1
  4. package/build/services/DeviceEventRouter.js +3 -7
  5. package/build/services/DeviceEventRouter.js.map +1 -1
  6. package/build/services/PhoneStreamCoordinator.d.ts +7 -20
  7. package/build/services/PhoneStreamCoordinator.d.ts.map +1 -1
  8. package/build/services/PhoneStreamCoordinator.js +16 -99
  9. package/build/services/PhoneStreamCoordinator.js.map +1 -1
  10. package/build/services/asg/galleryNotices.d.ts +1 -1
  11. package/build/services/asg/galleryNotices.d.ts.map +1 -1
  12. package/build/services/asg/galleryNotices.js.map +1 -1
  13. package/build/services/asg/gallerySyncService.d.ts +14 -0
  14. package/build/services/asg/gallerySyncService.d.ts.map +1 -1
  15. package/build/services/asg/gallerySyncService.js +232 -96
  16. package/build/services/asg/gallerySyncService.js.map +1 -1
  17. package/build/services/slimStreamStatus.d.ts.map +1 -1
  18. package/build/services/slimStreamStatus.js +16 -0
  19. package/build/services/slimStreamStatus.js.map +1 -1
  20. package/package.json +7 -7
  21. package/src/generated/releaseMetadata.ts +5 -5
  22. package/src/services/DeviceEventRouter.ts +5 -9
  23. package/src/services/PhoneStreamCoordinator.ts +23 -128
  24. package/src/services/asg/galleryNotices.ts +1 -0
  25. package/src/services/asg/gallerySyncService.ts +260 -112
  26. package/src/services/slimStreamStatus.ts +10 -1
  27. package/build/services/StreamLifecycleController.d.ts +0 -85
  28. package/build/services/StreamLifecycleController.d.ts.map +0 -1
  29. package/build/services/StreamLifecycleController.js +0 -173
  30. package/build/services/StreamLifecycleController.js.map +0 -1
  31. package/src/services/StreamLifecycleController.ts +0 -243
@@ -1 +1 @@
1
- {"version":3,"file":"slimStreamStatus.d.ts","sourceRoot":"","sources":["../../src/services/slimStreamStatus.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAC,iBAAiB,EAAC,MAAM,gCAAgC,CAAA;AAErE;;;;;;;;GAQG;AACH,eAAO,MAAM,6BAA6B,QAAQ,CAAA;AAElD,+EAA+E;AAC/E,wBAAgB,qBAAqB,CACnC,KAAK,EAAE,iBAAiB,EACxB,OAAO,GAAE;IAAC,qBAAqB,CAAC,EAAE,OAAO,CAAC;IAAC,kBAAkB,CAAC,EAAE,OAAO,CAAA;CAAM,GAC5E,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAiBzB;AAED,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAE9E"}
1
+ {"version":3,"file":"slimStreamStatus.d.ts","sourceRoot":"","sources":["../../src/services/slimStreamStatus.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAC,iBAAiB,EAAC,MAAM,gCAAgC,CAAA;AAErE;;;;;;;;GAQG;AACH,eAAO,MAAM,6BAA6B,QAAQ,CAAA;AAElD,+EAA+E;AAC/E,wBAAgB,qBAAqB,CACnC,KAAK,EAAE,iBAAiB,EACxB,OAAO,GAAE;IAAC,qBAAqB,CAAC,EAAE,OAAO,CAAC;IAAC,kBAAkB,CAAC,EAAE,OAAO,CAAA;CAAM,GAC5E,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CA0BzB;AAED,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAE9E"}
@@ -18,6 +18,22 @@ export function slimStreamStatusEvent(event, options = {}) {
18
18
  };
19
19
  if (event.streamId)
20
20
  slim.streamId = event.streamId;
21
+ if (event.sid)
22
+ slim.sid = event.sid;
23
+ if (typeof event.revision === "number")
24
+ slim.revision = event.revision;
25
+ if (typeof event.terminal === "boolean")
26
+ slim.terminal = event.terminal;
27
+ if (event.errorDetails)
28
+ slim.errorDetails = event.errorDetails;
29
+ if (event.kind === "reconnect") {
30
+ if ("reason" in event)
31
+ slim.reason = event.reason;
32
+ if ("attempt" in event)
33
+ slim.attempt = event.attempt;
34
+ if ("maxAttempts" in event)
35
+ slim.maxAttempts = event.maxAttempts;
36
+ }
21
37
  const ts = event.timestamp;
22
38
  if (typeof ts === "number" && Number.isFinite(ts))
23
39
  slim.timestamp = ts;
@@ -1 +1 @@
1
- {"version":3,"file":"slimStreamStatus.js","sourceRoot":"","sources":["../../src/services/slimStreamStatus.ts"],"names":[],"mappings":"AAEA;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,6BAA6B,GAAG,KAAK,CAAA;AAElD,+EAA+E;AAC/E,MAAM,UAAU,qBAAqB,CACnC,KAAwB,EACxB,UAA2E,EAAE;IAE7E,MAAM,kBAAkB,GAAG,OAAO,CAAC,kBAAkB,IAAI,6BAA6B,CAAA;IACtF,MAAM,IAAI,GAA4B;QACpC,IAAI,EAAE,eAAe;QACrB,IAAI,EAAE,KAAK,CAAC,IAAI;QAChB,MAAM,EAAE,KAAK,CAAC,MAAM;KACrB,CAAA;IACD,IAAI,KAAK,CAAC,QAAQ;QAAE,IAAI,CAAC,QAAQ,GAAG,KAAK,CAAC,QAAQ,CAAA;IAClD,MAAM,EAAE,GAAG,KAAK,CAAC,SAAS,CAAA;IAC1B,IAAI,OAAO,EAAE,KAAK,QAAQ,IAAI,MAAM,CAAC,QAAQ,CAAC,EAAE,CAAC;QAAE,IAAI,CAAC,SAAS,GAAG,EAAE,CAAA;IACtE,IAAI,OAAO,CAAC,qBAAqB,IAAI,KAAK,CAAC,cAAc,EAAE,CAAC;QAC1D,IAAI,CAAC,cAAc,GAAG,KAAK,CAAC,cAAc,CAAA;IAC5C,CAAC;IACD,+EAA+E;IAC/E,kFAAkF;IAClF,IAAI,kBAAkB,IAAI,KAAK,CAAC,KAAK;QAAE,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC,KAAK,CAAA;IAC/D,OAAO,IAAI,CAAA;AACb,CAAC;AAED,MAAM,UAAU,qBAAqB,CAAC,OAAgC;IACpE,OAAO,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,CAAA;AAChC,CAAC","sourcesContent":["import type {StreamStatusEvent} from \"@mentra/bluetooth-sdk/internal\"\r\n\r\n/**\r\n * 1Hz encoder FPS/bitrate telemetry on `stream_status.stats`.\r\n * Lifecycle status (started/stopped/error) is unaffected.\r\n * Keep false in production; flip locally to debug the Mentra Call FPS ladder.\r\n *\r\n * Compatibility kill switch: old glasses can still emit stats. Copy onto a new object;\r\n * never mutate the incoming event. Manual acceptance with every layer false: join\r\n * waterfall yes; BLE stream_status.stats / STREAM_QUALITY / encoder-stats / watch-stats no.\r\n */\r\nexport const ENABLE_PIPELINE_FPS_TELEMETRY = false\r\n\r\n/** Fields forwarded to cloud / miniapps after the first resolvedConfig ack. */\r\nexport function slimStreamStatusEvent(\r\n event: StreamStatusEvent,\r\n options: {includeResolvedConfig?: boolean; enableFpsTelemetry?: boolean} = {},\r\n): Record<string, unknown> {\r\n const enableFpsTelemetry = options.enableFpsTelemetry ?? ENABLE_PIPELINE_FPS_TELEMETRY\r\n const slim: Record<string, unknown> = {\r\n type: \"stream_status\",\r\n kind: event.kind,\r\n status: event.status,\r\n }\r\n if (event.streamId) slim.streamId = event.streamId\r\n const ts = event.timestamp\r\n if (typeof ts === \"number\" && Number.isFinite(ts)) slim.timestamp = ts\r\n if (options.includeResolvedConfig && event.resolvedConfig) {\r\n slim.resolvedConfig = event.resolvedConfig\r\n }\r\n // Compatibility kill switch: old glasses may still emit 1Hz stats. Copy onto a\r\n // new object; never mutate `event` — other consumers may still need the original.\r\n if (enableFpsTelemetry && event.stats) slim.stats = event.stats\r\n return slim\r\n}\r\n\r\nexport function streamStatusSignature(payload: Record<string, unknown>): string {\r\n return JSON.stringify(payload)\r\n}\r\n"]}
1
+ {"version":3,"file":"slimStreamStatus.js","sourceRoot":"","sources":["../../src/services/slimStreamStatus.ts"],"names":[],"mappings":"AAEA;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,6BAA6B,GAAG,KAAK,CAAA;AAElD,+EAA+E;AAC/E,MAAM,UAAU,qBAAqB,CACnC,KAAwB,EACxB,UAA2E,EAAE;IAE7E,MAAM,kBAAkB,GAAG,OAAO,CAAC,kBAAkB,IAAI,6BAA6B,CAAA;IACtF,MAAM,IAAI,GAA4B;QACpC,IAAI,EAAE,eAAe;QACrB,IAAI,EAAE,KAAK,CAAC,IAAI;QAChB,MAAM,EAAE,KAAK,CAAC,MAAM;KACrB,CAAA;IACD,IAAI,KAAK,CAAC,QAAQ;QAAE,IAAI,CAAC,QAAQ,GAAG,KAAK,CAAC,QAAQ,CAAA;IAClD,IAAI,KAAK,CAAC,GAAG;QAAE,IAAI,CAAC,GAAG,GAAG,KAAK,CAAC,GAAG,CAAA;IACnC,IAAI,OAAO,KAAK,CAAC,QAAQ,KAAK,QAAQ;QAAE,IAAI,CAAC,QAAQ,GAAG,KAAK,CAAC,QAAQ,CAAA;IACtE,IAAI,OAAO,KAAK,CAAC,QAAQ,KAAK,SAAS;QAAE,IAAI,CAAC,QAAQ,GAAG,KAAK,CAAC,QAAQ,CAAA;IACvE,IAAI,KAAK,CAAC,YAAY;QAAE,IAAI,CAAC,YAAY,GAAG,KAAK,CAAC,YAAY,CAAA;IAC9D,IAAI,KAAK,CAAC,IAAI,KAAK,WAAW,EAAE,CAAC;QAC/B,IAAI,QAAQ,IAAI,KAAK;YAAE,IAAI,CAAC,MAAM,GAAG,KAAK,CAAC,MAAM,CAAA;QACjD,IAAI,SAAS,IAAI,KAAK;YAAE,IAAI,CAAC,OAAO,GAAG,KAAK,CAAC,OAAO,CAAA;QACpD,IAAI,aAAa,IAAI,KAAK;YAAE,IAAI,CAAC,WAAW,GAAG,KAAK,CAAC,WAAW,CAAA;IAClE,CAAC;IACD,MAAM,EAAE,GAAG,KAAK,CAAC,SAAS,CAAA;IAC1B,IAAI,OAAO,EAAE,KAAK,QAAQ,IAAI,MAAM,CAAC,QAAQ,CAAC,EAAE,CAAC;QAAE,IAAI,CAAC,SAAS,GAAG,EAAE,CAAA;IACtE,IAAI,OAAO,CAAC,qBAAqB,IAAI,KAAK,CAAC,cAAc,EAAE,CAAC;QAC1D,IAAI,CAAC,cAAc,GAAG,KAAK,CAAC,cAAc,CAAA;IAC5C,CAAC;IACD,+EAA+E;IAC/E,kFAAkF;IAClF,IAAI,kBAAkB,IAAI,KAAK,CAAC,KAAK;QAAE,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC,KAAK,CAAA;IAC/D,OAAO,IAAI,CAAA;AACb,CAAC;AAED,MAAM,UAAU,qBAAqB,CAAC,OAAgC;IACpE,OAAO,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,CAAA;AAChC,CAAC","sourcesContent":["import type {StreamStatusEvent} from \"@mentra/bluetooth-sdk/internal\"\r\n\r\n/**\r\n * 1Hz encoder FPS/bitrate telemetry on `stream_status.stats`.\r\n * Lifecycle status (started/stopped/error) is unaffected.\r\n * Keep false in production; flip locally to debug the Mentra Call FPS ladder.\r\n *\r\n * Compatibility kill switch: old glasses can still emit stats. Copy onto a new object;\r\n * never mutate the incoming event. Manual acceptance with every layer false: join\r\n * waterfall yes; BLE stream_status.stats / STREAM_QUALITY / encoder-stats / watch-stats no.\r\n */\r\nexport const ENABLE_PIPELINE_FPS_TELEMETRY = false\r\n\r\n/** Fields forwarded to cloud / miniapps after the first resolvedConfig ack. */\r\nexport function slimStreamStatusEvent(\r\n event: StreamStatusEvent,\r\n options: {includeResolvedConfig?: boolean; enableFpsTelemetry?: boolean} = {},\r\n): Record<string, unknown> {\r\n const enableFpsTelemetry = options.enableFpsTelemetry ?? ENABLE_PIPELINE_FPS_TELEMETRY\r\n const slim: Record<string, unknown> = {\r\n type: \"stream_status\",\r\n kind: event.kind,\r\n status: event.status,\r\n }\r\n if (event.streamId) slim.streamId = event.streamId\n if (event.sid) slim.sid = event.sid\n if (typeof event.revision === \"number\") slim.revision = event.revision\n if (typeof event.terminal === \"boolean\") slim.terminal = event.terminal\n if (event.errorDetails) slim.errorDetails = event.errorDetails\n if (event.kind === \"reconnect\") {\n if (\"reason\" in event) slim.reason = event.reason\n if (\"attempt\" in event) slim.attempt = event.attempt\n if (\"maxAttempts\" in event) slim.maxAttempts = event.maxAttempts\n }\n const ts = event.timestamp\r\n if (typeof ts === \"number\" && Number.isFinite(ts)) slim.timestamp = ts\r\n if (options.includeResolvedConfig && event.resolvedConfig) {\r\n slim.resolvedConfig = event.resolvedConfig\r\n }\r\n // Compatibility kill switch: old glasses may still emit 1Hz stats. Copy onto a\r\n // new object; never mutate `event` — other consumers may still need the original.\r\n if (enableFpsTelemetry && event.stats) slim.stats = event.stats\r\n return slim\r\n}\r\n\r\nexport function streamStatusSignature(payload: Record<string, unknown>): string {\r\n return JSON.stringify(payload)\r\n}\r\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mentra/engine",
3
- "version": "3.2.0-dev.195",
3
+ "version": "3.2.0-dev.206",
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,12 +97,12 @@
97
97
  "registry": "https://registry.npmjs.org/"
98
98
  },
99
99
  "dependencies": {
100
- "@mentra/acs-meeting": "3.2.0-dev.195",
101
- "@mentra/bluetooth-sdk": "3.2.0-dev.195",
102
- "@mentra/cloud-client": "3.2.0-dev.195",
103
- "@mentra/cloud-protocol": "3.2.0-dev.195",
104
- "@mentra/crust": "3.2.0-dev.195",
105
- "@mentra/miniapp": "3.2.0-dev.195",
100
+ "@mentra/acs-meeting": "3.2.0-dev.206",
101
+ "@mentra/bluetooth-sdk": "3.2.0-dev.206",
102
+ "@mentra/cloud-client": "3.2.0-dev.206",
103
+ "@mentra/cloud-protocol": "3.2.0-dev.206",
104
+ "@mentra/crust": "3.2.0-dev.206",
105
+ "@mentra/miniapp": "3.2.0-dev.206",
106
106
  "buffer": "^6.0.3",
107
107
  "events": "^3.3.0",
108
108
  "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.195",
16
- "releaseSetId": "mentra-3.2.0-dev.195",
17
- "sourceCommit": "321202ca96ec1852be19a7230ebcc3593fe87e54",
18
- "otaManifestUrl": "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.195.json",
19
- "otaManifestSha256": "70f26063b2c281b9b046965f6f6d7ca22f8b53242c6c5be02030c5a04c7115e4"
15
+ "releaseIdentity": "3.2.0-dev.206",
16
+ "releaseSetId": "mentra-3.2.0-dev.206",
17
+ "sourceCommit": "3c22b92a5a53ce608c087503417ca5b3d16cc926",
18
+ "otaManifestUrl": "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.206.json",
19
+ "otaManifestSha256": "7bc5e09f2757b2c11da58c93459bb0a554f406dd0ae5b50b58db28b64b92c838"
20
20
  })
@@ -162,22 +162,18 @@ export function startDeviceEventRouter(): void {
162
162
  }),
163
163
  )
164
164
 
165
- // Phone-owned stream status / keep-alive → the stream coordinator. The Cloud V1 relay
165
+ // Phone-owned stream status → the stream coordinator. The Cloud V1 relay
166
166
  // for non-owned streams was removed with Cloud V1 app end-of-life.
167
167
  subs.push(
168
168
  BluetoothSdk.addListener("stream_status", (event) => {
169
- if (event.streamId && phoneStreamCoordinator.owns(event.streamId)) {
169
+ if (
170
+ (event.streamId && phoneStreamCoordinator.owns(event.streamId)) ||
171
+ (!event.streamId && event.kind === "snapshot" && event.terminal === true)
172
+ ) {
170
173
  phoneStreamCoordinator.handleGlassesStatus(event)
171
174
  }
172
175
  }),
173
176
  )
174
- subs.push(
175
- BluetoothSdk.addListener("keep_alive_ack", (event) => {
176
- if (event.streamId && phoneStreamCoordinator.owns(event.streamId)) {
177
- phoneStreamCoordinator.handleKeepAliveAck(event)
178
- }
179
- }),
180
- )
181
177
 
182
178
  // --- device input → local miniapps (forwardEvent is subscriber-gated; a no-op when
183
179
  // no miniapp listens). The v1 SocketComms cloud legs for these were removed with
@@ -5,7 +5,6 @@
5
5
  * miniapp → SDK → LocalMiniappRuntime → coordinator
6
6
  * coordinator → BluetoothSdk (BLE → glasses publisher)
7
7
  * coordinator ↔ cloudStreamApi (managed only, cloud-v2 runtime provisioning)
8
- * coordinator ↔ StreamLifecycleController (keep-alive heartbeat)
9
8
  * coordinator → status listeners → routed back to miniapp(s)
10
9
  *
11
10
  * Single-stream constraint:
@@ -28,29 +27,17 @@
28
27
  * the legacy path.
29
28
  *
30
29
  * 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.
30
+ * ASG owns the ten-second phone/controller liveness deadline. The coordinator
31
+ * observes link suspension, retains cloud resources briefly for reconciliation,
32
+ * and never uses missing JavaScript heartbeats as evidence of publisher failure.
33
+ * An explicit stop that could not reach BLE is sent on the next reconnect.
41
34
  */
42
35
 
43
36
  import BluetoothSdk from "@mentra/bluetooth-sdk/internal"
44
- import type {
45
- KeepAliveAckEvent,
46
- StreamResolvedConfig,
47
- StreamStartRequest,
48
- StreamStatusEvent,
49
- } from "@mentra/bluetooth-sdk/internal"
37
+ import type {StreamResolvedConfig, StreamStartRequest, StreamStatusEvent} from "@mentra/bluetooth-sdk/internal"
50
38
  import {isGlassesConnected} from "./GlassesReadiness"
51
39
  import {useGlassesStore} from "../stores/glasses"
52
40
 
53
- import {StreamLifecycleController, type LifecycleLogger} from "./StreamLifecycleController"
54
41
  import {slimStreamStatusEvent, streamStatusSignature} from "./slimStreamStatus"
55
42
  import {
56
43
  getManagedStreamStatus,
@@ -66,9 +53,6 @@ import {
66
53
  * can shorten them; production code should never override these.
67
54
  */
68
55
  const DEFAULT_TIMINGS = {
69
- keepAliveIntervalMs: 15_000,
70
- ackTimeoutMs: 10_000,
71
- maxMissedAcks: 3,
72
56
  cloudflareStatusPollMs: 5_000,
73
57
  // During WHIP startup, probe quickly so readiness is not quantized to the
74
58
  // steady-state 5s monitoring cadence. The delay backs off on each miss.
@@ -77,10 +61,8 @@ const DEFAULT_TIMINGS = {
77
61
  hlsReadinessInitialDelayMs: 5_000,
78
62
  hlsReadinessPollMs: 2_000,
79
63
  hlsReadinessMaxAttempts: 30,
80
- // How long a stream survives a BLE link drop before we give up on it. Must
81
- // stay under the glasses publisher's own 60s no-keep-alive watchdog with
82
- // margin for the resume heartbeat's round trip.
83
- glassesGraceMs: 45_000,
64
+ // Allow ASG's ten-second stop plus a short delivery/reconciliation margin.
65
+ glassesGraceMs: 15_000,
84
66
  // Consecutive Cloudflare "publisher disconnected" probes while suspended
85
67
  // before we conclude the glasses are off (not just out of BLE range).
86
68
  suspendedPublisherGoneProbes: 2,
@@ -111,19 +93,6 @@ export const LINK_STATUS = {
111
93
  reason: "glasses_disconnected",
112
94
  } as const
113
95
 
114
- // Console-backed minimal logger; replaces pino on the phone.
115
- const consoleLogger: LifecycleLogger = {
116
- child: (bindings) => ({
117
- ...consoleLogger,
118
- debug: (...args) => console.debug("[STREAM]", bindings, ...args),
119
- warn: (...args) => console.warn("[STREAM]", bindings, ...args),
120
- error: (...args) => console.error("[STREAM]", bindings, ...args),
121
- }),
122
- debug: (...args) => console.debug("[STREAM]", ...args),
123
- warn: (...args) => console.warn("[STREAM]", ...args),
124
- error: (...args) => console.error("[STREAM]", ...args),
125
- }
126
-
127
96
  export interface StartUnmanagedOptions {
128
97
  streamUrl: string
129
98
  video?: StreamStartRequest["video"]
@@ -237,7 +206,6 @@ interface SuspendedState {
237
206
 
238
207
  export class PhoneStreamCoordinator {
239
208
  private current: Entry | null = null
240
- private lifecycle: StreamLifecycleController | null = null
241
209
  private statusSubscriber: StatusSubscriber | null = null
242
210
  private idCounter = 0
243
211
  private readonly timings: TimingConfig
@@ -277,12 +245,7 @@ export class PhoneStreamCoordinator {
277
245
  */
278
246
  private assertGlassesConnected(): void {
279
247
  if (!this.linkSource.isConnected()) {
280
- throw new StreamConflictError(
281
- "GLASSES_NOT_CONNECTED",
282
- "Glasses are not connected",
283
- "command",
284
- "ble",
285
- )
248
+ throw new StreamConflictError("GLASSES_NOT_CONNECTED", "Glasses are not connected", "command", "ble")
286
249
  }
287
250
  }
288
251
 
@@ -352,10 +315,7 @@ export class PhoneStreamCoordinator {
352
315
  }
353
316
  }
354
317
 
355
- async startUnmanaged(
356
- packageName: string,
357
- opts: StartUnmanagedOptions,
358
- ): Promise<StreamPublisherStartResult> {
318
+ async startUnmanaged(packageName: string, opts: StartUnmanagedOptions): Promise<StreamPublisherStartResult> {
359
319
  // Pre-check the obvious-bad input before queueing — the lock is for
360
320
  // serializing state transitions, not for validating arguments.
361
321
  if (!opts.streamUrl || typeof opts.streamUrl !== "string") {
@@ -382,7 +342,7 @@ export class PhoneStreamCoordinator {
382
342
  this.current = entry
383
343
 
384
344
  try {
385
- const event = await BluetoothSdk.startExternallyManagedStream({
345
+ const event = await BluetoothSdk.startStream({
386
346
  type: "start_stream",
387
347
  streamUrl: opts.streamUrl,
388
348
  streamId,
@@ -404,10 +364,7 @@ export class PhoneStreamCoordinator {
404
364
  })
405
365
  }
406
366
 
407
- async startManaged(
408
- packageName: string,
409
- opts: StartManagedOptions,
410
- ): Promise<ManagedStartResult> {
367
+ async startManaged(packageName: string, opts: StartManagedOptions): Promise<ManagedStartResult> {
411
368
  const startupStartedAtMs = Date.now()
412
369
  // streamId doesn't exist yet — it's minted a few lines below, once we
413
370
  // know this is a fresh provision rather than a join onto an existing one.
@@ -446,9 +403,7 @@ export class PhoneStreamCoordinator {
446
403
  )
447
404
  }
448
405
  existing.subscribers.add(packageName)
449
- const immediate: ManagedStartResult | null = existing.hlsReady
450
- ? managedStartResult(existing)
451
- : null
406
+ const immediate: ManagedStartResult | null = existing.hlsReady ? managedStartResult(existing) : null
452
407
  return {kind: "join", entry: existing, immediate}
453
408
  }
454
409
 
@@ -458,8 +413,7 @@ export class PhoneStreamCoordinator {
458
413
  const provision = await provisionManagedStream(opts.restreamDestinations)
459
414
  const streamId = this.mintId("m")
460
415
  const ingestUrl = pickIngestUrl(provision, opts.ingest)
461
- const mode: ManagedEntry["mode"] =
462
- ingestUrl === provision.webrtcPublishUrl ? "webrtc" : "hls"
416
+ const mode: ManagedEntry["mode"] = ingestUrl === provision.webrtcPublishUrl ? "webrtc" : "hls"
463
417
 
464
418
  const entry: ManagedEntry = {
465
419
  kind: "managed",
@@ -488,7 +442,7 @@ export class PhoneStreamCoordinator {
488
442
  })
489
443
 
490
444
  try {
491
- const event = await BluetoothSdk.startExternallyManagedStream({
445
+ const event = await BluetoothSdk.startStream({
492
446
  type: "start_stream",
493
447
  streamUrl: ingestUrl,
494
448
  streamId,
@@ -573,7 +527,6 @@ export class PhoneStreamCoordinator {
573
527
  if (!this.current) return
574
528
  if (event.streamId && event.streamId !== this.current.streamId) return
575
529
 
576
- this.lifecycle?.recordActivity()
577
530
  const includeResolvedConfig = !this.resolvedConfigForwarded && !!event.resolvedConfig
578
531
  if (includeResolvedConfig) this.resolvedConfigForwarded = true
579
532
  const slimData = slimStreamStatusEvent(event, {includeResolvedConfig})
@@ -593,15 +546,14 @@ export class PhoneStreamCoordinator {
593
546
  // glasses publisher auto-recovers (error → reconnecting → reconnected),
594
547
  // and tearing down on the first hiccup deletes the live input out from
595
548
  // under a publisher that comes right back (it then retries into a dead
596
- // input forever). A publisher that errors and never recovers is reaped by
597
- // the keep-alive ack timeout. Queue the teardown through the transition
598
- // lock so it serializes with any start/stop currently in flight.
549
+ // input forever). ASG explicitly marks terminal failures; serialize teardown
550
+ // with any start/stop currently in flight.
599
551
  const isStopped =
600
552
  (event.kind === "lifecycle" && event.status === "stopped") ||
601
553
  (event.kind === "snapshot" && event.status === "stopped")
602
554
  const isGiveUp = event.kind === "reconnect" && event.status === "reconnect_failed"
603
- if (isGiveUp || isStopped) {
604
- const reason = isGiveUp ? "glasses_gave_up" : "glasses_stopped"
555
+ if (event.terminal === true || isGiveUp || isStopped) {
556
+ const reason = isGiveUp ? "glasses_gave_up" : event.status === "error" ? "glasses_error" : "glasses_stopped"
605
557
  const targetStreamId = this.current.streamId
606
558
  void this.runExclusive(async () => {
607
559
  // The stream we wanted to tear down may already be gone (e.g. another
@@ -612,12 +564,6 @@ export class PhoneStreamCoordinator {
612
564
  }
613
565
  }
614
566
 
615
- /** Called by MantleManager when a phone-owned keep_alive_ack arrives. */
616
- handleKeepAliveAck(event: KeepAliveAckEvent): void {
617
- if (!event.ackId) return
618
- this.lifecycle?.handleAck(event.ackId)
619
- }
620
-
621
567
  // ===========================================================================
622
568
  // BLE link suspension
623
569
  // ===========================================================================
@@ -649,9 +595,6 @@ export class PhoneStreamCoordinator {
649
595
  const entry = this.current
650
596
  if (!entry) return
651
597
  const since = Date.now()
652
- // Pause the heartbeat: misses now would be our own fault, not the
653
- // publisher's, and counting them would reap a stream that is still live.
654
- this.lifecycle?.setActive(false)
655
598
  const graceTimer = setTimeout(() => this.onGraceExpired(entry.streamId), this.timings.glassesGraceMs)
656
599
  this.suspended = {since, graceTimer, publisherGoneProbes: 0}
657
600
  console.warn("[STREAM] BLE link lost; stream suspended", {
@@ -674,10 +617,6 @@ export class PhoneStreamCoordinator {
674
617
  this.suspended = null
675
618
  const suspendedMs = Date.now() - suspended.since
676
619
  console.info("[STREAM] BLE link back; stream resumed", {streamId: entry.streamId, suspendedMs})
677
- // Resume and heartbeat immediately: the glasses watchdog has been running
678
- // the whole time, so the first keep-alive must not wait another interval.
679
- this.lifecycle?.setActive(true)
680
- this.lifecycle?.tickNow()
681
620
  // A resumed session is a fresh status baseline for subscribers.
682
621
  this.lastFanoutSignature = null
683
622
  this.fanout({
@@ -730,45 +669,10 @@ export class PhoneStreamCoordinator {
730
669
  return `phone-${prefix}-${Date.now().toString(36)}-${this.idCounter}`
731
670
  }
732
671
 
733
- /** BLE keep-alives. StreamLifecycleController uses BgTimer so they survive MentraOS backgrounding. */
734
- private startLifecycle(streamId: string): void {
735
- // A new stream supersedes any deferred stop for the previous one.
672
+ /** Observe link/status; native SDK and ASG own controller liveness. */
673
+ private startLifecycle(_streamId: string): void {
736
674
  this.pendingBleStop = null
737
675
  this.attachLink()
738
- this.lifecycle?.dispose()
739
- const ctrl = new StreamLifecycleController(
740
- {
741
- logger: consoleLogger,
742
- streamId,
743
- keepAliveIntervalMs: this.timings.keepAliveIntervalMs,
744
- ackTimeoutMs: this.timings.ackTimeoutMs,
745
- maxMissedAcks: this.timings.maxMissedAcks,
746
- },
747
- {
748
- sendKeepAlive: async (ackId) => {
749
- await BluetoothSdk.sendExternallyManagedStreamKeepAlive({
750
- type: "keep_stream_alive",
751
- streamId,
752
- ackId,
753
- })
754
- },
755
- onTimeout: async () => {
756
- this.fanout({
757
- streamId,
758
- source: "coordinator",
759
- status: "error",
760
- data: {reason: "keep_alive_timeout"},
761
- })
762
- await this.runExclusive(async () => {
763
- // The stream this timeout was bound to may already be gone.
764
- if (this.current?.streamId !== streamId) return
765
- await this.teardownLocked("keep_alive_timeout")
766
- })
767
- },
768
- },
769
- )
770
- ctrl.setActive(true)
771
- this.lifecycle = ctrl
772
676
  }
773
677
 
774
678
  private startCloudflareStatusPoll(entry: ManagedEntry): void {
@@ -967,10 +871,7 @@ export class PhoneStreamCoordinator {
967
871
 
968
872
  private fanout(update: StreamStatusUpdate): void {
969
873
  if (!this.current || !this.statusSubscriber) return
970
- const targets =
971
- this.current.kind === "managed"
972
- ? Array.from(this.current.subscribers)
973
- : [this.current.packageName]
874
+ const targets = this.current.kind === "managed" ? Array.from(this.current.subscribers) : [this.current.packageName]
974
875
  for (const pkg of targets) {
975
876
  try {
976
877
  this.statusSubscriber(pkg, update)
@@ -1000,12 +901,6 @@ export class PhoneStreamCoordinator {
1000
901
  this.suspended = null
1001
902
  }
1002
903
 
1003
- // Dispose the lifecycle controller immediately so it doesn't fire one
1004
- // more keep-alive against a stream we're tearing down. The transition
1005
- // lock guarantees no new lifecycle is started concurrently.
1006
- this.lifecycle?.dispose()
1007
- this.lifecycle = null
1008
-
1009
904
  // With the link down a BLE write can only fail (and hold the transition
1010
905
  // lock for the native timeout). Defer it to the next reconnect instead.
1011
906
  const linkUp = this.linkSource.isConnected()
@@ -1075,8 +970,8 @@ function pickIngestUrl(p: ProvisionResult, preference?: "srt" | "whip" | "rtmp")
1075
970
  preference === "whip"
1076
971
  ? p.webrtcPublishUrl || p.srtUrl || p.rtmpUrl
1077
972
  : preference === "rtmp"
1078
- ? p.rtmpUrl || p.srtUrl || p.webrtcPublishUrl
1079
- : p.srtUrl || p.rtmpUrl || p.webrtcPublishUrl
973
+ ? p.rtmpUrl || p.srtUrl || p.webrtcPublishUrl
974
+ : p.srtUrl || p.rtmpUrl || p.webrtcPublishUrl
1080
975
  if (!url) {
1081
976
  throw new Error("Cloudflare provision returned no usable ingest URL")
1082
977
  }
@@ -12,6 +12,7 @@ export type GalleryNoticeCode =
12
12
  | "wifi_initializing"
13
13
  | "wifi_off"
14
14
  | "location_services_off"
15
+ | "location_permission_required"
15
16
  | "camera_roll_permission_required"
16
17
  | "connect_to_glasses"
17
18