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

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 (114) hide show
  1. package/README.md +27 -12
  2. package/android/build.gradle +3 -0
  3. package/android/src/main/AndroidManifest.xml +3 -1
  4. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalytics.kt +34 -0
  5. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +1 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +169 -26
  7. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +72 -16
  8. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +127 -8
  9. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +4 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +400 -99
  11. package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +333 -0
  12. package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +43 -3
  13. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +18 -2
  14. package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleTraceLogger.kt +1 -1
  15. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +53 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Ar99.java +2653 -0
  17. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +1 -1
  18. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +16 -9
  19. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Mach1.kt +1 -1
  20. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +342 -42
  21. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +1 -1
  22. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +1 -1
  23. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +7 -2
  24. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +1 -1
  25. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/Ar99OtaManager.java +566 -0
  26. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaByteUtils.java +18 -0
  27. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaCommandConstants.java +35 -0
  28. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaCrc32Util.java +13 -0
  29. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaGattTransport.java +11 -0
  30. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaProtocol.java +92 -0
  31. package/android/src/main/java/com/mentra/bluetoothsdk/status/WifiHotspotStatus.kt +19 -3
  32. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +52 -2
  33. package/android/src/main/java/com/mentra/bluetoothsdk/tts/TTSTools.kt +115 -47
  34. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +7 -0
  35. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +8 -0
  36. package/android/src/main/java/com/mentra/bluetoothsdk/utils/Constants.kt +2 -1
  37. package/android/src/main/java/com/mentra/bluetoothsdk/utils/PhoneAudioMonitor.kt +45 -0
  38. package/android/src/main/java/com/mentra/bluetoothsdk/utils/audio/Ar99OpusPcmDecoder.java +398 -0
  39. package/android/src/main/java/com/mentra/bluetoothsdk/utils/audio/PcmStreamPlayer.kt +328 -0
  40. package/android/src/test/java/com/mentra/bluetoothsdk/OtaManifestDowngradeTest.kt +86 -0
  41. package/android/src/test/java/com/mentra/bluetoothsdk/StreamStatusEventTest.kt +29 -0
  42. package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +45 -0
  43. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/Ar99AdvertisementMatcherTest.java +22 -0
  44. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +20 -0
  45. package/android/src/test/java/com/mentra/bluetoothsdk/utils/PhoneAudioMonitorTest.kt +45 -0
  46. package/build/BluetoothSdk.types.d.ts +100 -22
  47. package/build/BluetoothSdk.types.d.ts.map +1 -1
  48. package/build/BluetoothSdk.types.js +2 -1
  49. package/build/BluetoothSdk.types.js.map +1 -1
  50. package/build/_internal.d.ts +3 -0
  51. package/build/_internal.d.ts.map +1 -1
  52. package/build/_internal.js +3 -0
  53. package/build/_internal.js.map +1 -1
  54. package/build/_private/BluetoothSdkModule.d.ts +31 -4
  55. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  56. package/build/_private/BluetoothSdkModule.js +26 -3
  57. package/build/_private/BluetoothSdkModule.js.map +1 -1
  58. package/build/_private/MentraLocalNetworkModule.d.ts +37 -0
  59. package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -0
  60. package/build/_private/MentraLocalNetworkModule.js +3 -0
  61. package/build/_private/MentraLocalNetworkModule.js.map +1 -0
  62. package/build/_private/cameraRequestPayload.d.ts +1 -1
  63. package/build/_private/cameraRequestPayload.d.ts.map +1 -1
  64. package/build/_private/cameraRequestPayload.js +7 -0
  65. package/build/_private/cameraRequestPayload.js.map +1 -1
  66. package/build/_private/photoRequestPayload.d.ts.map +1 -1
  67. package/build/_private/photoRequestPayload.js +10 -0
  68. package/build/_private/photoRequestPayload.js.map +1 -1
  69. package/build/_private/sdkOtaManifest.d.ts +20 -0
  70. package/build/_private/sdkOtaManifest.d.ts.map +1 -0
  71. package/build/_private/sdkOtaManifest.js +28 -0
  72. package/build/_private/sdkOtaManifest.js.map +1 -0
  73. package/build/index.d.ts +1 -1
  74. package/build/index.d.ts.map +1 -1
  75. package/build/index.js +11 -1
  76. package/build/index.js.map +1 -1
  77. package/expo-module.config.json +1 -0
  78. package/ios/BluetoothSdkModule.swift +113 -2
  79. package/ios/Source/BluetoothSdkDefaults.swift +2 -1
  80. package/ios/Source/Bridge.swift +87 -21
  81. package/ios/Source/DeviceManager.swift +118 -20
  82. package/ios/Source/DeviceStore.swift +4 -0
  83. package/ios/Source/MentraBluetoothSDK.swift +326 -33
  84. package/ios/Source/OtaManifest.swift +47 -4
  85. package/ios/Source/audio/PcmStreamPlayer.swift +410 -0
  86. package/ios/Source/camera/CameraModels.swift +26 -6
  87. package/ios/Source/internal/BleTraceLogger.swift +1 -1
  88. package/ios/Source/internal/BluetoothSdkAnalytics.swift +39 -0
  89. package/ios/Source/sgcs/Ar99.swift +2451 -0
  90. package/ios/Source/sgcs/G1.swift +1 -1
  91. package/ios/Source/sgcs/G2.swift +19 -7
  92. package/ios/Source/sgcs/Mach1.swift +1 -1
  93. package/ios/Source/sgcs/MentraLive.swift +478 -61
  94. package/ios/Source/sgcs/MentraNex.swift +1 -1
  95. package/ios/Source/sgcs/Nimo.swift +1 -1
  96. package/ios/Source/sgcs/SGCManager.swift +13 -2
  97. package/ios/Source/sgcs/Simulated.swift +1 -1
  98. package/ios/Source/sgcs/ar99/ota/Ar99OtaManager.swift +637 -0
  99. package/ios/Source/status/WifiHotspotStatus.swift +21 -2
  100. package/ios/Source/streaming/StreamModels.swift +68 -2
  101. package/ios/Source/tts/TTSTools.swift +1 -1
  102. package/ios/Source/types/DeviceModels.swift +13 -0
  103. package/ios/Source/utils/BleJsonCompact.swift +8 -0
  104. package/ios/Source/utils/Constants.swift +2 -0
  105. package/ios/Source/utils/PhoneAudioMonitor.swift +4 -0
  106. package/package.json +1 -2
  107. package/src/BluetoothSdk.types.ts +106 -24
  108. package/src/_internal.ts +3 -0
  109. package/src/_private/BluetoothSdkModule.ts +83 -18
  110. package/src/_private/MentraLocalNetworkModule.ts +50 -0
  111. package/src/_private/cameraRequestPayload.ts +9 -2
  112. package/src/_private/photoRequestPayload.ts +13 -4
  113. package/src/_private/sdkOtaManifest.ts +31 -0
  114. package/src/index.ts +13 -1
@@ -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
@@ -63,10 +68,13 @@ class MentraBluetoothSdk private constructor(
63
68
  }
64
69
 
65
70
  companion object {
66
- private val DEFAULT_DEVICE_KEYS = setOf("default_wearable", "device_name", "device_address")
71
+ private val DEFAULT_DEVICE_KEYS = setOf("default_wearable", "device_name", "device_address", "project_name")
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
 
@@ -221,6 +257,7 @@ class MentraBluetoothSdk private constructor(
221
257
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "default_wearable", device.model.deviceType)
222
258
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "device_name", device.name)
223
259
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "device_address", device.address ?: "")
260
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "project_name", device.projectName ?: "")
224
261
  } finally {
225
262
  suppressDefaultDeviceEvents = false
226
263
  }
@@ -233,6 +270,7 @@ class MentraBluetoothSdk private constructor(
233
270
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "default_wearable", "")
234
271
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "device_name", "")
235
272
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "device_address", "")
273
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "project_name", "")
236
274
  } finally {
237
275
  suppressDefaultDeviceEvents = false
238
276
  }
@@ -448,14 +486,14 @@ class MentraBluetoothSdk private constructor(
448
486
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "screen_disabled", disabled)
449
487
  }
450
488
 
451
- fun setGalleryModeEnabled(enabled: Boolean): SettingsAckEvent =
489
+ suspend fun setGalleryModeEnabled(enabled: Boolean): SettingsAckEvent =
452
490
  performSettingsCommand(
453
491
  setting = "gallery_mode",
454
492
  updateStore = { _ -> DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "gallery_mode", enabled) },
455
493
  send = { requestId -> deviceManager.sendGalleryMode(requestId, enabled) },
456
494
  )
457
495
 
458
- private fun performSettingsCommand(
496
+ private suspend fun performSettingsCommand(
459
497
  setting: String,
460
498
  updateStore: (SettingsAckEvent) -> Unit,
461
499
  send: (String) -> Unit,
@@ -477,19 +515,24 @@ class MentraBluetoothSdk private constructor(
477
515
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "voice_activity_detection_enabled", enabled)
478
516
  }
479
517
 
518
+ fun setLoudnessGateEnabled(enabled: Boolean) {
519
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "loudness_gate_enabled", enabled)
520
+ }
521
+
480
522
  @Deprecated(
481
523
  message =
482
524
  "Sticky action-button photo presets are deprecated. Prefer per-request " +
483
- "requestPhoto(...) options (e.g. mode=TEXT for AE ÷3, or explicit per-shot fields). " +
525
+ "requestPhoto(...) options (e.g. mode=TEXT for text sensor size/crop, or explicit per-shot fields). " +
484
526
  "This method still works but will be removed in a future release.",
485
527
  )
486
- fun setPhotoCaptureDefaults(settings: PhotoCaptureDefaults): SettingsAckEvent =
528
+ suspend fun setPhotoCaptureDefaults(settings: PhotoCaptureDefaults): SettingsAckEvent =
487
529
  performSettingsCommand(
488
530
  setting = "button_photo",
489
531
  updateStore = { _ ->
490
532
  if (settings.resetCaptureTuning) {
491
533
  // Clear all stored scan-tuning keys from phone cache
492
534
  listOf(
535
+ "button_photo_zsl_mfnr",
493
536
  "button_photo_mfnr",
494
537
  "button_photo_zsl",
495
538
  "button_photo_noise_reduction",
@@ -536,7 +579,7 @@ class MentraBluetoothSdk private constructor(
536
579
  send = { requestId -> deviceManager.sendButtonPhotoSettings(requestId, settings) },
537
580
  )
538
581
 
539
- fun setVideoRecordingDefaults(defaults: VideoRecordingDefaults): SettingsAckEvent =
582
+ suspend fun setVideoRecordingDefaults(defaults: VideoRecordingDefaults): SettingsAckEvent =
540
583
  performSettingsCommand(
541
584
  setting = "button_video_recording",
542
585
  updateStore = { _ ->
@@ -555,7 +598,7 @@ class MentraBluetoothSdk private constructor(
555
598
  },
556
599
  )
557
600
 
558
- fun setMaxVideoRecordingDuration(minutes: Int): SettingsAckEvent =
601
+ suspend fun setMaxVideoRecordingDuration(minutes: Int): SettingsAckEvent =
559
602
  performSettingsCommand(
560
603
  setting = "button_max_recording_time",
561
604
  updateStore = { _ ->
@@ -564,7 +607,7 @@ class MentraBluetoothSdk private constructor(
564
607
  send = { requestId -> deviceManager.sendButtonMaxRecordingTime(requestId, minutes) },
565
608
  )
566
609
 
567
- fun setCameraFov(fov: CameraFov): CameraFovResult {
610
+ suspend fun setCameraFov(fov: CameraFov): CameraFovResult {
568
611
  val ack = performSettingsCommand(
569
612
  setting = "camera_fov",
570
613
  updateStore = { _ -> },
@@ -579,6 +622,44 @@ class MentraBluetoothSdk private constructor(
579
622
  return result
580
623
  }
581
624
 
625
+ /**
626
+ * Compatibility path for ASG clients that support only the older one-way FOV command.
627
+ * It resolves once the setting is queued to BLE because those clients never acknowledge it.
628
+ */
629
+ fun setLegacyCameraFov(fov: CameraFov): CameraFovResult {
630
+ deviceManager.sendLegacyCameraFovSetting(fov.fov, fov.roiPosition.value)
631
+ return CameraFovResult("legacy", fov.fov, fov.roiPosition, System.currentTimeMillis())
632
+ }
633
+
634
+ /** Restores the persistent base FOV through the acknowledgement-free compatibility path. */
635
+ fun restoreLegacyCameraFov() {
636
+ deviceManager.restoreLegacyCameraFovSetting()
637
+ }
638
+
639
+ suspend fun setCameraFovOverride(leaseId: String, fov: CameraFov, ttlMs: Int): CameraFovResult {
640
+ val ack = performSettingsCommand(
641
+ setting = "camera_fov_override",
642
+ updateStore = { _ -> },
643
+ send = { requestId ->
644
+ deviceManager.sendCameraFovOverride(
645
+ requestId,
646
+ leaseId,
647
+ fov.fov,
648
+ fov.roiPosition.value,
649
+ ttlMs,
650
+ )
651
+ },
652
+ )
653
+ return CameraFovResult.fromAck(ack, fov)
654
+ }
655
+
656
+ suspend fun releaseCameraFovOverride(leaseId: String): SettingsAckEvent =
657
+ performSettingsCommand(
658
+ setting = "camera_fov_override",
659
+ updateStore = { _ -> },
660
+ send = { requestId -> deviceManager.releaseCameraFovOverride(requestId, leaseId) },
661
+ )
662
+
582
663
  /**
583
664
  * Configure camera HAL tuning on Mentra Live glasses.
584
665
  *
@@ -589,7 +670,7 @@ class MentraBluetoothSdk private constructor(
589
670
  * @param anrOn {@code true} = ANR enabled, {@code false} = ANR disabled (pixsmart param)
590
671
  * @param gainOn {@code true} = stock gain params, {@code false} = pixsmart gain-off params
591
672
  */
592
- fun setCameraTuningConfig(anrOn: Boolean, gainOn: Boolean): SettingsAckEvent =
673
+ suspend fun setCameraTuningConfig(anrOn: Boolean, gainOn: Boolean): SettingsAckEvent =
593
674
  performSettingsCommand(
594
675
  setting = "camera_tuning",
595
676
  updateStore = { _ -> },
@@ -643,12 +724,12 @@ class MentraBluetoothSdk private constructor(
643
724
  return GlassesMediaVolumeSetResult.fromMap(deviceManager.setGlassesMediaVolumeBlocking(level))
644
725
  }
645
726
 
646
- fun requestWifiScan(): List<WifiScanResult> {
727
+ suspend fun requestWifiScan(): List<WifiScanResult> {
647
728
  val pending = PendingResponse<List<WifiScanResult>>("WiFi scan request")
648
- val request = PendingWifiScan(pending)
729
+ val request = PendingWifiScan(pending, scanId = "scan-${UUID.randomUUID()}", waiters = 1)
649
730
  val existing =
650
731
  synchronized(oneShotLock) {
651
- pendingWifiScan ?: run {
732
+ pendingWifiScan?.also { it.waiters++ } ?: run {
652
733
  pendingWifiScan = request
653
734
  null
654
735
  }
@@ -656,13 +737,26 @@ class MentraBluetoothSdk private constructor(
656
737
  if (existing != null) {
657
738
  // Join the in-flight scan instead of failing with request_in_flight;
658
739
  // the scan screen auto-starts a scan on mount and can be pushed twice.
659
- return existing.pending.await(WIFI_SCAN_TIMEOUT_MS)
740
+ try {
741
+ return existing.pending.await(WIFI_SCAN_TIMEOUT_MS)
742
+ } finally {
743
+ releaseWifiScanWaiter(existing)
744
+ }
660
745
  }
661
746
  try {
662
- deviceManager.requestWifiScan()
747
+ // Claim the scan-results store for this scan before the command goes
748
+ // out, so a delayed chunk from an older, abandoned scan can no longer
749
+ // mutate it.
750
+ Bridge.claimWifiScanResults(request.scanId)
751
+ deviceManager.requestWifiScan(request.scanId)
663
752
  // The glasses wait up to 15s for scan-results broadcasts before sending
664
753
  // scan_complete, so give them longer than that before falling back.
665
754
  return pending.await(WIFI_SCAN_TIMEOUT_MS)
755
+ } catch (cancellation: CancellationException) {
756
+ // Only this caller is going away; the glasses scan keeps running and
757
+ // other coroutines may have joined the same pending entry. Leave the
758
+ // shared deferred alone so scan_complete can still resolve for them.
759
+ throw cancellation
666
760
  } catch (error: Throwable) {
667
761
  if (error is BluetoothSdkException && error.code == "request_timeout") {
668
762
  val fallbackResults = synchronized(oneShotLock) { request.latestResults }
@@ -677,15 +771,23 @@ class MentraBluetoothSdk private constructor(
677
771
  pending.reject(error)
678
772
  throw error
679
773
  } finally {
680
- synchronized(oneShotLock) {
681
- if (pendingWifiScan?.pending === pending) {
682
- pendingWifiScan = null
683
- }
774
+ releaseWifiScanWaiter(request)
775
+ }
776
+ }
777
+
778
+ /** Last waiter (starter or joiner) out clears the shared scan entry so a
779
+ * cancelled starter can't strand it, and an abandoned scan can't wedge
780
+ * future ones. handleWifiScanResultsForRequests may clear it first. */
781
+ private fun releaseWifiScanWaiter(request: PendingWifiScan) {
782
+ synchronized(oneShotLock) {
783
+ request.waiters--
784
+ if (request.waiters <= 0 && pendingWifiScan === request) {
785
+ pendingWifiScan = null
684
786
  }
685
787
  }
686
788
  }
687
789
 
688
- fun sendWifiCredentials(ssid: String, password: String): WifiStatusEvent {
790
+ suspend fun sendWifiCredentials(ssid: String, password: String): WifiStatusEvent {
689
791
  val pending = PendingResponse<WifiStatusEvent>("WiFi connect request")
690
792
  synchronized(oneShotLock) {
691
793
  if (pendingWifiStatus != null) {
@@ -708,7 +810,7 @@ class MentraBluetoothSdk private constructor(
708
810
  }
709
811
  }
710
812
 
711
- fun forgetWifiNetwork(ssid: String): WifiStatusEvent {
813
+ suspend fun forgetWifiNetwork(ssid: String): WifiStatusEvent {
712
814
  val pending = PendingResponse<WifiStatusEvent>("WiFi forget request")
713
815
  synchronized(oneShotLock) {
714
816
  if (pendingWifiStatus != null) {
@@ -731,7 +833,7 @@ class MentraBluetoothSdk private constructor(
731
833
  }
732
834
  }
733
835
 
734
- fun setHotspotState(enabled: Boolean): HotspotStatusEvent {
836
+ suspend fun setHotspotState(enabled: Boolean): HotspotStatusEvent {
735
837
  val pending = PendingResponse<HotspotStatusEvent>("hotspot ${if (enabled) "enable" else "disable"} request")
736
838
  synchronized(oneShotLock) {
737
839
  if (pendingHotspotStatus != null) {
@@ -758,7 +860,7 @@ class MentraBluetoothSdk private constructor(
758
860
  deviceManager.setSystemTime(timestampMs)
759
861
  }
760
862
 
761
- fun requestPhoto(request: PhotoRequest): PhotoResponseEvent {
863
+ suspend fun requestPhoto(request: PhotoRequest): PhotoResponseEvent {
762
864
  val routedRequest =
763
865
  nonBlankRequestId(request.requestId)
764
866
  ?.let { if (it == request.requestId) request else request.copy(requestId = it) }
@@ -770,17 +872,20 @@ class MentraBluetoothSdk private constructor(
770
872
  pendingPhotoRequests[routedRequest.requestId] = pending
771
873
  try {
772
874
  deviceManager.requestPhoto(routedRequest)
773
- return pending.await()
875
+ return pending.await(PHOTO_REQUEST_TIMEOUT_MS)
774
876
  } finally {
775
877
  pendingPhotoRequests.remove(routedRequest.requestId, pending)
776
878
  }
777
879
  }
778
880
 
779
- fun warmUpCamera(
881
+ suspend fun warmUpCamera(
780
882
  requestId: String? = null,
781
883
  size: PhotoSize,
884
+ mode: PhotoMode = PhotoMode.PHOTO,
782
885
  exposureTimeNs: Long?,
783
886
  durationMs: Int,
887
+ zsl: Boolean? = null,
888
+ mfnr: Boolean? = null,
784
889
  ): CameraStatusEvent {
785
890
  val effectiveRequestId = nonBlankRequestId(requestId) ?: generatedCameraRequestId("warm")
786
891
  val pending = PendingResponse<CameraStatusEvent>("camera warm up $effectiveRequestId")
@@ -793,14 +898,20 @@ class MentraBluetoothSdk private constructor(
793
898
  )
794
899
  }
795
900
  try {
796
- deviceManager.warmUpCamera(effectiveRequestId, size, exposureTimeNs, durationMs)
901
+ deviceManager.warmUpCamera(effectiveRequestId, size, mode, exposureTimeNs, durationMs, zsl, mfnr)
797
902
  return pending.await()
798
903
  } finally {
799
904
  pendingCameraStatusRequests.remove(effectiveRequestId, pending)
800
905
  }
801
906
  }
802
907
 
803
- fun queryGalleryStatus(): GalleryStatusEvent {
908
+ fun stopCameraWarmUp(requestId: String) {
909
+ val effectiveRequestId = nonBlankRequestId(requestId)
910
+ ?: throw BluetoothSdkException("invalid_request", "A warm-up request ID is required.")
911
+ deviceManager.stopCameraWarmUp(effectiveRequestId)
912
+ }
913
+
914
+ suspend fun queryGalleryStatus(): GalleryStatusEvent {
804
915
  val pending = PendingResponse<GalleryStatusEvent>("gallery status query")
805
916
  synchronized(oneShotLock) {
806
917
  if (pendingGalleryStatus != null) {
@@ -823,13 +934,13 @@ class MentraBluetoothSdk private constructor(
823
934
  }
824
935
  }
825
936
 
826
- fun startStream(request: StreamRequest): StreamStatusEvent =
937
+ suspend fun startStream(request: StreamRequest): StreamStatusEvent =
827
938
  startStream(request, startSdkKeepAlive = true)
828
939
 
829
- internal fun startExternallyManagedStream(request: StreamRequest): StreamStatusEvent =
940
+ internal suspend fun startExternallyManagedStream(request: StreamRequest): StreamStatusEvent =
830
941
  startStream(request, startSdkKeepAlive = false)
831
942
 
832
- private fun startStream(
943
+ private suspend fun startStream(
833
944
  request: StreamRequest,
834
945
  startSdkKeepAlive: Boolean,
835
946
  ): StreamStatusEvent {
@@ -838,17 +949,25 @@ class MentraBluetoothSdk private constructor(
838
949
  ?: "sdk-${UUID.randomUUID()}"
839
950
  message["streamId"] = streamId
840
951
  val pending = PendingResponse<StreamStatusEvent>("start stream $streamId")
841
- pendingStreamStarts[streamId] = pending
842
- stopStreamKeepAliveMonitor()
952
+ var start: PendingStreamStart? = null
843
953
  try {
844
- deviceManager.startStream(message)
954
+ // seq assignment and the BLE hand-off must be one critical section:
955
+ // rejectPreemptedStreamStarts only works if seq order equals the
956
+ // order the commands reach the glasses' FIFO.
957
+ synchronized(streamStartOrderLock) {
958
+ val registered = PendingStreamStart(streamStartSeq.incrementAndGet(), pending)
959
+ pendingStreamStarts[streamId] = registered
960
+ start = registered
961
+ stopStreamKeepAliveMonitor()
962
+ deviceManager.startStream(message)
963
+ }
845
964
  val event = pending.await(STREAM_START_TIMEOUT_MS)
846
965
  if (startSdkKeepAlive) {
847
966
  startStreamKeepAliveMonitor(streamId, DEFAULT_STREAM_KEEP_ALIVE_INTERVAL_SECONDS)
848
967
  }
849
968
  return event
850
969
  } finally {
851
- pendingStreamStarts.remove(streamId, pending)
970
+ start?.let { pendingStreamStarts.remove(streamId, it) }
852
971
  }
853
972
  }
854
973
 
@@ -856,7 +975,7 @@ class MentraBluetoothSdk private constructor(
856
975
  deviceManager.keepStreamAlive(request.toMap().toMutableMap())
857
976
  }
858
977
 
859
- fun rgbLedControl(request: RgbLedRequest): RgbLedControlResponseEvent {
978
+ suspend fun rgbLedControl(request: RgbLedRequest): RgbLedControlResponseEvent {
860
979
  val pending = PendingResponse<RgbLedControlResponseEvent>("RGB LED command ${request.requestId}")
861
980
  pendingRgbLedRequests[request.requestId] = pending
862
981
  try {
@@ -875,23 +994,30 @@ class MentraBluetoothSdk private constructor(
875
994
  }
876
995
  }
877
996
 
878
- fun stopStream(): StreamStatusEvent {
997
+ suspend fun stopStream(): StreamStatusEvent {
879
998
  val pending = PendingResponse<StreamStatusEvent>("stop stream")
880
999
  val targetStreamId = synchronized(streamKeepAliveLock) {
881
1000
  activeStreamKeepAlive?.streamId
882
1001
  }
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
1002
  try {
894
- deviceManager.stopStream()
1003
+ // The seq draw and the BLE hand-off share startStream's ordering
1004
+ // critical section: the stop takes its own slot in the send order,
1005
+ // so its ack can separate the pending starts the glasses consumed
1006
+ // before it (lower seq) from starts sent after it (higher seq).
1007
+ synchronized(streamStartOrderLock) {
1008
+ synchronized(oneShotLock) {
1009
+ if (pendingStreamStop != null) {
1010
+ throw BluetoothSdkException(
1011
+ "request_in_flight",
1012
+ "A stream stop command is already waiting for a glasses response.",
1013
+ )
1014
+ }
1015
+ pendingStreamStop =
1016
+ PendingStreamStop(targetStreamId, streamStartSeq.incrementAndGet(), pending)
1017
+ }
1018
+ stopStreamKeepAliveMonitor()
1019
+ deviceManager.stopStream()
1020
+ }
895
1021
  return pending.await(STREAM_STOP_TIMEOUT_MS)
896
1022
  } finally {
897
1023
  synchronized(oneShotLock) {
@@ -902,7 +1028,7 @@ class MentraBluetoothSdk private constructor(
902
1028
  }
903
1029
  }
904
1030
 
905
- fun startVideoRecording(request: VideoRecordingRequest): VideoRecordingStatusEvent {
1031
+ suspend fun startVideoRecording(request: VideoRecordingRequest): VideoRecordingStatusEvent {
906
1032
  require(request.requestId.isNotBlank()) { "requestId is required to start video recording." }
907
1033
  requireGlassesConnected("start video recording")
908
1034
  val pending = PendingResponse<VideoRecordingStatusEvent>("start video recording")
@@ -930,7 +1056,7 @@ class MentraBluetoothSdk private constructor(
930
1056
  }
931
1057
 
932
1058
  @JvmOverloads
933
- fun stopVideoRecording(
1059
+ suspend fun stopVideoRecording(
934
1060
  requestId: String,
935
1061
  webhookUrl: String? = null,
936
1062
  authToken: String? = null,
@@ -961,7 +1087,7 @@ class MentraBluetoothSdk private constructor(
961
1087
  }
962
1088
  }
963
1089
 
964
- fun requestVersionInfo(): VersionInfoResult {
1090
+ suspend fun requestVersionInfo(): VersionInfoResult {
965
1091
  val pending = PendingResponse<VersionInfoResult>("version info request")
966
1092
  synchronized(oneShotLock) {
967
1093
  if (pendingVersionInfo != null) {
@@ -991,7 +1117,7 @@ class MentraBluetoothSdk private constructor(
991
1117
  internal fun getOtaVersionUrl(): String = configuredOtaVersionUrl ?: OtaManifestDefaults.defaultOtaVersionUrl()
992
1118
 
993
1119
  /** Fetch the configured OTA manifest and return whether any ASG/BES/MTK update is available. */
994
- fun checkForOtaUpdate(): Boolean {
1120
+ suspend fun checkForOtaUpdate(): Boolean {
995
1121
  val status = getFreshGlassesStatus()
996
1122
  if (!status.connected) {
997
1123
  throw BluetoothSdkException(
@@ -1018,12 +1144,12 @@ class MentraBluetoothSdk private constructor(
1018
1144
  }
1019
1145
 
1020
1146
  /** Ask connected Mentra Live glasses to report the current OTA install/session status. */
1021
- private fun queryOtaStatus(): OtaQueryResult =
1147
+ private suspend fun queryOtaStatus(): OtaQueryResult =
1022
1148
  performOtaQuery("OTA status query") {
1023
1149
  deviceManager.sendOtaQueryStatus()
1024
1150
  }
1025
1151
 
1026
- private fun performOtaQuery(
1152
+ private suspend fun performOtaQuery(
1027
1153
  operation: String,
1028
1154
  sendRequest: () -> Unit,
1029
1155
  ): OtaQueryResult {
@@ -1050,12 +1176,12 @@ class MentraBluetoothSdk private constructor(
1050
1176
  }
1051
1177
 
1052
1178
  /** Start the OTA flow after your app has presented the available update to the user. */
1053
- fun startOtaUpdate(): OtaStartAckEvent {
1179
+ suspend fun startOtaUpdate(): OtaStartAckEvent {
1054
1180
  val otaVersionUrl = resolveOtaVersionUrl(getFreshGlassesStatus())
1055
1181
  return startOtaUpdate(otaVersionUrl)
1056
1182
  }
1057
1183
 
1058
- private fun startOtaCommand(otaVersionUrl: String): OtaStartAckEvent {
1184
+ private suspend fun startOtaCommand(otaVersionUrl: String): OtaStartAckEvent {
1059
1185
  val pending = PendingResponse<OtaStartAckEvent>("OTA start command")
1060
1186
  synchronized(oneShotLock) {
1061
1187
  if (pendingOtaStart != null) {
@@ -1078,12 +1204,26 @@ class MentraBluetoothSdk private constructor(
1078
1204
  }
1079
1205
  }
1080
1206
 
1081
- internal fun startOtaUpdate(otaVersionUrl: String): OtaStartAckEvent =
1207
+ internal suspend fun startOtaUpdate(otaVersionUrl: String): OtaStartAckEvent =
1082
1208
  startOtaCommand(otaVersionUrl)
1083
1209
 
1084
- internal fun sendOtaQueryStatus(): OtaQueryResult = queryOtaStatus()
1210
+ internal suspend fun sendOtaQueryStatus(): OtaQueryResult = queryOtaStatus()
1211
+
1212
+ fun startAr99OtaFromFile(path: String): Boolean {
1213
+ requireGlassesConnected("start AR99 OTA")
1214
+ return deviceManager.startAr99OtaFromFile(path)
1215
+ }
1216
+
1217
+ fun cancelAr99Ota() {
1218
+ deviceManager.cancelAr99Ota()
1219
+ }
1220
+
1221
+ fun sendAr99FactoryReset() {
1222
+ requireGlassesConnected("factory reset AR99")
1223
+ deviceManager.sendAr99FactoryReset()
1224
+ }
1085
1225
 
1086
- private fun getFreshGlassesStatus(): GlassesStatus {
1226
+ private suspend fun getFreshGlassesStatus(): GlassesStatus {
1087
1227
  val status = getRawGlassesStatus()
1088
1228
  if (!status.connected || status.buildNumber.isNotBlank()) {
1089
1229
  return status
@@ -1101,12 +1241,17 @@ class MentraBluetoothSdk private constructor(
1101
1241
  otaVersionUrl = versionInfo.otaVersionUrl.ifBlank { status.otaVersionUrl },
1102
1242
  appVersion = versionInfo.appVersion.ifBlank { status.appVersion },
1103
1243
  )
1244
+ } catch (cancellation: CancellationException) {
1245
+ // Never swallow cancellation into the stale-status fallback: callers
1246
+ // like startOtaUpdate() would otherwise keep running side effects
1247
+ // (sendOtaStart) on behalf of an already-cancelled coroutine.
1248
+ throw cancellation
1104
1249
  } catch (_: Throwable) {
1105
1250
  status
1106
1251
  }
1107
1252
  }
1108
1253
 
1109
- private fun waitForOtaManifestStatus(
1254
+ private suspend fun waitForOtaManifestStatus(
1110
1255
  initialStatus: GlassesStatus,
1111
1256
  manifest: org.json.JSONObject,
1112
1257
  ): GlassesStatus {
@@ -1132,7 +1277,7 @@ class MentraBluetoothSdk private constructor(
1132
1277
  return status
1133
1278
  }
1134
1279
 
1135
- private fun waitForGlassesStatus(
1280
+ private suspend fun waitForGlassesStatus(
1136
1281
  initialStatus: GlassesStatus,
1137
1282
  timeoutMs: Long,
1138
1283
  isReady: (GlassesStatus) -> Boolean,
@@ -1144,7 +1289,7 @@ class MentraBluetoothSdk private constructor(
1144
1289
  if (isReady(status)) return status
1145
1290
  val remainingMs = deadline - System.currentTimeMillis()
1146
1291
  if (remainingMs <= 0L) break
1147
- Thread.sleep(minOf(OTA_VERSION_POLL_MS, remainingMs))
1292
+ delay(minOf(OTA_VERSION_POLL_MS, remainingMs))
1148
1293
  }
1149
1294
  return getRawGlassesStatus()
1150
1295
  }
@@ -1223,10 +1368,12 @@ class MentraBluetoothSdk private constructor(
1223
1368
  val name = core["device_name"] as? String ?: return null
1224
1369
  if (model.isBlank() || name.isBlank()) return null
1225
1370
  val address = (core["device_address"] as? String)?.takeIf { it.isNotBlank() }
1371
+ val projectName = (core["project_name"] as? String)?.takeIf { it.isNotBlank() }
1226
1372
  return Device(
1227
1373
  model = DeviceModel.fromDeviceType(model),
1228
1374
  name = name,
1229
1375
  address = address,
1376
+ projectName = projectName,
1230
1377
  )
1231
1378
  }
1232
1379
 
@@ -1344,9 +1491,15 @@ class MentraBluetoothSdk private constructor(
1344
1491
  data.containsKey("scanComplete") || data.containsKey("scan_complete")
1345
1492
  val scanComplete =
1346
1493
  (data["scanComplete"] as? Boolean) ?: (data["scan_complete"] as? Boolean) ?: false
1347
- updateWifiScanLatestResults(networks)
1348
- if (scanComplete || !hasCompletionFlag) {
1349
- handleWifiScanResultsForRequests(networks)
1494
+ val scanId = (data["scanId"] as? String)?.ifEmpty { null }
1495
+ if (scanId != null) {
1496
+ handleCorrelatedWifiScanChunk(scanId, networks, scanComplete)
1497
+ } else {
1498
+ // Old firmware: no correlation id, keep the pre-scanId heuristics.
1499
+ updateWifiScanLatestResults(networks)
1500
+ if (scanComplete || !hasCompletionFlag) {
1501
+ handleWifiScanResultsForRequests(networks)
1502
+ }
1350
1503
  }
1351
1504
  dispatchToListeners { it.onRawEvent(eventName, data) }
1352
1505
  }
@@ -1512,17 +1665,54 @@ class MentraBluetoothSdk private constructor(
1512
1665
  private fun handleStreamStatusForRequests(event: StreamStatusEvent) {
1513
1666
  val startMatch = matchingStreamStart(event)
1514
1667
  if (startMatch != null) {
1515
- val (streamId, pending) = startMatch
1668
+ val (streamId, start) = startMatch
1669
+ // Preemption may only be inferred from states the glasses emit AFTER
1670
+ // start_stream has run stopAllServices() (see PREEMPTION_PROVEN_STATES).
1671
+ // ERROR must not fire it: a command-level preflight rejection (missing
1672
+ // URL, low battery, no WiFi) carries this start's streamId but is sent
1673
+ // BEFORE stopAllServices, so older starts were not preempted — and
1674
+ // rejecting one that already resolved and started its keep-alive
1675
+ // monitor would strand a live stream without keep-alives. STOPPED
1676
+ // must not fire it either: it is the stop-ack shape and proves
1677
+ // nothing about the start path having run.
1678
+ if (!event.streamId.isNullOrBlank() && event.state in PREEMPTION_PROVEN_STATES) {
1679
+ rejectPreemptedStreamStarts(start.seq)
1680
+ }
1516
1681
  when (event.state) {
1517
- StreamState.STREAMING -> {
1518
- pendingStreamStarts.remove(streamId, pending)
1519
- pending.resolve(event)
1682
+ // `reconnected` also proves the stream is live: when an async start
1683
+ // failure triggers the glasses' publisher retry, a successful retry
1684
+ // reports `reconnected` instead of `streaming`.
1685
+ StreamState.STREAMING,
1686
+ StreamState.RECONNECTED -> {
1687
+ pendingStreamStarts.remove(streamId, start)
1688
+ start.pending.resolve(event)
1520
1689
  }
1521
- StreamState.ERROR,
1522
1690
  StreamState.RECONNECT_FAILED,
1523
1691
  StreamState.STOPPED -> {
1524
- pendingStreamStarts.remove(streamId, pending)
1525
- pending.reject(streamStatusException(event, "stream_start_failed"))
1692
+ pendingStreamStarts.remove(streamId, start)
1693
+ start.pending.reject(
1694
+ streamStatusException(start.lastError ?: event, "stream_start_failed")
1695
+ )
1696
+ }
1697
+ StreamState.ERROR -> {
1698
+ if (!event.streamId.isNullOrBlank() && event.willRetry == true) {
1699
+ // The glasses flag errors their publisher will retry with
1700
+ // `willRetry` (emitting side lands in PR #3488), so keep the
1701
+ // start pending for the retry's verdict — `reconnected` or
1702
+ // `streaming` on success, `reconnect_failed` or the
1703
+ // streamId-less `stopped` wind-down (which reports these
1704
+ // stashed details) on failure.
1705
+ start.lastError = event
1706
+ } else {
1707
+ // An id-less error is the glasses' command-level rejection
1708
+ // (missing URL, low battery, no WiFi) emitted before any
1709
+ // publisher starts; an id-carrying error without `willRetry`
1710
+ // is terminal (`camera_busy` emits an error and nothing else).
1711
+ // Old firmware never sends `willRetry`, so its errors always
1712
+ // fail fast here — the shipped pre-#3487 Android behavior.
1713
+ pendingStreamStarts.remove(streamId, start)
1714
+ start.pending.reject(streamStatusException(event, "stream_start_failed"))
1715
+ }
1526
1716
  }
1527
1717
  else -> Unit
1528
1718
  }
@@ -1537,6 +1727,7 @@ class MentraBluetoothSdk private constructor(
1537
1727
  pendingStreamStop = null
1538
1728
  }
1539
1729
  }
1730
+ rejectStreamStartsSupersededByStop(stop.seq)
1540
1731
  stop.pending.resolve(stoppedStreamEvent(event, stop.streamId))
1541
1732
  }
1542
1733
  event.state == StreamState.ERROR || event.state == StreamState.RECONNECT_FAILED -> {
@@ -1552,22 +1743,79 @@ class MentraBluetoothSdk private constructor(
1552
1743
  }
1553
1744
  }
1554
1745
 
1555
- private fun matchingStreamStart(event: StreamStatusEvent): Pair<String, PendingResponse<StreamStatusEvent>>? {
1746
+ // The glasses process BLE commands FIFO and every start_stream begins by
1747
+ // stopping whatever runs, so an id-carrying status for start X in one of
1748
+ // these states proves every lower-seq start has already been preempted.
1749
+ // Their only verdict on current firmware is a streamId-less stopped, which
1750
+ // the id-less heuristic deliberately ignores — without this they would run
1751
+ // out the 30s timeout instead of failing fast.
1752
+ private fun rejectPreemptedStreamStarts(winnerSeq: Long) {
1753
+ for ((streamId, start) in pendingStreamStarts) {
1754
+ if (start.seq < winnerSeq && pendingStreamStarts.remove(streamId, start)) {
1755
+ start.pending.reject(
1756
+ BluetoothSdkException(
1757
+ "stream_preempted",
1758
+ "start stream $streamId was preempted by a newer start_stream; " +
1759
+ "the glasses run a single stream and the last request wins.",
1760
+ )
1761
+ )
1762
+ }
1763
+ }
1764
+ }
1765
+
1766
+ // The glasses process BLE commands FIFO, so this stop's ack also settles
1767
+ // every start sent before it: whatever those starts brought up (or would
1768
+ // have brought up) has been stopped, and no further status will arrive
1769
+ // for them. Without this they would run out the 30s start timeout.
1770
+ // Starts sent after the stop keep waiting for their own statuses.
1771
+ private fun rejectStreamStartsSupersededByStop(stopSeq: Long) {
1772
+ for ((streamId, start) in pendingStreamStarts) {
1773
+ if (start.seq < stopSeq && pendingStreamStarts.remove(streamId, start)) {
1774
+ start.pending.reject(
1775
+ BluetoothSdkException(
1776
+ "stream_stopped",
1777
+ "start stream $streamId was superseded by a stop_stream issued after it.",
1778
+ )
1779
+ )
1780
+ }
1781
+ }
1782
+ }
1783
+
1784
+ private fun matchingStreamStart(event: StreamStatusEvent): Pair<String, PendingStreamStart>? {
1556
1785
  val streamId = event.streamId
1557
1786
  if (!streamId.isNullOrBlank()) {
1558
- val pending = pendingStreamStarts[streamId] ?: return null
1559
- return streamId to pending
1787
+ val start = pendingStreamStarts[streamId] ?: return null
1788
+ return streamId to start
1789
+ }
1790
+ if (event.state == StreamState.ERROR) {
1791
+ // An id-less error is a command-level preflight rejection (missing
1792
+ // URL, low battery, no WiFi) emitted synchronously, while lifecycle
1793
+ // statuses arrive async — so with overlapping starts the wire is
1794
+ // genuinely ambiguous about which start it answers. Attribute it to
1795
+ // the newest pending start: the common case is a later start
1796
+ // failing preflight while an earlier one is already emitting
1797
+ // lifecycle statuses, and at worst this swaps which start carries
1798
+ // the error instead of letting both time out.
1799
+ val entry = pendingStreamStarts.entries.maxByOrNull { it.value.seq } ?: return null
1800
+ return entry.key to entry.value
1560
1801
  }
1561
1802
  if (pendingStreamStarts.size == 1) {
1803
+ // firstOrNull: timeouts and other handler threads mutate the
1804
+ // ConcurrentHashMap concurrently, so the map can empty between the
1805
+ // size check and this read; bail out instead of throwing.
1806
+ val entry = pendingStreamStarts.entries.firstOrNull() ?: return null
1562
1807
  // A streamId-less STOPPED is the glasses' stop-ack for a PREVIOUS
1563
1808
  // stream (their stop ack carries no streamId), not a verdict on the
1564
1809
  // pending start — a start_stream that replaces a running publisher
1565
1810
  // emits exactly this sequence (stopped -> initializing -> streaming).
1566
1811
  // Attributing it here would reject a start that is about to succeed.
1567
- if (event.state == StreamState.STOPPED) {
1812
+ // After a stashed `willRetry` error for the pending start, though,
1813
+ // the stopped is the retrying publisher's wind-down (the stop-ack
1814
+ // always precedes any status for the new start), so it is that
1815
+ // start's verdict.
1816
+ if (event.state == StreamState.STOPPED && entry.value.lastError == null) {
1568
1817
  return null
1569
1818
  }
1570
- val entry = pendingStreamStarts.entries.first()
1571
1819
  return entry.key to entry.value
1572
1820
  }
1573
1821
  return null
@@ -1715,6 +1963,35 @@ class MentraBluetoothSdk private constructor(
1715
1963
  request.pending.resolve(results)
1716
1964
  }
1717
1965
 
1966
+ // scanId-echoing glasses: results for another scan are ignored instead of
1967
+ // resolving the pending request, and matching chunks accumulate until the
1968
+ // glasses flag the scan complete.
1969
+ private fun handleCorrelatedWifiScanChunk(
1970
+ scanId: String,
1971
+ results: List<WifiScanResult>,
1972
+ scanComplete: Boolean,
1973
+ ) {
1974
+ val request: PendingWifiScan
1975
+ val accumulated: List<WifiScanResult>
1976
+ synchronized(oneShotLock) {
1977
+ request = pendingWifiScan?.takeIf { it.scanId == scanId } ?: return
1978
+ for (network in results) {
1979
+ if (request.accumulated.none { it.ssid == network.ssid }) {
1980
+ request.accumulated.add(network)
1981
+ }
1982
+ }
1983
+ accumulated = request.accumulated.toList()
1984
+ if (accumulated.isNotEmpty()) {
1985
+ request.latestResults = accumulated
1986
+ }
1987
+ if (!scanComplete) return
1988
+ if (pendingWifiScan === request) {
1989
+ pendingWifiScan = null
1990
+ }
1991
+ }
1992
+ request.pending.resolve(accumulated)
1993
+ }
1994
+
1718
1995
  private fun updateWifiScanLatestResults(results: List<WifiScanResult>) {
1719
1996
  if (results.isEmpty()) return
1720
1997
  synchronized(oneShotLock) {
@@ -1724,6 +2001,26 @@ class MentraBluetoothSdk private constructor(
1724
2001
 
1725
2002
  private fun handleWifiStatusForRequests(event: WifiStatusEvent) {
1726
2003
  val request = synchronized(oneShotLock) { pendingWifiStatus } ?: return
2004
+ // A wifi_status carrying the explicit error field is the glasses' failure
2005
+ // verdict for the in-flight connect: reject now instead of running out the
2006
+ // request timeout. Only the error field counts as failure — the glasses'
2007
+ // connect sequence emits a debounced bare connected=false ~1-2s after
2008
+ // credentials while association is still in progress, and rejecting on that
2009
+ // would kill every connect attempt early.
2010
+ if (request.operation == WifiStatusOperation.CONNECT && event.error != null) {
2011
+ synchronized(oneShotLock) {
2012
+ if (pendingWifiStatus === request) {
2013
+ pendingWifiStatus = null
2014
+ }
2015
+ }
2016
+ request.pending.reject(
2017
+ BluetoothSdkException(
2018
+ event.error,
2019
+ "Glasses failed to join \"${request.ssid}\": ${event.error}",
2020
+ )
2021
+ )
2022
+ return
2023
+ }
1727
2024
  if (!wifiStatusMatches(event.status, request)) return
1728
2025
  synchronized(oneShotLock) {
1729
2026
  if (pendingWifiStatus === request) {
@@ -1863,3 +2160,7 @@ class MentraBluetoothSdk private constructor(
1863
2160
  }
1864
2161
  }
1865
2162
  }
2163
+
2164
+
2165
+
2166
+