@mentra/bluetooth-sdk 3.2.0-dev.235 → 3.2.0-dev.247

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 (40) hide show
  1. package/README.md +48 -3
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +2 -23
  3. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +1 -1
  4. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +1 -0
  5. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  6. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +44 -19
  7. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +33 -8
  8. package/android/src/main/java/com/mentra/bluetoothsdk/internal/MapParsing.kt +14 -14
  9. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +31 -6
  10. package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +32 -0
  11. package/build/BluetoothSdk.types.d.ts +14 -6
  12. package/build/BluetoothSdk.types.d.ts.map +1 -1
  13. package/build/BluetoothSdk.types.js.map +1 -1
  14. package/build/_private/photoRequestPayload.d.ts.map +1 -1
  15. package/build/_private/photoRequestPayload.js +5 -1
  16. package/build/_private/photoRequestPayload.js.map +1 -1
  17. package/build/generated/changelogCatalog.d.ts +3 -0
  18. package/build/generated/changelogCatalog.d.ts.map +1 -1
  19. package/build/generated/changelogCatalog.js +4 -0
  20. package/build/generated/changelogCatalog.js.map +1 -1
  21. package/build/generated/releaseMetadata.js +5 -5
  22. package/build/generated/releaseMetadata.js.map +1 -1
  23. package/build/index.d.ts.map +1 -1
  24. package/build/index.js +6 -1
  25. package/build/index.js.map +1 -1
  26. package/ios/BluetoothSdkModule.swift +1 -1
  27. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  28. package/ios/Source/DeviceManager.swift +1 -1
  29. package/ios/Source/GeneratedChangelogCatalog.swift +1 -0
  30. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  31. package/ios/Source/MentraBluetoothSDK.swift +52 -25
  32. package/ios/Source/camera/CameraModels.swift +29 -15
  33. package/ios/Source/sgcs/MentraLive.swift +35 -4
  34. package/ios/Tests/PhotoCompressionTests.swift +21 -0
  35. package/package.json +4 -2
  36. package/src/BluetoothSdk.types.ts +15 -4
  37. package/src/_private/photoRequestPayload.ts +5 -1
  38. package/src/generated/changelogCatalog.ts +4 -0
  39. package/src/generated/releaseMetadata.ts +5 -5
  40. package/src/index.ts +6 -1
package/README.md CHANGED
@@ -583,13 +583,34 @@ const photo = await BluetoothSdk.requestPhoto({
583
583
  console.log("photo delivered", photo.photoUrl ?? photo.uploadUrl, photo.fileSizeBytes)
584
584
  ```
585
585
 
586
- `requestPhoto(...)` resolves only after the full photo action reaches terminal success: capture completed and the photo was delivered to the webhook, either directly from the glasses over Wi-Fi or through the phone's Bluetooth fallback relay. If you omit `requestId`, the SDK generates one and the terminal response includes it. It rejects if the ASG reports `state: "error"`, if phone-side fallback upload fails, if the SDK cannot send the command, or if no terminal `photo_response` arrives within 30 seconds. Photo requests use this longer operation-specific deadline because max-quality BLE fallback can legitimately exceed the 15-second deadline used by ordinary commands. Use `photo_status` for intermediate stages such as `accepted`, `configuring`, `capturing`, `captured`, `uploading`, `ble_fallback_compression`, `ready_for_transfer`, and `transferring`; `photo_status` is progress, while `photo_response` is terminal success/error. The raw `photo_response` event stream still includes both success and error events for subscribers. The webhook should accept multipart form data with a `photo` file and `requestId`. If `authToken` is provided, the uploader adds `Authorization: Bearer <token>`. The camera light is always enabled for photo capture.
586
+ `requestPhoto(...)` resolves only after the full photo action reaches terminal success: capture completed and the photo was delivered to the webhook, either directly from the glasses over Wi-Fi or through the phone's Bluetooth fallback relay. If you omit `requestId`, the SDK generates one and the terminal response includes it. It rejects if the ASG reports `state: "error"`, if phone-side fallback upload fails, if the SDK cannot send the command, or if no terminal `photo_response` arrives within 30 seconds (110 seconds when `presend_thumbnail` is enabled). Photo requests use this longer operation-specific deadline because max-quality BLE fallback can legitimately exceed the 15-second deadline used by ordinary commands. Use `photo_status` for intermediate stages such as `accepted`, `configuring`, `capturing`, `captured`, `uploading`, `ble_fallback_compression`, `ready_for_transfer`, and `transferring`; `photo_status` is progress, while `photo_response` is terminal success/error. The raw `photo_response` event stream still includes both success and error events for subscribers. The webhook should accept multipart form data with a `photo` file and `requestId`. If `authToken` is provided, the uploader adds `Authorization: Bearer <token>`. The camera light is always enabled for photo capture.
587
+
588
+ Set `presend_thumbnail: true` to receive a JPEG preview over Bluetooth before the
589
+ full photo (default: `false`). Listen for `photo_status` with
590
+ `status: "thumbnail_received"`; `thumbnailUrl` is a JPEG data URI and
591
+ `fileSizeBytes` is the preview size. The preview preserves aspect ratio, is at
592
+ most 500 pixels on its longest edge, never upscales, and uses JPEG quality 50.
593
+ Preview pixels match the full delivered image's display orientation, including
594
+ EXIF rotation/mirroring on direct uploads; no EXIF-aware preview renderer is needed.
595
+ It does not resolve `requestPhoto()` or replace the full webhook delivery.
596
+ The opted-in end-to-end deadline is 110 seconds: capture (45) + preview
597
+ transfer/retries and ACK (30) + full delivery (30) + response transit margin (5).
598
+ Both native SDK facades mirror ASG's constants; the glasses job watchdog is 105
599
+ seconds. Progress and preview ACK never restart these timers.
600
+ An upload target is required; save-only capture does not support previews.
601
+ Compatible ASG firmware is required; older firmware may ignore this option.
602
+
603
+ On the BLE delivery path, full-image encoding overlaps preview transmission,
604
+ but the full image is sent only after the phone acknowledges the preview.
605
+ Preview send failure or a missing acknowledgement fails the request rather than
606
+ starting a competing transfer. ASG timing logs use `BlePhotoTiming`; thumbnail
607
+ transmission and CPU processing overlap, so their durations are not additive.
587
608
 
588
609
  For one-shot manual capture tuning, pass `exposureTimeNs` and `iso` together. `exposureTimeNs` is sensor exposure time in nanoseconds; `iso` is sensor ISO. If `exposureTimeNs` is omitted, `null`, invalid, or unsupported by the connected glasses, the camera uses auto exposure and ignores `iso`.
589
610
 
590
- To own and explicitly release a warm camera, pass a request ID to `warmUpCamera({requestId, ...})`, then call `stopCameraWarmUp(requestId)` when the foreground UI closes. Warm holds default to 15 seconds and are capped at 60 seconds. Stopping while the camera is opening rejects the pending warm-up with `camera_warm_up_cancelled`; stopping after `ready` emits `stopped`. Compatible ready leases share the camera and expire independently.
611
+ To own and explicitly release a warm camera, pass a request ID to `warmUpCamera({requestId, ...})`, then call `stopCameraWarmUp(requestId)` when the foreground UI closes. Warm holds default to 15 seconds and are capped at 5 minutes. Stopping while the camera is opening rejects the pending warm-up with `camera_warm_up_cancelled`; stopping after `ready` emits `stopped`. Compatible ready leases share the camera and expire independently.
591
612
 
592
- Use `setCameraFov({fov, roiPosition})` to configure Mentra Live camera field of view and crop position. FOV is clamped to 62-118 degrees; ROI position is `"center"`, `"bottom"`, or `"top"`. You can also call `setCameraFov({preset: "narrow" | "standard" | "wide"})`; presets map to 82, 102, and 118 degrees with center ROI. The returned `CameraFovResult` resolves only after the ASG client reports that the setting was applied to camera hardware after the restart cooldown, and the promise rejects if the glasses report an error, persist the setting without hardware application, or time out. Raw `settings_ack` events remain available through `addListener("settings_ack", ...)` for diagnostic fields such as `hardwareApplied`. Treat FOV as a framing/ROI control; output resolution and effective detail can vary by capture path, firmware, and camera mode.
613
+ Use `setCameraFov({fov, roiPosition})` to configure Mentra Live camera field of view and crop position. FOV is clamped to 62-118 degrees; ROI position is `"center"`, `"bottom"`, or `"top"`. You can also call `setCameraFov({preset: "narrow" | "standard" | "wide"})`; presets map to 82, 102, and 118 degrees with center ROI. FOV calls use a 45-second deadline covering one active and one coalesced pending update on ASG, including camera re-registration. Replaced pending commands reject with `fov_superseded`. The returned `CameraFovResult` resolves only after the ASG client reports that the setting was applied to camera hardware after the restart cooldown, and the promise rejects if the glasses report an error, persist the setting without hardware application, or time out. Raw `settings_ack` events remain available through `addListener("settings_ack", ...)` for diagnostic fields such as `hardwareApplied`. Treat FOV as a framing/ROI control; output resolution and effective detail can vary by capture path, firmware, and camera mode.
593
614
 
594
615
  The missing/factory persistent FOV base is 102 degrees with centered ROI; existing saved values are not migrated. Use `setCameraFovOverride({leaseId, fov, roiPosition, ttlMs})` for a memory-only crop and `releaseCameraFovOverride(leaseId)` to restore the persistent base. A stale release is a safe no-op, and refreshing the same lease/config extends its TTL without another HAL restart.
595
616
 
@@ -726,3 +747,27 @@ secrets, not in the repository.
726
747
  ## Starter Example App
727
748
 
728
749
  The [Mentra Bluetooth SDK Starter Kit](https://github.com/Mentra-Community/Mentra-Bluetooth-SDK-Starter-Kit) includes starter example apps for Android, iOS, and React Native / Expo. The React Native starter demonstrates scan/connect, display, camera photo upload, RTMP/SRT/WebRTC streaming, Wi-Fi/hotspot, microphone PCM, RGB LED, gallery mode, and console event inspection.
750
+
751
+ BLE JPEG photo compression supports `none` (Q95), `low` (Q88), `medium` (Q78), and `high` (Q60) on updated glasses firmware. Omitted compression defaults to `none`. Values are sent unchanged and invalid values are rejected. Pixel limits are controlled separately by `size`.
752
+
753
+ ### Breaking change: photo compression
754
+
755
+ Photo compression now accepts exactly `none`, `low`, `medium`, and `high`, sent
756
+ unchanged across the Mentra Miniapp SDK, Bluetooth SDKs, and glasses.
757
+ The default is `none` everywhere. Glasses encode these as JPEG Q95, Q88, Q78,
758
+ and Q60 respectively; `size` controls dimensions separately. `none` still uses
759
+ lossy JPEG encoding. Unknown values are rejected.
760
+
761
+ Cloud only allocates upload/download URLs: it takes no photo options and ignores
762
+ any request body. Photo compression is handled by the phone and glasses.
763
+
764
+ The former `heavy` spelling is removed; callers must use `high`. Android's native
765
+ default changes from `medium` to `none`. Existing miniapps may therefore produce
766
+ different JPEG quality or payload sizes. There are no compatibility aliases or wire translations.
767
+
768
+ Photo compression omission is distinct from an explicit `null`: requests default
769
+ omission to `none`, and partial preset updates leave an omitted compression field
770
+ unchanged. A supplied `null` or other invalid value is rejected. Stored presets
771
+ containing removed values are invalid; replace their compression with one of the
772
+ four supported values before replaying the complete preset. There is no automatic
773
+ migration or substitution. Fresh valid preset updates remain available.
@@ -643,7 +643,7 @@ class BluetoothSdkModule : Module() {
643
643
 
644
644
  @Suppress("DEPRECATION")
645
645
  SdkCoroutineFunction("setPhotoCaptureDefaults") { params: Map<String, Any?> ->
646
- requireSdk().setPhotoCaptureDefaults(params.toPhotoCaptureDefaults()).values
646
+ requireSdk().setPhotoCaptureDefaults(PhotoCaptureDefaults.fromMap(params)).values
647
647
  }
648
648
 
649
649
  SdkCoroutineFunction("setVideoRecordingDefaults") { width: Int, height: Int, fps: Int ->
@@ -698,12 +698,7 @@ class BluetoothSdkModule : Module() {
698
698
  SdkCoroutineFunction("queryGalleryStatus") { -> requireSdk().queryGalleryStatus().values }
699
699
 
700
700
  SdkCoroutineFunction("requestPhoto") { params: Map<String, Any?> ->
701
- // JS may pass null for optional fields; Map<String, Any> rejects null values at the bridge.
702
- val sanitized =
703
- params.mapNotNull { (key, value) ->
704
- if (value == null) null else key to value
705
- }.toMap()
706
- val req = PhotoRequest.fromMap(sanitized)
701
+ val req = PhotoRequest.fromMap(params)
707
702
  Bridge.log(
708
703
  "NATIVE: PHOTO PIPELINE [3/6] BluetoothSdk.requestPhoto requestId=${req.requestId} size=${req.size} mode=${req.mode.value} compress=${req.compress} sound=${req.sound} exposureTimeNs=${req.exposureTimeNs} iso=${req.iso}"
709
704
  )
@@ -1044,22 +1039,6 @@ private fun Map<String, Any>?.toMentraDevice(): Device? {
1044
1039
  )
1045
1040
  }
1046
1041
 
1047
- private fun Map<String, Any?>.toPhotoCaptureDefaults(): PhotoCaptureDefaults =
1048
- PhotoCaptureDefaults(
1049
- size = (this["size"] as? String)?.let { PhotoSize.fromValue(it) },
1050
- mfnr = this["mfnr"] as? Boolean,
1051
- zsl = this["zsl"] as? Boolean,
1052
- noiseReduction = this["noiseReduction"] as? Boolean,
1053
- edgeEnhancement = this["edgeEnhancement"] as? Boolean,
1054
- ispDigitalGain = (this["ispDigitalGain"] as? Number)?.toInt(),
1055
- ispAnalogGain = this["ispAnalogGain"] as? String,
1056
- aeExposureDivisor = (this["aeExposureDivisor"] as? Number)?.toInt(),
1057
- isoCap = (this["isoCap"] as? Number)?.toInt(),
1058
- compress = this["compress"] as? String,
1059
- sound = this["sound"] as? Boolean,
1060
- resetCaptureTuning = this["resetCaptureTuning"] as? Boolean == true,
1061
- )
1062
-
1063
1042
  private fun Map<String, Any>?.toMentraConnectOptions(): ConnectOptions {
1064
1043
  val values = this ?: return ConnectOptions()
1065
1044
  return ConnectOptions(
@@ -1996,7 +1996,7 @@ class DeviceManager {
1996
1996
  settings.ispAnalogGain,
1997
1997
  settings.aeExposureDivisor,
1998
1998
  settings.isoCap,
1999
- settings.compress,
1999
+ settings.compress?.value,
2000
2000
  settings.sound,
2001
2001
  settings.resetCaptureTuning == true,
2002
2002
  )
@@ -4,5 +4,6 @@ package com.mentra.bluetoothsdk
4
4
  internal val GENERATED_RELEASE_CHANGELOGS: List<ReleaseChangelog> =
5
5
  listOf(
6
6
  ReleaseChangelog(version = "3.2.0", markdown = "This release is under active development. User-facing changes will be documented as they land.\n\n- Breaking Bluetooth SDK change: `forgetWifiNetwork(ssid)` now returns `WifiForgetResult` instead of `WifiStatusChangeEvent`. Migrate status-only consumers to the semantic `outcome`; the requested network remains `ssid`, while optional post-command connectivity is reported by `connected`, `currentSsid`, and `localIp`."),
7
+ ReleaseChangelog(version = "3.1.1", markdown = "Software updates are more reliable, with clearer progress and recovery when the glasses restart.\n\n- MentraOS, Mentra Engine, the Bluetooth SDK, and the glasses client now share one coordinated release version.\n- Mentra Live updates can continue across APK, system, and firmware restarts without asking the user to start the same update again.\n- Bluetooth photo capture and transfer diagnostics are more precise and less disruptive to normal glasses traffic.\n- Bluetooth SDK usage analytics now report the host app's version, build type, and install source (store, TestFlight, sideload, simulator), plus the glasses firmware versions on identification, so store usage can be measured separately from development builds.\n- Bluetooth SDK usage analytics now identify G2 glasses by serial, keep long-lived connections visible across day boundaries, and retry failed uploads instead of dropping them."),
7
8
  ReleaseChangelog(version = "3.1.0", markdown = "Software updates are more reliable, with clearer progress and recovery when the glasses restart.\n\n- MentraOS, Mentra Engine, the Bluetooth SDK, and the glasses client now share one coordinated release version.\n- Mentra Live updates can continue across APK, system, and firmware restarts without asking the user to start the same update again.\n- Bluetooth photo capture and transfer diagnostics are more precise and less disruptive to normal glasses traffic.\n- Bluetooth SDK usage analytics now report the host app's version, build type, and install source (store, TestFlight, sideload, simulator), plus the glasses firmware versions on identification, so store usage can be measured separately from development builds.\n- Bluetooth SDK usage analytics now identify G2 glasses by serial, keep long-lived connections visible across day boundaries, and retry failed uploads instead of dropping them."),
8
9
  )
@@ -3,9 +3,9 @@ package com.mentra.bluetoothsdk
3
3
  /** Generated by release CI. Do not edit in a release checkout. */
4
4
  internal object GeneratedReleaseMetadata {
5
5
  const val FAMILY_BASE_VERSION: String = "3.2.0"
6
- const val RELEASE_IDENTITY: String = "3.2.0-dev.235"
7
- const val RELEASE_SET_ID: String = "mentra-3.2.0-dev.235"
8
- const val SOURCE_COMMIT: String = "5f25e2687788b3d964e19d17fd6cd4139e3e677e"
9
- const val OTA_MANIFEST_URL: String = "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.235.json"
10
- const val OTA_MANIFEST_SHA256: String = "a9e9ebec66e2f638ab89f446d87925cdc2c85abf3091ed33e76f01986e658ca7"
6
+ const val RELEASE_IDENTITY: String = "3.2.0-dev.247"
7
+ const val RELEASE_SET_ID: String = "mentra-3.2.0-dev.247"
8
+ const val SOURCE_COMMIT: String = "2be1b25e83b0c8db5cef39294c93b0a3f9ec7aef"
9
+ const val OTA_MANIFEST_URL: String = "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.247.json"
10
+ const val OTA_MANIFEST_SHA256: String = "cba0e05a180f92e98e3bf47d0acdba2ae81cc79788da1393e80334fb5b26093b"
11
11
  }
@@ -19,6 +19,8 @@ import java.util.concurrent.atomic.AtomicLong
19
19
  import kotlinx.coroutines.CancellationException
20
20
  import kotlinx.coroutines.CompletableDeferred
21
21
  import kotlinx.coroutines.delay
22
+ import kotlinx.coroutines.sync.Mutex
23
+ import kotlinx.coroutines.sync.withLock
22
24
  import kotlinx.coroutines.withTimeoutOrNull
23
25
 
24
26
  class MentraBluetoothSdk private constructor(
@@ -73,6 +75,7 @@ class MentraBluetoothSdk private constructor(
73
75
  private var pendingWifiStatus: PendingWifiStatusRequest? = null
74
76
  private var pendingWifiForget: PendingWifiForgetRequest? = null
75
77
  private var pendingHotspotStatus: PendingHotspotStatusRequest? = null
78
+ private val hotspotMutex = Mutex()
76
79
  private var pendingVersionInfo: PendingVersionInfoRequest? = null
77
80
  private val wifiSessionCapabilities = WifiSessionCapabilities()
78
81
  @Volatile private var configuredOtaVersionUrl: String? = null
@@ -100,9 +103,24 @@ class MentraBluetoothSdk private constructor(
100
103
  private val SCAN_STATE_KEYS = setOf("searching", "searchingController", "searchResults")
101
104
  private const val DEFAULT_SCAN_TIMEOUT_MS = 15_000L
102
105
  private const val DEFAULT_REQUEST_TIMEOUT_MS = 15_000L
106
+ // Mirrors AsgConstants: one active + one coalesced pending FOV update, plus BLE margin.
107
+ // Keep in sync with ios/Source/MentraBluetoothSDK.swift and the ASG readiness budget.
108
+ private const val CAMERA_FOV_READY_TIMEOUT_MS = 20_000L
109
+ private const val CAMERA_FOV_DELIVERY_MARGIN_MS = 5_000L
110
+ private const val CAMERA_FOV_REQUEST_TIMEOUT_MS =
111
+ 2 * CAMERA_FOV_READY_TIMEOUT_MS + CAMERA_FOV_DELIVERY_MARGIN_MS
103
112
  // A photo response is terminal only after capture, encoding, transport, and upload.
104
113
  // Max-quality BLE fallback can legitimately exceed the generic command deadline.
105
114
  private const val PHOTO_REQUEST_TIMEOUT_MS = 30_000L
115
+ // Mirrors AsgConstants.PHOTO_THUMBNAIL_REQUEST_TIMEOUT_MS and the iOS facade.
116
+ // Reserve capture, preview ACK/retries, full delivery, then terminal-event transit.
117
+ private const val PHOTO_CAPTURE_TIMEOUT_MS = 45_000L
118
+ private const val PHOTO_THUMBNAIL_TIMEOUT_SECONDS = 30L
119
+ private const val PHOTO_DELIVERY_TIMEOUT_MS = 30_000L
120
+ private const val PHOTO_RESPONSE_MARGIN_MS = 5_000L
121
+ private const val PHOTO_THUMBNAIL_REQUEST_TIMEOUT_MS =
122
+ PHOTO_CAPTURE_TIMEOUT_MS + PHOTO_THUMBNAIL_TIMEOUT_SECONDS * 1000L +
123
+ PHOTO_DELIVERY_TIMEOUT_MS + PHOTO_RESPONSE_MARGIN_MS
106
124
  private const val WIFI_SCAN_TIMEOUT_MS = 20_000L
107
125
  private const val VIDEO_UPLOAD_STOP_TIMEOUT_MS = 10 * 60 * 1000L
108
126
  private const val STREAM_START_TIMEOUT_MS = 30_000L
@@ -597,7 +615,12 @@ class MentraBluetoothSdk private constructor(
597
615
  pendingSettingsRequests[requestId] = pending
598
616
  try {
599
617
  send(requestId)
600
- val ack = pending.await()
618
+ val timeoutMs = if (setting == "camera_fov" || setting == "camera_fov_override") {
619
+ CAMERA_FOV_REQUEST_TIMEOUT_MS
620
+ } else {
621
+ DEFAULT_REQUEST_TIMEOUT_MS
622
+ }
623
+ val ack = pending.await(timeoutMs)
601
624
  updateStore(ack)
602
625
  return ack
603
626
  } finally {
@@ -664,7 +687,7 @@ class MentraBluetoothSdk private constructor(
664
687
  DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_iso_cap", it)
665
688
  }
666
689
  settings.compress?.let {
667
- DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_compress", it)
690
+ DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_compress", it.value)
668
691
  }
669
692
  settings.sound?.let {
670
693
  DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_sound", it)
@@ -1010,23 +1033,23 @@ class MentraBluetoothSdk private constructor(
1010
1033
  }
1011
1034
 
1012
1035
  suspend fun setHotspotState(enabled: Boolean): HotspotStatusEvent {
1013
- val pending = PendingResponse<HotspotStatusEvent>("hotspot ${if (enabled) "enable" else "disable"} request")
1014
- synchronized(oneShotLock) {
1015
- if (pendingHotspotStatus != null) {
1016
- throw BluetoothSdkException(
1017
- "request_in_flight",
1018
- "A hotspot command is already waiting for a glasses response.",
1019
- )
1020
- }
1021
- pendingHotspotStatus = PendingHotspotStatusRequest(enabled, pending)
1022
- }
1023
- try {
1024
- deviceManager.setHotspotState(enabled)
1025
- return pending.await()
1026
- } finally {
1036
+ // SoftAP teardown disables from the transport and again from the host
1037
+ // barrier. Throwing request_in_flight on the second disable recorded a
1038
+ // cleanup error that refused the next join. Queue instead: same-state
1039
+ // and opposite-state both wait their turn, then send.
1040
+ return hotspotMutex.withLock {
1041
+ val pending = PendingResponse<HotspotStatusEvent>("hotspot ${if (enabled) "enable" else "disable"} request")
1027
1042
  synchronized(oneShotLock) {
1028
- if (pendingHotspotStatus?.pending === pending) {
1029
- pendingHotspotStatus = null
1043
+ pendingHotspotStatus = PendingHotspotStatusRequest(enabled, pending)
1044
+ }
1045
+ try {
1046
+ deviceManager.setHotspotState(enabled)
1047
+ pending.await()
1048
+ } finally {
1049
+ synchronized(oneShotLock) {
1050
+ if (pendingHotspotStatus?.pending === pending) {
1051
+ pendingHotspotStatus = null
1052
+ }
1030
1053
  }
1031
1054
  }
1032
1055
  }
@@ -1052,7 +1075,9 @@ class MentraBluetoothSdk private constructor(
1052
1075
  pendingPhotoRequests[routedRequest.requestId] = pending
1053
1076
  try {
1054
1077
  deviceManager.requestPhoto(routedRequest)
1055
- return pending.await(PHOTO_REQUEST_TIMEOUT_MS)
1078
+ return pending.await(
1079
+ if (routedRequest.presendThumbnail) PHOTO_THUMBNAIL_REQUEST_TIMEOUT_MS else PHOTO_REQUEST_TIMEOUT_MS,
1080
+ )
1056
1081
  } finally {
1057
1082
  pendingPhotoRequests.remove(routedRequest.requestId, pending)
1058
1083
  }
@@ -53,13 +53,15 @@ enum class ButtonPhotoSize(val value: String) {
53
53
 
54
54
  enum class PhotoCompression(val value: String) {
55
55
  NONE("none"),
56
+ LOW("low"),
56
57
  MEDIUM("medium"),
57
- HEAVY("heavy");
58
+ HIGH("high");
58
59
 
59
60
  companion object {
60
61
  @JvmStatic
61
- fun fromValue(value: String?): PhotoCompression =
62
- values().firstOrNull { it.value == value } ?: NONE
62
+ fun fromValue(value: Any?): PhotoCompression =
63
+ values().firstOrNull { it.value == value }
64
+ ?: throw IllegalArgumentException("Invalid photo compression $value. Expected none, low, medium, or high.")
63
65
  }
64
66
  }
65
67
 
@@ -73,10 +75,29 @@ data class PhotoCaptureDefaults(
73
75
  val ispAnalogGain: String? = null,
74
76
  val aeExposureDivisor: Int? = null,
75
77
  val isoCap: Int? = null,
76
- val compress: String? = null,
78
+ val compress: PhotoCompression? = null,
77
79
  val sound: Boolean? = null,
78
80
  val resetCaptureTuning: Boolean = false,
79
- )
81
+ ) {
82
+ companion object {
83
+ @JvmStatic
84
+ fun fromMap(values: Map<String, Any?>): PhotoCaptureDefaults =
85
+ PhotoCaptureDefaults(
86
+ size = (values["size"] as? String)?.let { PhotoSize.fromValue(it) },
87
+ mfnr = values["mfnr"] as? Boolean,
88
+ zsl = values["zsl"] as? Boolean,
89
+ noiseReduction = values["noiseReduction"] as? Boolean,
90
+ edgeEnhancement = values["edgeEnhancement"] as? Boolean,
91
+ ispDigitalGain = (values["ispDigitalGain"] as? Number)?.toInt(),
92
+ ispAnalogGain = values["ispAnalogGain"] as? String,
93
+ aeExposureDivisor = (values["aeExposureDivisor"] as? Number)?.toInt(),
94
+ isoCap = (values["isoCap"] as? Number)?.toInt(),
95
+ compress = if (values.containsKey("compress")) PhotoCompression.fromValue(values["compress"]) else null,
96
+ sound = values["sound"] as? Boolean,
97
+ resetCaptureTuning = values["resetCaptureTuning"] as? Boolean == true,
98
+ )
99
+ }
100
+ }
80
101
 
81
102
  data class VideoRecordingDefaults(
82
103
  val width: Int,
@@ -184,7 +205,7 @@ data class PhotoRequest @JvmOverloads constructor(
184
205
  val size: PhotoSize,
185
206
  val webhookUrl: String,
186
207
  val authToken: String? = null,
187
- val compress: PhotoCompression = PhotoCompression.MEDIUM,
208
+ val compress: PhotoCompression = PhotoCompression.NONE,
188
209
  val save: Boolean = false,
189
210
  val sound: Boolean = true,
190
211
  /** Sensor exposure time for this capture only (ns), or null for auto exposure */
@@ -203,6 +224,7 @@ data class PhotoRequest @JvmOverloads constructor(
203
224
  val mode: PhotoMode = PhotoMode.PHOTO,
204
225
  /** `direct` disables BLE fallback; `ble` skips direct upload; `auto` tries both. */
205
226
  val transferMethod: String = "auto",
227
+ val presendThumbnail: Boolean = false,
206
228
  ) {
207
229
  companion object {
208
230
  private fun transferMethodFromValue(value: Any?): String {
@@ -219,7 +241,7 @@ data class PhotoRequest @JvmOverloads constructor(
219
241
 
220
242
  /** Mirrors iOS `BluetoothSdkModule` defaults for keys omitted from the JS bridge. */
221
243
  @JvmStatic
222
- fun fromMap(values: Map<String, Any>): PhotoRequest {
244
+ fun fromMap(values: Map<String, Any?>): PhotoRequest {
223
245
  val rawExp = values["exposureTimeNs"] ?: values["exposure_time_ns"]
224
246
  val exposureTimeNs: Double? =
225
247
  when (rawExp) {
@@ -249,11 +271,12 @@ data class PhotoRequest @JvmOverloads constructor(
249
271
  size = PhotoSize.fromValue(stringValue(values, "size") ?: "medium"),
250
272
  webhookUrl = stringValue(values, "webhookUrl", "webhook_url").orEmpty(),
251
273
  authToken = stringValue(values, "authToken", "auth_token")?.takeIf { it.isNotBlank() },
252
- compress = PhotoCompression.fromValue(stringValue(values, "compress") ?: "none"),
274
+ compress = if (values.containsKey("compress")) PhotoCompression.fromValue(values["compress"]) else PhotoCompression.NONE,
253
275
  save = boolValue(values, "save", "saveToGallery") ?: false,
254
276
  sound = boolValue(values, "sound") ?: true,
255
277
  mode = PhotoMode.fromValue(stringValue(values, "mode")),
256
278
  transferMethod = transferMethodFromValue(values["transferMethod"]),
279
+ presendThumbnail = boolValue(values, "presend_thumbnail") ?: false,
257
280
  exposureTimeNs = exposureTimeNs,
258
281
  iso = iso,
259
282
  aeExposureDivisor = aeDivisor,
@@ -464,6 +487,8 @@ data class PhotoResponseEvent(
464
487
  data class PhotoStatusEvent(
465
488
  val values: Map<String, Any>,
466
489
  ) {
490
+ val thumbnailUrl: String? get() = stringValue(values, "thumbnailUrl")
491
+ val fileSizeBytes: Long? get() = longValue(values, "fileSizeBytes")
467
492
  val requestId: String get() = stringValue(values, "requestId").orEmpty()
468
493
  val status: String get() = stringValue(values, "status").orEmpty()
469
494
  val timestamp: Long get() = longValue(values, "timestamp") ?: System.currentTimeMillis()
@@ -1,7 +1,7 @@
1
1
  package com.mentra.bluetoothsdk
2
2
 
3
3
  internal fun numberValue(
4
- values: Map<String, Any>,
4
+ values: Map<String, Any?>,
5
5
  vararg keys: String,
6
6
  ): Int? =
7
7
  keys.firstNotNullOfOrNull { key ->
@@ -9,7 +9,7 @@ internal fun numberValue(
9
9
  }
10
10
 
11
11
  internal fun doubleValue(
12
- values: Map<String, Any>,
12
+ values: Map<String, Any?>,
13
13
  vararg keys: String,
14
14
  ): Double? =
15
15
  keys.firstNotNullOfOrNull { key ->
@@ -17,7 +17,7 @@ internal fun doubleValue(
17
17
  }
18
18
 
19
19
  internal fun stringValue(
20
- values: Map<String, Any>,
20
+ values: Map<String, Any?>,
21
21
  vararg keys: String,
22
22
  ): String? =
23
23
  keys.firstNotNullOfOrNull { key ->
@@ -25,7 +25,7 @@ internal fun stringValue(
25
25
  }
26
26
 
27
27
  internal fun boolValue(
28
- values: Map<String, Any>,
28
+ values: Map<String, Any?>,
29
29
  vararg keys: String,
30
30
  ): Boolean? =
31
31
  keys.firstNotNullOfOrNull { key ->
@@ -33,7 +33,7 @@ internal fun boolValue(
33
33
  }
34
34
 
35
35
  internal fun longValue(
36
- values: Map<String, Any>,
36
+ values: Map<String, Any?>,
37
37
  vararg keys: String,
38
38
  ): Long? =
39
39
  keys.firstNotNullOfOrNull { key ->
@@ -41,7 +41,7 @@ internal fun longValue(
41
41
  }
42
42
 
43
43
  internal fun hasAnyKey(
44
- values: Map<String, Any>,
44
+ values: Map<String, Any?>,
45
45
  vararg keys: String,
46
46
  ): Boolean = keys.any(values::containsKey)
47
47
 
@@ -53,7 +53,7 @@ internal fun Map<*, *>.stringKeyedMap(): Map<String, Any> =
53
53
  }.toMap()
54
54
 
55
55
  internal fun optionalNumberValue(
56
- values: Map<String, Any>,
56
+ values: Map<String, Any?>,
57
57
  vararg keys: String,
58
58
  ): Int? =
59
59
  if (hasAnyKey(values, *keys)) {
@@ -63,7 +63,7 @@ internal fun optionalNumberValue(
63
63
  }
64
64
 
65
65
  internal fun optionalLongValue(
66
- values: Map<String, Any>,
66
+ values: Map<String, Any?>,
67
67
  vararg keys: String,
68
68
  ): Long? =
69
69
  if (hasAnyKey(values, *keys)) {
@@ -73,7 +73,7 @@ internal fun optionalLongValue(
73
73
  }
74
74
 
75
75
  internal fun optionalStringValue(
76
- values: Map<String, Any>,
76
+ values: Map<String, Any?>,
77
77
  vararg keys: String,
78
78
  ): String? =
79
79
  if (hasAnyKey(values, *keys)) {
@@ -83,7 +83,7 @@ internal fun optionalStringValue(
83
83
  }
84
84
 
85
85
  internal fun optionalBoolValue(
86
- values: Map<String, Any>,
86
+ values: Map<String, Any?>,
87
87
  vararg keys: String,
88
88
  ): Boolean? =
89
89
  if (hasAnyKey(values, *keys)) {
@@ -93,12 +93,12 @@ internal fun optionalBoolValue(
93
93
  }
94
94
 
95
95
  internal fun stringListValue(
96
- values: Map<String, Any>,
96
+ values: Map<String, Any?>,
97
97
  key: String,
98
98
  ): List<String> = (values[key] as? List<*>)?.mapNotNull { it as? String } ?: emptyList()
99
99
 
100
100
  internal fun optionalStringListValue(
101
- values: Map<String, Any>,
101
+ values: Map<String, Any?>,
102
102
  key: String,
103
103
  ): List<String>? =
104
104
  if (values.containsKey(key)) {
@@ -108,13 +108,13 @@ internal fun optionalStringListValue(
108
108
  }
109
109
 
110
110
  internal fun mapListValue(
111
- values: Map<String, Any>,
111
+ values: Map<String, Any?>,
112
112
  key: String,
113
113
  ): List<Map<String, Any>> =
114
114
  (values[key] as? List<*>)?.mapNotNull(::stringMapValue) ?: emptyList()
115
115
 
116
116
  internal fun optionalMapListValue(
117
- values: Map<String, Any>,
117
+ values: Map<String, Any?>,
118
118
  key: String,
119
119
  ): List<Map<String, Any>>? =
120
120
  if (values.containsKey(key)) {
@@ -1,5 +1,7 @@
1
1
  package com.mentra.bluetoothsdk.sgcs
2
2
 
3
+ import com.mentra.bluetoothsdk.PhotoCompression
4
+
3
5
  // Mentra
4
6
  // old augmentos imports:
5
7
  import android.Manifest
@@ -729,6 +731,7 @@ class MentraLive : SGCManager() {
729
731
  var requestId: String,
730
732
  var webhookUrl: String?
731
733
  ) {
734
+ var isThumbnail: Boolean = false
732
735
  var authToken: String? = null
733
736
  var session: FileTransferSession? = null
734
737
  var phoneStartTime: Long = System.currentTimeMillis() // When phone received the request
@@ -5203,6 +5206,20 @@ class MentraLive : SGCManager() {
5203
5206
  val bleImgId = json.optString("bleImgId", "")
5204
5207
  val requestId = json.optString("requestId", "")
5205
5208
  val compressionDurationMs = json.optLong("compressionDurationMs", 0)
5209
+ if (json.optBoolean("thumbnail", false) && bleImgId.isNotEmpty() && requestId.isNotEmpty()) {
5210
+ val parent = blePhotoTransfers.values.firstOrNull {
5211
+ !it.isThumbnail && it.requestId == requestId &&
5212
+ bleImgId == "T" + it.bleImgId.drop(1)
5213
+ }
5214
+ if (parent == null) {
5215
+ Log.w(TAG, "Ignoring thumbnail for an unknown photo request")
5216
+ return
5217
+ }
5218
+ // Duplicate ready messages must not erase packets already received.
5219
+ blePhotoTransfers.getOrPut(bleImgId) {
5220
+ BlePhotoTransfer(bleImgId, requestId, null).apply { isThumbnail = true }
5221
+ }
5222
+ }
5206
5223
 
5207
5224
  Bridge.log(
5208
5225
  "LIVE: 📸 BLE photo ready notification: bleImgId=" +
@@ -6699,13 +6716,10 @@ class MentraLive : SGCManager() {
6699
6716
  json.put("size", size)
6700
6717
  }
6701
6718
  json.put("mode", mode)
6702
- if (compress != null && !compress.isEmpty()) {
6703
- json.put("compress", compress)
6704
- } else {
6705
- json.put("compress", "none")
6706
- }
6719
+ json.put("compress", compress)
6707
6720
  json.put("save", save)
6708
6721
  json.put("sound", sound)
6722
+ if (request.presendThumbnail) json.put("presend_thumbnail", true)
6709
6723
  if (exposureTimeNs != null && exposureTimeNs > 0L) {
6710
6724
  Bridge.log(
6711
6725
  "LIVE: Using manual exposure time for photo request " +
@@ -7918,7 +7932,7 @@ class MentraLive : SGCManager() {
7918
7932
  command.put("isoCap", isoCap)
7919
7933
  }
7920
7934
  if (!compress.isNullOrEmpty()) {
7921
- command.put("compress", compress)
7935
+ command.put("compress", PhotoCompression.fromValue(compress).value)
7922
7936
  }
7923
7937
  if (sound != null) {
7924
7938
  command.put("sound", sound)
@@ -10408,6 +10422,17 @@ class MentraLive : SGCManager() {
10408
10422
 
10409
10423
  /** Process and upload a BLE photo transfer */
10410
10424
  private fun processAndUploadBlePhoto(transfer: BlePhotoTransfer, imageData: ByteArray) {
10425
+ if (transfer.isThumbnail) {
10426
+ Bridge.sendPhotoStatus(mapOf(
10427
+ "type" to "photo_status",
10428
+ "requestId" to transfer.requestId,
10429
+ "status" to "thumbnail_received",
10430
+ "timestamp" to System.currentTimeMillis(),
10431
+ "thumbnailUrl" to ("data:image/jpeg;base64," + android.util.Base64.encodeToString(imageData, android.util.Base64.NO_WRAP)),
10432
+ "fileSizeBytes" to imageData.size,
10433
+ ))
10434
+ return
10435
+ }
10411
10436
  Bridge.log("LIVE: Processing BLE photo for upload. RequestId: " + transfer.requestId)
10412
10437
  val uploadStartTime = System.currentTimeMillis()
10413
10438
 
@@ -1,5 +1,6 @@
1
1
  package com.mentra.bluetoothsdk.camera
2
2
 
3
+ import com.mentra.bluetoothsdk.PhotoCaptureDefaults
3
4
  import com.mentra.bluetoothsdk.PhotoCompression
4
5
  import com.mentra.bluetoothsdk.PhotoMode
5
6
  import com.mentra.bluetoothsdk.PhotoRequest
@@ -9,6 +10,37 @@ import org.assertj.core.api.Assertions.assertThatThrownBy
9
10
  import org.junit.Test
10
11
 
11
12
  class PhotoRequestTest {
13
+ @Test
14
+ fun `compression has four exact wire values and defaults to none`() {
15
+ assertThat(PhotoCompression.values().map { it.value }).containsExactly("none", "low", "medium", "high")
16
+ for (compression in PhotoCompression.values()) {
17
+ assertThat(PhotoRequest.fromMap(mapOf("compress" to compression.value)).compress).isEqualTo(compression)
18
+ }
19
+ assertThat(PhotoRequest(size = PhotoSize.MEDIUM, webhookUrl = "https://example.com/upload", sound = true).compress).isEqualTo(PhotoCompression.NONE)
20
+ assertThat(PhotoRequest.fromMap(emptyMap()).compress).isEqualTo(PhotoCompression.NONE)
21
+ for (invalid in listOf("heavy", "", "HIGH", 1, false, null)) {
22
+ assertThatThrownBy { PhotoRequest.fromMap(mapOf("compress" to invalid)) }
23
+ .isInstanceOf(IllegalArgumentException::class.java)
24
+ assertThatThrownBy { PhotoCaptureDefaults.fromMap(mapOf("compress" to invalid)) }
25
+ .isInstanceOf(IllegalArgumentException::class.java)
26
+ }
27
+ assertThat(PhotoCaptureDefaults.fromMap(emptyMap()).compress).isNull()
28
+ for (compression in PhotoCompression.values()) {
29
+ assertThat(PhotoCaptureDefaults.fromMap(mapOf("compress" to compression.value)).compress).isEqualTo(compression)
30
+ }
31
+ // Nullable non-compression fields still follow the existing omission convention.
32
+ assertThat(PhotoRequest.fromMap(mapOf("authToken" to null)).compress).isEqualTo(PhotoCompression.NONE)
33
+ }
34
+
35
+ @Test
36
+ fun `thumbnail is opt in and survives request routing copies`() {
37
+ val fields = mapOf("size" to "medium", "webhookUrl" to "https://example.com/upload")
38
+ assertThat(PhotoRequest.fromMap(fields).presendThumbnail).isFalse()
39
+ assertThat(PhotoRequest.fromMap(fields + ("presend_thumbnail" to false)).presendThumbnail).isFalse()
40
+ val request = PhotoRequest.fromMap(fields + ("presend_thumbnail" to true))
41
+ assertThat(request.copy(requestId = "routed").presendThumbnail).isTrue()
42
+ assertThat(request.transferMethod).isEqualTo("auto")
43
+ }
12
44
  @Test
13
45
  fun `constructor generates requestId when omitted`() {
14
46
  val request =