@mentra/bluetooth-sdk 3.4.0-dev.993 → 3.4.0-dev.998

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 (68) hide show
  1. package/MIGRATION.md +61 -0
  2. package/README.md +47 -0
  3. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +6 -1
  4. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +8 -1
  5. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +48 -4
  6. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +21 -0
  7. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  8. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +2 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/connection/ConnectionModels.kt +5 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/GlassesAudioSetupPolicy.kt +64 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +788 -134
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLiveOwnerLossPolicy.kt +227 -0
  13. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingAdvertisement.kt +19 -0
  14. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingReadinessPolicy.kt +94 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingStateMachine.kt +411 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingStateStore.kt +124 -0
  17. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +50 -0
  18. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerOwnerLossTest.kt +146 -0
  19. package/android/src/test/java/com/mentra/bluetoothsdk/GlassesStatusAudioSetupTest.kt +51 -0
  20. package/android/src/test/java/com/mentra/bluetoothsdk/GlassesStatusClassicAudioTest.kt +2 -0
  21. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/GlassesAudioSetupPolicyTest.kt +65 -0
  22. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveAudioSetupReadinessTest.kt +231 -0
  23. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveOwnerLossPolicyTest.kt +368 -0
  24. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingAdvertisementParserTest.kt +20 -0
  25. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingReadinessPolicyTest.kt +136 -0
  26. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingStateMachineTest.kt +346 -0
  27. package/build/BluetoothSdk.types.d.ts +101 -0
  28. package/build/BluetoothSdk.types.d.ts.map +1 -1
  29. package/build/BluetoothSdk.types.js.map +1 -1
  30. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  31. package/build/_private/BluetoothSdkModule.js +1 -0
  32. package/build/_private/BluetoothSdkModule.js.map +1 -1
  33. package/build/generated/releaseMetadata.js +5 -5
  34. package/build/generated/releaseMetadata.js.map +1 -1
  35. package/build/index.d.ts +1 -1
  36. package/build/index.d.ts.map +1 -1
  37. package/build/index.js +3 -0
  38. package/build/index.js.map +1 -1
  39. package/ios/BluetoothSdkModule.swift +8 -2
  40. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  41. package/ios/Source/Bridge.swift +11 -3
  42. package/ios/Source/DeviceManager.swift +80 -6
  43. package/ios/Source/DeviceStore.swift +27 -0
  44. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  45. package/ios/Source/MentraBluetoothSDK.swift +10 -4
  46. package/ios/Source/errors/BluetoothSdkError.swift +5 -1
  47. package/ios/Source/sgcs/GlassesAudioSetupPolicy.swift +181 -0
  48. package/ios/Source/sgcs/MentraLive.swift +1053 -166
  49. package/ios/Source/sgcs/MentraLivePairingAdvertisement.swift +260 -1
  50. package/ios/Source/sgcs/MentraLivePairingStateMachine.swift +385 -0
  51. package/ios/Source/sgcs/MentraLivePairingStateStore.swift +121 -0
  52. package/ios/Source/sgcs/SGCManager.swift +7 -0
  53. package/ios/Source/sgcs/Simulated.swift +2 -0
  54. package/ios/Source/status/DeviceStatus.swift +35 -0
  55. package/ios/Source/types/DeviceModels.swift +8 -1
  56. package/ios/Source/utils/AudioSessionMonitor.swift +158 -5
  57. package/ios/Tests/BluetoothAudioRouteTests.swift +82 -0
  58. package/ios/Tests/GlassesAudioSetupPolicyTests.swift +191 -0
  59. package/ios/Tests/MentraLiveAudioSetupTests.swift +215 -0
  60. package/ios/Tests/MentraLiveConnectionOptionsTests.swift +55 -0
  61. package/ios/Tests/MentraLivePairingAdvertisementTests.swift +327 -0
  62. package/ios/Tests/MentraLivePairingStateMachineTests.swift +402 -0
  63. package/ios/Tests/MentraLiveSessionReadinessTests.swift +328 -26
  64. package/package.json +3 -2
  65. package/src/BluetoothSdk.types.ts +97 -0
  66. package/src/_private/BluetoothSdkModule.ts +1 -0
  67. package/src/generated/releaseMetadata.ts +5 -5
  68. package/src/index.ts +10 -0
@@ -728,28 +728,30 @@ extension MentraLive: CBCentralManagerDelegate {
728
728
  guard let self else { return }
729
729
  switch state {
730
730
  case .poweredOn:
731
- Bridge.log("LIVE: Bluetooth powered on")
732
- // If we have a saved device, try to reconnect
733
- if let savedDeviceName = UserDefaults.standard.string(forKey: PREFS_DEVICE_NAME),
734
- !savedDeviceName.isEmpty
731
+ self.pairFlow("central", "state=powered_on")
732
+ if self.pairingStore.isPaused {
733
+ self.startPairingWatchScan(reason: "powered_on")
734
+ } else if !self.automaticConnectBlocked(),
735
+ let savedDeviceName = UserDefaults.standard.string(forKey: PREFS_DEVICE_NAME),
736
+ !savedDeviceName.isEmpty
735
737
  {
736
- self.startScan()
738
+ self.attachKnownOrScan(savedDeviceName)
737
739
  }
738
740
 
739
741
  case .poweredOff:
740
- Bridge.log("LIVE: Bluetooth is powered off")
742
+ self.pairFlow("central", "state=powered_off")
741
743
  self.updateConnectionState(ConnTypes.DISCONNECTED)
742
744
 
743
745
  case .unauthorized:
744
- Bridge.log("LIVE: Bluetooth is unauthorized")
746
+ self.pairFlow("central", "state=unauthorized")
745
747
  self.updateConnectionState(ConnTypes.DISCONNECTED)
746
748
 
747
749
  case .unsupported:
748
- Bridge.log("LIVE: Bluetooth is unsupported")
750
+ self.pairFlow("central", "state=unsupported")
749
751
  self.updateConnectionState(ConnTypes.DISCONNECTED)
750
752
 
751
753
  default:
752
- Bridge.log("LIVE: Bluetooth state: \(state.rawValue)")
754
+ self.pairFlow("central", "state=\(state.rawValue)")
753
755
  }
754
756
  }
755
757
  }
@@ -759,7 +761,14 @@ extension MentraLive: CBCentralManagerDelegate {
759
761
  advertisementData: [String: Any] = [:],
760
762
  rssi: NSNumber? = nil
761
763
  ) {
762
- guard let name = peripheral.name else { return }
764
+ // The first iOS advertisement often has the name only in the packet, not on
765
+ // CBPeripheral. Dropping that packet hides glasses that are already in pairing mode.
766
+ guard let name = discoveredAdvertisementName(peripheral, advertisementData: advertisementData) else {
767
+ if manualDiscoveryActive, loggedUnnamedDiscoveries.insert(peripheral.identifier).inserted {
768
+ pairFlow("discover", "unnamed id=\(peripheral.identifier.uuidString)")
769
+ }
770
+ return
771
+ }
763
772
 
764
773
  // Check for compatible device names
765
774
  if name == "Xy_A" || name.hasPrefix("XyBLE_") || name.hasPrefix("MENTRA_LIVE_BLE")
@@ -767,60 +776,112 @@ extension MentraLive: CBCentralManagerDelegate {
767
776
  {
768
777
  let savedDeviceName = UserDefaults.standard.string(forKey: PREFS_DEVICE_NAME)
769
778
  let isReconnectTarget = savedDeviceName != nil && savedDeviceName == name
779
+ let discoveredAtMs = Self.monotonicNowMs()
780
+ if advertisesPairingFlag(advertisementData) {
781
+ Self.secureTrailerMemory.recordSecureTrailer(peripheral.identifier, nowMs: discoveredAtMs)
782
+ }
770
783
  if !isReconnectTarget && advertisesPairingFlag(advertisementData)
771
784
  && !isPairingDiscoverable(advertisementData)
772
785
  {
773
786
  // Keep nearby secure units visible with pairing-mode guidance, while RN blocks
774
787
  // the connection until a pairable advertisement arrives.
775
- Bridge.log(
776
- "LIVE: Nearby \(name) is secure firmware not in pairing mode — exposing as non-pairable"
788
+ pairFlow(
789
+ "discover",
790
+ "name=\(name) id=\(peripheral.identifier.uuidString) saved=\(savedDeviceName ?? "-") pairingMode=false secure=true connect=false"
777
791
  )
778
792
  discoveredPeripherals[name] = peripheral
779
- cacheAdvPairing(
793
+ rememberPeripheral(peripheral, name: name)
794
+ if cacheAdvPairing(
780
795
  name,
781
796
  pairingMode: false,
782
797
  pairingCode: nil,
783
798
  securePairingCapable: true
784
- )
785
- emitDiscoveredDeviceFromCache(
786
- name,
787
- identifier: peripheral.identifier.uuidString,
788
- rssi: rssi?.intValue
789
- )
799
+ ) {
800
+ emitDiscoveredDeviceFromCache(
801
+ name,
802
+ identifier: peripheral.identifier.uuidString,
803
+ rssi: rssi?.intValue
804
+ )
805
+ }
790
806
  return
791
807
  }
792
808
 
793
809
  let glassType = name == "Xy_A" ? "Standard" : "K900"
794
810
  let trailer = parseSecurePairingTrailer(advertisementData)
811
+ // A packet without the trailer from a peripheral that just showed it is a partial
812
+ // advertisement, not legacy firmware. Do not publish it: the scan list would show a
813
+ // secure unit that is not in pairing mode as pairable, with no caption.
814
+ let partialAdvertisement = Self.secureTrailerMemory.isPartialAdvertisement(
815
+ peripheral.identifier,
816
+ secureCapable: trailer.secureCapable,
817
+ nowMs: discoveredAtMs
818
+ )
795
819
  Bridge.log(
796
820
  trailer.secureCapable
797
821
  ? "Found compatible \(glassType) glasses device: \(name) (pairingMode=\(trailer.pairingMode))"
798
- : "Found compatible \(glassType) glasses device: \(name) (legacy firmware, pairable)"
822
+ : partialAdvertisement
823
+ ? "Found compatible \(glassType) glasses device: \(name) (partial advertisement, secure trailer seen recently)"
824
+ : "Found compatible \(glassType) glasses device: \(name) (legacy firmware, pairable)"
799
825
  )
800
826
 
801
827
  // Store the peripheral
802
828
  discoveredPeripherals[name] = peripheral
829
+ rememberPeripheral(peripheral, name: name)
803
830
 
804
- cacheAdvPairing(
831
+ if !partialAdvertisement, cacheAdvPairing(
805
832
  name,
806
833
  // Field firmware has no pairing flag. Treat it as pairable so the
807
834
  // new Mentra App can still find old glasses.
808
835
  pairingMode: trailer.secureCapable ? trailer.pairingMode : true,
809
836
  pairingCode: trailer.pairingCode,
810
837
  securePairingCapable: trailer.secureCapable
838
+ ) {
839
+ emitDiscoveredDeviceFromCache(
840
+ name,
841
+ identifier: peripheral.identifier.uuidString,
842
+ rssi: rssi?.intValue
843
+ )
844
+ }
845
+
846
+ let isSavedGlasses = savedDeviceName == name
847
+ if isSavedGlasses, trailer.secureCapable {
848
+ observeSavedGlassesAdvertisement(trailer)
849
+ }
850
+ if isSavedGlasses, automaticConnectBlocked() {
851
+ // Paused for a pairing window or rejected: observe only. The watch scan keeps
852
+ // reading the advertisement so a cleared pairing flag can end the pause.
853
+ if !pairingWatchScanActive {
854
+ pairFlow(
855
+ "discover",
856
+ "name=\(name) id=\(peripheral.identifier.uuidString) action=wait state=\(pairingStore.describe())"
857
+ )
858
+ }
859
+ return
860
+ }
861
+ let waitForWindow = MentraLiveConnectionAttemptPolicy.shouldStandDownInsteadOfReconnect(
862
+ pairingMode: trailer.secureCapable && trailer.pairingMode,
863
+ savedGlasses: isSavedGlasses,
864
+ explicitPairing: isExplicitPairingAttempt()
811
865
  )
812
- emitDiscoveredDeviceFromCache(
813
- name,
814
- identifier: peripheral.identifier.uuidString,
815
- rssi: rssi?.intValue
866
+ if waitForWindow {
867
+ // The saved glasses are in a pairing window this phone never heard about.
868
+ // Connecting would race the new phone for the single BLE slot; keep watching
869
+ // and connect once the flag clears.
870
+ if !pairingWatchScanActive {
871
+ pairFlow(
872
+ "discover",
873
+ "name=\(name) id=\(peripheral.identifier.uuidString) action=wait_pairing_mode"
874
+ )
875
+ }
876
+ startPairingWatchScan(reason: "saved_glasses_pairing_mode")
877
+ return
878
+ }
879
+ let willConnect = isSavedGlasses
880
+ pairFlow(
881
+ "discover",
882
+ "name=\(name) id=\(peripheral.identifier.uuidString) saved=\(savedDeviceName ?? "-") pairingMode=\(trailer.pairingMode) secure=\(trailer.secureCapable) connect=\(willConnect)"
816
883
  )
817
-
818
- // Check if this is the device we want to connect to
819
- if let savedDeviceName = UserDefaults.standard.string(forKey: PREFS_DEVICE_NAME),
820
- savedDeviceName == name
821
- {
822
- Bridge.log("Found our remembered device by name, connecting: \(name)")
823
- // stopScan()
884
+ if willConnect {
824
885
  centralManager?.stopScan()
825
886
  isScanning = false
826
887
  connectToDevice(peripheral)
@@ -842,7 +903,7 @@ extension MentraLive: CBCentralManagerDelegate {
842
903
  guard let self else { return }
843
904
  let matchesActiveAttempt = self.connectingPeripheral === peripheral
844
905
  guard MentraLiveConnectionAttemptPolicy.shouldAcceptDidConnect(
845
- pairingYieldActive: self.pairingYieldActive,
906
+ pairingYieldActive: self.automaticConnectBlocked(),
846
907
  matchesActiveAttempt: matchesActiveAttempt
847
908
  ) else {
848
909
  Bridge.log(
@@ -859,7 +920,11 @@ extension MentraLive: CBCentralManagerDelegate {
859
920
  self.centralManager?.cancelPeripheralConnection(peripheral)
860
921
  return
861
922
  }
862
- Bridge.log("Connected to GATT server, discovering services...")
923
+ self.pairFlow(
924
+ "gatt_connect",
925
+ "id=\(peripheral.identifier.uuidString) name=\(peripheral.name ?? "-")"
926
+ )
927
+ Self.clearStaleIosBondPrompt()
863
928
 
864
929
  self.stopConnectionTimeout()
865
930
  self.clearPendingReconnectTracking()
@@ -913,11 +978,19 @@ extension MentraLive: CBCentralManagerDelegate {
913
978
  let isCurrent =
914
979
  self.connectedPeripheral === peripheral || self.connectingPeripheral === peripheral
915
980
  if !isCurrent {
916
- Bridge.log("LIVE: Ignoring stale disconnect for \(peripheral.identifier)")
981
+ self.pairFlow("gatt_disconnect", "ignored id=\(peripheral.identifier.uuidString)")
917
982
  return
918
983
  }
919
984
  self.connectingPeripheral = nil
920
985
  self.clearPendingReconnectTracking()
986
+ let nsError = error as NSError?
987
+ self.pairFlow(
988
+ "gatt_disconnect",
989
+ "id=\(peripheral.identifier.uuidString) name=\(peripheral.name ?? "-") domain=\(nsError?.domain ?? "-") code=\(nsError?.code ?? 0)"
990
+ )
991
+ Bridge.log(
992
+ "PAIRFLOW: ble_disconnect id=\(peripheral.identifier.uuidString) domain=\(nsError?.domain ?? "none") code=\(nsError?.code ?? 0) killed=\(self.isKilled) explicitPairing=\(self.isExplicitPairingAttempt())"
993
+ )
921
994
  Bridge.log("LIVE: Disconnected from GATT server")
922
995
 
923
996
  self.isConnecting = false
@@ -941,9 +1014,18 @@ extension MentraLive: CBCentralManagerDelegate {
941
1014
  // Clean up characteristics
942
1015
  self.txCharacteristic = nil
943
1016
  self.rxCharacteristic = nil
1017
+ let waitingForOwnerCommit = self.awaitingSecurePairingConfirmation
1018
+ self.resetSecurePairingSession()
944
1019
 
945
- if self.pairingYieldAwaitingReclaim, Self.isPairingAuthFailure(error) {
946
- self.clearSavedGlassesAfterOwnerLoss(reason: "ios_auth_fail")
1020
+ if self.stopAfterConnectFailure(error) {
1021
+ return
1022
+ }
1023
+
1024
+ // The system pairing sheet was dismissed, or the bond never finished.
1025
+ // Reconnecting would leave the loading screen up with no failure.
1026
+ if waitingForOwnerCommit && self.isExplicitPairingAttempt() {
1027
+ Bridge.log("LIVE: secure pairing link dropped before owner commit")
1028
+ Bridge.sendPairFailureEvent("errors:pairingCouldNotStart")
947
1029
  return
948
1030
  }
949
1031
 
@@ -962,11 +1044,15 @@ extension MentraLive: CBCentralManagerDelegate {
962
1044
  let isCurrent =
963
1045
  self.connectedPeripheral === peripheral || self.connectingPeripheral === peripheral
964
1046
  if !isCurrent {
965
- Bridge.log("LIVE: Ignoring stale connect failure for \(peripheral.identifier)")
1047
+ self.pairFlow("gatt_connect_fail", "ignored id=\(peripheral.identifier.uuidString)")
966
1048
  return
967
1049
  }
968
1050
  self.connectingPeripheral = nil
969
- Bridge.log("LIVE: Failed to connect to peripheral: \(errorDescription)")
1051
+ let nsError = error as NSError?
1052
+ self.pairFlow(
1053
+ "gatt_connect_fail",
1054
+ "id=\(peripheral.identifier.uuidString) name=\(peripheral.name ?? "-") domain=\(nsError?.domain ?? "-") code=\(nsError?.code ?? 0) \(errorDescription)"
1055
+ )
970
1056
 
971
1057
  self.stopConnectionTimeout()
972
1058
  self.clearPendingReconnectTracking()
@@ -975,8 +1061,7 @@ extension MentraLive: CBCentralManagerDelegate {
975
1061
  self.connectedPeripheral = nil
976
1062
  self.updateConnectionState(ConnTypes.DISCONNECTED)
977
1063
 
978
- if self.pairingYieldAwaitingReclaim, Self.isPairingAuthFailure(error) {
979
- self.clearSavedGlassesAfterOwnerLoss(reason: "ios_auth_fail")
1064
+ if self.stopAfterConnectFailure(error) {
980
1065
  return
981
1066
  }
982
1067
 
@@ -1078,7 +1163,10 @@ extension MentraLive: CBPeripheralDelegate {
1078
1163
 
1079
1164
  // Check if we have both characteristics
1080
1165
  if self.txCharacteristic != nil, let rx = self.rxCharacteristic {
1081
- Bridge.log("LIVE: ✅ Both TX and RX characteristics found - BLE connection ready")
1166
+ self.pairFlow(
1167
+ "gatt_ready",
1168
+ "id=\(peripheral.identifier.uuidString) name=\(peripheral.name ?? "-") mtu=\(max(23, min(peripheral.maximumWriteValueLength(for: .withResponse) + 3, peripheral.maximumWriteValueLength(for: .withoutResponse) + 3)))"
1169
+ )
1082
1170
  Bridge.log("LIVE: 🔄 Waiting for glasses SOC to become ready...")
1083
1171
 
1084
1172
  // Don't set connected=true here - wait for SOC to be ready (fullyBooted=true)
@@ -1222,16 +1310,25 @@ extension MentraLive: CBPeripheralDelegate {
1222
1310
  let uuid = characteristic.uuid
1223
1311
  let isNotifying = characteristic.isNotifying
1224
1312
  let errorDescription = error?.localizedDescription
1313
+ let notifyError = error
1225
1314
  DispatchQueue.main.async { [weak self] in
1226
1315
  guard let self else { return }
1227
1316
  if let errorDescription {
1228
1317
  Bridge.log("LIVE: Error updating notification state: \(errorDescription)")
1318
+ // Insufficient authentication/encryption on the RX subscribe is the glasses
1319
+ // refusing this phone's keys.
1320
+ if uuid == self.RX_CHAR_UUID, Self.isPairingAuthFailure(notifyError),
1321
+ self.recordAuthRejection(reason: "ios_notify_auth_\((notifyError as NSError?)?.code ?? 0)")
1322
+ {
1323
+ return
1324
+ }
1229
1325
  } else {
1230
1326
  Bridge.log("Notification state updated for \(uuid): \(isNotifying ? "ON" : "OFF")")
1231
1327
 
1232
1328
  if uuid == self.RX_CHAR_UUID, isNotifying {
1233
1329
  Bridge.log("LIVE: 🔔 Ready to receive data via notifications")
1234
1330
  self.enableAncsRelayIfAuthorized()
1331
+ self.sendPairingSessionMessages(reason: "gatt_ready")
1235
1332
  }
1236
1333
  }
1237
1334
  }
@@ -1264,12 +1361,25 @@ enum MentraLiveConnectionState {
1264
1361
  }
1265
1362
 
1266
1363
  enum MentraLiveConnectionOptions {
1267
- static func coreBluetoothOptions(requiresAncs: Bool) -> [String: Any]? {
1364
+ /// `enableTransportBridging` asks iOS to bring up the Classic profiles (A2DP/HFP)
1365
+ /// after the BLE link is up (CBConnectPeripheralOptionEnableTransportBridgingKey,
1366
+ /// iOS 13+). The product default is on; this helper still omits the key unless
1367
+ /// the caller passes true.
1368
+ static func coreBluetoothOptions(
1369
+ requiresAncs: Bool,
1370
+ enableTransportBridging: Bool = false
1371
+ ) -> [String: Any]? {
1268
1372
  #if os(macOS)
1269
1373
  return nil
1270
1374
  #else
1271
- guard requiresAncs else { return nil }
1272
- return [CBConnectPeripheralOptionRequiresANCS: true]
1375
+ var options = [String: Any]()
1376
+ if requiresAncs {
1377
+ options[CBConnectPeripheralOptionRequiresANCS] = true
1378
+ }
1379
+ if enableTransportBridging {
1380
+ options[CBConnectPeripheralOptionEnableTransportBridgingKey] = true
1381
+ }
1382
+ return options.isEmpty ? nil : options
1273
1383
  #endif
1274
1384
  }
1275
1385
  }
@@ -1328,16 +1438,23 @@ class MentraLive: NSObject, SGCManager {
1328
1438
  let pairingMode: Bool
1329
1439
  let pairingCode: String?
1330
1440
  let secureCapable: Bool
1441
+ let pairingGeneration: Int?
1442
+ let ownerChanged: Bool
1331
1443
  }
1332
1444
 
1333
1445
  private func parseSecurePairingTrailer(_ advertisementData: [String: Any]) -> SecurePairingTrailer {
1334
1446
  guard let advertisement = pairingAdvertisement(advertisementData) else {
1335
- return SecurePairingTrailer(pairingMode: false, pairingCode: nil, secureCapable: false)
1447
+ return SecurePairingTrailer(
1448
+ pairingMode: false, pairingCode: nil, secureCapable: false, pairingGeneration: nil,
1449
+ ownerChanged: false
1450
+ )
1336
1451
  }
1337
1452
  return SecurePairingTrailer(
1338
1453
  pairingMode: advertisement.pairingMode,
1339
1454
  pairingCode: advertisement.pairingCode,
1340
- secureCapable: true
1455
+ secureCapable: true,
1456
+ pairingGeneration: advertisement.pairingGeneration,
1457
+ ownerChanged: advertisement.ownerChanged
1341
1458
  )
1342
1459
  }
1343
1460
 
@@ -1376,6 +1493,10 @@ class MentraLive: NSObject, SGCManager {
1376
1493
  DeviceStore.shared.apply("glasses", "hotspotOtaVersion", 0)
1377
1494
  }
1378
1495
  connectionState = state
1496
+ pairFlow(
1497
+ "conn_state",
1498
+ "state=\(state) gatt=\(txCharacteristic != nil) connecting=\(isConnecting) killed=\(isKilled) scanning=\(isScanning) id=\(connectedPeripheral?.identifier.uuidString ?? connectingPeripheral?.identifier.uuidString ?? "-") name=\(connectedPeripheral?.name ?? connectingPeripheral?.name ?? "-")"
1499
+ )
1379
1500
  DeviceStore.shared.apply("glasses", "connectionState", state)
1380
1501
  // Drop OTA caches when fully disconnected — avoids leaking session/step state from
1381
1502
  // a previous pairing into the next one (would otherwise surface as wrong overall_percent
@@ -1392,6 +1513,7 @@ class MentraLive: NSObject, SGCManager {
1392
1513
  DeviceStore.shared.apply("glasses", "signalStrength", -1)
1393
1514
  DeviceStore.shared.apply("glasses", "signalStrengthUpdatedAt", 0)
1394
1515
  resetOtaCache()
1516
+ endAudioSetupSession()
1395
1517
  }
1396
1518
  }
1397
1519
 
@@ -1426,9 +1548,12 @@ class MentraLive: NSObject, SGCManager {
1426
1548
 
1427
1549
  func forget() {
1428
1550
  Bridge.log("LIVE: Forgetting Mentra Live glasses")
1551
+ Self.secureTrailerMemory.clear()
1429
1552
 
1430
1553
  // Clear saved device name so the pairing filter is not bypassed on rescan
1431
1554
  UserDefaults.standard.removeObject(forKey: PREFS_DEVICE_NAME)
1555
+ UserDefaults.standard.removeObject(forKey: PREFS_PERIPHERAL_IDS)
1556
+ pairFlow("forget", "cleared_saved_peripheral")
1432
1557
 
1433
1558
  // Stop scanning first
1434
1559
  if isScanning {
@@ -1451,6 +1576,9 @@ class MentraLive: NSObject, SGCManager {
1451
1576
 
1452
1577
  var type = "Mentra Live"
1453
1578
  var hasMic = true
1579
+ var publishesAudioSetup: Bool {
1580
+ true
1581
+ }
1454
1582
 
1455
1583
  func setMicEnabled(_ enabled: Bool) {
1456
1584
  Bridge.log("LIVE: 🎤 Microphone state change requested: \(enabled)")
@@ -1558,6 +1686,7 @@ class MentraLive: NSObject, SGCManager {
1558
1686
 
1559
1687
  /// Device Settings Keys
1560
1688
  private let PREFS_DEVICE_NAME = "MentraLiveLastConnectedDeviceName"
1689
+ private let PREFS_PERIPHERAL_IDS = "MentraLivePeripheralIds"
1561
1690
 
1562
1691
  // MARK: - Properties
1563
1692
 
@@ -1583,10 +1712,49 @@ class MentraLive: NSObject, SGCManager {
1583
1712
  private var manualDiscoveryActive = false
1584
1713
  private var isConnecting = false
1585
1714
  private var isKilled = false
1586
- /// Glasses opened pairing window — stand down without forgetting identity/bonds.
1587
- private var pairingYieldActive = false
1588
- private var pairingYieldAwaitingReclaim = false
1589
- private var pairingYieldEndWorkItem: DispatchWorkItem?
1715
+ /// Pairing protocol this phone speaks (`phone_hello.pairing_protocol`). The glasses send
1716
+ /// window notices only to phones that announced protocol 4 or later.
1717
+ static let pairingProtocolVersion = 4
1718
+ /// Pause, takeover and rejection state for the saved glasses. Outlives this manager.
1719
+ private let pairingStore = MentraLivePairingStateStore.shared
1720
+ /// Ends the pairing-mode pause at its deadline.
1721
+ private var pairingPauseWorkItem: DispatchWorkItem?
1722
+ /// The running scan is the duplicate-reporting watch scan, not a connect scan.
1723
+ private var pairingWatchScanActive = false
1724
+ private var phoneHelloSentThisSession = false
1725
+ private var pairSelectSentThisSession = false
1726
+ /// `pairing_info` arrived on this BLE session, so the glasses run secure firmware.
1727
+ private var pairingInfoSeenThisSession = false
1728
+ /// `pairing_info.auto_audio` on this BLE session. Old firmware never sends it.
1729
+ private var pairingInfoAutoAudio = false
1730
+ /// Peripherals that advertised the secure-pairing trailer in the last few seconds.
1731
+ /// CoreBluetooth sometimes reports an advertisement without the manufacturer data that
1732
+ /// carries the trailer, so a packet without it right after one with it is partial, not
1733
+ /// legacy firmware. Nothing is persisted: glasses rolled back to legacy firmware must list
1734
+ /// as legacy again.
1735
+ private static var secureTrailerMemory = MentraLiveSecureTrailerMemory()
1736
+ /// Retired persisted name set that hid rolled-back glasses for good. Removed on startup.
1737
+ private static let retiredSecurePairingNamesKey = "mentra_live_secure_pairing_names"
1738
+ /// glasses_ready arrived for a secure pairing target before pairing_info confirmed the bond.
1739
+ private var awaitingSecurePairingConfirmation = false
1740
+ /// pairing_info from secure firmware arrived on this BLE session.
1741
+ private var securePairingConfirmed = false
1742
+ private var securePairingWaitWorkItem: DispatchWorkItem?
1743
+ /// glasses_ready arrived for an explicit pair whose capability is unknown; waiting for
1744
+ /// pairing_info to tell secure from legacy firmware.
1745
+ private var awaitingPairingInfoForUnknownCapability = false
1746
+ private var unknownCapabilityWaitWorkItem: DispatchWorkItem?
1747
+ /// Marks the saved glasses as legacy firmware when a ready session gets no pairing_info.
1748
+ private var secureFirmwareRefreshWorkItem: DispatchWorkItem?
1749
+ /// Internal so tests can shorten the waits.
1750
+ var unknownCapabilityPairingInfoWaitMs = MentraLiveConnectionAttemptPolicy.unknownCapabilityPairingInfoWaitMs
1751
+ var secureFirmwareRefreshMs = MentraLiveConnectionAttemptPolicy.secureFirmwareRefreshMs
1752
+ /// `GlassesStatus.audioSetup` for this driver (see `GlassesAudioSetupPolicy`).
1753
+ private var audioSetup = GlassesAudioSetupTracker()
1754
+ private var audioSetupDeadlineWorkItem: DispatchWorkItem?
1755
+ private var audioSetupStoreListenerId: String?
1756
+ /// Monotonic milliseconds. Internal so tests can drive the audio-setup clock.
1757
+ var audioSetupNowMs: () -> Int = { MentraLive.monotonicNowMs() }
1590
1758
  /// Hold GATT teardown until the unpair write can leave the phone.
1591
1759
  private var unpairFlushPending = false
1592
1760
  private var unpairFlushWorkItem: DispatchWorkItem?
@@ -1706,6 +1874,10 @@ class MentraLive: NSObject, SGCManager {
1706
1874
  private var pendingReconnectSettleWorkItem: DispatchWorkItem?
1707
1875
  private var reconnectionWorkItem: DispatchWorkItem?
1708
1876
  private var requiresAncs = true
1877
+ /// Experimental iOS transport bridging (Classic profiles after BLE connect). Off by default.
1878
+ /// Static so it can be set from `ConnectOptions` without threading a parameter
1879
+ /// through `DeviceManager`; it applies to every connect issued after it is set.
1880
+ static var enableTransportBridging = true
1709
1881
 
1710
1882
  // MARK: - Initialization
1711
1883
 
@@ -1725,6 +1897,10 @@ class MentraLive: NSObject, SGCManager {
1725
1897
  } else {
1726
1898
  Bridge.log("LIVE: 🎵 Phone audio monitor disabled (BLOCK_AUDIO_DUPLEX=false)")
1727
1899
  }
1900
+
1901
+ UserDefaults.standard.removeObject(forKey: Self.retiredSecurePairingNamesKey)
1902
+ restorePairingState()
1903
+ observeAudioSetupInputs()
1728
1904
  }
1729
1905
 
1730
1906
  deinit {
@@ -1745,12 +1921,15 @@ class MentraLive: NSObject, SGCManager {
1745
1921
  // MARK: - React Native Interface
1746
1922
 
1747
1923
  private var discoveredPeripherals = [String: CBPeripheral]() // name -> peripheral
1924
+ /// Advertisements that arrived with no name. Logged once so a pairing scan that
1925
+ /// sees the glasses but cannot list them is visible in the phone log.
1926
+ private var loggedUnnamedDiscoveries = Set<UUID>()
1748
1927
  /// Last advertisement pairing trailer for a discovered name. Re-emits (already-
1749
1928
  /// connected / scan replay) must not drop these flags: omitting them makes a
1750
1929
  /// secure idle unit look like pairable legacy firmware.
1751
1930
  private var discoveredAdvPairing = [String: CachedAdvPairing]()
1752
1931
 
1753
- private struct CachedAdvPairing {
1932
+ private struct CachedAdvPairing: Equatable {
1754
1933
  let pairingMode: Bool
1755
1934
  let pairingCode: String?
1756
1935
  let securePairingCapable: Bool
@@ -1779,20 +1958,67 @@ class MentraLive: NSObject, SGCManager {
1779
1958
  }
1780
1959
  }
1781
1960
 
1782
- func isPairingYieldActive() -> Bool {
1783
- pairingYieldActive
1961
+ /// Automatic connects wait: the saved glasses are in a pairing window or no longer accept
1962
+ /// this phone. An explicit pair is never blocked.
1963
+ private func automaticConnectBlocked() -> Bool {
1964
+ !pairingStore.allowsAutomaticReconnect && !isExplicitPairingAttempt()
1784
1965
  }
1785
1966
 
1786
- private func enterPairingYield(windowMs: Int) {
1787
- cancelPendingReconnect(reason: "pairing_yield")
1788
- pairingYieldActive = true
1789
- pairingYieldAwaitingReclaim = false
1790
- pairingYieldEndWorkItem?.cancel()
1791
- if isScanning {
1792
- stopScan()
1793
- }
1794
- // Publish disconnect immediately for Phone A UI. didDisconnectPeripheral may lag
1795
- // or race with cancelPeripheralConnection; do not wait on it for stand-down.
1967
+ private func applyPairingTransition(_ transition: MentraLivePairingTransition) {
1968
+ switch transition.effect {
1969
+ case .none:
1970
+ break
1971
+ case .pause:
1972
+ enterPairingPause()
1973
+ case .resume:
1974
+ resumeAfterPairingPause()
1975
+ case .reject:
1976
+ stopAfterPairingRejected()
1977
+ }
1978
+ }
1979
+
1980
+ /// The glasses opened a pairing window. Leave the link, keep the saved glasses and the bonds,
1981
+ /// and make no automatic connection until the window ends. Probing GATT during the window
1982
+ /// races the new phone for the single BLE slot. The watch scan only reads the advertisement.
1983
+ private func enterPairingPause() {
1984
+ leaveGlassesLink(reason: "pairing_mode")
1985
+ armPairingPauseTimer()
1986
+ startPairingWatchScan(reason: "pairing_pause")
1987
+ }
1988
+
1989
+ /// The window is over. Reconnect through a scan so the post-window advertisement
1990
+ /// (`OWNER_CHANGED`) is read before any GATT connect.
1991
+ private func resumeAfterPairingPause() {
1992
+ cancelPairingPauseTimer()
1993
+ if isScanning, !manualDiscoveryActive {
1994
+ centralManager?.stopScan()
1995
+ isScanning = false
1996
+ }
1997
+ guard !isKilled, connectedPeripheral == nil else { return }
1998
+ Bridge.log("LIVE: Pairing pause ended — resuming reconnect to saved glasses")
1999
+ reconnectAttempts = 0
2000
+ handleReconnection()
2001
+ }
2002
+
2003
+ /// The glasses no longer accept this phone. Stop reconnecting, keep the saved glasses so the
2004
+ /// app can offer Pair again or forget. The manager stays alive; every automatic path checks
2005
+ /// the pairing state.
2006
+ private func stopAfterPairingRejected() {
2007
+ cancelPairingPauseTimer()
2008
+ if isExplicitPairingAttempt() { return }
2009
+ leaveGlassesLink(reason: "pairing_rejected")
2010
+ }
2011
+
2012
+ /// Drop the BLE link and every session flag without destroying the manager.
2013
+ private func leaveGlassesLink(reason: String) {
2014
+ cancelPendingReconnect(reason: reason)
2015
+ cancelSecurePairingWait()
2016
+ if isScanning, !manualDiscoveryActive {
2017
+ centralManager?.stopScan()
2018
+ isScanning = false
2019
+ }
2020
+ // Publish the disconnect immediately. didDisconnectPeripheral may lag or race with
2021
+ // cancelPeripheralConnection, and is ignored once connectedPeripheral is nil.
1796
2022
  isConnecting = false
1797
2023
  clearPhysicalReadiness()
1798
2024
  connected = false
@@ -1800,6 +2026,9 @@ class MentraLive: NSObject, SGCManager {
1800
2026
  streamControlVersion = 0
1801
2027
  readinessCompletedThisBleSession = false
1802
2028
  rgbLedAuthorityClaimed = false
2029
+ lastSentMicTuningBody = nil
2030
+ wearTuningQueriedThisLink = false
2031
+ resetSecurePairingSession()
1803
2032
  stopAllTimers()
1804
2033
  closeL2capFileChannel()
1805
2034
  txCharacteristic = nil
@@ -1810,66 +2039,282 @@ class MentraLive: NSObject, SGCManager {
1810
2039
  }
1811
2040
  connectedPeripheral = nil
1812
2041
  connectingPeripheral = nil
1813
- // Stand down for the whole window. Probing GATT during yield races the new
1814
- // phone for the single BLE slot and is exactly what entering_pairing_mode forbids.
2042
+ }
1815
2043
 
2044
+ private func armPairingPauseTimer() {
2045
+ cancelPairingPauseTimer()
2046
+ guard pairingStore.isPaused else { return }
2047
+ let remaining = pairingStore.remainingPauseMs()
1816
2048
  let work = DispatchWorkItem { [weak self] in
1817
2049
  guard let self else { return }
1818
- Bridge.log("LIVE: Pairing yield ended — resume reconnect if still owned")
1819
- self.pairingYieldActive = false
1820
- self.pairingYieldAwaitingReclaim = true
1821
- self.pairingYieldEndWorkItem = nil
1822
- if !self.isKilled, self.connectedPeripheral == nil {
1823
- self.handleReconnection()
2050
+ self.pairingPauseWorkItem = nil
2051
+ let transition = self.pairingStore.update("pause_deadline") {
2052
+ $0.onTick(nowMs: MentraLivePairingStateStore.nowMs())
2053
+ }
2054
+ if transition.effect == .none, self.pairingStore.isPaused {
2055
+ self.armPairingPauseTimer()
2056
+ return
1824
2057
  }
2058
+ self.applyPairingTransition(transition)
2059
+ }
2060
+ pairingPauseWorkItem = work
2061
+ DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(max(remaining, 0)), execute: work)
2062
+ }
2063
+
2064
+ private func cancelPairingPauseTimer() {
2065
+ pairingPauseWorkItem?.cancel()
2066
+ pairingPauseWorkItem = nil
2067
+ }
2068
+
2069
+ /// Duplicate-reporting scan that only observes the saved glasses' advertisement, so a
2070
+ /// cleared pairing flag is seen while the phone waits.
2071
+ private func startPairingWatchScan(reason: String) {
2072
+ if centralManager == nil {
2073
+ centralManager = CBCentralManager(
2074
+ delegate: self, queue: bluetoothQueue,
2075
+ options: ["CBCentralManagerOptionShowPowerAlertKey": 0]
2076
+ )
2077
+ return
2078
+ }
2079
+ guard !isKilled, let central = centralManager, central.state == .poweredOn else { return }
2080
+ if isScanning, pairingWatchScanActive { return }
2081
+ if isScanning {
2082
+ central.stopScan()
2083
+ }
2084
+ isScanning = true
2085
+ pairingWatchScanActive = true
2086
+ central.scanForPeripherals(
2087
+ withServices: nil,
2088
+ options: [CBCentralManagerScanOptionAllowDuplicatesKey: true]
2089
+ )
2090
+ pairFlow("scan", "start trigger=pairing_watch reason=\(reason) state=\(pairingStore.describe())")
2091
+ }
2092
+
2093
+ private func observeSavedGlassesAdvertisement(_ trailer: SecurePairingTrailer) {
2094
+ let wasArmed = pairingStore.verificationArmed
2095
+ let transition = pairingStore.update("advert") {
2096
+ $0.onAdvertisement(
2097
+ pairingMode: trailer.pairingMode,
2098
+ ownerChanged: trailer.ownerChanged,
2099
+ generation: trailer.pairingGeneration,
2100
+ nowMs: MentraLivePairingStateStore.nowMs()
2101
+ )
2102
+ }
2103
+ if !wasArmed, pairingStore.verificationArmed {
2104
+ pairFlow("takeover_check", "owner_changed window=\(trailer.pairingGeneration ?? -1) action=verify_once")
2105
+ }
2106
+ applyPairingTransition(transition)
2107
+ }
2108
+
2109
+ /// `phone_hello` on every secure session (the glasses send window notices only to protocol
2110
+ /// 4 phones) and `pair_select` once on an explicit attempt, as soon as GATT is ready. Both
2111
+ /// callers run on a live link: the RX subscribe and a received `pairing_info`.
2112
+ private func sendPairingSessionMessages(reason: String) {
2113
+ let secureTarget = isSecurePairingTarget()
2114
+ // A trailer this scan just saw is a hint to speak the protocol at all; pair_select below
2115
+ // follows only the selected target.
2116
+ let recentSecureTrailer = connectedPeripheral.map {
2117
+ Self.secureTrailerMemory.hasRecentSecureTrailer($0.identifier, nowMs: Self.monotonicNowMs())
2118
+ } ?? false
2119
+ guard secureTarget || isSavedGlassesSecureFirmware() || pairingInfoSeenThisSession || recentSecureTrailer
2120
+ else { return }
2121
+ if !phoneHelloSentThisSession {
2122
+ phoneHelloSentThisSession = true
2123
+ pairFlow("phone_hello", "protocol=\(Self.pairingProtocolVersion) trigger=\(reason)")
2124
+ sendJson(["type": "phone_hello", "pairing_protocol": Self.pairingProtocolVersion], wakeUp: false, requireAck: false)
2125
+ }
2126
+ if !pairSelectSentThisSession,
2127
+ MentraLiveConnectionAttemptPolicy.shouldSendPairSelect(
2128
+ explicitPairing: isExplicitPairingAttempt(),
2129
+ securePairingTarget: secureTarget
2130
+ )
2131
+ {
2132
+ pairSelectSentThisSession = true
2133
+ pairFlow("pair_select", "trigger=\(reason)")
2134
+ sendJson(["type": "pair_select"], wakeUp: false, requireAck: false)
1825
2135
  }
1826
- pairingYieldEndWorkItem = work
1827
- DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(windowMs), execute: work)
1828
2136
  }
1829
2137
 
1830
- /// Pairing/auth failures after yield — CoreBluetooth codes, not localized strings.
1831
- /// Connection timeouts are not owner-loss.
1832
- private static func isPairingAuthFailure(_ error: Error?) -> Bool {
2138
+ private func resetSecurePairingSession() {
2139
+ securePairingConfirmed = false
2140
+ awaitingSecurePairingConfirmation = false
2141
+ cancelSecurePairingWait()
2142
+ cancelUnknownCapabilityWait()
2143
+ cancelSecureFirmwareRefresh()
2144
+ phoneHelloSentThisSession = false
2145
+ pairSelectSentThisSession = false
2146
+ pairingInfoSeenThisSession = false
2147
+ pairingInfoAutoAudio = false
2148
+ }
2149
+
2150
+ /// Owner-loss destroy marks the manager dead. A later connect must build a new one.
2151
+ /// A live manager already holds the peripheral found during the pairing scan.
2152
+ var isManagerKilled: Bool {
2153
+ isKilled
2154
+ }
2155
+
2156
+ /// Pairing/auth failures: CBError 14 or insufficient authentication/encryption.
2157
+ /// Connection timeouts and the glasses' 0x14 refusal are not.
2158
+ nonisolated static func isPairingAuthFailure(_ error: Error?) -> Bool {
1833
2159
  guard let error = error as NSError? else { return false }
1834
2160
  if error.domain == CBATTErrorDomain {
1835
2161
  return error.code == CBATTError.insufficientAuthentication.rawValue
1836
2162
  || error.code == CBATTError.insufficientEncryption.rawValue
1837
2163
  }
1838
2164
  if error.domain == CBErrorDomain {
1839
- return error.code == CBError.peerRemovedPairingInformation.rawValue
2165
+ return Self.isPeerRemovedPairingInformation(error)
1840
2166
  }
1841
2167
  return false
1842
2168
  }
1843
2169
 
1844
- private func clearSavedGlassesAfterOwnerLoss(reason: String) {
1845
- Bridge.log("LIVE: Owner loss (\(reason)) — clearing saved glasses")
1846
- pairingYieldAwaitingReclaim = false
1847
- pairingYieldActive = false
1848
- pairingYieldEndWorkItem?.cancel()
1849
- pairingYieldEndWorkItem = nil
1850
- forget()
2170
+ nonisolated static func isPeerRemovedPairingInformation(_ error: Error?) -> Bool {
2171
+ guard let error = error as NSError?, error.domain == CBErrorDomain else { return false }
2172
+ return error.code == CBError.peerRemovedPairingInformation.rawValue
2173
+ }
2174
+
2175
+ static let staleIosBondPromptedAtKey = "mentra_live_stale_ios_bond_prompted_at"
2176
+
2177
+ /// A new pairing attempt should be allowed to show the prompt again if it still fails.
2178
+ static func clearStaleIosBondPrompt() {
2179
+ UserDefaults.standard.removeObject(forKey: staleIosBondPromptedAtKey)
2180
+ }
2181
+
2182
+ /// What a connect or link failure means for the next attempt.
2183
+ private enum ConnectFailureOutcome {
2184
+ /// Not a pairing failure: retry as usual.
2185
+ case retry
2186
+ /// An auth failure that did not settle anything yet: retry through the scan backoff,
2187
+ /// because a pending direct connect would repeat the failure without delay.
2188
+ case backoff
2189
+ /// Stop: the user must act, or the glasses rejected this phone.
2190
+ case stop
2191
+ }
2192
+
2193
+ private func classifyConnectFailure(_ error: Error?) -> ConnectFailureOutcome {
2194
+ // iOS keeps keys the glasses no longer have. On an explicit attempt only the user can
2195
+ // fix that, in Settings > Bluetooth.
2196
+ if Self.isPeerRemovedPairingInformation(error), isExplicitPairingAttempt() {
2197
+ promptToForgetStaleIosBond()
2198
+ return .stop
2199
+ }
2200
+ if Self.isPairingAuthFailure(error) {
2201
+ let code = (error as NSError?)?.code ?? 0
2202
+ return recordAuthRejection(reason: "ios_auth_\(code)") ? .stop : .backoff
2203
+ }
2204
+ pairingStore.update("connect_failed") { $0.onConnectAttemptFailed() }
2205
+ return .retry
2206
+ }
2207
+
2208
+ /// Internal so native tests can feed CoreBluetooth errors.
2209
+ /// - Returns: true when the caller must not reconnect.
2210
+ func stopAfterConnectFailure(_ error: Error?) -> Bool {
2211
+ switch classifyConnectFailure(error) {
2212
+ case .retry:
2213
+ return false
2214
+ case .backoff:
2215
+ if !isKilled { handleReconnection() }
2216
+ return true
2217
+ case .stop:
2218
+ return true
2219
+ }
2220
+ }
2221
+
2222
+ /// Error 14 on an explicit attempt: show the forget-in-Settings screen once per attempt.
2223
+ private func promptToForgetStaleIosBond() {
2224
+ cancelPendingReconnect(reason: "stale_ios_bond")
2225
+ let now = Date().timeIntervalSince1970
2226
+ let promptedAt = UserDefaults.standard.double(forKey: Self.staleIosBondPromptedAtKey)
2227
+ guard MentraLiveConnectionAttemptPolicy.shouldPromptToForgetStaleIosBond(
2228
+ peerRemovedPairingInformation: true,
2229
+ promptedAt: promptedAt,
2230
+ now: now
2231
+ ) else {
2232
+ pairFlow("stale_ios_bond", "prompt=suppressed")
2233
+ return
2234
+ }
2235
+ UserDefaults.standard.set(now, forKey: Self.staleIosBondPromptedAtKey)
2236
+ pairFlow("stale_ios_bond", "prompt=forget_bluetooth code=\(MentraLiveConnectionAttemptPolicy.iosForgetPairingCode)")
2237
+ Bridge.sendPairFailureEvent(
2238
+ "errors:pairingCouldNotStart",
2239
+ code: MentraLiveConnectionAttemptPolicy.iosForgetPairingCode
2240
+ )
2241
+ }
2242
+
2243
+ /// The saved glasses run secure-pairing firmware, learned from their last `pairing_info`.
2244
+ /// Legacy firmware never rejects a non-owner on purpose, so its failures are not owner loss.
2245
+ private func isSavedGlassesSecureFirmware() -> Bool {
2246
+ DeviceStore.shared.get("bluetooth", "mentra_live_secure_firmware") as? Bool == true
2247
+ }
2248
+
2249
+ /// Remember what the connected glasses reported so later rejections can be read correctly.
2250
+ private func rememberSecureFirmware(_ secure: Bool) {
2251
+ guard isSavedGlassesSecureFirmware() != secure else { return }
1851
2252
  Task { @MainActor in
1852
- DeviceStore.shared.apply("bluetooth", "default_wearable", "")
1853
- DeviceStore.shared.apply("bluetooth", "device_name", "")
1854
- DeviceStore.shared.apply("bluetooth", "device_address", "")
2253
+ DeviceStore.shared.apply("bluetooth", "mentra_live_secure_firmware", secure)
2254
+ }
2255
+ Bridge.saveSetting("mentra_live_secure_firmware", secure)
2256
+ }
2257
+
2258
+ /// One authentication-type failure. The state machine decides whether it settles a takeover
2259
+ /// (the one verification after `OWNER_CHANGED`) or completes the 3-strike streak.
2260
+ /// - Returns: true when this phone is now `pairing_rejected`.
2261
+ @discardableResult
2262
+ private func recordAuthRejection(reason: String) -> Bool {
2263
+ let explicitAttempt = isExplicitPairingAttempt()
2264
+ let secureFirmware = isSavedGlassesSecureFirmware()
2265
+ let transition = pairingStore.update(reason) {
2266
+ $0.onAuthRejection(
2267
+ secureFirmware: secureFirmware,
2268
+ explicitAttempt: explicitAttempt,
2269
+ nowMs: MentraLivePairingStateStore.nowMs()
2270
+ )
2271
+ }
2272
+ Bridge.log("LIVE: Auth rejection (\(reason)) — \(transition.note)")
2273
+ applyPairingTransition(transition)
2274
+ return transition.effect == .reject
2275
+ }
2276
+
2277
+ /// A restarted app keeps the pause until its deadline and keeps a rejection.
2278
+ private func restorePairingState() {
2279
+ let savedName = UserDefaults.standard.string(forKey: PREFS_DEVICE_NAME) ?? ""
2280
+ guard let transition = pairingStore.ensureRestored(savedDeviceId: savedName) else {
2281
+ if pairingStore.isPaused {
2282
+ armPairingPauseTimer()
2283
+ startPairingWatchScan(reason: "manager_init")
2284
+ }
2285
+ return
2286
+ }
2287
+ if transition.effect == .pause {
2288
+ armPairingPauseTimer()
2289
+ startPairingWatchScan(reason: "restored_pause")
1855
2290
  }
1856
- Bridge.saveSetting("default_wearable", "")
1857
- Bridge.saveSetting("device_name", "")
1858
- Bridge.saveSetting("device_address", "")
1859
- Bridge.sendTypedMessage("owner_replaced", body: ["reason": reason])
1860
2291
  }
1861
2292
 
1862
2293
  func connectById(_ deviceName: String) {
1863
- if pairingYieldActive {
1864
- Bridge.log("LIVE: connectById blocked — pairing yield active")
2294
+ // A destroyed manager stays inert; DeviceManager builds a new one for the next connect.
2295
+ if isKilled {
2296
+ pairFlow("connect_by_id", "blocked reason=manager_killed name=\(deviceName)")
2297
+ return
2298
+ }
2299
+ if !isExplicitPairingAttempt() {
2300
+ pairingStore.update("connect_by_id") { $0.bindDevice(deviceName) }
2301
+ }
2302
+ if automaticConnectBlocked() {
2303
+ // Paused for a pairing window or rejected. Keep the saved glasses; a pause watches
2304
+ // the advertisement and resumes on its own, a rejection waits for Pair again.
2305
+ UserDefaults.standard.set(deviceName, forKey: PREFS_DEVICE_NAME)
2306
+ pairFlow("connect_by_id", "deferred state=\(pairingStore.describe()) name=\(deviceName)")
2307
+ if pairingStore.isPaused {
2308
+ armPairingPauseTimer()
2309
+ startPairingWatchScan(reason: "connect_by_id")
2310
+ }
1865
2311
  return
1866
2312
  }
1867
- Bridge.log("connectById: \(deviceName)")
2313
+ pairFlow("connect_by_id", "name=\(deviceName)")
1868
2314
  cancelPendingReconnect(reason: "connect_by_id")
1869
2315
  // Save the device name for future reconnection
1870
2316
  UserDefaults.standard.set(deviceName, forKey: PREFS_DEVICE_NAME)
1871
2317
 
1872
- // Start scanning to find this specific device
1873
2318
  if centralManager == nil {
1874
2319
  centralManager = CBCentralManager(
1875
2320
  delegate: self, queue: bluetoothQueue,
@@ -1877,37 +2322,111 @@ class MentraLive: NSObject, SGCManager {
1877
2322
  )
1878
2323
  }
1879
2324
 
1880
- // Check for already-connected peripherals first
1881
- let connectedPeripherals = centralManager!.retrieveConnectedPeripherals(withServices: [
1882
- SERVICE_UUID,
1883
- ])
1884
- for peripheral in connectedPeripherals {
1885
- Bridge.log("Found already-connected peripheral: \(peripheral.name ?? "Unknown")")
1886
- if let name = peripheral.name,
1887
- name == "Xy_A" || name.hasPrefix("XyBLE_") || name.hasPrefix("MENTRA_LIVE_BLE")
1888
- || name.hasPrefix("MENTRA_LIVE_BT") || name.lowercased().hasPrefix("mentra_live")
2325
+ attachKnownOrScan(deviceName)
2326
+ }
2327
+
2328
+ /// Connect the peripheral this phone already found. After the BLE bond the
2329
+ /// glasses stop general advertising, so a new name scan never sees them.
2330
+ private func attachKnownOrScan(_ deviceName: String) {
2331
+ if isConnecting {
2332
+ pairFlow("attach", "decision=already_connecting name=\(deviceName)")
2333
+ return
2334
+ }
2335
+ if txCharacteristic != nil, connectedPeripheral?.name == deviceName {
2336
+ pairFlow(
2337
+ "attach",
2338
+ "decision=live_gatt id=\(connectedPeripheral?.identifier.uuidString ?? "-") name=\(deviceName)"
2339
+ )
2340
+ return
2341
+ }
2342
+
2343
+ let poweredOn = centralManager?.state == .poweredOn
2344
+ let cached = discoveredPeripherals[deviceName]
2345
+ let savedId = savedPeripheralId(for: deviceName)
2346
+ let systemMatch = poweredOn ? systemConnectedPeripheral(deviceName: deviceName, savedId: savedId) : nil
2347
+ let route = MentraLiveKnownConnectRoute.decide(
2348
+ bluetoothPoweredOn: poweredOn,
2349
+ hasCachedPeripheral: cached != nil,
2350
+ hasSavedIdentifier: savedId != nil,
2351
+ systemConnectedMatch: systemMatch != nil
2352
+ )
2353
+ pairFlow(
2354
+ "attach",
2355
+ "decision=\(route.rawValue) name=\(deviceName) cached=\(cached != nil) savedId=\(savedId?.uuidString ?? "-") system=\(systemMatch?.identifier.uuidString ?? "-") central=\(centralManager?.state.rawValue ?? -1)"
2356
+ )
2357
+
2358
+ switch route {
2359
+ case .waitForPower:
2360
+ return
2361
+ case .cachedPeripheral:
2362
+ if let cached {
2363
+ connectToDevice(cached)
2364
+ }
2365
+ case .savedIdentifier:
2366
+ if let savedId,
2367
+ let peripheral = centralManager?.retrievePeripherals(withIdentifiers: [savedId]).first
1889
2368
  {
1890
- Bridge.log("Found already-connected peripheral: \(name)")
1891
- discoveredPeripherals[name] = peripheral
1892
- emitDiscoveredDeviceFromCache(name)
2369
+ rememberPeripheral(peripheral, name: deviceName)
2370
+ discoveredPeripherals[deviceName] = peripheral
2371
+ connectToDevice(peripheral)
2372
+ } else {
2373
+ pairFlow("attach", "decision=saved_identifier_missing id=\(savedId?.uuidString ?? "-") fallback=scan")
2374
+ startScan(reason: "saved_identifier_missing")
2375
+ }
2376
+ case .alreadyConnected:
2377
+ if let systemMatch {
2378
+ let matchedName = systemMatch.name ?? deviceName
2379
+ discoveredPeripherals[matchedName] = systemMatch
2380
+ rememberPeripheral(systemMatch, name: deviceName)
2381
+ connectToDevice(systemMatch)
2382
+ }
2383
+ case .scan:
2384
+ if isScanning {
2385
+ pairFlow("attach", "decision=already_scanning name=\(deviceName)")
2386
+ return
2387
+ }
2388
+ startScan(reason: "no_known_peripheral")
2389
+ }
2390
+ }
1893
2391
 
1894
- // Check if this is the device we want
1895
- if let savedDeviceName = UserDefaults.standard.string(
1896
- forKey: PREFS_DEVICE_NAME
1897
- ),
1898
- savedDeviceName == name
1899
- {
1900
- Bridge.log(
1901
- "Found our remembered device already connected, connecting: \(name)"
1902
- )
1903
- connectToDevice(peripheral)
1904
- return
1905
- }
2392
+ private func savedPeripheralId(for name: String) -> UUID? {
2393
+ let map = UserDefaults.standard.dictionary(forKey: PREFS_PERIPHERAL_IDS) as? [String: String]
2394
+ guard let raw = map?[name] else {
2395
+ return nil
2396
+ }
2397
+ return UUID(uuidString: raw)
2398
+ }
2399
+
2400
+ private func rememberPeripheral(_ peripheral: CBPeripheral, name: String) {
2401
+ var map = UserDefaults.standard.dictionary(forKey: PREFS_PERIPHERAL_IDS) as? [String: String] ?? [:]
2402
+ map[name] = peripheral.identifier.uuidString
2403
+ UserDefaults.standard.set(map, forKey: PREFS_PERIPHERAL_IDS)
2404
+ }
2405
+
2406
+ private func systemConnectedPeripheral(deviceName: String, savedId: UUID?) -> CBPeripheral? {
2407
+ let connected = centralManager?.retrieveConnectedPeripherals(withServices: [SERVICE_UUID]) ?? []
2408
+ pairFlow("system_connected", "count=\(connected.count)")
2409
+ for peripheral in connected {
2410
+ let name = peripheral.name ?? "-"
2411
+ let matches = MentraLivePendingPairingTarget.matches(
2412
+ connectedName: peripheral.name ?? "",
2413
+ connectedIdentifier: peripheral.identifier.uuidString,
2414
+ pendingName: deviceName,
2415
+ pendingIdentifier: savedId?.uuidString ?? ""
2416
+ )
2417
+ pairFlow(
2418
+ "system_connected",
2419
+ "name=\(name) id=\(peripheral.identifier.uuidString) match=\(matches)"
2420
+ )
2421
+ if matches {
2422
+ return peripheral
1906
2423
  }
1907
2424
  }
2425
+ return nil
2426
+ }
1908
2427
 
1909
- // Will connect when found during scan
1910
- startScan()
2428
+ private func pairFlow(_ event: String, _ detail: String) {
2429
+ Bridge.log("PAIRFLOW: \(event) \(detail)")
1911
2430
  }
1912
2431
 
1913
2432
  func getConnectedBluetoothName() -> String? {
@@ -1919,7 +2438,10 @@ class MentraLive: NSObject, SGCManager {
1919
2438
  Bridge.log("LIVE: disconnect deferred — waiting for unpair write")
1920
2439
  return
1921
2440
  }
1922
- Bridge.log("LIVE: disconnect() -Disconnecting from Mentra Live glasses")
2441
+ pairFlow(
2442
+ "disconnect",
2443
+ "name=\(connectedPeripheral?.name ?? "-") id=\(connectedPeripheral?.identifier.uuidString ?? "-") scanning=\(isScanning) connecting=\(isConnecting)"
2444
+ )
1923
2445
 
1924
2446
  // if rgbLedAuthorityClaimed {
1925
2447
  // sendRgbLedControlAuthority(false)
@@ -2351,25 +2873,26 @@ class MentraLive: NSObject, SGCManager {
2351
2873
 
2352
2874
  // MARK: - BLE Scanning
2353
2875
 
2354
- private func startScan() {
2355
- if pairingYieldActive {
2356
- Bridge.log("LIVE: startScan blocked — pairing yield active")
2357
- return
2358
- }
2359
-
2876
+ private func startScan(reason: String = "scan") {
2360
2877
  guard centralManager!.state == .poweredOn else {
2361
- Bridge.log("Attempting to scan but bluetooth is not powered on.")
2878
+ pairFlow("scan", "blocked reason=bluetooth_off trigger=\(reason) central=\(centralManager?.state.rawValue ?? -1)")
2362
2879
  return
2363
2880
  }
2364
2881
 
2365
- Bridge.log("Starting BLE scan for Mentra Live glasses")
2882
+ pairFlow("scan", "start trigger=\(reason) target=\(UserDefaults.standard.string(forKey: PREFS_DEVICE_NAME) ?? "-")")
2366
2883
  discoveredAdvPairing.removeAll()
2884
+ loggedUnnamedDiscoveries.removeAll()
2367
2885
  isScanning = true
2886
+ pairingWatchScanActive = false
2368
2887
 
2369
2888
  startReadinessCheckLoop()
2370
2889
 
2890
+ // The choose-glasses scan has to hear a later advertisement when the glasses
2891
+ // enter pairing mode. iOS reports each peripheral once unless duplicates are
2892
+ // allowed, so the row would stay "Not in pairing mode". Reconnect scans stay
2893
+ // duplicate-free so a saved glasses advertisement cannot start another connect.
2371
2894
  let scanOptions: [String: Any] = [
2372
- CBCentralManagerScanOptionAllowDuplicatesKey: false,
2895
+ CBCentralManagerScanOptionAllowDuplicatesKey: manualDiscoveryActive,
2373
2896
  ]
2374
2897
 
2375
2898
  // let knownPeripherals = centralManager?.retrieveConnectedPeripherals(withServices: [SERVICE_UUID])
@@ -2403,18 +2926,24 @@ class MentraLive: NSObject, SGCManager {
2403
2926
  centralManager?.stopScan()
2404
2927
  isScanning = false
2405
2928
  DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "searching", false)
2406
- Bridge.log("LIVE: BLE scan stopped")
2929
+ pairFlow("scan", "stop")
2407
2930
  }
2408
2931
 
2409
2932
  // MARK: - Connection Management
2410
2933
 
2411
2934
  private func connectToDevice(_ peripheral: CBPeripheral) {
2412
- if pairingYieldActive {
2413
- Bridge.log("LIVE: connectToDevice blocked — pairing yield active")
2935
+ if automaticConnectBlocked() {
2936
+ pairFlow(
2937
+ "connect_device",
2938
+ "blocked state=\(pairingStore.describe()) id=\(peripheral.identifier.uuidString)"
2939
+ )
2414
2940
  isConnecting = false
2415
2941
  return
2416
2942
  }
2417
- Bridge.log("LIVE: Connecting to device: \(peripheral.identifier.uuidString)")
2943
+ pairFlow(
2944
+ "connect_device",
2945
+ "id=\(peripheral.identifier.uuidString) name=\(peripheral.name ?? "-") state=\(peripheral.state.rawValue)"
2946
+ )
2418
2947
 
2419
2948
  // A timed attempt supersedes any pending reconnect: withdraw one aimed at other
2420
2949
  // glasses, and drop tracking for this one so its settle timer cannot touch this attempt.
@@ -2445,7 +2974,8 @@ class MentraLive: NSObject, SGCManager {
2445
2974
  guard let centralManager,
2446
2975
  MentraLiveConnectionAttemptPolicy.shouldReconnectDirectly(
2447
2976
  isKilled: isKilled,
2448
- pairingYieldActive: pairingYieldActive,
2977
+ // A window just ended: scan first so its advertisement is read.
2978
+ pairingYieldActive: automaticConnectBlocked() || pairingStore.awaitingTakeoverCheck,
2449
2979
  bluetoothPoweredOn: centralManager.state == .poweredOn,
2450
2980
  peripheralName: peripheral.name,
2451
2981
  savedDeviceName: UserDefaults.standard.string(forKey: PREFS_DEVICE_NAME)
@@ -2476,9 +3006,13 @@ class MentraLive: NSObject, SGCManager {
2476
3006
  // The default requirement lets the system complete that authorization flow
2477
3007
  // before the glasses subscribe; apps that do not relay notifications can opt out.
2478
3008
  Bridge.log("LIVE: ANCS connection requirement \(requiresAncs ? "enabled" : "disabled")")
3009
+ Bridge.log("LIVE: Transport bridging \(Self.enableTransportBridging ? "enabled" : "disabled")")
2479
3010
  centralManager?.connect(
2480
3011
  peripheral,
2481
- options: MentraLiveConnectionOptions.coreBluetoothOptions(requiresAncs: requiresAncs)
3012
+ options: MentraLiveConnectionOptions.coreBluetoothOptions(
3013
+ requiresAncs: requiresAncs,
3014
+ enableTransportBridging: Self.enableTransportBridging
3015
+ )
2482
3016
  )
2483
3017
  #endif
2484
3018
  }
@@ -2566,8 +3100,11 @@ class MentraLive: NSObject, SGCManager {
2566
3100
  return
2567
3101
  }
2568
3102
 
2569
- if pairingYieldActive {
2570
- Bridge.log("LIVE: Reconnection aborted - pairing yield active")
3103
+ if automaticConnectBlocked() {
3104
+ Bridge.log("LIVE: Reconnection aborted - pairing state \(pairingStore.describe())")
3105
+ if pairingStore.isPaused {
3106
+ startPairingWatchScan(reason: "reconnect_while_paused")
3107
+ }
2571
3108
  return
2572
3109
  }
2573
3110
 
@@ -2604,8 +3141,8 @@ class MentraLive: NSObject, SGCManager {
2604
3141
  guard let self = self else { return }
2605
3142
 
2606
3143
  // Use peripheral presence, not connectionState: we stay CONNECTING during backoff until scan/connect.
2607
- if self.pairingYieldActive {
2608
- Bridge.log("LIVE: Reconnection aborted - pairing yield active")
3144
+ if self.automaticConnectBlocked() {
3145
+ Bridge.log("LIVE: Reconnection aborted - pairing state \(self.pairingStore.describe())")
2609
3146
  return
2610
3147
  }
2611
3148
  if self.connectedPeripheral == nil, !self.isKilled {
@@ -2840,10 +3377,10 @@ class MentraLive: NSObject, SGCManager {
2840
3377
 
2841
3378
  switch type {
2842
3379
  case "glasses_ready":
2843
- // A queued glasses_ready from the connection we just cancelled for yield
2844
- // must not close the pairing window. Only accept readiness on a live GATT.
2845
- if pairingYieldActive && connectedPeripheral == nil {
2846
- Bridge.log("LIVE: Ignoring stale glasses_ready during pairing yield")
3380
+ // A queued glasses_ready from the connection just left for a pairing window
3381
+ // must not count as a session. Only accept readiness on a live GATT.
3382
+ if pairingStore.isPaused && connectedPeripheral == nil {
3383
+ Bridge.log("LIVE: Ignoring stale glasses_ready during pairing pause")
2847
3384
  return
2848
3385
  }
2849
3386
  // glasses_ready is a REMOTE wire-session reset: the glasses ran
@@ -2851,10 +3388,6 @@ class MentraLive: NSObject, SGCManager {
2851
3388
  // framing regardless of what this side negotiated earlier. Start a fresh
2852
3389
  // wire epoch (clears v2-active state that would otherwise gate the
2853
3390
  // handshake off), then negotiate from the caps this message advertises.
2854
- pairingYieldAwaitingReclaim = false
2855
- pairingYieldActive = false
2856
- pairingYieldEndWorkItem?.cancel()
2857
- pairingYieldEndWorkItem = nil
2858
3391
  resetWireNegotiationState()
2859
3392
  parsePeerWireCaps(json)
2860
3393
  maybeSendWireHandshake()
@@ -3050,25 +3583,113 @@ class MentraLive: NSObject, SGCManager {
3050
3583
  Bridge.log("LIVE: Received pong response - connection healthy")
3051
3584
 
3052
3585
  case "entering_pairing_mode":
3053
- let windowMs = max(5000, min(180_000, json["window_ms"] as? Int ?? 120_000))
3054
- Bridge.log("LIVE: Glasses entering pairing mode — yield \(windowMs)ms (no forget)")
3055
- enterPairingYield(windowMs: windowMs)
3056
- let body: [String: Any] = [
3057
- "window_ms": windowMs,
3058
- "reason": json["reason"] as? String ?? "user_gesture",
3586
+ let windowMs = json["window_ms"] as? Int
3587
+ let windowId = json["window_id"] as? Int
3588
+ let ownerRetained = json["owner_retained"] as? Bool ?? false
3589
+ let enterReason = json["reason"] as? String ?? "user_gesture"
3590
+ Bridge.log(
3591
+ "PAIRFLOW: entering_pairing_mode reason=\(enterReason) window_ms=\(windowMs ?? -1) window_id=\(windowId ?? -1) owner_retained=\(ownerRetained)"
3592
+ )
3593
+ var enterBody: [String: Any] = [
3594
+ "window_ms": windowMs ?? MentraLiveConnectionAttemptPolicy.defaultPairingWindowMs,
3595
+ "reason": enterReason,
3596
+ "owner_retained": ownerRetained,
3059
3597
  ]
3060
- Bridge.sendTypedMessage("entering_pairing_mode", body: body)
3598
+ if let windowId { enterBody["window_id"] = windowId }
3599
+ Bridge.sendTypedMessage("entering_pairing_mode", body: enterBody)
3600
+ let explicitAttempt = isExplicitPairingAttempt()
3601
+ let deviceId = connectedPeripheral?.name ?? UserDefaults.standard.string(forKey: PREFS_DEVICE_NAME) ?? ""
3602
+ let enterTransition = pairingStore.update("entering_pairing_mode") {
3603
+ $0.onEnteringPairingMode(
3604
+ windowMs: windowMs,
3605
+ windowId: windowId,
3606
+ ownerRetained: ownerRetained,
3607
+ explicitAttempt: explicitAttempt,
3608
+ deviceId: deviceId,
3609
+ nowMs: MentraLivePairingStateStore.nowMs()
3610
+ )
3611
+ }
3612
+ applyPairingTransition(enterTransition)
3613
+
3614
+ case "pairing_mode_ended":
3615
+ let endedReason = json["reason"] as? String
3616
+ let endedWindowId = json["window_id"] as? Int
3617
+ Bridge.log("PAIRFLOW: pairing_mode_ended reason=\(endedReason ?? "-") window_id=\(endedWindowId ?? -1)")
3618
+ var endedBody: [String: Any] = ["reason": endedReason ?? ""]
3619
+ if let endedWindowId { endedBody["window_id"] = endedWindowId }
3620
+ Bridge.sendTypedMessage("pairing_mode_ended", body: endedBody)
3621
+ let endedTransition = pairingStore.update("pairing_mode_ended") {
3622
+ $0.onPairingModeEnded(reason: endedReason, windowId: endedWindowId)
3623
+ }
3624
+ applyPairingTransition(endedTransition)
3061
3625
 
3062
3626
  case "pairing_info":
3627
+ Bridge.log(
3628
+ "PAIRFLOW: pairing_info secure=\(json["secure_pairing_capable"] as? Bool ?? false) classic_ready=\(json["classic_bond_ready"] as? Bool ?? false) had_previous=\(json["had_previous_bond"] as? Bool ?? false) transfer=\(json["ownership_transfer"] as? Bool ?? false) owner_committed=\(json["owner_committed"] as? Bool ?? false) auto_audio=\(json["auto_audio"] as? Bool ?? false) code=<redacted>"
3629
+ )
3630
+ let ownerCommitted = json["owner_committed"] as? Bool == true
3631
+ // Legacy firmware that omits this field is not secure-capable.
3632
+ let securePairingCapable = json["secure_pairing_capable"] as? Bool ?? false
3633
+ // Read before any readiness below: the audio wait of a pairing depends on it.
3634
+ pairingInfoAutoAudio = json["auto_audio"] as? Bool ?? false
3635
+ rememberSecureFirmware(securePairingCapable)
3636
+ if securePairingCapable {
3637
+ pairingInfoSeenThisSession = true
3638
+ cancelSecureFirmwareRefresh()
3639
+ sendPairingSessionMessages(reason: "pairing_info")
3640
+ }
3641
+ // Readiness parked on an unknown capability: this pairing_info decides the rule.
3642
+ var legacyReadiness = false
3643
+ if awaitingPairingInfoForUnknownCapability {
3644
+ cancelUnknownCapabilityWait()
3645
+ if securePairingCapable {
3646
+ pairFlow("readiness", "capability=unknown pairing_info=secure action=await_owner_commit")
3647
+ awaitingSecurePairingConfirmation = true
3648
+ if !ownerCommitted { armSecurePairingWait() }
3649
+ } else {
3650
+ pairFlow("readiness", "capability=unknown pairing_info=legacy action=ready")
3651
+ legacyReadiness = true
3652
+ }
3653
+ }
3654
+ // Only owner_committed proves the bond. secure_pairing_capable is a capability flag
3655
+ // readable on the unencrypted window session.
3656
+ if MentraLiveConnectionAttemptPolicy.pairingInfoConfirmsOwner(ownerCommitted: ownerCommitted) {
3657
+ securePairingConfirmed = true
3658
+ cancelSecurePairingWait()
3659
+ if awaitingSecurePairingConfirmation {
3660
+ Bridge.log("LIVE: pairing_info owner_committed — reporting ready")
3661
+ markFullyBooted()
3662
+ }
3663
+ }
3664
+ if legacyReadiness { markFullyBooted() }
3063
3665
  Bridge.sendPairingInfo(
3064
3666
  hadPreviousBond: json["had_previous_bond"] as? Bool ?? false,
3065
3667
  pairingCode: json["pairing_code"] as? String,
3066
3668
  classicBondReady: json["classic_bond_ready"] as? Bool ?? false,
3067
- // Legacy firmware that omits this field is not secure-capable.
3068
- securePairingCapable: json["secure_pairing_capable"] as? Bool ?? false,
3069
- protocolVersion: json["protocol_version"] as? Int ?? 1
3669
+ securePairingCapable: securePairingCapable,
3670
+ protocolVersion: json["protocol_version"] as? Int ?? 1,
3671
+ ownershipTransfer: json["ownership_transfer"] as? Bool ?? false,
3672
+ ownerCommitted: ownerCommitted,
3673
+ autoAudio: json["auto_audio"] as? Bool
3070
3674
  )
3071
3675
 
3676
+ case "classic_link":
3677
+ // The glasses' own view of the phone's A2DP and HFP links to them. Preferred over
3678
+ // the phone's audio route, which another Bluetooth audio device can hold.
3679
+ let a2dp = json["a2dp"] as? Bool ?? false
3680
+ let hfp = json["hfp"] as? Bool ?? false
3681
+ pairFlow("classic_link", "a2dp=\(a2dp) hfp=\(hfp)")
3682
+ audioSetup.classicLink(a2dp: a2dp, hfp: hfp)
3683
+ refreshAudioSetup(trigger: "classic_link")
3684
+ #if !os(macOS)
3685
+ if !AudioSessionMonitor.glassesReportClassicLink {
3686
+ // From now on the phone signals come from passive route observation, never
3687
+ // from the category probe. Re-read them that way.
3688
+ AudioSessionMonitor.glassesReportClassicLink = true
3689
+ DeviceManager.shared.checkCurrentAudioDevice()
3690
+ }
3691
+ #endif
3692
+
3072
3693
  case "imu_response", "imu_stream_response", "imu_gesture_response",
3073
3694
  "imu_gesture_subscribed", "imu_ack", "imu_error":
3074
3695
  // Handle IMU-related responses
@@ -4050,6 +4671,10 @@ class MentraLive: NSObject, SGCManager {
4050
4671
  // MARK: - Message Handlers
4051
4672
 
4052
4673
  private func handleGlassesReady() {
4674
+ pairFlow(
4675
+ "glasses_ready",
4676
+ "id=\(connectedPeripheral?.identifier.uuidString ?? "-") name=\(connectedPeripheral?.name ?? "-") gatt=\(txCharacteristic != nil)"
4677
+ )
4053
4678
  Bridge.log("LIVE: 🎉 Received glasses_ready message - SOC is booted and ready!")
4054
4679
 
4055
4680
  stopReadinessCheckLoop()
@@ -4107,11 +4732,142 @@ class MentraLive: NSObject, SGCManager {
4107
4732
  }
4108
4733
  }
4109
4734
 
4735
+ // handleDeviceReady promotes the pairing target to the default device. Secure glasses
4736
+ // own a phone only after a BLE bond and announce it with pairing_info; GATT works
4737
+ // unencrypted inside the open window, so readiness alone is not a pairing.
4738
+ let readiness = MentraLiveConnectionAttemptPolicy.readinessAtGlassesReady(
4739
+ explicitPairing: isExplicitPairingAttempt(),
4740
+ selectedCapability: selectedTargetSecureCapability(),
4741
+ securePairingInfoThisSession: pairingInfoSeenThisSession,
4742
+ securePairingConfirmed: securePairingConfirmed
4743
+ )
4744
+ switch readiness {
4745
+ case .ready:
4746
+ markFullyBooted()
4747
+ case .awaitOwnerCommit:
4748
+ Bridge.log("LIVE: glasses_ready before owner commit — waiting for pairing_info")
4749
+ awaitingSecurePairingConfirmation = true
4750
+ armSecurePairingWait()
4751
+ case .awaitPairingInfo:
4752
+ pairFlow("readiness", "capability=unknown action=await_pairing_info waitMs=\(unknownCapabilityPairingInfoWaitMs)")
4753
+ armUnknownCapabilityWait()
4754
+ }
4755
+ }
4756
+
4757
+ private func markFullyBooted() {
4758
+ // A session that reached ready means the glasses still admit this phone. Read the
4759
+ // attempt kind first: setting fullyBooted runs handleDeviceReady, which clears pending.
4760
+ let explicitAttempt = isExplicitPairingAttempt()
4761
+ pairingStore.update("session_ready") { $0.onSessionReady(explicitAttempt: explicitAttempt) }
4762
+ awaitingSecurePairingConfirmation = false
4763
+ cancelSecurePairingWait()
4764
+ cancelUnknownCapabilityWait()
4765
+ armSecureFirmwareRefresh()
4766
+ if !fullyBooted {
4767
+ audioSetup.startPhase(
4768
+ explicitAttempt ? .pairing(autoAudio: pairingInfoAutoAudio) : .reconnect,
4769
+ nowMs: audioSetupNowMs()
4770
+ )
4771
+ }
4772
+ // Publish audioSetup before readiness so the ready update never carries a stale idle.
4773
+ refreshAudioSetup(trigger: "session_ready", sessionReady: true)
4110
4774
  fullyBooted = true
4111
4775
  connected = true
4112
4776
  updateConnectionState(ConnTypes.CONNECTED)
4113
4777
  }
4114
4778
 
4779
+ /// The selected scan row's capability (`pending_device_secure_pairing_capable`); nil when
4780
+ /// unknown, for example a target re-listed from saved state.
4781
+ private func selectedTargetSecureCapability() -> Bool? {
4782
+ DeviceStore.shared.get("bluetooth", "pending_device_secure_pairing_capable") as? Bool
4783
+ }
4784
+
4785
+ /// Unknown capability: a secure pairing_info within the wait switches to the secure rule,
4786
+ /// none means legacy firmware and the glasses are reported ready.
4787
+ private func armUnknownCapabilityWait() {
4788
+ cancelUnknownCapabilityWait()
4789
+ awaitingPairingInfoForUnknownCapability = true
4790
+ let work = DispatchWorkItem { [weak self] in
4791
+ guard let self, self.awaitingPairingInfoForUnknownCapability else { return }
4792
+ self.unknownCapabilityWaitWorkItem = nil
4793
+ self.awaitingPairingInfoForUnknownCapability = false
4794
+ self.pairFlow("readiness", "capability=unknown pairing_info=none action=ready_legacy")
4795
+ self.markFullyBooted()
4796
+ }
4797
+ unknownCapabilityWaitWorkItem = work
4798
+ DispatchQueue.main.asyncAfter(
4799
+ deadline: .now() + .milliseconds(max(unknownCapabilityPairingInfoWaitMs, 0)),
4800
+ execute: work
4801
+ )
4802
+ }
4803
+
4804
+ private func cancelUnknownCapabilityWait() {
4805
+ awaitingPairingInfoForUnknownCapability = false
4806
+ unknownCapabilityWaitWorkItem?.cancel()
4807
+ unknownCapabilityWaitWorkItem = nil
4808
+ }
4809
+
4810
+ /// `mentra_live_secure_firmware` follows the current glasses: a ready session that gets no
4811
+ /// pairing_info runs legacy firmware (for example after a rollback).
4812
+ private func armSecureFirmwareRefresh() {
4813
+ guard secureFirmwareRefreshWorkItem == nil, !pairingInfoSeenThisSession else { return }
4814
+ let work = DispatchWorkItem { [weak self] in
4815
+ guard let self else { return }
4816
+ self.secureFirmwareRefreshWorkItem = nil
4817
+ guard !self.pairingInfoSeenThisSession else { return }
4818
+ self.pairFlow("secure_firmware", "pairing_info=none afterMs=\(self.secureFirmwareRefreshMs) secure=false")
4819
+ self.rememberSecureFirmware(false)
4820
+ }
4821
+ secureFirmwareRefreshWorkItem = work
4822
+ DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(max(secureFirmwareRefreshMs, 0)), execute: work)
4823
+ }
4824
+
4825
+ private func cancelSecureFirmwareRefresh() {
4826
+ secureFirmwareRefreshWorkItem?.cancel()
4827
+ secureFirmwareRefreshWorkItem = nil
4828
+ }
4829
+
4830
+ /// Bond confirmation has to arrive on its own. A dismissed pairing sheet, or a bond that
4831
+ /// never finishes, must fail the attempt instead of leaving the loading screen up.
4832
+ private func armSecurePairingWait() {
4833
+ cancelSecurePairingWait()
4834
+ // Every parked secure readiness gets this deadline, so the pair can never hang.
4835
+ guard MentraLiveConnectionAttemptPolicy.shouldRunSecurePairingWait(
4836
+ explicitPairing: isExplicitPairingAttempt(),
4837
+ securePairingTarget: isSecurePairingTarget()
4838
+ ) else { return }
4839
+ let work = DispatchWorkItem { [weak self] in
4840
+ guard let self else { return }
4841
+ guard self.awaitingSecurePairingConfirmation, self.isExplicitPairingAttempt(),
4842
+ !self.securePairingConfirmed
4843
+ else { return }
4844
+ self.awaitingSecurePairingConfirmation = false
4845
+ Bridge.log("LIVE: secure pairing bond wait expired")
4846
+ Bridge.sendPairFailureEvent("errors:pairingCouldNotStart")
4847
+ }
4848
+ securePairingWaitWorkItem = work
4849
+ DispatchQueue.main.asyncAfter(deadline: .now() + 40, execute: work)
4850
+ }
4851
+
4852
+ private func cancelSecurePairingWait() {
4853
+ securePairingWaitWorkItem?.cancel()
4854
+ securePairingWaitWorkItem = nil
4855
+ }
4856
+
4857
+ private func isSecurePairingTarget() -> Bool {
4858
+ MentraLiveConnectionAttemptPolicy.isSecurePairingTarget(
4859
+ selectedCapability: selectedTargetSecureCapability(),
4860
+ securePairingInfoThisSession: pairingInfoSeenThisSession
4861
+ )
4862
+ }
4863
+
4864
+ /// The user is pairing from the scan screen rather than reconnecting a saved owner.
4865
+ private func isExplicitPairingAttempt() -> Bool {
4866
+ let pendingName = DeviceStore.shared.get("bluetooth", "pending_device_name") as? String ?? ""
4867
+ let pendingAddress = DeviceStore.shared.get("bluetooth", "pending_device_address") as? String ?? ""
4868
+ return manualDiscoveryActive || !pendingName.isEmpty || !pendingAddress.isEmpty
4869
+ }
4870
+
4115
4871
  private func handleWifiScanResult(_ json: [String: Any]) {
4116
4872
  var networks: [[String: Any]] = []
4117
4873
 
@@ -5883,7 +6639,10 @@ class MentraLive: NSObject, SGCManager {
5883
6639
  private var readinessCheckDispatchTimer: DispatchSourceTimer?
5884
6640
 
5885
6641
  private func startReadinessCheckLoop() {
5886
- Bridge.log("LIVE: startReadinessCheckLoop()")
6642
+ pairFlow(
6643
+ "readiness",
6644
+ "start gatt=\(txCharacteristic != nil) id=\(connectedPeripheral?.identifier.uuidString ?? "-") name=\(connectedPeripheral?.name ?? "-")"
6645
+ )
5887
6646
  stopReadinessCheckLoop()
5888
6647
 
5889
6648
  readinessCheckCounter = 0
@@ -5903,8 +6662,15 @@ class MentraLive: NSObject, SGCManager {
5903
6662
  guard let self else { return }
5904
6663
 
5905
6664
  self.readinessCheckCounter += 1
6665
+ let gattReady = self.txCharacteristic != nil
6666
+ if self.readinessCheckCounter == 1 || self.readinessCheckCounter % 4 == 0 {
6667
+ self.pairFlow(
6668
+ "readiness",
6669
+ "n=\(self.readinessCheckCounter) gatt=\(gattReady) id=\(self.connectedPeripheral?.identifier.uuidString ?? "-")"
6670
+ )
6671
+ }
5906
6672
  Bridge.log(
5907
- "LIVE: 🔄 Readiness check #\(self.readinessCheckCounter): waiting for glasses SOC to boot"
6673
+ "LIVE: 🔄 Readiness check #\(self.readinessCheckCounter): waiting for glasses SOC to boot gatt=\(gattReady)"
5908
6674
  )
5909
6675
  // requestReadyK900()
5910
6676
 
@@ -5935,8 +6701,15 @@ class MentraLive: NSObject, SGCManager {
5935
6701
  }
5936
6702
 
5937
6703
  private func stopReadinessCheckLoop() {
6704
+ let wasRunning = readinessCheckDispatchTimer != nil
5938
6705
  readinessCheckDispatchTimer?.cancel()
5939
6706
  readinessCheckDispatchTimer = nil
6707
+ if wasRunning {
6708
+ pairFlow(
6709
+ "readiness",
6710
+ "stop n=\(readinessCheckCounter) gatt=\(txCharacteristic != nil) id=\(connectedPeripheral?.identifier.uuidString ?? "-")"
6711
+ )
6712
+ }
5940
6713
  Bridge.log("LIVE: 🔄 Stopped glasses SOC readiness check loop")
5941
6714
  }
5942
6715
 
@@ -5948,6 +6721,10 @@ class MentraLive: NSObject, SGCManager {
5948
6721
  guard let self else { return }
5949
6722
 
5950
6723
  if self.isConnecting, self.connectionState != ConnTypes.CONNECTED {
6724
+ self.pairFlow(
6725
+ "connect_timeout",
6726
+ "id=\(self.connectedPeripheral?.identifier.uuidString ?? "-") name=\(self.connectedPeripheral?.name ?? "-") state=\(self.connectionState)"
6727
+ )
5951
6728
  Bridge.log("LIVE: Connection timeout - closing GATT connection")
5952
6729
  self.isConnecting = false
5953
6730
 
@@ -5982,7 +6759,7 @@ class MentraLive: NSObject, SGCManager {
5982
6759
  /// promotes it to the default device. Re-emit only that explicit pending target; an
5983
6760
  /// established owner's connected glasses have no pending identity and remain hidden.
5984
6761
  private func emitConnectedPendingDeviceForPairingScan() {
5985
- guard manualDiscoveryActive, !pairingYieldActive, let peripheral = connectedPeripheral,
6762
+ guard manualDiscoveryActive, let peripheral = connectedPeripheral,
5986
6763
  let name = peripheral.name,
5987
6764
  MentraLivePendingPairingTarget.shouldRecover(
5988
6765
  isConnected: peripheral.state == .connected,
@@ -6008,17 +6785,35 @@ class MentraLive: NSObject, SGCManager {
6008
6785
  )
6009
6786
  }
6010
6787
 
6788
+ private func discoveredAdvertisementName(
6789
+ _ peripheral: CBPeripheral,
6790
+ advertisementData: [String: Any]
6791
+ ) -> String? {
6792
+ if let name = peripheral.name, !name.isEmpty { return name }
6793
+ if let name = advertisementData[CBAdvertisementDataLocalNameKey] as? String, !name.isEmpty {
6794
+ return name
6795
+ }
6796
+ return nil
6797
+ }
6798
+
6799
+ /// Returns true when this advertisement changed the row the scan screen is showing.
6800
+ @discardableResult
6011
6801
  private func cacheAdvPairing(
6012
6802
  _ name: String,
6013
6803
  pairingMode: Bool,
6014
6804
  pairingCode: String?,
6015
6805
  securePairingCapable: Bool
6016
- ) {
6017
- discoveredAdvPairing[name] = CachedAdvPairing(
6806
+ ) -> Bool {
6807
+ let next = CachedAdvPairing(
6018
6808
  pairingMode: pairingMode,
6019
6809
  pairingCode: pairingCode,
6020
6810
  securePairingCapable: securePairingCapable
6021
6811
  )
6812
+ if discoveredAdvPairing[name] == next {
6813
+ return false
6814
+ }
6815
+ discoveredAdvPairing[name] = next
6816
+ return true
6022
6817
  }
6023
6818
 
6024
6819
  private func emitDiscoveredDeviceFromCache(
@@ -6139,9 +6934,14 @@ class MentraLive: NSObject, SGCManager {
6139
6934
  // MARK: - Cleanup
6140
6935
 
6141
6936
  private func destroy() {
6142
- Bridge.log("Destroying MentraLiveManager")
6937
+ pairFlow(
6938
+ "destroy",
6939
+ "name=\(connectedPeripheral?.name ?? "-") id=\(connectedPeripheral?.identifier.uuidString ?? "-") scanning=\(isScanning)"
6940
+ )
6143
6941
 
6144
6942
  isKilled = true
6943
+ // The pairing state outlives the manager; only its timer belongs to this instance.
6944
+ cancelPairingPauseTimer()
6145
6945
  unpairFlushWorkItem?.cancel()
6146
6946
  unpairFlushWorkItem = nil
6147
6947
  unpairFlushPending = false
@@ -6185,6 +6985,92 @@ class MentraLive: NSObject, SGCManager {
6185
6985
  centralManager = nil
6186
6986
 
6187
6987
  updateConnectionState(ConnTypes.DISCONNECTED)
6988
+ resetSecurePairingSession()
6989
+ stopObservingAudioSetupInputs()
6990
+ }
6991
+ }
6992
+
6993
+ // MARK: - Audio setup
6994
+
6995
+ extension MentraLive {
6996
+ nonisolated static func monotonicNowMs() -> Int {
6997
+ Int(ProcessInfo.processInfo.systemUptime * 1000)
6998
+ }
6999
+
7000
+ private static let audioSetupInputKeys: Set<String> = [
7001
+ "fullyBooted", "bluetoothClassicConnected", "bluetoothClassicProfilesConnected", "bluetoothAudioRouted",
7002
+ ]
7003
+
7004
+ /// Readiness (from any writer, including `sr_hrt ready=0`) and the phone's three audio
7005
+ /// signals re-evaluate the state in the same update that changed them.
7006
+ fileprivate func observeAudioSetupInputs() {
7007
+ guard audioSetupStoreListenerId == nil else { return }
7008
+ audioSetup.startPhase(.reconnect, nowMs: audioSetupNowMs())
7009
+ audioSetupStoreListenerId = DeviceStore.shared.store.addListener { [weak self] category, changes in
7010
+ MainActor.assumeIsolated {
7011
+ guard category == "glasses",
7012
+ changes.keys.contains(where: { MentraLive.audioSetupInputKeys.contains($0) })
7013
+ else { return }
7014
+ self?.refreshAudioSetup(trigger: "status")
7015
+ }
7016
+ }
7017
+ refreshAudioSetup(trigger: "init")
7018
+ }
7019
+
7020
+ fileprivate func stopObservingAudioSetupInputs() {
7021
+ if let id = audioSetupStoreListenerId {
7022
+ DeviceStore.shared.store.removeListener(id)
7023
+ audioSetupStoreListenerId = nil
7024
+ }
7025
+ audioSetupDeadlineWorkItem?.cancel()
7026
+ audioSetupDeadlineWorkItem = nil
7027
+ }
7028
+
7029
+ /// The phone's view: the Classic bond, a connected HFP input or a matching Bluetooth output.
7030
+ private func phoneAudioUp() -> Bool {
7031
+ ["bluetoothClassicConnected", "bluetoothClassicProfilesConnected", "bluetoothAudioRouted"].contains {
7032
+ DeviceStore.shared.get("glasses", $0) as? Bool == true
7033
+ }
7034
+ }
7035
+
7036
+ /// The BLE session ended. Its `classic_link` report no longer applies.
7037
+ fileprivate func endAudioSetupSession() {
7038
+ audioSetup.endSession()
7039
+ #if !os(macOS)
7040
+ AudioSessionMonitor.glassesReportClassicLink = false
7041
+ #endif
7042
+ refreshAudioSetup(trigger: "disconnect")
7043
+ }
7044
+
7045
+ /// Feeds `GlassesAudioSetupPolicy`, arms one timer for its next deadline and publishes on change.
7046
+ /// Only the active driver publishes; a manager being created or torn down stays quiet.
7047
+ fileprivate func refreshAudioSetup(trigger: String, sessionReady: Bool? = nil) {
7048
+ guard (DeviceManager.shared.sgc as AnyObject?) === self else {
7049
+ audioSetupDeadlineWorkItem?.cancel()
7050
+ audioSetupDeadlineWorkItem = nil
7051
+ return
7052
+ }
7053
+ let nowMs = audioSetupNowMs()
7054
+ let decision = audioSetup.evaluate(
7055
+ sessionReady: sessionReady ?? fullyBooted,
7056
+ phoneAudioUp: phoneAudioUp(),
7057
+ nowMs: nowMs
7058
+ )
7059
+ audioSetupDeadlineWorkItem?.cancel()
7060
+ audioSetupDeadlineWorkItem = nil
7061
+ if let deadlineMs = decision.nextDeadlineMs {
7062
+ let work = DispatchWorkItem { [weak self] in
7063
+ guard let self else { return }
7064
+ self.audioSetupDeadlineWorkItem = nil
7065
+ self.refreshAudioSetup(trigger: "deadline")
7066
+ }
7067
+ audioSetupDeadlineWorkItem = work
7068
+ DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(max(deadlineMs - nowMs, 0)), execute: work)
7069
+ }
7070
+ guard DeviceStore.shared.publishAudioSetup(decision.state) else { return }
7071
+ Bridge.log(
7072
+ "PAIRFLOW: audio_setup state=\(decision.state.name) phase=\(audioSetup.phase.logName) afterMs=\(nowMs - audioSetup.phaseStartedAtMs) trigger=\(trigger) glassesReport=\(audioSetup.glassesReportsClassicLink ? "a2dp:\(audioSetup.glassesA2dp),hfp:\(audioSetup.glassesHfp)" : "none") phoneAudio=\(phoneAudioUp())"
7073
+ )
6188
7074
  }
6189
7075
  }
6190
7076
 
@@ -6288,6 +7174,7 @@ extension MentraLive {
6288
7174
 
6289
7175
  /// Sends phone_ready to (re)start the glasses-driven readiness flow.
6290
7176
  private func sendPhoneReady(reason: String) {
7177
+ pairFlow("phone_ready", "reason=\(reason) gatt=\(txCharacteristic != nil) id=\(connectedPeripheral?.identifier.uuidString ?? "-")")
6291
7178
  Bridge.log("LIVE: 📱 Sending phone_ready to glasses (\(reason)) - waiting for glasses_ready response")
6292
7179
  let readyMsg: [String: Any] = [
6293
7180
  "type": "phone_ready",