@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.
Files changed (83) hide show
  1. package/README.md +6 -2
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +239 -52
  3. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +65 -14
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +47 -4
  5. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +6 -1
  6. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +381 -97
  7. package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +333 -0
  8. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +16 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +2 -2
  10. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizer.kt +38 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +16 -9
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Mach1.kt +1 -1
  13. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +293 -174
  14. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +1 -1
  15. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +1 -1
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +4 -2
  17. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +1 -1
  18. package/android/src/main/java/com/mentra/bluetoothsdk/status/WifiHotspotStatus.kt +19 -3
  19. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +15 -2
  20. package/android/src/main/java/com/mentra/bluetoothsdk/tts/TTSTools.kt +115 -47
  21. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +2 -3
  22. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadService.java +65 -8
  23. package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +6 -5
  24. package/android/src/main/java/com/mentra/bluetoothsdk/utils/audio/PcmStreamPlayer.kt +328 -0
  25. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkExceptionTest.kt +18 -0
  26. package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +31 -0
  27. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizerTest.kt +31 -0
  28. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadServiceTest.java +9 -0
  29. package/build/BluetoothSdk.types.d.ts +71 -1
  30. package/build/BluetoothSdk.types.d.ts.map +1 -1
  31. package/build/BluetoothSdk.types.js.map +1 -1
  32. package/build/_internal.d.ts +2 -0
  33. package/build/_internal.d.ts.map +1 -1
  34. package/build/_internal.js +2 -0
  35. package/build/_internal.js.map +1 -1
  36. package/build/_private/BluetoothSdkModule.d.ts +27 -2
  37. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  38. package/build/_private/BluetoothSdkModule.js +20 -0
  39. package/build/_private/BluetoothSdkModule.js.map +1 -1
  40. package/build/_private/MentraLocalNetworkModule.d.ts +37 -0
  41. package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -0
  42. package/build/_private/MentraLocalNetworkModule.js +3 -0
  43. package/build/_private/MentraLocalNetworkModule.js.map +1 -0
  44. package/build/_private/cameraRequestPayload.d.ts.map +1 -1
  45. package/build/_private/cameraRequestPayload.js +1 -0
  46. package/build/_private/cameraRequestPayload.js.map +1 -1
  47. package/build/_private/photoRequestPayload.d.ts.map +1 -1
  48. package/build/_private/photoRequestPayload.js +2 -0
  49. package/build/_private/photoRequestPayload.js.map +1 -1
  50. package/build/index.d.ts +1 -1
  51. package/build/index.d.ts.map +1 -1
  52. package/build/index.js +9 -0
  53. package/build/index.js.map +1 -1
  54. package/expo-module.config.json +1 -0
  55. package/ios/BluetoothSdkModule.swift +72 -0
  56. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  57. package/ios/Source/Bridge.swift +72 -18
  58. package/ios/Source/DeviceManager.swift +52 -3
  59. package/ios/Source/DeviceStore.swift +6 -1
  60. package/ios/Source/MentraBluetoothSDK.swift +298 -21
  61. package/ios/Source/audio/PcmStreamPlayer.swift +410 -0
  62. package/ios/Source/camera/CameraModels.swift +22 -3
  63. package/ios/Source/sgcs/G1.swift +2 -2
  64. package/ios/Source/sgcs/G2.swift +19 -7
  65. package/ios/Source/sgcs/Mach1.swift +1 -1
  66. package/ios/Source/sgcs/MentraLive.swift +456 -52
  67. package/ios/Source/sgcs/MentraLiveL2capChannel.swift +183 -0
  68. package/ios/Source/sgcs/MentraNex.swift +1 -1
  69. package/ios/Source/sgcs/Nimo.swift +1 -1
  70. package/ios/Source/sgcs/SGCManager.swift +5 -2
  71. package/ios/Source/sgcs/Simulated.swift +1 -1
  72. package/ios/Source/status/WifiHotspotStatus.swift +21 -2
  73. package/ios/Source/streaming/StreamModels.swift +9 -0
  74. package/ios/Source/tts/TTSTools.swift +1 -1
  75. package/ios/Source/utils/G1Text.swift +30 -1
  76. package/package.json +1 -2
  77. package/src/BluetoothSdk.types.ts +75 -0
  78. package/src/_internal.ts +2 -0
  79. package/src/_private/BluetoothSdkModule.ts +68 -13
  80. package/src/_private/MentraLocalNetworkModule.ts +50 -0
  81. package/src/_private/cameraRequestPayload.ts +1 -0
  82. package/src/_private/photoRequestPayload.ts +3 -3
  83. 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
  }
@@ -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 = "\(text)"
742
+ let displayText = G1Text.sanitizeForDisplay(text)
743
743
  guard let textData = displayText.data(using: .utf8) else { return }
744
744
 
745
745
  var command: [UInt8] = [
@@ -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(magicRandom: Int32) -> Data {
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 nowSec = Int64(Date().timeIntervalSince1970)
597
- let tzSec = Int64(TimeZone.current.secondsFromGMT())
598
- tsW.writeInt32Field(1, Int32(truncatingIfNeeded: nowSec + tzSec))
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
- let msg = DevSettingsProto.timeSync(magicRandom: sendManager.nextMagicRandom())
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) {}
@@ -55,7 +55,7 @@ class Mach1: UltraliteBaseViewController, SGCManager {
55
55
  )
56
56
  }
57
57
 
58
- func requestWifiScan() {}
58
+ func requestWifiScan(scanId _: String?) {}
59
59
 
60
60
  func sendWifiCredentials(_: String, _: String) {}
61
61