@mentra/bluetooth-sdk 3.2.0-dev.261 → 3.2.0-dev.262

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.
@@ -16,6 +16,7 @@ import com.mentra.bluetoothsdk.services.ForegroundService
16
16
  import com.mentra.bluetoothsdk.services.PhoneMic
17
17
  import com.mentra.bluetoothsdk.sgcs.Ar99
18
18
  import com.mentra.bluetoothsdk.sgcs.G1
19
+ import com.mentra.bluetoothsdk.sgcs.GlassesLinkDiagnostics
19
20
  import com.mentra.bluetoothsdk.sgcs.G2
20
21
  import com.mentra.bluetoothsdk.sgcs.SceneElement
21
22
  import com.mentra.bluetoothsdk.sgcs.SceneFrame
@@ -879,6 +880,7 @@ class DeviceManager {
879
880
 
880
881
  private fun micDbgLc3Window(lc3: ByteArray, seq: Int?, pcm: ByteArray) {
881
882
  val now = System.currentTimeMillis()
883
+ GlassesLinkDiagnostics.recordMicPacket(now)
882
884
  if (micDbgWindowStart == 0L) micDbgWindowStart = now
883
885
  micDbgPkts++
884
886
  micDbgLc3Bytes += lc3.size
@@ -3,9 +3,9 @@ package com.mentra.bluetoothsdk
3
3
  /** Generated by release CI. Do not edit in a release checkout. */
4
4
  internal object GeneratedReleaseMetadata {
5
5
  const val FAMILY_BASE_VERSION: String = "3.2.0"
6
- const val RELEASE_IDENTITY: String = "3.2.0-dev.261"
7
- const val RELEASE_SET_ID: String = "mentra-3.2.0-dev.261"
8
- const val SOURCE_COMMIT: String = "ebd405ff0f311035faccac6e19b0a5a38b3a1483"
9
- const val OTA_MANIFEST_URL: String = "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.261.json"
10
- const val OTA_MANIFEST_SHA256: String = "e9d88a258f276ee9ddc5396e9c5745f91f924449b802248e6d37dfe9bb8bb3ee"
6
+ const val RELEASE_IDENTITY: String = "3.2.0-dev.262"
7
+ const val RELEASE_SET_ID: String = "mentra-3.2.0-dev.262"
8
+ const val SOURCE_COMMIT: String = "545ed70c7afa46ca09b895d2dcd651b45510dbb5"
9
+ const val OTA_MANIFEST_URL: String = "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.262.json"
10
+ const val OTA_MANIFEST_SHA256: String = "d1c4e63ae2c1cd8de479d4bbc57219d2b85e904c26a491441265f5182220125f"
11
11
  }
@@ -89,6 +89,9 @@ class MentraBluetoothSdk private constructor(
89
89
  activityLifecycleCallbacksRegistered = true
90
90
  }
91
91
  bridgeEventSinkId = Bridge.addEventSink { eventName, data -> dispatchBridgeEvent(eventName, data) }
92
+ // DeviceManager/MentraLive outlive Expo module remounts. glasses_ready already
93
+ // ran on this BLE session; replay it so startStream does not claim old firmware.
94
+ deviceManager.sgc?.replayStreamControlReady()
92
95
  // Baseline the analytics connection state before subscribing to the store:
93
96
  // store updates invoke listeners synchronously on the updating thread, so a
94
97
  // connected status observed before the baseline would be reported as a fresh
@@ -0,0 +1,96 @@
1
+ package com.mentra.bluetoothsdk.sgcs
2
+
3
+ /**
4
+ * Keeps the handful of measurements that explain why a Mentra Live BLE link died, and renders them
5
+ * as one line when it does.
6
+ *
7
+ * <p>A BES watchdog reboot reaches the phone only as `GATT connection error: status=8`, which says
8
+ * nothing about whether the link faded, went abruptly quiet, or was dropped while healthy. The
9
+ * answer is always in the seconds before: how far the RSSI had fallen, how long since the glasses
10
+ * last said anything, and how long since a mic packet arrived. Those exist today only as thousands
11
+ * of separate `RSSI:` and `MICDBG-RX` lines that have to be correlated by hand after the fact.
12
+ *
13
+ * <p>Phone-side, because the glasses' own log is the artifact most likely to be missing or to have
14
+ * lost the window: it uploads only a 600-line logcat tail, which the post-reboot reconnect fills.
15
+ */
16
+ object GlassesLinkDiagnostics {
17
+
18
+ /** Grep marker. Never build this by concatenation — a single grep must be exhaustive. */
19
+ const val MARKER = "LINK_DEATH"
20
+
21
+ private const val RSSI_HISTORY = 12
22
+
23
+ private val rssiHistory = ArrayDeque<Sample>(RSSI_HISTORY)
24
+ private var lastInboundMs: Long? = null
25
+ private var lastMicPacketMs: Long? = null
26
+ private var sessionStartMs: Long? = null
27
+
28
+ private data class Sample(val atMs: Long, val rssi: Int)
29
+
30
+ /** Called when a BLE session is established, so ages are measured against this link only. */
31
+ @Synchronized
32
+ fun onSessionStart(nowMs: Long = System.currentTimeMillis()) {
33
+ rssiHistory.clear()
34
+ lastInboundMs = null
35
+ lastMicPacketMs = null
36
+ sessionStartMs = nowMs
37
+ }
38
+
39
+ /** Called for every JSON message the glasses send. */
40
+ @Synchronized
41
+ fun recordInbound(nowMs: Long = System.currentTimeMillis()) {
42
+ lastInboundMs = nowMs
43
+ }
44
+
45
+ /** Called for every LC3 mic packet. Mic gaps lead the control-channel failure by seconds. */
46
+ @Synchronized
47
+ fun recordMicPacket(nowMs: Long = System.currentTimeMillis()) {
48
+ lastMicPacketMs = nowMs
49
+ }
50
+
51
+ @Synchronized
52
+ fun recordRssi(rssi: Int, nowMs: Long = System.currentTimeMillis()) {
53
+ while (rssiHistory.size >= RSSI_HISTORY) {
54
+ rssiHistory.removeFirst()
55
+ }
56
+ rssiHistory.addLast(Sample(nowMs, rssi))
57
+ }
58
+
59
+ /**
60
+ * One-line summary of the link's final seconds.
61
+ *
62
+ * @param reason the transition being explained, e.g. `gatt_error` or `gatt_disconnected`
63
+ * @param status the GATT status code, or null when the stack did not report one
64
+ */
65
+ @Synchronized
66
+ fun summary(
67
+ reason: String,
68
+ status: Int? = null,
69
+ streamActive: Boolean? = null,
70
+ nowMs: Long = System.currentTimeMillis()
71
+ ): String {
72
+ val parts = mutableListOf<String>()
73
+ parts.add(MARKER)
74
+ parts.add("reason=$reason")
75
+ if (status != null) parts.add("status=$status")
76
+ parts.add("sessionMs=${age(sessionStartMs, nowMs)}")
77
+ parts.add("msSinceInbound=${age(lastInboundMs, nowMs)}")
78
+ parts.add("msSinceMicPacket=${age(lastMicPacketMs, nowMs)}")
79
+ parts.add("lastRssi=${rssiHistory.lastOrNull()?.rssi ?: UNKNOWN}")
80
+ parts.add("minRssi=${rssiHistory.minOfOrNull { it.rssi } ?: UNKNOWN}")
81
+ parts.add("rssiTrend=${trend()}")
82
+ if (streamActive != null) parts.add("streamActive=$streamActive")
83
+ return parts.joinToString(" ")
84
+ }
85
+
86
+ /** Oldest-to-newest RSSI so a fade is distinguishable from an abrupt drop at full signal. */
87
+ private fun trend(): String =
88
+ if (rssiHistory.isEmpty()) "none"
89
+ else rssiHistory.joinToString(",") { it.rssi.toString() }
90
+
91
+ /** Reports [UNKNOWN] for a signal never seen on this link, rather than an age from epoch. */
92
+ private fun age(sinceMs: Long?, nowMs: Long): Long =
93
+ if (sinceMs == null) UNKNOWN.toLong() else nowMs - sinceMs
94
+
95
+ private const val UNKNOWN = -1
96
+ }
@@ -537,6 +537,9 @@ class MentraLive : SGCManager() {
537
537
  // cannot signal a restart - a CHANGED (or newly appearing) sid is the restart signal.
538
538
  // Null = no sid observed this BLE session (legacy glasses, or none seen yet).
539
539
  private var glassesSessionId: String? = null
540
+ // Last `glasses_ready.streamControlVersion`. Survives BluetoothSdkModule remounts
541
+ // so a new MentraBluetoothSdk can seed StreamSessionState without another ready.
542
+ private var streamControlVersion = 0
540
543
  // True once a glasses_ready completed on THIS physical BLE session; resets only with
541
544
  // the physical connection (never on heartbeat readiness flaps), so a first-seen sid
542
545
  // after an upgrade OTA is always detected as a restart.
@@ -1673,6 +1676,7 @@ class MentraLive : SGCManager() {
1673
1676
  connectedDevice = null
1674
1677
  glassesReady = false
1675
1678
  glassesSessionId = null
1679
+ streamControlVersion = 0
1676
1680
  readinessCompletedThisBleSession = false
1677
1681
  glassesReadyReceived = false
1678
1682
  ctkdInitiatedThisGattSession = false
@@ -1947,6 +1951,7 @@ class MentraLive : SGCManager() {
1947
1951
  connectedDevice = null
1948
1952
  glassesReady = false
1949
1953
  glassesSessionId = null
1954
+ streamControlVersion = 0
1950
1955
  readinessCompletedThisBleSession = false
1951
1956
  glassesReadyReceived = false
1952
1957
  ctkdInitiatedThisGattSession = false
@@ -2328,6 +2333,8 @@ class MentraLive : SGCManager() {
2328
2333
  // Discover services
2329
2334
  gatt.discoverServices()
2330
2335
 
2336
+ GlassesLinkDiagnostics.onSessionStart()
2337
+
2331
2338
  // Do NOT reset reconnectAttempts here — ephemeral GATT CONNECTED
2332
2339
  // followed by status 19 was zeroing the counter every ~1s and
2333
2340
  // preventing exponential backoff. Reset at ble_chars_ready instead.
@@ -2340,6 +2347,13 @@ class MentraLive : SGCManager() {
2340
2347
  Bridge.log(
2341
2348
  "LIVE: 🔌 ⚠️ Disconnected from GATT server - Will attempt reconnection"
2342
2349
  )
2350
+ Bridge.log(
2351
+ "LIVE: " +
2352
+ GlassesLinkDiagnostics.summary(
2353
+ "gatt_disconnected",
2354
+ status
2355
+ )
2356
+ )
2343
2357
  endPairingTiming(
2344
2358
  "gatt_disconnected",
2345
2359
  "queueSize=${sendQueue.size}"
@@ -2350,6 +2364,7 @@ class MentraLive : SGCManager() {
2350
2364
  connectedDevice = null
2351
2365
  glassesReady = false // Reset ready state on disconnect
2352
2366
  glassesSessionId = null // Fresh BLE session starts with no sid known
2367
+ streamControlVersion = 0
2353
2368
  readinessCompletedThisBleSession = false
2354
2369
 
2355
2370
  // Reset audio pairing flags
@@ -2401,6 +2416,9 @@ class MentraLive : SGCManager() {
2401
2416
  status +
2402
2417
  ") - Will retry reconnection"
2403
2418
  )
2419
+ Bridge.log(
2420
+ "LIVE: " + GlassesLinkDiagnostics.summary("gatt_error", status)
2421
+ )
2404
2422
  endPairingTiming(
2405
2423
  "gatt_error",
2406
2424
  "status=$status queueSize=${sendQueue.size}"
@@ -2409,6 +2427,7 @@ class MentraLive : SGCManager() {
2409
2427
  isConnecting = false
2410
2428
  glassesReady = false
2411
2429
  glassesSessionId = null
2430
+ streamControlVersion = 0
2412
2431
  readinessCompletedThisBleSession = false
2413
2432
  glassesReadyReceived = false
2414
2433
  audioConnected = false
@@ -3970,6 +3989,7 @@ class MentraLive : SGCManager() {
3970
3989
  Log.d(TAG, "LIVE: Got some JSON from glasses: " + json.toString())
3971
3990
  }
3972
3991
  BleTraceLogger.logJson("glasses_to_phone", "sdk_ble_event", json, null)
3992
+ GlassesLinkDiagnostics.recordInbound()
3973
3993
 
3974
3994
  if (MessageChunker.isChunkedMessage(json)) {
3975
3995
  processChunkedJsonMessage(json)
@@ -4571,6 +4591,7 @@ class MentraLive : SGCManager() {
4571
4591
  // already runs this full remote-reset flow, so recording (not re-triggering)
4572
4592
  // is correct here; version_info detection covers the restart case.
4573
4593
  glassesSessionId = json.optString("sid", "").takeIf { it.isNotEmpty() }
4594
+ streamControlVersion = json.optInt("streamControlVersion", 0)
4574
4595
  Bridge.sendTypedMessage(
4575
4596
  "wifi_protocol_session_ready",
4576
4597
  mapOf("sid" to (glassesSessionId ?: "")),
@@ -4578,7 +4599,7 @@ class MentraLive : SGCManager() {
4578
4599
  readinessCompletedThisBleSession = true
4579
4600
  Bridge.sendTypedMessage("stream_control_ready", mapOf(
4580
4601
  "sid" to json.optString("sid", ""),
4581
- "streamControlVersion" to json.optInt("streamControlVersion", 0),
4602
+ "streamControlVersion" to streamControlVersion,
4582
4603
  ))
4583
4604
 
4584
4605
  // Set the ready flag to stop any future readiness checks
@@ -5519,6 +5540,7 @@ class MentraLive : SGCManager() {
5519
5540
  glassesReady = false
5520
5541
  glassesReadyReceived = false
5521
5542
  glassesSessionId = null
5543
+ streamControlVersion = 0
5522
5544
  readinessCompletedThisBleSession = false
5523
5545
  }
5524
5546
  "sr_adota" -> {
@@ -6138,6 +6160,7 @@ class MentraLive : SGCManager() {
6138
6160
  val now = System.currentTimeMillis()
6139
6161
  DeviceStore.apply("glasses", "signalStrength", rssi)
6140
6162
  DeviceStore.apply("glasses", "signalStrengthUpdatedAt", now)
6163
+ GlassesLinkDiagnostics.recordRssi(rssi, now)
6141
6164
  Bridge.log("LIVE: 📶 RSSI: " + rssi + " dBm")
6142
6165
  }
6143
6166
 
@@ -6821,6 +6844,19 @@ class MentraLive : SGCManager() {
6821
6844
  sendJson(json, true)
6822
6845
  }
6823
6846
 
6847
+ override fun replayStreamControlReady() {
6848
+ val sid = glassesSessionId ?: return
6849
+ if (streamControlVersion != 1) return
6850
+ Bridge.log("LIVE: Replaying stream_control_ready sid=$sid version=$streamControlVersion")
6851
+ Bridge.sendTypedMessage(
6852
+ "stream_control_ready",
6853
+ mapOf(
6854
+ "sid" to sid,
6855
+ "streamControlVersion" to streamControlVersion,
6856
+ ),
6857
+ )
6858
+ }
6859
+
6824
6860
  override fun startStream(message: MutableMap<String, Any>) {
6825
6861
  Bridge.log("LIVE: Starting RTMP stream")
6826
6862
 
@@ -54,6 +54,11 @@ abstract class SGCManager {
54
54
  abstract fun requestPhoto(request: PhotoRequest)
55
55
  abstract fun startStream(message: MutableMap<String, Any>)
56
56
  abstract fun stopStream()
57
+ /**
58
+ * Re-advertise glasses-owned stream control after the phone SDK remounts.
59
+ * Mentra Live overrides this; other devices stay on the no-op.
60
+ */
61
+ open fun replayStreamControlReady() {}
57
62
  abstract fun sendStreamKeepAlive(message: MutableMap<String, Any>)
58
63
  abstract fun startVideoRecording(requestId: String, save: Boolean, sound: Boolean)
59
64
  open fun queryVideoRecordingStatus(requestId: String) {
@@ -0,0 +1,79 @@
1
+ package com.mentra.bluetoothsdk
2
+
3
+ import com.mentra.bluetoothsdk.sgcs.GlassesLinkDiagnostics
4
+ import org.junit.Assert.assertTrue
5
+ import org.junit.Before
6
+ import org.junit.Test
7
+
8
+ class GlassesLinkDiagnosticsTest {
9
+
10
+ @Before
11
+ fun resetSession() {
12
+ GlassesLinkDiagnostics.onSessionStart(nowMs = 0L)
13
+ }
14
+
15
+ @Test
16
+ fun `summary separates a fading link from an abrupt drop`() {
17
+ // Walking out of range: RSSI decays, then the glasses go quiet before GATT notices.
18
+ GlassesLinkDiagnostics.recordRssi(-62, nowMs = 1_000L)
19
+ GlassesLinkDiagnostics.recordRssi(-78, nowMs = 2_000L)
20
+ GlassesLinkDiagnostics.recordRssi(-92, nowMs = 3_000L)
21
+ GlassesLinkDiagnostics.recordInbound(nowMs = 3_200L)
22
+ GlassesLinkDiagnostics.recordMicPacket(nowMs = 3_400L)
23
+
24
+ val summary =
25
+ GlassesLinkDiagnostics.summary(
26
+ reason = "gatt_error",
27
+ status = 8,
28
+ streamActive = true,
29
+ nowMs = 12_000L
30
+ )
31
+
32
+ assertTrue(summary.startsWith("LINK_DEATH "))
33
+ assertTrue(summary.contains("reason=gatt_error"))
34
+ assertTrue(summary.contains("status=8"))
35
+ assertTrue(summary.contains("sessionMs=12000"))
36
+ assertTrue(summary.contains("msSinceInbound=8800"))
37
+ assertTrue(summary.contains("msSinceMicPacket=8600"))
38
+ assertTrue(summary.contains("lastRssi=-92"))
39
+ assertTrue(summary.contains("minRssi=-92"))
40
+ assertTrue(summary.contains("rssiTrend=-62,-78,-92"))
41
+ assertTrue(summary.contains("streamActive=true"))
42
+ }
43
+
44
+ @Test
45
+ fun `summary marks never-seen signals rather than reporting a bogus age`() {
46
+ val summary = GlassesLinkDiagnostics.summary(reason = "gatt_disconnected", nowMs = 5_000L)
47
+
48
+ assertTrue(summary.contains("msSinceInbound=-1"))
49
+ assertTrue(summary.contains("msSinceMicPacket=-1"))
50
+ assertTrue(summary.contains("lastRssi=-1"))
51
+ assertTrue(summary.contains("rssiTrend=none"))
52
+ assertTrue(!summary.contains("status="))
53
+ assertTrue(!summary.contains("streamActive="))
54
+ }
55
+
56
+ @Test
57
+ fun `rssi history is bounded and keeps the newest samples`() {
58
+ repeat(30) { i -> GlassesLinkDiagnostics.recordRssi(-40 - i, nowMs = i.toLong()) }
59
+
60
+ val summary = GlassesLinkDiagnostics.summary(reason = "gatt_error", nowMs = 100L)
61
+
62
+ assertTrue(summary.contains("lastRssi=-69"))
63
+ assertTrue(summary.contains("minRssi=-69"))
64
+ assertTrue(summary.contains("rssiTrend=-58,-59,-60,-61,-62,-63,-64,-65,-66,-67,-68,-69"))
65
+ }
66
+
67
+ @Test
68
+ fun `a new session forgets the previous link`() {
69
+ GlassesLinkDiagnostics.recordRssi(-91, nowMs = 1_000L)
70
+ GlassesLinkDiagnostics.recordInbound(nowMs = 1_000L)
71
+
72
+ GlassesLinkDiagnostics.onSessionStart(nowMs = 2_000L)
73
+ val summary = GlassesLinkDiagnostics.summary(reason = "gatt_error", nowMs = 2_500L)
74
+
75
+ assertTrue(summary.contains("sessionMs=500"))
76
+ assertTrue(summary.contains("rssiTrend=none"))
77
+ assertTrue(summary.contains("msSinceInbound=-1"))
78
+ }
79
+ }
@@ -1,10 +1,10 @@
1
1
  export const BLUETOOTH_SDK_RELEASE_METADATA = Object.freeze({
2
2
  "schemaVersion": 1,
3
3
  "familyBaseVersion": "3.2.0",
4
- "releaseIdentity": "3.2.0-dev.261",
5
- "releaseSetId": "mentra-3.2.0-dev.261",
6
- "sourceCommit": "ebd405ff0f311035faccac6e19b0a5a38b3a1483",
7
- "otaManifestUrl": "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.261.json",
8
- "otaManifestSha256": "e9d88a258f276ee9ddc5396e9c5745f91f924449b802248e6d37dfe9bb8bb3ee"
4
+ "releaseIdentity": "3.2.0-dev.262",
5
+ "releaseSetId": "mentra-3.2.0-dev.262",
6
+ "sourceCommit": "545ed70c7afa46ca09b895d2dcd651b45510dbb5",
7
+ "otaManifestUrl": "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.262.json",
8
+ "otaManifestSha256": "d1c4e63ae2c1cd8de479d4bbc57219d2b85e904c26a491441265f5182220125f"
9
9
  });
10
10
  //# sourceMappingURL=releaseMetadata.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"releaseMetadata.js","sourceRoot":"","sources":["../../src/generated/releaseMetadata.ts"],"names":[],"mappings":"AAWA,MAAM,CAAC,MAAM,8BAA8B,GAA0C,MAAM,CAAC,MAAM,CAAC;IACjG,eAAe,EAAE,CAAC;IAClB,mBAAmB,EAAE,OAAO;IAC5B,iBAAiB,EAAE,eAAe;IAClC,cAAc,EAAE,sBAAsB;IACtC,cAAc,EAAE,0CAA0C;IAC1D,gBAAgB,EAAE,wHAAwH;IAC1I,mBAAmB,EAAE,kEAAkE;CACxF,CAAC,CAAA","sourcesContent":["/** Generated by release CI. Do not edit in a release checkout. */\nexport interface BluetoothSdkReleaseMetadata {\n schemaVersion: 1\n familyBaseVersion: string | null\n releaseIdentity: string | null\n releaseSetId: string | null\n sourceCommit: string | null\n otaManifestUrl: string | null\n otaManifestSha256: string | null\n}\n\nexport const BLUETOOTH_SDK_RELEASE_METADATA: Readonly<BluetoothSdkReleaseMetadata> = Object.freeze({\n \"schemaVersion\": 1,\n \"familyBaseVersion\": \"3.2.0\",\n \"releaseIdentity\": \"3.2.0-dev.261\",\n \"releaseSetId\": \"mentra-3.2.0-dev.261\",\n \"sourceCommit\": \"ebd405ff0f311035faccac6e19b0a5a38b3a1483\",\n \"otaManifestUrl\": \"https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.261.json\",\n \"otaManifestSha256\": \"e9d88a258f276ee9ddc5396e9c5745f91f924449b802248e6d37dfe9bb8bb3ee\"\n})\n"]}
1
+ {"version":3,"file":"releaseMetadata.js","sourceRoot":"","sources":["../../src/generated/releaseMetadata.ts"],"names":[],"mappings":"AAWA,MAAM,CAAC,MAAM,8BAA8B,GAA0C,MAAM,CAAC,MAAM,CAAC;IACjG,eAAe,EAAE,CAAC;IAClB,mBAAmB,EAAE,OAAO;IAC5B,iBAAiB,EAAE,eAAe;IAClC,cAAc,EAAE,sBAAsB;IACtC,cAAc,EAAE,0CAA0C;IAC1D,gBAAgB,EAAE,wHAAwH;IAC1I,mBAAmB,EAAE,kEAAkE;CACxF,CAAC,CAAA","sourcesContent":["/** Generated by release CI. Do not edit in a release checkout. */\nexport interface BluetoothSdkReleaseMetadata {\n schemaVersion: 1\n familyBaseVersion: string | null\n releaseIdentity: string | null\n releaseSetId: string | null\n sourceCommit: string | null\n otaManifestUrl: string | null\n otaManifestSha256: string | null\n}\n\nexport const BLUETOOTH_SDK_RELEASE_METADATA: Readonly<BluetoothSdkReleaseMetadata> = Object.freeze({\n \"schemaVersion\": 1,\n \"familyBaseVersion\": \"3.2.0\",\n \"releaseIdentity\": \"3.2.0-dev.262\",\n \"releaseSetId\": \"mentra-3.2.0-dev.262\",\n \"sourceCommit\": \"545ed70c7afa46ca09b895d2dcd651b45510dbb5\",\n \"otaManifestUrl\": \"https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.262.json\",\n \"otaManifestSha256\": \"d1c4e63ae2c1cd8de479d4bbc57219d2b85e904c26a491441265f5182220125f\"\n})\n"]}
@@ -2,6 +2,16 @@ import CryptoKit
2
2
  import ExpoModulesCore
3
3
  import Foundation
4
4
 
5
+ /// Carry the SDK's own error code across the Expo bridge.
6
+ ///
7
+ /// `BluetoothSdkError` is a plain `Error`, not an Expo `Exception`, so `Promise.reject`
8
+ /// wraps it in `UnexpectedException` and every code JS branches on arrives as
9
+ /// `ERR_UNEXPECTED`. Android keeps the code because `DecoratedException` forwards
10
+ /// `cause.code`; this is the iOS half of that guarantee.
11
+ private func codedBridgeError(_ error: BluetoothSdkError) -> Exception {
12
+ Exception(name: error.code, description: error.message, code: error.code)
13
+ }
14
+
5
15
  public class BluetoothSdkModule: Module, MentraBluetoothSDKDelegate {
6
16
  private var sdk: MentraBluetoothSDK?
7
17
 
@@ -344,9 +354,16 @@ public class BluetoothSdkModule: Module, MentraBluetoothSDKDelegate {
344
354
  return try await sdk.forgetWifiNetwork(ssid: ssid).values
345
355
  }
346
356
 
357
+ // The host retries a hotspot command that a Wi-Fi protocol session refresh
358
+ // cancelled, and branches on the code to tell that apart from a real failure,
359
+ // so this one has to reach JS with its code intact.
347
360
  AsyncFunction("setHotspotState") { (enabled: Bool) in
348
361
  let sdk = await MainActor.run { self.bluetoothSdk() }
349
- return try await sdk.setHotspotState(enabled: enabled).values
362
+ do {
363
+ return try await sdk.setHotspotState(enabled: enabled).values
364
+ } catch let error as BluetoothSdkError {
365
+ throw codedBridgeError(error)
366
+ }
350
367
  }
351
368
 
352
369
  AsyncFunction("setWifiAdbState") { (enabled: Bool) in
@@ -11,7 +11,7 @@ enum BluetoothSdkDefaults {
11
11
  static let voiceActivityDetectionEnabled = false
12
12
  static let loudnessGateEnabled = false
13
13
  private static let infoSdkVersionKey = "MentraBluetoothSdkVersion"
14
- private static let swiftPackageSdkVersion = "3.2.0-dev.261"
14
+ private static let swiftPackageSdkVersion = "3.2.0-dev.262"
15
15
  private static let swiftPackageSdkVersionPlaceholder = "__MENTRA" + "_BLUETOOTH_SDK_VERSION__"
16
16
 
17
17
  private static func normalizedSdkVersion(_ value: String?) -> String? {
@@ -3,9 +3,9 @@ import Foundation
3
3
  /// Generated by release CI. Do not edit in a release checkout.
4
4
  enum GeneratedReleaseMetadata {
5
5
  static let familyBaseVersion = "3.2.0"
6
- static let releaseIdentity = "3.2.0-dev.261"
7
- static let releaseSetId = "mentra-3.2.0-dev.261"
8
- static let sourceCommit = "ebd405ff0f311035faccac6e19b0a5a38b3a1483"
9
- static let otaManifestUrl = "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.261.json"
10
- static let otaManifestSha256 = "e9d88a258f276ee9ddc5396e9c5745f91f924449b802248e6d37dfe9bb8bb3ee"
6
+ static let releaseIdentity = "3.2.0-dev.262"
7
+ static let releaseSetId = "mentra-3.2.0-dev.262"
8
+ static let sourceCommit = "545ed70c7afa46ca09b895d2dcd651b45510dbb5"
9
+ static let otaManifestUrl = "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.262.json"
10
+ static let otaManifestSha256 = "d1c4e63ae2c1cd8de479d4bbc57219d2b85e904c26a491441265f5182220125f"
11
11
  }
@@ -328,6 +328,7 @@ public final class MentraBluetoothSDK {
328
328
  }
329
329
  }
330
330
  }
331
+ DeviceManager.shared.sgc?.replayStreamControlReady()
331
332
  storeListenerId = DeviceStore.shared.store.addListener { [weak self] category, changes in
332
333
  Task { @MainActor [weak self] in
333
334
  self?.dispatchStoreUpdate(category, changes)
@@ -1056,6 +1056,7 @@ extension MentraLive: CBCentralManagerDelegate {
1056
1056
  self.fullyBooted = false
1057
1057
  self.connected = false
1058
1058
  self.glassesSessionId = nil // Fresh BLE session starts with no sid known
1059
+ self.streamControlVersion = 0
1059
1060
  self.readinessCompletedThisBleSession = false
1060
1061
  self.updateConnectionState(ConnTypes.DISCONNECTED)
1061
1062
  self.rgbLedAuthorityClaimed = false
@@ -1702,6 +1703,9 @@ class MentraLive: NSObject, SGCManager {
1702
1703
  /// cannot signal a restart - a CHANGED (or newly appearing) sid is the restart signal.
1703
1704
  /// Nil = no sid observed this BLE session (legacy glasses, or none seen yet).
1704
1705
  private var glassesSessionId: String?
1706
+ /// Last `glasses_ready.streamControlVersion`. Survives SDK remounts so a new
1707
+ /// MentraBluetoothSDK can seed StreamSessionState without another ready.
1708
+ private var streamControlVersion = 0
1705
1709
  // True once a glasses_ready completed on THIS physical BLE session. Unlike
1706
1710
  // fullyBooted, this never flaps on sr_hrt ready=0 heartbeats — it only resets with
1707
1711
  // the physical connection — so a first-seen sid after an upgrade OTA cannot be
@@ -1845,6 +1849,7 @@ class MentraLive: NSObject, SGCManager {
1845
1849
  connected = false
1846
1850
  fullyBooted = false
1847
1851
  glassesSessionId = nil
1852
+ streamControlVersion = 0
1848
1853
  readinessCompletedThisBleSession = false
1849
1854
  rgbLedAuthorityClaimed = false
1850
1855
  stopAllTimers()
@@ -2152,6 +2157,15 @@ class MentraLive: NSObject, SGCManager {
2152
2157
  )
2153
2158
  }
2154
2159
 
2160
+ func replayStreamControlReady() {
2161
+ guard let sid = glassesSessionId, streamControlVersion == 1 else { return }
2162
+ Bridge.log("LIVE: Replaying stream_control_ready sid=\(sid) version=\(streamControlVersion)")
2163
+ Bridge.sendTypedMessage("stream_control_ready", body: [
2164
+ "sid": sid,
2165
+ "streamControlVersion": streamControlVersion,
2166
+ ])
2167
+ }
2168
+
2155
2169
  func startStream(_ message: [String: Any]) {
2156
2170
  Bridge.log("Starting stream")
2157
2171
  var json = message
@@ -2522,6 +2536,7 @@ class MentraLive: NSObject, SGCManager {
2522
2536
  connected = false
2523
2537
  fullyBooted = false
2524
2538
  glassesSessionId = nil
2539
+ streamControlVersion = 0
2525
2540
  readinessCompletedThisBleSession = false
2526
2541
  readinessCompletedThisBleSession = false // Fresh BLE session starts with no sid known
2527
2542
  readinessCompletedThisBleSession = false
@@ -2801,6 +2816,7 @@ class MentraLive: NSObject, SGCManager {
2801
2816
  // already runs this full remote-reset flow, so recording (not re-triggering)
2802
2817
  // is correct here; version_info detection covers the restart case.
2803
2818
  glassesSessionId = (json["sid"] as? String).flatMap { $0.isEmpty ? nil : $0 }
2819
+ streamControlVersion = json["streamControlVersion"] as? Int ?? 0
2804
2820
  Bridge.sendTypedMessage(
2805
2821
  "wifi_protocol_session_ready",
2806
2822
  body: ["sid": glassesSessionId ?? ""]
@@ -2808,7 +2824,7 @@ class MentraLive: NSObject, SGCManager {
2808
2824
  readinessCompletedThisBleSession = true
2809
2825
  Bridge.sendTypedMessage("stream_control_ready", body: [
2810
2826
  "sid": json["sid"] as? String ?? "",
2811
- "streamControlVersion": json["streamControlVersion"] as? Int ?? 0,
2827
+ "streamControlVersion": streamControlVersion,
2812
2828
  ])
2813
2829
  handleGlassesReady()
2814
2830
 
@@ -5653,6 +5669,7 @@ class MentraLive: NSObject, SGCManager {
5653
5669
  fullyBooted = false
5654
5670
  connected = false
5655
5671
  glassesSessionId = nil
5672
+ streamControlVersion = 0
5656
5673
 
5657
5674
  Bridge.log("LIVE: 🔄 Starting glasses SOC readiness check loop")
5658
5675
 
@@ -66,6 +66,8 @@ protocol SGCManager {
66
66
  func requestPhoto(_ request: PhotoRequest)
67
67
  func startStream(_ message: [String: Any])
68
68
  func stopStream()
69
+ /// Re-advertise glasses-owned stream control after the phone SDK remounts.
70
+ func replayStreamControlReady()
69
71
  func sendStreamKeepAlive(_ message: [String: Any])
70
72
  func startVideoRecording(requestId: String, save: Bool, sound: Bool)
71
73
  func queryVideoRecordingStatus(requestId: String)
@@ -238,6 +240,8 @@ extension SGCManager {
238
240
  false
239
241
  }
240
242
 
243
+ func replayStreamControlReady() {}
244
+
241
245
  @discardableResult func requestSavedWifiNetworks(requestId _: String, sid _: String) -> Bool {
242
246
  false
243
247
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mentra/bluetooth-sdk",
3
- "version": "3.2.0-dev.261",
3
+ "version": "3.2.0-dev.262",
4
4
  "description": "SDK for communicating with smart glasses",
5
5
  "main": "build/index.js",
6
6
  "react-native": "src/index.ts",
@@ -114,7 +114,7 @@
114
114
  "registry": "https://registry.npmjs.org/"
115
115
  },
116
116
  "dependencies": {
117
- "@mentra/cloud-protocol": "3.2.0-dev.261"
117
+ "@mentra/cloud-protocol": "3.2.0-dev.262"
118
118
  },
119
119
  "devDependencies": {
120
120
  "@types/node": "^25.9.3",
@@ -12,9 +12,9 @@ export interface BluetoothSdkReleaseMetadata {
12
12
  export const BLUETOOTH_SDK_RELEASE_METADATA: Readonly<BluetoothSdkReleaseMetadata> = Object.freeze({
13
13
  "schemaVersion": 1,
14
14
  "familyBaseVersion": "3.2.0",
15
- "releaseIdentity": "3.2.0-dev.261",
16
- "releaseSetId": "mentra-3.2.0-dev.261",
17
- "sourceCommit": "ebd405ff0f311035faccac6e19b0a5a38b3a1483",
18
- "otaManifestUrl": "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.261.json",
19
- "otaManifestSha256": "e9d88a258f276ee9ddc5396e9c5745f91f924449b802248e6d37dfe9bb8bb3ee"
15
+ "releaseIdentity": "3.2.0-dev.262",
16
+ "releaseSetId": "mentra-3.2.0-dev.262",
17
+ "sourceCommit": "545ed70c7afa46ca09b895d2dcd651b45510dbb5",
18
+ "otaManifestUrl": "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.262.json",
19
+ "otaManifestSha256": "d1c4e63ae2c1cd8de479d4bbc57219d2b85e904c26a491441265f5182220125f"
20
20
  })