MeshPRS

MeshPRS Phase 0 findings

Verified facts that the MeshPRS design relies on. These notes are informative; the spec (spec/MeshPRS-Spec.md) is normative. Anything not confirmed is marked UNVERIFIED.

MeshCore source: github.com/meshcore-dev/MeshCore, commit 81dbae592ffad40b4212bc0b37e82b38da08f4bc (2026-10-15). The tags repeater-v1.18.0, companion-v1.18.0 and room-server-v1.18.0 point at this same commit, so the 1.18.0 build running on the SEQ mesh is identical to main. Paths are relative to the MeshCore root. APRS spec: APRS 1.2 draft C (APRS12c.pdf, WB2OSZ, github.com/wb2osz/aprsspec). It merges APRS101 with the 1.1 and 1.2 addenda and keeps APRS101’s chapter and page numbers, so “Ch14 p.71” below is the same in both. aprs.org itself is behind Cloudflare. Fetched: 2026-10-29.


1. MeshCore packet format

1.1 Header and framing

On the wire a packet is [header 1][transport_codes 4, only for route types 0 and 3][path_len 1][path][payload] (src/Packet.cpp:52-63, src/Dispatcher.cpp:148-189).

Header byte 0bVVPPPPRR (src/Packet.h:8-12):

Bits Field Notes
0-1 Route type  
2-5 Payload type  
6-7 Payload version Only V1 (0) exists. A higher version is dropped on receive (Dispatcher.cpp:152)

path_len byte (Packet.h:84-88):

Bits Field Notes
0-5 Hop count 0-63
6-7 Hash size − 1 1, 2 or 3 bytes; the value 11 is rejected

The path takes count × size bytes and must be ≤ 64 (MAX_PATH_SIZE). SEQ uses 2-byte hashes (MBUG path.hash.mode 1).

The source and destination hashes and the channel hash inside the payload are always 1 byte, whatever the path hash size (PATH_HASH_SIZE 1, src/MeshCore.h:18).

Transport codes are only present on the TRANSPORT_* route types.

1.2 Limits (src/MeshCore.h)

Constant Value
MAX_PACKET_PAYLOAD 184
MAX_PATH_SIZE 64
MAX_TRANS_UNIT 255
CIPHER_BLOCK_SIZE 16
CIPHER_MAC_SIZE 2
MAX_ADVERT_DATA_SIZE 32

A payload larger than 184 bytes is dropped on receive (Dispatcher.cpp:180-183).

1.3 Type constants (src/Packet.h:14-32)

Route types

Value Name
0x00 TRANSPORT_FLOOD
0x01 FLOOD
0x02 DIRECT
0x03 TRANSPORT_DIRECT

Payload types

Value Name Value Name
0x00 REQ 0x08 PATH
0x01 RESPONSE 0x09 TRACE
0x02 TXT_MSG 0x0A MULTIPART
0x03 ACK 0x0B CONTROL
0x04 ADVERT 0x0C-0x0E reserved
0x05 GRP_TXT 0x0F RAW_CUSTOM
0x06 GRP_DATA    
0x07 ANON_REQ    

1.4 Maximum application bytes per candidate carrier

Carrier Plaintext layout Max app bytes Source
GRP_DATA data_type u16 LE, data_len u8, blob 165-byte blob MAX_GROUP_DATA_LENGTH = 184−16−3; MeshCore.h:21, BaseChatMesh.cpp:544-555
GRP_TXT ts u32, txt_type u8, "name: text"\0 160 bytes, including the name: prefix MAX_TEXT_LEN 160; BaseChatMesh.h:8, .cpp:518-531
TXT_MSG (DM) ts u32, flags u8, text 160 BaseChatMesh.cpp:453
RAW_CUSTOM raw bytes 184; the companion requires at least 4 Mesh.cpp:590; companion MyMesh.cpp:1563
ADVERT app_data flags, [lat i32, lon i32], [feat1], [feat2], name 32 in total; a name can be 31 bytes, or 23 when lat/lon is included Mesh.cpp:405; AdvertDataHelpers.cpp:4-29

On-air size of a GRP_DATA packet carrying an N-byte blob:

1 (header) + 1 (path_len) + path + 1 (channel hash) + 2 (MAC) + 16·⌈(N+3)/16⌉

Padding means the blob size grows in steps. These are the sizes that use the full space at each step:

Blob N Ciphertext Payload On air with no path yet (sender)
≤ 13 16 19 21
≤ 29 32 35 37
≤ 45 48 51 53
≤ 61 64 67 69

Each repeater a flood passes through adds one path hash: 2 bytes per hop in SEQ.

GRP_TXT length. The 160-byte MAX_TEXT_LEN covers the name: prefix plus the text and excludes the NUL. The NUL is written into the buffer, but the datagram length is 5 + prefix_len + text_len (BaseChatMesh.cpp:519-531). The receiver relies on the zero padding of the last AES block to terminate the string.

LoRa preamble. 32 symbols at SF ≤ 8, 16 above: preambleLengthForSF(sf) { return sf <= 8 ? 32 : 16; } (src/helpers/radiolib/RadioLibWrappers.h:56, applied at RadioLibWrappers.cpp:30). At the MBUG setting of SF8 that is 32 symbols, the figure to use in time-on-air calculations.

The companion serial link carries a received channel-data blob of up to 167 bytes (MAX_CHANNEL_DATA_LENGTH = 176−9, companion MyMesh.cpp:106), so a 165-byte blob fits.

1.5 Encryption (src/Utils.cpp:85-175)

What travels in the clear:

Payload type Clear fields
GRP_TXT / GRP_DATA 1-byte channel hash: the first byte of sha256(PSK), BaseChatMesh.cpp:918
TXT_MSG / REQ / RESPONSE / PATH 1-byte destination hash and 1-byte source hash. The key is the ECDH shared secret
ADVERT The whole packet. It is signed (Ed25519, 64-byte signature) but not encrypted
ACK The whole packet (a 4-byte CRC)
CONTROL The whole packet
RAW_CUSTOM The whole packet: “for applications with custom encryption” (Packet.h:32)

Channel keys:

1.6 GRP_DATA data-type registry (docs/number_allocations.md)

Range Use
0000-00FF MeshCore internal. 0x0000 is reserved and rejected
0100-FEFF Apps. Allocated by pull request, and a working app must be shown first
FF00-FFFF Free for development, no registration needed. 0xFFFF = DATA_TYPE_DEV

Apps that already hold a 0100-FEFF allocation include MeshCore Open, Ripple, MCO Advanced, StreamSensor and Nelos.

1.7 MULTIPART is not general fragmentation

Byte 0 is remaining<<4 | inner_type. The only inner type implemented is ACK, used for the multi-ACK repeats (Mesh.cpp:300-322 and 574-587, with the comment “FUTURE: other multipart types??”).

No MeshCore payload type gives an application fragmentation. MeshPRS must do its own, or avoid needing it.


2. Stock repeater forwarding (the key result)

Mesh::onRecvPacket (src/Mesh.cpp:41) checks the route type first, then the payload type. A direct packet whose path count is greater than 0 is forwarded by a generic next-hop branch that never looks at the payload:

Mesh.cpp:89   if (self_id.isHashMatch(pkt->path, pkt->getPathHashSize()) && allowPacketForward(pkt)) {
Mesh.cpp:102    if (!_tables->wasSeen(pkt)) { _tables->markSeen(pkt); removeSelfFromPath(pkt);
Mesh.cpp:106      return ACTION_RETRANSMIT_DELAYED(0, d);  // Routed traffic is HIGHEST priority

Flood packets reach the payload switch, and only the cases that call routeRecvPacket (Mesh.cpp:344-347) are flooded on:

Mesh.cpp:225  case PAYLOAD_TYPE_GRP_DATA:
Mesh.cpp:226  case PAYLOAD_TYPE_GRP_TXT: { ...
Mesh.cpp:248      action = routeRecvPacket(pkt);      // regardless of whether this node knows the channel
Mesh.cpp:292  case PAYLOAD_TYPE_RAW_CUSTOM: {
Mesh.cpp:293      if (pkt->isRouteDirect() && !_tables->wasSeen(pkt)) { ... onRawDataRecv(pkt);
Mesh.cpp:296      //action = routeRecvPacket(pkt);    don't flood route these (yet)

The repeater’s searchChannelsByHash is the base stub that returns 0 (Mesh.cpp:37). A repeater floods channel packets it cannot decrypt.

2.1 What a stock repeater forwards

Payload Flood / transport flood Direct (this node is next hop)
RAW_CUSTOM Never. A flood RAW_CUSTOM isn’t even handled locally (Mesh.cpp:292-296) Yes (Mesh.cpp:89-106)
GRP_DATA Yes, without decrypting (Mesh.cpp:225-248) Yes
GRP_TXT Yes, the same as GRP_DATA Yes
TXT_MSG Yes, unless the repeater is itself the destination (Mesh.cpp:133-193) Yes
ACK Yes Yes, rebuilt with the optional extra ACKs (Mesh.cpp:93-99, 379)
MULTIPART No (Mesh.cpp:318 is commented out) Only when the inner type is ACK (Mesh.cpp:359-376)
ADVERT Yes, if the signature is valid and hops < flood.max.advert (default 8) Yes; seldom used
TRACE / CONTROL No (the default case, Mesh.cpp:326-328) TRACE yes; CONTROL 0x80 is zero-hop only

2.2 Gates on forwarding

All of these are in allowPacketForward, examples/simple_repeater/MyMesh.cpp:434-459.

Both route types:

Flood only:

Direct only:

2.3 Originating each carrier from a companion

RAW_CUSTOM

GRP_DATA

Flood scope (this settles the disagreement between SA-1 and SA-2)

2.4 Timing, priority and duplicates (inputs to the reliability analysis)

Receive delay

Retransmit delay (MyMesh.cpp:547-554, 893-894)

Queue priority

Airtime budget

Duplicate suppression (src/helpers/SimpleMeshTables.h:9; Packet.cpp:41-50)


3. Existing MeshCore features to reuse

3.1 Positions

Adverts carry the only open (unencrypted) position.

Positions from telemetry

Nothing else

3.2 Telemetry: CayenneLPP

The request is REQ 0x03 GET_TELEMETRY_DATA and the reply is [sender_ts 4][CayenneLPP]. Code: repeater MyMesh.cpp:240-259, room server :181-198, sensor SensorMesh.cpp:148-162, companion MyMesh.cpp:641-680.

What a reply contains:

Who can ask:

Sensor firmware extras:

Every telemetry request and reply is end-to-end encrypted. Kelly already parses LPP (kelly-page/js/meshcore.js:214-229).

3.3 Repeater status, neighbours and discovery

GET_STATUS (0x01)

GET_NEIGHBOURS (0x06)

Open discovery: CONTROL NODE_DISCOVER_REQ (0x80)

3.4 Room servers (examples/simple_room_server)

Storage

Login (MyMesh.cpp:328-354)

Delivery (MyMesh.cpp:1000-1040)

For MeshPRS bulletins

3.5 Structured data in channel text

There is no community convention in the MeshCore source or docs for structured data inside channel text.


4. APRS and APRS-IS rules

4.1 Line format and callsigns (aprs-is.net/Connecting.aspx; aprsc source)

Line format

Callsigns

4.2 q-constructs (aprs-is.net/q.aspx, IGateDetails.aspx)

A client may only generate qAR or qAO.

qAO is the receive-only iGate construct, and q.aspx explicitly names translated, non-APRS sources: “translated packets where the station is not APRS capable (e.g. D-STAR station transmitting GPS strings translated via D-PRS spec to APRS by the IGate).” This is the basis for MeshPRS using qAO.

Path rules

Packets not to gate to IS

aprsc drops NOGATE and RFONLY packets itself.

4.3 Login and testing

Login line

Passcode

Unverified logins

Ports and hosts

Testing

4.4 TOCALL (github.com/aprsorg/aprs-deviceid)

Experimental use

Getting an official allocation

4.5 Formats (APRS12c)

Data type identifier (Ch6 p.23)

  No timestamp Timestamp
No messaging ! /
Messaging = @

Only zulu DDHHMMz timestamps should be transmitted.

Uncompressed position (Ch8 p.32-33)

Compressed position (Ch9 p.37-40)

Messages (Ch14 p.71-73)

Bulletins (Ch14 p.74-75)

Objects and items (Ch11 p.58-59)

Status (Ch16 p.80)

Telemetry (Ch13 p.68-70)

Weather (Ch12 p.65-66)

4.6 Duplicates and gating the other way (IS → RF)

APRS-IS duplicate filter (aprsc dupecheck.c)

Local duplicate checking at the iGate

IS → RF gating (IGateDetails.aspx, IGating.aspx)

A message goes to RF only if all of these hold:

After that, pass the sender’s next position on to RF too.

UNVERIFIED: numeric IS → RF rate limits. aprs-is.net gives none; Dire Wolf’s IGTXLIMIT is the usual reference.

Third-party format on RF

?IGATE? query


5. Australian regulatory constraints

5.1 The LIPD class licence on 915-928 MHz

The LIPD Class Licence 2015 has been repealed. The instrument in force is the Radiocommunications (Low Interference Potential Devices) Class Licence 2025, F2025L01047, from 2025-09-05. Reference: legislation.gov.au/F2025L01047/latest/text.

The band appears in three items:

Item Device class Max EIRP Condition
Sch 1 item 20 Any transmitter 3 mW none
Table 8 item 1 Frequency hopping 1 W at least 20 hop channels (cl 39)
Table 8 item 6 Digital modulation 1 W power spectral density ≤ 25 mW per 3 kHz (cl 43)

5.2 Amateur Stations Class Licence 2023 (F2023L01648)

Bands

Encryption

Identification

Purpose

Unattended operation

Repeaters

Retransmitting content from non-amateur LIPD users on 145.175 MHz

5.3 APRS-IS

5.4 Airtime guidance

MBUG (wiki.mbug.com.au/en/Meshcore/Settings, updated 2026-08-23)

MeshPRS must set its own budget.

5.5 Candidate rules (SA-5’s recommendations, not decisions)

# Rule
R1 Gate to APRS-IS only packets carrying a valid callsign, through a passcode-verified gateway, marked qAO. Never gate anonymous LIPD users
R2 Gate only callsigns the gateway has bound to a mesh public key, for example through a node-name offer or a signed claim
R3 Never gate DMs or private-channel content. Gate only adverts and the MeshPRS channel, and only on the originator’s opt-in
R4 To amateur RF: plaintext only, only from a verified amateur callsign, third-party format, gateway callsign as source, no commercial content
R5 A gateway transmitting unattended on amateur RF must meet s13(4)
R6 No APRS RF → mesh → APRS RF chains without a repeater apparatus licence
R7 APRS-IS → mesh is legal under LIPD, but must be rate-limited to a per-gateway airtime budget, never flood #Public, and honour NOGATE/RFONLY
R8 Cite LIPD 2025, not 2015, and note the clause 43 PSD point

6. What this means for the Tier A carrier

Option Floods on stock repeaters? Verdict
RAW_CUSTOM No. It only travels direct, needs a known source route, has no destination, and companions refuse to flood it Unusable for beacons, and for anything sent to “whatever iGate is nearby”. It might suit iGate ↔ known-node unicast, but GRP_DATA direct does that too
GRP_DATA on a dedicated channel Yes, without decrypting; also direct-routable The only binary carrier that stock repeaters will flood. Blob up to 165 bytes, sized in 16-byte steps; data types FF00-FFFF free for development
GRP_TXT, compact APRS text Yes Works with stock phone apps, and people can read it. The cost is the name: prefix inside a 160-byte limit and a self-asserted sender. Bytes are higher than binary but still well under 160 for a position
ADVERT Yes, up to 8 hops Already carries an open position, but only 32 bytes of app data, and MBUG limits its rate. Kelly’s iGate already reads it (“version 0”)

Constraints any choice must handle: