Appearance
Fragmentation and fragment recovery
Large logical messages are split into multiple radio frames sharing:
messageId— 16-bit identifier unique per(srcId, logical_message)within a reuse window (see Packet layout).fragment(optional 2-byte block whenHAS_FRAGMENT) —fragmentIndex+moreFragments. For the current wire revision this block is normatively aligned withPAYLOAD_BODY_V2currentIndex/totalCount(not a hint).
Stateless chunk context (PAYLOAD_BODY_V2)
For file / blob transfers, LowMesh uses payloadType = 0x04 (PAYLOAD_BODY_V2) — see Payloads. PAYLOAD_BODY_V2 is a payload type identifier, not a public API version label.
Every such frame carries its own:
currentIndex— which chunk this is (0-based)totalCount— how many chunks the logical object haschunkLen+chunkBytes— the data for this chunk
There is no separate metadata-only payload that carries fragmentTotal (or totalCount) alone for the transfer. Reassembly MUST read currentIndex / totalCount (2 bytes LE each) from each received PAYLOAD_BODY_V2 body. Implementations MUST NOT require a prior “announcement” frame for the total count.
Consistency: For a given (channelHash, srcId, sessionId, messageId) logical transfer, totalCount MUST be identical on every chunk; currentIndex MUST be unique per chunk. Violations → drop the session.
Encoding fragment block (16-bit)
Packing (used for duplicate suppression, CCM nonce fragmentWord, and ordering):
| Bits | Field |
|---|---|
15 | moreFragments — 1 if more after this |
14–10 | reserved (MUST be 0) |
9–0 | fragmentIndex (0..1023) |
Current-revision limits (normative)
For the current wire format:
totalCountMUST be1..1024.currentIndexMUST be0..totalCount-1.- Therefore
currentIndexMUST be0..1023. - Every multi-frame logical transfer MUST set
HAS_FRAGMENTon every frame of that transfer. - For
PAYLOAD_BODY_V2, headerfragmentIndexMUST equalcurrentIndex. - Header
moreFragmentsMUST equal:
text
(currentIndex + 1 < totalCount)If these values disagree, the receiver MUST reject the fragment/session (drop before application reassembly; log DROP_BAD_TOTAL_COUNT, DROP_FRAGMENT_INDEX_MISMATCH, or DROP_BAD_MORE_FRAGMENTS as appropriate).
fragmentWord participates in the CCM nonce. Implementations MUST NOT wrap, reuse, or treat the 10-bit fragmentIndex as a hint when currentIndex would not fit. Supporting more than 1024 chunks requires a future wire-format/nonce revision and MUST NOT be implemented by wrapping or reusing the 10-bit fragment index.
Legacy / other payload types: Short chat (TEXT_UTF8) MAY still use the header fragment field alone for multi-frame text without PAYLOAD_BODY_V2. The same fragmentIndex 0..1023 and HAS_FRAGMENT rules apply; there is no out-of-range index.
Example: 10 chunks (PAYLOAD_BODY_V2)
Logical file message with messageId = 0x0099 (example), totalCount = 10 on every frame:
| Frame | currentIndex | moreFragments (header) |
|---|---|---|
| 1/10 | 0 | 1 |
| … | … | 1 |
| 10/10 | 9 | 0 |
Receivers assemble when:
- All indices
0..totalCount-1present (values taken from each chunk’sPAYLOAD_BODY_V2), or - Application deadline hits and triggers recovery.
Lost fragment request
FRAG_NACK (payloadType = 0x07)
| Field | Size | Description |
|---|---|---|
targetSrcId | 4 | Original sender (LE nodeId32) |
targetSessionId | 4 | Original sessionId (LE uint32) from the base header |
targetMessageId | 2 | messageId of the transfer (LE uint16) |
totalCount | 2 | totalCount from PAYLOAD_BODY_V2 (LE uint16) |
rangeCount | 1 | Number of ranges in missingRanges |
missingRanges | 4 × rangeCount | Repeated pairs: startIndex (2 LE), endIndex (2 LE) inclusive |
Each range lists inclusive currentIndex values still missing from the reassembly window. endIndex MUST be ≥ startIndex.
Rules:
innerBodyLenMUST equal13 + 4 × rangeCount.- Full transfer identity:
(channelHash, targetSrcId, targetSessionId, targetMessageId). totalCountMUST match the value seen on every chunk of that session.
The recipient (or any node caching content) SHOULD respond with retransmits of the corresponding PAYLOAD_BODY_V2 frames.
Interaction with routing
- All fragments share
messageIdandsessionId; duplicate suppression uses(channelHash, srcId, sessionId, messageId, fragmentWord)for the header slot, and SHOULD also keyPAYLOAD_BODY_V2sessions by(channelHash, srcId, sessionId, messageId)withtotalCountvalidation. - Floods MAY deliver chunks out of order; reassembly buffers (when used) MUST be keyed by
(channelHash, srcId, sessionId, messageId). - Proxy / tethered Clients MAY hold no full-file buffer for large third-party transfers — see Reassembly profiles below and USB & BLE hex API.
Reassembly profiles: where state lives
Large logical messages can be reassembled on the radio node or on a tethered host, depending on hardware profile. Both profiles use FRAG_NACK / timers on the same mesh; totalCount / currentIndex come from each PAYLOAD_BODY_V2 frame, not from a separate metadata packet.
Profile A: Host-Tethered Streaming (proxy / companion)
Typical hardware: RAM-constrained Client MCUs (e.g. nRF52840-class) with USB and/or BLE attached to a phone or PC.
Proxy role (normative)
When operating in this profile for large chunked transfers (PAYLOAD_BODY_V2 intended for the companion), the device MUST NOT buffer a full reconstructed file in local RAM for third-party traffic. It MUST act as a transparent pipe:
- Receive the RF frame, apply duplicate suppression and policy checks with bounded internal state.
- Immediately stream the relevant bytes to the connected companion using
FRAME_CAPTURE(0x02) — see USB & BLE hex API.
The MCU does not own sliding-window reassembly or gap lists for that transfer; the host reads currentIndex / totalCount from each forwarded frame.
Host-side state machine (companion)
When the Client is in proxy mode, the host app becomes responsible for:
- Sliding-window reassembly using
currentIndex/totalCountfrom eachPAYLOAD_BODY_V2. - Detecting missing indices within the window and originating
FRAG_NACKmesh frames.
The microcontroller’s job is to accept FRAG_NACK frames from the host (via FRAME_INJECT, 0x01) and route them back onto the radio per normal forwarding rules — without maintaining a full local reassembly buffer for the referenced transfer.
Companion recovery timing (recommended): Hosts SHOULD issue FRAG_NACK as soon as a missing index blocks forward progress in the sliding window (timeouts / reorder limits), not only after the sender has finished the whole transfer. End-of-transfer sweeps MAY still run as a secondary check.
Validation without full buffers (proxy)
Even in proxy mode, firmware MUST still validate cheap invariants before forwarding (and MUST drop abusive frames without allocating hole vectors):
currentIndex ≥ totalCounttotalCount == 0ortotalCount > 1024/totalCount > nFragMax(use the same publishednFragMaxcap as the local-reassembly profile)- header
fragmentIndex != currentIndex - incorrect
moreFragments - Rate limits on new
(channelHash, srcId, sessionId, messageId)sessions toward the host path (same rationale as plaintext abuse below)
Profile B: Local reassembly (gateway / capable repeater)
Typical hardware: Nodes with sufficient RAM and/or NVM (e.g. ESP32-based gateways, dedicated repeaters with store-and-forward).
Such devices MAY perform full local reassembly on the MCU, subject to explicit limits. They are not required to use Host-Tethered Streaming for every transfer.
Reassembly memory safety (local reassembly profile, anti-OOM)
Because totalCount can be large and can imply a big reconstructed object, receivers in Profile B MUST NOT allocate buffers proportional to “max index seen” or “max declared total” without explicit local limits.
Normative limits (receiver, Profile B)
Each implementation MUST publish (in firmware docs / operator notes) and enforce:
nFragMax: maximum supportedtotalCountfor any single logical message (MUST be ≤ 1024; recommended default256unless the product has proven RAM headroom).bMsgMax: maximum reconstructed logical message size in bytes (recommended default32 KiBfor handheld-class devices; gateways MAY be higher).nAsmMax: maximum number of concurrent in-progress reassembly sessions (recommended default2–4on MCUs; gateways MAY be higher).
If an inbound frame violates these limits, the receiver MUST drop the reassembly state for that (channelHash, srcId, sessionId, messageId) and MUST NOT grow buffers to satisfy the declared span.
Sparse / malicious chunk patterns (Profile B)
Receivers MUST treat the following as invalid / hostile for reassembly (drop the session; do not allocate “holes”):
currentIndex ≥ totalCounttotalCount == 0ortotalCount > 1024/totalCount > nFragMax- header
fragmentIndex != currentIndex - incorrect
moreFragments - attempted wraparound/reuse of the 10-bit fragment index
- Any claim implying
totalCount × maxChunkBytes > bMsgMax(use worst-case chunk sizing if the sender is untrusted) - Hole beyond window: if using a sliding window (recommended), receiving a chunk whose index is far ahead of the lowest missing index without intermediate progress MUST be dropped (prevents “allocate 1023 after chunk 0” patterns).
Recommended reassembly strategy (bounded RAM, Profile B)
Receivers SHOULD use sliding-window reassembly:
- Keep only a fixed window of next expected chunks in RAM (e.g. 8–32), not the entire
0..totalCount-1vector. - Validate monotonicity and bounds against
bMsgMaxbefore accepting storage. - If the window cannot advance due to a missing index, use
FRAG_NACK/ timers; if still stuck, purge the session.
Encrypted vs plaintext channel note
On every channel, fragment bodies are protected by AES-CCM-4 once decrypted; adversaries without the key cannot forge valid MICs. Implementations SHOULD still rate-limit new (channelHash, srcId, sessionId, messageId) reassembly sessions per peer (Profile B) and apply rate / nFragMax checks on Profile A proxy paths — see Security below.
Pseudocode: receiver (Profile B — local reassembly)
Profile A proxy firmware replaces reassembly.getOrCreateLimited with serialize-to-host (FRAME_CAPTURE) plus tiny dedupe state; see USB & BLE hex API.
text
onFrame(f):
key = (f.channelHash, f.srcId, f.sessionId, f.messageId)
if shouldIgnoreOrDrop(f):
return
if f.payload is PAYLOAD_BODY_V2:
idx = f.payload.currentIndex
tot = f.payload.totalCount
else:
// legacy / other types: derive idx/tot from header + app rules
...
buf = reassembly.getOrCreateLimited(key)
if buf is null:
return // at nAsmMax or policy denies new sessions
if not buf.acceptChunk(idx, tot, f.payload.chunkBytes):
purge(key)
return
if buf.completeWithinLimits():
deliver(toUpperLayer(buf))
purge(key)
else if buf.stale():
send(FRAG_NACK(...))
purge(key)Security
On encrypted channels, fragment requests are still authenticated by the channel key, preventing blind resource exhaustion from outsiders.
RAM exhaustion: even with authentication, a compromised member or buggy peer can still attempt sparse chunk patterns. Profile B receivers MUST enforce the Reassembly memory safety limits above so reassembly cannot allocate unbounded RAM. Profile A Clients MUST still enforce bounded queues toward USB/BLE and drop under backpressure (see USB & BLE hex API) rather than buffering unbounded RF traffic.