@mentra/bluetooth-sdk 3.4.0-dev.996 → 3.4.0-dev.998
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.
- package/MIGRATION.md +61 -0
- package/README.md +47 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +6 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +8 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +48 -4
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +21 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +2 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/connection/ConnectionModels.kt +5 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/GlassesAudioSetupPolicy.kt +64 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +788 -134
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLiveOwnerLossPolicy.kt +227 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingAdvertisement.kt +19 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingReadinessPolicy.kt +94 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingStateMachine.kt +411 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingStateStore.kt +124 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +50 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerOwnerLossTest.kt +146 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/GlassesStatusAudioSetupTest.kt +51 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/GlassesStatusClassicAudioTest.kt +2 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/GlassesAudioSetupPolicyTest.kt +65 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveAudioSetupReadinessTest.kt +231 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveOwnerLossPolicyTest.kt +368 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingAdvertisementParserTest.kt +20 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingReadinessPolicyTest.kt +136 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingStateMachineTest.kt +346 -0
- package/build/BluetoothSdk.types.d.ts +101 -0
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +1 -0
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/build/index.d.ts +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +3 -0
- package/build/index.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +8 -2
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/Bridge.swift +11 -3
- package/ios/Source/DeviceManager.swift +80 -6
- package/ios/Source/DeviceStore.swift +27 -0
- package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
- package/ios/Source/MentraBluetoothSDK.swift +10 -4
- package/ios/Source/errors/BluetoothSdkError.swift +5 -1
- package/ios/Source/sgcs/GlassesAudioSetupPolicy.swift +181 -0
- package/ios/Source/sgcs/MentraLive.swift +1053 -166
- package/ios/Source/sgcs/MentraLivePairingAdvertisement.swift +260 -1
- package/ios/Source/sgcs/MentraLivePairingStateMachine.swift +385 -0
- package/ios/Source/sgcs/MentraLivePairingStateStore.swift +121 -0
- package/ios/Source/sgcs/SGCManager.swift +7 -0
- package/ios/Source/sgcs/Simulated.swift +2 -0
- package/ios/Source/status/DeviceStatus.swift +35 -0
- package/ios/Source/types/DeviceModels.swift +8 -1
- package/ios/Source/utils/AudioSessionMonitor.swift +158 -5
- package/ios/Tests/BluetoothAudioRouteTests.swift +82 -0
- package/ios/Tests/GlassesAudioSetupPolicyTests.swift +191 -0
- package/ios/Tests/MentraLiveAudioSetupTests.swift +215 -0
- package/ios/Tests/MentraLiveConnectionOptionsTests.swift +55 -0
- package/ios/Tests/MentraLivePairingAdvertisementTests.swift +327 -0
- package/ios/Tests/MentraLivePairingStateMachineTests.swift +402 -0
- package/ios/Tests/MentraLiveSessionReadinessTests.swift +328 -26
- package/package.json +3 -2
- package/src/BluetoothSdk.types.ts +97 -0
- package/src/_private/BluetoothSdkModule.ts +1 -0
- package/src/generated/releaseMetadata.ts +5 -5
- package/src/index.ts +10 -0
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
package com.mentra.bluetoothsdk.sgcs
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Public Mentra Live pairing state (`MentraLivePairingState` in BluetoothSdk.types.ts).
|
|
5
|
+
* Mirrors iOS `MentraLivePairingState`.
|
|
6
|
+
*/
|
|
7
|
+
internal sealed class MentraLivePairingState {
|
|
8
|
+
object Normal : MentraLivePairingState()
|
|
9
|
+
|
|
10
|
+
/** The glasses opened a pairing window. Automatic reconnect waits until [endsAtMs] (wall clock). */
|
|
11
|
+
data class PairingMode(val windowId: Int, val endsAtMs: Long) : MentraLivePairingState()
|
|
12
|
+
|
|
13
|
+
/** The glasses no longer accept this phone. Only an explicit pair or a forget clears it. */
|
|
14
|
+
object Rejected : MentraLivePairingState()
|
|
15
|
+
|
|
16
|
+
fun toMap(): Map<String, Any> =
|
|
17
|
+
when (this) {
|
|
18
|
+
Normal -> mapOf("state" to "normal")
|
|
19
|
+
is PairingMode ->
|
|
20
|
+
mapOf("state" to "pairing_mode", "windowId" to windowId, "endsAtMs" to endsAtMs)
|
|
21
|
+
Rejected -> mapOf("state" to "pairing_rejected")
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** What the connection manager must do after a [MentraLivePairingStateMachine] input. */
|
|
26
|
+
internal enum class MentraLivePairingEffect {
|
|
27
|
+
NONE,
|
|
28
|
+
/** Leave the link and make no automatic connection until resumed. */
|
|
29
|
+
PAUSE,
|
|
30
|
+
/** The window is over: reconnect to the saved glasses. */
|
|
31
|
+
RESUME,
|
|
32
|
+
/** Stop reconnecting. The glasses belong to another phone. */
|
|
33
|
+
REJECT,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
internal data class MentraLivePairingTransition(
|
|
37
|
+
val effect: MentraLivePairingEffect,
|
|
38
|
+
val stateChanged: Boolean,
|
|
39
|
+
val note: String,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Pairing-mode pause/resume, takeover detection and rejection detection for one saved Mentra Live
|
|
44
|
+
* (pairing spec "Ownership transfer v4"). Pure and clock-free: every input carries `nowMs`
|
|
45
|
+
* (wall clock, because the pause deadline survives a process restart). Mirrors iOS
|
|
46
|
+
* `MentraLivePairingStateMachine` input for input.
|
|
47
|
+
*/
|
|
48
|
+
internal class MentraLivePairingStateMachine {
|
|
49
|
+
companion object {
|
|
50
|
+
/** Glasses flag-clear advertisements seen this soon after the notice can predate the window. */
|
|
51
|
+
const val FLAG_CLEAR_GRACE_MS = 3_000L
|
|
52
|
+
|
|
53
|
+
/** A pairing-mode advertisement this recent still means the glasses are in pairing mode. */
|
|
54
|
+
const val PAIRING_ADVERT_FRESH_MS = 10_000L
|
|
55
|
+
|
|
56
|
+
/** Window id used when protocol-3 firmware sends `owner_retained` without `window_id`. */
|
|
57
|
+
const val UNKNOWN_WINDOW_ID = -1
|
|
58
|
+
|
|
59
|
+
const val KEY_DEVICE_ID = "pairing_v4_device_id"
|
|
60
|
+
const val KEY_STATE = "pairing_v4_state"
|
|
61
|
+
const val KEY_WINDOW_ID = "pairing_v4_window_id"
|
|
62
|
+
const val KEY_ENDS_AT_MS = "pairing_v4_ends_at_ms"
|
|
63
|
+
const val KEY_PAUSE_MS = "pairing_v4_pause_ms"
|
|
64
|
+
const val KEY_ENDED_WINDOW_ID = "pairing_v4_ended_window_id"
|
|
65
|
+
const val KEY_VERIFIED_WINDOW_ID = "pairing_v4_verified_window_id"
|
|
66
|
+
val KEYS =
|
|
67
|
+
listOf(
|
|
68
|
+
KEY_DEVICE_ID,
|
|
69
|
+
KEY_STATE,
|
|
70
|
+
KEY_WINDOW_ID,
|
|
71
|
+
KEY_ENDS_AT_MS,
|
|
72
|
+
KEY_PAUSE_MS,
|
|
73
|
+
KEY_ENDED_WINDOW_ID,
|
|
74
|
+
KEY_VERIFIED_WINDOW_ID,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
/** Pre-v4 stand-down phase. Its presence migrates to [MentraLivePairingState.Rejected]. */
|
|
78
|
+
const val LEGACY_REJECTION_PHASE_KEY = "previous_owner_rejection_phase"
|
|
79
|
+
val LEGACY_KEYS =
|
|
80
|
+
listOf(
|
|
81
|
+
LEGACY_REJECTION_PHASE_KEY,
|
|
82
|
+
"previous_owner_rejection_generation",
|
|
83
|
+
"pairing_yield_until_wall_ms",
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
private const val STATE_PAIRING_MODE = "pairing_mode"
|
|
87
|
+
private const val STATE_REJECTED = "pairing_rejected"
|
|
88
|
+
|
|
89
|
+
private fun intValue(value: Any?): Int? = (value as? Number)?.toInt()
|
|
90
|
+
|
|
91
|
+
private fun longValue(value: Any?): Long? = (value as? Number)?.toLong()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
var state: MentraLivePairingState = MentraLivePairingState.Normal
|
|
95
|
+
private set
|
|
96
|
+
|
|
97
|
+
/** Saved glasses this record belongs to. Empty until known. */
|
|
98
|
+
var deviceId: String = ""
|
|
99
|
+
private set
|
|
100
|
+
|
|
101
|
+
/** The next connection attempt is the one verification after an `OWNER_CHANGED` advertisement. */
|
|
102
|
+
var verificationArmed: Boolean = false
|
|
103
|
+
private set
|
|
104
|
+
|
|
105
|
+
/** The window this phone paused for and that has ended. Read against `OWNER_CHANGED`. */
|
|
106
|
+
var endedWindowId: Int? = null
|
|
107
|
+
private set
|
|
108
|
+
|
|
109
|
+
private var pauseMs = 0L
|
|
110
|
+
private var pausedAtMs: Long? = null
|
|
111
|
+
private var verifiedWindowId: Int? = null
|
|
112
|
+
private var lastPairingAdvertAtMs: Long? = null
|
|
113
|
+
private var takeoverAdvertRead = false
|
|
114
|
+
private val streak = MentraLiveOwnerLossPolicy.OwnerRejectionStreak()
|
|
115
|
+
|
|
116
|
+
val allowsAutomaticReconnect: Boolean
|
|
117
|
+
get() = state == MentraLivePairingState.Normal
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* A window this phone paused for has ended and no post-window advertisement has been read,
|
|
121
|
+
* so a reconnect should scan first instead of dialing the saved address directly.
|
|
122
|
+
*/
|
|
123
|
+
val awaitingTakeoverCheck: Boolean
|
|
124
|
+
get() = state == MentraLivePairingState.Normal && endedWindowId != null && !takeoverAdvertRead
|
|
125
|
+
|
|
126
|
+
val isPaused: Boolean
|
|
127
|
+
get() = state is MentraLivePairingState.PairingMode
|
|
128
|
+
|
|
129
|
+
fun remainingPauseMs(nowMs: Long): Long =
|
|
130
|
+
(state as? MentraLivePairingState.PairingMode)?.let { maxOf(0L, it.endsAtMs - nowMs) } ?: 0L
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Rebuild from persisted values. Unknown keys and malformed values are ignored. A record for
|
|
134
|
+
* other glasses, or a pause whose deadline passed or is implausible, does not survive.
|
|
135
|
+
*/
|
|
136
|
+
fun restore(values: Map<String, *>, savedDeviceId: String, nowMs: Long): MentraLivePairingTransition {
|
|
137
|
+
resetInMemory()
|
|
138
|
+
state = MentraLivePairingState.Normal
|
|
139
|
+
deviceId = values[KEY_DEVICE_ID] as? String ?: ""
|
|
140
|
+
endedWindowId = intValue(values[KEY_ENDED_WINDOW_ID])
|
|
141
|
+
verifiedWindowId = intValue(values[KEY_VERIFIED_WINDOW_ID])
|
|
142
|
+
if (savedDeviceId.isNotEmpty() && deviceId.isNotEmpty() && deviceId != savedDeviceId) {
|
|
143
|
+
clearRecord()
|
|
144
|
+
deviceId = savedDeviceId
|
|
145
|
+
return none("record belongs to other glasses")
|
|
146
|
+
}
|
|
147
|
+
when (values[KEY_STATE] as? String) {
|
|
148
|
+
STATE_REJECTED -> {
|
|
149
|
+
state = MentraLivePairingState.Rejected
|
|
150
|
+
return MentraLivePairingTransition(MentraLivePairingEffect.REJECT, true, "restored rejected")
|
|
151
|
+
}
|
|
152
|
+
STATE_PAIRING_MODE -> {
|
|
153
|
+
val windowId = intValue(values[KEY_WINDOW_ID])
|
|
154
|
+
val endsAtMs = longValue(values[KEY_ENDS_AT_MS]) ?: 0L
|
|
155
|
+
val pause =
|
|
156
|
+
longValue(values[KEY_PAUSE_MS])
|
|
157
|
+
?: MentraLiveOwnerLossPolicy.yieldMs(
|
|
158
|
+
MentraLiveOwnerLossPolicy.DEFAULT_PAIRING_WINDOW_MS
|
|
159
|
+
)
|
|
160
|
+
val remaining = MentraLiveOwnerLossPolicy.persistedYieldRemainingMs(endsAtMs, nowMs, pause)
|
|
161
|
+
if (windowId != null && remaining > 0L) {
|
|
162
|
+
state = MentraLivePairingState.PairingMode(windowId, endsAtMs)
|
|
163
|
+
pauseMs = pause
|
|
164
|
+
return MentraLivePairingTransition(
|
|
165
|
+
MentraLivePairingEffect.PAUSE,
|
|
166
|
+
true,
|
|
167
|
+
"restored pairing_mode window=$windowId remaining=${remaining}ms",
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
if (windowId != null) endedWindowId = windowId
|
|
171
|
+
return none("persisted pause ended while the app was away")
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (values[LEGACY_REJECTION_PHASE_KEY] is String) {
|
|
175
|
+
state = MentraLivePairingState.Rejected
|
|
176
|
+
return MentraLivePairingTransition(MentraLivePairingEffect.REJECT, true, "migrated legacy stand-down")
|
|
177
|
+
}
|
|
178
|
+
return none("restored normal")
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The current record, keyed by [KEYS]. */
|
|
182
|
+
fun persisted(): Map<String, Any> {
|
|
183
|
+
val values = mutableMapOf<String, Any>()
|
|
184
|
+
if (deviceId.isNotEmpty()) values[KEY_DEVICE_ID] = deviceId
|
|
185
|
+
when (val current = state) {
|
|
186
|
+
is MentraLivePairingState.PairingMode -> {
|
|
187
|
+
values[KEY_STATE] = STATE_PAIRING_MODE
|
|
188
|
+
values[KEY_WINDOW_ID] = current.windowId
|
|
189
|
+
values[KEY_ENDS_AT_MS] = current.endsAtMs
|
|
190
|
+
values[KEY_PAUSE_MS] = pauseMs
|
|
191
|
+
}
|
|
192
|
+
MentraLivePairingState.Rejected -> values[KEY_STATE] = STATE_REJECTED
|
|
193
|
+
MentraLivePairingState.Normal -> Unit
|
|
194
|
+
}
|
|
195
|
+
endedWindowId?.let { values[KEY_ENDED_WINDOW_ID] = it }
|
|
196
|
+
// Armed verification is not persisted, so a window counts as verified only once its
|
|
197
|
+
// verification has been used. After a restart the next OWNER_CHANGED advert re-arms it.
|
|
198
|
+
if (!verificationArmed) verifiedWindowId?.let { values[KEY_VERIFIED_WINDOW_ID] = it }
|
|
199
|
+
return values
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** An automatic connect to [id]. A record for other glasses no longer applies. */
|
|
203
|
+
fun bindDevice(id: String): MentraLivePairingTransition {
|
|
204
|
+
if (id.isEmpty() || id == deviceId) return none("same glasses")
|
|
205
|
+
val hadRecord = deviceId.isNotEmpty()
|
|
206
|
+
val wasNormal = state == MentraLivePairingState.Normal
|
|
207
|
+
if (hadRecord) clearRecord()
|
|
208
|
+
deviceId = id
|
|
209
|
+
return MentraLivePairingTransition(
|
|
210
|
+
MentraLivePairingEffect.NONE,
|
|
211
|
+
hadRecord && !wasNormal,
|
|
212
|
+
if (hadRecord) "record moved to other glasses" else "bound",
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* `entering_pairing_mode`. Protocol 4 (`window_id`) pauses this phone for the window plus a
|
|
218
|
+
* margin. Firmware without ownership transfer forgets this phone on entry (pairing spec R4),
|
|
219
|
+
* so a notice without `window_id` or `owner_retained` means the glasses dropped it.
|
|
220
|
+
*/
|
|
221
|
+
fun onEnteringPairingMode(
|
|
222
|
+
windowMs: Long?,
|
|
223
|
+
windowId: Int?,
|
|
224
|
+
ownerRetained: Boolean,
|
|
225
|
+
explicitAttempt: Boolean,
|
|
226
|
+
deviceId: String,
|
|
227
|
+
nowMs: Long,
|
|
228
|
+
): MentraLivePairingTransition {
|
|
229
|
+
if (deviceId.isNotEmpty()) this.deviceId = deviceId
|
|
230
|
+
if (explicitAttempt) return none("notice during an explicit attempt")
|
|
231
|
+
if (windowId == null && !ownerRetained) return reject("forget_on_entry")
|
|
232
|
+
val id = windowId ?: UNKNOWN_WINDOW_ID
|
|
233
|
+
val current = state
|
|
234
|
+
if (current is MentraLivePairingState.PairingMode && current.windowId == id) {
|
|
235
|
+
return none("duplicate notice for window $id")
|
|
236
|
+
}
|
|
237
|
+
val pause = MentraLiveOwnerLossPolicy.yieldMs(windowMs ?: 0L)
|
|
238
|
+
state = MentraLivePairingState.PairingMode(id, nowMs + pause)
|
|
239
|
+
pauseMs = pause
|
|
240
|
+
pausedAtMs = nowMs
|
|
241
|
+
endedWindowId = null
|
|
242
|
+
verificationArmed = false
|
|
243
|
+
streak.reset()
|
|
244
|
+
return MentraLivePairingTransition(
|
|
245
|
+
MentraLivePairingEffect.PAUSE,
|
|
246
|
+
true,
|
|
247
|
+
"pairing_mode window=$id pause=${pause}ms",
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* `pairing_mode_ended`. Only a connected phone receives it, so the glasses still admit this
|
|
253
|
+
* phone and there is no takeover to check.
|
|
254
|
+
*/
|
|
255
|
+
fun onPairingModeEnded(reason: String?, windowId: Int?): MentraLivePairingTransition {
|
|
256
|
+
val current = state as? MentraLivePairingState.PairingMode ?: return none("not paused")
|
|
257
|
+
if (windowId != null && current.windowId != UNKNOWN_WINDOW_ID && windowId != current.windowId) {
|
|
258
|
+
return none("ended window $windowId is not ${current.windowId}")
|
|
259
|
+
}
|
|
260
|
+
state = MentraLivePairingState.Normal
|
|
261
|
+
pausedAtMs = null
|
|
262
|
+
endedWindowId = null
|
|
263
|
+
return MentraLivePairingTransition(
|
|
264
|
+
MentraLivePairingEffect.RESUME,
|
|
265
|
+
true,
|
|
266
|
+
"pairing_mode_ended reason=${reason ?: ""}",
|
|
267
|
+
)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* An advertisement from the saved glasses carrying the secure trailer. A cleared pairing flag
|
|
272
|
+
* ends the pause early. After the window this phone paused for, `OWNER_CHANGED` for that
|
|
273
|
+
* window arms exactly one verification attempt.
|
|
274
|
+
*/
|
|
275
|
+
fun onAdvertisement(
|
|
276
|
+
pairingMode: Boolean,
|
|
277
|
+
ownerChanged: Boolean,
|
|
278
|
+
generation: Int?,
|
|
279
|
+
nowMs: Long,
|
|
280
|
+
): MentraLivePairingTransition {
|
|
281
|
+
lastPairingAdvertAtMs = if (pairingMode) nowMs else null
|
|
282
|
+
var effect = MentraLivePairingEffect.NONE
|
|
283
|
+
var changed = false
|
|
284
|
+
val notes = mutableListOf<String>()
|
|
285
|
+
val current = state
|
|
286
|
+
if (current is MentraLivePairingState.PairingMode && !pairingMode) {
|
|
287
|
+
val sincePause = pausedAtMs?.let { nowMs - it } ?: Long.MAX_VALUE
|
|
288
|
+
if (sincePause >= FLAG_CLEAR_GRACE_MS) {
|
|
289
|
+
state = MentraLivePairingState.Normal
|
|
290
|
+
pausedAtMs = null
|
|
291
|
+
endedWindowId = current.windowId
|
|
292
|
+
effect = MentraLivePairingEffect.RESUME
|
|
293
|
+
changed = true
|
|
294
|
+
notes += "pairing flag cleared"
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (state == MentraLivePairingState.Normal && !pairingMode && endedWindowId != null) {
|
|
298
|
+
takeoverAdvertRead = true
|
|
299
|
+
if (ownerChanged &&
|
|
300
|
+
generation != null &&
|
|
301
|
+
generation == endedWindowId &&
|
|
302
|
+
verifiedWindowId != generation
|
|
303
|
+
) {
|
|
304
|
+
verificationArmed = true
|
|
305
|
+
verifiedWindowId = generation
|
|
306
|
+
notes += "owner_changed for window $generation — one verification attempt"
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return MentraLivePairingTransition(effect, changed, notes.joinToString("; ").ifEmpty { "advert" })
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** The pause deadline may have passed. */
|
|
313
|
+
fun onTick(nowMs: Long): MentraLivePairingTransition {
|
|
314
|
+
val current = state as? MentraLivePairingState.PairingMode ?: return none("not paused")
|
|
315
|
+
if (nowMs < current.endsAtMs) return none("pause continues")
|
|
316
|
+
state = MentraLivePairingState.Normal
|
|
317
|
+
pausedAtMs = null
|
|
318
|
+
endedWindowId = current.windowId
|
|
319
|
+
takeoverAdvertRead = false
|
|
320
|
+
return MentraLivePairingTransition(MentraLivePairingEffect.RESUME, true, "pause deadline passed")
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* One authentication-type failure (Android HCI 0x05/0x0F or bond removed, iOS CBError 14 or
|
|
325
|
+
* insufficient authentication/encryption). The intentional disconnect at window entry, the
|
|
326
|
+
* glasses' 0x14 refusals and anything while pairing mode is in effect never count.
|
|
327
|
+
*/
|
|
328
|
+
fun onAuthRejection(
|
|
329
|
+
secureFirmware: Boolean,
|
|
330
|
+
explicitAttempt: Boolean,
|
|
331
|
+
nowMs: Long,
|
|
332
|
+
): MentraLivePairingTransition {
|
|
333
|
+
if (!secureFirmware) return none("legacy firmware")
|
|
334
|
+
if (explicitAttempt) return none("explicit attempt")
|
|
335
|
+
when (state) {
|
|
336
|
+
is MentraLivePairingState.PairingMode -> return none("pairing_mode in effect")
|
|
337
|
+
MentraLivePairingState.Rejected -> return none("already rejected")
|
|
338
|
+
MentraLivePairingState.Normal -> Unit
|
|
339
|
+
}
|
|
340
|
+
if (advertShowsPairingMode(nowMs)) return none("advertisement shows pairing mode")
|
|
341
|
+
if (verificationArmed) {
|
|
342
|
+
return reject("owner_changed_verification_failed")
|
|
343
|
+
}
|
|
344
|
+
if (!streak.record(nowMs)) return none("counted toward the rejection streak")
|
|
345
|
+
return reject("auth_rejection_streak")
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** The verification attempt ended without an authentication verdict. */
|
|
349
|
+
fun onConnectAttemptFailed(): MentraLivePairingTransition {
|
|
350
|
+
if (!verificationArmed) return none("no verification")
|
|
351
|
+
verificationArmed = false
|
|
352
|
+
return none("verification attempt failed without an auth verdict")
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** A session reached ready. An explicit pair clears [MentraLivePairingState.Rejected]. */
|
|
356
|
+
fun onSessionReady(explicitAttempt: Boolean): MentraLivePairingTransition {
|
|
357
|
+
streak.reset()
|
|
358
|
+
verificationArmed = false
|
|
359
|
+
endedWindowId = null
|
|
360
|
+
lastPairingAdvertAtMs = null
|
|
361
|
+
val current = state
|
|
362
|
+
val clear =
|
|
363
|
+
current is MentraLivePairingState.PairingMode ||
|
|
364
|
+
(explicitAttempt && current == MentraLivePairingState.Rejected)
|
|
365
|
+
if (!clear) return none("ready")
|
|
366
|
+
state = MentraLivePairingState.Normal
|
|
367
|
+
pausedAtMs = null
|
|
368
|
+
return MentraLivePairingTransition(
|
|
369
|
+
MentraLivePairingEffect.NONE,
|
|
370
|
+
true,
|
|
371
|
+
if (explicitAttempt) "explicit pair succeeded" else "glasses admitted this phone",
|
|
372
|
+
)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
fun onForget(): MentraLivePairingTransition {
|
|
376
|
+
val changed = state != MentraLivePairingState.Normal
|
|
377
|
+
clearRecord()
|
|
378
|
+
deviceId = ""
|
|
379
|
+
return MentraLivePairingTransition(MentraLivePairingEffect.NONE, changed, "forget")
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
private fun advertShowsPairingMode(nowMs: Long): Boolean =
|
|
383
|
+
lastPairingAdvertAtMs?.let { nowMs - it in 0..PAIRING_ADVERT_FRESH_MS } == true
|
|
384
|
+
|
|
385
|
+
private fun reject(reason: String): MentraLivePairingTransition {
|
|
386
|
+
state = MentraLivePairingState.Rejected
|
|
387
|
+
pausedAtMs = null
|
|
388
|
+
endedWindowId = null
|
|
389
|
+
verificationArmed = false
|
|
390
|
+
streak.reset()
|
|
391
|
+
return MentraLivePairingTransition(MentraLivePairingEffect.REJECT, true, reason)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
private fun clearRecord() {
|
|
395
|
+
resetInMemory()
|
|
396
|
+
state = MentraLivePairingState.Normal
|
|
397
|
+
endedWindowId = null
|
|
398
|
+
verifiedWindowId = null
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
private fun resetInMemory() {
|
|
402
|
+
pauseMs = 0L
|
|
403
|
+
pausedAtMs = null
|
|
404
|
+
verificationArmed = false
|
|
405
|
+
lastPairingAdvertAtMs = null
|
|
406
|
+
takeoverAdvertRead = false
|
|
407
|
+
streak.reset()
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
private fun none(note: String) = MentraLivePairingTransition(MentraLivePairingEffect.NONE, false, note)
|
|
411
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
package com.mentra.bluetoothsdk.sgcs
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import androidx.annotation.VisibleForTesting
|
|
5
|
+
import com.mentra.bluetoothsdk.Bridge
|
|
6
|
+
import com.mentra.bluetoothsdk.DeviceStore
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Process-wide owner of the [MentraLivePairingStateMachine]. It outlives each [MentraLive]
|
|
10
|
+
* instance so a disconnect or SGC replacement keeps the pause and the rejection, persists the
|
|
11
|
+
* record natively (never through the replayed settings), and publishes
|
|
12
|
+
* `GlassesStatus.pairingState` plus `pairing_state_changed`.
|
|
13
|
+
*/
|
|
14
|
+
internal object MentraLivePairingStateStore {
|
|
15
|
+
private const val PREFS = "mentra_live_owner"
|
|
16
|
+
|
|
17
|
+
private var machine = MentraLivePairingStateMachine()
|
|
18
|
+
private var restored = false
|
|
19
|
+
|
|
20
|
+
/** Drops the in-memory state as a process restart would. Persisted values stay. */
|
|
21
|
+
@VisibleForTesting
|
|
22
|
+
@Synchronized
|
|
23
|
+
internal fun resetForTest() {
|
|
24
|
+
machine = MentraLivePairingStateMachine()
|
|
25
|
+
restored = false
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
val state: MentraLivePairingState
|
|
29
|
+
@Synchronized get() = machine.state
|
|
30
|
+
|
|
31
|
+
val allowsAutomaticReconnect: Boolean
|
|
32
|
+
@Synchronized get() = machine.allowsAutomaticReconnect
|
|
33
|
+
|
|
34
|
+
val isPaused: Boolean
|
|
35
|
+
@Synchronized get() = machine.isPaused
|
|
36
|
+
|
|
37
|
+
val isRejected: Boolean
|
|
38
|
+
@Synchronized get() = machine.state == MentraLivePairingState.Rejected
|
|
39
|
+
|
|
40
|
+
val verificationArmed: Boolean
|
|
41
|
+
@Synchronized get() = machine.verificationArmed
|
|
42
|
+
|
|
43
|
+
/** A window this phone paused for ended and its advertisement has not been read yet. */
|
|
44
|
+
val awaitingTakeoverCheck: Boolean
|
|
45
|
+
@Synchronized get() = machine.awaitingTakeoverCheck
|
|
46
|
+
|
|
47
|
+
@Synchronized
|
|
48
|
+
fun remainingPauseMs(nowMs: Long = System.currentTimeMillis()): Long = machine.remainingPauseMs(nowMs)
|
|
49
|
+
|
|
50
|
+
/** Restore once per process. Later calls only republish. */
|
|
51
|
+
@Synchronized
|
|
52
|
+
fun ensureRestored(savedDeviceId: String, nowMs: Long = System.currentTimeMillis()): MentraLivePairingTransition? {
|
|
53
|
+
if (restored) {
|
|
54
|
+
publish(emitEvent = false)
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
restored = true
|
|
58
|
+
val prefs = prefs()
|
|
59
|
+
val values = prefs?.all ?: emptyMap<String, Any?>()
|
|
60
|
+
val transition = machine.restore(values, savedDeviceId, nowMs)
|
|
61
|
+
prefs?.edit()?.apply {
|
|
62
|
+
MentraLivePairingStateMachine.LEGACY_KEYS.forEach { remove(it) }
|
|
63
|
+
apply()
|
|
64
|
+
}
|
|
65
|
+
persist()
|
|
66
|
+
publish()
|
|
67
|
+
Bridge.log("PAIRFLOW: pairing_state restore note=${transition.note} state=${describe()}")
|
|
68
|
+
return transition
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Run one machine input, persist the record, and publish when the public state changed. */
|
|
72
|
+
@Synchronized
|
|
73
|
+
fun update(
|
|
74
|
+
source: String,
|
|
75
|
+
input: (MentraLivePairingStateMachine) -> MentraLivePairingTransition,
|
|
76
|
+
): MentraLivePairingTransition {
|
|
77
|
+
val before = machine.state
|
|
78
|
+
val beforeRecord = machine.persisted()
|
|
79
|
+
val transition = input(machine)
|
|
80
|
+
// Duplicate-reporting scans feed every advertisement through here.
|
|
81
|
+
if (machine.persisted() != beforeRecord) persist()
|
|
82
|
+
if (machine.state != before) publish()
|
|
83
|
+
if (transition.effect != MentraLivePairingEffect.NONE || machine.state != before) {
|
|
84
|
+
Bridge.log(
|
|
85
|
+
"PAIRFLOW: pairing_state source=$source effect=${transition.effect} " +
|
|
86
|
+
"note=${transition.note} state=${describe()}"
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
return transition
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
@Synchronized
|
|
93
|
+
fun describe(): String =
|
|
94
|
+
when (val current = machine.state) {
|
|
95
|
+
MentraLivePairingState.Normal -> "normal"
|
|
96
|
+
is MentraLivePairingState.PairingMode ->
|
|
97
|
+
"pairing_mode(window=${current.windowId}, endsAtMs=${current.endsAtMs})"
|
|
98
|
+
MentraLivePairingState.Rejected -> "pairing_rejected"
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private fun persist() {
|
|
102
|
+
val prefs = prefs() ?: return
|
|
103
|
+
val editor = prefs.edit()
|
|
104
|
+
MentraLivePairingStateMachine.KEYS.forEach { editor.remove(it) }
|
|
105
|
+
machine.persisted().forEach { (key, value) ->
|
|
106
|
+
when (value) {
|
|
107
|
+
is Int -> editor.putInt(key, value)
|
|
108
|
+
is Long -> editor.putLong(key, value)
|
|
109
|
+
is String -> editor.putString(key, value)
|
|
110
|
+
is Boolean -> editor.putBoolean(key, value)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
editor.apply()
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private fun publish(emitEvent: Boolean = true) {
|
|
117
|
+
val map = machine.state.toMap()
|
|
118
|
+
DeviceStore.apply("glasses", "pairingState", map)
|
|
119
|
+
if (emitEvent) Bridge.sendTypedMessage("pairing_state_changed", mapOf("state" to map))
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private fun prefs() =
|
|
123
|
+
Bridge.getContext()?.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
|
124
|
+
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
package com.mentra.bluetoothsdk
|
|
2
2
|
|
|
3
|
+
import com.mentra.bluetoothsdk.sgcs.GlassesAudioSetupPolicy
|
|
4
|
+
import com.mentra.bluetoothsdk.sgcs.GlassesAudioSetupState
|
|
5
|
+
|
|
3
6
|
data class WifiScanResult(
|
|
4
7
|
val ssid: String,
|
|
5
8
|
val requiresPassword: Boolean,
|
|
@@ -35,6 +38,8 @@ internal data class GlassesStatus(
|
|
|
35
38
|
val voiceActivityDetectionEnabled: Boolean,
|
|
36
39
|
val connectionState: GlassesConnectionState,
|
|
37
40
|
val bluetoothClassicConnected: Boolean,
|
|
41
|
+
val bluetoothClassicProfilesConnected: Boolean,
|
|
42
|
+
val bluetoothAudioRouted: Boolean,
|
|
38
43
|
val signalStrength: Int,
|
|
39
44
|
val signalStrengthUpdatedAt: Long,
|
|
40
45
|
val deviceModel: String,
|
|
@@ -77,6 +82,14 @@ internal data class GlassesStatus(
|
|
|
77
82
|
val controllerBatteryLevel: Int,
|
|
78
83
|
val controllerSignalStrength: Int,
|
|
79
84
|
val ringSignalStrength: Int,
|
|
85
|
+
/** Mentra Live `MentraLivePairingState` map (`{state, windowId?, endsAtMs?}`); absent for other glasses. */
|
|
86
|
+
val pairingState: Map<String, Any>? = null,
|
|
87
|
+
/**
|
|
88
|
+
* `GlassesAudioSetupState` map, on every model. Derived from [fullyBooted] by
|
|
89
|
+
* [GlassesAudioSetupPolicy] rather than stored, so a snapshot that reports ready never
|
|
90
|
+
* carries a stale `idle`.
|
|
91
|
+
*/
|
|
92
|
+
val audioSetup: Map<String, Any> = GlassesAudioSetupPolicy.state(fullyBooted).toMap(),
|
|
80
93
|
) {
|
|
81
94
|
internal fun toMap(): Map<String, Any> {
|
|
82
95
|
val values =
|
|
@@ -85,6 +98,8 @@ internal data class GlassesStatus(
|
|
|
85
98
|
"micEnabled" to micEnabled,
|
|
86
99
|
"voiceActivityDetectionEnabled" to voiceActivityDetectionEnabled,
|
|
87
100
|
"bluetoothClassicConnected" to bluetoothClassicConnected,
|
|
101
|
+
"bluetoothClassicProfilesConnected" to bluetoothClassicProfilesConnected,
|
|
102
|
+
"bluetoothAudioRouted" to bluetoothAudioRouted,
|
|
88
103
|
"signalStrength" to signalStrength,
|
|
89
104
|
"signalStrengthUpdatedAt" to signalStrengthUpdatedAt,
|
|
90
105
|
"deviceModel" to deviceModel,
|
|
@@ -123,6 +138,8 @@ internal data class GlassesStatus(
|
|
|
123
138
|
"ringSignalStrength" to ringSignalStrength,
|
|
124
139
|
)
|
|
125
140
|
systemTimeMs?.let { values["systemTimeMs"] = it }
|
|
141
|
+
pairingState?.let { values["pairingState"] = it }
|
|
142
|
+
values["audioSetup"] = audioSetup
|
|
126
143
|
return values
|
|
127
144
|
}
|
|
128
145
|
|
|
@@ -137,6 +154,9 @@ internal data class GlassesStatus(
|
|
|
137
154
|
?: BluetoothSdkDefaults.VOICE_ACTIVITY_DETECTION_ENABLED,
|
|
138
155
|
connectionState = GlassesConnectionState.fromValue(stringValue(values, "connectionState")),
|
|
139
156
|
bluetoothClassicConnected = boolValue(values, "bluetoothClassicConnected") ?: false,
|
|
157
|
+
// Android has no separate profile/route signal; both mirror the Classic audio state.
|
|
158
|
+
bluetoothClassicProfilesConnected = boolValue(values, "bluetoothClassicConnected") ?: false,
|
|
159
|
+
bluetoothAudioRouted = boolValue(values, "bluetoothClassicConnected") ?: false,
|
|
140
160
|
signalStrength = numberValue(values, "signalStrength") ?: -1,
|
|
141
161
|
signalStrengthUpdatedAt = longValue(values, "signalStrengthUpdatedAt") ?: 0L,
|
|
142
162
|
deviceModel = stringValue(values, "deviceModel") ?: "",
|
|
@@ -174,10 +194,25 @@ internal data class GlassesStatus(
|
|
|
174
194
|
controllerBatteryLevel = numberValue(values, "controllerBatteryLevel") ?: -1,
|
|
175
195
|
controllerSignalStrength = numberValue(values, "controllerSignalStrength") ?: -1,
|
|
176
196
|
ringSignalStrength = numberValue(values, "ringSignalStrength") ?: -1,
|
|
197
|
+
pairingState = pairingStateValue(values),
|
|
177
198
|
)
|
|
178
199
|
}
|
|
179
200
|
}
|
|
180
201
|
|
|
202
|
+
/** `audioSetup` from an event or update map, keeping only a known state and its action. */
|
|
203
|
+
internal fun audioSetupValue(values: Map<String, Any>): Map<String, Any>? =
|
|
204
|
+
GlassesAudioSetupState.fromMap(values["audioSetup"])?.toMap()
|
|
205
|
+
|
|
206
|
+
/** `pairingState` from a store or event map, keeping only string keys. */
|
|
207
|
+
internal fun pairingStateValue(values: Map<String, Any>): Map<String, Any>? {
|
|
208
|
+
val raw = values["pairingState"] as? Map<*, *> ?: return null
|
|
209
|
+
val state = raw["state"] as? String ?: return null
|
|
210
|
+
val result = mutableMapOf<String, Any>("state" to state)
|
|
211
|
+
(raw["windowId"] as? Number)?.let { result["windowId"] = it.toInt() }
|
|
212
|
+
(raw["endsAtMs"] as? Number)?.let { result["endsAtMs"] = it.toLong() }
|
|
213
|
+
return result
|
|
214
|
+
}
|
|
215
|
+
|
|
181
216
|
data class VersionInfoResult(
|
|
182
217
|
val androidVersion: String,
|
|
183
218
|
val firmwareVersion: String,
|
|
@@ -406,6 +441,8 @@ internal data class GlassesStatusUpdate(
|
|
|
406
441
|
val voiceActivityDetectionEnabled: Boolean? = null,
|
|
407
442
|
val connectionState: GlassesConnectionState? = null,
|
|
408
443
|
val bluetoothClassicConnected: Boolean? = null,
|
|
444
|
+
val bluetoothClassicProfilesConnected: Boolean? = null,
|
|
445
|
+
val bluetoothAudioRouted: Boolean? = null,
|
|
409
446
|
val signalStrength: Int? = null,
|
|
410
447
|
val signalStrengthUpdatedAt: Long? = null,
|
|
411
448
|
val deviceModel: String? = null,
|
|
@@ -442,6 +479,9 @@ internal data class GlassesStatusUpdate(
|
|
|
442
479
|
val controllerBatteryLevel: Int? = null,
|
|
443
480
|
val controllerSignalStrength: Int? = null,
|
|
444
481
|
val ringSignalStrength: Int? = null,
|
|
482
|
+
val pairingState: Map<String, Any>? = null,
|
|
483
|
+
/** Explicit `GlassesAudioSetupState` map. When absent, an update that sets [fullyBooted] carries the matching state. */
|
|
484
|
+
val audioSetup: Map<String, Any>? = null,
|
|
445
485
|
) {
|
|
446
486
|
internal fun toMap(): Map<String, Any> =
|
|
447
487
|
buildMap {
|
|
@@ -458,6 +498,8 @@ internal data class GlassesStatusUpdate(
|
|
|
458
498
|
putIfNotNull("micEnabled", micEnabled)
|
|
459
499
|
putIfNotNull("voiceActivityDetectionEnabled", voiceActivityDetectionEnabled)
|
|
460
500
|
putIfNotNull("bluetoothClassicConnected", bluetoothClassicConnected)
|
|
501
|
+
putIfNotNull("bluetoothClassicProfilesConnected", bluetoothClassicProfilesConnected)
|
|
502
|
+
putIfNotNull("bluetoothAudioRouted", bluetoothAudioRouted)
|
|
461
503
|
putIfNotNull("signalStrength", signalStrength)
|
|
462
504
|
putIfNotNull("signalStrengthUpdatedAt", signalStrengthUpdatedAt)
|
|
463
505
|
putIfNotNull("deviceModel", deviceModel)
|
|
@@ -498,6 +540,10 @@ internal data class GlassesStatusUpdate(
|
|
|
498
540
|
putIfNotNull("controllerBatteryLevel", controllerBatteryLevel)
|
|
499
541
|
putIfNotNull("controllerSignalStrength", controllerSignalStrength)
|
|
500
542
|
putIfNotNull("ringSignalStrength", ringSignalStrength)
|
|
543
|
+
pairingState?.let { put("pairingState", it) }
|
|
544
|
+
(audioSetup ?: fullyBooted?.let { GlassesAudioSetupPolicy.state(it).toMap() })?.let {
|
|
545
|
+
put("audioSetup", it)
|
|
546
|
+
}
|
|
501
547
|
}
|
|
502
548
|
|
|
503
549
|
companion object {
|
|
@@ -509,6 +555,8 @@ internal data class GlassesStatusUpdate(
|
|
|
509
555
|
voiceActivityDetectionEnabled = optionalBoolValue(values, "voiceActivityDetectionEnabled"),
|
|
510
556
|
connectionState = GlassesConnectionState.optionalFromValue(optionalStringValue(values, "connectionState")),
|
|
511
557
|
bluetoothClassicConnected = optionalBoolValue(values, "bluetoothClassicConnected"),
|
|
558
|
+
bluetoothClassicProfilesConnected = optionalBoolValue(values, "bluetoothClassicProfilesConnected"),
|
|
559
|
+
bluetoothAudioRouted = optionalBoolValue(values, "bluetoothAudioRouted"),
|
|
512
560
|
signalStrength = optionalNumberValue(values, "signalStrength"),
|
|
513
561
|
signalStrengthUpdatedAt = optionalLongValue(values, "signalStrengthUpdatedAt"),
|
|
514
562
|
deviceModel = optionalStringValue(values, "deviceModel"),
|
|
@@ -559,6 +607,8 @@ internal data class GlassesStatusUpdate(
|
|
|
559
607
|
controllerBatteryLevel = optionalNumberValue(values, "controllerBatteryLevel"),
|
|
560
608
|
controllerSignalStrength = optionalNumberValue(values, "controllerSignalStrength"),
|
|
561
609
|
ringSignalStrength = optionalNumberValue(values, "ringSignalStrength"),
|
|
610
|
+
pairingState = pairingStateValue(values),
|
|
611
|
+
audioSetup = audioSetupValue(values),
|
|
562
612
|
)
|
|
563
613
|
}
|
|
564
614
|
}
|