@mentra/bluetooth-sdk 3.1.0-dev.7 → 3.1.0-dev.71

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 (51) hide show
  1. package/README.md +40 -34
  2. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +0 -5
  3. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +13 -8
  4. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +7 -0
  5. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  6. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +15 -0
  7. package/android/src/main/java/com/mentra/bluetoothsdk/ReleaseChangelog.kt +61 -0
  8. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +81 -69
  9. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingAdvertisement.kt +54 -0
  10. package/android/src/test/java/com/mentra/bluetoothsdk/ReleaseChangelogTest.kt +13 -0
  11. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingAdvertisementParserTest.kt +133 -0
  12. package/build/BluetoothSdk.types.d.ts +26 -5
  13. package/build/BluetoothSdk.types.d.ts.map +1 -1
  14. package/build/BluetoothSdk.types.js.map +1 -1
  15. package/build/changelogs.d.ts +7 -0
  16. package/build/changelogs.d.ts.map +1 -0
  17. package/build/changelogs.js +44 -0
  18. package/build/changelogs.js.map +1 -0
  19. package/build/generated/changelogCatalog.d.ts +6 -0
  20. package/build/generated/changelogCatalog.d.ts.map +1 -0
  21. package/build/generated/changelogCatalog.js +8 -0
  22. package/build/generated/changelogCatalog.js.map +1 -0
  23. package/build/generated/releaseMetadata.js +5 -5
  24. package/build/generated/releaseMetadata.js.map +1 -1
  25. package/build/index.d.ts +1 -1
  26. package/build/index.d.ts.map +1 -1
  27. package/build/index.js +14 -0
  28. package/build/index.js.map +1 -1
  29. package/build/ota-transport/index.d.ts +67 -0
  30. package/build/ota-transport/index.d.ts.map +1 -0
  31. package/build/ota-transport/index.js +58 -0
  32. package/build/ota-transport/index.js.map +1 -0
  33. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  34. package/ios/Source/Bridge.swift +1 -6
  35. package/ios/Source/DeviceManager.swift +13 -8
  36. package/ios/Source/GeneratedChangelogCatalog.swift +6 -0
  37. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  38. package/ios/Source/MentraBluetoothSDK.swift +17 -0
  39. package/ios/Source/ReleaseChangelog.swift +65 -0
  40. package/ios/Source/sgcs/MentraLive.swift +93 -80
  41. package/ios/Source/sgcs/MentraLivePairingAdvertisement.swift +64 -0
  42. package/ios/Tests/MentraLivePairingAdvertisementTests.swift +160 -0
  43. package/ios/Tests/ReleaseChangelogTests.swift +13 -0
  44. package/package.json +6 -1
  45. package/scripts/public-ota-api.test.mjs +37 -0
  46. package/src/BluetoothSdk.types.ts +28 -5
  47. package/src/changelogs.ts +44 -0
  48. package/src/generated/changelogCatalog.ts +9 -0
  49. package/src/generated/releaseMetadata.ts +5 -5
  50. package/src/index.ts +24 -0
  51. package/src/ota-transport/index.ts +116 -0
@@ -25,6 +25,35 @@ struct MentraLiveDevice {
25
25
  let address: String
26
26
  }
27
27
 
28
+ enum MentraLivePendingPairingTarget {
29
+ static func matches(
30
+ connectedName: String,
31
+ connectedIdentifier: String,
32
+ pendingName: String,
33
+ pendingIdentifier: String
34
+ ) -> Bool {
35
+ if !pendingIdentifier.isEmpty {
36
+ return connectedIdentifier.caseInsensitiveCompare(pendingIdentifier) == .orderedSame
37
+ }
38
+ return !pendingName.isEmpty && connectedName == pendingName
39
+ }
40
+
41
+ static func shouldRecover(
42
+ isConnected: Bool,
43
+ connectedName: String,
44
+ connectedIdentifier: String,
45
+ pendingName: String,
46
+ pendingIdentifier: String
47
+ ) -> Bool {
48
+ isConnected && matches(
49
+ connectedName: connectedName,
50
+ connectedIdentifier: connectedIdentifier,
51
+ pendingName: pendingName,
52
+ pendingIdentifier: pendingIdentifier
53
+ )
54
+ }
55
+ }
56
+
28
57
  // MARK: - BlePhotoUploadService
29
58
 
30
59
  class BlePhotoUploadService {
@@ -873,9 +902,10 @@ extension MentraLive: CBCentralManagerDelegate {
873
902
  if !isReconnectTarget && advertisesPairingFlag(advertisementData)
874
903
  && !isPairingDiscoverable(advertisementData)
875
904
  {
876
- // Nearby but not pairable: RN uses this for the empty-state hint, not the list.
905
+ // Keep nearby secure units visible with pairing-mode guidance, while RN blocks
906
+ // the connection until a pairable advertisement arrives.
877
907
  Bridge.log(
878
- "LIVE: Nearby \(name) is secure firmware not in pairing mode — hiding from scan list"
908
+ "LIVE: Nearby \(name) is secure firmware not in pairing mode — exposing as non-pairable"
879
909
  )
880
910
  discoveredPeripherals[name] = peripheral
881
911
  cacheAdvPairing(
@@ -942,11 +972,32 @@ extension MentraLive: CBCentralManagerDelegate {
942
972
  nonisolated func centralManager(_: CBCentralManager, didConnect peripheral: CBPeripheral) {
943
973
  DispatchQueue.main.async { [weak self] in
944
974
  guard let self else { return }
975
+ let matchesActiveAttempt = self.connectingPeripheral === peripheral
976
+ guard MentraLiveConnectionAttemptPolicy.shouldAcceptDidConnect(
977
+ pairingYieldActive: self.pairingYieldActive,
978
+ matchesActiveAttempt: matchesActiveAttempt
979
+ ) else {
980
+ Bridge.log(
981
+ "LIVE: Rejecting stale connection callback during pairing yield or after attempt replacement: \(peripheral.identifier)"
982
+ )
983
+ self.stopConnectionTimeout()
984
+ if self.connectingPeripheral === peripheral {
985
+ self.connectingPeripheral = nil
986
+ }
987
+ if self.connectedPeripheral === peripheral {
988
+ self.connectedPeripheral = nil
989
+ }
990
+ self.isConnecting = false
991
+ self.centralManager?.cancelPeripheralConnection(peripheral)
992
+ return
993
+ }
945
994
  Bridge.log("Connected to GATT server, discovering services...")
946
995
 
947
996
  self.stopConnectionTimeout()
948
997
  self.isConnecting = false
998
+ self.connectingPeripheral = nil
949
999
  self.connectedPeripheral = peripheral
1000
+ self.emitConnectedPendingDeviceForPairingScan()
950
1001
 
951
1002
  // Save device name and address for future reconnection
952
1003
  if let name = peripheral.name {
@@ -1345,56 +1396,17 @@ class MentraLive: NSObject, SGCManager {
1345
1396
  private let BLOCK_AUDIO_DUPLEX = false
1346
1397
  private static let voiceActivityDetectionSwitchType = 8
1347
1398
  private static let loudnessGateSwitchType = 10
1348
- private let mentraManufacturerId: UInt16 = 0xB822
1349
- // Payload-relative offset of the pairing flag, matching Android's index into the
1350
- // company-id-stripped manufacturer data from getManufacturerSpecificData().
1351
- private let advManufPairingFlagOffset = 5
1352
- // CoreBluetooth returns manufacturer data with the 2-byte company id prefix still attached,
1353
- // whereas Android strips it. Skip the prefix so both platforms read the same payload byte.
1354
- private let advManufCompanyIdLength = 2
1355
- private let advPairingDiscoverable: UInt8 = 0x01
1356
-
1357
- // CoreBluetooth returns manufacturer data prefixed with the 2-byte company id
1358
- // (little-endian) and does NOT filter by company id itself (unlike Android's
1359
- // getManufacturerSpecificData(companyId)), so every reader of this data must
1360
- // verify the company id before trusting any flag/trailer byte.
1361
- private func mentraManufacturerData(_ advertisementData: [String: Any]) -> Data? {
1362
- guard let manufData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data,
1363
- manufData.count >= advManufCompanyIdLength
1364
- else {
1365
- return nil
1366
- }
1367
- let companyId = UInt16(manufData[0]) | (UInt16(manufData[1]) << 8)
1368
- guard companyId == mentraManufacturerId else {
1369
- return nil
1370
- }
1371
- return manufData
1372
- }
1373
-
1374
- /// OS-1615 ads append `flag | version | capability | code_lo | code_hi` after the
1375
- /// connected byte. Field firmware uses the same 0xB822 company id but then writes
1376
- /// the XOR'd Classic MAC at those offsets. Length alone is not a pairing flag.
1377
- private func hasSecurePairingTrailer(_ advertisementData: [String: Any]) -> Bool {
1378
- let flagIndex = advManufCompanyIdLength + advManufPairingFlagOffset
1379
- let trailerBase = flagIndex + 1
1380
- guard let manufData = mentraManufacturerData(advertisementData),
1381
- manufData.count >= trailerBase + 4
1382
- else {
1383
- return false
1384
- }
1385
- let version = Int(manufData[trailerBase])
1386
- let capability = Int(manufData[trailerBase + 1])
1387
- return (1...15).contains(version) && (capability & 0x01) != 0
1399
+ private func pairingAdvertisement(
1400
+ _ advertisementData: [String: Any]
1401
+ ) -> MentraLivePairingAdvertisement? {
1402
+ MentraLivePairingAdvertisement.parse(
1403
+ coreBluetoothManufacturerData:
1404
+ advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data
1405
+ )
1388
1406
  }
1389
1407
 
1390
1408
  private func isPairingDiscoverable(_ advertisementData: [String: Any]) -> Bool {
1391
- guard hasSecurePairingTrailer(advertisementData),
1392
- let manufData = mentraManufacturerData(advertisementData)
1393
- else {
1394
- return false
1395
- }
1396
- let flagIndex = advManufCompanyIdLength + advManufPairingFlagOffset
1397
- return manufData[flagIndex] == advPairingDiscoverable
1409
+ pairingAdvertisement(advertisementData)?.pairingMode == true
1398
1410
  }
1399
1411
 
1400
1412
  private struct SecurePairingTrailer {
@@ -1403,24 +1415,19 @@ class MentraLive: NSObject, SGCManager {
1403
1415
  let secureCapable: Bool
1404
1416
  }
1405
1417
 
1406
- /// Trailer immediately after pairing flag: version | capability | code_lo | code_hi
1407
1418
  private func parseSecurePairingTrailer(_ advertisementData: [String: Any]) -> SecurePairingTrailer {
1408
- guard hasSecurePairingTrailer(advertisementData),
1409
- let manufData = mentraManufacturerData(advertisementData)
1410
- else {
1419
+ guard let advertisement = pairingAdvertisement(advertisementData) else {
1411
1420
  return SecurePairingTrailer(pairingMode: false, pairingCode: nil, secureCapable: false)
1412
1421
  }
1413
- let flagIndex = advManufCompanyIdLength + advManufPairingFlagOffset
1414
- let trailerBase = flagIndex + 1
1415
- let pairingMode = manufData[flagIndex] == advPairingDiscoverable
1416
- let codeLo = Int(manufData[trailerBase + 2])
1417
- let codeHi = Int(manufData[trailerBase + 3])
1418
- let code = String(format: "%02X%02X", codeHi, codeLo)
1419
- return SecurePairingTrailer(pairingMode: pairingMode, pairingCode: code, secureCapable: true)
1422
+ return SecurePairingTrailer(
1423
+ pairingMode: advertisement.pairingMode,
1424
+ pairingCode: advertisement.pairingCode,
1425
+ secureCapable: true
1426
+ )
1420
1427
  }
1421
1428
 
1422
1429
  private func advertisesPairingFlag(_ advertisementData: [String: Any]) -> Bool {
1423
- hasSecurePairingTrailer(advertisementData)
1430
+ pairingAdvertisement(advertisementData) != nil
1424
1431
  }
1425
1432
 
1426
1433
  var connectionState: String = ConnTypes.DISCONNECTED
@@ -1642,6 +1649,7 @@ class MentraLive: NSObject, SGCManager {
1642
1649
 
1643
1650
  // State Tracking
1644
1651
  private var isScanning = false
1652
+ private var manualDiscoveryActive = false
1645
1653
  private var isConnecting = false
1646
1654
  private var isKilled = false
1647
1655
  /// Glasses opened pairing window — stand down without forgetting identity/bonds.
@@ -1796,7 +1804,9 @@ class MentraLive: NSObject, SGCManager {
1796
1804
  // clear the saved device name:
1797
1805
  UserDefaults.standard.set("", forKey: PREFS_DEVICE_NAME)
1798
1806
 
1807
+ manualDiscoveryActive = true
1799
1808
  startScan()
1809
+ emitConnectedPendingDeviceForPairingScan()
1800
1810
  }
1801
1811
  }
1802
1812
 
@@ -2388,10 +2398,6 @@ class MentraLive: NSObject, SGCManager {
2388
2398
 
2389
2399
  centralManager?.scanForPeripherals(withServices: nil, options: scanOptions)
2390
2400
 
2391
- // Fresh advertisements refill pairing metadata. Re-emitting the last scan's
2392
- // cache would keep a unit pairable after it left pairing mode.
2393
- emitConnectedDeviceForPairingScan()
2394
-
2395
2401
  // var dName = DeviceManager.shared.deviceName
2396
2402
  // if dName.isEmpty {
2397
2403
  // dName = "MENTRA_LIVE"
@@ -2409,6 +2415,7 @@ class MentraLive: NSObject, SGCManager {
2409
2415
  }
2410
2416
 
2411
2417
  func stopScan() {
2418
+ manualDiscoveryActive = false
2412
2419
  guard isScanning else { return }
2413
2420
 
2414
2421
  centralManager?.stopScan()
@@ -2914,25 +2921,20 @@ class MentraLive: NSObject, SGCManager {
2914
2921
  let windowMs = max(5_000, min(180_000, json["window_ms"] as? Int ?? 120_000))
2915
2922
  Bridge.log("LIVE: Glasses entering pairing mode — yield \(windowMs)ms (no forget)")
2916
2923
  enterPairingYield(windowMs: windowMs)
2917
- var body: [String: Any] = [
2924
+ let body: [String: Any] = [
2918
2925
  "window_ms": windowMs,
2919
2926
  "reason": json["reason"] as? String ?? "user_gesture",
2920
2927
  ]
2921
- if let txn = json["txn"] {
2922
- body["txn"] = txn
2923
- }
2924
2928
  Bridge.sendTypedMessage("entering_pairing_mode", body: body)
2925
2929
 
2926
2930
  case "pairing_info":
2927
2931
  Bridge.sendPairingInfo(
2928
2932
  hadPreviousBond: json["had_previous_bond"] as? Bool ?? false,
2929
- transferId: json["transfer_id"] as? String,
2930
2933
  pairingCode: json["pairing_code"] as? String,
2931
2934
  classicBondReady: json["classic_bond_ready"] as? Bool ?? false,
2932
2935
  // Legacy firmware that omits this field is not secure-capable.
2933
2936
  securePairingCapable: json["secure_pairing_capable"] as? Bool ?? false,
2934
- protocolVersion: json["protocol_version"] as? Int ?? 1,
2935
- binding: json["binding"] as? String
2937
+ protocolVersion: json["protocol_version"] as? Int ?? 1
2936
2938
  )
2937
2939
 
2938
2940
  case "imu_response", "imu_stream_response", "imu_gesture_response",
@@ -5531,22 +5533,33 @@ class MentraLive: NSObject, SGCManager {
5531
5533
 
5532
5534
  // MARK: - Event Emission
5533
5535
 
5534
- /// Pairing scan listens for advertisements. A unit that is already GATT-connected
5535
- /// has stopped ADV, so emit it as pairable or the scan list stays empty.
5536
- private func emitConnectedDeviceForPairingScan() {
5537
- guard connected, let peripheral = connectedPeripheral, let name = peripheral.name,
5538
- name == "Xy_A" || name.hasPrefix("XyBLE_") || name.hasPrefix("MENTRA_LIVE_BLE")
5539
- || name.hasPrefix("MENTRA_LIVE_BT") || name.lowercased().hasPrefix("mentra_live")
5536
+ /// A selected pairing target can stop advertising once GATT connects, before readiness
5537
+ /// promotes it to the default device. Re-emit only that explicit pending target; an
5538
+ /// established owner's connected glasses have no pending identity and remain hidden.
5539
+ private func emitConnectedPendingDeviceForPairingScan() {
5540
+ guard manualDiscoveryActive, !pairingYieldActive, let peripheral = connectedPeripheral,
5541
+ let name = peripheral.name,
5542
+ MentraLivePendingPairingTarget.shouldRecover(
5543
+ isConnected: peripheral.state == .connected,
5544
+ connectedName: name,
5545
+ connectedIdentifier: peripheral.identifier.uuidString,
5546
+ pendingName: DeviceStore.shared.get("bluetooth", "pending_device_name") as? String ?? "",
5547
+ pendingIdentifier: DeviceStore.shared.get("bluetooth", "pending_device_address") as? String ?? ""
5548
+ )
5540
5549
  else {
5541
5550
  return
5542
5551
  }
5543
- Bridge.log("LIVE: Pairing scan: already GATT-connected to \(name) — emitting as pairable (ADV off while connected)")
5552
+
5553
+ Bridge.log("LIVE: Pairing scan: recovering connected pending target \(name) (\(peripheral.identifier))")
5544
5554
  emitDiscoveredDevice(
5545
5555
  name,
5546
5556
  identifier: peripheral.identifier.uuidString,
5547
5557
  pairingMode: true,
5548
5558
  pairingCode: nil,
5549
- securePairingCapable: discoveredAdvPairing[name]?.securePairingCapable ?? false
5559
+ securePairingCapable: DeviceStore.shared.get(
5560
+ "bluetooth",
5561
+ "pending_device_secure_pairing_capable"
5562
+ ) as? Bool
5550
5563
  )
5551
5564
  }
5552
5565
 
@@ -0,0 +1,64 @@
1
+ import Foundation
2
+
3
+ struct MentraLivePairingAdvertisement: Equatable {
4
+ let pairingMode: Bool
5
+ let pairingCode: String
6
+
7
+ private static let manufacturerId: UInt16 = 0xB822
8
+ private static let companyIdLength = 2
9
+ private static let pairingFlagOffset = 5
10
+ private static let pairingDiscoverable: UInt8 = 0x01
11
+ private static let protocolVersionOffset = pairingFlagOffset + 1
12
+ private static let capabilityOffset = protocolVersionOffset + 1
13
+ private static let codeLowOffset = capabilityOffset + 1
14
+ private static let codeHighOffset = codeLowOffset + 1
15
+ private static let magicFirstOffset = codeHighOffset + 1
16
+ private static let magicSecondOffset = magicFirstOffset + 1
17
+ private static let protocolVersionRange = 2 ... 15
18
+ private static let securePairingCapability = 0x01
19
+ private static let magicFirst: UInt8 = 0x4D // M
20
+ private static let magicSecond: UInt8 = 0x50 // P
21
+
22
+ /// Parses CoreBluetooth manufacturer data, including its two-byte company-id prefix.
23
+ ///
24
+ /// Legacy firmware stores an XOR'd Classic MAC where the original pairing implementation
25
+ /// expected version and capability bytes. Requiring the `MP` marker makes the formats
26
+ /// unambiguous instead of probabilistically classifying MAC bytes as a secure trailer.
27
+ static func parse(coreBluetoothManufacturerData data: Data?) -> MentraLivePairingAdvertisement? {
28
+ guard let data, data.count > companyIdLength + magicSecondOffset else {
29
+ return nil
30
+ }
31
+
32
+ let companyId = UInt16(data[0]) | (UInt16(data[1]) << 8)
33
+ guard companyId == manufacturerId else {
34
+ return nil
35
+ }
36
+
37
+ let payloadBase = companyIdLength
38
+ let version = Int(data[payloadBase + protocolVersionOffset])
39
+ let capability = Int(data[payloadBase + capabilityOffset])
40
+ guard protocolVersionRange.contains(version),
41
+ capability & securePairingCapability != 0,
42
+ data[payloadBase + magicFirstOffset] == magicFirst,
43
+ data[payloadBase + magicSecondOffset] == magicSecond
44
+ else {
45
+ return nil
46
+ }
47
+
48
+ let codeLow = Int(data[payloadBase + codeLowOffset])
49
+ let codeHigh = Int(data[payloadBase + codeHighOffset])
50
+ return MentraLivePairingAdvertisement(
51
+ pairingMode: data[payloadBase + pairingFlagOffset] == pairingDiscoverable,
52
+ pairingCode: String(format: "%02X%02X", codeHigh, codeLow)
53
+ )
54
+ }
55
+ }
56
+
57
+ enum MentraLiveConnectionAttemptPolicy {
58
+ static func shouldAcceptDidConnect(
59
+ pairingYieldActive: Bool,
60
+ matchesActiveAttempt: Bool
61
+ ) -> Bool {
62
+ !pairingYieldActive && matchesActiveAttempt
63
+ }
64
+ }
@@ -0,0 +1,160 @@
1
+ import Foundation
2
+ @testable import MentraBluetoothSDK
3
+ import XCTest
4
+
5
+ final class MentraLivePairingAdvertisementTests: XCTestCase {
6
+ func testConnectedPairingRecoveryRequiresExplicitPendingTarget() {
7
+ XCTAssertFalse(
8
+ MentraLivePendingPairingTarget.matches(
9
+ connectedName: "MENTRA_LIVE_BLE_OWNER",
10
+ connectedIdentifier: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE",
11
+ pendingName: "",
12
+ pendingIdentifier: ""
13
+ )
14
+ )
15
+ XCTAssertTrue(
16
+ MentraLivePendingPairingTarget.matches(
17
+ connectedName: "MENTRA_LIVE_BLE_TARGET",
18
+ connectedIdentifier: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE",
19
+ pendingName: "MENTRA_LIVE_BLE_TARGET",
20
+ pendingIdentifier: ""
21
+ )
22
+ )
23
+ XCTAssertTrue(
24
+ MentraLivePendingPairingTarget.matches(
25
+ connectedName: "MENTRA_LIVE_BLE_TARGET",
26
+ connectedIdentifier: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE",
27
+ pendingName: "OTHER",
28
+ pendingIdentifier: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
29
+ )
30
+ )
31
+ XCTAssertFalse(
32
+ MentraLivePendingPairingTarget.matches(
33
+ connectedName: "MENTRA_LIVE_BLE_TARGET",
34
+ connectedIdentifier: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE",
35
+ pendingName: "MENTRA_LIVE_BLE_TARGET",
36
+ pendingIdentifier: "11111111-2222-3333-4444-555555555555"
37
+ )
38
+ )
39
+ }
40
+
41
+ func testConnectedPairingRecoveryRequiresActiveGattConnection() {
42
+ XCTAssertFalse(
43
+ MentraLivePendingPairingTarget.shouldRecover(
44
+ isConnected: false,
45
+ connectedName: "MENTRA_LIVE_BLE_TARGET",
46
+ connectedIdentifier: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE",
47
+ pendingName: "MENTRA_LIVE_BLE_TARGET",
48
+ pendingIdentifier: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE"
49
+ )
50
+ )
51
+ XCTAssertTrue(
52
+ MentraLivePendingPairingTarget.shouldRecover(
53
+ isConnected: true,
54
+ connectedName: "MENTRA_LIVE_BLE_TARGET",
55
+ connectedIdentifier: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE",
56
+ pendingName: "MENTRA_LIVE_BLE_TARGET",
57
+ pendingIdentifier: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE"
58
+ )
59
+ )
60
+ }
61
+
62
+ func testConnectionCallbackPolicyRejectsPairingYieldAndStaleAttempts() {
63
+ XCTAssertFalse(
64
+ MentraLiveConnectionAttemptPolicy.shouldAcceptDidConnect(
65
+ pairingYieldActive: true,
66
+ matchesActiveAttempt: true
67
+ )
68
+ )
69
+ XCTAssertFalse(
70
+ MentraLiveConnectionAttemptPolicy.shouldAcceptDidConnect(
71
+ pairingYieldActive: false,
72
+ matchesActiveAttempt: false
73
+ )
74
+ )
75
+ XCTAssertTrue(
76
+ MentraLiveConnectionAttemptPolicy.shouldAcceptDidConnect(
77
+ pairingYieldActive: false,
78
+ matchesActiveAttempt: true
79
+ )
80
+ )
81
+ }
82
+
83
+ func testParsesMarkedSecurePairingAdvertisement() {
84
+ let result = MentraLivePairingAdvertisement.parse(
85
+ coreBluetoothManufacturerData: securePayload(pairingFlag: 1)
86
+ )
87
+
88
+ XCTAssertEqual(result?.pairingCode, "1234")
89
+ XCTAssertEqual(result?.pairingMode, true)
90
+ }
91
+
92
+ func testParsesMarkedOwnedAdvertisement() {
93
+ let result = MentraLivePairingAdvertisement.parse(
94
+ coreBluetoothManufacturerData: securePayload(pairingFlag: 0)
95
+ )
96
+
97
+ XCTAssertEqual(result?.pairingCode, "1234")
98
+ XCTAssertEqual(result?.pairingMode, false)
99
+ }
100
+
101
+ func testRejectsLegacyPayloadThatMatchesOldVersionCapabilityHeuristic() {
102
+ var payload = legacyPayload()
103
+ payload[2 + 5] = 0
104
+ payload[2 + 6] = 1
105
+ payload[2 + 7] = 1
106
+ payload[2 + 8] = 0x34
107
+ payload[2 + 9] = 0x12
108
+
109
+ XCTAssertNil(
110
+ MentraLivePairingAdvertisement.parse(coreBluetoothManufacturerData: payload)
111
+ )
112
+ }
113
+
114
+ func testRejectsUnmarkedFirstGenerationSecureTrailer() {
115
+ var payload = securePayload(pairingFlag: 1)
116
+ payload[2 + 10] = 0x11
117
+ payload[2 + 11] = 0x22
118
+
119
+ XCTAssertNil(
120
+ MentraLivePairingAdvertisement.parse(coreBluetoothManufacturerData: payload)
121
+ )
122
+ }
123
+
124
+ func testRejectsEveryLegacyVersionAndCapabilityCombination() {
125
+ for version in UInt8.min ... UInt8.max {
126
+ for capability in UInt8.min ... UInt8.max {
127
+ var payload = legacyPayload()
128
+ payload[2 + 5] = 1
129
+ payload[2 + 6] = version
130
+ payload[2 + 7] = capability
131
+ payload[2 + 10] = 0x4D
132
+ // Offset 11 is padding in the legacy format, so it cannot contain the second
133
+ // non-zero marker byte.
134
+ payload[2 + 11] = 0
135
+ XCTAssertNil(
136
+ MentraLivePairingAdvertisement.parse(coreBluetoothManufacturerData: payload)
137
+ )
138
+ }
139
+ }
140
+ }
141
+
142
+ private func legacyPayload() -> Data {
143
+ var data = Data(repeating: 0, count: 29)
144
+ data[0] = 0x22
145
+ data[1] = 0xB8
146
+ return data
147
+ }
148
+
149
+ private func securePayload(pairingFlag: UInt8) -> Data {
150
+ var data = legacyPayload()
151
+ data[2 + 5] = pairingFlag
152
+ data[2 + 6] = 2
153
+ data[2 + 7] = 1
154
+ data[2 + 8] = 0x34
155
+ data[2 + 9] = 0x12
156
+ data[2 + 10] = 0x4D
157
+ data[2 + 11] = 0x50
158
+ return data
159
+ }
160
+ }
@@ -0,0 +1,13 @@
1
+ @testable import MentraBluetoothSDK
2
+ import XCTest
3
+
4
+ final class ReleaseChangelogTests: XCTestCase {
5
+ func testIncludesTargetNotesForTransitionWithinOneReleaseTrain() throws {
6
+ let changelogs = try ReleaseChangelogCatalog.select(
7
+ fromVersion: "3.1.0-dev.2",
8
+ toVersion: "3.1.0-beta.8"
9
+ )
10
+
11
+ XCTAssertEqual(changelogs.map(\.version), ["3.1.0"])
12
+ }
13
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mentra/bluetooth-sdk",
3
- "version": "3.1.0-dev.7",
3
+ "version": "3.1.0-dev.71",
4
4
  "description": "SDK for communicating with smart glasses",
5
5
  "main": "build/index.js",
6
6
  "react-native": "src/index.ts",
@@ -32,6 +32,11 @@
32
32
  "types": "./build/photo-receiver/index.d.ts",
33
33
  "default": "./build/photo-receiver/index.js"
34
34
  },
35
+ "./ota-transport": {
36
+ "react-native": "./src/ota-transport/index.ts",
37
+ "types": "./build/ota-transport/index.d.ts",
38
+ "default": "./build/ota-transport/index.js"
39
+ },
35
40
  "./debug": {
36
41
  "react-native": "./src/debug.ts",
37
42
  "types": "./build/debug.d.ts",
@@ -0,0 +1,37 @@
1
+ import assert from "node:assert/strict"
2
+ import {readFileSync} from "node:fs"
3
+ import path from "node:path"
4
+ import test from "node:test"
5
+ import {fileURLToPath} from "node:url"
6
+
7
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
8
+
9
+ test("publishes the supported OTA transport entrypoint", () => {
10
+ const manifest = JSON.parse(readFileSync(path.join(packageRoot, "package.json"), "utf8"))
11
+ assert.deepEqual(manifest.exports["./ota-transport"], {
12
+ "react-native": "./src/ota-transport/index.ts",
13
+ "types": "./build/ota-transport/index.d.ts",
14
+ "default": "./build/ota-transport/index.js",
15
+ })
16
+
17
+ const source = readFileSync(path.join(packageRoot, "src/ota-transport/index.ts"), "utf8")
18
+ assert.match(source, /export const otaLocalNetwork/)
19
+ assert.match(source, /export const otaServer/)
20
+ assert.doesNotMatch(source, /export \{default as Mentra(LocalNetwork|OtaServer)/)
21
+ })
22
+
23
+ test("keeps restart signals and correlated OTA status on the public root", () => {
24
+ const source = readFileSync(path.join(packageRoot, "src/index.ts"), "utf8")
25
+ for (const required of [
26
+ '"glasses_session_changed"',
27
+ '"mtk_update_complete"',
28
+ "queryOtaStatus",
29
+ "subscribeGlassesStatus",
30
+ "subscribeBluetoothStatus",
31
+ "setSystemTime",
32
+ "ping",
33
+ ]) {
34
+ assert.ok(source.includes(required), `missing public OTA primitive: ${required}`)
35
+ }
36
+ assert.match(source, /Unsupported BluetoothSdk event/)
37
+ })
@@ -652,20 +652,15 @@ export type PairFailureEvent = {
652
652
 
653
653
  export type PairingInfoEvent = {
654
654
  had_previous_bond: boolean
655
- /** 16-char uppercase hex transfer id when secure pairing is active. */
656
- transfer_id?: string
657
655
  pairing_code?: string
658
656
  classic_bond_ready?: boolean
659
657
  secure_pairing_capable?: boolean
660
658
  protocol_version?: number
661
- /** Credential binding mode negotiated for this transfer, when reported by the glasses. */
662
- binding?: "ctkd" | "temporal" | "none" | string
663
659
  }
664
660
 
665
661
  export type EnteringPairingModeEvent = {
666
662
  window_ms: number
667
663
  reason?: string
668
- txn?: number
669
664
  }
670
665
 
671
666
  export type OwnerReplacedEvent = {
@@ -1048,6 +1043,10 @@ export type BluetoothSdkEventMap = {
1048
1043
  mic_lc3: MicLc3Event
1049
1044
  mic_health: MicHealthEvent
1050
1045
  stream_status: StreamStatusEvent
1046
+ /** Mentra Live MTK updater completed and the glasses are about to restart. */
1047
+ mtk_update_complete: MtkUpdateCompleteEvent
1048
+ /** The ASG process restarted while the BES kept the BLE connection alive. */
1049
+ glasses_session_changed: GlassesSessionChangedEvent
1051
1050
  ota_start_ack: OtaStartAckEvent
1052
1051
  ota_status: OtaStatusEvent
1053
1052
  ar99_ota_status: Ar99OtaStatusEvent
@@ -1073,6 +1072,15 @@ export interface BluetoothSdkPublicModule {
1073
1072
  listener: BluetoothSdkEventListener<EventName>,
1074
1073
  ): BluetoothSdkSubscription
1075
1074
 
1075
+ /** Read an immutable snapshot of the current glasses state. */
1076
+ getGlassesStatus(): Promise<PublicGlassesStatus>
1077
+ /** Read an immutable snapshot of the phone Bluetooth adapter state. */
1078
+ getBluetoothStatus(): Promise<PublicBluetoothStatus>
1079
+ /** Observe immutable glasses-state patches. Returns an unsubscribe function. */
1080
+ subscribeGlassesStatus(listener: (changed: Partial<PublicGlassesStatus>) => void): () => void
1081
+ /** Observe immutable Bluetooth-state patches. Returns an unsubscribe function. */
1082
+ subscribeBluetoothStatus(listener: (changed: Partial<PublicBluetoothStatus>) => void): () => void
1083
+
1076
1084
  getDefaultDevice(): Promise<Device | null>
1077
1085
  setDefaultDevice(device: Device | null): Promise<void>
1078
1086
  clearDefaultDevice(): Promise<void>
@@ -1094,11 +1102,15 @@ export interface BluetoothSdkPublicModule {
1094
1102
  setHeadUpAngle(angleDegrees: number): Promise<void>
1095
1103
  setImuEnabled(enabled: boolean): Promise<void>
1096
1104
  setScreenDisabled(disabled: boolean): Promise<void>
1105
+ /** Keep legacy Mentra Live OTA sessions awake. Modern sessions normally do not require this. */
1106
+ ping(): Promise<void>
1097
1107
 
1098
1108
  requestWifiScan(): Promise<WifiSearchResult[]>
1099
1109
  sendWifiCredentials(ssid: string, password: string): Promise<WifiStatusChangeEvent>
1100
1110
  forgetWifiNetwork(ssid: string): Promise<WifiStatusChangeEvent>
1101
1111
  setHotspotState(enabled: boolean): Promise<HotspotStatusChangeEvent>
1112
+ /** Set the glasses clock from the phone after an OTA clock-skew failure. */
1113
+ setSystemTime(timestampMs: number): Promise<void>
1102
1114
  /** Enable or disable Wi-Fi ADB on Mentra Live (no-op on other devices). */
1103
1115
  setWifiAdbState(enabled: boolean): Promise<void>
1104
1116
 
@@ -1187,8 +1199,12 @@ export interface BluetoothSdkPublicModule {
1187
1199
  getOtaVersionUrl(): string
1188
1200
  /** Fetch the configured OTA manifest and return whether any ASG/BES/MTK update is available. */
1189
1201
  checkForOtaUpdate(): Promise<boolean>
1202
+ /** Return bundled release changelogs crossed between two coordinated product versions, newest first. */
1203
+ getReleaseChangelogs(fromVersion?: string | null, toVersion?: string | null): ReleaseChangelog[]
1190
1204
  /** Start OTA from the configured or explicitly supplied manifest URL. */
1191
1205
  startOtaUpdate(otaVersionUrl?: string | null): Promise<OtaStartAckEvent>
1206
+ /** Query the active OTA session and return the correlated status response. */
1207
+ queryOtaStatus(): Promise<OtaQueryResult>
1192
1208
  startAr99OtaFromFile(path: string): Promise<boolean>
1193
1209
  cancelAr99Ota(): Promise<void>
1194
1210
  sendAr99FactoryReset(): Promise<void>
@@ -1267,6 +1283,13 @@ export interface OtaUpdateInfo {
1267
1283
  isDowngrade?: boolean
1268
1284
  }
1269
1285
 
1286
+ export interface ReleaseChangelog {
1287
+ /** Base production version, for example `3.1.0`. */
1288
+ version: string
1289
+ /** Markdown body authored in `/changelogs/<version>.md`. */
1290
+ markdown: string
1291
+ }
1292
+
1270
1293
  export interface OtaProgress {
1271
1294
  stage: OtaStage
1272
1295
  status: OtaProgressStatus