@mentra/bluetooth-sdk 0.1.19 → 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 (83) hide show
  1. package/README.md +6 -2
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +239 -52
  3. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +65 -14
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +47 -4
  5. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +6 -1
  6. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +381 -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 +16 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +2 -2
  10. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizer.kt +38 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +16 -9
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Mach1.kt +1 -1
  13. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +293 -174
  14. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +1 -1
  15. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +1 -1
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +4 -2
  17. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +1 -1
  18. package/android/src/main/java/com/mentra/bluetoothsdk/status/WifiHotspotStatus.kt +19 -3
  19. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +15 -2
  20. package/android/src/main/java/com/mentra/bluetoothsdk/tts/TTSTools.kt +115 -47
  21. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +2 -3
  22. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadService.java +65 -8
  23. package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +6 -5
  24. package/android/src/main/java/com/mentra/bluetoothsdk/utils/audio/PcmStreamPlayer.kt +328 -0
  25. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkExceptionTest.kt +18 -0
  26. package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +31 -0
  27. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizerTest.kt +31 -0
  28. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadServiceTest.java +9 -0
  29. package/build/BluetoothSdk.types.d.ts +71 -1
  30. package/build/BluetoothSdk.types.d.ts.map +1 -1
  31. package/build/BluetoothSdk.types.js.map +1 -1
  32. package/build/_internal.d.ts +2 -0
  33. package/build/_internal.d.ts.map +1 -1
  34. package/build/_internal.js +2 -0
  35. package/build/_internal.js.map +1 -1
  36. package/build/_private/BluetoothSdkModule.d.ts +27 -2
  37. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  38. package/build/_private/BluetoothSdkModule.js +20 -0
  39. package/build/_private/BluetoothSdkModule.js.map +1 -1
  40. package/build/_private/MentraLocalNetworkModule.d.ts +37 -0
  41. package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -0
  42. package/build/_private/MentraLocalNetworkModule.js +3 -0
  43. package/build/_private/MentraLocalNetworkModule.js.map +1 -0
  44. package/build/_private/cameraRequestPayload.d.ts.map +1 -1
  45. package/build/_private/cameraRequestPayload.js +1 -0
  46. package/build/_private/cameraRequestPayload.js.map +1 -1
  47. package/build/_private/photoRequestPayload.d.ts.map +1 -1
  48. package/build/_private/photoRequestPayload.js +2 -0
  49. package/build/_private/photoRequestPayload.js.map +1 -1
  50. package/build/index.d.ts +1 -1
  51. package/build/index.d.ts.map +1 -1
  52. package/build/index.js +9 -0
  53. package/build/index.js.map +1 -1
  54. package/expo-module.config.json +1 -0
  55. package/ios/BluetoothSdkModule.swift +72 -0
  56. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  57. package/ios/Source/Bridge.swift +72 -18
  58. package/ios/Source/DeviceManager.swift +52 -3
  59. package/ios/Source/DeviceStore.swift +6 -1
  60. package/ios/Source/MentraBluetoothSDK.swift +298 -21
  61. package/ios/Source/audio/PcmStreamPlayer.swift +410 -0
  62. package/ios/Source/camera/CameraModels.swift +22 -3
  63. package/ios/Source/sgcs/G1.swift +2 -2
  64. package/ios/Source/sgcs/G2.swift +19 -7
  65. package/ios/Source/sgcs/Mach1.swift +1 -1
  66. package/ios/Source/sgcs/MentraLive.swift +456 -52
  67. package/ios/Source/sgcs/MentraLiveL2capChannel.swift +183 -0
  68. package/ios/Source/sgcs/MentraNex.swift +1 -1
  69. package/ios/Source/sgcs/Nimo.swift +1 -1
  70. package/ios/Source/sgcs/SGCManager.swift +5 -2
  71. package/ios/Source/sgcs/Simulated.swift +1 -1
  72. package/ios/Source/status/WifiHotspotStatus.swift +21 -2
  73. package/ios/Source/streaming/StreamModels.swift +9 -0
  74. package/ios/Source/tts/TTSTools.swift +1 -1
  75. package/ios/Source/utils/G1Text.swift +30 -1
  76. package/package.json +1 -2
  77. package/src/BluetoothSdk.types.ts +75 -0
  78. package/src/_internal.ts +2 -0
  79. package/src/_private/BluetoothSdkModule.ts +68 -13
  80. package/src/_private/MentraLocalNetworkModule.ts +50 -0
  81. package/src/_private/cameraRequestPayload.ts +1 -0
  82. package/src/_private/photoRequestPayload.ts +3 -3
  83. package/src/index.ts +10 -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
@@ -475,7 +479,7 @@ For bare native iOS apps, use the public SwiftPM repository:
475
479
  https://github.com/Mentra-Community/mentra-bluetooth-sdk-ios.git
476
480
  ```
477
481
 
478
- Select version `0.1.19`, then add the `MentraBluetoothSDK` product to your app target.
482
+ Select version `0.1.20`, then add the `MentraBluetoothSDK` product to your app target.
479
483
 
480
484
  For local SDK development, add this package folder directly in Xcode:
481
485
 
@@ -1,13 +1,132 @@
1
+ @file:Suppress("FunctionName")
2
+
1
3
  package com.mentra.bluetoothsdk
2
4
 
3
5
  import com.mentra.bluetoothsdk.debug.BleTraceLogger
4
6
  import com.mentra.bluetoothsdk.utils.DeviceTypes
7
+ import com.mentra.bluetoothsdk.utils.audio.PcmStreamManager
8
+ import expo.modules.kotlin.exception.CodedException
5
9
  import expo.modules.kotlin.functions.Coroutine
6
10
  import expo.modules.kotlin.modules.Module
7
11
  import expo.modules.kotlin.modules.ModuleDefinition
12
+ import expo.modules.kotlin.modules.ModuleDefinitionBuilder
8
13
  import kotlinx.coroutines.Dispatchers
9
14
  import kotlinx.coroutines.withContext
10
15
 
16
+ // Expo has no module-level error mapper, so SDK-backed registrations translate
17
+ // core exceptions here while keeping Expo types out of the native SDK API.
18
+ private inline fun <T> withExpoSdkError(block: () -> T): T =
19
+ try {
20
+ block()
21
+ } catch (error: BluetoothSdkException) {
22
+ throw CodedException(error.code, error.message ?: error.code, error)
23
+ }
24
+
25
+ private suspend inline fun <T> withExpoSdkErrorSuspend(crossinline block: suspend () -> T): T =
26
+ try {
27
+ block()
28
+ } catch (error: BluetoothSdkException) {
29
+ throw CodedException(error.code, error.message ?: error.code, error)
30
+ }
31
+
32
+ private inline fun <reified R> ModuleDefinitionBuilder.SdkAsyncFunction(
33
+ name: String,
34
+ crossinline body: () -> R,
35
+ ) = AsyncFunction<R>(name) { withExpoSdkError { body() } }
36
+
37
+ private inline fun <reified R, reified P0> ModuleDefinitionBuilder.SdkAsyncFunction(
38
+ name: String,
39
+ crossinline body: (P0) -> R,
40
+ ) = AsyncFunction<R, P0>(name) { p0 -> withExpoSdkError { body(p0) } }
41
+
42
+ private inline fun <reified R, reified P0, reified P1> ModuleDefinitionBuilder.SdkAsyncFunction(
43
+ name: String,
44
+ crossinline body: (P0, P1) -> R,
45
+ ) = AsyncFunction<R, P0, P1>(name) { p0, p1 -> withExpoSdkError { body(p0, p1) } }
46
+
47
+ private inline fun <reified R, reified P0, reified P1, reified P2> ModuleDefinitionBuilder.SdkAsyncFunction(
48
+ name: String,
49
+ crossinline body: (P0, P1, P2) -> R,
50
+ ) = AsyncFunction<R, P0, P1, P2>(name) { p0, p1, p2 -> withExpoSdkError { body(p0, p1, p2) } }
51
+
52
+ private inline fun <reified R, reified P0, reified P1, reified P2, reified P3>
53
+ ModuleDefinitionBuilder.SdkAsyncFunction(
54
+ name: String,
55
+ crossinline body: (P0, P1, P2, P3) -> R,
56
+ ) = AsyncFunction<R, P0, P1, P2, P3>(name) { p0, p1, p2, p3 ->
57
+ withExpoSdkError { body(p0, p1, p2, p3) }
58
+ }
59
+
60
+ private inline fun <
61
+ reified R,
62
+ reified P0,
63
+ reified P1,
64
+ reified P2,
65
+ reified P3,
66
+ reified P4,
67
+ reified P5,
68
+ reified P6,
69
+ > ModuleDefinitionBuilder.SdkAsyncFunction(
70
+ name: String,
71
+ crossinline body: (P0, P1, P2, P3, P4, P5, P6) -> R,
72
+ ) = AsyncFunction<R, P0, P1, P2, P3, P4, P5, P6>(name) { p0, p1, p2, p3, p4, p5, p6 ->
73
+ withExpoSdkError { body(p0, p1, p2, p3, p4, p5, p6) }
74
+ }
75
+
76
+ private inline fun <reified R> ModuleDefinitionBuilder.SdkFunction(
77
+ name: String,
78
+ crossinline body: () -> R,
79
+ ) = Function<R>(name) { withExpoSdkError { body() } }
80
+
81
+ private inline fun <reified R, reified P0> ModuleDefinitionBuilder.SdkFunction(
82
+ name: String,
83
+ crossinline body: (P0) -> R,
84
+ ) = Function<R, P0>(name) { p0 -> withExpoSdkError { body(p0) } }
85
+
86
+ private inline fun <reified R> ModuleDefinitionBuilder.SdkCoroutineFunction(
87
+ name: String,
88
+ crossinline body: suspend () -> R,
89
+ ) = AsyncFunction(name) Coroutine { -> withExpoSdkErrorSuspend { body() } }
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
+
11
130
  class BluetoothSdkModule : Module() {
12
131
  private var sdk: MentraBluetoothSdk? = null
13
132
  private var deviceManager: DeviceManager? = null
@@ -183,9 +302,10 @@ class BluetoothSdkModule : Module() {
183
302
 
184
303
  private fun requireSdk(): MentraBluetoothSdk =
185
304
  sdk
186
- ?: throw BluetoothSdkException(
305
+ ?: throw CodedException(
187
306
  "sdk_not_initialized",
188
307
  "Bluetooth SDK is not initialized.",
308
+ null,
189
309
  )
190
310
 
191
311
  override fun definition() = ModuleDefinition {
@@ -280,6 +400,7 @@ class BluetoothSdkModule : Module() {
280
400
  "module_destroy"
281
401
  )
282
402
  sdk?.close()
403
+ PcmStreamManager.abortAll()
283
404
  sdk = null
284
405
  deviceManager = null
285
406
  }
@@ -335,11 +456,11 @@ class BluetoothSdkModule : Module() {
335
456
 
336
457
  // MARK: - Display Commands
337
458
 
338
- AsyncFunction("displayEvent") { params: Map<String, Any> ->
459
+ SdkAsyncFunction("displayEvent") { params: Map<String, Any> ->
339
460
  sdk?.displayEvent(DisplayEventRequest(params))
340
461
  }
341
462
 
342
- AsyncFunction("displayText") { text: String, x: Int?, y: Int?, size: Int? ->
463
+ SdkAsyncFunction("displayText") { text: String, x: Int?, y: Int?, size: Int? ->
343
464
  sdk?.displayText(
344
465
  text = text,
345
466
  x = x ?: 0,
@@ -348,34 +469,35 @@ class BluetoothSdkModule : Module() {
348
469
  )
349
470
  }
350
471
 
351
- AsyncFunction("clearDisplay") { sdk?.clearDisplay() }
472
+ SdkAsyncFunction("clearDisplay") { -> sdk?.clearDisplay() }
352
473
 
353
474
  // MARK: - Connection Commands
354
475
 
355
- AsyncFunction("connectDefault") { sdk?.connectDefault() }
476
+ SdkAsyncFunction("connectDefault") { -> sdk?.connectDefault() }
356
477
 
357
- AsyncFunction("connectDefaultWithOptions") { options: Map<String, Any> ->
478
+ SdkAsyncFunction("connectDefaultWithOptions") { options: Map<String, Any> ->
358
479
  sdk?.connectDefault(options.toMentraConnectOptions())
359
480
  }
360
481
 
361
- AsyncFunction("setDefaultDevice") { device: Map<String, Any>? ->
482
+ SdkAsyncFunction("setDefaultDevice") { device: Map<String, Any>? ->
362
483
  sdk?.setDefaultDevice(device.toMentraDevice())
363
484
  }
364
485
 
365
- AsyncFunction("clearDefaultDevice") { sdk?.clearDefaultDevice() }
486
+ SdkAsyncFunction("clearDefaultDevice") { -> sdk?.clearDefaultDevice() }
366
487
 
367
- AsyncFunction("connectWithOptions") { device: Map<String, Any>, options: Map<String, Any> ->
488
+ SdkAsyncFunction("connectWithOptions") { device: Map<String, Any>, options: Map<String, Any> ->
368
489
  sdk?.connect(
369
- device.toMentraDevice() ?: throw IllegalArgumentException("connect requires a Device with model and name."),
490
+ device.toMentraDevice()
491
+ ?: throw IllegalArgumentException("connect requires a Device with model and name."),
370
492
  options.toMentraConnectOptions(),
371
493
  )
372
494
  }
373
495
 
374
- AsyncFunction("connectSimulated") { sdk?.connectSimulated() }
496
+ SdkAsyncFunction("connectSimulated") { -> sdk?.connectSimulated() }
375
497
 
376
- AsyncFunction("disconnect") { sdk?.disconnect() }
498
+ SdkAsyncFunction("disconnect") { -> sdk?.disconnect() }
377
499
 
378
- AsyncFunction("forget") { sdk?.forget() }
500
+ SdkAsyncFunction("forget") { -> sdk?.forget() }
379
501
 
380
502
  AsyncFunction("connectDefaultController") { deviceManager?.connectDefaultController() }
381
503
 
@@ -383,15 +505,15 @@ class BluetoothSdkModule : Module() {
383
505
 
384
506
  AsyncFunction("forgetController") { deviceManager?.forgetController() }
385
507
 
386
- AsyncFunction("startScan") { model: String ->
508
+ SdkAsyncFunction("startScan") { model: String ->
387
509
  sdk?.startScan(DeviceModel.fromDeviceType(model))
388
510
  }
389
511
 
390
- AsyncFunction("stopScan") { sdk?.stopScan() }
512
+ SdkAsyncFunction("stopScan") { -> sdk?.stopScan() }
391
513
 
392
- AsyncFunction("cancelConnectionAttempt") { sdk?.cancelConnectionAttempt() }
514
+ SdkAsyncFunction("cancelConnectionAttempt") { -> sdk?.cancelConnectionAttempt() }
393
515
 
394
- AsyncFunction("showDashboard") { sdk?.showDashboard() }
516
+ SdkAsyncFunction("showDashboard") { -> sdk?.showDashboard() }
395
517
 
396
518
  AsyncFunction("ping") { deviceManager?.ping() }
397
519
 
@@ -409,51 +531,54 @@ class BluetoothSdkModule : Module() {
409
531
 
410
532
  // MARK: - Incident Reporting
411
533
 
412
- AsyncFunction("sendIncidentId") { incidentId: String, apiBaseUrl: String? ->
534
+ SdkAsyncFunction("sendIncidentId") { incidentId: String, apiBaseUrl: String? ->
413
535
  sdk?.sendIncidentId(incidentId, apiBaseUrl)
414
536
  }
415
537
 
416
538
  // MARK: - WiFi Commands
417
539
 
418
- AsyncFunction("requestWifiScan") { requireSdk().requestWifiScan().map { it.toMap() } }
540
+ SdkCoroutineFunction("requestWifiScan") { -> requireSdk().requestWifiScan().map { it.toMap() } }
419
541
 
420
- AsyncFunction("sendWifiCredentials") { ssid: String, password: String ->
542
+ SdkCoroutineFunction("sendWifiCredentials") { ssid: String, password: String ->
421
543
  requireSdk().sendWifiCredentials(ssid, password).values
422
544
  }
423
545
 
424
- AsyncFunction("forgetWifiNetwork") { ssid: String -> requireSdk().forgetWifiNetwork(ssid).values }
546
+ SdkCoroutineFunction("forgetWifiNetwork") { ssid: String ->
547
+ requireSdk().forgetWifiNetwork(ssid).values
548
+ }
425
549
 
426
- AsyncFunction("setHotspotState") { enabled: Boolean ->
550
+ SdkCoroutineFunction("setHotspotState") { enabled: Boolean ->
427
551
  requireSdk().setHotspotState(enabled).values
428
552
  }
429
553
 
430
- AsyncFunction("setSystemTime") { timestampMs: Double ->
554
+ SdkAsyncFunction("setSystemTime") { timestampMs: Double ->
431
555
  sdk?.setSystemTime(timestampMs.toLong())
432
556
  }
433
557
 
434
558
  // MARK: - Gallery Commands
435
559
 
436
- AsyncFunction("setGalleryModeEnabled") { enabled: Boolean ->
560
+ SdkCoroutineFunction("setGalleryModeEnabled") { enabled: Boolean ->
437
561
  requireSdk().setGalleryModeEnabled(enabled).values
438
562
  }
439
563
 
440
- AsyncFunction("setVoiceActivityDetectionEnabled") { enabled: Boolean ->
564
+ SdkAsyncFunction("setVoiceActivityDetectionEnabled") { enabled: Boolean ->
441
565
  sdk?.setVoiceActivityDetectionEnabled(enabled)
442
566
  }
443
567
 
444
- AsyncFunction("setPhotoCaptureDefaults") { params: Map<String, Any?> ->
568
+ @Suppress("DEPRECATION")
569
+ SdkCoroutineFunction("setPhotoCaptureDefaults") { params: Map<String, Any?> ->
445
570
  requireSdk().setPhotoCaptureDefaults(params.toPhotoCaptureDefaults()).values
446
571
  }
447
572
 
448
- AsyncFunction("setVideoRecordingDefaults") { width: Int, height: Int, fps: Int ->
573
+ SdkCoroutineFunction("setVideoRecordingDefaults") { width: Int, height: Int, fps: Int ->
449
574
  requireSdk().setVideoRecordingDefaults(VideoRecordingDefaults(width, height, fps)).values
450
575
  }
451
576
 
452
- AsyncFunction("setMaxVideoRecordingDuration") { minutes: Int ->
577
+ SdkCoroutineFunction("setMaxVideoRecordingDuration") { minutes: Int ->
453
578
  requireSdk().setMaxVideoRecordingDuration(minutes).values
454
579
  }
455
580
 
456
- AsyncFunction("setCameraFov") { fov: Map<String, Any> ->
581
+ SdkCoroutineFunction("setCameraFov") { fov: Map<String, Any> ->
457
582
  val value = (fov["fov"] as? Number)?.toInt() ?: CameraFov.DEFAULT_FOV
458
583
  val roiPosition = CameraRoiPosition.fromValue(
459
584
  (fov["roiPosition"] as? Number)?.toInt()
@@ -462,13 +587,41 @@ class BluetoothSdkModule : Module() {
462
587
  requireSdk().setCameraFov(CameraFov(value, roiPosition)).values
463
588
  }
464
589
 
465
- AsyncFunction("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 ->
466
619
  requireSdk().setCameraTuningConfig(anrOn, gainOn).values
467
620
  }
468
621
 
469
- AsyncFunction("queryGalleryStatus") { requireSdk().queryGalleryStatus().values }
622
+ SdkCoroutineFunction("queryGalleryStatus") { -> requireSdk().queryGalleryStatus().values }
470
623
 
471
- AsyncFunction("requestPhoto") { params: Map<String, Any?> ->
624
+ SdkCoroutineFunction("requestPhoto") { params: Map<String, Any?> ->
472
625
  // JS may pass null for optional fields; Map<String, Any> rejects null values at the bridge.
473
626
  val sanitized =
474
627
  params.mapNotNull { (key, value) ->
@@ -476,37 +629,42 @@ class BluetoothSdkModule : Module() {
476
629
  }.toMap()
477
630
  val req = PhotoRequest.fromMap(sanitized)
478
631
  Bridge.log(
479
- "NATIVE: PHOTO PIPELINE [3/6] BluetoothSdk.requestPhoto requestId=${req.requestId} size=${req.size} compress=${req.compress} sound=${req.sound} exposureTimeNs=${req.exposureTimeNs} iso=${req.iso}"
632
+ "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}"
480
633
  )
481
634
  requireSdk().requestPhoto(req).values
482
635
  }
483
636
 
484
- AsyncFunction("warmUpCamera") { params: Map<String, Any?> ->
637
+ SdkCoroutineFunction("warmUpCamera") { params: Map<String, Any?> ->
485
638
  val requestId = params["requestId"] as? String
486
639
  val size = PhotoSize.fromValue(params["size"] as? String)
640
+ val mode = PhotoMode.fromValue(params["mode"] as? String)
487
641
  val exposureRaw = (params["exposureTimeNs"] as? Number)?.toDouble()
488
642
  val exposureTimeNs =
489
643
  if (exposureRaw != null && exposureRaw.isFinite() && exposureRaw > 0) exposureRaw.toLong() else null
490
644
  val durationRaw = (params["durationMs"] as? Number)?.toInt() ?: 0
491
645
  val durationMs = if (durationRaw > 0) durationRaw else 15000
492
- 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)
493
651
  }
494
652
 
495
653
  // MARK: - OTA Commands
496
654
 
497
- Function("setOtaVersionUrl") { otaVersionUrl: String ->
655
+ SdkFunction("setOtaVersionUrl") { otaVersionUrl: String ->
498
656
  requireSdk().setOtaVersionUrl(otaVersionUrl)
499
657
  }
500
658
 
501
- Function("getOtaVersionUrl") { requireSdk().getOtaVersionUrl() }
659
+ SdkFunction("getOtaVersionUrl") { -> requireSdk().getOtaVersionUrl() }
502
660
 
503
661
  // Runs on Dispatchers.IO, not the shared Expo AsyncFunctionQueue:
504
662
  // manifest fetches and version waits can block for several seconds.
505
- AsyncFunction("checkForOtaUpdate") Coroutine { ->
663
+ SdkCoroutineFunction("checkForOtaUpdate") { ->
506
664
  withContext(Dispatchers.IO) { requireSdk().checkForOtaUpdate() }
507
665
  }
508
666
 
509
- AsyncFunction("startOtaUpdate") { otaVersionUrl: String? ->
667
+ SdkCoroutineFunction("startOtaUpdate") { otaVersionUrl: String? ->
510
668
  val sdk = requireSdk()
511
669
  if (otaVersionUrl.isNullOrBlank()) {
512
670
  sdk.startOtaUpdate().values
@@ -515,21 +673,21 @@ class BluetoothSdkModule : Module() {
515
673
  }
516
674
  }
517
675
 
518
- AsyncFunction("sendOtaQueryStatus") { requireSdk().sendOtaQueryStatus().values }
676
+ SdkCoroutineFunction("sendOtaQueryStatus") { -> requireSdk().sendOtaQueryStatus().values }
519
677
 
520
678
  // MARK: - Version Info Commands
521
679
 
522
- AsyncFunction("requestVersionInfo") { requireSdk().requestVersionInfo().toMap() }
680
+ SdkCoroutineFunction("requestVersionInfo") { -> requireSdk().requestVersionInfo().toMap() }
523
681
 
524
682
  // MARK: - Power Control Commands
525
683
 
526
- AsyncFunction("sendShutdown") { sdk?.sendShutdown() }
684
+ SdkAsyncFunction("sendShutdown") { -> sdk?.sendShutdown() }
527
685
 
528
- AsyncFunction("sendReboot") { sdk?.sendReboot() }
686
+ SdkAsyncFunction("sendReboot") { -> sdk?.sendReboot() }
529
687
 
530
688
  // MARK: - Video Recording Commands
531
689
 
532
- AsyncFunction("startVideoRecording") {
690
+ SdkCoroutineFunction("startVideoRecording") {
533
691
  requestId: String,
534
692
  save: Boolean,
535
693
  sound: Boolean,
@@ -553,7 +711,7 @@ class BluetoothSdkModule : Module() {
553
711
 
554
712
  // webhookUrl/authToken are supplied at stop (not start) so the token is
555
713
  // fresh when the upload runs. Empty/null webhook = keep on device.
556
- AsyncFunction("stopVideoRecording") {
714
+ SdkCoroutineFunction("stopVideoRecording") {
557
715
  requestId: String,
558
716
  webhookUrl: String?,
559
717
  authToken: String? ->
@@ -562,23 +720,23 @@ class BluetoothSdkModule : Module() {
562
720
 
563
721
  // MARK: - Stream Commands
564
722
 
565
- AsyncFunction("startStream") { params: Map<String, Any> ->
723
+ SdkCoroutineFunction("startStream") { params: Map<String, Any> ->
566
724
  requireSdk().startStream(StreamRequest.fromMap(params)).values
567
725
  }
568
726
 
569
- AsyncFunction("startExternallyManagedStream") { params: Map<String, Any> ->
727
+ SdkCoroutineFunction("startExternallyManagedStream") { params: Map<String, Any> ->
570
728
  requireSdk().startExternallyManagedStream(StreamRequest.fromMap(params)).values
571
729
  }
572
730
 
573
- AsyncFunction("stopStream") { requireSdk().stopStream().values }
731
+ SdkCoroutineFunction("stopStream") { -> requireSdk().stopStream().values }
574
732
 
575
- AsyncFunction("sendExternallyManagedStreamKeepAlive") { params: Map<String, Any> ->
733
+ SdkAsyncFunction("sendExternallyManagedStreamKeepAlive") { params: Map<String, Any> ->
576
734
  sdk?.sendExternallyManagedStreamKeepAlive(StreamKeepAliveRequest.fromMap(params))
577
735
  }
578
736
 
579
737
  // MARK: - Microphone Commands
580
738
 
581
- AsyncFunction("setMicState") {
739
+ SdkAsyncFunction("setMicState") {
582
740
  enabled: Boolean,
583
741
  useGlassesMic: Boolean?,
584
742
  sendTranscript: Boolean?,
@@ -600,10 +758,39 @@ class BluetoothSdkModule : Module() {
600
758
 
601
759
  // MARK: - Audio Playback Monitoring
602
760
 
603
- AsyncFunction("setOwnAppAudioPlaying") { playing: Boolean ->
761
+ SdkAsyncFunction("setOwnAppAudioPlaying") { playing: Boolean ->
604
762
  sdk?.setOwnAppAudioPlaying(playing)
605
763
  }
606
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
+
607
794
  // *Blocking on Dispatchers.IO, not the shared AsyncFunctionQueue: these wait on
608
795
  // a CountDownLatch (up to 5s) for a BLE round-trip, which would otherwise stall
609
796
  // every other native call queued behind them.
@@ -619,7 +806,7 @@ class BluetoothSdkModule : Module() {
619
806
 
620
807
  // MARK: - RGB LED Control
621
808
 
622
- AsyncFunction("rgbLedControl") {
809
+ SdkCoroutineFunction("rgbLedControl") {
623
810
  requestId: String,
624
811
  packageName: String?,
625
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