@mentra/bluetooth-sdk 0.1.20 → 0.1.21-beta.0

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 (73) hide show
  1. package/README.md +5 -1
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +127 -24
  3. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +65 -14
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +46 -3
  5. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +1 -1
  6. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +369 -97
  7. package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +333 -0
  8. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +3 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +1 -1
  10. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +16 -9
  11. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Mach1.kt +1 -1
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +183 -30
  13. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +1 -1
  14. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +1 -1
  15. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +4 -2
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +1 -1
  17. package/android/src/main/java/com/mentra/bluetoothsdk/status/WifiHotspotStatus.kt +19 -3
  18. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +15 -2
  19. package/android/src/main/java/com/mentra/bluetoothsdk/tts/TTSTools.kt +115 -47
  20. package/android/src/main/java/com/mentra/bluetoothsdk/utils/audio/PcmStreamPlayer.kt +328 -0
  21. package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +15 -0
  22. package/build/BluetoothSdk.types.d.ts +46 -1
  23. package/build/BluetoothSdk.types.d.ts.map +1 -1
  24. package/build/BluetoothSdk.types.js.map +1 -1
  25. package/build/_internal.d.ts +2 -0
  26. package/build/_internal.d.ts.map +1 -1
  27. package/build/_internal.js +2 -0
  28. package/build/_internal.js.map +1 -1
  29. package/build/_private/BluetoothSdkModule.d.ts +23 -2
  30. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  31. package/build/_private/BluetoothSdkModule.js +20 -0
  32. package/build/_private/BluetoothSdkModule.js.map +1 -1
  33. package/build/_private/MentraLocalNetworkModule.d.ts +37 -0
  34. package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -0
  35. package/build/_private/MentraLocalNetworkModule.js +3 -0
  36. package/build/_private/MentraLocalNetworkModule.js.map +1 -0
  37. package/build/_private/cameraRequestPayload.d.ts.map +1 -1
  38. package/build/_private/cameraRequestPayload.js +1 -0
  39. package/build/_private/cameraRequestPayload.js.map +1 -1
  40. package/build/_private/photoRequestPayload.d.ts.map +1 -1
  41. package/build/_private/photoRequestPayload.js +1 -0
  42. package/build/_private/photoRequestPayload.js.map +1 -1
  43. package/build/index.d.ts.map +1 -1
  44. package/build/index.js +4 -0
  45. package/build/index.js.map +1 -1
  46. package/expo-module.config.json +1 -0
  47. package/ios/BluetoothSdkModule.swift +72 -0
  48. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  49. package/ios/Source/Bridge.swift +72 -18
  50. package/ios/Source/DeviceManager.swift +51 -3
  51. package/ios/Source/DeviceStore.swift +1 -1
  52. package/ios/Source/MentraBluetoothSDK.swift +289 -25
  53. package/ios/Source/audio/PcmStreamPlayer.swift +410 -0
  54. package/ios/Source/camera/CameraModels.swift +8 -3
  55. package/ios/Source/sgcs/G1.swift +1 -1
  56. package/ios/Source/sgcs/G2.swift +19 -7
  57. package/ios/Source/sgcs/Mach1.swift +1 -1
  58. package/ios/Source/sgcs/MentraLive.swift +318 -37
  59. package/ios/Source/sgcs/MentraNex.swift +1 -1
  60. package/ios/Source/sgcs/Nimo.swift +1 -1
  61. package/ios/Source/sgcs/SGCManager.swift +5 -2
  62. package/ios/Source/sgcs/Simulated.swift +1 -1
  63. package/ios/Source/status/WifiHotspotStatus.swift +21 -2
  64. package/ios/Source/streaming/StreamModels.swift +9 -0
  65. package/ios/Source/tts/TTSTools.swift +1 -1
  66. package/package.json +1 -2
  67. package/src/BluetoothSdk.types.ts +49 -0
  68. package/src/_internal.ts +2 -0
  69. package/src/_private/BluetoothSdkModule.ts +64 -13
  70. package/src/_private/MentraLocalNetworkModule.ts +50 -0
  71. package/src/_private/cameraRequestPayload.ts +1 -0
  72. package/src/_private/photoRequestPayload.ts +2 -3
  73. package/src/index.ts +4 -0
package/README.md CHANGED
@@ -380,12 +380,16 @@ const photo = await BluetoothSdk.requestPhoto({
380
380
  console.log('photo delivered', photo.photoUrl ?? photo.uploadUrl, photo.fileSizeBytes)
381
381
  ```
382
382
 
383
- `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 before the command timeout. 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.
383
+ `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.
384
384
 
385
385
  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`.
386
386
 
387
+ 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.
388
+
387
389
  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.
388
390
 
391
+ 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.
392
+
389
393
  `startVideoRecording(...)` resolves from the ASG `video_recording_status` event whose status is `recording_started`. `stopVideoRecording(...)` without a webhook resolves from `recording_stopped`; with a webhook it waits for `recording_stopped` plus a video `media_success`, and rejects on `media_error`. Raw `video_recording_status`, `media_success`, and `media_error` events remain available through listeners.
390
394
 
391
395
  ## Streaming
@@ -4,6 +4,7 @@ package com.mentra.bluetoothsdk
4
4
 
5
5
  import com.mentra.bluetoothsdk.debug.BleTraceLogger
6
6
  import com.mentra.bluetoothsdk.utils.DeviceTypes
7
+ import com.mentra.bluetoothsdk.utils.audio.PcmStreamManager
7
8
  import expo.modules.kotlin.exception.CodedException
8
9
  import expo.modules.kotlin.functions.Coroutine
9
10
  import expo.modules.kotlin.modules.Module
@@ -87,6 +88,45 @@ private inline fun <reified R> ModuleDefinitionBuilder.SdkCoroutineFunction(
87
88
  crossinline body: suspend () -> R,
88
89
  ) = AsyncFunction(name) Coroutine { -> withExpoSdkErrorSuspend { body() } }
89
90
 
91
+ private inline fun <reified R, reified P0> ModuleDefinitionBuilder.SdkCoroutineFunction(
92
+ name: String,
93
+ crossinline body: suspend (P0) -> R,
94
+ ) = AsyncFunction(name) Coroutine { p0: P0 -> withExpoSdkErrorSuspend { body(p0) } }
95
+
96
+ private inline fun <reified R, reified P0, reified P1> ModuleDefinitionBuilder.SdkCoroutineFunction(
97
+ name: String,
98
+ crossinline body: suspend (P0, P1) -> R,
99
+ ) = AsyncFunction(name) Coroutine { p0: P0, p1: P1 -> withExpoSdkErrorSuspend { body(p0, p1) } }
100
+
101
+ private inline fun <reified R, reified P0, reified P1, reified P2> ModuleDefinitionBuilder.SdkCoroutineFunction(
102
+ name: String,
103
+ crossinline body: suspend (P0, P1, P2) -> R,
104
+ ) = AsyncFunction(name) Coroutine { p0: P0, p1: P1, p2: P2 -> withExpoSdkErrorSuspend { body(p0, p1, p2) } }
105
+
106
+ private inline fun <reified R, reified P0, reified P1, reified P2, reified P3>
107
+ ModuleDefinitionBuilder.SdkCoroutineFunction(
108
+ name: String,
109
+ crossinline body: suspend (P0, P1, P2, P3) -> R,
110
+ ) = AsyncFunction(name) Coroutine { p0: P0, p1: P1, p2: P2, p3: P3 ->
111
+ withExpoSdkErrorSuspend { body(p0, p1, p2, p3) }
112
+ }
113
+
114
+ private inline fun <
115
+ reified R,
116
+ reified P0,
117
+ reified P1,
118
+ reified P2,
119
+ reified P3,
120
+ reified P4,
121
+ reified P5,
122
+ reified P6,
123
+ > ModuleDefinitionBuilder.SdkCoroutineFunction(
124
+ name: String,
125
+ crossinline body: suspend (P0, P1, P2, P3, P4, P5, P6) -> R,
126
+ ) = AsyncFunction(name) Coroutine { p0: P0, p1: P1, p2: P2, p3: P3, p4: P4, p5: P5, p6: P6 ->
127
+ withExpoSdkErrorSuspend { body(p0, p1, p2, p3, p4, p5, p6) }
128
+ }
129
+
90
130
  class BluetoothSdkModule : Module() {
91
131
  private var sdk: MentraBluetoothSdk? = null
92
132
  private var deviceManager: DeviceManager? = null
@@ -360,6 +400,7 @@ class BluetoothSdkModule : Module() {
360
400
  "module_destroy"
361
401
  )
362
402
  sdk?.close()
403
+ PcmStreamManager.abortAll()
363
404
  sdk = null
364
405
  deviceManager = null
365
406
  }
@@ -496,17 +537,17 @@ class BluetoothSdkModule : Module() {
496
537
 
497
538
  // MARK: - WiFi Commands
498
539
 
499
- SdkAsyncFunction("requestWifiScan") { -> requireSdk().requestWifiScan().map { it.toMap() } }
540
+ SdkCoroutineFunction("requestWifiScan") { -> requireSdk().requestWifiScan().map { it.toMap() } }
500
541
 
501
- SdkAsyncFunction("sendWifiCredentials") { ssid: String, password: String ->
542
+ SdkCoroutineFunction("sendWifiCredentials") { ssid: String, password: String ->
502
543
  requireSdk().sendWifiCredentials(ssid, password).values
503
544
  }
504
545
 
505
- SdkAsyncFunction("forgetWifiNetwork") { ssid: String ->
546
+ SdkCoroutineFunction("forgetWifiNetwork") { ssid: String ->
506
547
  requireSdk().forgetWifiNetwork(ssid).values
507
548
  }
508
549
 
509
- SdkAsyncFunction("setHotspotState") { enabled: Boolean ->
550
+ SdkCoroutineFunction("setHotspotState") { enabled: Boolean ->
510
551
  requireSdk().setHotspotState(enabled).values
511
552
  }
512
553
 
@@ -516,7 +557,7 @@ class BluetoothSdkModule : Module() {
516
557
 
517
558
  // MARK: - Gallery Commands
518
559
 
519
- SdkAsyncFunction("setGalleryModeEnabled") { enabled: Boolean ->
560
+ SdkCoroutineFunction("setGalleryModeEnabled") { enabled: Boolean ->
520
561
  requireSdk().setGalleryModeEnabled(enabled).values
521
562
  }
522
563
 
@@ -525,19 +566,19 @@ class BluetoothSdkModule : Module() {
525
566
  }
526
567
 
527
568
  @Suppress("DEPRECATION")
528
- SdkAsyncFunction("setPhotoCaptureDefaults") { params: Map<String, Any?> ->
569
+ SdkCoroutineFunction("setPhotoCaptureDefaults") { params: Map<String, Any?> ->
529
570
  requireSdk().setPhotoCaptureDefaults(params.toPhotoCaptureDefaults()).values
530
571
  }
531
572
 
532
- SdkAsyncFunction("setVideoRecordingDefaults") { width: Int, height: Int, fps: Int ->
573
+ SdkCoroutineFunction("setVideoRecordingDefaults") { width: Int, height: Int, fps: Int ->
533
574
  requireSdk().setVideoRecordingDefaults(VideoRecordingDefaults(width, height, fps)).values
534
575
  }
535
576
 
536
- SdkAsyncFunction("setMaxVideoRecordingDuration") { minutes: Int ->
577
+ SdkCoroutineFunction("setMaxVideoRecordingDuration") { minutes: Int ->
537
578
  requireSdk().setMaxVideoRecordingDuration(minutes).values
538
579
  }
539
580
 
540
- SdkAsyncFunction("setCameraFov") { fov: Map<String, Any> ->
581
+ SdkCoroutineFunction("setCameraFov") { fov: Map<String, Any> ->
541
582
  val value = (fov["fov"] as? Number)?.toInt() ?: CameraFov.DEFAULT_FOV
542
583
  val roiPosition = CameraRoiPosition.fromValue(
543
584
  (fov["roiPosition"] as? Number)?.toInt()
@@ -546,13 +587,41 @@ class BluetoothSdkModule : Module() {
546
587
  requireSdk().setCameraFov(CameraFov(value, roiPosition)).values
547
588
  }
548
589
 
549
- SdkAsyncFunction("setCameraTuningConfig") { anrOn: Boolean, gainOn: Boolean ->
590
+ SdkCoroutineFunction("setLegacyCameraFov") { fov: Map<String, Any> ->
591
+ val value = (fov["fov"] as? Number)?.toInt() ?: CameraFov.DEFAULT_FOV
592
+ val roiPosition = CameraRoiPosition.fromValue(
593
+ (fov["roiPosition"] as? Number)?.toInt()
594
+ ?: (fov["roi_position"] as? Number)?.toInt(),
595
+ )
596
+ requireSdk().setLegacyCameraFov(CameraFov(value, roiPosition)).values
597
+ }
598
+
599
+ SdkCoroutineFunction("restoreLegacyCameraFov") { ->
600
+ requireSdk().restoreLegacyCameraFov()
601
+ }
602
+
603
+ SdkCoroutineFunction("setCameraFovOverride") { params: Map<String, Any> ->
604
+ val leaseId = params["leaseId"] as? String ?: throw IllegalArgumentException("leaseId is required")
605
+ val value = (params["fov"] as? Number)?.toInt() ?: CameraFov.DEFAULT_FOV
606
+ val roiPosition = CameraRoiPosition.fromValue(
607
+ (params["roiPosition"] as? Number)?.toInt()
608
+ ?: (params["roi_position"] as? Number)?.toInt(),
609
+ )
610
+ val ttlMs = (params["ttlMs"] as? Number)?.toInt() ?: 300000
611
+ requireSdk().setCameraFovOverride(leaseId, CameraFov(value, roiPosition), ttlMs).values
612
+ }
613
+
614
+ SdkCoroutineFunction("releaseCameraFovOverride") { leaseId: String ->
615
+ requireSdk().releaseCameraFovOverride(leaseId).values
616
+ }
617
+
618
+ SdkCoroutineFunction("setCameraTuningConfig") { anrOn: Boolean, gainOn: Boolean ->
550
619
  requireSdk().setCameraTuningConfig(anrOn, gainOn).values
551
620
  }
552
621
 
553
- SdkAsyncFunction("queryGalleryStatus") { -> requireSdk().queryGalleryStatus().values }
622
+ SdkCoroutineFunction("queryGalleryStatus") { -> requireSdk().queryGalleryStatus().values }
554
623
 
555
- SdkAsyncFunction("requestPhoto") { params: Map<String, Any?> ->
624
+ SdkCoroutineFunction("requestPhoto") { params: Map<String, Any?> ->
556
625
  // JS may pass null for optional fields; Map<String, Any> rejects null values at the bridge.
557
626
  val sanitized =
558
627
  params.mapNotNull { (key, value) ->
@@ -565,15 +634,20 @@ class BluetoothSdkModule : Module() {
565
634
  requireSdk().requestPhoto(req).values
566
635
  }
567
636
 
568
- SdkAsyncFunction("warmUpCamera") { params: Map<String, Any?> ->
637
+ SdkCoroutineFunction("warmUpCamera") { params: Map<String, Any?> ->
569
638
  val requestId = params["requestId"] as? String
570
639
  val size = PhotoSize.fromValue(params["size"] as? String)
640
+ val mode = PhotoMode.fromValue(params["mode"] as? String)
571
641
  val exposureRaw = (params["exposureTimeNs"] as? Number)?.toDouble()
572
642
  val exposureTimeNs =
573
643
  if (exposureRaw != null && exposureRaw.isFinite() && exposureRaw > 0) exposureRaw.toLong() else null
574
644
  val durationRaw = (params["durationMs"] as? Number)?.toInt() ?: 0
575
645
  val durationMs = if (durationRaw > 0) durationRaw else 15000
576
- requireSdk().warmUpCamera(requestId, size, exposureTimeNs, durationMs).values
646
+ requireSdk().warmUpCamera(requestId, size, mode, exposureTimeNs, durationMs).values
647
+ }
648
+
649
+ SdkAsyncFunction("stopCameraWarmUp") { requestId: String ->
650
+ requireSdk().stopCameraWarmUp(requestId)
577
651
  }
578
652
 
579
653
  // MARK: - OTA Commands
@@ -586,11 +660,11 @@ class BluetoothSdkModule : Module() {
586
660
 
587
661
  // Runs on Dispatchers.IO, not the shared Expo AsyncFunctionQueue:
588
662
  // manifest fetches and version waits can block for several seconds.
589
- SdkCoroutineFunction("checkForOtaUpdate") {
663
+ SdkCoroutineFunction("checkForOtaUpdate") { ->
590
664
  withContext(Dispatchers.IO) { requireSdk().checkForOtaUpdate() }
591
665
  }
592
666
 
593
- SdkAsyncFunction("startOtaUpdate") { otaVersionUrl: String? ->
667
+ SdkCoroutineFunction("startOtaUpdate") { otaVersionUrl: String? ->
594
668
  val sdk = requireSdk()
595
669
  if (otaVersionUrl.isNullOrBlank()) {
596
670
  sdk.startOtaUpdate().values
@@ -599,11 +673,11 @@ class BluetoothSdkModule : Module() {
599
673
  }
600
674
  }
601
675
 
602
- SdkAsyncFunction("sendOtaQueryStatus") { -> requireSdk().sendOtaQueryStatus().values }
676
+ SdkCoroutineFunction("sendOtaQueryStatus") { -> requireSdk().sendOtaQueryStatus().values }
603
677
 
604
678
  // MARK: - Version Info Commands
605
679
 
606
- SdkAsyncFunction("requestVersionInfo") { -> requireSdk().requestVersionInfo().toMap() }
680
+ SdkCoroutineFunction("requestVersionInfo") { -> requireSdk().requestVersionInfo().toMap() }
607
681
 
608
682
  // MARK: - Power Control Commands
609
683
 
@@ -613,7 +687,7 @@ class BluetoothSdkModule : Module() {
613
687
 
614
688
  // MARK: - Video Recording Commands
615
689
 
616
- SdkAsyncFunction("startVideoRecording") {
690
+ SdkCoroutineFunction("startVideoRecording") {
617
691
  requestId: String,
618
692
  save: Boolean,
619
693
  sound: Boolean,
@@ -637,7 +711,7 @@ class BluetoothSdkModule : Module() {
637
711
 
638
712
  // webhookUrl/authToken are supplied at stop (not start) so the token is
639
713
  // fresh when the upload runs. Empty/null webhook = keep on device.
640
- SdkAsyncFunction("stopVideoRecording") {
714
+ SdkCoroutineFunction("stopVideoRecording") {
641
715
  requestId: String,
642
716
  webhookUrl: String?,
643
717
  authToken: String? ->
@@ -646,15 +720,15 @@ class BluetoothSdkModule : Module() {
646
720
 
647
721
  // MARK: - Stream Commands
648
722
 
649
- SdkAsyncFunction("startStream") { params: Map<String, Any> ->
723
+ SdkCoroutineFunction("startStream") { params: Map<String, Any> ->
650
724
  requireSdk().startStream(StreamRequest.fromMap(params)).values
651
725
  }
652
726
 
653
- SdkAsyncFunction("startExternallyManagedStream") { params: Map<String, Any> ->
727
+ SdkCoroutineFunction("startExternallyManagedStream") { params: Map<String, Any> ->
654
728
  requireSdk().startExternallyManagedStream(StreamRequest.fromMap(params)).values
655
729
  }
656
730
 
657
- SdkAsyncFunction("stopStream") { -> requireSdk().stopStream().values }
731
+ SdkCoroutineFunction("stopStream") { -> requireSdk().stopStream().values }
658
732
 
659
733
  SdkAsyncFunction("sendExternallyManagedStreamKeepAlive") { params: Map<String, Any> ->
660
734
  sdk?.sendExternallyManagedStreamKeepAlive(StreamKeepAliveRequest.fromMap(params))
@@ -688,6 +762,35 @@ class BluetoothSdkModule : Module() {
688
762
  sdk?.setOwnAppAudioPlaying(playing)
689
763
  }
690
764
 
765
+ // MARK: - Live PCM output stream (miniapp speaker.createStream)
766
+ //
767
+ // MentraOS-internal. 16-bit LE PCM chunks into a streaming AudioTrack
768
+ // (USAGE_MEDIA → follows the media route, e.g. A2DP to glasses).
769
+ // write/close run *blocking on Dispatchers.IO*, not the shared Expo
770
+ // AsyncFunctionQueue: write intentionally blocks for backpressure and
771
+ // close blocks until the backlog drains — either would otherwise stall
772
+ // every other native call queued behind them.
773
+
774
+ AsyncFunction("pcmStreamOpen") { streamId: String, sampleRate: Int, channels: Int, volume: Double ->
775
+ PcmStreamManager.open(streamId, sampleRate, channels, volume.toFloat())
776
+ }
777
+
778
+ AsyncFunction("pcmStreamWrite") Coroutine { streamId: String, base64: String ->
779
+ withContext(Dispatchers.IO) {
780
+ mapOf("bufferedMs" to PcmStreamManager.write(streamId, base64))
781
+ }
782
+ }
783
+
784
+ AsyncFunction("pcmStreamClose") Coroutine { streamId: String ->
785
+ withContext(Dispatchers.IO) {
786
+ mapOf("durationMs" to PcmStreamManager.close(streamId))
787
+ }
788
+ }
789
+
790
+ AsyncFunction("pcmStreamAbort") { streamId: String ->
791
+ PcmStreamManager.abort(streamId)
792
+ }
793
+
691
794
  // *Blocking on Dispatchers.IO, not the shared AsyncFunctionQueue: these wait on
692
795
  // a CountDownLatch (up to 5s) for a BLE round-trip, which would otherwise stall
693
796
  // every other native call queued behind them.
@@ -703,7 +806,7 @@ class BluetoothSdkModule : Module() {
703
806
 
704
807
  // MARK: - RGB LED Control
705
808
 
706
- SdkAsyncFunction("rgbLedControl") {
809
+ SdkCoroutineFunction("rgbLedControl") {
707
810
  requestId: String,
708
811
  packageName: String?,
709
812
  action: String,
@@ -520,30 +520,81 @@ public class Bridge private constructor() {
520
520
  sendTypedMessage("glasses_serial_number", body as Map<String, Any>)
521
521
  }
522
522
 
523
- /** Send WiFi status change */
523
+ /**
524
+ * Send WiFi status change. [error] is the glasses' provisioning failure reason
525
+ * (e.g. "connect_timeout", "connected_to_other_network") when this status is the
526
+ * verdict of a failed connect attempt; null for routine link-state updates.
527
+ */
524
528
  @JvmStatic
525
- fun sendWifiStatusChange(connected: Boolean, ssid: String?, localIp: String?) {
529
+ @JvmOverloads
530
+ fun sendWifiStatusChange(
531
+ connected: Boolean,
532
+ ssid: String?,
533
+ localIp: String?,
534
+ error: String? = null
535
+ ) {
526
536
  val status = WifiStatus.fromStoreFields(connected, ssid, localIp) ?: return
527
- sendTypedMessage("wifi_status_change", status.toMap())
537
+ val payload =
538
+ if (error != null) status.toMap() + mapOf("error" to error)
539
+ else status.toMap()
540
+ sendTypedMessage("wifi_status_change", payload)
541
+ }
542
+
543
+ /**
544
+ * Claim the WiFi scan-results store for a newly requested scan. Called by the
545
+ * SDK when it generates the scanId, BEFORE the scan command goes out: store
546
+ * ownership is decided at request time, not by whichever chunk arrives first,
547
+ * so a delayed chunk from an older, abandoned scan can never reset or clobber
548
+ * the current scan's accumulator.
549
+ */
550
+ @JvmStatic
551
+ fun claimWifiScanResults(scanId: String) {
552
+ DeviceStore.apply("bluetooth", "wifiScanActiveScanId", scanId)
528
553
  }
529
554
 
530
555
  /** Send WiFi scan results */
531
556
  @JvmStatic
532
- fun updateWifiScanResults(networks: List<Map<String, Any>>, scanComplete: Boolean) {
533
- var storedNetworks: List<Map<String, Any>> =
534
- DeviceStore.get("bluetooth", "wifiScanResults") as? List<Map<String, Any>>
535
- ?: emptyList()
536
- // add the networks to the storedNetworks array, removing duplicates by ssid
537
- val updatedNetworks = storedNetworks.toMutableList()
538
- for (network in networks) {
539
- if (!updatedNetworks.any { it["ssid"] as? String == network["ssid"] as? String }) {
540
- updatedNetworks.add(network)
557
+ fun updateWifiScanResults(
558
+ networks: List<Map<String, Any>>,
559
+ scanComplete: Boolean,
560
+ scanId: String? = null
561
+ ) {
562
+ // Only chunks echoing the active scanId claimed at request time may mutate
563
+ // the store; foreign chunks are still forwarded to the SDK sink, which
564
+ // drops stale ids itself. Scan-id-less chunks (old firmware) keep the
565
+ // legacy accumulate-forever store behavior.
566
+ val ownsStore =
567
+ scanId == null || scanId == DeviceStore.get("bluetooth", "wifiScanActiveScanId")
568
+ var updatedNetworks: List<Map<String, Any>> = networks
569
+ if (ownsStore) {
570
+ var storedNetworks: List<Map<String, Any>> =
571
+ DeviceStore.get("bluetooth", "wifiScanResults") as? List<Map<String, Any>>
572
+ ?: emptyList()
573
+ val lastScanId = DeviceStore.get("bluetooth", "wifiScanResultsScanId")
574
+ if (scanId != null && scanId != lastScanId) {
575
+ // First chunk of a new scan: drop networks accumulated for a previous scan
576
+ // so stale entries never carry over into this scan's store.
577
+ storedNetworks = emptyList()
578
+ DeviceStore.apply("bluetooth", "wifiScanResultsScanId", scanId)
541
579
  }
580
+ // add the networks to the storedNetworks array, removing duplicates by ssid
581
+ val merged = storedNetworks.toMutableList()
582
+ for (network in networks) {
583
+ if (!merged.any { it["ssid"] as? String == network["ssid"] as? String }) {
584
+ merged.add(network)
585
+ }
586
+ }
587
+ DeviceStore.apply("bluetooth", "wifiScanResults", merged)
588
+ updatedNetworks = merged
542
589
  }
543
- DeviceStore.apply("bluetooth", "wifiScanResults", updatedNetworks)
544
590
  val body = HashMap<String, Any>()
545
- body["networks"] = updatedNetworks
591
+ // Correlated scans: the SDK accumulates and dedupes chunks per scanId itself,
592
+ // so forward only this chunk; the merged store list is for UI consumers.
593
+ body["networks"] = if (scanId != null) networks else updatedNetworks
546
594
  body["scanComplete"] = scanComplete
595
+ if (scanId != null) {
596
+ body["scanId"] = scanId
597
+ }
547
598
  sendTypedMessage("wifi_scan_result", body)
548
599
  }
549
600
 
@@ -329,6 +329,11 @@ class DeviceManager {
329
329
  return
330
330
  }
331
331
 
332
+ if (sgc?.isMicSuspendedForAudio == true) {
333
+ Bridge.log("MAN: Glasses mic intentionally suspended for phone audio; skipping mic recovery")
334
+ return
335
+ }
336
+
332
337
  // When no frame has ever been received, treat elapsed as "forever" so we
333
338
  // actually attempt recovery (was 0 before, which made the watchdog a no-op).
334
339
  val timeSinceLastLc3Event = System.currentTimeMillis() - (lastLc3Event ?: 0L)
@@ -1556,10 +1561,10 @@ class DeviceManager {
1556
1561
  sgc?.sendStreamKeepAlive(message)
1557
1562
  }
1558
1563
 
1559
- fun requestWifiScan() {
1564
+ fun requestWifiScan(scanId: String? = null) {
1560
1565
  Bridge.log("MAN: Requesting wifi scan")
1561
1566
  DeviceStore.apply("bluetooth", "wifiScanResults", emptyList<Any>())
1562
- sgc?.requestWifiScan()
1567
+ sgc?.requestWifiScan(scanId)
1563
1568
  }
1564
1569
 
1565
1570
  fun sendIncidentId(incidentId: String, apiBaseUrl: String? = null) {
@@ -1649,6 +1654,38 @@ class DeviceManager {
1649
1654
  live.sendCameraFovSetting(requestId, fov, roiPosition)
1650
1655
  }
1651
1656
 
1657
+ /** Sends the pre-lease FOV command used by ASG clients that do not send an acknowledgement. */
1658
+ fun sendLegacyCameraFovSetting(fov: Int, roiPosition: Int) {
1659
+ val glassesConnected = DeviceStore.get("glasses", "connected") as? Boolean ?: false
1660
+ if (!glassesConnected) throw IllegalStateException("not_connected")
1661
+ val live = sgc as? MentraLive ?: throw IllegalStateException("unsupported_device")
1662
+ live.sendCameraFovSetting(null, fov, roiPosition)
1663
+ }
1664
+
1665
+ /** Replays the persistent base FOV without waiting for an acknowledgement. */
1666
+ fun restoreLegacyCameraFovSetting() {
1667
+ val glassesConnected = DeviceStore.get("glasses", "connected") as? Boolean ?: false
1668
+ if (!glassesConnected) throw IllegalStateException("not_connected")
1669
+ val live = sgc as? MentraLive ?: throw IllegalStateException("unsupported_device")
1670
+ live.sendCameraFovSetting()
1671
+ }
1672
+
1673
+ fun sendCameraFovOverride(
1674
+ requestId: String,
1675
+ leaseId: String,
1676
+ fov: Int,
1677
+ roiPosition: Int,
1678
+ ttlMs: Int,
1679
+ ) {
1680
+ val live = sgc as? MentraLive ?: throw IllegalStateException("unsupported_device")
1681
+ live.sendCameraFovOverride(requestId, leaseId, fov, roiPosition, ttlMs)
1682
+ }
1683
+
1684
+ fun releaseCameraFovOverride(requestId: String, leaseId: String) {
1685
+ val live = sgc as? MentraLive ?: throw IllegalStateException("unsupported_device")
1686
+ live.releaseCameraFovOverride(requestId, leaseId)
1687
+ }
1688
+
1652
1689
  fun sendCameraTuningConfig(requestId: String, anrOn: Boolean, gainOn: Boolean) {
1653
1690
  val live = sgc as? MentraLive ?: throw IllegalStateException("unsupported_device")
1654
1691
  live.sendCameraTuningConfig(requestId, anrOn, gainOn)
@@ -1657,6 +1694,7 @@ class DeviceManager {
1657
1694
  fun warmUpCamera(
1658
1695
  requestId: String,
1659
1696
  size: PhotoSize,
1697
+ mode: PhotoMode = PhotoMode.PHOTO,
1660
1698
  exposureTimeNs: Long?,
1661
1699
  durationMs: Int,
1662
1700
  ) {
@@ -1668,7 +1706,12 @@ class DeviceManager {
1668
1706
  "unsupported_device",
1669
1707
  "This command requires Mentra Live glasses.",
1670
1708
  )
1671
- live.warmUpCamera(requestId, size, exposureTimeNs, durationMs)
1709
+ live.warmUpCamera(requestId, size, mode, exposureTimeNs, durationMs)
1710
+ }
1711
+
1712
+ fun stopCameraWarmUp(requestId: String) {
1713
+ val live = sgc as? MentraLive ?: throw IllegalStateException("unsupported_device")
1714
+ live.stopCameraWarmUp(requestId)
1672
1715
  }
1673
1716
 
1674
1717
  /**
@@ -128,7 +128,7 @@ object DeviceStore {
128
128
  store.set("bluetooth", "screen_disabled", false)
129
129
  store.set("bluetooth", "button_photo_size", "max")
130
130
  store.set("bluetooth", "button_max_recording_time", 10)
131
- store.set("bluetooth", "camera_fov", mapOf("fov" to 118, "roi_position" to 0))
131
+ store.set("bluetooth", "camera_fov", mapOf("fov" to 102, "roi_position" to 0))
132
132
  store.set("bluetooth", "button_video_width", 1280)
133
133
  store.set("bluetooth", "button_video_height", 720)
134
134
  store.set("bluetooth", "button_video_fps", 30)