@mentra/bluetooth-sdk 0.1.20 → 0.1.21-beta.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 (114) hide show
  1. package/README.md +27 -12
  2. package/android/build.gradle +3 -0
  3. package/android/src/main/AndroidManifest.xml +3 -1
  4. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalytics.kt +34 -0
  5. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +1 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +169 -26
  7. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +72 -16
  8. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +127 -8
  9. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +4 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +400 -99
  11. package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +333 -0
  12. package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +43 -3
  13. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +18 -2
  14. package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleTraceLogger.kt +1 -1
  15. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +53 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Ar99.java +2653 -0
  17. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +1 -1
  18. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +16 -9
  19. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Mach1.kt +1 -1
  20. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +342 -42
  21. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +1 -1
  22. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +1 -1
  23. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +7 -2
  24. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +1 -1
  25. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/Ar99OtaManager.java +566 -0
  26. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaByteUtils.java +18 -0
  27. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaCommandConstants.java +35 -0
  28. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaCrc32Util.java +13 -0
  29. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaGattTransport.java +11 -0
  30. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaProtocol.java +92 -0
  31. package/android/src/main/java/com/mentra/bluetoothsdk/status/WifiHotspotStatus.kt +19 -3
  32. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +52 -2
  33. package/android/src/main/java/com/mentra/bluetoothsdk/tts/TTSTools.kt +115 -47
  34. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +7 -0
  35. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +8 -0
  36. package/android/src/main/java/com/mentra/bluetoothsdk/utils/Constants.kt +2 -1
  37. package/android/src/main/java/com/mentra/bluetoothsdk/utils/PhoneAudioMonitor.kt +45 -0
  38. package/android/src/main/java/com/mentra/bluetoothsdk/utils/audio/Ar99OpusPcmDecoder.java +398 -0
  39. package/android/src/main/java/com/mentra/bluetoothsdk/utils/audio/PcmStreamPlayer.kt +328 -0
  40. package/android/src/test/java/com/mentra/bluetoothsdk/OtaManifestDowngradeTest.kt +86 -0
  41. package/android/src/test/java/com/mentra/bluetoothsdk/StreamStatusEventTest.kt +29 -0
  42. package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +45 -0
  43. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/Ar99AdvertisementMatcherTest.java +22 -0
  44. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +20 -0
  45. package/android/src/test/java/com/mentra/bluetoothsdk/utils/PhoneAudioMonitorTest.kt +45 -0
  46. package/build/BluetoothSdk.types.d.ts +100 -22
  47. package/build/BluetoothSdk.types.d.ts.map +1 -1
  48. package/build/BluetoothSdk.types.js +2 -1
  49. package/build/BluetoothSdk.types.js.map +1 -1
  50. package/build/_internal.d.ts +3 -0
  51. package/build/_internal.d.ts.map +1 -1
  52. package/build/_internal.js +3 -0
  53. package/build/_internal.js.map +1 -1
  54. package/build/_private/BluetoothSdkModule.d.ts +31 -4
  55. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  56. package/build/_private/BluetoothSdkModule.js +26 -3
  57. package/build/_private/BluetoothSdkModule.js.map +1 -1
  58. package/build/_private/MentraLocalNetworkModule.d.ts +37 -0
  59. package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -0
  60. package/build/_private/MentraLocalNetworkModule.js +3 -0
  61. package/build/_private/MentraLocalNetworkModule.js.map +1 -0
  62. package/build/_private/cameraRequestPayload.d.ts +1 -1
  63. package/build/_private/cameraRequestPayload.d.ts.map +1 -1
  64. package/build/_private/cameraRequestPayload.js +7 -0
  65. package/build/_private/cameraRequestPayload.js.map +1 -1
  66. package/build/_private/photoRequestPayload.d.ts.map +1 -1
  67. package/build/_private/photoRequestPayload.js +10 -0
  68. package/build/_private/photoRequestPayload.js.map +1 -1
  69. package/build/_private/sdkOtaManifest.d.ts +20 -0
  70. package/build/_private/sdkOtaManifest.d.ts.map +1 -0
  71. package/build/_private/sdkOtaManifest.js +28 -0
  72. package/build/_private/sdkOtaManifest.js.map +1 -0
  73. package/build/index.d.ts +1 -1
  74. package/build/index.d.ts.map +1 -1
  75. package/build/index.js +11 -1
  76. package/build/index.js.map +1 -1
  77. package/expo-module.config.json +1 -0
  78. package/ios/BluetoothSdkModule.swift +113 -2
  79. package/ios/Source/BluetoothSdkDefaults.swift +2 -1
  80. package/ios/Source/Bridge.swift +87 -21
  81. package/ios/Source/DeviceManager.swift +118 -20
  82. package/ios/Source/DeviceStore.swift +4 -0
  83. package/ios/Source/MentraBluetoothSDK.swift +326 -33
  84. package/ios/Source/OtaManifest.swift +47 -4
  85. package/ios/Source/audio/PcmStreamPlayer.swift +410 -0
  86. package/ios/Source/camera/CameraModels.swift +26 -6
  87. package/ios/Source/internal/BleTraceLogger.swift +1 -1
  88. package/ios/Source/internal/BluetoothSdkAnalytics.swift +39 -0
  89. package/ios/Source/sgcs/Ar99.swift +2451 -0
  90. package/ios/Source/sgcs/G1.swift +1 -1
  91. package/ios/Source/sgcs/G2.swift +19 -7
  92. package/ios/Source/sgcs/Mach1.swift +1 -1
  93. package/ios/Source/sgcs/MentraLive.swift +478 -61
  94. package/ios/Source/sgcs/MentraNex.swift +1 -1
  95. package/ios/Source/sgcs/Nimo.swift +1 -1
  96. package/ios/Source/sgcs/SGCManager.swift +13 -2
  97. package/ios/Source/sgcs/Simulated.swift +1 -1
  98. package/ios/Source/sgcs/ar99/ota/Ar99OtaManager.swift +637 -0
  99. package/ios/Source/status/WifiHotspotStatus.swift +21 -2
  100. package/ios/Source/streaming/StreamModels.swift +68 -2
  101. package/ios/Source/tts/TTSTools.swift +1 -1
  102. package/ios/Source/types/DeviceModels.swift +13 -0
  103. package/ios/Source/utils/BleJsonCompact.swift +8 -0
  104. package/ios/Source/utils/Constants.swift +2 -0
  105. package/ios/Source/utils/PhoneAudioMonitor.swift +4 -0
  106. package/package.json +1 -2
  107. package/src/BluetoothSdk.types.ts +106 -24
  108. package/src/_internal.ts +3 -0
  109. package/src/_private/BluetoothSdkModule.ts +83 -18
  110. package/src/_private/MentraLocalNetworkModule.ts +50 -0
  111. package/src/_private/cameraRequestPayload.ts +9 -2
  112. package/src/_private/photoRequestPayload.ts +13 -4
  113. package/src/_private/sdkOtaManifest.ts +31 -0
  114. package/src/index.ts +13 -1
@@ -34,6 +34,7 @@ import com.mentra.bluetoothsdk.Bridge
34
34
  import com.mentra.bluetoothsdk.DeviceManager
35
35
  import com.mentra.bluetoothsdk.PhotoRequest
36
36
  import com.mentra.bluetoothsdk.PhotoSize
37
+ import com.mentra.bluetoothsdk.PhotoMode
37
38
  import com.mentra.bluetoothsdk.DeviceStore
38
39
  import com.mentra.bluetoothsdk.ObservableStore
39
40
  import com.mentra.bluetoothsdk.debug.BleTraceLogger
@@ -94,6 +95,7 @@ class MentraLive : SGCManager() {
94
95
  // LC3 frame size for Mentra Live
95
96
  private const val LC3_FRAME_SIZE = 40
96
97
  private const val VOICE_ACTIVITY_DETECTION_SWITCH_TYPE = 8
98
+ private const val LOUDNESS_GATE_SWITCH_TYPE = 10
97
99
  private const val BES2700_MTU_LIMIT = 509
98
100
 
99
101
  // L2CAP CoC fast path: new BES2700 firmware registers an LE L2CAP CoC server on this
@@ -146,6 +148,10 @@ class MentraLive : SGCManager() {
146
148
 
147
149
  // Heartbeat parameters
148
150
  private const val HEARTBEAT_INTERVAL_MS = 30000 // 30 seconds
151
+ // Grace period before an unanswered BLE wire v2 handshake is re-armed for retry,
152
+ // and the per-session cap on automatic retries (see sendWireHandshake).
153
+ private const val WIRE_HANDSHAKE_RETRY_GRACE_MS = 5000L
154
+ private const val WIRE_HANDSHAKE_MAX_ATTEMPTS = 3
149
155
  private const val BATTERY_REQUEST_EVERY_N_HEARTBEATS = 10 // Every 10 heartbeats (5 minutes)
150
156
  private const val RSSI_READ_INTERVAL_MS = 10000L // 10 seconds
151
157
 
@@ -213,12 +219,30 @@ class MentraLive : SGCManager() {
213
219
  private var peerWireProtocolVersion = 0
214
220
  private var useBinaryWireProtocol = false
215
221
  private var wireHandshakeQueued = false
222
+ private var wireHandshakeAttempts = 0
223
+ // Session generation in which the LAST outgoing v2 handshake was sent. A handshake
224
+ // reply only activates v2 when it answers a handshake from the CURRENT epoch - a
225
+ // stale handshake that survived in flight across an epoch reset must not flip the
226
+ // new session to v2 before it negotiated its own build and capabilities.
227
+ private var wireHandshakeSentGeneration = -1
228
+ // Bumped on every BLE session reset; scheduled handshake-retry callbacks capture it
229
+ // and no-op if the session changed, so a timer from a dead session can't poke a new one.
230
+ private var wireSessionGeneration = 0
216
231
  // Negotiated K900 STRING length endianness for the phone<->glasses BLE link. Defaults to
217
232
  // legacy big-endian; upgraded to little-endian only when the glasses advertise wire_caps.k900_le
218
233
  // (or a v2 binary handshake succeeds, which implies wire-v2 LE).
219
234
  private var peerK900Le = false
220
235
  private var peerWireCapsBinary = false
221
236
  private var peerFilePayloadV2 = false
237
+ // Last observed glasses process session id (`sid` in glasses_ready / version_info_1).
238
+ // The BES keeps the BLE link alive across asg_client restarts, so transport state
239
+ // cannot signal a restart - a CHANGED (or newly appearing) sid is the restart signal.
240
+ // Null = no sid observed this BLE session (legacy glasses, or none seen yet).
241
+ private var glassesSessionId: String? = null
242
+ // True once a glasses_ready completed on THIS physical BLE session; resets only with
243
+ // the physical connection (never on heartbeat readiness flaps), so a first-seen sid
244
+ // after an upgrade OTA is always detected as a restart.
245
+ private var readinessCompletedThisBleSession = false
222
246
  // Note: appVersion, buildNumber, deviceModel, androidVersion
223
247
  // are inherited from SGCManager parent class
224
248
 
@@ -313,6 +337,8 @@ class MentraLive : SGCManager() {
313
337
  // So we temporarily suspend the LC3 mic during phone audio playback
314
338
  private var micIntentEnabled = false // User/system WANTS mic enabled
315
339
  private var micSuspendedForAudio = false // Mic temporarily suspended due to phone audio
340
+ override val isMicSuspendedForAudio: Boolean
341
+ get() = micSuspendedForAudio
316
342
  private var phoneAudioMonitor: PhoneAudioMonitor? = null
317
343
  private var micOnCount = 0
318
344
  private var micOffCount = 0
@@ -780,18 +806,25 @@ class MentraLive : SGCManager() {
780
806
  val isEqual = state == connectionState
781
807
  if (isEqual) {
782
808
  if (state == ConnTypes.DISCONNECTED) {
783
- incomingChunkReassembler.clear()
784
- peerWireProtocolVersion = 0
785
- useBinaryWireProtocol = false
786
- wireHandshakeQueued = false
787
- peerK900Le = false
788
- peerWireCapsBinary = false
789
- peerFilePayloadV2 = false
790
- BleJsonCompact.resetSession()
809
+ resetWireNegotiationState()
810
+ // Queued writes are session-bound: transmitting them into the NEXT session
811
+ // (e.g. a stale handshake whose reply activates v2 before the new session
812
+ // negotiated) is the bug class this clear removes. Higher layers re-send
813
+ // what still matters via their own ACK/retry tracking.
814
+ sendQueue.clear()
791
815
  }
792
816
  return
793
817
  }
794
818
 
819
+ if (state == ConnTypes.DISCONNECTED) {
820
+ // A manufacturing serial is session-bound. Clear it on disconnect so a previous
821
+ // pair's serial can never be associated with the next connection. Connect must NOT
822
+ // clear it: DeviceManager.disconnect already wipes it before any new connection, and
823
+ // clearing on CONNECTED would wipe a still-valid serial mid-session when a same-link
824
+ // glasses_ready (e.g. ASG restart) re-publishes CONNECTED.
825
+ DeviceStore.apply("glasses", "serialNumber", "")
826
+ }
827
+
795
828
  // Actually update the connection state!
796
829
  DeviceStore.apply("glasses", "connectionState", state)
797
830
 
@@ -807,6 +840,10 @@ class MentraLive : SGCManager() {
807
840
  DeviceStore.apply("glasses", "appVersion", "")
808
841
  DeviceStore.apply("glasses", "besFirmwareVersion", "")
809
842
  DeviceStore.apply("glasses", "mtkFirmwareVersion", "")
843
+ // Modern ASG builds omit ota_version_url entirely (the phone owns manifest
844
+ // selection), and the chunked parser only writes present fields — clear it here so
845
+ // a URL reported by a previous build cannot leak into this session.
846
+ DeviceStore.apply("glasses", "otaVersionUrl", "")
810
847
  Bridge.log("LIVE: Cleared cached version_info fields for fresh session")
811
848
  }
812
849
 
@@ -815,14 +852,9 @@ class MentraLive : SGCManager() {
815
852
  DeviceStore.apply("glasses", "connected", false)
816
853
  DeviceStore.apply("glasses", "signalStrength", -1)
817
854
  DeviceStore.apply("glasses", "signalStrengthUpdatedAt", 0L)
818
- incomingChunkReassembler.clear()
819
- peerWireProtocolVersion = 0
820
- useBinaryWireProtocol = false
821
- wireHandshakeQueued = false
822
- peerK900Le = false
823
- peerWireCapsBinary = false
824
- peerFilePayloadV2 = false
825
- BleJsonCompact.resetSession()
855
+ resetWireNegotiationState()
856
+ sendQueue.clear() // see the disconnect reset above: stale writes die with the session
857
+
826
858
  // Drop OTA caches when fully disconnected — avoids leaking session/step state
827
859
  // from a previous pairing into the next one.
828
860
  resetOtaCache()
@@ -1552,6 +1584,8 @@ class MentraLive : SGCManager() {
1552
1584
 
1553
1585
  connectedDevice = null
1554
1586
  glassesReady = false // Reset ready state on disconnect
1587
+ glassesSessionId = null // Fresh BLE session starts with no sid known
1588
+ readinessCompletedThisBleSession = false
1555
1589
 
1556
1590
  // Reset audio pairing flags
1557
1591
  glassesReadyReceived = false
@@ -1616,6 +1650,8 @@ class MentraLive : SGCManager() {
1616
1650
  isConnected = false
1617
1651
  isConnecting = false
1618
1652
  glassesReady = false
1653
+ glassesSessionId = null
1654
+ readinessCompletedThisBleSession = false
1619
1655
  glassesReadyReceived = false
1620
1656
  audioConnected = false
1621
1657
 
@@ -3136,7 +3172,12 @@ class MentraLive : SGCManager() {
3136
3172
  DeviceStore.apply("glasses", "wifiError", "")
3137
3173
  }
3138
3174
 
3139
- updateWifiStatus(wifiConnectedStatus, ssid, localIp)
3175
+ updateWifiStatus(
3176
+ wifiConnectedStatus,
3177
+ ssid,
3178
+ localIp,
3179
+ wifiError.takeIf { it.isNotEmpty() }
3180
+ )
3140
3181
  }
3141
3182
  "hotspot_status_update" -> {
3142
3183
  // Process hotspot status information (same pattern as "wifi_status")
@@ -3239,7 +3280,8 @@ class MentraLive : SGCManager() {
3239
3280
 
3240
3281
  val scanComplete =
3241
3282
  json.optBoolean("scan_complete", json.optBoolean("scanComplete", false))
3242
- Bridge.updateWifiScanResults(networks, scanComplete)
3283
+ val scanId = json.optString("scanId", "").ifEmpty { null }
3284
+ Bridge.updateWifiScanResults(networks, scanComplete, scanId)
3243
3285
  }
3244
3286
  "token_status" -> {
3245
3287
  // Process coreToken acknowledgment
@@ -3492,8 +3534,19 @@ class MentraLive : SGCManager() {
3492
3534
  // Glasses SOC has booted and is ready for communication
3493
3535
  Bridge.log("LIVE: 🎉 Received glasses_ready message - SOC is booted and ready!")
3494
3536
 
3495
- // Negotiate wire capabilities advertised in the glasses_ready handshake.
3537
+ // glasses_ready is a REMOTE wire-session reset: the glasses ran
3538
+ // onTransportReset() before sending it, so their side is back on legacy
3539
+ // framing regardless of what this side negotiated earlier. Start a fresh
3540
+ // wire epoch (clears v2-active state that would otherwise gate the
3541
+ // handshake off), then negotiate from the caps this message advertises.
3542
+ resetWireNegotiationState()
3496
3543
  parsePeerWireCaps(json)
3544
+ maybeSendWireHandshake()
3545
+ // Record the session id BEFORE marking ready: an unsolicited glasses_ready
3546
+ // already runs this full remote-reset flow, so recording (not re-triggering)
3547
+ // is correct here; version_info detection covers the restart case.
3548
+ json.optString("sid", "").takeIf { it.isNotEmpty() }?.let { glassesSessionId = it }
3549
+ readinessCompletedThisBleSession = true
3497
3550
 
3498
3551
  // Set the ready flag to stop any future readiness checks
3499
3552
  glassesReady = true
@@ -3653,7 +3706,7 @@ class MentraLive : SGCManager() {
3653
3706
 
3654
3707
  "version_info" -> {
3655
3708
  // Process version information from ASG client (legacy single-message format)
3656
- Bridge.log("LIVE: Received version info from ASG client: " + json.toString())
3709
+ Bridge.log("LIVE: Received version info from ASG client")
3657
3710
 
3658
3711
  // Extract version information
3659
3712
  val appVersionLegacy = json.optString("app_version", "")
@@ -3663,6 +3716,7 @@ class MentraLive : SGCManager() {
3663
3716
  val otaVersionUrlLegacy: String? = json.optString("ota_version_url", null)
3664
3717
  val firmwareVersionLegacy = json.optString("firmware_version", "")
3665
3718
  val btMacAddressLegacy = json.optString("bt_mac_address", "")
3719
+ val serialNumberLegacy = json.optString("serial_number", "")
3666
3720
 
3667
3721
  // Update parent SGCManager fields
3668
3722
  DeviceStore.apply("glasses", "appVersion", appVersionLegacy)
@@ -3676,6 +3730,9 @@ class MentraLive : SGCManager() {
3676
3730
  )
3677
3731
  DeviceStore.apply("glasses", "firmwareVersion", firmwareVersionLegacy)
3678
3732
  DeviceStore.apply("glasses", "bluetoothMacAddress", btMacAddressLegacy)
3733
+ if (serialNumberLegacy.isNotBlank()) {
3734
+ DeviceStore.apply("glasses", "serialNumber", serialNumberLegacy)
3735
+ }
3679
3736
 
3680
3737
  val versionInfoLegacy = HashMap<String, Any>()
3681
3738
  versionInfoLegacy["appVersion"] = appVersionLegacy
@@ -3857,7 +3914,7 @@ class MentraLive : SGCManager() {
3857
3914
  else -> {
3858
3915
  // Flexible version_info parsing - handle any version_info* message
3859
3916
  if (type.startsWith("version_info")) {
3860
- Bridge.log("LIVE: Received " + type + ": " + json.toString())
3917
+ Bridge.log("LIVE: Received " + type)
3861
3918
 
3862
3919
  // Extract all fields from JSON (except "type")
3863
3920
  val fields = HashMap<String, Any>()
@@ -3884,8 +3941,6 @@ class MentraLive : SGCManager() {
3884
3941
  val buildNumInt = Integer.parseInt(buildNum)
3885
3942
  buildNumberInt = buildNumInt
3886
3943
  Bridge.log("LIVE: Parsed build number as integer: " + buildNumInt)
3887
- parsePeerWireCaps(json)
3888
- maybeSendWireHandshake()
3889
3944
  } catch (e: NumberFormatException) {
3890
3945
  buildNumberInt = 0
3891
3946
  Log.e(TAG, "Failed to parse build number as integer: " + buildNum)
@@ -3946,6 +4001,10 @@ class MentraLive : SGCManager() {
3946
4001
  fields["bt_mac_address"] as String
3947
4002
  )
3948
4003
  }
4004
+ val serialNumber = fields["serial_number"] as? String
4005
+ if (!serialNumber.isNullOrBlank()) {
4006
+ DeviceStore.apply("glasses", "serialNumber", serialNumber)
4007
+ }
3949
4008
  if (fields.containsKey("system_time_ms")) {
3950
4009
  val v = fields["system_time_ms"]
3951
4010
  if (v is Number) {
@@ -3953,6 +4012,17 @@ class MentraLive : SGCManager() {
3953
4012
  }
3954
4013
  }
3955
4014
 
4015
+ // Negotiate wire caps from ANY version_info chunk, not only the one
4016
+ // carrying build_number: the glasses put wire_caps in version_info_3
4017
+ // (firmware chunk) while build_number travels in version_info_1, so
4018
+ // gating negotiation on build_number silently discards the caps.
4019
+ // parsePeerWireCaps no-ops without a wire_caps key and
4020
+ // maybeSendWireHandshake gates on caps + build + not-yet-queued, so
4021
+ // running them per chunk is safe.
4022
+ parsePeerWireCaps(json)
4023
+ maybeSendWireHandshake()
4024
+ handleGlassesSessionId(json)
4025
+
3956
4026
  Bridge.log("LIVE: Processed version_info fields and sent to RN")
3957
4027
  Bridge.sendVersionInfo(fields)
3958
4028
  } else {
@@ -4303,15 +4373,7 @@ class MentraLive : SGCManager() {
4303
4373
  // The glassesReady flag is reset on disconnect/reconnect, so this won't
4304
4374
  // prevent proper reconnection
4305
4375
  if (!glassesReady) {
4306
- Bridge.log(
4307
- "LIVE: 📱 Sending phone_ready to glasses - waiting for glasses_ready response"
4308
- )
4309
- val readyMsg = JSONObject()
4310
- readyMsg.put("type", "phone_ready")
4311
- readyMsg.put("timestamp", System.currentTimeMillis())
4312
-
4313
- // Send it through our data channel
4314
- sendJson(readyMsg, true)
4376
+ sendPhoneReady("SOC ready")
4315
4377
  } else {
4316
4378
  Bridge.log(
4317
4379
  "LIVE: ✅ Glasses already marked as ready, skipping phone_ready"
@@ -4392,6 +4454,8 @@ class MentraLive : SGCManager() {
4392
4454
  updateConnectionState(ConnTypes.DISCONNECTED)
4393
4455
  glassesReady = false
4394
4456
  glassesReadyReceived = false
4457
+ glassesSessionId = null
4458
+ readinessCompletedThisBleSession = false
4395
4459
  }
4396
4460
  "sr_adota" -> {
4397
4461
  // BES chip OTA progress — K900 path (serial busy during BES flash). Emit ota_status
@@ -4457,7 +4521,20 @@ class MentraLive : SGCManager() {
4457
4521
 
4458
4522
  val syntheticStatus: String
4459
4523
  if ("FINISHED" == besOtaStatus) {
4460
- syntheticStatus = "step_complete"
4524
+ // The glasses power-cycle right after the final BES tick, so a
4525
+ // session whose BES step is the LAST step never gets a follow-up
4526
+ // ota_status from the glasses — consumers mapping on this synthetic
4527
+ // status would otherwise never see a terminal state. Emit "complete"
4528
+ // for the final step; mid-session BES steps keep "step_complete" so
4529
+ // session-level trackers advance normally. Unknown sessions
4530
+ // (cachedOtaTotalSteps == 0, e.g. legacy glasses that never sent an
4531
+ // ota_status) conservatively keep "step_complete".
4532
+ syntheticStatus =
4533
+ if (cachedOtaTotalSteps > 0 &&
4534
+ cachedOtaCurrentStep >= cachedOtaTotalSteps
4535
+ )
4536
+ "complete"
4537
+ else "step_complete"
4461
4538
  } else if ("FAILED" == besOtaStatus) {
4462
4539
  syntheticStatus = "failed"
4463
4540
  } else {
@@ -4701,7 +4778,12 @@ class MentraLive : SGCManager() {
4701
4778
  /**
4702
4779
  * Update WiFi status and notify listeners Matches iOS MentraLive.swift updateWifiStatus pattern
4703
4780
  */
4704
- private fun updateWifiStatus(connected: Boolean, ssid: String, localIp: String) {
4781
+ private fun updateWifiStatus(
4782
+ connected: Boolean,
4783
+ ssid: String,
4784
+ localIp: String,
4785
+ error: String? = null
4786
+ ) {
4705
4787
  Bridge.log("LIVE: 🌐 Updating WiFi status - connected: " + connected + ", SSID: " + ssid)
4706
4788
 
4707
4789
  // Update parent SGCManager fields
@@ -4710,7 +4792,7 @@ class MentraLive : SGCManager() {
4710
4792
  DeviceStore.apply("glasses", "wifiLocalIp", localIp)
4711
4793
 
4712
4794
  // Send event to bridge for cloud communication
4713
- Bridge.sendWifiStatusChange(connected, ssid, localIp)
4795
+ Bridge.sendWifiStatusChange(connected, ssid, localIp, error)
4714
4796
  }
4715
4797
 
4716
4798
  /**
@@ -4784,10 +4866,13 @@ class MentraLive : SGCManager() {
4784
4866
  /**
4785
4867
  * Request WiFi scan from the glasses This will ask the glasses to scan for available networks
4786
4868
  */
4787
- override fun requestWifiScan() {
4869
+ override fun requestWifiScan(scanId: String?) {
4788
4870
  try {
4789
4871
  val json = JSONObject()
4790
4872
  json.put("type", "request_wifi_scan")
4873
+ if (!scanId.isNullOrEmpty()) {
4874
+ json.put("scanId", scanId)
4875
+ }
4791
4876
  sendJson(json, true)
4792
4877
  Bridge.log("LIVE: Sending WiFi scan request to glasses")
4793
4878
  } catch (e: JSONException) {
@@ -5458,8 +5543,9 @@ class MentraLive : SGCManager() {
5458
5543
  val bleImgId = "I" + String.format("%09d", System.currentTimeMillis() % 1000000000)
5459
5544
  json.put("bleImgId", bleImgId)
5460
5545
 
5461
- // Use auto mode by default - glasses try WiFi direct upload, BLE fallback via bleImgId
5462
- json.put("transferMethod", "auto")
5546
+ // Miniapps may explicitly skip direct Wi-Fi upload and force the
5547
+ // phone-relayed BLE path. Auto remains the default.
5548
+ json.put("transferMethod", request.transferMethod)
5463
5549
 
5464
5550
  // Always prepare for potential BLE transfer
5465
5551
  if (webhookUrl != null && !webhookUrl.isEmpty()) {
@@ -5469,7 +5555,7 @@ class MentraLive : SGCManager() {
5469
5555
  blePhotoTransfers[bleImgId] = transfer
5470
5556
  }
5471
5557
 
5472
- Bridge.log("LIVE: Using auto transfer mode with BLE fallback ID: " + bleImgId)
5558
+ Bridge.log("LIVE: Using " + request.transferMethod + " transfer mode with BLE fallback ID: " + bleImgId)
5473
5559
  Bridge.log(
5474
5560
  "LIVE: PHOTO PIPELINE [5b/6] JSON ready mode=" +
5475
5561
  mode +
@@ -5488,14 +5574,19 @@ class MentraLive : SGCManager() {
5488
5574
  fun warmUpCamera(
5489
5575
  requestId: String,
5490
5576
  size: PhotoSize,
5577
+ mode: PhotoMode = PhotoMode.PHOTO,
5491
5578
  exposureTimeNs: Long?,
5492
5579
  durationMs: Int,
5580
+ zsl: Boolean? = null,
5581
+ mfnr: Boolean? = null,
5493
5582
  ) {
5494
5583
  Bridge.log(
5495
5584
  "LIVE: warmUpCamera() entry — requestId=" +
5496
5585
  requestId +
5497
5586
  ", size=" +
5498
5587
  size.value +
5588
+ ", mode=" +
5589
+ mode.value +
5499
5590
  ", durationMs=" +
5500
5591
  durationMs
5501
5592
  )
@@ -5506,9 +5597,16 @@ class MentraLive : SGCManager() {
5506
5597
  json.put("requestId", requestId)
5507
5598
  val sizeValue = size.value
5508
5599
  json.put("size", if (sizeValue.isNotEmpty()) sizeValue else "medium")
5600
+ json.put("mode", mode.value)
5509
5601
  if (exposureTimeNs != null && exposureTimeNs > 0L) {
5510
5602
  json.put("exposureTimeNs", exposureTimeNs)
5511
5603
  }
5604
+ if (mfnr != null) {
5605
+ json.put("mfnr", mfnr)
5606
+ }
5607
+ if (zsl != null) {
5608
+ json.put("zsl", zsl)
5609
+ }
5512
5610
  json.put("durationMs", if (durationMs > 0) durationMs else 15000)
5513
5611
  sendJson(json, true)
5514
5612
  } catch (e: JSONException) {
@@ -5516,6 +5614,16 @@ class MentraLive : SGCManager() {
5516
5614
  }
5517
5615
  }
5518
5616
 
5617
+ fun stopCameraWarmUp(requestId: String) {
5618
+ if (!isConnected) {
5619
+ throw IllegalStateException("not_connected")
5620
+ }
5621
+ val json = JSONObject()
5622
+ json.put("type", "camera_warm_up_stop")
5623
+ json.put("requestId", requestId)
5624
+ sendJson(json, true)
5625
+ }
5626
+
5519
5627
  override fun startStream(message: MutableMap<String, Any>) {
5520
5628
  Bridge.log("LIVE: Starting RTMP stream")
5521
5629
 
@@ -7162,6 +7270,84 @@ class MentraLive : SGCManager() {
7162
7270
  * negotiated per-link endianness and binary support. Missing wire_caps leaves the legacy
7163
7271
  * defaults (BE, no binary) untouched so older glasses keep working.
7164
7272
  */
7273
+ /**
7274
+ * Drop every negotiated wire-session artifact and bump the session generation so
7275
+ * scheduled callbacks from the old session stand down. Called on BLE disconnect and on
7276
+ * glasses_ready: the glasses run onTransportReset() before sending glasses_ready, so a
7277
+ * mid-link ASG restart (no BLE disconnect) resets THEIR side to legacy framing - the
7278
+ * phone must treat every glasses_ready as a new wire epoch and renegotiate (bounded by
7279
+ * the per-session handshake attempt cap) instead of trusting stale v2-active state.
7280
+ */
7281
+ private fun resetWireNegotiationState() {
7282
+ incomingChunkReassembler.clear()
7283
+ peerWireProtocolVersion = 0
7284
+ useBinaryWireProtocol = false
7285
+ wireHandshakeQueued = false
7286
+ wireHandshakeAttempts = 0
7287
+ wireSessionGeneration++
7288
+ peerK900Le = false
7289
+ peerWireCapsBinary = false
7290
+ peerFilePayloadV2 = false
7291
+ BleJsonCompact.resetSession()
7292
+ wireHandshakeSentGeneration = -1
7293
+ }
7294
+
7295
+ /** Sends phone_ready to (re)start the glasses-driven readiness flow. */
7296
+ private fun sendPhoneReady(reason: String) {
7297
+ Bridge.log("LIVE: 📱 Sending phone_ready to glasses ($reason) - waiting for glasses_ready response")
7298
+ val readyMsg = JSONObject()
7299
+ readyMsg.put("type", "phone_ready")
7300
+ readyMsg.put("timestamp", System.currentTimeMillis())
7301
+ sendJson(readyMsg, true)
7302
+ }
7303
+
7304
+ /**
7305
+ * Tracks the glasses process session id (`sid`) and re-runs the phone-driven readiness
7306
+ * flow when it changes under a live BLE link. The BES holds the link across asg_client
7307
+ * restarts (APK OTA, crash recovery), so a restarted glasses process is invisible to
7308
+ * transport state; the sid is the explicit protocol signal.
7309
+ *
7310
+ * Tri-state by design (retro-compat with pre-sid glasses builds):
7311
+ * - no `sid` key: legacy glasses - do nothing, today's behavior;
7312
+ * - sid appears where none was known on an ESTABLISHED session: the glasses just
7313
+ * updated from a pre-sid build and restarted - treat as a restart (this is the
7314
+ * upgrade OTA itself);
7315
+ * - sid differs from the recorded one: the glasses restarted - treat as a restart;
7316
+ * - same sid: same process, no action.
7317
+ *
7318
+ * A restart is handled as a LOGICAL session reset: send phone_ready immediately
7319
+ * (instead of waiting for the next sr_hrt heartbeat, and bypassing its stale
7320
+ * glassesReady suppression); the returning glasses_ready runs the full existing
7321
+ * remote-wire-reset flow. glassesReady/fullyBooted are deliberately left untouched -
7322
+ * the physical link never dropped, so the connection UI must not flap. The RN layer
7323
+ * is notified so the OTA coordinator can treat it as its reconnect edge.
7324
+ */
7325
+ private fun handleGlassesSessionId(json: JSONObject) {
7326
+ val sid = json.optString("sid", "")
7327
+ if (sid.isEmpty()) return
7328
+ val previous = glassesSessionId
7329
+ if (sid == previous) return
7330
+ glassesSessionId = sid
7331
+ if (previous == null && !readinessCompletedThisBleSession) {
7332
+ // First sid of a fresh BLE session before readiness completes: the normal
7333
+ // pairing/readiness flow is already running - just record it.
7334
+ return
7335
+ }
7336
+ Bridge.log(
7337
+ "LIVE: 🔁 Glasses session changed (${previous ?: "<pre-sid build>"} -> $sid) - " +
7338
+ "asg restarted under a live link, re-running readiness"
7339
+ )
7340
+ sendPhoneReady("glasses session changed")
7341
+ try {
7342
+ val payload = HashMap<String, Any>()
7343
+ payload["previous_sid"] = previous ?: ""
7344
+ payload["sid"] = sid
7345
+ Bridge.sendTypedMessage("glasses_session_changed", payload)
7346
+ } catch (e: Exception) {
7347
+ Log.e(TAG, "Error emitting glasses_session_changed", e)
7348
+ }
7349
+ }
7350
+
7165
7351
  private fun parsePeerWireCaps(json: JSONObject) {
7166
7352
  val caps = json.optJSONObject("wire_caps") ?: return
7167
7353
  if (caps.optBoolean("k900_le", false)) {
@@ -7233,7 +7419,39 @@ class MentraLive : SGCManager() {
7233
7419
  )
7234
7420
  Bridge.log("LIVE: Sending BLE wire v2 handshake")
7235
7421
  wireHandshakeQueued = true
7422
+ wireHandshakeSentGeneration = wireSessionGeneration
7236
7423
  queueData(packed, null)
7424
+ // The handshake and its reply are fire-and-forget binary frames with no ACK
7425
+ // tracking; if either is lost, wireHandshakeQueued would block every future
7426
+ // attempt and the session would silently stay on the legacy string wire.
7427
+ // Re-arm after a grace period so negotiation can retry, bounded per session
7428
+ // so a peer that advertises binary but never answers doesn't get pinged
7429
+ // forever (later caps/version triggers may still retry explicitly).
7430
+ wireHandshakeAttempts++
7431
+ val scheduledGeneration = wireSessionGeneration
7432
+ handler.postDelayed(
7433
+ {
7434
+ if (scheduledGeneration == wireSessionGeneration &&
7435
+ wireHandshakeQueued &&
7436
+ peerWireProtocolVersion < BleWireProtocol.PROTOCOL_V2
7437
+ ) {
7438
+ Bridge.log(
7439
+ "LIVE: BLE wire v2 handshake unanswered after " +
7440
+ WIRE_HANDSHAKE_RETRY_GRACE_MS +
7441
+ "ms (attempt " +
7442
+ wireHandshakeAttempts +
7443
+ "/" +
7444
+ WIRE_HANDSHAKE_MAX_ATTEMPTS +
7445
+ ") - re-arming"
7446
+ )
7447
+ wireHandshakeQueued = false
7448
+ if (wireHandshakeAttempts < WIRE_HANDSHAKE_MAX_ATTEMPTS) {
7449
+ maybeSendWireHandshake()
7450
+ }
7451
+ }
7452
+ },
7453
+ WIRE_HANDSHAKE_RETRY_GRACE_MS
7454
+ )
7237
7455
  } catch (e: Exception) {
7238
7456
  Log.e(TAG, "Failed to send wire handshake", e)
7239
7457
  }
@@ -7243,12 +7461,17 @@ class MentraLive : SGCManager() {
7243
7461
  peerWireProtocolVersion = BleWireProtocol.PROTOCOL_V2
7244
7462
  useBinaryWireProtocol = true
7245
7463
  wireHandshakeQueued = false
7464
+ wireHandshakeAttempts = 0
7246
7465
  peerK900Le = true
7247
7466
  BleJsonCompact.markSessionConnected(System.currentTimeMillis())
7248
7467
  Bridge.log(logMessage)
7249
7468
  }
7250
7469
 
7251
7470
  private fun handlePeerWireHandshake() {
7471
+ if (wireHandshakeSentGeneration != wireSessionGeneration) {
7472
+ Bridge.log("LIVE: Ignoring wire v2 handshake reply from a previous session epoch")
7473
+ return
7474
+ }
7252
7475
  activateBinaryWireV2Session("LIVE: Peer confirmed BLE wire protocol v2")
7253
7476
  }
7254
7477
 
@@ -7947,7 +8170,7 @@ class MentraLive : SGCManager() {
7947
8170
  val type = json.optString("type", "")
7948
8171
 
7949
8172
  when (type) {
7950
- "request_wifi_scan" -> requestWifiScan()
8173
+ "request_wifi_scan" -> requestWifiScan(null)
7951
8174
  "rgb_led_control_on", "rgb_led_control_off" -> {
7952
8175
  // Forward LED control commands directly to glasses via BLE
7953
8176
  Log.d(TAG, "💡 Forwarding LED control command to glasses: " + type)
@@ -8763,6 +8986,9 @@ class MentraLive : SGCManager() {
8763
8986
 
8764
8987
  // Send glasses-side Voice Activity Detection setting.
8765
8988
  sendVoiceActivityDetectionSetting()
8989
+
8990
+ // Send glasses-side loudness / Barrier gate setting.
8991
+ sendLoudnessGateSetting()
8766
8992
  }
8767
8993
 
8768
8994
  override fun sendVoiceActivityDetectionSetting() {
@@ -8805,11 +9031,56 @@ class MentraLive : SGCManager() {
8805
9031
  }
8806
9032
  }
8807
9033
 
9034
+ override fun sendLoudnessGateSetting() {
9035
+ val value = DeviceStore.get("bluetooth", "loudness_gate_enabled")
9036
+ val enabled =
9037
+ if (value is Boolean) value
9038
+ else BluetoothSdkDefaults.LOUDNESS_GATE_ENABLED
9039
+
9040
+ Bridge.log("LIVE: 🎚️ Sending loudness/Barrier gate setting to glasses: " + enabled)
9041
+
9042
+ if (!isConnected) {
9043
+ Bridge.log("LIVE: Cannot send loudness gate setting - not connected")
9044
+ return
9045
+ }
9046
+
9047
+ try {
9048
+ val body = JSONObject()
9049
+ body.put("type", LOUDNESS_GATE_SWITCH_TYPE)
9050
+ body.put("switch", if (enabled) 1 else 0)
9051
+
9052
+ val cmdObject = JSONObject()
9053
+ cmdObject.put("C", "cs_swit")
9054
+ cmdObject.put("V", 1)
9055
+ cmdObject.put("B", body.toString())
9056
+
9057
+ val packedData =
9058
+ K900ProtocolUtils.packDataToK900(
9059
+ cmdObject.toString().toByteArray(StandardCharsets.UTF_8),
9060
+ K900ProtocolUtils.CMD_TYPE_STRING,
9061
+ k900LengthEndian()
9062
+ )
9063
+ if (packedData == null) {
9064
+ Bridge.log("LIVE: Failed to pack loudness gate setting command")
9065
+ return
9066
+ }
9067
+ queueData(packedData)
9068
+ } catch (e: JSONException) {
9069
+ Log.e(TAG, "Error creating loudness gate setting command", e)
9070
+ }
9071
+ }
9072
+
8808
9073
  /** Send button photo settings to glasses, replaying all stored scan-tuning fields. */
8809
9074
  override fun sendButtonPhotoSettings() {
9075
+ val legacyZslMfnr =
9076
+ DeviceStore.get(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_zsl_mfnr") as Boolean?
8810
9077
  val size = DeviceStore.get(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_size") as String?
8811
- val mfnr = DeviceStore.get(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_mfnr") as Boolean?
8812
- val zsl = DeviceStore.get(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_zsl") as Boolean?
9078
+ val mfnr =
9079
+ (DeviceStore.get(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_mfnr") as Boolean?)
9080
+ ?: legacyZslMfnr
9081
+ val zsl =
9082
+ (DeviceStore.get(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_zsl") as Boolean?)
9083
+ ?: legacyZslMfnr
8813
9084
  val noiseReduction = DeviceStore.get(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_noise_reduction") as Boolean?
8814
9085
  val edgeEnhancement = DeviceStore.get(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_edge_enhancement") as Boolean?
8815
9086
  val ispDigitalGain = DeviceStore.get(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_isp_digital_gain") as Int?
@@ -8868,6 +9139,35 @@ class MentraLive : SGCManager() {
8868
9139
  }
8869
9140
  }
8870
9141
 
9142
+ fun sendCameraFovOverride(
9143
+ requestId: String,
9144
+ leaseId: String,
9145
+ fov: Int,
9146
+ roiPosition: Int,
9147
+ ttlMs: Int,
9148
+ ) {
9149
+ val json = JSONObject()
9150
+ json.put("type", "camera_fov_override")
9151
+ json.put("request_id", requestId)
9152
+ json.put(
9153
+ "params",
9154
+ JSONObject()
9155
+ .put("lease_id", leaseId)
9156
+ .put("fov", fov)
9157
+ .put("roi_position", roiPosition)
9158
+ .put("ttl_ms", ttlMs),
9159
+ )
9160
+ sendJson(json, true)
9161
+ }
9162
+
9163
+ fun releaseCameraFovOverride(requestId: String, leaseId: String) {
9164
+ val json = JSONObject()
9165
+ json.put("type", "camera_fov_override_release")
9166
+ json.put("request_id", requestId)
9167
+ json.put("params", JSONObject().put("lease_id", leaseId))
9168
+ sendJson(json, true)
9169
+ }
9170
+
8871
9171
  /**
8872
9172
  * Send camera tuning config (ANR / gain) to the glasses via the {@code camera_tuning_config}
8873
9173
  * command. The ASG client relays this as a {@code camconfig} broadcast to the camera HAL.