@mentra/bluetooth-sdk 0.1.20 → 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 (73) hide show
  1. package/README.md +5 -1
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +127 -24
  3. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +65 -14
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +46 -3
  5. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +1 -1
  6. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +369 -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 +3 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +1 -1
  10. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +16 -9
  11. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Mach1.kt +1 -1
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +183 -30
  13. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +1 -1
  14. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +1 -1
  15. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +4 -2
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +1 -1
  17. package/android/src/main/java/com/mentra/bluetoothsdk/status/WifiHotspotStatus.kt +19 -3
  18. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +15 -2
  19. package/android/src/main/java/com/mentra/bluetoothsdk/tts/TTSTools.kt +115 -47
  20. package/android/src/main/java/com/mentra/bluetoothsdk/utils/audio/PcmStreamPlayer.kt +328 -0
  21. package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +15 -0
  22. package/build/BluetoothSdk.types.d.ts +46 -1
  23. package/build/BluetoothSdk.types.d.ts.map +1 -1
  24. package/build/BluetoothSdk.types.js.map +1 -1
  25. package/build/_internal.d.ts +2 -0
  26. package/build/_internal.d.ts.map +1 -1
  27. package/build/_internal.js +2 -0
  28. package/build/_internal.js.map +1 -1
  29. package/build/_private/BluetoothSdkModule.d.ts +23 -2
  30. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  31. package/build/_private/BluetoothSdkModule.js +20 -0
  32. package/build/_private/BluetoothSdkModule.js.map +1 -1
  33. package/build/_private/MentraLocalNetworkModule.d.ts +37 -0
  34. package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -0
  35. package/build/_private/MentraLocalNetworkModule.js +3 -0
  36. package/build/_private/MentraLocalNetworkModule.js.map +1 -0
  37. package/build/_private/cameraRequestPayload.d.ts.map +1 -1
  38. package/build/_private/cameraRequestPayload.js +1 -0
  39. package/build/_private/cameraRequestPayload.js.map +1 -1
  40. package/build/_private/photoRequestPayload.d.ts.map +1 -1
  41. package/build/_private/photoRequestPayload.js +1 -0
  42. package/build/_private/photoRequestPayload.js.map +1 -1
  43. package/build/index.d.ts.map +1 -1
  44. package/build/index.js +4 -0
  45. package/build/index.js.map +1 -1
  46. package/expo-module.config.json +1 -0
  47. package/ios/BluetoothSdkModule.swift +72 -0
  48. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  49. package/ios/Source/Bridge.swift +72 -18
  50. package/ios/Source/DeviceManager.swift +51 -3
  51. package/ios/Source/DeviceStore.swift +1 -1
  52. package/ios/Source/MentraBluetoothSDK.swift +289 -25
  53. package/ios/Source/audio/PcmStreamPlayer.swift +410 -0
  54. package/ios/Source/camera/CameraModels.swift +8 -3
  55. package/ios/Source/sgcs/G1.swift +1 -1
  56. package/ios/Source/sgcs/G2.swift +19 -7
  57. package/ios/Source/sgcs/Mach1.swift +1 -1
  58. package/ios/Source/sgcs/MentraLive.swift +318 -37
  59. package/ios/Source/sgcs/MentraNex.swift +1 -1
  60. package/ios/Source/sgcs/Nimo.swift +1 -1
  61. package/ios/Source/sgcs/SGCManager.swift +5 -2
  62. package/ios/Source/sgcs/Simulated.swift +1 -1
  63. package/ios/Source/status/WifiHotspotStatus.swift +21 -2
  64. package/ios/Source/streaming/StreamModels.swift +9 -0
  65. package/ios/Source/tts/TTSTools.swift +1 -1
  66. package/package.json +1 -2
  67. package/src/BluetoothSdk.types.ts +49 -0
  68. package/src/_internal.ts +2 -0
  69. package/src/_private/BluetoothSdkModule.ts +64 -13
  70. package/src/_private/MentraLocalNetworkModule.ts +50 -0
  71. package/src/_private/cameraRequestPayload.ts +1 -0
  72. package/src/_private/photoRequestPayload.ts +2 -3
  73. package/src/index.ts +4 -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
+ }
@@ -244,6 +244,7 @@ public struct PhotoRequest {
244
244
  public let requestId: String
245
245
  public let size: PhotoSize
246
246
  public let mode: PhotoMode
247
+ public let transferMethod: String
247
248
  public let webhookUrl: String?
248
249
  public let authToken: String?
249
250
  public let compress: PhotoCompression?
@@ -280,7 +281,8 @@ public struct PhotoRequest {
280
281
  zsl: Bool? = nil,
281
282
  ispDigitalGain: Int? = nil,
282
283
  ispAnalogGain: String? = nil,
283
- mode: PhotoMode = .photo
284
+ mode: PhotoMode = .photo,
285
+ transferMethod: String = "auto"
284
286
  ) {
285
287
  self.requestId = nonBlankRequestId(requestId) ?? generatedCameraRequestId("photo")
286
288
  self.size = size
@@ -300,6 +302,7 @@ public struct PhotoRequest {
300
302
  self.ispDigitalGain = ispDigitalGain
301
303
  self.ispAnalogGain = ispAnalogGain
302
304
  self.mode = mode
305
+ self.transferMethod = transferMethod == "ble" ? "ble" : "auto"
303
306
  }
304
307
 
305
308
  public static func from(params: [String: Any]) -> PhotoRequest {
@@ -367,7 +370,8 @@ public struct PhotoRequest {
367
370
  zsl: optionalBool("zsl"),
368
371
  ispDigitalGain: optionalInt("ispDigitalGain"),
369
372
  ispAnalogGain: params["ispAnalogGain"] as? String,
370
- mode: PhotoMode(normalizedRawValue: params["mode"] as? String)
373
+ mode: PhotoMode(normalizedRawValue: params["mode"] as? String),
374
+ transferMethod: params["transferMethod"] as? String ?? "auto"
371
375
  )
372
376
  }
373
377
 
@@ -417,7 +421,8 @@ public struct PhotoRequest {
417
421
  zsl: zsl,
418
422
  ispDigitalGain: ispDigitalGain,
419
423
  ispAnalogGain: ispAnalogGain,
420
- mode: mode
424
+ mode: mode,
425
+ transferMethod: transferMethod
421
426
  )
422
427
  }
423
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
 
@@ -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