@mentra/bluetooth-sdk 0.1.19 → 0.1.21-beta.0

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 (83) hide show
  1. package/README.md +6 -2
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +239 -52
  3. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +65 -14
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +47 -4
  5. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +6 -1
  6. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +381 -97
  7. package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +333 -0
  8. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +16 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +2 -2
  10. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizer.kt +38 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +16 -9
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Mach1.kt +1 -1
  13. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +293 -174
  14. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +1 -1
  15. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +1 -1
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +4 -2
  17. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +1 -1
  18. package/android/src/main/java/com/mentra/bluetoothsdk/status/WifiHotspotStatus.kt +19 -3
  19. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +15 -2
  20. package/android/src/main/java/com/mentra/bluetoothsdk/tts/TTSTools.kt +115 -47
  21. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +2 -3
  22. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadService.java +65 -8
  23. package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +6 -5
  24. package/android/src/main/java/com/mentra/bluetoothsdk/utils/audio/PcmStreamPlayer.kt +328 -0
  25. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkExceptionTest.kt +18 -0
  26. package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +31 -0
  27. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizerTest.kt +31 -0
  28. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadServiceTest.java +9 -0
  29. package/build/BluetoothSdk.types.d.ts +71 -1
  30. package/build/BluetoothSdk.types.d.ts.map +1 -1
  31. package/build/BluetoothSdk.types.js.map +1 -1
  32. package/build/_internal.d.ts +2 -0
  33. package/build/_internal.d.ts.map +1 -1
  34. package/build/_internal.js +2 -0
  35. package/build/_internal.js.map +1 -1
  36. package/build/_private/BluetoothSdkModule.d.ts +27 -2
  37. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  38. package/build/_private/BluetoothSdkModule.js +20 -0
  39. package/build/_private/BluetoothSdkModule.js.map +1 -1
  40. package/build/_private/MentraLocalNetworkModule.d.ts +37 -0
  41. package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -0
  42. package/build/_private/MentraLocalNetworkModule.js +3 -0
  43. package/build/_private/MentraLocalNetworkModule.js.map +1 -0
  44. package/build/_private/cameraRequestPayload.d.ts.map +1 -1
  45. package/build/_private/cameraRequestPayload.js +1 -0
  46. package/build/_private/cameraRequestPayload.js.map +1 -1
  47. package/build/_private/photoRequestPayload.d.ts.map +1 -1
  48. package/build/_private/photoRequestPayload.js +2 -0
  49. package/build/_private/photoRequestPayload.js.map +1 -1
  50. package/build/index.d.ts +1 -1
  51. package/build/index.d.ts.map +1 -1
  52. package/build/index.js +9 -0
  53. package/build/index.js.map +1 -1
  54. package/expo-module.config.json +1 -0
  55. package/ios/BluetoothSdkModule.swift +72 -0
  56. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  57. package/ios/Source/Bridge.swift +72 -18
  58. package/ios/Source/DeviceManager.swift +52 -3
  59. package/ios/Source/DeviceStore.swift +6 -1
  60. package/ios/Source/MentraBluetoothSDK.swift +298 -21
  61. package/ios/Source/audio/PcmStreamPlayer.swift +410 -0
  62. package/ios/Source/camera/CameraModels.swift +22 -3
  63. package/ios/Source/sgcs/G1.swift +2 -2
  64. package/ios/Source/sgcs/G2.swift +19 -7
  65. package/ios/Source/sgcs/Mach1.swift +1 -1
  66. package/ios/Source/sgcs/MentraLive.swift +456 -52
  67. package/ios/Source/sgcs/MentraLiveL2capChannel.swift +183 -0
  68. package/ios/Source/sgcs/MentraNex.swift +1 -1
  69. package/ios/Source/sgcs/Nimo.swift +1 -1
  70. package/ios/Source/sgcs/SGCManager.swift +5 -2
  71. package/ios/Source/sgcs/Simulated.swift +1 -1
  72. package/ios/Source/status/WifiHotspotStatus.swift +21 -2
  73. package/ios/Source/streaming/StreamModels.swift +9 -0
  74. package/ios/Source/tts/TTSTools.swift +1 -1
  75. package/ios/Source/utils/G1Text.swift +30 -1
  76. package/package.json +1 -2
  77. package/src/BluetoothSdk.types.ts +75 -0
  78. package/src/_internal.ts +2 -0
  79. package/src/_private/BluetoothSdkModule.ts +68 -13
  80. package/src/_private/MentraLocalNetworkModule.ts +50 -0
  81. package/src/_private/cameraRequestPayload.ts +1 -0
  82. package/src/_private/photoRequestPayload.ts +3 -3
  83. package/src/index.ts +10 -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
@@ -33,6 +34,7 @@ import com.mentra.bluetoothsdk.Bridge
33
34
  import com.mentra.bluetoothsdk.DeviceManager
34
35
  import com.mentra.bluetoothsdk.PhotoRequest
35
36
  import com.mentra.bluetoothsdk.PhotoSize
37
+ import com.mentra.bluetoothsdk.PhotoMode
36
38
  import com.mentra.bluetoothsdk.DeviceStore
37
39
  import com.mentra.bluetoothsdk.ObservableStore
38
40
  import com.mentra.bluetoothsdk.debug.BleTraceLogger
@@ -99,6 +101,7 @@ class MentraLive : SGCManager() {
99
101
  // PSM. When the phone opens the channel, the glasses send file packets over it instead
100
102
  // of GATT FILE_READ notifications. Phone→glasses traffic stays on GATT.
101
103
  private const val L2CAP_FILE_PSM = 0x00C9
104
+ private const val FILE_PACKET_LOG_INTERVAL = 32
102
105
 
103
106
  // BLE UUIDs - updated to match K900 BES2800 MCU UUIDs for compatibility with both glass
104
107
  // types
@@ -144,6 +147,10 @@ class MentraLive : SGCManager() {
144
147
 
145
148
  // Heartbeat parameters
146
149
  private const val HEARTBEAT_INTERVAL_MS = 30000 // 30 seconds
150
+ // Grace period before an unanswered BLE wire v2 handshake is re-armed for retry,
151
+ // and the per-session cap on automatic retries (see sendWireHandshake).
152
+ private const val WIRE_HANDSHAKE_RETRY_GRACE_MS = 5000L
153
+ private const val WIRE_HANDSHAKE_MAX_ATTEMPTS = 3
147
154
  private const val BATTERY_REQUEST_EVERY_N_HEARTBEATS = 10 // Every 10 heartbeats (5 minutes)
148
155
  private const val RSSI_READ_INTERVAL_MS = 10000L // 10 seconds
149
156
 
@@ -211,11 +218,21 @@ class MentraLive : SGCManager() {
211
218
  private var peerWireProtocolVersion = 0
212
219
  private var useBinaryWireProtocol = false
213
220
  private var wireHandshakeQueued = false
221
+ private var wireHandshakeAttempts = 0
222
+ // Session generation in which the LAST outgoing v2 handshake was sent. A handshake
223
+ // reply only activates v2 when it answers a handshake from the CURRENT epoch - a
224
+ // stale handshake that survived in flight across an epoch reset must not flip the
225
+ // new session to v2 before it negotiated its own build and capabilities.
226
+ private var wireHandshakeSentGeneration = -1
227
+ // Bumped on every BLE session reset; scheduled handshake-retry callbacks capture it
228
+ // and no-op if the session changed, so a timer from a dead session can't poke a new one.
229
+ private var wireSessionGeneration = 0
214
230
  // Negotiated K900 STRING length endianness for the phone<->glasses BLE link. Defaults to
215
231
  // legacy big-endian; upgraded to little-endian only when the glasses advertise wire_caps.k900_le
216
232
  // (or a v2 binary handshake succeeds, which implies wire-v2 LE).
217
233
  private var peerK900Le = false
218
234
  private var peerWireCapsBinary = false
235
+ private var peerFilePayloadV2 = false
219
236
  // Note: appVersion, buildNumber, deviceModel, androidVersion
220
237
  // are inherited from SGCManager parent class
221
238
 
@@ -242,6 +259,9 @@ class MentraLive : SGCManager() {
242
259
  private var lc3ReadCharacteristic: BluetoothGattCharacteristic? = null
243
260
  private var lc3WriteCharacteristic: BluetoothGattCharacteristic? = null
244
261
  private var handler = Handler(Looper.getMainLooper())
262
+ private val fileProcessingThread =
263
+ HandlerThread("MentraLive-FileProcessing").apply { start() }
264
+ private val fileProcessingHandler = Handler(fileProcessingThread.looper)
245
265
  private var scheduler: ScheduledExecutorService? = null
246
266
  private var isScanning = false
247
267
  private var isConnecting = false
@@ -307,6 +327,8 @@ class MentraLive : SGCManager() {
307
327
  // So we temporarily suspend the LC3 mic during phone audio playback
308
328
  private var micIntentEnabled = false // User/system WANTS mic enabled
309
329
  private var micSuspendedForAudio = false // Mic temporarily suspended due to phone audio
330
+ override val isMicSuspendedForAudio: Boolean
331
+ get() = micSuspendedForAudio
310
332
  private var phoneAudioMonitor: PhoneAudioMonitor? = null
311
333
  private var micOnCount = 0
312
334
  private var micOffCount = 0
@@ -321,7 +343,7 @@ class MentraLive : SGCManager() {
321
343
  private var activeFileTransfers = ConcurrentHashMap<String, FileTransferSession>()
322
344
 
323
345
  // BLE photo transfer tracking
324
- private var blePhotoTransfers: MutableMap<String, BlePhotoTransfer> = HashMap()
346
+ private var blePhotoTransfers: MutableMap<String, BlePhotoTransfer> = ConcurrentHashMap()
325
347
 
326
348
  /** Expected incident log relay files from glasses (B… firmware, L… logcat). */
327
349
  private val bleIncidentLogRelays = ConcurrentHashMap<String, BleIncidentLogRelay>()
@@ -774,13 +796,12 @@ class MentraLive : SGCManager() {
774
796
  val isEqual = state == connectionState
775
797
  if (isEqual) {
776
798
  if (state == ConnTypes.DISCONNECTED) {
777
- incomingChunkReassembler.clear()
778
- peerWireProtocolVersion = 0
779
- useBinaryWireProtocol = false
780
- wireHandshakeQueued = false
781
- peerK900Le = false
782
- peerWireCapsBinary = false
783
- BleJsonCompact.resetSession()
799
+ resetWireNegotiationState()
800
+ // Queued writes are session-bound: transmitting them into the NEXT session
801
+ // (e.g. a stale handshake whose reply activates v2 before the new session
802
+ // negotiated) is the bug class this clear removes. Higher layers re-send
803
+ // what still matters via their own ACK/retry tracking.
804
+ sendQueue.clear()
784
805
  }
785
806
  return
786
807
  }
@@ -808,13 +829,9 @@ class MentraLive : SGCManager() {
808
829
  DeviceStore.apply("glasses", "connected", false)
809
830
  DeviceStore.apply("glasses", "signalStrength", -1)
810
831
  DeviceStore.apply("glasses", "signalStrengthUpdatedAt", 0L)
811
- incomingChunkReassembler.clear()
812
- peerWireProtocolVersion = 0
813
- useBinaryWireProtocol = false
814
- wireHandshakeQueued = false
815
- peerK900Le = false
816
- peerWireCapsBinary = false
817
- BleJsonCompact.resetSession()
832
+ resetWireNegotiationState()
833
+ sendQueue.clear() // see the disconnect reset above: stale writes die with the session
834
+
818
835
  // Drop OTA caches when fully disconnected — avoids leaking session/step state
819
836
  // from a previous pairing into the next one.
820
837
  resetOtaCache()
@@ -1440,6 +1457,15 @@ class MentraLive : SGCManager() {
1440
1457
  )
1441
1458
  }
1442
1459
 
1460
+ private fun phyLabel(phy: Int): String {
1461
+ return when (phy) {
1462
+ BluetoothDevice.PHY_LE_1M -> "1M"
1463
+ BluetoothDevice.PHY_LE_2M -> "2M"
1464
+ BluetoothDevice.PHY_LE_CODED -> "coded"
1465
+ else -> "unknown($phy)"
1466
+ }
1467
+ }
1468
+
1443
1469
  /** GATT callback for BLE operations */
1444
1470
  private val gattCallback: BluetoothGattCallback =
1445
1471
  object : BluetoothGattCallback() {
@@ -1463,25 +1489,6 @@ class MentraLive : SGCManager() {
1463
1489
  isConnected = true
1464
1490
  connectedDevice = gatt.device
1465
1491
 
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
-
1485
1492
  DeviceStore.apply("glasses", "bluetoothName", connectedDevice!!.name)
1486
1493
  // Persist MAC so reconnection can use direct GATT instead of scanning
1487
1494
  if (connectedDevice!!.address != null) {
@@ -1580,6 +1587,8 @@ class MentraLive : SGCManager() {
1580
1587
 
1581
1588
  // Close the L2CAP file channel (if the fast path was open)
1582
1589
  closeL2capFileChannel()
1590
+ fileProcessingHandler.removeCallbacksAndMessages(null)
1591
+ clearFilePacketBuffer()
1583
1592
 
1584
1593
  // Clean up GATT resources
1585
1594
  closeGattQuietly(false)
@@ -1635,6 +1644,8 @@ class MentraLive : SGCManager() {
1635
1644
 
1636
1645
  // Close the L2CAP file channel (if the fast path was open)
1637
1646
  closeL2capFileChannel()
1647
+ fileProcessingHandler.removeCallbacksAndMessages(null)
1648
+ clearFilePacketBuffer()
1638
1649
 
1639
1650
  // Clean up resources
1640
1651
  closeGattQuietly(false)
@@ -1647,6 +1658,38 @@ class MentraLive : SGCManager() {
1647
1658
  }
1648
1659
  }
1649
1660
 
1661
+ override fun onPhyUpdate(
1662
+ gatt: BluetoothGatt,
1663
+ txPhy: Int,
1664
+ rxPhy: Int,
1665
+ status: Int
1666
+ ) {
1667
+ Bridge.log(
1668
+ "LIVE: PHY update tx=" +
1669
+ phyLabel(txPhy) +
1670
+ ", rx=" +
1671
+ phyLabel(rxPhy) +
1672
+ ", status=" +
1673
+ status
1674
+ )
1675
+ }
1676
+
1677
+ override fun onPhyRead(
1678
+ gatt: BluetoothGatt,
1679
+ txPhy: Int,
1680
+ rxPhy: Int,
1681
+ status: Int
1682
+ ) {
1683
+ Bridge.log(
1684
+ "LIVE: PHY read tx=" +
1685
+ phyLabel(txPhy) +
1686
+ ", rx=" +
1687
+ phyLabel(rxPhy) +
1688
+ ", status=" +
1689
+ status
1690
+ )
1691
+ }
1692
+
1650
1693
  override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
1651
1694
  if (status == BluetoothGatt.GATT_SUCCESS) {
1652
1695
  Bridge.log("LIVE: GATT services discovered")
@@ -2631,18 +2674,6 @@ class MentraLive : SGCManager() {
2631
2674
  var iterations = 0
2632
2675
  val MAX_ITERATIONS = 100
2633
2676
 
2634
- // Debug: Log hex dump of first 40 bytes
2635
- val hexFirst = StringBuilder()
2636
- for (i in 0 until Math.min(40, filePacketBufferSize)) {
2637
- hexFirst.append(String.format("%02X ", filePacketBuffer[i]))
2638
- }
2639
- Bridge.log(
2640
- "LIVE: 📦 extractCompleteFilePackets: buffer has " +
2641
- filePacketBufferSize +
2642
- " bytes, first 40: " +
2643
- hexFirst.toString()
2644
- )
2645
-
2646
2677
  while (pos < filePacketBufferSize && iterations++ < MAX_ITERATIONS) {
2647
2678
  // Find start marker ## (0x23 0x23)
2648
2679
  var startPos = -1
@@ -2677,11 +2708,6 @@ class MentraLive : SGCManager() {
2677
2708
 
2678
2709
  // Need at least 5 bytes to read type and packSize: ## (2) + type (1) + packSize (2)
2679
2710
  if (filePacketBufferSize - pos < 5) {
2680
- Bridge.log(
2681
- "LIVE: 📦 Not enough data for header, have " +
2682
- (filePacketBufferSize - pos) +
2683
- " bytes, need 5"
2684
- )
2685
2711
  break
2686
2712
  }
2687
2713
 
@@ -2691,33 +2717,9 @@ class MentraLive : SGCManager() {
2691
2717
  ((filePacketBuffer[packSizeOffset].toInt() and 0xFF) shl 8) or
2692
2718
  (filePacketBuffer[packSizeOffset + 1].toInt() and 0xFF)
2693
2719
 
2694
- // Also try little-endian for comparison
2695
- val packSizeLE =
2696
- (filePacketBuffer[packSizeOffset].toInt() and 0xFF) or
2697
- ((filePacketBuffer[packSizeOffset + 1].toInt() and 0xFF) shl 8)
2698
- Bridge.log(
2699
- "LIVE: 📦 Header bytes 3-4: 0x" +
2700
- String.format(
2701
- "%02X%02X",
2702
- filePacketBuffer[packSizeOffset],
2703
- filePacketBuffer[packSizeOffset + 1]
2704
- ) +
2705
- " -> packSize BE=" +
2706
- packSize +
2707
- ", LE=" +
2708
- packSizeLE
2709
- )
2710
-
2711
2720
  // Validate packSize
2712
2721
  if (packSize < 0 || packSize > K900ProtocolUtils.FILE_PACK_SIZE) {
2713
- Log.w(
2714
- TAG,
2715
- "Invalid packSize " +
2716
- packSize +
2717
- " (LE would be " +
2718
- packSizeLE +
2719
- "), skipping start marker"
2720
- )
2722
+ Log.w(TAG, "Invalid packSize $packSize, skipping start marker")
2721
2723
  pos = startPos + 1
2722
2724
  continue
2723
2725
  }
@@ -2730,17 +2732,7 @@ class MentraLive : SGCManager() {
2730
2732
  // Check if we have the complete packet
2731
2733
  val availableBytes = filePacketBufferSize - pos
2732
2734
  if (availableBytes < expectedPacketSize) {
2733
- // Not enough data yet, wait for more fragments
2734
- Bridge.log(
2735
- "LIVE: 📦 Waiting for more data: have " +
2736
- availableBytes +
2737
- " of " +
2738
- expectedPacketSize +
2739
- " bytes (packSize=" +
2740
- packSize +
2741
- ")"
2742
- )
2743
- break // IMPORTANT: break here, don't continue looking for end marker
2735
+ break
2744
2736
  }
2745
2737
 
2746
2738
  // Verify end marker $$ at expected position
@@ -2748,25 +2740,6 @@ class MentraLive : SGCManager() {
2748
2740
  val endByte1 = filePacketBuffer[endMarkerPos]
2749
2741
  val endByte2 = filePacketBuffer[endMarkerPos + 1]
2750
2742
 
2751
- // Debug: Show bytes around expected end marker position
2752
- val endContext = StringBuilder()
2753
- for (i in
2754
- Math.max(0, endMarkerPos - 5)..Math.min(
2755
- filePacketBufferSize - 1,
2756
- endMarkerPos + 5
2757
- )) {
2758
- if (i == endMarkerPos) endContext.append("[")
2759
- endContext.append(String.format("%02X", filePacketBuffer[i]))
2760
- if (i == endMarkerPos + 1) endContext.append("]")
2761
- endContext.append(" ")
2762
- }
2763
- Bridge.log(
2764
- "LIVE: 📦 End marker check at pos " +
2765
- endMarkerPos +
2766
- ": " +
2767
- endContext.toString()
2768
- )
2769
-
2770
2743
  if (endByte1 != 0x24.toByte() || endByte2 != 0x24.toByte()) {
2771
2744
  // End marker not found - could be corrupted packet or wrong packSize interpretation
2772
2745
  Log.w(
@@ -2791,22 +2764,10 @@ class MentraLive : SGCManager() {
2791
2764
  val completePacket = ByteArray(expectedPacketSize)
2792
2765
  System.arraycopy(filePacketBuffer, pos, completePacket, 0, expectedPacketSize)
2793
2766
 
2794
- Bridge.log(
2795
- "LIVE: 📦 ✅ Complete file packet reassembled: " + expectedPacketSize + " bytes"
2796
- )
2797
-
2798
2767
  // Process the complete packet
2799
2768
  val packetInfo = K900ProtocolUtils.extractFilePacket(completePacket)
2800
2769
  if (packetInfo != null && packetInfo.isValid) {
2801
- Bridge.log(
2802
- "LIVE: 📦 ✅ Packet validated: index=" +
2803
- packetInfo.packIndex +
2804
- ", fileName=" +
2805
- packetInfo.fileName
2806
- )
2807
- // Post to handler to process outside the lock
2808
- val finalPacketInfo = packetInfo
2809
- handler.post { processFilePacket(finalPacketInfo) }
2770
+ enqueueFilePacket(packetInfo)
2810
2771
  } else {
2811
2772
  Log.e(TAG, "Failed to extract/validate reassembled file packet")
2812
2773
  }
@@ -2819,13 +2780,6 @@ class MentraLive : SGCManager() {
2819
2780
  val remaining = filePacketBufferSize - pos
2820
2781
  System.arraycopy(filePacketBuffer, pos, filePacketBuffer, 0, remaining)
2821
2782
  filePacketBufferSize = remaining
2822
- Bridge.log(
2823
- "LIVE: 📦 Removed " +
2824
- pos +
2825
- " bytes, " +
2826
- remaining +
2827
- " bytes remaining in buffer"
2828
- )
2829
2783
  } else if (pos >= filePacketBufferSize) {
2830
2784
  filePacketBufferSize = 0
2831
2785
  }
@@ -2942,7 +2896,7 @@ class MentraLive : SGCManager() {
2942
2896
  // structure
2943
2897
  val packetInfo = K900ProtocolUtils.extractFilePacket(data)
2944
2898
  if (packetInfo != null && packetInfo.isValid) {
2945
- processFilePacket(packetInfo)
2899
+ enqueueFilePacket(packetInfo)
2946
2900
  } else {
2947
2901
  Log.e(TAG, "Thread-" + threadId + ": Failed to extract or validate file packet")
2948
2902
  // BES chip handles ACKs automatically
@@ -3191,7 +3145,12 @@ class MentraLive : SGCManager() {
3191
3145
  DeviceStore.apply("glasses", "wifiError", "")
3192
3146
  }
3193
3147
 
3194
- updateWifiStatus(wifiConnectedStatus, ssid, localIp)
3148
+ updateWifiStatus(
3149
+ wifiConnectedStatus,
3150
+ ssid,
3151
+ localIp,
3152
+ wifiError.takeIf { it.isNotEmpty() }
3153
+ )
3195
3154
  }
3196
3155
  "hotspot_status_update" -> {
3197
3156
  // Process hotspot status information (same pattern as "wifi_status")
@@ -3294,7 +3253,8 @@ class MentraLive : SGCManager() {
3294
3253
 
3295
3254
  val scanComplete =
3296
3255
  json.optBoolean("scan_complete", json.optBoolean("scanComplete", false))
3297
- Bridge.updateWifiScanResults(networks, scanComplete)
3256
+ val scanId = json.optString("scanId", "").ifEmpty { null }
3257
+ Bridge.updateWifiScanResults(networks, scanComplete, scanId)
3298
3258
  }
3299
3259
  "token_status" -> {
3300
3260
  // Process coreToken acknowledgment
@@ -3547,8 +3507,14 @@ class MentraLive : SGCManager() {
3547
3507
  // Glasses SOC has booted and is ready for communication
3548
3508
  Bridge.log("LIVE: 🎉 Received glasses_ready message - SOC is booted and ready!")
3549
3509
 
3550
- // Negotiate wire capabilities advertised in the glasses_ready handshake.
3510
+ // glasses_ready is a REMOTE wire-session reset: the glasses ran
3511
+ // onTransportReset() before sending it, so their side is back on legacy
3512
+ // framing regardless of what this side negotiated earlier. Start a fresh
3513
+ // wire epoch (clears v2-active state that would otherwise gate the
3514
+ // handshake off), then negotiate from the caps this message advertises.
3515
+ resetWireNegotiationState()
3551
3516
  parsePeerWireCaps(json)
3517
+ maybeSendWireHandshake()
3552
3518
 
3553
3519
  // Set the ready flag to stop any future readiness checks
3554
3520
  glassesReady = true
@@ -3559,6 +3525,7 @@ class MentraLive : SGCManager() {
3559
3525
 
3560
3526
  // Stop the readiness check loop since we got confirmation
3561
3527
  stopReadinessCheckLoop()
3528
+ advertiseFilePayloadCapabilityToBes()
3562
3529
 
3563
3530
  // Try to open the L2CAP CoC fast path for file transfers. No-op when the
3564
3531
  // firmware doesn't support it — GATT notifications remain the default path.
@@ -3938,8 +3905,6 @@ class MentraLive : SGCManager() {
3938
3905
  val buildNumInt = Integer.parseInt(buildNum)
3939
3906
  buildNumberInt = buildNumInt
3940
3907
  Bridge.log("LIVE: Parsed build number as integer: " + buildNumInt)
3941
- parsePeerWireCaps(json)
3942
- maybeSendWireHandshake()
3943
3908
  } catch (e: NumberFormatException) {
3944
3909
  buildNumberInt = 0
3945
3910
  Log.e(TAG, "Failed to parse build number as integer: " + buildNum)
@@ -4007,6 +3972,16 @@ class MentraLive : SGCManager() {
4007
3972
  }
4008
3973
  }
4009
3974
 
3975
+ // Negotiate wire caps from ANY version_info chunk, not only the one
3976
+ // carrying build_number: the glasses put wire_caps in version_info_3
3977
+ // (firmware chunk) while build_number travels in version_info_1, so
3978
+ // gating negotiation on build_number silently discards the caps.
3979
+ // parsePeerWireCaps no-ops without a wire_caps key and
3980
+ // maybeSendWireHandshake gates on caps + build + not-yet-queued, so
3981
+ // running them per chunk is safe.
3982
+ parsePeerWireCaps(json)
3983
+ maybeSendWireHandshake()
3984
+
4010
3985
  Bridge.log("LIVE: Processed version_info fields and sent to RN")
4011
3986
  Bridge.sendVersionInfo(fields)
4012
3987
  } else {
@@ -4511,7 +4486,20 @@ class MentraLive : SGCManager() {
4511
4486
 
4512
4487
  val syntheticStatus: String
4513
4488
  if ("FINISHED" == besOtaStatus) {
4514
- syntheticStatus = "step_complete"
4489
+ // The glasses power-cycle right after the final BES tick, so a
4490
+ // session whose BES step is the LAST step never gets a follow-up
4491
+ // ota_status from the glasses — consumers mapping on this synthetic
4492
+ // status would otherwise never see a terminal state. Emit "complete"
4493
+ // for the final step; mid-session BES steps keep "step_complete" so
4494
+ // session-level trackers advance normally. Unknown sessions
4495
+ // (cachedOtaTotalSteps == 0, e.g. legacy glasses that never sent an
4496
+ // ota_status) conservatively keep "step_complete".
4497
+ syntheticStatus =
4498
+ if (cachedOtaTotalSteps > 0 &&
4499
+ cachedOtaCurrentStep >= cachedOtaTotalSteps
4500
+ )
4501
+ "complete"
4502
+ else "step_complete"
4515
4503
  } else if ("FAILED" == besOtaStatus) {
4516
4504
  syntheticStatus = "failed"
4517
4505
  } else {
@@ -4755,7 +4743,12 @@ class MentraLive : SGCManager() {
4755
4743
  /**
4756
4744
  * Update WiFi status and notify listeners Matches iOS MentraLive.swift updateWifiStatus pattern
4757
4745
  */
4758
- private fun updateWifiStatus(connected: Boolean, ssid: String, localIp: String) {
4746
+ private fun updateWifiStatus(
4747
+ connected: Boolean,
4748
+ ssid: String,
4749
+ localIp: String,
4750
+ error: String? = null
4751
+ ) {
4759
4752
  Bridge.log("LIVE: 🌐 Updating WiFi status - connected: " + connected + ", SSID: " + ssid)
4760
4753
 
4761
4754
  // Update parent SGCManager fields
@@ -4764,7 +4757,7 @@ class MentraLive : SGCManager() {
4764
4757
  DeviceStore.apply("glasses", "wifiLocalIp", localIp)
4765
4758
 
4766
4759
  // Send event to bridge for cloud communication
4767
- Bridge.sendWifiStatusChange(connected, ssid, localIp)
4760
+ Bridge.sendWifiStatusChange(connected, ssid, localIp, error)
4768
4761
  }
4769
4762
 
4770
4763
  /**
@@ -4838,10 +4831,13 @@ class MentraLive : SGCManager() {
4838
4831
  /**
4839
4832
  * Request WiFi scan from the glasses This will ask the glasses to scan for available networks
4840
4833
  */
4841
- override fun requestWifiScan() {
4834
+ override fun requestWifiScan(scanId: String?) {
4842
4835
  try {
4843
4836
  val json = JSONObject()
4844
4837
  json.put("type", "request_wifi_scan")
4838
+ if (!scanId.isNullOrEmpty()) {
4839
+ json.put("scanId", scanId)
4840
+ }
4845
4841
  sendJson(json, true)
4846
4842
  Bridge.log("LIVE: Sending WiFi scan request to glasses")
4847
4843
  } catch (e: JSONException) {
@@ -5431,6 +5427,7 @@ class MentraLive : SGCManager() {
5431
5427
  override fun requestPhoto(request: PhotoRequest) {
5432
5428
  val requestId = request.requestId
5433
5429
  val size = request.size.value
5430
+ val mode = request.mode.value
5434
5431
  val webhookUrl = request.webhookUrl
5435
5432
  val authToken = request.authToken
5436
5433
  val compress = request.compress.value
@@ -5444,6 +5441,8 @@ class MentraLive : SGCManager() {
5444
5441
  requestId +
5445
5442
  " with size: " +
5446
5443
  size +
5444
+ ", mode=" +
5445
+ mode +
5447
5446
  ", webhookUrl: " +
5448
5447
  webhookUrl +
5449
5448
  ", authToken: " +
@@ -5481,6 +5480,7 @@ class MentraLive : SGCManager() {
5481
5480
  if (size != null && !size.isEmpty()) {
5482
5481
  json.put("size", size)
5483
5482
  }
5483
+ json.put("mode", mode)
5484
5484
  if (compress != null && !compress.isEmpty()) {
5485
5485
  json.put("compress", compress)
5486
5486
  } else {
@@ -5508,8 +5508,9 @@ class MentraLive : SGCManager() {
5508
5508
  val bleImgId = "I" + String.format("%09d", System.currentTimeMillis() % 1000000000)
5509
5509
  json.put("bleImgId", bleImgId)
5510
5510
 
5511
- // Use auto mode by default - glasses will decide based on connectivity
5512
- json.put("transferMethod", "auto")
5511
+ // Miniapps may explicitly skip direct Wi-Fi upload and force the
5512
+ // phone-relayed BLE path. Auto remains the default.
5513
+ json.put("transferMethod", request.transferMethod)
5513
5514
 
5514
5515
  // Always prepare for potential BLE transfer
5515
5516
  if (webhookUrl != null && !webhookUrl.isEmpty()) {
@@ -5519,9 +5520,11 @@ class MentraLive : SGCManager() {
5519
5520
  blePhotoTransfers[bleImgId] = transfer
5520
5521
  }
5521
5522
 
5522
- Bridge.log("LIVE: Using auto transfer mode with BLE fallback ID: " + bleImgId)
5523
+ Bridge.log("LIVE: Using " + request.transferMethod + " transfer mode with BLE fallback ID: " + bleImgId)
5523
5524
  Bridge.log(
5524
- "LIVE: PHOTO PIPELINE [5b/6] JSON ready " +
5525
+ "LIVE: PHOTO PIPELINE [5b/6] JSON ready mode=" +
5526
+ mode +
5527
+ " — " +
5525
5528
  summarizeOutgoingMessage(json.toString()) +
5526
5529
  ", wakeup=true"
5527
5530
  )
@@ -5536,6 +5539,7 @@ class MentraLive : SGCManager() {
5536
5539
  fun warmUpCamera(
5537
5540
  requestId: String,
5538
5541
  size: PhotoSize,
5542
+ mode: PhotoMode = PhotoMode.PHOTO,
5539
5543
  exposureTimeNs: Long?,
5540
5544
  durationMs: Int,
5541
5545
  ) {
@@ -5544,6 +5548,8 @@ class MentraLive : SGCManager() {
5544
5548
  requestId +
5545
5549
  ", size=" +
5546
5550
  size.value +
5551
+ ", mode=" +
5552
+ mode.value +
5547
5553
  ", durationMs=" +
5548
5554
  durationMs
5549
5555
  )
@@ -5554,6 +5560,7 @@ class MentraLive : SGCManager() {
5554
5560
  json.put("requestId", requestId)
5555
5561
  val sizeValue = size.value
5556
5562
  json.put("size", if (sizeValue.isNotEmpty()) sizeValue else "medium")
5563
+ json.put("mode", mode.value)
5557
5564
  if (exposureTimeNs != null && exposureTimeNs > 0L) {
5558
5565
  json.put("exposureTimeNs", exposureTimeNs)
5559
5566
  }
@@ -5564,6 +5571,16 @@ class MentraLive : SGCManager() {
5564
5571
  }
5565
5572
  }
5566
5573
 
5574
+ fun stopCameraWarmUp(requestId: String) {
5575
+ if (!isConnected) {
5576
+ throw IllegalStateException("not_connected")
5577
+ }
5578
+ val json = JSONObject()
5579
+ json.put("type", "camera_warm_up_stop")
5580
+ json.put("requestId", requestId)
5581
+ sendJson(json, true)
5582
+ }
5583
+
5567
5584
  override fun startStream(message: MutableMap<String, Any>) {
5568
5585
  Bridge.log("LIVE: Starting RTMP stream")
5569
5586
 
@@ -6178,6 +6195,7 @@ class MentraLive : SGCManager() {
6178
6195
 
6179
6196
  // Cancel any pending handlers
6180
6197
  handler.removeCallbacksAndMessages(null)
6198
+ fileProcessingHandler.removeCallbacksAndMessages(null)
6181
6199
  heartbeatHandler.removeCallbacksAndMessages(null)
6182
6200
  rssiReadHandler.removeCallbacksAndMessages(null)
6183
6201
  micBeatHandler.removeCallbacksAndMessages(null)
@@ -6209,6 +6227,7 @@ class MentraLive : SGCManager() {
6209
6227
 
6210
6228
  // Clear file packet reassembly buffer
6211
6229
  clearFilePacketBuffer()
6230
+ fileProcessingThread.quitSafely()
6212
6231
 
6213
6232
  // Reset state variables
6214
6233
  reconnectAttempts = 0
@@ -7208,6 +7227,28 @@ class MentraLive : SGCManager() {
7208
7227
  * negotiated per-link endianness and binary support. Missing wire_caps leaves the legacy
7209
7228
  * defaults (BE, no binary) untouched so older glasses keep working.
7210
7229
  */
7230
+ /**
7231
+ * Drop every negotiated wire-session artifact and bump the session generation so
7232
+ * scheduled callbacks from the old session stand down. Called on BLE disconnect and on
7233
+ * glasses_ready: the glasses run onTransportReset() before sending glasses_ready, so a
7234
+ * mid-link ASG restart (no BLE disconnect) resets THEIR side to legacy framing - the
7235
+ * phone must treat every glasses_ready as a new wire epoch and renegotiate (bounded by
7236
+ * the per-session handshake attempt cap) instead of trusting stale v2-active state.
7237
+ */
7238
+ private fun resetWireNegotiationState() {
7239
+ incomingChunkReassembler.clear()
7240
+ peerWireProtocolVersion = 0
7241
+ useBinaryWireProtocol = false
7242
+ wireHandshakeQueued = false
7243
+ wireHandshakeAttempts = 0
7244
+ wireSessionGeneration++
7245
+ peerK900Le = false
7246
+ peerWireCapsBinary = false
7247
+ peerFilePayloadV2 = false
7248
+ BleJsonCompact.resetSession()
7249
+ wireHandshakeSentGeneration = -1
7250
+ }
7251
+
7211
7252
  private fun parsePeerWireCaps(json: JSONObject) {
7212
7253
  val caps = json.optJSONObject("wire_caps") ?: return
7213
7254
  if (caps.optBoolean("k900_le", false)) {
@@ -7219,6 +7260,32 @@ class MentraLive : SGCManager() {
7219
7260
  if (caps.has("binary")) {
7220
7261
  peerWireCapsBinary = caps.optBoolean("binary", false)
7221
7262
  }
7263
+ if (caps.has("file_payload_v2")) {
7264
+ peerFilePayloadV2 = caps.optBoolean("file_payload_v2", false)
7265
+ }
7266
+ }
7267
+
7268
+ private fun advertiseFilePayloadCapabilityToBes() {
7269
+ if (!peerFilePayloadV2) return
7270
+ try {
7271
+ val command = JSONObject()
7272
+ command.put("C", "cs_file_payload")
7273
+ command.put("B", "{\"max\":" + K900ProtocolUtils.FILE_PACK_SIZE + "}")
7274
+ val packed =
7275
+ K900ProtocolUtils.packDataToK900(
7276
+ command.toString().toByteArray(StandardCharsets.UTF_8),
7277
+ K900ProtocolUtils.CMD_TYPE_STRING,
7278
+ k900LengthEndian()
7279
+ )
7280
+ queueData(packed)
7281
+ Bridge.log(
7282
+ "LIVE: 📦 Advertised file payload ceiling " +
7283
+ K900ProtocolUtils.FILE_PACK_SIZE +
7284
+ " to BES"
7285
+ )
7286
+ } catch (e: JSONException) {
7287
+ Log.e(TAG, "Failed to advertise file payload capability", e)
7288
+ }
7222
7289
  }
7223
7290
 
7224
7291
  private fun maybeSendWireHandshake() {
@@ -7253,7 +7320,39 @@ class MentraLive : SGCManager() {
7253
7320
  )
7254
7321
  Bridge.log("LIVE: Sending BLE wire v2 handshake")
7255
7322
  wireHandshakeQueued = true
7323
+ wireHandshakeSentGeneration = wireSessionGeneration
7256
7324
  queueData(packed, null)
7325
+ // The handshake and its reply are fire-and-forget binary frames with no ACK
7326
+ // tracking; if either is lost, wireHandshakeQueued would block every future
7327
+ // attempt and the session would silently stay on the legacy string wire.
7328
+ // Re-arm after a grace period so negotiation can retry, bounded per session
7329
+ // so a peer that advertises binary but never answers doesn't get pinged
7330
+ // forever (later caps/version triggers may still retry explicitly).
7331
+ wireHandshakeAttempts++
7332
+ val scheduledGeneration = wireSessionGeneration
7333
+ handler.postDelayed(
7334
+ {
7335
+ if (scheduledGeneration == wireSessionGeneration &&
7336
+ wireHandshakeQueued &&
7337
+ peerWireProtocolVersion < BleWireProtocol.PROTOCOL_V2
7338
+ ) {
7339
+ Bridge.log(
7340
+ "LIVE: BLE wire v2 handshake unanswered after " +
7341
+ WIRE_HANDSHAKE_RETRY_GRACE_MS +
7342
+ "ms (attempt " +
7343
+ wireHandshakeAttempts +
7344
+ "/" +
7345
+ WIRE_HANDSHAKE_MAX_ATTEMPTS +
7346
+ ") - re-arming"
7347
+ )
7348
+ wireHandshakeQueued = false
7349
+ if (wireHandshakeAttempts < WIRE_HANDSHAKE_MAX_ATTEMPTS) {
7350
+ maybeSendWireHandshake()
7351
+ }
7352
+ }
7353
+ },
7354
+ WIRE_HANDSHAKE_RETRY_GRACE_MS
7355
+ )
7257
7356
  } catch (e: Exception) {
7258
7357
  Log.e(TAG, "Failed to send wire handshake", e)
7259
7358
  }
@@ -7263,12 +7362,17 @@ class MentraLive : SGCManager() {
7263
7362
  peerWireProtocolVersion = BleWireProtocol.PROTOCOL_V2
7264
7363
  useBinaryWireProtocol = true
7265
7364
  wireHandshakeQueued = false
7365
+ wireHandshakeAttempts = 0
7266
7366
  peerK900Le = true
7267
7367
  BleJsonCompact.markSessionConnected(System.currentTimeMillis())
7268
7368
  Bridge.log(logMessage)
7269
7369
  }
7270
7370
 
7271
7371
  private fun handlePeerWireHandshake() {
7372
+ if (wireHandshakeSentGeneration != wireSessionGeneration) {
7373
+ Bridge.log("LIVE: Ignoring wire v2 handshake reply from a previous session epoch")
7374
+ return
7375
+ }
7272
7376
  activateBinaryWireV2Session("LIVE: Peer confirmed BLE wire protocol v2")
7273
7377
  }
7274
7378
 
@@ -7793,7 +7897,7 @@ class MentraLive : SGCManager() {
7793
7897
 
7794
7898
  private fun summarizeOutgoingMessage(payload: String?): String {
7795
7899
  if (payload == null || payload.isEmpty()) {
7796
- return "type=unknown, requestId=none, appId=none, transferMethod=none, bleImgId=none, exposureTimeNs=none, iso=none, mId=none"
7900
+ return "type=unknown, requestId=none, appId=none, mode=none, transferMethod=none, bleImgId=none, exposureTimeNs=none, iso=none, mId=none"
7797
7901
  }
7798
7902
  try {
7799
7903
  val obj = JSONObject(payload)
@@ -7802,6 +7906,7 @@ class MentraLive : SGCManager() {
7802
7906
  val appId = obj.optString("appId", "none")
7803
7907
  val transferMethod = obj.optString("transferMethod", "none")
7804
7908
  val bleImgId = obj.optString("bleImgId", "none")
7909
+ val mode = if (obj.has("mode")) obj.optString("mode", "photo") else "none"
7805
7910
  val exposure =
7806
7911
  if (obj.has("exposureTimeNs")) obj.optLong("exposureTimeNs").toString()
7807
7912
  else "none"
@@ -7817,6 +7922,8 @@ class MentraLive : SGCManager() {
7817
7922
  transferMethod +
7818
7923
  ", bleImgId=" +
7819
7924
  bleImgId +
7925
+ ", mode=" +
7926
+ mode +
7820
7927
  ", exposureTimeNs=" +
7821
7928
  exposure +
7822
7929
  ", iso=" +
@@ -7964,7 +8071,7 @@ class MentraLive : SGCManager() {
7964
8071
  val type = json.optString("type", "")
7965
8072
 
7966
8073
  when (type) {
7967
- "request_wifi_scan" -> requestWifiScan()
8074
+ "request_wifi_scan" -> requestWifiScan(null)
7968
8075
  "rgb_led_control_on", "rgb_led_control_off" -> {
7969
8076
  // Forward LED control commands directly to glasses via BLE
7970
8077
  Log.d(TAG, "💡 Forwarding LED control command to glasses: " + type)
@@ -8126,6 +8233,11 @@ class MentraLive : SGCManager() {
8126
8233
  // File Transfer Methods
8127
8234
  // ---------------------------------------
8128
8235
 
8236
+ /** Keep file assembly ordered without blocking BLE callbacks or the Android main looper. */
8237
+ private fun enqueueFilePacket(packetInfo: K900ProtocolUtils.FilePacketInfo) {
8238
+ fileProcessingHandler.post { processFilePacket(packetInfo) }
8239
+ }
8240
+
8129
8241
  /** Process a received file packet */
8130
8242
  private fun processFilePacket(packetInfo: K900ProtocolUtils.FilePacketInfo) {
8131
8243
  // Calculate total packets based on actual pack size (not hardcoded FILE_PACK_SIZE)
@@ -8133,18 +8245,15 @@ class MentraLive : SGCManager() {
8133
8245
  if (packetInfo.packSize > 0)
8134
8246
  (packetInfo.fileSize + packetInfo.packSize - 1) / packetInfo.packSize
8135
8247
  else 1
8136
- Bridge.log(
8137
- "LIVE: 📦 Processing file packet: " +
8138
- packetInfo.fileName +
8139
- " [" +
8140
- packetInfo.packIndex +
8141
- "/" +
8142
- (totalPackets - 1) +
8143
- "]" +
8144
- " (" +
8145
- packetInfo.packSize +
8146
- " bytes)"
8147
- )
8248
+ if (packetInfo.packIndex % FILE_PACKET_LOG_INTERVAL == 0 ||
8249
+ packetInfo.packIndex == totalPackets - 1
8250
+ ) {
8251
+ Log.d(
8252
+ TAG,
8253
+ "File transfer ${packetInfo.fileName}: " +
8254
+ "${packetInfo.packIndex + 1}/$totalPackets packets"
8255
+ )
8256
+ }
8148
8257
 
8149
8258
  // Check if this is a BLE photo transfer we're tracking
8150
8259
  // The filename might have an extension (.avif or .jpg), but we track by ID only
@@ -8154,8 +8263,6 @@ class MentraLive : SGCManager() {
8154
8263
  bleImgId = bleImgId.substring(0, dotIndex)
8155
8264
  }
8156
8265
 
8157
- Bridge.log("LIVE: 📦 BLE photo transfer packet for requestId: " + bleImgId)
8158
-
8159
8266
  val incidentRelay = bleIncidentLogRelays[bleImgId]
8160
8267
  if (incidentRelay != null) {
8161
8268
  Bridge.log("LIVE: 📦 BLE incident log relay packet for: " + bleImgId)
@@ -8209,18 +8316,8 @@ class MentraLive : SGCManager() {
8209
8316
  }
8210
8317
 
8211
8318
  val photoTransfer = blePhotoTransfers[bleImgId]
8212
- Bridge.log(
8213
- "LIVE: 📦 BLE photo transfer for requestId: " +
8214
- bleImgId +
8215
- " found: " +
8216
- (photoTransfer != null)
8217
- )
8218
8319
  if (photoTransfer != null) {
8219
8320
  // This is a BLE photo transfer
8220
- Bridge.log(
8221
- "LIVE: 📦 BLE photo transfer packet for requestId: " + photoTransfer.requestId
8222
- )
8223
-
8224
8321
  // Get or create session for this transfer
8225
8322
  if (photoTransfer.session == null) {
8226
8323
  photoTransfer.session =
@@ -8332,13 +8429,6 @@ class MentraLive : SGCManager() {
8332
8429
  val added = session.addPacket(packetInfo.packIndex, packetInfo.data)
8333
8430
 
8334
8431
  if (added) {
8335
- // BES chip handles ACKs automatically
8336
- Bridge.log(
8337
- "LIVE: 📦 Packet " +
8338
- packetInfo.packIndex +
8339
- " received successfully (BES will auto-ACK)"
8340
- )
8341
-
8342
8432
  // Check completion when final packet arrives or transfer is complete
8343
8433
  if (session.shouldCheckCompletion(packetInfo.packIndex)) {
8344
8434
  if (session.isComplete) {
@@ -8860,7 +8950,7 @@ class MentraLive : SGCManager() {
8860
8950
 
8861
8951
  /** Send camera FOV setting to glasses (K900 / Mentra Live). */
8862
8952
  override fun sendCameraFovSetting() {
8863
- var fov = 118
8953
+ var fov = 102
8864
8954
  var roiPosition = 0
8865
8955
  try {
8866
8956
  val raw = DeviceStore.get("bluetooth", "camera_fov")
@@ -8902,6 +8992,35 @@ class MentraLive : SGCManager() {
8902
8992
  }
8903
8993
  }
8904
8994
 
8995
+ fun sendCameraFovOverride(
8996
+ requestId: String,
8997
+ leaseId: String,
8998
+ fov: Int,
8999
+ roiPosition: Int,
9000
+ ttlMs: Int,
9001
+ ) {
9002
+ val json = JSONObject()
9003
+ json.put("type", "camera_fov_override")
9004
+ json.put("request_id", requestId)
9005
+ json.put(
9006
+ "params",
9007
+ JSONObject()
9008
+ .put("lease_id", leaseId)
9009
+ .put("fov", fov)
9010
+ .put("roi_position", roiPosition)
9011
+ .put("ttl_ms", ttlMs),
9012
+ )
9013
+ sendJson(json, true)
9014
+ }
9015
+
9016
+ fun releaseCameraFovOverride(requestId: String, leaseId: String) {
9017
+ val json = JSONObject()
9018
+ json.put("type", "camera_fov_override_release")
9019
+ json.put("request_id", requestId)
9020
+ json.put("params", JSONObject().put("lease_id", leaseId))
9021
+ sendJson(json, true)
9022
+ }
9023
+
8905
9024
  /**
8906
9025
  * Send camera tuning config (ANR / gain) to the glasses via the {@code camera_tuning_config}
8907
9026
  * command. The ASG client relays this as a {@code camconfig} broadcast to the camera HAL.