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