@mentra/bluetooth-sdk 3.2.1 → 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 (95) hide show
  1. package/README.md +68 -6
  2. package/android/build.gradle +15 -0
  3. package/android/lc3Lib/build.gradle +6 -1
  4. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalytics.kt +63 -14
  5. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTracker.kt +55 -1
  6. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +1 -1
  7. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkInstallId.kt +23 -0
  8. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +2 -2
  9. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkUpdateCheck.kt +184 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkUsageTracker.kt +388 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +4 -1
  12. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +2 -1
  13. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +6 -6
  14. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +223 -36
  15. package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +24 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/NetworkDiagnostics.kt +25 -0
  17. package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleTraceLogger.kt +3 -0
  18. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioConnectionTracker.kt +4 -0
  19. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioServiceDiscovery.kt +70 -0
  20. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizer.kt +11 -32
  21. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/LatinTextSanitizer.kt +39 -0
  22. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +93 -18
  23. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodec.kt +3 -1
  24. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTrackerTest.kt +58 -3
  25. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkUpdateCheckTest.kt +124 -0
  26. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkUsageTrackerTest.kt +263 -0
  27. package/android/src/test/java/com/mentra/bluetoothsdk/NetworkDiagnosticsTest.kt +55 -0
  28. package/android/src/test/java/com/mentra/bluetoothsdk/ScanCompletionTest.kt +5 -1
  29. package/android/src/test/java/com/mentra/bluetoothsdk/debug/BleTraceLoggerTest.kt +69 -0
  30. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioServiceDiscoveryTest.kt +124 -0
  31. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizerTest.kt +29 -4
  32. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveClassicDisconnectTest.kt +133 -0
  33. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveIncidentDispatchTest.kt +126 -0
  34. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveWifiCredentialLogTest.kt +55 -0
  35. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodecTest.kt +20 -7
  36. package/build/BluetoothSdk.types.d.ts +3 -0
  37. package/build/BluetoothSdk.types.d.ts.map +1 -1
  38. package/build/BluetoothSdk.types.js.map +1 -1
  39. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  40. package/build/_private/BluetoothSdkModule.js +2 -1
  41. package/build/_private/BluetoothSdkModule.js.map +1 -1
  42. package/build/_private/MentraLocalNetworkModule.d.ts +23 -0
  43. package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -1
  44. package/build/_private/MentraLocalNetworkModule.js.map +1 -1
  45. package/build/_private/nativeLogConsole.d.ts +19 -0
  46. package/build/_private/nativeLogConsole.d.ts.map +1 -1
  47. package/build/_private/nativeLogConsole.js +12 -0
  48. package/build/_private/nativeLogConsole.js.map +1 -1
  49. package/build/generated/changelogCatalog.d.ts +4 -1
  50. package/build/generated/changelogCatalog.d.ts.map +1 -1
  51. package/build/generated/changelogCatalog.js +5 -1
  52. package/build/generated/changelogCatalog.js.map +1 -1
  53. package/build/generated/releaseMetadata.js +6 -6
  54. package/build/generated/releaseMetadata.js.map +1 -1
  55. package/build/ota-transport/index.d.ts +4 -0
  56. package/build/ota-transport/index.d.ts.map +1 -1
  57. package/build/ota-transport/index.js +2 -0
  58. package/build/ota-transport/index.js.map +1 -1
  59. package/ios/BluetoothSdkModule.swift +8 -4
  60. package/ios/Source/BluetoothSdkDefaults.swift +2 -2
  61. package/ios/Source/DeviceManager.swift +6 -3
  62. package/ios/Source/GeneratedChangelogCatalog.swift +2 -1
  63. package/ios/Source/GeneratedReleaseMetadata.swift +6 -6
  64. package/ios/Source/MentraBluetoothSDK.swift +295 -54
  65. package/ios/Source/internal/BluetoothAvailability.swift +6 -0
  66. package/ios/Source/internal/BluetoothSdkAnalytics.swift +87 -13
  67. package/ios/Source/internal/BluetoothSdkAnalyticsTracker.swift +54 -1
  68. package/ios/Source/internal/BluetoothSdkInstallId.swift +21 -0
  69. package/ios/Source/internal/BluetoothSdkUpdateCheck.swift +223 -0
  70. package/ios/Source/internal/BluetoothSdkUsageTracker.swift +428 -0
  71. package/ios/Source/sgcs/MentraLive.swift +71 -41
  72. package/ios/Source/sgcs/NimoCanvasCodec.swift +4 -1
  73. package/ios/Source/sgcs/SGCManager.swift +1 -1
  74. package/ios/Source/utils/AudioSessionMonitor.swift +44 -8
  75. package/ios/Source/utils/G1Text.swift +2 -24
  76. package/ios/Source/utils/G1TextSanitizer.swift +27 -0
  77. package/ios/Source/utils/LatinTextSanitizer.swift +48 -0
  78. package/ios/Tests/BluetoothAudioRouteTests.swift +26 -0
  79. package/ios/Tests/BluetoothSdkAnalyticsTrackerTests.swift +54 -3
  80. package/ios/Tests/BluetoothSdkUpdateCheckTests.swift +153 -0
  81. package/ios/Tests/BluetoothSdkUsageTrackerTests.swift +296 -0
  82. package/ios/Tests/G1TextSanitizerTests.swift +32 -0
  83. package/ios/Tests/IncidentLogDispatchTests.swift +111 -0
  84. package/ios/Tests/MentraLiveSessionReadinessTests.swift +163 -0
  85. package/ios/Tests/NimoCanvasCodecTests.swift +17 -4
  86. package/ios/Tests/VersionInfoCommandTests.swift +142 -1
  87. package/ios/Tests/WifiCredentialLoggingTests.swift +42 -0
  88. package/package.json +2 -2
  89. package/src/BluetoothSdk.types.ts +1 -0
  90. package/src/_private/BluetoothSdkModule.ts +2 -1
  91. package/src/_private/MentraLocalNetworkModule.ts +28 -0
  92. package/src/_private/nativeLogConsole.ts +19 -0
  93. package/src/generated/changelogCatalog.ts +5 -1
  94. package/src/generated/releaseMetadata.ts +6 -6
  95. 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] = [:]
@@ -350,6 +362,31 @@ public final class MentraBluetoothSDK {
350
362
  }
351
363
  analytics.initializeGlassesStatus(glassesStatus)
352
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
+ }
353
390
  }
354
391
 
355
392
  public var state: MentraBluetoothState {
@@ -545,6 +582,7 @@ public final class MentraBluetoothSDK {
545
582
  }
546
583
 
547
584
  public func cancelConnectionAttempt() {
585
+ analytics.noteDisconnectRequested(BluetoothSdkAnalyticsTracker.disconnectCancel)
548
586
  clearBluetoothRestoreIntent()
549
587
  DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "pending_device_name", "")
550
588
  DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "pending_device_address", "")
@@ -558,6 +596,7 @@ public final class MentraBluetoothSDK {
558
596
  }
559
597
 
560
598
  public func disconnect() {
599
+ analytics.noteDisconnectRequested(BluetoothSdkAnalyticsTracker.disconnectUser)
561
600
  clearBluetoothRestoreIntent()
562
601
  DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "pending_device_name", "")
563
602
  DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "pending_device_address", "")
@@ -566,6 +605,7 @@ public final class MentraBluetoothSDK {
566
605
  }
567
606
 
568
607
  public func forget() {
608
+ analytics.noteDisconnectRequested(BluetoothSdkAnalyticsTracker.disconnectForget)
569
609
  clearBluetoothRestoreIntent()
570
610
  DeviceManager.shared.forget()
571
611
  }
@@ -678,7 +718,7 @@ public final class MentraBluetoothSDK {
678
718
  send: (String) throws -> Void
679
719
  ) async throws -> SettingsAckEvent {
680
720
  let requestId = "settings-\(setting)-\(UUID().uuidString)"
681
- let pending = PendingResponse<SettingsAckEvent>(operation: "set \(setting)")
721
+ let pending = PendingResponse<SettingsAckEvent>(operation: "set \(setting)", onTimeout: requestTimeoutReporter)
682
722
  pendingSettingsRequests[requestId] = pending
683
723
  do {
684
724
  try send(requestId)
@@ -951,7 +991,7 @@ public final class MentraBluetoothSDK {
951
991
  // the scan screen auto-starts a scan on mount and can be pushed twice.
952
992
  return try await existing.value
953
993
  }
954
- let pending = PendingResponse<[WifiScanResult]>(operation: "WiFi scan request")
994
+ let pending = PendingResponse<[WifiScanResult]>(operation: "WiFi scan request", onTimeout: requestTimeoutReporter)
955
995
  let request = PendingWifiScan(pending: pending, scanId: "scan-\(UUID().uuidString)")
956
996
  pendingWifiScan = request
957
997
  // Claim the scan-results store for this scan before the command goes out,
@@ -991,11 +1031,15 @@ public final class MentraBluetoothSDK {
991
1031
  message: "A WiFi status command is already waiting for a glasses response."
992
1032
  )
993
1033
  }
994
- let pending = PendingResponse<WifiStatusEvent>(operation: "WiFi connect request")
1034
+ let pending = PendingResponse<WifiStatusEvent>(operation: "WiFi connect request", onTimeout: requestTimeoutReporter)
995
1035
  pendingWifiStatus = PendingWifiStatusRequest(ssid: ssid, pending: pending)
996
- DeviceManager.shared.sendWifiCredentials(ssid, password)
997
1036
  do {
998
- 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
+ }
999
1043
  if pendingWifiStatus?.pending === pending {
1000
1044
  pendingWifiStatus = nil
1001
1045
  }
@@ -1018,7 +1062,7 @@ public final class MentraBluetoothSDK {
1018
1062
  message: "A WiFi status command is already waiting for a glasses response."
1019
1063
  )
1020
1064
  }
1021
- let pending = PendingResponse<WifiForgetResult>(operation: "WiFi forget request")
1065
+ let pending = PendingResponse<WifiForgetResult>(operation: "WiFi forget request", onTimeout: requestTimeoutReporter)
1022
1066
  let requestId = "forget-\(UUID().uuidString)"
1023
1067
  let request = PendingWifiForgetRequest(
1024
1068
  ssid: ssid,
@@ -1063,7 +1107,7 @@ public final class MentraBluetoothSDK {
1063
1107
  error: "saved_wifi_networks_unsupported"
1064
1108
  )
1065
1109
  }
1066
- let pending = PendingResponse<SavedWifiNetworksResult>(operation: "Saved WiFi networks request")
1110
+ let pending = PendingResponse<SavedWifiNetworksResult>(operation: "Saved WiFi networks request", onTimeout: requestTimeoutReporter)
1067
1111
  let request = PendingSavedWifiNetworks(
1068
1112
  requestId: requestId,
1069
1113
  sid: wifiSessionCapabilities.sessionId,
@@ -1100,12 +1144,18 @@ public final class MentraBluetoothSDK {
1100
1144
  _ = try? await previous.value
1101
1145
  }
1102
1146
  let pending = PendingResponse<HotspotStatusEvent>(
1103
- operation: "hotspot \(enabled ? "enable" : "disable") request"
1147
+ operation: "hotspot \(enabled ? "enable" : "disable") request",
1148
+ onTimeout: self.requestTimeoutReporter
1104
1149
  )
1105
1150
  self.pendingHotspotStatus = PendingHotspotStatusRequest(enabled: enabled, pending: pending)
1106
- DeviceManager.shared.setHotspotState(enabled)
1107
1151
  do {
1108
- 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
+ }
1109
1159
  if self.pendingHotspotStatus?.pending === pending {
1110
1160
  self.pendingHotspotStatus = nil
1111
1161
  }
@@ -1138,13 +1188,30 @@ public final class MentraBluetoothSDK {
1138
1188
  Bridge.log(
1139
1189
  "NATIVE: PHOTO PIPELINE [3b/6] MentraBluetoothSdk.requestPhoto requestId=\(routedRequest.requestId)"
1140
1190
  )
1141
- let pending = PendingResponse<PhotoResponseEvent>(operation: "photo request \(routedRequest.requestId)")
1191
+ let pending = PendingResponse<PhotoResponseEvent>(operation: "photo request \(routedRequest.requestId)", telemetryOperation: "photo request", onTimeout: requestTimeoutReporter)
1142
1192
  pendingPhotoRequests[routedRequest.requestId] = pending
1143
- DeviceManager.shared.requestPhoto(routedRequest)
1144
1193
  do {
1145
- let event = try await pending.wait(timeoutMs: routedRequest.presendThumbnail
1146
- ? MentraBluetoothSDK.photoThumbnailRequestTimeoutMs
1147
- : 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
+ }
1148
1215
  pendingPhotoRequests.removeValue(forKey: routedRequest.requestId)
1149
1216
  return event
1150
1217
  } catch {
@@ -1163,7 +1230,7 @@ public final class MentraBluetoothSDK {
1163
1230
  mfnr: Bool? = nil
1164
1231
  ) async throws -> CameraStatusEvent {
1165
1232
  let effectiveRequestId = nonBlankRequestId(requestId) ?? generatedCameraRequestId("warm")
1166
- 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)
1167
1234
  pendingCameraStatusRequests[effectiveRequestId] = pending
1168
1235
  do {
1169
1236
  // Inside the catch so an unsupported-device throw also clears the pending entry.
@@ -1201,7 +1268,7 @@ public final class MentraBluetoothSDK {
1201
1268
  message: "A gallery status query is already waiting for a glasses response."
1202
1269
  )
1203
1270
  }
1204
- let pending = PendingResponse<GalleryStatusEvent>(operation: "gallery status query")
1271
+ let pending = PendingResponse<GalleryStatusEvent>(operation: "gallery status query", onTimeout: requestTimeoutReporter)
1205
1272
  pendingGalleryStatus = pending
1206
1273
  DeviceManager.shared.queryGalleryStatus()
1207
1274
  do {
@@ -1219,33 +1286,53 @@ public final class MentraBluetoothSDK {
1219
1286
  }
1220
1287
 
1221
1288
  public func startStream(_ request: StreamRequest) async throws -> StreamStatusEvent {
1222
- try requireGlassesConnected(operation: "start stream")
1223
- guard streamSession.supported else {
1224
- throw BluetoothSdkError(code: "stream_control_unsupported", message: "Update the glasses software before starting a stream.")
1225
- }
1226
1289
  var values = request.values
1227
1290
  let streamId = stringValue(values, "streamId").flatMap { $0.isEmpty ? nil : $0 } ?? "sdk-\(UUID().uuidString)"
1228
1291
  values["streamId"] = streamId
1229
- let pending = PendingResponse<StreamStatusEvent>(operation: "start stream \(streamId)")
1230
- // seq assignment through the BLE hand-off must stay await-free so this
1231
- // MainActor turn is one critical section: rejectPreemptedStreamStarts
1232
- // only works if seq order equals the order the commands reach the
1233
- // glasses' FIFO.
1234
- streamStartSeq += 1
1235
- pendingStreamStarts[streamId] = PendingStreamStart(seq: streamStartSeq, pending: pending)
1236
- DeviceManager.shared.startStream(values)
1237
- do {
1238
- let event = try await pending.wait(timeoutMs: 30000)
1239
- pendingStreamStarts.removeValue(forKey: streamId)
1240
- return event
1241
- } catch {
1242
- pendingStreamStarts.removeValue(forKey: streamId)
1243
- 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
+ }
1244
1331
  }
1245
1332
  }
1246
1333
 
1247
1334
  public func rgbLedControl(_ request: RgbLedRequest) async throws -> RgbLedControlResponseEvent {
1248
- 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)
1249
1336
  pendingRgbLedRequests[request.requestId] = pending
1250
1337
  DeviceManager.shared.rgbLedControl(
1251
1338
  requestId: request.requestId,
@@ -1267,13 +1354,14 @@ public final class MentraBluetoothSDK {
1267
1354
  }
1268
1355
 
1269
1356
  public func stopStream() async throws -> StreamStatusEvent {
1357
+ analytics.updateUsage { $0.streamStopRequested() }
1270
1358
  guard pendingStreamStop == nil else {
1271
1359
  throw BluetoothSdkError(
1272
1360
  code: "request_in_flight",
1273
1361
  message: "A stream stop command is already waiting for a glasses response."
1274
1362
  )
1275
1363
  }
1276
- let pending = PendingResponse<StreamStatusEvent>(operation: "stop stream")
1364
+ let pending = PendingResponse<StreamStatusEvent>(operation: "stop stream", onTimeout: requestTimeoutReporter)
1277
1365
  // The seq draw and the BLE hand-off stay in this single await-free
1278
1366
  // MainActor turn, sharing startStream's ordering critical section:
1279
1367
  // the stop takes its own slot in the send order, so its ack can
@@ -1302,7 +1390,9 @@ public final class MentraBluetoothSDK {
1302
1390
  }
1303
1391
  try requireGlassesConnected(operation: "start video recording")
1304
1392
  let pending = PendingResponse<VideoRecordingStatusEvent>(
1305
- operation: "start video recording \(request.requestId)"
1393
+ operation: "start video recording \(request.requestId)",
1394
+ telemetryOperation: "start video recording",
1395
+ onTimeout: requestTimeoutReporter
1306
1396
  )
1307
1397
  guard pendingVideoRecordingRequests[request.requestId] == nil else {
1308
1398
  throw BluetoothSdkError(
@@ -1314,6 +1404,16 @@ public final class MentraBluetoothSDK {
1314
1404
  expectedStatus: "recording_started",
1315
1405
  pending: pending
1316
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
+ }
1317
1417
  DeviceManager.shared.startVideoRecording(
1318
1418
  request.requestId,
1319
1419
  request.save,
@@ -1340,7 +1440,7 @@ public final class MentraBluetoothSDK {
1340
1440
  throw BluetoothSdkError(code: "missing_request_id", message: "requestId is required to stop video recording.")
1341
1441
  }
1342
1442
  try requireGlassesConnected(operation: "stop video recording")
1343
- 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)
1344
1444
  guard pendingVideoRecordingRequests[requestId] == nil else {
1345
1445
  throw BluetoothSdkError(
1346
1446
  code: "request_in_flight",
@@ -1353,6 +1453,7 @@ public final class MentraBluetoothSDK {
1353
1453
  pending: pending,
1354
1454
  waitForUpload: waitForUpload
1355
1455
  )
1456
+ analytics.updateUsage { $0.videoStopRequested(requestId: requestId) }
1356
1457
  DeviceManager.shared.stopVideoRecording(requestId, webhookUrl, authToken)
1357
1458
  do {
1358
1459
  let timeoutMs = waitForUpload ? videoUploadStopTimeoutMs : 15000
@@ -1373,7 +1474,7 @@ public final class MentraBluetoothSDK {
1373
1474
  )
1374
1475
  }
1375
1476
  try requireGlassesConnected(operation: "query video recording status")
1376
- 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)
1377
1478
  guard pendingVideoRecordingRequests[requestId] == nil else {
1378
1479
  throw BluetoothSdkError(
1379
1480
  code: "request_in_flight",
@@ -1396,27 +1497,35 @@ public final class MentraBluetoothSDK {
1396
1497
  }
1397
1498
 
1398
1499
  public func requestVersionInfo() async throws -> VersionInfoResult {
1399
- guard pendingVersionInfo == nil else {
1500
+ if let request = pendingVersionInfo {
1501
+ logVersionInfoRequest(request, stage: "refused", extra: ["code": "request_in_flight"])
1400
1502
  throw BluetoothSdkError(
1401
1503
  code: "request_in_flight",
1402
1504
  message: "A version info request is already waiting for a glasses response."
1403
1505
  )
1404
1506
  }
1405
1507
  let requestId = UUID().uuidString
1406
- let pending = PendingResponse<VersionInfoResult>(operation: "version info request")
1508
+ let pending = PendingResponse<VersionInfoResult>(operation: "version info request", onTimeout: requestTimeoutReporter)
1407
1509
  let request = PendingVersionInfoRequest(pending: pending, requestId: requestId)
1408
1510
  pendingVersionInfo = request
1511
+ logVersionInfoRequest(request, stage: "registered")
1409
1512
  DeviceManager.shared.requestVersionInfo(requestId: requestId)
1410
1513
  do {
1411
1514
  let status = try await pending.wait()
1412
1515
  if pendingVersionInfo === request {
1413
1516
  pendingVersionInfo = nil
1414
1517
  }
1518
+ logVersionInfoRequest(request, stage: "resolved")
1415
1519
  return status
1416
1520
  } catch {
1417
1521
  if pendingVersionInfo === request {
1418
1522
  pendingVersionInfo = nil
1419
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
+ ])
1420
1529
  throw error
1421
1530
  }
1422
1531
  }
@@ -1494,7 +1603,7 @@ public final class MentraBluetoothSDK {
1494
1603
  message: "An OTA status query is already waiting for a glasses response."
1495
1604
  )
1496
1605
  }
1497
- let pending = PendingResponse<OtaQueryResult>(operation: operation)
1606
+ let pending = PendingResponse<OtaQueryResult>(operation: operation, onTimeout: requestTimeoutReporter)
1498
1607
  pendingOtaQuery = pending
1499
1608
  sendRequest()
1500
1609
  do {
@@ -1525,7 +1634,7 @@ public final class MentraBluetoothSDK {
1525
1634
  message: "An OTA start command is already waiting for a glasses response."
1526
1635
  )
1527
1636
  }
1528
- let pending = PendingResponse<OtaStartAckEvent>(operation: "OTA start command")
1637
+ let pending = PendingResponse<OtaStartAckEvent>(operation: "OTA start command", onTimeout: requestTimeoutReporter)
1529
1638
  pendingOtaStart = pending
1530
1639
  DeviceManager.shared.sendOtaStart(otaVersionUrl: otaVersionUrl)
1531
1640
  do {
@@ -1656,8 +1765,8 @@ public final class MentraBluetoothSDK {
1656
1765
  DeviceManager.shared.sendReboot()
1657
1766
  }
1658
1767
 
1659
- func sendIncidentId(_ incidentId: String, apiBaseUrl: String? = nil) {
1660
- DeviceManager.shared.sendIncidentId(incidentId, apiBaseUrl: apiBaseUrl)
1768
+ func sendIncidentId(_ incidentId: String, apiBaseUrl: String? = nil) throws {
1769
+ try DeviceManager.shared.sendIncidentId(incidentId, apiBaseUrl: apiBaseUrl)
1661
1770
  }
1662
1771
 
1663
1772
  public func invalidate() {
@@ -1674,6 +1783,7 @@ public final class MentraBluetoothSDK {
1674
1783
  DeviceStore.shared.store.removeListener(storeListenerId)
1675
1784
  self.storeListenerId = nil
1676
1785
  }
1786
+ updateCheck?.stop()
1677
1787
  delegate = nil
1678
1788
  }
1679
1789
 
@@ -2267,17 +2377,90 @@ public final class MentraBluetoothSDK {
2267
2377
  )
2268
2378
  }
2269
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
+
2270
2445
  private func dispatchStoreUpdate(_ category: String, _ changes: [String: Any]) {
2271
2446
  switch ObservableStore.normalizeCategory(category) {
2272
2447
  case "glasses":
2273
2448
  if changes["connected"] as? Bool == false {
2274
2449
  resetWifiProtocolSession(sessionId: "", code: "wifi_session_disconnected")
2275
2450
  }
2276
- 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 }
2277
2454
  let nextState = state
2278
2455
  delegate?.mentraBluetoothSDK(self, didUpdate: nextState)
2279
2456
  delegate?.mentraBluetoothSDK(self, didUpdateGlasses: nextState.glasses)
2280
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
+ }
2281
2464
  let nextState = state
2282
2465
  delegate?.mentraBluetoothSDK(self, didUpdate: nextState)
2283
2466
  delegate?.mentraBluetoothSDK(self, didUpdateSdkState: nextState.sdk)
@@ -2379,17 +2562,48 @@ public final class MentraBluetoothSDK {
2379
2562
 
2380
2563
  private func handleVersionInfoForRequest(_ data: [String: Any]) {
2381
2564
  guard let request = pendingVersionInfo else { return }
2382
- 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 {
2383
2586
  case .ignored:
2384
- break
2587
+ logVersionInfoRequest(request, stage: "response-ignored", extra: response)
2385
2588
  case .waiting:
2386
- break
2589
+ logVersionInfoRequest(request, stage: "response-waiting", extra: response)
2387
2590
  case let .complete(result):
2591
+ logVersionInfoRequest(request, stage: "response-complete", extra: response)
2388
2592
  pendingVersionInfo = nil
2389
2593
  request.pending.resolve(result)
2390
2594
  }
2391
2595
  }
2392
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
+
2393
2607
  private func dispatchBridgeEvent(_ eventName: String, _ data: [String: Any]) {
2394
2608
  switch eventName {
2395
2609
  case "log":
@@ -2487,6 +2701,11 @@ public final class MentraBluetoothSDK {
2487
2701
  case "photo_response":
2488
2702
  let event = PhotoResponseEvent(values: data)
2489
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
+ }
2490
2709
  delegate?.mentraBluetoothSDK(self, didReceive: .photoResponse(event))
2491
2710
  case "photo_status":
2492
2711
  delegate?.mentraBluetoothSDK(self, didReceive: .photoStatus(PhotoStatusEvent(values: data)))
@@ -2497,6 +2716,10 @@ public final class MentraBluetoothSDK {
2497
2716
  case "video_recording_status":
2498
2717
  let event = VideoRecordingStatusEvent(values: data)
2499
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
+ }
2500
2723
  delegate?.mentraBluetoothSDK(self, didReceive: .videoRecordingStatus(event))
2501
2724
  case "media_success", "media_error":
2502
2725
  let event = MediaUploadEvent(values: data)
@@ -2512,6 +2735,7 @@ public final class MentraBluetoothSDK {
2512
2735
  if data["revision"] != nil && !streamSession.accept(data) { return }
2513
2736
  let event = StreamStatusEvent(values: data)
2514
2737
  handleStreamStatusForRequests(event)
2738
+ recordStreamStatus(event)
2515
2739
  delegate?.mentraBluetoothSDK(self, didReceive: .streamStatus(event))
2516
2740
  case "keep_alive_ack":
2517
2741
  let event = KeepAliveAckEvent(values: data)
@@ -2532,6 +2756,23 @@ public final class MentraBluetoothSDK {
2532
2756
  BluetoothSdkError(code: errorCode, message: "Glasses rejected OTA start: \(errorCode)")
2533
2757
  )
2534
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
+ }
2535
2776
  delegate?.mentraBluetoothSDK(self, didReceive: .otaStatus(event))
2536
2777
  case "settings_ack":
2537
2778
  let event = SettingsAckEvent(values: data)