@mentra/bluetooth-sdk 0.1.14 → 0.1.17

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 (67) hide show
  1. package/README.md +4 -6
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +22 -9
  3. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +5 -20
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +358 -261
  5. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +3 -3
  6. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +133 -52
  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 +22 -6
  9. package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +1 -22
  10. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +50 -3
  11. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +1184 -795
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +422 -78
  13. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +3 -4
  14. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +2532 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +15 -1
  16. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +5 -3
  17. package/android/src/main/java/com/mentra/bluetoothsdk/utils/Constants.kt +2 -1
  18. package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +60 -1
  19. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoProtocolTest.kt +333 -0
  20. package/build/BluetoothSdk.types.d.ts +39 -14
  21. package/build/BluetoothSdk.types.d.ts.map +1 -1
  22. package/build/BluetoothSdk.types.js +1 -0
  23. package/build/BluetoothSdk.types.js.map +1 -1
  24. package/build/_internal.d.ts +4 -4
  25. package/build/_internal.js +4 -4
  26. package/build/_internal.js.map +1 -1
  27. package/build/_private/BluetoothSdkModule.d.ts +11 -3
  28. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  29. package/build/_private/BluetoothSdkModule.js +5 -0
  30. package/build/_private/BluetoothSdkModule.js.map +1 -1
  31. package/build/_private/cameraRequestPayload.d.ts +4 -0
  32. package/build/_private/cameraRequestPayload.d.ts.map +1 -0
  33. package/build/_private/cameraRequestPayload.js +25 -0
  34. package/build/_private/cameraRequestPayload.js.map +1 -0
  35. package/build/_private/photoRequestPayload.d.ts.map +1 -1
  36. package/build/_private/photoRequestPayload.js +8 -2
  37. package/build/_private/photoRequestPayload.js.map +1 -1
  38. package/build/index.d.ts +1 -1
  39. package/build/index.d.ts.map +1 -1
  40. package/build/index.js +72 -54
  41. package/build/index.js.map +1 -1
  42. package/ios/BluetoothSdkModule.swift +40 -12
  43. package/ios/Source/BluetoothSdkDefaults.swift +2 -30
  44. package/ios/Source/Bridge.swift +4 -16
  45. package/ios/Source/DeviceManager.swift +72 -12
  46. package/ios/Source/MentraBluetoothSDK.swift +120 -50
  47. package/ios/Source/OtaManifest.swift +13 -13
  48. package/ios/Source/camera/CameraModels.swift +75 -9
  49. package/ios/Source/errors/{BluetoothError.swift → BluetoothSdkError.swift} +1 -1
  50. package/ios/Source/events/BluetoothEvents.swift +5 -27
  51. package/ios/Source/internal/BluetoothAvailability.swift +5 -5
  52. package/ios/Source/sgcs/G1.swift +51 -1
  53. package/ios/Source/sgcs/G2.swift +643 -203
  54. package/ios/Source/sgcs/MentraLive.swift +53 -39
  55. package/ios/Source/sgcs/Nimo.swift +1928 -0
  56. package/ios/Source/sgcs/SGCManager.swift +12 -5
  57. package/ios/Source/stt/STTTools.swift +1 -1
  58. package/ios/Source/types/DeviceModels.swift +5 -0
  59. package/ios/Source/utils/Constants.swift +2 -0
  60. package/package.json +9 -1
  61. package/scripts/inject-ios-sdk-version.mjs +65 -0
  62. package/src/BluetoothSdk.types.ts +42 -17
  63. package/src/_internal.ts +4 -4
  64. package/src/_private/BluetoothSdkModule.ts +18 -2
  65. package/src/_private/cameraRequestPayload.ts +29 -0
  66. package/src/_private/photoRequestPayload.ts +9 -2
  67. package/src/index.ts +76 -58
@@ -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,7 +5278,6 @@ 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
@@ -5215,8 +5290,6 @@ class MentraLive : SGCManager() {
5215
5290
  Bridge.log(
5216
5291
  "LIVE: Requesting photo: " +
5217
5292
  requestId +
5218
- " for app: " +
5219
- appId +
5220
5293
  " with size: " +
5221
5294
  size +
5222
5295
  ", webhookUrl: " +
@@ -5240,16 +5313,13 @@ class MentraLive : SGCManager() {
5240
5313
  )
5241
5314
  Bridge.log(
5242
5315
  "LIVE: PHOTO PIPELINE [5/6] requestPhoto() entry — requestId=" +
5243
- requestId +
5244
- ", appId=" +
5245
- appId
5316
+ requestId
5246
5317
  )
5247
5318
 
5248
5319
  try {
5249
5320
  val json = JSONObject()
5250
5321
  json.put("type", "take_photo")
5251
5322
  json.put("requestId", requestId)
5252
- json.put("appId", appId)
5253
5323
  if (webhookUrl != null && !webhookUrl.isEmpty()) {
5254
5324
  json.put("webhookUrl", webhookUrl)
5255
5325
  }
@@ -5311,6 +5381,37 @@ class MentraLive : SGCManager() {
5311
5381
  }
5312
5382
  }
5313
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
+
5314
5415
  override fun startStream(message: MutableMap<String, Any>) {
5315
5416
  Bridge.log("LIVE: Starting RTMP stream")
5316
5417
 
@@ -6953,6 +7054,7 @@ class MentraLive : SGCManager() {
6953
7054
 
6954
7055
  try {
6955
7056
  val outgoingSummary = summarizeOutgoingMessage(data)
7057
+ val commandTraceInfo = parseOutgoingBleCommandTraceInfo(data)
6956
7058
  val isPhotoRequest = outgoingSummary.contains("type=take_photo")
6957
7059
  if (isPhotoRequest) {
6958
7060
  Bridge.log(
@@ -7010,11 +7112,34 @@ class MentraLive : SGCManager() {
7010
7112
  val packedData =
7011
7113
  K900ProtocolUtils.packJsonToK900(
7012
7114
  chunkStr,
7013
- wakeup && i == 0
7014
- ) // 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
+ )
7015
7140
 
7016
7141
  // Queue the chunk for sending
7017
- queueData(packedData)
7142
+ queueData(packedData, trace)
7018
7143
 
7019
7144
  // Add small delay between chunks to avoid overwhelming the connection
7020
7145
  if (i < chunks.size - 1) {
@@ -7036,9 +7161,25 @@ class MentraLive : SGCManager() {
7036
7161
 
7037
7162
  // Pack the data using the centralized utility
7038
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
+ )
7039
7180
 
7040
7181
  // Queue the data for sending
7041
- queueData(packedData)
7182
+ queueData(packedData, trace)
7042
7183
  if (isPhotoRequest) {
7043
7184
  Bridge.log(
7044
7185
  "LIVE: PHOTO PIPELINE BLE handoff — packedLen=" +
@@ -7052,6 +7193,179 @@ class MentraLive : SGCManager() {
7052
7193
  }
7053
7194
  }
7054
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
+
7055
7369
  private fun summarizeOutgoingMessage(payload: String?): String {
7056
7370
  if (payload == null || payload.isEmpty()) {
7057
7371
  return "type=unknown, requestId=none, appId=none, transferMethod=none, bleImgId=none, exposureTimeNs=none, iso=none, mId=none"
@@ -8162,6 +8476,36 @@ class MentraLive : SGCManager() {
8162
8476
  }
8163
8477
  }
8164
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
+
8165
8509
  /**
8166
8510
  * Send button max recording time to glasses Matches iOS MentraLive.swift
8167
8511
  * sendButtonMaxRecordingTime pattern