@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.
- package/README.md +5 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +127 -24
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +65 -14
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +46 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +369 -97
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +333 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +3 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +16 -9
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Mach1.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +183 -30
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +4 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/status/WifiHotspotStatus.kt +19 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +15 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/tts/TTSTools.kt +115 -47
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/audio/PcmStreamPlayer.kt +328 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +15 -0
- package/build/BluetoothSdk.types.d.ts +46 -1
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_internal.d.ts +2 -0
- package/build/_internal.d.ts.map +1 -1
- package/build/_internal.js +2 -0
- package/build/_internal.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +23 -2
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +20 -0
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/_private/MentraLocalNetworkModule.d.ts +37 -0
- package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -0
- package/build/_private/MentraLocalNetworkModule.js +3 -0
- package/build/_private/MentraLocalNetworkModule.js.map +1 -0
- package/build/_private/cameraRequestPayload.d.ts.map +1 -1
- package/build/_private/cameraRequestPayload.js +1 -0
- package/build/_private/cameraRequestPayload.js.map +1 -1
- package/build/_private/photoRequestPayload.d.ts.map +1 -1
- package/build/_private/photoRequestPayload.js +1 -0
- package/build/_private/photoRequestPayload.js.map +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +4 -0
- package/build/index.js.map +1 -1
- package/expo-module.config.json +1 -0
- package/ios/BluetoothSdkModule.swift +72 -0
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/Bridge.swift +72 -18
- package/ios/Source/DeviceManager.swift +51 -3
- package/ios/Source/DeviceStore.swift +1 -1
- package/ios/Source/MentraBluetoothSDK.swift +289 -25
- package/ios/Source/audio/PcmStreamPlayer.swift +410 -0
- package/ios/Source/camera/CameraModels.swift +8 -3
- package/ios/Source/sgcs/G1.swift +1 -1
- package/ios/Source/sgcs/G2.swift +19 -7
- package/ios/Source/sgcs/Mach1.swift +1 -1
- package/ios/Source/sgcs/MentraLive.swift +318 -37
- package/ios/Source/sgcs/MentraNex.swift +1 -1
- package/ios/Source/sgcs/Nimo.swift +1 -1
- package/ios/Source/sgcs/SGCManager.swift +5 -2
- package/ios/Source/sgcs/Simulated.swift +1 -1
- package/ios/Source/status/WifiHotspotStatus.swift +21 -2
- package/ios/Source/streaming/StreamModels.swift +9 -0
- package/ios/Source/tts/TTSTools.swift +1 -1
- package/package.json +1 -2
- package/src/BluetoothSdk.types.ts +49 -0
- package/src/_internal.ts +2 -0
- package/src/_private/BluetoothSdkModule.ts +64 -13
- package/src/_private/MentraLocalNetworkModule.ts +50 -0
- package/src/_private/cameraRequestPayload.ts +1 -0
- package/src/_private/photoRequestPayload.ts +2 -3
- 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,
|
|
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
|
|
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
|
-
|
|
150
|
-
result = value
|
|
151
|
-
latch.countDown()
|
|
152
|
-
}
|
|
194
|
+
deferred.complete(value)
|
|
153
195
|
}
|
|
154
196
|
|
|
155
197
|
fun reject(error: Throwable) {
|
|
156
|
-
|
|
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
|
-
|
|
164
|
-
|
|
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
|
-
|
|
733
|
+
try {
|
|
734
|
+
return existing.pending.await(WIFI_SCAN_TIMEOUT_MS)
|
|
735
|
+
} finally {
|
|
736
|
+
releaseWifiScanWaiter(existing)
|
|
737
|
+
}
|
|
660
738
|
}
|
|
661
739
|
try {
|
|
662
|
-
|
|
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
|
-
|
|
681
|
-
|
|
682
|
-
|
|
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
|
|
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
|
-
|
|
842
|
-
stopStreamKeepAliveMonitor()
|
|
943
|
+
var start: PendingStreamStart? = null
|
|
843
944
|
try {
|
|
844
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1348
|
-
if (
|
|
1349
|
-
|
|
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,
|
|
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
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
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,
|
|
1525
|
-
pending.reject(
|
|
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
|
-
|
|
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
|
|
1559
|
-
return streamId to
|
|
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
|
-
|
|
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) {
|