@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
@@ -0,0 +1,181 @@
1
+ @testable import MentraBluetoothSDK
2
+ import XCTest
3
+
4
+ final class VersionInfoResponseAccumulatorTests: XCTestCase {
5
+ func testMergesCurrentChunksAndCompletesOnFirmwareChunk() {
6
+ let accumulator = VersionInfoResponseAccumulator(expectedRequestId: "request-1")
7
+
8
+ guard case .waiting = accumulator.accept(
9
+ chunk("version_info_1", "request-1", ["buildNumber": "42"])
10
+ )
11
+ else {
12
+ return XCTFail("Expected first chunk to wait")
13
+ }
14
+
15
+ let outcome = accumulator.accept(
16
+ chunk(
17
+ "version_info_3",
18
+ "request-1",
19
+ [
20
+ "buildNumber": "",
21
+ "besFirmwareVersion": "26.8.27.0",
22
+ "mtkFirmwareVersion": "MentraLive_20260709",
23
+ ]
24
+ )
25
+ )
26
+ guard case let .complete(result) = outcome else {
27
+ return XCTFail("Expected final chunk to complete")
28
+ }
29
+
30
+ XCTAssertEqual(result.buildNumber, "42")
31
+ XCTAssertEqual(result.besFirmwareVersion, "26.8.27.0")
32
+ XCTAssertEqual(result.mtkFirmwareVersion, "MentraLive_20260709")
33
+ }
34
+
35
+ func testIgnoresMismatchedAndTrailingStaleChunks() {
36
+ let accumulator = VersionInfoResponseAccumulator(expectedRequestId: "request-1")
37
+
38
+ guard case .ignored = accumulator.accept(chunk("version_info_1", "other", ["buildNumber": "old"]))
39
+ else {
40
+ return XCTFail("Expected mismatched response to be ignored")
41
+ }
42
+ guard case .ignored = accumulator.accept(chunk("version_info_3", nil, ["besFirmwareVersion": "stale"]))
43
+ else {
44
+ return XCTFail("Expected stale trailing chunk to be ignored")
45
+ }
46
+ }
47
+
48
+ func testDoesNotMixCorrelatedAndUncorrelatedSequences() {
49
+ let accumulator = VersionInfoResponseAccumulator(expectedRequestId: "request-1")
50
+ _ = accumulator.accept(chunk("version_info_1", "request-1", ["buildNumber": "42"]))
51
+
52
+ guard case .ignored = accumulator.accept(
53
+ chunk("version_info_3", nil, ["besFirmwareVersion": "stale"])
54
+ ) else {
55
+ return XCTFail("Expected uncorrelated trailing chunk to be ignored")
56
+ }
57
+
58
+ let outcome = accumulator.accept(
59
+ chunk("version_info_3", "request-1", ["besFirmwareVersion": "current"])
60
+ )
61
+ guard case let .complete(result) = outcome else {
62
+ return XCTFail("Expected correlated final chunk to complete")
63
+ }
64
+ XCTAssertEqual(result.besFirmwareVersion, "current")
65
+ }
66
+
67
+ func testStaleUncorrelatedResponsesCannotReplaceCorrelatedSequence() {
68
+ let accumulator = VersionInfoResponseAccumulator(expectedRequestId: "request-1")
69
+ _ = accumulator.accept(chunk("version_info_1", "request-1", ["buildNumber": "42"]))
70
+
71
+ guard case .ignored = accumulator.accept(chunk("version_info_1", nil, ["buildNumber": "stale"]))
72
+ else {
73
+ return XCTFail("Expected stale first chunk to be ignored")
74
+ }
75
+ guard case .ignored = accumulator.accept(chunk("version_info", nil, ["buildNumber": "legacy"]))
76
+ else {
77
+ return XCTFail("Expected stale legacy response to be ignored")
78
+ }
79
+
80
+ let outcome = accumulator.accept(
81
+ chunk("version_info_3", "request-1", ["besFirmwareVersion": "current"])
82
+ )
83
+ guard case let .complete(result) = outcome else {
84
+ return XCTFail("Expected correlated final chunk to complete")
85
+ }
86
+ XCTAssertEqual(result.buildNumber, "42")
87
+ XCTAssertEqual(result.besFirmwareVersion, "current")
88
+ }
89
+
90
+ func testRepeatedFirstChunkResetsRatherThanMixingResponses() {
91
+ let accumulator = VersionInfoResponseAccumulator(expectedRequestId: "request-1")
92
+ _ = accumulator.accept(chunk("version_info_1", nil, ["appVersion": "old"]))
93
+ _ = accumulator.accept(chunk("version_info_1", nil, ["buildNumber": "43"]))
94
+
95
+ guard case let .complete(result) = accumulator.accept(
96
+ chunk("version_info_3", nil, ["besFirmwareVersion": "new"])
97
+ ) else { return XCTFail("Legacy final chunk must complete immediately") }
98
+ XCTAssertEqual(result.appVersion, "")
99
+ XCTAssertEqual(result.buildNumber, "43")
100
+ XCTAssertEqual(result.besFirmwareVersion, "new")
101
+ }
102
+
103
+ func testLegacySingleMessageCompletesImmediately() {
104
+ let accumulator = VersionInfoResponseAccumulator(expectedRequestId: "request-1")
105
+
106
+ let outcome = accumulator.accept(chunk("version_info", nil, ["buildNumber": "7"]))
107
+ guard case let .complete(result) = outcome else {
108
+ return XCTFail("Expected legacy response to complete")
109
+ }
110
+ XCTAssertEqual(result.buildNumber, "7")
111
+ }
112
+
113
+ func testLegacyFirstChunkDoesNotComplete() {
114
+ let accumulator = VersionInfoResponseAccumulator(expectedRequestId: "request-1")
115
+ guard case .waiting = accumulator.accept(chunk("version_info_1", nil, ["buildNumber": "8"])) else {
116
+ return XCTFail("Partial response must wait for final chunk or deadline")
117
+ }
118
+ }
119
+
120
+ func testModernFinalChunkWaitsForMissingFirstChunk() {
121
+ let accumulator = VersionInfoResponseAccumulator(expectedRequestId: "request-1")
122
+ guard case .waiting = accumulator.accept(chunk("version_info_3", "request-1", ["besFirmwareVersion": "new"])) else {
123
+ return XCTFail("Missing first chunk")
124
+ }
125
+ guard case let .complete(result) = accumulator.accept(chunk("version_info_1", "request-1", ["buildNumber": "42"])) else {
126
+ return XCTFail("Both chunks arrived")
127
+ }
128
+ XCTAssertEqual(result.buildNumber, "42")
129
+ XCTAssertEqual(result.besFirmwareVersion, "new")
130
+ }
131
+
132
+ func testDuplicateDoesNotCountAsMissingChunk() {
133
+ let accumulator = VersionInfoResponseAccumulator(expectedRequestId: "request-1")
134
+ let first = chunk("version_info_1", "request-1", ["buildNumber": "42"])
135
+ _ = accumulator.accept(first)
136
+ guard case .waiting = accumulator.accept(first) else { return XCTFail("Duplicate is not completion") }
137
+ }
138
+
139
+ func testRejectsMalformedModernMetadataAndMixedProcess() {
140
+ let accumulator = VersionInfoResponseAccumulator(expectedRequestId: "request-1")
141
+ let first = chunk("version_info_1", "request-1", ["buildNumber": "42"])
142
+ for (key, value) in [
143
+ VersionInfoResponseAccumulator.responseCountKey: 2.5,
144
+ VersionInfoResponseAccumulator.responseIndexKey: true,
145
+ VersionInfoResponseAccumulator.responseFinalKey: true,
146
+ VersionInfoResponseAccumulator.responseRequestIdKey: "",
147
+ ] as [String: Any] {
148
+ var invalid = first
149
+ invalid[key] = value
150
+ guard case .ignored = accumulator.accept(invalid) else { return XCTFail("Invalid " + key) }
151
+ }
152
+ _ = accumulator.accept(first)
153
+ let last = chunk("version_info_3", "request-1", ["besFirmwareVersion": "new"])
154
+ var restarted = last
155
+ restarted[VersionInfoResponseAccumulator.responseSidKey] = "process-2"
156
+ guard case .ignored = accumulator.accept(restarted) else { return XCTFail("Mixed process") }
157
+ guard case .complete = accumulator.accept(last) else { return XCTFail("Matching final chunk") }
158
+ }
159
+
160
+ func testCorrelatedResponseWithoutCompletenessMetadataCannotComplete() {
161
+ let accumulator = VersionInfoResponseAccumulator(expectedRequestId: "request-1")
162
+ let event: [String: Any] = [
163
+ VersionInfoResponseAccumulator.responseChunkKey: "version_info_3",
164
+ VersionInfoResponseAccumulator.responseRequestIdKey: "request-1",
165
+ ]
166
+ guard case .ignored = accumulator.accept(event) else { return XCTFail("Incomplete modern metadata") }
167
+ }
168
+
169
+ private func chunk(_ type: String, _ requestId: String?, _ values: [String: Any]) -> [String: Any] {
170
+ var event = values
171
+ event[VersionInfoResponseAccumulator.responseChunkKey] = type
172
+ if let requestId {
173
+ event[VersionInfoResponseAccumulator.responseRequestIdKey] = requestId
174
+ event[VersionInfoResponseAccumulator.responseIndexKey] = type == "version_info_1" ? 1 : 2
175
+ event[VersionInfoResponseAccumulator.responseCountKey] = 2
176
+ event[VersionInfoResponseAccumulator.responseFinalKey] = type == "version_info_3"
177
+ event[VersionInfoResponseAccumulator.responseSidKey] = "process-1"
178
+ }
179
+ return event
180
+ }
181
+ }
@@ -0,0 +1,320 @@
1
+ @testable import MentraBluetoothSDK
2
+ import XCTest
3
+
4
+ final class WifiRequestResolutionTests: XCTestCase {
5
+ @MainActor
6
+ func testUnsupportedControllerDoesNotClaimWifiDispatch() {
7
+ let keys = ["fullyBooted", "connected", "connectionState", "micEnabled", "voiceActivityDetectionEnabled", "bluetoothClassicConnected"]
8
+ let snapshot = keys.compactMap { key in DeviceStore.shared.get("glasses", key).map { (key, $0) } }
9
+ defer { for (key, value) in snapshot {
10
+ DeviceStore.shared.apply("glasses", key, value)
11
+ } }
12
+ let controller: SGCManager = Simulated()
13
+ XCTAssertFalse(controller.forgetWifiNetwork("AP", requestId: nil, sid: nil))
14
+ XCTAssertFalse(controller.forgetWifiNetwork("AP", requestId: "request", sid: "sid"))
15
+ XCTAssertFalse(controller.requestSavedWifiNetworks(requestId: "request", sid: "sid"))
16
+ }
17
+
18
+ func testFutureFractionalAndMalformedCapabilitiesAreUnsupportedNotLegacy() {
19
+ for version in [0, 2, -1, 1.5, "1", true, NSNull()] as [Any] {
20
+ let capabilities = WifiSessionCapabilities()
21
+ capabilities.reset(sessionId: "sid")
22
+ capabilities.applyVersionInfo1(["wifiForgetResultVersion": version, "savedWifiNetworksVersion": version])
23
+ XCTAssertEqual(capabilities.forgetMode(), .unsupported)
24
+ XCTAssertEqual(capabilities.savedNetworksMode(), .unsupported)
25
+ }
26
+ let missingSession = WifiSessionCapabilities()
27
+ missingSession.applyVersionInfo1(["wifiForgetResultVersion": 1])
28
+ XCTAssertEqual(missingSession.forgetMode(), .unsupported)
29
+ }
30
+
31
+ func testRawResponseRejectsPartialTuplesAndCoercionBeforeNormalization() {
32
+ let tuple: [String: Any] = ["protocol_version": 1, "requestId": "request", "sid": "sid"]
33
+ XCTAssertTrue(wifiResponseEnvelopeIsValid(tuple, allowLegacy: false))
34
+ for key in tuple.keys {
35
+ XCTAssertFalse(wifiResponseEnvelopeIsValid(tuple.filter { $0.key != key }, allowLegacy: true))
36
+ }
37
+ for version in [0, 2, 1.5, "1", true, NSNull()] as [Any] {
38
+ var malformed = tuple
39
+ malformed["protocol_version"] = version
40
+ XCTAssertFalse(wifiResponseEnvelopeIsValid(malformed, allowLegacy: true))
41
+ }
42
+ XCTAssertFalse(wifiResponseEnvelopeIsValid(tuple.merging(["connected": 0]) { _, new in new }, allowLegacy: true))
43
+ XCTAssertFalse(wifiResponseEnvelopeIsValid(tuple.merging(["dispatched": true]) { _, new in new }, allowLegacy: true))
44
+ XCTAssertTrue(wifiResponseEnvelopeIsValid(["dispatched": true], allowLegacy: true))
45
+ XCTAssertFalse(wifiResponseEnvelopeIsValid(["requestId": "", "dispatched": true], allowLegacy: true))
46
+ XCTAssertFalse(wifiResponseEnvelopeIsValid([:], allowLegacy: false))
47
+ }
48
+
49
+ func testSemanticResultsOmitTransportMetadata() throws {
50
+ let data: [String: Any] = ["requestId": "request", "sid": "sid", "protocolVersion": 1,
51
+ "ssid": "AP", "outcome": "confirmed", "networks": ["AP"]]
52
+ let forbidden: Set = ["mode", "capabilityVersion", "requestId", "sid", "protocolVersion"]
53
+ let forget = try XCTUnwrap(parseWifiForgetResult(expectedRequestId: "request", expectedSid: "sid", expectedSsid: "AP", capabilityVersion: 1, data: data))
54
+ let saved = try XCTUnwrap(parseSavedWifiNetworks(expectedRequestId: "request", expectedSid: "sid", capabilityVersion: 1, data: data))
55
+ XCTAssertTrue(Set(forget.values.keys).isDisjoint(with: forbidden))
56
+ XCTAssertTrue(Set(saved.values.keys).isDisjoint(with: forbidden))
57
+ for version in [1.5, true] as [Any] {
58
+ var malformed = data
59
+ malformed["protocolVersion"] = version
60
+ XCTAssertNil(parseWifiForgetResult(expectedRequestId: "request", expectedSid: "sid", expectedSsid: "AP", capabilityVersion: 1, data: malformed))
61
+ XCTAssertNil(parseSavedWifiNetworks(expectedRequestId: "request", expectedSid: "sid", capabilityVersion: 1, data: malformed))
62
+ }
63
+ }
64
+
65
+ @MainActor
66
+ func testPendingResponseCancellationResumesExactlyOnceWithoutTimeout() async {
67
+ let pending = PendingResponse<Int>(operation: "WiFi capability negotiation")
68
+ let waiter = Task { @MainActor in
69
+ try await pending.wait(timeoutMs: nil)
70
+ }
71
+ await Task.yield()
72
+
73
+ waiter.cancel()
74
+
75
+ do {
76
+ _ = try await waiter.value
77
+ XCTFail("Expected cancellation")
78
+ } catch let error as BluetoothSdkError {
79
+ XCTAssertEqual(error.code, "request_cancelled")
80
+ } catch {
81
+ XCTFail("Unexpected error: \(error)")
82
+ }
83
+ pending.resolve(42)
84
+ do {
85
+ _ = try await pending.wait(timeoutMs: nil)
86
+ XCTFail("Late resolve must not replace the cancellation result")
87
+ } catch let error as BluetoothSdkError {
88
+ XCTAssertEqual(error.code, "request_cancelled")
89
+ } catch {
90
+ XCTFail("Unexpected error: \(error)")
91
+ }
92
+ }
93
+
94
+ func testCapabilitiesAreUnknownPerSessionUntilVersionInfoFinalizesThem() {
95
+ let capabilities = WifiSessionCapabilities()
96
+ capabilities.reset(sessionId: "sid-1")
97
+
98
+ XCTAssertEqual(capabilities.forgetResult, .unknown)
99
+ XCTAssertEqual(capabilities.savedNetworks, .unknown)
100
+ capabilities.applyVersionInfo1(["sid": "sid-1", "wifiForgetResultVersion": 1])
101
+
102
+ XCTAssertEqual(capabilities.forgetResult, .supported(version: 1))
103
+ XCTAssertEqual(capabilities.savedNetworks, .legacy)
104
+ XCTAssertEqual(capabilities.forgetMode(), .modern)
105
+ XCTAssertEqual(capabilities.savedNetworksMode(), .legacy)
106
+ }
107
+
108
+ func testNewSessionResetsCapabilitiesAndAdvancesEpoch() {
109
+ let capabilities = WifiSessionCapabilities()
110
+ capabilities.reset(sessionId: "sid-1")
111
+ capabilities.applyVersionInfo1([
112
+ "wifiForgetResultVersion": 1,
113
+ "savedWifiNetworksVersion": 1,
114
+ ])
115
+ let oldEpoch = capabilities.epoch
116
+
117
+ capabilities.reset(sessionId: "sid-2")
118
+
119
+ XCTAssertGreaterThan(capabilities.epoch, oldEpoch)
120
+ XCTAssertEqual(capabilities.sessionId, "sid-2")
121
+ XCTAssertEqual(capabilities.forgetResult, .unknown)
122
+ XCTAssertEqual(capabilities.savedNetworks, .unknown)
123
+ }
124
+
125
+ func testSsidValidationRejectsBlankInputWithoutChangingIdentity() {
126
+ XCTAssertFalse(wifiSsidIsValid(" "))
127
+ XCTAssertTrue(wifiSsidIsValid(" Field AP "))
128
+ }
129
+
130
+ func testForgetResultRequiresExactIdSessionSsidAndProtocol() {
131
+ let exact: [String: Any] = [
132
+ "requestId": "forget-1",
133
+ "sid": "sid-1",
134
+ "ssid": " Field AP ",
135
+ "protocolVersion": 1,
136
+ "outcome": "dispatched",
137
+ "connected": false,
138
+ ]
139
+
140
+ XCTAssertNil(parseWifiForgetResult(
141
+ expectedRequestId: "other", expectedSid: "sid-1", expectedSsid: " Field AP ",
142
+ capabilityVersion: 1, data: exact
143
+ ))
144
+ XCTAssertNil(parseWifiForgetResult(
145
+ expectedRequestId: "forget-1", expectedSid: "sid-1", expectedSsid: " Field AP ",
146
+ capabilityVersion: 1, data: exact.filter { $0.key != "requestId" }
147
+ ))
148
+ XCTAssertNil(parseWifiForgetResult(
149
+ expectedRequestId: "forget-1", expectedSid: "other", expectedSsid: " Field AP ",
150
+ capabilityVersion: 1, data: exact
151
+ ))
152
+ XCTAssertNil(parseWifiForgetResult(
153
+ expectedRequestId: "forget-1", expectedSid: "sid-1", expectedSsid: "Field AP",
154
+ capabilityVersion: 1, data: exact
155
+ ))
156
+ XCTAssertNil(parseWifiForgetResult(
157
+ expectedRequestId: "forget-1", expectedSid: "sid-1", expectedSsid: " Field AP ",
158
+ capabilityVersion: 2, data: exact
159
+ ))
160
+ XCTAssertEqual(
161
+ parseWifiForgetResult(
162
+ expectedRequestId: "forget-1", expectedSid: "sid-1", expectedSsid: " Field AP ",
163
+ capabilityVersion: 1, data: exact
164
+ )?.outcome,
165
+ .dispatched
166
+ )
167
+ }
168
+
169
+ func testForgetParserPreservesEveryHonestTerminalOutcome() {
170
+ for outcome in ["confirmed", "dispatched", "not_found", "unsupported", "failed"] {
171
+ let parsed = parseWifiForgetResult(
172
+ expectedRequestId: "forget-1",
173
+ expectedSid: "sid-1",
174
+ expectedSsid: "AP",
175
+ capabilityVersion: 1,
176
+ data: [
177
+ "requestId": "forget-1",
178
+ "sid": "sid-1",
179
+ "ssid": "AP",
180
+ "protocolVersion": 1,
181
+ "outcome": outcome,
182
+ ]
183
+ )
184
+ XCTAssertEqual(parsed?.outcome.rawValue, outcome)
185
+ }
186
+ }
187
+
188
+ func testMissingConnectivitySnapshotRemainsUnknown() {
189
+ let parsed = parseWifiForgetResult(
190
+ expectedRequestId: "forget-1",
191
+ expectedSid: "sid-1",
192
+ expectedSsid: "AP",
193
+ capabilityVersion: 1,
194
+ data: [
195
+ "requestId": "forget-1",
196
+ "sid": "sid-1",
197
+ "ssid": "AP",
198
+ "protocolVersion": 1,
199
+ "outcome": "dispatched",
200
+ ]
201
+ )
202
+
203
+ XCTAssertNil(parsed?.connected)
204
+ XCTAssertNil(parsed?.values["connected"])
205
+ }
206
+
207
+ func testLegacyForgetIsExplicitlyUnverified() {
208
+ let result = legacyWifiForgetResult(
209
+ ssid: "AP"
210
+ )
211
+
212
+ XCTAssertTrue(Set(result.values.keys).isDisjoint(with: ["mode", "capabilityVersion", "requestId", "sid"]))
213
+ XCTAssertEqual(result.outcome, .legacyUnverified)
214
+ XCTAssertNil(result.connected)
215
+ XCTAssertNil(result.currentSsid)
216
+ XCTAssertNil(result.localIp)
217
+ }
218
+
219
+ func testSavedListPreservesIdentityAndRequiresCorrelationTuple() {
220
+ let exact: [String: Any] = [
221
+ "requestId": "saved-1",
222
+ "sid": "sid-1",
223
+ "protocolVersion": 1,
224
+ "outcome": "confirmed",
225
+ "networks": [" Field AP ", "", "Field AP", " Field AP "],
226
+ ]
227
+
228
+ XCTAssertNil(parseSavedWifiNetworks(
229
+ expectedRequestId: "other", expectedSid: "sid-1", capabilityVersion: 1, data: exact
230
+ ))
231
+ XCTAssertNil(parseSavedWifiNetworks(
232
+ expectedRequestId: "saved-1", expectedSid: "sid-1", capabilityVersion: 1,
233
+ data: exact.filter { $0.key != "requestId" }
234
+ ))
235
+ XCTAssertNil(parseSavedWifiNetworks(
236
+ expectedRequestId: "saved-1", expectedSid: "other", capabilityVersion: 1, data: exact
237
+ ))
238
+ XCTAssertNil(parseSavedWifiNetworks(
239
+ expectedRequestId: "saved-1", expectedSid: "sid-1", capabilityVersion: 2, data: exact
240
+ ))
241
+ XCTAssertEqual(
242
+ parseSavedWifiNetworks(
243
+ expectedRequestId: "saved-1", expectedSid: "sid-1", capabilityVersion: 1, data: exact
244
+ )?.networks,
245
+ [" Field AP ", "Field AP"]
246
+ )
247
+
248
+ var failure = exact
249
+ failure["outcome"] = "failed"
250
+ failure["error"] = "backend_failed"
251
+ failure["networks"] = [String]()
252
+ let parsedFailure = parseSavedWifiNetworks(
253
+ expectedRequestId: "saved-1",
254
+ expectedSid: "sid-1",
255
+ capabilityVersion: 1,
256
+ data: failure
257
+ )
258
+ XCTAssertEqual(parsedFailure?.outcome, .failed)
259
+ XCTAssertEqual(parsedFailure?.error, "backend_failed")
260
+ }
261
+
262
+
263
+ func testRawForgetEventPreservesModernAndLegacyWireTruth() {
264
+ let modern = normalizeWifiForgetResultEvent(
265
+ requestId: "forget-1",
266
+ sid: "sid-1",
267
+ ssid: "AP",
268
+ protocolVersion: 1,
269
+ outcome: "dispatched",
270
+ legacyDispatched: nil,
271
+ connected: false,
272
+ currentSsid: "",
273
+ localIp: "",
274
+ error: nil
275
+ )
276
+ let legacy = normalizeWifiForgetResultEvent(
277
+ requestId: "",
278
+ sid: "",
279
+ ssid: "AP",
280
+ protocolVersion: 0,
281
+ outcome: "",
282
+ legacyDispatched: true,
283
+ connected: false,
284
+ currentSsid: "",
285
+ localIp: "",
286
+ error: nil
287
+ )
288
+ let withoutSnapshot = normalizeWifiForgetResultEvent(
289
+ requestId: "forget-unknown",
290
+ sid: "sid-1",
291
+ ssid: "AP",
292
+ protocolVersion: 1,
293
+ outcome: "dispatched",
294
+ legacyDispatched: nil,
295
+ connected: nil,
296
+ currentSsid: "",
297
+ localIp: "",
298
+ error: nil
299
+ )
300
+
301
+ XCTAssertEqual(modern?["mode"] as? String, "modern")
302
+ XCTAssertEqual(modern?["outcome"] as? String, "dispatched")
303
+ XCTAssertEqual(legacy?["mode"] as? String, "legacy")
304
+ XCTAssertEqual(legacy?["dispatched"] as? Bool, true)
305
+ XCTAssertNil(legacy?["sid"])
306
+ XCTAssertNil(withoutSnapshot?["connected"])
307
+ XCTAssertNil(normalizeWifiForgetResultEvent(
308
+ requestId: "bad",
309
+ sid: "",
310
+ ssid: "AP",
311
+ protocolVersion: 0,
312
+ outcome: "",
313
+ legacyDispatched: nil,
314
+ connected: false,
315
+ currentSsid: "",
316
+ localIp: "",
317
+ error: nil
318
+ ))
319
+ }
320
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mentra/bluetooth-sdk",
3
- "version": "3.2.0-dev.195",
3
+ "version": "3.2.0-dev.203",
4
4
  "description": "SDK for communicating with smart glasses",
5
5
  "main": "build/index.js",
6
6
  "react-native": "src/index.ts",
@@ -128,6 +128,68 @@ export type WifiStatusChangeEvent = WifiStatus & {
128
128
  error?: string
129
129
  }
130
130
 
131
+ export type WifiForgetOutcome =
132
+ | "confirmed"
133
+ | "dispatched"
134
+ | "not_found"
135
+ | "unsupported"
136
+ | "failed"
137
+ | "legacy_unverified"
138
+
139
+ export type WifiForgetResult = {
140
+ ssid: string
141
+ outcome: WifiForgetOutcome
142
+ connected?: boolean
143
+ currentSsid?: string
144
+ localIp?: string
145
+ error?: string
146
+ }
147
+
148
+ export type ModernWifiForgetResultEvent = {
149
+ type: "wifi_forget_result"
150
+ mode: "modern"
151
+ requestId: string
152
+ sid: string
153
+ ssid: string
154
+ protocolVersion: number
155
+ outcome: Exclude<WifiForgetOutcome, "legacy_unverified">
156
+ connected?: boolean
157
+ currentSsid?: string
158
+ localIp?: string
159
+ error?: string
160
+ }
161
+
162
+ export type LegacyWifiForgetResultEvent = {
163
+ type: "wifi_forget_result"
164
+ mode: "legacy"
165
+ ssid: string
166
+ dispatched: boolean
167
+ connected?: boolean
168
+ currentSsid?: string
169
+ localIp?: string
170
+ error?: string
171
+ }
172
+
173
+ export type WifiForgetResultEvent = ModernWifiForgetResultEvent | LegacyWifiForgetResultEvent
174
+
175
+ export type SavedWifiNetworksOutcome = "confirmed" | "unsupported" | "failed"
176
+
177
+ export type SavedWifiNetworksResult = {
178
+ outcome: SavedWifiNetworksOutcome
179
+ networks: string[]
180
+ error?: string
181
+ }
182
+
183
+ export type SavedWifiNetworksEvent = {
184
+ type: "saved_wifi_networks"
185
+ requestId: string
186
+ sid: string
187
+ protocolVersion: number
188
+ outcome: SavedWifiNetworksOutcome
189
+ networks: string[]
190
+ error?: string
191
+ }
192
+
131
193
  export type HotspotStatus = {state: "disabled"} | {state: "enabled"; ssid: string; password: string; localIp: string}
132
194
 
133
195
  export type EnabledHotspotStatus = Extract<HotspotStatus, {state: "enabled"}>
@@ -147,6 +209,8 @@ export type HotspotErrorEvent = {
147
209
  }
148
210
 
149
211
  export type VersionInfoResult = {
212
+ versionInfoType?: string
213
+ sid?: string
150
214
  androidVersion: string
151
215
  firmwareVersion: string
152
216
  besFirmwareVersion: string
@@ -169,6 +233,10 @@ export type VersionInfoResult = {
169
233
  packageName?: string
170
234
  /** Phone-served hotspot OTA protocol version; 0 means unsupported/legacy glasses. */
171
235
  hotspotOtaVersion: number
236
+ /** Session-correlated WiFi forget result protocol; absent/0 means unsupported. */
237
+ wifiForgetResultVersion?: number
238
+ /** Session-correlated saved-network listing protocol; absent/0 means unsupported. */
239
+ savedWifiNetworksVersion?: number
172
240
  }
173
241
 
174
242
  export type VersionInfoEvent = VersionInfoResult & {
@@ -930,6 +998,8 @@ export type BluetoothSdkModuleEvents = {
930
998
  phone_notification_dismissed: (event: PhoneNotificationDismissedEvent) => void
931
999
  wifi_status_change: (event: WifiStatusChangeEvent) => void
932
1000
  wifi_scan_result: (event: WifiScanResultEvent) => void
1001
+ wifi_forget_result: (event: WifiForgetResultEvent) => void
1002
+ saved_wifi_networks: (event: SavedWifiNetworksEvent) => void
933
1003
  hotspot_status_change: (event: HotspotStatusChangeEvent) => void
934
1004
  hotspot_error: (event: HotspotErrorEvent) => void
935
1005
  photo_response: (event: PhotoResponseEvent) => void
@@ -1090,6 +1160,8 @@ export type BluetoothSdkEventMap = {
1090
1160
  local_transcription: LocalTranscriptionEvent
1091
1161
  wifi_status_change: WifiStatusChangeEvent
1092
1162
  wifi_scan_result: WifiScanResultEvent
1163
+ wifi_forget_result: WifiForgetResultEvent
1164
+ saved_wifi_networks: SavedWifiNetworksEvent
1093
1165
  hotspot_status_change: HotspotStatusChangeEvent
1094
1166
  hotspot_error: HotspotErrorEvent
1095
1167
  photo_response: PhotoResponseEvent
@@ -1182,8 +1254,11 @@ export interface BluetoothSdkPublicModule {
1182
1254
  ping(): Promise<void>
1183
1255
 
1184
1256
  requestWifiScan(): Promise<WifiSearchResult[]>
1257
+ /** List exact WiFi SSIDs from glasses that support reliable saved-network enumeration. */
1258
+ getSavedWifiNetworks(): Promise<SavedWifiNetworksResult>
1185
1259
  sendWifiCredentials(ssid: string, password: string): Promise<WifiStatusChangeEvent>
1186
- forgetWifiNetwork(ssid: string): Promise<WifiStatusChangeEvent>
1260
+ /** Forget a saved network and return its correlated semantic outcome. */
1261
+ forgetWifiNetwork(ssid: string): Promise<WifiForgetResult>
1187
1262
  setHotspotState(enabled: boolean): Promise<HotspotStatusChangeEvent>
1188
1263
  /** Set the glasses clock from the phone after an OTA clock-skew failure. */
1189
1264
  setSystemTime(timestampMs: number): Promise<void>
@@ -55,6 +55,8 @@ import {
55
55
  VideoRecordingStatusEvent,
56
56
  VersionInfoResult,
57
57
  WarmUpCameraParams,
58
+ SavedWifiNetworksResult,
59
+ WifiForgetResult,
58
60
  WifiSearchResult,
59
61
  WifiStatusChangeEvent,
60
62
  } from "../BluetoothSdk.types"
@@ -125,8 +127,9 @@ declare class BluetoothSdkNativeModule extends NativeModule<BluetoothSdkModuleEv
125
127
 
126
128
  // WiFi Commands
127
129
  requestWifiScan(): Promise<WifiSearchResult[]>
130
+ getSavedWifiNetworks(): Promise<SavedWifiNetworksResult>
128
131
  sendWifiCredentials(ssid: string, password: string): Promise<WifiStatusChangeEvent>
129
- forgetWifiNetwork(ssid: string): Promise<WifiStatusChangeEvent>
132
+ forgetWifiNetwork(ssid: string): Promise<WifiForgetResult>
130
133
  setHotspotState(enabled: boolean): Promise<HotspotStatusChangeEvent>
131
134
  /** Enable or disable Wi-Fi ADB on Mentra Live (no-op on other devices). */
132
135
  setWifiAdbState(enabled: boolean): Promise<void>
@@ -3,7 +3,7 @@ export const GENERATED_RELEASE_CHANGELOGS = Object.freeze(
3
3
  [
4
4
  {
5
5
  "version": "3.2.0",
6
- "markdown": "This release is under active development. User-facing changes will be documented as they land."
6
+ "markdown": "This release is under active development. User-facing changes will be documented as they land.\n\n- Breaking Bluetooth SDK change: `forgetWifiNetwork(ssid)` now returns `WifiForgetResult` instead of `WifiStatusChangeEvent`. Migrate status-only consumers to the semantic `outcome`; the requested network remains `ssid`, while optional post-command connectivity is reported by `connected`, `currentSsid`, and `localIp`."
7
7
  },
8
8
  {
9
9
  "version": "3.1.0",
@@ -12,9 +12,9 @@ export interface BluetoothSdkReleaseMetadata {
12
12
  export const BLUETOOTH_SDK_RELEASE_METADATA: Readonly<BluetoothSdkReleaseMetadata> = Object.freeze({
13
13
  "schemaVersion": 1,
14
14
  "familyBaseVersion": "3.2.0",
15
- "releaseIdentity": "3.2.0-dev.195",
16
- "releaseSetId": "mentra-3.2.0-dev.195",
17
- "sourceCommit": "321202ca96ec1852be19a7230ebcc3593fe87e54",
18
- "otaManifestUrl": "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.195.json",
19
- "otaManifestSha256": "70f26063b2c281b9b046965f6f6d7ca22f8b53242c6c5be02030c5a04c7115e4"
15
+ "releaseIdentity": "3.2.0-dev.203",
16
+ "releaseSetId": "mentra-3.2.0-dev.203",
17
+ "sourceCommit": "7eb4deefff27d498c2b4562d628607f399aa1f40",
18
+ "otaManifestUrl": "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.203.json",
19
+ "otaManifestSha256": "0ae026238c8e70970be4e181cc1ae73dd905418292edebddb610012f4f0a9b6f"
20
20
  })