@mentra/bluetooth-sdk 0.1.21-beta.3 → 3.1.0-beta.100

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 (144) hide show
  1. package/README.md +67 -25
  2. package/android/build.gradle +7 -4
  3. package/android/lc3Lib/build.gradle +18 -4
  4. package/android/silero/build.gradle +9 -1
  5. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +16 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +35 -4
  7. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +216 -23
  8. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +2 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +7 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +11 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +127 -8
  12. package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +93 -12
  13. package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +5 -7
  14. package/android/src/main/java/com/mentra/bluetoothsdk/ReleaseChangelog.kt +61 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/ScopedNetworkReadiness.kt +17 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/audio/AudioModels.kt +15 -0
  17. package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +24 -0
  18. package/android/src/main/java/com/mentra/bluetoothsdk/net/LocalIpv4.kt +57 -0
  19. package/android/src/main/java/com/mentra/bluetoothsdk/otaserver/LocalOtaServer.kt +304 -0
  20. package/android/src/main/java/com/mentra/bluetoothsdk/otaserver/MentraOtaServerModule.kt +120 -0
  21. package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/MentraPhotoReceiverModule.kt +2 -49
  22. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +61 -30
  23. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/BesOtaHeartbeatGuard.kt +49 -0
  24. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/BesOtaProgressMapper.kt +27 -0
  25. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +1393 -284
  26. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingAdvertisement.kt +54 -0
  27. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +8 -0
  28. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +17 -0
  29. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +12 -0
  30. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +74 -79
  31. package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +3 -3
  32. package/android/src/test/java/com/mentra/bluetoothsdk/OtaStartResponsePolicyTest.kt +30 -0
  33. package/android/src/test/java/com/mentra/bluetoothsdk/ReleaseChangelogTest.kt +13 -0
  34. package/android/src/test/java/com/mentra/bluetoothsdk/ScopedNetworkReadinessTest.kt +39 -0
  35. package/android/src/test/java/com/mentra/bluetoothsdk/otaserver/LocalOtaServerTest.kt +82 -0
  36. package/android/src/test/java/com/mentra/bluetoothsdk/services/ForegroundServiceTypeTest.kt +69 -0
  37. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/BesOtaHeartbeatGuardTest.kt +73 -0
  38. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/BesOtaProgressMapperTest.kt +67 -0
  39. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCharacteristicsTest.kt +39 -0
  40. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingAdvertisementParserTest.kt +133 -0
  41. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +148 -7
  42. package/build/BluetoothSdk.types.d.ts +84 -2
  43. package/build/BluetoothSdk.types.d.ts.map +1 -1
  44. package/build/BluetoothSdk.types.js.map +1 -1
  45. package/build/_internal.d.ts +2 -0
  46. package/build/_internal.d.ts.map +1 -1
  47. package/build/_internal.js +2 -0
  48. package/build/_internal.js.map +1 -1
  49. package/build/_private/BluetoothSdkModule.d.ts +4 -1
  50. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  51. package/build/_private/BluetoothSdkModule.js +1 -1
  52. package/build/_private/BluetoothSdkModule.js.map +1 -1
  53. package/build/_private/MentraLocalNetworkModule.d.ts +1 -0
  54. package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -1
  55. package/build/_private/MentraLocalNetworkModule.js.map +1 -1
  56. package/build/_private/sdkOtaManifest.d.ts +3 -12
  57. package/build/_private/sdkOtaManifest.d.ts.map +1 -1
  58. package/build/_private/sdkOtaManifest.js +5 -17
  59. package/build/_private/sdkOtaManifest.js.map +1 -1
  60. package/build/changelogs.d.ts +7 -0
  61. package/build/changelogs.d.ts.map +1 -0
  62. package/build/changelogs.js +44 -0
  63. package/build/changelogs.js.map +1 -0
  64. package/build/generated/changelogCatalog.d.ts +6 -0
  65. package/build/generated/changelogCatalog.d.ts.map +1 -0
  66. package/build/generated/changelogCatalog.js +8 -0
  67. package/build/generated/changelogCatalog.js.map +1 -0
  68. package/build/generated/releaseMetadata.d.ts +12 -0
  69. package/build/generated/releaseMetadata.d.ts.map +1 -0
  70. package/build/generated/releaseMetadata.js +10 -0
  71. package/build/generated/releaseMetadata.js.map +1 -0
  72. package/build/index.d.ts +1 -1
  73. package/build/index.d.ts.map +1 -1
  74. package/build/index.js +24 -2
  75. package/build/index.js.map +1 -1
  76. package/build/ota-server/MentraOtaServer.types.d.ts +14 -0
  77. package/build/ota-server/MentraOtaServer.types.d.ts.map +1 -0
  78. package/build/ota-server/MentraOtaServer.types.js +2 -0
  79. package/build/ota-server/MentraOtaServer.types.js.map +1 -0
  80. package/build/ota-server/MentraOtaServerModule.d.ts +21 -0
  81. package/build/ota-server/MentraOtaServerModule.d.ts.map +1 -0
  82. package/build/ota-server/MentraOtaServerModule.js +3 -0
  83. package/build/ota-server/MentraOtaServerModule.js.map +1 -0
  84. package/build/ota-server/MentraOtaServerModule.web.d.ts +10 -0
  85. package/build/ota-server/MentraOtaServerModule.web.d.ts.map +1 -0
  86. package/build/ota-server/MentraOtaServerModule.web.js +12 -0
  87. package/build/ota-server/MentraOtaServerModule.web.js.map +1 -0
  88. package/build/ota-server/index.d.ts +3 -0
  89. package/build/ota-server/index.d.ts.map +1 -0
  90. package/build/ota-server/index.js +3 -0
  91. package/build/ota-server/index.js.map +1 -0
  92. package/build/ota-transport/index.d.ts +67 -0
  93. package/build/ota-transport/index.d.ts.map +1 -0
  94. package/build/ota-transport/index.js +58 -0
  95. package/build/ota-transport/index.js.map +1 -0
  96. package/expo-module.config.json +3 -2
  97. package/ios/BackgroundOtaArtifactDownloader.swift +125 -0
  98. package/ios/BluetoothSdkModule.swift +16 -2
  99. package/ios/LocalIPv4.swift +150 -0
  100. package/ios/LocalOtaServer.swift +309 -0
  101. package/ios/MentraBluetoothSDK.podspec +5 -1
  102. package/ios/MentraOtaServerModule.swift +147 -0
  103. package/ios/MentraPhotoReceiverModule.swift +1 -81
  104. package/ios/Source/BluetoothSdkDefaults.swift +4 -1
  105. package/ios/Source/Bridge.swift +43 -4
  106. package/ios/Source/DeviceManager.swift +154 -19
  107. package/ios/Source/DeviceStore.swift +2 -0
  108. package/ios/Source/GeneratedChangelogCatalog.swift +6 -0
  109. package/ios/Source/GeneratedReleaseMetadata.swift +11 -0
  110. package/ios/Source/MentraBluetoothSDK.swift +91 -6
  111. package/ios/Source/OtaManifest.swift +5 -7
  112. package/ios/Source/ReleaseChangelog.swift +65 -0
  113. package/ios/Source/audio/AudioModels.swift +17 -0
  114. package/ios/Source/camera/CameraModels.swift +1 -0
  115. package/ios/Source/events/BluetoothEvents.swift +27 -0
  116. package/ios/Source/sgcs/MentraLive.swift +512 -78
  117. package/ios/Source/sgcs/MentraLivePairingAdvertisement.swift +64 -0
  118. package/ios/Source/sgcs/SGCManager.swift +11 -0
  119. package/ios/Source/status/DeviceStatus.swift +23 -0
  120. package/ios/Source/types/DeviceModels.swift +23 -2
  121. package/ios/Source/utils/BleJsonCompact.swift +73 -71
  122. package/ios/Tests/MentraLivePairingAdvertisementTests.swift +160 -0
  123. package/ios/Tests/ReleaseChangelogTests.swift +13 -0
  124. package/package.json +10 -1
  125. package/plugin/build/withIos.d.ts +3 -1
  126. package/plugin/build/withIos.js +22 -10
  127. package/scripts/public-ota-api.test.mjs +37 -0
  128. package/scripts/verify-release-package.mjs +34 -0
  129. package/scripts/write-release-metadata.mjs +154 -0
  130. package/scripts/write-release-metadata.test.mjs +29 -0
  131. package/src/BluetoothSdk.types.ts +97 -3
  132. package/src/_internal.ts +2 -0
  133. package/src/_private/BluetoothSdkModule.ts +5 -5
  134. package/src/_private/MentraLocalNetworkModule.ts +1 -1
  135. package/src/_private/sdkOtaManifest.ts +5 -17
  136. package/src/changelogs.ts +44 -0
  137. package/src/generated/changelogCatalog.ts +9 -0
  138. package/src/generated/releaseMetadata.ts +20 -0
  139. package/src/index.ts +43 -2
  140. package/src/ota-server/MentraOtaServer.types.ts +10 -0
  141. package/src/ota-server/MentraOtaServerModule.ts +23 -0
  142. package/src/ota-server/MentraOtaServerModule.web.ts +17 -0
  143. package/src/ota-server/index.ts +2 -0
  144. package/src/ota-transport/index.ts +116 -0
@@ -25,6 +25,35 @@ struct MentraLiveDevice {
25
25
  let address: String
26
26
  }
27
27
 
28
+ enum MentraLivePendingPairingTarget {
29
+ static func matches(
30
+ connectedName: String,
31
+ connectedIdentifier: String,
32
+ pendingName: String,
33
+ pendingIdentifier: String
34
+ ) -> Bool {
35
+ if !pendingIdentifier.isEmpty {
36
+ return connectedIdentifier.caseInsensitiveCompare(pendingIdentifier) == .orderedSame
37
+ }
38
+ return !pendingName.isEmpty && connectedName == pendingName
39
+ }
40
+
41
+ static func shouldRecover(
42
+ isConnected: Bool,
43
+ connectedName: String,
44
+ connectedIdentifier: String,
45
+ pendingName: String,
46
+ pendingIdentifier: String
47
+ ) -> Bool {
48
+ isConnected && matches(
49
+ connectedName: connectedName,
50
+ connectedIdentifier: connectedIdentifier,
51
+ pendingName: pendingName,
52
+ pendingIdentifier: pendingIdentifier
53
+ )
54
+ }
55
+ }
56
+
28
57
  // MARK: - BlePhotoUploadService
29
58
 
30
59
  class BlePhotoUploadService {
@@ -857,20 +886,66 @@ extension MentraLive: CBCentralManagerDelegate {
857
886
  }
858
887
  }
859
888
 
860
- func handleDiscoveredPeripheral(_ peripheral: CBPeripheral, rssi: NSNumber? = nil) {
889
+ func handleDiscoveredPeripheral(
890
+ _ peripheral: CBPeripheral,
891
+ advertisementData: [String: Any] = [:],
892
+ rssi: NSNumber? = nil
893
+ ) {
861
894
  guard let name = peripheral.name else { return }
862
895
 
863
896
  // Check for compatible device names
864
897
  if name == "Xy_A" || name.hasPrefix("XyBLE_") || name.hasPrefix("MENTRA_LIVE_BLE")
865
898
  || name.hasPrefix("MENTRA_LIVE_BT") || name.lowercased().hasPrefix("mentra_live")
866
899
  {
900
+ let savedDeviceName = UserDefaults.standard.string(forKey: PREFS_DEVICE_NAME)
901
+ let isReconnectTarget = savedDeviceName != nil && savedDeviceName == name
902
+ if !isReconnectTarget && advertisesPairingFlag(advertisementData)
903
+ && !isPairingDiscoverable(advertisementData)
904
+ {
905
+ // Keep nearby secure units visible with pairing-mode guidance, while RN blocks
906
+ // the connection until a pairable advertisement arrives.
907
+ Bridge.log(
908
+ "LIVE: Nearby \(name) is secure firmware not in pairing mode — exposing as non-pairable"
909
+ )
910
+ discoveredPeripherals[name] = peripheral
911
+ cacheAdvPairing(
912
+ name,
913
+ pairingMode: false,
914
+ pairingCode: nil,
915
+ securePairingCapable: true
916
+ )
917
+ emitDiscoveredDeviceFromCache(
918
+ name,
919
+ identifier: peripheral.identifier.uuidString,
920
+ rssi: rssi?.intValue
921
+ )
922
+ return
923
+ }
924
+
867
925
  let glassType = name == "Xy_A" ? "Standard" : "K900"
868
- Bridge.log("Found compatible \(glassType) glasses device: \(name)")
926
+ let trailer = parseSecurePairingTrailer(advertisementData)
927
+ Bridge.log(
928
+ trailer.secureCapable
929
+ ? "Found compatible \(glassType) glasses device: \(name) (pairingMode=\(trailer.pairingMode))"
930
+ : "Found compatible \(glassType) glasses device: \(name) (legacy firmware, pairable)"
931
+ )
869
932
 
870
933
  // Store the peripheral
871
934
  discoveredPeripherals[name] = peripheral
872
935
 
873
- emitDiscoveredDevice(name, identifier: peripheral.identifier.uuidString, rssi: rssi?.intValue)
936
+ cacheAdvPairing(
937
+ name,
938
+ // Field firmware has no pairing flag. Treat it as pairable so the
939
+ // new Mentra App can still find old glasses.
940
+ pairingMode: trailer.secureCapable ? trailer.pairingMode : true,
941
+ pairingCode: trailer.pairingCode,
942
+ securePairingCapable: trailer.secureCapable
943
+ )
944
+ emitDiscoveredDeviceFromCache(
945
+ name,
946
+ identifier: peripheral.identifier.uuidString,
947
+ rssi: rssi?.intValue
948
+ )
874
949
 
875
950
  // Check if this is the device we want to connect to
876
951
  if let savedDeviceName = UserDefaults.standard.string(forKey: PREFS_DEVICE_NAME),
@@ -887,21 +962,42 @@ extension MentraLive: CBCentralManagerDelegate {
887
962
 
888
963
  nonisolated func centralManager(
889
964
  _: CBCentralManager, didDiscover peripheral: CBPeripheral,
890
- advertisementData _: [String: Any], rssi: NSNumber
965
+ advertisementData: [String: Any], rssi: NSNumber
891
966
  ) {
892
967
  DispatchQueue.main.async { [weak self] in
893
- self?.handleDiscoveredPeripheral(peripheral, rssi: rssi)
968
+ self?.handleDiscoveredPeripheral(peripheral, advertisementData: advertisementData, rssi: rssi)
894
969
  }
895
970
  }
896
971
 
897
972
  nonisolated func centralManager(_: CBCentralManager, didConnect peripheral: CBPeripheral) {
898
973
  DispatchQueue.main.async { [weak self] in
899
974
  guard let self else { return }
975
+ let matchesActiveAttempt = self.connectingPeripheral === peripheral
976
+ guard MentraLiveConnectionAttemptPolicy.shouldAcceptDidConnect(
977
+ pairingYieldActive: self.pairingYieldActive,
978
+ matchesActiveAttempt: matchesActiveAttempt
979
+ ) else {
980
+ Bridge.log(
981
+ "LIVE: Rejecting stale connection callback during pairing yield or after attempt replacement: \(peripheral.identifier)"
982
+ )
983
+ self.stopConnectionTimeout()
984
+ if self.connectingPeripheral === peripheral {
985
+ self.connectingPeripheral = nil
986
+ }
987
+ if self.connectedPeripheral === peripheral {
988
+ self.connectedPeripheral = nil
989
+ }
990
+ self.isConnecting = false
991
+ self.centralManager?.cancelPeripheralConnection(peripheral)
992
+ return
993
+ }
900
994
  Bridge.log("Connected to GATT server, discovering services...")
901
995
 
902
996
  self.stopConnectionTimeout()
903
997
  self.isConnecting = false
998
+ self.connectingPeripheral = nil
904
999
  self.connectedPeripheral = peripheral
1000
+ self.emitConnectedPendingDeviceForPairingScan()
905
1001
 
906
1002
  // Save device name and address for future reconnection
907
1003
  if let name = peripheral.name {
@@ -939,10 +1035,17 @@ extension MentraLive: CBCentralManagerDelegate {
939
1035
  }
940
1036
 
941
1037
  nonisolated func centralManager(
942
- _: CBCentralManager, didDisconnectPeripheral _: CBPeripheral, error _: Error?
1038
+ _: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?
943
1039
  ) {
944
1040
  DispatchQueue.main.async { [weak self] in
945
1041
  guard let self else { return }
1042
+ let isCurrent =
1043
+ self.connectedPeripheral === peripheral || self.connectingPeripheral === peripheral
1044
+ if !isCurrent {
1045
+ Bridge.log("LIVE: Ignoring stale disconnect for \(peripheral.identifier)")
1046
+ return
1047
+ }
1048
+ self.connectingPeripheral = nil
946
1049
  Bridge.log("LIVE: Disconnected from GATT server")
947
1050
 
948
1051
  self.isConnecting = false
@@ -962,6 +1065,11 @@ extension MentraLive: CBCentralManagerDelegate {
962
1065
  self.txCharacteristic = nil
963
1066
  self.rxCharacteristic = nil
964
1067
 
1068
+ if self.pairingYieldAwaitingReclaim, Self.isPairingAuthFailure(error) {
1069
+ self.clearSavedGlassesAfterOwnerLoss(reason: "ios_auth_fail")
1070
+ return
1071
+ }
1072
+
965
1073
  // Attempt reconnection if not killed
966
1074
  if !self.isKilled {
967
1075
  self.handleReconnection()
@@ -969,10 +1077,17 @@ extension MentraLive: CBCentralManagerDelegate {
969
1077
  }
970
1078
  }
971
1079
 
972
- nonisolated func centralManager(_: CBCentralManager, didFailToConnect _: CBPeripheral, error: Error?) {
1080
+ nonisolated func centralManager(_: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
973
1081
  let errorDescription = error?.localizedDescription ?? "Unknown error"
974
1082
  DispatchQueue.main.async { [weak self] in
975
1083
  guard let self else { return }
1084
+ let isCurrent =
1085
+ self.connectedPeripheral === peripheral || self.connectingPeripheral === peripheral
1086
+ if !isCurrent {
1087
+ Bridge.log("LIVE: Ignoring stale connect failure for \(peripheral.identifier)")
1088
+ return
1089
+ }
1090
+ self.connectingPeripheral = nil
976
1091
  Bridge.log("LIVE: Failed to connect to peripheral: \(errorDescription)")
977
1092
 
978
1093
  self.stopConnectionTimeout()
@@ -981,6 +1096,11 @@ extension MentraLive: CBCentralManagerDelegate {
981
1096
  self.connectedPeripheral = nil
982
1097
  self.updateConnectionState(ConnTypes.DISCONNECTED)
983
1098
 
1099
+ if self.pairingYieldAwaitingReclaim, Self.isPairingAuthFailure(error) {
1100
+ self.clearSavedGlassesAfterOwnerLoss(reason: "ios_auth_fail")
1101
+ return
1102
+ }
1103
+
984
1104
  if !self.isKilled {
985
1105
  self.handleReconnection()
986
1106
  }
@@ -1276,6 +1396,39 @@ class MentraLive: NSObject, SGCManager {
1276
1396
  private let BLOCK_AUDIO_DUPLEX = false
1277
1397
  private static let voiceActivityDetectionSwitchType = 8
1278
1398
  private static let loudnessGateSwitchType = 10
1399
+ private func pairingAdvertisement(
1400
+ _ advertisementData: [String: Any]
1401
+ ) -> MentraLivePairingAdvertisement? {
1402
+ MentraLivePairingAdvertisement.parse(
1403
+ coreBluetoothManufacturerData:
1404
+ advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data
1405
+ )
1406
+ }
1407
+
1408
+ private func isPairingDiscoverable(_ advertisementData: [String: Any]) -> Bool {
1409
+ pairingAdvertisement(advertisementData)?.pairingMode == true
1410
+ }
1411
+
1412
+ private struct SecurePairingTrailer {
1413
+ let pairingMode: Bool
1414
+ let pairingCode: String?
1415
+ let secureCapable: Bool
1416
+ }
1417
+
1418
+ private func parseSecurePairingTrailer(_ advertisementData: [String: Any]) -> SecurePairingTrailer {
1419
+ guard let advertisement = pairingAdvertisement(advertisementData) else {
1420
+ return SecurePairingTrailer(pairingMode: false, pairingCode: nil, secureCapable: false)
1421
+ }
1422
+ return SecurePairingTrailer(
1423
+ pairingMode: advertisement.pairingMode,
1424
+ pairingCode: advertisement.pairingCode,
1425
+ secureCapable: true
1426
+ )
1427
+ }
1428
+
1429
+ private func advertisesPairingFlag(_ advertisementData: [String: Any]) -> Bool {
1430
+ pairingAdvertisement(advertisementData) != nil
1431
+ }
1279
1432
 
1280
1433
  var connectionState: String = ConnTypes.DISCONNECTED
1281
1434
 
@@ -1290,6 +1443,8 @@ class MentraLive: NSObject, SGCManager {
1290
1443
  // return, unlike Android).
1291
1444
  DeviceStore.shared.apply("glasses", "serialNumber", "")
1292
1445
  DeviceStore.shared.apply("glasses", "bluetoothMacAddress", "")
1446
+ DeviceStore.shared.apply("glasses", "wifiMacAddress", "")
1447
+ DeviceStore.shared.apply("glasses", "hotspotOtaVersion", 0)
1293
1448
  }
1294
1449
  connectionState = state
1295
1450
  DeviceStore.shared.apply("glasses", "connectionState", state)
@@ -1297,6 +1452,7 @@ class MentraLive: NSObject, SGCManager {
1297
1452
  // a previous pairing into the next one (would otherwise surface as wrong overall_percent
1298
1453
  // or stale lastBesOtaProgress on the next OTA).
1299
1454
  if state == ConnTypes.DISCONNECTED {
1455
+ resetPendingAckState(reason: "BLE session ended")
1300
1456
  resetWireNegotiationState()
1301
1457
  resetAncsRelayState()
1302
1458
  // Queued writes are session-bound: transmitting them into the NEXT session
@@ -1342,14 +1498,26 @@ class MentraLive: NSObject, SGCManager {
1342
1498
  func forget() {
1343
1499
  Bridge.log("LIVE: Forgetting Mentra Live glasses")
1344
1500
 
1501
+ // Clear saved device name so the pairing filter is not bypassed on rescan
1502
+ UserDefaults.standard.removeObject(forKey: PREFS_DEVICE_NAME)
1503
+
1345
1504
  // Stop scanning first
1346
1505
  if isScanning {
1347
1506
  stopScan()
1348
1507
  emitStopScanEvent()
1349
1508
  }
1350
1509
 
1351
- // Then do full cleanup (disconnect + clear all references)
1352
- destroy()
1510
+ // Then do full cleanup after the unpair write can leave GATT.
1511
+ sendJson(["type": "unpair"], wakeUp: true, requireAck: false)
1512
+ unpairFlushWorkItem?.cancel()
1513
+ unpairFlushPending = true
1514
+ let work = DispatchWorkItem { [self] in
1515
+ self.unpairFlushPending = false
1516
+ self.unpairFlushWorkItem = nil
1517
+ self.destroy()
1518
+ }
1519
+ unpairFlushWorkItem = work
1520
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.4, execute: work)
1353
1521
  }
1354
1522
 
1355
1523
  var type = "Mentra Live"
@@ -1427,7 +1595,6 @@ class MentraLive: NSObject, SGCManager {
1427
1595
  private var lc3ReadCharacteristic: CBCharacteristic?
1428
1596
  private var lc3WriteCharacteristic: CBCharacteristic?
1429
1597
  private var supportsLC3Audio = true
1430
- private var lastReceivedLc3Sequence: Int8 = -1
1431
1598
  private let LC3_FRAME_SIZE = 40 // bytes per LC3 frame
1432
1599
  private let MICBEAT_INTERVAL_MS: TimeInterval = 30 * 60 // 30 minutes in seconds
1433
1600
  private var micBeatTimer: Timer?
@@ -1452,6 +1619,7 @@ class MentraLive: NSObject, SGCManager {
1452
1619
  private let KEEP_ALIVE_INTERVAL_MS: UInt64 = 5_000_000_000 // 5 seconds
1453
1620
  private let CONNECTION_TIMEOUT_MS: UInt64 = 100_000_000_000 // 100 seconds
1454
1621
  private let HEARTBEAT_INTERVAL_MS: TimeInterval = 30.0 // 30 seconds
1622
+ private let BES_OTA_HEARTBEAT_LEASE_SEC: TimeInterval = 120.0
1455
1623
  private let BATTERY_REQUEST_EVERY_N_HEARTBEATS = 10
1456
1624
  private let SIGNAL_STRENGTH_READ_INTERVAL_MS: TimeInterval = 10.0
1457
1625
  private let MIN_SEND_DELAY_MS: UInt64 = 160_000_000 // 160ms in nanoseconds
@@ -1472,6 +1640,8 @@ class MentraLive: NSObject, SGCManager {
1472
1640
  private var centralManager: CBCentralManager?
1473
1641
 
1474
1642
  private var connectedPeripheral: CBPeripheral?
1643
+ /// Peripheral for the in-flight connect; used to ignore stale disconnect/fail callbacks.
1644
+ private var connectingPeripheral: CBPeripheral?
1475
1645
  private var txCharacteristic: CBCharacteristic?
1476
1646
  private var rxCharacteristic: CBCharacteristic?
1477
1647
  private let bes2700MtuLimit = 509
@@ -1479,8 +1649,16 @@ class MentraLive: NSObject, SGCManager {
1479
1649
 
1480
1650
  // State Tracking
1481
1651
  private var isScanning = false
1652
+ private var manualDiscoveryActive = false
1482
1653
  private var isConnecting = false
1483
1654
  private var isKilled = false
1655
+ /// Glasses opened pairing window — stand down without forgetting identity/bonds.
1656
+ private var pairingYieldActive = false
1657
+ private var pairingYieldAwaitingReclaim = false
1658
+ private var pairingYieldEndWorkItem: DispatchWorkItem?
1659
+ /// Hold GATT teardown until the unpair write can leave the phone.
1660
+ private var unpairFlushPending = false
1661
+ private var unpairFlushWorkItem: DispatchWorkItem?
1484
1662
  private var reconnectAttempts = 0
1485
1663
  private var isNewVersion = false
1486
1664
  private var peerWireProtocolVersion = 0
@@ -1546,6 +1724,7 @@ class MentraLive: NSObject, SGCManager {
1546
1724
 
1547
1725
  /// BES OTA progress tracking - only send to UI on 5% increments
1548
1726
  private var lastBesOtaProgress = -1
1727
+ private var besOtaHeartbeatSuppressedUntil: TimeInterval = 0
1549
1728
 
1550
1729
  // Cached OTA session context from last ota_status — used to fill in session fields for sr_adota
1551
1730
  private var cachedOtaSessionId: String?
@@ -1598,6 +1777,16 @@ class MentraLive: NSObject, SGCManager {
1598
1777
  // MARK: - React Native Interface
1599
1778
 
1600
1779
  private var discoveredPeripherals = [String: CBPeripheral]() // name -> peripheral
1780
+ /// Last advertisement pairing trailer for a discovered name. Re-emits (already-
1781
+ /// connected / scan replay) must not drop these flags: omitting them makes a
1782
+ /// secure idle unit look like pairable legacy firmware.
1783
+ private var discoveredAdvPairing = [String: CachedAdvPairing]()
1784
+
1785
+ private struct CachedAdvPairing {
1786
+ let pairingMode: Bool
1787
+ let pairingCode: String?
1788
+ let securePairingCapable: Bool
1789
+ }
1601
1790
 
1602
1791
  func findCompatibleDevices() {
1603
1792
  Bridge.log("Finding compatible Mentra Live glasses")
@@ -1615,11 +1804,95 @@ class MentraLive: NSObject, SGCManager {
1615
1804
  // clear the saved device name:
1616
1805
  UserDefaults.standard.set("", forKey: PREFS_DEVICE_NAME)
1617
1806
 
1807
+ manualDiscoveryActive = true
1618
1808
  startScan()
1809
+ emitConnectedPendingDeviceForPairingScan()
1619
1810
  }
1620
1811
  }
1621
1812
 
1813
+ func isPairingYieldActive() -> Bool {
1814
+ pairingYieldActive
1815
+ }
1816
+
1817
+ private func enterPairingYield(windowMs: Int) {
1818
+ pairingYieldActive = true
1819
+ pairingYieldAwaitingReclaim = false
1820
+ pairingYieldEndWorkItem?.cancel()
1821
+ if isScanning {
1822
+ stopScan()
1823
+ }
1824
+ // Publish disconnect immediately for Phone A UI. didDisconnectPeripheral may lag
1825
+ // or race with cancelPeripheralConnection; do not wait on it for stand-down.
1826
+ isConnecting = false
1827
+ connected = false
1828
+ fullyBooted = false
1829
+ glassesSessionId = nil
1830
+ readinessCompletedThisBleSession = false
1831
+ rgbLedAuthorityClaimed = false
1832
+ stopAllTimers()
1833
+ closeL2capFileChannel()
1834
+ txCharacteristic = nil
1835
+ rxCharacteristic = nil
1836
+ updateConnectionState(ConnTypes.DISCONNECTED)
1837
+ if let peripheral = connectedPeripheral {
1838
+ centralManager?.cancelPeripheralConnection(peripheral)
1839
+ }
1840
+ connectedPeripheral = nil
1841
+ connectingPeripheral = nil
1842
+ // Stand down for the whole window. Probing GATT during yield races the new
1843
+ // phone for the single BLE slot and is exactly what entering_pairing_mode forbids.
1844
+
1845
+ let work = DispatchWorkItem { [weak self] in
1846
+ guard let self else { return }
1847
+ Bridge.log("LIVE: Pairing yield ended — resume reconnect if still owned")
1848
+ self.pairingYieldActive = false
1849
+ self.pairingYieldAwaitingReclaim = true
1850
+ self.pairingYieldEndWorkItem = nil
1851
+ if !self.isKilled, self.connectedPeripheral == nil {
1852
+ self.handleReconnection()
1853
+ }
1854
+ }
1855
+ pairingYieldEndWorkItem = work
1856
+ DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(windowMs), execute: work)
1857
+ }
1858
+
1859
+ /// Pairing/auth failures after yield — CoreBluetooth codes, not localized strings.
1860
+ /// Connection timeouts are not owner-loss.
1861
+ private static func isPairingAuthFailure(_ error: Error?) -> Bool {
1862
+ guard let error = error as NSError? else { return false }
1863
+ if error.domain == CBATTErrorDomain {
1864
+ return error.code == CBATTError.insufficientAuthentication.rawValue
1865
+ || error.code == CBATTError.insufficientEncryption.rawValue
1866
+ }
1867
+ if error.domain == CBErrorDomain {
1868
+ return error.code == CBError.peerRemovedPairingInformation.rawValue
1869
+ }
1870
+ return false
1871
+ }
1872
+
1873
+ private func clearSavedGlassesAfterOwnerLoss(reason: String) {
1874
+ Bridge.log("LIVE: Owner loss (\(reason)) — clearing saved glasses")
1875
+ pairingYieldAwaitingReclaim = false
1876
+ pairingYieldActive = false
1877
+ pairingYieldEndWorkItem?.cancel()
1878
+ pairingYieldEndWorkItem = nil
1879
+ forget()
1880
+ Task { @MainActor in
1881
+ DeviceStore.shared.apply("bluetooth", "default_wearable", "")
1882
+ DeviceStore.shared.apply("bluetooth", "device_name", "")
1883
+ DeviceStore.shared.apply("bluetooth", "device_address", "")
1884
+ }
1885
+ Bridge.saveSetting("default_wearable", "")
1886
+ Bridge.saveSetting("device_name", "")
1887
+ Bridge.saveSetting("device_address", "")
1888
+ Bridge.sendTypedMessage("owner_replaced", body: ["reason": reason])
1889
+ }
1890
+
1622
1891
  func connectById(_ deviceName: String) {
1892
+ if pairingYieldActive {
1893
+ Bridge.log("LIVE: connectById blocked — pairing yield active")
1894
+ return
1895
+ }
1623
1896
  Bridge.log("connectById: \(deviceName)")
1624
1897
  // Save the device name for future reconnection
1625
1898
  UserDefaults.standard.set(deviceName, forKey: PREFS_DEVICE_NAME)
@@ -1644,7 +1917,7 @@ class MentraLive: NSObject, SGCManager {
1644
1917
  {
1645
1918
  Bridge.log("Found already-connected peripheral: \(name)")
1646
1919
  discoveredPeripherals[name] = peripheral
1647
- emitDiscoveredDevice(name)
1920
+ emitDiscoveredDeviceFromCache(name)
1648
1921
 
1649
1922
  // Check if this is the device we want
1650
1923
  if let savedDeviceName = UserDefaults.standard.string(
@@ -1670,6 +1943,10 @@ class MentraLive: NSObject, SGCManager {
1670
1943
  }
1671
1944
 
1672
1945
  @objc func disconnect() {
1946
+ if unpairFlushPending {
1947
+ Bridge.log("LIVE: disconnect deferred — waiting for unpair write")
1948
+ return
1949
+ }
1673
1950
  Bridge.log("LIVE: disconnect() -Disconnecting from Mentra Live glasses")
1674
1951
 
1675
1952
  // if rgbLedAuthorityClaimed {
@@ -1695,6 +1972,8 @@ class MentraLive: NSObject, SGCManager {
1695
1972
 
1696
1973
  /// Start the micbeat mechanism to keep LC3 audio streaming active
1697
1974
  private func startMicBeat() {
1975
+ // A resumed custom-audio stream starts a new sequence domain.
1976
+ DeviceManager.shared.resetMicSequenceBaseline()
1698
1977
  Bridge.log("LIVE: 🎤 Starting micbeat mechanism")
1699
1978
  micBeatCount = 0
1700
1979
 
@@ -1941,6 +2220,18 @@ class MentraLive: NSObject, SGCManager {
1941
2220
  private var pending: PendingMessage?
1942
2221
  private var pendingMessageTimer: Timer?
1943
2222
 
2223
+ /// ACK state belongs to the BLE session that sent the message. If the link drops while an ACK
2224
+ /// is outstanding, keeping `pending` set permanently blocks the command queue because the
2225
+ /// timeout timer is stopped during disconnect. Release both together before reconnecting.
2226
+ private func resetPendingAckState(reason: String) {
2227
+ pendingMessageTimer?.invalidate()
2228
+ pendingMessageTimer = nil
2229
+
2230
+ guard let pendingMessage = pending else { return }
2231
+ pending = nil
2232
+ Bridge.log("LIVE: Cleared pending ACK mId \(pendingMessage.id) because \(reason)")
2233
+ }
2234
+
1944
2235
  actor CommandQueue {
1945
2236
  private var commands: [PendingMessage] = []
1946
2237
 
@@ -2079,7 +2370,10 @@ class MentraLive: NSObject, SGCManager {
2079
2370
  // MARK: - BLE Scanning
2080
2371
 
2081
2372
  private func startScan() {
2082
- // guard !isScanning else { return }
2373
+ if pairingYieldActive {
2374
+ Bridge.log("LIVE: startScan blocked — pairing yield active")
2375
+ return
2376
+ }
2083
2377
 
2084
2378
  guard centralManager!.state == .poweredOn else {
2085
2379
  Bridge.log("Attempting to scan but bluetooth is not powered on.")
@@ -2087,6 +2381,7 @@ class MentraLive: NSObject, SGCManager {
2087
2381
  }
2088
2382
 
2089
2383
  Bridge.log("Starting BLE scan for Mentra Live glasses")
2384
+ discoveredAdvPairing.removeAll()
2090
2385
  isScanning = true
2091
2386
 
2092
2387
  startReadinessCheckLoop()
@@ -2103,12 +2398,6 @@ class MentraLive: NSObject, SGCManager {
2103
2398
 
2104
2399
  centralManager?.scanForPeripherals(withServices: nil, options: scanOptions)
2105
2400
 
2106
- // emit already discovered peripherals:
2107
- for (_, peripheral) in discoveredPeripherals {
2108
- Bridge.log("LIVE: (Already discovered) peripheral: \(peripheral.name ?? "Unknown")")
2109
- emitDiscoveredDevice(peripheral.name!)
2110
- }
2111
-
2112
2401
  // var dName = DeviceManager.shared.deviceName
2113
2402
  // if dName.isEmpty {
2114
2403
  // dName = "MENTRA_LIVE"
@@ -2126,6 +2415,7 @@ class MentraLive: NSObject, SGCManager {
2126
2415
  }
2127
2416
 
2128
2417
  func stopScan() {
2418
+ manualDiscoveryActive = false
2129
2419
  guard isScanning else { return }
2130
2420
 
2131
2421
  centralManager?.stopScan()
@@ -2137,10 +2427,16 @@ class MentraLive: NSObject, SGCManager {
2137
2427
  // MARK: - Connection Management
2138
2428
 
2139
2429
  private func connectToDevice(_ peripheral: CBPeripheral) {
2430
+ if pairingYieldActive {
2431
+ Bridge.log("LIVE: connectToDevice blocked — pairing yield active")
2432
+ isConnecting = false
2433
+ return
2434
+ }
2140
2435
  Bridge.log("LIVE: Connecting to device: \(peripheral.identifier.uuidString)")
2141
2436
 
2142
2437
  isConnecting = true
2143
2438
  updateConnectionState(ConnTypes.CONNECTING)
2439
+ connectingPeripheral = peripheral
2144
2440
  connectedPeripheral = peripheral
2145
2441
  peripheral.delegate = self
2146
2442
 
@@ -2185,6 +2481,11 @@ class MentraLive: NSObject, SGCManager {
2185
2481
  return
2186
2482
  }
2187
2483
 
2484
+ if pairingYieldActive {
2485
+ Bridge.log("LIVE: Reconnection aborted - pairing yield active")
2486
+ return
2487
+ }
2488
+
2188
2489
  // Check if we've exceeded max attempts
2189
2490
  if reconnectAttempts >= MAX_RECONNECT_ATTEMPTS {
2190
2491
  Bridge.log("LIVE: Maximum reconnection attempts reached (\(MAX_RECONNECT_ATTEMPTS))")
@@ -2217,6 +2518,10 @@ class MentraLive: NSObject, SGCManager {
2217
2518
  guard let self = self else { return }
2218
2519
 
2219
2520
  // Use peripheral presence, not connectionState: we stay CONNECTING during backoff until scan/connect.
2521
+ if self.pairingYieldActive {
2522
+ Bridge.log("LIVE: Reconnection aborted - pairing yield active")
2523
+ return
2524
+ }
2220
2525
  if self.connectedPeripheral == nil, !self.isKilled {
2221
2526
  // Check for last known device name to start scan
2222
2527
  if let lastDeviceName = UserDefaults.standard.string(
@@ -2446,11 +2751,21 @@ class MentraLive: NSObject, SGCManager {
2446
2751
 
2447
2752
  switch type {
2448
2753
  case "glasses_ready":
2754
+ // A queued glasses_ready from the connection we just cancelled for yield
2755
+ // must not close the pairing window. Only accept readiness on a live GATT.
2756
+ if pairingYieldActive && connectedPeripheral == nil {
2757
+ Bridge.log("LIVE: Ignoring stale glasses_ready during pairing yield")
2758
+ return
2759
+ }
2449
2760
  // glasses_ready is a REMOTE wire-session reset: the glasses ran
2450
2761
  // onTransportReset() before sending it, so their side is back on legacy
2451
2762
  // framing regardless of what this side negotiated earlier. Start a fresh
2452
2763
  // wire epoch (clears v2-active state that would otherwise gate the
2453
2764
  // handshake off), then negotiate from the caps this message advertises.
2765
+ pairingYieldAwaitingReclaim = false
2766
+ pairingYieldActive = false
2767
+ pairingYieldEndWorkItem?.cancel()
2768
+ pairingYieldEndWorkItem = nil
2454
2769
  resetWireNegotiationState()
2455
2770
  parsePeerWireCaps(json)
2456
2771
  maybeSendWireHandshake()
@@ -2602,6 +2917,26 @@ class MentraLive: NSObject, SGCManager {
2602
2917
  case "pong":
2603
2918
  Bridge.log("LIVE: Received pong response - connection healthy")
2604
2919
 
2920
+ case "entering_pairing_mode":
2921
+ let windowMs = max(5_000, min(180_000, json["window_ms"] as? Int ?? 120_000))
2922
+ Bridge.log("LIVE: Glasses entering pairing mode — yield \(windowMs)ms (no forget)")
2923
+ enterPairingYield(windowMs: windowMs)
2924
+ let body: [String: Any] = [
2925
+ "window_ms": windowMs,
2926
+ "reason": json["reason"] as? String ?? "user_gesture",
2927
+ ]
2928
+ Bridge.sendTypedMessage("entering_pairing_mode", body: body)
2929
+
2930
+ case "pairing_info":
2931
+ Bridge.sendPairingInfo(
2932
+ hadPreviousBond: json["had_previous_bond"] as? Bool ?? false,
2933
+ pairingCode: json["pairing_code"] as? String,
2934
+ classicBondReady: json["classic_bond_ready"] as? Bool ?? false,
2935
+ // Legacy firmware that omits this field is not secure-capable.
2936
+ securePairingCapable: json["secure_pairing_capable"] as? Bool ?? false,
2937
+ protocolVersion: json["protocol_version"] as? Int ?? 1
2938
+ )
2939
+
2605
2940
  case "imu_response", "imu_stream_response", "imu_gesture_response",
2606
2941
  "imu_gesture_subscribed", "imu_ack", "imu_error":
2607
2942
  // Handle IMU-related responses
@@ -2669,6 +3004,7 @@ class MentraLive: NSObject, SGCManager {
2669
3004
  if let seq = json["sq"] as? [String] ?? json["step_sequence"] as? [String], !seq.isEmpty {
2670
3005
  cachedOtaStepSequence = seq
2671
3006
  }
3007
+ updateBesOtaHeartbeatGuard(stepType: osStepType, phase: osPhase, status: osStatus)
2672
3008
 
2673
3009
  Bridge.log("LIVE: 📱 OTA status - step \(osCurrentStep)/\(osTotalSteps) \(osPhase) \(osStatus) \(osOverallPercent)%")
2674
3010
 
@@ -2702,6 +3038,7 @@ class MentraLive: NSObject, SGCManager {
2702
3038
  } else {
2703
3039
  unified = "in_progress"
2704
3040
  }
3041
+ updateBesOtaHeartbeatGuard(stepType: currentUpdate, phase: legacyPhase, status: unified)
2705
3042
  Bridge.log(
2706
3043
  "LIVE: 📱 Legacy ota_progress → ota_status: \(legacyStage) \(legacyStatus) \(legacyProgress)%"
2707
3044
  )
@@ -2761,9 +3098,19 @@ class MentraLive: NSObject, SGCManager {
2761
3098
  if let systemTimeMs = fields["system_time_ms"] as? NSNumber {
2762
3099
  DeviceStore.shared.apply("glasses", "systemTimeMs", systemTimeMs.int64Value)
2763
3100
  }
3101
+ if let hotspotOtaVersion = fields["hotspot_ota_version"] as? NSNumber {
3102
+ DeviceStore.shared.apply(
3103
+ "glasses",
3104
+ "hotspotOtaVersion",
3105
+ hotspotOtaVersion.intValue
3106
+ )
3107
+ }
2764
3108
  if let bluetoothMacAddress = nonEmptyStringValue(fields, "bt_mac_address") {
2765
3109
  DeviceStore.shared.apply("glasses", "bluetoothMacAddress", bluetoothMacAddress)
2766
3110
  }
3111
+ if let wifiMacAddress = nonEmptyStringValue(fields, "wifi_mac_address") {
3112
+ DeviceStore.shared.apply("glasses", "wifiMacAddress", wifiMacAddress)
3113
+ }
2767
3114
  if let serialNumber = nonEmptyStringValue(fields, "serial_number") {
2768
3115
  DeviceStore.shared.apply("glasses", "serialNumber", serialNumber)
2769
3116
  }
@@ -2829,6 +3176,19 @@ class MentraLive: NSObject, SGCManager {
2829
3176
  cachedOtaCurrentStep = 0
2830
3177
  cachedOtaStepSequence = nil
2831
3178
  lastBesOtaProgress = -1
3179
+ // The bounded UART-ownership lease is transport state, not session-cache state. Keep it
3180
+ // through a BLE reconnect so heartbeats cannot resume while BES still owns the UART.
3181
+ }
3182
+
3183
+ private func updateBesOtaHeartbeatGuard(stepType: String, phase: String, status: String) {
3184
+ guard stepType.lowercased() == "bes", phase.lowercased() == "install" else { return }
3185
+ switch status.lowercased() {
3186
+ case "failed", "complete", "step_complete", "finished", "success":
3187
+ besOtaHeartbeatSuppressedUntil = 0
3188
+ default:
3189
+ besOtaHeartbeatSuppressedUntil =
3190
+ ProcessInfo.processInfo.systemUptime + BES_OTA_HEARTBEAT_LEASE_SEC
3191
+ }
2832
3192
  }
2833
3193
 
2834
3194
  /// Falls back to raw besProgress when step sequence is unavailable.
@@ -3000,62 +3360,41 @@ class MentraLive: NSObject, SGCManager {
3000
3360
  var progress = ((rawProgress + 2) / 5) * 5
3001
3361
  if progress > 100 { progress = 100 }
3002
3362
 
3003
- // Only send if progress changed to a new 5% increment
3004
3363
  let isTerminalStatus = type == "success" || type == "error" || type == "fail"
3005
- if progress == lastBesOtaProgress && !isTerminalStatus {
3006
- break // Skip duplicate progress
3007
- }
3008
- lastBesOtaProgress = progress
3009
-
3010
- Bridge.log(
3011
- "LIVE: 📱 BES OTA progress via sr_adota - type: \(type), raw: \(rawProgress)%, rounded: \(progress)%"
3012
- )
3013
-
3014
- // Determine status and error message based on type
3015
- var besOtaStatus: String
3016
3364
  var besOtaProgressVal: Int
3017
3365
  var besOtaErrorMessage: String? = nil
3018
3366
 
3019
- // Order matters here: check completion (rawProgress >= 100 OR success) BEFORE
3020
- // type=="update", because some BES firmware emits the final 100% tick with
3021
- // type=="update" rather than type=="success". Treating that as PROGRESS would
3022
- // leave the UI stuck at 100% forever.
3023
- if type == "success" || rawProgress >= 100 {
3024
- besOtaStatus = "FINISHED"
3367
+ let failed = type == "error" || type == "fail"
3368
+ let succeeded = type == "success"
3369
+ if isTerminalStatus {
3370
+ besOtaHeartbeatSuppressedUntil = 0
3371
+ } else {
3372
+ // Refresh before progress de-duplication: raw progress proves the BES
3373
+ // transfer is active even when the rounded UI value is unchanged.
3374
+ besOtaHeartbeatSuppressedUntil =
3375
+ ProcessInfo.processInfo.systemUptime + BES_OTA_HEARTBEAT_LEASE_SEC
3376
+ }
3377
+ if succeeded {
3025
3378
  besOtaProgressVal = 100
3026
- lastBesOtaProgress = -1 // Reset for next OTA
3027
- } else if type == "error" || type == "fail" {
3028
- besOtaStatus = "FAILED"
3379
+ } else if failed {
3029
3380
  besOtaProgressVal = progress
3030
3381
  besOtaErrorMessage = bodyObj["message"] as? String ?? "BES update failed"
3031
- lastBesOtaProgress = -1 // Reset for next OTA
3032
- } else if type == "update" {
3033
- besOtaStatus = "PROGRESS"
3034
- besOtaProgressVal = progress
3035
3382
  } else {
3036
- // Unknown type, treat as progress
3037
- besOtaStatus = "PROGRESS"
3038
- besOtaProgressVal = progress
3383
+ // A raw update:100 precedes whole-image CRC/apply and is not terminal.
3384
+ besOtaProgressVal = min(progress, 95)
3039
3385
  }
3040
3386
 
3041
- let syntheticStatus: String
3042
- if besOtaStatus == "FINISHED" {
3043
- // The glasses power-cycle right after the final BES tick, so a session
3044
- // whose BES step is the LAST step never gets a follow-up ota_status from
3045
- // the glasses — consumers mapping on this synthetic status would otherwise
3046
- // never see a terminal state. Emit "complete" for the final step;
3047
- // mid-session BES steps keep "step_complete" so session-level trackers
3048
- // advance normally. Unknown sessions (cachedOtaTotalSteps == 0, e.g.
3049
- // legacy glasses that never sent an ota_status) conservatively keep
3050
- // "step_complete".
3051
- syntheticStatus = (cachedOtaTotalSteps > 0 && cachedOtaCurrentStep >= cachedOtaTotalSteps)
3052
- ? "complete"
3053
- : "step_complete"
3054
- } else if besOtaStatus == "FAILED" {
3055
- syntheticStatus = "failed"
3056
- } else {
3057
- syntheticStatus = "in_progress"
3387
+ // Only send if nonterminal display progress changed to a new 5% increment.
3388
+ if besOtaProgressVal == lastBesOtaProgress && !isTerminalStatus {
3389
+ break
3058
3390
  }
3391
+ lastBesOtaProgress = isTerminalStatus ? -1 : besOtaProgressVal
3392
+
3393
+ Bridge.log(
3394
+ "LIVE: 📱 BES OTA progress via sr_adota - type: \(type), raw: \(rawProgress)%, rounded: \(progress)%, display: \(besOtaProgressVal)%"
3395
+ )
3396
+
3397
+ let syntheticStatus = succeeded ? "step_complete" : (failed ? "failed" : "in_progress")
3059
3398
  let sid = cachedOtaSessionId ?? ""
3060
3399
  let totalSteps = cachedOtaTotalSteps > 0 ? cachedOtaTotalSteps : 1
3061
3400
  let currentStep = cachedOtaCurrentStep > 0 ? cachedOtaCurrentStep : 1
@@ -3210,6 +3549,17 @@ class MentraLive: NSObject, SGCManager {
3210
3549
  sendJson(json, wakeUp: true)
3211
3550
  }
3212
3551
 
3552
+ func sendWifiAdbState(_ enabled: Bool) {
3553
+ Bridge.log("LIVE: 🔧 Sending Wi-Fi ADB state: \(enabled)")
3554
+
3555
+ let json: [String: Any] = [
3556
+ "type": "set_wifi_adb_state",
3557
+ "enabled": enabled,
3558
+ ]
3559
+
3560
+ sendJson(json, wakeUp: true)
3561
+ }
3562
+
3213
3563
  func sendSetSystemTime(_ timestampMs: Int64) {
3214
3564
  Bridge.log("LIVE: ⏰ Sending set_system_time: \(timestampMs)")
3215
3565
 
@@ -3329,7 +3679,6 @@ class MentraLive: NSObject, SGCManager {
3329
3679
  {
3330
3680
  json["ota_version_url"] = otaVersionUrl
3331
3681
  }
3332
-
3333
3682
  sendJson(json, wakeUp: true)
3334
3683
  }
3335
3684
 
@@ -3500,17 +3849,9 @@ class MentraLive: NSObject, SGCManager {
3500
3849
  return
3501
3850
  }
3502
3851
 
3503
- let sequenceNumber = Int8(bitPattern: data[1])
3852
+ let sequenceNumber = Int(data[1])
3504
3853
  let lc3Data = data.subdata(in: 2 ..< data.count)
3505
3854
 
3506
- // Validate sequence number for packet loss detection
3507
- if lastReceivedLc3Sequence != -1 && (lastReceivedLc3Sequence &+ 1) != sequenceNumber {
3508
- Bridge.log(
3509
- "LIVE: LC3 packet sequence mismatch. Expected: \(lastReceivedLc3Sequence &+ 1), Got: \(sequenceNumber)"
3510
- )
3511
- }
3512
- lastReceivedLc3Sequence = sequenceNumber
3513
-
3514
3855
  // // Decode LC3 to PCM using existing PcmConverter
3515
3856
  // let pcmConverter = PcmConverter()
3516
3857
  // guard let pcmData = pcmConverter.decode(lc3Data) as? Data, pcmData.count > 0 else {
@@ -3524,7 +3865,7 @@ class MentraLive: NSObject, SGCManager {
3524
3865
  // Bridge.log(
3525
3866
  // "LIVE: Processed LC3 audio seq=\(sequenceNumber), \(lc3Data.count) bytes"
3526
3867
  // )
3527
- DeviceManager.shared.handleGlassesMicData(lc3Data, 40)
3868
+ DeviceManager.shared.handleGlassesMicData(lc3Data, 40, sequenceNumber: sequenceNumber)
3528
3869
 
3529
3870
  // Bridge.log(
3530
3871
  // "LIVE: Processed LC3 audio seq=\(sequenceNumber), \(lc3Data.count)→\(pcmData.count) bytes"
@@ -5068,6 +5409,10 @@ class MentraLive: NSObject, SGCManager {
5068
5409
  Bridge.log("LIVE: Skipping heartbeat - glasses not fully booted or not connected")
5069
5410
  return
5070
5411
  }
5412
+ if ProcessInfo.processInfo.systemUptime < besOtaHeartbeatSuppressedUntil {
5413
+ Bridge.log("LIVE: Skipping heartbeat while BES OTA owns the UART")
5414
+ return
5415
+ }
5071
5416
 
5072
5417
  // Send ping message to glasses hardware (no ACK needed for heartbeats)
5073
5418
  let pingJson: [String: Any] = ["type": "ping"]
@@ -5181,16 +5526,89 @@ class MentraLive: NSObject, SGCManager {
5181
5526
  stopReadinessCheckLoop()
5182
5527
  stopConnectionTimeout()
5183
5528
  stopMicBeat() // Stop LC3 audio micbeat
5184
- pendingMessageTimer?.invalidate()
5185
- pendingMessageTimer = nil
5529
+ resetPendingAckState(reason: "transport timers stopped")
5186
5530
  reconnectionWorkItem?.cancel()
5187
5531
  reconnectionWorkItem = nil
5188
5532
  }
5189
5533
 
5190
5534
  // MARK: - Event Emission
5191
5535
 
5192
- private func emitDiscoveredDevice(_ name: String, identifier: String = "", rssi: Int? = nil) {
5193
- Bridge.sendDiscoveredDevice("Mentra Live", name, deviceAddress: identifier, rssi: rssi)
5536
+ /// A selected pairing target can stop advertising once GATT connects, before readiness
5537
+ /// promotes it to the default device. Re-emit only that explicit pending target; an
5538
+ /// established owner's connected glasses have no pending identity and remain hidden.
5539
+ private func emitConnectedPendingDeviceForPairingScan() {
5540
+ guard manualDiscoveryActive, !pairingYieldActive, let peripheral = connectedPeripheral,
5541
+ let name = peripheral.name,
5542
+ MentraLivePendingPairingTarget.shouldRecover(
5543
+ isConnected: peripheral.state == .connected,
5544
+ connectedName: name,
5545
+ connectedIdentifier: peripheral.identifier.uuidString,
5546
+ pendingName: DeviceStore.shared.get("bluetooth", "pending_device_name") as? String ?? "",
5547
+ pendingIdentifier: DeviceStore.shared.get("bluetooth", "pending_device_address") as? String ?? ""
5548
+ )
5549
+ else {
5550
+ return
5551
+ }
5552
+
5553
+ Bridge.log("LIVE: Pairing scan: recovering connected pending target \(name) (\(peripheral.identifier))")
5554
+ emitDiscoveredDevice(
5555
+ name,
5556
+ identifier: peripheral.identifier.uuidString,
5557
+ pairingMode: true,
5558
+ pairingCode: nil,
5559
+ securePairingCapable: DeviceStore.shared.get(
5560
+ "bluetooth",
5561
+ "pending_device_secure_pairing_capable"
5562
+ ) as? Bool
5563
+ )
5564
+ }
5565
+
5566
+ private func cacheAdvPairing(
5567
+ _ name: String,
5568
+ pairingMode: Bool,
5569
+ pairingCode: String?,
5570
+ securePairingCapable: Bool
5571
+ ) {
5572
+ discoveredAdvPairing[name] = CachedAdvPairing(
5573
+ pairingMode: pairingMode,
5574
+ pairingCode: pairingCode,
5575
+ securePairingCapable: securePairingCapable
5576
+ )
5577
+ }
5578
+
5579
+ private func emitDiscoveredDeviceFromCache(
5580
+ _ name: String,
5581
+ identifier: String = "",
5582
+ rssi: Int? = nil
5583
+ ) {
5584
+ let cached = discoveredAdvPairing[name]
5585
+ emitDiscoveredDevice(
5586
+ name,
5587
+ identifier: identifier,
5588
+ rssi: rssi,
5589
+ pairingMode: cached?.pairingMode,
5590
+ pairingCode: cached?.pairingCode,
5591
+ securePairingCapable: cached?.securePairingCapable
5592
+ )
5593
+ }
5594
+
5595
+ private func emitDiscoveredDevice(
5596
+ _ name: String,
5597
+ identifier: String = "",
5598
+ rssi: Int? = nil,
5599
+ pairingMode: Bool? = nil,
5600
+ pairingCode: String? = nil,
5601
+ securePairingCapable: Bool? = nil
5602
+ ) {
5603
+ Bridge.sendDiscoveredDevice(
5604
+ "Mentra Live",
5605
+ name,
5606
+ deviceAddress: identifier,
5607
+ rssi: rssi,
5608
+ pairingMode: pairingMode,
5609
+ pairingCode: pairingCode,
5610
+ securePairingCapable: securePairingCapable
5611
+ )
5194
5612
  }
5195
5613
 
5196
5614
  private func emitStopScanEvent() {
@@ -5279,6 +5697,9 @@ class MentraLive: NSObject, SGCManager {
5279
5697
  Bridge.log("Destroying MentraLiveManager")
5280
5698
 
5281
5699
  isKilled = true
5700
+ unpairFlushWorkItem?.cancel()
5701
+ unpairFlushWorkItem = nil
5702
+ unpairFlushPending = false
5282
5703
 
5283
5704
  // Stop scanning
5284
5705
  if isScanning {
@@ -5312,6 +5733,8 @@ class MentraLive: NSObject, SGCManager {
5312
5733
  DeviceStore.shared.apply("glasses", "hotspotGatewayIp", "")
5313
5734
 
5314
5735
  connectedPeripheral = nil
5736
+ discoveredPeripherals.removeAll()
5737
+ discoveredAdvPairing.removeAll()
5315
5738
  centralManager?.delegate = nil
5316
5739
  centralManager = nil
5317
5740
 
@@ -6521,6 +6944,17 @@ extension MentraLive {
6521
6944
  stopVideoRecording(requestId: requestId, webhookUrl: nil, authToken: nil)
6522
6945
  }
6523
6946
 
6947
+ func queryVideoRecordingStatus(requestId: String) {
6948
+ guard connectionState == ConnTypes.CONNECTED else {
6949
+ Bridge.log("Cannot query video recording status - not connected")
6950
+ return
6951
+ }
6952
+ sendJson([
6953
+ "type": "get_video_recording_status",
6954
+ "requestId": requestId,
6955
+ ])
6956
+ }
6957
+
6524
6958
  func stopVideoRecording(requestId: String, webhookUrl: String?, authToken: String?) {
6525
6959
  Bridge.log(
6526
6960
  "Stopping video recording on glasses: requestId=\(requestId), webhook=\((webhookUrl?.isEmpty ?? true) ? "none" : "set")"