A distilled reference for implementing a MeshCore companion client (phone ↔ radio) and for decoding the small amount of over-the-air packet structure a client needs (channel messages and adverts it sniffs via the radio's RX log).
Provenance & status. Reverse-engineered from the MeshCore Open client
(meshcore_open, Flutter) during a security review, cross-checked in places against the
MeshCore firmware wire notes and michaelhart/meshcore-decoder. Byte layouts here match
that client's frame builders/parsers. Treat command/response/push code values and
fixed layouts as reliable; treat exact over-the-air raw-packet framing (§7) as
"validate against firmware + a live device before trusting." Where a field's meaning is
inferred, it is marked (inferred).
Endianness is little-endian for all multi-byte integers unless stated. Strings are
UTF-8; fixed-width name fields are null-padded/\0-terminated ("cstring").
Two distinct byte formats a client deals with:
- Companion frames (§3–§6) — the request/response/push protocol spoken directly to the attached radio over BLE/TCP/USB. This is the bulk of the client.
- Over-the-air packets (§7–§8) — the mesh packet format. A companion client mostly
does not build these (the radio does), but it must parse a few that the radio hands
up verbatim through the RX log push (
PUSH_CODE_LOG_RX_DATA): group-text (channel) packets it decrypts itself, and adverts.
All three transports carry the same companion frames; only the outer framing differs.
| Transport | Details |
|---|---|
| BLE | Nordic UART Service (NUS). Service 6e400001-b5a3-f393-e0a9-e50e24dcca9e; RX char (client → radio) 6e400002-…; TX char (radio → client, notify) 6e400003-…. Each BLE write / notification carries one frame — no link framing (request MTU ≥ 175 so a max-size frame fits one write). |
| TCP | Raw socket to host:port (common default hint 192.168.40.10:5000). Same start-byte + length framing as USB serial (below). |
| USB serial | 115200 baud, 8N1. [start][len_lo][len_hi][payload] — start 0x3C ('<') client → radio, 0x3E ('>') radio → client; len = u16 LE payload length ≤ 172. (Corrected 2026-07-31: an earlier draft said "COBS-framed"; the reference client's usb_serial_frame_codec.dart — used for both USB and TCP and validated against firmware — uses this start-byte + length framing, not COBS.) |
BLE scan name prefixes (advertised name starts with one of):
MeshCore-, Whisper-, WisCore-, Seeed, Lilygo, HT-, LowMesh_MC_.
Protocol constants
MAX_FRAME_SIZE = 172 // max companion frame bytes
MAX_TEXT_PAYLOAD_BYTES = 160 // firmware MAX_TEXT_LEN = 10 * CIPHER_BLOCK_SIZE
APP_PROTOCOL_VERSION = 4
CIPHER_BLOCK_SIZE = 16 // AES-128
CIPHER_MAC_SIZE = 2 // truncated channel MAC (see §8)
PUB_KEY_SIZE = 32
SIGNATURE_SIZE = 64
MAX_PATH_SIZE = 64
MAX_NAME_SIZE = 32
Byte [0] of every frame is a code:
- client → radio: a command code (§4),
- radio → client: a response code (reply to a command) or a push code (async;
high bit set,
0x80+) (§5, §6).
Responses are not tagged with a request id — correlate by ordering and by content
(e.g. ACK hashes, pubkey prefixes). A client typically keeps a small queue of
outstanding commands and matches RESP_CODE_OK/RESP_CODE_ERR to the oldest.
Transcribed from firmware 2026-08-06 —
examples/companion_radio/MyMesh.cpp(FIRMWARE_VERSION "v1.16.0",FIRMWARE_VER_CODE 13). This table is now a copy of the source of truth rather than an inference from a client, so where an earlier revision of this file disagreed, this wins. Rows marked ✗ are defined by firmware and not implemented here.
| Code | Name | Purpose | |
|---|---|---|---|
| 1 | CMD_APP_START |
Handshake / start session | |
| 2 | CMD_SEND_TXT_MSG |
Send a direct message (or CLI cmd, see txt_type) | |
| 3 | CMD_SEND_CHANNEL_TXT_MSG |
Send a channel (group) message | |
| 4 | CMD_GET_CONTACTS |
Request contact list (optional since) |
|
| 5 | CMD_GET_DEVICE_TIME |
Read RTC | |
| 6 | CMD_SET_DEVICE_TIME |
Set RTC | |
| 7 | CMD_SEND_SELF_ADVERT |
Advertise self (flood flag) | |
| 8 | CMD_SET_ADVERT_NAME |
Set node display name | |
| 9 | CMD_ADD_UPDATE_CONTACT |
Add/update a contact (custom path, etc.) | |
| 10 | CMD_SYNC_NEXT_MESSAGE |
Pull next queued inbound message | |
| 11 | CMD_SET_RADIO_PARAMS |
Freq/BW/SF/CR (+clientRepeat v9+) | |
| 12 | CMD_SET_RADIO_TX_POWER |
TX power (dBm) | |
| 13 | CMD_RESET_PATH |
Reset stored path to a contact | |
| 14 | CMD_SET_ADVERT_LATLON |
Set advertised location | |
| 15 | CMD_REMOVE_CONTACT |
Delete a contact | |
| 16 | CMD_SHARE_CONTACT |
Zero-hop share of a contact | |
| 17 | CMD_EXPORT_CONTACT |
Export a contact (or self if empty key) | |
| 18 | CMD_IMPORT_CONTACT |
Import a contact from an advert blob | |
| 19 | CMD_REBOOT |
Reboot radio (payload "reboot") |
|
| 20 | CMD_GET_BATT_AND_STORAGE |
Battery + storage stats | |
| 21 | CMD_SET_TUNING_PARAMS |
Radio tuning parameters | ✗ |
| 22 | CMD_DEVICE_QUERY |
Query device (app protocol version) | |
| 23 | CMD_EXPORT_PRIVATE_KEY |
Read the identity private key | ✗ |
| 24 | CMD_IMPORT_PRIVATE_KEY |
Replace the identity private key | ✗ |
| 25 | CMD_SEND_RAW_DATA |
Send a raw payload | ✗ |
| 26 | CMD_SEND_LOGIN |
Log in to a repeater/room (password) | |
| 27 | CMD_SEND_STATUS_REQ |
Request repeater status | |
| 28 | CMD_HAS_CONNECTION |
Is there a live session with a node | ✗ |
| 29 | CMD_LOGOUT |
End a repeater/room session ("Disconnect") | ✗ |
| 30 | CMD_GET_CONTACT_BY_KEY |
Fetch one contact by pubkey | |
| 31 | CMD_GET_CHANNEL |
Read a channel slot | |
| 32 | CMD_SET_CHANNEL |
Write a channel slot (name + PSK) | |
| 33 | CMD_SIGN_START |
Begin a signing session | ✗ |
| 34 | CMD_SIGN_DATA |
Feed data to sign | ✗ |
| 35 | CMD_SIGN_FINISH |
Finish and return the signature | ✗ |
| 36 | CMD_SEND_TRACE_PATH |
Path trace (tag/auth/flag) | |
| 37 | CMD_SET_DEVICE_PIN |
Set the BLE pairing PIN (u32 LE) | |
| 38 | CMD_SET_OTHER_PARAMS |
Telemetry/advert-location/multi-ack policy | |
| 39 | CMD_SEND_TELEMETRY_REQ |
Request telemetry ("can deprecate this") | |
| 40 | CMD_GET_CUSTOM_VARS |
Read custom vars | |
| 41 | CMD_SET_CUSTOM_VAR |
Write a custom var | |
| 42 | CMD_GET_ADVERT_PATH |
Read the path an advert took | ✗ |
| 43 | CMD_GET_TUNING_PARAMS |
Read radio tuning parameters | ✗ |
| 50 | CMD_SEND_BINARY_REQ |
Binary request (telemetry/neighbors/etc.) | |
| 51 | CMD_FACTORY_RESET |
Wipe the companion radio | ✗ |
| 52 | CMD_SEND_PATH_DISCOVERY_REQ |
Path discovery (→ push 0x8D) |
✗ |
| 54 | CMD_SET_FLOOD_SCOPE_KEY |
Set flood scope/region tag (v8+) | |
| 55 | CMD_SEND_CONTROL_DATA |
Control/discovery packet (v8+) | |
| 56 | CMD_GET_STATS |
Core/radio/packet stats (v8+) | |
| 57 | CMD_SEND_ANON_REQ |
Anonymous request (e.g. regions) | |
| 58 | CMD_SET_AUTOADD_CONFIG |
Auto-add contact policy | |
| 59 | CMD_GET_AUTOADD_CONFIG |
Read auto-add policy | |
| 60 | CMD_GET_ALLOWED_REPEAT_FREQ |
Frequencies this node may client-repeat on | ✗ |
| 61 | CMD_SET_PATH_HASH_MODE |
On-air path hash width (mode 0–3) | |
| 62 | CMD_SEND_CHANNEL_DATA |
Channel datagram | ✗ |
| 63 | CMD_SET_DEFAULT_FLOOD_SCOPE |
Default flood scope | ✗ |
| 64 | CMD_GET_DEFAULT_FLOOD_SCOPE |
Read default flood scope | ✗ |
| 65 | CMD_SEND_RAW_PACKET |
Transmit a raw packet | ✗ |
There is no companion CLI command, and no companion CLI. Confirmed
2026-08-06 against firmware: examples/companion_radio/ never includes
CommonCLI — the text CLI belongs to repeater/room firmware.
CMD_SEND_TXT_MSG with txt_type = TXT_TYPE_CLI_DATA only ever
addresses a remote contact (sendCommandData(*recipient, …)), and
the one console in companion firmware is enterCLIRescue(), which reads
USB serial directly and prints to Serial, bypassing the frame protocol
entirely. A BLE-attached client cannot run a CLI command on its own
radio and cannot read its output.
| Code | Name |
|---|---|
| 0 | RESP_CODE_OK |
| 1 | RESP_CODE_ERR (byte [1] = error code, if present) |
| 2 | RESP_CODE_CONTACTS_START ([1..4] total count, v3+) |
| 3 | RESP_CODE_CONTACT (see §9) |
| 4 | RESP_CODE_END_OF_CONTACTS |
| 5 | RESP_CODE_SELF_INFO |
| 6 | RESP_CODE_SENT ([1]=is_flood, [2..5]=ack_hash u32, [6..9]=timeout_ms u32) |
| 7 | RESP_CODE_CONTACT_MSG_RECV (§9) |
| 9 | RESP_CODE_CURR_TIME |
| 10 | RESP_CODE_NO_MORE_MESSAGES |
| 11 | RESP_CODE_EXPORT_CONTACT (advert blob ≥ 98 bytes) |
| 12 | RESP_CODE_BATT_AND_STORAGE ([1..2]=mV u16, …) |
| 13 | RESP_CODE_DEVICE_INFO |
| 16 | RESP_CODE_CONTACT_MSG_RECV_V3 (adds SNR + reserved; §9) |
| 17 | RESP_CODE_CHANNEL_MSG_RECV_V3 |
| 18 | RESP_CODE_CHANNEL_INFO (§9) |
| 21 | RESP_CODE_CUSTOM_VARS |
| 24 | RESP_CODE_STATS ([1]=stats type: 0 core / 1 radio / 2 packets) |
| 14 | RESP_CODE_PRIVATE_KEY (reply to CMD_EXPORT_PRIVATE_KEY) |
| 15 | RESP_CODE_DISABLED |
| 19 | RESP_CODE_SIGN_START |
| 20 | RESP_CODE_SIGNATURE |
| 22 | RESP_CODE_ADVERT_PATH |
| 23 | RESP_CODE_TUNING_PARAMS |
| 25 | RESP_CODE_AUTOADD_CONFIG |
| 26 | RESP_ALLOWED_REPEAT_FREQ |
| 27 | RESP_CODE_CHANNEL_DATA_RECV |
| 28 | RESP_CODE_DEFAULT_FLOOD_SCOPE |
(RESP_CODE_CHANNEL_MSG_RECV = 8 exists alongside its V3 form 17.)
| Code | Name | Meaning |
|---|---|---|
| 0x80 | PUSH_CODE_ADVERT |
Known contact re-heard (pubkey only) |
| 0x81 | PUSH_CODE_PATH_UPDATED |
[1..32] pubkey — path changed |
| 0x82 | PUSH_CODE_SEND_CONFIRMED |
[1..4]=ack_hash u32, [5..8]=trip_ms u32 |
The ACK hash stayed 4 bytes at the companion boundary. Firmware PR #2594 (merged 2026-05-21) widened the over-the-air ACK payload to 6 bytes —
sha256(...)[0..3]unchanged, then a copy of the extended attempt byte, then one random byte. Verified from the diff:composeMsgPacket()still returnsuint32_t& expected_ack, soRESP_CODE_SENTandPUSH_CODE_SEND_CONFIRMEDare untouched and a client matching on the u32 needs no change. The reason for the widening is retries:SimpleMeshTablesdropped its separate ACK-CRC table, so without the attempt byte a retry's ACK is byte-identical to the first attempt's and gets swallowed as a duplicate by the mesh's seen-table. Send a distinctattemptper try or your retries cannot be acknowledged. | 0x83 |PUSH_CODE_MSG_WAITING| Inbound message queued →CMD_SYNC_NEXT_MESSAGE| | 0x85 |PUSH_CODE_LOGIN_SUCCESS|[1]=perm (fw sends 1/0),[2..7]=pubkey prefix | | 0x86 |PUSH_CODE_LOGIN_FAIL| | | 0x87 |PUSH_CODE_STATUS_RESPONSE| Repeater status | | 0x88 |PUSH_CODE_LOG_RX_DATA| Raw RX packet ([1]=snr/4,[2]=rssi, then §7 packet) |
The RX log fires before deduplication, and that is load-bearing.
Dispatcher::checkRecv()callslogRxRaw()on the line afterrecvRaw()— beforetryParsePacket, before_tables->hasSeen(), before any routing decision. A client therefore receives every packet the radio demodulates, including ones the mesh layer is about to discard. That includes rebroadcasts of the client's OWN packets: firmware marks outbound packets seen precisely so it will not re-transmit them (Mesh.cpp, "mark this packet as already sent in case it is rebroadcast back to us"), so they are dropped for routing and still logged. This is what makes "which repeaters carry my traffic" answerable at all — see PARITY §2. Verified against firmware v1.16.0 on 2026-08-06. | 0x89 |PUSH_CODE_TRACE_DATA| Path-trace result | | 0x8A |PUSH_CODE_NEW_ADVERT| New contact advert (same layout asRESP_CODE_CONTACT) | | 0x8B |PUSH_CODE_TELEMETRY_RESPONSE| Telemetry (Cayenne LPP) | | 0x8C |PUSH_CODE_BINARY_RESPONSE| Response toCMD_SEND_BINARY_REQ| | 0x8E |PUSH_CODE_CONTROL_DATA| Discovery/control response | | 0x84 |PUSH_CODE_RAW_DATA| Raw payload received | | 0x8D |PUSH_CODE_PATH_DISCOVERY_RESPONSE| Reply toCMD_SEND_PATH_DISCOVERY_REQ| | 0x8F |PUSH_CODE_CONTACT_DELETED| A contact was evicted (storage full) | | 0x90 |PUSH_CODE_CONTACTS_FULL| Contact storage is full |
u8/u16/u32/i32 = little-endian; [n] = n bytes; cstr(n) = n-byte null-padded
UTF-8; text…\0 = UTF-8 text + trailing \0.
CMD_APP_START (1)
[1] app_ver=1 | [6] reserved | app_name…\0 // e.g. "MeshCoreOpen"
CMD_DEVICE_QUERY (22)
[1] app_protocol_version (=4)
CMD_SEND_TXT_MSG (2) // direct message; also CLI when txt_type=1
[1] txt_type | [1] attempt | u32 timestamp | [6] dest_pubkey_prefix | text…\0
txt_type: 0=plain, 1=cli_data, 2=signed
CMD_SEND_CHANNEL_TXT_MSG (3)
[1] txt_type | [1] channel_idx | u32 timestamp | text…\0 // text = "name: msg"
CMD_SEND_LOGIN (26) // repeater/room login — password is CLEARTEXT on the wire
[32] recipient_pubkey | password…\0
CMD_GET_CONTACT_BY_KEY (30) [32] pubkey
CMD_REMOVE_CONTACT (15) [32] pubkey
CMD_RESET_PATH (13) [32] pubkey
CMD_EXPORT_CONTACT (17) [32] pubkey (empty = export self)
CMD_SHARE_CONTACT (16) [32] pubkey
CMD_GET_CHANNEL (31) [1] channel_idx
CMD_SET_CHANNEL (32)
[1] channel_idx | cstr(32) name | [16] psk
CMD_ADD_UPDATE_CONTACT (9) // e.g. set a custom path
[32] pubkey | [1] type | [1] flags | [1] path_len | [64] path (zero-pad) |
cstr(32) name | u32 timestamp | [ i32 lat*1e6 | i32 lon*1e6 | (u32 lastmod) ]?
CMD_SET_RADIO_PARAMS (11)
u32 freq_KHZ | u32 bw_hz | [1] sf(5..12) | [1] cr(5..8) | [1] client_repeat?(v9+)
// freq is kHz, bw is Hz — see §11. SELF_INFO reports them back the same way.
CMD_SET_RADIO_TX_POWER (12) [1] power_dbm
CMD_SET_ADVERT_LATLON (14) i32 lat*1e6 | i32 lon*1e6
CMD_SET_ADVERT_NAME (8) name (≤31 bytes)
CMD_SEND_SELF_ADVERT (7) [1] flood(0/1)
CMD_SET_DEVICE_PIN (37) u32 LE pin
// Evidence: companion_radio/MyMesh.cpp requires `len >= 5` and reads
// uint32_t pin; memcpy(&pin, &cmd_frame[1], 4);
// so this is a NUMBER, not the six ASCII digits the user typed.
//
// ACCEPTED VALUES — the handler is explicit, and a client that offers
// anything else just earns ERR_CODE_ILLEGAL_ARG:
// if (pin == 0 || (pin >= 100000 && pin <= 999999))
// So: six digits NOT starting with zero, or 0. "012345" is refused.
//
// 0 does NOT mean "no PIN". It clears the stored value and the node
// falls back to its compiled BLE_PIN_CODE — 123456 on a board with no
// screen, and a fresh random PIN per session on a board with a display
// to show it on (see the _active_ble_pin block in MyMesh::begin).
//
// A CHANGE NEEDS A REBOOT. The handler writes _prefs.ble_pin and calls
// savePrefs(), but the PIN in force is _active_ble_pin, computed once
// during startup and never updated afterwards.
//
// READABLE: RESP_CODE_DEVICE_INFO carries it at bytes [4..7]
// memcpy(&out_frame[i], &_prefs.ble_pin, 4)
// — that is the CONFIGURED value, which after a set and before a
// reboot is not the one the radio is actually pairing with.
CMD_SET_PATH_HASH_MODE (61) [1] 0 | [1] mode(0..3) // hop-hash width = mode+1 bytes
CMD_SET_FLOOD_SCOPE (54) [1] 0 [| [16] scope] // scope = SHA256("#region")[:16]; omit=reset
CMD_SEND_TRACE_PATH (36) u32 tag | u32 auth | [1] flag | payload
flag = hop-hash width, encoded: 1 byte→0, 2 bytes→1, ≥3 bytes→2
(the receiver derives width back as `1 << (flag & 0x03)`)
payload = the ROUTE to trace, hop hashes in path order. NOT optional:
a trace with no route is answered with RESP_CODE_ERR.
CMD_SEND_TELEMETRY_REQ (39) [3] reserved | [32] pubkey?
CMD_SEND_BINARY_REQ (50) [32] pubkey | payload // payload[0]=req_type (see §11)
CMD_SEND_ANON_REQ (57)
[32] pubkey | [1] req_type | [1] enc_path_len | reply_path
enc_path_len = ((hash_width-1) << 6) | (hop_count & 0x3F)
reply_path = the route the ANSWER takes, reversed hop-by-hop
req_type 0x01 = regions. Reply arrives as PUSH_CODE_BINARY_RESPONSE
[1] reserved | u32 tag (= the RESP_CODE_SENT ack hash) | body
body = [4] header | comma-separated NUL-padded UTF-8 names
A node with NO named regions answers with a single '*' (0x2a) —
that is an answer, not silence. Verified 2026-08-01:
TX 39 <pubkey> 01 40 // width 2, 0 hops
RX 06 00 08896e6a 24090000 // SENT, est 2340 ms
RX 8c 00 08896e6a 008c6e6a 2a 00 00 … // body '*'
CMD_SEND_CONTROL_DATA (55) payload // discovery: [ (0x8<<4)|prefixOnly ][ type_mask ][ u32 tag ][ u32 since ]
The one-line summary above used to read [1] flag | payload?, which is
true and useless: it names the fields without saying what goes in them.
Reading it alone produces a trace the radio accepts and no node answers.
The details below are from live captures against a companion radio
(Galaxy A42 + MeshCore-Blue, 2-byte hop hashes), cross-checked against
the reference client's lib/screens/path_trace_map.dart.
flag carries the hop-hash width. It is not a bitfield of options.
The encoding is 1 byte→0, 2 bytes→1, ≥3 bytes→2, and the receiving
side derives the width back out as 1 << (flag & 0x03). Sending a
hardcoded 0 on a 2-byte mesh produces a packet the radio will accept,
transmit, and never get an answer to.
payload is the route, and it is required. It is the hop hashes of
the path being traced, width bytes each, in path order. The reference
client sends a single 0x00 when it has no route; a companion radio
answers that with RESP_CODE_ERR. There is nothing to trace on a
direct contact — no intermediate node exists to report — so a client
should refuse locally rather than spend airtime being told no.
Path direction is genuinely ambiguous. The reference client exposes
reversePathAround and flipPathAround as user-facing toggles
rather than committing to one order, which is itself the finding: try
both and use whichever answers.
auth is 0 in every observed request.
Captured exchange — a 2-hop route (b389 → c985), accepted:
TX 24 9e6ba8bf 00000000 01 b3 89 c9 85
RX 06 00 9e6ba8bf de0e0000 // RESP_CODE_SENT, airtime estimate 3806 ms
The same request with no route, refused:
TX 24 5e32afbf 00000000 01 00
RX RESP_CODE_ERR
Timing. RESP_CODE_SENT carries the radio's own airtime estimate
for the round trip (u32 ms, 0x0ede = 3806 above). Wait on that plus
grace rather than a fixed timeout — a fixed one makes a dead trace and
a slow one indistinguishable.
Resolved 2026-08-01: it works. The earlier silence was the route, not the protocol. Traced along a one-hop route through a node heard seconds earlier, the reply came back in about a second:
TX 24 4c0cf7bf 00000000 01 b3 89 // 1 hop, width 2
RX 06 00 4c0cf7bf 900a0000 // SENT, est 2704 ms
RX 89 00 02 01 4c0cf7bf 00000000 b3 89 2b 25 // TRACE_DATA
Reply layout (PUSH_CODE_TRACE_DATA): [1] reserved | [1] path_len | [1] flags | u32 tag | u32 auth | path | per-hop SNR, SNR in quarter-dB
signed (0x2b = 10.75 dB, 0x25 = 9.25 dB). The tag echoes the
request's.
The earlier failures were against a route whose far end had last been heard 24 hours before. A trace has to traverse the whole route and return, so one dead node anywhere along it yields exactly the silence observed — which is why the client should say "check every node on this route has been heard recently" rather than implying a fault.
Fixed 148-byte record (offsets from byte 0 = code):
[1..32] pubkey (32)
[33] type // 1=chat, 2=repeater, 3=room, 4=sensor
[34] flags // bit0 favorite, bit1 tele_base(+battery), bit2 tele_loc, bit3 tele_env
[35] path_len
[36..99] path (64, zero-padded)
[100..131] name cstr(32)
[132..135] timestamp u32
[136..139] lat i32 (/1e6)
[140..143] lon i32 (/1e6)
[144..147] last_modified u32
Validation: reject all-zero (or mostly-zero, >16/32) pubkeys and all-non-printable names.
[0] code
(v3 only) [1..3] snr + reserved (skip 3)
[+0..5] sender pubkey prefix (6)
[+6] path_len (skip)
[+7] txt_type
[+8..11] timestamp u32
[+12..15] signature (4) // only if txt_type indicates signed ((type>>2)==2 or type==2)
text…\0
Text body is "<sender_name>: <message>" — the sender name is unauthenticated
(see §12). channel_idx identifies the slot.
[1] channel_idx
[2..33] name cstr(32)
[34..49] psk (16)
After the push header ([0]=0x88, [1]=snr/4, [2]=rssi), the raw packet is:
[1] header = (payload_ver << 6) | (payload_type << 2) | route_type
route_type: bits0-1 (flood/direct transport → 4 extra bytes follow)
payload_type: bits2-5 (§10)
payload_ver: bits6-7
[4] transport bytes // present only when route_type is flood or direct
[1] path_len_enc = ((hash_width-1) << 6) | (hop_count & 0x3F)
[..] path = hop_count * hash_width bytes (each hop = hash_width-byte prefix of a pubkey)
[..] payload // interpretation per payload_type
This is the only place the full route appears. The companion frames for a received
message (§8) carry path_len and nothing more — a hop COUNT — so "which repeaters
carried this" is answerable only from the RX log. The path is in TRAVEL order: hop 0 is
the repeater nearest the SENDER, the last hop is the one that reached this node. ⚠ It is
therefore the reverse of a stored out-path; reverse it hop-by-hop before pinning it as a
route to reply on.
Correlating an RX-log packet with the message it carried:
- Group text is exact — the client decrypts the GRP_TXT payload itself, so the packet and the message are the same object.
- Direct text must be inferred — a TXT_MSG payload is encrypted to the recipient's
identity key, so the raw packet and the decrypted message arrive separately. They share
the sender's key prefix (via
src_hashbelow) and the hop count. Match on both, within a time window, and only when exactly one packet fits.
0x00 REQ 0x04 ADVERT 0x08 PATH 0x0B CONTROL
0x01 RESPONSE 0x05 GRP_TXT 0x09 TRACE 0x0F RAW_CUSTOM
0x02 TXT_MSG 0x06 GRP_DATA 0x0A MULTIPART
0x03 ACK 0x07 ANON_REQ
[32] pub_key | u32 timestamp | [64] signature | app_data
app_data = [1] flags [ i32 lat | i32 lon ]? [ name… ]?
flags: bits0-3 type; 0x10 has_location; 0x80 has_name
Signature = Ed25519 over pub_key ‖ timestamp ‖ app_data — i.e. the whole payload
with the 64-byte signature spliced out. Verify with pub_key as the key. (Confirmed
against michaelhart/meshcore-decoder.) A client MUST verify this before trusting an
advert's name/type/location — an unsigned/forged advert otherwise spoofs identity/GPS.
[1] dest_hash // first byte of the RECIPIENT's public key
[1] src_hash // first byte of the SENDER's public key
[2] mac
[..] ciphertext // encrypted to the recipient's identity key
The prefix layout is from the reference client's own payload-type table ("prefixed with
dest/src hashes, MAC"), which applies equally to REQ (0x00), RESPONSE (0x01) and PATH
(0x08). A companion app never holds the identity key, so the body is opaque to it — but
src_hash is enough to narrow which sender a heard packet belongs to. One byte: it
narrows, it never identifies.
[1] channel_hash // = SHA256(psk)[0]
encrypted…
Channel/group messages use a 16-byte pre-shared key (PSK).
Channel identification. channel_hash = SHA256(psk)[0] (one byte). Multiple channels
can collide on this — try every configured channel whose hash matches, not just the first.
Encrypted blob layout (encrypted after the channel_hash byte):
[2] mac = HMAC_SHA256(key32, ciphertext)[0..1] // only 2 bytes checked
[..] ciphertext = AES-128-ECB( key16 ) over 16-byte blocks
key32 = psk zero-padded/truncated to 32 bytes (HMAC key)
key16 = psk[0..15] (AES key)
Plaintext (after ECB decrypt):
u32 timestamp | [1] txt_type | text cstr // text = "<name>: <message>"; drop if (txt_type>>2)!=0
⚠️ Protocol-inherent weaknesses (cannot be fixed without breaking interop): AES-ECB (identical plaintext blocks → identical ciphertext; block splicing) and a 2-byte MAC (~1-in-65 536 forgery). Encryption is done by firmware; a companion client only decrypts. Do not present channel messages as authenticated.
PSK derivation
Public channel PSK (well-known, world-readable): 8b3387e9c5cdea6ac9e5edbaa115cd72
Hashtag channel PSK: SHA256("#" + name)[0..15] // no secret — enumerable
Community public PSK: HMAC_SHA256(K, "channel:v1:__public__")[0..15]
Community hashtag PSK: HMAC_SHA256(K, "channel:v1:" + normalize(name))[0..15]
normalize = strip leading '#', lowercase, trim
Community ID (public): SHA256("community:v1" ‖ K) // K = 32-byte community secret
__public__/hashtag community channels are opaque to non-members (need K); plain
hashtag channels are obfuscation only (anyone can derive the key from the name).
Radio params — the units are ASYMMETRIC, and every name in the ecosystem lies about it. Frequency is kHz; bandwidth is Hz.
freq_khz 300 000 – 2 500 000 // 300–2500 MHz. 910.525 MHz → 910525
bw_hz 7 000 – 500 000 // 7–500 kHz. 62.5 kHz → 62500
sf 5–12, cr 5–8
Resolved 2026-08-02 against the reference client's sender, after a radio rejected a US/Canada preset sent as 910 525 000:
- send:
final freqHz = (freqMHz * 1000).round()— MHz×1000 is kHz, whatever it is called; - read back:
_frequencyController.text = (currentFreqHz / 1000.0).toStringAsFixed(3)to display MHz; - and three lines under the send,
validRepeatFreqsKHz = {433000, 869000, 918000}is compared against that samefreqHzvariable.
Bandwidth really is Hz — the reference's LoRaBandwidth enum is explicit (62.5 kHz →
62500). Do not "correct" one to match the other.
The range above was recorded here correctly from the start and is by itself decisive (300 000 Hz is not a LoRa band); it was annotated "firmware uses Hz here" anyway, and that guess propagated into the code. When a range and a field name disagree, believe the range.
Binary request types (CMD_SEND_BINARY_REQ payload [0]):
0x01 get_status, 0x02 keep_alive, 0x03 get_telemetry, 0x05 get_access_list,
0x06 get_neighbours. Telemetry payload: [0x03,0,0,0,0] (byte1 = inverse permission mask).
0x06 get_neighbours is 11 bytes and the node reads every one of them. Corrected
2026-08-07 from firmware v1.16.0 (examples/simple_repeater/MyMesh.cpp:279-294), after
shipping a one-byte request — the type alone — which the node answered with a table
header and zero rows. It was not paging and no retry would have helped: count came from
whatever followed our payload.
[0] 0x06
[1] request_version — only 0 is implemented; anything else is silently ignored
[2] count u8 entries to return
[3..4] offset u16 LE index into the sorted list
[5] order_by u8 0 newest→oldest, 1 oldest→newest, 2 strongest→weakest, 3 weakest→strongest
[6] prefix_len u8 bytes of pub key per entry; clamped to PUB_KEY_SIZE (32)
[7..10] nonce 4 random bytes, for packet-hash uniqueness
Reply body, after the binary-response header:
u16 total neighbours the node knows (its whole table, not this page)
u16 count entries in THIS reply
count × { [prefix_len] key_prefix | u32 heard_seconds_ago | i8 snr_quarters }
Two traps in the reply. heard_seconds_ago is elapsed time, now - heard_timestamp
on the node's own clock — not an epoch stamp. And the entry width is prefix_len, the
value we sent, so a parser must be handed the request that produced the bytes rather
than assuming a constant.
The node's results_buffer is 130 bytes, so a page holds 130 / (prefix_len + 5)
entries however large a count is asked for; over-asking just truncates and then looks
like paging.
What the table contains is much narrower than "neighbours" suggests: only other
repeaters, heard at zero hops. onAdvertRecv calls putNeighbour only when
getPathHashCount() == 0, the packet is not a Share, and the advert type is
ADV_TYPE_REPEATER (MyMesh.cpp:641). Companions, room servers, sensors and trackers
never appear, and a relayed advert never counts — so a repeat-enabled room server is
still excluded, because it advertises ADV_TYPE_ROOM
(examples/simple_room_server/MyMesh.cpp:119). A repeater commonly reports two or three
neighbours; that is the mesh, not a cap (MAX_NEIGHBOURS is 50 on every shipped variant).
Room-server firmware has no neighbour table and no 0x06 handler at all, so it cannot
answer this request.
Who may ask. A binary request reaches handleRequest() only for a sender already in the
node's ACL (onPeerDataRecv → acl.getClientByIdx, MyMesh.cpp:663-677), so it takes a
login — but 0x06 carries no isAdmin() gate, unlike 0x05 get_access_list which is
written payload[0] == REQ_TYPE_GET_ACCESS_LIST && sender->isAdmin() (MyMesh.cpp:262 vs
:276). A guest session is enough to read the neighbour table. And a login with a blank
password is the ordinary way to get one: handleLoginReq first looks the sender up in the
ACL, and failing that compares the empty string against the admin password and then the guest
password — which ships empty (MyMesh.cpp:90-107). Read from firmware v1.16.0+ on
2026-08-24. Note this is the ONE thing about the map's neighbour links not yet confirmed
against a live repeater here.
heard_seconds_ago cannot be stored on its own. It is elapsed time at the instant the
node answered, so a persisted copy needs the local clock reading that produced it or it goes
on reporting the same age forever. See presentation/NeighbourLinks.kt.
Control/discovery (CMD_SEND_CONTROL_DATA): subtypes 0x08 DISCOVER_REQ /
0x09 DISCOVER_RESP; discover payload [(0x08<<4)|prefix_only][type_mask][u32 tag][u32 since].
Auto-add flags (CMD_SET_AUTO_ADD_CONFIG): 0x01 overwrite-oldest, 0x02 chat,
0x04 repeater, 0x08 room, 0x10 sensor.
LoRa airtime / ACK timeout — Semtech SX127x airtime formula; direct-path timeout
500ms + (airtime*6 + 250ms)*(hops+1), flood 500ms + 16*airtime. Used to time out ACKs.
Contact-share URIs (QR codes). Two forms exist in the wild; a client should emit the first and accept both:
- Contact card — what the mainstream MeshCore app emits and scans:
Spaces are
meshcore://contact/add?name=<pct-encoded UTF-8>&public_key=<64 hex, UPPER>&type=<adv type>%20(not+);typematches the ADVERT type byte (1chat,2repeater,3room,4sensor). Verified byte-for-byte against a QR that app produced. - Advert blob —
meshcore://<hex>, the exported advert payload. Used by MeshCore Open and by early versions of this client. Import withCMD_IMPORT_CONTACT.
The two differ in what they prove. Form 2 is the signed advert, so the radio verifies it on
import. Form 1 is unsigned — name, key and type are plain query parameters anyone can
mint — so it cannot be imported through the verify path; it becomes a contact only via
CMD_ADD_UPDATE_CONTACT (path unknown, so flood until a route is learned), and the client
must ask the user to confirm the key rather than adding it silently. Treat the name as
display text in both forms; only the public key identifies anyone.
-
Mesh settings —
meshcore://radio/set, defined by this project, not seen elsewhere. Added 2026-08-09 after confirming nobody has one: the firmware has no QR at all, the mainstream app ships only forms 1 and 2 above pluschannel/add, and MeshCore Open's "community" code is a JSON blob ({type:"meshcore_community",v:1,name,k}) carrying a 32-byte channel secret — not radio parameters. Expect no interop yet.meshcore://radio/set?v=1&name=<pct>&freq=<MHz>&bw=<kHz>&sf=<5-12>&cr=<5-8>&hash=<0-2>[®ion=<pct>]Units are the human ones (MHz, kHz), converted to the wire's kHz/Hz on import.
hashispath.hash.mode; absent means mode 0, since meshes predate the setting.regionis the flood scope and is optional.Deliberately absent: TX power and channel keys. Every field present is "match this or you are not on the mesh"; TX power is not — it is the legal limit where the scanner is standing and what their hardware can do, so shipping it propagates one person's jurisdiction to everyone who scans. A PSK would turn a config code into a secret needing keystore handling and log redaction;
channel/addalready exists for that.Nothing in it is authenticated, and it is the most consequential code the app accepts: a bad contact card adds a row, whereas these values decide whether a node is on a mesh at all and which frequency it transmits on. Ranges are enforced at parse (
ShareUri.decodeRadioConfig) so an impossible value cannot reach a radio, and it must always be shown and confirmed — never applied on scan.docs/settings-qr/index.html(published at https://thatsfguy.github.io/meshcore-mobile-app/settings-qr/) generates them offline.
Note also that QR codes rendered by dark-mode apps are inverted (light modules on a dark
field). ZXing rejects those unless DecodeHintType.ALSO_INVERTED is set — worth doing, or
half the codes in circulation won't scan.
Not a MeshCore protocol at all: it is Nordic's legacy (nRF5 SDK 11) DFU, spoken by the
Adafruit nRF52 bootloader, which MeshCore nRF boards ship. Documented here because the
companion exposes it on the same BLE connection as the NUS, so a client meets it whether or
not it implements it. ESP32 boards have no BLE path — their start ota raises a WiFi
hotspot and serves an upload form at http://192.168.4.1/update.
Sources: ble_dfu.c/ble_dfu.h
and bootloader_dfu/dfu_transport_ble.c in the bootloader; BLEDfu.cpp in
Adafruit_nRF52_Arduino; src/helpers/nrf52/SerialBLEInterface.cpp in MeshCore
(PR #2323, companion v1.15+).
| Service | Address | Name | |
|---|---|---|---|
| App mode (MeshCore running) | 00001530-1212-EFDE-1523-785FEABCD123 |
the radio's own | its MeshCore name |
| Bootloader (after the jump) | same UUID | LSB + 1, wrapping (addr.addr[0] += 1) |
AdaDFU, or <board>_OTA on MeshCore/OTAFIX builds |
Characteristics: control point …1531 (write + notify), packet …1532 (write without
response), revision …1534. Both are registered with SECMODE_ENC_WITH_MITM, so the link
must be bonded and authenticated — the same requirement the NUS already has.
On a repeater, room server or sensor the CLI's start ota
(CommonCLI.cpp → NRF52Board::startOTAUpdate) starts a BLE stack inside the running
firmware. It does not reset, and the node keeps repeating:
Bluefruit.begin(1, 0);
Bluefruit.setName(ota_name); // "RAK4631_OTA", "T114_OTA", "Meshtiny OTA" …
bledfu.begin(); // the app-mode DFU service
Bluefruit.Advertising.start(0); // forever
sprintf(reply, "OK - mac: %02X:%02X:…", mac_addr[5], …, mac_addr[0]);Three consequences for a client:
- The node is in app mode, not the bootloader. It has to be sent the jump of §11a.2 before there is anything to flash. It reboots at that point and not before, so a command sent and never acted on costs nothing.
- The reply carries the address it is advertising on, printed most-significant octet first — the same order a phone shows. Use it as-is; the bootloader's +1 has not happened yet.
- No bond is needed. Unlike the companion's, this
bledfuis registered withoutsetPermission(SECMODE_ENC_WITH_MITM).
The advertised name is the same shape in both modes, so a name match alone cannot tell an
advertising repeater from a bootloader. Note also that the app-mode jump (0x01) and the
bootloader's start-DFU (0x01 0x04) share their first byte — BLEDfu.cpp tests only
data[0] == 1 — so a client that mistakes one peer for the other will trigger the reboot
by accident and then lose the connection mid-sequence.
A companion reports its board and firmware in RESP_CODE_DEVICE_INFO. A repeater does not,
and the CLI is the only way to ask (CommonCLI.cpp):
| command | reply |
|---|---|
board |
getManufacturerName(), e.g. ProMicro DIY — the same string a companion reports |
ver |
"%s (Build: %s)", e.g. v1.16.0-07a3ca9 (Build: 06-Jun-2026) |
Worth asking before the node enters update mode, and worth keeping: from the moment it does, it is off the mesh and can no longer answer. A client that resolves the board only on demand cannot pick firmware for the one node that most needs it.
Enable notifications on the control point first — BLEDfu.cpp rejects an unsubscribed
write with ATTERR_CPS_CCCD_CONFIG_ERROR (0xFD) before looking at the payload, and a bond
made before the node carried this service produces the same error. Then write 0x01. The
radio saves the bond keys for the bootloader, disconnects, sets GPREGRET 0xB1 and resets.
The disconnect is the acknowledgement; there is no reply.
Control-point op codes (ble_dfu.c), each answered by a [0x10, procedure, result]
notification where result 1 is success (2 invalid state, 3 not supported, 4 data
size, 5 CRC, 6 failed):
| Op | Meaning | Written to the packet characteristic |
|---|---|---|
1 + image type (4 = app) |
Start DFU | exactly 12 bytes: SoftDevice, bootloader and application sizes, u32 LE. Anything else is answered NOT_SUPPORTED. |
2 0x00 / 2 0x01 |
Receive init params / done | the .dat init packet |
8 + u16 LE |
Request packet-receipt notifications | — |
3 |
Receive firmware | the .bin, chunked (20 bytes fits the 23-byte ATT default; OTAFIX negotiates larger) |
4 |
Validate | — |
5 |
Activate and reset | — |
6 |
System reset — abandon and boot the existing image | — |
Every N packets the peer notifies [0x11, u32 LE bytes received]. That count is the only
in-band check that both sides agree about how much arrived, and it is worth enforcing: the
image hash is not checked until the end, by which time a stock bootloader has already
erased the application.
An adafruit-nrfutil zip: manifest.json naming a .dat and a .bin. The init packet
(dfu_types.h, dfu_init_packet_t) is device type u16, device revision u16, application
version u32, a u16 count of accepted SoftDevice IDs and that many u16s, then the extended
block — a CRC-16 on unsigned builds, or length/SHA-256/ECDSA-P256 on signed ones.
The package does not identify the board. src/dfu_init.c compares the device type
against a single ADAFRUIT_DEVICE_TYPE (0x0052) for every nRF52832/nRF52840 alike, so a
RAK 4631 image and a T114 image are indistinguishable to the bootloader and to any client.
Only the filename says. A client must make a human confirm the board rather than infer it.
Identity. Ed25519. Public key 32 bytes. MeshCore uses an expanded 64-byte private
key: SHA512(seed) with standard clamping h[0]&=248; h[31]&=63; h[31]|=64 (keeps the
scalar in the large subgroup; required by firmware repeater key validation). Generate the
seed with a CSPRNG. A vanity-prefix search (regenerate until pubkey matches a hex
prefix) is supported.
prv.key over the CLI — the 64 bytes are not optional (added 2026-08-23). This
paragraph was right from the start and the app still shipped a rekey that no node would
accept, so the wire form is now written out with its citations:
set prv.key <128 hex chars> // PRV_KEY_SIZE = 64 bytes, MeshCore.h:9
get prv.key // replies "> <128 hex chars>"
Utils::fromHex(prv_key, PRV_KEY_SIZE, &config[8])—helpers/CommonCLI.cpp:510-512— andfromHexopens withif (len != dest_size*2) return false(Utils.cpp:206-208). A 64-character seed is refused on length, before a digit is read:Error, bad key.- The layout is
[clamped scalar (32) || nonce prefix (32)]. The firmware derives the public key from the scalar half alone (ed25519_derive_pub,Identity.cpp:69, reached throughLocalIdentity::readFrom(src, PRV_KEY_SIZE)), so sending the 64 bytes is self-sufficient — the public key does not travel with it. - Ground truth for that layout is in the firmware itself:
LocalIdentity::validatePrivateKey(Identity.cpp:67-90) carries a known-goodtest_client_prv/test_client_pubpair, and the scalar half of that private key times the base point is that public key. - The firmware refuses some valid keys.
if (pub[0] == 0x00 || pub[0] == 0xFF) return false(Identity.cpp:71-72) — about 1 key in 128. A generator that ignores this produces an occasional rekey that fails for no visible reason. - On success the node replies
OK, reboot to apply! New pubkey: <hex>(CommonCLI.cpp:517-519). It keeps the old identity until it restarts. get prv.keyis serial-only. The branch is guarded bysender_timestamp == 0(CommonCLI.cpp:832, commented "from serial command line only"), so a remote admin session can never read it, however good the link is. The reply is the same 64 bytes (LocalIdentity::writeTo,Identity.cpp:128-138).
Applying it: a reboot is never confirmed, and the node that comes back is quiet (added 2026-08-23). Three firmware facts that together decide what a client can honestly tell a user after a rekey:
rebootsends no reply.else if (memcmp(command, "reboot", 6) == 0) { _board->reboot(); // doesn't return }(CommonCLI.cpp:185-186) — the reply buffer is never written, so nothing is sent before the restart. Contrastadvertthree lines below, which passes a 1500 ms delay explicitly commented "give CLI response time to be sent first": the firmware knows the difference and does not try here.- There is no ACK either. A repeater acknowledges a text message only when
flags == TXT_TYPE_PLAIN, commented "for legacy CLI" (examples/simple_repeater/MyMesh.cpp:717-731). Companion clients send commands asTXT_TYPE_CLI_DATA, whose only answer is the reply datagram — and a reboot has none. So a client has no protocol evidence at all that a reboot was received: not a reply, not a delivery confirmation. The only positive evidence available is indirect — the new identity answering something afterwards. - The boot advert is zero-hop.
the_mesh.sendSelfAdvertisement(16000, false)(examples/simple_repeater/main.cpp:119) — 16 seconds after start, andfalseis not flooded, so only nodes in direct radio range hear it. A repeater reached over hops is not one of them. The next advert that propagates is the flood advert, defaultflood_advert_interval = 47hours (MyMesh.cpp:904).
The consequence for a client: after set prv.key the reply's New pubkey: hex is the only
copy of the node's new identity available for up to two days. Write it into the contact
list rather than waiting for an advert — the alternative is a repeater that has silently
become a stranger.
A node's on-air name is a PREFIX of its public key, not the key. Identity::copyHashTo
is literally memcpy(dest, pub_key, len) // hash is just prefix of pub_key
(Identity.h:20-26). Two widths matter, and both are leading bytes:
- destination/source hash — always 1 byte (
#define PATH_HASH_SIZE 1,MeshCore.h:18;Mesh.cpp:443-444, 462-463), on every direct packet, for every node type; - path hash —
path_hash_mode + 1bytes (1–3), appended by each forwarding repeater and matched on the way back (Mesh.cpp:89, 345-349), with the width carried in the top two bits ofpath_len(Mesh.cpp:449).
Who chooses the width: the ORIGINATOR, per packet — not the repeaters. This is the part that surprises people, and it is worth being exact about because a client that assumes otherwise misreads every route it did not send.
- The sending node stamps its own configured width into the packet:
Mesh::sendFlood(packet, delay, path_hash_size)callssetPathHashSizeAndCount(path_hash_size, 0)(Mesh.cpp:637-649), and refuses anything outside 1–3 even though the two-bit field could encode 4. - Every repeater then honours the packet's width, never its own setting. Appending on a
flood is
self_id.copyHashTo(&packet->path[n * packet->getPathHashSize()], packet->getPathHashSize())(Mesh.cpp:349); matching on a direct route isself_id.isHashMatch(pkt->path, pkt->getPathHashSize())(Mesh.cpp:89). Both readgetPathHashSize()off the packet in hand.
So a mesh does not have "a" hop-hash width. Widths are per-packet and mixed traffic is normal: on a busy channel you will see 1-, 2- and 3-byte paths side by side, one per originator, and the width on a message tells you about that sender's configuration and nothing about the repeaters that carried it. Observed on the author's mesh, 2026-08-24.
The width is not free. (n + 1) * size <= MAX_PATH_SIZE (64) gates the append
(Mesh.cpp:347) and the count field is 6 bits, so the ceiling is 63 hops at 1 byte, 32 at
2, 21 at 3 — past it a repeater silently stops appending, and the route stops being
recoverable. Against that, 1 byte is 8 bits of name for the whole mesh.
So two repeaters whose public keys share their leading bytes are one node as far as a stored route is concerned. That is a keygen constraint, not a display detail: a replacement identity has to be checked against the prefixes already in use at the mesh's configured width — and, at 1 byte per hop, there are only 254 usable names, so a busy mesh runs out and the client has to decide which node to collide with rather than whether to.
Things the client is responsible for (learned the hard way in the MeshCore Open audit):
- Verify advert Ed25519 signatures (§9) before importing/updating a contact. Skipping this = identity/GPS spoofing.
- Never trust the channel sender name (§8) for identity, contact-record mutation, or self-echo suppression — it is attacker-controllable.
- Don't infer delivery from malformed frames — only mark sent/delivered on a
well-formed
RESP_CODE_SENT/PUSH_CODE_SEND_CONFIRMED. - Guard every parse — a short/truncated frame from a hostile peer must not crash the RX path.
- Store secrets in the platform keystore/keychain, not plaintext prefs: repeater login passwords, channel PSKs, community secrets, and the device identity key.
- TCP transport is plaintext & unauthenticated — the
CMD_SEND_LOGINpassword and all message text cross the wire in the clear. Warn the user; prefer BLE/USB on untrusted networks. - Channel crypto is weak by protocol (ECB + 2-byte MAC) — present channels as obfuscated, not secure.
Mirror the reticulum-mobile-app split: keep the transport layer (BLE NUS, TCP, USB
serial, reconnect supervisors, foreground service) and put everything above in
commonMain:
transport/ BLE-NUS · TCP · USB-serial · framing (COBS) · reconnect
protocol/
Frames.kt command builders + response/push parsers (§4–§8)
Codes.kt command/response/push code enums (§4–§6)
Advert.kt advert parse + Ed25519 verify (§9)
ChannelCrypto.kt PSK derivation + AES-ECB/MAC decrypt (§10)
Identity.kt Ed25519 keygen (expanded key), sign/verify (§12)
model/ Contact · Channel · Message · Telemetry
This is the seam where a MeshCore protocol layer drops in beside (or in place of) the Reticulum/LXMF one.
⚠ Nothing below is in the firmware. Reactions are ordinary text messages that clients agree to render differently, so this section documents what is on the air rather than what the protocol defines. It is the one place in this file where a client is the citation, because a client is all there is to cite.
MeshCore has no reaction field, and neither does the firmware (issue #880 proposes one and is still open), so every client that offers reactions invents a text convention and hopes the others read it. At least two are live:
| Client | Wire format | Target hash |
|---|---|---|
| MeshCore Open (and forks) | r:HHHH:II |
Dart String.hashCode, 16 bits |
| MeshCore One | {emoji}@[{sender}]\n{hash} |
SHA-256(text + LE timestamp), 40 bits |
This app reads both and sends MeshCore Open's. Being open about why, since it is a choice
and not a technical verdict: MeshCore One's hash is the better design — reproducible from any
language, where ours reimplements another runtime's hashCode by hand — but a reaction only lands
if the people who see it run a client that reads the same bytes. A survey of the local mesh
(2026-08-23) came back roughly 6:1 in favour of MeshCore Open, so that is what we emit.
That is a headcount, not a principle. If the balance shifts, this switches to the winning format. Whichever convention a reaction arrives in, an unmatched one is rendered as "reacted to an earlier message" rather than as raw wire text.
For an implementer: read both, and treat an unmatched reaction as a reaction to an unknown
message rather than rendering the raw wire text. The Dart String.hashCode reimplementation is
the fragile half — it copies another runtime's internal hash by hand, and there are no published
vectors to check it against (their own tests assert only that the hash is deterministic and four
hex digits), so ours is pinned against captured traffic instead.