@mentra/bluetooth-sdk 0.1.12 → 0.1.13
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 +16 -9
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +51 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +73 -21
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +190 -26
- 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 +78 -10
- package/android/src/main/java/com/mentra/bluetoothsdk/controllers/ControllerManager.kt +3 -14
- package/android/src/main/java/com/mentra/bluetoothsdk/controllers/R1.kt +3 -6
- package/android/src/main/java/com/mentra/bluetoothsdk/services/PhoneMic.kt +90 -4
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +3835 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +20 -23
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/{Mach1.java → Mach1.kt} +517 -560
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +8712 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +11 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +3 -13
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +7 -14
- package/build/BluetoothSdk.types.d.ts +39 -9
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +8 -6
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +0 -2
- 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 +43 -1
- 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 +14 -5
- package/build/index.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +38 -57
- package/ios/Source/BluetoothSdkDefaults.swift +23 -3
- package/ios/Source/DeviceManager.swift +37 -26
- package/ios/Source/DeviceStore.swift +5 -1
- package/ios/Source/MentraBluetoothSDK.swift +197 -26
- 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 +218 -8
- package/ios/Source/controllers/ControllerManager.swift +2 -5
- package/ios/Source/controllers/R1.swift +2 -5
- package/ios/Source/sgcs/G1.swift +6 -5
- package/ios/Source/sgcs/G2.swift +20 -18
- package/ios/Source/sgcs/Mach1.swift +6 -5
- package/ios/Source/sgcs/MentraLive.swift +129 -33
- package/ios/Source/sgcs/MentraNex.swift +6 -5
- package/ios/Source/sgcs/SGCManager.swift +3 -5
- package/ios/Source/sgcs/Simulated.swift +7 -3
- package/ios/Source/status/DeviceStatus.swift +1 -1
- package/package.json +6 -1
- package/src/BluetoothSdk.types.ts +40 -9
- package/src/_private/BluetoothSdkModule.ts +8 -9
- package/src/_private/photoRequestPayload.ts +45 -2
- package/src/debug.ts +9 -0
- package/src/index.ts +23 -6
- 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
|
@@ -3,10 +3,16 @@ import Foundation
|
|
|
3
3
|
/// Defaults for the public Bluetooth SDK surface.
|
|
4
4
|
enum BluetoothSdkDefaults {
|
|
5
5
|
static var sdkVersion: String? {
|
|
6
|
-
|
|
6
|
+
#if SWIFT_PACKAGE
|
|
7
|
+
normalizedSdkVersion(swiftPackageSdkVersion)
|
|
8
|
+
#else
|
|
9
|
+
packageVersion(from: sdkBundle)
|
|
10
|
+
#endif
|
|
7
11
|
}
|
|
8
12
|
|
|
9
13
|
static let voiceActivityDetectionEnabled = false
|
|
14
|
+
private static let swiftPackageSdkVersion = "__MENTRA_BLUETOOTH_SDK_VERSION__"
|
|
15
|
+
private static let swiftPackageSdkVersionPlaceholder = "__MENTRA" + "_BLUETOOTH_SDK_VERSION__"
|
|
10
16
|
|
|
11
17
|
private static var sdkBundle: Bundle {
|
|
12
18
|
#if SWIFT_PACKAGE
|
|
@@ -21,13 +27,27 @@ enum BluetoothSdkDefaults {
|
|
|
21
27
|
let build = bundle.object(forInfoDictionaryKey: "CFBundleVersion") as? String
|
|
22
28
|
|
|
23
29
|
for candidate in [version, build] {
|
|
24
|
-
if let
|
|
25
|
-
return
|
|
30
|
+
if let sdkVersion = normalizedSdkVersion(candidate) {
|
|
31
|
+
return sdkVersion
|
|
26
32
|
}
|
|
27
33
|
}
|
|
28
34
|
|
|
29
35
|
return nil
|
|
30
36
|
}
|
|
37
|
+
|
|
38
|
+
private static func normalizedSdkVersion(_ value: String?) -> String? {
|
|
39
|
+
guard let value else {
|
|
40
|
+
return nil
|
|
41
|
+
}
|
|
42
|
+
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
43
|
+
guard !trimmed.isEmpty,
|
|
44
|
+
trimmed != "1.0",
|
|
45
|
+
trimmed != swiftPackageSdkVersionPlaceholder
|
|
46
|
+
else {
|
|
47
|
+
return nil
|
|
48
|
+
}
|
|
49
|
+
return trimmed
|
|
50
|
+
}
|
|
31
51
|
}
|
|
32
52
|
|
|
33
53
|
private final class BluetoothSdkBundleToken {}
|
|
@@ -1169,9 +1169,9 @@ struct ViewState {
|
|
|
1169
1169
|
/// Send OTA start command to glasses.
|
|
1170
1170
|
/// Called when user approves an update (onboarding or background mode).
|
|
1171
1171
|
/// Triggers glasses to begin download and installation.
|
|
1172
|
-
func sendOtaStart() {
|
|
1172
|
+
func sendOtaStart(otaVersionUrl: String? = nil) {
|
|
1173
1173
|
Bridge.log("MAN: 📱 Sending OTA start command to glasses")
|
|
1174
|
-
sgc?.sendOtaStart()
|
|
1174
|
+
sgc?.sendOtaStart(otaVersionUrl: otaVersionUrl)
|
|
1175
1175
|
}
|
|
1176
1176
|
|
|
1177
1177
|
func sendOtaQueryStatus() {
|
|
@@ -1191,7 +1191,14 @@ struct ViewState {
|
|
|
1191
1191
|
}
|
|
1192
1192
|
|
|
1193
1193
|
func sendButtonPhotoSettings(requestId: String, size: String) throws {
|
|
1194
|
-
try
|
|
1194
|
+
try sendButtonPhotoSettings(
|
|
1195
|
+
requestId: requestId,
|
|
1196
|
+
settings: ButtonPhotoSettings(size: ButtonPhotoSize(normalizedRawValue: size))
|
|
1197
|
+
)
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
func sendButtonPhotoSettings(requestId: String, settings: ButtonPhotoSettings) throws {
|
|
1201
|
+
try liveSgc().sendButtonPhotoSettings(requestId: requestId, settings: settings)
|
|
1195
1202
|
}
|
|
1196
1203
|
|
|
1197
1204
|
func sendButtonVideoRecordingSettings(requestId: String, width: Int, height: Int, fps: Int) throws {
|
|
@@ -1317,36 +1324,40 @@ struct ViewState {
|
|
|
1317
1324
|
}
|
|
1318
1325
|
}
|
|
1319
1326
|
|
|
1320
|
-
func requestPhoto(
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1327
|
+
func requestPhoto(_ request: PhotoRequest) {
|
|
1328
|
+
let manualExposureNs = request.exposureTimeNs.flatMap { $0.isFinite && $0 > 0 ? $0 : nil }
|
|
1329
|
+
let manualIso = manualExposureNs != nil ? request.iso.flatMap { $0 > 0 ? $0 : nil } : nil
|
|
1330
|
+
let routed = PhotoRequest(
|
|
1331
|
+
requestId: request.requestId,
|
|
1332
|
+
appId: request.appId,
|
|
1333
|
+
size: request.size,
|
|
1334
|
+
webhookUrl: request.webhookUrl,
|
|
1335
|
+
authToken: request.authToken,
|
|
1336
|
+
compress: request.compress,
|
|
1337
|
+
flash: request.flash,
|
|
1338
|
+
save: request.save,
|
|
1339
|
+
sound: request.sound,
|
|
1340
|
+
exposureTimeNs: manualExposureNs,
|
|
1341
|
+
iso: manualIso,
|
|
1342
|
+
aeExposureDivisor: request.aeExposureDivisor,
|
|
1343
|
+
isoCap: request.isoCap,
|
|
1344
|
+
noiseReduction: request.noiseReduction,
|
|
1345
|
+
edgeEnhancement: request.edgeEnhancement,
|
|
1346
|
+
mfnr: request.mfnr,
|
|
1347
|
+
zsl: request.zsl,
|
|
1348
|
+
ispDigitalGain: request.ispDigitalGain,
|
|
1349
|
+
ispAnalogGain: request.ispAnalogGain
|
|
1350
|
+
)
|
|
1337
1351
|
Bridge.log(
|
|
1338
|
-
"MAN: PHOTO PIPELINE [4/6] DeviceManager.requestPhoto requestId=\(requestId) appId=\(appId) webhookUrl=\(webhookUrl ?? "nil") size=\(size) compress=\(compress ?? "none") flash=\(flash) save=\(save) sound=\(sound) exposureTimeNs=\(manualExposureNs.map { String($0) } ?? "nil") iso=\(manualIso.map { String($0) } ?? "auto") sgc=\(sgc != nil ? String(describing: type(of: sgc!)) : "null")"
|
|
1352
|
+
"MAN: PHOTO PIPELINE [4/6] DeviceManager.requestPhoto requestId=\(routed.requestId) appId=\(routed.appId) webhookUrl=\(routed.webhookUrl ?? "nil") size=\(routed.size.rawValue) compress=\(routed.compress?.rawValue ?? "none") flash=\(routed.flash) save=\(routed.save) sound=\(routed.sound) exposureTimeNs=\(manualExposureNs.map { String($0) } ?? "nil") iso=\(manualIso.map { String($0) } ?? "auto") aeDivisor=\(routed.aeExposureDivisor.map { String($0) } ?? "nil") isoCap=\(routed.isoCap.map { String($0) } ?? "nil") sgc=\(sgc != nil ? String(describing: type(of: sgc!)) : "null")"
|
|
1339
1353
|
)
|
|
1340
1354
|
guard let sgc else {
|
|
1341
1355
|
Bridge.log(
|
|
1342
|
-
"MAN: PHOTO PIPELINE — sgc is null (glasses not connected); dropping requestId=\(requestId)"
|
|
1356
|
+
"MAN: PHOTO PIPELINE — sgc is null (glasses not connected); dropping requestId=\(routed.requestId)"
|
|
1343
1357
|
)
|
|
1344
1358
|
return
|
|
1345
1359
|
}
|
|
1346
|
-
sgc.requestPhoto(
|
|
1347
|
-
requestId, appId: appId, size: size, webhookUrl: webhookUrl, authToken: authToken,
|
|
1348
|
-
compress: compress, flash: flash, save: save, sound: sound, exposureTimeNs: manualExposureNs, iso: manualIso
|
|
1349
|
-
)
|
|
1360
|
+
sgc.requestPhoto(routed)
|
|
1350
1361
|
}
|
|
1351
1362
|
|
|
1352
1363
|
func connectDefault() {
|
|
@@ -91,7 +91,7 @@ class DeviceStore {
|
|
|
91
91
|
// Mentra Nex feature flag (off by default; toggled from Nex Developer Settings):
|
|
92
92
|
store.set("bluetooth", "nex_chinese_captions", false)
|
|
93
93
|
store.set("bluetooth", "screen_disabled", false)
|
|
94
|
-
store.set("bluetooth", "button_photo_size", "
|
|
94
|
+
store.set("bluetooth", "button_photo_size", "max")
|
|
95
95
|
store.set("bluetooth", "button_camera_led", true)
|
|
96
96
|
store.set("bluetooth", "button_max_recording_time", 10)
|
|
97
97
|
store.set("bluetooth", "camera_fov", ["fov": 118, "roi_position": 0])
|
|
@@ -116,6 +116,10 @@ class DeviceStore {
|
|
|
116
116
|
store.set(category, key, value)
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
func remove(_ category: String, _ key: String) {
|
|
120
|
+
store.remove(category, key)
|
|
121
|
+
}
|
|
122
|
+
|
|
119
123
|
private func scheduleDashboardHeightToGlasses() {
|
|
120
124
|
dashboardHeightDebounceTask?.cancel()
|
|
121
125
|
dashboardHeightDebounceTask = Task { @MainActor in
|
|
@@ -147,6 +147,10 @@ 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
|
+
|
|
150
154
|
public weak var delegate: MentraBluetoothSDKDelegate?
|
|
151
155
|
|
|
152
156
|
private let configuration: MentraBluetoothSDKConfiguration
|
|
@@ -176,6 +180,7 @@ public final class MentraBluetoothSDK {
|
|
|
176
180
|
private var pendingWifiStatus: PendingWifiStatusRequest?
|
|
177
181
|
private var pendingHotspotStatus: PendingHotspotStatusRequest?
|
|
178
182
|
private var pendingVersionInfo: PendingResponse<VersionInfoResult>?
|
|
183
|
+
private var configuredOtaVersionUrl: String?
|
|
179
184
|
|
|
180
185
|
public init(configuration: MentraBluetoothSDKConfiguration = .default) {
|
|
181
186
|
self.configuration = configuration
|
|
@@ -478,15 +483,66 @@ public final class MentraBluetoothSDK {
|
|
|
478
483
|
}
|
|
479
484
|
|
|
480
485
|
public func setButtonPhotoSettings(size: ButtonPhotoSize) async throws -> SettingsAckEvent {
|
|
481
|
-
try await
|
|
482
|
-
setting: "button_photo",
|
|
483
|
-
updateStore: { _ in DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_photo_size", size.rawValue) },
|
|
484
|
-
send: { requestId in try DeviceManager.shared.sendButtonPhotoSettings(requestId: requestId, size: size.rawValue) }
|
|
485
|
-
)
|
|
486
|
+
try await setButtonPhotoSettings(ButtonPhotoSettings(size: size))
|
|
486
487
|
}
|
|
487
488
|
|
|
488
489
|
public func setButtonPhotoSettings(_ settings: ButtonPhotoSettings) async throws -> SettingsAckEvent {
|
|
489
|
-
try await
|
|
490
|
+
try await performSettingsCommand(
|
|
491
|
+
setting: "button_photo",
|
|
492
|
+
updateStore: { _ in
|
|
493
|
+
if settings.resetCaptureTuning == true {
|
|
494
|
+
// Mirror Android: clear all cached scan-tuning keys so reconnect sync
|
|
495
|
+
// does not replay stale values after a reset.
|
|
496
|
+
let cat = ObservableStore.bluetoothCategory
|
|
497
|
+
for key in ["button_photo_mfnr", "button_photo_zsl", "button_photo_noise_reduction",
|
|
498
|
+
"button_photo_edge_enhancement", "button_photo_isp_digital_gain",
|
|
499
|
+
"button_photo_isp_analog_gain", "button_photo_ae_exposure_divisor",
|
|
500
|
+
"button_photo_iso_cap", "button_photo_compress", "button_photo_sound"] {
|
|
501
|
+
DeviceStore.shared.remove(cat, key)
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
if let size = settings.size {
|
|
505
|
+
DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_photo_size", size.rawValue)
|
|
506
|
+
}
|
|
507
|
+
if let mfnr = settings.mfnr {
|
|
508
|
+
DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_photo_mfnr", mfnr)
|
|
509
|
+
}
|
|
510
|
+
if let zsl = settings.zsl {
|
|
511
|
+
DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_photo_zsl", zsl)
|
|
512
|
+
}
|
|
513
|
+
if let noiseReduction = settings.noiseReduction {
|
|
514
|
+
DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_photo_noise_reduction", noiseReduction)
|
|
515
|
+
}
|
|
516
|
+
if let edgeEnhancement = settings.edgeEnhancement {
|
|
517
|
+
DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_photo_edge_enhancement", edgeEnhancement)
|
|
518
|
+
}
|
|
519
|
+
if let ispDigitalGain = settings.ispDigitalGain {
|
|
520
|
+
DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_photo_isp_digital_gain", ispDigitalGain)
|
|
521
|
+
}
|
|
522
|
+
if let ispAnalogGain = settings.ispAnalogGain {
|
|
523
|
+
DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_photo_isp_analog_gain", ispAnalogGain)
|
|
524
|
+
}
|
|
525
|
+
if let aeExposureDivisor = settings.aeExposureDivisor {
|
|
526
|
+
DeviceStore.shared.set(
|
|
527
|
+
ObservableStore.bluetoothCategory,
|
|
528
|
+
"button_photo_ae_exposure_divisor",
|
|
529
|
+
aeExposureDivisor
|
|
530
|
+
)
|
|
531
|
+
}
|
|
532
|
+
if let isoCap = settings.isoCap {
|
|
533
|
+
DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_photo_iso_cap", isoCap)
|
|
534
|
+
}
|
|
535
|
+
if let compress = settings.compress {
|
|
536
|
+
DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_photo_compress", compress)
|
|
537
|
+
}
|
|
538
|
+
if let sound = settings.sound {
|
|
539
|
+
DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_photo_sound", sound)
|
|
540
|
+
}
|
|
541
|
+
},
|
|
542
|
+
send: { requestId in
|
|
543
|
+
try DeviceManager.shared.sendButtonPhotoSettings(requestId: requestId, settings: settings)
|
|
544
|
+
}
|
|
545
|
+
)
|
|
490
546
|
}
|
|
491
547
|
|
|
492
548
|
public func setButtonVideoRecordingSettings(width: Int, height: Int, fps: Int) async throws -> SettingsAckEvent {
|
|
@@ -714,19 +770,7 @@ public final class MentraBluetoothSDK {
|
|
|
714
770
|
)
|
|
715
771
|
let pending = PendingResponse<PhotoResponseEvent>(operation: "photo request \(request.requestId)")
|
|
716
772
|
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
|
-
)
|
|
773
|
+
DeviceManager.shared.requestPhoto(request)
|
|
730
774
|
do {
|
|
731
775
|
let event = try await pending.wait()
|
|
732
776
|
pendingPhotoRequests.removeValue(forKey: request.requestId)
|
|
@@ -926,8 +970,43 @@ public final class MentraBluetoothSDK {
|
|
|
926
970
|
}
|
|
927
971
|
}
|
|
928
972
|
|
|
929
|
-
|
|
930
|
-
|
|
973
|
+
func setOtaVersionUrl(_ otaVersionUrl: String) throws {
|
|
974
|
+
configuredOtaVersionUrl = try OtaManifestChecker.normalizeHttpUrl(otaVersionUrl)
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
func getOtaVersionUrl() throws -> String {
|
|
978
|
+
try configuredOtaVersionUrl ?? OtaManifestDefaults.defaultOtaVersionUrl()
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/// Fetch the configured OTA manifest and return whether any ASG/BES/MTK update is available.
|
|
982
|
+
public func checkForOtaUpdate() async throws -> Bool {
|
|
983
|
+
let status = await getFreshGlassesStatus()
|
|
984
|
+
guard status.connected else {
|
|
985
|
+
throw BluetoothError(
|
|
986
|
+
code: "glasses_not_connected",
|
|
987
|
+
message: "Cannot check OTA update because glasses are not connected."
|
|
988
|
+
)
|
|
989
|
+
}
|
|
990
|
+
guard !status.buildNumber.isEmpty else {
|
|
991
|
+
throw BluetoothError(
|
|
992
|
+
code: "missing_glasses_version",
|
|
993
|
+
message: "Cannot check OTA update because glasses build number is unavailable."
|
|
994
|
+
)
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
let manifestUrl = try resolveOtaVersionUrl(status: status)
|
|
998
|
+
let manifest = try await OtaManifestChecker.fetch(manifestUrl)
|
|
999
|
+
let otaStatus = try await waitForOtaManifestStatus(status, manifest: manifest)
|
|
1000
|
+
return try OtaManifestChecker.hasUpdate(
|
|
1001
|
+
currentBuildNumber: otaStatus.buildNumber,
|
|
1002
|
+
currentMtkVersion: otaStatus.mtkFirmwareVersion,
|
|
1003
|
+
currentBesVersion: otaStatus.besFirmwareVersion,
|
|
1004
|
+
manifest: manifest
|
|
1005
|
+
)
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
/// Ask connected Mentra Live glasses to report the current OTA install/session status.
|
|
1009
|
+
private func queryOtaStatus() async throws -> OtaQueryResult {
|
|
931
1010
|
try await performOtaQuery(operation: "OTA status query") {
|
|
932
1011
|
DeviceManager.shared.sendOtaQueryStatus()
|
|
933
1012
|
}
|
|
@@ -962,6 +1041,12 @@ public final class MentraBluetoothSDK {
|
|
|
962
1041
|
|
|
963
1042
|
/// Start the OTA flow after your app has presented the available update to the user.
|
|
964
1043
|
public func startOtaUpdate() async throws -> OtaStartAckEvent {
|
|
1044
|
+
let status = await getFreshGlassesStatus()
|
|
1045
|
+
let otaVersionUrl = try resolveOtaVersionUrl(status: status)
|
|
1046
|
+
return try await startOtaUpdate(otaVersionUrl: otaVersionUrl)
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
private func startOtaCommand(otaVersionUrl: String) async throws -> OtaStartAckEvent {
|
|
965
1050
|
if pendingOtaStart != nil {
|
|
966
1051
|
throw BluetoothError(
|
|
967
1052
|
code: "request_in_flight",
|
|
@@ -970,7 +1055,7 @@ public final class MentraBluetoothSDK {
|
|
|
970
1055
|
}
|
|
971
1056
|
let pending = PendingResponse<OtaStartAckEvent>(operation: "OTA start command")
|
|
972
1057
|
pendingOtaStart = pending
|
|
973
|
-
DeviceManager.shared.sendOtaStart()
|
|
1058
|
+
DeviceManager.shared.sendOtaStart(otaVersionUrl: otaVersionUrl)
|
|
974
1059
|
do {
|
|
975
1060
|
let event = try await pending.wait()
|
|
976
1061
|
if pendingOtaStart === pending {
|
|
@@ -985,16 +1070,102 @@ public final class MentraBluetoothSDK {
|
|
|
985
1070
|
}
|
|
986
1071
|
}
|
|
987
1072
|
|
|
988
|
-
|
|
989
|
-
|
|
1073
|
+
func startOtaUpdate(otaVersionUrl: String) async throws -> OtaStartAckEvent {
|
|
1074
|
+
try await startOtaCommand(otaVersionUrl: otaVersionUrl)
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
func sendOtaQueryStatus() async throws -> OtaQueryResult { try await queryOtaStatus() }
|
|
1078
|
+
|
|
1079
|
+
/// Re-run the glasses-side OTA version check after an internal clock-skew recovery.
|
|
1080
|
+
func retryOtaVersionCheck() async throws -> OtaQueryResult {
|
|
990
1081
|
try await performOtaQuery(operation: "OTA version retry") {
|
|
991
1082
|
DeviceManager.shared.retryOtaVersionCheck()
|
|
992
1083
|
}
|
|
993
1084
|
}
|
|
994
1085
|
|
|
995
|
-
func
|
|
1086
|
+
private func getFreshGlassesStatus() async -> GlassesStatus {
|
|
1087
|
+
let status = glassesStatus
|
|
1088
|
+
if !status.connected || !status.buildNumber.isEmpty {
|
|
1089
|
+
return status
|
|
1090
|
+
}
|
|
996
1091
|
|
|
997
|
-
|
|
1092
|
+
do {
|
|
1093
|
+
let versionInfo = try await requestVersionInfo()
|
|
1094
|
+
let values = status.values.merging(versionInfo.dictionary) { existing, new in
|
|
1095
|
+
if let newString = new as? String, newString.isEmpty {
|
|
1096
|
+
return existing
|
|
1097
|
+
}
|
|
1098
|
+
return new
|
|
1099
|
+
}
|
|
1100
|
+
return GlassesStatus(values: values)
|
|
1101
|
+
} catch {
|
|
1102
|
+
return status
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
private func waitForOtaManifestStatus(_ initialStatus: GlassesStatus, manifest: OtaManifest) async throws -> GlassesStatus {
|
|
1107
|
+
var status = initialStatus
|
|
1108
|
+
if OtaManifestChecker.hasBesFirmware(manifest), status.besFirmwareVersion.isEmpty {
|
|
1109
|
+
status = await waitForGlassesStatus(status, timeoutMs: Self.otaBesVersionWaitMs) {
|
|
1110
|
+
!$0.connected || !$0.besFirmwareVersion.isEmpty
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
if OtaManifestChecker.hasMtkPatches(manifest), status.mtkFirmwareVersion.isEmpty {
|
|
1115
|
+
status = await waitForGlassesStatus(status, timeoutMs: Self.otaMtkVersionWaitMs) {
|
|
1116
|
+
!$0.connected || !$0.mtkFirmwareVersion.isEmpty
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
guard status.connected else {
|
|
1121
|
+
throw BluetoothError(
|
|
1122
|
+
code: "glasses_not_connected",
|
|
1123
|
+
message: "Cannot check OTA update because glasses disconnected."
|
|
1124
|
+
)
|
|
1125
|
+
}
|
|
1126
|
+
return status
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
private func waitForGlassesStatus(
|
|
1130
|
+
_ initialStatus: GlassesStatus,
|
|
1131
|
+
timeoutMs: Int,
|
|
1132
|
+
isReady: (GlassesStatus) -> Bool
|
|
1133
|
+
) async -> GlassesStatus {
|
|
1134
|
+
let deadline = Date().addingTimeInterval(Double(timeoutMs) / 1_000)
|
|
1135
|
+
var status = initialStatus
|
|
1136
|
+
while Date() < deadline {
|
|
1137
|
+
status = glassesStatus
|
|
1138
|
+
if isReady(status) {
|
|
1139
|
+
return status
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
let remainingMs = max(0, Int(deadline.timeIntervalSinceNow * 1_000))
|
|
1143
|
+
let sleepMs = min(Self.otaVersionPollMs, remainingMs)
|
|
1144
|
+
if sleepMs <= 0 {
|
|
1145
|
+
break
|
|
1146
|
+
}
|
|
1147
|
+
try? await Task.sleep(nanoseconds: UInt64(sleepMs) * 1_000_000)
|
|
1148
|
+
}
|
|
1149
|
+
return glassesStatus
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
private func resolveOtaVersionUrl(status: GlassesStatus) throws -> String {
|
|
1153
|
+
let deviceUrl = status.otaVersionUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
1154
|
+
if isLegacyAsgOtaStartBuild(status.buildNumber) {
|
|
1155
|
+
return deviceUrl.isEmpty ? OtaManifestDefaults.prodOtaVersionUrl : deviceUrl
|
|
1156
|
+
}
|
|
1157
|
+
// SDK consumers are pinned to the manifest built for their SDK version.
|
|
1158
|
+
// A future glasses-advertised URL should not silently change that pairing.
|
|
1159
|
+
if let configuredOtaVersionUrl {
|
|
1160
|
+
return configuredOtaVersionUrl
|
|
1161
|
+
}
|
|
1162
|
+
return try OtaManifestDefaults.defaultOtaVersionUrl()
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
private func isLegacyAsgOtaStartBuild(_ buildNumber: String) -> Bool {
|
|
1166
|
+
guard let parsed = Int(buildNumber) else { return false }
|
|
1167
|
+
return parsed < 100_000
|
|
1168
|
+
}
|
|
998
1169
|
|
|
999
1170
|
func sendShutdown() {
|
|
1000
1171
|
DeviceManager.shared.sendShutdown()
|
|
@@ -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
|
+
}
|