@mentra/bluetooth-sdk 0.1.11 → 0.1.13

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 (80) hide show
  1. package/README.md +67 -11
  2. package/android/build.gradle +1 -0
  3. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalytics.kt +182 -0
  4. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +73 -6
  5. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +10 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +83 -26
  7. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +1 -1
  8. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +266 -31
  9. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdkDebug.kt +14 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/ObservableStore.kt +20 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +153 -0
  12. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +93 -10
  13. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/ControllerManager.kt +3 -14
  14. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/R1.kt +3 -6
  15. package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +1 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/services/PhoneMic.kt +90 -4
  17. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +3835 -0
  18. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +20 -23
  19. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/{Mach1.java → Mach1.kt} +517 -560
  20. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +8712 -0
  21. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +11 -1
  22. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +15 -13
  23. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +7 -14
  24. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +2 -1
  25. package/build/BluetoothSdk.types.d.ts +72 -10
  26. package/build/BluetoothSdk.types.d.ts.map +1 -1
  27. package/build/BluetoothSdk.types.js.map +1 -1
  28. package/build/_private/BluetoothSdkModule.d.ts +9 -7
  29. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  30. package/build/_private/BluetoothSdkModule.js +0 -2
  31. package/build/_private/BluetoothSdkModule.js.map +1 -1
  32. package/build/_private/photoRequestPayload.d.ts +3 -1
  33. package/build/_private/photoRequestPayload.d.ts.map +1 -1
  34. package/build/_private/photoRequestPayload.js +43 -1
  35. package/build/_private/photoRequestPayload.js.map +1 -1
  36. package/build/debug.d.ts +3 -0
  37. package/build/debug.d.ts.map +1 -0
  38. package/build/debug.js +8 -0
  39. package/build/debug.js.map +1 -0
  40. package/build/index.d.ts +1 -1
  41. package/build/index.d.ts.map +1 -1
  42. package/build/index.js +16 -5
  43. package/build/index.js.map +1 -1
  44. package/ios/BluetoothSdkModule.swift +59 -64
  45. package/ios/Source/BluetoothSdkDefaults.swift +47 -0
  46. package/ios/Source/Bridge.swift +6 -0
  47. package/ios/Source/DeviceManager.swift +46 -32
  48. package/ios/Source/DeviceStore.swift +5 -1
  49. package/ios/Source/MentraBluetoothSDK.swift +357 -34
  50. package/ios/Source/MentraBluetoothSDKDebug.swift +12 -0
  51. package/ios/Source/ObservableStore.swift +11 -0
  52. package/ios/Source/OtaManifest.swift +170 -0
  53. package/ios/Source/camera/CameraModels.swift +267 -9
  54. package/ios/Source/controllers/ControllerManager.swift +2 -5
  55. package/ios/Source/controllers/R1.swift +2 -5
  56. package/ios/Source/events/BluetoothEvents.swift +3 -0
  57. package/ios/Source/internal/BluetoothAvailability.swift +20 -0
  58. package/ios/Source/internal/BluetoothSdkAnalytics.swift +160 -0
  59. package/ios/Source/sgcs/G1.swift +6 -5
  60. package/ios/Source/sgcs/G2.swift +20 -18
  61. package/ios/Source/sgcs/Mach1.swift +6 -5
  62. package/ios/Source/sgcs/MentraLive.swift +158 -38
  63. package/ios/Source/sgcs/MentraNex.swift +6 -5
  64. package/ios/Source/sgcs/SGCManager.swift +16 -7
  65. package/ios/Source/sgcs/Simulated.swift +7 -3
  66. package/ios/Source/status/DeviceStatus.swift +1 -1
  67. package/ios/Source/types/DeviceModels.swift +5 -1
  68. package/package.json +6 -1
  69. package/plugin/build/index.d.ts +4 -0
  70. package/plugin/build/withAndroid.d.ts +2 -3
  71. package/plugin/build/withAndroid.js +46 -0
  72. package/plugin/build/withIos.d.ts +2 -3
  73. package/plugin/build/withIos.js +32 -0
  74. package/src/BluetoothSdk.types.ts +80 -10
  75. package/src/_private/BluetoothSdkModule.ts +13 -10
  76. package/src/_private/photoRequestPayload.ts +45 -2
  77. package/src/debug.ts +9 -0
  78. package/src/index.ts +25 -6
  79. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.java +0 -3974
  80. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.java +0 -7405
@@ -81,10 +81,14 @@ private final class PendingHotspotStatusRequest {
81
81
  private final class PendingVideoRecordingRequest {
82
82
  let expectedStatus: String
83
83
  let pending: PendingResponse<VideoRecordingStatusEvent>
84
+ let waitForUpload: Bool
85
+ var stoppedEvent: VideoRecordingStatusEvent?
86
+ var uploadSucceeded = false
84
87
 
85
- init(expectedStatus: String, pending: PendingResponse<VideoRecordingStatusEvent>) {
88
+ init(expectedStatus: String, pending: PendingResponse<VideoRecordingStatusEvent>, waitForUpload: Bool = false) {
86
89
  self.expectedStatus = expectedStatus
87
90
  self.pending = pending
91
+ self.waitForUpload = waitForUpload
88
92
  }
89
93
  }
90
94
 
@@ -143,17 +147,26 @@ private final class PendingResponse<T> {
143
147
 
144
148
  @MainActor
145
149
  public final class MentraBluetoothSDK {
150
+ private static let otaBesVersionWaitMs = 5_000
151
+ private static let otaMtkVersionWaitMs = 2_000
152
+ private static let otaVersionPollMs = 100
153
+
146
154
  public weak var delegate: MentraBluetoothSDKDelegate?
147
155
 
148
156
  private let configuration: MentraBluetoothSDKConfiguration
149
157
  private var discoveredDeviceNames = Set<String>()
158
+ private var bluetoothAvailabilityListenerId: UUID?
159
+ private var shouldRestoreGlassesOnBluetoothRestore = false
160
+ private var shouldRestoreControllerOnBluetoothRestore = false
150
161
  private var bridgeEventSinkId: String?
151
162
  private var storeListenerId: String?
152
163
  private let defaultDeviceKeys: Set<String> = ["default_wearable", "device_name", "device_address"]
164
+ private let videoUploadStopTimeoutMs = 10 * 60 * 1000
153
165
  private var suppressDefaultDeviceEvents = false
154
166
  private var defaultDeviceApplyGeneration = 0
155
167
  private var activeScanSessions: [UUID: ActiveScanSession] = [:]
156
168
  private var activeStreamKeepAlive: ActiveStreamKeepAlive?
169
+ private let analytics: BluetoothSdkAnalytics
157
170
  private var pendingPhotoRequests: [String: PendingResponse<PhotoResponseEvent>] = [:]
158
171
  private var pendingVideoRecordingRequests: [String: PendingVideoRecordingRequest] = [:]
159
172
  private var pendingRgbLedRequests: [String: PendingResponse<RgbLedControlResponseEvent>] = [:]
@@ -167,10 +180,16 @@ public final class MentraBluetoothSDK {
167
180
  private var pendingWifiStatus: PendingWifiStatusRequest?
168
181
  private var pendingHotspotStatus: PendingHotspotStatusRequest?
169
182
  private var pendingVersionInfo: PendingResponse<VersionInfoResult>?
183
+ private var configuredOtaVersionUrl: String?
170
184
 
171
185
  public init(configuration: MentraBluetoothSDKConfiguration = .default) {
172
186
  self.configuration = configuration
173
- _ = BluetoothAvailability.shared
187
+ analytics = BluetoothSdkAnalytics(configuration: configuration.analytics)
188
+ bluetoothAvailabilityListenerId = BluetoothAvailability.shared.addStateListener { [weak self] state in
189
+ Task { @MainActor [weak self] in
190
+ self?.handleBluetoothAvailability(state)
191
+ }
192
+ }
174
193
  bridgeEventSinkId = Bridge.addEventSink { [weak self] eventName, data in
175
194
  Task { @MainActor [weak self] in
176
195
  self?.dispatchBridgeEvent(eventName, data)
@@ -181,6 +200,8 @@ public final class MentraBluetoothSDK {
181
200
  self?.dispatchStoreUpdate(category, changes)
182
201
  }
183
202
  }
203
+ analytics.initializeGlassesStatus(glassesStatus)
204
+ analytics.captureStarted()
184
205
  }
185
206
 
186
207
  public var state: MentraBluetoothState {
@@ -211,6 +232,15 @@ public final class MentraBluetoothSDK {
211
232
  currentDefaultDevice()
212
233
  }
213
234
 
235
+ private func requireGlassesConnected(operation: String) throws {
236
+ guard glassesStatus.connected else {
237
+ throw BluetoothError(
238
+ code: "glasses_not_connected",
239
+ message: "Cannot \(operation) because glasses are not connected."
240
+ )
241
+ }
242
+ }
243
+
214
244
  public func getDefaultDevice() -> Device? {
215
245
  currentDefaultDevice()
216
246
  }
@@ -296,6 +326,7 @@ public final class MentraBluetoothSDK {
296
326
  }
297
327
 
298
328
  public func connect(to device: Device, options: ConnectOptions = ConnectOptions()) throws {
329
+ clearBluetoothRestoreIntent()
299
330
  if device.model != .simulated {
300
331
  try BluetoothAvailability.shared.requirePoweredOn(operation: "connect to glasses")
301
332
  }
@@ -315,6 +346,7 @@ public final class MentraBluetoothSDK {
315
346
  }
316
347
 
317
348
  public func connectDefault(options: ConnectOptions = ConnectOptions()) throws {
349
+ clearBluetoothRestoreIntent()
318
350
  guard let device = currentDefaultDevice() else {
319
351
  throw BluetoothError(
320
352
  code: "default_device_missing",
@@ -331,18 +363,22 @@ public final class MentraBluetoothSDK {
331
363
  }
332
364
 
333
365
  public func cancelConnectionAttempt() {
366
+ clearBluetoothRestoreIntent()
334
367
  DeviceManager.shared.disconnect()
335
368
  }
336
369
 
337
370
  func connectSimulated() {
371
+ clearBluetoothRestoreIntent()
338
372
  DeviceManager.shared.connectSimulated()
339
373
  }
340
374
 
341
375
  public func disconnect() {
376
+ clearBluetoothRestoreIntent()
342
377
  DeviceManager.shared.disconnect()
343
378
  }
344
379
 
345
380
  public func forget() {
381
+ clearBluetoothRestoreIntent()
346
382
  DeviceManager.shared.forget()
347
383
  }
348
384
 
@@ -447,15 +483,66 @@ public final class MentraBluetoothSDK {
447
483
  }
448
484
 
449
485
  public func setButtonPhotoSettings(size: ButtonPhotoSize) async throws -> SettingsAckEvent {
450
- try await performSettingsCommand(
451
- setting: "button_photo",
452
- updateStore: { _ in DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_photo_size", size.rawValue) },
453
- send: { requestId in try DeviceManager.shared.sendButtonPhotoSettings(requestId: requestId, size: size.rawValue) }
454
- )
486
+ try await setButtonPhotoSettings(ButtonPhotoSettings(size: size))
455
487
  }
456
488
 
457
489
  public func setButtonPhotoSettings(_ settings: ButtonPhotoSettings) async throws -> SettingsAckEvent {
458
- try await setButtonPhotoSettings(size: settings.size)
490
+ try await performSettingsCommand(
491
+ setting: "button_photo",
492
+ updateStore: { _ in
493
+ if settings.resetCaptureTuning == true {
494
+ // Mirror Android: clear all cached scan-tuning keys so reconnect sync
495
+ // does not replay stale values after a reset.
496
+ let cat = ObservableStore.bluetoothCategory
497
+ for key in ["button_photo_mfnr", "button_photo_zsl", "button_photo_noise_reduction",
498
+ "button_photo_edge_enhancement", "button_photo_isp_digital_gain",
499
+ "button_photo_isp_analog_gain", "button_photo_ae_exposure_divisor",
500
+ "button_photo_iso_cap", "button_photo_compress", "button_photo_sound"] {
501
+ DeviceStore.shared.remove(cat, key)
502
+ }
503
+ }
504
+ if let size = settings.size {
505
+ DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_photo_size", size.rawValue)
506
+ }
507
+ if let mfnr = settings.mfnr {
508
+ DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_photo_mfnr", mfnr)
509
+ }
510
+ if let zsl = settings.zsl {
511
+ DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_photo_zsl", zsl)
512
+ }
513
+ if let noiseReduction = settings.noiseReduction {
514
+ DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_photo_noise_reduction", noiseReduction)
515
+ }
516
+ if let edgeEnhancement = settings.edgeEnhancement {
517
+ DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_photo_edge_enhancement", edgeEnhancement)
518
+ }
519
+ if let ispDigitalGain = settings.ispDigitalGain {
520
+ DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_photo_isp_digital_gain", ispDigitalGain)
521
+ }
522
+ if let ispAnalogGain = settings.ispAnalogGain {
523
+ DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_photo_isp_analog_gain", ispAnalogGain)
524
+ }
525
+ if let aeExposureDivisor = settings.aeExposureDivisor {
526
+ DeviceStore.shared.set(
527
+ ObservableStore.bluetoothCategory,
528
+ "button_photo_ae_exposure_divisor",
529
+ aeExposureDivisor
530
+ )
531
+ }
532
+ if let isoCap = settings.isoCap {
533
+ DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_photo_iso_cap", isoCap)
534
+ }
535
+ if let compress = settings.compress {
536
+ DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_photo_compress", compress)
537
+ }
538
+ if let sound = settings.sound {
539
+ DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_photo_sound", sound)
540
+ }
541
+ },
542
+ send: { requestId in
543
+ try DeviceManager.shared.sendButtonPhotoSettings(requestId: requestId, settings: settings)
544
+ }
545
+ )
459
546
  }
460
547
 
461
548
  public func setButtonVideoRecordingSettings(width: Int, height: Int, fps: Int) async throws -> SettingsAckEvent {
@@ -683,19 +770,7 @@ public final class MentraBluetoothSDK {
683
770
  )
684
771
  let pending = PendingResponse<PhotoResponseEvent>(operation: "photo request \(request.requestId)")
685
772
  pendingPhotoRequests[request.requestId] = pending
686
- DeviceManager.shared.requestPhoto(
687
- request.requestId,
688
- request.appId,
689
- request.size.rawValue,
690
- request.webhookUrl,
691
- request.authToken,
692
- request.compress?.rawValue,
693
- request.flash,
694
- request.save,
695
- request.sound,
696
- exposureTimeNs: request.exposureTimeNs,
697
- iso: request.iso
698
- )
773
+ DeviceManager.shared.requestPhoto(request)
699
774
  do {
700
775
  let event = try await pending.wait()
701
776
  pendingPhotoRequests.removeValue(forKey: request.requestId)
@@ -806,6 +881,7 @@ public final class MentraBluetoothSDK {
806
881
  guard !request.requestId.isEmpty else {
807
882
  throw BluetoothError(code: "missing_request_id", message: "requestId is required to start video recording.")
808
883
  }
884
+ try requireGlassesConnected(operation: "start video recording")
809
885
  let pending = PendingResponse<VideoRecordingStatusEvent>(
810
886
  operation: "start video recording \(request.requestId)"
811
887
  )
@@ -825,7 +901,8 @@ public final class MentraBluetoothSDK {
825
901
  request.sound,
826
902
  request.width,
827
903
  request.height,
828
- request.fps
904
+ request.fps,
905
+ request.maxRecordingTimeMinutes
829
906
  )
830
907
  do {
831
908
  let event = try await pending.wait()
@@ -837,10 +914,13 @@ public final class MentraBluetoothSDK {
837
914
  }
838
915
  }
839
916
 
840
- public func stopVideoRecording(requestId: String) async throws -> VideoRecordingStatusEvent {
917
+ public func stopVideoRecording(
918
+ requestId: String, webhookUrl: String? = nil, authToken: String? = nil
919
+ ) async throws -> VideoRecordingStatusEvent {
841
920
  guard !requestId.isEmpty else {
842
921
  throw BluetoothError(code: "missing_request_id", message: "requestId is required to stop video recording.")
843
922
  }
923
+ try requireGlassesConnected(operation: "stop video recording")
844
924
  let pending = PendingResponse<VideoRecordingStatusEvent>(operation: "stop video recording \(requestId)")
845
925
  guard pendingVideoRecordingRequests[requestId] == nil else {
846
926
  throw BluetoothError(
@@ -848,13 +928,16 @@ public final class MentraBluetoothSDK {
848
928
  message: "A video recording command is already waiting for requestId \(requestId)."
849
929
  )
850
930
  }
931
+ let waitForUpload = !(webhookUrl?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true)
851
932
  pendingVideoRecordingRequests[requestId] = PendingVideoRecordingRequest(
852
933
  expectedStatus: "recording_stopped",
853
- pending: pending
934
+ pending: pending,
935
+ waitForUpload: waitForUpload
854
936
  )
855
- DeviceManager.shared.stopVideoRecording(requestId)
937
+ DeviceManager.shared.stopVideoRecording(requestId, webhookUrl, authToken)
856
938
  do {
857
- let event = try await pending.wait()
939
+ let timeoutMs = waitForUpload ? videoUploadStopTimeoutMs : 15_000
940
+ let event = try await pending.wait(timeoutMs: timeoutMs)
858
941
  pendingVideoRecordingRequests.removeValue(forKey: requestId)
859
942
  return event
860
943
  } catch {
@@ -887,8 +970,43 @@ public final class MentraBluetoothSDK {
887
970
  }
888
971
  }
889
972
 
890
- /// Ask connected Mentra Live glasses to check/report OTA availability and status.
891
- public func checkForOtaUpdate() async throws -> OtaQueryResult {
973
+ func setOtaVersionUrl(_ otaVersionUrl: String) throws {
974
+ configuredOtaVersionUrl = try OtaManifestChecker.normalizeHttpUrl(otaVersionUrl)
975
+ }
976
+
977
+ func getOtaVersionUrl() throws -> String {
978
+ try configuredOtaVersionUrl ?? OtaManifestDefaults.defaultOtaVersionUrl()
979
+ }
980
+
981
+ /// Fetch the configured OTA manifest and return whether any ASG/BES/MTK update is available.
982
+ public func checkForOtaUpdate() async throws -> Bool {
983
+ let status = await getFreshGlassesStatus()
984
+ guard status.connected else {
985
+ throw BluetoothError(
986
+ code: "glasses_not_connected",
987
+ message: "Cannot check OTA update because glasses are not connected."
988
+ )
989
+ }
990
+ guard !status.buildNumber.isEmpty else {
991
+ throw BluetoothError(
992
+ code: "missing_glasses_version",
993
+ message: "Cannot check OTA update because glasses build number is unavailable."
994
+ )
995
+ }
996
+
997
+ let manifestUrl = try resolveOtaVersionUrl(status: status)
998
+ let manifest = try await OtaManifestChecker.fetch(manifestUrl)
999
+ let otaStatus = try await waitForOtaManifestStatus(status, manifest: manifest)
1000
+ return try OtaManifestChecker.hasUpdate(
1001
+ currentBuildNumber: otaStatus.buildNumber,
1002
+ currentMtkVersion: otaStatus.mtkFirmwareVersion,
1003
+ currentBesVersion: otaStatus.besFirmwareVersion,
1004
+ manifest: manifest
1005
+ )
1006
+ }
1007
+
1008
+ /// Ask connected Mentra Live glasses to report the current OTA install/session status.
1009
+ private func queryOtaStatus() async throws -> OtaQueryResult {
892
1010
  try await performOtaQuery(operation: "OTA status query") {
893
1011
  DeviceManager.shared.sendOtaQueryStatus()
894
1012
  }
@@ -923,6 +1041,12 @@ public final class MentraBluetoothSDK {
923
1041
 
924
1042
  /// Start the OTA flow after your app has presented the available update to the user.
925
1043
  public func startOtaUpdate() async throws -> OtaStartAckEvent {
1044
+ let status = await getFreshGlassesStatus()
1045
+ let otaVersionUrl = try resolveOtaVersionUrl(status: status)
1046
+ return try await startOtaUpdate(otaVersionUrl: otaVersionUrl)
1047
+ }
1048
+
1049
+ private func startOtaCommand(otaVersionUrl: String) async throws -> OtaStartAckEvent {
926
1050
  if pendingOtaStart != nil {
927
1051
  throw BluetoothError(
928
1052
  code: "request_in_flight",
@@ -931,7 +1055,7 @@ public final class MentraBluetoothSDK {
931
1055
  }
932
1056
  let pending = PendingResponse<OtaStartAckEvent>(operation: "OTA start command")
933
1057
  pendingOtaStart = pending
934
- DeviceManager.shared.sendOtaStart()
1058
+ DeviceManager.shared.sendOtaStart(otaVersionUrl: otaVersionUrl)
935
1059
  do {
936
1060
  let event = try await pending.wait()
937
1061
  if pendingOtaStart === pending {
@@ -946,16 +1070,102 @@ public final class MentraBluetoothSDK {
946
1070
  }
947
1071
  }
948
1072
 
949
- /// Re-run the glasses-side OTA version check, mainly after correcting clock skew/TLS failures.
950
- public func retryOtaVersionCheck() async throws -> OtaQueryResult {
1073
+ func startOtaUpdate(otaVersionUrl: String) async throws -> OtaStartAckEvent {
1074
+ try await startOtaCommand(otaVersionUrl: otaVersionUrl)
1075
+ }
1076
+
1077
+ func sendOtaQueryStatus() async throws -> OtaQueryResult { try await queryOtaStatus() }
1078
+
1079
+ /// Re-run the glasses-side OTA version check after an internal clock-skew recovery.
1080
+ func retryOtaVersionCheck() async throws -> OtaQueryResult {
951
1081
  try await performOtaQuery(operation: "OTA version retry") {
952
1082
  DeviceManager.shared.retryOtaVersionCheck()
953
1083
  }
954
1084
  }
955
1085
 
956
- func sendOtaStart() async throws -> OtaStartAckEvent { try await startOtaUpdate() }
1086
+ private func getFreshGlassesStatus() async -> GlassesStatus {
1087
+ let status = glassesStatus
1088
+ if !status.connected || !status.buildNumber.isEmpty {
1089
+ return status
1090
+ }
1091
+
1092
+ do {
1093
+ let versionInfo = try await requestVersionInfo()
1094
+ let values = status.values.merging(versionInfo.dictionary) { existing, new in
1095
+ if let newString = new as? String, newString.isEmpty {
1096
+ return existing
1097
+ }
1098
+ return new
1099
+ }
1100
+ return GlassesStatus(values: values)
1101
+ } catch {
1102
+ return status
1103
+ }
1104
+ }
1105
+
1106
+ private func waitForOtaManifestStatus(_ initialStatus: GlassesStatus, manifest: OtaManifest) async throws -> GlassesStatus {
1107
+ var status = initialStatus
1108
+ if OtaManifestChecker.hasBesFirmware(manifest), status.besFirmwareVersion.isEmpty {
1109
+ status = await waitForGlassesStatus(status, timeoutMs: Self.otaBesVersionWaitMs) {
1110
+ !$0.connected || !$0.besFirmwareVersion.isEmpty
1111
+ }
1112
+ }
957
1113
 
958
- func sendOtaQueryStatus() async throws -> OtaQueryResult { try await checkForOtaUpdate() }
1114
+ if OtaManifestChecker.hasMtkPatches(manifest), status.mtkFirmwareVersion.isEmpty {
1115
+ status = await waitForGlassesStatus(status, timeoutMs: Self.otaMtkVersionWaitMs) {
1116
+ !$0.connected || !$0.mtkFirmwareVersion.isEmpty
1117
+ }
1118
+ }
1119
+
1120
+ guard status.connected else {
1121
+ throw BluetoothError(
1122
+ code: "glasses_not_connected",
1123
+ message: "Cannot check OTA update because glasses disconnected."
1124
+ )
1125
+ }
1126
+ return status
1127
+ }
1128
+
1129
+ private func waitForGlassesStatus(
1130
+ _ initialStatus: GlassesStatus,
1131
+ timeoutMs: Int,
1132
+ isReady: (GlassesStatus) -> Bool
1133
+ ) async -> GlassesStatus {
1134
+ let deadline = Date().addingTimeInterval(Double(timeoutMs) / 1_000)
1135
+ var status = initialStatus
1136
+ while Date() < deadline {
1137
+ status = glassesStatus
1138
+ if isReady(status) {
1139
+ return status
1140
+ }
1141
+
1142
+ let remainingMs = max(0, Int(deadline.timeIntervalSinceNow * 1_000))
1143
+ let sleepMs = min(Self.otaVersionPollMs, remainingMs)
1144
+ if sleepMs <= 0 {
1145
+ break
1146
+ }
1147
+ try? await Task.sleep(nanoseconds: UInt64(sleepMs) * 1_000_000)
1148
+ }
1149
+ return glassesStatus
1150
+ }
1151
+
1152
+ private func resolveOtaVersionUrl(status: GlassesStatus) throws -> String {
1153
+ let deviceUrl = status.otaVersionUrl.trimmingCharacters(in: .whitespacesAndNewlines)
1154
+ if isLegacyAsgOtaStartBuild(status.buildNumber) {
1155
+ return deviceUrl.isEmpty ? OtaManifestDefaults.prodOtaVersionUrl : deviceUrl
1156
+ }
1157
+ // SDK consumers are pinned to the manifest built for their SDK version.
1158
+ // A future glasses-advertised URL should not silently change that pairing.
1159
+ if let configuredOtaVersionUrl {
1160
+ return configuredOtaVersionUrl
1161
+ }
1162
+ return try OtaManifestDefaults.defaultOtaVersionUrl()
1163
+ }
1164
+
1165
+ private func isLegacyAsgOtaStartBuild(_ buildNumber: String) -> Bool {
1166
+ guard let parsed = Int(buildNumber) else { return false }
1167
+ return parsed < 100_000
1168
+ }
959
1169
 
960
1170
  func sendShutdown() {
961
1171
  DeviceManager.shared.sendShutdown()
@@ -971,6 +1181,10 @@ public final class MentraBluetoothSDK {
971
1181
 
972
1182
  public func invalidate() {
973
1183
  stopStreamKeepAliveMonitor()
1184
+ if let bluetoothAvailabilityListenerId {
1185
+ BluetoothAvailability.shared.removeStateListener(bluetoothAvailabilityListenerId)
1186
+ self.bluetoothAvailabilityListenerId = nil
1187
+ }
974
1188
  if let bridgeEventSinkId {
975
1189
  Bridge.removeEventSink(bridgeEventSinkId)
976
1190
  self.bridgeEventSinkId = nil
@@ -982,6 +1196,83 @@ public final class MentraBluetoothSDK {
982
1196
  delegate = nil
983
1197
  }
984
1198
 
1199
+ private func handleBluetoothAvailability(_ state: CBManagerState) {
1200
+ switch state {
1201
+ case .poweredOff, .resetting, .unauthorized, .unsupported:
1202
+ handleBluetoothUnavailable()
1203
+ case .poweredOn:
1204
+ handleBluetoothRestored()
1205
+ case .unknown:
1206
+ break
1207
+ @unknown default:
1208
+ handleBluetoothUnavailable()
1209
+ }
1210
+ }
1211
+
1212
+ private func handleBluetoothUnavailable() {
1213
+ cancelActiveScanSessions(reason: .cancelled)
1214
+ clearBluetoothDiscoveryState()
1215
+ disconnectActiveConnections()
1216
+ }
1217
+
1218
+ private func disconnectActiveConnections() {
1219
+ if glassesStatus.controllerConnected {
1220
+ DeviceManager.shared.disconnectController()
1221
+ shouldRestoreControllerOnBluetoothRestore = true
1222
+ }
1223
+ if glassesStatus.deviceModel == DeviceTypes.SIMULATED
1224
+ || DeviceManager.shared.sgc?.type.contains(DeviceTypes.SIMULATED) == true
1225
+ {
1226
+ return
1227
+ }
1228
+ if glassesStatus.connected || glassesStatus.connectionState != .disconnected {
1229
+ DeviceManager.shared.disconnect()
1230
+ shouldRestoreGlassesOnBluetoothRestore = true
1231
+ }
1232
+ }
1233
+
1234
+ /// Reconnect only what `handleBluetoothUnavailable` tore down, never a
1235
+ /// connection the user closed themselves (explicit connect/disconnect
1236
+ /// calls clear the restore intent).
1237
+ private func handleBluetoothRestored() {
1238
+ let restoreGlasses = shouldRestoreGlassesOnBluetoothRestore
1239
+ let restoreController = shouldRestoreControllerOnBluetoothRestore
1240
+ clearBluetoothRestoreIntent()
1241
+
1242
+ if restoreGlasses, !glassesStatus.connected, glassesStatus.connectionState == .disconnected {
1243
+ DeviceManager.shared.connectDefault() // also restores the controller
1244
+ } else if restoreController, !glassesStatus.controllerConnected {
1245
+ DeviceManager.shared.connectDefaultController()
1246
+ }
1247
+ }
1248
+
1249
+ private func clearBluetoothRestoreIntent() {
1250
+ shouldRestoreGlassesOnBluetoothRestore = false
1251
+ shouldRestoreControllerOnBluetoothRestore = false
1252
+ }
1253
+
1254
+ private func clearBluetoothDiscoveryState() {
1255
+ discoveredDeviceNames.removeAll()
1256
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "searching", false)
1257
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "searchingController", false)
1258
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "searchResults", [] as [[String: Any]])
1259
+ }
1260
+
1261
+ private func cancelActiveScanSessions(reason: ScanStopReason) {
1262
+ let ids = Array(activeScanSessions.keys)
1263
+ guard !ids.isEmpty else {
1264
+ if bluetoothStatus.searching || bluetoothStatus.searchingController {
1265
+ stopScan(reason: reason)
1266
+ }
1267
+ return
1268
+ }
1269
+ for (index, id) in ids.enumerated() {
1270
+ // Stop the underlying scan once (first session); the rest only
1271
+ // complete their callbacks.
1272
+ finishScanSession(id, reason: reason, shouldStopScan: index == 0)
1273
+ }
1274
+ }
1275
+
985
1276
  private func startStreamKeepAliveMonitor(streamId: String, intervalSeconds requestedIntervalSeconds: Int) {
986
1277
  let intervalSeconds = requestedIntervalSeconds > 0 ? requestedIntervalSeconds : 5
987
1278
  let tracker = ActiveStreamKeepAlive(streamId: streamId, intervalSeconds: intervalSeconds)
@@ -1162,7 +1453,14 @@ public final class MentraBluetoothSDK {
1162
1453
  guard let request = pendingVideoRecordingRequests[event.requestId] else { return }
1163
1454
  if event.success {
1164
1455
  if event.status == request.expectedStatus {
1165
- request.pending.resolve(event)
1456
+ if request.waitForUpload {
1457
+ request.stoppedEvent = event
1458
+ if request.uploadSucceeded {
1459
+ request.pending.resolve(event)
1460
+ }
1461
+ } else {
1462
+ request.pending.resolve(event)
1463
+ }
1166
1464
  }
1167
1465
  } else {
1168
1466
  request.pending.reject(
@@ -1174,6 +1472,26 @@ public final class MentraBluetoothSDK {
1174
1472
  }
1175
1473
  }
1176
1474
 
1475
+ private func handleMediaUploadForRequests(_ event: MediaUploadEvent) {
1476
+ guard event.isVideo, let request = pendingVideoRecordingRequests[event.requestId], request.waitForUpload else {
1477
+ return
1478
+ }
1479
+ if event.isSuccess {
1480
+ if let stoppedEvent = request.stoppedEvent {
1481
+ request.pending.resolve(stoppedEvent)
1482
+ } else {
1483
+ request.uploadSucceeded = true
1484
+ }
1485
+ } else {
1486
+ request.pending.reject(
1487
+ BluetoothError(
1488
+ code: "video_upload_failed",
1489
+ message: event.errorMessage ?? "Video upload failed."
1490
+ )
1491
+ )
1492
+ }
1493
+ }
1494
+
1177
1495
  private func handleRgbLedResponseForRequests(_ event: RgbLedControlResponseEvent) {
1178
1496
  guard let pending = pendingRgbLedRequests[event.requestId] else { return }
1179
1497
  if event.state == "success" {
@@ -1273,6 +1591,7 @@ public final class MentraBluetoothSDK {
1273
1591
  private func dispatchStoreUpdate(_ category: String, _ changes: [String: Any]) {
1274
1592
  switch ObservableStore.normalizeCategory(category) {
1275
1593
  case "glasses":
1594
+ analytics.observeGlassesStatus(glassesStatus)
1276
1595
  let nextState = state
1277
1596
  delegate?.mentraBluetoothSDK(self, didUpdate: nextState)
1278
1597
  delegate?.mentraBluetoothSDK(self, didUpdateGlasses: nextState.glasses)
@@ -1424,6 +1743,10 @@ public final class MentraBluetoothSDK {
1424
1743
  let event = VideoRecordingStatusEvent(values: data)
1425
1744
  handleVideoRecordingStatusForRequests(event)
1426
1745
  delegate?.mentraBluetoothSDK(self, didReceive: .videoRecordingStatus(event))
1746
+ case "media_success", "media_error":
1747
+ let event = MediaUploadEvent(values: data)
1748
+ handleMediaUploadForRequests(event)
1749
+ delegate?.mentraBluetoothSDK(self, didReceive: .mediaUpload(event))
1427
1750
  case "rgb_led_control_response":
1428
1751
  let event = RgbLedControlResponseEvent(values: data)
1429
1752
  handleRgbLedResponseForRequests(event)
@@ -0,0 +1,12 @@
1
+ import Foundation
2
+
3
+ @MainActor
4
+ public enum MentraBluetoothSDKDebug {
5
+ public static func setOtaVersionUrl(_ otaVersionUrl: String, on sdk: MentraBluetoothSDK) throws {
6
+ try sdk.setOtaVersionUrl(otaVersionUrl)
7
+ }
8
+
9
+ public static func getOtaVersionUrl(on sdk: MentraBluetoothSDK) throws -> String {
10
+ try sdk.getOtaVersionUrl()
11
+ }
12
+ }
@@ -54,6 +54,17 @@ class ObservableStore {
54
54
  }
55
55
  }
56
56
 
57
+ func remove(_ category: String, _ key: String) {
58
+ let normalizedCategory = Self.normalizeCategory(category)
59
+ let fullKey = "\(normalizedCategory).\(key)"
60
+ guard values[fullKey] != nil else { return }
61
+ values.removeValue(forKey: fullKey)
62
+ // Emit updated category snapshot so UI listeners clear the removed key
63
+ let snapshot = getCategory(normalizedCategory)
64
+ onEmit?(normalizedCategory, snapshot)
65
+ for listener in Array(listeners.values) { listener(normalizedCategory, snapshot) }
66
+ }
67
+
57
68
  func get(_ category: String, _ key: String) -> Any? {
58
69
  values["\(Self.normalizeCategory(category)).\(key)"]
59
70
  }