57 KiB
MeshCore OTA - .mota container & LoRa protocol
This is the single source of truth for MeshCore's over-the-air firmware update system ("mOTA"). It is written for developers who want to implement an interoperable peer (server, fetcher, relay, or host tool) in another codebase or project. Everything below is implemented in this repository and covered by host, simulation, build, or hardware tests as noted in the relevant section. Hardware qualification is target- and chain-specific; do not infer it from implementation alone. Where a section names a source file, that file is the authoritative reference for byte-level details.
Just want to update your node? See the plain-language OTA user guide - this document is the technical/wire specification.
Design goals
- Distribute firmware over LoRa as a self-verifying, resumable, single-source block transfer that survives reboots and never auto-applies without explicit consent.
- Trustless mesh relay: repeaters may forward packets while the source alone serves firmware data; integrity is content-addressed against a signed merkle root, so a relay need not be trusted and never needs the signing keys.
- Primary while transferring: periodic discovery stays at background priority, but manifest, block, data, and proof packets for an active fetch use primary queue priority at every relay hop.
- Portable: the engine (
src/helpers/ota/OtaManager) is Arduino/radio/crypto-free and host-testable, so the same logic drives a device, a simulation, or a third-party implementation.
Source map (all under src/helpers/ota/ unless noted)
| Concern | File |
|---|---|
| Constants, enums, flags | OtaFormat.h |
| Container/manifest parse | MotaContainer.{h,cpp} |
| Merkle tree + proofs | MerkleTree.{h,cpp} |
| EndF self-identity | FirmwareInfo.{h,cpp} |
| Wire message codec | OtaProtocol.{h,cpp} |
| Session engine (serve+fetch+discovery) | OtaManager.{h,cpp} |
| Multi-mota / folder relay | OtaSource.h, MotaSourceSerial.{h,cpp}, MotaSeederProto.h |
| Staging stores | OtaStore.h, OtaStoreFlashNrf52.*, OtaStoreFlashEsp32.* |
| Apply | OtaApply.*, bootloader Adafruit_nRF52_Bootloader_OTAFIX |
| Device glue (CLI/context) | OtaCli.cpp, OtaContext.h |
| Host tooling | motatool (standalone Rust CLI: build/verify/inspect/serve); tools/mota/ (Python reference lib motalib.py + build/test glue) |
1. Conventions
- Endianness: all multi-byte integers are little-endian unless stated.
- Hashes (multihash): the hash family is declared once per manifest via
hash_algo=0x12= SHA-256 (the multihash code for sha2-256). Truncations used:sha2-256:4- first 4 bytes of the SHA-256 digest. Merkle leaves, internal nodes, root, proofs,manifest_id, and the discoveryset_digest.sha2-256:8- first 8 bytes. Base-firmware identity (base_hash,EndF.body_hash).sha2-256:32- full digest. The image security anchor (image_hash). Digests are stored bare (just the truncated bytes); the family is implied byhash_algo.
- Signatures: Ed25519 (RFC 8032), 64-byte detached signature, 32-byte public key.
Reference constants (OtaFormat.h):
| Name | Value | ASCII / note |
|---|---|---|
Container MAGIC |
6D 4F 54 41 |
mOTA |
Container TRAILER |
76 6B 34 39 36 |
vk496 |
EndF marker |
45 6E 64 46 |
EndF |
hash_algo (sha2-256) |
0x12 |
multihash code |
format_ver |
0x02 |
this spec |
approval = not approved |
FF FF FF FF |
erased NOR word |
approval = approved |
41 50 52 56 |
APRV |
MFLAG_FULL |
0x01 |
flags bit0 |
MFLAG_SIGNED |
0x02 |
flags bit1 |
CODEC_FULL / _SEQUENTIAL / _INPLACE |
0 / 1 / 2 |
Section 5 |
PAYLOAD_TYPE_OTA |
0x0C |
MeshCore packet type (src/Packet.h) |
MAX_PACKET_PAYLOAD |
184 |
usable bytes per packet (src/MeshCore.h) |
| Default block size | 1024 |
block_size_log2 = 0x0A |
| OTA discovery TX priority | 250 |
background (OTA_TX_PRIORITY, src/Mesh.h) |
| OTA active-transfer TX priority | 0 |
primary (OTA_TRANSFER_TX_PRIORITY, src/Mesh.h) |
2. Firmware image & the EndF trailer
Every OTA-capable build appends a fixed 56-byte EndF trailer to its flashed image so a running node
can discover its own size and self-describing identity on any MCU (no linker symbols needed). Every
field is always present at a constant offset. Implemented by FirmwareInfo.cpp; appended at build time by
tools/mota/pio_endf.py (post-build hook).
flashed image = BODY (image bytes) || EndF trailer
EndF trailer (fixed 56 bytes):
off 0 4 "EndF" 45 6E 64 46
off 4 4 body_len uint32 LE - length of BODY (excludes the whole trailer)
off 8 8 body_hash sha2-256:8 of BODY
off 16 4 fw_version uint32 LE, packed MAJOR<<24|MINOR<<16|PATCH<<8|pre (0 = unknown)
off 20 4 target_id uint32 LE - sha2-256:4(pio_env): hardware + role + partition (fetch routing)
off 24 32 hw_id NUL-padded ASCII hardware tag (brick-safety), e.g. "RAK4631" ("" = unknown)
- Self-describing identity.
pio_endf.pyusesbuild.sh'sMOTA_TARGET_IDwhen present (required for virtual LoRa-OTA build names), otherwise it computestarget_idfrom the PlatformIO env name. It readshw_idfromMOTA_HW_IDandfw_versionfromFIRMWARE_VERSION. The device reads them back (ota_self_firmware()), so a node's advertised identity is correct regardless of how it was built - and the packaging tool reads them straight from a raw.bin(no--target-env/--fw-versionflags, no reliance on filenames; Section 9, Section 13). A dev build with no dotted version simply carriesfw_version = 0/ emptyhw_id(= unknown) - still a full 56-byte trailer. - Size discovery: scan flash from the partition top downward for the
EndFmarker; the byte before it is the last BODY byte (the trailer is always 56 bytes). Seeota_self_firmware(). - Delta base matching: a node's
body_hashis read directly from its ownEndF; a delta'sbase_hash(Section 5) must equal it.body_hashis over BODY only. - No circularity:
EndFhashes only the BODY, never itself.
The "reconstructed image" referenced by the manifest is the full BODY || EndF (what gets flashed).
ESP32 application-slot profiles
ESP32 companion firmware is exempt from the portable-slot limit. USB and WiFi companion artifacts retain
LoRa OTA and carry -ota- in their filenames so they can seed a host folder over serial or TCP; they keep
their target partition table rather than using the FULL profile. A small set of high-capacity, non-PSRAM classic ESP32
companions cannot combine their configured contact, group-channel, and offline-queue capacities with LoRa
OTA in internal DRAM. Their normal artifacts remain unchanged, and option 3 also emits -full-ota- and
-full-logging-ota- variants with 100 contacts, 8 group channels, and a 16-frame offline queue. MQTT
observers and ESP-NOW bridges always use FULL builds because fitting them into the legacy slot would require
removing CLI and role features. Except for those FULL roles and the ESP32-C6 case below, non-companion ESP32
artifacts, including room, sensor, and repeater roles, must fit the legacy slot from 0x10000 up to
0x150000 (0x140000, 1,310,720 bytes), including the 56-byte EndF trailer. The build checks both that
limit and the target's actual app partition. The ESP32-C6 no_external_sensors OTA siblings are the narrow
exception: the Arduino 3.x WiFi runtime cannot fit that cross-family ceiling, so those images retain their
established target-specific 1920 KiB or larger A/B app layout and are checked against the actual app
partition. For every standalone ESP32 and nRF52 repeater, build.sh also exposes an explicit
*_lora_ota_no_external_sensors artifact: the ordinary repeater remains sensor-enabled, while that sibling
disables optional external environmental-sensor drivers for LoRa distribution. Integrated GPS and other
board-native telemetry remain enabled where the target selects the GPS-preserving lean profile. RAK3401 is
the explicit exception: RAK_3401_repeater_lora_ota_no_external_sensors undefines ENV_INCLUDE_GPS, so it
does not detect or use a RAK12501. RAK12501 GPS requires the ordinary full-sensor RAK_3401_repeater build
and sensor slot A; slot D conflicts with the RAK13302 radio's BUSY/DIO1 lines.
ESP32 siblings retain the compact browser WiFi updater and use the full
254-entry neighbor table. RP2040 and STM32 targets are not offered because
those platforms do not yet have a safe bootloader/apply path.
nRF52 LoRa-OTA siblings use size optimization rather than the Adafruit platform's default -Ofast. This
keeps the runtime software Ed25519 fallback from being expanded into tens of kilobytes of repeated curve
arithmetic while retaining CC310 hardware crypto, hardware RNG mixing, telemetry history, and board-native
features.
WiFi-heavy non-companion roles are not reduced to fit the legacy application slot. build.sh automatically
promotes every ESP32 MQTT observer and ESP-NOW bridge to the expanded FULL partition profile. These artifacts
retain the complete role CLI, WebConfig where supported, display and optional sensor support, full timezone
and TLS behavior, and the board's normal power-management implementation. The compact CLI is not compiled
into any build. Ordinary repeater builds remain sensor-enabled; only explicitly named
*_lora_ota_no_external_sensors siblings omit sensors for LoRa distribution, and those siblings retain the
complete CLI.
MQTT observer radio and bridge preferences use verified temporary files plus a recoverable backup. A reset
during a settings save restores the last committed common preference image or publishes the completed new
image; it does not leave a partially written /com_prefs file to fail on the next boot. A truncated legacy
image is rejected before any partial radio or string fields are applied, then rewritten from safe defaults.
Option 3 in build.sh emits *-full-ota-* and *-full-logging-ota-* ESP32 artifacts for
FULL-capable non-companion roles and for the constrained companion fallbacks described above. MQTT observers
and ESP-NOW bridges are emitted only with expanded FULL partitions. Menu option 8, or
build-full-esp32-firmwares, builds the logging-off FULL
artifacts from matching MQTT targets. Menu option 9, or build-full-esp32-logging-firmwares, builds the
FULL logging artifacts from matching non-MQTT targets.
FULL builds restore WebConfig, display support, optional external sensors, and the full role CLI and feature
set,
full ElegantOTA where that target declares the required library, and LoRa OTA for every included role,
including room servers, sensors, observers, and bridges. They use expanded A/B partition
tables: 1984 KiB application slots on 4 MiB boards and the framework's larger dual-OTA tables on 8 MiB
and 16 MiB boards. Explicit *_lora_ota_no_external_sensors targets are not duplicated; their ordinary
repeater build is the FULL, sensor-enabled counterpart. The *-full-logging-ota-* profile enables USB
debug and packet logging and explicitly disables MQTT. Install a matching
*-full-ota-*-merged.bin or *-full-logging-ota-*-merged.bin over USB once to write the expanded partition
table. After that, its matching non-merged FULL application image can be installed through USB, WiFi OTA,
or LoRa OTA. Do not install a non-merged FULL image onto a node that still has its old partition table.
Implementer note: the bootloader (and any non-Arduino consumer) MUST locate the body extent by scanning for
EndF, never by trusting a stored size - see the bootloader contract in Section 12.
3. The .mota container
The distributed form (host-built, wire-transferred). Parsed by mota_parse() in MotaContainer.cpp.
off size field
0 4 MAGIC = 6D 4F 54 41
4 4 MOTA_TOTAL_SIZE uint32 LE - total container bytes (incl. manifest, leaves[],
payload, trailer). Lets a node pre-reserve staging and compute
write_start = staging_region_end - MOTA_TOTAL_SIZE.
8 M MANIFEST (Section 4; M = 197 fixed + leaves[], 4*BC; no length field - BC from payload_size)
8 + M P PAYLOAD (payload_size bytes; delta or full image)
8 + M + P 5 TRAILER = 76 6B 34 39 36
MOTA_TOTAL_SIZE = 4 + 4 + M + P + 5. The manifest M includes leaves[]; the manifest-minus-leaves
prefix (mfl, sent over the wire as OTA_MANIFEST) is [8, leaves_off).
Staged (in-flash) form. Written bottom-aligned so TRAILER ends at staging_region_end. Identical
bytes, except the device mutates two regions in place (both NOR-safe, no re-erase): the leaves[] slots
(filled as blocks arrive - Section 7) and the 4-byte approval field (on owner consent - Section 4.2). Everything else
is immutable.
4. The manifest
Fixed layout. Every field sits at a constant offset and is always present - base_hash,
signer_pubkey and signature are zero-filled when not applicable (a full image / an unsigned container).
Only leaves[] is variable (one 4-byte hash per block). So the manifest-minus-leaves (mfl) is always
197 bytes and the parser is plain offset reads - no conditionals. Parsed by mota_parse_manifest().
off size field notes
0 1 format_ver = 0x02
1 1 flags bit0 FULL (0=delta/partial, 1=full image); bit1 SIGNED; bits2-7 reserved 0
2 1 hash_algo 0x12 = sha2-256
3 4 target_id device/arch/role discriminator (Section 9)
7 4 fw_version MAJOR<<24 | MINOR<<16 | PATCH<<8 | pre (comparable uint32)
11 4 image_size size of the reconstructed image (BODY||EndF)
15 4 payload_size PAYLOAD bytes in this container
19 1 block_size_log2 e.g. 0x0A = 1024
20 4 merkle_root sha2-256:4 over PAYLOAD blocks (Section 6) - also the manifest_id
24 32 image_hash sha2-256:32 of the reconstructed image - SECURITY anchor
56 1 codec_id 0=full/raw, 1=detools-sequential, 2=detools-in-place
57 32 hw_id NUL-padded ASCII hardware tag (e.g. "RAK4631"); same tag => bootable-compatible.
SIGNED. Applier refuses a mismatch (brick-safety); empty on either side = skip.
89 8 base_hash sha2-256:8 of the BASE image's BODY (== that build's EndF.body_hash). 0 if FULL.
97 32 signer_pubkey Ed25519 public key. 0 if not SIGNED.
129 64 signature Ed25519 over manifest[0, 129). 0 if not SIGNED.
193 4 approval FF FF FF FF = not approved; 41 50 52 56 ("APRV") = approved
--- end of manifest-minus-leaves: mfl = 197 (constant); leaves_off = 8 + 197 = 205 in the container ---
197 4*BC leaves[] BC = ceil(payload_size / 2^block_size_log2). sha2-256:4 each (the only variable field)
The signature always covers manifest[0, 129) (the head + base_hash + signer_pubkey). approval is
outside the signed region so it can be flipped in place on consent without breaking the signature.
Manifest-minus-leaves size (mfl) is a constant 197 bytes for every container (full or delta, signed
or unsigned). At 197 bytes the manifest exceeds one packet, so OTA_MANIFEST is always sent multi-fragment
(Section 8.4, 2 fragments) and reassembled by the fetcher.
4.1 Signed region
signature covers manifest bytes [0, 129) - the head + base_hash + signer_pubkey. It does not
cover approval or leaves[]:
leaves[]are verified against the signedmerkle_root(Section 6), so they need no separate signature.approvalis device-local consent (Section 4.2), deliberately outside the signature.
4.2 The approval field
- Distributed and forced on ingest to
FF FF FF FF(a peer can never pre-approve). - The local owner's
ota applydeltawrites41 50 52 56("APRV") - a single NOR-safe write (only clears bits from the erased word). Any partial/other value reads as not-approved (fail-safe). - Bound to this image (lives in this
.mota's manifest, re-erased when a new.motais staged). - A consent marker, not a security primitive. Authenticity =
signature+image_hash+hw_id.
5. Payload, codecs & delta base
PAYLOAD is either the full reconstructed image (FULL) or a delta (!FULL).
codec_id |
Meaning | Used by |
|---|---|---|
| 0 | full / raw | PAYLOAD = reconstructed image (`BODY |
| 1 | detools sequential | random read of base + sequential write of result -> ESP32 A->B inactive slot. |
| 2 | detools in-place | bounded scratch; rewrites the app region in place -> nRF52 single-slot. |
For deltas, base_hash = the base build's EndF.body_hash (sha2-256:8 of its BODY). A node applies a
delta only if base_hash matches its own EndF.body_hash. After applying, the result MUST hash
(sha2-256:32) to image_hash before it is booted - the hard security gate.
A fetcher only requests firmware it can apply. Each node declares the codec(s) it can apply
(set_apply_codec/set_apply_codec2): ESP32 accepts full + sequential (+ in-place). Internal-staging
nRF52 targets accept only in-place because internal flash cannot hold a second full application image.
Matched SD and raw-QSPI nRF52 targets accept full + in-place because external media holds the container. A .mota with
an unsupported codec is rejected at discovery time, before any blocks are requested. A manual pull to
an external folder may accept other codecs because that path captures bytes and never installs them.
Compression is internal to the detools patch and must be supported by the applier. Patches are produced by
detools 0.53.0 (tools/mota -> detools.create_patch) and decoded on-device by detools' embeddable C
decoder, vendored verbatim at src/helpers/ota/detools/ (see its README.meshcore.txt). That build
enables only the self-contained NONE + CRLE compressions (no malloc/liblzma/heatshrink), so MeshCore
deltas use --compression crle. Do not reimplement the codec - use the vendored decoder.
6. Merkle tree (sha2-256:4)
Verifies each PAYLOAD block against the signed merkle_root before the whole payload exists, so
corruption/forgery is localized to a block. Implemented in MerkleTree.cpp.
- Blocks: PAYLOAD splits into
BC = ceil(payload_size / B)blocks,B = 2^block_size_log2(default 1024). The last block is its real length (no zero padding). - Leaf:
leaves[i] = sha2-256:4( block_i_bytes ). - Internal node:
node = sha2-256:4( left || right )(4+4 input bytes). - Odd level: an odd count promotes the last node unchanged to the next level (no duplication).
- Root: reduce until one node remains.
BC == 1-> root =leaves[0].BC == 0is invalid.
6.1 Proofs
A proof for block i is the ordered list of sibling digests from leaf to root. Promoted levels contribute
no element. Verification (needs BC to know the tree shape):
h = leaf_i ; idx = i ; n = BC ; p = 0
while n > 1:
if (n is odd) and (idx == n-1): # this node was promoted
pass
else:
sib, side = proof[p] ; p += 1
h = sha2-256:4( sib || h ) if side==left else sha2-256:4( h || sib )
idx //= 2 ; n = (n + 1) // 2
accept iff h == merkle_root and p == len(proof)
Over LoRa, leaves[] are omitted from the manifest transfer. A serving node computes a block's proof
on demand from its stored leaves[] and normally sends OTA_PROOF immediately after that block's paced
OTA_DATA. OTA_REQ_PROOF remains the fallback for an older source or a lost proactive proof. The fetcher
fills its own leaves[i] as each verified block lands.
7. Block availability, staging & resume
There is no separate availability structure. Block i is present <=> leaves[i] is non-erased
(!= FF FF FF FF). Because leaves[] live in the staged flash region, availability survives reboot.
Commit order per block (crash-safe): (1) verify proof, (2) write block payload to its offset, (3) write
leaves[i] last. A power loss before step 3 leaves the slot erased -> the block is simply re-fetched
(idempotent). On boot a node rebuilds an in-RAM present-bitmap by scanning leaves[].
Resume (OtaManager::resumeStaged + OtaStore::checkpoint/reopen): an interrupted fetch resumes from
the staged container after a reboot - re-parse the stored manifest, recompute geometry, count present
blocks, continue fetching the holes (or jump straight to COMPLETE). The checkpoint cadence (persist progress
every N committed blocks) is runtime-tunable (ota config checkpoint <N>, 0 = only finalized containers
resume). Stores keep leaves[] in RAM until flush and never auto-GC, preserving resumable progress.
Flash-store note (RX-safe writes): a flash page-erase halts the CPU (~85 ms on nRF52) and starves LoRa
RX, so the flash stores (OtaStoreFlashNrf52/OtaStoreFlashEsp32) coalesce writes to the erase unit
(4 KB page / sector) and commit each once off the per-packet path - RAM stays O(one page), not O(image). A
small delta that fits page 0 does zero flash I/O until COMPLETE.
8. LoRa OTA protocol
Carried in MeshCore packets with PAYLOAD_TYPE_OTA = 0x0C. Every OTA packet payload is:
[0] ota_msg_type (OtaMsgType, OtaFormat.h)
[1..] body (fixed per type; encode/decode in OtaProtocol.cpp)
Message types:
ota_msg_type |
val | routing | purpose |
|---|---|---|---|
OTA_ADV |
0x01 | discovery | tiny per-node beacon (discovery tier 1) |
OTA_QUERY |
0x02 | discovery | ask a source for its catalog (discovery tier 2) |
OTA_HAVE |
0x03 | discovery | the catalog reply (fragmented, digest-tagged) |
OTA_GET_MANIFEST |
0x04 | transfer | request a manifest's fragments (want_mask) by manifest_id |
OTA_MANIFEST |
0x05 | transfer | the manifest-minus-leaves, fragmented |
OTA_REQ |
0x06 | transfer | request fragments from an adaptive flight of 1-4 blocks (want_mask per block) |
OTA_DATA |
0x07 | transfer | one self-describing fragment of a block's data |
OTA_REQ_PROOF |
0x08 | transfer | request/re-request a missing proof |
OTA_PROOF |
0x09 | transfer | the merkle proof for one block |
OTA_GET_LEAVES |
0x0A | transfer | request the target's leaves[] fragments (want_mask) - warm-start only |
OTA_LEAVES |
0x0B | transfer | a fragment of the leaves[] array (for host-side seed leaf-diff) |
manifest_id= the manifest'smerkle_root(4 bytes) - a compact content id present in every transfer message, so a multi-mota server dispatches each request to the right image.- Priority:
OTA_ADV,OTA_QUERY, andOTA_HAVEenqueue at background priority 250. Once a fetch is active, manifest, block request, data, and proof messages enqueue at primary priority 0. Relay-only nodes classify the wire message identically, so a transfer stays primary across the complete path. - Reliability is eventual: the fetcher re-requests only missing fragments after an adaptive deadline derived from packet airtime, outstanding response packets, duty pacing, and path length. The manager may still run a one-second maintenance tick, but that tick is not itself a retry timer. No hard ACKs or global ordering are required.
- Relay envelope: OTA still uses a bounded flood-shaped mesh header so the same packets can cross the
configured number of hops without first discovering an addressed return path. During TempRadio each node
forwards one copy; active OTA packets do not use the generic flood-retry subsystem. The fetcher verifies
every block against the signed root, and a repeater without
ENABLE_OTAcan transportPAYLOAD_TYPE_OTAopaquely without the manager, staging store, installer, or destination bootloader. - Hop limit + duty cycle: OTA floods accumulate one path-hash per relay (the mesh's flood routing). A
node with the OTA manager accepts a packet only if it arrived within
ota config hopshops (default 3;0= direct only) and relays it only while still under that limit, appending its own hash. Relay-only repeaters instead use their ordinary flood limits and forwarding filters. Discovery relays remain lowest-priority and may be skipped when the packet pool runs low. Active-transfer relays bypass that background pool gate and use priority 0; operators should therefore treat TempRadio as a dedicated OTA maintenance window because the transfer can delay unrelated mesh traffic.
8.1 Two-tier discovery
Because a node may serve many mOTAs (its own firmware plus an external folder - Section 10), discovery is split so the periodic beacon stays tiny regardless of catalog size:
Tier 1 - OTA_ADV beacon (10 bytes, constant). Flooded as a short burst at boot, then every
advert_mins minutes (default 24h; runtime-tunable via ota config advert, 0 disables the periodic
re-advertise). It is also emitted immediately whenever the served set changes (e.g. a motatool folder is
attached/detached), so peers learn about newly-available firmware without waiting for the next interval:
seeder_id[4] advertiser node id = pubkey[0:4]; the QUERY address + distinct-source id
n_motas uint8 - count of complete servable mOTAs (saturates at 255)
set_digest[4] sha2-256:4 over the SORTED set of served manifest_ids (see below)
set_digest is a content hash of the offering, not a counter: canonical across nodes, and it changes
iff the set of served mids changes. A peer that has already catalogued this {seeder, set_digest} ignores
the beacon (steady state is query-free). For a single served mota, set_digest = sha2-256:4(mid).
Tier 2 - OTA_QUERY -> OTA_HAVE (on interest only):
OTA_QUERY (flood): seeder_id[4] set_digest[4] filter_target(uint32) want_fragments(uint32)
# filter_target 0 = everything; want_fragments 0 = every fragment
OTA_HAVE (flood): seeder_id[4] set_digest[4] frag_idx(1) frag_total(1) n_rows(1) rows[]
HaveRow (16 bytes, OTA_HAVE_ROW_BYTES): mid[4] target_id(4) fw_version(4) codec_id(1) flags(1) have_count(2)
have_count is the number of blocks the source holds (== block_count for a complete offered image).
Receivers do not advertise partial or completed downloads as new sources.
A node interested in a source's offering schedules a QUERY; the source replies with its full catalog as
OTA_HAVE rows (fragmented if they exceed one packet - 10 rows per fragment). A receiver marks the catalog
complete only after all frag_total fragments arrive. If any are missing after the recovery timeout, it sends
another QUERY whose want_fragments bitmap names only the holes. want_fragments is an append-only extension:
an original 13-byte QUERY is still accepted and means "send every fragment." The heavy manifest is
fetched per-mid only on commit (Section 8.3).
Fragment numbers are canonical pages of the complete catalog sorted by manifest_id. filter_target may
remove rows from a requested page (and can therefore produce an empty fragment), but it never renumbers pages
or changes frag_total. This keeps missing-fragment recovery unambiguous when filtered and unfiltered queries
for the same {seeder, set_digest} are overheard together.
8.2 Anti-storm (mandatory at mesh scale)
If 50 neighbours all queried a new beacon at once, the mesh would collapse. Mitigations (gossip/mDNS
pattern), all in OtaManager:
OTA_HAVEis flooded and digest-tagged. EVERY node that overhears it caches the rows passively (keyed by{seeder, set_digest}) - no query of its own needed.- Jittered query: a peer needing a catalog schedules its
OTA_QUERYafter a random delayOTA_QUERY_MIN_MS (300) + rand(OTA_QUERY_SPREAD_MS (4000)), derived fromid +/ digest +/ self. - Overhear suppression: during the jitter window, overhearing another QUERY that covers the same scope,
or completing the HAVE fragment set for the same
{seeder, set_digest}, cancels the pending query. - Per-source recovery: each seeder has independent query/retry state. One source cannot overwrite another
source's timer, and a partial reply requests only missing fragments after 15 seconds (five bounded retries,
then another source ADV or explicit
ota lscan start a fresh series).
Net effect: a digest change costs ~1 query + ~1 HAVE flood mesh-wide; a stable mesh is query-free.
8.3 Fetch handshake
fetcher server (any node that has the mid)
OTA_GET_MANIFEST(mid, want_mask) > (want_mask=0xFFFF first; only missing fragments on retry)
<------- OTA_MANIFEST(mid, frag_idx, frag_total, bytes) x requested frags
(reassemble manifest, verify, compute geometry: BC, block_size, payload_size)
for each adaptive flight of missing blocks (starts at 1, grows on clean flights):
OTA_REQ(mid, {block_idx, want_mask}[]) > (one packet; all fragments first, only holes on recovery)
<------- OTA_DATA(mid, block_idx, frag_off, data) x requested frags/block
<------- OTA_PROOF(mid, block_idx, n_proof, proof) x requested blocks
(independently reassemble + verify each block, but remain RX-silent until the flight drains)
[after adaptive deadline: recover one block's holes, or OTA_REQ_PROOF for a missing proof]
(clean flight grows by one block; recovered flight halves the next width)
when all blocks present: verify full merkle_root + image_hash -> COMPLETE
8.4 Message bodies (transfer)
All offsets after the 1-byte type. Encoders/decoders in OtaProtocol.cpp; constants in OtaManager.h.
OTA_GET_MANIFEST: manifest_id[4] want_mask(uint16) # bit k = send manifest fragment k; 0xFFFF = all
OTA_MANIFEST: manifest_id[4] frag_idx(1) frag_total(1) bytes[] # up to OTA_MF_FRAG=176 B/frag
OTA_REQ: manifest_id[4] { block_idx(uint16) want_mask(uint16) }[1..4]
# one or more rows; bit k = send fragment k of that block
OTA_DATA: manifest_id[4] block_idx(uint16) frag_off(uint16) data[] # up to OTA_FRAG_DATA=160 B
OTA_REQ_PROOF: manifest_id[4] block_idx(uint16)
OTA_PROOF: manifest_id[4] block_idx(uint16) n_proof(1) proof[] # n_proof x 4 bytes
OTA_GET_LEAVES: manifest_id[4] want_mask(uint16) # bit k = send leaves fragment k; 0xFFFF = all
OTA_LEAVES: manifest_id[4] frag_idx(1) frag_total(1) bytes[] # up to OTA_LEAVES_FRAG=176 leaf bytes
-
Warm-start / leaf-diff (
OTA_GET_LEAVES/OTA_LEAVES) - motatool folder-capture only. Capturing a device's firmware into amotatool servefolder is slow (a full image is hundreds of blocks). Because builds here are non-deterministic, you cannot reproduce the exact target on the host - but a similar build (e.g. a fresh recompile) is ~99% identical. Somotatool serve --seed <similar.mota>stages that build's payload into the destination.part, andota pull <mid8> folder validatemakes the fetcher (1) bulk- fetch the target'sleaves[]viaOTA_GET_LEAVES/OTA_LEAVES(bitmap-fragmented with awant_mask, same anti-burst rule asOTA_MANIFEST), (2) recompute the merkle root from them and check it equals the manifest root (authenticate), then (3) keep every seeded block whose leaf matches and pull fullOTA_DATAonly for the blocks that differ. Thewant_maskis a fixed uint16, soleaves[]is capped atOTA_LEAVES_MAXFRAG=16fragments (OTA_DIFF_MAX_BLOCKS=704blocks); larger images just fall back to a full fetch. Normal P2P nodes never use this - they target only the blocks they want; the only always-on part is answeringOTA_GET_LEAVESwith leaves the node already holds, so any node's firmware can be captured. -
Block <-> fragments: a 1 KB block remains split into self-describing
OTA_DATAfragments.frag_offis the byte offset ofdatawithin the block, so the global position isblock_idx*block_size + frag_off- a fragment is self-placing when returned by the source. The fetcher tracks a per-block slice bitmap and reassembles before requesting the proof. -
Adaptive flight size is not signed block size. The container continues to use 1 KiB Merkle leaves and each slot is one existing 1 KiB block. A clean link changes how many of those blocks one
OTA_REQnames: 1, then 2, then 3, then the compiled cap. The manifest storesblock_size_log2, so 3 KiB is not a valid logical geometry; enabling 2 KiB would require larger device reassembly buffers and new-package/bootloader validation while saving only one request/proof pair per 2 KiB. It does not address premature retries, which were the dominant packet multiplier. -
Append-only request-window compatibility: the first four-byte request row is exactly the original
block_idx + want_maskbody. Old sources decode that row and ignore appended bytes. New sources queue all rows. If a new fetcher meets an old source, the unserved tail rows eventually time out and are recovered as ordinary single-row requests; the dirty flight then contracts. Old fetchers continue sending nine-byte single-row requests, which new sources accept normally. -
Fragment-level requests (anti-deadlock + anti-congestion):
OTA_REQ,OTA_GET_MANIFEST, andOTA_QUERYcarry fragment masks. For catalog discovery,want_fragmentsis a 32-bit bitmap and covers the protocol maximum 255-row catalog (26 fragments at the current packet size). For block and manifest transfer, thewant_maskis 16 bits. A fetcher requests the full set on the first ask ((1<<nf)-1, or0xFFFFbeforefrag_totalis known) and only the still-missing bits on any retry, so recovering one lost fragment re-sends one fragment, not the whole block/manifest. This is essential on half-duplex radios: re-requesting a whole multi-fragment burst let the periodic retry (a transmit) collide with the tail of the in-flight burst and drop the same fragment forever - a hang. Requesting only the hole removes the burst, so there is nothing to collide with. The block/manifest mask matches the 16-bit reassembly bitmap (<=16 fragments/block; 1 KB blocks = 7).OTA_PROOFis a single packet and needs no mask. -
Data and proof remain separate packets, without a normal extra round trip. A server retains requested blocks in a bounded descriptor queue, admits at most one response per main-loop pass, and sends one
OTA_PROOFafter each block's requested fragments. Before admitting that proactive proof, the source leaves an airtime/duty-aware 100-3000 ms RX turnaround gap. It accounts for one active transmission plus the two paced-response queue credits. A legacy receiver uses the gap to send its immediateOTA_REQ_PROOF; receiving that explicit request bypasses the remaining gap, avoiding a proof/request collision and an otherwise multi-second legacy retry. A newer receiver uses an airtime/path-aware proof grace (never less than 500 ms) and defersOTA_REQ_PROOFwhile any flight slot still expects DATA. Thus a missing early proof cannot make the receiver transmit into the rest of a legitimate half-duplex response train. After the complete-flight deadline, only one slot's missing fragments/proof is requested at a time.
8.5 Sizing against MAX_PACKET_PAYLOAD = 184
| message | fixed overhead | payload/packet |
|---|---|---|
OTA_DATA |
9 B (type+mid4+idx2+off2) | OTA_FRAG_DATA = 160 -> 7 frags per 1 KB block |
OTA_MANIFEST |
7 B | OTA_MF_FRAG = 176 -> signed manifest ~ 2 frags |
OTA_HAVE |
12 B | 10 rows x 16 B per fragment |
OTA_PROOF |
8 B | up to ~44 sibling digests (>> any real tree) |
A served mota supports up to OTA_MAX_BLOCK/4 leaves in the default 4 KB proof scratch (<=1024 blocks ~ 1 MB
payload); larger self-images pass a bigger scratch buffer.
8.6 Temporary-radio and transfer boundary
OTA packets may cross normal mesh relay hops, but each participating node processes or relays them only while
its tempradio window is actually running. A receiver selects missing blocks in serial order into a bounded
request flight. Every session starts with one block. A clean completed flight increases the next request by
one block; a flight requiring fragment/proof recovery halves the next width (4 -> 2, 3 -> 2, 2 -> 1).
The default compiled cap is two blocks; the RAK3401 LoRa-OTA target caps at four, so it probes
1 -> 2 -> 3 -> 4. All rows are sent in one backward-compatible OTA_REQ, and no freed slot is refilled
until the current flight is finished. It never serves
partial blocks. A normal install receiver never re-advertises its completed
download. An SD archive node is the deliberate exception: after a fully proof-verified container is published
to its persistent archive, it registers that complete file as a MotaSource and advertises it as a new seeder.
This keeps each active transfer as one transmitter and one receiver while still allowing active temporary-radio
repeaters between them and persistent archive nodes to improve future availability.
TempRadio is treated as a private maintenance network. Active transfer packets use priority 0, bypass the
public-flood receive holdoff, use the full transmit budget without overwriting the saved normal-radio airtime
factor, retain the relay role's airtime-scaled transmit collision window, and do not schedule generic flood
retries. Deployed firmware predating that TempRadio budget override can be accelerated manually with a saved
get af / temporary set af 0 / restore sequence. The bounded serving
queue admits at most two DATA/PROOF packets ahead of the radio while preserving at least four free packet-pool
entries. CAD remains enabled to arbitrate the half-duplex channel, but its busy retry is scaled to one-quarter
of a packet airtime and clamped to 5-50 ms instead of the ordinary 120-360 ms cadence. Discovery traffic keeps
collision jitter and background priority. The fetch deadline uses the active radio's measured maximum-packet
airtime, remaining DATA/PROOF packet count, dispatcher airtime factor, and longest observed path (falling back
to the configured hop horizon before one is observed), with bounded guard time. Faster SF/BW settings
therefore recover loss sooner; slower or multi-hop settings do not spuriously re-request a response still on
air. These changes remove software waits and duplicate bursts; they do not remove the one required forwarding
transmission per hop.
9. Identity, trust & versioning
target_id(4 B):sha2-256:4(pio_env_name)(little-endian uint32). The env name uniquely captures hardware and role/partition, so a node auto-fetches only matching firmware (a companion image is not fetched onto a repeater even though it shareshw_id). It is self-described in the firmware's EndF (Section 2, written bypio_endf.py) and read viaota_self_firmware(), so it is correct on any build;-D MOTA_TARGET_ID/MainBoard::getOtaTargetId()is the fallback when no EndF identity is present.tools/motareads it from the firmware's EndF (or--target-env). A manualota pull/wantcan override target (deliberate role switch); thehw_idbrick-safety gate (Section 4) still applies at apply time.target_idvshw_id- complementary, not redundant:target_idis the fetch-routing key (hw + role + partition);hw_idis the human-readable brick-safety key (hardware only). Same board, two roles => samehw_id, differenttarget_id.- Naming a
target_idlocally: only the 4-bytetarget_idever travels on the wire. To show which board/role a target is, a node (andmotatool) reverse-looks-it-up insrc/helpers/ota/OtaTargets.h- a generatedtarget_id -> env-nametable covering everyENABLE_OTAenv (tools/mota/gen_targets.py, resolved frompio project config). Soota lscan render[Heltec_v3_repeater]for a neighbour's beacon without the string being transmitted. Unknown IDs show as rawhw XXXXXXXX/N/Avalues. fw_version: packed comparable uint32 (MAJOR<<24 | MINOR<<16 | PATCH<<8 | pre); also self-described in EndF.ota lsprints the stable eight-hex manifest ID and uses[same target],[unsupported], or[rescue]after combining target equality with the local codec, bootloader, and EndF preflight. A known different target is rendered by environment name; an unknown/unset target remains raw or?. Target equality is routing information, not by itself an assertion that an image is safe to install.hw_id: 32-byte NUL-padded ASCII hardware tag inside the signed head. The applier refuses a.motawhosehw_iddiffers from the device's own tag (empty on either side = permissive). Brick-safety independent of signature.- Signing & allowlist: a node keeps a runtime allowlist of trusted Ed25519 signer pubkeys (none embedded
in firmware;
ota key add/list/rm). A.motais eligible for auto-install only if signed by an allowlisted key, the signature verifies, andimage_hashmatches. Manual install permits unsigned packages, but a package that claims to be signed must have a valid signature from an allowlisted key or it is rejected. Transfer needs no trust - blocks are content-addressed against the manifest's merkle root. - Policies (persisted):
autofetchin {off, any, signed} (default off) gates automatic block fetching of own-target adverts;autoinstallin {off, trusted} (default off) gates auto-apply of a COMPLETE signed + allowlisted fetch. Conservative defaults: a fresh node discovers + announces but never fetches/installs without operator intent. - Supersession: a newer version announced mid-download does not abort the in-progress transfer (finish-current).
10. Multi-mota serve & the external "folder" relay
A node serves a set of mOTAs: its own firmware plus, optionally, an external folder of .mota files it
relays without holding them in flash. To peers it simply "has N mOTAs"; the relay is trustless (fetchers
verify everything). The serve side (OtaManager) keeps a lightweight registry of what it advertises and two
resident "views": view0 (its own firmware) and one on-demand view loaded from a source when a request
targets an external mota. Every fetch message carries manifest_id, so dispatch is a registry lookup.
The same host-folder link is also a pull destination (the reverse direction): ota pull <mid8> folder
fetches a .mota off the mesh and streams it onto the host as <mid>.mota via the seeder STORAGE ops
(OP_STAT/BEGIN/WRITE/SREAD/FIN, see MotaSeederProto.h), using a FolderMotaStore as the fetch's
OtaStore instead of RAM/flash. This captures an exact copy of a device's firmware - e.g. to build a delta
against firmware you don't have. Resume is bookkeeping-free: BEGIN 0xFF-fills the file and, on reconnect
after a link drop (the fetch PAUSES, holding progress on the host - no RAM/flash fallback), STAT+SREAD
let the fetcher recompute and refill only the missing blocks.
10.1 The MotaSource abstraction (OtaSource.h)
Transport-agnostic provider of one or more complete .mota as random-access bytes. The same serve code
drives USB-serial, BLE, a WiFi URL list, an NFS/samba mount, etc. - only read() differs.
struct MotaDesc { // catalog metadata + region offsets (no whole image in RAM)
uint8_t mid[4]; uint32_t target_id, fw_version; uint8_t codec_id, flags;
uint32_t total_size, leaves_off, block_count, payload_off, payload_size;
};
class MotaSource {
virtual uint8_t count(); // # mOTAs offered
virtual bool describe(uint8_t idx, MotaDesc& out); // metadata + offsets
virtual bool read(uint8_t idx, uint32_t off, uint8_t* buf, uint32_t len); // random-access bytes
};
To serve an external mota the node reads its manifest-minus-leaves + leaves[] into RAM (<=4 KB for <=1024
blocks) and streams payload blocks from the source on demand; proofs are generated from the read leaves.
10.2 The mota-seeder transport (MotaSeederProto.h)
A MotaSource is fed by a host that serves a folder over the device's USB serial (the same console the
CLI uses - no extra hardware) or, on an ESP32 WiFi companion or FULL ESP32 role, over WiFi (TCP). The
host is the
standalone Rust tool motatool (motatool serve --serial <port> /
--tcp <host[:port]>, which also builds + verifies + inspects .mota). The device only emits request frames while
actively serving a fetch, and reads the reply synchronously, so over the shared USB console binary frames
coexist with the text CLI/logs (resync on magic + checksum). Little-endian, XOR-checksummed:
request (device -> host): 'M' 'S' op(1) args... xsum(1 = XOR of op+args)
response (host -> device): 'm' 's' op(1) status(1) payload... xsum(1 = XOR of all prior)
OP_COUNT 0x01 args: - -> payload: count(1)
OP_DESCRIBE 0x02 args: idx(1) -> payload: MotaDesc wire (38 B)
OP_READ 0x03 args: idx(1) off(4) len(2) -> payload: len bytes
MotaDesc wire (38 B): mid[4] target_id(4) fw_version(4) codec(1) flags(1)
total_size(4) leaves_off(4) block_count(4) payload_off(4) payload_size(4)
block_size_log2(1) reserved(3)
status: 0 = OK, non-zero = error (out of range / past EOF).
SerialMotaSource splits logical reads into replies of at most 192 payload
bytes. A manifest's leaf table can exceed 256 bytes and payload blocks are
normally 1 KiB; requesting either in one transaction can overrun common USB
CDC/UART receive rings even though the host successfully wrote the complete
reply. Chunking is internal to the transport and does not change OP_READ or
the MotaSource random-access contract.
Manifest fragments are retained as bounded response jobs and admitted one at
a time. Their source-side gap follows the active maximum packet airtime and
dispatcher duty spacing, clamped to 100-1000 ms. The 100 ms floor protects
fast radios' TX-to-RX turnaround; the cap keeps the receiver's one-second
manifest progress/retry observation responsive. The source uses the same
radio-aware 100-3000 ms drain/turnaround gap before an unsolicited block proof,
but immediately serves a
legacy receiver's explicit OTA_REQ_PROOF. Relay collision delay is a separate
setting: active OTA floods honor the relay role's configured txdelay, and the
deployment runner temporarily uses 0.3 on managed relays.
What to plug into --serial. Use the USB serial console of an OTA-enabled MeshCore node built with
OTA_FOLDER_SERIAL. The node must have a working LoRa radio plus an
ota folder on command; that command confirms it can host and advertise the folder. A KISS modem will not
work: KISS firmware exposes a TNC/KISS frame interface, not the MeshCore CLI and mota-seeder
request/response transport. An ESP32 WiFi companion or FULL ESP32 role with active WiFi is the alternative
source connection: use its dedicated seeder port with motatool serve --tcp <host>:5001.
Device CLI: ota folder on (attach + announce), ota folder (list), ota folder off. Build flag
OTA_FOLDER_SERIAL (default stream = console Serial; override OTA_FOLDER_SERIAL_STREAM + define
OTA_FOLDER_SERIAL_BEGIN for a dedicated UART). ESP32 WiFi companions and FULL ESP32 roles run a
WiFiServer on the dedicated seeder port (OTA_SEEDER_TCP_PORT, default 5001) while WiFi is usable.
On a companion it is separate from the app port (TCP_PORT, default 5000); on infrastructure roles it is
separate from WebConfig and browser OTA on port 80. The node auto-attaches the source when a seeder client
connects and detaches when it closes (no ota folder on needed over TCP). An already-active serial folder
causes a TCP client to be rejected instead of silently replacing it. Verified on hardware: a RAK4631
relays a host folder to a
Heltec V3 over one USB cable, and a host feeds a Heltec V3 over WiFi (:5001) while the companion serves a
phone on :5000 - every block merkle-checked.
The attach reply and bare ota folder report host=advertised/offered. The registry is RAM-bounded
(OTA_MAX_SERVE, with the node's own firmware consuming one slot), so a host may correctly index more valid
files than this particular firmware can advertise. Omitted entries are now reported instead of silently
disappearing. Operators should split a large chain or use a higher-capacity/SD seeder when the two counts differ.
Transport-agnostic by design. The request/response semantics (COUNT / DESCRIBE(idx) /
READ(idx, off, len) over a folder catalog) are independent of the link. The 2-byte magic + XOR checksum +
resync framing above exists for the shared USB-UART (an unframed byte stream); it is harmless over a
reliable stream and the WiFi (TCP) transport reuses it as-is - both ends just treat the socket as a
byte stream (on-device, SerialMotaSource runs verbatim over an Arduino Stream-compatible WiFiClient;
motatool's TcpTransport mirrors its SerialTransport). A future framed link such as BLE GATT (an
Android phone relaying a folder) could carry the same ops with no magic/checksum at all - a request
characteristic write delivers op + args, the reply notifies status + payload. motatool reflects this
split: a transport-free SeederCore (the catalog logic) under a swappable framing/transport layer.
11. CLI surface (OtaCli.cpp)
User-facing OTA data should travel via CMD_OTA_* companion binary frames; the text CLI below is
debug/operator oriented and replies are snprintf-bounded into a 160-byte buffer.
Commands take intuitive aliases (matched by the first word; see is_cmd in OtaCli.cpp) so they're easy
to type and read - status/neighbors/pull/drop/applydelta are the canonical names, the aliases are
the recommended user-facing forms. Output is plain-language (a user-facing guide lives at
ota_user_guide.md).
ota help | ? | h list the commands
ota status | st (or bare `ota`) plain-language: running fw, the one fetch session (state/%/id), serving, keys
ota ls | neighbors | nbrs | updates | n [page] paged updates (queries sources; rows arrive async via OTA_HAVE)
ota get | pull | download <mid8|#index> flash [rescue] | folder [validate]
fetch by stable mid8 (preferred) or current page index
ota install | apply | applydelta verify + approve + (ESP32) apply / (nRF52) reboot-to-bootloader
ota rescue install <base_hash16> internal-flash nRF52 only: recover from failed app-side EndF validation
ota cancel | drop | stop drop the current fetch session (frees the slot)
ota announce | adv serve self + send a beacon now
ota self | id print this firmware's EndF (body/image size, base_hash)
ota folder | fold [on|off] attach/detach an external .mota folder (host daemon) ; bare = list
ota config | cfg | set [autofetch|autoinstall|checkpoint] ... show/set persisted policy
ota key | keys [add|rm <hex>] trusted signer allowlist ; bare = list
ota dev ... bring-up helpers (stage/recv/serve/verify)
12. Apply & bootloader contract
- ESP32 (A/B): applied in-firmware via the detools decoder into the inactive OTA slot
(
OtaApply.cpp::ota_apply_detools_mota+OtaStoreFlashEsp32), then set-boot + reboot (power-safe, rollback-capable). No bootloader changes. Erase ranges must be sector-aligned (4096). - nRF52 (single-slot): the running firmware never flashes the app.
ota installverifies the container fully (image_hash, codec, signature/allowlist,hw_id, andbase_hashfor a delta), writesapproval = "APRV", then reboots into the modified bootloader (Adafruit_nRF52_Bootloader_OTAFIX). The bootloader:- locates the staged
.motain the approved internal, raw-SD, or raw-QSPI store without trusting an unchecked stored size, - re-checks
TRAILER,image_hash, andapproval == "APRV"; for a delta it also checks thatbase_hashequals the running firmware'sEndF.body_hash(recomputed by scanning forEndF- never trustbank_0_size), - writes a full external-media payload or applies the in-place codec over the app region, then boots only
if the result hashes to
image_hash.
- locates the staged
- nRF52 internal staging ceiling: an internal-store application derives the ceiling from facts available
in every build, not a board-name list. A companion that actually links the internal ExtraFS datastore stays
below
0xD4000; a default linker region or a role that does not mount ExtraFS can reclaim the unused 100 KiB through0xED000. The application uses the larger window only when the installed bootloader advertises the GPREGRET2 ceiling-handoff capability. The bootloader treats every unknown/legacy handoff value as0xD4000, and accepts a container only at the bottom-aligned position for the selected ceiling. - nRF52 dynamic apply window: the post-build hook records the resolved app base, linked app end,
internal-ExtraFS/SD/QSPI storage flags, and desired staging ceiling immediately before
EndF.motatoolreads that authenticated firmware record and choosesmemory_sizefrom the actual patch size and bottom-aligned stage address; firmware without the record retains the conservative0x98000default. Before writingAPRV, an internal-store app validates the staged-address bound; an external SD/QSPI app validates the full detools geometry against the application workspace. The bootloader independently parses and validates the same geometry before its first application write. Expanded auto-sized packages require a bootloader with the ceiling-handoff capability; use--inplace-memory 0x98000when intentionally targeting an older bootloader and the images still fit that window. - nRF52 EndF rescue:
ota rescue install <base_hash16>is a pre-provisioned recovery path for an internal-flash nRF52 application that still runs but cannot validate its own EndF identity. It refuses when normal EndF validation succeeds, requires the operator hash to exactly equal the staged delta'sbase_hash, requires the packagetarget_idto match and itshw_idto pass the normal hardware gate, and retains the normal payload and signature/allowlist gates. Approval only delegates the base decision: OTAFIX independently locates the physical EndF, hashes the running app, and compares that value with the package before its first app write. A physically absent/corrupt EndF or wrong base therefore returns to the unchanged app; it still requires USB recovery if that app does not already contain this command. A chain intended to cross historical firmware must introduce this command in its first bridge and retain it in every later bridge. Manual pulls still use the build-provided target ID when app-side EndF parsing fails, so a rescue-capable bridge can fetch its exact successor before invoking the guarded command. Such a node must acknowledge the condition up front withota pull <mid8> flash rescue; an ordinary flash pull refuses before altering staged data. Firmware that predates both rescue commands still requires USB recovery. - MeshTower V2 SD nRF52: the application stores a contiguous
/meshcore-ota.motaon microSD and publishes its raw sector range in a checksummed handoff record outside the MBR partition. The matching bootloader reads the card without mounting FAT, supports either a full image or an in-place delta, verifies the staged/full result hash, and never writes through0xED000where InternalFS begins. - Matched external-QSPI nRF52 repeaters: the application reserves the board's dedicated QSPI NOR as a
raw store beginning at offset zero. It obtains a 1-16 MiB capacity from JEDEC, checkpoints payload before
leaf metadata, and verifies each erased/programmed page. GPREGRET2
0x51selects QSPI only when the matching bootloader advertises the QSPI storage bit; legacy markers retain the internal scan path. The bootloader pre-hashes a full payload before invalidating the app, or applies an in-place delta with the complete internal application region as workspace. Companion builds never enable this raw store: some use QSPI as a filesystem, while others simply leave that chip outside OTA ownership. See the nRF52 QSPI guide.
A signature, when present, proves author authenticity and must pass the device allowlist. Unsigned packages
remain installable when local policy permits them. The one-shot approval marker records local consent for
either form before the bootloader may apply it.
Bootloader testing note: always test apply with a real different image (base != target). A same-image (X->X) "delta" trivially reproduces the target and gives a false positive.
13. Versioning of this spec
format_ver = 2. A parser accepts exactly this value and rejects anything else - there is one container
format, fixed-layout, and no compatibility shims to carry. If the format ever needs to change, bump
format_ver; the multihash hash_algo separately allows swapping the digest family without a format
bump. Unknown codec_id / ota_msg_type values are ignored (a node simply won't fetch what it can't apply).