From 786355d2a3c63c8417ae3f7bda1aa56b2c169d3e Mon Sep 17 00:00:00 2001 From: bruschill <621389+bruschill@users.noreply.github.com> Date: Sat, 18 Jul 2026 17:19:17 -0500 Subject: [PATCH 1/2] fix(ble): keep pairing PIN dialog open on first connect (#2057) The connect handshake ran with hard step timeouts and tore down the CoreBluetooth peripheral on expiry. On a first-ever connection iOS presents the pairing PIN sheet during characteristic subscription, but the old flow resolved Step 1 immediately and a 5s timeout could cancelPeripheralConnection while the sheet was up, dismissing it before the user could enter the PIN. - Gate connect completion on the FROMNUM notify subscription confirming (the definitive bonding-complete signal) instead of resolving optimistically. - Give Step 1 a 90s window for a first-time BLE bond; already-bonded radios (and non-BLE transports) keep the fast 5s fail so dead/out-of-range devices still fail quickly. Seeds the hint from preferredPeripheralId to avoid an upgrade slowdown. - Classify notify-state errors: real auth/encryption failures fail the connect cleanly (with the existing check-your-PIN message); benign per-characteristic errors are ignored. - Resume a suspended connect continuation on disconnect so a link drop during pairing (e.g. user cancels the sheet) fails fast instead of stalling. - Persist bonded peripheral UUIDs in UserDefaults as a self-healing hint. Adds unit tests for the paired-peripheral hint helpers and the pairing-failure error classifier. --- .../AccessoryManager+Connect.swift | 17 ++- .../Bluetooth Low Energy/BLEConnection.swift | 99 +++++++++++++++- Meshtastic/Extensions/UserDefaults.swift | 26 +++++ MeshtasticTests/BLEPairingHintTests.swift | 107 ++++++++++++++++++ 4 files changed, 242 insertions(+), 7 deletions(-) create mode 100644 MeshtasticTests/BLEPairingHintTests.swift diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Connect.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Connect.swift index 2386dec77..54601c9da 100644 --- a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Connect.swift +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Connect.swift @@ -42,7 +42,20 @@ extension AccessoryManager { self.allowDisconnect = true self.userRequestedConnectionCancellation = false - + + // On a first-ever BLE connection, iOS presents the pairing PIN sheet during + // characteristic subscription (Step 1). The user needs time to read and type a + // 6-digit PIN, so give the connect step a long window in that case. Already-bonded + // peripherals (and non-BLE transports) keep the fast timeout so a dead/out-of-range + // radio still fails quickly on reconnect. + // Treat the previously-preferred peripheral as already-bonded too, so users upgrading + // to this build (empty pairedPeripheralIds) don't pay the long pairing window on the + // first reconnect to a radio they already paired before. + let knownBonded = UserDefaults.isPairedPeripheral(device.id) + || device.id.uuidString == UserDefaults.preferredPeripheralId + let isFirstTimeBLEBond = device.transportType == .ble && !knownBonded + let connectStepTimeout: Duration = isFirstTimeBLEBond ? .seconds(90) : .seconds(5) + // Prepare to connect self.connectionStepper = SequentialSteps(maxRetries: retries ?? maxRetries, retryDelay: retryDelay) { @@ -63,7 +76,7 @@ extension AccessoryManager { } // Step 1: Setup the connection - Step(timeout: .seconds(5)) { @MainActor _ in + Step(timeout: connectStepTimeout) { @MainActor _ in Logger.transport.info("🔗👟[Connect] Step 1: connection to \(device.id, privacy: .public)") do { let connection: Connection diff --git a/Meshtastic/Accessory/Transports/Bluetooth Low Energy/BLEConnection.swift b/Meshtastic/Accessory/Transports/Bluetooth Low Energy/BLEConnection.swift index 6650c2f8e..91b644675 100644 --- a/Meshtastic/Accessory/Transports/Bluetooth Low Energy/BLEConnection.swift +++ b/Meshtastic/Accessory/Transports/Bluetooth Low Energy/BLEConnection.swift @@ -53,6 +53,14 @@ actor BLEConnection: Connection { private var connectContinuation: CheckedContinuation? private var writeContinuations: [CheckedContinuation] private var readContinuations: [CheckedContinuation] + + /// Notify characteristics whose subscription must confirm before the connect + /// process is considered complete. Subscribing to an encrypted characteristic is + /// what makes iOS present the pairing PIN sheet on a first-ever connection, so we + /// hold Step 1 open here until bonding actually completes (or fails) rather than + /// resolving optimistically and tearing the connection — and the sheet — down. + private var pendingNotifyConfirmations: Set = [] + private var isAwaitingNotifyConfirmation: Bool = false private var rssiTask: Task? @@ -70,6 +78,25 @@ actor BLEConnection: Connection { } func disconnect(withError error: Error? = nil, shouldReconnect: Bool) async throws { + // If we're torn down while still waiting for the pairing subscription to confirm, + // bonding did not complete. Forget the paired hint so the next attempt uses the + // long pairing window instead of the fast reconnect timeout (self-heals a stale + // hint left by a bond the user removed via iOS Settings > Bluetooth). + if isAwaitingNotifyConfirmation { + isAwaitingNotifyConfirmation = false + pendingNotifyConfirmations.removeAll() + UserDefaults.forgetPairedPeripheral(peripheral.identifier) + } + + // If we're torn down while the connect handshake is still suspended (e.g. the user + // cancels the pairing sheet, which iOS commonly delivers as a peripheral disconnect + // rather than a characteristic auth error), resume the connect continuation now so + // Step 1 fails fast instead of waiting out the full pairing timeout. Idempotent with + // the timeout/cancel paths since continueConnectionProcess nils the continuation. + if connectContinuation != nil { + continueConnectionProcess(throwing: error ?? AccessoryError.disconnected("Disconnected during connect")) + } + if peripheral.state == .connected { if let characteristic = FROMRADIO_characteristic { peripheral.setNotifyValue(false, for: characteristic) @@ -275,6 +302,10 @@ actor BLEConnection: Connection { private func requestRSSIRead() { peripheral.readRSSI() } +} + +// MARK: - CBPeripheral delegate event handling & I/O +extension BLEConnection { func didDiscoverServices(error: Error? ) { if let error = error { @@ -336,11 +367,14 @@ actor BLEConnection: Connection { } if TORADIO_characteristic != nil && FROMRADIO_characteristic != nil && FROMNUM_characteristic != nil { - Logger.transport.info("🛜 [BLE] characteristics ready") - self.continueConnectionProcess() - - // Read initial RSSI on ready - peripheral.readRSSI() + // Gate connect-completion on the FROMNUM notify subscription confirming. + // FROMNUM is always notify-capable and encrypted, so its confirmation is the + // definitive "bonding succeeded" signal on a first-ever (PIN) connection. We + // wait for the CoreBluetooth callback (which does not fire until the user + // dismisses the pairing sheet) instead of resolving immediately. + Logger.transport.info("🛜 [BLE] characteristics ready, awaiting FROMNUM subscription confirmation") + pendingNotifyConfirmations = [FROMNUM_UUID] + isAwaitingNotifyConfirmation = true } else { Logger.transport.info("🛜 [BLE] Missing required characteristics") self.continueConnectionProcess(throwing: AccessoryError.discoveryFailed("Missing required characteristics")) @@ -383,6 +417,57 @@ actor BLEConnection: Connection { } } + func didUpdateNotificationState(characteristic: CBCharacteristic, error: Error?) { + if let error { + Logger.transport.error("🛜 [BLE] Notify state error for \(characteristic.meshtasticCharacteristicName, privacy: .public): \(error, privacy: .public)") + // A pairing/auth failure (wrong or cancelled PIN) surfaces here as a + // CBATTError. Treat it as a bond failure even if it lands on a non-gating + // characteristic (FROMRADIO/LOGRADIO) — encryption failures typically hit + // every subscription. A benign "notify not supported" error on those, by + // contrast, is ignored so it can't fail an otherwise-good connect. + if isAwaitingNotifyConfirmation + && (pendingNotifyConfirmations.contains(characteristic.uuid) || Self.isPairingFailure(error)) { + isAwaitingNotifyConfirmation = false + pendingNotifyConfirmations.removeAll() + UserDefaults.forgetPairedPeripheral(peripheral.identifier) + self.continueConnectionProcess(throwing: error) + } + return + } + + guard isAwaitingNotifyConfirmation else { return } + pendingNotifyConfirmations.remove(characteristic.uuid) + guard pendingNotifyConfirmations.isEmpty else { return } + + // Subscription confirmed — bonding (if any) completed successfully. + isAwaitingNotifyConfirmation = false + // Remember this bond so future reconnects use the fast timeouts. + UserDefaults.rememberPairedPeripheral(peripheral.identifier) + Logger.transport.info("🛜 [BLE] FROMNUM subscription confirmed, connection ready") + self.continueConnectionProcess() + + // Read initial RSSI on ready + peripheral.readRSSI() + } + + /// Whether a notify-state error indicates a BLE pairing/bonding failure (wrong or + /// cancelled PIN) rather than a benign per-characteristic issue such as a + /// characteristic that does not support notifications. + static func isPairingFailure(_ error: Error) -> Bool { + if let attError = error as? CBATTError { + switch attError.code { + case .insufficientAuthentication, .insufficientEncryption, .insufficientAuthorization: + return true + default: + return false + } + } + if let cbError = error as? CBError { + return cbError.code == .encryptionTimedOut || cbError.code == .peerRemovedPairingInformation + } + return false + } + func didWriteValueFor(characteristic: CBCharacteristic, error: Error?) { guard characteristic.uuid == TORADIO_UUID else { Logger.transport.error("🛜 [BLE] didWriteValueFor a characteristic other than TORADIO_UUID. Should not happen!") @@ -530,6 +615,10 @@ class BLEConnectionDelegate: NSObject, CBPeripheralDelegate { Task { await connection?.didUpdateValueFor(characteristic: characteristic, error: error) } } + func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) { + Task { await connection?.didUpdateNotificationState(characteristic: characteristic, error: error) } + } + func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { Task { await connection?.didWriteValueFor(characteristic: characteristic, error: error) } } diff --git a/Meshtastic/Extensions/UserDefaults.swift b/Meshtastic/Extensions/UserDefaults.swift index 9ad7d1758..37383e7fc 100644 --- a/Meshtastic/Extensions/UserDefaults.swift +++ b/Meshtastic/Extensions/UserDefaults.swift @@ -90,6 +90,7 @@ extension UserDefaults { case firmwareUpdateNotificationKeys case lastEventFirmwareAPIUpdate case useEventTheme + case pairedPeripheralIds } func reset() { @@ -210,6 +211,31 @@ extension UserDefaults { keys.insert(key) firmwareUpdateNotificationKeys = keys.sorted() } + + /// UUIDs of BLE peripherals we have successfully bonded with (subscription to an + /// encrypted characteristic confirmed). Used only as a *hint*: on a first-ever + /// connection we allow a long window for the user to enter the pairing PIN, while + /// already-bonded peripherals keep the fast reconnect timeouts. Ground truth is + /// still the CoreBluetooth callbacks — a stale hint only affects the timeout length, + /// never correctness. + @UserDefault(.pairedPeripheralIds, defaultValue: []) + static var pairedPeripheralIds: [String] + + static func isPairedPeripheral(_ id: UUID) -> Bool { + pairedPeripheralIds.contains(id.uuidString) + } + + static func rememberPairedPeripheral(_ id: UUID) { + var ids = Set(pairedPeripheralIds) + guard ids.insert(id.uuidString).inserted else { return } + pairedPeripheralIds = ids.sorted() + } + + static func forgetPairedPeripheral(_ id: UUID) { + var ids = Set(pairedPeripheralIds) + guard ids.remove(id.uuidString) != nil else { return } + pairedPeripheralIds = ids.sorted() + } static var manualConnections: [Device] { get { diff --git a/MeshtasticTests/BLEPairingHintTests.swift b/MeshtasticTests/BLEPairingHintTests.swift new file mode 100644 index 000000000..d9412ebfd --- /dev/null +++ b/MeshtasticTests/BLEPairingHintTests.swift @@ -0,0 +1,107 @@ +// +// BLEPairingHintTests.swift +// MeshtasticTests +// +// Covers the pure-logic pieces of the issue #2057 fix (custom-PIN BLE pairing sheet +// auto-dismissing): the persisted paired-peripheral hint used to pick the connect +// timeout, and the classification of notify-state errors into pairing failures vs. +// benign per-characteristic errors. +// + +import Foundation +import CoreBluetooth +import Testing + +@testable import Meshtastic + +// Serialized: these tests share global UserDefaults state (`pairedPeripheralIds`), +// so they must not run in parallel with each other. +@Suite("Paired peripheral hint", .serialized) +struct PairedPeripheralHintTests { + + private let idA = UUID(uuidString: "00000000-0000-0000-0000-0000000000AA")! + private let idB = UUID(uuidString: "00000000-0000-0000-0000-0000000000BB")! + + /// Start every test from a clean slate so global UserDefaults state doesn't leak. + private func reset() { + UserDefaults.pairedPeripheralIds = [] + } + + @Test func rememberMakesPeripheralKnown() { + reset() + #expect(UserDefaults.isPairedPeripheral(idA) == false) + + UserDefaults.rememberPairedPeripheral(idA) + + #expect(UserDefaults.isPairedPeripheral(idA)) + #expect(UserDefaults.isPairedPeripheral(idB) == false) + } + + @Test func rememberIsIdempotent() { + reset() + UserDefaults.rememberPairedPeripheral(idA) + UserDefaults.rememberPairedPeripheral(idA) + + #expect(UserDefaults.pairedPeripheralIds == [idA.uuidString]) + } + + @Test func forgetRemovesOnlyThatPeripheral() { + reset() + UserDefaults.rememberPairedPeripheral(idA) + UserDefaults.rememberPairedPeripheral(idB) + + UserDefaults.forgetPairedPeripheral(idA) + + #expect(UserDefaults.isPairedPeripheral(idA) == false) + #expect(UserDefaults.isPairedPeripheral(idB)) + } + + @Test func forgetUnknownPeripheralIsNoOp() { + reset() + UserDefaults.rememberPairedPeripheral(idB) + + UserDefaults.forgetPairedPeripheral(idA) + + #expect(UserDefaults.pairedPeripheralIds == [idB.uuidString]) + } + + @Test func storedIdsAreSorted() { + reset() + UserDefaults.rememberPairedPeripheral(idB) + UserDefaults.rememberPairedPeripheral(idA) + + #expect(UserDefaults.pairedPeripheralIds == [idA.uuidString, idB.uuidString].sorted()) + } +} + +@Suite("BLE pairing failure classification") +struct BLEPairingFailureTests { + + @Test func authAndEncryptionAttErrorsArePairingFailures() { + #expect(BLEConnection.isPairingFailure(CBATTError(.insufficientAuthentication))) + #expect(BLEConnection.isPairingFailure(CBATTError(.insufficientEncryption))) + #expect(BLEConnection.isPairingFailure(CBATTError(.insufficientAuthorization))) + } + + @Test func benignAttErrorsAreNotPairingFailures() { + // e.g. a characteristic that doesn't support notifications — must not fail an + // otherwise-good connect. + #expect(BLEConnection.isPairingFailure(CBATTError(.writeNotPermitted)) == false) + #expect(BLEConnection.isPairingFailure(CBATTError(.requestNotSupported)) == false) + } + + @Test func encryptionAndRemovedPairingCbErrorsArePairingFailures() { + #expect(BLEConnection.isPairingFailure(CBError(.encryptionTimedOut))) + #expect(BLEConnection.isPairingFailure(CBError(.peerRemovedPairingInformation))) + } + + @Test func unrelatedCbErrorsAreNotPairingFailures() { + #expect(BLEConnection.isPairingFailure(CBError(.connectionTimeout)) == false) + #expect(BLEConnection.isPairingFailure(CBError(.peripheralDisconnected)) == false) + } + + @Test func genericErrorsAreNotPairingFailures() { + let generic = NSError(domain: "com.example.test", code: 42) + #expect(BLEConnection.isPairingFailure(generic) == false) + } +} From 76310a28dc7ca317af9191f358ebcebb3b803109 Mon Sep 17 00:00:00 2001 From: bruschill <621389+bruschill@users.noreply.github.com> Date: Sat, 18 Jul 2026 17:49:09 -0500 Subject: [PATCH 2/2] fix(ble): self-heal pairing timeout via one-time preferred-peripheral migration Address CodeRabbit review on #2113: - Convert the legacy preferredPeripheralId fallback into a one-time migration guarded by migratedPreferredPeripheralPairing, so a bond removed in iOS Settings self-heals back to the long pairing window instead of being pinned to the fast reconnect timeout. - Restore original pairedPeripheralIds after each test (struct -> final class with init/deinit teardown). - Document the notification-gated handshake, pairing timeout, and paired-hint lifecycle in docs/developer/transport.md. --- .../AccessoryManager+Connect.swift | 17 +++++++++++---- Meshtastic/Extensions/UserDefaults.swift | 8 +++++++ MeshtasticTests/BLEPairingHintTests.swift | 21 ++++++++++++------- docs/developer/transport.md | 9 ++++++++ 4 files changed, 43 insertions(+), 12 deletions(-) diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Connect.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Connect.swift index 54601c9da..77a4820b4 100644 --- a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Connect.swift +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Connect.swift @@ -48,11 +48,20 @@ extension AccessoryManager { // 6-digit PIN, so give the connect step a long window in that case. Already-bonded // peripherals (and non-BLE transports) keep the fast timeout so a dead/out-of-range // radio still fails quickly on reconnect. - // Treat the previously-preferred peripheral as already-bonded too, so users upgrading - // to this build (empty pairedPeripheralIds) don't pay the long pairing window on the - // first reconnect to a radio they already paired before. + // One-time migration: seed pairedPeripheralIds from the legacy preferredPeripheralId so + // users upgrading to this build (empty pairedPeripheralIds) don't pay the long pairing + // window on the first reconnect to a radio they already paired before. After this runs + // once, the preferred-peripheral fallback is never consulted again — the remember/forget + // lifecycle in BLEConnection becomes the sole source of truth. That way a bond the user + // later removes (e.g. via iOS Settings > Bluetooth) self-heals back to the long pairing + // window instead of being pinned to the fast reconnect timeout forever. + if !UserDefaults.migratedPreferredPeripheralPairing { + UserDefaults.migratedPreferredPeripheralPairing = true + if let preferredUUID = UUID(uuidString: UserDefaults.preferredPeripheralId) { + UserDefaults.rememberPairedPeripheral(preferredUUID) + } + } let knownBonded = UserDefaults.isPairedPeripheral(device.id) - || device.id.uuidString == UserDefaults.preferredPeripheralId let isFirstTimeBLEBond = device.transportType == .ble && !knownBonded let connectStepTimeout: Duration = isFirstTimeBLEBond ? .seconds(90) : .seconds(5) diff --git a/Meshtastic/Extensions/UserDefaults.swift b/Meshtastic/Extensions/UserDefaults.swift index 37383e7fc..1e812e0c1 100644 --- a/Meshtastic/Extensions/UserDefaults.swift +++ b/Meshtastic/Extensions/UserDefaults.swift @@ -91,6 +91,7 @@ extension UserDefaults { case lastEventFirmwareAPIUpdate case useEventTheme case pairedPeripheralIds + case migratedPreferredPeripheralPairing } func reset() { @@ -236,6 +237,13 @@ extension UserDefaults { guard ids.remove(id.uuidString) != nil else { return } pairedPeripheralIds = ids.sorted() } + + /// One-time flag: whether the legacy `preferredPeripheralId` has been migrated into + /// `pairedPeripheralIds`. Once set, the preferred-peripheral fallback is never consulted for + /// bonding decisions again, so a bond the user later removes can self-heal back to the long + /// pairing window instead of being pinned to the fast reconnect timeout. + @UserDefault(.migratedPreferredPeripheralPairing, defaultValue: false) + static var migratedPreferredPeripheralPairing: Bool static var manualConnections: [Device] { get { diff --git a/MeshtasticTests/BLEPairingHintTests.swift b/MeshtasticTests/BLEPairingHintTests.swift index d9412ebfd..b2bac1c7b 100644 --- a/MeshtasticTests/BLEPairingHintTests.swift +++ b/MeshtasticTests/BLEPairingHintTests.swift @@ -17,18 +17,27 @@ import Testing // Serialized: these tests share global UserDefaults state (`pairedPeripheralIds`), // so they must not run in parallel with each other. @Suite("Paired peripheral hint", .serialized) -struct PairedPeripheralHintTests { +final class PairedPeripheralHintTests { private let idA = UUID(uuidString: "00000000-0000-0000-0000-0000000000AA")! private let idB = UUID(uuidString: "00000000-0000-0000-0000-0000000000BB")! - /// Start every test from a clean slate so global UserDefaults state doesn't leak. - private func reset() { + /// Snapshot of the real persisted value, captured before each test and restored in `deinit` + /// so this suite leaves no residue for later tests to observe. + private let originalPairedIds: [String] + + /// Swift Testing creates a fresh instance per test, so `init`/`deinit` act as per-test + /// setup/teardown: start every test from a clean slate, then restore the original value. + init() { + originalPairedIds = UserDefaults.pairedPeripheralIds UserDefaults.pairedPeripheralIds = [] } + deinit { + UserDefaults.pairedPeripheralIds = originalPairedIds + } + @Test func rememberMakesPeripheralKnown() { - reset() #expect(UserDefaults.isPairedPeripheral(idA) == false) UserDefaults.rememberPairedPeripheral(idA) @@ -38,7 +47,6 @@ struct PairedPeripheralHintTests { } @Test func rememberIsIdempotent() { - reset() UserDefaults.rememberPairedPeripheral(idA) UserDefaults.rememberPairedPeripheral(idA) @@ -46,7 +54,6 @@ struct PairedPeripheralHintTests { } @Test func forgetRemovesOnlyThatPeripheral() { - reset() UserDefaults.rememberPairedPeripheral(idA) UserDefaults.rememberPairedPeripheral(idB) @@ -57,7 +64,6 @@ struct PairedPeripheralHintTests { } @Test func forgetUnknownPeripheralIsNoOp() { - reset() UserDefaults.rememberPairedPeripheral(idB) UserDefaults.forgetPairedPeripheral(idA) @@ -66,7 +72,6 @@ struct PairedPeripheralHintTests { } @Test func storedIdsAreSorted() { - reset() UserDefaults.rememberPairedPeripheral(idB) UserDefaults.rememberPairedPeripheral(idA) diff --git a/docs/developer/transport.md b/docs/developer/transport.md index 715e54071..cd2615be8 100644 --- a/docs/developer/transport.md +++ b/docs/developer/transport.md @@ -79,6 +79,15 @@ During an explicit radio switch from the Connect view, the app uses the same con This refresh is only enabled for the switch-radio flow. Automatic reconnects and ordinary connects continue using the standard transport handshake without forcing a hardware catalog refresh. +### BLE Pairing PIN Handshake + +A first-ever connection to an encrypted radio makes iOS present a 6-digit pairing PIN sheet. `BLEConnection` gates connect-completion on that bond so the sheet is not torn down before the user can respond: + +- **Notification-gated handshake.** After the required characteristics are discovered, `BLEConnection` does *not* resolve the connect step immediately. It subscribes to the `FROMNUM` notify characteristic (always notify-capable and encrypted) and holds the connect continuation open until `didUpdateNotificationState` confirms the subscription. On a first-ever connection that CoreBluetooth callback does not fire until the user dismisses the pairing sheet, so the connection stays alive while the PIN is entered. +- **Pairing timeout.** `AccessoryManager+Connect` selects the Step 1 timeout based on whether the peripheral is already bonded: a first-time BLE bond gets a long window (90s) so there is time to read and type the PIN, while already-bonded peripherals and non-BLE transports keep the fast reconnect timeout (5s) so a dead/out-of-range radio still fails quickly. +- **Pairing-failure classification.** A wrong or cancelled PIN surfaces as a `CBATTError` (insufficient authentication/encryption/authorization) or a `CBError` (`encryptionTimedOut`, `peerRemovedPairingInformation`). `BLEConnection.isPairingFailure(_:)` distinguishes these bond failures from benign per-characteristic errors (e.g. "notify not supported") so only real failures fail the connect. Cancelling the sheet often arrives as a plain peripheral disconnect, so `disconnect` also resumes any suspended connect continuation to fail Step 1 fast instead of waiting out the full window. +- **Paired-hint lifecycle.** The set of bonded peripheral UUIDs is persisted in `UserDefaults.pairedPeripheralIds`. A confirmed subscription calls `rememberPairedPeripheral`; a bond failure or a teardown while still awaiting confirmation calls `forgetPairedPeripheral`, so a bond the user removes in iOS Settings self-heals back to the long pairing window on the next attempt. The legacy `preferredPeripheralId` is migrated into this list exactly once (guarded by `migratedPreferredPeripheralPairing`) so upgrading users skip the long window on their first reconnect without permanently pinning the fast timeout. + ## Adding a New Packet Type 1. Add the protobuf definition in the `protobufs/` submodule.