Version: 1.0 (Draft) Status: Derived from ZephCore firmware implementation, cross-referenced with MeshCore reference firmware and documentation. Scope: This document defines The Protocol — the RF network protocol used by nodes to communicate over a LoRa mesh. It specifies packet format, routing, payload structures, and cryptography.
Companion document: The host-side protocol used by applications to control and query a node over a local transport (serial/USB, Bluetooth Low Energy, or TCP) is defined separately in Core Protocol Specification — Part 2: The Companion Protocol (Host Layer). This document and the Companion Protocol document share the conventions defined in §1.
This document is written to be implementation-agnostic. Field names, constants, and structures are described in neutral terms, not tied to any specific language or firmware. Where pseudo-code is provided, it is illustrative only.
0x prefix denotes hexadecimal. Example: 0x80 = 128 decimal.0b prefix denotes binary. Example: 0b0110 = 6 decimal.0bABCDEFGH has A as bit 7 and H as bit 0.0x00) on the right when shorter than the field size.A compliant implementation consists of three layers:
┌─────────────────────────────────────────────────────┐
│ Application (contacts, channels, messaging, UI) │
├─────────────────────────────────────────────────────┤
│ The Companion Protocol (host ↔ node) │ ← Part 2 (companion document)
├─────────────────────────────────────────────────────┤
│ The Protocol (RF network) │ ← this document (Part 1)
├─────────────────────────────────────────────────────┤
│ Physical Layer (LoRa radio) │
└─────────────────────────────────────────────────────┘
The Protocol and the Companion Protocol are independent. A node that speaks only The Protocol (e.g., a repeater with no host interface) is compliant. A host library that speaks only the Companion Protocol (without touching the radio) is compliant. The two are bridged by the node firmware.
This part specifies the packet format, routing, payload structures, and cryptography used by nodes to communicate over the LoRa RF mesh.
Every packet transmitted over the air has the following layout:
┌──────────┬─────────────────────┬─────────────┬──────┬──────────┐
│ header │ transport_codes │ path_length │ path │ payload │
│ 1 byte │ 4 bytes (optional) │ 1 byte │ V │ V │
└──────────┴─────────────────────┴─────────────┴──────┴──────────┘
| Field | Size | Notes |
|---|---|---|
header |
1 byte | Encodes route type, payload type, and payload version. |
transport_codes |
4 bytes (optional) | Present only when route type is a “transport” variant (§2.4). |
path_length |
1 byte | Encodes hop count and per-hash size (§2.5). |
path |
variable | Zero or more node hashes (§2.5). May be empty. |
payload |
variable | Payload-type-specific data (§2.6). |
| Constant | Value | Description |
|---|---|---|
MAX_PACKET_PAYLOAD |
184 bytes | Maximum payload length. |
MAX_PATH_SIZE |
64 bytes | Maximum total bytes in the path field. |
MAX_TRANS_UNIT |
255 bytes | Maximum total on-wire packet size (physical-layer FIFO size). |
Receivers MUST drop any packet whose payload length exceeds MAX_PACKET_PAYLOAD or whose total length exceeds MAX_TRANS_UNIT. Receivers MUST drop packets whose path byte-length exceeds MAX_PATH_SIZE.
The header byte packs three fields:
bit 7 6 5 4 3 2 1 0
│ │ │ │ │ │
└─┬─┘ └─────┬─────┘ └─┬─┘
│ │ │
Payload Payload Route
Version Type Type
(2 bits) (4 bits) (2 bits)
| Bits | Mask | Field | Description |
|---|---|---|---|
| 0–1 | 0x03 |
Route Type | See §2.3. |
| 2–5 | 0x3C |
Payload Type | See §2.6. |
| 6–7 | 0xC0 |
Payload Version | Protocol versioning of the payload wrapper. |
The current specification defines only version 1. Versions 2–4 are reserved.
| Value | Version | Meaning |
|---|---|---|
0b00 |
1 | 1-byte source/destination node hashes, 2-byte MAC. |
0b01 |
2 | Reserved for future use (e.g., 2-byte hashes, 4-byte MAC). |
0b10 |
3 | Reserved. |
0b11 |
4 | Reserved. |
Nodes implementing this specification MUST emit version 1 only. Nodes MUST ignore packets whose payload version is unknown.
The value 0xFF for the entire header byte is reserved as a local in-memory “do not retransmit” marker. It MUST NOT appear on the wire. Receivers that see 0xFF on the wire MUST drop the packet.
The low 2 bits of header specify how the packet is routed.
| Value | Name | Description |
|---|---|---|
0x00 |
ROUTE_TYPE_TRANSPORT_FLOOD |
Flood routing, filtered by a transport-code scope. |
0x01 |
ROUTE_TYPE_FLOOD |
Flood routing (no scope). |
0x02 |
ROUTE_TYPE_DIRECT |
Direct routing along an explicit path. |
0x03 |
ROUTE_TYPE_TRANSPORT_DIRECT |
Direct routing, filtered by a transport-code scope. |
transport_codes field (§2.4) used to scope forwarding to a region or subnetwork.path_length = 0; it is not forwarded at all.When the route type is ROUTE_TYPE_TRANSPORT_FLOOD or ROUTE_TYPE_TRANSPORT_DIRECT, the header is immediately followed by four bytes:
| Offset | Size | Field | Description |
|---|---|---|---|
| 0 | 2 | transport_code_1 |
Primary scope code (unsigned 16-bit, little-endian). |
| 2 | 2 | transport_code_2 |
Secondary scope code (reserved; unused in v1). |
A transport code is computed from a 16-byte transport key and the packet’s payload:
message = payload_type_byte || payload
hmac = HMAC-SHA256(key = transport_key, data = message)
code = (hmac[0]) | (hmac[1] << 8) # little-endian 16-bit
if code == 0x0000: code = 0x0001 # reserved
if code == 0xFFFF: code = 0xFFFE # reserved
The codes 0x0000 and 0xFFFF are reserved and MUST NOT appear on the wire; implementations MUST coerce them to 0x0001 and 0xFFFE respectively.
A node that does not share a given transport key cannot verify a matching transport code and therefore MUST NOT forward transport-scoped packets whose code does not match any of its loaded transport keys.
transport_code_2 is reserved; senders MUST set it to 0x0000 and receivers MUST accept any value without treating it as a match criterion.
The path_length byte encodes two fields:
bit 7 6 5 4 3 2 1 0
│ │ │ │ │ │ │ │
└─┬─┘ └───────┬───────────┘
│ │
Hash Size Hop Count
Code (2 bits) (6 bits, 0–63)
| Bits | Field | Meaning |
|---|---|---|
| 0–5 | Hop Count | Number of path hashes present (0 to 63). |
| 6–7 | Hash Size Code | Per-hash byte size, minus 1. |
| Hash Size Code | Per-hash Size | Notes |
|---|---|---|
0b00 |
1 byte | Legacy; default. |
0b01 |
2 bytes | Supported. |
0b10 |
3 bytes | Supported. |
0b11 |
(reserved) | Invalid — drop packet. |
The on-wire path field is exactly hop_count × hash_size bytes. Receivers MUST compute the byte length from these two values; the path_length byte is NOT a raw byte count.
path_length |
Hash Size | Hop Count | path byte length |
|---|---|---|---|
0x00 |
1 | 0 | 0 |
0x05 |
1 | 5 | 5 |
0x45 |
2 | 5 | 10 |
0x8A |
3 | 10 | 30 |
A path_length byte is valid if and only if:
0b11, andhop_count × hash_size ≤ MAX_PATH_SIZE (64).Receivers MUST drop packets with invalid path_length.
A node hash of size N is the first N bytes of the node’s Ed25519 public key. All nodes in a given path-hash-mode deployment MUST use the same hash size for a packet’s path field.
Bits 2–5 of header specify the payload type. The payload structure for each type is defined in sections 2.8–2.16.
| Value | Name | Description |
|---|---|---|
0x00 |
PAYLOAD_TYPE_REQ |
Encrypted request to a known peer. |
0x01 |
PAYLOAD_TYPE_RESPONSE |
Encrypted response to a REQ or ANON_REQ. |
0x02 |
PAYLOAD_TYPE_TXT_MSG |
Encrypted text message. |
0x03 |
PAYLOAD_TYPE_ACK |
Acknowledgement. |
0x04 |
PAYLOAD_TYPE_ADVERT |
Signed node advertisement. |
0x05 |
PAYLOAD_TYPE_GRP_TXT |
Group (channel) text message. |
0x06 |
PAYLOAD_TYPE_GRP_DATA |
Group (channel) datagram. |
0x07 |
PAYLOAD_TYPE_ANON_REQ |
Anonymous (unsolicited, signed) request. |
0x08 |
PAYLOAD_TYPE_PATH |
Path return (route discovery response). |
0x09 |
PAYLOAD_TYPE_TRACE |
Trace route with per-hop SNR. |
0x0A |
PAYLOAD_TYPE_MULTIPART |
Multi-packet composite (currently, multi-ack). |
0x0B |
PAYLOAD_TYPE_CONTROL |
Unencrypted control data. |
0x0C |
(reserved) | |
0x0D |
(reserved) | |
0x0E |
(reserved) | |
0x0F |
PAYLOAD_TYPE_RAW_CUSTOM |
Raw bytes with caller-defined encryption. |
The following primitives are used in The Protocol:
| Purpose | Algorithm |
|---|---|
| Node identity, signing | Ed25519 |
| Key agreement | X25519 (performed on the Ed25519 keypair’s Montgomery-form equivalent, as in ed25519_key_exchange) |
| Symmetric encryption | AES-128 in ECB mode with zero padding |
| Message authentication code | HMAC-SHA256, truncated to the first 2 bytes |
| Hashing | SHA-256 |
Each node has an Ed25519 keypair:
The node hash used in paths and routing is a 1, 2, or 3-byte prefix of the public key (§2.5.3).
The shared secret between two nodes is derived by X25519 key exchange on their Ed25519 keypairs:
shared_secret = X25519(my_private_key, their_public_key) # 32 bytes
Both sides produce the same 32-byte secret.
AES-128-ECB with zero padding. The first 16 bytes of the 32-byte shared secret are used as the AES key.
Plaintext is padded with zero bytes up to a multiple of 16. Ciphertext length equals padded plaintext length.
aes_key = shared_secret[0..16] # first 16 bytes
padded = plaintext || zero_pad_to_multiple_of(16)
ciphertext = AES-128-ECB-encrypt(aes_key, padded)
Decryption reverses the process. The receiver cannot distinguish trailing zero bytes added by padding from trailing zero bytes in the original plaintext; application payloads either carry an internal length field or tolerate trailing zeros.
The MAC is the first 2 bytes of HMAC-SHA256 computed over the ciphertext, using the full 32-byte shared secret as the HMAC key.
mac = HMAC-SHA256(key = shared_secret, data = ciphertext)[0..2]
Wire format for an encrypted region is mac || ciphertext (MAC first, then ciphertext). Receivers MUST verify the MAC before decrypting; packets with mismatched MACs MUST be silently dropped.
Note on the HMAC key: The HMAC key uses the full 32-byte shared secret, even though the AES key uses only the first 16 bytes. Implementations that truncate the HMAC key to 16 bytes will not interoperate.
A group channel has a symmetric secret. The secret is either 16 or 32 bytes:
The 1-byte channel hash used on the wire is:
channel_hash = SHA-256(channel_secret)[0] # first byte
Where channel_secret is the raw 16-byte (or 32-byte) secret bytes.
#tag: the first 16 bytes of SHA-256("#tag").Payload type PAYLOAD_TYPE_ADVERT (0x04). Broadcast by every node to announce its presence. The payload is not encrypted but is signed by the advertising node.
┌───────────────┬───────────┬───────────┬────────────┐
│ public_key │ timestamp │ signature │ app_data │
│ 32 bytes │ 4 bytes │ 64 bytes │ 0–32 bytes │
└───────────────┴───────────┴───────────┴────────────┘
| Field | Size | Description |
|---|---|---|
public_key |
32 | The advertising node’s Ed25519 public key. |
timestamp |
4 | Unix timestamp (seconds, unsigned 32-bit, little-endian). |
signature |
64 | Ed25519 signature covering `public_key |
app_data |
0 to 32 bytes | Optional application-level metadata. See below. |
The maximum app_data length is MAX_ADVERT_DATA_SIZE = 32 bytes. Receivers MUST clip app_data to 32 bytes if the payload length suggests otherwise.
A receiver verifies the signature by computing:
message = public_key || timestamp || app_data
valid = Ed25519-verify(signature, message, public_key)
Receivers MUST drop adverts with invalid signatures.
app_data Structure
¶
When present, app_data begins with a flags byte that indicates which optional fields follow:
┌───────┬───────────┬───────────┬──────────┬──────────┬─────────────────┐
│ flags │ latitude │ longitude │ feature1 │ feature2 │ name │
│ 1 byte│ 4 (opt) │ 4 (opt) │ 2 (opt) │ 2 (opt) │ 0–N bytes (opt) │
└───────┴───────────┴───────────┴──────────┴──────────┴─────────────────┘
Fields appear in the listed order; each is present if and only if the corresponding flag bit is set. The name field, if present, fills the remainder of app_data.
flags Byte
¶
The flags byte is split into a 4-bit node type (low nibble) and 4 presence flag bits (high nibble).
| Bits | Mask | Field | Description |
|---|---|---|---|
| 0–3 | 0x0F |
Node Type | See table below. |
| 4 | 0x10 |
Has Location | latitude and longitude are present. |
| 5 | 0x20 |
Has Feature 1 | feature1 is present. Reserved; currently unused. |
| 6 | 0x40 |
Has Feature 2 | feature2 is present. Reserved; currently unused. |
| 7 | 0x80 |
Has Name | name is present. |
Node Type values (low 4 bits):
| Value | Name | Description |
|---|---|---|
0x00 |
ADV_TYPE_NONE |
Unspecified / generic node. |
0x01 |
ADV_TYPE_CHAT |
Interactive chat node (phone companion). |
0x02 |
ADV_TYPE_REPEATER |
Store-and-forward repeater. |
0x03 |
ADV_TYPE_ROOM |
Room server (multi-user chat host). |
0x04 |
ADV_TYPE_SENSOR |
Sensor node (telemetry publisher). |
0x05–0x0F |
(reserved) |
Clarification: Earlier documentation listed the node-type values among the presence flag bits, which is incorrect. They occupy the low 4 bits of the flags byte. The high 4 bits are the presence flags.
If the Has Location flag is set:
| Offset | Size | Field | Description |
|---|---|---|---|
| 0 | 4 | latitude |
Signed 32-bit integer, little-endian. degrees × 1,000,000. |
| 4 | 4 | longitude |
Signed 32-bit integer, little-endian. degrees × 1,000,000. |
Example: latitude = 49123456 encodes 49.123456°.
If the Has Name flag is set, all remaining bytes of app_data are the UTF-8 node name. There is no null terminator on the wire.
Payload types PAYLOAD_TYPE_REQ (0x00), PAYLOAD_TYPE_RESPONSE (0x01), PAYLOAD_TYPE_TXT_MSG (0x02), and PAYLOAD_TYPE_PATH (0x08) share a common wrapper:
┌──────────────────┬─────────────┬──────┬────────────┐
│ destination_hash │ source_hash │ mac │ ciphertext │
│ 1 byte │ 1 byte │ 2 │ variable │
└──────────────────┴─────────────┴──────┴────────────┘
| Field | Size | Description |
|---|---|---|
destination_hash |
1 | First byte of the destination node’s public key. |
source_hash |
1 | First byte of the source node’s public key. |
mac |
2 | MAC of ciphertext (§2.7.4). |
ciphertext |
V | AES-encrypted plaintext (§2.7.3). The plaintext structure is payload-type-specific. |
The shared secret used for encryption and MAC is derived between the source and destination via X25519 (§2.7.2).
When a node receives a packet with one of these payload types:
destination_hash does not match any of the node’s own hashes, treat as not-for-me (but may still forward per routing rules).source_hash matches, derive the shared secret, verify the MAC, decrypt. If MAC verification succeeds, process the plaintext.REQ Plaintext (Request)
¶
┌───────────┬───────────────────────┐
│ timestamp │ request_data │
│ 4 bytes │ rest of plaintext │
└───────────┴───────────────────────┘
timestamp — sender’s Unix time in seconds (little-endian).request_data — application-defined request body. The first byte typically encodes a request sub-type.Common request sub-types (first byte of request_data):
| Value | Name | Description |
|---|---|---|
0x01 |
REQ_TYPE_GET_STATUS |
Query node status (battery, counters). |
0x02 |
REQ_TYPE_KEEP_ALIVE |
Maintain a session. |
0x03 |
REQ_TYPE_GET_TELEMETRY |
Query telemetry (CayenneLPP). |
Other sub-types are application-defined and out of scope for this specification.
RESPONSE Plaintext
¶
┌───────────────────────────────────┐
│ response_data │
│ rest of plaintext │
└───────────────────────────────────┘
Responses are opaque to this layer. The format is determined by the application and the corresponding request.
TXT_MSG Plaintext
¶
┌───────────┬─────────────────┬─────────────────┐
│ timestamp │ txt_type_attempt│ message │
│ 4 bytes │ 1 byte │ rest of plaintxt│
└───────────┴─────────────────┴─────────────────┘
The txt_type_attempt byte packs two fields:
| Bits | Mask | Field | Description |
|---|---|---|---|
| 0–1 | 0x03 |
Attempt | Retry counter (0–3). |
| 2–7 | 0xFC |
Text Type | Shifted right by 2 to produce a 6-bit value. |
Defined text types (after right-shift):
| Value | Name | Message Content |
|---|---|---|
0x00 |
TXT_TYPE_PLAIN |
UTF-8 plain text message. |
0x01 |
TXT_TYPE_CLI_DATA |
CLI command text (sent to a node’s command processor). |
0x02 |
TXT_TYPE_SIGNED_PLAIN |
First 4 bytes are the sender’s public-key prefix, remainder is UTF-8 text. |
PATH Plaintext (Returned Path)
¶
A path-return packet communicates a discovered route back to an originator.
┌─────────────┬──────────┬────────────┬────────────────┐
│ path_length │ path │ extra_type │ extra_payload │
│ 1 byte │ variable │ 1 byte │ variable │
└─────────────┴──────────┴────────────┴────────────────┘
| Field | Size | Description |
|---|---|---|
path_length |
1 | Same encoding as in the packet header (§2.5). |
path |
variable | hop_count × hash_size bytes. |
extra_type |
1 | Low 4 bits: another payload type (typically ACK or RESPONSE). 0xFF means “no extra”. |
extra_payload |
variable | Content of the bundled extra payload, or 4 random bytes when extra_type = 0xFF. |
When extra_type is 0xFF (no extra), senders MUST follow it with 4 random bytes. This prevents the encrypted block from being a known-plaintext target.
Payload type PAYLOAD_TYPE_ANON_REQ (0x07). Used when the sender is not yet a known contact of the destination; the sender includes their full public key inline so the destination can derive a shared secret.
┌──────────────────┬────────────────┬──────┬────────────┐
│ destination_hash │ sender_pubkey │ mac │ ciphertext │
│ 1 byte │ 32 bytes │ 2 │ variable │
└──────────────────┴────────────────┴──────┴────────────┘
| Field | Size | Description |
|---|---|---|
destination_hash |
1 | First byte of the destination’s public key. |
sender_pubkey |
32 | The sender’s Ed25519 public key. |
mac |
2 | MAC of ciphertext. |
ciphertext |
V | AES-encrypted plaintext. |
The destination derives the shared secret via X25519 between its own private key and the inline sender_pubkey, then MAC-verifies and decrypts.
The plaintext format depends on the use case. Common examples:
Room-server login:
┌───────────┬────────────────┬──────────┐
│ timestamp │ sync_timestamp │ password │
│ 4 bytes │ 4 bytes │ variable │
└───────────┴────────────────┴──────────┘
timestamp — sender time (Unix seconds, little-endian).sync_timestamp — “sync messages since” epoch (Unix seconds, little-endian).password — UTF-8 password for the room.Repeater / sensor login:
┌───────────┬──────────┐
│ timestamp │ password │
│ 4 bytes │ variable │
└───────────┴──────────┘
Repeater sub-request (regions / owner info / clock-and-status):
┌───────────┬──────────┬────────────────┬────────────┐
│ timestamp │ req_type │ reply_path_len │ reply_path │
│ 4 bytes │ 1 byte │ 1 byte │ variable │
└───────────┴──────────┴────────────────┴────────────┘
Where req_type is 0x01 (regions), 0x02 (owner info), or 0x03 (clock and status). The reply_path uses the same encoding as §2.5.
Other plaintext formats are application-defined.
Payload types PAYLOAD_TYPE_GRP_TXT (0x05) and PAYLOAD_TYPE_GRP_DATA (0x06). Used for channel (group) messaging with a pre-shared channel secret.
┌──────────────┬─────┬────────────┐
│ channel_hash │ mac │ ciphertext │
│ 1 byte │ 2 │ variable │
└──────────────┴─────┴────────────┘
| Field | Size | Description |
|---|---|---|
channel_hash |
1 | First byte of SHA-256(channel_secret) (§2.7.5). |
mac |
2 | MAC of ciphertext, keyed by the channel secret. |
ciphertext |
V | AES-encrypted plaintext, keyed by the channel secret. |
The maximum plaintext length for group payloads is MAX_PACKET_PAYLOAD − 16 − 3 = 165 bytes (accounting for cipher block rounding, channel_hash, and mac).
GRP_TXT Plaintext
¶
Same format as TXT_MSG plaintext (§2.9.4). The convention is that the message body is of the form sender_name: message_body so receivers can display the sender.
GRP_DATA Plaintext
¶
Application-defined. The first byte typically identifies a data sub-type; the remainder is sub-type-specific data.
Payload type PAYLOAD_TYPE_ACK (0x03).
┌──────────┐
│ ack_hash │
│ 4 bytes │
└──────────┘
| Field | Size | Description |
|---|---|---|
ack_hash |
4 | First 4 bytes of a SHA-256 hash computed over a message-type-dependent buffer (§2.12.1). |
The field is conventionally called a “checksum” in parts of the reference firmware, but it is NOT a CRC — it is the first 4 bytes (not a truncated CRC-32) of a SHA-256 digest, used as a short collision-resistant identifier of the specific message being acknowledged.
The hash is computed over a buffer whose contents depend on the message type being acknowledged. The recipient or sender’s public key is appended to the hashed data to “salt” the hash — ensuring that different recipients of the same broadcast text produce different ACK hashes, so that senders can tell which peer acknowledged.
For TXT_MSG with txt_type == TXT_TYPE_PLAIN (0x00) or TXT_TYPE_CLI_DATA (0x01):
buffer = timestamp (4 bytes LE)
∥ txt_type_attempt (1 byte)
∥ message_text (UTF-8, no terminator)
∥ sender_public_key (32 bytes)
ack_hash = SHA-256(buffer)[0..4] ; first 4 bytes
CLI-type messages (TXT_TYPE_CLI_DATA) MUST NOT produce an ACK on the wire despite being hashable. The sender does not track an expected hash for CLI messages; only TXT_TYPE_PLAIN triggers the full send/ack lifecycle.
For TXT_MSG with txt_type == TXT_TYPE_SIGNED_PLAIN (0x02):
buffer = timestamp (4 bytes LE)
∥ txt_type_attempt (1 byte)
∥ sender_pubkey_prefix (4 bytes)
∥ message_text (UTF-8, no terminator)
∥ recipient_public_key (32 bytes)
ack_hash = SHA-256(buffer)[0..4] ; first 4 bytes
The sender’s 4-byte public-key prefix that appears at the start of a signed-plain plaintext (§2.9.4) participates in the hash; otherwise the hash input is identical in structure to the plain form.
Salting note. The public key is appended to the message bytes but is NOT transmitted as part of the hash input over the air — it is implicitly known to both sides (sender knows it from its contact record; recipient knows its own key). This turns the 4-byte ACK hash into a message-AND-recipient identifier, enabling the sender to match incoming ACKs even when the same message has been sent to multiple contacts.
A sender that emits a TXT_MSG requesting acknowledgement SHOULD:
ack_hash using the public key at send time. NOTE
The public key differs between TXT_TYPE_PLAIN and TXT_TYPE_SIGNED_PLAIN.PAYLOAD_TYPE_ACK whose 4-byte ack_hash matches a pending entry, mark the message as delivered and discard the entry.The same ACK MAY be received multiple times if the acknowledging peer retransmits (e.g., via multi-ACK, §2.14); senders SHOULD tolerate this and treat only the first arrival as delivery confirmation.
Earlier drafts of this specification described the ack_hash field as a little-endian CRC-32. That was incorrect. The reference firmware (BaseChatMesh.cpp:221-222, 248-249) uses SHA-256(...)[0..4] exclusively. An implementation that computes CRC-32 over the same input buffer will not match any valid ACK and will fail all deduplication checks.
Payload type PAYLOAD_TYPE_TRACE (0x09). Used to discover and measure a path through the mesh, collecting a per-hop SNR sample at each forwarding node.
TRACE packets use an unusual wire layout that repurposes fields from the normal packet structure. Three specific oddities distinguish TRACE from every other payload type:
path_length byte is a hop-consumption counter, not an encoded path-length (§2.5). The low 6 bits start at zero and increment by one at each forwarding hop as the packet travels. The top 2 bits of the byte remain zero; the actual per-hash byte size used for the hop sequence is carried in the flags byte of the payload instead.path field as with other direct-routed payloads. Forwarders never modify these hashes; they remain in the payload for the life of the packet.path field accumulates SNR measurements, one signed byte per consumed hop, written at position path_length immediately before path_length is incremented. By the time the packet reaches its final destination, the header’s path buffer holds the sequence of measured link qualities in traversal order.┌─────┬───────────┬───────┬────────────────────┐
│ tag │ auth_code │ flags │ path_hashes │
│ 4 │ 4 │ 1 │ variable (per hop) │
└─────┴───────────┴───────┴────────────────────┘
| Field | Size | Description |
|---|---|---|
tag |
4 | Request identifier chosen by the originator (little-endian). Echoed in the trace result reported to the originator. |
auth_code |
4 | Application-defined authentication code (little-endian). Opaque to this layer. |
flags |
1 | Low 2 bits: path hash size code. Upper 6 bits: reserved, MUST be zero. |
path_hashes |
V | The original ordered sequence of hop hashes the originator intends the packet to traverse. hop_count × hash_size bytes. Never modified during forwarding. |
Flags byte:
| Bits | Mask | Field | Description |
|---|---|---|---|
| 0–1 | 0x03 | Path Hash Size | path_hash_size = 1 << bits (yielding 1, 2, or 4 bytes per hash). |
| 2–7 | 0xFC | (reserved) | Set to 0. |
Note that the TRACE flags’ path-hash-size encoding (1 << bits, values 1/2/4) differs from the encoding used in the normal packet header’s path_length byte (§2.5), which encodes hash sizes 1/2/3 as code + 1. TRACE’s encoding permits a 4-byte hash size that the normal header cannot represent.
The packet header fields adjacent to the payload carry protocol-repurposed meaning:
| Header field | Normal meaning | TRACE meaning |
|---|---|---|
path_length (low 6 bits) |
Encoded hop count | Number of hops consumed so far (starts at 0; incremented each forwarding hop). |
path_length (top 2 bits) |
Hash size code | MUST be zero for TRACE. The true hash size lives in the payload’s flags byte. |
path field |
Ordered hop hashes | Accumulated SNR measurements: one int8 (SNR_dB × 4) per consumed hop, in order. |
The header’s path field is thus written at byte positions (one per consumed hop), not at hash positions, even when path_hash_size > 1. This is valid because each SNR sample is always exactly one byte.
Each accumulated SNR sample is a signed 8-bit integer representing measured_SNR_dB × 4 — i.e., 0.25 dB resolution, range approximately −32 to +31.75 dB. The same convention is used by PACKET_STATS and every place else SNR is carried in this protocol.
When a node receives a TRACE packet with route_type == ROUTE_TYPE_DIRECT:
tag, auth_code, flags. Extract path_sz = flags & 0x03.consumed_bytes = header.path_length × (1 << path_sz). This is the byte offset within the payload’s path_hashes trailer of the next hop hash to be checked.consumed_bytes ≥ path_hashes_length, the TRACE has consumed every hop: this node is the final destination. Deliver the trace (hashes + SNRs) to the application layer and do not forward.(1 << path_sz) bytes at path_hashes[consumed_bytes] against this node’s own public-key prefix. If they do not match, drop the packet (this node is not the expected next hop).(int8)(measured_snr_dB × 4)) to the header’s path buffer at offset path_length.
b. Increment the header’s path_length by 1.
c. Retransmit.Because only the header fields change during forwarding, the payload (including the original path_hashes) remains byte-stable across hops. This is what makes a TRACE packet useful: at the final destination, the receiver can hand both the original intended path and the measured per-hop SNRs to the application for analysis.
Because TRACE packets mutate their header fields as they traverse the mesh, a naive packet-signature scheme that ignores the header (§2.17.5) would correctly deduplicate each hop. However, the specification in §2.17.5 explicitly includes path_length in the TRACE signature precisely to prevent the same trace from being forwarded twice along overlapping sub-paths (which could occur if the return path revisits an intermediate node). Implementations MUST follow §2.17.5.
Earlier drafts of this specification stated that “SNR samples are in the payload” and described only a flat payload of tag + auth + flags + snr_samples. That description is inconsistent with the reference firmware, which:
path_hashes in the payload (never modified by forwarders).path[] buffer, not the payload.path_length field as a hop-consumption counter, not as its §2.5 encoded form.The layout documented above reflects the actual wire behaviour of the reference firmware (see Mesh.cpp:41-65, Mesh.cpp:684-698).
Payload type PAYLOAD_TYPE_MULTIPART (0x0A). A composite payload that encapsulates another payload plus a “remaining” counter. Currently used only for multi-ACK bursts.
┌─────────────────────────┬──────────────────────┐
│ remaining_and_subtype │ sub_payload │
│ 1 byte │ variable │
└─────────────────────────┴──────────────────────┘
The leading byte packs:
| Bits | Mask | Field | Description |
|---|---|---|---|
| 0–3 | 0x0F |
Sub-type | A payload type value (e.g., ACK). |
| 4–7 | 0xF0 |
Remaining | Number of additional bursts expected. |
For a multi-ACK:
PAYLOAD_TYPE_ACK (0x03)Payload type PAYLOAD_TYPE_CONTROL (0x0B). Carries unencrypted control data, typically for discovery.
┌───────┬─────────────────┐
│ flags │ data │
│ 1 B │ variable │
└───────┴─────────────────┘
The flags byte has two sub-fields:
| Bits | Mask | Field | Description |
|---|---|---|---|
| 0–3 | 0x0F |
Sub-data | Sub-type-specific flag bits. |
| 4–7 | 0xF0 |
Sub-type | Identifies the control message sub-type. |
Bit 7 of the flags byte (0x80) additionally signals that the control packet is valid only as a zero-hop direct packet; receivers MUST drop it if the packet’s hop count is not zero.
DISCOVER_REQ (sub-type 0x8)
¶
┌───────┬─────────────┬──────┬──────────────┐
│ flags │ type_filter │ tag │ since │
│ 1 B │ 1 B │ 4 B │ 4 B (opt) │
└───────┴─────────────┴──────┴──────────────┘
flags = 0x80 | 0x01 if prefix-only reply is requested.type_filter — bitfield. Bit n set means the sender wants responses from ADV_TYPE_n nodes.tag — random identifier, reflected in responses.since — optional 4-byte Unix timestamp; responders with a last-advert-time earlier than this SHOULD NOT respond. Omitted means 0.DISCOVER_RESP (sub-type 0x9)
¶
┌───────┬──────┬──────┬──────────────────┐
│ flags │ snr │ tag │ pubkey │
│ 1 B │ 1 B │ 4 B │ 8 or 32 B │
└───────┴──────┴──────┴──────────────────┘
flags — high nibble = 0x9, low nibble = responder’s ADV_TYPE_*.snr — signed 8-bit SNR of the request packet (SNR × 4, 0.25 dB units).tag — echoed from the request.pubkey — 32-byte full public key, or 8-byte prefix if the request’s prefix-only bit was set.Payload type PAYLOAD_TYPE_RAW_CUSTOM (0x0F). The payload is opaque to the network layer; the application is responsible for any framing, encryption, and authentication. Raw packets are handled only in direct routing mode.
When a node sends a flood packet:
ROUTE_TYPE_FLOOD (or ROUTE_TYPE_TRANSPORT_FLOOD with appropriate codes).path; the hop_count is 0 when transmitted.When a node receives a flood packet:
path_length. Drop if invalid.path, incrementing hop_count.
b. If appending would cause hop_count × hash_size > MAX_PATH_SIZE, do not forward.
c. Schedule a retransmission after a randomized delay based on estimated airtime.Direct-routed packets carry an explicit ordered path of hashes. Each forwarder consumes one entry.
When a node sends a direct packet with a known path of length N:
ROUTE_TYPE_DIRECT (or ROUTE_TYPE_TRANSPORT_DIRECT).path = the sequence of hashes from the next hop to the final destination, in order.hop_count = N.When a node receives a direct packet:
path_length.hop_count == 0, this is a zero-hop packet — process only if this node is the intended destination.path matches this node’s hash:
a. Process the payload if addressed to us.
b. If forwarding is permitted, remove the first entry from path (shifting subsequent entries left) and decrement hop_count.
c. Retransmit.A zero-hop packet is a direct-routed packet with hop_count = 0. It is intended only for neighbours in direct radio range and is never forwarded.
Each node decides locally whether to forward a given packet. Typical policies:
A forwarder MUST respect these hard rules:
path_length is invalid.PAYLOAD_TYPE_TRACE unless the next-hop entry matches this node.ROUTE_TYPE_TRANSPORT_*) unless the node has a transport key whose derived code matches the packet’s transport_code_1.Every packet has a deduplication signature computed from the payload type and payload content. For most payload types:
signature_input = payload_type_byte || payload
signature = SHA-256(signature_input)[0..8] # first 8 bytes, used as 64-bit hash
For PAYLOAD_TYPE_TRACE, the path length is also included:
signature_input = payload_type_byte || path_length_byte || payload
This ensures TRACE packets with different accumulated SNR paths are not falsely deduplicated.
A compliant node MUST maintain a “seen packets” table that retains recent packet signatures (§2.17.5) for long enough to suppress duplicate retransmissions across the mesh.
Recommended policy:
Duplicate suppression is REQUIRED to prevent broadcast storms during flood routing.
A minimally compliant implementation of The Protocol MUST:
path_length per §2.5.2 and drop invalid packets.MAX_PACKET_PAYLOAD and MAX_TRANS_UNIT.PUB_KEY_SIZE = 32 bytes for the HMAC key and the first 16 bytes for the AES key (§2.7.4).0xFF as an on-wire invalid header and drop such packets (§2.2.2).Compliance requirements for the Companion Protocol are defined in the companion document (Part 2: The Companion Protocol, Appendix A).
| Constant | Value | Units |
|---|---|---|
PUB_KEY_SIZE |
32 | bytes |
PRV_KEY_SIZE |
64 | bytes |
SEED_SIZE |
32 | bytes |
SIGNATURE_SIZE |
64 | bytes |
CIPHER_KEY_SIZE |
16 | bytes |
CIPHER_BLOCK_SIZE |
16 | bytes |
CIPHER_MAC_SIZE |
2 | bytes |
MAX_ADVERT_DATA_SIZE |
32 | bytes |
MAX_PACKET_PAYLOAD |
184 | bytes |
MAX_PATH_SIZE |
64 | bytes |
MAX_TRANS_UNIT |
255 | bytes |
MAX_GROUP_DATA_LENGTH |
165 | bytes |
MAX_TEXT_LEN |
160 | bytes |
The Companion Protocol’s default MAX_FRAME_SIZE (172 bytes) is defined in the companion document (Part 2, Appendix B).
| Value | Name |
|---|---|
0x00 |
ROUTE_TYPE_TRANSPORT_FLOOD |
0x01 |
ROUTE_TYPE_FLOOD |
0x02 |
ROUTE_TYPE_DIRECT |
0x03 |
ROUTE_TYPE_TRANSPORT_DIRECT |
| Value | Name |
|---|---|
0x00 |
PAYLOAD_TYPE_REQ |
0x01 |
PAYLOAD_TYPE_RESPONSE |
0x02 |
PAYLOAD_TYPE_TXT_MSG |
0x03 |
PAYLOAD_TYPE_ACK |
0x04 |
PAYLOAD_TYPE_ADVERT |
0x05 |
PAYLOAD_TYPE_GRP_TXT |
0x06 |
PAYLOAD_TYPE_GRP_DATA |
0x07 |
PAYLOAD_TYPE_ANON_REQ |
0x08 |
PAYLOAD_TYPE_PATH |
0x09 |
PAYLOAD_TYPE_TRACE |
0x0A |
PAYLOAD_TYPE_MULTIPART |
0x0B |
PAYLOAD_TYPE_CONTROL |
0x0F |
PAYLOAD_TYPE_RAW_CUSTOM |
| Value | Name |
|---|---|
0x00 |
ADV_TYPE_NONE |
0x01 |
ADV_TYPE_CHAT |
0x02 |
ADV_TYPE_REPEATER |
0x03 |
ADV_TYPE_ROOM |
0x04 |
ADV_TYPE_SENSOR |
| Value | Name |
|---|---|
0x00 |
TXT_TYPE_PLAIN |
0x01 |
TXT_TYPE_CLI_DATA |
0x02 |
TXT_TYPE_SIGNED_PLAIN |
Companion Protocol command and response codes are tabulated in the companion document (Part 2, Appendix B).
This specification reflects the actual on-wire behaviour of a compliant implementation, not prior Core documentation drafts. The following discrepancies were identified and resolved in favour of the implementation:
Earlier documentation listed the values 0x01 (chat), 0x02 (repeater), 0x03 (room), 0x04 (sensor) alongside the bit-flag masks 0x10, 0x20, 0x40, 0x80 in a single “flags” table, implying they occupied the same field. In reality, the node type occupies the low 4 bits of the flags byte, and the presence flags occupy the high 4 bits. The type and presence flags are decoded independently. This specification clarifies the split.
The HMAC key uses the full 32-byte shared secret, even though the AES key uses only the first 16 bytes of that same secret. A reader of the existing MeshCore docs might reasonably assume a 16-byte HMAC key by symmetry; the implementation does not. Incorrectly truncating the HMAC key to 16 bytes produces MAC verification failures that are hard to diagnose.
Most payload types carry their routing path in the packet header’s path field. TRACE packets do not: the header’s path field is repurposed as an accumulator of measured per-hop SNR bytes, and the original path-hash sequence lives in the payload trailer (after the 9-byte tag/auth/flags preamble). Additionally, the header’s path_length byte is reinterpreted as a hop-consumption counter (its low 6 bits) and the top 2 bits (normally the hash-size code of §2.5) are locked at zero — the TRACE hash size is carried separately in the payload’s flags byte using a distinct encoding (1 << bits → 1/2/4 bytes per hash).
This is substantially different from every other payload type and requires special-case handling by forwarders. See §2.13 for the full specification.
Earlier drafts of this specification described TRACE packets as carrying their SNR samples in the payload. That description does not match the reference firmware; it has been replaced.
Payload versions 2–4 (header bits 6–7) are reserved but not in use. All current implementations emit and expect version 1.
Earlier drafts described PAYLOAD_TYPE_ACK as carrying a 4-byte little-endian CRC-32 of the acknowledged message. This was incorrect. The reference firmware (BaseChatMesh.cpp) computes the field as the first 4 bytes of SHA-256 over a message-type-dependent buffer that includes the recipient’s public key as a salt, and transmits those 4 bytes directly (no additional byte-order transformation).
The field name checksum in some firmware comments is a legacy term; it is not a CRC in any standard sense. Implementations of the original CRC-32 description will never produce a matching ACK.
Companion Protocol discrepancies (error codes, stats frames) are documented in the companion document (Part 2, Appendix C).