@mentra/bluetooth-sdk 0.1.17 → 0.1.19

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 (57) hide show
  1. package/README.md +1 -1
  2. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +52 -4
  3. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +119 -16
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +0 -6
  5. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +22 -21
  6. package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +1 -1
  7. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +488 -15
  8. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +457 -31
  9. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLiveL2capChannel.kt +274 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +361 -67
  11. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraosBle.java +7501 -3704
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +134 -0
  13. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +0 -6
  14. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +493 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadService.java +4 -1
  16. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleWireProtocol.java +108 -0
  17. package/android/src/main/java/com/mentra/bluetoothsdk/utils/IncidentLogBleUploadService.java +4 -3
  18. package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900LengthCodec.java +115 -0
  19. package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +103 -68
  20. package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunkReassembler.java +106 -0
  21. package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunker.java +93 -0
  22. package/android/src/main/java/com/mentra/bluetoothsdk/utils/NexSGCUtils.kt +111 -11
  23. package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +7 -9
  24. package/android/src/test/java/com/mentra/bluetoothsdk/utils/AvifExifStripperTest.java +17 -1
  25. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BinaryMessageChunkerTest.java +84 -0
  26. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +158 -0
  27. package/android/src/test/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtilsEndiannessTest.java +172 -0
  28. package/android/src/test/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtilsTest.java +24 -0
  29. package/build/BluetoothSdk.types.d.ts +12 -1
  30. package/build/BluetoothSdk.types.d.ts.map +1 -1
  31. package/build/BluetoothSdk.types.js.map +1 -1
  32. package/build/types/index.d.ts +3 -0
  33. package/build/types/index.d.ts.map +1 -0
  34. package/build/types/index.js +2 -0
  35. package/build/types/index.js.map +1 -0
  36. package/ios/LocalPhotoUploadServer.swift +78 -0
  37. package/ios/MentraPhotoReceiverModule.swift +10 -0
  38. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  39. package/ios/Source/Bridge.swift +63 -0
  40. package/ios/Source/DeviceManager.swift +134 -29
  41. package/ios/Source/DeviceStore.swift +0 -5
  42. package/ios/Source/MentraBluetoothSDK.swift +26 -23
  43. package/ios/Source/OtaManifest.swift +1 -1
  44. package/ios/Source/internal/BleTraceLogger.swift +192 -0
  45. package/ios/Source/sgcs/G2.swift +518 -11
  46. package/ios/Source/sgcs/MentraLive.swift +607 -39
  47. package/ios/Source/sgcs/MentraNex.swift +416 -32
  48. package/ios/Source/sgcs/SGCManager.swift +155 -0
  49. package/ios/Source/sgcs/mentraos_ble.pb.swift +726 -122
  50. package/ios/Source/status/DeviceStatus.swift +0 -8
  51. package/ios/Source/utils/BleJsonCompact.swift +395 -0
  52. package/ios/Source/utils/BleWireProtocol.swift +92 -0
  53. package/ios/Source/utils/MessageChunkReassembler.swift +82 -0
  54. package/ios/Source/utils/MessageChunker.swift +73 -0
  55. package/package.json +14 -7
  56. package/src/BluetoothSdk.types.ts +13 -1
  57. package/src/types/index.ts +7 -0
@@ -41,6 +41,9 @@ import com.mentra.bluetoothsdk.utils.ConnTypes
41
41
  import com.mentra.bluetoothsdk.utils.DeviceTypes
42
42
  import com.mentra.bluetoothsdk.utils.IncidentLogBleRelayNaming
43
43
  import com.mentra.bluetoothsdk.utils.IncidentLogBleUploadService
44
+ import com.mentra.bluetoothsdk.utils.BleJsonCompact
45
+ import com.mentra.bluetoothsdk.utils.BleWireProtocol
46
+ import com.mentra.bluetoothsdk.utils.K900LengthCodec
44
47
  import com.mentra.bluetoothsdk.utils.K900ProtocolUtils
45
48
  import com.mentra.bluetoothsdk.utils.MessageChunkReassembler
46
49
  import com.mentra.bluetoothsdk.utils.MessageChunker
@@ -90,6 +93,12 @@ class MentraLive : SGCManager() {
90
93
  // LC3 frame size for Mentra Live
91
94
  private const val LC3_FRAME_SIZE = 40
92
95
  private const val VOICE_ACTIVITY_DETECTION_SWITCH_TYPE = 8
96
+ private const val BES2700_MTU_LIMIT = 509
97
+
98
+ // L2CAP CoC fast path: new BES2700 firmware registers an LE L2CAP CoC server on this
99
+ // PSM. When the phone opens the channel, the glasses send file packets over it instead
100
+ // of GATT FILE_READ notifications. Phone→glasses traffic stays on GATT.
101
+ private const val L2CAP_FILE_PSM = 0x00C9
93
102
 
94
103
  // BLE UUIDs - updated to match K900 BES2800 MCU UUIDs for compatibility with both glass
95
104
  // types
@@ -199,6 +208,14 @@ class MentraLive : SGCManager() {
199
208
 
200
209
  // Local-only fields (not in parent SGCManager)
201
210
  private var buildNumberInt = 0 // Build number as integer for version checks
211
+ private var peerWireProtocolVersion = 0
212
+ private var useBinaryWireProtocol = false
213
+ private var wireHandshakeQueued = false
214
+ // Negotiated K900 STRING length endianness for the phone<->glasses BLE link. Defaults to
215
+ // legacy big-endian; upgraded to little-endian only when the glasses advertise wire_caps.k900_le
216
+ // (or a v2 binary handshake succeeds, which implies wire-v2 LE).
217
+ private var peerK900Le = false
218
+ private var peerWireCapsBinary = false
202
219
  // Note: appVersion, buildNumber, deviceModel, androidVersion
203
220
  // are inherited from SGCManager parent class
204
221
 
@@ -319,6 +336,12 @@ class MentraLive : SGCManager() {
319
336
  private var fileReadNotificationCount = 0 // Debug counter for FILE_READ notifications
320
337
  private val incomingChunkReassembler = MessageChunkReassembler()
321
338
 
339
+ // L2CAP CoC fast path for incoming file transfers (see L2CAP_FILE_PSM).
340
+ // The channel is read-only; all outgoing messages remain on GATT. When it can't be
341
+ // opened (older firmware, Android < 10), GATT notifications remain the file path.
342
+ private val enableL2capFilePath = true
343
+ private var l2capFileChannel: MentraLiveL2capChannel? = null
344
+
322
345
  private val connectionLock = Any()
323
346
 
324
347
  private var glassesMediaVolumeTimeoutRunnable: Runnable? = null
@@ -752,6 +775,12 @@ class MentraLive : SGCManager() {
752
775
  if (isEqual) {
753
776
  if (state == ConnTypes.DISCONNECTED) {
754
777
  incomingChunkReassembler.clear()
778
+ peerWireProtocolVersion = 0
779
+ useBinaryWireProtocol = false
780
+ wireHandshakeQueued = false
781
+ peerK900Le = false
782
+ peerWireCapsBinary = false
783
+ BleJsonCompact.resetSession()
755
784
  }
756
785
  return
757
786
  }
@@ -780,6 +809,12 @@ class MentraLive : SGCManager() {
780
809
  DeviceStore.apply("glasses", "signalStrength", -1)
781
810
  DeviceStore.apply("glasses", "signalStrengthUpdatedAt", 0L)
782
811
  incomingChunkReassembler.clear()
812
+ peerWireProtocolVersion = 0
813
+ useBinaryWireProtocol = false
814
+ wireHandshakeQueued = false
815
+ peerK900Le = false
816
+ peerWireCapsBinary = false
817
+ BleJsonCompact.resetSession()
783
818
  // Drop OTA caches when fully disconnected — avoids leaking session/step state
784
819
  // from a previous pairing into the next one.
785
820
  resetOtaCache()
@@ -1427,6 +1462,26 @@ class MentraLive : SGCManager() {
1427
1462
  isConnecting = false
1428
1463
  isConnected = true
1429
1464
  connectedDevice = gatt.device
1465
+
1466
+ // High-priority connection interval (~11-15ms vs 30-50ms
1467
+ // default) and 2M PHY, as MentraNex and G2 already do. The
1468
+ // BES firmware only *prefers* 2M PHY; the central has to
1469
+ // request the switch or the link stays on 1M.
1470
+ try {
1471
+ gatt.requestConnectionPriority(
1472
+ BluetoothGatt.CONNECTION_PRIORITY_HIGH
1473
+ )
1474
+ gatt.setPreferredPhy(
1475
+ BluetoothDevice.PHY_LE_2M_MASK,
1476
+ BluetoothDevice.PHY_LE_2M_MASK,
1477
+ BluetoothDevice.PHY_OPTION_NO_PREFERRED
1478
+ )
1479
+ } catch (e: SecurityException) {
1480
+ Bridge.log(
1481
+ "LIVE: requestConnectionPriority/setPreferredPhy denied: ${e.message}"
1482
+ )
1483
+ }
1484
+
1430
1485
  DeviceStore.apply("glasses", "bluetoothName", connectedDevice!!.name)
1431
1486
  // Persist MAC so reconnection can use direct GATT instead of scanning
1432
1487
  if (connectedDevice!!.address != null) {
@@ -1523,6 +1578,9 @@ class MentraLive : SGCManager() {
1523
1578
  // Stop micbeat mechanism
1524
1579
  stopMicBeat()
1525
1580
 
1581
+ // Close the L2CAP file channel (if the fast path was open)
1582
+ closeL2capFileChannel()
1583
+
1526
1584
  // Clean up GATT resources
1527
1585
  closeGattQuietly(false)
1528
1586
 
@@ -1575,6 +1633,9 @@ class MentraLive : SGCManager() {
1575
1633
  // Stop micbeat mechanism
1576
1634
  stopMicBeat()
1577
1635
 
1636
+ // Close the L2CAP file channel (if the fast path was open)
1637
+ closeL2capFileChannel()
1638
+
1578
1639
  // Clean up resources
1579
1640
  closeGattQuietly(false)
1580
1641
 
@@ -2780,6 +2841,49 @@ class MentraLive : SGCManager() {
2780
2841
  synchronized(filePacketBufferLock) { filePacketBufferSize = 0 }
2781
2842
  }
2782
2843
 
2844
+ /**
2845
+ * Open the L2CAP CoC fast path for incoming file transfers (PSM 0x00C9).
2846
+ *
2847
+ * New BES2700 firmware registers an LE L2CAP CoC server; when the phone opens the channel,
2848
+ * the glasses send K900 file packets over it instead of GATT FILE_READ notifications.
2849
+ * Complete frames from the channel are fed into processFilePacketData — the exact same entry
2850
+ * point used by GATT FILE_READ notifications — so downstream reassembly (FileTransferSession,
2851
+ * transfer_complete) is unchanged. On any failure to open we stay on GATT notifications.
2852
+ */
2853
+ private fun openL2capFileChannel() {
2854
+ if (!enableL2capFilePath) {
2855
+ return
2856
+ }
2857
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
2858
+ // createInsecureL2capChannel requires API 29
2859
+ Bridge.log("LIVE: L2CAP: unavailable, staying on GATT (requires Android 10+)")
2860
+ return
2861
+ }
2862
+ if (l2capFileChannel != null) {
2863
+ // Already open (or connecting) for this connection — e.g. repeated glasses_ready
2864
+ return
2865
+ }
2866
+ val device = connectedDevice
2867
+ if (device == null) {
2868
+ Log.w(TAG, "L2CAP: no connected device, staying on GATT")
2869
+ return
2870
+ }
2871
+ val channel =
2872
+ MentraLiveL2capChannel(L2CAP_FILE_PSM) { frame ->
2873
+ // Same code path as a GATT FILE_READ notification carrying this frame
2874
+ processFilePacketData(frame)
2875
+ }
2876
+ l2capFileChannel = channel
2877
+ channel.open(device)
2878
+ }
2879
+
2880
+ /** Close the L2CAP file channel if open (call on disconnect). */
2881
+ private fun closeL2capFileChannel() {
2882
+ val channel = l2capFileChannel ?: return
2883
+ l2capFileChannel = null
2884
+ channel.close()
2885
+ }
2886
+
2783
2887
  /** Process data received from the glasses */
2784
2888
  private fun processReceivedData(data: ByteArray?, size: Int) {
2785
2889
  // Bridge.log("LIVE: Processing received data: " + bytesToHex(data));
@@ -2847,10 +2951,26 @@ class MentraLive : SGCManager() {
2847
2951
  return // Exit after processing file packet
2848
2952
  }
2849
2953
 
2954
+ if (cmdType == K900ProtocolUtils.CMD_TYPE_BINARY_MSG) {
2955
+ processBinaryWireFrame(data)
2956
+ return
2957
+ }
2958
+
2959
+ // Learn the glasses' K900 length endianness from the frame we just received so future
2960
+ // outbound frames match. Guards against a peer that never advertises wire_caps.
2961
+ K900LengthCodec.detectLength(data)?.let { detected ->
2962
+ peerK900Le = detected.endian == K900LengthCodec.Endian.LE
2963
+ }
2964
+
2850
2965
  // Otherwise it's a normal JSON message
2851
2966
  val json = K900ProtocolUtils.processReceivedBytesToJson(data)
2852
2967
  if (json != null) {
2853
- processJsonMessage(json)
2968
+ val expanded = expandCompactWireJson(json)
2969
+ if (expanded == null) {
2970
+ Log.w(TAG, "Thread-$threadId: Rejected unsupported compact wire form")
2971
+ return
2972
+ }
2973
+ processJsonMessage(expanded)
2854
2974
  } else {
2855
2975
  Log.w(TAG, "Thread-" + threadId + ": Failed to parse K900 protocol data")
2856
2976
  // #region agent log [810da2] Hypothesis A+B: log header-declared length vs actual
@@ -3035,10 +3155,12 @@ class MentraLive : SGCManager() {
3035
3155
  )
3036
3156
  "speaking_status" -> handleSpeakingStatus(json.optBoolean("speaking", false))
3037
3157
  "battery_status" -> {
3038
- // Process battery status
3158
+ // Percent only. battery_status derives from hm_batv, which carries no charge
3159
+ // bit — older glasses fabricate `charging` from a voltage threshold (>3.9V)
3160
+ // that reads "charging" for most of a discharging pack's range. Charging
3161
+ // state comes exclusively from the PMU charg bit in the sr_hrt heartbeat.
3039
3162
  val percent = json.optInt("percent", batteryLevel)
3040
- val charging = json.optBoolean("charging", isCharging)
3041
- updateBatteryStatus(percent, charging)
3163
+ updateBatteryStatus(percent, isCharging)
3042
3164
  }
3043
3165
  "pong" ->
3044
3166
  // Process heartbeat pong response
@@ -3057,6 +3179,18 @@ class MentraLive : SGCManager() {
3057
3179
  val ssid = json.optString("ssid", "")
3058
3180
  val localIp = json.optString("local_ip", "")
3059
3181
 
3182
+ // Provisioning failure reason (e.g. connect_timeout). Sticky: routine
3183
+ // error-less status updates (link-state debounce, request_wifi_status)
3184
+ // must not clear a failure nothing recovered from — only a newer error
3185
+ // or a successful connection overwrites it.
3186
+ val wifiError = json.optString("error", "")
3187
+ if (wifiError.isNotEmpty()) {
3188
+ Bridge.log("LIVE: 🌐 WiFi provisioning error from glasses: $wifiError")
3189
+ DeviceStore.apply("glasses", "wifiError", wifiError)
3190
+ } else if (wifiConnectedStatus) {
3191
+ DeviceStore.apply("glasses", "wifiError", "")
3192
+ }
3193
+
3060
3194
  updateWifiStatus(wifiConnectedStatus, ssid, localIp)
3061
3195
  }
3062
3196
  "hotspot_status_update" -> {
@@ -3413,6 +3547,9 @@ class MentraLive : SGCManager() {
3413
3547
  // Glasses SOC has booted and is ready for communication
3414
3548
  Bridge.log("LIVE: 🎉 Received glasses_ready message - SOC is booted and ready!")
3415
3549
 
3550
+ // Negotiate wire capabilities advertised in the glasses_ready handshake.
3551
+ parsePeerWireCaps(json)
3552
+
3416
3553
  // Set the ready flag to stop any future readiness checks
3417
3554
  glassesReady = true
3418
3555
  glassesReadyReceived = true
@@ -3423,11 +3560,16 @@ class MentraLive : SGCManager() {
3423
3560
  // Stop the readiness check loop since we got confirmation
3424
3561
  stopReadinessCheckLoop()
3425
3562
 
3563
+ // Try to open the L2CAP CoC fast path for file transfers. No-op when the
3564
+ // firmware doesn't support it — GATT notifications remain the default path.
3565
+ try {
3566
+ openL2capFileChannel()
3567
+ } catch (t: Throwable) {
3568
+ Bridge.log("LIVE: ⚠️ glasses_ready: openL2capFileChannel threw: " + t)
3569
+ }
3570
+
3426
3571
  // Send BLE MTU config to glasses so they can adjust file packet sizes.
3427
- // Use the minimum of negotiated MTU and BES2700's known limit (256).
3428
- // BES2700 chip often ignores higher negotiated MTUs and truncates to 253 bytes,
3429
- // but we should respect the actual negotiated value if it's lower.
3430
- val BES2700_MTU_LIMIT = 256 // BES2700's known notification size limit
3572
+ // Use the minimum of negotiated MTU and BES2700's datapath limit (509).
3431
3573
  val effectiveMtu = Math.min(currentMtu, BES2700_MTU_LIMIT)
3432
3574
  Bridge.log(
3433
3575
  "LIVE: 📦 Sending BLE MTU config: negotiated=" +
@@ -3607,6 +3749,8 @@ class MentraLive : SGCManager() {
3607
3749
  buildNumberInt = 0
3608
3750
  Log.e(TAG, "Failed to parse build number as integer: " + buildNumberLegacy)
3609
3751
  }
3752
+ parsePeerWireCaps(json)
3753
+ maybeSendWireHandshake()
3610
3754
  }
3611
3755
  "ota_download_progress" -> {
3612
3756
  // Process OTA download progress from ASG client
@@ -3792,8 +3936,12 @@ class MentraLive : SGCManager() {
3792
3936
  // Parse build number as integer for version checks
3793
3937
  try {
3794
3938
  val buildNumInt = Integer.parseInt(buildNum)
3939
+ buildNumberInt = buildNumInt
3795
3940
  Bridge.log("LIVE: Parsed build number as integer: " + buildNumInt)
3941
+ parsePeerWireCaps(json)
3942
+ maybeSendWireHandshake()
3796
3943
  } catch (e: NumberFormatException) {
3944
+ buildNumberInt = 0
3797
3945
  Log.e(TAG, "Failed to parse build number as integer: " + buildNum)
3798
3946
  }
3799
3947
  }
@@ -4253,11 +4401,10 @@ class MentraLive : SGCManager() {
4253
4401
  "%"
4254
4402
  )
4255
4403
 
4256
- // Determine charging status based on voltage (K900 typical charging voltage
4257
- // is >4.0V)
4258
- val isCharging = voltageMillivolts > 4000
4259
-
4260
- // Update battery status using the existing method
4404
+ // Percent only. sr_batv carries just voltage+percent; inferring
4405
+ // charging from voltage (>4.0V) reads "not charging" for most of a
4406
+ // genuinely-charging pack's range. Charging state comes exclusively
4407
+ // from the PMU charg bit in the sr_hrt heartbeat.
4261
4408
  updateBatteryStatus(batteryPercentage, isCharging)
4262
4409
  }
4263
4410
  } catch (e: Exception) {
@@ -4561,6 +4708,11 @@ class MentraLive : SGCManager() {
4561
4708
  * pattern
4562
4709
  */
4563
4710
  private fun updateBatteryStatus(level: Int, isCharging: Boolean) {
4711
+ // Keep the field in sync: percent-only messages (battery_status/sr_batv) re-pass
4712
+ // it as the last-known charging state, so a stale field would clobber the value
4713
+ // the sr_hrt PMU charg bit established.
4714
+ this.isCharging = isCharging
4715
+
4564
4716
  // Update parent SGCManager fields
4565
4717
  DeviceStore.apply("glasses", "batteryLevel", level)
4566
4718
  DeviceStore.apply("glasses", "charging", isCharging)
@@ -6043,6 +6195,9 @@ class MentraLive : SGCManager() {
6043
6195
  // sendRgbLedControlAuthority(false);
6044
6196
  // }
6045
6197
 
6198
+ // Close the L2CAP file channel (if the fast path was open)
6199
+ closeL2capFileChannel()
6200
+
6046
6201
  // Disconnect from GATT if connected
6047
6202
  closeGattQuietly(true)
6048
6203
 
@@ -6366,7 +6521,8 @@ class MentraLive : SGCManager() {
6366
6521
  val packedData =
6367
6522
  K900ProtocolUtils.packDataToK900(
6368
6523
  jsonStr.toByteArray(StandardCharsets.UTF_8),
6369
- K900ProtocolUtils.CMD_TYPE_STRING
6524
+ K900ProtocolUtils.CMD_TYPE_STRING,
6525
+ k900LengthEndian()
6370
6526
  )
6371
6527
 
6372
6528
  queueData(packedData)
@@ -6550,7 +6706,8 @@ class MentraLive : SGCManager() {
6550
6706
  val packedData =
6551
6707
  K900ProtocolUtils.packDataToK900(
6552
6708
  jsonStr.toByteArray(StandardCharsets.UTF_8),
6553
- K900ProtocolUtils.CMD_TYPE_STRING
6709
+ K900ProtocolUtils.CMD_TYPE_STRING,
6710
+ k900LengthEndian()
6554
6711
  )
6555
6712
  queueData(packedData)
6556
6713
  } catch (e: JSONException) {
@@ -6579,7 +6736,8 @@ class MentraLive : SGCManager() {
6579
6736
  val packedData =
6580
6737
  K900ProtocolUtils.packDataToK900(
6581
6738
  jsonStr.toByteArray(StandardCharsets.UTF_8),
6582
- K900ProtocolUtils.CMD_TYPE_STRING
6739
+ K900ProtocolUtils.CMD_TYPE_STRING,
6740
+ k900LengthEndian()
6583
6741
  )
6584
6742
  queueData(packedData)
6585
6743
  } catch (e: JSONException) {
@@ -6653,7 +6811,8 @@ class MentraLive : SGCManager() {
6653
6811
  val packedData =
6654
6812
  K900ProtocolUtils.packDataToK900(
6655
6813
  jsonStr.toByteArray(StandardCharsets.UTF_8),
6656
- K900ProtocolUtils.CMD_TYPE_STRING
6814
+ K900ProtocolUtils.CMD_TYPE_STRING,
6815
+ k900LengthEndian()
6657
6816
  )
6658
6817
  Bridge.log("LIVE: AUDIO: Sending cs_getvol command: " + jsonStr)
6659
6818
  queueData(packedData)
@@ -6677,7 +6836,8 @@ class MentraLive : SGCManager() {
6677
6836
  val packedData =
6678
6837
  K900ProtocolUtils.packDataToK900(
6679
6838
  jsonStr.toByteArray(StandardCharsets.UTF_8),
6680
- K900ProtocolUtils.CMD_TYPE_STRING
6839
+ K900ProtocolUtils.CMD_TYPE_STRING,
6840
+ k900LengthEndian()
6681
6841
  )
6682
6842
  queueData(packedData)
6683
6843
  return true
@@ -7039,6 +7199,168 @@ class MentraLive : SGCManager() {
7039
7199
  }
7040
7200
  }
7041
7201
 
7202
+ /** Negotiated K900 STRING length endianness for outbound frames to the glasses. */
7203
+ private fun k900LengthEndian(): K900LengthCodec.Endian =
7204
+ if (peerK900Le) K900LengthCodec.Endian.LE else K900LengthCodec.Endian.BE
7205
+
7206
+ /**
7207
+ * Parse a {@code wire_caps} object from a version_info/glasses_ready message and update the
7208
+ * negotiated per-link endianness and binary support. Missing wire_caps leaves the legacy
7209
+ * defaults (BE, no binary) untouched so older glasses keep working.
7210
+ */
7211
+ private fun parsePeerWireCaps(json: JSONObject) {
7212
+ val caps = json.optJSONObject("wire_caps") ?: return
7213
+ if (caps.optBoolean("k900_le", false)) {
7214
+ if (!peerK900Le) {
7215
+ peerK900Le = true
7216
+ Bridge.log("LIVE: wire_caps negotiated k900 endian=LE")
7217
+ }
7218
+ }
7219
+ if (caps.has("binary")) {
7220
+ peerWireCapsBinary = caps.optBoolean("binary", false)
7221
+ }
7222
+ }
7223
+
7224
+ private fun maybeSendWireHandshake() {
7225
+ // Only attempt the v2 binary handshake once the glasses have advertised binary support via
7226
+ // wire_caps. Older builds that report build>=5 but lack wire_caps stay on the legacy path.
7227
+ if (buildNumberInt < 5 ||
7228
+ !peerWireCapsBinary ||
7229
+ wireHandshakeQueued ||
7230
+ peerWireProtocolVersion >= BleWireProtocol.PROTOCOL_V2
7231
+ ) {
7232
+ return
7233
+ }
7234
+ sendWireHandshake()
7235
+ }
7236
+
7237
+ private fun sendWireHandshake() {
7238
+ if (buildNumberInt < 5) {
7239
+ return
7240
+ }
7241
+ try {
7242
+ val payload = BleWireProtocol.HANDSHAKE_PAYLOAD_V2.toByteArray(StandardCharsets.UTF_8)
7243
+ var flags = BleWireProtocol.BLE_WIRE_FLAG_HANDSHAKE.toInt()
7244
+ flags = flags or BleWireProtocol.BLE_WIRE_FLAG_FIRST_FRAG.toInt()
7245
+ flags = flags or BleWireProtocol.BLE_WIRE_FLAG_LAST_FRAG.toInt()
7246
+ val packed =
7247
+ K900ProtocolUtils.packBinaryFragment(
7248
+ flags.toByte(),
7249
+ 0,
7250
+ 0,
7251
+ 1,
7252
+ payload
7253
+ )
7254
+ Bridge.log("LIVE: Sending BLE wire v2 handshake")
7255
+ wireHandshakeQueued = true
7256
+ queueData(packed, null)
7257
+ } catch (e: Exception) {
7258
+ Log.e(TAG, "Failed to send wire handshake", e)
7259
+ }
7260
+ }
7261
+
7262
+ private fun activateBinaryWireV2Session(logMessage: String) {
7263
+ peerWireProtocolVersion = BleWireProtocol.PROTOCOL_V2
7264
+ useBinaryWireProtocol = true
7265
+ wireHandshakeQueued = false
7266
+ peerK900Le = true
7267
+ BleJsonCompact.markSessionConnected(System.currentTimeMillis())
7268
+ Bridge.log(logMessage)
7269
+ }
7270
+
7271
+ private fun handlePeerWireHandshake() {
7272
+ activateBinaryWireV2Session("LIVE: Peer confirmed BLE wire protocol v2")
7273
+ }
7274
+
7275
+ private fun processBinaryWireFrame(data: ByteArray) {
7276
+ val info = K900ProtocolUtils.extractBinaryFragmentInfo(data) ?: run {
7277
+ Log.w(TAG, "Failed to parse binary wire frame")
7278
+ return
7279
+ }
7280
+
7281
+ if (BleWireProtocol.isHandshakeV2(info)) {
7282
+ handlePeerWireHandshake()
7283
+ return
7284
+ }
7285
+
7286
+ if (!useBinaryWireProtocol && buildNumberInt >= 5) {
7287
+ activateBinaryWireV2Session(
7288
+ "LIVE: Auto-enabled BLE wire v2 from incoming binary frame"
7289
+ )
7290
+ }
7291
+
7292
+ val reassembled =
7293
+ incomingChunkReassembler.addBinaryFragment(
7294
+ info.msgId,
7295
+ info.fragIdx,
7296
+ info.fragCount,
7297
+ info.payload
7298
+ )
7299
+ ?: return
7300
+
7301
+ try {
7302
+ val jsonStr = String(reassembled, StandardCharsets.UTF_8)
7303
+ val json = expandCompactWireJson(JSONObject(jsonStr))
7304
+ if (json == null) {
7305
+ Log.w(TAG, "Rejected unsupported compact reassembled wire form")
7306
+ return
7307
+ }
7308
+ logWireMetrics(
7309
+ reassembled.size,
7310
+ data.size,
7311
+ info.fragCount,
7312
+ BleWireProtocol.PROTOCOL_V2,
7313
+ "glasses_to_phone"
7314
+ )
7315
+ processJsonMessage(json)
7316
+ } catch (e: JSONException) {
7317
+ Log.e(TAG, "Failed to parse reassembled binary wire JSON", e)
7318
+ }
7319
+ }
7320
+
7321
+ private fun compactWireJson(json: JSONObject): JSONObject {
7322
+ if (!useBinaryWireProtocol || buildNumberInt < 5) {
7323
+ return json
7324
+ }
7325
+ return try {
7326
+ BleJsonCompact.encode(json)
7327
+ } catch (_: JSONException) {
7328
+ json
7329
+ }
7330
+ }
7331
+
7332
+ private fun expandCompactWireJson(json: JSONObject): JSONObject? {
7333
+ if (!useBinaryWireProtocol || buildNumberInt < 5) {
7334
+ return json
7335
+ }
7336
+ return try {
7337
+ BleJsonCompact.decodeIfSupported(json)
7338
+ } catch (_: JSONException) {
7339
+ json
7340
+ }
7341
+ }
7342
+
7343
+ private fun logWireMetrics(
7344
+ payloadBytes: Int,
7345
+ wireBytes: Int,
7346
+ packetCount: Int,
7347
+ protocolVersion: Int,
7348
+ direction: String
7349
+ ) {
7350
+ Bridge.log(
7351
+ "BLE_TRACE direction=" +
7352
+ direction +
7353
+ " proto=v" +
7354
+ protocolVersion +
7355
+ " payload=" +
7356
+ payloadBytes +
7357
+ " wire=" +
7358
+ wireBytes +
7359
+ " packets=" +
7360
+ packetCount
7361
+ )
7362
+ }
7363
+
7042
7364
  /**
7043
7365
  * Send data directly to the glasses using the K900 protocol utility. This method uses
7044
7366
  * K900ProtocolUtils.packJsonToK900 to handle C-wrapping and protocol formatting. Large messages
@@ -7052,8 +7374,19 @@ class MentraLive : SGCManager() {
7052
7374
  return
7053
7375
  }
7054
7376
 
7377
+ val wireData =
7378
+ try {
7379
+ if (useBinaryWireProtocol && buildNumberInt >= 5) {
7380
+ compactWireJson(JSONObject(data)).toString()
7381
+ } else {
7382
+ data
7383
+ }
7384
+ } catch (_: JSONException) {
7385
+ data
7386
+ }
7387
+
7055
7388
  try {
7056
- val outgoingSummary = summarizeOutgoingMessage(data)
7389
+ val outgoingSummary = summarizeOutgoingMessage(wireData)
7057
7390
  val commandTraceInfo = parseOutgoingBleCommandTraceInfo(data)
7058
7391
  val isPhotoRequest = outgoingSummary.contains("type=take_photo")
7059
7392
  if (isPhotoRequest) {
@@ -7065,10 +7398,15 @@ class MentraLive : SGCManager() {
7065
7398
  )
7066
7399
  }
7067
7400
 
7401
+ if (useBinaryWireProtocol && buildNumberInt >= 5) {
7402
+ sendDataToGlassesBinary(wireData, wakeup, commandTraceInfo, isPhotoRequest)
7403
+ return
7404
+ }
7405
+
7068
7406
  // First check if the message needs chunking
7069
7407
  // Create a test C-wrapped version to check size
7070
7408
  val testWrapper = JSONObject()
7071
- testWrapper.put("C", data)
7409
+ testWrapper.put("C", wireData)
7072
7410
  if (wakeup) {
7073
7411
  testWrapper.put("W", 1)
7074
7412
  }
@@ -7086,14 +7424,14 @@ class MentraLive : SGCManager() {
7086
7424
  // Extract message ID if present for ACK tracking
7087
7425
  var messageId = -1L
7088
7426
  try {
7089
- val originalJson = JSONObject(data)
7427
+ val originalJson = JSONObject(wireData)
7090
7428
  messageId = originalJson.optLong("mId", -1)
7091
7429
  } catch (e: JSONException) {
7092
7430
  // Not a JSON message or no mId, that's okay
7093
7431
  }
7094
7432
 
7095
7433
  // Create chunks
7096
- val chunks = MessageChunker.createChunks(data, messageId, wakeup)
7434
+ val chunks = MessageChunker.createChunks(wireData, messageId, wakeup)
7097
7435
  Bridge.log("LIVE: Sending " + chunks.size + " chunks")
7098
7436
  if (isPhotoRequest) {
7099
7437
  Bridge.log(
@@ -7108,11 +7446,12 @@ class MentraLive : SGCManager() {
7108
7446
  val chunk = chunks[i]
7109
7447
  val chunkStr = chunk.toString()
7110
7448
 
7111
- // Pack each chunk using the normal K900 protocol
7449
+ // Pack each chunk using the normal K900 protocol with the negotiated endianness
7112
7450
  val packedData =
7113
7451
  K900ProtocolUtils.packJsonToK900(
7114
7452
  chunkStr,
7115
- wakeup && i == 0
7453
+ wakeup && i == 0,
7454
+ k900LengthEndian()
7116
7455
  ) // Only wakeup on first chunk
7117
7456
 
7118
7457
  val trace =
@@ -7134,7 +7473,7 @@ class MentraLive : SGCManager() {
7134
7473
  mapOf(
7135
7474
  "chunkJsonBytes" to
7136
7475
  chunkStr.toByteArray(StandardCharsets.UTF_8).size,
7137
- "messageBytes" to data.toByteArray(StandardCharsets.UTF_8).size
7476
+ "messageBytes" to wireData.toByteArray(StandardCharsets.UTF_8).size
7138
7477
  )
7139
7478
  )
7140
7479
 
@@ -7157,17 +7496,18 @@ class MentraLive : SGCManager() {
7157
7496
  }
7158
7497
  } else {
7159
7498
  // Normal single message transmission
7160
- Bridge.log("LIVE: Sending data to glasses: " + data)
7499
+ Bridge.log("LIVE: Sending data to glasses: " + wireData)
7161
7500
 
7162
- // Pack the data using the centralized utility
7163
- val packedData = K900ProtocolUtils.packJsonToK900(data, wakeup)
7501
+ // Pack the data using the centralized utility with the negotiated endianness
7502
+ val packedData =
7503
+ K900ProtocolUtils.packJsonToK900(wireData, wakeup, k900LengthEndian())
7164
7504
  val trace =
7165
7505
  createBleWriteTrace(
7166
7506
  commandTraceInfo,
7167
7507
  null,
7168
7508
  null,
7169
7509
  null,
7170
- data.toByteArray(StandardCharsets.UTF_8).size,
7510
+ wireData.toByteArray(StandardCharsets.UTF_8).size,
7171
7511
  packedData.size,
7172
7512
  wakeup,
7173
7513
  false
@@ -7175,7 +7515,7 @@ class MentraLive : SGCManager() {
7175
7515
  logBleChunkTrace(
7176
7516
  "created",
7177
7517
  trace,
7178
- mapOf("messageBytes" to data.toByteArray(StandardCharsets.UTF_8).size)
7518
+ mapOf("messageBytes" to wireData.toByteArray(StandardCharsets.UTF_8).size)
7179
7519
  )
7180
7520
 
7181
7521
  // Queue the data for sending
@@ -7193,6 +7533,91 @@ class MentraLive : SGCManager() {
7193
7533
  }
7194
7534
  }
7195
7535
 
7536
+ private fun sendDataToGlassesBinary(
7537
+ data: String,
7538
+ wakeup: Boolean,
7539
+ commandTraceInfo: OutgoingBleCommandTraceInfo,
7540
+ isPhotoRequest: Boolean
7541
+ ) {
7542
+ val payloadBytes = data.toByteArray(StandardCharsets.UTF_8)
7543
+ var messageId = 0
7544
+ var ackRequested = false
7545
+ try {
7546
+ val originalJson = JSONObject(data)
7547
+ messageId = (originalJson.optLong("mId", 0L) and 0xFFFFL).toInt()
7548
+ ackRequested = originalJson.has("mId")
7549
+ } catch (_: JSONException) {
7550
+ // Raw non-JSON payloads are still sent as a single binary fragment.
7551
+ }
7552
+
7553
+ val fragments =
7554
+ MessageChunker.createBinaryFragments(
7555
+ payloadBytes,
7556
+ messageId,
7557
+ wakeup,
7558
+ ackRequested
7559
+ )
7560
+ var totalWireBytes = 0
7561
+ for (i in fragments.indices) {
7562
+ val fragment = fragments[i]
7563
+ val packedData =
7564
+ K900ProtocolUtils.packBinaryFragment(
7565
+ fragment.flags,
7566
+ fragment.msgId,
7567
+ fragment.fragIdx,
7568
+ fragment.fragCount,
7569
+ fragment.payload
7570
+ )
7571
+ totalWireBytes += packedData.size
7572
+
7573
+ val trace =
7574
+ createBleWriteTrace(
7575
+ commandTraceInfo,
7576
+ null,
7577
+ fragment.fragIdx,
7578
+ fragment.fragCount,
7579
+ fragment.payload.size,
7580
+ packedData.size,
7581
+ wakeup && i == 0,
7582
+ fragments.size > 1
7583
+ )
7584
+ logBleChunkTrace(
7585
+ "created",
7586
+ trace,
7587
+ mapOf(
7588
+ "messageBytes" to payloadBytes.size,
7589
+ "wireProtocol" to "v2"
7590
+ )
7591
+ )
7592
+ queueData(packedData, trace)
7593
+
7594
+ if (i < fragments.size - 1) {
7595
+ try {
7596
+ Thread.sleep(50)
7597
+ } catch (e: InterruptedException) {
7598
+ Log.w(TAG, "Interrupted during binary fragment delay")
7599
+ }
7600
+ }
7601
+ }
7602
+
7603
+ logWireMetrics(
7604
+ payloadBytes.size,
7605
+ totalWireBytes,
7606
+ fragments.size,
7607
+ BleWireProtocol.PROTOCOL_V2,
7608
+ "phone_to_glasses"
7609
+ )
7610
+
7611
+ if (isPhotoRequest) {
7612
+ Bridge.log(
7613
+ "LIVE: PHOTO PIPELINE BLE handoff — binary v2 queued " +
7614
+ fragments.size +
7615
+ " fragments, wireBytes=" +
7616
+ totalWireBytes
7617
+ )
7618
+ }
7619
+ }
7620
+
7196
7621
  private fun parseOutgoingBleCommandTraceInfo(payload: String): OutgoingBleCommandTraceInfo {
7197
7622
  return try {
7198
7623
  val obj = JSONObject(payload)
@@ -8400,7 +8825,8 @@ class MentraLive : SGCManager() {
8400
8825
  val packedData =
8401
8826
  K900ProtocolUtils.packDataToK900(
8402
8827
  cmdObject.toString().toByteArray(StandardCharsets.UTF_8),
8403
- K900ProtocolUtils.CMD_TYPE_STRING
8828
+ K900ProtocolUtils.CMD_TYPE_STRING,
8829
+ k900LengthEndian()
8404
8830
  )
8405
8831
  if (packedData == null) {
8406
8832
  Bridge.log("LIVE: Failed to pack Voice Activity Detection setting command")