Skip to content

Latest commit

 

History

History
990 lines (826 loc) · 51.2 KB

File metadata and controls

990 lines (826 loc) · 51.2 KB

MeshCore Companion Protocol — Implementation Spec

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").


1. Scope

Two distinct byte formats a client deals with:

  1. 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.
  2. 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.

2. Transports & framing

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

3. Frame model

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.


4. Command codes (client → radio)

Transcribed from firmware 2026-08-06examples/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.


5. Response codes (radio → command)

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.)


6. Push codes (radio → client, async; 0x80+)

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 returns uint32_t& expected_ack, so RESP_CODE_SENT and PUSH_CODE_SEND_CONFIRMED are untouched and a client matching on the u32 needs no change. The reason for the widening is retries: SimpleMeshTables dropped 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 distinct attempt per 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() calls logRxRaw() on the line after recvRaw() — before tryParsePacket, 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 as RESP_CODE_CONTACT) | | 0x8B | PUSH_CODE_TELEMETRY_RESPONSE | Telemetry (Cayenne LPP) | | 0x8C | PUSH_CODE_BINARY_RESPONSE | Response to CMD_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 to CMD_SEND_PATH_DISCOVERY_REQ | | 0x8F | PUSH_CODE_CONTACT_DELETED | A contact was evicted (storage full) | | 0x90 | PUSH_CODE_CONTACTS_FULL | Contact storage is full |


7. Key companion frame layouts

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 ]

Path trace in detail (CMD_SEND_TRACE_PATH, corrected 2026-08-01)

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 (b389c985), 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.


8. Contact, message & channel-info layouts (radio → client)

Contact (RESP_CODE_CONTACT = 3, and PUSH_CODE_NEW_ADVERT = 0x8A)

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.

Direct message (RESP_CODE_CONTACT_MSG_RECV = 7 / …_V3 = 16)

[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

Channel message (RESP_CODE_CHANNEL_MSG_RECV = 8 / …_V3 = 17)

Text body is "<sender_name>: <message>"the sender name is unauthenticated (see §12). channel_idx identifies the slot.

Channel info (RESP_CODE_CHANNEL_INFO = 18)

[1]      channel_idx
[2..33]  name cstr(32)
[34..49] psk (16)

9. Over-the-air packet (inside PUSH_CODE_LOG_RX_DATA) — validate against firmware

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_hash below) and the hop count. Match on both, within a time window, and only when exactly one packet fits.

Payload types

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

ADVERT payload (0x04) — signed node identity

[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.

TXT_MSG payload (0x02) — direct message (NOT decryptable by a companion client)

[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.

GRP_TXT payload (0x05) — channel message (see §10 to decrypt)

[1] channel_hash        // = SHA256(psk)[0]
encrypted…

10. Channel cryptography

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).


11. Misc encodings

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 same freqHz variable.

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 (onPeerDataRecvacl.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:

  1. Contact card — what the mainstream MeshCore app emits and scans:
    meshcore://contact/add?name=<pct-encoded UTF-8>&public_key=<64 hex, UPPER>&type=<adv type>
    
    Spaces are %20 (not +); type matches the ADVERT type byte (1 chat, 2 repeater, 3 room, 4 sensor). Verified byte-for-byte against a QR that app produced.
  2. Advert blobmeshcore://<hex>, the exported advert payload. Used by MeshCore Open and by early versions of this client. Import with CMD_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.

  1. Mesh settingsmeshcore://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 plus channel/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>[&region=<pct>]
    

    Units are the human ones (MHz, kHz), converted to the wire's kHz/Hz on import. hash is path.hash.mode; absent means mode 0, since meshes predate the setting. region is 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/add already 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.


11a. Firmware update over BLE (nRF52) — read from firmware + bootloader source

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+).

11a.1 The two peers

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.

11a.1a start ota does not reboot anything

On a repeater, room server or sensor the CLI's start ota (CommonCLI.cppNRF52Board::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:

  1. 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.
  2. 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.
  3. No bond is needed. Unlike the companion's, this bledfu is registered without setPermission(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.

11a.1b Asking a node what it is

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.

11a.2 App mode: the buttonless jump

Enable notifications on the control point firstBLEDfu.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.

11a.3 Bootloader: the transfer

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.

11a.4 The package

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.


12. Identity & security notes for the client

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 — and fromHex opens with if (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 through LocalIdentity::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-good test_client_prv / test_client_pub pair, 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.key is serial-only. The branch is guarded by sender_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:

  • reboot sends 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. Contrast advert three 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 as TXT_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, and false is 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, default flood_advert_interval = 47 hours (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 + 1 bytes (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 of path_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) calls setPathHashSizeAndCount(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 is self_id.isHashMatch(pkt->path, pkt->getPathHashSize()) (Mesh.cpp:89). Both read getPathHashSize() 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_LOGIN password 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.

13. Suggested module boundary (for a KMP client)

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.


14. Reactions — a convention, not a protocol feature

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.