@mentra/bluetooth-sdk 3.2.0-dev.200 → 3.2.0-dev.206

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 (60) hide show
  1. package/README.md +46 -45
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +5 -9
  3. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +63 -0
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +5 -4
  5. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +1 -1
  6. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  7. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +298 -181
  8. package/android/src/main/java/com/mentra/bluetoothsdk/MessageAckPolicy.kt +4 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/WifiRequestResolution.kt +246 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioConnectionTracker.kt +104 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +326 -45
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +2 -0
  13. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +14 -0
  14. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamControllerProbe.kt +14 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +16 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamSessionState.kt +32 -0
  17. package/android/src/test/java/com/mentra/bluetoothsdk/GlassesStatusClassicAudioTest.kt +14 -0
  18. package/android/src/test/java/com/mentra/bluetoothsdk/MessageAckPolicyTest.kt +16 -0
  19. package/android/src/test/java/com/mentra/bluetoothsdk/WifiRequestResolutionTest.kt +271 -0
  20. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioConnectionTrackerTest.kt +131 -0
  21. package/android/src/test/java/com/mentra/bluetoothsdk/streaming/StreamControllerProbeTest.kt +24 -0
  22. package/android/src/test/java/com/mentra/bluetoothsdk/streaming/StreamSessionStateTest.kt +43 -0
  23. package/build/BluetoothSdk.types.d.ts +68 -2
  24. package/build/BluetoothSdk.types.d.ts.map +1 -1
  25. package/build/BluetoothSdk.types.js.map +1 -1
  26. package/build/_private/BluetoothSdkModule.d.ts +3 -4
  27. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  28. package/build/_private/BluetoothSdkModule.js.map +1 -1
  29. package/build/generated/changelogCatalog.d.ts +1 -1
  30. package/build/generated/changelogCatalog.js +1 -1
  31. package/build/generated/changelogCatalog.js.map +1 -1
  32. package/build/generated/releaseMetadata.js +5 -5
  33. package/build/generated/releaseMetadata.js.map +1 -1
  34. package/build/index.d.ts +1 -1
  35. package/build/index.d.ts.map +1 -1
  36. package/build/index.js +3 -0
  37. package/build/index.js.map +1 -1
  38. package/ios/BluetoothSdkModule.swift +8 -13
  39. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  40. package/ios/Source/Bridge.swift +63 -0
  41. package/ios/Source/DeviceManager.swift +6 -3
  42. package/ios/Source/GeneratedChangelogCatalog.swift +1 -1
  43. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  44. package/ios/Source/MentraBluetoothSDK.swift +372 -220
  45. package/ios/Source/WifiRequestResolution.swift +251 -0
  46. package/ios/Source/sgcs/MentraLive.swift +216 -70
  47. package/ios/Source/sgcs/SGCManager.swift +13 -1
  48. package/ios/Source/status/DeviceStatus.swift +28 -0
  49. package/ios/Source/streaming/StreamControllerProbe.swift +18 -0
  50. package/ios/Source/streaming/StreamModels.swift +12 -0
  51. package/ios/Source/streaming/StreamSessionState.swift +29 -0
  52. package/ios/Tests/StreamControllerProbeTests.swift +27 -0
  53. package/ios/Tests/StreamSessionStateTests.swift +38 -0
  54. package/ios/Tests/WifiRequestResolutionTests.swift +320 -0
  55. package/package.json +1 -1
  56. package/src/BluetoothSdk.types.ts +82 -2
  57. package/src/_private/BluetoothSdkModule.ts +4 -4
  58. package/src/generated/changelogCatalog.ts +1 -1
  59. package/src/generated/releaseMetadata.ts +5 -5
  60. package/src/index.ts +11 -0
@@ -1,24 +1,6 @@
1
1
  import CoreBluetooth
2
2
  import Foundation
3
3
 
4
- @MainActor
5
- private final class ActiveStreamKeepAlive {
6
- let streamId: String
7
- let intervalSeconds: Int
8
- var pendingAckId: String?
9
- var missedAckCount = 0
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
15
-
16
- init(streamId: String, intervalSeconds: Int) {
17
- self.streamId = streamId
18
- self.intervalSeconds = intervalSeconds
19
- }
20
- }
21
-
22
4
  @MainActor
23
5
  private final class ActiveScanSession {
24
6
  let model: DeviceModel
@@ -47,8 +29,8 @@ private final class PendingWifiScan {
47
29
  let pending: PendingResponse<[WifiScanResult]>
48
30
  let scanId: String
49
31
  var latestResults: [WifiScanResult] = []
50
- // Chunks accumulated from scanId-echoing glasses, deduplicated by SSID;
51
- // resolved only when the glasses flag the scan complete.
32
+ /// Chunks accumulated from scanId-echoing glasses, deduplicated by SSID;
33
+ /// resolved only when the glasses flag the scan complete.
52
34
  var accumulated: [WifiScanResult] = []
53
35
 
54
36
  init(pending: PendingResponse<[WifiScanResult]>, scanId: String) {
@@ -57,21 +39,68 @@ private final class PendingWifiScan {
57
39
  }
58
40
  }
59
41
 
60
- private enum WifiStatusOperation {
61
- case connect
62
- case forget
63
- }
64
-
65
42
  @MainActor
66
43
  private final class PendingWifiStatusRequest {
67
- let operation: WifiStatusOperation
68
44
  let ssid: String
69
45
  let pending: PendingResponse<WifiStatusEvent>
70
46
 
71
- init(operation: WifiStatusOperation, ssid: String, pending: PendingResponse<WifiStatusEvent>) {
72
- self.operation = operation
47
+ init(
48
+ ssid: String,
49
+ pending: PendingResponse<WifiStatusEvent>
50
+ ) {
51
+ self.ssid = ssid
52
+ self.pending = pending
53
+ }
54
+ }
55
+
56
+ @MainActor
57
+ private final class PendingWifiForgetRequest {
58
+ let ssid: String
59
+ let requestId: String
60
+ var sid: String
61
+ let epoch: UInt64
62
+ let pending: PendingResponse<WifiForgetResult>
63
+ var mode: WifiRequestMode
64
+ var commandSent = false
65
+
66
+ init(
67
+ ssid: String,
68
+ requestId: String,
69
+ sid: String,
70
+ epoch: UInt64,
71
+ pending: PendingResponse<WifiForgetResult>,
72
+ mode: WifiRequestMode
73
+ ) {
73
74
  self.ssid = ssid
75
+ self.requestId = requestId
76
+ self.sid = sid
77
+ self.epoch = epoch
78
+ self.pending = pending
79
+ self.mode = mode
80
+ }
81
+ }
82
+
83
+ @MainActor
84
+ private final class PendingSavedWifiNetworks {
85
+ let requestId: String
86
+ var sid: String
87
+ let epoch: UInt64
88
+ let pending: PendingResponse<SavedWifiNetworksResult>
89
+ var mode: WifiRequestMode
90
+ var commandSent = false
91
+
92
+ init(
93
+ requestId: String,
94
+ sid: String,
95
+ epoch: UInt64,
96
+ pending: PendingResponse<SavedWifiNetworksResult>,
97
+ mode: WifiRequestMode
98
+ ) {
99
+ self.requestId = requestId
100
+ self.sid = sid
101
+ self.epoch = epoch
74
102
  self.pending = pending
103
+ self.mode = mode
75
104
  }
76
105
  }
77
106
 
@@ -112,17 +141,17 @@ private final class PendingVersionInfoRequest {
112
141
  }
113
142
  }
114
143
 
115
- // seq records send order (assigned and handed to the BLE queue in a single
116
- // MainActor turn) so that an id-carrying status for a newer start can
117
- // identify which older in-flight starts it preempted.
144
+ /// seq records send order (assigned and handed to the BLE queue in a single
145
+ /// MainActor turn) so that an id-carrying status for a newer start can
146
+ /// identify which older in-flight starts it preempted.
118
147
  @MainActor
119
148
  private final class PendingStreamStart {
120
149
  let seq: Int
121
150
  let pending: PendingResponse<StreamStatusEvent>
122
- // Set when an id-carrying error arrives: a fatal publisher error never
123
- // reaches the reconnect machinery and winds down with a streamId-less
124
- // stopped, so the stash both attributes that stopped to this start and
125
- // preserves the real error details for the rejection.
151
+ /// Set when an id-carrying error arrives: a fatal publisher error never
152
+ /// reaches the reconnect machinery and winds down with a streamId-less
153
+ /// stopped, so the stash both attributes that stopped to this start and
154
+ /// preserves the real error details for the rejection.
126
155
  var lastError: StreamStatusEvent?
127
156
 
128
157
  init(seq: Int, pending: PendingResponse<StreamStatusEvent>) {
@@ -132,7 +161,7 @@ private final class PendingStreamStart {
132
161
  }
133
162
 
134
163
  @MainActor
135
- private final class PendingResponse<T> {
164
+ final class PendingResponse<T> {
136
165
  private let operation: String
137
166
  private var continuation: CheckedContinuation<T, Error>?
138
167
  private var timeoutTask: Task<Void, Never>?
@@ -161,22 +190,39 @@ private final class PendingResponse<T> {
161
190
  continuation = nil
162
191
  }
163
192
 
164
- func wait(timeoutMs: Int = 15_000) async throws -> T {
193
+ func wait(timeoutMs: Int? = 15000) async throws -> T {
194
+ if Task.isCancelled {
195
+ throw BluetoothSdkError(code: "request_cancelled", message: "\(operation) was cancelled.")
196
+ }
165
197
  if let result {
166
198
  return try result.get()
167
199
  }
168
- return try await withCheckedThrowingContinuation { continuation in
169
- self.continuation = continuation
170
- timeoutTask = Task { @MainActor [weak self] in
171
- do {
172
- try await Task.sleep(nanoseconds: UInt64(timeoutMs) * 1_000_000)
173
- } catch {
174
- return
200
+ return try await withTaskCancellationHandler {
201
+ try await withCheckedThrowingContinuation { continuation in
202
+ self.continuation = continuation
203
+ if let timeoutMs {
204
+ timeoutTask = Task { @MainActor [weak self] in
205
+ do {
206
+ try await Task.sleep(nanoseconds: UInt64(timeoutMs) * 1_000_000)
207
+ } catch {
208
+ return
209
+ }
210
+ self?.reject(
211
+ BluetoothSdkError(
212
+ code: "request_timeout",
213
+ message: "\(self?.operation ?? "Request") timed out waiting for glasses response."
214
+ )
215
+ )
216
+ }
175
217
  }
176
- self?.reject(
218
+ }
219
+ } onCancel: {
220
+ Task { @MainActor [weak self] in
221
+ guard let self else { return }
222
+ reject(
177
223
  BluetoothSdkError(
178
- code: "request_timeout",
179
- message: "\(self?.operation ?? "Request") timed out waiting for glasses response."
224
+ code: "request_cancelled",
225
+ message: "\(operation) was cancelled."
180
226
  )
181
227
  )
182
228
  }
@@ -186,14 +232,13 @@ private final class PendingResponse<T> {
186
232
 
187
233
  @MainActor
188
234
  public final class MentraBluetoothSDK {
189
- private static let wifiScanTimeoutMs = 20_000
235
+ private static let wifiScanTimeoutMs = 20000
190
236
  // A photo response is terminal only after capture, encoding, transport, and upload.
191
237
  // Max-quality BLE fallback can legitimately exceed the generic command deadline.
192
- private static let photoRequestTimeoutMs = 30_000
193
- private static let otaBesVersionWaitMs = 5_000
194
- private static let otaMtkVersionWaitMs = 2_000
238
+ private static let photoRequestTimeoutMs = 30000
239
+ private static let otaBesVersionWaitMs = 5000
240
+ private static let otaMtkVersionWaitMs = 2000
195
241
  private static let otaVersionPollMs = 100
196
- private static let defaultStreamKeepAliveIntervalSeconds = 5
197
242
 
198
243
  public weak var delegate: MentraBluetoothSDKDelegate?
199
244
 
@@ -210,7 +255,7 @@ public final class MentraBluetoothSDK {
210
255
  private var suppressDefaultDeviceEvents = false
211
256
  private var defaultDeviceApplyGeneration = 0
212
257
  private var activeScanSessions: [UUID: ActiveScanSession] = [:]
213
- private var activeStreamKeepAlive: ActiveStreamKeepAlive?
258
+ private let streamSession = StreamSessionState()
214
259
  private let analytics: BluetoothSdkAnalytics
215
260
  private var pendingPhotoRequests: [String: PendingResponse<PhotoResponseEvent>] = [:]
216
261
  private var pendingCameraStatusRequests: [String: PendingResponse<CameraStatusEvent>] = [:]
@@ -227,10 +272,13 @@ public final class MentraBluetoothSDK {
227
272
  private var pendingOtaQuery: PendingResponse<OtaQueryResult>?
228
273
  private var pendingOtaStart: PendingResponse<OtaStartAckEvent>?
229
274
  private var pendingWifiScan: PendingWifiScan?
275
+ private var pendingSavedWifiNetworks: PendingSavedWifiNetworks?
230
276
  private var wifiScanTask: Task<[WifiScanResult], Error>?
231
277
  private var pendingWifiStatus: PendingWifiStatusRequest?
278
+ private var pendingWifiForget: PendingWifiForgetRequest?
232
279
  private var pendingHotspotStatus: PendingHotspotStatusRequest?
233
280
  private var pendingVersionInfo: PendingVersionInfoRequest?
281
+ private let wifiSessionCapabilities = WifiSessionCapabilities()
234
282
  private var configuredOtaVersionUrl: String?
235
283
 
236
284
  public init(configuration: MentraBluetoothSDKConfiguration = .default) {
@@ -620,7 +668,8 @@ public final class MentraBluetoothSDK {
620
668
  for key in ["button_photo_zsl_mfnr", "button_photo_mfnr", "button_photo_zsl", "button_photo_noise_reduction",
621
669
  "button_photo_edge_enhancement", "button_photo_isp_digital_gain",
622
670
  "button_photo_isp_analog_gain", "button_photo_ae_exposure_divisor",
623
- "button_photo_iso_cap", "button_photo_compress", "button_photo_sound"] {
671
+ "button_photo_iso_cap", "button_photo_compress", "button_photo_sound"]
672
+ {
624
673
  DeviceStore.shared.remove(cat, key)
625
674
  }
626
675
  }
@@ -874,7 +923,8 @@ public final class MentraBluetoothSDK {
874
923
  return try await pending.wait(timeoutMs: MentraBluetoothSDK.wifiScanTimeoutMs)
875
924
  } catch {
876
925
  if (error as? BluetoothSdkError)?.code == "request_timeout",
877
- !request.latestResults.isEmpty {
926
+ !request.latestResults.isEmpty
927
+ {
878
928
  return request.latestResults
879
929
  }
880
930
  throw error
@@ -885,14 +935,14 @@ public final class MentraBluetoothSDK {
885
935
  }
886
936
 
887
937
  public func sendWifiCredentials(ssid: String, password: String) async throws -> WifiStatusEvent {
888
- guard pendingWifiStatus == nil else {
938
+ guard pendingWifiStatus == nil, pendingWifiForget == nil else {
889
939
  throw BluetoothSdkError(
890
940
  code: "request_in_flight",
891
941
  message: "A WiFi status command is already waiting for a glasses response."
892
942
  )
893
943
  }
894
944
  let pending = PendingResponse<WifiStatusEvent>(operation: "WiFi connect request")
895
- pendingWifiStatus = PendingWifiStatusRequest(operation: .connect, ssid: ssid, pending: pending)
945
+ pendingWifiStatus = PendingWifiStatusRequest(ssid: ssid, pending: pending)
896
946
  DeviceManager.shared.sendWifiCredentials(ssid, password)
897
947
  do {
898
948
  let event = try await pending.wait()
@@ -908,25 +958,83 @@ public final class MentraBluetoothSDK {
908
958
  }
909
959
  }
910
960
 
911
- public func forgetWifiNetwork(ssid: String) async throws -> WifiStatusEvent {
912
- guard pendingWifiStatus == nil else {
961
+ public func forgetWifiNetwork(ssid: String) async throws -> WifiForgetResult {
962
+ guard wifiSsidIsValid(ssid) else {
963
+ throw BluetoothSdkError(code: "invalid_ssid", message: "WiFi SSID cannot be empty.")
964
+ }
965
+ guard pendingWifiStatus == nil, pendingWifiForget == nil else {
913
966
  throw BluetoothSdkError(
914
967
  code: "request_in_flight",
915
968
  message: "A WiFi status command is already waiting for a glasses response."
916
969
  )
917
970
  }
918
- let pending = PendingResponse<WifiStatusEvent>(operation: "WiFi forget request")
919
- pendingWifiStatus = PendingWifiStatusRequest(operation: .forget, ssid: ssid, pending: pending)
920
- DeviceManager.shared.forgetWifiNetwork(ssid)
971
+ let pending = PendingResponse<WifiForgetResult>(operation: "WiFi forget request")
972
+ let requestId = "forget-\(UUID().uuidString)"
973
+ let request = PendingWifiForgetRequest(
974
+ ssid: ssid,
975
+ requestId: requestId,
976
+ sid: wifiSessionCapabilities.sessionId,
977
+ epoch: wifiSessionCapabilities.epoch,
978
+ pending: pending,
979
+ mode: wifiSessionCapabilities.forgetMode()
980
+ )
981
+ pendingWifiForget = request
982
+ dispatchWifiForgetIfReady(request)
921
983
  do {
922
984
  let event = try await pending.wait()
923
- if pendingWifiStatus?.pending === pending {
924
- pendingWifiStatus = nil
985
+ if pendingWifiForget === request {
986
+ pendingWifiForget = nil
925
987
  }
926
988
  return event
927
989
  } catch {
928
- if pendingWifiStatus?.pending === pending {
929
- pendingWifiStatus = nil
990
+ if pendingWifiForget === request {
991
+ pendingWifiForget = nil
992
+ }
993
+ if let sdkError = error as? BluetoothSdkError, sdkError.code == "request_timeout", request.mode == .discovering {
994
+ throw BluetoothSdkError(code: wifiCapabilityNegotiationTimeoutCode, message: "WiFi capability negotiation timed out.")
995
+ }
996
+ throw error
997
+ }
998
+ }
999
+
1000
+ public func getSavedWifiNetworks() async throws -> SavedWifiNetworksResult {
1001
+ guard pendingSavedWifiNetworks == nil else {
1002
+ throw BluetoothSdkError(
1003
+ code: "request_in_flight",
1004
+ message: "A saved WiFi networks request is already waiting for a glasses response."
1005
+ )
1006
+ }
1007
+ let requestId = "saved-\(UUID().uuidString)"
1008
+ let mode = wifiSessionCapabilities.savedNetworksMode()
1009
+ if mode == .legacy || mode == .unsupported {
1010
+ return SavedWifiNetworksResult(
1011
+ outcome: .unsupported,
1012
+ networks: [],
1013
+ error: "saved_wifi_networks_unsupported"
1014
+ )
1015
+ }
1016
+ let pending = PendingResponse<SavedWifiNetworksResult>(operation: "Saved WiFi networks request")
1017
+ let request = PendingSavedWifiNetworks(
1018
+ requestId: requestId,
1019
+ sid: wifiSessionCapabilities.sessionId,
1020
+ epoch: wifiSessionCapabilities.epoch,
1021
+ pending: pending,
1022
+ mode: mode
1023
+ )
1024
+ pendingSavedWifiNetworks = request
1025
+ dispatchSavedWifiNetworksIfReady(request)
1026
+ do {
1027
+ let networks = try await pending.wait()
1028
+ if pendingSavedWifiNetworks === request {
1029
+ pendingSavedWifiNetworks = nil
1030
+ }
1031
+ return networks
1032
+ } catch {
1033
+ if pendingSavedWifiNetworks === request {
1034
+ pendingSavedWifiNetworks = nil
1035
+ }
1036
+ if let sdkError = error as? BluetoothSdkError, sdkError.code == "request_timeout", request.mode == .discovering {
1037
+ throw BluetoothSdkError(code: wifiCapabilityNegotiationTimeoutCode, message: "WiFi capability negotiation timed out.")
930
1038
  }
931
1039
  throw error
932
1040
  }
@@ -1054,14 +1162,10 @@ public final class MentraBluetoothSDK {
1054
1162
  }
1055
1163
 
1056
1164
  public func startStream(_ request: StreamRequest) async throws -> StreamStatusEvent {
1057
- try await startStream(request, startSdkKeepAlive: true)
1058
- }
1059
-
1060
- func startExternallyManagedStream(_ request: StreamRequest) async throws -> StreamStatusEvent {
1061
- try await startStream(request, startSdkKeepAlive: false)
1062
- }
1063
-
1064
- private func startStream(_ request: StreamRequest, startSdkKeepAlive: Bool) async throws -> StreamStatusEvent {
1165
+ try requireGlassesConnected(operation: "start stream")
1166
+ guard streamSession.supported else {
1167
+ throw BluetoothSdkError(code: "stream_control_unsupported", message: "Update the glasses software before starting a stream.")
1168
+ }
1065
1169
  var values = request.values
1066
1170
  let streamId = stringValue(values, "streamId").flatMap { $0.isEmpty ? nil : $0 } ?? "sdk-\(UUID().uuidString)"
1067
1171
  values["streamId"] = streamId
@@ -1072,17 +1176,10 @@ public final class MentraBluetoothSDK {
1072
1176
  // glasses' FIFO.
1073
1177
  streamStartSeq += 1
1074
1178
  pendingStreamStarts[streamId] = PendingStreamStart(seq: streamStartSeq, pending: pending)
1075
- stopStreamKeepAliveMonitor()
1076
1179
  DeviceManager.shared.startStream(values)
1077
1180
  do {
1078
- let event = try await pending.wait(timeoutMs: 30_000)
1181
+ let event = try await pending.wait(timeoutMs: 30000)
1079
1182
  pendingStreamStarts.removeValue(forKey: streamId)
1080
- if startSdkKeepAlive {
1081
- startStreamKeepAliveMonitor(
1082
- streamId: streamId,
1083
- intervalSeconds: Self.defaultStreamKeepAliveIntervalSeconds
1084
- )
1085
- }
1086
1183
  return event
1087
1184
  } catch {
1088
1185
  pendingStreamStarts.removeValue(forKey: streamId)
@@ -1090,10 +1187,6 @@ public final class MentraBluetoothSDK {
1090
1187
  }
1091
1188
  }
1092
1189
 
1093
- func sendExternallyManagedStreamKeepAlive(_ request: StreamKeepAliveRequest) {
1094
- DeviceManager.shared.keepStreamAlive(request.values)
1095
- }
1096
-
1097
1190
  public func rgbLedControl(_ request: RgbLedRequest) async throws -> RgbLedControlResponseEvent {
1098
1191
  let pending = PendingResponse<RgbLedControlResponseEvent>(operation: "RGB LED command \(request.requestId)")
1099
1192
  pendingRgbLedRequests[request.requestId] = pending
@@ -1130,11 +1223,10 @@ public final class MentraBluetoothSDK {
1130
1223
  // separate the pending starts the glasses consumed before it (lower
1131
1224
  // seq) from starts sent after it (higher seq).
1132
1225
  streamStartSeq += 1
1133
- pendingStreamStop = (streamId: activeStreamKeepAlive?.streamId, seq: streamStartSeq, pending: pending)
1134
- stopStreamKeepAliveMonitor()
1226
+ pendingStreamStop = (streamId: streamSession.currentStreamId, seq: streamStartSeq, pending: pending)
1135
1227
  DeviceManager.shared.stopStream()
1136
1228
  do {
1137
- let event = try await pending.wait(timeoutMs: 15_000)
1229
+ let event = try await pending.wait(timeoutMs: 15000)
1138
1230
  if pendingStreamStop?.pending === pending {
1139
1231
  pendingStreamStop = nil
1140
1232
  }
@@ -1206,7 +1298,7 @@ public final class MentraBluetoothSDK {
1206
1298
  )
1207
1299
  DeviceManager.shared.stopVideoRecording(requestId, webhookUrl, authToken)
1208
1300
  do {
1209
- let timeoutMs = waitForUpload ? videoUploadStopTimeoutMs : 15_000
1301
+ let timeoutMs = waitForUpload ? videoUploadStopTimeoutMs : 15000
1210
1302
  let event = try await pending.wait(timeoutMs: timeoutMs)
1211
1303
  pendingVideoRecordingRequests.removeValue(forKey: requestId)
1212
1304
  return event
@@ -1397,7 +1489,9 @@ public final class MentraBluetoothSDK {
1397
1489
  try await startOtaCommand(otaVersionUrl: otaVersionUrl)
1398
1490
  }
1399
1491
 
1400
- func sendOtaQueryStatus() async throws -> OtaQueryResult { try await queryOtaStatus() }
1492
+ func sendOtaQueryStatus() async throws -> OtaQueryResult {
1493
+ try await queryOtaStatus()
1494
+ }
1401
1495
 
1402
1496
  func startAr99OtaFromFile(_ path: String) throws -> Bool {
1403
1497
  try requireGlassesConnected(operation: "start AR99 OTA")
@@ -1461,7 +1555,7 @@ public final class MentraBluetoothSDK {
1461
1555
  timeoutMs: Int,
1462
1556
  isReady: (GlassesStatus) -> Bool
1463
1557
  ) async -> GlassesStatus {
1464
- let deadline = Date().addingTimeInterval(Double(timeoutMs) / 1_000)
1558
+ let deadline = Date().addingTimeInterval(Double(timeoutMs) / 1000)
1465
1559
  var status = initialStatus
1466
1560
  while Date() < deadline {
1467
1561
  status = glassesStatus
@@ -1469,7 +1563,7 @@ public final class MentraBluetoothSDK {
1469
1563
  return status
1470
1564
  }
1471
1565
 
1472
- let remainingMs = max(0, Int(deadline.timeIntervalSinceNow * 1_000))
1566
+ let remainingMs = max(0, Int(deadline.timeIntervalSinceNow * 1000))
1473
1567
  let sleepMs = min(Self.otaVersionPollMs, remainingMs)
1474
1568
  if sleepMs <= 0 {
1475
1569
  break
@@ -1510,7 +1604,7 @@ public final class MentraBluetoothSDK {
1510
1604
  }
1511
1605
 
1512
1606
  public func invalidate() {
1513
- stopStreamKeepAliveMonitor()
1607
+ resetWifiProtocolSession(sessionId: "", code: "sdk_closed")
1514
1608
  if let bluetoothAvailabilityListenerId {
1515
1609
  BluetoothAvailability.shared.removeStateListener(bluetoothAvailabilityListenerId)
1516
1610
  self.bluetoothAvailabilityListenerId = nil
@@ -1605,90 +1699,6 @@ public final class MentraBluetoothSDK {
1605
1699
  }
1606
1700
  }
1607
1701
 
1608
- private func startStreamKeepAliveMonitor(streamId: String, intervalSeconds requestedIntervalSeconds: Int) {
1609
- let intervalSeconds = requestedIntervalSeconds > 0 ? requestedIntervalSeconds : Self.defaultStreamKeepAliveIntervalSeconds
1610
- let tracker = ActiveStreamKeepAlive(streamId: streamId, intervalSeconds: intervalSeconds)
1611
- activeStreamKeepAlive = tracker
1612
- sendNextStreamKeepAlive(for: tracker)
1613
- }
1614
-
1615
- private func stopStreamKeepAliveMonitor() {
1616
- activeStreamKeepAlive?.task?.cancel()
1617
- activeStreamKeepAlive = nil
1618
- }
1619
-
1620
- private func sendNextStreamKeepAlive(for tracker: ActiveStreamKeepAlive) {
1621
- guard activeStreamKeepAlive === tracker else { return }
1622
-
1623
- if tracker.armed, tracker.pendingAckId != nil {
1624
- tracker.missedAckCount += 1
1625
- if tracker.missedAckCount >= 3 {
1626
- activeStreamKeepAlive = nil
1627
- tracker.task?.cancel()
1628
- let event = StreamStatusEvent(
1629
- status: .error(
1630
- streamId: tracker.streamId,
1631
- errorDetails: "Stream keep-alive timed out after \(tracker.missedAckCount) missed ACKs",
1632
- timestamp: Int(Date().timeIntervalSince1970 * 1000),
1633
- resolvedConfig: nil
1634
- )
1635
- )
1636
- delegate?.mentraBluetoothSDK(self, didReceive: .streamStatus(event))
1637
- stopStreamKeepAliveMonitor()
1638
- DeviceManager.shared.stopStream()
1639
- return
1640
- }
1641
- }
1642
-
1643
- let ackId = "ack-\(Int(Date().timeIntervalSince1970 * 1000))"
1644
- tracker.pendingAckId = ackId
1645
- DeviceManager.shared.keepStreamAlive(
1646
- StreamKeepAliveRequest(streamId: tracker.streamId, ackId: ackId).values
1647
- )
1648
-
1649
- tracker.task?.cancel()
1650
- tracker.task = Task { @MainActor [weak self, weak tracker] in
1651
- guard let tracker else { return }
1652
- try? await Task.sleep(nanoseconds: UInt64(tracker.intervalSeconds) * 1_000_000_000)
1653
- self?.sendNextStreamKeepAlive(for: tracker)
1654
- }
1655
- }
1656
-
1657
- private func handleStreamKeepAliveAck(_ event: KeepAliveAckEvent) -> Bool {
1658
- guard let tracker = activeStreamKeepAlive,
1659
- event.streamId == tracker.streamId,
1660
- event.ackId == tracker.pendingAckId
1661
- else {
1662
- return false
1663
- }
1664
- tracker.pendingAckId = nil
1665
- tracker.missedAckCount = 0
1666
- return true
1667
- }
1668
-
1669
- private func handleStreamStatusForKeepAlive(_ status: StreamStatus) {
1670
- guard let streamId = status.streamId,
1671
- activeStreamKeepAlive?.streamId == streamId
1672
- else {
1673
- return
1674
- }
1675
-
1676
- switch status.state {
1677
- case .stopped, .stopping, .error, .reconnectFailed:
1678
- stopStreamKeepAliveMonitor()
1679
- default:
1680
- // A non-terminal status means the stream is live or coming up and the glasses can
1681
- // now ACK; arm the missed-ACK detector from here so a slow startup before the first
1682
- // ACK can't trip a false keep-alive timeout. On the arming transition, drop any
1683
- // pre-arm bookkeeping so a stale unacked id can't immediately count as a miss.
1684
- if let tracker = activeStreamKeepAlive, !tracker.armed {
1685
- tracker.armed = true
1686
- tracker.pendingAckId = nil
1687
- tracker.missedAckCount = 0
1688
- }
1689
- }
1690
- }
1691
-
1692
1702
  private func handleStreamStatusForRequests(_ event: StreamStatusEvent) {
1693
1703
  if let (streamId, start) = matchingStreamStart(for: event) {
1694
1704
  // Preemption is only proven by statuses the start path emits after
@@ -1756,13 +1766,13 @@ public final class MentraBluetoothSDK {
1756
1766
  }
1757
1767
  }
1758
1768
 
1759
- // The glasses process BLE commands FIFO and every start_stream begins by
1760
- // stopping whatever runs, so an id-carrying status proving start X's
1761
- // start path ran on the glasses also proves every lower-seq start has
1762
- // already been preempted. Their only verdict on current firmware is a
1763
- // streamId-less stopped, which the id-less heuristic deliberately
1764
- // ignores — without this they would run out the 30s timeout instead of
1765
- // failing fast.
1769
+ /// The glasses process BLE commands FIFO and every start_stream begins by
1770
+ /// stopping whatever runs, so an id-carrying status proving start X's
1771
+ /// start path ran on the glasses also proves every lower-seq start has
1772
+ /// already been preempted. Their only verdict on current firmware is a
1773
+ /// streamId-less stopped, which the id-less heuristic deliberately
1774
+ /// ignores — without this they would run out the 30s timeout instead of
1775
+ /// failing fast.
1766
1776
  private func rejectPreemptedStreamStarts(winnerSeq: Int) {
1767
1777
  for (streamId, start) in pendingStreamStarts where start.seq < winnerSeq {
1768
1778
  pendingStreamStarts.removeValue(forKey: streamId)
@@ -1776,11 +1786,11 @@ public final class MentraBluetoothSDK {
1776
1786
  }
1777
1787
  }
1778
1788
 
1779
- // The glasses process BLE commands FIFO, so this stop's ack also settles
1780
- // every start sent before it: whatever those starts brought up (or would
1781
- // have brought up) has been stopped, and no further status will arrive
1782
- // for them. Without this they would run out the 30s start timeout.
1783
- // Starts sent after the stop keep waiting for their own statuses.
1789
+ /// The glasses process BLE commands FIFO, so this stop's ack also settles
1790
+ /// every start sent before it: whatever those starts brought up (or would
1791
+ /// have brought up) has been stopped, and no further status will arrive
1792
+ /// for them. Without this they would run out the 30s start timeout.
1793
+ /// Starts sent after the stop keep waiting for their own statuses.
1784
1794
  private func rejectStreamStartsSuperseded(byStopSeq stopSeq: Int) {
1785
1795
  for (streamId, start) in pendingStreamStarts where start.seq < stopSeq {
1786
1796
  pendingStreamStarts.removeValue(forKey: streamId)
@@ -1982,9 +1992,9 @@ public final class MentraBluetoothSDK {
1982
1992
  request.pending.resolve(results)
1983
1993
  }
1984
1994
 
1985
- // scanId-echoing glasses: results for another scan are ignored instead of
1986
- // resolving the pending request, and matching chunks accumulate until the
1987
- // glasses flag the scan complete.
1995
+ /// scanId-echoing glasses: results for another scan are ignored instead of
1996
+ /// resolving the pending request, and matching chunks accumulate until the
1997
+ /// glasses flag the scan complete.
1988
1998
  private func handleCorrelatedWifiScanChunk(
1989
1999
  scanId: String,
1990
2000
  results: [WifiScanResult],
@@ -2010,49 +2020,167 @@ public final class MentraBluetoothSDK {
2010
2020
  }
2011
2021
 
2012
2022
  private func handleWifiStatusForRequests(_ event: WifiStatusEvent) {
2013
- guard let request = pendingWifiStatus else { return }
2023
+ let connectRequest = pendingWifiStatus
2014
2024
  // A wifi_status carrying the explicit error field is the glasses' failure
2015
2025
  // verdict for the in-flight connect: reject now instead of running out the
2016
2026
  // request timeout. Only the error field counts as failure — the glasses'
2017
2027
  // connect sequence emits a debounced bare connected=false ~1-2s after
2018
2028
  // credentials while association is still in progress, and rejecting on that
2019
2029
  // would kill every connect attempt early.
2020
- if request.operation == .connect, let error = event.error {
2021
- if pendingWifiStatus === request {
2030
+ if let connectRequest, let error = event.error {
2031
+ if pendingWifiStatus === connectRequest {
2022
2032
  pendingWifiStatus = nil
2023
2033
  }
2024
- request.pending.reject(
2034
+ connectRequest.pending.reject(
2025
2035
  BluetoothSdkError(
2026
2036
  code: error,
2027
- message: "Glasses failed to join \"\(request.ssid)\": \(error)"
2037
+ message: "Glasses failed to join \"\(connectRequest.ssid)\": \(error)"
2028
2038
  )
2029
2039
  )
2030
2040
  return
2031
2041
  }
2032
- guard wifiStatusMatches(event.status, request: request) else { return }
2033
- if pendingWifiStatus === request {
2034
- pendingWifiStatus = nil
2042
+ if let connectRequest, wifiStatusMatchesConnect(event.status, ssid: connectRequest.ssid) {
2043
+ if pendingWifiStatus === connectRequest {
2044
+ pendingWifiStatus = nil
2045
+ }
2046
+ connectRequest.pending.resolve(event)
2047
+ return
2048
+ }
2049
+
2050
+ // Forget never settles from link state: modern requests require a correlated result,
2051
+ // while legacy requests resolve as unverified at accepted dispatch.
2052
+ }
2053
+
2054
+ private func handleWifiForgetResultForRequests(_ data: [String: Any]) {
2055
+ guard let request = pendingWifiForget,
2056
+ request.mode == .modern,
2057
+ request.epoch == wifiSessionCapabilities.epoch,
2058
+ case let .supported(version) = wifiSessionCapabilities.forgetResult,
2059
+ let result = parseWifiForgetResult(
2060
+ expectedRequestId: request.requestId,
2061
+ expectedSid: request.sid,
2062
+ expectedSsid: request.ssid,
2063
+ capabilityVersion: version,
2064
+ data: data
2065
+ )
2066
+ else { return }
2067
+ if pendingWifiForget === request, request.epoch == wifiSessionCapabilities.epoch {
2068
+ pendingWifiForget = nil
2069
+ }
2070
+ request.pending.resolve(result)
2071
+ }
2072
+
2073
+ private func handleSavedWifiNetworksForRequests(_ data: [String: Any]) {
2074
+ guard let request = pendingSavedWifiNetworks,
2075
+ request.mode == .modern,
2076
+ request.epoch == wifiSessionCapabilities.epoch,
2077
+ case let .supported(version) = wifiSessionCapabilities.savedNetworks,
2078
+ let result = parseSavedWifiNetworks(
2079
+ expectedRequestId: request.requestId,
2080
+ expectedSid: request.sid,
2081
+ capabilityVersion: version,
2082
+ data: data
2083
+ )
2084
+ else { return }
2085
+ if pendingSavedWifiNetworks === request, request.epoch == wifiSessionCapabilities.epoch {
2086
+ pendingSavedWifiNetworks = nil
2087
+ }
2088
+ request.pending.resolve(result)
2089
+ }
2090
+
2091
+ private func wifiStatusMatchesConnect(_ status: WifiStatus, ssid: String) -> Bool {
2092
+ if case let .connected(currentSsid, _) = status {
2093
+ return currentSsid == ssid
2094
+ }
2095
+ return false
2096
+ }
2097
+
2098
+ private func dispatchWifiForgetIfReady(_ request: PendingWifiForgetRequest) {
2099
+ guard pendingWifiForget === request,
2100
+ request.epoch == wifiSessionCapabilities.epoch,
2101
+ request.mode != .discovering,
2102
+ !request.commandSent
2103
+ else { return }
2104
+ if request.mode == .unsupported {
2105
+ request.pending.reject(BluetoothSdkError(code: "wifi_protocol_unsupported", message: "Unsupported WiFi forget protocol version."))
2106
+ return
2107
+ }
2108
+ request.sid = wifiSessionCapabilities.sessionId
2109
+ request.commandSent = DeviceManager.shared.forgetWifiNetwork(
2110
+ request.ssid,
2111
+ requestId: request.mode == .modern ? request.requestId : nil,
2112
+ sid: request.mode == .modern ? request.sid : nil
2113
+ )
2114
+ if !request.commandSent {
2115
+ request.pending.reject(BluetoothSdkError(code: "dispatch_failed", message: "No active glasses transport accepted WiFi forget."))
2116
+ } else if request.mode == .legacy {
2117
+ pendingWifiForget = nil
2118
+ request.pending.resolve(legacyWifiForgetResult(ssid: request.ssid))
2035
2119
  }
2036
- request.pending.resolve(event)
2037
2120
  }
2038
2121
 
2039
- private func wifiStatusMatches(_ status: WifiStatus, request: PendingWifiStatusRequest) -> Bool {
2040
- switch request.operation {
2041
- case .connect:
2042
- if case let .connected(ssid, _) = status {
2043
- return ssid == request.ssid
2044
- }
2045
- return false
2046
- case .forget:
2047
- switch status {
2048
- case .disconnected:
2049
- return true
2050
- case let .connected(ssid, _):
2051
- return ssid != request.ssid
2122
+ private func dispatchSavedWifiNetworksIfReady(_ request: PendingSavedWifiNetworks) {
2123
+ guard pendingSavedWifiNetworks === request,
2124
+ request.epoch == wifiSessionCapabilities.epoch,
2125
+ request.mode == .modern,
2126
+ !request.commandSent
2127
+ else { return }
2128
+ request.sid = wifiSessionCapabilities.sessionId
2129
+ request.commandSent = DeviceManager.shared.requestSavedWifiNetworks(requestId: request.requestId, sid: request.sid)
2130
+ if !request.commandSent {
2131
+ request.pending.reject(BluetoothSdkError(code: "dispatch_failed", message: "No active glasses transport accepted saved WiFi request."))
2132
+ }
2133
+ }
2134
+
2135
+ private func applyWifiProtocolCapabilities(_ data: [String: Any]) {
2136
+ wifiSessionCapabilities.applyVersionInfo1(data)
2137
+ if let request = pendingWifiForget,
2138
+ request.epoch == wifiSessionCapabilities.epoch,
2139
+ request.mode == .discovering
2140
+ {
2141
+ request.mode = wifiSessionCapabilities.forgetMode()
2142
+ dispatchWifiForgetIfReady(request)
2143
+ }
2144
+ if let request = pendingSavedWifiNetworks,
2145
+ request.epoch == wifiSessionCapabilities.epoch,
2146
+ request.mode == .discovering
2147
+ {
2148
+ request.mode = wifiSessionCapabilities.savedNetworksMode()
2149
+ if request.mode == .legacy || request.mode == .unsupported {
2150
+ pendingSavedWifiNetworks = nil
2151
+ request.pending.resolve(
2152
+ SavedWifiNetworksResult(
2153
+ outcome: .unsupported,
2154
+ networks: [],
2155
+ error: "saved_wifi_networks_unsupported"
2156
+ )
2157
+ )
2158
+ } else {
2159
+ dispatchSavedWifiNetworksIfReady(request)
2052
2160
  }
2053
2161
  }
2054
2162
  }
2055
2163
 
2164
+ private func resetWifiProtocolSession(sessionId: String, code: String) {
2165
+ let error = BluetoothSdkError(code: code, message: "The glasses WiFi protocol session changed.")
2166
+ let wifiStatus = pendingWifiStatus?.pending
2167
+ let wifiForget = pendingWifiForget?.pending
2168
+ let savedNetworks = pendingSavedWifiNetworks?.pending
2169
+ let wifiScan = pendingWifiScan?.pending
2170
+ let hotspot = pendingHotspotStatus?.pending
2171
+ wifiSessionCapabilities.reset(sessionId: sessionId)
2172
+ pendingWifiStatus = nil
2173
+ pendingWifiForget = nil
2174
+ pendingSavedWifiNetworks = nil
2175
+ pendingWifiScan = nil
2176
+ pendingHotspotStatus = nil
2177
+ wifiStatus?.reject(error)
2178
+ wifiForget?.reject(error)
2179
+ savedNetworks?.reject(error)
2180
+ wifiScan?.reject(error)
2181
+ hotspot?.reject(error)
2182
+ }
2183
+
2056
2184
  private func handleHotspotStatusForRequests(_ event: HotspotStatusEvent) {
2057
2185
  guard let request = pendingHotspotStatus else { return }
2058
2186
  guard hotspotStatusMatches(event.status, enabled: request.enabled) else { return }
@@ -2085,6 +2213,9 @@ public final class MentraBluetoothSDK {
2085
2213
  private func dispatchStoreUpdate(_ category: String, _ changes: [String: Any]) {
2086
2214
  switch ObservableStore.normalizeCategory(category) {
2087
2215
  case "glasses":
2216
+ if changes["connected"] as? Bool == false {
2217
+ resetWifiProtocolSession(sessionId: "", code: "wifi_session_disconnected")
2218
+ }
2088
2219
  analytics.observeGlassesStatus(glassesStatus)
2089
2220
  let nextState = state
2090
2221
  delegate?.mentraBluetoothSDK(self, didUpdate: nextState)
@@ -2129,7 +2260,8 @@ public final class MentraBluetoothSDK {
2129
2260
  projectName: projectName
2130
2261
  )
2131
2262
  }
2132
- private func dispatchDiscoveredDevices(_ rawSearchResults: Any?) {
2263
+
2264
+ private func dispatchDiscoveredDevices(_ rawSearchResults: Any?) {
2133
2265
  guard let results = rawSearchResults as? [[String: Any]] else { return }
2134
2266
  for result in results {
2135
2267
  guard let name = result["name"] as? String else { continue }
@@ -2251,6 +2383,23 @@ private func dispatchDiscoveredDevices(_ rawSearchResults: Any?) {
2251
2383
  let event = WifiStatusEvent(values: data)
2252
2384
  handleWifiStatusForRequests(event)
2253
2385
  delegate?.mentraBluetoothSDK(self, didReceive: .wifiStatus(event))
2386
+ case "wifi_forget_result":
2387
+ handleWifiForgetResultForRequests(data)
2388
+ delegate?.mentraBluetoothSDK(self, didReceive: .raw(name: eventName, values: data))
2389
+ case "saved_wifi_networks":
2390
+ handleSavedWifiNetworksForRequests(data)
2391
+ delegate?.mentraBluetoothSDK(self, didReceive: .raw(name: eventName, values: data))
2392
+ case "wifi_protocol_session_ready":
2393
+ resetWifiProtocolSession(
2394
+ sessionId: data["sid"] as? String ?? "",
2395
+ code: "wifi_session_restarted"
2396
+ )
2397
+ case "glasses_session_changed":
2398
+ resetWifiProtocolSession(
2399
+ sessionId: data["sid"] as? String ?? "",
2400
+ code: "wifi_session_changed"
2401
+ )
2402
+ delegate?.mentraBluetoothSDK(self, didReceive: .raw(name: eventName, values: data))
2254
2403
  case "wifi_scan_result":
2255
2404
  let networks = (data["networks"] as? [[String: Any]])?.map(WifiScanResult.init(values:)) ?? []
2256
2405
  let hasCompletionFlag = data.keys.contains("scanComplete") || data.keys.contains("scan_complete")
@@ -2300,16 +2449,16 @@ private func dispatchDiscoveredDevices(_ rawSearchResults: Any?) {
2300
2449
  let event = RgbLedControlResponseEvent(values: data)
2301
2450
  handleRgbLedResponseForRequests(event)
2302
2451
  delegate?.mentraBluetoothSDK(self, didReceive: .rgbLedControlResponse(event))
2452
+ case "stream_control_ready":
2453
+ streamSession.ready(sessionId: data["sid"] as? String, controlVersion: data["streamControlVersion"] as? Int)
2303
2454
  case "stream_status":
2455
+ if data["revision"] != nil && !streamSession.accept(data) { return }
2304
2456
  let event = StreamStatusEvent(values: data)
2305
2457
  handleStreamStatusForRequests(event)
2306
- handleStreamStatusForKeepAlive(event.status)
2307
2458
  delegate?.mentraBluetoothSDK(self, didReceive: .streamStatus(event))
2308
2459
  case "keep_alive_ack":
2309
2460
  let event = KeepAliveAckEvent(values: data)
2310
- if !handleStreamKeepAliveAck(event) {
2311
- delegate?.mentraBluetoothSDK(self, didReceive: .keepAliveAck(event))
2312
- }
2461
+ delegate?.mentraBluetoothSDK(self, didReceive: .keepAliveAck(event))
2313
2462
  case "ota_start_ack":
2314
2463
  var values = data
2315
2464
  values["type"] = "ota_start_ack"
@@ -2332,6 +2481,9 @@ private func dispatchDiscoveredDevices(_ rawSearchResults: Any?) {
2332
2481
  handleSettingsAckForRequests(event)
2333
2482
  delegate?.mentraBluetoothSDK(self, didReceive: .settingsAck(event))
2334
2483
  case "version_info":
2484
+ if let infoType = data["versionInfoType"] as? String, ["version_info_1", "version_info"].contains(infoType) {
2485
+ applyWifiProtocolCapabilities(data)
2486
+ }
2335
2487
  let event = VersionInfoResult(values: data)
2336
2488
  handleVersionInfoForRequest(data)
2337
2489
  delegate?.mentraBluetoothSDK(self, didReceive: .versionInfo(event))