@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
@@ -85,6 +85,8 @@ struct ViewState {
85
85
  // Check if device is paired (don't activate to preserve A2DP music playback)
86
86
  let isPaired = AudioSessionMonitor.isDevicePaired(devicePattern: audioDevicePattern)
87
87
 
88
+ publishClassicAudioProfiles(devicePattern: audioDevicePattern)
89
+
88
90
  if isPaired {
89
91
  // Device is paired! Don't activate it - let PhoneMic.swift activate when recording starts
90
92
  Bridge.log("Audio: Mentra Live is paired (preserving A2DP for music)")
@@ -98,6 +100,7 @@ struct ViewState {
98
100
  AudioSessionMonitor.startMonitoring(devicePattern: audioDevicePattern) {
99
101
  [weak self] (connected: Bool, _: String?) in
100
102
  guard let self = self else { return }
103
+ self.publishClassicAudioProfiles(devicePattern: audioDevicePattern)
101
104
 
102
105
  if connected {
103
106
  Bridge.log("Audio: Device paired and connected")
@@ -133,6 +136,22 @@ struct ViewState {
133
136
  set { DeviceStore.shared.apply("bluetooth", "default_wearable", newValue) }
134
137
  }
135
138
 
139
+ /// Clears the retired pre-v4 owner-lost setting. The Mentra Live pairing state is native
140
+ /// (`MentraLivePairingStateStore`) and never rides the settings replay.
141
+ private func clearOwnerLost() {
142
+ guard DeviceStore.shared.get("bluetooth", "mentra_live_owner_lost") as? Bool == true else { return }
143
+ DeviceStore.shared.apply("bluetooth", "mentra_live_owner_lost", false)
144
+ Bridge.saveSetting("mentra_live_owner_lost", false)
145
+ }
146
+
147
+ /// The saved glasses' firmware is no longer known once they are forgotten. The next glasses
148
+ /// report it again with their first `pairing_info`.
149
+ private func clearSecureFirmware() {
150
+ guard DeviceStore.shared.get("bluetooth", "mentra_live_secure_firmware") as? Bool == true else { return }
151
+ DeviceStore.shared.apply("bluetooth", "mentra_live_secure_firmware", false)
152
+ Bridge.saveSetting("mentra_live_secure_firmware", false)
153
+ }
154
+
136
155
  private var pendingWearable: String {
137
156
  get { DeviceStore.shared.get("bluetooth", "pending_wearable") as? String ?? "" }
138
157
  set { DeviceStore.shared.apply("bluetooth", "pending_wearable", newValue) }
@@ -253,6 +272,26 @@ struct ViewState {
253
272
  set { DeviceStore.shared.apply("glasses", "bluetoothClassicConnected", newValue) }
254
273
  }
255
274
 
275
+ /// An HFP input is available or a matching Bluetooth output is active.
276
+ private var glassesBluetoothClassicProfilesConnected: Bool {
277
+ get { DeviceStore.shared.get("glasses", "bluetoothClassicProfilesConnected") as? Bool ?? false }
278
+ set { DeviceStore.shared.apply("glasses", "bluetoothClassicProfilesConnected", newValue) }
279
+ }
280
+
281
+ /// A matching Bluetooth output is the active audio route.
282
+ private var glassesBluetoothAudioRouted: Bool {
283
+ get { DeviceStore.shared.get("glasses", "bluetoothAudioRouted") as? Bool ?? false }
284
+ set { DeviceStore.shared.apply("glasses", "bluetoothAudioRouted", newValue) }
285
+ }
286
+
287
+ #if !os(macOS)
288
+ private func publishClassicAudioProfiles(devicePattern: String) {
289
+ let profiles = AudioSessionMonitor.observeProfiles(devicePattern: devicePattern)
290
+ glassesBluetoothClassicProfilesConnected = profiles.profilesConnected
291
+ glassesBluetoothAudioRouted = profiles.audioRouted
292
+ }
293
+ #endif
294
+
256
295
  private var micRanking: [String] {
257
296
  get {
258
297
  DeviceStore.shared.get("bluetooth", "micRanking") as? [String] ?? MicMap.map["auto"]!
@@ -943,17 +982,23 @@ struct ViewState {
943
982
 
944
983
  if audioDevicePattern.isEmpty || audioDevicePattern == DeviceTypes.SIMULATED {
945
984
  glassesBluetoothClassicConnected = false
985
+ glassesBluetoothClassicProfilesConnected = false
986
+ glassesBluetoothAudioRouted = false
946
987
  Bridge.log("MAN: Audio device pattern is empty or simulated, returning")
947
988
  return
948
989
  }
949
990
 
950
991
  #if os(macOS)
951
992
  glassesBluetoothClassicConnected = AudioSessionMonitor.isAudioDeviceConnected(devicePattern: audioDevicePattern)
993
+ // macOS only sees active audio devices, so every fact collapses to the same value.
994
+ glassesBluetoothClassicProfilesConnected = glassesBluetoothClassicConnected
995
+ glassesBluetoothAudioRouted = glassesBluetoothClassicConnected
952
996
  otherBtConnected = AudioSessionMonitor.isOtherAudioDeviceConnected(devicePattern: audioDevicePattern)
953
997
  #else
954
998
  // A connected HFP input remains eligible even while another output is
955
999
  // selected. Inspect available inputs as well as the active A2DP route.
956
1000
  glassesBluetoothClassicConnected = AudioSessionMonitor.isDevicePaired(devicePattern: audioDevicePattern)
1001
+ publishClassicAudioProfiles(devicePattern: audioDevicePattern)
957
1002
  otherBtConnected = AudioSessionMonitor.isOtherAudioDeviceConnected(devicePattern: audioDevicePattern)
958
1003
  #endif
959
1004
  }
@@ -1876,9 +1921,18 @@ struct ViewState {
1876
1921
  }
1877
1922
  initSGC(defaultWearable)
1878
1923
  (sgc as? MentraLive)?.setRequiresAncs(requiresAncs)
1879
- if let live = sgc as? MentraLive, live.isPairingYieldActive() {
1880
- Bridge.log("MAN: connectDefault skipped — Mentra Live pairing yield active")
1881
- return
1924
+ if let live = sgc as? MentraLive {
1925
+ let pairingState = MentraLivePairingStateStore.shared
1926
+ if pairingState.isRejected {
1927
+ Bridge.log("MAN: connectDefault skipped — Mentra Live pairing_rejected (Pair Again or forget)")
1928
+ return
1929
+ }
1930
+ if pairingState.isPaused {
1931
+ // The manager watches the advertisement and resumes on its own.
1932
+ Bridge.log("MAN: connectDefault deferred — Mentra Live pairing_mode")
1933
+ live.connectById(reconnectTarget)
1934
+ return
1935
+ }
1882
1936
  }
1883
1937
  searching = true
1884
1938
  sgc?.connectById(reconnectTarget)
@@ -1900,22 +1954,22 @@ struct ViewState {
1900
1954
  }
1901
1955
 
1902
1956
  func connectByName(_ dName: String, requiresAncs: Bool = true) {
1903
- Bridge.log("MAN: Connecting to wearable: \(dName)")
1904
1957
  var name = dName
1905
1958
 
1906
1959
  // use stored device name if available:
1907
1960
  if dName.isEmpty && !deviceName.isEmpty {
1908
1961
  name = deviceName
1909
1962
  }
1963
+ Bridge.log("PAIRFLOW: connect_by_name requested=\(dName) resolved=\(name)")
1910
1964
 
1911
1965
  if pendingWearable.isEmpty, defaultWearable.isEmpty {
1912
- Bridge.log("MAN: No pending or default wearable, returning")
1966
+ Bridge.log("PAIRFLOW: connect_by_name abort reason=no_wearable")
1913
1967
  return
1914
1968
  }
1915
1969
 
1916
1970
  guard NimoResourceFixture.shared.allowsManager(pendingWearable.isEmpty ? defaultWearable : pendingWearable, name: name) else { return }
1917
1971
  if pendingWearable.isEmpty, !defaultWearable.isEmpty {
1918
- Bridge.log("MAN: No pending wearable, using default wearable: \(defaultWearable)")
1972
+ Bridge.log("PAIRFLOW: connect_by_name pending=\(defaultWearable)")
1919
1973
  pendingWearable = defaultWearable
1920
1974
  }
1921
1975
 
@@ -1927,6 +1981,21 @@ struct ViewState {
1927
1981
  return
1928
1982
  }
1929
1983
 
1984
+ // Only an existing Mentra Live manager is reused. Calling initSGC first would swap out a
1985
+ // manager of another model here, before the queued handoff below disposes of it.
1986
+ if pendingWearable.contains(DeviceTypes.LIVE), let live = sgc as? MentraLive, !live.isManagerKilled {
1987
+ // The pairing scan already found this peripheral. Destroying the
1988
+ // manager here drops it, and the glasses are no longer discoverable
1989
+ // after the BLE bond, so the replacement scan never sees them.
1990
+ Bridge.log("PAIRFLOW: connect_by_name decision=reuse_manager name=\(name)")
1991
+ searching = true
1992
+ pendingDeviceName = name
1993
+ live.setRequiresAncs(requiresAncs)
1994
+ live.connectById(name)
1995
+ return
1996
+ }
1997
+
1998
+ Bridge.log("PAIRFLOW: connect_by_name decision=new_manager name=\(name)")
1930
1999
  let managerEntry = NimoResourceFixture.shared.beginManagerEntry(pendingWearable)
1931
2000
  Task {
1932
2001
  defer { NimoResourceFixture.shared.endManagerEntry(managerEntry) }
@@ -1972,6 +2041,8 @@ struct ViewState {
1972
2041
  #if os(macOS)
1973
2042
  audioRouteObserver = nil
1974
2043
  glassesBluetoothClassicConnected = false
2044
+ glassesBluetoothClassicProfilesConnected = false
2045
+ glassesBluetoothAudioRouted = false
1975
2046
  otherBtConnected = false
1976
2047
  #endif
1977
2048
  sgc?.clearDisplay() // clear the screen
@@ -2048,6 +2119,9 @@ struct ViewState {
2048
2119
 
2049
2120
  func forget() {
2050
2121
  Bridge.log("MAN: Forgetting smart glasses")
2122
+ MentraLivePairingStateStore.shared.update("forget") { $0.onForget() }
2123
+ clearOwnerLost()
2124
+ clearSecureFirmware()
2051
2125
  // Call forget first to stop timers/handlers/reconnect logic
2052
2126
  sgc?.forget()
2053
2127
  disconnect()
@@ -31,6 +31,8 @@ class DeviceStore {
31
31
  store.set("glasses", "micEnabled", false)
32
32
  store.set("glasses", "voiceActivityDetectionEnabled", BluetoothSdkDefaults.voiceActivityDetectionEnabled)
33
33
  store.set("glasses", "bluetoothClassicConnected", false)
34
+ store.set("glasses", "bluetoothClassicProfilesConnected", false)
35
+ store.set("glasses", "bluetoothAudioRouted", false)
34
36
  store.set("glasses", "caseRemoved", true)
35
37
  store.set("glasses", "caseOpen", true)
36
38
  store.set("glasses", "caseCharging", false)
@@ -59,6 +61,7 @@ class DeviceStore {
59
61
  store.set("glasses", "signalStrength", -1)
60
62
  store.set("glasses", "signalStrengthUpdatedAt", 0)
61
63
  store.set("glasses", "ringSignalStrength", -1)
64
+ store.set("glasses", "audioSetup", GlassesAudioSetupState.idle.toMap())
62
65
 
63
66
  // CORE STATE:
64
67
  store.set("bluetooth", "systemMicUnavailable", false)
@@ -144,10 +147,34 @@ class DeviceStore {
144
147
  }
145
148
  }
146
149
 
150
+ /// Publishes `GlassesStatus.audioSetup` and `audio_setup_changed` when the state changed.
151
+ @discardableResult
152
+ func publishAudioSetup(_ state: GlassesAudioSetupState) -> Bool {
153
+ guard GlassesAudioSetupState(map: store.get("glasses", "audioSetup")) != state else { return false }
154
+ let map = state.toMap()
155
+ store.set("glasses", "audioSetup", map)
156
+ Bridge.sendTypedMessage("audio_setup_changed", body: ["state": map])
157
+ return true
158
+ }
159
+
160
+ /// Glasses without a driver-specific audio rule report `not_applicable` while ready and
161
+ /// `idle` otherwise. Runs before readiness is stored, so the update that marks the glasses
162
+ /// ready already carries the matching `audioSetup`.
163
+ private func publishModelDefaultAudioSetup(_ category: String, _ key: String, _ value: Any) {
164
+ guard ObservableStore.normalizeCategory(category) == "glasses", key == "fullyBooted",
165
+ let ready = value as? Bool,
166
+ DeviceManager.shared.sgc?.publishesAudioSetup != true
167
+ else { return }
168
+ publishAudioSetup(.modelDefault(ready: ready))
169
+ }
170
+
147
171
  /// Apply changes with side effects
148
172
  func apply(_ category: String, _ key: String, _ value: Any) {
149
173
  let oldValue = store.get(category, key)
150
174
  let storeWouldSkipSet = store.wouldSkipSet(category, key, value)
175
+ if !storeWouldSkipSet {
176
+ publishModelDefaultAudioSetup(category, key, value)
177
+ }
151
178
  store.set(category, key, value)
152
179
  if storeWouldSkipSet {
153
180
  return
@@ -3,9 +3,9 @@ import Foundation
3
3
  /// Generated by release CI. Do not edit in a release checkout.
4
4
  enum GeneratedReleaseMetadata {
5
5
  static let familyBaseVersion = "3.4.0"
6
- static let releaseIdentity = "3.4.0-dev.993"
7
- static let releaseSetId = "mentra-3.4.0-dev.993"
8
- static let sourceCommit = "7fd0a5cfd9fb3db878f18f3975121c613a4b8fcc"
9
- static let otaManifestUrl = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.4.0/mentra-live-ota-3.4.0-dev.993.json"
10
- static let otaManifestSha256 = "fb9e951467dcb09ceca5e7b3c244f6e7c21f0fd61efadbf3c06d59134e272345"
6
+ static let releaseIdentity = "3.4.0-dev.998"
7
+ static let releaseSetId = "mentra-3.4.0-dev.998"
8
+ static let sourceCommit = "c77108af50fcaabe3f2c1464b2a5d649a30e337b"
9
+ static let otaManifestUrl = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.4.0/mentra-live-ota-3.4.0-dev.998.json"
10
+ static let otaManifestSha256 = "8c797478019499beb9a637bf418ff6586fd974115f712dfb51c7a96f6e08151c"
11
11
  }
@@ -549,6 +549,7 @@ public final class MentraBluetoothSDK {
549
549
  let isController = ControllerTypes.ALL.contains(device.model.deviceType)
550
550
  if !isController {
551
551
  requiresAncsForBluetoothRestore = options.requiresAncs
552
+ MentraLive.enableTransportBridging = options.enableTransportBridging
552
553
  }
553
554
  if options.cancelExistingConnectionAttempt {
554
555
  if isController {
@@ -557,6 +558,9 @@ public final class MentraBluetoothSDK {
557
558
  cancelConnectionAttempt()
558
559
  }
559
560
  }
561
+ if !isController {
562
+ MentraLive.clearStaleIosBondPrompt()
563
+ }
560
564
  if options.saveAsDefault && !isController {
561
565
  setDefaultDevice(device)
562
566
  } else if !isController {
@@ -575,6 +579,7 @@ public final class MentraBluetoothSDK {
575
579
  public func connectDefault(options: ConnectOptions = ConnectOptions()) throws {
576
580
  clearBluetoothRestoreIntent()
577
581
  requiresAncsForBluetoothRestore = options.requiresAncs
582
+ MentraLive.enableTransportBridging = options.enableTransportBridging
578
583
  guard let device = currentDefaultDevice() else {
579
584
  throw BluetoothSdkError(
580
585
  code: "default_device_missing",
@@ -2722,9 +2727,9 @@ public final class MentraBluetoothSDK {
2722
2727
  let event = GalleryStatusEvent(values: data)
2723
2728
  pendingGalleryStatus?.resolve(event)
2724
2729
  delegate?.mentraBluetoothSDK(self, didReceive: .raw(name: "gallery_status", values: event.values))
2725
- case "pairing_info":
2726
- delegate?.mentraBluetoothSDK(self, didReceive: .raw(name: "pairing_info", values: data))
2727
- case "entering_pairing_mode", "owner_replaced":
2730
+ case "pairing_info", "pairing_state_changed", "audio_setup_changed":
2731
+ delegate?.mentraBluetoothSDK(self, didReceive: .raw(name: eventName, values: data))
2732
+ case "entering_pairing_mode", "pairing_mode_ended", "owner_replaced":
2728
2733
  delegate?.mentraBluetoothSDK(self, didReceive: .raw(name: eventName, values: data))
2729
2734
  case "photo_response":
2730
2735
  let event = PhotoResponseEvent(values: data)
@@ -2820,7 +2825,8 @@ public final class MentraBluetoothSDK {
2820
2825
  self,
2821
2826
  didFail: BluetoothSdkError(
2822
2827
  code: "pair_failure",
2823
- message: data["error"] as? String ?? data.description
2828
+ message: data["error"] as? String ?? data.description,
2829
+ failureCode: data["code"] as? String
2824
2830
  )
2825
2831
  )
2826
2832
  default:
@@ -3,10 +3,14 @@ import Foundation
3
3
  public struct BluetoothSdkError: Error, LocalizedError, CustomStringConvertible {
4
4
  public let code: String
5
5
  public let message: String
6
+ /// Machine-readable reason a `pair_failure` carries (`pair_failure.code`), such as
7
+ /// `ios_forget_pairing`. Nil for other errors.
8
+ public let failureCode: String?
6
9
 
7
- public init(code: String, message: String) {
10
+ public init(code: String, message: String, failureCode: String? = nil) {
8
11
  self.code = code
9
12
  self.message = message
13
+ self.failureCode = failureCode
10
14
  }
11
15
 
12
16
  public var errorDescription: String? {
@@ -0,0 +1,181 @@
1
+ import Foundation
2
+
3
+ /// Whether the user has to do anything to get glasses audio working (`GlassesAudioSetupState` in
4
+ /// BluetoothSdk.types.ts). Published as `GlassesStatus.audioSetup` and `audio_setup_changed`.
5
+ /// Same map encoding as Android `GlassesAudioSetupState`.
6
+ enum GlassesAudioSetupState: Equatable {
7
+ /// No glasses session.
8
+ case idle
9
+ /// This platform and glasses need no user step for audio.
10
+ case notApplicable
11
+ /// The link is up, audio is not, and the SDK still expects audio to come up on its own.
12
+ case connecting
13
+ /// Classic audio to these glasses is up.
14
+ case connected
15
+ /// Audio did not come up on its own. The user connects it in system Bluetooth settings.
16
+ case needsUser(action: String)
17
+
18
+ static let connectInSettingsAction = "connect_in_settings"
19
+ static let needsUserConnectInSettings = GlassesAudioSetupState.needsUser(action: connectInSettingsAction)
20
+
21
+ var name: String {
22
+ switch self {
23
+ case .idle: return "idle"
24
+ case .notApplicable: return "not_applicable"
25
+ case .connecting: return "connecting"
26
+ case .connected: return "connected"
27
+ case .needsUser: return "needs_user"
28
+ }
29
+ }
30
+
31
+ /// Native map encoding: `{"state": "needs_user", "action": "connect_in_settings"}`.
32
+ func toMap() -> [String: Any] {
33
+ if case let .needsUser(action) = self {
34
+ return ["state": name, "action": action]
35
+ }
36
+ return ["state": name]
37
+ }
38
+
39
+ init?(map: Any?) {
40
+ guard let map = map as? [String: Any], let state = map["state"] as? String else { return nil }
41
+ switch state {
42
+ case "idle": self = .idle
43
+ case "not_applicable": self = .notApplicable
44
+ case "connecting": self = .connecting
45
+ case "connected": self = .connected
46
+ case "needs_user":
47
+ self = .needsUser(action: map["action"] as? String ?? Self.connectInSettingsAction)
48
+ default: return nil
49
+ }
50
+ }
51
+
52
+ /// Glasses without a driver-specific rule (every model except Mentra Live today): no user
53
+ /// step while the glasses are ready.
54
+ static func modelDefault(ready: Bool) -> GlassesAudioSetupState {
55
+ ready ? .notApplicable : .idle
56
+ }
57
+ }
58
+
59
+ /// When the SDK started waiting for audio. Recorded when the session becomes ready.
60
+ enum GlassesAudioSetupPhase: Equatable {
61
+ /// Readiness completed an explicit pairing attempt. `autoAudio` is this session's
62
+ /// `pairing_info.auto_audio`; old firmware never sends it.
63
+ case pairing(autoAudio: Bool)
64
+ /// Any other readiness, and audio lost after `connected`.
65
+ case reconnect
66
+
67
+ var logName: String {
68
+ switch self {
69
+ case let .pairing(autoAudio): return autoAudio ? "pairing_auto_audio" : "pairing"
70
+ case .reconnect: return "reconnect"
71
+ }
72
+ }
73
+ }
74
+
75
+ struct GlassesAudioSetupInputs: Equatable {
76
+ var sessionReady: Bool
77
+ /// A `classic_link` report arrived in this BLE session.
78
+ var glassesReportsClassicLink: Bool
79
+ var glassesA2dp: Bool
80
+ var glassesHfp: Bool
81
+ /// The phone's own signals: Classic bond, a connected HFP input or a matching Bluetooth output.
82
+ var phoneAudioUp: Bool
83
+ var phase: GlassesAudioSetupPhase
84
+ var phaseStartedAtMs: Int
85
+ }
86
+
87
+ struct GlassesAudioSetupDecision: Equatable {
88
+ let state: GlassesAudioSetupState
89
+ /// When the state changes on its own if no input changes first. Nil when it never does.
90
+ let nextDeadlineMs: Int?
91
+ }
92
+
93
+ /// iOS audio-setup rules for glasses that bring Classic audio up from the BLE pair. Pure: the
94
+ /// driver feeds inputs and arms one timer for `nextDeadlineMs`. Android has no user step and
95
+ /// reports `not_applicable` while ready.
96
+ enum GlassesAudioSetupPolicy {
97
+ static let pairingAutoAudioWaitMs = 8000
98
+ static let reconnectAudioGraceMs = 10000
99
+
100
+ /// Either source is enough. A glasses report of both links down never vetoes the phone's view.
101
+ static func audioUp(_ inputs: GlassesAudioSetupInputs) -> Bool {
102
+ (inputs.glassesReportsClassicLink && (inputs.glassesA2dp || inputs.glassesHfp)) || inputs.phoneAudioUp
103
+ }
104
+
105
+ static func waitMs(_ phase: GlassesAudioSetupPhase) -> Int {
106
+ switch phase {
107
+ case let .pairing(autoAudio): return autoAudio ? pairingAutoAudioWaitMs : 0
108
+ case .reconnect: return reconnectAudioGraceMs
109
+ }
110
+ }
111
+
112
+ static func decide(_ inputs: GlassesAudioSetupInputs, nowMs: Int) -> GlassesAudioSetupDecision {
113
+ guard inputs.sessionReady else { return GlassesAudioSetupDecision(state: .idle, nextDeadlineMs: nil) }
114
+ if audioUp(inputs) { return GlassesAudioSetupDecision(state: .connected, nextDeadlineMs: nil) }
115
+ // A report of A2DP and HFP both down does not shorten the wait: iOS may still be
116
+ // bringing the link up.
117
+ let deadline = inputs.phaseStartedAtMs + waitMs(inputs.phase)
118
+ if nowMs < deadline {
119
+ return GlassesAudioSetupDecision(state: .connecting, nextDeadlineMs: deadline)
120
+ }
121
+ return GlassesAudioSetupDecision(state: .needsUserConnectInSettings, nextDeadlineMs: nil)
122
+ }
123
+ }
124
+
125
+ /// Per-driver audio-setup bookkeeping around `GlassesAudioSetupPolicy`: the phase, the glasses'
126
+ /// `classic_link` report for the current BLE session, and the last published state.
127
+ struct GlassesAudioSetupTracker {
128
+ private(set) var phase: GlassesAudioSetupPhase = .reconnect
129
+ private(set) var phaseStartedAtMs = 0
130
+ private(set) var glassesReportsClassicLink = false
131
+ private(set) var glassesA2dp = false
132
+ private(set) var glassesHfp = false
133
+ private(set) var state: GlassesAudioSetupState = .idle
134
+
135
+ /// The session became ready.
136
+ mutating func startPhase(_ phase: GlassesAudioSetupPhase, nowMs: Int) {
137
+ self.phase = phase
138
+ phaseStartedAtMs = nowMs
139
+ }
140
+
141
+ mutating func classicLink(a2dp: Bool, hfp: Bool) {
142
+ glassesReportsClassicLink = true
143
+ glassesA2dp = a2dp
144
+ glassesHfp = hfp
145
+ }
146
+
147
+ /// The BLE session ended. The next session has not reported its Classic link yet.
148
+ mutating func endSession() {
149
+ glassesReportsClassicLink = false
150
+ glassesA2dp = false
151
+ glassesHfp = false
152
+ }
153
+
154
+ func inputs(sessionReady: Bool, phoneAudioUp: Bool) -> GlassesAudioSetupInputs {
155
+ GlassesAudioSetupInputs(
156
+ sessionReady: sessionReady,
157
+ glassesReportsClassicLink: glassesReportsClassicLink,
158
+ glassesA2dp: glassesA2dp,
159
+ glassesHfp: glassesHfp,
160
+ phoneAudioUp: phoneAudioUp,
161
+ phase: phase,
162
+ phaseStartedAtMs: phaseStartedAtMs
163
+ )
164
+ }
165
+
166
+ /// Re-evaluates and records the new state. Losing audio after `connected` starts the
167
+ /// reconnect wait again from `nowMs`.
168
+ mutating func evaluate(sessionReady: Bool, phoneAudioUp: Bool, nowMs: Int) -> GlassesAudioSetupDecision {
169
+ var decision = GlassesAudioSetupPolicy.decide(
170
+ inputs(sessionReady: sessionReady, phoneAudioUp: phoneAudioUp), nowMs: nowMs
171
+ )
172
+ if state == .connected, sessionReady, decision.state != .connected {
173
+ startPhase(.reconnect, nowMs: nowMs)
174
+ decision = GlassesAudioSetupPolicy.decide(
175
+ inputs(sessionReady: sessionReady, phoneAudioUp: phoneAudioUp), nowMs: nowMs
176
+ )
177
+ }
178
+ state = decision.state
179
+ return decision
180
+ }
181
+ }