@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
@@ -9,9 +9,12 @@ import com.mentra.bluetoothsdk.utils.PhoneAudioMonitor
9
9
  import java.util.Collections
10
10
  import java.util.UUID
11
11
  import java.util.concurrent.ConcurrentHashMap
12
- import java.util.concurrent.CountDownLatch
13
- import java.util.concurrent.TimeUnit
14
12
  import java.util.concurrent.atomic.AtomicBoolean
13
+ import java.util.concurrent.atomic.AtomicLong
14
+ import kotlinx.coroutines.CancellationException
15
+ import kotlinx.coroutines.CompletableDeferred
16
+ import kotlinx.coroutines.delay
17
+ import kotlinx.coroutines.withTimeoutOrNull
15
18
 
16
19
  class MentraBluetoothSdk private constructor(
17
20
  context: Context,
@@ -36,7 +39,9 @@ class MentraBluetoothSdk private constructor(
36
39
  ConcurrentHashMap<String, PendingVideoRecordingRequest>()
37
40
  private val pendingRgbLedRequests = ConcurrentHashMap<String, PendingResponse<RgbLedControlResponseEvent>>()
38
41
  private val pendingSettingsRequests = ConcurrentHashMap<String, PendingResponse<SettingsAckEvent>>()
39
- private val pendingStreamStarts = ConcurrentHashMap<String, PendingResponse<StreamStatusEvent>>()
42
+ private val pendingStreamStarts = ConcurrentHashMap<String, PendingStreamStart>()
43
+ private val streamStartSeq = AtomicLong()
44
+ private val streamStartOrderLock = Any()
40
45
  private val oneShotLock = Any()
41
46
  private var pendingGalleryStatus: PendingResponse<GalleryStatusEvent>? = null
42
47
  private var pendingOtaQuery: PendingResponse<OtaQueryResult>? = null
@@ -67,6 +72,9 @@ class MentraBluetoothSdk private constructor(
67
72
  private val SCAN_STATE_KEYS = setOf("searching", "searchingController", "searchResults")
68
73
  private const val DEFAULT_SCAN_TIMEOUT_MS = 15_000L
69
74
  private const val DEFAULT_REQUEST_TIMEOUT_MS = 15_000L
75
+ // A photo response is terminal only after capture, encoding, transport, and upload.
76
+ // Max-quality BLE fallback can legitimately exceed the generic command deadline.
77
+ private const val PHOTO_REQUEST_TIMEOUT_MS = 30_000L
70
78
  private const val WIFI_SCAN_TIMEOUT_MS = 20_000L
71
79
  private const val VIDEO_UPLOAD_STOP_TIMEOUT_MS = 10 * 60 * 1000L
72
80
  private const val STREAM_START_TIMEOUT_MS = 30_000L
@@ -77,6 +85,18 @@ class MentraBluetoothSdk private constructor(
77
85
  private const val DEFAULT_STREAM_KEEP_ALIVE_INTERVAL_SECONDS = 5
78
86
  private const val MAX_MISSED_STREAM_KEEP_ALIVE_ACKS = 3
79
87
 
88
+ // Stream states the glasses only reach after start_stream has run past
89
+ // stopAllServices() — the point where every older start is actually
90
+ // preempted. ERROR and STOPPED are deliberately absent: an ERROR can be
91
+ // a command-level preflight rejection emitted before stopAllServices,
92
+ // and a STOPPED is a stop ack; neither proves older starts were killed.
93
+ private val PREEMPTION_PROVEN_STATES = setOf(
94
+ StreamState.INITIALIZING,
95
+ StreamState.RECONNECTING,
96
+ StreamState.RECONNECTED,
97
+ StreamState.STREAMING,
98
+ )
99
+
80
100
  @JvmStatic
81
101
  fun create(
82
102
  context: Context,
@@ -103,8 +123,25 @@ class MentraBluetoothSdk private constructor(
103
123
  var armed: Boolean = false,
104
124
  )
105
125
 
126
+ // seq records send order (assigned and handed to the BLE queue under
127
+ // streamStartOrderLock) so that an id-carrying status for a newer start can
128
+ // identify which older in-flight starts it preempted.
129
+ private data class PendingStreamStart(
130
+ val seq: Long,
131
+ val pending: PendingResponse<StreamStatusEvent>,
132
+ // Set when an id-carrying error arrives: a fatal publisher error never
133
+ // reaches the reconnect machinery and winds down with a streamId-less
134
+ // stopped, so the stash both attributes that stopped to this start and
135
+ // preserves the real error details for the rejection.
136
+ var lastError: StreamStatusEvent? = null,
137
+ )
138
+
106
139
  private data class PendingStreamStop(
107
140
  val streamId: String?,
141
+ // The stop's slot in the shared streamStartSeq order (drawn inside
142
+ // streamStartOrderLock at send time): every pending start with a
143
+ // lower seq reached the glasses' FIFO before this stop.
144
+ val seq: Long,
108
145
  val pending: PendingResponse<StreamStatusEvent>,
109
146
  )
110
147
 
@@ -118,7 +155,12 @@ class MentraBluetoothSdk private constructor(
118
155
 
119
156
  private data class PendingWifiScan(
120
157
  val pending: PendingResponse<List<WifiScanResult>>,
158
+ val scanId: String,
121
159
  var latestResults: List<WifiScanResult> = emptyList(),
160
+ var waiters: Int = 0,
161
+ // Chunks accumulated from scanId-echoing glasses, deduplicated by SSID;
162
+ // resolved only when the glasses flag the scan complete.
163
+ val accumulated: MutableList<WifiScanResult> = mutableListOf(),
122
164
  )
123
165
 
124
166
  private data class PendingWifiStatusRequest(
@@ -137,40 +179,34 @@ class MentraBluetoothSdk private constructor(
137
179
  val pending: PendingResponse<HotspotStatusEvent>,
138
180
  )
139
181
 
182
+ // Suspends instead of parking a thread (OS-1714): the old CountDownLatch
183
+ // version blocked its caller for the whole glasses round-trip. Expo runs
184
+ // every plain AsyncFunction of every module on ONE shared thread
185
+ // (expo.modules.AsyncFunctionQueue), so a blocked await here froze all
186
+ // miniapp event delivery (Crust.mentraJsDispatchToJs) until the glasses
187
+ // responded. resolve()/reject() stay callable from any thread.
140
188
  private class PendingResponse<T>(
141
189
  private val operation: String,
142
190
  ) {
143
- private val latch = CountDownLatch(1)
144
- private val completed = AtomicBoolean(false)
145
- @Volatile private var result: T? = null
146
- @Volatile private var error: Throwable? = null
191
+ private val deferred = CompletableDeferred<T>()
147
192
 
148
193
  fun resolve(value: T) {
149
- if (completed.compareAndSet(false, true)) {
150
- result = value
151
- latch.countDown()
152
- }
194
+ deferred.complete(value)
153
195
  }
154
196
 
155
197
  fun reject(error: Throwable) {
156
- if (completed.compareAndSet(false, true)) {
157
- this.error = error
158
- latch.countDown()
159
- }
198
+ deferred.completeExceptionally(error)
160
199
  }
161
200
 
162
- fun await(timeoutMs: Long = DEFAULT_REQUEST_TIMEOUT_MS): T {
163
- if (!latch.await(timeoutMs, TimeUnit.MILLISECONDS)) {
164
- throw BluetoothSdkException(
201
+ suspend fun await(timeoutMs: Long = DEFAULT_REQUEST_TIMEOUT_MS): T {
202
+ // All response types are non-null, so a null from withTimeoutOrNull
203
+ // can only mean the deadline elapsed. A rejected deferred rethrows
204
+ // its error from await() and propagates out unchanged.
205
+ return withTimeoutOrNull(timeoutMs) { deferred.await() }
206
+ ?: throw BluetoothSdkException(
165
207
  "request_timeout",
166
208
  "$operation timed out waiting for glasses response.",
167
209
  )
168
- }
169
- error?.let { throw it }
170
- return result ?: throw BluetoothSdkException(
171
- "empty_response",
172
- "$operation completed without a response payload.",
173
- )
174
210
  }
175
211
  }
176
212
 
@@ -448,14 +484,14 @@ class MentraBluetoothSdk private constructor(
448
484
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "screen_disabled", disabled)
449
485
  }
450
486
 
451
- fun setGalleryModeEnabled(enabled: Boolean): SettingsAckEvent =
487
+ suspend fun setGalleryModeEnabled(enabled: Boolean): SettingsAckEvent =
452
488
  performSettingsCommand(
453
489
  setting = "gallery_mode",
454
490
  updateStore = { _ -> DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "gallery_mode", enabled) },
455
491
  send = { requestId -> deviceManager.sendGalleryMode(requestId, enabled) },
456
492
  )
457
493
 
458
- private fun performSettingsCommand(
494
+ private suspend fun performSettingsCommand(
459
495
  setting: String,
460
496
  updateStore: (SettingsAckEvent) -> Unit,
461
497
  send: (String) -> Unit,
@@ -477,7 +513,13 @@ class MentraBluetoothSdk private constructor(
477
513
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "voice_activity_detection_enabled", enabled)
478
514
  }
479
515
 
480
- fun setPhotoCaptureDefaults(settings: PhotoCaptureDefaults): SettingsAckEvent =
516
+ @Deprecated(
517
+ message =
518
+ "Sticky action-button photo presets are deprecated. Prefer per-request " +
519
+ "requestPhoto(...) options (e.g. mode=TEXT for AE ÷3, or explicit per-shot fields). " +
520
+ "This method still works but will be removed in a future release.",
521
+ )
522
+ suspend fun setPhotoCaptureDefaults(settings: PhotoCaptureDefaults): SettingsAckEvent =
481
523
  performSettingsCommand(
482
524
  setting = "button_photo",
483
525
  updateStore = { _ ->
@@ -530,10 +572,16 @@ class MentraBluetoothSdk private constructor(
530
572
  send = { requestId -> deviceManager.sendButtonPhotoSettings(requestId, settings) },
531
573
  )
532
574
 
533
- fun setVideoRecordingDefaults(defaults: VideoRecordingDefaults): SettingsAckEvent =
575
+ suspend fun setVideoRecordingDefaults(defaults: VideoRecordingDefaults): SettingsAckEvent =
534
576
  performSettingsCommand(
535
577
  setting = "button_video_recording",
536
578
  updateStore = { _ ->
579
+ DeviceStore.set(
580
+ ObservableStore.BLUETOOTH_CATEGORY,
581
+ "button_video_settings",
582
+ mapOf("width" to defaults.width, "height" to defaults.height, "fps" to defaults.fps),
583
+ )
584
+ // Keep legacy cache keys readable for older internal callers during migration.
537
585
  DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_video_width", defaults.width)
538
586
  DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_video_height", defaults.height)
539
587
  DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_video_fps", defaults.fps)
@@ -543,7 +591,7 @@ class MentraBluetoothSdk private constructor(
543
591
  },
544
592
  )
545
593
 
546
- fun setMaxVideoRecordingDuration(minutes: Int): SettingsAckEvent =
594
+ suspend fun setMaxVideoRecordingDuration(minutes: Int): SettingsAckEvent =
547
595
  performSettingsCommand(
548
596
  setting = "button_max_recording_time",
549
597
  updateStore = { _ ->
@@ -552,7 +600,7 @@ class MentraBluetoothSdk private constructor(
552
600
  send = { requestId -> deviceManager.sendButtonMaxRecordingTime(requestId, minutes) },
553
601
  )
554
602
 
555
- fun setCameraFov(fov: CameraFov): CameraFovResult {
603
+ suspend fun setCameraFov(fov: CameraFov): CameraFovResult {
556
604
  val ack = performSettingsCommand(
557
605
  setting = "camera_fov",
558
606
  updateStore = { _ -> },
@@ -567,6 +615,44 @@ class MentraBluetoothSdk private constructor(
567
615
  return result
568
616
  }
569
617
 
618
+ /**
619
+ * Compatibility path for ASG clients that support only the older one-way FOV command.
620
+ * It resolves once the setting is queued to BLE because those clients never acknowledge it.
621
+ */
622
+ fun setLegacyCameraFov(fov: CameraFov): CameraFovResult {
623
+ deviceManager.sendLegacyCameraFovSetting(fov.fov, fov.roiPosition.value)
624
+ return CameraFovResult("legacy", fov.fov, fov.roiPosition, System.currentTimeMillis())
625
+ }
626
+
627
+ /** Restores the persistent base FOV through the acknowledgement-free compatibility path. */
628
+ fun restoreLegacyCameraFov() {
629
+ deviceManager.restoreLegacyCameraFovSetting()
630
+ }
631
+
632
+ suspend fun setCameraFovOverride(leaseId: String, fov: CameraFov, ttlMs: Int): CameraFovResult {
633
+ val ack = performSettingsCommand(
634
+ setting = "camera_fov_override",
635
+ updateStore = { _ -> },
636
+ send = { requestId ->
637
+ deviceManager.sendCameraFovOverride(
638
+ requestId,
639
+ leaseId,
640
+ fov.fov,
641
+ fov.roiPosition.value,
642
+ ttlMs,
643
+ )
644
+ },
645
+ )
646
+ return CameraFovResult.fromAck(ack, fov)
647
+ }
648
+
649
+ suspend fun releaseCameraFovOverride(leaseId: String): SettingsAckEvent =
650
+ performSettingsCommand(
651
+ setting = "camera_fov_override",
652
+ updateStore = { _ -> },
653
+ send = { requestId -> deviceManager.releaseCameraFovOverride(requestId, leaseId) },
654
+ )
655
+
570
656
  /**
571
657
  * Configure camera HAL tuning on Mentra Live glasses.
572
658
  *
@@ -577,7 +663,7 @@ class MentraBluetoothSdk private constructor(
577
663
  * @param anrOn {@code true} = ANR enabled, {@code false} = ANR disabled (pixsmart param)
578
664
  * @param gainOn {@code true} = stock gain params, {@code false} = pixsmart gain-off params
579
665
  */
580
- fun setCameraTuningConfig(anrOn: Boolean, gainOn: Boolean): SettingsAckEvent =
666
+ suspend fun setCameraTuningConfig(anrOn: Boolean, gainOn: Boolean): SettingsAckEvent =
581
667
  performSettingsCommand(
582
668
  setting = "camera_tuning",
583
669
  updateStore = { _ -> },
@@ -631,12 +717,12 @@ class MentraBluetoothSdk private constructor(
631
717
  return GlassesMediaVolumeSetResult.fromMap(deviceManager.setGlassesMediaVolumeBlocking(level))
632
718
  }
633
719
 
634
- fun requestWifiScan(): List<WifiScanResult> {
720
+ suspend fun requestWifiScan(): List<WifiScanResult> {
635
721
  val pending = PendingResponse<List<WifiScanResult>>("WiFi scan request")
636
- val request = PendingWifiScan(pending)
722
+ val request = PendingWifiScan(pending, scanId = "scan-${UUID.randomUUID()}", waiters = 1)
637
723
  val existing =
638
724
  synchronized(oneShotLock) {
639
- pendingWifiScan ?: run {
725
+ pendingWifiScan?.also { it.waiters++ } ?: run {
640
726
  pendingWifiScan = request
641
727
  null
642
728
  }
@@ -644,13 +730,26 @@ class MentraBluetoothSdk private constructor(
644
730
  if (existing != null) {
645
731
  // Join the in-flight scan instead of failing with request_in_flight;
646
732
  // the scan screen auto-starts a scan on mount and can be pushed twice.
647
- return existing.pending.await(WIFI_SCAN_TIMEOUT_MS)
733
+ try {
734
+ return existing.pending.await(WIFI_SCAN_TIMEOUT_MS)
735
+ } finally {
736
+ releaseWifiScanWaiter(existing)
737
+ }
648
738
  }
649
739
  try {
650
- deviceManager.requestWifiScan()
740
+ // Claim the scan-results store for this scan before the command goes
741
+ // out, so a delayed chunk from an older, abandoned scan can no longer
742
+ // mutate it.
743
+ Bridge.claimWifiScanResults(request.scanId)
744
+ deviceManager.requestWifiScan(request.scanId)
651
745
  // The glasses wait up to 15s for scan-results broadcasts before sending
652
746
  // scan_complete, so give them longer than that before falling back.
653
747
  return pending.await(WIFI_SCAN_TIMEOUT_MS)
748
+ } catch (cancellation: CancellationException) {
749
+ // Only this caller is going away; the glasses scan keeps running and
750
+ // other coroutines may have joined the same pending entry. Leave the
751
+ // shared deferred alone so scan_complete can still resolve for them.
752
+ throw cancellation
654
753
  } catch (error: Throwable) {
655
754
  if (error is BluetoothSdkException && error.code == "request_timeout") {
656
755
  val fallbackResults = synchronized(oneShotLock) { request.latestResults }
@@ -665,15 +764,23 @@ class MentraBluetoothSdk private constructor(
665
764
  pending.reject(error)
666
765
  throw error
667
766
  } finally {
668
- synchronized(oneShotLock) {
669
- if (pendingWifiScan?.pending === pending) {
670
- pendingWifiScan = null
671
- }
767
+ releaseWifiScanWaiter(request)
768
+ }
769
+ }
770
+
771
+ /** Last waiter (starter or joiner) out clears the shared scan entry so a
772
+ * cancelled starter can't strand it, and an abandoned scan can't wedge
773
+ * future ones. handleWifiScanResultsForRequests may clear it first. */
774
+ private fun releaseWifiScanWaiter(request: PendingWifiScan) {
775
+ synchronized(oneShotLock) {
776
+ request.waiters--
777
+ if (request.waiters <= 0 && pendingWifiScan === request) {
778
+ pendingWifiScan = null
672
779
  }
673
780
  }
674
781
  }
675
782
 
676
- fun sendWifiCredentials(ssid: String, password: String): WifiStatusEvent {
783
+ suspend fun sendWifiCredentials(ssid: String, password: String): WifiStatusEvent {
677
784
  val pending = PendingResponse<WifiStatusEvent>("WiFi connect request")
678
785
  synchronized(oneShotLock) {
679
786
  if (pendingWifiStatus != null) {
@@ -696,7 +803,7 @@ class MentraBluetoothSdk private constructor(
696
803
  }
697
804
  }
698
805
 
699
- fun forgetWifiNetwork(ssid: String): WifiStatusEvent {
806
+ suspend fun forgetWifiNetwork(ssid: String): WifiStatusEvent {
700
807
  val pending = PendingResponse<WifiStatusEvent>("WiFi forget request")
701
808
  synchronized(oneShotLock) {
702
809
  if (pendingWifiStatus != null) {
@@ -719,7 +826,7 @@ class MentraBluetoothSdk private constructor(
719
826
  }
720
827
  }
721
828
 
722
- fun setHotspotState(enabled: Boolean): HotspotStatusEvent {
829
+ suspend fun setHotspotState(enabled: Boolean): HotspotStatusEvent {
723
830
  val pending = PendingResponse<HotspotStatusEvent>("hotspot ${if (enabled) "enable" else "disable"} request")
724
831
  synchronized(oneShotLock) {
725
832
  if (pendingHotspotStatus != null) {
@@ -746,7 +853,7 @@ class MentraBluetoothSdk private constructor(
746
853
  deviceManager.setSystemTime(timestampMs)
747
854
  }
748
855
 
749
- fun requestPhoto(request: PhotoRequest): PhotoResponseEvent {
856
+ suspend fun requestPhoto(request: PhotoRequest): PhotoResponseEvent {
750
857
  val routedRequest =
751
858
  nonBlankRequestId(request.requestId)
752
859
  ?.let { if (it == request.requestId) request else request.copy(requestId = it) }
@@ -758,15 +865,16 @@ class MentraBluetoothSdk private constructor(
758
865
  pendingPhotoRequests[routedRequest.requestId] = pending
759
866
  try {
760
867
  deviceManager.requestPhoto(routedRequest)
761
- return pending.await()
868
+ return pending.await(PHOTO_REQUEST_TIMEOUT_MS)
762
869
  } finally {
763
870
  pendingPhotoRequests.remove(routedRequest.requestId, pending)
764
871
  }
765
872
  }
766
873
 
767
- fun warmUpCamera(
874
+ suspend fun warmUpCamera(
768
875
  requestId: String? = null,
769
876
  size: PhotoSize,
877
+ mode: PhotoMode = PhotoMode.PHOTO,
770
878
  exposureTimeNs: Long?,
771
879
  durationMs: Int,
772
880
  ): CameraStatusEvent {
@@ -781,14 +889,20 @@ class MentraBluetoothSdk private constructor(
781
889
  )
782
890
  }
783
891
  try {
784
- deviceManager.warmUpCamera(effectiveRequestId, size, exposureTimeNs, durationMs)
892
+ deviceManager.warmUpCamera(effectiveRequestId, size, mode, exposureTimeNs, durationMs)
785
893
  return pending.await()
786
894
  } finally {
787
895
  pendingCameraStatusRequests.remove(effectiveRequestId, pending)
788
896
  }
789
897
  }
790
898
 
791
- fun queryGalleryStatus(): GalleryStatusEvent {
899
+ fun stopCameraWarmUp(requestId: String) {
900
+ val effectiveRequestId = nonBlankRequestId(requestId)
901
+ ?: throw BluetoothSdkException("invalid_request", "A warm-up request ID is required.")
902
+ deviceManager.stopCameraWarmUp(effectiveRequestId)
903
+ }
904
+
905
+ suspend fun queryGalleryStatus(): GalleryStatusEvent {
792
906
  val pending = PendingResponse<GalleryStatusEvent>("gallery status query")
793
907
  synchronized(oneShotLock) {
794
908
  if (pendingGalleryStatus != null) {
@@ -811,13 +925,13 @@ class MentraBluetoothSdk private constructor(
811
925
  }
812
926
  }
813
927
 
814
- fun startStream(request: StreamRequest): StreamStatusEvent =
928
+ suspend fun startStream(request: StreamRequest): StreamStatusEvent =
815
929
  startStream(request, startSdkKeepAlive = true)
816
930
 
817
- internal fun startExternallyManagedStream(request: StreamRequest): StreamStatusEvent =
931
+ internal suspend fun startExternallyManagedStream(request: StreamRequest): StreamStatusEvent =
818
932
  startStream(request, startSdkKeepAlive = false)
819
933
 
820
- private fun startStream(
934
+ private suspend fun startStream(
821
935
  request: StreamRequest,
822
936
  startSdkKeepAlive: Boolean,
823
937
  ): StreamStatusEvent {
@@ -826,17 +940,25 @@ class MentraBluetoothSdk private constructor(
826
940
  ?: "sdk-${UUID.randomUUID()}"
827
941
  message["streamId"] = streamId
828
942
  val pending = PendingResponse<StreamStatusEvent>("start stream $streamId")
829
- pendingStreamStarts[streamId] = pending
830
- stopStreamKeepAliveMonitor()
943
+ var start: PendingStreamStart? = null
831
944
  try {
832
- deviceManager.startStream(message)
945
+ // seq assignment and the BLE hand-off must be one critical section:
946
+ // rejectPreemptedStreamStarts only works if seq order equals the
947
+ // order the commands reach the glasses' FIFO.
948
+ synchronized(streamStartOrderLock) {
949
+ val registered = PendingStreamStart(streamStartSeq.incrementAndGet(), pending)
950
+ pendingStreamStarts[streamId] = registered
951
+ start = registered
952
+ stopStreamKeepAliveMonitor()
953
+ deviceManager.startStream(message)
954
+ }
833
955
  val event = pending.await(STREAM_START_TIMEOUT_MS)
834
956
  if (startSdkKeepAlive) {
835
957
  startStreamKeepAliveMonitor(streamId, DEFAULT_STREAM_KEEP_ALIVE_INTERVAL_SECONDS)
836
958
  }
837
959
  return event
838
960
  } finally {
839
- pendingStreamStarts.remove(streamId, pending)
961
+ start?.let { pendingStreamStarts.remove(streamId, it) }
840
962
  }
841
963
  }
842
964
 
@@ -844,7 +966,7 @@ class MentraBluetoothSdk private constructor(
844
966
  deviceManager.keepStreamAlive(request.toMap().toMutableMap())
845
967
  }
846
968
 
847
- fun rgbLedControl(request: RgbLedRequest): RgbLedControlResponseEvent {
969
+ suspend fun rgbLedControl(request: RgbLedRequest): RgbLedControlResponseEvent {
848
970
  val pending = PendingResponse<RgbLedControlResponseEvent>("RGB LED command ${request.requestId}")
849
971
  pendingRgbLedRequests[request.requestId] = pending
850
972
  try {
@@ -863,23 +985,30 @@ class MentraBluetoothSdk private constructor(
863
985
  }
864
986
  }
865
987
 
866
- fun stopStream(): StreamStatusEvent {
988
+ suspend fun stopStream(): StreamStatusEvent {
867
989
  val pending = PendingResponse<StreamStatusEvent>("stop stream")
868
990
  val targetStreamId = synchronized(streamKeepAliveLock) {
869
991
  activeStreamKeepAlive?.streamId
870
992
  }
871
- synchronized(oneShotLock) {
872
- if (pendingStreamStop != null) {
873
- throw BluetoothSdkException(
874
- "request_in_flight",
875
- "A stream stop command is already waiting for a glasses response.",
876
- )
877
- }
878
- pendingStreamStop = PendingStreamStop(targetStreamId, pending)
879
- }
880
- stopStreamKeepAliveMonitor()
881
993
  try {
882
- deviceManager.stopStream()
994
+ // The seq draw and the BLE hand-off share startStream's ordering
995
+ // critical section: the stop takes its own slot in the send order,
996
+ // so its ack can separate the pending starts the glasses consumed
997
+ // before it (lower seq) from starts sent after it (higher seq).
998
+ synchronized(streamStartOrderLock) {
999
+ synchronized(oneShotLock) {
1000
+ if (pendingStreamStop != null) {
1001
+ throw BluetoothSdkException(
1002
+ "request_in_flight",
1003
+ "A stream stop command is already waiting for a glasses response.",
1004
+ )
1005
+ }
1006
+ pendingStreamStop =
1007
+ PendingStreamStop(targetStreamId, streamStartSeq.incrementAndGet(), pending)
1008
+ }
1009
+ stopStreamKeepAliveMonitor()
1010
+ deviceManager.stopStream()
1011
+ }
883
1012
  return pending.await(STREAM_STOP_TIMEOUT_MS)
884
1013
  } finally {
885
1014
  synchronized(oneShotLock) {
@@ -890,7 +1019,7 @@ class MentraBluetoothSdk private constructor(
890
1019
  }
891
1020
  }
892
1021
 
893
- fun startVideoRecording(request: VideoRecordingRequest): VideoRecordingStatusEvent {
1022
+ suspend fun startVideoRecording(request: VideoRecordingRequest): VideoRecordingStatusEvent {
894
1023
  require(request.requestId.isNotBlank()) { "requestId is required to start video recording." }
895
1024
  requireGlassesConnected("start video recording")
896
1025
  val pending = PendingResponse<VideoRecordingStatusEvent>("start video recording")
@@ -918,7 +1047,7 @@ class MentraBluetoothSdk private constructor(
918
1047
  }
919
1048
 
920
1049
  @JvmOverloads
921
- fun stopVideoRecording(
1050
+ suspend fun stopVideoRecording(
922
1051
  requestId: String,
923
1052
  webhookUrl: String? = null,
924
1053
  authToken: String? = null,
@@ -949,7 +1078,7 @@ class MentraBluetoothSdk private constructor(
949
1078
  }
950
1079
  }
951
1080
 
952
- fun requestVersionInfo(): VersionInfoResult {
1081
+ suspend fun requestVersionInfo(): VersionInfoResult {
953
1082
  val pending = PendingResponse<VersionInfoResult>("version info request")
954
1083
  synchronized(oneShotLock) {
955
1084
  if (pendingVersionInfo != null) {
@@ -979,7 +1108,7 @@ class MentraBluetoothSdk private constructor(
979
1108
  internal fun getOtaVersionUrl(): String = configuredOtaVersionUrl ?: OtaManifestDefaults.defaultOtaVersionUrl()
980
1109
 
981
1110
  /** Fetch the configured OTA manifest and return whether any ASG/BES/MTK update is available. */
982
- fun checkForOtaUpdate(): Boolean {
1111
+ suspend fun checkForOtaUpdate(): Boolean {
983
1112
  val status = getFreshGlassesStatus()
984
1113
  if (!status.connected) {
985
1114
  throw BluetoothSdkException(
@@ -1006,12 +1135,12 @@ class MentraBluetoothSdk private constructor(
1006
1135
  }
1007
1136
 
1008
1137
  /** Ask connected Mentra Live glasses to report the current OTA install/session status. */
1009
- private fun queryOtaStatus(): OtaQueryResult =
1138
+ private suspend fun queryOtaStatus(): OtaQueryResult =
1010
1139
  performOtaQuery("OTA status query") {
1011
1140
  deviceManager.sendOtaQueryStatus()
1012
1141
  }
1013
1142
 
1014
- private fun performOtaQuery(
1143
+ private suspend fun performOtaQuery(
1015
1144
  operation: String,
1016
1145
  sendRequest: () -> Unit,
1017
1146
  ): OtaQueryResult {
@@ -1038,12 +1167,12 @@ class MentraBluetoothSdk private constructor(
1038
1167
  }
1039
1168
 
1040
1169
  /** Start the OTA flow after your app has presented the available update to the user. */
1041
- fun startOtaUpdate(): OtaStartAckEvent {
1170
+ suspend fun startOtaUpdate(): OtaStartAckEvent {
1042
1171
  val otaVersionUrl = resolveOtaVersionUrl(getFreshGlassesStatus())
1043
1172
  return startOtaUpdate(otaVersionUrl)
1044
1173
  }
1045
1174
 
1046
- private fun startOtaCommand(otaVersionUrl: String): OtaStartAckEvent {
1175
+ private suspend fun startOtaCommand(otaVersionUrl: String): OtaStartAckEvent {
1047
1176
  val pending = PendingResponse<OtaStartAckEvent>("OTA start command")
1048
1177
  synchronized(oneShotLock) {
1049
1178
  if (pendingOtaStart != null) {
@@ -1066,12 +1195,12 @@ class MentraBluetoothSdk private constructor(
1066
1195
  }
1067
1196
  }
1068
1197
 
1069
- internal fun startOtaUpdate(otaVersionUrl: String): OtaStartAckEvent =
1198
+ internal suspend fun startOtaUpdate(otaVersionUrl: String): OtaStartAckEvent =
1070
1199
  startOtaCommand(otaVersionUrl)
1071
1200
 
1072
- internal fun sendOtaQueryStatus(): OtaQueryResult = queryOtaStatus()
1201
+ internal suspend fun sendOtaQueryStatus(): OtaQueryResult = queryOtaStatus()
1073
1202
 
1074
- private fun getFreshGlassesStatus(): GlassesStatus {
1203
+ private suspend fun getFreshGlassesStatus(): GlassesStatus {
1075
1204
  val status = getRawGlassesStatus()
1076
1205
  if (!status.connected || status.buildNumber.isNotBlank()) {
1077
1206
  return status
@@ -1089,12 +1218,17 @@ class MentraBluetoothSdk private constructor(
1089
1218
  otaVersionUrl = versionInfo.otaVersionUrl.ifBlank { status.otaVersionUrl },
1090
1219
  appVersion = versionInfo.appVersion.ifBlank { status.appVersion },
1091
1220
  )
1221
+ } catch (cancellation: CancellationException) {
1222
+ // Never swallow cancellation into the stale-status fallback: callers
1223
+ // like startOtaUpdate() would otherwise keep running side effects
1224
+ // (sendOtaStart) on behalf of an already-cancelled coroutine.
1225
+ throw cancellation
1092
1226
  } catch (_: Throwable) {
1093
1227
  status
1094
1228
  }
1095
1229
  }
1096
1230
 
1097
- private fun waitForOtaManifestStatus(
1231
+ private suspend fun waitForOtaManifestStatus(
1098
1232
  initialStatus: GlassesStatus,
1099
1233
  manifest: org.json.JSONObject,
1100
1234
  ): GlassesStatus {
@@ -1120,7 +1254,7 @@ class MentraBluetoothSdk private constructor(
1120
1254
  return status
1121
1255
  }
1122
1256
 
1123
- private fun waitForGlassesStatus(
1257
+ private suspend fun waitForGlassesStatus(
1124
1258
  initialStatus: GlassesStatus,
1125
1259
  timeoutMs: Long,
1126
1260
  isReady: (GlassesStatus) -> Boolean,
@@ -1132,7 +1266,7 @@ class MentraBluetoothSdk private constructor(
1132
1266
  if (isReady(status)) return status
1133
1267
  val remainingMs = deadline - System.currentTimeMillis()
1134
1268
  if (remainingMs <= 0L) break
1135
- Thread.sleep(minOf(OTA_VERSION_POLL_MS, remainingMs))
1269
+ delay(minOf(OTA_VERSION_POLL_MS, remainingMs))
1136
1270
  }
1137
1271
  return getRawGlassesStatus()
1138
1272
  }
@@ -1332,9 +1466,15 @@ class MentraBluetoothSdk private constructor(
1332
1466
  data.containsKey("scanComplete") || data.containsKey("scan_complete")
1333
1467
  val scanComplete =
1334
1468
  (data["scanComplete"] as? Boolean) ?: (data["scan_complete"] as? Boolean) ?: false
1335
- updateWifiScanLatestResults(networks)
1336
- if (scanComplete || !hasCompletionFlag) {
1337
- handleWifiScanResultsForRequests(networks)
1469
+ val scanId = (data["scanId"] as? String)?.ifEmpty { null }
1470
+ if (scanId != null) {
1471
+ handleCorrelatedWifiScanChunk(scanId, networks, scanComplete)
1472
+ } else {
1473
+ // Old firmware: no correlation id, keep the pre-scanId heuristics.
1474
+ updateWifiScanLatestResults(networks)
1475
+ if (scanComplete || !hasCompletionFlag) {
1476
+ handleWifiScanResultsForRequests(networks)
1477
+ }
1338
1478
  }
1339
1479
  dispatchToListeners { it.onRawEvent(eventName, data) }
1340
1480
  }
@@ -1500,17 +1640,54 @@ class MentraBluetoothSdk private constructor(
1500
1640
  private fun handleStreamStatusForRequests(event: StreamStatusEvent) {
1501
1641
  val startMatch = matchingStreamStart(event)
1502
1642
  if (startMatch != null) {
1503
- val (streamId, pending) = startMatch
1643
+ val (streamId, start) = startMatch
1644
+ // Preemption may only be inferred from states the glasses emit AFTER
1645
+ // start_stream has run stopAllServices() (see PREEMPTION_PROVEN_STATES).
1646
+ // ERROR must not fire it: a command-level preflight rejection (missing
1647
+ // URL, low battery, no WiFi) carries this start's streamId but is sent
1648
+ // BEFORE stopAllServices, so older starts were not preempted — and
1649
+ // rejecting one that already resolved and started its keep-alive
1650
+ // monitor would strand a live stream without keep-alives. STOPPED
1651
+ // must not fire it either: it is the stop-ack shape and proves
1652
+ // nothing about the start path having run.
1653
+ if (!event.streamId.isNullOrBlank() && event.state in PREEMPTION_PROVEN_STATES) {
1654
+ rejectPreemptedStreamStarts(start.seq)
1655
+ }
1504
1656
  when (event.state) {
1505
- StreamState.STREAMING -> {
1506
- pendingStreamStarts.remove(streamId, pending)
1507
- pending.resolve(event)
1657
+ // `reconnected` also proves the stream is live: when an async start
1658
+ // failure triggers the glasses' publisher retry, a successful retry
1659
+ // reports `reconnected` instead of `streaming`.
1660
+ StreamState.STREAMING,
1661
+ StreamState.RECONNECTED -> {
1662
+ pendingStreamStarts.remove(streamId, start)
1663
+ start.pending.resolve(event)
1508
1664
  }
1509
- StreamState.ERROR,
1510
1665
  StreamState.RECONNECT_FAILED,
1511
1666
  StreamState.STOPPED -> {
1512
- pendingStreamStarts.remove(streamId, pending)
1513
- pending.reject(streamStatusException(event, "stream_start_failed"))
1667
+ pendingStreamStarts.remove(streamId, start)
1668
+ start.pending.reject(
1669
+ streamStatusException(start.lastError ?: event, "stream_start_failed")
1670
+ )
1671
+ }
1672
+ StreamState.ERROR -> {
1673
+ if (!event.streamId.isNullOrBlank() && event.willRetry == true) {
1674
+ // The glasses flag errors their publisher will retry with
1675
+ // `willRetry` (emitting side lands in PR #3488), so keep the
1676
+ // start pending for the retry's verdict — `reconnected` or
1677
+ // `streaming` on success, `reconnect_failed` or the
1678
+ // streamId-less `stopped` wind-down (which reports these
1679
+ // stashed details) on failure.
1680
+ start.lastError = event
1681
+ } else {
1682
+ // An id-less error is the glasses' command-level rejection
1683
+ // (missing URL, low battery, no WiFi) emitted before any
1684
+ // publisher starts; an id-carrying error without `willRetry`
1685
+ // is terminal (`camera_busy` emits an error and nothing else).
1686
+ // Old firmware never sends `willRetry`, so its errors always
1687
+ // fail fast here — the shipped pre-#3487 Android behavior.
1688
+ pendingStreamStarts.remove(streamId, start)
1689
+ start.pending.reject(streamStatusException(event, "stream_start_failed"))
1690
+ }
1514
1691
  }
1515
1692
  else -> Unit
1516
1693
  }
@@ -1525,6 +1702,7 @@ class MentraBluetoothSdk private constructor(
1525
1702
  pendingStreamStop = null
1526
1703
  }
1527
1704
  }
1705
+ rejectStreamStartsSupersededByStop(stop.seq)
1528
1706
  stop.pending.resolve(stoppedStreamEvent(event, stop.streamId))
1529
1707
  }
1530
1708
  event.state == StreamState.ERROR || event.state == StreamState.RECONNECT_FAILED -> {
@@ -1540,22 +1718,79 @@ class MentraBluetoothSdk private constructor(
1540
1718
  }
1541
1719
  }
1542
1720
 
1543
- private fun matchingStreamStart(event: StreamStatusEvent): Pair<String, PendingResponse<StreamStatusEvent>>? {
1721
+ // The glasses process BLE commands FIFO and every start_stream begins by
1722
+ // stopping whatever runs, so an id-carrying status for start X in one of
1723
+ // these states proves every lower-seq start has already been preempted.
1724
+ // Their only verdict on current firmware is a streamId-less stopped, which
1725
+ // the id-less heuristic deliberately ignores — without this they would run
1726
+ // out the 30s timeout instead of failing fast.
1727
+ private fun rejectPreemptedStreamStarts(winnerSeq: Long) {
1728
+ for ((streamId, start) in pendingStreamStarts) {
1729
+ if (start.seq < winnerSeq && pendingStreamStarts.remove(streamId, start)) {
1730
+ start.pending.reject(
1731
+ BluetoothSdkException(
1732
+ "stream_preempted",
1733
+ "start stream $streamId was preempted by a newer start_stream; " +
1734
+ "the glasses run a single stream and the last request wins.",
1735
+ )
1736
+ )
1737
+ }
1738
+ }
1739
+ }
1740
+
1741
+ // The glasses process BLE commands FIFO, so this stop's ack also settles
1742
+ // every start sent before it: whatever those starts brought up (or would
1743
+ // have brought up) has been stopped, and no further status will arrive
1744
+ // for them. Without this they would run out the 30s start timeout.
1745
+ // Starts sent after the stop keep waiting for their own statuses.
1746
+ private fun rejectStreamStartsSupersededByStop(stopSeq: Long) {
1747
+ for ((streamId, start) in pendingStreamStarts) {
1748
+ if (start.seq < stopSeq && pendingStreamStarts.remove(streamId, start)) {
1749
+ start.pending.reject(
1750
+ BluetoothSdkException(
1751
+ "stream_stopped",
1752
+ "start stream $streamId was superseded by a stop_stream issued after it.",
1753
+ )
1754
+ )
1755
+ }
1756
+ }
1757
+ }
1758
+
1759
+ private fun matchingStreamStart(event: StreamStatusEvent): Pair<String, PendingStreamStart>? {
1544
1760
  val streamId = event.streamId
1545
1761
  if (!streamId.isNullOrBlank()) {
1546
- val pending = pendingStreamStarts[streamId] ?: return null
1547
- return streamId to pending
1762
+ val start = pendingStreamStarts[streamId] ?: return null
1763
+ return streamId to start
1764
+ }
1765
+ if (event.state == StreamState.ERROR) {
1766
+ // An id-less error is a command-level preflight rejection (missing
1767
+ // URL, low battery, no WiFi) emitted synchronously, while lifecycle
1768
+ // statuses arrive async — so with overlapping starts the wire is
1769
+ // genuinely ambiguous about which start it answers. Attribute it to
1770
+ // the newest pending start: the common case is a later start
1771
+ // failing preflight while an earlier one is already emitting
1772
+ // lifecycle statuses, and at worst this swaps which start carries
1773
+ // the error instead of letting both time out.
1774
+ val entry = pendingStreamStarts.entries.maxByOrNull { it.value.seq } ?: return null
1775
+ return entry.key to entry.value
1548
1776
  }
1549
1777
  if (pendingStreamStarts.size == 1) {
1778
+ // firstOrNull: timeouts and other handler threads mutate the
1779
+ // ConcurrentHashMap concurrently, so the map can empty between the
1780
+ // size check and this read; bail out instead of throwing.
1781
+ val entry = pendingStreamStarts.entries.firstOrNull() ?: return null
1550
1782
  // A streamId-less STOPPED is the glasses' stop-ack for a PREVIOUS
1551
1783
  // stream (their stop ack carries no streamId), not a verdict on the
1552
1784
  // pending start — a start_stream that replaces a running publisher
1553
1785
  // emits exactly this sequence (stopped -> initializing -> streaming).
1554
1786
  // Attributing it here would reject a start that is about to succeed.
1555
- if (event.state == StreamState.STOPPED) {
1787
+ // After a stashed `willRetry` error for the pending start, though,
1788
+ // the stopped is the retrying publisher's wind-down (the stop-ack
1789
+ // always precedes any status for the new start), so it is that
1790
+ // start's verdict.
1791
+ if (event.state == StreamState.STOPPED && entry.value.lastError == null) {
1556
1792
  return null
1557
1793
  }
1558
- val entry = pendingStreamStarts.entries.first()
1559
1794
  return entry.key to entry.value
1560
1795
  }
1561
1796
  return null
@@ -1703,6 +1938,35 @@ class MentraBluetoothSdk private constructor(
1703
1938
  request.pending.resolve(results)
1704
1939
  }
1705
1940
 
1941
+ // scanId-echoing glasses: results for another scan are ignored instead of
1942
+ // resolving the pending request, and matching chunks accumulate until the
1943
+ // glasses flag the scan complete.
1944
+ private fun handleCorrelatedWifiScanChunk(
1945
+ scanId: String,
1946
+ results: List<WifiScanResult>,
1947
+ scanComplete: Boolean,
1948
+ ) {
1949
+ val request: PendingWifiScan
1950
+ val accumulated: List<WifiScanResult>
1951
+ synchronized(oneShotLock) {
1952
+ request = pendingWifiScan?.takeIf { it.scanId == scanId } ?: return
1953
+ for (network in results) {
1954
+ if (request.accumulated.none { it.ssid == network.ssid }) {
1955
+ request.accumulated.add(network)
1956
+ }
1957
+ }
1958
+ accumulated = request.accumulated.toList()
1959
+ if (accumulated.isNotEmpty()) {
1960
+ request.latestResults = accumulated
1961
+ }
1962
+ if (!scanComplete) return
1963
+ if (pendingWifiScan === request) {
1964
+ pendingWifiScan = null
1965
+ }
1966
+ }
1967
+ request.pending.resolve(accumulated)
1968
+ }
1969
+
1706
1970
  private fun updateWifiScanLatestResults(results: List<WifiScanResult>) {
1707
1971
  if (results.isEmpty()) return
1708
1972
  synchronized(oneShotLock) {
@@ -1712,6 +1976,26 @@ class MentraBluetoothSdk private constructor(
1712
1976
 
1713
1977
  private fun handleWifiStatusForRequests(event: WifiStatusEvent) {
1714
1978
  val request = synchronized(oneShotLock) { pendingWifiStatus } ?: return
1979
+ // A wifi_status carrying the explicit error field is the glasses' failure
1980
+ // verdict for the in-flight connect: reject now instead of running out the
1981
+ // request timeout. Only the error field counts as failure — the glasses'
1982
+ // connect sequence emits a debounced bare connected=false ~1-2s after
1983
+ // credentials while association is still in progress, and rejecting on that
1984
+ // would kill every connect attempt early.
1985
+ if (request.operation == WifiStatusOperation.CONNECT && event.error != null) {
1986
+ synchronized(oneShotLock) {
1987
+ if (pendingWifiStatus === request) {
1988
+ pendingWifiStatus = null
1989
+ }
1990
+ }
1991
+ request.pending.reject(
1992
+ BluetoothSdkException(
1993
+ event.error,
1994
+ "Glasses failed to join \"${request.ssid}\": ${event.error}",
1995
+ )
1996
+ )
1997
+ return
1998
+ }
1715
1999
  if (!wifiStatusMatches(event.status, request)) return
1716
2000
  synchronized(oneShotLock) {
1717
2001
  if (pendingWifiStatus === request) {