@mentra/bluetooth-sdk 0.1.21-beta.3 → 3.1.0-dev.10

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 (122) hide show
  1. package/README.md +56 -9
  2. package/android/build.gradle +7 -4
  3. package/android/lc3Lib/build.gradle +18 -4
  4. package/android/silero/build.gradle +9 -1
  5. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +16 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +35 -4
  7. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +216 -23
  8. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +2 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +11 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +112 -8
  11. package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +93 -12
  12. package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +5 -7
  13. package/android/src/main/java/com/mentra/bluetoothsdk/ScopedNetworkReadiness.kt +17 -0
  14. package/android/src/main/java/com/mentra/bluetoothsdk/audio/AudioModels.kt +15 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +24 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/net/LocalIpv4.kt +57 -0
  17. package/android/src/main/java/com/mentra/bluetoothsdk/otaserver/LocalOtaServer.kt +304 -0
  18. package/android/src/main/java/com/mentra/bluetoothsdk/otaserver/MentraOtaServerModule.kt +120 -0
  19. package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/MentraPhotoReceiverModule.kt +2 -49
  20. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +61 -30
  21. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/BesOtaHeartbeatGuard.kt +49 -0
  22. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/BesOtaProgressMapper.kt +27 -0
  23. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +1344 -284
  24. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingAdvertisement.kt +54 -0
  25. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +8 -0
  26. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +17 -0
  27. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +12 -0
  28. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +74 -79
  29. package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +3 -3
  30. package/android/src/test/java/com/mentra/bluetoothsdk/OtaStartResponsePolicyTest.kt +30 -0
  31. package/android/src/test/java/com/mentra/bluetoothsdk/ScopedNetworkReadinessTest.kt +39 -0
  32. package/android/src/test/java/com/mentra/bluetoothsdk/otaserver/LocalOtaServerTest.kt +82 -0
  33. package/android/src/test/java/com/mentra/bluetoothsdk/services/ForegroundServiceTypeTest.kt +69 -0
  34. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/BesOtaHeartbeatGuardTest.kt +73 -0
  35. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/BesOtaProgressMapperTest.kt +67 -0
  36. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCharacteristicsTest.kt +39 -0
  37. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingAdvertisementParserTest.kt +75 -0
  38. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +148 -7
  39. package/build/BluetoothSdk.types.d.ts +58 -2
  40. package/build/BluetoothSdk.types.d.ts.map +1 -1
  41. package/build/BluetoothSdk.types.js.map +1 -1
  42. package/build/_internal.d.ts +2 -0
  43. package/build/_internal.d.ts.map +1 -1
  44. package/build/_internal.js +2 -0
  45. package/build/_internal.js.map +1 -1
  46. package/build/_private/BluetoothSdkModule.d.ts +4 -1
  47. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  48. package/build/_private/BluetoothSdkModule.js +1 -1
  49. package/build/_private/BluetoothSdkModule.js.map +1 -1
  50. package/build/_private/MentraLocalNetworkModule.d.ts +1 -0
  51. package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -1
  52. package/build/_private/MentraLocalNetworkModule.js.map +1 -1
  53. package/build/_private/sdkOtaManifest.d.ts +3 -12
  54. package/build/_private/sdkOtaManifest.d.ts.map +1 -1
  55. package/build/_private/sdkOtaManifest.js +5 -17
  56. package/build/_private/sdkOtaManifest.js.map +1 -1
  57. package/build/generated/releaseMetadata.d.ts +12 -0
  58. package/build/generated/releaseMetadata.d.ts.map +1 -0
  59. package/build/generated/releaseMetadata.js +10 -0
  60. package/build/generated/releaseMetadata.js.map +1 -0
  61. package/build/index.d.ts +1 -1
  62. package/build/index.d.ts.map +1 -1
  63. package/build/index.js +10 -2
  64. package/build/index.js.map +1 -1
  65. package/build/ota-server/MentraOtaServer.types.d.ts +14 -0
  66. package/build/ota-server/MentraOtaServer.types.d.ts.map +1 -0
  67. package/build/ota-server/MentraOtaServer.types.js +2 -0
  68. package/build/ota-server/MentraOtaServer.types.js.map +1 -0
  69. package/build/ota-server/MentraOtaServerModule.d.ts +21 -0
  70. package/build/ota-server/MentraOtaServerModule.d.ts.map +1 -0
  71. package/build/ota-server/MentraOtaServerModule.js +3 -0
  72. package/build/ota-server/MentraOtaServerModule.js.map +1 -0
  73. package/build/ota-server/MentraOtaServerModule.web.d.ts +10 -0
  74. package/build/ota-server/MentraOtaServerModule.web.d.ts.map +1 -0
  75. package/build/ota-server/MentraOtaServerModule.web.js +12 -0
  76. package/build/ota-server/MentraOtaServerModule.web.js.map +1 -0
  77. package/build/ota-server/index.d.ts +3 -0
  78. package/build/ota-server/index.d.ts.map +1 -0
  79. package/build/ota-server/index.js +3 -0
  80. package/build/ota-server/index.js.map +1 -0
  81. package/expo-module.config.json +3 -2
  82. package/ios/BackgroundOtaArtifactDownloader.swift +125 -0
  83. package/ios/BluetoothSdkModule.swift +16 -2
  84. package/ios/LocalIPv4.swift +150 -0
  85. package/ios/LocalOtaServer.swift +309 -0
  86. package/ios/MentraBluetoothSDK.podspec +5 -1
  87. package/ios/MentraOtaServerModule.swift +147 -0
  88. package/ios/MentraPhotoReceiverModule.swift +1 -81
  89. package/ios/Source/BluetoothSdkDefaults.swift +4 -1
  90. package/ios/Source/Bridge.swift +43 -4
  91. package/ios/Source/DeviceManager.swift +154 -19
  92. package/ios/Source/DeviceStore.swift +2 -0
  93. package/ios/Source/GeneratedReleaseMetadata.swift +11 -0
  94. package/ios/Source/MentraBluetoothSDK.swift +74 -6
  95. package/ios/Source/OtaManifest.swift +5 -7
  96. package/ios/Source/audio/AudioModels.swift +17 -0
  97. package/ios/Source/camera/CameraModels.swift +1 -0
  98. package/ios/Source/events/BluetoothEvents.swift +27 -0
  99. package/ios/Source/sgcs/MentraLive.swift +469 -77
  100. package/ios/Source/sgcs/MentraLivePairingAdvertisement.swift +64 -0
  101. package/ios/Source/sgcs/SGCManager.swift +11 -0
  102. package/ios/Source/status/DeviceStatus.swift +23 -0
  103. package/ios/Source/types/DeviceModels.swift +23 -2
  104. package/ios/Source/utils/BleJsonCompact.swift +73 -71
  105. package/ios/Tests/MentraLivePairingAdvertisementTests.swift +104 -0
  106. package/package.json +5 -1
  107. package/plugin/build/withIos.d.ts +3 -1
  108. package/plugin/build/withIos.js +22 -10
  109. package/scripts/verify-release-package.mjs +34 -0
  110. package/scripts/write-release-metadata.mjs +154 -0
  111. package/scripts/write-release-metadata.test.mjs +29 -0
  112. package/src/BluetoothSdk.types.ts +69 -3
  113. package/src/_internal.ts +2 -0
  114. package/src/_private/BluetoothSdkModule.ts +5 -5
  115. package/src/_private/MentraLocalNetworkModule.ts +1 -1
  116. package/src/_private/sdkOtaManifest.ts +5 -17
  117. package/src/generated/releaseMetadata.ts +20 -0
  118. package/src/index.ts +19 -2
  119. package/src/ota-server/MentraOtaServer.types.ts +10 -0
  120. package/src/ota-server/MentraOtaServerModule.ts +23 -0
  121. package/src/ota-server/MentraOtaServerModule.web.ts +17 -0
  122. package/src/ota-server/index.ts +2 -0
@@ -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
+ }
@@ -68,6 +68,7 @@ protocol SGCManager {
68
68
  func stopStream()
69
69
  func sendStreamKeepAlive(_ message: [String: Any])
70
70
  func startVideoRecording(requestId: String, save: Bool, sound: Bool)
71
+ func queryVideoRecordingStatus(requestId: String)
71
72
  /// Start video recording with optional per-recording resolution/fps. A width,
72
73
  /// height, or fps of 0 means "use the device's saved button-video default".
73
74
  /// Defaulted in an extension to delegate to the basic recording path; devices
@@ -189,6 +190,7 @@ protocol SGCManager {
189
190
  func sendWifiCredentials(_ ssid: String, _ password: String)
190
191
  func forgetWifiNetwork(_ ssid: String)
191
192
  func sendHotspotState(_ enabled: Bool)
193
+ func sendWifiAdbState(_ enabled: Bool)
192
194
  func sendOtaStart(otaVersionUrl: String?)
193
195
  func sendOtaQueryStatus()
194
196
  func sendSetSystemTime(_ timestampMs: Int64)
@@ -327,6 +329,10 @@ extension SGCManager {
327
329
  startVideoRecording(requestId: requestId, save: save, sound: sound)
328
330
  }
329
331
 
332
+ func queryVideoRecordingStatus(requestId _: String) {
333
+ Bridge.log("SGC: queryVideoRecordingStatus operation not supported")
334
+ }
335
+
330
336
  func stopVideoRecording(requestId: String, webhookUrl _: String?, authToken _: String?) {
331
337
  stopVideoRecording(requestId: requestId)
332
338
  }
@@ -378,6 +384,11 @@ extension SGCManager {
378
384
  Bridge.log("SGC: sendSetSystemTime not supported")
379
385
  }
380
386
 
387
+ /// Default no-op; Mentra Live overrides to enable/disable Wi-Fi ADB.
388
+ func sendWifiAdbState(_: Bool) {
389
+ Bridge.log("SGC: sendWifiAdbState not supported")
390
+ }
391
+
381
392
  // MARK: - Default DeviceStore-backed property implementations
382
393
 
383
394
  var fullyBooted: Bool {
@@ -182,6 +182,10 @@ struct GlassesStatus: CustomStringConvertible {
182
182
  stringValue(values, "bluetoothMacAddress") ?? ""
183
183
  }
184
184
 
185
+ var wifiMacAddress: String {
186
+ stringValue(values, "wifiMacAddress") ?? ""
187
+ }
188
+
185
189
  var leftMacAddress: String {
186
190
  stringValue(values, "leftMacAddress") ?? ""
187
191
  }
@@ -206,6 +210,10 @@ struct GlassesStatus: CustomStringConvertible {
206
210
  stringValue(values, "appVersion") ?? ""
207
211
  }
208
212
 
213
+ var hotspotOtaVersion: Int {
214
+ intValue(values["hotspotOtaVersion"]) ?? 0
215
+ }
216
+
209
217
  var bluetoothName: String {
210
218
  stringValue(values, "bluetoothName") ?? ""
211
219
  }
@@ -359,6 +367,7 @@ public struct VersionInfoResult: CustomStringConvertible {
359
367
  public let systemTimeMs: Int?
360
368
  public let otaVersionUrl: String
361
369
  public let appVersion: String
370
+ public let hotspotOtaVersion: Int
362
371
 
363
372
  init(status: GlassesStatus) {
364
373
  androidVersion = status.androidVersion
@@ -369,6 +378,7 @@ public struct VersionInfoResult: CustomStringConvertible {
369
378
  systemTimeMs = intValue(status.values["systemTimeMs"])
370
379
  otaVersionUrl = status.otaVersionUrl
371
380
  appVersion = status.appVersion
381
+ hotspotOtaVersion = status.hotspotOtaVersion
372
382
  }
373
383
 
374
384
  init(values: [String: Any]) {
@@ -380,6 +390,10 @@ public struct VersionInfoResult: CustomStringConvertible {
380
390
  systemTimeMs = intValue(values["systemTimeMs"]) ?? intValue(values["system_time_ms"])
381
391
  otaVersionUrl = stringValue(values, "otaVersionUrl", "ota_version_url") ?? ""
382
392
  appVersion = stringValue(values, "appVersion", "app_version") ?? ""
393
+ hotspotOtaVersion =
394
+ intValue(values["hotspotOtaVersion"])
395
+ ?? intValue(values["hotspot_ota_version"])
396
+ ?? 0
383
397
  }
384
398
 
385
399
  public var dictionary: [String: Any] {
@@ -391,6 +405,7 @@ public struct VersionInfoResult: CustomStringConvertible {
391
405
  "buildNumber": buildNumber,
392
406
  "otaVersionUrl": otaVersionUrl,
393
407
  "appVersion": appVersion,
408
+ "hotspotOtaVersion": hotspotOtaVersion,
394
409
  ]
395
410
  if let systemTimeMs {
396
411
  values["systemTimeMs"] = systemTimeMs
@@ -661,6 +676,10 @@ struct GlassesStatusUpdate: CustomStringConvertible {
661
676
  optionalStringValue(values, "bluetoothMacAddress")
662
677
  }
663
678
 
679
+ var wifiMacAddress: String? {
680
+ optionalStringValue(values, "wifiMacAddress")
681
+ }
682
+
664
683
  var leftMacAddress: String? {
665
684
  optionalStringValue(values, "leftMacAddress")
666
685
  }
@@ -685,6 +704,10 @@ struct GlassesStatusUpdate: CustomStringConvertible {
685
704
  optionalStringValue(values, "appVersion")
686
705
  }
687
706
 
707
+ var hotspotOtaVersion: Int? {
708
+ optionalIntValue(values, "hotspotOtaVersion")
709
+ }
710
+
688
711
  var bluetoothName: String? {
689
712
  optionalStringValue(values, "bluetoothName")
690
713
  }
@@ -89,6 +89,9 @@ public struct Device: Identifiable, Equatable, CustomStringConvertible {
89
89
  public let rssi: Int?
90
90
  /// Stable app-facing scan-result key. Do not parse; use typed fields instead.
91
91
  public let id: String
92
+ public let pairingMode: Bool?
93
+ public let pairingCode: String?
94
+ public let securePairingCapable: Bool?
92
95
 
93
96
  public init(
94
97
  model: DeviceModel,
@@ -96,7 +99,10 @@ public struct Device: Identifiable, Equatable, CustomStringConvertible {
96
99
  identifier: String? = nil,
97
100
  projectName: String? = nil,
98
101
  rssi: Int? = nil,
99
- id: String? = nil
102
+ id: String? = nil,
103
+ pairingMode: Bool? = nil,
104
+ pairingCode: String? = nil,
105
+ securePairingCapable: Bool? = nil
100
106
  ) {
101
107
  self.model = model
102
108
  self.name = name
@@ -104,6 +110,9 @@ public struct Device: Identifiable, Equatable, CustomStringConvertible {
104
110
  self.projectName = projectName
105
111
  self.rssi = rssi
106
112
  self.id = id ?? identifier.flatMap { $0.isEmpty ? nil : $0 } ?? "\(model.deviceType):\(name)"
113
+ self.pairingMode = pairingMode
114
+ self.pairingCode = pairingCode
115
+ self.securePairingCapable = securePairingCapable
107
116
  }
108
117
 
109
118
  public var description: String {
@@ -125,6 +134,15 @@ public struct Device: Identifiable, Equatable, CustomStringConvertible {
125
134
  if let rssi {
126
135
  values["rssi"] = rssi
127
136
  }
137
+ if let pairingMode {
138
+ values["pairingMode"] = pairingMode
139
+ }
140
+ if let pairingCode, !pairingCode.isEmpty {
141
+ values["pairingCode"] = pairingCode
142
+ }
143
+ if let securePairingCapable {
144
+ values["securePairingCapable"] = securePairingCapable
145
+ }
128
146
  return values
129
147
  }
130
148
 
@@ -140,7 +158,10 @@ public struct Device: Identifiable, Equatable, CustomStringConvertible {
140
158
  identifier: identifier,
141
159
  projectName: projectName,
142
160
  rssi: rssi,
143
- id: stringValue(values, "id")
161
+ id: stringValue(values, "id"),
162
+ pairingMode: boolValue(values, "pairingMode"),
163
+ pairingCode: stringValue(values, "pairingCode").flatMap { $0.isEmpty ? nil : $0 },
164
+ securePairingCapable: boolValue(values, "securePairingCapable")
144
165
  )
145
166
  }
146
167
  }
@@ -1,6 +1,6 @@
1
1
  import Foundation
2
2
 
3
- /// BLE Wire Protocol v2 JSON compaction: short keys, enum ints, omitted defaults, config diff.
3
+ /// BLE Wire Protocol v2 JSON compaction with lossless fallback for ambiguous payloads.
4
4
  enum BleJsonCompact {
5
5
  static let keyResolvedConfigHash = "rch"
6
6
 
@@ -64,8 +64,6 @@ enum BleJsonCompact {
64
64
  }()
65
65
 
66
66
  private static var sessionConnectEpochMs: Int64 = 0
67
- private static var resolvedConfigSent = false
68
- private static var currentSessionResolvedConfigHash: String?
69
67
  private static var resolvedConfigByHash: [String: [String: Any]] = [:]
70
68
 
71
69
  private static let highRoiMessageTypes: Set<String> = [
@@ -103,8 +101,6 @@ enum BleJsonCompact {
103
101
 
104
102
  static func resetSession() {
105
103
  sessionConnectEpochMs = 0
106
- resolvedConfigSent = false
107
- currentSessionResolvedConfigHash = nil
108
104
  resolvedConfigByHash.removeAll()
109
105
  }
110
106
 
@@ -125,7 +121,7 @@ enum BleJsonCompact {
125
121
  return json
126
122
  }
127
123
  let messageType = extractMessageType(json)
128
- if !shouldCompactOutbound(messageType) {
124
+ if !shouldCompactOutbound(messageType) || !canCompactLosslessly(json, topLevel: true) {
129
125
  return json
130
126
  }
131
127
  return compactObject(json, topLevel: true)
@@ -170,48 +166,81 @@ enum BleJsonCompact {
170
166
  return decode(json)
171
167
  }
172
168
 
173
- private static func compactObject(_ input: [String: Any], topLevel: Bool) -> [String: Any] {
174
- var output: [String: Any] = [:]
169
+ /// Compact JSON reserves short keys and enum integers. Fall back to verbose JSON whenever an
170
+ /// otherwise valid future payload already uses one of those wire representations.
171
+ private static func canCompactLosslessly(
172
+ _ input: [String: Any],
173
+ topLevel: Bool
174
+ ) -> Bool {
175
175
  let messageType = stringValue(input["type"]) ?? stringValue(input["t"]) ?? ""
176
- var skipResolvedConfig = false
177
-
178
- if topLevel, shouldDiffResolvedConfig(messageType),
179
- let resolvedConfig = input["resolvedConfig"] as? [String: Any]
180
- {
181
- let hash = hashConfig(resolvedConfig)
182
- resolvedConfigByHash[hash] = resolvedConfig
183
- if resolvedConfigSent, hash == currentSessionResolvedConfigHash {
184
- output[keyResolvedConfigHash] = hash
185
- skipResolvedConfig = true
186
- } else {
187
- currentSessionResolvedConfigHash = hash
188
- resolvedConfigSent = true
176
+ for (key, value) in input {
177
+ if shortToLong[key] != nil
178
+ || (topLevel && key == keyResolvedConfigHash)
179
+ || isAmbiguousEnumValue(key: key, value: value, messageType: messageType)
180
+ {
181
+ return false
182
+ }
183
+ if let nested = value as? [String: Any],
184
+ !canCompactLosslessly(nested, topLevel: false)
185
+ {
186
+ return false
187
+ }
188
+ if let array = value as? [Any], !canCompactArrayLosslessly(array) {
189
+ return false
189
190
  }
190
191
  }
192
+ return true
193
+ }
191
194
 
192
- for (key, value) in input {
193
- if skipResolvedConfig, key == "resolvedConfig" { continue }
194
- let shortKey = longToShort[key] ?? key
195
- guard let compacted = compactValue(longKey: key, value: value, messageType: messageType),
196
- !shouldOmit(compacted)
197
- else {
198
- continue
195
+ private static func canCompactArrayLosslessly(_ array: [Any]) -> Bool {
196
+ for value in array {
197
+ if let nested = value as? [String: Any],
198
+ !canCompactLosslessly(nested, topLevel: false)
199
+ {
200
+ return false
201
+ }
202
+ if let nestedArray = value as? [Any],
203
+ !canCompactArrayLosslessly(nestedArray)
204
+ {
205
+ return false
199
206
  }
200
- output[shortKey] = compacted
201
207
  }
208
+ return true
209
+ }
202
210
 
203
- if topLevel {
204
- compactTimestamp(&output)
211
+ private static func isAmbiguousEnumValue(
212
+ key: String,
213
+ value: Any,
214
+ messageType: String
215
+ ) -> Bool {
216
+ guard let number = value as? NSNumber,
217
+ CFGetTypeID(number) != CFBooleanGetTypeID()
218
+ else {
219
+ return false
205
220
  }
206
- return output
221
+ return key == "kind"
222
+ || key == "source"
223
+ || key == "aeStateName"
224
+ || (key == "status" && messageType == "photo_status")
207
225
  }
208
226
 
209
- private static func shouldDiffResolvedConfig(_ messageType: String) -> Bool {
210
- messageType == "photo_status" || messageType == "stream_status"
227
+ private static func compactObject(_ input: [String: Any], topLevel: Bool) -> [String: Any] {
228
+ var output: [String: Any] = [:]
229
+ let messageType = stringValue(input["type"]) ?? stringValue(input["t"]) ?? ""
230
+
231
+ for (key, value) in input {
232
+ if let compacted = compactValue(longKey: key, value: value, messageType: messageType) {
233
+ // Legacy peers treat top-level `ts` as a session-relative delta. Preserve the
234
+ // verbose key so an absolute timestamp remains absolute across mixed versions.
235
+ let wireKey = topLevel && key == "timestamp" ? key : (longToShort[key] ?? key)
236
+ output[wireKey] = compacted
237
+ }
238
+ }
239
+ return output
211
240
  }
212
241
 
213
242
  private static func compactValue(longKey: String, value: Any, messageType: String) -> Any? {
214
- if value is NSNull { return nil }
243
+ if value is NSNull { return NSNull() }
215
244
  if let nested = value as? [String: Any] {
216
245
  return compactObject(nested, topLevel: false)
217
246
  }
@@ -239,35 +268,6 @@ enum BleJsonCompact {
239
268
  return value
240
269
  }
241
270
 
242
- private static func shouldOmit(_ value: Any) -> Bool {
243
- if value is NSNull { return true }
244
- if let boolValue = value as? Bool, !boolValue { return true }
245
- if let dict = value as? [String: Any], dict.isEmpty { return true }
246
- if let array = value as? [Any], array.isEmpty { return true }
247
- return false
248
- }
249
-
250
- private static func compactTimestamp(_ output: inout [String: Any]) {
251
- let tsKey: String?
252
- if output["timestamp"] != nil {
253
- tsKey = "timestamp"
254
- } else if output["ts"] != nil {
255
- tsKey = "ts"
256
- } else {
257
- tsKey = nil
258
- }
259
- guard let key = tsKey else { return }
260
- let absolute = int64Value(output[key]) ?? 0
261
- guard absolute > 0 else { return }
262
- if sessionConnectEpochMs <= 0 {
263
- sessionConnectEpochMs = absolute
264
- }
265
- output["ts"] = absolute - sessionConnectEpochMs
266
- if key != "ts" {
267
- output.removeValue(forKey: key)
268
- }
269
- }
270
-
271
271
  private static func expandObject(_ input: [String: Any], topLevel: Bool) -> [String: Any] {
272
272
  var output: [String: Any] = [:]
273
273
  let messageType = stringValue(input["type"]) ?? stringValue(input["t"]) ?? ""
@@ -278,10 +278,9 @@ enum BleJsonCompact {
278
278
  continue
279
279
  }
280
280
  let longKey = shortToLong[key] ?? key
281
- guard let expanded = expandValue(longKey: longKey, value: value, messageType: messageType) else {
282
- continue
281
+ if let expanded = expandValue(longKey: longKey, value: value, messageType: messageType) {
282
+ output[longKey] = expanded
283
283
  }
284
- output[longKey] = expanded
285
284
  }
286
285
 
287
286
  if topLevel {
@@ -296,8 +295,6 @@ enum BleJsonCompact {
296
295
  guard let resolvedConfig = output["resolvedConfig"] as? [String: Any] else { return }
297
296
  let hash = hashConfig(resolvedConfig)
298
297
  resolvedConfigByHash[hash] = resolvedConfig
299
- currentSessionResolvedConfigHash = hash
300
- resolvedConfigSent = true
301
298
  }
302
299
 
303
300
  private static func expandResolvedConfigHash(_ output: inout [String: Any]) {
@@ -312,7 +309,7 @@ enum BleJsonCompact {
312
309
  }
313
310
 
314
311
  private static func expandValue(longKey: String, value: Any, messageType: String) -> Any? {
315
- if value is NSNull { return nil }
312
+ if value is NSNull { return NSNull() }
316
313
  if let nested = value as? [String: Any] {
317
314
  return expandObject(nested, topLevel: false)
318
315
  }
@@ -340,7 +337,12 @@ enum BleJsonCompact {
340
337
  }
341
338
 
342
339
  private static func expandTimestamp(_ output: inout [String: Any]) {
343
- guard output["timestamp"] == nil, let delta = int64Value(output["ts"]) else { return }
340
+ guard output["timestamp"] == nil, let timestamp = output["ts"] else { return }
341
+ guard let delta = int64Value(timestamp) else {
342
+ output["timestamp"] = timestamp
343
+ output.removeValue(forKey: "ts")
344
+ return
345
+ }
344
346
  let absolute = sessionConnectEpochMs > 0 ? sessionConnectEpochMs + delta : delta
345
347
  output["timestamp"] = absolute
346
348
  output.removeValue(forKey: "ts")
@@ -0,0 +1,104 @@
1
+ import Foundation
2
+ @testable import MentraBluetoothSDK
3
+ import XCTest
4
+
5
+ final class MentraLivePairingAdvertisementTests: XCTestCase {
6
+ func testConnectionCallbackPolicyRejectsPairingYieldAndStaleAttempts() {
7
+ XCTAssertFalse(
8
+ MentraLiveConnectionAttemptPolicy.shouldAcceptDidConnect(
9
+ pairingYieldActive: true,
10
+ matchesActiveAttempt: true
11
+ )
12
+ )
13
+ XCTAssertFalse(
14
+ MentraLiveConnectionAttemptPolicy.shouldAcceptDidConnect(
15
+ pairingYieldActive: false,
16
+ matchesActiveAttempt: false
17
+ )
18
+ )
19
+ XCTAssertTrue(
20
+ MentraLiveConnectionAttemptPolicy.shouldAcceptDidConnect(
21
+ pairingYieldActive: false,
22
+ matchesActiveAttempt: true
23
+ )
24
+ )
25
+ }
26
+
27
+ func testParsesMarkedSecurePairingAdvertisement() {
28
+ let result = MentraLivePairingAdvertisement.parse(
29
+ coreBluetoothManufacturerData: securePayload(pairingFlag: 1)
30
+ )
31
+
32
+ XCTAssertEqual(result?.pairingCode, "1234")
33
+ XCTAssertEqual(result?.pairingMode, true)
34
+ }
35
+
36
+ func testParsesMarkedOwnedAdvertisement() {
37
+ let result = MentraLivePairingAdvertisement.parse(
38
+ coreBluetoothManufacturerData: securePayload(pairingFlag: 0)
39
+ )
40
+
41
+ XCTAssertEqual(result?.pairingCode, "1234")
42
+ XCTAssertEqual(result?.pairingMode, false)
43
+ }
44
+
45
+ func testRejectsLegacyPayloadThatMatchesOldVersionCapabilityHeuristic() {
46
+ var payload = legacyPayload()
47
+ payload[2 + 5] = 0
48
+ payload[2 + 6] = 1
49
+ payload[2 + 7] = 1
50
+ payload[2 + 8] = 0x34
51
+ payload[2 + 9] = 0x12
52
+
53
+ XCTAssertNil(
54
+ MentraLivePairingAdvertisement.parse(coreBluetoothManufacturerData: payload)
55
+ )
56
+ }
57
+
58
+ func testRejectsUnmarkedFirstGenerationSecureTrailer() {
59
+ var payload = securePayload(pairingFlag: 1)
60
+ payload[2 + 10] = 0x11
61
+ payload[2 + 11] = 0x22
62
+
63
+ XCTAssertNil(
64
+ MentraLivePairingAdvertisement.parse(coreBluetoothManufacturerData: payload)
65
+ )
66
+ }
67
+
68
+ func testRejectsEveryLegacyVersionAndCapabilityCombination() {
69
+ for version in UInt8.min ... UInt8.max {
70
+ for capability in UInt8.min ... UInt8.max {
71
+ var payload = legacyPayload()
72
+ payload[2 + 5] = 1
73
+ payload[2 + 6] = version
74
+ payload[2 + 7] = capability
75
+ payload[2 + 10] = 0x4D
76
+ // Offset 11 is padding in the legacy format, so it cannot contain the second
77
+ // non-zero marker byte.
78
+ payload[2 + 11] = 0
79
+ XCTAssertNil(
80
+ MentraLivePairingAdvertisement.parse(coreBluetoothManufacturerData: payload)
81
+ )
82
+ }
83
+ }
84
+ }
85
+
86
+ private func legacyPayload() -> Data {
87
+ var data = Data(repeating: 0, count: 29)
88
+ data[0] = 0x22
89
+ data[1] = 0xB8
90
+ return data
91
+ }
92
+
93
+ private func securePayload(pairingFlag: UInt8) -> Data {
94
+ var data = legacyPayload()
95
+ data[2 + 5] = pairingFlag
96
+ data[2 + 6] = 2
97
+ data[2 + 7] = 1
98
+ data[2 + 8] = 0x34
99
+ data[2 + 9] = 0x12
100
+ data[2 + 10] = 0x4D
101
+ data[2 + 11] = 0x50
102
+ return data
103
+ }
104
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mentra/bluetooth-sdk",
3
- "version": "0.1.21-beta.3",
3
+ "version": "3.1.0-dev.10",
4
4
  "description": "SDK for communicating with smart glasses",
5
5
  "main": "build/index.js",
6
6
  "react-native": "src/index.ts",
@@ -73,9 +73,12 @@
73
73
  "build:plugin": "expo-module build plugin",
74
74
  "clean": "expo-module clean",
75
75
  "test": "expo-module test",
76
+ "test:release-metadata": "node --test scripts/*.test.mjs",
76
77
  "prepare": "expo-module prepare",
77
78
  "prepack": "node scripts/inject-ios-sdk-version.mjs",
78
79
  "postpack": "node scripts/inject-ios-sdk-version.mjs --restore",
80
+ "write:release-metadata": "node scripts/write-release-metadata.mjs",
81
+ "verify:release-package": "node scripts/verify-release-package.mjs",
79
82
  "prepublishOnly": "expo-module prepublishOnly",
80
83
  "lint": "expo-module lint",
81
84
  "expo-module": "expo-module",
@@ -105,6 +108,7 @@
105
108
  "access": "public",
106
109
  "registry": "https://registry.npmjs.org/"
107
110
  },
111
+ "dependencies": {},
108
112
  "devDependencies": {
109
113
  "@types/node": "^25.9.3",
110
114
  "expo-module-scripts": "^55.0.2",
@@ -1,3 +1,5 @@
1
- import { type ConfigPlugin } from "expo/config-plugins";
1
+ import { IOSConfig, type ConfigPlugin } from "expo/config-plugins";
2
2
  import { type BluetoothSdkPluginProps } from "./index";
3
+ export declare const INFO_SDK_VERSION = "MentraBluetoothSdkVersion";
4
+ export declare function applyBluetoothSdkInfoPlist(infoPlist: IOSConfig.InfoPlist, props: BluetoothSdkPluginProps | undefined): IOSConfig.InfoPlist;
3
5
  export declare const withIosConfiguration: ConfigPlugin<BluetoothSdkPluginProps>;
@@ -3,7 +3,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.withIosConfiguration = void 0;
6
+ exports.withIosConfiguration = exports.INFO_SDK_VERSION = void 0;
7
+ exports.applyBluetoothSdkInfoPlist = applyBluetoothSdkInfoPlist;
7
8
  const child_process_1 = require("child_process");
8
9
  const fs_1 = __importDefault(require("fs"));
9
10
  const path_1 = __importDefault(require("path"));
@@ -11,6 +12,7 @@ const config_plugins_1 = require("expo/config-plugins");
11
12
  const BLUETOOTH_SDK_EXPO_ADAPTER_ENV = "MENTRA_BLUETOOTH_SDK_INCLUDE_EXPO_ADAPTER";
12
13
  const BLUETOOTH_SDK_EXPO_ADAPTER_LINE = `ENV['${BLUETOOTH_SDK_EXPO_ADAPTER_ENV}'] ||= '1'`;
13
14
  const INFO_ANALYTICS_DISABLED = "MentraBluetoothSdkAnalyticsDisabled";
15
+ exports.INFO_SDK_VERSION = "MentraBluetoothSdkVersion";
14
16
  const STALE_INFO_POSTHOG_API_KEY = "MentraBluetoothSdkPostHogApiKey";
15
17
  const STALE_INFO_POSTHOG_HOST = "MentraBluetoothSdkPostHogHost";
16
18
  const ensureBluetoothSdkExpoAdapterPodEnv = (podfile) => {
@@ -70,15 +72,25 @@ function resolveAnalyticsProps(props) {
70
72
  disabled,
71
73
  };
72
74
  }
73
- function withAnalyticsInfoPlist(config, props) {
75
+ function applyBluetoothSdkInfoPlist(infoPlist, props) {
76
+ const packageJson = require("../../package.json");
77
+ const sdkVersion = typeof packageJson.version === "string" ? packageJson.version.trim() : "";
78
+ if (!sdkVersion) {
79
+ throw new Error("@mentra/bluetooth-sdk package.json is missing a version");
80
+ }
81
+ const analytics = resolveAnalyticsProps(props);
82
+ delete infoPlist[INFO_ANALYTICS_DISABLED];
83
+ delete infoPlist[STALE_INFO_POSTHOG_API_KEY];
84
+ delete infoPlist[STALE_INFO_POSTHOG_HOST];
85
+ infoPlist[exports.INFO_SDK_VERSION] = sdkVersion;
86
+ if (analytics.disabled !== undefined) {
87
+ infoPlist[INFO_ANALYTICS_DISABLED] = analytics.disabled;
88
+ }
89
+ return infoPlist;
90
+ }
91
+ function withBluetoothSdkInfoPlist(config, props) {
74
92
  return (0, config_plugins_1.withInfoPlist)(config, (config) => {
75
- const analytics = resolveAnalyticsProps(props);
76
- delete config.modResults[INFO_ANALYTICS_DISABLED];
77
- delete config.modResults[STALE_INFO_POSTHOG_API_KEY];
78
- delete config.modResults[STALE_INFO_POSTHOG_HOST];
79
- if (analytics.disabled !== undefined) {
80
- config.modResults[INFO_ANALYTICS_DISABLED] = analytics.disabled;
81
- }
93
+ config.modResults = applyBluetoothSdkInfoPlist(config.modResults, props);
82
94
  return config;
83
95
  });
84
96
  }
@@ -87,7 +99,7 @@ const withIosConfiguration = (config, props) => {
87
99
  config.modResults.contents = ensureBluetoothSdkExpoAdapterPodEnv(config.modResults.contents);
88
100
  return config;
89
101
  });
90
- config = withAnalyticsInfoPlist(config, props);
102
+ config = withBluetoothSdkInfoPlist(config, props);
91
103
  if (props?.node) {
92
104
  config = withXcodeEnvLocal(config);
93
105
  }