@mentra/bluetooth-sdk 0.1.19 → 0.1.21-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +239 -52
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +65 -14
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +47 -4
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +6 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +381 -97
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +333 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +16 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +2 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizer.kt +38 -0
- 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 +293 -174
- 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/types/DeviceModels.kt +2 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadService.java +65 -8
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +6 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/audio/PcmStreamPlayer.kt +328 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkExceptionTest.kt +18 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +31 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizerTest.kt +31 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadServiceTest.java +9 -0
- package/build/BluetoothSdk.types.d.ts +71 -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 +27 -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 +2 -0
- package/build/_private/photoRequestPayload.js.map +1 -1
- package/build/index.d.ts +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +9 -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 +52 -3
- package/ios/Source/DeviceStore.swift +6 -1
- package/ios/Source/MentraBluetoothSDK.swift +298 -21
- package/ios/Source/audio/PcmStreamPlayer.swift +410 -0
- package/ios/Source/camera/CameraModels.swift +22 -3
- package/ios/Source/sgcs/G1.swift +2 -2
- package/ios/Source/sgcs/G2.swift +19 -7
- package/ios/Source/sgcs/Mach1.swift +1 -1
- package/ios/Source/sgcs/MentraLive.swift +456 -52
- package/ios/Source/sgcs/MentraLiveL2capChannel.swift +183 -0
- 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/ios/Source/utils/G1Text.swift +30 -1
- package/package.json +1 -2
- package/src/BluetoothSdk.types.ts +75 -0
- package/src/_internal.ts +2 -0
- package/src/_private/BluetoothSdkModule.ts +68 -13
- package/src/_private/MentraLocalNetworkModule.ts +50 -0
- package/src/_private/cameraRequestPayload.ts +1 -0
- package/src/_private/photoRequestPayload.ts +3 -3
- package/src/index.ts +10 -0
|
@@ -9,9 +9,12 @@ import com.mentra.bluetoothsdk.utils.PhoneAudioMonitor
|
|
|
9
9
|
import java.util.Collections
|
|
10
10
|
import java.util.UUID
|
|
11
11
|
import java.util.concurrent.ConcurrentHashMap
|
|
12
|
-
import java.util.concurrent.CountDownLatch
|
|
13
|
-
import java.util.concurrent.TimeUnit
|
|
14
12
|
import java.util.concurrent.atomic.AtomicBoolean
|
|
13
|
+
import java.util.concurrent.atomic.AtomicLong
|
|
14
|
+
import kotlinx.coroutines.CancellationException
|
|
15
|
+
import kotlinx.coroutines.CompletableDeferred
|
|
16
|
+
import kotlinx.coroutines.delay
|
|
17
|
+
import kotlinx.coroutines.withTimeoutOrNull
|
|
15
18
|
|
|
16
19
|
class MentraBluetoothSdk private constructor(
|
|
17
20
|
context: Context,
|
|
@@ -36,7 +39,9 @@ class MentraBluetoothSdk private constructor(
|
|
|
36
39
|
ConcurrentHashMap<String, PendingVideoRecordingRequest>()
|
|
37
40
|
private val pendingRgbLedRequests = ConcurrentHashMap<String, PendingResponse<RgbLedControlResponseEvent>>()
|
|
38
41
|
private val pendingSettingsRequests = ConcurrentHashMap<String, PendingResponse<SettingsAckEvent>>()
|
|
39
|
-
private val pendingStreamStarts = ConcurrentHashMap<String,
|
|
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,
|
|
@@ -477,7 +513,13 @@ class MentraBluetoothSdk private constructor(
|
|
|
477
513
|
DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "voice_activity_detection_enabled", enabled)
|
|
478
514
|
}
|
|
479
515
|
|
|
480
|
-
|
|
516
|
+
@Deprecated(
|
|
517
|
+
message =
|
|
518
|
+
"Sticky action-button photo presets are deprecated. Prefer per-request " +
|
|
519
|
+
"requestPhoto(...) options (e.g. mode=TEXT for AE ÷3, or explicit per-shot fields). " +
|
|
520
|
+
"This method still works but will be removed in a future release.",
|
|
521
|
+
)
|
|
522
|
+
suspend fun setPhotoCaptureDefaults(settings: PhotoCaptureDefaults): SettingsAckEvent =
|
|
481
523
|
performSettingsCommand(
|
|
482
524
|
setting = "button_photo",
|
|
483
525
|
updateStore = { _ ->
|
|
@@ -530,10 +572,16 @@ class MentraBluetoothSdk private constructor(
|
|
|
530
572
|
send = { requestId -> deviceManager.sendButtonPhotoSettings(requestId, settings) },
|
|
531
573
|
)
|
|
532
574
|
|
|
533
|
-
fun setVideoRecordingDefaults(defaults: VideoRecordingDefaults): SettingsAckEvent =
|
|
575
|
+
suspend fun setVideoRecordingDefaults(defaults: VideoRecordingDefaults): SettingsAckEvent =
|
|
534
576
|
performSettingsCommand(
|
|
535
577
|
setting = "button_video_recording",
|
|
536
578
|
updateStore = { _ ->
|
|
579
|
+
DeviceStore.set(
|
|
580
|
+
ObservableStore.BLUETOOTH_CATEGORY,
|
|
581
|
+
"button_video_settings",
|
|
582
|
+
mapOf("width" to defaults.width, "height" to defaults.height, "fps" to defaults.fps),
|
|
583
|
+
)
|
|
584
|
+
// Keep legacy cache keys readable for older internal callers during migration.
|
|
537
585
|
DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_video_width", defaults.width)
|
|
538
586
|
DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_video_height", defaults.height)
|
|
539
587
|
DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_video_fps", defaults.fps)
|
|
@@ -543,7 +591,7 @@ class MentraBluetoothSdk private constructor(
|
|
|
543
591
|
},
|
|
544
592
|
)
|
|
545
593
|
|
|
546
|
-
fun setMaxVideoRecordingDuration(minutes: Int): SettingsAckEvent =
|
|
594
|
+
suspend fun setMaxVideoRecordingDuration(minutes: Int): SettingsAckEvent =
|
|
547
595
|
performSettingsCommand(
|
|
548
596
|
setting = "button_max_recording_time",
|
|
549
597
|
updateStore = { _ ->
|
|
@@ -552,7 +600,7 @@ class MentraBluetoothSdk private constructor(
|
|
|
552
600
|
send = { requestId -> deviceManager.sendButtonMaxRecordingTime(requestId, minutes) },
|
|
553
601
|
)
|
|
554
602
|
|
|
555
|
-
fun setCameraFov(fov: CameraFov): CameraFovResult {
|
|
603
|
+
suspend fun setCameraFov(fov: CameraFov): CameraFovResult {
|
|
556
604
|
val ack = performSettingsCommand(
|
|
557
605
|
setting = "camera_fov",
|
|
558
606
|
updateStore = { _ -> },
|
|
@@ -567,6 +615,44 @@ class MentraBluetoothSdk private constructor(
|
|
|
567
615
|
return result
|
|
568
616
|
}
|
|
569
617
|
|
|
618
|
+
/**
|
|
619
|
+
* Compatibility path for ASG clients that support only the older one-way FOV command.
|
|
620
|
+
* It resolves once the setting is queued to BLE because those clients never acknowledge it.
|
|
621
|
+
*/
|
|
622
|
+
fun setLegacyCameraFov(fov: CameraFov): CameraFovResult {
|
|
623
|
+
deviceManager.sendLegacyCameraFovSetting(fov.fov, fov.roiPosition.value)
|
|
624
|
+
return CameraFovResult("legacy", fov.fov, fov.roiPosition, System.currentTimeMillis())
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/** Restores the persistent base FOV through the acknowledgement-free compatibility path. */
|
|
628
|
+
fun restoreLegacyCameraFov() {
|
|
629
|
+
deviceManager.restoreLegacyCameraFovSetting()
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
suspend fun setCameraFovOverride(leaseId: String, fov: CameraFov, ttlMs: Int): CameraFovResult {
|
|
633
|
+
val ack = performSettingsCommand(
|
|
634
|
+
setting = "camera_fov_override",
|
|
635
|
+
updateStore = { _ -> },
|
|
636
|
+
send = { requestId ->
|
|
637
|
+
deviceManager.sendCameraFovOverride(
|
|
638
|
+
requestId,
|
|
639
|
+
leaseId,
|
|
640
|
+
fov.fov,
|
|
641
|
+
fov.roiPosition.value,
|
|
642
|
+
ttlMs,
|
|
643
|
+
)
|
|
644
|
+
},
|
|
645
|
+
)
|
|
646
|
+
return CameraFovResult.fromAck(ack, fov)
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
suspend fun releaseCameraFovOverride(leaseId: String): SettingsAckEvent =
|
|
650
|
+
performSettingsCommand(
|
|
651
|
+
setting = "camera_fov_override",
|
|
652
|
+
updateStore = { _ -> },
|
|
653
|
+
send = { requestId -> deviceManager.releaseCameraFovOverride(requestId, leaseId) },
|
|
654
|
+
)
|
|
655
|
+
|
|
570
656
|
/**
|
|
571
657
|
* Configure camera HAL tuning on Mentra Live glasses.
|
|
572
658
|
*
|
|
@@ -577,7 +663,7 @@ class MentraBluetoothSdk private constructor(
|
|
|
577
663
|
* @param anrOn {@code true} = ANR enabled, {@code false} = ANR disabled (pixsmart param)
|
|
578
664
|
* @param gainOn {@code true} = stock gain params, {@code false} = pixsmart gain-off params
|
|
579
665
|
*/
|
|
580
|
-
fun setCameraTuningConfig(anrOn: Boolean, gainOn: Boolean): SettingsAckEvent =
|
|
666
|
+
suspend fun setCameraTuningConfig(anrOn: Boolean, gainOn: Boolean): SettingsAckEvent =
|
|
581
667
|
performSettingsCommand(
|
|
582
668
|
setting = "camera_tuning",
|
|
583
669
|
updateStore = { _ -> },
|
|
@@ -631,12 +717,12 @@ class MentraBluetoothSdk private constructor(
|
|
|
631
717
|
return GlassesMediaVolumeSetResult.fromMap(deviceManager.setGlassesMediaVolumeBlocking(level))
|
|
632
718
|
}
|
|
633
719
|
|
|
634
|
-
fun requestWifiScan(): List<WifiScanResult> {
|
|
720
|
+
suspend fun requestWifiScan(): List<WifiScanResult> {
|
|
635
721
|
val pending = PendingResponse<List<WifiScanResult>>("WiFi scan request")
|
|
636
|
-
val request = PendingWifiScan(pending)
|
|
722
|
+
val request = PendingWifiScan(pending, scanId = "scan-${UUID.randomUUID()}", waiters = 1)
|
|
637
723
|
val existing =
|
|
638
724
|
synchronized(oneShotLock) {
|
|
639
|
-
pendingWifiScan ?: run {
|
|
725
|
+
pendingWifiScan?.also { it.waiters++ } ?: run {
|
|
640
726
|
pendingWifiScan = request
|
|
641
727
|
null
|
|
642
728
|
}
|
|
@@ -644,13 +730,26 @@ class MentraBluetoothSdk private constructor(
|
|
|
644
730
|
if (existing != null) {
|
|
645
731
|
// Join the in-flight scan instead of failing with request_in_flight;
|
|
646
732
|
// the scan screen auto-starts a scan on mount and can be pushed twice.
|
|
647
|
-
|
|
733
|
+
try {
|
|
734
|
+
return existing.pending.await(WIFI_SCAN_TIMEOUT_MS)
|
|
735
|
+
} finally {
|
|
736
|
+
releaseWifiScanWaiter(existing)
|
|
737
|
+
}
|
|
648
738
|
}
|
|
649
739
|
try {
|
|
650
|
-
|
|
740
|
+
// Claim the scan-results store for this scan before the command goes
|
|
741
|
+
// out, so a delayed chunk from an older, abandoned scan can no longer
|
|
742
|
+
// mutate it.
|
|
743
|
+
Bridge.claimWifiScanResults(request.scanId)
|
|
744
|
+
deviceManager.requestWifiScan(request.scanId)
|
|
651
745
|
// The glasses wait up to 15s for scan-results broadcasts before sending
|
|
652
746
|
// scan_complete, so give them longer than that before falling back.
|
|
653
747
|
return pending.await(WIFI_SCAN_TIMEOUT_MS)
|
|
748
|
+
} catch (cancellation: CancellationException) {
|
|
749
|
+
// Only this caller is going away; the glasses scan keeps running and
|
|
750
|
+
// other coroutines may have joined the same pending entry. Leave the
|
|
751
|
+
// shared deferred alone so scan_complete can still resolve for them.
|
|
752
|
+
throw cancellation
|
|
654
753
|
} catch (error: Throwable) {
|
|
655
754
|
if (error is BluetoothSdkException && error.code == "request_timeout") {
|
|
656
755
|
val fallbackResults = synchronized(oneShotLock) { request.latestResults }
|
|
@@ -665,15 +764,23 @@ class MentraBluetoothSdk private constructor(
|
|
|
665
764
|
pending.reject(error)
|
|
666
765
|
throw error
|
|
667
766
|
} finally {
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
767
|
+
releaseWifiScanWaiter(request)
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/** Last waiter (starter or joiner) out clears the shared scan entry so a
|
|
772
|
+
* cancelled starter can't strand it, and an abandoned scan can't wedge
|
|
773
|
+
* future ones. handleWifiScanResultsForRequests may clear it first. */
|
|
774
|
+
private fun releaseWifiScanWaiter(request: PendingWifiScan) {
|
|
775
|
+
synchronized(oneShotLock) {
|
|
776
|
+
request.waiters--
|
|
777
|
+
if (request.waiters <= 0 && pendingWifiScan === request) {
|
|
778
|
+
pendingWifiScan = null
|
|
672
779
|
}
|
|
673
780
|
}
|
|
674
781
|
}
|
|
675
782
|
|
|
676
|
-
fun sendWifiCredentials(ssid: String, password: String): WifiStatusEvent {
|
|
783
|
+
suspend fun sendWifiCredentials(ssid: String, password: String): WifiStatusEvent {
|
|
677
784
|
val pending = PendingResponse<WifiStatusEvent>("WiFi connect request")
|
|
678
785
|
synchronized(oneShotLock) {
|
|
679
786
|
if (pendingWifiStatus != null) {
|
|
@@ -696,7 +803,7 @@ class MentraBluetoothSdk private constructor(
|
|
|
696
803
|
}
|
|
697
804
|
}
|
|
698
805
|
|
|
699
|
-
fun forgetWifiNetwork(ssid: String): WifiStatusEvent {
|
|
806
|
+
suspend fun forgetWifiNetwork(ssid: String): WifiStatusEvent {
|
|
700
807
|
val pending = PendingResponse<WifiStatusEvent>("WiFi forget request")
|
|
701
808
|
synchronized(oneShotLock) {
|
|
702
809
|
if (pendingWifiStatus != null) {
|
|
@@ -719,7 +826,7 @@ class MentraBluetoothSdk private constructor(
|
|
|
719
826
|
}
|
|
720
827
|
}
|
|
721
828
|
|
|
722
|
-
fun setHotspotState(enabled: Boolean): HotspotStatusEvent {
|
|
829
|
+
suspend fun setHotspotState(enabled: Boolean): HotspotStatusEvent {
|
|
723
830
|
val pending = PendingResponse<HotspotStatusEvent>("hotspot ${if (enabled) "enable" else "disable"} request")
|
|
724
831
|
synchronized(oneShotLock) {
|
|
725
832
|
if (pendingHotspotStatus != null) {
|
|
@@ -746,7 +853,7 @@ class MentraBluetoothSdk private constructor(
|
|
|
746
853
|
deviceManager.setSystemTime(timestampMs)
|
|
747
854
|
}
|
|
748
855
|
|
|
749
|
-
fun requestPhoto(request: PhotoRequest): PhotoResponseEvent {
|
|
856
|
+
suspend fun requestPhoto(request: PhotoRequest): PhotoResponseEvent {
|
|
750
857
|
val routedRequest =
|
|
751
858
|
nonBlankRequestId(request.requestId)
|
|
752
859
|
?.let { if (it == request.requestId) request else request.copy(requestId = it) }
|
|
@@ -758,15 +865,16 @@ class MentraBluetoothSdk private constructor(
|
|
|
758
865
|
pendingPhotoRequests[routedRequest.requestId] = pending
|
|
759
866
|
try {
|
|
760
867
|
deviceManager.requestPhoto(routedRequest)
|
|
761
|
-
return pending.await()
|
|
868
|
+
return pending.await(PHOTO_REQUEST_TIMEOUT_MS)
|
|
762
869
|
} finally {
|
|
763
870
|
pendingPhotoRequests.remove(routedRequest.requestId, pending)
|
|
764
871
|
}
|
|
765
872
|
}
|
|
766
873
|
|
|
767
|
-
fun warmUpCamera(
|
|
874
|
+
suspend fun warmUpCamera(
|
|
768
875
|
requestId: String? = null,
|
|
769
876
|
size: PhotoSize,
|
|
877
|
+
mode: PhotoMode = PhotoMode.PHOTO,
|
|
770
878
|
exposureTimeNs: Long?,
|
|
771
879
|
durationMs: Int,
|
|
772
880
|
): CameraStatusEvent {
|
|
@@ -781,14 +889,20 @@ class MentraBluetoothSdk private constructor(
|
|
|
781
889
|
)
|
|
782
890
|
}
|
|
783
891
|
try {
|
|
784
|
-
deviceManager.warmUpCamera(effectiveRequestId, size, exposureTimeNs, durationMs)
|
|
892
|
+
deviceManager.warmUpCamera(effectiveRequestId, size, mode, exposureTimeNs, durationMs)
|
|
785
893
|
return pending.await()
|
|
786
894
|
} finally {
|
|
787
895
|
pendingCameraStatusRequests.remove(effectiveRequestId, pending)
|
|
788
896
|
}
|
|
789
897
|
}
|
|
790
898
|
|
|
791
|
-
fun
|
|
899
|
+
fun stopCameraWarmUp(requestId: String) {
|
|
900
|
+
val effectiveRequestId = nonBlankRequestId(requestId)
|
|
901
|
+
?: throw BluetoothSdkException("invalid_request", "A warm-up request ID is required.")
|
|
902
|
+
deviceManager.stopCameraWarmUp(effectiveRequestId)
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
suspend fun queryGalleryStatus(): GalleryStatusEvent {
|
|
792
906
|
val pending = PendingResponse<GalleryStatusEvent>("gallery status query")
|
|
793
907
|
synchronized(oneShotLock) {
|
|
794
908
|
if (pendingGalleryStatus != null) {
|
|
@@ -811,13 +925,13 @@ class MentraBluetoothSdk private constructor(
|
|
|
811
925
|
}
|
|
812
926
|
}
|
|
813
927
|
|
|
814
|
-
fun startStream(request: StreamRequest): StreamStatusEvent =
|
|
928
|
+
suspend fun startStream(request: StreamRequest): StreamStatusEvent =
|
|
815
929
|
startStream(request, startSdkKeepAlive = true)
|
|
816
930
|
|
|
817
|
-
internal fun startExternallyManagedStream(request: StreamRequest): StreamStatusEvent =
|
|
931
|
+
internal suspend fun startExternallyManagedStream(request: StreamRequest): StreamStatusEvent =
|
|
818
932
|
startStream(request, startSdkKeepAlive = false)
|
|
819
933
|
|
|
820
|
-
private fun startStream(
|
|
934
|
+
private suspend fun startStream(
|
|
821
935
|
request: StreamRequest,
|
|
822
936
|
startSdkKeepAlive: Boolean,
|
|
823
937
|
): StreamStatusEvent {
|
|
@@ -826,17 +940,25 @@ class MentraBluetoothSdk private constructor(
|
|
|
826
940
|
?: "sdk-${UUID.randomUUID()}"
|
|
827
941
|
message["streamId"] = streamId
|
|
828
942
|
val pending = PendingResponse<StreamStatusEvent>("start stream $streamId")
|
|
829
|
-
|
|
830
|
-
stopStreamKeepAliveMonitor()
|
|
943
|
+
var start: PendingStreamStart? = null
|
|
831
944
|
try {
|
|
832
|
-
|
|
945
|
+
// seq assignment and the BLE hand-off must be one critical section:
|
|
946
|
+
// rejectPreemptedStreamStarts only works if seq order equals the
|
|
947
|
+
// order the commands reach the glasses' FIFO.
|
|
948
|
+
synchronized(streamStartOrderLock) {
|
|
949
|
+
val registered = PendingStreamStart(streamStartSeq.incrementAndGet(), pending)
|
|
950
|
+
pendingStreamStarts[streamId] = registered
|
|
951
|
+
start = registered
|
|
952
|
+
stopStreamKeepAliveMonitor()
|
|
953
|
+
deviceManager.startStream(message)
|
|
954
|
+
}
|
|
833
955
|
val event = pending.await(STREAM_START_TIMEOUT_MS)
|
|
834
956
|
if (startSdkKeepAlive) {
|
|
835
957
|
startStreamKeepAliveMonitor(streamId, DEFAULT_STREAM_KEEP_ALIVE_INTERVAL_SECONDS)
|
|
836
958
|
}
|
|
837
959
|
return event
|
|
838
960
|
} finally {
|
|
839
|
-
pendingStreamStarts.remove(streamId,
|
|
961
|
+
start?.let { pendingStreamStarts.remove(streamId, it) }
|
|
840
962
|
}
|
|
841
963
|
}
|
|
842
964
|
|
|
@@ -844,7 +966,7 @@ class MentraBluetoothSdk private constructor(
|
|
|
844
966
|
deviceManager.keepStreamAlive(request.toMap().toMutableMap())
|
|
845
967
|
}
|
|
846
968
|
|
|
847
|
-
fun rgbLedControl(request: RgbLedRequest): RgbLedControlResponseEvent {
|
|
969
|
+
suspend fun rgbLedControl(request: RgbLedRequest): RgbLedControlResponseEvent {
|
|
848
970
|
val pending = PendingResponse<RgbLedControlResponseEvent>("RGB LED command ${request.requestId}")
|
|
849
971
|
pendingRgbLedRequests[request.requestId] = pending
|
|
850
972
|
try {
|
|
@@ -863,23 +985,30 @@ class MentraBluetoothSdk private constructor(
|
|
|
863
985
|
}
|
|
864
986
|
}
|
|
865
987
|
|
|
866
|
-
fun stopStream(): StreamStatusEvent {
|
|
988
|
+
suspend fun stopStream(): StreamStatusEvent {
|
|
867
989
|
val pending = PendingResponse<StreamStatusEvent>("stop stream")
|
|
868
990
|
val targetStreamId = synchronized(streamKeepAliveLock) {
|
|
869
991
|
activeStreamKeepAlive?.streamId
|
|
870
992
|
}
|
|
871
|
-
synchronized(oneShotLock) {
|
|
872
|
-
if (pendingStreamStop != null) {
|
|
873
|
-
throw BluetoothSdkException(
|
|
874
|
-
"request_in_flight",
|
|
875
|
-
"A stream stop command is already waiting for a glasses response.",
|
|
876
|
-
)
|
|
877
|
-
}
|
|
878
|
-
pendingStreamStop = PendingStreamStop(targetStreamId, pending)
|
|
879
|
-
}
|
|
880
|
-
stopStreamKeepAliveMonitor()
|
|
881
993
|
try {
|
|
882
|
-
|
|
994
|
+
// The seq draw and the BLE hand-off share startStream's ordering
|
|
995
|
+
// critical section: the stop takes its own slot in the send order,
|
|
996
|
+
// so its ack can separate the pending starts the glasses consumed
|
|
997
|
+
// before it (lower seq) from starts sent after it (higher seq).
|
|
998
|
+
synchronized(streamStartOrderLock) {
|
|
999
|
+
synchronized(oneShotLock) {
|
|
1000
|
+
if (pendingStreamStop != null) {
|
|
1001
|
+
throw BluetoothSdkException(
|
|
1002
|
+
"request_in_flight",
|
|
1003
|
+
"A stream stop command is already waiting for a glasses response.",
|
|
1004
|
+
)
|
|
1005
|
+
}
|
|
1006
|
+
pendingStreamStop =
|
|
1007
|
+
PendingStreamStop(targetStreamId, streamStartSeq.incrementAndGet(), pending)
|
|
1008
|
+
}
|
|
1009
|
+
stopStreamKeepAliveMonitor()
|
|
1010
|
+
deviceManager.stopStream()
|
|
1011
|
+
}
|
|
883
1012
|
return pending.await(STREAM_STOP_TIMEOUT_MS)
|
|
884
1013
|
} finally {
|
|
885
1014
|
synchronized(oneShotLock) {
|
|
@@ -890,7 +1019,7 @@ class MentraBluetoothSdk private constructor(
|
|
|
890
1019
|
}
|
|
891
1020
|
}
|
|
892
1021
|
|
|
893
|
-
fun startVideoRecording(request: VideoRecordingRequest): VideoRecordingStatusEvent {
|
|
1022
|
+
suspend fun startVideoRecording(request: VideoRecordingRequest): VideoRecordingStatusEvent {
|
|
894
1023
|
require(request.requestId.isNotBlank()) { "requestId is required to start video recording." }
|
|
895
1024
|
requireGlassesConnected("start video recording")
|
|
896
1025
|
val pending = PendingResponse<VideoRecordingStatusEvent>("start video recording")
|
|
@@ -918,7 +1047,7 @@ class MentraBluetoothSdk private constructor(
|
|
|
918
1047
|
}
|
|
919
1048
|
|
|
920
1049
|
@JvmOverloads
|
|
921
|
-
fun stopVideoRecording(
|
|
1050
|
+
suspend fun stopVideoRecording(
|
|
922
1051
|
requestId: String,
|
|
923
1052
|
webhookUrl: String? = null,
|
|
924
1053
|
authToken: String? = null,
|
|
@@ -949,7 +1078,7 @@ class MentraBluetoothSdk private constructor(
|
|
|
949
1078
|
}
|
|
950
1079
|
}
|
|
951
1080
|
|
|
952
|
-
fun requestVersionInfo(): VersionInfoResult {
|
|
1081
|
+
suspend fun requestVersionInfo(): VersionInfoResult {
|
|
953
1082
|
val pending = PendingResponse<VersionInfoResult>("version info request")
|
|
954
1083
|
synchronized(oneShotLock) {
|
|
955
1084
|
if (pendingVersionInfo != null) {
|
|
@@ -979,7 +1108,7 @@ class MentraBluetoothSdk private constructor(
|
|
|
979
1108
|
internal fun getOtaVersionUrl(): String = configuredOtaVersionUrl ?: OtaManifestDefaults.defaultOtaVersionUrl()
|
|
980
1109
|
|
|
981
1110
|
/** Fetch the configured OTA manifest and return whether any ASG/BES/MTK update is available. */
|
|
982
|
-
fun checkForOtaUpdate(): Boolean {
|
|
1111
|
+
suspend fun checkForOtaUpdate(): Boolean {
|
|
983
1112
|
val status = getFreshGlassesStatus()
|
|
984
1113
|
if (!status.connected) {
|
|
985
1114
|
throw BluetoothSdkException(
|
|
@@ -1006,12 +1135,12 @@ class MentraBluetoothSdk private constructor(
|
|
|
1006
1135
|
}
|
|
1007
1136
|
|
|
1008
1137
|
/** Ask connected Mentra Live glasses to report the current OTA install/session status. */
|
|
1009
|
-
private fun queryOtaStatus(): OtaQueryResult =
|
|
1138
|
+
private suspend fun queryOtaStatus(): OtaQueryResult =
|
|
1010
1139
|
performOtaQuery("OTA status query") {
|
|
1011
1140
|
deviceManager.sendOtaQueryStatus()
|
|
1012
1141
|
}
|
|
1013
1142
|
|
|
1014
|
-
private fun performOtaQuery(
|
|
1143
|
+
private suspend fun performOtaQuery(
|
|
1015
1144
|
operation: String,
|
|
1016
1145
|
sendRequest: () -> Unit,
|
|
1017
1146
|
): OtaQueryResult {
|
|
@@ -1038,12 +1167,12 @@ class MentraBluetoothSdk private constructor(
|
|
|
1038
1167
|
}
|
|
1039
1168
|
|
|
1040
1169
|
/** Start the OTA flow after your app has presented the available update to the user. */
|
|
1041
|
-
fun startOtaUpdate(): OtaStartAckEvent {
|
|
1170
|
+
suspend fun startOtaUpdate(): OtaStartAckEvent {
|
|
1042
1171
|
val otaVersionUrl = resolveOtaVersionUrl(getFreshGlassesStatus())
|
|
1043
1172
|
return startOtaUpdate(otaVersionUrl)
|
|
1044
1173
|
}
|
|
1045
1174
|
|
|
1046
|
-
private fun startOtaCommand(otaVersionUrl: String): OtaStartAckEvent {
|
|
1175
|
+
private suspend fun startOtaCommand(otaVersionUrl: String): OtaStartAckEvent {
|
|
1047
1176
|
val pending = PendingResponse<OtaStartAckEvent>("OTA start command")
|
|
1048
1177
|
synchronized(oneShotLock) {
|
|
1049
1178
|
if (pendingOtaStart != null) {
|
|
@@ -1066,12 +1195,12 @@ class MentraBluetoothSdk private constructor(
|
|
|
1066
1195
|
}
|
|
1067
1196
|
}
|
|
1068
1197
|
|
|
1069
|
-
internal fun startOtaUpdate(otaVersionUrl: String): OtaStartAckEvent =
|
|
1198
|
+
internal suspend fun startOtaUpdate(otaVersionUrl: String): OtaStartAckEvent =
|
|
1070
1199
|
startOtaCommand(otaVersionUrl)
|
|
1071
1200
|
|
|
1072
|
-
internal fun sendOtaQueryStatus(): OtaQueryResult = queryOtaStatus()
|
|
1201
|
+
internal suspend fun sendOtaQueryStatus(): OtaQueryResult = queryOtaStatus()
|
|
1073
1202
|
|
|
1074
|
-
private fun getFreshGlassesStatus(): GlassesStatus {
|
|
1203
|
+
private suspend fun getFreshGlassesStatus(): GlassesStatus {
|
|
1075
1204
|
val status = getRawGlassesStatus()
|
|
1076
1205
|
if (!status.connected || status.buildNumber.isNotBlank()) {
|
|
1077
1206
|
return status
|
|
@@ -1089,12 +1218,17 @@ class MentraBluetoothSdk private constructor(
|
|
|
1089
1218
|
otaVersionUrl = versionInfo.otaVersionUrl.ifBlank { status.otaVersionUrl },
|
|
1090
1219
|
appVersion = versionInfo.appVersion.ifBlank { status.appVersion },
|
|
1091
1220
|
)
|
|
1221
|
+
} catch (cancellation: CancellationException) {
|
|
1222
|
+
// Never swallow cancellation into the stale-status fallback: callers
|
|
1223
|
+
// like startOtaUpdate() would otherwise keep running side effects
|
|
1224
|
+
// (sendOtaStart) on behalf of an already-cancelled coroutine.
|
|
1225
|
+
throw cancellation
|
|
1092
1226
|
} catch (_: Throwable) {
|
|
1093
1227
|
status
|
|
1094
1228
|
}
|
|
1095
1229
|
}
|
|
1096
1230
|
|
|
1097
|
-
private fun waitForOtaManifestStatus(
|
|
1231
|
+
private suspend fun waitForOtaManifestStatus(
|
|
1098
1232
|
initialStatus: GlassesStatus,
|
|
1099
1233
|
manifest: org.json.JSONObject,
|
|
1100
1234
|
): GlassesStatus {
|
|
@@ -1120,7 +1254,7 @@ class MentraBluetoothSdk private constructor(
|
|
|
1120
1254
|
return status
|
|
1121
1255
|
}
|
|
1122
1256
|
|
|
1123
|
-
private fun waitForGlassesStatus(
|
|
1257
|
+
private suspend fun waitForGlassesStatus(
|
|
1124
1258
|
initialStatus: GlassesStatus,
|
|
1125
1259
|
timeoutMs: Long,
|
|
1126
1260
|
isReady: (GlassesStatus) -> Boolean,
|
|
@@ -1132,7 +1266,7 @@ class MentraBluetoothSdk private constructor(
|
|
|
1132
1266
|
if (isReady(status)) return status
|
|
1133
1267
|
val remainingMs = deadline - System.currentTimeMillis()
|
|
1134
1268
|
if (remainingMs <= 0L) break
|
|
1135
|
-
|
|
1269
|
+
delay(minOf(OTA_VERSION_POLL_MS, remainingMs))
|
|
1136
1270
|
}
|
|
1137
1271
|
return getRawGlassesStatus()
|
|
1138
1272
|
}
|
|
@@ -1332,9 +1466,15 @@ class MentraBluetoothSdk private constructor(
|
|
|
1332
1466
|
data.containsKey("scanComplete") || data.containsKey("scan_complete")
|
|
1333
1467
|
val scanComplete =
|
|
1334
1468
|
(data["scanComplete"] as? Boolean) ?: (data["scan_complete"] as? Boolean) ?: false
|
|
1335
|
-
|
|
1336
|
-
if (
|
|
1337
|
-
|
|
1469
|
+
val scanId = (data["scanId"] as? String)?.ifEmpty { null }
|
|
1470
|
+
if (scanId != null) {
|
|
1471
|
+
handleCorrelatedWifiScanChunk(scanId, networks, scanComplete)
|
|
1472
|
+
} else {
|
|
1473
|
+
// Old firmware: no correlation id, keep the pre-scanId heuristics.
|
|
1474
|
+
updateWifiScanLatestResults(networks)
|
|
1475
|
+
if (scanComplete || !hasCompletionFlag) {
|
|
1476
|
+
handleWifiScanResultsForRequests(networks)
|
|
1477
|
+
}
|
|
1338
1478
|
}
|
|
1339
1479
|
dispatchToListeners { it.onRawEvent(eventName, data) }
|
|
1340
1480
|
}
|
|
@@ -1500,17 +1640,54 @@ class MentraBluetoothSdk private constructor(
|
|
|
1500
1640
|
private fun handleStreamStatusForRequests(event: StreamStatusEvent) {
|
|
1501
1641
|
val startMatch = matchingStreamStart(event)
|
|
1502
1642
|
if (startMatch != null) {
|
|
1503
|
-
val (streamId,
|
|
1643
|
+
val (streamId, start) = startMatch
|
|
1644
|
+
// Preemption may only be inferred from states the glasses emit AFTER
|
|
1645
|
+
// start_stream has run stopAllServices() (see PREEMPTION_PROVEN_STATES).
|
|
1646
|
+
// ERROR must not fire it: a command-level preflight rejection (missing
|
|
1647
|
+
// URL, low battery, no WiFi) carries this start's streamId but is sent
|
|
1648
|
+
// BEFORE stopAllServices, so older starts were not preempted — and
|
|
1649
|
+
// rejecting one that already resolved and started its keep-alive
|
|
1650
|
+
// monitor would strand a live stream without keep-alives. STOPPED
|
|
1651
|
+
// must not fire it either: it is the stop-ack shape and proves
|
|
1652
|
+
// nothing about the start path having run.
|
|
1653
|
+
if (!event.streamId.isNullOrBlank() && event.state in PREEMPTION_PROVEN_STATES) {
|
|
1654
|
+
rejectPreemptedStreamStarts(start.seq)
|
|
1655
|
+
}
|
|
1504
1656
|
when (event.state) {
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1657
|
+
// `reconnected` also proves the stream is live: when an async start
|
|
1658
|
+
// failure triggers the glasses' publisher retry, a successful retry
|
|
1659
|
+
// reports `reconnected` instead of `streaming`.
|
|
1660
|
+
StreamState.STREAMING,
|
|
1661
|
+
StreamState.RECONNECTED -> {
|
|
1662
|
+
pendingStreamStarts.remove(streamId, start)
|
|
1663
|
+
start.pending.resolve(event)
|
|
1508
1664
|
}
|
|
1509
|
-
StreamState.ERROR,
|
|
1510
1665
|
StreamState.RECONNECT_FAILED,
|
|
1511
1666
|
StreamState.STOPPED -> {
|
|
1512
|
-
pendingStreamStarts.remove(streamId,
|
|
1513
|
-
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
|
+
}
|
|
1514
1691
|
}
|
|
1515
1692
|
else -> Unit
|
|
1516
1693
|
}
|
|
@@ -1525,6 +1702,7 @@ class MentraBluetoothSdk private constructor(
|
|
|
1525
1702
|
pendingStreamStop = null
|
|
1526
1703
|
}
|
|
1527
1704
|
}
|
|
1705
|
+
rejectStreamStartsSupersededByStop(stop.seq)
|
|
1528
1706
|
stop.pending.resolve(stoppedStreamEvent(event, stop.streamId))
|
|
1529
1707
|
}
|
|
1530
1708
|
event.state == StreamState.ERROR || event.state == StreamState.RECONNECT_FAILED -> {
|
|
@@ -1540,22 +1718,79 @@ class MentraBluetoothSdk private constructor(
|
|
|
1540
1718
|
}
|
|
1541
1719
|
}
|
|
1542
1720
|
|
|
1543
|
-
|
|
1721
|
+
// The glasses process BLE commands FIFO and every start_stream begins by
|
|
1722
|
+
// stopping whatever runs, so an id-carrying status for start X in one of
|
|
1723
|
+
// these states proves every lower-seq start has already been preempted.
|
|
1724
|
+
// Their only verdict on current firmware is a streamId-less stopped, which
|
|
1725
|
+
// the id-less heuristic deliberately ignores — without this they would run
|
|
1726
|
+
// out the 30s timeout instead of failing fast.
|
|
1727
|
+
private fun rejectPreemptedStreamStarts(winnerSeq: Long) {
|
|
1728
|
+
for ((streamId, start) in pendingStreamStarts) {
|
|
1729
|
+
if (start.seq < winnerSeq && pendingStreamStarts.remove(streamId, start)) {
|
|
1730
|
+
start.pending.reject(
|
|
1731
|
+
BluetoothSdkException(
|
|
1732
|
+
"stream_preempted",
|
|
1733
|
+
"start stream $streamId was preempted by a newer start_stream; " +
|
|
1734
|
+
"the glasses run a single stream and the last request wins.",
|
|
1735
|
+
)
|
|
1736
|
+
)
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
// The glasses process BLE commands FIFO, so this stop's ack also settles
|
|
1742
|
+
// every start sent before it: whatever those starts brought up (or would
|
|
1743
|
+
// have brought up) has been stopped, and no further status will arrive
|
|
1744
|
+
// for them. Without this they would run out the 30s start timeout.
|
|
1745
|
+
// Starts sent after the stop keep waiting for their own statuses.
|
|
1746
|
+
private fun rejectStreamStartsSupersededByStop(stopSeq: Long) {
|
|
1747
|
+
for ((streamId, start) in pendingStreamStarts) {
|
|
1748
|
+
if (start.seq < stopSeq && pendingStreamStarts.remove(streamId, start)) {
|
|
1749
|
+
start.pending.reject(
|
|
1750
|
+
BluetoothSdkException(
|
|
1751
|
+
"stream_stopped",
|
|
1752
|
+
"start stream $streamId was superseded by a stop_stream issued after it.",
|
|
1753
|
+
)
|
|
1754
|
+
)
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
private fun matchingStreamStart(event: StreamStatusEvent): Pair<String, PendingStreamStart>? {
|
|
1544
1760
|
val streamId = event.streamId
|
|
1545
1761
|
if (!streamId.isNullOrBlank()) {
|
|
1546
|
-
val
|
|
1547
|
-
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
|
|
1548
1776
|
}
|
|
1549
1777
|
if (pendingStreamStarts.size == 1) {
|
|
1778
|
+
// firstOrNull: timeouts and other handler threads mutate the
|
|
1779
|
+
// ConcurrentHashMap concurrently, so the map can empty between the
|
|
1780
|
+
// size check and this read; bail out instead of throwing.
|
|
1781
|
+
val entry = pendingStreamStarts.entries.firstOrNull() ?: return null
|
|
1550
1782
|
// A streamId-less STOPPED is the glasses' stop-ack for a PREVIOUS
|
|
1551
1783
|
// stream (their stop ack carries no streamId), not a verdict on the
|
|
1552
1784
|
// pending start — a start_stream that replaces a running publisher
|
|
1553
1785
|
// emits exactly this sequence (stopped -> initializing -> streaming).
|
|
1554
1786
|
// Attributing it here would reject a start that is about to succeed.
|
|
1555
|
-
|
|
1787
|
+
// After a stashed `willRetry` error for the pending start, though,
|
|
1788
|
+
// the stopped is the retrying publisher's wind-down (the stop-ack
|
|
1789
|
+
// always precedes any status for the new start), so it is that
|
|
1790
|
+
// start's verdict.
|
|
1791
|
+
if (event.state == StreamState.STOPPED && entry.value.lastError == null) {
|
|
1556
1792
|
return null
|
|
1557
1793
|
}
|
|
1558
|
-
val entry = pendingStreamStarts.entries.first()
|
|
1559
1794
|
return entry.key to entry.value
|
|
1560
1795
|
}
|
|
1561
1796
|
return null
|
|
@@ -1703,6 +1938,35 @@ class MentraBluetoothSdk private constructor(
|
|
|
1703
1938
|
request.pending.resolve(results)
|
|
1704
1939
|
}
|
|
1705
1940
|
|
|
1941
|
+
// scanId-echoing glasses: results for another scan are ignored instead of
|
|
1942
|
+
// resolving the pending request, and matching chunks accumulate until the
|
|
1943
|
+
// glasses flag the scan complete.
|
|
1944
|
+
private fun handleCorrelatedWifiScanChunk(
|
|
1945
|
+
scanId: String,
|
|
1946
|
+
results: List<WifiScanResult>,
|
|
1947
|
+
scanComplete: Boolean,
|
|
1948
|
+
) {
|
|
1949
|
+
val request: PendingWifiScan
|
|
1950
|
+
val accumulated: List<WifiScanResult>
|
|
1951
|
+
synchronized(oneShotLock) {
|
|
1952
|
+
request = pendingWifiScan?.takeIf { it.scanId == scanId } ?: return
|
|
1953
|
+
for (network in results) {
|
|
1954
|
+
if (request.accumulated.none { it.ssid == network.ssid }) {
|
|
1955
|
+
request.accumulated.add(network)
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
accumulated = request.accumulated.toList()
|
|
1959
|
+
if (accumulated.isNotEmpty()) {
|
|
1960
|
+
request.latestResults = accumulated
|
|
1961
|
+
}
|
|
1962
|
+
if (!scanComplete) return
|
|
1963
|
+
if (pendingWifiScan === request) {
|
|
1964
|
+
pendingWifiScan = null
|
|
1965
|
+
}
|
|
1966
|
+
}
|
|
1967
|
+
request.pending.resolve(accumulated)
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1706
1970
|
private fun updateWifiScanLatestResults(results: List<WifiScanResult>) {
|
|
1707
1971
|
if (results.isEmpty()) return
|
|
1708
1972
|
synchronized(oneShotLock) {
|
|
@@ -1712,6 +1976,26 @@ class MentraBluetoothSdk private constructor(
|
|
|
1712
1976
|
|
|
1713
1977
|
private fun handleWifiStatusForRequests(event: WifiStatusEvent) {
|
|
1714
1978
|
val request = synchronized(oneShotLock) { pendingWifiStatus } ?: return
|
|
1979
|
+
// A wifi_status carrying the explicit error field is the glasses' failure
|
|
1980
|
+
// verdict for the in-flight connect: reject now instead of running out the
|
|
1981
|
+
// request timeout. Only the error field counts as failure — the glasses'
|
|
1982
|
+
// connect sequence emits a debounced bare connected=false ~1-2s after
|
|
1983
|
+
// credentials while association is still in progress, and rejecting on that
|
|
1984
|
+
// would kill every connect attempt early.
|
|
1985
|
+
if (request.operation == WifiStatusOperation.CONNECT && event.error != null) {
|
|
1986
|
+
synchronized(oneShotLock) {
|
|
1987
|
+
if (pendingWifiStatus === request) {
|
|
1988
|
+
pendingWifiStatus = null
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
request.pending.reject(
|
|
1992
|
+
BluetoothSdkException(
|
|
1993
|
+
event.error,
|
|
1994
|
+
"Glasses failed to join \"${request.ssid}\": ${event.error}",
|
|
1995
|
+
)
|
|
1996
|
+
)
|
|
1997
|
+
return
|
|
1998
|
+
}
|
|
1715
1999
|
if (!wifiStatusMatches(event.status, request)) return
|
|
1716
2000
|
synchronized(oneShotLock) {
|
|
1717
2001
|
if (pendingWifiStatus === request) {
|