@mentra/bluetooth-sdk 0.1.11 → 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 (80) hide show
  1. package/README.md +67 -11
  2. package/android/build.gradle +1 -0
  3. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalytics.kt +182 -0
  4. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +73 -6
  5. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +10 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +83 -26
  7. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +1 -1
  8. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +266 -31
  9. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdkDebug.kt +14 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/ObservableStore.kt +20 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +153 -0
  12. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +93 -10
  13. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/ControllerManager.kt +3 -14
  14. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/R1.kt +3 -6
  15. package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +1 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/services/PhoneMic.kt +90 -4
  17. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +3835 -0
  18. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +20 -23
  19. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/{Mach1.java → Mach1.kt} +517 -560
  20. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +8712 -0
  21. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +11 -1
  22. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +15 -13
  23. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +7 -14
  24. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +2 -1
  25. package/build/BluetoothSdk.types.d.ts +72 -10
  26. package/build/BluetoothSdk.types.d.ts.map +1 -1
  27. package/build/BluetoothSdk.types.js.map +1 -1
  28. package/build/_private/BluetoothSdkModule.d.ts +9 -7
  29. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  30. package/build/_private/BluetoothSdkModule.js +0 -2
  31. package/build/_private/BluetoothSdkModule.js.map +1 -1
  32. package/build/_private/photoRequestPayload.d.ts +3 -1
  33. package/build/_private/photoRequestPayload.d.ts.map +1 -1
  34. package/build/_private/photoRequestPayload.js +43 -1
  35. package/build/_private/photoRequestPayload.js.map +1 -1
  36. package/build/debug.d.ts +3 -0
  37. package/build/debug.d.ts.map +1 -0
  38. package/build/debug.js +8 -0
  39. package/build/debug.js.map +1 -0
  40. package/build/index.d.ts +1 -1
  41. package/build/index.d.ts.map +1 -1
  42. package/build/index.js +16 -5
  43. package/build/index.js.map +1 -1
  44. package/ios/BluetoothSdkModule.swift +59 -64
  45. package/ios/Source/BluetoothSdkDefaults.swift +47 -0
  46. package/ios/Source/Bridge.swift +6 -0
  47. package/ios/Source/DeviceManager.swift +46 -32
  48. package/ios/Source/DeviceStore.swift +5 -1
  49. package/ios/Source/MentraBluetoothSDK.swift +357 -34
  50. package/ios/Source/MentraBluetoothSDKDebug.swift +12 -0
  51. package/ios/Source/ObservableStore.swift +11 -0
  52. package/ios/Source/OtaManifest.swift +170 -0
  53. package/ios/Source/camera/CameraModels.swift +267 -9
  54. package/ios/Source/controllers/ControllerManager.swift +2 -5
  55. package/ios/Source/controllers/R1.swift +2 -5
  56. package/ios/Source/events/BluetoothEvents.swift +3 -0
  57. package/ios/Source/internal/BluetoothAvailability.swift +20 -0
  58. package/ios/Source/internal/BluetoothSdkAnalytics.swift +160 -0
  59. package/ios/Source/sgcs/G1.swift +6 -5
  60. package/ios/Source/sgcs/G2.swift +20 -18
  61. package/ios/Source/sgcs/Mach1.swift +6 -5
  62. package/ios/Source/sgcs/MentraLive.swift +158 -38
  63. package/ios/Source/sgcs/MentraNex.swift +6 -5
  64. package/ios/Source/sgcs/SGCManager.swift +16 -7
  65. package/ios/Source/sgcs/Simulated.swift +7 -3
  66. package/ios/Source/status/DeviceStatus.swift +1 -1
  67. package/ios/Source/types/DeviceModels.swift +5 -1
  68. package/package.json +6 -1
  69. package/plugin/build/index.d.ts +4 -0
  70. package/plugin/build/withAndroid.d.ts +2 -3
  71. package/plugin/build/withAndroid.js +46 -0
  72. package/plugin/build/withIos.d.ts +2 -3
  73. package/plugin/build/withIos.js +32 -0
  74. package/src/BluetoothSdk.types.ts +80 -10
  75. package/src/_private/BluetoothSdkModule.ts +13 -10
  76. package/src/_private/photoRequestPayload.ts +45 -2
  77. package/src/debug.ts +9 -0
  78. package/src/index.ts +25 -6
  79. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.java +0 -3974
  80. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.java +0 -7405
@@ -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
+ }
@@ -1,17 +1,44 @@
1
1
  import Foundation
2
2
 
3
3
  public enum PhotoSize: String {
4
- case small
4
+ case low
5
5
  case medium
6
- case large
7
- case full
6
+ case high
7
+ case max
8
+
9
+ public static func normalizeLegacy(_ value: String?) -> String {
10
+ switch value {
11
+ case "small":
12
+ return PhotoSize.low.rawValue
13
+ case "large":
14
+ return PhotoSize.high.rawValue
15
+ case "full":
16
+ return PhotoSize.max.rawValue
17
+ default:
18
+ return value ?? PhotoSize.medium.rawValue
19
+ }
20
+ }
21
+
22
+ public init(normalizedRawValue value: String?) {
23
+ let normalized = PhotoSize.normalizeLegacy(value)
24
+ self = PhotoSize(rawValue: normalized) ?? .medium
25
+ }
8
26
  }
9
27
 
10
28
  public enum ButtonPhotoSize: String {
11
- case small
29
+ case low
12
30
  case medium
13
- case large
31
+ case high
14
32
  case max
33
+
34
+ public static func normalizeLegacy(_ value: String?) -> String {
35
+ PhotoSize.normalizeLegacy(value)
36
+ }
37
+
38
+ public init(normalizedRawValue value: String?) {
39
+ let normalized = ButtonPhotoSize.normalizeLegacy(value)
40
+ self = ButtonPhotoSize(rawValue: normalized) ?? .medium
41
+ }
15
42
  }
16
43
 
17
44
  public enum PhotoCompression: String {
@@ -21,10 +48,66 @@ public enum PhotoCompression: String {
21
48
  }
22
49
 
23
50
  public struct ButtonPhotoSettings {
24
- public let size: ButtonPhotoSize
51
+ public let size: ButtonPhotoSize?
52
+ public let mfnr: Bool?
53
+ public let zsl: Bool?
54
+ public let noiseReduction: Bool?
55
+ public let edgeEnhancement: Bool?
56
+ public let ispDigitalGain: Int?
57
+ public let ispAnalogGain: String?
58
+ public let aeExposureDivisor: Int?
59
+ public let isoCap: Int?
60
+ public let compress: String?
61
+ public let sound: Bool?
62
+ public let resetCaptureTuning: Bool?
25
63
 
26
- public init(size: ButtonPhotoSize) {
64
+ public init(
65
+ size: ButtonPhotoSize?,
66
+ mfnr: Bool? = nil,
67
+ zsl: Bool? = nil,
68
+ noiseReduction: Bool? = nil,
69
+ edgeEnhancement: Bool? = nil,
70
+ ispDigitalGain: Int? = nil,
71
+ ispAnalogGain: String? = nil,
72
+ aeExposureDivisor: Int? = nil,
73
+ isoCap: Int? = nil,
74
+ compress: String? = nil,
75
+ sound: Bool? = nil,
76
+ resetCaptureTuning: Bool? = nil
77
+ ) {
27
78
  self.size = size
79
+ self.mfnr = mfnr
80
+ self.zsl = zsl
81
+ self.noiseReduction = noiseReduction
82
+ self.edgeEnhancement = edgeEnhancement
83
+ self.ispDigitalGain = ispDigitalGain
84
+ self.ispAnalogGain = ispAnalogGain
85
+ self.aeExposureDivisor = aeExposureDivisor
86
+ self.isoCap = isoCap
87
+ self.compress = compress
88
+ self.sound = sound
89
+ self.resetCaptureTuning = resetCaptureTuning
90
+ }
91
+
92
+ static func from(params: [String: Any]) -> ButtonPhotoSettings {
93
+ let size = (params["size"] as? String).map { ButtonPhotoSize(normalizedRawValue: $0) }
94
+ let aeExposureDivisor =
95
+ optionalIntValue(params, "aeExposureDivisor").flatMap { $0 > 1 ? $0 : nil }
96
+ let isoCap = optionalIntValue(params, "isoCap").flatMap { $0 > 0 ? $0 : nil }
97
+ return ButtonPhotoSettings(
98
+ size: size,
99
+ mfnr: optionalBoolValue(params, "mfnr"),
100
+ zsl: optionalBoolValue(params, "zsl"),
101
+ noiseReduction: optionalBoolValue(params, "noiseReduction"),
102
+ edgeEnhancement: optionalBoolValue(params, "edgeEnhancement"),
103
+ ispDigitalGain: optionalIntValue(params, "ispDigitalGain"),
104
+ ispAnalogGain: optionalStringValue(params, "ispAnalogGain"),
105
+ aeExposureDivisor: aeExposureDivisor,
106
+ isoCap: isoCap,
107
+ compress: optionalStringValue(params, "compress"),
108
+ sound: optionalBoolValue(params, "sound"),
109
+ resetCaptureTuning: optionalBoolValue(params, "resetCaptureTuning")
110
+ )
28
111
  }
29
112
  }
30
113
 
@@ -152,6 +235,14 @@ public struct PhotoRequest {
152
235
  public let exposureTimeNs: Double?
153
236
  /// Sensor ISO for this capture only. Only used when exposureTimeNs enables manual exposure.
154
237
  public let iso: Int?
238
+ public let aeExposureDivisor: Int?
239
+ public let isoCap: Int?
240
+ public let noiseReduction: Bool?
241
+ public let edgeEnhancement: Bool?
242
+ public let mfnr: Bool?
243
+ public let zsl: Bool?
244
+ public let ispDigitalGain: Int?
245
+ public let ispAnalogGain: String?
155
246
 
156
247
  public init(
157
248
  requestId: String,
@@ -164,7 +255,15 @@ public struct PhotoRequest {
164
255
  save: Bool = false,
165
256
  sound: Bool,
166
257
  exposureTimeNs: Double? = nil,
167
- iso: Int? = nil
258
+ iso: Int? = nil,
259
+ aeExposureDivisor: Int? = nil,
260
+ isoCap: Int? = nil,
261
+ noiseReduction: Bool? = nil,
262
+ edgeEnhancement: Bool? = nil,
263
+ mfnr: Bool? = nil,
264
+ zsl: Bool? = nil,
265
+ ispDigitalGain: Int? = nil,
266
+ ispAnalogGain: String? = nil
168
267
  ) {
169
268
  self.requestId = requestId
170
269
  self.appId = appId
@@ -177,6 +276,117 @@ public struct PhotoRequest {
177
276
  self.sound = sound
178
277
  self.exposureTimeNs = exposureTimeNs
179
278
  self.iso = iso
279
+ self.aeExposureDivisor = aeExposureDivisor
280
+ self.isoCap = isoCap
281
+ self.noiseReduction = noiseReduction
282
+ self.edgeEnhancement = edgeEnhancement
283
+ self.mfnr = mfnr
284
+ self.zsl = zsl
285
+ self.ispDigitalGain = ispDigitalGain
286
+ self.ispAnalogGain = ispAnalogGain
287
+ }
288
+
289
+ public static func from(params: [String: Any]) -> PhotoRequest {
290
+ let sizeRaw = params["size"] as? String ?? "medium"
291
+ let compressRaw = params["compress"] as? String ?? "none"
292
+ let exposureTimeNs: Double?
293
+ switch params["exposureTimeNs"] {
294
+ case let value as Double:
295
+ exposureTimeNs = value.isFinite && value > 0 ? value : nil
296
+ case let value as Int:
297
+ exposureTimeNs = value > 0 ? Double(value) : nil
298
+ case let value as NSNumber:
299
+ let d = value.doubleValue
300
+ exposureTimeNs = d.isFinite && d > 0 ? d : nil
301
+ default:
302
+ exposureTimeNs = nil
303
+ }
304
+ let iso: Int?
305
+ switch params["iso"] {
306
+ case let value as Int:
307
+ iso = value > 0 ? value : nil
308
+ case let value as Double:
309
+ iso = value.isFinite && value > 0 && value < Double(Int.max) ? Int(value) : nil
310
+ case let value as NSNumber:
311
+ let intValue = value.intValue
312
+ iso = intValue > 0 ? intValue : nil
313
+ default:
314
+ iso = nil
315
+ }
316
+ func optionalInt(_ key: String, min: Int = Int.min, filter: (Int) -> Bool = { _ in true }) -> Int? {
317
+ guard params.keys.contains(key) else { return nil }
318
+ switch params[key] {
319
+ case let value as Int:
320
+ return filter(value) ? value : nil
321
+ case let value as Double:
322
+ guard value.isFinite, value >= Double(min) else { return nil }
323
+ return filter(Int(value)) ? Int(value) : nil
324
+ case let value as NSNumber:
325
+ let intValue = value.intValue
326
+ return filter(intValue) ? intValue : nil
327
+ default:
328
+ return nil
329
+ }
330
+ }
331
+ func optionalBool(_ key: String) -> Bool? {
332
+ guard params.keys.contains(key) else { return nil }
333
+ return params[key] as? Bool
334
+ }
335
+
336
+ return PhotoRequest(
337
+ requestId: params["requestId"] as? String ?? "",
338
+ appId: params["appId"] as? String ?? "",
339
+ size: PhotoSize(normalizedRawValue: sizeRaw),
340
+ webhookUrl: params["webhookUrl"] as? String,
341
+ authToken: (params["authToken"] as? String)?.nilIfBlank,
342
+ compress: PhotoCompression(rawValue: compressRaw),
343
+ flash: params["flash"] as? Bool ?? true,
344
+ save: (params["save"] as? Bool) ?? (params["saveToGallery"] as? Bool) ?? false,
345
+ sound: params["sound"] as? Bool ?? true,
346
+ exposureTimeNs: exposureTimeNs,
347
+ iso: iso,
348
+ aeExposureDivisor: optionalInt("aeExposureDivisor", min: 2) { $0 > 1 },
349
+ isoCap: optionalInt("isoCap", min: 1) { $0 > 0 },
350
+ noiseReduction: optionalBool("noiseReduction"),
351
+ edgeEnhancement: optionalBool("edgeEnhancement"),
352
+ mfnr: optionalBool("mfnr"),
353
+ zsl: optionalBool("zsl"),
354
+ ispDigitalGain: optionalInt("ispDigitalGain"),
355
+ ispAnalogGain: params["ispAnalogGain"] as? String
356
+ )
357
+ }
358
+
359
+ func appendScanFields(to json: inout [String: Any]) {
360
+ if let aeExposureDivisor {
361
+ json["aeExposureDivisor"] = aeExposureDivisor
362
+ }
363
+ if let isoCap {
364
+ json["isoCap"] = isoCap
365
+ }
366
+ if let noiseReduction {
367
+ json["noiseReduction"] = noiseReduction
368
+ }
369
+ if let edgeEnhancement {
370
+ json["edgeEnhancement"] = edgeEnhancement
371
+ }
372
+ if let mfnr {
373
+ json["mfnr"] = mfnr
374
+ }
375
+ if let zsl {
376
+ json["zsl"] = zsl
377
+ }
378
+ if let ispDigitalGain {
379
+ json["ispDigitalGain"] = ispDigitalGain
380
+ }
381
+ if let ispAnalogGain {
382
+ json["ispAnalogGain"] = ispAnalogGain
383
+ }
384
+ }
385
+ }
386
+
387
+ private extension String {
388
+ var nilIfBlank: String? {
389
+ isEmpty ? nil : self
180
390
  }
181
391
  }
182
392
 
@@ -229,9 +439,12 @@ public struct VideoRecordingRequest {
229
439
  public let width: Int
230
440
  public let height: Int
231
441
  public let fps: Int
442
+ // Optional auto-stop timer in minutes; 0 = record until stopped/interrupted.
443
+ public let maxRecordingTimeMinutes: Int
232
444
 
233
445
  public init(
234
- requestId: String, save: Bool, sound: Bool, width: Int = 0, height: Int = 0, fps: Int = 0
446
+ requestId: String, save: Bool, sound: Bool, width: Int = 0, height: Int = 0, fps: Int = 0,
447
+ maxRecordingTimeMinutes: Int = 0
235
448
  ) {
236
449
  self.requestId = requestId
237
450
  self.save = save
@@ -239,6 +452,7 @@ public struct VideoRecordingRequest {
239
452
  self.width = width
240
453
  self.height = height
241
454
  self.fps = fps
455
+ self.maxRecordingTimeMinutes = maxRecordingTimeMinutes
242
456
  }
243
457
  }
244
458
 
@@ -280,6 +494,50 @@ public struct VideoRecordingStatusEvent: CustomStringConvertible {
280
494
  }
281
495
  }
282
496
 
497
+ public struct MediaUploadEvent: CustomStringConvertible {
498
+ public let values: [String: Any]
499
+
500
+ public init(values: [String: Any]) {
501
+ self.values = values
502
+ }
503
+
504
+ public var type: String {
505
+ stringValue(values, "type") ?? ""
506
+ }
507
+
508
+ public var requestId: String {
509
+ stringValue(values, "requestId") ?? ""
510
+ }
511
+
512
+ public var mediaUrl: String? {
513
+ stringValue(values, "mediaUrl")
514
+ }
515
+
516
+ public var errorMessage: String? {
517
+ stringValue(values, "errorMessage")
518
+ }
519
+
520
+ public var mediaType: Int? {
521
+ intValue(values["mediaType"])
522
+ }
523
+
524
+ public var timestamp: Int {
525
+ intValue(values["timestamp"]) ?? Int(Date().timeIntervalSince1970 * 1000)
526
+ }
527
+
528
+ public var isSuccess: Bool {
529
+ type == "media_success"
530
+ }
531
+
532
+ public var isVideo: Bool {
533
+ mediaType == 2
534
+ }
535
+
536
+ public var description: String {
537
+ "MediaUploadEvent(requestId: \(requestId), type: \(type), mediaType: \(mediaType ?? -1))"
538
+ }
539
+ }
540
+
283
541
  public enum PhotoResponse: CustomStringConvertible, Equatable {
284
542
  public enum State: String {
285
543
  case success
@@ -18,10 +18,7 @@ protocol ControllerManager {
18
18
 
19
19
  // MARK: - Camera & Media
20
20
 
21
- func requestPhoto(
22
- _ requestId: String, appId: String, size: String?, webhookUrl: String?, authToken: String?,
23
- compress: String?, flash: Bool, save: Bool, sound: Bool, exposureTimeNs: Double?, iso: Int?
24
- )
21
+ func requestPhoto(_ request: PhotoRequest)
25
22
  func startStream(_ message: [String: Any])
26
23
  func stopStream()
27
24
  func sendStreamKeepAlive(_ message: [String: Any])
@@ -75,7 +72,7 @@ protocol ControllerManager {
75
72
  func sendWifiCredentials(_ ssid: String, _ password: String)
76
73
  func forgetWifiNetwork(_ ssid: String)
77
74
  func sendHotspotState(_ enabled: Bool)
78
- func sendOtaStart()
75
+ func sendOtaStart(otaVersionUrl: String?)
79
76
  func sendOtaQueryStatus()
80
77
 
81
78
  // MARK: - User Context (for crash reporting)
@@ -504,10 +504,7 @@ class R1: NSObject, ControllerManager {
504
504
  }
505
505
 
506
506
  func sendJson(_: [String: Any], wakeUp _: Bool, requireAck _: Bool) {}
507
- func requestPhoto(
508
- _: String, appId _: String, size _: String?, webhookUrl _: String?, authToken _: String?,
509
- compress _: String?, flash _: Bool, save _: Bool, sound _: Bool, exposureTimeNs _: Double?, iso _: Int?
510
- ) {}
507
+ func requestPhoto(_: PhotoRequest) {}
511
508
  func startVideoRecording(requestId _: String, save _: Bool, flash _: Bool, sound _: Bool) {}
512
509
  func stopVideoRecording(requestId _: String) {}
513
510
  func startStream(_: [String: Any]) {}
@@ -543,7 +540,7 @@ class R1: NSObject, ControllerManager {
543
540
  func sendWifiCredentials(_: String, _: String) {}
544
541
  func forgetWifiNetwork(_: String) {}
545
542
  func sendHotspotState(_: Bool) {}
546
- func sendOtaStart() {}
543
+ func sendOtaStart(otaVersionUrl: String?) {}
547
544
  func sendOtaQueryStatus() {}
548
545
  func sendUserEmailToGlasses(_: String) {}
549
546
  func queryGalleryStatus() {}
@@ -244,6 +244,7 @@ public enum BluetoothEvent: CustomStringConvertible {
244
244
  case photoResponse(PhotoResponseEvent)
245
245
  case photoStatus(PhotoStatusEvent)
246
246
  case videoRecordingStatus(VideoRecordingStatusEvent)
247
+ case mediaUpload(MediaUploadEvent)
247
248
  case rgbLedControlResponse(RgbLedControlResponseEvent)
248
249
  case streamStatus(StreamStatusEvent)
249
250
  case keepAliveAck(KeepAliveAckEvent)
@@ -277,6 +278,8 @@ public enum BluetoothEvent: CustomStringConvertible {
277
278
  event.description
278
279
  case let .videoRecordingStatus(event):
279
280
  event.description
281
+ case let .mediaUpload(event):
282
+ event.description
280
283
  case let .rgbLedControlResponse(event):
281
284
  event.description
282
285
  case let .streamStatus(event):
@@ -6,6 +6,7 @@ final class BluetoothAvailability: NSObject, CBCentralManagerDelegate {
6
6
 
7
7
  private var centralManager: CBCentralManager?
8
8
  private var state: CBManagerState = .unknown
9
+ private var listeners: [UUID: (CBManagerState) -> Void] = [:]
9
10
 
10
11
  override private init() {
11
12
  super.init()
@@ -19,6 +20,25 @@ final class BluetoothAvailability: NSObject, CBCentralManagerDelegate {
19
20
 
20
21
  func centralManagerDidUpdateState(_ central: CBCentralManager) {
21
22
  state = central.state
23
+ for listener in Array(listeners.values) {
24
+ listener(state)
25
+ }
26
+ }
27
+
28
+ @discardableResult
29
+ func addStateListener(_ listener: @escaping (CBManagerState) -> Void) -> UUID {
30
+ // `listeners` is unsynchronized; it is only safe because the central
31
+ // manager queue is `.main` and all callers are main-thread bound.
32
+ dispatchPrecondition(condition: .onQueue(.main))
33
+ let id = UUID()
34
+ listeners[id] = listener
35
+ listener(state)
36
+ return id
37
+ }
38
+
39
+ func removeStateListener(_ id: UUID) {
40
+ dispatchPrecondition(condition: .onQueue(.main))
41
+ listeners[id] = nil
22
42
  }
23
43
 
24
44
  func requirePoweredOn(operation: String) throws {