@mentra/bluetooth-sdk 0.1.18 → 0.1.20

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 (77) hide show
  1. package/README.md +1 -1
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +135 -51
  3. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +120 -17
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +5 -6
  5. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +34 -21
  6. package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +1 -1
  7. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +13 -0
  8. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +1 -1
  9. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizer.kt +38 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +488 -15
  11. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +551 -159
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLiveL2capChannel.kt +274 -0
  13. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +361 -67
  14. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraosBle.java +7501 -3704
  15. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +134 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +0 -6
  17. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +2 -3
  18. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +493 -0
  19. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadService.java +65 -5
  20. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleWireProtocol.java +108 -0
  21. package/android/src/main/java/com/mentra/bluetoothsdk/utils/IncidentLogBleUploadService.java +4 -3
  22. package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900LengthCodec.java +115 -0
  23. package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +109 -73
  24. package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunkReassembler.java +106 -0
  25. package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunker.java +93 -0
  26. package/android/src/main/java/com/mentra/bluetoothsdk/utils/NexSGCUtils.kt +111 -11
  27. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkExceptionTest.kt +18 -0
  28. package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +23 -9
  29. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizerTest.kt +31 -0
  30. package/android/src/test/java/com/mentra/bluetoothsdk/utils/AvifExifStripperTest.java +17 -1
  31. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BinaryMessageChunkerTest.java +84 -0
  32. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +158 -0
  33. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadServiceTest.java +9 -0
  34. package/android/src/test/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtilsEndiannessTest.java +172 -0
  35. package/android/src/test/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtilsTest.java +24 -0
  36. package/build/BluetoothSdk.types.d.ts +37 -1
  37. package/build/BluetoothSdk.types.d.ts.map +1 -1
  38. package/build/BluetoothSdk.types.js.map +1 -1
  39. package/build/_private/BluetoothSdkModule.d.ts +4 -0
  40. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  41. package/build/_private/BluetoothSdkModule.js.map +1 -1
  42. package/build/_private/photoRequestPayload.d.ts.map +1 -1
  43. package/build/_private/photoRequestPayload.js +1 -0
  44. package/build/_private/photoRequestPayload.js.map +1 -1
  45. package/build/index.d.ts +1 -1
  46. package/build/index.d.ts.map +1 -1
  47. package/build/index.js +5 -0
  48. package/build/index.js.map +1 -1
  49. package/build/types/index.d.ts +3 -0
  50. package/build/types/index.d.ts.map +1 -0
  51. package/build/types/index.js +2 -0
  52. package/build/types/index.js.map +1 -0
  53. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  54. package/ios/Source/DeviceManager.swift +136 -30
  55. package/ios/Source/DeviceStore.swift +5 -5
  56. package/ios/Source/MentraBluetoothSDK.swift +40 -24
  57. package/ios/Source/OtaManifest.swift +1 -1
  58. package/ios/Source/camera/CameraModels.swift +17 -3
  59. package/ios/Source/sgcs/G1.swift +1 -1
  60. package/ios/Source/sgcs/G2.swift +518 -11
  61. package/ios/Source/sgcs/MentraLive.swift +443 -42
  62. package/ios/Source/sgcs/MentraLiveL2capChannel.swift +183 -0
  63. package/ios/Source/sgcs/MentraNex.swift +416 -32
  64. package/ios/Source/sgcs/SGCManager.swift +155 -0
  65. package/ios/Source/sgcs/mentraos_ble.pb.swift +726 -122
  66. package/ios/Source/status/DeviceStatus.swift +0 -8
  67. package/ios/Source/utils/BleJsonCompact.swift +395 -0
  68. package/ios/Source/utils/BleWireProtocol.swift +92 -0
  69. package/ios/Source/utils/G1Text.swift +30 -1
  70. package/ios/Source/utils/MessageChunkReassembler.swift +82 -0
  71. package/ios/Source/utils/MessageChunker.swift +73 -0
  72. package/package.json +14 -7
  73. package/src/BluetoothSdk.types.ts +39 -1
  74. package/src/_private/BluetoothSdkModule.ts +4 -0
  75. package/src/_private/photoRequestPayload.ts +1 -0
  76. package/src/index.ts +6 -0
  77. package/src/types/index.ts +7 -0
@@ -25,6 +25,7 @@ import android.content.pm.PackageManager
25
25
  import android.graphics.Bitmap
26
26
  import android.os.Build
27
27
  import android.os.Handler
28
+ import android.os.HandlerThread
28
29
  import android.os.Looper
29
30
  import android.util.Log
30
31
  import androidx.core.app.ActivityCompat
@@ -41,6 +42,9 @@ import com.mentra.bluetoothsdk.utils.ConnTypes
41
42
  import com.mentra.bluetoothsdk.utils.DeviceTypes
42
43
  import com.mentra.bluetoothsdk.utils.IncidentLogBleRelayNaming
43
44
  import com.mentra.bluetoothsdk.utils.IncidentLogBleUploadService
45
+ import com.mentra.bluetoothsdk.utils.BleJsonCompact
46
+ import com.mentra.bluetoothsdk.utils.BleWireProtocol
47
+ import com.mentra.bluetoothsdk.utils.K900LengthCodec
44
48
  import com.mentra.bluetoothsdk.utils.K900ProtocolUtils
45
49
  import com.mentra.bluetoothsdk.utils.MessageChunkReassembler
46
50
  import com.mentra.bluetoothsdk.utils.MessageChunker
@@ -90,6 +94,13 @@ class MentraLive : SGCManager() {
90
94
  // LC3 frame size for Mentra Live
91
95
  private const val LC3_FRAME_SIZE = 40
92
96
  private const val VOICE_ACTIVITY_DETECTION_SWITCH_TYPE = 8
97
+ private const val BES2700_MTU_LIMIT = 509
98
+
99
+ // L2CAP CoC fast path: new BES2700 firmware registers an LE L2CAP CoC server on this
100
+ // PSM. When the phone opens the channel, the glasses send file packets over it instead
101
+ // of GATT FILE_READ notifications. Phone→glasses traffic stays on GATT.
102
+ private const val L2CAP_FILE_PSM = 0x00C9
103
+ private const val FILE_PACKET_LOG_INTERVAL = 32
93
104
 
94
105
  // BLE UUIDs - updated to match K900 BES2800 MCU UUIDs for compatibility with both glass
95
106
  // types
@@ -199,6 +210,15 @@ class MentraLive : SGCManager() {
199
210
 
200
211
  // Local-only fields (not in parent SGCManager)
201
212
  private var buildNumberInt = 0 // Build number as integer for version checks
213
+ private var peerWireProtocolVersion = 0
214
+ private var useBinaryWireProtocol = false
215
+ private var wireHandshakeQueued = false
216
+ // Negotiated K900 STRING length endianness for the phone<->glasses BLE link. Defaults to
217
+ // legacy big-endian; upgraded to little-endian only when the glasses advertise wire_caps.k900_le
218
+ // (or a v2 binary handshake succeeds, which implies wire-v2 LE).
219
+ private var peerK900Le = false
220
+ private var peerWireCapsBinary = false
221
+ private var peerFilePayloadV2 = false
202
222
  // Note: appVersion, buildNumber, deviceModel, androidVersion
203
223
  // are inherited from SGCManager parent class
204
224
 
@@ -225,6 +245,9 @@ class MentraLive : SGCManager() {
225
245
  private var lc3ReadCharacteristic: BluetoothGattCharacteristic? = null
226
246
  private var lc3WriteCharacteristic: BluetoothGattCharacteristic? = null
227
247
  private var handler = Handler(Looper.getMainLooper())
248
+ private val fileProcessingThread =
249
+ HandlerThread("MentraLive-FileProcessing").apply { start() }
250
+ private val fileProcessingHandler = Handler(fileProcessingThread.looper)
228
251
  private var scheduler: ScheduledExecutorService? = null
229
252
  private var isScanning = false
230
253
  private var isConnecting = false
@@ -304,7 +327,7 @@ class MentraLive : SGCManager() {
304
327
  private var activeFileTransfers = ConcurrentHashMap<String, FileTransferSession>()
305
328
 
306
329
  // BLE photo transfer tracking
307
- private var blePhotoTransfers: MutableMap<String, BlePhotoTransfer> = HashMap()
330
+ private var blePhotoTransfers: MutableMap<String, BlePhotoTransfer> = ConcurrentHashMap()
308
331
 
309
332
  /** Expected incident log relay files from glasses (B… firmware, L… logcat). */
310
333
  private val bleIncidentLogRelays = ConcurrentHashMap<String, BleIncidentLogRelay>()
@@ -319,6 +342,12 @@ class MentraLive : SGCManager() {
319
342
  private var fileReadNotificationCount = 0 // Debug counter for FILE_READ notifications
320
343
  private val incomingChunkReassembler = MessageChunkReassembler()
321
344
 
345
+ // L2CAP CoC fast path for incoming file transfers (see L2CAP_FILE_PSM).
346
+ // The channel is read-only; all outgoing messages remain on GATT. When it can't be
347
+ // opened (older firmware, Android < 10), GATT notifications remain the file path.
348
+ private val enableL2capFilePath = true
349
+ private var l2capFileChannel: MentraLiveL2capChannel? = null
350
+
322
351
  private val connectionLock = Any()
323
352
 
324
353
  private var glassesMediaVolumeTimeoutRunnable: Runnable? = null
@@ -752,6 +781,13 @@ class MentraLive : SGCManager() {
752
781
  if (isEqual) {
753
782
  if (state == ConnTypes.DISCONNECTED) {
754
783
  incomingChunkReassembler.clear()
784
+ peerWireProtocolVersion = 0
785
+ useBinaryWireProtocol = false
786
+ wireHandshakeQueued = false
787
+ peerK900Le = false
788
+ peerWireCapsBinary = false
789
+ peerFilePayloadV2 = false
790
+ BleJsonCompact.resetSession()
755
791
  }
756
792
  return
757
793
  }
@@ -780,6 +816,13 @@ class MentraLive : SGCManager() {
780
816
  DeviceStore.apply("glasses", "signalStrength", -1)
781
817
  DeviceStore.apply("glasses", "signalStrengthUpdatedAt", 0L)
782
818
  incomingChunkReassembler.clear()
819
+ peerWireProtocolVersion = 0
820
+ useBinaryWireProtocol = false
821
+ wireHandshakeQueued = false
822
+ peerK900Le = false
823
+ peerWireCapsBinary = false
824
+ peerFilePayloadV2 = false
825
+ BleJsonCompact.resetSession()
783
826
  // Drop OTA caches when fully disconnected — avoids leaking session/step state
784
827
  // from a previous pairing into the next one.
785
828
  resetOtaCache()
@@ -1405,6 +1448,15 @@ class MentraLive : SGCManager() {
1405
1448
  )
1406
1449
  }
1407
1450
 
1451
+ private fun phyLabel(phy: Int): String {
1452
+ return when (phy) {
1453
+ BluetoothDevice.PHY_LE_1M -> "1M"
1454
+ BluetoothDevice.PHY_LE_2M -> "2M"
1455
+ BluetoothDevice.PHY_LE_CODED -> "coded"
1456
+ else -> "unknown($phy)"
1457
+ }
1458
+ }
1459
+
1408
1460
  /** GATT callback for BLE operations */
1409
1461
  private val gattCallback: BluetoothGattCallback =
1410
1462
  object : BluetoothGattCallback() {
@@ -1427,6 +1479,7 @@ class MentraLive : SGCManager() {
1427
1479
  isConnecting = false
1428
1480
  isConnected = true
1429
1481
  connectedDevice = gatt.device
1482
+
1430
1483
  DeviceStore.apply("glasses", "bluetoothName", connectedDevice!!.name)
1431
1484
  // Persist MAC so reconnection can use direct GATT instead of scanning
1432
1485
  if (connectedDevice!!.address != null) {
@@ -1523,6 +1576,11 @@ class MentraLive : SGCManager() {
1523
1576
  // Stop micbeat mechanism
1524
1577
  stopMicBeat()
1525
1578
 
1579
+ // Close the L2CAP file channel (if the fast path was open)
1580
+ closeL2capFileChannel()
1581
+ fileProcessingHandler.removeCallbacksAndMessages(null)
1582
+ clearFilePacketBuffer()
1583
+
1526
1584
  // Clean up GATT resources
1527
1585
  closeGattQuietly(false)
1528
1586
 
@@ -1575,6 +1633,11 @@ 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
+ fileProcessingHandler.removeCallbacksAndMessages(null)
1639
+ clearFilePacketBuffer()
1640
+
1578
1641
  // Clean up resources
1579
1642
  closeGattQuietly(false)
1580
1643
 
@@ -1586,6 +1649,38 @@ class MentraLive : SGCManager() {
1586
1649
  }
1587
1650
  }
1588
1651
 
1652
+ override fun onPhyUpdate(
1653
+ gatt: BluetoothGatt,
1654
+ txPhy: Int,
1655
+ rxPhy: Int,
1656
+ status: Int
1657
+ ) {
1658
+ Bridge.log(
1659
+ "LIVE: PHY update tx=" +
1660
+ phyLabel(txPhy) +
1661
+ ", rx=" +
1662
+ phyLabel(rxPhy) +
1663
+ ", status=" +
1664
+ status
1665
+ )
1666
+ }
1667
+
1668
+ override fun onPhyRead(
1669
+ gatt: BluetoothGatt,
1670
+ txPhy: Int,
1671
+ rxPhy: Int,
1672
+ status: Int
1673
+ ) {
1674
+ Bridge.log(
1675
+ "LIVE: PHY read tx=" +
1676
+ phyLabel(txPhy) +
1677
+ ", rx=" +
1678
+ phyLabel(rxPhy) +
1679
+ ", status=" +
1680
+ status
1681
+ )
1682
+ }
1683
+
1589
1684
  override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
1590
1685
  if (status == BluetoothGatt.GATT_SUCCESS) {
1591
1686
  Bridge.log("LIVE: GATT services discovered")
@@ -2570,18 +2665,6 @@ class MentraLive : SGCManager() {
2570
2665
  var iterations = 0
2571
2666
  val MAX_ITERATIONS = 100
2572
2667
 
2573
- // Debug: Log hex dump of first 40 bytes
2574
- val hexFirst = StringBuilder()
2575
- for (i in 0 until Math.min(40, filePacketBufferSize)) {
2576
- hexFirst.append(String.format("%02X ", filePacketBuffer[i]))
2577
- }
2578
- Bridge.log(
2579
- "LIVE: 📦 extractCompleteFilePackets: buffer has " +
2580
- filePacketBufferSize +
2581
- " bytes, first 40: " +
2582
- hexFirst.toString()
2583
- )
2584
-
2585
2668
  while (pos < filePacketBufferSize && iterations++ < MAX_ITERATIONS) {
2586
2669
  // Find start marker ## (0x23 0x23)
2587
2670
  var startPos = -1
@@ -2616,11 +2699,6 @@ class MentraLive : SGCManager() {
2616
2699
 
2617
2700
  // Need at least 5 bytes to read type and packSize: ## (2) + type (1) + packSize (2)
2618
2701
  if (filePacketBufferSize - pos < 5) {
2619
- Bridge.log(
2620
- "LIVE: 📦 Not enough data for header, have " +
2621
- (filePacketBufferSize - pos) +
2622
- " bytes, need 5"
2623
- )
2624
2702
  break
2625
2703
  }
2626
2704
 
@@ -2630,33 +2708,9 @@ class MentraLive : SGCManager() {
2630
2708
  ((filePacketBuffer[packSizeOffset].toInt() and 0xFF) shl 8) or
2631
2709
  (filePacketBuffer[packSizeOffset + 1].toInt() and 0xFF)
2632
2710
 
2633
- // Also try little-endian for comparison
2634
- val packSizeLE =
2635
- (filePacketBuffer[packSizeOffset].toInt() and 0xFF) or
2636
- ((filePacketBuffer[packSizeOffset + 1].toInt() and 0xFF) shl 8)
2637
- Bridge.log(
2638
- "LIVE: 📦 Header bytes 3-4: 0x" +
2639
- String.format(
2640
- "%02X%02X",
2641
- filePacketBuffer[packSizeOffset],
2642
- filePacketBuffer[packSizeOffset + 1]
2643
- ) +
2644
- " -> packSize BE=" +
2645
- packSize +
2646
- ", LE=" +
2647
- packSizeLE
2648
- )
2649
-
2650
2711
  // Validate packSize
2651
2712
  if (packSize < 0 || packSize > K900ProtocolUtils.FILE_PACK_SIZE) {
2652
- Log.w(
2653
- TAG,
2654
- "Invalid packSize " +
2655
- packSize +
2656
- " (LE would be " +
2657
- packSizeLE +
2658
- "), skipping start marker"
2659
- )
2713
+ Log.w(TAG, "Invalid packSize $packSize, skipping start marker")
2660
2714
  pos = startPos + 1
2661
2715
  continue
2662
2716
  }
@@ -2669,17 +2723,7 @@ class MentraLive : SGCManager() {
2669
2723
  // Check if we have the complete packet
2670
2724
  val availableBytes = filePacketBufferSize - pos
2671
2725
  if (availableBytes < expectedPacketSize) {
2672
- // Not enough data yet, wait for more fragments
2673
- Bridge.log(
2674
- "LIVE: 📦 Waiting for more data: have " +
2675
- availableBytes +
2676
- " of " +
2677
- expectedPacketSize +
2678
- " bytes (packSize=" +
2679
- packSize +
2680
- ")"
2681
- )
2682
- break // IMPORTANT: break here, don't continue looking for end marker
2726
+ break
2683
2727
  }
2684
2728
 
2685
2729
  // Verify end marker $$ at expected position
@@ -2687,25 +2731,6 @@ class MentraLive : SGCManager() {
2687
2731
  val endByte1 = filePacketBuffer[endMarkerPos]
2688
2732
  val endByte2 = filePacketBuffer[endMarkerPos + 1]
2689
2733
 
2690
- // Debug: Show bytes around expected end marker position
2691
- val endContext = StringBuilder()
2692
- for (i in
2693
- Math.max(0, endMarkerPos - 5)..Math.min(
2694
- filePacketBufferSize - 1,
2695
- endMarkerPos + 5
2696
- )) {
2697
- if (i == endMarkerPos) endContext.append("[")
2698
- endContext.append(String.format("%02X", filePacketBuffer[i]))
2699
- if (i == endMarkerPos + 1) endContext.append("]")
2700
- endContext.append(" ")
2701
- }
2702
- Bridge.log(
2703
- "LIVE: 📦 End marker check at pos " +
2704
- endMarkerPos +
2705
- ": " +
2706
- endContext.toString()
2707
- )
2708
-
2709
2734
  if (endByte1 != 0x24.toByte() || endByte2 != 0x24.toByte()) {
2710
2735
  // End marker not found - could be corrupted packet or wrong packSize interpretation
2711
2736
  Log.w(
@@ -2730,22 +2755,10 @@ class MentraLive : SGCManager() {
2730
2755
  val completePacket = ByteArray(expectedPacketSize)
2731
2756
  System.arraycopy(filePacketBuffer, pos, completePacket, 0, expectedPacketSize)
2732
2757
 
2733
- Bridge.log(
2734
- "LIVE: 📦 ✅ Complete file packet reassembled: " + expectedPacketSize + " bytes"
2735
- )
2736
-
2737
2758
  // Process the complete packet
2738
2759
  val packetInfo = K900ProtocolUtils.extractFilePacket(completePacket)
2739
2760
  if (packetInfo != null && packetInfo.isValid) {
2740
- Bridge.log(
2741
- "LIVE: 📦 ✅ Packet validated: index=" +
2742
- packetInfo.packIndex +
2743
- ", fileName=" +
2744
- packetInfo.fileName
2745
- )
2746
- // Post to handler to process outside the lock
2747
- val finalPacketInfo = packetInfo
2748
- handler.post { processFilePacket(finalPacketInfo) }
2761
+ enqueueFilePacket(packetInfo)
2749
2762
  } else {
2750
2763
  Log.e(TAG, "Failed to extract/validate reassembled file packet")
2751
2764
  }
@@ -2758,13 +2771,6 @@ class MentraLive : SGCManager() {
2758
2771
  val remaining = filePacketBufferSize - pos
2759
2772
  System.arraycopy(filePacketBuffer, pos, filePacketBuffer, 0, remaining)
2760
2773
  filePacketBufferSize = remaining
2761
- Bridge.log(
2762
- "LIVE: 📦 Removed " +
2763
- pos +
2764
- " bytes, " +
2765
- remaining +
2766
- " bytes remaining in buffer"
2767
- )
2768
2774
  } else if (pos >= filePacketBufferSize) {
2769
2775
  filePacketBufferSize = 0
2770
2776
  }
@@ -2780,6 +2786,49 @@ class MentraLive : SGCManager() {
2780
2786
  synchronized(filePacketBufferLock) { filePacketBufferSize = 0 }
2781
2787
  }
2782
2788
 
2789
+ /**
2790
+ * Open the L2CAP CoC fast path for incoming file transfers (PSM 0x00C9).
2791
+ *
2792
+ * New BES2700 firmware registers an LE L2CAP CoC server; when the phone opens the channel,
2793
+ * the glasses send K900 file packets over it instead of GATT FILE_READ notifications.
2794
+ * Complete frames from the channel are fed into processFilePacketData — the exact same entry
2795
+ * point used by GATT FILE_READ notifications — so downstream reassembly (FileTransferSession,
2796
+ * transfer_complete) is unchanged. On any failure to open we stay on GATT notifications.
2797
+ */
2798
+ private fun openL2capFileChannel() {
2799
+ if (!enableL2capFilePath) {
2800
+ return
2801
+ }
2802
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
2803
+ // createInsecureL2capChannel requires API 29
2804
+ Bridge.log("LIVE: L2CAP: unavailable, staying on GATT (requires Android 10+)")
2805
+ return
2806
+ }
2807
+ if (l2capFileChannel != null) {
2808
+ // Already open (or connecting) for this connection — e.g. repeated glasses_ready
2809
+ return
2810
+ }
2811
+ val device = connectedDevice
2812
+ if (device == null) {
2813
+ Log.w(TAG, "L2CAP: no connected device, staying on GATT")
2814
+ return
2815
+ }
2816
+ val channel =
2817
+ MentraLiveL2capChannel(L2CAP_FILE_PSM) { frame ->
2818
+ // Same code path as a GATT FILE_READ notification carrying this frame
2819
+ processFilePacketData(frame)
2820
+ }
2821
+ l2capFileChannel = channel
2822
+ channel.open(device)
2823
+ }
2824
+
2825
+ /** Close the L2CAP file channel if open (call on disconnect). */
2826
+ private fun closeL2capFileChannel() {
2827
+ val channel = l2capFileChannel ?: return
2828
+ l2capFileChannel = null
2829
+ channel.close()
2830
+ }
2831
+
2783
2832
  /** Process data received from the glasses */
2784
2833
  private fun processReceivedData(data: ByteArray?, size: Int) {
2785
2834
  // Bridge.log("LIVE: Processing received data: " + bytesToHex(data));
@@ -2838,7 +2887,7 @@ class MentraLive : SGCManager() {
2838
2887
  // structure
2839
2888
  val packetInfo = K900ProtocolUtils.extractFilePacket(data)
2840
2889
  if (packetInfo != null && packetInfo.isValid) {
2841
- processFilePacket(packetInfo)
2890
+ enqueueFilePacket(packetInfo)
2842
2891
  } else {
2843
2892
  Log.e(TAG, "Thread-" + threadId + ": Failed to extract or validate file packet")
2844
2893
  // BES chip handles ACKs automatically
@@ -2847,10 +2896,26 @@ class MentraLive : SGCManager() {
2847
2896
  return // Exit after processing file packet
2848
2897
  }
2849
2898
 
2899
+ if (cmdType == K900ProtocolUtils.CMD_TYPE_BINARY_MSG) {
2900
+ processBinaryWireFrame(data)
2901
+ return
2902
+ }
2903
+
2904
+ // Learn the glasses' K900 length endianness from the frame we just received so future
2905
+ // outbound frames match. Guards against a peer that never advertises wire_caps.
2906
+ K900LengthCodec.detectLength(data)?.let { detected ->
2907
+ peerK900Le = detected.endian == K900LengthCodec.Endian.LE
2908
+ }
2909
+
2850
2910
  // Otherwise it's a normal JSON message
2851
2911
  val json = K900ProtocolUtils.processReceivedBytesToJson(data)
2852
2912
  if (json != null) {
2853
- processJsonMessage(json)
2913
+ val expanded = expandCompactWireJson(json)
2914
+ if (expanded == null) {
2915
+ Log.w(TAG, "Thread-$threadId: Rejected unsupported compact wire form")
2916
+ return
2917
+ }
2918
+ processJsonMessage(expanded)
2854
2919
  } else {
2855
2920
  Log.w(TAG, "Thread-" + threadId + ": Failed to parse K900 protocol data")
2856
2921
  // #region agent log [810da2] Hypothesis A+B: log header-declared length vs actual
@@ -3035,10 +3100,12 @@ class MentraLive : SGCManager() {
3035
3100
  )
3036
3101
  "speaking_status" -> handleSpeakingStatus(json.optBoolean("speaking", false))
3037
3102
  "battery_status" -> {
3038
- // Process battery status
3103
+ // Percent only. battery_status derives from hm_batv, which carries no charge
3104
+ // bit — older glasses fabricate `charging` from a voltage threshold (>3.9V)
3105
+ // that reads "charging" for most of a discharging pack's range. Charging
3106
+ // state comes exclusively from the PMU charg bit in the sr_hrt heartbeat.
3039
3107
  val percent = json.optInt("percent", batteryLevel)
3040
- val charging = json.optBoolean("charging", isCharging)
3041
- updateBatteryStatus(percent, charging)
3108
+ updateBatteryStatus(percent, isCharging)
3042
3109
  }
3043
3110
  "pong" ->
3044
3111
  // Process heartbeat pong response
@@ -3057,6 +3124,18 @@ class MentraLive : SGCManager() {
3057
3124
  val ssid = json.optString("ssid", "")
3058
3125
  val localIp = json.optString("local_ip", "")
3059
3126
 
3127
+ // Provisioning failure reason (e.g. connect_timeout). Sticky: routine
3128
+ // error-less status updates (link-state debounce, request_wifi_status)
3129
+ // must not clear a failure nothing recovered from — only a newer error
3130
+ // or a successful connection overwrites it.
3131
+ val wifiError = json.optString("error", "")
3132
+ if (wifiError.isNotEmpty()) {
3133
+ Bridge.log("LIVE: 🌐 WiFi provisioning error from glasses: $wifiError")
3134
+ DeviceStore.apply("glasses", "wifiError", wifiError)
3135
+ } else if (wifiConnectedStatus) {
3136
+ DeviceStore.apply("glasses", "wifiError", "")
3137
+ }
3138
+
3060
3139
  updateWifiStatus(wifiConnectedStatus, ssid, localIp)
3061
3140
  }
3062
3141
  "hotspot_status_update" -> {
@@ -3413,6 +3492,9 @@ class MentraLive : SGCManager() {
3413
3492
  // Glasses SOC has booted and is ready for communication
3414
3493
  Bridge.log("LIVE: 🎉 Received glasses_ready message - SOC is booted and ready!")
3415
3494
 
3495
+ // Negotiate wire capabilities advertised in the glasses_ready handshake.
3496
+ parsePeerWireCaps(json)
3497
+
3416
3498
  // Set the ready flag to stop any future readiness checks
3417
3499
  glassesReady = true
3418
3500
  glassesReadyReceived = true
@@ -3422,12 +3504,18 @@ class MentraLive : SGCManager() {
3422
3504
 
3423
3505
  // Stop the readiness check loop since we got confirmation
3424
3506
  stopReadinessCheckLoop()
3507
+ advertiseFilePayloadCapabilityToBes()
3508
+
3509
+ // Try to open the L2CAP CoC fast path for file transfers. No-op when the
3510
+ // firmware doesn't support it — GATT notifications remain the default path.
3511
+ try {
3512
+ openL2capFileChannel()
3513
+ } catch (t: Throwable) {
3514
+ Bridge.log("LIVE: ⚠️ glasses_ready: openL2capFileChannel threw: " + t)
3515
+ }
3425
3516
 
3426
3517
  // 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
3518
+ // Use the minimum of negotiated MTU and BES2700's datapath limit (509).
3431
3519
  val effectiveMtu = Math.min(currentMtu, BES2700_MTU_LIMIT)
3432
3520
  Bridge.log(
3433
3521
  "LIVE: 📦 Sending BLE MTU config: negotiated=" +
@@ -3607,6 +3695,8 @@ class MentraLive : SGCManager() {
3607
3695
  buildNumberInt = 0
3608
3696
  Log.e(TAG, "Failed to parse build number as integer: " + buildNumberLegacy)
3609
3697
  }
3698
+ parsePeerWireCaps(json)
3699
+ maybeSendWireHandshake()
3610
3700
  }
3611
3701
  "ota_download_progress" -> {
3612
3702
  // Process OTA download progress from ASG client
@@ -3792,8 +3882,12 @@ class MentraLive : SGCManager() {
3792
3882
  // Parse build number as integer for version checks
3793
3883
  try {
3794
3884
  val buildNumInt = Integer.parseInt(buildNum)
3885
+ buildNumberInt = buildNumInt
3795
3886
  Bridge.log("LIVE: Parsed build number as integer: " + buildNumInt)
3887
+ parsePeerWireCaps(json)
3888
+ maybeSendWireHandshake()
3796
3889
  } catch (e: NumberFormatException) {
3890
+ buildNumberInt = 0
3797
3891
  Log.e(TAG, "Failed to parse build number as integer: " + buildNum)
3798
3892
  }
3799
3893
  }
@@ -4253,11 +4347,10 @@ class MentraLive : SGCManager() {
4253
4347
  "%"
4254
4348
  )
4255
4349
 
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
4350
+ // Percent only. sr_batv carries just voltage+percent; inferring
4351
+ // charging from voltage (>4.0V) reads "not charging" for most of a
4352
+ // genuinely-charging pack's range. Charging state comes exclusively
4353
+ // from the PMU charg bit in the sr_hrt heartbeat.
4261
4354
  updateBatteryStatus(batteryPercentage, isCharging)
4262
4355
  }
4263
4356
  } catch (e: Exception) {
@@ -4561,6 +4654,11 @@ class MentraLive : SGCManager() {
4561
4654
  * pattern
4562
4655
  */
4563
4656
  private fun updateBatteryStatus(level: Int, isCharging: Boolean) {
4657
+ // Keep the field in sync: percent-only messages (battery_status/sr_batv) re-pass
4658
+ // it as the last-known charging state, so a stale field would clobber the value
4659
+ // the sr_hrt PMU charg bit established.
4660
+ this.isCharging = isCharging
4661
+
4564
4662
  // Update parent SGCManager fields
4565
4663
  DeviceStore.apply("glasses", "batteryLevel", level)
4566
4664
  DeviceStore.apply("glasses", "charging", isCharging)
@@ -5279,6 +5377,7 @@ class MentraLive : SGCManager() {
5279
5377
  override fun requestPhoto(request: PhotoRequest) {
5280
5378
  val requestId = request.requestId
5281
5379
  val size = request.size.value
5380
+ val mode = request.mode.value
5282
5381
  val webhookUrl = request.webhookUrl
5283
5382
  val authToken = request.authToken
5284
5383
  val compress = request.compress.value
@@ -5292,6 +5391,8 @@ class MentraLive : SGCManager() {
5292
5391
  requestId +
5293
5392
  " with size: " +
5294
5393
  size +
5394
+ ", mode=" +
5395
+ mode +
5295
5396
  ", webhookUrl: " +
5296
5397
  webhookUrl +
5297
5398
  ", authToken: " +
@@ -5329,6 +5430,7 @@ class MentraLive : SGCManager() {
5329
5430
  if (size != null && !size.isEmpty()) {
5330
5431
  json.put("size", size)
5331
5432
  }
5433
+ json.put("mode", mode)
5332
5434
  if (compress != null && !compress.isEmpty()) {
5333
5435
  json.put("compress", compress)
5334
5436
  } else {
@@ -5356,7 +5458,7 @@ class MentraLive : SGCManager() {
5356
5458
  val bleImgId = "I" + String.format("%09d", System.currentTimeMillis() % 1000000000)
5357
5459
  json.put("bleImgId", bleImgId)
5358
5460
 
5359
- // Use auto mode by default - glasses will decide based on connectivity
5461
+ // Use auto mode by default - glasses try WiFi direct upload, BLE fallback via bleImgId
5360
5462
  json.put("transferMethod", "auto")
5361
5463
 
5362
5464
  // Always prepare for potential BLE transfer
@@ -5369,7 +5471,9 @@ class MentraLive : SGCManager() {
5369
5471
 
5370
5472
  Bridge.log("LIVE: Using auto transfer mode with BLE fallback ID: " + bleImgId)
5371
5473
  Bridge.log(
5372
- "LIVE: PHOTO PIPELINE [5b/6] JSON ready " +
5474
+ "LIVE: PHOTO PIPELINE [5b/6] JSON ready mode=" +
5475
+ mode +
5476
+ " — " +
5373
5477
  summarizeOutgoingMessage(json.toString()) +
5374
5478
  ", wakeup=true"
5375
5479
  )
@@ -6026,6 +6130,7 @@ class MentraLive : SGCManager() {
6026
6130
 
6027
6131
  // Cancel any pending handlers
6028
6132
  handler.removeCallbacksAndMessages(null)
6133
+ fileProcessingHandler.removeCallbacksAndMessages(null)
6029
6134
  heartbeatHandler.removeCallbacksAndMessages(null)
6030
6135
  rssiReadHandler.removeCallbacksAndMessages(null)
6031
6136
  micBeatHandler.removeCallbacksAndMessages(null)
@@ -6043,6 +6148,9 @@ class MentraLive : SGCManager() {
6043
6148
  // sendRgbLedControlAuthority(false);
6044
6149
  // }
6045
6150
 
6151
+ // Close the L2CAP file channel (if the fast path was open)
6152
+ closeL2capFileChannel()
6153
+
6046
6154
  // Disconnect from GATT if connected
6047
6155
  closeGattQuietly(true)
6048
6156
 
@@ -6054,6 +6162,7 @@ class MentraLive : SGCManager() {
6054
6162
 
6055
6163
  // Clear file packet reassembly buffer
6056
6164
  clearFilePacketBuffer()
6165
+ fileProcessingThread.quitSafely()
6057
6166
 
6058
6167
  // Reset state variables
6059
6168
  reconnectAttempts = 0
@@ -6366,7 +6475,8 @@ class MentraLive : SGCManager() {
6366
6475
  val packedData =
6367
6476
  K900ProtocolUtils.packDataToK900(
6368
6477
  jsonStr.toByteArray(StandardCharsets.UTF_8),
6369
- K900ProtocolUtils.CMD_TYPE_STRING
6478
+ K900ProtocolUtils.CMD_TYPE_STRING,
6479
+ k900LengthEndian()
6370
6480
  )
6371
6481
 
6372
6482
  queueData(packedData)
@@ -6550,7 +6660,8 @@ class MentraLive : SGCManager() {
6550
6660
  val packedData =
6551
6661
  K900ProtocolUtils.packDataToK900(
6552
6662
  jsonStr.toByteArray(StandardCharsets.UTF_8),
6553
- K900ProtocolUtils.CMD_TYPE_STRING
6663
+ K900ProtocolUtils.CMD_TYPE_STRING,
6664
+ k900LengthEndian()
6554
6665
  )
6555
6666
  queueData(packedData)
6556
6667
  } catch (e: JSONException) {
@@ -6579,7 +6690,8 @@ class MentraLive : SGCManager() {
6579
6690
  val packedData =
6580
6691
  K900ProtocolUtils.packDataToK900(
6581
6692
  jsonStr.toByteArray(StandardCharsets.UTF_8),
6582
- K900ProtocolUtils.CMD_TYPE_STRING
6693
+ K900ProtocolUtils.CMD_TYPE_STRING,
6694
+ k900LengthEndian()
6583
6695
  )
6584
6696
  queueData(packedData)
6585
6697
  } catch (e: JSONException) {
@@ -6653,7 +6765,8 @@ class MentraLive : SGCManager() {
6653
6765
  val packedData =
6654
6766
  K900ProtocolUtils.packDataToK900(
6655
6767
  jsonStr.toByteArray(StandardCharsets.UTF_8),
6656
- K900ProtocolUtils.CMD_TYPE_STRING
6768
+ K900ProtocolUtils.CMD_TYPE_STRING,
6769
+ k900LengthEndian()
6657
6770
  )
6658
6771
  Bridge.log("LIVE: AUDIO: Sending cs_getvol command: " + jsonStr)
6659
6772
  queueData(packedData)
@@ -6677,7 +6790,8 @@ class MentraLive : SGCManager() {
6677
6790
  val packedData =
6678
6791
  K900ProtocolUtils.packDataToK900(
6679
6792
  jsonStr.toByteArray(StandardCharsets.UTF_8),
6680
- K900ProtocolUtils.CMD_TYPE_STRING
6793
+ K900ProtocolUtils.CMD_TYPE_STRING,
6794
+ k900LengthEndian()
6681
6795
  )
6682
6796
  queueData(packedData)
6683
6797
  return true
@@ -7039,6 +7153,194 @@ class MentraLive : SGCManager() {
7039
7153
  }
7040
7154
  }
7041
7155
 
7156
+ /** Negotiated K900 STRING length endianness for outbound frames to the glasses. */
7157
+ private fun k900LengthEndian(): K900LengthCodec.Endian =
7158
+ if (peerK900Le) K900LengthCodec.Endian.LE else K900LengthCodec.Endian.BE
7159
+
7160
+ /**
7161
+ * Parse a {@code wire_caps} object from a version_info/glasses_ready message and update the
7162
+ * negotiated per-link endianness and binary support. Missing wire_caps leaves the legacy
7163
+ * defaults (BE, no binary) untouched so older glasses keep working.
7164
+ */
7165
+ private fun parsePeerWireCaps(json: JSONObject) {
7166
+ val caps = json.optJSONObject("wire_caps") ?: return
7167
+ if (caps.optBoolean("k900_le", false)) {
7168
+ if (!peerK900Le) {
7169
+ peerK900Le = true
7170
+ Bridge.log("LIVE: wire_caps negotiated k900 endian=LE")
7171
+ }
7172
+ }
7173
+ if (caps.has("binary")) {
7174
+ peerWireCapsBinary = caps.optBoolean("binary", false)
7175
+ }
7176
+ if (caps.has("file_payload_v2")) {
7177
+ peerFilePayloadV2 = caps.optBoolean("file_payload_v2", false)
7178
+ }
7179
+ }
7180
+
7181
+ private fun advertiseFilePayloadCapabilityToBes() {
7182
+ if (!peerFilePayloadV2) return
7183
+ try {
7184
+ val command = JSONObject()
7185
+ command.put("C", "cs_file_payload")
7186
+ command.put("B", "{\"max\":" + K900ProtocolUtils.FILE_PACK_SIZE + "}")
7187
+ val packed =
7188
+ K900ProtocolUtils.packDataToK900(
7189
+ command.toString().toByteArray(StandardCharsets.UTF_8),
7190
+ K900ProtocolUtils.CMD_TYPE_STRING,
7191
+ k900LengthEndian()
7192
+ )
7193
+ queueData(packed)
7194
+ Bridge.log(
7195
+ "LIVE: 📦 Advertised file payload ceiling " +
7196
+ K900ProtocolUtils.FILE_PACK_SIZE +
7197
+ " to BES"
7198
+ )
7199
+ } catch (e: JSONException) {
7200
+ Log.e(TAG, "Failed to advertise file payload capability", e)
7201
+ }
7202
+ }
7203
+
7204
+ private fun maybeSendWireHandshake() {
7205
+ // Only attempt the v2 binary handshake once the glasses have advertised binary support via
7206
+ // wire_caps. Older builds that report build>=5 but lack wire_caps stay on the legacy path.
7207
+ if (buildNumberInt < 5 ||
7208
+ !peerWireCapsBinary ||
7209
+ wireHandshakeQueued ||
7210
+ peerWireProtocolVersion >= BleWireProtocol.PROTOCOL_V2
7211
+ ) {
7212
+ return
7213
+ }
7214
+ sendWireHandshake()
7215
+ }
7216
+
7217
+ private fun sendWireHandshake() {
7218
+ if (buildNumberInt < 5) {
7219
+ return
7220
+ }
7221
+ try {
7222
+ val payload = BleWireProtocol.HANDSHAKE_PAYLOAD_V2.toByteArray(StandardCharsets.UTF_8)
7223
+ var flags = BleWireProtocol.BLE_WIRE_FLAG_HANDSHAKE.toInt()
7224
+ flags = flags or BleWireProtocol.BLE_WIRE_FLAG_FIRST_FRAG.toInt()
7225
+ flags = flags or BleWireProtocol.BLE_WIRE_FLAG_LAST_FRAG.toInt()
7226
+ val packed =
7227
+ K900ProtocolUtils.packBinaryFragment(
7228
+ flags.toByte(),
7229
+ 0,
7230
+ 0,
7231
+ 1,
7232
+ payload
7233
+ )
7234
+ Bridge.log("LIVE: Sending BLE wire v2 handshake")
7235
+ wireHandshakeQueued = true
7236
+ queueData(packed, null)
7237
+ } catch (e: Exception) {
7238
+ Log.e(TAG, "Failed to send wire handshake", e)
7239
+ }
7240
+ }
7241
+
7242
+ private fun activateBinaryWireV2Session(logMessage: String) {
7243
+ peerWireProtocolVersion = BleWireProtocol.PROTOCOL_V2
7244
+ useBinaryWireProtocol = true
7245
+ wireHandshakeQueued = false
7246
+ peerK900Le = true
7247
+ BleJsonCompact.markSessionConnected(System.currentTimeMillis())
7248
+ Bridge.log(logMessage)
7249
+ }
7250
+
7251
+ private fun handlePeerWireHandshake() {
7252
+ activateBinaryWireV2Session("LIVE: Peer confirmed BLE wire protocol v2")
7253
+ }
7254
+
7255
+ private fun processBinaryWireFrame(data: ByteArray) {
7256
+ val info = K900ProtocolUtils.extractBinaryFragmentInfo(data) ?: run {
7257
+ Log.w(TAG, "Failed to parse binary wire frame")
7258
+ return
7259
+ }
7260
+
7261
+ if (BleWireProtocol.isHandshakeV2(info)) {
7262
+ handlePeerWireHandshake()
7263
+ return
7264
+ }
7265
+
7266
+ if (!useBinaryWireProtocol && buildNumberInt >= 5) {
7267
+ activateBinaryWireV2Session(
7268
+ "LIVE: Auto-enabled BLE wire v2 from incoming binary frame"
7269
+ )
7270
+ }
7271
+
7272
+ val reassembled =
7273
+ incomingChunkReassembler.addBinaryFragment(
7274
+ info.msgId,
7275
+ info.fragIdx,
7276
+ info.fragCount,
7277
+ info.payload
7278
+ )
7279
+ ?: return
7280
+
7281
+ try {
7282
+ val jsonStr = String(reassembled, StandardCharsets.UTF_8)
7283
+ val json = expandCompactWireJson(JSONObject(jsonStr))
7284
+ if (json == null) {
7285
+ Log.w(TAG, "Rejected unsupported compact reassembled wire form")
7286
+ return
7287
+ }
7288
+ logWireMetrics(
7289
+ reassembled.size,
7290
+ data.size,
7291
+ info.fragCount,
7292
+ BleWireProtocol.PROTOCOL_V2,
7293
+ "glasses_to_phone"
7294
+ )
7295
+ processJsonMessage(json)
7296
+ } catch (e: JSONException) {
7297
+ Log.e(TAG, "Failed to parse reassembled binary wire JSON", e)
7298
+ }
7299
+ }
7300
+
7301
+ private fun compactWireJson(json: JSONObject): JSONObject {
7302
+ if (!useBinaryWireProtocol || buildNumberInt < 5) {
7303
+ return json
7304
+ }
7305
+ return try {
7306
+ BleJsonCompact.encode(json)
7307
+ } catch (_: JSONException) {
7308
+ json
7309
+ }
7310
+ }
7311
+
7312
+ private fun expandCompactWireJson(json: JSONObject): JSONObject? {
7313
+ if (!useBinaryWireProtocol || buildNumberInt < 5) {
7314
+ return json
7315
+ }
7316
+ return try {
7317
+ BleJsonCompact.decodeIfSupported(json)
7318
+ } catch (_: JSONException) {
7319
+ json
7320
+ }
7321
+ }
7322
+
7323
+ private fun logWireMetrics(
7324
+ payloadBytes: Int,
7325
+ wireBytes: Int,
7326
+ packetCount: Int,
7327
+ protocolVersion: Int,
7328
+ direction: String
7329
+ ) {
7330
+ Bridge.log(
7331
+ "BLE_TRACE direction=" +
7332
+ direction +
7333
+ " proto=v" +
7334
+ protocolVersion +
7335
+ " payload=" +
7336
+ payloadBytes +
7337
+ " wire=" +
7338
+ wireBytes +
7339
+ " packets=" +
7340
+ packetCount
7341
+ )
7342
+ }
7343
+
7042
7344
  /**
7043
7345
  * Send data directly to the glasses using the K900 protocol utility. This method uses
7044
7346
  * K900ProtocolUtils.packJsonToK900 to handle C-wrapping and protocol formatting. Large messages
@@ -7052,8 +7354,19 @@ class MentraLive : SGCManager() {
7052
7354
  return
7053
7355
  }
7054
7356
 
7357
+ val wireData =
7358
+ try {
7359
+ if (useBinaryWireProtocol && buildNumberInt >= 5) {
7360
+ compactWireJson(JSONObject(data)).toString()
7361
+ } else {
7362
+ data
7363
+ }
7364
+ } catch (_: JSONException) {
7365
+ data
7366
+ }
7367
+
7055
7368
  try {
7056
- val outgoingSummary = summarizeOutgoingMessage(data)
7369
+ val outgoingSummary = summarizeOutgoingMessage(wireData)
7057
7370
  val commandTraceInfo = parseOutgoingBleCommandTraceInfo(data)
7058
7371
  val isPhotoRequest = outgoingSummary.contains("type=take_photo")
7059
7372
  if (isPhotoRequest) {
@@ -7065,10 +7378,15 @@ class MentraLive : SGCManager() {
7065
7378
  )
7066
7379
  }
7067
7380
 
7381
+ if (useBinaryWireProtocol && buildNumberInt >= 5) {
7382
+ sendDataToGlassesBinary(wireData, wakeup, commandTraceInfo, isPhotoRequest)
7383
+ return
7384
+ }
7385
+
7068
7386
  // First check if the message needs chunking
7069
7387
  // Create a test C-wrapped version to check size
7070
7388
  val testWrapper = JSONObject()
7071
- testWrapper.put("C", data)
7389
+ testWrapper.put("C", wireData)
7072
7390
  if (wakeup) {
7073
7391
  testWrapper.put("W", 1)
7074
7392
  }
@@ -7086,14 +7404,14 @@ class MentraLive : SGCManager() {
7086
7404
  // Extract message ID if present for ACK tracking
7087
7405
  var messageId = -1L
7088
7406
  try {
7089
- val originalJson = JSONObject(data)
7407
+ val originalJson = JSONObject(wireData)
7090
7408
  messageId = originalJson.optLong("mId", -1)
7091
7409
  } catch (e: JSONException) {
7092
7410
  // Not a JSON message or no mId, that's okay
7093
7411
  }
7094
7412
 
7095
7413
  // Create chunks
7096
- val chunks = MessageChunker.createChunks(data, messageId, wakeup)
7414
+ val chunks = MessageChunker.createChunks(wireData, messageId, wakeup)
7097
7415
  Bridge.log("LIVE: Sending " + chunks.size + " chunks")
7098
7416
  if (isPhotoRequest) {
7099
7417
  Bridge.log(
@@ -7108,11 +7426,12 @@ class MentraLive : SGCManager() {
7108
7426
  val chunk = chunks[i]
7109
7427
  val chunkStr = chunk.toString()
7110
7428
 
7111
- // Pack each chunk using the normal K900 protocol
7429
+ // Pack each chunk using the normal K900 protocol with the negotiated endianness
7112
7430
  val packedData =
7113
7431
  K900ProtocolUtils.packJsonToK900(
7114
7432
  chunkStr,
7115
- wakeup && i == 0
7433
+ wakeup && i == 0,
7434
+ k900LengthEndian()
7116
7435
  ) // Only wakeup on first chunk
7117
7436
 
7118
7437
  val trace =
@@ -7134,7 +7453,7 @@ class MentraLive : SGCManager() {
7134
7453
  mapOf(
7135
7454
  "chunkJsonBytes" to
7136
7455
  chunkStr.toByteArray(StandardCharsets.UTF_8).size,
7137
- "messageBytes" to data.toByteArray(StandardCharsets.UTF_8).size
7456
+ "messageBytes" to wireData.toByteArray(StandardCharsets.UTF_8).size
7138
7457
  )
7139
7458
  )
7140
7459
 
@@ -7157,17 +7476,18 @@ class MentraLive : SGCManager() {
7157
7476
  }
7158
7477
  } else {
7159
7478
  // Normal single message transmission
7160
- Bridge.log("LIVE: Sending data to glasses: " + data)
7479
+ Bridge.log("LIVE: Sending data to glasses: " + wireData)
7161
7480
 
7162
- // Pack the data using the centralized utility
7163
- val packedData = K900ProtocolUtils.packJsonToK900(data, wakeup)
7481
+ // Pack the data using the centralized utility with the negotiated endianness
7482
+ val packedData =
7483
+ K900ProtocolUtils.packJsonToK900(wireData, wakeup, k900LengthEndian())
7164
7484
  val trace =
7165
7485
  createBleWriteTrace(
7166
7486
  commandTraceInfo,
7167
7487
  null,
7168
7488
  null,
7169
7489
  null,
7170
- data.toByteArray(StandardCharsets.UTF_8).size,
7490
+ wireData.toByteArray(StandardCharsets.UTF_8).size,
7171
7491
  packedData.size,
7172
7492
  wakeup,
7173
7493
  false
@@ -7175,7 +7495,7 @@ class MentraLive : SGCManager() {
7175
7495
  logBleChunkTrace(
7176
7496
  "created",
7177
7497
  trace,
7178
- mapOf("messageBytes" to data.toByteArray(StandardCharsets.UTF_8).size)
7498
+ mapOf("messageBytes" to wireData.toByteArray(StandardCharsets.UTF_8).size)
7179
7499
  )
7180
7500
 
7181
7501
  // Queue the data for sending
@@ -7193,6 +7513,91 @@ class MentraLive : SGCManager() {
7193
7513
  }
7194
7514
  }
7195
7515
 
7516
+ private fun sendDataToGlassesBinary(
7517
+ data: String,
7518
+ wakeup: Boolean,
7519
+ commandTraceInfo: OutgoingBleCommandTraceInfo,
7520
+ isPhotoRequest: Boolean
7521
+ ) {
7522
+ val payloadBytes = data.toByteArray(StandardCharsets.UTF_8)
7523
+ var messageId = 0
7524
+ var ackRequested = false
7525
+ try {
7526
+ val originalJson = JSONObject(data)
7527
+ messageId = (originalJson.optLong("mId", 0L) and 0xFFFFL).toInt()
7528
+ ackRequested = originalJson.has("mId")
7529
+ } catch (_: JSONException) {
7530
+ // Raw non-JSON payloads are still sent as a single binary fragment.
7531
+ }
7532
+
7533
+ val fragments =
7534
+ MessageChunker.createBinaryFragments(
7535
+ payloadBytes,
7536
+ messageId,
7537
+ wakeup,
7538
+ ackRequested
7539
+ )
7540
+ var totalWireBytes = 0
7541
+ for (i in fragments.indices) {
7542
+ val fragment = fragments[i]
7543
+ val packedData =
7544
+ K900ProtocolUtils.packBinaryFragment(
7545
+ fragment.flags,
7546
+ fragment.msgId,
7547
+ fragment.fragIdx,
7548
+ fragment.fragCount,
7549
+ fragment.payload
7550
+ )
7551
+ totalWireBytes += packedData.size
7552
+
7553
+ val trace =
7554
+ createBleWriteTrace(
7555
+ commandTraceInfo,
7556
+ null,
7557
+ fragment.fragIdx,
7558
+ fragment.fragCount,
7559
+ fragment.payload.size,
7560
+ packedData.size,
7561
+ wakeup && i == 0,
7562
+ fragments.size > 1
7563
+ )
7564
+ logBleChunkTrace(
7565
+ "created",
7566
+ trace,
7567
+ mapOf(
7568
+ "messageBytes" to payloadBytes.size,
7569
+ "wireProtocol" to "v2"
7570
+ )
7571
+ )
7572
+ queueData(packedData, trace)
7573
+
7574
+ if (i < fragments.size - 1) {
7575
+ try {
7576
+ Thread.sleep(50)
7577
+ } catch (e: InterruptedException) {
7578
+ Log.w(TAG, "Interrupted during binary fragment delay")
7579
+ }
7580
+ }
7581
+ }
7582
+
7583
+ logWireMetrics(
7584
+ payloadBytes.size,
7585
+ totalWireBytes,
7586
+ fragments.size,
7587
+ BleWireProtocol.PROTOCOL_V2,
7588
+ "phone_to_glasses"
7589
+ )
7590
+
7591
+ if (isPhotoRequest) {
7592
+ Bridge.log(
7593
+ "LIVE: PHOTO PIPELINE BLE handoff — binary v2 queued " +
7594
+ fragments.size +
7595
+ " fragments, wireBytes=" +
7596
+ totalWireBytes
7597
+ )
7598
+ }
7599
+ }
7600
+
7196
7601
  private fun parseOutgoingBleCommandTraceInfo(payload: String): OutgoingBleCommandTraceInfo {
7197
7602
  return try {
7198
7603
  val obj = JSONObject(payload)
@@ -7368,7 +7773,7 @@ class MentraLive : SGCManager() {
7368
7773
 
7369
7774
  private fun summarizeOutgoingMessage(payload: String?): String {
7370
7775
  if (payload == null || payload.isEmpty()) {
7371
- return "type=unknown, requestId=none, appId=none, transferMethod=none, bleImgId=none, exposureTimeNs=none, iso=none, mId=none"
7776
+ return "type=unknown, requestId=none, appId=none, mode=none, transferMethod=none, bleImgId=none, exposureTimeNs=none, iso=none, mId=none"
7372
7777
  }
7373
7778
  try {
7374
7779
  val obj = JSONObject(payload)
@@ -7377,6 +7782,7 @@ class MentraLive : SGCManager() {
7377
7782
  val appId = obj.optString("appId", "none")
7378
7783
  val transferMethod = obj.optString("transferMethod", "none")
7379
7784
  val bleImgId = obj.optString("bleImgId", "none")
7785
+ val mode = if (obj.has("mode")) obj.optString("mode", "photo") else "none"
7380
7786
  val exposure =
7381
7787
  if (obj.has("exposureTimeNs")) obj.optLong("exposureTimeNs").toString()
7382
7788
  else "none"
@@ -7392,6 +7798,8 @@ class MentraLive : SGCManager() {
7392
7798
  transferMethod +
7393
7799
  ", bleImgId=" +
7394
7800
  bleImgId +
7801
+ ", mode=" +
7802
+ mode +
7395
7803
  ", exposureTimeNs=" +
7396
7804
  exposure +
7397
7805
  ", iso=" +
@@ -7701,6 +8109,11 @@ class MentraLive : SGCManager() {
7701
8109
  // File Transfer Methods
7702
8110
  // ---------------------------------------
7703
8111
 
8112
+ /** Keep file assembly ordered without blocking BLE callbacks or the Android main looper. */
8113
+ private fun enqueueFilePacket(packetInfo: K900ProtocolUtils.FilePacketInfo) {
8114
+ fileProcessingHandler.post { processFilePacket(packetInfo) }
8115
+ }
8116
+
7704
8117
  /** Process a received file packet */
7705
8118
  private fun processFilePacket(packetInfo: K900ProtocolUtils.FilePacketInfo) {
7706
8119
  // Calculate total packets based on actual pack size (not hardcoded FILE_PACK_SIZE)
@@ -7708,18 +8121,15 @@ class MentraLive : SGCManager() {
7708
8121
  if (packetInfo.packSize > 0)
7709
8122
  (packetInfo.fileSize + packetInfo.packSize - 1) / packetInfo.packSize
7710
8123
  else 1
7711
- Bridge.log(
7712
- "LIVE: 📦 Processing file packet: " +
7713
- packetInfo.fileName +
7714
- " [" +
7715
- packetInfo.packIndex +
7716
- "/" +
7717
- (totalPackets - 1) +
7718
- "]" +
7719
- " (" +
7720
- packetInfo.packSize +
7721
- " bytes)"
7722
- )
8124
+ if (packetInfo.packIndex % FILE_PACKET_LOG_INTERVAL == 0 ||
8125
+ packetInfo.packIndex == totalPackets - 1
8126
+ ) {
8127
+ Log.d(
8128
+ TAG,
8129
+ "File transfer ${packetInfo.fileName}: " +
8130
+ "${packetInfo.packIndex + 1}/$totalPackets packets"
8131
+ )
8132
+ }
7723
8133
 
7724
8134
  // Check if this is a BLE photo transfer we're tracking
7725
8135
  // The filename might have an extension (.avif or .jpg), but we track by ID only
@@ -7729,8 +8139,6 @@ class MentraLive : SGCManager() {
7729
8139
  bleImgId = bleImgId.substring(0, dotIndex)
7730
8140
  }
7731
8141
 
7732
- Bridge.log("LIVE: 📦 BLE photo transfer packet for requestId: " + bleImgId)
7733
-
7734
8142
  val incidentRelay = bleIncidentLogRelays[bleImgId]
7735
8143
  if (incidentRelay != null) {
7736
8144
  Bridge.log("LIVE: 📦 BLE incident log relay packet for: " + bleImgId)
@@ -7784,18 +8192,8 @@ class MentraLive : SGCManager() {
7784
8192
  }
7785
8193
 
7786
8194
  val photoTransfer = blePhotoTransfers[bleImgId]
7787
- Bridge.log(
7788
- "LIVE: 📦 BLE photo transfer for requestId: " +
7789
- bleImgId +
7790
- " found: " +
7791
- (photoTransfer != null)
7792
- )
7793
8195
  if (photoTransfer != null) {
7794
8196
  // This is a BLE photo transfer
7795
- Bridge.log(
7796
- "LIVE: 📦 BLE photo transfer packet for requestId: " + photoTransfer.requestId
7797
- )
7798
-
7799
8197
  // Get or create session for this transfer
7800
8198
  if (photoTransfer.session == null) {
7801
8199
  photoTransfer.session =
@@ -7907,13 +8305,6 @@ class MentraLive : SGCManager() {
7907
8305
  val added = session.addPacket(packetInfo.packIndex, packetInfo.data)
7908
8306
 
7909
8307
  if (added) {
7910
- // BES chip handles ACKs automatically
7911
- Bridge.log(
7912
- "LIVE: 📦 Packet " +
7913
- packetInfo.packIndex +
7914
- " received successfully (BES will auto-ACK)"
7915
- )
7916
-
7917
8308
  // Check completion when final packet arrives or transfer is complete
7918
8309
  if (session.shouldCheckCompletion(packetInfo.packIndex)) {
7919
8310
  if (session.isComplete) {
@@ -8400,7 +8791,8 @@ class MentraLive : SGCManager() {
8400
8791
  val packedData =
8401
8792
  K900ProtocolUtils.packDataToK900(
8402
8793
  cmdObject.toString().toByteArray(StandardCharsets.UTF_8),
8403
- K900ProtocolUtils.CMD_TYPE_STRING
8794
+ K900ProtocolUtils.CMD_TYPE_STRING,
8795
+ k900LengthEndian()
8404
8796
  )
8405
8797
  if (packedData == null) {
8406
8798
  Bridge.log("LIVE: Failed to pack Voice Activity Detection setting command")