AccessoryManager abstracts BLE, TCP/IP, and serial transports behind a single interface. Views and services interact only with AccessoryManager — never with transport implementations directly.
Transports live in Meshtastic/Accessory/Transports/:
| File | Protocol | Notes |
|---|---|---|
BLETransport.swift |
CoreBluetooth | Standard BLE connection to radios |
TCPTransport.swift |
Network.framework | Wi-Fi / TCP/IP to radios with networking |
SerialTransport.swift |
IOKit serial | macOS only; USB-serial adapters |
Each transport conforms to a MeshTransport protocol that exposes connect(), disconnect(), send(data:), and a received publisher.
BLETransport.status mirrors CBManagerState via handleCentralState(_:central:). .poweredOn settles on .discovering, not .ready — .ready is only assigned later, by stopScanning(), and only while Bluetooth is still powered on. Every other state — including .poweredOff — settles on .error(...). Concretely, when Bluetooth powers off, status becomes .error(BLETransport.poweredOffStatusMessage) ("Bluetooth is powered off") and stays there. This matches .unauthorized, .unsupported, .resetting, and .unknown, which all settle on .error(...) too.
status is actor-isolated state, so nothing outside BLETransport could observe it changing until statusUpdates() -> AsyncStream<TransportStatus> was added: it replays the current status to a new subscriber, then yields again on every subsequent change (a didSet on status drives the broadcast, guarded so an unchanged value never yields a duplicate). AccessoryManager.observeBLETransportStatus() is the sole subscriber — it consumes the stream for the app's lifetime and mirrors every value onto @Published var bleTransportStatus, from which the computed isBluetoothPoweredOff derives. The Connect tab reads isBluetoothPoweredOff to show an inline "Bluetooth is off" row in Available Radios, since the system "Bluetooth is turned off" alert is intentionally suppressed (CBCentralManagerOptionShowPowerAlertKey: false, see above) and would otherwise be the only in-app signal a BLE user gets.
| Extension | Key Methods |
|---|---|
+Discovery |
startScanning(), stopScanning(), peripheral(_:didDiscover:) |
+Connect |
connect(peripheral:), disconnect(), centralManager(_:didConnect:) |
+ToRadio |
sendPacket(_:), sendWantConfig(), sendWaypoint(_:) |
+FromRadio |
handleFromRadio(_:), handleMeshPacket(_:) |
+Position |
startLocationUpdates(), sendPosition(_:) |
+MQTT |
connectMQTT(), publishPacket(_:), mqttClient(_:didReceiveMessage:) |
+TAK |
handleATAKPluginPacket(_:), handleATAKPluginV2Packet(_:), handleATAKForwarderPacket(_:), sendTAKPacket(_:channel:), sendTAKV2Packet(_:channel:), sendCoTToMeshV2(_:channel:). See TAK Protocol for the V1/V2 wire format detail. |
Radio (BLE/TCP/Serial)
→ Transport.received publisher
→ AccessoryManager+FromRadio.handleFromRadio(_:)
→ Decode protobuf (MeshtasticProtobufs)
→ Route by packet type:
MeshPacket → handleMeshPacket(_:)
NodeInfo → updateNodeInfo(_:)
MyNodeInfo → updateMyNodeInfo(_:)
Config → updateConfig(_:)
...
→ Write to SwiftData via MeshPackets @ModelActor
→ Publish changes via @Published properties (UI updates)
Every transport turns raw inbound bytes into a FromRadio frame through one shared funnel, FromRadioDecoder.classify(_:) in Accessory/Protocols/Connection.swift, so BLE, TCP, and Serial handle a malformed frame identically instead of each rolling its own try? FromRadio(serializedBytes:). It returns a FromRadioDecodeOutcome:
| Outcome | Meaning | Transport action |
|---|---|---|
.decoded(FromRadio) |
Frame decoded cleanly | Yield .data(_) to AccessoryManager |
.skipInvalidUTF8(Error) |
A string field (e.g. a node's long_name) failed SwiftProtobuf's UTF-8 validation |
Log and skip the frame; the connection stays alive and keeps reading |
.failed(Error) |
Genuine framing / wire corruption | BLE & TCP call disconnect(withError:shouldReconnect:) and reconnect; Serial logs and skips |
An invalid encoding in a single string field is a per-field content problem, not a transport failure, so it must not tear down an otherwise healthy stream. SwiftProtobuf validates UTF-8 during decode and throws BinaryDecodingError.invalidUTF8; FromRadioDecoder isolates that case so only genuine framing errors trigger a reconnect.
View / Service
→ AccessoryManager+ToRadio.sendPacket(_:)
→ Encode to protobuf (ToRadio wrapper)
→ Transport.send(data:)
→ Radio
AccessoryManager+Connect runs connection setup as a sequenced series of steps: transport connect, heartbeat, wantConfig, optional database retrieval, and version checks.
During an explicit radio switch from the Connect view, the app uses the same connect pipeline but enables an extra post-config refresh. Once sendWantConfig() completes for the newly selected device, the app first applies the bundled DeviceHardware.json catalog and bundled device images to SwiftData, then schedules MeshtasticAPI.shared.refreshDevicesAPIData() in the background. That network refresh updates the same locally cached hardware catalog from https://api.meshtastic.org/resource/deviceHardware without blocking the rest of the connection sequence.
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.
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:
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.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.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.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.TCPConnection bridges NWConnection's callback API to async/await with checked continuations, and callbacks fire on the private tcp.connection queue while the continuation resumes on the actor. Two rules follow from that split:
stateUpdateHandler must be one-shot. It resumes the connect continuation from .ready, .failed, and .cancelled, and it stays installed until the awaiting task resumes on the actor and reaches the replacement handler. A second terminal state arriving inside that window would resume the same continuation twice, which is an uncatchable SWIFT TASK CONTINUATION MISUSE trap rather than a throwable error. Two paths reach it in practice: .ready followed by the Step 1 timeout or a user disconnect cancelling the step (onCancel calls cancel(), producing .cancelled), and .ready followed by the radio resetting the socket (.failed), which is what a radio that already has a TCP client does. A latch claimed inside the handler guarantees exactly one resume; because the handler only ever runs on the serial tcp.connection queue, a plain flag is sufficient synchronization.stateUpdateHandler from inside the handler after resuming looks like tidy cleanup but races the replacement handler: the nil store runs on tcp.connection while the task that resume just woke is concurrently installing the replacement on the actor, and nothing orders the two. If the nil lands last it erases the replacement, silently disabling the post-ready .failed teardown and its auto-reconnect, and it is an unsynchronized write to the same ARC-managed property from two threads. Installing the replacement is the detach; the latch alone carries the single-resume guarantee, including on the throw path where connect() rethrows and never installs a replacement.send and receiveData must fail when there is no socket. disconnect() sets connection = nil. Optional-chaining the NWConnection call inside a continuation body turns the whole statement into a no-op there: the continuation is created, never handed to a completion handler, and never resumed, so the caller suspends permanently. Both continuation bodies therefore guard let the connection and resume with AccessoryError.disconnected when it is gone. This matters because AccessoryManager.send checks isConnected and then calls connection.send across two separate suspension points, so a teardown can land between the check and the call, and the two heartbeat connect steps that call send (Steps 2 and 4) are constructed with timeout: nil, so nothing times the parked step out. Steps 3 and 5 (sendWantConfig, sendWantDatabase) do carry explicit timeouts, but the ~25 send call sites outside the connect sequence have no timeout wrapper at all.send is still uncancellable. The guard above only covers the no-socket case. A live but stalled NWConnection (.waiting/.preparing, e.g. path loss mid-send) queues the send without ever calling .contentProcessed, and unlike receiveData — which wraps its continuation in withTaskCancellationHandler and cancels the NWConnection in onCancel — the send continuation has no cancellation handler, so cancelling the caller does not free it. Closing this means mirroring receiveData's pattern, which also changes what a cancelled send does to the socket, so it is deliberately left alone rather than folded into the no-socket fix.protobufs/ submodule../scripts/gen_protos.sh.AccessoryManager+FromRadio.handleFromRadio(_:).AccessoryManager+ToRadio.swift.AccessoryManager is not @MainActor. Its @Published properties are observed from SwiftUI views on the main actor. Use await MainActor.run { } when updating published properties from background tasks or CoreBluetooth delegate callbacks.
Background persistence writes must go through the MeshPackets @ModelActor, not the main ModelContext.