@mentra/bluetooth-sdk 0.1.7 → 0.1.8

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 (82) hide show
  1. package/README.md +11 -9
  2. package/android/.project +28 -0
  3. package/android/build.gradle +3 -0
  4. package/android/lc3Lib/.project +28 -0
  5. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +15 -3
  6. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +26 -1
  7. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +4 -2
  8. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +136 -4
  9. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +40 -3
  10. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/ControllerManager.kt +1 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/R1.kt +1 -1
  12. package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleTraceLogger.kt +149 -0
  13. package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +1 -0
  14. package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/LocalPhotoUploadServer.kt +297 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/MentraPhotoReceiverModule.kt +120 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +13 -0
  17. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.java +1 -1
  18. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +1 -0
  19. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Mach1.java +1 -1
  20. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.java +104 -21
  21. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +1 -1
  22. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +1 -0
  23. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +1 -0
  24. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +128 -3
  25. package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunkReassembler.java +105 -0
  26. package/build/BluetoothSdk.types.d.ts +66 -29
  27. package/build/BluetoothSdk.types.d.ts.map +1 -1
  28. package/build/BluetoothSdk.types.js +3 -0
  29. package/build/BluetoothSdk.types.js.map +1 -1
  30. package/build/_private/BluetoothSdkModule.d.ts +2 -1
  31. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  32. package/build/_private/BluetoothSdkModule.js +24 -7
  33. package/build/_private/BluetoothSdkModule.js.map +1 -1
  34. package/build/index.d.ts +2 -2
  35. package/build/index.d.ts.map +1 -1
  36. package/build/index.js +2 -3
  37. package/build/index.js.map +1 -1
  38. package/build/photo-receiver/MentraPhotoReceiver.types.d.ts +18 -0
  39. package/build/photo-receiver/MentraPhotoReceiver.types.d.ts.map +1 -0
  40. package/build/photo-receiver/MentraPhotoReceiver.types.js +2 -0
  41. package/build/photo-receiver/MentraPhotoReceiver.types.js.map +1 -0
  42. package/build/photo-receiver/MentraPhotoReceiverModule.d.ts +10 -0
  43. package/build/photo-receiver/MentraPhotoReceiverModule.d.ts.map +1 -0
  44. package/build/photo-receiver/MentraPhotoReceiverModule.js +3 -0
  45. package/build/photo-receiver/MentraPhotoReceiverModule.js.map +1 -0
  46. package/build/photo-receiver/MentraPhotoReceiverModule.web.d.ts +10 -0
  47. package/build/photo-receiver/MentraPhotoReceiverModule.web.d.ts.map +1 -0
  48. package/build/photo-receiver/MentraPhotoReceiverModule.web.js +12 -0
  49. package/build/photo-receiver/MentraPhotoReceiverModule.web.js.map +1 -0
  50. package/build/photo-receiver/index.d.ts +3 -0
  51. package/build/photo-receiver/index.d.ts.map +1 -0
  52. package/build/photo-receiver/index.js +3 -0
  53. package/build/photo-receiver/index.js.map +1 -0
  54. package/expo-module.config.json +5 -2
  55. package/ios/BluetoothSdkModule.swift +19 -3
  56. package/ios/LocalPhotoUploadServer.swift +411 -0
  57. package/ios/MentraBluetoothSDK.podspec +8 -2
  58. package/ios/MentraPhotoReceiverModule.swift +147 -0
  59. package/ios/Source/Bridge.swift +20 -1
  60. package/ios/Source/Camera/CameraModels.swift +64 -11
  61. package/ios/Source/DeviceManager.swift +6 -4
  62. package/ios/Source/Events/BluetoothEvents.swift +3 -0
  63. package/ios/Source/MentraBluetoothSDK.swift +111 -5
  64. package/ios/Source/Streaming/StreamModels.swift +234 -40
  65. package/ios/Source/controllers/ControllerManager.swift +1 -1
  66. package/ios/Source/controllers/R1.swift +9 -3
  67. package/ios/Source/sgcs/G1.swift +5 -4
  68. package/ios/Source/sgcs/G2.swift +4 -4
  69. package/ios/Source/sgcs/Mach1.swift +1 -1
  70. package/ios/Source/sgcs/MentraLive.swift +76 -12
  71. package/ios/Source/sgcs/MentraNex.swift +1 -1
  72. package/ios/Source/sgcs/SGCManager.swift +1 -1
  73. package/ios/Source/sgcs/Simulated.swift +1 -1
  74. package/ios/Source/utils/MessageChunkReassembler.swift +89 -0
  75. package/package.json +6 -1
  76. package/src/BluetoothSdk.types.ts +90 -35
  77. package/src/_private/BluetoothSdkModule.ts +38 -8
  78. package/src/index.ts +9 -4
  79. package/src/photo-receiver/MentraPhotoReceiver.types.ts +20 -0
  80. package/src/photo-receiver/MentraPhotoReceiverModule.ts +14 -0
  81. package/src/photo-receiver/MentraPhotoReceiverModule.web.ts +20 -0
  82. package/src/photo-receiver/index.ts +2 -0
package/README.md CHANGED
@@ -266,11 +266,17 @@ await BluetoothSdk.requestPhoto({
266
266
  authToken: 'optional-token',
267
267
  compress: 'medium',
268
268
  sound: true,
269
+ exposureTimeNs: null, // auto exposure; pass a positive nanosecond value for manual exposure
270
+ iso: null, // auto ISO; pass a positive ISO only with manual exposureTimeNs
269
271
  })
270
272
  ```
271
273
 
272
274
  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.
273
275
 
276
+ 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`.
277
+
278
+ Use `setCameraFov({fov, roiPosition})` to configure Mentra Live camera field of view and crop position. FOV is clamped to 82-118 degrees; ROI position is `0` center, `1` bottom, or `2` top. Applying FOV/ROI restarts the camera for about 5 seconds, so wait before requesting the next photo.
279
+
274
280
  ## Streaming
275
281
 
276
282
  ```ts
@@ -283,16 +289,11 @@ await BluetoothSdk.startStream({
283
289
  video: {fps: 15},
284
290
  })
285
291
 
286
- await BluetoothSdk.keepStreamAlive({
287
- type: 'keep_stream_alive',
288
- streamId,
289
- ackId: `ack-${Date.now()}`,
290
- })
291
-
292
292
  await BluetoothSdk.stopStream()
293
293
  ```
294
294
 
295
- Use `rtmp://` or `rtmps://` for RTMP, `srt://` for SRT, and `http://` or `https://` for WHIP/WebRTC ingest. Send keep-alives about every 15 seconds while streaming. The camera light is always enabled while streaming.
295
+ Use `rtmp://` or `rtmps://` for RTMP, `srt://` for SRT, and `http://` or `https://` for WHIP/WebRTC ingest. The SDK sends stream keep-alives automatically while streaming and reports keep-alive failures through `stream_status`. The camera light is always enabled while streaming.
296
+ `stream_status` events may include `resolvedConfig`, which reports the effective transport, video, and audio settings after glasses defaults, clamps, and camera preflight.
296
297
 
297
298
  ## Events
298
299
 
@@ -304,6 +305,7 @@ import {useBluetoothEvent} from '@mentra/bluetooth-sdk/react'
304
305
  export function HardwareEventLogger() {
305
306
  useBluetoothEvent('button_press', (event) => console.log(event))
306
307
  useBluetoothEvent('touch_event', (event) => console.log(event))
308
+ useBluetoothEvent('photo_status', (event) => console.log(event.status, event.resolvedConfig))
307
309
  useBluetoothEvent('photo_response', (event) => console.log(event))
308
310
  useBluetoothEvent('stream_status', (event) => console.log(event))
309
311
  useBluetoothEvent('speaking_status', (event) => console.log(event.speaking))
@@ -318,9 +320,9 @@ export function HardwareEventLogger() {
318
320
 
319
321
  For non-React modules, `BluetoothSdk.addListener(...)` is the low-level subscription API. Keep the returned subscription and call `remove()` when the listener is no longer needed.
320
322
 
321
- Common event names include `button_press`, `touch_event`, `head_up`, `battery_status`, `wifi_status_change`, `hotspot_status_change`, `photo_response`, `gallery_status`, `stream_status`, `keep_alive_ack`, `mic_pcm`, `mic_lc3`, `local_transcription`, `rgb_led_control_response`, `audio_connected`, `audio_disconnected`, and `log`.
323
+ Common event names include `button_press`, `touch_event`, `head_up`, `battery_status`, `wifi_status_change`, `hotspot_status_change`, `photo_status`, `photo_response`, `gallery_status`, `stream_status`, `mic_pcm`, `mic_lc3`, `local_transcription`, `rgb_led_control_response`, `audio_connected`, `audio_disconnected`, and `log`.
322
324
 
323
- React Native event payload fields use camelCase. For example, `touch_event` includes `gestureName`, `photo_response` success includes `uploadUrl`, and `gallery_status` includes `hasContent` and `cameraBusy`. `mic_pcm` includes `sampleRate`, `bitsPerSample`, `channels`, and `encoding`; `mic_lc3` includes `sampleRate`, `channels`, `encoding`, `frameDurationMs`, `frameSizeBytes`, `bitrate`, and `packetizedFromGlasses`.
325
+ React Native event payload fields use camelCase. For example, `touch_event` includes `gestureName`, `photo_response` success includes `uploadUrl`, and `gallery_status` includes `hasContent` and `cameraBusy`. `photo_status` reports intermediate photo states such as `accepted`, `queued`, `configuring`, `capturing`, `captured`, `compressing`, `uploading`, `ready_for_transfer`, `transferring`, and `failed`; the `configuring` event includes `resolvedConfig` with the effective JPEG dimensions, quality, requested size, transfer method, compression, and manual exposure fields when present. `mic_pcm` includes `sampleRate`, `bitsPerSample`, `channels`, and `encoding`; `mic_lc3` includes `sampleRate`, `channels`, `encoding`, `frameDurationMs`, `frameSizeBytes`, `bitrate`, and `packetizedFromGlasses`.
324
326
 
325
327
  Only documented imports are supported for app developers. Undocumented package subpaths or symbols with a leading underscore can change without notice.
326
328
 
@@ -0,0 +1,28 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <projectDescription>
3
+ <name>mentra-bluetooth-sdk</name>
4
+ <comment>Project mentra-bluetooth-sdk created by Buildship.</comment>
5
+ <projects>
6
+ </projects>
7
+ <buildSpec>
8
+ <buildCommand>
9
+ <name>org.eclipse.buildship.core.gradleprojectbuilder</name>
10
+ <arguments>
11
+ </arguments>
12
+ </buildCommand>
13
+ </buildSpec>
14
+ <natures>
15
+ <nature>org.eclipse.buildship.core.gradleprojectnature</nature>
16
+ </natures>
17
+ <filteredResources>
18
+ <filter>
19
+ <id>1779400133450</id>
20
+ <name></name>
21
+ <type>30</type>
22
+ <matcher>
23
+ <id>org.eclipse.core.resources.regexFilterMatcher</id>
24
+ <arguments>node_modules|\.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__</arguments>
25
+ </matcher>
26
+ </filter>
27
+ </filteredResources>
28
+ </projectDescription>
@@ -83,6 +83,9 @@ dependencies {
83
83
  // OkHttp for HTTP requests
84
84
  implementation 'com.squareup.okhttp3:okhttp:4.12.0'
85
85
 
86
+ // Local photo upload receiver
87
+ implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1"
88
+
86
89
  // LC3 audio codec library
87
90
  implementation project(':lc3Lib')
88
91
 
@@ -0,0 +1,28 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <projectDescription>
3
+ <name>lc3Lib</name>
4
+ <comment>Project lc3Lib created by Buildship.</comment>
5
+ <projects>
6
+ </projects>
7
+ <buildSpec>
8
+ <buildCommand>
9
+ <name>org.eclipse.buildship.core.gradleprojectbuilder</name>
10
+ <arguments>
11
+ </arguments>
12
+ </buildCommand>
13
+ </buildSpec>
14
+ <natures>
15
+ <nature>org.eclipse.buildship.core.gradleprojectnature</nature>
16
+ </natures>
17
+ <filteredResources>
18
+ <filter>
19
+ <id>1779400133449</id>
20
+ <name></name>
21
+ <type>30</type>
22
+ <matcher>
23
+ <id>org.eclipse.core.resources.regexFilterMatcher</id>
24
+ <arguments>node_modules|\.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__</arguments>
25
+ </matcher>
26
+ </filter>
27
+ </filteredResources>
28
+ </projectDescription>
@@ -1,5 +1,6 @@
1
1
  package com.mentra.bluetoothsdk
2
2
 
3
+ import com.mentra.bluetoothsdk.debug.BleTraceLogger
3
4
  import com.mentra.bluetoothsdk.utils.DeviceTypes
4
5
  import expo.modules.kotlin.modules.Module
5
6
  import expo.modules.kotlin.modules.ModuleDefinition
@@ -108,6 +109,10 @@ class BluetoothSdkModule : Module() {
108
109
  sendEvent("photo_response", event.values)
109
110
  }
110
111
 
112
+ override fun onPhotoStatus(event: PhotoStatusEvent) {
113
+ sendEvent("photo_status", event.values)
114
+ }
115
+
111
116
  override fun onStreamStatus(event: StreamStatusEvent) {
112
117
  sendEvent("stream_status", event.values)
113
118
  }
@@ -164,6 +169,7 @@ class BluetoothSdkModule : Module() {
164
169
  "hotspot_status_change",
165
170
  "hotspot_error",
166
171
  "photo_response",
172
+ "photo_status",
167
173
  "gallery_status",
168
174
  "compatible_glasses_search_stop",
169
175
  "heartbeat_sent",
@@ -203,11 +209,17 @@ class BluetoothSdkModule : Module() {
203
209
  appContext.reactContext
204
210
  ?: appContext.currentActivity
205
211
  ?: throw IllegalStateException("No context available")
212
+ BleTraceLogger.logLifecycle(context, "BluetoothSdkModule", "module_create")
206
213
  sdk = MentraBluetoothSdk.create(context, sdkListener)
207
214
  deviceManager = DeviceManager.getInstance()
208
215
  }
209
216
 
210
217
  OnDestroy {
218
+ BleTraceLogger.logLifecycle(
219
+ appContext.reactContext ?: appContext.currentActivity,
220
+ "BluetoothSdkModule",
221
+ "module_destroy"
222
+ )
211
223
  sdk?.close()
212
224
  sdk = null
213
225
  deviceManager = null
@@ -376,7 +388,7 @@ class BluetoothSdkModule : Module() {
376
388
  }.toMap()
377
389
  val req = PhotoRequest.fromMap(sanitized)
378
390
  Bridge.log(
379
- "NATIVE: PHOTO PIPELINE [3/6] BluetoothSdk.requestPhoto requestId=${req.requestId} appId=${req.appId} size=${req.size} compress=${req.compress} flash=${req.flash} sound=${req.sound} exposureTimeNs=${req.exposureTimeNs}"
391
+ "NATIVE: PHOTO PIPELINE [3/6] BluetoothSdk.requestPhoto requestId=${req.requestId} appId=${req.appId} size=${req.size} compress=${req.compress} flash=${req.flash} sound=${req.sound} exposureTimeNs=${req.exposureTimeNs} iso=${req.iso}"
380
392
  )
381
393
  val activeSdk = sdk
382
394
  if (activeSdk == null) {
@@ -422,8 +434,8 @@ class BluetoothSdkModule : Module() {
422
434
 
423
435
  AsyncFunction("stopStream") { sdk?.stopStream() }
424
436
 
425
- AsyncFunction("keepStreamAlive") { params: Map<String, Any> ->
426
- sdk?.keepStreamAlive(StreamKeepAliveRequest.fromMap(params))
437
+ AsyncFunction("sendCloudStreamKeepAlive") { params: Map<String, Any> ->
438
+ sdk?.sendCloudStreamKeepAlive(StreamKeepAliveRequest.fromMap(params))
427
439
  }
428
440
 
429
441
  // MARK: - Microphone Commands
@@ -9,6 +9,7 @@ package com.mentra.bluetoothsdk
9
9
 
10
10
  import android.util.Base64
11
11
  import android.util.Log
12
+ import com.mentra.bluetoothsdk.debug.BleTraceLogger
12
13
  import java.util.HashMap
13
14
  import java.util.UUID
14
15
  import kotlin.jvm.JvmStatic
@@ -347,14 +348,29 @@ public class Bridge private constructor() {
347
348
 
348
349
  @JvmStatic
349
350
  fun sendPhotoError(requestId: String, errorCode: String, errorMessage: String) {
351
+ val timestamp = System.currentTimeMillis()
350
352
  val event = HashMap<String, Any>()
351
353
  event["type"] = "photo_response"
352
354
  event["state"] = "error"
353
355
  event["requestId"] = requestId
354
356
  event["errorCode"] = errorCode
355
357
  event["errorMessage"] = errorMessage
356
- event["timestamp"] = System.currentTimeMillis()
358
+ event["timestamp"] = timestamp
357
359
  sendTypedMessage("photo_response", event as Map<String, Any>)
360
+
361
+ val status = HashMap<String, Any>()
362
+ status["type"] = "photo_status"
363
+ status["status"] = "failed"
364
+ status["requestId"] = requestId
365
+ status["errorCode"] = errorCode
366
+ status["errorMessage"] = errorMessage
367
+ status["timestamp"] = timestamp
368
+ sendTypedMessage("photo_status", status as Map<String, Any>)
369
+ }
370
+
371
+ @JvmStatic
372
+ fun sendPhotoStatus(statusJson: Map<String, Any>) {
373
+ sendTypedMessage("photo_status", statusJson)
358
374
  }
359
375
 
360
376
  /** Send RGB LED control response */
@@ -645,6 +661,15 @@ public class Bridge private constructor() {
645
661
  return
646
662
  }
647
663
 
664
+ if (type != "log") {
665
+ BleTraceLogger.logMap(
666
+ "phone_to_app",
667
+ "sdk_event_dispatch",
668
+ type,
669
+ mutableBody as Map<String, Any>,
670
+ )
671
+ }
672
+
648
673
  // Send directly using type as event name - no JSON serialization
649
674
  sinks.forEach { sink ->
650
675
  try {
@@ -1353,6 +1353,7 @@ class DeviceManager {
1353
1353
  flash: Boolean,
1354
1354
  sound: Boolean,
1355
1355
  exposureTimeNs: Double? = null,
1356
+ iso: Int? = null,
1356
1357
  ) {
1357
1358
  val exposureNs: Long? =
1358
1359
  exposureTimeNs?.takeIf { it.isFinite() && it > 0 }?.let { v ->
@@ -1361,8 +1362,9 @@ class DeviceManager {
1361
1362
  else -> v.toLong()
1362
1363
  }
1363
1364
  }
1365
+ val manualIso = if (exposureNs != null) iso?.takeIf { it > 0 } else null
1364
1366
  Bridge.log(
1365
- "MAN: PHOTO PIPELINE [4/6] DeviceManager.requestPhoto requestId=$requestId appId=$appId size=$size compress=$compress flash=$flash sound=$sound exposureTimeNs=$exposureNs sgc=${sgc?.javaClass?.simpleName ?: "null"}"
1367
+ "MAN: PHOTO PIPELINE [4/6] DeviceManager.requestPhoto requestId=$requestId appId=$appId size=$size compress=$compress flash=$flash sound=$sound exposureTimeNs=$exposureNs iso=${manualIso ?: "auto"} sgc=${sgc?.javaClass?.simpleName ?: "null"}"
1366
1368
  )
1367
1369
  val activeSgc = sgc
1368
1370
  if (activeSgc == null) {
@@ -1371,7 +1373,7 @@ class DeviceManager {
1371
1373
  )
1372
1374
  return
1373
1375
  }
1374
- activeSgc.requestPhoto(requestId, appId, size, webhookUrl, authToken, compress, flash, sound, exposureNs)
1376
+ activeSgc.requestPhoto(requestId, appId, size, webhookUrl, authToken, compress, flash, sound, exposureNs, manualIso)
1375
1377
  }
1376
1378
 
1377
1379
  fun rgbLedControl(
@@ -7,6 +7,7 @@ import android.os.Looper
7
7
  import com.mentra.bluetoothsdk.utils.ControllerTypes
8
8
  import com.mentra.bluetoothsdk.utils.PhoneAudioMonitor
9
9
  import java.util.Collections
10
+ import java.util.UUID
10
11
  import java.util.concurrent.atomic.AtomicBoolean
11
12
 
12
13
  class MentraBluetoothSdk private constructor(
@@ -23,6 +24,8 @@ class MentraBluetoothSdk private constructor(
23
24
  private val bridgeEventSinkId: String
24
25
  private val storeListenerId: String
25
26
  private var suppressDefaultDeviceEvents = false
27
+ private val streamKeepAliveLock = Any()
28
+ private var activeStreamKeepAlive: ActiveStreamKeepAlive? = null
26
29
 
27
30
  init {
28
31
  listeners.add(listener)
@@ -36,6 +39,8 @@ class MentraBluetoothSdk private constructor(
36
39
  private val DEFAULT_DEVICE_KEYS = setOf("default_wearable", "device_name", "device_address")
37
40
  private val SCAN_STATE_KEYS = setOf("searching", "searchingController", "searchResults")
38
41
  private const val DEFAULT_SCAN_TIMEOUT_MS = 15_000L
42
+ private const val DEFAULT_STREAM_KEEP_ALIVE_INTERVAL_SECONDS = 5
43
+ private const val MAX_MISSED_STREAM_KEEP_ALIVE_ACKS = 3
39
44
 
40
45
  @JvmStatic
41
46
  fun create(
@@ -51,6 +56,14 @@ class MentraBluetoothSdk private constructor(
51
56
  ): MentraBluetoothSdk = MentraBluetoothSdk(context, config, listener)
52
57
  }
53
58
 
59
+ private data class ActiveStreamKeepAlive(
60
+ val streamId: String,
61
+ val intervalMs: Long,
62
+ var pendingAckId: String? = null,
63
+ var missedAckCount: Int = 0,
64
+ var nextTick: Runnable? = null,
65
+ )
66
+
54
67
  fun addListener(listener: MentraBluetoothSdkListener) {
55
68
  listeners.add(listener)
56
69
  }
@@ -427,6 +440,7 @@ class MentraBluetoothSdk private constructor(
427
440
  request.flash,
428
441
  request.sound,
429
442
  request.exposureTimeNs,
443
+ request.iso,
430
444
  )
431
445
  }
432
446
 
@@ -435,10 +449,18 @@ class MentraBluetoothSdk private constructor(
435
449
  }
436
450
 
437
451
  fun startStream(request: StreamRequest) {
438
- deviceManager.startStream(request.toMap().toMutableMap())
452
+ val message = request.toMap().toMutableMap()
453
+ val streamId = (message["streamId"] as? String)?.takeIf { it.isNotBlank() }
454
+ ?: "sdk-${UUID.randomUUID()}"
455
+ message["streamId"] = streamId
456
+ stopStreamKeepAliveMonitor()
457
+ deviceManager.startStream(message)
458
+ if (request.keepAlive && !request.isExternallyManagedKeepAlive()) {
459
+ startStreamKeepAliveMonitor(streamId, request.keepAliveIntervalSeconds)
460
+ }
439
461
  }
440
462
 
441
- fun keepStreamAlive(request: StreamKeepAliveRequest) {
463
+ internal fun sendCloudStreamKeepAlive(request: StreamKeepAliveRequest) {
442
464
  deviceManager.keepStreamAlive(request.toMap().toMutableMap())
443
465
  }
444
466
 
@@ -455,6 +477,7 @@ class MentraBluetoothSdk private constructor(
455
477
  }
456
478
 
457
479
  fun stopStream() {
480
+ stopStreamKeepAliveMonitor()
458
481
  deviceManager.stopStream()
459
482
  }
460
483
 
@@ -491,6 +514,7 @@ class MentraBluetoothSdk private constructor(
491
514
  }
492
515
 
493
516
  override fun close() {
517
+ stopStreamKeepAliveMonitor()
494
518
  Bridge.removeEventSink(bridgeEventSinkId)
495
519
  DeviceStore.store.removeListener(storeListenerId)
496
520
  listeners.clear()
@@ -644,8 +668,18 @@ class MentraBluetoothSdk private constructor(
644
668
  "hotspot_error" -> dispatchToListeners { it.onHotspotError(HotspotErrorEvent(data)) }
645
669
  "gallery_status" -> dispatchToListeners { it.onGalleryStatus(GalleryStatusEvent(data)) }
646
670
  "photo_response" -> dispatchToListeners { it.onPhotoResponse(PhotoResponseEvent(data)) }
647
- "stream_status" -> dispatchToListeners { it.onStreamStatus(StreamStatusEvent(data)) }
648
- "keep_alive_ack" -> dispatchToListeners { it.onKeepAliveAck(KeepAliveAckEvent(data)) }
671
+ "photo_status" -> dispatchToListeners { it.onPhotoStatus(PhotoStatusEvent(data)) }
672
+ "stream_status" -> {
673
+ val event = StreamStatusEvent(data)
674
+ handleStreamStatusForKeepAlive(event.status)
675
+ dispatchToListeners { it.onStreamStatus(event) }
676
+ }
677
+ "keep_alive_ack" -> {
678
+ val event = KeepAliveAckEvent(data)
679
+ if (!handleStreamKeepAliveAck(event)) {
680
+ dispatchToListeners { it.onKeepAliveAck(event) }
681
+ }
682
+ }
649
683
  "mic_pcm" -> {
650
684
  val event = MicPcmEvent(data)
651
685
  if (event.pcm.isNotEmpty()) {
@@ -700,4 +734,102 @@ class MentraBluetoothSdk private constructor(
700
734
  deliver()
701
735
  }
702
736
  }
737
+
738
+ private fun startStreamKeepAliveMonitor(streamId: String, requestedIntervalSeconds: Int) {
739
+ val intervalSeconds =
740
+ requestedIntervalSeconds.takeIf { it > 0 } ?: DEFAULT_STREAM_KEEP_ALIVE_INTERVAL_SECONDS
741
+ val tracker = ActiveStreamKeepAlive(
742
+ streamId = streamId,
743
+ intervalMs = intervalSeconds * 1_000L,
744
+ )
745
+ synchronized(streamKeepAliveLock) {
746
+ activeStreamKeepAlive = tracker
747
+ }
748
+ sendNextStreamKeepAlive(tracker)
749
+ }
750
+
751
+ private fun stopStreamKeepAliveMonitor() {
752
+ val tracker =
753
+ synchronized(streamKeepAliveLock) {
754
+ val current = activeStreamKeepAlive
755
+ activeStreamKeepAlive = null
756
+ current
757
+ }
758
+ tracker?.nextTick?.let { mainHandler.removeCallbacks(it) }
759
+ }
760
+
761
+ private fun sendNextStreamKeepAlive(tracker: ActiveStreamKeepAlive) {
762
+ var timeoutEvent: StreamStatusEvent? = null
763
+ var request: StreamKeepAliveRequest? = null
764
+
765
+ synchronized(streamKeepAliveLock) {
766
+ if (activeStreamKeepAlive !== tracker) {
767
+ return
768
+ }
769
+
770
+ if (tracker.pendingAckId != null) {
771
+ tracker.missedAckCount += 1
772
+ if (tracker.missedAckCount >= MAX_MISSED_STREAM_KEEP_ALIVE_ACKS) {
773
+ activeStreamKeepAlive = null
774
+ tracker.nextTick?.let { mainHandler.removeCallbacks(it) }
775
+ timeoutEvent =
776
+ StreamStatusEvent(
777
+ StreamStatus.Error(
778
+ streamId = tracker.streamId,
779
+ errorDetails =
780
+ "Stream keep-alive timed out after ${tracker.missedAckCount} missed ACKs",
781
+ timestamp = System.currentTimeMillis(),
782
+ resolvedConfig = null,
783
+ )
784
+ )
785
+ return@synchronized
786
+ }
787
+ }
788
+
789
+ val ackId = "ack-${System.currentTimeMillis()}"
790
+ tracker.pendingAckId = ackId
791
+ request = StreamKeepAliveRequest(streamId = tracker.streamId, ackId = ackId)
792
+ val nextTick = Runnable { sendNextStreamKeepAlive(tracker) }
793
+ tracker.nextTick = nextTick
794
+ mainHandler.postDelayed(nextTick, tracker.intervalMs)
795
+ }
796
+
797
+ timeoutEvent?.let { event ->
798
+ dispatchToListeners { it.onStreamStatus(event) }
799
+ return
800
+ }
801
+
802
+ request?.let { keepAlive ->
803
+ deviceManager.keepStreamAlive(keepAlive.toMap().toMutableMap())
804
+ }
805
+ }
806
+
807
+ private fun handleStreamKeepAliveAck(event: KeepAliveAckEvent): Boolean {
808
+ synchronized(streamKeepAliveLock) {
809
+ val tracker = activeStreamKeepAlive ?: return false
810
+ if (event.streamId != tracker.streamId || event.ackId != tracker.pendingAckId) {
811
+ return false
812
+ }
813
+ tracker.pendingAckId = null
814
+ tracker.missedAckCount = 0
815
+ return true
816
+ }
817
+ }
818
+
819
+ private fun handleStreamStatusForKeepAlive(status: StreamStatus) {
820
+ val streamId = status.streamId
821
+ if (
822
+ streamId != null &&
823
+ synchronized(streamKeepAliveLock) { activeStreamKeepAlive?.streamId } != streamId
824
+ ) {
825
+ return
826
+ }
827
+ when (status.state) {
828
+ StreamState.STOPPED,
829
+ StreamState.STOPPING,
830
+ StreamState.ERROR,
831
+ StreamState.RECONNECT_FAILED -> stopStreamKeepAliveMonitor()
832
+ else -> Unit
833
+ }
834
+ }
703
835
  }
@@ -47,9 +47,21 @@ data class ButtonVideoRecordingSettings(
47
47
  val fps: Int,
48
48
  )
49
49
 
50
- enum class CameraFov(val fov: Int, val roiPosition: Int) {
51
- STANDARD(118, 0),
52
- WIDE(118, 0),
50
+ class CameraFov @JvmOverloads constructor(
51
+ fov: Int = DEFAULT_FOV,
52
+ roiPosition: Int = DEFAULT_ROI_POSITION,
53
+ ) {
54
+ val fov: Int = fov.coerceIn(MIN_FOV, MAX_FOV)
55
+ val roiPosition: Int = roiPosition.coerceIn(MIN_ROI_POSITION, MAX_ROI_POSITION)
56
+
57
+ companion object {
58
+ const val MIN_FOV = 82
59
+ const val MAX_FOV = 118
60
+ const val DEFAULT_FOV = 102
61
+ const val MIN_ROI_POSITION = 0
62
+ const val MAX_ROI_POSITION = 2
63
+ const val DEFAULT_ROI_POSITION = 0
64
+ }
53
65
  }
54
66
 
55
67
  data class PhotoRequest @JvmOverloads constructor(
@@ -63,6 +75,8 @@ data class PhotoRequest @JvmOverloads constructor(
63
75
  val sound: Boolean = true,
64
76
  /** Sensor exposure time for this capture only (ns), or null for auto exposure */
65
77
  val exposureTimeNs: Double? = null,
78
+ /** Sensor ISO for this capture only. Only used when exposureTimeNs enables manual exposure. */
79
+ val iso: Int? = null,
66
80
  ) {
67
81
  companion object {
68
82
  /** Mirrors iOS `BluetoothSdkModule` defaults for keys omitted from the JS bridge. */
@@ -74,6 +88,17 @@ data class PhotoRequest @JvmOverloads constructor(
74
88
  is Number -> rawExp.toDouble().takeIf { it.isFinite() && it > 0 }
75
89
  else -> null
76
90
  }
91
+ val rawIso = values["iso"]
92
+ val iso: Int? =
93
+ when (rawIso) {
94
+ is Number -> rawIso.toDouble().takeIf { it.isFinite() && it > 0 }?.let { value ->
95
+ when {
96
+ value > Int.MAX_VALUE.toDouble() -> Int.MAX_VALUE
97
+ else -> value.toInt()
98
+ }
99
+ }
100
+ else -> null
101
+ }
77
102
  return PhotoRequest(
78
103
  requestId = stringValue(values, "requestId", "request_id").orEmpty(),
79
104
  appId = stringValue(values, "appId", "app_id").orEmpty(),
@@ -84,6 +109,7 @@ data class PhotoRequest @JvmOverloads constructor(
84
109
  flash = boolValue(values, "flash") ?: true,
85
110
  sound = boolValue(values, "sound") ?: true,
86
111
  exposureTimeNs = exposureTimeNs,
112
+ iso = iso,
87
113
  )
88
114
  }
89
115
  }
@@ -207,3 +233,14 @@ data class PhotoResponseEvent(
207
233
  val requestId: String get() = response.requestId
208
234
  val values: Map<String, Any> get() = response.toEventMap()
209
235
  }
236
+
237
+ data class PhotoStatusEvent(
238
+ val values: Map<String, Any>,
239
+ ) {
240
+ val requestId: String get() = stringValue(values, "requestId").orEmpty()
241
+ val status: String get() = stringValue(values, "status").orEmpty()
242
+ val timestamp: Long get() = longValue(values, "timestamp") ?: System.currentTimeMillis()
243
+ val resolvedConfig: Map<String, Any>? get() = stringMapValue(values["resolvedConfig"])
244
+ val errorCode: String? get() = stringValue(values, "errorCode")
245
+ val errorMessage: String? get() = stringValue(values, "errorMessage")
246
+ }
@@ -24,6 +24,7 @@ abstract class ControllerManager {
24
24
  flash: Boolean,
25
25
  sound: Boolean,
26
26
  exposureTimeNs: Long?,
27
+ iso: Int?,
27
28
  )
28
29
  abstract fun startStream(message: Map<String, Any>)
29
30
  abstract fun stopStream()
@@ -864,7 +864,7 @@ class R1 : ControllerManager() {
864
864
  override fun requestPhoto(
865
865
  requestId: String, appId: String, size: String?, webhookUrl: String?,
866
866
  authToken: String?, compress: String?, flash: Boolean, sound: Boolean,
867
- exposureTimeNs: Long?,
867
+ exposureTimeNs: Long?, iso: Int?,
868
868
  ) {}
869
869
  override fun startVideoRecording(requestId: String, save: Boolean, flash: Boolean, sound: Boolean) {}
870
870
  override fun stopVideoRecording(requestId: String) {}