@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
@@ -142,7 +142,7 @@ object DeviceStore {
142
142
  store.set("bluetooth", "use_native_dashboard", false)
143
143
  // Mentra Nex feature flags (off by default; toggled from Nex Developer Settings):
144
144
  store.set("bluetooth", "nex_chinese_captions", false)
145
- store.set("bluetooth", "nex_audio_playback", false)
145
+ store.set("bluetooth", "nex_lc3_audio_playback", false)
146
146
  }
147
147
 
148
148
  fun get(category: String, key: String): Any? {
@@ -268,9 +268,9 @@ object DeviceStore {
268
268
  "bluetooth" to "voice_activity_detection_enabled" -> {
269
269
  DeviceManager.getInstance().sgc?.sendVoiceActivityDetectionSetting()
270
270
  }
271
- "bluetooth" to "nex_audio_playback" -> {
271
+ "bluetooth" to "nex_lc3_audio_playback" -> {
272
272
  (value as? Boolean)?.let { enabled ->
273
- Bridge.log("DeviceStore: nex_audio_playback changed to $enabled")
273
+ Bridge.log("DeviceStore: nex_lc3_audio_playback changed to $enabled")
274
274
  DeviceManager.getInstance().sgc?.applyNexAudioPlaybackSetting()
275
275
  }
276
276
  }
@@ -31,6 +31,7 @@ class MentraBluetoothSdk private constructor(
31
31
  private val streamKeepAliveLock = Any()
32
32
  private var activeStreamKeepAlive: ActiveStreamKeepAlive? = null
33
33
  private val pendingPhotoRequests = ConcurrentHashMap<String, PendingResponse<PhotoResponseEvent>>()
34
+ private val pendingCameraStatusRequests = ConcurrentHashMap<String, PendingResponse<CameraStatusEvent>>()
34
35
  private val pendingVideoRecordingRequests =
35
36
  ConcurrentHashMap<String, PendingVideoRecordingRequest>()
36
37
  private val pendingRgbLedRequests = ConcurrentHashMap<String, PendingResponse<RgbLedControlResponseEvent>>()
@@ -116,6 +117,7 @@ class MentraBluetoothSdk private constructor(
116
117
 
117
118
  private data class PendingWifiScan(
118
119
  val pending: PendingResponse<List<WifiScanResult>>,
120
+ var latestResults: List<WifiScanResult> = emptyList(),
119
121
  )
120
122
 
121
123
  private data class PendingWifiStatusRequest(
@@ -158,13 +160,13 @@ class MentraBluetoothSdk private constructor(
158
160
 
159
161
  fun await(timeoutMs: Long = DEFAULT_REQUEST_TIMEOUT_MS): T {
160
162
  if (!latch.await(timeoutMs, TimeUnit.MILLISECONDS)) {
161
- throw BluetoothException(
163
+ throw BluetoothSdkException(
162
164
  "request_timeout",
163
165
  "$operation timed out waiting for glasses response.",
164
166
  )
165
167
  }
166
168
  error?.let { throw it }
167
- return result ?: throw BluetoothException(
169
+ return result ?: throw BluetoothSdkException(
168
170
  "empty_response",
169
171
  "$operation completed without a response payload.",
170
172
  )
@@ -201,7 +203,7 @@ class MentraBluetoothSdk private constructor(
201
203
 
202
204
  private fun requireGlassesConnected(operation: String) {
203
205
  if (!getRawGlassesStatus().connected) {
204
- throw BluetoothException(
206
+ throw BluetoothSdkException(
205
207
  "glasses_not_connected",
206
208
  "Cannot $operation because glasses are not connected.",
207
209
  )
@@ -353,7 +355,7 @@ class MentraBluetoothSdk private constructor(
353
355
  fun connectDefault(options: ConnectOptions = ConnectOptions()) {
354
356
  val defaultDevice =
355
357
  currentDefaultDevice()
356
- ?: throw BluetoothException(
358
+ ?: throw BluetoothSdkException(
357
359
  "default_device_missing",
358
360
  "Set a default glasses device before calling connectDefault.",
359
361
  )
@@ -564,6 +566,23 @@ class MentraBluetoothSdk private constructor(
564
566
  return result
565
567
  }
566
568
 
569
+ /**
570
+ * Configure camera HAL tuning on Mentra Live glasses.
571
+ *
572
+ * The phone sends a {@code camera_tuning_config} BLE message; the ASG client relays it as a
573
+ * {@code camconfig} broadcast to SystemUI's CTReceiver so the camera HAL picks up the new
574
+ * parameters without a reboot.
575
+ *
576
+ * @param anrOn {@code true} = ANR enabled, {@code false} = ANR disabled (pixsmart param)
577
+ * @param gainOn {@code true} = stock gain params, {@code false} = pixsmart gain-off params
578
+ */
579
+ fun setCameraTuningConfig(anrOn: Boolean, gainOn: Boolean): SettingsAckEvent =
580
+ performSettingsCommand(
581
+ setting = "camera_tuning",
582
+ updateStore = { _ -> },
583
+ send = { requestId -> deviceManager.sendCameraTuningConfig(requestId, anrOn, gainOn) },
584
+ )
585
+
567
586
  fun setMicState(
568
587
  enabled: Boolean,
569
588
  useGlassesMic: Boolean = true,
@@ -613,18 +632,37 @@ class MentraBluetoothSdk private constructor(
613
632
 
614
633
  fun requestWifiScan(): List<WifiScanResult> {
615
634
  val pending = PendingResponse<List<WifiScanResult>>("WiFi scan request")
635
+ val request = PendingWifiScan(pending)
616
636
  synchronized(oneShotLock) {
617
637
  if (pendingWifiScan != null) {
618
- throw BluetoothException(
638
+ throw BluetoothSdkException(
619
639
  "request_in_flight",
620
640
  "A WiFi scan is already waiting for a glasses response.",
621
641
  )
622
642
  }
623
- pendingWifiScan = PendingWifiScan(pending)
643
+ pendingWifiScan = request
624
644
  }
625
645
  try {
626
646
  deviceManager.requestWifiScan()
627
647
  return pending.await()
648
+ } catch (error: BluetoothSdkException) {
649
+ if (error.code == "request_timeout") {
650
+ val fallbackResults =
651
+ synchronized(oneShotLock) {
652
+ if (request.latestResults.isNotEmpty()) {
653
+ if (pendingWifiScan === request) {
654
+ pendingWifiScan = null
655
+ }
656
+ request.latestResults
657
+ } else {
658
+ emptyList()
659
+ }
660
+ }
661
+ if (fallbackResults.isNotEmpty()) {
662
+ return fallbackResults
663
+ }
664
+ }
665
+ throw error
628
666
  } finally {
629
667
  synchronized(oneShotLock) {
630
668
  if (pendingWifiScan?.pending === pending) {
@@ -638,7 +676,7 @@ class MentraBluetoothSdk private constructor(
638
676
  val pending = PendingResponse<WifiStatusEvent>("WiFi connect request")
639
677
  synchronized(oneShotLock) {
640
678
  if (pendingWifiStatus != null) {
641
- throw BluetoothException(
679
+ throw BluetoothSdkException(
642
680
  "request_in_flight",
643
681
  "A WiFi status command is already waiting for a glasses response.",
644
682
  )
@@ -661,7 +699,7 @@ class MentraBluetoothSdk private constructor(
661
699
  val pending = PendingResponse<WifiStatusEvent>("WiFi forget request")
662
700
  synchronized(oneShotLock) {
663
701
  if (pendingWifiStatus != null) {
664
- throw BluetoothException(
702
+ throw BluetoothSdkException(
665
703
  "request_in_flight",
666
704
  "A WiFi status command is already waiting for a glasses response.",
667
705
  )
@@ -684,7 +722,7 @@ class MentraBluetoothSdk private constructor(
684
722
  val pending = PendingResponse<HotspotStatusEvent>("hotspot ${if (enabled) "enable" else "disable"} request")
685
723
  synchronized(oneShotLock) {
686
724
  if (pendingHotspotStatus != null) {
687
- throw BluetoothException(
725
+ throw BluetoothSdkException(
688
726
  "request_in_flight",
689
727
  "A hotspot command is already waiting for a glasses response.",
690
728
  )
@@ -708,16 +746,44 @@ class MentraBluetoothSdk private constructor(
708
746
  }
709
747
 
710
748
  fun requestPhoto(request: PhotoRequest): PhotoResponseEvent {
749
+ val routedRequest =
750
+ nonBlankRequestId(request.requestId)
751
+ ?.let { if (it == request.requestId) request else request.copy(requestId = it) }
752
+ ?: request.copy(requestId = generatedCameraRequestId("photo"))
711
753
  Bridge.log(
712
- "NATIVE: PHOTO PIPELINE [3b/6] MentraBluetoothSdk.requestPhoto requestId=${request.requestId} appId=${request.appId}"
754
+ "NATIVE: PHOTO PIPELINE [3b/6] MentraBluetoothSdk.requestPhoto requestId=${routedRequest.requestId}"
713
755
  )
714
- val pending = PendingResponse<PhotoResponseEvent>("photo request ${request.requestId}")
715
- pendingPhotoRequests[request.requestId] = pending
756
+ val pending = PendingResponse<PhotoResponseEvent>("photo request ${routedRequest.requestId}")
757
+ pendingPhotoRequests[routedRequest.requestId] = pending
758
+ try {
759
+ deviceManager.requestPhoto(routedRequest)
760
+ return pending.await()
761
+ } finally {
762
+ pendingPhotoRequests.remove(routedRequest.requestId, pending)
763
+ }
764
+ }
765
+
766
+ fun warmUpCamera(
767
+ requestId: String? = null,
768
+ size: PhotoSize,
769
+ exposureTimeNs: Long?,
770
+ durationMs: Int,
771
+ ): CameraStatusEvent {
772
+ val effectiveRequestId = nonBlankRequestId(requestId) ?: generatedCameraRequestId("warm")
773
+ val pending = PendingResponse<CameraStatusEvent>("camera warm up $effectiveRequestId")
774
+ // Guard against a concurrent warm-up reusing the same requestId: a blind put would overwrite
775
+ // (and strand) the first caller's continuation until it times out. Reject the duplicate.
776
+ if (pendingCameraStatusRequests.putIfAbsent(effectiveRequestId, pending) != null) {
777
+ throw BluetoothSdkException(
778
+ "request_in_flight",
779
+ "A camera warm-up request with this requestId is already waiting for a glasses response.",
780
+ )
781
+ }
716
782
  try {
717
- deviceManager.requestPhoto(request)
783
+ deviceManager.warmUpCamera(effectiveRequestId, size, exposureTimeNs, durationMs)
718
784
  return pending.await()
719
785
  } finally {
720
- pendingPhotoRequests.remove(request.requestId, pending)
786
+ pendingCameraStatusRequests.remove(effectiveRequestId, pending)
721
787
  }
722
788
  }
723
789
 
@@ -725,7 +791,7 @@ class MentraBluetoothSdk private constructor(
725
791
  val pending = PendingResponse<GalleryStatusEvent>("gallery status query")
726
792
  synchronized(oneShotLock) {
727
793
  if (pendingGalleryStatus != null) {
728
- throw BluetoothException(
794
+ throw BluetoothSdkException(
729
795
  "request_in_flight",
730
796
  "A gallery status query is already waiting for a glasses response.",
731
797
  )
@@ -803,7 +869,7 @@ class MentraBluetoothSdk private constructor(
803
869
  }
804
870
  synchronized(oneShotLock) {
805
871
  if (pendingStreamStop != null) {
806
- throw BluetoothException(
872
+ throw BluetoothSdkException(
807
873
  "request_in_flight",
808
874
  "A stream stop command is already waiting for a glasses response.",
809
875
  )
@@ -829,7 +895,7 @@ class MentraBluetoothSdk private constructor(
829
895
  val pending = PendingResponse<VideoRecordingStatusEvent>("start video recording")
830
896
  val pendingRequest = PendingVideoRecordingRequest("recording_started", pending)
831
897
  if (pendingVideoRecordingRequests.putIfAbsent(request.requestId, pendingRequest) != null) {
832
- throw BluetoothException(
898
+ throw BluetoothSdkException(
833
899
  "request_in_flight",
834
900
  "A video recording command is already waiting for requestId ${request.requestId}.",
835
901
  )
@@ -867,7 +933,7 @@ class MentraBluetoothSdk private constructor(
867
933
  waitForUpload = waitForUpload,
868
934
  )
869
935
  if (pendingVideoRecordingRequests.putIfAbsent(requestId, pendingRequest) != null) {
870
- throw BluetoothException(
936
+ throw BluetoothSdkException(
871
937
  "request_in_flight",
872
938
  "A video recording command is already waiting for requestId $requestId.",
873
939
  )
@@ -886,7 +952,7 @@ class MentraBluetoothSdk private constructor(
886
952
  val pending = PendingResponse<VersionInfoResult>("version info request")
887
953
  synchronized(oneShotLock) {
888
954
  if (pendingVersionInfo != null) {
889
- throw BluetoothException(
955
+ throw BluetoothSdkException(
890
956
  "request_in_flight",
891
957
  "A version info request is already waiting for a glasses response.",
892
958
  )
@@ -915,13 +981,13 @@ class MentraBluetoothSdk private constructor(
915
981
  fun checkForOtaUpdate(): Boolean {
916
982
  val status = getFreshGlassesStatus()
917
983
  if (!status.connected) {
918
- throw BluetoothException(
984
+ throw BluetoothSdkException(
919
985
  "glasses_not_connected",
920
986
  "Cannot check OTA update because glasses are not connected.",
921
987
  )
922
988
  }
923
989
  if (status.buildNumber.isBlank()) {
924
- throw BluetoothException(
990
+ throw BluetoothSdkException(
925
991
  "missing_glasses_version",
926
992
  "Cannot check OTA update because glasses build number is unavailable.",
927
993
  )
@@ -951,7 +1017,7 @@ class MentraBluetoothSdk private constructor(
951
1017
  val pending = PendingResponse<OtaQueryResult>(operation)
952
1018
  synchronized(oneShotLock) {
953
1019
  if (pendingOtaQuery != null) {
954
- throw BluetoothException(
1020
+ throw BluetoothSdkException(
955
1021
  "request_in_flight",
956
1022
  "An OTA status query is already waiting for a glasses response.",
957
1023
  )
@@ -980,7 +1046,7 @@ class MentraBluetoothSdk private constructor(
980
1046
  val pending = PendingResponse<OtaStartAckEvent>("OTA start command")
981
1047
  synchronized(oneShotLock) {
982
1048
  if (pendingOtaStart != null) {
983
- throw BluetoothException(
1049
+ throw BluetoothSdkException(
984
1050
  "request_in_flight",
985
1051
  "An OTA start command is already waiting for a glasses response.",
986
1052
  )
@@ -1004,13 +1070,6 @@ class MentraBluetoothSdk private constructor(
1004
1070
 
1005
1071
  internal fun sendOtaQueryStatus(): OtaQueryResult = queryOtaStatus()
1006
1072
 
1007
- /** Re-run the glasses-side OTA version check after an internal clock-skew recovery. */
1008
- @JvmSynthetic
1009
- internal fun retryOtaVersionCheck(): OtaQueryResult =
1010
- performOtaQuery("OTA version retry") {
1011
- deviceManager.retryOtaVersionCheck()
1012
- }
1013
-
1014
1073
  private fun getFreshGlassesStatus(): GlassesStatus {
1015
1074
  val status = getRawGlassesStatus()
1016
1075
  if (!status.connected || status.buildNumber.isNotBlank()) {
@@ -1052,7 +1111,7 @@ class MentraBluetoothSdk private constructor(
1052
1111
  }
1053
1112
 
1054
1113
  if (!status.connected) {
1055
- throw BluetoothException(
1114
+ throw BluetoothSdkException(
1056
1115
  "glasses_not_connected",
1057
1116
  "Cannot check OTA update because glasses disconnected.",
1058
1117
  )
@@ -1080,7 +1139,7 @@ class MentraBluetoothSdk private constructor(
1080
1139
  private fun resolveOtaVersionUrl(status: GlassesStatus): String {
1081
1140
  val deviceUrl = status.otaVersionUrl.trim()
1082
1141
  if (isLegacyAsgOtaStartBuild(status.buildNumber)) {
1083
- return deviceUrl.ifBlank { OtaManifestDefaults.PROD_OTA_VERSION_URL }
1142
+ return OtaManifestDefaults.LEGACY_PROD_OTA_VERSION_URL
1084
1143
  }
1085
1144
  // SDK consumers are pinned to the manifest built for their SDK version.
1086
1145
  // A future glasses-advertised URL should not silently change that pairing.
@@ -1089,7 +1148,7 @@ class MentraBluetoothSdk private constructor(
1089
1148
 
1090
1149
  private fun isLegacyAsgOtaStartBuild(buildNumber: String): Boolean {
1091
1150
  val parsed = buildNumber.toIntOrNull()
1092
- return parsed != null && parsed < 100_000
1151
+ return parsed != null && parsed < 39
1093
1152
  }
1094
1153
 
1095
1154
  internal fun sendShutdown() {
@@ -1162,7 +1221,7 @@ class MentraBluetoothSdk private constructor(
1162
1221
  val bluetoothManager = appContext.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager
1163
1222
  val adapter =
1164
1223
  bluetoothManager?.adapter
1165
- ?: throw BluetoothException(
1224
+ ?: throw BluetoothSdkException(
1166
1225
  "bluetooth_unsupported",
1167
1226
  "This phone does not support Bluetooth.",
1168
1227
  )
@@ -1170,14 +1229,14 @@ class MentraBluetoothSdk private constructor(
1170
1229
  try {
1171
1230
  adapter.isEnabled
1172
1231
  } catch (error: SecurityException) {
1173
- throw BluetoothException(
1232
+ throw BluetoothSdkException(
1174
1233
  "bluetooth_permission_denied",
1175
1234
  "Allow Bluetooth permission to $operation.",
1176
1235
  error,
1177
1236
  )
1178
1237
  }
1179
1238
  if (!enabled) {
1180
- throw BluetoothException(
1239
+ throw BluetoothSdkException(
1181
1240
  "bluetooth_powered_off",
1182
1241
  "Turn on phone Bluetooth to $operation.",
1183
1242
  )
@@ -1185,7 +1244,7 @@ class MentraBluetoothSdk private constructor(
1185
1244
  }
1186
1245
 
1187
1246
  private fun Throwable.toBluetoothError(defaultCode: String): BluetoothError =
1188
- if (this is BluetoothException) {
1247
+ if (this is BluetoothSdkException) {
1189
1248
  BluetoothError(code, message ?: code, this)
1190
1249
  } else {
1191
1250
  BluetoothError(defaultCode, message ?: toString(), this)
@@ -1272,6 +1331,7 @@ class MentraBluetoothSdk private constructor(
1272
1331
  data.containsKey("scanComplete") || data.containsKey("scan_complete")
1273
1332
  val scanComplete =
1274
1333
  (data["scanComplete"] as? Boolean) ?: (data["scan_complete"] as? Boolean) ?: false
1334
+ updateWifiScanLatestResults(networks)
1275
1335
  if (scanComplete || !hasCompletionFlag) {
1276
1336
  handleWifiScanResultsForRequests(networks)
1277
1337
  }
@@ -1300,6 +1360,11 @@ class MentraBluetoothSdk private constructor(
1300
1360
  dispatchToListeners { it.onPhotoResponse(event) }
1301
1361
  }
1302
1362
  "photo_status" -> dispatchToListeners { it.onPhotoStatus(PhotoStatusEvent(data)) }
1363
+ "camera_status" -> {
1364
+ val event = CameraStatusEvent(data)
1365
+ handleCameraStatusForRequests(event)
1366
+ dispatchToListeners { it.onCameraStatus(event) }
1367
+ }
1303
1368
  "video_recording_status" -> {
1304
1369
  val event = VideoRecordingStatusEvent(data)
1305
1370
  handleVideoRecordingStatusForRequests(event)
@@ -1327,13 +1392,6 @@ class MentraBluetoothSdk private constructor(
1327
1392
  dispatchToListeners { it.onKeepAliveAck(event) }
1328
1393
  }
1329
1394
  }
1330
- "ota_update_available" -> {
1331
- val resultValues = data + mapOf("type" to "ota_update_available")
1332
- synchronized(oneShotLock) {
1333
- pendingOtaQuery?.resolve(OtaQueryResult(resultValues))
1334
- }
1335
- dispatchToListeners { it.onOtaUpdateAvailable(OtaUpdateAvailableEvent.fromMap(resultValues)) }
1336
- }
1337
1395
  "ota_start_ack" -> {
1338
1396
  val event = OtaStartAckEvent.fromMap(data + mapOf("type" to "ota_start_ack"))
1339
1397
  synchronized(oneShotLock) {
@@ -1523,10 +1581,10 @@ class MentraBluetoothSdk private constructor(
1523
1581
  )
1524
1582
  )
1525
1583
 
1526
- private fun streamStatusException(event: StreamStatusEvent, code: String): BluetoothException {
1584
+ private fun streamStatusException(event: StreamStatusEvent, code: String): BluetoothSdkException {
1527
1585
  val details = (event.status as? StreamStatus.Error)?.errorDetails
1528
1586
  ?: "Stream status ${event.state.value}"
1529
- return BluetoothException(code, details)
1587
+ return BluetoothSdkException(code, details)
1530
1588
  }
1531
1589
 
1532
1590
  private fun handlePhotoResponseForRequests(event: PhotoResponseEvent) {
@@ -1535,7 +1593,7 @@ class MentraBluetoothSdk private constructor(
1535
1593
  is PhotoResponse.Success -> pending.resolve(event)
1536
1594
  is PhotoResponse.Error ->
1537
1595
  pending.reject(
1538
- BluetoothException(
1596
+ BluetoothSdkException(
1539
1597
  response.errorCode ?: "photo_request_failed",
1540
1598
  response.errorMessage,
1541
1599
  )
@@ -1543,6 +1601,22 @@ class MentraBluetoothSdk private constructor(
1543
1601
  }
1544
1602
  }
1545
1603
 
1604
+ private fun handleCameraStatusForRequests(event: CameraStatusEvent) {
1605
+ val pending = pendingCameraStatusRequests[event.requestId] ?: return
1606
+ when (event.state.lowercase()) {
1607
+ "ready" -> pending.resolve(event)
1608
+ "error" ->
1609
+ pending.reject(
1610
+ BluetoothSdkException(
1611
+ event.errorCode ?: "camera_warm_up_failed",
1612
+ event.errorMessage ?: "Camera warm-up failed.",
1613
+ )
1614
+ )
1615
+ // "warming"/"stopped" are progress updates; leave the pending promise alone.
1616
+ else -> {}
1617
+ }
1618
+ }
1619
+
1546
1620
  private fun handleVideoRecordingStatusForRequests(event: VideoRecordingStatusEvent) {
1547
1621
  val request = pendingVideoRecordingRequests[event.requestId] ?: return
1548
1622
  if (event.success) {
@@ -1558,7 +1632,7 @@ class MentraBluetoothSdk private constructor(
1558
1632
  }
1559
1633
  } else {
1560
1634
  request.pending.reject(
1561
- BluetoothException(
1635
+ BluetoothSdkException(
1562
1636
  event.status.ifBlank { "video_recording_failed" },
1563
1637
  event.details ?: "Video recording command failed.",
1564
1638
  )
@@ -1579,7 +1653,7 @@ class MentraBluetoothSdk private constructor(
1579
1653
  }
1580
1654
  } else {
1581
1655
  request.pending.reject(
1582
- BluetoothException(
1656
+ BluetoothSdkException(
1583
1657
  "video_upload_failed",
1584
1658
  event.errorMessage ?: "Video upload failed.",
1585
1659
  )
@@ -1593,7 +1667,7 @@ class MentraBluetoothSdk private constructor(
1593
1667
  pending.resolve(event)
1594
1668
  } else {
1595
1669
  pending.reject(
1596
- BluetoothException(
1670
+ BluetoothSdkException(
1597
1671
  event.errorCode ?: "rgb_led_control_failed",
1598
1672
  event.errorCode ?: "RGB LED command failed.",
1599
1673
  )
@@ -1605,7 +1679,7 @@ class MentraBluetoothSdk private constructor(
1605
1679
  val pending = pendingSettingsRequests[event.requestId] ?: return
1606
1680
  if (isFailureStatus(event.status)) {
1607
1681
  pending.reject(
1608
- BluetoothException(
1682
+ BluetoothSdkException(
1609
1683
  event.errorCode ?: "${event.setting.ifBlank { "settings" }}_failed",
1610
1684
  event.errorMessage ?: "Settings command ${event.setting.ifBlank { event.requestId }} failed.",
1611
1685
  )
@@ -1628,6 +1702,13 @@ class MentraBluetoothSdk private constructor(
1628
1702
  request.pending.resolve(results)
1629
1703
  }
1630
1704
 
1705
+ private fun updateWifiScanLatestResults(results: List<WifiScanResult>) {
1706
+ if (results.isEmpty()) return
1707
+ synchronized(oneShotLock) {
1708
+ pendingWifiScan?.latestResults = results
1709
+ }
1710
+ }
1711
+
1631
1712
  private fun handleWifiStatusForRequests(event: WifiStatusEvent) {
1632
1713
  val request = synchronized(oneShotLock) { pendingWifiStatus } ?: return
1633
1714
  if (!wifiStatusMatches(event.status, request)) return
@@ -1673,7 +1754,7 @@ class MentraBluetoothSdk private constructor(
1673
1754
  }
1674
1755
  }
1675
1756
  request.pending.reject(
1676
- BluetoothException(
1757
+ BluetoothSdkException(
1677
1758
  "hotspot_command_failed",
1678
1759
  event.message ?: "Hotspot command failed.",
1679
1760
  )
@@ -9,14 +9,14 @@ import org.json.JSONObject
9
9
  internal object OtaManifestDefaults {
10
10
  private const val SDK_OTA_RELEASE_BASE_URL =
11
11
  "https://github.com/Mentra-Community/MentraOS/releases/download/bluetooth-sdk-ota"
12
- // Keep prod as the legacy-device fallback: pre-override ASG builds ignore
13
- // ota_start.ota_version_url and use their compiled MentraOS default.
14
- const val PROD_OTA_VERSION_URL = "https://ota.mentraglass.com/prod_live_version.json"
12
+ // ASG builds before 39 ignore ota_start.ota_version_url, so SDK checks must
13
+ // use the same legacy production manifest those glasses will install from.
14
+ const val LEGACY_PROD_OTA_VERSION_URL = "https://ota.mentraglass.com/prod_live_version.json"
15
15
 
16
16
  fun defaultOtaVersionUrl(): String {
17
17
  val sdkVersion = BuildConfig.SDK_VERSION.trim()
18
18
  if (sdkVersion.isBlank() || sdkVersion == "unspecified") {
19
- throw BluetoothException(
19
+ throw BluetoothSdkException(
20
20
  "missing_sdk_version",
21
21
  "Cannot determine Bluetooth SDK version for the default OTA manifest URL.",
22
22
  )
@@ -31,19 +31,19 @@ internal object OtaManifestChecker {
31
31
  fun normalizeHttpUrl(value: String): String {
32
32
  val trimmed = value.trim()
33
33
  if (trimmed.isEmpty()) {
34
- throw BluetoothException("invalid_ota_url", "OTA version URL must be a non-empty http(s) URL.")
34
+ throw BluetoothSdkException("invalid_ota_url", "OTA version URL must be a non-empty http(s) URL.")
35
35
  }
36
36
 
37
37
  val uri =
38
38
  try {
39
39
  URI(trimmed)
40
40
  } catch (error: Exception) {
41
- throw BluetoothException("invalid_ota_url", "OTA version URL must be a valid http(s) URL.", error)
41
+ throw BluetoothSdkException("invalid_ota_url", "OTA version URL must be a valid http(s) URL.", error)
42
42
  }
43
43
 
44
44
  val scheme = uri.scheme?.lowercase()
45
45
  if ((scheme != "http" && scheme != "https") || uri.host.isNullOrBlank()) {
46
- throw BluetoothException("invalid_ota_url", "OTA version URL must be an http(s) URL.")
46
+ throw BluetoothSdkException("invalid_ota_url", "OTA version URL must be an http(s) URL.")
47
47
  }
48
48
 
49
49
  return uri.toString()
@@ -57,7 +57,7 @@ internal object OtaManifestChecker {
57
57
  return try {
58
58
  val status = connection.responseCode
59
59
  if (status !in 200..299) {
60
- throw BluetoothException("ota_manifest_request_failed", "OTA manifest request failed with HTTP $status.")
60
+ throw BluetoothSdkException("ota_manifest_request_failed", "OTA manifest request failed with HTTP $status.")
61
61
  }
62
62
  JSONObject(connection.inputStream.bufferedReader().use { it.readText() })
63
63
  } finally {
@@ -92,13 +92,13 @@ internal object OtaManifestChecker {
92
92
  return manifest
93
93
  }
94
94
 
95
- throw BluetoothException("invalid_ota_manifest", "OTA manifest is missing ASG app versionCode.")
95
+ throw BluetoothSdkException("invalid_ota_manifest", "OTA manifest is missing ASG app versionCode.")
96
96
  }
97
97
 
98
98
  private fun hasApkUpdate(currentBuildNumber: String, manifest: JSONObject): Boolean {
99
99
  val currentVersion =
100
100
  currentBuildNumber.toLongOrNull()
101
- ?: throw BluetoothException(
101
+ ?: throw BluetoothSdkException(
102
102
  "invalid_glasses_version",
103
103
  "Cannot check OTA update because glasses build number is invalid.",
104
104
  )
@@ -124,7 +124,7 @@ internal object OtaManifestChecker {
124
124
  if (besFirmware == null) return false
125
125
  val serverVersion = besFirmware.optString("version", "")
126
126
  if (serverVersion.isBlank()) {
127
- throw BluetoothException("invalid_ota_manifest", "OTA manifest bes_firmware.version is missing.")
127
+ throw BluetoothSdkException("invalid_ota_manifest", "OTA manifest bes_firmware.version is missing.")
128
128
  }
129
129
  if (currentVersion.isBlank()) return true
130
130
  return compareVersions(serverVersion, currentVersion) > 0
@@ -149,5 +149,5 @@ internal object OtaManifestChecker {
149
149
 
150
150
  private fun JSONObject.requiredLong(key: String): Long =
151
151
  (opt(key) as? Number)?.toLong()
152
- ?: throw BluetoothException("invalid_ota_manifest", "OTA manifest is missing ASG app versionCode.")
152
+ ?: throw BluetoothSdkException("invalid_ota_manifest", "OTA manifest is missing ASG app versionCode.")
153
153
  }
@@ -1,6 +1,13 @@
1
1
  package com.mentra.bluetoothsdk
2
2
 
3
3
  import org.json.JSONObject
4
+ import java.util.UUID
5
+
6
+ internal fun generatedCameraRequestId(prefix: String): String =
7
+ "$prefix-${System.currentTimeMillis()}-${UUID.randomUUID().toString().take(8)}"
8
+
9
+ internal fun nonBlankRequestId(requestId: String?): String? =
10
+ requestId?.trim()?.takeIf { it.isNotEmpty() }
4
11
 
5
12
  enum class PhotoSize(val value: String) {
6
13
  LOW("low"),
@@ -138,13 +145,13 @@ data class CameraFovResult(
138
145
  fallback: CameraFov,
139
146
  ): CameraFovResult {
140
147
  if (ack.status == "error") {
141
- throw BluetoothException(
148
+ throw BluetoothSdkException(
142
149
  ack.errorCode ?: "camera_fov_failed",
143
150
  ack.errorMessage ?: "Camera FOV request failed.",
144
151
  )
145
152
  }
146
153
  if (!ack.hardwareApplied) {
147
- throw BluetoothException(
154
+ throw BluetoothSdkException(
148
155
  "camera_fov_not_applied",
149
156
  "Camera FOV was saved but not applied to hardware.",
150
157
  )
@@ -161,8 +168,7 @@ data class CameraFovResult(
161
168
  }
162
169
 
163
170
  data class PhotoRequest @JvmOverloads constructor(
164
- val requestId: String,
165
- val appId: String,
171
+ val requestId: String = generatedCameraRequestId("photo"),
166
172
  val size: PhotoSize,
167
173
  val webhookUrl: String,
168
174
  val authToken: String? = null,
@@ -211,8 +217,8 @@ data class PhotoRequest @JvmOverloads constructor(
211
217
  val ispAnalogGain = stringValue(values, "ispAnalogGain")
212
218
 
213
219
  return PhotoRequest(
214
- requestId = stringValue(values, "requestId", "request_id").orEmpty(),
215
- appId = stringValue(values, "appId", "app_id").orEmpty(),
220
+ requestId = nonBlankRequestId(stringValue(values, "requestId", "request_id"))
221
+ ?: generatedCameraRequestId("photo"),
216
222
  size = PhotoSize.fromValue(stringValue(values, "size") ?: "medium"),
217
223
  webhookUrl = stringValue(values, "webhookUrl", "webhook_url").orEmpty(),
218
224
  authToken = stringValue(values, "authToken", "auth_token")?.takeIf { it.isNotBlank() },
@@ -439,3 +445,13 @@ data class PhotoStatusEvent(
439
445
  val errorCode: String? get() = stringValue(values, "errorCode")
440
446
  val errorMessage: String? get() = stringValue(values, "errorMessage")
441
447
  }
448
+
449
+ data class CameraStatusEvent(
450
+ val values: Map<String, Any>,
451
+ ) {
452
+ val requestId: String get() = stringValue(values, "requestId").orEmpty()
453
+ val state: String get() = stringValue(values, "state").orEmpty()
454
+ val timestamp: Long get() = longValue(values, "timestamp") ?: System.currentTimeMillis()
455
+ val errorCode: String? get() = stringValue(values, "errorCode")
456
+ val errorMessage: String? get() = stringValue(values, "errorMessage")
457
+ }