@mentra/bluetooth-sdk 3.2.0-dev.200 → 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 (50) hide show
  1. package/README.md +46 -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 +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 +285 -29
  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 +312 -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/test/java/com/mentra/bluetoothsdk/GlassesStatusClassicAudioTest.kt +14 -0
  15. package/android/src/test/java/com/mentra/bluetoothsdk/MessageAckPolicyTest.kt +16 -0
  16. package/android/src/test/java/com/mentra/bluetoothsdk/WifiRequestResolutionTest.kt +271 -0
  17. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioConnectionTrackerTest.kt +131 -0
  18. package/build/BluetoothSdk.types.d.ts +62 -1
  19. package/build/BluetoothSdk.types.d.ts.map +1 -1
  20. package/build/BluetoothSdk.types.js.map +1 -1
  21. package/build/_private/BluetoothSdkModule.d.ts +3 -2
  22. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  23. package/build/_private/BluetoothSdkModule.js.map +1 -1
  24. package/build/generated/changelogCatalog.d.ts +1 -1
  25. package/build/generated/changelogCatalog.js +1 -1
  26. package/build/generated/changelogCatalog.js.map +1 -1
  27. package/build/generated/releaseMetadata.js +5 -5
  28. package/build/generated/releaseMetadata.js.map +1 -1
  29. package/build/index.d.ts +1 -1
  30. package/build/index.d.ts.map +1 -1
  31. package/build/index.js +3 -0
  32. package/build/index.js.map +1 -1
  33. package/ios/BluetoothSdkModule.swift +8 -2
  34. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  35. package/ios/Source/Bridge.swift +63 -0
  36. package/ios/Source/DeviceManager.swift +6 -3
  37. package/ios/Source/GeneratedChangelogCatalog.swift +1 -1
  38. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  39. package/ios/Source/MentraBluetoothSDK.swift +365 -93
  40. package/ios/Source/WifiRequestResolution.swift +251 -0
  41. package/ios/Source/sgcs/MentraLive.swift +170 -35
  42. package/ios/Source/sgcs/SGCManager.swift +13 -1
  43. package/ios/Source/status/DeviceStatus.swift +28 -0
  44. package/ios/Tests/WifiRequestResolutionTests.swift +320 -0
  45. package/package.json +1 -1
  46. package/src/BluetoothSdk.types.ts +76 -1
  47. package/src/_private/BluetoothSdkModule.ts +4 -1
  48. package/src/generated/changelogCatalog.ts +1 -1
  49. package/src/generated/releaseMetadata.ts +5 -5
  50. 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,24 +57,71 @@ 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
+ ) {
73
69
  self.ssid = ssid
74
70
  self.pending = pending
75
71
  }
76
72
  }
77
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
+ ) {
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
120
+ self.pending = pending
121
+ self.mode = mode
122
+ }
123
+ }
124
+
78
125
  @MainActor
79
126
  private final class PendingHotspotStatusRequest {
80
127
  let enabled: Bool
@@ -112,17 +159,17 @@ private final class PendingVersionInfoRequest {
112
159
  }
113
160
  }
114
161
 
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.
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.
118
165
  @MainActor
119
166
  private final class PendingStreamStart {
120
167
  let seq: Int
121
168
  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.
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.
126
173
  var lastError: StreamStatusEvent?
127
174
 
128
175
  init(seq: Int, pending: PendingResponse<StreamStatusEvent>) {
@@ -132,7 +179,7 @@ private final class PendingStreamStart {
132
179
  }
133
180
 
134
181
  @MainActor
135
- private final class PendingResponse<T> {
182
+ final class PendingResponse<T> {
136
183
  private let operation: String
137
184
  private var continuation: CheckedContinuation<T, Error>?
138
185
  private var timeoutTask: Task<Void, Never>?
@@ -161,22 +208,39 @@ private final class PendingResponse<T> {
161
208
  continuation = nil
162
209
  }
163
210
 
164
- 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
+ }
165
215
  if let result {
166
216
  return try result.get()
167
217
  }
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
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
+ }
175
235
  }
176
- self?.reject(
236
+ }
237
+ } onCancel: {
238
+ Task { @MainActor [weak self] in
239
+ guard let self else { return }
240
+ reject(
177
241
  BluetoothSdkError(
178
- code: "request_timeout",
179
- message: "\(self?.operation ?? "Request") timed out waiting for glasses response."
242
+ code: "request_cancelled",
243
+ message: "\(operation) was cancelled."
180
244
  )
181
245
  )
182
246
  }
@@ -186,12 +250,12 @@ private final class PendingResponse<T> {
186
250
 
187
251
  @MainActor
188
252
  public final class MentraBluetoothSDK {
189
- private static let wifiScanTimeoutMs = 20_000
253
+ private static let wifiScanTimeoutMs = 20000
190
254
  // A photo response is terminal only after capture, encoding, transport, and upload.
191
255
  // 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
256
+ private static let photoRequestTimeoutMs = 30000
257
+ private static let otaBesVersionWaitMs = 5000
258
+ private static let otaMtkVersionWaitMs = 2000
195
259
  private static let otaVersionPollMs = 100
196
260
  private static let defaultStreamKeepAliveIntervalSeconds = 5
197
261
 
@@ -227,10 +291,13 @@ public final class MentraBluetoothSDK {
227
291
  private var pendingOtaQuery: PendingResponse<OtaQueryResult>?
228
292
  private var pendingOtaStart: PendingResponse<OtaStartAckEvent>?
229
293
  private var pendingWifiScan: PendingWifiScan?
294
+ private var pendingSavedWifiNetworks: PendingSavedWifiNetworks?
230
295
  private var wifiScanTask: Task<[WifiScanResult], Error>?
231
296
  private var pendingWifiStatus: PendingWifiStatusRequest?
297
+ private var pendingWifiForget: PendingWifiForgetRequest?
232
298
  private var pendingHotspotStatus: PendingHotspotStatusRequest?
233
299
  private var pendingVersionInfo: PendingVersionInfoRequest?
300
+ private let wifiSessionCapabilities = WifiSessionCapabilities()
234
301
  private var configuredOtaVersionUrl: String?
235
302
 
236
303
  public init(configuration: MentraBluetoothSDKConfiguration = .default) {
@@ -620,7 +687,8 @@ public final class MentraBluetoothSDK {
620
687
  for key in ["button_photo_zsl_mfnr", "button_photo_mfnr", "button_photo_zsl", "button_photo_noise_reduction",
621
688
  "button_photo_edge_enhancement", "button_photo_isp_digital_gain",
622
689
  "button_photo_isp_analog_gain", "button_photo_ae_exposure_divisor",
623
- "button_photo_iso_cap", "button_photo_compress", "button_photo_sound"] {
690
+ "button_photo_iso_cap", "button_photo_compress", "button_photo_sound"]
691
+ {
624
692
  DeviceStore.shared.remove(cat, key)
625
693
  }
626
694
  }
@@ -874,7 +942,8 @@ public final class MentraBluetoothSDK {
874
942
  return try await pending.wait(timeoutMs: MentraBluetoothSDK.wifiScanTimeoutMs)
875
943
  } catch {
876
944
  if (error as? BluetoothSdkError)?.code == "request_timeout",
877
- !request.latestResults.isEmpty {
945
+ !request.latestResults.isEmpty
946
+ {
878
947
  return request.latestResults
879
948
  }
880
949
  throw error
@@ -885,14 +954,14 @@ public final class MentraBluetoothSDK {
885
954
  }
886
955
 
887
956
  public func sendWifiCredentials(ssid: String, password: String) async throws -> WifiStatusEvent {
888
- guard pendingWifiStatus == nil else {
957
+ guard pendingWifiStatus == nil, pendingWifiForget == nil else {
889
958
  throw BluetoothSdkError(
890
959
  code: "request_in_flight",
891
960
  message: "A WiFi status command is already waiting for a glasses response."
892
961
  )
893
962
  }
894
963
  let pending = PendingResponse<WifiStatusEvent>(operation: "WiFi connect request")
895
- pendingWifiStatus = PendingWifiStatusRequest(operation: .connect, ssid: ssid, pending: pending)
964
+ pendingWifiStatus = PendingWifiStatusRequest(ssid: ssid, pending: pending)
896
965
  DeviceManager.shared.sendWifiCredentials(ssid, password)
897
966
  do {
898
967
  let event = try await pending.wait()
@@ -908,25 +977,83 @@ public final class MentraBluetoothSDK {
908
977
  }
909
978
  }
910
979
 
911
- public func forgetWifiNetwork(ssid: String) async throws -> WifiStatusEvent {
912
- 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 {
913
985
  throw BluetoothSdkError(
914
986
  code: "request_in_flight",
915
987
  message: "A WiFi status command is already waiting for a glasses response."
916
988
  )
917
989
  }
918
- let pending = PendingResponse<WifiStatusEvent>(operation: "WiFi forget request")
919
- pendingWifiStatus = PendingWifiStatusRequest(operation: .forget, ssid: ssid, pending: pending)
920
- 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)
921
1002
  do {
922
1003
  let event = try await pending.wait()
923
- if pendingWifiStatus?.pending === pending {
924
- pendingWifiStatus = nil
1004
+ if pendingWifiForget === request {
1005
+ pendingWifiForget = nil
925
1006
  }
926
1007
  return event
927
1008
  } catch {
928
- if pendingWifiStatus?.pending === pending {
929
- 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.")
930
1057
  }
931
1058
  throw error
932
1059
  }
@@ -1075,7 +1202,7 @@ public final class MentraBluetoothSDK {
1075
1202
  stopStreamKeepAliveMonitor()
1076
1203
  DeviceManager.shared.startStream(values)
1077
1204
  do {
1078
- let event = try await pending.wait(timeoutMs: 30_000)
1205
+ let event = try await pending.wait(timeoutMs: 30000)
1079
1206
  pendingStreamStarts.removeValue(forKey: streamId)
1080
1207
  if startSdkKeepAlive {
1081
1208
  startStreamKeepAliveMonitor(
@@ -1134,7 +1261,7 @@ public final class MentraBluetoothSDK {
1134
1261
  stopStreamKeepAliveMonitor()
1135
1262
  DeviceManager.shared.stopStream()
1136
1263
  do {
1137
- let event = try await pending.wait(timeoutMs: 15_000)
1264
+ let event = try await pending.wait(timeoutMs: 15000)
1138
1265
  if pendingStreamStop?.pending === pending {
1139
1266
  pendingStreamStop = nil
1140
1267
  }
@@ -1206,7 +1333,7 @@ public final class MentraBluetoothSDK {
1206
1333
  )
1207
1334
  DeviceManager.shared.stopVideoRecording(requestId, webhookUrl, authToken)
1208
1335
  do {
1209
- let timeoutMs = waitForUpload ? videoUploadStopTimeoutMs : 15_000
1336
+ let timeoutMs = waitForUpload ? videoUploadStopTimeoutMs : 15000
1210
1337
  let event = try await pending.wait(timeoutMs: timeoutMs)
1211
1338
  pendingVideoRecordingRequests.removeValue(forKey: requestId)
1212
1339
  return event
@@ -1397,7 +1524,9 @@ public final class MentraBluetoothSDK {
1397
1524
  try await startOtaCommand(otaVersionUrl: otaVersionUrl)
1398
1525
  }
1399
1526
 
1400
- func sendOtaQueryStatus() async throws -> OtaQueryResult { try await queryOtaStatus() }
1527
+ func sendOtaQueryStatus() async throws -> OtaQueryResult {
1528
+ try await queryOtaStatus()
1529
+ }
1401
1530
 
1402
1531
  func startAr99OtaFromFile(_ path: String) throws -> Bool {
1403
1532
  try requireGlassesConnected(operation: "start AR99 OTA")
@@ -1461,7 +1590,7 @@ public final class MentraBluetoothSDK {
1461
1590
  timeoutMs: Int,
1462
1591
  isReady: (GlassesStatus) -> Bool
1463
1592
  ) async -> GlassesStatus {
1464
- let deadline = Date().addingTimeInterval(Double(timeoutMs) / 1_000)
1593
+ let deadline = Date().addingTimeInterval(Double(timeoutMs) / 1000)
1465
1594
  var status = initialStatus
1466
1595
  while Date() < deadline {
1467
1596
  status = glassesStatus
@@ -1469,7 +1598,7 @@ public final class MentraBluetoothSDK {
1469
1598
  return status
1470
1599
  }
1471
1600
 
1472
- let remainingMs = max(0, Int(deadline.timeIntervalSinceNow * 1_000))
1601
+ let remainingMs = max(0, Int(deadline.timeIntervalSinceNow * 1000))
1473
1602
  let sleepMs = min(Self.otaVersionPollMs, remainingMs)
1474
1603
  if sleepMs <= 0 {
1475
1604
  break
@@ -1511,6 +1640,7 @@ public final class MentraBluetoothSDK {
1511
1640
 
1512
1641
  public func invalidate() {
1513
1642
  stopStreamKeepAliveMonitor()
1643
+ resetWifiProtocolSession(sessionId: "", code: "sdk_closed")
1514
1644
  if let bluetoothAvailabilityListenerId {
1515
1645
  BluetoothAvailability.shared.removeStateListener(bluetoothAvailabilityListenerId)
1516
1646
  self.bluetoothAvailabilityListenerId = nil
@@ -1756,13 +1886,13 @@ public final class MentraBluetoothSDK {
1756
1886
  }
1757
1887
  }
1758
1888
 
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.
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.
1766
1896
  private func rejectPreemptedStreamStarts(winnerSeq: Int) {
1767
1897
  for (streamId, start) in pendingStreamStarts where start.seq < winnerSeq {
1768
1898
  pendingStreamStarts.removeValue(forKey: streamId)
@@ -1776,11 +1906,11 @@ public final class MentraBluetoothSDK {
1776
1906
  }
1777
1907
  }
1778
1908
 
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.
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.
1784
1914
  private func rejectStreamStartsSuperseded(byStopSeq stopSeq: Int) {
1785
1915
  for (streamId, start) in pendingStreamStarts where start.seq < stopSeq {
1786
1916
  pendingStreamStarts.removeValue(forKey: streamId)
@@ -1982,9 +2112,9 @@ public final class MentraBluetoothSDK {
1982
2112
  request.pending.resolve(results)
1983
2113
  }
1984
2114
 
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.
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.
1988
2118
  private func handleCorrelatedWifiScanChunk(
1989
2119
  scanId: String,
1990
2120
  results: [WifiScanResult],
@@ -2010,49 +2140,167 @@ public final class MentraBluetoothSDK {
2010
2140
  }
2011
2141
 
2012
2142
  private func handleWifiStatusForRequests(_ event: WifiStatusEvent) {
2013
- guard let request = pendingWifiStatus else { return }
2143
+ let connectRequest = pendingWifiStatus
2014
2144
  // A wifi_status carrying the explicit error field is the glasses' failure
2015
2145
  // verdict for the in-flight connect: reject now instead of running out the
2016
2146
  // request timeout. Only the error field counts as failure — the glasses'
2017
2147
  // connect sequence emits a debounced bare connected=false ~1-2s after
2018
2148
  // credentials while association is still in progress, and rejecting on that
2019
2149
  // would kill every connect attempt early.
2020
- if request.operation == .connect, let error = event.error {
2021
- if pendingWifiStatus === request {
2150
+ if let connectRequest, let error = event.error {
2151
+ if pendingWifiStatus === connectRequest {
2022
2152
  pendingWifiStatus = nil
2023
2153
  }
2024
- request.pending.reject(
2154
+ connectRequest.pending.reject(
2025
2155
  BluetoothSdkError(
2026
2156
  code: error,
2027
- message: "Glasses failed to join \"\(request.ssid)\": \(error)"
2157
+ message: "Glasses failed to join \"\(connectRequest.ssid)\": \(error)"
2028
2158
  )
2029
2159
  )
2030
2160
  return
2031
2161
  }
2032
- guard wifiStatusMatches(event.status, request: request) else { return }
2033
- if pendingWifiStatus === request {
2034
- 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))
2035
2239
  }
2036
- request.pending.resolve(event)
2037
2240
  }
2038
2241
 
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
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)
2052
2280
  }
2053
2281
  }
2054
2282
  }
2055
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
+
2056
2304
  private func handleHotspotStatusForRequests(_ event: HotspotStatusEvent) {
2057
2305
  guard let request = pendingHotspotStatus else { return }
2058
2306
  guard hotspotStatusMatches(event.status, enabled: request.enabled) else { return }
@@ -2085,6 +2333,9 @@ public final class MentraBluetoothSDK {
2085
2333
  private func dispatchStoreUpdate(_ category: String, _ changes: [String: Any]) {
2086
2334
  switch ObservableStore.normalizeCategory(category) {
2087
2335
  case "glasses":
2336
+ if changes["connected"] as? Bool == false {
2337
+ resetWifiProtocolSession(sessionId: "", code: "wifi_session_disconnected")
2338
+ }
2088
2339
  analytics.observeGlassesStatus(glassesStatus)
2089
2340
  let nextState = state
2090
2341
  delegate?.mentraBluetoothSDK(self, didUpdate: nextState)
@@ -2129,7 +2380,8 @@ public final class MentraBluetoothSDK {
2129
2380
  projectName: projectName
2130
2381
  )
2131
2382
  }
2132
- private func dispatchDiscoveredDevices(_ rawSearchResults: Any?) {
2383
+
2384
+ private func dispatchDiscoveredDevices(_ rawSearchResults: Any?) {
2133
2385
  guard let results = rawSearchResults as? [[String: Any]] else { return }
2134
2386
  for result in results {
2135
2387
  guard let name = result["name"] as? String else { continue }
@@ -2251,6 +2503,23 @@ private func dispatchDiscoveredDevices(_ rawSearchResults: Any?) {
2251
2503
  let event = WifiStatusEvent(values: data)
2252
2504
  handleWifiStatusForRequests(event)
2253
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))
2254
2523
  case "wifi_scan_result":
2255
2524
  let networks = (data["networks"] as? [[String: Any]])?.map(WifiScanResult.init(values:)) ?? []
2256
2525
  let hasCompletionFlag = data.keys.contains("scanComplete") || data.keys.contains("scan_complete")
@@ -2332,6 +2601,9 @@ private func dispatchDiscoveredDevices(_ rawSearchResults: Any?) {
2332
2601
  handleSettingsAckForRequests(event)
2333
2602
  delegate?.mentraBluetoothSDK(self, didReceive: .settingsAck(event))
2334
2603
  case "version_info":
2604
+ if let infoType = data["versionInfoType"] as? String, ["version_info_1", "version_info"].contains(infoType) {
2605
+ applyWifiProtocolCapabilities(data)
2606
+ }
2335
2607
  let event = VersionInfoResult(values: data)
2336
2608
  handleVersionInfoForRequest(data)
2337
2609
  delegate?.mentraBluetoothSDK(self, didReceive: .versionInfo(event))