@mentra/bluetooth-sdk 3.2.1-dev.314 → 3.3.0-beta.942

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 (191) hide show
  1. package/README.md +72 -10
  2. package/android/build.gradle +15 -0
  3. package/android/lc3Lib/build.gradle +6 -1
  4. package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +368 -0
  5. package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocol.kt +156 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalytics.kt +63 -14
  7. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTracker.kt +55 -1
  8. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +4 -1
  9. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkInstallId.kt +23 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +8 -2
  11. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkUpdateCheck.kt +184 -0
  12. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkUsageTracker.kt +388 -0
  13. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +2 -1
  14. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +133 -26
  15. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +37 -13
  16. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +3 -2
  17. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +6 -6
  18. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +227 -36
  19. package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +24 -0
  20. package/android/src/main/java/com/mentra/bluetoothsdk/NetworkDiagnostics.kt +25 -0
  21. package/android/src/main/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulator.kt +13 -2
  22. package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleTraceLogger.kt +3 -0
  23. package/android/src/main/java/com/mentra/bluetoothsdk/requests/DisplayRequests.kt +1 -1
  24. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +33 -0
  25. package/android/src/main/java/com/mentra/bluetoothsdk/services/G2ConnectionRecovery.kt +47 -0
  26. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioConnectionTracker.kt +4 -0
  27. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioServiceDiscovery.kt +70 -0
  28. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +2 -2
  29. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizer.kt +11 -32
  30. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +72 -21
  31. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/LatinTextSanitizer.kt +39 -0
  32. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +194 -40
  33. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +12 -8
  34. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +314 -395
  35. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodec.kt +247 -0
  36. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinator.kt +238 -0
  37. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasEncoder.kt +58 -0
  38. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSession.kt +180 -0
  39. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +11 -0
  40. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SelectedDeviceAddress.kt +17 -0
  41. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SerializedGattCallback.kt +5 -0
  42. package/android/src/main/java/com/mentra/bluetoothsdk/utils/G2Text.kt +7 -0
  43. package/android/src/release/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +20 -0
  44. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTrackerTest.kt +58 -3
  45. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkUpdateCheckTest.kt +124 -0
  46. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkUsageTrackerTest.kt +263 -0
  47. package/android/src/test/java/com/mentra/bluetoothsdk/DashboardContentFormatterTest.kt +2 -2
  48. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceIdentityPairingTest.kt +59 -0
  49. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerSceneHandoffTest.kt +666 -0
  50. package/android/src/test/java/com/mentra/bluetoothsdk/G2TextTest.kt +13 -0
  51. package/android/src/test/java/com/mentra/bluetoothsdk/NetworkDiagnosticsTest.kt +55 -0
  52. package/android/src/test/java/com/mentra/bluetoothsdk/ScanCompletionTest.kt +5 -1
  53. package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulatorTest.kt +71 -0
  54. package/android/src/test/java/com/mentra/bluetoothsdk/debug/BleTraceLoggerTest.kt +69 -0
  55. package/android/src/test/java/com/mentra/bluetoothsdk/services/G2ConnectionRecoveryTest.kt +51 -0
  56. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioServiceDiscoveryTest.kt +124 -0
  57. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1PairingTest.kt +105 -0
  58. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizerTest.kt +29 -4
  59. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2DisplayThreadTest.kt +120 -0
  60. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2PairingTargetTest.kt +90 -0
  61. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveClassicDisconnectTest.kt +133 -0
  62. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveFileTransferSessionTest.kt +55 -0
  63. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCallbackTest.kt +33 -0
  64. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveIncidentDispatchTest.kt +126 -0
  65. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingTargetTest.kt +65 -0
  66. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveWifiCredentialLogTest.kt +55 -0
  67. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraNexPairingTest.kt +151 -0
  68. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoBitmapAcceptanceTest.kt +105 -0
  69. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodecTest.kt +233 -0
  70. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinatorTest.kt +348 -0
  71. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSessionTest.kt +339 -0
  72. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoMicReconnectTest.kt +93 -0
  73. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoProtocolTest.kt +11 -0
  74. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/SelectedDeviceAddressTest.kt +27 -0
  75. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnosticsTest.kt +287 -0
  76. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocolTest.kt +127 -0
  77. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoLifecycleTest.kt +288 -0
  78. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoReadinessDriverTest.kt +205 -0
  79. package/build/BluetoothSdk.types.d.ts +16 -0
  80. package/build/BluetoothSdk.types.d.ts.map +1 -1
  81. package/build/BluetoothSdk.types.js +1 -0
  82. package/build/BluetoothSdk.types.js.map +1 -1
  83. package/build/_private/BluetoothSdkModule.d.ts +4 -0
  84. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  85. package/build/_private/BluetoothSdkModule.js +5 -1
  86. package/build/_private/BluetoothSdkModule.js.map +1 -1
  87. package/build/_private/MentraLocalNetworkModule.d.ts +23 -0
  88. package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -1
  89. package/build/_private/MentraLocalNetworkModule.js.map +1 -1
  90. package/build/_private/nativeLogConsole.d.ts +19 -0
  91. package/build/_private/nativeLogConsole.d.ts.map +1 -1
  92. package/build/_private/nativeLogConsole.js +12 -0
  93. package/build/_private/nativeLogConsole.js.map +1 -1
  94. package/build/generated/changelogCatalog.d.ts +5 -2
  95. package/build/generated/changelogCatalog.d.ts.map +1 -1
  96. package/build/generated/changelogCatalog.js +6 -2
  97. package/build/generated/changelogCatalog.js.map +1 -1
  98. package/build/generated/releaseMetadata.js +6 -6
  99. package/build/generated/releaseMetadata.js.map +1 -1
  100. package/build/index.d.ts.map +1 -1
  101. package/build/index.js +1 -0
  102. package/build/index.js.map +1 -1
  103. package/build/ota-transport/index.d.ts +4 -0
  104. package/build/ota-transport/index.d.ts.map +1 -1
  105. package/build/ota-transport/index.js +2 -0
  106. package/build/ota-transport/index.js.map +1 -1
  107. package/ios/BluetoothSdkModule.swift +17 -4
  108. package/ios/MentraBluetoothSDK.podspec +3 -2
  109. package/ios/Source/BluetoothBackgroundMode.swift +32 -0
  110. package/ios/Source/BluetoothSdkDefaults.swift +7 -2
  111. package/ios/Source/Bridge.swift +15 -2
  112. package/ios/Source/DeviceManager.swift +102 -66
  113. package/ios/Source/DeviceStore.swift +26 -7
  114. package/ios/Source/GeneratedChangelogCatalog.swift +3 -2
  115. package/ios/Source/GeneratedReleaseMetadata.swift +6 -6
  116. package/ios/Source/MentraBluetoothSDK.swift +313 -54
  117. package/ios/Source/audio/GlassesMicWatchdog.swift +33 -0
  118. package/ios/Source/internal/BluetoothAvailability.swift +6 -0
  119. package/ios/Source/internal/BluetoothSdkAnalytics.swift +87 -13
  120. package/ios/Source/internal/BluetoothSdkAnalyticsTracker.swift +54 -1
  121. package/ios/Source/internal/BluetoothSdkInstallId.swift +21 -0
  122. package/ios/Source/internal/BluetoothSdkUpdateCheck.swift +223 -0
  123. package/ios/Source/internal/BluetoothSdkUsageTracker.swift +428 -0
  124. package/ios/Source/requests/DisplayRequests.swift +1 -1
  125. package/ios/Source/requests/VersionInfoResponseAccumulator.swift +11 -1
  126. package/ios/Source/sgcs/Ar99.swift +1 -2
  127. package/ios/Source/sgcs/G1.swift +68 -38
  128. package/ios/Source/sgcs/G1ConnectionTarget.swift +13 -0
  129. package/ios/Source/sgcs/G2.swift +261 -247
  130. package/ios/Source/sgcs/G2ConnectionSession.swift +101 -0
  131. package/ios/Source/sgcs/Mach1.swift +19 -7
  132. package/ios/Source/sgcs/MentraLive.swift +272 -197
  133. package/ios/Source/sgcs/MentraLiveFileTransferSession.swift +137 -0
  134. package/ios/Source/sgcs/MentraLivePairingAdvertisement.swift +47 -0
  135. package/ios/Source/sgcs/MentraNex.swift +8 -31
  136. package/ios/Source/sgcs/NexConnectionTarget.swift +15 -0
  137. package/ios/Source/sgcs/Nimo.swift +280 -408
  138. package/ios/Source/sgcs/NimoCanvasCodec.swift +272 -0
  139. package/ios/Source/sgcs/NimoCanvasCoordinator.swift +233 -0
  140. package/ios/Source/sgcs/NimoCanvasImage.swift +53 -0
  141. package/ios/Source/sgcs/NimoCanvasSession.swift +166 -0
  142. package/ios/Source/sgcs/NimoDiscovery.swift +49 -0
  143. package/ios/Source/sgcs/SGCManager.swift +25 -1
  144. package/ios/Source/sgcs/VuzixConnectionTarget.swift +15 -0
  145. package/ios/Source/status/DeviceStatus.swift +1 -0
  146. package/ios/Source/stt/SherpaOnnxTranscriber.swift +49 -189
  147. package/ios/Source/utils/AudioSessionMonitor.swift +44 -8
  148. package/ios/Source/utils/DemandDrivenAudioWorker.swift +150 -0
  149. package/ios/Source/utils/G1Text.swift +2 -24
  150. package/ios/Source/utils/G1TextSanitizer.swift +27 -0
  151. package/ios/Source/utils/G2Text.swift +11 -0
  152. package/ios/Source/utils/LatinTextSanitizer.swift +48 -0
  153. package/ios/Tests/BluetoothAudioRouteTests.swift +26 -0
  154. package/ios/Tests/BluetoothBackgroundModeTests.swift +32 -0
  155. package/ios/Tests/BluetoothSdkAnalyticsTrackerTests.swift +54 -3
  156. package/ios/Tests/BluetoothSdkUpdateCheckTests.swift +153 -0
  157. package/ios/Tests/BluetoothSdkUsageTrackerTests.swift +296 -0
  158. package/ios/Tests/DashboardContentFormatterTests.swift +2 -2
  159. package/ios/Tests/DashboardRenderingTests.swift +31 -0
  160. package/ios/Tests/DemandDrivenAudioWorkerTests.swift +157 -0
  161. package/ios/Tests/DeviceIdentityPairingTests.swift +49 -0
  162. package/ios/Tests/DeviceManagerReconnectTests.swift +362 -0
  163. package/ios/Tests/G1PairingTests.swift +162 -0
  164. package/ios/Tests/G1TextSanitizerTests.swift +32 -0
  165. package/ios/Tests/G2ConnectionSessionTests.swift +172 -0
  166. package/ios/Tests/G2TextTests.swift +10 -0
  167. package/ios/Tests/IncidentLogDispatchTests.swift +111 -0
  168. package/ios/Tests/MentraLiveFileTransferSessionTests.swift +47 -0
  169. package/ios/Tests/MentraLiveReconnectPolicyTests.swift +58 -0
  170. package/ios/Tests/MentraLiveSessionReadinessTests.swift +163 -0
  171. package/ios/Tests/NimoCanvasCodecTests.swift +217 -0
  172. package/ios/Tests/NimoCanvasSessionTests.swift +181 -0
  173. package/ios/Tests/NimoCanvasTransportTests.swift +186 -0
  174. package/ios/Tests/NimoDeviceCommandsTests.swift +16 -0
  175. package/ios/Tests/NimoDiscoveryTests.swift +79 -0
  176. package/ios/Tests/SGCConnectionTargetTests.swift +48 -0
  177. package/ios/Tests/VersionInfoCommandTests.swift +194 -0
  178. package/ios/Tests/VersionInfoResponseAccumulatorTests.swift +65 -0
  179. package/ios/Tests/VideoRecordingCommandTests.swift +81 -0
  180. package/ios/Tests/WifiCredentialLoggingTests.swift +42 -0
  181. package/package.json +2 -2
  182. package/plugin/build/withIos.d.ts +2 -0
  183. package/plugin/build/withIos.js +12 -1
  184. package/src/BluetoothSdk.types.ts +15 -0
  185. package/src/_private/BluetoothSdkModule.ts +10 -1
  186. package/src/_private/MentraLocalNetworkModule.ts +28 -0
  187. package/src/_private/nativeLogConsole.ts +19 -0
  188. package/src/generated/changelogCatalog.ts +6 -2
  189. package/src/generated/releaseMetadata.ts +6 -6
  190. package/src/index.ts +1 -0
  191. package/src/ota-transport/index.ts +10 -1
@@ -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,12 +917,13 @@ 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
1054
924
 
1055
925
  self.connectedPeripheral = nil
1056
- self.fullyBooted = false
926
+ self.clearPhysicalReadiness()
1057
927
  self.connected = false
1058
928
  self.glassesSessionId = nil // Fresh BLE session starts with no sid known
1059
929
  self.streamControlVersion = 0
@@ -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? {
@@ -1476,7 +1349,7 @@ class MentraLive: NSObject, SGCManager {
1476
1349
  // Queued writes are session-bound: transmitting them into the NEXT session
1477
1350
  // (e.g. a stale handshake whose reply activates v2 before the new session
1478
1351
  // negotiated) is the bug class this clear removes.
1479
- Task { await commandQueue.removeAll() }
1352
+ commandQueue.removeAll()
1480
1353
  stopSignalStrengthPolling()
1481
1354
  DeviceStore.shared.apply("glasses", "signalStrength", -1)
1482
1355
  DeviceStore.shared.apply("glasses", "signalStrengthUpdatedAt", 0)
@@ -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?
@@ -1744,6 +1617,16 @@ class MentraLive: NSObject, SGCManager {
1744
1617
  set { DeviceStore.shared.apply("glasses", "connected", newValue) }
1745
1618
  }
1746
1619
 
1620
+ private func clearPhysicalReadiness() {
1621
+ let readinessWasPending = connected && !fullyBooted
1622
+ fullyBooted = false
1623
+ if readinessWasPending {
1624
+ // Logical ASG readiness already emitted false without disconnecting.
1625
+ // The real disconnect must still perform the cleanup apply deduplicates.
1626
+ DeviceManager.shared.handleDeviceDisconnected()
1627
+ }
1628
+ }
1629
+
1747
1630
  // Queue Management
1748
1631
  private let commandQueue = CommandQueue()
1749
1632
  private let bluetoothQueue = DispatchQueue(label: "MentraLiveBluetooth", qos: .userInitiated)
@@ -1777,6 +1660,10 @@ class MentraLive: NSObject, SGCManager {
1777
1660
  private static let glassesMediaVolumeTimeoutSec: TimeInterval = 2.0
1778
1661
 
1779
1662
  private var connectionTimeoutTimer: Timer?
1663
+ /// Peripheral of an armed pending reconnect (see `reconnectDirectly`).
1664
+ private var pendingReconnectPeripheral: CBPeripheral?
1665
+ private var pendingReconnect = MentraLivePendingReconnect()
1666
+ private var pendingReconnectSettleWorkItem: DispatchWorkItem?
1780
1667
  private var reconnectionWorkItem: DispatchWorkItem?
1781
1668
  private var requiresAncs = true
1782
1669
 
@@ -1844,6 +1731,7 @@ class MentraLive: NSObject, SGCManager {
1844
1731
 
1845
1732
  // clear the saved device name:
1846
1733
  UserDefaults.standard.set("", forKey: PREFS_DEVICE_NAME)
1734
+ cancelPendingReconnect(reason: "discovery")
1847
1735
 
1848
1736
  manualDiscoveryActive = true
1849
1737
  startScan()
@@ -1856,6 +1744,7 @@ class MentraLive: NSObject, SGCManager {
1856
1744
  }
1857
1745
 
1858
1746
  private func enterPairingYield(windowMs: Int) {
1747
+ cancelPendingReconnect(reason: "pairing_yield")
1859
1748
  pairingYieldActive = true
1860
1749
  pairingYieldAwaitingReclaim = false
1861
1750
  pairingYieldEndWorkItem?.cancel()
@@ -1865,8 +1754,8 @@ class MentraLive: NSObject, SGCManager {
1865
1754
  // Publish disconnect immediately for Phone A UI. didDisconnectPeripheral may lag
1866
1755
  // or race with cancelPeripheralConnection; do not wait on it for stand-down.
1867
1756
  isConnecting = false
1757
+ clearPhysicalReadiness()
1868
1758
  connected = false
1869
- fullyBooted = false
1870
1759
  glassesSessionId = nil
1871
1760
  streamControlVersion = 0
1872
1761
  readinessCompletedThisBleSession = false
@@ -1936,6 +1825,7 @@ class MentraLive: NSObject, SGCManager {
1936
1825
  return
1937
1826
  }
1938
1827
  Bridge.log("connectById: \(deviceName)")
1828
+ cancelPendingReconnect(reason: "connect_by_id")
1939
1829
  // Save the device name for future reconnection
1940
1830
  UserDefaults.standard.set(deviceName, forKey: PREFS_DEVICE_NAME)
1941
1831
 
@@ -2243,8 +2133,7 @@ class MentraLive: NSObject, SGCManager {
2243
2133
  /// Wire-session generation at enqueue time; the drain loop drops entries from a
2244
2134
  /// previous session so a stale write (worst case: an old handshake whose reply
2245
2135
  /// would activate v2 pre-negotiation) can never cross a session boundary. This is
2246
- /// the deterministic guard - the async removeAll() on disconnect is best-effort
2247
- /// cleanup that may land after a reconnect has already resumed the drain loop.
2136
+ /// the deterministic guard if a consumer already dequeued a previous-session write.
2248
2137
  let generation: Int
2249
2138
  }
2250
2139
 
@@ -2286,7 +2175,7 @@ class MentraLive: NSObject, SGCManager {
2286
2175
  Bridge.log("LIVE: Cleared pending ACK mId \(pendingMessage.id) because \(reason)")
2287
2176
  }
2288
2177
 
2289
- actor CommandQueue {
2178
+ @MainActor final class CommandQueue {
2290
2179
  private var commands: [PendingMessage] = []
2291
2180
 
2292
2181
  func enqueue(_ command: PendingMessage) -> Int {
@@ -2402,15 +2291,11 @@ class MentraLive: NSObject, SGCManager {
2402
2291
  )
2403
2292
 
2404
2293
  // Push to front of queue for immediate retry
2405
- Task {
2406
- let queueSize = await self.commandQueue.pushToFront(retryMessage)
2407
- await MainActor.run {
2408
- self.logBleWriteTrace("retry_queued", retryMessage.trace, extra: [
2409
- "retryDelayMs": 0,
2410
- "queueSizeAfterAdd": queueSize,
2411
- ])
2412
- }
2413
- }
2294
+ let queueSize = commandQueue.pushToFront(retryMessage)
2295
+ logBleWriteTrace("retry_queued", retryMessage.trace, extra: [
2296
+ "retryDelayMs": 0,
2297
+ "queueSizeAfterAdd": queueSize,
2298
+ ])
2414
2299
 
2415
2300
  Bridge.log(
2416
2301
  "🔄 Retrying message mId: \(pendingMessage.id) (attempt \(retryMessage.retries)/3)"
@@ -2488,15 +2373,59 @@ class MentraLive: NSObject, SGCManager {
2488
2373
  }
2489
2374
  Bridge.log("LIVE: Connecting to device: \(peripheral.identifier.uuidString)")
2490
2375
 
2376
+ // A timed attempt supersedes any pending reconnect: withdraw one aimed at other
2377
+ // glasses, and drop tracking for this one so its settle timer cannot touch this attempt.
2378
+ if let pending = pendingReconnectPeripheral, pending !== peripheral {
2379
+ cancelPendingReconnect(reason: "superseded")
2380
+ } else {
2381
+ clearPendingReconnectTracking()
2382
+ }
2383
+
2491
2384
  isConnecting = true
2492
2385
  updateConnectionState(ConnTypes.CONNECTING)
2493
2386
  connectingPeripheral = peripheral
2494
2387
  connectedPeripheral = peripheral
2495
- peripheral.delegate = self
2496
2388
 
2497
2389
  // Set connection timeout
2498
2390
  startConnectionTimeout()
2499
2391
 
2392
+ issueConnect(peripheral)
2393
+ }
2394
+
2395
+ /// Re-arms a pending connection to the glasses that just dropped. Unlike `connectToDevice`,
2396
+ /// it sets no timeout: CoreBluetooth keeps the request open until the glasses advertise
2397
+ /// again (for example after a BES firmware reset), completes it in the background and wakes
2398
+ /// the app. Discovery, connect-by-id, pairing yield and `destroy()` cancel it through
2399
+ /// `cancelPendingReconnect`. Returns false when the policy does not apply, so the caller can
2400
+ /// fall back to scanning.
2401
+ private func reconnectDirectly(to peripheral: CBPeripheral) -> Bool {
2402
+ guard let centralManager,
2403
+ MentraLiveConnectionAttemptPolicy.shouldReconnectDirectly(
2404
+ isKilled: isKilled,
2405
+ pairingYieldActive: pairingYieldActive,
2406
+ bluetoothPoweredOn: centralManager.state == .poweredOn,
2407
+ peripheralName: peripheral.name,
2408
+ savedDeviceName: UserDefaults.standard.string(forKey: PREFS_DEVICE_NAME)
2409
+ )
2410
+ else {
2411
+ return false
2412
+ }
2413
+ Bridge.log(
2414
+ "LIVE: Pending direct reconnect to \(peripheral.identifier.uuidString) (no timeout, completes in background)"
2415
+ )
2416
+ isConnecting = true
2417
+ updateConnectionState(ConnTypes.CONNECTING)
2418
+ connectingPeripheral = peripheral
2419
+ connectedPeripheral = peripheral
2420
+ pendingReconnectPeripheral = peripheral
2421
+ issueConnect(peripheral)
2422
+ schedulePendingReconnectSettle(attempt: pendingReconnect.arm())
2423
+ return true
2424
+ }
2425
+
2426
+ /// The single CoreBluetooth connect call, shared by user-initiated and pending reconnects.
2427
+ private func issueConnect(_ peripheral: CBPeripheral) {
2428
+ peripheral.delegate = self
2500
2429
  #if os(macOS)
2501
2430
  centralManager?.connect(peripheral, options: nil)
2502
2431
  #else
@@ -2511,6 +2440,58 @@ class MentraLive: NSObject, SGCManager {
2511
2440
  #endif
2512
2441
  }
2513
2442
 
2443
+ /// A pending connect never times out, so the glasses may simply never return (battery
2444
+ /// dead, left behind). After the old scan backoff's span, report DISCONNECTED like that
2445
+ /// backoff did, but keep the request armed: iOS still completes it whenever the glasses
2446
+ /// advertise, and `didConnect` accepts it through `connectingPeripheral`.
2447
+ private func schedulePendingReconnectSettle(attempt: Int) {
2448
+ pendingReconnectSettleWorkItem?.cancel()
2449
+ let work = DispatchWorkItem { [weak self] in
2450
+ // Only the attempt that armed this timer may settle state; a failure, fallback
2451
+ // connect or re-arm clears or replaces it.
2452
+ guard let self, self.pendingReconnect.owns(attempt),
2453
+ self.connectionState != ConnTypes.CONNECTED
2454
+ else { return }
2455
+ Bridge.log("LIVE: Glasses still unreachable; reporting disconnected, pending reconnect stays armed")
2456
+ self.pendingReconnectSettleWorkItem = nil
2457
+ self.isConnecting = false
2458
+ self.connectedPeripheral = nil
2459
+ self.updateConnectionState(ConnTypes.DISCONNECTED)
2460
+ }
2461
+ pendingReconnectSettleWorkItem = work
2462
+ DispatchQueue.main.asyncAfter(
2463
+ deadline: .now() + .milliseconds(MentraLiveConnectionAttemptPolicy.pendingReconnectSettleMs),
2464
+ execute: work
2465
+ )
2466
+ }
2467
+
2468
+ /// Stops tracking a pending reconnect after it connected; no CoreBluetooth call needed.
2469
+ private func clearPendingReconnectTracking() {
2470
+ pendingReconnectSettleWorkItem?.cancel()
2471
+ pendingReconnectSettleWorkItem = nil
2472
+ pendingReconnect.clear()
2473
+ pendingReconnectPeripheral = nil
2474
+ }
2475
+
2476
+ /// Withdraws a pending reconnect before the user retargets (discovery, connect-by-id,
2477
+ /// pairing yield) or tears down, so iOS cannot later complete it to the previous glasses.
2478
+ private func cancelPendingReconnect(reason: String) {
2479
+ guard let peripheral = pendingReconnectPeripheral else {
2480
+ clearPendingReconnectTracking()
2481
+ return
2482
+ }
2483
+ clearPendingReconnectTracking()
2484
+ Bridge.log("LIVE: Cancelling pending reconnect to \(peripheral.identifier.uuidString) (\(reason))")
2485
+ if connectingPeripheral === peripheral {
2486
+ connectingPeripheral = nil
2487
+ }
2488
+ if connectedPeripheral === peripheral, connectionState != ConnTypes.CONNECTED {
2489
+ connectedPeripheral = nil
2490
+ }
2491
+ isConnecting = false
2492
+ centralManager?.cancelPeripheralConnection(peripheral)
2493
+ }
2494
+
2514
2495
  /// Opt the firmware into ANCS only after iOS has authorized this accessory.
2515
2496
  /// Old firmware safely ignores the command, while new firmware stays inert
2516
2497
  /// for old mobile clients that never send it.
@@ -2552,8 +2533,8 @@ class MentraLive: NSObject, SGCManager {
2552
2533
  Bridge.log("LIVE: Maximum reconnection attempts reached (\(MAX_RECONNECT_ATTEMPTS))")
2553
2534
  reconnectAttempts = 0
2554
2535
  updateConnectionState(ConnTypes.DISCONNECTED)
2536
+ clearPhysicalReadiness()
2555
2537
  connected = false
2556
- fullyBooted = false
2557
2538
  glassesSessionId = nil
2558
2539
  streamControlVersion = 0
2559
2540
  readinessCompletedThisBleSession = false
@@ -2638,7 +2619,8 @@ class MentraLive: NSObject, SGCManager {
2638
2619
  // The reader thread keeps draining the stream (and returning CoC credits)
2639
2620
  // while the existing transfer state remains serialized on the main actor.
2640
2621
  DispatchQueue.main.async {
2641
- self?.processReceivedData(frame)
2622
+ guard let self, self.l2capFileChannelId == channelId else { return }
2623
+ self.processReceivedData(frame)
2642
2624
  }
2643
2625
  },
2644
2626
  onClose: { [weak self] in
@@ -2665,7 +2647,7 @@ class MentraLive: NSObject, SGCManager {
2665
2647
 
2666
2648
  // MARK: - Data Processing
2667
2649
 
2668
- // Internal so native integration tests can replay the actual BLE receive path.
2650
+ /// Internal so native integration tests can replay the actual BLE receive path.
2669
2651
  func processReceivedData(_ data: Data) {
2670
2652
  guard data.count > 0 else { return }
2671
2653
 
@@ -3015,7 +2997,9 @@ class MentraLive: NSObject, SGCManager {
3015
2997
 
3016
2998
  case "stream_controller_probe":
3017
2999
  if let response = StreamControllerProbe.response(json) {
3018
- sendJson(response)
3000
+ // BES buffers non-waking commands when MTK enters standby, even while
3001
+ // its streaming CPU lease is held. The current probe needs a live reply.
3002
+ sendJson(response, wakeUp: true)
3019
3003
  }
3020
3004
 
3021
3005
  case "pong":
@@ -3164,6 +3148,11 @@ class MentraLive: NSObject, SGCManager {
3164
3148
  if type.hasPrefix("version_info") {
3165
3149
  Bridge.log("LIVE: Received \(type)")
3166
3150
 
3151
+ // A new process can report its build before glasses_ready resets the
3152
+ // wire epoch. Publish its pending readiness before those version fields
3153
+ // can complete OTA and trigger the next request.
3154
+ handleGlassesSessionId(json)
3155
+
3167
3156
  // Extract all fields from JSON (except "type")
3168
3157
  var fields: [String: Any] = [:]
3169
3158
  fields["version_info_type"] = type
@@ -3236,7 +3225,6 @@ class MentraLive: NSObject, SGCManager {
3236
3225
  // running them per chunk is safe.
3237
3226
  parsePeerWireCaps(json)
3238
3227
  maybeSendWireHandshake()
3239
- handleGlassesSessionId(json)
3240
3228
 
3241
3229
  Bridge.sendVersionInfo(fields, responseChunk: type)
3242
3230
  } else {
@@ -3440,10 +3428,13 @@ class MentraLive: NSObject, SGCManager {
3440
3428
  if let body = k900ParseBody(json["B"]) {
3441
3429
  let switchType = k900JsonInt(body, "type") ?? -1
3442
3430
  let switchValue = k900JsonInt(body, "switch") ?? -1
3431
+ // K900 replies carry result in "S"; 0 is RC_SUCCESS.
3432
+ let resultCode = k900JsonInt(json, "S") ?? -1
3443
3433
  handleSwitchStatus(
3444
3434
  switchType: switchType,
3445
3435
  value: switchValue,
3446
- timestamp: Int64(Date().timeIntervalSince1970 * 1000)
3436
+ timestamp: Int64(Date().timeIntervalSince1970 * 1000),
3437
+ resultCode: resultCode
3447
3438
  )
3448
3439
  }
3449
3440
 
@@ -3459,7 +3450,14 @@ class MentraLive: NSObject, SGCManager {
3459
3450
 
3460
3451
  case "sr_wrst":
3461
3452
  if let body = k900ParseBody(json["B"]) {
3462
- Bridge.sendWearState(worn: (k900JsonInt(body, "on") ?? 0) != 0)
3453
+ Bridge.sendWearState(
3454
+ worn: (k900JsonInt(body, "on") ?? 0) != 0,
3455
+ elapsedMs: k900JsonInt(body, "elapsed_ms"),
3456
+ timeoutMs: k900JsonInt(body, "timeout_ms"),
3457
+ enabled: body["enabled"] == nil ? nil : (k900JsonInt(body, "enabled") ?? 0) != 0,
3458
+ armed: body["armed"] == nil ? nil : (k900JsonInt(body, "armed") ?? 0) != 0,
3459
+ inhibited: body["inhibited"] == nil ? nil : (k900JsonInt(body, "inhibited") ?? 0) != 0
3460
+ )
3463
3461
  }
3464
3462
 
3465
3463
  case "sr_micrms":
@@ -3725,7 +3723,19 @@ class MentraLive: NSObject, SGCManager {
3725
3723
  sendJson(json, wakeUp: true)
3726
3724
  }
3727
3725
 
3728
- func sendIncidentId(_ incidentId: String, apiBaseUrl: String?) {
3726
+ var incidentLogTransportReady: Bool {
3727
+ connectedPeripheral?.state == .connected && txCharacteristic != nil
3728
+ }
3729
+
3730
+ func sendIncidentId(_ incidentId: String, apiBaseUrl: String?) throws {
3731
+ guard incidentLogTransportReady else {
3732
+ throw BluetoothSdkError(code: "glasses_not_connected", message: "Cannot request incident logs because the glasses BLE link is not ready.")
3733
+ }
3734
+ // Token sync may happen after glasses_ready; never rely on the last pairing's token.
3735
+ let coreToken = DeviceStore.shared.get("bluetooth", "core_token") as? String ?? ""
3736
+ guard !coreToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
3737
+ throw BluetoothSdkError(code: "core_token_unavailable", message: "Cannot request incident logs without the current Core access token.")
3738
+ }
3729
3739
  var base = (apiBaseUrl ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
3730
3740
  if base.isEmpty {
3731
3741
  base = "https://api.mentra.glass"
@@ -3742,10 +3752,20 @@ class MentraLive: NSObject, SGCManager {
3742
3752
  fileBaseKey: lKey, incidentId: incidentId, apiBaseUrl: base, kind: .logcat
3743
3753
  )
3744
3754
 
3745
- Bridge.log(
3746
- "LIVE: Sending incidentId to glasses for log upload: \(incidentId) (BLE relay \(bKey), \(lKey))"
3747
- )
3748
- sendJson(["type": "upload_incident_logs", "incidentId": incidentId, "apiBaseUrl": base], wakeUp: true)
3755
+ let auth: [String: Any] = [
3756
+ "type": "auth_token", "coreToken": coreToken,
3757
+ "timestamp": Int64(Date().timeIntervalSince1970 * 1000),
3758
+ ]
3759
+ // MainActor queue insertion preserves auth-before-upload order, including all fragments.
3760
+ guard sendJson(auth, wakeUp: true, requireAck: true, bridgeLogging: true),
3761
+ sendJson(["type": "upload_incident_logs", "incidentId": incidentId, "apiBaseUrl": base],
3762
+ wakeUp: true, requireAck: true, bridgeLogging: true)
3763
+ else {
3764
+ bleIncidentLogRelays.removeValue(forKey: bKey)
3765
+ bleIncidentLogRelays.removeValue(forKey: lKey)
3766
+ throw BluetoothSdkError(code: "incident_dispatch_failed", message: "Could not queue the glasses incident-log request.")
3767
+ }
3768
+ Bridge.log("LIVE: Queued incidentId to glasses for log upload: \(incidentId) (BLE relay \(bKey), \(lKey))")
3749
3769
  }
3750
3770
 
3751
3771
  private static func incidentBleFileBase(incidentId: String, prefix: Character) -> String {
@@ -4148,7 +4168,7 @@ class MentraLive: NSObject, SGCManager {
4148
4168
  activeFileTransfers.removeValue(forKey: fileName)
4149
4169
  }
4150
4170
 
4151
- var session = FileTransferSession(
4171
+ var session = MentraLiveFileTransferSession(
4152
4172
  fileName: fileName, fileSize: fileSize, announcedPackets: totalPackets
4153
4173
  )
4154
4174
  session.isAnnounced = true
@@ -4158,7 +4178,7 @@ class MentraLive: NSObject, SGCManager {
4158
4178
  if var bleTransfer = blePhotoTransfers[bleImgId] {
4159
4179
  var bleSession =
4160
4180
  bleTransfer.session
4161
- ?? FileTransferSession(
4181
+ ?? MentraLiveFileTransferSession(
4162
4182
  fileName: fileName, fileSize: fileSize, announcedPackets: totalPackets
4163
4183
  )
4164
4184
  bleSession.updateAnnouncedPackets(totalPackets)
@@ -4251,8 +4271,8 @@ class MentraLive: NSObject, SGCManager {
4251
4271
 
4252
4272
  if incidentRelay.session == nil {
4253
4273
  activeFileTransfers.removeValue(forKey: packetInfo.fileName)
4254
- var session = FileTransferSession(
4255
- fileName: packetInfo.fileName, fileSize: Int(packetInfo.fileSize)
4274
+ var session = MentraLiveFileTransferSession(
4275
+ fileName: packetInfo.fileName, fileSize: Int(packetInfo.fileSize), flags: packetInfo.flags
4256
4276
  )
4257
4277
  session.recalculateTotalPackets(actualPackSize: Int(packetInfo.packSize))
4258
4278
  incidentRelay.session = session
@@ -4303,10 +4323,11 @@ class MentraLive: NSObject, SGCManager {
4303
4323
 
4304
4324
  // Get or create session for this transfer
4305
4325
  if photoTransfer.session == nil {
4306
- var session = FileTransferSession(
4326
+ var session = MentraLiveFileTransferSession(
4307
4327
  fileName: packetInfo.fileName,
4308
- fileSize: Int(packetInfo.fileSize)
4328
+ fileSize: Int(packetInfo.fileSize), flags: packetInfo.flags
4309
4329
  )
4330
+ session.recalculateTotalPackets(actualPackSize: Int(packetInfo.packSize))
4310
4331
  photoTransfer.session = session
4311
4332
  blePhotoTransfers[bleImgId] = photoTransfer
4312
4333
  Bridge.log(
@@ -4379,9 +4400,10 @@ class MentraLive: NSObject, SGCManager {
4379
4400
  var session = activeFileTransfers[packetInfo.fileName]
4380
4401
  if session == nil {
4381
4402
  // New file transfer
4382
- session = FileTransferSession(
4383
- fileName: packetInfo.fileName, fileSize: Int(packetInfo.fileSize)
4403
+ session = MentraLiveFileTransferSession(
4404
+ fileName: packetInfo.fileName, fileSize: Int(packetInfo.fileSize), flags: packetInfo.flags
4384
4405
  )
4406
+ session?.recalculateTotalPackets(actualPackSize: Int(packetInfo.packSize))
4385
4407
  activeFileTransfers[packetInfo.fileName] = session
4386
4408
 
4387
4409
  Bridge.log(
@@ -4680,20 +4702,13 @@ class MentraLive: NSObject, SGCManager {
4680
4702
 
4681
4703
  func queueSend(_ data: Data, id: String, trace: BleWriteTrace?) {
4682
4704
  let significantQueueSize = SIGNIFICANT_BLE_TRACE_QUEUE_SIZE
4683
- // Capture the epoch at enqueue-request time: the Task body may run after a
4684
- // session reset, and a pre-reset payload stamped with the new generation would
4685
- // defeat the stale-write guard in the drain loop.
4705
+ // Insert synchronously on MainActor so fragments and dependent commands retain call order.
4686
4706
  let generation = wireSessionGeneration
4687
- Task {
4688
- let queueSize = await commandQueue.enqueue(PendingMessage(data: data, id: id, retries: 0, trace: trace, generation: generation))
4689
- guard trace != nil, queueSize >= significantQueueSize else {
4690
- return
4691
- }
4692
- await MainActor.run {
4693
- self.logBleChunkTrace("queued", trace, extra: [
4694
- "queueSizeAfterAdd": queueSize,
4695
- ])
4696
- }
4707
+ let queueSize = commandQueue.enqueue(PendingMessage(data: data, id: id, retries: 0, trace: trace, generation: generation))
4708
+ if trace != nil, queueSize >= significantQueueSize {
4709
+ logBleChunkTrace("queued", trace, extra: [
4710
+ "queueSizeAfterAdd": queueSize,
4711
+ ])
4697
4712
  }
4698
4713
  }
4699
4714
 
@@ -5210,7 +5225,10 @@ class MentraLive: NSObject, SGCManager {
5210
5225
  bridgeLogging: bridgeLogging
5211
5226
  )
5212
5227
  }
5213
- transportLog("LIVE: Sending data to glasses: \(jsonString)", bridgeLogging: bridgeLogging)
5228
+ // Credentials reach the wire unchanged; diagnostic logs omit their values.
5229
+ let loggedPayload = json["password"] == nil && json["coreToken"] == nil
5230
+ ? jsonString : "<\(commandInfo.commandType) with credentials omitted>"
5231
+ transportLog("LIVE: Sending data to glasses: \(loggedPayload)", bridgeLogging: bridgeLogging)
5214
5232
  let packedData =
5215
5233
  packJson(
5216
5234
  jsonString,
@@ -5273,7 +5291,8 @@ class MentraLive: NSObject, SGCManager {
5273
5291
  if let requestId {
5274
5292
  json["request_id"] = requestId
5275
5293
  }
5276
- sendJson(json)
5294
+ // Wake ASG so the version request and its response can finish after idle.
5295
+ sendJson(json, wakeUp: true)
5277
5296
  }
5278
5297
 
5279
5298
  private func sendCoreTokenToAsgClient() {
@@ -5523,8 +5542,17 @@ class MentraLive: NSObject, SGCManager {
5523
5542
  Bridge.sendSpeakingStatus(speaking)
5524
5543
  }
5525
5544
 
5526
- private func handleSwitchStatus(switchType: Int, value: Int, timestamp: Int64) {
5545
+ private func handleSwitchStatus(
5546
+ switchType: Int, value: Int, timestamp: Int64, resultCode: Int = -1
5547
+ ) {
5527
5548
  Bridge.sendSwitchStatus(switchType: switchType, value: value, timestamp: timestamp)
5549
+ if switchType == Self.autoPowerOffSwitchType {
5550
+ let ok = resultCode == 0
5551
+ Bridge.log(
5552
+ "LIVE: 🔋 auto power-off sr_swit reply type=\(switchType) switch=\(value)"
5553
+ + " result=\(resultCode) ok=\(ok)"
5554
+ )
5555
+ }
5528
5556
  if switchType == Self.voiceActivityDetectionSwitchType, value == 0 || value == 1 {
5529
5557
  handleVoiceActivityDetectionStatus(enabled: value == 1)
5530
5558
  }
@@ -5723,7 +5751,7 @@ class MentraLive: NSObject, SGCManager {
5723
5751
  stopReadinessCheckLoop()
5724
5752
 
5725
5753
  readinessCheckCounter = 0
5726
- fullyBooted = false
5754
+ clearPhysicalReadiness()
5727
5755
  connected = false
5728
5756
  glassesSessionId = nil
5729
5757
  streamControlVersion = 0
@@ -5995,16 +6023,17 @@ class MentraLive: NSObject, SGCManager {
5995
6023
  // Stop all timers
5996
6024
  stopAllTimers()
5997
6025
  resetWireNegotiationState()
5998
- Task { await commandQueue.removeAll() } // stale writes die with the session
6026
+ commandQueue.removeAll() // stale writes die with the session
5999
6027
  closeL2capFileChannel()
6000
6028
 
6001
6029
  // Disconnect BLE
6030
+ cancelPendingReconnect(reason: "destroy")
6002
6031
  if let peripheral = connectedPeripheral {
6003
6032
  centralManager?.cancelPeripheralConnection(peripheral)
6004
6033
  }
6005
6034
 
6035
+ clearPhysicalReadiness()
6006
6036
  DeviceStore.shared.apply("glasses", "connected", false)
6007
- DeviceStore.shared.apply("glasses", "fullyBooted", false)
6008
6037
  DeviceStore.shared.apply("glasses", "wifiConnected", false)
6009
6038
  DeviceStore.shared.apply("glasses", "wifiSsid", "")
6010
6039
  DeviceStore.shared.apply("glasses", "wifiLocalIp", "")
@@ -6141,8 +6170,8 @@ extension MentraLive {
6141
6170
  /// pre-sid build) or differing from the recorded one = restart; same sid = no action.
6142
6171
  /// A restart is a LOGICAL session reset: send phone_ready immediately (bypassing the
6143
6172
  /// sr_hrt heartbeat's stale-readiness suppression); the returning glasses_ready runs
6144
- /// the full existing remote-wire-reset flow. fullyBooted is deliberately untouched -
6145
- /// the physical link never dropped, so the connection UI must not flap.
6173
+ /// the full existing remote-wire-reset flow. Keep the physical connection intact,
6174
+ /// but mark readiness pending until that new wire epoch has been established.
6146
6175
  private func handleGlassesSessionId(_ json: [String: Any]) {
6147
6176
  guard let sid = json["sid"] as? String, !sid.isEmpty else { return }
6148
6177
  let previous = glassesSessionId
@@ -6157,6 +6186,9 @@ extension MentraLive {
6157
6186
  "LIVE: 🔁 Glasses session changed (\(previous ?? "<pre-sid build>") -> \(sid)) - " +
6158
6187
  "asg restarted under a live link, re-running readiness"
6159
6188
  )
6189
+ // Emit the existing connected-but-not-ready status without invoking the
6190
+ // physical-disconnect side effects of DeviceStore.apply(fullyBooted: false).
6191
+ DeviceStore.shared.set("glasses", "fullyBooted", false)
6160
6192
  sendPhoneReady(reason: "glasses session changed")
6161
6193
  Bridge.sendTypedMessage(
6162
6194
  "glasses_session_changed",
@@ -6874,6 +6906,9 @@ extension MentraLive {
6874
6906
  // Send glasses-side loudness / Barrier gate setting.
6875
6907
  sendLoudnessGateSetting()
6876
6908
 
6909
+ // Send glasses-side auto power-off setting.
6910
+ sendAutoPowerOffSetting()
6911
+
6877
6912
  // Send mic tuning. With nothing authorized this sends a reset, which is
6878
6913
  // what returns a freshly connected pair of glasses to stock behaviour.
6879
6914
  sendMicTuningSetting()
@@ -7100,6 +7135,46 @@ extension MentraLive {
7100
7135
  }
7101
7136
  }
7102
7137
 
7138
+ func sendAutoPowerOffSetting() {
7139
+ let enabled = DeviceStore.shared.get("bluetooth", "auto_power_off_enabled") as? Bool
7140
+ ?? BluetoothSdkDefaults.autoPowerOffEnabled
7141
+ Bridge.log(
7142
+ "LIVE: 🔋 Sending auto power-off setting to glasses: enabled=\(enabled)"
7143
+ + " (cs_swit type=\(Self.autoPowerOffSwitchType))"
7144
+ )
7145
+
7146
+ guard connectedPeripheral != nil, txCharacteristic != nil else {
7147
+ Bridge.log("LIVE: Cannot send auto power-off setting - BLE write path not ready")
7148
+ return
7149
+ }
7150
+
7151
+ do {
7152
+ let bodyData = try JSONSerialization.data(withJSONObject: [
7153
+ "type": Self.autoPowerOffSwitchType,
7154
+ "switch": enabled ? 1 : 0,
7155
+ ])
7156
+ guard let bodyString = String(data: bodyData, encoding: .utf8) else {
7157
+ Bridge.log("LIVE: Failed to encode auto power-off payload")
7158
+ return
7159
+ }
7160
+ let command: [String: Any] = [
7161
+ "C": "cs_swit",
7162
+ "V": 1,
7163
+ "B": bodyString,
7164
+ ]
7165
+ if !sendRawK900Command(command, wakeUp: true) {
7166
+ Bridge.log("LIVE: Failed to send auto power-off setting command")
7167
+ } else {
7168
+ Bridge.log(
7169
+ "LIVE: 🔋 Queued auto power-off cs_swit type=\(Self.autoPowerOffSwitchType)"
7170
+ + " switch=\(enabled ? 1 : 0)"
7171
+ )
7172
+ }
7173
+ } catch {
7174
+ Bridge.log("LIVE: Error encoding auto power-off payload: \(error)")
7175
+ }
7176
+ }
7177
+
7103
7178
  func sendButtonVideoRecordingSettings() {
7104
7179
  let settings =
7105
7180
  DeviceStore.shared.get("bluetooth", "button_video_settings") as? [String: Any] ?? [
@@ -7418,7 +7493,7 @@ extension MentraLive {
7418
7493
  if fps > 0 { settings["fps"] = fps }
7419
7494
  json["settings"] = settings
7420
7495
  }
7421
- sendJson(json)
7496
+ sendJson(json, wakeUp: true)
7422
7497
  }
7423
7498
 
7424
7499
  func stopVideoRecording(requestId: String) {
@@ -7433,7 +7508,7 @@ extension MentraLive {
7433
7508
  sendJson([
7434
7509
  "type": "get_video_recording_status",
7435
7510
  "requestId": requestId,
7436
- ])
7511
+ ], wakeUp: true)
7437
7512
  }
7438
7513
 
7439
7514
  func stopVideoRecording(requestId: String, webhookUrl: String?, authToken: String?) {
@@ -7458,7 +7533,7 @@ extension MentraLive {
7458
7533
  if let authToken, !authToken.isEmpty {
7459
7534
  json["authToken"] = authToken
7460
7535
  }
7461
- sendJson(json)
7536
+ sendJson(json, wakeUp: true)
7462
7537
  }
7463
7538
  }
7464
7539