@mentra/bluetooth-sdk 3.3.0-dev.822 → 3.3.0-dev.826

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 (42) hide show
  1. package/README.md +68 -6
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalytics.kt +63 -14
  3. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTracker.kt +55 -1
  4. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkInstallId.kt +23 -0
  5. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +1 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkUpdateCheck.kt +184 -0
  7. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkUsageTracker.kt +388 -0
  8. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  9. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +223 -36
  10. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTrackerTest.kt +58 -3
  11. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkUpdateCheckTest.kt +124 -0
  12. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkUsageTrackerTest.kt +263 -0
  13. package/build/BluetoothSdk.types.d.ts +3 -0
  14. package/build/BluetoothSdk.types.d.ts.map +1 -1
  15. package/build/BluetoothSdk.types.js.map +1 -1
  16. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  17. package/build/_private/BluetoothSdkModule.js +2 -1
  18. package/build/_private/BluetoothSdkModule.js.map +1 -1
  19. package/build/_private/nativeLogConsole.d.ts +19 -0
  20. package/build/_private/nativeLogConsole.d.ts.map +1 -1
  21. package/build/_private/nativeLogConsole.js +12 -0
  22. package/build/_private/nativeLogConsole.js.map +1 -1
  23. package/build/generated/releaseMetadata.js +5 -5
  24. package/build/generated/releaseMetadata.js.map +1 -1
  25. package/ios/BluetoothSdkModule.swift +1 -0
  26. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  27. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  28. package/ios/Source/MentraBluetoothSDK.swift +247 -48
  29. package/ios/Source/internal/BluetoothAvailability.swift +6 -0
  30. package/ios/Source/internal/BluetoothSdkAnalytics.swift +87 -13
  31. package/ios/Source/internal/BluetoothSdkAnalyticsTracker.swift +54 -1
  32. package/ios/Source/internal/BluetoothSdkInstallId.swift +21 -0
  33. package/ios/Source/internal/BluetoothSdkUpdateCheck.swift +223 -0
  34. package/ios/Source/internal/BluetoothSdkUsageTracker.swift +428 -0
  35. package/ios/Tests/BluetoothSdkAnalyticsTrackerTests.swift +54 -3
  36. package/ios/Tests/BluetoothSdkUpdateCheckTests.swift +153 -0
  37. package/ios/Tests/BluetoothSdkUsageTrackerTests.swift +296 -0
  38. package/package.json +2 -2
  39. package/src/BluetoothSdk.types.ts +1 -0
  40. package/src/_private/BluetoothSdkModule.ts +2 -1
  41. package/src/_private/nativeLogConsole.ts +19 -0
  42. package/src/generated/releaseMetadata.ts +5 -5
@@ -166,13 +166,18 @@ private final class PendingStreamStart {
166
166
  @MainActor
167
167
  final class PendingResponse<T> {
168
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)?
169
172
  private var continuation: CheckedContinuation<T, Error>?
170
173
  private var timeoutTask: Task<Void, Never>?
171
174
  private var result: Result<T, Error>?
172
175
  private var completed = false
173
176
 
174
- init(operation: String) {
177
+ init(operation: String, telemetryOperation: String? = nil, onTimeout: ((String) -> Void)? = nil) {
175
178
  self.operation = operation
179
+ self.telemetryOperation = telemetryOperation ?? operation
180
+ self.onTimeout = onTimeout
176
181
  }
177
182
 
178
183
  func resolve(_ value: T) {
@@ -210,10 +215,12 @@ final class PendingResponse<T> {
210
215
  } catch {
211
216
  return
212
217
  }
213
- self?.reject(
218
+ guard let self, !self.completed else { return }
219
+ self.onTimeout?(self.telemetryOperation)
220
+ self.reject(
214
221
  BluetoothSdkError(
215
222
  code: "request_timeout",
216
- message: "\(self?.operation ?? "Request") timed out waiting for glasses response."
223
+ message: "\(self.operation) timed out waiting for glasses response."
217
224
  )
218
225
  )
219
226
  }
@@ -257,6 +264,7 @@ public final class MentraBluetoothSDK {
257
264
  private static let otaBesVersionWaitMs = 5000
258
265
  private static let otaMtkVersionWaitMs = 2000
259
266
  private static let otaVersionPollMs = 100
267
+ private static let micStateKeys: Set<String> = ["currentMic", "should_send_transcript", "local_stt_fallback_active"]
260
268
 
261
269
  public weak var delegate: MentraBluetoothSDKDelegate?
262
270
 
@@ -275,6 +283,7 @@ public final class MentraBluetoothSDK {
275
283
  private var activeScanSessions: [UUID: ActiveScanSession] = [:]
276
284
  private let streamSession = StreamSessionState()
277
285
  private let analytics: BluetoothSdkAnalytics
286
+ private var updateCheck: BluetoothSdkUpdateCheck?
278
287
  private var pendingPhotoRequests: [String: PendingResponse<PhotoResponseEvent>] = [:]
279
288
  private var pendingCameraStatusRequests: [String: PendingResponse<CameraStatusEvent>] = [:]
280
289
  private var pendingVideoRecordingRequests: [String: PendingVideoRecordingRequest] = [:]
@@ -353,6 +362,31 @@ public final class MentraBluetoothSDK {
353
362
  }
354
363
  analytics.initializeGlassesStatus(glassesStatus)
355
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
+ }
356
390
  }
357
391
 
358
392
  public var state: MentraBluetoothState {
@@ -548,6 +582,7 @@ public final class MentraBluetoothSDK {
548
582
  }
549
583
 
550
584
  public func cancelConnectionAttempt() {
585
+ analytics.noteDisconnectRequested(BluetoothSdkAnalyticsTracker.disconnectCancel)
551
586
  clearBluetoothRestoreIntent()
552
587
  DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "pending_device_name", "")
553
588
  DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "pending_device_address", "")
@@ -561,6 +596,7 @@ public final class MentraBluetoothSDK {
561
596
  }
562
597
 
563
598
  public func disconnect() {
599
+ analytics.noteDisconnectRequested(BluetoothSdkAnalyticsTracker.disconnectUser)
564
600
  clearBluetoothRestoreIntent()
565
601
  DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "pending_device_name", "")
566
602
  DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "pending_device_address", "")
@@ -569,6 +605,7 @@ public final class MentraBluetoothSDK {
569
605
  }
570
606
 
571
607
  public func forget() {
608
+ analytics.noteDisconnectRequested(BluetoothSdkAnalyticsTracker.disconnectForget)
572
609
  clearBluetoothRestoreIntent()
573
610
  DeviceManager.shared.forget()
574
611
  }
@@ -681,7 +718,7 @@ public final class MentraBluetoothSDK {
681
718
  send: (String) throws -> Void
682
719
  ) async throws -> SettingsAckEvent {
683
720
  let requestId = "settings-\(setting)-\(UUID().uuidString)"
684
- let pending = PendingResponse<SettingsAckEvent>(operation: "set \(setting)")
721
+ let pending = PendingResponse<SettingsAckEvent>(operation: "set \(setting)", onTimeout: requestTimeoutReporter)
685
722
  pendingSettingsRequests[requestId] = pending
686
723
  do {
687
724
  try send(requestId)
@@ -954,7 +991,7 @@ public final class MentraBluetoothSDK {
954
991
  // the scan screen auto-starts a scan on mount and can be pushed twice.
955
992
  return try await existing.value
956
993
  }
957
- let pending = PendingResponse<[WifiScanResult]>(operation: "WiFi scan request")
994
+ let pending = PendingResponse<[WifiScanResult]>(operation: "WiFi scan request", onTimeout: requestTimeoutReporter)
958
995
  let request = PendingWifiScan(pending: pending, scanId: "scan-\(UUID().uuidString)")
959
996
  pendingWifiScan = request
960
997
  // Claim the scan-results store for this scan before the command goes out,
@@ -994,11 +1031,15 @@ public final class MentraBluetoothSDK {
994
1031
  message: "A WiFi status command is already waiting for a glasses response."
995
1032
  )
996
1033
  }
997
- let pending = PendingResponse<WifiStatusEvent>(operation: "WiFi connect request")
1034
+ let pending = PendingResponse<WifiStatusEvent>(operation: "WiFi connect request", onTimeout: requestTimeoutReporter)
998
1035
  pendingWifiStatus = PendingWifiStatusRequest(ssid: ssid, pending: pending)
999
- DeviceManager.shared.sendWifiCredentials(ssid, password)
1000
1036
  do {
1001
- 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
+ }
1002
1043
  if pendingWifiStatus?.pending === pending {
1003
1044
  pendingWifiStatus = nil
1004
1045
  }
@@ -1021,7 +1062,7 @@ public final class MentraBluetoothSDK {
1021
1062
  message: "A WiFi status command is already waiting for a glasses response."
1022
1063
  )
1023
1064
  }
1024
- let pending = PendingResponse<WifiForgetResult>(operation: "WiFi forget request")
1065
+ let pending = PendingResponse<WifiForgetResult>(operation: "WiFi forget request", onTimeout: requestTimeoutReporter)
1025
1066
  let requestId = "forget-\(UUID().uuidString)"
1026
1067
  let request = PendingWifiForgetRequest(
1027
1068
  ssid: ssid,
@@ -1066,7 +1107,7 @@ public final class MentraBluetoothSDK {
1066
1107
  error: "saved_wifi_networks_unsupported"
1067
1108
  )
1068
1109
  }
1069
- let pending = PendingResponse<SavedWifiNetworksResult>(operation: "Saved WiFi networks request")
1110
+ let pending = PendingResponse<SavedWifiNetworksResult>(operation: "Saved WiFi networks request", onTimeout: requestTimeoutReporter)
1070
1111
  let request = PendingSavedWifiNetworks(
1071
1112
  requestId: requestId,
1072
1113
  sid: wifiSessionCapabilities.sessionId,
@@ -1103,12 +1144,18 @@ public final class MentraBluetoothSDK {
1103
1144
  _ = try? await previous.value
1104
1145
  }
1105
1146
  let pending = PendingResponse<HotspotStatusEvent>(
1106
- operation: "hotspot \(enabled ? "enable" : "disable") request"
1147
+ operation: "hotspot \(enabled ? "enable" : "disable") request",
1148
+ onTimeout: self.requestTimeoutReporter
1107
1149
  )
1108
1150
  self.pendingHotspotStatus = PendingHotspotStatusRequest(enabled: enabled, pending: pending)
1109
- DeviceManager.shared.setHotspotState(enabled)
1110
1151
  do {
1111
- 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
+ }
1112
1159
  if self.pendingHotspotStatus?.pending === pending {
1113
1160
  self.pendingHotspotStatus = nil
1114
1161
  }
@@ -1141,13 +1188,30 @@ public final class MentraBluetoothSDK {
1141
1188
  Bridge.log(
1142
1189
  "NATIVE: PHOTO PIPELINE [3b/6] MentraBluetoothSdk.requestPhoto requestId=\(routedRequest.requestId)"
1143
1190
  )
1144
- let pending = PendingResponse<PhotoResponseEvent>(operation: "photo request \(routedRequest.requestId)")
1191
+ let pending = PendingResponse<PhotoResponseEvent>(operation: "photo request \(routedRequest.requestId)", telemetryOperation: "photo request", onTimeout: requestTimeoutReporter)
1145
1192
  pendingPhotoRequests[routedRequest.requestId] = pending
1146
- DeviceManager.shared.requestPhoto(routedRequest)
1147
1193
  do {
1148
- let event = try await pending.wait(timeoutMs: routedRequest.presendThumbnail
1149
- ? MentraBluetoothSDK.photoThumbnailRequestTimeoutMs
1150
- : 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
+ }
1151
1215
  pendingPhotoRequests.removeValue(forKey: routedRequest.requestId)
1152
1216
  return event
1153
1217
  } catch {
@@ -1166,7 +1230,7 @@ public final class MentraBluetoothSDK {
1166
1230
  mfnr: Bool? = nil
1167
1231
  ) async throws -> CameraStatusEvent {
1168
1232
  let effectiveRequestId = nonBlankRequestId(requestId) ?? generatedCameraRequestId("warm")
1169
- 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)
1170
1234
  pendingCameraStatusRequests[effectiveRequestId] = pending
1171
1235
  do {
1172
1236
  // Inside the catch so an unsupported-device throw also clears the pending entry.
@@ -1204,7 +1268,7 @@ public final class MentraBluetoothSDK {
1204
1268
  message: "A gallery status query is already waiting for a glasses response."
1205
1269
  )
1206
1270
  }
1207
- let pending = PendingResponse<GalleryStatusEvent>(operation: "gallery status query")
1271
+ let pending = PendingResponse<GalleryStatusEvent>(operation: "gallery status query", onTimeout: requestTimeoutReporter)
1208
1272
  pendingGalleryStatus = pending
1209
1273
  DeviceManager.shared.queryGalleryStatus()
1210
1274
  do {
@@ -1222,33 +1286,53 @@ public final class MentraBluetoothSDK {
1222
1286
  }
1223
1287
 
1224
1288
  public func startStream(_ request: StreamRequest) async throws -> StreamStatusEvent {
1225
- try requireGlassesConnected(operation: "start stream")
1226
- guard streamSession.supported else {
1227
- throw BluetoothSdkError(code: "stream_control_unsupported", message: "Update the glasses software before starting a stream.")
1228
- }
1229
1289
  var values = request.values
1230
1290
  let streamId = stringValue(values, "streamId").flatMap { $0.isEmpty ? nil : $0 } ?? "sdk-\(UUID().uuidString)"
1231
1291
  values["streamId"] = streamId
1232
- let pending = PendingResponse<StreamStatusEvent>(operation: "start stream \(streamId)")
1233
- // seq assignment through the BLE hand-off must stay await-free so this
1234
- // MainActor turn is one critical section: rejectPreemptedStreamStarts
1235
- // only works if seq order equals the order the commands reach the
1236
- // glasses' FIFO.
1237
- streamStartSeq += 1
1238
- pendingStreamStarts[streamId] = PendingStreamStart(seq: streamStartSeq, pending: pending)
1239
- DeviceManager.shared.startStream(values)
1240
- do {
1241
- let event = try await pending.wait(timeoutMs: 30000)
1242
- pendingStreamStarts.removeValue(forKey: streamId)
1243
- return event
1244
- } catch {
1245
- pendingStreamStarts.removeValue(forKey: streamId)
1246
- 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
+ }
1247
1331
  }
1248
1332
  }
1249
1333
 
1250
1334
  public func rgbLedControl(_ request: RgbLedRequest) async throws -> RgbLedControlResponseEvent {
1251
- 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)
1252
1336
  pendingRgbLedRequests[request.requestId] = pending
1253
1337
  DeviceManager.shared.rgbLedControl(
1254
1338
  requestId: request.requestId,
@@ -1270,13 +1354,14 @@ public final class MentraBluetoothSDK {
1270
1354
  }
1271
1355
 
1272
1356
  public func stopStream() async throws -> StreamStatusEvent {
1357
+ analytics.updateUsage { $0.streamStopRequested() }
1273
1358
  guard pendingStreamStop == nil else {
1274
1359
  throw BluetoothSdkError(
1275
1360
  code: "request_in_flight",
1276
1361
  message: "A stream stop command is already waiting for a glasses response."
1277
1362
  )
1278
1363
  }
1279
- let pending = PendingResponse<StreamStatusEvent>(operation: "stop stream")
1364
+ let pending = PendingResponse<StreamStatusEvent>(operation: "stop stream", onTimeout: requestTimeoutReporter)
1280
1365
  // The seq draw and the BLE hand-off stay in this single await-free
1281
1366
  // MainActor turn, sharing startStream's ordering critical section:
1282
1367
  // the stop takes its own slot in the send order, so its ack can
@@ -1305,7 +1390,9 @@ public final class MentraBluetoothSDK {
1305
1390
  }
1306
1391
  try requireGlassesConnected(operation: "start video recording")
1307
1392
  let pending = PendingResponse<VideoRecordingStatusEvent>(
1308
- operation: "start video recording \(request.requestId)"
1393
+ operation: "start video recording \(request.requestId)",
1394
+ telemetryOperation: "start video recording",
1395
+ onTimeout: requestTimeoutReporter
1309
1396
  )
1310
1397
  guard pendingVideoRecordingRequests[request.requestId] == nil else {
1311
1398
  throw BluetoothSdkError(
@@ -1317,6 +1404,16 @@ public final class MentraBluetoothSDK {
1317
1404
  expectedStatus: "recording_started",
1318
1405
  pending: pending
1319
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
+ }
1320
1417
  DeviceManager.shared.startVideoRecording(
1321
1418
  request.requestId,
1322
1419
  request.save,
@@ -1343,7 +1440,7 @@ public final class MentraBluetoothSDK {
1343
1440
  throw BluetoothSdkError(code: "missing_request_id", message: "requestId is required to stop video recording.")
1344
1441
  }
1345
1442
  try requireGlassesConnected(operation: "stop video recording")
1346
- 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)
1347
1444
  guard pendingVideoRecordingRequests[requestId] == nil else {
1348
1445
  throw BluetoothSdkError(
1349
1446
  code: "request_in_flight",
@@ -1356,6 +1453,7 @@ public final class MentraBluetoothSDK {
1356
1453
  pending: pending,
1357
1454
  waitForUpload: waitForUpload
1358
1455
  )
1456
+ analytics.updateUsage { $0.videoStopRequested(requestId: requestId) }
1359
1457
  DeviceManager.shared.stopVideoRecording(requestId, webhookUrl, authToken)
1360
1458
  do {
1361
1459
  let timeoutMs = waitForUpload ? videoUploadStopTimeoutMs : 15000
@@ -1376,7 +1474,7 @@ public final class MentraBluetoothSDK {
1376
1474
  )
1377
1475
  }
1378
1476
  try requireGlassesConnected(operation: "query video recording status")
1379
- 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)
1380
1478
  guard pendingVideoRecordingRequests[requestId] == nil else {
1381
1479
  throw BluetoothSdkError(
1382
1480
  code: "request_in_flight",
@@ -1407,7 +1505,7 @@ public final class MentraBluetoothSDK {
1407
1505
  )
1408
1506
  }
1409
1507
  let requestId = UUID().uuidString
1410
- let pending = PendingResponse<VersionInfoResult>(operation: "version info request")
1508
+ let pending = PendingResponse<VersionInfoResult>(operation: "version info request", onTimeout: requestTimeoutReporter)
1411
1509
  let request = PendingVersionInfoRequest(pending: pending, requestId: requestId)
1412
1510
  pendingVersionInfo = request
1413
1511
  logVersionInfoRequest(request, stage: "registered")
@@ -1505,7 +1603,7 @@ public final class MentraBluetoothSDK {
1505
1603
  message: "An OTA status query is already waiting for a glasses response."
1506
1604
  )
1507
1605
  }
1508
- let pending = PendingResponse<OtaQueryResult>(operation: operation)
1606
+ let pending = PendingResponse<OtaQueryResult>(operation: operation, onTimeout: requestTimeoutReporter)
1509
1607
  pendingOtaQuery = pending
1510
1608
  sendRequest()
1511
1609
  do {
@@ -1536,7 +1634,7 @@ public final class MentraBluetoothSDK {
1536
1634
  message: "An OTA start command is already waiting for a glasses response."
1537
1635
  )
1538
1636
  }
1539
- let pending = PendingResponse<OtaStartAckEvent>(operation: "OTA start command")
1637
+ let pending = PendingResponse<OtaStartAckEvent>(operation: "OTA start command", onTimeout: requestTimeoutReporter)
1540
1638
  pendingOtaStart = pending
1541
1639
  DeviceManager.shared.sendOtaStart(otaVersionUrl: otaVersionUrl)
1542
1640
  do {
@@ -1685,6 +1783,7 @@ public final class MentraBluetoothSDK {
1685
1783
  DeviceStore.shared.store.removeListener(storeListenerId)
1686
1784
  self.storeListenerId = nil
1687
1785
  }
1786
+ updateCheck?.stop()
1688
1787
  delegate = nil
1689
1788
  }
1690
1789
 
@@ -2278,17 +2377,90 @@ public final class MentraBluetoothSDK {
2278
2377
  )
2279
2378
  }
2280
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
+
2281
2445
  private func dispatchStoreUpdate(_ category: String, _ changes: [String: Any]) {
2282
2446
  switch ObservableStore.normalizeCategory(category) {
2283
2447
  case "glasses":
2284
2448
  if changes["connected"] as? Bool == false {
2285
2449
  resetWifiProtocolSession(sessionId: "", code: "wifi_session_disconnected")
2286
2450
  }
2287
- 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 }
2288
2454
  let nextState = state
2289
2455
  delegate?.mentraBluetoothSDK(self, didUpdate: nextState)
2290
2456
  delegate?.mentraBluetoothSDK(self, didUpdateGlasses: nextState.glasses)
2291
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
+ }
2292
2464
  let nextState = state
2293
2465
  delegate?.mentraBluetoothSDK(self, didUpdate: nextState)
2294
2466
  delegate?.mentraBluetoothSDK(self, didUpdateSdkState: nextState.sdk)
@@ -2529,6 +2701,11 @@ public final class MentraBluetoothSDK {
2529
2701
  case "photo_response":
2530
2702
  let event = PhotoResponseEvent(values: data)
2531
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
+ }
2532
2709
  delegate?.mentraBluetoothSDK(self, didReceive: .photoResponse(event))
2533
2710
  case "photo_status":
2534
2711
  delegate?.mentraBluetoothSDK(self, didReceive: .photoStatus(PhotoStatusEvent(values: data)))
@@ -2539,6 +2716,10 @@ public final class MentraBluetoothSDK {
2539
2716
  case "video_recording_status":
2540
2717
  let event = VideoRecordingStatusEvent(values: data)
2541
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
+ }
2542
2723
  delegate?.mentraBluetoothSDK(self, didReceive: .videoRecordingStatus(event))
2543
2724
  case "media_success", "media_error":
2544
2725
  let event = MediaUploadEvent(values: data)
@@ -2554,6 +2735,7 @@ public final class MentraBluetoothSDK {
2554
2735
  if data["revision"] != nil && !streamSession.accept(data) { return }
2555
2736
  let event = StreamStatusEvent(values: data)
2556
2737
  handleStreamStatusForRequests(event)
2738
+ recordStreamStatus(event)
2557
2739
  delegate?.mentraBluetoothSDK(self, didReceive: .streamStatus(event))
2558
2740
  case "keep_alive_ack":
2559
2741
  let event = KeepAliveAckEvent(values: data)
@@ -2574,6 +2756,23 @@ public final class MentraBluetoothSDK {
2574
2756
  BluetoothSdkError(code: errorCode, message: "Glasses rejected OTA start: \(errorCode)")
2575
2757
  )
2576
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
+ }
2577
2776
  delegate?.mentraBluetoothSDK(self, didReceive: .otaStatus(event))
2578
2777
  case "settings_ack":
2579
2778
  let event = SettingsAckEvent(values: data)
@@ -41,6 +41,12 @@ final class BluetoothAvailability: NSObject, CBCentralManagerDelegate {
41
41
  listeners[id] = nil
42
42
  }
43
43
 
44
+ /// Whether phone Bluetooth is switched off right now. Read on the main thread,
45
+ /// where the central manager delivers its state.
46
+ var isPoweredOff: Bool {
47
+ (centralManager?.state ?? state) == .poweredOff
48
+ }
49
+
44
50
  /// Read-only system lookup. It neither connects nor claims ownership.
45
51
  func connectedPeripherals(withServices services: [CBUUID]) -> [CBPeripheral] {
46
52
  dispatchPrecondition(condition: .onQueue(.main))