User Friendly BLE Transport errors (#1365)

* Remove Stale keys

* update debug logo

* 15 Second heartbeat

* Onboarding updates for network and BLE

* Add transport error enum

* Customize BLE errors

* Add pin errors

* Error cleanup

* Override error text and reconnection logic for 4 BLE errors

* Update Meshtastic/Views/Onboarding/DeviceOnboarding.swift

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Switch back to onFirstAppear

* Update Meshtastic/Views/Onboarding/DeviceOnboarding.swift

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Friendly error for peer removed pairing information

* use radio in all custom BLE errors

* Update info.plist

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Garth Vander Houwen 2025-09-02 22:30:43 -07:00 committed by GitHub
parent 182241c223
commit f99e50f47b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 97 additions and 307 deletions

View file

@ -23126,71 +23126,6 @@
}
}
},
"Node info received for: %@" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Knoteninformation empfangen für: %@"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Information du noeud reçue pour : %@"
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "מידע אודות מכשיר התקבל: %@"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Ricevute informazioni sul nodo per: %@"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "ノード情報を受信しました: %@"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "Odebrano informacje o węźle dla: %@"
}
},
"se" : {
"stringUnit" : {
"state" : "translated",
"value" : "Nodinformation mottagen för: %@"
}
},
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Информације о чвору примљене за: %@"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "Node info received for: %@"
}
},
"zh-Hant-TW" : {
"stringUnit" : {
"state" : "translated",
"value" : "Node info received for: %@"
}
}
}
},
"Node Map" : {
"localizations" : {
"de" : {
@ -26095,71 +26030,6 @@
}
}
},
"Position Packet received from node: %@" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Position empfangen von Knoten: %@"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Paquet de la position reçu du noeud : %@"
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "הודעת מיקום התקבלו מ-%@"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Posizione Pacchetto ricevuto dal nodo: %@"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "ノード %@ から位置パケットを受信しました"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "Odebrano pakiet pozycji od węzła: %@"
}
},
"se" : {
"stringUnit" : {
"state" : "translated",
"value" : "Positionspaket mottaget från nod: %@"
}
},
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Пакет позиције примљен од чвора: %@"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "Position Packet received from node: %@"
}
},
"zh-Hant-TW" : {
"stringUnit" : {
"state" : "translated",
"value" : "Position Packet received from node: %@"
}
}
}
},
"Position Sent" : {
"localizations" : {
"de" : {
@ -35713,41 +35583,6 @@
}
}
},
"The most recent public key for this node does not match the previously recorded key. You can delete the node and let it exchange keys again, but this also may indicate a more serious security problem. Contact the user through another trusted channel to determine if the key change was due to a factory reset or other intentional action." : {
"extractionState" : "stale",
"localizations" : {
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "La chiave pubblica più recente di questo nodo non corrisponde alla chiave registrata in precedenza. È possibile eliminare il nodo e fargli scambiare nuovamente le chiavi, ma questo potrebbe indicare un problema di sicurezza più serio. Contattare l'utente attraverso un altro canale fidato per determinare se la modifica della chiave è dovuta a un reset di fabbrica o a un'altra azione intenzionale."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "このノードの最新の公開キーが以前記録されたキーと一致しません。ノードを削除して再度キー交換を行うことができますが、これはより深刻なセキュリティ問題を示している可能性もあります。信頼できる別のチャンネルを通じてユーザーに連絡し、キーの変更が工場リセットやその他の intentional action によるものかどうかを確認してください。"
}
},
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Најновији јавни кључ за овај чвор се не подудара са претходно снимљеним кључем. Можете избрисати чвор и дозволити му да поново размени кључеве, али ово такође може указивати на озбиљнији безбедносни проблем. Контактирајте корисника преко другог поузданог канала како бисте утврдили да ли је промена кључа резултат фабричког ресетовања или друге намерне акције."
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "该节点的最新公钥与之前记录的公钥不匹配。您可以删除该节点,让它重新交换公钥,但这也可能表明存在更严重的安全问题。通过其他可信渠道联系用户,以确定公钥更改是否是由于出厂重置或其他故意行为造成的。"
}
},
"zh-Hant-TW" : {
"stringUnit" : {
"state" : "translated",
"value" : "此節點最新的公開金鑰與先前記錄的不符。您可以刪除此節點並讓它重新交換金鑰,但這也可能代表出現了更嚴重的安全問題。請透過其他可信的聯絡方式與該使用者確認金鑰變更是否因為恢復原廠設定或其他有意的操作所導致。"
}
}
}
},
"The packet is too large" : {
"localizations" : {
"de" : {
@ -35852,41 +35687,6 @@
}
}
},
"The public key does not match the recorded key. You may delete the node and let it exchange keys again, but this may indicate a more serious security problem. Contact the user through another trusted channel, to determine if the key change was due to a factory reset or other intentional action." : {
"extractionState" : "stale",
"localizations" : {
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "La chiave pubblica non corrisponde alla chiave registrata. È possibile eliminare il nodo e fargli scambiare nuovamente le chiavi, ma questo potrebbe indicare un problema di sicurezza più serio. Contattare l'utente attraverso un altro canale fidato, per determinare se la modifica della chiave è dovuta a un reset di fabbrica o a un'altra azione intenzionale."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "公開キーが記録されたキーと一致しません。ノードを削除して再度キー交換を行うことができますが、これはより深刻なセキュリティ問題を示している可能性があります。信頼できる別のチャンネルを通じてユーザーに連絡し、キーの変更が工場リセットやその他の意図的な操作によるものかどうか確認してください。"
}
},
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Јавни кључ се не подудара са снимљеним кључем. Можете избрисати чвор и дозволити му да поново размени кључеве, али ово може указивати на озбиљнији безбедносни проблем. Контактирајте корисника преко другог поузданог канала како бисте утврдили да ли је промена кључа резултат фабричког ресетовања или друге намерне акције."
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "公钥与记录的公钥不匹配。您可以删除节点,让它重新交换公钥,但这可能表明存在更严重的安全问题。通过其他可信渠道联系用户,以确定公钥更改是否是由于出厂重置或其他故意行为造成的。"
}
},
"zh-Hant-TW" : {
"stringUnit" : {
"state" : "translated",
"value" : "公開金鑰與原先記錄的不符。您可以刪除此節點並讓它重新交換金鑰,但這可能表示存在更嚴重的安全問題。請透過其他可信的聯絡方式與該使用者確認,此次金鑰變更是否因為恢復原廠設定或其他有意的操作所造成。"
}
}
}
},
"The region where you will be using your radios." : {
"localizations" : {
"it" : {

View file

@ -66,7 +66,7 @@ extension AccessoryManager {
UserDefaults.preferredPeripheralId = device.id.uuidString
}
} catch let error as CBError where error.code == .peerRemovedPairingInformation {
await self.connectionStepper?.cancelCurrentlyExecutingStep(withError: error, cancelFullProcess: true)
await self.connectionStepper?.cancelCurrentlyExecutingStep(withError: AccessoryError.coreBluetoothError(error), cancelFullProcess: true)
}
}

View file

@ -6,6 +6,7 @@
import Foundation
import SwiftUI
import MeshtasticProtobufs
import CoreBluetooth
import OSLog
import CocoaMQTT
import Combine
@ -20,6 +21,8 @@ enum AccessoryError: Error, LocalizedError {
case disconnected(String)
case tooManyRetries
case eventStreamCancelled
case coreBluetoothError(CBError)
case coreBluetoothATTError(CBATTError)
var errorDescription: String? {
switch self {
@ -34,13 +37,37 @@ enum AccessoryError: Error, LocalizedError {
case .appError(let message):
return "Application error: \(message)"
case .timeout:
return "Timeout"
return "Connection Timeout"
case .disconnected(let message):
return "Disconnected: \(message)"
case .tooManyRetries:
return "Too Many Retries"
case .eventStreamCancelled:
return "Event stream cancelled"
case .coreBluetoothError(let cbError):
// Map specific CBError values to a more user-friendly message
switch cbError.code {
case .connectionTimeout: // 6
return "The Bluetooth connection to the radio unexpectedly disconnected, it will automatically reconnect to the preferred radio when it comes back in range or is powered back on.".localized
case .peripheralDisconnected: // 7
return "The Bluetooth connection to the radio was disconnected, it will automatically reconnect to the preferred radio when it is powered back on or finishes rebooting.".localized
case .peerRemovedPairingInformation: // 14
return "The radio has deleted its stored pairing information, but your device has not. To resolve this, you must forget the radio under Settings > Bluetooth to clear the old, now invalid, pairing information.".localized
default:
// Fallback for other CBError codes
return "A Bluetooth error occurred: \(cbError.localizedDescription)"
}
case .coreBluetoothATTError(let attError):
// Map specific CBATTError values to a more user-friendly message
switch attError.code {
case .insufficientAuthentication: // 5
return "Bluetooth \(attError.localizedDescription) Please try connecting again and check the BLE PIN carefully.".localized
case .insufficientEncryption: // 15
return "Bluetooth \(attError.localizedDescription) Please try connecting again and check the BLE PIN carefully.".localized
default:
// Fallback for other CBError codes
return "A Bluetooth Attribute Protocol error occurred: \(attError.localizedDescription)"
}
}
}
}
@ -349,14 +376,14 @@ class AccessoryManager: ObservableObject, MqttClientProxyManagerDelegate {
self.processFromRadio(fromRadio)
Task {
await self.heartbeatResponseTimer?.cancel(withReason: "Data packet received")
await self.heartbeatTimer?.reset(delay: .seconds(60.0))
await self.heartbeatTimer?.reset(delay: .seconds(15.0))
}
case .logMessage(let message):
self.didReceiveLog(message: message)
Task {
await self.heartbeatResponseTimer?.cancel(withReason: "Log message packet received")
await self.heartbeatTimer?.reset(delay: .seconds(60.0))
await self.heartbeatTimer?.reset(delay: .seconds(15.0))
}
case .rssiUpdate(let rssi):
@ -698,7 +725,7 @@ extension AccessoryManager {
}
}
}
await self.heartbeatTimer?.reset(delay: .seconds(60.0))
await self.heartbeatTimer?.reset(delay: .seconds(15.0))
}
}

View file

@ -100,9 +100,21 @@ actor BLEConnection: Connection {
if let error {
// Inform the AccessoryManager of the error and intent to reconnect
if shouldReconnect {
connectionStreamContinuation?.yield(.error(error))
if let cbError = error as? CBError {
connectionStreamContinuation?.yield(.error(AccessoryError.coreBluetoothError(cbError)))
} else if let attError = error as? CBATTError {
connectionStreamContinuation?.yield(.error(AccessoryError.coreBluetoothATTError(attError)))
} else {
connectionStreamContinuation?.yield(.error(error))
}
} else {
connectionStreamContinuation?.yield(.errorWithoutReconnect(error))
if let cbError = error as? CBError {
connectionStreamContinuation?.yield(.errorWithoutReconnect(AccessoryError.coreBluetoothError(cbError)))
} else if let attError = error as? CBATTError {
connectionStreamContinuation?.yield(.errorWithoutReconnect(AccessoryError.coreBluetoothATTError(attError)))
} else {
connectionStreamContinuation?.yield(.errorWithoutReconnect(error))
}
}
} else {
connectionStreamContinuation?.yield(.disconnected(shouldReconnect: shouldReconnect))
@ -398,62 +410,32 @@ actor BLEConnection: Connection {
}
func handlePeripheralError(error: Error) async throws {
/// Explicit retries for a few specific errors where we want to re-connect, all other errors should not reconnect automatically
var shouldReconnect = false
switch error {
case let attError as CBATTError:
switch attError.code {
default:
// All CBATTErrors should not try and reconnect
Logger.transport.error("🛜 [BLEConnection] Disconnected with CBATTError code: \(attError.code.rawValue) - \(attError.localizedDescription)")
}
case let cbError as CBError:
switch cbError.code {
case .unknown: // 0
Logger.transport.error("🛜 [BLEConnection] Disconnected due to unknown error.")
case .invalidParameters: // 1
Logger.transport.error("🛜 [BLEConnection] Disconnected due to invalid parameters.")
case .invalidHandle: // 2
Logger.transport.error("🛜 [BLEConnection] Disconnected due to invalid handle.")
case .notConnected: // 3
Logger.transport.error("🛜 [BLEConnection] Disconnected because device was not connected.")
case .outOfSpace: // 4
Logger.transport.error("🛜 [BLEConnection] Disconnected due to out of space.")
case .operationCancelled: // 5
Logger.transport.error("🛜 [BLEConnection] Disconnected due to operation cancelled.")
case .connectionTimeout: // 6
// Happens when the node goes out of range or the shutdown or reset buttons are presses
// Should disconnect, show error, and retry when re-advertised
Logger.transport.error("🛜 [BLEConnection] Disconnected due to connection timeout.")
Logger.transport.error("🛜 [BLEConnection] Disconnected with CBError code: \(cbError.code.rawValue) - \(cbError.localizedDescription)")
shouldReconnect = true
case .peripheralDisconnected: // 7
// Likely prompting for a PIN
// Happens when the node reboots or shuts down intentionally via the firmware or app
// Should disconnect, show error, and retry when re-advertised
Logger.transport.error("🛜 [BLEConnection] Disconnected by peripheral.")
Logger.transport.error("🛜 [BLEConnection] Disconnected with CBError code: \(cbError.code.rawValue) - \(cbError.localizedDescription)")
shouldReconnect = true
case .uuidNotAllowed: // 8
Logger.transport.error("🛜 [BLEConnection] Disconnected due to UUID not allowed.")
case .alreadyAdvertising: // 9
Logger.transport.error("🛜 [BLEConnection] Disconnected because already advertising.")
case .connectionFailed: // 10
Logger.transport.error("🛜 [BLEConnection] Disconnected due to connection failure.")
case .connectionLimitReached: // 11
Logger.transport.error("🛜 [BLEConnection] Disconnected due to connection limit reached.")
case .unknownDevice, .unkownDevice: // 12
Logger.transport.error("🛜 [BLEConnection] Disconnected due to unknown device.")
case .operationNotSupported: // 13
Logger.transport.error("🛜 [BLEConnection] Disconnected due to operation not supported.")
case .peerRemovedPairingInformation: // 14
// Should disconnect and not retry
Logger.transport.error("🛜 [BLEConnection] Disconnected because peer removed pairing information.")
case .encryptionTimedOut: // 15
Logger.transport.error("🛜 [BLEConnection] Disconnected due to encryption timeout.")
case .tooManyLEPairedDevices: // 16
Logger.transport.error("🛜 [BLEConnection] Disconnected due to too many LE paired devices.")
// leGatt cases are watchOS only
case .leGattExceededBackgroundNotificationLimit: // 17
Logger.transport.error("🛜 [BLEConnection] Disconnected due to exceeding LE GATT background notification limit.")
case .leGattNearBackgroundNotificationLimit: // 18
Logger.transport.error("🛜 [BLEConnection] Disconnected due to nearing LE GATT background notification limit.")
@unknown default:
Logger.transport.error("🛜 [BLEConnection] Disconnected due to unknown future error code: \(cbError.code.rawValue)")
default:
Logger.transport.error("🛜 [BLEConnection] Disconnected with CBError code: \(cbError.code.rawValue) - \(cbError.localizedDescription)")
}
case let otherError:
Logger.transport.error("🛜 [BLEConnection] Disconnected with non-CBError: \(otherError.localizedDescription)")
Logger.transport.error("🛜 [BLEConnection] Disconnected with non CBError or CBATTError: \(otherError.localizedDescription)")
}
// Inform the active connection that there was an error and it should disconnect

View file

@ -231,55 +231,19 @@ class BLETransport: Transport {
switch error {
case let cbError as CBError:
switch cbError.code {
case .unknown: // 0
Logger.transport.error("🛜 [BLETransport] Disconnected due to unknown error.")
case .invalidParameters: // 1
Logger.transport.error("🛜 [BLETransport] Disconnected due to invalid parameters.")
case .invalidHandle: // 2
Logger.transport.error("🛜 [BLETransport] Disconnected due to invalid handle.")
case .notConnected: // 3
Logger.transport.error("🛜 [BLETransport] Disconnected because device was not connected.")
case .outOfSpace: // 4
Logger.transport.error("🛜 [BLETransport] Disconnected due to out of space.")
case .operationCancelled: // 5
Logger.transport.error("🛜 [BLETransport] Disconnected due to operation cancelled.")
case .connectionTimeout: // 6
// Happens when the node goes out of range or the shutdown or reset buttons are presses
// Should disconnect, show error, and retry when re-advertised
Logger.transport.error("🛜 [BLETransport] Disconnected due to connection timeout.")
Logger.transport.error("🛜 [BLETransport] Disconnected with CBError code: \(cbError.code.rawValue) - \(cbError.localizedDescription)")
shouldReconnect = true
case .peripheralDisconnected: // 7
// Likely prompting for a PIN
// Happens when the node reboots or shuts down intentionally via the firmware or app
// Should disconnect, show error, and retry when re-advertised
Logger.transport.error("🛜 [BLETransport] Disconnected by peripheral.")
Logger.transport.error("🛜 [BLETransport] Disconnected with CBError code: \(cbError.code.rawValue) - \(cbError.localizedDescription)")
shouldReconnect = true
case .uuidNotAllowed: // 8
Logger.transport.error("🛜 [BLETransport] Disconnected due to UUID not allowed.")
case .alreadyAdvertising: // 9
Logger.transport.error("🛜 [BLETransport] Disconnected because already advertising.")
case .connectionFailed: // 10
Logger.transport.error("🛜 [BLETransport] Disconnected due to connection failure.")
case .connectionLimitReached: // 11
Logger.transport.error("🛜 [BLETransport] Disconnected due to connection limit reached.")
case .unknownDevice, .unkownDevice: // 12
Logger.transport.error("🛜 [BLETransport] Disconnected due to unknown device.")
case .operationNotSupported: // 13
Logger.transport.error("🛜 [BLETransport] Disconnected due to operation not supported.")
case .peerRemovedPairingInformation: // 14
// Should disconnect and not retry
Logger.transport.error("🛜 [BLETransport] Disconnected because peer removed pairing information.")
case .encryptionTimedOut: // 15
Logger.transport.error("🛜 [BLETransport] Disconnected due to encryption timeout.")
case .tooManyLEPairedDevices: // 16
Logger.transport.error("🛜 [BLETransport] Disconnected due to too many LE paired devices.")
// leGatt cases are watchOS only
case .leGattExceededBackgroundNotificationLimit: // 17
Logger.transport.error("🛜 [BLETransport] Disconnected due to exceeding LE GATT background notification limit.")
case .leGattNearBackgroundNotificationLimit: // 18
Logger.transport.error("🛜 [BLETransport] Disconnected due to nearing LE GATT background notification limit.")
@unknown default:
Logger.transport.error("🛜 [BLETransport] Disconnected due to unknown future error code: \(cbError.code.rawValue)")
default:
// Fallback for other CBError codes
Logger.transport.error("🛜 [BLETransport] Disconnected with CBError code: \(cbError.code.rawValue) - \(cbError.localizedDescription)")
}
case let otherError:
Logger.transport.error("🛜 [BLETransport] Disconnected with non-CBError: \(otherError.localizedDescription)")

View file

@ -86,7 +86,7 @@
<string>Intent</string>
</array>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<true/>
<key>LSApplicationCategoryType</key>
<string>public.app-category.utilities</string>
<key>LSHasLocalizedDisplayName</key>
@ -139,6 +139,8 @@
<array>
<string>armv7</string>
</array>
<key>UIRequiresPersistentWiFi</key>
<true/>
<key>UIStatusBarStyle</key>
<string></string>
<key>UISupportedInterfaceOrientations</key>

View file

@ -282,4 +282,3 @@ struct ChannelMessageList: View {
}
}
}

View file

@ -228,6 +228,11 @@ struct DeviceOnboarding: View {
title: "Background Connections".localized,
subtitle: "Background network connections are not supported and may disconnect when you leave the app.".localized
)
makeRow(
icon: "arrow.trianglehead.2.clockwise",
title: "Minimum Firmware Version".localized,
subtitle: "For the best connection experience, minimum firmware version 2.7.4 is required.".localized
)
}
.padding()
}
@ -264,9 +269,9 @@ struct DeviceOnboarding: View {
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
makeRow(
icon: "network",
title: "Network-based Nodes".localized,
subtitle: "The Meshtastic App can connect to and manage network-enabled nodes.".localized
icon: "custom.bluetooth",
title: "Bluetooth Connected Nodes".localized,
subtitle: "The most reliable messaging experience is with Bluetooth Low Energy connected nodes.".localized
)
makeRow(
icon: "person.and.background.dotted",
@ -320,14 +325,25 @@ struct DeviceOnboarding: View {
subtitle: String
) -> some View {
HStack(alignment: .center) {
Image(systemName: icon)
.resizable()
.symbolRenderingMode(.multicolor)
.font(.subheadline)
.aspectRatio(contentMode: .fit)
.padding(.horizontal)
.padding(.vertical, 8)
.frame(width: 72, height: 60)
if icon.starts(with: "custom.") {
Image(icon)
.resizable()
.symbolRenderingMode(.multicolor)
.font(.subheadline)
.aspectRatio(contentMode: .fit)
.padding(.horizontal)
.padding(.vertical, 8)
.frame(width: 72, height: 60)
} else {
Image(systemName: icon)
.resizable()
.symbolRenderingMode(.multicolor)
.font(.subheadline)
.aspectRatio(contentMode: .fit)
.padding(.horizontal)
.padding(.vertical, 8)
.frame(width: 72, height: 60)
}
VStack(alignment: .leading) {
Text(title)
.font(.subheadline.weight(.semibold))