@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
@@ -313,8 +313,10 @@ abstract class SGCManager {
313
313
 
314
314
  // Network Management
315
315
  abstract fun requestWifiScan(scanId: String?)
316
+ open fun requestSavedWifiNetworks(requestId: String, sid: String): Boolean = false
316
317
  abstract fun sendWifiCredentials(ssid: String, password: String)
317
318
  abstract fun forgetWifiNetwork(ssid: String)
319
+ open fun forgetWifiNetwork(ssid: String, requestId: String?, sid: String?): Boolean = false
318
320
  abstract fun sendHotspotState(enabled: Boolean)
319
321
 
320
322
  /** Set glasses system clock (Mentra Live and G2; no-op on other devices). */
@@ -189,6 +189,10 @@ data class VersionInfoResult(
189
189
  val appVersion: String,
190
190
  val packageName: String,
191
191
  val hotspotOtaVersion: Int,
192
+ val wifiForgetResultVersion: Int? = null,
193
+ val savedWifiNetworksVersion: Int? = null,
194
+ val versionInfoType: String? = null,
195
+ val sid: String? = null,
192
196
  ) {
193
197
  internal fun toMap(): Map<String, Any> =
194
198
  buildMap {
@@ -206,6 +210,10 @@ data class VersionInfoResult(
206
210
  put("packageName", packageName)
207
211
  }
208
212
  put("hotspotOtaVersion", hotspotOtaVersion)
213
+ wifiForgetResultVersion?.let { put("wifiForgetResultVersion", it) }
214
+ savedWifiNetworksVersion?.let { put("savedWifiNetworksVersion", it) }
215
+ versionInfoType?.let { put("versionInfoType", it) }
216
+ sid?.let { put("sid", it) }
209
217
  }
210
218
 
211
219
  companion object {
@@ -236,6 +244,12 @@ data class VersionInfoResult(
236
244
  packageName = stringValue(values, "packageName", "package_name") ?: "",
237
245
  hotspotOtaVersion =
238
246
  numberValue(values, "hotspotOtaVersion", "hotspot_ota_version") ?: 0,
247
+ wifiForgetResultVersion =
248
+ numberValue(values, "wifiForgetResultVersion", "wifi_forget_result_version"),
249
+ savedWifiNetworksVersion =
250
+ numberValue(values, "savedWifiNetworksVersion", "saved_wifi_networks_version"),
251
+ versionInfoType = stringValue(values, "versionInfoType", "version_info_type"),
252
+ sid = stringValue(values, "sid"),
239
253
  )
240
254
  }
241
255
  }
@@ -0,0 +1,14 @@
1
+ package com.mentra.bluetoothsdk
2
+
3
+ import org.assertj.core.api.Assertions.assertThat
4
+ import org.junit.Test
5
+
6
+ class GlassesStatusClassicAudioTest {
7
+ @Test
8
+ fun `serializes connected Classic audio state for React Native`() {
9
+ val serialized =
10
+ GlassesStatus.fromMap(mapOf("bluetoothClassicConnected" to true)).toMap()
11
+
12
+ assertThat(serialized["bluetoothClassicConnected"]).isEqualTo(true)
13
+ }
14
+ }
@@ -0,0 +1,16 @@
1
+ package com.mentra.bluetoothsdk
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Assert.assertNull
5
+ import org.junit.Test
6
+
7
+ class MessageAckPolicyTest {
8
+ @Test
9
+ fun `every non-ack message id is acknowledged regardless of type`() {
10
+ assertEquals(17L, incomingGlassesMessageAckId("wifi_forget_result", 17L))
11
+ assertEquals(18L, incomingGlassesMessageAckId("saved_wifi_networks", 18L))
12
+ assertEquals(19L, incomingGlassesMessageAckId("future_terminal_type", 19L))
13
+ assertNull(incomingGlassesMessageAckId("msg_ack", 20L))
14
+ assertNull(incomingGlassesMessageAckId("wifi_forget_result", null))
15
+ }
16
+ }
@@ -0,0 +1,271 @@
1
+ package com.mentra.bluetoothsdk
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Assert.assertFalse
5
+ import org.junit.Assert.assertNull
6
+ import org.junit.Assert.assertTrue
7
+ import org.junit.Test
8
+
9
+ class WifiRequestResolutionTest {
10
+ @Test
11
+ fun `future fractional and malformed capabilities are unsupported not legacy`() {
12
+ for (version in listOf(0, 2, -1, 1.5, "1", true)) {
13
+ val capabilities = WifiSessionCapabilities()
14
+ capabilities.reset("sid")
15
+ capabilities.applyVersionInfo1(mapOf("wifiForgetResultVersion" to version, "savedWifiNetworksVersion" to version))
16
+ assertEquals(WifiRequestMode.UNSUPPORTED, capabilities.forgetMode())
17
+ assertEquals(WifiRequestMode.UNSUPPORTED, capabilities.savedNetworksMode())
18
+ }
19
+ val missingSession = WifiSessionCapabilities()
20
+ missingSession.applyVersionInfo1(mapOf("wifiForgetResultVersion" to 1))
21
+ assertEquals(WifiRequestMode.UNSUPPORTED, missingSession.forgetMode())
22
+ }
23
+
24
+ @Test
25
+ fun `raw response rejects partial tuples and coercion before normalization`() {
26
+ val tuple = mapOf<String, Any>("protocol_version" to 1, "requestId" to "request", "sid" to "sid")
27
+ assertTrue(wifiResponseEnvelopeIsValid(tuple, false))
28
+ for (key in tuple.keys) assertFalse(wifiResponseEnvelopeIsValid(tuple - key, true))
29
+ for (version in listOf(0, 2, 1.5, "1", true)) {
30
+ assertFalse(wifiResponseEnvelopeIsValid(tuple + ("protocol_version" to version), true))
31
+ }
32
+ assertFalse(wifiResponseEnvelopeIsValid(tuple + ("connected" to 0), true))
33
+ assertFalse(wifiResponseEnvelopeIsValid(tuple + ("dispatched" to true), true))
34
+ assertTrue(wifiResponseEnvelopeIsValid(mapOf("dispatched" to true), true))
35
+ assertFalse(wifiResponseEnvelopeIsValid(mapOf("requestId" to "", "dispatched" to true), true))
36
+ assertFalse(wifiResponseEnvelopeIsValid(emptyMap(), false))
37
+ }
38
+
39
+ @Test
40
+ fun `semantic results omit transport metadata`() {
41
+ val data = mapOf<String, Any>("requestId" to "request", "sid" to "sid", "protocolVersion" to 1,
42
+ "ssid" to "AP", "outcome" to "confirmed", "networks" to listOf("AP"))
43
+ val forbidden = setOf("mode", "capabilityVersion", "requestId", "sid", "protocolVersion")
44
+ assertTrue(parseWifiForgetResult("request", "sid", "AP", 1, data)!!.toMap().keys.intersect(forbidden).isEmpty())
45
+ assertTrue(parseSavedWifiNetworks("request", "sid", 1, data)!!.toMap().keys.intersect(forbidden).isEmpty())
46
+ assertNull(parseWifiForgetResult("request", "sid", "AP", 1, data + ("protocolVersion" to 1.5)))
47
+ assertNull(parseSavedWifiNetworks("request", "sid", 1, data + ("protocolVersion" to 1.5)))
48
+ }
49
+
50
+ @Test
51
+ fun `capabilities are unknown per session until version info finalizes them`() {
52
+ val capabilities = WifiSessionCapabilities()
53
+ capabilities.reset("sid-1")
54
+
55
+ assertEquals(WifiRequestMode.DISCOVERING, capabilities.forgetMode())
56
+ assertEquals(WifiRequestMode.DISCOVERING, capabilities.savedNetworksMode())
57
+ capabilities.applyVersionInfo1(mapOf("sid" to "sid-1", "wifiForgetResultVersion" to 1))
58
+
59
+ assertEquals(WifiProtocolCapability.Supported(1), capabilities.forgetResult)
60
+ assertEquals(WifiProtocolCapability.Legacy, capabilities.savedNetworks)
61
+ assertEquals(WifiRequestMode.MODERN, capabilities.forgetMode())
62
+ assertEquals(WifiRequestMode.LEGACY, capabilities.savedNetworksMode())
63
+ }
64
+
65
+ @Test
66
+ fun `new session resets capabilities and advances epoch`() {
67
+ val capabilities = WifiSessionCapabilities()
68
+ capabilities.reset("sid-1")
69
+ capabilities.applyVersionInfo1(
70
+ mapOf("wifiForgetResultVersion" to 1, "savedWifiNetworksVersion" to 1)
71
+ )
72
+ val oldEpoch = capabilities.epoch
73
+
74
+ capabilities.reset("sid-2")
75
+
76
+ assertTrue(capabilities.epoch > oldEpoch)
77
+ assertEquals("sid-2", capabilities.sessionId)
78
+ assertEquals(WifiProtocolCapability.Unknown, capabilities.forgetResult)
79
+ assertEquals(WifiProtocolCapability.Unknown, capabilities.savedNetworks)
80
+ }
81
+
82
+ @Test
83
+ fun `saved request snapshot cannot mix mode with a later session`() {
84
+ val capabilities = WifiSessionCapabilities()
85
+ capabilities.reset("sid-1")
86
+ capabilities.applyVersionInfo1(mapOf("savedWifiNetworksVersion" to 1))
87
+ val first = capabilities.savedNetworksRequestSnapshot()
88
+
89
+ capabilities.reset("sid-2")
90
+ val second = capabilities.savedNetworksRequestSnapshot()
91
+
92
+ assertEquals(WifiRequestMode.MODERN, first.mode)
93
+ assertEquals("sid-1", first.sessionId)
94
+ assertEquals(WifiRequestMode.DISCOVERING, second.mode)
95
+ assertEquals("sid-2", second.sessionId)
96
+ assertTrue(second.epoch > first.epoch)
97
+ }
98
+
99
+ @Test
100
+ fun `SSID validation rejects blank input without changing identity`() {
101
+ assertFalse(wifiSsidIsValid(" "))
102
+ assertTrue(wifiSsidIsValid(" Field AP "))
103
+ }
104
+
105
+ @Test
106
+ fun `forget result requires exact id session ssid and protocol`() {
107
+ val exact =
108
+ mapOf<String, Any>(
109
+ "requestId" to "forget-1",
110
+ "sid" to "sid-1",
111
+ "ssid" to " Field AP ",
112
+ "protocolVersion" to 1,
113
+ "outcome" to "dispatched",
114
+ "connected" to false,
115
+ )
116
+
117
+ assertNull(parseWifiForgetResult("other", "sid-1", " Field AP ", 1, exact))
118
+ assertNull(parseWifiForgetResult("forget-1", "sid-1", " Field AP ", 1, exact - "requestId"))
119
+ assertNull(parseWifiForgetResult("forget-1", "other", " Field AP ", 1, exact))
120
+ assertNull(parseWifiForgetResult("forget-1", "sid-1", "Field AP", 1, exact))
121
+ assertNull(parseWifiForgetResult("forget-1", "sid-1", " Field AP ", 2, exact))
122
+ assertEquals(
123
+ WifiForgetOutcome.DISPATCHED,
124
+ parseWifiForgetResult("forget-1", "sid-1", " Field AP ", 1, exact)?.outcome,
125
+ )
126
+ }
127
+
128
+ @Test
129
+ fun `forget parser preserves each honest terminal outcome`() {
130
+ listOf("confirmed", "dispatched", "not_found", "unsupported", "failed").forEach { outcome ->
131
+ val parsed =
132
+ parseWifiForgetResult(
133
+ "forget-1",
134
+ "sid-1",
135
+ "AP",
136
+ 1,
137
+ mapOf(
138
+ "requestId" to "forget-1",
139
+ "sid" to "sid-1",
140
+ "ssid" to "AP",
141
+ "protocolVersion" to 1,
142
+ "outcome" to outcome,
143
+ ),
144
+ )
145
+ assertEquals(outcome, parsed?.outcome?.wireValue)
146
+ }
147
+ }
148
+
149
+ @Test
150
+ fun `missing connectivity snapshot remains unknown`() {
151
+ val parsed =
152
+ parseWifiForgetResult(
153
+ "forget-1",
154
+ "sid-1",
155
+ "AP",
156
+ 1,
157
+ mapOf(
158
+ "requestId" to "forget-1",
159
+ "sid" to "sid-1",
160
+ "ssid" to "AP",
161
+ "protocolVersion" to 1,
162
+ "outcome" to "dispatched",
163
+ ),
164
+ )
165
+
166
+ assertNull(parsed?.connected)
167
+ assertFalse(parsed?.toMap()?.containsKey("connected") ?: true)
168
+ }
169
+
170
+ @Test
171
+ fun `legacy forget is explicitly unverified`() {
172
+ val result =
173
+ legacyWifiForgetResult(
174
+ "AP",
175
+ )
176
+
177
+ assertFalse(result.toMap().keys.any { it in setOf("mode", "capabilityVersion", "requestId", "sid") })
178
+ assertEquals(WifiForgetOutcome.LEGACY_UNVERIFIED, result.outcome)
179
+ assertNull(result.connected)
180
+ assertNull(result.currentSsid)
181
+ assertNull(result.localIp)
182
+ }
183
+
184
+ @Test
185
+ fun `saved list preserves exact identity and requires correlation tuple`() {
186
+ val exact =
187
+ mapOf<String, Any>(
188
+ "requestId" to "saved-1",
189
+ "sid" to "sid-1",
190
+ "protocolVersion" to 1,
191
+ "outcome" to "confirmed",
192
+ "networks" to listOf(" Field AP ", "", "Field AP", " Field AP "),
193
+ )
194
+
195
+ assertNull(parseSavedWifiNetworks("other", "sid-1", 1, exact))
196
+ assertNull(parseSavedWifiNetworks("saved-1", "sid-1", 1, exact - "requestId"))
197
+ assertNull(parseSavedWifiNetworks("saved-1", "other", 1, exact))
198
+ assertNull(parseSavedWifiNetworks("saved-1", "sid-1", 2, exact))
199
+ assertEquals(
200
+ listOf(" Field AP ", "Field AP"),
201
+ parseSavedWifiNetworks("saved-1", "sid-1", 1, exact)?.networks,
202
+ )
203
+
204
+ val failure =
205
+ parseSavedWifiNetworks(
206
+ "saved-1",
207
+ "sid-1",
208
+ 1,
209
+ exact + ("outcome" to "failed") + ("error" to "backend_failed") + ("networks" to emptyList<String>()),
210
+ )
211
+ assertEquals(SavedWifiNetworksOutcome.FAILED, failure?.outcome)
212
+ assertEquals("backend_failed", failure?.error)
213
+ }
214
+
215
+
216
+ @Test
217
+ fun `raw forget event preserves modern and legacy wire truth`() {
218
+ val modern =
219
+ normalizeWifiForgetResultEvent(
220
+ "forget-1",
221
+ "sid-1",
222
+ "AP",
223
+ 1,
224
+ "dispatched",
225
+ null,
226
+ false,
227
+ "",
228
+ "",
229
+ null,
230
+ )
231
+ val legacy =
232
+ normalizeWifiForgetResultEvent(
233
+ "",
234
+ "",
235
+ "AP",
236
+ 0,
237
+ "",
238
+ true,
239
+ false,
240
+ "",
241
+ "",
242
+ null,
243
+ )
244
+ val withoutSnapshot =
245
+ normalizeWifiForgetResultEvent(
246
+ "forget-unknown",
247
+ "sid-1",
248
+ "AP",
249
+ 1,
250
+ "dispatched",
251
+ null,
252
+ null,
253
+ "",
254
+ "",
255
+ null,
256
+ )
257
+
258
+ assertEquals("modern", modern?.get("mode"))
259
+ assertEquals("dispatched", modern?.get("outcome"))
260
+ assertEquals("legacy", legacy?.get("mode"))
261
+ assertEquals(true, legacy?.get("dispatched"))
262
+ assertFalse(legacy?.containsKey("sid") ?: true)
263
+ assertFalse(withoutSnapshot?.containsKey("connected") ?: true)
264
+ assertNull(
265
+ normalizeWifiForgetResultEvent(
266
+ "bad", "", "AP", 0, "", null, false, "", "", null
267
+ )
268
+ )
269
+ }
270
+
271
+ }
@@ -0,0 +1,131 @@
1
+ package com.mentra.bluetoothsdk.sgcs
2
+
3
+ import org.assertj.core.api.Assertions.assertThat
4
+ import org.junit.Test
5
+
6
+ class ClassicAudioConnectionTrackerTest {
7
+ @Test
8
+ fun `available connected profile is sufficient even if the other query fails or never answers`() {
9
+ assertThat(readyClassicAudioSnapshot(mapOf(ClassicAudioProfile.A2DP to true)))
10
+ .containsExactly(ClassicAudioProfile.A2DP)
11
+ assertThat(readyClassicAudioSnapshot(mapOf(ClassicAudioProfile.HEADSET to true)))
12
+ .containsExactly(ClassicAudioProfile.HEADSET)
13
+ }
14
+
15
+ @Test
16
+ fun `missing profile never fabricates a disconnect during a profile switch`() {
17
+ assertThat(readyClassicAudioSnapshot(mapOf(ClassicAudioProfile.A2DP to false))).isNull()
18
+ assertThat(readyClassicAudioSnapshot(mapOf(ClassicAudioProfile.A2DP to false,
19
+ ClassicAudioProfile.HEADSET to false))).isEmpty()
20
+ }
21
+
22
+ private val address = "AA:BB:CC:DD:EE:FF"
23
+
24
+ private fun snapshot(tracker: ClassicAudioConnectionTracker, vararg profiles: ClassicAudioProfile) {
25
+ val ticket = tracker.beginSnapshot(address)!!
26
+ assertThat(tracker.applySnapshot(ticket, address, profiles.toSet())).isTrue()
27
+ }
28
+
29
+ @Test
30
+ fun `publishes complete A2DP and HFP snapshots without intermediate false negative`() {
31
+ val changes = mutableListOf<Boolean>()
32
+ val tracker = ClassicAudioConnectionTracker(changes::add)
33
+ tracker.setTarget(address)
34
+ snapshot(tracker, ClassicAudioProfile.A2DP)
35
+ snapshot(tracker, ClassicAudioProfile.HEADSET)
36
+ snapshot(tracker)
37
+ assertThat(changes).containsExactly(true, false)
38
+ }
39
+
40
+ @Test
41
+ fun `address comparison is case insensitive`() {
42
+ val tracker = ClassicAudioConnectionTracker {}
43
+ tracker.setTarget(address.lowercase())
44
+ snapshot(tracker, ClassicAudioProfile.A2DP)
45
+ assertThat(tracker.connected).isTrue()
46
+ }
47
+
48
+ @Test
49
+ fun `another device cannot request a snapshot`() {
50
+ val tracker = ClassicAudioConnectionTracker {}
51
+ tracker.setTarget(address)
52
+ assertThat(tracker.beginSnapshot("11:22:33:44:55:66")).isNull()
53
+ }
54
+
55
+ @Test
56
+ fun `late result for previous device cannot replace current profiles`() {
57
+ val tracker = ClassicAudioConnectionTracker {}
58
+ tracker.setTarget(address)
59
+ val ticket = tracker.beginSnapshot(address)!!
60
+ tracker.setTarget("11:22:33:44:55:66")
61
+ assertThat(tracker.applySnapshot(ticket, address, setOf(ClassicAudioProfile.A2DP))).isFalse()
62
+ assertThat(tracker.connected).isFalse()
63
+ }
64
+
65
+ @Test
66
+ fun `old response cannot resurrect reconnected session with same MAC`() {
67
+ val tracker = ClassicAudioConnectionTracker {}
68
+ tracker.setTarget(address)
69
+ val ticket = tracker.beginSnapshot(address)!!
70
+ tracker.invalidate(address)
71
+ tracker.setTarget(address)
72
+ snapshot(tracker)
73
+ assertThat(tracker.applySnapshot(ticket, address, setOf(ClassicAudioProfile.A2DP))).isFalse()
74
+ assertThat(tracker.connected).isFalse()
75
+ }
76
+
77
+ @Test
78
+ fun `newer query invalidates in flight snapshot even before its response arrives`() {
79
+ val tracker = ClassicAudioConnectionTracker {}
80
+ tracker.setTarget(address)
81
+ val first = tracker.beginSnapshot(address)!!
82
+ val second = tracker.beginSnapshot(address)!!
83
+ assertThat(tracker.applySnapshot(first, address, setOf(ClassicAudioProfile.A2DP))).isFalse()
84
+ assertThat(tracker.applySnapshot(second, address, emptySet())).isTrue()
85
+ }
86
+
87
+ @Test
88
+ fun `late disconnect snapshot cannot overwrite newer connected snapshot`() {
89
+ val tracker = ClassicAudioConnectionTracker {}
90
+ tracker.setTarget(address)
91
+ val old = tracker.beginSnapshot(address)!!
92
+ snapshot(tracker, ClassicAudioProfile.HEADSET)
93
+ assertThat(tracker.applySnapshot(old, address, emptySet())).isFalse()
94
+ assertThat(tracker.connected).isTrue()
95
+ }
96
+
97
+ @Test
98
+ fun `clear invalidates queries but retains target for retry`() {
99
+ val tracker = ClassicAudioConnectionTracker {}
100
+ tracker.setTarget(address)
101
+ val old = tracker.beginSnapshot(address)!!
102
+ assertThat(tracker.clear(address)).isTrue()
103
+ assertThat(tracker.applySnapshot(old, address, setOf(ClassicAudioProfile.A2DP))).isFalse()
104
+ snapshot(tracker, ClassicAudioProfile.A2DP)
105
+ assertThat(tracker.connected).isTrue()
106
+ }
107
+
108
+ @Test
109
+ fun `reset clears truth and prevents pending results from applying`() {
110
+ val changes = mutableListOf<Boolean>()
111
+ val tracker = ClassicAudioConnectionTracker(changes::add)
112
+ tracker.setTarget(address)
113
+ snapshot(tracker, ClassicAudioProfile.A2DP)
114
+ val ticket = tracker.beginSnapshot(address)!!
115
+ tracker.reset()
116
+ assertThat(tracker.applySnapshot(ticket, address, setOf(ClassicAudioProfile.A2DP))).isFalse()
117
+ assertThat(tracker.beginSnapshot(address)).isNull()
118
+ assertThat(changes).containsExactly(true, false)
119
+ }
120
+
121
+ @Test
122
+ fun `stale snapshot cannot emit audio routing notifications`() {
123
+ val tracker = ClassicAudioConnectionTracker {}
124
+ tracker.setTarget(address)
125
+ val old = tracker.beginSnapshot(address)!!
126
+ tracker.setTarget(address)
127
+ var notifications = 0
128
+ tracker.applySnapshot(old, address, setOf(ClassicAudioProfile.A2DP)) { notifications++ }
129
+ assertThat(notifications).isZero()
130
+ }
131
+ }
@@ -99,6 +99,54 @@ export type WifiStatusChangeEvent = WifiStatus & {
99
99
  */
100
100
  error?: string;
101
101
  };
102
+ export type WifiForgetOutcome = "confirmed" | "dispatched" | "not_found" | "unsupported" | "failed" | "legacy_unverified";
103
+ export type WifiForgetResult = {
104
+ ssid: string;
105
+ outcome: WifiForgetOutcome;
106
+ connected?: boolean;
107
+ currentSsid?: string;
108
+ localIp?: string;
109
+ error?: string;
110
+ };
111
+ export type ModernWifiForgetResultEvent = {
112
+ type: "wifi_forget_result";
113
+ mode: "modern";
114
+ requestId: string;
115
+ sid: string;
116
+ ssid: string;
117
+ protocolVersion: number;
118
+ outcome: Exclude<WifiForgetOutcome, "legacy_unverified">;
119
+ connected?: boolean;
120
+ currentSsid?: string;
121
+ localIp?: string;
122
+ error?: string;
123
+ };
124
+ export type LegacyWifiForgetResultEvent = {
125
+ type: "wifi_forget_result";
126
+ mode: "legacy";
127
+ ssid: string;
128
+ dispatched: boolean;
129
+ connected?: boolean;
130
+ currentSsid?: string;
131
+ localIp?: string;
132
+ error?: string;
133
+ };
134
+ export type WifiForgetResultEvent = ModernWifiForgetResultEvent | LegacyWifiForgetResultEvent;
135
+ export type SavedWifiNetworksOutcome = "confirmed" | "unsupported" | "failed";
136
+ export type SavedWifiNetworksResult = {
137
+ outcome: SavedWifiNetworksOutcome;
138
+ networks: string[];
139
+ error?: string;
140
+ };
141
+ export type SavedWifiNetworksEvent = {
142
+ type: "saved_wifi_networks";
143
+ requestId: string;
144
+ sid: string;
145
+ protocolVersion: number;
146
+ outcome: SavedWifiNetworksOutcome;
147
+ networks: string[];
148
+ error?: string;
149
+ };
102
150
  export type HotspotStatus = {
103
151
  state: "disabled";
104
152
  } | {
@@ -120,6 +168,8 @@ export type HotspotErrorEvent = {
120
168
  timestamp: number;
121
169
  };
122
170
  export type VersionInfoResult = {
171
+ versionInfoType?: string;
172
+ sid?: string;
123
173
  androidVersion: string;
124
174
  firmwareVersion: string;
125
175
  besFirmwareVersion: string;
@@ -142,6 +192,10 @@ export type VersionInfoResult = {
142
192
  packageName?: string;
143
193
  /** Phone-served hotspot OTA protocol version; 0 means unsupported/legacy glasses. */
144
194
  hotspotOtaVersion: number;
195
+ /** Session-correlated WiFi forget result protocol; absent/0 means unsupported. */
196
+ wifiForgetResultVersion?: number;
197
+ /** Session-correlated saved-network listing protocol; absent/0 means unsupported. */
198
+ savedWifiNetworksVersion?: number;
145
199
  };
146
200
  export type VersionInfoEvent = VersionInfoResult & {
147
201
  type: "version_info";
@@ -782,6 +836,8 @@ export type BluetoothSdkModuleEvents = {
782
836
  phone_notification_dismissed: (event: PhoneNotificationDismissedEvent) => void;
783
837
  wifi_status_change: (event: WifiStatusChangeEvent) => void;
784
838
  wifi_scan_result: (event: WifiScanResultEvent) => void;
839
+ wifi_forget_result: (event: WifiForgetResultEvent) => void;
840
+ saved_wifi_networks: (event: SavedWifiNetworksEvent) => void;
785
841
  hotspot_status_change: (event: HotspotStatusChangeEvent) => void;
786
842
  hotspot_error: (event: HotspotErrorEvent) => void;
787
843
  photo_response: (event: PhotoResponseEvent) => void;
@@ -918,6 +974,8 @@ export type BluetoothSdkEventMap = {
918
974
  local_transcription: LocalTranscriptionEvent;
919
975
  wifi_status_change: WifiStatusChangeEvent;
920
976
  wifi_scan_result: WifiScanResultEvent;
977
+ wifi_forget_result: WifiForgetResultEvent;
978
+ saved_wifi_networks: SavedWifiNetworksEvent;
921
979
  hotspot_status_change: HotspotStatusChangeEvent;
922
980
  hotspot_error: HotspotErrorEvent;
923
981
  photo_response: PhotoResponseEvent;
@@ -995,8 +1053,11 @@ export interface BluetoothSdkPublicModule {
995
1053
  /** Keep legacy Mentra Live OTA sessions awake. Modern sessions normally do not require this. */
996
1054
  ping(): Promise<void>;
997
1055
  requestWifiScan(): Promise<WifiSearchResult[]>;
1056
+ /** List exact WiFi SSIDs from glasses that support reliable saved-network enumeration. */
1057
+ getSavedWifiNetworks(): Promise<SavedWifiNetworksResult>;
998
1058
  sendWifiCredentials(ssid: string, password: string): Promise<WifiStatusChangeEvent>;
999
- forgetWifiNetwork(ssid: string): Promise<WifiStatusChangeEvent>;
1059
+ /** Forget a saved network and return its correlated semantic outcome. */
1060
+ forgetWifiNetwork(ssid: string): Promise<WifiForgetResult>;
1000
1061
  setHotspotState(enabled: boolean): Promise<HotspotStatusChangeEvent>;
1001
1062
  /** Set the glasses clock from the phone after an OTA clock-skew failure. */
1002
1063
  setSystemTime(timestampMs: number): Promise<void>;