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

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 (122) hide show
  1. package/README.md +56 -9
  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/GeneratedReleaseMetadata.kt +11 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +112 -8
  11. package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +93 -12
  12. package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +5 -7
  13. package/android/src/main/java/com/mentra/bluetoothsdk/ScopedNetworkReadiness.kt +17 -0
  14. package/android/src/main/java/com/mentra/bluetoothsdk/audio/AudioModels.kt +15 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +24 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/net/LocalIpv4.kt +57 -0
  17. package/android/src/main/java/com/mentra/bluetoothsdk/otaserver/LocalOtaServer.kt +304 -0
  18. package/android/src/main/java/com/mentra/bluetoothsdk/otaserver/MentraOtaServerModule.kt +120 -0
  19. package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/MentraPhotoReceiverModule.kt +2 -49
  20. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +61 -30
  21. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/BesOtaHeartbeatGuard.kt +49 -0
  22. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/BesOtaProgressMapper.kt +27 -0
  23. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +1344 -284
  24. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingAdvertisement.kt +54 -0
  25. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +8 -0
  26. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +17 -0
  27. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +12 -0
  28. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +74 -79
  29. package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +3 -3
  30. package/android/src/test/java/com/mentra/bluetoothsdk/OtaStartResponsePolicyTest.kt +30 -0
  31. package/android/src/test/java/com/mentra/bluetoothsdk/ScopedNetworkReadinessTest.kt +39 -0
  32. package/android/src/test/java/com/mentra/bluetoothsdk/otaserver/LocalOtaServerTest.kt +82 -0
  33. package/android/src/test/java/com/mentra/bluetoothsdk/services/ForegroundServiceTypeTest.kt +69 -0
  34. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/BesOtaHeartbeatGuardTest.kt +73 -0
  35. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/BesOtaProgressMapperTest.kt +67 -0
  36. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCharacteristicsTest.kt +39 -0
  37. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingAdvertisementParserTest.kt +75 -0
  38. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +148 -7
  39. package/build/BluetoothSdk.types.d.ts +58 -2
  40. package/build/BluetoothSdk.types.d.ts.map +1 -1
  41. package/build/BluetoothSdk.types.js.map +1 -1
  42. package/build/_internal.d.ts +2 -0
  43. package/build/_internal.d.ts.map +1 -1
  44. package/build/_internal.js +2 -0
  45. package/build/_internal.js.map +1 -1
  46. package/build/_private/BluetoothSdkModule.d.ts +4 -1
  47. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  48. package/build/_private/BluetoothSdkModule.js +1 -1
  49. package/build/_private/BluetoothSdkModule.js.map +1 -1
  50. package/build/_private/MentraLocalNetworkModule.d.ts +1 -0
  51. package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -1
  52. package/build/_private/MentraLocalNetworkModule.js.map +1 -1
  53. package/build/_private/sdkOtaManifest.d.ts +3 -12
  54. package/build/_private/sdkOtaManifest.d.ts.map +1 -1
  55. package/build/_private/sdkOtaManifest.js +5 -17
  56. package/build/_private/sdkOtaManifest.js.map +1 -1
  57. package/build/generated/releaseMetadata.d.ts +12 -0
  58. package/build/generated/releaseMetadata.d.ts.map +1 -0
  59. package/build/generated/releaseMetadata.js +10 -0
  60. package/build/generated/releaseMetadata.js.map +1 -0
  61. package/build/index.d.ts +1 -1
  62. package/build/index.d.ts.map +1 -1
  63. package/build/index.js +10 -2
  64. package/build/index.js.map +1 -1
  65. package/build/ota-server/MentraOtaServer.types.d.ts +14 -0
  66. package/build/ota-server/MentraOtaServer.types.d.ts.map +1 -0
  67. package/build/ota-server/MentraOtaServer.types.js +2 -0
  68. package/build/ota-server/MentraOtaServer.types.js.map +1 -0
  69. package/build/ota-server/MentraOtaServerModule.d.ts +21 -0
  70. package/build/ota-server/MentraOtaServerModule.d.ts.map +1 -0
  71. package/build/ota-server/MentraOtaServerModule.js +3 -0
  72. package/build/ota-server/MentraOtaServerModule.js.map +1 -0
  73. package/build/ota-server/MentraOtaServerModule.web.d.ts +10 -0
  74. package/build/ota-server/MentraOtaServerModule.web.d.ts.map +1 -0
  75. package/build/ota-server/MentraOtaServerModule.web.js +12 -0
  76. package/build/ota-server/MentraOtaServerModule.web.js.map +1 -0
  77. package/build/ota-server/index.d.ts +3 -0
  78. package/build/ota-server/index.d.ts.map +1 -0
  79. package/build/ota-server/index.js +3 -0
  80. package/build/ota-server/index.js.map +1 -0
  81. package/expo-module.config.json +3 -2
  82. package/ios/BackgroundOtaArtifactDownloader.swift +125 -0
  83. package/ios/BluetoothSdkModule.swift +16 -2
  84. package/ios/LocalIPv4.swift +150 -0
  85. package/ios/LocalOtaServer.swift +309 -0
  86. package/ios/MentraBluetoothSDK.podspec +5 -1
  87. package/ios/MentraOtaServerModule.swift +147 -0
  88. package/ios/MentraPhotoReceiverModule.swift +1 -81
  89. package/ios/Source/BluetoothSdkDefaults.swift +4 -1
  90. package/ios/Source/Bridge.swift +43 -4
  91. package/ios/Source/DeviceManager.swift +154 -19
  92. package/ios/Source/DeviceStore.swift +2 -0
  93. package/ios/Source/GeneratedReleaseMetadata.swift +11 -0
  94. package/ios/Source/MentraBluetoothSDK.swift +74 -6
  95. package/ios/Source/OtaManifest.swift +5 -7
  96. package/ios/Source/audio/AudioModels.swift +17 -0
  97. package/ios/Source/camera/CameraModels.swift +1 -0
  98. package/ios/Source/events/BluetoothEvents.swift +27 -0
  99. package/ios/Source/sgcs/MentraLive.swift +469 -77
  100. package/ios/Source/sgcs/MentraLivePairingAdvertisement.swift +64 -0
  101. package/ios/Source/sgcs/SGCManager.swift +11 -0
  102. package/ios/Source/status/DeviceStatus.swift +23 -0
  103. package/ios/Source/types/DeviceModels.swift +23 -2
  104. package/ios/Source/utils/BleJsonCompact.swift +73 -71
  105. package/ios/Tests/MentraLivePairingAdvertisementTests.swift +104 -0
  106. package/package.json +5 -1
  107. package/plugin/build/withIos.d.ts +3 -1
  108. package/plugin/build/withIos.js +22 -10
  109. package/scripts/verify-release-package.mjs +34 -0
  110. package/scripts/write-release-metadata.mjs +154 -0
  111. package/scripts/write-release-metadata.test.mjs +29 -0
  112. package/src/BluetoothSdk.types.ts +69 -3
  113. package/src/_internal.ts +2 -0
  114. package/src/_private/BluetoothSdkModule.ts +5 -5
  115. package/src/_private/MentraLocalNetworkModule.ts +1 -1
  116. package/src/_private/sdkOtaManifest.ts +5 -17
  117. package/src/generated/releaseMetadata.ts +20 -0
  118. package/src/index.ts +19 -2
  119. package/src/ota-server/MentraOtaServer.types.ts +10 -0
  120. package/src/ota-server/MentraOtaServerModule.ts +23 -0
  121. package/src/ota-server/MentraOtaServerModule.web.ts +17 -0
  122. package/src/ota-server/index.ts +2 -0
@@ -857,20 +857,65 @@ extension MentraLive: CBCentralManagerDelegate {
857
857
  }
858
858
  }
859
859
 
860
- func handleDiscoveredPeripheral(_ peripheral: CBPeripheral, rssi: NSNumber? = nil) {
860
+ func handleDiscoveredPeripheral(
861
+ _ peripheral: CBPeripheral,
862
+ advertisementData: [String: Any] = [:],
863
+ rssi: NSNumber? = nil
864
+ ) {
861
865
  guard let name = peripheral.name else { return }
862
866
 
863
867
  // Check for compatible device names
864
868
  if name == "Xy_A" || name.hasPrefix("XyBLE_") || name.hasPrefix("MENTRA_LIVE_BLE")
865
869
  || name.hasPrefix("MENTRA_LIVE_BT") || name.lowercased().hasPrefix("mentra_live")
866
870
  {
871
+ let savedDeviceName = UserDefaults.standard.string(forKey: PREFS_DEVICE_NAME)
872
+ let isReconnectTarget = savedDeviceName != nil && savedDeviceName == name
873
+ if !isReconnectTarget && advertisesPairingFlag(advertisementData)
874
+ && !isPairingDiscoverable(advertisementData)
875
+ {
876
+ // Nearby but not pairable: RN uses this for the empty-state hint, not the list.
877
+ Bridge.log(
878
+ "LIVE: Nearby \(name) is secure firmware not in pairing mode — hiding from scan list"
879
+ )
880
+ discoveredPeripherals[name] = peripheral
881
+ cacheAdvPairing(
882
+ name,
883
+ pairingMode: false,
884
+ pairingCode: nil,
885
+ securePairingCapable: true
886
+ )
887
+ emitDiscoveredDeviceFromCache(
888
+ name,
889
+ identifier: peripheral.identifier.uuidString,
890
+ rssi: rssi?.intValue
891
+ )
892
+ return
893
+ }
894
+
867
895
  let glassType = name == "Xy_A" ? "Standard" : "K900"
868
- Bridge.log("Found compatible \(glassType) glasses device: \(name)")
896
+ let trailer = parseSecurePairingTrailer(advertisementData)
897
+ Bridge.log(
898
+ trailer.secureCapable
899
+ ? "Found compatible \(glassType) glasses device: \(name) (pairingMode=\(trailer.pairingMode))"
900
+ : "Found compatible \(glassType) glasses device: \(name) (legacy firmware, pairable)"
901
+ )
869
902
 
870
903
  // Store the peripheral
871
904
  discoveredPeripherals[name] = peripheral
872
905
 
873
- emitDiscoveredDevice(name, identifier: peripheral.identifier.uuidString, rssi: rssi?.intValue)
906
+ cacheAdvPairing(
907
+ name,
908
+ // Field firmware has no pairing flag. Treat it as pairable so the
909
+ // new Mentra App can still find old glasses.
910
+ pairingMode: trailer.secureCapable ? trailer.pairingMode : true,
911
+ pairingCode: trailer.pairingCode,
912
+ securePairingCapable: trailer.secureCapable
913
+ )
914
+ emitDiscoveredDeviceFromCache(
915
+ name,
916
+ identifier: peripheral.identifier.uuidString,
917
+ rssi: rssi?.intValue
918
+ )
874
919
 
875
920
  // Check if this is the device we want to connect to
876
921
  if let savedDeviceName = UserDefaults.standard.string(forKey: PREFS_DEVICE_NAME),
@@ -887,20 +932,40 @@ extension MentraLive: CBCentralManagerDelegate {
887
932
 
888
933
  nonisolated func centralManager(
889
934
  _: CBCentralManager, didDiscover peripheral: CBPeripheral,
890
- advertisementData _: [String: Any], rssi: NSNumber
935
+ advertisementData: [String: Any], rssi: NSNumber
891
936
  ) {
892
937
  DispatchQueue.main.async { [weak self] in
893
- self?.handleDiscoveredPeripheral(peripheral, rssi: rssi)
938
+ self?.handleDiscoveredPeripheral(peripheral, advertisementData: advertisementData, rssi: rssi)
894
939
  }
895
940
  }
896
941
 
897
942
  nonisolated func centralManager(_: CBCentralManager, didConnect peripheral: CBPeripheral) {
898
943
  DispatchQueue.main.async { [weak self] in
899
944
  guard let self else { return }
945
+ let matchesActiveAttempt = self.connectingPeripheral === peripheral
946
+ guard MentraLiveConnectionAttemptPolicy.shouldAcceptDidConnect(
947
+ pairingYieldActive: self.pairingYieldActive,
948
+ matchesActiveAttempt: matchesActiveAttempt
949
+ ) else {
950
+ Bridge.log(
951
+ "LIVE: Rejecting stale connection callback during pairing yield or after attempt replacement: \(peripheral.identifier)"
952
+ )
953
+ self.stopConnectionTimeout()
954
+ if self.connectingPeripheral === peripheral {
955
+ self.connectingPeripheral = nil
956
+ }
957
+ if self.connectedPeripheral === peripheral {
958
+ self.connectedPeripheral = nil
959
+ }
960
+ self.isConnecting = false
961
+ self.centralManager?.cancelPeripheralConnection(peripheral)
962
+ return
963
+ }
900
964
  Bridge.log("Connected to GATT server, discovering services...")
901
965
 
902
966
  self.stopConnectionTimeout()
903
967
  self.isConnecting = false
968
+ self.connectingPeripheral = nil
904
969
  self.connectedPeripheral = peripheral
905
970
 
906
971
  // Save device name and address for future reconnection
@@ -939,10 +1004,17 @@ extension MentraLive: CBCentralManagerDelegate {
939
1004
  }
940
1005
 
941
1006
  nonisolated func centralManager(
942
- _: CBCentralManager, didDisconnectPeripheral _: CBPeripheral, error _: Error?
1007
+ _: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?
943
1008
  ) {
944
1009
  DispatchQueue.main.async { [weak self] in
945
1010
  guard let self else { return }
1011
+ let isCurrent =
1012
+ self.connectedPeripheral === peripheral || self.connectingPeripheral === peripheral
1013
+ if !isCurrent {
1014
+ Bridge.log("LIVE: Ignoring stale disconnect for \(peripheral.identifier)")
1015
+ return
1016
+ }
1017
+ self.connectingPeripheral = nil
946
1018
  Bridge.log("LIVE: Disconnected from GATT server")
947
1019
 
948
1020
  self.isConnecting = false
@@ -962,6 +1034,11 @@ extension MentraLive: CBCentralManagerDelegate {
962
1034
  self.txCharacteristic = nil
963
1035
  self.rxCharacteristic = nil
964
1036
 
1037
+ if self.pairingYieldAwaitingReclaim, Self.isPairingAuthFailure(error) {
1038
+ self.clearSavedGlassesAfterOwnerLoss(reason: "ios_auth_fail")
1039
+ return
1040
+ }
1041
+
965
1042
  // Attempt reconnection if not killed
966
1043
  if !self.isKilled {
967
1044
  self.handleReconnection()
@@ -969,10 +1046,17 @@ extension MentraLive: CBCentralManagerDelegate {
969
1046
  }
970
1047
  }
971
1048
 
972
- nonisolated func centralManager(_: CBCentralManager, didFailToConnect _: CBPeripheral, error: Error?) {
1049
+ nonisolated func centralManager(_: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
973
1050
  let errorDescription = error?.localizedDescription ?? "Unknown error"
974
1051
  DispatchQueue.main.async { [weak self] in
975
1052
  guard let self else { return }
1053
+ let isCurrent =
1054
+ self.connectedPeripheral === peripheral || self.connectingPeripheral === peripheral
1055
+ if !isCurrent {
1056
+ Bridge.log("LIVE: Ignoring stale connect failure for \(peripheral.identifier)")
1057
+ return
1058
+ }
1059
+ self.connectingPeripheral = nil
976
1060
  Bridge.log("LIVE: Failed to connect to peripheral: \(errorDescription)")
977
1061
 
978
1062
  self.stopConnectionTimeout()
@@ -981,6 +1065,11 @@ extension MentraLive: CBCentralManagerDelegate {
981
1065
  self.connectedPeripheral = nil
982
1066
  self.updateConnectionState(ConnTypes.DISCONNECTED)
983
1067
 
1068
+ if self.pairingYieldAwaitingReclaim, Self.isPairingAuthFailure(error) {
1069
+ self.clearSavedGlassesAfterOwnerLoss(reason: "ios_auth_fail")
1070
+ return
1071
+ }
1072
+
984
1073
  if !self.isKilled {
985
1074
  self.handleReconnection()
986
1075
  }
@@ -1276,6 +1365,39 @@ class MentraLive: NSObject, SGCManager {
1276
1365
  private let BLOCK_AUDIO_DUPLEX = false
1277
1366
  private static let voiceActivityDetectionSwitchType = 8
1278
1367
  private static let loudnessGateSwitchType = 10
1368
+ private func pairingAdvertisement(
1369
+ _ advertisementData: [String: Any]
1370
+ ) -> MentraLivePairingAdvertisement? {
1371
+ MentraLivePairingAdvertisement.parse(
1372
+ coreBluetoothManufacturerData:
1373
+ advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data
1374
+ )
1375
+ }
1376
+
1377
+ private func isPairingDiscoverable(_ advertisementData: [String: Any]) -> Bool {
1378
+ pairingAdvertisement(advertisementData)?.pairingMode == true
1379
+ }
1380
+
1381
+ private struct SecurePairingTrailer {
1382
+ let pairingMode: Bool
1383
+ let pairingCode: String?
1384
+ let secureCapable: Bool
1385
+ }
1386
+
1387
+ private func parseSecurePairingTrailer(_ advertisementData: [String: Any]) -> SecurePairingTrailer {
1388
+ guard let advertisement = pairingAdvertisement(advertisementData) else {
1389
+ return SecurePairingTrailer(pairingMode: false, pairingCode: nil, secureCapable: false)
1390
+ }
1391
+ return SecurePairingTrailer(
1392
+ pairingMode: advertisement.pairingMode,
1393
+ pairingCode: advertisement.pairingCode,
1394
+ secureCapable: true
1395
+ )
1396
+ }
1397
+
1398
+ private func advertisesPairingFlag(_ advertisementData: [String: Any]) -> Bool {
1399
+ pairingAdvertisement(advertisementData) != nil
1400
+ }
1279
1401
 
1280
1402
  var connectionState: String = ConnTypes.DISCONNECTED
1281
1403
 
@@ -1290,6 +1412,8 @@ class MentraLive: NSObject, SGCManager {
1290
1412
  // return, unlike Android).
1291
1413
  DeviceStore.shared.apply("glasses", "serialNumber", "")
1292
1414
  DeviceStore.shared.apply("glasses", "bluetoothMacAddress", "")
1415
+ DeviceStore.shared.apply("glasses", "wifiMacAddress", "")
1416
+ DeviceStore.shared.apply("glasses", "hotspotOtaVersion", 0)
1293
1417
  }
1294
1418
  connectionState = state
1295
1419
  DeviceStore.shared.apply("glasses", "connectionState", state)
@@ -1297,6 +1421,7 @@ class MentraLive: NSObject, SGCManager {
1297
1421
  // a previous pairing into the next one (would otherwise surface as wrong overall_percent
1298
1422
  // or stale lastBesOtaProgress on the next OTA).
1299
1423
  if state == ConnTypes.DISCONNECTED {
1424
+ resetPendingAckState(reason: "BLE session ended")
1300
1425
  resetWireNegotiationState()
1301
1426
  resetAncsRelayState()
1302
1427
  // Queued writes are session-bound: transmitting them into the NEXT session
@@ -1342,14 +1467,26 @@ class MentraLive: NSObject, SGCManager {
1342
1467
  func forget() {
1343
1468
  Bridge.log("LIVE: Forgetting Mentra Live glasses")
1344
1469
 
1470
+ // Clear saved device name so the pairing filter is not bypassed on rescan
1471
+ UserDefaults.standard.removeObject(forKey: PREFS_DEVICE_NAME)
1472
+
1345
1473
  // Stop scanning first
1346
1474
  if isScanning {
1347
1475
  stopScan()
1348
1476
  emitStopScanEvent()
1349
1477
  }
1350
1478
 
1351
- // Then do full cleanup (disconnect + clear all references)
1352
- destroy()
1479
+ // Then do full cleanup after the unpair write can leave GATT.
1480
+ sendJson(["type": "unpair"], wakeUp: true, requireAck: false)
1481
+ unpairFlushWorkItem?.cancel()
1482
+ unpairFlushPending = true
1483
+ let work = DispatchWorkItem { [self] in
1484
+ self.unpairFlushPending = false
1485
+ self.unpairFlushWorkItem = nil
1486
+ self.destroy()
1487
+ }
1488
+ unpairFlushWorkItem = work
1489
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.4, execute: work)
1353
1490
  }
1354
1491
 
1355
1492
  var type = "Mentra Live"
@@ -1427,7 +1564,6 @@ class MentraLive: NSObject, SGCManager {
1427
1564
  private var lc3ReadCharacteristic: CBCharacteristic?
1428
1565
  private var lc3WriteCharacteristic: CBCharacteristic?
1429
1566
  private var supportsLC3Audio = true
1430
- private var lastReceivedLc3Sequence: Int8 = -1
1431
1567
  private let LC3_FRAME_SIZE = 40 // bytes per LC3 frame
1432
1568
  private let MICBEAT_INTERVAL_MS: TimeInterval = 30 * 60 // 30 minutes in seconds
1433
1569
  private var micBeatTimer: Timer?
@@ -1452,6 +1588,7 @@ class MentraLive: NSObject, SGCManager {
1452
1588
  private let KEEP_ALIVE_INTERVAL_MS: UInt64 = 5_000_000_000 // 5 seconds
1453
1589
  private let CONNECTION_TIMEOUT_MS: UInt64 = 100_000_000_000 // 100 seconds
1454
1590
  private let HEARTBEAT_INTERVAL_MS: TimeInterval = 30.0 // 30 seconds
1591
+ private let BES_OTA_HEARTBEAT_LEASE_SEC: TimeInterval = 120.0
1455
1592
  private let BATTERY_REQUEST_EVERY_N_HEARTBEATS = 10
1456
1593
  private let SIGNAL_STRENGTH_READ_INTERVAL_MS: TimeInterval = 10.0
1457
1594
  private let MIN_SEND_DELAY_MS: UInt64 = 160_000_000 // 160ms in nanoseconds
@@ -1472,6 +1609,8 @@ class MentraLive: NSObject, SGCManager {
1472
1609
  private var centralManager: CBCentralManager?
1473
1610
 
1474
1611
  private var connectedPeripheral: CBPeripheral?
1612
+ /// Peripheral for the in-flight connect; used to ignore stale disconnect/fail callbacks.
1613
+ private var connectingPeripheral: CBPeripheral?
1475
1614
  private var txCharacteristic: CBCharacteristic?
1476
1615
  private var rxCharacteristic: CBCharacteristic?
1477
1616
  private let bes2700MtuLimit = 509
@@ -1481,6 +1620,13 @@ class MentraLive: NSObject, SGCManager {
1481
1620
  private var isScanning = false
1482
1621
  private var isConnecting = false
1483
1622
  private var isKilled = false
1623
+ /// Glasses opened pairing window — stand down without forgetting identity/bonds.
1624
+ private var pairingYieldActive = false
1625
+ private var pairingYieldAwaitingReclaim = false
1626
+ private var pairingYieldEndWorkItem: DispatchWorkItem?
1627
+ /// Hold GATT teardown until the unpair write can leave the phone.
1628
+ private var unpairFlushPending = false
1629
+ private var unpairFlushWorkItem: DispatchWorkItem?
1484
1630
  private var reconnectAttempts = 0
1485
1631
  private var isNewVersion = false
1486
1632
  private var peerWireProtocolVersion = 0
@@ -1546,6 +1692,7 @@ class MentraLive: NSObject, SGCManager {
1546
1692
 
1547
1693
  /// BES OTA progress tracking - only send to UI on 5% increments
1548
1694
  private var lastBesOtaProgress = -1
1695
+ private var besOtaHeartbeatSuppressedUntil: TimeInterval = 0
1549
1696
 
1550
1697
  // Cached OTA session context from last ota_status — used to fill in session fields for sr_adota
1551
1698
  private var cachedOtaSessionId: String?
@@ -1598,6 +1745,16 @@ class MentraLive: NSObject, SGCManager {
1598
1745
  // MARK: - React Native Interface
1599
1746
 
1600
1747
  private var discoveredPeripherals = [String: CBPeripheral]() // name -> peripheral
1748
+ /// Last advertisement pairing trailer for a discovered name. Re-emits (already-
1749
+ /// connected / scan replay) must not drop these flags: omitting them makes a
1750
+ /// secure idle unit look like pairable legacy firmware.
1751
+ private var discoveredAdvPairing = [String: CachedAdvPairing]()
1752
+
1753
+ private struct CachedAdvPairing {
1754
+ let pairingMode: Bool
1755
+ let pairingCode: String?
1756
+ let securePairingCapable: Bool
1757
+ }
1601
1758
 
1602
1759
  func findCompatibleDevices() {
1603
1760
  Bridge.log("Finding compatible Mentra Live glasses")
@@ -1619,7 +1776,89 @@ class MentraLive: NSObject, SGCManager {
1619
1776
  }
1620
1777
  }
1621
1778
 
1779
+ func isPairingYieldActive() -> Bool {
1780
+ pairingYieldActive
1781
+ }
1782
+
1783
+ private func enterPairingYield(windowMs: Int) {
1784
+ pairingYieldActive = true
1785
+ pairingYieldAwaitingReclaim = false
1786
+ pairingYieldEndWorkItem?.cancel()
1787
+ if isScanning {
1788
+ stopScan()
1789
+ }
1790
+ // Publish disconnect immediately for Phone A UI. didDisconnectPeripheral may lag
1791
+ // or race with cancelPeripheralConnection; do not wait on it for stand-down.
1792
+ isConnecting = false
1793
+ connected = false
1794
+ fullyBooted = false
1795
+ glassesSessionId = nil
1796
+ readinessCompletedThisBleSession = false
1797
+ rgbLedAuthorityClaimed = false
1798
+ stopAllTimers()
1799
+ closeL2capFileChannel()
1800
+ txCharacteristic = nil
1801
+ rxCharacteristic = nil
1802
+ updateConnectionState(ConnTypes.DISCONNECTED)
1803
+ if let peripheral = connectedPeripheral {
1804
+ centralManager?.cancelPeripheralConnection(peripheral)
1805
+ }
1806
+ connectedPeripheral = nil
1807
+ connectingPeripheral = nil
1808
+ // Stand down for the whole window. Probing GATT during yield races the new
1809
+ // phone for the single BLE slot and is exactly what entering_pairing_mode forbids.
1810
+
1811
+ let work = DispatchWorkItem { [weak self] in
1812
+ guard let self else { return }
1813
+ Bridge.log("LIVE: Pairing yield ended — resume reconnect if still owned")
1814
+ self.pairingYieldActive = false
1815
+ self.pairingYieldAwaitingReclaim = true
1816
+ self.pairingYieldEndWorkItem = nil
1817
+ if !self.isKilled, self.connectedPeripheral == nil {
1818
+ self.handleReconnection()
1819
+ }
1820
+ }
1821
+ pairingYieldEndWorkItem = work
1822
+ DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(windowMs), execute: work)
1823
+ }
1824
+
1825
+ /// Pairing/auth failures after yield — CoreBluetooth codes, not localized strings.
1826
+ /// Connection timeouts are not owner-loss.
1827
+ private static func isPairingAuthFailure(_ error: Error?) -> Bool {
1828
+ guard let error = error as NSError? else { return false }
1829
+ if error.domain == CBATTErrorDomain {
1830
+ return error.code == CBATTError.insufficientAuthentication.rawValue
1831
+ || error.code == CBATTError.insufficientEncryption.rawValue
1832
+ }
1833
+ if error.domain == CBErrorDomain {
1834
+ return error.code == CBError.peerRemovedPairingInformation.rawValue
1835
+ }
1836
+ return false
1837
+ }
1838
+
1839
+ private func clearSavedGlassesAfterOwnerLoss(reason: String) {
1840
+ Bridge.log("LIVE: Owner loss (\(reason)) — clearing saved glasses")
1841
+ pairingYieldAwaitingReclaim = false
1842
+ pairingYieldActive = false
1843
+ pairingYieldEndWorkItem?.cancel()
1844
+ pairingYieldEndWorkItem = nil
1845
+ forget()
1846
+ Task { @MainActor in
1847
+ DeviceStore.shared.apply("bluetooth", "default_wearable", "")
1848
+ DeviceStore.shared.apply("bluetooth", "device_name", "")
1849
+ DeviceStore.shared.apply("bluetooth", "device_address", "")
1850
+ }
1851
+ Bridge.saveSetting("default_wearable", "")
1852
+ Bridge.saveSetting("device_name", "")
1853
+ Bridge.saveSetting("device_address", "")
1854
+ Bridge.sendTypedMessage("owner_replaced", body: ["reason": reason])
1855
+ }
1856
+
1622
1857
  func connectById(_ deviceName: String) {
1858
+ if pairingYieldActive {
1859
+ Bridge.log("LIVE: connectById blocked — pairing yield active")
1860
+ return
1861
+ }
1623
1862
  Bridge.log("connectById: \(deviceName)")
1624
1863
  // Save the device name for future reconnection
1625
1864
  UserDefaults.standard.set(deviceName, forKey: PREFS_DEVICE_NAME)
@@ -1644,7 +1883,7 @@ class MentraLive: NSObject, SGCManager {
1644
1883
  {
1645
1884
  Bridge.log("Found already-connected peripheral: \(name)")
1646
1885
  discoveredPeripherals[name] = peripheral
1647
- emitDiscoveredDevice(name)
1886
+ emitDiscoveredDeviceFromCache(name)
1648
1887
 
1649
1888
  // Check if this is the device we want
1650
1889
  if let savedDeviceName = UserDefaults.standard.string(
@@ -1670,6 +1909,10 @@ class MentraLive: NSObject, SGCManager {
1670
1909
  }
1671
1910
 
1672
1911
  @objc func disconnect() {
1912
+ if unpairFlushPending {
1913
+ Bridge.log("LIVE: disconnect deferred — waiting for unpair write")
1914
+ return
1915
+ }
1673
1916
  Bridge.log("LIVE: disconnect() -Disconnecting from Mentra Live glasses")
1674
1917
 
1675
1918
  // if rgbLedAuthorityClaimed {
@@ -1695,6 +1938,8 @@ class MentraLive: NSObject, SGCManager {
1695
1938
 
1696
1939
  /// Start the micbeat mechanism to keep LC3 audio streaming active
1697
1940
  private func startMicBeat() {
1941
+ // A resumed custom-audio stream starts a new sequence domain.
1942
+ DeviceManager.shared.resetMicSequenceBaseline()
1698
1943
  Bridge.log("LIVE: 🎤 Starting micbeat mechanism")
1699
1944
  micBeatCount = 0
1700
1945
 
@@ -1941,6 +2186,18 @@ class MentraLive: NSObject, SGCManager {
1941
2186
  private var pending: PendingMessage?
1942
2187
  private var pendingMessageTimer: Timer?
1943
2188
 
2189
+ /// ACK state belongs to the BLE session that sent the message. If the link drops while an ACK
2190
+ /// is outstanding, keeping `pending` set permanently blocks the command queue because the
2191
+ /// timeout timer is stopped during disconnect. Release both together before reconnecting.
2192
+ private func resetPendingAckState(reason: String) {
2193
+ pendingMessageTimer?.invalidate()
2194
+ pendingMessageTimer = nil
2195
+
2196
+ guard let pendingMessage = pending else { return }
2197
+ pending = nil
2198
+ Bridge.log("LIVE: Cleared pending ACK mId \(pendingMessage.id) because \(reason)")
2199
+ }
2200
+
1944
2201
  actor CommandQueue {
1945
2202
  private var commands: [PendingMessage] = []
1946
2203
 
@@ -2079,7 +2336,10 @@ class MentraLive: NSObject, SGCManager {
2079
2336
  // MARK: - BLE Scanning
2080
2337
 
2081
2338
  private func startScan() {
2082
- // guard !isScanning else { return }
2339
+ if pairingYieldActive {
2340
+ Bridge.log("LIVE: startScan blocked — pairing yield active")
2341
+ return
2342
+ }
2083
2343
 
2084
2344
  guard centralManager!.state == .poweredOn else {
2085
2345
  Bridge.log("Attempting to scan but bluetooth is not powered on.")
@@ -2087,6 +2347,7 @@ class MentraLive: NSObject, SGCManager {
2087
2347
  }
2088
2348
 
2089
2349
  Bridge.log("Starting BLE scan for Mentra Live glasses")
2350
+ discoveredAdvPairing.removeAll()
2090
2351
  isScanning = true
2091
2352
 
2092
2353
  startReadinessCheckLoop()
@@ -2103,11 +2364,9 @@ class MentraLive: NSObject, SGCManager {
2103
2364
 
2104
2365
  centralManager?.scanForPeripherals(withServices: nil, options: scanOptions)
2105
2366
 
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
- }
2367
+ // Fresh advertisements refill pairing metadata. Re-emitting the last scan's
2368
+ // cache would keep a unit pairable after it left pairing mode.
2369
+ emitConnectedDeviceForPairingScan()
2111
2370
 
2112
2371
  // var dName = DeviceManager.shared.deviceName
2113
2372
  // if dName.isEmpty {
@@ -2137,10 +2396,16 @@ class MentraLive: NSObject, SGCManager {
2137
2396
  // MARK: - Connection Management
2138
2397
 
2139
2398
  private func connectToDevice(_ peripheral: CBPeripheral) {
2399
+ if pairingYieldActive {
2400
+ Bridge.log("LIVE: connectToDevice blocked — pairing yield active")
2401
+ isConnecting = false
2402
+ return
2403
+ }
2140
2404
  Bridge.log("LIVE: Connecting to device: \(peripheral.identifier.uuidString)")
2141
2405
 
2142
2406
  isConnecting = true
2143
2407
  updateConnectionState(ConnTypes.CONNECTING)
2408
+ connectingPeripheral = peripheral
2144
2409
  connectedPeripheral = peripheral
2145
2410
  peripheral.delegate = self
2146
2411
 
@@ -2185,6 +2450,11 @@ class MentraLive: NSObject, SGCManager {
2185
2450
  return
2186
2451
  }
2187
2452
 
2453
+ if pairingYieldActive {
2454
+ Bridge.log("LIVE: Reconnection aborted - pairing yield active")
2455
+ return
2456
+ }
2457
+
2188
2458
  // Check if we've exceeded max attempts
2189
2459
  if reconnectAttempts >= MAX_RECONNECT_ATTEMPTS {
2190
2460
  Bridge.log("LIVE: Maximum reconnection attempts reached (\(MAX_RECONNECT_ATTEMPTS))")
@@ -2217,6 +2487,10 @@ class MentraLive: NSObject, SGCManager {
2217
2487
  guard let self = self else { return }
2218
2488
 
2219
2489
  // Use peripheral presence, not connectionState: we stay CONNECTING during backoff until scan/connect.
2490
+ if self.pairingYieldActive {
2491
+ Bridge.log("LIVE: Reconnection aborted - pairing yield active")
2492
+ return
2493
+ }
2220
2494
  if self.connectedPeripheral == nil, !self.isKilled {
2221
2495
  // Check for last known device name to start scan
2222
2496
  if let lastDeviceName = UserDefaults.standard.string(
@@ -2446,11 +2720,21 @@ class MentraLive: NSObject, SGCManager {
2446
2720
 
2447
2721
  switch type {
2448
2722
  case "glasses_ready":
2723
+ // A queued glasses_ready from the connection we just cancelled for yield
2724
+ // must not close the pairing window. Only accept readiness on a live GATT.
2725
+ if pairingYieldActive && connectedPeripheral == nil {
2726
+ Bridge.log("LIVE: Ignoring stale glasses_ready during pairing yield")
2727
+ return
2728
+ }
2449
2729
  // glasses_ready is a REMOTE wire-session reset: the glasses ran
2450
2730
  // onTransportReset() before sending it, so their side is back on legacy
2451
2731
  // framing regardless of what this side negotiated earlier. Start a fresh
2452
2732
  // wire epoch (clears v2-active state that would otherwise gate the
2453
2733
  // handshake off), then negotiate from the caps this message advertises.
2734
+ pairingYieldAwaitingReclaim = false
2735
+ pairingYieldActive = false
2736
+ pairingYieldEndWorkItem?.cancel()
2737
+ pairingYieldEndWorkItem = nil
2454
2738
  resetWireNegotiationState()
2455
2739
  parsePeerWireCaps(json)
2456
2740
  maybeSendWireHandshake()
@@ -2602,6 +2886,26 @@ class MentraLive: NSObject, SGCManager {
2602
2886
  case "pong":
2603
2887
  Bridge.log("LIVE: Received pong response - connection healthy")
2604
2888
 
2889
+ case "entering_pairing_mode":
2890
+ let windowMs = max(5_000, min(180_000, json["window_ms"] as? Int ?? 120_000))
2891
+ Bridge.log("LIVE: Glasses entering pairing mode — yield \(windowMs)ms (no forget)")
2892
+ enterPairingYield(windowMs: windowMs)
2893
+ let body: [String: Any] = [
2894
+ "window_ms": windowMs,
2895
+ "reason": json["reason"] as? String ?? "user_gesture",
2896
+ ]
2897
+ Bridge.sendTypedMessage("entering_pairing_mode", body: body)
2898
+
2899
+ case "pairing_info":
2900
+ Bridge.sendPairingInfo(
2901
+ hadPreviousBond: json["had_previous_bond"] as? Bool ?? false,
2902
+ pairingCode: json["pairing_code"] as? String,
2903
+ classicBondReady: json["classic_bond_ready"] as? Bool ?? false,
2904
+ // Legacy firmware that omits this field is not secure-capable.
2905
+ securePairingCapable: json["secure_pairing_capable"] as? Bool ?? false,
2906
+ protocolVersion: json["protocol_version"] as? Int ?? 1
2907
+ )
2908
+
2605
2909
  case "imu_response", "imu_stream_response", "imu_gesture_response",
2606
2910
  "imu_gesture_subscribed", "imu_ack", "imu_error":
2607
2911
  // Handle IMU-related responses
@@ -2669,6 +2973,7 @@ class MentraLive: NSObject, SGCManager {
2669
2973
  if let seq = json["sq"] as? [String] ?? json["step_sequence"] as? [String], !seq.isEmpty {
2670
2974
  cachedOtaStepSequence = seq
2671
2975
  }
2976
+ updateBesOtaHeartbeatGuard(stepType: osStepType, phase: osPhase, status: osStatus)
2672
2977
 
2673
2978
  Bridge.log("LIVE: 📱 OTA status - step \(osCurrentStep)/\(osTotalSteps) \(osPhase) \(osStatus) \(osOverallPercent)%")
2674
2979
 
@@ -2702,6 +3007,7 @@ class MentraLive: NSObject, SGCManager {
2702
3007
  } else {
2703
3008
  unified = "in_progress"
2704
3009
  }
3010
+ updateBesOtaHeartbeatGuard(stepType: currentUpdate, phase: legacyPhase, status: unified)
2705
3011
  Bridge.log(
2706
3012
  "LIVE: 📱 Legacy ota_progress → ota_status: \(legacyStage) \(legacyStatus) \(legacyProgress)%"
2707
3013
  )
@@ -2761,9 +3067,19 @@ class MentraLive: NSObject, SGCManager {
2761
3067
  if let systemTimeMs = fields["system_time_ms"] as? NSNumber {
2762
3068
  DeviceStore.shared.apply("glasses", "systemTimeMs", systemTimeMs.int64Value)
2763
3069
  }
3070
+ if let hotspotOtaVersion = fields["hotspot_ota_version"] as? NSNumber {
3071
+ DeviceStore.shared.apply(
3072
+ "glasses",
3073
+ "hotspotOtaVersion",
3074
+ hotspotOtaVersion.intValue
3075
+ )
3076
+ }
2764
3077
  if let bluetoothMacAddress = nonEmptyStringValue(fields, "bt_mac_address") {
2765
3078
  DeviceStore.shared.apply("glasses", "bluetoothMacAddress", bluetoothMacAddress)
2766
3079
  }
3080
+ if let wifiMacAddress = nonEmptyStringValue(fields, "wifi_mac_address") {
3081
+ DeviceStore.shared.apply("glasses", "wifiMacAddress", wifiMacAddress)
3082
+ }
2767
3083
  if let serialNumber = nonEmptyStringValue(fields, "serial_number") {
2768
3084
  DeviceStore.shared.apply("glasses", "serialNumber", serialNumber)
2769
3085
  }
@@ -2829,6 +3145,19 @@ class MentraLive: NSObject, SGCManager {
2829
3145
  cachedOtaCurrentStep = 0
2830
3146
  cachedOtaStepSequence = nil
2831
3147
  lastBesOtaProgress = -1
3148
+ // The bounded UART-ownership lease is transport state, not session-cache state. Keep it
3149
+ // through a BLE reconnect so heartbeats cannot resume while BES still owns the UART.
3150
+ }
3151
+
3152
+ private func updateBesOtaHeartbeatGuard(stepType: String, phase: String, status: String) {
3153
+ guard stepType.lowercased() == "bes", phase.lowercased() == "install" else { return }
3154
+ switch status.lowercased() {
3155
+ case "failed", "complete", "step_complete", "finished", "success":
3156
+ besOtaHeartbeatSuppressedUntil = 0
3157
+ default:
3158
+ besOtaHeartbeatSuppressedUntil =
3159
+ ProcessInfo.processInfo.systemUptime + BES_OTA_HEARTBEAT_LEASE_SEC
3160
+ }
2832
3161
  }
2833
3162
 
2834
3163
  /// Falls back to raw besProgress when step sequence is unavailable.
@@ -3000,62 +3329,41 @@ class MentraLive: NSObject, SGCManager {
3000
3329
  var progress = ((rawProgress + 2) / 5) * 5
3001
3330
  if progress > 100 { progress = 100 }
3002
3331
 
3003
- // Only send if progress changed to a new 5% increment
3004
3332
  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
3333
  var besOtaProgressVal: Int
3017
3334
  var besOtaErrorMessage: String? = nil
3018
3335
 
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"
3336
+ let failed = type == "error" || type == "fail"
3337
+ let succeeded = type == "success"
3338
+ if isTerminalStatus {
3339
+ besOtaHeartbeatSuppressedUntil = 0
3340
+ } else {
3341
+ // Refresh before progress de-duplication: raw progress proves the BES
3342
+ // transfer is active even when the rounded UI value is unchanged.
3343
+ besOtaHeartbeatSuppressedUntil =
3344
+ ProcessInfo.processInfo.systemUptime + BES_OTA_HEARTBEAT_LEASE_SEC
3345
+ }
3346
+ if succeeded {
3025
3347
  besOtaProgressVal = 100
3026
- lastBesOtaProgress = -1 // Reset for next OTA
3027
- } else if type == "error" || type == "fail" {
3028
- besOtaStatus = "FAILED"
3348
+ } else if failed {
3029
3349
  besOtaProgressVal = progress
3030
3350
  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
3351
  } else {
3036
- // Unknown type, treat as progress
3037
- besOtaStatus = "PROGRESS"
3038
- besOtaProgressVal = progress
3352
+ // A raw update:100 precedes whole-image CRC/apply and is not terminal.
3353
+ besOtaProgressVal = min(progress, 95)
3039
3354
  }
3040
3355
 
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"
3356
+ // Only send if nonterminal display progress changed to a new 5% increment.
3357
+ if besOtaProgressVal == lastBesOtaProgress && !isTerminalStatus {
3358
+ break
3058
3359
  }
3360
+ lastBesOtaProgress = isTerminalStatus ? -1 : besOtaProgressVal
3361
+
3362
+ Bridge.log(
3363
+ "LIVE: 📱 BES OTA progress via sr_adota - type: \(type), raw: \(rawProgress)%, rounded: \(progress)%, display: \(besOtaProgressVal)%"
3364
+ )
3365
+
3366
+ let syntheticStatus = succeeded ? "step_complete" : (failed ? "failed" : "in_progress")
3059
3367
  let sid = cachedOtaSessionId ?? ""
3060
3368
  let totalSteps = cachedOtaTotalSteps > 0 ? cachedOtaTotalSteps : 1
3061
3369
  let currentStep = cachedOtaCurrentStep > 0 ? cachedOtaCurrentStep : 1
@@ -3210,6 +3518,17 @@ class MentraLive: NSObject, SGCManager {
3210
3518
  sendJson(json, wakeUp: true)
3211
3519
  }
3212
3520
 
3521
+ func sendWifiAdbState(_ enabled: Bool) {
3522
+ Bridge.log("LIVE: 🔧 Sending Wi-Fi ADB state: \(enabled)")
3523
+
3524
+ let json: [String: Any] = [
3525
+ "type": "set_wifi_adb_state",
3526
+ "enabled": enabled,
3527
+ ]
3528
+
3529
+ sendJson(json, wakeUp: true)
3530
+ }
3531
+
3213
3532
  func sendSetSystemTime(_ timestampMs: Int64) {
3214
3533
  Bridge.log("LIVE: ⏰ Sending set_system_time: \(timestampMs)")
3215
3534
 
@@ -3329,7 +3648,6 @@ class MentraLive: NSObject, SGCManager {
3329
3648
  {
3330
3649
  json["ota_version_url"] = otaVersionUrl
3331
3650
  }
3332
-
3333
3651
  sendJson(json, wakeUp: true)
3334
3652
  }
3335
3653
 
@@ -3500,17 +3818,9 @@ class MentraLive: NSObject, SGCManager {
3500
3818
  return
3501
3819
  }
3502
3820
 
3503
- let sequenceNumber = Int8(bitPattern: data[1])
3821
+ let sequenceNumber = Int(data[1])
3504
3822
  let lc3Data = data.subdata(in: 2 ..< data.count)
3505
3823
 
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
3824
  // // Decode LC3 to PCM using existing PcmConverter
3515
3825
  // let pcmConverter = PcmConverter()
3516
3826
  // guard let pcmData = pcmConverter.decode(lc3Data) as? Data, pcmData.count > 0 else {
@@ -3524,7 +3834,7 @@ class MentraLive: NSObject, SGCManager {
3524
3834
  // Bridge.log(
3525
3835
  // "LIVE: Processed LC3 audio seq=\(sequenceNumber), \(lc3Data.count) bytes"
3526
3836
  // )
3527
- DeviceManager.shared.handleGlassesMicData(lc3Data, 40)
3837
+ DeviceManager.shared.handleGlassesMicData(lc3Data, 40, sequenceNumber: sequenceNumber)
3528
3838
 
3529
3839
  // Bridge.log(
3530
3840
  // "LIVE: Processed LC3 audio seq=\(sequenceNumber), \(lc3Data.count)→\(pcmData.count) bytes"
@@ -5068,6 +5378,10 @@ class MentraLive: NSObject, SGCManager {
5068
5378
  Bridge.log("LIVE: Skipping heartbeat - glasses not fully booted or not connected")
5069
5379
  return
5070
5380
  }
5381
+ if ProcessInfo.processInfo.systemUptime < besOtaHeartbeatSuppressedUntil {
5382
+ Bridge.log("LIVE: Skipping heartbeat while BES OTA owns the UART")
5383
+ return
5384
+ }
5071
5385
 
5072
5386
  // Send ping message to glasses hardware (no ACK needed for heartbeats)
5073
5387
  let pingJson: [String: Any] = ["type": "ping"]
@@ -5181,16 +5495,78 @@ class MentraLive: NSObject, SGCManager {
5181
5495
  stopReadinessCheckLoop()
5182
5496
  stopConnectionTimeout()
5183
5497
  stopMicBeat() // Stop LC3 audio micbeat
5184
- pendingMessageTimer?.invalidate()
5185
- pendingMessageTimer = nil
5498
+ resetPendingAckState(reason: "transport timers stopped")
5186
5499
  reconnectionWorkItem?.cancel()
5187
5500
  reconnectionWorkItem = nil
5188
5501
  }
5189
5502
 
5190
5503
  // MARK: - Event Emission
5191
5504
 
5192
- private func emitDiscoveredDevice(_ name: String, identifier: String = "", rssi: Int? = nil) {
5193
- Bridge.sendDiscoveredDevice("Mentra Live", name, deviceAddress: identifier, rssi: rssi)
5505
+ /// Pairing scan listens for advertisements. A unit that is already GATT-connected
5506
+ /// has stopped ADV, so emit it as pairable or the scan list stays empty.
5507
+ private func emitConnectedDeviceForPairingScan() {
5508
+ guard connected, let peripheral = connectedPeripheral, let name = peripheral.name,
5509
+ name == "Xy_A" || name.hasPrefix("XyBLE_") || name.hasPrefix("MENTRA_LIVE_BLE")
5510
+ || name.hasPrefix("MENTRA_LIVE_BT") || name.lowercased().hasPrefix("mentra_live")
5511
+ else {
5512
+ return
5513
+ }
5514
+ Bridge.log("LIVE: Pairing scan: already GATT-connected to \(name) — emitting as pairable (ADV off while connected)")
5515
+ emitDiscoveredDevice(
5516
+ name,
5517
+ identifier: peripheral.identifier.uuidString,
5518
+ pairingMode: true,
5519
+ pairingCode: nil,
5520
+ securePairingCapable: discoveredAdvPairing[name]?.securePairingCapable ?? false
5521
+ )
5522
+ }
5523
+
5524
+ private func cacheAdvPairing(
5525
+ _ name: String,
5526
+ pairingMode: Bool,
5527
+ pairingCode: String?,
5528
+ securePairingCapable: Bool
5529
+ ) {
5530
+ discoveredAdvPairing[name] = CachedAdvPairing(
5531
+ pairingMode: pairingMode,
5532
+ pairingCode: pairingCode,
5533
+ securePairingCapable: securePairingCapable
5534
+ )
5535
+ }
5536
+
5537
+ private func emitDiscoveredDeviceFromCache(
5538
+ _ name: String,
5539
+ identifier: String = "",
5540
+ rssi: Int? = nil
5541
+ ) {
5542
+ let cached = discoveredAdvPairing[name]
5543
+ emitDiscoveredDevice(
5544
+ name,
5545
+ identifier: identifier,
5546
+ rssi: rssi,
5547
+ pairingMode: cached?.pairingMode,
5548
+ pairingCode: cached?.pairingCode,
5549
+ securePairingCapable: cached?.securePairingCapable
5550
+ )
5551
+ }
5552
+
5553
+ private func emitDiscoveredDevice(
5554
+ _ name: String,
5555
+ identifier: String = "",
5556
+ rssi: Int? = nil,
5557
+ pairingMode: Bool? = nil,
5558
+ pairingCode: String? = nil,
5559
+ securePairingCapable: Bool? = nil
5560
+ ) {
5561
+ Bridge.sendDiscoveredDevice(
5562
+ "Mentra Live",
5563
+ name,
5564
+ deviceAddress: identifier,
5565
+ rssi: rssi,
5566
+ pairingMode: pairingMode,
5567
+ pairingCode: pairingCode,
5568
+ securePairingCapable: securePairingCapable
5569
+ )
5194
5570
  }
5195
5571
 
5196
5572
  private func emitStopScanEvent() {
@@ -5279,6 +5655,9 @@ class MentraLive: NSObject, SGCManager {
5279
5655
  Bridge.log("Destroying MentraLiveManager")
5280
5656
 
5281
5657
  isKilled = true
5658
+ unpairFlushWorkItem?.cancel()
5659
+ unpairFlushWorkItem = nil
5660
+ unpairFlushPending = false
5282
5661
 
5283
5662
  // Stop scanning
5284
5663
  if isScanning {
@@ -5312,6 +5691,8 @@ class MentraLive: NSObject, SGCManager {
5312
5691
  DeviceStore.shared.apply("glasses", "hotspotGatewayIp", "")
5313
5692
 
5314
5693
  connectedPeripheral = nil
5694
+ discoveredPeripherals.removeAll()
5695
+ discoveredAdvPairing.removeAll()
5315
5696
  centralManager?.delegate = nil
5316
5697
  centralManager = nil
5317
5698
 
@@ -6521,6 +6902,17 @@ extension MentraLive {
6521
6902
  stopVideoRecording(requestId: requestId, webhookUrl: nil, authToken: nil)
6522
6903
  }
6523
6904
 
6905
+ func queryVideoRecordingStatus(requestId: String) {
6906
+ guard connectionState == ConnTypes.CONNECTED else {
6907
+ Bridge.log("Cannot query video recording status - not connected")
6908
+ return
6909
+ }
6910
+ sendJson([
6911
+ "type": "get_video_recording_status",
6912
+ "requestId": requestId,
6913
+ ])
6914
+ }
6915
+
6524
6916
  func stopVideoRecording(requestId: String, webhookUrl: String?, authToken: String?) {
6525
6917
  Bridge.log(
6526
6918
  "Stopping video recording on glasses: requestId=\(requestId), webhook=\((webhookUrl?.isEmpty ?? true) ? "none" : "set")"