The Meshtastic Apple implementation (iOS/iPadOS/macOS via Catalyst) uses a modular architecture with separate transport layers (BLE, TCP, Serial) and a unified AccessoryManager orchestrator. The codebase is Swift-based with async/await concurrency patterns and uses protobuf for serialization.
File Location: /Meshtastic/Accessory/Transports/Bluetooth Low Energy/
- Meshtastic Service UUID:
0x6BA1B218-15A8-461F-9FA8-5DCAE273EAFD - TORADIO Characteristic:
0xF75C76D2-129E-4DAD-A1DD-7866124401E7(write-without-response) - FROMRADIO Characteristic:
0x2C55E69E-4993-11ED-B878-0242AC120002(notify) - FROMNUM Characteristic:
0xED9DA18C-A800-4F66-A670-AA7547E34453(notify) - LOGRADIO Characteristic:
0x5a3d6e49-06e6-4423-9944-e9de8cdf9547(notify)
These match Android's BLE implementation, confirming protocol-level consistency.
Key Behavior:
FROMNUMnotification signals that data is ready onFROMRADIO- Upon receiving
FROMNUMnotification, the code callsstartDrainPendingPackets() - The drain loop repeatedly reads
FROMRADIOviaread()until empty (reads return 0 bytes) - Each read is protobuf-deserialized to
FromRadioand yielded as aConnectionEvent.data - Process continues until either no data remains or an error occurs
Code Pattern (BLEConnection.swift):
setNotifyValue(true)subscribes to bothFROMRADIOandFROMNUMcharacteristics- When notification arrives,
didUpdateValueFor characteristictriggers the drain - Drain respects
needsDrainflag to coalesce multiple notifications - Uses
isDrainingguard to prevent concurrent drain operations
CoreBluetooth Limitation:
- iOS doesn't expose
maximumWriteValueLengthproperty directly - Apple's
CBPeripheralconstrains write-without-response based on device negotiation (typically 20–512 bytes) - Observation: The Apple code writes raw
ToRadioprotobuf toTORADIOwithout explicit fragmentation logic visible in the transport layer - Fragment handling likely occurs at the radio firmware level (not SDK-specific)
Key Finding: Unlike Android which may pre-fragment, the Apple implementation relies on CoreBluetooth to handle MTU negotiation transparently.
iOS Limitation:
- CoreBluetooth does not expose bonding APIs directly
- Bonding is handled by the OS automatically during pairing
- The Apple app uses standard iOS Bluetooth pairing UI
Implementation Detail:
- The code checks for
CBATTError.insufficientAuthentication(error code 5) andinsufficientEncryption(error code 15) - When detected, a user-friendly error message prompts to "check the BLE PIN carefully"
- Recovery is manual: user must re-pair via iOS Settings → Bluetooth
Platform-Specific Workaround:
- macOS (Catalyst) path in
MeshtasticAppDelegateinitializesTAKServerManageron startup, suggesting some pre-flight checks specific to desktop - No explicit "forget and re-pair" automation; defers to iOS system UI
Discovery:
BLETransport.discoverDevices()initiatesscanForPeripherals(withServices:[meshtasticServiceCBUUID])filtered to Meshtastic service UUID- Allows duplicates (
CBCentralManagerScanOptionAllowDuplicatesKey: true) to track RSSI changes - Discovered peripherals cached with last-seen timestamp; older than 30 seconds are pruned every 15 seconds
Connection:
- On successful connection to a peripheral, the code:
- Discovers Meshtastic service
- Discovers characteristics (TORADIO, FROMRADIO, FROMNUM, LOGRADIO)
- Subscribes to FROMRADIO, FROMNUM, LOGRADIO for notifications
- Initiates periodic RSSI reads every 10 seconds
Reconnection Strategy:
- If Bluetooth powers off during active connection, the code calls
disconnect(withError:shouldReconnect:true) AccessoryManagerrespects theshouldReconnectflag to trigger automatic reconnection (subject to exponential backoff)- Failed connections do not auto-reconnect if
shouldReconnectAfterErroris false
Diff vs Android:
- Android likely uses explicit connection state machine with retry backoff
- Apple defers reconnection logic to
AccessoryManager(higher layer), not transport layer - iOS/macOS seamless Bluetooth restore on app restart (via
CBCentralManagerOptionRestoreIdentifierKey) — Android may not have equivalent
File Location: /Meshtastic/Accessory/Transports/TCP/
mDNS Discovery:
- Uses
NetServiceBrowserto search for"_meshtastic._tcp"services in local domain - Resolves service to hostname, IPv4 address, and port
- Extracts device shortname and node ID from TXT records (keys: "shortname", "id")
Manual Connection:
- Supports manual IP:port input via
device.identifierformat:"host:port" - Defaults to port 4403 if not specified
Framing Structure (identical to Android):
[0x94][0xC3] [uint16 big-endian length] [protobuf payload]
Encoding (in TCPConnection.send()):
- Serialize
ToRadioprotobuf to bytes - Prepend: 0x94, 0xC3
- Append: length of payload as big-endian uint16
- Append: serialized protobuf
- Send via
NWConnection.send()
Decoding (in startReader()):
- Poll for magic bytes 0x94 0xC3 using
waitForMagicBytes()loop - Read uint16 length (big-endian)
- Read exactly
lengthbytes - Deserialize to
FromRadioprotobuf - Yield as
ConnectionEvent.data - Repeat until connection closed
Implementation:
receiveData(min:max:)usesNWConnection.receive(minimumIncompleteLength:maximumLength:)callback- Blocks until minimum bytes available; does not buffer across calls
- If less than expected, the loop retries until full payload received
- Key: Big-endian length field tells reader exactly how many bytes to expect, eliminating ambiguity
Network.framework vs BSD Sockets:
- Uses Apple's modern
Network.framework(NWConnection) - Not BSD sockets (deprecated pattern)
- Provides automatic IPv4/IPv6 dual-stack, TLS capability, and state machine
Diff vs Android:
- Android may use OkHttp or raw sockets with explicit buffering
- Apple's Network.framework provides higher-level abstraction
- Framing is protocol-identical; differences are in I/O plumbing
File Location: /Meshtastic/Accessory/Transports/Serial/
- iOS / iPadOS: No native USB serial support (USB On-The-Go not available on iPhone/iPad)
- macOS (Catalyst): Serial support is compiled in (see
#if targetEnvironment(macCatalyst)guard in AccessoryManager) - Uses
ORSSerialor similar third-party library (inferred from structure; exact dependency not visible in public API)
Implementation Note:
SerialTransport.swiftandSerialConnection.swiftexist but are minimal stubs- Serial device discovery and connection deferred to macOS-specific USB/RS232 drivers
- Not a focus for mobile-first architecture
Diff vs Android:
- Android supports USB serial for OTG devices
- Apple: iOS completely blocked by platform; macOS has theoretical support but rarely used in practice
File Location: /Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift
Constants:
let NONCE_ONLY_CONFIG = 69420
let NONCE_ONLY_DB = 69421Handshake Flow:
-
Config Phase:
- Send
ToRadiowithwantConfigID = 69420 - Device responds with a sequence of
FromRadiomessages:DeviceMetadata(firmware version, etc.)MyInfo(own node info)Channel(repeated for each channel)Config(device settings)ModuleConfig(repeated for modules: CannedMessages, ExternalNotification, etc.)
- Flow blocked via
wantConfigContinuationuntil first config message received - Once received, continues draining remaining config packets
- Send
-
Database Phase:
- Send
ToRadiowithwantConfigID = 69421 - Device responds with stream of
NodeInfomessages - First
NodeInforesumesfirstDatabaseNodeInfoContinuation - Remaining nodes received asynchronously
- State updates to
retrievingDatabase(nodeCount: N)for progress UI
- Send
Expected Message Sequence:
- Both phases use nonce-based synchronization (not explicit ACKs)
- Nonces are fixed constants, not random per-session
- Key Finding: This differs from some protocols that use session-unique nonces; Apple/Meshtastic uses well-known nonces
App Suspension Handling:
appDidEnterBackground()/appDidBecomeActive()lifecycle hooks in Connection protocol- TCP connection stays alive (Network.framework handles backgrounding)
- BLE connections are automatically suspended by iOS (CoreBluetooth state machine)
- On app return,
AccessoryManagerchecks connection state and re-drains if needed
Timeout & Watchdog:
heartbeatTimerandheartbeatResponseTimermanage keep-alive- TCP requires periodic heartbeat (property:
requiresPeriodicHeartbeat = true) - BLE heartbeat not required (property:
requiresPeriodicHeartbeat = false)
Diff vs Android:
- Android likely handles app lifecycle differently (no equivalent suspend)
- Nonce strategy appears identical (both use well-known constants)
- iOS's explicit lifecycle awareness is a platform-specific workaround
File Locations:
- Transport layer: BLEConnection, TCPConnection
- Protobuf generation: MeshtasticProtobufs package
Protocol Constant:
- Bytes 0x94, 0xC3 hardcoded in
TCPConnection.swift - BLE transport does not use framing (protobuf sent directly; length implicit in BLE MTU)
- Serial transport (macOS) likely inherits TCP framing for consistency
Magic Byte Search (waitForMagicBytes()):
- Maintain state:
waitingOnByte(0 or 1) - Read 1 byte at a time
- If byte matches
startOfFrame[waitingOnByte], increment counter - If mismatch, reset to 0
- Once 2 bytes matched, return true
- Loop handles jitter/corruption gracefully
Key Property: Byte-by-byte search means resync can occur at any point in the stream (no lock-step requirement).
Diff vs Android:
- Identical 0x94 0xC3 magic bytes
- Resync algorithm likely similar (both byte-oriented search)
- No documented differences in framing approach
Core Data Schema: /Meshtastic/Meshtastic.xcdatamodeld
Primary Entities:
MyInfoEntity— Local node info (channels, hardware model)NodeInfoEntity— Remote nodes (presence in mesh)UserEntity— User profiles (longName, shortName, publicKey for PKI)ChannelEntity— Mesh channels (settings, PSK state)MessageEntity— Text messagesTelemetryEntity— Environmental telemetry (GPS, temp, battery)RouteEntity— Path traces (mesh routing data)TraceRouteEntity— Trace route requests/responsesStoreForwardConfigEntity— S&F router metadata- Various config entities (DeviceEntity, BluetoothConfigEntity, etc.)
Apple Approach:
- Core Data (SQLite-backed, managed migrations)
- One MyInfoEntity per device; accessed via predicate on
myNodeNum - Channels stored as
NSOrderedSeton MyInfoEntity (normalized in Android?)
Key Differences:
- Apple clears existing channels when syncing new config (
tryClearExistingChannels()) - Android likely re-fetches or upserts channels per-connection
- No explicit "device" entity tracking in Apple (device is ephemeral in AccessoryManager)
Diff vs Android:
- Android may use Room (local SQLite ORM) or similar
- Apple's Core Data less granular (doesn't expose "devices" as entities)
- PKI public keys stored in
UserEntity.publicKey(binary Data field)
CryptoKit Usage (Apple's Native Crypto):
Implementation Inferred:
- Channel encryption uses AES in Counter (CTR) mode
- AES key derived from channel PSK (pre-shared key)
- IV/nonce construction matches Android (likely based on firmware spec, not SDK choice)
No Direct CryptoKit Code in Accessory Layer:
- Encryption likely offloaded to firmware (via
pkiEncryptedflag onMeshPacket) - SDK does not encrypt/decrypt payload — firmware owns crypto
Direct Message Encryption (PKI):
- Recipient's public key stored in
UserEntity.publicKey - On send:
meshPacket.pkiEncrypted = true; meshPacket.publicKey = recipient.publicKey - Firmware performs X25519 ECDH + AES-GCM encryption
- Key Finding: SDK only sets the flag and public key; encryption is hardware-side
Code Evidence (AccessoryManager+ToRadio.swift):
if newMessage.toUser?.pkiEncrypted ?? false {
meshPacket.pkiEncrypted = true
meshPacket.publicKey = newMessage.toUser?.publicKey ?? Data()
}No explicit AES-GCM or Curve25519 calls in the transport layer.
Keychain:
KeychainHelper.swiftexists but is used for UI/settings passwords, not mesh keys- Channel PSK and user public keys stored in Core Data (not Keychain)
- Platform-Specific: On iOS, Core Data file is protected by Data Protection API (automatic)
Diff vs Android:
- Android likely uses Android Keystore for key management
- Apple relies on Core Data's built-in encryption (or none, if not configured)
- Actual crypto operations are firmware-side on both (SDK is agnostic to algorithm details)
File: /Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift (89 KB)
MessageEntity States:
QUEUED— Message pending transmissionENROUTE— Message sent to device, awaiting ACKDELIVERED— ACK received
Packet ID Tracking:
- On send: Assign
UInt32packet ID tomeshPacket.id = UInt32(newMessage.messageId) - Firmware echoes ID in received ACK packet
AccessoryManagerlooks upMessageEntitybymessageId- On ACK receipt: Update entity state to
receivedACK = true
Code Evidence:
var meshPacket = MeshPacket()
meshPacket.id = UInt32(newMessage.messageId)
// ... send packet
// On ACK received: newMessage.receivedACK = truePer-Platform Differences:
- Apple uses Core Data
MessageEntity.messageId(UUID or Int64) - Android likely uses a similar ID-based correlation
- Protocol-level: packet IDs are uint32 (firmware spec, not SDK choice)
File: /Meshtastic/Helpers/Mqtt/MqttClientProxyManager.swift
MQTT Library: CocoaMQTT (third-party pure-Swift library)
TLS to mqtt.meshtastic.org:
- Default: Port 8883 (TLS)
- Enforces TLS for public server (
mqtt.meshtastic.org) - Custom servers can opt for unencrypted (port 1883)
- Certificate validation:
allowUntrustCACertificate = true(permissive for self-signed)
Subscription Check:
- Before connecting, iterates all channels to check if any has
downlinkEnabled == true - If yes:
shouldSubscribe = true, subscribes to MQTT topic{root}/2/e/# - If no:
shouldSubscribe = false, does not subscribe (publish-only mode)
Connection Flow:
- Extract MQTT config from
NodeInfoEntity.mqttConfig - Set username/password if configured
- Set keep-alive to 60 seconds
- Auto-reconnect enabled
- Subscribe to wildcard topic on connected
Diff vs Android:
- Android's MQTT library (likely Paho) similar in structure
- Downlink filtering logic (checking channels) is app-level, not library-level
- No evidence of direct CocoaMQTT vs Paho differences in protocol handling
File: /Meshtastic/Accessory/Accessory Manager/ (spread across ToRadio/FromRadio handlers)
AdminMessage Protocol:
- User initiates admin request (e.g., factory reset, reboot)
- App generates session ID / nonce
- Sends
AdminMessagewith request + session nonce - Device echoes session ID in response
- App validates session match to prevent tampering
Implementation Observation:
- No explicit biometric gate in code (unlike some apps)
- Standard iOS permission prompts apply (Bluetooth, location)
- Admin features require active connection; no queue persistence
Key Finding: No special iOS UX decisions observed that would leak into SDK:
- No Face ID / Touch ID integration at the SDK layer
- All admin logic is protobuf-level (agnostic to platform)
- iOS app simply displays confirmation dialogs before sending
Diff vs Android:
- Android may use BiometricPrompt
- Protocol flow identical (session nonce echo)
- No reason to expose platform-specific auth to SDK
| Aspect | Apple | Android | Note |
|---|---|---|---|
| Transport Abstraction | Actor-based async protocols | Likely interface-based | Apple uses Swift concurrency |
| BLE Stack | CoreBluetooth | Android BLE API | Apple doesn't expose bonding; Android may have different handling |
| TCP Library | Network.framework | OkHttp / raw sockets | Modern vs pragmatic |
| Serialization | Swift Protobuf | Protobuf Java | Language difference, protocol identical |
| State Management | Core Data (SQLite) | Room / Realm | Both SQLite-backed, different ORMs |
None found. The following are protocol-agnostic:
- Service UUIDs match
- Framing (0x94 0xC3) identical
- Nonces (69420, 69421) identical
- Packet ID correlation identical
- MQTT topic structure identical
- AdminMessage session nonce echo identical
| Feature | Apple | Android | Classification |
|---|---|---|---|
| BLE MTU Handling | Implicit (CoreBluetooth negotiates) | Possibly explicit fragmentation | Platform-specific workaround |
| Bonding Flow | iOS automatic, no app control | May be more flexible | Platform limitation |
| Reconnection Backoff | AccessoryManager state machine | Likely similar | Protocol-neutral (implementation detail) |
| TCP Framing | NWConnection, async/await | BSD sockets or OkHttp | I/O plumbing (protocol identical) |
| Lifecycle Handling | Explicit appDidEnterBackground/Active | Service-based lifecycle | Platform architecture |
Protocol / Algorithm (Identical Across Platforms):
- Framing: 0x94 0xC3 length framing
- PhoneAPI nonces: 69420, 69421
- Packet ID correlation for ACK
- MQTT topic structure:
{root}/2/e/# - Channel PSK → AES-CTR IV construction (if spec exists)
Platform-Specific (Expect Differences):
- BLE discovery & scanning behavior
- Reconnection backoff strategies
- State machine transition timings
- Encryption/decryption (if SDK-level, vs firmware-level)
- Keychain vs Core Data vs SQLite abstractions
- Framing encoder/decoder — 0x94 0xC3 is deterministic
- PhoneAPI handshake — Nonce values are fixed
- MQTT subscription logic — Downlink channel filter is protocol, not platform
- Packet ID → ACK correlation — State machine is identical
- Protobuf models — Shared proto definitions
- BLE scanning/discovery — CoreBluetooth vs Android BLE API differ fundamentally
- Connection lifecycle — iOS suspension vs Android background services
- Reconnection backoff — May vary by platform capability
- Encryption/decryption — If done in SDK (currently firmware-owned on both)
- Persistence layer — Core Data vs Room ORM
- TCP socket details — Network.framework vs raw sockets
- No encryption differences — Both rely on firmware for channel AES-CTR and PKI
- No framing differences — Both use identical 0x94 0xC3 magic bytes
- No session/nonce differences — Both use fixed nonces (69420, 69421)
- No PKI differences — Both flag
pkiEncryptedand set recipient public key
Conclusion: The Meshtastic protocol is highly consistent between Apple and Android implementations. Differences are architectural (transport libraries, state management, lifecycle) rather than protocol-fundamental. A Kotlin Multiplatform SDK should isolate transports to platform modules while sharing framing, handshake logic, and message handling in commonMain.