@mentra/bluetooth-sdk 3.2.1-dev.311 → 3.2.1

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 (133) hide show
  1. package/README.md +4 -4
  2. package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +368 -0
  3. package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocol.kt +156 -0
  4. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +3 -0
  5. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +6 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +2 -1
  7. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +129 -25
  8. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +37 -13
  9. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +2 -2
  10. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  11. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +4 -0
  12. package/android/src/main/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulator.kt +13 -2
  13. package/android/src/main/java/com/mentra/bluetoothsdk/requests/DisplayRequests.kt +1 -1
  14. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +33 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/services/G2ConnectionRecovery.kt +47 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +2 -2
  17. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +72 -21
  18. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +101 -22
  19. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +12 -8
  20. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +314 -395
  21. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodec.kt +245 -0
  22. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinator.kt +238 -0
  23. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasEncoder.kt +58 -0
  24. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSession.kt +180 -0
  25. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +11 -0
  26. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SelectedDeviceAddress.kt +17 -0
  27. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SerializedGattCallback.kt +5 -0
  28. package/android/src/main/java/com/mentra/bluetoothsdk/utils/G2Text.kt +7 -0
  29. package/android/src/release/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +20 -0
  30. package/android/src/test/java/com/mentra/bluetoothsdk/DashboardContentFormatterTest.kt +2 -2
  31. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceIdentityPairingTest.kt +59 -0
  32. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerSceneHandoffTest.kt +666 -0
  33. package/android/src/test/java/com/mentra/bluetoothsdk/G2TextTest.kt +13 -0
  34. package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulatorTest.kt +71 -0
  35. package/android/src/test/java/com/mentra/bluetoothsdk/services/G2ConnectionRecoveryTest.kt +51 -0
  36. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1PairingTest.kt +105 -0
  37. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2DisplayThreadTest.kt +120 -0
  38. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2PairingTargetTest.kt +90 -0
  39. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveFileTransferSessionTest.kt +55 -0
  40. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCallbackTest.kt +33 -0
  41. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingTargetTest.kt +65 -0
  42. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraNexPairingTest.kt +151 -0
  43. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoBitmapAcceptanceTest.kt +105 -0
  44. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodecTest.kt +220 -0
  45. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinatorTest.kt +348 -0
  46. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSessionTest.kt +339 -0
  47. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoMicReconnectTest.kt +93 -0
  48. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoProtocolTest.kt +11 -0
  49. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/SelectedDeviceAddressTest.kt +27 -0
  50. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnosticsTest.kt +287 -0
  51. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocolTest.kt +127 -0
  52. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoLifecycleTest.kt +288 -0
  53. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoReadinessDriverTest.kt +205 -0
  54. package/build/BluetoothSdk.types.d.ts +13 -0
  55. package/build/BluetoothSdk.types.d.ts.map +1 -1
  56. package/build/BluetoothSdk.types.js +1 -0
  57. package/build/BluetoothSdk.types.js.map +1 -1
  58. package/build/_private/BluetoothSdkModule.d.ts +4 -0
  59. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  60. package/build/_private/BluetoothSdkModule.js +3 -0
  61. package/build/_private/BluetoothSdkModule.js.map +1 -1
  62. package/build/generated/changelogCatalog.d.ts +2 -2
  63. package/build/generated/changelogCatalog.js +2 -2
  64. package/build/generated/changelogCatalog.js.map +1 -1
  65. package/build/generated/releaseMetadata.js +5 -5
  66. package/build/generated/releaseMetadata.js.map +1 -1
  67. package/build/index.d.ts.map +1 -1
  68. package/build/index.js +1 -0
  69. package/build/index.js.map +1 -1
  70. package/ios/BluetoothSdkModule.swift +9 -0
  71. package/ios/MentraBluetoothSDK.podspec +3 -2
  72. package/ios/Source/BluetoothBackgroundMode.swift +32 -0
  73. package/ios/Source/BluetoothSdkDefaults.swift +6 -1
  74. package/ios/Source/Bridge.swift +15 -2
  75. package/ios/Source/DeviceManager.swift +96 -63
  76. package/ios/Source/DeviceStore.swift +26 -7
  77. package/ios/Source/GeneratedChangelogCatalog.swift +2 -2
  78. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  79. package/ios/Source/MentraBluetoothSDK.swift +18 -0
  80. package/ios/Source/audio/GlassesMicWatchdog.swift +33 -0
  81. package/ios/Source/requests/DisplayRequests.swift +1 -1
  82. package/ios/Source/requests/VersionInfoResponseAccumulator.swift +11 -1
  83. package/ios/Source/sgcs/Ar99.swift +1 -2
  84. package/ios/Source/sgcs/G1.swift +68 -38
  85. package/ios/Source/sgcs/G1ConnectionTarget.swift +13 -0
  86. package/ios/Source/sgcs/G2.swift +261 -247
  87. package/ios/Source/sgcs/G2ConnectionSession.swift +101 -0
  88. package/ios/Source/sgcs/Mach1.swift +19 -7
  89. package/ios/Source/sgcs/MentraLive.swift +201 -156
  90. package/ios/Source/sgcs/MentraLiveFileTransferSession.swift +137 -0
  91. package/ios/Source/sgcs/MentraLivePairingAdvertisement.swift +47 -0
  92. package/ios/Source/sgcs/MentraNex.swift +8 -31
  93. package/ios/Source/sgcs/NexConnectionTarget.swift +15 -0
  94. package/ios/Source/sgcs/Nimo.swift +280 -408
  95. package/ios/Source/sgcs/NimoCanvasCodec.swift +269 -0
  96. package/ios/Source/sgcs/NimoCanvasCoordinator.swift +233 -0
  97. package/ios/Source/sgcs/NimoCanvasImage.swift +53 -0
  98. package/ios/Source/sgcs/NimoCanvasSession.swift +166 -0
  99. package/ios/Source/sgcs/NimoDiscovery.swift +49 -0
  100. package/ios/Source/sgcs/SGCManager.swift +24 -0
  101. package/ios/Source/sgcs/VuzixConnectionTarget.swift +15 -0
  102. package/ios/Source/status/DeviceStatus.swift +1 -0
  103. package/ios/Source/stt/SherpaOnnxTranscriber.swift +49 -189
  104. package/ios/Source/utils/DemandDrivenAudioWorker.swift +150 -0
  105. package/ios/Source/utils/G2Text.swift +11 -0
  106. package/ios/Tests/BluetoothBackgroundModeTests.swift +32 -0
  107. package/ios/Tests/DashboardContentFormatterTests.swift +2 -2
  108. package/ios/Tests/DashboardRenderingTests.swift +31 -0
  109. package/ios/Tests/DemandDrivenAudioWorkerTests.swift +157 -0
  110. package/ios/Tests/DeviceIdentityPairingTests.swift +49 -0
  111. package/ios/Tests/DeviceManagerReconnectTests.swift +362 -0
  112. package/ios/Tests/G1PairingTests.swift +162 -0
  113. package/ios/Tests/G2ConnectionSessionTests.swift +172 -0
  114. package/ios/Tests/G2TextTests.swift +10 -0
  115. package/ios/Tests/MentraLiveFileTransferSessionTests.swift +47 -0
  116. package/ios/Tests/MentraLiveReconnectPolicyTests.swift +58 -0
  117. package/ios/Tests/NimoCanvasCodecTests.swift +204 -0
  118. package/ios/Tests/NimoCanvasSessionTests.swift +181 -0
  119. package/ios/Tests/NimoCanvasTransportTests.swift +186 -0
  120. package/ios/Tests/NimoDeviceCommandsTests.swift +16 -0
  121. package/ios/Tests/NimoDiscoveryTests.swift +79 -0
  122. package/ios/Tests/SGCConnectionTargetTests.swift +48 -0
  123. package/ios/Tests/VersionInfoCommandTests.swift +53 -0
  124. package/ios/Tests/VersionInfoResponseAccumulatorTests.swift +65 -0
  125. package/ios/Tests/VideoRecordingCommandTests.swift +81 -0
  126. package/package.json +2 -2
  127. package/plugin/build/withIos.d.ts +2 -0
  128. package/plugin/build/withIos.js +12 -1
  129. package/src/BluetoothSdk.types.ts +14 -0
  130. package/src/_private/BluetoothSdkModule.ts +8 -0
  131. package/src/generated/changelogCatalog.ts +2 -2
  132. package/src/generated/releaseMetadata.ts +5 -5
  133. package/src/index.ts +1 -0
@@ -1,5 +1,6 @@
1
1
  package com.mentra.bluetoothsdk.sgcs
2
2
 
3
+ import com.mentra.bluetoothsdk.utils.G2Text
3
4
  import com.mentra.bluetoothsdk.PhotoRequest
4
5
  import android.bluetooth.BluetoothAdapter
5
6
  import android.bluetooth.BluetoothGatt
@@ -1363,17 +1364,18 @@ class G2 : SGCManager() {
1363
1364
  // Map device names to serial numbers (populated from manufacturer data during scan)
1364
1365
  private val deviceNameToSerialNumber = mutableMapOf<String, String>()
1365
1366
 
1366
- // Saved addresses for reconnection
1367
+ // Scope each side's address to the selected serial. Legacy global addresses have
1368
+ // no trustworthy owner, so the first connection after upgrading learns them by scan.
1367
1369
  private var leftGlassAddress: String?
1368
1370
  get() =
1369
1371
  context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
1370
- .getString(KEY_LEFT_ADDRESS, null)
1372
+ .getString("$KEY_LEFT_ADDRESS:$DEVICE_SEARCH_ID", null)
1371
1373
  set(value) {
1372
1374
  context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
1373
1375
  .edit()
1374
1376
  .apply {
1375
- if (value != null) putString(KEY_LEFT_ADDRESS, value)
1376
- else remove(KEY_LEFT_ADDRESS)
1377
+ if (value != null) putString("$KEY_LEFT_ADDRESS:$DEVICE_SEARCH_ID", value)
1378
+ else remove("$KEY_LEFT_ADDRESS:$DEVICE_SEARCH_ID")
1377
1379
  }
1378
1380
  .apply()
1379
1381
  }
@@ -1381,13 +1383,13 @@ class G2 : SGCManager() {
1381
1383
  private var rightGlassAddress: String?
1382
1384
  get() =
1383
1385
  context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
1384
- .getString(KEY_RIGHT_ADDRESS, null)
1386
+ .getString("$KEY_RIGHT_ADDRESS:$DEVICE_SEARCH_ID", null)
1385
1387
  set(value) {
1386
1388
  context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
1387
1389
  .edit()
1388
1390
  .apply {
1389
- if (value != null) putString(KEY_RIGHT_ADDRESS, value)
1390
- else remove(KEY_RIGHT_ADDRESS)
1391
+ if (value != null) putString("$KEY_RIGHT_ADDRESS:$DEVICE_SEARCH_ID", value)
1392
+ else remove("$KEY_RIGHT_ADDRESS:$DEVICE_SEARCH_ID")
1391
1393
  }
1392
1394
  .apply()
1393
1395
  }
@@ -1664,8 +1666,15 @@ class G2 : SGCManager() {
1664
1666
  if (char == null || gatt == null) return
1665
1667
  char.value = packet
1666
1668
  char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
1667
- val ok = gatt.writeCharacteristic(char)
1668
- bgcapNoteWriteResult(ok) // BGCAP
1669
+ // A rejected write never entered Android's queue. Retry on this dedicated
1670
+ // BLE worker before later commands (especially a page replacement) overtake it.
1671
+ var ok = false
1672
+ for (attempt in 0 until 5) {
1673
+ ok = gatt.writeCharacteristic(char)
1674
+ bgcapNoteWriteResult(ok)
1675
+ if (ok) break
1676
+ if (attempt < 4) Thread.sleep(BLE_PACKET_GAP_MS)
1677
+ }
1669
1678
  if (leg != null) {
1670
1679
  Bridge.log("G2/FILE: write $leg ${packet.size}B -> ${if (ok) "queued" else "DROPPED (stack busy)"}")
1671
1680
  }
@@ -2190,7 +2199,7 @@ class G2 : SGCManager() {
2190
2199
  val rBorderColor = borderColor ?: defaultTextBorderColor
2191
2200
  val rBorderRadius = borderRadius ?: defaultTextBorderRadius
2192
2201
  val rPaddingLength = paddingLength ?: defaultTextPaddingLength
2193
- val content = if (text.isEmpty()) " " else text
2202
+ val content = G2Text.containerContent(text)
2194
2203
 
2195
2204
  // Pure state mutation: update the container's content and schedule its sends; the reconcile
2196
2205
  // loop does the actual updateText writes. Reuse an existing container if the rect matches
@@ -2257,6 +2266,12 @@ class G2 : SGCManager() {
2257
2266
  }
2258
2267
 
2259
2268
  override fun clearDisplay() {
2269
+ // Expo invokes this on its worker queue; all container mutations must
2270
+ // share Main with reconcileDisplay and scene updates.
2271
+ if (Looper.myLooper() != Looper.getMainLooper()) {
2272
+ mainHandler.post { clearDisplay() }
2273
+ return
2274
+ }
2260
2275
  Bridge.log("G2: clearDisplay()")
2261
2276
  // Clear the text in place — do NOT shut down + rebuild the EvenHub page. Tearing the page
2262
2277
  // down (rebuildPage) kills audio streaming AND triggers a firmware systemExit /
@@ -2266,9 +2281,22 @@ class G2 : SGCManager() {
2266
2281
  // several seconds (incident 8164175a). Just blank the text + clear images; the reconcile
2267
2282
  // loop pushes the blanked text on a live page, and a dead page is only resurrected for
2268
2283
  // meaningful (non-blank) content, so a clear can't churn it back up.
2269
- for (i in textContainers.indices) {
2270
- textContainers[i].content = "\n"
2271
- textContainers[i].pendingSends = 1 + EVEN_HUB_RESEND_COUNT
2284
+ for (container in textContainers) {
2285
+ // Erase the firmware's existing text BEFORE forgetting its container.
2286
+ // A queued reconcile cannot do this after the positioned container is removed.
2287
+ // Cover the previous byte range, rather than replacing only its first character.
2288
+ val blank = " ".repeat(maxOf(1, container.content.toByteArray(Charsets.UTF_8).size))
2289
+ container.content = blank
2290
+ container.pendingSends = 1 + EVEN_HUB_RESEND_COUNT
2291
+ if (pageCreated) {
2292
+ val erase = EvenHubProto.updateTextMessage(
2293
+ containerID = container.id,
2294
+ contentOffset = 0,
2295
+ contentLength = blank.length,
2296
+ content = blank
2297
+ )
2298
+ repeat(1 + EVEN_HUB_RESEND_COUNT) { sendEvenHubCommand(erase) }
2299
+ }
2272
2300
  }
2273
2301
  for (i in imageContainers.indices) {
2274
2302
  // The firmware still shows this container's image; emptying bmpData locally never reaches
@@ -2296,8 +2324,13 @@ class G2 : SGCManager() {
2296
2324
  textContainers.removeAll { it.id in huskIds }
2297
2325
  sceneTextByElement.entries.removeAll { it.value in huskIds }
2298
2326
  sceneImageByElement.clear()
2299
- Bridge.log("G2: clearDisplay() — purging ${huskIds.size} positioned husk container(s), one rebuild")
2300
- displayScope.launch { coalescedPageRebuild() }
2327
+ // Replace the live page now, while still on Main. A shutdown followed by
2328
+ // reconcile cannot clear this case: reconcile intentionally skips blank pages,
2329
+ // leaving the firmware's old positioned text visible until new speech arrives.
2330
+ // Queue the blank replacement before any following miniapp frame, without
2331
+ // tearing down the page (or its microphone session).
2332
+ Bridge.log("G2: clearDisplay() — replacing page after removing ${huskIds.size} positioned container(s)")
2333
+ if (pageCreated) createPageWithContainers()
2301
2334
  }
2302
2335
  }
2303
2336
 
@@ -2344,6 +2377,15 @@ class G2 : SGCManager() {
2344
2377
  return false
2345
2378
  }
2346
2379
 
2380
+ if (Looper.myLooper() != Looper.getMainLooper()) {
2381
+ mainHandler.post { applyBitmap(rx, ry, rw, rh, bmpData) }
2382
+ return true
2383
+ }
2384
+ return applyBitmap(rx, ry, rw, rh, bmpData)
2385
+ }
2386
+
2387
+ // Called only on Main, including the inline scene-frame path.
2388
+ private fun applyBitmap(rx: Int, ry: Int, rw: Int, rh: Int, bmpData: ByteArray): Boolean {
2347
2389
  // Pure state mutation: update the target container's bytes and mark it dirty. The reconcile
2348
2390
  // loop is the sole sender, so two displayBitmap calls can never overlap a sendImageData and
2349
2391
  // clobber the single-slot image ACK — no lock needed. Reuse an existing container if the rect
@@ -2538,7 +2580,7 @@ class G2 : SGCManager() {
2538
2580
  @Suppress("NAME_SHADOWING")
2539
2581
  val height = minOf(maxOf(height, minTextContainerHeight), 288 - y)
2540
2582
  run {
2541
- val content = if (text.isEmpty()) " " else text
2583
+ val content = G2Text.containerContent(text)
2542
2584
  val existingId = sceneTextByElement[elementId]
2543
2585
  if (existingId != null) {
2544
2586
  val idx = textContainers.indexOfFirst { it.id == existingId }
@@ -2999,15 +3041,15 @@ class G2 : SGCManager() {
2999
3041
  }
3000
3042
  // Only settle the flag if it's still empty — a displayBitmap during the await would
3001
3043
  // have set new bytes, so leave it dirty for the next pass to send the real image.
3002
- val jj = imageContainers.indexOfFirst { it.id == container.id }
3044
+ val jj = imageContainers.indexOfFirst { it === container }
3003
3045
  if (jj >= 0 && imageContainers[jj].bmpData.isEmpty()) {
3004
3046
  imageContainers[jj].dirty = false
3005
3047
  }
3006
3048
  continue
3007
3049
  }
3008
3050
  sendImageData(container.id, container.name, sentBytes)
3009
- // Re-find by id: the list may have shifted (eviction) during the await.
3010
- val j = imageContainers.indexOfFirst { it.id == container.id }
3051
+ // Re-find by identity: eviction can reuse this ID during the await.
3052
+ val j = imageContainers.indexOfFirst { it === container }
3011
3053
  if (j >= 0 && imageContainers[j].bmpData.contentEquals(sentBytes)) {
3012
3054
  imageContainers[j].dirty = false
3013
3055
  }
@@ -3677,8 +3719,17 @@ class G2 : SGCManager() {
3677
3719
  rightAuthenticated = false
3678
3720
  startupPageCreated = false
3679
3721
  pageCreated = false
3680
- imageContainers.clear()
3681
- textContainers.clear()
3722
+ // Keep GATT teardown synchronous for manager replacement, but never
3723
+ // remove containers underneath Main's reconcile/scene iteration.
3724
+ if (Looper.myLooper() == Looper.getMainLooper()) {
3725
+ imageContainers.clear()
3726
+ textContainers.clear()
3727
+ } else {
3728
+ mainHandler.post {
3729
+ imageContainers.clear()
3730
+ textContainers.clear()
3731
+ }
3732
+ }
3682
3733
  dashboardShowing = 0
3683
3734
  dashboardOpening = false
3684
3735
  heartbeatCounter = 0
@@ -141,6 +141,7 @@ class MentraLive : SGCManager() {
141
141
  private const val LC3_FRAME_SIZE = 40
142
142
  private const val VOICE_ACTIVITY_DETECTION_SWITCH_TYPE = 8
143
143
  private const val LOUDNESS_GATE_SWITCH_TYPE = 10
144
+ private const val AUTO_POWER_OFF_SWITCH_TYPE = 11
144
145
  // Mic tuning field names, matching the BES cs_mictun body.
145
146
  private val MIC_TUNING_FIELDS =
146
147
  listOf("gain", "open", "close", "attack", "hang", "sp_open", "sp_close", "sp_hold")
@@ -776,7 +777,8 @@ class MentraLive : SGCManager() {
776
777
  }
777
778
 
778
779
  // Inner class to track incoming file transfers
779
- private class FileTransferSession(var fileName: String, var fileSize: Int) {
780
+ internal class FileTransferSession(var fileName: String, var fileSize: Int, flags: Int = 0) {
781
+ private val dynamicPayload = (flags and 0x0002) != 0
780
782
  // NOTE: fileSize may be "fake" (inflated) due to BES firmware workaround
781
783
  var actualPackSize =
782
784
  0 // Actual pack size from first received packet (for BES lie detection)
@@ -805,9 +807,8 @@ class MentraLive : SGCManager() {
805
807
  * Recalculate total packets based on actual pack size from received packet. Called when
806
808
  * first packet is received to handle variable pack sizes.
807
809
  *
808
- * NOTE: Due to BES firmware workaround, fileSize in header may be "fake" (inflated). We
809
- * detect this by checking if fileSize is a multiple of 400 (BES_HARDCODED_PACK_SIZE). If
810
- * so, totalPackets = fileSize / 400, regardless of actual pack size.
810
+ * Dynamic-payload headers carry the true byte count. Only legacy small packs use
811
+ * the inflated-size workaround (multiples of BES_HARDCODED_PACK_SIZE).
811
812
  */
812
813
  fun recalculateTotalPackets(actualPackSize: Int) {
813
814
  if (actualPackSize <= 0 || actualPackSize > K900ProtocolUtils.FILE_PACK_SIZE) {
@@ -818,8 +819,8 @@ class MentraLive : SGCManager() {
818
819
 
819
820
  // Detect BES lie: if fileSize is exact multiple of 400, glasses used the lie strategy
820
821
  val isBesLie =
821
- (fileSize % BES_HARDCODED_PACK_SIZE == 0) &&
822
- (actualPackSize != BES_HARDCODED_PACK_SIZE)
822
+ !dynamicPayload && (fileSize % BES_HARDCODED_PACK_SIZE == 0) &&
823
+ (actualPackSize < BES_HARDCODED_PACK_SIZE)
823
824
 
824
825
  val newTotalPackets: Int
825
826
  if (isBesLie) {
@@ -857,6 +858,10 @@ class MentraLive : SGCManager() {
857
858
  }
858
859
 
859
860
  fun addPacket(index: Int, data: ByteArray): Boolean {
861
+ if (dynamicPayload && (index < 0 || index >= totalPackets || actualPackSize <= 0 ||
862
+ data.size != minOf(actualPackSize, fileSize - index * actualPackSize))) {
863
+ return false
864
+ }
860
865
  if (index >= 0 && index < totalPackets && !receivedPackets.containsKey(index)) {
861
866
  receivedPackets[index] = data
862
867
 
@@ -2147,7 +2152,7 @@ class MentraLive : SGCManager() {
2147
2152
  // than scanning).
2148
2153
  // Falls back to name-based scan if no address is saved.
2149
2154
  val lastDeviceAddress =
2150
- DeviceStore.get("bluetooth", "device_address") as String?
2155
+ selectedConnectionAddress()
2151
2156
  if (lastDeviceAddress != null &&
2152
2157
  !lastDeviceAddress.isEmpty() &&
2153
2158
  bluetoothAdapter != null
@@ -4137,7 +4142,9 @@ class MentraLive : SGCManager() {
4137
4142
  "controllerId" to json.opt("controllerId"), "streamId" to json.opt("streamId"),
4138
4143
  "probeId" to json.opt("probeId"))
4139
4144
  com.mentra.bluetoothsdk.streaming.StreamControllerProbe.response(values)?.let {
4140
- sendJson(JSONObject(it))
4145
+ // BES buffers non-waking commands when MTK enters standby, even while
4146
+ // its streaming CPU lease is held. The current probe needs a live reply.
4147
+ sendJson(JSONObject(it), true)
4141
4148
  }
4142
4149
  }
4143
4150
  "pong" ->
@@ -5548,7 +5555,9 @@ class MentraLive : SGCManager() {
5548
5555
  if (bodyObj != null) {
5549
5556
  val type = bodyObj.optInt("type", -1)
5550
5557
  val value = bodyObj.optInt("switch", -1)
5551
- handleSwitchStatus(type, value, System.currentTimeMillis())
5558
+ // K900 replies carry result in "S"; 0 is RC_SUCCESS.
5559
+ val resultCode = json.optInt("S", -1)
5560
+ handleSwitchStatus(type, value, System.currentTimeMillis(), resultCode)
5552
5561
  }
5553
5562
  } catch (e: Exception) {
5554
5563
  Log.e(TAG, "Error parsing sr_swit response", e)
@@ -5578,7 +5587,13 @@ class MentraLive : SGCManager() {
5578
5587
  try {
5579
5588
  val bodyObj = optK900Body(json)
5580
5589
  if (bodyObj != null) {
5581
- Bridge.sendWearState(bodyObj.optInt("on", 0) != 0)
5590
+ val extras = HashMap<String, Any>()
5591
+ if (bodyObj.has("elapsed_ms")) extras["elapsedMs"] = bodyObj.optInt("elapsed_ms")
5592
+ if (bodyObj.has("timeout_ms")) extras["timeoutMs"] = bodyObj.optInt("timeout_ms")
5593
+ if (bodyObj.has("enabled")) extras["enabled"] = bodyObj.optInt("enabled") != 0
5594
+ if (bodyObj.has("armed")) extras["armed"] = bodyObj.optInt("armed") != 0
5595
+ if (bodyObj.has("inhibited")) extras["inhibited"] = bodyObj.optInt("inhibited") != 0
5596
+ Bridge.sendWearState(bodyObj.optInt("on", 0) != 0, extras)
5582
5597
  }
5583
5598
  } catch (e: Exception) {
5584
5599
  Log.e(TAG, "Error parsing sr_wrst response", e)
@@ -5933,8 +5948,20 @@ class MentraLive : SGCManager() {
5933
5948
  Bridge.sendWearTuningState(state)
5934
5949
  }
5935
5950
 
5936
- private fun handleSwitchStatus(switchType: Int, switchValue: Int, timestamp: Long) {
5951
+ private fun handleSwitchStatus(
5952
+ switchType: Int,
5953
+ switchValue: Int,
5954
+ timestamp: Long,
5955
+ resultCode: Int = -1
5956
+ ) {
5937
5957
  Bridge.sendSwitchStatus(switchType, switchValue, timestamp)
5958
+ if (switchType == AUTO_POWER_OFF_SWITCH_TYPE) {
5959
+ val ok = resultCode == 0
5960
+ Bridge.log(
5961
+ "LIVE: 🔋 auto power-off sr_swit reply type=$switchType switch=$switchValue" +
5962
+ " result=$resultCode ok=$ok"
5963
+ )
5964
+ }
5938
5965
  if (switchType == VOICE_ACTIVITY_DETECTION_SWITCH_TYPE &&
5939
5966
  (switchValue == 0 || switchValue == 1)
5940
5967
  ) {
@@ -6140,7 +6167,8 @@ class MentraLive : SGCManager() {
6140
6167
  val json = JSONObject()
6141
6168
  json.put("type", "request_version")
6142
6169
  requestId?.let { json.put("request_id", it) }
6143
- sendJson(json, false)
6170
+ // Wake ASG so the version request and its response can finish after idle.
6171
+ sendJson(json, true)
6144
6172
  Bridge.log("LIVE: 📱 Requesting version info from glasses")
6145
6173
  } catch (e: JSONException) {
6146
6174
  Log.e(TAG, "📱 Error creating request_version command", e)
@@ -6602,6 +6630,14 @@ class MentraLive : SGCManager() {
6602
6630
  return false
6603
6631
  }
6604
6632
 
6633
+ private fun selectedConnectionAddress(): String? = SelectedDeviceAddress.resolve(
6634
+ savedDeviceName,
6635
+ DeviceStore.get("bluetooth", "pending_device_name") as? String,
6636
+ DeviceStore.get("bluetooth", "pending_device_address") as? String,
6637
+ DeviceStore.get("bluetooth", "device_name") as? String,
6638
+ DeviceStore.get("bluetooth", "device_address") as? String,
6639
+ )
6640
+
6605
6641
  fun connectToSmartGlasses() {
6606
6642
  if (postGattLifecycle { connectToSmartGlasses() }) return
6607
6643
  if (pairingYieldActive) {
@@ -6636,13 +6672,7 @@ class MentraLive : SGCManager() {
6636
6672
  // var context = Bridge.getContext();
6637
6673
  // SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
6638
6674
  // String lastDeviceAddress = prefs.getString(PREF_DEVICE_NAME, null);
6639
- val pendingAddress =
6640
- (DeviceStore.get("bluetooth", "pending_device_address") as String?)?.takeIf {
6641
- it.isNotEmpty()
6642
- }
6643
- val lastDeviceAddress =
6644
- pendingAddress
6645
- ?: (DeviceStore.get("bluetooth", "device_address") as String?)
6675
+ val lastDeviceAddress = selectedConnectionAddress()
6646
6676
 
6647
6677
  if (lastDeviceAddress != null && lastDeviceAddress.length > 0) {
6648
6678
  // Connect to last known device if available
@@ -10193,7 +10223,7 @@ class MentraLive : SGCManager() {
10193
10223
  if (incidentRelay.session == null) {
10194
10224
  activeFileTransfers.remove(packetInfo.fileName)
10195
10225
  incidentRelay.session =
10196
- FileTransferSession(packetInfo.fileName, packetInfo.fileSize)
10226
+ FileTransferSession(packetInfo.fileName, packetInfo.fileSize, packetInfo.flags)
10197
10227
  incidentRelay.session!!.recalculateTotalPackets(packetInfo.packSize)
10198
10228
  Bridge.log(
10199
10229
  "LIVE: 📦 Started BLE incident log transfer: " +
@@ -10244,7 +10274,7 @@ class MentraLive : SGCManager() {
10244
10274
  // Get or create session for this transfer
10245
10275
  if (photoTransfer.session == null) {
10246
10276
  photoTransfer.session =
10247
- FileTransferSession(packetInfo.fileName, packetInfo.fileSize)
10277
+ FileTransferSession(packetInfo.fileName, packetInfo.fileSize, packetInfo.flags)
10248
10278
  // Recalculate total packets based on actual pack size (handles variable MTU)
10249
10279
  photoTransfer.session!!.recalculateTotalPackets(packetInfo.packSize)
10250
10280
  Bridge.log(
@@ -10330,7 +10360,7 @@ class MentraLive : SGCManager() {
10330
10360
  var session = activeFileTransfers[packetInfo.fileName]
10331
10361
  if (session == null) {
10332
10362
  // New file transfer
10333
- session = FileTransferSession(packetInfo.fileName, packetInfo.fileSize)
10363
+ session = FileTransferSession(packetInfo.fileName, packetInfo.fileSize, packetInfo.flags)
10334
10364
  // Recalculate total packets based on actual pack size (handles variable MTU)
10335
10365
  session.recalculateTotalPackets(packetInfo.packSize)
10336
10366
  activeFileTransfers[packetInfo.fileName] = session
@@ -10825,6 +10855,9 @@ class MentraLive : SGCManager() {
10825
10855
  // Send glasses-side loudness / Barrier gate setting.
10826
10856
  sendLoudnessGateSetting()
10827
10857
 
10858
+ // Send glasses-side auto power-off setting.
10859
+ sendAutoPowerOffSetting()
10860
+
10828
10861
  // Send mic tuning. With nothing authorized this sends a reset, which is
10829
10862
  // what returns a freshly connected pair of glasses to stock behaviour.
10830
10863
  sendMicTuningSetting()
@@ -11108,6 +11141,52 @@ class MentraLive : SGCManager() {
11108
11141
  }
11109
11142
  }
11110
11143
 
11144
+ override fun sendAutoPowerOffSetting() {
11145
+ val value = DeviceStore.get("bluetooth", "auto_power_off_enabled")
11146
+ val enabled =
11147
+ if (value is Boolean) value
11148
+ else BluetoothSdkDefaults.AUTO_POWER_OFF_ENABLED
11149
+
11150
+ Bridge.log(
11151
+ "LIVE: 🔋 Sending auto power-off setting to glasses: enabled=$enabled" +
11152
+ " (cs_swit type=$AUTO_POWER_OFF_SWITCH_TYPE)"
11153
+ )
11154
+
11155
+ if (!isConnected) {
11156
+ Bridge.log("LIVE: Cannot send auto power-off setting - not connected")
11157
+ return
11158
+ }
11159
+
11160
+ try {
11161
+ val body = JSONObject()
11162
+ body.put("type", AUTO_POWER_OFF_SWITCH_TYPE)
11163
+ body.put("switch", if (enabled) 1 else 0)
11164
+
11165
+ val cmdObject = JSONObject()
11166
+ cmdObject.put("C", "cs_swit")
11167
+ cmdObject.put("V", 1)
11168
+ cmdObject.put("B", body.toString())
11169
+
11170
+ val packedData =
11171
+ K900ProtocolUtils.packDataToK900(
11172
+ cmdObject.toString().toByteArray(StandardCharsets.UTF_8),
11173
+ K900ProtocolUtils.CMD_TYPE_STRING,
11174
+ k900LengthEndian()
11175
+ )
11176
+ if (packedData == null) {
11177
+ Bridge.log("LIVE: Failed to pack auto power-off setting command")
11178
+ return
11179
+ }
11180
+ queueData(packedData)
11181
+ Bridge.log(
11182
+ "LIVE: 🔋 Queued auto power-off cs_swit type=$AUTO_POWER_OFF_SWITCH_TYPE" +
11183
+ " switch=${if (enabled) 1 else 0}"
11184
+ )
11185
+ } catch (e: JSONException) {
11186
+ Log.e(TAG, "Error creating auto power-off setting command", e)
11187
+ }
11188
+ }
11189
+
11111
11190
  /** Send button photo settings to glasses, replaying all stored scan-tuning fields. */
11112
11191
  override fun sendButtonPhotoSettings() {
11113
11192
  val legacyZslMfnr =
@@ -208,7 +208,7 @@ class MentraNex : SGCManager() {
208
208
 
209
209
  // If we already have saved device names for main...
210
210
  if (name != null && preferredMainDeviceId != null) {
211
- if (!name.contains(preferredMainDeviceId!!)) {
211
+ if (name != preferredMainDeviceId) {
212
212
  return // Not a matching device
213
213
  }
214
214
  }
@@ -230,7 +230,6 @@ class MentraNex : SGCManager() {
230
230
  type = DeviceTypes.NEX
231
231
  hasMic = true
232
232
  DeviceStore.apply("glasses", "micEnabled", false)
233
- preferredMainDeviceId = DeviceManager.getInstance().deviceName
234
233
 
235
234
  // Initialize LC3 audio player
236
235
  lc3AudioPlayer = Lc3Player(context)
@@ -1315,23 +1314,28 @@ class MentraNex : SGCManager() {
1315
1314
  }
1316
1315
 
1317
1316
  private fun connectToSmartGlasses() {
1318
- val deviceModelName = DeviceManager.getInstance().deviceName
1319
- val deviceAddress = DeviceManager.getInstance().deviceAddress
1317
+ val target = preferredMainDeviceId?.takeIf { it.isNotBlank() } ?: return
1318
+ val deviceAddress = SelectedDeviceAddress.resolve(
1319
+ target,
1320
+ DeviceStore.get("bluetooth", "pending_device_name") as? String,
1321
+ DeviceStore.get("bluetooth", "pending_device_address") as? String,
1322
+ DeviceStore.get("bluetooth", "device_name") as? String,
1323
+ DeviceStore.get("bluetooth", "device_address") as? String,
1324
+ )
1320
1325
 
1321
1326
  // Register bonding receiver
1322
1327
  Bridge.log("connectToSmartGlasses start")
1323
- Bridge.log("try to ConnectToSmartGlassesing deviceModelName: ${deviceModelName} deviceAddress: ${deviceAddress}")
1324
- preferredMainDeviceId = DeviceManager.getInstance().deviceName
1328
+ Bridge.log("Connecting to selected Nex: $target address: $deviceAddress")
1325
1329
  if (!bluetoothAdapter.isEnabled) {
1326
1330
  return
1327
1331
  }
1328
1332
  when {
1329
- !deviceModelName.isNullOrEmpty() && !deviceAddress.isNullOrEmpty() -> {
1333
+ deviceAddress != null -> {
1330
1334
  stopScan()
1331
1335
  mainDevice = bluetoothAdapter.getRemoteDevice(deviceAddress)
1332
1336
  mainTaskHandler?.sendEmptyMessageDelayed(MAIN_TASK_HANDLER_CODE_RECONNECT_DEVICE, 0)
1333
1337
  }
1334
- savedNexMainAddress != null -> {
1338
+ savedNexMainName == target && savedNexMainAddress != null -> {
1335
1339
  mainDevice = bluetoothAdapter.getRemoteDevice(savedNexMainAddress)
1336
1340
  mainTaskHandler?.sendEmptyMessageDelayed(MAIN_TASK_HANDLER_CODE_RECONNECT_DEVICE, 0)
1337
1341
  }