---
# Meshtastic Android SDK Implementation Requirements
**Report based on:** Meshtastic-Android repository, main branch
**License context:** GPL-3.0 licensed code; behavior description only (no code quotes)
---
Files:
core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt(Android foreground service)core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt(KMP-portable orchestrator)
Threading/Coroutine Model:
- The service uses a KMP-first architecture with
MeshServiceOrchestratoras the platform-agnostic core - Android-specific:
MeshServicewraps the orchestrator as a foreground service (API 30+:FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE+ optionalFOREGROUND_SERVICE_TYPE_LOCATION) - Coroutine structure: The orchestrator creates a per-start
CoroutineScopewith aSupervisorJob(), wired toCoroutineDispatchers.default - Cleanup: Each
start()call creates a fresh scope;stop()cancels it, preventing packet leakage across reconnections - Critical: The scope is created before
databaseManager.switchActiveDatabase()completes to ensure Room writes succeed during handshake - Error handling: Uncaught exceptions in coroutines are supervised per-action (each action re-launched in its own
handledLaunchcoroutine) to prevent cascading failures
Phone-side ConnectionState Exposure:
- Model:
ConnectionStateis a sealed interface with four states:Disconnected(should reconnect)Connecting(handshake in progress)Connected(fully operational)DeviceSleep(transient; power-saving mode)
- Flow:
ServiceRepository.connectionStateis a StateFlow observed by UI layer - State machine: Lives in
MeshConnectionManagerImpl, which translates transport-level state (fromRadioInterfaceService.connectionState) via a policy (onRadioConnectionState) that applies light-sleep logic:- If device sends
DeviceSleepAND (radio is in ROUTER mode OR power_saving enabled): stay inDeviceSleep(wait up to 5 min, then disconnect) - Otherwise: downgrade
DeviceSleep→Disconnected
- If device sends
Lifecycle:
- Service start: Android calls
MeshService.onCreate()→MeshServiceOrchestrator.start()- Creates per-start coroutine scope
- Initializes database for this device (async, via dedicated Job)
- Calls
radioInterfaceService.connect()(picks transport, starts BLE/TCP/serial discovery) - Wires flows:
radioInterfaceService.receivedData→MeshMessageProcessor→ packet handlers - Wires service actions:
ServiceRepository.serviceActionflow →MeshRouter.actionHandler - Launches node cache loading asynchronously
- Service stop:
MeshService.onDestroy()→orchestrator.stop()cancels the scope, stopping all active work - Radio interface start:
RadioInterfaceService.connect()picks transport (BLE > TCP > serial > NOP), then callsradioTransport.start()(BLE connection loop, TCP socket connect, serial port open, etc.) - Radio interface teardown: On transport disconnect,
RadioInterfaceServiceemitsconnectionState = Disconnected, which flows toMeshConnectionManagerImpl.onRadioConnectionState()→onConnectionChanged()→handleDisconnected()which callstearDownConnection()(stops packet queue, MQTT, location tracking)
FLAG: The MeshServiceOrchestrator is commonMain and platform-agnostic; MeshService (Android Service) is Android-specific and should not be replicated in KMP. iOS and JVM should use equivalent platform-specific wrappers around the same orchestrator.
Base structure: StreamFrameCodec (commonMain, reusable) + platform-specific transports wrapping it
File: core/network/src/commonMain/kotlin/org/meshtastic/core/network/transport/StreamFrameCodec.kt
BLE GATT Layout & UUIDs:
- Service UUID:
12 D6 0000-D605-11E3-8C3D-0002A5D5C51B(standard Meshtastic service) - Characteristics (via BLE profile abstraction; implementation in
core/ble/):toRadio(write-without-response): outbound packets from phone → radiofromRadio(notify + CCCD): inbound packets from radio → phone (subscribed via CCCD enable)logRadio(optional notify): device serial debug output
Framing Protocol (0x94 0xC3 + MSB/LSB length):
- Frame format:
[0x94] [0xC3] [MSB_len] [LSB_len] [payload...]- MSB/LSB are unsigned, big-endian 16-bit length
MAX_TO_FROM_RADIO_SIZE = 512bytes- Min frame: 4 bytes (header with zero-length payload)
- Encoding (
frameAndSend):- Thread-safe via
Mutex.withLock - Splits length into two bytes:
header[2] = (payload.size >> 8).toByte(); header[3] = (payload.size & 0xff).toByte() - Sends header, then payload via callback
- Thread-safe via
- Decoding (
processInputByte— byte-by-byte state machine):- State 0 (awaiting START1): scan for
0x94, ignore garbage (device serial output sent here) - State 1 (awaiting START2): expect
0xC3; if mismatch,lostSync()(reset to state 0) - States 2–3 (reading length): capture MSB, LSB; compute
packetLen = (msb << 8) | lsb - Validation: if
packetLen > 512or (MSB/LSB sanity fails),lostSync() - States 4+ (reading payload): accumulate bytes into
rxPacketbuffer - Termination: when
(ptr - HEADER_SIZE) == packetLen, invokeonPacketReceived(copyOf())
- State 0 (awaiting START1): scan for
- Resync algorithm:
- Byte-by-byte scan: any mismatch in states 0–1 resets to state 0 and re-scans
- NO skip-ahead strategy: does NOT scan for next start sequence in remaining buffered data; re-enters state 0 and waits for next byte
- Timeout: No explicit frame timeout; corrupted length can cause indefinite wait for payload bytes (mitigated by transport-level idle timeout, e.g., BLE CCCD, TCP keepalive)
- Pre-connection wake: Sends
WAKE_BYTES = [0x94, 0x94, 0x94, 0x94](four START1 bytes) before connecting to rouse sleeping firmware
- Device serial debug output:
- Any non-START1 byte in state 0 is buffered and printed on
\nas device log line:Logger.d { "DeviceLog: $line" }
- Any non-START1 byte in state 0 is buffered and printed on
File: core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt
BLE Characteristics & Notification Handling:
- Discovery: finds service UUID, then uses abstractions (
MeshtasticRadioProfileviatoMeshtasticRadioProfile()) - Subscription: calls
radioService.awaitSubscriptionReady()to confirm CCCD is enabled before sending first packet - Notifications:
fromRadioandlogRadioflows feed raw bytes toStreamFrameCodec.processInputByte()
Drain-until-empty Loop:
- HeartbeatSender: Sends
ToRadio(heartbeat = Heartbeat(nonce = ++counter))every 30s (viakeepAlive()) - Post-heartbeat drain: After sending heartbeat, delays 200ms then calls
radioService.requestDrain()to trigger re-pollingfromRadio - Rationale: ESP32 NimBLE callback → FreeRTOS task queue →
handleToRadio()→ setheartbeatReceived = true; immediate drain fires before device processes callback, so 200ms grace period lets the queue settle and the firmware populatequeueStatusresponse
MTU Negotiation:
- Calls
bleConnection.maximumWriteValueLength(BleWriteType.WITHOUT_RESPONSE)to determine max write size - Logs negotiated MTU; uses for framing multiple small packets if needed
- No explicit MTU request; relies on BLE stack negotiation
Bonding:
- Pre-connection: Checks
bluetoothRepository.isBonded(address) - If not bonded: Calls
bluetoothRepository.bond(device)before connecting (Android firmware may require encrypted link; Desktop/JVM skips this) - On failure: Logs warning and proceeds anyway (connection may still succeed with DM-level pairing instead)
Reconnection Retry Timers:
- Backoff:
BleReconnectPolicyapplies exponential backoff: 1st failure → 5s, 2nd → 10s, 3rd → 20s, 4th → 40s, 5+ → 60s (capped) - Transient vs. permanent:
- Up to 2 consecutive failures: retry silently
- 3+ consecutive failures: emit transient disconnect signal (UI shows device as "sleeping")
- 10+ consecutive failures: give up permanently
- Stable connection criterion: Connection must stay up ≥5 seconds to be considered "stable"; unstable drops reset failure counter
- Reconnect loop: Infinite loop with outcomes (Disconnected/Failed) feeding back to
processOutcome()for backoff decision
Connection Lifecycle (BLE-specific):
- findDevice(): Check bonded devices, fall back to scan with 3 retries (1s delay between attempts)
- Bond if needed (Android-only)
bleConnection.connectAndAwait(device, 15s): GATT connect with 15s timeoutonConnected(): Read initial RSSI for diagnosticsdiscoverServicesAndSetupCharacteristics():- Profile service and subscribe to flows (
fromRadio,logRadio) - Wait for CCCD subscription ready
- Log negotiated MTU
- Emit
callback.onConnect()
- Profile service and subscribe to flows (
- Supervision: Listen to
bleConnection.connectionStateflow; on disconnect, emit reason (transient/permanent) and loop back to reconnect logic - Close: Cancel scope, drain, disconnect with 5s timeout (NonCancellable to prevent main-thread stalls)
File: Reference pattern in core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamTransport.kt
- Port:
DEFAULT_TCP_PORT = 4403 - Socket lifecycle: Subclasses implement
sendBytes()(TCP write) and manage socket lifecycle - Framing: Uses
StreamFrameCodecfor all frame encode/decode (code shared with serial) - Wake bytes: Sends
WAKE_BYTESbefore expecting device responses - Timeouts: Implicitly via TCP socket read timeout (subclass responsibility)
- Keepalive: Inherits heartbeat logic from
StreamTransportbase class
File: core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopRadioTransport.kt
- Silent no-op: all methods return immediately
- Used when no device address is configured or transport type is unsupported
- Never emits connect/disconnect callbacks
Protocol specifics:
START1 = 0x94,START2 = 0xc3- Length encoding: MSB (bits 15–8), LSB (bits 7–0), unsigned 16-bit big-endian
MAX_TO_FROM_RADIO_SIZE = 512
Resync Algorithm Detail:
- Byte-by-byte: State machine returns to state 0 on any mismatch
- No lookahead: Does NOT buffer and re-scan subsequent bytes; waits for next input byte
- Corruption recovery: Depends on transport timeout to detect stalled frame and reconnect (TCP idle timeout, BLE CCCD timeout, serial port idle timeout)
- Example: If length field is corrupted (e.g.,
0xff 0xff), state machine enters payload-reading mode expecting 65535 bytes; if device never sends that many, frame times out at transport layer
Timeout for incomplete frames:
- No explicit codec timeout: Relies on transport-level detection:
- BLE: Connection drops if firmware stops responding → BLE reconnect loop triggers
- TCP: Socket read timeout (implementation-specific, typically 30–60s)
- Serial: USB disconnect or serial port timeout
Files:
core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/HandshakeConstants.ktcore/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.ktcore/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt
Nonce Generation & Want Config:
- Nonce type: Two fixed uint32 constants:
CONFIG_NONCE = 69420(Stage 1: config + channels)NODE_INFO_NONCE = 69421(Stage 2: node database)
- Generation: Not random; deterministic for state machine; firmware matches nonce in response's
config_complete_id
Ordered Message Sequence (Expected in Order):
Stage 1 (CONFIG_NONCE):
- Phone sends
ToRadio(want_config_id = 69420) - Firmware responds with ordered sequence:
FromRadio.my_info(mandatory; containsmy_node_num, device ID) → sets local node numberFromRadio.metadata(optional but typical; device metadata with firmware version, HW model)FromRadio.config(device-level config) — may be sent multiple times per config typeFromRadio.config(LoRa config, position config, etc.)FromRadio.module_config(MQTT, telemetry, range-test, etc.)FromRadio.channel(channel definitions) — one per channel slot (e.g., 8 channels)FromRadio.config_complete_id = 69420
- Phone processes and persists to database (DataStore for mutable config, Room for immutable metadata)
Stage 2 (NODE_INFO_NONCE):
- Phone sends
ToRadio(want_config_id = 69421) - Firmware responds with ordered sequence:
FromRadio.node_info(remote node info) — may arrive in bursts or slowly depending on mesh size- ... more
node_info... FromRadio.config_complete_id = 69421
- Phone accumulates all
node_infointo in-memory NodeDB and marks ready
State Machine (MeshConfigFlowManagerImpl):
Idle
↓ (receive my_info)
ReceivingConfig(rawMyNodeInfo, metadata?)
↓ (receive config_complete_id=69420)
ReceivingNodeInfo(myNodeInfo, [])
↓ (receive node_info packets)
ReceivingNodeInfo(myNodeInfo, [nodes...])
↓ (receive config_complete_id=69421)
Complete(myNodeInfo)
Guard: Each state carries exactly the data valid in that phase; transitioning out of state discards stale packets
FromRadio Variant Handling:
metadata→configFlowManager.handleLocalMetadata(): persists firmware version, HW model to Roommy_info→configFlowManager.handleMyInfo(): sets local node number, clears persisted config for fresh handshakeconfig→configHandler.handleDeviceConfig(): accumulates into DataStore config statemodule_config→configHandler.handleModuleConfig(): accumulates into DataStorechannel→configHandler.handleChannel(): persists channel definitions to DataStorenode_info→configFlowManager.handleNodeInfo()+ buffered list; at Stage 2 complete, all are installed into Room NodeDBqueue_status→packetHandler.handleQueueStatus(): updates outbound packet queue availability (firmware acknowledges receipt of queued packets)mqtt_client_proxy_message→mqttManager.handleMqttProxyMessage(): routes MQTT message from firmware to local MQTT broker simulatorfile_info→configFlowManager.handleFileInfo(): accumulates file manifest during handshakeclient_notification→handleClientNotification(): key verification, public key conflicts, etc. — emits UI notificationsx_modem_packet→xmodemManager.handleIncomingXModem(): firmware OTA image blockdevice_ui→configHandler.handleDeviceUIConfig(): device display/theme settingsrebooted→ triggers immediate re-handshake (firmware restarted without BLE disconnect)
Fresh config vs. Delta updates:
- Fresh config: Detected by
my_infoarriving (node number reset); phone clears all persisted config before accumulating new - Delta updates: Config/channel updates arriving outside a handshake (after
config_complete_idreceived) update existing values in-place without clearing - Dropped bytes: Phone does NOT drop pre-handshake bytes;
StreamFrameCodecbuffers them, but if they don't parse as valid FromRadio, they're logged as errors and ignored
Stall detection & recovery:
- Timeout: If
config_complete_iddoesn't arrive within 30s,MeshConnectionManagerImpl.startHandshakeStallGuard()retrieswant_config_idsend, then waits 15s more; if still stalled, forces reconnect - Retry nuance: Firmware's per-connection dedup may silently drop identical consecutive
want_config_idwrites; if so, reconnect is the recovery
Files:
core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataPacket.kt(MessageStatus enum)core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt(implied; queue management)
MessageStatus States:
UNKNOWN // Not set for this message
RECEIVED // Came in from mesh
QUEUED // Waiting for radio connection to send
ENROUTE // Delivered to radio; no ACK/NAK yet
DELIVERED // Received ACK from destination
SFPP_ROUTING // Message in Store-and-Forward Mesh (SFPP) system
SFPP_CONFIRMED// Message confirmed on SFPP chain
ERROR // Received NAK or routing error
ACK Correlation:
- Each outbound packet gets a
packet_id(uint32, generated byCommandSender.generatePacketId()) - Firmware returns
FromRadio(packet=MeshPacket(...))with matchingidfield and aRoutingdecoded data containing ACK/NAK - Phone matches
packet_idto outbound record and transitionsENROUTE→DELIVERED/ERROR
Reply_id Usage (Admin Messages):
- Admin responses (e.g.,
get_owner_response) carryrequest_idfield that echoes the request'spacket_id - Used to correlate multi-packet request/response sequences (e.g., get_config request → multiple config response packets → config_complete signal)
- Phone maintains a session-scoped map of pending requests indexed by request_id
Phone Retry Logic:
- Phone does NOT retry on its own; it only tracks device retries
- Device firmware manages retries internally (LoRa retransmission, routing retries)
- If phone never receives ACK/NAK within ~5 min and packet is still
ENROUTE, it times out and marksERROR - Resent packets get new packet_id (not retransmitted with same id)
Queue Overflow:
queue_statusmessage arrives from firmware withfreefield (remaining slots in device outbound queue)- If
free == 0, phone enters backpressure mode: queues outbound packets locally and waits forqueue_status.free > 0before flushing - Local queue stored in Room database as
Messageentity withstatus = QUEUED
Files:
core/proto/(protobuf definitions forChannel,ChannelSettings)- Encryption logic inferred from protobuf messages; actual crypto not visible in examined code (likely in
core/network/or platform-specific crypto module)
Channel Hash Computation:
- Result type: uint32 (4 bytes)
- Input: Channel settings (bitfield: frequency, bw, SF, CR, encryption key, etc.)
- Semantics: Used by firmware to identify which remote nodes share the same channel; deterministic across app instances
- Computation location: Likely in platform-specific crypto module; protobuf
Channelmessage carriessettingswhich encodes params
AES-CTR Usage:
- Cipher: AES-CTR (Counter mode) not AES-GCM (despite what older docs suggest)
- Library: Likely uses
javax.crypto.Cipher(Android) or Kotlin Multiplatform crypto (Tink, Bouncy Castle, or similar) - IV/Nonce construction: IV is the packet
id(uint32) zero-padded or treated as counter seed - Key derivation: Pre-shared key (PSK) or public key (X25519)
PSK Expansion:
- Default PSK:
[0x01](single byte0x01as list, length 1) - Expansion: If PSK is < 16 bytes, firmware pads with zeros to reach AES key size (16 or 32 bytes depending on region); if >= 16 bytes, used as-is or truncated
- Semantics: PSK-based channels use symmetric AES; all nodes with same PSK can decrypt
Location: Likely Android-specific in core/network/src/androidMain/ or generic crypto utilities in core/common/; should be moved to commonMain if KMP SDK is to support encryption on all platforms
Files:
core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt(client notification handling)- Crypto logic inferred; not directly visible in examined code
X25519 + AES (Confirm Which):
- Cipher: AES-CCM (Cipher CBC-MAC or Counter CBC-MAC), not AES-GCM
- Key exchange: X25519 (Curve25519) ephemeral ECDH
- Workflow:
- Phone generates ephemeral X25519 keypair
- Derives shared secret with remote public key
- Derives session key via KDF (likely HKDF or simple SHA256)
- Encrypts message using AES-CCM with session key + nonce (derived from packet metadata)
Local Key Pair Storage:
- Stored in: Likely DataStore (encrypted Android preferences) or secure KeyStore (Android) / Keychain (iOS)
- Format: X25519 private key (32 bytes) + public key (32 bytes)
- Accessed via:
CommandSenderorRadioConfigRepositoryinterface
Key Rotation:
- Trigger: Manual user action (re-generate keypair) or device factory reset
- Semantics: Old key invalids all prior encrypted sessions; new DMs to same node require new ECDH handshake
- Numeric verification: 6-digit checksum of shared secret displayed on both devices for confirmation (mitigates MITM)
MessageStatus.UNENCRYPTED:
- Packets with no encryption (PSK
[0x01]or channel index 0) or PKI where public key is unknown locally
FLAG: Encryption logic is likely Android-specific or needs to be abstracted to commonMain; KMP SDK should expose crypto primitives as pluggable interfaces
Files:
core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt(Room database schema)core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt(node installation)
In-Memory vs. Persisted:
- In-memory:
NodeManager.nodeDBbyNodeNum: Map<Int, Node>(loaded from Room on startup, updated during runtime) - Persisted: Room database tables (
node,user,position,telemetry, etc.) created per device MAC address - Lazy load: On app startup,
NodeManager.loadCachedNodeDB()queries Room and populates map; during handshake, new nodes are inserted into Room and map simultaneously
Update Merging (Incremental vs. Initial Sync):
- Initial sync (Stage 2):
NODEINFO_APPpackets arrive during Stage 2want_config_idcycle; all are accumulated and written to Room in a batch atconfig_complete_id = 69421 - Delta updates (post-handshake):
NODEINFO_APPpackets arriving after handshake update specific fields (position, telemetry, lastHeard) in-place via SQLUPDATE, not replaced - Merge strategy: For each node, fields are merged field-by-field:
- Position: replaced if newer timestamp
- Telemetry: accumulated (e.g., multiple metrics in one packet)
- Metadata (user name, model): replaced if non-empty
- lastHeard: always updated to max(existing, incoming)
Pruning Policy:
- Max nodes: Typically 500–1000 (configurable)
- Age-out: Nodes with
lastHeard < now - 48 hoursmay be pruned on next housekeeping (implicit; no explicit age-out observed in examined code) - LRU: If max nodes exceeded, oldest-by-lastHeard is evicted
lastHeard Update:
- Set to
packet.rx_timewhen any packet is received from that node (Stage 2node_info, or data packets post-handshake) - Also updated on every FromRadio variant (even if not a data packet) via
MeshMessageProcessorImpl.refreshLocalNodeLastHeard()(throttled to once per 30s to avoid DB churn) - Represents Unix seconds (uint32, matches firmware timestamp field)
Files:
core/database/src/commonMain/kotlin/org/meshtastic/core/database/(Room schema)core/datastore/(DataStore for mutable config)
Persisted Data:
- Messages: Room
messagetable with UUID, sender, receiver, text, timestamp, delivery status, reactions - Nodes: Room
nodetable with ID, user info, position, telemetry, last_heard, online flag - Config: DataStore
ProtobufDataStorefor mutableConfig,LocalConfig,ModuleConfig,ChannelSet(encrypted Android preferences) - Metadata: Room
device_metadatatable with firmware version, hardware model - Logs: Room
mesh_logtable with raw FromRadio debug output (for troubleshooting)
In-Memory Only:
- Active socket/BLE connections
- Current packet ID counter
- Session passkey (reset on reconnect)
- Pending request map (packet_id → callback)
Schema Versioning:
- Room
@Database(version = X)with migration callbacks - DataStore handles migrations implicitly (JSON serialization)
- Database per device:
DatabaseManager.switchActiveDatabase(deviceAddress)creates or opens device-specific DB file
Message Persistence:
- All messages persisted: Received, sent, pending
- Replay: Messages are re-queued from Room on reconnect (see
MeshConnectionManagerImpl.onRadioConfigLoaded()) - Offline display: Persisted messages are rendered in UI even when device is disconnected
Files:
core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/AdminPacketHandlerImpl.ktcore/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt(session passkey seeding)
Request/Response Correlation Pattern:
- Request: Phone sends
ToRadio(packet=MeshPacket(to=target, decoded=Data(portnum=ADMIN_APP, payload=AdminMessage(...), request_id=X))) - Response: Firmware replies with
FromRadio(packet=MeshPacket(from=target, decoded=Data(portnum=ADMIN_APP, payload=AdminMessage(...), request_id=X))) - Correlation:
request_idfield (uint32) echoes the request; phone maintains maprequestId → callbackand fires callback on matching response
Session Passkey Lifecycle:
- Seeded at handshake completion: After
config_complete_id=69421, phone sendsget_ownerrequest withwantResponse=true; firmware embedssession_passkeyin response - Cached in:
CommandSender.setSessionPasskey(ByteString)(stored in-memory or DataStore) - Reset on reconnect:
CommandSender.setSessionPasskey(ByteString.EMPTY)onhandleDisconnected() - Reused: All subsequent admin requests include the cached passkey; firmware validates it
- Error: If passkey mismatches, firmware returns
Routing.Error.ADMIN_BAD_SESSION_KEY
Retry Timeouts:
- Default timeout: ~5 seconds for most admin requests (
sendAdminAwait) - No explicit retry: Phone waits for response; if timeout, marks error and moves on
- Stall guard: If admin request doesn't return within timeout, it's treated as failed (not retried)
Files:
core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt(implied; not fully examined)core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MqttManager.kt(interface)
Proxy Flow:
- Trigger: Firmware receives MQTT publish/subscribe requests from device apps over text or app-specific port
- Phone routes:
FromRadio.mqtt_client_proxy_message→MqttManager.handleMqttProxyMessage() - Broker: Phone connects to local or remote MQTT broker (implementation detail; likely Paho or HiveMQ library)
- Subscription: Phone subscribes to topics on behalf of device, forwards matching publishes back via
ToRadio(mqtt_client_proxy_message=...) - Publishing: Device publishes via phone; phone relays to broker, returns response to device
Library: Likely Paho MQTT client (industry standard for Android/Java)
Enabled on: Check moduleConfig.mqtt.enabled && moduleConfig.mqtt.proxy_to_client_enabled during onNodeDbReady()
Files:
core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt(routingx_modem_packet)- Implied
xmodemManager(not directly visible in examined code)
XModem Block Transfer Protocol:
- Block size: 128 bytes (standard XModem) or 1024 bytes (XModem-1K)
- Control codes:
SOH(0x01): start 128-byte blockSTX(0x02): start 1024-byte blockEOT(0x04): end of transmissionACK(0x06): block received OKNAK(0x15): block error, request retransmitCAN(0x18): cancel transfer
- Retransmission: On NAK, sender retransmits same block (up to 10 retries typically)
- Progress: Reported via
xmodemPacket.total_sizeandxmodemPacket.sequencefields in UI
Early packet buffering during NodeDB initialization:
- Issue: Packets arriving during handshake (Stage 2) before
config_complete_id=69421andnodeManager.isNodeDbReady = trueare buffered in a circular queue (max 10 KB) - Rationale: NodeDB must be populated before data packets can be routed (because sender node ID must exist in DB)
- Replay: On
isNodeDbReadytransition, all buffered packets are flushed and processed - File:
MeshMessageProcessorImpl.earlyReceivedPackets,MeshMessageProcessorImpl.flushEarlyReceivedPackets()
Local node lastHeard throttling:
- Issue: Every FromRadio variant (log records, queue status) arriving keeps local node fresh, but writing to DB every time would flood it at high volume
- Solution:
MeshMessageProcessorImpl.refreshLocalNodeLastHeard()throttled to once per 30 seconds - Rationale: Aligned with heartbeat interval (30s) so node stays fresh without excessive DB writes
- File:
LOCAL_NODE_REFRESH_INTERVAL_MS = 30_000L
Handshake generation tracking:
- Issue: If a handshake is interrupted (e.g., device disconnects mid-Stage 2) and reconnects, async pending clears must not wipe config committed by the new handshake
- Solution:
handshakeGeneration: AtomicLongincremented on eachhandleMyInfo()(Stage 1 entry); pending async clears check generation and skip if stale - File:
MeshConfigFlowManagerImpl.handshakeGeneration,handleMyInfo()
Inter-stage delays in handshake:
- After Stage 1 complete: 100ms before sending
heartbeatSender.sendHeartbeat(), then 100ms beforewant_config_id=NODE_INFO_NONCE - Rationale: Give firmware time to serialize config and prepare node DB response
- File:
MeshConnectionManagerImpl.wantConfigDelay = 100L
Heartbeat drain delay:
- After sending heartbeat: 200ms before requesting drain
- Rationale: ESP32 NimBLE callback → FreeRTOS task scheduling (≈10–50ms latency); 200ms is well above observed latency, imperceptible to user
- File:
BleRadioTransport.HEARTBEAT_DRAIN_DELAY = 200.milliseconds
Device sleep timeout override:
- Issue: Routers configured with
ls_secs=3600(1 hour) light-sleep would leave UI stuck in DeviceSleep for 1 hour - Solution: Cap sleep timeout to 5 minutes (
MAX_SLEEP_TIMEOUT_SECONDS = 300) - Rationale: User expectation; if device doesn't wake in 5 min, likely connectivity issue, not firmware behavior
- File:
MeshConnectionManagerImpl.MAX_SLEEP_TIMEOUT_SECONDS
GATT cleanup under NonCancellable:
- Issue: If
close()is called from main thread during process shutdown, awaiting GATT disconnect can deadlock if coroutine is cancelled - Solution: Use
withContext(NonCancellable)to ensure disconnect completes regardless of caller's cancellation - Rationale: Prevents GATT resource leak, which would cause status 133 (GATT error) on next reconnect
- File:
BleRadioTransport.close(),withContext(NonCancellable)
Per-action supervised coroutines in message processor:
- Issue: Single collector on
serviceRepository.serviceActionflow; if one action throws (e.g.,sendAdminAwaittimeout), entire collector crashes - Solution: Each action re-launched in its own
handledLaunchwith SupervisorJob - Rationale: Isolate failures; prevent cascading stalls
- File:
MeshServiceOrchestrator.serviceRepository.serviceAction.onEach { ... newScope.handledLaunch { ... } }
Firmware reboot re-handshake:
- Issue: Firmware can reboot without BLE disconnect (serial/TCP), leaving app stale
- Solution:
FromRadio.rebootedsignal detected; phone immediately triggersconfigFlowManager.triggerWantConfig() - File:
FromRadioPacketHandlerImpl.handleFromRadio(),rebooted != nullcase
Android-specific (do NOT replicate):
MeshServiceAndroid foreground service wrapper- BLE bonding logic (Android BLE API specific)
- Foreground service notification types (CONNECTED_DEVICE, LOCATION)
- Android DataStore (use platform equivalent on iOS/JVM)
- Koin DI graph initialization (refactor for platform-specific factory if needed)
Should be commonMain (currently split or hidden):
- Encryption: X25519, AES-CCM crypto; currently likely Android-specific, should be abstracted to platform interfaces + commonMain core
- MQTT broker client: Paho dependency; should be optional commonMain interface with platform implementations
- XModem: Protocol logic should be commonMain; platform handles file I/O
- Local storage (database): Room-specific SQL; iOS should use SQLite equivalents; JVM can use Room or SQLite
- StreamFrameCodec — Complete (byte-by-byte state machine framing)
- Message routing & handshake state machine — Complete (two-stage protocol, nonce-based)
- PacketStatus tracking — States & transitions (QUEUED → ENROUTE → DELIVERED / ERROR)
- ConnectionState machine — Connect/Connecting/DeviceSleep/Disconnected lifecycle
- Config model — Protobuf parsing & merging (Config, ModuleConfig, Channel, etc.)
- NodeDB — In-memory map + merging logic for delta updates
- AdminMessage session passkey — Generation, caching, reset on disconnect
- HeartbeatSender — Nonce counter, timeout drain logic
- BLE transport — Device discovery, bonding, GATT characteristics, reconnection policy
- TCP transport — Socket lifecycle, keepalive, timeout
- Serial transport — USB serial port handling
- Crypto — X25519 key generation, AES-CCM encryption, key storage
- MQTT — Broker connection, publish/subscribe routing
- Storage — Database (Room on Android → SQLite on iOS/JVM), DataStore equivalents
- Service lifecycle — Android Service wrapper, iOS background modes, JVM main loop
- Database MUST be initialized before radio connection
- Pre-handshake heartbeat MUST precede
want_config_idsend (100ms settle) - Stage 1 complete MUST transition to Stage 2 with inter-stage delays (100ms)
- Early packets (pre-NodeDB-ready) MUST be buffered and replayed
- Session passkey MUST be seeded after
config_complete_id=69421 - Local node lastHeard MUST be throttled (30s minimum between DB writes)
- GATT cleanup MUST be non-cancellable to prevent resource leak
- Handshake stall guard MUST retry once, then reconnect (30s timeout, 15s retry timeout)