@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.
Files changed (73) hide show
  1. package/README.md +5 -1
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +127 -24
  3. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +65 -14
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +46 -3
  5. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +1 -1
  6. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +369 -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 +3 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +1 -1
  10. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +16 -9
  11. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Mach1.kt +1 -1
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +183 -30
  13. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +1 -1
  14. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +1 -1
  15. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +4 -2
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +1 -1
  17. package/android/src/main/java/com/mentra/bluetoothsdk/status/WifiHotspotStatus.kt +19 -3
  18. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +15 -2
  19. package/android/src/main/java/com/mentra/bluetoothsdk/tts/TTSTools.kt +115 -47
  20. package/android/src/main/java/com/mentra/bluetoothsdk/utils/audio/PcmStreamPlayer.kt +328 -0
  21. package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +15 -0
  22. package/build/BluetoothSdk.types.d.ts +46 -1
  23. package/build/BluetoothSdk.types.d.ts.map +1 -1
  24. package/build/BluetoothSdk.types.js.map +1 -1
  25. package/build/_internal.d.ts +2 -0
  26. package/build/_internal.d.ts.map +1 -1
  27. package/build/_internal.js +2 -0
  28. package/build/_internal.js.map +1 -1
  29. package/build/_private/BluetoothSdkModule.d.ts +23 -2
  30. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  31. package/build/_private/BluetoothSdkModule.js +20 -0
  32. package/build/_private/BluetoothSdkModule.js.map +1 -1
  33. package/build/_private/MentraLocalNetworkModule.d.ts +37 -0
  34. package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -0
  35. package/build/_private/MentraLocalNetworkModule.js +3 -0
  36. package/build/_private/MentraLocalNetworkModule.js.map +1 -0
  37. package/build/_private/cameraRequestPayload.d.ts.map +1 -1
  38. package/build/_private/cameraRequestPayload.js +1 -0
  39. package/build/_private/cameraRequestPayload.js.map +1 -1
  40. package/build/_private/photoRequestPayload.d.ts.map +1 -1
  41. package/build/_private/photoRequestPayload.js +1 -0
  42. package/build/_private/photoRequestPayload.js.map +1 -1
  43. package/build/index.d.ts.map +1 -1
  44. package/build/index.js +4 -0
  45. package/build/index.js.map +1 -1
  46. package/expo-module.config.json +1 -0
  47. package/ios/BluetoothSdkModule.swift +72 -0
  48. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  49. package/ios/Source/Bridge.swift +72 -18
  50. package/ios/Source/DeviceManager.swift +51 -3
  51. package/ios/Source/DeviceStore.swift +1 -1
  52. package/ios/Source/MentraBluetoothSDK.swift +289 -25
  53. package/ios/Source/audio/PcmStreamPlayer.swift +410 -0
  54. package/ios/Source/camera/CameraModels.swift +8 -3
  55. package/ios/Source/sgcs/G1.swift +1 -1
  56. package/ios/Source/sgcs/G2.swift +19 -7
  57. package/ios/Source/sgcs/Mach1.swift +1 -1
  58. package/ios/Source/sgcs/MentraLive.swift +318 -37
  59. package/ios/Source/sgcs/MentraNex.swift +1 -1
  60. package/ios/Source/sgcs/Nimo.swift +1 -1
  61. package/ios/Source/sgcs/SGCManager.swift +5 -2
  62. package/ios/Source/sgcs/Simulated.swift +1 -1
  63. package/ios/Source/status/WifiHotspotStatus.swift +21 -2
  64. package/ios/Source/streaming/StreamModels.swift +9 -0
  65. package/ios/Source/tts/TTSTools.swift +1 -1
  66. package/package.json +1 -2
  67. package/src/BluetoothSdk.types.ts +49 -0
  68. package/src/_internal.ts +2 -0
  69. package/src/_private/BluetoothSdkModule.ts +64 -13
  70. package/src/_private/MentraLocalNetworkModule.ts +50 -0
  71. package/src/_private/cameraRequestPayload.ts +1 -0
  72. package/src/_private/photoRequestPayload.ts +2 -3
  73. 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: 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
@@ -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
- 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)
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
- 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)
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
- 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)
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, 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
+ }
1415
1555
  switch event.state {
1416
- 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:
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
- // The glasses publisher automatically retries transient transport
1424
- // errors. Keep the start pending so a subsequent `streaming`
1425
- // status can resolve it; `reconnectFailed` or the request timeout
1426
- // still terminates a publisher that cannot recover.
1427
- break
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
- 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)? {
1449
1643
  if let streamId = event.streamId, !streamId.isEmpty {
1450
- guard let pending = pendingStreamStarts[streamId] else { return nil }
1451
- 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)
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
- updateWifiScanLatestResults(networks)
1807
- if scanComplete || !hasCompletionFlag {
1808
- 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
+ }
1809
2073
  }
1810
2074
  delegate?.mentraBluetoothSDK(self, didReceive: .raw(name: "wifi_scan_result", values: data))
1811
2075
  case "hotspot_error":