@mentra/bluetooth-sdk 0.1.21-beta.5 → 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 (118) 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 +205 -19
  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/MentraLive.kt +1137 -191
  22. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingAdvertisement.kt +54 -0
  23. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +8 -0
  24. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +17 -0
  25. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +12 -0
  26. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +74 -79
  27. package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +3 -3
  28. package/android/src/test/java/com/mentra/bluetoothsdk/OtaStartResponsePolicyTest.kt +30 -0
  29. package/android/src/test/java/com/mentra/bluetoothsdk/ScopedNetworkReadinessTest.kt +39 -0
  30. package/android/src/test/java/com/mentra/bluetoothsdk/otaserver/LocalOtaServerTest.kt +82 -0
  31. package/android/src/test/java/com/mentra/bluetoothsdk/services/ForegroundServiceTypeTest.kt +69 -0
  32. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCharacteristicsTest.kt +39 -0
  33. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingAdvertisementParserTest.kt +75 -0
  34. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +148 -7
  35. package/build/BluetoothSdk.types.d.ts +56 -2
  36. package/build/BluetoothSdk.types.d.ts.map +1 -1
  37. package/build/BluetoothSdk.types.js.map +1 -1
  38. package/build/_internal.d.ts +2 -0
  39. package/build/_internal.d.ts.map +1 -1
  40. package/build/_internal.js +2 -0
  41. package/build/_internal.js.map +1 -1
  42. package/build/_private/BluetoothSdkModule.d.ts +4 -1
  43. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  44. package/build/_private/BluetoothSdkModule.js +1 -1
  45. package/build/_private/BluetoothSdkModule.js.map +1 -1
  46. package/build/_private/MentraLocalNetworkModule.d.ts +1 -0
  47. package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -1
  48. package/build/_private/MentraLocalNetworkModule.js.map +1 -1
  49. package/build/_private/sdkOtaManifest.d.ts +3 -12
  50. package/build/_private/sdkOtaManifest.d.ts.map +1 -1
  51. package/build/_private/sdkOtaManifest.js +5 -17
  52. package/build/_private/sdkOtaManifest.js.map +1 -1
  53. package/build/generated/releaseMetadata.d.ts +12 -0
  54. package/build/generated/releaseMetadata.d.ts.map +1 -0
  55. package/build/generated/releaseMetadata.js +10 -0
  56. package/build/generated/releaseMetadata.js.map +1 -0
  57. package/build/index.d.ts +1 -1
  58. package/build/index.d.ts.map +1 -1
  59. package/build/index.js +10 -2
  60. package/build/index.js.map +1 -1
  61. package/build/ota-server/MentraOtaServer.types.d.ts +14 -0
  62. package/build/ota-server/MentraOtaServer.types.d.ts.map +1 -0
  63. package/build/ota-server/MentraOtaServer.types.js +2 -0
  64. package/build/ota-server/MentraOtaServer.types.js.map +1 -0
  65. package/build/ota-server/MentraOtaServerModule.d.ts +21 -0
  66. package/build/ota-server/MentraOtaServerModule.d.ts.map +1 -0
  67. package/build/ota-server/MentraOtaServerModule.js +3 -0
  68. package/build/ota-server/MentraOtaServerModule.js.map +1 -0
  69. package/build/ota-server/MentraOtaServerModule.web.d.ts +10 -0
  70. package/build/ota-server/MentraOtaServerModule.web.d.ts.map +1 -0
  71. package/build/ota-server/MentraOtaServerModule.web.js +12 -0
  72. package/build/ota-server/MentraOtaServerModule.web.js.map +1 -0
  73. package/build/ota-server/index.d.ts +3 -0
  74. package/build/ota-server/index.d.ts.map +1 -0
  75. package/build/ota-server/index.js +3 -0
  76. package/build/ota-server/index.js.map +1 -0
  77. package/expo-module.config.json +3 -2
  78. package/ios/BackgroundOtaArtifactDownloader.swift +125 -0
  79. package/ios/BluetoothSdkModule.swift +16 -2
  80. package/ios/LocalIPv4.swift +150 -0
  81. package/ios/LocalOtaServer.swift +309 -0
  82. package/ios/MentraBluetoothSDK.podspec +5 -1
  83. package/ios/MentraOtaServerModule.swift +147 -0
  84. package/ios/MentraPhotoReceiverModule.swift +1 -81
  85. package/ios/Source/BluetoothSdkDefaults.swift +4 -1
  86. package/ios/Source/Bridge.swift +43 -4
  87. package/ios/Source/DeviceManager.swift +143 -13
  88. package/ios/Source/DeviceStore.swift +2 -0
  89. package/ios/Source/GeneratedReleaseMetadata.swift +11 -0
  90. package/ios/Source/MentraBluetoothSDK.swift +74 -6
  91. package/ios/Source/OtaManifest.swift +5 -7
  92. package/ios/Source/audio/AudioModels.swift +17 -0
  93. package/ios/Source/camera/CameraModels.swift +1 -0
  94. package/ios/Source/events/BluetoothEvents.swift +27 -0
  95. package/ios/Source/sgcs/MentraLive.swift +410 -30
  96. package/ios/Source/sgcs/MentraLivePairingAdvertisement.swift +64 -0
  97. package/ios/Source/sgcs/SGCManager.swift +11 -0
  98. package/ios/Source/status/DeviceStatus.swift +23 -0
  99. package/ios/Source/types/DeviceModels.swift +23 -2
  100. package/ios/Source/utils/BleJsonCompact.swift +73 -71
  101. package/ios/Tests/MentraLivePairingAdvertisementTests.swift +104 -0
  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 +67 -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,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)
@@ -1343,14 +1467,26 @@ class MentraLive: NSObject, SGCManager {
1343
1467
  func forget() {
1344
1468
  Bridge.log("LIVE: Forgetting Mentra Live glasses")
1345
1469
 
1470
+ // Clear saved device name so the pairing filter is not bypassed on rescan
1471
+ UserDefaults.standard.removeObject(forKey: PREFS_DEVICE_NAME)
1472
+
1346
1473
  // Stop scanning first
1347
1474
  if isScanning {
1348
1475
  stopScan()
1349
1476
  emitStopScanEvent()
1350
1477
  }
1351
1478
 
1352
- // Then do full cleanup (disconnect + clear all references)
1353
- 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)
1354
1490
  }
1355
1491
 
1356
1492
  var type = "Mentra Live"
@@ -1428,7 +1564,6 @@ class MentraLive: NSObject, SGCManager {
1428
1564
  private var lc3ReadCharacteristic: CBCharacteristic?
1429
1565
  private var lc3WriteCharacteristic: CBCharacteristic?
1430
1566
  private var supportsLC3Audio = true
1431
- private var lastReceivedLc3Sequence: Int8 = -1
1432
1567
  private let LC3_FRAME_SIZE = 40 // bytes per LC3 frame
1433
1568
  private let MICBEAT_INTERVAL_MS: TimeInterval = 30 * 60 // 30 minutes in seconds
1434
1569
  private var micBeatTimer: Timer?
@@ -1474,6 +1609,8 @@ class MentraLive: NSObject, SGCManager {
1474
1609
  private var centralManager: CBCentralManager?
1475
1610
 
1476
1611
  private var connectedPeripheral: CBPeripheral?
1612
+ /// Peripheral for the in-flight connect; used to ignore stale disconnect/fail callbacks.
1613
+ private var connectingPeripheral: CBPeripheral?
1477
1614
  private var txCharacteristic: CBCharacteristic?
1478
1615
  private var rxCharacteristic: CBCharacteristic?
1479
1616
  private let bes2700MtuLimit = 509
@@ -1483,6 +1620,13 @@ class MentraLive: NSObject, SGCManager {
1483
1620
  private var isScanning = false
1484
1621
  private var isConnecting = false
1485
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?
1486
1630
  private var reconnectAttempts = 0
1487
1631
  private var isNewVersion = false
1488
1632
  private var peerWireProtocolVersion = 0
@@ -1601,6 +1745,16 @@ class MentraLive: NSObject, SGCManager {
1601
1745
  // MARK: - React Native Interface
1602
1746
 
1603
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
+ }
1604
1758
 
1605
1759
  func findCompatibleDevices() {
1606
1760
  Bridge.log("Finding compatible Mentra Live glasses")
@@ -1622,7 +1776,89 @@ class MentraLive: NSObject, SGCManager {
1622
1776
  }
1623
1777
  }
1624
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
+
1625
1857
  func connectById(_ deviceName: String) {
1858
+ if pairingYieldActive {
1859
+ Bridge.log("LIVE: connectById blocked — pairing yield active")
1860
+ return
1861
+ }
1626
1862
  Bridge.log("connectById: \(deviceName)")
1627
1863
  // Save the device name for future reconnection
1628
1864
  UserDefaults.standard.set(deviceName, forKey: PREFS_DEVICE_NAME)
@@ -1647,7 +1883,7 @@ class MentraLive: NSObject, SGCManager {
1647
1883
  {
1648
1884
  Bridge.log("Found already-connected peripheral: \(name)")
1649
1885
  discoveredPeripherals[name] = peripheral
1650
- emitDiscoveredDevice(name)
1886
+ emitDiscoveredDeviceFromCache(name)
1651
1887
 
1652
1888
  // Check if this is the device we want
1653
1889
  if let savedDeviceName = UserDefaults.standard.string(
@@ -1673,6 +1909,10 @@ class MentraLive: NSObject, SGCManager {
1673
1909
  }
1674
1910
 
1675
1911
  @objc func disconnect() {
1912
+ if unpairFlushPending {
1913
+ Bridge.log("LIVE: disconnect deferred — waiting for unpair write")
1914
+ return
1915
+ }
1676
1916
  Bridge.log("LIVE: disconnect() -Disconnecting from Mentra Live glasses")
1677
1917
 
1678
1918
  // if rgbLedAuthorityClaimed {
@@ -1698,6 +1938,8 @@ class MentraLive: NSObject, SGCManager {
1698
1938
 
1699
1939
  /// Start the micbeat mechanism to keep LC3 audio streaming active
1700
1940
  private func startMicBeat() {
1941
+ // A resumed custom-audio stream starts a new sequence domain.
1942
+ DeviceManager.shared.resetMicSequenceBaseline()
1701
1943
  Bridge.log("LIVE: 🎤 Starting micbeat mechanism")
1702
1944
  micBeatCount = 0
1703
1945
 
@@ -2094,7 +2336,10 @@ class MentraLive: NSObject, SGCManager {
2094
2336
  // MARK: - BLE Scanning
2095
2337
 
2096
2338
  private func startScan() {
2097
- // guard !isScanning else { return }
2339
+ if pairingYieldActive {
2340
+ Bridge.log("LIVE: startScan blocked — pairing yield active")
2341
+ return
2342
+ }
2098
2343
 
2099
2344
  guard centralManager!.state == .poweredOn else {
2100
2345
  Bridge.log("Attempting to scan but bluetooth is not powered on.")
@@ -2102,6 +2347,7 @@ class MentraLive: NSObject, SGCManager {
2102
2347
  }
2103
2348
 
2104
2349
  Bridge.log("Starting BLE scan for Mentra Live glasses")
2350
+ discoveredAdvPairing.removeAll()
2105
2351
  isScanning = true
2106
2352
 
2107
2353
  startReadinessCheckLoop()
@@ -2118,11 +2364,9 @@ class MentraLive: NSObject, SGCManager {
2118
2364
 
2119
2365
  centralManager?.scanForPeripherals(withServices: nil, options: scanOptions)
2120
2366
 
2121
- // emit already discovered peripherals:
2122
- for (_, peripheral) in discoveredPeripherals {
2123
- Bridge.log("LIVE: (Already discovered) peripheral: \(peripheral.name ?? "Unknown")")
2124
- emitDiscoveredDevice(peripheral.name!)
2125
- }
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()
2126
2370
 
2127
2371
  // var dName = DeviceManager.shared.deviceName
2128
2372
  // if dName.isEmpty {
@@ -2152,10 +2396,16 @@ class MentraLive: NSObject, SGCManager {
2152
2396
  // MARK: - Connection Management
2153
2397
 
2154
2398
  private func connectToDevice(_ peripheral: CBPeripheral) {
2399
+ if pairingYieldActive {
2400
+ Bridge.log("LIVE: connectToDevice blocked — pairing yield active")
2401
+ isConnecting = false
2402
+ return
2403
+ }
2155
2404
  Bridge.log("LIVE: Connecting to device: \(peripheral.identifier.uuidString)")
2156
2405
 
2157
2406
  isConnecting = true
2158
2407
  updateConnectionState(ConnTypes.CONNECTING)
2408
+ connectingPeripheral = peripheral
2159
2409
  connectedPeripheral = peripheral
2160
2410
  peripheral.delegate = self
2161
2411
 
@@ -2200,6 +2450,11 @@ class MentraLive: NSObject, SGCManager {
2200
2450
  return
2201
2451
  }
2202
2452
 
2453
+ if pairingYieldActive {
2454
+ Bridge.log("LIVE: Reconnection aborted - pairing yield active")
2455
+ return
2456
+ }
2457
+
2203
2458
  // Check if we've exceeded max attempts
2204
2459
  if reconnectAttempts >= MAX_RECONNECT_ATTEMPTS {
2205
2460
  Bridge.log("LIVE: Maximum reconnection attempts reached (\(MAX_RECONNECT_ATTEMPTS))")
@@ -2232,6 +2487,10 @@ class MentraLive: NSObject, SGCManager {
2232
2487
  guard let self = self else { return }
2233
2488
 
2234
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
+ }
2235
2494
  if self.connectedPeripheral == nil, !self.isKilled {
2236
2495
  // Check for last known device name to start scan
2237
2496
  if let lastDeviceName = UserDefaults.standard.string(
@@ -2461,11 +2720,21 @@ class MentraLive: NSObject, SGCManager {
2461
2720
 
2462
2721
  switch type {
2463
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
+ }
2464
2729
  // glasses_ready is a REMOTE wire-session reset: the glasses ran
2465
2730
  // onTransportReset() before sending it, so their side is back on legacy
2466
2731
  // framing regardless of what this side negotiated earlier. Start a fresh
2467
2732
  // wire epoch (clears v2-active state that would otherwise gate the
2468
2733
  // handshake off), then negotiate from the caps this message advertises.
2734
+ pairingYieldAwaitingReclaim = false
2735
+ pairingYieldActive = false
2736
+ pairingYieldEndWorkItem?.cancel()
2737
+ pairingYieldEndWorkItem = nil
2469
2738
  resetWireNegotiationState()
2470
2739
  parsePeerWireCaps(json)
2471
2740
  maybeSendWireHandshake()
@@ -2617,6 +2886,26 @@ class MentraLive: NSObject, SGCManager {
2617
2886
  case "pong":
2618
2887
  Bridge.log("LIVE: Received pong response - connection healthy")
2619
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
+
2620
2909
  case "imu_response", "imu_stream_response", "imu_gesture_response",
2621
2910
  "imu_gesture_subscribed", "imu_ack", "imu_error":
2622
2911
  // Handle IMU-related responses
@@ -2778,9 +3067,19 @@ class MentraLive: NSObject, SGCManager {
2778
3067
  if let systemTimeMs = fields["system_time_ms"] as? NSNumber {
2779
3068
  DeviceStore.shared.apply("glasses", "systemTimeMs", systemTimeMs.int64Value)
2780
3069
  }
3070
+ if let hotspotOtaVersion = fields["hotspot_ota_version"] as? NSNumber {
3071
+ DeviceStore.shared.apply(
3072
+ "glasses",
3073
+ "hotspotOtaVersion",
3074
+ hotspotOtaVersion.intValue
3075
+ )
3076
+ }
2781
3077
  if let bluetoothMacAddress = nonEmptyStringValue(fields, "bt_mac_address") {
2782
3078
  DeviceStore.shared.apply("glasses", "bluetoothMacAddress", bluetoothMacAddress)
2783
3079
  }
3080
+ if let wifiMacAddress = nonEmptyStringValue(fields, "wifi_mac_address") {
3081
+ DeviceStore.shared.apply("glasses", "wifiMacAddress", wifiMacAddress)
3082
+ }
2784
3083
  if let serialNumber = nonEmptyStringValue(fields, "serial_number") {
2785
3084
  DeviceStore.shared.apply("glasses", "serialNumber", serialNumber)
2786
3085
  }
@@ -3219,6 +3518,17 @@ class MentraLive: NSObject, SGCManager {
3219
3518
  sendJson(json, wakeUp: true)
3220
3519
  }
3221
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
+
3222
3532
  func sendSetSystemTime(_ timestampMs: Int64) {
3223
3533
  Bridge.log("LIVE: ⏰ Sending set_system_time: \(timestampMs)")
3224
3534
 
@@ -3338,7 +3648,6 @@ class MentraLive: NSObject, SGCManager {
3338
3648
  {
3339
3649
  json["ota_version_url"] = otaVersionUrl
3340
3650
  }
3341
-
3342
3651
  sendJson(json, wakeUp: true)
3343
3652
  }
3344
3653
 
@@ -3509,17 +3818,9 @@ class MentraLive: NSObject, SGCManager {
3509
3818
  return
3510
3819
  }
3511
3820
 
3512
- let sequenceNumber = Int8(bitPattern: data[1])
3821
+ let sequenceNumber = Int(data[1])
3513
3822
  let lc3Data = data.subdata(in: 2 ..< data.count)
3514
3823
 
3515
- // Validate sequence number for packet loss detection
3516
- if lastReceivedLc3Sequence != -1 && (lastReceivedLc3Sequence &+ 1) != sequenceNumber {
3517
- Bridge.log(
3518
- "LIVE: LC3 packet sequence mismatch. Expected: \(lastReceivedLc3Sequence &+ 1), Got: \(sequenceNumber)"
3519
- )
3520
- }
3521
- lastReceivedLc3Sequence = sequenceNumber
3522
-
3523
3824
  // // Decode LC3 to PCM using existing PcmConverter
3524
3825
  // let pcmConverter = PcmConverter()
3525
3826
  // guard let pcmData = pcmConverter.decode(lc3Data) as? Data, pcmData.count > 0 else {
@@ -3533,7 +3834,7 @@ class MentraLive: NSObject, SGCManager {
3533
3834
  // Bridge.log(
3534
3835
  // "LIVE: Processed LC3 audio seq=\(sequenceNumber), \(lc3Data.count) bytes"
3535
3836
  // )
3536
- DeviceManager.shared.handleGlassesMicData(lc3Data, 40)
3837
+ DeviceManager.shared.handleGlassesMicData(lc3Data, 40, sequenceNumber: sequenceNumber)
3537
3838
 
3538
3839
  // Bridge.log(
3539
3840
  // "LIVE: Processed LC3 audio seq=\(sequenceNumber), \(lc3Data.count)→\(pcmData.count) bytes"
@@ -5201,8 +5502,71 @@ class MentraLive: NSObject, SGCManager {
5201
5502
 
5202
5503
  // MARK: - Event Emission
5203
5504
 
5204
- private func emitDiscoveredDevice(_ name: String, identifier: String = "", rssi: Int? = nil) {
5205
- 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
+ )
5206
5570
  }
5207
5571
 
5208
5572
  private func emitStopScanEvent() {
@@ -5291,6 +5655,9 @@ class MentraLive: NSObject, SGCManager {
5291
5655
  Bridge.log("Destroying MentraLiveManager")
5292
5656
 
5293
5657
  isKilled = true
5658
+ unpairFlushWorkItem?.cancel()
5659
+ unpairFlushWorkItem = nil
5660
+ unpairFlushPending = false
5294
5661
 
5295
5662
  // Stop scanning
5296
5663
  if isScanning {
@@ -5324,6 +5691,8 @@ class MentraLive: NSObject, SGCManager {
5324
5691
  DeviceStore.shared.apply("glasses", "hotspotGatewayIp", "")
5325
5692
 
5326
5693
  connectedPeripheral = nil
5694
+ discoveredPeripherals.removeAll()
5695
+ discoveredAdvPairing.removeAll()
5327
5696
  centralManager?.delegate = nil
5328
5697
  centralManager = nil
5329
5698
 
@@ -6533,6 +6902,17 @@ extension MentraLive {
6533
6902
  stopVideoRecording(requestId: requestId, webhookUrl: nil, authToken: nil)
6534
6903
  }
6535
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
+
6536
6916
  func stopVideoRecording(requestId: String, webhookUrl: String?, authToken: String?) {
6537
6917
  Bridge.log(
6538
6918
  "Stopping video recording on glasses: requestId=\(requestId), webhook=\((webhookUrl?.isEmpty ?? true) ? "none" : "set")"