@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
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import CoreBluetooth
|
|
1
2
|
@testable import MentraBluetoothSDK
|
|
2
3
|
import XCTest
|
|
3
4
|
|
|
@@ -93,10 +94,19 @@ final class MentraLiveSessionReadinessTests: XCTestCase {
|
|
|
93
94
|
BluetoothSdkDefaults.voiceActivityDetectionEnabled)
|
|
94
95
|
}
|
|
95
96
|
|
|
96
|
-
|
|
97
|
+
/// The pairing state is process-wide and persisted; every test starts and ends clean.
|
|
98
|
+
private func resetPairingState() {
|
|
99
|
+
UserDefaults.standard.removeObject(forKey: MentraLivePairingStateStore.defaultsKey)
|
|
100
|
+
MentraLivePairingStateStore.shared.resetForTest()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private func withPairingTransport(
|
|
104
|
+
_ body: (SessionReadinessTransport, MentraBluetoothSDK, ObservableStore) throws -> Void
|
|
105
|
+
) rethrows {
|
|
97
106
|
let store = DeviceStore.shared.store
|
|
98
107
|
let saved = ["bluetooth", "glasses"].map { ($0, store.getCategory($0)) }
|
|
99
108
|
let previous = DeviceManager.shared.sgc
|
|
109
|
+
resetPairingState()
|
|
100
110
|
let transport = SessionReadinessTransport()
|
|
101
111
|
let sdk = MentraBluetoothSDK()
|
|
102
112
|
DeviceManager.shared.sgc = transport
|
|
@@ -104,6 +114,7 @@ final class MentraLiveSessionReadinessTests: XCTestCase {
|
|
|
104
114
|
sdk.invalidate()
|
|
105
115
|
transport.cleanup()
|
|
106
116
|
DeviceManager.shared.sgc = previous
|
|
117
|
+
resetPairingState()
|
|
107
118
|
for (category, values) in saved {
|
|
108
119
|
for key in store.getCategory(category).keys where values[key] == nil {
|
|
109
120
|
store.remove(category, key)
|
|
@@ -111,36 +122,327 @@ final class MentraLiveSessionReadinessTests: XCTestCase {
|
|
|
111
122
|
for (key, value) in values { store.set(category, key, value) }
|
|
112
123
|
}
|
|
113
124
|
}
|
|
125
|
+
try body(transport, sdk, store)
|
|
126
|
+
}
|
|
114
127
|
|
|
115
|
-
|
|
116
|
-
|
|
128
|
+
private func receive(_ transport: MentraLive, _ value: [String: Any]) throws {
|
|
129
|
+
try transport.processReceivedData(JSONSerialization.data(withJSONObject: value))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
func testPairingPauseDuringPendingReadinessCleansUpAndLastsTheWindowPlusMargin() throws {
|
|
133
|
+
try withPairingTransport { transport, sdk, store in
|
|
134
|
+
try receive(transport, ["type": "glasses_ready", "sid": "original"])
|
|
135
|
+
try receive(transport, ["type": "version_info_1", "sid": "replacement", "build_number": "302010070"])
|
|
136
|
+
XCTAssertTrue(sdk.glassesStatus.connected)
|
|
137
|
+
XCTAssertFalse(sdk.glassesStatus.fullyBooted)
|
|
138
|
+
store.set("glasses", "headUp", true)
|
|
139
|
+
|
|
140
|
+
var enteringBodies: [[String: Any]] = []
|
|
141
|
+
var stateEvents: [[String: Any]] = []
|
|
142
|
+
let sink = Bridge.addEventSink { event, body in
|
|
143
|
+
if event == "entering_pairing_mode" { enteringBodies.append(body) }
|
|
144
|
+
if event == "pairing_state_changed", let state = body["state"] as? [String: Any] {
|
|
145
|
+
stateEvents.append(state)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
defer { Bridge.removeEventSink(sink) }
|
|
149
|
+
|
|
150
|
+
let before = MentraLivePairingStateStore.nowMs()
|
|
151
|
+
try receive(transport, [
|
|
152
|
+
"type": "entering_pairing_mode", "window_ms": 5000, "window_id": 7,
|
|
153
|
+
"owner_retained": true, "protocol_version": 4,
|
|
154
|
+
])
|
|
155
|
+
let after = MentraLivePairingStateStore.nowMs()
|
|
156
|
+
let pairingStore = MentraLivePairingStateStore.shared
|
|
157
|
+
XCTAssertTrue(pairingStore.isPaused)
|
|
158
|
+
XCTAssertFalse(transport.isManagerKilled)
|
|
159
|
+
guard case let .pairingMode(windowId, endsAtMs) = pairingStore.state else {
|
|
160
|
+
return XCTFail("expected pairing_mode, got \(pairingStore.describe())")
|
|
161
|
+
}
|
|
162
|
+
XCTAssertEqual(windowId, 7)
|
|
163
|
+
// window_ms + 30 s, with no 300 s floor.
|
|
164
|
+
XCTAssertGreaterThanOrEqual(endsAtMs, before + 35_000)
|
|
165
|
+
XCTAssertLessThanOrEqual(endsAtMs, after + 35_000)
|
|
166
|
+
|
|
167
|
+
XCTAssertEqual(enteringBodies.count, 1)
|
|
168
|
+
XCTAssertEqual(enteringBodies.first?["window_id"] as? Int, 7)
|
|
169
|
+
XCTAssertEqual(enteringBodies.first?["owner_retained"] as? Bool, true)
|
|
170
|
+
XCTAssertEqual(stateEvents.last?["state"] as? String, "pairing_mode")
|
|
171
|
+
XCTAssertEqual(stateEvents.last?["windowId"] as? Int, 7)
|
|
172
|
+
let published = store.get("glasses", "pairingState") as? [String: Any]
|
|
173
|
+
XCTAssertEqual(published?["state"] as? String, "pairing_mode")
|
|
174
|
+
XCTAssertEqual(sdk.glassesStatus.pairingState?["windowId"] as? Int, 7)
|
|
175
|
+
XCTAssertFalse(sdk.glassesStatus.connected)
|
|
176
|
+
XCTAssertEqual(store.get("glasses", "headUp") as? Bool, false)
|
|
177
|
+
|
|
178
|
+
// Readiness queued on the relinquished GATT must not end the pause.
|
|
179
|
+
try receive(transport, ["type": "glasses_ready", "sid": "replacement"])
|
|
180
|
+
XCTAssertTrue(pairingStore.isPaused)
|
|
181
|
+
XCTAssertFalse(sdk.glassesStatus.fullyBooted)
|
|
182
|
+
|
|
183
|
+
// The pause is persisted with its window id.
|
|
184
|
+
let record = UserDefaults.standard.dictionary(forKey: MentraLivePairingStateStore.defaultsKey)
|
|
185
|
+
XCTAssertEqual(record?[MentraLivePairingStateMachine.keyWindowId] as? Int, 7)
|
|
186
|
+
XCTAssertEqual(record?[MentraLivePairingStateMachine.keyState] as? String, "pairing_mode")
|
|
187
|
+
|
|
188
|
+
// pairing_mode_ended for another window is ignored; the matching one resumes early.
|
|
189
|
+
var endedBodies: [[String: Any]] = []
|
|
190
|
+
let endedSink = Bridge.addEventSink { event, body in
|
|
191
|
+
if event == "pairing_mode_ended" { endedBodies.append(body) }
|
|
192
|
+
}
|
|
193
|
+
defer { Bridge.removeEventSink(endedSink) }
|
|
194
|
+
try receive(transport, ["type": "pairing_mode_ended", "reason": "timeout", "window_id": 8])
|
|
195
|
+
XCTAssertTrue(pairingStore.isPaused)
|
|
196
|
+
try receive(transport, ["type": "pairing_mode_ended", "reason": "timeout", "window_id": 7])
|
|
197
|
+
XCTAssertEqual(pairingStore.state, .normal)
|
|
198
|
+
XCTAssertEqual(endedBodies.count, 2)
|
|
199
|
+
XCTAssertEqual(endedBodies.last?["reason"] as? String, "timeout")
|
|
200
|
+
XCTAssertEqual(endedBodies.last?["window_id"] as? Int, 7)
|
|
201
|
+
XCTAssertEqual(stateEvents.last?["state"] as? String, "normal")
|
|
117
202
|
}
|
|
118
|
-
|
|
119
|
-
try receive(["type": "version_info_1", "sid": "replacement", "build_number": "302010070"])
|
|
120
|
-
XCTAssertTrue(sdk.glassesStatus.connected)
|
|
121
|
-
XCTAssertFalse(sdk.glassesStatus.fullyBooted)
|
|
122
|
-
store.set("glasses", "headUp", true)
|
|
123
|
-
store.set("glasses", "voiceActivityDetectionEnabled", !BluetoothSdkDefaults.voiceActivityDetectionEnabled)
|
|
203
|
+
}
|
|
124
204
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
205
|
+
func testFlagOffFirmwareEnteringPairingModeIsAPersistentRejection() throws {
|
|
206
|
+
try withPairingTransport { transport, _, store in
|
|
207
|
+
var enteringEvents = 0
|
|
208
|
+
let sink = Bridge.addEventSink { event, _ in
|
|
209
|
+
if event == "entering_pairing_mode" { enteringEvents += 1 }
|
|
210
|
+
}
|
|
211
|
+
defer { Bridge.removeEventSink(sink) }
|
|
132
212
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
213
|
+
try receive(transport, ["type": "entering_pairing_mode", "window_ms": 5000])
|
|
214
|
+
let pairingStore = MentraLivePairingStateStore.shared
|
|
215
|
+
XCTAssertTrue(pairingStore.isRejected)
|
|
216
|
+
// The manager stays alive; the pairing state gates every automatic connect.
|
|
217
|
+
XCTAssertFalse(transport.isManagerKilled)
|
|
218
|
+
XCTAssertEqual(enteringEvents, 1)
|
|
219
|
+
XCTAssertEqual((store.get("glasses", "pairingState") as? [String: Any])?["state"] as? String, "pairing_rejected")
|
|
220
|
+
// Native code never writes the retired settings flag.
|
|
221
|
+
XCTAssertNotEqual(store.get("bluetooth", "mentra_live_owner_lost") as? Bool, true)
|
|
137
222
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
223
|
+
// A later pairing mode does not lift it, and the settings replay cannot either.
|
|
224
|
+
try receive(transport, ["type": "entering_pairing_mode", "window_ms": 5000])
|
|
225
|
+
store.set("bluetooth", "mentra_live_owner_lost", false)
|
|
226
|
+
XCTAssertTrue(pairingStore.isRejected)
|
|
227
|
+
|
|
228
|
+
// A restart keeps it.
|
|
229
|
+
pairingStore.resetForTest()
|
|
230
|
+
pairingStore.ensureRestored(savedDeviceId: "")
|
|
231
|
+
XCTAssertTrue(pairingStore.isRejected)
|
|
232
|
+
|
|
233
|
+
// A successful automatic session does not clear it; an explicit pair does. The
|
|
234
|
+
// selected row lists this flag-off unit as legacy firmware.
|
|
235
|
+
try receive(transport, ["type": "glasses_ready", "sid": "automatic"])
|
|
236
|
+
XCTAssertTrue(pairingStore.isRejected)
|
|
237
|
+
store.set("bluetooth", "pending_device_name", "MENTRA_LIVE_BLE_ABCD")
|
|
238
|
+
store.set("bluetooth", "pending_device_secure_pairing_capable", false)
|
|
239
|
+
try receive(transport, ["type": "glasses_ready", "sid": "explicit"])
|
|
240
|
+
XCTAssertEqual(pairingStore.state, .normal)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
func testPhoneHelloOnEverySecureSessionAndPairSelectOnlyOnAnExplicitAttempt() throws {
|
|
245
|
+
try withPairingTransport { transport, _, store in
|
|
246
|
+
let securePairingInfo: [String: Any] = [
|
|
247
|
+
"type": "pairing_info", "secure_pairing_capable": true, "protocol_version": 4,
|
|
248
|
+
"had_previous_bond": true, "owner_committed": true, "auto_audio": true,
|
|
249
|
+
]
|
|
250
|
+
var pairingInfoBodies: [[String: Any]] = []
|
|
251
|
+
let sink = Bridge.addEventSink { event, body in
|
|
252
|
+
if event == "pairing_info" { pairingInfoBodies.append(body) }
|
|
253
|
+
}
|
|
254
|
+
defer { Bridge.removeEventSink(sink) }
|
|
255
|
+
|
|
256
|
+
// Automatic reconnect: hello only.
|
|
257
|
+
try receive(transport, securePairingInfo)
|
|
258
|
+
try receive(transport, securePairingInfo)
|
|
259
|
+
let types = transport.commands.compactMap { $0["type"] as? String }
|
|
260
|
+
XCTAssertEqual(types.filter { $0 == "phone_hello" }.count, 1)
|
|
261
|
+
XCTAssertFalse(types.contains("pair_select"))
|
|
262
|
+
let hello = transport.commands.first { $0["type"] as? String == "phone_hello" }
|
|
263
|
+
XCTAssertEqual(hello?["pairing_protocol"] as? Int, 4)
|
|
264
|
+
XCTAssertEqual(pairingInfoBodies.last?["auto_audio"] as? Bool, true)
|
|
265
|
+
XCTAssertNil(pairingInfoBodies.last?["awaiting_confirm"])
|
|
266
|
+
XCTAssertNil(pairingInfoBodies.last?["retained_owner_link"])
|
|
267
|
+
|
|
268
|
+
// An explicit attempt on a new session also selects this phone.
|
|
269
|
+
transport.cleanup()
|
|
270
|
+
let explicitTransport = SessionReadinessTransport()
|
|
271
|
+
defer { explicitTransport.cleanup() }
|
|
272
|
+
store.set("bluetooth", "pending_device_name", "MENTRA_LIVE_BLE_ABCD")
|
|
273
|
+
store.set("bluetooth", "pending_device_secure_pairing_capable", true)
|
|
274
|
+
try receive(explicitTransport, securePairingInfo)
|
|
275
|
+
try receive(explicitTransport, securePairingInfo)
|
|
276
|
+
let explicitTypes = explicitTransport.commands.compactMap { $0["type"] as? String }
|
|
277
|
+
XCTAssertEqual(explicitTypes.filter { $0 == "phone_hello" }.count, 1)
|
|
278
|
+
XCTAssertEqual(explicitTypes.filter { $0 == "pair_select" }.count, 1)
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
func testLegacyFirmwareGetsNoPairingProtocolMessages() throws {
|
|
283
|
+
try withPairingTransport { transport, _, store in
|
|
284
|
+
store.set("bluetooth", "mentra_live_secure_firmware", false)
|
|
285
|
+
store.set("bluetooth", "pending_device_name", "MENTRA_LIVE_BLE_ABCD")
|
|
286
|
+
try receive(transport, ["type": "pairing_info", "had_previous_bond": false])
|
|
287
|
+
let types = transport.commands.compactMap { $0["type"] as? String }
|
|
288
|
+
XCTAssertFalse(types.contains("phone_hello"))
|
|
289
|
+
XCTAssertFalse(types.contains("pair_select"))
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
func testError14OnAnExplicitAttemptAsksToForgetThePairingWithItsCode() throws {
|
|
294
|
+
try withPairingTransport { transport, _, store in
|
|
295
|
+
var failures: [[String: Any]] = []
|
|
296
|
+
let sink = Bridge.addEventSink { event, body in
|
|
297
|
+
if event == "pair_failure" { failures.append(body) }
|
|
298
|
+
}
|
|
299
|
+
defer { Bridge.removeEventSink(sink) }
|
|
300
|
+
MentraLive.clearStaleIosBondPrompt()
|
|
301
|
+
defer { MentraLive.clearStaleIosBondPrompt() }
|
|
302
|
+
|
|
303
|
+
let error14 = NSError(domain: CBErrorDomain, code: CBError.peerRemovedPairingInformation.rawValue)
|
|
304
|
+
store.set("bluetooth", "pending_device_name", "MENTRA_LIVE_BLE_ABCD")
|
|
305
|
+
XCTAssertTrue(transport.stopAfterConnectFailure(error14))
|
|
306
|
+
XCTAssertEqual(failures.count, 1)
|
|
307
|
+
XCTAssertEqual(failures.first?["code"] as? String, "ios_forget_pairing")
|
|
308
|
+
XCTAssertEqual(failures.first?["error"] as? String, "errors:pairingCouldNotStart")
|
|
309
|
+
// An explicit attempt never counts toward a rejection.
|
|
310
|
+
XCTAssertEqual(MentraLivePairingStateStore.shared.state, .normal)
|
|
311
|
+
|
|
312
|
+
// On an automatic reconnect error 14 is one strike, not a prompt.
|
|
313
|
+
store.set("bluetooth", "pending_device_name", "")
|
|
314
|
+
store.set("bluetooth", "mentra_live_secure_firmware", true)
|
|
315
|
+
_ = transport.stopAfterConnectFailure(error14)
|
|
316
|
+
XCTAssertEqual(failures.count, 1)
|
|
317
|
+
XCTAssertEqual(MentraLivePairingStateStore.shared.state, .normal)
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// MARK: - Pairing readiness by target capability
|
|
322
|
+
|
|
323
|
+
private func runMainLoop(until condition: () -> Bool, timeout: TimeInterval = 3) {
|
|
324
|
+
let deadline = Date().addingTimeInterval(timeout)
|
|
325
|
+
while !condition(), Date() < deadline {
|
|
326
|
+
RunLoop.main.run(until: Date().addingTimeInterval(0.01))
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
private func ready(_ store: ObservableStore) -> Bool {
|
|
331
|
+
store.get("glasses", "fullyBooted") as? Bool == true
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/// An explicit pair to the row `capability` (nil when the row did not know it).
|
|
335
|
+
private func startExplicitPair(_ store: ObservableStore, capability: Bool?) {
|
|
336
|
+
store.set("glasses", "fullyBooted", false)
|
|
337
|
+
store.set("bluetooth", "pending_device_name", "MENTRA_LIVE_BLE_ABCD")
|
|
338
|
+
store.set("bluetooth", "pending_device_secure_pairing_capable", capability.map { $0 as Any } ?? "")
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
func testUnknownCapabilityWithoutPairingInfoIsReportedReadyAfterTheWait() throws {
|
|
342
|
+
try withPairingTransport { transport, _, store in
|
|
343
|
+
transport.unknownCapabilityPairingInfoWaitMs = 50
|
|
344
|
+
startExplicitPair(store, capability: nil)
|
|
345
|
+
try receive(transport, ["type": "glasses_ready", "sid": "legacy"])
|
|
346
|
+
XCTAssertFalse(ready(store), "readiness waits, bounded, for pairing_info")
|
|
347
|
+
|
|
348
|
+
runMainLoop { self.ready(store) }
|
|
349
|
+
XCTAssertTrue(ready(store), "no pairing_info: legacy firmware, reported ready")
|
|
350
|
+
let types = transport.commands.compactMap { $0["type"] as? String }
|
|
351
|
+
XCTAssertFalse(types.contains("pair_select"))
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
func testUnknownCapabilityWithASecurePairingInfoFollowsTheSecureRule() throws {
|
|
356
|
+
try withPairingTransport { transport, _, store in
|
|
357
|
+
var failures = 0
|
|
358
|
+
let sink = Bridge.addEventSink { event, _ in
|
|
359
|
+
if event == "pair_failure" { failures += 1 }
|
|
360
|
+
}
|
|
361
|
+
defer { Bridge.removeEventSink(sink) }
|
|
362
|
+
startExplicitPair(store, capability: nil)
|
|
363
|
+
try receive(transport, ["type": "glasses_ready", "sid": "secure"])
|
|
364
|
+
XCTAssertFalse(ready(store))
|
|
365
|
+
|
|
366
|
+
try receive(transport, [
|
|
367
|
+
"type": "pairing_info", "secure_pairing_capable": true, "owner_committed": false,
|
|
368
|
+
"protocol_version": 4,
|
|
369
|
+
])
|
|
370
|
+
XCTAssertFalse(ready(store), "secure firmware: ready only after the owner commit")
|
|
371
|
+
let types = transport.commands.compactMap { $0["type"] as? String }
|
|
372
|
+
XCTAssertEqual(types.filter { $0 == "pair_select" }.count, 1)
|
|
373
|
+
|
|
374
|
+
// The unknown-capability wait no longer applies.
|
|
375
|
+
RunLoop.main.run(until: Date().addingTimeInterval(0.1))
|
|
376
|
+
XCTAssertFalse(ready(store))
|
|
377
|
+
|
|
378
|
+
try receive(transport, [
|
|
379
|
+
"type": "pairing_info", "secure_pairing_capable": true, "owner_committed": true,
|
|
380
|
+
"protocol_version": 4,
|
|
381
|
+
])
|
|
382
|
+
XCTAssertTrue(ready(store))
|
|
383
|
+
XCTAssertEqual(failures, 0)
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
func testUnknownCapabilityWithALegacyPairingInfoIsReadyAtOnce() throws {
|
|
388
|
+
try withPairingTransport { transport, _, store in
|
|
389
|
+
startExplicitPair(store, capability: nil)
|
|
390
|
+
try receive(transport, ["type": "glasses_ready", "sid": "legacy"])
|
|
391
|
+
XCTAssertFalse(ready(store))
|
|
392
|
+
try receive(transport, ["type": "pairing_info", "had_previous_bond": false])
|
|
393
|
+
XCTAssertTrue(ready(store))
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
func testTheSelectedRowDecidesTheSecureRuleNotTheSession() throws {
|
|
398
|
+
try withPairingTransport { transport, _, store in
|
|
399
|
+
// The row listed legacy firmware: no owner-commit wait and no pair_select, even if
|
|
400
|
+
// the glasses then report secure firmware.
|
|
401
|
+
startExplicitPair(store, capability: false)
|
|
402
|
+
try receive(transport, ["type": "pairing_info", "secure_pairing_capable": true, "protocol_version": 4])
|
|
403
|
+
try receive(transport, ["type": "glasses_ready", "sid": "row_legacy"])
|
|
404
|
+
XCTAssertTrue(ready(store))
|
|
405
|
+
XCTAssertFalse(transport.commands.contains { $0["type"] as? String == "pair_select" })
|
|
406
|
+
|
|
407
|
+
// A secure row waits for the owner commit.
|
|
408
|
+
transport.cleanup()
|
|
409
|
+
let secureTransport = SessionReadinessTransport()
|
|
410
|
+
DeviceManager.shared.sgc = secureTransport
|
|
411
|
+
defer { secureTransport.cleanup() }
|
|
412
|
+
startExplicitPair(store, capability: true)
|
|
413
|
+
try receive(secureTransport, ["type": "glasses_ready", "sid": "row_secure"])
|
|
414
|
+
XCTAssertFalse(ready(store))
|
|
415
|
+
try receive(secureTransport, [
|
|
416
|
+
"type": "pairing_info", "secure_pairing_capable": true, "owner_committed": true,
|
|
417
|
+
"protocol_version": 4,
|
|
418
|
+
])
|
|
419
|
+
XCTAssertTrue(ready(store))
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
func testAReadySessionWithoutPairingInfoClearsTheSecureFirmwareFlag() throws {
|
|
424
|
+
try withPairingTransport { transport, _, store in
|
|
425
|
+
transport.secureFirmwareRefreshMs = 50
|
|
426
|
+
store.set("bluetooth", "mentra_live_secure_firmware", true)
|
|
427
|
+
store.set("glasses", "fullyBooted", false)
|
|
428
|
+
try receive(transport, ["type": "glasses_ready", "sid": "rolled_back"])
|
|
429
|
+
XCTAssertTrue(ready(store))
|
|
430
|
+
runMainLoop { store.get("bluetooth", "mentra_live_secure_firmware") as? Bool == false }
|
|
431
|
+
XCTAssertEqual(store.get("bluetooth", "mentra_live_secure_firmware") as? Bool, false)
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
func testASecurePairingInfoKeepsTheSecureFirmwareFlag() throws {
|
|
436
|
+
try withPairingTransport { transport, _, store in
|
|
437
|
+
transport.secureFirmwareRefreshMs = 50
|
|
438
|
+
store.set("bluetooth", "mentra_live_secure_firmware", false)
|
|
439
|
+
store.set("glasses", "fullyBooted", false)
|
|
440
|
+
try receive(transport, ["type": "glasses_ready", "sid": "secure"])
|
|
441
|
+
try receive(transport, ["type": "pairing_info", "secure_pairing_capable": true, "protocol_version": 4])
|
|
442
|
+
runMainLoop { store.get("bluetooth", "mentra_live_secure_firmware") as? Bool == true }
|
|
443
|
+
RunLoop.main.run(until: Date().addingTimeInterval(0.15))
|
|
444
|
+
XCTAssertEqual(store.get("bluetooth", "mentra_live_secure_firmware") as? Bool, true)
|
|
445
|
+
}
|
|
144
446
|
}
|
|
145
447
|
|
|
146
448
|
func testInitialVersionChunkDoesNotStartAnExtraReadinessExchange() throws {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mentra/bluetooth-sdk",
|
|
3
|
-
"version": "3.4.0-dev.
|
|
3
|
+
"version": "3.4.0-dev.998",
|
|
4
4
|
"description": "SDK for communicating with smart glasses",
|
|
5
5
|
"main": "build/index.js",
|
|
6
6
|
"react-native": "src/index.ts",
|
|
@@ -68,6 +68,7 @@
|
|
|
68
68
|
"!ios/Pods",
|
|
69
69
|
"!ios/Pods/**",
|
|
70
70
|
"plugin/build",
|
|
71
|
+
"MIGRATION.md",
|
|
71
72
|
"README.md",
|
|
72
73
|
"scripts",
|
|
73
74
|
"src",
|
|
@@ -114,7 +115,7 @@
|
|
|
114
115
|
"registry": "https://registry.npmjs.org/"
|
|
115
116
|
},
|
|
116
117
|
"dependencies": {
|
|
117
|
-
"@mentra/cloud-protocol": "3.4.0-dev.
|
|
118
|
+
"@mentra/cloud-protocol": "3.4.0-dev.998"
|
|
118
119
|
},
|
|
119
120
|
"devDependencies": {
|
|
120
121
|
"@types/node": "^25.9.3",
|
|
@@ -876,9 +876,68 @@ export type StreamKeepAliveRequest = {
|
|
|
876
876
|
ackId: string
|
|
877
877
|
}
|
|
878
878
|
|
|
879
|
+
/**
|
|
880
|
+
* Stable machine-readable `pair_failure` causes. Unknown native codes still typecheck.
|
|
881
|
+
* `ios_forget_pairing`: iOS holds a stale Bluetooth pairing for these glasses during an
|
|
882
|
+
* explicit attempt; the user must remove it in Settings > Bluetooth before pairing again.
|
|
883
|
+
*/
|
|
884
|
+
export type PairFailureCode = "ios_forget_pairing" | string
|
|
885
|
+
|
|
879
886
|
export type PairFailureEvent = {
|
|
880
887
|
type: "pair_failure"
|
|
881
888
|
error: string
|
|
889
|
+
/** Stable cause, when the SDK can classify the failure. */
|
|
890
|
+
code?: PairFailureCode
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* Mentra Live pairing state, owned by the SDK.
|
|
895
|
+
* - `normal`: no pairing mode in effect; automatic reconnect is allowed.
|
|
896
|
+
* - `pairing_mode`: the glasses opened a pairing window. Automatic reconnect is paused until
|
|
897
|
+
* `endsAtMs` or until the glasses report the window ended. `windowId` identifies the
|
|
898
|
+
* window and survives a cold restart.
|
|
899
|
+
* - `pairing_rejected`: the glasses no longer accept this phone (another phone paired, or repeated
|
|
900
|
+
* authentication rejections on secure-pairing firmware). Reconnect has stopped. Render Pair Again.
|
|
901
|
+
*/
|
|
902
|
+
export type MentraLivePairingState =
|
|
903
|
+
| {state: "normal"}
|
|
904
|
+
| {state: "pairing_mode"; windowId: number; endsAtMs: number}
|
|
905
|
+
| {state: "pairing_rejected"}
|
|
906
|
+
|
|
907
|
+
export type PairingStateChangedEvent = {
|
|
908
|
+
state: MentraLivePairingState
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* Whether the user has to do anything to get glasses audio working, decided by the SDK for
|
|
913
|
+
* every glasses model. Render it; do not add your own audio timers or platform checks.
|
|
914
|
+
*
|
|
915
|
+
* - `idle`: no glasses session.
|
|
916
|
+
* - `not_applicable`: no user step for audio. Android attaches Classic audio from the BLE bond,
|
|
917
|
+
* and glasses without Classic audio have nothing to set up.
|
|
918
|
+
* - `connecting`: the glasses are connected, audio is not, and the SDK still expects audio to
|
|
919
|
+
* come up on its own (for example iPhone audio derived from the BLE pair).
|
|
920
|
+
* - `connected`: Classic audio (A2DP or HFP) to the glasses is up.
|
|
921
|
+
* - `needs_user`: audio did not come up on its own. Ask the user to connect the glasses in the
|
|
922
|
+
* system Bluetooth settings (iPhone: Settings > Bluetooth). The state moves to `connected`
|
|
923
|
+
* as soon as audio comes up.
|
|
924
|
+
*/
|
|
925
|
+
export type GlassesAudioSetupState =
|
|
926
|
+
| {state: "idle"}
|
|
927
|
+
| {state: "not_applicable"}
|
|
928
|
+
| {state: "connecting"}
|
|
929
|
+
| {state: "connected"}
|
|
930
|
+
| {state: "needs_user"; action: "connect_in_settings"}
|
|
931
|
+
|
|
932
|
+
export type AudioSetupChangedEvent = {
|
|
933
|
+
state: GlassesAudioSetupState
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
export type PairingModeEndedReason = "paired" | "cancelled" | "timeout" | string
|
|
937
|
+
|
|
938
|
+
export type PairingModeEndedEvent = {
|
|
939
|
+
reason: PairingModeEndedReason
|
|
940
|
+
window_id?: number
|
|
882
941
|
}
|
|
883
942
|
|
|
884
943
|
export type PairingInfoEvent = {
|
|
@@ -887,13 +946,27 @@ export type PairingInfoEvent = {
|
|
|
887
946
|
classic_bond_ready?: boolean
|
|
888
947
|
secure_pairing_capable?: boolean
|
|
889
948
|
protocol_version?: number
|
|
949
|
+
/** Present when the glasses build includes ownership transfer. */
|
|
950
|
+
ownership_transfer?: boolean
|
|
951
|
+
/** True when the glasses derive Classic audio from the LE pair (CTKD), so audio should connect on its own. */
|
|
952
|
+
auto_audio?: boolean
|
|
953
|
+
/** True only after a BLE bond committed the owner and the window closed. */
|
|
954
|
+
owner_committed?: boolean
|
|
890
955
|
}
|
|
891
956
|
|
|
892
957
|
export type EnteringPairingModeEvent = {
|
|
893
958
|
window_ms: number
|
|
959
|
+
/** Identifies this pairing window. Also carried in `pairing_mode_ended` and the advertisement. */
|
|
960
|
+
window_id?: number
|
|
894
961
|
reason?: string
|
|
962
|
+
/** The glasses kept their bonds when the window opened; they change only if another phone pairs. */
|
|
963
|
+
owner_retained?: boolean
|
|
895
964
|
}
|
|
896
965
|
|
|
966
|
+
/**
|
|
967
|
+
* @deprecated No longer emitted. Observe `pairing_state_changed` (or `GlassesStatus.pairingState`)
|
|
968
|
+
* and render Pair Again for `pairing_rejected`.
|
|
969
|
+
*/
|
|
897
970
|
export type OwnerReplacedEvent = {
|
|
898
971
|
reason: string
|
|
899
972
|
}
|
|
@@ -1182,6 +1255,10 @@ export type BluetoothSdkModuleEvents = {
|
|
|
1182
1255
|
pair_failure: (event: PairFailureEvent) => void
|
|
1183
1256
|
pairing_info: (event: PairingInfoEvent) => void
|
|
1184
1257
|
entering_pairing_mode: (event: EnteringPairingModeEvent) => void
|
|
1258
|
+
pairing_mode_ended: (event: PairingModeEndedEvent) => void
|
|
1259
|
+
pairing_state_changed: (event: PairingStateChangedEvent) => void
|
|
1260
|
+
audio_setup_changed: (event: AudioSetupChangedEvent) => void
|
|
1261
|
+
/** @deprecated No longer emitted. Use `pairing_state_changed`. */
|
|
1185
1262
|
owner_replaced: (event: OwnerReplacedEvent) => void
|
|
1186
1263
|
audio_pairing_needed: (event: AudioPairingNeededEvent) => void
|
|
1187
1264
|
audio_connected: (event: AudioConnectedEvent) => void
|
|
@@ -1346,6 +1423,10 @@ export type BluetoothSdkEventMap = {
|
|
|
1346
1423
|
pair_failure: PairFailureEvent
|
|
1347
1424
|
pairing_info: PairingInfoEvent
|
|
1348
1425
|
entering_pairing_mode: EnteringPairingModeEvent
|
|
1426
|
+
pairing_mode_ended: PairingModeEndedEvent
|
|
1427
|
+
pairing_state_changed: PairingStateChangedEvent
|
|
1428
|
+
audio_setup_changed: AudioSetupChangedEvent
|
|
1429
|
+
/** @deprecated No longer emitted. Use `pairing_state_changed`. */
|
|
1349
1430
|
owner_replaced: OwnerReplacedEvent
|
|
1350
1431
|
audio_pairing_needed: AudioPairingNeededEvent
|
|
1351
1432
|
audio_connected: AudioConnectedEvent
|
|
@@ -1639,6 +1720,10 @@ export interface GlassesStatus {
|
|
|
1639
1720
|
micEnabled: boolean
|
|
1640
1721
|
voiceActivityDetectionEnabled: boolean
|
|
1641
1722
|
bluetoothClassicConnected: boolean
|
|
1723
|
+
/** iOS: an HFP input is available or a matching Bluetooth output is active. Android: same as `bluetoothClassicConnected`. */
|
|
1724
|
+
bluetoothClassicProfilesConnected: boolean
|
|
1725
|
+
/** iOS: a matching Bluetooth output is the active audio route. Android: same as `bluetoothClassicConnected`. */
|
|
1726
|
+
bluetoothAudioRouted: boolean
|
|
1642
1727
|
signalStrength: number
|
|
1643
1728
|
/** Milliseconds since epoch when signalStrength was last refreshed by the phone BLE stack. */
|
|
1644
1729
|
signalStrengthUpdatedAt: number
|
|
@@ -1653,6 +1738,10 @@ export interface GlassesStatus {
|
|
|
1653
1738
|
leftMacAddress: string
|
|
1654
1739
|
rightMacAddress: string
|
|
1655
1740
|
buildNumber: string
|
|
1741
|
+
/** Mentra Live pairing state owned by the SDK. Absent on other models. */
|
|
1742
|
+
pairingState?: MentraLivePairingState
|
|
1743
|
+
/** Audio setup state owned by the SDK, on every model. See `GlassesAudioSetupState`. */
|
|
1744
|
+
audioSetup?: GlassesAudioSetupState
|
|
1656
1745
|
/** Glasses System.currentTimeMillis() from last version_info (clock skew detection). */
|
|
1657
1746
|
systemTimeMs?: number
|
|
1658
1747
|
otaVersionUrl: string
|
|
@@ -1747,6 +1836,14 @@ export interface ConnectOptions {
|
|
|
1747
1836
|
* Android accepts this option as a no-op.
|
|
1748
1837
|
*/
|
|
1749
1838
|
requiresAncs?: boolean
|
|
1839
|
+
/**
|
|
1840
|
+
* iOS Mentra Live only. When true, CoreBluetooth is asked to bring up the
|
|
1841
|
+
* Classic profiles (A2DP/HFP) after the BLE connection
|
|
1842
|
+
* (CBConnectPeripheralOptionEnableTransportBridgingKey). Defaults to true:
|
|
1843
|
+
* without it the pairing screen waits for an audio route that never appears.
|
|
1844
|
+
* Android accepts this option as a no-op.
|
|
1845
|
+
*/
|
|
1846
|
+
enableTransportBridging?: boolean
|
|
1750
1847
|
}
|
|
1751
1848
|
|
|
1752
1849
|
export type ScanResultsCallback = (devices: Device[]) => void
|
|
@@ -12,9 +12,9 @@ export interface BluetoothSdkReleaseMetadata {
|
|
|
12
12
|
export const BLUETOOTH_SDK_RELEASE_METADATA: Readonly<BluetoothSdkReleaseMetadata> = Object.freeze({
|
|
13
13
|
"schemaVersion": 1,
|
|
14
14
|
"familyBaseVersion": "3.4.0",
|
|
15
|
-
"releaseIdentity": "3.4.0-dev.
|
|
16
|
-
"releaseSetId": "mentra-3.4.0-dev.
|
|
17
|
-
"sourceCommit": "
|
|
18
|
-
"otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.4.0/mentra-live-ota-3.4.0-dev.
|
|
19
|
-
"otaManifestSha256": "
|
|
15
|
+
"releaseIdentity": "3.4.0-dev.998",
|
|
16
|
+
"releaseSetId": "mentra-3.4.0-dev.998",
|
|
17
|
+
"sourceCommit": "c77108af50fcaabe3f2c1464b2a5d649a30e337b",
|
|
18
|
+
"otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.4.0/mentra-live-ota-3.4.0-dev.998.json",
|
|
19
|
+
"otaManifestSha256": "8c797478019499beb9a637bf418ff6586fd974115f712dfb51c7a96f6e08151c"
|
|
20
20
|
})
|
package/src/index.ts
CHANGED
|
@@ -47,6 +47,9 @@ const PUBLIC_EVENT_NAMES = new Set<BluetoothSdkEventName>([
|
|
|
47
47
|
"pair_failure",
|
|
48
48
|
"pairing_info",
|
|
49
49
|
"entering_pairing_mode",
|
|
50
|
+
"pairing_mode_ended",
|
|
51
|
+
"pairing_state_changed",
|
|
52
|
+
"audio_setup_changed",
|
|
50
53
|
"owner_replaced",
|
|
51
54
|
"audio_pairing_needed",
|
|
52
55
|
"audio_connected",
|
|
@@ -266,9 +269,16 @@ export type {
|
|
|
266
269
|
OtaUpdateInfo,
|
|
267
270
|
ReleaseChangelog,
|
|
268
271
|
MtkUpdateCompleteEvent,
|
|
272
|
+
PairFailureCode,
|
|
269
273
|
PairFailureEvent,
|
|
270
274
|
PairingInfoEvent,
|
|
271
275
|
EnteringPairingModeEvent,
|
|
276
|
+
MentraLivePairingState,
|
|
277
|
+
PairingModeEndedEvent,
|
|
278
|
+
PairingModeEndedReason,
|
|
279
|
+
PairingStateChangedEvent,
|
|
280
|
+
GlassesAudioSetupState,
|
|
281
|
+
AudioSetupChangedEvent,
|
|
272
282
|
OwnerReplacedEvent,
|
|
273
283
|
PhotoCaptureMetadata,
|
|
274
284
|
PhotoResolvedConfig,
|