@mentra/bluetooth-sdk 0.1.8 → 0.1.10

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 (147) hide show
  1. package/README.md +116 -18
  2. package/android/build.gradle +289 -16
  3. package/android/gradle.properties.example +9 -0
  4. package/android/lc3Lib/build.gradle +95 -6
  5. package/android/settings.gradle +3 -0
  6. package/android/silero/LICENSE.md +17 -0
  7. package/android/silero/build.gradle +42 -0
  8. package/android/silero/proguard-rules.pro +24 -0
  9. package/android/silero/src/androidTest/assets/hello.wav +0 -0
  10. package/android/silero/src/androidTest/java/com/konovalov/vad/silero/VadSileroTest.kt +79 -0
  11. package/android/silero/src/main/assets/LICENSE +21 -0
  12. package/android/silero/src/main/assets/silero_vad.onnx +0 -0
  13. package/android/silero/src/main/java/com/konovalov/vad/silero/Vad.kt +160 -0
  14. package/android/silero/src/main/java/com/konovalov/vad/silero/VadSilero.kt +449 -0
  15. package/android/silero/src/main/java/com/konovalov/vad/silero/config/FrameSize.kt +15 -0
  16. package/android/silero/src/main/java/com/konovalov/vad/silero/config/Mode.kt +14 -0
  17. package/android/silero/src/main/java/com/konovalov/vad/silero/config/SampleRate.kt +12 -0
  18. package/android/silero/src/main/java/com/konovalov/vad/silero/utils/AudioUtils.kt +47 -0
  19. package/android/silero/src/main/java/com/konovalov/vad/silero/utils/TensorMap.kt +41 -0
  20. package/android/silero/src/test/java/com/konovalov/vad/silero/utils/AudioUtilsTest.kt +44 -0
  21. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +6 -0
  22. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +165 -27
  23. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +89 -21
  24. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +189 -16
  25. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +22 -2
  26. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +787 -85
  27. package/android/src/main/java/com/mentra/bluetoothsdk/audio/AudioModels.kt +6 -2
  28. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +123 -10
  29. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/ControllerManager.kt +12 -2
  30. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/R1.kt +8 -2
  31. package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleTraceLogger.kt +33 -17
  32. package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +101 -0
  33. package/android/src/main/java/com/mentra/bluetoothsdk/internal/MapParsing.kt +8 -0
  34. package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/LocalPhotoReceiverRegistry.java +108 -0
  35. package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/LocalPhotoUploadServer.kt +399 -74
  36. package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/MentraPhotoReceiverModule.kt +100 -17
  37. package/android/src/main/java/com/mentra/bluetoothsdk/requests/DisplayRequests.kt +17 -0
  38. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.java +2 -2
  39. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +569 -220
  40. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Mach1.java +2 -2
  41. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.java +271 -66
  42. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +176 -104
  43. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraosBle.java +12753 -27036
  44. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +54 -2
  45. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +16 -4
  46. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +60 -3
  47. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +12 -3
  48. package/android/src/main/java/com/mentra/bluetoothsdk/stt/STTTools.kt +87 -36
  49. package/android/src/main/java/com/mentra/bluetoothsdk/stt/SherpaOnnxTranscriber.kt +13 -4
  50. package/android/src/main/java/com/mentra/bluetoothsdk/stt/VadGateSpeechPolicy.kt +229 -0
  51. package/android/src/main/java/com/mentra/bluetoothsdk/tts/TTSTools.kt +180 -0
  52. package/android/src/main/java/com/mentra/bluetoothsdk/utils/AvifExifStripper.java +573 -0
  53. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadService.java +488 -50
  54. package/android/src/main/java/com/mentra/bluetoothsdk/utils/HeifExifTagReader.java +199 -0
  55. package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunkReassembler.java +18 -1
  56. package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunker.java +64 -18
  57. package/android/src/main/java/com/mentra/bluetoothsdk/utils/NexSGCUtils.kt +48 -39
  58. package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +23 -0
  59. package/android/src/test/java/com/mentra/bluetoothsdk/photoreceiver/LocalPhotoReceiverRegistryTest.java +75 -0
  60. package/android/src/test/java/com/mentra/bluetoothsdk/utils/AvifExifStripperTest.java +30 -0
  61. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadServiceTest.java +98 -0
  62. package/android/src/test/resources/avif_with_exif.avif +0 -0
  63. package/build/BluetoothSdk.types.d.ts +241 -43
  64. package/build/BluetoothSdk.types.d.ts.map +1 -1
  65. package/build/BluetoothSdk.types.js +11 -10
  66. package/build/BluetoothSdk.types.js.map +1 -1
  67. package/build/_private/BluetoothSdkModule.d.ts +36 -23
  68. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  69. package/build/_private/BluetoothSdkModule.js +30 -52
  70. package/build/_private/BluetoothSdkModule.js.map +1 -1
  71. package/build/_private/photoRequestPayload.d.ts +4 -0
  72. package/build/_private/photoRequestPayload.d.ts.map +1 -0
  73. package/build/_private/photoRequestPayload.js +25 -0
  74. package/build/_private/photoRequestPayload.js.map +1 -0
  75. package/build/index.d.ts +1 -1
  76. package/build/index.d.ts.map +1 -1
  77. package/build/index.js +23 -0
  78. package/build/index.js.map +1 -1
  79. package/build/react/useMentraBluetooth.d.ts +2 -2
  80. package/build/react/useMentraBluetooth.d.ts.map +1 -1
  81. package/build/react/useMentraBluetooth.js +3 -3
  82. package/build/react/useMentraBluetooth.js.map +1 -1
  83. package/ios/BluetoothSdkModule.swift +174 -70
  84. package/ios/LocalPhotoUploadServer.swift +370 -105
  85. package/ios/MentraBluetoothSDK.podspec +3 -1
  86. package/ios/MentraPhotoReceiverModule.swift +62 -10
  87. package/ios/Packages/CoreObjC/include/PcmConverter.h +22 -0
  88. package/ios/Packages/SherpaOnnx/SherpaOnnx.swift +668 -26
  89. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/Info.plist +13 -9
  90. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/Headers/sherpa-onnx/c-api/c-api.h +4551 -0
  91. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/Headers/sherpa-onnx/c-api/cxx-api.h +1745 -0
  92. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/libsherpa-onnx.a +0 -0
  93. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64_x86_64-simulator/Headers/sherpa-onnx/c-api/c-api.h +4551 -0
  94. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64_x86_64-simulator/Headers/sherpa-onnx/c-api/cxx-api.h +1745 -0
  95. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64_x86_64-simulator/libsherpa-onnx.a +0 -0
  96. package/ios/Source/BluetoothSdkDefaults.swift +6 -0
  97. package/ios/Source/Bridge.swift +58 -19
  98. package/ios/Source/DeviceManager.swift +149 -17
  99. package/ios/Source/DeviceStore.swift +16 -6
  100. package/ios/Source/MentraBluetoothSDK.swift +803 -47
  101. package/ios/Source/ObservableStore.swift +8 -2
  102. package/ios/Source/{Audio → audio}/AudioModels.swift +9 -7
  103. package/ios/Source/{Camera → camera}/CameraModels.swift +225 -16
  104. package/ios/Source/controllers/ControllerManager.swift +4 -3
  105. package/ios/Source/controllers/R1.swift +3 -3
  106. package/ios/Source/{Events → events}/BluetoothEvents.swift +183 -1
  107. package/ios/Source/{Internal → internal}/ValueParsing.swift +10 -1
  108. package/ios/Source/{Requests → requests}/DisplayRequests.swift +28 -4
  109. package/ios/Source/services/PhoneMic.swift +1 -0
  110. package/ios/Source/sgcs/G1.swift +2 -2
  111. package/ios/Source/sgcs/G2.swift +645 -403
  112. package/ios/Source/sgcs/Mach1.swift +2 -2
  113. package/ios/Source/sgcs/MentraLive.swift +812 -319
  114. package/ios/Source/sgcs/MentraNex.swift +412 -182
  115. package/ios/Source/sgcs/SGCManager.swift +57 -3
  116. package/ios/Source/sgcs/Simulated.swift +7 -3
  117. package/ios/Source/sgcs/mentraos_ble.pb.swift +3073 -3134
  118. package/ios/Source/status/DeviceStatus.swift +974 -0
  119. package/ios/Source/{Status → status}/WifiHotspotStatus.swift +4 -4
  120. package/ios/Source/{Streaming → streaming}/StreamModels.swift +24 -9
  121. package/ios/Source/stt/STTTools.swift +27 -12
  122. package/ios/Source/stt/SherpaOnnxTranscriber.swift +31 -8
  123. package/ios/Source/tts/TTSTools.swift +164 -0
  124. package/ios/Source/utils/JSCExperiment.swift +7 -7
  125. package/ios/Source/utils/MessageChunkReassembler.swift +20 -1
  126. package/ios/Source/utils/MessageChunker.swift +78 -21
  127. package/ios/Source/utils/TarBz2Extractor.swift +53 -2
  128. package/package.json +1 -1
  129. package/plugin/build/withAndroid.js +68 -4
  130. package/src/BluetoothSdk.types.ts +331 -59
  131. package/src/_private/BluetoothSdkModule.ts +113 -102
  132. package/src/_private/photoRequestPayload.ts +28 -0
  133. package/src/index.ts +58 -15
  134. package/src/react/useMentraBluetooth.ts +6 -4
  135. package/android/.project +0 -28
  136. package/android/lc3Lib/.project +0 -28
  137. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/Headers/cargs.h +0 -162
  138. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/Headers/sherpa-onnx/c-api/c-api.h +0 -1852
  139. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/Headers/sherpa-onnx/c-api/cxx-api.h +0 -674
  140. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/sherpa-onnx.a +0 -0
  141. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64_x86_64-simulator/sherpa-onnx.a +0 -0
  142. package/ios/Source/Status/DeviceStatus.swift +0 -471
  143. /package/ios/Source/{Connection → connection}/ScanSession.swift +0 -0
  144. /package/ios/Source/{Errors → errors}/BluetoothError.swift +0 -0
  145. /package/ios/Source/{Internal → internal}/BluetoothAvailability.swift +0 -0
  146. /package/ios/Source/{Status → status}/RuntimeState.swift +0 -0
  147. /package/ios/Source/{Types → types}/DeviceModels.swift +0 -0
@@ -15,6 +15,7 @@
15
15
  import Combine
16
16
  import CoreBluetooth
17
17
  import Foundation
18
+ import ImageIO
18
19
  import UIKit
19
20
 
20
21
  // MARK: - Supporting Types
@@ -68,7 +69,9 @@ class BlePhotoUploadService {
68
69
  imageData: Data,
69
70
  requestId: String,
70
71
  webhookUrl: String,
71
- authToken: String?
72
+ authToken: String?,
73
+ onSuccess: ((String, String) -> Void)? = nil,
74
+ onError: ((String, String) -> Void)? = nil
72
75
  ) {
73
76
  Task {
74
77
  do {
@@ -76,32 +79,11 @@ class BlePhotoUploadService {
76
79
  "\(TAG): Processing BLE photo for upload. Image size: \(imageData.count) bytes"
77
80
  )
78
81
 
79
- // 1. Decode image (AVIF or JPEG) to UIImage
80
- guard let image = decodeImage(imageData: imageData) else {
81
- throw NSError(
82
- domain: "BlePhotoUpload",
83
- code: -1,
84
- userInfo: [NSLocalizedDescriptionKey: "Failed to decode image data"]
85
- )
86
- }
87
-
88
- Bridge.log(
89
- "\(TAG): Decoded image to bitmap: \(Int(image.size.width))x\(Int(image.size.height))"
90
- )
91
-
92
- // 2. Convert to JPEG for upload (in case it was AVIF)
93
- guard let jpegData = image.jpegData(compressionQuality: 0.9) else {
94
- throw NSError(
95
- domain: "BlePhotoUpload",
96
- code: -2,
97
- userInfo: [NSLocalizedDescriptionKey: "Failed to convert image to JPEG"]
98
- )
99
- }
100
-
82
+ let jpegData = try convertToJpegPreservingExif(imageData: imageData)
101
83
  Bridge.log("\(TAG): Converted to JPEG for upload. Size: \(jpegData.count) bytes")
102
84
 
103
- // 3. Upload to webhook
104
- try await uploadToWebhook(
85
+ // Upload to webhook
86
+ let responseBody = try await uploadToWebhook(
105
87
  jpegData: jpegData,
106
88
  requestId: requestId,
107
89
  webhookUrl: webhookUrl,
@@ -109,43 +91,313 @@ class BlePhotoUploadService {
109
91
  )
110
92
 
111
93
  Bridge.log("\(TAG): Photo uploaded successfully for requestId: \(requestId)")
112
-
113
- // DispatchQueue.main.async {
114
- // callback.onSuccess(requestId: requestId)
115
- // }
94
+ onSuccess?(requestId, responseBody)
116
95
 
117
96
  } catch {
118
97
  Bridge.log(
119
98
  "\(TAG): Error processing BLE photo for requestId: \(requestId), error: \(error)"
120
99
  )
100
+ onError?(requestId, error.localizedDescription)
101
+ }
102
+ }
103
+ }
104
+
105
+ private static func convertToJpegPreservingExif(imageData: Data) throws -> Data {
106
+ logIncomingImageDiagnostics(imageData: imageData)
107
+ let imuJson = readImuJsonFromImageData(imageData)
108
+
109
+ guard let image = decodeImage(imageData: imageData) else {
110
+ throw NSError(
111
+ domain: "BlePhotoUpload",
112
+ code: -1,
113
+ userInfo: [NSLocalizedDescriptionKey: "Failed to decode image data"]
114
+ )
115
+ }
116
+
117
+ Bridge.log(
118
+ "\(TAG): Decoded image to bitmap: \(Int(image.size.width))x\(Int(image.size.height))"
119
+ )
120
+
121
+ guard var jpegData = image.jpegData(compressionQuality: 0.9) else {
122
+ throw NSError(
123
+ domain: "BlePhotoUpload",
124
+ code: -2,
125
+ userInfo: [NSLocalizedDescriptionKey: "Failed to convert image to JPEG"]
126
+ )
127
+ }
128
+
129
+ if let imuJson, !imuJson.isEmpty {
130
+ jpegData = try writeImuJsonToJpegData(jpegData, imuJson: imuJson)
131
+ Bridge.log("\(TAG): Re-attached IMU EXIF UserComment on output JPEG (\(imuJson.count) chars)")
132
+ } else {
133
+ let rawHasExif = containsExifMarker(in: imageData)
134
+ Bridge.log(
135
+ "\(TAG): No IMU from ImageIO (container=\(describeContainer(imageData)), rawHasExifMarker=\(rawHasExif))"
136
+ )
137
+ }
138
+
139
+ return jpegData
140
+ }
141
+
142
+ private static func logIncomingImageDiagnostics(imageData: Data) {
143
+ Bridge.log(
144
+ "\(TAG): BLE image diagnostics: size=\(imageData.count) bytes, container=\(describeContainer(imageData)), rawHasExifMarker=\(containsExifMarker(in: imageData))"
145
+ )
146
+ }
147
+
148
+ private static func describeContainer(_ data: Data) -> String {
149
+ let bytes = [UInt8](data.prefix(12))
150
+ if bytes.count >= 2, bytes[0] == 0xFF, bytes[1] == 0xD8 { return "jpeg" }
151
+ if bytes.count >= 12, bytes[4] == 0x66, bytes[5] == 0x74, bytes[6] == 0x79, bytes[7] == 0x70 {
152
+ let brand = String(bytes: bytes[8..<12], encoding: .ascii) ?? "?"
153
+ return "iso_bmff/ftyp=\(brand)"
154
+ }
155
+ return "unknown"
156
+ }
121
157
 
122
- // DispatchQueue.main.async {
123
- // callback.onError(requestId: requestId, error: error.localizedDescription)
124
- // }
158
+ private static func containsExifMarker(in data: Data) -> Bool {
159
+ let marker: [UInt8] = [0x45, 0x78, 0x69, 0x66, 0, 0]
160
+ let bytes = [UInt8](data)
161
+ guard bytes.count >= marker.count else { return false }
162
+ for i in 0...(bytes.count - marker.count) {
163
+ if Array(bytes[i..<(i + marker.count)]) == marker { return true }
164
+ }
165
+ return false
166
+ }
167
+
168
+ private static func readImuJsonFromImageData(_ imageData: Data) -> String? {
169
+ // Primary: ImageIO EXIF (works for JPEG and well-formed AVIF)
170
+ if let source = CGImageSourceCreateWithData(imageData as CFData, nil),
171
+ let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [String: Any]
172
+ {
173
+ let exif = properties[kCGImagePropertyExifDictionary as String] as? [String: Any]
174
+ let userComment = exif?[kCGImagePropertyExifUserComment as String] as? String
175
+ let tiff = properties[kCGImagePropertyTIFFDictionary as String] as? [String: Any]
176
+ let description = tiff?[kCGImagePropertyTIFFImageDescription as String] as? String
177
+ Bridge.log(
178
+ "\(TAG): ImageIO EXIF UserComment=\(describeExifAttribute(userComment)), ImageDescription=\(describeExifAttribute(description))"
179
+ )
180
+ if let userComment, !userComment.isEmpty { return userComment }
181
+ if let description, !description.isEmpty { return description }
182
+ } else {
183
+ Bridge.log("\(TAG): ImageIO: could not read properties")
184
+ }
185
+
186
+ // Fallback: raw TIFF scan for AVIFs where ImageIO doesn't expose the embedded Exif block
187
+ if containsExifMarker(in: imageData) {
188
+ if let tiffResult = readImuJsonFromTiff(imageData) {
189
+ Bridge.log("\(TAG): Read IMU UserComment via TIFF scan (\(tiffResult.count) chars)")
190
+ return tiffResult
191
+ }
192
+ }
193
+ return nil
194
+ }
195
+
196
+ /// Scans raw bytes for the {@code Exif\0\0} TIFF header and reads UserComment (0x9286).
197
+ private static func readImuJsonFromTiff(_ data: Data) -> String? {
198
+ let bytes = [UInt8](data)
199
+ let marker: [UInt8] = [0x45, 0x78, 0x69, 0x66, 0, 0]
200
+ var searchFrom = 0
201
+ while searchFrom <= bytes.count - marker.count {
202
+ guard let exifOff = findBytes(marker, in: bytes, from: searchFrom) else { break }
203
+ let tiff = exifOff + 6
204
+ guard tiff + 8 <= bytes.count else { break }
205
+ let littleEndian = bytes[tiff] == 0x49 && bytes[tiff + 1] == 0x49
206
+ let bigEndian = bytes[tiff] == 0x4D && bytes[tiff + 1] == 0x4D
207
+ guard littleEndian || bigEndian else { searchFrom = exifOff + 1; continue }
208
+ let magic = readU16(bytes, at: tiff + 2, le: littleEndian)
209
+ guard magic == 0x002A else { searchFrom = exifOff + 1; continue }
210
+ let ifd0Off = Int(readU32(bytes, at: tiff + 4, le: littleEndian))
211
+ if let result = readTagFromIfd(
212
+ bytes, tiff: tiff, ifdOff: tiff + ifd0Off, tag: 0x9286, le: littleEndian)
213
+ {
214
+ return result
125
215
  }
216
+ searchFrom = exifOff + 1
126
217
  }
218
+ return nil
219
+ }
220
+
221
+ private static func readTagFromIfd(
222
+ _ bytes: [UInt8], tiff: Int, ifdOff: Int, tag: UInt16, le: Bool
223
+ ) -> String? {
224
+ guard ifdOff + 2 <= bytes.count else { return nil }
225
+ let count = Int(readU16(bytes, at: ifdOff, le: le))
226
+ var off = ifdOff + 2
227
+ for _ in 0..<count {
228
+ guard off + 12 <= bytes.count else { break }
229
+ let entryTag = readU16(bytes, at: off, le: le)
230
+ let type = readU16(bytes, at: off + 2, le: le)
231
+ let valueCount = Int(readU32(bytes, at: off + 4, le: le))
232
+ if entryTag == tag {
233
+ let byteLen = valueCount
234
+ let valueOff: Int
235
+ if byteLen > 4 {
236
+ valueOff = tiff + Int(readU32(bytes, at: off + 8, le: le))
237
+ } else {
238
+ valueOff = off + 8
239
+ }
240
+ // UserComment starts with 8-byte charset prefix
241
+ let startOff = (tag == 0x9286 && byteLen > 8) ? valueOff + 8 : valueOff
242
+ let len = (tag == 0x9286 && byteLen > 8) ? byteLen - 8 : byteLen
243
+ guard startOff + len <= bytes.count else { return nil }
244
+ return String(bytes: Array(bytes[startOff..<startOff + len]), encoding: .utf8)?
245
+ .trimmingCharacters(in: .init(charactersIn: "\0"))
246
+ }
247
+ // Follow Exif IFD pointer
248
+ if entryTag == 0x8769, type == 4 {
249
+ let subOff = tiff + Int(readU32(bytes, at: off + 8, le: le))
250
+ if let r = readTagFromIfd(bytes, tiff: tiff, ifdOff: subOff, tag: tag, le: le) {
251
+ return r
252
+ }
253
+ }
254
+ off += 12
255
+ }
256
+ return nil
257
+ }
258
+
259
+ private static func findBytes(_ needle: [UInt8], in haystack: [UInt8], from: Int) -> Int? {
260
+ guard haystack.count >= needle.count else { return nil }
261
+ for i in from...(haystack.count - needle.count) {
262
+ if Array(haystack[i..<i + needle.count]) == needle { return i }
263
+ }
264
+ return nil
265
+ }
266
+
267
+ private static func readU16(_ bytes: [UInt8], at off: Int, le: Bool) -> UInt16 {
268
+ let a = UInt16(bytes[off]), b = UInt16(bytes[off + 1])
269
+ return le ? a | (b << 8) : (a << 8) | b
270
+ }
271
+
272
+ private static func readU32(_ bytes: [UInt8], at off: Int, le: Bool) -> UInt32 {
273
+ let a = UInt32(bytes[off]), b = UInt32(bytes[off + 1]),
274
+ c = UInt32(bytes[off + 2]), d = UInt32(bytes[off + 3])
275
+ return le ? a | (b << 8) | (c << 16) | (d << 24) : (a << 24) | (b << 16) | (c << 8) | d
276
+ }
277
+
278
+ private static func describeExifAttribute(_ value: String?) -> String {
279
+ guard let value else { return "null" }
280
+ if value.isEmpty { return "empty" }
281
+ let preview = value.count > 80 ? String(value.prefix(80)) + "…" : value
282
+ return "len=\(value.count) preview=\"\(preview)\""
283
+ }
284
+
285
+ private static func writeImuJsonToJpegData(_ jpegData: Data, imuJson: String) throws -> Data {
286
+ guard let source = CGImageSourceCreateWithData(jpegData as CFData, nil),
287
+ let imageType = CGImageSourceGetType(source),
288
+ let cgImage = CGImageSourceCreateImageAtIndex(source, 0, nil)
289
+ else {
290
+ throw PhotoUploadError.decodingFailed
291
+ }
292
+
293
+ let output = NSMutableData()
294
+ guard let destination = CGImageDestinationCreateWithData(
295
+ output, imageType, 1, nil
296
+ ) else {
297
+ throw PhotoUploadError.decodingFailed
298
+ }
299
+
300
+ var properties =
301
+ (CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [String: Any]) ?? [:]
302
+ var exif =
303
+ (properties[kCGImagePropertyExifDictionary as String] as? [String: Any]) ?? [:]
304
+ exif[kCGImagePropertyExifUserComment as String] = imuJson
305
+ properties[kCGImagePropertyExifDictionary as String] = exif
306
+
307
+ CGImageDestinationAddImage(destination, cgImage, properties as CFDictionary)
308
+ guard CGImageDestinationFinalize(destination) else {
309
+ throw PhotoUploadError.decodingFailed
310
+ }
311
+ return output as Data
127
312
  }
128
313
 
129
314
  /**
130
- * Decode image data (AVIF or JPEG) to UIImage
315
+ * Decode image data (AVIF or JPEG) to UIImage.
316
+ * AVIF arriving from glasses has a TIFF EXIF block appended to {@code mdat}; iOS ImageIO
317
+ * rejects those bytes the same way Android does. Strip the Exif tail before decoding.
131
318
  */
132
319
  private static func decodeImage(imageData: Data) -> UIImage? {
133
- // First try standard UIImage decoding (works for JPEG, PNG, etc)
134
- if let image = UIImage(data: imageData) {
320
+ let isAvif = isAvifData(imageData)
321
+ var decodeData = imageData
322
+ if isAvif && containsExifMarker(in: imageData) {
323
+ let stripped = stripAvifExifTail(imageData)
324
+ if stripped.count < imageData.count {
325
+ Bridge.log(
326
+ "\(TAG): Stripped Exif metadata item for decode: \(imageData.count) -> \(stripped.count) bytes"
327
+ )
328
+ decodeData = stripped
329
+ }
330
+ }
331
+
332
+ if let image = UIImage(data: decodeData) {
135
333
  return image
136
334
  }
137
335
 
138
- // If that fails, try AVIF decoding
139
- // Note: AVIF support requires iOS 16+ or a third-party library
140
- if #available(iOS 16.0, *) {
141
- // iOS 16+ has native AVIF support
142
- return UIImage(data: imageData)
143
- } else {
144
- // For older iOS versions, you would need to integrate a third-party
145
- // AVIF decoder library like libavif
146
- Bridge.log("\(TAG): AVIF decoding not supported on this iOS version")
147
- return nil
336
+ if isAvif {
337
+ if #available(iOS 16.0, *) {
338
+ return UIImage(data: decodeData)
339
+ } else {
340
+ Bridge.log("\(TAG): AVIF decoding not supported on iOS < 16")
341
+ return nil
342
+ }
343
+ }
344
+ return nil
345
+ }
346
+
347
+ private static func isAvifData(_ data: Data) -> Bool {
348
+ let bytes = [UInt8](data.prefix(12))
349
+ return bytes.count >= 12
350
+ && bytes[4] == 0x66 && bytes[5] == 0x74 && bytes[6] == 0x79 && bytes[7] == 0x70
351
+ && bytes[8] == 0x61 && bytes[9] == 0x76 && bytes[10] == 0x69 && bytes[11] == 0x66
352
+ }
353
+
354
+ /// Truncates the {@code mdat} box at the {@code Exif\0\0} marker, removing the TIFF EXIF
355
+ /// block that the glasses encoder appends. Returns original data unchanged on any parse error.
356
+ private static func stripAvifExifTail(_ data: Data) -> Data {
357
+ let bytes = [UInt8](data)
358
+ let marker: [UInt8] = [0x45, 0x78, 0x69, 0x66, 0, 0]
359
+
360
+ // Find last Exif marker
361
+ var lastExif = -1
362
+ for i in 0...(bytes.count - marker.count) {
363
+ if Array(bytes[i..<i + marker.count]) == marker { lastExif = i }
364
+ }
365
+ guard lastExif >= 0 else { return data }
366
+
367
+ // Walk top-level boxes to find mdat
368
+ var off = 0
369
+ while off + 8 <= bytes.count {
370
+ let boxSize = Int(readU32BE(bytes, at: off))
371
+ guard boxSize >= 8, off + boxSize <= bytes.count else { break }
372
+ let boxType = String(bytes: Array(bytes[off + 4..<off + 8]), encoding: .ascii) ?? ""
373
+ if boxType == "mdat" {
374
+ let payloadStart = off + 8
375
+ let payloadEnd = off + boxSize
376
+ guard lastExif >= payloadStart, lastExif < payloadEnd else { break }
377
+ let newPayloadLen = lastExif - payloadStart
378
+ guard newPayloadLen > 0 else { break }
379
+
380
+ var result = Data()
381
+ // Everything up to mdat header
382
+ result.append(contentsOf: bytes[0..<off])
383
+ // New mdat box header with updated size
384
+ let newMdatSize = UInt32(8 + newPayloadLen)
385
+ result.append(UInt8((newMdatSize >> 24) & 0xFF))
386
+ result.append(UInt8((newMdatSize >> 16) & 0xFF))
387
+ result.append(UInt8((newMdatSize >> 8) & 0xFF))
388
+ result.append(UInt8(newMdatSize & 0xFF))
389
+ result.append(contentsOf: [0x6D, 0x64, 0x61, 0x74]) // "mdat"
390
+ result.append(contentsOf: bytes[payloadStart..<payloadStart + newPayloadLen])
391
+ return result
392
+ }
393
+ off += boxSize
148
394
  }
395
+ return data
396
+ }
397
+
398
+ private static func readU32BE(_ bytes: [UInt8], at off: Int) -> UInt32 {
399
+ UInt32(bytes[off]) << 24 | UInt32(bytes[off + 1]) << 16
400
+ | UInt32(bytes[off + 2]) << 8 | UInt32(bytes[off + 3])
149
401
  }
150
402
 
151
403
  private static func uploadToWebhook(
@@ -153,10 +405,10 @@ class BlePhotoUploadService {
153
405
  requestId: String,
154
406
  webhookUrl: String,
155
407
  authToken: String?
156
- ) async throws {
408
+ ) async throws -> String {
157
409
  guard let url = URL(string: webhookUrl) else {
158
410
  Bridge.log("LIVE: Invalid webhook URL: \(webhookUrl)")
159
- return
411
+ throw PhotoUploadError.uploadFailed("Invalid webhook URL")
160
412
  }
161
413
 
162
414
  var request = URLRequest(url: url)
@@ -220,6 +472,7 @@ class BlePhotoUploadService {
220
472
  }
221
473
 
222
474
  print("LIVE: Upload successful. Response code: \(httpResponse.statusCode)")
475
+ return String(data: data, encoding: .utf8) ?? ""
223
476
 
224
477
  } catch {
225
478
  if error is PhotoUploadError {
@@ -546,31 +799,36 @@ private final class BleIncidentLogRelayEntry {
546
799
  // MARK: - CBCentralManagerDelegate
547
800
 
548
801
  extension MentraLive: CBCentralManagerDelegate {
549
- func centralManagerDidUpdateState(_ central: CBCentralManager) {
550
- switch central.state {
551
- case .poweredOn:
552
- Bridge.log("LIVE: Bluetooth powered on")
553
- // If we have a saved device, try to reconnect
554
- if let savedDeviceName = UserDefaults.standard.string(forKey: PREFS_DEVICE_NAME),
555
- !savedDeviceName.isEmpty
556
- {
557
- startScan()
558
- }
802
+ // CoreBluetooth delivers these callbacks on `bluetoothQueue`; hop back before touching MainActor state.
803
+ nonisolated func centralManagerDidUpdateState(_ central: CBCentralManager) {
804
+ let state = central.state
805
+ DispatchQueue.main.async { [weak self] in
806
+ guard let self else { return }
807
+ switch state {
808
+ case .poweredOn:
809
+ Bridge.log("LIVE: Bluetooth powered on")
810
+ // If we have a saved device, try to reconnect
811
+ if let savedDeviceName = UserDefaults.standard.string(forKey: PREFS_DEVICE_NAME),
812
+ !savedDeviceName.isEmpty
813
+ {
814
+ self.startScan()
815
+ }
559
816
 
560
- case .poweredOff:
561
- Bridge.log("LIVE: Bluetooth is powered off")
562
- updateConnectionState(ConnTypes.DISCONNECTED)
817
+ case .poweredOff:
818
+ Bridge.log("LIVE: Bluetooth is powered off")
819
+ self.updateConnectionState(ConnTypes.DISCONNECTED)
563
820
 
564
- case .unauthorized:
565
- Bridge.log("LIVE: Bluetooth is unauthorized")
566
- updateConnectionState(ConnTypes.DISCONNECTED)
821
+ case .unauthorized:
822
+ Bridge.log("LIVE: Bluetooth is unauthorized")
823
+ self.updateConnectionState(ConnTypes.DISCONNECTED)
567
824
 
568
- case .unsupported:
569
- Bridge.log("LIVE: Bluetooth is unsupported")
570
- updateConnectionState(ConnTypes.DISCONNECTED)
825
+ case .unsupported:
826
+ Bridge.log("LIVE: Bluetooth is unsupported")
827
+ self.updateConnectionState(ConnTypes.DISCONNECTED)
571
828
 
572
- default:
573
- Bridge.log("LIVE: Bluetooth state: \(central.state.rawValue)")
829
+ default:
830
+ Bridge.log("LIVE: Bluetooth state: \(state.rawValue)")
831
+ }
574
832
  }
575
833
  }
576
834
 
@@ -602,79 +860,89 @@ extension MentraLive: CBCentralManagerDelegate {
602
860
  }
603
861
  }
604
862
 
605
- func centralManager(
863
+ nonisolated func centralManager(
606
864
  _: CBCentralManager, didDiscover peripheral: CBPeripheral,
607
865
  advertisementData _: [String: Any], rssi: NSNumber
608
866
  ) {
609
- handleDiscoveredPeripheral(peripheral, rssi: rssi)
867
+ DispatchQueue.main.async { [weak self] in
868
+ self?.handleDiscoveredPeripheral(peripheral, rssi: rssi)
869
+ }
610
870
  }
611
871
 
612
- func centralManager(_: CBCentralManager, didConnect peripheral: CBPeripheral) {
613
- Bridge.log("Connected to GATT server, discovering services...")
872
+ nonisolated func centralManager(_: CBCentralManager, didConnect peripheral: CBPeripheral) {
873
+ DispatchQueue.main.async { [weak self] in
874
+ guard let self else { return }
875
+ Bridge.log("Connected to GATT server, discovering services...")
614
876
 
615
- stopConnectionTimeout()
616
- isConnecting = false
617
- connectedPeripheral = peripheral
877
+ self.stopConnectionTimeout()
878
+ self.isConnecting = false
879
+ self.connectedPeripheral = peripheral
618
880
 
619
- // Save device name and address for future reconnection
620
- if let name = peripheral.name {
621
- UserDefaults.standard.set(name, forKey: PREFS_DEVICE_NAME)
622
- Bridge.log("Saved device name for future reconnection: \(name)")
623
- DeviceStore.shared.apply("glasses", "bluetoothName", name)
624
- }
625
- // Persist peripheral UUID so DeviceManager can sync it to RN settings
626
- DeviceStore.shared.apply("bluetooth", "device_address", peripheral.identifier.uuidString)
881
+ // Save device name and address for future reconnection
882
+ if let name = peripheral.name {
883
+ UserDefaults.standard.set(name, forKey: PREFS_DEVICE_NAME)
884
+ Bridge.log("Saved device name for future reconnection: \(name)")
885
+ DeviceStore.shared.apply("glasses", "bluetoothName", name)
886
+ }
887
+ // Persist peripheral UUID so DeviceManager can sync it to RN settings
888
+ DeviceStore.shared.apply("bluetooth", "device_address", peripheral.identifier.uuidString)
627
889
 
628
- // Audio Pairing: Setup Bluetooth audio after BLE connection
629
- if let deviceName = peripheral.name {
630
- Bridge.log("BLE connection established, setting up audio...")
631
- // setupAudioPairing(deviceName: deviceName)
632
- }
890
+ // Audio Pairing: Setup Bluetooth audio after BLE connection
891
+ if let deviceName = peripheral.name {
892
+ Bridge.log("BLE connection established, setting up audio...")
893
+ // setupAudioPairing(deviceName: deviceName)
894
+ }
633
895
 
634
- // Discover services
635
- peripheral.discoverServices([SERVICE_UUID])
896
+ // Discover services
897
+ peripheral.discoverServices([SERVICE_UUID])
636
898
 
637
- // Reset reconnect attempts
638
- reconnectAttempts = 0
899
+ // Reset reconnect attempts
900
+ self.reconnectAttempts = 0
901
+ }
639
902
  }
640
903
 
641
- func centralManager(
904
+ nonisolated func centralManager(
642
905
  _: CBCentralManager, didDisconnectPeripheral _: CBPeripheral, error _: Error?
643
906
  ) {
644
- Bridge.log("LIVE: Disconnected from GATT server")
907
+ DispatchQueue.main.async { [weak self] in
908
+ guard let self else { return }
909
+ Bridge.log("LIVE: Disconnected from GATT server")
645
910
 
646
- isConnecting = false
911
+ self.isConnecting = false
647
912
 
648
- connectedPeripheral = nil
649
- fullyBooted = false
650
- connected = false
651
- updateConnectionState(ConnTypes.DISCONNECTED)
652
- rgbLedAuthorityClaimed = false
913
+ self.connectedPeripheral = nil
914
+ self.fullyBooted = false
915
+ self.connected = false
916
+ self.updateConnectionState(ConnTypes.DISCONNECTED)
917
+ self.rgbLedAuthorityClaimed = false
653
918
 
654
- stopAllTimers()
919
+ self.stopAllTimers()
655
920
 
656
- // Clean up characteristics
657
- txCharacteristic = nil
658
- rxCharacteristic = nil
921
+ // Clean up characteristics
922
+ self.txCharacteristic = nil
923
+ self.rxCharacteristic = nil
659
924
 
660
- // Attempt reconnection if not killed
661
- if !isKilled {
662
- handleReconnection()
925
+ // Attempt reconnection if not killed
926
+ if !self.isKilled {
927
+ self.handleReconnection()
928
+ }
663
929
  }
664
930
  }
665
931
 
666
- func centralManager(_: CBCentralManager, didFailToConnect _: CBPeripheral, error: Error?) {
667
- Bridge.log(
668
- "LIVE: Failed to connect to peripheral: \(error?.localizedDescription ?? "Unknown error")"
669
- )
932
+ nonisolated func centralManager(_: CBCentralManager, didFailToConnect _: CBPeripheral, error: Error?) {
933
+ let errorDescription = error?.localizedDescription ?? "Unknown error"
934
+ DispatchQueue.main.async { [weak self] in
935
+ guard let self else { return }
936
+ Bridge.log("LIVE: Failed to connect to peripheral: \(errorDescription)")
670
937
 
671
- stopConnectionTimeout()
672
- isConnecting = false
673
- connectedPeripheral = nil
674
- updateConnectionState(ConnTypes.DISCONNECTED)
938
+ self.stopConnectionTimeout()
939
+ self.isConnecting = false
940
+ self.connectedPeripheral = nil
941
+ self.updateConnectionState(ConnTypes.DISCONNECTED)
675
942
 
676
- if !isKilled {
677
- handleReconnection()
943
+ if !self.isKilled {
944
+ self.handleReconnection()
945
+ }
678
946
  }
679
947
  }
680
948
  }
@@ -682,201 +950,228 @@ extension MentraLive: CBCentralManagerDelegate {
682
950
  // MARK: - CBPeripheralDelegate
683
951
 
684
952
  extension MentraLive: CBPeripheralDelegate {
685
- func peripheral(_: CBPeripheral, didReadRSSI RSSI: NSNumber, error: Error?) {
686
- signalStrengthReadInFlight = false
687
- if let error {
688
- Bridge.log("LIVE: Error reading RSSI: \(error.localizedDescription)")
689
- } else {
690
- updateSignalStrength(Int(truncating: RSSI))
953
+ nonisolated func peripheral(_: CBPeripheral, didReadRSSI RSSI: NSNumber, error: Error?) {
954
+ let errorDescription = error?.localizedDescription
955
+ DispatchQueue.main.async { [weak self] in
956
+ guard let self else { return }
957
+ self.signalStrengthReadInFlight = false
958
+ if let errorDescription {
959
+ Bridge.log("LIVE: Error reading RSSI: \(errorDescription)")
960
+ } else {
961
+ self.updateSignalStrength(Int(truncating: RSSI))
962
+ }
691
963
  }
692
964
  }
693
965
 
694
- func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
695
- if let error {
696
- Bridge.log("LIVE: Error discovering services: \(error.localizedDescription)")
697
- centralManager?.cancelPeripheralConnection(peripheral)
698
- return
699
- }
966
+ nonisolated func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
967
+ let errorDescription = error?.localizedDescription
968
+ DispatchQueue.main.async { [weak self] in
969
+ guard let self else { return }
970
+ if let errorDescription {
971
+ Bridge.log("LIVE: Error discovering services: \(errorDescription)")
972
+ self.centralManager?.cancelPeripheralConnection(peripheral)
973
+ return
974
+ }
700
975
 
701
- guard let services = peripheral.services else { return }
976
+ guard let services = peripheral.services else { return }
702
977
 
703
- for service in services where service.uuid == SERVICE_UUID {
704
- Bridge.log("LIVE: Found UART service, discovering characteristics...")
705
- peripheral.discoverCharacteristics(
706
- [
707
- TX_CHAR_UUID, RX_CHAR_UUID, FILE_READ_UUID, FILE_WRITE_UUID, LC3_READ_UUID,
708
- LC3_WRITE_UUID,
709
- ], for: service
710
- )
978
+ for service in services where service.uuid == SERVICE_UUID {
979
+ Bridge.log("LIVE: Found UART service, discovering characteristics...")
980
+ peripheral.discoverCharacteristics(
981
+ [
982
+ TX_CHAR_UUID, RX_CHAR_UUID, FILE_READ_UUID, FILE_WRITE_UUID, LC3_READ_UUID,
983
+ LC3_WRITE_UUID,
984
+ ], for: service
985
+ )
986
+ }
711
987
  }
712
988
  }
713
989
 
714
- func peripheral(
990
+ nonisolated func peripheral(
715
991
  _ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?
716
992
  ) {
717
- if let error {
718
- Bridge.log("LIVE: Error discovering characteristics: \(error.localizedDescription)")
719
- centralManager?.cancelPeripheralConnection(peripheral)
720
- return
721
- }
993
+ let errorDescription = error?.localizedDescription
994
+ DispatchQueue.main.async { [weak self] in
995
+ guard let self else { return }
996
+ if let errorDescription {
997
+ Bridge.log("LIVE: Error discovering characteristics: \(errorDescription)")
998
+ self.centralManager?.cancelPeripheralConnection(peripheral)
999
+ return
1000
+ }
722
1001
 
723
- guard let characteristics = service.characteristics else { return }
724
-
725
- for characteristic in characteristics {
726
- // Log characteristic properties for debugging
727
- let props = characteristic.properties
728
- let propsStr = [
729
- props.contains(.notify) ? "NOTIFY" : nil,
730
- props.contains(.indicate) ? "INDICATE" : nil,
731
- props.contains(.read) ? "READ" : nil,
732
- props.contains(.write) ? "WRITE" : nil,
733
- props.contains(.writeWithoutResponse) ? "WRITE_NO_RESPONSE" : nil,
734
- ].compactMap { $0 }.joined(separator: " ")
735
- Bridge.log("📋 Characteristic \(characteristic.uuid): properties=[\(propsStr)]")
736
-
737
- if characteristic.uuid == TX_CHAR_UUID {
738
- txCharacteristic = characteristic
739
- Bridge.log("LIVE: ✅ Found TX characteristic")
740
- } else if characteristic.uuid == RX_CHAR_UUID {
741
- rxCharacteristic = characteristic
742
- Bridge.log(
743
- "LIVE: ✅ Found RX characteristic - hasNotify=\(props.contains(.notify)), hasIndicate=\(props.contains(.indicate))"
744
- )
745
- } else if characteristic.uuid == FILE_READ_UUID {
746
- fileReadCharacteristic = characteristic
747
- Bridge.log("LIVE: 📁 Found FILE_READ characteristic (72FF)!")
748
- } else if characteristic.uuid == FILE_WRITE_UUID {
749
- fileWriteCharacteristic = characteristic
750
- Bridge.log("LIVE: 📁 Found FILE_WRITE characteristic (73FF)!")
751
- } else if characteristic.uuid == LC3_READ_UUID {
752
- lc3ReadCharacteristic = characteristic
753
- Bridge.log("LIVE: 🎤 Found LC3_READ characteristic (audio input)!")
754
- } else if characteristic.uuid == LC3_WRITE_UUID {
755
- lc3WriteCharacteristic = characteristic
756
- Bridge.log("LIVE: 🎤 Found LC3_WRITE characteristic (audio output)!")
1002
+ guard let characteristics = service.characteristics else { return }
1003
+
1004
+ for characteristic in characteristics {
1005
+ // Log characteristic properties for debugging
1006
+ let props = characteristic.properties
1007
+ let propsStr = [
1008
+ props.contains(.notify) ? "NOTIFY" : nil,
1009
+ props.contains(.indicate) ? "INDICATE" : nil,
1010
+ props.contains(.read) ? "READ" : nil,
1011
+ props.contains(.write) ? "WRITE" : nil,
1012
+ props.contains(.writeWithoutResponse) ? "WRITE_NO_RESPONSE" : nil,
1013
+ ].compactMap { $0 }.joined(separator: " ")
1014
+ Bridge.log("📋 Characteristic \(characteristic.uuid): properties=[\(propsStr)]")
1015
+
1016
+ if characteristic.uuid == TX_CHAR_UUID {
1017
+ self.txCharacteristic = characteristic
1018
+ Bridge.log("LIVE: ✅ Found TX characteristic")
1019
+ } else if characteristic.uuid == RX_CHAR_UUID {
1020
+ self.rxCharacteristic = characteristic
1021
+ Bridge.log(
1022
+ "LIVE: ✅ Found RX characteristic - hasNotify=\(props.contains(.notify)), hasIndicate=\(props.contains(.indicate))"
1023
+ )
1024
+ } else if characteristic.uuid == FILE_READ_UUID {
1025
+ self.fileReadCharacteristic = characteristic
1026
+ Bridge.log("LIVE: 📁 Found FILE_READ characteristic (72FF)!")
1027
+ } else if characteristic.uuid == FILE_WRITE_UUID {
1028
+ self.fileWriteCharacteristic = characteristic
1029
+ Bridge.log("LIVE: 📁 Found FILE_WRITE characteristic (73FF)!")
1030
+ } else if characteristic.uuid == LC3_READ_UUID {
1031
+ self.lc3ReadCharacteristic = characteristic
1032
+ Bridge.log("LIVE: 🎤 Found LC3_READ characteristic (audio input)!")
1033
+ } else if characteristic.uuid == LC3_WRITE_UUID {
1034
+ self.lc3WriteCharacteristic = characteristic
1035
+ Bridge.log("LIVE: 🎤 Found LC3_WRITE characteristic (audio output)!")
1036
+ }
757
1037
  }
758
- }
759
1038
 
760
- // Check if we have both characteristics
761
- if let tx = txCharacteristic, let rx = rxCharacteristic {
762
- Bridge.log("LIVE: ✅ Both TX and RX characteristics found - BLE connection ready")
763
- Bridge.log("LIVE: 🔄 Waiting for glasses SOC to become ready...")
1039
+ // Check if we have both characteristics
1040
+ if self.txCharacteristic != nil, let rx = self.rxCharacteristic {
1041
+ Bridge.log("LIVE: ✅ Both TX and RX characteristics found - BLE connection ready")
1042
+ Bridge.log("LIVE: 🔄 Waiting for glasses SOC to become ready...")
764
1043
 
765
- // Don't set connected=true here - wait for SOC to be ready (fullyBooted=true)
766
- // DeviceStore handles connected state based on fullyBooted
1044
+ // Don't set connected=true here - wait for SOC to be ready (fullyBooted=true)
1045
+ // DeviceStore handles connected state based on fullyBooted
767
1046
 
768
- // Keep state as connecting until glasses are ready
769
- updateConnectionState(ConnTypes.CONNECTING)
1047
+ // Keep state as connecting until glasses are ready
1048
+ self.updateConnectionState(ConnTypes.CONNECTING)
770
1049
 
771
- let withResponseMtu = peripheral.maximumWriteValueLength(for: .withResponse) + 3
772
- let withoutResponseMtu = peripheral.maximumWriteValueLength(for: .withoutResponse) + 3
773
- currentMtu = max(23, min(withResponseMtu, withoutResponseMtu))
774
- Bridge.log(
775
- "LIVE: Current MTU estimate: withResponse=\(withResponseMtu), withoutResponse=\(withoutResponseMtu), selected=\(currentMtu)"
776
- )
1050
+ let withResponseMtu = peripheral.maximumWriteValueLength(for: .withResponse) + 3
1051
+ let withoutResponseMtu = peripheral.maximumWriteValueLength(for: .withoutResponse) + 3
1052
+ self.currentMtu = max(23, min(withResponseMtu, withoutResponseMtu))
1053
+ Bridge.log(
1054
+ "LIVE: Current MTU estimate: withResponse=\(withResponseMtu), withoutResponse=\(withoutResponseMtu), selected=\(self.currentMtu)"
1055
+ )
777
1056
 
778
- // Enable notifications on RX characteristic
779
- peripheral.setNotifyValue(true, for: rx)
1057
+ // Enable notifications on RX characteristic
1058
+ peripheral.setNotifyValue(true, for: rx)
780
1059
 
781
- // Enable notifications on file characteristics if available
782
- if let fileRead = fileReadCharacteristic {
783
- peripheral.setNotifyValue(true, for: fileRead)
784
- }
1060
+ // Enable notifications on file characteristics if available
1061
+ if let fileRead = self.fileReadCharacteristic {
1062
+ peripheral.setNotifyValue(true, for: fileRead)
1063
+ }
785
1064
 
786
- // Enable notifications on LC3 audio characteristic if device supports it
787
- if supportsLC3Audio, let lc3Read = lc3ReadCharacteristic {
788
- peripheral.setNotifyValue(true, for: lc3Read)
789
- Bridge.log("LIVE: 🎤 Enabled LC3 audio notifications")
790
- }
1065
+ // Enable notifications on LC3 audio characteristic if device supports it
1066
+ if self.supportsLC3Audio, let lc3Read = self.lc3ReadCharacteristic {
1067
+ peripheral.setNotifyValue(true, for: lc3Read)
1068
+ Bridge.log("LIVE: 🎤 Enabled LC3 audio notifications")
1069
+ }
791
1070
 
792
- // Start readiness check loop
793
- startSignalStrengthPolling()
794
- startReadinessCheckLoop()
795
- } else {
796
- Bridge.log("LIVE: Required BLE characteristics not found")
797
- if txCharacteristic == nil {
798
- Bridge.log("LIVE: TX characteristic not found")
799
- }
800
- if rxCharacteristic == nil {
801
- Bridge.log("LIVE: RX characteristic not found")
1071
+ // Start readiness check loop
1072
+ self.startSignalStrengthPolling()
1073
+ self.startReadinessCheckLoop()
1074
+ } else {
1075
+ Bridge.log("LIVE: Required BLE characteristics not found")
1076
+ if self.txCharacteristic == nil {
1077
+ Bridge.log("LIVE: TX characteristic not found")
1078
+ }
1079
+ if self.rxCharacteristic == nil {
1080
+ Bridge.log("LIVE: RX characteristic not found")
1081
+ }
1082
+ self.centralManager?.cancelPeripheralConnection(peripheral)
802
1083
  }
803
- centralManager?.cancelPeripheralConnection(peripheral)
804
1084
  }
805
1085
  }
806
1086
 
807
- func peripheral(
1087
+ nonisolated func peripheral(
808
1088
  _: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?
809
1089
  ) {
810
- // Bridge.log("LIVE: DEBUG: didUpdateValueFor CALLED - characteristic: \(characteristic.uuid), dataSize: \(characteristic.value?.count ?? 0)")
811
- // Log raw hex for debugging glasses_ready issue
812
- if let data = characteristic.value {
813
- let hexString = data.prefix(50).map { String(format: "%02X ", $0) }.joined()
814
- // Bridge.log("LIVE: DEBUG: RAW HEX (first 50): \(hexString)")
815
- }
816
- if let error {
817
- Bridge.log(
818
- "LIVE: Error updating value for characteristic: \(error.localizedDescription)"
819
- )
820
- return
821
- }
1090
+ let uuid = characteristic.uuid
1091
+ let data = characteristic.value
1092
+ let errorDescription = error?.localizedDescription
1093
+ DispatchQueue.main.async { [weak self] in
1094
+ guard let self else { return }
1095
+ // Bridge.log("LIVE: DEBUG: didUpdateValueFor CALLED - characteristic: \(uuid), dataSize: \(data?.count ?? 0)")
1096
+ // Log raw hex for debugging glasses_ready issue
1097
+ if let data {
1098
+ let hexString = data.prefix(50).map { String(format: "%02X ", $0) }.joined()
1099
+ _ = hexString
1100
+ // Bridge.log("LIVE: DEBUG: RAW HEX (first 50): \(hexString)")
1101
+ }
1102
+ if let errorDescription {
1103
+ Bridge.log("LIVE: Error updating value for characteristic: \(errorDescription)")
1104
+ return
1105
+ }
822
1106
 
823
- guard let data = characteristic.value else {
824
- Bridge.log("LIVE: Characteristic value is nil")
825
- return
826
- }
1107
+ guard let data else {
1108
+ Bridge.log("LIVE: Characteristic value is nil")
1109
+ return
1110
+ }
827
1111
 
828
- let threadId = Thread.current.hash
829
- let uuid = characteristic.uuid
1112
+ let threadId = Thread.current.hash
1113
+ _ = threadId
830
1114
 
831
- // Bridge.log("Thread-\(threadId): 🎉 didUpdateValueFor CALLBACK TRIGGERED! Characteristic: \(uuid)")
832
- // if uuid == RX_CHAR_UUID {
833
- // Bridge.log("Thread-\(threadId): 🎯 RECEIVED DATA ON RX CHARACTERISTIC (Peripheral's TX)")
834
- // } else if uuid == TX_CHAR_UUID {
835
- // Bridge.log("Thread-\(threadId): 🎯 RECEIVED DATA ON TX CHARACTERISTIC (Peripheral's RX)")
836
- // }
837
- // Bridge.log("Thread-\(threadId): 🔍 Processing received data - \(data.count) bytes")
1115
+ // Bridge.log("Thread-\(threadId): 🎉 didUpdateValueFor CALLBACK TRIGGERED! Characteristic: \(uuid)")
1116
+ // if uuid == RX_CHAR_UUID {
1117
+ // Bridge.log("Thread-\(threadId): 🎯 RECEIVED DATA ON RX CHARACTERISTIC (Peripheral's TX)")
1118
+ // } else if uuid == TX_CHAR_UUID {
1119
+ // Bridge.log("Thread-\(threadId): 🎯 RECEIVED DATA ON TX CHARACTERISTIC (Peripheral's RX)")
1120
+ // }
1121
+ // Bridge.log("Thread-\(threadId): 🔍 Processing received data - \(data.count) bytes")
838
1122
 
839
- // Handle LC3 audio data separately (dedicated characteristic for LC3-capable devices)
840
- if uuid == LC3_READ_UUID && supportsLC3Audio {
841
- // Bridge.log("LIVE: Received data on LC3_READ characteristic (audio input)")
842
- processLc3AudioPacket(data)
843
- return
844
- }
1123
+ // Handle LC3 audio data separately (dedicated characteristic for LC3-capable devices)
1124
+ if uuid == LC3_READ_UUID && self.supportsLC3Audio {
1125
+ // Bridge.log("LIVE: Received data on LC3_READ characteristic (audio input)")
1126
+ self.processLc3AudioPacket(data)
1127
+ return
1128
+ }
845
1129
 
846
- // Handle regular data (JSON messages, file transfers, etc.)
847
- processReceivedData(data)
1130
+ // Handle regular data (JSON messages, file transfers, etc.)
1131
+ self.processReceivedData(data)
1132
+ }
848
1133
  }
849
1134
 
850
- func peripheral(_: CBPeripheral, didWriteValueFor _: CBCharacteristic, error: Error?) {
851
- if let error {
852
- Bridge.log("LIVE: Error writing characteristic: \(error.localizedDescription)")
853
- } else {
854
- Bridge.log("LIVE: Characteristic write successful")
1135
+ nonisolated func peripheral(_: CBPeripheral, didWriteValueFor _: CBCharacteristic, error: Error?) {
1136
+ let errorDescription = error?.localizedDescription
1137
+ DispatchQueue.main.async {
1138
+ if let errorDescription {
1139
+ Bridge.log("LIVE: Error writing characteristic: \(errorDescription)")
1140
+ } else {
1141
+ Bridge.log("LIVE: Characteristic write successful")
1142
+ }
855
1143
  }
856
1144
  }
857
1145
 
858
- func peripheral(
1146
+ nonisolated func peripheral(
859
1147
  _: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic,
860
1148
  error: Error?
861
1149
  ) {
862
- if let error {
863
- Bridge.log("LIVE: Error updating notification state: \(error.localizedDescription)")
864
- } else {
865
- Bridge.log(
866
- "Notification state updated for \(characteristic.uuid): \(characteristic.isNotifying ? "ON" : "OFF")"
867
- )
1150
+ let uuid = characteristic.uuid
1151
+ let isNotifying = characteristic.isNotifying
1152
+ let errorDescription = error?.localizedDescription
1153
+ DispatchQueue.main.async { [weak self] in
1154
+ guard let self else { return }
1155
+ if let errorDescription {
1156
+ Bridge.log("LIVE: Error updating notification state: \(errorDescription)")
1157
+ } else {
1158
+ Bridge.log("Notification state updated for \(uuid): \(isNotifying ? "ON" : "OFF")")
868
1159
 
869
- if characteristic.uuid == RX_CHAR_UUID, characteristic.isNotifying {
870
- Bridge.log("LIVE: 🔔 Ready to receive data via notifications")
1160
+ if uuid == self.RX_CHAR_UUID, isNotifying {
1161
+ Bridge.log("LIVE: 🔔 Ready to receive data via notifications")
1162
+ }
871
1163
  }
872
1164
  }
873
1165
  }
874
1166
 
875
- func peripheralDidUpdateRSSI(_ peripheral: CBPeripheral, error: Error?) {
876
- if let error {
877
- Bridge.log("LIVE: Error reading RSSI: \(error.localizedDescription)")
878
- } else {
879
- Bridge.log("LIVE: RSSI: \(peripheral.readRSSI())")
1167
+ nonisolated func peripheralDidUpdateRSSI(_ peripheral: CBPeripheral, error: Error?) {
1168
+ let errorDescription = error?.localizedDescription
1169
+ DispatchQueue.main.async {
1170
+ if let errorDescription {
1171
+ Bridge.log("LIVE: Error reading RSSI: \(errorDescription)")
1172
+ } else {
1173
+ Bridge.log("LIVE: RSSI: \(peripheral.readRSSI())")
1174
+ }
880
1175
  }
881
1176
  }
882
1177
  }
@@ -918,6 +1213,7 @@ class MentraLive: NSObject, SGCManager {
918
1213
  // a previous pairing into the next one (would otherwise surface as wrong overall_percent
919
1214
  // or stale lastBesOtaProgress on the next OTA).
920
1215
  if state == ConnTypes.DISCONNECTED {
1216
+ incomingChunkReassembler.clear()
921
1217
  stopSignalStrengthPolling()
922
1218
  DeviceStore.shared.apply("glasses", "signalStrength", -1)
923
1219
  DeviceStore.shared.apply("glasses", "signalStrengthUpdatedAt", 0)
@@ -929,7 +1225,7 @@ class MentraLive: NSObject, SGCManager {
929
1225
  func setSilentMode(_: Bool) {}
930
1226
  func exit() {}
931
1227
  func showDashboard() {}
932
- func displayBitmap(base64ImageData _: String) async -> Bool {
1228
+ func displayBitmap(base64ImageData _: String, x _: Int32? = nil, y _: Int32? = nil, width _: Int32? = nil, height _: Int32? = nil) async -> Bool {
933
1229
  return true
934
1230
  }
935
1231
 
@@ -1317,10 +1613,10 @@ class MentraLive: NSObject, SGCManager {
1317
1613
 
1318
1614
  func requestPhoto(
1319
1615
  _ requestId: String, appId: String, size: String?, webhookUrl: String?, authToken: String?,
1320
- compress: String?, flash: Bool, sound: Bool, exposureTimeNs: Double?, iso: Int?
1616
+ compress: String?, flash: Bool, save: Bool, sound: Bool, exposureTimeNs: Double?, iso: Int?
1321
1617
  ) {
1322
1618
  Bridge.log(
1323
- "LIVE: PHOTO PIPELINE [5/6] requestPhoto() entry requestId=\(requestId) appId=\(appId) flash=\(flash) sound=\(sound) iso=\(iso.map { String($0) } ?? "auto")"
1619
+ "LIVE: PHOTO PIPELINE [5/6] requestPhoto() entry requestId=\(requestId) appId=\(appId) flash=\(flash) save=\(save) sound=\(sound) iso=\(iso.map { String($0) } ?? "auto")"
1324
1620
  )
1325
1621
 
1326
1622
  var json: [String: Any] = [
@@ -1366,6 +1662,7 @@ class MentraLive: NSObject, SGCManager {
1366
1662
  json["compress"] = compress ?? "none"
1367
1663
 
1368
1664
  json["flash"] = flash
1665
+ json["save"] = save
1369
1666
  json["sound"] = sound
1370
1667
 
1371
1668
  if let e = exposureTimeNs, e.isFinite, e > 0, e <= Double(Int64.max) {
@@ -1838,7 +2135,8 @@ class MentraLive: NSObject, SGCManager {
1838
2135
  updateBatteryStatus(level: level, isCharging: isCharging)
1839
2136
 
1840
2137
  case "voice_activity_detection_status":
1841
- let enabled = json["voiceActivityDetectionEnabled"] as? Bool ?? true
2138
+ let enabled = json["voiceActivityDetectionEnabled"] as? Bool
2139
+ ?? BluetoothSdkDefaults.voiceActivityDetectionEnabled
1842
2140
  handleVoiceActivityDetectionStatus(enabled: enabled)
1843
2141
 
1844
2142
  case "speaking_status":
@@ -1870,21 +2168,34 @@ class MentraLive: NSObject, SGCManager {
1870
2168
  case "stream_status":
1871
2169
  emitRtmpStreamStatus(json)
1872
2170
 
2171
+ case "video_recording_status":
2172
+ emitVideoRecordingStatus(json)
2173
+
1873
2174
  case "photo_status":
1874
2175
  emitPhotoStatus(json)
1875
2176
 
2177
+ case "photo_response":
2178
+ emitPhotoResponse(json)
2179
+
1876
2180
  case "gallery_status":
1877
2181
  let photoCount = json["photos"] as? Int ?? 0
1878
2182
  let videoCount = json["videos"] as? Int ?? 0
1879
2183
  let totalCount = json["total"] as? Int ?? 0
1880
2184
  let totalSize = json["total_size"] as? Int64 ?? 0
1881
2185
  let hasContent = json["has_content"] as? Bool ?? false
2186
+ let cameraBusy = Self.galleryCameraBusy(json)
2187
+ let cameraBusyReason = Self.galleryCameraBusyReason(json)
1882
2188
  handleGalleryStatus(
1883
2189
  photoCount: photoCount, videoCount: videoCount,
1884
2190
  totalCount: totalCount, totalSize: totalSize,
1885
- hasContent: hasContent
2191
+ hasContent: hasContent,
2192
+ cameraBusy: cameraBusy,
2193
+ cameraBusyReason: cameraBusyReason
1886
2194
  )
1887
2195
 
2196
+ case "settings_ack":
2197
+ emitSettingsAck(json)
2198
+
1888
2199
  case "button_press":
1889
2200
  handleButtonPress(json)
1890
2201
 
@@ -2035,6 +2346,7 @@ class MentraLive: NSObject, SGCManager {
2035
2346
 
2036
2347
  Bridge.log("LIVE: 📱 OTA status - step \(osCurrentStep)/\(osTotalSteps) \(osPhase) \(osStatus) \(osOverallPercent)%")
2037
2348
 
2349
+ let glassesTimeMs = (json["glasses_time_ms"] as? NSNumber)?.int64Value ?? 0
2038
2350
  Bridge.sendOtaStatus(
2039
2351
  sessionId: osSessionId,
2040
2352
  totalSteps: osTotalSteps,
@@ -2044,7 +2356,8 @@ class MentraLive: NSObject, SGCManager {
2044
2356
  stepPercent: osStepPercent,
2045
2357
  overallPercent: osOverallPercent,
2046
2358
  status: osStatus,
2047
- errorMessage: osErrorMessage
2359
+ errorMessage: osErrorMessage,
2360
+ glassesTimeMs: glassesTimeMs > 0 ? glassesTimeMs : nil
2048
2361
  )
2049
2362
 
2050
2363
  case "ota_progress":
@@ -2125,12 +2438,11 @@ class MentraLive: NSObject, SGCManager {
2125
2438
  if let bluetoothMacAddress = fields["bt_mac_address"] as? String {
2126
2439
  DeviceStore.shared.apply("glasses", "bluetoothMacAddress", bluetoothMacAddress)
2127
2440
  }
2441
+ if let systemTimeMs = fields["system_time_ms"] as? NSNumber {
2442
+ DeviceStore.shared.apply("glasses", "systemTimeMs", systemTimeMs.int64Value)
2443
+ }
2128
2444
 
2129
- // Send fields immediately to RN - no waiting for other chunks
2130
- // All fields including mtk_fw_version are forwarded to RN
2131
- // Bridge.sendTypedMessage("version_info", body: fields)
2132
-
2133
- // Bridge.log("LIVE: Processed version_info fields and sent to RN")
2445
+ Bridge.sendVersionInfo(fields)
2134
2446
  } else {
2135
2447
  Bridge.log("Unhandled message type: \(type)")
2136
2448
  }
@@ -2461,6 +2773,17 @@ class MentraLive: NSObject, SGCManager {
2461
2773
  sendJson(json, wakeUp: true)
2462
2774
  }
2463
2775
 
2776
+ func sendSetSystemTime(_ timestampMs: Int64) {
2777
+ Bridge.log("LIVE: ⏰ Sending set_system_time: \(timestampMs)")
2778
+
2779
+ let json: [String: Any] = [
2780
+ "type": "set_system_time",
2781
+ "timestamp_ms": timestampMs,
2782
+ ]
2783
+
2784
+ sendJson(json, wakeUp: true)
2785
+ }
2786
+
2464
2787
  func sendUserEmailToGlasses(_ email: String) {
2465
2788
  Bridge.log("LIVE: Sending user email to glasses for crash reporting")
2466
2789
 
@@ -2536,13 +2859,20 @@ class MentraLive: NSObject, SGCManager {
2536
2859
 
2537
2860
  func sendGalleryMode() {
2538
2861
  let active = DeviceStore.shared.get("bluetooth", "gallery_mode") as! Bool
2862
+ sendGalleryMode(requestId: nil, active: active)
2863
+ }
2864
+
2865
+ func sendGalleryMode(requestId: String?, active: Bool) {
2539
2866
  Bridge.log("LIVE: 📸 Sending gallery mode active to glasses: \(active)")
2540
2867
 
2541
- let json: [String: Any] = [
2868
+ var json: [String: Any] = [
2542
2869
  "type": "save_in_gallery_mode",
2543
2870
  "active": active,
2544
2871
  "timestamp": Int(Date().timeIntervalSince1970 * 1000),
2545
2872
  ]
2873
+ if let requestId, !requestId.isEmpty {
2874
+ json["request_id"] = requestId
2875
+ }
2546
2876
 
2547
2877
  sendJson(json, wakeUp: true)
2548
2878
  }
@@ -2572,6 +2902,17 @@ class MentraLive: NSObject, SGCManager {
2572
2902
  sendJson(json, wakeUp: true)
2573
2903
  }
2574
2904
 
2905
+ func sendOtaRetryVersionCheck() {
2906
+ Bridge.log("LIVE: ⏰ Sending ota_retry_version_check command to glasses")
2907
+
2908
+ let json: [String: Any] = [
2909
+ "type": "ota_retry_version_check",
2910
+ "timestamp": Int(Date().timeIntervalSince1970 * 1000),
2911
+ ]
2912
+
2913
+ sendJson(json, wakeUp: true)
2914
+ }
2915
+
2575
2916
  func keepAwake() {
2576
2917
  Bridge.log("LIVE: 📱 Sending keep_awake command to glasses")
2577
2918
 
@@ -2644,7 +2985,8 @@ class MentraLive: NSObject, SGCManager {
2644
2985
  networks = networksNeoArray
2645
2986
  }
2646
2987
 
2647
- Bridge.updateWifiScanResults(networks)
2988
+ let scanComplete = json["scan_complete"] as? Bool ?? json["scanComplete"] as? Bool ?? false
2989
+ Bridge.updateWifiScanResults(networks, scanComplete: scanComplete)
2648
2990
  }
2649
2991
 
2650
2992
  private func handleButtonPress(_ json: [String: Any]) {
@@ -3226,7 +3568,53 @@ class MentraLive: NSObject, SGCManager {
3226
3568
  BlePhotoUploadService.processAndUploadPhoto(
3227
3569
  imageData: imageData, requestId: transfer.requestId, webhookUrl: transfer.webhookUrl,
3228
3570
  authToken: transfer.authToken
3229
- )
3571
+ ) { [weak self] requestId, responseBody in
3572
+ self?.sendPhotoTerminalSuccessResponse(
3573
+ requestId: requestId,
3574
+ uploadUrl: transfer.webhookUrl,
3575
+ responseBody: responseBody
3576
+ )
3577
+ } onError: { requestId, error in
3578
+ Bridge.sendPhotoError(
3579
+ requestId: requestId,
3580
+ errorCode: "PHONE_UPLOAD_FAILED",
3581
+ errorMessage: "BLE photo upload failed: \(error)"
3582
+ )
3583
+ }
3584
+ }
3585
+
3586
+ private func sendPhotoTerminalSuccessResponse(
3587
+ requestId: String,
3588
+ uploadUrl: String,
3589
+ responseBody: String
3590
+ ) {
3591
+ var event: [String: Any] = [
3592
+ "type": "photo_response",
3593
+ "state": "success",
3594
+ "success": true,
3595
+ "requestId": requestId,
3596
+ "uploadUrl": uploadUrl,
3597
+ "timestamp": Int(Date().timeIntervalSince1970 * 1000),
3598
+ ]
3599
+ copyPhotoUploadResponseMetadata(into: &event, responseBody: responseBody)
3600
+ Bridge.sendPhotoResponse(event)
3601
+ }
3602
+
3603
+ private func copyPhotoUploadResponseMetadata(
3604
+ into event: inout [String: Any],
3605
+ responseBody: String
3606
+ ) {
3607
+ guard !responseBody.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
3608
+ let data = responseBody.data(using: .utf8),
3609
+ let response = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
3610
+ else {
3611
+ return
3612
+ }
3613
+ for key in ["photoUrl", "statusUrl", "mimeType", "contentType", "bytes", "size"] {
3614
+ if let value = response[key], !(value is NSNull) {
3615
+ event[key] = value
3616
+ }
3617
+ }
3230
3618
  }
3231
3619
 
3232
3620
  private func sendAckToGlasses(messageId: Int) {
@@ -3305,8 +3693,12 @@ class MentraLive: NSObject, SGCManager {
3305
3693
 
3306
3694
  // Create chunks
3307
3695
  let chunks = MessageChunker.createChunks(
3308
- originalJson: jsonString, messageId: messageId
3696
+ originalJson: jsonString, messageId: messageId, wakeUp: wakeUp
3309
3697
  )
3698
+ guard !chunks.isEmpty else {
3699
+ Bridge.log("LIVE: Failed to create BLE chunks within K900 packet limit")
3700
+ return
3701
+ }
3310
3702
  Bridge.log("LIVE: Sending \(chunks.count) chunks")
3311
3703
 
3312
3704
  // Send each chunk
@@ -3661,24 +4053,55 @@ class MentraLive: NSObject, SGCManager {
3661
4053
 
3662
4054
  private func handleGalleryStatus(
3663
4055
  photoCount: Int, videoCount: Int, totalCount: Int,
3664
- totalSize: Int64, hasContent: Bool
4056
+ totalSize: Int64, hasContent: Bool, cameraBusy: Bool,
4057
+ cameraBusyReason: String?
3665
4058
  ) {
3666
4059
  Bridge.log(
3667
4060
  "LIVE: 📸 Received gallery status - photos: \(photoCount), videos: \(videoCount), total size: \(totalSize) bytes"
3668
4061
  )
3669
4062
 
3670
4063
  // Emit gallery status event like other status events
3671
- let eventBody =
4064
+ var eventBody =
3672
4065
  [
4066
+ "type": "gallery_status",
3673
4067
  "photos": photoCount,
3674
4068
  "videos": videoCount,
3675
4069
  "total": totalCount,
3676
4070
  "totalSize": totalSize,
3677
4071
  "hasContent": hasContent,
4072
+ "cameraBusy": cameraBusy,
3678
4073
  ] as [String: Any]
4074
+ if let cameraBusyReason, !cameraBusyReason.isEmpty {
4075
+ eventBody["cameraBusyReason"] = cameraBusyReason
4076
+ }
3679
4077
  Bridge.sendTypedMessage("gallery_status", body: eventBody)
3680
4078
  }
3681
4079
 
4080
+ private static func galleryCameraBusy(_ json: [String: Any]) -> Bool {
4081
+ if let busy = json["cameraBusy"] as? Bool {
4082
+ return busy
4083
+ }
4084
+ if let busy = json["camera_busy"] as? Bool {
4085
+ return busy
4086
+ }
4087
+ if let reason = json["camera_busy"] as? String {
4088
+ let normalized = reason.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
4089
+ return !normalized.isEmpty && normalized != "false"
4090
+ }
4091
+ return false
4092
+ }
4093
+
4094
+ private static func galleryCameraBusyReason(_ json: [String: Any]) -> String? {
4095
+ if let reason = json["cameraBusyReason"] as? String, !reason.isEmpty {
4096
+ return reason
4097
+ }
4098
+ if let reason = json["camera_busy"] as? String {
4099
+ let normalized = reason.trimmingCharacters(in: .whitespacesAndNewlines)
4100
+ return normalized.isEmpty || normalized.lowercased() == "false" ? nil : normalized
4101
+ }
4102
+ return nil
4103
+ }
4104
+
3682
4105
  // MARK: - Timers
3683
4106
 
3684
4107
  private func startHeartbeat() {
@@ -3921,6 +4344,10 @@ class MentraLive: NSObject, SGCManager {
3921
4344
  Bridge.sendPhotoStatus(json)
3922
4345
  }
3923
4346
 
4347
+ private func emitPhotoResponse(_ json: [String: Any]) {
4348
+ Bridge.sendPhotoResponse(json)
4349
+ }
4350
+
3924
4351
  private func emitButtonPress(buttonId: String, pressType: String, timestamp: Int64) {
3925
4352
  let eventBody: [String: Any] = [
3926
4353
  "device_model": "Mentra Live",
@@ -3946,7 +4373,7 @@ class MentraLive: NSObject, SGCManager {
3946
4373
  "bt_mac_address": bluetoothMacAddress,
3947
4374
  ]
3948
4375
 
3949
- Bridge.sendTypedMessage("version_info", body: eventBody)
4376
+ Bridge.sendVersionInfo(eventBody)
3950
4377
  }
3951
4378
 
3952
4379
  private func emitKeepAliveAck(_ json: [String: Any]) {
@@ -3972,6 +4399,7 @@ class MentraLive: NSObject, SGCManager {
3972
4399
 
3973
4400
  // Stop all timers
3974
4401
  stopAllTimers()
4402
+ incomingChunkReassembler.clear()
3975
4403
 
3976
4404
  // Disconnect BLE
3977
4405
  if let peripheral = connectedPeripheral {
@@ -4544,6 +4972,50 @@ extension MentraLive {
4544
4972
  return Int64(Date().timeIntervalSince1970 * 1000)
4545
4973
  }
4546
4974
 
4975
+ private func emitSettingsAck(_ json: [String: Any]) {
4976
+ var body = json
4977
+ if let requestId = body["request_id"], body["requestId"] == nil {
4978
+ body["requestId"] = requestId
4979
+ }
4980
+ if let roiPosition = body["roi_position"], body["roiPosition"] == nil {
4981
+ body["roiPosition"] = roiPosition
4982
+ }
4983
+ if let errorCode = body["error_code"], body["errorCode"] == nil {
4984
+ body["errorCode"] = errorCode
4985
+ }
4986
+ if let errorMessage = body["error_message"], body["errorMessage"] == nil {
4987
+ body["errorMessage"] = errorMessage
4988
+ }
4989
+ if let hardwareApplied = body["hardware_applied"], body["hardwareApplied"] == nil {
4990
+ body["hardwareApplied"] = hardwareApplied
4991
+ }
4992
+ body.removeValue(forKey: "request_id")
4993
+ body.removeValue(forKey: "roi_position")
4994
+ body.removeValue(forKey: "error_code")
4995
+ body.removeValue(forKey: "error_message")
4996
+ body.removeValue(forKey: "hardware_applied")
4997
+ if body["timestamp"] == nil {
4998
+ body["timestamp"] = Int64(Date().timeIntervalSince1970 * 1000)
4999
+ }
5000
+ Bridge.sendSettingsAck(body)
5001
+ }
5002
+
5003
+ private func emitVideoRecordingStatus(_ json: [String: Any]) {
5004
+ var body = json
5005
+ if let requestId = body["request_id"], body["requestId"] == nil {
5006
+ body["requestId"] = requestId
5007
+ }
5008
+ if let details = body["error_details"], body["details"] == nil {
5009
+ body["details"] = details
5010
+ }
5011
+ body.removeValue(forKey: "request_id")
5012
+ body.removeValue(forKey: "error_details")
5013
+ if body["timestamp"] == nil {
5014
+ body["timestamp"] = Int64(Date().timeIntervalSince1970 * 1000)
5015
+ }
5016
+ Bridge.sendVideoRecordingStatus(body)
5017
+ }
5018
+
4547
5019
  /**
4548
5020
  * Check if a JSON string is already properly formatted for K900 protocol
4549
5021
  */
@@ -4624,7 +5096,8 @@ extension MentraLive {
4624
5096
  }
4625
5097
 
4626
5098
  func sendVoiceActivityDetectionSetting() {
4627
- let enabled = DeviceStore.shared.get("bluetooth", "voice_activity_detection_enabled") as? Bool ?? true
5099
+ let enabled = DeviceStore.shared.get("bluetooth", "voice_activity_detection_enabled") as? Bool
5100
+ ?? BluetoothSdkDefaults.voiceActivityDetectionEnabled
4628
5101
  Bridge.log("LIVE: 🎤 Sending Voice Activity Detection setting to glasses: \(enabled)")
4629
5102
 
4630
5103
  guard connectedPeripheral != nil, txCharacteristic != nil else {
@@ -4672,28 +5145,37 @@ extension MentraLive {
4672
5145
  let finalHeight = height > 0 ? height : 720
4673
5146
  let finalFps = fps > 0 ? fps : 30
4674
5147
 
4675
- Bridge.log(
4676
- "Sending button video recording settings: \(finalWidth)x\(finalHeight)@\(finalFps)fps"
4677
- )
5148
+ sendButtonVideoRecordingSettings(requestId: nil, width: finalWidth, height: finalHeight, fps: finalFps)
5149
+ }
5150
+
5151
+ func sendButtonVideoRecordingSettings(requestId: String?, width: Int, height: Int, fps: Int) {
5152
+ Bridge.log("Sending button video recording settings: \(width)x\(height)@\(fps)fps")
4678
5153
 
4679
5154
  guard connectionState == ConnTypes.CONNECTED else {
4680
5155
  Bridge.log("Cannot send button video recording settings - not connected")
4681
5156
  return
4682
5157
  }
4683
5158
 
4684
- let json: [String: Any] = [
5159
+ var json: [String: Any] = [
4685
5160
  "type": "button_video_recording_setting",
4686
5161
  "params": [
4687
- "width": finalWidth,
4688
- "height": finalHeight,
4689
- "fps": finalFps,
5162
+ "width": width,
5163
+ "height": height,
5164
+ "fps": fps,
4690
5165
  ],
4691
5166
  ]
5167
+ if let requestId, !requestId.isEmpty {
5168
+ json["request_id"] = requestId
5169
+ }
4692
5170
  sendJson(json, wakeUp: true)
4693
5171
  }
4694
5172
 
4695
5173
  func sendButtonMaxRecordingTime() {
4696
5174
  let maxTime = DeviceStore.shared.get("bluetooth", "button_max_recording_time") as? Int ?? 10
5175
+ sendButtonMaxRecordingTime(requestId: nil, minutes: maxTime)
5176
+ }
5177
+
5178
+ func sendButtonMaxRecordingTime(requestId: String?, minutes maxTime: Int) {
4697
5179
  Bridge.log("Sending button max recording time: \(maxTime) minutes")
4698
5180
 
4699
5181
  guard connectionState == ConnTypes.CONNECTED else {
@@ -4701,16 +5183,22 @@ extension MentraLive {
4701
5183
  return
4702
5184
  }
4703
5185
 
4704
- let json: [String: Any] = [
5186
+ var json: [String: Any] = [
4705
5187
  "type": "button_max_recording_time",
4706
5188
  "minutes": maxTime,
4707
5189
  ]
5190
+ if let requestId, !requestId.isEmpty {
5191
+ json["request_id"] = requestId
5192
+ }
4708
5193
  sendJson(json, wakeUp: true)
4709
5194
  }
4710
5195
 
4711
5196
  func sendButtonPhotoSettings() {
4712
5197
  let size = DeviceStore.shared.get("bluetooth", "button_photo_size") as! String
5198
+ sendButtonPhotoSettings(requestId: nil, size: size)
5199
+ }
4713
5200
 
5201
+ func sendButtonPhotoSettings(requestId: String?, size: String) {
4714
5202
  Bridge.log("Sending button photo setting: \(size)")
4715
5203
 
4716
5204
  guard connectionState == ConnTypes.CONNECTED else {
@@ -4718,16 +5206,22 @@ extension MentraLive {
4718
5206
  return
4719
5207
  }
4720
5208
 
4721
- let json: [String: Any] = [
5209
+ var json: [String: Any] = [
4722
5210
  "type": "button_photo_setting",
4723
5211
  "size": size,
4724
5212
  ]
5213
+ if let requestId, !requestId.isEmpty {
5214
+ json["request_id"] = requestId
5215
+ }
4725
5216
  sendJson(json, wakeUp: true)
4726
5217
  }
4727
5218
 
4728
5219
  func sendButtonCameraLedSetting() {
4729
5220
  let enabled = DeviceStore.shared.get("bluetooth", "button_camera_led") as! Bool
5221
+ sendButtonCameraLedSetting(requestId: nil, enabled: enabled)
5222
+ }
4730
5223
 
5224
+ func sendButtonCameraLedSetting(requestId: String?, enabled: Bool) {
4731
5225
  Bridge.log("Sending button camera LED setting: \(enabled)")
4732
5226
 
4733
5227
  guard connectionState == ConnTypes.CONNECTED else {
@@ -4735,10 +5229,13 @@ extension MentraLive {
4735
5229
  return
4736
5230
  }
4737
5231
 
4738
- let json: [String: Any] = [
5232
+ var json: [String: Any] = [
4739
5233
  "type": "button_camera_led",
4740
5234
  "enabled": enabled,
4741
5235
  ]
5236
+ if let requestId, !requestId.isEmpty {
5237
+ json["request_id"] = requestId
5238
+ }
4742
5239
  sendJson(json, wakeUp: true)
4743
5240
  }
4744
5241
 
@@ -4746,7 +5243,10 @@ extension MentraLive {
4746
5243
  let settings = DeviceStore.shared.get("bluetooth", "camera_fov") as? [String: Any] ?? ["fov": 118, "roi_position": 0]
4747
5244
  let fov = settings["fov"] as? Int ?? 118
4748
5245
  let roiPosition = settings["roi_position"] as? Int ?? 0
5246
+ sendCameraFovSetting(requestId: nil, fov: fov, roiPosition: roiPosition)
5247
+ }
4749
5248
 
5249
+ func sendCameraFovSetting(requestId: String?, fov: Int, roiPosition: Int) {
4750
5250
  Bridge.log("Sending camera FOV setting: fov=\(fov), roiPosition=\(roiPosition)")
4751
5251
 
4752
5252
  guard connectionState == ConnTypes.CONNECTED else {
@@ -4754,13 +5254,16 @@ extension MentraLive {
4754
5254
  return
4755
5255
  }
4756
5256
 
4757
- let json: [String: Any] = [
5257
+ var json: [String: Any] = [
4758
5258
  "type": "camera_fov_setting",
4759
5259
  "params": [
4760
5260
  "fov": fov,
4761
5261
  "roi_position": roiPosition,
4762
5262
  ],
4763
5263
  ]
5264
+ if let requestId, !requestId.isEmpty {
5265
+ json["request_id"] = requestId
5266
+ }
4764
5267
  sendJson(json, wakeUp: true)
4765
5268
  }
4766
5269
 
@@ -4773,20 +5276,7 @@ extension MentraLive {
4773
5276
  // MARK: - SGCManager Protocol Compliance
4774
5277
 
4775
5278
  func sendButtonMaxRecordingTime(_ minutes: Int) {
4776
- let maxTime = minutes
4777
-
4778
- Bridge.log("Sending button max recording time: \(maxTime) minutes")
4779
-
4780
- guard connectionState == ConnTypes.CONNECTED else {
4781
- Bridge.log("Cannot send button max recording time - not connected")
4782
- return
4783
- }
4784
-
4785
- let json: [String: Any] = [
4786
- "type": "button_max_recording_time",
4787
- "minutes": maxTime,
4788
- ]
4789
- sendJson(json, wakeUp: true)
5279
+ sendButtonMaxRecordingTime(requestId: nil, minutes: minutes)
4790
5280
  }
4791
5281
 
4792
5282
  func startVideoRecording(
@@ -4809,13 +5299,16 @@ extension MentraLive {
4809
5299
  "sound": sound,
4810
5300
  ]
4811
5301
 
4812
- // Add video settings if provided
4813
- if width > 0, height > 0 {
4814
- json["settings"] = [
4815
- "width": width,
4816
- "height": height,
4817
- "fps": fps > 0 ? fps : 30,
4818
- ]
5302
+ // Add video settings when any field is overridden. Each field is sent
5303
+ // only when > 0; the glasses merge the missing fields onto their saved
5304
+ // button-video defaults, so a partial override (e.g. fps-only) still
5305
+ // takes effect instead of being dropped here.
5306
+ if width > 0 || height > 0 || fps > 0 {
5307
+ var settings: [String: Any] = [:]
5308
+ if width > 0 { settings["width"] = width }
5309
+ if height > 0 { settings["height"] = height }
5310
+ if fps > 0 { settings["fps"] = fps }
5311
+ json["settings"] = settings
4819
5312
  }
4820
5313
  sendJson(json)
4821
5314
  }