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

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 (118) hide show
  1. package/README.md +38 -2
  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 +39 -3
  7. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +209 -21
  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 +1381 -284
  24. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +8 -0
  25. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +17 -0
  26. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +12 -0
  27. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +74 -79
  28. package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +3 -3
  29. package/android/src/test/java/com/mentra/bluetoothsdk/OtaStartResponsePolicyTest.kt +30 -0
  30. package/android/src/test/java/com/mentra/bluetoothsdk/ScopedNetworkReadinessTest.kt +39 -0
  31. package/android/src/test/java/com/mentra/bluetoothsdk/otaserver/LocalOtaServerTest.kt +82 -0
  32. package/android/src/test/java/com/mentra/bluetoothsdk/services/ForegroundServiceTypeTest.kt +69 -0
  33. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/BesOtaHeartbeatGuardTest.kt +73 -0
  34. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/BesOtaProgressMapperTest.kt +67 -0
  35. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCharacteristicsTest.kt +39 -0
  36. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +148 -7
  37. package/build/BluetoothSdk.types.d.ts +63 -2
  38. package/build/BluetoothSdk.types.d.ts.map +1 -1
  39. package/build/BluetoothSdk.types.js.map +1 -1
  40. package/build/_internal.d.ts +2 -0
  41. package/build/_internal.d.ts.map +1 -1
  42. package/build/_internal.js +2 -0
  43. package/build/_internal.js.map +1 -1
  44. package/build/_private/BluetoothSdkModule.d.ts +4 -1
  45. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  46. package/build/_private/BluetoothSdkModule.js +1 -1
  47. package/build/_private/BluetoothSdkModule.js.map +1 -1
  48. package/build/_private/MentraLocalNetworkModule.d.ts +1 -0
  49. package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -1
  50. package/build/_private/MentraLocalNetworkModule.js.map +1 -1
  51. package/build/_private/sdkOtaManifest.d.ts +3 -12
  52. package/build/_private/sdkOtaManifest.d.ts.map +1 -1
  53. package/build/_private/sdkOtaManifest.js +5 -17
  54. package/build/_private/sdkOtaManifest.js.map +1 -1
  55. package/build/generated/releaseMetadata.d.ts +12 -0
  56. package/build/generated/releaseMetadata.d.ts.map +1 -0
  57. package/build/generated/releaseMetadata.js +10 -0
  58. package/build/generated/releaseMetadata.js.map +1 -0
  59. package/build/index.d.ts +1 -1
  60. package/build/index.d.ts.map +1 -1
  61. package/build/index.js +10 -2
  62. package/build/index.js.map +1 -1
  63. package/build/ota-server/MentraOtaServer.types.d.ts +14 -0
  64. package/build/ota-server/MentraOtaServer.types.d.ts.map +1 -0
  65. package/build/ota-server/MentraOtaServer.types.js +2 -0
  66. package/build/ota-server/MentraOtaServer.types.js.map +1 -0
  67. package/build/ota-server/MentraOtaServerModule.d.ts +21 -0
  68. package/build/ota-server/MentraOtaServerModule.d.ts.map +1 -0
  69. package/build/ota-server/MentraOtaServerModule.js +3 -0
  70. package/build/ota-server/MentraOtaServerModule.js.map +1 -0
  71. package/build/ota-server/MentraOtaServerModule.web.d.ts +10 -0
  72. package/build/ota-server/MentraOtaServerModule.web.d.ts.map +1 -0
  73. package/build/ota-server/MentraOtaServerModule.web.js +12 -0
  74. package/build/ota-server/MentraOtaServerModule.web.js.map +1 -0
  75. package/build/ota-server/index.d.ts +3 -0
  76. package/build/ota-server/index.d.ts.map +1 -0
  77. package/build/ota-server/index.js +3 -0
  78. package/build/ota-server/index.js.map +1 -0
  79. package/expo-module.config.json +3 -2
  80. package/ios/BackgroundOtaArtifactDownloader.swift +125 -0
  81. package/ios/BluetoothSdkModule.swift +16 -2
  82. package/ios/LocalIPv4.swift +150 -0
  83. package/ios/LocalOtaServer.swift +309 -0
  84. package/ios/MentraBluetoothSDK.podspec +5 -1
  85. package/ios/MentraOtaServerModule.swift +147 -0
  86. package/ios/MentraPhotoReceiverModule.swift +1 -81
  87. package/ios/Source/BluetoothSdkDefaults.swift +4 -1
  88. package/ios/Source/Bridge.swift +47 -3
  89. package/ios/Source/DeviceManager.swift +146 -16
  90. package/ios/Source/DeviceStore.swift +2 -0
  91. package/ios/Source/GeneratedReleaseMetadata.swift +11 -0
  92. package/ios/Source/MentraBluetoothSDK.swift +74 -6
  93. package/ios/Source/OtaManifest.swift +5 -7
  94. package/ios/Source/audio/AudioModels.swift +17 -0
  95. package/ios/Source/camera/CameraModels.swift +1 -0
  96. package/ios/Source/events/BluetoothEvents.swift +27 -0
  97. package/ios/Source/sgcs/MentraLive.swift +498 -77
  98. package/ios/Source/sgcs/SGCManager.swift +11 -0
  99. package/ios/Source/status/DeviceStatus.swift +23 -0
  100. package/ios/Source/types/DeviceModels.swift +23 -2
  101. package/ios/Source/utils/BleJsonCompact.swift +73 -71
  102. package/package.json +5 -1
  103. package/plugin/build/withIos.d.ts +3 -1
  104. package/plugin/build/withIos.js +22 -10
  105. package/scripts/verify-release-package.mjs +34 -0
  106. package/scripts/write-release-metadata.mjs +154 -0
  107. package/scripts/write-release-metadata.test.mjs +29 -0
  108. package/src/BluetoothSdk.types.ts +74 -3
  109. package/src/_internal.ts +2 -0
  110. package/src/_private/BluetoothSdkModule.ts +5 -5
  111. package/src/_private/MentraLocalNetworkModule.ts +1 -1
  112. package/src/_private/sdkOtaManifest.ts +5 -17
  113. package/src/generated/releaseMetadata.ts +20 -0
  114. package/src/index.ts +19 -2
  115. package/src/ota-server/MentraOtaServer.types.ts +10 -0
  116. package/src/ota-server/MentraOtaServerModule.ts +23 -0
  117. package/src/ota-server/MentraOtaServerModule.web.ts +17 -0
  118. 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,10 +932,10 @@ 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
 
@@ -939,10 +984,17 @@ extension MentraLive: CBCentralManagerDelegate {
939
984
  }
940
985
 
941
986
  nonisolated func centralManager(
942
- _: CBCentralManager, didDisconnectPeripheral _: CBPeripheral, error _: Error?
987
+ _: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?
943
988
  ) {
944
989
  DispatchQueue.main.async { [weak self] in
945
990
  guard let self else { return }
991
+ let isCurrent =
992
+ self.connectedPeripheral === peripheral || self.connectingPeripheral === peripheral
993
+ if !isCurrent {
994
+ Bridge.log("LIVE: Ignoring stale disconnect for \(peripheral.identifier)")
995
+ return
996
+ }
997
+ self.connectingPeripheral = nil
946
998
  Bridge.log("LIVE: Disconnected from GATT server")
947
999
 
948
1000
  self.isConnecting = false
@@ -962,6 +1014,11 @@ extension MentraLive: CBCentralManagerDelegate {
962
1014
  self.txCharacteristic = nil
963
1015
  self.rxCharacteristic = nil
964
1016
 
1017
+ if self.pairingYieldAwaitingReclaim, Self.isPairingAuthFailure(error) {
1018
+ self.clearSavedGlassesAfterOwnerLoss(reason: "ios_auth_fail")
1019
+ return
1020
+ }
1021
+
965
1022
  // Attempt reconnection if not killed
966
1023
  if !self.isKilled {
967
1024
  self.handleReconnection()
@@ -969,10 +1026,17 @@ extension MentraLive: CBCentralManagerDelegate {
969
1026
  }
970
1027
  }
971
1028
 
972
- nonisolated func centralManager(_: CBCentralManager, didFailToConnect _: CBPeripheral, error: Error?) {
1029
+ nonisolated func centralManager(_: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
973
1030
  let errorDescription = error?.localizedDescription ?? "Unknown error"
974
1031
  DispatchQueue.main.async { [weak self] in
975
1032
  guard let self else { return }
1033
+ let isCurrent =
1034
+ self.connectedPeripheral === peripheral || self.connectingPeripheral === peripheral
1035
+ if !isCurrent {
1036
+ Bridge.log("LIVE: Ignoring stale connect failure for \(peripheral.identifier)")
1037
+ return
1038
+ }
1039
+ self.connectingPeripheral = nil
976
1040
  Bridge.log("LIVE: Failed to connect to peripheral: \(errorDescription)")
977
1041
 
978
1042
  self.stopConnectionTimeout()
@@ -981,6 +1045,11 @@ extension MentraLive: CBCentralManagerDelegate {
981
1045
  self.connectedPeripheral = nil
982
1046
  self.updateConnectionState(ConnTypes.DISCONNECTED)
983
1047
 
1048
+ if self.pairingYieldAwaitingReclaim, Self.isPairingAuthFailure(error) {
1049
+ self.clearSavedGlassesAfterOwnerLoss(reason: "ios_auth_fail")
1050
+ return
1051
+ }
1052
+
984
1053
  if !self.isKilled {
985
1054
  self.handleReconnection()
986
1055
  }
@@ -1276,6 +1345,83 @@ class MentraLive: NSObject, SGCManager {
1276
1345
  private let BLOCK_AUDIO_DUPLEX = false
1277
1346
  private static let voiceActivityDetectionSwitchType = 8
1278
1347
  private static let loudnessGateSwitchType = 10
1348
+ private let mentraManufacturerId: UInt16 = 0xB822
1349
+ // Payload-relative offset of the pairing flag, matching Android's index into the
1350
+ // company-id-stripped manufacturer data from getManufacturerSpecificData().
1351
+ private let advManufPairingFlagOffset = 5
1352
+ // CoreBluetooth returns manufacturer data with the 2-byte company id prefix still attached,
1353
+ // whereas Android strips it. Skip the prefix so both platforms read the same payload byte.
1354
+ private let advManufCompanyIdLength = 2
1355
+ private let advPairingDiscoverable: UInt8 = 0x01
1356
+
1357
+ // CoreBluetooth returns manufacturer data prefixed with the 2-byte company id
1358
+ // (little-endian) and does NOT filter by company id itself (unlike Android's
1359
+ // getManufacturerSpecificData(companyId)), so every reader of this data must
1360
+ // verify the company id before trusting any flag/trailer byte.
1361
+ private func mentraManufacturerData(_ advertisementData: [String: Any]) -> Data? {
1362
+ guard let manufData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data,
1363
+ manufData.count >= advManufCompanyIdLength
1364
+ else {
1365
+ return nil
1366
+ }
1367
+ let companyId = UInt16(manufData[0]) | (UInt16(manufData[1]) << 8)
1368
+ guard companyId == mentraManufacturerId else {
1369
+ return nil
1370
+ }
1371
+ return manufData
1372
+ }
1373
+
1374
+ /// OS-1615 ads append `flag | version | capability | code_lo | code_hi` after the
1375
+ /// connected byte. Field firmware uses the same 0xB822 company id but then writes
1376
+ /// the XOR'd Classic MAC at those offsets. Length alone is not a pairing flag.
1377
+ private func hasSecurePairingTrailer(_ advertisementData: [String: Any]) -> Bool {
1378
+ let flagIndex = advManufCompanyIdLength + advManufPairingFlagOffset
1379
+ let trailerBase = flagIndex + 1
1380
+ guard let manufData = mentraManufacturerData(advertisementData),
1381
+ manufData.count >= trailerBase + 4
1382
+ else {
1383
+ return false
1384
+ }
1385
+ let version = Int(manufData[trailerBase])
1386
+ let capability = Int(manufData[trailerBase + 1])
1387
+ return (1...15).contains(version) && (capability & 0x01) != 0
1388
+ }
1389
+
1390
+ private func isPairingDiscoverable(_ advertisementData: [String: Any]) -> Bool {
1391
+ guard hasSecurePairingTrailer(advertisementData),
1392
+ let manufData = mentraManufacturerData(advertisementData)
1393
+ else {
1394
+ return false
1395
+ }
1396
+ let flagIndex = advManufCompanyIdLength + advManufPairingFlagOffset
1397
+ return manufData[flagIndex] == advPairingDiscoverable
1398
+ }
1399
+
1400
+ private struct SecurePairingTrailer {
1401
+ let pairingMode: Bool
1402
+ let pairingCode: String?
1403
+ let secureCapable: Bool
1404
+ }
1405
+
1406
+ /// Trailer immediately after pairing flag: version | capability | code_lo | code_hi
1407
+ private func parseSecurePairingTrailer(_ advertisementData: [String: Any]) -> SecurePairingTrailer {
1408
+ guard hasSecurePairingTrailer(advertisementData),
1409
+ let manufData = mentraManufacturerData(advertisementData)
1410
+ else {
1411
+ return SecurePairingTrailer(pairingMode: false, pairingCode: nil, secureCapable: false)
1412
+ }
1413
+ let flagIndex = advManufCompanyIdLength + advManufPairingFlagOffset
1414
+ let trailerBase = flagIndex + 1
1415
+ let pairingMode = manufData[flagIndex] == advPairingDiscoverable
1416
+ let codeLo = Int(manufData[trailerBase + 2])
1417
+ let codeHi = Int(manufData[trailerBase + 3])
1418
+ let code = String(format: "%02X%02X", codeHi, codeLo)
1419
+ return SecurePairingTrailer(pairingMode: pairingMode, pairingCode: code, secureCapable: true)
1420
+ }
1421
+
1422
+ private func advertisesPairingFlag(_ advertisementData: [String: Any]) -> Bool {
1423
+ hasSecurePairingTrailer(advertisementData)
1424
+ }
1279
1425
 
1280
1426
  var connectionState: String = ConnTypes.DISCONNECTED
1281
1427
 
@@ -1290,6 +1436,8 @@ class MentraLive: NSObject, SGCManager {
1290
1436
  // return, unlike Android).
1291
1437
  DeviceStore.shared.apply("glasses", "serialNumber", "")
1292
1438
  DeviceStore.shared.apply("glasses", "bluetoothMacAddress", "")
1439
+ DeviceStore.shared.apply("glasses", "wifiMacAddress", "")
1440
+ DeviceStore.shared.apply("glasses", "hotspotOtaVersion", 0)
1293
1441
  }
1294
1442
  connectionState = state
1295
1443
  DeviceStore.shared.apply("glasses", "connectionState", state)
@@ -1297,6 +1445,7 @@ class MentraLive: NSObject, SGCManager {
1297
1445
  // a previous pairing into the next one (would otherwise surface as wrong overall_percent
1298
1446
  // or stale lastBesOtaProgress on the next OTA).
1299
1447
  if state == ConnTypes.DISCONNECTED {
1448
+ resetPendingAckState(reason: "BLE session ended")
1300
1449
  resetWireNegotiationState()
1301
1450
  resetAncsRelayState()
1302
1451
  // Queued writes are session-bound: transmitting them into the NEXT session
@@ -1342,14 +1491,26 @@ class MentraLive: NSObject, SGCManager {
1342
1491
  func forget() {
1343
1492
  Bridge.log("LIVE: Forgetting Mentra Live glasses")
1344
1493
 
1494
+ // Clear saved device name so the pairing filter is not bypassed on rescan
1495
+ UserDefaults.standard.removeObject(forKey: PREFS_DEVICE_NAME)
1496
+
1345
1497
  // Stop scanning first
1346
1498
  if isScanning {
1347
1499
  stopScan()
1348
1500
  emitStopScanEvent()
1349
1501
  }
1350
1502
 
1351
- // Then do full cleanup (disconnect + clear all references)
1352
- destroy()
1503
+ // Then do full cleanup after the unpair write can leave GATT.
1504
+ sendJson(["type": "unpair"], wakeUp: true, requireAck: false)
1505
+ unpairFlushWorkItem?.cancel()
1506
+ unpairFlushPending = true
1507
+ let work = DispatchWorkItem { [self] in
1508
+ self.unpairFlushPending = false
1509
+ self.unpairFlushWorkItem = nil
1510
+ self.destroy()
1511
+ }
1512
+ unpairFlushWorkItem = work
1513
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.4, execute: work)
1353
1514
  }
1354
1515
 
1355
1516
  var type = "Mentra Live"
@@ -1427,7 +1588,6 @@ class MentraLive: NSObject, SGCManager {
1427
1588
  private var lc3ReadCharacteristic: CBCharacteristic?
1428
1589
  private var lc3WriteCharacteristic: CBCharacteristic?
1429
1590
  private var supportsLC3Audio = true
1430
- private var lastReceivedLc3Sequence: Int8 = -1
1431
1591
  private let LC3_FRAME_SIZE = 40 // bytes per LC3 frame
1432
1592
  private let MICBEAT_INTERVAL_MS: TimeInterval = 30 * 60 // 30 minutes in seconds
1433
1593
  private var micBeatTimer: Timer?
@@ -1452,6 +1612,7 @@ class MentraLive: NSObject, SGCManager {
1452
1612
  private let KEEP_ALIVE_INTERVAL_MS: UInt64 = 5_000_000_000 // 5 seconds
1453
1613
  private let CONNECTION_TIMEOUT_MS: UInt64 = 100_000_000_000 // 100 seconds
1454
1614
  private let HEARTBEAT_INTERVAL_MS: TimeInterval = 30.0 // 30 seconds
1615
+ private let BES_OTA_HEARTBEAT_LEASE_SEC: TimeInterval = 120.0
1455
1616
  private let BATTERY_REQUEST_EVERY_N_HEARTBEATS = 10
1456
1617
  private let SIGNAL_STRENGTH_READ_INTERVAL_MS: TimeInterval = 10.0
1457
1618
  private let MIN_SEND_DELAY_MS: UInt64 = 160_000_000 // 160ms in nanoseconds
@@ -1472,6 +1633,8 @@ class MentraLive: NSObject, SGCManager {
1472
1633
  private var centralManager: CBCentralManager?
1473
1634
 
1474
1635
  private var connectedPeripheral: CBPeripheral?
1636
+ /// Peripheral for the in-flight connect; used to ignore stale disconnect/fail callbacks.
1637
+ private var connectingPeripheral: CBPeripheral?
1475
1638
  private var txCharacteristic: CBCharacteristic?
1476
1639
  private var rxCharacteristic: CBCharacteristic?
1477
1640
  private let bes2700MtuLimit = 509
@@ -1481,6 +1644,13 @@ class MentraLive: NSObject, SGCManager {
1481
1644
  private var isScanning = false
1482
1645
  private var isConnecting = false
1483
1646
  private var isKilled = false
1647
+ /// Glasses opened pairing window — stand down without forgetting identity/bonds.
1648
+ private var pairingYieldActive = false
1649
+ private var pairingYieldAwaitingReclaim = false
1650
+ private var pairingYieldEndWorkItem: DispatchWorkItem?
1651
+ /// Hold GATT teardown until the unpair write can leave the phone.
1652
+ private var unpairFlushPending = false
1653
+ private var unpairFlushWorkItem: DispatchWorkItem?
1484
1654
  private var reconnectAttempts = 0
1485
1655
  private var isNewVersion = false
1486
1656
  private var peerWireProtocolVersion = 0
@@ -1546,6 +1716,7 @@ class MentraLive: NSObject, SGCManager {
1546
1716
 
1547
1717
  /// BES OTA progress tracking - only send to UI on 5% increments
1548
1718
  private var lastBesOtaProgress = -1
1719
+ private var besOtaHeartbeatSuppressedUntil: TimeInterval = 0
1549
1720
 
1550
1721
  // Cached OTA session context from last ota_status — used to fill in session fields for sr_adota
1551
1722
  private var cachedOtaSessionId: String?
@@ -1598,6 +1769,16 @@ class MentraLive: NSObject, SGCManager {
1598
1769
  // MARK: - React Native Interface
1599
1770
 
1600
1771
  private var discoveredPeripherals = [String: CBPeripheral]() // name -> peripheral
1772
+ /// Last advertisement pairing trailer for a discovered name. Re-emits (already-
1773
+ /// connected / scan replay) must not drop these flags: omitting them makes a
1774
+ /// secure idle unit look like pairable legacy firmware.
1775
+ private var discoveredAdvPairing = [String: CachedAdvPairing]()
1776
+
1777
+ private struct CachedAdvPairing {
1778
+ let pairingMode: Bool
1779
+ let pairingCode: String?
1780
+ let securePairingCapable: Bool
1781
+ }
1601
1782
 
1602
1783
  func findCompatibleDevices() {
1603
1784
  Bridge.log("Finding compatible Mentra Live glasses")
@@ -1619,7 +1800,89 @@ class MentraLive: NSObject, SGCManager {
1619
1800
  }
1620
1801
  }
1621
1802
 
1803
+ func isPairingYieldActive() -> Bool {
1804
+ pairingYieldActive
1805
+ }
1806
+
1807
+ private func enterPairingYield(windowMs: Int) {
1808
+ pairingYieldActive = true
1809
+ pairingYieldAwaitingReclaim = false
1810
+ pairingYieldEndWorkItem?.cancel()
1811
+ if isScanning {
1812
+ stopScan()
1813
+ }
1814
+ // Publish disconnect immediately for Phone A UI. didDisconnectPeripheral may lag
1815
+ // or race with cancelPeripheralConnection; do not wait on it for stand-down.
1816
+ isConnecting = false
1817
+ connected = false
1818
+ fullyBooted = false
1819
+ glassesSessionId = nil
1820
+ readinessCompletedThisBleSession = false
1821
+ rgbLedAuthorityClaimed = false
1822
+ stopAllTimers()
1823
+ closeL2capFileChannel()
1824
+ txCharacteristic = nil
1825
+ rxCharacteristic = nil
1826
+ updateConnectionState(ConnTypes.DISCONNECTED)
1827
+ if let peripheral = connectedPeripheral {
1828
+ centralManager?.cancelPeripheralConnection(peripheral)
1829
+ }
1830
+ connectedPeripheral = nil
1831
+ connectingPeripheral = nil
1832
+ // Stand down for the whole window. Probing GATT during yield races the new
1833
+ // phone for the single BLE slot and is exactly what entering_pairing_mode forbids.
1834
+
1835
+ let work = DispatchWorkItem { [weak self] in
1836
+ guard let self else { return }
1837
+ Bridge.log("LIVE: Pairing yield ended — resume reconnect if still owned")
1838
+ self.pairingYieldActive = false
1839
+ self.pairingYieldAwaitingReclaim = true
1840
+ self.pairingYieldEndWorkItem = nil
1841
+ if !self.isKilled, self.connectedPeripheral == nil {
1842
+ self.handleReconnection()
1843
+ }
1844
+ }
1845
+ pairingYieldEndWorkItem = work
1846
+ DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(windowMs), execute: work)
1847
+ }
1848
+
1849
+ /// Pairing/auth failures after yield — CoreBluetooth codes, not localized strings.
1850
+ /// Connection timeouts are not owner-loss.
1851
+ private static func isPairingAuthFailure(_ error: Error?) -> Bool {
1852
+ guard let error = error as NSError? else { return false }
1853
+ if error.domain == CBATTErrorDomain {
1854
+ return error.code == CBATTError.insufficientAuthentication.rawValue
1855
+ || error.code == CBATTError.insufficientEncryption.rawValue
1856
+ }
1857
+ if error.domain == CBErrorDomain {
1858
+ return error.code == CBError.peerRemovedPairingInformation.rawValue
1859
+ }
1860
+ return false
1861
+ }
1862
+
1863
+ private func clearSavedGlassesAfterOwnerLoss(reason: String) {
1864
+ Bridge.log("LIVE: Owner loss (\(reason)) — clearing saved glasses")
1865
+ pairingYieldAwaitingReclaim = false
1866
+ pairingYieldActive = false
1867
+ pairingYieldEndWorkItem?.cancel()
1868
+ pairingYieldEndWorkItem = nil
1869
+ forget()
1870
+ Task { @MainActor in
1871
+ DeviceStore.shared.apply("bluetooth", "default_wearable", "")
1872
+ DeviceStore.shared.apply("bluetooth", "device_name", "")
1873
+ DeviceStore.shared.apply("bluetooth", "device_address", "")
1874
+ }
1875
+ Bridge.saveSetting("default_wearable", "")
1876
+ Bridge.saveSetting("device_name", "")
1877
+ Bridge.saveSetting("device_address", "")
1878
+ Bridge.sendTypedMessage("owner_replaced", body: ["reason": reason])
1879
+ }
1880
+
1622
1881
  func connectById(_ deviceName: String) {
1882
+ if pairingYieldActive {
1883
+ Bridge.log("LIVE: connectById blocked — pairing yield active")
1884
+ return
1885
+ }
1623
1886
  Bridge.log("connectById: \(deviceName)")
1624
1887
  // Save the device name for future reconnection
1625
1888
  UserDefaults.standard.set(deviceName, forKey: PREFS_DEVICE_NAME)
@@ -1644,7 +1907,7 @@ class MentraLive: NSObject, SGCManager {
1644
1907
  {
1645
1908
  Bridge.log("Found already-connected peripheral: \(name)")
1646
1909
  discoveredPeripherals[name] = peripheral
1647
- emitDiscoveredDevice(name)
1910
+ emitDiscoveredDeviceFromCache(name)
1648
1911
 
1649
1912
  // Check if this is the device we want
1650
1913
  if let savedDeviceName = UserDefaults.standard.string(
@@ -1670,6 +1933,10 @@ class MentraLive: NSObject, SGCManager {
1670
1933
  }
1671
1934
 
1672
1935
  @objc func disconnect() {
1936
+ if unpairFlushPending {
1937
+ Bridge.log("LIVE: disconnect deferred — waiting for unpair write")
1938
+ return
1939
+ }
1673
1940
  Bridge.log("LIVE: disconnect() -Disconnecting from Mentra Live glasses")
1674
1941
 
1675
1942
  // if rgbLedAuthorityClaimed {
@@ -1695,6 +1962,8 @@ class MentraLive: NSObject, SGCManager {
1695
1962
 
1696
1963
  /// Start the micbeat mechanism to keep LC3 audio streaming active
1697
1964
  private func startMicBeat() {
1965
+ // A resumed custom-audio stream starts a new sequence domain.
1966
+ DeviceManager.shared.resetMicSequenceBaseline()
1698
1967
  Bridge.log("LIVE: 🎤 Starting micbeat mechanism")
1699
1968
  micBeatCount = 0
1700
1969
 
@@ -1941,6 +2210,18 @@ class MentraLive: NSObject, SGCManager {
1941
2210
  private var pending: PendingMessage?
1942
2211
  private var pendingMessageTimer: Timer?
1943
2212
 
2213
+ /// ACK state belongs to the BLE session that sent the message. If the link drops while an ACK
2214
+ /// is outstanding, keeping `pending` set permanently blocks the command queue because the
2215
+ /// timeout timer is stopped during disconnect. Release both together before reconnecting.
2216
+ private func resetPendingAckState(reason: String) {
2217
+ pendingMessageTimer?.invalidate()
2218
+ pendingMessageTimer = nil
2219
+
2220
+ guard let pendingMessage = pending else { return }
2221
+ pending = nil
2222
+ Bridge.log("LIVE: Cleared pending ACK mId \(pendingMessage.id) because \(reason)")
2223
+ }
2224
+
1944
2225
  actor CommandQueue {
1945
2226
  private var commands: [PendingMessage] = []
1946
2227
 
@@ -2079,7 +2360,10 @@ class MentraLive: NSObject, SGCManager {
2079
2360
  // MARK: - BLE Scanning
2080
2361
 
2081
2362
  private func startScan() {
2082
- // guard !isScanning else { return }
2363
+ if pairingYieldActive {
2364
+ Bridge.log("LIVE: startScan blocked — pairing yield active")
2365
+ return
2366
+ }
2083
2367
 
2084
2368
  guard centralManager!.state == .poweredOn else {
2085
2369
  Bridge.log("Attempting to scan but bluetooth is not powered on.")
@@ -2087,6 +2371,7 @@ class MentraLive: NSObject, SGCManager {
2087
2371
  }
2088
2372
 
2089
2373
  Bridge.log("Starting BLE scan for Mentra Live glasses")
2374
+ discoveredAdvPairing.removeAll()
2090
2375
  isScanning = true
2091
2376
 
2092
2377
  startReadinessCheckLoop()
@@ -2103,11 +2388,9 @@ class MentraLive: NSObject, SGCManager {
2103
2388
 
2104
2389
  centralManager?.scanForPeripherals(withServices: nil, options: scanOptions)
2105
2390
 
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
- }
2391
+ // Fresh advertisements refill pairing metadata. Re-emitting the last scan's
2392
+ // cache would keep a unit pairable after it left pairing mode.
2393
+ emitConnectedDeviceForPairingScan()
2111
2394
 
2112
2395
  // var dName = DeviceManager.shared.deviceName
2113
2396
  // if dName.isEmpty {
@@ -2137,10 +2420,16 @@ class MentraLive: NSObject, SGCManager {
2137
2420
  // MARK: - Connection Management
2138
2421
 
2139
2422
  private func connectToDevice(_ peripheral: CBPeripheral) {
2423
+ if pairingYieldActive {
2424
+ Bridge.log("LIVE: connectToDevice blocked — pairing yield active")
2425
+ isConnecting = false
2426
+ return
2427
+ }
2140
2428
  Bridge.log("LIVE: Connecting to device: \(peripheral.identifier.uuidString)")
2141
2429
 
2142
2430
  isConnecting = true
2143
2431
  updateConnectionState(ConnTypes.CONNECTING)
2432
+ connectingPeripheral = peripheral
2144
2433
  connectedPeripheral = peripheral
2145
2434
  peripheral.delegate = self
2146
2435
 
@@ -2185,6 +2474,11 @@ class MentraLive: NSObject, SGCManager {
2185
2474
  return
2186
2475
  }
2187
2476
 
2477
+ if pairingYieldActive {
2478
+ Bridge.log("LIVE: Reconnection aborted - pairing yield active")
2479
+ return
2480
+ }
2481
+
2188
2482
  // Check if we've exceeded max attempts
2189
2483
  if reconnectAttempts >= MAX_RECONNECT_ATTEMPTS {
2190
2484
  Bridge.log("LIVE: Maximum reconnection attempts reached (\(MAX_RECONNECT_ATTEMPTS))")
@@ -2217,6 +2511,10 @@ class MentraLive: NSObject, SGCManager {
2217
2511
  guard let self = self else { return }
2218
2512
 
2219
2513
  // Use peripheral presence, not connectionState: we stay CONNECTING during backoff until scan/connect.
2514
+ if self.pairingYieldActive {
2515
+ Bridge.log("LIVE: Reconnection aborted - pairing yield active")
2516
+ return
2517
+ }
2220
2518
  if self.connectedPeripheral == nil, !self.isKilled {
2221
2519
  // Check for last known device name to start scan
2222
2520
  if let lastDeviceName = UserDefaults.standard.string(
@@ -2446,11 +2744,21 @@ class MentraLive: NSObject, SGCManager {
2446
2744
 
2447
2745
  switch type {
2448
2746
  case "glasses_ready":
2747
+ // A queued glasses_ready from the connection we just cancelled for yield
2748
+ // must not close the pairing window. Only accept readiness on a live GATT.
2749
+ if pairingYieldActive && connectedPeripheral == nil {
2750
+ Bridge.log("LIVE: Ignoring stale glasses_ready during pairing yield")
2751
+ return
2752
+ }
2449
2753
  // glasses_ready is a REMOTE wire-session reset: the glasses ran
2450
2754
  // onTransportReset() before sending it, so their side is back on legacy
2451
2755
  // framing regardless of what this side negotiated earlier. Start a fresh
2452
2756
  // wire epoch (clears v2-active state that would otherwise gate the
2453
2757
  // handshake off), then negotiate from the caps this message advertises.
2758
+ pairingYieldAwaitingReclaim = false
2759
+ pairingYieldActive = false
2760
+ pairingYieldEndWorkItem?.cancel()
2761
+ pairingYieldEndWorkItem = nil
2454
2762
  resetWireNegotiationState()
2455
2763
  parsePeerWireCaps(json)
2456
2764
  maybeSendWireHandshake()
@@ -2602,6 +2910,31 @@ class MentraLive: NSObject, SGCManager {
2602
2910
  case "pong":
2603
2911
  Bridge.log("LIVE: Received pong response - connection healthy")
2604
2912
 
2913
+ case "entering_pairing_mode":
2914
+ let windowMs = max(5_000, min(180_000, json["window_ms"] as? Int ?? 120_000))
2915
+ Bridge.log("LIVE: Glasses entering pairing mode — yield \(windowMs)ms (no forget)")
2916
+ enterPairingYield(windowMs: windowMs)
2917
+ var body: [String: Any] = [
2918
+ "window_ms": windowMs,
2919
+ "reason": json["reason"] as? String ?? "user_gesture",
2920
+ ]
2921
+ if let txn = json["txn"] {
2922
+ body["txn"] = txn
2923
+ }
2924
+ Bridge.sendTypedMessage("entering_pairing_mode", body: body)
2925
+
2926
+ case "pairing_info":
2927
+ Bridge.sendPairingInfo(
2928
+ hadPreviousBond: json["had_previous_bond"] as? Bool ?? false,
2929
+ transferId: json["transfer_id"] as? String,
2930
+ pairingCode: json["pairing_code"] as? String,
2931
+ classicBondReady: json["classic_bond_ready"] as? Bool ?? false,
2932
+ // Legacy firmware that omits this field is not secure-capable.
2933
+ securePairingCapable: json["secure_pairing_capable"] as? Bool ?? false,
2934
+ protocolVersion: json["protocol_version"] as? Int ?? 1,
2935
+ binding: json["binding"] as? String
2936
+ )
2937
+
2605
2938
  case "imu_response", "imu_stream_response", "imu_gesture_response",
2606
2939
  "imu_gesture_subscribed", "imu_ack", "imu_error":
2607
2940
  // Handle IMU-related responses
@@ -2669,6 +3002,7 @@ class MentraLive: NSObject, SGCManager {
2669
3002
  if let seq = json["sq"] as? [String] ?? json["step_sequence"] as? [String], !seq.isEmpty {
2670
3003
  cachedOtaStepSequence = seq
2671
3004
  }
3005
+ updateBesOtaHeartbeatGuard(stepType: osStepType, phase: osPhase, status: osStatus)
2672
3006
 
2673
3007
  Bridge.log("LIVE: 📱 OTA status - step \(osCurrentStep)/\(osTotalSteps) \(osPhase) \(osStatus) \(osOverallPercent)%")
2674
3008
 
@@ -2702,6 +3036,7 @@ class MentraLive: NSObject, SGCManager {
2702
3036
  } else {
2703
3037
  unified = "in_progress"
2704
3038
  }
3039
+ updateBesOtaHeartbeatGuard(stepType: currentUpdate, phase: legacyPhase, status: unified)
2705
3040
  Bridge.log(
2706
3041
  "LIVE: 📱 Legacy ota_progress → ota_status: \(legacyStage) \(legacyStatus) \(legacyProgress)%"
2707
3042
  )
@@ -2761,9 +3096,19 @@ class MentraLive: NSObject, SGCManager {
2761
3096
  if let systemTimeMs = fields["system_time_ms"] as? NSNumber {
2762
3097
  DeviceStore.shared.apply("glasses", "systemTimeMs", systemTimeMs.int64Value)
2763
3098
  }
3099
+ if let hotspotOtaVersion = fields["hotspot_ota_version"] as? NSNumber {
3100
+ DeviceStore.shared.apply(
3101
+ "glasses",
3102
+ "hotspotOtaVersion",
3103
+ hotspotOtaVersion.intValue
3104
+ )
3105
+ }
2764
3106
  if let bluetoothMacAddress = nonEmptyStringValue(fields, "bt_mac_address") {
2765
3107
  DeviceStore.shared.apply("glasses", "bluetoothMacAddress", bluetoothMacAddress)
2766
3108
  }
3109
+ if let wifiMacAddress = nonEmptyStringValue(fields, "wifi_mac_address") {
3110
+ DeviceStore.shared.apply("glasses", "wifiMacAddress", wifiMacAddress)
3111
+ }
2767
3112
  if let serialNumber = nonEmptyStringValue(fields, "serial_number") {
2768
3113
  DeviceStore.shared.apply("glasses", "serialNumber", serialNumber)
2769
3114
  }
@@ -2829,6 +3174,19 @@ class MentraLive: NSObject, SGCManager {
2829
3174
  cachedOtaCurrentStep = 0
2830
3175
  cachedOtaStepSequence = nil
2831
3176
  lastBesOtaProgress = -1
3177
+ // The bounded UART-ownership lease is transport state, not session-cache state. Keep it
3178
+ // through a BLE reconnect so heartbeats cannot resume while BES still owns the UART.
3179
+ }
3180
+
3181
+ private func updateBesOtaHeartbeatGuard(stepType: String, phase: String, status: String) {
3182
+ guard stepType.lowercased() == "bes", phase.lowercased() == "install" else { return }
3183
+ switch status.lowercased() {
3184
+ case "failed", "complete", "step_complete", "finished", "success":
3185
+ besOtaHeartbeatSuppressedUntil = 0
3186
+ default:
3187
+ besOtaHeartbeatSuppressedUntil =
3188
+ ProcessInfo.processInfo.systemUptime + BES_OTA_HEARTBEAT_LEASE_SEC
3189
+ }
2832
3190
  }
2833
3191
 
2834
3192
  /// Falls back to raw besProgress when step sequence is unavailable.
@@ -3000,62 +3358,41 @@ class MentraLive: NSObject, SGCManager {
3000
3358
  var progress = ((rawProgress + 2) / 5) * 5
3001
3359
  if progress > 100 { progress = 100 }
3002
3360
 
3003
- // Only send if progress changed to a new 5% increment
3004
3361
  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
3362
  var besOtaProgressVal: Int
3017
3363
  var besOtaErrorMessage: String? = nil
3018
3364
 
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"
3365
+ let failed = type == "error" || type == "fail"
3366
+ let succeeded = type == "success"
3367
+ if isTerminalStatus {
3368
+ besOtaHeartbeatSuppressedUntil = 0
3369
+ } else {
3370
+ // Refresh before progress de-duplication: raw progress proves the BES
3371
+ // transfer is active even when the rounded UI value is unchanged.
3372
+ besOtaHeartbeatSuppressedUntil =
3373
+ ProcessInfo.processInfo.systemUptime + BES_OTA_HEARTBEAT_LEASE_SEC
3374
+ }
3375
+ if succeeded {
3025
3376
  besOtaProgressVal = 100
3026
- lastBesOtaProgress = -1 // Reset for next OTA
3027
- } else if type == "error" || type == "fail" {
3028
- besOtaStatus = "FAILED"
3377
+ } else if failed {
3029
3378
  besOtaProgressVal = progress
3030
3379
  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
3380
  } else {
3036
- // Unknown type, treat as progress
3037
- besOtaStatus = "PROGRESS"
3038
- besOtaProgressVal = progress
3381
+ // A raw update:100 precedes whole-image CRC/apply and is not terminal.
3382
+ besOtaProgressVal = min(progress, 95)
3039
3383
  }
3040
3384
 
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"
3385
+ // Only send if nonterminal display progress changed to a new 5% increment.
3386
+ if besOtaProgressVal == lastBesOtaProgress && !isTerminalStatus {
3387
+ break
3058
3388
  }
3389
+ lastBesOtaProgress = isTerminalStatus ? -1 : besOtaProgressVal
3390
+
3391
+ Bridge.log(
3392
+ "LIVE: 📱 BES OTA progress via sr_adota - type: \(type), raw: \(rawProgress)%, rounded: \(progress)%, display: \(besOtaProgressVal)%"
3393
+ )
3394
+
3395
+ let syntheticStatus = succeeded ? "step_complete" : (failed ? "failed" : "in_progress")
3059
3396
  let sid = cachedOtaSessionId ?? ""
3060
3397
  let totalSteps = cachedOtaTotalSteps > 0 ? cachedOtaTotalSteps : 1
3061
3398
  let currentStep = cachedOtaCurrentStep > 0 ? cachedOtaCurrentStep : 1
@@ -3210,6 +3547,17 @@ class MentraLive: NSObject, SGCManager {
3210
3547
  sendJson(json, wakeUp: true)
3211
3548
  }
3212
3549
 
3550
+ func sendWifiAdbState(_ enabled: Bool) {
3551
+ Bridge.log("LIVE: 🔧 Sending Wi-Fi ADB state: \(enabled)")
3552
+
3553
+ let json: [String: Any] = [
3554
+ "type": "set_wifi_adb_state",
3555
+ "enabled": enabled,
3556
+ ]
3557
+
3558
+ sendJson(json, wakeUp: true)
3559
+ }
3560
+
3213
3561
  func sendSetSystemTime(_ timestampMs: Int64) {
3214
3562
  Bridge.log("LIVE: ⏰ Sending set_system_time: \(timestampMs)")
3215
3563
 
@@ -3329,7 +3677,6 @@ class MentraLive: NSObject, SGCManager {
3329
3677
  {
3330
3678
  json["ota_version_url"] = otaVersionUrl
3331
3679
  }
3332
-
3333
3680
  sendJson(json, wakeUp: true)
3334
3681
  }
3335
3682
 
@@ -3500,17 +3847,9 @@ class MentraLive: NSObject, SGCManager {
3500
3847
  return
3501
3848
  }
3502
3849
 
3503
- let sequenceNumber = Int8(bitPattern: data[1])
3850
+ let sequenceNumber = Int(data[1])
3504
3851
  let lc3Data = data.subdata(in: 2 ..< data.count)
3505
3852
 
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
3853
  // // Decode LC3 to PCM using existing PcmConverter
3515
3854
  // let pcmConverter = PcmConverter()
3516
3855
  // guard let pcmData = pcmConverter.decode(lc3Data) as? Data, pcmData.count > 0 else {
@@ -3524,7 +3863,7 @@ class MentraLive: NSObject, SGCManager {
3524
3863
  // Bridge.log(
3525
3864
  // "LIVE: Processed LC3 audio seq=\(sequenceNumber), \(lc3Data.count) bytes"
3526
3865
  // )
3527
- DeviceManager.shared.handleGlassesMicData(lc3Data, 40)
3866
+ DeviceManager.shared.handleGlassesMicData(lc3Data, 40, sequenceNumber: sequenceNumber)
3528
3867
 
3529
3868
  // Bridge.log(
3530
3869
  // "LIVE: Processed LC3 audio seq=\(sequenceNumber), \(lc3Data.count)→\(pcmData.count) bytes"
@@ -5068,6 +5407,10 @@ class MentraLive: NSObject, SGCManager {
5068
5407
  Bridge.log("LIVE: Skipping heartbeat - glasses not fully booted or not connected")
5069
5408
  return
5070
5409
  }
5410
+ if ProcessInfo.processInfo.systemUptime < besOtaHeartbeatSuppressedUntil {
5411
+ Bridge.log("LIVE: Skipping heartbeat while BES OTA owns the UART")
5412
+ return
5413
+ }
5071
5414
 
5072
5415
  // Send ping message to glasses hardware (no ACK needed for heartbeats)
5073
5416
  let pingJson: [String: Any] = ["type": "ping"]
@@ -5181,16 +5524,78 @@ class MentraLive: NSObject, SGCManager {
5181
5524
  stopReadinessCheckLoop()
5182
5525
  stopConnectionTimeout()
5183
5526
  stopMicBeat() // Stop LC3 audio micbeat
5184
- pendingMessageTimer?.invalidate()
5185
- pendingMessageTimer = nil
5527
+ resetPendingAckState(reason: "transport timers stopped")
5186
5528
  reconnectionWorkItem?.cancel()
5187
5529
  reconnectionWorkItem = nil
5188
5530
  }
5189
5531
 
5190
5532
  // MARK: - Event Emission
5191
5533
 
5192
- private func emitDiscoveredDevice(_ name: String, identifier: String = "", rssi: Int? = nil) {
5193
- Bridge.sendDiscoveredDevice("Mentra Live", name, deviceAddress: identifier, rssi: rssi)
5534
+ /// Pairing scan listens for advertisements. A unit that is already GATT-connected
5535
+ /// has stopped ADV, so emit it as pairable or the scan list stays empty.
5536
+ private func emitConnectedDeviceForPairingScan() {
5537
+ guard connected, let peripheral = connectedPeripheral, let name = peripheral.name,
5538
+ name == "Xy_A" || name.hasPrefix("XyBLE_") || name.hasPrefix("MENTRA_LIVE_BLE")
5539
+ || name.hasPrefix("MENTRA_LIVE_BT") || name.lowercased().hasPrefix("mentra_live")
5540
+ else {
5541
+ return
5542
+ }
5543
+ Bridge.log("LIVE: Pairing scan: already GATT-connected to \(name) — emitting as pairable (ADV off while connected)")
5544
+ emitDiscoveredDevice(
5545
+ name,
5546
+ identifier: peripheral.identifier.uuidString,
5547
+ pairingMode: true,
5548
+ pairingCode: nil,
5549
+ securePairingCapable: discoveredAdvPairing[name]?.securePairingCapable ?? false
5550
+ )
5551
+ }
5552
+
5553
+ private func cacheAdvPairing(
5554
+ _ name: String,
5555
+ pairingMode: Bool,
5556
+ pairingCode: String?,
5557
+ securePairingCapable: Bool
5558
+ ) {
5559
+ discoveredAdvPairing[name] = CachedAdvPairing(
5560
+ pairingMode: pairingMode,
5561
+ pairingCode: pairingCode,
5562
+ securePairingCapable: securePairingCapable
5563
+ )
5564
+ }
5565
+
5566
+ private func emitDiscoveredDeviceFromCache(
5567
+ _ name: String,
5568
+ identifier: String = "",
5569
+ rssi: Int? = nil
5570
+ ) {
5571
+ let cached = discoveredAdvPairing[name]
5572
+ emitDiscoveredDevice(
5573
+ name,
5574
+ identifier: identifier,
5575
+ rssi: rssi,
5576
+ pairingMode: cached?.pairingMode,
5577
+ pairingCode: cached?.pairingCode,
5578
+ securePairingCapable: cached?.securePairingCapable
5579
+ )
5580
+ }
5581
+
5582
+ private func emitDiscoveredDevice(
5583
+ _ name: String,
5584
+ identifier: String = "",
5585
+ rssi: Int? = nil,
5586
+ pairingMode: Bool? = nil,
5587
+ pairingCode: String? = nil,
5588
+ securePairingCapable: Bool? = nil
5589
+ ) {
5590
+ Bridge.sendDiscoveredDevice(
5591
+ "Mentra Live",
5592
+ name,
5593
+ deviceAddress: identifier,
5594
+ rssi: rssi,
5595
+ pairingMode: pairingMode,
5596
+ pairingCode: pairingCode,
5597
+ securePairingCapable: securePairingCapable
5598
+ )
5194
5599
  }
5195
5600
 
5196
5601
  private func emitStopScanEvent() {
@@ -5279,6 +5684,9 @@ class MentraLive: NSObject, SGCManager {
5279
5684
  Bridge.log("Destroying MentraLiveManager")
5280
5685
 
5281
5686
  isKilled = true
5687
+ unpairFlushWorkItem?.cancel()
5688
+ unpairFlushWorkItem = nil
5689
+ unpairFlushPending = false
5282
5690
 
5283
5691
  // Stop scanning
5284
5692
  if isScanning {
@@ -5312,6 +5720,8 @@ class MentraLive: NSObject, SGCManager {
5312
5720
  DeviceStore.shared.apply("glasses", "hotspotGatewayIp", "")
5313
5721
 
5314
5722
  connectedPeripheral = nil
5723
+ discoveredPeripherals.removeAll()
5724
+ discoveredAdvPairing.removeAll()
5315
5725
  centralManager?.delegate = nil
5316
5726
  centralManager = nil
5317
5727
 
@@ -6521,6 +6931,17 @@ extension MentraLive {
6521
6931
  stopVideoRecording(requestId: requestId, webhookUrl: nil, authToken: nil)
6522
6932
  }
6523
6933
 
6934
+ func queryVideoRecordingStatus(requestId: String) {
6935
+ guard connectionState == ConnTypes.CONNECTED else {
6936
+ Bridge.log("Cannot query video recording status - not connected")
6937
+ return
6938
+ }
6939
+ sendJson([
6940
+ "type": "get_video_recording_status",
6941
+ "requestId": requestId,
6942
+ ])
6943
+ }
6944
+
6524
6945
  func stopVideoRecording(requestId: String, webhookUrl: String?, authToken: String?) {
6525
6946
  Bridge.log(
6526
6947
  "Stopping video recording on glasses: requestId=\(requestId), webhook=\((webhookUrl?.isEmpty ?? true) ? "none" : "set")"