@mentra/bluetooth-sdk 0.1.14 → 0.1.17

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 (67) hide show
  1. package/README.md +4 -6
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +22 -9
  3. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +5 -20
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +358 -261
  5. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +3 -3
  6. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +133 -52
  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 +22 -6
  9. package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +1 -22
  10. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +50 -3
  11. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +1184 -795
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +422 -78
  13. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +3 -4
  14. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +2532 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +15 -1
  16. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +5 -3
  17. package/android/src/main/java/com/mentra/bluetoothsdk/utils/Constants.kt +2 -1
  18. package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +60 -1
  19. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoProtocolTest.kt +333 -0
  20. package/build/BluetoothSdk.types.d.ts +39 -14
  21. package/build/BluetoothSdk.types.d.ts.map +1 -1
  22. package/build/BluetoothSdk.types.js +1 -0
  23. package/build/BluetoothSdk.types.js.map +1 -1
  24. package/build/_internal.d.ts +4 -4
  25. package/build/_internal.js +4 -4
  26. package/build/_internal.js.map +1 -1
  27. package/build/_private/BluetoothSdkModule.d.ts +11 -3
  28. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  29. package/build/_private/BluetoothSdkModule.js +5 -0
  30. package/build/_private/BluetoothSdkModule.js.map +1 -1
  31. package/build/_private/cameraRequestPayload.d.ts +4 -0
  32. package/build/_private/cameraRequestPayload.d.ts.map +1 -0
  33. package/build/_private/cameraRequestPayload.js +25 -0
  34. package/build/_private/cameraRequestPayload.js.map +1 -0
  35. package/build/_private/photoRequestPayload.d.ts.map +1 -1
  36. package/build/_private/photoRequestPayload.js +8 -2
  37. package/build/_private/photoRequestPayload.js.map +1 -1
  38. package/build/index.d.ts +1 -1
  39. package/build/index.d.ts.map +1 -1
  40. package/build/index.js +72 -54
  41. package/build/index.js.map +1 -1
  42. package/ios/BluetoothSdkModule.swift +40 -12
  43. package/ios/Source/BluetoothSdkDefaults.swift +2 -30
  44. package/ios/Source/Bridge.swift +4 -16
  45. package/ios/Source/DeviceManager.swift +72 -12
  46. package/ios/Source/MentraBluetoothSDK.swift +120 -50
  47. package/ios/Source/OtaManifest.swift +13 -13
  48. package/ios/Source/camera/CameraModels.swift +75 -9
  49. package/ios/Source/errors/{BluetoothError.swift → BluetoothSdkError.swift} +1 -1
  50. package/ios/Source/events/BluetoothEvents.swift +5 -27
  51. package/ios/Source/internal/BluetoothAvailability.swift +5 -5
  52. package/ios/Source/sgcs/G1.swift +51 -1
  53. package/ios/Source/sgcs/G2.swift +643 -203
  54. package/ios/Source/sgcs/MentraLive.swift +53 -39
  55. package/ios/Source/sgcs/Nimo.swift +1928 -0
  56. package/ios/Source/sgcs/SGCManager.swift +12 -5
  57. package/ios/Source/stt/STTTools.swift +1 -1
  58. package/ios/Source/types/DeviceModels.swift +5 -0
  59. package/ios/Source/utils/Constants.swift +2 -0
  60. package/package.json +9 -1
  61. package/scripts/inject-ios-sdk-version.mjs +65 -0
  62. package/src/BluetoothSdk.types.ts +42 -17
  63. package/src/_internal.ts +4 -4
  64. package/src/_private/BluetoothSdkModule.ts +18 -2
  65. package/src/_private/cameraRequestPayload.ts +29 -0
  66. package/src/_private/photoRequestPayload.ts +9 -2
  67. package/src/index.ts +76 -58
@@ -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
  )
@@ -169,6 +170,7 @@ public final class MentraBluetoothSDK {
169
170
  private var activeStreamKeepAlive: ActiveStreamKeepAlive?
170
171
  private let analytics: BluetoothSdkAnalytics
171
172
  private var pendingPhotoRequests: [String: PendingResponse<PhotoResponseEvent>] = [:]
173
+ private var pendingCameraStatusRequests: [String: PendingResponse<CameraStatusEvent>] = [:]
172
174
  private var pendingVideoRecordingRequests: [String: PendingVideoRecordingRequest] = [:]
173
175
  private var pendingRgbLedRequests: [String: PendingResponse<RgbLedControlResponseEvent>] = [:]
174
176
  private var pendingSettingsRequests: [String: PendingResponse<SettingsAckEvent>] = [:]
@@ -235,7 +237,7 @@ public final class MentraBluetoothSDK {
235
237
 
236
238
  private func requireGlassesConnected(operation: String) throws {
237
239
  guard glassesStatus.connected else {
238
- throw BluetoothError(
240
+ throw BluetoothSdkError(
239
241
  code: "glasses_not_connected",
240
242
  message: "Cannot \(operation) because glasses are not connected."
241
243
  )
@@ -349,7 +351,7 @@ public final class MentraBluetoothSDK {
349
351
  public func connectDefault(options: ConnectOptions = ConnectOptions()) throws {
350
352
  clearBluetoothRestoreIntent()
351
353
  guard let device = currentDefaultDevice() else {
352
- throw BluetoothError(
354
+ throw BluetoothSdkError(
353
355
  code: "default_device_missing",
354
356
  message: "Set a default glasses device before calling connectDefault."
355
357
  )
@@ -594,6 +596,20 @@ public final class MentraBluetoothSDK {
594
596
  return result
595
597
  }
596
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
+
597
613
  public func setMicState(
598
614
  enabled: Bool,
599
615
  useGlassesMic: Bool = true,
@@ -639,7 +655,7 @@ public final class MentraBluetoothSDK {
639
655
 
640
656
  public func setGlassesMediaVolume(_ level: Int) async throws -> GlassesMediaVolumeSetResult {
641
657
  guard (0 ... 15).contains(level) else {
642
- throw BluetoothError(
658
+ throw BluetoothSdkError(
643
659
  code: "invalid_volume_level",
644
660
  message: "Glasses media volume must be between 0 and 15."
645
661
  )
@@ -649,13 +665,14 @@ public final class MentraBluetoothSDK {
649
665
 
650
666
  public func requestWifiScan() async throws -> [WifiScanResult] {
651
667
  guard pendingWifiScan == nil else {
652
- throw BluetoothError(
668
+ throw BluetoothSdkError(
653
669
  code: "request_in_flight",
654
670
  message: "A WiFi scan is already waiting for a glasses response."
655
671
  )
656
672
  }
657
673
  let pending = PendingResponse<[WifiScanResult]>(operation: "WiFi scan request")
658
- pendingWifiScan = PendingWifiScan(pending: pending)
674
+ let request = PendingWifiScan(pending: pending)
675
+ pendingWifiScan = request
659
676
  DeviceManager.shared.requestWifiScan()
660
677
  do {
661
678
  let results = try await pending.wait()
@@ -664,16 +681,25 @@ public final class MentraBluetoothSDK {
664
681
  }
665
682
  return results
666
683
  } catch {
684
+ let fallbackResults: [WifiScanResult]
685
+ if (error as? BluetoothSdkError)?.code == "request_timeout" {
686
+ fallbackResults = request.latestResults
687
+ } else {
688
+ fallbackResults = []
689
+ }
667
690
  if pendingWifiScan?.pending === pending {
668
691
  pendingWifiScan = nil
669
692
  }
693
+ if !fallbackResults.isEmpty {
694
+ return fallbackResults
695
+ }
670
696
  throw error
671
697
  }
672
698
  }
673
699
 
674
700
  public func sendWifiCredentials(ssid: String, password: String) async throws -> WifiStatusEvent {
675
701
  guard pendingWifiStatus == nil else {
676
- throw BluetoothError(
702
+ throw BluetoothSdkError(
677
703
  code: "request_in_flight",
678
704
  message: "A WiFi status command is already waiting for a glasses response."
679
705
  )
@@ -697,7 +723,7 @@ public final class MentraBluetoothSDK {
697
723
 
698
724
  public func forgetWifiNetwork(ssid: String) async throws -> WifiStatusEvent {
699
725
  guard pendingWifiStatus == nil else {
700
- throw BluetoothError(
726
+ throw BluetoothSdkError(
701
727
  code: "request_in_flight",
702
728
  message: "A WiFi status command is already waiting for a glasses response."
703
729
  )
@@ -721,7 +747,7 @@ public final class MentraBluetoothSDK {
721
747
 
722
748
  public func setHotspotState(enabled: Bool) async throws -> HotspotStatusEvent {
723
749
  guard pendingHotspotStatus == nil else {
724
- throw BluetoothError(
750
+ throw BluetoothSdkError(
725
751
  code: "request_in_flight",
726
752
  message: "A hotspot command is already waiting for a glasses response."
727
753
  )
@@ -750,25 +776,53 @@ public final class MentraBluetoothSDK {
750
776
  }
751
777
 
752
778
  public func requestPhoto(_ request: PhotoRequest) async throws -> PhotoResponseEvent {
779
+ let routedRequest = nonBlankRequestId(request.requestId).map { request.withRequestId($0) }
780
+ ?? request.withRequestId(generatedCameraRequestId("photo"))
753
781
  Bridge.log(
754
- "NATIVE: PHOTO PIPELINE [3b/6] MentraBluetoothSdk.requestPhoto requestId=\(request.requestId) appId=\(request.appId)"
782
+ "NATIVE: PHOTO PIPELINE [3b/6] MentraBluetoothSdk.requestPhoto requestId=\(routedRequest.requestId)"
755
783
  )
756
- let pending = PendingResponse<PhotoResponseEvent>(operation: "photo request \(request.requestId)")
757
- pendingPhotoRequests[request.requestId] = pending
758
- DeviceManager.shared.requestPhoto(request)
784
+ let pending = PendingResponse<PhotoResponseEvent>(operation: "photo request \(routedRequest.requestId)")
785
+ pendingPhotoRequests[routedRequest.requestId] = pending
786
+ DeviceManager.shared.requestPhoto(routedRequest)
787
+ do {
788
+ let event = try await pending.wait()
789
+ pendingPhotoRequests.removeValue(forKey: routedRequest.requestId)
790
+ return event
791
+ } catch {
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
759
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
+ )
760
814
  let event = try await pending.wait()
761
- pendingPhotoRequests.removeValue(forKey: request.requestId)
815
+ pendingCameraStatusRequests.removeValue(forKey: effectiveRequestId)
762
816
  return event
763
817
  } catch {
764
- pendingPhotoRequests.removeValue(forKey: request.requestId)
818
+ pendingCameraStatusRequests.removeValue(forKey: effectiveRequestId)
765
819
  throw error
766
820
  }
767
821
  }
768
822
 
769
823
  public func queryGalleryStatus() async throws -> GalleryStatusEvent {
770
824
  if pendingGalleryStatus != nil {
771
- throw BluetoothError(
825
+ throw BluetoothSdkError(
772
826
  code: "request_in_flight",
773
827
  message: "A gallery status query is already waiting for a glasses response."
774
828
  )
@@ -850,7 +904,7 @@ public final class MentraBluetoothSDK {
850
904
 
851
905
  public func stopStream() async throws -> StreamStatusEvent {
852
906
  guard pendingStreamStop == nil else {
853
- throw BluetoothError(
907
+ throw BluetoothSdkError(
854
908
  code: "request_in_flight",
855
909
  message: "A stream stop command is already waiting for a glasses response."
856
910
  )
@@ -875,14 +929,14 @@ public final class MentraBluetoothSDK {
875
929
 
876
930
  public func startVideoRecording(_ request: VideoRecordingRequest) async throws -> VideoRecordingStatusEvent {
877
931
  guard !request.requestId.isEmpty else {
878
- 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.")
879
933
  }
880
934
  try requireGlassesConnected(operation: "start video recording")
881
935
  let pending = PendingResponse<VideoRecordingStatusEvent>(
882
936
  operation: "start video recording \(request.requestId)"
883
937
  )
884
938
  guard pendingVideoRecordingRequests[request.requestId] == nil else {
885
- throw BluetoothError(
939
+ throw BluetoothSdkError(
886
940
  code: "request_in_flight",
887
941
  message: "A video recording command is already waiting for requestId \(request.requestId)."
888
942
  )
@@ -914,12 +968,12 @@ public final class MentraBluetoothSDK {
914
968
  requestId: String, webhookUrl: String? = nil, authToken: String? = nil
915
969
  ) async throws -> VideoRecordingStatusEvent {
916
970
  guard !requestId.isEmpty else {
917
- 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.")
918
972
  }
919
973
  try requireGlassesConnected(operation: "stop video recording")
920
974
  let pending = PendingResponse<VideoRecordingStatusEvent>(operation: "stop video recording \(requestId)")
921
975
  guard pendingVideoRecordingRequests[requestId] == nil else {
922
- throw BluetoothError(
976
+ throw BluetoothSdkError(
923
977
  code: "request_in_flight",
924
978
  message: "A video recording command is already waiting for requestId \(requestId)."
925
979
  )
@@ -944,7 +998,7 @@ public final class MentraBluetoothSDK {
944
998
 
945
999
  public func requestVersionInfo() async throws -> VersionInfoResult {
946
1000
  guard pendingVersionInfo == nil else {
947
- throw BluetoothError(
1001
+ throw BluetoothSdkError(
948
1002
  code: "request_in_flight",
949
1003
  message: "A version info request is already waiting for a glasses response."
950
1004
  )
@@ -978,13 +1032,13 @@ public final class MentraBluetoothSDK {
978
1032
  public func checkForOtaUpdate() async throws -> Bool {
979
1033
  let status = await getFreshGlassesStatus()
980
1034
  guard status.connected else {
981
- throw BluetoothError(
1035
+ throw BluetoothSdkError(
982
1036
  code: "glasses_not_connected",
983
1037
  message: "Cannot check OTA update because glasses are not connected."
984
1038
  )
985
1039
  }
986
1040
  guard !status.buildNumber.isEmpty else {
987
- throw BluetoothError(
1041
+ throw BluetoothSdkError(
988
1042
  code: "missing_glasses_version",
989
1043
  message: "Cannot check OTA update because glasses build number is unavailable."
990
1044
  )
@@ -1013,7 +1067,7 @@ public final class MentraBluetoothSDK {
1013
1067
  sendRequest: () -> Void
1014
1068
  ) async throws -> OtaQueryResult {
1015
1069
  if pendingOtaQuery != nil {
1016
- throw BluetoothError(
1070
+ throw BluetoothSdkError(
1017
1071
  code: "request_in_flight",
1018
1072
  message: "An OTA status query is already waiting for a glasses response."
1019
1073
  )
@@ -1044,7 +1098,7 @@ public final class MentraBluetoothSDK {
1044
1098
 
1045
1099
  private func startOtaCommand(otaVersionUrl: String) async throws -> OtaStartAckEvent {
1046
1100
  if pendingOtaStart != nil {
1047
- throw BluetoothError(
1101
+ throw BluetoothSdkError(
1048
1102
  code: "request_in_flight",
1049
1103
  message: "An OTA start command is already waiting for a glasses response."
1050
1104
  )
@@ -1072,13 +1126,6 @@ public final class MentraBluetoothSDK {
1072
1126
 
1073
1127
  func sendOtaQueryStatus() async throws -> OtaQueryResult { try await queryOtaStatus() }
1074
1128
 
1075
- /// Re-run the glasses-side OTA version check after an internal clock-skew recovery.
1076
- func retryOtaVersionCheck() async throws -> OtaQueryResult {
1077
- try await performOtaQuery(operation: "OTA version retry") {
1078
- DeviceManager.shared.retryOtaVersionCheck()
1079
- }
1080
- }
1081
-
1082
1129
  private func getFreshGlassesStatus() async -> GlassesStatus {
1083
1130
  let status = glassesStatus
1084
1131
  if !status.connected || !status.buildNumber.isEmpty {
@@ -1114,7 +1161,7 @@ public final class MentraBluetoothSDK {
1114
1161
  }
1115
1162
 
1116
1163
  guard status.connected else {
1117
- throw BluetoothError(
1164
+ throw BluetoothSdkError(
1118
1165
  code: "glasses_not_connected",
1119
1166
  message: "Cannot check OTA update because glasses disconnected."
1120
1167
  )
@@ -1148,7 +1195,7 @@ public final class MentraBluetoothSDK {
1148
1195
  private func resolveOtaVersionUrl(status: GlassesStatus) throws -> String {
1149
1196
  let deviceUrl = status.otaVersionUrl.trimmingCharacters(in: .whitespacesAndNewlines)
1150
1197
  if isLegacyAsgOtaStartBuild(status.buildNumber) {
1151
- return deviceUrl.isEmpty ? OtaManifestDefaults.prodOtaVersionUrl : deviceUrl
1198
+ return OtaManifestDefaults.legacyProdOtaVersionUrl
1152
1199
  }
1153
1200
  // SDK consumers are pinned to the manifest built for their SDK version.
1154
1201
  // A future glasses-advertised URL should not silently change that pairing.
@@ -1160,7 +1207,7 @@ public final class MentraBluetoothSDK {
1160
1207
 
1161
1208
  private func isLegacyAsgOtaStartBuild(_ buildNumber: String) -> Bool {
1162
1209
  guard let parsed = Int(buildNumber) else { return false }
1163
- return parsed < 100_000
1210
+ return parsed < 39
1164
1211
  }
1165
1212
 
1166
1213
  func sendShutdown() {
@@ -1420,14 +1467,14 @@ public final class MentraBluetoothSDK {
1420
1467
  )
1421
1468
  }
1422
1469
 
1423
- private func streamStatusError(_ event: StreamStatusEvent, code: String) -> BluetoothError {
1470
+ private func streamStatusError(_ event: StreamStatusEvent, code: String) -> BluetoothSdkError {
1424
1471
  let message: String
1425
1472
  if case let .error(_, errorDetails, _, _) = event.status {
1426
1473
  message = errorDetails
1427
1474
  } else {
1428
1475
  message = "Stream status \(event.state.rawValue)"
1429
1476
  }
1430
- return BluetoothError(code: code, message: message)
1477
+ return BluetoothSdkError(code: code, message: message)
1431
1478
  }
1432
1479
 
1433
1480
  private func handlePhotoResponseForRequests(_ event: PhotoResponseEvent) {
@@ -1437,7 +1484,7 @@ public final class MentraBluetoothSDK {
1437
1484
  pending.resolve(event)
1438
1485
  case let .error(_, errorCode, errorMessage, _):
1439
1486
  pending.reject(
1440
- BluetoothError(
1487
+ BluetoothSdkError(
1441
1488
  code: errorCode ?? "photo_request_failed",
1442
1489
  message: errorMessage
1443
1490
  )
@@ -1445,6 +1492,24 @@ public final class MentraBluetoothSDK {
1445
1492
  }
1446
1493
  }
1447
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
+
1448
1513
  private func handleVideoRecordingStatusForRequests(_ event: VideoRecordingStatusEvent) {
1449
1514
  guard let request = pendingVideoRecordingRequests[event.requestId] else { return }
1450
1515
  if event.success {
@@ -1460,7 +1525,7 @@ public final class MentraBluetoothSDK {
1460
1525
  }
1461
1526
  } else {
1462
1527
  request.pending.reject(
1463
- BluetoothError(
1528
+ BluetoothSdkError(
1464
1529
  code: event.status.isEmpty ? "video_recording_failed" : event.status,
1465
1530
  message: event.details ?? "Video recording command failed."
1466
1531
  )
@@ -1480,7 +1545,7 @@ public final class MentraBluetoothSDK {
1480
1545
  }
1481
1546
  } else {
1482
1547
  request.pending.reject(
1483
- BluetoothError(
1548
+ BluetoothSdkError(
1484
1549
  code: "video_upload_failed",
1485
1550
  message: event.errorMessage ?? "Video upload failed."
1486
1551
  )
@@ -1494,7 +1559,7 @@ public final class MentraBluetoothSDK {
1494
1559
  pending.resolve(event)
1495
1560
  } else {
1496
1561
  pending.reject(
1497
- BluetoothError(
1562
+ BluetoothSdkError(
1498
1563
  code: event.errorCode ?? "rgb_led_control_failed",
1499
1564
  message: event.errorCode ?? "RGB LED command failed."
1500
1565
  )
@@ -1507,7 +1572,7 @@ public final class MentraBluetoothSDK {
1507
1572
  if isFailureStatus(event.status) {
1508
1573
  let fallbackSetting = event.setting.isEmpty ? event.requestId : event.setting
1509
1574
  pending.reject(
1510
- BluetoothError(
1575
+ BluetoothSdkError(
1511
1576
  code: event.errorCode ?? "\(event.setting.isEmpty ? "settings" : event.setting)_failed",
1512
1577
  message: event.errorMessage ?? "Settings command \(fallbackSetting) failed."
1513
1578
  )
@@ -1529,6 +1594,11 @@ public final class MentraBluetoothSDK {
1529
1594
  request.pending.resolve(results)
1530
1595
  }
1531
1596
 
1597
+ private func updateWifiScanLatestResults(_ results: [WifiScanResult]) {
1598
+ guard !results.isEmpty else { return }
1599
+ pendingWifiScan?.latestResults = results
1600
+ }
1601
+
1532
1602
  private func handleWifiStatusForRequests(_ event: WifiStatusEvent) {
1533
1603
  guard let request = pendingWifiStatus else { return }
1534
1604
  guard wifiStatusMatches(event.status, request: request) else { return }
@@ -1577,7 +1647,7 @@ public final class MentraBluetoothSDK {
1577
1647
  pendingHotspotStatus = nil
1578
1648
  }
1579
1649
  request.pending.reject(
1580
- BluetoothError(
1650
+ BluetoothSdkError(
1581
1651
  code: "hotspot_command_failed",
1582
1652
  message: event.message ?? "Hotspot command failed."
1583
1653
  )
@@ -1717,6 +1787,7 @@ public final class MentraBluetoothSDK {
1717
1787
  let networks = (data["networks"] as? [[String: Any]])?.map(WifiScanResult.init(values:)) ?? []
1718
1788
  let hasCompletionFlag = data.keys.contains("scanComplete") || data.keys.contains("scan_complete")
1719
1789
  let scanComplete = data["scanComplete"] as? Bool ?? data["scan_complete"] as? Bool ?? false
1790
+ updateWifiScanLatestResults(networks)
1720
1791
  if scanComplete || !hasCompletionFlag {
1721
1792
  handleWifiScanResultsForRequests(networks)
1722
1793
  }
@@ -1735,6 +1806,10 @@ public final class MentraBluetoothSDK {
1735
1806
  delegate?.mentraBluetoothSDK(self, didReceive: .photoResponse(event))
1736
1807
  case "photo_status":
1737
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))
1738
1813
  case "video_recording_status":
1739
1814
  let event = VideoRecordingStatusEvent(values: data)
1740
1815
  handleVideoRecordingStatusForRequests(event)
@@ -1757,11 +1832,6 @@ public final class MentraBluetoothSDK {
1757
1832
  if !handleStreamKeepAliveAck(event) {
1758
1833
  delegate?.mentraBluetoothSDK(self, didReceive: .keepAliveAck(event))
1759
1834
  }
1760
- case "ota_update_available":
1761
- var resultValues = data
1762
- resultValues["type"] = "ota_update_available"
1763
- pendingOtaQuery?.resolve(OtaQueryResult(values: resultValues))
1764
- delegate?.mentraBluetoothSDK(self, didReceive: .otaUpdateAvailable(OtaUpdateAvailableEvent(values: resultValues)))
1765
1835
  case "ota_start_ack":
1766
1836
  var values = data
1767
1837
  values["type"] = "ota_start_ack"
@@ -1786,7 +1856,7 @@ public final class MentraBluetoothSDK {
1786
1856
  case "pair_failure":
1787
1857
  delegate?.mentraBluetoothSDK(
1788
1858
  self,
1789
- didFail: BluetoothError(
1859
+ didFail: BluetoothSdkError(
1790
1860
  code: "pair_failure",
1791
1861
  message: data["error"] as? String ?? data.description
1792
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