@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.
Files changed (68) hide show
  1. package/MIGRATION.md +61 -0
  2. package/README.md +47 -0
  3. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +6 -1
  4. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +8 -1
  5. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +48 -4
  6. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +21 -0
  7. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  8. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +2 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/connection/ConnectionModels.kt +5 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/GlassesAudioSetupPolicy.kt +64 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +788 -134
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLiveOwnerLossPolicy.kt +227 -0
  13. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingAdvertisement.kt +19 -0
  14. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingReadinessPolicy.kt +94 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingStateMachine.kt +411 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingStateStore.kt +124 -0
  17. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +50 -0
  18. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerOwnerLossTest.kt +146 -0
  19. package/android/src/test/java/com/mentra/bluetoothsdk/GlassesStatusAudioSetupTest.kt +51 -0
  20. package/android/src/test/java/com/mentra/bluetoothsdk/GlassesStatusClassicAudioTest.kt +2 -0
  21. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/GlassesAudioSetupPolicyTest.kt +65 -0
  22. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveAudioSetupReadinessTest.kt +231 -0
  23. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveOwnerLossPolicyTest.kt +368 -0
  24. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingAdvertisementParserTest.kt +20 -0
  25. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingReadinessPolicyTest.kt +136 -0
  26. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingStateMachineTest.kt +346 -0
  27. package/build/BluetoothSdk.types.d.ts +101 -0
  28. package/build/BluetoothSdk.types.d.ts.map +1 -1
  29. package/build/BluetoothSdk.types.js.map +1 -1
  30. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  31. package/build/_private/BluetoothSdkModule.js +1 -0
  32. package/build/_private/BluetoothSdkModule.js.map +1 -1
  33. package/build/generated/releaseMetadata.js +5 -5
  34. package/build/generated/releaseMetadata.js.map +1 -1
  35. package/build/index.d.ts +1 -1
  36. package/build/index.d.ts.map +1 -1
  37. package/build/index.js +3 -0
  38. package/build/index.js.map +1 -1
  39. package/ios/BluetoothSdkModule.swift +8 -2
  40. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  41. package/ios/Source/Bridge.swift +11 -3
  42. package/ios/Source/DeviceManager.swift +80 -6
  43. package/ios/Source/DeviceStore.swift +27 -0
  44. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  45. package/ios/Source/MentraBluetoothSDK.swift +10 -4
  46. package/ios/Source/errors/BluetoothSdkError.swift +5 -1
  47. package/ios/Source/sgcs/GlassesAudioSetupPolicy.swift +181 -0
  48. package/ios/Source/sgcs/MentraLive.swift +1053 -166
  49. package/ios/Source/sgcs/MentraLivePairingAdvertisement.swift +260 -1
  50. package/ios/Source/sgcs/MentraLivePairingStateMachine.swift +385 -0
  51. package/ios/Source/sgcs/MentraLivePairingStateStore.swift +121 -0
  52. package/ios/Source/sgcs/SGCManager.swift +7 -0
  53. package/ios/Source/sgcs/Simulated.swift +2 -0
  54. package/ios/Source/status/DeviceStatus.swift +35 -0
  55. package/ios/Source/types/DeviceModels.swift +8 -1
  56. package/ios/Source/utils/AudioSessionMonitor.swift +158 -5
  57. package/ios/Tests/BluetoothAudioRouteTests.swift +82 -0
  58. package/ios/Tests/GlassesAudioSetupPolicyTests.swift +191 -0
  59. package/ios/Tests/MentraLiveAudioSetupTests.swift +215 -0
  60. package/ios/Tests/MentraLiveConnectionOptionsTests.swift +55 -0
  61. package/ios/Tests/MentraLivePairingAdvertisementTests.swift +327 -0
  62. package/ios/Tests/MentraLivePairingStateMachineTests.swift +402 -0
  63. package/ios/Tests/MentraLiveSessionReadinessTests.swift +328 -26
  64. package/package.json +3 -2
  65. package/src/BluetoothSdk.types.ts +97 -0
  66. package/src/_private/BluetoothSdkModule.ts +1 -0
  67. package/src/generated/releaseMetadata.ts +5 -5
  68. 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
+ }