Canonical, implementer-facing synthesis of the Meshtastic device PhoneAPI as required to build a clean-room host (phone/desktop) client. This document is the single source of truth that the SDK targets.
Sources (all in
references/):
meshtastic/protobufs— schema (ground truth for message structure)meshtastic/firmware— device-side reference (ground truth for behavior; GPL-3.0 — never copy code)meshtastic/Meshtastic-Android— Android phone-side reference (GPL-3.0 — never copy code)meshtastic/Meshtastic-Apple— Apple phone-side reference (GPL-3.0 — never copy code)meshtastic/meshtastic— official Docusaurus docs site (https://meshtastic.org) — authoritative human-readable spec (GPL-3.0 — never copy text)When sources conflict, precedence is: firmware (behavior) > protobufs (structure) > official docs site (intent and rationale) > Android/Apple impls. Conflicts are flagged inline.
- Transport overview
- Stream framing (TCP & Serial)
- BLE GATT framing
- HTTP API framing
- PhoneAPI envelopes
- Handshake state machine
- MeshPacket structure
- PortNums and per-port payloads
- Channel encryption (AES-CTR)
- PKI direct messages (X25519 + AES-CCM)
- Routing, ACK, retry semantics
- Outbound queue & send lifecycle
- Admin protocol & session passkey
- MQTT proxy mode
- Firmware update (XModem)
- Heartbeat & queue status
- Channels, roles, regions, presets
- Constants & magic numbers
- Implementation pitfalls (read this)
The PhoneAPI is the wire protocol between a Meshtastic device (radio) and a host (phone, desktop). It runs over four transport types, all carrying the same logical stream of ToRadio / FromRadio protobuf envelopes:
| Transport | Framing | Default endpoint | Notes |
|---|---|---|---|
| TCP | 4-byte stream framing (§2) | port 4403 | Single concurrent client per device. 15-minute idle timeout. (See § 1A for idle semantics.) |
| Serial (USB-CDC) | 4-byte stream framing (§2) | 115200 8N1, no flow control | Identical wire format to TCP. Allows clean transition from device debug-text output to protobuf mode on the same port. |
| BLE | Native GATT (§3); no stream framing | Service 6BA1B218-15A8-461F-9FA8-5DCAE273EAFD |
Each read() of fromradio returns exactly one FromRadio protobuf (or empty when queue drained). Each write() to toradio carries exactly one ToRadio. |
| HTTP | Naked protobuf in HTTP body (§4); no stream framing | port 80 (HTTP) / 443 (HTTPS) | RESTful: PUT /api/v1/toradio and GET /api/v1/fromradio?all={bool}. Devices use self-signed certs for HTTPS. |
The host SDK must implement framing per-transport but expose a transport-agnostic RadioTransport interface to the upper layers.
The Meshtastic firmware closes any idle TCP connection after 15 minutes (900_000 ms) with no traffic in either direction.
What "idle" means:
- A socket is idle if no
FromRadioorToRadiocrosses it. - Heartbeat traffic (§16) from the firmware counts as activity and resets the idle timer.
What happens when the timeout fires:
- The firmware closes the TCP socket on its side.
- The host SDK detects EOF on
recv()and transitions toTransportState.Error(recoverable=true). - The engine's liveness timeout (§6, typically 2× heartbeat interval) also triggers shortly after.
- The host must reconnect by calling
connect()again.
Recovery strategy for hosts:
- Implement automatic reconnection with exponential backoff (e.g., 1s, 2s, 4s, 8s, max 60s). The SDK does not auto-reconnect today; see
consumer-guides/error-handling.md→ "Reconnect supervisor (consumer-side)" for a working snippet that observesconnection: StateFlow<ConnectionState>and re-entersconnect(). - Send heartbeats to prevent idleness: the SDK does this automatically if the app is receiving mesh traffic; for a quiet link, the app should send a periodic
ToRadioor rely on the firmware's own heartbeat (viaNodeInfoping). - For interactive use cases (chat apps, UI frontends), the 15-minute timeout is not typically hit because user activity generates traffic.
- For headless/gateway scenarios with low traffic, consider a background heartbeat or explicit keep-alive mechanism.
Note: The idle timeout is a device-side setting defined in firmware:src/mesh/api/ServerAPI.cpp. It is not configurable from the host SDK and is the same across all transport types.
Used by TCP and Serial transports only. NOT used by BLE — BLE relies on GATT's natural message boundaries.
+------+------+----------+----------+==================+
| 0x94 | 0xC3 | LEN_HI | LEN_LO | protobuf bytes |
+------+------+----------+----------+==================+
1B 1B 1B 1B 0..512 bytes
START1=0x94(firmware:src/mesh/StreamAPI.cpp:7)START2=0xC3(firmware:src/mesh/StreamAPI.cpp:8)- Length = big-endian uint16 (
high << 8 | low), counting only the protobuf payload bytes that follow — does not include the 4-byte header. - Maximum payload =
MAX_TO_FROM_RADIO_SIZE= 512 bytes (firmware:src/mesh/PhoneAPI.h:13). Any frame whose declared length exceeds 512 must be discarded and resync started.
The host MUST be tolerant to garbage on the wire, especially on serial (a device may have just emitted boot text). The firmware itself implements a strict resync state machine; the host SDK must do the same on the receive side.
state := SCAN_FOR_START1
loop on each received byte b:
case state:
SCAN_FOR_START1:
if b == 0x94: state := EXPECT_START2
EXPECT_START2:
if b == 0xC3: state := READ_LEN_HI
else if b == 0x94: state := EXPECT_START2 // double-94 still valid
else: state := SCAN_FOR_START1
READ_LEN_HI:
hi := b; state := READ_LEN_LO
READ_LEN_LO:
len := (hi << 8) | b
if len > 512:
state := SCAN_FOR_START1 // bogus length, resume scanning
else:
bytesRemaining := len; state := READ_PAYLOAD
READ_PAYLOAD:
append b to buffer
if buffer.size == len:
try parse FromRadio from buffer
on success: emit FromRadio; clear; state := SCAN_FOR_START1
on failure: log warning; clear; state := SCAN_FOR_START1
Pitfall: a corrupt length byte can swallow up to 512 subsequent bytes before the next resync attempt. Property-test your codec with random byte streams to confirm bounded recovery.
On TCP and serial connect, host implementations SHOULD write 4 × 0x94 (START1) bytes immediately after the link comes up and before the first ToRadio.want_config_id. These are valid no-ops for a healthy firmware framer (the resync FSM stays in EXPECT_START2 on repeated START1) and corrective for a framer left mid-frame by an unclean previous disconnect — which would otherwise burn most of the Stage 1 timeout (§6) chasing a phantom payload length.
Matches the Meshtastic-Android reference (StreamFrameCodec.WAKE_BYTES). BLE has no stream framing (§3) and does not need this.
emit 0x94, 0xC3, (payload.size >> 8) & 0xFF, payload.size & 0xFF, ...payload
If payload.size > 512, the SDK must reject the message with a programmer error; do not split.
BLE uses GATT message boundaries directly. The phone subscribes to a notification characteristic and drains a read characteristic until empty.
| Role | UUID | Properties |
|---|---|---|
| Mesh service | 6BA1B218-15A8-461F-9FA8-5DCAE273EAFD |
— |
fromradio |
2C55E69E-4993-11ED-B878-0242AC120002 |
READ — returns one FromRadio protobuf per read; empty read means queue drained |
toradio |
F75C76D2-129E-4DAD-A1DD-7866124401E7 |
WRITE (with or without response) — phone writes one ToRadio protobuf per write. Clients SHOULD prefer WRITE-WITHOUT-RESPONSE when maximumWriteValueLength(WITHOUT_RESPONSE) suffices for throughput. |
fromnum |
ED9DA18C-A800-4F66-A670-AA7547E34453 |
NOTIFY + READ — 4-byte little-endian counter that increments whenever the device pushes data into the fromradio queue |
logradio (optional, current) |
5A3D6E49-06E6-4423-9944-E9DE8CDF9547 |
NOTIFY + READ — streaming device log lines; informational only. Primary on current firmware. |
logradio (optional, legacy) |
6C6FD238-78FA-436B-AACF-15C5BE1EF2E2 |
NOTIFY + READ — legacy log characteristic. Kept by firmware for backward compatibility; clients should subscribe to whichever is advertised. |
Also exposed: standard Bluetooth Battery Service 0x180F with characteristic 0x2A19.
on connect:
subscribe to fromnum (notify)
subscribe to fromradio (notify) // some firmware variants notify here too
trigger initial drain
on fromnum notification (or any wake signal):
if isDraining: set needsRedrain := true; return
isDraining := true
loop:
bytes := read(fromradio)
if bytes.isEmpty(): break // queue exhausted
parse bytes as FromRadio; deliver to upper layer
isDraining := false
if needsRedrain: needsRedrain := false; goto drain entry
Pitfall: do not treat
fromnum's value as a "messages waiting" count. It's just an ever-increasing wake signal. Always drain by reading until empty.
Pitfall (during config): while the device is in any
STATE_SEND_*state other thanSTATE_SEND_PACKETS, an empty read blocks on some firmware variants — the device intentionally waits to push the next config message rather than returning empty. Phones see this as natural backpressure; do not poll-spin.
- Device requests MTU 517 on ESP32-S3 / C6 (allows 512-byte payload + ATT overhead).
- Default fallback if negotiation fails: 23 (BLE minimum).
- The host should not assume any specific MTU; use
maximumWriteValueLength(WITHOUT_RESPONSE)(Android Kable) or CoreBluetooth's per-write limit (iOS) to determine actual write capacity. Eachtoradiowrite must fit within the negotiated MTU.
- Numeric comparison (6-digit PIN). Device shows the PIN on its display (if it has one) and logs it.
- Optionally:
config.bluetooth.fixed_pinfor a static PIN. - Pairing/bonding is OS-managed (Android Settings, iOS Settings). The SDK should not attempt to drive bonding directly; surface bonding-required errors to the host app.
- NRF52 platforms use legacy classic BLE pairing via
NRF52Bluetooth.cpp; ESP32 platforms use NimBLE.
ESP32-based devices with WiFi (or Ethernet) expose a REST API used by browser-based clients (e.g., the official web client at client.meshtastic.org). Each request carries exactly one envelope as a binary protobuf body — no 0x94 0xC3 stream framing.
| Method | Path | Body | Response |
|---|---|---|---|
PUT |
/api/v1/toradio |
One ToRadio protobuf, binary |
204 No Content on success |
GET |
/api/v1/fromradio |
(none) | One FromRadio protobuf, binary; empty body when queue drained |
GET |
/api/v1/fromradio?all=true |
(none) | Concatenation of all queued FromRadio protobufs; the host parses sequentially using protobuf length-delimited semantics or by stopping at end-of-body |
OPTIONS |
/api/v1/toradio |
(none) | 204 No Content (CORS / connection validation) |
- Request and response
Content-Type: application/x-protobuf. - Optional response header
X-Protobuf-Schemamay carry a URI to the schema for documentation / discovery.
- HTTP on port 80, HTTPS on port 443. Devices serve their own self-signed certificate; browsers require manual trust. The hosted web client at
client.meshtastic.orgis HTTPS-only and so requires the device's HTTPS endpoint be trusted out-of-band. - No authentication — trust is established by network access. SDK consumers exposing a device over public networks must add their own gateway-level auth.
- The HTTP API is stateless from the device's perspective: there is no equivalent of TCP's "single concurrent connection" lock; multiple GET pollers can share a queue (though each
FromRadiois consumed once).
The SDK polls GET /api/v1/fromradio?all=true periodically (typically 1–5 seconds depending on UI responsiveness needs). The empty body sentinel signals "queue drained, no need to re-poll until next user action or wakeup." Future firmware revisions are expected to add a chunked=true long-polling mode (per docs).
meshtastic.js (https://github.com/meshtastic/js) is the canonical TypeScript client. An equivalent Kotlin transport implementation belongs in a future transport-http module (see ./future/wasm-rpc-roadmap.md), not in transport-tcp — the framing is fundamentally different.
Pitfall: Do NOT prepend
0x94 0xC3headers to HTTP bodies. The HTTP transport is the only PhoneAPI transport that carries a raw protobuf, with no length prefix at all (HTTPContent-Lengthdoes that job).
All transports carry exactly one FromRadio per inbound message and one ToRadio per outbound message. These are oneof-based protobuf wrappers defined in mesh.proto.
message ToRadio {
oneof payload_variant {
MeshPacket packet = 1; // App-layer outbound packet (text, position, telemetry, admin, etc.)
uint32 want_config_id = 3; // Triggers the config handshake (see §6)
bool disconnect = 4; // Phone politely tells device it is dropping the link
XModem xmodem_packet = 5; // Firmware update transfer (see §15)
MqttClientProxyMessage mqtt_client_proxy_message = 6; // (see §14)
Heartbeat heartbeat = 7; // Liveness ping (see §16)
}
}message FromRadio {
uint32 id = 1; // Sequential per-session
oneof payload_variant {
MeshPacket packet = 2; // App-layer inbound packet
MyNodeInfo my_info = 3; // Phone's view of THIS node (config handshake)
NodeInfo node_info = 4; // Other-node info from NodeDB (config handshake)
Config config = 5; // Config sub-message (config handshake)
LogRecord log_record = 6; // (legacy text log)
uint32 config_complete_id = 7; // Terminator: handshake complete (echoes want_config_id)
bool rebooted = 8; // Device booted; treat as forced disconnect
ModuleConfig module_config = 9; // ModuleConfig sub-message (config handshake)
Channel channel = 10; // Channel definition (config handshake)
QueueStatus queue_status = 11; // Outbound queue accounting (see §12, §16)
XModem xmodem_packet = 12; // Firmware update transfer (see §15)
DeviceMetadata metadata = 13; // Firmware version, hw model, role, hasPKC, etc. (config handshake)
MqttClientProxyMessage mqtt_client_proxy_message = 14; // (see §14)
FileInfo file_info = 15; // File system manifest entry (config handshake)
ClientNotification client_notification = 16; // User-facing notification
DeviceUIConfig device_ui = 17; // UI customization payload
}
}The
idfield onFromRadiois a per-session counter from the device, useful for ordering / dedupe but the SDK should not depend on it for correlation — useMeshPacket.idfor app-level packet correlation.
Every fresh transport connection MUST begin with a two-stage configuration handshake. Stage 1 streams configs/module-configs/channels (and the file manifest); the phone then sends a heartbeat to settle the device and begins Stage 2, which streams the NodeDB. Both stages are framed by a want_config_id nonce echoed back in config_complete_id as a sentinel. Cross-validated against Meshtastic-Android (HandshakeStateMachine, HeartbeatSender) and Meshtastic-Apple (BLEManager.discoverConfig).
Phone sends:
ToRadio(want_config_id = N)
where N is a uint32 nonce. The firmware does NOT require randomness — only that the value matches what comes back in config_complete_id.
| Nonce | Constant | Behavior |
|---|---|---|
69420 |
SPECIAL_NONCE_ONLY_CONFIG (Android CONFIG_NONCE) |
Stage 1. Stream MyInfo, metadata, configs, module-configs, channels, file manifest; skip the NodeDB. |
69421 |
SPECIAL_NONCE_ONLY_NODES (Android NODE_INFO_NONCE) |
Stage 2. Stream the NodeDB only. |
| Any other non-zero | (legacy single-stage) | Full config + NodeDB stream. New SDK code SHOULD use the two-stage flow. |
0 |
— | Phones MUST NOT send 0. |
Phone → ToRadio(want_config_id = 69420) ── Stage 1 begin
Device → FromRadio.my_info / metadata / channel × N
/ config × N / module_config × N
/ file_info × N
/ config_complete_id = 69420 ── Stage 1 end
Phone: wait 100 ms
Phone → ToRadio(heartbeat = Heartbeat(nonce = ++n)) ── pre-handshake heartbeat (settle)
Phone: wait 100 ms
Phone → ToRadio(want_config_id = 69421) ── Stage 2 begin
Device → FromRadio.node_info × N
/ config_complete_id = 69421 ── Stage 2 end (Connected)
Phone → AdminMessage(get_owner_request, want_response = true)
Device → AdminMessage(get_owner_response, session_passkey = …) ── seed session key
After Stage 2 completes the link is Connected; subsequent FromRadio traffic is normal operation (packet, queue_status, mqtt_client_proxy_message, etc.).
sequenceDiagram
autonumber
participant Phone as Phone (SDK)
participant Device as Device (firmware)
Note over Phone,Device: Stage 1 — config + channels + file manifest
Phone->>Device: ToRadio(want_config_id = 69420)
Device-->>Phone: FromRadio.my_info
Device-->>Phone: FromRadio.metadata
Device-->>Phone: FromRadio.channel × N
Device-->>Phone: FromRadio.config × N
Device-->>Phone: FromRadio.module_config × N
Device-->>Phone: FromRadio.file_info × N
Device-->>Phone: FromRadio(config_complete_id = 69420)
Note over Phone: wait 100 ms
Phone->>Device: ToRadio(heartbeat = nonce++)
Note over Phone: wait 100 ms
Note over Phone,Device: Stage 2 — NodeDB
Phone->>Device: ToRadio(want_config_id = 69421)
Device-->>Phone: FromRadio.node_info × N
Device-->>Phone: FromRadio(config_complete_id = 69421)
Note over Phone,Device: Seeding session — admin session key
Phone->>Device: AdminMessage(get_owner_request, want_response = true)
Device-->>Phone: AdminMessage(get_owner_response, session_passkey)
Note over Phone,Device: Connected — normal FromRadio.packet / queue_status / …
The firmware emits these FromRadio payloads in this order (firmware:src/mesh/PhoneAPI.cpp:213-223); item 8 (NodeDB) is skipped under nonce 69420 = SPECIAL_NONCE_ONLY_CONFIG and item 9 (FILEMANIFEST) is short-circuited to empty under 69421 = SPECIAL_NONCE_ONLY_NODES:
| # | State | FromRadio variant |
Notes |
|---|---|---|---|
| 1 | STATE_SEND_MY_INFO |
my_info: MyNodeInfo |
Mandatory. Sets phone's view of my_node_num. |
| 2 | STATE_SEND_UIDATA |
device_ui: DeviceUIConfig |
Optional UI payload. |
| 3 | STATE_SEND_OWN_NODEINFO |
node_info: NodeInfo |
Self entry in NodeDB. |
| 4 | STATE_SEND_METADATA |
metadata: DeviceMetadata |
Firmware version, hw model, role, hasPKC (PKI capability), excluded modules. |
| 5 | STATE_SEND_CHANNELS |
channel: Channel × N |
One per defined channel slot. |
| 6 | STATE_SEND_CONFIG |
config: Config × N |
One per Config sub-message: device, position, power, network, display, lora, bluetooth, security, sessionkey, device_ui. |
| 7 | STATE_SEND_MODULECONFIG |
module_config: ModuleConfig × N |
One per ModuleConfig sub-message: mqtt, serial, external_notification, store_forward, range_test, telemetry, canned_message, audio, remote_hardware, neighbor_info, detection_sensor, ambient_lighting, paxcounter, traffic_management. |
| 8 | STATE_SEND_OTHER_NODEINFOS |
node_info: NodeInfo × N |
Skipped under nonce 69420. Under 69421 the firmware jumps directly here from STATE_SEND_OWN_NODEINFO (skipping rows 4–7). |
| 9 | STATE_SEND_FILEMANIFEST |
file_info: FileInfo × N |
File-system manifest. Emitted only under 69420 (and any non-special nonce); short-circuited to empty under 69421 (PhoneAPI.cpp line 522). |
| 10 | STATE_SEND_COMPLETE_ID |
config_complete_id = N |
Sentinel — current stage done; value MUST equal the nonce the phone sent. |
| 11 | STATE_SEND_PACKETS |
packet: MeshPacket, queue_status, mqtt_client_proxy_message, etc. |
Normal operation (after Stage 2). |
Upstream contract.
firmware:src/mesh/PhoneAPI.cpp getFromRadio()tags this exact sequence with the verbatim comment "the client apps ASSUME THIS SEQUENCE, DO NOT CHANGE IT." Treat the order as a hard-locked wire contract: any host implementing this handshake MUST accept these variants in this order and MUST NOT gate Stage-1 completion on later variants arriving before earlier ones.
Stage 2 sequence (under 69421): rows 1–3 (MY_INFO, UIDATA, OWN_NODEINFO) → row 8 (OTHER_NODEINFOS × N) → row 10 (COMPLETE_ID = 69421). Rows 4–7 (metadata, channels, configs, module configs) and row 9 (file manifest) are skipped. Configuration data is therefore Stage-1-only; node DB is Stage-2-only.
- The phone MUST consider itself
Configuringuntilconfig_complete_id == 69421(or matching sentinel for the legacy single-stage flow). It MAY surface configs/channels/nodes as they stream in, but MUST NOT consider itselfConnecteduntil Stage 2 ends. - Pre-handshake bytes are discarded. Any
FromRadioreceived before the matchingconfig_complete_idfor the current stage is dropped from the public surface (may be logged). This rule applies to all transports — BLE-only "drain stale buffer" hacks are not needed; the transport-agnostic invariant is "discard until matching sentinel". - Inter-stage settle. Between Stage 1 sentinel and Stage 2 trigger the phone MUST send
ToRadio.heartbeatand SHOULD wait ~100 ms either side of it (Android:HandshakeStateMachinelines 187–222 —delay(100); heartbeatSender.send(); delay(100)). This lets the device's task queue drain before NodeDB streaming starts. - Session passkey seeding. Immediately after Stage 2 completes, the phone SHOULD issue an
AdminMessage(get_owner_request, want_response = true). The response carriessession_passkeywhich is required for all subsequent state-changing admin requests (§13). - If the device emits
FromRadio.rebooted = true, treat as a forced disconnect — clear all state and re-issue Stage 1 with a fresh nonce pair (69420then69421). - On TCP/Serial close, the device resets its
config_nonceto0and clears all session state. On reconnection, both stages are mandatory. - While the device is mid-handshake, queued outbound packets in the device router are NOT dropped — they will be sent after
config_complete_id. However, the device WILL drop incomingMqttClientProxyMessagefrom the phone during handshake with a warning log.
For the canonical two-stage flow use the well-known nonces 69420 / 69421. For diagnostic / legacy single-stage handshakes a random non-zero uint32 per cycle keeps logs unambiguous; randomness is not a protocol requirement.
The MeshPacket is the unit of mesh-network traffic. Every app-layer event (text, position, telemetry, admin, ack, etc.) is a MeshPacket with a payload identified by PortNum.
message MeshPacket {
fixed32 from = 1; // Sending node ID (uint32)
fixed32 to = 2; // Destination node ID; 0xFFFFFFFF = broadcast
uint32 channel = 3; // Channel index (0 = primary)
oneof payload_variant {
Data decoded = 4; // Decrypted payload (only present after channel decryption)
bytes encrypted = 5; // Ciphertext (always present on-air; phone-side may decrypt to `decoded`)
}
fixed32 id = 6; // Packet ID (uint32). Generated by sender. Used for ACK correlation, dedupe.
fixed32 rx_time = 7; // Receiver-side wall-clock seconds since UNIX epoch (set by device)
float rx_snr = 8; // Received SNR
uint32 hop_limit = 9; // Decremented at each relay; max 7 (HOP_LIMIT_MAX)
bool want_ack = 10; // Sender wants explicit ACK from destination via Routing app
Priority priority = 11; // BACKGROUND/DEFAULT/RELIABLE/ACK/MAX/HIGH (see enum)
uint32 rx_rssi = 12; // Received RSSI
uint32 via_mqtt = 13; // 1 if received via MQTT proxy (ServiceEnvelope) rather than airwaves
uint32 hop_start = 14; // Original hop_limit at send time. (hop_start - hop_limit) = hops_away
bytes public_key = 15; // (PKI) Sender's 32-byte X25519 public key, if PKI used
bool pki_encrypted = 16; // True if `encrypted` is PKI-encrypted (X25519+AES-CCM, see §10)
fixed32 next_hop = 17; // Next-hop node ID (uint32) for unicast routing hint
fixed32 relay_node = 18; // The node that relayed this to me (uint32)
uint32 tx_after = 19; // Transmit-after delay (seconds), used by router for backoff
Delayed delayed = 20; // Whether this is a delayed/store-and-forward packet
TransportMechanism transport = 21; // How this packet arrived (LoRa, MQTT, API, etc.)
}
enum Priority {
UNSET = 0;
MIN = 1;
BACKGROUND = 10;
DEFAULT = 64;
RELIABLE = 70;
RESPONSE = 80;
HIGH = 100;
ALERT = 110;
ACK = 120;
MAX = 127;
}HOP_LIMIT_MAX= 7 (3-bit field; max useful hop_limit)- Broadcast address:
0xFFFFFFFF(NOT0and NOT0xFF) PublicKeysize: always 32 bytes (Curve25519)
- Outbound from phone: phone constructs
MeshPacketwithdecoded: Data{...}populated, setsid,to,channel, etc., wraps inToRadio.packet, sends. Device encrypts (if channel has PSK) or PKI-encrypts (if direct message and recipient has known public key) before air transmission. - Inbound to phone: device hands phone a
MeshPacketwith EITHERdecoded(already-decrypted by the device — both channel PSK and PKI direct messages are decrypted on-device usingCryptoEngine.cpp'sdecryptCurve25519/ channel-PSK paths) ORencrypted(phone must decrypt — only the case when the phone holds a channel PSK that the device does not have configured locally; PKI keypairs live on the device, not the phone).
Pitfall: a
MeshPacket.idof0is invalid for outbound packets. The device assigns the ID if the phone leaves it0, but for ACK correlation the phone usually generates the ID itself.
PortNum (defined in portnums.proto) routes the bytes inside Data.payload to the correct decoder.
message Data {
PortNum portnum = 1; // Routes payload interpretation
bytes payload = 2; // Raw bytes (interpretation depends on portnum)
bool want_response = 3; // Recipient should reply if possible (use sparingly on broadcast)
fixed32 dest = 4; // [INTERNAL] Multihop destination
fixed32 source = 5; // [INTERNAL] Multihop source
fixed32 request_id = 6; // [ROUTING] Echoed by routing failure responses; correlates ACKs
fixed32 reply_id = 7; // [REPLY] If non-zero, this is a reply to the packet with this id
fixed32 emoji = 8; // [REACTION] Emoji reaction code
uint32 bitfield = 9; // Misc bit flags
}| PortNum | Value | Payload type | Direction | Notes |
|---|---|---|---|---|
UNKNOWN_APP |
0 | — | — | Unset/error |
TEXT_MESSAGE_APP |
1 | UTF-8 string in payload |
both | Plain chat |
REMOTE_HARDWARE_APP |
2 | HardwareMessage |
both | Remote GPIO |
POSITION_APP |
3 | Position |
both | Lat/lon/alt/precision/time |
NODEINFO_APP |
4 | User |
both | Long/short name, public_key, role, is_licensed |
ROUTING_APP |
5 | Routing |
both | ACK/NAK and route discovery |
ADMIN_APP |
6 | AdminMessage |
both | Device administration (see §13) |
TEXT_MESSAGE_COMPRESSED_APP |
7 | unishox2-compressed UTF-8 |
both | Optional compression |
WAYPOINT_APP |
8 | Waypoint |
both | Shared map markers |
AUDIO_APP |
9 | Codec2 audio frames | both | Push-to-talk |
DETECTION_SENSOR_APP |
10 | UTF-8 string | device→phone | GPIO event notifications |
ALERT_APP |
11 | UTF-8 string | both | High-priority broadcast alerts |
KEY_VERIFICATION_APP |
12 | KeyVerification |
both | PKI fingerprint verification (see §10) |
REMOTE_SHELL_APP |
13 | UTF-8 bytes | both | Remote shell module (admin gated) |
REPLY_APP |
32 | payload is text/emoji reply |
both | Modern reply channel |
IP_TUNNEL_APP |
33 | IP packet bytes | both | Optional IP-over-LoRa |
PAXCOUNTER_APP |
34 | Paxcount |
device→phone | Pedestrian counter |
STORE_FORWARD_PLUSPLUS_APP |
35 | StoreAndForward (extended) |
both | Store-and-forward v2 |
NODE_STATUS_APP |
36 | proprietary | — | Reserved |
SERIAL_APP |
64 | UTF-8 / bytes | both | Serial-bridge module |
STORE_FORWARD_APP |
65 | StoreAndForward |
both | Store-and-forward router |
RANGE_TEST_APP |
66 | UTF-8 sequence numbers | both | Range testing module |
TELEMETRY_APP |
67 | Telemetry |
both | DeviceMetrics / EnvironmentMetrics / etc. |
ZPS_APP |
68 | proprietary | — | Reserved |
SIMULATOR_APP |
69 | proprietary | — | Reserved |
TRACEROUTE_APP |
70 | RouteDiscovery |
both | Route trace |
NEIGHBORINFO_APP |
71 | NeighborInfo |
both | Neighbor heartbeat |
ATAK_PLUGIN |
72 | TAKPacket |
both | ATAK integration |
MAP_REPORT_APP |
73 | MapReport |
device→MQTT | Public map reporting |
POWERSTRESS_APP |
74 | proprietary | — | Power stress test |
LORAWAN_BRIDGE |
75 | proprietary | — | LoRaWAN bridge module |
RETICULUM_TUNNEL_APP |
76 | Reticulum bytes | both | Reticulum integration |
CAYENNE_APP |
77 | bytes | — | LoRaWAN Cayenne payload |
ATAK_PLUGIN_V2 |
78 | TAKPacket (v2) |
both | ATAK integration v2 |
GROUPALARM_APP |
112 | proprietary | — | Group alarm module |
PRIVATE_APP |
256 | bytes | — | Reserved for private use |
ATAK_FORWARDER |
257 | bytes | — | ATAK forwarding |
MAX |
511 | sentinel | — | Max valid PortNum |
Phone-app payloads are all defined in
meshtastic/protobufs(telemetry.proto,mesh.proto,admin.proto, etc.). The SDK uses Wire to generate Kotlin DTOs for all of them; consumers see typed payloads.
message Routing {
oneof variant {
RouteDiscovery route_request = 1; // Traceroute outbound
RouteDiscovery route_reply = 2; // Traceroute response
Error error_reason = 3; // ACK/NAK; 0 = NONE = ACK
}
}
enum Error {
NONE = 0; // Success / ACK
NO_ROUTE = 1;
GOT_NAK = 2;
TIMEOUT = 3;
NO_INTERFACE = 4;
MAX_RETRANSMIT = 5;
NO_CHANNEL = 6;
TOO_LARGE = 7;
NO_RESPONSE = 8;
DUTY_CYCLE_LIMIT = 9;
BAD_REQUEST = 32;
NOT_AUTHORIZED = 33;
PKI_FAILED = 34;
PKI_UNKNOWN_PUBKEY = 35;
ADMIN_BAD_SESSION_KEY = 36;
ADMIN_PUBLIC_KEY_UNAUTHORIZED = 37;
RATE_LIMIT_EXCEEDED = 38;
}Routing packets carry their request_id field referencing the original packet's id — that is how the SDK correlates ACKs/NAKs to outbound sends.
Channels provide symmetric encryption with a pre-shared key (PSK). All nodes on a channel share the PSK; any node can decrypt any traffic.
message Channel {
int32 index = 1;
ChannelSettings settings = 2;
Role role = 3; // DISABLED / PRIMARY / SECONDARY
}
message ChannelSettings {
bytes psk = 1; // 0/1/16/32 bytes (see PSK shorthand below)
string name = 2; // Human-readable channel name
fixed32 id = 3; // Numeric ID (channel hash; see below)
bool uplink_enabled = 4;
bool downlink_enabled = 5;
ModuleSettings module_settings = 6;
}The psk bytes field is interpreted by length:
psk.size |
Meaning |
|---|---|
| 0 | No encryption — ciphertext on the wire equals plaintext. |
1, value 0x00 |
No encryption (alternative encoding). |
1, value 0x01 |
Default channel key: {0xd4, 0xf1, 0xbb, 0x3a, 0x20, 0x29, 0x07, 0x59, 0xf0, 0xbc, 0xff, 0xab, 0xcf, 0x4e, 0x69, 0x01} (16 bytes; AES-128). This is the well-known LongFast/MediumFast default. Index 1 is also a no-op offset over itself (see next row). |
1, value pskIndex (0x02..) |
"Simple" preset: same as default key but with the last byte replaced by defaultPsk[15] + (pskIndex - 1) (firmware:src/mesh/Channels.cpp lines 228–242). So [0x02] → last byte 0x02, [0x03] → 0x03, …, [0x0A] → 0x0A. Firmware imposes no upper bound (the offset is uint8_t and wraps), but the reference clients expose this as simple1..simple9 mapped to pskIndex 2..10 (the canonical UI range — not a wire requirement). |
| 16 | AES-128 key, used as-is. |
| 32 | AES-256 key, used as-is. |
| Other lengths | Invalid; must be rejected. |
The 8-bit channel hash is computed as:
hash := xorBytes(channel.name.utf8) ^ xorBytes(channel.psk)
where xorBytes(bytes) := bytes.fold(0) { acc, b -> acc ^ b }
This hash is stored in MeshPacket.channel for outbound packets so receivers can quickly skip packets they cannot decrypt. The SDK must compute and provide this hash when constructing outbound packets.
Source:
firmware:src/mesh/Channels.cpp:27-51(generateHashandxorHash).
algorithm := AES-256-CTR if psk.size == 32
algorithm := AES-128-CTR if psk.size == 16 (or expanded 1-byte shorthand)
algorithm := none if psk.size == 0 or psk == [0x00]
key := the (possibly expanded) 16- or 32-byte PSK
nonce := 16 bytes:
bytes 0..7 : packet_id (uint64 little-endian — pad uint32 packet_id with leading zeros)
bytes 8..11 : from_node (uint32 little-endian)
bytes 12..15 : 0x00 0x00 0x00 0x00
ciphertext := AES-CTR(key, nonce).process(plaintext)
The plaintext is the serialized Data protobuf (i.e. the inner message that lives in MeshPacket.decoded). The ciphertext goes into MeshPacket.encrypted.
Pitfall (cross-source conflict resolved): an older Android-side note suggested the IV is uint32 packet_id zero-padded. This is incorrect. The firmware (
src/mesh/CryptoEngine.cpp:287-296initNonce) treats the first 8 bytes aspacket_idinterpreted as uint64. For uint32 packet_ids this means: write the 32-bit packet_id as little-endian into bytes 0..3, then zeros in bytes 4..7. Trust the firmware.
Pitfall: AES-CTR provides no authentication. A malicious actor with the PSK can produce undetectable forgeries. This is by-design for channel traffic (anyone with the PSK is trusted). For authenticated direct messages, use PKI (§10).
For unicast messages between two nodes that have exchanged Curve25519 public keys, the device can use authenticated encryption instead of channel PSK.
A MeshPacket is PKI-encrypted (rather than channel-encrypted) when ALL of:
tois unicast (not0xFFFFFFFF)- The sender knows the recipient's
public_key(from a priorNODEINFO_APPpacket) - The recipient is signaled by setting
MeshPacket.pki_encrypted = trueon the wire
sender_priv := local 32-byte X25519 private key (host-managed; persisted securely)
recipient_pub := 32-byte X25519 public key (from User.public_key in NodeDB)
shared := X25519(sender_priv, recipient_pub) // 32 bytes
session_key := SHA-256(shared) // 32 bytes (AES-256 key)
nonce := 16 bytes constructed as in §9 (packet_id || from_node || zeros)
BUT only the first 13 bytes are used by AES-CCM
auth_tag_size := 8 bytes (M=8 in CCM parameters)
extra_nonce := 4 bytes random, stored AFTER the auth tag
ciphertext := AES-256-CCM(session_key, nonce[..13], auth_size=8).encrypt(plaintext)
on_wire := ciphertext || auth_tag(8 bytes) || extra_nonce(4 bytes)
The wire payload (MeshPacket.encrypted) thus has 12 bytes of overhead over the plaintext.
The recipient's MeshPacket.public_key field MAY carry the sender's public key for one-shot decryption when the recipient does not yet have it cached.
Source:
firmware:src/mesh/CryptoEngine.cpp:106-178(encryptCurve25519/decryptCurve25519).
Pitfall (cross-source conflict resolved): the Apple report notes "X25519 + AES-GCM via CryptoKit". This is incorrect. The firmware uses AES-CCM, not GCM. CryptoKit on iOS does not natively expose CCM, so a KMP impl on iOS must use a Kotlin/Native-friendly library (or a small hand-rolled CCM over CryptoKit's AES primitives). On JVM/Android, BouncyCastle's
AES/CCM/NoPaddingis the standard.
The X25519 keypair lives on the device, not on the phone. Both encryption (encryptCurve25519) and decryption (decryptCurve25519) happen inside the firmware (firmware:src/mesh/CryptoEngine.cpp:106-178). The phone:
- Receives PKI direct messages already-decrypted in
MeshPacket.decoded(the firmware decrypts before forwarding via PhoneAPI). - Sends PKI direct messages by setting
pki_encrypted = true(or simply addressing a unicast packet to a node whoseUser.public_keythe device has cached); the device performs the encryption. - Caches public keys for other nodes via
User.public_keyfromNODEINFO_APPpackets — these are ordinary NodeDB data, persisted with the rest of the cache.
Consequently the SDK does not define a KeyMaterialStore interface — there is no private key for the host to store. The local node's public key is exposed via ownNode.user.public_key for out-of-band display/QR verification (see below).
Earlier drafts of this document specified a
KeyMaterialStore. That contradicts the firmware reality and has been removed; the rule is device owns the keypair.
Numeric out-of-band key verification is not implemented in firmware. Hosts may surface the local public key as a string/QR for manual verification. The SDK should expose localUser.publicKey as an opaque ByteArray.
on send: p.hop_limit := config.lora.hop_limit (default 3, max 7)
p.hop_start := p.hop_limit
on receive: hops_away := p.hop_start - p.hop_limit
on relay: p.hop_limit -= 1
- Setting
want_ack = trueon an outboundMeshPacketcauses the destination node to emit aROUTING_APPpacket back to the sender containingRouting.error_reason = NONE(success) or one of the error codes. - Relays implicitly ACK by retransmitting (the sender hears its own packet relayed = implicit ACK).
want_ackis automatically cleared by the device for broadcast packets to prevent ack storms.
The device-side ReliableRouter / NextHopRouter performs retries (exponential backoff, several attempts). After the device gives up, it emits a ROUTING_APP MAX_RETRANSMIT error response. The host SDK MUST NOT implement a separate retry timer; doing so would cause duplicate transmissions on the air and undermine the device's retry accounting.
The host SDK's responsibility is purely:
- Track the outbound packet by
id. - Wait for either a
ROUTING_APPresponse (ACK or NAK) or a host-side timeout (~5 minutes, after which markFailed(Timeout)). - If the user retries from the UI, generate a new
packet_id(do NOT resend with the same ID).
Data.request_idis set byROUTING_APPresponses (and by some module replies) to echo the original packet'sid. Host correlates by this field.Data.reply_idis set by app-level replies (e.g., a text message that is a reply-to) to point at the packet being replied to.
The reference Android impl tracks these states in its persisted message store; the SDK exposes equivalent typed states via SendState:
| State | Meaning |
|---|---|
Queued |
Locally queued; not yet handed to the device (offline, or device queue full). |
Enroute |
Handed to the device; awaiting ACK/NAK. |
Delivered |
Destination ACKed via ROUTING_APP NONE. |
Failed(reason) |
NAKed (Routing.error_reason != NONE), or host-side timeout, or device emitted MAX_RETRANSMIT. |
Android additionally has
SfppRoutingandSfppConfirmedfor store-and-forward; the SDK can model these as additional sealed variants.
The device emits FromRadio.queue_status whenever its outbound queue depth changes:
message QueueStatus {
int32 res = 1; // Success/error code
uint32 free = 2; // Free slots in device tx queue
uint32 maxlen = 3; // Total queue capacity
uint32 mesh_packet_id = 4; // Packet that triggered the status
}Host SDK:
- If
free == 0, enter local backpressure: do not write moreToRadio.packetmessages untilfree > 0arrives. - The local
Queuedstate holds packets while waiting for capacity. - Default device queue depth is small (typically 8–16); the host SHOULD NOT assume large depth.
MeshPacket.id (uint32) must be unique per session. The reference impls use a monotonic counter seeded from a random value. The SDK uses IdGenerator (atomic counter; seeded with Random.nextInt()) to produce IDs.
The Android reference sends ToRadio(heartbeat = Heartbeat(nonce = ++counter)) on a 30 s timer (Apple uses 15 s). The nonce field is functional: the firmware's per-connection write deduplication does a byte-level memcmp and would silently drop byte-identical consecutive heartbeats — incrementing the nonce keeps every heartbeat distinct on the wire (Meshtastic-Android:HeartbeatSender.kt, DataLayerHeartbeatSender.kt). The firmware does not echo the value back; it just sets heartbeatReceived = true and queues a queueStatus response (firmware:src/mesh/PhoneAPI.cpp lines 192–233).
On BLE specifically, the Android reference waits 200 ms after sending heartbeat before re-draining the fromradio characteristic. The 200 ms grace is to let the ESP32 NimBLE FreeRTOS task finish populating its outbound queue with the heartbeat response. On TCP/Serial there is no analogous race — frames stream as they're produced.
See §16 for the heartbeat policy by transport.
Device administration (changing configs, rebooting, factory-reset, key management) goes through PortNum.ADMIN_APP carrying an AdminMessage payload.
message AdminMessage {
bytes session_passkey = 101; // Required for state-changing requests
oneof payload_variant {
bool get_channel_request = 1; // (with channel index in get_channel_request)
Channel get_channel_response = 2;
bool get_owner_request = 3;
User get_owner_response = 4;
AdminMessageConfigType get_config_request = 5;
Config get_config_response = 6;
AdminMessageModuleConfigType get_module_config_request = 7;
ModuleConfig get_module_config_response = 8;
bool get_canned_message_module_messages_request = 10;
string get_canned_message_module_messages_response = 11;
bool get_device_metadata_request = 12;
DeviceMetadata get_device_metadata_response = 13;
string get_ringtone_request = 14;
string get_ringtone_response = 15;
bool get_device_connection_status_request = 16;
DeviceConnectionStatus get_device_connection_status_response = 17;
HamParameters set_ham_mode = 18;
NodeRemoteHardwarePinsResponse get_node_remote_hardware_pins_response = 19;
bool get_node_remote_hardware_pins_request = 20;
bool begin_edit_settings = 64;
bool commit_edit_settings = 65;
fixed32 reboot_ota_seconds = 95;
bool exit_simulator = 96;
int32 reboot_seconds = 97;
int32 shutdown_seconds = 98;
int32 factory_reset_config = 99;
bool nodedb_reset = 100;
Position set_fixed_position = 102;
bool remove_fixed_position = 103;
fixed32 set_time_only = 104;
User set_owner = 32;
Channel set_channel = 33;
Config set_config = 34;
ModuleConfig set_module_config = 35;
string set_canned_message_module_messages = 36;
string set_ringtone_message = 37;
fixed32 remove_by_nodenum = 38;
fixed32 set_favorite_node = 39;
fixed32 remove_favorite_node = 40;
Position set_fixed_position_legacy = 41; // (deprecated)
bool enter_dfu_mode_request = 92;
string delete_file_request = 93;
BackupLocation backup_preferences = 94;
bool factory_reset_device = 105;
fixed32 set_ignored_node = 106;
fixed32 remove_ignored_node = 107;
// ... (full enum is large; consult admin.proto)
}
}State-changing admin operations require a session passkey. Workflow:
- Phone sends
AdminMessage(get_device_metadata_request = true)(no passkey required). - Device responds with
DeviceMetadatacontaining fields including the session passkey (8 bytes). - Phone caches the passkey for ~5 minutes (firmware regenerates at ~150s for a sliding window).
- Phone includes the passkey in the
session_passkeyfield of every state-changing admin request. - If the passkey expires or doesn't match, the device responds with
Routing.error_reason = ADMIN_BAD_SESSION_KEY.
The SDK should:
- Auto-fetch the passkey on first admin request and refresh on
ADMIN_BAD_SESSION_KEYfailure. - NOT persist the passkey across SDK lifetimes — always fetch fresh after reconnect.
For multi-field config edits, use begin_edit_settings / commit_edit_settings to bundle changes atomically. The device locks against other clients during the edit window and applies all changes on commit.
By convention, admin messages travel on the admin channel (a dedicated channel role). If the device has no admin channel configured, admin messages travel on the primary channel.
When the device is configured with module_config.mqtt.proxy_to_client_enabled = true AND has at least one channel with MQTT enabled (or map reporting), it delegates MQTT traffic to the phone instead of opening its own MQTT TCP connection. This is useful when the phone has internet connectivity and the device does not.
message MqttClientProxyMessage {
string topic = 1;
oneof payload_variant {
bytes data = 2; // Binary payload (typically a serialized ServiceEnvelope)
string text = 3; // Text payload (some MQTT topics use JSON or plaintext)
}
bool retained = 4;
}- Device → phone: device sends
FromRadio.mqtt_client_proxy_messageindicating "publish thisdata(ortext) totopicon the configured broker, with retain flagretained." The phone is responsible for performing the MQTT PUBLISH. - Phone → device: phone sends
ToRadio.mqtt_client_proxy_messageindicating "I just received this message on a topic the device subscribed to; please process it as if you'd received it directly."
msh/2/e/{channel_name}/{node_id_hex} // encrypted channel traffic
msh/2/c/... // (other variants for stat/json/map)
msh/2/stat/{node_id_hex} // node stat
msh/2/json/... // JSON-decoded variants
The device emits ServiceEnvelope (defined in mqtt.proto) as the protobuf payload — a MeshPacket plus channel context plus gateway info.
When implementing an MQTT-proxy module, the SDK consumer wires an MQTT client (recommended: org.meshtastic:mqtt-client, the official sibling KMP MQTT 5 library) into the SDK via a MqttClientProxy interface. The SDK relays messages between the device and the MQTT client; it does not embed an MQTT implementation.
While the device is in STATE_SEND_* (anything other than STATE_SEND_PACKETS), incoming ToRadio.mqtt_client_proxy_message from the phone is dropped with a warning log. The host SHOULD buffer outbound proxy messages until the connection state is Connected.
The device supports XModem-style firmware transfers initiated by an AdminMessage(enter_dfu_mode_request = true) followed by an XModem byte-stream over the same PhoneAPI channel.
message XModem {
Control control = 1;
uint32 seq = 2;
uint32 crc16 = 3;
bytes buffer = 4;
}
enum Control {
NUL = 0;
SOH = 1; // Start of Header (128-byte block)
STX = 2; // Start of Text (1024-byte block)
EOT = 4; // End of Transmission
ACK = 6;
NAK = 21;
CAN = 24; // Cancel
CTRLZ = 26;
}Carried on ToRadio.xmodem_packet and FromRadio.xmodem_packet. Block sizes 128 (SOH) and 1024 (STX). Standard XModem protocol with CRC-16 instead of checksum. Standard recovery: NAK any block with bad CRC; expect retransmit. EOT terminates.
The SDK should expose firmware update as an opt-in module (deferred to Phase 6 in the project plan).
When a device enters DFU mode (XModem firmware update) or reboots (triggered via AdminMessage(reboot_request = true) or power cycle), the PhoneAPI connection is lost and must be re-established from scratch.
For stream transports (TCP, Serial):
- The host's resync FSM (§2) remains in effect: any garbage on the wire after the device reboots is correctly discarded.
- On TCP: the device may be unreachable for 5–30 s while rebooting; the transport's
connect()will timeout and the SDK will auto-retry per the reconnect policy (ADR-002, exponential backoff with jitter). - On Serial: if the device emits boot text or debug output before re-entering PhoneAPI mode, the resync FSM absorbs it and returns to
SCAN_FOR_START1. - Wake bytes (§2) are RECOMMENDED immediately after reconnect to ensure the firmware's framer is not left mid-frame by the DFU transition.
For BLE:
- The device will disconnect the GATT connection; Android/iOS will surface this as a
BluetoothExceptionorCBPeripheralConnectionError. - The transport layer must handle re-enumeration (BLE peripheral disappears temporarily, then reappears).
- No explicit resync is needed on BLE — the transport layer's
connect()re-establishes the GATT connection and drains thefromradioqueue from the top.
Handshake behavior post-reboot:
- The device's
config_complete_idcounter does NOT persist across reboot; the first reconnect after a reboot is indistinguishable from a cold connect. - If the host's
NodeDBor other state was persisted to storage, the app may compare the incomingnode_infostream against its prior view to detect changes (e.g., a new node, channel change, role change). - Heartbeat nonce is reset; the host must start a fresh counter after reconnect.
// From meshtastic/protobufs:meshtastic/mesh.proto
// "This is currently only needed to keep serial connections alive,
// but can be used by any PhoneAPI."
message Heartbeat {
uint32 nonce = 1; // Strictly-increasing counter; defeats the firmware's
// per-connection memcmp dedup so consecutive heartbeats
// are not silently dropped. Firmware does not echo it back.
// NOTE: `nonce == 1` is reserved as a "force-broadcast NodeInfo"
// sentinel in current firmware — see the warning below.
}ToRadio.heartbeat is a liveness ping. Clients MUST increment nonce on every send (a monotonic counter is sufficient).
⚠️ Reserved nonce —nonce == 1. Current firmware (firmware:src/mesh/PhoneAPI.cpphandleToRadio/meshtastic_ToRadio_heartbeat_tagbranch) overloadsHeartbeat(nonce = 1)as a side-channel trigger to force-broadcast our ownNodeInfoonto the LoRa mesh, bypassing the 10-minute NodeInfo cooldown. This is a post-reboot / factory-reset recovery affordance, not a liveness ping. Clients SHOULD skip nonce == 1 entirely (start the counter at2) unless they explicitly want to rebroadcast their NodeInfo. This SDK initialises its counter to1and pre-increments before send, so the first emitted nonce is2.
Cadence by transport (cross-validated against Meshtastic-Apple:Transport.swift + AccessoryManager.setupPeriodicHeartbeat and Meshtastic-Android:SharedRadioInterfaceService.kt + HeartbeatSender.kt):
| Transport | Periodic heartbeat | Reference cadence | Rationale |
|---|---|---|---|
| TCP | REQUIRED | Apple 15 s, Android 30 s | Firmware ServerAPI times the link out; without heartbeat the device drops the connection. Apple sets requiresPeriodicHeartbeat = true on TCPTransport. |
| Serial (USB / jSerialComm) | REQUIRED | 15–30 s | Apple sets requiresPeriodicHeartbeat = true on SerialTransport. The proto comment ("currently only needed to keep serial connections alive") names this as the original use case. |
| BLE | OPTIONAL | Android sends every 30 s; Apple skips entirely | Apple sets requiresPeriodicHeartbeat = false; the GATT subscription provides liveness. Android still sends, both as defence-in-depth and to trigger the post-heartbeat drain (§12). Either policy works against firmware. |
| HTTP | NOT applicable | — | Polling model (§4); HTTP keep-alive handles liveness. |
In addition to the periodic timer, the handshake flow in §6 sends one heartbeat between Stage 1 and Stage 2 on every transport (the inter-stage settle). That is independent of the periodic policy above.
Apple's reference implementation also resets the heartbeat timer on every received data/log packet (effectively "send heartbeat only after 15 s of silence"); this is an optimisation, not a protocol requirement. A simple fixed-interval timer is conformant.
If no FromRadio arrives for ~2× the heartbeat interval after a heartbeat-required transport's last send, the SDK MUST treat the link as stale, emit MeshEvent.TransportError("liveness timeout"), and tear the session down to Disconnected so higher layers can reconnect. This SDK implements the watchdog in MeshEngine (LIVENESS_TIMEOUT_MS = HEARTBEAT_INTERVAL_MS * 2 = 60 s); TCP adds a second, transport-layer read timeout as a backstop for the pre-Ready handshake window where the engine watchdog isn't running yet.
See §12.
The constants in this section come from the official documentation site (meshtastic/meshtastic) and are documented for SDK consumers (e.g., to validate user-supplied configs and to provide ergonomic typed enums).
- 8 slots total, indices
0..7. - Index 0 is the
PRIMARYchannel — required, cannot be disabled. All periodic broadcasts (position, telemetry) default to this channel. - Indices
1..7areSECONDARYorDISABLED. - Active channels MUST be consecutive — you cannot have a
DISABLEDchannel between two active ones. The SDK should validate this onsetChannelcalls. - Channel name max 12 bytes; the literal name
"admin"is reserved for legacy admin-channel mode (pre-firmware-2.5.0).
Each ChannelSettings carries:
psk— see §9 for shorthand encodingname— display nameuplink_enabled/downlink_enabled— whether traffic is bridged to MQTTmodule_settings.position_precision—0..32bits of lat/lon precision (0 = never send position; 32 = full precision; in between truncates lower bits for privacy on shared channels)
DeviceConfig.role (enum Role):
| Role | Behavior |
|---|---|
CLIENT |
(default) Normal node; routes for the mesh, sends own traffic. |
CLIENT_MUTE |
As CLIENT but suppresses broadcast of self-position/telemetry. |
CLIENT_HIDDEN |
Does not announce itself in NodeDB to other nodes. |
CLIENT_BASE |
Stationary client at a known location (e.g., a base station). |
TRACKER |
Mobile asset tracker; optimized for periodic position broadcasts; rebroadcast_mode=NONE permitted. |
LOST_AND_FOUND |
Beacon mode for a node intended to be found. |
SENSOR |
Sensor-only node; rebroadcast_mode=NONE permitted. |
TAK |
ATAK-integrated full client. |
TAK_TRACKER |
ATAK tracker; rebroadcast_mode=NONE permitted. |
ROUTER |
Dedicated relay; prioritizes routing over self-traffic; intended for elevated positions. |
ROUTER_LATE |
Router that intentionally delays retransmissions to break broadcast races on densely-meshed networks. |
REPEATER |
Deprecated as of firmware 2.7.11+. Pure relay with rebroadcast_mode=ALL_SKIP_DECODING. Replaced by ROUTER + appropriate rebroadcast mode. |
DeviceConfig.rebroadcast_mode (enum RebroadcastMode):
| Mode | Behavior |
|---|---|
ALL |
(default) Rebroadcast everything heard, including from foreign meshes with same modem settings but different encryption. |
ALL_SKIP_DECODING |
Rebroadcast raw without attempting to decode. Only valid with deprecated REPEATER role. |
LOCAL_ONLY |
Rebroadcast only on this node's primary/secondary channels; ignore foreign meshes. |
KNOWN_ONLY |
Like LOCAL_ONLY but additionally drop packets from nodes not in the local NodeDB. |
NONE |
No rebroadcast. Permitted only for SENSOR, TRACKER, TAK_TRACKER roles. |
CORE_PORTNUMS_ONLY |
Rebroadcast only standard portnums (Text, NodeInfo, Position, Telemetry, Routing); drop module portnums (TAK, RangeTest, Paxcounter, etc.). |
LoRaConfig.region (enum RegionCode) — selects frequency band, duty-cycle limits, and legal max TX power. Full list (from docs):
| Code | Coverage | Frequency band | Notes |
|---|---|---|---|
US |
North America | 902–928 MHz | |
EU_433 |
Europe | 433–435 MHz | 10% hourly duty-cycle limit |
EU_868 |
Europe | 863–870 MHz | 10% hourly duty-cycle limit |
CN |
China | 470–510 MHz | |
JP |
Japan | 920–923 MHz | |
ANZ |
Australia/NZ | 915–928 MHz | |
ANZ_433 |
Australia/NZ | 433–435 MHz | |
KR |
South Korea | 920–923 MHz | |
TW |
Taiwan | 920–928 MHz | |
RU |
Russia | 866–869 MHz | |
IN |
India | 865–867 MHz | |
NZ_865 |
New Zealand | 865–867 MHz | |
TH |
Thailand | 920–925 MHz | |
LORA_24 |
(worldwide ISM 2.4 GHz) | 2400–2483.5 MHz | Experimental; SX1280 hardware |
UA_433 |
Ukraine | 433–435 MHz | |
UA_868 |
Ukraine | 863–870 MHz | |
MY_433 |
Malaysia | 433–435 MHz | |
MY_919 |
Malaysia | 919–923 MHz | |
SG_923 |
Singapore | 923–925 MHz | |
KZ_433 |
Kazakhstan | 433–435 MHz | |
KZ_863 |
Kazakhstan | 863–865 MHz | |
BR_902 |
Brazil | 902–928 MHz | |
NP_865 |
Nepal | 865–867 MHz | |
PH_868 |
Philippines | 863–870 MHz | |
PH_915 |
Philippines | 902–928 MHz | |
ITU1_2M |
ITU Region 1 | 144–146 MHz | Amateur Radio 2m band |
ITU23_2M |
ITU Region 2/3 | 144–148 MHz | Amateur Radio 2m band |
EU_866 |
Europe | 866 MHz | Band no. 47b of 2006/771/EC (Non-specific SRD) |
EU_874 |
Europe | 874 MHz | Band no. 1 and 4 of 2022/172/EC |
EU_917 |
Europe | 917 MHz | Band no. 1 and 4 of 2022/172/EC |
EU_N_868 |
Europe | 868 MHz | Narrow presets |
UNSET |
— | — | Device refuses TX until set |
LoRaConfig.modem_preset (enum ModemPreset) — combinations of bandwidth / spreading factor / coding rate. Listed fastest → slowest:
| Preset | Range | Notes |
|---|---|---|
SHORT_TURBO |
shortest | Highest BW (500 kHz); not legal in all regions |
SHORT_FAST |
short | |
SHORT_SLOW |
short–medium | |
MEDIUM_FAST |
medium | |
MEDIUM_SLOW |
medium | |
LONG_TURBO |
balanced | Similar to LongFast but with 500kHz BW |
LONG_FAST |
balanced | Default; recommended for most users |
LONG_MODERATE |
longer | |
LITE_FAST |
medium | Optimized for EU 866MHz SRD (125kHz); link budget similar to MEDIUM_FAST |
LITE_SLOW |
medium-long | Optimized for EU 866MHz SRD (125kHz); link budget similar to LONG_FAST |
NARROW_FAST |
medium-long | Optimized for EU 868MHz (62.5kHz); avoids interference |
NARROW_SLOW |
moderate | Optimized for EU 868MHz (62.5kHz); link budget/data rate similar to LONG_FAST |
LONG_SLOW |
very long | Deprecated in 2.7 (unpopular slow preset) |
VERY_LONG_SLOW |
longest | Deprecated in 2.5 (requires TXCO, unusably slow) |
When LoRaConfig.use_preset = false, bandwidth, spread_factor, and coding_rate are user-supplied:
- Bandwidth: special small-int encodings:
31→31.25 kHz,62→62.5 kHz,200→203.125 kHz,400→406.25 kHz,800→812.5 kHz,1600→1625.0 kHz. Values <62.5 kHz typically require a TCXO. - Spread Factor: 5–12 (older SX127x/RF95 chips support only 7–12).
- Coding Rate: denominator of 4/N (e.g.,
5→ 4/5,8→ 4/8).
module_settings.position_precision truncates the low bits of latitude and longitude before transmission, providing per-channel privacy. The docs publish a lookup table mapping bits → approximate ground-truth radius. Typical UI presets:
| Bits | Approx. precision |
|---|---|
| 0 | Never send position (only "I am here" with no coordinates) |
| 11 | ~14 km (city / county) |
| 13 | ~3.5 km (neighborhood) |
| 16 | ~450 m (street) |
| 19 | ~57 m (building) |
| 32 | Full precision (centimeter) |
The SDK should expose a typed PositionPrecision value class with constants for these named presets.
| Constant | Value | Source | Purpose |
|---|---|---|---|
START1 |
0x94 |
firmware:src/mesh/StreamAPI.cpp:7 |
Stream framing |
START2 |
0xC3 |
firmware:src/mesh/StreamAPI.cpp:8 |
Stream framing |
MAX_TO_FROM_RADIO_SIZE |
512 |
firmware:src/mesh/PhoneAPI.h:13 |
Max protobuf payload per frame |
DATA_PAYLOAD_LEN |
233 |
protobufs:mesh.proto Constants |
Max Data.payload bytes (excludes 16-byte LoRa header) |
HOP_LIMIT_MAX |
7 |
protobufs:mesh.proto |
3-bit hop_limit field |
BROADCAST_ADDR |
0xFFFFFFFF |
protobufs:mesh.proto MeshPacket.to |
Broadcast destination |
PUBLIC_KEY_SIZE |
32 |
protobufs:mesh.proto User.public_key |
Curve25519 |
SPECIAL_NONCE_ONLY_CONFIG |
69420 |
firmware:src/mesh/PhoneAPI.h:23 |
want_config_id sentinel: skip NodeDB |
SPECIAL_NONCE_ONLY_NODES |
69421 |
firmware:src/mesh/PhoneAPI.h:24 |
want_config_id sentinel: skip configs |
| Default channel PSK | {0xd4,0xf1,0xbb,0x3a,0x20,0x29,0x07,0x59,0xf0,0xbc,0xff,0xab,0xcf,0x4e,0x69,0x01} |
protobufs:channel.proto |
LongFast/MediumFast default; encoded as psk = [0x01] |
| Session passkey size | 8 |
firmware:src/modules/AdminModule.cpp:1464 |
AdminMessage.session_passkey |
| Session passkey lifetime | ~300s (regen at ~150s) |
firmware:src/modules/AdminModule.cpp:1480 |
Sliding window |
| TCP default port | 4403 |
firmware:src/mesh/api/ServerAPI.h:6 |
PhoneAPI over TCP |
| TCP idle timeout | 15 min (900_000ms) |
firmware:src/mesh/api/ServerAPI.cpp:6 |
Inactivity disconnect |
| Serial baud | 115200 |
firmware:src/DebugConfiguration.h:23 |
USB-CDC speed |
| Serial format | 8N1, no flow control |
firmware:src/SerialConsole.cpp:67 |
— |
| BLE service UUID | 6BA1B218-15A8-461F-9FA8-5DCAE273EAFD |
firmware:src/BluetoothCommon.h:9 |
Mesh service |
BLE fromradio UUID |
2C55E69E-4993-11ED-B878-0242AC120002 |
firmware:src/BluetoothCommon.h:12 |
READ |
BLE toradio UUID |
F75C76D2-129E-4DAD-A1DD-7866124401E7 |
firmware:src/BluetoothCommon.h:11 |
WRITE / WRITE_NO_RESPONSE — clients SHOULD prefer WRITE_NO_RESPONSE for throughput |
BLE fromnum UUID |
ED9DA18C-A800-4F66-A670-AA7547E34453 |
firmware:src/BluetoothCommon.h:13 |
NOTIFY (4-byte LE counter) |
BLE logradio UUID (current) |
5A3D6E49-06E6-4423-9944-E9DE8CDF9547 |
firmware:src/BluetoothCommon.h (LOGRADIO_UUID) |
NOTIFY — primary log characteristic on current firmware |
BLE logradio UUID (legacy) |
6C6FD238-78FA-436B-AACF-15C5BE1EF2E2 |
firmware:src/BluetoothCommon.h (LEGACY_LOGRADIO_UUID) |
NOTIFY — kept for backward compatibility |
| BLE preferred MTU | 517 (ESP32-S3/C6) |
firmware:src/nimble/NimbleBluetooth.cpp:33-35 |
Allows 512-byte payload |
| BLE fallback MTU | 23 |
BLE base spec | Negotiation failure |
| Device tx queue (phone→radio, BLE) | 3 slots (NIMBLE_BLUETOOTH_FROM_PHONE_QUEUE_SIZE) |
firmware:src/nimble/NimbleBluetooth.cpp:44-45 |
Phone-to-radio backpressure |
| Device tx queue (radio→phone, BLE) | 3 slots (NIMBLE_BLUETOOTH_TO_PHONE_QUEUE_SIZE) |
firmware:src/nimble/NimbleBluetooth.cpp:44-45 |
Radio-to-phone backpressure (slow host) |
| Device rx queue | 4 slots (MAX_RX_FROMRADIO) |
firmware:src/mesh/Router.cpp:30 |
— |
| AES-CTR nonce size | 16 bytes |
firmware:src/mesh/CryptoEngine.cpp:287-296 |
packet_id (8B LE) ‖ from_node (4B LE) ‖ zeros (4B) |
| AES-CCM auth tag size | 8 bytes |
firmware:src/mesh/CryptoEngine.cpp:106-140 |
PKI direct messages |
| AES-CCM extra nonce | 4 bytes (random, appended after auth tag) |
firmware:src/mesh/CryptoEngine.cpp:106-140 |
PKI direct messages |
In rough order of "how often we expect implementers to be bitten":
- Broadcast is
0xFFFFFFFF, not0or0xFF. Off-by-one on this value silently broadcasts to nobody. - AES-CTR nonce uses uint64 packet_id, not uint32. uint32 packet IDs are zero-padded into the first 8 bytes. Getting this wrong yields packets that decrypt to garbage and fail protobuf parse.
- PKI uses AES-CCM, NOT AES-GCM. Apple's CryptoKit doesn't expose CCM natively; iOS impl needs a third-party CCM or hand-rolled CCM atop CryptoKit AES.
- Default PSK is the byte
0x01shorthand, NOT no encryption.psk = [](empty) = no encryption;psk = [0x00]= no encryption;psk = [0x01]= AES-128 with the well-known 16-byte default key. - Frame length is BIG-endian. Off-by-byte-order is a classic bug; the network-byte-order convention is consistent here.
- BLE has no stream framing. Each
read(fromradio)returns exactly oneFromRadioprotobuf or empty. Do not look for0x94 0xC3in BLE payloads. fromnumis a wake counter, not a message count. Always drain by repeatedread(fromradio)until empty, regardless offromnumdeltas.- The handshake nonce is per-connection. Reuse across connections will cause confusion; use a fresh non-zero uint32 per fresh transport-up.
- Pre-handshake
FromRadiobytes must be discarded. Even on TCP/Serial — not just BLE. Anything received before the matchingconfig_complete_idis logically garbage. - The SDK MUST NOT retry sends on its own. The device implements retries internally. Host-side retries cause duplicate transmissions.
- Do not assume BLE MTU. Always query the negotiated MTU before each write; on Android Kable use
maximumWriteValueLength(WITHOUT_RESPONSE); on iOS use the per-write length CoreBluetooth provides. - Pkts during config handshake: device queues outbound packets during config but drops incoming
mqtt_client_proxy_messagefrom the phone with a warning. Buffer phone-side untilConnected. - Session passkey is 8 bytes and expires. Re-fetch on
ADMIN_BAD_SESSION_KEY. Do not persist across reconnects. MeshPacket.id == 0is not valid for outbound. Always generate a unique non-zero uint32.- Encoded
MeshPacket.encryptedcarries a serializedDataprotobuf as plaintext. Decryption yields protobuf bytes, not the innerpayload. Don't conflate the two layers. - TCP allows only one concurrent client per device. A second connection will hang or fail. The SDK should surface this as a typed error.
- The device has no real-time clock without GPS.
Position.timeset by the phone is the device's only source of wall-clock time. The SDK should populatePosition.timewith current UTC seconds when uploading positions. - Packet ordering is not guaranteed across the mesh. Apply ordering at the app layer (e.g., display by
rx_time); do not rely on receive order. - Channel hash collisions are possible (8-bit hash, 256 values). The hash is a hint, not a unique identifier; the device disambiguates by trial-decryption.
MeshPacket.via_mqtt = 1indicates the packet arrived via MQTT proxy, not over the air. Useful to suppress duplicate UI notifications when both LoRa and MQTT deliver the same packet.
| Item | Source A | Source B | Resolved (firmware authoritative) |
|---|---|---|---|
| AES-CTR nonce: packet_id width | Android note: uint32 zero-padded | Firmware: uint64 LE (uint32 zero-padded into 8 bytes) | uint64 LE |
| PKI cipher | Apple note: AES-GCM (CryptoKit native) | Firmware: AES-CCM with 8B tag + 4B extra nonce | AES-CCM |
| BLE MTU default | Apple note: "iOS doesn't expose maximumWriteValueLength directly" | Android: queries maximumWriteValueLength(WITHOUT_RESPONSE) |
Both correct for their platform; SDK must abstract |
| TCP framing on BLE | (none) | (none) | BLE has NO 0x94 0xC3 framing; uses GATT message boundaries directly |
This file is a snapshot synthesized from the four upstream repos. Re-run the four research agents (one per upstream repo) when:
- Firmware advances a major version
- A new
FromRadio/ToRadiovariant is added to the protobuf schema - A new transport is supported by firmware
Keep raw research artifacts in references/ and re-derive this canonical document. Do not edit upstream-derived constants here; chase them upstream first.