@mentra/bluetooth-sdk 0.1.20 → 0.1.21-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/README.md +27 -12
  2. package/android/build.gradle +3 -0
  3. package/android/src/main/AndroidManifest.xml +3 -1
  4. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalytics.kt +34 -0
  5. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +1 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +169 -26
  7. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +72 -16
  8. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +127 -8
  9. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +4 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +400 -99
  11. package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +333 -0
  12. package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +43 -3
  13. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +18 -2
  14. package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleTraceLogger.kt +1 -1
  15. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +53 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Ar99.java +2653 -0
  17. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +1 -1
  18. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +16 -9
  19. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Mach1.kt +1 -1
  20. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +342 -42
  21. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +1 -1
  22. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +1 -1
  23. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +7 -2
  24. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +1 -1
  25. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/Ar99OtaManager.java +566 -0
  26. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaByteUtils.java +18 -0
  27. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaCommandConstants.java +35 -0
  28. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaCrc32Util.java +13 -0
  29. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaGattTransport.java +11 -0
  30. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaProtocol.java +92 -0
  31. package/android/src/main/java/com/mentra/bluetoothsdk/status/WifiHotspotStatus.kt +19 -3
  32. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +52 -2
  33. package/android/src/main/java/com/mentra/bluetoothsdk/tts/TTSTools.kt +115 -47
  34. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +7 -0
  35. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +8 -0
  36. package/android/src/main/java/com/mentra/bluetoothsdk/utils/Constants.kt +2 -1
  37. package/android/src/main/java/com/mentra/bluetoothsdk/utils/PhoneAudioMonitor.kt +45 -0
  38. package/android/src/main/java/com/mentra/bluetoothsdk/utils/audio/Ar99OpusPcmDecoder.java +398 -0
  39. package/android/src/main/java/com/mentra/bluetoothsdk/utils/audio/PcmStreamPlayer.kt +328 -0
  40. package/android/src/test/java/com/mentra/bluetoothsdk/OtaManifestDowngradeTest.kt +86 -0
  41. package/android/src/test/java/com/mentra/bluetoothsdk/StreamStatusEventTest.kt +29 -0
  42. package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +45 -0
  43. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/Ar99AdvertisementMatcherTest.java +22 -0
  44. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +20 -0
  45. package/android/src/test/java/com/mentra/bluetoothsdk/utils/PhoneAudioMonitorTest.kt +45 -0
  46. package/build/BluetoothSdk.types.d.ts +100 -22
  47. package/build/BluetoothSdk.types.d.ts.map +1 -1
  48. package/build/BluetoothSdk.types.js +2 -1
  49. package/build/BluetoothSdk.types.js.map +1 -1
  50. package/build/_internal.d.ts +3 -0
  51. package/build/_internal.d.ts.map +1 -1
  52. package/build/_internal.js +3 -0
  53. package/build/_internal.js.map +1 -1
  54. package/build/_private/BluetoothSdkModule.d.ts +31 -4
  55. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  56. package/build/_private/BluetoothSdkModule.js +26 -3
  57. package/build/_private/BluetoothSdkModule.js.map +1 -1
  58. package/build/_private/MentraLocalNetworkModule.d.ts +37 -0
  59. package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -0
  60. package/build/_private/MentraLocalNetworkModule.js +3 -0
  61. package/build/_private/MentraLocalNetworkModule.js.map +1 -0
  62. package/build/_private/cameraRequestPayload.d.ts +1 -1
  63. package/build/_private/cameraRequestPayload.d.ts.map +1 -1
  64. package/build/_private/cameraRequestPayload.js +7 -0
  65. package/build/_private/cameraRequestPayload.js.map +1 -1
  66. package/build/_private/photoRequestPayload.d.ts.map +1 -1
  67. package/build/_private/photoRequestPayload.js +10 -0
  68. package/build/_private/photoRequestPayload.js.map +1 -1
  69. package/build/_private/sdkOtaManifest.d.ts +20 -0
  70. package/build/_private/sdkOtaManifest.d.ts.map +1 -0
  71. package/build/_private/sdkOtaManifest.js +28 -0
  72. package/build/_private/sdkOtaManifest.js.map +1 -0
  73. package/build/index.d.ts +1 -1
  74. package/build/index.d.ts.map +1 -1
  75. package/build/index.js +11 -1
  76. package/build/index.js.map +1 -1
  77. package/expo-module.config.json +1 -0
  78. package/ios/BluetoothSdkModule.swift +113 -2
  79. package/ios/Source/BluetoothSdkDefaults.swift +2 -1
  80. package/ios/Source/Bridge.swift +87 -21
  81. package/ios/Source/DeviceManager.swift +118 -20
  82. package/ios/Source/DeviceStore.swift +4 -0
  83. package/ios/Source/MentraBluetoothSDK.swift +326 -33
  84. package/ios/Source/OtaManifest.swift +47 -4
  85. package/ios/Source/audio/PcmStreamPlayer.swift +410 -0
  86. package/ios/Source/camera/CameraModels.swift +26 -6
  87. package/ios/Source/internal/BleTraceLogger.swift +1 -1
  88. package/ios/Source/internal/BluetoothSdkAnalytics.swift +39 -0
  89. package/ios/Source/sgcs/Ar99.swift +2451 -0
  90. package/ios/Source/sgcs/G1.swift +1 -1
  91. package/ios/Source/sgcs/G2.swift +19 -7
  92. package/ios/Source/sgcs/Mach1.swift +1 -1
  93. package/ios/Source/sgcs/MentraLive.swift +478 -61
  94. package/ios/Source/sgcs/MentraNex.swift +1 -1
  95. package/ios/Source/sgcs/Nimo.swift +1 -1
  96. package/ios/Source/sgcs/SGCManager.swift +13 -2
  97. package/ios/Source/sgcs/Simulated.swift +1 -1
  98. package/ios/Source/sgcs/ar99/ota/Ar99OtaManager.swift +637 -0
  99. package/ios/Source/status/WifiHotspotStatus.swift +21 -2
  100. package/ios/Source/streaming/StreamModels.swift +68 -2
  101. package/ios/Source/tts/TTSTools.swift +1 -1
  102. package/ios/Source/types/DeviceModels.swift +13 -0
  103. package/ios/Source/utils/BleJsonCompact.swift +8 -0
  104. package/ios/Source/utils/Constants.swift +2 -0
  105. package/ios/Source/utils/PhoneAudioMonitor.swift +4 -0
  106. package/package.json +1 -2
  107. package/src/BluetoothSdk.types.ts +106 -24
  108. package/src/_internal.ts +3 -0
  109. package/src/_private/BluetoothSdkModule.ts +83 -18
  110. package/src/_private/MentraLocalNetworkModule.ts +50 -0
  111. package/src/_private/cameraRequestPayload.ts +9 -2
  112. package/src/_private/photoRequestPayload.ts +13 -4
  113. package/src/_private/sdkOtaManifest.ts +31 -0
  114. package/src/index.ts +13 -1
@@ -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
@@ -163,7 +190,7 @@ public final class MentraBluetoothSDK {
163
190
  private var shouldRestoreControllerOnBluetoothRestore = false
164
191
  private var bridgeEventSinkId: String?
165
192
  private var storeListenerId: String?
166
- private let defaultDeviceKeys: Set<String> = ["default_wearable", "device_name", "device_address"]
193
+ private let defaultDeviceKeys: Set<String> = ["default_wearable", "device_name", "device_address", "project_name"]
167
194
  private let videoUploadStopTimeoutMs = 10 * 60 * 1000
168
195
  private var suppressDefaultDeviceEvents = false
169
196
  private var defaultDeviceApplyGeneration = 0
@@ -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
@@ -261,6 +308,7 @@ public final class MentraBluetoothSDK {
261
308
  DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "default_wearable", device.model.deviceType)
262
309
  DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "device_name", device.name)
263
310
  DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "device_address", device.identifier ?? "")
311
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "project_name", device.projectName ?? "")
264
312
  finishDefaultDeviceApply(generation: generation)
265
313
  }
266
314
 
@@ -271,6 +319,7 @@ public final class MentraBluetoothSDK {
271
319
  DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "default_wearable", "")
272
320
  DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "device_name", "")
273
321
  DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "device_address", "")
322
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "project_name", "")
274
323
  finishDefaultDeviceApply(generation: generation)
275
324
  }
276
325
 
@@ -487,7 +536,11 @@ public final class MentraBluetoothSDK {
487
536
  DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "voice_activity_detection_enabled", enabled)
488
537
  }
489
538
 
490
- @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.")
539
+ public func setLoudnessGateEnabled(_ enabled: Bool) async throws {
540
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "loudness_gate_enabled", enabled)
541
+ }
542
+
543
+ @available(*, deprecated, message: "Sticky action-button photo presets are deprecated. Prefer per-request requestPhoto(...) options (e.g. mode .text for text sensor size/crop, or explicit per-shot fields). This method still works but will be removed in a future release.")
491
544
  public func setPhotoCaptureDefaults(_ settings: PhotoCaptureDefaults) async throws -> SettingsAckEvent {
492
545
  try await performSettingsCommand(
493
546
  setting: "button_photo",
@@ -496,7 +549,7 @@ public final class MentraBluetoothSDK {
496
549
  // Mirror Android: clear all cached scan-tuning keys so reconnect sync
497
550
  // does not replay stale values after a reset.
498
551
  let cat = ObservableStore.bluetoothCategory
499
- for key in ["button_photo_mfnr", "button_photo_zsl", "button_photo_noise_reduction",
552
+ for key in ["button_photo_zsl_mfnr", "button_photo_mfnr", "button_photo_zsl", "button_photo_noise_reduction",
500
553
  "button_photo_edge_enhancement", "button_photo_isp_digital_gain",
501
554
  "button_photo_isp_analog_gain", "button_photo_ae_exposure_divisor",
502
555
  "button_photo_iso_cap", "button_photo_compress", "button_photo_sound"] {
@@ -605,6 +658,59 @@ public final class MentraBluetoothSDK {
605
658
  return result
606
659
  }
607
660
 
661
+ /// Compatibility path for ASG clients that support only the older one-way FOV command.
662
+ public func setLegacyCameraFov(_ fov: CameraFov) throws -> CameraFovResult {
663
+ try DeviceManager.shared.sendLegacyCameraFovSetting(
664
+ fov: fov.fov,
665
+ roiPosition: fov.roiPosition.rawValue
666
+ )
667
+ return CameraFovResult(
668
+ requestId: "legacy",
669
+ fov: fov.fov,
670
+ roiPosition: fov.roiPosition,
671
+ timestamp: Int(Date().timeIntervalSince1970 * 1000)
672
+ )
673
+ }
674
+
675
+ /// Restores the persistent base FOV without requiring a settings acknowledgement.
676
+ public func restoreLegacyCameraFov() throws {
677
+ try DeviceManager.shared.restoreLegacyCameraFovSetting()
678
+ }
679
+
680
+ public func setCameraFovOverride(
681
+ leaseId: String,
682
+ fov: CameraFov,
683
+ ttlMs: Int
684
+ ) async throws -> CameraFovResult {
685
+ let ack = try await performSettingsCommand(
686
+ setting: "camera_fov_override",
687
+ updateStore: { _ in },
688
+ send: { requestId in
689
+ try DeviceManager.shared.sendCameraFovOverride(
690
+ requestId: requestId,
691
+ leaseId: leaseId,
692
+ fov: fov.fov,
693
+ roiPosition: fov.roiPosition.rawValue,
694
+ ttlMs: ttlMs
695
+ )
696
+ }
697
+ )
698
+ return try CameraFovResult.from(ack: ack, fallback: fov)
699
+ }
700
+
701
+ public func releaseCameraFovOverride(leaseId: String) async throws -> SettingsAckEvent {
702
+ try await performSettingsCommand(
703
+ setting: "camera_fov_override",
704
+ updateStore: { _ in },
705
+ send: { requestId in
706
+ try DeviceManager.shared.releaseCameraFovOverride(
707
+ requestId: requestId,
708
+ leaseId: leaseId
709
+ )
710
+ }
711
+ )
712
+ }
713
+
608
714
  public func setCameraTuningConfig(anrOn: Bool, gainOn: Bool) async throws -> SettingsAckEvent {
609
715
  return try await performSettingsCommand(
610
716
  setting: "camera_tuning",
@@ -679,9 +785,12 @@ public final class MentraBluetoothSDK {
679
785
  return try await existing.value
680
786
  }
681
787
  let pending = PendingResponse<[WifiScanResult]>(operation: "WiFi scan request")
682
- let request = PendingWifiScan(pending: pending)
788
+ let request = PendingWifiScan(pending: pending, scanId: "scan-\(UUID().uuidString)")
683
789
  pendingWifiScan = request
684
- DeviceManager.shared.requestWifiScan()
790
+ // Claim the scan-results store for this scan before the command goes out,
791
+ // so a delayed chunk from an older, abandoned scan can no longer mutate it.
792
+ Bridge.claimWifiScanResults(scanId: request.scanId)
793
+ DeviceManager.shared.requestWifiScan(scanId: request.scanId)
685
794
  let task = Task { @MainActor [weak self] () async throws -> [WifiScanResult] in
686
795
  defer {
687
796
  if let self {
@@ -795,7 +904,7 @@ public final class MentraBluetoothSDK {
795
904
  pendingPhotoRequests[routedRequest.requestId] = pending
796
905
  DeviceManager.shared.requestPhoto(routedRequest)
797
906
  do {
798
- let event = try await pending.wait()
907
+ let event = try await pending.wait(timeoutMs: MentraBluetoothSDK.photoRequestTimeoutMs)
799
908
  pendingPhotoRequests.removeValue(forKey: routedRequest.requestId)
800
909
  return event
801
910
  } catch {
@@ -807,8 +916,11 @@ public final class MentraBluetoothSDK {
807
916
  public func warmUpCamera(
808
917
  requestId: String? = nil,
809
918
  size: PhotoSize,
919
+ mode: PhotoMode = .photo,
810
920
  exposureTimeNs: Double?,
811
- durationMs: Int
921
+ durationMs: Int,
922
+ zsl: Bool? = nil,
923
+ mfnr: Bool? = nil
812
924
  ) async throws -> CameraStatusEvent {
813
925
  let effectiveRequestId = nonBlankRequestId(requestId) ?? generatedCameraRequestId("warm")
814
926
  let pending = PendingResponse<CameraStatusEvent>(operation: "camera warm up \(effectiveRequestId)")
@@ -818,8 +930,11 @@ public final class MentraBluetoothSDK {
818
930
  try DeviceManager.shared.warmUpCamera(
819
931
  requestId: effectiveRequestId,
820
932
  size: size,
933
+ mode: mode,
821
934
  exposureTimeNs: exposureTimeNs,
822
- durationMs: durationMs
935
+ durationMs: durationMs,
936
+ zsl: zsl,
937
+ mfnr: mfnr
823
938
  )
824
939
  let event = try await pending.wait()
825
940
  pendingCameraStatusRequests.removeValue(forKey: effectiveRequestId)
@@ -830,6 +945,15 @@ public final class MentraBluetoothSDK {
830
945
  }
831
946
  }
832
947
 
948
+ public func stopCameraWarmUp(requestId: String) throws {
949
+ guard let effectiveRequestId = nonBlankRequestId(requestId) else {
950
+ throw BluetoothSdkError(
951
+ code: "invalid_request", message: "A warm-up request ID is required."
952
+ )
953
+ }
954
+ try DeviceManager.shared.stopCameraWarmUp(requestId: effectiveRequestId)
955
+ }
956
+
833
957
  public func queryGalleryStatus() async throws -> GalleryStatusEvent {
834
958
  if pendingGalleryStatus != nil {
835
959
  throw BluetoothSdkError(
@@ -867,7 +991,12 @@ public final class MentraBluetoothSDK {
867
991
  let streamId = stringValue(values, "streamId").flatMap { $0.isEmpty ? nil : $0 } ?? "sdk-\(UUID().uuidString)"
868
992
  values["streamId"] = streamId
869
993
  let pending = PendingResponse<StreamStatusEvent>(operation: "start stream \(streamId)")
870
- pendingStreamStarts[streamId] = pending
994
+ // seq assignment through the BLE hand-off must stay await-free so this
995
+ // MainActor turn is one critical section: rejectPreemptedStreamStarts
996
+ // only works if seq order equals the order the commands reach the
997
+ // glasses' FIFO.
998
+ streamStartSeq += 1
999
+ pendingStreamStarts[streamId] = PendingStreamStart(seq: streamStartSeq, pending: pending)
871
1000
  stopStreamKeepAliveMonitor()
872
1001
  DeviceManager.shared.startStream(values)
873
1002
  do {
@@ -920,7 +1049,13 @@ public final class MentraBluetoothSDK {
920
1049
  )
921
1050
  }
922
1051
  let pending = PendingResponse<StreamStatusEvent>(operation: "stop stream")
923
- pendingStreamStop = (streamId: activeStreamKeepAlive?.streamId, pending: pending)
1052
+ // The seq draw and the BLE hand-off stay in this single await-free
1053
+ // MainActor turn, sharing startStream's ordering critical section:
1054
+ // the stop takes its own slot in the send order, so its ack can
1055
+ // separate the pending starts the glasses consumed before it (lower
1056
+ // seq) from starts sent after it (higher seq).
1057
+ streamStartSeq += 1
1058
+ pendingStreamStop = (streamId: activeStreamKeepAlive?.streamId, seq: streamStartSeq, pending: pending)
924
1059
  stopStreamKeepAliveMonitor()
925
1060
  DeviceManager.shared.stopStream()
926
1061
  do {
@@ -1136,6 +1271,20 @@ public final class MentraBluetoothSDK {
1136
1271
 
1137
1272
  func sendOtaQueryStatus() async throws -> OtaQueryResult { try await queryOtaStatus() }
1138
1273
 
1274
+ func startAr99OtaFromFile(_ path: String) throws -> Bool {
1275
+ try requireGlassesConnected(operation: "start AR99 OTA")
1276
+ return try DeviceManager.shared.startAr99OtaFromFile(path)
1277
+ }
1278
+
1279
+ func cancelAr99Ota() {
1280
+ DeviceManager.shared.cancelAr99Ota()
1281
+ }
1282
+
1283
+ func sendAr99FactoryReset() throws {
1284
+ try requireGlassesConnected(operation: "factory reset AR99")
1285
+ try DeviceManager.shared.sendAr99FactoryReset()
1286
+ }
1287
+
1139
1288
  private func getFreshGlassesStatus() async -> GlassesStatus {
1140
1289
  let status = glassesStatus
1141
1290
  if !status.connected || !status.buildNumber.isEmpty {
@@ -1411,20 +1560,51 @@ public final class MentraBluetoothSDK {
1411
1560
  }
1412
1561
 
1413
1562
  private func handleStreamStatusForRequests(_ event: StreamStatusEvent) {
1414
- if let (streamId, pending) = matchingStreamStart(for: event) {
1563
+ if let (streamId, start) = matchingStreamStart(for: event) {
1564
+ // Preemption is only proven by statuses the start path emits after
1565
+ // the glasses' stopAllServices has run: initializing, reconnecting,
1566
+ // reconnected, streaming. An id-stamped `error` can be a
1567
+ // command-level preflight rejection (#3488 firmware) emitted
1568
+ // synchronously before stopAllServices, and `stopped` is a
1569
+ // stop-ack for a previous stream — treating either as a winner
1570
+ // could reject an older start that is still healthy.
1571
+ if let eventStreamId = event.streamId, !eventStreamId.isEmpty {
1572
+ switch event.state {
1573
+ case .initializing, .reconnecting, .reconnected, .streaming:
1574
+ rejectPreemptedStreamStarts(winnerSeq: start.seq)
1575
+ default:
1576
+ break
1577
+ }
1578
+ }
1415
1579
  switch event.state {
1416
- case .streaming:
1580
+ // `reconnected` also proves the stream is live: when an async start
1581
+ // failure triggers the glasses' publisher retry, a successful retry
1582
+ // reports `reconnected` instead of `streaming`.
1583
+ case .streaming, .reconnected:
1417
1584
  pendingStreamStarts.removeValue(forKey: streamId)
1418
- pending.resolve(event)
1585
+ start.pending.resolve(event)
1419
1586
  case .reconnectFailed, .stopped:
1420
1587
  pendingStreamStarts.removeValue(forKey: streamId)
1421
- pending.reject(streamStatusError(event, code: "stream_start_failed"))
1588
+ start.pending.reject(streamStatusError(start.lastError ?? event, code: "stream_start_failed"))
1422
1589
  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
1590
+ if let eventStreamId = event.streamId, !eventStreamId.isEmpty, event.willRetry == true {
1591
+ // The glasses flag errors their publisher will retry with
1592
+ // `willRetry` (emitting side lands in PR #3488), so keep the
1593
+ // start pending for the retry's verdict — `reconnected` or
1594
+ // `streaming` on success, `reconnect_failed` or the
1595
+ // streamId-less `stopped` wind-down (which reports these
1596
+ // stashed details) on failure.
1597
+ start.lastError = event
1598
+ } else {
1599
+ // An id-less error is the glasses' command-level rejection
1600
+ // (missing URL, low battery, no WiFi) emitted before any
1601
+ // publisher starts; an id-carrying error without `willRetry`
1602
+ // is terminal (`camera_busy` emits an error and nothing else).
1603
+ // Old firmware never sends `willRetry`, so its errors always
1604
+ // fail fast here — the shipped pre-#3487 Android behavior.
1605
+ pendingStreamStarts.removeValue(forKey: streamId)
1606
+ start.pending.reject(streamStatusError(event, code: "stream_start_failed"))
1607
+ }
1428
1608
  default:
1429
1609
  break
1430
1610
  }
@@ -1435,6 +1615,7 @@ public final class MentraBluetoothSDK {
1435
1615
  if pendingStreamStop?.pending === stop.pending {
1436
1616
  pendingStreamStop = nil
1437
1617
  }
1618
+ rejectStreamStartsSuperseded(byStopSeq: stop.seq)
1438
1619
  stop.pending.resolve(stoppedStreamEvent(from: event, fallbackStreamId: stop.streamId))
1439
1620
  } else if event.state == .error || event.state == .reconnectFailed {
1440
1621
  if pendingStreamStop?.pending === stop.pending {
@@ -1445,12 +1626,73 @@ public final class MentraBluetoothSDK {
1445
1626
  }
1446
1627
  }
1447
1628
 
1448
- private func matchingStreamStart(for event: StreamStatusEvent) -> (String, PendingResponse<StreamStatusEvent>)? {
1629
+ // The glasses process BLE commands FIFO and every start_stream begins by
1630
+ // stopping whatever runs, so an id-carrying status proving start X's
1631
+ // start path ran on the glasses also proves every lower-seq start has
1632
+ // already been preempted. Their only verdict on current firmware is a
1633
+ // streamId-less stopped, which the id-less heuristic deliberately
1634
+ // ignores — without this they would run out the 30s timeout instead of
1635
+ // failing fast.
1636
+ private func rejectPreemptedStreamStarts(winnerSeq: Int) {
1637
+ for (streamId, start) in pendingStreamStarts where start.seq < winnerSeq {
1638
+ pendingStreamStarts.removeValue(forKey: streamId)
1639
+ start.pending.reject(
1640
+ BluetoothSdkError(
1641
+ code: "stream_preempted",
1642
+ message: "start stream \(streamId) was preempted by a newer start_stream; "
1643
+ + "the glasses run a single stream and the last request wins."
1644
+ )
1645
+ )
1646
+ }
1647
+ }
1648
+
1649
+ // The glasses process BLE commands FIFO, so this stop's ack also settles
1650
+ // every start sent before it: whatever those starts brought up (or would
1651
+ // have brought up) has been stopped, and no further status will arrive
1652
+ // for them. Without this they would run out the 30s start timeout.
1653
+ // Starts sent after the stop keep waiting for their own statuses.
1654
+ private func rejectStreamStartsSuperseded(byStopSeq stopSeq: Int) {
1655
+ for (streamId, start) in pendingStreamStarts where start.seq < stopSeq {
1656
+ pendingStreamStarts.removeValue(forKey: streamId)
1657
+ start.pending.reject(
1658
+ BluetoothSdkError(
1659
+ code: "stream_stopped",
1660
+ message: "start stream \(streamId) was superseded by a stop_stream issued after it."
1661
+ )
1662
+ )
1663
+ }
1664
+ }
1665
+
1666
+ private func matchingStreamStart(for event: StreamStatusEvent) -> (String, PendingStreamStart)? {
1449
1667
  if let streamId = event.streamId, !streamId.isEmpty {
1450
- guard let pending = pendingStreamStarts[streamId] else { return nil }
1451
- return (streamId, pending)
1668
+ guard let start = pendingStreamStarts[streamId] else { return nil }
1669
+ return (streamId, start)
1670
+ }
1671
+ if event.state == .error {
1672
+ // An id-less error is a command-level preflight rejection (missing
1673
+ // URL, low battery, no WiFi) emitted synchronously, while lifecycle
1674
+ // statuses arrive async — so with overlapping starts the wire is
1675
+ // genuinely ambiguous about which start it answers. Attribute it to
1676
+ // the newest pending start: the common case is a later start
1677
+ // failing preflight while an earlier one is already emitting
1678
+ // lifecycle statuses, and at worst this swaps which start carries
1679
+ // the error instead of letting both time out.
1680
+ guard let entry = pendingStreamStarts.max(by: { $0.value.seq < $1.value.seq }) else { return nil }
1681
+ return (entry.key, entry.value)
1452
1682
  }
1453
1683
  if pendingStreamStarts.count == 1, let entry = pendingStreamStarts.first {
1684
+ // A streamId-less STOPPED is the glasses' stop-ack for a PREVIOUS
1685
+ // stream (their stop ack carries no streamId), not a verdict on the
1686
+ // pending start — a start_stream that replaces a running publisher
1687
+ // emits exactly this sequence (stopped -> initializing -> streaming).
1688
+ // Attributing it here would reject a start that is about to succeed.
1689
+ // After a stashed `willRetry` error for the pending start, though,
1690
+ // the stopped is the retrying publisher's wind-down (the stop-ack
1691
+ // always precedes any status for the new start), so it is that
1692
+ // start's verdict.
1693
+ if event.state == .stopped, entry.value.lastError == nil {
1694
+ return nil
1695
+ }
1454
1696
  return (entry.key, entry.value)
1455
1697
  }
1456
1698
  return nil
@@ -1610,6 +1852,28 @@ public final class MentraBluetoothSDK {
1610
1852
  request.pending.resolve(results)
1611
1853
  }
1612
1854
 
1855
+ // scanId-echoing glasses: results for another scan are ignored instead of
1856
+ // resolving the pending request, and matching chunks accumulate until the
1857
+ // glasses flag the scan complete.
1858
+ private func handleCorrelatedWifiScanChunk(
1859
+ scanId: String,
1860
+ results: [WifiScanResult],
1861
+ scanComplete: Bool
1862
+ ) {
1863
+ guard let request = pendingWifiScan, request.scanId == scanId else { return }
1864
+ for network in results where !request.accumulated.contains(where: { $0.ssid == network.ssid }) {
1865
+ request.accumulated.append(network)
1866
+ }
1867
+ if !request.accumulated.isEmpty {
1868
+ request.latestResults = request.accumulated
1869
+ }
1870
+ guard scanComplete else { return }
1871
+ if pendingWifiScan === request {
1872
+ pendingWifiScan = nil
1873
+ }
1874
+ request.pending.resolve(request.accumulated)
1875
+ }
1876
+
1613
1877
  private func updateWifiScanLatestResults(_ results: [WifiScanResult]) {
1614
1878
  guard !results.isEmpty else { return }
1615
1879
  pendingWifiScan?.latestResults = results
@@ -1617,6 +1881,24 @@ public final class MentraBluetoothSDK {
1617
1881
 
1618
1882
  private func handleWifiStatusForRequests(_ event: WifiStatusEvent) {
1619
1883
  guard let request = pendingWifiStatus else { return }
1884
+ // A wifi_status carrying the explicit error field is the glasses' failure
1885
+ // verdict for the in-flight connect: reject now instead of running out the
1886
+ // request timeout. Only the error field counts as failure — the glasses'
1887
+ // connect sequence emits a debounced bare connected=false ~1-2s after
1888
+ // credentials while association is still in progress, and rejecting on that
1889
+ // would kill every connect attempt early.
1890
+ if request.operation == .connect, let error = event.error {
1891
+ if pendingWifiStatus === request {
1892
+ pendingWifiStatus = nil
1893
+ }
1894
+ request.pending.reject(
1895
+ BluetoothSdkError(
1896
+ code: error,
1897
+ message: "Glasses failed to join \"\(request.ssid)\": \(error)"
1898
+ )
1899
+ )
1900
+ return
1901
+ }
1620
1902
  guard wifiStatusMatches(event.status, request: request) else { return }
1621
1903
  if pendingWifiStatus === request {
1622
1904
  pendingWifiStatus = nil
@@ -1709,14 +1991,15 @@ public final class MentraBluetoothSDK {
1709
1991
  guard let model = core["default_wearable"] as? String, !model.isEmpty else { return nil }
1710
1992
  guard let name = core["device_name"] as? String, !name.isEmpty else { return nil }
1711
1993
  let identifier = (core["device_address"] as? String).flatMap { $0.isEmpty ? nil : $0 }
1994
+ let projectName = (core["project_name"] as? String).flatMap { $0.isEmpty ? nil : $0 }
1712
1995
  return Device(
1713
1996
  model: DeviceModel.fromDeviceType(model),
1714
1997
  name: name,
1715
- identifier: identifier
1998
+ identifier: identifier,
1999
+ projectName: projectName
1716
2000
  )
1717
2001
  }
1718
-
1719
- private func dispatchDiscoveredDevices(_ rawSearchResults: Any?) {
2002
+ private func dispatchDiscoveredDevices(_ rawSearchResults: Any?) {
1720
2003
  guard let results = rawSearchResults as? [[String: Any]] else { return }
1721
2004
  for result in results {
1722
2005
  guard let name = result["name"] as? String else { continue }
@@ -1803,9 +2086,15 @@ public final class MentraBluetoothSDK {
1803
2086
  let networks = (data["networks"] as? [[String: Any]])?.map(WifiScanResult.init(values:)) ?? []
1804
2087
  let hasCompletionFlag = data.keys.contains("scanComplete") || data.keys.contains("scan_complete")
1805
2088
  let scanComplete = data["scanComplete"] as? Bool ?? data["scan_complete"] as? Bool ?? false
1806
- updateWifiScanLatestResults(networks)
1807
- if scanComplete || !hasCompletionFlag {
1808
- handleWifiScanResultsForRequests(networks)
2089
+ let scanId = (data["scanId"] as? String).flatMap { $0.isEmpty ? nil : $0 }
2090
+ if let scanId {
2091
+ handleCorrelatedWifiScanChunk(scanId: scanId, results: networks, scanComplete: scanComplete)
2092
+ } else {
2093
+ // Old firmware: no correlation id, keep the pre-scanId heuristics.
2094
+ updateWifiScanLatestResults(networks)
2095
+ if scanComplete || !hasCompletionFlag {
2096
+ handleWifiScanResultsForRequests(networks)
2097
+ }
1809
2098
  }
1810
2099
  delegate?.mentraBluetoothSDK(self, didReceive: .raw(name: "wifi_scan_result", values: data))
1811
2100
  case "hotspot_error":
@@ -1882,3 +2171,7 @@ public final class MentraBluetoothSDK {
1882
2171
  }
1883
2172
  }
1884
2173
  }
2174
+
2175
+
2176
+
2177
+
@@ -88,9 +88,18 @@ enum OtaManifestChecker {
88
88
  currentBuildNumber: String,
89
89
  currentMtkVersion: String,
90
90
  currentBesVersion: String,
91
- manifest: OtaManifest
91
+ manifest: OtaManifest,
92
+ // Downgrade floor: a non-positive value (the default) disables downgrades entirely,
93
+ // matching ASG's fail-closed DowngradeGate and the engine. OEM SDK consumers that do not
94
+ // set a floor get upgrade-only behavior and are never told a downgrade is available that
95
+ // the glasses would refuse.
96
+ downgradeFloorVersionCode: Int = 0
92
97
  ) throws -> Bool {
93
- try hasApkUpdate(currentBuildNumber: currentBuildNumber, manifest: manifest) ||
98
+ try hasApkUpdate(
99
+ currentBuildNumber: currentBuildNumber,
100
+ manifest: manifest,
101
+ downgradeFloorVersionCode: downgradeFloorVersionCode
102
+ ) ||
94
103
  hasMtkUpdate(patches: manifest.mtkPatches, currentVersion: currentMtkVersion) ||
95
104
  hasBesUpdate(besFirmware: manifest.besFirmware, currentVersion: currentBesVersion)
96
105
  }
@@ -113,7 +122,11 @@ enum OtaManifestChecker {
113
122
  throw BluetoothSdkError(code: "invalid_ota_manifest", message: "OTA manifest is missing ASG app versionCode.")
114
123
  }
115
124
 
116
- private static func hasApkUpdate(currentBuildNumber: String, manifest: OtaManifest) throws -> Bool {
125
+ private static func hasApkUpdate(
126
+ currentBuildNumber: String,
127
+ manifest: OtaManifest,
128
+ downgradeFloorVersionCode: Int
129
+ ) throws -> Bool {
117
130
  guard let currentVersion = Int(currentBuildNumber) else {
118
131
  throw BluetoothSdkError(
119
132
  code: "invalid_glasses_version",
@@ -123,7 +136,37 @@ enum OtaManifestChecker {
123
136
  guard let serverVersion = try latestAppInfo(manifest).versionCode else {
124
137
  throw BluetoothSdkError(code: "invalid_ota_manifest", message: "OTA manifest is missing ASG app versionCode.")
125
138
  }
126
- return serverVersion > currentVersion
139
+ // Only apps-shaped manifests are exact pins. A legacy top-level manifest (versionCode at
140
+ // the root, no apps entry) is NOT a pin and stays strictly upgrade-only, matching the
141
+ // engine TS checker and Kotlin.
142
+ let isExactPin = manifest.apps?[asgClientPackage]?.versionCode != nil
143
+ if !isExactPin {
144
+ return serverVersion > currentVersion
145
+ }
146
+ // Exact pin: any mismatch is an update in either direction (downgrades take the
147
+ // uninstall-then-reinstall detour on the glasses). A non-positive pin is only legitimate
148
+ // in the frozen legacy rescue manifests that pre-39 glasses are checked against; for
149
+ // modern glasses it means the manifest cannot verify anything, and that must surface as
150
+ // an error — never as "no update". Mirrors the Kotlin OtaManifestChecker.
151
+ if serverVersion <= 0 {
152
+ if currentVersion >= 39 {
153
+ throw BluetoothSdkError(
154
+ code: "invalid_ota_manifest",
155
+ message: "OTA manifest ASG pin is missing or zero; cannot verify update state."
156
+ )
157
+ }
158
+ return false
159
+ }
160
+ if serverVersion > currentVersion {
161
+ return true
162
+ }
163
+ // Exact match: already on the pin, no update.
164
+ if serverVersion == currentVersion {
165
+ return false
166
+ }
167
+ // Downgrade: only actionable at/above the enabled floor; a non-positive floor disables
168
+ // downgrades (fail closed, matching ASG and the engine).
169
+ return downgradeFloorVersionCode > 0 && serverVersion >= downgradeFloorVersionCode
127
170
  }
128
171
 
129
172
  private static func hasMtkUpdate(patches: [MtkPatch]?, currentVersion: String) throws -> Bool {