# 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](ota_user_guide.md) - 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`](https://github.com/vk496/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](https://github.com/multiformats/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 discovery `set_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 by `hash_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 | | application `format_ver` | `0x02` | ordinary full/delta application package | | bootloader `format_ver` | `0x03` | privileged exact-identity nRF52 bootloader package only | | `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 | | `MFLAG_BOOTLOADER` | `0x04` | flags bit2; valid only in the exact v3 bootloader profile | | `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 application block size | `2048` | `block_size_log2 = 0x0B`; deployed 1 KiB application packages remain accepted | | Bootloader-package block size | `1024` | fixed `block_size_log2 = 0x0A` for format 3 | | 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`) | Current application firmware publishes its receive/reassembly ceiling as the compact `maxblk:2048` field near the front of both `ota status` and `ota self`. Keeping the field ahead of variable diagnostics ensures it survives the 160-byte remote-admin reply limit. Host updaters must treat an absent field as the deployed legacy capability (`1024`), reject a ready package whose manifest block size exceeds that capability, and use the reported size when turning a raw firmware image into a new application package. This capability does not change the format-3 bootloader package's fixed 1 KiB geometry. --- ## 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.py` uses `build.sh`'s `MOTA_TARGET_ID` when present (required for virtual LoRa-OTA build names), otherwise it computes `target_id` from the PlatformIO env name. It reads `hw_id` from `MOTA_HW_ID` and `fw_version` from `FIRMWARE_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-version` flags, no reliance on filenames; Section 9, Section 13). A dev build with no dotted version simply carries `fw_version = 0` / empty `hw_id` (= unknown) - still a full 56-byte trailer. - **Size discovery:** scan flash from the partition top downward for the `EndF` marker; the byte before it is the last BODY byte (the trailer is always 56 bytes). See `ota_self_firmware()`. - **Delta base matching:** a node's `body_hash` is read directly from its own `EndF`; a delta's `base_hash` (Section 5) must equal it. `body_hash` is over BODY only. - **No circularity:** `EndF` hashes 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. When an exact Full recipe exists, one expanded-partition image supplies USB, BLE, WiFi, source-only LoRa OTA, and persistent USB logging instead of separate transport artifacts. A small set of high-capacity, non-PSRAM classic ESP32 companions use 100 contacts, 8 group channels, and a 16-frame offline queue in that combined image to preserve internal-DRAM headroom. 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 standalone ESP32 and nRF52 repeaters that need a lean staging profile, `build.sh` also exposes an explicit `*_lora_ota_no_external_sensors` artifact: the ordinary repeater remains sensor-enabled, while that sibling trims selected optional environmental/ranging drivers for LoRa distribution. SolarXiao 30S and 33S use matched external QSPI staging, so their ordinary full-sensor repeater is already install-capable and no redundant lean sibling is generated. Integrated GPS and other board-native telemetry remain enabled where the target selects the GPS-preserving lean profile. The legacy suffix describes a driver trim, not removal of the generic I2C bus. Reduced RAK3401 and RAK4631 profiles retain INA219, INA226, INA260, and INA3221 voltage/current monitors. These are retained optional-sensor-table drivers, not the only I2C consumers: SSD1306 display, supported autodiscovered RTCs, and RAK12500 GPS remain separate I2C peripherals where selected by the board recipe. The RAK3401 OTA repeater also retains RAK12500 I2C and RAK12501/L76K UART GPS support; install either GPS module in sensor slot A because slot D conflicts with the RAK13302 radio's BUSY/DIO1 lines. The plain RAK4631 OTA repeater and its Serial2 bridge retain GPS. RAK12501 uses Serial1. Its explicitly compiled Serial1 bridge therefore omits the combined GPS provider, including RAK12500, even though RAK12500 itself does not use the UART. The firmware-configured INA3221 address and RAK12500 address are both `0x42`, so those devices cannot share one bus at those addresses. Keep RAK12500 at `0x42`, strap INA3221 A0 to SCL for `0x43`, and use firmware built with `-DTELEM_INA3221_ADDRESS=0x43` when both are installed. ESP32 siblings retain the compact browser WiFi updater and use up to 254 neighbors. Internal-DRAM-constrained targets use 50 as recorded in the artifact capability manifest; see [ESP32 memory budget](esp32_memory_budget.md). 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 trim selected optional environmental/ranging drivers for LoRa distribution, and those siblings retain the complete CLI and target-declared I2C peripherals. 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 one `*-full-usb-wifi-ota-*` ESP32 artifact for each FULL-capable non-companion hardware/role that has a matching MQTT environment. It compiles USB packet logging and direct WiFi MQTT together. A `*-full-logging-ota-*` fallback is emitted only when there is no MQTT sibling; ordinary non-OTA roles compile runtime USB logging into their canonical image, so separate standard-logging artifacts are not emitted. Non-MQTT FULL twins are also skipped for covered ESP32 roles. MQTT observers and ESP-NOW bridges are emitted only with expanded FULL partitions. Menu option 8, or `build-full-esp32-firmwares`, builds the unified profiles plus necessary fallbacks. Menu option 9, or `build-full-esp32-logging-firmwares`, builds only those fallbacks. 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-usb-wifi-ota-*` profile enables USB packet logging and MQTT, with a persistent `logging.output` selector; its verbose internal debug remains off. The fallback `*-full-logging-ota-*` profile enables USB debug and packet logging and has no MQTT target. Install a matching `*-full-usb-wifi-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 application, or 0x03 privileged bootloader package 1 1 flags bit0 FULL; bit1 SIGNED; bit2 BOOTLOADER; bits3-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. 0x0B = 2048 (new application default); 0x0A = deployed 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. The two versions are deliberately disjoint. Version 2 accepts application packages only and rejects the BOOTLOADER bit. Version 3 accepts only flags exactly `FULL|SIGNED|BOOTLOADER`; a non-bootloader v3 package is invalid. Consequently, deployed v2-only application parsers reject a bootloader package before they can mistake its raw 40 KiB payload for an application image. ### 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 signed `merkle_root` (Section 6), so they need no separate signature. - `approval` is 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 applydelta` writes `41 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 `.mota` is staged). - A **consent** marker, not a security primitive. Authenticity = `signature` + `image_hash` + `hw_id`. ### 4.3 Privileged nRF52 bootloader package profile A v3 bootloader package has a deliberately narrow, non-extensible profile: - flags exactly `FULL|SIGNED|BOOTLOADER`, `CODEC_FULL`, nonzero `fw_version`, zero `base_hash`; - a raw payload and `image_size` of exactly `0xA000` (40 KiB), split into exactly forty 1024-byte blocks; - a target derived from the installed CRC-valid embedded manifest identity. Deployed XIAO identities keep raw board IDs `0x28860044`/`0x28860045`; generic identities use LE32(SHA-256(canonical padded hw32)); - signed `hw_id` exactly `XIAO_BL_28860044`/`XIAO_BL_28860045` for deployed XIAO, or the zero-padded 32-byte `NRF_BL__` for a generic target; - a sane nRF52840 vector table, exactly one CRC-valid embedded manifest v1 with the exact board/name pair, followed by the required CRC-covered `BLM2`/`SOFT` continuity extension (embedded boot version, SoftDevice family/FWID, application base, and layout ABI), with that complete 76-byte envelope at the canonical final-image offset `0x9FB4`, and exactly one `MOTABLDR` marker advertising ABI >= 3, both application codecs (`FULL|INPLACE`, mask `0x0005`), boot-update continuity, and the exact storage flags for the application layout (`0x09` MeshTower V2 SD, `0x0E` XIAO QSPI, or `0x0A` shared internal staging). The incoming embedded identity must exactly match the installed CRC-valid bootloader identity. Both scans consider every aligned structurally valid candidate so magic bytes in a literal pool cannot shadow the real manifest. Duplicate accounting counts each CRC-valid 44-byte base record before interpreting adjacent continuity metadata, so a corrupt or half-present `BLM2` extension cannot hide a second identity; after exactly one base record is selected, malformed claimed continuity fails closed. A package must be signed by a key already in the device's trusted allowlist; unlike ordinary application packages, there is no unsigned manual-install exception. The signed outer `fw_version` must equal the embedded boot version. Qualified internal/QSPI targets may bootstrap a CRC-valid legacy-v1 installed bootloader once; MeshTower SD instead requires local BLM2 provisioning because it has no safe legacy media handoff. After bootstrap every remote successor must be strictly newer and match the live SoftDevice/application layout. Low-byte zero and all-ones boot versions are invalid. Remote rollback has no override and must use local DFU/SWD. --- ## 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||EndF`). ESP32 A/B or an external SD/QSPI nRF52 target. | | 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` (new application default 2048; deployed 1024-byte application packages remain valid). The last block is its real length (**no zero padding**). Format-3 bootloader packages remain fixed at 1024 bytes. - **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 == 0` is 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[]` for a persistent, reopenable store. A hybrid nRF52 transfer is the deliberate exception: its SRAM-backed payload suffix is volatile, so the application refuses to adopt that staged header instead of rebuilding partial progress. **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 `, 0 = only finalized containers resume). Boot-time adoption is an automatic fetch decision: current `autofetch` must be enabled, the stored target must equal the node target, policy `signed` requires the signed bit, and an enabled running-version floor requires a strictly newer manifest. An explicit MID pull may deliberately resume an older or unsigned package and keeps target `0` as a MID-only wildcard. Stores keep `leaves[]` in RAM until flush and never auto-GC, preserving resumable progress. The debug/operator equivalent is `ota dev resume `; after a reboot the MID is mandatory, while a no-argument form may only reuse a still-active session MID. It never uses the `nullptr` automatic-adoption path, so a malformed MID or no active MID fails closed. Hybrid nRF52 staging cannot enter this resume path after an application restart, even if its flash prefix still contains metadata; the complete logical container must be fetched again. **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. Ordinary stores keep RAM at O(one page), not O(image); a qualified hybrid nRF52 profile additionally reserves one fixed 64 KiB staging arena. 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's `merkle_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`, and `OTA_HAVE` enqueue 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_OTA` can transport `PAYLOAD_TYPE_OTA` opaquely 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 hops` hops (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_HAVE` is 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_QUERY` after a random delay `OTA_QUERY_MIN_MS (300) + rand(OTA_QUERY_SPREAD_MS (4000))`, derived from `id +/ 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 ls` can 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 ``` Before allocating or writing the selected store, the receiver parses the reassembled manifest and requires its `merkle_root` to equal the requested/wire `manifest_id` and its `target_id` to equal the catalog or explicit-pull target that opened the receive slot. The wire envelope and HAVE row are advisory; they cannot label and stage a different manifest. ### 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] # legacy: bit k requests its 160-byte fragment # v2: bit15=marker, bit14=allow transport DEFLATE, # bit13=2 KiB descriptor, bits0..12=fragment bitmap; # bit12 must be zero when bit13 is set (0xFFFF stays legacy) OTA_DATA legacy: manifest_id[4] block_idx(uint16) frag_off(uint16) data[<=160] OTA_DATA v2: manifest_id[4] block_idx(uint16) descriptor(uint16) stream_id[4] data[<=171] # deployed 1 KiB descriptor: bit15=marker, bit14=DEFLATE, # bits13..10=fragment, bits9..0=complete encoded length minus one # negotiated 2 KiB descriptor: bit15=marker, bits14..11=fragment, # bits10..0=complete encoded length minus one; DEFLATE iff that # length is strictly less than the manifest-derived raw block length 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 a `motatool serve` folder 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. So `motatool serve --seed ` stages that build's payload into the destination `.part`, and `ota pull folder validate` makes the fetcher (1) bulk- fetch the target's `leaves[]` via `OTA_GET_LEAVES`/`OTA_LEAVES` (bitmap-fragmented with a `want_mask`, same anti-burst rule as `OTA_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 full `OTA_DATA` only for the blocks that differ. The `want_mask` is a fixed **uint16**, so `leaves[]` is capped at `OTA_LEAVES_MAXFRAG=16` fragments (`OTA_DIFF_MAX_BLOCKS=704` blocks); 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 answering `OTA_GET_LEAVES` with leaves the node already holds, so any node's firmware can be captured. - **Negotiated 171-byte fragments:** the deployed profile remains unchanged: `frag_off` is a byte offset and `data[]` carries at most 160 bytes. A new fetcher marks an `OTA_REQ` row as v2. For a deployed 1 KiB block, its first request also includes every fragment bit needed by the legacy representation, so an old source can answer it completely. The extended 2 KiB profile reserves bit 12 as zero; this both fits its twelve v2 fragments and ensures the deployed `0xFFFF` legacy all-fragments request is never misclassified. Receipt of untagged `OTA_DATA` switches the fetch session to the old geometry. If no v2 data appears by the first adaptive deadline, the fetcher retries with an ordinary legacy mask. New sources answer v2 with a packed descriptor, a repeated 4-byte representation ID, and exactly 171 data bytes except the final fragment. A raw 1 KiB block falls from seven data packets to six; a raw 2 KiB block falls from thirteen to twelve. Message type IDs do not change, so multi-hop relays continue to forward request/data/proof packets opaquely with the same priority. - **Transport-only DEFLATE:** a fetcher sets the v2 DEFLATE-permission bit only when its application includes and configures the decoder. A source may encode each logical block as an independent raw RFC 1951 stream with full stored, fixed-Huffman, and dynamic-Huffman support (`BTYPE=0/1/2`). Compression is used only when strictly smaller, otherwise the source returns raw v2 data. The receiver inflates to the manifest-derived block length, then performs the unchanged Merkle proof and writes the original bytes to staging. Thus staging and bootloader apply remain uncompressed. Deployed 1 KiB `.mota` containers remain compatible; new 2 KiB containers require a receiver advertising the extended descriptor. Format-3 bootloader packages retain their strict 1 KiB geometry. The 4-byte `stream_id` is SHA-256:4 of the complete raw or compressed representation and appears in every fragment. A receiver locks `{encoding, encoded length, stream_id}` for a block, preventing fragments from independently encoded seeders from being mixed. After one sparse retry, a second stalled interval clears the full in-flight window and switches the session to legacy geometry, so a vanished v2 seeder cannot prevent an older source from taking over; proof verification remains unchanged. Every `ENABLE_OTA` MeshCore application registers the vendored tinf 1.2.1 full raw decoder; its wrapper bounds output to the exact logical block length and rejects truncation, malformed streams, and trailing whole bytes. Builds without OTA do not link the decoder. This application capability is independent of the bootloader because transport data is inflated before the unchanged staged container is written. - **Adaptive flight size is not signed block size.** Each slot holds one manifest-defined block: 1 KiB for deployed application containers or 2 KiB for new ones. A clean link changes how many complete blocks one `OTA_REQ` names: 1, then 2, then 3, then the compiled cap. It never changes block geometry. Two KiB halves the leaf, block-request, and proof count for a payload while giving each independent DEFLATE stream a larger history window. - **Append-only request-window compatibility:** the first four-byte request row is exactly the original `block_idx + want_mask` body. 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`, and `OTA_QUERY` carry fragment masks. For catalog discovery, `want_fragments` is a 32-bit bitmap and covers the protocol maximum 255-row catalog (26 fragments at the current packet size). For block and manifest transfer, the `want_mask` is 16 bits. A fetcher requests the full set on the first ask (`(1< 7 raw frags/1 KiB or 13/2 KiB | | `OTA_DATA` v2 | 13 B (legacy header + stream ID) | `OTA_FRAG_DATA_V2 = 171` -> 6 raw frags/1 KiB or 12/2 KiB; fewer when compressed | | `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 1024 leaves in the default 4 KiB proof scratch (about 2 MiB of payload at the new 2 KiB default, or 1 MiB for a deployed 1 KiB container); 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 shares `hw_id`). It is **self-described in the firmware's EndF** (Section 2, written by `pio_endf.py`) and read via `ota_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/mota` reads it from the firmware's EndF (or `--target-env`). A manual `ota pull`/`want` can override target (deliberate role switch); the `hw_id` brick-safety gate (Section 4) still applies at apply time. - **`target_id` vs `hw_id`** - complementary, not redundant: `target_id` is the fetch-routing key (hw + role + partition); `hw_id` is the human-readable brick-safety key (hardware only). Same board, two roles => same `hw_id`, different `target_id`. - **Naming a `target_id` locally:** only the 4-byte `target_id` ever travels on the wire. To show *which* board/role a target is, a node (and `motatool`) reverse-looks-it-up in `src/helpers/ota/OtaTargets.h` - a generated `target_id -> env-name` table covering every `ENABLE_OTA` env (`tools/mota/gen_targets.py`, resolved from `pio project config`). So `ota ls` can render `[Heltec_v3_repeater]` for a neighbour's beacon without the string being transmitted. Unknown IDs show as raw `hw XXXXXXXX` / `N/A` values. - **`fw_version`:** packed comparable uint32 (`MAJOR<<24 | MINOR<<16 | PATCH<<8 | pre`); also self-described in EndF. `ota ls` prints 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 `.mota` whose `hw_id` differs 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 `.mota` is eligible for **auto-install** only if signed by an allowlisted key, the signature verifies, `image_hash` matches, and its nonzero signed `fw_version` is strictly greater than the running hash-valid EndF version. Both catalog admission and final automatic apply enforce the version floor, so a lying HAVE row cannot bypass it. Manual `ota install` is the explicit equal-version/rollback override and generally permits unsigned packages, but a package that claims to be signed must have a valid signature from an allowlisted key or it is rejected. The removable-SD target is stricter: every application install needs a valid allowlisted signature because the app mints an authenticated one-reset media authorization for OTAFIX. **Transfer needs no trust** - blocks are content-addressed against the manifest's merkle root. - **Policies (persisted):** `autofetch` in {off, any, signed} (default off) gates automatic block fetching of own-target adverts; `autoinstall` in {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 USB/TCP host-folder link can also be a **pull destination** (the reverse direction): `ota pull folder` fetches a `.mota` off the mesh and streams it onto the host as `.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. The phone-oriented BLE link is deliberately source-only and does not register a folder destination. ### 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. ```cpp 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; uint8_t block_size_log2, source_caps; 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 virtual bool read_deflated_block(uint8_t idx, uint16_t block, uint8_t* buf, uint16_t cap, uint16_t* len); // optional raw RFC 1951 }; ``` To serve an external mota the node reads its manifest-minus-leaves + `leaves[]` into RAM (<=4 KiB 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), on an ESP32 WiFi companion or FULL ESP32 role over **WiFi (TCP)**, or on an nRF52 Full Companion over an encrypted **BLE GATT** service. The host is the standalone Rust tool [`motatool`](https://github.com/vk496/motatool) (`motatool serve --serial ` / `--tcp `, 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 OP_DEFLATE_BLOCK 0x09 args: idx(1) block(2) off(2) len(2) -> payload: total_encoded_len(2) + requested 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) source_caps(1) reserved(2) 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 new payload blocks are normally 2 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. `OP_DEFLATE_BLOCK` lets a host-folder source perform the optional transport compression without linking an encoder into the embedded seeder. `len=0, off=0` queries the exact encoded length; subsequent chunks are at most 190 bytes, keeping the total response payload at 192 bytes. The host independently encodes each manifest payload block as ordinary raw RFC 1951 at level 9. It returns an error for an invalid range, an unsupported operation, or a result that is not smaller than the raw block; `SerialMotaSource` then serves the ordinary raw v2 representation. A supporting host sets `source_caps bit 0` in every descriptor. Older hosts leave that formerly-reserved byte zero, so upgraded firmware skips the optional request instead of waiting for an old daemon that silently ignores unknown operations. 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 :5001`. An nRF52 Full Companion can instead pair with a phone or Linux host, subscribe to its mOTA request characteristic, and use protocol-v14 `CMD_BLE_MOTA_SOURCE`. That BLE path is source-only; it does not expose the reverse `FolderMotaStore` capture operations. 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`). The nRF52 Full Companion's **BLE GATT** path also reuses the exact frame and checksum. Device requests are notifications on a dedicated characteristic and host responses are ordered write-with-response fragments on a second characteristic. Keeping the same framing makes retries and corruption handling identical across USB, TCP, and GATT. The Linux reference implementation is `tools/ble_mota/ble_mota_seeder.py`; a phone app can implement the same transport-free catalog operations. --- ## 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_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 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 internal-flash nRF52 only: recover from failed app-side EndF validation ota bootloader [status] capable allowlisted nRF52 repeater: installed BL identity/caps + staged confirmation ota bootloader install explicitly verify/arm one complete trusted v3 package; never automatic ota cancel | drop | stop drop the fetch; durably invalidate device staging, or retain a folder partial for resume ota announce | adv serve self + send a beacon now ota self | id print this firmware's EndF (body/image size, base_hash) ota qspi | storage QSPI nRF52 only: JEDEC/SR1/stage/latched storage error (read-only) 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 ] trusted signer allowlist ; bare = list ota dev ... bring-up helpers (stage/recv/serve/resume /verify) ``` For a device-backed pull, current firmware returns success only after the persistent store can no longer be reopened; flash/SD/QSPI I/O or readback failure is reported as an error even though the in-memory manager session was dropped. For a `folder` pull, cancellation detaches the live transfer but deliberately leaves the host `.part` file available for a later resume. If the shared receive engine is currently performing the MeshTower SD auto-archive capture, cancellation likewise detaches that archive transfer and retains its `.part` file; it does not erase the unrelated manual-install store. --- ## 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 install` verifies the container fully (`image_hash`, codec, signature/allowlist, `hw_id`, and `base_hash` for a delta), writes `approval = "APRV"`, then reboots into the modified bootloader (`Adafruit_nRF52_Bootloader_OTAFIX`). The bootloader: 1. locates the staged `.mota` in the approved internal, raw-SD, or raw-QSPI store without trusting an unchecked stored size, 2. re-checks `TRAILER`, `image_hash`, and `approval == "APRV"`; for a delta it also checks that `base_hash` equals the running firmware's `EndF.body_hash` (recomputed by scanning for `EndF` - never trust `bank_0_size`), 3. 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`. - **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 through `0xED000`. A qualified internal bootloader-self-update target keeps that normal flash ceiling and does not reserve a second boot-package or flash-scratch region, but it uses a dedicated application linker that reserves the top 64 KiB of SRAM (`0x20030000..0x20040000`) plus a 72-byte retained authorization record after the existing persistent clock bytes. Application deltas larger than one flash page use a deterministic page-aligned flash prefix ending at `0xED000` and place up to 64 KiB of the logical container tail in that SRAM. Packages of one page or less, and bootloader-update packages, remain wholly in flash. Hybrid staging is enabled only when the installed bootloader contains exactly one valid `MOTARAMA` capability marker; otherwise a larger hybrid-profile fetch fails before erase. Immediately before a software reset the application publishes a valid-last `MOTAHYB1` record binding the split geometry and normalized container hash. The bootloader consumes it once and rejects power-loss, stale-reset, corrupt, or mismatched-RAM cases before its first application write. A hybrid transfer cannot resume after an application restart because its suffix is deliberately volatile. The bootloader treats every unknown/legacy GPREGRET2 handoff value as `0xD4000`, and accepts a flash-only 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/hybrid-RAM storage flags, and desired staging ceiling immediately before `EndF`. `motatool` reads that authenticated firmware record and chooses `memory_size` from the actual patch size and bottom-aligned stage address. For a hybrid base it charges only the deterministic flash prefix against that workspace; the retained-RAM suffix is still part of the same hashed logical container. Firmware without the record retains the conservative `0x98000` default. Before writing `APRV`, 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 0x98000` when intentionally targeting an older bootloader and the images still fit that window. - **nRF52 EndF rescue:** `ota rescue install ` 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's `base_hash`, requires the package `target_id` to match and its `hw_id` to 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 with `ota pull flash rescue`; an ordinary flash pull refuses before altering staged data. Firmware that predates both rescue commands still requires USB recovery. Internal-bootloader-self-update builds are a stricter exception: because their ordinary linker may extend through `0xED000`, an absent/corrupt live `EndF` disables every internal staging pull before erase instead of trusting the legacy 608 KiB estimate. - **MeshTower V2 SD nRF52:** the application stores a contiguous `/meshcore-ota.mota` on microSD. After authenticating one exact signed manifest and verifying the leaves/payload/image, it hashes the exact full container with only `APRV` normalized to zero and publishes a 72-byte reset-retained `MOTASDA2` record. That record binds app-vs-boot purpose, format, raw sector range, total/card geometry, and normalized digest; OTAFIX consumes and clears it before reading the card. There is no normal sector-1 handoff or additional OTA-specific partition-layout requirement beyond what the bundled SdFat can mount. The matching bootloader reads the authorized sectors without mounting FAT, supports either a full image or an in-place delta, verifies the staged/full result hash, and never writes through `0xED000` where InternalFS begins. The exact SD repeater also accepts a manually selected, signed v3 bootloader package when installed and candidate markers are exactly `0x09` (`SD|BOOT_UPDATE`). MeshCore streams the same strict identity, CRC, vector, signature, MID/hash-confirmation, and complete-image checks from the SD file. GPREGRET `0x6B` plus GPREGRET2 `0x53` selects this privileged path. Both MeshCore and OTAFIX require a hash-valid live `EndF` ending by `0xE0000`; when a nonzero boot-settings bank CRC is active, its recorded size must also cover that complete live image and stop by `0xE0000`. For fmt3 MeshCore additionally writes a readback-checked `MOTASDBL` token at `0xE0000` containing the exact total and signed `image_hash`. OTAFIX binds the parsed manifest, streamed payload, and final scratch image to that token, so a removable-media change can only fail closed. OTAFIX then uses `0xE0000..0xEA000` as temporary scratch; the normal application linker remains at `0xED000` and ordinary application updates do not inherit this scratch headroom restriction. Both fmt2 application apply and fmt3 bootloader apply require installed BLM2 continuity matching the live S140 FWID/application layout. Preview.12 must be upgraded locally over USB/BLE DFU or SWD. MeshCore never writes a raw sector-1 handoff. - **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 `0x51` selects 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](ota_nrf52_qspi.md). - **XIAO bootloader self-update (explicit only):** selected XIAO-module QSPI repeater builds link the ordinary application below `0xE0000`, reserving `0xE0000..0xEA000` as a 40 KiB internal scratch bank. They accept a v3 bootloader package only through an exact manual MID pull. Ordinary `ota install`, autofetch, autoinstall, and every application apply backend reject it. The operator then copies the staged package's exact values from `ota bootloader` into `ota bootloader install `. The app repeats the strict v3 geometry, installed/candidate identity, vectors, embedded CRC/capabilities, complete payload/image hashes, signature, and trusted-key gates before writing `APRV`. GPREGRET `0x6B` plus GPREGRET2 `0x51` hands the QSPI package to OTAFIX. `APRV` carries the app's authenticated and explicitly confirmed authorization decision; OTAFIX does not repeat Ed25519/allowlist, Merkle, or typed operator confirmation. It independently rechecks the strict v3 structure, canonical identity/capabilities, vectors, payload SHA, embedded CRC, and scratch/copy hashes, uses the scratch bank to preserve the running application while replacing `0xF4000..0xFE000`, and reports boot-update results in GPREGRET2 `0xC0..0xCF` (`0xC8` success). This mechanism cannot bootstrap a stock/older bootloader; install an ABI-3, boot-update-capable exact-board OTAFIX over USB/BLE DFU or SWD once first. - **Internal-flash bootloader self-update (explicit only):** curated nRF52840 lean repeater/bridge targets without an OTA-owned SD/QSPI store share the normal bottom-aligned internal store below `0xED000`. It holds either an ordinary app delta or the exact 41,330-byte v3 container, never both. The boot package bottom-aligns at `0xE2000`; a hash-valid live `EndF` must prove the complete running image ends at or below that address before the first erase. OTAFIX reads each source window before erasing and compacts the payload forward in the same eleven pages to raw `0xE2000..0xEC000`; no separate flash scratch bank exists. The qualified application linker reserves a 64 KiB SRAM arena only for hybrid application deltas; the bootloader package remains wholly in flash. GPREGRET `0x6B` plus GPREGRET2 `0xED` selects boot update, while ordinary app apply uses GPREGRET `0x6A` plus the same storage source. Exact installed/candidate capability flags are `0x0A` (`STAGE_CEILING|BOOT_UPDATE`). Ordinary deltas remain dynamically sized, may start below `0xE2000`, and reconstruct only below the normal `0xED000` app ceiling. The same signature, explicit confirmation, exact identity, vector, CRC, and single-marker rules as the XIAO path apply. Bootloader FULL admission is isolated from ordinary application FULL policy, and privileged partials are never resumed automatically after an application reboot. See [the nRF52 bootloader-update guide](ota_nrf52_bootloader_update.md) for the exact target inventory. A signature, when present, proves author authenticity and must pass the device allowlist. Unsigned v2 application packages remain installable when local policy permits them. A v3 bootloader package is always signed and trusted. The one-shot `approval` marker records local consent 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 The fixed byte layout has two intentionally disjoint profiles: `format_ver = 2` for ordinary application packages and `format_ver = 3` only for the exact privileged bootloader profile in Section 4.3. A parser must reject v2+BOOTLOADER, v3 without exact `FULL|SIGNED|BOOTLOADER`, and every other version. 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 will not fetch what it cannot apply).