@mentra/bluetooth-sdk 0.1.21-beta.5 → 3.1.0-dev.7

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 (114) 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 +198 -17
  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 +1174 -191
  22. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +8 -0
  23. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +17 -0
  24. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +12 -0
  25. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +74 -79
  26. package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +3 -3
  27. package/android/src/test/java/com/mentra/bluetoothsdk/OtaStartResponsePolicyTest.kt +30 -0
  28. package/android/src/test/java/com/mentra/bluetoothsdk/ScopedNetworkReadinessTest.kt +39 -0
  29. package/android/src/test/java/com/mentra/bluetoothsdk/otaserver/LocalOtaServerTest.kt +82 -0
  30. package/android/src/test/java/com/mentra/bluetoothsdk/services/ForegroundServiceTypeTest.kt +69 -0
  31. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCharacteristicsTest.kt +39 -0
  32. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +148 -7
  33. package/build/BluetoothSdk.types.d.ts +61 -2
  34. package/build/BluetoothSdk.types.d.ts.map +1 -1
  35. package/build/BluetoothSdk.types.js.map +1 -1
  36. package/build/_internal.d.ts +2 -0
  37. package/build/_internal.d.ts.map +1 -1
  38. package/build/_internal.js +2 -0
  39. package/build/_internal.js.map +1 -1
  40. package/build/_private/BluetoothSdkModule.d.ts +4 -1
  41. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  42. package/build/_private/BluetoothSdkModule.js +1 -1
  43. package/build/_private/BluetoothSdkModule.js.map +1 -1
  44. package/build/_private/MentraLocalNetworkModule.d.ts +1 -0
  45. package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -1
  46. package/build/_private/MentraLocalNetworkModule.js.map +1 -1
  47. package/build/_private/sdkOtaManifest.d.ts +3 -12
  48. package/build/_private/sdkOtaManifest.d.ts.map +1 -1
  49. package/build/_private/sdkOtaManifest.js +5 -17
  50. package/build/_private/sdkOtaManifest.js.map +1 -1
  51. package/build/generated/releaseMetadata.d.ts +12 -0
  52. package/build/generated/releaseMetadata.d.ts.map +1 -0
  53. package/build/generated/releaseMetadata.js +10 -0
  54. package/build/generated/releaseMetadata.js.map +1 -0
  55. package/build/index.d.ts +1 -1
  56. package/build/index.d.ts.map +1 -1
  57. package/build/index.js +10 -2
  58. package/build/index.js.map +1 -1
  59. package/build/ota-server/MentraOtaServer.types.d.ts +14 -0
  60. package/build/ota-server/MentraOtaServer.types.d.ts.map +1 -0
  61. package/build/ota-server/MentraOtaServer.types.js +2 -0
  62. package/build/ota-server/MentraOtaServer.types.js.map +1 -0
  63. package/build/ota-server/MentraOtaServerModule.d.ts +21 -0
  64. package/build/ota-server/MentraOtaServerModule.d.ts.map +1 -0
  65. package/build/ota-server/MentraOtaServerModule.js +3 -0
  66. package/build/ota-server/MentraOtaServerModule.js.map +1 -0
  67. package/build/ota-server/MentraOtaServerModule.web.d.ts +10 -0
  68. package/build/ota-server/MentraOtaServerModule.web.d.ts.map +1 -0
  69. package/build/ota-server/MentraOtaServerModule.web.js +12 -0
  70. package/build/ota-server/MentraOtaServerModule.web.js.map +1 -0
  71. package/build/ota-server/index.d.ts +3 -0
  72. package/build/ota-server/index.d.ts.map +1 -0
  73. package/build/ota-server/index.js +3 -0
  74. package/build/ota-server/index.js.map +1 -0
  75. package/expo-module.config.json +3 -2
  76. package/ios/BackgroundOtaArtifactDownloader.swift +125 -0
  77. package/ios/BluetoothSdkModule.swift +16 -2
  78. package/ios/LocalIPv4.swift +150 -0
  79. package/ios/LocalOtaServer.swift +309 -0
  80. package/ios/MentraBluetoothSDK.podspec +5 -1
  81. package/ios/MentraOtaServerModule.swift +147 -0
  82. package/ios/MentraPhotoReceiverModule.swift +1 -81
  83. package/ios/Source/BluetoothSdkDefaults.swift +4 -1
  84. package/ios/Source/Bridge.swift +47 -3
  85. package/ios/Source/DeviceManager.swift +135 -10
  86. package/ios/Source/DeviceStore.swift +2 -0
  87. package/ios/Source/GeneratedReleaseMetadata.swift +11 -0
  88. package/ios/Source/MentraBluetoothSDK.swift +74 -6
  89. package/ios/Source/OtaManifest.swift +5 -7
  90. package/ios/Source/audio/AudioModels.swift +17 -0
  91. package/ios/Source/camera/CameraModels.swift +1 -0
  92. package/ios/Source/events/BluetoothEvents.swift +27 -0
  93. package/ios/Source/sgcs/MentraLive.swift +439 -30
  94. package/ios/Source/sgcs/SGCManager.swift +11 -0
  95. package/ios/Source/status/DeviceStatus.swift +23 -0
  96. package/ios/Source/types/DeviceModels.swift +23 -2
  97. package/ios/Source/utils/BleJsonCompact.swift +73 -71
  98. package/package.json +5 -1
  99. package/plugin/build/withIos.d.ts +3 -1
  100. package/plugin/build/withIos.js +22 -10
  101. package/scripts/verify-release-package.mjs +34 -0
  102. package/scripts/write-release-metadata.mjs +154 -0
  103. package/scripts/write-release-metadata.test.mjs +29 -0
  104. package/src/BluetoothSdk.types.ts +72 -3
  105. package/src/_internal.ts +2 -0
  106. package/src/_private/BluetoothSdkModule.ts +5 -5
  107. package/src/_private/MentraLocalNetworkModule.ts +1 -1
  108. package/src/_private/sdkOtaManifest.ts +5 -17
  109. package/src/generated/releaseMetadata.ts +20 -0
  110. package/src/index.ts +19 -2
  111. package/src/ota-server/MentraOtaServer.types.ts +10 -0
  112. package/src/ota-server/MentraOtaServerModule.ts +23 -0
  113. package/src/ota-server/MentraOtaServerModule.web.ts +17 -0
  114. 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)
@@ -1343,14 +1491,26 @@ class MentraLive: NSObject, SGCManager {
1343
1491
  func forget() {
1344
1492
  Bridge.log("LIVE: Forgetting Mentra Live glasses")
1345
1493
 
1494
+ // Clear saved device name so the pairing filter is not bypassed on rescan
1495
+ UserDefaults.standard.removeObject(forKey: PREFS_DEVICE_NAME)
1496
+
1346
1497
  // Stop scanning first
1347
1498
  if isScanning {
1348
1499
  stopScan()
1349
1500
  emitStopScanEvent()
1350
1501
  }
1351
1502
 
1352
- // Then do full cleanup (disconnect + clear all references)
1353
- 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)
1354
1514
  }
1355
1515
 
1356
1516
  var type = "Mentra Live"
@@ -1428,7 +1588,6 @@ class MentraLive: NSObject, SGCManager {
1428
1588
  private var lc3ReadCharacteristic: CBCharacteristic?
1429
1589
  private var lc3WriteCharacteristic: CBCharacteristic?
1430
1590
  private var supportsLC3Audio = true
1431
- private var lastReceivedLc3Sequence: Int8 = -1
1432
1591
  private let LC3_FRAME_SIZE = 40 // bytes per LC3 frame
1433
1592
  private let MICBEAT_INTERVAL_MS: TimeInterval = 30 * 60 // 30 minutes in seconds
1434
1593
  private var micBeatTimer: Timer?
@@ -1474,6 +1633,8 @@ class MentraLive: NSObject, SGCManager {
1474
1633
  private var centralManager: CBCentralManager?
1475
1634
 
1476
1635
  private var connectedPeripheral: CBPeripheral?
1636
+ /// Peripheral for the in-flight connect; used to ignore stale disconnect/fail callbacks.
1637
+ private var connectingPeripheral: CBPeripheral?
1477
1638
  private var txCharacteristic: CBCharacteristic?
1478
1639
  private var rxCharacteristic: CBCharacteristic?
1479
1640
  private let bes2700MtuLimit = 509
@@ -1483,6 +1644,13 @@ class MentraLive: NSObject, SGCManager {
1483
1644
  private var isScanning = false
1484
1645
  private var isConnecting = false
1485
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?
1486
1654
  private var reconnectAttempts = 0
1487
1655
  private var isNewVersion = false
1488
1656
  private var peerWireProtocolVersion = 0
@@ -1601,6 +1769,16 @@ class MentraLive: NSObject, SGCManager {
1601
1769
  // MARK: - React Native Interface
1602
1770
 
1603
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
+ }
1604
1782
 
1605
1783
  func findCompatibleDevices() {
1606
1784
  Bridge.log("Finding compatible Mentra Live glasses")
@@ -1622,7 +1800,89 @@ class MentraLive: NSObject, SGCManager {
1622
1800
  }
1623
1801
  }
1624
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
+
1625
1881
  func connectById(_ deviceName: String) {
1882
+ if pairingYieldActive {
1883
+ Bridge.log("LIVE: connectById blocked — pairing yield active")
1884
+ return
1885
+ }
1626
1886
  Bridge.log("connectById: \(deviceName)")
1627
1887
  // Save the device name for future reconnection
1628
1888
  UserDefaults.standard.set(deviceName, forKey: PREFS_DEVICE_NAME)
@@ -1647,7 +1907,7 @@ class MentraLive: NSObject, SGCManager {
1647
1907
  {
1648
1908
  Bridge.log("Found already-connected peripheral: \(name)")
1649
1909
  discoveredPeripherals[name] = peripheral
1650
- emitDiscoveredDevice(name)
1910
+ emitDiscoveredDeviceFromCache(name)
1651
1911
 
1652
1912
  // Check if this is the device we want
1653
1913
  if let savedDeviceName = UserDefaults.standard.string(
@@ -1673,6 +1933,10 @@ class MentraLive: NSObject, SGCManager {
1673
1933
  }
1674
1934
 
1675
1935
  @objc func disconnect() {
1936
+ if unpairFlushPending {
1937
+ Bridge.log("LIVE: disconnect deferred — waiting for unpair write")
1938
+ return
1939
+ }
1676
1940
  Bridge.log("LIVE: disconnect() -Disconnecting from Mentra Live glasses")
1677
1941
 
1678
1942
  // if rgbLedAuthorityClaimed {
@@ -1698,6 +1962,8 @@ class MentraLive: NSObject, SGCManager {
1698
1962
 
1699
1963
  /// Start the micbeat mechanism to keep LC3 audio streaming active
1700
1964
  private func startMicBeat() {
1965
+ // A resumed custom-audio stream starts a new sequence domain.
1966
+ DeviceManager.shared.resetMicSequenceBaseline()
1701
1967
  Bridge.log("LIVE: 🎤 Starting micbeat mechanism")
1702
1968
  micBeatCount = 0
1703
1969
 
@@ -2094,7 +2360,10 @@ class MentraLive: NSObject, SGCManager {
2094
2360
  // MARK: - BLE Scanning
2095
2361
 
2096
2362
  private func startScan() {
2097
- // guard !isScanning else { return }
2363
+ if pairingYieldActive {
2364
+ Bridge.log("LIVE: startScan blocked — pairing yield active")
2365
+ return
2366
+ }
2098
2367
 
2099
2368
  guard centralManager!.state == .poweredOn else {
2100
2369
  Bridge.log("Attempting to scan but bluetooth is not powered on.")
@@ -2102,6 +2371,7 @@ class MentraLive: NSObject, SGCManager {
2102
2371
  }
2103
2372
 
2104
2373
  Bridge.log("Starting BLE scan for Mentra Live glasses")
2374
+ discoveredAdvPairing.removeAll()
2105
2375
  isScanning = true
2106
2376
 
2107
2377
  startReadinessCheckLoop()
@@ -2118,11 +2388,9 @@ class MentraLive: NSObject, SGCManager {
2118
2388
 
2119
2389
  centralManager?.scanForPeripherals(withServices: nil, options: scanOptions)
2120
2390
 
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
- }
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()
2126
2394
 
2127
2395
  // var dName = DeviceManager.shared.deviceName
2128
2396
  // if dName.isEmpty {
@@ -2152,10 +2420,16 @@ class MentraLive: NSObject, SGCManager {
2152
2420
  // MARK: - Connection Management
2153
2421
 
2154
2422
  private func connectToDevice(_ peripheral: CBPeripheral) {
2423
+ if pairingYieldActive {
2424
+ Bridge.log("LIVE: connectToDevice blocked — pairing yield active")
2425
+ isConnecting = false
2426
+ return
2427
+ }
2155
2428
  Bridge.log("LIVE: Connecting to device: \(peripheral.identifier.uuidString)")
2156
2429
 
2157
2430
  isConnecting = true
2158
2431
  updateConnectionState(ConnTypes.CONNECTING)
2432
+ connectingPeripheral = peripheral
2159
2433
  connectedPeripheral = peripheral
2160
2434
  peripheral.delegate = self
2161
2435
 
@@ -2200,6 +2474,11 @@ class MentraLive: NSObject, SGCManager {
2200
2474
  return
2201
2475
  }
2202
2476
 
2477
+ if pairingYieldActive {
2478
+ Bridge.log("LIVE: Reconnection aborted - pairing yield active")
2479
+ return
2480
+ }
2481
+
2203
2482
  // Check if we've exceeded max attempts
2204
2483
  if reconnectAttempts >= MAX_RECONNECT_ATTEMPTS {
2205
2484
  Bridge.log("LIVE: Maximum reconnection attempts reached (\(MAX_RECONNECT_ATTEMPTS))")
@@ -2232,6 +2511,10 @@ class MentraLive: NSObject, SGCManager {
2232
2511
  guard let self = self else { return }
2233
2512
 
2234
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
+ }
2235
2518
  if self.connectedPeripheral == nil, !self.isKilled {
2236
2519
  // Check for last known device name to start scan
2237
2520
  if let lastDeviceName = UserDefaults.standard.string(
@@ -2461,11 +2744,21 @@ class MentraLive: NSObject, SGCManager {
2461
2744
 
2462
2745
  switch type {
2463
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
+ }
2464
2753
  // glasses_ready is a REMOTE wire-session reset: the glasses ran
2465
2754
  // onTransportReset() before sending it, so their side is back on legacy
2466
2755
  // framing regardless of what this side negotiated earlier. Start a fresh
2467
2756
  // wire epoch (clears v2-active state that would otherwise gate the
2468
2757
  // handshake off), then negotiate from the caps this message advertises.
2758
+ pairingYieldAwaitingReclaim = false
2759
+ pairingYieldActive = false
2760
+ pairingYieldEndWorkItem?.cancel()
2761
+ pairingYieldEndWorkItem = nil
2469
2762
  resetWireNegotiationState()
2470
2763
  parsePeerWireCaps(json)
2471
2764
  maybeSendWireHandshake()
@@ -2617,6 +2910,31 @@ class MentraLive: NSObject, SGCManager {
2617
2910
  case "pong":
2618
2911
  Bridge.log("LIVE: Received pong response - connection healthy")
2619
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
+
2620
2938
  case "imu_response", "imu_stream_response", "imu_gesture_response",
2621
2939
  "imu_gesture_subscribed", "imu_ack", "imu_error":
2622
2940
  // Handle IMU-related responses
@@ -2778,9 +3096,19 @@ class MentraLive: NSObject, SGCManager {
2778
3096
  if let systemTimeMs = fields["system_time_ms"] as? NSNumber {
2779
3097
  DeviceStore.shared.apply("glasses", "systemTimeMs", systemTimeMs.int64Value)
2780
3098
  }
3099
+ if let hotspotOtaVersion = fields["hotspot_ota_version"] as? NSNumber {
3100
+ DeviceStore.shared.apply(
3101
+ "glasses",
3102
+ "hotspotOtaVersion",
3103
+ hotspotOtaVersion.intValue
3104
+ )
3105
+ }
2781
3106
  if let bluetoothMacAddress = nonEmptyStringValue(fields, "bt_mac_address") {
2782
3107
  DeviceStore.shared.apply("glasses", "bluetoothMacAddress", bluetoothMacAddress)
2783
3108
  }
3109
+ if let wifiMacAddress = nonEmptyStringValue(fields, "wifi_mac_address") {
3110
+ DeviceStore.shared.apply("glasses", "wifiMacAddress", wifiMacAddress)
3111
+ }
2784
3112
  if let serialNumber = nonEmptyStringValue(fields, "serial_number") {
2785
3113
  DeviceStore.shared.apply("glasses", "serialNumber", serialNumber)
2786
3114
  }
@@ -3219,6 +3547,17 @@ class MentraLive: NSObject, SGCManager {
3219
3547
  sendJson(json, wakeUp: true)
3220
3548
  }
3221
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
+
3222
3561
  func sendSetSystemTime(_ timestampMs: Int64) {
3223
3562
  Bridge.log("LIVE: ⏰ Sending set_system_time: \(timestampMs)")
3224
3563
 
@@ -3338,7 +3677,6 @@ class MentraLive: NSObject, SGCManager {
3338
3677
  {
3339
3678
  json["ota_version_url"] = otaVersionUrl
3340
3679
  }
3341
-
3342
3680
  sendJson(json, wakeUp: true)
3343
3681
  }
3344
3682
 
@@ -3509,17 +3847,9 @@ class MentraLive: NSObject, SGCManager {
3509
3847
  return
3510
3848
  }
3511
3849
 
3512
- let sequenceNumber = Int8(bitPattern: data[1])
3850
+ let sequenceNumber = Int(data[1])
3513
3851
  let lc3Data = data.subdata(in: 2 ..< data.count)
3514
3852
 
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
3853
  // // Decode LC3 to PCM using existing PcmConverter
3524
3854
  // let pcmConverter = PcmConverter()
3525
3855
  // guard let pcmData = pcmConverter.decode(lc3Data) as? Data, pcmData.count > 0 else {
@@ -3533,7 +3863,7 @@ class MentraLive: NSObject, SGCManager {
3533
3863
  // Bridge.log(
3534
3864
  // "LIVE: Processed LC3 audio seq=\(sequenceNumber), \(lc3Data.count) bytes"
3535
3865
  // )
3536
- DeviceManager.shared.handleGlassesMicData(lc3Data, 40)
3866
+ DeviceManager.shared.handleGlassesMicData(lc3Data, 40, sequenceNumber: sequenceNumber)
3537
3867
 
3538
3868
  // Bridge.log(
3539
3869
  // "LIVE: Processed LC3 audio seq=\(sequenceNumber), \(lc3Data.count)→\(pcmData.count) bytes"
@@ -5201,8 +5531,71 @@ class MentraLive: NSObject, SGCManager {
5201
5531
 
5202
5532
  // MARK: - Event Emission
5203
5533
 
5204
- private func emitDiscoveredDevice(_ name: String, identifier: String = "", rssi: Int? = nil) {
5205
- 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
+ )
5206
5599
  }
5207
5600
 
5208
5601
  private func emitStopScanEvent() {
@@ -5291,6 +5684,9 @@ class MentraLive: NSObject, SGCManager {
5291
5684
  Bridge.log("Destroying MentraLiveManager")
5292
5685
 
5293
5686
  isKilled = true
5687
+ unpairFlushWorkItem?.cancel()
5688
+ unpairFlushWorkItem = nil
5689
+ unpairFlushPending = false
5294
5690
 
5295
5691
  // Stop scanning
5296
5692
  if isScanning {
@@ -5324,6 +5720,8 @@ class MentraLive: NSObject, SGCManager {
5324
5720
  DeviceStore.shared.apply("glasses", "hotspotGatewayIp", "")
5325
5721
 
5326
5722
  connectedPeripheral = nil
5723
+ discoveredPeripherals.removeAll()
5724
+ discoveredAdvPairing.removeAll()
5327
5725
  centralManager?.delegate = nil
5328
5726
  centralManager = nil
5329
5727
 
@@ -6533,6 +6931,17 @@ extension MentraLive {
6533
6931
  stopVideoRecording(requestId: requestId, webhookUrl: nil, authToken: nil)
6534
6932
  }
6535
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
+
6536
6945
  func stopVideoRecording(requestId: String, webhookUrl: String?, authToken: String?) {
6537
6946
  Bridge.log(
6538
6947
  "Stopping video recording on glasses: requestId=\(requestId), webhook=\((webhookUrl?.isEmpty ?? true) ? "none" : "set")"