@mentra/bluetooth-sdk 0.1.12 → 0.1.14
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 +30 -11
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +47 -12
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +75 -29
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +1 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +208 -45
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdkDebug.kt +14 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/ObservableStore.kt +20 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +153 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +80 -14
- package/android/src/main/java/com/mentra/bluetoothsdk/controllers/ControllerManager.kt +4 -16
- package/android/src/main/java/com/mentra/bluetoothsdk/controllers/R1.kt +4 -8
- package/android/src/main/java/com/mentra/bluetoothsdk/services/PhoneMic.kt +90 -4
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +3831 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +20 -28
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/{Mach1.java → Mach1.kt} +514 -561
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +8674 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +12 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +5 -17
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +9 -20
- package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +0 -6
- package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +16 -33
- package/build/BluetoothSdk.types.d.ts +48 -17
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +9 -9
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +0 -6
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/_private/photoRequestPayload.d.ts +3 -1
- package/build/_private/photoRequestPayload.d.ts.map +1 -1
- package/build/_private/photoRequestPayload.js +46 -2
- package/build/_private/photoRequestPayload.js.map +1 -1
- package/build/debug.d.ts +3 -0
- package/build/debug.d.ts.map +1 -0
- package/build/debug.js +8 -0
- package/build/debug.js.map +1 -0
- package/build/index.d.ts +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +8 -8
- package/build/index.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +44 -68
- package/ios/Source/BluetoothSdkDefaults.swift +23 -3
- package/ios/Source/DeviceManager.swift +38 -33
- package/ios/Source/DeviceStore.swift +5 -5
- package/ios/Source/MentraBluetoothSDK.swift +217 -50
- package/ios/Source/MentraBluetoothSDKDebug.swift +12 -0
- package/ios/Source/ObservableStore.swift +11 -0
- package/ios/Source/OtaManifest.swift +170 -0
- package/ios/Source/camera/CameraModels.swift +219 -13
- package/ios/Source/controllers/ControllerManager.swift +3 -7
- package/ios/Source/controllers/R1.swift +3 -7
- package/ios/Source/sgcs/Frame.swift +0 -2
- package/ios/Source/sgcs/G1.swift +7 -8
- package/ios/Source/sgcs/G2.swift +21 -20
- package/ios/Source/sgcs/Mach1.swift +7 -8
- package/ios/Source/sgcs/MentraLive.swift +132 -63
- package/ios/Source/sgcs/MentraNex.swift +7 -8
- package/ios/Source/sgcs/SGCManager.swift +7 -10
- package/ios/Source/sgcs/Simulated.swift +8 -8
- package/ios/Source/status/DeviceStatus.swift +1 -9
- package/ios/Source/streaming/StreamModels.swift +7 -32
- package/package.json +6 -1
- package/src/BluetoothSdk.types.ts +49 -17
- package/src/_private/BluetoothSdkModule.ts +9 -17
- package/src/_private/photoRequestPayload.ts +48 -3
- package/src/debug.ts +9 -0
- package/src/index.ts +19 -10
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.java +0 -3974
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.java +0 -7434
|
@@ -147,6 +147,11 @@ private final class PendingResponse<T> {
|
|
|
147
147
|
|
|
148
148
|
@MainActor
|
|
149
149
|
public final class MentraBluetoothSDK {
|
|
150
|
+
private static let otaBesVersionWaitMs = 5_000
|
|
151
|
+
private static let otaMtkVersionWaitMs = 2_000
|
|
152
|
+
private static let otaVersionPollMs = 100
|
|
153
|
+
private static let defaultStreamKeepAliveIntervalSeconds = 5
|
|
154
|
+
|
|
150
155
|
public weak var delegate: MentraBluetoothSDKDelegate?
|
|
151
156
|
|
|
152
157
|
private let configuration: MentraBluetoothSDKConfiguration
|
|
@@ -176,6 +181,7 @@ public final class MentraBluetoothSDK {
|
|
|
176
181
|
private var pendingWifiStatus: PendingWifiStatusRequest?
|
|
177
182
|
private var pendingHotspotStatus: PendingHotspotStatusRequest?
|
|
178
183
|
private var pendingVersionInfo: PendingResponse<VersionInfoResult>?
|
|
184
|
+
private var configuredOtaVersionUrl: String?
|
|
179
185
|
|
|
180
186
|
public init(configuration: MentraBluetoothSDKConfiguration = .default) {
|
|
181
187
|
self.configuration = configuration
|
|
@@ -477,50 +483,85 @@ public final class MentraBluetoothSDK {
|
|
|
477
483
|
DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "voice_activity_detection_enabled", enabled)
|
|
478
484
|
}
|
|
479
485
|
|
|
480
|
-
public func
|
|
486
|
+
public func setPhotoCaptureDefaults(_ settings: PhotoCaptureDefaults) async throws -> SettingsAckEvent {
|
|
481
487
|
try await performSettingsCommand(
|
|
482
488
|
setting: "button_photo",
|
|
483
|
-
updateStore: { _ in
|
|
484
|
-
|
|
489
|
+
updateStore: { _ in
|
|
490
|
+
if settings.resetCaptureTuning == true {
|
|
491
|
+
// Mirror Android: clear all cached scan-tuning keys so reconnect sync
|
|
492
|
+
// does not replay stale values after a reset.
|
|
493
|
+
let cat = ObservableStore.bluetoothCategory
|
|
494
|
+
for key in ["button_photo_mfnr", "button_photo_zsl", "button_photo_noise_reduction",
|
|
495
|
+
"button_photo_edge_enhancement", "button_photo_isp_digital_gain",
|
|
496
|
+
"button_photo_isp_analog_gain", "button_photo_ae_exposure_divisor",
|
|
497
|
+
"button_photo_iso_cap", "button_photo_compress", "button_photo_sound"] {
|
|
498
|
+
DeviceStore.shared.remove(cat, key)
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
if let size = settings.size {
|
|
502
|
+
DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_photo_size", size.rawValue)
|
|
503
|
+
}
|
|
504
|
+
if let mfnr = settings.mfnr {
|
|
505
|
+
DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_photo_mfnr", mfnr)
|
|
506
|
+
}
|
|
507
|
+
if let zsl = settings.zsl {
|
|
508
|
+
DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_photo_zsl", zsl)
|
|
509
|
+
}
|
|
510
|
+
if let noiseReduction = settings.noiseReduction {
|
|
511
|
+
DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_photo_noise_reduction", noiseReduction)
|
|
512
|
+
}
|
|
513
|
+
if let edgeEnhancement = settings.edgeEnhancement {
|
|
514
|
+
DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_photo_edge_enhancement", edgeEnhancement)
|
|
515
|
+
}
|
|
516
|
+
if let ispDigitalGain = settings.ispDigitalGain {
|
|
517
|
+
DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_photo_isp_digital_gain", ispDigitalGain)
|
|
518
|
+
}
|
|
519
|
+
if let ispAnalogGain = settings.ispAnalogGain {
|
|
520
|
+
DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_photo_isp_analog_gain", ispAnalogGain)
|
|
521
|
+
}
|
|
522
|
+
if let aeExposureDivisor = settings.aeExposureDivisor {
|
|
523
|
+
DeviceStore.shared.set(
|
|
524
|
+
ObservableStore.bluetoothCategory,
|
|
525
|
+
"button_photo_ae_exposure_divisor",
|
|
526
|
+
aeExposureDivisor
|
|
527
|
+
)
|
|
528
|
+
}
|
|
529
|
+
if let isoCap = settings.isoCap {
|
|
530
|
+
DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_photo_iso_cap", isoCap)
|
|
531
|
+
}
|
|
532
|
+
if let compress = settings.compress {
|
|
533
|
+
DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_photo_compress", compress)
|
|
534
|
+
}
|
|
535
|
+
if let sound = settings.sound {
|
|
536
|
+
DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_photo_sound", sound)
|
|
537
|
+
}
|
|
538
|
+
},
|
|
539
|
+
send: { requestId in
|
|
540
|
+
try DeviceManager.shared.sendButtonPhotoSettings(requestId: requestId, settings: settings)
|
|
541
|
+
}
|
|
485
542
|
)
|
|
486
543
|
}
|
|
487
544
|
|
|
488
|
-
public func
|
|
489
|
-
try await setButtonPhotoSettings(size: settings.size)
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
public func setButtonVideoRecordingSettings(width: Int, height: Int, fps: Int) async throws -> SettingsAckEvent {
|
|
545
|
+
public func setVideoRecordingDefaults(_ defaults: VideoRecordingDefaults) async throws -> SettingsAckEvent {
|
|
493
546
|
try await performSettingsCommand(
|
|
494
547
|
setting: "button_video_recording",
|
|
495
548
|
updateStore: { _ in
|
|
496
|
-
DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_video_width", width)
|
|
497
|
-
DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_video_height", height)
|
|
498
|
-
DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_video_fps", fps)
|
|
549
|
+
DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_video_width", defaults.width)
|
|
550
|
+
DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_video_height", defaults.height)
|
|
551
|
+
DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_video_fps", defaults.fps)
|
|
499
552
|
},
|
|
500
553
|
send: { requestId in
|
|
501
554
|
try DeviceManager.shared.sendButtonVideoRecordingSettings(
|
|
502
555
|
requestId: requestId,
|
|
503
|
-
width: width,
|
|
504
|
-
height: height,
|
|
505
|
-
fps: fps
|
|
556
|
+
width: defaults.width,
|
|
557
|
+
height: defaults.height,
|
|
558
|
+
fps: defaults.fps
|
|
506
559
|
)
|
|
507
560
|
}
|
|
508
561
|
)
|
|
509
562
|
}
|
|
510
563
|
|
|
511
|
-
public func
|
|
512
|
-
try await setButtonVideoRecordingSettings(width: settings.width, height: settings.height, fps: settings.fps)
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
public func setButtonCameraLed(enabled: Bool) async throws -> SettingsAckEvent {
|
|
516
|
-
try await performSettingsCommand(
|
|
517
|
-
setting: "button_camera_led",
|
|
518
|
-
updateStore: { _ in DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_camera_led", enabled) },
|
|
519
|
-
send: { requestId in try DeviceManager.shared.sendButtonCameraLedSetting(requestId: requestId, enabled: enabled) }
|
|
520
|
-
)
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
public func setButtonMaxRecordingTime(minutes: Int) async throws -> SettingsAckEvent {
|
|
564
|
+
public func setMaxVideoRecordingDuration(minutes: Int) async throws -> SettingsAckEvent {
|
|
524
565
|
try await performSettingsCommand(
|
|
525
566
|
setting: "button_max_recording_time",
|
|
526
567
|
updateStore: { _ in
|
|
@@ -714,19 +755,7 @@ public final class MentraBluetoothSDK {
|
|
|
714
755
|
)
|
|
715
756
|
let pending = PendingResponse<PhotoResponseEvent>(operation: "photo request \(request.requestId)")
|
|
716
757
|
pendingPhotoRequests[request.requestId] = pending
|
|
717
|
-
DeviceManager.shared.requestPhoto(
|
|
718
|
-
request.requestId,
|
|
719
|
-
request.appId,
|
|
720
|
-
request.size.rawValue,
|
|
721
|
-
request.webhookUrl,
|
|
722
|
-
request.authToken,
|
|
723
|
-
request.compress?.rawValue,
|
|
724
|
-
request.flash,
|
|
725
|
-
request.save,
|
|
726
|
-
request.sound,
|
|
727
|
-
exposureTimeNs: request.exposureTimeNs,
|
|
728
|
-
iso: request.iso
|
|
729
|
-
)
|
|
758
|
+
DeviceManager.shared.requestPhoto(request)
|
|
730
759
|
do {
|
|
731
760
|
let event = try await pending.wait()
|
|
732
761
|
pendingPhotoRequests.removeValue(forKey: request.requestId)
|
|
@@ -762,6 +791,14 @@ public final class MentraBluetoothSDK {
|
|
|
762
791
|
}
|
|
763
792
|
|
|
764
793
|
public func startStream(_ request: StreamRequest) async throws -> StreamStatusEvent {
|
|
794
|
+
try await startStream(request, startSdkKeepAlive: true)
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
func startExternallyManagedStream(_ request: StreamRequest) async throws -> StreamStatusEvent {
|
|
798
|
+
try await startStream(request, startSdkKeepAlive: false)
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
private func startStream(_ request: StreamRequest, startSdkKeepAlive: Bool) async throws -> StreamStatusEvent {
|
|
765
802
|
var values = request.values
|
|
766
803
|
let streamId = stringValue(values, "streamId").flatMap { $0.isEmpty ? nil : $0 } ?? "sdk-\(UUID().uuidString)"
|
|
767
804
|
values["streamId"] = streamId
|
|
@@ -772,8 +809,11 @@ public final class MentraBluetoothSDK {
|
|
|
772
809
|
do {
|
|
773
810
|
let event = try await pending.wait(timeoutMs: 30_000)
|
|
774
811
|
pendingStreamStarts.removeValue(forKey: streamId)
|
|
775
|
-
if
|
|
776
|
-
startStreamKeepAliveMonitor(
|
|
812
|
+
if startSdkKeepAlive {
|
|
813
|
+
startStreamKeepAliveMonitor(
|
|
814
|
+
streamId: streamId,
|
|
815
|
+
intervalSeconds: Self.defaultStreamKeepAliveIntervalSeconds
|
|
816
|
+
)
|
|
777
817
|
}
|
|
778
818
|
return event
|
|
779
819
|
} catch {
|
|
@@ -926,8 +966,43 @@ public final class MentraBluetoothSDK {
|
|
|
926
966
|
}
|
|
927
967
|
}
|
|
928
968
|
|
|
929
|
-
|
|
930
|
-
|
|
969
|
+
func setOtaVersionUrl(_ otaVersionUrl: String) throws {
|
|
970
|
+
configuredOtaVersionUrl = try OtaManifestChecker.normalizeHttpUrl(otaVersionUrl)
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
func getOtaVersionUrl() throws -> String {
|
|
974
|
+
try configuredOtaVersionUrl ?? OtaManifestDefaults.defaultOtaVersionUrl()
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/// Fetch the configured OTA manifest and return whether any ASG/BES/MTK update is available.
|
|
978
|
+
public func checkForOtaUpdate() async throws -> Bool {
|
|
979
|
+
let status = await getFreshGlassesStatus()
|
|
980
|
+
guard status.connected else {
|
|
981
|
+
throw BluetoothError(
|
|
982
|
+
code: "glasses_not_connected",
|
|
983
|
+
message: "Cannot check OTA update because glasses are not connected."
|
|
984
|
+
)
|
|
985
|
+
}
|
|
986
|
+
guard !status.buildNumber.isEmpty else {
|
|
987
|
+
throw BluetoothError(
|
|
988
|
+
code: "missing_glasses_version",
|
|
989
|
+
message: "Cannot check OTA update because glasses build number is unavailable."
|
|
990
|
+
)
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
let manifestUrl = try resolveOtaVersionUrl(status: status)
|
|
994
|
+
let manifest = try await OtaManifestChecker.fetch(manifestUrl)
|
|
995
|
+
let otaStatus = try await waitForOtaManifestStatus(status, manifest: manifest)
|
|
996
|
+
return try OtaManifestChecker.hasUpdate(
|
|
997
|
+
currentBuildNumber: otaStatus.buildNumber,
|
|
998
|
+
currentMtkVersion: otaStatus.mtkFirmwareVersion,
|
|
999
|
+
currentBesVersion: otaStatus.besFirmwareVersion,
|
|
1000
|
+
manifest: manifest
|
|
1001
|
+
)
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/// Ask connected Mentra Live glasses to report the current OTA install/session status.
|
|
1005
|
+
private func queryOtaStatus() async throws -> OtaQueryResult {
|
|
931
1006
|
try await performOtaQuery(operation: "OTA status query") {
|
|
932
1007
|
DeviceManager.shared.sendOtaQueryStatus()
|
|
933
1008
|
}
|
|
@@ -962,6 +1037,12 @@ public final class MentraBluetoothSDK {
|
|
|
962
1037
|
|
|
963
1038
|
/// Start the OTA flow after your app has presented the available update to the user.
|
|
964
1039
|
public func startOtaUpdate() async throws -> OtaStartAckEvent {
|
|
1040
|
+
let status = await getFreshGlassesStatus()
|
|
1041
|
+
let otaVersionUrl = try resolveOtaVersionUrl(status: status)
|
|
1042
|
+
return try await startOtaUpdate(otaVersionUrl: otaVersionUrl)
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
private func startOtaCommand(otaVersionUrl: String) async throws -> OtaStartAckEvent {
|
|
965
1046
|
if pendingOtaStart != nil {
|
|
966
1047
|
throw BluetoothError(
|
|
967
1048
|
code: "request_in_flight",
|
|
@@ -970,7 +1051,7 @@ public final class MentraBluetoothSDK {
|
|
|
970
1051
|
}
|
|
971
1052
|
let pending = PendingResponse<OtaStartAckEvent>(operation: "OTA start command")
|
|
972
1053
|
pendingOtaStart = pending
|
|
973
|
-
DeviceManager.shared.sendOtaStart()
|
|
1054
|
+
DeviceManager.shared.sendOtaStart(otaVersionUrl: otaVersionUrl)
|
|
974
1055
|
do {
|
|
975
1056
|
let event = try await pending.wait()
|
|
976
1057
|
if pendingOtaStart === pending {
|
|
@@ -985,16 +1066,102 @@ public final class MentraBluetoothSDK {
|
|
|
985
1066
|
}
|
|
986
1067
|
}
|
|
987
1068
|
|
|
988
|
-
|
|
989
|
-
|
|
1069
|
+
func startOtaUpdate(otaVersionUrl: String) async throws -> OtaStartAckEvent {
|
|
1070
|
+
try await startOtaCommand(otaVersionUrl: otaVersionUrl)
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
func sendOtaQueryStatus() async throws -> OtaQueryResult { try await queryOtaStatus() }
|
|
1074
|
+
|
|
1075
|
+
/// Re-run the glasses-side OTA version check after an internal clock-skew recovery.
|
|
1076
|
+
func retryOtaVersionCheck() async throws -> OtaQueryResult {
|
|
990
1077
|
try await performOtaQuery(operation: "OTA version retry") {
|
|
991
1078
|
DeviceManager.shared.retryOtaVersionCheck()
|
|
992
1079
|
}
|
|
993
1080
|
}
|
|
994
1081
|
|
|
995
|
-
func
|
|
1082
|
+
private func getFreshGlassesStatus() async -> GlassesStatus {
|
|
1083
|
+
let status = glassesStatus
|
|
1084
|
+
if !status.connected || !status.buildNumber.isEmpty {
|
|
1085
|
+
return status
|
|
1086
|
+
}
|
|
996
1087
|
|
|
997
|
-
|
|
1088
|
+
do {
|
|
1089
|
+
let versionInfo = try await requestVersionInfo()
|
|
1090
|
+
let values = status.values.merging(versionInfo.dictionary) { existing, new in
|
|
1091
|
+
if let newString = new as? String, newString.isEmpty {
|
|
1092
|
+
return existing
|
|
1093
|
+
}
|
|
1094
|
+
return new
|
|
1095
|
+
}
|
|
1096
|
+
return GlassesStatus(values: values)
|
|
1097
|
+
} catch {
|
|
1098
|
+
return status
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
private func waitForOtaManifestStatus(_ initialStatus: GlassesStatus, manifest: OtaManifest) async throws -> GlassesStatus {
|
|
1103
|
+
var status = initialStatus
|
|
1104
|
+
if OtaManifestChecker.hasBesFirmware(manifest), status.besFirmwareVersion.isEmpty {
|
|
1105
|
+
status = await waitForGlassesStatus(status, timeoutMs: Self.otaBesVersionWaitMs) {
|
|
1106
|
+
!$0.connected || !$0.besFirmwareVersion.isEmpty
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
if OtaManifestChecker.hasMtkPatches(manifest), status.mtkFirmwareVersion.isEmpty {
|
|
1111
|
+
status = await waitForGlassesStatus(status, timeoutMs: Self.otaMtkVersionWaitMs) {
|
|
1112
|
+
!$0.connected || !$0.mtkFirmwareVersion.isEmpty
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
guard status.connected else {
|
|
1117
|
+
throw BluetoothError(
|
|
1118
|
+
code: "glasses_not_connected",
|
|
1119
|
+
message: "Cannot check OTA update because glasses disconnected."
|
|
1120
|
+
)
|
|
1121
|
+
}
|
|
1122
|
+
return status
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
private func waitForGlassesStatus(
|
|
1126
|
+
_ initialStatus: GlassesStatus,
|
|
1127
|
+
timeoutMs: Int,
|
|
1128
|
+
isReady: (GlassesStatus) -> Bool
|
|
1129
|
+
) async -> GlassesStatus {
|
|
1130
|
+
let deadline = Date().addingTimeInterval(Double(timeoutMs) / 1_000)
|
|
1131
|
+
var status = initialStatus
|
|
1132
|
+
while Date() < deadline {
|
|
1133
|
+
status = glassesStatus
|
|
1134
|
+
if isReady(status) {
|
|
1135
|
+
return status
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
let remainingMs = max(0, Int(deadline.timeIntervalSinceNow * 1_000))
|
|
1139
|
+
let sleepMs = min(Self.otaVersionPollMs, remainingMs)
|
|
1140
|
+
if sleepMs <= 0 {
|
|
1141
|
+
break
|
|
1142
|
+
}
|
|
1143
|
+
try? await Task.sleep(nanoseconds: UInt64(sleepMs) * 1_000_000)
|
|
1144
|
+
}
|
|
1145
|
+
return glassesStatus
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
private func resolveOtaVersionUrl(status: GlassesStatus) throws -> String {
|
|
1149
|
+
let deviceUrl = status.otaVersionUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
1150
|
+
if isLegacyAsgOtaStartBuild(status.buildNumber) {
|
|
1151
|
+
return deviceUrl.isEmpty ? OtaManifestDefaults.prodOtaVersionUrl : deviceUrl
|
|
1152
|
+
}
|
|
1153
|
+
// SDK consumers are pinned to the manifest built for their SDK version.
|
|
1154
|
+
// A future glasses-advertised URL should not silently change that pairing.
|
|
1155
|
+
if let configuredOtaVersionUrl {
|
|
1156
|
+
return configuredOtaVersionUrl
|
|
1157
|
+
}
|
|
1158
|
+
return try OtaManifestDefaults.defaultOtaVersionUrl()
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
private func isLegacyAsgOtaStartBuild(_ buildNumber: String) -> Bool {
|
|
1162
|
+
guard let parsed = Int(buildNumber) else { return false }
|
|
1163
|
+
return parsed < 100_000
|
|
1164
|
+
}
|
|
998
1165
|
|
|
999
1166
|
func sendShutdown() {
|
|
1000
1167
|
DeviceManager.shared.sendShutdown()
|
|
@@ -1103,7 +1270,7 @@ public final class MentraBluetoothSDK {
|
|
|
1103
1270
|
}
|
|
1104
1271
|
|
|
1105
1272
|
private func startStreamKeepAliveMonitor(streamId: String, intervalSeconds requestedIntervalSeconds: Int) {
|
|
1106
|
-
let intervalSeconds = requestedIntervalSeconds > 0 ? requestedIntervalSeconds :
|
|
1273
|
+
let intervalSeconds = requestedIntervalSeconds > 0 ? requestedIntervalSeconds : Self.defaultStreamKeepAliveIntervalSeconds
|
|
1107
1274
|
let tracker = ActiveStreamKeepAlive(streamId: streamId, intervalSeconds: intervalSeconds)
|
|
1108
1275
|
activeStreamKeepAlive = tracker
|
|
1109
1276
|
sendNextStreamKeepAlive(for: tracker)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
@MainActor
|
|
4
|
+
public enum MentraBluetoothSDKDebug {
|
|
5
|
+
public static func setOtaVersionUrl(_ otaVersionUrl: String, on sdk: MentraBluetoothSDK) throws {
|
|
6
|
+
try sdk.setOtaVersionUrl(otaVersionUrl)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
public static func getOtaVersionUrl(on sdk: MentraBluetoothSDK) throws -> String {
|
|
10
|
+
try sdk.getOtaVersionUrl()
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -54,6 +54,17 @@ class ObservableStore {
|
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
func remove(_ category: String, _ key: String) {
|
|
58
|
+
let normalizedCategory = Self.normalizeCategory(category)
|
|
59
|
+
let fullKey = "\(normalizedCategory).\(key)"
|
|
60
|
+
guard values[fullKey] != nil else { return }
|
|
61
|
+
values.removeValue(forKey: fullKey)
|
|
62
|
+
// Emit updated category snapshot so UI listeners clear the removed key
|
|
63
|
+
let snapshot = getCategory(normalizedCategory)
|
|
64
|
+
onEmit?(normalizedCategory, snapshot)
|
|
65
|
+
for listener in Array(listeners.values) { listener(normalizedCategory, snapshot) }
|
|
66
|
+
}
|
|
67
|
+
|
|
57
68
|
func get(_ category: String, _ key: String) -> Any? {
|
|
58
69
|
values["\(Self.normalizeCategory(category)).\(key)"]
|
|
59
70
|
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
enum OtaManifestDefaults {
|
|
4
|
+
private static let sdkOtaReleaseBaseUrl = "https://github.com/Mentra-Community/MentraOS/releases/download/bluetooth-sdk-ota"
|
|
5
|
+
// Keep prod as the legacy-device fallback: pre-override ASG builds ignore
|
|
6
|
+
// ota_start.ota_version_url and use their compiled MentraOS default.
|
|
7
|
+
static let prodOtaVersionUrl = "https://ota.mentraglass.com/prod_live_version.json"
|
|
8
|
+
|
|
9
|
+
static func defaultOtaVersionUrl() throws -> String {
|
|
10
|
+
guard let sdkVersion = BluetoothSdkDefaults.sdkVersion,
|
|
11
|
+
!sdkVersion.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
12
|
+
else {
|
|
13
|
+
throw BluetoothError(
|
|
14
|
+
code: "missing_sdk_version",
|
|
15
|
+
message: "Cannot determine Bluetooth SDK version for the default OTA manifest URL."
|
|
16
|
+
)
|
|
17
|
+
}
|
|
18
|
+
return "\(sdkOtaReleaseBaseUrl)/bluetooth-sdk-\(sdkVersion)-version.json"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
struct OtaManifestApp: Decodable {
|
|
23
|
+
let versionCode: Int?
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
struct MtkPatch: Decodable {
|
|
27
|
+
let startFirmware: String
|
|
28
|
+
|
|
29
|
+
enum CodingKeys: String, CodingKey {
|
|
30
|
+
case startFirmware = "start_firmware"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
struct BesFirmware: Decodable {
|
|
35
|
+
let version: String?
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
struct OtaManifest: Decodable {
|
|
39
|
+
let apps: [String: OtaManifestApp]?
|
|
40
|
+
let mtkPatches: [MtkPatch]?
|
|
41
|
+
let besFirmware: BesFirmware?
|
|
42
|
+
let versionCode: Int?
|
|
43
|
+
|
|
44
|
+
enum CodingKeys: String, CodingKey {
|
|
45
|
+
case apps
|
|
46
|
+
case mtkPatches = "mtk_patches"
|
|
47
|
+
case besFirmware = "bes_firmware"
|
|
48
|
+
case versionCode
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
enum OtaManifestChecker {
|
|
53
|
+
private static let asgClientPackage = "com.mentra.asg_client"
|
|
54
|
+
|
|
55
|
+
static func normalizeHttpUrl(_ value: String) throws -> String {
|
|
56
|
+
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
57
|
+
guard !trimmed.isEmpty else {
|
|
58
|
+
throw BluetoothError(code: "invalid_ota_url", message: "OTA version URL must be a non-empty http(s) URL.")
|
|
59
|
+
}
|
|
60
|
+
guard let url = URL(string: trimmed),
|
|
61
|
+
let scheme = url.scheme?.lowercased(),
|
|
62
|
+
(scheme == "http" || scheme == "https"),
|
|
63
|
+
url.host?.isEmpty == false
|
|
64
|
+
else {
|
|
65
|
+
throw BluetoothError(code: "invalid_ota_url", message: "OTA version URL must be a valid http(s) URL.")
|
|
66
|
+
}
|
|
67
|
+
return url.absoluteString
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
static func fetch(_ otaVersionUrl: String) async throws -> OtaManifest {
|
|
71
|
+
guard let url = URL(string: otaVersionUrl) else {
|
|
72
|
+
throw BluetoothError(code: "invalid_ota_url", message: "OTA version URL must be a valid http(s) URL.")
|
|
73
|
+
}
|
|
74
|
+
let (data, response) = try await URLSession.shared.data(from: url)
|
|
75
|
+
guard let httpResponse = response as? HTTPURLResponse else {
|
|
76
|
+
throw BluetoothError(code: "ota_manifest_request_failed", message: "OTA manifest request failed.")
|
|
77
|
+
}
|
|
78
|
+
guard (200 ... 299).contains(httpResponse.statusCode) else {
|
|
79
|
+
throw BluetoothError(
|
|
80
|
+
code: "ota_manifest_request_failed",
|
|
81
|
+
message: "OTA manifest request failed with HTTP \(httpResponse.statusCode)."
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
return try JSONDecoder().decode(OtaManifest.self, from: data)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
static func hasUpdate(
|
|
88
|
+
currentBuildNumber: String,
|
|
89
|
+
currentMtkVersion: String,
|
|
90
|
+
currentBesVersion: String,
|
|
91
|
+
manifest: OtaManifest
|
|
92
|
+
) throws -> Bool {
|
|
93
|
+
try hasApkUpdate(currentBuildNumber: currentBuildNumber, manifest: manifest) ||
|
|
94
|
+
hasMtkUpdate(patches: manifest.mtkPatches, currentVersion: currentMtkVersion) ||
|
|
95
|
+
hasBesUpdate(besFirmware: manifest.besFirmware, currentVersion: currentBesVersion)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
static func hasMtkPatches(_ manifest: OtaManifest) -> Bool {
|
|
99
|
+
!(manifest.mtkPatches?.isEmpty ?? true)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
static func hasBesFirmware(_ manifest: OtaManifest) -> Bool {
|
|
103
|
+
manifest.besFirmware != nil
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private static func latestAppInfo(_ manifest: OtaManifest) throws -> OtaManifestApp {
|
|
107
|
+
if let app = manifest.apps?[asgClientPackage], app.versionCode != nil {
|
|
108
|
+
return app
|
|
109
|
+
}
|
|
110
|
+
if let versionCode = manifest.versionCode {
|
|
111
|
+
return OtaManifestApp(versionCode: versionCode)
|
|
112
|
+
}
|
|
113
|
+
throw BluetoothError(code: "invalid_ota_manifest", message: "OTA manifest is missing ASG app versionCode.")
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private static func hasApkUpdate(currentBuildNumber: String, manifest: OtaManifest) throws -> Bool {
|
|
117
|
+
guard let currentVersion = Int(currentBuildNumber) else {
|
|
118
|
+
throw BluetoothError(
|
|
119
|
+
code: "invalid_glasses_version",
|
|
120
|
+
message: "Cannot check OTA update because glasses build number is invalid."
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
guard let serverVersion = try latestAppInfo(manifest).versionCode else {
|
|
124
|
+
throw BluetoothError(code: "invalid_ota_manifest", message: "OTA manifest is missing ASG app versionCode.")
|
|
125
|
+
}
|
|
126
|
+
return serverVersion > currentVersion
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private static func hasMtkUpdate(patches: [MtkPatch]?, currentVersion: String) throws -> Bool {
|
|
130
|
+
guard let patches, !patches.isEmpty else { return false }
|
|
131
|
+
guard !currentVersion.isEmpty else { return false }
|
|
132
|
+
|
|
133
|
+
return patches.contains { patch in
|
|
134
|
+
if patch.startFirmware == currentVersion {
|
|
135
|
+
return true
|
|
136
|
+
}
|
|
137
|
+
let serverDate = patch.startFirmware.contains("_")
|
|
138
|
+
? String(patch.startFirmware.split(separator: "_").last ?? "")
|
|
139
|
+
: patch.startFirmware
|
|
140
|
+
return serverDate == currentVersion
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private static func hasBesUpdate(besFirmware: BesFirmware?, currentVersion: String) throws -> Bool {
|
|
145
|
+
guard let besFirmware else { return false }
|
|
146
|
+
guard let serverVersion = besFirmware.version, !serverVersion.isEmpty else {
|
|
147
|
+
throw BluetoothError(code: "invalid_ota_manifest", message: "OTA manifest bes_firmware.version is missing.")
|
|
148
|
+
}
|
|
149
|
+
if currentVersion.isEmpty {
|
|
150
|
+
return true
|
|
151
|
+
}
|
|
152
|
+
return compareVersions(serverVersion, currentVersion) > 0
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private static func compareVersions(_ version1: String, _ version2: String) -> Int {
|
|
156
|
+
if version1.contains("."), version2.contains(".") {
|
|
157
|
+
let parts1 = version1.split(separator: ".").map { Int($0) ?? 0 }
|
|
158
|
+
let parts2 = version2.split(separator: ".").map { Int($0) ?? 0 }
|
|
159
|
+
for index in 0 ..< max(parts1.count, parts2.count) {
|
|
160
|
+
let value1 = index < parts1.count ? parts1[index] : 0
|
|
161
|
+
let value2 = index < parts2.count ? parts2[index] : 0
|
|
162
|
+
if value1 != value2 {
|
|
163
|
+
return value1 - value2
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return 0
|
|
167
|
+
}
|
|
168
|
+
return version1.compare(version2).rawValue
|
|
169
|
+
}
|
|
170
|
+
}
|