@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.
- package/README.md +27 -12
- package/android/build.gradle +3 -0
- package/android/src/main/AndroidManifest.xml +3 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalytics.kt +34 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +1 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +169 -26
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +72 -16
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +127 -8
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +4 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +400 -99
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +333 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +43 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +18 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleTraceLogger.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +53 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Ar99.java +2653 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +16 -9
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Mach1.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +342 -42
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +7 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/Ar99OtaManager.java +566 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaByteUtils.java +18 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaCommandConstants.java +35 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaCrc32Util.java +13 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaGattTransport.java +11 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaProtocol.java +92 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/status/WifiHotspotStatus.kt +19 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +52 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/tts/TTSTools.kt +115 -47
- package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +7 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +8 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/Constants.kt +2 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/PhoneAudioMonitor.kt +45 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/audio/Ar99OpusPcmDecoder.java +398 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/audio/PcmStreamPlayer.kt +328 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/OtaManifestDowngradeTest.kt +86 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/StreamStatusEventTest.kt +29 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +45 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/Ar99AdvertisementMatcherTest.java +22 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +20 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/PhoneAudioMonitorTest.kt +45 -0
- package/build/BluetoothSdk.types.d.ts +100 -22
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js +2 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_internal.d.ts +3 -0
- package/build/_internal.d.ts.map +1 -1
- package/build/_internal.js +3 -0
- package/build/_internal.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +31 -4
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +26 -3
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/_private/MentraLocalNetworkModule.d.ts +37 -0
- package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -0
- package/build/_private/MentraLocalNetworkModule.js +3 -0
- package/build/_private/MentraLocalNetworkModule.js.map +1 -0
- package/build/_private/cameraRequestPayload.d.ts +1 -1
- package/build/_private/cameraRequestPayload.d.ts.map +1 -1
- package/build/_private/cameraRequestPayload.js +7 -0
- package/build/_private/cameraRequestPayload.js.map +1 -1
- package/build/_private/photoRequestPayload.d.ts.map +1 -1
- package/build/_private/photoRequestPayload.js +10 -0
- package/build/_private/photoRequestPayload.js.map +1 -1
- package/build/_private/sdkOtaManifest.d.ts +20 -0
- package/build/_private/sdkOtaManifest.d.ts.map +1 -0
- package/build/_private/sdkOtaManifest.js +28 -0
- package/build/_private/sdkOtaManifest.js.map +1 -0
- package/build/index.d.ts +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +11 -1
- package/build/index.js.map +1 -1
- package/expo-module.config.json +1 -0
- package/ios/BluetoothSdkModule.swift +113 -2
- package/ios/Source/BluetoothSdkDefaults.swift +2 -1
- package/ios/Source/Bridge.swift +87 -21
- package/ios/Source/DeviceManager.swift +118 -20
- package/ios/Source/DeviceStore.swift +4 -0
- package/ios/Source/MentraBluetoothSDK.swift +326 -33
- package/ios/Source/OtaManifest.swift +47 -4
- package/ios/Source/audio/PcmStreamPlayer.swift +410 -0
- package/ios/Source/camera/CameraModels.swift +26 -6
- package/ios/Source/internal/BleTraceLogger.swift +1 -1
- package/ios/Source/internal/BluetoothSdkAnalytics.swift +39 -0
- package/ios/Source/sgcs/Ar99.swift +2451 -0
- package/ios/Source/sgcs/G1.swift +1 -1
- package/ios/Source/sgcs/G2.swift +19 -7
- package/ios/Source/sgcs/Mach1.swift +1 -1
- package/ios/Source/sgcs/MentraLive.swift +478 -61
- package/ios/Source/sgcs/MentraNex.swift +1 -1
- package/ios/Source/sgcs/Nimo.swift +1 -1
- package/ios/Source/sgcs/SGCManager.swift +13 -2
- package/ios/Source/sgcs/Simulated.swift +1 -1
- package/ios/Source/sgcs/ar99/ota/Ar99OtaManager.swift +637 -0
- package/ios/Source/status/WifiHotspotStatus.swift +21 -2
- package/ios/Source/streaming/StreamModels.swift +68 -2
- package/ios/Source/tts/TTSTools.swift +1 -1
- package/ios/Source/types/DeviceModels.swift +13 -0
- package/ios/Source/utils/BleJsonCompact.swift +8 -0
- package/ios/Source/utils/Constants.swift +2 -0
- package/ios/Source/utils/PhoneAudioMonitor.swift +4 -0
- package/package.json +1 -2
- package/src/BluetoothSdk.types.ts +106 -24
- package/src/_internal.ts +3 -0
- package/src/_private/BluetoothSdkModule.ts +83 -18
- package/src/_private/MentraLocalNetworkModule.ts +50 -0
- package/src/_private/cameraRequestPayload.ts +9 -2
- package/src/_private/photoRequestPayload.ts +13 -4
- package/src/_private/sdkOtaManifest.ts +31 -0
- 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 =
|
|
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.
|
|
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
|