@mentra/bluetooth-sdk 0.1.20 → 0.1.21-beta.1

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 (114) hide show
  1. package/README.md +27 -12
  2. package/android/build.gradle +3 -0
  3. package/android/src/main/AndroidManifest.xml +3 -1
  4. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalytics.kt +34 -0
  5. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +1 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +169 -26
  7. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +72 -16
  8. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +127 -8
  9. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +4 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +400 -99
  11. package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +333 -0
  12. package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +43 -3
  13. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +18 -2
  14. package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleTraceLogger.kt +1 -1
  15. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +53 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Ar99.java +2653 -0
  17. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +1 -1
  18. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +16 -9
  19. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Mach1.kt +1 -1
  20. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +342 -42
  21. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +1 -1
  22. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +1 -1
  23. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +7 -2
  24. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +1 -1
  25. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/Ar99OtaManager.java +566 -0
  26. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaByteUtils.java +18 -0
  27. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaCommandConstants.java +35 -0
  28. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaCrc32Util.java +13 -0
  29. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaGattTransport.java +11 -0
  30. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaProtocol.java +92 -0
  31. package/android/src/main/java/com/mentra/bluetoothsdk/status/WifiHotspotStatus.kt +19 -3
  32. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +52 -2
  33. package/android/src/main/java/com/mentra/bluetoothsdk/tts/TTSTools.kt +115 -47
  34. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +7 -0
  35. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +8 -0
  36. package/android/src/main/java/com/mentra/bluetoothsdk/utils/Constants.kt +2 -1
  37. package/android/src/main/java/com/mentra/bluetoothsdk/utils/PhoneAudioMonitor.kt +45 -0
  38. package/android/src/main/java/com/mentra/bluetoothsdk/utils/audio/Ar99OpusPcmDecoder.java +398 -0
  39. package/android/src/main/java/com/mentra/bluetoothsdk/utils/audio/PcmStreamPlayer.kt +328 -0
  40. package/android/src/test/java/com/mentra/bluetoothsdk/OtaManifestDowngradeTest.kt +86 -0
  41. package/android/src/test/java/com/mentra/bluetoothsdk/StreamStatusEventTest.kt +29 -0
  42. package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +45 -0
  43. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/Ar99AdvertisementMatcherTest.java +22 -0
  44. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +20 -0
  45. package/android/src/test/java/com/mentra/bluetoothsdk/utils/PhoneAudioMonitorTest.kt +45 -0
  46. package/build/BluetoothSdk.types.d.ts +100 -22
  47. package/build/BluetoothSdk.types.d.ts.map +1 -1
  48. package/build/BluetoothSdk.types.js +2 -1
  49. package/build/BluetoothSdk.types.js.map +1 -1
  50. package/build/_internal.d.ts +3 -0
  51. package/build/_internal.d.ts.map +1 -1
  52. package/build/_internal.js +3 -0
  53. package/build/_internal.js.map +1 -1
  54. package/build/_private/BluetoothSdkModule.d.ts +31 -4
  55. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  56. package/build/_private/BluetoothSdkModule.js +26 -3
  57. package/build/_private/BluetoothSdkModule.js.map +1 -1
  58. package/build/_private/MentraLocalNetworkModule.d.ts +37 -0
  59. package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -0
  60. package/build/_private/MentraLocalNetworkModule.js +3 -0
  61. package/build/_private/MentraLocalNetworkModule.js.map +1 -0
  62. package/build/_private/cameraRequestPayload.d.ts +1 -1
  63. package/build/_private/cameraRequestPayload.d.ts.map +1 -1
  64. package/build/_private/cameraRequestPayload.js +7 -0
  65. package/build/_private/cameraRequestPayload.js.map +1 -1
  66. package/build/_private/photoRequestPayload.d.ts.map +1 -1
  67. package/build/_private/photoRequestPayload.js +10 -0
  68. package/build/_private/photoRequestPayload.js.map +1 -1
  69. package/build/_private/sdkOtaManifest.d.ts +20 -0
  70. package/build/_private/sdkOtaManifest.d.ts.map +1 -0
  71. package/build/_private/sdkOtaManifest.js +28 -0
  72. package/build/_private/sdkOtaManifest.js.map +1 -0
  73. package/build/index.d.ts +1 -1
  74. package/build/index.d.ts.map +1 -1
  75. package/build/index.js +11 -1
  76. package/build/index.js.map +1 -1
  77. package/expo-module.config.json +1 -0
  78. package/ios/BluetoothSdkModule.swift +113 -2
  79. package/ios/Source/BluetoothSdkDefaults.swift +2 -1
  80. package/ios/Source/Bridge.swift +87 -21
  81. package/ios/Source/DeviceManager.swift +118 -20
  82. package/ios/Source/DeviceStore.swift +4 -0
  83. package/ios/Source/MentraBluetoothSDK.swift +326 -33
  84. package/ios/Source/OtaManifest.swift +47 -4
  85. package/ios/Source/audio/PcmStreamPlayer.swift +410 -0
  86. package/ios/Source/camera/CameraModels.swift +26 -6
  87. package/ios/Source/internal/BleTraceLogger.swift +1 -1
  88. package/ios/Source/internal/BluetoothSdkAnalytics.swift +39 -0
  89. package/ios/Source/sgcs/Ar99.swift +2451 -0
  90. package/ios/Source/sgcs/G1.swift +1 -1
  91. package/ios/Source/sgcs/G2.swift +19 -7
  92. package/ios/Source/sgcs/Mach1.swift +1 -1
  93. package/ios/Source/sgcs/MentraLive.swift +478 -61
  94. package/ios/Source/sgcs/MentraNex.swift +1 -1
  95. package/ios/Source/sgcs/Nimo.swift +1 -1
  96. package/ios/Source/sgcs/SGCManager.swift +13 -2
  97. package/ios/Source/sgcs/Simulated.swift +1 -1
  98. package/ios/Source/sgcs/ar99/ota/Ar99OtaManager.swift +637 -0
  99. package/ios/Source/status/WifiHotspotStatus.swift +21 -2
  100. package/ios/Source/streaming/StreamModels.swift +68 -2
  101. package/ios/Source/tts/TTSTools.swift +1 -1
  102. package/ios/Source/types/DeviceModels.swift +13 -0
  103. package/ios/Source/utils/BleJsonCompact.swift +8 -0
  104. package/ios/Source/utils/Constants.swift +2 -0
  105. package/ios/Source/utils/PhoneAudioMonitor.swift +4 -0
  106. package/package.json +1 -2
  107. package/src/BluetoothSdk.types.ts +106 -24
  108. package/src/_internal.ts +3 -0
  109. package/src/_private/BluetoothSdkModule.ts +83 -18
  110. package/src/_private/MentraLocalNetworkModule.ts +50 -0
  111. package/src/_private/cameraRequestPayload.ts +9 -2
  112. package/src/_private/photoRequestPayload.ts +13 -4
  113. package/src/_private/sdkOtaManifest.ts +31 -0
  114. package/src/index.ts +13 -1
@@ -0,0 +1,410 @@
1
+ import AVFoundation
2
+ import Foundation
3
+
4
+ private enum PcmStreamError: LocalizedError {
5
+ case invalidArgument(String)
6
+ case closed
7
+ case native(String)
8
+
9
+ var errorDescription: String? {
10
+ switch self {
11
+ case let .invalidArgument(message), let .native(message):
12
+ return message
13
+ case .closed:
14
+ return "PCM stream is closed"
15
+ }
16
+ }
17
+ }
18
+
19
+ /// Low-latency, bounded live PCM playback for `speaker.createStream()`.
20
+ ///
21
+ /// Input is signed 16-bit little-endian mono PCM. Chunks are converted to the
22
+ /// float format AVAudioEngine renders natively, scheduled on an
23
+ /// AVAudioPlayerNode, and accounted when they reach the output device. The
24
+ /// serial state queue keeps writes, completion callbacks, interruption
25
+ /// recovery, close, and abort deterministic without blocking Expo's module
26
+ /// queue.
27
+ final class PcmStreamPlayer: @unchecked Sendable {
28
+ private static let backpressureCeilingMs: Int64 = 2000
29
+ private static let maximumBacklogMs: Int64 = 10000
30
+
31
+ private let streamId: String
32
+ private let sampleRate: Int
33
+ private let stateQueue: DispatchQueue
34
+ private let engine = AVAudioEngine()
35
+ private let player = AVAudioPlayerNode()
36
+ private let format: AVAudioFormat
37
+
38
+ private var queuedFrames: Int64 = 0
39
+ private var playedFrames: Int64 = 0
40
+ private var closing = false
41
+ private var aborted = false
42
+ private var finished = false
43
+ private var terminalError: Error?
44
+ private var writeWaiters: [CheckedContinuation<Int64, Error>] = []
45
+ private var closeWaiters: [CheckedContinuation<Int64, Error>] = []
46
+ private var observers: [NSObjectProtocol] = []
47
+
48
+ init(streamId: String, sampleRate: Int, channels: Int, volume: Float) throws {
49
+ guard !streamId.isEmpty else {
50
+ throw PcmStreamError.invalidArgument("streamId is required")
51
+ }
52
+ guard [16000, 24000, 48000].contains(sampleRate) else {
53
+ throw PcmStreamError.invalidArgument("unsupported PCM sample rate \(sampleRate)")
54
+ }
55
+ guard channels == 1 else {
56
+ throw PcmStreamError.invalidArgument("only mono PCM is supported")
57
+ }
58
+ guard let format = AVAudioFormat(
59
+ commonFormat: .pcmFormatFloat32,
60
+ sampleRate: Double(sampleRate),
61
+ channels: 1,
62
+ interleaved: false
63
+ ) else {
64
+ throw PcmStreamError.native("failed to create PCM playback format")
65
+ }
66
+
67
+ self.streamId = streamId
68
+ self.sampleRate = sampleRate
69
+ self.format = format
70
+ stateQueue = DispatchQueue(label: "com.mentra.pcm-stream.\(streamId)")
71
+
72
+ engine.attach(player)
73
+ engine.connect(player, to: engine.mainMixerNode, format: format)
74
+ player.volume = min(max(volume, 0), 1)
75
+ engine.prepare()
76
+ try engine.start()
77
+ player.play()
78
+ installAudioSessionObservers()
79
+ }
80
+
81
+ deinit {
82
+ observers.forEach(NotificationCenter.default.removeObserver)
83
+ }
84
+
85
+ func write(base64: String) async throws -> Int64 {
86
+ guard let data = Data(base64Encoded: base64), !data.isEmpty else {
87
+ throw PcmStreamError.invalidArgument("PCM chunk is empty or invalid base64")
88
+ }
89
+ guard data.count.isMultiple(of: 2) else {
90
+ throw PcmStreamError.invalidArgument(
91
+ "PCM chunk length \(data.count) is not aligned to a 2-byte frame"
92
+ )
93
+ }
94
+
95
+ return try await withCheckedThrowingContinuation { continuation in
96
+ stateQueue.async { [self] in
97
+ do {
98
+ if let terminalError { throw terminalError }
99
+ guard !closing && !aborted else { throw PcmStreamError.closed }
100
+
101
+ let frameCount = data.count / 2
102
+ let nextBacklog = bufferedMs(frames: queuedFrames + Int64(frameCount))
103
+ guard nextBacklog <= Self.maximumBacklogMs else {
104
+ throw PcmStreamError.invalidArgument(
105
+ "PCM backlog exceeds \(Self.maximumBacklogMs)ms; producer is not throttling"
106
+ )
107
+ }
108
+
109
+ let buffer = try makeBuffer(data: data, frameCount: frameCount)
110
+ queuedFrames += Int64(frameCount)
111
+ player.scheduleBuffer(
112
+ buffer,
113
+ completionCallbackType: .dataPlayedBack
114
+ ) { [weak self] _ in
115
+ self?.stateQueue.async {
116
+ self?.didPlay(frameCount: frameCount)
117
+ }
118
+ }
119
+ resumePlaybackIfNeeded()
120
+ if let terminalError { throw terminalError }
121
+
122
+ let current = bufferedMs(frames: queuedFrames)
123
+ if current <= Self.backpressureCeilingMs {
124
+ continuation.resume(returning: current)
125
+ } else {
126
+ writeWaiters.append(continuation)
127
+ }
128
+ } catch {
129
+ continuation.resume(throwing: error)
130
+ }
131
+ }
132
+ }
133
+ }
134
+
135
+ func close() async throws -> Int64 {
136
+ try await withCheckedThrowingContinuation { continuation in
137
+ stateQueue.async { [self] in
138
+ if let terminalError {
139
+ continuation.resume(throwing: terminalError)
140
+ return
141
+ }
142
+ if aborted {
143
+ continuation.resume(returning: durationMs)
144
+ return
145
+ }
146
+ closing = true
147
+ if queuedFrames == 0 {
148
+ finishClose(extra: continuation)
149
+ } else {
150
+ closeWaiters.append(continuation)
151
+ }
152
+ }
153
+ }
154
+ }
155
+
156
+ func abort() async {
157
+ await withCheckedContinuation { continuation in
158
+ stateQueue.async { [self] in
159
+ if !aborted {
160
+ aborted = true
161
+ finished = true
162
+ player.stop()
163
+ engine.stop()
164
+ queuedFrames = 0
165
+ let error = PcmStreamError.closed
166
+ writeWaiters.forEach { $0.resume(throwing: error) }
167
+ writeWaiters.removeAll()
168
+ closeWaiters.forEach { $0.resume(returning: durationMs) }
169
+ closeWaiters.removeAll()
170
+ removeAudioSessionObservers()
171
+ }
172
+ continuation.resume()
173
+ }
174
+ }
175
+ }
176
+
177
+ private var durationMs: Int64 {
178
+ bufferedMs(frames: playedFrames)
179
+ }
180
+
181
+ private func bufferedMs(frames: Int64) -> Int64 {
182
+ max(0, frames) * 1000 / Int64(sampleRate)
183
+ }
184
+
185
+ private func makeBuffer(data: Data, frameCount: Int) throws -> AVAudioPCMBuffer {
186
+ guard let buffer = AVAudioPCMBuffer(
187
+ pcmFormat: format,
188
+ frameCapacity: AVAudioFrameCount(frameCount)
189
+ ), let channel = buffer.floatChannelData?[0] else {
190
+ throw PcmStreamError.native("failed to allocate PCM audio buffer")
191
+ }
192
+
193
+ let bytes = [UInt8](data)
194
+ for index in 0 ..< frameCount {
195
+ let bits = UInt16(bytes[index * 2]) | (UInt16(bytes[index * 2 + 1]) << 8)
196
+ channel[index] = Float(Int16(bitPattern: bits)) / 32768.0
197
+ }
198
+ buffer.frameLength = AVAudioFrameCount(frameCount)
199
+ return buffer
200
+ }
201
+
202
+ private func didPlay(frameCount: Int) {
203
+ guard !aborted, terminalError == nil else { return }
204
+ queuedFrames = max(0, queuedFrames - Int64(frameCount))
205
+ playedFrames += Int64(frameCount)
206
+
207
+ if bufferedMs(frames: queuedFrames) <= Self.backpressureCeilingMs {
208
+ let current = bufferedMs(frames: queuedFrames)
209
+ writeWaiters.forEach { $0.resume(returning: current) }
210
+ writeWaiters.removeAll()
211
+ }
212
+ if closing && queuedFrames == 0 {
213
+ finishClose()
214
+ }
215
+ }
216
+
217
+ private func finishClose(extra: CheckedContinuation<Int64, Error>? = nil) {
218
+ guard !finished else {
219
+ extra?.resume(returning: durationMs)
220
+ return
221
+ }
222
+ finished = true
223
+ player.stop()
224
+ engine.stop()
225
+ removeAudioSessionObservers()
226
+ let result = durationMs
227
+ writeWaiters.forEach { $0.resume(throwing: PcmStreamError.closed) }
228
+ writeWaiters.removeAll()
229
+ closeWaiters.forEach { $0.resume(returning: result) }
230
+ closeWaiters.removeAll()
231
+ extra?.resume(returning: result)
232
+ }
233
+
234
+ private func resumePlaybackIfNeeded() {
235
+ guard !finished, terminalError == nil else { return }
236
+ do {
237
+ if !engine.isRunning {
238
+ try engine.start()
239
+ }
240
+ if !player.isPlaying {
241
+ player.play()
242
+ }
243
+ } catch {
244
+ fail(PcmStreamError.native("PCM playback restart failed: \(error.localizedDescription)"))
245
+ }
246
+ }
247
+
248
+ private func fail(_ error: Error) {
249
+ guard terminalError == nil && !aborted else { return }
250
+ terminalError = error
251
+ finished = true
252
+ player.stop()
253
+ engine.stop()
254
+ queuedFrames = 0
255
+ writeWaiters.forEach { $0.resume(throwing: error) }
256
+ writeWaiters.removeAll()
257
+ closeWaiters.forEach { $0.resume(throwing: error) }
258
+ closeWaiters.removeAll()
259
+ removeAudioSessionObservers()
260
+ }
261
+
262
+ private func installAudioSessionObservers() {
263
+ let center = NotificationCenter.default
264
+ observers.append(
265
+ center.addObserver(
266
+ forName: AVAudioSession.interruptionNotification,
267
+ object: nil,
268
+ queue: nil
269
+ ) { [weak self] notification in
270
+ self?.stateQueue.async {
271
+ self?.handleInterruption(notification)
272
+ }
273
+ }
274
+ )
275
+ observers.append(
276
+ center.addObserver(
277
+ forName: AVAudioSession.routeChangeNotification,
278
+ object: nil,
279
+ queue: nil
280
+ ) { [weak self] _ in
281
+ self?.stateQueue.asyncAfter(deadline: .now() + 0.1) {
282
+ self?.resumePlaybackIfNeeded()
283
+ }
284
+ }
285
+ )
286
+ observers.append(
287
+ center.addObserver(
288
+ forName: AVAudioSession.mediaServicesWereResetNotification,
289
+ object: nil,
290
+ queue: nil
291
+ ) { [weak self] _ in
292
+ self?.stateQueue.async {
293
+ self?.fail(PcmStreamError.native("iOS audio services reset; reopen the PCM stream"))
294
+ }
295
+ }
296
+ )
297
+ }
298
+
299
+ private func removeAudioSessionObservers() {
300
+ observers.forEach(NotificationCenter.default.removeObserver)
301
+ observers.removeAll()
302
+ }
303
+
304
+ private func handleInterruption(_ notification: Notification) {
305
+ guard
306
+ let raw = notification.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt,
307
+ let type = AVAudioSession.InterruptionType(rawValue: raw)
308
+ else { return }
309
+
310
+ switch type {
311
+ case .began:
312
+ player.pause()
313
+ case .ended:
314
+ // A close may be waiting for queued buffers to drain. Resume that
315
+ // drain too; otherwise an interruption that begins just before
316
+ // close leaves its continuation suspended indefinitely.
317
+ if !aborted, !finished {
318
+ resumePlaybackIfNeeded()
319
+ }
320
+ @unknown default:
321
+ break
322
+ }
323
+ }
324
+ }
325
+
326
+ /// Process-wide registry matching the Android PCM stream bridge contract.
327
+ enum PcmStreamManager {
328
+ private static let lock = NSLock()
329
+ private static var players: [String: PcmStreamPlayer] = [:]
330
+
331
+ static func open(
332
+ streamId: String,
333
+ sampleRate: Int,
334
+ channels: Int,
335
+ volume: Float
336
+ ) throws {
337
+ let player = try PcmStreamPlayer(
338
+ streamId: streamId,
339
+ sampleRate: sampleRate,
340
+ channels: channels,
341
+ volume: volume
342
+ )
343
+ lock.lock()
344
+ let previous = players.updateValue(player, forKey: streamId)
345
+ lock.unlock()
346
+ if let previous {
347
+ Task { await previous.abort() }
348
+ }
349
+ }
350
+
351
+ static func write(streamId: String, base64: String) async throws -> Int64 {
352
+ let player = try lookup(streamId: streamId)
353
+ return try await player.write(base64: base64)
354
+ }
355
+
356
+ static func close(streamId: String) async throws -> Int64 {
357
+ let player = try lookup(streamId: streamId)
358
+ do {
359
+ let duration = try await player.close()
360
+ removeIfCurrent(streamId: streamId, player: player)
361
+ return duration
362
+ } catch {
363
+ removeIfCurrent(streamId: streamId, player: player)
364
+ throw error
365
+ }
366
+ }
367
+
368
+ static func abort(streamId: String) async {
369
+ guard let player = optionalRemove(streamId: streamId) else { return }
370
+ await player.abort()
371
+ }
372
+
373
+ static func abortAll() async {
374
+ let active = takeAll()
375
+ for player in active {
376
+ await player.abort()
377
+ }
378
+ }
379
+
380
+ private static func lookup(streamId: String) throws -> PcmStreamPlayer {
381
+ lock.lock()
382
+ defer { lock.unlock() }
383
+ guard let player = players[streamId] else {
384
+ throw PcmStreamError.closed
385
+ }
386
+ return player
387
+ }
388
+
389
+ private static func optionalRemove(streamId: String) -> PcmStreamPlayer? {
390
+ lock.lock()
391
+ defer { lock.unlock() }
392
+ return players.removeValue(forKey: streamId)
393
+ }
394
+
395
+ private static func removeIfCurrent(streamId: String, player: PcmStreamPlayer) {
396
+ lock.lock()
397
+ defer { lock.unlock() }
398
+ if players[streamId] === player {
399
+ players.removeValue(forKey: streamId)
400
+ }
401
+ }
402
+
403
+ private static func takeAll() -> [PcmStreamPlayer] {
404
+ lock.lock()
405
+ defer { lock.unlock() }
406
+ let active = Array(players.values)
407
+ players.removeAll()
408
+ return active
409
+ }
410
+ }
@@ -169,15 +169,16 @@ public enum CameraRoiPosition: Int {
169
169
  public struct CameraFov {
170
170
  public static let minFov = 62
171
171
  public static let maxFov = 118
172
- public static let defaultFov = 102
172
+ public static let defaultFov = 118
173
173
  public static let narrowFov = 82
174
+ public static let standardFov = 102
174
175
  public static let defaultRoiPosition = CameraRoiPosition.center
175
176
  public static let narrow = CameraFov(
176
177
  fov: CameraFov.narrowFov,
177
178
  roiPosition: CameraFov.defaultRoiPosition
178
179
  )
179
180
  public static let standard = CameraFov(
180
- fov: CameraFov.defaultFov,
181
+ fov: CameraFov.standardFov,
181
182
  roiPosition: CameraFov.defaultRoiPosition
182
183
  )
183
184
  public static let wide = CameraFov(
@@ -244,6 +245,7 @@ public struct PhotoRequest {
244
245
  public let requestId: String
245
246
  public let size: PhotoSize
246
247
  public let mode: PhotoMode
248
+ public let transferMethod: String
247
249
  public let webhookUrl: String?
248
250
  public let authToken: String?
249
251
  public let compress: PhotoCompression?
@@ -280,7 +282,8 @@ public struct PhotoRequest {
280
282
  zsl: Bool? = nil,
281
283
  ispDigitalGain: Int? = nil,
282
284
  ispAnalogGain: String? = nil,
283
- mode: PhotoMode = .photo
285
+ mode: PhotoMode = .photo,
286
+ transferMethod: String = "auto"
284
287
  ) {
285
288
  self.requestId = nonBlankRequestId(requestId) ?? generatedCameraRequestId("photo")
286
289
  self.size = size
@@ -300,11 +303,26 @@ public struct PhotoRequest {
300
303
  self.ispDigitalGain = ispDigitalGain
301
304
  self.ispAnalogGain = ispAnalogGain
302
305
  self.mode = mode
306
+ self.transferMethod = transferMethod
303
307
  }
304
308
 
305
- public static func from(params: [String: Any]) -> PhotoRequest {
309
+ public static func from(params: [String: Any]) throws -> PhotoRequest {
306
310
  let sizeRaw = params["size"] as? String ?? "medium"
307
311
  let compressRaw = params["compress"] as? String ?? "none"
312
+ let transferMethod: String
313
+ if let rawValue = params["transferMethod"] {
314
+ guard let rawString = rawValue as? String,
315
+ rawString == "auto" || rawString == "direct" || rawString == "ble"
316
+ else {
317
+ throw BluetoothSdkError(
318
+ code: "invalid_photo_transfer_method",
319
+ message: "Invalid transferMethod \(String(describing: rawValue)). Expected auto, direct, or ble."
320
+ )
321
+ }
322
+ transferMethod = rawString
323
+ } else {
324
+ transferMethod = "auto"
325
+ }
308
326
  let exposureTimeNs: Double?
309
327
  switch params["exposureTimeNs"] {
310
328
  case let value as Double:
@@ -367,7 +385,8 @@ public struct PhotoRequest {
367
385
  zsl: optionalBool("zsl"),
368
386
  ispDigitalGain: optionalInt("ispDigitalGain"),
369
387
  ispAnalogGain: params["ispAnalogGain"] as? String,
370
- mode: PhotoMode(normalizedRawValue: params["mode"] as? String)
388
+ mode: PhotoMode(normalizedRawValue: params["mode"] as? String),
389
+ transferMethod: transferMethod
371
390
  )
372
391
  }
373
392
 
@@ -417,7 +436,8 @@ public struct PhotoRequest {
417
436
  zsl: zsl,
418
437
  ispDigitalGain: ispDigitalGain,
419
438
  ispAnalogGain: ispAnalogGain,
420
- mode: mode
439
+ mode: mode,
440
+ transferMethod: transferMethod
421
441
  )
422
442
  }
423
443
  }
@@ -6,7 +6,7 @@ enum BleTraceLogger {
6
6
  private static let log = OSLog(subsystem: "com.mentra.bluetoothsdk", category: "MentraBleTrace")
7
7
  private static let maxPayloadChars = 3000
8
8
  private static let k900Type = "k900"
9
- private static let sensitiveKeyParts = ["password", "pass", "token", "secret", "authorization", "auth", "email"]
9
+ private static let sensitiveKeyParts = ["password", "pass", "token", "secret", "authorization", "auth", "email", "serial"]
10
10
 
11
11
  static func logJson(
12
12
  direction: String,
@@ -39,6 +39,7 @@ final class BluetoothSdkAnalytics {
39
39
  private let configuration: BluetoothSdkAnalyticsConfiguration
40
40
  private var startedCaptured = false
41
41
  private var lastConnected = false
42
+ private var identifiedCapturedForConnection = false
42
43
 
43
44
  init(configuration: BluetoothSdkAnalyticsConfiguration) {
44
45
  self.configuration = configuration.resolvedForApp()
@@ -47,6 +48,12 @@ final class BluetoothSdkAnalytics {
47
48
  func initializeGlassesStatus(_ status: GlassesStatus) {
48
49
  stateQueue.sync {
49
50
  lastConnected = status.analyticsConnected
51
+ // Only treat identification as already captured when a valid serial is
52
+ // present at init. If the glasses are connected but the serial has not
53
+ // arrived yet (Mentra Live fills it via version_info after connect), leave
54
+ // this false so the identify event still fires once the serial arrives.
55
+ identifiedCapturedForConnection =
56
+ status.analyticsConnected && status.serialNumber.validManufacturingSerial != nil
50
57
  }
51
58
  }
52
59
 
@@ -72,7 +79,12 @@ final class BluetoothSdkAnalytics {
72
79
  let wasConnected = lastConnected
73
80
  lastConnected = isConnected
74
81
  guard configuration.isReady else { return }
82
+ guard isConnected else {
83
+ identifiedCapturedForConnection = false
84
+ return
85
+ }
75
86
  if isConnected, !wasConnected {
87
+ identifiedCapturedForConnection = false
76
88
  var properties: [String: Any] = [
77
89
  "event_kind": "glasses_connected",
78
90
  "fully_booted": status.fullyBooted,
@@ -81,7 +93,25 @@ final class BluetoothSdkAnalytics {
81
93
  properties["glasses_model"] = status.deviceModel
82
94
  }
83
95
  capture(event: "bluetooth_sdk_glasses_connected", properties: properties, configuration: configuration)
96
+ // Fall through: a serial already present at connect time (G1/Ar99
97
+ // report it in the advertisement) should be identified now rather than
98
+ // waiting for some later, unrelated glasses-store update to run.
99
+ }
100
+
101
+ guard !identifiedCapturedForConnection,
102
+ let serialNumber = status.serialNumber.validManufacturingSerial
103
+ else { return }
104
+ identifiedCapturedForConnection = true
105
+ var properties: [String: Any] = [
106
+ "event_kind": "glasses_identified",
107
+ "fully_booted": status.fullyBooted,
108
+ "glasses_device_id": serialNumber,
109
+ "glasses_device_id_type": "manufacturing_serial",
110
+ ]
111
+ if !status.deviceModel.isEmpty {
112
+ properties["glasses_model"] = status.deviceModel
84
113
  }
114
+ capture(event: "bluetooth_sdk_glasses_identified", properties: properties, configuration: configuration)
85
115
  }
86
116
  }
87
117
 
@@ -116,6 +146,7 @@ final class BluetoothSdkAnalytics {
116
146
  "event_source": "mentra_bluetooth_sdk",
117
147
  "sdk_platform": "ios",
118
148
  "sdk_surface": configuration.surface,
149
+ "app_identifier": Bundle.main.bundleIdentifier ?? "",
119
150
  "app_bundle_identifier": Bundle.main.bundleIdentifier ?? "",
120
151
  "os_platform": "ios",
121
152
  "os_version": ProcessInfo.processInfo.operatingSystemVersionString,
@@ -148,6 +179,14 @@ private extension GlassesStatus {
148
179
  }
149
180
  }
150
181
 
182
+ private extension String {
183
+ var validManufacturingSerial: String? {
184
+ let normalized = trimmingCharacters(in: .whitespacesAndNewlines)
185
+ guard !normalized.isEmpty, normalized.contains(where: { $0 != "0" }) else { return nil }
186
+ return normalized
187
+ }
188
+ }
189
+
151
190
  private extension BluetoothSdkAnalyticsConfiguration {
152
191
  func resolvedForApp() -> BluetoothSdkAnalyticsConfiguration {
153
192
  let disabledByApp = Bundle.main.object(forInfoDictionaryKey: "MentraBluetoothSdkAnalyticsDisabled") as? Bool == true