@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.
- package/MIGRATION.md +61 -0
- package/README.md +47 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +6 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +8 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +48 -4
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +21 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +2 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/connection/ConnectionModels.kt +5 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/GlassesAudioSetupPolicy.kt +64 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +788 -134
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLiveOwnerLossPolicy.kt +227 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingAdvertisement.kt +19 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingReadinessPolicy.kt +94 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingStateMachine.kt +411 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingStateStore.kt +124 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +50 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerOwnerLossTest.kt +146 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/GlassesStatusAudioSetupTest.kt +51 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/GlassesStatusClassicAudioTest.kt +2 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/GlassesAudioSetupPolicyTest.kt +65 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveAudioSetupReadinessTest.kt +231 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveOwnerLossPolicyTest.kt +368 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingAdvertisementParserTest.kt +20 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingReadinessPolicyTest.kt +136 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingStateMachineTest.kt +346 -0
- package/build/BluetoothSdk.types.d.ts +101 -0
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +1 -0
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/build/index.d.ts +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +3 -0
- package/build/index.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +8 -2
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/Bridge.swift +11 -3
- package/ios/Source/DeviceManager.swift +80 -6
- package/ios/Source/DeviceStore.swift +27 -0
- package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
- package/ios/Source/MentraBluetoothSDK.swift +10 -4
- package/ios/Source/errors/BluetoothSdkError.swift +5 -1
- package/ios/Source/sgcs/GlassesAudioSetupPolicy.swift +181 -0
- package/ios/Source/sgcs/MentraLive.swift +1053 -166
- package/ios/Source/sgcs/MentraLivePairingAdvertisement.swift +260 -1
- package/ios/Source/sgcs/MentraLivePairingStateMachine.swift +385 -0
- package/ios/Source/sgcs/MentraLivePairingStateStore.swift +121 -0
- package/ios/Source/sgcs/SGCManager.swift +7 -0
- package/ios/Source/sgcs/Simulated.swift +2 -0
- package/ios/Source/status/DeviceStatus.swift +35 -0
- package/ios/Source/types/DeviceModels.swift +8 -1
- package/ios/Source/utils/AudioSessionMonitor.swift +158 -5
- package/ios/Tests/BluetoothAudioRouteTests.swift +82 -0
- package/ios/Tests/GlassesAudioSetupPolicyTests.swift +191 -0
- package/ios/Tests/MentraLiveAudioSetupTests.swift +215 -0
- package/ios/Tests/MentraLiveConnectionOptionsTests.swift +55 -0
- package/ios/Tests/MentraLivePairingAdvertisementTests.swift +327 -0
- package/ios/Tests/MentraLivePairingStateMachineTests.swift +402 -0
- package/ios/Tests/MentraLiveSessionReadinessTests.swift +328 -26
- package/package.json +3 -2
- package/src/BluetoothSdk.types.ts +97 -0
- package/src/_private/BluetoothSdkModule.ts +1 -0
- package/src/generated/releaseMetadata.ts +5 -5
- 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
|
|
1880
|
-
|
|
1881
|
-
|
|
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("
|
|
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("
|
|
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.
|
|
7
|
-
static let releaseSetId = "mentra-3.4.0-dev.
|
|
8
|
-
static let sourceCommit = "
|
|
9
|
-
static let otaManifestUrl = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.4.0/mentra-live-ota-3.4.0-dev.
|
|
10
|
-
static let otaManifestSha256 = "
|
|
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:
|
|
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
|
+
}
|