@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,160 @@
1
+ import Foundation
2
+
3
+ public struct BluetoothSdkAnalyticsConfiguration {
4
+ public static let disabled = BluetoothSdkAnalyticsConfiguration(enabled: false)
5
+
6
+ public let enabled: Bool
7
+ let surface: String
8
+
9
+ public init(enabled: Bool = true) {
10
+ self.enabled = enabled
11
+ surface = "ios"
12
+ }
13
+
14
+ var isReady: Bool {
15
+ enabled
16
+ }
17
+
18
+ func withSurface(_ surface: String) -> BluetoothSdkAnalyticsConfiguration {
19
+ BluetoothSdkAnalyticsConfiguration(
20
+ enabled: enabled,
21
+ surface: surface
22
+ )
23
+ }
24
+
25
+ private init(
26
+ enabled: Bool,
27
+ surface: String
28
+ ) {
29
+ self.enabled = enabled
30
+ self.surface = surface
31
+ }
32
+ }
33
+
34
+ final class BluetoothSdkAnalytics {
35
+ private static let defaultPostHogApiKey = "phc_FCweXVAxVgU7wZK4Fk3okOx4RmyNqVHJf62YpZSfJt5"
36
+ private static let defaultPostHogHost = "https://us.i.posthog.com"
37
+ private let stateQueue = DispatchQueue(label: "com.mentra.bluetoothsdk.analytics.state")
38
+ private let transportQueue = DispatchQueue(label: "com.mentra.bluetoothsdk.analytics.transport")
39
+ private let configuration: BluetoothSdkAnalyticsConfiguration
40
+ private var startedCaptured = false
41
+ private var lastConnected = false
42
+
43
+ init(configuration: BluetoothSdkAnalyticsConfiguration) {
44
+ self.configuration = configuration.resolvedForApp()
45
+ }
46
+
47
+ func initializeGlassesStatus(_ status: GlassesStatus) {
48
+ stateQueue.sync {
49
+ lastConnected = status.analyticsConnected
50
+ }
51
+ }
52
+
53
+ func captureStarted() {
54
+ stateQueue.sync {
55
+ captureStartedLocked()
56
+ }
57
+ }
58
+
59
+ private func captureStartedLocked() {
60
+ guard !startedCaptured, configuration.isReady else { return }
61
+ startedCaptured = true
62
+ capture(
63
+ event: "bluetooth_sdk_started",
64
+ properties: ["event_kind": "sdk_started"],
65
+ configuration: configuration
66
+ )
67
+ }
68
+
69
+ func observeGlassesStatus(_ status: GlassesStatus) {
70
+ stateQueue.sync {
71
+ let isConnected = status.analyticsConnected
72
+ let wasConnected = lastConnected
73
+ lastConnected = isConnected
74
+ guard configuration.isReady else { return }
75
+ if isConnected, !wasConnected {
76
+ var properties: [String: Any] = [
77
+ "event_kind": "glasses_connected",
78
+ "fully_booted": status.fullyBooted,
79
+ ]
80
+ if !status.deviceModel.isEmpty {
81
+ properties["glasses_model"] = status.deviceModel
82
+ }
83
+ capture(event: "bluetooth_sdk_glasses_connected", properties: properties, configuration: configuration)
84
+ }
85
+ }
86
+ }
87
+
88
+ private func capture(
89
+ event: String,
90
+ properties: [String: Any],
91
+ configuration activeConfiguration: BluetoothSdkAnalyticsConfiguration
92
+ ) {
93
+ guard activeConfiguration.isReady else { return }
94
+
95
+ transportQueue.async {
96
+ let payload: [String: Any] = [
97
+ "api_key": Self.defaultPostHogApiKey,
98
+ "event": event,
99
+ "distinct_id": self.distinctId(),
100
+ "properties": self.baseProperties(configuration: activeConfiguration).merging(properties) { _, new in new },
101
+ ]
102
+ guard let body = try? JSONSerialization.data(withJSONObject: payload) else { return }
103
+ guard let captureURL = self.captureURL() else { return }
104
+ var request = URLRequest(url: captureURL)
105
+ request.httpMethod = "POST"
106
+ request.timeoutInterval = 4
107
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
108
+ request.httpBody = body
109
+ URLSession.shared.dataTask(with: request).resume()
110
+ }
111
+ }
112
+
113
+ private func baseProperties(configuration: BluetoothSdkAnalyticsConfiguration) -> [String: Any] {
114
+ var properties: [String: Any] = [
115
+ "$process_person_profile": false,
116
+ "event_source": "mentra_bluetooth_sdk",
117
+ "sdk_platform": "ios",
118
+ "sdk_surface": configuration.surface,
119
+ "app_bundle_identifier": Bundle.main.bundleIdentifier ?? "",
120
+ "os_platform": "ios",
121
+ "os_version": ProcessInfo.processInfo.operatingSystemVersionString,
122
+ ]
123
+ if let sdkVersion = BluetoothSdkDefaults.sdkVersion {
124
+ properties["sdk_version"] = sdkVersion
125
+ }
126
+ return properties
127
+ }
128
+
129
+ private func distinctId() -> String {
130
+ let key = "mentra_bluetooth_sdk_analytics_distinct_id"
131
+ if let existing = UserDefaults.standard.string(forKey: key), !existing.isEmpty {
132
+ return existing
133
+ }
134
+ let generated = "mentra-bt-sdk-\(UUID().uuidString)"
135
+ UserDefaults.standard.set(generated, forKey: key)
136
+ return generated
137
+ }
138
+
139
+ private func captureURL() -> URL? {
140
+ let normalized = Self.defaultPostHogHost.trimmingCharacters(in: .whitespacesAndNewlines).trimmingCharacters(in: CharacterSet(charactersIn: "/"))
141
+ return URL(string: "\(normalized)/i/v0/e/")
142
+ }
143
+ }
144
+
145
+ private extension GlassesStatus {
146
+ var analyticsConnected: Bool {
147
+ connectionState.isConnected || connected || fullyBooted
148
+ }
149
+ }
150
+
151
+ private extension BluetoothSdkAnalyticsConfiguration {
152
+ func resolvedForApp() -> BluetoothSdkAnalyticsConfiguration {
153
+ let disabledByApp = Bundle.main.object(forInfoDictionaryKey: "MentraBluetoothSdkAnalyticsDisabled") as? Bool == true
154
+
155
+ return BluetoothSdkAnalyticsConfiguration(
156
+ enabled: enabled && !disabledByApp,
157
+ surface: surface
158
+ )
159
+ }
160
+ }
@@ -276,10 +276,7 @@ class G1: NSObject, SGCManager {
276
276
 
277
277
  func sendButtonMaxRecordingTime(_: Int) {}
278
278
 
279
- func requestPhoto(
280
- _: String, appId _: String, size _: String?, webhookUrl _: String?, authToken _: String?,
281
- compress _: String?, flash _: Bool, save _: Bool, sound _: Bool, exposureTimeNs _: Double?, iso _: Int?
282
- ) {}
279
+ func requestPhoto(_: PhotoRequest) {}
283
280
 
284
281
  func startStream(_: [String: Any]) {}
285
282
 
@@ -317,7 +314,7 @@ class G1: NSObject, SGCManager {
317
314
 
318
315
  func queryGalleryStatus() {}
319
316
 
320
- func sendOtaStart() {}
317
+ func sendOtaStart(otaVersionUrl: String?) {}
321
318
  func sendOtaQueryStatus() {}
322
319
 
323
320
  func ping() {}
@@ -786,6 +783,10 @@ class G1: NSObject, SGCManager {
786
783
  // }
787
784
  }
788
785
 
786
+ func sendText(_ text: String) async {
787
+ await sendTextWall(text)
788
+ }
789
+
789
790
  func sendTextWall(_ text: String) async {
790
791
  let chunks = textHelper.createTextWallChunks(text)
791
792
  queueChunks(chunks, sleepAfterMs: 10)
@@ -1534,7 +1534,6 @@ class G2: NSObject, SGCManager {
1534
1534
  /// Fixed pool of container IDs the page protocol expects.
1535
1535
  private let imageContainerIDPool: [Int32] = [10, 11, 12, 13]
1536
1536
  private let textContainerIDPool: [Int32] = [1, 2, 3, 4, 5, 6]
1537
- /// Default container seeded into every fresh page: 100x100 in the top-left.
1538
1537
  private static let defaultImgContainer = (
1539
1538
  x: Int32(188), y: Int32(44), width: Int32(200), height: Int32(100)
1540
1539
  )
@@ -1927,7 +1926,7 @@ class G2: NSObject, SGCManager {
1927
1926
  // MARK: - SGCManager: Display Control
1928
1927
 
1929
1928
  func sendTextWall(_ text: String) async {
1930
- await sendText(
1929
+ await sendTextAt(
1931
1930
  text,
1932
1931
  x: G2.defaultTextContainer.x,
1933
1932
  y: G2.defaultTextContainer.y,
@@ -1940,7 +1939,13 @@ class G2: NSObject, SGCManager {
1940
1939
  )
1941
1940
  }
1942
1941
 
1943
- func sendText(
1942
+ // Protocol witness for SGCManager.sendText — G2 renders a simple string as a
1943
+ // default-positioned text wall. The positioned variant is `sendTextAt`.
1944
+ func sendText(_ text: String) async {
1945
+ await sendTextWall(text)
1946
+ }
1947
+
1948
+ func sendTextAt(
1944
1949
  _ text: String, x: Int32? = nil, y: Int32? = nil, width: Int32? = nil, height: Int32? = nil,
1945
1950
  borderWidth: Int32? = nil, borderColor: Int32? = nil, borderRadius: Int32? = nil,
1946
1951
  paddingLength: Int32? = nil
@@ -1983,7 +1988,7 @@ class G2: NSObject, SGCManager {
1983
1988
  let msg = EvenHubProto.updateTextMessage(
1984
1989
  containerID: container.id,
1985
1990
  contentOffset: 0,
1986
- contentLength: Int32(text.utf8.count),
1991
+ contentLength: Int32(container.content.utf8.count),
1987
1992
  content: container.content
1988
1993
  )
1989
1994
  queueEvenHubCommand(msg)
@@ -2011,10 +2016,10 @@ class G2: NSObject, SGCManager {
2011
2016
  // Don't shutdown the EvenHub page — that kills audio streaming too.
2012
2017
  // Instead, just clear the text content by sending a space.
2013
2018
 
2014
- if !pageCreated {
2015
- Bridge.log("G2: clearDisplay() - page not created")
2016
- createPageWithContainers()
2017
- }
2019
+ // if !pageCreated {
2020
+ // Bridge.log("G2: clearDisplay() - page not created")
2021
+ // createPageWithContainers()
2022
+ // }
2018
2023
 
2019
2024
  // reset the content of all text containers to empty:
2020
2025
  for i in textContainers.indices {
@@ -2024,10 +2029,7 @@ class G2: NSObject, SGCManager {
2024
2029
  imageContainers[i].bmpData = Data()
2025
2030
  }
2026
2031
  // shutdown the page and then recreate the containers without the content:
2027
- let msg = EvenHubProto.shutdownMessage()
2028
- sendEvenHubCommand(msg)
2029
- createPageWithContainers()
2030
- restartMicIfAlreadyEnabled()
2032
+ Task { await rebuildPage() }
2031
2033
  }
2032
2034
 
2033
2035
  /// Send BMP data to an image container via fragmented updateImageRawData
@@ -2207,6 +2209,9 @@ class G2: NSObject, SGCManager {
2207
2209
  // )
2208
2210
  // sendEvenHubCommand(msg)
2209
2211
  // }
2212
+
2213
+ try? await Task.sleep(nanoseconds: 300_000_000) // 300ms to settle
2214
+ restartMicIfAlreadyEnabled()
2210
2215
  }
2211
2216
 
2212
2217
  /// Upscale BMP pixel data by 2x (200x100 → 400x200) using nearest-neighbor
@@ -2766,6 +2771,7 @@ class G2: NSObject, SGCManager {
2766
2771
  func disconnect() {
2767
2772
  Bridge.log("G2: disconnect()")
2768
2773
  isDisconnecting = true
2774
+ clearDisplay()
2769
2775
  cancelPairingTimeout()
2770
2776
  stopHeartbeats()
2771
2777
  Task { await reconnectionManager.stop() }
@@ -3140,11 +3146,7 @@ class G2: NSObject, SGCManager {
3140
3146
 
3141
3147
  // MARK: - SGCManager: Camera & Media (not supported on G2)
3142
3148
 
3143
- func requestPhoto(
3144
- _: String, appId _: String, size _: String?, webhookUrl _: String?, authToken _: String?,
3145
- compress _: String?, flash _: Bool, save _: Bool, sound _: Bool, exposureTimeNs _: Double?,
3146
- iso _: Int?
3147
- ) {}
3149
+ func requestPhoto(_: PhotoRequest) {}
3148
3150
  func startVideoRecording(requestId _: String, save _: Bool, flash _: Bool, sound _: Bool) {}
3149
3151
  func startStream(_: [String: Any]) {}
3150
3152
  func stopStream() {}
@@ -3163,7 +3165,7 @@ class G2: NSObject, SGCManager {
3163
3165
  func sendWifiCredentials(_: String, _: String) {}
3164
3166
  func forgetWifiNetwork(_: String) {}
3165
3167
  func sendHotspotState(_: Bool) {}
3166
- func sendOtaStart() {}
3168
+ func sendOtaStart(otaVersionUrl: String?) {}
3167
3169
  func sendOtaQueryStatus() {}
3168
3170
 
3169
3171
  // MARK: - SGCManager: User Context
@@ -15,10 +15,7 @@ import UltraliteSDK
15
15
  class Mach1: UltraliteBaseViewController, SGCManager {
16
16
  func sendIncidentId(_: String, apiBaseUrl _: String?) {}
17
17
 
18
- func requestPhoto(
19
- _: String, appId _: String, size _: String?, webhookUrl _: String?, authToken _: String?,
20
- compress _: String?, flash _: Bool, save _: Bool, sound _: Bool, exposureTimeNs _: Double?, iso _: Int?
21
- ) {}
18
+ func requestPhoto(_: PhotoRequest) {}
22
19
 
23
20
  func sendGalleryMode() {}
24
21
 
@@ -26,7 +23,7 @@ class Mach1: UltraliteBaseViewController, SGCManager {
26
23
 
27
24
  var connectionState: String = ConnTypes.DISCONNECTED
28
25
 
29
- func sendOtaStart() {}
26
+ func sendOtaStart(otaVersionUrl: String?) {}
30
27
  func sendOtaQueryStatus() {}
31
28
 
32
29
  func sendJson(_: [String: Any], wakeUp _: Bool, requireAck _: Bool) {}
@@ -325,6 +322,10 @@ class Mach1: UltraliteBaseViewController, SGCManager {
325
322
  UltraliteManager.shared.stopScan()
326
323
  }
327
324
 
325
+ func sendText(_ text: String) async {
326
+ await sendTextWall(text)
327
+ }
328
+
328
329
  func sendTextWall(_ text: String) async {
329
330
  // displayTextWall(text)
330
331
  guard let device = UltraliteManager.shared.currentDevice else {
@@ -1234,6 +1234,9 @@ class MentraLive: NSObject, SGCManager {
1234
1234
  func getBatteryStatus() {}
1235
1235
  func setBrightness(_: Int, autoMode _: Bool) {}
1236
1236
  func clearDisplay() {}
1237
+ func sendText(_ text: String) async {
1238
+ await sendTextWall(text)
1239
+ }
1237
1240
  func sendTextWall(_: String) async {}
1238
1241
  func ping() {
1239
1242
  Bridge.log("LIVE: ping()")
@@ -1611,18 +1614,15 @@ class MentraLive: NSObject, SGCManager {
1611
1614
  }
1612
1615
  }
1613
1616
 
1614
- func requestPhoto(
1615
- _ requestId: String, appId: String, size: String?, webhookUrl: String?, authToken: String?,
1616
- compress: String?, flash: Bool, save: Bool, sound: Bool, exposureTimeNs: Double?, iso: Int?
1617
- ) {
1617
+ func requestPhoto(_ request: PhotoRequest) {
1618
1618
  Bridge.log(
1619
- "LIVE: PHOTO PIPELINE [5/6] requestPhoto() entry requestId=\(requestId) appId=\(appId) flash=\(flash) save=\(save) sound=\(sound) iso=\(iso.map { String($0) } ?? "auto")"
1619
+ "LIVE: PHOTO PIPELINE [5/6] requestPhoto() entry requestId=\(request.requestId) appId=\(request.appId) flash=\(request.flash) save=\(request.save) sound=\(request.sound) iso=\(request.iso.map { String($0) } ?? "auto") aeDivisor=\(request.aeExposureDivisor.map { String($0) } ?? "nil")"
1620
1620
  )
1621
1621
 
1622
1622
  var json: [String: Any] = [
1623
1623
  "type": "take_photo",
1624
- "requestId": requestId,
1625
- "appId": appId,
1624
+ "requestId": request.requestId,
1625
+ "appId": request.appId,
1626
1626
  ]
1627
1627
 
1628
1628
  // Always generate BLE ID for potential fallback
@@ -1631,15 +1631,15 @@ class MentraLive: NSObject, SGCManager {
1631
1631
  json["bleImgId"] = bleImgId
1632
1632
  json["transferMethod"] = "auto"
1633
1633
 
1634
- if let webhookUrl, !webhookUrl.isEmpty {
1634
+ if let webhookUrl = request.webhookUrl, !webhookUrl.isEmpty {
1635
1635
  json["webhookUrl"] = webhookUrl
1636
1636
 
1637
1637
  var transfer = BlePhotoTransfer(
1638
- bleImgId: bleImgId, requestId: requestId, webhookUrl: webhookUrl
1638
+ bleImgId: bleImgId, requestId: request.requestId, webhookUrl: webhookUrl
1639
1639
  )
1640
1640
 
1641
1641
  // Store authToken for BLE transfer if provided
1642
- if let authToken, !authToken.isEmpty {
1642
+ if let authToken = request.authToken, !authToken.isEmpty {
1643
1643
  transfer.authToken = authToken
1644
1644
  }
1645
1645
 
@@ -1647,32 +1647,28 @@ class MentraLive: NSObject, SGCManager {
1647
1647
  }
1648
1648
 
1649
1649
  // Add authToken to JSON if provided
1650
- if let authToken, !authToken.isEmpty {
1650
+ if let authToken = request.authToken, !authToken.isEmpty {
1651
1651
  json["authToken"] = authToken
1652
1652
  }
1653
1653
 
1654
- // propagate size (default to medium if invalid)
1655
- if let size, ["small", "medium", "large", "full"].contains(size) {
1656
- json["size"] = size
1657
- } else {
1658
- json["size"] = "medium"
1659
- }
1660
-
1661
- // Add compress parameter
1662
- json["compress"] = compress ?? "none"
1654
+ let allowedSizes = ["low", "medium", "high", "max"]
1655
+ let size = request.size.rawValue
1656
+ json["size"] = allowedSizes.contains(size) ? size : "medium"
1663
1657
 
1664
- json["flash"] = flash
1665
- json["save"] = save
1666
- json["sound"] = sound
1658
+ json["compress"] = request.compress?.rawValue ?? "none"
1659
+ json["flash"] = request.flash
1660
+ json["save"] = request.save
1661
+ json["sound"] = request.sound
1667
1662
 
1668
- if let e = exposureTimeNs, e.isFinite, e > 0, e <= Double(Int64.max) {
1669
- Bridge.log("LIVE: Using manual exposure time for photo request \(requestId): \(Int64(e)) ns")
1663
+ if let e = request.exposureTimeNs, e.isFinite, e > 0, e <= Double(Int64.max) {
1664
+ Bridge.log("LIVE: Using manual exposure time for photo request \(request.requestId): \(Int64(e)) ns")
1670
1665
  json["exposureTimeNs"] = Int64(e)
1671
1666
  }
1672
- if let iso, iso > 0 {
1673
- Bridge.log("LIVE: Using manual ISO for photo request \(requestId): ISO \(iso)")
1667
+ if let iso = request.iso, iso > 0 {
1668
+ Bridge.log("LIVE: Using manual ISO for photo request \(request.requestId): ISO \(iso)")
1674
1669
  json["iso"] = iso
1675
1670
  }
1671
+ request.appendScanFields(to: &json)
1676
1672
 
1677
1673
  Bridge.log("LIVE: PHOTO PIPELINE [5b/6] take_photo JSON ready bleImgId=\(bleImgId) transferMethod=auto")
1678
1674
  Bridge.log("LIVE: PHOTO PIPELINE [6/6] Dispatching take_photo to sendJson()")
@@ -2171,6 +2167,9 @@ class MentraLive: NSObject, SGCManager {
2171
2167
  case "video_recording_status":
2172
2168
  emitVideoRecordingStatus(json)
2173
2169
 
2170
+ case "media_success", "media_error":
2171
+ Bridge.sendMediaUploadEvent(type: type, values: json)
2172
+
2174
2173
  case "photo_status":
2175
2174
  emitPhotoStatus(json)
2176
2175
 
@@ -2880,13 +2879,17 @@ class MentraLive: NSObject, SGCManager {
2880
2879
  /// Send OTA start command to glasses.
2881
2880
  /// Called when user approves an update (onboarding or background mode).
2882
2881
  /// Triggers glasses to begin download and installation.
2883
- func sendOtaStart() {
2882
+ func sendOtaStart(otaVersionUrl: String?) {
2884
2883
  Bridge.log("LIVE: 📱 Sending ota_start command to glasses")
2885
2884
 
2886
- let json: [String: Any] = [
2885
+ var json: [String: Any] = [
2887
2886
  "type": "ota_start",
2888
2887
  "timestamp": Int(Date().timeIntervalSince1970 * 1000),
2889
2888
  ]
2889
+ if let otaVersionUrl = otaVersionUrl?.trimmingCharacters(in: .whitespacesAndNewlines),
2890
+ !otaVersionUrl.isEmpty {
2891
+ json["ota_version_url"] = otaVersionUrl
2892
+ }
2890
2893
 
2891
2894
  sendJson(json, wakeUp: true)
2892
2895
  }
@@ -5194,12 +5197,73 @@ extension MentraLive {
5194
5197
  }
5195
5198
 
5196
5199
  func sendButtonPhotoSettings() {
5197
- let size = DeviceStore.shared.get("bluetooth", "button_photo_size") as! String
5198
- sendButtonPhotoSettings(requestId: nil, size: size)
5200
+ let size = DeviceStore.shared.get("bluetooth", "button_photo_size") as? String
5201
+ let mfnr = DeviceStore.shared.get("bluetooth", "button_photo_mfnr") as? Bool
5202
+ let zsl = DeviceStore.shared.get("bluetooth", "button_photo_zsl") as? Bool
5203
+ let noiseReduction = DeviceStore.shared.get("bluetooth", "button_photo_noise_reduction") as? Bool
5204
+ let edgeEnhancement = DeviceStore.shared.get("bluetooth", "button_photo_edge_enhancement") as? Bool
5205
+ let ispDigitalGain = DeviceStore.shared.get("bluetooth", "button_photo_isp_digital_gain") as? Int
5206
+ let ispAnalogGain = DeviceStore.shared.get("bluetooth", "button_photo_isp_analog_gain") as? String
5207
+ let aeExposureDivisor = DeviceStore.shared.get("bluetooth", "button_photo_ae_exposure_divisor") as? Int
5208
+ let isoCap = DeviceStore.shared.get("bluetooth", "button_photo_iso_cap") as? Int
5209
+ let compressStr = DeviceStore.shared.get("bluetooth", "button_photo_compress") as? String
5210
+ let sound = DeviceStore.shared.get("bluetooth", "button_photo_sound") as? Bool
5211
+
5212
+ let settings = ButtonPhotoSettings(
5213
+ size: ButtonPhotoSize(normalizedRawValue: size ?? "medium"),
5214
+ mfnr: mfnr,
5215
+ zsl: zsl,
5216
+ noiseReduction: noiseReduction,
5217
+ edgeEnhancement: edgeEnhancement,
5218
+ ispDigitalGain: ispDigitalGain,
5219
+ ispAnalogGain: ispAnalogGain,
5220
+ aeExposureDivisor: aeExposureDivisor,
5221
+ isoCap: isoCap,
5222
+ compress: compressStr,
5223
+ sound: sound,
5224
+ resetCaptureTuning: false
5225
+ )
5226
+
5227
+ sendButtonPhotoSettings(requestId: nil, settings: settings)
5199
5228
  }
5200
5229
 
5201
5230
  func sendButtonPhotoSettings(requestId: String?, size: String) {
5202
- Bridge.log("Sending button photo setting: \(size)")
5231
+ sendButtonPhotoSettings(requestId: requestId, settings: ButtonPhotoSettings(size: ButtonPhotoSize(normalizedRawValue: size)))
5232
+ }
5233
+
5234
+ func sendButtonPhotoSettings(requestId: String?, settings: ButtonPhotoSettings) {
5235
+ var details = settings.size.map { "size=\($0.rawValue)" } ?? "size=unchanged"
5236
+ if let mfnr = settings.mfnr {
5237
+ details += ", mfnr=\(mfnr)"
5238
+ }
5239
+ if let zsl = settings.zsl {
5240
+ details += ", zsl=\(zsl)"
5241
+ }
5242
+ if let noiseReduction = settings.noiseReduction {
5243
+ details += ", noiseReduction=\(noiseReduction)"
5244
+ }
5245
+ if let edgeEnhancement = settings.edgeEnhancement {
5246
+ details += ", edgeEnhancement=\(edgeEnhancement)"
5247
+ }
5248
+ if let ispDigitalGain = settings.ispDigitalGain {
5249
+ details += ", ispDigitalGain=\(ispDigitalGain)"
5250
+ }
5251
+ if let ispAnalogGain = settings.ispAnalogGain {
5252
+ details += ", ispAnalogGain=\(ispAnalogGain)"
5253
+ }
5254
+ if let aeExposureDivisor = settings.aeExposureDivisor {
5255
+ details += ", aeExposureDivisor=\(aeExposureDivisor)"
5256
+ }
5257
+ if let isoCap = settings.isoCap {
5258
+ details += ", isoCap=\(isoCap)"
5259
+ }
5260
+ if let compress = settings.compress {
5261
+ details += ", compress=\(compress)"
5262
+ }
5263
+ if let sound = settings.sound {
5264
+ details += ", sound=\(sound)"
5265
+ }
5266
+ Bridge.log("Sending button photo setting: \(details)")
5203
5267
 
5204
5268
  guard connectionState == ConnTypes.CONNECTED else {
5205
5269
  Bridge.log("Cannot send button photo settings - not connected")
@@ -5208,11 +5272,46 @@ extension MentraLive {
5208
5272
 
5209
5273
  var json: [String: Any] = [
5210
5274
  "type": "button_photo_setting",
5211
- "size": size,
5212
5275
  ]
5276
+ if let size = settings.size {
5277
+ json["size"] = size.rawValue
5278
+ }
5213
5279
  if let requestId, !requestId.isEmpty {
5214
5280
  json["request_id"] = requestId
5215
5281
  }
5282
+ if let mfnr = settings.mfnr {
5283
+ json["mfnr"] = mfnr
5284
+ }
5285
+ if let zsl = settings.zsl {
5286
+ json["zsl"] = zsl
5287
+ }
5288
+ if let noiseReduction = settings.noiseReduction {
5289
+ json["noiseReduction"] = noiseReduction
5290
+ }
5291
+ if let edgeEnhancement = settings.edgeEnhancement {
5292
+ json["edgeEnhancement"] = edgeEnhancement
5293
+ }
5294
+ if let ispDigitalGain = settings.ispDigitalGain {
5295
+ json["ispDigitalGain"] = ispDigitalGain
5296
+ }
5297
+ if let ispAnalogGain = settings.ispAnalogGain, !ispAnalogGain.isEmpty {
5298
+ json["ispAnalogGain"] = ispAnalogGain
5299
+ }
5300
+ if let aeExposureDivisor = settings.aeExposureDivisor, aeExposureDivisor > 1 {
5301
+ json["aeExposureDivisor"] = aeExposureDivisor
5302
+ }
5303
+ if let isoCap = settings.isoCap, isoCap > 0 {
5304
+ json["isoCap"] = isoCap
5305
+ }
5306
+ if let compress = settings.compress, !compress.isEmpty {
5307
+ json["compress"] = compress
5308
+ }
5309
+ if let sound = settings.sound {
5310
+ json["sound"] = sound
5311
+ }
5312
+ if settings.resetCaptureTuning == true {
5313
+ json["resetCaptureTuning"] = true
5314
+ }
5216
5315
  sendJson(json, wakeUp: true)
5217
5316
  }
5218
5317
 
@@ -5269,7 +5368,8 @@ extension MentraLive {
5269
5368
 
5270
5369
  func startVideoRecording(requestId: String, save: Bool, flash: Bool, sound: Bool) {
5271
5370
  startVideoRecording(
5272
- requestId: requestId, save: save, flash: flash, sound: sound, width: 0, height: 0, fps: 0
5371
+ requestId: requestId, save: save, flash: flash, sound: sound, width: 0, height: 0, fps: 0,
5372
+ maxRecordingTimeMinutes: 0
5273
5373
  )
5274
5374
  }
5275
5375
 
@@ -5280,10 +5380,11 @@ extension MentraLive {
5280
5380
  }
5281
5381
 
5282
5382
  func startVideoRecording(
5283
- requestId: String, save: Bool, flash: Bool, sound: Bool, width: Int, height: Int, fps: Int
5383
+ requestId: String, save: Bool, flash: Bool, sound: Bool, width: Int, height: Int, fps: Int,
5384
+ maxRecordingTimeMinutes: Int
5284
5385
  ) {
5285
5386
  Bridge.log(
5286
- "Starting video recording on glasses: requestId=\(requestId), save=\(save), flash=\(flash), sound=\(sound), resolution=\(width)x\(height)@\(fps)fps"
5387
+ "Starting video recording on glasses: requestId=\(requestId), save=\(save), flash=\(flash), sound=\(sound), resolution=\(width)x\(height)@\(fps)fps, maxRecordingTimeMinutes=\(maxRecordingTimeMinutes)"
5287
5388
  )
5288
5389
 
5289
5390
  guard connectionState == ConnTypes.CONNECTED else {
@@ -5299,6 +5400,11 @@ extension MentraLive {
5299
5400
  "sound": sound,
5300
5401
  ]
5301
5402
 
5403
+ // Auto-stop timer; only sent when set (> 0). 0 = record until stopped.
5404
+ if maxRecordingTimeMinutes > 0 {
5405
+ json["maxRecordingTimeMinutes"] = maxRecordingTimeMinutes
5406
+ }
5407
+
5302
5408
  // Add video settings when any field is overridden. Each field is sent
5303
5409
  // only when > 0; the glasses merge the missing fields onto their saved
5304
5410
  // button-video defaults, so a partial override (e.g. fps-only) still
@@ -5314,17 +5420,31 @@ extension MentraLive {
5314
5420
  }
5315
5421
 
5316
5422
  func stopVideoRecording(requestId: String) {
5317
- Bridge.log("Stopping video recording on glasses: requestId=\(requestId)")
5423
+ stopVideoRecording(requestId: requestId, webhookUrl: nil, authToken: nil)
5424
+ }
5425
+
5426
+ func stopVideoRecording(requestId: String, webhookUrl: String?, authToken: String?) {
5427
+ Bridge.log(
5428
+ "Stopping video recording on glasses: requestId=\(requestId), webhook=\((webhookUrl?.isEmpty ?? true) ? "none" : "set")"
5429
+ )
5318
5430
 
5319
5431
  guard connectionState == ConnTypes.CONNECTED else {
5320
5432
  Bridge.log("Cannot stop video recording - not connected")
5321
5433
  return
5322
5434
  }
5323
5435
 
5324
- let json: [String: Any] = [
5436
+ var json: [String: Any] = [
5325
5437
  "type": "stop_video_recording",
5326
5438
  "requestId": requestId,
5327
5439
  ]
5440
+ // Webhook upload target, supplied at stop so the token is fresh.
5441
+ // Only sent when present; empty webhook = keep video on device.
5442
+ if let webhookUrl, !webhookUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
5443
+ json["webhookUrl"] = webhookUrl
5444
+ }
5445
+ if let authToken, !authToken.isEmpty {
5446
+ json["authToken"] = authToken
5447
+ }
5328
5448
  sendJson(json)
5329
5449
  }
5330
5450
  }
@@ -51,10 +51,7 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
51
51
  }
52
52
  }
53
53
 
54
- func requestPhoto(
55
- _: String, appId _: String, size _: String?, webhookUrl _: String?, authToken _: String?,
56
- compress _: String?, flash _: Bool, save _: Bool, sound _: Bool, exposureTimeNs _: Double?, iso _: Int?
57
- ) {}
54
+ func requestPhoto(_: PhotoRequest) {}
58
55
 
59
56
  func startStream(_: [String: Any]) {}
60
57
 
@@ -81,6 +78,10 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
81
78
  updateGlassesAutoBrightness(autoMode)
82
79
  }
83
80
 
81
+ func sendText(_ text: String) async {
82
+ await sendTextWall(text)
83
+ }
84
+
84
85
  func sendDoubleTextWall(_ top: String, _ bottom: String) async {
85
86
  await sendTextWall("\(top)\n\(bottom)")
86
87
  }
@@ -178,7 +179,7 @@ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SG
178
179
 
179
180
  func sendHotspotState(_: Bool) {}
180
181
 
181
- func sendOtaStart() {}
182
+ func sendOtaStart(otaVersionUrl: String?) {}
182
183
  func sendOtaQueryStatus() {}
183
184
 
184
185
  func sendUserEmailToGlasses(_: String) {}