@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
@@ -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
- func testPairingYieldDuringPendingReadinessCleansUpAndReclaimsAfterItsWindow() async throws {
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
- func receive(_ value: [String: Any]) throws {
116
- try transport.processReceivedData(JSONSerialization.data(withJSONObject: value))
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
- try receive(["type": "glasses_ready", "sid": "original"])
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
- try receive(["type": "entering_pairing_mode", "window_ms": 5000])
126
- XCTAssertTrue(transport.isPairingYieldActive())
127
- XCTAssertFalse(sdk.glassesStatus.connected)
128
- XCTAssertFalse(sdk.glassesStatus.fullyBooted)
129
- XCTAssertEqual(store.get("glasses", "headUp") as? Bool, false)
130
- XCTAssertEqual(store.get("glasses", "voiceActivityDetectionEnabled") as? Bool,
131
- BluetoothSdkDefaults.voiceActivityDetectionEnabled)
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
- // Readiness queued on the relinquished GATT must not close the yield.
134
- try receive(["type": "glasses_ready", "sid": "replacement"])
135
- XCTAssertTrue(transport.isPairingYieldActive())
136
- XCTAssertFalse(sdk.glassesStatus.fullyBooted)
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
- // Let the existing minimum yield expire, then accept a fresh readiness.
139
- try await Task.sleep(nanoseconds: 5_100_000_000)
140
- XCTAssertFalse(transport.isPairingYieldActive())
141
- try receive(["type": "glasses_ready", "sid": "reclaimed"])
142
- XCTAssertTrue(sdk.glassesStatus.connected)
143
- XCTAssertTrue(sdk.glassesStatus.fullyBooted)
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.993",
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.993"
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
@@ -361,6 +361,7 @@ const DEFAULT_CONNECT_OPTIONS: Required<ConnectOptions> = {
361
361
  saveAsDefault: true,
362
362
  cancelExistingConnectionAttempt: true,
363
363
  requiresAncs: true,
364
+ enableTransportBridging: true,
364
365
  }
365
366
 
366
367
  const DEFAULT_SCAN_TIMEOUT_MS = 15_000
@@ -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.993",
16
- "releaseSetId": "mentra-3.4.0-dev.993",
17
- "sourceCommit": "7fd0a5cfd9fb3db878f18f3975121c613a4b8fcc",
18
- "otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.4.0/mentra-live-ota-3.4.0-dev.993.json",
19
- "otaManifestSha256": "fb9e951467dcb09ceca5e7b3c244f6e7c21f0fd61efadbf3c06d59134e272345"
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,