@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.
Files changed (65) hide show
  1. package/README.md +16 -9
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +51 -3
  3. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +73 -21
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +1 -1
  5. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +190 -26
  6. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdkDebug.kt +14 -0
  7. package/android/src/main/java/com/mentra/bluetoothsdk/ObservableStore.kt +20 -0
  8. package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +153 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +78 -10
  10. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/ControllerManager.kt +3 -14
  11. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/R1.kt +3 -6
  12. package/android/src/main/java/com/mentra/bluetoothsdk/services/PhoneMic.kt +90 -4
  13. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +3835 -0
  14. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +20 -23
  15. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/{Mach1.java → Mach1.kt} +517 -560
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +8712 -0
  17. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +11 -1
  18. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +3 -13
  19. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +7 -14
  20. package/build/BluetoothSdk.types.d.ts +39 -9
  21. package/build/BluetoothSdk.types.d.ts.map +1 -1
  22. package/build/BluetoothSdk.types.js.map +1 -1
  23. package/build/_private/BluetoothSdkModule.d.ts +8 -6
  24. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  25. package/build/_private/BluetoothSdkModule.js +0 -2
  26. package/build/_private/BluetoothSdkModule.js.map +1 -1
  27. package/build/_private/photoRequestPayload.d.ts +3 -1
  28. package/build/_private/photoRequestPayload.d.ts.map +1 -1
  29. package/build/_private/photoRequestPayload.js +43 -1
  30. package/build/_private/photoRequestPayload.js.map +1 -1
  31. package/build/debug.d.ts +3 -0
  32. package/build/debug.d.ts.map +1 -0
  33. package/build/debug.js +8 -0
  34. package/build/debug.js.map +1 -0
  35. package/build/index.d.ts +1 -1
  36. package/build/index.d.ts.map +1 -1
  37. package/build/index.js +14 -5
  38. package/build/index.js.map +1 -1
  39. package/ios/BluetoothSdkModule.swift +38 -57
  40. package/ios/Source/BluetoothSdkDefaults.swift +23 -3
  41. package/ios/Source/DeviceManager.swift +37 -26
  42. package/ios/Source/DeviceStore.swift +5 -1
  43. package/ios/Source/MentraBluetoothSDK.swift +197 -26
  44. package/ios/Source/MentraBluetoothSDKDebug.swift +12 -0
  45. package/ios/Source/ObservableStore.swift +11 -0
  46. package/ios/Source/OtaManifest.swift +170 -0
  47. package/ios/Source/camera/CameraModels.swift +218 -8
  48. package/ios/Source/controllers/ControllerManager.swift +2 -5
  49. package/ios/Source/controllers/R1.swift +2 -5
  50. package/ios/Source/sgcs/G1.swift +6 -5
  51. package/ios/Source/sgcs/G2.swift +20 -18
  52. package/ios/Source/sgcs/Mach1.swift +6 -5
  53. package/ios/Source/sgcs/MentraLive.swift +129 -33
  54. package/ios/Source/sgcs/MentraNex.swift +6 -5
  55. package/ios/Source/sgcs/SGCManager.swift +3 -5
  56. package/ios/Source/sgcs/Simulated.swift +7 -3
  57. package/ios/Source/status/DeviceStatus.swift +1 -1
  58. package/package.json +6 -1
  59. package/src/BluetoothSdk.types.ts +40 -9
  60. package/src/_private/BluetoothSdkModule.ts +8 -9
  61. package/src/_private/photoRequestPayload.ts +45 -2
  62. package/src/debug.ts +9 -0
  63. package/src/index.ts +23 -6
  64. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.java +0 -3974
  65. 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
- packageVersion(from: sdkBundle)
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 candidate, !candidate.isEmpty, candidate != "1.0" {
25
- return candidate
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 liveSgc().sendButtonPhotoSettings(requestId: requestId, size: size)
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
- _ requestId: String,
1322
- _ appId: String,
1323
- _ size: String,
1324
- _ webhookUrl: String?,
1325
- _ authToken: String?,
1326
- _ compress: String?,
1327
- _ flash: Bool,
1328
- _ save: Bool,
1329
- _ sound: Bool,
1330
- exposureTimeNs: Double? = nil,
1331
- iso: Int? = nil
1332
- ) {
1333
- // Only honor manual exposure when it is a usable value; manual ISO is a one-shot
1334
- // companion to manual exposure and must be dropped when exposure is invalid.
1335
- let manualExposureNs = exposureTimeNs.flatMap { $0.isFinite && $0 > 0 ? $0 : nil }
1336
- let manualIso = manualExposureNs != nil ? iso.flatMap { $0 > 0 ? $0 : nil } : nil
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", "medium")
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 performSettingsCommand(
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 setButtonPhotoSettings(size: settings.size)
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
- /// Ask connected Mentra Live glasses to check/report OTA availability and status.
930
- public func checkForOtaUpdate() async throws -> OtaQueryResult {
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
- /// Re-run the glasses-side OTA version check, mainly after correcting clock skew/TLS failures.
989
- public func retryOtaVersionCheck() async throws -> OtaQueryResult {
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 sendOtaStart() async throws -> OtaStartAckEvent { try await startOtaUpdate() }
1086
+ private func getFreshGlassesStatus() async -> GlassesStatus {
1087
+ let status = glassesStatus
1088
+ if !status.connected || !status.buildNumber.isEmpty {
1089
+ return status
1090
+ }
996
1091
 
997
- func sendOtaQueryStatus() async throws -> OtaQueryResult { try await checkForOtaUpdate() }
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
+ }