@mentra/bluetooth-sdk 3.2.1-dev.311 → 3.2.1

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 (133) hide show
  1. package/README.md +4 -4
  2. package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +368 -0
  3. package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocol.kt +156 -0
  4. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +3 -0
  5. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +6 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +2 -1
  7. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +129 -25
  8. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +37 -13
  9. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +2 -2
  10. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  11. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +4 -0
  12. package/android/src/main/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulator.kt +13 -2
  13. package/android/src/main/java/com/mentra/bluetoothsdk/requests/DisplayRequests.kt +1 -1
  14. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +33 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/services/G2ConnectionRecovery.kt +47 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +2 -2
  17. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +72 -21
  18. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +101 -22
  19. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +12 -8
  20. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +314 -395
  21. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodec.kt +245 -0
  22. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinator.kt +238 -0
  23. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasEncoder.kt +58 -0
  24. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSession.kt +180 -0
  25. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +11 -0
  26. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SelectedDeviceAddress.kt +17 -0
  27. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SerializedGattCallback.kt +5 -0
  28. package/android/src/main/java/com/mentra/bluetoothsdk/utils/G2Text.kt +7 -0
  29. package/android/src/release/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +20 -0
  30. package/android/src/test/java/com/mentra/bluetoothsdk/DashboardContentFormatterTest.kt +2 -2
  31. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceIdentityPairingTest.kt +59 -0
  32. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerSceneHandoffTest.kt +666 -0
  33. package/android/src/test/java/com/mentra/bluetoothsdk/G2TextTest.kt +13 -0
  34. package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulatorTest.kt +71 -0
  35. package/android/src/test/java/com/mentra/bluetoothsdk/services/G2ConnectionRecoveryTest.kt +51 -0
  36. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1PairingTest.kt +105 -0
  37. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2DisplayThreadTest.kt +120 -0
  38. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2PairingTargetTest.kt +90 -0
  39. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveFileTransferSessionTest.kt +55 -0
  40. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCallbackTest.kt +33 -0
  41. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingTargetTest.kt +65 -0
  42. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraNexPairingTest.kt +151 -0
  43. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoBitmapAcceptanceTest.kt +105 -0
  44. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodecTest.kt +220 -0
  45. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinatorTest.kt +348 -0
  46. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSessionTest.kt +339 -0
  47. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoMicReconnectTest.kt +93 -0
  48. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoProtocolTest.kt +11 -0
  49. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/SelectedDeviceAddressTest.kt +27 -0
  50. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnosticsTest.kt +287 -0
  51. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocolTest.kt +127 -0
  52. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoLifecycleTest.kt +288 -0
  53. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoReadinessDriverTest.kt +205 -0
  54. package/build/BluetoothSdk.types.d.ts +13 -0
  55. package/build/BluetoothSdk.types.d.ts.map +1 -1
  56. package/build/BluetoothSdk.types.js +1 -0
  57. package/build/BluetoothSdk.types.js.map +1 -1
  58. package/build/_private/BluetoothSdkModule.d.ts +4 -0
  59. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  60. package/build/_private/BluetoothSdkModule.js +3 -0
  61. package/build/_private/BluetoothSdkModule.js.map +1 -1
  62. package/build/generated/changelogCatalog.d.ts +2 -2
  63. package/build/generated/changelogCatalog.js +2 -2
  64. package/build/generated/changelogCatalog.js.map +1 -1
  65. package/build/generated/releaseMetadata.js +5 -5
  66. package/build/generated/releaseMetadata.js.map +1 -1
  67. package/build/index.d.ts.map +1 -1
  68. package/build/index.js +1 -0
  69. package/build/index.js.map +1 -1
  70. package/ios/BluetoothSdkModule.swift +9 -0
  71. package/ios/MentraBluetoothSDK.podspec +3 -2
  72. package/ios/Source/BluetoothBackgroundMode.swift +32 -0
  73. package/ios/Source/BluetoothSdkDefaults.swift +6 -1
  74. package/ios/Source/Bridge.swift +15 -2
  75. package/ios/Source/DeviceManager.swift +96 -63
  76. package/ios/Source/DeviceStore.swift +26 -7
  77. package/ios/Source/GeneratedChangelogCatalog.swift +2 -2
  78. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  79. package/ios/Source/MentraBluetoothSDK.swift +18 -0
  80. package/ios/Source/audio/GlassesMicWatchdog.swift +33 -0
  81. package/ios/Source/requests/DisplayRequests.swift +1 -1
  82. package/ios/Source/requests/VersionInfoResponseAccumulator.swift +11 -1
  83. package/ios/Source/sgcs/Ar99.swift +1 -2
  84. package/ios/Source/sgcs/G1.swift +68 -38
  85. package/ios/Source/sgcs/G1ConnectionTarget.swift +13 -0
  86. package/ios/Source/sgcs/G2.swift +261 -247
  87. package/ios/Source/sgcs/G2ConnectionSession.swift +101 -0
  88. package/ios/Source/sgcs/Mach1.swift +19 -7
  89. package/ios/Source/sgcs/MentraLive.swift +201 -156
  90. package/ios/Source/sgcs/MentraLiveFileTransferSession.swift +137 -0
  91. package/ios/Source/sgcs/MentraLivePairingAdvertisement.swift +47 -0
  92. package/ios/Source/sgcs/MentraNex.swift +8 -31
  93. package/ios/Source/sgcs/NexConnectionTarget.swift +15 -0
  94. package/ios/Source/sgcs/Nimo.swift +280 -408
  95. package/ios/Source/sgcs/NimoCanvasCodec.swift +269 -0
  96. package/ios/Source/sgcs/NimoCanvasCoordinator.swift +233 -0
  97. package/ios/Source/sgcs/NimoCanvasImage.swift +53 -0
  98. package/ios/Source/sgcs/NimoCanvasSession.swift +166 -0
  99. package/ios/Source/sgcs/NimoDiscovery.swift +49 -0
  100. package/ios/Source/sgcs/SGCManager.swift +24 -0
  101. package/ios/Source/sgcs/VuzixConnectionTarget.swift +15 -0
  102. package/ios/Source/status/DeviceStatus.swift +1 -0
  103. package/ios/Source/stt/SherpaOnnxTranscriber.swift +49 -189
  104. package/ios/Source/utils/DemandDrivenAudioWorker.swift +150 -0
  105. package/ios/Source/utils/G2Text.swift +11 -0
  106. package/ios/Tests/BluetoothBackgroundModeTests.swift +32 -0
  107. package/ios/Tests/DashboardContentFormatterTests.swift +2 -2
  108. package/ios/Tests/DashboardRenderingTests.swift +31 -0
  109. package/ios/Tests/DemandDrivenAudioWorkerTests.swift +157 -0
  110. package/ios/Tests/DeviceIdentityPairingTests.swift +49 -0
  111. package/ios/Tests/DeviceManagerReconnectTests.swift +362 -0
  112. package/ios/Tests/G1PairingTests.swift +162 -0
  113. package/ios/Tests/G2ConnectionSessionTests.swift +172 -0
  114. package/ios/Tests/G2TextTests.swift +10 -0
  115. package/ios/Tests/MentraLiveFileTransferSessionTests.swift +47 -0
  116. package/ios/Tests/MentraLiveReconnectPolicyTests.swift +58 -0
  117. package/ios/Tests/NimoCanvasCodecTests.swift +204 -0
  118. package/ios/Tests/NimoCanvasSessionTests.swift +181 -0
  119. package/ios/Tests/NimoCanvasTransportTests.swift +186 -0
  120. package/ios/Tests/NimoDeviceCommandsTests.swift +16 -0
  121. package/ios/Tests/NimoDiscoveryTests.swift +79 -0
  122. package/ios/Tests/SGCConnectionTargetTests.swift +48 -0
  123. package/ios/Tests/VersionInfoCommandTests.swift +53 -0
  124. package/ios/Tests/VersionInfoResponseAccumulatorTests.swift +65 -0
  125. package/ios/Tests/VideoRecordingCommandTests.swift +81 -0
  126. package/package.json +2 -2
  127. package/plugin/build/withIos.d.ts +2 -0
  128. package/plugin/build/withIos.js +12 -1
  129. package/src/BluetoothSdk.types.ts +14 -0
  130. package/src/_private/BluetoothSdkModule.ts +8 -0
  131. package/src/generated/changelogCatalog.ts +2 -2
  132. package/src/generated/releaseMetadata.ts +5 -5
  133. package/src/index.ts +1 -0
@@ -677,145 +677,13 @@ enum K900ProtocolUtils {
677
677
  }
678
678
  }
679
679
 
680
- private struct FileTransferSession {
681
- let fileName: String
682
- let fileSize: Int // NOTE: May be "fake" (inflated) due to BES firmware workaround
683
- var actualPackSize: Int = 0 // Actual pack size from first received packet
684
- var totalPackets: Int
685
- var expectedNextPacket: Int = 0
686
- var receivedPackets: [Int: Data] = [:]
687
- let startTime: Date
688
- var isComplete: Bool = false
689
- var isAnnounced: Bool = false
690
-
691
- /// BES2700 firmware hardcodes FILE_PACK_SIZE=400 when calculating totalPack.
692
- /// Android glasses "lie" about fileSize to make BES expect correct packet count.
693
- private static let BES_HARDCODED_PACK_SIZE = 400
694
-
695
- init(fileName: String, fileSize: Int, announcedPackets: Int? = nil) {
696
- self.fileName = fileName
697
- self.fileSize = fileSize
698
- let computedPackets =
699
- (fileSize + K900ProtocolUtils.FILE_PACK_SIZE - 1) / K900ProtocolUtils.FILE_PACK_SIZE
700
- if let announced = announcedPackets, announced > 0 {
701
- totalPackets = announced
702
- isAnnounced = true
703
- } else {
704
- totalPackets = computedPackets
705
- isAnnounced = false
706
- }
707
- startTime = Date()
708
- }
709
-
710
- mutating func updateAnnouncedPackets(_ announced: Int) {
711
- guard announced > 0 else { return }
712
- totalPackets = announced
713
- isAnnounced = true
714
- if expectedNextPacket >= totalPackets {
715
- expectedNextPacket = min(expectedNextPacket, max(totalPackets - 1, 0))
716
- }
717
- }
718
-
719
- /// Recalculate total packets based on actual pack size from received packet.
720
- /// Detects BES lie: if fileSize is multiple of 400 but actual pack size differs.
721
- mutating func recalculateTotalPackets(actualPackSize: Int) {
722
- guard actualPackSize > 0, actualPackSize <= K900ProtocolUtils.FILE_PACK_SIZE else { return }
723
-
724
- self.actualPackSize = actualPackSize
725
-
726
- // Detect BES lie: if fileSize is exact multiple of 400, glasses used the lie strategy
727
- let isBesLie =
728
- (fileSize % Self.BES_HARDCODED_PACK_SIZE == 0)
729
- && (actualPackSize != Self.BES_HARDCODED_PACK_SIZE)
730
-
731
- let newTotalPackets: Int
732
- if isBesLie {
733
- // BES lie detected: totalPackets = fileSize / 400
734
- newTotalPackets = fileSize / Self.BES_HARDCODED_PACK_SIZE
735
- Bridge.log(
736
- "📦 BES Lie detected! fakeFileSize=\(fileSize), totalPackets=\(newTotalPackets), actualPackSize=\(actualPackSize)"
737
- )
738
- } else {
739
- // Normal case: calculate based on actual pack size
740
- newTotalPackets = (fileSize + actualPackSize - 1) / actualPackSize
741
- }
742
-
743
- if newTotalPackets != totalPackets {
744
- Bridge.log(
745
- "📦 Recalculating totalPackets: \(totalPackets) -> \(newTotalPackets) (packSize=\(actualPackSize), fileSize=\(fileSize))"
746
- )
747
- totalPackets = newTotalPackets
748
- }
749
- }
750
-
751
- mutating func addPacket(_ index: Int, data: Data) -> Bool {
752
- guard index >= 0 else { return false }
753
-
754
- // On first packet, recalculate total packets only when we do not already
755
- // have an authoritative pack size from protocol metadata.
756
- if receivedPackets.isEmpty && actualPackSize == 0 && !data.isEmpty {
757
- recalculateTotalPackets(actualPackSize: data.count)
758
- }
759
-
760
- if index >= totalPackets {
761
- totalPackets = index + 1
762
- }
763
-
764
- guard receivedPackets[index] == nil else {
765
- return false
766
- }
767
-
768
- receivedPackets[index] = data
769
-
770
- while receivedPackets[expectedNextPacket] != nil, expectedNextPacket < totalPackets {
771
- expectedNextPacket += 1
772
- }
773
-
774
- isComplete = (receivedPackets.count == totalPackets)
775
- return true
776
- }
777
-
778
- func isFinalPacket(_ index: Int) -> Bool {
779
- index == totalPackets - 1
780
- }
781
-
782
- func missingPacketIndices() -> [Int] {
783
- guard totalPackets > receivedPackets.count else { return [] }
784
- return (0 ..< totalPackets).compactMap { receivedPackets[$0] == nil ? $0 : nil }
785
- }
786
-
787
- /// Assemble file from received packets.
788
- /// NOTE: Calculates actual file size from received data, NOT from header fileSize,
789
- /// because fileSize may be "fake" (inflated) due to BES firmware workaround.
790
- func assembleFile() -> Data? {
791
- guard isComplete else { return nil }
792
-
793
- // Calculate actual file size by summing all received packet sizes
794
- let actualFileSize = receivedPackets.values.reduce(0) { $0 + $1.count }
795
-
796
- Bridge.log(
797
- "📦 Assembling file: headerFileSize=\(fileSize), actualFileSize=\(actualFileSize), totalPackets=\(totalPackets)"
798
- )
799
-
800
- var fileData = Data(capacity: actualFileSize)
801
-
802
- for i in 0 ..< totalPackets {
803
- if let packet = receivedPackets[i] {
804
- fileData.append(packet)
805
- }
806
- }
807
-
808
- return fileData
809
- }
810
- }
811
-
812
680
  private struct BlePhotoTransfer {
813
681
  var isThumbnail = false
814
682
  let bleImgId: String
815
683
  let requestId: String
816
684
  let webhookUrl: String
817
685
  var authToken: String?
818
- var session: FileTransferSession?
686
+ var session: MentraLiveFileTransferSession?
819
687
  let phoneStartTime: Date
820
688
  var bleTransferStartTime: Date?
821
689
  var glassesCompressionDurationMs: Int64 = 0
@@ -838,7 +706,7 @@ private final class BleIncidentLogRelayEntry {
838
706
  let incidentId: String
839
707
  let apiBaseUrl: String
840
708
  let kind: BleIncidentLogRelayKind
841
- var session: FileTransferSession?
709
+ var session: MentraLiveFileTransferSession?
842
710
 
843
711
  init(
844
712
  fileBaseKey: String, incidentId: String, apiBaseUrl: String, kind: BleIncidentLogRelayKind
@@ -994,6 +862,7 @@ extension MentraLive: CBCentralManagerDelegate {
994
862
  Bridge.log("Connected to GATT server, discovering services...")
995
863
 
996
864
  self.stopConnectionTimeout()
865
+ self.clearPendingReconnectTracking()
997
866
  self.isConnecting = false
998
867
  self.connectingPeripheral = nil
999
868
  self.connectedPeripheral = peripheral
@@ -1048,6 +917,7 @@ extension MentraLive: CBCentralManagerDelegate {
1048
917
  return
1049
918
  }
1050
919
  self.connectingPeripheral = nil
920
+ self.clearPendingReconnectTracking()
1051
921
  Bridge.log("LIVE: Disconnected from GATT server")
1052
922
 
1053
923
  self.isConnecting = false
@@ -1077,8 +947,9 @@ extension MentraLive: CBCentralManagerDelegate {
1077
947
  return
1078
948
  }
1079
949
 
1080
- // Attempt reconnection if not killed
1081
- if !self.isKilled {
950
+ // Prefer a pending direct reconnect: it is the only path iOS completes while the
951
+ // app is suspended. Fall back to the scan backoff when it does not apply.
952
+ if !self.isKilled, !self.reconnectDirectly(to: peripheral) {
1082
953
  self.handleReconnection()
1083
954
  }
1084
955
  }
@@ -1098,6 +969,7 @@ extension MentraLive: CBCentralManagerDelegate {
1098
969
  Bridge.log("LIVE: Failed to connect to peripheral: \(errorDescription)")
1099
970
 
1100
971
  self.stopConnectionTimeout()
972
+ self.clearPendingReconnectTracking()
1101
973
  self.isConnecting = false
1102
974
  self.closeL2capFileChannel()
1103
975
  self.connectedPeripheral = nil
@@ -1414,6 +1286,7 @@ class MentraLive: NSObject, SGCManager {
1414
1286
  private let BLOCK_AUDIO_DUPLEX = false
1415
1287
  private static let voiceActivityDetectionSwitchType = 8
1416
1288
  private static let loudnessGateSwitchType = 10
1289
+ private static let autoPowerOffSwitchType = 11
1417
1290
  private func pairingAdvertisement(
1418
1291
  _ advertisementData: [String: Any]
1419
1292
  ) -> MentraLivePairingAdvertisement? {
@@ -1601,7 +1474,7 @@ class MentraLive: NSObject, SGCManager {
1601
1474
  // NEW: File transfer properties
1602
1475
  private var fileReadCharacteristic: CBCharacteristic?
1603
1476
  private var fileWriteCharacteristic: CBCharacteristic?
1604
- private var activeFileTransfers = [String: FileTransferSession]()
1477
+ private var activeFileTransfers = [String: MentraLiveFileTransferSession]()
1605
1478
  private var blePhotoTransfers = [String: BlePhotoTransfer]()
1606
1479
  private var bleIncidentLogRelays = [String: BleIncidentLogRelayEntry]()
1607
1480
  private var l2capFileChannel: MentraLiveL2capChannel?
@@ -1777,6 +1650,10 @@ class MentraLive: NSObject, SGCManager {
1777
1650
  private static let glassesMediaVolumeTimeoutSec: TimeInterval = 2.0
1778
1651
 
1779
1652
  private var connectionTimeoutTimer: Timer?
1653
+ /// Peripheral of an armed pending reconnect (see `reconnectDirectly`).
1654
+ private var pendingReconnectPeripheral: CBPeripheral?
1655
+ private var pendingReconnect = MentraLivePendingReconnect()
1656
+ private var pendingReconnectSettleWorkItem: DispatchWorkItem?
1780
1657
  private var reconnectionWorkItem: DispatchWorkItem?
1781
1658
  private var requiresAncs = true
1782
1659
 
@@ -1844,6 +1721,7 @@ class MentraLive: NSObject, SGCManager {
1844
1721
 
1845
1722
  // clear the saved device name:
1846
1723
  UserDefaults.standard.set("", forKey: PREFS_DEVICE_NAME)
1724
+ cancelPendingReconnect(reason: "discovery")
1847
1725
 
1848
1726
  manualDiscoveryActive = true
1849
1727
  startScan()
@@ -1856,6 +1734,7 @@ class MentraLive: NSObject, SGCManager {
1856
1734
  }
1857
1735
 
1858
1736
  private func enterPairingYield(windowMs: Int) {
1737
+ cancelPendingReconnect(reason: "pairing_yield")
1859
1738
  pairingYieldActive = true
1860
1739
  pairingYieldAwaitingReclaim = false
1861
1740
  pairingYieldEndWorkItem?.cancel()
@@ -1936,6 +1815,7 @@ class MentraLive: NSObject, SGCManager {
1936
1815
  return
1937
1816
  }
1938
1817
  Bridge.log("connectById: \(deviceName)")
1818
+ cancelPendingReconnect(reason: "connect_by_id")
1939
1819
  // Save the device name for future reconnection
1940
1820
  UserDefaults.standard.set(deviceName, forKey: PREFS_DEVICE_NAME)
1941
1821
 
@@ -2488,15 +2368,59 @@ class MentraLive: NSObject, SGCManager {
2488
2368
  }
2489
2369
  Bridge.log("LIVE: Connecting to device: \(peripheral.identifier.uuidString)")
2490
2370
 
2371
+ // A timed attempt supersedes any pending reconnect: withdraw one aimed at other
2372
+ // glasses, and drop tracking for this one so its settle timer cannot touch this attempt.
2373
+ if let pending = pendingReconnectPeripheral, pending !== peripheral {
2374
+ cancelPendingReconnect(reason: "superseded")
2375
+ } else {
2376
+ clearPendingReconnectTracking()
2377
+ }
2378
+
2491
2379
  isConnecting = true
2492
2380
  updateConnectionState(ConnTypes.CONNECTING)
2493
2381
  connectingPeripheral = peripheral
2494
2382
  connectedPeripheral = peripheral
2495
- peripheral.delegate = self
2496
2383
 
2497
2384
  // Set connection timeout
2498
2385
  startConnectionTimeout()
2499
2386
 
2387
+ issueConnect(peripheral)
2388
+ }
2389
+
2390
+ /// Re-arms a pending connection to the glasses that just dropped. Unlike `connectToDevice`,
2391
+ /// it sets no timeout: CoreBluetooth keeps the request open until the glasses advertise
2392
+ /// again (for example after a BES firmware reset), completes it in the background and wakes
2393
+ /// the app. Discovery, connect-by-id, pairing yield and `destroy()` cancel it through
2394
+ /// `cancelPendingReconnect`. Returns false when the policy does not apply, so the caller can
2395
+ /// fall back to scanning.
2396
+ private func reconnectDirectly(to peripheral: CBPeripheral) -> Bool {
2397
+ guard let centralManager,
2398
+ MentraLiveConnectionAttemptPolicy.shouldReconnectDirectly(
2399
+ isKilled: isKilled,
2400
+ pairingYieldActive: pairingYieldActive,
2401
+ bluetoothPoweredOn: centralManager.state == .poweredOn,
2402
+ peripheralName: peripheral.name,
2403
+ savedDeviceName: UserDefaults.standard.string(forKey: PREFS_DEVICE_NAME)
2404
+ )
2405
+ else {
2406
+ return false
2407
+ }
2408
+ Bridge.log(
2409
+ "LIVE: Pending direct reconnect to \(peripheral.identifier.uuidString) (no timeout, completes in background)"
2410
+ )
2411
+ isConnecting = true
2412
+ updateConnectionState(ConnTypes.CONNECTING)
2413
+ connectingPeripheral = peripheral
2414
+ connectedPeripheral = peripheral
2415
+ pendingReconnectPeripheral = peripheral
2416
+ issueConnect(peripheral)
2417
+ schedulePendingReconnectSettle(attempt: pendingReconnect.arm())
2418
+ return true
2419
+ }
2420
+
2421
+ /// The single CoreBluetooth connect call, shared by user-initiated and pending reconnects.
2422
+ private func issueConnect(_ peripheral: CBPeripheral) {
2423
+ peripheral.delegate = self
2500
2424
  #if os(macOS)
2501
2425
  centralManager?.connect(peripheral, options: nil)
2502
2426
  #else
@@ -2511,6 +2435,58 @@ class MentraLive: NSObject, SGCManager {
2511
2435
  #endif
2512
2436
  }
2513
2437
 
2438
+ /// A pending connect never times out, so the glasses may simply never return (battery
2439
+ /// dead, left behind). After the old scan backoff's span, report DISCONNECTED like that
2440
+ /// backoff did, but keep the request armed: iOS still completes it whenever the glasses
2441
+ /// advertise, and `didConnect` accepts it through `connectingPeripheral`.
2442
+ private func schedulePendingReconnectSettle(attempt: Int) {
2443
+ pendingReconnectSettleWorkItem?.cancel()
2444
+ let work = DispatchWorkItem { [weak self] in
2445
+ // Only the attempt that armed this timer may settle state; a failure, fallback
2446
+ // connect or re-arm clears or replaces it.
2447
+ guard let self, self.pendingReconnect.owns(attempt),
2448
+ self.connectionState != ConnTypes.CONNECTED
2449
+ else { return }
2450
+ Bridge.log("LIVE: Glasses still unreachable; reporting disconnected, pending reconnect stays armed")
2451
+ self.pendingReconnectSettleWorkItem = nil
2452
+ self.isConnecting = false
2453
+ self.connectedPeripheral = nil
2454
+ self.updateConnectionState(ConnTypes.DISCONNECTED)
2455
+ }
2456
+ pendingReconnectSettleWorkItem = work
2457
+ DispatchQueue.main.asyncAfter(
2458
+ deadline: .now() + .milliseconds(MentraLiveConnectionAttemptPolicy.pendingReconnectSettleMs),
2459
+ execute: work
2460
+ )
2461
+ }
2462
+
2463
+ /// Stops tracking a pending reconnect after it connected; no CoreBluetooth call needed.
2464
+ private func clearPendingReconnectTracking() {
2465
+ pendingReconnectSettleWorkItem?.cancel()
2466
+ pendingReconnectSettleWorkItem = nil
2467
+ pendingReconnect.clear()
2468
+ pendingReconnectPeripheral = nil
2469
+ }
2470
+
2471
+ /// Withdraws a pending reconnect before the user retargets (discovery, connect-by-id,
2472
+ /// pairing yield) or tears down, so iOS cannot later complete it to the previous glasses.
2473
+ private func cancelPendingReconnect(reason: String) {
2474
+ guard let peripheral = pendingReconnectPeripheral else {
2475
+ clearPendingReconnectTracking()
2476
+ return
2477
+ }
2478
+ clearPendingReconnectTracking()
2479
+ Bridge.log("LIVE: Cancelling pending reconnect to \(peripheral.identifier.uuidString) (\(reason))")
2480
+ if connectingPeripheral === peripheral {
2481
+ connectingPeripheral = nil
2482
+ }
2483
+ if connectedPeripheral === peripheral, connectionState != ConnTypes.CONNECTED {
2484
+ connectedPeripheral = nil
2485
+ }
2486
+ isConnecting = false
2487
+ centralManager?.cancelPeripheralConnection(peripheral)
2488
+ }
2489
+
2514
2490
  /// Opt the firmware into ANCS only after iOS has authorized this accessory.
2515
2491
  /// Old firmware safely ignores the command, while new firmware stays inert
2516
2492
  /// for old mobile clients that never send it.
@@ -2638,7 +2614,8 @@ class MentraLive: NSObject, SGCManager {
2638
2614
  // The reader thread keeps draining the stream (and returning CoC credits)
2639
2615
  // while the existing transfer state remains serialized on the main actor.
2640
2616
  DispatchQueue.main.async {
2641
- self?.processReceivedData(frame)
2617
+ guard let self, self.l2capFileChannelId == channelId else { return }
2618
+ self.processReceivedData(frame)
2642
2619
  }
2643
2620
  },
2644
2621
  onClose: { [weak self] in
@@ -2665,7 +2642,7 @@ class MentraLive: NSObject, SGCManager {
2665
2642
 
2666
2643
  // MARK: - Data Processing
2667
2644
 
2668
- // Internal so native integration tests can replay the actual BLE receive path.
2645
+ /// Internal so native integration tests can replay the actual BLE receive path.
2669
2646
  func processReceivedData(_ data: Data) {
2670
2647
  guard data.count > 0 else { return }
2671
2648
 
@@ -3015,7 +2992,9 @@ class MentraLive: NSObject, SGCManager {
3015
2992
 
3016
2993
  case "stream_controller_probe":
3017
2994
  if let response = StreamControllerProbe.response(json) {
3018
- sendJson(response)
2995
+ // BES buffers non-waking commands when MTK enters standby, even while
2996
+ // its streaming CPU lease is held. The current probe needs a live reply.
2997
+ sendJson(response, wakeUp: true)
3019
2998
  }
3020
2999
 
3021
3000
  case "pong":
@@ -3440,10 +3419,13 @@ class MentraLive: NSObject, SGCManager {
3440
3419
  if let body = k900ParseBody(json["B"]) {
3441
3420
  let switchType = k900JsonInt(body, "type") ?? -1
3442
3421
  let switchValue = k900JsonInt(body, "switch") ?? -1
3422
+ // K900 replies carry result in "S"; 0 is RC_SUCCESS.
3423
+ let resultCode = k900JsonInt(json, "S") ?? -1
3443
3424
  handleSwitchStatus(
3444
3425
  switchType: switchType,
3445
3426
  value: switchValue,
3446
- timestamp: Int64(Date().timeIntervalSince1970 * 1000)
3427
+ timestamp: Int64(Date().timeIntervalSince1970 * 1000),
3428
+ resultCode: resultCode
3447
3429
  )
3448
3430
  }
3449
3431
 
@@ -3459,7 +3441,14 @@ class MentraLive: NSObject, SGCManager {
3459
3441
 
3460
3442
  case "sr_wrst":
3461
3443
  if let body = k900ParseBody(json["B"]) {
3462
- Bridge.sendWearState(worn: (k900JsonInt(body, "on") ?? 0) != 0)
3444
+ Bridge.sendWearState(
3445
+ worn: (k900JsonInt(body, "on") ?? 0) != 0,
3446
+ elapsedMs: k900JsonInt(body, "elapsed_ms"),
3447
+ timeoutMs: k900JsonInt(body, "timeout_ms"),
3448
+ enabled: body["enabled"] == nil ? nil : (k900JsonInt(body, "enabled") ?? 0) != 0,
3449
+ armed: body["armed"] == nil ? nil : (k900JsonInt(body, "armed") ?? 0) != 0,
3450
+ inhibited: body["inhibited"] == nil ? nil : (k900JsonInt(body, "inhibited") ?? 0) != 0
3451
+ )
3463
3452
  }
3464
3453
 
3465
3454
  case "sr_micrms":
@@ -4148,7 +4137,7 @@ class MentraLive: NSObject, SGCManager {
4148
4137
  activeFileTransfers.removeValue(forKey: fileName)
4149
4138
  }
4150
4139
 
4151
- var session = FileTransferSession(
4140
+ var session = MentraLiveFileTransferSession(
4152
4141
  fileName: fileName, fileSize: fileSize, announcedPackets: totalPackets
4153
4142
  )
4154
4143
  session.isAnnounced = true
@@ -4158,7 +4147,7 @@ class MentraLive: NSObject, SGCManager {
4158
4147
  if var bleTransfer = blePhotoTransfers[bleImgId] {
4159
4148
  var bleSession =
4160
4149
  bleTransfer.session
4161
- ?? FileTransferSession(
4150
+ ?? MentraLiveFileTransferSession(
4162
4151
  fileName: fileName, fileSize: fileSize, announcedPackets: totalPackets
4163
4152
  )
4164
4153
  bleSession.updateAnnouncedPackets(totalPackets)
@@ -4251,8 +4240,8 @@ class MentraLive: NSObject, SGCManager {
4251
4240
 
4252
4241
  if incidentRelay.session == nil {
4253
4242
  activeFileTransfers.removeValue(forKey: packetInfo.fileName)
4254
- var session = FileTransferSession(
4255
- fileName: packetInfo.fileName, fileSize: Int(packetInfo.fileSize)
4243
+ var session = MentraLiveFileTransferSession(
4244
+ fileName: packetInfo.fileName, fileSize: Int(packetInfo.fileSize), flags: packetInfo.flags
4256
4245
  )
4257
4246
  session.recalculateTotalPackets(actualPackSize: Int(packetInfo.packSize))
4258
4247
  incidentRelay.session = session
@@ -4303,10 +4292,11 @@ class MentraLive: NSObject, SGCManager {
4303
4292
 
4304
4293
  // Get or create session for this transfer
4305
4294
  if photoTransfer.session == nil {
4306
- var session = FileTransferSession(
4295
+ var session = MentraLiveFileTransferSession(
4307
4296
  fileName: packetInfo.fileName,
4308
- fileSize: Int(packetInfo.fileSize)
4297
+ fileSize: Int(packetInfo.fileSize), flags: packetInfo.flags
4309
4298
  )
4299
+ session.recalculateTotalPackets(actualPackSize: Int(packetInfo.packSize))
4310
4300
  photoTransfer.session = session
4311
4301
  blePhotoTransfers[bleImgId] = photoTransfer
4312
4302
  Bridge.log(
@@ -4379,9 +4369,10 @@ class MentraLive: NSObject, SGCManager {
4379
4369
  var session = activeFileTransfers[packetInfo.fileName]
4380
4370
  if session == nil {
4381
4371
  // New file transfer
4382
- session = FileTransferSession(
4383
- fileName: packetInfo.fileName, fileSize: Int(packetInfo.fileSize)
4372
+ session = MentraLiveFileTransferSession(
4373
+ fileName: packetInfo.fileName, fileSize: Int(packetInfo.fileSize), flags: packetInfo.flags
4384
4374
  )
4375
+ session?.recalculateTotalPackets(actualPackSize: Int(packetInfo.packSize))
4385
4376
  activeFileTransfers[packetInfo.fileName] = session
4386
4377
 
4387
4378
  Bridge.log(
@@ -5273,7 +5264,8 @@ class MentraLive: NSObject, SGCManager {
5273
5264
  if let requestId {
5274
5265
  json["request_id"] = requestId
5275
5266
  }
5276
- sendJson(json)
5267
+ // Wake ASG so the version request and its response can finish after idle.
5268
+ sendJson(json, wakeUp: true)
5277
5269
  }
5278
5270
 
5279
5271
  private func sendCoreTokenToAsgClient() {
@@ -5523,8 +5515,17 @@ class MentraLive: NSObject, SGCManager {
5523
5515
  Bridge.sendSpeakingStatus(speaking)
5524
5516
  }
5525
5517
 
5526
- private func handleSwitchStatus(switchType: Int, value: Int, timestamp: Int64) {
5518
+ private func handleSwitchStatus(
5519
+ switchType: Int, value: Int, timestamp: Int64, resultCode: Int = -1
5520
+ ) {
5527
5521
  Bridge.sendSwitchStatus(switchType: switchType, value: value, timestamp: timestamp)
5522
+ if switchType == Self.autoPowerOffSwitchType {
5523
+ let ok = resultCode == 0
5524
+ Bridge.log(
5525
+ "LIVE: 🔋 auto power-off sr_swit reply type=\(switchType) switch=\(value)"
5526
+ + " result=\(resultCode) ok=\(ok)"
5527
+ )
5528
+ }
5528
5529
  if switchType == Self.voiceActivityDetectionSwitchType, value == 0 || value == 1 {
5529
5530
  handleVoiceActivityDetectionStatus(enabled: value == 1)
5530
5531
  }
@@ -5999,6 +6000,7 @@ class MentraLive: NSObject, SGCManager {
5999
6000
  closeL2capFileChannel()
6000
6001
 
6001
6002
  // Disconnect BLE
6003
+ cancelPendingReconnect(reason: "destroy")
6002
6004
  if let peripheral = connectedPeripheral {
6003
6005
  centralManager?.cancelPeripheralConnection(peripheral)
6004
6006
  }
@@ -6874,6 +6876,9 @@ extension MentraLive {
6874
6876
  // Send glasses-side loudness / Barrier gate setting.
6875
6877
  sendLoudnessGateSetting()
6876
6878
 
6879
+ // Send glasses-side auto power-off setting.
6880
+ sendAutoPowerOffSetting()
6881
+
6877
6882
  // Send mic tuning. With nothing authorized this sends a reset, which is
6878
6883
  // what returns a freshly connected pair of glasses to stock behaviour.
6879
6884
  sendMicTuningSetting()
@@ -7100,6 +7105,46 @@ extension MentraLive {
7100
7105
  }
7101
7106
  }
7102
7107
 
7108
+ func sendAutoPowerOffSetting() {
7109
+ let enabled = DeviceStore.shared.get("bluetooth", "auto_power_off_enabled") as? Bool
7110
+ ?? BluetoothSdkDefaults.autoPowerOffEnabled
7111
+ Bridge.log(
7112
+ "LIVE: 🔋 Sending auto power-off setting to glasses: enabled=\(enabled)"
7113
+ + " (cs_swit type=\(Self.autoPowerOffSwitchType))"
7114
+ )
7115
+
7116
+ guard connectedPeripheral != nil, txCharacteristic != nil else {
7117
+ Bridge.log("LIVE: Cannot send auto power-off setting - BLE write path not ready")
7118
+ return
7119
+ }
7120
+
7121
+ do {
7122
+ let bodyData = try JSONSerialization.data(withJSONObject: [
7123
+ "type": Self.autoPowerOffSwitchType,
7124
+ "switch": enabled ? 1 : 0,
7125
+ ])
7126
+ guard let bodyString = String(data: bodyData, encoding: .utf8) else {
7127
+ Bridge.log("LIVE: Failed to encode auto power-off payload")
7128
+ return
7129
+ }
7130
+ let command: [String: Any] = [
7131
+ "C": "cs_swit",
7132
+ "V": 1,
7133
+ "B": bodyString,
7134
+ ]
7135
+ if !sendRawK900Command(command, wakeUp: true) {
7136
+ Bridge.log("LIVE: Failed to send auto power-off setting command")
7137
+ } else {
7138
+ Bridge.log(
7139
+ "LIVE: 🔋 Queued auto power-off cs_swit type=\(Self.autoPowerOffSwitchType)"
7140
+ + " switch=\(enabled ? 1 : 0)"
7141
+ )
7142
+ }
7143
+ } catch {
7144
+ Bridge.log("LIVE: Error encoding auto power-off payload: \(error)")
7145
+ }
7146
+ }
7147
+
7103
7148
  func sendButtonVideoRecordingSettings() {
7104
7149
  let settings =
7105
7150
  DeviceStore.shared.get("bluetooth", "button_video_settings") as? [String: Any] ?? [
@@ -7418,7 +7463,7 @@ extension MentraLive {
7418
7463
  if fps > 0 { settings["fps"] = fps }
7419
7464
  json["settings"] = settings
7420
7465
  }
7421
- sendJson(json)
7466
+ sendJson(json, wakeUp: true)
7422
7467
  }
7423
7468
 
7424
7469
  func stopVideoRecording(requestId: String) {
@@ -7433,7 +7478,7 @@ extension MentraLive {
7433
7478
  sendJson([
7434
7479
  "type": "get_video_recording_status",
7435
7480
  "requestId": requestId,
7436
- ])
7481
+ ], wakeUp: true)
7437
7482
  }
7438
7483
 
7439
7484
  func stopVideoRecording(requestId: String, webhookUrl: String?, authToken: String?) {
@@ -7458,7 +7503,7 @@ extension MentraLive {
7458
7503
  if let authToken, !authToken.isEmpty {
7459
7504
  json["authToken"] = authToken
7460
7505
  }
7461
- sendJson(json)
7506
+ sendJson(json, wakeUp: true)
7462
7507
  }
7463
7508
  }
7464
7509