@mentra/bluetooth-sdk 0.1.20 → 0.1.21-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +127 -24
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +65 -14
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +46 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +369 -97
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +333 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +3 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +16 -9
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Mach1.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +183 -30
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +4 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/status/WifiHotspotStatus.kt +19 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +15 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/tts/TTSTools.kt +115 -47
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/audio/PcmStreamPlayer.kt +328 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +15 -0
- package/build/BluetoothSdk.types.d.ts +46 -1
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_internal.d.ts +2 -0
- package/build/_internal.d.ts.map +1 -1
- package/build/_internal.js +2 -0
- package/build/_internal.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +23 -2
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +20 -0
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/_private/MentraLocalNetworkModule.d.ts +37 -0
- package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -0
- package/build/_private/MentraLocalNetworkModule.js +3 -0
- package/build/_private/MentraLocalNetworkModule.js.map +1 -0
- package/build/_private/cameraRequestPayload.d.ts.map +1 -1
- package/build/_private/cameraRequestPayload.js +1 -0
- package/build/_private/cameraRequestPayload.js.map +1 -1
- package/build/_private/photoRequestPayload.d.ts.map +1 -1
- package/build/_private/photoRequestPayload.js +1 -0
- package/build/_private/photoRequestPayload.js.map +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +4 -0
- package/build/index.js.map +1 -1
- package/expo-module.config.json +1 -0
- package/ios/BluetoothSdkModule.swift +72 -0
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/Bridge.swift +72 -18
- package/ios/Source/DeviceManager.swift +51 -3
- package/ios/Source/DeviceStore.swift +1 -1
- package/ios/Source/MentraBluetoothSDK.swift +289 -25
- package/ios/Source/audio/PcmStreamPlayer.swift +410 -0
- package/ios/Source/camera/CameraModels.swift +8 -3
- package/ios/Source/sgcs/G1.swift +1 -1
- package/ios/Source/sgcs/G2.swift +19 -7
- package/ios/Source/sgcs/Mach1.swift +1 -1
- package/ios/Source/sgcs/MentraLive.swift +318 -37
- package/ios/Source/sgcs/MentraNex.swift +1 -1
- package/ios/Source/sgcs/Nimo.swift +1 -1
- package/ios/Source/sgcs/SGCManager.swift +5 -2
- package/ios/Source/sgcs/Simulated.swift +1 -1
- package/ios/Source/status/WifiHotspotStatus.swift +21 -2
- package/ios/Source/streaming/StreamModels.swift +9 -0
- package/ios/Source/tts/TTSTools.swift +1 -1
- package/package.json +1 -2
- package/src/BluetoothSdk.types.ts +49 -0
- package/src/_internal.ts +2 -0
- package/src/_private/BluetoothSdkModule.ts +64 -13
- package/src/_private/MentraLocalNetworkModule.ts +50 -0
- package/src/_private/cameraRequestPayload.ts +1 -0
- package/src/_private/photoRequestPayload.ts +2 -3
- package/src/index.ts +4 -0
|
@@ -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
|
|
@@ -605,6 +652,59 @@ public final class MentraBluetoothSDK {
|
|
|
605
652
|
return result
|
|
606
653
|
}
|
|
607
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
|
+
|
|
608
708
|
public func setCameraTuningConfig(anrOn: Bool, gainOn: Bool) async throws -> SettingsAckEvent {
|
|
609
709
|
return try await performSettingsCommand(
|
|
610
710
|
setting: "camera_tuning",
|
|
@@ -679,9 +779,12 @@ public final class MentraBluetoothSDK {
|
|
|
679
779
|
return try await existing.value
|
|
680
780
|
}
|
|
681
781
|
let pending = PendingResponse<[WifiScanResult]>(operation: "WiFi scan request")
|
|
682
|
-
let request = PendingWifiScan(pending: pending)
|
|
782
|
+
let request = PendingWifiScan(pending: pending, scanId: "scan-\(UUID().uuidString)")
|
|
683
783
|
pendingWifiScan = request
|
|
684
|
-
|
|
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)
|
|
685
788
|
let task = Task { @MainActor [weak self] () async throws -> [WifiScanResult] in
|
|
686
789
|
defer {
|
|
687
790
|
if let self {
|
|
@@ -795,7 +898,7 @@ public final class MentraBluetoothSDK {
|
|
|
795
898
|
pendingPhotoRequests[routedRequest.requestId] = pending
|
|
796
899
|
DeviceManager.shared.requestPhoto(routedRequest)
|
|
797
900
|
do {
|
|
798
|
-
let event = try await pending.wait()
|
|
901
|
+
let event = try await pending.wait(timeoutMs: MentraBluetoothSDK.photoRequestTimeoutMs)
|
|
799
902
|
pendingPhotoRequests.removeValue(forKey: routedRequest.requestId)
|
|
800
903
|
return event
|
|
801
904
|
} catch {
|
|
@@ -807,6 +910,7 @@ public final class MentraBluetoothSDK {
|
|
|
807
910
|
public func warmUpCamera(
|
|
808
911
|
requestId: String? = nil,
|
|
809
912
|
size: PhotoSize,
|
|
913
|
+
mode: PhotoMode = .photo,
|
|
810
914
|
exposureTimeNs: Double?,
|
|
811
915
|
durationMs: Int
|
|
812
916
|
) async throws -> CameraStatusEvent {
|
|
@@ -818,6 +922,7 @@ public final class MentraBluetoothSDK {
|
|
|
818
922
|
try DeviceManager.shared.warmUpCamera(
|
|
819
923
|
requestId: effectiveRequestId,
|
|
820
924
|
size: size,
|
|
925
|
+
mode: mode,
|
|
821
926
|
exposureTimeNs: exposureTimeNs,
|
|
822
927
|
durationMs: durationMs
|
|
823
928
|
)
|
|
@@ -830,6 +935,15 @@ public final class MentraBluetoothSDK {
|
|
|
830
935
|
}
|
|
831
936
|
}
|
|
832
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
|
+
|
|
833
947
|
public func queryGalleryStatus() async throws -> GalleryStatusEvent {
|
|
834
948
|
if pendingGalleryStatus != nil {
|
|
835
949
|
throw BluetoothSdkError(
|
|
@@ -867,7 +981,12 @@ public final class MentraBluetoothSDK {
|
|
|
867
981
|
let streamId = stringValue(values, "streamId").flatMap { $0.isEmpty ? nil : $0 } ?? "sdk-\(UUID().uuidString)"
|
|
868
982
|
values["streamId"] = streamId
|
|
869
983
|
let pending = PendingResponse<StreamStatusEvent>(operation: "start stream \(streamId)")
|
|
870
|
-
|
|
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)
|
|
871
990
|
stopStreamKeepAliveMonitor()
|
|
872
991
|
DeviceManager.shared.startStream(values)
|
|
873
992
|
do {
|
|
@@ -920,7 +1039,13 @@ public final class MentraBluetoothSDK {
|
|
|
920
1039
|
)
|
|
921
1040
|
}
|
|
922
1041
|
let pending = PendingResponse<StreamStatusEvent>(operation: "stop stream")
|
|
923
|
-
|
|
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)
|
|
924
1049
|
stopStreamKeepAliveMonitor()
|
|
925
1050
|
DeviceManager.shared.stopStream()
|
|
926
1051
|
do {
|
|
@@ -1411,20 +1536,51 @@ public final class MentraBluetoothSDK {
|
|
|
1411
1536
|
}
|
|
1412
1537
|
|
|
1413
1538
|
private func handleStreamStatusForRequests(_ event: StreamStatusEvent) {
|
|
1414
|
-
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
|
+
}
|
|
1415
1555
|
switch event.state {
|
|
1416
|
-
|
|
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:
|
|
1417
1560
|
pendingStreamStarts.removeValue(forKey: streamId)
|
|
1418
|
-
pending.resolve(event)
|
|
1561
|
+
start.pending.resolve(event)
|
|
1419
1562
|
case .reconnectFailed, .stopped:
|
|
1420
1563
|
pendingStreamStarts.removeValue(forKey: streamId)
|
|
1421
|
-
pending.reject(streamStatusError(event, code: "stream_start_failed"))
|
|
1564
|
+
start.pending.reject(streamStatusError(start.lastError ?? event, code: "stream_start_failed"))
|
|
1422
1565
|
case .error:
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
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
|
+
}
|
|
1428
1584
|
default:
|
|
1429
1585
|
break
|
|
1430
1586
|
}
|
|
@@ -1435,6 +1591,7 @@ public final class MentraBluetoothSDK {
|
|
|
1435
1591
|
if pendingStreamStop?.pending === stop.pending {
|
|
1436
1592
|
pendingStreamStop = nil
|
|
1437
1593
|
}
|
|
1594
|
+
rejectStreamStartsSuperseded(byStopSeq: stop.seq)
|
|
1438
1595
|
stop.pending.resolve(stoppedStreamEvent(from: event, fallbackStreamId: stop.streamId))
|
|
1439
1596
|
} else if event.state == .error || event.state == .reconnectFailed {
|
|
1440
1597
|
if pendingStreamStop?.pending === stop.pending {
|
|
@@ -1445,12 +1602,73 @@ public final class MentraBluetoothSDK {
|
|
|
1445
1602
|
}
|
|
1446
1603
|
}
|
|
1447
1604
|
|
|
1448
|
-
|
|
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)? {
|
|
1449
1643
|
if let streamId = event.streamId, !streamId.isEmpty {
|
|
1450
|
-
guard let
|
|
1451
|
-
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)
|
|
1452
1658
|
}
|
|
1453
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
|
+
}
|
|
1454
1672
|
return (entry.key, entry.value)
|
|
1455
1673
|
}
|
|
1456
1674
|
return nil
|
|
@@ -1610,6 +1828,28 @@ public final class MentraBluetoothSDK {
|
|
|
1610
1828
|
request.pending.resolve(results)
|
|
1611
1829
|
}
|
|
1612
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
|
+
|
|
1613
1853
|
private func updateWifiScanLatestResults(_ results: [WifiScanResult]) {
|
|
1614
1854
|
guard !results.isEmpty else { return }
|
|
1615
1855
|
pendingWifiScan?.latestResults = results
|
|
@@ -1617,6 +1857,24 @@ public final class MentraBluetoothSDK {
|
|
|
1617
1857
|
|
|
1618
1858
|
private func handleWifiStatusForRequests(_ event: WifiStatusEvent) {
|
|
1619
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
|
+
}
|
|
1620
1878
|
guard wifiStatusMatches(event.status, request: request) else { return }
|
|
1621
1879
|
if pendingWifiStatus === request {
|
|
1622
1880
|
pendingWifiStatus = nil
|
|
@@ -1803,9 +2061,15 @@ public final class MentraBluetoothSDK {
|
|
|
1803
2061
|
let networks = (data["networks"] as? [[String: Any]])?.map(WifiScanResult.init(values:)) ?? []
|
|
1804
2062
|
let hasCompletionFlag = data.keys.contains("scanComplete") || data.keys.contains("scan_complete")
|
|
1805
2063
|
let scanComplete = data["scanComplete"] as? Bool ?? data["scan_complete"] as? Bool ?? false
|
|
1806
|
-
|
|
1807
|
-
if
|
|
1808
|
-
|
|
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
|
+
}
|
|
1809
2073
|
}
|
|
1810
2074
|
delegate?.mentraBluetoothSDK(self, didReceive: .raw(name: "wifi_scan_result", values: data))
|
|
1811
2075
|
case "hotspot_error":
|