@mentra/bluetooth-sdk 0.1.9 → 0.1.11

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 (147) hide show
  1. package/README.md +116 -18
  2. package/android/build.gradle +289 -16
  3. package/android/gradle.properties.example +9 -0
  4. package/android/lc3Lib/build.gradle +95 -6
  5. package/android/settings.gradle +3 -0
  6. package/android/silero/LICENSE.md +17 -0
  7. package/android/silero/build.gradle +42 -0
  8. package/android/silero/proguard-rules.pro +24 -0
  9. package/android/silero/src/androidTest/assets/hello.wav +0 -0
  10. package/android/silero/src/androidTest/java/com/konovalov/vad/silero/VadSileroTest.kt +79 -0
  11. package/android/silero/src/main/assets/LICENSE +21 -0
  12. package/android/silero/src/main/assets/silero_vad.onnx +0 -0
  13. package/android/silero/src/main/java/com/konovalov/vad/silero/Vad.kt +160 -0
  14. package/android/silero/src/main/java/com/konovalov/vad/silero/VadSilero.kt +449 -0
  15. package/android/silero/src/main/java/com/konovalov/vad/silero/config/FrameSize.kt +15 -0
  16. package/android/silero/src/main/java/com/konovalov/vad/silero/config/Mode.kt +14 -0
  17. package/android/silero/src/main/java/com/konovalov/vad/silero/config/SampleRate.kt +12 -0
  18. package/android/silero/src/main/java/com/konovalov/vad/silero/utils/AudioUtils.kt +47 -0
  19. package/android/silero/src/main/java/com/konovalov/vad/silero/utils/TensorMap.kt +41 -0
  20. package/android/silero/src/test/java/com/konovalov/vad/silero/utils/AudioUtilsTest.kt +44 -0
  21. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +6 -0
  22. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +195 -34
  23. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +103 -21
  24. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +210 -18
  25. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +30 -2
  26. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +787 -85
  27. package/android/src/main/java/com/mentra/bluetoothsdk/audio/AudioModels.kt +6 -2
  28. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +125 -11
  29. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/ControllerManager.kt +12 -2
  30. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/R1.kt +8 -2
  31. package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleTraceLogger.kt +33 -17
  32. package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +101 -0
  33. package/android/src/main/java/com/mentra/bluetoothsdk/internal/MapParsing.kt +8 -0
  34. package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/LocalPhotoReceiverRegistry.java +108 -0
  35. package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/LocalPhotoUploadServer.kt +399 -74
  36. package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/MentraPhotoReceiverModule.kt +100 -17
  37. package/android/src/main/java/com/mentra/bluetoothsdk/requests/DisplayRequests.kt +17 -0
  38. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.java +2 -2
  39. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +1293 -758
  40. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Mach1.java +2 -2
  41. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.java +271 -66
  42. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +197 -149
  43. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraosBle.java +12753 -27036
  44. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +64 -2
  45. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +16 -4
  46. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +60 -3
  47. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +12 -3
  48. package/android/src/main/java/com/mentra/bluetoothsdk/stt/STTTools.kt +87 -36
  49. package/android/src/main/java/com/mentra/bluetoothsdk/stt/SherpaOnnxTranscriber.kt +13 -4
  50. package/android/src/main/java/com/mentra/bluetoothsdk/stt/VadGateSpeechPolicy.kt +229 -0
  51. package/android/src/main/java/com/mentra/bluetoothsdk/tts/TTSTools.kt +180 -0
  52. package/android/src/main/java/com/mentra/bluetoothsdk/utils/AvifExifStripper.java +573 -0
  53. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadService.java +488 -50
  54. package/android/src/main/java/com/mentra/bluetoothsdk/utils/HeifExifTagReader.java +199 -0
  55. package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunkReassembler.java +18 -1
  56. package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunker.java +64 -18
  57. package/android/src/main/java/com/mentra/bluetoothsdk/utils/NexSGCUtils.kt +48 -39
  58. package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +23 -0
  59. package/android/src/test/java/com/mentra/bluetoothsdk/photoreceiver/LocalPhotoReceiverRegistryTest.java +75 -0
  60. package/android/src/test/java/com/mentra/bluetoothsdk/utils/AvifExifStripperTest.java +30 -0
  61. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadServiceTest.java +98 -0
  62. package/android/src/test/resources/avif_with_exif.avif +0 -0
  63. package/build/BluetoothSdk.types.d.ts +253 -44
  64. package/build/BluetoothSdk.types.d.ts.map +1 -1
  65. package/build/BluetoothSdk.types.js +11 -10
  66. package/build/BluetoothSdk.types.js.map +1 -1
  67. package/build/_private/BluetoothSdkModule.d.ts +37 -23
  68. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  69. package/build/_private/BluetoothSdkModule.js +43 -52
  70. package/build/_private/BluetoothSdkModule.js.map +1 -1
  71. package/build/_private/photoRequestPayload.d.ts +4 -0
  72. package/build/_private/photoRequestPayload.d.ts.map +1 -0
  73. package/build/_private/photoRequestPayload.js +25 -0
  74. package/build/_private/photoRequestPayload.js.map +1 -0
  75. package/build/index.d.ts +1 -1
  76. package/build/index.d.ts.map +1 -1
  77. package/build/index.js +25 -0
  78. package/build/index.js.map +1 -1
  79. package/build/react/useMentraBluetooth.d.ts +2 -2
  80. package/build/react/useMentraBluetooth.d.ts.map +1 -1
  81. package/build/react/useMentraBluetooth.js +3 -3
  82. package/build/react/useMentraBluetooth.js.map +1 -1
  83. package/ios/BluetoothSdkModule.swift +177 -70
  84. package/ios/LocalPhotoUploadServer.swift +370 -105
  85. package/ios/MentraBluetoothSDK.podspec +3 -1
  86. package/ios/MentraPhotoReceiverModule.swift +62 -10
  87. package/ios/Packages/CoreObjC/include/PcmConverter.h +22 -0
  88. package/ios/Packages/SherpaOnnx/SherpaOnnx.swift +668 -26
  89. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/Info.plist +13 -9
  90. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/Headers/sherpa-onnx/c-api/c-api.h +4551 -0
  91. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/Headers/sherpa-onnx/c-api/cxx-api.h +1745 -0
  92. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/libsherpa-onnx.a +0 -0
  93. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64_x86_64-simulator/Headers/sherpa-onnx/c-api/c-api.h +4551 -0
  94. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64_x86_64-simulator/Headers/sherpa-onnx/c-api/cxx-api.h +1745 -0
  95. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64_x86_64-simulator/libsherpa-onnx.a +0 -0
  96. package/ios/Source/BluetoothSdkDefaults.swift +6 -0
  97. package/ios/Source/Bridge.swift +68 -19
  98. package/ios/Source/DeviceManager.swift +167 -32
  99. package/ios/Source/DeviceStore.swift +24 -8
  100. package/ios/Source/MentraBluetoothSDK.swift +803 -47
  101. package/ios/Source/ObservableStore.swift +6 -0
  102. package/ios/Source/{Audio → audio}/AudioModels.swift +9 -7
  103. package/ios/Source/{Camera → camera}/CameraModels.swift +226 -16
  104. package/ios/Source/controllers/ControllerManager.swift +4 -3
  105. package/ios/Source/controllers/R1.swift +3 -3
  106. package/ios/Source/{Events → events}/BluetoothEvents.swift +183 -1
  107. package/ios/Source/{Internal → internal}/ValueParsing.swift +10 -1
  108. package/ios/Source/{Requests → requests}/DisplayRequests.swift +28 -4
  109. package/ios/Source/services/PhoneMic.swift +1 -0
  110. package/ios/Source/sgcs/G1.swift +5 -5
  111. package/ios/Source/sgcs/G2.swift +1494 -870
  112. package/ios/Source/sgcs/Mach1.swift +4 -4
  113. package/ios/Source/sgcs/MentraLive.swift +814 -321
  114. package/ios/Source/sgcs/MentraNex.swift +604 -244
  115. package/ios/Source/sgcs/SGCManager.swift +68 -5
  116. package/ios/Source/sgcs/Simulated.swift +9 -5
  117. package/ios/Source/sgcs/mentraos_ble.pb.swift +3073 -3134
  118. package/ios/Source/status/DeviceStatus.swift +974 -0
  119. package/ios/Source/{Status → status}/WifiHotspotStatus.swift +4 -4
  120. package/ios/Source/{Streaming → streaming}/StreamModels.swift +24 -9
  121. package/ios/Source/stt/STTTools.swift +27 -12
  122. package/ios/Source/stt/SherpaOnnxTranscriber.swift +31 -8
  123. package/ios/Source/tts/TTSTools.swift +164 -0
  124. package/ios/Source/utils/JSCExperiment.swift +7 -7
  125. package/ios/Source/utils/MessageChunkReassembler.swift +20 -1
  126. package/ios/Source/utils/MessageChunker.swift +78 -21
  127. package/ios/Source/utils/TarBz2Extractor.swift +53 -2
  128. package/package.json +1 -1
  129. package/plugin/build/withAndroid.js +68 -4
  130. package/src/BluetoothSdk.types.ts +344 -60
  131. package/src/_private/BluetoothSdkModule.ts +132 -102
  132. package/src/_private/photoRequestPayload.ts +28 -0
  133. package/src/index.ts +61 -15
  134. package/src/react/useMentraBluetooth.ts +6 -4
  135. package/android/.project +0 -28
  136. package/android/lc3Lib/.project +0 -28
  137. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/Headers/cargs.h +0 -162
  138. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/Headers/sherpa-onnx/c-api/c-api.h +0 -1852
  139. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/Headers/sherpa-onnx/c-api/cxx-api.h +0 -674
  140. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/sherpa-onnx.a +0 -0
  141. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64_x86_64-simulator/sherpa-onnx.a +0 -0
  142. package/ios/Source/Status/DeviceStatus.swift +0 -471
  143. /package/ios/Source/{Connection → connection}/ScanSession.swift +0 -0
  144. /package/ios/Source/{Errors → errors}/BluetoothError.swift +0 -0
  145. /package/ios/Source/{Internal → internal}/BluetoothAvailability.swift +0 -0
  146. /package/ios/Source/{Status → status}/RuntimeState.swift +0 -0
  147. /package/ios/Source/{Types → types}/DeviceModels.swift +0 -0
@@ -8,6 +8,10 @@ private final class ActiveStreamKeepAlive {
8
8
  var pendingAckId: String?
9
9
  var missedAckCount = 0
10
10
  var task: Task<Void, Never>?
11
+ // Missed-ACK counting only begins once the stream is confirmed live/coming up, so a slow
12
+ // startup (glasses can't ACK until they reach starting/streaming) can't trip a false
13
+ // keep-alive timeout before the stream is ever up.
14
+ var armed = false
11
15
 
12
16
  init(streamId: String, intervalSeconds: Int) {
13
17
  self.streamId = streamId
@@ -35,6 +39,108 @@ private final class ActiveScanSession {
35
39
  }
36
40
  }
37
41
 
42
+ @MainActor
43
+ private final class PendingWifiScan {
44
+ let pending: PendingResponse<[WifiScanResult]>
45
+
46
+ init(pending: PendingResponse<[WifiScanResult]>) {
47
+ self.pending = pending
48
+ }
49
+ }
50
+
51
+ private enum WifiStatusOperation {
52
+ case connect
53
+ case forget
54
+ }
55
+
56
+ @MainActor
57
+ private final class PendingWifiStatusRequest {
58
+ let operation: WifiStatusOperation
59
+ let ssid: String
60
+ let pending: PendingResponse<WifiStatusEvent>
61
+
62
+ init(operation: WifiStatusOperation, ssid: String, pending: PendingResponse<WifiStatusEvent>) {
63
+ self.operation = operation
64
+ self.ssid = ssid
65
+ self.pending = pending
66
+ }
67
+ }
68
+
69
+ @MainActor
70
+ private final class PendingHotspotStatusRequest {
71
+ let enabled: Bool
72
+ let pending: PendingResponse<HotspotStatusEvent>
73
+
74
+ init(enabled: Bool, pending: PendingResponse<HotspotStatusEvent>) {
75
+ self.enabled = enabled
76
+ self.pending = pending
77
+ }
78
+ }
79
+
80
+ @MainActor
81
+ private final class PendingVideoRecordingRequest {
82
+ let expectedStatus: String
83
+ let pending: PendingResponse<VideoRecordingStatusEvent>
84
+
85
+ init(expectedStatus: String, pending: PendingResponse<VideoRecordingStatusEvent>) {
86
+ self.expectedStatus = expectedStatus
87
+ self.pending = pending
88
+ }
89
+ }
90
+
91
+ @MainActor
92
+ private final class PendingResponse<T> {
93
+ private let operation: String
94
+ private var continuation: CheckedContinuation<T, Error>?
95
+ private var timeoutTask: Task<Void, Never>?
96
+ private var result: Result<T, Error>?
97
+ private var completed = false
98
+
99
+ init(operation: String) {
100
+ self.operation = operation
101
+ }
102
+
103
+ func resolve(_ value: T) {
104
+ guard !completed else { return }
105
+ completed = true
106
+ result = .success(value)
107
+ timeoutTask?.cancel()
108
+ continuation?.resume(returning: value)
109
+ continuation = nil
110
+ }
111
+
112
+ func reject(_ error: Error) {
113
+ guard !completed else { return }
114
+ completed = true
115
+ result = .failure(error)
116
+ timeoutTask?.cancel()
117
+ continuation?.resume(throwing: error)
118
+ continuation = nil
119
+ }
120
+
121
+ func wait(timeoutMs: Int = 15_000) async throws -> T {
122
+ if let result {
123
+ return try result.get()
124
+ }
125
+ return try await withCheckedThrowingContinuation { continuation in
126
+ self.continuation = continuation
127
+ timeoutTask = Task { @MainActor [weak self] in
128
+ do {
129
+ try await Task.sleep(nanoseconds: UInt64(timeoutMs) * 1_000_000)
130
+ } catch {
131
+ return
132
+ }
133
+ self?.reject(
134
+ BluetoothError(
135
+ code: "request_timeout",
136
+ message: "\(self?.operation ?? "Request") timed out waiting for glasses response."
137
+ )
138
+ )
139
+ }
140
+ }
141
+ }
142
+ }
143
+
38
144
  @MainActor
39
145
  public final class MentraBluetoothSDK {
40
146
  public weak var delegate: MentraBluetoothSDKDelegate?
@@ -48,6 +154,19 @@ public final class MentraBluetoothSDK {
48
154
  private var defaultDeviceApplyGeneration = 0
49
155
  private var activeScanSessions: [UUID: ActiveScanSession] = [:]
50
156
  private var activeStreamKeepAlive: ActiveStreamKeepAlive?
157
+ private var pendingPhotoRequests: [String: PendingResponse<PhotoResponseEvent>] = [:]
158
+ private var pendingVideoRecordingRequests: [String: PendingVideoRecordingRequest] = [:]
159
+ private var pendingRgbLedRequests: [String: PendingResponse<RgbLedControlResponseEvent>] = [:]
160
+ private var pendingSettingsRequests: [String: PendingResponse<SettingsAckEvent>] = [:]
161
+ private var pendingStreamStarts: [String: PendingResponse<StreamStatusEvent>] = [:]
162
+ private var pendingStreamStop: (streamId: String?, pending: PendingResponse<StreamStatusEvent>)?
163
+ private var pendingGalleryStatus: PendingResponse<GalleryStatusEvent>?
164
+ private var pendingOtaQuery: PendingResponse<OtaQueryResult>?
165
+ private var pendingOtaStart: PendingResponse<OtaStartAckEvent>?
166
+ private var pendingWifiScan: PendingWifiScan?
167
+ private var pendingWifiStatus: PendingWifiStatusRequest?
168
+ private var pendingHotspotStatus: PendingHotspotStatusRequest?
169
+ private var pendingVersionInfo: PendingResponse<VersionInfoResult>?
51
170
 
52
171
  public init(configuration: MentraBluetoothSDKConfiguration = .default) {
53
172
  self.configuration = configuration
@@ -247,6 +366,10 @@ public final class MentraBluetoothSDK {
247
366
  DeviceManager.shared.showDashboard()
248
367
  }
249
368
 
369
+ public func showNotificationsPanel() {
370
+ DeviceManager.shared.showNotificationsPanel()
371
+ }
372
+
250
373
  func setBrightness(_ level: Int, autoMode: Bool? = nil) async throws {
251
374
  if let autoMode {
252
375
  DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "auto_brightness", autoMode)
@@ -275,6 +398,14 @@ public final class MentraBluetoothSDK {
275
398
  )
276
399
  }
277
400
 
401
+ func setCalendarEvents(_ events: [CalendarEvent]) async throws {
402
+ DeviceStore.shared.apply(
403
+ ObservableStore.bluetoothCategory,
404
+ "calendar_events",
405
+ events.map(\.dictionary)
406
+ )
407
+ }
408
+
278
409
  public func setHeadUpAngle(_ angleDegrees: Int) async throws {
279
410
  DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "head_up_angle", angleDegrees)
280
411
  }
@@ -283,42 +414,112 @@ public final class MentraBluetoothSDK {
283
414
  DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "screen_disabled", disabled)
284
415
  }
285
416
 
286
- public func setGalleryModeEnabled(_ enabled: Bool) async throws {
287
- DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "gallery_mode", enabled)
417
+ public func setGalleryModeEnabled(_ enabled: Bool) async throws -> SettingsAckEvent {
418
+ try await performSettingsCommand(
419
+ setting: "gallery_mode",
420
+ updateStore: { _ in DeviceStore.shared.set(ObservableStore.bluetoothCategory, "gallery_mode", enabled) },
421
+ send: { requestId in try DeviceManager.shared.sendGalleryMode(requestId: requestId, enabled: enabled) }
422
+ )
423
+ }
424
+
425
+ private func performSettingsCommand(
426
+ setting: String,
427
+ updateStore: (SettingsAckEvent) -> Void,
428
+ send: (String) throws -> Void
429
+ ) async throws -> SettingsAckEvent {
430
+ let requestId = "settings-\(setting)-\(UUID().uuidString)"
431
+ let pending = PendingResponse<SettingsAckEvent>(operation: "set \(setting)")
432
+ pendingSettingsRequests[requestId] = pending
433
+ do {
434
+ try send(requestId)
435
+ let ack = try await pending.wait()
436
+ updateStore(ack)
437
+ pendingSettingsRequests.removeValue(forKey: requestId)
438
+ return ack
439
+ } catch {
440
+ pendingSettingsRequests.removeValue(forKey: requestId)
441
+ throw error
442
+ }
288
443
  }
289
444
 
290
445
  public func setVoiceActivityDetectionEnabled(_ enabled: Bool) async throws {
291
446
  DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "voice_activity_detection_enabled", enabled)
292
447
  }
293
448
 
294
- public func setButtonPhotoSettings(size: ButtonPhotoSize) async throws {
295
- DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "button_photo_size", size.rawValue)
449
+ public func setButtonPhotoSettings(size: ButtonPhotoSize) async throws -> SettingsAckEvent {
450
+ try await performSettingsCommand(
451
+ setting: "button_photo",
452
+ updateStore: { _ in DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_photo_size", size.rawValue) },
453
+ send: { requestId in try DeviceManager.shared.sendButtonPhotoSettings(requestId: requestId, size: size.rawValue) }
454
+ )
296
455
  }
297
456
 
298
- public func setButtonPhotoSettings(_ settings: ButtonPhotoSettings) async throws {
457
+ public func setButtonPhotoSettings(_ settings: ButtonPhotoSettings) async throws -> SettingsAckEvent {
299
458
  try await setButtonPhotoSettings(size: settings.size)
300
459
  }
301
460
 
302
- public func setButtonVideoRecordingSettings(width: Int, height: Int, fps: Int) async throws {
303
- DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "button_video_width", width)
304
- DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "button_video_height", height)
305
- DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "button_video_fps", fps)
461
+ public func setButtonVideoRecordingSettings(width: Int, height: Int, fps: Int) async throws -> SettingsAckEvent {
462
+ try await performSettingsCommand(
463
+ setting: "button_video_recording",
464
+ updateStore: { _ in
465
+ DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_video_width", width)
466
+ DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_video_height", height)
467
+ DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_video_fps", fps)
468
+ },
469
+ send: { requestId in
470
+ try DeviceManager.shared.sendButtonVideoRecordingSettings(
471
+ requestId: requestId,
472
+ width: width,
473
+ height: height,
474
+ fps: fps
475
+ )
476
+ }
477
+ )
306
478
  }
307
479
 
308
- public func setButtonVideoRecordingSettings(_ settings: ButtonVideoRecordingSettings) async throws {
480
+ public func setButtonVideoRecordingSettings(_ settings: ButtonVideoRecordingSettings) async throws -> SettingsAckEvent {
309
481
  try await setButtonVideoRecordingSettings(width: settings.width, height: settings.height, fps: settings.fps)
310
482
  }
311
483
 
312
- public func setButtonCameraLed(enabled: Bool) async throws {
313
- DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "button_camera_led", enabled)
484
+ public func setButtonCameraLed(enabled: Bool) async throws -> SettingsAckEvent {
485
+ try await performSettingsCommand(
486
+ setting: "button_camera_led",
487
+ updateStore: { _ in DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_camera_led", enabled) },
488
+ send: { requestId in try DeviceManager.shared.sendButtonCameraLedSetting(requestId: requestId, enabled: enabled) }
489
+ )
314
490
  }
315
491
 
316
- public func setButtonMaxRecordingTime(minutes: Int) async throws {
317
- DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "button_max_recording_time", minutes)
492
+ public func setButtonMaxRecordingTime(minutes: Int) async throws -> SettingsAckEvent {
493
+ try await performSettingsCommand(
494
+ setting: "button_max_recording_time",
495
+ updateStore: { _ in
496
+ DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_max_recording_time", minutes)
497
+ },
498
+ send: { requestId in
499
+ try DeviceManager.shared.sendButtonMaxRecordingTime(requestId: requestId, minutes: minutes)
500
+ }
501
+ )
318
502
  }
319
503
 
320
- public func setCameraFov(_ fov: CameraFov) async throws {
321
- DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "camera_fov", fov.value)
504
+ public func setCameraFov(_ fov: CameraFov) async throws -> CameraFovResult {
505
+ let ack = try await performSettingsCommand(
506
+ setting: "camera_fov",
507
+ updateStore: { _ in },
508
+ send: { requestId in
509
+ try DeviceManager.shared.sendCameraFovSetting(
510
+ requestId: requestId,
511
+ fov: fov.fov,
512
+ roiPosition: fov.roiPosition.rawValue
513
+ )
514
+ }
515
+ )
516
+ let result = try CameraFovResult.from(ack: ack, fallback: fov)
517
+ DeviceStore.shared.set(
518
+ ObservableStore.bluetoothCategory,
519
+ "camera_fov",
520
+ ["fov": result.fov, "roi_position": result.roiPosition.rawValue]
521
+ )
522
+ return result
322
523
  }
323
524
 
324
525
  public func setMicState(
@@ -361,39 +562,127 @@ public final class MentraBluetoothSDK {
361
562
  }
362
563
 
363
564
  public func getGlassesMediaVolume() async throws -> GlassesMediaVolumeGetResult {
364
- GlassesMediaVolumeGetResult(values: try await DeviceManager.shared.getGlassesMediaVolume())
565
+ try GlassesMediaVolumeGetResult(values: await DeviceManager.shared.getGlassesMediaVolume())
365
566
  }
366
567
 
367
568
  public func setGlassesMediaVolume(_ level: Int) async throws -> GlassesMediaVolumeSetResult {
368
- guard (0...15).contains(level) else {
569
+ guard (0 ... 15).contains(level) else {
369
570
  throw BluetoothError(
370
571
  code: "invalid_volume_level",
371
572
  message: "Glasses media volume must be between 0 and 15."
372
573
  )
373
574
  }
374
- return GlassesMediaVolumeSetResult(values: try await DeviceManager.shared.setGlassesMediaVolume(level: level))
575
+ return try GlassesMediaVolumeSetResult(values: await DeviceManager.shared.setGlassesMediaVolume(level: level))
375
576
  }
376
577
 
377
- public func requestWifiScan() {
578
+ public func requestWifiScan() async throws -> [WifiScanResult] {
579
+ guard pendingWifiScan == nil else {
580
+ throw BluetoothError(
581
+ code: "request_in_flight",
582
+ message: "A WiFi scan is already waiting for a glasses response."
583
+ )
584
+ }
585
+ let pending = PendingResponse<[WifiScanResult]>(operation: "WiFi scan request")
586
+ pendingWifiScan = PendingWifiScan(pending: pending)
378
587
  DeviceManager.shared.requestWifiScan()
588
+ do {
589
+ let results = try await pending.wait()
590
+ if pendingWifiScan?.pending === pending {
591
+ pendingWifiScan = nil
592
+ }
593
+ return results
594
+ } catch {
595
+ if pendingWifiScan?.pending === pending {
596
+ pendingWifiScan = nil
597
+ }
598
+ throw error
599
+ }
379
600
  }
380
601
 
381
- public func sendWifiCredentials(ssid: String, password: String) {
602
+ public func sendWifiCredentials(ssid: String, password: String) async throws -> WifiStatusEvent {
603
+ guard pendingWifiStatus == nil else {
604
+ throw BluetoothError(
605
+ code: "request_in_flight",
606
+ message: "A WiFi status command is already waiting for a glasses response."
607
+ )
608
+ }
609
+ let pending = PendingResponse<WifiStatusEvent>(operation: "WiFi connect request")
610
+ pendingWifiStatus = PendingWifiStatusRequest(operation: .connect, ssid: ssid, pending: pending)
382
611
  DeviceManager.shared.sendWifiCredentials(ssid, password)
612
+ do {
613
+ let event = try await pending.wait()
614
+ if pendingWifiStatus?.pending === pending {
615
+ pendingWifiStatus = nil
616
+ }
617
+ return event
618
+ } catch {
619
+ if pendingWifiStatus?.pending === pending {
620
+ pendingWifiStatus = nil
621
+ }
622
+ throw error
623
+ }
383
624
  }
384
625
 
385
- public func forgetWifiNetwork(ssid: String) {
626
+ public func forgetWifiNetwork(ssid: String) async throws -> WifiStatusEvent {
627
+ guard pendingWifiStatus == nil else {
628
+ throw BluetoothError(
629
+ code: "request_in_flight",
630
+ message: "A WiFi status command is already waiting for a glasses response."
631
+ )
632
+ }
633
+ let pending = PendingResponse<WifiStatusEvent>(operation: "WiFi forget request")
634
+ pendingWifiStatus = PendingWifiStatusRequest(operation: .forget, ssid: ssid, pending: pending)
386
635
  DeviceManager.shared.forgetWifiNetwork(ssid)
636
+ do {
637
+ let event = try await pending.wait()
638
+ if pendingWifiStatus?.pending === pending {
639
+ pendingWifiStatus = nil
640
+ }
641
+ return event
642
+ } catch {
643
+ if pendingWifiStatus?.pending === pending {
644
+ pendingWifiStatus = nil
645
+ }
646
+ throw error
647
+ }
387
648
  }
388
649
 
389
- public func setHotspotState(enabled: Bool) {
650
+ public func setHotspotState(enabled: Bool) async throws -> HotspotStatusEvent {
651
+ guard pendingHotspotStatus == nil else {
652
+ throw BluetoothError(
653
+ code: "request_in_flight",
654
+ message: "A hotspot command is already waiting for a glasses response."
655
+ )
656
+ }
657
+ let pending = PendingResponse<HotspotStatusEvent>(
658
+ operation: "hotspot \(enabled ? "enable" : "disable") request"
659
+ )
660
+ pendingHotspotStatus = PendingHotspotStatusRequest(enabled: enabled, pending: pending)
390
661
  DeviceManager.shared.setHotspotState(enabled)
662
+ do {
663
+ let event = try await pending.wait()
664
+ if pendingHotspotStatus?.pending === pending {
665
+ pendingHotspotStatus = nil
666
+ }
667
+ return event
668
+ } catch {
669
+ if pendingHotspotStatus?.pending === pending {
670
+ pendingHotspotStatus = nil
671
+ }
672
+ throw error
673
+ }
674
+ }
675
+
676
+ func setSystemTime(timestampMs: Int64) {
677
+ DeviceManager.shared.setSystemTime(timestampMs)
391
678
  }
392
679
 
393
- public func requestPhoto(_ request: PhotoRequest) {
680
+ public func requestPhoto(_ request: PhotoRequest) async throws -> PhotoResponseEvent {
394
681
  Bridge.log(
395
682
  "NATIVE: PHOTO PIPELINE [3b/6] MentraBluetoothSdk.requestPhoto requestId=\(request.requestId) appId=\(request.appId)"
396
683
  )
684
+ let pending = PendingResponse<PhotoResponseEvent>(operation: "photo request \(request.requestId)")
685
+ pendingPhotoRequests[request.requestId] = pending
397
686
  DeviceManager.shared.requestPhoto(
398
687
  request.requestId,
399
688
  request.appId,
@@ -402,32 +691,73 @@ public final class MentraBluetoothSDK {
402
691
  request.authToken,
403
692
  request.compress?.rawValue,
404
693
  request.flash,
694
+ request.save,
405
695
  request.sound,
406
696
  exposureTimeNs: request.exposureTimeNs,
407
697
  iso: request.iso
408
698
  )
699
+ do {
700
+ let event = try await pending.wait()
701
+ pendingPhotoRequests.removeValue(forKey: request.requestId)
702
+ return event
703
+ } catch {
704
+ pendingPhotoRequests.removeValue(forKey: request.requestId)
705
+ throw error
706
+ }
409
707
  }
410
708
 
411
- public func queryGalleryStatus() {
709
+ public func queryGalleryStatus() async throws -> GalleryStatusEvent {
710
+ if pendingGalleryStatus != nil {
711
+ throw BluetoothError(
712
+ code: "request_in_flight",
713
+ message: "A gallery status query is already waiting for a glasses response."
714
+ )
715
+ }
716
+ let pending = PendingResponse<GalleryStatusEvent>(operation: "gallery status query")
717
+ pendingGalleryStatus = pending
412
718
  DeviceManager.shared.queryGalleryStatus()
719
+ do {
720
+ let event = try await pending.wait()
721
+ if pendingGalleryStatus === pending {
722
+ pendingGalleryStatus = nil
723
+ }
724
+ return event
725
+ } catch {
726
+ if pendingGalleryStatus === pending {
727
+ pendingGalleryStatus = nil
728
+ }
729
+ throw error
730
+ }
413
731
  }
414
732
 
415
- public func startStream(_ request: StreamRequest) {
733
+ public func startStream(_ request: StreamRequest) async throws -> StreamStatusEvent {
416
734
  var values = request.values
417
735
  let streamId = stringValue(values, "streamId").flatMap { $0.isEmpty ? nil : $0 } ?? "sdk-\(UUID().uuidString)"
418
736
  values["streamId"] = streamId
737
+ let pending = PendingResponse<StreamStatusEvent>(operation: "start stream \(streamId)")
738
+ pendingStreamStarts[streamId] = pending
419
739
  stopStreamKeepAliveMonitor()
420
740
  DeviceManager.shared.startStream(values)
421
- if request.keepAlive, !request.isExternallyManagedKeepAlive {
422
- startStreamKeepAliveMonitor(streamId: streamId, intervalSeconds: request.keepAliveIntervalSeconds)
741
+ do {
742
+ let event = try await pending.wait(timeoutMs: 30_000)
743
+ pendingStreamStarts.removeValue(forKey: streamId)
744
+ if request.keepAlive, !request.isExternallyManagedKeepAlive {
745
+ startStreamKeepAliveMonitor(streamId: streamId, intervalSeconds: request.keepAliveIntervalSeconds)
746
+ }
747
+ return event
748
+ } catch {
749
+ pendingStreamStarts.removeValue(forKey: streamId)
750
+ throw error
423
751
  }
424
752
  }
425
753
 
426
- func sendCloudStreamKeepAlive(_ request: StreamKeepAliveRequest) {
754
+ func sendExternallyManagedStreamKeepAlive(_ request: StreamKeepAliveRequest) {
427
755
  DeviceManager.shared.keepStreamAlive(request.values)
428
756
  }
429
757
 
430
- public func rgbLedControl(_ request: RgbLedRequest) {
758
+ public func rgbLedControl(_ request: RgbLedRequest) async throws -> RgbLedControlResponseEvent {
759
+ let pending = PendingResponse<RgbLedControlResponseEvent>(operation: "RGB LED command \(request.requestId)")
760
+ pendingRgbLedRequests[request.requestId] = pending
431
761
  DeviceManager.shared.rgbLedControl(
432
762
  requestId: request.requestId,
433
763
  packageName: request.packageName,
@@ -437,37 +767,196 @@ public final class MentraBluetoothSDK {
437
767
  offDurationMs: request.offDurationMs,
438
768
  count: request.count
439
769
  )
770
+ do {
771
+ let event = try await pending.wait()
772
+ pendingRgbLedRequests.removeValue(forKey: request.requestId)
773
+ return event
774
+ } catch {
775
+ pendingRgbLedRequests.removeValue(forKey: request.requestId)
776
+ throw error
777
+ }
440
778
  }
441
779
 
442
- public func stopStream() {
780
+ public func stopStream() async throws -> StreamStatusEvent {
781
+ guard pendingStreamStop == nil else {
782
+ throw BluetoothError(
783
+ code: "request_in_flight",
784
+ message: "A stream stop command is already waiting for a glasses response."
785
+ )
786
+ }
787
+ let pending = PendingResponse<StreamStatusEvent>(operation: "stop stream")
788
+ pendingStreamStop = (streamId: activeStreamKeepAlive?.streamId, pending: pending)
443
789
  stopStreamKeepAliveMonitor()
444
790
  DeviceManager.shared.stopStream()
791
+ do {
792
+ let event = try await pending.wait(timeoutMs: 15_000)
793
+ if pendingStreamStop?.pending === pending {
794
+ pendingStreamStop = nil
795
+ }
796
+ return event
797
+ } catch {
798
+ if pendingStreamStop?.pending === pending {
799
+ pendingStreamStop = nil
800
+ }
801
+ throw error
802
+ }
445
803
  }
446
804
 
447
- public func startVideoRecording(_ request: VideoRecordingRequest) {
805
+ public func startVideoRecording(_ request: VideoRecordingRequest) async throws -> VideoRecordingStatusEvent {
806
+ guard !request.requestId.isEmpty else {
807
+ throw BluetoothError(code: "missing_request_id", message: "requestId is required to start video recording.")
808
+ }
809
+ let pending = PendingResponse<VideoRecordingStatusEvent>(
810
+ operation: "start video recording \(request.requestId)"
811
+ )
812
+ guard pendingVideoRecordingRequests[request.requestId] == nil else {
813
+ throw BluetoothError(
814
+ code: "request_in_flight",
815
+ message: "A video recording command is already waiting for requestId \(request.requestId)."
816
+ )
817
+ }
818
+ pendingVideoRecordingRequests[request.requestId] = PendingVideoRecordingRequest(
819
+ expectedStatus: "recording_started",
820
+ pending: pending
821
+ )
448
822
  DeviceManager.shared.startVideoRecording(
449
823
  request.requestId,
450
824
  request.save,
451
- request.sound
825
+ request.sound,
826
+ request.width,
827
+ request.height,
828
+ request.fps
452
829
  )
830
+ do {
831
+ let event = try await pending.wait()
832
+ pendingVideoRecordingRequests.removeValue(forKey: request.requestId)
833
+ return event
834
+ } catch {
835
+ pendingVideoRecordingRequests.removeValue(forKey: request.requestId)
836
+ throw error
837
+ }
453
838
  }
454
839
 
455
- public func stopVideoRecording(requestId: String) {
840
+ public func stopVideoRecording(requestId: String) async throws -> VideoRecordingStatusEvent {
841
+ guard !requestId.isEmpty else {
842
+ throw BluetoothError(code: "missing_request_id", message: "requestId is required to stop video recording.")
843
+ }
844
+ let pending = PendingResponse<VideoRecordingStatusEvent>(operation: "stop video recording \(requestId)")
845
+ guard pendingVideoRecordingRequests[requestId] == nil else {
846
+ throw BluetoothError(
847
+ code: "request_in_flight",
848
+ message: "A video recording command is already waiting for requestId \(requestId)."
849
+ )
850
+ }
851
+ pendingVideoRecordingRequests[requestId] = PendingVideoRecordingRequest(
852
+ expectedStatus: "recording_stopped",
853
+ pending: pending
854
+ )
456
855
  DeviceManager.shared.stopVideoRecording(requestId)
856
+ do {
857
+ let event = try await pending.wait()
858
+ pendingVideoRecordingRequests.removeValue(forKey: requestId)
859
+ return event
860
+ } catch {
861
+ pendingVideoRecordingRequests.removeValue(forKey: requestId)
862
+ throw error
863
+ }
457
864
  }
458
865
 
459
- public func requestVersionInfo() {
866
+ public func requestVersionInfo() async throws -> VersionInfoResult {
867
+ guard pendingVersionInfo == nil else {
868
+ throw BluetoothError(
869
+ code: "request_in_flight",
870
+ message: "A version info request is already waiting for a glasses response."
871
+ )
872
+ }
873
+ let pending = PendingResponse<VersionInfoResult>(operation: "version info request")
874
+ pendingVersionInfo = pending
460
875
  DeviceManager.shared.requestVersionInfo()
876
+ do {
877
+ let status = try await pending.wait()
878
+ if pendingVersionInfo === pending {
879
+ pendingVersionInfo = nil
880
+ }
881
+ return status
882
+ } catch {
883
+ if pendingVersionInfo === pending {
884
+ pendingVersionInfo = nil
885
+ }
886
+ throw error
887
+ }
888
+ }
889
+
890
+ /// Ask connected Mentra Live glasses to check/report OTA availability and status.
891
+ public func checkForOtaUpdate() async throws -> OtaQueryResult {
892
+ try await performOtaQuery(operation: "OTA status query") {
893
+ DeviceManager.shared.sendOtaQueryStatus()
894
+ }
895
+ }
896
+
897
+ private func performOtaQuery(
898
+ operation: String,
899
+ sendRequest: () -> Void
900
+ ) async throws -> OtaQueryResult {
901
+ if pendingOtaQuery != nil {
902
+ throw BluetoothError(
903
+ code: "request_in_flight",
904
+ message: "An OTA status query is already waiting for a glasses response."
905
+ )
906
+ }
907
+ let pending = PendingResponse<OtaQueryResult>(operation: operation)
908
+ pendingOtaQuery = pending
909
+ sendRequest()
910
+ do {
911
+ let result = try await pending.wait()
912
+ if pendingOtaQuery === pending {
913
+ pendingOtaQuery = nil
914
+ }
915
+ return result
916
+ } catch {
917
+ if pendingOtaQuery === pending {
918
+ pendingOtaQuery = nil
919
+ }
920
+ throw error
921
+ }
461
922
  }
462
923
 
463
- func sendOtaStart() {
924
+ /// Start the OTA flow after your app has presented the available update to the user.
925
+ public func startOtaUpdate() async throws -> OtaStartAckEvent {
926
+ if pendingOtaStart != nil {
927
+ throw BluetoothError(
928
+ code: "request_in_flight",
929
+ message: "An OTA start command is already waiting for a glasses response."
930
+ )
931
+ }
932
+ let pending = PendingResponse<OtaStartAckEvent>(operation: "OTA start command")
933
+ pendingOtaStart = pending
464
934
  DeviceManager.shared.sendOtaStart()
935
+ do {
936
+ let event = try await pending.wait()
937
+ if pendingOtaStart === pending {
938
+ pendingOtaStart = nil
939
+ }
940
+ return event
941
+ } catch {
942
+ if pendingOtaStart === pending {
943
+ pendingOtaStart = nil
944
+ }
945
+ throw error
946
+ }
465
947
  }
466
948
 
467
- func sendOtaQueryStatus() {
468
- DeviceManager.shared.sendOtaQueryStatus()
949
+ /// Re-run the glasses-side OTA version check, mainly after correcting clock skew/TLS failures.
950
+ public func retryOtaVersionCheck() async throws -> OtaQueryResult {
951
+ try await performOtaQuery(operation: "OTA version retry") {
952
+ DeviceManager.shared.retryOtaVersionCheck()
953
+ }
469
954
  }
470
955
 
956
+ func sendOtaStart() async throws -> OtaStartAckEvent { try await startOtaUpdate() }
957
+
958
+ func sendOtaQueryStatus() async throws -> OtaQueryResult { try await checkForOtaUpdate() }
959
+
471
960
  func sendShutdown() {
472
961
  DeviceManager.shared.sendShutdown()
473
962
  }
@@ -508,7 +997,7 @@ public final class MentraBluetoothSDK {
508
997
  private func sendNextStreamKeepAlive(for tracker: ActiveStreamKeepAlive) {
509
998
  guard activeStreamKeepAlive === tracker else { return }
510
999
 
511
- if tracker.pendingAckId != nil {
1000
+ if tracker.armed, tracker.pendingAckId != nil {
512
1001
  tracker.missedAckCount += 1
513
1002
  if tracker.missedAckCount >= 3 {
514
1003
  activeStreamKeepAlive = nil
@@ -522,6 +1011,8 @@ public final class MentraBluetoothSDK {
522
1011
  )
523
1012
  )
524
1013
  delegate?.mentraBluetoothSDK(self, didReceive: .streamStatus(event))
1014
+ stopStreamKeepAliveMonitor()
1015
+ DeviceManager.shared.stopStream()
525
1016
  return
526
1017
  }
527
1018
  }
@@ -553,9 +1044,9 @@ public final class MentraBluetoothSDK {
553
1044
  }
554
1045
 
555
1046
  private func handleStreamStatusForKeepAlive(_ status: StreamStatus) {
556
- if let streamId = status.streamId,
557
- activeStreamKeepAlive?.streamId != streamId
558
- {
1047
+ guard let streamId = status.streamId,
1048
+ activeStreamKeepAlive?.streamId == streamId
1049
+ else {
559
1050
  return
560
1051
  }
561
1052
 
@@ -563,10 +1054,222 @@ public final class MentraBluetoothSDK {
563
1054
  case .stopped, .stopping, .error, .reconnectFailed:
564
1055
  stopStreamKeepAliveMonitor()
565
1056
  default:
566
- break
1057
+ // A non-terminal status means the stream is live or coming up and the glasses can
1058
+ // now ACK; arm the missed-ACK detector from here so a slow startup before the first
1059
+ // ACK can't trip a false keep-alive timeout. On the arming transition, drop any
1060
+ // pre-arm bookkeeping so a stale unacked id can't immediately count as a miss.
1061
+ if let tracker = activeStreamKeepAlive, !tracker.armed {
1062
+ tracker.armed = true
1063
+ tracker.pendingAckId = nil
1064
+ tracker.missedAckCount = 0
1065
+ }
1066
+ }
1067
+ }
1068
+
1069
+ private func handleStreamStatusForRequests(_ event: StreamStatusEvent) {
1070
+ if let (streamId, pending) = matchingStreamStart(for: event) {
1071
+ switch event.state {
1072
+ case .streaming:
1073
+ pendingStreamStarts.removeValue(forKey: streamId)
1074
+ pending.resolve(event)
1075
+ case .error, .reconnectFailed, .stopped:
1076
+ pendingStreamStarts.removeValue(forKey: streamId)
1077
+ pending.reject(streamStatusError(event, code: "stream_start_failed"))
1078
+ default:
1079
+ break
1080
+ }
1081
+ }
1082
+
1083
+ if let stop = pendingStreamStop, streamStatus(event, matches: stop.streamId) {
1084
+ if isAlreadyStoppedStreamStatus(event) {
1085
+ if pendingStreamStop?.pending === stop.pending {
1086
+ pendingStreamStop = nil
1087
+ }
1088
+ stop.pending.resolve(stoppedStreamEvent(from: event, fallbackStreamId: stop.streamId))
1089
+ } else if event.state == .error || event.state == .reconnectFailed {
1090
+ if pendingStreamStop?.pending === stop.pending {
1091
+ pendingStreamStop = nil
1092
+ }
1093
+ stop.pending.reject(streamStatusError(event, code: "stream_stop_failed"))
1094
+ }
567
1095
  }
568
1096
  }
569
1097
 
1098
+ private func matchingStreamStart(for event: StreamStatusEvent) -> (String, PendingResponse<StreamStatusEvent>)? {
1099
+ if let streamId = event.streamId, !streamId.isEmpty {
1100
+ guard let pending = pendingStreamStarts[streamId] else { return nil }
1101
+ return (streamId, pending)
1102
+ }
1103
+ if pendingStreamStarts.count == 1, let entry = pendingStreamStarts.first {
1104
+ return (entry.key, entry.value)
1105
+ }
1106
+ return nil
1107
+ }
1108
+
1109
+ private func streamStatus(_ event: StreamStatusEvent, matches streamId: String?) -> Bool {
1110
+ guard let streamId, !streamId.isEmpty else { return true }
1111
+ guard let eventStreamId = event.streamId, !eventStreamId.isEmpty else { return true }
1112
+ return eventStreamId == streamId
1113
+ }
1114
+
1115
+ private func isAlreadyStoppedStreamStatus(_ event: StreamStatusEvent) -> Bool {
1116
+ if event.state == .stopped {
1117
+ return true
1118
+ }
1119
+ guard case let .error(_, errorDetails, _, _) = event.status else {
1120
+ return false
1121
+ }
1122
+ return ["not_streaming", "already_stopped", "not streaming"].contains(errorDetails.lowercased())
1123
+ }
1124
+
1125
+ private func stoppedStreamEvent(from event: StreamStatusEvent, fallbackStreamId: String?) -> StreamStatusEvent {
1126
+ StreamStatusEvent(
1127
+ status: .lifecycle(
1128
+ state: .stopped,
1129
+ streamId: event.streamId ?? fallbackStreamId,
1130
+ timestamp: event.status.timestamp ?? Int(Date().timeIntervalSince1970 * 1000),
1131
+ resolvedConfig: event.resolvedConfig
1132
+ )
1133
+ )
1134
+ }
1135
+
1136
+ private func streamStatusError(_ event: StreamStatusEvent, code: String) -> BluetoothError {
1137
+ let message: String
1138
+ if case let .error(_, errorDetails, _, _) = event.status {
1139
+ message = errorDetails
1140
+ } else {
1141
+ message = "Stream status \(event.state.rawValue)"
1142
+ }
1143
+ return BluetoothError(code: code, message: message)
1144
+ }
1145
+
1146
+ private func handlePhotoResponseForRequests(_ event: PhotoResponseEvent) {
1147
+ guard let pending = pendingPhotoRequests[event.requestId] else { return }
1148
+ switch event.response {
1149
+ case .success:
1150
+ pending.resolve(event)
1151
+ case let .error(_, errorCode, errorMessage, _):
1152
+ pending.reject(
1153
+ BluetoothError(
1154
+ code: errorCode ?? "photo_request_failed",
1155
+ message: errorMessage
1156
+ )
1157
+ )
1158
+ }
1159
+ }
1160
+
1161
+ private func handleVideoRecordingStatusForRequests(_ event: VideoRecordingStatusEvent) {
1162
+ guard let request = pendingVideoRecordingRequests[event.requestId] else { return }
1163
+ if event.success {
1164
+ if event.status == request.expectedStatus {
1165
+ request.pending.resolve(event)
1166
+ }
1167
+ } else {
1168
+ request.pending.reject(
1169
+ BluetoothError(
1170
+ code: event.status.isEmpty ? "video_recording_failed" : event.status,
1171
+ message: event.details ?? "Video recording command failed."
1172
+ )
1173
+ )
1174
+ }
1175
+ }
1176
+
1177
+ private func handleRgbLedResponseForRequests(_ event: RgbLedControlResponseEvent) {
1178
+ guard let pending = pendingRgbLedRequests[event.requestId] else { return }
1179
+ if event.state == "success" {
1180
+ pending.resolve(event)
1181
+ } else {
1182
+ pending.reject(
1183
+ BluetoothError(
1184
+ code: event.errorCode ?? "rgb_led_control_failed",
1185
+ message: event.errorCode ?? "RGB LED command failed."
1186
+ )
1187
+ )
1188
+ }
1189
+ }
1190
+
1191
+ private func handleSettingsAckForRequests(_ event: SettingsAckEvent) {
1192
+ guard let pending = pendingSettingsRequests[event.requestId] else { return }
1193
+ if isFailureStatus(event.status) {
1194
+ let fallbackSetting = event.setting.isEmpty ? event.requestId : event.setting
1195
+ pending.reject(
1196
+ BluetoothError(
1197
+ code: event.errorCode ?? "\(event.setting.isEmpty ? "settings" : event.setting)_failed",
1198
+ message: event.errorMessage ?? "Settings command \(fallbackSetting) failed."
1199
+ )
1200
+ )
1201
+ } else {
1202
+ pending.resolve(event)
1203
+ }
1204
+ }
1205
+
1206
+ private func isFailureStatus(_ status: String) -> Bool {
1207
+ ["error", "failed", "failure", "rejected"].contains(status.lowercased())
1208
+ }
1209
+
1210
+ private func handleWifiScanResultsForRequests(_ results: [WifiScanResult]) {
1211
+ guard let request = pendingWifiScan else { return }
1212
+ if pendingWifiScan === request {
1213
+ pendingWifiScan = nil
1214
+ }
1215
+ request.pending.resolve(results)
1216
+ }
1217
+
1218
+ private func handleWifiStatusForRequests(_ event: WifiStatusEvent) {
1219
+ guard let request = pendingWifiStatus else { return }
1220
+ guard wifiStatusMatches(event.status, request: request) else { return }
1221
+ if pendingWifiStatus === request {
1222
+ pendingWifiStatus = nil
1223
+ }
1224
+ request.pending.resolve(event)
1225
+ }
1226
+
1227
+ private func wifiStatusMatches(_ status: WifiStatus, request: PendingWifiStatusRequest) -> Bool {
1228
+ switch request.operation {
1229
+ case .connect:
1230
+ if case let .connected(ssid, _) = status {
1231
+ return ssid == request.ssid
1232
+ }
1233
+ return false
1234
+ case .forget:
1235
+ switch status {
1236
+ case .disconnected:
1237
+ return true
1238
+ case let .connected(ssid, _):
1239
+ return ssid != request.ssid
1240
+ }
1241
+ }
1242
+ }
1243
+
1244
+ private func handleHotspotStatusForRequests(_ event: HotspotStatusEvent) {
1245
+ guard let request = pendingHotspotStatus else { return }
1246
+ guard hotspotStatusMatches(event.status, enabled: request.enabled) else { return }
1247
+ if pendingHotspotStatus === request {
1248
+ pendingHotspotStatus = nil
1249
+ }
1250
+ request.pending.resolve(event)
1251
+ }
1252
+
1253
+ private func hotspotStatusMatches(_ status: HotspotStatus, enabled: Bool) -> Bool {
1254
+ if enabled {
1255
+ return status.isEnabled
1256
+ }
1257
+ return status == .disabled
1258
+ }
1259
+
1260
+ private func handleHotspotErrorForRequests(_ event: HotspotErrorEvent) {
1261
+ guard let request = pendingHotspotStatus else { return }
1262
+ if pendingHotspotStatus === request {
1263
+ pendingHotspotStatus = nil
1264
+ }
1265
+ request.pending.reject(
1266
+ BluetoothError(
1267
+ code: "hotspot_command_failed",
1268
+ message: event.message ?? "Hotspot command failed."
1269
+ )
1270
+ )
1271
+ }
1272
+
570
1273
  private func dispatchStoreUpdate(_ category: String, _ changes: [String: Any]) {
571
1274
  switch ObservableStore.normalizeCategory(category) {
572
1275
  case "glasses":
@@ -688,17 +1391,46 @@ public final class MentraBluetoothSDK {
688
1391
  didReceive: .speakingStatus(SpeakingStatusEvent(values: data))
689
1392
  )
690
1393
  case "hotspot_status_change":
691
- delegate?.mentraBluetoothSDK(self, didReceive: .hotspotStatus(HotspotStatusEvent(values: data)))
1394
+ let event = HotspotStatusEvent(values: data)
1395
+ handleHotspotStatusForRequests(event)
1396
+ delegate?.mentraBluetoothSDK(self, didReceive: .hotspotStatus(event))
692
1397
  case "wifi_status_change":
693
- delegate?.mentraBluetoothSDK(self, didReceive: .wifiStatus(WifiStatusEvent(values: data)))
1398
+ let event = WifiStatusEvent(values: data)
1399
+ handleWifiStatusForRequests(event)
1400
+ delegate?.mentraBluetoothSDK(self, didReceive: .wifiStatus(event))
1401
+ case "wifi_scan_result":
1402
+ let networks = (data["networks"] as? [[String: Any]])?.map(WifiScanResult.init(values:)) ?? []
1403
+ let hasCompletionFlag = data.keys.contains("scanComplete") || data.keys.contains("scan_complete")
1404
+ let scanComplete = data["scanComplete"] as? Bool ?? data["scan_complete"] as? Bool ?? false
1405
+ if scanComplete || !hasCompletionFlag {
1406
+ handleWifiScanResultsForRequests(networks)
1407
+ }
1408
+ delegate?.mentraBluetoothSDK(self, didReceive: .raw(name: "wifi_scan_result", values: data))
694
1409
  case "hotspot_error":
695
- delegate?.mentraBluetoothSDK(self, didReceive: .hotspotError(HotspotErrorEvent(values: data)))
1410
+ let event = HotspotErrorEvent(values: data)
1411
+ handleHotspotErrorForRequests(event)
1412
+ delegate?.mentraBluetoothSDK(self, didReceive: .hotspotError(event))
1413
+ case "gallery_status":
1414
+ let event = GalleryStatusEvent(values: data)
1415
+ pendingGalleryStatus?.resolve(event)
1416
+ delegate?.mentraBluetoothSDK(self, didReceive: .raw(name: "gallery_status", values: event.values))
696
1417
  case "photo_response":
697
- delegate?.mentraBluetoothSDK(self, didReceive: .photoResponse(PhotoResponseEvent(values: data)))
1418
+ let event = PhotoResponseEvent(values: data)
1419
+ handlePhotoResponseForRequests(event)
1420
+ delegate?.mentraBluetoothSDK(self, didReceive: .photoResponse(event))
698
1421
  case "photo_status":
699
1422
  delegate?.mentraBluetoothSDK(self, didReceive: .photoStatus(PhotoStatusEvent(values: data)))
1423
+ case "video_recording_status":
1424
+ let event = VideoRecordingStatusEvent(values: data)
1425
+ handleVideoRecordingStatusForRequests(event)
1426
+ delegate?.mentraBluetoothSDK(self, didReceive: .videoRecordingStatus(event))
1427
+ case "rgb_led_control_response":
1428
+ let event = RgbLedControlResponseEvent(values: data)
1429
+ handleRgbLedResponseForRequests(event)
1430
+ delegate?.mentraBluetoothSDK(self, didReceive: .rgbLedControlResponse(event))
700
1431
  case "stream_status":
701
1432
  let event = StreamStatusEvent(values: data)
1433
+ handleStreamStatusForRequests(event)
702
1434
  handleStreamStatusForKeepAlive(event.status)
703
1435
  delegate?.mentraBluetoothSDK(self, didReceive: .streamStatus(event))
704
1436
  case "keep_alive_ack":
@@ -706,6 +1438,30 @@ public final class MentraBluetoothSDK {
706
1438
  if !handleStreamKeepAliveAck(event) {
707
1439
  delegate?.mentraBluetoothSDK(self, didReceive: .keepAliveAck(event))
708
1440
  }
1441
+ case "ota_update_available":
1442
+ var resultValues = data
1443
+ resultValues["type"] = "ota_update_available"
1444
+ pendingOtaQuery?.resolve(OtaQueryResult(values: resultValues))
1445
+ delegate?.mentraBluetoothSDK(self, didReceive: .otaUpdateAvailable(OtaUpdateAvailableEvent(values: resultValues)))
1446
+ case "ota_start_ack":
1447
+ var values = data
1448
+ values["type"] = "ota_start_ack"
1449
+ let event = OtaStartAckEvent(values: values)
1450
+ pendingOtaStart?.resolve(event)
1451
+ delegate?.mentraBluetoothSDK(self, didReceive: .otaStartAck(event))
1452
+ case "ota_status":
1453
+ var resultValues = data
1454
+ resultValues["type"] = "ota_status"
1455
+ pendingOtaQuery?.resolve(OtaQueryResult(values: resultValues))
1456
+ delegate?.mentraBluetoothSDK(self, didReceive: .otaStatus(OtaStatusEvent(values: resultValues)))
1457
+ case "settings_ack":
1458
+ let event = SettingsAckEvent(values: data)
1459
+ handleSettingsAckForRequests(event)
1460
+ delegate?.mentraBluetoothSDK(self, didReceive: .settingsAck(event))
1461
+ case "version_info":
1462
+ let event = VersionInfoResult(values: data)
1463
+ pendingVersionInfo?.resolve(event)
1464
+ delegate?.mentraBluetoothSDK(self, didReceive: .versionInfo(event))
709
1465
  case "compatible_glasses_search_stop":
710
1466
  delegate?.mentraBluetoothSDK(self, didStopScan: .completed)
711
1467
  case "pair_failure":