Skip to content

Commit 786355d

Browse files
committed
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.
1 parent e531f15 commit 786355d

4 files changed

Lines changed: 242 additions & 7 deletions

File tree

Meshtastic/Accessory/Accessory Manager/AccessoryManager+Connect.swift

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,20 @@ extension AccessoryManager {
4242

4343
self.allowDisconnect = true
4444
self.userRequestedConnectionCancellation = false
45-
45+
46+
// On a first-ever BLE connection, iOS presents the pairing PIN sheet during
47+
// characteristic subscription (Step 1). The user needs time to read and type a
48+
// 6-digit PIN, so give the connect step a long window in that case. Already-bonded
49+
// peripherals (and non-BLE transports) keep the fast timeout so a dead/out-of-range
50+
// radio still fails quickly on reconnect.
51+
// Treat the previously-preferred peripheral as already-bonded too, so users upgrading
52+
// to this build (empty pairedPeripheralIds) don't pay the long pairing window on the
53+
// first reconnect to a radio they already paired before.
54+
let knownBonded = UserDefaults.isPairedPeripheral(device.id)
55+
|| device.id.uuidString == UserDefaults.preferredPeripheralId
56+
let isFirstTimeBLEBond = device.transportType == .ble && !knownBonded
57+
let connectStepTimeout: Duration = isFirstTimeBLEBond ? .seconds(90) : .seconds(5)
58+
4659
// Prepare to connect
4760
self.connectionStepper = SequentialSteps(maxRetries: retries ?? maxRetries, retryDelay: retryDelay) {
4861

@@ -63,7 +76,7 @@ extension AccessoryManager {
6376
}
6477

6578
// Step 1: Setup the connection
66-
Step(timeout: .seconds(5)) { @MainActor _ in
79+
Step(timeout: connectStepTimeout) { @MainActor _ in
6780
Logger.transport.info("🔗👟[Connect] Step 1: connection to \(device.id, privacy: .public)")
6881
do {
6982
let connection: Connection

Meshtastic/Accessory/Transports/Bluetooth Low Energy/BLEConnection.swift

Lines changed: 94 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,14 @@ actor BLEConnection: Connection {
5353
private var connectContinuation: CheckedContinuation<Void, Error>?
5454
private var writeContinuations: [CheckedContinuation<Void, Error>]
5555
private var readContinuations: [CheckedContinuation<Data, Error>]
56+
57+
/// Notify characteristics whose subscription must confirm before the connect
58+
/// process is considered complete. Subscribing to an encrypted characteristic is
59+
/// what makes iOS present the pairing PIN sheet on a first-ever connection, so we
60+
/// hold Step 1 open here until bonding actually completes (or fails) rather than
61+
/// resolving optimistically and tearing the connection — and the sheet — down.
62+
private var pendingNotifyConfirmations: Set<CBUUID> = []
63+
private var isAwaitingNotifyConfirmation: Bool = false
5664

5765
private var rssiTask: Task<Void, Never>?
5866

@@ -70,6 +78,25 @@ actor BLEConnection: Connection {
7078
}
7179

7280
func disconnect(withError error: Error? = nil, shouldReconnect: Bool) async throws {
81+
// If we're torn down while still waiting for the pairing subscription to confirm,
82+
// bonding did not complete. Forget the paired hint so the next attempt uses the
83+
// long pairing window instead of the fast reconnect timeout (self-heals a stale
84+
// hint left by a bond the user removed via iOS Settings > Bluetooth).
85+
if isAwaitingNotifyConfirmation {
86+
isAwaitingNotifyConfirmation = false
87+
pendingNotifyConfirmations.removeAll()
88+
UserDefaults.forgetPairedPeripheral(peripheral.identifier)
89+
}
90+
91+
// If we're torn down while the connect handshake is still suspended (e.g. the user
92+
// cancels the pairing sheet, which iOS commonly delivers as a peripheral disconnect
93+
// rather than a characteristic auth error), resume the connect continuation now so
94+
// Step 1 fails fast instead of waiting out the full pairing timeout. Idempotent with
95+
// the timeout/cancel paths since continueConnectionProcess nils the continuation.
96+
if connectContinuation != nil {
97+
continueConnectionProcess(throwing: error ?? AccessoryError.disconnected("Disconnected during connect"))
98+
}
99+
73100
if peripheral.state == .connected {
74101
if let characteristic = FROMRADIO_characteristic {
75102
peripheral.setNotifyValue(false, for: characteristic)
@@ -275,6 +302,10 @@ actor BLEConnection: Connection {
275302
private func requestRSSIRead() {
276303
peripheral.readRSSI()
277304
}
305+
}
306+
307+
// MARK: - CBPeripheral delegate event handling & I/O
308+
extension BLEConnection {
278309

279310
func didDiscoverServices(error: Error? ) {
280311
if let error = error {
@@ -336,11 +367,14 @@ actor BLEConnection: Connection {
336367
}
337368

338369
if TORADIO_characteristic != nil && FROMRADIO_characteristic != nil && FROMNUM_characteristic != nil {
339-
Logger.transport.info("🛜 [BLE] characteristics ready")
340-
self.continueConnectionProcess()
341-
342-
// Read initial RSSI on ready
343-
peripheral.readRSSI()
370+
// Gate connect-completion on the FROMNUM notify subscription confirming.
371+
// FROMNUM is always notify-capable and encrypted, so its confirmation is the
372+
// definitive "bonding succeeded" signal on a first-ever (PIN) connection. We
373+
// wait for the CoreBluetooth callback (which does not fire until the user
374+
// dismisses the pairing sheet) instead of resolving immediately.
375+
Logger.transport.info("🛜 [BLE] characteristics ready, awaiting FROMNUM subscription confirmation")
376+
pendingNotifyConfirmations = [FROMNUM_UUID]
377+
isAwaitingNotifyConfirmation = true
344378
} else {
345379
Logger.transport.info("🛜 [BLE] Missing required characteristics")
346380
self.continueConnectionProcess(throwing: AccessoryError.discoveryFailed("Missing required characteristics"))
@@ -383,6 +417,57 @@ actor BLEConnection: Connection {
383417
}
384418
}
385419

420+
func didUpdateNotificationState(characteristic: CBCharacteristic, error: Error?) {
421+
if let error {
422+
Logger.transport.error("🛜 [BLE] Notify state error for \(characteristic.meshtasticCharacteristicName, privacy: .public): \(error, privacy: .public)")
423+
// A pairing/auth failure (wrong or cancelled PIN) surfaces here as a
424+
// CBATTError. Treat it as a bond failure even if it lands on a non-gating
425+
// characteristic (FROMRADIO/LOGRADIO) — encryption failures typically hit
426+
// every subscription. A benign "notify not supported" error on those, by
427+
// contrast, is ignored so it can't fail an otherwise-good connect.
428+
if isAwaitingNotifyConfirmation
429+
&& (pendingNotifyConfirmations.contains(characteristic.uuid) || Self.isPairingFailure(error)) {
430+
isAwaitingNotifyConfirmation = false
431+
pendingNotifyConfirmations.removeAll()
432+
UserDefaults.forgetPairedPeripheral(peripheral.identifier)
433+
self.continueConnectionProcess(throwing: error)
434+
}
435+
return
436+
}
437+
438+
guard isAwaitingNotifyConfirmation else { return }
439+
pendingNotifyConfirmations.remove(characteristic.uuid)
440+
guard pendingNotifyConfirmations.isEmpty else { return }
441+
442+
// Subscription confirmed — bonding (if any) completed successfully.
443+
isAwaitingNotifyConfirmation = false
444+
// Remember this bond so future reconnects use the fast timeouts.
445+
UserDefaults.rememberPairedPeripheral(peripheral.identifier)
446+
Logger.transport.info("🛜 [BLE] FROMNUM subscription confirmed, connection ready")
447+
self.continueConnectionProcess()
448+
449+
// Read initial RSSI on ready
450+
peripheral.readRSSI()
451+
}
452+
453+
/// Whether a notify-state error indicates a BLE pairing/bonding failure (wrong or
454+
/// cancelled PIN) rather than a benign per-characteristic issue such as a
455+
/// characteristic that does not support notifications.
456+
static func isPairingFailure(_ error: Error) -> Bool {
457+
if let attError = error as? CBATTError {
458+
switch attError.code {
459+
case .insufficientAuthentication, .insufficientEncryption, .insufficientAuthorization:
460+
return true
461+
default:
462+
return false
463+
}
464+
}
465+
if let cbError = error as? CBError {
466+
return cbError.code == .encryptionTimedOut || cbError.code == .peerRemovedPairingInformation
467+
}
468+
return false
469+
}
470+
386471
func didWriteValueFor(characteristic: CBCharacteristic, error: Error?) {
387472
guard characteristic.uuid == TORADIO_UUID else {
388473
Logger.transport.error("🛜 [BLE] didWriteValueFor a characteristic other than TORADIO_UUID. Should not happen!")
@@ -530,6 +615,10 @@ class BLEConnectionDelegate: NSObject, CBPeripheralDelegate {
530615
Task { await connection?.didUpdateValueFor(characteristic: characteristic, error: error) }
531616
}
532617

618+
func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) {
619+
Task { await connection?.didUpdateNotificationState(characteristic: characteristic, error: error) }
620+
}
621+
533622
func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
534623
Task { await connection?.didWriteValueFor(characteristic: characteristic, error: error) }
535624
}

Meshtastic/Extensions/UserDefaults.swift

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ extension UserDefaults {
9090
case firmwareUpdateNotificationKeys
9191
case lastEventFirmwareAPIUpdate
9292
case useEventTheme
93+
case pairedPeripheralIds
9394
}
9495

9596
func reset() {
@@ -210,6 +211,31 @@ extension UserDefaults {
210211
keys.insert(key)
211212
firmwareUpdateNotificationKeys = keys.sorted()
212213
}
214+
215+
/// UUIDs of BLE peripherals we have successfully bonded with (subscription to an
216+
/// encrypted characteristic confirmed). Used only as a *hint*: on a first-ever
217+
/// connection we allow a long window for the user to enter the pairing PIN, while
218+
/// already-bonded peripherals keep the fast reconnect timeouts. Ground truth is
219+
/// still the CoreBluetooth callbacks — a stale hint only affects the timeout length,
220+
/// never correctness.
221+
@UserDefault(.pairedPeripheralIds, defaultValue: [])
222+
static var pairedPeripheralIds: [String]
223+
224+
static func isPairedPeripheral(_ id: UUID) -> Bool {
225+
pairedPeripheralIds.contains(id.uuidString)
226+
}
227+
228+
static func rememberPairedPeripheral(_ id: UUID) {
229+
var ids = Set(pairedPeripheralIds)
230+
guard ids.insert(id.uuidString).inserted else { return }
231+
pairedPeripheralIds = ids.sorted()
232+
}
233+
234+
static func forgetPairedPeripheral(_ id: UUID) {
235+
var ids = Set(pairedPeripheralIds)
236+
guard ids.remove(id.uuidString) != nil else { return }
237+
pairedPeripheralIds = ids.sorted()
238+
}
213239

214240
static var manualConnections: [Device] {
215241
get {
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
//
2+
// BLEPairingHintTests.swift
3+
// MeshtasticTests
4+
//
5+
// Covers the pure-logic pieces of the issue #2057 fix (custom-PIN BLE pairing sheet
6+
// auto-dismissing): the persisted paired-peripheral hint used to pick the connect
7+
// timeout, and the classification of notify-state errors into pairing failures vs.
8+
// benign per-characteristic errors.
9+
//
10+
11+
import Foundation
12+
import CoreBluetooth
13+
import Testing
14+
15+
@testable import Meshtastic
16+
17+
// Serialized: these tests share global UserDefaults state (`pairedPeripheralIds`),
18+
// so they must not run in parallel with each other.
19+
@Suite("Paired peripheral hint", .serialized)
20+
struct PairedPeripheralHintTests {
21+
22+
private let idA = UUID(uuidString: "00000000-0000-0000-0000-0000000000AA")!
23+
private let idB = UUID(uuidString: "00000000-0000-0000-0000-0000000000BB")!
24+
25+
/// Start every test from a clean slate so global UserDefaults state doesn't leak.
26+
private func reset() {
27+
UserDefaults.pairedPeripheralIds = []
28+
}
29+
30+
@Test func rememberMakesPeripheralKnown() {
31+
reset()
32+
#expect(UserDefaults.isPairedPeripheral(idA) == false)
33+
34+
UserDefaults.rememberPairedPeripheral(idA)
35+
36+
#expect(UserDefaults.isPairedPeripheral(idA))
37+
#expect(UserDefaults.isPairedPeripheral(idB) == false)
38+
}
39+
40+
@Test func rememberIsIdempotent() {
41+
reset()
42+
UserDefaults.rememberPairedPeripheral(idA)
43+
UserDefaults.rememberPairedPeripheral(idA)
44+
45+
#expect(UserDefaults.pairedPeripheralIds == [idA.uuidString])
46+
}
47+
48+
@Test func forgetRemovesOnlyThatPeripheral() {
49+
reset()
50+
UserDefaults.rememberPairedPeripheral(idA)
51+
UserDefaults.rememberPairedPeripheral(idB)
52+
53+
UserDefaults.forgetPairedPeripheral(idA)
54+
55+
#expect(UserDefaults.isPairedPeripheral(idA) == false)
56+
#expect(UserDefaults.isPairedPeripheral(idB))
57+
}
58+
59+
@Test func forgetUnknownPeripheralIsNoOp() {
60+
reset()
61+
UserDefaults.rememberPairedPeripheral(idB)
62+
63+
UserDefaults.forgetPairedPeripheral(idA)
64+
65+
#expect(UserDefaults.pairedPeripheralIds == [idB.uuidString])
66+
}
67+
68+
@Test func storedIdsAreSorted() {
69+
reset()
70+
UserDefaults.rememberPairedPeripheral(idB)
71+
UserDefaults.rememberPairedPeripheral(idA)
72+
73+
#expect(UserDefaults.pairedPeripheralIds == [idA.uuidString, idB.uuidString].sorted())
74+
}
75+
}
76+
77+
@Suite("BLE pairing failure classification")
78+
struct BLEPairingFailureTests {
79+
80+
@Test func authAndEncryptionAttErrorsArePairingFailures() {
81+
#expect(BLEConnection.isPairingFailure(CBATTError(.insufficientAuthentication)))
82+
#expect(BLEConnection.isPairingFailure(CBATTError(.insufficientEncryption)))
83+
#expect(BLEConnection.isPairingFailure(CBATTError(.insufficientAuthorization)))
84+
}
85+
86+
@Test func benignAttErrorsAreNotPairingFailures() {
87+
// e.g. a characteristic that doesn't support notifications — must not fail an
88+
// otherwise-good connect.
89+
#expect(BLEConnection.isPairingFailure(CBATTError(.writeNotPermitted)) == false)
90+
#expect(BLEConnection.isPairingFailure(CBATTError(.requestNotSupported)) == false)
91+
}
92+
93+
@Test func encryptionAndRemovedPairingCbErrorsArePairingFailures() {
94+
#expect(BLEConnection.isPairingFailure(CBError(.encryptionTimedOut)))
95+
#expect(BLEConnection.isPairingFailure(CBError(.peerRemovedPairingInformation)))
96+
}
97+
98+
@Test func unrelatedCbErrorsAreNotPairingFailures() {
99+
#expect(BLEConnection.isPairingFailure(CBError(.connectionTimeout)) == false)
100+
#expect(BLEConnection.isPairingFailure(CBError(.peripheralDisconnected)) == false)
101+
}
102+
103+
@Test func genericErrorsAreNotPairingFailures() {
104+
let generic = NSError(domain: "com.example.test", code: 42)
105+
#expect(BLEConnection.isPairingFailure(generic) == false)
106+
}
107+
}

0 commit comments

Comments
 (0)