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 | timestamp | app_data`. | ||
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.
Responses to the repeater sub-requests defined in §2.10.1 are delivered as a
PAYLOAD_TYPE_RESPONSE payload (§2.9.3), encrypted with the same shared secret
used for the request (§2.7.2). A receiver without the relevant shared secret
cannot recover any of the fields described below — only the destination/source
hashes and ciphertext length are visible on the wire.
If the original request arrived as ROUTE_TYPE_FLOOD, the response is wrapped
in a PATH payload (§2.9.5) addressed back along the path the request
travelled, so the requester also learns a route to the responder. If the request
arrived as ROUTE_TYPE_DIRECT with an explicit reply path already supplied in
the request body (see §2.10.1), the response is sent directly along that path
instead.
All three sub-request responses share an 8-byte preamble:
| Field | Size | Description |
|---|---|---|
reflected_tag | 4 | Echoes the request’s timestamp (§2.10.1) back verbatim. |
responder_time | 4 | Responder’s own clock at reply time. |
body | variable | Sub-request-specific; see §2.10.2.1–§2.10.2.3. |
Both timestamp fields are little-endian uint32, consistent with §1.2.
Despite its name in the reference firmware (“prefix with sender_timestamp, like
a tag”), reflected_tag is not a freshly generated tag — it is the requester’s
own timestamp, reflected back, serving the same correlation role as tag does
in DISCOVER_RESP (§2.15.2). responder_time is the responder’s clock in Unix
seconds, included both for opportunistic clock sync and to vary the packet hash
across replies to structurally identical requests.
req_type = 0x01)
¶The body is a UTF-8 string, not null-terminated on the wire, listing the
responder’s configured transport scopes (“regions”) as comma-separated names —
for example: NORTHEAST,SOUTHWEST. The string length is implied by the
remaining payload length, per the general convention in §1.3.
A bare * entry represents the responder’s wildcard/default scope (the scope
applied to packets that don’t match any more specific transport key). It is
included under the same inclusion rule as every other region — see below — not
unconditionally.
Inclusion rule. A region (including the wildcard) is included in the
response if and only if it does not have its flood-deny flag set (reference
firmware: REGION_DENY_FLOOD, value 0x01, checked against each region’s
flags byte). This means a responder configured to deny flood-forwarding for a
particular scope omits that scope’s name from the regions response entirely —
even if the scope is otherwise configured and actively used for direct-routed
traffic. Implementations querying this response to enumerate all configured
scopes (not just flood-eligible ones) should be aware this response
under-reports relative to the responder’s full configuration.
Name normalization. Region names that begin with # in the responder’s
local configuration have that prefix stripped before being included in the
response; the # is a local naming convention (its own meaning is out of scope
for this section) and is never transmitted.
Name length and truncation. Each region name is at most 30 characters in the
reference implementation (RegionEntry.name is a 31-byte buffer, one byte
reserved for a null terminator that is never itself transmitted). The overall
response is bounded by MAX_PACKET_PAYLOAD (184 bytes, §2.1.1) minus the 8-byte
preamble and a 4-byte safety margin reserved by the reference implementation (12
bytes total). A responder with enough configured regions that the full
comma-separated list would not fit truncates silently: names are appended one at
a time only while they still fit, and the response is returned with however many
fit, dropping the rest without any truncation indicator. Implementations should
not assume a regions response that fills (or nearly fills) the available space
represents the complete list.
Rate limiting. The reference implementation rate-limits responses to this sub-request (shared with owner-info and clock-and-status — see §2.10.2.4) to at most 4 responses per any 180-second rolling window, on top of any per-anon-request channel limits already implied by §2.10. A request arriving while the limiter is exhausted receives no response at all (not an error response); requesters should be prepared to retry after a delay rather than treating silence as “no regions configured.”
req_type = 0x02)
¶The body is two UTF-8 strings joined by a single \n (0x0A) byte: the
responder’s configured node name, followed by its configured owner-info text.
Neither component is independently length-prefixed; a consuming implementation
splits on the first \n byte. The combined string is not null-terminated on the
wire; its length is implied by the remaining payload length.
In the reference implementation, the node name component is sourced from a
32-byte (including null terminator) configuration field and the owner-info
component from a 120-byte (including null terminator) field, so the practical
maximum combined length is approximately 150 characters — comfortably within
MAX_PACKET_PAYLOAD once the 8-byte preamble is accounted for, and unlike the
regions response, this response has no documented truncation behavior in the
reference implementation (the two source fields are themselves bounded at
configuration time, not at response-construction time).
req_type = 0x03)
¶The body is exactly one byte: a features/status flags byte.
| Bit(s) | Mask | Field | Description |
|---|---|---|---|
| 0 | 0x01 | Bridge: UART | UART bridge support enabled. |
| 0–1 | 0x03 | Bridge: ESP-NOW | ESP-NOW bridge support enabled. |
| 7 | 0x80 | Forwarding disabled | Forwarding administratively disabled. |
| 1–6 | 0x7E | (reserved) | Unset in the current reference implementation. |
Note on bit overlap: This flags byte does not follow the clean “each bit is independent” convention used elsewhere in this specification (e.g. the advert presence flags in §2.8.3). The UART and ESP-NOW bridge indicators overlap at bit 0 and are mutually exclusive in the reference firmware (selected at compile time): a value of
0x03means ESP-NOW, not “both bridges” with bit 1 set independently. Implementations should treat0x00(no bridge),0x01(UART bridge), and0x03(ESP-NOW bridge) as the three valid low-bit states rather than decoding bit 0 and bit 1 independently.
A comment in the reference implementation notes a planned but unimplemented “moving-window utilization metric” field; no such field exists in the current wire format, and implementations should not expect one until a future protocol revision documents it.
All three sub-request types (§2.10.2.1–§2.10.2.3) draw from the same rate limiter in the reference implementation: at most 4 accepted requests per any rolling 180-second window, shared across regions, owner-info, and clock-and-status requests combined (not 4 of each). A responder that has exhausted this budget silently drops the request and sends no response, indistinguishable on the wire from a request that was lost in transit. Implementations should apply normal request timeout/retry handling rather than treating one missing response as conclusive evidence the responder doesn’t support the sub-request type.
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).