mesh-rs extensions

This document specifies the protocol and firmware extensions implemented by mesh-rs for developers building compatible firmware or applications.

Turn-based games

Two-player games are carried as readable chat lines that stock clients display as ordinary messages.

Transport

Frames are transmitted as plain-text DMs or room posts, not group-channel messages. In a room, the 4-byte author prefix in the server's push identifies the player.

A message is treated as a game frame if it parses as a v1 frame, or if it begins with ~g<N> (for decimal N from 2 to 99) followed by a non-empty body. Other text, including malformed ~g1 lines, remains chat. A session is identified by the conversation key (the DM peer or room server) and gid.

Frame

~g1 <game> <gid> <verb> [args...]
Verb Syntax Meaning
invite inv [w|b|f|s] f or s selects whether the inviter moves first or second; w or b selects the inviter's colour in chess. When omitted, the inviter moves first.
accept ok Accepts a pending invitation.
decline no [f|v] f indicates that the session limit has been reached; v indicates an unknown protocol version or game.
bind go <8 hex> Used in rooms only; identifies the accepted opponent by author prefix.
move m <seq> <move>[/<echo>] #hash Applies a move at seq. When present, echo repeats the opponent's move from seq−1 verbatim.
repeat m! ... Re-transmits the sender's last move with only the verb changed.
snapshot st <seq> <state> #hash Carries the full game state after move seq.
sync sync <seq> Reports the sender's highest contiguous applied sequence number, or 0 before the first move.
resign rz <seq> Concedes the game for the sender. Valid only during an active session.
draw dr? / dr+ Offers (dr?) or accepts (dr+) a draw in checkers, morris or chess. Any move cancels a standing draw offer.
commit / reveal cm <32 hex> / rv <layout> <32 hex> Commits to or reveals a fleet layout in battleships.
~g1 t 3fa2 inv f
~g1 t 3fa2 ok
~g1 t 3fa2 m 1 5 #2829
~g1 t 3fa2 m 2 1/5 #8b27

Lifecycle

Side A always moves first, and the invite argument selects the inviter's side. The active turn is determined by the game state rather than sequence-number parity.

In a direct-message session, the game becomes active for the invitee when it sends ok, and for the inviter when it receives either ok or a valid move at sequence 1 from the peer.

In a room, members track incoming invitations while below their session limit. The inviter binds the session to the sender of the first ok by sending go with that member's author prefix; that member becomes the second player and all other room members spectate. Moves are accepted only from the player whose turn is indicated by the state.

An invitation for an unsupported game or protocol version is answered with no v. When the session limit (6 sessions, or 4 on small-screen boards) is reached, a node answers DM invitations with no f and ignores room invitations. Incoming invitations never evict active sessions, and unanswered invitations expire locally after 7 days. Each client detects game completion directly from the rules without a separate end frame.

Repair

Let held denote the receiver's local sequence number. An incoming move with a sequence number at or below held is discarded. A move at held + 1 is applied if the sender holds the active turn. A move at held + 2 that includes an echo field first applies the echoed move at held + 1, provided doing so transfers the turn to the sender, and then applies the move at held + 2. Any larger sequence gap, illegal move or state-hash mismatch triggers a sync <held> reply.

A participant replies to sync with an st snapshot only when the session is active, its local sequence number exceeds the requested value, and it sent the most recent move. A snapshot is accepted only when its state blob and hash are valid and its sequence number is at least held. Active sessions accept snapshots only while a sync request is pending, whereas pending room sessions also accept snapshots to initialize their state. Automatic sync requests and st responses are each rate-limited to one per 60 s per session.

Receiving a move, resignation, draw frame, commitment or reveal for an unknown gid triggers at most one sync 0 per gid every 300 s. An unsolicited snapshot for an unknown gid is not applied without explicit user acceptance.

Per-game encodings

Snapshots encode the canonical state bytes as lowercase hex unless stated otherwise. In canonical binary states, the turn byte is 0 for side A and 1 for side B; in snapshots, the trailing letter a or b identifies the side to move. Turns alternate strictly in every game except mancala.

A trit board packs five cells per byte, placing cell i in byte i / 5 at weight 3i mod 5 with values 0 for empty, 1 for side A and 2 for side B. Battleships uses the cell values defined in its table entry below.

Game Move Canonical state Rules to match
Tic-tac-toe t Cell 1 to 9 in reading order. 3 bytes encoding xmask | (omask << 9), where bit i corresponds to cell i + 1. The snapshot encodes this integer as 5 hex digits, most significant digit first. Side A plays X.
Mancala m Pit 1 to 6 on the mover's side, numbered in sowing order. 15 bytes: side A pits 1 to 6, side A store, side B pits 1 to 6, side B store, and the turn byte. The snapshot encodes the 14 seed counts as 2 hex digits each, followed by a or b. Kalah rules with 4 initial seeds per pit. Sowing skips the opponent's store. Landing the last seed in the mover's store grants another turn; landing it in an empty pit on the mover's side opposite a non-empty pit captures both into the mover's store. The game ends when the side to move has no seeds in its pits, and the opponent sweeps its remaining seeds.
Checkers k Simple step 9-14 or jump chain 22x15x6 (up to 12 squares). 13 bytes: side A, side B and king bitmasks as u32 values (where bit n − 1 represents square n), followed by the turn byte. The snapshot encodes the three masks as 24 hex digits, followed by a or b. Dark squares are numbered 1 to 32 starting from side A's back row. Captures are mandatory, with free choice among available jumps. A jump sequence continues while the same piece has another capture, and crowning terminates the turn. Kings move and capture one square diagonally. A player with no legal move loses.
Morris n point to place a piece, or from-to to move, suffixed with x<point> when completing a mill. 8 bytes: side A and side B board masks (3 bytes each), a hands byte (side A in the high nibble, side B in the low nibble, starting at 0x99), and the turn byte. The snapshot encodes 12 hex digits for the masks, 2 hex digits for the hand counts, and a or b. Points 1 to 24 are numbered row by row: outer, middle and inner top; left side from outer to inner; right side from inner to outer; and inner, middle and outer bottom. There are 16 orthogonal mills and no diagonal mills. Forming a double mill still captures only one piece. A piece inside a mill is eligible for capture only when every opposing piece lies in a mill. A player with 3 pieces on the board and none in hand flies to any empty point. A player reduced to 2 pieces or left with no legal move loses.
Gomoku g Coordinate a1 to o15, mapped to zero-based cell index (row − 1) · 15 + (column − a). 45-byte trit board, with the side to move derived from the stone counts. Played on a 15×15 board under free-style rules, where side A plays Black. A line of five or more stones wins, and a full board without a line is a draw.
Quarto q At sequence 1, the initial handed piece 0 to f. Subsequent moves are cell-piece (for cells a1 to d4), or bare cell when the placement wins or fills the board. 12 bytes: an 80-bit board packing cell i at bit offset 5i as 0x10 | piece (or 0 when empty), the handed piece byte (0xFF when none), and the turn byte. The snapshot encodes 20 hex digits for the board, the handed piece digit or ., and a or b. Winning lines are the rows, columns and two main diagonals. Completing a line of four pieces that share at least one attribute wins for the player who places the fourth piece.
Chess c Four ICCF digits such as 5254, with a fifth digit for promotion (1 Q, 2 R, 3 B, 4 N). Castling is encoded as the king's two-square move. Occupancy mask as u64 (bit rank · 8 + file, both zero-based), piece nibbles in square order packed low nibble first (1 to 6 for P, N, B, R, Q, K, plus 8 for Black), flags byte (bit 0 Black to move, bits 1 to 4 castling rights WK, WQ, BK, BQ), en-passant file (0xFF when none), and halfmove clock as u8. Side A plays White. The en-passant file is recorded after every double pawn advance. Any move touching a1, h1, e1, a8, h8 or e8 clears the corresponding castling rights. Endings are evaluated in order: bare kings (draw), checkmate or stalemate, and the 100-halfmove rule (draw). Draw by repetition is claimed by mutual draw agreement.
Pente p Coordinate a1 to o15, as in gomoku. 46 bytes: a 45-byte gomoku trit board followed by a status byte (bits 0 to 2 side A captured pairs, bits 3 to 5 side B captured pairs, bit 6 side B to move). Side A opens at h8. Bracketing a contiguous pair of opposing stones along a line captures the pair, and a single move can capture in multiple directions. Placing a stone between two opposing stones does not self-capture. Captures are resolved before checking for five in a row; capturing five pairs or forming an unbroken line of five or more stones wins.
Battleships b [verdict]cell, where verdict is - (miss), x (hit), or s2 to s5 (sunk ship of that length). A verdict prefix is included whenever the opponent's preceding shot is awaiting a verdict. 42 bytes: a flags byte (bit 0 side A committed, bit 1 side B committed, bit 2 side B to move), the pending shot cell index (0xFF when none), and the 20-byte trit boards for side A and side B (0 unshot, 1 miss, 2 hit). Played on a 10×10 grid with cells a1 to j10. The fleet consists of ships of lengths 5, 4, 3, 3 and 2, encoded without separators in that order as <cell><h|v> from each ship's lowest coordinate; ships are allowed to touch. Once the session is active, each player sends cm containing the first 16 bytes of SHA-256(16-byte salt ‖ layout), and both commitments are required before the first shot. Reaching 17 claimed hits wins. After the game ends, either player can send rv to reveal its layout and salt, which the peer verifies against the commitment, fleet placement rules and all reported verdicts.

Aquarium visits

Virtual fish visit other nodes using the LFG1| direct-message protocol from LoRaGotchi DX, in which fields are separated by literal ASCII pipe characters (|). mesh-rs extends the COMMIT message with an optional trailing appearance field that stock hosts ignore.

Line Sender
LFG1|REQ|tx|fid|stage|name owner
LFG1|ACK|tx|fid, LFG1|BUSY|tx|fid host
LFG1|COMMIT|tx|fid[|look] owner
LFG1|ARRIVED|tx|fid host
LFG1|RECALL|tx|fid owner
LFG1|RETURN|tx|fid host

Owner

After sending REQ, the owner waits up to 90 s for ACK or BUSY. After ACK, the owner sends COMMIT and waits 120 s for ARRIVED. It retries COMMIT once before abandoning the visit after another 120 s.

The 3600 s visit lease begins when the owner receives ARRIVED. At expiry or on user recall, the owner sends RECALL. The fish returns home when RETURN is received or after 120 s.

Host

A host accommodates between 1 and 3 visiting fish at a time, with at most one guest per owner. A duplicate REQ for an active reservation is answered with ACK again. The host replies with BUSY when its tank is full, the fish is already present, visitor hosting is disabled, local time is between 20:00 and 06:00, or the node's clock is unset.

The host replies to every matching COMMIT with ARRIVED and starts its 3600 s lease on receipt of the first matching COMMIT. A reservation that receives no COMMIT expires after 300 s. The host ends the visit and sends RETURN either on receipt of RECALL or when the lease expires.

Fry

A visiting adult or elder fish leaves a fry in the host tank if the visit ends within 120 s of the lease deadline. The breeding seed is the first 4 bytes of SHA-256(host fid ‖ visitor fid ‖ tx), with each identifier encoded as a 4-byte integer. The fry inherits the host's hue, the visitor's fin and body shape, and the pattern encoded in bits 5 to 6 of the first seed byte.

Clock tower

A node without a trusted clock requests the time from a designated contact so its adverts and messages carry accurate timestamps. A node serves as a tower when the feature is enabled and its own clock source is at app, network or GNSS tier.

Request and reply

A time request is transmitted as a DIRECT anonymous info request (ANON_REQ) with subtype 0x05 along the contact's stored path, or zero-hop when no path is stored. Multi-byte binary integers in this and other wire formats are little-endian unless stated otherwise.

Message Layout
Request inner tag u32 | 0x05 | reply path. Each request contains a fresh, unpredictable tag to resist replay of old replies, including after a reboot.
Reply body tag u32 | unix u32 | tier u8. Replies shorter than 9 bytes are invalid.

Clock tiers in ascending priority order are 0 (unset), 1 (manual), 2 (restored), 3 (mesh), 4 (app), 5 (network) and 6 (GNSS).

Tower

The tower responds only to DIRECT requests while clock.tower is enabled and its clock source is above mesh tier. Time replies are rate-limited to 16 per 180 s, independently of other info replies.

A direct neighbour receives a zero-hop reply, while other requesters receive replies along the supplied reply path.

Room login messages have the layout timestamp u32 | sync cursor u32 | password. To distinguish time requests from logins, a room server treats a message as a time request only when its first four bytes fall outside the login timestamp window (15 minutes before to 5 minutes after the server's clock).

Requester

Eligible contacts are favourites with bit 0x20 set in the contact flags byte. The first request is sent 300 s after boot, and subsequent requests are sent every clock.tower.interval (default 1800 s, minimum 300 s). No requests are sent while the local clock source is at mesh tier or higher. Candidates are queried in turn, starting with the most recently active contact.

A reply is accepted only from the last queried tower, with a matching request tag, before the next request is sent, and with a reported tier above mesh. The accepted timestamp is applied at mesh tier according to the tier priority order above.

CLI

Command Effect
get clock.tower, set clock.tower on|off Enables or disables serving time requests.
get clock.tower.interval, set clock.tower.interval <secs> Gets or sets the query interval in seconds.
contact.timesource <hex prefix> on|off Sets or clears flag bit 0x20 on the first matching contact.

Public channel discovery

Repeaters advertise the public hashtag channels active around them so users can discover and join those channels by name.

A public channel name begins with # followed by 1 to 29 bytes of UTF-8 containing no commas, 0x7F, or bytes at or below 0x20. The prefix #$ is reserved. The 16-byte channel key is SHA-256(name with #)[0..16].

A discovery request is an ANON_REQ info request with subtype 0x04, framed and routed in the same manner as a clock-tower request. The reply body has the layout tag u32 | unix u32 | names, where names is a comma-separated list truncated on a channel-name boundary to at most 160 bytes; an empty list is valid. Receivers stop parsing the list at the first NUL byte and ignore invalid or duplicate names. Channel-discovery replies share the repeater's general info-reply budget of 4 replies per 180 s.

On the repeater CLI, channel advertise <name>, channel remove <name> and channel list (which prints -none- when empty) manage the advertised list. Up to 8 channel names are persisted in configuration storage.

To scan for channels, a client broadcasts a zero-hop node-discovery request for repeaters and, over the next 60 s, queries up to 4 newly heard repeaters every 5 s.

Region channels

A region channel is a public channel bound to a geographic region whose messages always flood under that region's transport scope. This extension implements the proposal in meshcore-dev/MeshCore#2908.

A region channel name begins with + followed by a valid region name of 1 to 29 bytes of ASCII lowercase letters, digits and hyphens. The channel key is SHA-256(name with +)[0..16], derived in the same way as a hashtag channel key, while the flood scope is the region key SHA-256(# and the bare name)[0..16].

A channel is treated as a region channel only if its name is valid and its 16-byte key matches the derivation; otherwise it is treated as an ordinary private channel. The flood scope is re-derived from the name and key on every load and every channel write, so a client cannot strip the scope without modifying the channel itself.

Messages sent on a region channel use its region scope in place of both the app's session scope and the node's default scope. Requests to change the scope of a region channel are ignored. The leading characters +, # and $ are reserved and cannot be used to start a new private channel name.

Repeater relay filter

A repeater evaluates an ordered list of filter rules and a per-origin advert rate limiter before relaying packets. Operators can suppress traffic from a noisy channel, bot, region or upstream repeater while continuing to relay other traffic.

Matching

Packets not addressed to the local node are filtered after duplicate suppression and before the standard hop, region and loop checks.

Content predicates are evaluated only on group packets decrypted with a known channel key and a valid message authentication code (MAC). The first ": " substring separates the sender name from the message body, and the first NUL byte terminates the text.

Filtering is enabled by default with an empty rule list. The repeater supports up to 32 rules, 32 filter channels and 512 tracked advert origins.

Rules

A rule matches a packet only when every predicate configured on the rule matches. Omitted predicates impose no restriction, and repeating a key in a rule definition replaces its earlier value.

Key Value Matches
type Comma-separated list: req response msg ack advert txt data anonreq path trace multipart control raw, or any Matches the packet payload type, where txt and data denote group text and group data packets.
route flood or direct Matches the routing mode; flood matches both plain and transport-scoped flood packets.
hops, len Interval Matches the flood hop count (for flood packets only) or the payload length in bytes.
snr Interval in dB, rounded to 0.25 Matches the received SNR truncated to 0.25 dB steps. Packets without receive metadata do not match.
path [^]HEX>HEX…[$], 1 to 4 entries of 1 to 4 bytes Matches flood packets whose path contains the specified sequence of adjacent hop hashes, each compared over min(entry length, hash size) bytes. ^ anchors the match to the first hop and $ to the last.
hsize Comma-separated list of 1 to 4 Matches the packet's path-hash width in bytes.
chanhash 2 hex digits Matches group packets whose first payload byte equals the specified byte.
region Comma-separated list of lowercase region names, unscoped or * Matches flood packets by region scope; plain flood packets match unscoped, and transport-flood packets match the region whose transport code matches.
chan Comma-separated list of filter channel names Matches group packets successfully decrypted with one of the named filter channels.
sender, text Pattern, ≤31 and ≤63 bytes Matches the non-empty sender name or message body of a decrypted group text packet.
signed meshos or none Matches decrypted group text packets with (meshos) or without (none) a MeshOS v1 signature trailer.
prob 1 to 100 Gates a matching rule so that its action applies with the given percentage probability.
throttle 1 to 65535 s Gates a matching rule with a cooldown window in seconds.
action drop (default) or forward Selects the action applied when the rule matches and its gates are satisfied.

Numeric intervals are written as a single value v or as a range [a,b], (a,b), [a,b) or (a,b], where square brackets are inclusive, parentheses are exclusive, and * denotes an unbounded endpoint.

When type is specified on a rule, the predicates chan, sender, text, signed and chanhash are accepted only if the listed types include the corresponding packet types.

A MeshOS v1 trailer immediately follows the NUL byte terminating the text and has the binary layout 'S' 0x01 | key id [8] | signature [64], followed by fewer than 16 zero padding bytes. The 8-byte key identifier is the prefix of the signer's public key. The signature covers "MeshOS signed channel v1" ‖ SHA-256(channel key)[0..16] ‖ ts u32 ‖ "name: text"; repeaters check only the trailer structure and do not verify the Ed25519 signature because the signer's full public key is not transmitted in the packet.

Rule actions

Patterns

Pattern predicates use tiny-regex-c syntax, supporting . ^ $ * + ?, character classes [abc] and [^a-z], shorthand classes \d \D \w \W \s \S, and backslash-escaped literals. Matching is byte-oriented, case-sensitive and unanchored unless explicitly anchored with ^ or $. The wildcard . matches any byte except CR and LF. A top-level | splits a pattern into at most 8 branches with anchors binding to their own branch, whereas \| matches a literal ASCII pipe character.

A quantifier with no preceding atom or an anchor placed away from its branch boundary never matches. Each branch compiles to at most 29 pattern atoms, and evaluation stops after 5000 steps per branch so complex expressions cannot stall packet relaying. Exceeding the step limit treats the branch as a non-match.

CLI

Arguments are separated by spaces, and double quotes group values that contain spaces. Replies fit within 160 bytes, error replies begin with Err - , and multi-page listings end with ; next=N giving the start index for the next page.

Command Reply
filter on; rules N/32; chans N/32; ratelimit advert Hh; cache C/512; limiter L; aborted A
filter on|off OK - filter on
filter add k=v ... OK - rule N added
filter list [start] on N/32: then <i><e|d><D|F><digest & 0xFFF as 3 hex> per rule
filter get <i> r<i> en|dis drop|forward, the set predicates in table order (throttle=S pass=K), then hits=H air=MS
filter enable|disable|del <i> OK - rule N enabled, disabled, deleted
filter move <from> <to> OK - rule F moved to T
filter clear OK - rules cleared (chans kept)
filter chan [list [start]] <i>:<name>:<hash hex|nokey> entries, or no channels
filter chan add <name> [psk hex] OK - chan NAME h=HH, plus (derived) without a key
filter chan del <name> OK - chan NAME deleted
filter ratelimit [advert <0..720>|clear] ratelimit advert Hh; cache C/512, OK - advert ratelimit Hh, OK - advert cache cleared
filter stats [reset|start] lim:L abort:A air:MS:P%; hits: i:n ..., OK - filter stats reset

Filter channel names are 1 to 15 bytes long and exclude , = " and control bytes. Keys for Public and #name are derived automatically when those channels are first referenced in a rule. Deleting a channel removes it from every rule, and a rule left with an empty channel list never matches.

Vendor companion frames

Vendor companion frames provide diagnostics, file transfer and channel discovery using opcodes outside the upstream range. Commands (0xE0), responses (0xE0) and pushes (0xE1) share the binary layout sub | payload, and chunk sequence numbers increment modulo 256.

Sub Direction Payload Meaning
0x01 to node text Executes a console command line; output follows as 0x07 frames.
0x07, 0x14 from node text Carries a console output line or a spontaneous notice.
0x02, 0x03 from node seq | bytes Streams raw bytes received from or sent to the GNSS receiver.
0x04 from node 1 | fmt | w u16 | h u16 | stride u16 | rot | len u32 Starts a screenshot stream, where fmt is 0 for MSB-first monochrome, 1 for big-endian RGB565, or 2 for little-endian RGB565.
0x05, 0x06 from node seq | bytes, none Carries a screenshot data chunk or marks the end of the screenshot.
0x08 to node kind | key[32] or slot | have u16 Requests a history page older than have rows, where kind is 0 for a DM peer key or 1 for a channel slot.
0x09, 0x0A, 0x0B from node complete | rows u16 | len u32, seq | bytes, none Returns a history page header, a data chunk, or the end of the page; the reassembled payload is a CBOR array of [ts, dir, sender?, text, delivery?, snr?] tuples.
0x0C, 0x10 to node path Lists a directory or deletes a path on the SD card.
0x0D, 0x0E, 0x0F to node len u32 | crc32 | mtime u32 | path, seq | bytes, none Begins a file write, sends a file data chunk (with up to 3 chunks in flight), or aborts the transfer.
0x11, 0x12, 0x13 from node dir | len u32 | mtime u32 | name, have u32, status Reports a directory entry, cumulative bytes accepted, or operation completion (status 0 indicates success).
0x15, 0x16, 0x17 both none, enable, enable | persists Reads, sets or reports the public channel-discovery state.
0x18, 0x19, 0x1A both none, name, none Starts a channel-discovery scan, reports a discovered channel name, or marks the scan complete.
0x1B, 0x1C both name, status | slot Adds a public channel by name and reports the result (status 0 added, 1 table full, 2 invalid name).

Console commands include screenshot, mem, key [long] <key> and tap <x> <y> [long] for remote input.

Companion frame fragmentation

Responses exceeding the 176-byte frame limit are fragmented for apps that send CMD_DEVICE_QUERY with app_target_ver 15 or above.

Each fragment is sent as push 0x91 with the layout frag_id u16 | index | count | orig_type | orig_len u16 | offset u16 | chunk (≤166 bytes).

Fragments of live pushes are sent consecutively. For queued messages, one fragment is sent per CMD_SYNC_NEXT_MESSAGE request, which carries the payload flags (bit 0 ack) | frag_id u16 | index. The next fragment is sent after a matching acknowledgement; any other request resends the current fragment.

Companion response additions

BLE alert and time services

Nodes expose standard Bluetooth GATT services alongside the Nordic UART service so a bonded phone can trigger an audible alert or synchronize the clock without speaking the companion protocol.

Multipart reassembly

The receiver reassembles MULTIPART packets carrying payload types other than ACK. The remaining-parts counter decreases by one in each successive part until it reaches zero. Up to 4 messages of at most 1024 bytes each are reassembled concurrently, and an out-of-order part resets the partial message.

Stock repeaters do not relay MULTIPART packets, so multipart exchanges are limited to direct neighbours.

Admin CLI additions

Command Effect
set lbt none|cad|rssi [dBm] Configures listen-before-talk mode and, for RSSI mode, the threshold from −110 to −40 dBm.
set cad.offset 0..10, set cad.auto on|off, set cad.probe off|auto|10..3600, set cad.reset Tunes channel-activity-detection offset, automatic adaptation and probing interval, or resets CAD state.
get config [start] Outputs every setting that differs from its default as a replayable CLI line, paged with ... next:N.
set repeat.unrestricted.hops <n> Stops relaying plain flood packets once their hop count exceeds n.
set repeat.restrict.path.hash.size <n> Beyond the unrestricted hop limit, drops flood packets whose path-hash width is narrower than n bytes.