@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.
Files changed (69) hide show
  1. package/README.md +30 -11
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +47 -12
  3. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +75 -29
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +1 -5
  5. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +208 -45
  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 +80 -14
  10. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/ControllerManager.kt +4 -16
  11. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/R1.kt +4 -8
  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 +3831 -0
  14. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +20 -28
  15. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/{Mach1.java → Mach1.kt} +514 -561
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +8674 -0
  17. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +12 -3
  18. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +5 -17
  19. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +9 -20
  20. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +0 -6
  21. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +16 -33
  22. package/build/BluetoothSdk.types.d.ts +48 -17
  23. package/build/BluetoothSdk.types.d.ts.map +1 -1
  24. package/build/BluetoothSdk.types.js.map +1 -1
  25. package/build/_private/BluetoothSdkModule.d.ts +9 -9
  26. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  27. package/build/_private/BluetoothSdkModule.js +0 -6
  28. package/build/_private/BluetoothSdkModule.js.map +1 -1
  29. package/build/_private/photoRequestPayload.d.ts +3 -1
  30. package/build/_private/photoRequestPayload.d.ts.map +1 -1
  31. package/build/_private/photoRequestPayload.js +46 -2
  32. package/build/_private/photoRequestPayload.js.map +1 -1
  33. package/build/debug.d.ts +3 -0
  34. package/build/debug.d.ts.map +1 -0
  35. package/build/debug.js +8 -0
  36. package/build/debug.js.map +1 -0
  37. package/build/index.d.ts +1 -1
  38. package/build/index.d.ts.map +1 -1
  39. package/build/index.js +8 -8
  40. package/build/index.js.map +1 -1
  41. package/ios/BluetoothSdkModule.swift +44 -68
  42. package/ios/Source/BluetoothSdkDefaults.swift +23 -3
  43. package/ios/Source/DeviceManager.swift +38 -33
  44. package/ios/Source/DeviceStore.swift +5 -5
  45. package/ios/Source/MentraBluetoothSDK.swift +217 -50
  46. package/ios/Source/MentraBluetoothSDKDebug.swift +12 -0
  47. package/ios/Source/ObservableStore.swift +11 -0
  48. package/ios/Source/OtaManifest.swift +170 -0
  49. package/ios/Source/camera/CameraModels.swift +219 -13
  50. package/ios/Source/controllers/ControllerManager.swift +3 -7
  51. package/ios/Source/controllers/R1.swift +3 -7
  52. package/ios/Source/sgcs/Frame.swift +0 -2
  53. package/ios/Source/sgcs/G1.swift +7 -8
  54. package/ios/Source/sgcs/G2.swift +21 -20
  55. package/ios/Source/sgcs/Mach1.swift +7 -8
  56. package/ios/Source/sgcs/MentraLive.swift +132 -63
  57. package/ios/Source/sgcs/MentraNex.swift +7 -8
  58. package/ios/Source/sgcs/SGCManager.swift +7 -10
  59. package/ios/Source/sgcs/Simulated.swift +8 -8
  60. package/ios/Source/status/DeviceStatus.swift +1 -9
  61. package/ios/Source/streaming/StreamModels.swift +7 -32
  62. package/package.json +6 -1
  63. package/src/BluetoothSdk.types.ts +49 -17
  64. package/src/_private/BluetoothSdkModule.ts +9 -17
  65. package/src/_private/photoRequestPayload.ts +48 -3
  66. package/src/debug.ts +9 -0
  67. package/src/index.ts +19 -10
  68. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.java +0 -3974
  69. 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 setButtonPhotoSettings(size: ButtonPhotoSize) async throws -> SettingsAckEvent {
486
+ public func setPhotoCaptureDefaults(_ settings: PhotoCaptureDefaults) async throws -> SettingsAckEvent {
481
487
  try await performSettingsCommand(
482
488
  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) }
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 setButtonPhotoSettings(_ settings: ButtonPhotoSettings) async throws -> SettingsAckEvent {
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 setButtonVideoRecordingSettings(_ settings: ButtonVideoRecordingSettings) async throws -> SettingsAckEvent {
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 request.keepAlive, !request.isExternallyManagedKeepAlive {
776
- startStreamKeepAliveMonitor(streamId: streamId, intervalSeconds: request.keepAliveIntervalSeconds)
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
- /// Ask connected Mentra Live glasses to check/report OTA availability and status.
930
- public func checkForOtaUpdate() async throws -> OtaQueryResult {
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
- /// Re-run the glasses-side OTA version check, mainly after correcting clock skew/TLS failures.
989
- public func retryOtaVersionCheck() async throws -> OtaQueryResult {
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 sendOtaStart() async throws -> OtaStartAckEvent { try await startOtaUpdate() }
1082
+ private func getFreshGlassesStatus() async -> GlassesStatus {
1083
+ let status = glassesStatus
1084
+ if !status.connected || !status.buildNumber.isEmpty {
1085
+ return status
1086
+ }
996
1087
 
997
- func sendOtaQueryStatus() async throws -> OtaQueryResult { try await checkForOtaUpdate() }
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 : 5
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
+ }