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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +5 -1
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +127 -24
  3. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +65 -14
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +46 -3
  5. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +1 -1
  6. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +369 -97
  7. package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +333 -0
  8. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +3 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +1 -1
  10. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +16 -9
  11. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Mach1.kt +1 -1
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +183 -30
  13. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +1 -1
  14. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +1 -1
  15. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +4 -2
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +1 -1
  17. package/android/src/main/java/com/mentra/bluetoothsdk/status/WifiHotspotStatus.kt +19 -3
  18. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +15 -2
  19. package/android/src/main/java/com/mentra/bluetoothsdk/tts/TTSTools.kt +115 -47
  20. package/android/src/main/java/com/mentra/bluetoothsdk/utils/audio/PcmStreamPlayer.kt +328 -0
  21. package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +15 -0
  22. package/build/BluetoothSdk.types.d.ts +46 -1
  23. package/build/BluetoothSdk.types.d.ts.map +1 -1
  24. package/build/BluetoothSdk.types.js.map +1 -1
  25. package/build/_internal.d.ts +2 -0
  26. package/build/_internal.d.ts.map +1 -1
  27. package/build/_internal.js +2 -0
  28. package/build/_internal.js.map +1 -1
  29. package/build/_private/BluetoothSdkModule.d.ts +23 -2
  30. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  31. package/build/_private/BluetoothSdkModule.js +20 -0
  32. package/build/_private/BluetoothSdkModule.js.map +1 -1
  33. package/build/_private/MentraLocalNetworkModule.d.ts +37 -0
  34. package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -0
  35. package/build/_private/MentraLocalNetworkModule.js +3 -0
  36. package/build/_private/MentraLocalNetworkModule.js.map +1 -0
  37. package/build/_private/cameraRequestPayload.d.ts.map +1 -1
  38. package/build/_private/cameraRequestPayload.js +1 -0
  39. package/build/_private/cameraRequestPayload.js.map +1 -1
  40. package/build/_private/photoRequestPayload.d.ts.map +1 -1
  41. package/build/_private/photoRequestPayload.js +1 -0
  42. package/build/_private/photoRequestPayload.js.map +1 -1
  43. package/build/index.d.ts.map +1 -1
  44. package/build/index.js +4 -0
  45. package/build/index.js.map +1 -1
  46. package/expo-module.config.json +1 -0
  47. package/ios/BluetoothSdkModule.swift +72 -0
  48. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  49. package/ios/Source/Bridge.swift +72 -18
  50. package/ios/Source/DeviceManager.swift +51 -3
  51. package/ios/Source/DeviceStore.swift +1 -1
  52. package/ios/Source/MentraBluetoothSDK.swift +289 -25
  53. package/ios/Source/audio/PcmStreamPlayer.swift +410 -0
  54. package/ios/Source/camera/CameraModels.swift +8 -3
  55. package/ios/Source/sgcs/G1.swift +1 -1
  56. package/ios/Source/sgcs/G2.swift +19 -7
  57. package/ios/Source/sgcs/Mach1.swift +1 -1
  58. package/ios/Source/sgcs/MentraLive.swift +318 -37
  59. package/ios/Source/sgcs/MentraNex.swift +1 -1
  60. package/ios/Source/sgcs/Nimo.swift +1 -1
  61. package/ios/Source/sgcs/SGCManager.swift +5 -2
  62. package/ios/Source/sgcs/Simulated.swift +1 -1
  63. package/ios/Source/status/WifiHotspotStatus.swift +21 -2
  64. package/ios/Source/streaming/StreamModels.swift +9 -0
  65. package/ios/Source/tts/TTSTools.swift +1 -1
  66. package/package.json +1 -2
  67. package/src/BluetoothSdk.types.ts +49 -0
  68. package/src/_internal.ts +2 -0
  69. package/src/_private/BluetoothSdkModule.ts +64 -13
  70. package/src/_private/MentraLocalNetworkModule.ts +50 -0
  71. package/src/_private/cameraRequestPayload.ts +1 -0
  72. package/src/_private/photoRequestPayload.ts +2 -3
  73. package/src/index.ts +4 -0
@@ -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,
@@ -483,7 +519,7 @@ class MentraBluetoothSdk private constructor(
483
519
  "requestPhoto(...) options (e.g. mode=TEXT for AE ÷3, or explicit per-shot fields). " +
484
520
  "This method still works but will be removed in a future release.",
485
521
  )
486
- fun setPhotoCaptureDefaults(settings: PhotoCaptureDefaults): SettingsAckEvent =
522
+ suspend fun setPhotoCaptureDefaults(settings: PhotoCaptureDefaults): SettingsAckEvent =
487
523
  performSettingsCommand(
488
524
  setting = "button_photo",
489
525
  updateStore = { _ ->
@@ -536,7 +572,7 @@ class MentraBluetoothSdk private constructor(
536
572
  send = { requestId -> deviceManager.sendButtonPhotoSettings(requestId, settings) },
537
573
  )
538
574
 
539
- fun setVideoRecordingDefaults(defaults: VideoRecordingDefaults): SettingsAckEvent =
575
+ suspend fun setVideoRecordingDefaults(defaults: VideoRecordingDefaults): SettingsAckEvent =
540
576
  performSettingsCommand(
541
577
  setting = "button_video_recording",
542
578
  updateStore = { _ ->
@@ -555,7 +591,7 @@ class MentraBluetoothSdk private constructor(
555
591
  },
556
592
  )
557
593
 
558
- fun setMaxVideoRecordingDuration(minutes: Int): SettingsAckEvent =
594
+ suspend fun setMaxVideoRecordingDuration(minutes: Int): SettingsAckEvent =
559
595
  performSettingsCommand(
560
596
  setting = "button_max_recording_time",
561
597
  updateStore = { _ ->
@@ -564,7 +600,7 @@ class MentraBluetoothSdk private constructor(
564
600
  send = { requestId -> deviceManager.sendButtonMaxRecordingTime(requestId, minutes) },
565
601
  )
566
602
 
567
- fun setCameraFov(fov: CameraFov): CameraFovResult {
603
+ suspend fun setCameraFov(fov: CameraFov): CameraFovResult {
568
604
  val ack = performSettingsCommand(
569
605
  setting = "camera_fov",
570
606
  updateStore = { _ -> },
@@ -579,6 +615,44 @@ class MentraBluetoothSdk private constructor(
579
615
  return result
580
616
  }
581
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
+
582
656
  /**
583
657
  * Configure camera HAL tuning on Mentra Live glasses.
584
658
  *
@@ -589,7 +663,7 @@ class MentraBluetoothSdk private constructor(
589
663
  * @param anrOn {@code true} = ANR enabled, {@code false} = ANR disabled (pixsmart param)
590
664
  * @param gainOn {@code true} = stock gain params, {@code false} = pixsmart gain-off params
591
665
  */
592
- fun setCameraTuningConfig(anrOn: Boolean, gainOn: Boolean): SettingsAckEvent =
666
+ suspend fun setCameraTuningConfig(anrOn: Boolean, gainOn: Boolean): SettingsAckEvent =
593
667
  performSettingsCommand(
594
668
  setting = "camera_tuning",
595
669
  updateStore = { _ -> },
@@ -643,12 +717,12 @@ class MentraBluetoothSdk private constructor(
643
717
  return GlassesMediaVolumeSetResult.fromMap(deviceManager.setGlassesMediaVolumeBlocking(level))
644
718
  }
645
719
 
646
- fun requestWifiScan(): List<WifiScanResult> {
720
+ suspend fun requestWifiScan(): List<WifiScanResult> {
647
721
  val pending = PendingResponse<List<WifiScanResult>>("WiFi scan request")
648
- val request = PendingWifiScan(pending)
722
+ val request = PendingWifiScan(pending, scanId = "scan-${UUID.randomUUID()}", waiters = 1)
649
723
  val existing =
650
724
  synchronized(oneShotLock) {
651
- pendingWifiScan ?: run {
725
+ pendingWifiScan?.also { it.waiters++ } ?: run {
652
726
  pendingWifiScan = request
653
727
  null
654
728
  }
@@ -656,13 +730,26 @@ class MentraBluetoothSdk private constructor(
656
730
  if (existing != null) {
657
731
  // Join the in-flight scan instead of failing with request_in_flight;
658
732
  // the scan screen auto-starts a scan on mount and can be pushed twice.
659
- 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
+ }
660
738
  }
661
739
  try {
662
- 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)
663
745
  // The glasses wait up to 15s for scan-results broadcasts before sending
664
746
  // scan_complete, so give them longer than that before falling back.
665
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
666
753
  } catch (error: Throwable) {
667
754
  if (error is BluetoothSdkException && error.code == "request_timeout") {
668
755
  val fallbackResults = synchronized(oneShotLock) { request.latestResults }
@@ -677,15 +764,23 @@ class MentraBluetoothSdk private constructor(
677
764
  pending.reject(error)
678
765
  throw error
679
766
  } finally {
680
- synchronized(oneShotLock) {
681
- if (pendingWifiScan?.pending === pending) {
682
- pendingWifiScan = null
683
- }
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
684
779
  }
685
780
  }
686
781
  }
687
782
 
688
- fun sendWifiCredentials(ssid: String, password: String): WifiStatusEvent {
783
+ suspend fun sendWifiCredentials(ssid: String, password: String): WifiStatusEvent {
689
784
  val pending = PendingResponse<WifiStatusEvent>("WiFi connect request")
690
785
  synchronized(oneShotLock) {
691
786
  if (pendingWifiStatus != null) {
@@ -708,7 +803,7 @@ class MentraBluetoothSdk private constructor(
708
803
  }
709
804
  }
710
805
 
711
- fun forgetWifiNetwork(ssid: String): WifiStatusEvent {
806
+ suspend fun forgetWifiNetwork(ssid: String): WifiStatusEvent {
712
807
  val pending = PendingResponse<WifiStatusEvent>("WiFi forget request")
713
808
  synchronized(oneShotLock) {
714
809
  if (pendingWifiStatus != null) {
@@ -731,7 +826,7 @@ class MentraBluetoothSdk private constructor(
731
826
  }
732
827
  }
733
828
 
734
- fun setHotspotState(enabled: Boolean): HotspotStatusEvent {
829
+ suspend fun setHotspotState(enabled: Boolean): HotspotStatusEvent {
735
830
  val pending = PendingResponse<HotspotStatusEvent>("hotspot ${if (enabled) "enable" else "disable"} request")
736
831
  synchronized(oneShotLock) {
737
832
  if (pendingHotspotStatus != null) {
@@ -758,7 +853,7 @@ class MentraBluetoothSdk private constructor(
758
853
  deviceManager.setSystemTime(timestampMs)
759
854
  }
760
855
 
761
- fun requestPhoto(request: PhotoRequest): PhotoResponseEvent {
856
+ suspend fun requestPhoto(request: PhotoRequest): PhotoResponseEvent {
762
857
  val routedRequest =
763
858
  nonBlankRequestId(request.requestId)
764
859
  ?.let { if (it == request.requestId) request else request.copy(requestId = it) }
@@ -770,15 +865,16 @@ class MentraBluetoothSdk private constructor(
770
865
  pendingPhotoRequests[routedRequest.requestId] = pending
771
866
  try {
772
867
  deviceManager.requestPhoto(routedRequest)
773
- return pending.await()
868
+ return pending.await(PHOTO_REQUEST_TIMEOUT_MS)
774
869
  } finally {
775
870
  pendingPhotoRequests.remove(routedRequest.requestId, pending)
776
871
  }
777
872
  }
778
873
 
779
- fun warmUpCamera(
874
+ suspend fun warmUpCamera(
780
875
  requestId: String? = null,
781
876
  size: PhotoSize,
877
+ mode: PhotoMode = PhotoMode.PHOTO,
782
878
  exposureTimeNs: Long?,
783
879
  durationMs: Int,
784
880
  ): CameraStatusEvent {
@@ -793,14 +889,20 @@ class MentraBluetoothSdk private constructor(
793
889
  )
794
890
  }
795
891
  try {
796
- deviceManager.warmUpCamera(effectiveRequestId, size, exposureTimeNs, durationMs)
892
+ deviceManager.warmUpCamera(effectiveRequestId, size, mode, exposureTimeNs, durationMs)
797
893
  return pending.await()
798
894
  } finally {
799
895
  pendingCameraStatusRequests.remove(effectiveRequestId, pending)
800
896
  }
801
897
  }
802
898
 
803
- 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 {
804
906
  val pending = PendingResponse<GalleryStatusEvent>("gallery status query")
805
907
  synchronized(oneShotLock) {
806
908
  if (pendingGalleryStatus != null) {
@@ -823,13 +925,13 @@ class MentraBluetoothSdk private constructor(
823
925
  }
824
926
  }
825
927
 
826
- fun startStream(request: StreamRequest): StreamStatusEvent =
928
+ suspend fun startStream(request: StreamRequest): StreamStatusEvent =
827
929
  startStream(request, startSdkKeepAlive = true)
828
930
 
829
- internal fun startExternallyManagedStream(request: StreamRequest): StreamStatusEvent =
931
+ internal suspend fun startExternallyManagedStream(request: StreamRequest): StreamStatusEvent =
830
932
  startStream(request, startSdkKeepAlive = false)
831
933
 
832
- private fun startStream(
934
+ private suspend fun startStream(
833
935
  request: StreamRequest,
834
936
  startSdkKeepAlive: Boolean,
835
937
  ): StreamStatusEvent {
@@ -838,17 +940,25 @@ class MentraBluetoothSdk private constructor(
838
940
  ?: "sdk-${UUID.randomUUID()}"
839
941
  message["streamId"] = streamId
840
942
  val pending = PendingResponse<StreamStatusEvent>("start stream $streamId")
841
- pendingStreamStarts[streamId] = pending
842
- stopStreamKeepAliveMonitor()
943
+ var start: PendingStreamStart? = null
843
944
  try {
844
- 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
+ }
845
955
  val event = pending.await(STREAM_START_TIMEOUT_MS)
846
956
  if (startSdkKeepAlive) {
847
957
  startStreamKeepAliveMonitor(streamId, DEFAULT_STREAM_KEEP_ALIVE_INTERVAL_SECONDS)
848
958
  }
849
959
  return event
850
960
  } finally {
851
- pendingStreamStarts.remove(streamId, pending)
961
+ start?.let { pendingStreamStarts.remove(streamId, it) }
852
962
  }
853
963
  }
854
964
 
@@ -856,7 +966,7 @@ class MentraBluetoothSdk private constructor(
856
966
  deviceManager.keepStreamAlive(request.toMap().toMutableMap())
857
967
  }
858
968
 
859
- fun rgbLedControl(request: RgbLedRequest): RgbLedControlResponseEvent {
969
+ suspend fun rgbLedControl(request: RgbLedRequest): RgbLedControlResponseEvent {
860
970
  val pending = PendingResponse<RgbLedControlResponseEvent>("RGB LED command ${request.requestId}")
861
971
  pendingRgbLedRequests[request.requestId] = pending
862
972
  try {
@@ -875,23 +985,30 @@ class MentraBluetoothSdk private constructor(
875
985
  }
876
986
  }
877
987
 
878
- fun stopStream(): StreamStatusEvent {
988
+ suspend fun stopStream(): StreamStatusEvent {
879
989
  val pending = PendingResponse<StreamStatusEvent>("stop stream")
880
990
  val targetStreamId = synchronized(streamKeepAliveLock) {
881
991
  activeStreamKeepAlive?.streamId
882
992
  }
883
- synchronized(oneShotLock) {
884
- if (pendingStreamStop != null) {
885
- throw BluetoothSdkException(
886
- "request_in_flight",
887
- "A stream stop command is already waiting for a glasses response.",
888
- )
889
- }
890
- pendingStreamStop = PendingStreamStop(targetStreamId, pending)
891
- }
892
- stopStreamKeepAliveMonitor()
893
993
  try {
894
- 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
+ }
895
1012
  return pending.await(STREAM_STOP_TIMEOUT_MS)
896
1013
  } finally {
897
1014
  synchronized(oneShotLock) {
@@ -902,7 +1019,7 @@ class MentraBluetoothSdk private constructor(
902
1019
  }
903
1020
  }
904
1021
 
905
- fun startVideoRecording(request: VideoRecordingRequest): VideoRecordingStatusEvent {
1022
+ suspend fun startVideoRecording(request: VideoRecordingRequest): VideoRecordingStatusEvent {
906
1023
  require(request.requestId.isNotBlank()) { "requestId is required to start video recording." }
907
1024
  requireGlassesConnected("start video recording")
908
1025
  val pending = PendingResponse<VideoRecordingStatusEvent>("start video recording")
@@ -930,7 +1047,7 @@ class MentraBluetoothSdk private constructor(
930
1047
  }
931
1048
 
932
1049
  @JvmOverloads
933
- fun stopVideoRecording(
1050
+ suspend fun stopVideoRecording(
934
1051
  requestId: String,
935
1052
  webhookUrl: String? = null,
936
1053
  authToken: String? = null,
@@ -961,7 +1078,7 @@ class MentraBluetoothSdk private constructor(
961
1078
  }
962
1079
  }
963
1080
 
964
- fun requestVersionInfo(): VersionInfoResult {
1081
+ suspend fun requestVersionInfo(): VersionInfoResult {
965
1082
  val pending = PendingResponse<VersionInfoResult>("version info request")
966
1083
  synchronized(oneShotLock) {
967
1084
  if (pendingVersionInfo != null) {
@@ -991,7 +1108,7 @@ class MentraBluetoothSdk private constructor(
991
1108
  internal fun getOtaVersionUrl(): String = configuredOtaVersionUrl ?: OtaManifestDefaults.defaultOtaVersionUrl()
992
1109
 
993
1110
  /** Fetch the configured OTA manifest and return whether any ASG/BES/MTK update is available. */
994
- fun checkForOtaUpdate(): Boolean {
1111
+ suspend fun checkForOtaUpdate(): Boolean {
995
1112
  val status = getFreshGlassesStatus()
996
1113
  if (!status.connected) {
997
1114
  throw BluetoothSdkException(
@@ -1018,12 +1135,12 @@ class MentraBluetoothSdk private constructor(
1018
1135
  }
1019
1136
 
1020
1137
  /** Ask connected Mentra Live glasses to report the current OTA install/session status. */
1021
- private fun queryOtaStatus(): OtaQueryResult =
1138
+ private suspend fun queryOtaStatus(): OtaQueryResult =
1022
1139
  performOtaQuery("OTA status query") {
1023
1140
  deviceManager.sendOtaQueryStatus()
1024
1141
  }
1025
1142
 
1026
- private fun performOtaQuery(
1143
+ private suspend fun performOtaQuery(
1027
1144
  operation: String,
1028
1145
  sendRequest: () -> Unit,
1029
1146
  ): OtaQueryResult {
@@ -1050,12 +1167,12 @@ class MentraBluetoothSdk private constructor(
1050
1167
  }
1051
1168
 
1052
1169
  /** Start the OTA flow after your app has presented the available update to the user. */
1053
- fun startOtaUpdate(): OtaStartAckEvent {
1170
+ suspend fun startOtaUpdate(): OtaStartAckEvent {
1054
1171
  val otaVersionUrl = resolveOtaVersionUrl(getFreshGlassesStatus())
1055
1172
  return startOtaUpdate(otaVersionUrl)
1056
1173
  }
1057
1174
 
1058
- private fun startOtaCommand(otaVersionUrl: String): OtaStartAckEvent {
1175
+ private suspend fun startOtaCommand(otaVersionUrl: String): OtaStartAckEvent {
1059
1176
  val pending = PendingResponse<OtaStartAckEvent>("OTA start command")
1060
1177
  synchronized(oneShotLock) {
1061
1178
  if (pendingOtaStart != null) {
@@ -1078,12 +1195,12 @@ class MentraBluetoothSdk private constructor(
1078
1195
  }
1079
1196
  }
1080
1197
 
1081
- internal fun startOtaUpdate(otaVersionUrl: String): OtaStartAckEvent =
1198
+ internal suspend fun startOtaUpdate(otaVersionUrl: String): OtaStartAckEvent =
1082
1199
  startOtaCommand(otaVersionUrl)
1083
1200
 
1084
- internal fun sendOtaQueryStatus(): OtaQueryResult = queryOtaStatus()
1201
+ internal suspend fun sendOtaQueryStatus(): OtaQueryResult = queryOtaStatus()
1085
1202
 
1086
- private fun getFreshGlassesStatus(): GlassesStatus {
1203
+ private suspend fun getFreshGlassesStatus(): GlassesStatus {
1087
1204
  val status = getRawGlassesStatus()
1088
1205
  if (!status.connected || status.buildNumber.isNotBlank()) {
1089
1206
  return status
@@ -1101,12 +1218,17 @@ class MentraBluetoothSdk private constructor(
1101
1218
  otaVersionUrl = versionInfo.otaVersionUrl.ifBlank { status.otaVersionUrl },
1102
1219
  appVersion = versionInfo.appVersion.ifBlank { status.appVersion },
1103
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
1104
1226
  } catch (_: Throwable) {
1105
1227
  status
1106
1228
  }
1107
1229
  }
1108
1230
 
1109
- private fun waitForOtaManifestStatus(
1231
+ private suspend fun waitForOtaManifestStatus(
1110
1232
  initialStatus: GlassesStatus,
1111
1233
  manifest: org.json.JSONObject,
1112
1234
  ): GlassesStatus {
@@ -1132,7 +1254,7 @@ class MentraBluetoothSdk private constructor(
1132
1254
  return status
1133
1255
  }
1134
1256
 
1135
- private fun waitForGlassesStatus(
1257
+ private suspend fun waitForGlassesStatus(
1136
1258
  initialStatus: GlassesStatus,
1137
1259
  timeoutMs: Long,
1138
1260
  isReady: (GlassesStatus) -> Boolean,
@@ -1144,7 +1266,7 @@ class MentraBluetoothSdk private constructor(
1144
1266
  if (isReady(status)) return status
1145
1267
  val remainingMs = deadline - System.currentTimeMillis()
1146
1268
  if (remainingMs <= 0L) break
1147
- Thread.sleep(minOf(OTA_VERSION_POLL_MS, remainingMs))
1269
+ delay(minOf(OTA_VERSION_POLL_MS, remainingMs))
1148
1270
  }
1149
1271
  return getRawGlassesStatus()
1150
1272
  }
@@ -1344,9 +1466,15 @@ class MentraBluetoothSdk private constructor(
1344
1466
  data.containsKey("scanComplete") || data.containsKey("scan_complete")
1345
1467
  val scanComplete =
1346
1468
  (data["scanComplete"] as? Boolean) ?: (data["scan_complete"] as? Boolean) ?: false
1347
- updateWifiScanLatestResults(networks)
1348
- if (scanComplete || !hasCompletionFlag) {
1349
- 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
+ }
1350
1478
  }
1351
1479
  dispatchToListeners { it.onRawEvent(eventName, data) }
1352
1480
  }
@@ -1512,17 +1640,54 @@ class MentraBluetoothSdk private constructor(
1512
1640
  private fun handleStreamStatusForRequests(event: StreamStatusEvent) {
1513
1641
  val startMatch = matchingStreamStart(event)
1514
1642
  if (startMatch != null) {
1515
- 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
+ }
1516
1656
  when (event.state) {
1517
- StreamState.STREAMING -> {
1518
- pendingStreamStarts.remove(streamId, pending)
1519
- 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)
1520
1664
  }
1521
- StreamState.ERROR,
1522
1665
  StreamState.RECONNECT_FAILED,
1523
1666
  StreamState.STOPPED -> {
1524
- pendingStreamStarts.remove(streamId, pending)
1525
- 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
+ }
1526
1691
  }
1527
1692
  else -> Unit
1528
1693
  }
@@ -1537,6 +1702,7 @@ class MentraBluetoothSdk private constructor(
1537
1702
  pendingStreamStop = null
1538
1703
  }
1539
1704
  }
1705
+ rejectStreamStartsSupersededByStop(stop.seq)
1540
1706
  stop.pending.resolve(stoppedStreamEvent(event, stop.streamId))
1541
1707
  }
1542
1708
  event.state == StreamState.ERROR || event.state == StreamState.RECONNECT_FAILED -> {
@@ -1552,22 +1718,79 @@ class MentraBluetoothSdk private constructor(
1552
1718
  }
1553
1719
  }
1554
1720
 
1555
- 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>? {
1556
1760
  val streamId = event.streamId
1557
1761
  if (!streamId.isNullOrBlank()) {
1558
- val pending = pendingStreamStarts[streamId] ?: return null
1559
- 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
1560
1776
  }
1561
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
1562
1782
  // A streamId-less STOPPED is the glasses' stop-ack for a PREVIOUS
1563
1783
  // stream (their stop ack carries no streamId), not a verdict on the
1564
1784
  // pending start — a start_stream that replaces a running publisher
1565
1785
  // emits exactly this sequence (stopped -> initializing -> streaming).
1566
1786
  // Attributing it here would reject a start that is about to succeed.
1567
- 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) {
1568
1792
  return null
1569
1793
  }
1570
- val entry = pendingStreamStarts.entries.first()
1571
1794
  return entry.key to entry.value
1572
1795
  }
1573
1796
  return null
@@ -1715,6 +1938,35 @@ class MentraBluetoothSdk private constructor(
1715
1938
  request.pending.resolve(results)
1716
1939
  }
1717
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
+
1718
1970
  private fun updateWifiScanLatestResults(results: List<WifiScanResult>) {
1719
1971
  if (results.isEmpty()) return
1720
1972
  synchronized(oneShotLock) {
@@ -1724,6 +1976,26 @@ class MentraBluetoothSdk private constructor(
1724
1976
 
1725
1977
  private fun handleWifiStatusForRequests(event: WifiStatusEvent) {
1726
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
+ }
1727
1999
  if (!wifiStatusMatches(event.status, request)) return
1728
2000
  synchronized(oneShotLock) {
1729
2001
  if (pendingWifiStatus === request) {