Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,29 @@ 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.
// 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)
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) {

Expand All @@ -63,7 +85,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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,14 @@ actor BLEConnection: Connection {
private var connectContinuation: CheckedContinuation<Void, Error>?
private var writeContinuations: [CheckedContinuation<Void, Error>]
private var readContinuations: [CheckedContinuation<Data, Error>]

/// 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<CBUUID> = []
private var isAwaitingNotifyConfirmation: Bool = false

private var rssiTask: Task<Void, Never>?

Expand All @@ -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)
Expand Down Expand Up @@ -275,6 +302,10 @@ actor BLEConnection: Connection {
private func requestRSSIRead() {
peripheral.readRSSI()
}
}

// MARK: - CBPeripheral delegate event handling & I/O
extension BLEConnection {
Comment thread
coderabbitai[bot] marked this conversation as resolved.

func didDiscoverServices(error: Error? ) {
if let error = error {
Expand Down Expand Up @@ -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"))
Expand Down Expand Up @@ -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!")
Expand Down Expand Up @@ -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) }
}
Expand Down
34 changes: 34 additions & 0 deletions Meshtastic/Extensions/UserDefaults.swift
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ extension UserDefaults {
case firmwareUpdateNotificationKeys
case lastEventFirmwareAPIUpdate
case useEventTheme
case pairedPeripheralIds
case migratedPreferredPeripheralPairing
}

func reset() {
Expand Down Expand Up @@ -210,6 +212,38 @@ 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()
}

/// 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 {
Expand Down
112 changes: 112 additions & 0 deletions MeshtasticTests/BLEPairingHintTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
//
// 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)
final class PairedPeripheralHintTests {

private let idA = UUID(uuidString: "00000000-0000-0000-0000-0000000000AA")!
private let idB = UUID(uuidString: "00000000-0000-0000-0000-0000000000BB")!

/// 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() {
#expect(UserDefaults.isPairedPeripheral(idA) == false)

UserDefaults.rememberPairedPeripheral(idA)

#expect(UserDefaults.isPairedPeripheral(idA))
#expect(UserDefaults.isPairedPeripheral(idB) == false)
}

@Test func rememberIsIdempotent() {
UserDefaults.rememberPairedPeripheral(idA)
UserDefaults.rememberPairedPeripheral(idA)

#expect(UserDefaults.pairedPeripheralIds == [idA.uuidString])
}

@Test func forgetRemovesOnlyThatPeripheral() {
UserDefaults.rememberPairedPeripheral(idA)
UserDefaults.rememberPairedPeripheral(idB)

UserDefaults.forgetPairedPeripheral(idA)

#expect(UserDefaults.isPairedPeripheral(idA) == false)
#expect(UserDefaults.isPairedPeripheral(idB))
}

@Test func forgetUnknownPeripheralIsNoOp() {
UserDefaults.rememberPairedPeripheral(idB)

UserDefaults.forgetPairedPeripheral(idA)

#expect(UserDefaults.pairedPeripheralIds == [idB.uuidString])
}

@Test func storedIdsAreSorted() {
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)
}
}
9 changes: 9 additions & 0 deletions docs/developer/transport.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Comment on lines +82 to +90

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check if the bundled HTML was regenerated and included in the recent commits.

if git log -15 --name-only | grep -qi "Meshtastic/Resources/docs/.*\.html"; then
  echo "HTML files are included in the PR."
else
  echo "Warning: No generated HTML docs found in the recent commits."
fi

Repository: meshtastic/Meshtastic-Apple

Length of output: 226


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== status =="
git status --short

echo
echo "== changed files =="
git diff --name-only HEAD~1..HEAD 2>/dev/null || true

echo
echo "== docs html changes in recent diff =="
git diff --name-only HEAD~1..HEAD 2>/dev/null | grep -E '^Meshtastic/Resources/docs/.*\.html$' || true

echo
echo "== transport markdown change =="
git diff --name-only HEAD~1..HEAD 2>/dev/null | grep -E '^docs/developer/transport\.md$' || true

Repository: meshtastic/Meshtastic-Apple

Length of output: 481


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'HEAD: '; git rev-parse --short HEAD
printf 'Branch: '; git branch --show-current || true

echo "Changed files against merge-base with origin/main (if available):"
base=$(git merge-base HEAD origin/main 2>/dev/null || git merge-base HEAD main 2>/dev/null || true)
if [ -n "${base:-}" ]; then
  git diff --name-only "$base"..HEAD | sed 's#^`#-` #'
else
  echo "(no base found)"
fi

echo
echo "Generated docs output present in diff?"
if [ -n "${base:-}" ]; then
  git diff --name-only "$base"..HEAD | grep -E '^Meshtastic/Resources/docs/.*\.html$' || echo "(none)"
fi

echo
echo "Transport markdown present in diff?"
if [ -n "${base:-}" ]; then
  git diff --name-only "$base"..HEAD | grep -E '^docs/developer/transport\.md$' || echo "(none)"
fi

Repository: meshtastic/Meshtastic-Apple

Length of output: 630


Regenerate the bundled HTML docs. docs/developer/transport.md changed, but Meshtastic/Resources/docs wasn’t updated. Run bash scripts/build-docs.sh --output Meshtastic/Resources/docs and include the generated HTML in this PR.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/developer/transport.md` around lines 82 - 90, Regenerate the bundled
HTML documentation from the updated transport guide by running
scripts/build-docs.sh with Meshtastic/Resources/docs as the output directory,
then include all resulting generated HTML changes in the PR.

Source: Coding guidelines

## Adding a New Packet Type

1. Add the protobuf definition in the `protobufs/` submodule.
Expand Down