@mentra/bluetooth-sdk 0.1.21-beta.0 → 0.1.21-beta.2

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 (89) hide show
  1. package/README.md +22 -11
  2. package/android/build.gradle +3 -0
  3. package/android/src/main/AndroidManifest.xml +3 -1
  4. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalytics.kt +34 -0
  5. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +1 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +43 -3
  7. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +7 -2
  8. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +82 -6
  9. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +5 -1
  10. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +32 -3
  11. package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +43 -3
  12. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +17 -4
  13. package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleTraceLogger.kt +1 -1
  14. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +53 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Ar99.java +2653 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +161 -14
  17. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +3 -0
  18. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/Ar99OtaManager.java +566 -0
  19. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaByteUtils.java +18 -0
  20. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaCommandConstants.java +35 -0
  21. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaCrc32Util.java +13 -0
  22. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaGattTransport.java +11 -0
  23. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaProtocol.java +92 -0
  24. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +41 -1
  25. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +7 -0
  26. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +8 -0
  27. package/android/src/main/java/com/mentra/bluetoothsdk/utils/Constants.kt +2 -1
  28. package/android/src/main/java/com/mentra/bluetoothsdk/utils/PhoneAudioMonitor.kt +45 -0
  29. package/android/src/main/java/com/mentra/bluetoothsdk/utils/audio/Ar99OpusPcmDecoder.java +398 -0
  30. package/android/src/test/java/com/mentra/bluetoothsdk/OtaManifestDowngradeTest.kt +86 -0
  31. package/android/src/test/java/com/mentra/bluetoothsdk/StreamStatusEventTest.kt +29 -0
  32. package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +30 -0
  33. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/Ar99AdvertisementMatcherTest.java +22 -0
  34. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +20 -0
  35. package/android/src/test/java/com/mentra/bluetoothsdk/utils/PhoneAudioMonitorTest.kt +45 -0
  36. package/build/BluetoothSdk.types.d.ts +58 -25
  37. package/build/BluetoothSdk.types.d.ts.map +1 -1
  38. package/build/BluetoothSdk.types.js +2 -1
  39. package/build/BluetoothSdk.types.js.map +1 -1
  40. package/build/_internal.d.ts +1 -0
  41. package/build/_internal.d.ts.map +1 -1
  42. package/build/_internal.js +1 -0
  43. package/build/_internal.js.map +1 -1
  44. package/build/_private/BluetoothSdkModule.d.ts +8 -2
  45. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  46. package/build/_private/BluetoothSdkModule.js +6 -3
  47. package/build/_private/BluetoothSdkModule.js.map +1 -1
  48. package/build/_private/cameraRequestPayload.d.ts +1 -1
  49. package/build/_private/cameraRequestPayload.d.ts.map +1 -1
  50. package/build/_private/cameraRequestPayload.js +6 -0
  51. package/build/_private/cameraRequestPayload.js.map +1 -1
  52. package/build/_private/photoRequestPayload.d.ts.map +1 -1
  53. package/build/_private/photoRequestPayload.js +10 -1
  54. package/build/_private/photoRequestPayload.js.map +1 -1
  55. package/build/_private/sdkOtaManifest.d.ts +20 -0
  56. package/build/_private/sdkOtaManifest.d.ts.map +1 -0
  57. package/build/_private/sdkOtaManifest.js +28 -0
  58. package/build/_private/sdkOtaManifest.js.map +1 -0
  59. package/build/index.d.ts +1 -1
  60. package/build/index.d.ts.map +1 -1
  61. package/build/index.js +7 -1
  62. package/build/index.js.map +1 -1
  63. package/ios/BluetoothSdkModule.swift +41 -2
  64. package/ios/Source/BluetoothSdkDefaults.swift +2 -1
  65. package/ios/Source/Bridge.swift +15 -3
  66. package/ios/Source/DeviceManager.swift +67 -17
  67. package/ios/Source/DeviceStore.swift +5 -1
  68. package/ios/Source/MentraBluetoothSDK.swift +37 -8
  69. package/ios/Source/OtaManifest.swift +47 -4
  70. package/ios/Source/camera/CameraModels.swift +20 -5
  71. package/ios/Source/internal/BleTraceLogger.swift +1 -1
  72. package/ios/Source/internal/BluetoothSdkAnalytics.swift +39 -0
  73. package/ios/Source/sgcs/Ar99.swift +2451 -0
  74. package/ios/Source/sgcs/MentraLive.swift +174 -38
  75. package/ios/Source/sgcs/SGCManager.swift +8 -0
  76. package/ios/Source/sgcs/ar99/ota/Ar99OtaManager.swift +637 -0
  77. package/ios/Source/streaming/StreamModels.swift +71 -7
  78. package/ios/Source/types/DeviceModels.swift +13 -0
  79. package/ios/Source/utils/BleJsonCompact.swift +8 -0
  80. package/ios/Source/utils/Constants.swift +2 -0
  81. package/ios/Source/utils/PhoneAudioMonitor.swift +4 -0
  82. package/package.json +1 -1
  83. package/src/BluetoothSdk.types.ts +61 -28
  84. package/src/_internal.ts +1 -0
  85. package/src/_private/BluetoothSdkModule.ts +20 -6
  86. package/src/_private/cameraRequestPayload.ts +8 -2
  87. package/src/_private/photoRequestPayload.ts +12 -2
  88. package/src/_private/sdkOtaManifest.ts +31 -0
  89. package/src/index.ts +9 -1
@@ -88,12 +88,13 @@ class DeviceStore {
88
88
  store.set("bluetooth", "contextual_dashboard", true)
89
89
  store.set("bluetooth", "gallery_mode", true)
90
90
  store.set("bluetooth", "voice_activity_detection_enabled", BluetoothSdkDefaults.voiceActivityDetectionEnabled)
91
+ store.set("bluetooth", "loudness_gate_enabled", BluetoothSdkDefaults.loudnessGateEnabled)
91
92
  // Mentra Nex feature flag (off by default; toggled from Nex Developer Settings):
92
93
  store.set("bluetooth", "nex_chinese_captions", false)
93
94
  store.set("bluetooth", "screen_disabled", false)
94
95
  store.set("bluetooth", "button_photo_size", "max")
95
96
  store.set("bluetooth", "button_max_recording_time", 10)
96
- store.set("bluetooth", "camera_fov", ["fov": 102, "roi_position": 0])
97
+ store.set("bluetooth", "camera_fov", ["fov": 118, "roi_position": 0])
97
98
  store.set("bluetooth", "button_video_width", 1280)
98
99
  store.set("bluetooth", "button_video_height", 720)
99
100
  store.set("bluetooth", "button_video_fps", 30)
@@ -245,6 +246,9 @@ class DeviceStore {
245
246
  case ("bluetooth", "voice_activity_detection_enabled"):
246
247
  DeviceManager.shared.sgc?.sendVoiceActivityDetectionSetting()
247
248
 
249
+ case ("bluetooth", "loudness_gate_enabled"):
250
+ DeviceManager.shared.sgc?.sendLoudnessGateSetting()
251
+
248
252
  case ("bluetooth", "screen_disabled"):
249
253
  if let disabled = value as? Bool {
250
254
  if disabled {
@@ -190,7 +190,7 @@ public final class MentraBluetoothSDK {
190
190
  private var shouldRestoreControllerOnBluetoothRestore = false
191
191
  private var bridgeEventSinkId: String?
192
192
  private var storeListenerId: String?
193
- private let defaultDeviceKeys: Set<String> = ["default_wearable", "device_name", "device_address"]
193
+ private let defaultDeviceKeys: Set<String> = ["default_wearable", "device_name", "device_address", "project_name"]
194
194
  private let videoUploadStopTimeoutMs = 10 * 60 * 1000
195
195
  private var suppressDefaultDeviceEvents = false
196
196
  private var defaultDeviceApplyGeneration = 0
@@ -308,6 +308,7 @@ public final class MentraBluetoothSDK {
308
308
  DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "default_wearable", device.model.deviceType)
309
309
  DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "device_name", device.name)
310
310
  DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "device_address", device.identifier ?? "")
311
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "project_name", device.projectName ?? "")
311
312
  finishDefaultDeviceApply(generation: generation)
312
313
  }
313
314
 
@@ -318,6 +319,7 @@ public final class MentraBluetoothSDK {
318
319
  DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "default_wearable", "")
319
320
  DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "device_name", "")
320
321
  DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "device_address", "")
322
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "project_name", "")
321
323
  finishDefaultDeviceApply(generation: generation)
322
324
  }
323
325
 
@@ -534,7 +536,11 @@ public final class MentraBluetoothSDK {
534
536
  DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "voice_activity_detection_enabled", enabled)
535
537
  }
536
538
 
537
- @available(*, deprecated, message: "Sticky action-button photo presets are deprecated. Prefer per-request requestPhoto(...) options (e.g. mode .text for AE ÷3, or explicit per-shot fields). This method still works but will be removed in a future release.")
539
+ public func setLoudnessGateEnabled(_ enabled: Bool) async throws {
540
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "loudness_gate_enabled", enabled)
541
+ }
542
+
543
+ @available(*, deprecated, message: "Sticky action-button photo presets are deprecated. Prefer per-request requestPhoto(...) options (e.g. mode .text for text sensor size/crop, or explicit per-shot fields). This method still works but will be removed in a future release.")
538
544
  public func setPhotoCaptureDefaults(_ settings: PhotoCaptureDefaults) async throws -> SettingsAckEvent {
539
545
  try await performSettingsCommand(
540
546
  setting: "button_photo",
@@ -543,7 +549,7 @@ public final class MentraBluetoothSDK {
543
549
  // Mirror Android: clear all cached scan-tuning keys so reconnect sync
544
550
  // does not replay stale values after a reset.
545
551
  let cat = ObservableStore.bluetoothCategory
546
- for key in ["button_photo_mfnr", "button_photo_zsl", "button_photo_noise_reduction",
552
+ for key in ["button_photo_zsl_mfnr", "button_photo_mfnr", "button_photo_zsl", "button_photo_noise_reduction",
547
553
  "button_photo_edge_enhancement", "button_photo_isp_digital_gain",
548
554
  "button_photo_isp_analog_gain", "button_photo_ae_exposure_divisor",
549
555
  "button_photo_iso_cap", "button_photo_compress", "button_photo_sound"] {
@@ -912,7 +918,9 @@ public final class MentraBluetoothSDK {
912
918
  size: PhotoSize,
913
919
  mode: PhotoMode = .photo,
914
920
  exposureTimeNs: Double?,
915
- durationMs: Int
921
+ durationMs: Int,
922
+ zsl: Bool? = nil,
923
+ mfnr: Bool? = nil
916
924
  ) async throws -> CameraStatusEvent {
917
925
  let effectiveRequestId = nonBlankRequestId(requestId) ?? generatedCameraRequestId("warm")
918
926
  let pending = PendingResponse<CameraStatusEvent>(operation: "camera warm up \(effectiveRequestId)")
@@ -924,7 +932,9 @@ public final class MentraBluetoothSDK {
924
932
  size: size,
925
933
  mode: mode,
926
934
  exposureTimeNs: exposureTimeNs,
927
- durationMs: durationMs
935
+ durationMs: durationMs,
936
+ zsl: zsl,
937
+ mfnr: mfnr
928
938
  )
929
939
  let event = try await pending.wait()
930
940
  pendingCameraStatusRequests.removeValue(forKey: effectiveRequestId)
@@ -1261,6 +1271,20 @@ public final class MentraBluetoothSDK {
1261
1271
 
1262
1272
  func sendOtaQueryStatus() async throws -> OtaQueryResult { try await queryOtaStatus() }
1263
1273
 
1274
+ func startAr99OtaFromFile(_ path: String) throws -> Bool {
1275
+ try requireGlassesConnected(operation: "start AR99 OTA")
1276
+ return try DeviceManager.shared.startAr99OtaFromFile(path)
1277
+ }
1278
+
1279
+ func cancelAr99Ota() {
1280
+ DeviceManager.shared.cancelAr99Ota()
1281
+ }
1282
+
1283
+ func sendAr99FactoryReset() throws {
1284
+ try requireGlassesConnected(operation: "factory reset AR99")
1285
+ try DeviceManager.shared.sendAr99FactoryReset()
1286
+ }
1287
+
1264
1288
  private func getFreshGlassesStatus() async -> GlassesStatus {
1265
1289
  let status = glassesStatus
1266
1290
  if !status.connected || !status.buildNumber.isEmpty {
@@ -1967,14 +1991,15 @@ public final class MentraBluetoothSDK {
1967
1991
  guard let model = core["default_wearable"] as? String, !model.isEmpty else { return nil }
1968
1992
  guard let name = core["device_name"] as? String, !name.isEmpty else { return nil }
1969
1993
  let identifier = (core["device_address"] as? String).flatMap { $0.isEmpty ? nil : $0 }
1994
+ let projectName = (core["project_name"] as? String).flatMap { $0.isEmpty ? nil : $0 }
1970
1995
  return Device(
1971
1996
  model: DeviceModel.fromDeviceType(model),
1972
1997
  name: name,
1973
- identifier: identifier
1998
+ identifier: identifier,
1999
+ projectName: projectName
1974
2000
  )
1975
2001
  }
1976
-
1977
- private func dispatchDiscoveredDevices(_ rawSearchResults: Any?) {
2002
+ private func dispatchDiscoveredDevices(_ rawSearchResults: Any?) {
1978
2003
  guard let results = rawSearchResults as? [[String: Any]] else { return }
1979
2004
  for result in results {
1980
2005
  guard let name = result["name"] as? String else { continue }
@@ -2146,3 +2171,7 @@ public final class MentraBluetoothSDK {
2146
2171
  }
2147
2172
  }
2148
2173
  }
2174
+
2175
+
2176
+
2177
+
@@ -88,9 +88,18 @@ enum OtaManifestChecker {
88
88
  currentBuildNumber: String,
89
89
  currentMtkVersion: String,
90
90
  currentBesVersion: String,
91
- manifest: OtaManifest
91
+ manifest: OtaManifest,
92
+ // Downgrade floor: a non-positive value (the default) disables downgrades entirely,
93
+ // matching ASG's fail-closed DowngradeGate and the engine. OEM SDK consumers that do not
94
+ // set a floor get upgrade-only behavior and are never told a downgrade is available that
95
+ // the glasses would refuse.
96
+ downgradeFloorVersionCode: Int = 0
92
97
  ) throws -> Bool {
93
- try hasApkUpdate(currentBuildNumber: currentBuildNumber, manifest: manifest) ||
98
+ try hasApkUpdate(
99
+ currentBuildNumber: currentBuildNumber,
100
+ manifest: manifest,
101
+ downgradeFloorVersionCode: downgradeFloorVersionCode
102
+ ) ||
94
103
  hasMtkUpdate(patches: manifest.mtkPatches, currentVersion: currentMtkVersion) ||
95
104
  hasBesUpdate(besFirmware: manifest.besFirmware, currentVersion: currentBesVersion)
96
105
  }
@@ -113,7 +122,11 @@ enum OtaManifestChecker {
113
122
  throw BluetoothSdkError(code: "invalid_ota_manifest", message: "OTA manifest is missing ASG app versionCode.")
114
123
  }
115
124
 
116
- private static func hasApkUpdate(currentBuildNumber: String, manifest: OtaManifest) throws -> Bool {
125
+ private static func hasApkUpdate(
126
+ currentBuildNumber: String,
127
+ manifest: OtaManifest,
128
+ downgradeFloorVersionCode: Int
129
+ ) throws -> Bool {
117
130
  guard let currentVersion = Int(currentBuildNumber) else {
118
131
  throw BluetoothSdkError(
119
132
  code: "invalid_glasses_version",
@@ -123,7 +136,37 @@ enum OtaManifestChecker {
123
136
  guard let serverVersion = try latestAppInfo(manifest).versionCode else {
124
137
  throw BluetoothSdkError(code: "invalid_ota_manifest", message: "OTA manifest is missing ASG app versionCode.")
125
138
  }
126
- return serverVersion > currentVersion
139
+ // Only apps-shaped manifests are exact pins. A legacy top-level manifest (versionCode at
140
+ // the root, no apps entry) is NOT a pin and stays strictly upgrade-only, matching the
141
+ // engine TS checker and Kotlin.
142
+ let isExactPin = manifest.apps?[asgClientPackage]?.versionCode != nil
143
+ if !isExactPin {
144
+ return serverVersion > currentVersion
145
+ }
146
+ // Exact pin: any mismatch is an update in either direction (downgrades take the
147
+ // uninstall-then-reinstall detour on the glasses). A non-positive pin is only legitimate
148
+ // in the frozen legacy rescue manifests that pre-39 glasses are checked against; for
149
+ // modern glasses it means the manifest cannot verify anything, and that must surface as
150
+ // an error — never as "no update". Mirrors the Kotlin OtaManifestChecker.
151
+ if serverVersion <= 0 {
152
+ if currentVersion >= 39 {
153
+ throw BluetoothSdkError(
154
+ code: "invalid_ota_manifest",
155
+ message: "OTA manifest ASG pin is missing or zero; cannot verify update state."
156
+ )
157
+ }
158
+ return false
159
+ }
160
+ if serverVersion > currentVersion {
161
+ return true
162
+ }
163
+ // Exact match: already on the pin, no update.
164
+ if serverVersion == currentVersion {
165
+ return false
166
+ }
167
+ // Downgrade: only actionable at/above the enabled floor; a non-positive floor disables
168
+ // downgrades (fail closed, matching ASG and the engine).
169
+ return downgradeFloorVersionCode > 0 && serverVersion >= downgradeFloorVersionCode
127
170
  }
128
171
 
129
172
  private static func hasMtkUpdate(patches: [MtkPatch]?, currentVersion: String) throws -> Bool {
@@ -169,15 +169,16 @@ public enum CameraRoiPosition: Int {
169
169
  public struct CameraFov {
170
170
  public static let minFov = 62
171
171
  public static let maxFov = 118
172
- public static let defaultFov = 102
172
+ public static let defaultFov = 118
173
173
  public static let narrowFov = 82
174
+ public static let standardFov = 102
174
175
  public static let defaultRoiPosition = CameraRoiPosition.center
175
176
  public static let narrow = CameraFov(
176
177
  fov: CameraFov.narrowFov,
177
178
  roiPosition: CameraFov.defaultRoiPosition
178
179
  )
179
180
  public static let standard = CameraFov(
180
- fov: CameraFov.defaultFov,
181
+ fov: CameraFov.standardFov,
181
182
  roiPosition: CameraFov.defaultRoiPosition
182
183
  )
183
184
  public static let wide = CameraFov(
@@ -302,12 +303,26 @@ public struct PhotoRequest {
302
303
  self.ispDigitalGain = ispDigitalGain
303
304
  self.ispAnalogGain = ispAnalogGain
304
305
  self.mode = mode
305
- self.transferMethod = transferMethod == "ble" ? "ble" : "auto"
306
+ self.transferMethod = transferMethod
306
307
  }
307
308
 
308
- public static func from(params: [String: Any]) -> PhotoRequest {
309
+ public static func from(params: [String: Any]) throws -> PhotoRequest {
309
310
  let sizeRaw = params["size"] as? String ?? "medium"
310
311
  let compressRaw = params["compress"] as? String ?? "none"
312
+ let transferMethod: String
313
+ if let rawValue = params["transferMethod"] {
314
+ guard let rawString = rawValue as? String,
315
+ rawString == "auto" || rawString == "direct" || rawString == "ble"
316
+ else {
317
+ throw BluetoothSdkError(
318
+ code: "invalid_photo_transfer_method",
319
+ message: "Invalid transferMethod \(String(describing: rawValue)). Expected auto, direct, or ble."
320
+ )
321
+ }
322
+ transferMethod = rawString
323
+ } else {
324
+ transferMethod = "auto"
325
+ }
311
326
  let exposureTimeNs: Double?
312
327
  switch params["exposureTimeNs"] {
313
328
  case let value as Double:
@@ -371,7 +386,7 @@ public struct PhotoRequest {
371
386
  ispDigitalGain: optionalInt("ispDigitalGain"),
372
387
  ispAnalogGain: params["ispAnalogGain"] as? String,
373
388
  mode: PhotoMode(normalizedRawValue: params["mode"] as? String),
374
- transferMethod: params["transferMethod"] as? String ?? "auto"
389
+ transferMethod: transferMethod
375
390
  )
376
391
  }
377
392
 
@@ -6,7 +6,7 @@ enum BleTraceLogger {
6
6
  private static let log = OSLog(subsystem: "com.mentra.bluetoothsdk", category: "MentraBleTrace")
7
7
  private static let maxPayloadChars = 3000
8
8
  private static let k900Type = "k900"
9
- private static let sensitiveKeyParts = ["password", "pass", "token", "secret", "authorization", "auth", "email"]
9
+ private static let sensitiveKeyParts = ["password", "pass", "token", "secret", "authorization", "auth", "email", "serial"]
10
10
 
11
11
  static func logJson(
12
12
  direction: String,
@@ -39,6 +39,7 @@ final class BluetoothSdkAnalytics {
39
39
  private let configuration: BluetoothSdkAnalyticsConfiguration
40
40
  private var startedCaptured = false
41
41
  private var lastConnected = false
42
+ private var identifiedCapturedForConnection = false
42
43
 
43
44
  init(configuration: BluetoothSdkAnalyticsConfiguration) {
44
45
  self.configuration = configuration.resolvedForApp()
@@ -47,6 +48,12 @@ final class BluetoothSdkAnalytics {
47
48
  func initializeGlassesStatus(_ status: GlassesStatus) {
48
49
  stateQueue.sync {
49
50
  lastConnected = status.analyticsConnected
51
+ // Only treat identification as already captured when a valid serial is
52
+ // present at init. If the glasses are connected but the serial has not
53
+ // arrived yet (Mentra Live fills it via version_info after connect), leave
54
+ // this false so the identify event still fires once the serial arrives.
55
+ identifiedCapturedForConnection =
56
+ status.analyticsConnected && status.serialNumber.validManufacturingSerial != nil
50
57
  }
51
58
  }
52
59
 
@@ -72,7 +79,12 @@ final class BluetoothSdkAnalytics {
72
79
  let wasConnected = lastConnected
73
80
  lastConnected = isConnected
74
81
  guard configuration.isReady else { return }
82
+ guard isConnected else {
83
+ identifiedCapturedForConnection = false
84
+ return
85
+ }
75
86
  if isConnected, !wasConnected {
87
+ identifiedCapturedForConnection = false
76
88
  var properties: [String: Any] = [
77
89
  "event_kind": "glasses_connected",
78
90
  "fully_booted": status.fullyBooted,
@@ -81,7 +93,25 @@ final class BluetoothSdkAnalytics {
81
93
  properties["glasses_model"] = status.deviceModel
82
94
  }
83
95
  capture(event: "bluetooth_sdk_glasses_connected", properties: properties, configuration: configuration)
96
+ // Fall through: a serial already present at connect time (G1/Ar99
97
+ // report it in the advertisement) should be identified now rather than
98
+ // waiting for some later, unrelated glasses-store update to run.
99
+ }
100
+
101
+ guard !identifiedCapturedForConnection,
102
+ let serialNumber = status.serialNumber.validManufacturingSerial
103
+ else { return }
104
+ identifiedCapturedForConnection = true
105
+ var properties: [String: Any] = [
106
+ "event_kind": "glasses_identified",
107
+ "fully_booted": status.fullyBooted,
108
+ "glasses_device_id": serialNumber,
109
+ "glasses_device_id_type": "manufacturing_serial",
110
+ ]
111
+ if !status.deviceModel.isEmpty {
112
+ properties["glasses_model"] = status.deviceModel
84
113
  }
114
+ capture(event: "bluetooth_sdk_glasses_identified", properties: properties, configuration: configuration)
85
115
  }
86
116
  }
87
117
 
@@ -116,6 +146,7 @@ final class BluetoothSdkAnalytics {
116
146
  "event_source": "mentra_bluetooth_sdk",
117
147
  "sdk_platform": "ios",
118
148
  "sdk_surface": configuration.surface,
149
+ "app_identifier": Bundle.main.bundleIdentifier ?? "",
119
150
  "app_bundle_identifier": Bundle.main.bundleIdentifier ?? "",
120
151
  "os_platform": "ios",
121
152
  "os_version": ProcessInfo.processInfo.operatingSystemVersionString,
@@ -148,6 +179,14 @@ private extension GlassesStatus {
148
179
  }
149
180
  }
150
181
 
182
+ private extension String {
183
+ var validManufacturingSerial: String? {
184
+ let normalized = trimmingCharacters(in: .whitespacesAndNewlines)
185
+ guard !normalized.isEmpty, normalized.contains(where: { $0 != "0" }) else { return nil }
186
+ return normalized
187
+ }
188
+ }
189
+
151
190
  private extension BluetoothSdkAnalyticsConfiguration {
152
191
  func resolvedForApp() -> BluetoothSdkAnalyticsConfiguration {
153
192
  let disabledByApp = Bundle.main.object(forInfoDictionaryKey: "MentraBluetoothSdkAnalyticsDisabled") as? Bool == true