@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
|
@@ -42,10 +42,15 @@ private final class ActiveScanSession {
|
|
|
42
42
|
@MainActor
|
|
43
43
|
private final class PendingWifiScan {
|
|
44
44
|
let pending: PendingResponse<[WifiScanResult]>
|
|
45
|
+
let scanId: String
|
|
45
46
|
var latestResults: [WifiScanResult] = []
|
|
47
|
+
// Chunks accumulated from scanId-echoing glasses, deduplicated by SSID;
|
|
48
|
+
// resolved only when the glasses flag the scan complete.
|
|
49
|
+
var accumulated: [WifiScanResult] = []
|
|
46
50
|
|
|
47
|
-
init(pending: PendingResponse<[WifiScanResult]
|
|
51
|
+
init(pending: PendingResponse<[WifiScanResult]>, scanId: String) {
|
|
48
52
|
self.pending = pending
|
|
53
|
+
self.scanId = scanId
|
|
49
54
|
}
|
|
50
55
|
}
|
|
51
56
|
|
|
@@ -93,6 +98,25 @@ private final class PendingVideoRecordingRequest {
|
|
|
93
98
|
}
|
|
94
99
|
}
|
|
95
100
|
|
|
101
|
+
// seq records send order (assigned and handed to the BLE queue in a single
|
|
102
|
+
// MainActor turn) so that an id-carrying status for a newer start can
|
|
103
|
+
// identify which older in-flight starts it preempted.
|
|
104
|
+
@MainActor
|
|
105
|
+
private final class PendingStreamStart {
|
|
106
|
+
let seq: Int
|
|
107
|
+
let pending: PendingResponse<StreamStatusEvent>
|
|
108
|
+
// Set when an id-carrying error arrives: a fatal publisher error never
|
|
109
|
+
// reaches the reconnect machinery and winds down with a streamId-less
|
|
110
|
+
// stopped, so the stash both attributes that stopped to this start and
|
|
111
|
+
// preserves the real error details for the rejection.
|
|
112
|
+
var lastError: StreamStatusEvent?
|
|
113
|
+
|
|
114
|
+
init(seq: Int, pending: PendingResponse<StreamStatusEvent>) {
|
|
115
|
+
self.seq = seq
|
|
116
|
+
self.pending = pending
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
96
120
|
@MainActor
|
|
97
121
|
private final class PendingResponse<T> {
|
|
98
122
|
private let operation: String
|
|
@@ -149,6 +173,9 @@ private final class PendingResponse<T> {
|
|
|
149
173
|
@MainActor
|
|
150
174
|
public final class MentraBluetoothSDK {
|
|
151
175
|
private static let wifiScanTimeoutMs = 20_000
|
|
176
|
+
// A photo response is terminal only after capture, encoding, transport, and upload.
|
|
177
|
+
// Max-quality BLE fallback can legitimately exceed the generic command deadline.
|
|
178
|
+
private static let photoRequestTimeoutMs = 30_000
|
|
152
179
|
private static let otaBesVersionWaitMs = 5_000
|
|
153
180
|
private static let otaMtkVersionWaitMs = 2_000
|
|
154
181
|
private static let otaVersionPollMs = 100
|
|
@@ -175,8 +202,12 @@ public final class MentraBluetoothSDK {
|
|
|
175
202
|
private var pendingVideoRecordingRequests: [String: PendingVideoRecordingRequest] = [:]
|
|
176
203
|
private var pendingRgbLedRequests: [String: PendingResponse<RgbLedControlResponseEvent>] = [:]
|
|
177
204
|
private var pendingSettingsRequests: [String: PendingResponse<SettingsAckEvent>] = [:]
|
|
178
|
-
private var pendingStreamStarts: [String:
|
|
179
|
-
private var
|
|
205
|
+
private var pendingStreamStarts: [String: PendingStreamStart] = [:]
|
|
206
|
+
private var streamStartSeq = 0
|
|
207
|
+
// seq is the stop's slot in the shared streamStartSeq order (drawn in the
|
|
208
|
+
// same await-free MainActor turn that sends the command): every pending
|
|
209
|
+
// start with a lower seq reached the glasses' FIFO before this stop.
|
|
210
|
+
private var pendingStreamStop: (streamId: String?, seq: Int, pending: PendingResponse<StreamStatusEvent>)?
|
|
180
211
|
private var pendingGalleryStatus: PendingResponse<GalleryStatusEvent>?
|
|
181
212
|
private var pendingOtaQuery: PendingResponse<OtaQueryResult>?
|
|
182
213
|
private var pendingOtaStart: PendingResponse<OtaStartAckEvent>?
|
|
@@ -196,8 +227,24 @@ public final class MentraBluetoothSDK {
|
|
|
196
227
|
}
|
|
197
228
|
}
|
|
198
229
|
bridgeEventSinkId = Bridge.addEventSink { [weak self] eventName, data in
|
|
199
|
-
|
|
200
|
-
|
|
230
|
+
// Bridge.dispatchEvent always invokes sinks on the main thread, in the
|
|
231
|
+
// FIFO order events were dispatched (correlated WiFi-scan chunks rely
|
|
232
|
+
// on that order). A fresh Task per event can reach the MainActor out
|
|
233
|
+
// of creation order and reorder chunks, so consume synchronously
|
|
234
|
+
// while still on main.
|
|
235
|
+
if Thread.isMainThread {
|
|
236
|
+
MainActor.assumeIsolated {
|
|
237
|
+
self?.dispatchBridgeEvent(eventName, data)
|
|
238
|
+
}
|
|
239
|
+
} else {
|
|
240
|
+
// Defensive fallback only — Bridge.dispatchEvent should never take
|
|
241
|
+
// this path. A serial main-queue hop still preserves FIFO order,
|
|
242
|
+
// unlike an unstructured Task.
|
|
243
|
+
DispatchQueue.main.async {
|
|
244
|
+
MainActor.assumeIsolated {
|
|
245
|
+
self?.dispatchBridgeEvent(eventName, data)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
201
248
|
}
|
|
202
249
|
}
|
|
203
250
|
storeListenerId = DeviceStore.shared.store.addListener { [weak self] category, changes in
|
|
@@ -487,6 +534,7 @@ public final class MentraBluetoothSDK {
|
|
|
487
534
|
DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "voice_activity_detection_enabled", enabled)
|
|
488
535
|
}
|
|
489
536
|
|
|
537
|
+
@available(*, deprecated, message: "Sticky action-button photo presets are deprecated. Prefer per-request requestPhoto(...) options (e.g. mode .text for AE ÷3, or explicit per-shot fields). This method still works but will be removed in a future release.")
|
|
490
538
|
public func setPhotoCaptureDefaults(_ settings: PhotoCaptureDefaults) async throws -> SettingsAckEvent {
|
|
491
539
|
try await performSettingsCommand(
|
|
492
540
|
setting: "button_photo",
|
|
@@ -550,6 +598,12 @@ public final class MentraBluetoothSDK {
|
|
|
550
598
|
try await performSettingsCommand(
|
|
551
599
|
setting: "button_video_recording",
|
|
552
600
|
updateStore: { _ in
|
|
601
|
+
DeviceStore.shared.set(
|
|
602
|
+
ObservableStore.bluetoothCategory,
|
|
603
|
+
"button_video_settings",
|
|
604
|
+
["width": defaults.width, "height": defaults.height, "fps": defaults.fps]
|
|
605
|
+
)
|
|
606
|
+
// Keep legacy cache keys readable for older internal callers during migration.
|
|
553
607
|
DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_video_width", defaults.width)
|
|
554
608
|
DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_video_height", defaults.height)
|
|
555
609
|
DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_video_fps", defaults.fps)
|
|
@@ -598,6 +652,59 @@ public final class MentraBluetoothSDK {
|
|
|
598
652
|
return result
|
|
599
653
|
}
|
|
600
654
|
|
|
655
|
+
/// Compatibility path for ASG clients that support only the older one-way FOV command.
|
|
656
|
+
public func setLegacyCameraFov(_ fov: CameraFov) throws -> CameraFovResult {
|
|
657
|
+
try DeviceManager.shared.sendLegacyCameraFovSetting(
|
|
658
|
+
fov: fov.fov,
|
|
659
|
+
roiPosition: fov.roiPosition.rawValue
|
|
660
|
+
)
|
|
661
|
+
return CameraFovResult(
|
|
662
|
+
requestId: "legacy",
|
|
663
|
+
fov: fov.fov,
|
|
664
|
+
roiPosition: fov.roiPosition,
|
|
665
|
+
timestamp: Int(Date().timeIntervalSince1970 * 1000)
|
|
666
|
+
)
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/// Restores the persistent base FOV without requiring a settings acknowledgement.
|
|
670
|
+
public func restoreLegacyCameraFov() throws {
|
|
671
|
+
try DeviceManager.shared.restoreLegacyCameraFovSetting()
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
public func setCameraFovOverride(
|
|
675
|
+
leaseId: String,
|
|
676
|
+
fov: CameraFov,
|
|
677
|
+
ttlMs: Int
|
|
678
|
+
) async throws -> CameraFovResult {
|
|
679
|
+
let ack = try await performSettingsCommand(
|
|
680
|
+
setting: "camera_fov_override",
|
|
681
|
+
updateStore: { _ in },
|
|
682
|
+
send: { requestId in
|
|
683
|
+
try DeviceManager.shared.sendCameraFovOverride(
|
|
684
|
+
requestId: requestId,
|
|
685
|
+
leaseId: leaseId,
|
|
686
|
+
fov: fov.fov,
|
|
687
|
+
roiPosition: fov.roiPosition.rawValue,
|
|
688
|
+
ttlMs: ttlMs
|
|
689
|
+
)
|
|
690
|
+
}
|
|
691
|
+
)
|
|
692
|
+
return try CameraFovResult.from(ack: ack, fallback: fov)
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
public func releaseCameraFovOverride(leaseId: String) async throws -> SettingsAckEvent {
|
|
696
|
+
try await performSettingsCommand(
|
|
697
|
+
setting: "camera_fov_override",
|
|
698
|
+
updateStore: { _ in },
|
|
699
|
+
send: { requestId in
|
|
700
|
+
try DeviceManager.shared.releaseCameraFovOverride(
|
|
701
|
+
requestId: requestId,
|
|
702
|
+
leaseId: leaseId
|
|
703
|
+
)
|
|
704
|
+
}
|
|
705
|
+
)
|
|
706
|
+
}
|
|
707
|
+
|
|
601
708
|
public func setCameraTuningConfig(anrOn: Bool, gainOn: Bool) async throws -> SettingsAckEvent {
|
|
602
709
|
return try await performSettingsCommand(
|
|
603
710
|
setting: "camera_tuning",
|
|
@@ -672,9 +779,12 @@ public final class MentraBluetoothSDK {
|
|
|
672
779
|
return try await existing.value
|
|
673
780
|
}
|
|
674
781
|
let pending = PendingResponse<[WifiScanResult]>(operation: "WiFi scan request")
|
|
675
|
-
let request = PendingWifiScan(pending: pending)
|
|
782
|
+
let request = PendingWifiScan(pending: pending, scanId: "scan-\(UUID().uuidString)")
|
|
676
783
|
pendingWifiScan = request
|
|
677
|
-
|
|
784
|
+
// Claim the scan-results store for this scan before the command goes out,
|
|
785
|
+
// so a delayed chunk from an older, abandoned scan can no longer mutate it.
|
|
786
|
+
Bridge.claimWifiScanResults(scanId: request.scanId)
|
|
787
|
+
DeviceManager.shared.requestWifiScan(scanId: request.scanId)
|
|
678
788
|
let task = Task { @MainActor [weak self] () async throws -> [WifiScanResult] in
|
|
679
789
|
defer {
|
|
680
790
|
if let self {
|
|
@@ -788,7 +898,7 @@ public final class MentraBluetoothSDK {
|
|
|
788
898
|
pendingPhotoRequests[routedRequest.requestId] = pending
|
|
789
899
|
DeviceManager.shared.requestPhoto(routedRequest)
|
|
790
900
|
do {
|
|
791
|
-
let event = try await pending.wait()
|
|
901
|
+
let event = try await pending.wait(timeoutMs: MentraBluetoothSDK.photoRequestTimeoutMs)
|
|
792
902
|
pendingPhotoRequests.removeValue(forKey: routedRequest.requestId)
|
|
793
903
|
return event
|
|
794
904
|
} catch {
|
|
@@ -800,6 +910,7 @@ public final class MentraBluetoothSDK {
|
|
|
800
910
|
public func warmUpCamera(
|
|
801
911
|
requestId: String? = nil,
|
|
802
912
|
size: PhotoSize,
|
|
913
|
+
mode: PhotoMode = .photo,
|
|
803
914
|
exposureTimeNs: Double?,
|
|
804
915
|
durationMs: Int
|
|
805
916
|
) async throws -> CameraStatusEvent {
|
|
@@ -811,6 +922,7 @@ public final class MentraBluetoothSDK {
|
|
|
811
922
|
try DeviceManager.shared.warmUpCamera(
|
|
812
923
|
requestId: effectiveRequestId,
|
|
813
924
|
size: size,
|
|
925
|
+
mode: mode,
|
|
814
926
|
exposureTimeNs: exposureTimeNs,
|
|
815
927
|
durationMs: durationMs
|
|
816
928
|
)
|
|
@@ -823,6 +935,15 @@ public final class MentraBluetoothSDK {
|
|
|
823
935
|
}
|
|
824
936
|
}
|
|
825
937
|
|
|
938
|
+
public func stopCameraWarmUp(requestId: String) throws {
|
|
939
|
+
guard let effectiveRequestId = nonBlankRequestId(requestId) else {
|
|
940
|
+
throw BluetoothSdkError(
|
|
941
|
+
code: "invalid_request", message: "A warm-up request ID is required."
|
|
942
|
+
)
|
|
943
|
+
}
|
|
944
|
+
try DeviceManager.shared.stopCameraWarmUp(requestId: effectiveRequestId)
|
|
945
|
+
}
|
|
946
|
+
|
|
826
947
|
public func queryGalleryStatus() async throws -> GalleryStatusEvent {
|
|
827
948
|
if pendingGalleryStatus != nil {
|
|
828
949
|
throw BluetoothSdkError(
|
|
@@ -860,7 +981,12 @@ public final class MentraBluetoothSDK {
|
|
|
860
981
|
let streamId = stringValue(values, "streamId").flatMap { $0.isEmpty ? nil : $0 } ?? "sdk-\(UUID().uuidString)"
|
|
861
982
|
values["streamId"] = streamId
|
|
862
983
|
let pending = PendingResponse<StreamStatusEvent>(operation: "start stream \(streamId)")
|
|
863
|
-
|
|
984
|
+
// seq assignment through the BLE hand-off must stay await-free so this
|
|
985
|
+
// MainActor turn is one critical section: rejectPreemptedStreamStarts
|
|
986
|
+
// only works if seq order equals the order the commands reach the
|
|
987
|
+
// glasses' FIFO.
|
|
988
|
+
streamStartSeq += 1
|
|
989
|
+
pendingStreamStarts[streamId] = PendingStreamStart(seq: streamStartSeq, pending: pending)
|
|
864
990
|
stopStreamKeepAliveMonitor()
|
|
865
991
|
DeviceManager.shared.startStream(values)
|
|
866
992
|
do {
|
|
@@ -913,7 +1039,13 @@ public final class MentraBluetoothSDK {
|
|
|
913
1039
|
)
|
|
914
1040
|
}
|
|
915
1041
|
let pending = PendingResponse<StreamStatusEvent>(operation: "stop stream")
|
|
916
|
-
|
|
1042
|
+
// The seq draw and the BLE hand-off stay in this single await-free
|
|
1043
|
+
// MainActor turn, sharing startStream's ordering critical section:
|
|
1044
|
+
// the stop takes its own slot in the send order, so its ack can
|
|
1045
|
+
// separate the pending starts the glasses consumed before it (lower
|
|
1046
|
+
// seq) from starts sent after it (higher seq).
|
|
1047
|
+
streamStartSeq += 1
|
|
1048
|
+
pendingStreamStop = (streamId: activeStreamKeepAlive?.streamId, seq: streamStartSeq, pending: pending)
|
|
917
1049
|
stopStreamKeepAliveMonitor()
|
|
918
1050
|
DeviceManager.shared.stopStream()
|
|
919
1051
|
do {
|
|
@@ -1404,14 +1536,51 @@ public final class MentraBluetoothSDK {
|
|
|
1404
1536
|
}
|
|
1405
1537
|
|
|
1406
1538
|
private func handleStreamStatusForRequests(_ event: StreamStatusEvent) {
|
|
1407
|
-
if let (streamId,
|
|
1539
|
+
if let (streamId, start) = matchingStreamStart(for: event) {
|
|
1540
|
+
// Preemption is only proven by statuses the start path emits after
|
|
1541
|
+
// the glasses' stopAllServices has run: initializing, reconnecting,
|
|
1542
|
+
// reconnected, streaming. An id-stamped `error` can be a
|
|
1543
|
+
// command-level preflight rejection (#3488 firmware) emitted
|
|
1544
|
+
// synchronously before stopAllServices, and `stopped` is a
|
|
1545
|
+
// stop-ack for a previous stream — treating either as a winner
|
|
1546
|
+
// could reject an older start that is still healthy.
|
|
1547
|
+
if let eventStreamId = event.streamId, !eventStreamId.isEmpty {
|
|
1548
|
+
switch event.state {
|
|
1549
|
+
case .initializing, .reconnecting, .reconnected, .streaming:
|
|
1550
|
+
rejectPreemptedStreamStarts(winnerSeq: start.seq)
|
|
1551
|
+
default:
|
|
1552
|
+
break
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1408
1555
|
switch event.state {
|
|
1409
|
-
|
|
1556
|
+
// `reconnected` also proves the stream is live: when an async start
|
|
1557
|
+
// failure triggers the glasses' publisher retry, a successful retry
|
|
1558
|
+
// reports `reconnected` instead of `streaming`.
|
|
1559
|
+
case .streaming, .reconnected:
|
|
1410
1560
|
pendingStreamStarts.removeValue(forKey: streamId)
|
|
1411
|
-
pending.resolve(event)
|
|
1412
|
-
case .
|
|
1561
|
+
start.pending.resolve(event)
|
|
1562
|
+
case .reconnectFailed, .stopped:
|
|
1413
1563
|
pendingStreamStarts.removeValue(forKey: streamId)
|
|
1414
|
-
pending.reject(streamStatusError(event, code: "stream_start_failed"))
|
|
1564
|
+
start.pending.reject(streamStatusError(start.lastError ?? event, code: "stream_start_failed"))
|
|
1565
|
+
case .error:
|
|
1566
|
+
if let eventStreamId = event.streamId, !eventStreamId.isEmpty, event.willRetry == true {
|
|
1567
|
+
// The glasses flag errors their publisher will retry with
|
|
1568
|
+
// `willRetry` (emitting side lands in PR #3488), so keep the
|
|
1569
|
+
// start pending for the retry's verdict — `reconnected` or
|
|
1570
|
+
// `streaming` on success, `reconnect_failed` or the
|
|
1571
|
+
// streamId-less `stopped` wind-down (which reports these
|
|
1572
|
+
// stashed details) on failure.
|
|
1573
|
+
start.lastError = event
|
|
1574
|
+
} else {
|
|
1575
|
+
// An id-less error is the glasses' command-level rejection
|
|
1576
|
+
// (missing URL, low battery, no WiFi) emitted before any
|
|
1577
|
+
// publisher starts; an id-carrying error without `willRetry`
|
|
1578
|
+
// is terminal (`camera_busy` emits an error and nothing else).
|
|
1579
|
+
// Old firmware never sends `willRetry`, so its errors always
|
|
1580
|
+
// fail fast here — the shipped pre-#3487 Android behavior.
|
|
1581
|
+
pendingStreamStarts.removeValue(forKey: streamId)
|
|
1582
|
+
start.pending.reject(streamStatusError(event, code: "stream_start_failed"))
|
|
1583
|
+
}
|
|
1415
1584
|
default:
|
|
1416
1585
|
break
|
|
1417
1586
|
}
|
|
@@ -1422,6 +1591,7 @@ public final class MentraBluetoothSDK {
|
|
|
1422
1591
|
if pendingStreamStop?.pending === stop.pending {
|
|
1423
1592
|
pendingStreamStop = nil
|
|
1424
1593
|
}
|
|
1594
|
+
rejectStreamStartsSuperseded(byStopSeq: stop.seq)
|
|
1425
1595
|
stop.pending.resolve(stoppedStreamEvent(from: event, fallbackStreamId: stop.streamId))
|
|
1426
1596
|
} else if event.state == .error || event.state == .reconnectFailed {
|
|
1427
1597
|
if pendingStreamStop?.pending === stop.pending {
|
|
@@ -1432,12 +1602,73 @@ public final class MentraBluetoothSDK {
|
|
|
1432
1602
|
}
|
|
1433
1603
|
}
|
|
1434
1604
|
|
|
1435
|
-
|
|
1605
|
+
// The glasses process BLE commands FIFO and every start_stream begins by
|
|
1606
|
+
// stopping whatever runs, so an id-carrying status proving start X's
|
|
1607
|
+
// start path ran on the glasses also proves every lower-seq start has
|
|
1608
|
+
// already been preempted. Their only verdict on current firmware is a
|
|
1609
|
+
// streamId-less stopped, which the id-less heuristic deliberately
|
|
1610
|
+
// ignores — without this they would run out the 30s timeout instead of
|
|
1611
|
+
// failing fast.
|
|
1612
|
+
private func rejectPreemptedStreamStarts(winnerSeq: Int) {
|
|
1613
|
+
for (streamId, start) in pendingStreamStarts where start.seq < winnerSeq {
|
|
1614
|
+
pendingStreamStarts.removeValue(forKey: streamId)
|
|
1615
|
+
start.pending.reject(
|
|
1616
|
+
BluetoothSdkError(
|
|
1617
|
+
code: "stream_preempted",
|
|
1618
|
+
message: "start stream \(streamId) was preempted by a newer start_stream; "
|
|
1619
|
+
+ "the glasses run a single stream and the last request wins."
|
|
1620
|
+
)
|
|
1621
|
+
)
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
// The glasses process BLE commands FIFO, so this stop's ack also settles
|
|
1626
|
+
// every start sent before it: whatever those starts brought up (or would
|
|
1627
|
+
// have brought up) has been stopped, and no further status will arrive
|
|
1628
|
+
// for them. Without this they would run out the 30s start timeout.
|
|
1629
|
+
// Starts sent after the stop keep waiting for their own statuses.
|
|
1630
|
+
private func rejectStreamStartsSuperseded(byStopSeq stopSeq: Int) {
|
|
1631
|
+
for (streamId, start) in pendingStreamStarts where start.seq < stopSeq {
|
|
1632
|
+
pendingStreamStarts.removeValue(forKey: streamId)
|
|
1633
|
+
start.pending.reject(
|
|
1634
|
+
BluetoothSdkError(
|
|
1635
|
+
code: "stream_stopped",
|
|
1636
|
+
message: "start stream \(streamId) was superseded by a stop_stream issued after it."
|
|
1637
|
+
)
|
|
1638
|
+
)
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
private func matchingStreamStart(for event: StreamStatusEvent) -> (String, PendingStreamStart)? {
|
|
1436
1643
|
if let streamId = event.streamId, !streamId.isEmpty {
|
|
1437
|
-
guard let
|
|
1438
|
-
return (streamId,
|
|
1644
|
+
guard let start = pendingStreamStarts[streamId] else { return nil }
|
|
1645
|
+
return (streamId, start)
|
|
1646
|
+
}
|
|
1647
|
+
if event.state == .error {
|
|
1648
|
+
// An id-less error is a command-level preflight rejection (missing
|
|
1649
|
+
// URL, low battery, no WiFi) emitted synchronously, while lifecycle
|
|
1650
|
+
// statuses arrive async — so with overlapping starts the wire is
|
|
1651
|
+
// genuinely ambiguous about which start it answers. Attribute it to
|
|
1652
|
+
// the newest pending start: the common case is a later start
|
|
1653
|
+
// failing preflight while an earlier one is already emitting
|
|
1654
|
+
// lifecycle statuses, and at worst this swaps which start carries
|
|
1655
|
+
// the error instead of letting both time out.
|
|
1656
|
+
guard let entry = pendingStreamStarts.max(by: { $0.value.seq < $1.value.seq }) else { return nil }
|
|
1657
|
+
return (entry.key, entry.value)
|
|
1439
1658
|
}
|
|
1440
1659
|
if pendingStreamStarts.count == 1, let entry = pendingStreamStarts.first {
|
|
1660
|
+
// A streamId-less STOPPED is the glasses' stop-ack for a PREVIOUS
|
|
1661
|
+
// stream (their stop ack carries no streamId), not a verdict on the
|
|
1662
|
+
// pending start — a start_stream that replaces a running publisher
|
|
1663
|
+
// emits exactly this sequence (stopped -> initializing -> streaming).
|
|
1664
|
+
// Attributing it here would reject a start that is about to succeed.
|
|
1665
|
+
// After a stashed `willRetry` error for the pending start, though,
|
|
1666
|
+
// the stopped is the retrying publisher's wind-down (the stop-ack
|
|
1667
|
+
// always precedes any status for the new start), so it is that
|
|
1668
|
+
// start's verdict.
|
|
1669
|
+
if event.state == .stopped, entry.value.lastError == nil {
|
|
1670
|
+
return nil
|
|
1671
|
+
}
|
|
1441
1672
|
return (entry.key, entry.value)
|
|
1442
1673
|
}
|
|
1443
1674
|
return nil
|
|
@@ -1597,6 +1828,28 @@ public final class MentraBluetoothSDK {
|
|
|
1597
1828
|
request.pending.resolve(results)
|
|
1598
1829
|
}
|
|
1599
1830
|
|
|
1831
|
+
// scanId-echoing glasses: results for another scan are ignored instead of
|
|
1832
|
+
// resolving the pending request, and matching chunks accumulate until the
|
|
1833
|
+
// glasses flag the scan complete.
|
|
1834
|
+
private func handleCorrelatedWifiScanChunk(
|
|
1835
|
+
scanId: String,
|
|
1836
|
+
results: [WifiScanResult],
|
|
1837
|
+
scanComplete: Bool
|
|
1838
|
+
) {
|
|
1839
|
+
guard let request = pendingWifiScan, request.scanId == scanId else { return }
|
|
1840
|
+
for network in results where !request.accumulated.contains(where: { $0.ssid == network.ssid }) {
|
|
1841
|
+
request.accumulated.append(network)
|
|
1842
|
+
}
|
|
1843
|
+
if !request.accumulated.isEmpty {
|
|
1844
|
+
request.latestResults = request.accumulated
|
|
1845
|
+
}
|
|
1846
|
+
guard scanComplete else { return }
|
|
1847
|
+
if pendingWifiScan === request {
|
|
1848
|
+
pendingWifiScan = nil
|
|
1849
|
+
}
|
|
1850
|
+
request.pending.resolve(request.accumulated)
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1600
1853
|
private func updateWifiScanLatestResults(_ results: [WifiScanResult]) {
|
|
1601
1854
|
guard !results.isEmpty else { return }
|
|
1602
1855
|
pendingWifiScan?.latestResults = results
|
|
@@ -1604,6 +1857,24 @@ public final class MentraBluetoothSDK {
|
|
|
1604
1857
|
|
|
1605
1858
|
private func handleWifiStatusForRequests(_ event: WifiStatusEvent) {
|
|
1606
1859
|
guard let request = pendingWifiStatus else { return }
|
|
1860
|
+
// A wifi_status carrying the explicit error field is the glasses' failure
|
|
1861
|
+
// verdict for the in-flight connect: reject now instead of running out the
|
|
1862
|
+
// request timeout. Only the error field counts as failure — the glasses'
|
|
1863
|
+
// connect sequence emits a debounced bare connected=false ~1-2s after
|
|
1864
|
+
// credentials while association is still in progress, and rejecting on that
|
|
1865
|
+
// would kill every connect attempt early.
|
|
1866
|
+
if request.operation == .connect, let error = event.error {
|
|
1867
|
+
if pendingWifiStatus === request {
|
|
1868
|
+
pendingWifiStatus = nil
|
|
1869
|
+
}
|
|
1870
|
+
request.pending.reject(
|
|
1871
|
+
BluetoothSdkError(
|
|
1872
|
+
code: error,
|
|
1873
|
+
message: "Glasses failed to join \"\(request.ssid)\": \(error)"
|
|
1874
|
+
)
|
|
1875
|
+
)
|
|
1876
|
+
return
|
|
1877
|
+
}
|
|
1607
1878
|
guard wifiStatusMatches(event.status, request: request) else { return }
|
|
1608
1879
|
if pendingWifiStatus === request {
|
|
1609
1880
|
pendingWifiStatus = nil
|
|
@@ -1790,9 +2061,15 @@ public final class MentraBluetoothSDK {
|
|
|
1790
2061
|
let networks = (data["networks"] as? [[String: Any]])?.map(WifiScanResult.init(values:)) ?? []
|
|
1791
2062
|
let hasCompletionFlag = data.keys.contains("scanComplete") || data.keys.contains("scan_complete")
|
|
1792
2063
|
let scanComplete = data["scanComplete"] as? Bool ?? data["scan_complete"] as? Bool ?? false
|
|
1793
|
-
|
|
1794
|
-
if
|
|
1795
|
-
|
|
2064
|
+
let scanId = (data["scanId"] as? String).flatMap { $0.isEmpty ? nil : $0 }
|
|
2065
|
+
if let scanId {
|
|
2066
|
+
handleCorrelatedWifiScanChunk(scanId: scanId, results: networks, scanComplete: scanComplete)
|
|
2067
|
+
} else {
|
|
2068
|
+
// Old firmware: no correlation id, keep the pre-scanId heuristics.
|
|
2069
|
+
updateWifiScanLatestResults(networks)
|
|
2070
|
+
if scanComplete || !hasCompletionFlag {
|
|
2071
|
+
handleWifiScanResultsForRequests(networks)
|
|
2072
|
+
}
|
|
1796
2073
|
}
|
|
1797
2074
|
delegate?.mentraBluetoothSDK(self, didReceive: .raw(name: "wifi_scan_result", values: data))
|
|
1798
2075
|
case "hotspot_error":
|