@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
@@ -127,7 +127,6 @@ object DeviceStore {
127
127
  store.set("bluetooth", "voice_activity_detection_enabled", BluetoothSdkDefaults.VOICE_ACTIVITY_DETECTION_ENABLED)
128
128
  store.set("bluetooth", "screen_disabled", false)
129
129
  store.set("bluetooth", "button_photo_size", "max")
130
- store.set("bluetooth", "button_camera_led", true)
131
130
  store.set("bluetooth", "button_max_recording_time", 10)
132
131
  store.set("bluetooth", "camera_fov", mapOf("fov" to 118, "roi_position" to 0))
133
132
  store.set("bluetooth", "button_video_width", 1280)
@@ -143,7 +142,7 @@ object DeviceStore {
143
142
  store.set("bluetooth", "use_native_dashboard", false)
144
143
  // Mentra Nex feature flags (off by default; toggled from Nex Developer Settings):
145
144
  store.set("bluetooth", "nex_chinese_captions", false)
146
- store.set("bluetooth", "nex_audio_playback", false)
145
+ store.set("bluetooth", "nex_lc3_audio_playback", false)
147
146
  }
148
147
 
149
148
  fun get(category: String, key: String): Any? {
@@ -269,9 +268,9 @@ object DeviceStore {
269
268
  "bluetooth" to "voice_activity_detection_enabled" -> {
270
269
  DeviceManager.getInstance().sgc?.sendVoiceActivityDetectionSetting()
271
270
  }
272
- "bluetooth" to "nex_audio_playback" -> {
271
+ "bluetooth" to "nex_lc3_audio_playback" -> {
273
272
  (value as? Boolean)?.let { enabled ->
274
- Bridge.log("DeviceStore: nex_audio_playback changed to $enabled")
273
+ Bridge.log("DeviceStore: nex_lc3_audio_playback changed to $enabled")
275
274
  DeviceManager.getInstance().sgc?.applyNexAudioPlaybackSetting()
276
275
  }
277
276
  }
@@ -287,9 +286,6 @@ object DeviceStore {
287
286
  "bluetooth" to "button_photo_size" -> {
288
287
  DeviceManager.getInstance().sgc?.sendButtonPhotoSettings()
289
288
  }
290
- "bluetooth" to "button_camera_led" -> {
291
- DeviceManager.getInstance().sgc?.sendButtonCameraLedSetting()
292
- }
293
289
  "bluetooth" to "button_max_recording_time" -> {
294
290
  DeviceManager.getInstance().sgc?.sendButtonMaxRecordingTime()
295
291
  }
@@ -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
  )
@@ -474,7 +476,7 @@ class MentraBluetoothSdk private constructor(
474
476
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "voice_activity_detection_enabled", enabled)
475
477
  }
476
478
 
477
- fun setButtonPhotoSettings(settings: ButtonPhotoSettings): SettingsAckEvent =
479
+ fun setPhotoCaptureDefaults(settings: PhotoCaptureDefaults): SettingsAckEvent =
478
480
  performSettingsCommand(
479
481
  setting = "button_photo",
480
482
  updateStore = { _ ->
@@ -527,30 +529,20 @@ class MentraBluetoothSdk private constructor(
527
529
  send = { requestId -> deviceManager.sendButtonPhotoSettings(requestId, settings) },
528
530
  )
529
531
 
530
- fun setButtonVideoRecordingSettings(width: Int, height: Int, fps: Int): SettingsAckEvent =
532
+ fun setVideoRecordingDefaults(defaults: VideoRecordingDefaults): SettingsAckEvent =
531
533
  performSettingsCommand(
532
534
  setting = "button_video_recording",
533
535
  updateStore = { _ ->
534
- DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_video_width", width)
535
- DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_video_height", height)
536
- DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_video_fps", fps)
536
+ DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_video_width", defaults.width)
537
+ DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_video_height", defaults.height)
538
+ DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_video_fps", defaults.fps)
537
539
  },
538
540
  send = { requestId ->
539
- deviceManager.sendButtonVideoRecordingSettings(requestId, width, height, fps)
541
+ deviceManager.sendButtonVideoRecordingSettings(requestId, defaults.width, defaults.height, defaults.fps)
540
542
  },
541
543
  )
542
544
 
543
- fun setButtonVideoRecordingSettings(settings: ButtonVideoRecordingSettings): SettingsAckEvent =
544
- setButtonVideoRecordingSettings(width = settings.width, height = settings.height, fps = settings.fps)
545
-
546
- fun setButtonCameraLed(enabled: Boolean): SettingsAckEvent =
547
- performSettingsCommand(
548
- setting = "button_camera_led",
549
- updateStore = { _ -> DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_camera_led", enabled) },
550
- send = { requestId -> deviceManager.sendButtonCameraLedSetting(requestId, enabled) },
551
- )
552
-
553
- fun setButtonMaxRecordingTime(minutes: Int): SettingsAckEvent =
545
+ fun setMaxVideoRecordingDuration(minutes: Int): SettingsAckEvent =
554
546
  performSettingsCommand(
555
547
  setting = "button_max_recording_time",
556
548
  updateStore = { _ ->
@@ -574,6 +566,23 @@ class MentraBluetoothSdk private constructor(
574
566
  return result
575
567
  }
576
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
+
577
586
  fun setMicState(
578
587
  enabled: Boolean,
579
588
  useGlassesMic: Boolean = true,
@@ -623,18 +632,37 @@ class MentraBluetoothSdk private constructor(
623
632
 
624
633
  fun requestWifiScan(): List<WifiScanResult> {
625
634
  val pending = PendingResponse<List<WifiScanResult>>("WiFi scan request")
635
+ val request = PendingWifiScan(pending)
626
636
  synchronized(oneShotLock) {
627
637
  if (pendingWifiScan != null) {
628
- throw BluetoothException(
638
+ throw BluetoothSdkException(
629
639
  "request_in_flight",
630
640
  "A WiFi scan is already waiting for a glasses response.",
631
641
  )
632
642
  }
633
- pendingWifiScan = PendingWifiScan(pending)
643
+ pendingWifiScan = request
634
644
  }
635
645
  try {
636
646
  deviceManager.requestWifiScan()
637
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
638
666
  } finally {
639
667
  synchronized(oneShotLock) {
640
668
  if (pendingWifiScan?.pending === pending) {
@@ -648,7 +676,7 @@ class MentraBluetoothSdk private constructor(
648
676
  val pending = PendingResponse<WifiStatusEvent>("WiFi connect request")
649
677
  synchronized(oneShotLock) {
650
678
  if (pendingWifiStatus != null) {
651
- throw BluetoothException(
679
+ throw BluetoothSdkException(
652
680
  "request_in_flight",
653
681
  "A WiFi status command is already waiting for a glasses response.",
654
682
  )
@@ -671,7 +699,7 @@ class MentraBluetoothSdk private constructor(
671
699
  val pending = PendingResponse<WifiStatusEvent>("WiFi forget request")
672
700
  synchronized(oneShotLock) {
673
701
  if (pendingWifiStatus != null) {
674
- throw BluetoothException(
702
+ throw BluetoothSdkException(
675
703
  "request_in_flight",
676
704
  "A WiFi status command is already waiting for a glasses response.",
677
705
  )
@@ -694,7 +722,7 @@ class MentraBluetoothSdk private constructor(
694
722
  val pending = PendingResponse<HotspotStatusEvent>("hotspot ${if (enabled) "enable" else "disable"} request")
695
723
  synchronized(oneShotLock) {
696
724
  if (pendingHotspotStatus != null) {
697
- throw BluetoothException(
725
+ throw BluetoothSdkException(
698
726
  "request_in_flight",
699
727
  "A hotspot command is already waiting for a glasses response.",
700
728
  )
@@ -718,16 +746,44 @@ class MentraBluetoothSdk private constructor(
718
746
  }
719
747
 
720
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"))
721
753
  Bridge.log(
722
- "NATIVE: PHOTO PIPELINE [3b/6] MentraBluetoothSdk.requestPhoto requestId=${request.requestId} appId=${request.appId}"
754
+ "NATIVE: PHOTO PIPELINE [3b/6] MentraBluetoothSdk.requestPhoto requestId=${routedRequest.requestId}"
723
755
  )
724
- val pending = PendingResponse<PhotoResponseEvent>("photo request ${request.requestId}")
725
- 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
+ }
726
782
  try {
727
- deviceManager.requestPhoto(request)
783
+ deviceManager.warmUpCamera(effectiveRequestId, size, exposureTimeNs, durationMs)
728
784
  return pending.await()
729
785
  } finally {
730
- pendingPhotoRequests.remove(request.requestId, pending)
786
+ pendingCameraStatusRequests.remove(effectiveRequestId, pending)
731
787
  }
732
788
  }
733
789
 
@@ -735,7 +791,7 @@ class MentraBluetoothSdk private constructor(
735
791
  val pending = PendingResponse<GalleryStatusEvent>("gallery status query")
736
792
  synchronized(oneShotLock) {
737
793
  if (pendingGalleryStatus != null) {
738
- throw BluetoothException(
794
+ throw BluetoothSdkException(
739
795
  "request_in_flight",
740
796
  "A gallery status query is already waiting for a glasses response.",
741
797
  )
@@ -754,7 +810,16 @@ class MentraBluetoothSdk private constructor(
754
810
  }
755
811
  }
756
812
 
757
- fun startStream(request: StreamRequest): StreamStatusEvent {
813
+ fun startStream(request: StreamRequest): StreamStatusEvent =
814
+ startStream(request, startSdkKeepAlive = true)
815
+
816
+ internal fun startExternallyManagedStream(request: StreamRequest): StreamStatusEvent =
817
+ startStream(request, startSdkKeepAlive = false)
818
+
819
+ private fun startStream(
820
+ request: StreamRequest,
821
+ startSdkKeepAlive: Boolean,
822
+ ): StreamStatusEvent {
758
823
  val message = request.toMap().toMutableMap()
759
824
  val streamId = (message["streamId"] as? String)?.takeIf { it.isNotBlank() }
760
825
  ?: "sdk-${UUID.randomUUID()}"
@@ -765,8 +830,8 @@ class MentraBluetoothSdk private constructor(
765
830
  try {
766
831
  deviceManager.startStream(message)
767
832
  val event = pending.await(STREAM_START_TIMEOUT_MS)
768
- if (request.keepAlive && !request.isExternallyManagedKeepAlive()) {
769
- startStreamKeepAliveMonitor(streamId, request.keepAliveIntervalSeconds)
833
+ if (startSdkKeepAlive) {
834
+ startStreamKeepAliveMonitor(streamId, DEFAULT_STREAM_KEEP_ALIVE_INTERVAL_SECONDS)
770
835
  }
771
836
  return event
772
837
  } finally {
@@ -804,7 +869,7 @@ class MentraBluetoothSdk private constructor(
804
869
  }
805
870
  synchronized(oneShotLock) {
806
871
  if (pendingStreamStop != null) {
807
- throw BluetoothException(
872
+ throw BluetoothSdkException(
808
873
  "request_in_flight",
809
874
  "A stream stop command is already waiting for a glasses response.",
810
875
  )
@@ -830,7 +895,7 @@ class MentraBluetoothSdk private constructor(
830
895
  val pending = PendingResponse<VideoRecordingStatusEvent>("start video recording")
831
896
  val pendingRequest = PendingVideoRecordingRequest("recording_started", pending)
832
897
  if (pendingVideoRecordingRequests.putIfAbsent(request.requestId, pendingRequest) != null) {
833
- throw BluetoothException(
898
+ throw BluetoothSdkException(
834
899
  "request_in_flight",
835
900
  "A video recording command is already waiting for requestId ${request.requestId}.",
836
901
  )
@@ -868,7 +933,7 @@ class MentraBluetoothSdk private constructor(
868
933
  waitForUpload = waitForUpload,
869
934
  )
870
935
  if (pendingVideoRecordingRequests.putIfAbsent(requestId, pendingRequest) != null) {
871
- throw BluetoothException(
936
+ throw BluetoothSdkException(
872
937
  "request_in_flight",
873
938
  "A video recording command is already waiting for requestId $requestId.",
874
939
  )
@@ -887,7 +952,7 @@ class MentraBluetoothSdk private constructor(
887
952
  val pending = PendingResponse<VersionInfoResult>("version info request")
888
953
  synchronized(oneShotLock) {
889
954
  if (pendingVersionInfo != null) {
890
- throw BluetoothException(
955
+ throw BluetoothSdkException(
891
956
  "request_in_flight",
892
957
  "A version info request is already waiting for a glasses response.",
893
958
  )
@@ -916,13 +981,13 @@ class MentraBluetoothSdk private constructor(
916
981
  fun checkForOtaUpdate(): Boolean {
917
982
  val status = getFreshGlassesStatus()
918
983
  if (!status.connected) {
919
- throw BluetoothException(
984
+ throw BluetoothSdkException(
920
985
  "glasses_not_connected",
921
986
  "Cannot check OTA update because glasses are not connected.",
922
987
  )
923
988
  }
924
989
  if (status.buildNumber.isBlank()) {
925
- throw BluetoothException(
990
+ throw BluetoothSdkException(
926
991
  "missing_glasses_version",
927
992
  "Cannot check OTA update because glasses build number is unavailable.",
928
993
  )
@@ -952,7 +1017,7 @@ class MentraBluetoothSdk private constructor(
952
1017
  val pending = PendingResponse<OtaQueryResult>(operation)
953
1018
  synchronized(oneShotLock) {
954
1019
  if (pendingOtaQuery != null) {
955
- throw BluetoothException(
1020
+ throw BluetoothSdkException(
956
1021
  "request_in_flight",
957
1022
  "An OTA status query is already waiting for a glasses response.",
958
1023
  )
@@ -981,7 +1046,7 @@ class MentraBluetoothSdk private constructor(
981
1046
  val pending = PendingResponse<OtaStartAckEvent>("OTA start command")
982
1047
  synchronized(oneShotLock) {
983
1048
  if (pendingOtaStart != null) {
984
- throw BluetoothException(
1049
+ throw BluetoothSdkException(
985
1050
  "request_in_flight",
986
1051
  "An OTA start command is already waiting for a glasses response.",
987
1052
  )
@@ -1005,13 +1070,6 @@ class MentraBluetoothSdk private constructor(
1005
1070
 
1006
1071
  internal fun sendOtaQueryStatus(): OtaQueryResult = queryOtaStatus()
1007
1072
 
1008
- /** Re-run the glasses-side OTA version check after an internal clock-skew recovery. */
1009
- @JvmSynthetic
1010
- internal fun retryOtaVersionCheck(): OtaQueryResult =
1011
- performOtaQuery("OTA version retry") {
1012
- deviceManager.retryOtaVersionCheck()
1013
- }
1014
-
1015
1073
  private fun getFreshGlassesStatus(): GlassesStatus {
1016
1074
  val status = getRawGlassesStatus()
1017
1075
  if (!status.connected || status.buildNumber.isNotBlank()) {
@@ -1053,7 +1111,7 @@ class MentraBluetoothSdk private constructor(
1053
1111
  }
1054
1112
 
1055
1113
  if (!status.connected) {
1056
- throw BluetoothException(
1114
+ throw BluetoothSdkException(
1057
1115
  "glasses_not_connected",
1058
1116
  "Cannot check OTA update because glasses disconnected.",
1059
1117
  )
@@ -1081,7 +1139,7 @@ class MentraBluetoothSdk private constructor(
1081
1139
  private fun resolveOtaVersionUrl(status: GlassesStatus): String {
1082
1140
  val deviceUrl = status.otaVersionUrl.trim()
1083
1141
  if (isLegacyAsgOtaStartBuild(status.buildNumber)) {
1084
- return deviceUrl.ifBlank { OtaManifestDefaults.PROD_OTA_VERSION_URL }
1142
+ return OtaManifestDefaults.LEGACY_PROD_OTA_VERSION_URL
1085
1143
  }
1086
1144
  // SDK consumers are pinned to the manifest built for their SDK version.
1087
1145
  // A future glasses-advertised URL should not silently change that pairing.
@@ -1090,7 +1148,7 @@ class MentraBluetoothSdk private constructor(
1090
1148
 
1091
1149
  private fun isLegacyAsgOtaStartBuild(buildNumber: String): Boolean {
1092
1150
  val parsed = buildNumber.toIntOrNull()
1093
- return parsed != null && parsed < 100_000
1151
+ return parsed != null && parsed < 39
1094
1152
  }
1095
1153
 
1096
1154
  internal fun sendShutdown() {
@@ -1163,7 +1221,7 @@ class MentraBluetoothSdk private constructor(
1163
1221
  val bluetoothManager = appContext.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager
1164
1222
  val adapter =
1165
1223
  bluetoothManager?.adapter
1166
- ?: throw BluetoothException(
1224
+ ?: throw BluetoothSdkException(
1167
1225
  "bluetooth_unsupported",
1168
1226
  "This phone does not support Bluetooth.",
1169
1227
  )
@@ -1171,14 +1229,14 @@ class MentraBluetoothSdk private constructor(
1171
1229
  try {
1172
1230
  adapter.isEnabled
1173
1231
  } catch (error: SecurityException) {
1174
- throw BluetoothException(
1232
+ throw BluetoothSdkException(
1175
1233
  "bluetooth_permission_denied",
1176
1234
  "Allow Bluetooth permission to $operation.",
1177
1235
  error,
1178
1236
  )
1179
1237
  }
1180
1238
  if (!enabled) {
1181
- throw BluetoothException(
1239
+ throw BluetoothSdkException(
1182
1240
  "bluetooth_powered_off",
1183
1241
  "Turn on phone Bluetooth to $operation.",
1184
1242
  )
@@ -1186,7 +1244,7 @@ class MentraBluetoothSdk private constructor(
1186
1244
  }
1187
1245
 
1188
1246
  private fun Throwable.toBluetoothError(defaultCode: String): BluetoothError =
1189
- if (this is BluetoothException) {
1247
+ if (this is BluetoothSdkException) {
1190
1248
  BluetoothError(code, message ?: code, this)
1191
1249
  } else {
1192
1250
  BluetoothError(defaultCode, message ?: toString(), this)
@@ -1273,6 +1331,7 @@ class MentraBluetoothSdk private constructor(
1273
1331
  data.containsKey("scanComplete") || data.containsKey("scan_complete")
1274
1332
  val scanComplete =
1275
1333
  (data["scanComplete"] as? Boolean) ?: (data["scan_complete"] as? Boolean) ?: false
1334
+ updateWifiScanLatestResults(networks)
1276
1335
  if (scanComplete || !hasCompletionFlag) {
1277
1336
  handleWifiScanResultsForRequests(networks)
1278
1337
  }
@@ -1301,6 +1360,11 @@ class MentraBluetoothSdk private constructor(
1301
1360
  dispatchToListeners { it.onPhotoResponse(event) }
1302
1361
  }
1303
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
+ }
1304
1368
  "video_recording_status" -> {
1305
1369
  val event = VideoRecordingStatusEvent(data)
1306
1370
  handleVideoRecordingStatusForRequests(event)
@@ -1328,13 +1392,6 @@ class MentraBluetoothSdk private constructor(
1328
1392
  dispatchToListeners { it.onKeepAliveAck(event) }
1329
1393
  }
1330
1394
  }
1331
- "ota_update_available" -> {
1332
- val resultValues = data + mapOf("type" to "ota_update_available")
1333
- synchronized(oneShotLock) {
1334
- pendingOtaQuery?.resolve(OtaQueryResult(resultValues))
1335
- }
1336
- dispatchToListeners { it.onOtaUpdateAvailable(OtaUpdateAvailableEvent.fromMap(resultValues)) }
1337
- }
1338
1395
  "ota_start_ack" -> {
1339
1396
  val event = OtaStartAckEvent.fromMap(data + mapOf("type" to "ota_start_ack"))
1340
1397
  synchronized(oneShotLock) {
@@ -1524,10 +1581,10 @@ class MentraBluetoothSdk private constructor(
1524
1581
  )
1525
1582
  )
1526
1583
 
1527
- private fun streamStatusException(event: StreamStatusEvent, code: String): BluetoothException {
1584
+ private fun streamStatusException(event: StreamStatusEvent, code: String): BluetoothSdkException {
1528
1585
  val details = (event.status as? StreamStatus.Error)?.errorDetails
1529
1586
  ?: "Stream status ${event.state.value}"
1530
- return BluetoothException(code, details)
1587
+ return BluetoothSdkException(code, details)
1531
1588
  }
1532
1589
 
1533
1590
  private fun handlePhotoResponseForRequests(event: PhotoResponseEvent) {
@@ -1536,7 +1593,7 @@ class MentraBluetoothSdk private constructor(
1536
1593
  is PhotoResponse.Success -> pending.resolve(event)
1537
1594
  is PhotoResponse.Error ->
1538
1595
  pending.reject(
1539
- BluetoothException(
1596
+ BluetoothSdkException(
1540
1597
  response.errorCode ?: "photo_request_failed",
1541
1598
  response.errorMessage,
1542
1599
  )
@@ -1544,6 +1601,22 @@ class MentraBluetoothSdk private constructor(
1544
1601
  }
1545
1602
  }
1546
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
+
1547
1620
  private fun handleVideoRecordingStatusForRequests(event: VideoRecordingStatusEvent) {
1548
1621
  val request = pendingVideoRecordingRequests[event.requestId] ?: return
1549
1622
  if (event.success) {
@@ -1559,7 +1632,7 @@ class MentraBluetoothSdk private constructor(
1559
1632
  }
1560
1633
  } else {
1561
1634
  request.pending.reject(
1562
- BluetoothException(
1635
+ BluetoothSdkException(
1563
1636
  event.status.ifBlank { "video_recording_failed" },
1564
1637
  event.details ?: "Video recording command failed.",
1565
1638
  )
@@ -1580,7 +1653,7 @@ class MentraBluetoothSdk private constructor(
1580
1653
  }
1581
1654
  } else {
1582
1655
  request.pending.reject(
1583
- BluetoothException(
1656
+ BluetoothSdkException(
1584
1657
  "video_upload_failed",
1585
1658
  event.errorMessage ?: "Video upload failed.",
1586
1659
  )
@@ -1594,7 +1667,7 @@ class MentraBluetoothSdk private constructor(
1594
1667
  pending.resolve(event)
1595
1668
  } else {
1596
1669
  pending.reject(
1597
- BluetoothException(
1670
+ BluetoothSdkException(
1598
1671
  event.errorCode ?: "rgb_led_control_failed",
1599
1672
  event.errorCode ?: "RGB LED command failed.",
1600
1673
  )
@@ -1606,7 +1679,7 @@ class MentraBluetoothSdk private constructor(
1606
1679
  val pending = pendingSettingsRequests[event.requestId] ?: return
1607
1680
  if (isFailureStatus(event.status)) {
1608
1681
  pending.reject(
1609
- BluetoothException(
1682
+ BluetoothSdkException(
1610
1683
  event.errorCode ?: "${event.setting.ifBlank { "settings" }}_failed",
1611
1684
  event.errorMessage ?: "Settings command ${event.setting.ifBlank { event.requestId }} failed.",
1612
1685
  )
@@ -1629,6 +1702,13 @@ class MentraBluetoothSdk private constructor(
1629
1702
  request.pending.resolve(results)
1630
1703
  }
1631
1704
 
1705
+ private fun updateWifiScanLatestResults(results: List<WifiScanResult>) {
1706
+ if (results.isEmpty()) return
1707
+ synchronized(oneShotLock) {
1708
+ pendingWifiScan?.latestResults = results
1709
+ }
1710
+ }
1711
+
1632
1712
  private fun handleWifiStatusForRequests(event: WifiStatusEvent) {
1633
1713
  val request = synchronized(oneShotLock) { pendingWifiStatus } ?: return
1634
1714
  if (!wifiStatusMatches(event.status, request)) return
@@ -1674,7 +1754,7 @@ class MentraBluetoothSdk private constructor(
1674
1754
  }
1675
1755
  }
1676
1756
  request.pending.reject(
1677
- BluetoothException(
1757
+ BluetoothSdkException(
1678
1758
  "hotspot_command_failed",
1679
1759
  event.message ?: "Hotspot command failed.",
1680
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
  }