Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

MeshCore Protocol Specification

Section 0: Overview

Spec version: v0.1.0 · see Spec Versions for the full list of frozen releases.

Status and Provenance

This is an unofficial, independent specification. It is not part of the upstream MeshCore project, not affiliated with or endorsed by its maintainers, and not authored by them. Read this section before relying on the spec for anything load-bearing.

This document was primarily written by an AI (Claude, by Anthropic — mostly the Opus 4.x family) under the direction of SWaits, with human proofreading and editing on top. Substantive edits — additions, audit passes, errata fixes — are produced through model-driven analysis of the upstream MeshCore C++ source and reviewed before commit.

The upstream MeshCore C++ firmware is the source of truth for all protocol behavior. This spec is downstream and derivative. Whenever the spec and the upstream C++ disagree, upstream wins — and a fix to this spec is filed against this repo, not against MeshCore. Each substantive change in CHANGELOG.md records the upstream commit hash it was validated against.

Refinements have also been driven in part by observations from building DongLoRa firmware and apps against live MeshCore deployments. Where DongLoRa behavior diverges from upstream, this spec follows upstream and treats the DongLoRa side as a follow-up bug, not a spec change.

Conformance against the test corpus is a useful signal but not a substitute for cross-checking against upstream firmware. If you find a divergence from upstream, please file an issue — that is exactly the kind of report this project exists to catch.

Spec Versions

The published site preserves prior spec versions alongside the rolling latest build. The URL layout is:

  • https://swaits.github.io/meshcore-spec/latest/ — built from the tip of main. May change between any two visits.
  • https://swaits.github.io/meshcore-spec/v0.1.0/ (and likewise for any future vX.Y.Z) — a frozen content snapshot. Suitable for citing or for certifying an implementation against.

Frozen versions live as their own directory in the repo (versions/vX.Y.Z/) — a literal copy of src/ taken at release time. This deliberately separates content (versioned, frozen) from site infrastructure (theme, picker, build pipeline; lives at the repo root and is unversioned). Every deploy rebuilds every published version using today’s infrastructure, so site improvements show up everywhere without backporting.

The version-picker in the top-right of every page lists every published version; switching keeps you on the same chapter where it exists in the target version. Spec versions use semver and are independent of the upstream MeshCore firmware’s own versioning — each release records the upstream commit hash it was validated against in CHANGELOG.md.

Scope

This specification covers:

  • The packet wire format (framing, header, path, payload)
  • All payload types and their binary encodings
  • Cryptographic operations (encryption, signing, key exchange, hashing)
  • The BLE/Serial companion protocol
  • The KISS modem protocol
  • The bridge protocol (RS232/ESP-NOW)

It does NOT cover:

  • Application-level behavior (e.g., room server logic, repeater policies)
  • Radio-layer parameters (LoRa spreading factor, bandwidth, frequency)
  • Duty cycle management or transmission scheduling
  • User interface or companion app behavior

Terminology

The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119.

Additional terms used throughout:

TermDefinition
PacketThe fundamental transmission unit in MeshCore.
HeaderThe first byte of a packet, encoding route type, payload type, and protocol version.
PathA sequence of node hashes representing the route a packet has taken or should follow.
HashA truncated prefix of a node’s Ed25519 public key, used for routing.
PayloadThe data portion of a packet, whose structure depends on the payload type.
Transport CodesOptional 4-byte field present in transport-mode packets, used for regional scoping.
MACMessage Authentication Code — a 2-byte truncated HMAC-SHA256 used for integrity verification.
MTUMaximum Transmission Unit — 255 bytes for MeshCore.
Flood RoutingRouting mode where packets are broadcast and repeated by intermediate nodes, building up the path as they travel.
Direct RoutingRouting mode where the path is supplied by the sender and the packet follows a specific route.

Byte Order

All multi-byte integer fields in MeshCore are encoded in little-endian byte order unless explicitly stated otherwise. The one exception is CayenneLPP sensor data, which uses big-endian encoding.

Notation

  • Binary data is shown as hex bytes separated by spaces: 0D 00 EF BE AD DE
  • Bit fields are shown in binary with MSB first: 0bVVPPPPRR
  • Bit numbering is LSB = bit 0, MSB = bit 7
  • Field sizes are in bytes unless noted as bits
  • [field(N)] denotes a field of N bytes
  • [field(N)?] denotes an optional field of N bytes

Protocol Constants

These constants define the fundamental limits of the protocol:

ConstantValueDescription
MAX_TRANS_UNIT255Maximum packet size on the wire (bytes)
MAX_PACKET_PAYLOAD184Maximum payload size (bytes)
MAX_PATH_SIZE64Maximum path size (bytes)
PUB_KEY_SIZE32Ed25519 public key size (bytes)
PRV_KEY_SIZE64Ed25519 private key size (bytes)
SIGNATURE_SIZE64Ed25519 signature size (bytes)
CIPHER_KEY_SIZE16AES-128 key size (bytes)
CIPHER_BLOCK_SIZE16AES-128 block size (bytes)
CIPHER_MAC_SIZE2Truncated HMAC-SHA256 MAC size (bytes)
PATH_HASH_SIZE1Default path hash size for v1 (bytes)
MAX_HASH_SIZE8Maximum hash size for deduplication (bytes)
MAX_ADVERT_DATA_SIZE32Maximum advertisement app data size (bytes)

Protocol Versions

The protocol version is encoded in bits 6-7 of the header byte:

VersionValueStatusDescription
V10x00Active1-byte src/dest hashes, 2-byte MAC
V20x01ReservedFuture (e.g., 2-byte hashes, 4-byte MAC)
V30x02ReservedFuture
V40x03ReservedFuture

Currently only V1 is defined. Implementations MUST support V1. Implementations SHOULD reject packets with unrecognized version values unless they have explicit support for that version.

Document Organization

SectionTitleDescription
00OverviewThis document
01Wire FormatPacket framing and field layout
02HeaderHeader byte encoding
03PathPath length encoding and path field
04Payload: ACKAcknowledgment payload
05Payload: AdvertisementNode advertisement and app data
06Payload: EncryptedREQ/RESPONSE/TXT_MSG payloads
07Payload: Anonymous RequestAnonymous request payload
08Payload: GroupGroup text and data payloads
09Payload: Path ReturnEncrypted path return payload
10Payload: TracePath trace payload
11Payload: MultipartMulti-packet payload
12Payload: ControlControl and discovery payloads
13Payload: Raw CustomCustom raw payload
14CryptographyAES-128, HMAC-SHA256, encrypt-then-MAC
15IdentityEd25519 keys, ECDH, key hashing
16Packet HashSHA-256 deduplication hashing
17RoutingFlood and direct routing behavior
18Companion ProtocolBLE/Serial companion communication
19KISS ProtocolKISS modem framing and extensions
20Bridge ProtocolRS232/ESP-NOW bridge framing

References

MeshCore Protocol Specification

Section 1: Wire Format

Overview

A MeshCore packet is the fundamental transmission unit. It consists of a 1-byte header, an optional 4-byte transport codes field, a 1-byte path length, a variable-length path, and a variable-length payload. The maximum packet size on the wire is 255 bytes (MAX_TRANS_UNIT).

Packet Layout

 0                   1                   2
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 ...
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|    Header     | Transport Codes (opt, 4B) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|  Path Length   |     Path (variable)      |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|            Payload (variable)             |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

The fields appear in this order on the wire:

[header(1)][transport_codes(4)?][path_len(1)][path(0-64)][payload(1-184)]

Field Summary

FieldSizeRequiredDescription
Header1 byteYesRoute type, payload type, protocol version (see Section 2)
Transport Codes4 bytesConditionalTwo uint16_le values; present only when route type is TRANSPORT_FLOOD (0x00) or TRANSPORT_DIRECT (0x03)
Path Length1 byteYesEncoded hash count and hash size (see Section 3)
Path0-64 bytesYesSequence of node hashes; length = hash_count × hash_size
Payload1-184 bytesYesPacket data; structure depends on payload type

Transport Codes

Transport codes are present if and only if the route type (header bits 0-1) is TRANSPORT_FLOOD (0x00) or TRANSPORT_DIRECT (0x03).

When present, transport codes consist of two little-endian 16-bit unsigned integers, for a total of 4 bytes:

 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|        Code 1 (uint16_le)     |        Code 2 (uint16_le)     |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
  • Code 1: Calculated from regional scope parameters
  • Code 2: Reserved (typically 0)

When transport codes are not present (route types FLOOD and DIRECT), the implementation MUST set both transport code values to zero internally.

Encoding Algorithm

To encode a packet to wire format:

  1. Write the header byte.
  2. If the route type is TRANSPORT_FLOOD or TRANSPORT_DIRECT: a. Write transport_codes[0] as uint16_le (2 bytes). b. Write transport_codes[1] as uint16_le (2 bytes).
  3. Write the path_len byte.
  4. Calculate path byte length as hash_count × hash_size (see Section 3).
  5. Write path_byte_length bytes from the path array.
  6. Write payload_len bytes from the payload array.

The total wire length is:

1 + (hasTransportCodes ? 4 : 0) + 1 + path_byte_length + payload_len

This MUST NOT exceed MAX_TRANS_UNIT (255) bytes.

Decoding Algorithm

To decode a packet from wire format:

  1. Read 1 byte as the header. Extract route type from bits 0-1.
  2. If route type is TRANSPORT_FLOOD (0x00) or TRANSPORT_DIRECT (0x03): a. Read 2 bytes as transport_codes[0] (uint16_le). b. Read 2 bytes as transport_codes[1] (uint16_le). Otherwise, set both transport codes to zero.
  3. Read 1 byte as path_len.
  4. Validate path_len (see Section 3): a. Extract hash_size = (path_len >> 6) + 1. If hash_size is 4, the packet is INVALID. b. Extract hash_count = path_len & 63. c. If hash_count × hash_size > MAX_PATH_SIZE (64), the packet is INVALID.
  5. Calculate path_byte_length = hash_count × hash_size.
  6. Read path_byte_length bytes as the path.
  7. Let i be the current read position. If i >= total_length, the packet is INVALID (payload MUST contain at least 1 byte).
  8. Calculate payload_len = total_length - i.
  9. If payload_len > MAX_PACKET_PAYLOAD (184), the packet is INVALID.
  10. Read payload_len bytes as the payload.

Size Constraints

ConstraintValueEnforcement
Maximum wire length255 bytesEncoder MUST NOT produce packets exceeding this
Maximum payload184 bytesDecoder MUST reject payloads exceeding this
Maximum path64 bytesDecoder MUST reject paths exceeding this
Minimum payload1 byteDecoder MUST reject packets with zero-length payload
Minimum packet3 bytesHeader (1) + path_len (1) + payload (1 minimum)

Error Conditions

A conforming decoder MUST reject packets with any of the following:

  1. Total wire length less than 3 bytes (no room for header + path_len + payload)
  2. Total wire length less than minimum required for the route type:
    • 3 bytes for FLOOD or DIRECT
    • 7 bytes for TRANSPORT_FLOOD or TRANSPORT_DIRECT (adds 4 transport code bytes)
  3. Invalid path_len encoding (see Section 3)
  4. Path byte length exceeds remaining bytes
  5. Zero-length payload after header, transport codes, path_len, and path
  6. Payload length exceeding MAX_PACKET_PAYLOAD (184)

Cross-References

Reference Implementation

  • Packet::writeTo() in src/Packet.cpp — Encoding
  • Packet::readFrom() in src/Packet.cpp — Decoding
  • Packet::getRawLength() in src/Packet.cpp — Wire length calculation

MeshCore Protocol Specification

Section 2: Header

Overview

The header is the first byte of every MeshCore packet. It encodes three fields using bit packing: the route type (2 bits), the payload type (4 bits), and the protocol version (2 bits).

Bit Layout

  Bit:  7   6   5   4   3   2   1   0
      +---+---+---+---+---+---+---+---+
      | V1  V0| P3  P2  P1  P0| R1  R0|
      +---+---+---+---+---+---+---+---+
             |         |           |
             |         |           +-- Route Type (bits 0-1)
             |         +-------------- Payload Type (bits 2-5)
             +------------------------ Protocol Version (bits 6-7)

The header byte value is computed as:

header = (version << 6) | (payload_type << 2) | route_type

And fields are extracted as:

route_type   = header & 0x03
payload_type = (header >> 2) & 0x0F
version      = (header >> 6) & 0x03

Route Type (bits 0-1)

The route type determines how the packet is routed through the mesh and whether transport codes are present.

ValueNameTransport CodesDescription
0x00TRANSPORT_FLOODYes (4 bytes)Flood routing with transport codes for regional scoping
0x01FLOODNoStandard flood routing; path is built up by intermediate nodes
0x02DIRECTNoDirect routing; path is supplied by the sender
0x03TRANSPORT_DIRECTYes (4 bytes)Direct routing with transport codes

Transport codes MUST be present in the wire format when route type is 0x00 or 0x03, and MUST NOT be present when route type is 0x01 or 0x02.

Payload Type (bits 2-5)

The payload type identifies the structure of the payload data.

ValueNameDescription
0x00REQUESTEncrypted request (dest_hash + src_hash + MAC + ciphertext)
0x01RESPONSEEncrypted response to REQ or ANON_REQ
0x02TXT_MSGEncrypted text message
0x03ACKSimple acknowledgment (4-byte CRC)
0x04ADVERTNode identity advertisement
0x05GRP_TXTEncrypted group text message
0x06GRP_DATAEncrypted group datagram
0x07ANON_REQAnonymous request (full public key instead of hash)
0x08PATHEncrypted returned path information
0x09TRACEPath trace with per-hop SNR collection
0x0AMULTIPARTOne part of a multi-packet sequence
0x0BCONTROLControl/discovery packet
0x0C(reserved)Reserved for future use
0x0D(reserved)Reserved for future use
0x0E(reserved)Reserved for future use
0x0FRAW_CUSTOMCustom raw bytes for application-defined payloads

Implementations SHOULD silently discard packets with reserved payload type values (0x0C-0x0E) unless they have explicit support for extended types.

Protocol Version (bits 6-7)

ValueNameDescription
0x00V1Current version: 1-byte src/dest hashes, 2-byte MAC
0x01V2Reserved for future use
0x02V3Reserved for future use
0x03V4Reserved for future use

Implementations MUST support version 0x00 (V1). Implementations SHOULD reject packets with unrecognized version values.

Special Header Values

The header value 0xFF is used internally as a “do not retransmit” marker. This is an in-memory sentinel only and MUST NOT appear on the wire. A header of 0xFF would decode as version=3, payload_type=0x0F (RAW_CUSTOM), route_type=3 (TRANSPORT_DIRECT).

Encoding Examples

Header ByteBinaryVersionPayload TypeRoute Type
0x0100 000000 01V1 (0)REQUEST (0)FLOOD (1)
0x0500 000001 01V1 (0)RESPONSE (1)FLOOD (1)
0x0900 000010 01V1 (0)TXT_MSG (2)FLOOD (1)
0x0D00 000011 01V1 (0)ACK (3)FLOOD (1)
0x1100 000100 01V1 (0)ADVERT (4)FLOOD (1)
0x0C00 000011 00V1 (0)ACK (3)TRANSPORT_FLOOD (0)
0x0E00 000011 10V1 (0)ACK (3)DIRECT (2)
0x0F00 000011 11V1 (0)ACK (3)TRANSPORT_DIRECT (3)
0x4D01 000011 01V2 (1, reserved)ACK (3)FLOOD (1)

Cross-References

Reference Implementation

  • Packet::getRouteType() in src/Packet.hheader & 0x03
  • Packet::getPayloadType() in src/Packet.h(header >> 2) & 0x0F
  • Packet::getPayloadVer() in src/Packet.h(header >> 6) & 0x03
  • Packet::hasTransportCodes() in src/Packet.h — route type 0x00 or 0x03
  • Route type constants in src/Packet.h
  • Payload type constants in src/Packet.h

MeshCore Protocol Specification

Section 3: Path

Overview

The path field carries routing information as a sequence of node hashes. In flood routing, intermediate nodes append their hash to the path as they forward the packet. In direct routing, the sender supplies the full path. The path length byte uses bit packing to encode both the number of hashes and the size of each hash.

Path Length Byte

The path length byte immediately follows the header (and transport codes, if present). It encodes two values:

  Bit:  7   6   5   4   3   2   1   0
      +---+---+---+---+---+---+---+---+
      | S1  S0| C5  C4  C3  C2  C1  C0|
      +---+---+---+---+---+---+---+---+
             |              |
             |              +-- Hash Count (bits 0-5): 0-63
             +----------------- Hash Size Code (bits 6-7): 0-2 (3 is reserved)
FieldBitsExtractionRangeDescription
Hash Count0-5path_len & 0x3F0-63Number of hashes in the path
Hash Size Code6-7path_len >> 60-2Encoded hash size: actual size = code + 1

The actual hash size in bytes is:

hash_size = (path_len >> 6) + 1
Hash Size CodeActual Hash SizeDescription
0 (0b00)1 byteV1 default (PATH_HASH_SIZE)
1 (0b01)2 bytesExtended precision
2 (0b10)3 bytesExtended precision
3 (0b11)RESERVEDMUST be rejected as invalid

The path length byte is constructed as:

path_len = ((hash_size - 1) << 6) | (hash_count & 0x3F)

Path Field

The path field immediately follows the path length byte. Its size in bytes is:

path_byte_length = hash_count × hash_size

The path contains hash_count consecutive hashes, each hash_size bytes long. Each hash is a prefix of a node’s Ed25519 public key (see Section 15).

+----------+----------+-----+----------+
| Hash 0   | Hash 1   | ... | Hash N-1 |
| (H bytes)| (H bytes)| ... | (H bytes)|
+----------+----------+-----+----------+

where H = hash_size, N = hash_count

When hash_count is 0, the path field is empty (zero bytes on the wire).

Validation Rules

A conforming implementation MUST reject a packet if any of the following are true:

  1. Reserved hash size: Hash size code is 3 (bits 6-7 = 0b11), meaning hash_size would be 4. This value is reserved for future use.

  2. Path overflow: hash_count × hash_size > MAX_PATH_SIZE (64 bytes).

The maximum number of hashes depends on hash size:

Hash SizeMax Hash CountMax Path Bytes
1 byte6363
2 bytes3264
3 bytes2163

Note: With 1-byte hashes the maximum hash count is 63 (not 64), because the count field is only 6 bits wide. With 2-byte hashes, 32 × 2 = 64 bytes exactly fills MAX_PATH_SIZE. With 3-byte hashes, 21 × 3 = 63 bytes is the maximum before 22 × 3 = 66 would overflow.

Path Manipulation

Appending a hash (flood routing): When a node forwards a flood-routed packet, it appends its own hash to the path:

  1. Let n = current hash_count.
  2. Verify (n + 1) × hash_size <= MAX_PATH_SIZE. If not, the node MUST NOT forward the packet.
  3. Copy the node’s hash (first hash_size bytes of its public key) to path[n × hash_size].
  4. Increment hash_count: setPathHashCount(n + 1).

Setting hash size and count:

path_len = ((hash_size - 1) << 6) | (hash_count & 0x3F)

Encoding Examples

path_len byteBinaryHash Size CodeHash CountHash SizePath Bytes
0x0000 0000000010
0x0100 0000010111
0x0300 0000110313
0x3F00 111111063163
0x4101 0000011122
0x4201 0000101224
0x6001 100000132264
0x8110 0000012133
0x9510 010101221363
0xC011 00000030INVALID
0xFF11 111111363INVALID

Known Implementation Discrepancy

The Rust implementation meshcore-rs treats the path_len byte as a raw byte count rather than decoding hash_count and hash_size from the bitfields. For example, a path_len value of 0x42 (binary 01 000010) should decode as hash_size=2, hash_count=2, path_bytes=4. The incorrect interpretation reads it as a raw count of 66 bytes.

Test vectors in this corpus specifically exercise multi-byte hash sizes to catch this class of bug.

Cross-References

Reference Implementation

  • Packet::getPathHashSize() in src/Packet.h(path_len >> 6) + 1
  • Packet::getPathHashCount() in src/Packet.hpath_len & 63
  • Packet::getPathByteLen() in src/Packet.hgetPathHashCount() * getPathHashSize()
  • Packet::setPathHashSizeAndCount() in src/Packet.h((sz - 1) << 6) | (n & 63)
  • Packet::isValidPathLen() in src/Packet.cpp — Validation logic
  • Packet::writePath() in src/Packet.cpp — Path serialization

MeshCore Protocol Specification

Section 4: Payload — ACK

Overview

The ACK (acknowledgment) payload is the simplest payload type. It consists of a single 4-byte CRC value used to confirm receipt of a previously sent message. The CRC is a truncated SHA-256 over the original TXT_MSG plaintext prefix concatenated with the sender’s public key.

Payload Type

Header payload type field: 0x03 (ACK)

Wire Format

 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                      ACK CRC (uint32_le)                      |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

Fields

FieldOffsetSizeTypeDescription
ack_crc04 bytesuint32_leCRC identifying the acknowledged message

ACK CRC Computation

The 4-byte ACK CRC is computed as the first 4 bytes of a SHA-256 digest over the acknowledged TXT_MSG’s plaintext prefix followed by the sender’s Ed25519 public key:

ack_crc = SHA-256( plaintext_prefix || sender_pub_key )[0..3]

Where plaintext_prefix is the TXT_MSG plaintext up to and including the message text, excluding any trailing NUL terminator:

plaintext_prefix = timestamp_le(4) || txt_type_attempt(1) || text
  • timestamp_le and txt_type_attempt are defined in Section 6.
  • text is the raw UTF-8 message bytes with no NUL terminator.
  • sender_pub_key is the full 32-byte Ed25519 public key of the node that originated the TXT_MSG (i.e., the node expecting this ACK back).

For TXT_TYPE_SIGNED_PLAIN messages, the 4-byte signature prefix is included:

plaintext_prefix = timestamp_le(4) || txt_type_attempt(1) || signature(4) || text
ack_crc = SHA-256( plaintext_prefix || receiver_pub_key )[0..3]

Note the SIGNED_PLAIN variant hashes against the receiver’s public key; see BaseChatMesh::onPeerDataRecv().

Because txt_type_attempt includes the 2-bit attempt counter, each retry with an incremented attempt produces a different ACK CRC. This lets the sender attribute each received ACK to a specific transmission attempt. See Section 6: Reliable DM Delivery.

Encoding

  1. Compute the 4-byte CRC value for the message being acknowledged (above).
  2. Write the CRC as a little-endian 32-bit unsigned integer.
  3. Set payload_len to 4.

Decoding

  1. Read 4 bytes from the payload as a little-endian uint32.
  2. If fewer than 4 bytes are available, the packet is INVALID.

Constraints

  • The payload MUST be exactly 4 bytes.
  • Implementations SHOULD log or discard ACK packets with payload_len < 4.

Cross-References

Reference Implementation

  • Mesh::createAck() in src/Mesh.cpp — Packet construction
  • BaseChatMesh::composeMsgPacket() in src/helpers/BaseChatMesh.cpp — Sender-side expected-ACK computation (expected_ack)
  • BaseChatMesh::onPeerDataRecv() in src/helpers/BaseChatMesh.cpp — Receiver-side ACK CRC computation (ack_hash)
  • ACK reception in Mesh::onRecvPacket(), case PAYLOAD_TYPE_ACK

MeshCore Protocol Specification

Section 5: Payload — Advertisement

Overview

The advertisement payload allows a node to announce its identity to the mesh. It contains the node’s Ed25519 public key, a timestamp, a signature proving ownership of the key, and optional application data describing the node’s type, location, features, and name.

Payload Type

Header payload type field: 0x04 (ADVERT)

Wire Format

 0                                                              31
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                                                               |
|                   Ed25519 Public Key (32 bytes)               |
|                                                               |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                    Timestamp (uint32_le)                       |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                                                               |
|                   Ed25519 Signature (64 bytes)                |
|                                                               |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                   App Data (0-32 bytes)                       |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

Fields

FieldOffsetSizeTypeDescription
pub_key032 bytesrawEd25519 public key of the advertising node
timestamp324 bytesuint32_leUnix epoch timestamp of advertisement creation
signature3664 bytesrawEd25519 signature (see Signature section below)
app_data1000-32 bytesstructuredOptional application data (see App Data section below)

Minimum and Maximum Sizes

  • Minimum payload: 100 bytes (pub_key + timestamp + signature, no app_data)
  • Maximum payload: 132 bytes (100 + MAX_ADVERT_DATA_SIZE of 32)

Signature

The signature is computed over the concatenation of:

message = pub_key(32) || timestamp_le(4) || app_data(0-32)

The node signs this message using its Ed25519 private key. Receivers MUST verify the signature using the public key from the payload. Packets with invalid signatures MUST be discarded.

App Data Format

When present (payload_len > 100), app_data is a structured field beginning with a flags byte:

 Bit:  7       6       5       4       3   2   1   0
     +-------+-------+-------+-------+---+---+---+---+
     |has_   |has_   |has_   |has_   |    Node Type   |
     |name   |feat2  |feat1  |loc    |   (4 bits)     |
     +-------+-------+-------+-------+---+---+---+---+
FieldOffsetSizeConditionTypeDescription
flags01 byteAlwaysuint8See bit layout above
latitude14 bytesflags bit 4 setint32_leLatitude × 1,000,000
longitude54 bytesflags bit 4 setint32_leLongitude × 1,000,000
feat1varies2 bytesflags bit 5 setuint16_leFeature field 1
feat2varies2 bytesflags bit 6 setuint16_leFeature field 2
namevariesremainingflags bit 7 setUTF-8Node name (no null terminator)

Fields appear in the order listed. The offset of each field depends on which preceding optional fields are present.

Node Types (flags bits 0-3)

ValueNameDescription
0NoneUnspecified
1ChatChat client node
2RepeaterMesh repeater node
3RoomRoom server node
4SensorSensor node

Decoding Algorithm

  1. Read 32 bytes as pub_key.
  2. Read 4 bytes as timestamp (uint32_le).
  3. Read 64 bytes as signature.
  4. If offset (100) > payload_len, the packet is INVALID (incomplete).
  5. If offset (100) == payload_len, there is no app_data. Done.
  6. Read remaining bytes as app_data (up to MAX_ADVERT_DATA_SIZE = 32 bytes). If more than 32 bytes remain, implementations MUST truncate to 32.
  7. Construct the signature verification message: pub_key || timestamp_le || app_data.
  8. Verify the Ed25519 signature. If invalid, the receiver MUST discard the packet.
  9. Parse app_data: a. Read 1 byte as flags. b. If flags bit 4 set: read 4 bytes latitude (int32_le), 4 bytes longitude (int32_le). c. If flags bit 5 set: read 2 bytes feat1 (uint16_le). d. If flags bit 6 set: read 2 bytes feat2 (uint16_le). e. If flags bit 7 set: read remaining bytes as UTF-8 name.

Cross-References

Reference Implementation

  • Mesh::createAdvert() in src/Mesh.cpp — Encoding
  • Mesh::onRecvPacket(), case PAYLOAD_TYPE_ADVERT in src/Mesh.cpp — Decoding and verification

MeshCore Protocol Specification

Section 6: Payload — Encrypted (REQ / RESPONSE / TXT_MSG)

Overview

The REQUEST, RESPONSE, and TXT_MSG payload types share an identical wire format. They consist of a destination hash, source hash, and an encrypt-then-MAC ciphertext blob. The three types differ only in their semantic meaning at the application layer.

Payload Types

ValueNameDescription
0x00REQUESTEncrypted request (e.g., login, data query)
0x01RESPONSEEncrypted response to a REQUEST or ANON_REQ
0x02TXT_MSGEncrypted text message

Wire Format

 0
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| dest_hash(1)  |  src_hash(1)  |    Cipher MAC (2 bytes)       |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                                                               |
|              Ciphertext (N × 16 bytes, N ≥ 1)                |
|                                                               |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

Fields

FieldOffsetSizeTypeDescription
dest_hash0PATH_HASH_SIZE (1)rawFirst byte of recipient’s Ed25519 public key
src_hash1PATH_HASH_SIZE (1)rawFirst byte of sender’s Ed25519 public key
cipher_mac2CIPHER_MAC_SIZE (2)rawTruncated HMAC-SHA256 over ciphertext
ciphertext4variable (multiple of 16)rawAES-128-ECB encrypted data

Minimum Size

  • Minimum payload: 4 + 16 = 20 bytes (dest_hash + src_hash + MAC + 1 AES block)

Encryption

The ciphertext is produced using the encrypt-then-MAC scheme described in Section 14:

  1. Compute the ECDH shared secret between sender and recipient.
  2. Zero-pad the plaintext on the right with 0x00 bytes until its length is a multiple of 16 (AES block size). If the plaintext is already a multiple of 16, no padding is added (the scheme is not unambiguous PKCS#7-style padding — the receiver MUST disambiguate trailing zeros using the inner plaintext format; see “Plaintext Format” below).
  3. Encrypt the padded plaintext using AES-128-ECB with the first 16 bytes of the shared secret as the key.
  4. Compute HMAC-SHA256 over the ciphertext using the full 32-byte shared secret as the HMAC key. Truncate to 2 bytes.
  5. Prepend the 2-byte MAC to the ciphertext.

Decryption

  1. Extract dest_hash and src_hash.
  2. If dest_hash matches this node, search for peers matching src_hash.
  3. For each matching peer, compute or retrieve the shared secret.
  4. Verify the MAC: compute HMAC-SHA256 over the ciphertext portion using the full 32-byte shared secret. Compare the first 2 bytes with cipher_mac.
  5. If the MAC is valid, decrypt the ciphertext using AES-128-ECB with the first 16 bytes of the shared secret. The decrypted data may contain trailing zero bytes from padding.
  6. If no peer’s MAC matches, the packet is not for this node (or the peer is unknown). The packet MAY still be forwarded.

Plaintext Format (TXT_MSG)

For TXT_MSG payloads, the decrypted plaintext has this structure:

FieldOffsetSizeTypeDescription
timestamp04 bytesuint32_leMessage timestamp
txt_type_attempt41 byteuint8Bits 2-7: message type (see Section 4); bits 0-1: attempt counter (0-3). See “Attempt Counter Semantics” below.
text5remainingUTF-8Message text

Attempt Counter Semantics

The attempt sub-field (bits 0-1 of txt_type_attempt) carries the low two bits of a transmission attempt counter used by the sender’s retry logic. For a given logical TXT_MSG:

  • The timestamp field MUST remain stable across all retries of the same message.
  • The attempt counter MUST be 0 on the first transmission and MUST be incremented by 1 on each retry.
  • Bits 0–1 of txt_type_attempt MUST always carry attempt & 3. These bits are part of the ACK CRC input (see Section 4), so each retry of a message produces a distinct expected ACK CRC.
  • For attempts in 0..3, no further encoding is needed; the plaintext ends with the text.
  • For attempts > 3, the sender MUST append a 2-byte tail [NUL_terminator(1)][attempt_full(1)] to the plaintext after the text, where attempt_full is the full 1-byte counter value (not just the low 2 bits). The receiver locates the text by scanning for the NUL terminator and recovers the full attempt value from the trailing byte. Because this tail costs 2 bytes of plaintext capacity, the maximum text length is reduced by 2 bytes when attempt > 3.
  • The ACK CRC is computed over timestamp(4) || txt_type_attempt(1) || text only. The [NUL][attempt_full] tail (when present) and the implicit C string NUL terminator (when not) are excluded from the hash input. This means a retry with attempt 4 and a retry with attempt 0 produce ACK CRCs that match (since 4 & 3 == 0); senders relying on attempts beyond 3 SHOULD also verify the trailing attempt byte if they need to distinguish rolled-over attempts.

See BaseChatMesh::composeMsgPacket() in src/helpers/BaseChatMesh.cpp for the reference encoding.

Reliable DM Delivery

The following sender-side behavior is informational and documents how interoperating implementations deliver TXT_MSG reliably. Exact timeouts, backoff schedules, and queue depths are implementation-defined; see Section 17 — Sender Behavior for routing-layer details.

  • Expected-ACK tracking. On transmit, the sender SHOULD compute the expected ack_crc (see Section 4) and record it in a pending-ACK table keyed by CRC. The table SHOULD bound entries by capacity and age out entries after the retry schedule has completed (plus a margin for in-flight ACKs).
  • Retry. If no matching ACK is received within an implementation-defined timeout, the sender SHOULD retransmit with attempt incremented (per “Attempt Counter Semantics” above) and re-register the new expected ack_crc. Up to 4 attempts are representable in the 2-bit attempt field.
  • ACK consumption. On receipt of a PAYLOAD_TYPE_ACK or a MULTIPART-wrapped ACK whose ack_crc matches a pending entry, the sender MUST treat the message as delivered, remove the pending-ACK entry, and cancel any queued retries for that CRC.
  • Duplicate DM handling at the receiver. A receiver MAY cache its most recent reply per peer keyed by the inbound DM’s plaintext and, on a duplicate inbound DM within a short window, re-emit the cached reply as a loss-recovery optimization. This is not normative; the normative behavior is “process once and rely on Section 16 dedup.” Implementations that re-emit cached replies SHOULD suppress re-emission while a retry for that reply is still pending, to avoid piling on.

Plaintext Format (REQUEST / RESPONSE)

The plaintext format for REQUEST and RESPONSE is application-defined. Common patterns include:

  • REQUEST: [timestamp(4)][request_data...]
  • RESPONSE: [timestamp(4)][response_data...]

Cross-References

Reference Implementation

  • Mesh::createDatagram() in src/Mesh.cpp — Encoding
  • Mesh::onRecvPacket(), cases PAYLOAD_TYPE_REQ/RESPONSE/TXT_MSG — Decoding
  • Utils::encryptThenMAC() in src/Utils.cpp — Encryption
  • Utils::MACThenDecrypt() in src/Utils.cpp — Decryption and verification

MeshCore Protocol Specification

Section 7: Payload — Anonymous Request

Overview

The anonymous request payload allows a node to send an encrypted message to a recipient without the recipient needing to know the sender in advance. Instead of a 1-byte source hash, the full 32-byte Ed25519 public key of the sender is included, enabling the recipient to compute the shared secret for decryption.

Payload Type

Header payload type field: 0x07 (ANON_REQ)

Wire Format

 0
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| dest_hash(1)  |                                               |
+-+-+-+-+-+-+-+-+                                               |
|                   Sender Public Key (32 bytes)                |
|                                                               |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|    Cipher MAC (2 bytes)       |                               |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+                               |
|              Ciphertext (N × 16 bytes, N ≥ 1)                |
|                                                               |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

Fields

FieldOffsetSizeTypeDescription
dest_hash01 byterawFirst byte of recipient’s Ed25519 public key
sender_pub_key132 bytesrawFull Ed25519 public key of sender
cipher_mac332 bytesrawTruncated HMAC-SHA256 over ciphertext
ciphertext35variable (multiple of 16)rawAES-128-ECB encrypted data

Minimum Size

  • Minimum payload: 1 + 32 + 2 + 16 = 51 bytes

Encryption

  1. Sender computes ECDH shared secret using its private key and the recipient’s public key.
  2. Encrypt plaintext using AES-128-ECB with first 16 bytes of shared secret.
  3. Compute HMAC-SHA256 over ciphertext with full 32-byte shared secret. Truncate to 2 bytes.
  4. Assemble: dest_hash || sender_pub_key || MAC || ciphertext.

Decryption

  1. Read dest_hash. If it does not match this node, the packet is not for us (but MAY be forwarded).
  2. Read 32-byte sender_pub_key. Construct an Identity from it.
  3. Compute ECDH shared secret between this node’s private key and sender’s public key.
  4. Verify the MAC and decrypt as in Section 14.

Cross-References

Reference Implementation

  • Mesh::createAnonDatagram() in src/Mesh.cpp — Encoding
  • Mesh::onRecvPacket(), case PAYLOAD_TYPE_ANON_REQ — Decoding

MeshCore Protocol Specification

Section 8: Payload — Group (GRP_TXT / GRP_DATA)

Overview

Group payloads enable encrypted communication within a channel. All members of a channel share a symmetric secret. The payload begins with a 1-byte channel hash (derived from the channel’s shared key), followed by an encrypt-then-MAC blob.

Payload Types

ValueNameDescription
0x05GRP_TXTGroup text message
0x06GRP_DATAGroup datagram (binary data)

Wire Format

 0
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|channel_hash(1)|    Cipher MAC (2 bytes)       |               |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+               |
|              Ciphertext (N × 16 bytes, N ≥ 1)                |
|                                                               |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

Fields

FieldOffsetSizeTypeDescription
channel_hash01 byterawFirst byte of SHA-256(channel_shared_key)
cipher_mac12 bytesrawTruncated HMAC-SHA256 over ciphertext
ciphertext3variable (multiple of 16)rawAES-128-ECB encrypted data

Minimum Size

  • Minimum payload: 1 + 2 + 16 = 19 bytes

Channel Hash Derivation

The channel_hash is the first byte of the SHA-256 hash of the channel’s 32-byte shared key (PUB_KEY_SIZE bytes):

channel_hash = SHA256(channel_shared_key)[0]

This means hash collisions are expected (1 in 256 chance). When a receiver finds a matching channel_hash, it attempts decryption with each matching channel’s secret. Only a successful MAC verification confirms the correct channel.

Encryption

  1. Encrypt plaintext using AES-128-ECB with first 16 bytes of the channel’s shared key (GroupChannel.secret).
  2. Compute HMAC-SHA256 over ciphertext using all 32 bytes of the channel’s shared key. Truncate to 2 bytes.
  3. Assemble: channel_hash || MAC || ciphertext.

Decryption

  1. Read channel_hash (1 byte).
  2. Search local channel database for channels with matching hash. Multiple channels may match (up to 4 in reference implementation).
  3. For each matching channel, attempt MAC verification and decryption using the channel’s shared key.
  4. The first channel that produces a valid MAC yields the correct decryption.

Cross-References

Reference Implementation

  • Mesh::createGroupDatagram() in src/Mesh.cpp — Encoding
  • Mesh::onRecvPacket(), cases PAYLOAD_TYPE_GRP_TXT/GRP_DATA — Decoding
  • Mesh::searchChannelsByHash() — Channel lookup

MeshCore Protocol Specification

Section 9: Payload — Path Return

Overview

The PATH payload carries an encrypted return path from one node to another. It uses the same dest_hash + src_hash + encrypt-then-MAC envelope as REQ/RESPONSE/ TXT_MSG, but the decrypted contents contain routing path data and optional extra application data.

Payload Type

Header payload type field: 0x08 (PATH)

Wire Format (Outer)

 0
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| dest_hash(1)  |  src_hash(1)  |    Cipher MAC (2 bytes)       |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                                                               |
|              Ciphertext (N × 16 bytes, N ≥ 1)                |
|                                                               |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

The outer format is identical to Section 6.

Decrypted Inner Format

+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|  path_len(1)  |         Path (hash_count × hash_size)        |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| extra_type(1) |         Extra Data (variable)                 |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
FieldSizeTypeDescription
path_len1 byteencodedSame bitfield encoding as packet path_len (see Section 3)
pathhash_count × hash_sizerawThe return path to the sender
extra_type1 byteuint8Type of extra data (lower 4 bits used; upper 4 reserved)
extraremainingrawExtra data (may include trailing zero padding from AES)

Extra Type Values

ValueDescription
0x00No meaningful extra data (reciprocal path)
0xFFDummy/padding (used when no real extra data; followed by 4 random bytes)
OtherApplication-defined

When creating a path return with no extra data, the reference implementation appends 0xFF as extra_type followed by 4 random bytes to ensure the packet hash is unique.

Encoding

  1. Build the inner plaintext: a. Write path_len byte (encoded hash_size and hash_count). b. Write path bytes (hash_count × hash_size). c. If extra data is provided: write extra_type (lower 4 bits), write extra. d. If no extra data: write 0xFF, write 4 random bytes.
  2. Encrypt the inner plaintext using encrypt-then-MAC with the shared secret.
  3. Prepend dest_hash and src_hash.

Decoding

  1. Decode the outer envelope (dest_hash, src_hash, MAC, ciphertext) as in Section 6.
  2. After decryption: a. Read 1 byte as path_len. Decode hash_size and hash_count. b. Read hash_count × hash_size bytes as the path. c. Read 1 byte as extra_type (use lower 4 bits only). d. Remaining bytes are extra data (may include AES zero-padding).

Cross-References

Reference Implementation

  • Mesh::createPathReturn() in src/Mesh.cpp — Encoding
  • Mesh::onRecvPacket(), PATH case within REQ/RESPONSE/TXT_MSG handling — Decoding

MeshCore Protocol Specification

Section 10: Payload — Trace

Overview

The TRACE payload enables path discovery with per-hop signal quality measurement. A trace packet travels along a specified direct path, and each intermediate node appends its received SNR value to the packet’s path field. When the trace reaches its final hop, the accumulated SNR values and node hashes are delivered.

Payload Type

Header payload type field: 0x09 (TRACE)

Wire Format

The trace payload has a fixed 9-byte header followed by an optional path hashes section (appended when sent via direct routing):

 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                        Tag (uint32_le)                        |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                     Auth Code (uint32_le)                     |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|     Flags     |          Path Hashes (variable)               |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

Fields

FieldOffsetSizeTypeDescription
tag04 bytesuint32_leRandom tag set by the initiator for correlation
auth_code44 bytesuint32_leAuthentication code
flags81 byteuint8Flags (see below)
path_hashes9variablerawNode hashes for the path to trace (appended for direct send)

Flags Field

  Bit:  7   6   5   4   3   2   1   0
      +---+---+---+---+---+---+---+---+
      |      Reserved         | S1  S0|
      +---+---+---+---+---+---+---+---+
BitsDescription
0-1Path hash size for the trace path hashes (as a power of 2: 0=1 byte, 1=2 bytes, 2=4 bytes)
2-7Reserved (must be zero)

Note: The flags field path hash size encoding (1 << (flags & 0x03)) differs from the packet path_len encoding ((code >> 6) + 1).

Direct Sending

When a TRACE packet is sent via direct routing, the path hashes are appended directly to the payload (after the 9-byte header), not placed in the packet’s path field. The packet’s path field is instead used to accumulate SNR values from intermediate nodes.

On creation:  payload = [tag(4)][auth_code(4)][flags(1)]  (9 bytes)
On sendDirect: payload += path_hashes                      (9 + N bytes)
               packet.path_len = 0                         (path used for SNR)

SNR Accumulation

As each intermediate node forwards a TRACE packet, it appends its received SNR value as a single signed byte to the packet’s path field:

path[path_len++] = (int8_t)(SNR × 4)

The SNR value is stored as SNR × 4 for 0.25 dB precision, matching the format used in RxMeta frames.

Trace Completion

A TRACE is considered complete when the path_len counter (used as an offset into the path hashes in the payload) reaches or exceeds the remaining path hashes. At that point, onTraceRecv is called with:

  • The accumulated SNR values (in packet.path)
  • The path hashes (in packet.payload, after byte 9)
  • The path length (number of hops traversed)

Minimum Size

  • Minimum payload: 9 bytes (tag + auth_code + flags, no path hashes)

Cross-References

Reference Implementation

  • Mesh::createTrace() in src/Mesh.cpp — Creation (9-byte base)
  • Mesh::sendDirect() — Path appended to payload for TRACE type
  • Mesh::onRecvPacket(), TRACE handling — SNR accumulation and completion check

MeshCore Protocol Specification

Section 11: Payload — Multipart

Overview

The MULTIPART payload wraps another payload type to indicate it is part of a multi-packet sequence. The first byte encodes the number of remaining packets in the sequence and the sub-payload type. Currently, only multipart ACK is defined in the reference implementation.

Payload Type

Header payload type field: 0x0A (MULTIPART)

Wire Format

 0                   1
 0 1 2 3 4 5 6 7 8 9 0 ...
+-+-+-+-+-+-+-+-+-+-+-+-+
|Rem|  SubType  | Sub-payload  |
+-+-+-+-+-+-+-+-+-+-+-+-+

First byte encoding:

  Bit:  7   6   5   4   3   2   1   0
      +---+---+---+---+---+---+---+---+
      | Remaining (4) |  Sub-Type (4) |
      +---+---+---+---+---+---+---+---+

Fields

FieldOffsetSizeTypeDescription
remaining0 (bits 4-7)4 bitsuintNumber of remaining packets in sequence (0-15)
sub_type0 (bits 0-3)4 bitsuintPayload type of the wrapped content
sub_payload1variablerawThe wrapped payload data
first_byte = (remaining << 4) | (sub_type & 0x0F)

Multipart ACK

The only currently defined multipart sub-type is ACK (sub_type = 0x03):

+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|  (rem<<4)|0x03|                ACK CRC (uint32_le)            |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

Total payload: 5 bytes (1 byte header + 4 byte CRC).

Multipart ACKs are used to send additional ACK retransmissions for reliability. The remaining field indicates how many more ACK packets follow this one, counting down: a chain of K extra copies is emitted as MULTIPART with remaining = K, K-1, …, 1, followed by exactly one final plain PAYLOAD_TYPE_ACK packet (NOT a MULTIPART with remaining = 0). All copies in the chain carry the same ack_crc. Receivers MUST treat the first copy received as the authoritative ACK and dedup subsequent copies by ack_crc at the application layer.

Multipart ACK Usage Constraints

  • A sender MUST only emit MULTIPART-wrapped ACKs on direct-routed return paths (i.e., when a direct path to the original sender is known). When the return path is unknown, the sender MUST fall back to emitting a plain PAYLOAD_TYPE_ACK via flood routing. This reflects the firmware behavior in Mesh::routeDirectRecvAcks() (which emits MULTIPART copies only when a direct path is available) and BaseChatMesh::sendAckTo() (which falls back to sendFloodScoped() when out_path_len == OUT_PATH_UNKNOWN).
  • All MULTIPART-ACK copies and the final plain ACK for the same acknowledged message carry an identical 4-byte ack_crc. Receivers MUST treat them as the same logical ACK: the first one received satisfies the acknowledgment, and subsequent duplicates MUST be deduplicated per Section 16 (each copy has a distinct packet hash because the wrapper byte differs, so ordinary dedup does not suppress them on the wire — application-level dedup by ack_crc is required).
  • The typical inter-copy delay in the reference implementation is approximately 300 ms plus the direct retransmit delay (Mesh::routeDirectRecvAcks()); exact timing is implementation-defined.
  • The number of extra copies is configured via getExtraAckTransmitCount() and is implementation-defined. The firmware default is 0 (plain ACK only).

A receiver MUST NOT infer delivery reliability from the remaining counter alone: the counter is a hint about how many additional copies the sender intends to emit, not a guarantee that they will arrive.

Encoding (Multipart ACK)

  1. Set first byte to (remaining << 4) | PAYLOAD_TYPE_ACK.
  2. Copy the 4-byte ACK CRC starting at offset 1.
  3. Set payload_len to 5.

Decoding (Multipart ACK)

  1. Read first byte. Extract remaining = byte >> 4, sub_type = byte & 0x0F.
  2. If sub_type == 0x03 (ACK) and payload_len >= 5: a. Read 4 bytes at offset 1 as ACK CRC (uint32_le).
  3. For other sub_types: reserved for future use.

Constraints

  • Payload MUST be at least 2 bytes (1 header + 1 sub-payload minimum).
  • For multipart ACK, payload MUST be at least 5 bytes.

Cross-References

Reference Implementation

  • Mesh::createMultiAck() in src/Mesh.cpp — Encoding
  • Mesh::routeDirectRecvAcks() in src/Mesh.cpp — Direct-only gating and inter-copy spacing
  • BaseChatMesh::sendAckTo() in src/helpers/BaseChatMesh.cpp — Flood fallback when no direct return path is known
  • Mesh::onRecvPacket(), case PAYLOAD_TYPE_MULTIPART — Decoding

MeshCore Protocol Specification

Section 12: Payload — Control

Overview

The CONTROL payload carries control and discovery data. Control packets are raw byte payloads whose structure is determined by the first byte. A subset of control packets (those with bit 7 of the first byte set) are restricted to zero-hop delivery only.

Payload Type

Header payload type field: 0x0B (CONTROL)

Wire Format

 0
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|  Control Byte |  Data...      |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

Fields

FieldOffsetSizeTypeDescription
control_byte01 byteuint8Control type identifier
data1variablerawControl-specific data

Zero-Hop Restriction

When bit 7 (0x80) of the control byte is set, the packet is restricted to zero-hop delivery only. The reference implementation enforces this:

  • If (payload[0] & 0x80) != 0 AND the packet is direct-routed:
    • It is processed only if getPathHashCount() == 0.
    • It is NOT forwarded to other nodes.

Discovery Protocols

Control packets are commonly used for node discovery. The specific sub-protocol formats are application-defined, but typically include:

  • Discovery Request: Sent zero-hop to find nearby nodes
  • Discovery Response: Contains node type, name, SNR, and other metadata

Encoding

  1. Construct the raw control data bytes.
  2. If the control packet should be zero-hop only, set bit 7 of the first byte.
  3. Copy data to payload, set payload_len.

Constraints

  • Payload MUST be at least 1 byte (the control byte).
  • Maximum payload: 184 bytes (MAX_PACKET_PAYLOAD).

Cross-References

Reference Implementation

  • Mesh::createControlData() in src/Mesh.cpp — Encoding
  • Mesh::onRecvPacket(), CONTROL handling — Zero-hop check and delivery

MeshCore Protocol Specification

Section 13: Payload — Raw Custom

Overview

The RAW_CUSTOM payload carries application-defined raw bytes with no prescribed structure. It is intended for applications that implement their own encryption, framing, or payload formats on top of the MeshCore transport.

Payload Type

Header payload type field: 0x0F (RAW_CUSTOM)

Wire Format

 0
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|     Raw Data (1-184 bytes)    |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

Fields

FieldOffsetSizeTypeDescription
data01-184 bytesrawApplication-defined payload

Routing

In the current reference implementation, RAW_CUSTOM packets are only processed when received via direct routing. They are NOT flood-routed.

Constraints

  • Payload MUST be at least 1 byte.
  • Maximum payload: 184 bytes (MAX_PACKET_PAYLOAD).
  • Maximum total data: limited by sizeof(Packet::payload) which is MAX_PACKET_PAYLOAD (184).

Cross-References

Reference Implementation

  • Mesh::createRawData() in src/Mesh.cpp — Encoding
  • Mesh::onRecvPacket(), case PAYLOAD_TYPE_RAW_CUSTOM — Processing (direct only)

MeshCore Protocol Specification

Section 14: Cryptography

Overview

MeshCore uses a combination of symmetric and asymmetric cryptography. Data confidentiality is provided by AES-128 in ECB mode with zero padding. Data integrity is provided by HMAC-SHA256 truncated to 2 bytes. These are combined in an encrypt-then-MAC scheme.

Algorithms

OperationAlgorithmKey SizeOutput
Symmetric EncryptionAES-128-ECB16 bytesMultiple of 16 bytes
Message AuthenticationHMAC-SHA25632 bytes2 bytes (truncated)
Digital SignatureEd2551932/64 bytes64 bytes
Key ExchangeX25519 (ECDH)32 bytes32 bytes
HashingSHA-256Up to 32 bytes

AES-128-ECB Encryption

MeshCore uses AES-128 in Electronic Codebook (ECB) mode with zero-byte padding.

Key: The first 16 bytes (CIPHER_KEY_SIZE) of the shared secret.

Encryption:

  1. Set the AES-128 key to shared_secret[0..15].
  2. Process the plaintext in 16-byte blocks: a. For each complete 16-byte block, encrypt in place. b. For the final partial block (< 16 bytes):
    • Copy the remaining bytes to a 16-byte buffer.
    • Fill remaining bytes with zeros.
    • Encrypt the padded block.
  3. If the plaintext is empty, encrypt a 16-byte all-zero block.
  4. The output is always a multiple of 16 bytes.

Decryption:

  1. Set the AES-128 key to shared_secret[0..15].
  2. Process the ciphertext in 16-byte blocks, decrypting each.
  3. The output length equals the ciphertext length.
  4. The final block MAY contain trailing zero bytes from padding. The application must know the original plaintext length or use a length-prefixed format to determine where meaningful data ends.

HMAC-SHA256 (Truncated to 2 Bytes)

MeshCore computes HMAC-SHA256 over the ciphertext and truncates the result to CIPHER_MAC_SIZE (2) bytes.

Key: The full 32 bytes (PUB_KEY_SIZE) of the shared secret.

Note: The HMAC key uses the full 32-byte shared secret, while the AES key uses only the first 16 bytes. This is an important distinction.

Computation:

mac = HMAC-SHA256(key=shared_secret[0..31], message=ciphertext)[0..1]

The first 2 bytes of the HMAC-SHA256 output are used as the MAC.

Encrypt-then-MAC

The encrypt-then-MAC scheme combines AES-128-ECB encryption with HMAC-SHA256 authentication:

Encoding (encryptThenMAC):

  1. Encrypt the plaintext using AES-128-ECB (see above). Store ciphertext at dest[2..] (offset by MAC size).
  2. Compute HMAC-SHA256 over the ciphertext using the full 32-byte shared secret. Truncate to 2 bytes.
  3. Store the 2-byte MAC at dest[0..1].
  4. Return total length: 2 + ciphertext_length.

Output format:

[MAC (2 bytes)][Ciphertext (N × 16 bytes)]

Decoding (MACThenDecrypt):

  1. If input length ≤ 2 (CIPHER_MAC_SIZE), return failure (invalid).
  2. Recompute HMAC-SHA256 over src[2..] (the ciphertext portion) using the full 32-byte shared secret. Truncate to 2 bytes.
  3. Compare the computed MAC with src[0..1].
  4. If MACs do not match, return failure (0 = invalid HMAC).
  5. If MACs match, decrypt src[2..] using AES-128-ECB.
  6. Return the decrypted plaintext length.

Security Notes

  • ECB mode: AES-ECB encrypts each 16-byte block independently. Identical plaintext blocks produce identical ciphertext blocks. This is a known weakness of ECB mode, but MeshCore’s short payloads and mesh-network context make this acceptable for its use case.
  • 2-byte MAC: The truncated HMAC provides only 16 bits of authentication. This means there is approximately a 1-in-65536 chance of a forged MAC being accepted. This trade-off prioritizes bandwidth over authentication strength.
  • Key derivation: The shared secret is used directly as both AES key (first 16 bytes) and HMAC key (full 32 bytes). No key derivation function (KDF) is applied.

Cross-References

Reference Implementation

  • Utils::encrypt() in src/Utils.cpp — AES-128-ECB encryption
  • Utils::decrypt() in src/Utils.cpp — AES-128-ECB decryption
  • Utils::encryptThenMAC() in src/Utils.cpp — Encrypt-then-MAC
  • Utils::MACThenDecrypt() in src/Utils.cpp — MAC-then-Decrypt

MeshCore Protocol Specification

Section 15: Identity

Overview

Every MeshCore node has an Ed25519 key pair. The 32-byte public key serves as the node’s identity. Hashes used in routing are truncated prefixes of the public key. Shared secrets for encryption are computed via ECDH key exchange using X25519 (Ed25519 keys transposed to Curve25519).

Key Types

TypeSizeDescription
Public Key32 bytes (PUB_KEY_SIZE)Ed25519 public key
Private Key64 bytes (PRV_KEY_SIZE)Ed25519 private key (seed + public)
Signature64 bytes (SIGNATURE_SIZE)Ed25519 signature
Shared Secret32 bytes (PUB_KEY_SIZE)ECDH shared secret

Hash Derivation

A node’s routing hash is simply a prefix of its public key:

hash = pub_key[0..hash_size-1]

Where hash_size is 1, 2, or 3 bytes depending on the protocol version and path encoding (see Section 3).

For V1, PATH_HASH_SIZE is 1, meaning a node’s hash is the first byte of its public key. This gives only 256 unique hash values, so hash collisions are common and expected. The protocol handles this by attempting decryption with all peers matching a given hash.

Hash Matching

To check if a hash matches a node’s identity:

match = memcmp(hash, pub_key, hash_size) == 0

Ed25519 Signing

Used for advertisement signatures (see Section 5):

  1. Construct the message to sign (e.g., pub_key || timestamp || app_data).
  2. Sign using the Ed25519 private key.
  3. The 64-byte signature is included in the advertisement payload.

Verification:

  1. Extract the public key and signature from the payload.
  2. Reconstruct the message.
  3. Verify the signature using Ed25519 verification.
  4. Receivers MUST discard the packet if verification fails.

ECDH Key Exchange (X25519)

To compute a shared secret between two nodes:

  1. Convert the Ed25519 keys to X25519 (Curve25519) format.
  2. Perform X25519 Diffie-Hellman: shared_secret = X25519(my_private, their_public).
  3. The resulting 32-byte shared secret is used for:
    • AES-128 key: shared_secret[0..15]
    • HMAC-SHA256 key: shared_secret[0..31]

Identity Serialization

Identities can be serialized for storage:

  • Public Identity: 32 bytes (public key only)
  • Local Identity: 64 bytes (private key) + 32 bytes (public key) = 96 bytes

Hex representation uses uppercase characters with 2 hex digits per byte.

Cross-References

Reference Implementation

  • Identity class in src/Identity.h — Public key and verification
  • LocalIdentity class in src/Identity.h — Key pair, signing, ECDH
  • Identity::copyHashTo() — Hash derivation (prefix copy)
  • Identity::isHashMatch() — Hash comparison
  • LocalIdentity::calcSharedSecret() — ECDH key exchange

MeshCore Protocol Specification

Section 16: Packet Hash

Overview

MeshCore uses SHA-256 hashing for packet deduplication. Each node maintains a table of recently seen packet hashes and discards packets it has already processed. The hash is computed over the payload type and payload contents, with a special case for TRACE packets.

Hash Computation

The packet hash is computed as:

hash = SHA-256(payload_type_byte || payload_data)[0..MAX_HASH_SIZE-1]

Where:

  • payload_type_byte is a single byte: (header >> 2) & 0x0F
  • payload_data is the raw payload bytes (payload_len bytes)
  • The SHA-256 output is truncated to MAX_HASH_SIZE (8) bytes

TRACE Packet Special Case

For TRACE packets (payload_type = 0x09), the path_len byte is included in the hash to distinguish trace packets that revisit the same node on their return path:

hash = SHA-256(payload_type_byte || path_len_byte || payload_data)[0..MAX_HASH_SIZE-1]

Algorithm

function calculatePacketHash(packet):
    sha = SHA256_init()
    type_byte = getPayloadType(packet.header)  // single byte
    SHA256_update(sha, type_byte)
    if type_byte == 0x09:  // TRACE
        SHA256_update(sha, packet.path_len)  // 1 byte (as uint8)
    SHA256_update(sha, packet.payload, packet.payload_len)
    hash = SHA256_finalize(sha)
    return hash[0..7]  // first MAX_HASH_SIZE bytes

Properties

  • The hash does NOT include the header byte, path, or transport codes. This means the same logical message received via different routes produces the same hash.
  • The hash does NOT include the route type or version. Only the payload type and payload content determine the hash.
  • For TRACE packets, including path_len ensures that the same trace payload at different stages of traversal produces different hashes.

Cross-References

Reference Implementation

  • Packet::calculatePacketHash() in src/Packet.cpp
  • MeshTables::hasSeen() — Deduplication table lookup

MeshCore Protocol Specification

Section 17: Routing

Overview

MeshCore supports two routing modes: flood routing and direct routing. Flood routing broadcasts packets to all neighbors, building up a path as the packet propagates. Direct routing sends packets along a pre-determined path.

Flood Routing

In flood routing, the sender emits a packet with an empty path. Each intermediate node that forwards the packet appends its hash to the path before retransmitting.

Sending (flood):

  1. Set route type to FLOOD (0x01) or TRANSPORT_FLOOD (0x00).
  2. Set path to empty: setPathHashSizeAndCount(hash_size, 0).
  3. Transmit the packet.

Forwarding (flood):

  1. Receive a flood-routed packet.
  2. Check the deduplication table. If already seen, discard.
  3. Process the packet (decrypt if addressed to this node, etc.).
  4. If the packet should be forwarded (node is a repeater): a. Verify (hash_count + 1) × hash_size <= MAX_PATH_SIZE. b. Append this node’s hash at path[hash_count × hash_size]. c. Increment hash_count. d. Retransmit with a random delay.

Path growth: The path grows by one hash per hop. When the path reaches MAX_PATH_SIZE (64 bytes), no more nodes can be appended and the packet stops propagating.

Direct Routing

In direct routing, the sender provides the complete path. Each intermediate node checks whether it is the next hop, removes itself from the path, and forwards to the next node.

Sending (direct):

  1. Set route type to DIRECT (0x02) or TRANSPORT_DIRECT (0x03).
  2. Set the path to the destination’s known route.
  3. Transmit the packet.

Forwarding (direct):

  1. Receive a direct-routed packet.
  2. Check if the first hash in the path matches this node.
  3. If yes, and the node allows forwarding: a. Remove the first hash from the path (shift remaining hashes left). b. Decrement hash_count. c. Retransmit.
  4. If no, discard (this node is not the next hop).

Path removal (removeSelfFromPath):

  1. Decrement hash_count.
  2. Shift the path array: for each index k from 0 to (hash_count × hash_size), copy path[k + hash_size] to path[k].

Zero-Hop Delivery

Zero-hop packets are direct-routed packets with hash_count = 0. They reach only immediate neighbors (nodes within radio range). Used for:

  • Control packets with bit 7 set in the control byte
  • Discovery protocols
  • Local-only operations

Transport Codes

Transport codes (present in TRANSPORT_FLOOD and TRANSPORT_DIRECT) enable regional scoping. Nodes can filter packets based on transport codes, accepting only packets from their region.

TRACE Routing

TRACE packets are a special case. They are sent via direct routing, but instead of the normal path field, the path hashes are appended to the payload. The packet’s path field is repurposed to accumulate SNR values from each hop (see Section 10).

Retransmission Priority

Packets are prioritized for transmission:

PriorityPacket TypeDescription
0 (highest)Direct routedRouted traffic
1Path return, standardMost flood packets
2Path return (flood)Path packets
3AdvertisementDe-prioritized
5TraceTrace forwarding
N (hash_count)Flood forwardedLower priority for more distant sources

Sender Behavior (Informational)

This section documents sender-side behavior that is not fully constrained by the wire protocol but is common across interoperating implementations. The reference firmware establishes the baseline; client implementations SHOULD follow these patterns to interoperate cleanly. Specific numeric tuning parameters (timeouts, queue sizes, retry counts) are implementation-defined.

Path Learning

  • When a node receives a flood-routed packet from a peer, the accumulated path field records the hops the packet traversed. The receiving node SHOULD cache the reversed path keyed by the peer’s source hash and subsequently use that cached path to send direct-routed traffic back.
  • When multiple paths are observed for the same peer, the reference implementation unconditionally replaces the cached path with the most recently observed one (BaseChatMesh::onContactPathRecv() in src/helpers/BaseChatMesh.cpp — its source comment explicitly flags hash-size or SNR-based selection as future work). Implementations MAY layer a quality heuristic on top (e.g., prefer larger hash sizes or better SNR margin) if they store and compare multiple candidate paths, but this is not part of the reference behavior and the exact policy is implementation-defined.
  • Cached paths in the reference implementation do not expire on a timer; they live as long as the contact entry, and are overwritten whenever a fresher path is observed. Implementations MAY add a TTL or age-based eviction policy as an extension. The reference implementation does not persist paths across reboots.

PATH_RETURN

After receiving a flood-routed DM, a receiver MAY proactively emit a PAYLOAD_TYPE_PATH packet (Section 9) that embeds the reversed path and optionally an ACK as extra data. This lets the original sender learn a direct return path before its first reply, converting subsequent traffic from flood to direct routing. See BaseChatMesh::onPeerDataRecv() and Mesh::createPathReturn().

The reference implementation schedules the PATH_RETURN transmission with a small delay (TXT_ACK_DELAY, default 200 ms) to avoid colliding with other packets the receiver is about to emit. Exact timing is implementation-defined; the goal is to space PATH_RETURN, ACK, and any application reply across distinct on-air slots.

Flood Fallback and Retries

  • A sender SHOULD track outstanding DMs by their expected ack_crc (see Section 4) and retry transmissions that are not acknowledged within a timeout. Timeout values and backoff schedules are implementation-defined and typically depend on LoRa airtime parameters.
  • On each retry, the sender MUST increment the attempt sub-field of txt_type_attempt (see Section 6). The timestamp field MUST be held constant across retries of the same logical message. This changes the ACK CRC per attempt, letting the sender attribute a returned ACK to a specific transmission.
  • After N unsuccessful direct-routed retries, the sender SHOULD discard the cached direct path and retry via flood routing, so that a stale cached path does not indefinitely block delivery. The value of N is implementation-defined (the reference is small — single-digit).
  • On receipt of a matching ACK (plain or MULTIPART, per Section 11), the sender MUST cancel any queued retries for that ACK CRC.

Transmission Priority (sender-originated)

Client implementations that maintain a TX queue SHOULD prioritize sender-originated packets roughly as follows (highest first):

PriorityPacket Type
0ACK (plain or MULTIPART)
1PATH / PATH_RETURN
2Direct-routed reply (DM)
3Flood-routed reply (DM)
4Request/response (e.g., login, keep-alive)
5Group text / group data
6Advertisement

This ordering complements the forwarding-priority table above: ACKs and PATH returns are time-sensitive and short; direct replies take precedence over flood replies to reduce channel occupancy. Exact ordering and queue depth are implementation-defined.

Packet Deduplication

Before processing or forwarding any packet, nodes check if the packet has already been seen using hasSeen() with the packet hash from Section 16. This prevents infinite loops in flood routing.

Once a packet is marked as seen, it is NOT forwarded again even if received via a different route. This is a “first packet wins” approach.

Cross-References

Reference Implementation

  • Mesh::sendFlood() in src/Mesh.cpp — Flood send
  • Mesh::sendDirect() in src/Mesh.cpp — Direct send
  • Mesh::sendZeroHop() in src/Mesh.cpp — Zero-hop send
  • Mesh::routeRecvPacket() in src/Mesh.cpp — Flood forwarding
  • Mesh::removeSelfFromPath() in src/Mesh.cpp — Path manipulation
  • Mesh::onRecvPacket() in src/Mesh.cpp — Direct forwarding

MeshCore Protocol Specification

Section 18: Companion Protocol

Overview

The companion protocol enables BLE (Bluetooth Low Energy) and serial communication between a MeshCore radio and a companion app (phone, tablet, or computer). It uses a binary framing format over the Nordic UART Service (NUS) BLE profile.

BLE Service

UUIDDescription
6E400001-B5A3-F393-E0A9-E50E24DCCA9ENordic UART Service
6E400002-...RX Characteristic (app writes to radio)
6E400003-...TX Characteristic (radio notifies app)

Frame Format

Each companion protocol frame consists of a 1-byte type identifier followed by variable-length data:

[type(1)][data(0-171)]

Maximum frame size is 172 bytes.

Message Types (Radio to App)

TypeValueDescription
PACKET_CHANNEL_MSG_RECV0x01Channel message received
PACKET_CONTACT_MSG_RECV0x02Contact message received
PACKET_ADV_RECV0x03Advertisement received
PACKET_CONTACT_MSG_RECV_V30x04Contact message V3 (includes SNR)
PACKET_CHANNEL_MSG_RECV_V30x05Channel message V3 (includes SNR)

Command Types (App to Radio)

CommandValueDescription
CMD_APP_START0x01Initialize connection
CMD_DEVICE_QUERY0x02Query device info
CMD_SET_CHANNEL0x03Set active channel
CMD_SEND_CHANNEL_MESSAGE0x04Send message to channel
CMD_SEND_CONTACT_MESSAGE0x05Send message to contact
CMD_GET_CONTACTS0x06Get contact list
CMD_GET_CHANNELS0x07Get channel list
CMD_SET_TIME0x08Set device time
CMD_ADD_CONTACT0x09Add a contact
CMD_ADD_CHANNEL0x0AAdd a channel
CMD_SEND_LOGIN0x0BLogin to room/repeater
CMD_GET_SETTINGS0x0CGet device settings
CMD_SET_SETTINGS0x0DSet device settings
CMD_REMOVE_CONTACT0x0ERemove a contact
CMD_REMOVE_CHANNEL0x0FRemove a channel
CMD_SHARE_CONTACT0x10Share contact info
CMD_SET_ADV_NAME0x11Set advertisement name
CMD_REBOOT0x12Reboot device
CMD_SEND_RAW0x13Send raw packet

Connection Sequence

  1. Scan for BLE devices advertising the Nordic UART Service.
  2. Connect and discover services.
  3. Enable notifications on the TX characteristic.
  4. Send CMD_APP_START to initialize.
  5. Send CMD_DEVICE_QUERY to get device info.
  6. Send CMD_SET_TIME to synchronize clock.
  7. Send CMD_GET_CONTACTS and CMD_GET_CHANNELS to fetch state.

Byte Order

All multi-byte integers in the companion protocol are little-endian, except for CayenneLPP sensor data which uses big-endian.

Message Length Limit

Text messages are limited to 133 characters.

MTU Considerations

The default BLE MTU is 23 bytes (20 bytes of payload). For complex operations, implementations SHOULD request a larger MTU (up to 512 bytes).

Cross-References

Reference Implementation

  • docs/companion_protocol.md in the MeshCore repository

MeshCore Protocol Specification

Section 19: KISS Modem Protocol

Overview

The KISS (Keep It Simple, Stupid) modem protocol provides a serial interface to MeshCore LoRa radios. It follows the KA9Q/K3MC KISS TNC specification with MeshCore-specific SetHardware extensions for cryptographic operations, radio control, and telemetry.

Serial Configuration

  • Baud rate: 115200
  • Data bits: 8
  • Parity: None
  • Stop bits: 1
  • Flow control: None

Frame Format

┌──────┬───────────┬──────────────┬──────┐
│ FEND │ Type Byte │ Data (escaped)│ FEND │
│ 0xC0 │  1 byte   │ 0-510 bytes  │ 0xC0 │
└──────┴───────────┴──────────────┴──────┘

Special Bytes

ByteNameValueDescription
FENDFrame End0xC0Frame delimiter
FESCFrame Escape0xDBEscape character
TFENDTransposed FEND0xDCFESC + TFEND represents 0xC0 in data
TFESCTransposed FESC0xDDFESC + TFESC represents 0xDB in data

Byte Stuffing

To send a data byte of 0xC0 (FEND), emit: 0xDB 0xDC (FESC TFEND). To send a data byte of 0xDB (FESC), emit: 0xDB 0xDD (FESC TFESC).

Type Byte

  Bit:  7   6   5   4   3   2   1   0
      +---+---+---+---+---+---+---+---+
      |    Port (4)   |  Command (4)  |
      +---+---+---+---+---+---+---+---+

Port is 0 for single-port TNC (standard for MeshCore).

Standard KISS Commands (Host to TNC)

CommandValueDataDescription
DataFrame0x00Raw packetQueue packet for transmission
TXDELAY0x011 byteTransmitter keyup delay (× 10ms)
Persistence0x021 byteCSMA persistence (0-255)
SlotTime0x031 byteCSMA slot interval (× 10ms)
TXtail0x041 bytePost-TX hold time (× 10ms)
FullDuplex0x051 byte0=half, nonzero=full
SetHardware0x06Sub-cmd + dataMeshCore extensions
Return0xFFExit KISS mode (no-op)

TNC to Host

TypeValueDataDescription
DataFrame0x00Raw packetReceived packet from radio

Data frames carry raw MeshCore packets (up to 255 bytes, MAX_TRANS_UNIT).

SetHardware Extensions (0x06)

MeshCore extends the KISS protocol via the SetHardware command. The first data byte is a sub-command identifier.

Request Sub-commands (Host to TNC):

Sub-cmdValueDataDescription
GetIdentity0x01Get node’s Ed25519 public key
GetRandom0x02len(1)Get random bytes (1-64)
VerifySignature0x03pubkey(32)+sig(64)+dataVerify Ed25519 signature
SignData0x04dataEd25519 sign
EncryptData0x05key(32)+plaintextAES-128 encrypt
DecryptData0x06key(32)+mac(2)+ciphertextAES-128 decrypt with MAC
KeyExchange0x07remote_pub(32)X25519 ECDH
Hash0x08dataSHA-256 hash
SetRadio0x09freq(4)+bw(4)+sf(1)+cr(1)Set radio parameters
SetTxPower0x0Apower(1)Set TX power (dBm)
GetRadio0x0BGet radio parameters
GetTxPower0x0CGet TX power
GetCurrentRssi0x0DGet current RSSI
IsChannelBusy0x0ECheck if channel busy
GetAirtime0x0Fpkt_len(1)Estimate air time
GetNoiseFloor0x10Get noise floor
GetVersion0x11Get firmware version
GetStats0x12Get RX/TX statistics
GetBattery0x13Get battery voltage
GetMCUTemp0x14Get MCU temperature
GetSensors0x15perms(1)Get sensor data
GetDeviceName0x16Get device name
Ping0x17Ping
Reboot0x18Reboot device
SetSignalReport0x19enable(1)Enable/disable RxMeta
GetSignalReport0x1AGet signal report status

Response Sub-commands (TNC to Host):

Response codes: response = command | 0x80

Sub-cmdValueData
Identity0x81pubkey(32)
Random0x82random_bytes(1-64)
Verify0x83result(1): 0=invalid, 1=valid
Signature0x84signature(64)
Encrypted0x85mac(2)+ciphertext
Decrypted0x86plaintext
SharedSecret0x87secret(32)
HashResult0x88hash(32)
Radio0x8Bfreq(4)+bw(4)+sf(1)+cr(1)
TxPower0x8Cpower(1)
CurrentRssi0x8Drssi(1, signed)
ChannelBusy0x8Ebusy(1): 0=clear, 1=busy
Airtime0x8Fmillis(4)
NoiseFloor0x90dBm(2, signed)
Version0x91version(1)+reserved(1)
Stats0x92rx(4)+tx(4)+errors(4)
Battery0x93millivolts(2)
MCUTemp0x94temp(2, signed, tenths °C)
Sensors0x95CayenneLPP data
DeviceName0x96name(UTF-8)
Pong0x97
SignalReport0x9Astatus(1)
OK0xF0
Error0xF1error_code(1)
TxDone0xF8result(1): 0=fail, 1=success
RxMeta0xF9snr(1)+rssi(1)

Error Codes

CodeValueDescription
InvalidLength0x01Request data too short
InvalidParam0x02Invalid parameter value
NoCallback0x03Feature not available
MacFailed0x04MAC verification failed
UnknownCmd0x05Unknown sub-command
EncryptFailed0x06Encryption failed

Unsolicited Events

TxDone (0xF8): Sent after packet transmission. 0x01 = success, 0x00 = fail.

RxMeta (0xF9): Sent after each received data frame. Contains:

  • SNR: 1 byte, signed, × 4 for 0.25 dB precision
  • RSSI: 1 byte, signed, dBm

Data Format Notes

  • Maximum payload per frame: 255 bytes (MAX_TRANS_UNIT)
  • Frames larger than 255 unescaped bytes are silently dropped
  • All multi-byte integers are little-endian
  • Radio frequency is in Hz (e.g., 869618000)
  • Battery voltage in millivolts
  • MCU temperature in tenths of °C (e.g., 253 = 25.3°C)

Cross-References

Reference Implementation

  • docs/kiss_modem_protocol.md in the MeshCore repository

MeshCore Protocol Specification

Section 20: Bridge Protocol

Overview

The bridge protocol enables MeshCore packet forwarding over non-LoRa transports such as RS232 serial links and ESP-NOW WiFi. It adds a simple framing layer with a magic number and checksum around the raw MeshCore packet.

Frame Format

┌──────────────┬──────────────┬──────────────┬──────────────┐
│  Magic (2B)  │   Payload    │ Checksum (2B)│              │
│   0xC03E     │  (variable)  │ Fletcher-16  │              │
└──────────────┴──────────────┴──────────────┴──────────────┘

Fields

FieldSizeTypeDescription
Magic2 bytesuint160xC03E — identifies the frame as a MeshCore bridge packet
PayloadvariablerawRaw MeshCore packet (as produced by Packet::writeTo())
Checksum2 bytesFletcher-16Fletcher-16 checksum over the payload

RS232 Variant

The RS232 variant adds a length field between the magic and payload:

┌──────────────┬──────────────┬──────────────┬──────────────┐
│  Magic (2B)  │  Length (2B) │   Payload    │ Checksum (2B)│
│   0xC03E     │  uint16_le   │  (variable)  │ Fletcher-16  │
└──────────────┴──────────────┴──────────────┴──────────────┘
FieldSizeTypeDescription
Length2 bytesuint16_leLength of the payload in bytes

Fletcher-16 Checksum

The Fletcher-16 checksum is computed over the payload bytes:

function fletcher16(data):
    sum1 = 0
    sum2 = 0
    for byte in data:
        sum1 = (sum1 + byte) mod 255
        sum2 = (sum2 + sum1) mod 255
    return (sum2 << 8) | sum1

ESP-NOW Variant

For ESP-NOW transport, the frame format is the same as the basic format (magic + payload + checksum) without the length field, since ESP-NOW provides its own length framing.

Cross-References

Reference Implementation

  • Bridge handling in MeshCore firmware (board-specific implementations)