@mentra/bluetooth-sdk 0.1.19 → 0.1.21-beta.0
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 +6 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +239 -52
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +65 -14
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +47 -4
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +6 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +381 -97
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +333 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +16 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +2 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizer.kt +38 -0
- 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 +293 -174
- 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 +4 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/status/WifiHotspotStatus.kt +19 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +15 -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 +2 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadService.java +65 -8
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +6 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/audio/PcmStreamPlayer.kt +328 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkExceptionTest.kt +18 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +31 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizerTest.kt +31 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadServiceTest.java +9 -0
- package/build/BluetoothSdk.types.d.ts +71 -1
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_internal.d.ts +2 -0
- package/build/_internal.d.ts.map +1 -1
- package/build/_internal.js +2 -0
- package/build/_internal.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +27 -2
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +20 -0
- 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.map +1 -1
- package/build/_private/cameraRequestPayload.js +1 -0
- package/build/_private/cameraRequestPayload.js.map +1 -1
- package/build/_private/photoRequestPayload.d.ts.map +1 -1
- package/build/_private/photoRequestPayload.js +2 -0
- package/build/_private/photoRequestPayload.js.map +1 -1
- package/build/index.d.ts +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +9 -0
- package/build/index.js.map +1 -1
- package/expo-module.config.json +1 -0
- package/ios/BluetoothSdkModule.swift +72 -0
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/Bridge.swift +72 -18
- package/ios/Source/DeviceManager.swift +52 -3
- package/ios/Source/DeviceStore.swift +6 -1
- package/ios/Source/MentraBluetoothSDK.swift +298 -21
- package/ios/Source/audio/PcmStreamPlayer.swift +410 -0
- package/ios/Source/camera/CameraModels.swift +22 -3
- package/ios/Source/sgcs/G1.swift +2 -2
- package/ios/Source/sgcs/G2.swift +19 -7
- package/ios/Source/sgcs/Mach1.swift +1 -1
- package/ios/Source/sgcs/MentraLive.swift +456 -52
- package/ios/Source/sgcs/MentraLiveL2capChannel.swift +183 -0
- package/ios/Source/sgcs/MentraNex.swift +1 -1
- package/ios/Source/sgcs/Nimo.swift +1 -1
- package/ios/Source/sgcs/SGCManager.swift +5 -2
- package/ios/Source/sgcs/Simulated.swift +1 -1
- package/ios/Source/status/WifiHotspotStatus.swift +21 -2
- package/ios/Source/streaming/StreamModels.swift +9 -0
- package/ios/Source/tts/TTSTools.swift +1 -1
- package/ios/Source/utils/G1Text.swift +30 -1
- package/package.json +1 -2
- package/src/BluetoothSdk.types.ts +75 -0
- package/src/_internal.ts +2 -0
- package/src/_private/BluetoothSdkModule.ts +68 -13
- package/src/_private/MentraLocalNetworkModule.ts +50 -0
- package/src/_private/cameraRequestPayload.ts +1 -0
- package/src/_private/photoRequestPayload.ts +3 -3
- package/src/index.ts +10 -0
|
@@ -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
|
+
}
|
|
@@ -35,6 +35,15 @@ public enum PhotoSize: String {
|
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
public enum PhotoMode: String {
|
|
39
|
+
case photo
|
|
40
|
+
case text
|
|
41
|
+
|
|
42
|
+
public init(normalizedRawValue value: String?) {
|
|
43
|
+
self = PhotoMode(rawValue: value ?? "") ?? .photo
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
38
47
|
public enum ButtonPhotoSize: String {
|
|
39
48
|
case low
|
|
40
49
|
case medium
|
|
@@ -234,6 +243,8 @@ public struct CameraFovResult: CustomStringConvertible {
|
|
|
234
243
|
public struct PhotoRequest {
|
|
235
244
|
public let requestId: String
|
|
236
245
|
public let size: PhotoSize
|
|
246
|
+
public let mode: PhotoMode
|
|
247
|
+
public let transferMethod: String
|
|
237
248
|
public let webhookUrl: String?
|
|
238
249
|
public let authToken: String?
|
|
239
250
|
public let compress: PhotoCompression?
|
|
@@ -269,7 +280,9 @@ public struct PhotoRequest {
|
|
|
269
280
|
mfnr: Bool? = nil,
|
|
270
281
|
zsl: Bool? = nil,
|
|
271
282
|
ispDigitalGain: Int? = nil,
|
|
272
|
-
ispAnalogGain: String? = nil
|
|
283
|
+
ispAnalogGain: String? = nil,
|
|
284
|
+
mode: PhotoMode = .photo,
|
|
285
|
+
transferMethod: String = "auto"
|
|
273
286
|
) {
|
|
274
287
|
self.requestId = nonBlankRequestId(requestId) ?? generatedCameraRequestId("photo")
|
|
275
288
|
self.size = size
|
|
@@ -288,6 +301,8 @@ public struct PhotoRequest {
|
|
|
288
301
|
self.zsl = zsl
|
|
289
302
|
self.ispDigitalGain = ispDigitalGain
|
|
290
303
|
self.ispAnalogGain = ispAnalogGain
|
|
304
|
+
self.mode = mode
|
|
305
|
+
self.transferMethod = transferMethod == "ble" ? "ble" : "auto"
|
|
291
306
|
}
|
|
292
307
|
|
|
293
308
|
public static func from(params: [String: Any]) -> PhotoRequest {
|
|
@@ -354,7 +369,9 @@ public struct PhotoRequest {
|
|
|
354
369
|
mfnr: optionalBool("mfnr"),
|
|
355
370
|
zsl: optionalBool("zsl"),
|
|
356
371
|
ispDigitalGain: optionalInt("ispDigitalGain"),
|
|
357
|
-
ispAnalogGain: params["ispAnalogGain"] as? String
|
|
372
|
+
ispAnalogGain: params["ispAnalogGain"] as? String,
|
|
373
|
+
mode: PhotoMode(normalizedRawValue: params["mode"] as? String),
|
|
374
|
+
transferMethod: params["transferMethod"] as? String ?? "auto"
|
|
358
375
|
)
|
|
359
376
|
}
|
|
360
377
|
|
|
@@ -403,7 +420,9 @@ public struct PhotoRequest {
|
|
|
403
420
|
mfnr: mfnr,
|
|
404
421
|
zsl: zsl,
|
|
405
422
|
ispDigitalGain: ispDigitalGain,
|
|
406
|
-
ispAnalogGain: ispAnalogGain
|
|
423
|
+
ispAnalogGain: ispAnalogGain,
|
|
424
|
+
mode: mode,
|
|
425
|
+
transferMethod: transferMethod
|
|
407
426
|
)
|
|
408
427
|
}
|
|
409
428
|
}
|
package/ios/Source/sgcs/G1.swift
CHANGED
|
@@ -300,7 +300,7 @@ class G1: NSObject, SGCManager {
|
|
|
300
300
|
|
|
301
301
|
func setSilentMode(_: Bool) {}
|
|
302
302
|
|
|
303
|
-
func requestWifiScan() {}
|
|
303
|
+
func requestWifiScan(scanId _: String?) {}
|
|
304
304
|
|
|
305
305
|
func sendWifiCredentials(_: String, _: String) {}
|
|
306
306
|
|
|
@@ -739,7 +739,7 @@ class G1: NSObject, SGCManager {
|
|
|
739
739
|
|
|
740
740
|
@objc func RN_sendText(_ text: String) {
|
|
741
741
|
Task {
|
|
742
|
-
let displayText =
|
|
742
|
+
let displayText = G1Text.sanitizeForDisplay(text)
|
|
743
743
|
guard let textData = displayText.data(using: .utf8) else { return }
|
|
744
744
|
|
|
745
745
|
var command: [UInt8] = [
|
package/ios/Source/sgcs/G2.swift
CHANGED
|
@@ -587,15 +587,19 @@ private enum DevSettingsProto {
|
|
|
587
587
|
/// TimeSync submessage: f1 = (Unix seconds + TZ offset seconds) as Int32, no TZ field.
|
|
588
588
|
/// Firmware appears to ignore the TZ field, so we pre-shift the timestamp itself
|
|
589
589
|
/// to make UTC interpretation read as local. Empirically confirmed via probe variants in dbg1().
|
|
590
|
-
static func timeSync(
|
|
590
|
+
static func timeSync(
|
|
591
|
+
magicRandom: Int32,
|
|
592
|
+
timestampMs: Int64 = Int64(Date().timeIntervalSince1970 * 1000)
|
|
593
|
+
) -> Data {
|
|
591
594
|
var w = ProtobufWriter()
|
|
592
595
|
w.writeInt32Field(1, DevCfgCommandId.timeSync.rawValue)
|
|
593
596
|
w.writeInt32Field(2, magicRandom)
|
|
594
597
|
|
|
595
598
|
var tsW = ProtobufWriter()
|
|
596
|
-
let
|
|
597
|
-
let
|
|
598
|
-
|
|
599
|
+
let timestamp = Date(timeIntervalSince1970: Double(timestampMs) / 1000)
|
|
600
|
+
let timestampSec = timestampMs / 1000
|
|
601
|
+
let tzSec = Int64(TimeZone.current.secondsFromGMT(for: timestamp))
|
|
602
|
+
tsW.writeInt32Field(1, Int32(truncatingIfNeeded: timestampSec + tzSec))
|
|
599
603
|
w.writeMessageField(128, tsW.data) // timeSync (field 128 in DevCfgDataPackage)
|
|
600
604
|
return w.data
|
|
601
605
|
}
|
|
@@ -1908,7 +1912,7 @@ class G2: NSObject, SGCManager {
|
|
|
1908
1912
|
let timeSync = DevSettingsProto.timeSync(
|
|
1909
1913
|
magicRandom: self.sendManager.nextMagicRandom()
|
|
1910
1914
|
)
|
|
1911
|
-
self.sendDevSettingsCommand(timeSync)
|
|
1915
|
+
self.sendDevSettingsCommand(timeSync, left: true, right: true)
|
|
1912
1916
|
|
|
1913
1917
|
// Skip onboarding on connect
|
|
1914
1918
|
try? await Task.sleep(nanoseconds: 200_000_000)
|
|
@@ -3941,7 +3945,15 @@ class G2: NSObject, SGCManager {
|
|
|
3941
3945
|
/// time-zone travel, or a long sleep where the glasses' clock has drifted.
|
|
3942
3946
|
func syncTime() {
|
|
3943
3947
|
Bridge.log("G2: syncTime()")
|
|
3944
|
-
|
|
3948
|
+
sendSetSystemTime(Int64(Date().timeIntervalSince1970 * 1000))
|
|
3949
|
+
}
|
|
3950
|
+
|
|
3951
|
+
func sendSetSystemTime(_ timestampMs: Int64) {
|
|
3952
|
+
Bridge.log("G2: sendSetSystemTime()")
|
|
3953
|
+
let msg = DevSettingsProto.timeSync(
|
|
3954
|
+
magicRandom: sendManager.nextMagicRandom(),
|
|
3955
|
+
timestampMs: timestampMs
|
|
3956
|
+
)
|
|
3945
3957
|
sendDevSettingsCommand(msg, left: true, right: true)
|
|
3946
3958
|
}
|
|
3947
3959
|
|
|
@@ -3975,7 +3987,7 @@ class G2: NSObject, SGCManager {
|
|
|
3975
3987
|
|
|
3976
3988
|
// MARK: - SGCManager: Network (G2 has no WiFi)
|
|
3977
3989
|
|
|
3978
|
-
func requestWifiScan() {}
|
|
3990
|
+
func requestWifiScan(scanId _: String?) {}
|
|
3979
3991
|
func sendWifiCredentials(_: String, _: String) {}
|
|
3980
3992
|
func forgetWifiNetwork(_: String) {}
|
|
3981
3993
|
func sendHotspotState(_: Bool) {}
|