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.
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.
code[0] = HMAC-SHA256(regionKey, type‖payload), truncated to 2 bytes. A public #region key is sha256(name) (src/helpers/TransportKeyStore.cpp:4-47).code[1] is always 0 for now.| 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).
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 |
| 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.
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:
izOH6cXN6mrJ5e26oRXNcg==, which is hex 8b3387e9c5cdea6ac9e5edbaa115cd72 (companion MyMesh.cpp:114; MBUG publishes it too).sha256("#name"). The phone app derives it; the firmware only ever takes a PSK (docs/companion_protocol.md:461-468).| 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.
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.
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.
| 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 |
All of these are in allowPacketForward, examples/simple_repeater/MyMesh.cpp:434-459.
Both route types:
set repeat off (disable_fwd) stops all forwarding.Flood only:
flood.max (default 64), or flood.max.unscoped (64), or flood.max.advert (8) stop the packet (RoutingPolicy.h:15-22; MyMesh.cpp:906-908).(n+1)·hash_size ≤ 64. With SEQ’s 2-byte hashes that is about 32 hops.Direct only:
RAW_CUSTOM
CMD_SEND_RAW_DATA (25) as [path_len][path][payload ≥ 4], and always sent direct.PUSH_CODE_RAW_DATA.GRP_DATA
CMD_SEND_CHANNEL_DATA (62). Path value 0xFF means flood; a real path means direct (companion :1198-1237).0x0000 is rejected.Flood scope (this settles the disagreement between SA-1 and SA-2)
DEFAULT_FLOOD_SCOPE_NAME or the app sets one with CMD_SET_DEFAULT_FLOOD_SCOPE (63).Receive delay
rx_delay_base = 0 turns it off by default (MyMesh.cpp:542-545, 892).Retransmit delay (MyMesh.cpp:547-554, 893-894)
random(0, 5·t + 1) ms, uniform, where t = airtime(packet) × factor.Queue priority
Airtime budget
airtime_factor defaults to 1.0, which is about 50% duty cycle over a 1-hour window (Dispatcher.cpp:26, 173).Duplicate suppression (src/helpers/SimpleMeshTables.h:9; Packet.cpp:41-50)
SHA-256(payload_type ‖ payload).Adverts carry the only open (unencrypted) position.
ADV_LATLON_MASK 0x10, int32 × 1e-6 degrees (AdvertDataHelpers.h:14).advert_loc_policy decides what is sent (CommonCLI.cpp:170-181): NONE, SHARE (live GPS) or PREFS (a fixed position).flood.advert.interval 83).Positions from telemetry
addGPS, lat/lon/alt), but only inside an encrypted REQ/RESPONSE.Nothing else
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:
LOCATION (0x02): GPS.ENVIRONMENT (0x04): temperature, humidity, pressure, altitude, voltage, current, power and so on, on channels 2 and up (EnvironmentSensorManager.cpp:249-530).Who can ask:
Sensor firmware extras:
GET_AVG_MIN_MAX (0x04).SUBSCRIBE (0x08): the sensor pushes a reading when a value moves past a set change threshold (SensorMesh.cpp:213-240 and 1042-1065).sendAlert).Every telemetry request and reply is end-to-end encrypted. Kelly already parses LPP (kelly-page/js/meshcore.js:214-229).
GET_STATUS (0x01)
GET_NEIGHBOURS (0x06)
[pubkey prefix][heard secs ago u32][snr×4 i8] for each neighbour (MyMesh.cpp:276-370).MAX_NEIGHBOURS is set.meshcore.js:237-238).Open discovery: CONTROL NODE_DISCOVER_REQ (0x80)
DISCOVER_RESP, carrying the SNR at which it heard the request and its key.Storage
memset runs at startup (MyMesh.cpp:690). Only the ACL and prefs are saved to flash.Login (MyMesh.cpp:328-354)
ANON_REQ [ts][sync_since][password].allow_read_only on: guest, which can read but not post.Delivery (MyMesh.cpp:1000-1040)
sync_since moves forward when it ACKs.For MeshPRS bulletins
room.post system post (MyMesh.cpp:977).There is no community convention in the MeshCore source or docs for structured data inside channel text.
Line format
SRC>DEST,PATH:info, ending CR/LF.PACKETLEN_MAX 512.Callsigns
-0 is not allowed.-AB are legal on APRS-IS, but some Yaesu radios won’t reply to them.} header can be any 1-9 printable characters (Ch17 p.84).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
,qAR/qAO,IGATECALL.Packets not to gate to IS
} packets whose inner header contains TCPIP.? queries.aprsc drops NOGATE and RFONLY packets itself.
Login line
user CALL-SS pass NNNNN vers NAME VER filter ...Passcode
kelly-page/js/igate.js uses.Unverified logins
pass -1) login cannot send. The packets are dropped (aprsc disallow_unverified = 1), so it is not a test sandbox.Ports and hosts
aunz.aprs2.net. rotate.aprs2.net also works.Testing
srvr.aprs-is.net:8080 is documented “for testing”, but its traffic goes onto the live network.Experimental use
APZ???, with no registration needed.APZKPG is unallocated today.Getting an official allocation
APM2A?, “Meshtastic to APRS (mesh2aprs)”, class daemon.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)
!DDMM.mmN/DDDMM.mmE$comment./A=nnnnnn (feet).Compressed position (Ch9 p.37-40)
/YYYYXXXX$csT, base91 (ASCII − 33).380926·(90−lat). Longitude: 190463·(180+lon).cs pair carries one of:
c·4 with speed 1.08^s − 1 knotsc is {)1.002^(c·91+s) feet (when the GGA bits are set in T).Messages (Ch14 p.71-73)
:ADDRESSEE:text{ID, with the addressee padded to 9 characters.{.ack/rej + ID.{MM}AA. UNVERIFIED: whether each half is limited to 2 characters.Bulletins (Ch14 p.74-75)
:BLNn :.:BLNx : (a letter instead of a digit).:BLNnGROUP: (group name up to 5 characters).Objects and items (Ch11 p.58-59)
;NAME_____*DDHHMMz<pos>, a 9-character name with * for live or _ for killed. Always timestamped.)NAME!<pos> (_ for killed). APRS12c marks items “not recommended”.Status (Ch16 p.80)
>text: up to 62 characters, or 55 with a timestamp.Telemetry (Ch13 p.68-70)
T#sss,a1,a2,a3,a4,a5,bbbbbbbb. Decimal values are accepted.PARM., UNIT., EQNS. (a·v² + b·v + c, × 5) and BITS.bbbbbbbb,Title are sent as messages addressed to the station’s own call.|ss11…|: values 0-8280, still “experimental” (he.fi/doc/aprs-base91-comment-telemetry.txt).Weather (Ch12 p.65-66)
_ symbol, then ddd/sss (wind direction/speed), then:
g gust (mph)t temperature (°F)r / p / P rain: last hour, last 24 h, since midnight (hundredths of an inch)h humidity (%, 00 = 100%)b pressure (tenths of hPa, 5 digits)L/l luminosity, s snow, # raw rain counter.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>APRS,PATH:}FROM>TO,TCPIP,IGATE*:data, with q-constructs stripped.APxxxx, while aprs-is.net’s example uses APRS.?IGATE? query
<IGATE,MSG_CNT=n,LOC_CNT=n.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) |
Bands
Encryption
Identification
Purpose
Unattended operation
Repeaters
Retransmitting content from non-amateur LIPD users on 145.175 MHz
MBUG (wiki.mbug.com.au/en/Meshcore/Settings, updated 2026-08-23)
path.hash.mode 1, multi.acks 1 and flood.advert.interval 83 (“if anyone wants to avoid floods, it’s Brisbane”).MeshPRS must set its own budget.
| # | 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 |
| 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: