@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.
Files changed (83) hide show
  1. package/README.md +6 -2
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +239 -52
  3. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +65 -14
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +47 -4
  5. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +6 -1
  6. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +381 -97
  7. package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +333 -0
  8. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +16 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +2 -2
  10. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizer.kt +38 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +16 -9
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Mach1.kt +1 -1
  13. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +293 -174
  14. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +1 -1
  15. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +1 -1
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +4 -2
  17. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +1 -1
  18. package/android/src/main/java/com/mentra/bluetoothsdk/status/WifiHotspotStatus.kt +19 -3
  19. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +15 -2
  20. package/android/src/main/java/com/mentra/bluetoothsdk/tts/TTSTools.kt +115 -47
  21. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +2 -3
  22. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadService.java +65 -8
  23. package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +6 -5
  24. package/android/src/main/java/com/mentra/bluetoothsdk/utils/audio/PcmStreamPlayer.kt +328 -0
  25. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkExceptionTest.kt +18 -0
  26. package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +31 -0
  27. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizerTest.kt +31 -0
  28. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadServiceTest.java +9 -0
  29. package/build/BluetoothSdk.types.d.ts +71 -1
  30. package/build/BluetoothSdk.types.d.ts.map +1 -1
  31. package/build/BluetoothSdk.types.js.map +1 -1
  32. package/build/_internal.d.ts +2 -0
  33. package/build/_internal.d.ts.map +1 -1
  34. package/build/_internal.js +2 -0
  35. package/build/_internal.js.map +1 -1
  36. package/build/_private/BluetoothSdkModule.d.ts +27 -2
  37. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  38. package/build/_private/BluetoothSdkModule.js +20 -0
  39. package/build/_private/BluetoothSdkModule.js.map +1 -1
  40. package/build/_private/MentraLocalNetworkModule.d.ts +37 -0
  41. package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -0
  42. package/build/_private/MentraLocalNetworkModule.js +3 -0
  43. package/build/_private/MentraLocalNetworkModule.js.map +1 -0
  44. package/build/_private/cameraRequestPayload.d.ts.map +1 -1
  45. package/build/_private/cameraRequestPayload.js +1 -0
  46. package/build/_private/cameraRequestPayload.js.map +1 -1
  47. package/build/_private/photoRequestPayload.d.ts.map +1 -1
  48. package/build/_private/photoRequestPayload.js +2 -0
  49. package/build/_private/photoRequestPayload.js.map +1 -1
  50. package/build/index.d.ts +1 -1
  51. package/build/index.d.ts.map +1 -1
  52. package/build/index.js +9 -0
  53. package/build/index.js.map +1 -1
  54. package/expo-module.config.json +1 -0
  55. package/ios/BluetoothSdkModule.swift +72 -0
  56. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  57. package/ios/Source/Bridge.swift +72 -18
  58. package/ios/Source/DeviceManager.swift +52 -3
  59. package/ios/Source/DeviceStore.swift +6 -1
  60. package/ios/Source/MentraBluetoothSDK.swift +298 -21
  61. package/ios/Source/audio/PcmStreamPlayer.swift +410 -0
  62. package/ios/Source/camera/CameraModels.swift +22 -3
  63. package/ios/Source/sgcs/G1.swift +2 -2
  64. package/ios/Source/sgcs/G2.swift +19 -7
  65. package/ios/Source/sgcs/Mach1.swift +1 -1
  66. package/ios/Source/sgcs/MentraLive.swift +456 -52
  67. package/ios/Source/sgcs/MentraLiveL2capChannel.swift +183 -0
  68. package/ios/Source/sgcs/MentraNex.swift +1 -1
  69. package/ios/Source/sgcs/Nimo.swift +1 -1
  70. package/ios/Source/sgcs/SGCManager.swift +5 -2
  71. package/ios/Source/sgcs/Simulated.swift +1 -1
  72. package/ios/Source/status/WifiHotspotStatus.swift +21 -2
  73. package/ios/Source/streaming/StreamModels.swift +9 -0
  74. package/ios/Source/tts/TTSTools.swift +1 -1
  75. package/ios/Source/utils/G1Text.swift +30 -1
  76. package/package.json +1 -2
  77. package/src/BluetoothSdk.types.ts +75 -0
  78. package/src/_internal.ts +2 -0
  79. package/src/_private/BluetoothSdkModule.ts +68 -13
  80. package/src/_private/MentraLocalNetworkModule.ts +50 -0
  81. package/src/_private/cameraRequestPayload.ts +1 -0
  82. package/src/_private/photoRequestPayload.ts +3 -3
  83. 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: PendingResponse<StreamStatusEvent>] = [:]
179
- private var pendingStreamStop: (streamId: String?, pending: PendingResponse<StreamStatusEvent>)?
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
- Task { @MainActor [weak self] in
200
- self?.dispatchBridgeEvent(eventName, data)
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
- DeviceManager.shared.requestWifiScan()
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
- pendingStreamStarts[streamId] = pending
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
- pendingStreamStop = (streamId: activeStreamKeepAlive?.streamId, pending: pending)
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, pending) = matchingStreamStart(for: event) {
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
- case .streaming:
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 .error, .reconnectFailed, .stopped:
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
- private func matchingStreamStart(for event: StreamStatusEvent) -> (String, PendingResponse<StreamStatusEvent>)? {
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 pending = pendingStreamStarts[streamId] else { return nil }
1438
- return (streamId, pending)
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
- updateWifiScanLatestResults(networks)
1794
- if scanComplete || !hasCompletionFlag {
1795
- handleWifiScanResultsForRequests(networks)
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":