@mentra/bluetooth-sdk 3.4.0-dev.993 → 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
|
@@ -3,6 +3,19 @@ import Foundation
|
|
|
3
3
|
struct MentraLivePairingAdvertisement: Equatable {
|
|
4
4
|
let pairingMode: Bool
|
|
5
5
|
let pairingCode: String
|
|
6
|
+
/// The glasses' `window_id`: moves on each pairing-mode entry. Nil on firmware that does not
|
|
7
|
+
/// send it. A stable extra byte (the legacy MAC that follows the trailer) is not a new entry.
|
|
8
|
+
let pairingGeneration: Int?
|
|
9
|
+
/// Trailer flag `OWNER_CHANGED`: the window `pairingGeneration` committed a different phone.
|
|
10
|
+
/// Unauthenticated, so it only triggers one verification reconnect.
|
|
11
|
+
let ownerChanged: Bool
|
|
12
|
+
|
|
13
|
+
init(pairingMode: Bool, pairingCode: String, pairingGeneration: Int? = nil, ownerChanged: Bool = false) {
|
|
14
|
+
self.pairingMode = pairingMode
|
|
15
|
+
self.pairingCode = pairingCode
|
|
16
|
+
self.pairingGeneration = pairingGeneration
|
|
17
|
+
self.ownerChanged = ownerChanged
|
|
18
|
+
}
|
|
6
19
|
|
|
7
20
|
private static let manufacturerId: UInt16 = 0xB822
|
|
8
21
|
private static let companyIdLength = 2
|
|
@@ -16,6 +29,7 @@ struct MentraLivePairingAdvertisement: Equatable {
|
|
|
16
29
|
private static let magicSecondOffset = magicFirstOffset + 1
|
|
17
30
|
private static let protocolVersionRange = 2 ... 15
|
|
18
31
|
private static let securePairingCapability = 0x01
|
|
32
|
+
private static let ownerChangedFlag = 0x04
|
|
19
33
|
private static let magicFirst: UInt8 = 0x4D // M
|
|
20
34
|
private static let magicSecond: UInt8 = 0x50 // P
|
|
21
35
|
|
|
@@ -47,13 +61,49 @@ struct MentraLivePairingAdvertisement: Equatable {
|
|
|
47
61
|
|
|
48
62
|
let codeLow = Int(data[payloadBase + codeLowOffset])
|
|
49
63
|
let codeHigh = Int(data[payloadBase + codeHighOffset])
|
|
64
|
+
let generationIndex = payloadBase + magicSecondOffset + 1
|
|
65
|
+
let pairingGeneration = data.count > generationIndex ? Int(data[generationIndex]) : nil
|
|
50
66
|
return MentraLivePairingAdvertisement(
|
|
51
67
|
pairingMode: data[payloadBase + pairingFlagOffset] == pairingDiscoverable,
|
|
52
|
-
pairingCode: String(format: "%02X%02X", codeHigh, codeLow)
|
|
68
|
+
pairingCode: String(format: "%02X%02X", codeHigh, codeLow),
|
|
69
|
+
pairingGeneration: pairingGeneration,
|
|
70
|
+
ownerChanged: capability & ownerChangedFlag != 0
|
|
53
71
|
)
|
|
54
72
|
}
|
|
55
73
|
}
|
|
56
74
|
|
|
75
|
+
/// How to reach glasses that already left discoverable advertising. A fresh name
|
|
76
|
+
/// scan cannot see them after the BLE bond; the peripheral found during pairing
|
|
77
|
+
/// has to be connected directly.
|
|
78
|
+
enum MentraLiveKnownConnectRoute: String {
|
|
79
|
+
case waitForPower
|
|
80
|
+
case cachedPeripheral
|
|
81
|
+
case savedIdentifier
|
|
82
|
+
case alreadyConnected
|
|
83
|
+
case scan
|
|
84
|
+
|
|
85
|
+
static func decide(
|
|
86
|
+
bluetoothPoweredOn: Bool,
|
|
87
|
+
hasCachedPeripheral: Bool,
|
|
88
|
+
hasSavedIdentifier: Bool,
|
|
89
|
+
systemConnectedMatch: Bool
|
|
90
|
+
) -> MentraLiveKnownConnectRoute {
|
|
91
|
+
if !bluetoothPoweredOn {
|
|
92
|
+
return .waitForPower
|
|
93
|
+
}
|
|
94
|
+
if hasCachedPeripheral {
|
|
95
|
+
return .cachedPeripheral
|
|
96
|
+
}
|
|
97
|
+
if hasSavedIdentifier {
|
|
98
|
+
return .savedIdentifier
|
|
99
|
+
}
|
|
100
|
+
if systemConnectedMatch {
|
|
101
|
+
return .alreadyConnected
|
|
102
|
+
}
|
|
103
|
+
return .scan
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
57
107
|
enum MentraLiveConnectionAttemptPolicy {
|
|
58
108
|
static func shouldAcceptDidConnect(
|
|
59
109
|
pairingYieldActive: Bool,
|
|
@@ -86,6 +136,172 @@ enum MentraLiveConnectionAttemptPolicy {
|
|
|
86
136
|
}
|
|
87
137
|
return peripheralName == savedDeviceName
|
|
88
138
|
}
|
|
139
|
+
|
|
140
|
+
/// `secure_pairing_capable` only means the firmware can do secure pairing.
|
|
141
|
+
/// Ownership is the separate `owner_committed` bit, set after the BLE bond.
|
|
142
|
+
static func pairingInfoConfirmsOwner(ownerCommitted: Bool) -> Bool {
|
|
143
|
+
ownerCommitted
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/// `entering_pairing_mode.window_ms` when the glasses omit it.
|
|
147
|
+
static let defaultPairingWindowMs = 120_000
|
|
148
|
+
|
|
149
|
+
/// Covers the glasses' bounded close deferral (30 s) after the window timer.
|
|
150
|
+
static let pairingResumeMarginMs = 30_000
|
|
151
|
+
|
|
152
|
+
/// How long automatic reconnect pauses for a window: the window plus the close-deferral
|
|
153
|
+
/// margin. No floor beyond a missing value and no upper clamp; the glasses own the window.
|
|
154
|
+
/// Mirrors Android `MentraLiveOwnerLossPolicy.yieldMs`.
|
|
155
|
+
static func yieldMs(windowMs: Int) -> Int {
|
|
156
|
+
(windowMs > 0 ? windowMs : defaultPairingWindowMs) + pairingResumeMarginMs
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/// Automatic reconnect must not take the single BLE slot while the saved glasses advertise
|
|
160
|
+
/// pairing mode. An explicit pairing attempt (the new phone, or Pair Again) still connects.
|
|
161
|
+
/// Mirrors Android `MentraLiveOwnerLossPolicy.shouldStandDownInsteadOfReconnect`.
|
|
162
|
+
static func shouldStandDownInsteadOfReconnect(
|
|
163
|
+
pairingMode: Bool,
|
|
164
|
+
savedGlasses: Bool,
|
|
165
|
+
explicitPairing: Bool
|
|
166
|
+
) -> Bool {
|
|
167
|
+
pairingMode && savedGlasses && !explicitPairing
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/// Remaining pause after a process restart. A missing, expired, or implausible deadline (more
|
|
171
|
+
/// than the pause that was persisted with it) returns 0 so the phone reconnects. Mirrors
|
|
172
|
+
/// Android `MentraLiveOwnerLossPolicy.persistedYieldRemainingMs`.
|
|
173
|
+
static func persistedYieldRemainingMs(untilWallMs: Int, nowWallMs: Int, pauseMs: Int) -> Int {
|
|
174
|
+
guard untilWallMs > 0, pauseMs > 0 else { return 0 }
|
|
175
|
+
let remaining = untilWallMs - nowWallMs
|
|
176
|
+
guard remaining > 0, remaining <= pauseMs else { return 0 }
|
|
177
|
+
return remaining
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/// Readiness parked for the owner commit always has a deadline: an explicit secure attempt
|
|
181
|
+
/// arms the pairing wait, so a missing bond or owner commit fails the pair instead of leaving
|
|
182
|
+
/// it loading. Mirrors Android `MentraLiveOwnerLossPolicy.shouldRunSecureBondWait`.
|
|
183
|
+
static func shouldRunSecurePairingWait(explicitPairing: Bool, securePairingTarget: Bool) -> Bool {
|
|
184
|
+
explicitPairing && securePairingTarget
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/// How long readiness waits after `glasses_ready` for a `pairing_info` when the selected
|
|
188
|
+
/// glasses' capability is unknown. No `pairing_info` by then means legacy firmware.
|
|
189
|
+
static let unknownCapabilityPairingInfoWaitMs = 5_000
|
|
190
|
+
|
|
191
|
+
/// A ready session that gets no `pairing_info` within this time runs legacy firmware.
|
|
192
|
+
static let secureFirmwareRefreshMs = 15_000
|
|
193
|
+
|
|
194
|
+
/// The selected scan row decides (`pending_device_secure_pairing_capable`). When the row's
|
|
195
|
+
/// capability is unknown, a secure `pairing_info` on this session decides. Never a name seen
|
|
196
|
+
/// earlier: a unit rolled back to legacy firmware keeps its name.
|
|
197
|
+
static func isSecurePairingTarget(selectedCapability: Bool?, securePairingInfoThisSession: Bool) -> Bool {
|
|
198
|
+
selectedCapability ?? securePairingInfoThisSession
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
enum Readiness: Equatable {
|
|
202
|
+
/// Report ready now.
|
|
203
|
+
case ready
|
|
204
|
+
/// Secure pairing: wait for `pairing_info.owner_committed`, with the pairing wait.
|
|
205
|
+
case awaitOwnerCommit
|
|
206
|
+
/// Unknown capability: wait `unknownCapabilityPairingInfoWaitMs` for a `pairing_info`.
|
|
207
|
+
case awaitPairingInfo
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/// What `glasses_ready` means for readiness. Every wait has a deadline.
|
|
211
|
+
static func readinessAtGlassesReady(
|
|
212
|
+
explicitPairing: Bool,
|
|
213
|
+
selectedCapability: Bool?,
|
|
214
|
+
securePairingInfoThisSession: Bool,
|
|
215
|
+
securePairingConfirmed: Bool
|
|
216
|
+
) -> Readiness {
|
|
217
|
+
guard explicitPairing, !securePairingConfirmed else { return .ready }
|
|
218
|
+
if isSecurePairingTarget(
|
|
219
|
+
selectedCapability: selectedCapability,
|
|
220
|
+
securePairingInfoThisSession: securePairingInfoThisSession
|
|
221
|
+
) {
|
|
222
|
+
return .awaitOwnerCommit
|
|
223
|
+
}
|
|
224
|
+
return selectedCapability == nil ? .awaitPairingInfo : .ready
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/// `pair_select` tells the glasses this link is the user's explicit choice (pairing spec V4).
|
|
228
|
+
/// Once per BLE session, only on an explicit attempt to secure glasses, never on an
|
|
229
|
+
/// automatic reconnect. Mirrors Android `MentraLiveOwnerLossPolicy.shouldSendPairSelect`.
|
|
230
|
+
static func shouldSendPairSelect(explicitPairing: Bool, securePairingTarget: Bool) -> Bool {
|
|
231
|
+
explicitPairing && securePairingTarget
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/// `pair_failure.code` when an explicit iOS attempt hits a Bluetooth key the glasses deleted.
|
|
235
|
+
static let iosForgetPairingCode = "ios_forget_pairing"
|
|
236
|
+
|
|
237
|
+
/// iOS CoreBluetooth error 14: the glasses deleted their BLE bond and this phone still
|
|
238
|
+
/// has the old keys. GATT never opens, so the glasses cannot ask for a wipe. Stop
|
|
239
|
+
/// reconnecting and tell the user to forget the glasses in Bluetooth settings.
|
|
240
|
+
/// `promptedAt` is 0 when this phone has not shown that prompt yet.
|
|
241
|
+
static let staleIosBondPromptCooldownSeconds: TimeInterval = 30
|
|
242
|
+
|
|
243
|
+
static func shouldPromptToForgetStaleIosBond(
|
|
244
|
+
peerRemovedPairingInformation: Bool,
|
|
245
|
+
promptedAt: TimeInterval,
|
|
246
|
+
now: TimeInterval
|
|
247
|
+
) -> Bool {
|
|
248
|
+
guard peerRemovedPairingInformation else { return false }
|
|
249
|
+
if promptedAt <= 0 { return true }
|
|
250
|
+
return now - promptedAt >= staleIosBondPromptCooldownSeconds
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/// A connect-time authentication failure on saved secure glasses. Legacy firmware never
|
|
254
|
+
/// rejects a non-owner on purpose, and during an explicit pairing attempt the user is
|
|
255
|
+
/// pairing anew. This is one observation; `MentraLivePairingStateMachine.onAuthRejection`
|
|
256
|
+
/// decides what it adds up to.
|
|
257
|
+
static func isOwnerRejection(
|
|
258
|
+
isAuthFailure: Bool,
|
|
259
|
+
explicitPairing: Bool,
|
|
260
|
+
secureFirmware: Bool
|
|
261
|
+
) -> Bool {
|
|
262
|
+
secureFirmware && !explicitPairing && isAuthFailure
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/// Tracks owner rejections across automatic reconnects. One rejection can be a glasses reboot
|
|
267
|
+
/// or a stale bond, so owner loss needs `rejectionsToStandDown` attempts in a row that span at
|
|
268
|
+
/// least `minSpanMs`. A session that reaches ready calls `reset()`.
|
|
269
|
+
struct MentraLiveOwnerRejectionStreak {
|
|
270
|
+
static let rejectionsToStandDown = 3
|
|
271
|
+
/// The rejections must keep arriving for about this long, so a glasses reboot is not owner loss.
|
|
272
|
+
static let minSpanMs = 45_000
|
|
273
|
+
/// A gap this long between rejections means they are unrelated, not one streak.
|
|
274
|
+
static let streakGapMs = 90_000
|
|
275
|
+
/// One failed attempt can report twice (didFailToConnect and didDisconnect). Count it once.
|
|
276
|
+
static let sameAttemptMs = 2_000
|
|
277
|
+
|
|
278
|
+
private var count = 0
|
|
279
|
+
private var firstAtMs = 0
|
|
280
|
+
private var lastAtMs = 0
|
|
281
|
+
|
|
282
|
+
/// Records one rejection and returns true once the streak means owner loss.
|
|
283
|
+
mutating func record(nowMs: Int) -> Bool {
|
|
284
|
+
if count > 0, nowMs - lastAtMs <= Self.sameAttemptMs {
|
|
285
|
+
return reached(nowMs: nowMs)
|
|
286
|
+
}
|
|
287
|
+
if count > 0, nowMs - lastAtMs > Self.streakGapMs {
|
|
288
|
+
count = 0
|
|
289
|
+
}
|
|
290
|
+
if count == 0 { firstAtMs = nowMs }
|
|
291
|
+
count += 1
|
|
292
|
+
lastAtMs = nowMs
|
|
293
|
+
return reached(nowMs: nowMs)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
mutating func reset() {
|
|
297
|
+
count = 0
|
|
298
|
+
firstAtMs = 0
|
|
299
|
+
lastAtMs = 0
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private func reached(nowMs: Int) -> Bool {
|
|
303
|
+
count >= Self.rejectionsToStandDown && nowMs - firstAtMs >= Self.minSpanMs
|
|
304
|
+
}
|
|
89
305
|
}
|
|
90
306
|
|
|
91
307
|
/// Ownership of the one armed pending reconnect. Each arm returns a new attempt number, so a
|
|
@@ -109,3 +325,46 @@ struct MentraLivePendingReconnect {
|
|
|
109
325
|
isArmed && candidate == attempt
|
|
110
326
|
}
|
|
111
327
|
}
|
|
328
|
+
|
|
329
|
+
enum MentraLiveDiscoveryPolicy {
|
|
330
|
+
/// How long a secure-pairing trailer from one peripheral marks its trailer-less packets as
|
|
331
|
+
/// partial. Short, so glasses rolled back to legacy firmware list as legacy again.
|
|
332
|
+
static let secureTrailerMemoryMs = 10_000
|
|
333
|
+
|
|
334
|
+
/// CoreBluetooth can deliver an advertisement without the manufacturer data that holds the
|
|
335
|
+
/// secure-pairing trailer (for example right after a scan starts or a link drops). A packet
|
|
336
|
+
/// without it is partial only when the same peripheral showed the trailer within
|
|
337
|
+
/// `secureTrailerMemoryMs`; otherwise it is legacy firmware and listed as pairable.
|
|
338
|
+
static func isPartialAdvertisement(secureCapable: Bool, lastSecureTrailerAtMs: Int?, nowMs: Int) -> Bool {
|
|
339
|
+
guard !secureCapable, let lastSecureTrailerAtMs else { return false }
|
|
340
|
+
let elapsedMs = nowMs - lastSecureTrailerAtMs
|
|
341
|
+
return elapsedMs >= 0 && elapsedMs <= secureTrailerMemoryMs
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/// Last time each peripheral advertised the secure-pairing trailer. In memory only, keyed by the
|
|
346
|
+
/// CoreBluetooth identifier so a same-named unit is never affected.
|
|
347
|
+
struct MentraLiveSecureTrailerMemory {
|
|
348
|
+
private var seenAtMs: [UUID: Int] = [:]
|
|
349
|
+
|
|
350
|
+
mutating func recordSecureTrailer(_ identifier: UUID, nowMs: Int) {
|
|
351
|
+
seenAtMs = seenAtMs.filter { nowMs - $0.value <= MentraLiveDiscoveryPolicy.secureTrailerMemoryMs }
|
|
352
|
+
seenAtMs[identifier] = nowMs
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
func hasRecentSecureTrailer(_ identifier: UUID, nowMs: Int) -> Bool {
|
|
356
|
+
isPartialAdvertisement(identifier, secureCapable: false, nowMs: nowMs)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
func isPartialAdvertisement(_ identifier: UUID, secureCapable: Bool, nowMs: Int) -> Bool {
|
|
360
|
+
MentraLiveDiscoveryPolicy.isPartialAdvertisement(
|
|
361
|
+
secureCapable: secureCapable,
|
|
362
|
+
lastSecureTrailerAtMs: seenAtMs[identifier],
|
|
363
|
+
nowMs: nowMs
|
|
364
|
+
)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
mutating func clear() {
|
|
368
|
+
seenAtMs.removeAll()
|
|
369
|
+
}
|
|
370
|
+
}
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Public Mentra Live pairing state (`MentraLivePairingState` in BluetoothSdk.types.ts).
|
|
4
|
+
/// Mirrors Android `MentraLivePairingState`.
|
|
5
|
+
enum MentraLivePairingState: Equatable {
|
|
6
|
+
case normal
|
|
7
|
+
/// The glasses opened a pairing window. Automatic reconnect waits until `endsAtMs` (wall clock).
|
|
8
|
+
case pairingMode(windowId: Int, endsAtMs: Int)
|
|
9
|
+
/// The glasses no longer accept this phone. Only an explicit pair or a forget clears it.
|
|
10
|
+
case rejected
|
|
11
|
+
|
|
12
|
+
func toMap() -> [String: Any] {
|
|
13
|
+
switch self {
|
|
14
|
+
case .normal:
|
|
15
|
+
return ["state": "normal"]
|
|
16
|
+
case let .pairingMode(windowId, endsAtMs):
|
|
17
|
+
return ["state": "pairing_mode", "windowId": windowId, "endsAtMs": endsAtMs]
|
|
18
|
+
case .rejected:
|
|
19
|
+
return ["state": "pairing_rejected"]
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/// What the connection manager must do after a `MentraLivePairingStateMachine` input.
|
|
25
|
+
enum MentraLivePairingEffect: Equatable {
|
|
26
|
+
case none
|
|
27
|
+
/// Leave the link and make no automatic connection until resumed.
|
|
28
|
+
case pause
|
|
29
|
+
/// The window is over: reconnect to the saved glasses.
|
|
30
|
+
case resume
|
|
31
|
+
/// Stop reconnecting. The glasses belong to another phone.
|
|
32
|
+
case reject
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
struct MentraLivePairingTransition: Equatable {
|
|
36
|
+
let effect: MentraLivePairingEffect
|
|
37
|
+
let stateChanged: Bool
|
|
38
|
+
let note: String
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/// Pairing-mode pause/resume, takeover detection and rejection detection for one saved Mentra
|
|
42
|
+
/// Live (pairing spec "Ownership transfer v4"). Pure and clock-free: every input carries `nowMs`
|
|
43
|
+
/// (wall clock, because the pause deadline survives a process restart). Mirrors Android
|
|
44
|
+
/// `MentraLivePairingStateMachine` input for input.
|
|
45
|
+
struct MentraLivePairingStateMachine {
|
|
46
|
+
/// Glasses flag-clear advertisements seen this soon after the notice can predate the window.
|
|
47
|
+
static let flagClearGraceMs = 3_000
|
|
48
|
+
/// A pairing-mode advertisement this recent still means the glasses are in pairing mode.
|
|
49
|
+
static let pairingAdvertFreshMs = 10_000
|
|
50
|
+
/// Window id used when protocol-3 firmware sends `owner_retained` without `window_id`.
|
|
51
|
+
static let unknownWindowId = -1
|
|
52
|
+
|
|
53
|
+
static let keyDeviceId = "pairing_v4_device_id"
|
|
54
|
+
static let keyState = "pairing_v4_state"
|
|
55
|
+
static let keyWindowId = "pairing_v4_window_id"
|
|
56
|
+
static let keyEndsAtMs = "pairing_v4_ends_at_ms"
|
|
57
|
+
static let keyPauseMs = "pairing_v4_pause_ms"
|
|
58
|
+
static let keyEndedWindowId = "pairing_v4_ended_window_id"
|
|
59
|
+
static let keyVerifiedWindowId = "pairing_v4_verified_window_id"
|
|
60
|
+
static let keys = [
|
|
61
|
+
keyDeviceId, keyState, keyWindowId, keyEndsAtMs, keyPauseMs, keyEndedWindowId, keyVerifiedWindowId,
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
/// Pre-v4 stand-down phase. Its presence migrates to `.rejected`.
|
|
65
|
+
static let legacyRejectionPhaseKey = "mentra_live_previous_owner_rejection_phase"
|
|
66
|
+
static let legacyKeys = [
|
|
67
|
+
legacyRejectionPhaseKey,
|
|
68
|
+
"mentra_live_previous_owner_rejection_generation",
|
|
69
|
+
"mentra_live_pairing_yield_until_wall_ms",
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
private static let statePairingMode = "pairing_mode"
|
|
73
|
+
private static let stateRejected = "pairing_rejected"
|
|
74
|
+
|
|
75
|
+
private(set) var state: MentraLivePairingState = .normal
|
|
76
|
+
/// Saved glasses this record belongs to. Empty until known.
|
|
77
|
+
private(set) var deviceId = ""
|
|
78
|
+
/// The next connection attempt is the one verification after an `OWNER_CHANGED` advertisement.
|
|
79
|
+
private(set) var verificationArmed = false
|
|
80
|
+
/// The window this phone paused for and that has ended. Read against `OWNER_CHANGED`.
|
|
81
|
+
private(set) var endedWindowId: Int?
|
|
82
|
+
|
|
83
|
+
private var pauseMs = 0
|
|
84
|
+
private var pausedAtMs: Int?
|
|
85
|
+
private var verifiedWindowId: Int?
|
|
86
|
+
private var lastPairingAdvertAtMs: Int?
|
|
87
|
+
private var takeoverAdvertRead = false
|
|
88
|
+
private var streak = MentraLiveOwnerRejectionStreak()
|
|
89
|
+
|
|
90
|
+
var allowsAutomaticReconnect: Bool { state == .normal }
|
|
91
|
+
|
|
92
|
+
var isPaused: Bool {
|
|
93
|
+
if case .pairingMode = state { return true }
|
|
94
|
+
return false
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/// A window this phone paused for has ended and no post-window advertisement has been read,
|
|
98
|
+
/// so a reconnect should scan first instead of dialing the saved peripheral directly.
|
|
99
|
+
var awaitingTakeoverCheck: Bool {
|
|
100
|
+
state == .normal && endedWindowId != nil && !takeoverAdvertRead
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
func remainingPauseMs(nowMs: Int) -> Int {
|
|
104
|
+
guard case let .pairingMode(_, endsAtMs) = state else { return 0 }
|
|
105
|
+
return max(0, endsAtMs - nowMs)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/// Rebuild from persisted values. Unknown keys and malformed values are ignored. A record for
|
|
109
|
+
/// other glasses, or a pause whose deadline passed or is implausible, does not survive.
|
|
110
|
+
@discardableResult
|
|
111
|
+
mutating func restore(values: [String: Any], savedDeviceId: String, nowMs: Int) -> MentraLivePairingTransition {
|
|
112
|
+
resetInMemory()
|
|
113
|
+
state = .normal
|
|
114
|
+
deviceId = values[Self.keyDeviceId] as? String ?? ""
|
|
115
|
+
endedWindowId = Self.intValue(values[Self.keyEndedWindowId])
|
|
116
|
+
verifiedWindowId = Self.intValue(values[Self.keyVerifiedWindowId])
|
|
117
|
+
if !savedDeviceId.isEmpty, !deviceId.isEmpty, deviceId != savedDeviceId {
|
|
118
|
+
clearRecord()
|
|
119
|
+
deviceId = savedDeviceId
|
|
120
|
+
return none("record belongs to other glasses")
|
|
121
|
+
}
|
|
122
|
+
switch values[Self.keyState] as? String {
|
|
123
|
+
case Self.stateRejected:
|
|
124
|
+
state = .rejected
|
|
125
|
+
return MentraLivePairingTransition(effect: .reject, stateChanged: true, note: "restored rejected")
|
|
126
|
+
case Self.statePairingMode:
|
|
127
|
+
let windowId = Self.intValue(values[Self.keyWindowId])
|
|
128
|
+
let endsAtMs = Self.intValue(values[Self.keyEndsAtMs]) ?? 0
|
|
129
|
+
let pause = Self.intValue(values[Self.keyPauseMs])
|
|
130
|
+
?? MentraLiveConnectionAttemptPolicy.yieldMs(
|
|
131
|
+
windowMs: MentraLiveConnectionAttemptPolicy.defaultPairingWindowMs
|
|
132
|
+
)
|
|
133
|
+
let remaining = MentraLiveConnectionAttemptPolicy.persistedYieldRemainingMs(
|
|
134
|
+
untilWallMs: endsAtMs, nowWallMs: nowMs, pauseMs: pause
|
|
135
|
+
)
|
|
136
|
+
if let windowId, remaining > 0 {
|
|
137
|
+
state = .pairingMode(windowId: windowId, endsAtMs: endsAtMs)
|
|
138
|
+
pauseMs = pause
|
|
139
|
+
return MentraLivePairingTransition(
|
|
140
|
+
effect: .pause,
|
|
141
|
+
stateChanged: true,
|
|
142
|
+
note: "restored pairing_mode window=\(windowId) remaining=\(remaining)ms"
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
if let windowId { endedWindowId = windowId }
|
|
146
|
+
return none("persisted pause ended while the app was away")
|
|
147
|
+
default:
|
|
148
|
+
break
|
|
149
|
+
}
|
|
150
|
+
if values[Self.legacyRejectionPhaseKey] is String {
|
|
151
|
+
state = .rejected
|
|
152
|
+
return MentraLivePairingTransition(effect: .reject, stateChanged: true, note: "migrated legacy stand-down")
|
|
153
|
+
}
|
|
154
|
+
return none("restored normal")
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/// The current record, keyed by `keys`.
|
|
158
|
+
func persisted() -> [String: Any] {
|
|
159
|
+
var values: [String: Any] = [:]
|
|
160
|
+
if !deviceId.isEmpty { values[Self.keyDeviceId] = deviceId }
|
|
161
|
+
switch state {
|
|
162
|
+
case let .pairingMode(windowId, endsAtMs):
|
|
163
|
+
values[Self.keyState] = Self.statePairingMode
|
|
164
|
+
values[Self.keyWindowId] = windowId
|
|
165
|
+
values[Self.keyEndsAtMs] = endsAtMs
|
|
166
|
+
values[Self.keyPauseMs] = pauseMs
|
|
167
|
+
case .rejected:
|
|
168
|
+
values[Self.keyState] = Self.stateRejected
|
|
169
|
+
case .normal:
|
|
170
|
+
break
|
|
171
|
+
}
|
|
172
|
+
if let endedWindowId { values[Self.keyEndedWindowId] = endedWindowId }
|
|
173
|
+
// Armed verification is not persisted, so a window counts as verified only once its
|
|
174
|
+
// verification has been used. After a restart the next OWNER_CHANGED advert re-arms it.
|
|
175
|
+
if !verificationArmed, let verifiedWindowId { values[Self.keyVerifiedWindowId] = verifiedWindowId }
|
|
176
|
+
return values
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/// An automatic connect to `id`. A record for other glasses no longer applies.
|
|
180
|
+
@discardableResult
|
|
181
|
+
mutating func bindDevice(_ id: String) -> MentraLivePairingTransition {
|
|
182
|
+
if id.isEmpty || id == deviceId { return none("same glasses") }
|
|
183
|
+
let hadRecord = !deviceId.isEmpty
|
|
184
|
+
let wasNormal = state == .normal
|
|
185
|
+
if hadRecord { clearRecord() }
|
|
186
|
+
deviceId = id
|
|
187
|
+
return MentraLivePairingTransition(
|
|
188
|
+
effect: .none,
|
|
189
|
+
stateChanged: hadRecord && !wasNormal,
|
|
190
|
+
note: hadRecord ? "record moved to other glasses" : "bound"
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/// `entering_pairing_mode`. Protocol 4 (`window_id`) pauses this phone for the window plus a
|
|
195
|
+
/// margin. Firmware without ownership transfer forgets this phone on entry (pairing spec R4),
|
|
196
|
+
/// so a notice without `window_id` or `owner_retained` means the glasses dropped it.
|
|
197
|
+
@discardableResult
|
|
198
|
+
mutating func onEnteringPairingMode(
|
|
199
|
+
windowMs: Int?,
|
|
200
|
+
windowId: Int?,
|
|
201
|
+
ownerRetained: Bool,
|
|
202
|
+
explicitAttempt: Bool,
|
|
203
|
+
deviceId: String,
|
|
204
|
+
nowMs: Int
|
|
205
|
+
) -> MentraLivePairingTransition {
|
|
206
|
+
if !deviceId.isEmpty { self.deviceId = deviceId }
|
|
207
|
+
if explicitAttempt { return none("notice during an explicit attempt") }
|
|
208
|
+
if windowId == nil, !ownerRetained { return reject("forget_on_entry") }
|
|
209
|
+
let id = windowId ?? Self.unknownWindowId
|
|
210
|
+
if case let .pairingMode(current, _) = state, current == id {
|
|
211
|
+
return none("duplicate notice for window \(id)")
|
|
212
|
+
}
|
|
213
|
+
let pause = MentraLiveConnectionAttemptPolicy.yieldMs(windowMs: windowMs ?? 0)
|
|
214
|
+
state = .pairingMode(windowId: id, endsAtMs: nowMs + pause)
|
|
215
|
+
pauseMs = pause
|
|
216
|
+
pausedAtMs = nowMs
|
|
217
|
+
endedWindowId = nil
|
|
218
|
+
verificationArmed = false
|
|
219
|
+
streak.reset()
|
|
220
|
+
return MentraLivePairingTransition(effect: .pause, stateChanged: true, note: "pairing_mode window=\(id) pause=\(pause)ms")
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/// `pairing_mode_ended`. Only a connected phone receives it, so the glasses still admit this
|
|
224
|
+
/// phone and there is no takeover to check.
|
|
225
|
+
@discardableResult
|
|
226
|
+
mutating func onPairingModeEnded(reason: String?, windowId: Int?) -> MentraLivePairingTransition {
|
|
227
|
+
guard case let .pairingMode(current, _) = state else { return none("not paused") }
|
|
228
|
+
if let windowId, current != Self.unknownWindowId, windowId != current {
|
|
229
|
+
return none("ended window \(windowId) is not \(current)")
|
|
230
|
+
}
|
|
231
|
+
state = .normal
|
|
232
|
+
pausedAtMs = nil
|
|
233
|
+
endedWindowId = nil
|
|
234
|
+
return MentraLivePairingTransition(effect: .resume, stateChanged: true, note: "pairing_mode_ended reason=\(reason ?? "")")
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/// An advertisement from the saved glasses carrying the secure trailer. A cleared pairing flag
|
|
238
|
+
/// ends the pause early. After the window this phone paused for, `OWNER_CHANGED` for that
|
|
239
|
+
/// window arms exactly one verification attempt.
|
|
240
|
+
@discardableResult
|
|
241
|
+
mutating func onAdvertisement(
|
|
242
|
+
pairingMode: Bool,
|
|
243
|
+
ownerChanged: Bool,
|
|
244
|
+
generation: Int?,
|
|
245
|
+
nowMs: Int
|
|
246
|
+
) -> MentraLivePairingTransition {
|
|
247
|
+
lastPairingAdvertAtMs = pairingMode ? nowMs : nil
|
|
248
|
+
var effect = MentraLivePairingEffect.none
|
|
249
|
+
var changed = false
|
|
250
|
+
var notes: [String] = []
|
|
251
|
+
if case let .pairingMode(current, _) = state, !pairingMode {
|
|
252
|
+
let sincePause = pausedAtMs.map { nowMs - $0 } ?? Int.max
|
|
253
|
+
if sincePause >= Self.flagClearGraceMs {
|
|
254
|
+
state = .normal
|
|
255
|
+
pausedAtMs = nil
|
|
256
|
+
endedWindowId = current
|
|
257
|
+
effect = .resume
|
|
258
|
+
changed = true
|
|
259
|
+
notes.append("pairing flag cleared")
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if state == .normal, !pairingMode, endedWindowId != nil {
|
|
263
|
+
takeoverAdvertRead = true
|
|
264
|
+
if ownerChanged, let generation, generation == endedWindowId, verifiedWindowId != generation {
|
|
265
|
+
verificationArmed = true
|
|
266
|
+
verifiedWindowId = generation
|
|
267
|
+
notes.append("owner_changed for window \(generation) — one verification attempt")
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return MentraLivePairingTransition(
|
|
271
|
+
effect: effect,
|
|
272
|
+
stateChanged: changed,
|
|
273
|
+
note: notes.isEmpty ? "advert" : notes.joined(separator: "; ")
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/// The pause deadline may have passed.
|
|
278
|
+
@discardableResult
|
|
279
|
+
mutating func onTick(nowMs: Int) -> MentraLivePairingTransition {
|
|
280
|
+
guard case let .pairingMode(current, endsAtMs) = state else { return none("not paused") }
|
|
281
|
+
if nowMs < endsAtMs { return none("pause continues") }
|
|
282
|
+
state = .normal
|
|
283
|
+
pausedAtMs = nil
|
|
284
|
+
endedWindowId = current
|
|
285
|
+
takeoverAdvertRead = false
|
|
286
|
+
return MentraLivePairingTransition(effect: .resume, stateChanged: true, note: "pause deadline passed")
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/// One authentication-type failure (Android HCI 0x05/0x0F or bond removed, iOS CBError 14 or
|
|
290
|
+
/// insufficient authentication/encryption). The intentional disconnect at window entry, the
|
|
291
|
+
/// glasses' 0x14 refusals and anything while pairing mode is in effect never count.
|
|
292
|
+
@discardableResult
|
|
293
|
+
mutating func onAuthRejection(
|
|
294
|
+
secureFirmware: Bool,
|
|
295
|
+
explicitAttempt: Bool,
|
|
296
|
+
nowMs: Int
|
|
297
|
+
) -> MentraLivePairingTransition {
|
|
298
|
+
if !secureFirmware { return none("legacy firmware") }
|
|
299
|
+
if explicitAttempt { return none("explicit attempt") }
|
|
300
|
+
switch state {
|
|
301
|
+
case .pairingMode: return none("pairing_mode in effect")
|
|
302
|
+
case .rejected: return none("already rejected")
|
|
303
|
+
case .normal: break
|
|
304
|
+
}
|
|
305
|
+
if advertShowsPairingMode(nowMs: nowMs) { return none("advertisement shows pairing mode") }
|
|
306
|
+
if verificationArmed { return reject("owner_changed_verification_failed") }
|
|
307
|
+
if !streak.record(nowMs: nowMs) { return none("counted toward the rejection streak") }
|
|
308
|
+
return reject("auth_rejection_streak")
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/// The verification attempt ended without an authentication verdict.
|
|
312
|
+
@discardableResult
|
|
313
|
+
mutating func onConnectAttemptFailed() -> MentraLivePairingTransition {
|
|
314
|
+
guard verificationArmed else { return none("no verification") }
|
|
315
|
+
verificationArmed = false
|
|
316
|
+
return none("verification attempt failed without an auth verdict")
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/// A session reached ready. An explicit pair clears `.rejected`.
|
|
320
|
+
@discardableResult
|
|
321
|
+
mutating func onSessionReady(explicitAttempt: Bool) -> MentraLivePairingTransition {
|
|
322
|
+
streak.reset()
|
|
323
|
+
verificationArmed = false
|
|
324
|
+
endedWindowId = nil
|
|
325
|
+
lastPairingAdvertAtMs = nil
|
|
326
|
+
let clear = isPaused || (explicitAttempt && state == .rejected)
|
|
327
|
+
guard clear else { return none("ready") }
|
|
328
|
+
state = .normal
|
|
329
|
+
pausedAtMs = nil
|
|
330
|
+
return MentraLivePairingTransition(
|
|
331
|
+
effect: .none,
|
|
332
|
+
stateChanged: true,
|
|
333
|
+
note: explicitAttempt ? "explicit pair succeeded" : "glasses admitted this phone"
|
|
334
|
+
)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
@discardableResult
|
|
338
|
+
mutating func onForget() -> MentraLivePairingTransition {
|
|
339
|
+
let changed = state != .normal
|
|
340
|
+
clearRecord()
|
|
341
|
+
deviceId = ""
|
|
342
|
+
return MentraLivePairingTransition(effect: .none, stateChanged: changed, note: "forget")
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
private func advertShowsPairingMode(nowMs: Int) -> Bool {
|
|
346
|
+
guard let at = lastPairingAdvertAtMs else { return false }
|
|
347
|
+
let age = nowMs - at
|
|
348
|
+
return age >= 0 && age <= Self.pairingAdvertFreshMs
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
private mutating func reject(_ reason: String) -> MentraLivePairingTransition {
|
|
352
|
+
state = .rejected
|
|
353
|
+
pausedAtMs = nil
|
|
354
|
+
endedWindowId = nil
|
|
355
|
+
verificationArmed = false
|
|
356
|
+
streak.reset()
|
|
357
|
+
return MentraLivePairingTransition(effect: .reject, stateChanged: true, note: reason)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
private mutating func clearRecord() {
|
|
361
|
+
resetInMemory()
|
|
362
|
+
state = .normal
|
|
363
|
+
endedWindowId = nil
|
|
364
|
+
verifiedWindowId = nil
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
private mutating func resetInMemory() {
|
|
368
|
+
pauseMs = 0
|
|
369
|
+
pausedAtMs = nil
|
|
370
|
+
verificationArmed = false
|
|
371
|
+
lastPairingAdvertAtMs = nil
|
|
372
|
+
takeoverAdvertRead = false
|
|
373
|
+
streak.reset()
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
private func none(_ note: String) -> MentraLivePairingTransition {
|
|
377
|
+
MentraLivePairingTransition(effect: .none, stateChanged: false, note: note)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/// UserDefaults hands back NSNumber; a String or other type is malformed and ignored.
|
|
381
|
+
private static func intValue(_ value: Any?) -> Int? {
|
|
382
|
+
if value is String { return nil }
|
|
383
|
+
return (value as? NSNumber)?.intValue ?? (value as? Int)
|
|
384
|
+
}
|
|
385
|
+
}
|