@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.
- package/README.md +27 -12
- package/android/build.gradle +3 -0
- package/android/src/main/AndroidManifest.xml +3 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalytics.kt +34 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +1 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +169 -26
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +72 -16
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +127 -8
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +4 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +400 -99
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +333 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +43 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +18 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleTraceLogger.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +53 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Ar99.java +2653 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +16 -9
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Mach1.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +342 -42
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +7 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/Ar99OtaManager.java +566 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaByteUtils.java +18 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaCommandConstants.java +35 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaCrc32Util.java +13 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaGattTransport.java +11 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaProtocol.java +92 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/status/WifiHotspotStatus.kt +19 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +52 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/tts/TTSTools.kt +115 -47
- package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +7 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +8 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/Constants.kt +2 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/PhoneAudioMonitor.kt +45 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/audio/Ar99OpusPcmDecoder.java +398 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/audio/PcmStreamPlayer.kt +328 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/OtaManifestDowngradeTest.kt +86 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/StreamStatusEventTest.kt +29 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +45 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/Ar99AdvertisementMatcherTest.java +22 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +20 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/PhoneAudioMonitorTest.kt +45 -0
- package/build/BluetoothSdk.types.d.ts +100 -22
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js +2 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_internal.d.ts +3 -0
- package/build/_internal.d.ts.map +1 -1
- package/build/_internal.js +3 -0
- package/build/_internal.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +31 -4
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +26 -3
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/_private/MentraLocalNetworkModule.d.ts +37 -0
- package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -0
- package/build/_private/MentraLocalNetworkModule.js +3 -0
- package/build/_private/MentraLocalNetworkModule.js.map +1 -0
- package/build/_private/cameraRequestPayload.d.ts +1 -1
- package/build/_private/cameraRequestPayload.d.ts.map +1 -1
- package/build/_private/cameraRequestPayload.js +7 -0
- package/build/_private/cameraRequestPayload.js.map +1 -1
- package/build/_private/photoRequestPayload.d.ts.map +1 -1
- package/build/_private/photoRequestPayload.js +10 -0
- package/build/_private/photoRequestPayload.js.map +1 -1
- package/build/_private/sdkOtaManifest.d.ts +20 -0
- package/build/_private/sdkOtaManifest.d.ts.map +1 -0
- package/build/_private/sdkOtaManifest.js +28 -0
- package/build/_private/sdkOtaManifest.js.map +1 -0
- package/build/index.d.ts +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +11 -1
- package/build/index.js.map +1 -1
- package/expo-module.config.json +1 -0
- package/ios/BluetoothSdkModule.swift +113 -2
- package/ios/Source/BluetoothSdkDefaults.swift +2 -1
- package/ios/Source/Bridge.swift +87 -21
- package/ios/Source/DeviceManager.swift +118 -20
- package/ios/Source/DeviceStore.swift +4 -0
- package/ios/Source/MentraBluetoothSDK.swift +326 -33
- package/ios/Source/OtaManifest.swift +47 -4
- package/ios/Source/audio/PcmStreamPlayer.swift +410 -0
- package/ios/Source/camera/CameraModels.swift +26 -6
- package/ios/Source/internal/BleTraceLogger.swift +1 -1
- package/ios/Source/internal/BluetoothSdkAnalytics.swift +39 -0
- package/ios/Source/sgcs/Ar99.swift +2451 -0
- package/ios/Source/sgcs/G1.swift +1 -1
- package/ios/Source/sgcs/G2.swift +19 -7
- package/ios/Source/sgcs/Mach1.swift +1 -1
- package/ios/Source/sgcs/MentraLive.swift +478 -61
- package/ios/Source/sgcs/MentraNex.swift +1 -1
- package/ios/Source/sgcs/Nimo.swift +1 -1
- package/ios/Source/sgcs/SGCManager.swift +13 -2
- package/ios/Source/sgcs/Simulated.swift +1 -1
- package/ios/Source/sgcs/ar99/ota/Ar99OtaManager.swift +637 -0
- package/ios/Source/status/WifiHotspotStatus.swift +21 -2
- package/ios/Source/streaming/StreamModels.swift +68 -2
- package/ios/Source/tts/TTSTools.swift +1 -1
- package/ios/Source/types/DeviceModels.swift +13 -0
- package/ios/Source/utils/BleJsonCompact.swift +8 -0
- package/ios/Source/utils/Constants.swift +2 -0
- package/ios/Source/utils/PhoneAudioMonitor.swift +4 -0
- package/package.json +1 -2
- package/src/BluetoothSdk.types.ts +106 -24
- package/src/_internal.ts +3 -0
- package/src/_private/BluetoothSdkModule.ts +83 -18
- package/src/_private/MentraLocalNetworkModule.ts +50 -0
- package/src/_private/cameraRequestPayload.ts +9 -2
- package/src/_private/photoRequestPayload.ts +13 -4
- package/src/_private/sdkOtaManifest.ts +31 -0
- 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:
|
|
179
|
-
private var
|
|
205
|
+
private var pendingStreamStarts: [String: PendingStreamStart] = [:]
|
|
206
|
+
private var streamStartSeq = 0
|
|
207
|
+
// seq is the stop's slot in the shared streamStartSeq order (drawn in the
|
|
208
|
+
// same await-free MainActor turn that sends the command): every pending
|
|
209
|
+
// start with a lower seq reached the glasses' FIFO before this stop.
|
|
210
|
+
private var pendingStreamStop: (streamId: String?, seq: Int, pending: PendingResponse<StreamStatusEvent>)?
|
|
180
211
|
private var pendingGalleryStatus: PendingResponse<GalleryStatusEvent>?
|
|
181
212
|
private var pendingOtaQuery: PendingResponse<OtaQueryResult>?
|
|
182
213
|
private var pendingOtaStart: PendingResponse<OtaStartAckEvent>?
|
|
@@ -196,8 +227,24 @@ public final class MentraBluetoothSDK {
|
|
|
196
227
|
}
|
|
197
228
|
}
|
|
198
229
|
bridgeEventSinkId = Bridge.addEventSink { [weak self] eventName, data in
|
|
199
|
-
|
|
200
|
-
|
|
230
|
+
// Bridge.dispatchEvent always invokes sinks on the main thread, in the
|
|
231
|
+
// FIFO order events were dispatched (correlated WiFi-scan chunks rely
|
|
232
|
+
// on that order). A fresh Task per event can reach the MainActor out
|
|
233
|
+
// of creation order and reorder chunks, so consume synchronously
|
|
234
|
+
// while still on main.
|
|
235
|
+
if Thread.isMainThread {
|
|
236
|
+
MainActor.assumeIsolated {
|
|
237
|
+
self?.dispatchBridgeEvent(eventName, data)
|
|
238
|
+
}
|
|
239
|
+
} else {
|
|
240
|
+
// Defensive fallback only — Bridge.dispatchEvent should never take
|
|
241
|
+
// this path. A serial main-queue hop still preserves FIFO order,
|
|
242
|
+
// unlike an unstructured Task.
|
|
243
|
+
DispatchQueue.main.async {
|
|
244
|
+
MainActor.assumeIsolated {
|
|
245
|
+
self?.dispatchBridgeEvent(eventName, data)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
201
248
|
}
|
|
202
249
|
}
|
|
203
250
|
storeListenerId = DeviceStore.shared.store.addListener { [weak self] category, changes in
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
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
|
-
|
|
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
|
|
1451
|
-
return (streamId,
|
|
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
|
-
|
|
1807
|
-
if
|
|
1808
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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 {
|