@mentra/bluetooth-sdk 3.2.0-dev.195 → 3.2.0-dev.203

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 (63) hide show
  1. package/README.md +64 -45
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +5 -1
  3. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +77 -1
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +12 -6
  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 +318 -36
  8. package/android/src/main/java/com/mentra/bluetoothsdk/MessageAckPolicy.kt +4 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulator.kt +88 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/WifiRequestResolution.kt +246 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +50 -16
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioConnectionTracker.kt +104 -0
  13. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +626 -120
  14. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattLifecycle.kt +64 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +2 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SerializedGattCallback.kt +76 -0
  17. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +16 -3
  18. package/android/src/test/java/com/mentra/bluetoothsdk/GlassesStatusClassicAudioTest.kt +14 -0
  19. package/android/src/test/java/com/mentra/bluetoothsdk/MessageAckPolicyTest.kt +16 -0
  20. package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoBridgeTest.kt +33 -0
  21. package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulatorTest.kt +169 -0
  22. package/android/src/test/java/com/mentra/bluetoothsdk/WifiRequestResolutionTest.kt +271 -0
  23. package/android/src/test/java/com/mentra/bluetoothsdk/services/ForegroundServiceManifestTest.kt +57 -0
  24. package/android/src/test/java/com/mentra/bluetoothsdk/services/ForegroundServiceTypeTest.kt +52 -0
  25. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioConnectionTrackerTest.kt +131 -0
  26. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCallbackTest.kt +94 -0
  27. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattLifecycleTest.kt +112 -0
  28. package/build/BluetoothSdk.types.d.ts +62 -1
  29. package/build/BluetoothSdk.types.d.ts.map +1 -1
  30. package/build/BluetoothSdk.types.js.map +1 -1
  31. package/build/_private/BluetoothSdkModule.d.ts +3 -2
  32. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  33. package/build/_private/BluetoothSdkModule.js.map +1 -1
  34. package/build/generated/changelogCatalog.d.ts +1 -1
  35. package/build/generated/changelogCatalog.js +1 -1
  36. package/build/generated/changelogCatalog.js.map +1 -1
  37. package/build/generated/releaseMetadata.js +5 -5
  38. package/build/generated/releaseMetadata.js.map +1 -1
  39. package/build/index.d.ts +1 -1
  40. package/build/index.d.ts.map +1 -1
  41. package/build/index.js +3 -0
  42. package/build/index.js.map +1 -1
  43. package/ios/BluetoothSdkModule.swift +8 -2
  44. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  45. package/ios/Source/Bridge.swift +78 -1
  46. package/ios/Source/DeviceManager.swift +13 -6
  47. package/ios/Source/GeneratedChangelogCatalog.swift +1 -1
  48. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  49. package/ios/Source/MentraBluetoothSDK.swift +397 -99
  50. package/ios/Source/WifiRequestResolution.swift +251 -0
  51. package/ios/Source/requests/VersionInfoResponseAccumulator.swift +101 -0
  52. package/ios/Source/sgcs/MentraLive.swift +179 -37
  53. package/ios/Source/sgcs/SGCManager.swift +13 -1
  54. package/ios/Source/status/DeviceStatus.swift +30 -3
  55. package/ios/Tests/VersionInfoBridgeTests.swift +32 -0
  56. package/ios/Tests/VersionInfoResponseAccumulatorTests.swift +181 -0
  57. package/ios/Tests/WifiRequestResolutionTests.swift +320 -0
  58. package/package.json +1 -1
  59. package/src/BluetoothSdk.types.ts +76 -1
  60. package/src/_private/BluetoothSdkModule.ts +4 -1
  61. package/src/generated/changelogCatalog.ts +1 -1
  62. package/src/generated/releaseMetadata.ts +5 -5
  63. package/src/index.ts +11 -0
@@ -8,9 +8,9 @@ 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.
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
14
  var armed = false
15
15
 
16
16
  init(streamId: String, intervalSeconds: Int) {
@@ -47,8 +47,8 @@ private final class PendingWifiScan {
47
47
  let pending: PendingResponse<[WifiScanResult]>
48
48
  let scanId: String
49
49
  var latestResults: [WifiScanResult] = []
50
- // Chunks accumulated from scanId-echoing glasses, deduplicated by SSID;
51
- // resolved only when the glasses flag the scan complete.
50
+ /// Chunks accumulated from scanId-echoing glasses, deduplicated by SSID;
51
+ /// resolved only when the glasses flag the scan complete.
52
52
  var accumulated: [WifiScanResult] = []
53
53
 
54
54
  init(pending: PendingResponse<[WifiScanResult]>, scanId: String) {
@@ -57,21 +57,68 @@ private final class PendingWifiScan {
57
57
  }
58
58
  }
59
59
 
60
- private enum WifiStatusOperation {
61
- case connect
62
- case forget
63
- }
64
-
65
60
  @MainActor
66
61
  private final class PendingWifiStatusRequest {
67
- let operation: WifiStatusOperation
68
62
  let ssid: String
69
63
  let pending: PendingResponse<WifiStatusEvent>
70
64
 
71
- init(operation: WifiStatusOperation, ssid: String, pending: PendingResponse<WifiStatusEvent>) {
72
- self.operation = operation
65
+ init(
66
+ ssid: String,
67
+ pending: PendingResponse<WifiStatusEvent>
68
+ ) {
69
+ self.ssid = ssid
70
+ self.pending = pending
71
+ }
72
+ }
73
+
74
+ @MainActor
75
+ private final class PendingWifiForgetRequest {
76
+ let ssid: String
77
+ let requestId: String
78
+ var sid: String
79
+ let epoch: UInt64
80
+ let pending: PendingResponse<WifiForgetResult>
81
+ var mode: WifiRequestMode
82
+ var commandSent = false
83
+
84
+ init(
85
+ ssid: String,
86
+ requestId: String,
87
+ sid: String,
88
+ epoch: UInt64,
89
+ pending: PendingResponse<WifiForgetResult>,
90
+ mode: WifiRequestMode
91
+ ) {
73
92
  self.ssid = ssid
93
+ self.requestId = requestId
94
+ self.sid = sid
95
+ self.epoch = epoch
96
+ self.pending = pending
97
+ self.mode = mode
98
+ }
99
+ }
100
+
101
+ @MainActor
102
+ private final class PendingSavedWifiNetworks {
103
+ let requestId: String
104
+ var sid: String
105
+ let epoch: UInt64
106
+ let pending: PendingResponse<SavedWifiNetworksResult>
107
+ var mode: WifiRequestMode
108
+ var commandSent = false
109
+
110
+ init(
111
+ requestId: String,
112
+ sid: String,
113
+ epoch: UInt64,
114
+ pending: PendingResponse<SavedWifiNetworksResult>,
115
+ mode: WifiRequestMode
116
+ ) {
117
+ self.requestId = requestId
118
+ self.sid = sid
119
+ self.epoch = epoch
74
120
  self.pending = pending
121
+ self.mode = mode
75
122
  }
76
123
  }
77
124
 
@@ -101,17 +148,28 @@ private final class PendingVideoRecordingRequest {
101
148
  }
102
149
  }
103
150
 
104
- // seq records send order (assigned and handed to the BLE queue in a single
105
- // MainActor turn) so that an id-carrying status for a newer start can
106
- // identify which older in-flight starts it preempted.
151
+ @MainActor
152
+ private final class PendingVersionInfoRequest {
153
+ let pending: PendingResponse<VersionInfoResult>
154
+ let accumulator: VersionInfoResponseAccumulator
155
+
156
+ init(pending: PendingResponse<VersionInfoResult>, requestId: String) {
157
+ self.pending = pending
158
+ accumulator = VersionInfoResponseAccumulator(expectedRequestId: requestId)
159
+ }
160
+ }
161
+
162
+ /// seq records send order (assigned and handed to the BLE queue in a single
163
+ /// MainActor turn) so that an id-carrying status for a newer start can
164
+ /// identify which older in-flight starts it preempted.
107
165
  @MainActor
108
166
  private final class PendingStreamStart {
109
167
  let seq: Int
110
168
  let pending: PendingResponse<StreamStatusEvent>
111
- // Set when an id-carrying error arrives: a fatal publisher error never
112
- // reaches the reconnect machinery and winds down with a streamId-less
113
- // stopped, so the stash both attributes that stopped to this start and
114
- // preserves the real error details for the rejection.
169
+ /// Set when an id-carrying error arrives: a fatal publisher error never
170
+ /// reaches the reconnect machinery and winds down with a streamId-less
171
+ /// stopped, so the stash both attributes that stopped to this start and
172
+ /// preserves the real error details for the rejection.
115
173
  var lastError: StreamStatusEvent?
116
174
 
117
175
  init(seq: Int, pending: PendingResponse<StreamStatusEvent>) {
@@ -121,7 +179,7 @@ private final class PendingStreamStart {
121
179
  }
122
180
 
123
181
  @MainActor
124
- private final class PendingResponse<T> {
182
+ final class PendingResponse<T> {
125
183
  private let operation: String
126
184
  private var continuation: CheckedContinuation<T, Error>?
127
185
  private var timeoutTask: Task<Void, Never>?
@@ -150,22 +208,39 @@ private final class PendingResponse<T> {
150
208
  continuation = nil
151
209
  }
152
210
 
153
- func wait(timeoutMs: Int = 15_000) async throws -> T {
211
+ func wait(timeoutMs: Int? = 15000) async throws -> T {
212
+ if Task.isCancelled {
213
+ throw BluetoothSdkError(code: "request_cancelled", message: "\(operation) was cancelled.")
214
+ }
154
215
  if let result {
155
216
  return try result.get()
156
217
  }
157
- return try await withCheckedThrowingContinuation { continuation in
158
- self.continuation = continuation
159
- timeoutTask = Task { @MainActor [weak self] in
160
- do {
161
- try await Task.sleep(nanoseconds: UInt64(timeoutMs) * 1_000_000)
162
- } catch {
163
- return
218
+ return try await withTaskCancellationHandler {
219
+ try await withCheckedThrowingContinuation { continuation in
220
+ self.continuation = continuation
221
+ if let timeoutMs {
222
+ timeoutTask = Task { @MainActor [weak self] in
223
+ do {
224
+ try await Task.sleep(nanoseconds: UInt64(timeoutMs) * 1_000_000)
225
+ } catch {
226
+ return
227
+ }
228
+ self?.reject(
229
+ BluetoothSdkError(
230
+ code: "request_timeout",
231
+ message: "\(self?.operation ?? "Request") timed out waiting for glasses response."
232
+ )
233
+ )
234
+ }
164
235
  }
165
- self?.reject(
236
+ }
237
+ } onCancel: {
238
+ Task { @MainActor [weak self] in
239
+ guard let self else { return }
240
+ reject(
166
241
  BluetoothSdkError(
167
- code: "request_timeout",
168
- message: "\(self?.operation ?? "Request") timed out waiting for glasses response."
242
+ code: "request_cancelled",
243
+ message: "\(operation) was cancelled."
169
244
  )
170
245
  )
171
246
  }
@@ -175,12 +250,12 @@ private final class PendingResponse<T> {
175
250
 
176
251
  @MainActor
177
252
  public final class MentraBluetoothSDK {
178
- private static let wifiScanTimeoutMs = 20_000
253
+ private static let wifiScanTimeoutMs = 20000
179
254
  // A photo response is terminal only after capture, encoding, transport, and upload.
180
255
  // Max-quality BLE fallback can legitimately exceed the generic command deadline.
181
- private static let photoRequestTimeoutMs = 30_000
182
- private static let otaBesVersionWaitMs = 5_000
183
- private static let otaMtkVersionWaitMs = 2_000
256
+ private static let photoRequestTimeoutMs = 30000
257
+ private static let otaBesVersionWaitMs = 5000
258
+ private static let otaMtkVersionWaitMs = 2000
184
259
  private static let otaVersionPollMs = 100
185
260
  private static let defaultStreamKeepAliveIntervalSeconds = 5
186
261
 
@@ -216,10 +291,13 @@ public final class MentraBluetoothSDK {
216
291
  private var pendingOtaQuery: PendingResponse<OtaQueryResult>?
217
292
  private var pendingOtaStart: PendingResponse<OtaStartAckEvent>?
218
293
  private var pendingWifiScan: PendingWifiScan?
294
+ private var pendingSavedWifiNetworks: PendingSavedWifiNetworks?
219
295
  private var wifiScanTask: Task<[WifiScanResult], Error>?
220
296
  private var pendingWifiStatus: PendingWifiStatusRequest?
297
+ private var pendingWifiForget: PendingWifiForgetRequest?
221
298
  private var pendingHotspotStatus: PendingHotspotStatusRequest?
222
- private var pendingVersionInfo: PendingResponse<VersionInfoResult>?
299
+ private var pendingVersionInfo: PendingVersionInfoRequest?
300
+ private let wifiSessionCapabilities = WifiSessionCapabilities()
223
301
  private var configuredOtaVersionUrl: String?
224
302
 
225
303
  public init(configuration: MentraBluetoothSDKConfiguration = .default) {
@@ -609,7 +687,8 @@ public final class MentraBluetoothSDK {
609
687
  for key in ["button_photo_zsl_mfnr", "button_photo_mfnr", "button_photo_zsl", "button_photo_noise_reduction",
610
688
  "button_photo_edge_enhancement", "button_photo_isp_digital_gain",
611
689
  "button_photo_isp_analog_gain", "button_photo_ae_exposure_divisor",
612
- "button_photo_iso_cap", "button_photo_compress", "button_photo_sound"] {
690
+ "button_photo_iso_cap", "button_photo_compress", "button_photo_sound"]
691
+ {
613
692
  DeviceStore.shared.remove(cat, key)
614
693
  }
615
694
  }
@@ -863,7 +942,8 @@ public final class MentraBluetoothSDK {
863
942
  return try await pending.wait(timeoutMs: MentraBluetoothSDK.wifiScanTimeoutMs)
864
943
  } catch {
865
944
  if (error as? BluetoothSdkError)?.code == "request_timeout",
866
- !request.latestResults.isEmpty {
945
+ !request.latestResults.isEmpty
946
+ {
867
947
  return request.latestResults
868
948
  }
869
949
  throw error
@@ -874,14 +954,14 @@ public final class MentraBluetoothSDK {
874
954
  }
875
955
 
876
956
  public func sendWifiCredentials(ssid: String, password: String) async throws -> WifiStatusEvent {
877
- guard pendingWifiStatus == nil else {
957
+ guard pendingWifiStatus == nil, pendingWifiForget == nil else {
878
958
  throw BluetoothSdkError(
879
959
  code: "request_in_flight",
880
960
  message: "A WiFi status command is already waiting for a glasses response."
881
961
  )
882
962
  }
883
963
  let pending = PendingResponse<WifiStatusEvent>(operation: "WiFi connect request")
884
- pendingWifiStatus = PendingWifiStatusRequest(operation: .connect, ssid: ssid, pending: pending)
964
+ pendingWifiStatus = PendingWifiStatusRequest(ssid: ssid, pending: pending)
885
965
  DeviceManager.shared.sendWifiCredentials(ssid, password)
886
966
  do {
887
967
  let event = try await pending.wait()
@@ -897,25 +977,83 @@ public final class MentraBluetoothSDK {
897
977
  }
898
978
  }
899
979
 
900
- public func forgetWifiNetwork(ssid: String) async throws -> WifiStatusEvent {
901
- guard pendingWifiStatus == nil else {
980
+ public func forgetWifiNetwork(ssid: String) async throws -> WifiForgetResult {
981
+ guard wifiSsidIsValid(ssid) else {
982
+ throw BluetoothSdkError(code: "invalid_ssid", message: "WiFi SSID cannot be empty.")
983
+ }
984
+ guard pendingWifiStatus == nil, pendingWifiForget == nil else {
902
985
  throw BluetoothSdkError(
903
986
  code: "request_in_flight",
904
987
  message: "A WiFi status command is already waiting for a glasses response."
905
988
  )
906
989
  }
907
- let pending = PendingResponse<WifiStatusEvent>(operation: "WiFi forget request")
908
- pendingWifiStatus = PendingWifiStatusRequest(operation: .forget, ssid: ssid, pending: pending)
909
- DeviceManager.shared.forgetWifiNetwork(ssid)
990
+ let pending = PendingResponse<WifiForgetResult>(operation: "WiFi forget request")
991
+ let requestId = "forget-\(UUID().uuidString)"
992
+ let request = PendingWifiForgetRequest(
993
+ ssid: ssid,
994
+ requestId: requestId,
995
+ sid: wifiSessionCapabilities.sessionId,
996
+ epoch: wifiSessionCapabilities.epoch,
997
+ pending: pending,
998
+ mode: wifiSessionCapabilities.forgetMode()
999
+ )
1000
+ pendingWifiForget = request
1001
+ dispatchWifiForgetIfReady(request)
910
1002
  do {
911
1003
  let event = try await pending.wait()
912
- if pendingWifiStatus?.pending === pending {
913
- pendingWifiStatus = nil
1004
+ if pendingWifiForget === request {
1005
+ pendingWifiForget = nil
914
1006
  }
915
1007
  return event
916
1008
  } catch {
917
- if pendingWifiStatus?.pending === pending {
918
- pendingWifiStatus = nil
1009
+ if pendingWifiForget === request {
1010
+ pendingWifiForget = nil
1011
+ }
1012
+ if let sdkError = error as? BluetoothSdkError, sdkError.code == "request_timeout", request.mode == .discovering {
1013
+ throw BluetoothSdkError(code: wifiCapabilityNegotiationTimeoutCode, message: "WiFi capability negotiation timed out.")
1014
+ }
1015
+ throw error
1016
+ }
1017
+ }
1018
+
1019
+ public func getSavedWifiNetworks() async throws -> SavedWifiNetworksResult {
1020
+ guard pendingSavedWifiNetworks == nil else {
1021
+ throw BluetoothSdkError(
1022
+ code: "request_in_flight",
1023
+ message: "A saved WiFi networks request is already waiting for a glasses response."
1024
+ )
1025
+ }
1026
+ let requestId = "saved-\(UUID().uuidString)"
1027
+ let mode = wifiSessionCapabilities.savedNetworksMode()
1028
+ if mode == .legacy || mode == .unsupported {
1029
+ return SavedWifiNetworksResult(
1030
+ outcome: .unsupported,
1031
+ networks: [],
1032
+ error: "saved_wifi_networks_unsupported"
1033
+ )
1034
+ }
1035
+ let pending = PendingResponse<SavedWifiNetworksResult>(operation: "Saved WiFi networks request")
1036
+ let request = PendingSavedWifiNetworks(
1037
+ requestId: requestId,
1038
+ sid: wifiSessionCapabilities.sessionId,
1039
+ epoch: wifiSessionCapabilities.epoch,
1040
+ pending: pending,
1041
+ mode: mode
1042
+ )
1043
+ pendingSavedWifiNetworks = request
1044
+ dispatchSavedWifiNetworksIfReady(request)
1045
+ do {
1046
+ let networks = try await pending.wait()
1047
+ if pendingSavedWifiNetworks === request {
1048
+ pendingSavedWifiNetworks = nil
1049
+ }
1050
+ return networks
1051
+ } catch {
1052
+ if pendingSavedWifiNetworks === request {
1053
+ pendingSavedWifiNetworks = nil
1054
+ }
1055
+ if let sdkError = error as? BluetoothSdkError, sdkError.code == "request_timeout", request.mode == .discovering {
1056
+ throw BluetoothSdkError(code: wifiCapabilityNegotiationTimeoutCode, message: "WiFi capability negotiation timed out.")
919
1057
  }
920
1058
  throw error
921
1059
  }
@@ -1064,7 +1202,7 @@ public final class MentraBluetoothSDK {
1064
1202
  stopStreamKeepAliveMonitor()
1065
1203
  DeviceManager.shared.startStream(values)
1066
1204
  do {
1067
- let event = try await pending.wait(timeoutMs: 30_000)
1205
+ let event = try await pending.wait(timeoutMs: 30000)
1068
1206
  pendingStreamStarts.removeValue(forKey: streamId)
1069
1207
  if startSdkKeepAlive {
1070
1208
  startStreamKeepAliveMonitor(
@@ -1123,7 +1261,7 @@ public final class MentraBluetoothSDK {
1123
1261
  stopStreamKeepAliveMonitor()
1124
1262
  DeviceManager.shared.stopStream()
1125
1263
  do {
1126
- let event = try await pending.wait(timeoutMs: 15_000)
1264
+ let event = try await pending.wait(timeoutMs: 15000)
1127
1265
  if pendingStreamStop?.pending === pending {
1128
1266
  pendingStreamStop = nil
1129
1267
  }
@@ -1195,7 +1333,7 @@ public final class MentraBluetoothSDK {
1195
1333
  )
1196
1334
  DeviceManager.shared.stopVideoRecording(requestId, webhookUrl, authToken)
1197
1335
  do {
1198
- let timeoutMs = waitForUpload ? videoUploadStopTimeoutMs : 15_000
1336
+ let timeoutMs = waitForUpload ? videoUploadStopTimeoutMs : 15000
1199
1337
  let event = try await pending.wait(timeoutMs: timeoutMs)
1200
1338
  pendingVideoRecordingRequests.removeValue(forKey: requestId)
1201
1339
  return event
@@ -1242,17 +1380,19 @@ public final class MentraBluetoothSDK {
1242
1380
  message: "A version info request is already waiting for a glasses response."
1243
1381
  )
1244
1382
  }
1383
+ let requestId = UUID().uuidString
1245
1384
  let pending = PendingResponse<VersionInfoResult>(operation: "version info request")
1246
- pendingVersionInfo = pending
1247
- DeviceManager.shared.requestVersionInfo()
1385
+ let request = PendingVersionInfoRequest(pending: pending, requestId: requestId)
1386
+ pendingVersionInfo = request
1387
+ DeviceManager.shared.requestVersionInfo(requestId: requestId)
1248
1388
  do {
1249
1389
  let status = try await pending.wait()
1250
- if pendingVersionInfo === pending {
1390
+ if pendingVersionInfo === request {
1251
1391
  pendingVersionInfo = nil
1252
1392
  }
1253
1393
  return status
1254
1394
  } catch {
1255
- if pendingVersionInfo === pending {
1395
+ if pendingVersionInfo === request {
1256
1396
  pendingVersionInfo = nil
1257
1397
  }
1258
1398
  throw error
@@ -1384,7 +1524,9 @@ public final class MentraBluetoothSDK {
1384
1524
  try await startOtaCommand(otaVersionUrl: otaVersionUrl)
1385
1525
  }
1386
1526
 
1387
- func sendOtaQueryStatus() async throws -> OtaQueryResult { try await queryOtaStatus() }
1527
+ func sendOtaQueryStatus() async throws -> OtaQueryResult {
1528
+ try await queryOtaStatus()
1529
+ }
1388
1530
 
1389
1531
  func startAr99OtaFromFile(_ path: String) throws -> Bool {
1390
1532
  try requireGlassesConnected(operation: "start AR99 OTA")
@@ -1448,7 +1590,7 @@ public final class MentraBluetoothSDK {
1448
1590
  timeoutMs: Int,
1449
1591
  isReady: (GlassesStatus) -> Bool
1450
1592
  ) async -> GlassesStatus {
1451
- let deadline = Date().addingTimeInterval(Double(timeoutMs) / 1_000)
1593
+ let deadline = Date().addingTimeInterval(Double(timeoutMs) / 1000)
1452
1594
  var status = initialStatus
1453
1595
  while Date() < deadline {
1454
1596
  status = glassesStatus
@@ -1456,7 +1598,7 @@ public final class MentraBluetoothSDK {
1456
1598
  return status
1457
1599
  }
1458
1600
 
1459
- let remainingMs = max(0, Int(deadline.timeIntervalSinceNow * 1_000))
1601
+ let remainingMs = max(0, Int(deadline.timeIntervalSinceNow * 1000))
1460
1602
  let sleepMs = min(Self.otaVersionPollMs, remainingMs)
1461
1603
  if sleepMs <= 0 {
1462
1604
  break
@@ -1498,6 +1640,7 @@ public final class MentraBluetoothSDK {
1498
1640
 
1499
1641
  public func invalidate() {
1500
1642
  stopStreamKeepAliveMonitor()
1643
+ resetWifiProtocolSession(sessionId: "", code: "sdk_closed")
1501
1644
  if let bluetoothAvailabilityListenerId {
1502
1645
  BluetoothAvailability.shared.removeStateListener(bluetoothAvailabilityListenerId)
1503
1646
  self.bluetoothAvailabilityListenerId = nil
@@ -1743,13 +1886,13 @@ public final class MentraBluetoothSDK {
1743
1886
  }
1744
1887
  }
1745
1888
 
1746
- // The glasses process BLE commands FIFO and every start_stream begins by
1747
- // stopping whatever runs, so an id-carrying status proving start X's
1748
- // start path ran on the glasses also proves every lower-seq start has
1749
- // already been preempted. Their only verdict on current firmware is a
1750
- // streamId-less stopped, which the id-less heuristic deliberately
1751
- // ignores — without this they would run out the 30s timeout instead of
1752
- // failing fast.
1889
+ /// The glasses process BLE commands FIFO and every start_stream begins by
1890
+ /// stopping whatever runs, so an id-carrying status proving start X's
1891
+ /// start path ran on the glasses also proves every lower-seq start has
1892
+ /// already been preempted. Their only verdict on current firmware is a
1893
+ /// streamId-less stopped, which the id-less heuristic deliberately
1894
+ /// ignores — without this they would run out the 30s timeout instead of
1895
+ /// failing fast.
1753
1896
  private func rejectPreemptedStreamStarts(winnerSeq: Int) {
1754
1897
  for (streamId, start) in pendingStreamStarts where start.seq < winnerSeq {
1755
1898
  pendingStreamStarts.removeValue(forKey: streamId)
@@ -1763,11 +1906,11 @@ public final class MentraBluetoothSDK {
1763
1906
  }
1764
1907
  }
1765
1908
 
1766
- // The glasses process BLE commands FIFO, so this stop's ack also settles
1767
- // every start sent before it: whatever those starts brought up (or would
1768
- // have brought up) has been stopped, and no further status will arrive
1769
- // for them. Without this they would run out the 30s start timeout.
1770
- // Starts sent after the stop keep waiting for their own statuses.
1909
+ /// The glasses process BLE commands FIFO, so this stop's ack also settles
1910
+ /// every start sent before it: whatever those starts brought up (or would
1911
+ /// have brought up) has been stopped, and no further status will arrive
1912
+ /// for them. Without this they would run out the 30s start timeout.
1913
+ /// Starts sent after the stop keep waiting for their own statuses.
1771
1914
  private func rejectStreamStartsSuperseded(byStopSeq stopSeq: Int) {
1772
1915
  for (streamId, start) in pendingStreamStarts where start.seq < stopSeq {
1773
1916
  pendingStreamStarts.removeValue(forKey: streamId)
@@ -1969,9 +2112,9 @@ public final class MentraBluetoothSDK {
1969
2112
  request.pending.resolve(results)
1970
2113
  }
1971
2114
 
1972
- // scanId-echoing glasses: results for another scan are ignored instead of
1973
- // resolving the pending request, and matching chunks accumulate until the
1974
- // glasses flag the scan complete.
2115
+ /// scanId-echoing glasses: results for another scan are ignored instead of
2116
+ /// resolving the pending request, and matching chunks accumulate until the
2117
+ /// glasses flag the scan complete.
1975
2118
  private func handleCorrelatedWifiScanChunk(
1976
2119
  scanId: String,
1977
2120
  results: [WifiScanResult],
@@ -1997,49 +2140,167 @@ public final class MentraBluetoothSDK {
1997
2140
  }
1998
2141
 
1999
2142
  private func handleWifiStatusForRequests(_ event: WifiStatusEvent) {
2000
- guard let request = pendingWifiStatus else { return }
2143
+ let connectRequest = pendingWifiStatus
2001
2144
  // A wifi_status carrying the explicit error field is the glasses' failure
2002
2145
  // verdict for the in-flight connect: reject now instead of running out the
2003
2146
  // request timeout. Only the error field counts as failure — the glasses'
2004
2147
  // connect sequence emits a debounced bare connected=false ~1-2s after
2005
2148
  // credentials while association is still in progress, and rejecting on that
2006
2149
  // would kill every connect attempt early.
2007
- if request.operation == .connect, let error = event.error {
2008
- if pendingWifiStatus === request {
2150
+ if let connectRequest, let error = event.error {
2151
+ if pendingWifiStatus === connectRequest {
2009
2152
  pendingWifiStatus = nil
2010
2153
  }
2011
- request.pending.reject(
2154
+ connectRequest.pending.reject(
2012
2155
  BluetoothSdkError(
2013
2156
  code: error,
2014
- message: "Glasses failed to join \"\(request.ssid)\": \(error)"
2157
+ message: "Glasses failed to join \"\(connectRequest.ssid)\": \(error)"
2015
2158
  )
2016
2159
  )
2017
2160
  return
2018
2161
  }
2019
- guard wifiStatusMatches(event.status, request: request) else { return }
2020
- if pendingWifiStatus === request {
2021
- pendingWifiStatus = nil
2162
+ if let connectRequest, wifiStatusMatchesConnect(event.status, ssid: connectRequest.ssid) {
2163
+ if pendingWifiStatus === connectRequest {
2164
+ pendingWifiStatus = nil
2165
+ }
2166
+ connectRequest.pending.resolve(event)
2167
+ return
2168
+ }
2169
+
2170
+ // Forget never settles from link state: modern requests require a correlated result,
2171
+ // while legacy requests resolve as unverified at accepted dispatch.
2172
+ }
2173
+
2174
+ private func handleWifiForgetResultForRequests(_ data: [String: Any]) {
2175
+ guard let request = pendingWifiForget,
2176
+ request.mode == .modern,
2177
+ request.epoch == wifiSessionCapabilities.epoch,
2178
+ case let .supported(version) = wifiSessionCapabilities.forgetResult,
2179
+ let result = parseWifiForgetResult(
2180
+ expectedRequestId: request.requestId,
2181
+ expectedSid: request.sid,
2182
+ expectedSsid: request.ssid,
2183
+ capabilityVersion: version,
2184
+ data: data
2185
+ )
2186
+ else { return }
2187
+ if pendingWifiForget === request, request.epoch == wifiSessionCapabilities.epoch {
2188
+ pendingWifiForget = nil
2189
+ }
2190
+ request.pending.resolve(result)
2191
+ }
2192
+
2193
+ private func handleSavedWifiNetworksForRequests(_ data: [String: Any]) {
2194
+ guard let request = pendingSavedWifiNetworks,
2195
+ request.mode == .modern,
2196
+ request.epoch == wifiSessionCapabilities.epoch,
2197
+ case let .supported(version) = wifiSessionCapabilities.savedNetworks,
2198
+ let result = parseSavedWifiNetworks(
2199
+ expectedRequestId: request.requestId,
2200
+ expectedSid: request.sid,
2201
+ capabilityVersion: version,
2202
+ data: data
2203
+ )
2204
+ else { return }
2205
+ if pendingSavedWifiNetworks === request, request.epoch == wifiSessionCapabilities.epoch {
2206
+ pendingSavedWifiNetworks = nil
2207
+ }
2208
+ request.pending.resolve(result)
2209
+ }
2210
+
2211
+ private func wifiStatusMatchesConnect(_ status: WifiStatus, ssid: String) -> Bool {
2212
+ if case let .connected(currentSsid, _) = status {
2213
+ return currentSsid == ssid
2214
+ }
2215
+ return false
2216
+ }
2217
+
2218
+ private func dispatchWifiForgetIfReady(_ request: PendingWifiForgetRequest) {
2219
+ guard pendingWifiForget === request,
2220
+ request.epoch == wifiSessionCapabilities.epoch,
2221
+ request.mode != .discovering,
2222
+ !request.commandSent
2223
+ else { return }
2224
+ if request.mode == .unsupported {
2225
+ request.pending.reject(BluetoothSdkError(code: "wifi_protocol_unsupported", message: "Unsupported WiFi forget protocol version."))
2226
+ return
2227
+ }
2228
+ request.sid = wifiSessionCapabilities.sessionId
2229
+ request.commandSent = DeviceManager.shared.forgetWifiNetwork(
2230
+ request.ssid,
2231
+ requestId: request.mode == .modern ? request.requestId : nil,
2232
+ sid: request.mode == .modern ? request.sid : nil
2233
+ )
2234
+ if !request.commandSent {
2235
+ request.pending.reject(BluetoothSdkError(code: "dispatch_failed", message: "No active glasses transport accepted WiFi forget."))
2236
+ } else if request.mode == .legacy {
2237
+ pendingWifiForget = nil
2238
+ request.pending.resolve(legacyWifiForgetResult(ssid: request.ssid))
2022
2239
  }
2023
- request.pending.resolve(event)
2024
2240
  }
2025
2241
 
2026
- private func wifiStatusMatches(_ status: WifiStatus, request: PendingWifiStatusRequest) -> Bool {
2027
- switch request.operation {
2028
- case .connect:
2029
- if case let .connected(ssid, _) = status {
2030
- return ssid == request.ssid
2031
- }
2032
- return false
2033
- case .forget:
2034
- switch status {
2035
- case .disconnected:
2036
- return true
2037
- case let .connected(ssid, _):
2038
- return ssid != request.ssid
2242
+ private func dispatchSavedWifiNetworksIfReady(_ request: PendingSavedWifiNetworks) {
2243
+ guard pendingSavedWifiNetworks === request,
2244
+ request.epoch == wifiSessionCapabilities.epoch,
2245
+ request.mode == .modern,
2246
+ !request.commandSent
2247
+ else { return }
2248
+ request.sid = wifiSessionCapabilities.sessionId
2249
+ request.commandSent = DeviceManager.shared.requestSavedWifiNetworks(requestId: request.requestId, sid: request.sid)
2250
+ if !request.commandSent {
2251
+ request.pending.reject(BluetoothSdkError(code: "dispatch_failed", message: "No active glasses transport accepted saved WiFi request."))
2252
+ }
2253
+ }
2254
+
2255
+ private func applyWifiProtocolCapabilities(_ data: [String: Any]) {
2256
+ wifiSessionCapabilities.applyVersionInfo1(data)
2257
+ if let request = pendingWifiForget,
2258
+ request.epoch == wifiSessionCapabilities.epoch,
2259
+ request.mode == .discovering
2260
+ {
2261
+ request.mode = wifiSessionCapabilities.forgetMode()
2262
+ dispatchWifiForgetIfReady(request)
2263
+ }
2264
+ if let request = pendingSavedWifiNetworks,
2265
+ request.epoch == wifiSessionCapabilities.epoch,
2266
+ request.mode == .discovering
2267
+ {
2268
+ request.mode = wifiSessionCapabilities.savedNetworksMode()
2269
+ if request.mode == .legacy || request.mode == .unsupported {
2270
+ pendingSavedWifiNetworks = nil
2271
+ request.pending.resolve(
2272
+ SavedWifiNetworksResult(
2273
+ outcome: .unsupported,
2274
+ networks: [],
2275
+ error: "saved_wifi_networks_unsupported"
2276
+ )
2277
+ )
2278
+ } else {
2279
+ dispatchSavedWifiNetworksIfReady(request)
2039
2280
  }
2040
2281
  }
2041
2282
  }
2042
2283
 
2284
+ private func resetWifiProtocolSession(sessionId: String, code: String) {
2285
+ let error = BluetoothSdkError(code: code, message: "The glasses WiFi protocol session changed.")
2286
+ let wifiStatus = pendingWifiStatus?.pending
2287
+ let wifiForget = pendingWifiForget?.pending
2288
+ let savedNetworks = pendingSavedWifiNetworks?.pending
2289
+ let wifiScan = pendingWifiScan?.pending
2290
+ let hotspot = pendingHotspotStatus?.pending
2291
+ wifiSessionCapabilities.reset(sessionId: sessionId)
2292
+ pendingWifiStatus = nil
2293
+ pendingWifiForget = nil
2294
+ pendingSavedWifiNetworks = nil
2295
+ pendingWifiScan = nil
2296
+ pendingHotspotStatus = nil
2297
+ wifiStatus?.reject(error)
2298
+ wifiForget?.reject(error)
2299
+ savedNetworks?.reject(error)
2300
+ wifiScan?.reject(error)
2301
+ hotspot?.reject(error)
2302
+ }
2303
+
2043
2304
  private func handleHotspotStatusForRequests(_ event: HotspotStatusEvent) {
2044
2305
  guard let request = pendingHotspotStatus else { return }
2045
2306
  guard hotspotStatusMatches(event.status, enabled: request.enabled) else { return }
@@ -2072,6 +2333,9 @@ public final class MentraBluetoothSDK {
2072
2333
  private func dispatchStoreUpdate(_ category: String, _ changes: [String: Any]) {
2073
2334
  switch ObservableStore.normalizeCategory(category) {
2074
2335
  case "glasses":
2336
+ if changes["connected"] as? Bool == false {
2337
+ resetWifiProtocolSession(sessionId: "", code: "wifi_session_disconnected")
2338
+ }
2075
2339
  analytics.observeGlassesStatus(glassesStatus)
2076
2340
  let nextState = state
2077
2341
  delegate?.mentraBluetoothSDK(self, didUpdate: nextState)
@@ -2116,7 +2380,8 @@ public final class MentraBluetoothSDK {
2116
2380
  projectName: projectName
2117
2381
  )
2118
2382
  }
2119
- private func dispatchDiscoveredDevices(_ rawSearchResults: Any?) {
2383
+
2384
+ private func dispatchDiscoveredDevices(_ rawSearchResults: Any?) {
2120
2385
  guard let results = rawSearchResults as? [[String: Any]] else { return }
2121
2386
  for result in results {
2122
2387
  guard let name = result["name"] as? String else { continue }
@@ -2175,6 +2440,19 @@ private func dispatchDiscoveredDevices(_ rawSearchResults: Any?) {
2175
2440
  activeSession.onComplete(activeSession.latestResults)
2176
2441
  }
2177
2442
 
2443
+ private func handleVersionInfoForRequest(_ data: [String: Any]) {
2444
+ guard let request = pendingVersionInfo else { return }
2445
+ switch request.accumulator.accept(data) {
2446
+ case .ignored:
2447
+ break
2448
+ case .waiting:
2449
+ break
2450
+ case let .complete(result):
2451
+ pendingVersionInfo = nil
2452
+ request.pending.resolve(result)
2453
+ }
2454
+ }
2455
+
2178
2456
  private func dispatchBridgeEvent(_ eventName: String, _ data: [String: Any]) {
2179
2457
  switch eventName {
2180
2458
  case "log":
@@ -2225,6 +2503,23 @@ private func dispatchDiscoveredDevices(_ rawSearchResults: Any?) {
2225
2503
  let event = WifiStatusEvent(values: data)
2226
2504
  handleWifiStatusForRequests(event)
2227
2505
  delegate?.mentraBluetoothSDK(self, didReceive: .wifiStatus(event))
2506
+ case "wifi_forget_result":
2507
+ handleWifiForgetResultForRequests(data)
2508
+ delegate?.mentraBluetoothSDK(self, didReceive: .raw(name: eventName, values: data))
2509
+ case "saved_wifi_networks":
2510
+ handleSavedWifiNetworksForRequests(data)
2511
+ delegate?.mentraBluetoothSDK(self, didReceive: .raw(name: eventName, values: data))
2512
+ case "wifi_protocol_session_ready":
2513
+ resetWifiProtocolSession(
2514
+ sessionId: data["sid"] as? String ?? "",
2515
+ code: "wifi_session_restarted"
2516
+ )
2517
+ case "glasses_session_changed":
2518
+ resetWifiProtocolSession(
2519
+ sessionId: data["sid"] as? String ?? "",
2520
+ code: "wifi_session_changed"
2521
+ )
2522
+ delegate?.mentraBluetoothSDK(self, didReceive: .raw(name: eventName, values: data))
2228
2523
  case "wifi_scan_result":
2229
2524
  let networks = (data["networks"] as? [[String: Any]])?.map(WifiScanResult.init(values:)) ?? []
2230
2525
  let hasCompletionFlag = data.keys.contains("scanComplete") || data.keys.contains("scan_complete")
@@ -2306,8 +2601,11 @@ private func dispatchDiscoveredDevices(_ rawSearchResults: Any?) {
2306
2601
  handleSettingsAckForRequests(event)
2307
2602
  delegate?.mentraBluetoothSDK(self, didReceive: .settingsAck(event))
2308
2603
  case "version_info":
2604
+ if let infoType = data["versionInfoType"] as? String, ["version_info_1", "version_info"].contains(infoType) {
2605
+ applyWifiProtocolCapabilities(data)
2606
+ }
2309
2607
  let event = VersionInfoResult(values: data)
2310
- pendingVersionInfo?.resolve(event)
2608
+ handleVersionInfoForRequest(data)
2311
2609
  delegate?.mentraBluetoothSDK(self, didReceive: .versionInfo(event))
2312
2610
  case "compatible_glasses_search_stop":
2313
2611
  delegate?.mentraBluetoothSDK(self, didStopScan: .completed)