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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +46 -45
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +5 -9
  3. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +63 -0
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +5 -4
  5. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +1 -1
  6. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  7. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +298 -181
  8. package/android/src/main/java/com/mentra/bluetoothsdk/MessageAckPolicy.kt +4 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/WifiRequestResolution.kt +246 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioConnectionTracker.kt +104 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +326 -45
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +2 -0
  13. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +14 -0
  14. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamControllerProbe.kt +14 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +16 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamSessionState.kt +32 -0
  17. package/android/src/test/java/com/mentra/bluetoothsdk/GlassesStatusClassicAudioTest.kt +14 -0
  18. package/android/src/test/java/com/mentra/bluetoothsdk/MessageAckPolicyTest.kt +16 -0
  19. package/android/src/test/java/com/mentra/bluetoothsdk/WifiRequestResolutionTest.kt +271 -0
  20. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioConnectionTrackerTest.kt +131 -0
  21. package/android/src/test/java/com/mentra/bluetoothsdk/streaming/StreamControllerProbeTest.kt +24 -0
  22. package/android/src/test/java/com/mentra/bluetoothsdk/streaming/StreamSessionStateTest.kt +43 -0
  23. package/build/BluetoothSdk.types.d.ts +68 -2
  24. package/build/BluetoothSdk.types.d.ts.map +1 -1
  25. package/build/BluetoothSdk.types.js.map +1 -1
  26. package/build/_private/BluetoothSdkModule.d.ts +3 -4
  27. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  28. package/build/_private/BluetoothSdkModule.js.map +1 -1
  29. package/build/generated/changelogCatalog.d.ts +1 -1
  30. package/build/generated/changelogCatalog.js +1 -1
  31. package/build/generated/changelogCatalog.js.map +1 -1
  32. package/build/generated/releaseMetadata.js +5 -5
  33. package/build/generated/releaseMetadata.js.map +1 -1
  34. package/build/index.d.ts +1 -1
  35. package/build/index.d.ts.map +1 -1
  36. package/build/index.js +3 -0
  37. package/build/index.js.map +1 -1
  38. package/ios/BluetoothSdkModule.swift +8 -13
  39. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  40. package/ios/Source/Bridge.swift +63 -0
  41. package/ios/Source/DeviceManager.swift +6 -3
  42. package/ios/Source/GeneratedChangelogCatalog.swift +1 -1
  43. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  44. package/ios/Source/MentraBluetoothSDK.swift +372 -220
  45. package/ios/Source/WifiRequestResolution.swift +251 -0
  46. package/ios/Source/sgcs/MentraLive.swift +216 -70
  47. package/ios/Source/sgcs/SGCManager.swift +13 -1
  48. package/ios/Source/status/DeviceStatus.swift +28 -0
  49. package/ios/Source/streaming/StreamControllerProbe.swift +18 -0
  50. package/ios/Source/streaming/StreamModels.swift +12 -0
  51. package/ios/Source/streaming/StreamSessionState.swift +29 -0
  52. package/ios/Tests/StreamControllerProbeTests.swift +27 -0
  53. package/ios/Tests/StreamSessionStateTests.swift +38 -0
  54. package/ios/Tests/WifiRequestResolutionTests.swift +320 -0
  55. package/package.json +1 -1
  56. package/src/BluetoothSdk.types.ts +82 -2
  57. package/src/_private/BluetoothSdkModule.ts +4 -4
  58. package/src/generated/changelogCatalog.ts +1 -1
  59. package/src/generated/releaseMetadata.ts +5 -5
  60. package/src/index.ts +11 -0
@@ -0,0 +1,4 @@
1
+ package com.mentra.bluetoothsdk
2
+
3
+ internal fun incomingGlassesMessageAckId(type: String, messageId: Long?): Long? =
4
+ messageId?.takeIf { type != "msg_ack" }
@@ -0,0 +1,246 @@
1
+ package com.mentra.bluetoothsdk
2
+
3
+ internal fun wifiSsidIsValid(ssid: String): Boolean = ssid.trim().isNotEmpty()
4
+
5
+ /** Validate raw fields before JSON convenience getters can coerce numbers or erase presence. */
6
+ internal fun wifiResponseEnvelopeIsValid(values: Map<String, Any>, allowLegacy: Boolean): Boolean {
7
+ if (values.containsKey("connected") && values["connected"] !is Boolean) return false
8
+ if (values.containsKey("dispatched") && values["dispatched"] !is Boolean) return false
9
+ val hasTuple = listOf("protocol_version", "requestId", "sid").any(values::containsKey)
10
+ if (!hasTuple) return allowLegacy && !values.containsKey("outcome")
11
+ return (values["protocol_version"] as? Number)?.toDouble() == 1.0 &&
12
+ (values["requestId"] as? String)?.isNotBlank() == true &&
13
+ (values["sid"] as? String)?.isNotBlank() == true &&
14
+ !values.containsKey("dispatched")
15
+ }
16
+
17
+ internal const val WIFI_CAPABILITY_NEGOTIATION_TIMEOUT_CODE = "capability_negotiation_timeout"
18
+
19
+ internal sealed class WifiProtocolCapability {
20
+ data object Unknown : WifiProtocolCapability()
21
+
22
+ data class Supported(val version: Int) : WifiProtocolCapability()
23
+
24
+ data object Unsupported : WifiProtocolCapability()
25
+
26
+ data object Legacy : WifiProtocolCapability()
27
+ }
28
+
29
+ internal enum class WifiRequestMode {
30
+ DISCOVERING,
31
+ MODERN,
32
+ LEGACY,
33
+ UNSUPPORTED,
34
+ }
35
+
36
+ internal data class WifiSessionRequestSnapshot(
37
+ val mode: WifiRequestMode,
38
+ val sessionId: String,
39
+ val epoch: Long,
40
+ )
41
+
42
+ internal class WifiSessionCapabilities {
43
+ var sessionId: String = ""
44
+ private set
45
+ var epoch: Long = 0
46
+ private set
47
+ var forgetResult: WifiProtocolCapability = WifiProtocolCapability.Unknown
48
+ private set
49
+ var savedNetworks: WifiProtocolCapability = WifiProtocolCapability.Unknown
50
+ private set
51
+
52
+ fun reset(sessionId: String = "") {
53
+ epoch += 1
54
+ this.sessionId = sessionId
55
+ forgetResult = WifiProtocolCapability.Unknown
56
+ savedNetworks = WifiProtocolCapability.Unknown
57
+ }
58
+
59
+ fun applyVersionInfo1(values: Map<String, Any>) {
60
+ (values["sid"] as? String)?.takeIf { it.isNotEmpty() }?.let { sessionId = it }
61
+ forgetResult = capability(values["wifiForgetResultVersion"])
62
+ savedNetworks = capability(values["savedWifiNetworksVersion"])
63
+ }
64
+
65
+ fun forgetMode(): WifiRequestMode = requestMode(forgetResult)
66
+
67
+ fun savedNetworksMode(): WifiRequestMode = requestMode(savedNetworks)
68
+
69
+ fun savedNetworksRequestSnapshot(): WifiSessionRequestSnapshot =
70
+ WifiSessionRequestSnapshot(savedNetworksMode(), sessionId, epoch)
71
+
72
+ private fun capability(raw: Any?): WifiProtocolCapability {
73
+ if (raw == null) return WifiProtocolCapability.Legacy
74
+ return if (raw is Number && raw.toDouble() == 1.0 && sessionId.isNotEmpty()) {
75
+ WifiProtocolCapability.Supported(1)
76
+ } else WifiProtocolCapability.Unsupported
77
+ }
78
+
79
+ private fun requestMode(capability: WifiProtocolCapability): WifiRequestMode =
80
+ when (capability) {
81
+ WifiProtocolCapability.Unknown -> WifiRequestMode.DISCOVERING
82
+ is WifiProtocolCapability.Supported -> WifiRequestMode.MODERN
83
+ WifiProtocolCapability.Legacy -> WifiRequestMode.LEGACY
84
+ WifiProtocolCapability.Unsupported -> WifiRequestMode.UNSUPPORTED
85
+ }
86
+ }
87
+
88
+ enum class WifiForgetOutcome(val wireValue: String) {
89
+ CONFIRMED("confirmed"),
90
+ DISPATCHED("dispatched"),
91
+ NOT_FOUND("not_found"),
92
+ UNSUPPORTED("unsupported"),
93
+ FAILED("failed"),
94
+ LEGACY_UNVERIFIED("legacy_unverified");
95
+
96
+ companion object {
97
+ internal fun fromWire(value: String): WifiForgetOutcome? =
98
+ entries.find { it.wireValue == value }
99
+ }
100
+ }
101
+
102
+ internal fun normalizeWifiForgetResultEvent(
103
+ requestId: String,
104
+ sid: String,
105
+ ssid: String,
106
+ protocolVersion: Int,
107
+ outcome: String,
108
+ legacyDispatched: Boolean?,
109
+ connected: Boolean?,
110
+ currentSsid: String,
111
+ localIp: String,
112
+ error: String?,
113
+ ): Map<String, Any>? {
114
+ if (!wifiSsidIsValid(ssid)) return null
115
+ val modernOutcome = WifiForgetOutcome.fromWire(outcome)
116
+ if (requestId.isNotEmpty() && sid.isNotEmpty() && protocolVersion == 1 &&
117
+ modernOutcome != null && modernOutcome != WifiForgetOutcome.LEGACY_UNVERIFIED
118
+ ) {
119
+ return buildMap {
120
+ put("mode", "modern")
121
+ put("requestId", requestId)
122
+ put("sid", sid)
123
+ put("ssid", ssid)
124
+ put("protocolVersion", protocolVersion)
125
+ put("outcome", modernOutcome.wireValue)
126
+ connected?.let { put("connected", it) }
127
+ if (currentSsid.isNotEmpty()) put("currentSsid", currentSsid)
128
+ if (localIp.isNotEmpty()) put("localIp", localIp)
129
+ error?.let { put("error", it) }
130
+ }
131
+ }
132
+ if (requestId.isEmpty() && sid.isEmpty() && protocolVersion == 0 && outcome.isEmpty() && legacyDispatched != null) {
133
+ return buildMap {
134
+ put("mode", "legacy")
135
+ put("ssid", ssid)
136
+ put("dispatched", legacyDispatched)
137
+ connected?.let { put("connected", it) }
138
+ if (currentSsid.isNotEmpty()) put("currentSsid", currentSsid)
139
+ if (localIp.isNotEmpty()) put("localIp", localIp)
140
+ error?.let { put("error", it) }
141
+ }
142
+ }
143
+ return null
144
+ }
145
+
146
+ data class WifiForgetResult(
147
+ val ssid: String,
148
+ val outcome: WifiForgetOutcome,
149
+ val connected: Boolean?,
150
+ val currentSsid: String?,
151
+ val localIp: String?,
152
+ val error: String? = null,
153
+ ) {
154
+ internal fun toMap(): Map<String, Any> =
155
+ buildMap {
156
+ put("ssid", ssid)
157
+ put("outcome", outcome.wireValue)
158
+ connected?.let { put("connected", it) }
159
+ currentSsid?.let { put("currentSsid", it) }
160
+ localIp?.let { put("localIp", it) }
161
+ error?.let { put("error", it) }
162
+ }
163
+ }
164
+
165
+ enum class SavedWifiNetworksOutcome(val wireValue: String) {
166
+ CONFIRMED("confirmed"),
167
+ UNSUPPORTED("unsupported"),
168
+ FAILED("failed");
169
+
170
+ companion object {
171
+ internal fun fromWire(value: String): SavedWifiNetworksOutcome? =
172
+ entries.find { it.wireValue == value }
173
+ }
174
+ }
175
+
176
+ data class SavedWifiNetworksResult(
177
+ val outcome: SavedWifiNetworksOutcome,
178
+ val networks: List<String>,
179
+ val error: String? = null,
180
+ ) {
181
+ internal fun toMap(): Map<String, Any> =
182
+ buildMap {
183
+ put("outcome", outcome.wireValue)
184
+ put("networks", networks)
185
+ error?.let { put("error", it) }
186
+ }
187
+ }
188
+
189
+ internal fun parseWifiForgetResult(
190
+ expectedRequestId: String,
191
+ expectedSid: String,
192
+ expectedSsid: String,
193
+ capabilityVersion: Int,
194
+ data: Map<String, Any>,
195
+ ): WifiForgetResult? {
196
+ if (capabilityVersion != 1 || expectedRequestId.isEmpty() || expectedSid.isEmpty()) return null
197
+ if (data["requestId"] as? String != expectedRequestId) return null
198
+ if (data["sid"] as? String != expectedSid) return null
199
+ if (data["ssid"] as? String != expectedSsid) return null
200
+ if ((data["protocolVersion"] as? Number)?.toDouble() != 1.0) return null
201
+ val outcome = WifiForgetOutcome.fromWire(data["outcome"] as? String ?: return null) ?: return null
202
+ if (outcome == WifiForgetOutcome.LEGACY_UNVERIFIED) return null
203
+ return WifiForgetResult(
204
+ ssid = expectedSsid,
205
+ outcome = outcome,
206
+ connected = data["connected"] as? Boolean,
207
+ currentSsid = data["currentSsid"] as? String,
208
+ localIp = data["localIp"] as? String,
209
+ error = (data["error"] as? String)?.takeIf { it.isNotEmpty() },
210
+ )
211
+ }
212
+
213
+ internal fun parseSavedWifiNetworks(
214
+ expectedRequestId: String,
215
+ expectedSid: String,
216
+ capabilityVersion: Int,
217
+ data: Map<String, Any>,
218
+ ): SavedWifiNetworksResult? {
219
+ if (capabilityVersion != 1 || expectedRequestId.isEmpty() || expectedSid.isEmpty()) return null
220
+ if (data["requestId"] as? String != expectedRequestId) return null
221
+ if (data["sid"] as? String != expectedSid) return null
222
+ if ((data["protocolVersion"] as? Number)?.toDouble() != 1.0) return null
223
+ val outcome =
224
+ SavedWifiNetworksOutcome.fromWire(data["outcome"] as? String ?: return null) ?: return null
225
+ val rawNetworks = data["networks"] as? List<*> ?: return null
226
+ if (rawNetworks.any { it !is String }) return null
227
+ if (outcome != SavedWifiNetworksOutcome.CONFIRMED && rawNetworks.isNotEmpty()) return null
228
+ val networks = rawNetworks.map { it as String }.filter { it.trim().isNotEmpty() }.distinct()
229
+ return SavedWifiNetworksResult(
230
+ outcome = outcome,
231
+ networks = networks,
232
+ error = (data["error"] as? String)?.takeIf { it.isNotEmpty() },
233
+ )
234
+ }
235
+
236
+ internal fun legacyWifiForgetResult(
237
+ ssid: String,
238
+ ): WifiForgetResult {
239
+ return WifiForgetResult(
240
+ ssid = ssid,
241
+ outcome = WifiForgetOutcome.LEGACY_UNVERIFIED,
242
+ connected = null,
243
+ currentSsid = null,
244
+ localIp = null,
245
+ )
246
+ }
@@ -0,0 +1,104 @@
1
+ package com.mentra.bluetoothsdk.sgcs
2
+
3
+ internal enum class ClassicAudioProfile {
4
+ A2DP,
5
+ HEADSET,
6
+ }
7
+
8
+ /** A confirmed connection is sufficient; declaring disconnection requires both profile reads. */
9
+ internal fun readyClassicAudioSnapshot(states: Map<ClassicAudioProfile, Boolean>): Set<ClassicAudioProfile>? {
10
+ val connected = states.filterValues { it }.keys
11
+ return if (connected.isNotEmpty() || states.size == ClassicAudioProfile.entries.size) connected else null
12
+ }
13
+
14
+ /**
15
+ * Tracks the Android Classic audio profiles for one dual-mode glasses device.
16
+ *
17
+ * Android reports A2DP and HFP independently. Keeping both states prevents an A2DP disconnect from
18
+ * publishing a false negative while HFP is still connected. Snapshot tickets reject obsolete
19
+ * queries, including a previous session with the same MAC address. Broadcast payloads are not truth.
20
+ */
21
+ internal class ClassicAudioConnectionTracker(
22
+ private val onConnectedChanged: (Boolean) -> Unit,
23
+ ) {
24
+ private var targetAddress: String? = null
25
+ private var revision = 0L
26
+ private val connectedProfiles = mutableSetOf<ClassicAudioProfile>()
27
+
28
+ @get:Synchronized
29
+ val connected: Boolean
30
+ get() = connectedProfiles.isNotEmpty()
31
+
32
+ @Synchronized
33
+ fun setTarget(address: String) {
34
+ val normalizedAddress = address.normalizedBluetoothAddress()
35
+ revision++
36
+ if (targetAddress == normalizedAddress) return
37
+
38
+ targetAddress = normalizedAddress
39
+ clearConnectedProfiles()
40
+ }
41
+
42
+ @Synchronized
43
+ fun beginSnapshot(address: String): Long? {
44
+ if (targetAddress != address.normalizedBluetoothAddress()) return null
45
+ return ++revision
46
+ }
47
+
48
+ /** Both profile queries must belong to the newest request for this session. */
49
+ @Synchronized
50
+ fun applySnapshot(
51
+ ticket: Long,
52
+ address: String,
53
+ profiles: Set<ClassicAudioProfile>,
54
+ onAccepted: () -> Unit = {},
55
+ ): Boolean {
56
+ if (ticket != revision || targetAddress != address.normalizedBluetoothAddress()) return false
57
+ val wasConnected = this.connected
58
+ connectedProfiles.clear()
59
+ connectedProfiles.addAll(profiles)
60
+ publishIfChanged(wasConnected)
61
+ // Audio routing notifications must not race a target invalidation after validation.
62
+ onAccepted()
63
+ return true
64
+ }
65
+
66
+ @Synchronized
67
+ fun clear(address: String): Boolean {
68
+ if (targetAddress != address.normalizedBluetoothAddress()) return false
69
+ revision++
70
+ clearConnectedProfiles()
71
+ return true
72
+ }
73
+
74
+ @Synchronized
75
+ fun invalidate(address: String): Boolean {
76
+ if (targetAddress != address.normalizedBluetoothAddress()) return false
77
+
78
+ targetAddress = null
79
+ revision++
80
+ clearConnectedProfiles()
81
+ return true
82
+ }
83
+
84
+ @Synchronized
85
+ fun reset() {
86
+ targetAddress = null
87
+ revision++
88
+ clearConnectedProfiles()
89
+ }
90
+
91
+ private fun clearConnectedProfiles() {
92
+ val wasConnected = connected
93
+ connectedProfiles.clear()
94
+ publishIfChanged(wasConnected)
95
+ }
96
+
97
+ private fun publishIfChanged(wasConnected: Boolean) {
98
+ if (wasConnected != connected) {
99
+ onConnectedChanged(connected)
100
+ }
101
+ }
102
+ }
103
+
104
+ private fun String.normalizedBluetoothAddress(): String = trim().uppercase()