@mentra/bluetooth-sdk 0.1.13 → 0.1.16

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 (80) hide show
  1. package/README.md +18 -8
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +48 -48
  3. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +5 -20
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +360 -269
  5. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +3 -7
  6. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +152 -72
  7. package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +12 -12
  8. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +25 -11
  9. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/ControllerManager.kt +1 -2
  10. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/R1.kt +1 -2
  11. package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +1 -22
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +51 -8
  13. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +1184 -800
  14. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Mach1.kt +1 -5
  15. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +423 -117
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +4 -6
  17. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +2532 -0
  18. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +17 -5
  19. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +3 -7
  20. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +0 -6
  21. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +16 -33
  22. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +5 -3
  23. package/android/src/main/java/com/mentra/bluetoothsdk/utils/Constants.kt +2 -1
  24. package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +60 -1
  25. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoProtocolTest.kt +333 -0
  26. package/build/BluetoothSdk.types.d.ts +50 -24
  27. package/build/BluetoothSdk.types.d.ts.map +1 -1
  28. package/build/BluetoothSdk.types.js +1 -0
  29. package/build/BluetoothSdk.types.js.map +1 -1
  30. package/build/_internal.d.ts +4 -4
  31. package/build/_internal.js +4 -4
  32. package/build/_internal.js.map +1 -1
  33. package/build/_private/BluetoothSdkModule.d.ts +14 -8
  34. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  35. package/build/_private/BluetoothSdkModule.js +4 -3
  36. package/build/_private/BluetoothSdkModule.js.map +1 -1
  37. package/build/_private/cameraRequestPayload.d.ts +4 -0
  38. package/build/_private/cameraRequestPayload.d.ts.map +1 -0
  39. package/build/_private/cameraRequestPayload.js +25 -0
  40. package/build/_private/cameraRequestPayload.js.map +1 -0
  41. package/build/_private/photoRequestPayload.d.ts.map +1 -1
  42. package/build/_private/photoRequestPayload.js +11 -3
  43. package/build/_private/photoRequestPayload.js.map +1 -1
  44. package/build/index.d.ts +1 -1
  45. package/build/index.d.ts.map +1 -1
  46. package/build/index.js +70 -61
  47. package/build/index.js.map +1 -1
  48. package/ios/BluetoothSdkModule.swift +51 -28
  49. package/ios/Source/Bridge.swift +4 -16
  50. package/ios/Source/DeviceManager.swift +76 -22
  51. package/ios/Source/DeviceStore.swift +0 -4
  52. package/ios/Source/MentraBluetoothSDK.swift +144 -78
  53. package/ios/Source/OtaManifest.swift +13 -13
  54. package/ios/Source/camera/CameraModels.swift +82 -20
  55. package/ios/Source/controllers/ControllerManager.swift +1 -2
  56. package/ios/Source/controllers/R1.swift +1 -2
  57. package/ios/Source/errors/{BluetoothError.swift → BluetoothSdkError.swift} +1 -1
  58. package/ios/Source/events/BluetoothEvents.swift +5 -27
  59. package/ios/Source/internal/BluetoothAvailability.swift +5 -5
  60. package/ios/Source/sgcs/Frame.swift +0 -2
  61. package/ios/Source/sgcs/G1.swift +52 -4
  62. package/ios/Source/sgcs/G2.swift +643 -204
  63. package/ios/Source/sgcs/Mach1.swift +1 -3
  64. package/ios/Source/sgcs/MentraLive.swift +64 -77
  65. package/ios/Source/sgcs/MentraNex.swift +1 -3
  66. package/ios/Source/sgcs/Nimo.swift +1928 -0
  67. package/ios/Source/sgcs/SGCManager.swift +16 -10
  68. package/ios/Source/sgcs/Simulated.swift +2 -6
  69. package/ios/Source/status/DeviceStatus.swift +0 -8
  70. package/ios/Source/streaming/StreamModels.swift +7 -32
  71. package/ios/Source/stt/STTTools.swift +1 -1
  72. package/ios/Source/types/DeviceModels.swift +5 -0
  73. package/ios/Source/utils/Constants.swift +2 -0
  74. package/package.json +6 -1
  75. package/src/BluetoothSdk.types.ts +54 -28
  76. package/src/_internal.ts +4 -4
  77. package/src/_private/BluetoothSdkModule.ts +20 -11
  78. package/src/_private/cameraRequestPayload.ts +29 -0
  79. package/src/_private/photoRequestPayload.ts +12 -3
  80. package/src/index.ts +77 -67
@@ -32,6 +32,7 @@ import com.mentra.bluetoothsdk.BluetoothSdkDefaults
32
32
  import com.mentra.bluetoothsdk.Bridge
33
33
  import com.mentra.bluetoothsdk.DeviceManager
34
34
  import com.mentra.bluetoothsdk.PhotoRequest
35
+ import com.mentra.bluetoothsdk.PhotoSize
35
36
  import com.mentra.bluetoothsdk.DeviceStore
36
37
  import com.mentra.bluetoothsdk.ObservableStore
37
38
  import com.mentra.bluetoothsdk.debug.BleTraceLogger
@@ -157,6 +158,8 @@ class MentraLive : SGCManager() {
157
158
 
158
159
  // Rate limiting - minimum delay between BLE characteristic writes
159
160
  private const val MIN_SEND_DELAY_MS = 160L // 160ms minimum delay (increased from 100ms)
161
+ private const val SIGNIFICANT_BLE_TRACE_DELAY_MS = 250L
162
+ private const val SIGNIFICANT_BLE_TRACE_QUEUE_SIZE = 5
160
163
 
161
164
  // File transfer management
162
165
  private const val FILE_SAVE_DIR = "MentraLive_Images"
@@ -237,7 +240,35 @@ class MentraLive : SGCManager() {
237
240
  private var a2dpProfile: BluetoothA2dp? = null
238
241
  private var isA2dpProxyRegistered = false
239
242
 
240
- private var sendQueue = ConcurrentLinkedQueue<ByteArray>()
243
+ private data class OutgoingBleCommandTraceInfo(
244
+ val commandType: String,
245
+ val requestId: String?,
246
+ val appId: String?,
247
+ val messageId: Long?
248
+ )
249
+
250
+ private data class BleWriteTrace(
251
+ val sequence: Long,
252
+ val commandType: String,
253
+ val requestId: String?,
254
+ val appId: String?,
255
+ val messageId: Long?,
256
+ val chunkId: String?,
257
+ val chunkIndex: Int?,
258
+ val totalChunks: Int?,
259
+ val payloadBytes: Int?,
260
+ val packedBytes: Int,
261
+ val wakeup: Boolean,
262
+ val chunked: Boolean,
263
+ val queuedAtMs: Long
264
+ )
265
+
266
+ private data class QueuedBleWrite(val data: ByteArray, val trace: BleWriteTrace?)
267
+
268
+ private var sendQueue = ConcurrentLinkedQueue<QueuedBleWrite>()
269
+ private val bleWriteTraceSequence = AtomicLong(1)
270
+ private var inFlightBleWriteTrace: BleWriteTrace? = null
271
+ private var inFlightBleWriteStartedAtMs = 0L
241
272
  // Queue for serializing BLE descriptor writes (only one GATT operation at a time)
242
273
  private val pendingDescriptorWrites = ConcurrentLinkedQueue<BluetoothGattDescriptor>()
243
274
  private var isDescriptorWriteInProgress = false
@@ -735,8 +766,7 @@ class MentraLive : SGCManager() {
735
766
  }
736
767
  // Drop cached version fields from the previous BLE session so the next version_info
737
768
  // repopulates RN. Otherwise a stale build (e.g. 38) can remain while ASG is still 36,
738
- // and the phone-side OTA check will disagree with glasses' PackageManager +
739
- // ota_update_available.
769
+ // and the phone-side OTA manifest check will compare against the wrong build.
740
770
  DeviceStore.apply("glasses", "buildNumber", "")
741
771
  DeviceStore.apply("glasses", "appVersion", "")
742
772
  DeviceStore.apply("glasses", "besFirmwareVersion", "")
@@ -1672,11 +1702,20 @@ class MentraLive : SGCManager() {
1672
1702
  characteristic: BluetoothGattCharacteristic,
1673
1703
  status: Int
1674
1704
  ) {
1705
+ val trace = inFlightBleWriteTrace
1706
+ val callbackAtMs = System.currentTimeMillis()
1707
+ val callbackDelayMs =
1708
+ if (inFlightBleWriteStartedAtMs > 0L)
1709
+ callbackAtMs - inFlightBleWriteStartedAtMs
1710
+ else null
1711
+ inFlightBleWriteTrace = null
1712
+ inFlightBleWriteStartedAtMs = 0L
1713
+
1675
1714
  if (status == BluetoothGatt.GATT_SUCCESS) {
1676
1715
  // Bridge.log("LIVE: Characteristic write successful");
1677
1716
 
1678
1717
  // Calculate time since last send to enforce rate limiting
1679
- val currentTimeMs = System.currentTimeMillis()
1718
+ val currentTimeMs = callbackAtMs
1680
1719
  val timeSinceLastSendMs = currentTimeMs - lastSendTimeMs
1681
1720
  val nextProcessDelayMs: Long
1682
1721
 
@@ -1691,9 +1730,34 @@ class MentraLive : SGCManager() {
1691
1730
  }
1692
1731
 
1693
1732
  // Schedule the next queue processing with appropriate delay
1733
+ logBleWriteTrace(
1734
+ "write_callback",
1735
+ trace,
1736
+ mapOf(
1737
+ "status" to status,
1738
+ "success" to true,
1739
+ "callbackDelayMs" to callbackDelayMs,
1740
+ "timeSinceLastSendMs" to timeSinceLastSendMs,
1741
+ "nextProcessDelayMs" to nextProcessDelayMs,
1742
+ "queueSize" to sendQueue.size,
1743
+ "characteristicUuid" to characteristic.uuid.toString()
1744
+ )
1745
+ )
1694
1746
  handler.postDelayed(processSendQueueRunnable!!, nextProcessDelayMs)
1695
1747
  } else {
1696
1748
  Log.e(TAG, "Characteristic write failed with status: " + status)
1749
+ logBleWriteTrace(
1750
+ "write_callback",
1751
+ trace,
1752
+ mapOf(
1753
+ "status" to status,
1754
+ "success" to false,
1755
+ "callbackDelayMs" to callbackDelayMs,
1756
+ "retryDelayMs" to 500L,
1757
+ "queueSize" to sendQueue.size,
1758
+ "characteristicUuid" to characteristic.uuid.toString()
1759
+ )
1760
+ )
1697
1761
  // If write fails, try again with a longer delay
1698
1762
  handler.postDelayed(processSendQueueRunnable!!, 500L)
1699
1763
  }
@@ -2101,13 +2165,22 @@ class MentraLive : SGCManager() {
2101
2165
  Bridge.log(
2102
2166
  "LIVE: Rate limiting: Waiting " + remainingDelayMs + "ms before next BLE send"
2103
2167
  )
2168
+ logBleWriteTrace(
2169
+ "rate_limited",
2170
+ sendQueue.peek()?.trace,
2171
+ mapOf(
2172
+ "remainingDelayMs" to remainingDelayMs,
2173
+ "timeSinceLastSendMs" to timeSinceLastSendMs,
2174
+ "queueSize" to sendQueue.size
2175
+ )
2176
+ )
2104
2177
  handler.postDelayed(processSendQueueRunnable!!, remainingDelayMs)
2105
2178
  return
2106
2179
  }
2107
2180
 
2108
2181
  // Send the next item from the queue
2109
- val data = sendQueue.poll()
2110
- if (data != null) {
2182
+ val queuedWrite = sendQueue.poll()
2183
+ if (queuedWrite != null) {
2111
2184
  // Update last send time before sending
2112
2185
  lastSendTimeMs = currentTimeMs
2113
2186
  Bridge.log(
@@ -2117,28 +2190,78 @@ class MentraLive : SGCManager() {
2117
2190
  timeSinceLastSendMs +
2118
2191
  "ms"
2119
2192
  )
2120
- sendDataInternal(data)
2193
+ logBleWriteTrace(
2194
+ "dequeued",
2195
+ queuedWrite.trace,
2196
+ mapOf(
2197
+ "queueDelayMs" to
2198
+ (queuedWrite.trace?.let { currentTimeMs - it.queuedAtMs }),
2199
+ "timeSinceLastSendMs" to timeSinceLastSendMs,
2200
+ "queueSizeAfterPoll" to sendQueue.size
2201
+ )
2202
+ )
2203
+ sendDataInternal(queuedWrite)
2121
2204
  }
2122
2205
  }
2123
2206
 
2124
2207
  /** Send data through BLE */
2125
- private fun sendDataInternal(data: ByteArray?) {
2126
- if (!isConnected || bluetoothGatt == null || txCharacteristic == null || data == null) {
2208
+ private fun sendDataInternal(write: QueuedBleWrite?) {
2209
+ if (!isConnected || bluetoothGatt == null || txCharacteristic == null || write == null) {
2127
2210
  return
2128
2211
  }
2129
2212
 
2130
2213
  try {
2131
- txCharacteristic!!.value = data
2132
- bluetoothGatt!!.writeCharacteristic(txCharacteristic)
2214
+ val writeStartedAtMs = System.currentTimeMillis()
2215
+ txCharacteristic!!.value = write.data
2216
+ inFlightBleWriteTrace = write.trace
2217
+ inFlightBleWriteStartedAtMs = writeStartedAtMs
2218
+ val writeAccepted = bluetoothGatt!!.writeCharacteristic(txCharacteristic)
2219
+ logBleWriteTrace(
2220
+ "write_call",
2221
+ write.trace,
2222
+ mapOf(
2223
+ "writeAccepted" to writeAccepted,
2224
+ "currentMtu" to currentMtu,
2225
+ "writeType" to txCharacteristic!!.writeType,
2226
+ "queueSize" to sendQueue.size,
2227
+ "characteristicUuid" to txCharacteristic!!.uuid.toString()
2228
+ )
2229
+ )
2230
+ if (!writeAccepted) {
2231
+ inFlightBleWriteTrace = null
2232
+ inFlightBleWriteStartedAtMs = 0L
2233
+ }
2133
2234
  } catch (e: Exception) {
2134
2235
  Log.e(TAG, "Error sending data via BLE", e)
2236
+ logBleWriteTrace(
2237
+ "write_exception",
2238
+ write.trace,
2239
+ mapOf(
2240
+ "errorClass" to e.javaClass.simpleName,
2241
+ "errorMessage" to (e.message ?: "unknown")
2242
+ )
2243
+ )
2244
+ inFlightBleWriteTrace = null
2245
+ inFlightBleWriteStartedAtMs = 0L
2135
2246
  }
2136
2247
  }
2137
2248
 
2138
2249
  /** Queue data to be sent */
2139
- private fun queueData(data: ByteArray?) {
2250
+ private fun queueData(data: ByteArray?, trace: BleWriteTrace? = null) {
2140
2251
  if (data != null) {
2141
- sendQueue.add(data)
2252
+ val queuedTrace =
2253
+ trace?.copy(
2254
+ queuedAtMs =
2255
+ if (trace.queuedAtMs > 0L)
2256
+ trace.queuedAtMs
2257
+ else System.currentTimeMillis()
2258
+ )
2259
+ sendQueue.add(QueuedBleWrite(data, queuedTrace))
2260
+ logBleChunkTrace(
2261
+ "queued",
2262
+ queuedTrace,
2263
+ mapOf("queueSizeAfterAdd" to sendQueue.size)
2264
+ )
2142
2265
  // Bridge.log("LIVE: 📋 Added " + data.length + " to send queue - New queue size: " +
2143
2266
  // sendQueue.size());
2144
2267
 
@@ -2836,6 +2959,12 @@ class MentraLive : SGCManager() {
2836
2959
  } catch (e: JSONException) {
2837
2960
  Log.e(TAG, "Error converting photo status to Map", e)
2838
2961
  }
2962
+ "camera_status" ->
2963
+ try {
2964
+ Bridge.sendCameraStatus(jsonObjectToMap(json))
2965
+ } catch (e: JSONException) {
2966
+ Log.e(TAG, "Error converting camera status to Map", e)
2967
+ }
2839
2968
  "stream_status" -> {
2840
2969
  // Process streaming status update from ASG client
2841
2970
  Bridge.log("LIVE: Received stream status update from glasses: " + json.toString())
@@ -3041,47 +3170,6 @@ class MentraLive : SGCManager() {
3041
3170
  (if (success) "SUCCESS" else "FAILED")
3042
3171
  )
3043
3172
  }
3044
- "ota_update_available" -> {
3045
- // Process OTA update available notification from glasses (background mode)
3046
- Bridge.log("LIVE: 📱 Received ota_update_available from glasses")
3047
- Bridge.log("LIVE: 📱 OTA update available: " + json.toString())
3048
- try {
3049
- val otaVersionCode = json.optLong("version_code", 0)
3050
- val otaVersionName = json.optString("version_name", "")
3051
- val otaTotalSize = json.optLong("total_size", 0)
3052
-
3053
- // Parse updates array
3054
- val updates: MutableList<String> = ArrayList()
3055
- if (json.has("updates")) {
3056
- val updatesArray = json.getJSONArray("updates")
3057
- for (i in 0 until updatesArray.length()) {
3058
- updates.add(updatesArray.getString(i))
3059
- }
3060
- }
3061
-
3062
- Bridge.log(
3063
- "LIVE: 📱 OTA available - version: " +
3064
- otaVersionName +
3065
- " (" +
3066
- otaVersionCode +
3067
- "), updates: " +
3068
- updates +
3069
- ", size: " +
3070
- otaTotalSize +
3071
- " bytes"
3072
- )
3073
-
3074
- // Send to React Native
3075
- Bridge.sendOtaUpdateAvailable(
3076
- otaVersionCode,
3077
- otaVersionName,
3078
- updates,
3079
- otaTotalSize
3080
- )
3081
- } catch (e: JSONException) {
3082
- Log.e(TAG, "Error parsing ota_update_available", e)
3083
- }
3084
- }
3085
3173
  "ota_start_ack" -> {
3086
3174
  // Glasses acknowledged receipt of ota_start — phone can cancel its retry timer
3087
3175
  Bridge.log("LIVE: 📱 Received ota_start_ack from glasses")
@@ -4660,7 +4748,7 @@ class MentraLive : SGCManager() {
4660
4748
  * When [otaVersionUrl] is non-null it is sent as the `ota_version_url` field so the glasses
4661
4749
  * download from that manifest; asg_client's OtaCommandHandler reads and validates that field
4662
4750
  * (it must be an http(s) URL). A null url omits the field, leaving the glasses to fall back to
4663
- * their prefetched/default version manifest.
4751
+ * their default version manifest.
4664
4752
  */
4665
4753
  fun sendOtaStart(otaVersionUrl: String? = null) {
4666
4754
  try {
@@ -4689,18 +4777,6 @@ class MentraLive : SGCManager() {
4689
4777
  }
4690
4778
  }
4691
4779
 
4692
- fun sendOtaRetryVersionCheck() {
4693
- try {
4694
- val json = JSONObject()
4695
- json.put("type", "ota_retry_version_check")
4696
- json.put("timestamp", System.currentTimeMillis())
4697
- sendJson(json, true)
4698
- Bridge.log("LIVE: ⏰ Sending ota_retry_version_check command to glasses")
4699
- } catch (e: JSONException) {
4700
- Log.e(TAG, "⏰ Error creating ota_retry_version_check command", e)
4701
- }
4702
- }
4703
-
4704
4780
  /**
4705
4781
  * Request version info from glasses. Glasses will respond with version_info message containing
4706
4782
  * build number, firmware version, etc.
@@ -5202,12 +5278,10 @@ class MentraLive : SGCManager() {
5202
5278
 
5203
5279
  override fun requestPhoto(request: PhotoRequest) {
5204
5280
  val requestId = request.requestId
5205
- val appId = request.appId
5206
5281
  val size = request.size.value
5207
5282
  val webhookUrl = request.webhookUrl
5208
5283
  val authToken = request.authToken
5209
5284
  val compress = request.compress.value
5210
- val flash = request.flash
5211
5285
  val save = request.save
5212
5286
  val sound = request.sound
5213
5287
  val exposureTimeNs = request.exposureTimeNs
@@ -5216,8 +5290,6 @@ class MentraLive : SGCManager() {
5216
5290
  Bridge.log(
5217
5291
  "LIVE: Requesting photo: " +
5218
5292
  requestId +
5219
- " for app: " +
5220
- appId +
5221
5293
  " with size: " +
5222
5294
  size +
5223
5295
  ", webhookUrl: " +
@@ -5226,8 +5298,6 @@ class MentraLive : SGCManager() {
5226
5298
  (if (hasAuthToken) "***" else "none") +
5227
5299
  ", compress=" +
5228
5300
  compress +
5229
- ", flash=" +
5230
- flash +
5231
5301
  ", save=" +
5232
5302
  save +
5233
5303
  ", sound=" +
@@ -5243,16 +5313,13 @@ class MentraLive : SGCManager() {
5243
5313
  )
5244
5314
  Bridge.log(
5245
5315
  "LIVE: PHOTO PIPELINE [5/6] requestPhoto() entry — requestId=" +
5246
- requestId +
5247
- ", appId=" +
5248
- appId
5316
+ requestId
5249
5317
  )
5250
5318
 
5251
5319
  try {
5252
5320
  val json = JSONObject()
5253
5321
  json.put("type", "take_photo")
5254
5322
  json.put("requestId", requestId)
5255
- json.put("appId", appId)
5256
5323
  if (webhookUrl != null && !webhookUrl.isEmpty()) {
5257
5324
  json.put("webhookUrl", webhookUrl)
5258
5325
  }
@@ -5267,7 +5334,6 @@ class MentraLive : SGCManager() {
5267
5334
  } else {
5268
5335
  json.put("compress", "none")
5269
5336
  }
5270
- json.put("flash", flash)
5271
5337
  json.put("save", save)
5272
5338
  json.put("sound", sound)
5273
5339
  if (exposureTimeNs != null && exposureTimeNs > 0L) {
@@ -5315,6 +5381,37 @@ class MentraLive : SGCManager() {
5315
5381
  }
5316
5382
  }
5317
5383
 
5384
+ fun warmUpCamera(
5385
+ requestId: String,
5386
+ size: PhotoSize,
5387
+ exposureTimeNs: Long?,
5388
+ durationMs: Int,
5389
+ ) {
5390
+ Bridge.log(
5391
+ "LIVE: warmUpCamera() entry — requestId=" +
5392
+ requestId +
5393
+ ", size=" +
5394
+ size.value +
5395
+ ", durationMs=" +
5396
+ durationMs
5397
+ )
5398
+
5399
+ try {
5400
+ val json = JSONObject()
5401
+ json.put("type", "camera_warm_up")
5402
+ json.put("requestId", requestId)
5403
+ val sizeValue = size.value
5404
+ json.put("size", if (sizeValue.isNotEmpty()) sizeValue else "medium")
5405
+ if (exposureTimeNs != null && exposureTimeNs > 0L) {
5406
+ json.put("exposureTimeNs", exposureTimeNs)
5407
+ }
5408
+ json.put("durationMs", if (durationMs > 0) durationMs else 15000)
5409
+ sendJson(json, true)
5410
+ } catch (e: JSONException) {
5411
+ Log.e(TAG, "Error creating camera warm up JSON", e)
5412
+ }
5413
+ }
5414
+
5318
5415
  override fun startStream(message: MutableMap<String, Any>) {
5319
5416
  Bridge.log("LIVE: Starting RTMP stream")
5320
5417
 
@@ -6144,25 +6241,6 @@ class MentraLive : SGCManager() {
6144
6241
  }
6145
6242
  }
6146
6243
 
6147
- fun sendButtonCameraLedSetting(enabled: Boolean) {
6148
- sendButtonCameraLedSetting(null, enabled)
6149
- }
6150
-
6151
- fun sendButtonCameraLedSetting(requestId: String?, enabled: Boolean) {
6152
- // Send LED setting to glasses
6153
- val command = JSONObject()
6154
- try {
6155
- command.put("type", "button_camera_led")
6156
- if (requestId != null && !requestId.isEmpty()) {
6157
- command.put("request_id", requestId)
6158
- }
6159
- command.put("enabled", enabled)
6160
- sendJson(command, true)
6161
- } catch (e: Exception) {
6162
- Log.e(TAG, "Error sending button camera LED setting", e)
6163
- }
6164
- }
6165
-
6166
6244
  override fun sendText(text: String) {
6167
6245
  Bridge.log("LIVE: [STUB] Device has no display. Text would show: " + text)
6168
6246
  }
@@ -6976,6 +7054,7 @@ class MentraLive : SGCManager() {
6976
7054
 
6977
7055
  try {
6978
7056
  val outgoingSummary = summarizeOutgoingMessage(data)
7057
+ val commandTraceInfo = parseOutgoingBleCommandTraceInfo(data)
6979
7058
  val isPhotoRequest = outgoingSummary.contains("type=take_photo")
6980
7059
  if (isPhotoRequest) {
6981
7060
  Bridge.log(
@@ -7033,11 +7112,34 @@ class MentraLive : SGCManager() {
7033
7112
  val packedData =
7034
7113
  K900ProtocolUtils.packJsonToK900(
7035
7114
  chunkStr,
7036
- wakeup && i == 0
7037
- ) // Only wakeup on first chunk
7115
+ wakeup && i == 0
7116
+ ) // Only wakeup on first chunk
7117
+
7118
+ val trace =
7119
+ createBleWriteTrace(
7120
+ commandTraceInfo,
7121
+ chunk.optString("id", "").takeIf { it.isNotBlank() },
7122
+ chunk.optInt("c", i),
7123
+ chunk.optInt("n", chunks.size),
7124
+ chunk.optString("d", "")
7125
+ .toByteArray(StandardCharsets.UTF_8)
7126
+ .size,
7127
+ packedData.size,
7128
+ wakeup && i == 0,
7129
+ true
7130
+ )
7131
+ logBleChunkTrace(
7132
+ "created",
7133
+ trace,
7134
+ mapOf(
7135
+ "chunkJsonBytes" to
7136
+ chunkStr.toByteArray(StandardCharsets.UTF_8).size,
7137
+ "messageBytes" to data.toByteArray(StandardCharsets.UTF_8).size
7138
+ )
7139
+ )
7038
7140
 
7039
7141
  // Queue the chunk for sending
7040
- queueData(packedData)
7142
+ queueData(packedData, trace)
7041
7143
 
7042
7144
  // Add small delay between chunks to avoid overwhelming the connection
7043
7145
  if (i < chunks.size - 1) {
@@ -7059,9 +7161,25 @@ class MentraLive : SGCManager() {
7059
7161
 
7060
7162
  // Pack the data using the centralized utility
7061
7163
  val packedData = K900ProtocolUtils.packJsonToK900(data, wakeup)
7164
+ val trace =
7165
+ createBleWriteTrace(
7166
+ commandTraceInfo,
7167
+ null,
7168
+ null,
7169
+ null,
7170
+ data.toByteArray(StandardCharsets.UTF_8).size,
7171
+ packedData.size,
7172
+ wakeup,
7173
+ false
7174
+ )
7175
+ logBleChunkTrace(
7176
+ "created",
7177
+ trace,
7178
+ mapOf("messageBytes" to data.toByteArray(StandardCharsets.UTF_8).size)
7179
+ )
7062
7180
 
7063
7181
  // Queue the data for sending
7064
- queueData(packedData)
7182
+ queueData(packedData, trace)
7065
7183
  if (isPhotoRequest) {
7066
7184
  Bridge.log(
7067
7185
  "LIVE: PHOTO PIPELINE BLE handoff — packedLen=" +
@@ -7075,6 +7193,179 @@ class MentraLive : SGCManager() {
7075
7193
  }
7076
7194
  }
7077
7195
 
7196
+ private fun parseOutgoingBleCommandTraceInfo(payload: String): OutgoingBleCommandTraceInfo {
7197
+ return try {
7198
+ val obj = JSONObject(payload)
7199
+ OutgoingBleCommandTraceInfo(
7200
+ commandType = obj.optString("type", "unknown"),
7201
+ requestId = optNonBlankString(obj, "requestId"),
7202
+ appId = optNonBlankString(obj, "appId"),
7203
+ messageId = if (obj.has("mId")) obj.optLong("mId") else null
7204
+ )
7205
+ } catch (_: Exception) {
7206
+ OutgoingBleCommandTraceInfo(
7207
+ commandType = "unknown",
7208
+ requestId = null,
7209
+ appId = null,
7210
+ messageId = null
7211
+ )
7212
+ }
7213
+ }
7214
+
7215
+ private fun createBleWriteTrace(
7216
+ commandInfo: OutgoingBleCommandTraceInfo,
7217
+ chunkId: String?,
7218
+ chunkIndex: Int?,
7219
+ totalChunks: Int?,
7220
+ payloadBytes: Int?,
7221
+ packedBytes: Int,
7222
+ wakeup: Boolean,
7223
+ chunked: Boolean
7224
+ ): BleWriteTrace {
7225
+ return BleWriteTrace(
7226
+ sequence = bleWriteTraceSequence.getAndIncrement(),
7227
+ commandType = commandInfo.commandType,
7228
+ requestId = commandInfo.requestId,
7229
+ appId = commandInfo.appId,
7230
+ messageId = commandInfo.messageId,
7231
+ chunkId = chunkId,
7232
+ chunkIndex = chunkIndex,
7233
+ totalChunks = totalChunks,
7234
+ payloadBytes = payloadBytes,
7235
+ packedBytes = packedBytes,
7236
+ wakeup = wakeup,
7237
+ chunked = chunked,
7238
+ queuedAtMs = System.currentTimeMillis()
7239
+ )
7240
+ }
7241
+
7242
+ private fun optNonBlankString(obj: JSONObject, key: String): String? {
7243
+ return obj.optString(key, "").takeIf { it.isNotBlank() }
7244
+ }
7245
+
7246
+ private fun logBleChunkTrace(
7247
+ stage: String,
7248
+ trace: BleWriteTrace?,
7249
+ extra: Map<String, Any?> = emptyMap()
7250
+ ) {
7251
+ logBleTrace("sdk_ble_chunk", stage, trace, extra)
7252
+ }
7253
+
7254
+ private fun logBleWriteTrace(
7255
+ stage: String,
7256
+ trace: BleWriteTrace?,
7257
+ extra: Map<String, Any?> = emptyMap()
7258
+ ) {
7259
+ logBleTrace("sdk_ble_write", stage, trace, extra)
7260
+ }
7261
+
7262
+ private fun logBleTrace(
7263
+ layer: String,
7264
+ stage: String,
7265
+ trace: BleWriteTrace?,
7266
+ extra: Map<String, Any?> = emptyMap()
7267
+ ) {
7268
+ if (trace == null) {
7269
+ return
7270
+ }
7271
+
7272
+ val warningReason = bleTraceWarningReason(stage, extra)
7273
+ if (warningReason == null) {
7274
+ return
7275
+ }
7276
+
7277
+ try {
7278
+ val payload = mutableMapOf<String, Any>(
7279
+ "level" to "warning",
7280
+ "warningReason" to warningReason,
7281
+ "stage" to stage,
7282
+ "sequence" to trace.sequence,
7283
+ "commandType" to trace.commandType,
7284
+ "packedBytes" to trace.packedBytes,
7285
+ "wakeup" to trace.wakeup,
7286
+ "chunked" to trace.chunked,
7287
+ "queuedAtMs" to trace.queuedAtMs
7288
+ )
7289
+ trace.requestId?.let { payload["requestId"] = it }
7290
+ trace.appId?.let { payload["appId"] = it }
7291
+ trace.messageId?.let { payload["messageId"] = it }
7292
+ trace.chunkId?.let { payload["chunkId"] = it }
7293
+ trace.chunkIndex?.let {
7294
+ payload["chunkIndex"] = it
7295
+ payload["chunkNumber"] = it + 1
7296
+ }
7297
+ trace.totalChunks?.let { payload["totalChunks"] = it }
7298
+ trace.payloadBytes?.let { payload["payloadBytes"] = it }
7299
+ extra.forEach { (key, value) ->
7300
+ if (value != null) {
7301
+ payload[key] = value
7302
+ }
7303
+ }
7304
+
7305
+ BleTraceLogger.logMap("phone_to_glasses", layer, trace.commandType, payload)
7306
+ } catch (e: Exception) {
7307
+ Log.d(TAG, "BLE trace logging failed for $layer/$stage", e)
7308
+ }
7309
+ }
7310
+
7311
+ private fun bleTraceWarningReason(stage: String, extra: Map<String, Any?>): String? {
7312
+ if (extra["errorClass"] != null || extra["errorMessage"] != null) {
7313
+ return "ble_write_error"
7314
+ }
7315
+ if (extra["success"] == false) {
7316
+ return "ble_write_failed"
7317
+ }
7318
+ if (extra["writeAccepted"] == false) {
7319
+ return "ble_write_rejected"
7320
+ }
7321
+
7322
+ if (traceLongAtLeast(extra["queueDelayMs"], SIGNIFICANT_BLE_TRACE_DELAY_MS)) {
7323
+ return "queue_delay"
7324
+ }
7325
+ if (traceLongAtLeast(extra["callbackDelayMs"], SIGNIFICANT_BLE_TRACE_DELAY_MS)) {
7326
+ return "write_callback_delay"
7327
+ }
7328
+ if (traceLongAtLeast(extra["remainingDelayMs"], SIGNIFICANT_BLE_TRACE_DELAY_MS)) {
7329
+ return "rate_limit_delay"
7330
+ }
7331
+ if (traceLongAtLeast(extra["nextProcessDelayMs"], SIGNIFICANT_BLE_TRACE_DELAY_MS)) {
7332
+ return "next_process_delay"
7333
+ }
7334
+ if (extra["retryDelayMs"].asTraceLong() != null) {
7335
+ return "write_retry"
7336
+ }
7337
+ if (stage == "queued" &&
7338
+ traceIntAtLeast(extra["queueSizeAfterAdd"], SIGNIFICANT_BLE_TRACE_QUEUE_SIZE)
7339
+ ) {
7340
+ return "queue_depth"
7341
+ }
7342
+ return null
7343
+ }
7344
+
7345
+ private fun traceLongAtLeast(value: Any?, threshold: Long): Boolean {
7346
+ return (value.asTraceLong() ?: Long.MIN_VALUE) >= threshold
7347
+ }
7348
+
7349
+ private fun traceIntAtLeast(value: Any?, threshold: Int): Boolean {
7350
+ return (value.asTraceInt() ?: Int.MIN_VALUE) >= threshold
7351
+ }
7352
+
7353
+ private fun Any?.asTraceLong(): Long? {
7354
+ return when (this) {
7355
+ is Number -> this.toLong()
7356
+ is String -> this.toLongOrNull()
7357
+ else -> null
7358
+ }
7359
+ }
7360
+
7361
+ private fun Any?.asTraceInt(): Int? {
7362
+ return when (this) {
7363
+ is Number -> this.toInt()
7364
+ is String -> this.toIntOrNull()
7365
+ else -> null
7366
+ }
7367
+ }
7368
+
7078
7369
  private fun summarizeOutgoingMessage(payload: String?): String {
7079
7370
  if (payload == null || payload.isEmpty()) {
7080
7371
  return "type=unknown, requestId=none, appId=none, transferMethod=none, bleImgId=none, exposureTimeNs=none, iso=none, mId=none"
@@ -8073,9 +8364,6 @@ class MentraLive : SGCManager() {
8073
8364
  // Send button photo settings
8074
8365
  sendButtonPhotoSettings()
8075
8366
 
8076
- // Send button camera LED setting
8077
- sendButtonCameraLedSetting()
8078
-
8079
8367
  // Send camera FOV setting (K900 / Mentra Live)
8080
8368
  sendCameraFovSetting()
8081
8369
 
@@ -8144,12 +8432,6 @@ class MentraLive : SGCManager() {
8144
8432
  )
8145
8433
  }
8146
8434
 
8147
- /** Send button camera LED setting to glasses */
8148
- override fun sendButtonCameraLedSetting() {
8149
- val enabled = DeviceStore.get("bluetooth", "button_camera_led") as Boolean
8150
- sendButtonCameraLedSetting(null, enabled)
8151
- }
8152
-
8153
8435
  /** Send camera FOV setting to glasses (K900 / Mentra Live). */
8154
8436
  override fun sendCameraFovSetting() {
8155
8437
  var fov = 118
@@ -8194,6 +8476,36 @@ class MentraLive : SGCManager() {
8194
8476
  }
8195
8477
  }
8196
8478
 
8479
+ /**
8480
+ * Send camera tuning config (ANR / gain) to the glasses via the {@code camera_tuning_config}
8481
+ * command. The ASG client relays this as a {@code camconfig} broadcast to the camera HAL.
8482
+ *
8483
+ * @param requestId optional request ID echoed in the settings_ack response
8484
+ * @param anrOn {@code true} = ANR enabled, {@code false} = ANR disabled
8485
+ * @param gainOn {@code true} = stock gain params, {@code false} = pixsmart gain-off params
8486
+ */
8487
+ fun sendCameraTuningConfig(requestId: String?, anrOn: Boolean, gainOn: Boolean) {
8488
+ Bridge.log("LIVE: Sending camera tuning config: anr=$anrOn, gain=$gainOn")
8489
+
8490
+ if (!isConnected) {
8491
+ Log.w(TAG, "Cannot send camera tuning config - not connected")
8492
+ return
8493
+ }
8494
+
8495
+ try {
8496
+ val json = JSONObject()
8497
+ json.put("type", "camera_tuning_config")
8498
+ if (!requestId.isNullOrEmpty()) {
8499
+ json.put("request_id", requestId)
8500
+ }
8501
+ json.put("anr", anrOn)
8502
+ json.put("gain", gainOn)
8503
+ sendJson(json, true)
8504
+ } catch (e: JSONException) {
8505
+ Log.e(TAG, "Error creating camera tuning config message", e)
8506
+ }
8507
+ }
8508
+
8197
8509
  /**
8198
8510
  * Send button max recording time to glasses Matches iOS MentraLive.swift
8199
8511
  * sendButtonMaxRecordingTime pattern
@@ -8228,17 +8540,15 @@ class MentraLive : SGCManager() {
8228
8540
  override fun startVideoRecording(
8229
8541
  requestId: String,
8230
8542
  save: Boolean,
8231
- flash: Boolean,
8232
8543
  sound: Boolean
8233
8544
  ) {
8234
- startVideoRecording(requestId, save, flash, sound, 0, 0, 0, 0) // Use defaults
8545
+ startVideoRecording(requestId, save, sound, 0, 0, 0, 0) // Use defaults
8235
8546
  }
8236
8547
 
8237
8548
  /**
8238
8549
  * Start video recording with optional resolution settings
8239
8550
  * @param requestId Request ID for tracking
8240
8551
  * @param save Whether to save the video
8241
- * @param flash Whether to enable privacy flash LED
8242
8552
  * @param sound Whether to enable start/stop sounds
8243
8553
  * @param width Video width (0 for default)
8244
8554
  * @param height Video height (0 for default)
@@ -8248,7 +8558,6 @@ class MentraLive : SGCManager() {
8248
8558
  override fun startVideoRecording(
8249
8559
  requestId: String,
8250
8560
  save: Boolean,
8251
- flash: Boolean,
8252
8561
  sound: Boolean,
8253
8562
  width: Int,
8254
8563
  height: Int,
@@ -8260,8 +8569,6 @@ class MentraLive : SGCManager() {
8260
8569
  requestId +
8261
8570
  ", save=" +
8262
8571
  save +
8263
- ", flash=" +
8264
- flash +
8265
8572
  ", sound=" +
8266
8573
  sound +
8267
8574
  ", resolution=" +
@@ -8285,7 +8592,6 @@ class MentraLive : SGCManager() {
8285
8592
  json.put("type", "start_video_recording")
8286
8593
  json.put("requestId", requestId)
8287
8594
  json.put("save", save)
8288
- json.put("flash", flash)
8289
8595
  json.put("sound", sound)
8290
8596
 
8291
8597
  // Auto-stop timer; only sent when set (> 0). 0 = record until stopped.