@mentra/bluetooth-sdk 3.2.1-dev.314 → 3.3.0-beta.942

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 (191) hide show
  1. package/README.md +72 -10
  2. package/android/build.gradle +15 -0
  3. package/android/lc3Lib/build.gradle +6 -1
  4. package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +368 -0
  5. package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocol.kt +156 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalytics.kt +63 -14
  7. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTracker.kt +55 -1
  8. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +4 -1
  9. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkInstallId.kt +23 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +8 -2
  11. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkUpdateCheck.kt +184 -0
  12. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkUsageTracker.kt +388 -0
  13. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +2 -1
  14. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +133 -26
  15. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +37 -13
  16. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +3 -2
  17. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +6 -6
  18. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +227 -36
  19. package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +24 -0
  20. package/android/src/main/java/com/mentra/bluetoothsdk/NetworkDiagnostics.kt +25 -0
  21. package/android/src/main/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulator.kt +13 -2
  22. package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleTraceLogger.kt +3 -0
  23. package/android/src/main/java/com/mentra/bluetoothsdk/requests/DisplayRequests.kt +1 -1
  24. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +33 -0
  25. package/android/src/main/java/com/mentra/bluetoothsdk/services/G2ConnectionRecovery.kt +47 -0
  26. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioConnectionTracker.kt +4 -0
  27. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioServiceDiscovery.kt +70 -0
  28. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +2 -2
  29. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizer.kt +11 -32
  30. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +72 -21
  31. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/LatinTextSanitizer.kt +39 -0
  32. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +194 -40
  33. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +12 -8
  34. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +314 -395
  35. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodec.kt +247 -0
  36. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinator.kt +238 -0
  37. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasEncoder.kt +58 -0
  38. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSession.kt +180 -0
  39. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +11 -0
  40. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SelectedDeviceAddress.kt +17 -0
  41. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SerializedGattCallback.kt +5 -0
  42. package/android/src/main/java/com/mentra/bluetoothsdk/utils/G2Text.kt +7 -0
  43. package/android/src/release/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +20 -0
  44. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTrackerTest.kt +58 -3
  45. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkUpdateCheckTest.kt +124 -0
  46. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkUsageTrackerTest.kt +263 -0
  47. package/android/src/test/java/com/mentra/bluetoothsdk/DashboardContentFormatterTest.kt +2 -2
  48. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceIdentityPairingTest.kt +59 -0
  49. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerSceneHandoffTest.kt +666 -0
  50. package/android/src/test/java/com/mentra/bluetoothsdk/G2TextTest.kt +13 -0
  51. package/android/src/test/java/com/mentra/bluetoothsdk/NetworkDiagnosticsTest.kt +55 -0
  52. package/android/src/test/java/com/mentra/bluetoothsdk/ScanCompletionTest.kt +5 -1
  53. package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulatorTest.kt +71 -0
  54. package/android/src/test/java/com/mentra/bluetoothsdk/debug/BleTraceLoggerTest.kt +69 -0
  55. package/android/src/test/java/com/mentra/bluetoothsdk/services/G2ConnectionRecoveryTest.kt +51 -0
  56. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioServiceDiscoveryTest.kt +124 -0
  57. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1PairingTest.kt +105 -0
  58. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizerTest.kt +29 -4
  59. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2DisplayThreadTest.kt +120 -0
  60. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2PairingTargetTest.kt +90 -0
  61. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveClassicDisconnectTest.kt +133 -0
  62. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveFileTransferSessionTest.kt +55 -0
  63. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCallbackTest.kt +33 -0
  64. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveIncidentDispatchTest.kt +126 -0
  65. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingTargetTest.kt +65 -0
  66. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveWifiCredentialLogTest.kt +55 -0
  67. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraNexPairingTest.kt +151 -0
  68. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoBitmapAcceptanceTest.kt +105 -0
  69. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodecTest.kt +233 -0
  70. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinatorTest.kt +348 -0
  71. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSessionTest.kt +339 -0
  72. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoMicReconnectTest.kt +93 -0
  73. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoProtocolTest.kt +11 -0
  74. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/SelectedDeviceAddressTest.kt +27 -0
  75. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnosticsTest.kt +287 -0
  76. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocolTest.kt +127 -0
  77. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoLifecycleTest.kt +288 -0
  78. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoReadinessDriverTest.kt +205 -0
  79. package/build/BluetoothSdk.types.d.ts +16 -0
  80. package/build/BluetoothSdk.types.d.ts.map +1 -1
  81. package/build/BluetoothSdk.types.js +1 -0
  82. package/build/BluetoothSdk.types.js.map +1 -1
  83. package/build/_private/BluetoothSdkModule.d.ts +4 -0
  84. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  85. package/build/_private/BluetoothSdkModule.js +5 -1
  86. package/build/_private/BluetoothSdkModule.js.map +1 -1
  87. package/build/_private/MentraLocalNetworkModule.d.ts +23 -0
  88. package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -1
  89. package/build/_private/MentraLocalNetworkModule.js.map +1 -1
  90. package/build/_private/nativeLogConsole.d.ts +19 -0
  91. package/build/_private/nativeLogConsole.d.ts.map +1 -1
  92. package/build/_private/nativeLogConsole.js +12 -0
  93. package/build/_private/nativeLogConsole.js.map +1 -1
  94. package/build/generated/changelogCatalog.d.ts +5 -2
  95. package/build/generated/changelogCatalog.d.ts.map +1 -1
  96. package/build/generated/changelogCatalog.js +6 -2
  97. package/build/generated/changelogCatalog.js.map +1 -1
  98. package/build/generated/releaseMetadata.js +6 -6
  99. package/build/generated/releaseMetadata.js.map +1 -1
  100. package/build/index.d.ts.map +1 -1
  101. package/build/index.js +1 -0
  102. package/build/index.js.map +1 -1
  103. package/build/ota-transport/index.d.ts +4 -0
  104. package/build/ota-transport/index.d.ts.map +1 -1
  105. package/build/ota-transport/index.js +2 -0
  106. package/build/ota-transport/index.js.map +1 -1
  107. package/ios/BluetoothSdkModule.swift +17 -4
  108. package/ios/MentraBluetoothSDK.podspec +3 -2
  109. package/ios/Source/BluetoothBackgroundMode.swift +32 -0
  110. package/ios/Source/BluetoothSdkDefaults.swift +7 -2
  111. package/ios/Source/Bridge.swift +15 -2
  112. package/ios/Source/DeviceManager.swift +102 -66
  113. package/ios/Source/DeviceStore.swift +26 -7
  114. package/ios/Source/GeneratedChangelogCatalog.swift +3 -2
  115. package/ios/Source/GeneratedReleaseMetadata.swift +6 -6
  116. package/ios/Source/MentraBluetoothSDK.swift +313 -54
  117. package/ios/Source/audio/GlassesMicWatchdog.swift +33 -0
  118. package/ios/Source/internal/BluetoothAvailability.swift +6 -0
  119. package/ios/Source/internal/BluetoothSdkAnalytics.swift +87 -13
  120. package/ios/Source/internal/BluetoothSdkAnalyticsTracker.swift +54 -1
  121. package/ios/Source/internal/BluetoothSdkInstallId.swift +21 -0
  122. package/ios/Source/internal/BluetoothSdkUpdateCheck.swift +223 -0
  123. package/ios/Source/internal/BluetoothSdkUsageTracker.swift +428 -0
  124. package/ios/Source/requests/DisplayRequests.swift +1 -1
  125. package/ios/Source/requests/VersionInfoResponseAccumulator.swift +11 -1
  126. package/ios/Source/sgcs/Ar99.swift +1 -2
  127. package/ios/Source/sgcs/G1.swift +68 -38
  128. package/ios/Source/sgcs/G1ConnectionTarget.swift +13 -0
  129. package/ios/Source/sgcs/G2.swift +261 -247
  130. package/ios/Source/sgcs/G2ConnectionSession.swift +101 -0
  131. package/ios/Source/sgcs/Mach1.swift +19 -7
  132. package/ios/Source/sgcs/MentraLive.swift +272 -197
  133. package/ios/Source/sgcs/MentraLiveFileTransferSession.swift +137 -0
  134. package/ios/Source/sgcs/MentraLivePairingAdvertisement.swift +47 -0
  135. package/ios/Source/sgcs/MentraNex.swift +8 -31
  136. package/ios/Source/sgcs/NexConnectionTarget.swift +15 -0
  137. package/ios/Source/sgcs/Nimo.swift +280 -408
  138. package/ios/Source/sgcs/NimoCanvasCodec.swift +272 -0
  139. package/ios/Source/sgcs/NimoCanvasCoordinator.swift +233 -0
  140. package/ios/Source/sgcs/NimoCanvasImage.swift +53 -0
  141. package/ios/Source/sgcs/NimoCanvasSession.swift +166 -0
  142. package/ios/Source/sgcs/NimoDiscovery.swift +49 -0
  143. package/ios/Source/sgcs/SGCManager.swift +25 -1
  144. package/ios/Source/sgcs/VuzixConnectionTarget.swift +15 -0
  145. package/ios/Source/status/DeviceStatus.swift +1 -0
  146. package/ios/Source/stt/SherpaOnnxTranscriber.swift +49 -189
  147. package/ios/Source/utils/AudioSessionMonitor.swift +44 -8
  148. package/ios/Source/utils/DemandDrivenAudioWorker.swift +150 -0
  149. package/ios/Source/utils/G1Text.swift +2 -24
  150. package/ios/Source/utils/G1TextSanitizer.swift +27 -0
  151. package/ios/Source/utils/G2Text.swift +11 -0
  152. package/ios/Source/utils/LatinTextSanitizer.swift +48 -0
  153. package/ios/Tests/BluetoothAudioRouteTests.swift +26 -0
  154. package/ios/Tests/BluetoothBackgroundModeTests.swift +32 -0
  155. package/ios/Tests/BluetoothSdkAnalyticsTrackerTests.swift +54 -3
  156. package/ios/Tests/BluetoothSdkUpdateCheckTests.swift +153 -0
  157. package/ios/Tests/BluetoothSdkUsageTrackerTests.swift +296 -0
  158. package/ios/Tests/DashboardContentFormatterTests.swift +2 -2
  159. package/ios/Tests/DashboardRenderingTests.swift +31 -0
  160. package/ios/Tests/DemandDrivenAudioWorkerTests.swift +157 -0
  161. package/ios/Tests/DeviceIdentityPairingTests.swift +49 -0
  162. package/ios/Tests/DeviceManagerReconnectTests.swift +362 -0
  163. package/ios/Tests/G1PairingTests.swift +162 -0
  164. package/ios/Tests/G1TextSanitizerTests.swift +32 -0
  165. package/ios/Tests/G2ConnectionSessionTests.swift +172 -0
  166. package/ios/Tests/G2TextTests.swift +10 -0
  167. package/ios/Tests/IncidentLogDispatchTests.swift +111 -0
  168. package/ios/Tests/MentraLiveFileTransferSessionTests.swift +47 -0
  169. package/ios/Tests/MentraLiveReconnectPolicyTests.swift +58 -0
  170. package/ios/Tests/MentraLiveSessionReadinessTests.swift +163 -0
  171. package/ios/Tests/NimoCanvasCodecTests.swift +217 -0
  172. package/ios/Tests/NimoCanvasSessionTests.swift +181 -0
  173. package/ios/Tests/NimoCanvasTransportTests.swift +186 -0
  174. package/ios/Tests/NimoDeviceCommandsTests.swift +16 -0
  175. package/ios/Tests/NimoDiscoveryTests.swift +79 -0
  176. package/ios/Tests/SGCConnectionTargetTests.swift +48 -0
  177. package/ios/Tests/VersionInfoCommandTests.swift +194 -0
  178. package/ios/Tests/VersionInfoResponseAccumulatorTests.swift +65 -0
  179. package/ios/Tests/VideoRecordingCommandTests.swift +81 -0
  180. package/ios/Tests/WifiCredentialLoggingTests.swift +42 -0
  181. package/package.json +2 -2
  182. package/plugin/build/withIos.d.ts +2 -0
  183. package/plugin/build/withIos.js +12 -1
  184. package/src/BluetoothSdk.types.ts +15 -0
  185. package/src/_private/BluetoothSdkModule.ts +10 -1
  186. package/src/_private/MentraLocalNetworkModule.ts +28 -0
  187. package/src/_private/nativeLogConsole.ts +19 -0
  188. package/src/generated/changelogCatalog.ts +6 -2
  189. package/src/generated/releaseMetadata.ts +6 -6
  190. package/src/index.ts +1 -0
  191. package/src/ota-transport/index.ts +10 -1
@@ -134,9 +134,12 @@ private final class PendingVideoRecordingRequest {
134
134
  private final class PendingVersionInfoRequest {
135
135
  let pending: PendingResponse<VersionInfoResult>
136
136
  let accumulator: VersionInfoResponseAccumulator
137
+ let requestId: String
138
+ let startedAt = ProcessInfo.processInfo.systemUptime
137
139
 
138
140
  init(pending: PendingResponse<VersionInfoResult>, requestId: String) {
139
141
  self.pending = pending
142
+ self.requestId = requestId
140
143
  accumulator = VersionInfoResponseAccumulator(expectedRequestId: requestId)
141
144
  }
142
145
  }
@@ -163,13 +166,18 @@ private final class PendingStreamStart {
163
166
  @MainActor
164
167
  final class PendingResponse<T> {
165
168
  private let operation: String
169
+ /// The operation without per-request ids, so timeouts group in analytics.
170
+ private let telemetryOperation: String
171
+ private let onTimeout: ((String) -> Void)?
166
172
  private var continuation: CheckedContinuation<T, Error>?
167
173
  private var timeoutTask: Task<Void, Never>?
168
174
  private var result: Result<T, Error>?
169
175
  private var completed = false
170
176
 
171
- init(operation: String) {
177
+ init(operation: String, telemetryOperation: String? = nil, onTimeout: ((String) -> Void)? = nil) {
172
178
  self.operation = operation
179
+ self.telemetryOperation = telemetryOperation ?? operation
180
+ self.onTimeout = onTimeout
173
181
  }
174
182
 
175
183
  func resolve(_ value: T) {
@@ -207,10 +215,12 @@ final class PendingResponse<T> {
207
215
  } catch {
208
216
  return
209
217
  }
210
- self?.reject(
218
+ guard let self, !self.completed else { return }
219
+ self.onTimeout?(self.telemetryOperation)
220
+ self.reject(
211
221
  BluetoothSdkError(
212
222
  code: "request_timeout",
213
- message: "\(self?.operation ?? "Request") timed out waiting for glasses response."
223
+ message: "\(self.operation) timed out waiting for glasses response."
214
224
  )
215
225
  )
216
226
  }
@@ -254,6 +264,7 @@ public final class MentraBluetoothSDK {
254
264
  private static let otaBesVersionWaitMs = 5000
255
265
  private static let otaMtkVersionWaitMs = 2000
256
266
  private static let otaVersionPollMs = 100
267
+ private static let micStateKeys: Set<String> = ["currentMic", "should_send_transcript", "local_stt_fallback_active"]
257
268
 
258
269
  public weak var delegate: MentraBluetoothSDKDelegate?
259
270
 
@@ -272,6 +283,7 @@ public final class MentraBluetoothSDK {
272
283
  private var activeScanSessions: [UUID: ActiveScanSession] = [:]
273
284
  private let streamSession = StreamSessionState()
274
285
  private let analytics: BluetoothSdkAnalytics
286
+ private var updateCheck: BluetoothSdkUpdateCheck?
275
287
  private var pendingPhotoRequests: [String: PendingResponse<PhotoResponseEvent>] = [:]
276
288
  private var pendingCameraStatusRequests: [String: PendingResponse<CameraStatusEvent>] = [:]
277
289
  private var pendingVideoRecordingRequests: [String: PendingVideoRecordingRequest] = [:]
@@ -299,8 +311,22 @@ public final class MentraBluetoothSDK {
299
311
  private let wifiSessionCapabilities = WifiSessionCapabilities()
300
312
  private var configuredOtaVersionUrl: String?
301
313
 
314
+ /// Whether the app declares the `bluetooth-central` background mode. When false on iOS, the
315
+ /// glasses stay disconnected after a link drop until the user opens the app, so a software
316
+ /// update started on a locked phone stalls. Always true on macOS.
317
+ public static var supportsBackgroundReconnect: Bool {
318
+ BluetoothBackgroundMode.isDeclared(in: Bundle.main.infoDictionary)
319
+ }
320
+
321
+ private static var didWarnMissingBackgroundMode = false
322
+
302
323
  public init(configuration: MentraBluetoothSDKConfiguration = .default) {
303
324
  self.configuration = configuration
325
+ // Once per process: apps may create the SDK more than once.
326
+ if !Self.didWarnMissingBackgroundMode, !Self.supportsBackgroundReconnect {
327
+ Self.didWarnMissingBackgroundMode = true
328
+ Bridge.log(BluetoothBackgroundMode.missingWarning)
329
+ }
304
330
  analytics = BluetoothSdkAnalytics(configuration: configuration.analytics)
305
331
  bluetoothAvailabilityListenerId = BluetoothAvailability.shared.addStateListener { [weak self] state in
306
332
  Task { @MainActor [weak self] in
@@ -336,6 +362,31 @@ public final class MentraBluetoothSDK {
336
362
  }
337
363
  analytics.initializeGlassesStatus(glassesStatus)
338
364
  analytics.captureStarted()
365
+ let updateCheck = BluetoothSdkUpdateCheck(
366
+ surface: BluetoothSdkUpdateCheck.surface(forAnalyticsSurface: configuration.analytics.surface)
367
+ ) { [weak self] banner, result in
368
+ NSLog("%@", banner)
369
+ var values: [String: Any] = [
370
+ "message": banner,
371
+ "status": result.status,
372
+ "installedVersion": BluetoothSdkDefaults.sdkVersion ?? "unknown",
373
+ ]
374
+ if let latestVersion = result.latestVersion { values["latestVersion"] = latestVersion }
375
+ let notice = values
376
+ Task { @MainActor [weak self] in
377
+ guard let self else { return }
378
+ self.delegate?.mentraBluetoothSDK(self, didReceive: .raw(name: "sdk_update_notice", values: notice))
379
+ }
380
+ }
381
+ self.updateCheck = updateCheck
382
+ updateCheck.start()
383
+ }
384
+
385
+ /// Reports a glasses command that timed out, without its per-request id.
386
+ private var requestTimeoutReporter: (String) -> Void {
387
+ { [weak self] operation in
388
+ self?.analytics.recordUsage { $0.requestTimedOut(operation: operation) }
389
+ }
339
390
  }
340
391
 
341
392
  public var state: MentraBluetoothState {
@@ -531,6 +582,7 @@ public final class MentraBluetoothSDK {
531
582
  }
532
583
 
533
584
  public func cancelConnectionAttempt() {
585
+ analytics.noteDisconnectRequested(BluetoothSdkAnalyticsTracker.disconnectCancel)
534
586
  clearBluetoothRestoreIntent()
535
587
  DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "pending_device_name", "")
536
588
  DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "pending_device_address", "")
@@ -544,6 +596,7 @@ public final class MentraBluetoothSDK {
544
596
  }
545
597
 
546
598
  public func disconnect() {
599
+ analytics.noteDisconnectRequested(BluetoothSdkAnalyticsTracker.disconnectUser)
547
600
  clearBluetoothRestoreIntent()
548
601
  DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "pending_device_name", "")
549
602
  DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "pending_device_address", "")
@@ -552,6 +605,7 @@ public final class MentraBluetoothSDK {
552
605
  }
553
606
 
554
607
  public func forget() {
608
+ analytics.noteDisconnectRequested(BluetoothSdkAnalyticsTracker.disconnectForget)
555
609
  clearBluetoothRestoreIntent()
556
610
  DeviceManager.shared.forget()
557
611
  }
@@ -664,7 +718,7 @@ public final class MentraBluetoothSDK {
664
718
  send: (String) throws -> Void
665
719
  ) async throws -> SettingsAckEvent {
666
720
  let requestId = "settings-\(setting)-\(UUID().uuidString)"
667
- let pending = PendingResponse<SettingsAckEvent>(operation: "set \(setting)")
721
+ let pending = PendingResponse<SettingsAckEvent>(operation: "set \(setting)", onTimeout: requestTimeoutReporter)
668
722
  pendingSettingsRequests[requestId] = pending
669
723
  do {
670
724
  try send(requestId)
@@ -688,6 +742,10 @@ public final class MentraBluetoothSDK {
688
742
  DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "loudness_gate_enabled", enabled)
689
743
  }
690
744
 
745
+ public func setAutoPowerOffEnabled(_ enabled: Bool) async throws {
746
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "auto_power_off_enabled", enabled)
747
+ }
748
+
691
749
  @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.")
692
750
  public func setPhotoCaptureDefaults(_ settings: PhotoCaptureDefaults) async throws -> SettingsAckEvent {
693
751
  try await performSettingsCommand(
@@ -933,7 +991,7 @@ public final class MentraBluetoothSDK {
933
991
  // the scan screen auto-starts a scan on mount and can be pushed twice.
934
992
  return try await existing.value
935
993
  }
936
- let pending = PendingResponse<[WifiScanResult]>(operation: "WiFi scan request")
994
+ let pending = PendingResponse<[WifiScanResult]>(operation: "WiFi scan request", onTimeout: requestTimeoutReporter)
937
995
  let request = PendingWifiScan(pending: pending, scanId: "scan-\(UUID().uuidString)")
938
996
  pendingWifiScan = request
939
997
  // Claim the scan-results store for this scan before the command goes out,
@@ -973,11 +1031,15 @@ public final class MentraBluetoothSDK {
973
1031
  message: "A WiFi status command is already waiting for a glasses response."
974
1032
  )
975
1033
  }
976
- let pending = PendingResponse<WifiStatusEvent>(operation: "WiFi connect request")
1034
+ let pending = PendingResponse<WifiStatusEvent>(operation: "WiFi connect request", onTimeout: requestTimeoutReporter)
977
1035
  pendingWifiStatus = PendingWifiStatusRequest(ssid: ssid, pending: pending)
978
- DeviceManager.shared.sendWifiCredentials(ssid, password)
979
1036
  do {
980
- let event = try await pending.wait()
1037
+ let event = try await withUsageOutcome(record: { latencyMs, errorCode, _ in
1038
+ analytics.recordUsage { $0.wifiSetupSettled(latencyMs: latencyMs, errorCode: errorCode) }
1039
+ }) {
1040
+ DeviceManager.shared.sendWifiCredentials(ssid, password)
1041
+ return try await pending.wait()
1042
+ }
981
1043
  if pendingWifiStatus?.pending === pending {
982
1044
  pendingWifiStatus = nil
983
1045
  }
@@ -1000,7 +1062,7 @@ public final class MentraBluetoothSDK {
1000
1062
  message: "A WiFi status command is already waiting for a glasses response."
1001
1063
  )
1002
1064
  }
1003
- let pending = PendingResponse<WifiForgetResult>(operation: "WiFi forget request")
1065
+ let pending = PendingResponse<WifiForgetResult>(operation: "WiFi forget request", onTimeout: requestTimeoutReporter)
1004
1066
  let requestId = "forget-\(UUID().uuidString)"
1005
1067
  let request = PendingWifiForgetRequest(
1006
1068
  ssid: ssid,
@@ -1045,7 +1107,7 @@ public final class MentraBluetoothSDK {
1045
1107
  error: "saved_wifi_networks_unsupported"
1046
1108
  )
1047
1109
  }
1048
- let pending = PendingResponse<SavedWifiNetworksResult>(operation: "Saved WiFi networks request")
1110
+ let pending = PendingResponse<SavedWifiNetworksResult>(operation: "Saved WiFi networks request", onTimeout: requestTimeoutReporter)
1049
1111
  let request = PendingSavedWifiNetworks(
1050
1112
  requestId: requestId,
1051
1113
  sid: wifiSessionCapabilities.sessionId,
@@ -1082,12 +1144,18 @@ public final class MentraBluetoothSDK {
1082
1144
  _ = try? await previous.value
1083
1145
  }
1084
1146
  let pending = PendingResponse<HotspotStatusEvent>(
1085
- operation: "hotspot \(enabled ? "enable" : "disable") request"
1147
+ operation: "hotspot \(enabled ? "enable" : "disable") request",
1148
+ onTimeout: self.requestTimeoutReporter
1086
1149
  )
1087
1150
  self.pendingHotspotStatus = PendingHotspotStatusRequest(enabled: enabled, pending: pending)
1088
- DeviceManager.shared.setHotspotState(enabled)
1089
1151
  do {
1090
- let event = try await pending.wait()
1152
+ let event = try await self.withUsageOutcome(record: { latencyMs, errorCode, _ in
1153
+ guard enabled else { return }
1154
+ self.analytics.recordUsage { $0.hotspotStartSettled(latencyMs: latencyMs, errorCode: errorCode) }
1155
+ }) {
1156
+ DeviceManager.shared.setHotspotState(enabled)
1157
+ return try await pending.wait()
1158
+ }
1091
1159
  if self.pendingHotspotStatus?.pending === pending {
1092
1160
  self.pendingHotspotStatus = nil
1093
1161
  }
@@ -1120,13 +1188,30 @@ public final class MentraBluetoothSDK {
1120
1188
  Bridge.log(
1121
1189
  "NATIVE: PHOTO PIPELINE [3b/6] MentraBluetoothSdk.requestPhoto requestId=\(routedRequest.requestId)"
1122
1190
  )
1123
- let pending = PendingResponse<PhotoResponseEvent>(operation: "photo request \(routedRequest.requestId)")
1191
+ let pending = PendingResponse<PhotoResponseEvent>(operation: "photo request \(routedRequest.requestId)", telemetryOperation: "photo request", onTimeout: requestTimeoutReporter)
1124
1192
  pendingPhotoRequests[routedRequest.requestId] = pending
1125
- DeviceManager.shared.requestPhoto(routedRequest)
1126
1193
  do {
1127
- let event = try await pending.wait(timeoutMs: routedRequest.presendThumbnail
1128
- ? MentraBluetoothSDK.photoThumbnailRequestTimeoutMs
1129
- : MentraBluetoothSDK.photoRequestTimeoutMs)
1194
+ let event = try await withUsageOutcome(record: { (latencyMs, errorCode, event: PhotoResponseEvent?) in
1195
+ var fileSizeBytes: Int64?
1196
+ if case let .success(_, _, _, _, _, size?, _) = event?.response { fileSizeBytes = Int64(size) }
1197
+ analytics.recordUsage {
1198
+ $0.photoSettled(
1199
+ size: routedRequest.size.rawValue,
1200
+ mode: routedRequest.mode.rawValue,
1201
+ compress: routedRequest.compress.rawValue,
1202
+ save: routedRequest.save,
1203
+ transferMethod: routedRequest.transferMethod,
1204
+ latencyMs: latencyMs,
1205
+ fileSizeBytes: fileSizeBytes,
1206
+ errorCode: errorCode
1207
+ )
1208
+ }
1209
+ }) {
1210
+ DeviceManager.shared.requestPhoto(routedRequest)
1211
+ return try await pending.wait(timeoutMs: routedRequest.presendThumbnail
1212
+ ? MentraBluetoothSDK.photoThumbnailRequestTimeoutMs
1213
+ : MentraBluetoothSDK.photoRequestTimeoutMs)
1214
+ }
1130
1215
  pendingPhotoRequests.removeValue(forKey: routedRequest.requestId)
1131
1216
  return event
1132
1217
  } catch {
@@ -1145,7 +1230,7 @@ public final class MentraBluetoothSDK {
1145
1230
  mfnr: Bool? = nil
1146
1231
  ) async throws -> CameraStatusEvent {
1147
1232
  let effectiveRequestId = nonBlankRequestId(requestId) ?? generatedCameraRequestId("warm")
1148
- let pending = PendingResponse<CameraStatusEvent>(operation: "camera warm up \(effectiveRequestId)")
1233
+ let pending = PendingResponse<CameraStatusEvent>(operation: "camera warm up \(effectiveRequestId)", telemetryOperation: "camera warm up", onTimeout: requestTimeoutReporter)
1149
1234
  pendingCameraStatusRequests[effectiveRequestId] = pending
1150
1235
  do {
1151
1236
  // Inside the catch so an unsupported-device throw also clears the pending entry.
@@ -1183,7 +1268,7 @@ public final class MentraBluetoothSDK {
1183
1268
  message: "A gallery status query is already waiting for a glasses response."
1184
1269
  )
1185
1270
  }
1186
- let pending = PendingResponse<GalleryStatusEvent>(operation: "gallery status query")
1271
+ let pending = PendingResponse<GalleryStatusEvent>(operation: "gallery status query", onTimeout: requestTimeoutReporter)
1187
1272
  pendingGalleryStatus = pending
1188
1273
  DeviceManager.shared.queryGalleryStatus()
1189
1274
  do {
@@ -1201,33 +1286,53 @@ public final class MentraBluetoothSDK {
1201
1286
  }
1202
1287
 
1203
1288
  public func startStream(_ request: StreamRequest) async throws -> StreamStatusEvent {
1204
- try requireGlassesConnected(operation: "start stream")
1205
- guard streamSession.supported else {
1206
- throw BluetoothSdkError(code: "stream_control_unsupported", message: "Update the glasses software before starting a stream.")
1207
- }
1208
1289
  var values = request.values
1209
1290
  let streamId = stringValue(values, "streamId").flatMap { $0.isEmpty ? nil : $0 } ?? "sdk-\(UUID().uuidString)"
1210
1291
  values["streamId"] = streamId
1211
- let pending = PendingResponse<StreamStatusEvent>(operation: "start stream \(streamId)")
1212
- // seq assignment through the BLE hand-off must stay await-free so this
1213
- // MainActor turn is one critical section: rejectPreemptedStreamStarts
1214
- // only works if seq order equals the order the commands reach the
1215
- // glasses' FIFO.
1216
- streamStartSeq += 1
1217
- pendingStreamStarts[streamId] = PendingStreamStart(seq: streamStartSeq, pending: pending)
1218
- DeviceManager.shared.startStream(values)
1219
- do {
1220
- let event = try await pending.wait(timeoutMs: 30000)
1221
- pendingStreamStarts.removeValue(forKey: streamId)
1222
- return event
1223
- } catch {
1224
- pendingStreamStarts.removeValue(forKey: streamId)
1225
- throw error
1292
+ let message = values
1293
+ return try await withUsageOutcome(record: { latencyMs, errorCode, _ in
1294
+ let glassesNetwork = glassesNetwork()
1295
+ analytics.recordUsage {
1296
+ $0.streamStartSettled(
1297
+ streamUrl: request.streamUrl,
1298
+ width: request.video?.width,
1299
+ height: request.video?.height,
1300
+ fps: request.video?.fps,
1301
+ bitrate: request.video?.bitrate,
1302
+ captureAudio: request.captureAudio,
1303
+ glassesNetwork: glassesNetwork,
1304
+ latencyMs: latencyMs,
1305
+ errorCode: errorCode
1306
+ )
1307
+ }
1308
+ }) {
1309
+ try requireGlassesConnected(operation: "start stream")
1310
+ guard streamSession.supported else {
1311
+ throw BluetoothSdkError(code: "stream_control_unsupported", message: "Update the glasses software before starting a stream.")
1312
+ }
1313
+ let pending = PendingResponse<StreamStatusEvent>(operation: "start stream \(streamId)", telemetryOperation: "start stream", onTimeout: requestTimeoutReporter)
1314
+ let requestedAt = BluetoothSdkAnalytics.epochMillis()
1315
+ analytics.recordUsage { $0.streamRequested(streamId: streamId, streamUrl: request.streamUrl, nowMs: requestedAt) }
1316
+ // seq assignment through the BLE hand-off must stay await-free so this
1317
+ // MainActor turn is one critical section: rejectPreemptedStreamStarts
1318
+ // only works if seq order equals the order the commands reach the
1319
+ // glasses' FIFO.
1320
+ streamStartSeq += 1
1321
+ pendingStreamStarts[streamId] = PendingStreamStart(seq: streamStartSeq, pending: pending)
1322
+ DeviceManager.shared.startStream(message)
1323
+ do {
1324
+ let event = try await pending.wait(timeoutMs: 30000)
1325
+ pendingStreamStarts.removeValue(forKey: streamId)
1326
+ return event
1327
+ } catch {
1328
+ pendingStreamStarts.removeValue(forKey: streamId)
1329
+ throw error
1330
+ }
1226
1331
  }
1227
1332
  }
1228
1333
 
1229
1334
  public func rgbLedControl(_ request: RgbLedRequest) async throws -> RgbLedControlResponseEvent {
1230
- let pending = PendingResponse<RgbLedControlResponseEvent>(operation: "RGB LED command \(request.requestId)")
1335
+ let pending = PendingResponse<RgbLedControlResponseEvent>(operation: "RGB LED command \(request.requestId)", telemetryOperation: "RGB LED command", onTimeout: requestTimeoutReporter)
1231
1336
  pendingRgbLedRequests[request.requestId] = pending
1232
1337
  DeviceManager.shared.rgbLedControl(
1233
1338
  requestId: request.requestId,
@@ -1249,13 +1354,14 @@ public final class MentraBluetoothSDK {
1249
1354
  }
1250
1355
 
1251
1356
  public func stopStream() async throws -> StreamStatusEvent {
1357
+ analytics.updateUsage { $0.streamStopRequested() }
1252
1358
  guard pendingStreamStop == nil else {
1253
1359
  throw BluetoothSdkError(
1254
1360
  code: "request_in_flight",
1255
1361
  message: "A stream stop command is already waiting for a glasses response."
1256
1362
  )
1257
1363
  }
1258
- let pending = PendingResponse<StreamStatusEvent>(operation: "stop stream")
1364
+ let pending = PendingResponse<StreamStatusEvent>(operation: "stop stream", onTimeout: requestTimeoutReporter)
1259
1365
  // The seq draw and the BLE hand-off stay in this single await-free
1260
1366
  // MainActor turn, sharing startStream's ordering critical section:
1261
1367
  // the stop takes its own slot in the send order, so its ack can
@@ -1284,7 +1390,9 @@ public final class MentraBluetoothSDK {
1284
1390
  }
1285
1391
  try requireGlassesConnected(operation: "start video recording")
1286
1392
  let pending = PendingResponse<VideoRecordingStatusEvent>(
1287
- operation: "start video recording \(request.requestId)"
1393
+ operation: "start video recording \(request.requestId)",
1394
+ telemetryOperation: "start video recording",
1395
+ onTimeout: requestTimeoutReporter
1288
1396
  )
1289
1397
  guard pendingVideoRecordingRequests[request.requestId] == nil else {
1290
1398
  throw BluetoothSdkError(
@@ -1296,6 +1404,16 @@ public final class MentraBluetoothSDK {
1296
1404
  expectedStatus: "recording_started",
1297
1405
  pending: pending
1298
1406
  )
1407
+ analytics.updateUsage {
1408
+ $0.videoRequested(
1409
+ requestId: request.requestId,
1410
+ width: request.width,
1411
+ height: request.height,
1412
+ fps: request.fps,
1413
+ save: request.save,
1414
+ maxDurationMinutes: request.maxRecordingTimeMinutes
1415
+ )
1416
+ }
1299
1417
  DeviceManager.shared.startVideoRecording(
1300
1418
  request.requestId,
1301
1419
  request.save,
@@ -1322,7 +1440,7 @@ public final class MentraBluetoothSDK {
1322
1440
  throw BluetoothSdkError(code: "missing_request_id", message: "requestId is required to stop video recording.")
1323
1441
  }
1324
1442
  try requireGlassesConnected(operation: "stop video recording")
1325
- let pending = PendingResponse<VideoRecordingStatusEvent>(operation: "stop video recording \(requestId)")
1443
+ let pending = PendingResponse<VideoRecordingStatusEvent>(operation: "stop video recording \(requestId)", telemetryOperation: "stop video recording", onTimeout: requestTimeoutReporter)
1326
1444
  guard pendingVideoRecordingRequests[requestId] == nil else {
1327
1445
  throw BluetoothSdkError(
1328
1446
  code: "request_in_flight",
@@ -1335,6 +1453,7 @@ public final class MentraBluetoothSDK {
1335
1453
  pending: pending,
1336
1454
  waitForUpload: waitForUpload
1337
1455
  )
1456
+ analytics.updateUsage { $0.videoStopRequested(requestId: requestId) }
1338
1457
  DeviceManager.shared.stopVideoRecording(requestId, webhookUrl, authToken)
1339
1458
  do {
1340
1459
  let timeoutMs = waitForUpload ? videoUploadStopTimeoutMs : 15000
@@ -1355,7 +1474,7 @@ public final class MentraBluetoothSDK {
1355
1474
  )
1356
1475
  }
1357
1476
  try requireGlassesConnected(operation: "query video recording status")
1358
- let pending = PendingResponse<VideoRecordingStatusEvent>(operation: "query video recording status \(requestId)")
1477
+ let pending = PendingResponse<VideoRecordingStatusEvent>(operation: "query video recording status \(requestId)", telemetryOperation: "query video recording status", onTimeout: requestTimeoutReporter)
1359
1478
  guard pendingVideoRecordingRequests[requestId] == nil else {
1360
1479
  throw BluetoothSdkError(
1361
1480
  code: "request_in_flight",
@@ -1378,27 +1497,35 @@ public final class MentraBluetoothSDK {
1378
1497
  }
1379
1498
 
1380
1499
  public func requestVersionInfo() async throws -> VersionInfoResult {
1381
- guard pendingVersionInfo == nil else {
1500
+ if let request = pendingVersionInfo {
1501
+ logVersionInfoRequest(request, stage: "refused", extra: ["code": "request_in_flight"])
1382
1502
  throw BluetoothSdkError(
1383
1503
  code: "request_in_flight",
1384
1504
  message: "A version info request is already waiting for a glasses response."
1385
1505
  )
1386
1506
  }
1387
1507
  let requestId = UUID().uuidString
1388
- let pending = PendingResponse<VersionInfoResult>(operation: "version info request")
1508
+ let pending = PendingResponse<VersionInfoResult>(operation: "version info request", onTimeout: requestTimeoutReporter)
1389
1509
  let request = PendingVersionInfoRequest(pending: pending, requestId: requestId)
1390
1510
  pendingVersionInfo = request
1511
+ logVersionInfoRequest(request, stage: "registered")
1391
1512
  DeviceManager.shared.requestVersionInfo(requestId: requestId)
1392
1513
  do {
1393
1514
  let status = try await pending.wait()
1394
1515
  if pendingVersionInfo === request {
1395
1516
  pendingVersionInfo = nil
1396
1517
  }
1518
+ logVersionInfoRequest(request, stage: "resolved")
1397
1519
  return status
1398
1520
  } catch {
1399
1521
  if pendingVersionInfo === request {
1400
1522
  pendingVersionInfo = nil
1401
1523
  }
1524
+ let sdkError = error as? BluetoothSdkError
1525
+ logVersionInfoRequest(request, stage: "rejected", extra: [
1526
+ "code": sdkError?.code ?? String((error as NSError).code),
1527
+ "message": String((sdkError?.message ?? error.localizedDescription).prefix(256)),
1528
+ ])
1402
1529
  throw error
1403
1530
  }
1404
1531
  }
@@ -1476,7 +1603,7 @@ public final class MentraBluetoothSDK {
1476
1603
  message: "An OTA status query is already waiting for a glasses response."
1477
1604
  )
1478
1605
  }
1479
- let pending = PendingResponse<OtaQueryResult>(operation: operation)
1606
+ let pending = PendingResponse<OtaQueryResult>(operation: operation, onTimeout: requestTimeoutReporter)
1480
1607
  pendingOtaQuery = pending
1481
1608
  sendRequest()
1482
1609
  do {
@@ -1507,7 +1634,7 @@ public final class MentraBluetoothSDK {
1507
1634
  message: "An OTA start command is already waiting for a glasses response."
1508
1635
  )
1509
1636
  }
1510
- let pending = PendingResponse<OtaStartAckEvent>(operation: "OTA start command")
1637
+ let pending = PendingResponse<OtaStartAckEvent>(operation: "OTA start command", onTimeout: requestTimeoutReporter)
1511
1638
  pendingOtaStart = pending
1512
1639
  DeviceManager.shared.sendOtaStart(otaVersionUrl: otaVersionUrl)
1513
1640
  do {
@@ -1638,8 +1765,8 @@ public final class MentraBluetoothSDK {
1638
1765
  DeviceManager.shared.sendReboot()
1639
1766
  }
1640
1767
 
1641
- func sendIncidentId(_ incidentId: String, apiBaseUrl: String? = nil) {
1642
- DeviceManager.shared.sendIncidentId(incidentId, apiBaseUrl: apiBaseUrl)
1768
+ func sendIncidentId(_ incidentId: String, apiBaseUrl: String? = nil) throws {
1769
+ try DeviceManager.shared.sendIncidentId(incidentId, apiBaseUrl: apiBaseUrl)
1643
1770
  }
1644
1771
 
1645
1772
  public func invalidate() {
@@ -1656,6 +1783,7 @@ public final class MentraBluetoothSDK {
1656
1783
  DeviceStore.shared.store.removeListener(storeListenerId)
1657
1784
  self.storeListenerId = nil
1658
1785
  }
1786
+ updateCheck?.stop()
1659
1787
  delegate = nil
1660
1788
  }
1661
1789
 
@@ -2249,17 +2377,90 @@ public final class MentraBluetoothSDK {
2249
2377
  )
2250
2378
  }
2251
2379
 
2380
+ private func recordStreamStatus(_ event: StreamStatusEvent) {
2381
+ guard analytics.isEnabled else { return }
2382
+ let attempt: Int?
2383
+ var statusErrorDetails: String?
2384
+ switch event.status {
2385
+ case let .reconnecting(_, reconnectAttempt, _, _, _, _), let .reconnected(_, reconnectAttempt, _, _):
2386
+ attempt = reconnectAttempt
2387
+ case let .error(_, errorDetails, _, _):
2388
+ attempt = nil
2389
+ statusErrorDetails = errorDetails
2390
+ default:
2391
+ attempt = nil
2392
+ }
2393
+ let errorDetails = event.errorDetails ?? statusErrorDetails
2394
+ let stats = event.stats
2395
+ let now = BluetoothSdkAnalytics.epochMillis()
2396
+ analytics.recordUsage {
2397
+ $0.streamStatus(
2398
+ streamId: event.streamId,
2399
+ state: event.state.rawValue,
2400
+ terminal: event.terminal,
2401
+ reconnectAttempt: attempt,
2402
+ bitrate: stats?.bitrate,
2403
+ fps: stats?.fps,
2404
+ droppedFrames: stats?.droppedFrames,
2405
+ temperatureC: stats?.temperatureC,
2406
+ errorDetails: errorDetails,
2407
+ nowMs: now
2408
+ )
2409
+ }
2410
+ }
2411
+
2412
+ private func glassesNetwork() -> String {
2413
+ let glasses = glassesStatus
2414
+ if case .enabled = glasses.hotspot { return "hotspot" }
2415
+ if case .connected = glasses.wifi { return "wifi" }
2416
+ return "none"
2417
+ }
2418
+
2419
+ /// Runs a glasses command and reports its latency and outcome to usage
2420
+ /// analytics. Cancellation is the caller giving up, not a command outcome, so
2421
+ /// it is not reported.
2422
+ private func withUsageOutcome<T>(
2423
+ record: (_ latencyMs: Int64, _ errorCode: String?, _ result: T?) -> Void,
2424
+ _ block: () async throws -> T
2425
+ ) async throws -> T {
2426
+ let startedAt = ProcessInfo.processInfo.systemUptime
2427
+ func elapsedMs() -> Int64 {
2428
+ Int64(((ProcessInfo.processInfo.systemUptime - startedAt) * 1000).rounded(.down))
2429
+ }
2430
+ let result: T
2431
+ do {
2432
+ result = try await block()
2433
+ } catch {
2434
+ let sdkError = error as? BluetoothSdkError
2435
+ let cancelled = error is CancellationError || sdkError?.code == "request_cancelled"
2436
+ if analytics.isEnabled, !cancelled {
2437
+ record(elapsedMs(), sdkError?.code ?? String(describing: type(of: error)), nil)
2438
+ }
2439
+ throw error
2440
+ }
2441
+ if analytics.isEnabled { record(elapsedMs(), nil, result) }
2442
+ return result
2443
+ }
2444
+
2252
2445
  private func dispatchStoreUpdate(_ category: String, _ changes: [String: Any]) {
2253
2446
  switch ObservableStore.normalizeCategory(category) {
2254
2447
  case "glasses":
2255
2448
  if changes["connected"] as? Bool == false {
2256
2449
  resetWifiProtocolSession(sessionId: "", code: "wifi_session_disconnected")
2257
2450
  }
2258
- analytics.observeGlassesStatus(glassesStatus)
2451
+ // CBCentralManager state is read here on the main actor, not on the analytics queue.
2452
+ let phoneBluetoothOff = BluetoothAvailability.shared.isPoweredOff
2453
+ analytics.observeGlassesStatus(glassesStatus) { phoneBluetoothOff }
2259
2454
  let nextState = state
2260
2455
  delegate?.mentraBluetoothSDK(self, didUpdate: nextState)
2261
2456
  delegate?.mentraBluetoothSDK(self, didUpdateGlasses: nextState.glasses)
2262
2457
  case ObservableStore.bluetoothCategory:
2458
+ if analytics.isEnabled, changes.keys.contains(where: { Self.micStateKeys.contains($0) }) {
2459
+ let bluetooth = bluetoothStatus
2460
+ let transcribing = bluetooth.shouldSendTranscript || bluetooth.localSttFallbackActive
2461
+ let now = BluetoothSdkAnalytics.epochMillis()
2462
+ analytics.recordUsage { $0.observeMic(currentMic: bluetooth.currentMic, transcribing: transcribing, nowMs: now) }
2463
+ }
2263
2464
  let nextState = state
2264
2465
  delegate?.mentraBluetoothSDK(self, didUpdate: nextState)
2265
2466
  delegate?.mentraBluetoothSDK(self, didUpdateSdkState: nextState.sdk)
@@ -2361,17 +2562,48 @@ public final class MentraBluetoothSDK {
2361
2562
 
2362
2563
  private func handleVersionInfoForRequest(_ data: [String: Any]) {
2363
2564
  guard let request = pendingVersionInfo else { return }
2364
- switch request.accumulator.accept(data) {
2565
+ let outcome = request.accumulator.accept(data)
2566
+ var response: [String: Any] = [
2567
+ "responseRequestId": String((data[VersionInfoResponseAccumulator.responseRequestIdKey] as? String ?? "").prefix(128)),
2568
+ "responseChunk": String((data[VersionInfoResponseAccumulator.responseChunkKey] as? String ?? "").prefix(64)),
2569
+ ]
2570
+ for key in [VersionInfoResponseAccumulator.responseIndexKey,
2571
+ VersionInfoResponseAccumulator.responseCountKey,
2572
+ VersionInfoResponseAccumulator.responseFinalKey]
2573
+ {
2574
+ // Typed strings survive the logger's Bool/NSNumber sanitization and
2575
+ // preserve malformed metadata rather than making it look valid.
2576
+ if let value = data[key] as? NSNumber {
2577
+ response[key] = CFGetTypeID(value) == CFBooleanGetTypeID()
2578
+ ? "boolean:\(value.boolValue)" : "number:\(value.stringValue)"
2579
+ } else if let value = data[key] as? String {
2580
+ response[key] = "string:\(value.prefix(64))"
2581
+ } else if let value = data[key] {
2582
+ response[key] = "unsupported:\(type(of: value))"
2583
+ }
2584
+ }
2585
+ switch outcome {
2365
2586
  case .ignored:
2366
- break
2587
+ logVersionInfoRequest(request, stage: "response-ignored", extra: response)
2367
2588
  case .waiting:
2368
- break
2589
+ logVersionInfoRequest(request, stage: "response-waiting", extra: response)
2369
2590
  case let .complete(result):
2591
+ logVersionInfoRequest(request, stage: "response-complete", extra: response)
2370
2592
  pendingVersionInfo = nil
2371
2593
  request.pending.resolve(result)
2372
2594
  }
2373
2595
  }
2374
2596
 
2597
+ private func logVersionInfoRequest(_ request: PendingVersionInfoRequest, stage: String, extra: [String: Any] = [:]) {
2598
+ var payload: [String: Any] = [
2599
+ "requestId": request.requestId,
2600
+ "stage": stage,
2601
+ "elapsedMs": Int((ProcessInfo.processInfo.systemUptime - request.startedAt) * 1000),
2602
+ ]
2603
+ payload.merge(extra) { _, value in value }
2604
+ BleTraceLogger.logMap(direction: "phone_app", layer: "sdk_version_request", type: "version_info", payload: payload)
2605
+ }
2606
+
2375
2607
  private func dispatchBridgeEvent(_ eventName: String, _ data: [String: Any]) {
2376
2608
  switch eventName {
2377
2609
  case "log":
@@ -2469,6 +2701,11 @@ public final class MentraBluetoothSDK {
2469
2701
  case "photo_response":
2470
2702
  let event = PhotoResponseEvent(values: data)
2471
2703
  handlePhotoResponseForRequests(event)
2704
+ if case let .error(requestId, errorCode, _, _) = event.response,
2705
+ requestId == BluetoothSdkUsageTracker.localPhotoRequestId
2706
+ {
2707
+ analytics.recordUsage { $0.buttonPhotoFailed(errorCode: errorCode) }
2708
+ }
2472
2709
  delegate?.mentraBluetoothSDK(self, didReceive: .photoResponse(event))
2473
2710
  case "photo_status":
2474
2711
  delegate?.mentraBluetoothSDK(self, didReceive: .photoStatus(PhotoStatusEvent(values: data)))
@@ -2479,6 +2716,10 @@ public final class MentraBluetoothSDK {
2479
2716
  case "video_recording_status":
2480
2717
  let event = VideoRecordingStatusEvent(values: data)
2481
2718
  handleVideoRecordingStatusForRequests(event)
2719
+ let now = BluetoothSdkAnalytics.epochMillis()
2720
+ analytics.recordUsage {
2721
+ $0.videoStatus(requestId: event.requestId, status: event.status, success: event.success, nowMs: now)
2722
+ }
2482
2723
  delegate?.mentraBluetoothSDK(self, didReceive: .videoRecordingStatus(event))
2483
2724
  case "media_success", "media_error":
2484
2725
  let event = MediaUploadEvent(values: data)
@@ -2494,6 +2735,7 @@ public final class MentraBluetoothSDK {
2494
2735
  if data["revision"] != nil && !streamSession.accept(data) { return }
2495
2736
  let event = StreamStatusEvent(values: data)
2496
2737
  handleStreamStatusForRequests(event)
2738
+ recordStreamStatus(event)
2497
2739
  delegate?.mentraBluetoothSDK(self, didReceive: .streamStatus(event))
2498
2740
  case "keep_alive_ack":
2499
2741
  let event = KeepAliveAckEvent(values: data)
@@ -2514,6 +2756,23 @@ public final class MentraBluetoothSDK {
2514
2756
  BluetoothSdkError(code: errorCode, message: "Glasses rejected OTA start: \(errorCode)")
2515
2757
  )
2516
2758
  }
2759
+ if analytics.isEnabled {
2760
+ let glasses = glassesStatus
2761
+ let now = BluetoothSdkAnalytics.epochMillis()
2762
+ analytics.recordUsage {
2763
+ $0.otaStatus(
2764
+ sessionId: event.sessionId,
2765
+ status: event.status,
2766
+ stepType: event.stepType,
2767
+ totalSteps: event.totalSteps,
2768
+ errorMessage: event.errorMessage,
2769
+ fromBuildNumber: glasses.buildNumber,
2770
+ fromBesFirmwareVersion: glasses.besFirmwareVersion,
2771
+ fromMtkFirmwareVersion: glasses.mtkFirmwareVersion,
2772
+ nowMs: now
2773
+ )
2774
+ }
2775
+ }
2517
2776
  delegate?.mentraBluetoothSDK(self, didReceive: .otaStatus(event))
2518
2777
  case "settings_ack":
2519
2778
  let event = SettingsAckEvent(values: data)