@mentra/bluetooth-sdk 0.1.8 → 0.1.10

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 (147) hide show
  1. package/README.md +116 -18
  2. package/android/build.gradle +289 -16
  3. package/android/gradle.properties.example +9 -0
  4. package/android/lc3Lib/build.gradle +95 -6
  5. package/android/settings.gradle +3 -0
  6. package/android/silero/LICENSE.md +17 -0
  7. package/android/silero/build.gradle +42 -0
  8. package/android/silero/proguard-rules.pro +24 -0
  9. package/android/silero/src/androidTest/assets/hello.wav +0 -0
  10. package/android/silero/src/androidTest/java/com/konovalov/vad/silero/VadSileroTest.kt +79 -0
  11. package/android/silero/src/main/assets/LICENSE +21 -0
  12. package/android/silero/src/main/assets/silero_vad.onnx +0 -0
  13. package/android/silero/src/main/java/com/konovalov/vad/silero/Vad.kt +160 -0
  14. package/android/silero/src/main/java/com/konovalov/vad/silero/VadSilero.kt +449 -0
  15. package/android/silero/src/main/java/com/konovalov/vad/silero/config/FrameSize.kt +15 -0
  16. package/android/silero/src/main/java/com/konovalov/vad/silero/config/Mode.kt +14 -0
  17. package/android/silero/src/main/java/com/konovalov/vad/silero/config/SampleRate.kt +12 -0
  18. package/android/silero/src/main/java/com/konovalov/vad/silero/utils/AudioUtils.kt +47 -0
  19. package/android/silero/src/main/java/com/konovalov/vad/silero/utils/TensorMap.kt +41 -0
  20. package/android/silero/src/test/java/com/konovalov/vad/silero/utils/AudioUtilsTest.kt +44 -0
  21. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +6 -0
  22. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +165 -27
  23. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +89 -21
  24. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +189 -16
  25. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +22 -2
  26. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +787 -85
  27. package/android/src/main/java/com/mentra/bluetoothsdk/audio/AudioModels.kt +6 -2
  28. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +123 -10
  29. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/ControllerManager.kt +12 -2
  30. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/R1.kt +8 -2
  31. package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleTraceLogger.kt +33 -17
  32. package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +101 -0
  33. package/android/src/main/java/com/mentra/bluetoothsdk/internal/MapParsing.kt +8 -0
  34. package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/LocalPhotoReceiverRegistry.java +108 -0
  35. package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/LocalPhotoUploadServer.kt +399 -74
  36. package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/MentraPhotoReceiverModule.kt +100 -17
  37. package/android/src/main/java/com/mentra/bluetoothsdk/requests/DisplayRequests.kt +17 -0
  38. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.java +2 -2
  39. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +569 -220
  40. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Mach1.java +2 -2
  41. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.java +271 -66
  42. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +176 -104
  43. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraosBle.java +12753 -27036
  44. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +54 -2
  45. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +16 -4
  46. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +60 -3
  47. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +12 -3
  48. package/android/src/main/java/com/mentra/bluetoothsdk/stt/STTTools.kt +87 -36
  49. package/android/src/main/java/com/mentra/bluetoothsdk/stt/SherpaOnnxTranscriber.kt +13 -4
  50. package/android/src/main/java/com/mentra/bluetoothsdk/stt/VadGateSpeechPolicy.kt +229 -0
  51. package/android/src/main/java/com/mentra/bluetoothsdk/tts/TTSTools.kt +180 -0
  52. package/android/src/main/java/com/mentra/bluetoothsdk/utils/AvifExifStripper.java +573 -0
  53. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadService.java +488 -50
  54. package/android/src/main/java/com/mentra/bluetoothsdk/utils/HeifExifTagReader.java +199 -0
  55. package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunkReassembler.java +18 -1
  56. package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunker.java +64 -18
  57. package/android/src/main/java/com/mentra/bluetoothsdk/utils/NexSGCUtils.kt +48 -39
  58. package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +23 -0
  59. package/android/src/test/java/com/mentra/bluetoothsdk/photoreceiver/LocalPhotoReceiverRegistryTest.java +75 -0
  60. package/android/src/test/java/com/mentra/bluetoothsdk/utils/AvifExifStripperTest.java +30 -0
  61. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadServiceTest.java +98 -0
  62. package/android/src/test/resources/avif_with_exif.avif +0 -0
  63. package/build/BluetoothSdk.types.d.ts +241 -43
  64. package/build/BluetoothSdk.types.d.ts.map +1 -1
  65. package/build/BluetoothSdk.types.js +11 -10
  66. package/build/BluetoothSdk.types.js.map +1 -1
  67. package/build/_private/BluetoothSdkModule.d.ts +36 -23
  68. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  69. package/build/_private/BluetoothSdkModule.js +30 -52
  70. package/build/_private/BluetoothSdkModule.js.map +1 -1
  71. package/build/_private/photoRequestPayload.d.ts +4 -0
  72. package/build/_private/photoRequestPayload.d.ts.map +1 -0
  73. package/build/_private/photoRequestPayload.js +25 -0
  74. package/build/_private/photoRequestPayload.js.map +1 -0
  75. package/build/index.d.ts +1 -1
  76. package/build/index.d.ts.map +1 -1
  77. package/build/index.js +23 -0
  78. package/build/index.js.map +1 -1
  79. package/build/react/useMentraBluetooth.d.ts +2 -2
  80. package/build/react/useMentraBluetooth.d.ts.map +1 -1
  81. package/build/react/useMentraBluetooth.js +3 -3
  82. package/build/react/useMentraBluetooth.js.map +1 -1
  83. package/ios/BluetoothSdkModule.swift +174 -70
  84. package/ios/LocalPhotoUploadServer.swift +370 -105
  85. package/ios/MentraBluetoothSDK.podspec +3 -1
  86. package/ios/MentraPhotoReceiverModule.swift +62 -10
  87. package/ios/Packages/CoreObjC/include/PcmConverter.h +22 -0
  88. package/ios/Packages/SherpaOnnx/SherpaOnnx.swift +668 -26
  89. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/Info.plist +13 -9
  90. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/Headers/sherpa-onnx/c-api/c-api.h +4551 -0
  91. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/Headers/sherpa-onnx/c-api/cxx-api.h +1745 -0
  92. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/libsherpa-onnx.a +0 -0
  93. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64_x86_64-simulator/Headers/sherpa-onnx/c-api/c-api.h +4551 -0
  94. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64_x86_64-simulator/Headers/sherpa-onnx/c-api/cxx-api.h +1745 -0
  95. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64_x86_64-simulator/libsherpa-onnx.a +0 -0
  96. package/ios/Source/BluetoothSdkDefaults.swift +6 -0
  97. package/ios/Source/Bridge.swift +58 -19
  98. package/ios/Source/DeviceManager.swift +149 -17
  99. package/ios/Source/DeviceStore.swift +16 -6
  100. package/ios/Source/MentraBluetoothSDK.swift +803 -47
  101. package/ios/Source/ObservableStore.swift +8 -2
  102. package/ios/Source/{Audio → audio}/AudioModels.swift +9 -7
  103. package/ios/Source/{Camera → camera}/CameraModels.swift +225 -16
  104. package/ios/Source/controllers/ControllerManager.swift +4 -3
  105. package/ios/Source/controllers/R1.swift +3 -3
  106. package/ios/Source/{Events → events}/BluetoothEvents.swift +183 -1
  107. package/ios/Source/{Internal → internal}/ValueParsing.swift +10 -1
  108. package/ios/Source/{Requests → requests}/DisplayRequests.swift +28 -4
  109. package/ios/Source/services/PhoneMic.swift +1 -0
  110. package/ios/Source/sgcs/G1.swift +2 -2
  111. package/ios/Source/sgcs/G2.swift +645 -403
  112. package/ios/Source/sgcs/Mach1.swift +2 -2
  113. package/ios/Source/sgcs/MentraLive.swift +812 -319
  114. package/ios/Source/sgcs/MentraNex.swift +412 -182
  115. package/ios/Source/sgcs/SGCManager.swift +57 -3
  116. package/ios/Source/sgcs/Simulated.swift +7 -3
  117. package/ios/Source/sgcs/mentraos_ble.pb.swift +3073 -3134
  118. package/ios/Source/status/DeviceStatus.swift +974 -0
  119. package/ios/Source/{Status → status}/WifiHotspotStatus.swift +4 -4
  120. package/ios/Source/{Streaming → streaming}/StreamModels.swift +24 -9
  121. package/ios/Source/stt/STTTools.swift +27 -12
  122. package/ios/Source/stt/SherpaOnnxTranscriber.swift +31 -8
  123. package/ios/Source/tts/TTSTools.swift +164 -0
  124. package/ios/Source/utils/JSCExperiment.swift +7 -7
  125. package/ios/Source/utils/MessageChunkReassembler.swift +20 -1
  126. package/ios/Source/utils/MessageChunker.swift +78 -21
  127. package/ios/Source/utils/TarBz2Extractor.swift +53 -2
  128. package/package.json +1 -1
  129. package/plugin/build/withAndroid.js +68 -4
  130. package/src/BluetoothSdk.types.ts +331 -59
  131. package/src/_private/BluetoothSdkModule.ts +113 -102
  132. package/src/_private/photoRequestPayload.ts +28 -0
  133. package/src/index.ts +58 -15
  134. package/src/react/useMentraBluetooth.ts +6 -4
  135. package/android/.project +0 -28
  136. package/android/lc3Lib/.project +0 -28
  137. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/Headers/cargs.h +0 -162
  138. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/Headers/sherpa-onnx/c-api/c-api.h +0 -1852
  139. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/Headers/sherpa-onnx/c-api/cxx-api.h +0 -674
  140. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/sherpa-onnx.a +0 -0
  141. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64_x86_64-simulator/sherpa-onnx.a +0 -0
  142. package/ios/Source/Status/DeviceStatus.swift +0 -471
  143. /package/ios/Source/{Connection → connection}/ScanSession.swift +0 -0
  144. /package/ios/Source/{Errors → errors}/BluetoothError.swift +0 -0
  145. /package/ios/Source/{Internal → internal}/BluetoothAvailability.swift +0 -0
  146. /package/ios/Source/{Status → status}/RuntimeState.swift +0 -0
  147. /package/ios/Source/{Types → types}/DeviceModels.swift +0 -0
@@ -8,6 +8,9 @@ import com.mentra.bluetoothsdk.utils.ControllerTypes
8
8
  import com.mentra.bluetoothsdk.utils.PhoneAudioMonitor
9
9
  import java.util.Collections
10
10
  import java.util.UUID
11
+ import java.util.concurrent.ConcurrentHashMap
12
+ import java.util.concurrent.CountDownLatch
13
+ import java.util.concurrent.TimeUnit
11
14
  import java.util.concurrent.atomic.AtomicBoolean
12
15
 
13
16
  class MentraBluetoothSdk private constructor(
@@ -26,6 +29,21 @@ class MentraBluetoothSdk private constructor(
26
29
  private var suppressDefaultDeviceEvents = false
27
30
  private val streamKeepAliveLock = Any()
28
31
  private var activeStreamKeepAlive: ActiveStreamKeepAlive? = null
32
+ private val pendingPhotoRequests = ConcurrentHashMap<String, PendingResponse<PhotoResponseEvent>>()
33
+ private val pendingVideoRecordingRequests =
34
+ ConcurrentHashMap<String, PendingVideoRecordingRequest>()
35
+ private val pendingRgbLedRequests = ConcurrentHashMap<String, PendingResponse<RgbLedControlResponseEvent>>()
36
+ private val pendingSettingsRequests = ConcurrentHashMap<String, PendingResponse<SettingsAckEvent>>()
37
+ private val pendingStreamStarts = ConcurrentHashMap<String, PendingResponse<StreamStatusEvent>>()
38
+ private val oneShotLock = Any()
39
+ private var pendingGalleryStatus: PendingResponse<GalleryStatusEvent>? = null
40
+ private var pendingOtaQuery: PendingResponse<OtaQueryResult>? = null
41
+ private var pendingOtaStart: PendingResponse<OtaStartAckEvent>? = null
42
+ private var pendingStreamStop: PendingStreamStop? = null
43
+ private var pendingWifiScan: PendingWifiScan? = null
44
+ private var pendingWifiStatus: PendingWifiStatusRequest? = null
45
+ private var pendingHotspotStatus: PendingHotspotStatusRequest? = null
46
+ private var pendingVersionInfo: PendingResponse<VersionInfoResult>? = null
29
47
 
30
48
  init {
31
49
  listeners.add(listener)
@@ -39,6 +57,9 @@ class MentraBluetoothSdk private constructor(
39
57
  private val DEFAULT_DEVICE_KEYS = setOf("default_wearable", "device_name", "device_address")
40
58
  private val SCAN_STATE_KEYS = setOf("searching", "searchingController", "searchResults")
41
59
  private const val DEFAULT_SCAN_TIMEOUT_MS = 15_000L
60
+ private const val DEFAULT_REQUEST_TIMEOUT_MS = 15_000L
61
+ private const val STREAM_START_TIMEOUT_MS = 30_000L
62
+ private const val STREAM_STOP_TIMEOUT_MS = 15_000L
42
63
  private const val DEFAULT_STREAM_KEEP_ALIVE_INTERVAL_SECONDS = 5
43
64
  private const val MAX_MISSED_STREAM_KEEP_ALIVE_ACKS = 3
44
65
 
@@ -62,8 +83,79 @@ class MentraBluetoothSdk private constructor(
62
83
  var pendingAckId: String? = null,
63
84
  var missedAckCount: Int = 0,
64
85
  var nextTick: Runnable? = null,
86
+ // Missed-ACK counting only begins once the stream is confirmed live/coming up, so a
87
+ // slow startup (glasses can't ACK until they reach starting/streaming) can't trip a
88
+ // false keep-alive timeout before the stream is ever up.
89
+ var armed: Boolean = false,
65
90
  )
66
91
 
92
+ private data class PendingStreamStop(
93
+ val streamId: String?,
94
+ val pending: PendingResponse<StreamStatusEvent>,
95
+ )
96
+
97
+ private data class PendingVideoRecordingRequest(
98
+ val expectedStatus: String,
99
+ val pending: PendingResponse<VideoRecordingStatusEvent>,
100
+ )
101
+
102
+ private data class PendingWifiScan(
103
+ val pending: PendingResponse<List<WifiScanResult>>,
104
+ )
105
+
106
+ private data class PendingWifiStatusRequest(
107
+ val operation: WifiStatusOperation,
108
+ val ssid: String,
109
+ val pending: PendingResponse<WifiStatusEvent>,
110
+ )
111
+
112
+ private enum class WifiStatusOperation {
113
+ CONNECT,
114
+ FORGET,
115
+ }
116
+
117
+ private data class PendingHotspotStatusRequest(
118
+ val enabled: Boolean,
119
+ val pending: PendingResponse<HotspotStatusEvent>,
120
+ )
121
+
122
+ private class PendingResponse<T>(
123
+ private val operation: String,
124
+ ) {
125
+ private val latch = CountDownLatch(1)
126
+ private val completed = AtomicBoolean(false)
127
+ @Volatile private var result: T? = null
128
+ @Volatile private var error: Throwable? = null
129
+
130
+ fun resolve(value: T) {
131
+ if (completed.compareAndSet(false, true)) {
132
+ result = value
133
+ latch.countDown()
134
+ }
135
+ }
136
+
137
+ fun reject(error: Throwable) {
138
+ if (completed.compareAndSet(false, true)) {
139
+ this.error = error
140
+ latch.countDown()
141
+ }
142
+ }
143
+
144
+ fun await(timeoutMs: Long = DEFAULT_REQUEST_TIMEOUT_MS): T {
145
+ if (!latch.await(timeoutMs, TimeUnit.MILLISECONDS)) {
146
+ throw BluetoothException(
147
+ "request_timeout",
148
+ "$operation timed out waiting for glasses response.",
149
+ )
150
+ }
151
+ error?.let { throw it }
152
+ return result ?: throw BluetoothException(
153
+ "empty_response",
154
+ "$operation completed without a response payload.",
155
+ )
156
+ }
157
+ }
158
+
67
159
  fun addListener(listener: MentraBluetoothSdkListener) {
68
160
  listeners.add(listener)
69
161
  }
@@ -313,6 +405,14 @@ class MentraBluetoothSdk private constructor(
313
405
  )
314
406
  }
315
407
 
408
+ internal fun setCalendarEvents(events: List<CalendarEvent>) {
409
+ DeviceStore.apply(
410
+ ObservableStore.BLUETOOTH_CATEGORY,
411
+ "calendar_events",
412
+ events.map { it.toMap() },
413
+ )
414
+ }
415
+
316
416
  fun setHeadUpAngle(angleDegrees: Int) {
317
417
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "head_up_angle", angleDegrees)
318
418
  }
@@ -321,46 +421,90 @@ class MentraBluetoothSdk private constructor(
321
421
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "screen_disabled", disabled)
322
422
  }
323
423
 
324
- fun setGalleryModeEnabled(enabled: Boolean) {
325
- DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "gallery_mode", enabled)
424
+ fun setGalleryModeEnabled(enabled: Boolean): SettingsAckEvent =
425
+ performSettingsCommand(
426
+ setting = "gallery_mode",
427
+ updateStore = { _ -> DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "gallery_mode", enabled) },
428
+ send = { requestId -> deviceManager.sendGalleryMode(requestId, enabled) },
429
+ )
430
+
431
+ private fun performSettingsCommand(
432
+ setting: String,
433
+ updateStore: (SettingsAckEvent) -> Unit,
434
+ send: (String) -> Unit,
435
+ ): SettingsAckEvent {
436
+ val requestId = "settings-$setting-${UUID.randomUUID()}"
437
+ val pending = PendingResponse<SettingsAckEvent>("set $setting")
438
+ pendingSettingsRequests[requestId] = pending
439
+ try {
440
+ send(requestId)
441
+ val ack = pending.await()
442
+ updateStore(ack)
443
+ return ack
444
+ } finally {
445
+ pendingSettingsRequests.remove(requestId, pending)
446
+ }
326
447
  }
327
448
 
328
449
  fun setVoiceActivityDetectionEnabled(enabled: Boolean) {
329
450
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "voice_activity_detection_enabled", enabled)
330
451
  }
331
452
 
332
- fun setButtonPhotoSettings(size: ButtonPhotoSize) {
333
- DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_size", size.value)
334
- }
453
+ fun setButtonPhotoSettings(size: ButtonPhotoSize): SettingsAckEvent =
454
+ performSettingsCommand(
455
+ setting = "button_photo",
456
+ updateStore = { _ -> DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_size", size.value) },
457
+ send = { requestId -> deviceManager.sendButtonPhotoSettings(requestId, size.value) },
458
+ )
335
459
 
336
- fun setButtonPhotoSettings(settings: ButtonPhotoSettings) {
460
+ fun setButtonPhotoSettings(settings: ButtonPhotoSettings): SettingsAckEvent =
337
461
  setButtonPhotoSettings(size = settings.size)
338
- }
339
462
 
340
- fun setButtonVideoRecordingSettings(width: Int, height: Int, fps: Int) {
341
- DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "button_video_width", width)
342
- DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "button_video_height", height)
343
- DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "button_video_fps", fps)
344
- }
463
+ fun setButtonVideoRecordingSettings(width: Int, height: Int, fps: Int): SettingsAckEvent =
464
+ performSettingsCommand(
465
+ setting = "button_video_recording",
466
+ updateStore = { _ ->
467
+ DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_video_width", width)
468
+ DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_video_height", height)
469
+ DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_video_fps", fps)
470
+ },
471
+ send = { requestId ->
472
+ deviceManager.sendButtonVideoRecordingSettings(requestId, width, height, fps)
473
+ },
474
+ )
345
475
 
346
- fun setButtonVideoRecordingSettings(settings: ButtonVideoRecordingSettings) {
476
+ fun setButtonVideoRecordingSettings(settings: ButtonVideoRecordingSettings): SettingsAckEvent =
347
477
  setButtonVideoRecordingSettings(width = settings.width, height = settings.height, fps = settings.fps)
348
- }
349
478
 
350
- fun setButtonCameraLed(enabled: Boolean) {
351
- DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "button_camera_led", enabled)
352
- }
479
+ fun setButtonCameraLed(enabled: Boolean): SettingsAckEvent =
480
+ performSettingsCommand(
481
+ setting = "button_camera_led",
482
+ updateStore = { _ -> DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_camera_led", enabled) },
483
+ send = { requestId -> deviceManager.sendButtonCameraLedSetting(requestId, enabled) },
484
+ )
353
485
 
354
- fun setButtonMaxRecordingTime(minutes: Int) {
355
- DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "button_max_recording_time", minutes)
356
- }
486
+ fun setButtonMaxRecordingTime(minutes: Int): SettingsAckEvent =
487
+ performSettingsCommand(
488
+ setting = "button_max_recording_time",
489
+ updateStore = { _ ->
490
+ DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_max_recording_time", minutes)
491
+ },
492
+ send = { requestId -> deviceManager.sendButtonMaxRecordingTime(requestId, minutes) },
493
+ )
357
494
 
358
- fun setCameraFov(fov: CameraFov) {
359
- DeviceStore.apply(
495
+ fun setCameraFov(fov: CameraFov): CameraFovResult {
496
+ val ack = performSettingsCommand(
497
+ setting = "camera_fov",
498
+ updateStore = { _ -> },
499
+ send = { requestId -> deviceManager.sendCameraFovSetting(requestId, fov.fov, fov.roiPosition.value) },
500
+ )
501
+ val result = CameraFovResult.fromAck(ack, fov)
502
+ DeviceStore.set(
360
503
  ObservableStore.BLUETOOTH_CATEGORY,
361
504
  "camera_fov",
362
- mapOf("fov" to fov.fov, "roi_position" to fov.roiPosition),
505
+ mapOf("fov" to result.fov, "roi_position" to result.roiPosition.value),
363
506
  )
507
+ return result
364
508
  }
365
509
 
366
510
  fun setMicState(
@@ -410,97 +554,353 @@ class MentraBluetoothSdk private constructor(
410
554
  return GlassesMediaVolumeSetResult.fromMap(deviceManager.setGlassesMediaVolumeBlocking(level))
411
555
  }
412
556
 
413
- fun requestWifiScan() {
414
- deviceManager.requestWifiScan()
557
+ fun requestWifiScan(): List<WifiScanResult> {
558
+ val pending = PendingResponse<List<WifiScanResult>>("WiFi scan request")
559
+ synchronized(oneShotLock) {
560
+ if (pendingWifiScan != null) {
561
+ throw BluetoothException(
562
+ "request_in_flight",
563
+ "A WiFi scan is already waiting for a glasses response.",
564
+ )
565
+ }
566
+ pendingWifiScan = PendingWifiScan(pending)
567
+ }
568
+ try {
569
+ deviceManager.requestWifiScan()
570
+ return pending.await()
571
+ } finally {
572
+ synchronized(oneShotLock) {
573
+ if (pendingWifiScan?.pending === pending) {
574
+ pendingWifiScan = null
575
+ }
576
+ }
577
+ }
415
578
  }
416
579
 
417
- fun sendWifiCredentials(ssid: String, password: String) {
418
- deviceManager.sendWifiCredentials(ssid, password)
580
+ fun sendWifiCredentials(ssid: String, password: String): WifiStatusEvent {
581
+ val pending = PendingResponse<WifiStatusEvent>("WiFi connect request")
582
+ synchronized(oneShotLock) {
583
+ if (pendingWifiStatus != null) {
584
+ throw BluetoothException(
585
+ "request_in_flight",
586
+ "A WiFi status command is already waiting for a glasses response.",
587
+ )
588
+ }
589
+ pendingWifiStatus = PendingWifiStatusRequest(WifiStatusOperation.CONNECT, ssid, pending)
590
+ }
591
+ try {
592
+ deviceManager.sendWifiCredentials(ssid, password)
593
+ return pending.await()
594
+ } finally {
595
+ synchronized(oneShotLock) {
596
+ if (pendingWifiStatus?.pending === pending) {
597
+ pendingWifiStatus = null
598
+ }
599
+ }
600
+ }
601
+ }
602
+
603
+ fun forgetWifiNetwork(ssid: String): WifiStatusEvent {
604
+ val pending = PendingResponse<WifiStatusEvent>("WiFi forget request")
605
+ synchronized(oneShotLock) {
606
+ if (pendingWifiStatus != null) {
607
+ throw BluetoothException(
608
+ "request_in_flight",
609
+ "A WiFi status command is already waiting for a glasses response.",
610
+ )
611
+ }
612
+ pendingWifiStatus = PendingWifiStatusRequest(WifiStatusOperation.FORGET, ssid, pending)
613
+ }
614
+ try {
615
+ deviceManager.forgetWifiNetwork(ssid)
616
+ return pending.await()
617
+ } finally {
618
+ synchronized(oneShotLock) {
619
+ if (pendingWifiStatus?.pending === pending) {
620
+ pendingWifiStatus = null
621
+ }
622
+ }
623
+ }
419
624
  }
420
625
 
421
- fun forgetWifiNetwork(ssid: String) {
422
- deviceManager.forgetWifiNetwork(ssid)
626
+ fun setHotspotState(enabled: Boolean): HotspotStatusEvent {
627
+ val pending = PendingResponse<HotspotStatusEvent>("hotspot ${if (enabled) "enable" else "disable"} request")
628
+ synchronized(oneShotLock) {
629
+ if (pendingHotspotStatus != null) {
630
+ throw BluetoothException(
631
+ "request_in_flight",
632
+ "A hotspot command is already waiting for a glasses response.",
633
+ )
634
+ }
635
+ pendingHotspotStatus = PendingHotspotStatusRequest(enabled, pending)
636
+ }
637
+ try {
638
+ deviceManager.setHotspotState(enabled)
639
+ return pending.await()
640
+ } finally {
641
+ synchronized(oneShotLock) {
642
+ if (pendingHotspotStatus?.pending === pending) {
643
+ pendingHotspotStatus = null
644
+ }
645
+ }
646
+ }
423
647
  }
424
648
 
425
- fun setHotspotState(enabled: Boolean) {
426
- deviceManager.setHotspotState(enabled)
649
+ fun setSystemTime(timestampMs: Long) {
650
+ deviceManager.setSystemTime(timestampMs)
427
651
  }
428
652
 
429
- fun requestPhoto(request: PhotoRequest) {
653
+ fun requestPhoto(request: PhotoRequest): PhotoResponseEvent {
430
654
  Bridge.log(
431
655
  "NATIVE: PHOTO PIPELINE [3b/6] MentraBluetoothSdk.requestPhoto requestId=${request.requestId} appId=${request.appId}"
432
656
  )
433
- deviceManager.requestPhoto(
434
- request.requestId,
435
- request.appId,
436
- request.size.value,
437
- request.webhookUrl,
438
- request.authToken,
439
- request.compress.value,
440
- request.flash,
441
- request.sound,
442
- request.exposureTimeNs,
443
- request.iso,
444
- )
657
+ val pending = PendingResponse<PhotoResponseEvent>("photo request ${request.requestId}")
658
+ pendingPhotoRequests[request.requestId] = pending
659
+ try {
660
+ deviceManager.requestPhoto(
661
+ request.requestId,
662
+ request.appId,
663
+ request.size.value,
664
+ request.webhookUrl,
665
+ request.authToken,
666
+ request.compress.value,
667
+ request.flash,
668
+ request.save,
669
+ request.sound,
670
+ request.exposureTimeNs,
671
+ request.iso,
672
+ )
673
+ return pending.await()
674
+ } finally {
675
+ pendingPhotoRequests.remove(request.requestId, pending)
676
+ }
445
677
  }
446
678
 
447
- fun queryGalleryStatus() {
448
- deviceManager.queryGalleryStatus()
679
+ fun queryGalleryStatus(): GalleryStatusEvent {
680
+ val pending = PendingResponse<GalleryStatusEvent>("gallery status query")
681
+ synchronized(oneShotLock) {
682
+ if (pendingGalleryStatus != null) {
683
+ throw BluetoothException(
684
+ "request_in_flight",
685
+ "A gallery status query is already waiting for a glasses response.",
686
+ )
687
+ }
688
+ pendingGalleryStatus = pending
689
+ }
690
+ try {
691
+ deviceManager.queryGalleryStatus()
692
+ return pending.await()
693
+ } finally {
694
+ synchronized(oneShotLock) {
695
+ if (pendingGalleryStatus === pending) {
696
+ pendingGalleryStatus = null
697
+ }
698
+ }
699
+ }
449
700
  }
450
701
 
451
- fun startStream(request: StreamRequest) {
702
+ fun startStream(request: StreamRequest): StreamStatusEvent {
452
703
  val message = request.toMap().toMutableMap()
453
704
  val streamId = (message["streamId"] as? String)?.takeIf { it.isNotBlank() }
454
705
  ?: "sdk-${UUID.randomUUID()}"
455
706
  message["streamId"] = streamId
707
+ val pending = PendingResponse<StreamStatusEvent>("start stream $streamId")
708
+ pendingStreamStarts[streamId] = pending
456
709
  stopStreamKeepAliveMonitor()
457
- deviceManager.startStream(message)
458
- if (request.keepAlive && !request.isExternallyManagedKeepAlive()) {
459
- startStreamKeepAliveMonitor(streamId, request.keepAliveIntervalSeconds)
710
+ try {
711
+ deviceManager.startStream(message)
712
+ val event = pending.await(STREAM_START_TIMEOUT_MS)
713
+ if (request.keepAlive && !request.isExternallyManagedKeepAlive()) {
714
+ startStreamKeepAliveMonitor(streamId, request.keepAliveIntervalSeconds)
715
+ }
716
+ return event
717
+ } finally {
718
+ pendingStreamStarts.remove(streamId, pending)
460
719
  }
461
720
  }
462
721
 
463
- internal fun sendCloudStreamKeepAlive(request: StreamKeepAliveRequest) {
722
+ internal fun sendExternallyManagedStreamKeepAlive(request: StreamKeepAliveRequest) {
464
723
  deviceManager.keepStreamAlive(request.toMap().toMutableMap())
465
724
  }
466
725
 
467
- fun rgbLedControl(request: RgbLedRequest) {
468
- deviceManager.rgbLedControl(
469
- request.requestId,
470
- request.packageName,
471
- request.action.value,
472
- request.color?.value,
473
- request.onDurationMs,
474
- request.offDurationMs,
475
- request.count,
476
- )
726
+ fun rgbLedControl(request: RgbLedRequest): RgbLedControlResponseEvent {
727
+ val pending = PendingResponse<RgbLedControlResponseEvent>("RGB LED command ${request.requestId}")
728
+ pendingRgbLedRequests[request.requestId] = pending
729
+ try {
730
+ deviceManager.rgbLedControl(
731
+ request.requestId,
732
+ request.packageName,
733
+ request.action.value,
734
+ request.color?.value,
735
+ request.onDurationMs,
736
+ request.offDurationMs,
737
+ request.count,
738
+ )
739
+ return pending.await()
740
+ } finally {
741
+ pendingRgbLedRequests.remove(request.requestId, pending)
742
+ }
477
743
  }
478
744
 
479
- fun stopStream() {
745
+ fun stopStream(): StreamStatusEvent {
746
+ val pending = PendingResponse<StreamStatusEvent>("stop stream")
747
+ val targetStreamId = synchronized(streamKeepAliveLock) {
748
+ activeStreamKeepAlive?.streamId
749
+ }
750
+ synchronized(oneShotLock) {
751
+ if (pendingStreamStop != null) {
752
+ throw BluetoothException(
753
+ "request_in_flight",
754
+ "A stream stop command is already waiting for a glasses response.",
755
+ )
756
+ }
757
+ pendingStreamStop = PendingStreamStop(targetStreamId, pending)
758
+ }
480
759
  stopStreamKeepAliveMonitor()
481
- deviceManager.stopStream()
760
+ try {
761
+ deviceManager.stopStream()
762
+ return pending.await(STREAM_STOP_TIMEOUT_MS)
763
+ } finally {
764
+ synchronized(oneShotLock) {
765
+ if (pendingStreamStop?.pending === pending) {
766
+ pendingStreamStop = null
767
+ }
768
+ }
769
+ }
482
770
  }
483
771
 
484
- fun startVideoRecording(request: VideoRecordingRequest) {
485
- deviceManager.startVideoRecording(request.requestId, request.save, request.sound)
772
+ fun startVideoRecording(request: VideoRecordingRequest): VideoRecordingStatusEvent {
773
+ require(request.requestId.isNotBlank()) { "requestId is required to start video recording." }
774
+ val pending = PendingResponse<VideoRecordingStatusEvent>("start video recording")
775
+ val pendingRequest = PendingVideoRecordingRequest("recording_started", pending)
776
+ if (pendingVideoRecordingRequests.putIfAbsent(request.requestId, pendingRequest) != null) {
777
+ throw BluetoothException(
778
+ "request_in_flight",
779
+ "A video recording command is already waiting for requestId ${request.requestId}.",
780
+ )
781
+ }
782
+ try {
783
+ deviceManager.startVideoRecording(
784
+ request.requestId,
785
+ request.save,
786
+ request.sound,
787
+ request.width,
788
+ request.height,
789
+ request.fps,
790
+ )
791
+ return pending.await()
792
+ } finally {
793
+ pendingVideoRecordingRequests.remove(request.requestId, pendingRequest)
794
+ }
486
795
  }
487
796
 
488
- fun stopVideoRecording(requestId: String) {
489
- deviceManager.stopVideoRecording(requestId)
797
+ fun stopVideoRecording(requestId: String): VideoRecordingStatusEvent {
798
+ require(requestId.isNotBlank()) { "requestId is required to stop video recording." }
799
+ val pending = PendingResponse<VideoRecordingStatusEvent>("stop video recording")
800
+ val pendingRequest = PendingVideoRecordingRequest("recording_stopped", pending)
801
+ if (pendingVideoRecordingRequests.putIfAbsent(requestId, pendingRequest) != null) {
802
+ throw BluetoothException(
803
+ "request_in_flight",
804
+ "A video recording command is already waiting for requestId $requestId.",
805
+ )
806
+ }
807
+ try {
808
+ deviceManager.stopVideoRecording(requestId)
809
+ return pending.await()
810
+ } finally {
811
+ pendingVideoRecordingRequests.remove(requestId, pendingRequest)
812
+ }
490
813
  }
491
814
 
492
- fun requestVersionInfo() {
493
- deviceManager.requestVersionInfo()
815
+ fun requestVersionInfo(): VersionInfoResult {
816
+ val pending = PendingResponse<VersionInfoResult>("version info request")
817
+ synchronized(oneShotLock) {
818
+ if (pendingVersionInfo != null) {
819
+ throw BluetoothException(
820
+ "request_in_flight",
821
+ "A version info request is already waiting for a glasses response.",
822
+ )
823
+ }
824
+ pendingVersionInfo = pending
825
+ }
826
+ try {
827
+ deviceManager.requestVersionInfo()
828
+ return pending.await()
829
+ } finally {
830
+ synchronized(oneShotLock) {
831
+ if (pendingVersionInfo === pending) {
832
+ pendingVersionInfo = null
833
+ }
834
+ }
835
+ }
494
836
  }
495
837
 
496
- internal fun sendOtaStart() {
497
- deviceManager.sendOtaStart()
838
+ /** Ask connected Mentra Live glasses to check/report OTA availability and status. */
839
+ fun checkForOtaUpdate(): OtaQueryResult =
840
+ performOtaQuery("OTA status query") {
841
+ deviceManager.sendOtaQueryStatus()
842
+ }
843
+
844
+ private fun performOtaQuery(
845
+ operation: String,
846
+ sendRequest: () -> Unit,
847
+ ): OtaQueryResult {
848
+ val pending = PendingResponse<OtaQueryResult>(operation)
849
+ synchronized(oneShotLock) {
850
+ if (pendingOtaQuery != null) {
851
+ throw BluetoothException(
852
+ "request_in_flight",
853
+ "An OTA status query is already waiting for a glasses response.",
854
+ )
855
+ }
856
+ pendingOtaQuery = pending
857
+ }
858
+ try {
859
+ sendRequest()
860
+ return pending.await()
861
+ } finally {
862
+ synchronized(oneShotLock) {
863
+ if (pendingOtaQuery === pending) {
864
+ pendingOtaQuery = null
865
+ }
866
+ }
867
+ }
498
868
  }
499
869
 
500
- internal fun sendOtaQueryStatus() {
501
- deviceManager.sendOtaQueryStatus()
870
+ /** Start the OTA flow after your app has presented the available update to the user. */
871
+ fun startOtaUpdate(): OtaStartAckEvent {
872
+ val pending = PendingResponse<OtaStartAckEvent>("OTA start command")
873
+ synchronized(oneShotLock) {
874
+ if (pendingOtaStart != null) {
875
+ throw BluetoothException(
876
+ "request_in_flight",
877
+ "An OTA start command is already waiting for a glasses response.",
878
+ )
879
+ }
880
+ pendingOtaStart = pending
881
+ }
882
+ try {
883
+ deviceManager.sendOtaStart()
884
+ return pending.await()
885
+ } finally {
886
+ synchronized(oneShotLock) {
887
+ if (pendingOtaStart === pending) {
888
+ pendingOtaStart = null
889
+ }
890
+ }
891
+ }
502
892
  }
503
893
 
894
+ /** Re-run the glasses-side OTA version check, mainly after correcting clock skew/TLS failures. */
895
+ fun retryOtaVersionCheck(): OtaQueryResult =
896
+ performOtaQuery("OTA version retry") {
897
+ deviceManager.retryOtaVersionCheck()
898
+ }
899
+
900
+ internal fun sendOtaStart(): OtaStartAckEvent = startOtaUpdate()
901
+
902
+ internal fun sendOtaQueryStatus(): OtaQueryResult = checkForOtaUpdate()
903
+
504
904
  internal fun sendShutdown() {
505
905
  deviceManager.sendShutdown()
506
906
  }
@@ -639,7 +1039,8 @@ class MentraBluetoothSdk private constructor(
639
1039
  it.onVoiceActivityDetectionStatus(
640
1040
  VoiceActivityDetectionStatusEvent(
641
1041
  voiceActivityDetectionEnabled =
642
- data["voiceActivityDetectionEnabled"] as? Boolean ?: true,
1042
+ data["voiceActivityDetectionEnabled"] as? Boolean
1043
+ ?: BluetoothSdkDefaults.VOICE_ACTIVITY_DETECTION_ENABLED,
643
1044
  values = data,
644
1045
  )
645
1046
  )
@@ -663,14 +1064,62 @@ class MentraBluetoothSdk private constructor(
663
1064
  )
664
1065
  )
665
1066
  }
666
- "wifi_status_change" -> dispatchToListeners { it.onWifiStatusChanged(WifiStatusEvent(data)) }
667
- "hotspot_status_change" -> dispatchToListeners { it.onHotspotStatusChanged(HotspotStatusEvent(data)) }
668
- "hotspot_error" -> dispatchToListeners { it.onHotspotError(HotspotErrorEvent(data)) }
669
- "gallery_status" -> dispatchToListeners { it.onGalleryStatus(GalleryStatusEvent(data)) }
670
- "photo_response" -> dispatchToListeners { it.onPhotoResponse(PhotoResponseEvent(data)) }
1067
+ "wifi_status_change" -> {
1068
+ val event = WifiStatusEvent(data)
1069
+ handleWifiStatusForRequests(event)
1070
+ dispatchToListeners { it.onWifiStatusChanged(event) }
1071
+ }
1072
+ "wifi_scan_result" -> {
1073
+ val networks =
1074
+ (data["networks"] as? List<*>)
1075
+ ?.mapNotNull { (it as? Map<*, *>)?.stringKeyedMap() }
1076
+ ?.map(WifiScanResult::fromMap)
1077
+ ?: emptyList()
1078
+ val hasCompletionFlag =
1079
+ data.containsKey("scanComplete") || data.containsKey("scan_complete")
1080
+ val scanComplete =
1081
+ (data["scanComplete"] as? Boolean) ?: (data["scan_complete"] as? Boolean) ?: false
1082
+ if (scanComplete || !hasCompletionFlag) {
1083
+ handleWifiScanResultsForRequests(networks)
1084
+ }
1085
+ dispatchToListeners { it.onRawEvent(eventName, data) }
1086
+ }
1087
+ "hotspot_status_change" -> {
1088
+ val event = HotspotStatusEvent(data)
1089
+ handleHotspotStatusForRequests(event)
1090
+ dispatchToListeners { it.onHotspotStatusChanged(event) }
1091
+ }
1092
+ "hotspot_error" -> {
1093
+ val event = HotspotErrorEvent(data)
1094
+ handleHotspotErrorForRequests(event)
1095
+ dispatchToListeners { it.onHotspotError(event) }
1096
+ }
1097
+ "gallery_status" -> {
1098
+ val event = GalleryStatusEvent(data)
1099
+ synchronized(oneShotLock) {
1100
+ pendingGalleryStatus?.resolve(event)
1101
+ }
1102
+ dispatchToListeners { it.onGalleryStatus(event) }
1103
+ }
1104
+ "photo_response" -> {
1105
+ val event = PhotoResponseEvent(data)
1106
+ handlePhotoResponseForRequests(event)
1107
+ dispatchToListeners { it.onPhotoResponse(event) }
1108
+ }
671
1109
  "photo_status" -> dispatchToListeners { it.onPhotoStatus(PhotoStatusEvent(data)) }
1110
+ "video_recording_status" -> {
1111
+ val event = VideoRecordingStatusEvent(data)
1112
+ handleVideoRecordingStatusForRequests(event)
1113
+ dispatchToListeners { it.onVideoRecordingStatus(event) }
1114
+ }
1115
+ "rgb_led_control_response" -> {
1116
+ val event = RgbLedControlResponseEvent(data)
1117
+ handleRgbLedResponseForRequests(event)
1118
+ dispatchToListeners { it.onRgbLedControlResponse(event) }
1119
+ }
672
1120
  "stream_status" -> {
673
1121
  val event = StreamStatusEvent(data)
1122
+ handleStreamStatusForRequests(event)
674
1123
  handleStreamStatusForKeepAlive(event.status)
675
1124
  dispatchToListeners { it.onStreamStatus(event) }
676
1125
  }
@@ -680,6 +1129,39 @@ class MentraBluetoothSdk private constructor(
680
1129
  dispatchToListeners { it.onKeepAliveAck(event) }
681
1130
  }
682
1131
  }
1132
+ "ota_update_available" -> {
1133
+ val resultValues = data + mapOf("type" to "ota_update_available")
1134
+ synchronized(oneShotLock) {
1135
+ pendingOtaQuery?.resolve(OtaQueryResult(resultValues))
1136
+ }
1137
+ dispatchToListeners { it.onOtaUpdateAvailable(OtaUpdateAvailableEvent.fromMap(resultValues)) }
1138
+ }
1139
+ "ota_start_ack" -> {
1140
+ val event = OtaStartAckEvent.fromMap(data + mapOf("type" to "ota_start_ack"))
1141
+ synchronized(oneShotLock) {
1142
+ pendingOtaStart?.resolve(event)
1143
+ }
1144
+ dispatchToListeners { it.onOtaStartAck(event) }
1145
+ }
1146
+ "ota_status" -> {
1147
+ val resultValues = data + mapOf("type" to "ota_status")
1148
+ synchronized(oneShotLock) {
1149
+ pendingOtaQuery?.resolve(OtaQueryResult(resultValues))
1150
+ }
1151
+ dispatchToListeners { it.onOtaStatus(OtaStatusEvent.fromMap(resultValues)) }
1152
+ }
1153
+ "settings_ack" -> {
1154
+ val event = SettingsAckEvent(data)
1155
+ handleSettingsAckForRequests(event)
1156
+ dispatchToListeners { it.onSettingsAck(event) }
1157
+ }
1158
+ "version_info" -> {
1159
+ val event = VersionInfoResult.fromMap(data)
1160
+ synchronized(oneShotLock) {
1161
+ pendingVersionInfo?.resolve(event)
1162
+ }
1163
+ dispatchToListeners { it.onVersionInfo(event) }
1164
+ }
683
1165
  "mic_pcm" -> {
684
1166
  val event = MicPcmEvent(data)
685
1167
  if (event.pcm.isNotEmpty()) {
@@ -758,6 +1240,212 @@ class MentraBluetoothSdk private constructor(
758
1240
  tracker?.nextTick?.let { mainHandler.removeCallbacks(it) }
759
1241
  }
760
1242
 
1243
+ private fun handleStreamStatusForRequests(event: StreamStatusEvent) {
1244
+ val startMatch = matchingStreamStart(event)
1245
+ if (startMatch != null) {
1246
+ val (streamId, pending) = startMatch
1247
+ when (event.state) {
1248
+ StreamState.STREAMING -> {
1249
+ pendingStreamStarts.remove(streamId, pending)
1250
+ pending.resolve(event)
1251
+ }
1252
+ StreamState.ERROR,
1253
+ StreamState.RECONNECT_FAILED,
1254
+ StreamState.STOPPED -> {
1255
+ pendingStreamStarts.remove(streamId, pending)
1256
+ pending.reject(streamStatusException(event, "stream_start_failed"))
1257
+ }
1258
+ else -> Unit
1259
+ }
1260
+ }
1261
+
1262
+ val stop = synchronized(oneShotLock) { pendingStreamStop }
1263
+ if (stop != null && streamStatusMatches(event, stop.streamId)) {
1264
+ when {
1265
+ isAlreadyStoppedStreamStatus(event) -> {
1266
+ synchronized(oneShotLock) {
1267
+ if (pendingStreamStop === stop) {
1268
+ pendingStreamStop = null
1269
+ }
1270
+ }
1271
+ stop.pending.resolve(stoppedStreamEvent(event, stop.streamId))
1272
+ }
1273
+ event.state == StreamState.ERROR || event.state == StreamState.RECONNECT_FAILED -> {
1274
+ synchronized(oneShotLock) {
1275
+ if (pendingStreamStop === stop) {
1276
+ pendingStreamStop = null
1277
+ }
1278
+ }
1279
+ stop.pending.reject(streamStatusException(event, "stream_stop_failed"))
1280
+ }
1281
+ else -> Unit
1282
+ }
1283
+ }
1284
+ }
1285
+
1286
+ private fun matchingStreamStart(event: StreamStatusEvent): Pair<String, PendingResponse<StreamStatusEvent>>? {
1287
+ val streamId = event.streamId
1288
+ if (!streamId.isNullOrBlank()) {
1289
+ val pending = pendingStreamStarts[streamId] ?: return null
1290
+ return streamId to pending
1291
+ }
1292
+ if (pendingStreamStarts.size == 1) {
1293
+ val entry = pendingStreamStarts.entries.first()
1294
+ return entry.key to entry.value
1295
+ }
1296
+ return null
1297
+ }
1298
+
1299
+ private fun streamStatusMatches(event: StreamStatusEvent, streamId: String?): Boolean =
1300
+ streamId.isNullOrBlank() || event.streamId.isNullOrBlank() || event.streamId == streamId
1301
+
1302
+ private fun isAlreadyStoppedStreamStatus(event: StreamStatusEvent): Boolean {
1303
+ if (event.state == StreamState.STOPPED) {
1304
+ return true
1305
+ }
1306
+ val details = (event.status as? StreamStatus.Error)?.errorDetails?.lowercase()
1307
+ return details in setOf("not_streaming", "already_stopped", "not streaming")
1308
+ }
1309
+
1310
+ private fun stoppedStreamEvent(event: StreamStatusEvent, streamId: String?): StreamStatusEvent =
1311
+ StreamStatusEvent(
1312
+ StreamStatus.Lifecycle(
1313
+ state = StreamState.STOPPED,
1314
+ streamId = event.streamId ?: streamId,
1315
+ timestamp = event.status.timestamp ?: System.currentTimeMillis(),
1316
+ resolvedConfig = event.resolvedConfig,
1317
+ )
1318
+ )
1319
+
1320
+ private fun streamStatusException(event: StreamStatusEvent, code: String): BluetoothException {
1321
+ val details = (event.status as? StreamStatus.Error)?.errorDetails
1322
+ ?: "Stream status ${event.state.value}"
1323
+ return BluetoothException(code, details)
1324
+ }
1325
+
1326
+ private fun handlePhotoResponseForRequests(event: PhotoResponseEvent) {
1327
+ val pending = pendingPhotoRequests[event.requestId] ?: return
1328
+ when (val response = event.response) {
1329
+ is PhotoResponse.Success -> pending.resolve(event)
1330
+ is PhotoResponse.Error ->
1331
+ pending.reject(
1332
+ BluetoothException(
1333
+ response.errorCode ?: "photo_request_failed",
1334
+ response.errorMessage,
1335
+ )
1336
+ )
1337
+ }
1338
+ }
1339
+
1340
+ private fun handleVideoRecordingStatusForRequests(event: VideoRecordingStatusEvent) {
1341
+ val request = pendingVideoRecordingRequests[event.requestId] ?: return
1342
+ if (event.success) {
1343
+ if (event.status == request.expectedStatus) {
1344
+ request.pending.resolve(event)
1345
+ }
1346
+ } else {
1347
+ request.pending.reject(
1348
+ BluetoothException(
1349
+ event.status.ifBlank { "video_recording_failed" },
1350
+ event.details ?: "Video recording command failed.",
1351
+ )
1352
+ )
1353
+ }
1354
+ }
1355
+
1356
+ private fun handleRgbLedResponseForRequests(event: RgbLedControlResponseEvent) {
1357
+ val pending = pendingRgbLedRequests[event.requestId] ?: return
1358
+ if (event.state == "success") {
1359
+ pending.resolve(event)
1360
+ } else {
1361
+ pending.reject(
1362
+ BluetoothException(
1363
+ event.errorCode ?: "rgb_led_control_failed",
1364
+ event.errorCode ?: "RGB LED command failed.",
1365
+ )
1366
+ )
1367
+ }
1368
+ }
1369
+
1370
+ private fun handleSettingsAckForRequests(event: SettingsAckEvent) {
1371
+ val pending = pendingSettingsRequests[event.requestId] ?: return
1372
+ if (isFailureStatus(event.status)) {
1373
+ pending.reject(
1374
+ BluetoothException(
1375
+ event.errorCode ?: "${event.setting.ifBlank { "settings" }}_failed",
1376
+ event.errorMessage ?: "Settings command ${event.setting.ifBlank { event.requestId }} failed.",
1377
+ )
1378
+ )
1379
+ } else {
1380
+ pending.resolve(event)
1381
+ }
1382
+ }
1383
+
1384
+ private fun isFailureStatus(status: String): Boolean =
1385
+ status.lowercase() in setOf("error", "failed", "failure", "rejected")
1386
+
1387
+ private fun handleWifiScanResultsForRequests(results: List<WifiScanResult>) {
1388
+ val request = synchronized(oneShotLock) { pendingWifiScan } ?: return
1389
+ synchronized(oneShotLock) {
1390
+ if (pendingWifiScan === request) {
1391
+ pendingWifiScan = null
1392
+ }
1393
+ }
1394
+ request.pending.resolve(results)
1395
+ }
1396
+
1397
+ private fun handleWifiStatusForRequests(event: WifiStatusEvent) {
1398
+ val request = synchronized(oneShotLock) { pendingWifiStatus } ?: return
1399
+ if (!wifiStatusMatches(event.status, request)) return
1400
+ synchronized(oneShotLock) {
1401
+ if (pendingWifiStatus === request) {
1402
+ pendingWifiStatus = null
1403
+ }
1404
+ }
1405
+ request.pending.resolve(event)
1406
+ }
1407
+
1408
+ private fun wifiStatusMatches(status: WifiStatus, request: PendingWifiStatusRequest): Boolean =
1409
+ when (request.operation) {
1410
+ WifiStatusOperation.CONNECT ->
1411
+ status is WifiStatus.Connected && status.ssid == request.ssid
1412
+ WifiStatusOperation.FORGET ->
1413
+ status == WifiStatus.Disconnected || (status is WifiStatus.Connected && status.ssid != request.ssid)
1414
+ }
1415
+
1416
+ private fun handleHotspotStatusForRequests(event: HotspotStatusEvent) {
1417
+ val request = synchronized(oneShotLock) { pendingHotspotStatus } ?: return
1418
+ if (!hotspotStatusMatches(event.status, request.enabled)) return
1419
+ synchronized(oneShotLock) {
1420
+ if (pendingHotspotStatus === request) {
1421
+ pendingHotspotStatus = null
1422
+ }
1423
+ }
1424
+ request.pending.resolve(event)
1425
+ }
1426
+
1427
+ private fun hotspotStatusMatches(status: HotspotStatus, enabled: Boolean): Boolean =
1428
+ if (enabled) {
1429
+ status is HotspotStatus.Enabled
1430
+ } else {
1431
+ status == HotspotStatus.Disabled
1432
+ }
1433
+
1434
+ private fun handleHotspotErrorForRequests(event: HotspotErrorEvent) {
1435
+ val request = synchronized(oneShotLock) { pendingHotspotStatus } ?: return
1436
+ synchronized(oneShotLock) {
1437
+ if (pendingHotspotStatus === request) {
1438
+ pendingHotspotStatus = null
1439
+ }
1440
+ }
1441
+ request.pending.reject(
1442
+ BluetoothException(
1443
+ "hotspot_command_failed",
1444
+ event.message ?: "Hotspot command failed.",
1445
+ )
1446
+ )
1447
+ }
1448
+
761
1449
  private fun sendNextStreamKeepAlive(tracker: ActiveStreamKeepAlive) {
762
1450
  var timeoutEvent: StreamStatusEvent? = null
763
1451
  var request: StreamKeepAliveRequest? = null
@@ -767,7 +1455,7 @@ class MentraBluetoothSdk private constructor(
767
1455
  return
768
1456
  }
769
1457
 
770
- if (tracker.pendingAckId != null) {
1458
+ if (tracker.armed && tracker.pendingAckId != null) {
771
1459
  tracker.missedAckCount += 1
772
1460
  if (tracker.missedAckCount >= MAX_MISSED_STREAM_KEEP_ALIVE_ACKS) {
773
1461
  activeStreamKeepAlive = null
@@ -796,6 +1484,8 @@ class MentraBluetoothSdk private constructor(
796
1484
 
797
1485
  timeoutEvent?.let { event ->
798
1486
  dispatchToListeners { it.onStreamStatus(event) }
1487
+ stopStreamKeepAliveMonitor()
1488
+ deviceManager.stopStream()
799
1489
  return
800
1490
  }
801
1491
 
@@ -818,10 +1508,8 @@ class MentraBluetoothSdk private constructor(
818
1508
 
819
1509
  private fun handleStreamStatusForKeepAlive(status: StreamStatus) {
820
1510
  val streamId = status.streamId
821
- if (
822
- streamId != null &&
823
- synchronized(streamKeepAliveLock) { activeStreamKeepAlive?.streamId } != streamId
824
- ) {
1511
+ val activeStreamId = synchronized(streamKeepAliveLock) { activeStreamKeepAlive?.streamId }
1512
+ if (streamId == null || activeStreamId != streamId) {
825
1513
  return
826
1514
  }
827
1515
  when (status.state) {
@@ -829,7 +1517,21 @@ class MentraBluetoothSdk private constructor(
829
1517
  StreamState.STOPPING,
830
1518
  StreamState.ERROR,
831
1519
  StreamState.RECONNECT_FAILED -> stopStreamKeepAliveMonitor()
832
- else -> Unit
1520
+ // A non-terminal status means the stream is live or coming up and the glasses can
1521
+ // now ACK; arm the missed-ACK detector from here so a slow startup before the first
1522
+ // ACK can't trip a false keep-alive timeout. On the arming transition, drop any
1523
+ // pre-arm bookkeeping so a stale unacked id (sent before the glasses could ACK)
1524
+ // can't immediately count as a miss.
1525
+ else ->
1526
+ synchronized(streamKeepAliveLock) {
1527
+ activeStreamKeepAlive?.let {
1528
+ if (it.streamId == streamId && !it.armed) {
1529
+ it.armed = true
1530
+ it.pendingAckId = null
1531
+ it.missedAckCount = 0
1532
+ }
1533
+ }
1534
+ }
833
1535
  }
834
1536
  }
835
1537
  }