@mentra/bluetooth-sdk 0.1.13 → 0.1.16

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 +18 -8
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +48 -48
  3. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +5 -20
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +360 -269
  5. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +3 -7
  6. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +152 -72
  7. package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +12 -12
  8. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +25 -11
  9. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/ControllerManager.kt +1 -2
  10. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/R1.kt +1 -2
  11. package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +1 -22
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +51 -8
  13. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +1184 -800
  14. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Mach1.kt +1 -5
  15. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +423 -117
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +4 -6
  17. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +2532 -0
  18. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +17 -5
  19. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +3 -7
  20. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +0 -6
  21. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +16 -33
  22. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +5 -3
  23. package/android/src/main/java/com/mentra/bluetoothsdk/utils/Constants.kt +2 -1
  24. package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +60 -1
  25. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoProtocolTest.kt +333 -0
  26. package/build/BluetoothSdk.types.d.ts +50 -24
  27. package/build/BluetoothSdk.types.d.ts.map +1 -1
  28. package/build/BluetoothSdk.types.js +1 -0
  29. package/build/BluetoothSdk.types.js.map +1 -1
  30. package/build/_internal.d.ts +4 -4
  31. package/build/_internal.js +4 -4
  32. package/build/_internal.js.map +1 -1
  33. package/build/_private/BluetoothSdkModule.d.ts +14 -8
  34. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  35. package/build/_private/BluetoothSdkModule.js +4 -3
  36. package/build/_private/BluetoothSdkModule.js.map +1 -1
  37. package/build/_private/cameraRequestPayload.d.ts +4 -0
  38. package/build/_private/cameraRequestPayload.d.ts.map +1 -0
  39. package/build/_private/cameraRequestPayload.js +25 -0
  40. package/build/_private/cameraRequestPayload.js.map +1 -0
  41. package/build/_private/photoRequestPayload.d.ts.map +1 -1
  42. package/build/_private/photoRequestPayload.js +11 -3
  43. package/build/_private/photoRequestPayload.js.map +1 -1
  44. package/build/index.d.ts +1 -1
  45. package/build/index.d.ts.map +1 -1
  46. package/build/index.js +70 -61
  47. package/build/index.js.map +1 -1
  48. package/ios/BluetoothSdkModule.swift +51 -28
  49. package/ios/Source/Bridge.swift +4 -16
  50. package/ios/Source/DeviceManager.swift +76 -22
  51. package/ios/Source/DeviceStore.swift +0 -4
  52. package/ios/Source/MentraBluetoothSDK.swift +144 -78
  53. package/ios/Source/OtaManifest.swift +13 -13
  54. package/ios/Source/camera/CameraModels.swift +82 -20
  55. package/ios/Source/controllers/ControllerManager.swift +1 -2
  56. package/ios/Source/controllers/R1.swift +1 -2
  57. package/ios/Source/errors/{BluetoothError.swift → BluetoothSdkError.swift} +1 -1
  58. package/ios/Source/events/BluetoothEvents.swift +5 -27
  59. package/ios/Source/internal/BluetoothAvailability.swift +5 -5
  60. package/ios/Source/sgcs/Frame.swift +0 -2
  61. package/ios/Source/sgcs/G1.swift +52 -4
  62. package/ios/Source/sgcs/G2.swift +643 -204
  63. package/ios/Source/sgcs/Mach1.swift +1 -3
  64. package/ios/Source/sgcs/MentraLive.swift +64 -77
  65. package/ios/Source/sgcs/MentraNex.swift +1 -3
  66. package/ios/Source/sgcs/Nimo.swift +1928 -0
  67. package/ios/Source/sgcs/SGCManager.swift +16 -10
  68. package/ios/Source/sgcs/Simulated.swift +2 -6
  69. package/ios/Source/status/DeviceStatus.swift +0 -8
  70. package/ios/Source/streaming/StreamModels.swift +7 -32
  71. package/ios/Source/stt/STTTools.swift +1 -1
  72. package/ios/Source/types/DeviceModels.swift +5 -0
  73. package/ios/Source/utils/Constants.swift +2 -0
  74. package/package.json +6 -1
  75. package/src/BluetoothSdk.types.ts +54 -28
  76. package/src/_internal.ts +4 -4
  77. package/src/_private/BluetoothSdkModule.ts +20 -11
  78. package/src/_private/cameraRequestPayload.ts +29 -0
  79. package/src/_private/photoRequestPayload.ts +12 -3
  80. package/src/index.ts +77 -67
@@ -42,6 +42,7 @@ private final class ActiveScanSession {
42
42
  @MainActor
43
43
  private final class PendingWifiScan {
44
44
  let pending: PendingResponse<[WifiScanResult]>
45
+ var latestResults: [WifiScanResult] = []
45
46
 
46
47
  init(pending: PendingResponse<[WifiScanResult]>) {
47
48
  self.pending = pending
@@ -135,7 +136,7 @@ private final class PendingResponse<T> {
135
136
  return
136
137
  }
137
138
  self?.reject(
138
- BluetoothError(
139
+ BluetoothSdkError(
139
140
  code: "request_timeout",
140
141
  message: "\(self?.operation ?? "Request") timed out waiting for glasses response."
141
142
  )
@@ -150,6 +151,7 @@ public final class MentraBluetoothSDK {
150
151
  private static let otaBesVersionWaitMs = 5_000
151
152
  private static let otaMtkVersionWaitMs = 2_000
152
153
  private static let otaVersionPollMs = 100
154
+ private static let defaultStreamKeepAliveIntervalSeconds = 5
153
155
 
154
156
  public weak var delegate: MentraBluetoothSDKDelegate?
155
157
 
@@ -168,6 +170,7 @@ public final class MentraBluetoothSDK {
168
170
  private var activeStreamKeepAlive: ActiveStreamKeepAlive?
169
171
  private let analytics: BluetoothSdkAnalytics
170
172
  private var pendingPhotoRequests: [String: PendingResponse<PhotoResponseEvent>] = [:]
173
+ private var pendingCameraStatusRequests: [String: PendingResponse<CameraStatusEvent>] = [:]
171
174
  private var pendingVideoRecordingRequests: [String: PendingVideoRecordingRequest] = [:]
172
175
  private var pendingRgbLedRequests: [String: PendingResponse<RgbLedControlResponseEvent>] = [:]
173
176
  private var pendingSettingsRequests: [String: PendingResponse<SettingsAckEvent>] = [:]
@@ -234,7 +237,7 @@ public final class MentraBluetoothSDK {
234
237
 
235
238
  private func requireGlassesConnected(operation: String) throws {
236
239
  guard glassesStatus.connected else {
237
- throw BluetoothError(
240
+ throw BluetoothSdkError(
238
241
  code: "glasses_not_connected",
239
242
  message: "Cannot \(operation) because glasses are not connected."
240
243
  )
@@ -348,7 +351,7 @@ public final class MentraBluetoothSDK {
348
351
  public func connectDefault(options: ConnectOptions = ConnectOptions()) throws {
349
352
  clearBluetoothRestoreIntent()
350
353
  guard let device = currentDefaultDevice() else {
351
- throw BluetoothError(
354
+ throw BluetoothSdkError(
352
355
  code: "default_device_missing",
353
356
  message: "Set a default glasses device before calling connectDefault."
354
357
  )
@@ -482,11 +485,7 @@ public final class MentraBluetoothSDK {
482
485
  DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "voice_activity_detection_enabled", enabled)
483
486
  }
484
487
 
485
- public func setButtonPhotoSettings(size: ButtonPhotoSize) async throws -> SettingsAckEvent {
486
- try await setButtonPhotoSettings(ButtonPhotoSettings(size: size))
487
- }
488
-
489
- public func setButtonPhotoSettings(_ settings: ButtonPhotoSettings) async throws -> SettingsAckEvent {
488
+ public func setPhotoCaptureDefaults(_ settings: PhotoCaptureDefaults) async throws -> SettingsAckEvent {
490
489
  try await performSettingsCommand(
491
490
  setting: "button_photo",
492
491
  updateStore: { _ in
@@ -545,38 +544,26 @@ public final class MentraBluetoothSDK {
545
544
  )
546
545
  }
547
546
 
548
- public func setButtonVideoRecordingSettings(width: Int, height: Int, fps: Int) async throws -> SettingsAckEvent {
547
+ public func setVideoRecordingDefaults(_ defaults: VideoRecordingDefaults) async throws -> SettingsAckEvent {
549
548
  try await performSettingsCommand(
550
549
  setting: "button_video_recording",
551
550
  updateStore: { _ in
552
- DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_video_width", width)
553
- DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_video_height", height)
554
- DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_video_fps", fps)
551
+ DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_video_width", defaults.width)
552
+ DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_video_height", defaults.height)
553
+ DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_video_fps", defaults.fps)
555
554
  },
556
555
  send: { requestId in
557
556
  try DeviceManager.shared.sendButtonVideoRecordingSettings(
558
557
  requestId: requestId,
559
- width: width,
560
- height: height,
561
- fps: fps
558
+ width: defaults.width,
559
+ height: defaults.height,
560
+ fps: defaults.fps
562
561
  )
563
562
  }
564
563
  )
565
564
  }
566
565
 
567
- public func setButtonVideoRecordingSettings(_ settings: ButtonVideoRecordingSettings) async throws -> SettingsAckEvent {
568
- try await setButtonVideoRecordingSettings(width: settings.width, height: settings.height, fps: settings.fps)
569
- }
570
-
571
- public func setButtonCameraLed(enabled: Bool) async throws -> SettingsAckEvent {
572
- try await performSettingsCommand(
573
- setting: "button_camera_led",
574
- updateStore: { _ in DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_camera_led", enabled) },
575
- send: { requestId in try DeviceManager.shared.sendButtonCameraLedSetting(requestId: requestId, enabled: enabled) }
576
- )
577
- }
578
-
579
- public func setButtonMaxRecordingTime(minutes: Int) async throws -> SettingsAckEvent {
566
+ public func setMaxVideoRecordingDuration(minutes: Int) async throws -> SettingsAckEvent {
580
567
  try await performSettingsCommand(
581
568
  setting: "button_max_recording_time",
582
569
  updateStore: { _ in
@@ -609,6 +596,20 @@ public final class MentraBluetoothSDK {
609
596
  return result
610
597
  }
611
598
 
599
+ public func setCameraTuningConfig(anrOn: Bool, gainOn: Bool) async throws -> SettingsAckEvent {
600
+ return try await performSettingsCommand(
601
+ setting: "camera_tuning",
602
+ updateStore: { _ in },
603
+ send: { requestId in
604
+ try DeviceManager.shared.sendCameraTuningConfig(
605
+ requestId: requestId,
606
+ anrOn: anrOn,
607
+ gainOn: gainOn
608
+ )
609
+ }
610
+ )
611
+ }
612
+
612
613
  public func setMicState(
613
614
  enabled: Bool,
614
615
  useGlassesMic: Bool = true,
@@ -654,7 +655,7 @@ public final class MentraBluetoothSDK {
654
655
 
655
656
  public func setGlassesMediaVolume(_ level: Int) async throws -> GlassesMediaVolumeSetResult {
656
657
  guard (0 ... 15).contains(level) else {
657
- throw BluetoothError(
658
+ throw BluetoothSdkError(
658
659
  code: "invalid_volume_level",
659
660
  message: "Glasses media volume must be between 0 and 15."
660
661
  )
@@ -664,13 +665,14 @@ public final class MentraBluetoothSDK {
664
665
 
665
666
  public func requestWifiScan() async throws -> [WifiScanResult] {
666
667
  guard pendingWifiScan == nil else {
667
- throw BluetoothError(
668
+ throw BluetoothSdkError(
668
669
  code: "request_in_flight",
669
670
  message: "A WiFi scan is already waiting for a glasses response."
670
671
  )
671
672
  }
672
673
  let pending = PendingResponse<[WifiScanResult]>(operation: "WiFi scan request")
673
- pendingWifiScan = PendingWifiScan(pending: pending)
674
+ let request = PendingWifiScan(pending: pending)
675
+ pendingWifiScan = request
674
676
  DeviceManager.shared.requestWifiScan()
675
677
  do {
676
678
  let results = try await pending.wait()
@@ -679,16 +681,25 @@ public final class MentraBluetoothSDK {
679
681
  }
680
682
  return results
681
683
  } catch {
684
+ let fallbackResults: [WifiScanResult]
685
+ if (error as? BluetoothSdkError)?.code == "request_timeout" {
686
+ fallbackResults = request.latestResults
687
+ } else {
688
+ fallbackResults = []
689
+ }
682
690
  if pendingWifiScan?.pending === pending {
683
691
  pendingWifiScan = nil
684
692
  }
693
+ if !fallbackResults.isEmpty {
694
+ return fallbackResults
695
+ }
685
696
  throw error
686
697
  }
687
698
  }
688
699
 
689
700
  public func sendWifiCredentials(ssid: String, password: String) async throws -> WifiStatusEvent {
690
701
  guard pendingWifiStatus == nil else {
691
- throw BluetoothError(
702
+ throw BluetoothSdkError(
692
703
  code: "request_in_flight",
693
704
  message: "A WiFi status command is already waiting for a glasses response."
694
705
  )
@@ -712,7 +723,7 @@ public final class MentraBluetoothSDK {
712
723
 
713
724
  public func forgetWifiNetwork(ssid: String) async throws -> WifiStatusEvent {
714
725
  guard pendingWifiStatus == nil else {
715
- throw BluetoothError(
726
+ throw BluetoothSdkError(
716
727
  code: "request_in_flight",
717
728
  message: "A WiFi status command is already waiting for a glasses response."
718
729
  )
@@ -736,7 +747,7 @@ public final class MentraBluetoothSDK {
736
747
 
737
748
  public func setHotspotState(enabled: Bool) async throws -> HotspotStatusEvent {
738
749
  guard pendingHotspotStatus == nil else {
739
- throw BluetoothError(
750
+ throw BluetoothSdkError(
740
751
  code: "request_in_flight",
741
752
  message: "A hotspot command is already waiting for a glasses response."
742
753
  )
@@ -765,25 +776,53 @@ public final class MentraBluetoothSDK {
765
776
  }
766
777
 
767
778
  public func requestPhoto(_ request: PhotoRequest) async throws -> PhotoResponseEvent {
779
+ let routedRequest = nonBlankRequestId(request.requestId).map { request.withRequestId($0) }
780
+ ?? request.withRequestId(generatedCameraRequestId("photo"))
768
781
  Bridge.log(
769
- "NATIVE: PHOTO PIPELINE [3b/6] MentraBluetoothSdk.requestPhoto requestId=\(request.requestId) appId=\(request.appId)"
782
+ "NATIVE: PHOTO PIPELINE [3b/6] MentraBluetoothSdk.requestPhoto requestId=\(routedRequest.requestId)"
770
783
  )
771
- let pending = PendingResponse<PhotoResponseEvent>(operation: "photo request \(request.requestId)")
772
- pendingPhotoRequests[request.requestId] = pending
773
- DeviceManager.shared.requestPhoto(request)
784
+ let pending = PendingResponse<PhotoResponseEvent>(operation: "photo request \(routedRequest.requestId)")
785
+ pendingPhotoRequests[routedRequest.requestId] = pending
786
+ DeviceManager.shared.requestPhoto(routedRequest)
774
787
  do {
775
788
  let event = try await pending.wait()
776
- pendingPhotoRequests.removeValue(forKey: request.requestId)
789
+ pendingPhotoRequests.removeValue(forKey: routedRequest.requestId)
777
790
  return event
778
791
  } catch {
779
- pendingPhotoRequests.removeValue(forKey: request.requestId)
792
+ pendingPhotoRequests.removeValue(forKey: routedRequest.requestId)
793
+ throw error
794
+ }
795
+ }
796
+
797
+ public func warmUpCamera(
798
+ requestId: String? = nil,
799
+ size: PhotoSize,
800
+ exposureTimeNs: Double?,
801
+ durationMs: Int
802
+ ) async throws -> CameraStatusEvent {
803
+ let effectiveRequestId = nonBlankRequestId(requestId) ?? generatedCameraRequestId("warm")
804
+ let pending = PendingResponse<CameraStatusEvent>(operation: "camera warm up \(effectiveRequestId)")
805
+ pendingCameraStatusRequests[effectiveRequestId] = pending
806
+ do {
807
+ // Inside the catch so an unsupported-device throw also clears the pending entry.
808
+ try DeviceManager.shared.warmUpCamera(
809
+ requestId: effectiveRequestId,
810
+ size: size,
811
+ exposureTimeNs: exposureTimeNs,
812
+ durationMs: durationMs
813
+ )
814
+ let event = try await pending.wait()
815
+ pendingCameraStatusRequests.removeValue(forKey: effectiveRequestId)
816
+ return event
817
+ } catch {
818
+ pendingCameraStatusRequests.removeValue(forKey: effectiveRequestId)
780
819
  throw error
781
820
  }
782
821
  }
783
822
 
784
823
  public func queryGalleryStatus() async throws -> GalleryStatusEvent {
785
824
  if pendingGalleryStatus != nil {
786
- throw BluetoothError(
825
+ throw BluetoothSdkError(
787
826
  code: "request_in_flight",
788
827
  message: "A gallery status query is already waiting for a glasses response."
789
828
  )
@@ -806,6 +845,14 @@ public final class MentraBluetoothSDK {
806
845
  }
807
846
 
808
847
  public func startStream(_ request: StreamRequest) async throws -> StreamStatusEvent {
848
+ try await startStream(request, startSdkKeepAlive: true)
849
+ }
850
+
851
+ func startExternallyManagedStream(_ request: StreamRequest) async throws -> StreamStatusEvent {
852
+ try await startStream(request, startSdkKeepAlive: false)
853
+ }
854
+
855
+ private func startStream(_ request: StreamRequest, startSdkKeepAlive: Bool) async throws -> StreamStatusEvent {
809
856
  var values = request.values
810
857
  let streamId = stringValue(values, "streamId").flatMap { $0.isEmpty ? nil : $0 } ?? "sdk-\(UUID().uuidString)"
811
858
  values["streamId"] = streamId
@@ -816,8 +863,11 @@ public final class MentraBluetoothSDK {
816
863
  do {
817
864
  let event = try await pending.wait(timeoutMs: 30_000)
818
865
  pendingStreamStarts.removeValue(forKey: streamId)
819
- if request.keepAlive, !request.isExternallyManagedKeepAlive {
820
- startStreamKeepAliveMonitor(streamId: streamId, intervalSeconds: request.keepAliveIntervalSeconds)
866
+ if startSdkKeepAlive {
867
+ startStreamKeepAliveMonitor(
868
+ streamId: streamId,
869
+ intervalSeconds: Self.defaultStreamKeepAliveIntervalSeconds
870
+ )
821
871
  }
822
872
  return event
823
873
  } catch {
@@ -854,7 +904,7 @@ public final class MentraBluetoothSDK {
854
904
 
855
905
  public func stopStream() async throws -> StreamStatusEvent {
856
906
  guard pendingStreamStop == nil else {
857
- throw BluetoothError(
907
+ throw BluetoothSdkError(
858
908
  code: "request_in_flight",
859
909
  message: "A stream stop command is already waiting for a glasses response."
860
910
  )
@@ -879,14 +929,14 @@ public final class MentraBluetoothSDK {
879
929
 
880
930
  public func startVideoRecording(_ request: VideoRecordingRequest) async throws -> VideoRecordingStatusEvent {
881
931
  guard !request.requestId.isEmpty else {
882
- throw BluetoothError(code: "missing_request_id", message: "requestId is required to start video recording.")
932
+ throw BluetoothSdkError(code: "missing_request_id", message: "requestId is required to start video recording.")
883
933
  }
884
934
  try requireGlassesConnected(operation: "start video recording")
885
935
  let pending = PendingResponse<VideoRecordingStatusEvent>(
886
936
  operation: "start video recording \(request.requestId)"
887
937
  )
888
938
  guard pendingVideoRecordingRequests[request.requestId] == nil else {
889
- throw BluetoothError(
939
+ throw BluetoothSdkError(
890
940
  code: "request_in_flight",
891
941
  message: "A video recording command is already waiting for requestId \(request.requestId)."
892
942
  )
@@ -918,12 +968,12 @@ public final class MentraBluetoothSDK {
918
968
  requestId: String, webhookUrl: String? = nil, authToken: String? = nil
919
969
  ) async throws -> VideoRecordingStatusEvent {
920
970
  guard !requestId.isEmpty else {
921
- throw BluetoothError(code: "missing_request_id", message: "requestId is required to stop video recording.")
971
+ throw BluetoothSdkError(code: "missing_request_id", message: "requestId is required to stop video recording.")
922
972
  }
923
973
  try requireGlassesConnected(operation: "stop video recording")
924
974
  let pending = PendingResponse<VideoRecordingStatusEvent>(operation: "stop video recording \(requestId)")
925
975
  guard pendingVideoRecordingRequests[requestId] == nil else {
926
- throw BluetoothError(
976
+ throw BluetoothSdkError(
927
977
  code: "request_in_flight",
928
978
  message: "A video recording command is already waiting for requestId \(requestId)."
929
979
  )
@@ -948,7 +998,7 @@ public final class MentraBluetoothSDK {
948
998
 
949
999
  public func requestVersionInfo() async throws -> VersionInfoResult {
950
1000
  guard pendingVersionInfo == nil else {
951
- throw BluetoothError(
1001
+ throw BluetoothSdkError(
952
1002
  code: "request_in_flight",
953
1003
  message: "A version info request is already waiting for a glasses response."
954
1004
  )
@@ -982,13 +1032,13 @@ public final class MentraBluetoothSDK {
982
1032
  public func checkForOtaUpdate() async throws -> Bool {
983
1033
  let status = await getFreshGlassesStatus()
984
1034
  guard status.connected else {
985
- throw BluetoothError(
1035
+ throw BluetoothSdkError(
986
1036
  code: "glasses_not_connected",
987
1037
  message: "Cannot check OTA update because glasses are not connected."
988
1038
  )
989
1039
  }
990
1040
  guard !status.buildNumber.isEmpty else {
991
- throw BluetoothError(
1041
+ throw BluetoothSdkError(
992
1042
  code: "missing_glasses_version",
993
1043
  message: "Cannot check OTA update because glasses build number is unavailable."
994
1044
  )
@@ -1017,7 +1067,7 @@ public final class MentraBluetoothSDK {
1017
1067
  sendRequest: () -> Void
1018
1068
  ) async throws -> OtaQueryResult {
1019
1069
  if pendingOtaQuery != nil {
1020
- throw BluetoothError(
1070
+ throw BluetoothSdkError(
1021
1071
  code: "request_in_flight",
1022
1072
  message: "An OTA status query is already waiting for a glasses response."
1023
1073
  )
@@ -1048,7 +1098,7 @@ public final class MentraBluetoothSDK {
1048
1098
 
1049
1099
  private func startOtaCommand(otaVersionUrl: String) async throws -> OtaStartAckEvent {
1050
1100
  if pendingOtaStart != nil {
1051
- throw BluetoothError(
1101
+ throw BluetoothSdkError(
1052
1102
  code: "request_in_flight",
1053
1103
  message: "An OTA start command is already waiting for a glasses response."
1054
1104
  )
@@ -1076,13 +1126,6 @@ public final class MentraBluetoothSDK {
1076
1126
 
1077
1127
  func sendOtaQueryStatus() async throws -> OtaQueryResult { try await queryOtaStatus() }
1078
1128
 
1079
- /// Re-run the glasses-side OTA version check after an internal clock-skew recovery.
1080
- func retryOtaVersionCheck() async throws -> OtaQueryResult {
1081
- try await performOtaQuery(operation: "OTA version retry") {
1082
- DeviceManager.shared.retryOtaVersionCheck()
1083
- }
1084
- }
1085
-
1086
1129
  private func getFreshGlassesStatus() async -> GlassesStatus {
1087
1130
  let status = glassesStatus
1088
1131
  if !status.connected || !status.buildNumber.isEmpty {
@@ -1118,7 +1161,7 @@ public final class MentraBluetoothSDK {
1118
1161
  }
1119
1162
 
1120
1163
  guard status.connected else {
1121
- throw BluetoothError(
1164
+ throw BluetoothSdkError(
1122
1165
  code: "glasses_not_connected",
1123
1166
  message: "Cannot check OTA update because glasses disconnected."
1124
1167
  )
@@ -1152,7 +1195,7 @@ public final class MentraBluetoothSDK {
1152
1195
  private func resolveOtaVersionUrl(status: GlassesStatus) throws -> String {
1153
1196
  let deviceUrl = status.otaVersionUrl.trimmingCharacters(in: .whitespacesAndNewlines)
1154
1197
  if isLegacyAsgOtaStartBuild(status.buildNumber) {
1155
- return deviceUrl.isEmpty ? OtaManifestDefaults.prodOtaVersionUrl : deviceUrl
1198
+ return OtaManifestDefaults.legacyProdOtaVersionUrl
1156
1199
  }
1157
1200
  // SDK consumers are pinned to the manifest built for their SDK version.
1158
1201
  // A future glasses-advertised URL should not silently change that pairing.
@@ -1164,7 +1207,7 @@ public final class MentraBluetoothSDK {
1164
1207
 
1165
1208
  private func isLegacyAsgOtaStartBuild(_ buildNumber: String) -> Bool {
1166
1209
  guard let parsed = Int(buildNumber) else { return false }
1167
- return parsed < 100_000
1210
+ return parsed < 39
1168
1211
  }
1169
1212
 
1170
1213
  func sendShutdown() {
@@ -1274,7 +1317,7 @@ public final class MentraBluetoothSDK {
1274
1317
  }
1275
1318
 
1276
1319
  private func startStreamKeepAliveMonitor(streamId: String, intervalSeconds requestedIntervalSeconds: Int) {
1277
- let intervalSeconds = requestedIntervalSeconds > 0 ? requestedIntervalSeconds : 5
1320
+ let intervalSeconds = requestedIntervalSeconds > 0 ? requestedIntervalSeconds : Self.defaultStreamKeepAliveIntervalSeconds
1278
1321
  let tracker = ActiveStreamKeepAlive(streamId: streamId, intervalSeconds: intervalSeconds)
1279
1322
  activeStreamKeepAlive = tracker
1280
1323
  sendNextStreamKeepAlive(for: tracker)
@@ -1424,14 +1467,14 @@ public final class MentraBluetoothSDK {
1424
1467
  )
1425
1468
  }
1426
1469
 
1427
- private func streamStatusError(_ event: StreamStatusEvent, code: String) -> BluetoothError {
1470
+ private func streamStatusError(_ event: StreamStatusEvent, code: String) -> BluetoothSdkError {
1428
1471
  let message: String
1429
1472
  if case let .error(_, errorDetails, _, _) = event.status {
1430
1473
  message = errorDetails
1431
1474
  } else {
1432
1475
  message = "Stream status \(event.state.rawValue)"
1433
1476
  }
1434
- return BluetoothError(code: code, message: message)
1477
+ return BluetoothSdkError(code: code, message: message)
1435
1478
  }
1436
1479
 
1437
1480
  private func handlePhotoResponseForRequests(_ event: PhotoResponseEvent) {
@@ -1441,7 +1484,7 @@ public final class MentraBluetoothSDK {
1441
1484
  pending.resolve(event)
1442
1485
  case let .error(_, errorCode, errorMessage, _):
1443
1486
  pending.reject(
1444
- BluetoothError(
1487
+ BluetoothSdkError(
1445
1488
  code: errorCode ?? "photo_request_failed",
1446
1489
  message: errorMessage
1447
1490
  )
@@ -1449,6 +1492,24 @@ public final class MentraBluetoothSDK {
1449
1492
  }
1450
1493
  }
1451
1494
 
1495
+ private func handleCameraStatusForRequests(_ event: CameraStatusEvent) {
1496
+ guard let pending = pendingCameraStatusRequests[event.requestId] else { return }
1497
+ switch event.state.lowercased() {
1498
+ case "ready":
1499
+ pending.resolve(event)
1500
+ case "error":
1501
+ pending.reject(
1502
+ BluetoothSdkError(
1503
+ code: event.errorCode ?? "camera_warm_up_failed",
1504
+ message: event.errorMessage ?? "Camera warm-up failed."
1505
+ )
1506
+ )
1507
+ default:
1508
+ // "warming"/"stopped" are progress updates; leave the pending promise alone.
1509
+ break
1510
+ }
1511
+ }
1512
+
1452
1513
  private func handleVideoRecordingStatusForRequests(_ event: VideoRecordingStatusEvent) {
1453
1514
  guard let request = pendingVideoRecordingRequests[event.requestId] else { return }
1454
1515
  if event.success {
@@ -1464,7 +1525,7 @@ public final class MentraBluetoothSDK {
1464
1525
  }
1465
1526
  } else {
1466
1527
  request.pending.reject(
1467
- BluetoothError(
1528
+ BluetoothSdkError(
1468
1529
  code: event.status.isEmpty ? "video_recording_failed" : event.status,
1469
1530
  message: event.details ?? "Video recording command failed."
1470
1531
  )
@@ -1484,7 +1545,7 @@ public final class MentraBluetoothSDK {
1484
1545
  }
1485
1546
  } else {
1486
1547
  request.pending.reject(
1487
- BluetoothError(
1548
+ BluetoothSdkError(
1488
1549
  code: "video_upload_failed",
1489
1550
  message: event.errorMessage ?? "Video upload failed."
1490
1551
  )
@@ -1498,7 +1559,7 @@ public final class MentraBluetoothSDK {
1498
1559
  pending.resolve(event)
1499
1560
  } else {
1500
1561
  pending.reject(
1501
- BluetoothError(
1562
+ BluetoothSdkError(
1502
1563
  code: event.errorCode ?? "rgb_led_control_failed",
1503
1564
  message: event.errorCode ?? "RGB LED command failed."
1504
1565
  )
@@ -1511,7 +1572,7 @@ public final class MentraBluetoothSDK {
1511
1572
  if isFailureStatus(event.status) {
1512
1573
  let fallbackSetting = event.setting.isEmpty ? event.requestId : event.setting
1513
1574
  pending.reject(
1514
- BluetoothError(
1575
+ BluetoothSdkError(
1515
1576
  code: event.errorCode ?? "\(event.setting.isEmpty ? "settings" : event.setting)_failed",
1516
1577
  message: event.errorMessage ?? "Settings command \(fallbackSetting) failed."
1517
1578
  )
@@ -1533,6 +1594,11 @@ public final class MentraBluetoothSDK {
1533
1594
  request.pending.resolve(results)
1534
1595
  }
1535
1596
 
1597
+ private func updateWifiScanLatestResults(_ results: [WifiScanResult]) {
1598
+ guard !results.isEmpty else { return }
1599
+ pendingWifiScan?.latestResults = results
1600
+ }
1601
+
1536
1602
  private func handleWifiStatusForRequests(_ event: WifiStatusEvent) {
1537
1603
  guard let request = pendingWifiStatus else { return }
1538
1604
  guard wifiStatusMatches(event.status, request: request) else { return }
@@ -1581,7 +1647,7 @@ public final class MentraBluetoothSDK {
1581
1647
  pendingHotspotStatus = nil
1582
1648
  }
1583
1649
  request.pending.reject(
1584
- BluetoothError(
1650
+ BluetoothSdkError(
1585
1651
  code: "hotspot_command_failed",
1586
1652
  message: event.message ?? "Hotspot command failed."
1587
1653
  )
@@ -1721,6 +1787,7 @@ public final class MentraBluetoothSDK {
1721
1787
  let networks = (data["networks"] as? [[String: Any]])?.map(WifiScanResult.init(values:)) ?? []
1722
1788
  let hasCompletionFlag = data.keys.contains("scanComplete") || data.keys.contains("scan_complete")
1723
1789
  let scanComplete = data["scanComplete"] as? Bool ?? data["scan_complete"] as? Bool ?? false
1790
+ updateWifiScanLatestResults(networks)
1724
1791
  if scanComplete || !hasCompletionFlag {
1725
1792
  handleWifiScanResultsForRequests(networks)
1726
1793
  }
@@ -1739,6 +1806,10 @@ public final class MentraBluetoothSDK {
1739
1806
  delegate?.mentraBluetoothSDK(self, didReceive: .photoResponse(event))
1740
1807
  case "photo_status":
1741
1808
  delegate?.mentraBluetoothSDK(self, didReceive: .photoStatus(PhotoStatusEvent(values: data)))
1809
+ case "camera_status":
1810
+ let event = CameraStatusEvent(values: data)
1811
+ handleCameraStatusForRequests(event)
1812
+ delegate?.mentraBluetoothSDK(self, didReceive: .cameraStatus(event))
1742
1813
  case "video_recording_status":
1743
1814
  let event = VideoRecordingStatusEvent(values: data)
1744
1815
  handleVideoRecordingStatusForRequests(event)
@@ -1761,11 +1832,6 @@ public final class MentraBluetoothSDK {
1761
1832
  if !handleStreamKeepAliveAck(event) {
1762
1833
  delegate?.mentraBluetoothSDK(self, didReceive: .keepAliveAck(event))
1763
1834
  }
1764
- case "ota_update_available":
1765
- var resultValues = data
1766
- resultValues["type"] = "ota_update_available"
1767
- pendingOtaQuery?.resolve(OtaQueryResult(values: resultValues))
1768
- delegate?.mentraBluetoothSDK(self, didReceive: .otaUpdateAvailable(OtaUpdateAvailableEvent(values: resultValues)))
1769
1835
  case "ota_start_ack":
1770
1836
  var values = data
1771
1837
  values["type"] = "ota_start_ack"
@@ -1790,7 +1856,7 @@ public final class MentraBluetoothSDK {
1790
1856
  case "pair_failure":
1791
1857
  delegate?.mentraBluetoothSDK(
1792
1858
  self,
1793
- didFail: BluetoothError(
1859
+ didFail: BluetoothSdkError(
1794
1860
  code: "pair_failure",
1795
1861
  message: data["error"] as? String ?? data.description
1796
1862
  )
@@ -2,15 +2,15 @@ import Foundation
2
2
 
3
3
  enum OtaManifestDefaults {
4
4
  private static let sdkOtaReleaseBaseUrl = "https://github.com/Mentra-Community/MentraOS/releases/download/bluetooth-sdk-ota"
5
- // Keep prod as the legacy-device fallback: pre-override ASG builds ignore
6
- // ota_start.ota_version_url and use their compiled MentraOS default.
7
- static let prodOtaVersionUrl = "https://ota.mentraglass.com/prod_live_version.json"
5
+ // ASG builds before 39 ignore ota_start.ota_version_url, so SDK checks must
6
+ // use the same legacy production manifest those glasses will install from.
7
+ static let legacyProdOtaVersionUrl = "https://ota.mentraglass.com/prod_live_version.json"
8
8
 
9
9
  static func defaultOtaVersionUrl() throws -> String {
10
10
  guard let sdkVersion = BluetoothSdkDefaults.sdkVersion,
11
11
  !sdkVersion.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
12
12
  else {
13
- throw BluetoothError(
13
+ throw BluetoothSdkError(
14
14
  code: "missing_sdk_version",
15
15
  message: "Cannot determine Bluetooth SDK version for the default OTA manifest URL."
16
16
  )
@@ -55,28 +55,28 @@ enum OtaManifestChecker {
55
55
  static func normalizeHttpUrl(_ value: String) throws -> String {
56
56
  let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
57
57
  guard !trimmed.isEmpty else {
58
- throw BluetoothError(code: "invalid_ota_url", message: "OTA version URL must be a non-empty http(s) URL.")
58
+ throw BluetoothSdkError(code: "invalid_ota_url", message: "OTA version URL must be a non-empty http(s) URL.")
59
59
  }
60
60
  guard let url = URL(string: trimmed),
61
61
  let scheme = url.scheme?.lowercased(),
62
62
  (scheme == "http" || scheme == "https"),
63
63
  url.host?.isEmpty == false
64
64
  else {
65
- throw BluetoothError(code: "invalid_ota_url", message: "OTA version URL must be a valid http(s) URL.")
65
+ throw BluetoothSdkError(code: "invalid_ota_url", message: "OTA version URL must be a valid http(s) URL.")
66
66
  }
67
67
  return url.absoluteString
68
68
  }
69
69
 
70
70
  static func fetch(_ otaVersionUrl: String) async throws -> OtaManifest {
71
71
  guard let url = URL(string: otaVersionUrl) else {
72
- throw BluetoothError(code: "invalid_ota_url", message: "OTA version URL must be a valid http(s) URL.")
72
+ throw BluetoothSdkError(code: "invalid_ota_url", message: "OTA version URL must be a valid http(s) URL.")
73
73
  }
74
74
  let (data, response) = try await URLSession.shared.data(from: url)
75
75
  guard let httpResponse = response as? HTTPURLResponse else {
76
- throw BluetoothError(code: "ota_manifest_request_failed", message: "OTA manifest request failed.")
76
+ throw BluetoothSdkError(code: "ota_manifest_request_failed", message: "OTA manifest request failed.")
77
77
  }
78
78
  guard (200 ... 299).contains(httpResponse.statusCode) else {
79
- throw BluetoothError(
79
+ throw BluetoothSdkError(
80
80
  code: "ota_manifest_request_failed",
81
81
  message: "OTA manifest request failed with HTTP \(httpResponse.statusCode)."
82
82
  )
@@ -110,18 +110,18 @@ enum OtaManifestChecker {
110
110
  if let versionCode = manifest.versionCode {
111
111
  return OtaManifestApp(versionCode: versionCode)
112
112
  }
113
- throw BluetoothError(code: "invalid_ota_manifest", message: "OTA manifest is missing ASG app versionCode.")
113
+ throw BluetoothSdkError(code: "invalid_ota_manifest", message: "OTA manifest is missing ASG app versionCode.")
114
114
  }
115
115
 
116
116
  private static func hasApkUpdate(currentBuildNumber: String, manifest: OtaManifest) throws -> Bool {
117
117
  guard let currentVersion = Int(currentBuildNumber) else {
118
- throw BluetoothError(
118
+ throw BluetoothSdkError(
119
119
  code: "invalid_glasses_version",
120
120
  message: "Cannot check OTA update because glasses build number is invalid."
121
121
  )
122
122
  }
123
123
  guard let serverVersion = try latestAppInfo(manifest).versionCode else {
124
- throw BluetoothError(code: "invalid_ota_manifest", message: "OTA manifest is missing ASG app versionCode.")
124
+ throw BluetoothSdkError(code: "invalid_ota_manifest", message: "OTA manifest is missing ASG app versionCode.")
125
125
  }
126
126
  return serverVersion > currentVersion
127
127
  }
@@ -144,7 +144,7 @@ enum OtaManifestChecker {
144
144
  private static func hasBesUpdate(besFirmware: BesFirmware?, currentVersion: String) throws -> Bool {
145
145
  guard let besFirmware else { return false }
146
146
  guard let serverVersion = besFirmware.version, !serverVersion.isEmpty else {
147
- throw BluetoothError(code: "invalid_ota_manifest", message: "OTA manifest bes_firmware.version is missing.")
147
+ throw BluetoothSdkError(code: "invalid_ota_manifest", message: "OTA manifest bes_firmware.version is missing.")
148
148
  }
149
149
  if currentVersion.isEmpty {
150
150
  return true