From 659b96ddbb8485f2d3cdc71aa1bcccf350d362c9 Mon Sep 17 00:00:00 2001 From: Valentin Kivachuk Burda Date: Fri, 26 Jun 2026 22:11:10 +0200 Subject: [PATCH] Improve OTA cli and protocol --- .github/workflows/dev-firmware-rolling.yml | 101 ++++ OTA_STATUS.md | 131 ----- docs/ota_protocol.md | 496 +++++++++++++----- platformio.ini | 13 +- src/Mesh.cpp | 64 ++- src/Mesh.h | 4 + src/MeshCore.h | 10 + src/helpers/CommonCLI.cpp | 54 +- src/helpers/CommonCLI.h | 9 + src/helpers/ota/MotaContainer.cpp | 10 +- src/helpers/ota/MotaContainer.h | 1 + src/helpers/ota/MotaSeederProto.h | 44 ++ src/helpers/ota/MotaSourceSerial.cpp | 100 ++++ src/helpers/ota/MotaSourceSerial.h | 35 ++ src/helpers/ota/OtaApply.cpp | 243 ++++++++- src/helpers/ota/OtaApply.h | 17 +- src/helpers/ota/OtaCli.cpp | 297 ++++++++--- src/helpers/ota/OtaContext.h | 110 +++- src/helpers/ota/OtaFormat.h | 15 +- src/helpers/ota/OtaManager.cpp | 579 +++++++++++++++++---- src/helpers/ota/OtaManager.h | 231 +++++++- src/helpers/ota/OtaProtocol.cpp | 68 ++- src/helpers/ota/OtaProtocol.h | 65 ++- src/helpers/ota/OtaSelf.cpp | 84 +++ src/helpers/ota/OtaSelf.h | 10 + src/helpers/ota/OtaSource.h | 55 ++ src/helpers/ota/OtaStore.h | 36 +- src/helpers/ota/OtaStoreFlashEsp32.cpp | 247 +++++++++ src/helpers/ota/OtaStoreFlashEsp32.h | 116 +++++ src/helpers/ota/OtaStoreFlashNrf52.cpp | 37 ++ src/helpers/ota/OtaStoreFlashNrf52.h | 2 + test/test_ota/mota_vectors.h | 15 +- test/test_ota/test_ota_core.cpp | 311 +++++++++-- tools/mota/README.md | 41 +- tools/mota/gen_vectors.py | 15 +- tools/mota/mota.py | 4 + tools/mota/mota_seeder.py | 212 ++++++++ tools/mota/motalib.py | 21 +- tools/mota/test_mota.py | 25 + variants/heltec_v3/platformio.ini | 1 + variants/rak4631/platformio.ini | 2 + 41 files changed, 3355 insertions(+), 576 deletions(-) create mode 100644 .github/workflows/dev-firmware-rolling.yml delete mode 100644 OTA_STATUS.md create mode 100644 src/helpers/ota/MotaSeederProto.h create mode 100644 src/helpers/ota/MotaSourceSerial.cpp create mode 100644 src/helpers/ota/MotaSourceSerial.h create mode 100644 src/helpers/ota/OtaSource.h create mode 100644 src/helpers/ota/OtaStoreFlashEsp32.cpp create mode 100644 src/helpers/ota/OtaStoreFlashEsp32.h create mode 100755 tools/mota/mota_seeder.py diff --git a/.github/workflows/dev-firmware-rolling.yml b/.github/workflows/dev-firmware-rolling.yml new file mode 100644 index 00000000..4e8c6a07 --- /dev/null +++ b/.github/workflows/dev-firmware-rolling.yml @@ -0,0 +1,101 @@ +# Rolling DEV firmware release (fork convenience). +# +# On every push, rebuild ALL device firmwares and replace the assets of ONE rolling prerelease +# (tag `dev-latest`) so other devs can always grab the current binaries from the same place. Also packages +# a demo `.mota` (full + a same-image delta, which is tiny) so the OTA format can be exercised. +# +# These are UNSIGNED development builds for testing only — not official releases. +# To limit which branches trigger this, add a `branches:` filter under `push:` below. + +name: Dev Firmware (rolling) + +on: + workflow_dispatch: + push: + +permissions: + contents: write + +# Only the latest push matters — cancel any in-flight run so the release tracks HEAD. +concurrency: + group: dev-firmware-rolling + cancel-in-progress: true + +env: + RELEASE_TAG: dev-latest + FIRMWARE_VERSION: dev + MOTA_ENV: Heltec_v3_repeater # representative ESP32 env whose built .bin carries EndF, used for the demo .mota + +jobs: + build-and-release: + runs-on: ubuntu-latest + steps: + - name: Clone Repo + uses: actions/checkout@v6 + + - name: Setup Build Environment + uses: ./.github/actions/setup-build-environment + + # build.sh is best-effort: a board that fails to compile is skipped, the rest still land in out/. + # All three roles append to the same out/ directory. + - name: Build all firmwares (companion + repeater + room-server) + run: | + /usr/bin/env bash build.sh build-companion-firmwares + /usr/bin/env bash build.sh build-repeater-firmwares + /usr/bin/env bash build.sh build-room-server-firmwares + echo "Built artifacts:"; ls -la out + + - name: Package demo .mota (full + same-image delta) + run: | + set -euo pipefail + pip install --quiet detools # delta codec only (a full .mota needs no extra deps) + BIN="$(ls out/${MOTA_ENV}-*.bin 2>/dev/null | grep -v -- '-merged' | head -1 || true)" + if [ -z "$BIN" ]; then + echo "::warning::no ${MOTA_ENV} .bin built — skipping demo .mota" + else + echo "Using firmware image: $BIN" + # full image .mota (codec 0): payload = the flashable image (BODY||EndF) + python3 tools/mota/mota.py build --fw "$BIN" --target-env "$MOTA_ENV" \ + --fw-version 0.0.0 --codec full --out "out/${MOTA_ENV}-demo.full.mota" + # delta .mota (codec 1, sequential+crle): base == target == same image -> near-empty patch + python3 tools/mota/mota.py build --fw "$BIN" --base "$BIN" --target-env "$MOTA_ENV" \ + --fw-version 0.0.1 --codec sequential --compression crle --out "out/${MOTA_ENV}-demo.delta.mota" + echo "Demo .mota sizes:"; ls -l out/${MOTA_ENV}-demo.*.mota + fi + + - name: Upload workflow artifacts + uses: actions/upload-artifact@v7 + with: + name: dev-firmwares + path: out + if-no-files-found: warn + retention-days: 5 + + - name: Replace the rolling dev release + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + if [ -z "$(ls -A out 2>/dev/null)" ]; then + echo "::error::nothing built — not touching the release"; exit 1 + fi + # delete the previous release + its tag so old binaries disappear, then recreate at HEAD + gh release delete "$RELEASE_TAG" --yes --cleanup-tag 2>/dev/null || true + NOTES=$(cat <\`. + + \`${MOTA_ENV}-demo.full.mota\` / \`*.delta.mota\` are OTA format demos built from this run's + \`${MOTA_ENV}\` image (the delta is a same-image patch, so it is intentionally tiny). + EOF + ) + gh release create "$RELEASE_TAG" out/* \ + --title "Dev firmware (latest commit)" \ + --notes "$NOTES" \ + --prerelease \ + --target "$GITHUB_SHA" diff --git a/OTA_STATUS.md b/OTA_STATUS.md deleted file mode 100644 index 65f1ccf0..00000000 --- a/OTA_STATUS.md +++ /dev/null @@ -1,131 +0,0 @@ -# OTA-over-LoRa — Implementation Status - -Working record of what's built, what's validated (and how), and what remains. Companion to the design -docs: `OTA.md` (spec), `OTA_PLAN.md` (plan), `docs/ota_protocol.md` (wire format). - -Everything is gated behind `-D ENABLE_OTA=1` and is byte-for-byte inert when off. Nothing is committed -to git yet — all changes are in the working tree. - -## Validation summary - -| Phase | Component | Validated how | Status | -|---|---|---|---| -| P0 | `.mota` format + host packager (`tools/mota/`): build, delta (detools), Ed25519 sign, inspect, verify; `EndF` injector | `tools/mota/test_mota.py` (12/12); CLI end-to-end | ✅ | -| P1 | Portable C++ core: `Multihash`, `MerkleTree` (+proofs, gen+verify), `MotaContainer` parse, `BlockBitmap` | `pio test -e native` (21/21), cross-checked vs Python oracle (proofs byte-identical) | ✅ | -| P2 | `EndF` self-scan, `target_id` / `getOtaTargetId()`, `build.sh` injection | **on Heltec v3**: `ota status` reports exact size/hash/target_id matching the build hook | ✅ | -| P3 | `SignerAllowlist`, `OtaStore`, full verify (parse + merkle root + image_hash + **Ed25519** + allowlist) | **on Heltec v3 AND RAK4631**: `verify` → `ok=1 auto=1` with key, `auto=0` without | ✅ cross-platform | -| P4a | `OtaProtocol` message codec (ADV/QUERY/HAVE/GET_MANIFEST/MANIFEST/REQ/DATA) + server proof-gen | native (21/21), proof-gen matches Python | ✅ | -| P4b | `OtaManager` serve+fetch state machine | native: **two-manager full transfer simulation → byte-identical** reassembly | ✅ | -| P4c | Mesh integration: `PAYLOAD_TYPE_OTA` dispatch, lowest-priority hop-capped flood, wired into `simple_repeater` | **full on-air transfer RAK4631→Heltec COMPLETE + VERIFIED** (see below) | ✅ | -| P5 | **Delta apply (ESP32 A/B) via detools 0.53.0**: vendored detools embeddable C decoder (`src/helpers/ota/detools/`, NONE+CRLE) decodes a `--codec sequential --compression crle` patch against the running slot into the inactive slot, hashing→`image_hash` (`ota applydelta`) | **full LoRa run RAK→Heltec**: 129-byte delta (0.01% of a 1.18 MB image) → `detools decoded 1179184 B, hash OK, armed` → reboot → **booted v1.16.9** | ✅ | -| P6 | **Apply (ESP32 A/B)**: verify inactive-slot image vs signed manifest (image_hash + Ed25519 + allowlist) → `esp_ota_set_boot_partition` → reboot | **real role switch on Heltec: repeater → companion, booted correctly** (`ota apply manifest/verify/commit`; after reboot it speaks the companion frame protocol) | ✅ | -| — | Build + flash both platforms | esptool (Heltec/ESP32) + adafruit-nrfutil **DFU** (RAK4631/nRF52) both confirmed | ✅ | - -## On-air status (real LoRa, RAK4631 → Heltec v3) — ✅ COMPLETE + VERIFIED - -A full signed `.mota` was transferred **over real LoRa from the RAK4631 (nRF52) to the Heltec v3 -(ESP32)** — cross-platform — and **completed + verified end-to-end**: -``` -fetch=F 1/6 → 3/6 → 4/6 → 5/6 → 6/6 → fetch=C (~26 s) -verify: parsed=1 root=1 img=1 signed=1 sig=1 trust=1 | ok=1 auto=1 -``` -announce → get-manifest → manifest → windowed request → data; every block **merkle-verified against the -signed root** before storage; the reassembled container then **fully verified** (root + image_hash + -Ed25519 signature + allowlist → auto-appliable). Three real bugs were found and fixed via on-device -testing (none caught by the host sim, which doesn't model the mesh): -- **Windowed requests** (`OTA_REQ_WINDOW`) — was requesting the whole image at once → server TX/pool - congestion. Now paced to the link. -- **Manifest + request retry** in `OtaManager::loop()` — was unrecoverable if a reply dropped. -- **Dedup vs. retries (the key one):** the mesh `hasSeen()` dedup suppressed identical retried requests, - so a single lost reply stalled forever. Fixed: OTA packets are **always processed** (handlers are - idempotent); `hasSeen()` now only gates re-flooding. This makes it genuinely *eventually reliable* — - lossy RF just means more time, exactly as intended. - -## Source map (`src/helpers/ota/`) - -| File | Role | Portable (native) | -|---|---|---| -| `OtaFormat.h` | wire constants (magics, flags, codecs, msg types) | yes | -| `Multihash.h` | sha2-256 truncations via `Utils::sha256` | yes | -| `MerkleTree.{h,cpp}` | leaf/root (O(log n)), verify, gen proof | yes | -| `MotaContainer.{h,cpp}` | `.mota` parse + root/image-hash checks | yes | -| `BlockBitmap.h` | availability from `leaves[]` (erased = missing) | yes | -| `FirmwareInfo.{h,cpp}` | `EndF` self-scan over a region | yes | -| `OtaStore.h` | staging interface + `OtaStoreRam` | yes | -| `OtaProtocol.{h,cpp}` | message encode/decode | yes | -| `OtaManager.{h,cpp}` | serve+fetch state machine | yes | -| `SignerAllowlist.h` | trusted Ed25519 signer keys | yes | -| `OtaVerify.{h,cpp}` | full verify incl. Ed25519 (uses `Identity`) | device-only | -| `OtaSelf.{h,cpp}` | running-firmware region (ESP32 `esp_partition_read`) | device-only | -| `OtaContext.{h,cpp}` | per-device singleton (manager + stores + allowlist) | device-only | -| `OtaCli.{h,cpp}` | `ota …` CLI commands | device-only | - -Core edits (gated): `Packet.h` (`PAYLOAD_TYPE_OTA=0x0C`), `Mesh.{h,cpp}` (dispatch + `createOtaPacket`/ -`sendOtaFlood` + hop limit), `MeshCore.h` (`getOtaTargetId`), `CommonCLI.cpp` (`ota` command), -`examples/simple_repeater/MyMesh.{h,cpp}` (`onOtaRecv` + adapter + begin/loop wiring), `build.sh` -(`MOTA_TARGET_ID`), `test/mocks/SHA256.h` (real host SHA-256). Env wiring: `variants/heltec_v3` and -`variants/rak4631` repeater envs (`ENABLE_OTA` + ota sources [+ `EndF` hook on ESP32]). - -## `ota` CLI (serial console; also remote-admin over LoRa) - -``` -ota status target_id, self-fw size/hash, serve/fetch state, key count -ota key add|list|rm signer allowlist -ota stage prepare serve buffer -ota recv write a chunk into the serve buffer (host streams the .mota) -ota serve parse+verify the staged .mota and make it servable -ota announce broadcast OTA_ADV for the served .mota -ota verify full verify of the staged/served (or fetched) .mota -ota want |auto manual cross-target override (deliberate role switch, e.g. companion->repeater) -ota clear reset buffers -``` -Host harness: `tools/mota/` packager + the scratch `onair*.py` orchestration scripts. - -## Variant coverage (which platforms have OTA, which need special treatment) - -OTA is enabled at the platform base so every variant inherits it; only the apply path differs by HW. - -| Platform | OTA build | Apply path | Special treatment | -|---|---|---|---| -| **ESP32** (all chips) | ✅ enabled in `[esp32_base]` (`ENABLE_OTA`, `helpers/ota/*.cpp`, `detools.c`, `pio_endf`) | A/B via `esp_ota` + detools-**sequential** decode into the inactive slot | `applydelta` only runs on a **dual-app/OTA partition table** (2 app slots + otadata). `min_spiffs.csv` boards already qualify (1.875 MB slots); `huge_app`/single-app boards (most esp32/S3 defaults, 3.19 MB) build fine but refuse apply (`ERR no A/B slot`) until repartitioned. | -| **nRF52 — RAK4631 hardware** (rak4631 + gat562_30s / evb_pro / tracker_pro / watch13, muziworks_r1_neo, rak_wismesh_tag) | ✅ `[rak4631]` (inline) + `[rak4631_hw]` (shared, the other 6) | single-slot **in-place** detools, applied by the custom OTAFIX bootloader after reboot | Device must run the **OTAFIX bootloader** fork. `detools.c` is NOT built into the app (only the bootloader decodes). | -| **nRF52 — non-RAK** (heltec_t1/t096/t114/mesh_solar/mesh_pocket, lilygo techo*/t_impulse_plus, thinknode_m1/m3/m6, t1000-e, nano_g2_ultra, promicro, xiao_nrf52, ikoka_*, wio*, sensecap_solar, rak3401, keepteen_lt1, meshtiny, minewsemi_me25ls01) | ❌ not enabled | none | **Needs its own bootloader fork** (single-slot, like RAK) before OTA is safe. No A/B slot, and the stock Adafruit/SoftDevice bootloader can't apply in place. Out of scope until per-board bootloaders exist. | -| **RP2040 / STM32** | ❌ not enabled | none | No A/B apply path implemented yet. | - -Build-verified this pass (OTA on): ESP32 across all 4 chip families — esp32 `Heltec_v2` (34.6%), S3 `Heltec_v3` (35.3%), C6 `Xiao_C6` (27.9%), and the tight C3 default-partition class up to the fattest config `Heltec_ct62_companion_radio_ble` **96.2%** / `Xiao_C3_companion_radio_ble` 94.6% (the global flash worst case — fits). All 6 RAK4631-hw nRF52 variants build (Flash 55–65%, RAM ≤ 74%). `native` test suite green. (Pre-existing, OTA-unrelated, fail on clean `main` too: `tenstar_c3` stale `helpers/XiaoC3Board.h` include; `generic_espnow` undefined `P_LORA_DIO_1`.) - -## Remaining (clearly scoped) - -1. **Device-side full-image delivery to the slot** — the apply path is done + validated, but the role- - switch test delivered the 631 KB image to the inactive slot via esptool (simulating the transfer, - which is separately proven on-air). The device writing the slot itself during a *full-image* OTA - needs `esp_ota_write`/`esp_partition_write` streaming + a bulk transfer (the RAM `OtaStore` is for - delta-sized images / bring-up). -2. **Multi-fragment blocks** — v1 uses ≤128-byte single-packet blocks; 1 KB blocks need fragment - reassembly in `OtaManager` (`OTA_DATA` already carries `frag_idx`/`frag_total`). -3. **nRF52 apply** — write the `approval` field + reboot-to-DFU for the bootloader fork (external repo). -4. **nRF52 `EndF` `.hex` build wiring** — currently only the ESP32 `.bin` hook is implemented. -5. **P7 auto-propagation + retention** — 24 h announce, finish-current on supersession, 30-day stale GC. -6. **Companion app frames** (`CMD_OTA_*`) + relay/web-seed ingress for the home-node case. -7. **`hw_id` brick-safety** for cross-target (see plan §6.1) — manifest format change, awaiting confirm. - -> Device state: the **Heltec now runs companion_radio_usb** (from the role-switch test); reflash the -> repeater env to continue OTA work. RAK4631 runs the OTA repeater. -8. **`hw_id` brick-safety** for cross-target (`ota want`) — manifest field = `sha2-256:4(manufacturer)`; - allows same-HW role switches but refuses incompatible-HW firmware. Manifest format change → see - `OTA_PLAN.md §6.1` (awaiting confirmation since the format was frozen). The manual override - itself is **done + native-tested**. - -## Reproduce - -```bash -# host tests -./meshcore/bin/python tools/mota/test_mota.py -./meshcore/bin/pio test -e native -f test_ota - -# build + flash (OTA repeater) -./meshcore/bin/pio run -e Heltec_v3_repeater -t upload --upload-port /dev/ttyUSB0 -./meshcore/bin/pio run -e RAK_4631_repeater -t upload --upload-port /dev/ttyACM0 # DFU - -# on-device verify / on-air transfer: see tools/mota/ + scratchpad onair*.py -``` diff --git a/docs/ota_protocol.md b/docs/ota_protocol.md index 94712871..1f99c228 100644 --- a/docs/ota_protocol.md +++ b/docs/ota_protocol.md @@ -1,63 +1,103 @@ -# MeshCore OTA — `.mota` container & LoRa protocol (v1 draft) +# MeshCore OTA — `.mota` container & LoRa protocol (v2) -Goals: distribute firmware over LoRa as a self-verifying, resumable, BitTorrent-v2-style block -transfer that survives reboots, never auto-applies without consent, and is portable enough for other -projects (e.g. Meshtastic) to adopt. +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 and hardware-verified in this repository; +where a section names a source file, that file is the authoritative reference for byte-level details. + +**Design goals** + +- Distribute firmware over LoRa as a **self-verifying, resumable, BitTorrent-style block transfer** that + survives reboots and never auto-applies without explicit consent. +- **Trustless transport / relay:** any node may carry or relay any block; integrity is content-addressed + against a signed merkle root, so a relay need not be trusted and never needs the signing keys. +- **Lowest priority, always:** OTA traffic is enqueued behind all mesh traffic — "eventually upgradable". + A busy node delays OTA indefinitely rather than competing with real traffic. +- **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 | `tools/mota/` (`mota.py`, `motalib.py`, `mota_seeder.py`) | --- ## 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`. v1 uses +- **Hashes (multihash):** the hash family is declared once per manifest via `hash_algo`. v2 uses `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. - - `sha2-256:8` — first 8 bytes. Base-firmware identity (`base_hash`, `EndF`). + - `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: +**Reference constants** (`OtaFormat.h`): -| Name | Bytes (hex) | ASCII | +| 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) | `12` | — | -| `approval` = not approved | `FF FF FF FF` | (erased) | +| `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` | -| `format_ver` | `01` | — | +| `MFLAG_FULL` | `0x01` | flags bit0 | +| `MFLAG_SIGNED` | `0x02` | flags bit1 | +| `CODEC_FULL` / `_SEQUENTIAL` / `_INPLACE` | `0` / `1` / `2` | §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 TX priority | `250` | lowest (`OTA_TX_PRIORITY`, `src/Mesh.h`) | --- ## 2. Firmware image & the `EndF` trailer -Every OTA-capable firmware build appends a 16-byte `EndF` trailer to its flashed image so a running -node can discover its own size/identity on any MCU (no linker symbols needed). +Every OTA-capable build appends a 16-byte `EndF` trailer to its flashed image so a running node can +discover its own size/identity on any MCU (no linker symbols needed). 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 (16 bytes): - off 0 4 "EndF" (45 6E 64 46) + off 0 4 "EndF" 45 6E 64 46 off 4 4 body_len uint32 LE — length of BODY (excludes this 16-byte trailer) - off 12 8 body_hash sha2-256:8 of BODY + off 8 8 body_hash sha2-256:8 of BODY ``` -- **Size discovery:** scan flash from the partition top downward for the `EndF` marker; the byte - before it is the last BODY byte. (Same technique as `NRF52Board::getBootloaderVersion`.) -- **Self-identity / delta base matching:** a node's `body_hash` is read directly from its own `EndF`; - a delta's `base_hash` (§5) must equal it. No self-hashing pass required at match time. +- **Size discovery:** scan flash from the partition top downward for the `EndF` marker; the byte before + it is the last BODY byte. (See `ota_self_firmware()`.) +- **Delta base matching:** a node's `body_hash` is read directly from its own `EndF`; a delta's + `base_hash` (§5) must equal it. No self-hashing pass at match time. - **No circularity:** `EndF` hashes only the BODY, never itself. The "reconstructed image" referenced by the manifest is the full `BODY || EndF` (what gets flashed). +> **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 §12. + --- ## 3. The `.mota` container -This is the **distributed** form (host-built, wire-transferred). +The distributed form (host-built, wire-transferred). Parsed by `mota_parse()` in `MotaContainer.cpp`. ``` off size field @@ -70,62 +110,66 @@ off size field 8 + M + P 5 TRAILER = 76 6B 34 39 36 ``` -`MOTA_TOTAL_SIZE = 4 + 4 + M + P + 5`. +`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) and the 4-byte `approval` field (on user approval). Everything else is -immutable. +bytes, except the device mutates two regions in place (both NOR-safe, no re-erase): the `leaves[]` slots +(filled as blocks arrive — §7) and the 4-byte `approval` field (on owner consent — §4.2). Everything else +is immutable. --- ## 4. The manifest -Fields are serialized in this exact order. Conditional fields are present per `flags`. +Fields serialized in this exact order; conditional fields present per `flags`. Fixed head is **89 bytes** +(through `hw_id`). Parsed by `mota_parse_manifest()`. ``` off size field notes -0 1 format_ver = 0x01 -1 1 flags bit0 FULL (0=delta/partial, 1=full image) - bit1 SIGNED - bits2-7 reserved (0) +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 (§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 (§6) +20 4 merkle_root sha2-256:4 over PAYLOAD blocks (§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 8 base_hash [present iff !FULL] sha2-256:8 of the BASE image's BODY (matches EndF.body_hash) -. 32 signer_pubkey [present iff SIGNED] Ed25519 public key -. 64 signature [present iff SIGNED] Ed25519 over all bytes from off 0 up to here (exclusive) +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. +--- end of fixed 89-byte head --- +89 8 base_hash [iff !FULL] sha2-256:8 of the BASE image's BODY (== that build's EndF.body_hash) +. 32 signer_pubkey [iff SIGNED] Ed25519 public key +. 64 signature [iff SIGNED] Ed25519 over all bytes from off 0 up to here (exclusive) . 4 approval ALWAYS present. FF FF FF FF = not approved; 41 50 52 56 ("APRV") = approved +--- end of manifest-minus-leaves (mfl); leaves_off = 8 + mfl in the container --- . 4*BC leaves[] ALWAYS present. BC = ceil(payload_size / 2^block_size_log2). sha2-256:4 each ``` -Self-delimiting: a parser knows every offset from `format_ver`/`flags` + `payload_size` (→ `BC`); no -explicit length field is stored. +Self-delimiting: every offset is known from `flags` + `payload_size` (→ `BC`); no length field is stored. -Manifest size (excluding `leaves[]`): unsigned-full 57+4=61, signed-full 161, unsigned-delta 69, -**signed-delta 165**. +Manifest-minus-leaves size (`mfl`): unsigned-full `89+4 = 93`, signed-full `189`, unsigned-delta `101`, +**signed-delta `197`**. A signed manifest exceeds one packet, so `OTA_MANIFEST` is sent multi-fragment +(§8.4) and reassembled by the fetcher. ### 4.1 Signed region -`signature` covers manifest bytes `[0, signature_offset)` — i.e. everything before it, including -`signer_pubkey` and (for deltas) `base_hash`. It does **not** cover `approval` or `leaves[]`: +`signature` covers manifest bytes `[0, signature_offset)` — everything before it, including `signer_pubkey` +and (for deltas) `base_hash`. It does **not** cover `approval` or `leaves[]`: + - `leaves[]` are verified against the signed `merkle_root` (§6), so they need no separate signature. -- `approval` is device-local consent (§7), deliberately outside the signature. +- `approval` is device-local consent (§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 user's `ota apply` writes `41 50 52 56` (`"APRV"`) — a single NOR-safe write (only clears +- 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). -- Auto-bound to this image: it lives in this `.mota`'s manifest and is re-erased when a new `.mota` is - staged. -- It is a **consent** marker, not a security primitive. Authenticity = `signature` + `image_hash`. +- 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`. --- @@ -133,44 +177,45 @@ Manifest size (excluding `leaves[]`): unsigned-full 57+4=61, signed-full 161, un `PAYLOAD` is either the full reconstructed image (`FULL`) or a delta (`!FULL`). -| `codec_id` | Meaning | Notes | +| `codec_id` | Meaning | Used by | |---|---|---| -| 0 | full / raw | PAYLOAD = reconstructed image (`BODY||EndF`). Typical for ESP32 (A/B). | -| 1 | detools sequential | needs random read of base + sequential write of result (e.g. ESP32 A→B). | -| 2 | detools in-place | bounded scratch; rewrites the app region in place (nRF52 single-slot). | +| 0 | full / raw | PAYLOAD = reconstructed image (`BODY‖EndF`). ESP32 A/B (and any board for a full image). | +| 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 image's `EndF.body_hash` (sha2-256:8 of its BODY). A node applies a +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 flashed — this is the hard security gate. +(sha2-256:32) to `image_hash` before it is booted — the hard security gate. -Compression is internal to the detools patch; the chosen scheme must be supported by the applier -(bootloader contract, §12). Patches are produced by detools 0.53.0 (`tools/mota` → `detools.create_patch`) -and decoded on-device by detools' own 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 -`--codec sequential --compression crle`. The ESP32 applier (`OtaApply.cpp::ota_apply_detools_mota`) -wires the decoder's callbacks to: read base ← running OTA slot, stream patch ← fetched bytes in RAM, -write output → inactive slot, hashing the output and checking it against `image_hash` before arming. +**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`), nRF52 accepts +`full` + `in-place`. `CODEC_FULL` is always acceptable. A `.mota` with an unsupported codec is rejected at +discovery time, before any blocks are requested. + +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) -Purpose: verify each PAYLOAD block against the signed `merkle_root` **before** the whole payload -exists, so corruption/forgery is localized to a block. +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 is split into `BC = ceil(payload_size / B)` blocks, `B = 2^block_size_log2` - (default 1024). The last block is its real length (**no zero padding**). +- **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 = 8 input bytes). -- **Odd level:** if a level has an odd number of nodes, the **last node is promoted unchanged** to the - next level (no duplication). +- **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, each tagged -left/right. Promoted levels contribute **no** element. Verification (needs `BC` to know the shape): +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 @@ -179,110 +224,287 @@ while n > 1: pass else: sib, side = proof[p] ; p += 1 - h = sha2-256:4( sib || h ) if side==left else sha2-256:4( h || sib ) + 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 the fetcher fills its own `leaves[i]` as each verified -block lands. +Over LoRa, `leaves[]` are **omitted** from the manifest transfer; a serving node computes a block's proof +on demand from its stored `leaves[]` (`OTA_REQ_PROOF`/`OTA_PROOF`, §8.5), and the fetcher fills its own +`leaves[i]` as each verified block lands. --- -## 7. Block availability (persistent, derived from `leaves[]`) +## 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 a small in-RAM bitmap (`ceil(BC/8)` bytes) -by scanning `leaves[]`. +(`!= FF FF FF FF`). Because `leaves[]` live in the staged flash region, availability **survives reboot**. -A node holding the complete payload (or relaying/serving its own firmware) advertises `have_all` -instead of a bitmap. +**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 `, 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`** (subject to change if core devs prefer -reusing `RAW_CUSTOM 0x0F` + subtype). Every OTA packet payload: +Carried in MeshCore packets with **`PAYLOAD_TYPE_OTA = 0x0C`**. Every OTA packet payload is: ``` -[0] ota_msg_type -[1..] body +[0] ota_msg_type (OtaMsgType, OtaFormat.h) +[1..] body (fixed per type; encode/decode in OtaProtocol.cpp) ``` -- **Routing:** `OTA_ADV`/`OTA_QUERY` flood; `OTA_HAVE`/`OTA_MANIFEST`/`OTA_REQ`/`OTA_DATA` direct. -- **Hop cap:** OTA refuses to retransmit when `getPathHashCount() >= ota_hop_limit` (default **3**, - configurable). No change to core routing. -- **Priority:** enqueued at the lowest TX priority (~250) and only when the duty-cycle/airtime budget - has spare headroom, so OTA never competes with mesh traffic. -- **`manifest_id`** = the manifest's `merkle_root` (4 bytes) — a compact content id. +Message types: -| `ota_msg_type` | val | dir | body | +| `ota_msg_type` | val | routing | purpose | |---|---|---|---| -| `OTA_ADV` | 0x01 | flood | `target_id(4) fw_version(4) image_size(4) block_size_log2(1) merkle_root(4) image_hash8(8) flags(1) [base_hash(8) if delta] have_all(1)` | -| `OTA_QUERY` | 0x02 | flood | `target_id(4) min_version(4) caps(1)` (caps bit0 want_delta, bit1 want_full) | -| `OTA_HAVE` | 0x03 | direct | `manifest_id(4) bitmap_off(2) bitmap[]` | -| `OTA_GET_MANIFEST` | 0x04 | direct | `manifest_id(4)` | -| `OTA_MANIFEST` | 0x05 | direct | `manifest_id(4) frag_idx(1) frag_total(1) bytes[]` (omits `leaves[]`) | -| `OTA_REQ` | 0x06 | direct | `manifest_id(4) want_off(2) want_bitmap[]` | -| `OTA_DATA` | 0x07 | direct | `manifest_id(4) block_idx(2) frag_idx(1) frag_total(1) [proof in frag0] bytes[]` | +| `OTA_ADV` | 0x01 | flood | tiny per-node beacon (discovery tier 1) | +| `OTA_QUERY` | 0x02 | flood | ask a source for its catalog (discovery tier 2) | +| `OTA_HAVE` | 0x03 | flood | the catalog reply (fragmented, digest-tagged) | +| `OTA_GET_MANIFEST` | 0x04 | direct | request a manifest by `manifest_id` | +| `OTA_MANIFEST` | 0x05 | direct | the manifest-minus-leaves, fragmented | +| `OTA_REQ` | 0x06 | direct | request a window of blocks' DATA | +| `OTA_DATA` | 0x07 | direct | one self-describing fragment of a block's data | +| `OTA_REQ_PROOF` | 0x08 | direct | request the merkle proof for one block | +| `OTA_PROOF` | 0x09 | direct | the merkle proof for one block | -Sizing against the 184-byte `MAX_PACKET_PAYLOAD`: `OTA_DATA` fixed overhead ≈ 9 B → ~175 B/fragment → -**6 fragments per 1 KB block**; a proof for ≤512 blocks ≤ 9×4 = 36 B (carried in `frag0`); an -availability bitmap for 500 blocks ≈ 63 B (one packet). +- **`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:** all OTA packets enqueue at `OTA_TX_PRIORITY = 250` (lowest). OTA never competes with mesh + traffic; on a busy node it is delayed indefinitely. +- **Reliability is *eventual*:** the fetcher re-requests missing fragments/blocks after a timeout, possibly + from a different peer. No hard ACKs, no global ordering. +- **Relay:** replies are flooded, so transparent relay needs no per-requester addressing, and the transfer + is trustless (the fetcher verifies every block against the signed root). Any neighbor may serve any + fragment it has. -Reliability is *eventual*: the fetcher re-requests un-acked blocks after a timeout, possibly from a -different peer. No hard ACKs, no ordering. +### 8.1 Two-tier discovery -### 8.1 Relay seeding (companion frames) +Because a node may serve **many** mOTAs (its own firmware plus an external folder — §10), discovery is split +so the periodic beacon stays tiny regardless of catalog size: -A node need not store a foreign-target `.mota` to serve it: a relay advertises a manifest on behalf of -an external source and **pulls blocks on demand**. Companion-app frames: `CMD_OTA_PROVIDE_MANIFEST` -(app→node, starts advertising), event `PUSH_OTA_BLOCK_REQ(manifest_id, block_idx)` (node→app), reply -`CMD_OTA_PROVIDE_BLOCK(manifest_id, block_idx, bytes)`. +**Tier 1 — `OTA_ADV` beacon** (10 bytes, constant, flooded periodically): + +``` +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) # filter_target 0 = everything +OTA_HAVE (flood): seeder_id[4] set_digest[4] frag_idx(1) frag_total(1) n_rows(1) rows[] + HaveRow (14 bytes, OTA_HAVE_ROW_BYTES): mid[4] target_id(4) fw_version(4) codec_id(1) flags(1) +``` + +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 — up to 12 rows per fragment). The heavy manifest is +fetched per-mid only on commit (§8.3). + +### 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 **or** a HAVE for the same + `{seeder, set_digest}` CANCELS the pending query. + +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) ───────► + ◄─────── OTA_MANIFEST(mid, frag_idx, frag_total, bytes) × frag_total + (reassemble manifest, verify, compute geometry: BC, block_size, payload_size) + for each missing block window: + OTA_REQ(mid, start_block, count) ► + ◄─────── OTA_DATA(mid, block_idx, frag_off, data) × (per block) + (reassemble block from frag_off slices) + OTA_REQ_PROOF(mid, block_idx) ────► + ◄─────── OTA_PROOF(mid, block_idx, n_proof, proof) + (verify proof vs merkle_root → write block → write leaves[i]) + 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] +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] start_block(uint16) count(1) +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 × 4 bytes +``` + +- **Block ⇆ fragments:** a 1 KB block is split into self-describing `OTA_DATA` fragments. `frag_off` is the + byte offset of `data` within the block, so the global position is `block_idx*block_size + frag_off` — + a fragment is self-placing and may be requested from **any** peer (BitTorrent-style). The fetcher tracks a + per-block slice bitmap and reassembles before requesting the proof. +- **Data and proof are separate phases.** `OTA_DATA` carries no proof; the proof is fetched once per block + via `OTA_REQ_PROOF`/`OTA_PROOF` after the block's data is complete. + +### 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 | 12 rows × 14 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. --- ## 9. Identity, trust & versioning -- **`target_id`** (4 B): compile-time `sha2-256:4(pio_env_name + radio_class + ldscript/partition + - platform)`, injected by `build.sh`, read via `MainBoard::getOtaTargetId()`. A node only fetches/serves - matching `target_id`. (The PlatformIO env name uniquely captures hardware AND role/partition.) -- **`fw_version`:** packed comparable uint32 (`MAJOR<<24|MINOR<<16|PATCH<<8|pre`). -- **Signing & allowlist:** a node keeps a runtime-managed allowlist of trusted Ed25519 signer pubkeys - (none embedded in firmware). A `.mota` is eligible for **auto-apply** only if signed by an allowlisted - key, the signature verifies, and `image_hash` matches; otherwise it is manual-apply only with explicit - confirmation. **Transfer needs no trust** — blocks are content-addressed against the signed root, so - any (untrusted) neighbor may relay them. - -### 9.1 Supersession & retention - -- **Finish-current:** a newer version announced mid-download does not abort the in-progress transfer. -- **Stale GC:** a staged `.mota` carries a persistent `staged_at` epoch; it is discarded after - `ota_stale_ttl` (default **30 days**) unless pinned (`ota keep`) or applying — reclaiming flash from - superseded-complete and stalled-partial images alike. `ota discard` frees the slot immediately. +- **`target_id`** (4 B): compile-time `sha2-256:4(pio_env_name)` (little-endian uint32), injected as + `-D MOTA_TARGET_ID` by `build.sh` and read via `MainBoard::getOtaTargetId()`; `tools/mota` computes the + same from `--target-env`. The PlatformIO env name uniquely captures hardware **and** role/partition, so a + node auto-fetches only matching firmware. A manual `ota pull`/`want` can override target (deliberate role + switch); the `hw_id` brick-safety gate (§4) still applies at apply time. +- **`fw_version`:** packed comparable uint32 (`MAJOR<<24 | MINOR<<16 | PATCH<<8 | pre`). +- **`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, and `image_hash` matches; otherwise it is manual-apply only with + explicit confirmation. **Transfer needs no trust** — blocks are content-addressed against the signed root. +- **Policies (persisted):** `autofetch` ∈ {off, any, signed} (default off) gates automatic block fetching of + own-target adverts; `autoinstall` ∈ {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. Apply & bootloader contract (summary) +## 10. Multi-mota serve & the external "folder" relay -- **ESP32:** in-firmware via `Update`/`esp_ota_*` into the inactive A/B slot, then set boot + reboot - (power-safe, rollback-capable). No bootloader changes. -- **nRF52:** running firmware **never** flashes the app. `ota apply` verifies fully, writes the - `approval` field (`"APRV"`), then reboots into DFU. The modified bootloader - (`Adafruit_nRF52_Bootloader_OTAFIX`) locates the staged `.mota` by scanning for `MAGIC`, re-checks - `TRAILER` + signature + `image_hash` + `approval == "APRV"`, applies the codec (delta in-place over - the app region), then clears state and boots. The signature proves author authenticity; `approval` - proves local owner consent — both required. +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. + +### 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; + 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`) + +The first concrete `MotaSource` is a host daemon (`tools/mota/mota_seeder.py`) serving a folder over the +device's **USB serial — the same console the CLI uses** (no extra hardware). The device only emits request +frames *while actively serving a fetch*, and reads the reply synchronously, so 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) +status: 0 = OK, non-zero = error (out of range / past EOF). +``` + +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). Verified on hardware: a RAK4631 relays a host folder to a +Heltec V3 over one USB cable, every block merkle-checked. --- -## 11. Versioning of this spec +## 11. CLI surface (`OtaCli.cpp`) -`format_ver = 1`. Future changes bump `format_ver`; the multihash `hash_algo` allows changing the -digest family without a format bump. Unknown `format_ver`/`codec_id`/`ota_msg_type` values are ignored -(forward-compatible). +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. + +``` +ota status session + self-fw summary +ota neighbors discovered mOTAs (queries sources; rows arrive async via OTA_HAVE) +ota announce serve self + send a beacon now +ota pull <#|mid8> fetch a chosen mOTA (manual; works regardless of autofetch) +ota drop drop the current fetch session (free the slot) +ota folder on|off attach/detach an external .mota folder (host daemon) ; bare = list +ota self print this firmware's EndF (body/image size, base_hash) +ota applydelta verify + approve + (ESP32) apply / (nRF52) reboot-to-bootloader +ota config [autofetch|autoinstall|checkpoint] ... show/set persisted policy +ota key add|list|rm trusted signer allowlist +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 applydelta` verifies fully + (`image_hash`, `base_hash`, signature/allowlist, `hw_id`), writes `approval = "APRV"`, then reboots into + the modified bootloader (`Adafruit_nRF52_Bootloader_OTAFIX`). The bootloader: + 1. **scans flash for `MAGIC`** to find the staged `.mota` (it must NOT trust any stored size), + 2. re-checks `TRAILER`, `image_hash`, `approval == "APRV"`, and that the delta's `base_hash` equals the + running firmware's `EndF.body_hash` (recomputed by scanning for `EndF` — never trust `bank_0_size`), + 3. applies the in-place codec over the app region and boots only if the result hashes to `image_hash`. + +The signature proves author authenticity; `approval` proves local owner consent — both required to apply. + +> **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` (v2 added `hw_id` to the signed head and split discovery/transfer as in §8/§10). Future +changes bump `format_ver`; the multihash `hash_algo` allows changing the digest family without a format +bump. Unknown `format_ver` / `codec_id` / `ota_msg_type` values are ignored (forward-compatible). diff --git a/platformio.ini b/platformio.ini index d3e25576..5523ce7f 100644 --- a/platformio.ini +++ b/platformio.ini @@ -59,15 +59,19 @@ extends = arduino_base platform = platformio/espressif32@6.11.0 monitor_filters = esp32_exception_decoder ; OTA is available on every ESP32 variant (A/B via esp_ota; the detools-sequential apply decodes into the -; inactive slot). The build always includes it; for `applydelta` to actually run, the board must use a -; dual-app/OTA partition table (board_build.partitions) with two app slots + otadata. pio_endf appends -; the EndF self-identity trailer. (nRF52 single-slot OTA is enabled per-board, not here — it needs a -; custom bootloader; RP2040/STM32 have no A/B path yet.) +; inactive slot). The received .mota is staged in the INACTIVE OTA slot (OtaStoreFlashEsp32), not RAM, so +; deltas/full images of any size fetch over the air (RX-safe, sector-coalesced). The build always includes +; it; for `applydelta` to actually run, the board must use a dual-app/OTA partition table +; (board_build.partitions) with two app slots + otadata (boards without one fetch-refuse cleanly). +; pio_endf appends the EndF self-identity trailer. (nRF52 single-slot OTA is enabled per-board, not here — +; it needs a custom bootloader; RP2040/STM32 have no A/B path yet.) extra_scripts = merge-bin.py post:tools/mota/pio_endf.py build_flags = ${arduino_base.build_flags} -D ESP32_PLATFORM -D ENABLE_OTA=1 + -D OTA_FLASH_STORE=1 + -D OTA_FOLDER_SERIAL ; `ota folder on` relays a host folder of .mota over the USB console (no extra HW) ; -D ESP32_CPU_FREQ=80 ; change it to your need build_src_filter = ${arduino_base.build_src_filter} + @@ -118,6 +122,7 @@ extra_scripts = ${nrf52_base.extra_scripts} build_flags = ${nrf52_base.build_flags} -D ENABLE_OTA=1 -D OTA_FLASH_STORE=1 + -D OTA_FOLDER_SERIAL ; `ota folder on` relays a host folder of .mota over the USB console (no extra HW) build_src_filter = ${nrf52_base.build_src_filter} + lib_deps = ${nrf52_base.lib_deps} diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 013b2a32..ecab951f 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -2,6 +2,20 @@ //#include #if defined(ENABLE_OTA) #include "helpers/ota/OtaContext.h" // OTA mesh-integration is centralized here so every role gets it +#include "helpers/ota/OtaProtocol.h" // decode_adv -> the `ota neighbors` discovery table +#include "helpers/ota/OtaSelf.h" // ota_self_firmware -> auto-advertise our own image +#ifndef OTA_ANNOUNCE_BOOT_MS +#define OTA_ANNOUNCE_BOOT_MS 30000UL // first self-advert ~30 s after boot (let the node settle) +#endif +#ifndef OTA_ANNOUNCE_BURST +#define OTA_ANNOUNCE_BURST 4 // a few closely-spaced boot adverts so co-booting peers catch one +#endif +#ifndef OTA_ANNOUNCE_BURST_MS +#define OTA_ANNOUNCE_BURST_MS 45000UL // spacing during the boot burst (~3 min total), then ... +#endif +#ifndef OTA_ANNOUNCE_INTERVAL_MS +#define OTA_ANNOUNCE_INTERVAL_MS 86400000UL // ... every 24 h — all lowest priority, duty-gated +#endif #endif namespace mesh { @@ -22,7 +36,13 @@ void Mesh::begin() { #ifdef MOTA_TARGET_ID my_tid = (uint32_t)(MOTA_TARGET_ID); // sha2-256:4(env name), injected by build.sh #endif - ota::ota_ctx().begin(my_tid, Mesh::otaSendAdapter, this); // also sets the platform apply codec + const char* my_hw = ""; + #ifdef MOTA_HW_ID + my_hw = MOTA_HW_ID; // human-readable hardware tag (per-variant), for the apply hw gate + #endif + ota::ota_ctx().begin(my_tid, Mesh::otaSendAdapter, this, my_hw); // also sets the platform apply codec + ota::ota_ctx().manager.set_seeder_id(self_id.pub_key); // node id (pubkey[0:4]) for advert seeder count + _next_ota_announce = futureMillis(OTA_ANNOUNCE_BOOT_MS); // advertise our own fw shortly after boot #endif } @@ -47,9 +67,44 @@ void Mesh::loop() { } } if (millisHasNowPassed(_next_ota_tick)) { - ota::ota_ctx().manager.loop(); // re-request still-missing OTA blocks (rate-limited) + // one-shot on first tick: resume an interrupted fetch left staged in flash before a reboot. Only adopt + // a PARTIAL container (continue fetching the holes); a COMPLETE one is left for manual/auto-install, + // not re-adopted at boot. requestMissing() (inside resumeStaged) drives the rest via REQ/DATA. + if (!_ota_resumed) { + _ota_resumed = true; + ota::OtaContext& oc = ota::ota_ctx(); + if (oc.manager.fetchState() == ota::OtaManager::IDLE && oc.manager.resumeStaged(nullptr) + && oc.manager.fetchState() == ota::OtaManager::COMPLETE) { + oc.manager.reset_session(); // don't auto-adopt a complete staged container on boot + } + } + ota::ota_ctx().manager.set_clock(_ms->getMillis()); // for discovery jitter/ages + the pending-query timer + ota::ota_ctx().manager.loop(); // re-request still-missing OTA blocks + fire scheduled queries _next_ota_tick = futureMillis(3000); } + if (millisHasNowPassed(_next_ota_announce)) { // auto-advertise so peers discover us (tiny beacon) + ota::OtaContext& oc = ota::ota_ctx(); + // To be discoverable as a source of our OWN firmware, set up flash-backed self-serve once; then the + // beacon (announce) advertises our served set and peers can QUERY + fetch it. + if (!oc.serving) oc.serving = ota::ota_serve_self(oc, 0); + oc.manager.announce(); + // boot burst (a few closely-spaced adverts so a co-booting peer catches one), then settle to daily + _next_ota_announce = futureMillis(_ota_announce_count < OTA_ANNOUNCE_BURST + ? OTA_ANNOUNCE_BURST_MS : OTA_ANNOUNCE_INTERVAL_MS); + if (_ota_announce_count < 250) _ota_announce_count++; + } + { // auto-install (once per COMPLETE fetch): only signed images, and apply_fetched enforces trust + ota::OtaContext& oc = ota::ota_ctx(); + if (oc.manager.fetchState() != ota::OtaManager::COMPLETE) { + _ota_autoinstall_tried = false; + } else if (!_ota_autoinstall_tried && !oc.apply_pending + && oc.autoinstall == ota::OtaContext::AUTOINSTALL_TRUSTED + && oc.manager.fetched_is_signed()) { + _ota_autoinstall_tried = true; + char msg[100]; + oc.apply_fetched(msg); // arms + sets apply_pending only if signed & allowlisted; refused otherwise + } + } #endif } @@ -358,7 +413,10 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { // dedup would suppress those retries and the transfer could never recover from a lost reply. // hasSeen() is used ONLY to avoid re-flooding the same packet more than once. bool seen = _tables->hasSeen(pkt); - ota::ota_ctx().manager.on_message(pkt->payload, pkt->payload_len); // central OTA receive (all roles) + ota::ota_ctx().manager.set_clock(_ms->getMillis()); // discovery jitter/ages + ota::ota_ctx().manager.on_message(pkt->payload, pkt->payload_len); // central OTA receive (beacon/query/ + // have/manifest/data/proof; all roles) + ota::ota_ctx().track_session(ota::ota_ctx().manager.fetchState(), _ms->getMillis()); onOtaRecv(pkt); // optional per-example hook // Re-flood with a hop cap and the LOWEST priority, so OTA never competes with mesh traffic. uint8_t n = pkt->getPathHashCount(); diff --git a/src/Mesh.h b/src/Mesh.h index 8c03b3d0..e550e2c7 100644 --- a/src/Mesh.h +++ b/src/Mesh.h @@ -168,6 +168,10 @@ protected: // companion, room, sensor, ...) gets fetch/serve/apply without per-example wiring. static void otaSendAdapter(void* ctx, const uint8_t* msg, uint16_t len, bool flood); unsigned long _next_ota_tick = 0; + unsigned long _next_ota_announce = 0; // auto-advertise our own fw: boot burst + every OTA_ANNOUNCE_INTERVAL + uint8_t _ota_announce_count = 0; // adverts sent so far (boot burst before settling to daily) + bool _ota_resumed = false; // one-shot: resumed an interrupted fetch staged in flash on boot + bool _ota_autoinstall_tried = false; // attempted auto-install for the current COMPLETE fetch #endif /** diff --git a/src/MeshCore.h b/src/MeshCore.h index debcb807..6c99b115 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -74,6 +74,16 @@ public: return 0; #endif } + // Human-readable hardware tag (<=32 ASCII chars, e.g. "RAK4631") naming the hardware this firmware can + // boot on. Same tag == bootable-compatible; the OTA applier refuses a `.mota` whose hw_id differs (brick- + // safety). Defined per-variant via the MOTA_HW_ID build flag; "" when unset (then the check is skipped). + virtual const char* getOtaHwId() const { + #ifdef MOTA_HW_ID + return MOTA_HW_ID; + #else + return ""; + #endif + } #endif // Power management interface (boards with power management override these) diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 333873e2..b24e2a76 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -6,6 +6,7 @@ #include #if defined(ENABLE_OTA) #include "ota/OtaCli.h" + #include "ota/OtaContext.h" // persist/sync OTA policy + signer allowlist with NodePrefs #endif #ifndef BRIDGE_MAX_BAUD @@ -31,15 +32,33 @@ static bool isValidName(const char *n) { } void CommonCLI::loadPrefs(FILESYSTEM* fs) { + bool loaded = false; if (fs->exists("/com_prefs")) { - loadPrefsInt(fs, "/com_prefs"); // new filename + loadPrefsInt(fs, "/com_prefs"); loaded = true; // new filename } else if (fs->exists("/node_prefs")) { loadPrefsInt(fs, "/node_prefs"); savePrefs(fs); // save to new filename fs->remove("/node_prefs"); // remove old + loaded = true; } +#if defined(ENABLE_OTA) + if (loaded) syncOtaConfigFromPrefs(); // persisted OTA policy/keys -> OtaContext (else keep safe defaults) +#endif } +#if defined(ENABLE_OTA) +// Push the persisted OTA policy + signer allowlist into the running OtaContext (called after load). +void CommonCLI::syncOtaConfigFromPrefs() { + mesh::ota::OtaContext& c = mesh::ota::ota_ctx(); + c.manager.set_autofetch(_prefs->ota_autofetch); + c.manager.set_checkpoint_blocks(_prefs->ota_checkpoint_blocks); + c.autoinstall = _prefs->ota_autoinstall; + c.allow.clear(); + for (uint8_t i = 0; i < _prefs->ota_signer_count && i < MAX_OTA_SIGNERS; i++) + c.allow.add(_prefs->ota_signers[i]); +} +#endif + void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { #if defined(RP2040_PLATFORM) File file = fs->open(filename, "r"); @@ -94,7 +113,16 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { file.read((uint8_t *)&_prefs->rx_boosted_gain, sizeof(_prefs->rx_boosted_gain)); // 290 file.read((uint8_t *)&_prefs->flood_max_unscoped, sizeof(_prefs->flood_max_unscoped)); // 291 file.read((uint8_t *)&_prefs->flood_max_advert, sizeof(_prefs->flood_max_advert)); // 292 - // next: 293 + // OTA config (293+). Default first so older prefs files (which lack these) keep conservative + // defaults: a short file makes the reads below no-ops (read returns 0 bytes, values unchanged). + _prefs->ota_autofetch = 0; _prefs->ota_autoinstall = 0; _prefs->ota_signer_count = 0; + _prefs->ota_checkpoint_blocks = 32; // default = OTA_CHECKPOINT_BLOCKS (older prefs lack it -> stays 32) + file.read((uint8_t *)&_prefs->ota_autofetch, sizeof(_prefs->ota_autofetch)); // 293 + file.read((uint8_t *)&_prefs->ota_autoinstall, sizeof(_prefs->ota_autoinstall)); // 294 + file.read((uint8_t *)&_prefs->ota_signer_count, sizeof(_prefs->ota_signer_count)); // 295 + file.read((uint8_t *)_prefs->ota_signers, sizeof(_prefs->ota_signers)); // 296 + file.read((uint8_t *)&_prefs->ota_checkpoint_blocks, sizeof(_prefs->ota_checkpoint_blocks)); // 424 + // next: 426 // sanitise bad pref values _prefs->rx_delay_base = constrain(_prefs->rx_delay_base, 0, 20.0f); @@ -124,6 +152,10 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { // sanitise settings _prefs->rx_boosted_gain = constrain(_prefs->rx_boosted_gain, 0, 1); // boolean + _prefs->ota_autofetch = constrain(_prefs->ota_autofetch, 0, 2); + _prefs->ota_autoinstall = constrain(_prefs->ota_autoinstall, 0, 1); + if (_prefs->ota_checkpoint_blocks > 4096) _prefs->ota_checkpoint_blocks = 32; // 0=never; cap absurd + if (_prefs->ota_signer_count > 4) _prefs->ota_signer_count = 0; // corrupt count -> drop keys file.close(); } @@ -187,7 +219,12 @@ void CommonCLI::savePrefs(FILESYSTEM* fs) { file.write((uint8_t *)&_prefs->rx_boosted_gain, sizeof(_prefs->rx_boosted_gain)); // 290 file.write((uint8_t *)&_prefs->flood_max_unscoped, sizeof(_prefs->flood_max_unscoped)); // 291 file.write((uint8_t *)&_prefs->flood_max_advert, sizeof(_prefs->flood_max_advert)); // 292 - // next: 293 + file.write((uint8_t *)&_prefs->ota_autofetch, sizeof(_prefs->ota_autofetch)); // 293 + file.write((uint8_t *)&_prefs->ota_autoinstall, sizeof(_prefs->ota_autoinstall)); // 294 + file.write((uint8_t *)&_prefs->ota_signer_count, sizeof(_prefs->ota_signer_count)); // 295 + file.write((uint8_t *)_prefs->ota_signers, sizeof(_prefs->ota_signers)); // 296 + file.write((uint8_t *)&_prefs->ota_checkpoint_blocks, sizeof(_prefs->ota_checkpoint_blocks)); // 424 + // next: 424 file.close(); } @@ -312,6 +349,17 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re #if defined(ENABLE_OTA) } else if (memcmp(command, "ota", 3) == 0 && (command[3] == 0 || command[3] == ' ')) { mesh::ota::handle_ota_command(command, reply, *_board); + if (mesh::ota::ota_ctx().config_dirty) { // a policy/key changed via the CLI -> persist it + mesh::ota::OtaContext& c = mesh::ota::ota_ctx(); + _prefs->ota_autofetch = c.manager.autofetch(); + _prefs->ota_checkpoint_blocks = c.manager.checkpoint_blocks(); + _prefs->ota_autoinstall = c.autoinstall; + _prefs->ota_signer_count = c.allow.count(); + for (uint8_t i = 0; i < c.allow.count() && i < MAX_OTA_SIGNERS; i++) + memcpy(_prefs->ota_signers[i], c.allow.get(i), 32); + _callbacks->savePrefs(); + c.config_dirty = false; + } #endif } else if (memcmp(command, "sensor get ", 11) == 0) { const char* key = command + 11; diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index b509c2b3..5144c863 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -63,6 +63,12 @@ struct NodePrefs { // persisted to file uint8_t rx_boosted_gain; // power settings uint8_t path_hash_mode; // which path mode to use when sending uint8_t loop_detect; + // OTA config (persisted; synced to OtaContext on load, written on change). 0 = conservative defaults. + uint8_t ota_autofetch; // OtaManager AUTOFETCH_* (0=off, 1=any-compatible, 2=signed-only) + uint8_t ota_autoinstall; // OtaContext AUTOINSTALL_* (0=off, 1=trusted-only) + uint8_t ota_signer_count; // # of allowlisted signer pubkeys below + uint8_t ota_signers[4][32]; // trusted Ed25519 signer pubkeys (== MAX_OTA_SIGNERS) + uint16_t ota_checkpoint_blocks; // resume checkpoint cadence (blocks); 0=never. Default 32 (runtime-tunable) }; class CommonCLICallbacks { @@ -127,6 +133,9 @@ class CommonCLI { mesh::RTCClock* getRTCClock() { return _rtc; } void savePrefs(); void loadPrefsInt(FILESYSTEM* _fs, const char* filename); +#if defined(ENABLE_OTA) + void syncOtaConfigFromPrefs(); // persisted OTA policy + signer allowlist -> running OtaContext +#endif void handleRegionCmd(char* command, char* reply); void handleGetCmd(uint32_t sender_timestamp, char* command, char* reply); diff --git a/src/helpers/ota/MotaContainer.cpp b/src/helpers/ota/MotaContainer.cpp index 7eae43a9..859337ce 100644 --- a/src/helpers/ota/MotaContainer.cpp +++ b/src/helpers/ota/MotaContainer.cpp @@ -28,7 +28,7 @@ bool mota_parse(const uint8_t* buf, uint32_t len, MotaManifest& out) { // helper bounds check #define NEED(n) do { if ((uint32_t)(end - p) < (uint32_t)(n)) return false; } while (0) - NEED(3 + 16 + 1 + 4 + 32 + 1); + NEED(3 + 16 + 1 + 4 + 32 + 1 + 32); // fixed head incl. hw_id[32] out.format_ver = p[0]; if (out.format_ver != MOTA_FORMAT_VER) return false; out.flags = p[1]; @@ -41,7 +41,8 @@ bool mota_parse(const uint8_t* buf, uint32_t len, MotaManifest& out) { out.merkle_root = p + 20; out.image_hash = p + 24; out.codec_id = p[56]; - p += 57; + out.hw_id = p + 57; // 32-byte NUL-padded hardware tag (signed) + p += 89; if (out.block_size_log2 == 0 || out.block_size_log2 > 24) return false; uint32_t bs = out.block_size(); @@ -77,7 +78,7 @@ bool mota_parse_manifest(const uint8_t* mf, uint32_t len, MotaManifest& out) { const uint8_t* end = mf + len; #define NEEDM(n) do { if ((uint32_t)(end - p) < (uint32_t)(n)) return false; } while (0) - NEEDM(57); + NEEDM(89); // fixed head incl. hw_id[32] out.manifest_start = mf; out.format_ver = p[0]; if (out.format_ver != MOTA_FORMAT_VER) return false; @@ -91,7 +92,8 @@ bool mota_parse_manifest(const uint8_t* mf, uint32_t len, MotaManifest& out) { out.merkle_root = p + 20; out.image_hash = p + 24; out.codec_id = p[56]; - p += 57; + out.hw_id = p + 57; // 32-byte NUL-padded hardware tag (signed) + p += 89; if (!out.is_full()) { NEEDM(8); out.base_hash = p; p += 8; } if (out.is_signed()) { NEEDM(32); out.signer_pubkey = p; p += 32; diff --git a/src/helpers/ota/MotaContainer.h b/src/helpers/ota/MotaContainer.h index 708674dd..64dc0b7c 100644 --- a/src/helpers/ota/MotaContainer.h +++ b/src/helpers/ota/MotaContainer.h @@ -26,6 +26,7 @@ struct MotaManifest { const uint8_t* merkle_root = nullptr; // 4 const uint8_t* image_hash = nullptr; // 32 + const uint8_t* hw_id = nullptr; // 32 (NUL-padded ASCII hardware tag; signed; v2+) const uint8_t* base_hash = nullptr; // 8 (delta only) const uint8_t* signer_pubkey = nullptr; // 32 (signed only) const uint8_t* signature = nullptr; // 64 (signed only) diff --git a/src/helpers/ota/MotaSeederProto.h b/src/helpers/ota/MotaSeederProto.h new file mode 100644 index 00000000..51767c9c --- /dev/null +++ b/src/helpers/ota/MotaSeederProto.h @@ -0,0 +1,44 @@ +#pragma once + +#include + +// Wire contract for the "mota-seeder" link: a device (CLIENT) pulls `.mota` bytes on demand from a host +// daemon (SERVER) that owns a folder of `.mota` files. This is the FIRST concrete MotaSource transport +// (docs/ota_protocol.md §9) — the device speaks it over a dedicated Stream (a spare UART / USB-UART), so +// it never contends with the line-based text CLI on the main console. +// +// The device always initiates; every request gets exactly one response. Framing is resync-safe: the +// reader scans for the 2-byte magic, so line noise / a half-read frame just times out and is retried +// (OTA is lowest priority — eventually-upgradable). All multi-byte fields are little-endian. +// +// 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: - resp payload: count(1) +// OP_DESCRIBE 0x02 args: idx(1) resp payload: MotaDesc wire (38 B, see below) [status OK] +// OP_READ 0x03 args: idx(1) off(4) len(2) resp payload: len bytes [status OK] +// +// 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) +// +// status: 0 = OK, non-zero = error (idx out of range, read past EOF, ...). On error the response carries +// no payload (just magic+op+status+xsum). + +namespace mesh { +namespace ota { + +static const uint8_t MOTA_SEEDER_REQ_MAGIC0 = 'M'; +static const uint8_t MOTA_SEEDER_REQ_MAGIC1 = 'S'; +static const uint8_t MOTA_SEEDER_RSP_MAGIC0 = 'm'; +static const uint8_t MOTA_SEEDER_RSP_MAGIC1 = 's'; + +static const uint8_t MS_OP_COUNT = 0x01; +static const uint8_t MS_OP_DESCRIBE = 0x02; +static const uint8_t MS_OP_READ = 0x03; + +static const uint8_t MS_STATUS_OK = 0x00; + +static const uint16_t MOTA_DESC_WIRE = 38; // bytes of a MotaDesc on the wire (see layout above) + +} // namespace ota +} // namespace mesh diff --git a/src/helpers/ota/MotaSourceSerial.cpp b/src/helpers/ota/MotaSourceSerial.cpp new file mode 100644 index 00000000..ea4f93e5 --- /dev/null +++ b/src/helpers/ota/MotaSourceSerial.cpp @@ -0,0 +1,100 @@ +#include "MotaSourceSerial.h" +#include "MotaSeederProto.h" +#include + +namespace mesh { +namespace ota { + +static uint32_t rd_u32le(const uint8_t* p) { + return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24); +} +static void wr_u32le(uint8_t* p, uint32_t v) { p[0]=v; p[1]=v>>8; p[2]=v>>16; p[3]=v>>24; } + +bool SerialMotaSource::readByteT(uint8_t& b) { + uint32_t t0 = millis(); + while ((millis() - t0) < _to) { + int c = _io.read(); + if (c >= 0) { b = (uint8_t)c; return true; } + } + return false; +} + +bool SerialMotaSource::readExact(uint8_t* b, uint32_t n) { + for (uint32_t i = 0; i < n; i++) if (!readByteT(b[i])) return false; + return true; +} + +// One request/response transaction. Resync-safe: drains stale input, frames the request with an XOR +// checksum, then scans for the response magic and validates op+status+checksum before delivering payload. +bool SerialMotaSource::txn(uint8_t op, const uint8_t* args, uint8_t arglen, + uint8_t* payload, uint32_t payload_len) { + while (_io.read() >= 0) {} // drop any stale/partial bytes before a fresh request + uint8_t xs = op; + for (uint8_t i = 0; i < arglen; i++) xs ^= args[i]; + _io.write(MOTA_SEEDER_REQ_MAGIC0); _io.write(MOTA_SEEDER_REQ_MAGIC1); + _io.write(op); + if (arglen) _io.write(args, arglen); + _io.write(xs); + _io.flush(); + + // scan for response magic 'm' 's' (tolerate leading noise) + uint32_t t0 = millis(); bool got = false; + uint8_t prev = 0; + while ((millis() - t0) < _to) { + int c = _io.read(); + if (c < 0) continue; + if (prev == MOTA_SEEDER_RSP_MAGIC0 && (uint8_t)c == MOTA_SEEDER_RSP_MAGIC1) { got = true; break; } + prev = (uint8_t)c; + } + if (!got) return false; + + uint8_t hdr[2]; + if (!readExact(hdr, 2)) return false; // op, status + if (hdr[0] != op) return false; + uint8_t rxs = (uint8_t)(MOTA_SEEDER_RSP_MAGIC0 ^ MOTA_SEEDER_RSP_MAGIC1) ^ hdr[0] ^ hdr[1]; + bool ok = (hdr[1] == MS_STATUS_OK); + if (ok && payload_len) { + if (!readExact(payload, payload_len)) return false; + for (uint32_t i = 0; i < payload_len; i++) rxs ^= payload[i]; + } + uint8_t xsum; + if (!readByteT(xsum)) return false; + if (xsum != rxs) return false; // corrupt frame -> caller retries + return ok; +} + +uint8_t SerialMotaSource::count() { + uint8_t n = 0; + if (!txn(MS_OP_COUNT, nullptr, 0, &n, 1)) return 0; + return n; +} + +bool SerialMotaSource::describe(uint8_t idx, MotaDesc& out) { + uint8_t args[1] = { idx }; + uint8_t w[MOTA_DESC_WIRE]; + if (!txn(MS_OP_DESCRIBE, args, 1, w, MOTA_DESC_WIRE)) return false; + memcpy(out.mid, w, 4); + out.target_id = rd_u32le(w + 4); + out.fw_version = rd_u32le(w + 8); + out.codec_id = w[12]; + out.flags = w[13]; + out.total_size = rd_u32le(w + 14); + out.leaves_off = rd_u32le(w + 18); + out.block_count = rd_u32le(w + 22); + out.payload_off = rd_u32le(w + 26); + out.payload_size = rd_u32le(w + 30); + // bytes [34,38) reserved (zero) — kept for forward compat without changing MOTA_DESC_WIRE + return true; +} + +bool SerialMotaSource::read(uint8_t idx, uint32_t off, uint8_t* buf, uint32_t len) { + if (len > 0xFFFF) return false; // single transaction caps at 64 KB (a block is <=1 KB) + uint8_t args[7]; + args[0] = idx; + wr_u32le(args + 1, off); + args[5] = (uint8_t)(len & 0xFF); args[6] = (uint8_t)(len >> 8); + return txn(MS_OP_READ, args, 7, buf, len); +} + +} // namespace ota +} // namespace mesh diff --git a/src/helpers/ota/MotaSourceSerial.h b/src/helpers/ota/MotaSourceSerial.h new file mode 100644 index 00000000..26b9e298 --- /dev/null +++ b/src/helpers/ota/MotaSourceSerial.h @@ -0,0 +1,35 @@ +#pragma once + +#include +#include "OtaSource.h" + +// A MotaSource backed by a host "mota-seeder" daemon over a dedicated Stream (a spare UART / USB-UART). +// The device pulls catalog + bytes on demand (MotaSeederProto.h); the folder image is never held on the +// device — it streams through. Use a stream that is NOT the text-CLI console so the binary framing never +// collides with command/log text. Reads block on the Stream up to `timeout_ms` (OTA is lowest priority, +// so a serial round-trip's latency is acceptable; keep the daemon on a fast link). + +namespace mesh { +namespace ota { + +class SerialMotaSource : public MotaSource { +public: + explicit SerialMotaSource(Stream& io, uint32_t timeout_ms = 400) : _io(io), _to(timeout_ms) {} + + uint8_t count() override; + bool describe(uint8_t idx, MotaDesc& out) override; + bool read(uint8_t idx, uint32_t off, uint8_t* buf, uint32_t len) override; + +private: + // Send a request (op+args) and read its response header; on OK, `payload` (if non-null) receives + // `payload_len` bytes. Returns true iff a well-formed OK response for `op` arrived in time. + bool txn(uint8_t op, const uint8_t* args, uint8_t arglen, uint8_t* payload, uint32_t payload_len); + bool readByteT(uint8_t& b); // one byte within the timeout + bool readExact(uint8_t* b, uint32_t n); + + Stream& _io; + uint32_t _to; +}; + +} // namespace ota +} // namespace mesh diff --git a/src/helpers/ota/OtaApply.cpp b/src/helpers/ota/OtaApply.cpp index 62dfc38a..8b10a0e2 100644 --- a/src/helpers/ota/OtaApply.cpp +++ b/src/helpers/ota/OtaApply.cpp @@ -12,6 +12,10 @@ extern "C" { #include "detools/detools.h" // vendored detools 0.53.0 embeddable decoder (CRLE-only build) } + #if defined(OTA_FLASH_STORE) + #include "OtaStoreFlashEsp32.h" // flash-staged container (delta patch / full image in the slot) + #include "OtaSelf.h" // SelfFwInfo / ota_self_firmware (running-image base_hash gate) + #endif #elif defined(NRF52_PLATFORM) #include "OtaVerify.h" #include "OtaSelf.h" @@ -87,7 +91,12 @@ bool ota_apply_commit() { struct DetoolsCtx { const esp_partition_t* base; // delta base (running image), read at absolute `from_pos` long from_pos; // absolute byte offset into `base` - const uint8_t* patch; // .mota payload (whole patch held in RAM) +#if defined(OTA_FLASH_STORE) + OtaStoreFlashEsp32* store; // staged container; patch = payload region [patch_base, +patch_len) + uint32_t patch_base; // container offset where the payload (patch) begins +#else + const uint8_t* patch; // .mota payload held wholly in RAM (legacy RAM store) +#endif uint32_t patch_len; uint32_t patch_pos; esp_ota_handle_t out; // inactive slot write handle @@ -112,7 +121,11 @@ static int dt_from_seek(void* arg, int offset) { // detools uses relati static int dt_patch_read(void* arg, uint8_t* buf, size_t size) { DetoolsCtx* c = (DetoolsCtx*)arg; if (c->patch_pos + size > c->patch_len) return -DETOOLS_IO_FAILED; +#if defined(OTA_FLASH_STORE) + if (!c->store->read(c->patch_base + c->patch_pos, buf, size)) { c->io_ok = false; return -DETOOLS_IO_FAILED; } +#else memcpy(buf, c->patch + c->patch_pos, size); +#endif c->patch_pos += (uint32_t)size; return DETOOLS_OK; } @@ -124,6 +137,223 @@ static int dt_to_write(void* arg, const uint8_t* buf, size_t size) { return DETOOLS_OK; } +#if defined(OTA_FLASH_STORE) +// --- in-place delta on ESP32 (codec 2) ---------------------------------------------------------- +// A single in-place `.mota` can target BOTH nRF52 (bootloader applies it) and ESP32. On ESP32 the +// inactive slot is used as the in-place working memory: we copy the running image (the base) into the +// slot's bottom-staged-container-FREE region [0, write_start), then run detools' in-place decoder over +// that region (it reads the base, erases segments, writes the target back), reading the patch from the +// staged container's payload (which lives at/below write_start, disjoint from the working region). The +// decoded image is hashed against the signed image_hash BEFORE arming, so a bad decode never boots; the +// callbacks are bounded to [0, write_start) so they fail gracefully instead of touching the patch. +// (Sequential is still preferred on ESP32 — it streams straight to the slot with no base-copy; in-place +// exists only for single-artifact distribution. Requires the patch built with --inplace-segment 4096.) +struct InPlaceCtx { + const esp_partition_t* slot; // in-place working memory = slot[0, mem_max) + uint32_t mem_max; // = container write_start; accesses beyond this are refused + OtaStoreFlashEsp32* store; // staged container; patch = payload region [patch_base, +patch_len) + uint32_t patch_base, patch_len, patch_pos; + int step; // detools resume cursor (RAM; no cross-reboot resume of the apply) + bool io_ok; + const char* fail; // first failure point (diagnostic), nullptr until set + uint32_t fa, fn; int frc; // failing addr / len / esp_err +}; +static inline int ip_fail(InPlaceCtx* c, const char* w, uint32_t a, size_t n, int rc) { + if (!c->fail) { c->fail = w; c->fa = a; c->fn = (uint32_t)n; c->frc = rc; } + c->io_ok = false; return -DETOOLS_IO_FAILED; +} +static int ip_mem_read(void* a, void* dst, uintptr_t src, size_t n) { + InPlaceCtx* c = (InPlaceCtx*)a; + if ((uint32_t)src + n > c->mem_max) return ip_fail(c, "rd>max", (uint32_t)src, n, 0); + int rc = esp_partition_read(c->slot, (size_t)src, dst, n); + if (rc != ESP_OK) return ip_fail(c, "rd", (uint32_t)src, n, rc); + return DETOOLS_OK; +} +static int ip_mem_write(void* a, uintptr_t dst, void* src, size_t n) { + InPlaceCtx* c = (InPlaceCtx*)a; + if ((uint32_t)dst + n > c->mem_max) return ip_fail(c, "wr>max", (uint32_t)dst, n, 0); + int rc = esp_partition_write(c->slot, (size_t)dst, src, n); + if (rc != ESP_OK) return ip_fail(c, "wr", (uint32_t)dst, n, rc); + return DETOOLS_OK; +} +static int ip_mem_erase(void* a, uintptr_t addr, size_t n) { + InPlaceCtx* c = (InPlaceCtx*)a; + // esp_partition_erase_range requires a SECTOR-aligned size; detools' final in-place segment is partial + // (the image tail past the last full sector). addr is sector-aligned (== --inplace-segment), so round + // the length UP to a full sector. The over-erased bytes are scratch beyond image_size (never hashed), + // and — since detools processes high→low and erases-before-writing — they are never live patch data. + const uint32_t SEC = 4096; + if ((uint32_t)addr % SEC != 0) return ip_fail(c, "er!align", (uint32_t)addr, n, 0); + uint32_t len = ((uint32_t)n + SEC - 1) & ~(SEC - 1); + if ((uint32_t)addr + len > c->mem_max) return ip_fail(c, "er>max", (uint32_t)addr, len, 0); + int rc = esp_partition_erase_range(c->slot, (size_t)addr, len); + if (rc != ESP_OK) return ip_fail(c, "er", (uint32_t)addr, len, rc); + return DETOOLS_OK; +} +static int ip_step_set(void* a, int s) { ((InPlaceCtx*)a)->step = s; return DETOOLS_OK; } +static int ip_step_get(void* a, int* s) { *s = ((InPlaceCtx*)a)->step; return DETOOLS_OK; } +static int ip_patch_read(void* a, uint8_t* b, size_t n) { + InPlaceCtx* c = (InPlaceCtx*)a; + if (c->patch_pos + n > c->patch_len) return ip_fail(c, "patch>len", c->patch_pos, n, 0); + if (!c->store->read(c->patch_base + c->patch_pos, b, n)) return ip_fail(c, "patch_rd", c->patch_pos, n, 0); + c->patch_pos += (uint32_t)n; + return DETOOLS_OK; +} + +static bool esp32_inplace_apply(OtaStoreFlashEsp32& store, const MotaManifest& m, ApplyState& st, char* msg) { + const esp_partition_t* slot = store.partition(); + const esp_partition_t* base = esp_ota_get_running_partition(); + if (!slot || !base) { strcpy(msg, "no slot/base partition"); return false; } + SelfFwInfo fi; + if (!ota_self_firmware(fi) || !fi.valid) { strcpy(msg, "cannot read running firmware (no EndF)"); return false; } + if (!m.base_hash || memcmp(m.base_hash, fi.body_hash, 8) != 0) { strcpy(msg, "not built for the running firmware (base mismatch)"); return false; } + uint32_t mem_max = store.write_start(); // working region [0, mem_max); the patch sits at/above it + if (mem_max == 0) { strcpy(msg, "in-place needs a bottom-staged container"); return false; } + if (fi.image_len > mem_max || m.image_size > mem_max) { strcpy(msg, "in-place region too small for base/image"); return false; } + + // load the base (running image) into the working region [0, base_len), sector by sector (erase + copy) + uint8_t buf[512]; + for (uint32_t off = 0; off < fi.image_len; ) { + uint32_t sec = off & ~(4096u - 1); + if (esp_partition_erase_range(slot, sec, 4096) != ESP_OK) { strcpy(msg, "base erase failed"); return false; } + uint32_t secend = sec + 4096; if (secend > fi.image_len) secend = fi.image_len; + for (uint32_t p = (off > sec ? off : sec); p < secend; ) { + uint32_t n = secend - p; if (n > sizeof(buf)) n = sizeof(buf); + if (esp_partition_read(base, p, buf, n) != ESP_OK || esp_partition_write(slot, p, buf, n) != ESP_OK) { + strcpy(msg, "base copy failed"); return false; } + p += n; + } + off = secend; + } + + // patch in place over the working region; patch streamed from the staged container payload + InPlaceCtx c; + c.slot = slot; c.mem_max = mem_max; c.store = &store; + c.patch_base = store.meta_bytes(); c.patch_len = m.payload_size; c.patch_pos = 0; c.step = 0; c.io_ok = true; + c.fail = nullptr; c.fa = c.fn = 0; c.frc = 0; + int r = detools_apply_patch_in_place_callbacks(ip_mem_read, ip_mem_write, ip_mem_erase, + ip_step_set, ip_step_get, ip_patch_read, + (size_t)m.payload_size, &c); + if (r < 0 || !c.io_ok) { + if (c.fail) sprintf(msg, "in-place decode err %d @%s a=%u n=%u rc=%d max=%u", r, c.fail, + (unsigned)c.fa, (unsigned)c.fn, c.frc, (unsigned)mem_max); + else sprintf(msg, "in-place decode err %d", r); + return false; + } + if ((uint32_t)r != m.image_size) { sprintf(msg, "in-place size %u!=%u", (unsigned)r, (unsigned)m.image_size); return false; } + + // verify the decoded slot image against the signed image_hash BEFORE arming (mismatch -> never boots) + SHA256 sha; + for (uint32_t off = 0; off < m.image_size; ) { + uint32_t n = m.image_size - off; if (n > sizeof(buf)) n = sizeof(buf); + if (esp_partition_read(slot, off, buf, n) != ESP_OK) { strcpy(msg, "slot read failed"); return false; } + sha.update(buf, n); off += n; + } + uint8_t hh[32]; sha.finalize(hh, 32); + st.slot_ok = (memcmp(hh, m.image_hash, 32) == 0); + if (!st.slot_ok) { strcpy(msg, "image_hash MISMATCH after in-place decode"); return false; } + if (esp_ota_set_boot_partition(slot) != ESP_OK) { strcpy(msg, "set_boot failed"); return false; } + sprintf(msg, "verified%s; in-place decoded %u B, image hash OK — armed, rebooting to apply", + m.is_signed() ? " (signer trusted)" : " (unsigned)", (unsigned)m.image_size); + return true; +} + +// Apply the `.mota` staged in the inactive slot by OtaStoreFlashEsp32 (no contiguous RAM copy). +// FULL: the payload was streamed straight to slot offset 0 during the fetch -> hash the slot image +// and compare to the signed image_hash, then arm. No decode, no copy. +// DELTA (sequential): base = the running slot; the patch is read from the staged payload region (the +// slot's bottom); the reconstructed image is written to the inactive slot via esp_ota_write and +// hashed vs image_hash. esp_ota_begin only erases [0, image_size], which the fetch-time fit +// check kept below the bottom-staged container, so the patch survives while we decode over it. +// DELTA (in-place): copy the running image into the slot's working region then patch in place +// (esp32_inplace_apply); image_hash-gated before arming. Lets one in-place .mota target both +// ESP32 and nRF52. Sequential is still preferred on ESP32 (no base-copy). +// The result is verified (signature/trust up front, image_hash after) and the slot armed; the caller +// reboots once the confirmation reply has gone out. +bool ota_apply_detools_mota(OtaStoreFlashEsp32& store, const SignerAllowlist& allow, ApplyState& st, char* msg) { + st = ApplyState(); + const esp_partition_t* slot = store.partition(); + if (!slot || store.staged_size() < 16) { strcpy(msg, "no staged update"); return false; } + st.slot_addr = slot->address; st.slot_size = slot->size; + + // read + parse the manifest out of the staged container (header = MAGIC(4) + total(4)) + uint8_t hdr[8]; + if (!store.read(0, hdr, 8) || memcmp(hdr, MOTA_MAGIC, 4) != 0) { strcpy(msg, "bad container"); return false; } + uint8_t mfbuf[256]; + uint32_t mflen = store.meta_bytes() > 8 ? store.meta_bytes() - 8 : 0; // manifest+leaves; cap to mfbuf + if (mflen > sizeof(mfbuf)) mflen = sizeof(mfbuf); + MotaManifest m; + if (mflen < 57 || !store.read(8, mfbuf, mflen) || !mota_parse_manifest(mfbuf, mflen, m)) { + strcpy(msg, "manifest parse failed"); return false; } + st.image_size = m.image_size; memcpy(st.image_hash, m.image_hash, 32); st.manifest_ok = true; + if (m.image_size == 0 || m.image_size > slot->size) { strcpy(msg, "image > slot"); return false; } + + // signature / trust BEFORE arming an untrusted image (image_hash below is the target-firmware gate) + if (m.is_signed()) { + mesh::Identity signer(m.signer_pubkey); + st.sig_ok = signer.verify(m.signature, m.manifest_start, (int)m.signed_len); + st.trusted = st.sig_ok && allow.contains(m.signer_pubkey); + if (!st.sig_ok) { strcpy(msg, "bad signature"); return false; } + if (!st.trusted) { strcpy(msg, "untrusted signer (pubkey not in allowlist)"); return false; } + } + + // ---- FULL: payload already in slot[0]; verify hash + arm ---- + if (m.is_full()) { + SHA256 sha; uint8_t buf[512]; + for (uint32_t off = 0; off < m.image_size; ) { + uint32_t n = m.image_size - off; if (n > sizeof(buf)) n = sizeof(buf); + if (esp_partition_read(slot, off, buf, n) != ESP_OK) { strcpy(msg, "slot read failed"); return false; } + sha.update(buf, n); off += n; + } + uint8_t hh[32]; sha.finalize(hh, 32); + st.slot_ok = (memcmp(hh, m.image_hash, 32) == 0); + if (!st.slot_ok) { strcpy(msg, "image_hash MISMATCH (slot)"); return false; } + if (esp_ota_set_boot_partition(slot) != ESP_OK) { strcpy(msg, "set_boot failed"); return false; } + sprintf(msg, "verified%s full image %u B in slot — armed, rebooting to apply", + m.is_signed() ? " (trusted)" : " (unsigned)", (unsigned)m.image_size); + return true; + } + + // ---- DELTA ---- + if (m.codec_id == CODEC_DETOOLS_INPLACE) return esp32_inplace_apply(store, m, st, msg); // single-artifact codec + if (m.codec_id != CODEC_DETOOLS_SEQUENTIAL) { strcpy(msg, "unknown delta codec"); return false; } + + // delta must be built for the running firmware (cheap early gate; image_hash is the definitive check) + if (m.base_hash) { + SelfFwInfo fi; + if (!ota_self_firmware(fi) || !fi.valid) { strcpy(msg, "cannot read running firmware (no EndF)"); return false; } + if (memcmp(m.base_hash, fi.body_hash, 8) != 0) { strcpy(msg, "delta not built for the running firmware (base mismatch)"); return false; } + } + + const esp_partition_t* base = esp_ota_get_running_partition(); + if (!base) { strcpy(msg, "no running partition"); return false; } + esp_ota_handle_t h; + if (esp_ota_begin(slot, m.image_size, &h) != ESP_OK) { strcpy(msg, "ota_begin failed"); return false; } + + SHA256 sha; + DetoolsCtx ctx; + ctx.base = base; ctx.from_pos = 0; + ctx.store = &store; ctx.patch_base = store.meta_bytes(); ctx.patch_len = m.payload_size; ctx.patch_pos = 0; + ctx.out = h; ctx.sha = &sha; ctx.out_pos = 0; ctx.io_ok = true; + + int r = detools_apply_patch_callbacks(dt_from_read, dt_from_seek, dt_patch_read, + (size_t)m.payload_size, dt_to_write, &ctx); + if (r < 0 || !ctx.io_ok) { esp_ota_abort(h); sprintf(msg, "detools err %d @%u/%u", + ctx.io_ok ? r : -DETOOLS_IO_FAILED, (unsigned)ctx.out_pos, (unsigned)m.image_size); return false; } + if ((uint32_t)r != m.image_size || ctx.out_pos != m.image_size) { + esp_ota_abort(h); sprintf(msg, "size mismatch %u!=%u", (unsigned)ctx.out_pos, (unsigned)m.image_size); return false; } + uint8_t hh[32]; sha.finalize(hh, 32); + st.slot_ok = (memcmp(hh, m.image_hash, 32) == 0); + if (!st.slot_ok) { esp_ota_abort(h); strcpy(msg, "image_hash MISMATCH after decode"); return false; } + if (esp_ota_end(h) != ESP_OK) { strcpy(msg, "ota_end failed"); return false; } + if (esp_ota_set_boot_partition(slot) != ESP_OK) { strcpy(msg, "set_boot failed"); return false; } + sprintf(msg, "verified%s; decoded %u B, image hash OK — armed, rebooting to apply", + m.is_signed() ? " (signer trusted)" : " (unsigned)", (unsigned)m.image_size); + return true; +} + +#else // !OTA_FLASH_STORE: legacy RAM-staged apply (whole .mota in a contiguous RAM buffer; bring-up) + bool ota_apply_detools_mota(const uint8_t* buf, uint32_t len, const SignerAllowlist& allow, ApplyState& st, char* msg) { st = ApplyState(); @@ -133,9 +363,6 @@ bool ota_apply_detools_mota(const uint8_t* buf, uint32_t len, const SignerAllowl st.image_size = m.image_size; memcpy(st.image_hash, m.image_hash, 32); st.manifest_ok = true; - // signature (if signed): valid Ed25519 AND signer in this device's allowlist — refuse otherwise, - // BEFORE decoding an untrusted image into the slot. (The decoded result is also checked against the - // manifest image_hash below, which is the target-firmware-hash gate.) if (m.is_signed()) { mesh::Identity signer(m.signer_pubkey); st.sig_ok = signer.verify(m.signature, m.manifest_start, (int)m.signed_len); @@ -143,29 +370,24 @@ bool ota_apply_detools_mota(const uint8_t* buf, uint32_t len, const SignerAllowl if (!st.sig_ok) { strcpy(msg, "bad signature"); return false; } if (!st.trusted) { strcpy(msg, "untrusted signer (pubkey not in allowlist)"); return false; } } - - const esp_partition_t* base = esp_ota_get_running_partition(); // delta base = what's running + const esp_partition_t* base = esp_ota_get_running_partition(); const esp_partition_t* out = esp_ota_get_next_update_partition(nullptr); if (!base || !out) { strcpy(msg, "no A/B slot"); return false; } st.slot_addr = out->address; st.slot_size = out->size; if (m.image_size > out->size) { strcpy(msg, "image > slot"); return false; } - esp_ota_handle_t h; if (esp_ota_begin(out, m.image_size, &h) != ESP_OK) { strcpy(msg, "ota_begin failed"); return false; } - SHA256 sha; DetoolsCtx ctx; ctx.base = base; ctx.from_pos = 0; ctx.patch = m.payload; ctx.patch_len = m.payload_size; ctx.patch_pos = 0; ctx.out = h; ctx.sha = &sha; ctx.out_pos = 0; ctx.io_ok = true; - int r = detools_apply_patch_callbacks(dt_from_read, dt_from_seek, dt_patch_read, (size_t)m.payload_size, dt_to_write, &ctx); if (r < 0 || !ctx.io_ok) { esp_ota_abort(h); sprintf(msg, "detools err %d @%u/%u", ctx.io_ok ? r : -DETOOLS_IO_FAILED, (unsigned)ctx.out_pos, (unsigned)m.image_size); return false; } if ((uint32_t)r != m.image_size || ctx.out_pos != m.image_size) { esp_ota_abort(h); sprintf(msg, "size mismatch %u!=%u", (unsigned)ctx.out_pos, (unsigned)m.image_size); return false; } - uint8_t hh[32]; sha.finalize(hh, 32); st.slot_ok = (memcmp(hh, m.image_hash, 32) == 0); if (!st.slot_ok) { esp_ota_abort(h); strcpy(msg, "image_hash MISMATCH after decode"); return false; } @@ -175,6 +397,7 @@ bool ota_apply_detools_mota(const uint8_t* buf, uint32_t len, const SignerAllowl m.is_signed() ? " (signer trusted)" : " (unsigned)", (unsigned)m.image_size); return true; } +#endif // OTA_FLASH_STORE bool ota_apply_mota_nrf52(const uint8_t*, uint32_t, const SignerAllowlist&, ApplyState& st, char* msg) { st = ApplyState(); strcpy(msg, "nRF52-only (ESP32 uses ota_apply_detools_mota)"); return false; diff --git a/src/helpers/ota/OtaApply.h b/src/helpers/ota/OtaApply.h index a0d06b9b..2b424e19 100644 --- a/src/helpers/ota/OtaApply.h +++ b/src/helpers/ota/OtaApply.h @@ -29,14 +29,19 @@ bool ota_apply_set_manifest(const uint8_t* mf, uint32_t len, bool ota_apply_verify_slot(ApplyState& st); // hash the slot vs image_hash bool ota_apply_commit(); // set-boot + reboot (no return) -// Apply a detools-sequential delta `.mota` (whole container in `buf`) using detools' own embeddable -// C decoder (CODEC_DETOOLS_SEQUENTIAL, --compression crle). The running slot is the delta base; the -// decoder streams the patch (held in RAM) and writes the reconstructed image into the inactive slot, -// while we hash the output and check it against the signed manifest image_hash. On success the -// inactive slot is set as boot partition; the caller then reboots. `msg` (>=80 bytes) receives a -// human-readable result. Returns true if the slot is verified + armed. +// Apply an ESP32 A/B `.mota` (CODEC_DETOOLS_SEQUENTIAL delta or CODEC_FULL image) and arm the inactive +// slot; the caller reboots after the confirmation reply. `msg` (>=80 bytes) receives a human-readable +// result. With OTA_FLASH_STORE the container is staged in the inactive slot (no contiguous RAM copy): a +// full payload is already in the slot (just verified), a sequential delta is decoded from the running +// slot over the staged patch. Without OTA_FLASH_STORE (bring-up) the whole container is a RAM buffer. +#if defined(ESP32_PLATFORM) && defined(OTA_FLASH_STORE) +class OtaStoreFlashEsp32; +bool ota_apply_detools_mota(OtaStoreFlashEsp32& store, + const SignerAllowlist& allow, ApplyState& st, char* msg); +#else bool ota_apply_detools_mota(const uint8_t* buf, uint32_t len, const SignerAllowlist& allow, ApplyState& st, char* msg); +#endif // nRF52 (RAK4631) single-slot apply. The running app can't rewrite itself, so it does NOT decode: it // runs the gated verification chain (payload hash -> built-for-this-firmware -> signature/trust) and, diff --git a/src/helpers/ota/OtaCli.cpp b/src/helpers/ota/OtaCli.cpp index c1677ff1..9b20ec39 100644 --- a/src/helpers/ota/OtaCli.cpp +++ b/src/helpers/ota/OtaCli.cpp @@ -5,6 +5,8 @@ #include "Utils.h" #include #include +#include +#include // millis() for session-age display (device-only command surface) namespace mesh { namespace ota { @@ -26,46 +28,216 @@ static char fstate_char(OtaManager::FetchState s) { } } +static const char* codec_name(uint8_t c) { + return c == CODEC_FULL ? "full" : (c == CODEC_DETOOLS_SEQUENTIAL ? "seq" + : (c == CODEC_DETOOLS_INPLACE ? "inpl" : "?")); +} + +// The everyday OTA surface is BitTorrent-shaped: `ota` shows what you're holding (your running firmware +// as a full mOTA + your one fetch session), `ota neighbors` shows the mOTAs heard around you, `ota pull` +// starts fetching one, `ota drop` frees the session. The raw primitives (manual content load, low-level +// apply steps) live under `ota dev ...` so they don't clutter the everyday surface. Every reply fits one +// packet so it works as remote-admin over LoRa. +static bool handle_dev(const char* d, char* reply, OtaContext& c); + bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board) { const char* a = command + 3; if (*a != 0 && *a != ' ') return false; while (*a == ' ') a++; OtaContext& c = ota_ctx(); + // ---- raw / internal primitives, tucked under `ota dev ...` ---- + if (strncmp(a, "dev", 3) == 0 && (a[3] == 0 || a[3] == ' ')) { + const char* d = a + 3; while (*d == ' ') d++; + return handle_dev(d, reply, c); + } + + // ---- inventory dashboard: running fw (self), the one fetch session, serving state ---- if (*a == 0 || strncmp(a, "status", 6) == 0) { SelfFwInfo fi; bool s = ota_self_firmware(fi); - sprintf(reply, "OTA tid=%08X self=%u serve=%u%s fetch=%c %u/%u keys=%u", - (unsigned)board.getOtaTargetId(), (unsigned)(s ? fi.body_len : 0), - (unsigned)c.serve_expected, c.serving ? "(on)" : "", - fstate_char(c.manager.fetchState()), + char selfhx[9]; if (s && fi.valid) mesh::Utils::toHex(selfhx, fi.body_hash, 4); else strcpy(selfhx, "?"); + OtaManager::FetchState fs = c.manager.fetchState(); + char midhx[9]; strcpy(midhx, "-"); + if (fs != OtaManager::IDLE) mesh::Utils::toHex(midhx, c.manager.fetchManifestId(), 4); + unsigned age = (fs != OtaManager::IDLE && c.session_started_ms) ? (unsigned)((millis() - c.session_started_ms) / 1000) : 0; + sprintf(reply, "OTA tgt=%08X fw=%s | self:%s%uK | sess:%c %u/%u mid=%s age=%us | serv:%s keys=%u", + (unsigned)board.getOtaTargetId(), selfhx, s ? "full " : "?", + (unsigned)((s ? fi.image_len : 0) / 1024), fstate_char(fs), (unsigned)c.manager.blocksHave(), (unsigned)c.manager.blocksTotal(), - (unsigned)c.allow.count()); + midhx, age, c.serving ? "on" : "off", (unsigned)c.allow.count()); + // ---- what's available around me (catalogued from beacons + OTA_HAVE), best/most-recent first ---- + } else if (strncmp(a, "neighbors", 9) == 0 || strncmp(a, "nbrs", 4) == 0) { + // Kick a fresh round of catalog queries (async — rows arrive over the next seconds); render what we + // have now. The reply buffer is 160 B (serial / one LoRa packet for remote-admin) so writes are bounded. + c.manager.queryAll(); + const int CAP = 160; + int n = snprintf(reply, CAP, "nbrs #:mid t=tgt codec seed age *=cur (src=%u)", (unsigned)c.manager.sourceCount()); + const uint8_t* cur = (c.manager.fetchState() != OtaManager::IDLE) ? c.manager.fetchManifestId() : nullptr; + uint32_t now = millis(); int shown = 0, more = 0; + for (uint8_t i = 0; i < c.manager.catalogCount(); i++) { + const OtaManager::CatRow* h = c.manager.catalogRow(i); + if (CAP - n < 60) { more++; continue; } + char midhx[9]; mesh::Utils::toHex(midhx, h->mid, 4); + bool on = cur && memcmp(cur, h->mid, 4) == 0; + uint32_t age = (now - h->last_ms) / 1000; if (age > 99999) age = 99999; + n += snprintf(reply + n, CAP - n, "\n %d:%s t=%08X %s seed=%u %us%s", shown + 1, midhx, + (unsigned)h->target_id, codec_name(h->codec), (unsigned)h->n_seeders, + (unsigned)age, on ? "*" : ""); + shown++; + } + if (more && n < CAP) snprintf(reply + n, CAP - n, "\n +%d more", more); + if (shown == 0) strcpy(reply, "nbrs: none yet — sources beacon periodically; re-run in a few s (queried now)"); + + // ---- start fetching a specific catalogued mOTA (by list index or manifest_id) ---- + } else if (strncmp(a, "pull", 4) == 0 && (a[4] == 0 || a[4] == ' ')) { + const char* p = a + 4; while (*p == ' ') p++; + if (*p == 0) { strcpy(reply, "usage: ota pull <#|mid8> (see `ota neighbors`)"); return true; } + const OtaManager::CatRow* sel = nullptr; uint8_t mid[4]; + if (*p == '#' || (p[0] >= '1' && p[0] <= '9' && (p[1] == 0 || p[1] == ' '))) { // index among catalogue + int idx = atoi(*p == '#' ? p + 1 : p); + if (idx >= 1 && idx <= c.manager.catalogCount()) sel = c.manager.catalogRow((uint8_t)(idx - 1)); + } else if (mesh::Utils::fromHex(mid, 4, p)) { // explicit manifest_id + for (uint8_t i = 0; i < c.manager.catalogCount(); i++) + if (memcmp(c.manager.catalogRow(i)->mid, mid, 4) == 0) { sel = c.manager.catalogRow(i); break; } + } + if (!sel) { strcpy(reply, "ERR no such neighbor (see `ota neighbors`)"); return true; } + if (c.apply_pending) { strcpy(reply, "ERR busy applying"); return true; } + uint8_t selmid[4]; uint32_t seltgt = sel->target_id; memcpy(selmid, sel->mid, 4); // sel may move on reset + c.manager.reset_session(); c.fetch_store.clear(); + c.manager.pull(selmid, seltgt); // sets want + begins the manifest fetch now + char midhx[9]; mesh::Utils::toHex(midhx, selmid, 4); + sprintf(reply, "OK pulling mid=%s target=%08X (low priority)", midhx, (unsigned)seltgt); + + // ---- discard the current session (e.g. a stalled old fetch) to free the slot ---- + } else if (strncmp(a, "drop", 4) == 0) { + OtaManager::FetchState fs = c.manager.fetchState(); + char midhx[9]; strcpy(midhx, "-"); + if (fs != OtaManager::IDLE) mesh::Utils::toHex(midhx, c.manager.fetchManifestId(), 4); + c.manager.reset_session(); c.manager.want(0); c.manager.want_mid(nullptr); + c.fetch_store.clear(); c.serving = false; c.serve_expected = 0; c.session_started_ms = 0; + sprintf(reply, "OK dropped session (was %c mid=%s); slot free for a new pull", fstate_char(fs), midhx); + + // ---- broadcast our tiny beacon so peers discover us. If not already serving, set up flash-backed + // self-serve first (so we're a real, fetchable source of our own running firmware). ---- + } else if (strncmp(a, "announce", 8) == 0) { + if (!c.serving) c.serving = ota_serve_self(c, 0); + c.manager.announce(); + sprintf(reply, "OK beacon sent (serving=%s)", c.serving ? "self fw" : "nothing"); + + // ---- running firmware identity (compare against a delta's base_hash) ---- + } else if (strncmp(a, "self", 4) == 0) { + SelfFwInfo fi; + if (!ota_self_firmware(fi) || !fi.valid) { strcpy(reply, "ERR no EndF (firmware lacks the trailer?)"); return true; } + char hx[17]; mesh::Utils::toHex(hx, fi.body_hash, 8); + sprintf(reply, "self body=%u image=%u base_hash=%s", (unsigned)fi.body_len, (unsigned)fi.image_len, hx); + + } else if (strncmp(a, "applydelta", 10) == 0) { + // Apply the fetched update. Destructive (reflashes + reboots) and GATED, not interactive (no "type + // yes" round-trip — unreliable over LoRa): refuse unless the fetch is COMPLETE, then the apply path + // validates in order (payload hash -> built-for-this-firmware -> signature/trust) and returns the + // FIRST failing gate, so the operator knows exactly why it refused; it proceeds only if all pass. + if (c.manager.fetchState() != OtaManager::COMPLETE || c.fetch_store.staged_size() == 0) { + sprintf(reply, "ERR no complete update fetched (fetch=%c %u/%u)", + fstate_char(c.manager.fetchState()), (unsigned)c.manager.blocksHave(), + (unsigned)c.manager.blocksTotal()); + return true; + } + // On success the slot is armed but NOT yet rebooted — defer so this reply reaches the operator first; + // the mesh loop reboots once it has been transmitted (same path used by auto-install). + char m2[100]; + bool ok = c.apply_fetched(m2); + sprintf(reply, "%s | %s", ok ? "OK" : "ERR", m2); + + // ---- external folder relay: advertise + serve `.mota` from a host daemon over the seeder UART, so the + // node hosts MANY images (any architecture) it doesn't hold in flash. Trustless (fetchers verify). -- + } else if (strncmp(a, "folder", 6) == 0) { + const char* p = a + 6; while (*p == ' ') p++; + if (strncmp(p, "on", 2) == 0) { +#if defined(OTA_FOLDER_SERIAL) + if (!c.serving) c.serving = ota_serve_self(c, 0); // keep serving our own fw alongside the folder + char m2[120]; c.attach_folder(m2, sizeof(m2)); c.manager.announce(); + strncpy(reply, m2, 159); reply[159] = 0; +#else + strcpy(reply, "ERR not built with OTA_FOLDER_SERIAL (set the seeder UART in platformio.ini)"); +#endif + } else if (strncmp(p, "off", 3) == 0) { + c.detach_folder(); c.manager.announce(); + strcpy(reply, "OK folder detached (still serving own fw)"); + } else { // status + list served entries (* = our own fw) + int n = snprintf(reply, 159, "folder=%s serving=%u:", c.folder_active ? "on" : "off", + (unsigned)c.manager.servedCount()); + for (uint8_t i = 0; i < c.manager.servedCount() && n < 148; i++) { + const OtaManager::ServeEntry* e = c.manager.servedEntry(i); + if (!e) break; + char midhx[9]; mesh::Utils::toHex(midhx, e->mid, 4); + n += snprintf(reply + n, 159 - n, " %s%s/%08X", e->is_self ? "*" : "", midhx, (unsigned)e->target_id); + } + } + + // ---- policy config (persisted via NodePrefs). conservative defaults: autofetch/autoinstall off ---- + } else if (strncmp(a, "config", 6) == 0) { + const char* p = a + 6; while (*p == ' ') p++; + if (strncmp(p, "autofetch ", 10) == 0) { + const char* v = p + 10; + uint8_t pol = strncmp(v, "any", 3) == 0 ? OtaManager::AUTOFETCH_ANY + : strncmp(v, "signed", 6) == 0 ? OtaManager::AUTOFETCH_SIGNED + : strncmp(v, "off", 3) == 0 ? OtaManager::AUTOFETCH_OFF : 0xFF; + if (pol == 0xFF) { strcpy(reply, "ERR usage: ota config autofetch "); return true; } + c.manager.set_autofetch(pol); c.config_dirty = true; strcpy(reply, "OK autofetch updated (saved)"); + } else if (strncmp(p, "autoinstall ", 12) == 0) { + const char* v = p + 12; + uint8_t pol = strncmp(v, "trusted", 7) == 0 ? OtaContext::AUTOINSTALL_TRUSTED + : strncmp(v, "off", 3) == 0 ? OtaContext::AUTOINSTALL_OFF : 0xFF; + if (pol == 0xFF) { strcpy(reply, "ERR usage: ota config autoinstall "); return true; } + c.autoinstall = pol; c.config_dirty = true; strcpy(reply, "OK autoinstall updated (saved)"); + } else if (strncmp(p, "checkpoint ", 11) == 0) { // resume checkpoint cadence (blocks; 0=never) + long n = atol(p + 11); + if (n < 0 || n > 4096) { strcpy(reply, "ERR usage: ota config checkpoint <0..4096> (blocks; 0=never)"); return true; } + c.manager.set_checkpoint_blocks((uint16_t)n); c.config_dirty = true; + sprintf(reply, "OK checkpoint every %ld blocks (saved)%s", n, n == 0 ? " — periodic resume disabled" : ""); + } else { // show current policy + uint8_t af = c.manager.autofetch(); + sprintf(reply, "ota config: autofetch=%s autoinstall=%s checkpoint=%u keys=%u (persisted)", + af == OtaManager::AUTOFETCH_ANY ? "any" : af == OtaManager::AUTOFETCH_SIGNED ? "signed" : "off", + c.autoinstall == OtaContext::AUTOINSTALL_TRUSTED ? "trusted" : "off", + (unsigned)c.manager.checkpoint_blocks(), (unsigned)c.allow.count()); + } + + // ---- trusted signer allowlist (security config; persisted) ---- } else if (strncmp(a, "key add ", 8) == 0) { uint8_t pub[32]; - strcpy(reply, (mesh::Utils::fromHex(pub, 32, a + 8) && c.allow.add(pub)) ? "OK key added" : "ERR key"); - + if (mesh::Utils::fromHex(pub, 32, a + 8) && c.allow.add(pub)) { c.config_dirty = true; strcpy(reply, "OK key added (saved)"); } + else strcpy(reply, "ERR key"); } else if (strncmp(a, "key list", 8) == 0) { int n = sprintf(reply, "keys=%u:", (unsigned)c.allow.count()); for (uint8_t i = 0; i < c.allow.count() && n < 140; i++) { char hx[17]; mesh::Utils::toHex(hx, c.allow.get(i), 8); n += sprintf(reply + n, " %s", hx); } - } else if (strncmp(a, "key rm ", 7) == 0) { uint8_t pub[32]; - strcpy(reply, (mesh::Utils::fromHex(pub, 32, a + 7) && c.allow.remove(pub)) ? "OK removed" : "ERR"); + if (mesh::Utils::fromHex(pub, 32, a + 7) && c.allow.remove(pub)) { c.config_dirty = true; strcpy(reply, "OK removed (saved)"); } + else strcpy(reply, "ERR"); - } else if (strncmp(a, "stage ", 6) == 0) { - uint32_t sz = parse_u32(a + 6); + } else { + strcpy(reply, "ota: status|neighbors|announce|pull <#|mid>|drop|folder|config|self|applydelta|key|dev"); + } + return true; +} + +// Raw / internal primitives (manual content load + low-level apply steps), under `ota dev ...`. +static bool handle_dev(const char* d, char* reply, OtaContext& c) { + if (strncmp(d, "stage ", 6) == 0) { + uint32_t sz = parse_u32(d + 6); if (sz == 0 || sz > OTA_SERVE_BUF_SIZE) { sprintf(reply, "ERR size 1..%u", OTA_SERVE_BUF_SIZE); } else { memset(c.serve_buf, 0xFF, sz); c.serve_expected = sz; c.serving = false; sprintf(reply, "OK stage %u bytes", (unsigned)sz); } - } else if (strncmp(a, "recv ", 5) == 0) { - const char* p = a + 5; uint32_t off = parse_u32(p); + } else if (strncmp(d, "recv ", 5) == 0) { + const char* p = d + 5; uint32_t off = parse_u32(p); const char* hex = strchr(p, ' '); - if (!hex) { strcpy(reply, "ERR usage: ota recv "); return true; } + if (!hex) { strcpy(reply, "ERR usage: ota dev recv "); return true; } hex++; int blen = (int)strlen(hex) / 2; uint8_t tmp[80]; @@ -73,103 +245,72 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board else if (off + blen > c.serve_expected) strcpy(reply, "ERR off>size (stage first)"); else { memcpy(c.serve_buf + off, tmp, blen); sprintf(reply, "OK %d@%u", blen, (unsigned)off); } - } else if (strncmp(a, "serve", 5) == 0) { + } else if (strncmp(d, "serve self", 10) == 0) { // host our own running firmware, served from flash + if (ota_serve_self(c, 0)) { + c.serving = true; + char midhx[9]; mesh::Utils::toHex(midhx, c.serve_self_manifest + 20, 4); + uint32_t img = (uint32_t)c.serve_self_manifest[11] | ((uint32_t)c.serve_self_manifest[12] << 8) + | ((uint32_t)c.serve_self_manifest[13] << 16) | ((uint32_t)c.serve_self_manifest[14] << 24); + sprintf(reply, "OK serving self fw mid=%s (%u B, flash-backed) — peers can pull it", midhx, (unsigned)img); + } else strcpy(reply, "ERR serve self (no EndF / image too big / OOM)"); + } else if (strncmp(d, "serve", 5) == 0) { c.serving = c.manager.serve(c.serve_buf, c.serve_expected); if (!c.serving) { strcpy(reply, "ERR serve (bad .mota)"); return true; } VerifyResult r = ota_verify(c.serve_buf, c.serve_expected, c.allow); sprintf(reply, "OK serving | root=%d img=%d sig=%d trust=%d", r.root_ok, r.image_ok, r.sig_ok, r.trusted); - } else if (strncmp(a, "announce", 8) == 0) { - if (!c.serving) { strcpy(reply, "ERR not serving (ota serve first)"); return true; } + } else if (strncmp(d, "resume", 6) == 0) { // re-adopt a container already staged in flash (test/debug) + bool ok = c.manager.resumeStaged(nullptr); + sprintf(reply, "%s resume: sess=%c %u/%u", ok ? "OK" : "ERR", fstate_char(c.manager.fetchState()), + (unsigned)c.manager.blocksHave(), (unsigned)c.manager.blocksTotal()); + + } else if (strncmp(d, "announce", 8) == 0) { + if (!c.serving) { strcpy(reply, "ERR not serving (ota dev serve first)"); return true; } c.manager.announce(); strcpy(reply, "OK announced"); - } else if (strncmp(a, "verify", 6) == 0) { - // verify whatever is staged-to-serve, OR the fetched container if a fetch is complete + } else if (strncmp(d, "verify", 6) == 0) { const uint8_t* buf; uint32_t len; if (c.manager.fetchState() == OtaManager::COMPLETE) { buf = c.fetch_store.data(); len = c.fetch_store.staged_size(); } else { buf = c.serve_buf; len = c.serve_expected; } - if (len == 0) { strcpy(reply, "ERR nothing to verify"); return true; } + if (len == 0 || !buf) { strcpy(reply, "ERR nothing to verify (flash-staged: applydelta verifies)"); return true; } VerifyResult r = ota_verify(buf, len, c.allow); sprintf(reply, "verify parsed=%d root=%d img=%d signed=%d sig=%d trust=%d | ok=%d auto=%d", - r.parsed, r.root_ok, r.image_ok, r.is_signed, r.sig_ok, r.trusted, - r.integrity_ok(), r.auto_appliable()); + r.parsed, r.root_ok, r.image_ok, r.is_signed, r.sig_ok, r.trusted, r.integrity_ok(), r.auto_appliable()); - } else if (strncmp(a, "applydelta", 10) == 0) { - // Apply the fetched delta. ESP32: detools-sequential decode into the inactive A/B slot + verify + - // arm (reboot after). nRF52: verify + mark APPROVED in flash + reboot into the bootloader, which - // does the in-place decode + verify before booting it (this call does not return on success). - // - // This is destructive (it reboots and reflashes). It is GATED, not interactive — no "type yes" - // round-trip (unreliable over LoRa). First, refuse unless a full update is present: the fetch must - // be COMPLETE (every block received AND the merkle root re-verified). Then the apply path validates - // in order and returns the FIRST failing gate, so the operator knows exactly why it refused - // (payload hash -> built-for-this-firmware -> signature/trust); it proceeds only if all pass. - if (c.manager.fetchState() != OtaManager::COMPLETE || c.fetch_store.staged_size() == 0) { - sprintf(reply, "ERR no complete update fetched (fetch=%c %u/%u)", - fstate_char(c.manager.fetchState()), (unsigned)c.manager.blocksHave(), - (unsigned)c.manager.blocksTotal()); - return true; - } - char m2[100]; -#if defined(NRF52_PLATFORM) - bool ok = ota_apply_mota_nrf52(c.fetch_store.data(), c.fetch_store.staged_size(), c.allow, c.apply_st, m2); -#else - bool ok = ota_apply_detools_mota(c.fetch_store.data(), c.fetch_store.staged_size(), c.allow, c.apply_st, m2); -#endif - // On success the update is approved/armed but NOT yet rebooted — arm the deferred handoff so this - // reply reaches the operator first; the mesh loop reboots once it has been transmitted. - if (ok) c.apply_pending = true; - sprintf(reply, "%s | %s", ok ? "OK" : "ERR", m2); + } else if (strncmp(d, "want ", 5) == 0) { + const char* p = d + 5; while (*p == ' ') p++; + if (strncmp(p, "auto", 4) == 0) { c.manager.want(0); c.manager.want_mid(nullptr); strcpy(reply, "OK auto (own target only)"); } + else { uint32_t t = (uint32_t)strtoul(p, nullptr, 16); c.manager.want(t); c.manager.want_mid(nullptr); + sprintf(reply, "OK cross-target: will fetch %08X (you ensure HW compatible)", (unsigned)t); } - } else if (strncmp(a, "self", 4) == 0) { - // running firmware identity (EndF): body_len + body_hash:8 — compare against a delta's base_hash - SelfFwInfo fi; - if (!ota_self_firmware(fi) || !fi.valid) { strcpy(reply, "ERR no EndF (firmware lacks the trailer?)"); return true; } - char hx[17]; mesh::Utils::toHex(hx, fi.body_hash, 8); - sprintf(reply, "self body=%u image=%u base_hash=%s", (unsigned)fi.body_len, (unsigned)fi.image_len, hx); - - } else if (strncmp(a, "apply", 5) == 0) { - const char* sub = a + 5; - while (*sub == ' ') sub++; + } else if (strncmp(d, "apply", 5) == 0) { + const char* sub = d + 5; while (*sub == ' ') sub++; if (strncmp(sub, "slot", 4) == 0) { uint32_t addr = 0, size = 0; if (ota_apply_slot_info(&addr, &size)) sprintf(reply, "inactive slot addr=0x%X size=%u", (unsigned)addr, (unsigned)size); else strcpy(reply, "ERR no A/B slot (apply unsupported on this build)"); } else if (strncmp(sub, "manifest", 8) == 0) { - // the manifest-fixed bytes were loaded into serve_buf via `ota stage`/`ota recv` if (ota_apply_set_manifest(c.serve_buf, c.serve_expected, c.allow, c.apply_st)) - sprintf(reply, "manifest ok img=%u sig=%d trust=%d", (unsigned)c.apply_st.image_size, - c.apply_st.sig_ok, c.apply_st.trusted); + sprintf(reply, "manifest ok img=%u sig=%d trust=%d", (unsigned)c.apply_st.image_size, c.apply_st.sig_ok, c.apply_st.trusted); else strcpy(reply, "ERR manifest parse / not full-image / unsupported"); } else if (strncmp(sub, "verify", 6) == 0) { bool ok = ota_apply_verify_slot(c.apply_st); sprintf(reply, "slot image_hash %s (size=%u)", ok ? "MATCH" : "MISMATCH", (unsigned)c.apply_st.image_size); } else if (strncmp(sub, "commit", 6) == 0) { - if (!c.apply_st.slot_ok) { strcpy(reply, "ERR run 'ota apply verify' first (slot must match)"); return true; } - // (D2: auto-apply would also require c.apply_st.trusted; a manual commit is allowed here.) - ota_apply_commit(); // sets boot partition + reboots into the new image; no return + if (!c.apply_st.slot_ok) { strcpy(reply, "ERR run 'ota dev apply verify' first (slot must match)"); return true; } + ota_apply_commit(); // set boot partition + reboot; no return strcpy(reply, "ERR commit failed (no A/B slot?)"); } else { - strcpy(reply, "ERR ota apply (slot|manifest|verify|commit)"); + strcpy(reply, "ERR ota dev apply (slot|manifest|verify|commit)"); } - } else if (strncmp(a, "want ", 5) == 0) { - const char* p = a + 5; - while (*p == ' ') p++; - if (strncmp(p, "auto", 4) == 0) { c.manager.want(0); strcpy(reply, "OK auto (own target only)"); } - else { - uint32_t t = (uint32_t)strtoul(p, nullptr, 16); // hex target_id (e.g. from another env) - c.manager.want(t); - sprintf(reply, "OK cross-target: will fetch %08X (you ensure HW compatible)", (unsigned)t); - } - - } else if (strncmp(a, "clear", 5) == 0) { - c.serve_expected = 0; c.serving = false; c.fetch_store.clear(); + } else if (strncmp(d, "clear", 5) == 0) { + c.serve_expected = 0; c.serving = false; c.fetch_store.clear(); c.manager.reset_session(); strcpy(reply, "OK cleared"); } else { - strcpy(reply, "ERR (status|self|key|stage|recv|serve|announce|verify|want|applydelta|clear)"); + strcpy(reply, "ota dev: stage|recv|serve|announce|verify|want|apply slot|manifest|verify|commit|clear"); } return true; } diff --git a/src/helpers/ota/OtaContext.h b/src/helpers/ota/OtaContext.h index 68b3abb7..2cf22342 100644 --- a/src/helpers/ota/OtaContext.h +++ b/src/helpers/ota/OtaContext.h @@ -1,5 +1,7 @@ #pragma once +#include // snprintf (hw_id mismatch message) +#include // strncmp/strncpy (hw_id) #include "OtaManager.h" #include "OtaStore.h" #include "SignerAllowlist.h" @@ -7,6 +9,19 @@ #include "OtaFormat.h" #if defined(NRF52_PLATFORM) && defined(OTA_FLASH_STORE) #include "OtaStoreFlashNrf52.h" +#elif defined(ESP32_PLATFORM) && defined(OTA_FLASH_STORE) + #include "OtaStoreFlashEsp32.h" +#endif +#if defined(OTA_FOLDER_SERIAL) + #include "MotaSourceSerial.h" // relay an external folder served by a host daemon over the USB serial + #ifndef OTA_FOLDER_SERIAL_STREAM + #define OTA_FOLDER_SERIAL_STREAM Serial // default: the same USB console the CLI uses (no extra HW) + #endif + #ifndef OTA_FOLDER_SERIAL_BAUD + #define OTA_FOLDER_SERIAL_BAUD 115200 + #endif + // The console Serial is already begun by the example; a DEDICATED UART (override the stream) needs init, + // so define OTA_FOLDER_SERIAL_BEGIN to have attach_folder() call .begin(baud) on it. #endif // Per-device OTA singleton shared by the CLI (OtaCli) and the mesh adapter (the example's MyMesh). @@ -31,6 +46,8 @@ struct OtaContext { OtaManager manager; #if defined(NRF52_PLATFORM) && defined(OTA_FLASH_STORE) OtaStoreFlashNrf52 fetch_store; // persistent flash staging (survives reboot; large deltas) +#elif defined(ESP32_PLATFORM) && defined(OTA_FLASH_STORE) + OtaStoreFlashEsp32 fetch_store; // stages in the inactive A/B slot (delta + full, RX-safe) #else OtaStoreRam fetch_store; #endif @@ -38,8 +55,62 @@ struct OtaContext { uint8_t serve_buf[OTA_SERVE_BUF_SIZE]; uint32_t serve_expected = 0; // size declared by `ota stage` bool serving = false; // manager.serve() succeeded + // flash-backed self-serve: cached merkle leaves (heap, freed on re-serve) + assembled manifest of our + // own running firmware. The payload is read from flash per block; only the metadata is held in RAM. + // serve_self_proof is the proof-gen working buffer (>= block_count*4) — sized to OUR image's block + // count (the manager's fixed 4 KB scratch only covers <=1024 blocks; a >1 MB image needs more). + uint8_t* serve_self_leaves = nullptr; + uint8_t* serve_self_proof = nullptr; + uint8_t serve_self_manifest[96]; // v2 full+unsigned manifest = 89 (fixed incl. hw_id) + 4 approval ApplyState apply_st; // pending apply (P6) + // OTA policy (persisted via NodePrefs; autofetch lives in the manager). Conservative defaults: a fresh + // node discovers + announces but never fetches/installs without operator intent. + static const uint8_t AUTOINSTALL_OFF = 0, AUTOINSTALL_TRUSTED = 1; + uint8_t autoinstall = AUTOINSTALL_OFF; // 1 = auto-apply a COMPLETE fetch IF signed + allowlisted + bool config_dirty = false; // CLI set a policy/key -> CommonCLI persists + clears + char hw_id[33] = {0}; // this device's hardware tag (from board.getOtaHwId(), set in begin) + + // True if the staged .mota's hw_id is compatible with this device: equal tags, or either side empty + // ("unknown" -> can't enforce -> permissive). Brick-safety gate for apply (esp. manual cross-target). + bool hwMatches(const uint8_t* mhw /*32B, may be null*/) const { + if (!hw_id[0] || !mhw) return true; + bool declared = false; for (int i = 0; i < 32; i++) if (mhw[i]) { declared = true; break; } + if (!declared) return true; + return strncmp((const char*)mhw, hw_id, 32) == 0; + } + + // Apply the COMPLETE fetched .mota (platform dispatch) and arm the slot; sets apply_pending on success + // so the deferred-reboot path (mesh loop) takes over. Caller ensures the fetch is COMPLETE. Shared by + // manual `ota applydelta` and the auto-install path. + bool apply_fetched(char* msg) { + // hardware-compatibility gate (brick-safety) — refuse a .mota whose hw_id is for different hardware, + // independent of signature; covers a manual cross-target `ota dev want` onto an incompatible board. + { + uint8_t hdr[8], mb[256]; + uint32_t total = fetch_store.staged_size(); + if (total >= 13 && fetch_store.read(0, hdr, 8) && memcmp(hdr, MOTA_MAGIC, 4) == 0) { + uint32_t mr = total - 8; if (mr > sizeof(mb)) mr = sizeof(mb); + MotaManifest mm; + if (fetch_store.read(8, mb, mr) && mota_parse_manifest(mb, mr, mm) && !hwMatches(mm.hw_id)) { + char want[33] = {0}; memcpy(want, mm.hw_id, 32); + snprintf(msg, 96, "refused: .mota hw_id '%.32s' != this device '%s' (incompatible hardware)", want, hw_id); + return false; + } + } + } + bool ok; +#if defined(NRF52_PLATFORM) + ok = ota_apply_mota_nrf52(fetch_store.data(), fetch_store.staged_size(), allow, apply_st, msg); +#elif defined(ESP32_PLATFORM) && defined(OTA_FLASH_STORE) + ok = ota_apply_detools_mota(fetch_store, allow, apply_st, msg); +#else + ok = ota_apply_detools_mota(fetch_store.data(), fetch_store.staged_size(), allow, apply_st, msg); +#endif + if (ok) apply_pending = true; + return ok; + } + // Deferred apply-reboot: a verified `ota applydelta` approves the update but does NOT reboot inline, // so the CLI can first deliver the "verified; applying" reply (over LoRa it's the only way the // operator learns the apply started). The mesh loop then calls ota_reboot_to_apply() once that reply @@ -48,13 +119,48 @@ struct OtaContext { uint32_t apply_at = 0; // earliest reboot time (lets the reply get queued + start sending) uint32_t apply_hard = 0; // hard cap, in case the TX queue never idles on a busy node - void begin(uint32_t target_id, OtaSend send, void* ctx) { + // --- discovery: the "what mOTAs are available around me" view ---------------------------------- + // The catalog (heard mOTAs) + the heard-sources table now live in OtaManager (built from beacons + + // OTA_HAVE catalog replies, the two-tier discovery). `ota neighbors` renders manager.catalogRow(); + // `ota pull` acts on a mid. Here we only keep the fetch-session age stamp. + uint32_t session_started_ms = 0; // when the fetch session last left IDLE (for the age display) + uint8_t prev_fstate = OtaManager::IDLE; + bool folder_active = false; // an external `.mota` folder is attached + being relayed + + // Attach/detach an external folder of `.mota` served by a host daemon over the seeder UART (the node + // then advertises + relays them alongside its own fw). Only built when OTA_FOLDER_SERIAL is configured. +#if defined(OTA_FOLDER_SERIAL) + bool attach_folder(char* msg, size_t cap) { + static SerialMotaSource src(OTA_FOLDER_SERIAL_STREAM, 600); +#ifdef OTA_FOLDER_SERIAL_BEGIN + OTA_FOLDER_SERIAL_STREAM.begin(OTA_FOLDER_SERIAL_BAUD); // dedicated UART; console is already up +#endif + manager.clear_sources(); // idempotent re-attach + if (!manager.add_source(&src)) { strncpy(msg, "ERR no free source slot", cap); return false; } + folder_active = true; + snprintf(msg, cap, "OK folder attached (serial) — serving %u mOTA total (own fw + folder)", + (unsigned)manager.servedCount()); + return true; + } +#endif + void detach_folder() { manager.clear_sources(); folder_active = false; } + + void track_session(uint8_t fstate, uint32_t now) { // stamp the session start (age display) + if (fstate != prev_fstate) { + if (prev_fstate == OtaManager::IDLE && fstate != OtaManager::IDLE) session_started_ms = now; + prev_fstate = fstate; + } + } + + void begin(uint32_t target_id, OtaSend send, void* ctx, const char* hw = nullptr) { manager.begin(target_id, send, ctx); + if (hw) { strncpy(hw_id, hw, sizeof(hw_id) - 1); hw_id[sizeof(hw_id) - 1] = 0; } // a node only fetches firmware it can apply: ESP32 A/B -> sequential, nRF52 single-slot -> in-place #if defined(NRF52_PLATFORM) manager.set_apply_codec(CODEC_DETOOLS_INPLACE); #elif defined(ESP32_PLATFORM) - manager.set_apply_codec(CODEC_DETOOLS_SEQUENTIAL); + manager.set_apply_codec(CODEC_DETOOLS_SEQUENTIAL); // preferred (streams straight to the slot) + manager.set_apply_codec2(CODEC_DETOOLS_INPLACE); // also accepted -> a single in-place .mota fits both #endif manager.set_fetch_store(&fetch_store); } diff --git a/src/helpers/ota/OtaFormat.h b/src/helpers/ota/OtaFormat.h index f4680b48..e9808ae5 100644 --- a/src/helpers/ota/OtaFormat.h +++ b/src/helpers/ota/OtaFormat.h @@ -18,9 +18,16 @@ static const uint8_t ENDF_MAGIC[4] = { 'E', 'n', 'd', 'F' }; // 45 6E 64 4 static const uint32_t ENDF_LEN = 16; // marker(4)+body_len(4)+body_hash8(8) // ---- manifest ------------------------------------------------------------- -static const uint8_t MOTA_FORMAT_VER = 1; +static const uint8_t MOTA_FORMAT_VER = 2; // v2 adds hw_id[32] (a human-readable hardware tag) static const uint8_t HASH_ALGO_SHA256 = 0x12; // multihash code +// hw_id: a fixed 32-byte, NUL-padded ASCII string naming the hardware a firmware can boot on (e.g. +// "RAK4631", "Heltec_v3"). Same hw_id == bootable-compatible (a role switch on the same board keeps it; +// different MCU/board differs). It sits in the SIGNED region of the manifest, so it can't be tampered. +// The applier refuses a `.mota` whose hw_id differs from the device's own (brick-safety, esp. for a manual +// cross-target `ota dev want`). An empty hw_id on either side = "unknown", and the check is skipped. +static const uint8_t MOTA_HW_ID_LEN = 32; + static const uint8_t MFLAG_FULL = 0x01; // 0 = delta/partial, 1 = full image static const uint8_t MFLAG_SIGNED = 0x02; @@ -46,8 +53,10 @@ enum OtaMsgType : uint8_t { OTA_HAVE = 0x03, OTA_GET_MANIFEST = 0x04, OTA_MANIFEST = 0x05, - OTA_REQ = 0x06, - OTA_DATA = 0x07, + OTA_REQ = 0x06, // request a window of blocks' DATA fragments + OTA_DATA = 0x07, // one fragment of a block's data (self-describing by frag_off; no proof) + OTA_REQ_PROOF = 0x08, // request the merkle proof for one block (data + proof are fetched separately) + OTA_PROOF = 0x09, // the merkle proof for one block }; static const uint16_t OTA_DEFAULT_BLOCK_SIZE = 1024; diff --git a/src/helpers/ota/OtaManager.cpp b/src/helpers/ota/OtaManager.cpp index 5684448f..f38c0b7d 100644 --- a/src/helpers/ota/OtaManager.cpp +++ b/src/helpers/ota/OtaManager.cpp @@ -15,80 +15,369 @@ static void wr_u32(uint8_t* p, uint32_t v) { p[0]=v; p[1]=v>>8; p[2]=v>>16; p[3] void OtaManager::begin(uint32_t my_target_id, OtaSend send, void* ctx) { _target = my_target_id; _send = send; _ctx = ctx; - _fstate = IDLE; _has_serve = false; _have = 0; _fbc = 0; + _fstate = IDLE; _have = 0; _fbc = 0; + _n_serve = 0; _n_src_obj = 0; _view0.valid = false; _srcv.valid = false; } -// ---------------- serve ---------------- +// ---------------- serve (multi-mota registry) ---------------- +// +// A node offers a SET of mOTAs: its own firmware (view0) plus any external "folder" sources (OtaSource). +// Every fetch message carries the manifest_id, so a request dispatches to the matching ServeView via +// resolve() — view0 is resident; an external mota is (re)loaded on demand into _srcv. The catalog (what +// we advertise / answer OTA_QUERY with) is the lightweight _serve[] registry. bool OtaManager::serve(const uint8_t* mota, uint32_t len) { - if (!mota_parse(mota, len, _sm)) return false; - _serve_buf = mota; _serve_len = len; _has_serve = true; + if (!mota_parse(mota, len, _view0.m)) return false; + _view0.mfl = (uint16_t)(_view0.m.leaves - _view0.m.manifest_start); // contiguous container + _view0.read = nullptr; _view0.read_ctx = nullptr; // payload is contiguous _view0.m.payload + _view0.scratch = _scratch; _view0.scratch_sz = sizeof(_scratch); // <=1024 blocks (RAM .mota is small) + _view0.valid = true; + registerSelfEntry(); return true; } -void OtaManager::announce() { - if (!_has_serve) return; +bool OtaManager::serve_self(const uint8_t* manifest, uint16_t mfl, const uint8_t* leaves, + uint32_t block_count, uint8_t* proof_scratch, uint32_t proof_scratch_sz, + ServeReadFn read, void* ctx) { + if (proof_scratch_sz < (uint64_t)block_count * 4) return false; // proof-gen needs count*4 working bytes + if (!mota_parse_manifest(manifest, mfl, _view0.m)) return false; // fixed fields: root, image_hash, sizes + _view0.m.manifest_start = manifest; + _view0.m.leaves = leaves; // pre-computed, caller-owned (heap) + _view0.m.payload = nullptr; // read on demand via `read` + _view0.m.block_count = block_count; + _view0.mfl = mfl; _view0.read = read; _view0.read_ctx = ctx; + _view0.scratch = proof_scratch; _view0.scratch_sz = proof_scratch_sz; // sized for our (large) image + _view0.valid = true; + registerSelfEntry(); + return true; +} + +// (Re)build registry slot 0 from view0 (our own fw / RAM mota). Keeps any source entries in [1..]. +void OtaManager::registerSelfEntry() { + if (!_view0.valid) return; + ServeEntry& e = _serve[0]; + memcpy(e.mid, _view0.m.merkle_root, 4); + e.target_id = _view0.m.target_id; e.fw_version = _view0.m.fw_version; + e.codec_id = _view0.m.codec_id; e.flags = _view0.m.flags; + e.is_self = true; e.src = nullptr; e.src_idx = 0; + if (_n_serve == 0) _n_serve = 1; +} + +bool OtaManager::add_source(MotaSource* src) { + if (!src || _n_src_obj >= OTA_MAX_SOURCE_OBJ) return false; + _src_list[_n_src_obj++] = src; + refresh_sources(); + return true; +} + +void OtaManager::refresh_sources() { + uint8_t base = _view0.valid ? 1 : 0; // entry 0 stays our own fw + if (_view0.valid) registerSelfEntry(); + _n_serve = base; + for (uint8_t s = 0; s < _n_src_obj; s++) { + MotaSource* src = _src_list[s]; + if (!src) continue; + uint8_t cnt = src->count(); + for (uint8_t i = 0; i < cnt && _n_serve < OTA_MAX_SERVE; i++) { + MotaDesc d; + if (!src->describe(i, d)) continue; + if (serveEntryIndex(d.mid) >= 0) continue; // already offered (e.g. our own fw in the folder) + ServeEntry& e = _serve[_n_serve++]; + memcpy(e.mid, d.mid, 4); + e.target_id = d.target_id; e.fw_version = d.fw_version; + e.codec_id = d.codec_id; e.flags = d.flags; + e.is_self = false; e.src = src; e.src_idx = i; e.desc = d; + } + } + _srcv.valid = false; // a loaded source view may now be stale; reloads on demand +} + +void OtaManager::clear_sources() { + _n_src_obj = 0; _srcv.valid = false; + _n_serve = _view0.valid ? 1 : 0; + if (_view0.valid) registerSelfEntry(); +} + +int OtaManager::serveEntryIndex(const uint8_t* mid) const { + for (uint8_t i = 0; i < _n_serve; i++) + if (memcmp(_serve[i].mid, mid, 4) == 0) return i; + return -1; +} + +OtaManager::ServeView* OtaManager::resolve(const uint8_t* mid) { + if (_view0.valid && memcmp(mid, _view0.m.merkle_root, 4) == 0) return &_view0; + if (_srcv.valid && memcmp(mid, _srcv_mid, 4) == 0) return &_srcv; + int i = serveEntryIndex(mid); + if (i < 0) return nullptr; + if (_serve[i].is_self) return _view0.valid ? &_view0 : nullptr; + return loadSource(_serve[i]) ? &_srcv : nullptr; +} + +// Load an external mota into the on-demand _srcv: read its manifest-minus-leaves + leaves[] from the +// source into RAM, parse, and wire a payload reader that streams blocks from the source on REQ. (The +// payload itself is NOT held in RAM — only the small head + the leaves, <=4 KB for <=1024 blocks.) +bool OtaManager::loadSource(const ServeEntry& e) { + const MotaDesc& d = e.desc; + if (!e.src || d.leaves_off < 8) return false; + uint16_t mfl = (uint16_t)(d.leaves_off - 8); + if (mfl == 0 || mfl > sizeof(_src_manifest)) return false; + if (d.block_count == 0 || (uint64_t)d.block_count * 4 > sizeof(_src_leaves)) return false; + if (!e.src->read(e.src_idx, 8, _src_manifest, mfl)) return false; + if (!mota_parse_manifest(_src_manifest, mfl, _srcv.m)) return false; + if (memcmp(_srcv.m.merkle_root, d.mid, 4) != 0) return false; // descriptor/bytes disagree + if (_srcv.m.block_count != d.block_count) return false; + if (!e.src->read(e.src_idx, d.leaves_off, _src_leaves, d.block_count * 4)) return false; + _srcv.m.manifest_start = _src_manifest; + _srcv.m.leaves = _src_leaves; + _srcv.m.payload = nullptr; + _srcv.mfl = mfl; + _srcv_rdctx.src = e.src; _srcv_rdctx.idx = e.src_idx; _srcv_rdctx.payload_off = d.payload_off; + _srcv.read = srcReadTramp; _srcv.read_ctx = &_srcv_rdctx; + _srcv.scratch = _scratch; _srcv.scratch_sz = sizeof(_scratch); + memcpy(_srcv_mid, d.mid, 4); + _srcv.valid = true; + return true; +} + +// ServeReadFn trampoline: payload-relative offset -> absolute source read. +bool OtaManager::srcReadTramp(void* c, uint32_t off, uint8_t* buf, uint32_t len) { + SrcReadCtx* x = (SrcReadCtx*)c; + return x->src->read(x->idx, x->payload_off + off, buf, len); +} + +// sha2-256:4 over the SORTED set of mids we serve — peers use it to tell if our offering changed. Sorting +// makes it canonical across nodes regardless of insert order; for a single mota it is mh4(mid) (unchanged). +void OtaManager::setDigest(uint8_t out[4]) const { + if (_n_serve == 0) { memset(out, 0, 4); return; } + uint8_t order[OTA_MAX_SERVE]; + for (uint8_t i = 0; i < _n_serve; i++) order[i] = i; + for (uint8_t i = 1; i < _n_serve; i++) { // insertion sort by mid (n <= 12) + uint8_t v = order[i]; int j = (int)i - 1; + while (j >= 0 && memcmp(_serve[order[j]].mid, _serve[v].mid, 4) > 0) { order[j+1] = order[j]; j--; } + order[j+1] = v; + } + uint8_t cat[OTA_MAX_SERVE * 4]; + for (uint8_t i = 0; i < _n_serve; i++) memcpy(cat + (uint32_t)i * 4, _serve[order[i]].mid, 4); + mh4(out, cat, (size_t)_n_serve * 4); +} + +void OtaManager::announce() { // tiny per-node beacon (constant size, independent of how many mOTAs) AdvMsg a; - a.target_id = _sm.target_id; - a.fw_version = _sm.fw_version; - memcpy(a.manifest_id, _sm.merkle_root, 4); - a.flags = _sm.flags; - a.have_all = 1; - a.codec_id = _sm.codec_id; - uint8_t b[32]; + memcpy(a.seeder_id, _seeder_id, 4); + a.n_motas = _n_serve; + setDigest(a.set_digest); + uint8_t b[16]; emit(b, encode_adv(b, sizeof(b), a), true); } +// OTA_QUERY: two roles. (1) OVERHEAR-SUPPRESSION — any node that has a pending query for the same +// {source,digest} cancels it (someone else already asked; the broadcast HAVE is coming). (2) If the query +// is addressed to US, reply with our catalog (broadcast, tagged with our digest so every overhearer caches +// it). All served mOTAs matching filter_target are returned, fragmented if they exceed one packet. +void OtaManager::handleQuery(const uint8_t* m, uint16_t n) { + QueryMsg q; + if (!decode_query(m, n, q)) return; + if (_pq_active && memcmp(_pq_seeder, q.seeder_id, 4) == 0 && memcmp(_pq_digest, q.set_digest, 4) == 0) + _pq_active = false; // (1) suppress our own pending query + if (_n_serve == 0 || memcmp(q.seeder_id, _seeder_id, 4) != 0) return; // (2) only WE answer queries to us + uint8_t dg[4]; setDigest(dg); + uint8_t rowbuf[OTA_MAX_SERVE * OTA_HAVE_ROW_BYTES]; + uint8_t nm = 0; + for (uint8_t i = 0; i < _n_serve; i++) { + const ServeEntry& e = _serve[i]; + if (q.filter_target != 0 && q.filter_target != e.target_id) continue; + uint8_t* row = rowbuf + (uint32_t)nm * OTA_HAVE_ROW_BYTES; + memcpy(row, e.mid, 4); + wr_u32(row + 4, e.target_id); wr_u32(row + 8, e.fw_version); + row[12] = e.codec_id; row[13] = e.flags; + nm++; + } + const uint8_t per = (uint8_t)((MAX_PACKET_PAYLOAD - 12) / OTA_HAVE_ROW_BYTES); // rows per HAVE fragment + uint8_t ftotal = (uint8_t)((nm + per - 1) / per); if (ftotal == 0) ftotal = 1; + for (uint8_t fi = 0; fi < ftotal; fi++) { + uint8_t bse = (uint8_t)(fi * per); + uint8_t cnt = (uint8_t)((nm - bse > per) ? per : (nm - bse)); + HaveMsg hv; memcpy(hv.seeder_id, _seeder_id, 4); memcpy(hv.set_digest, dg, 4); + hv.frag_idx = fi; hv.frag_total = ftotal; hv.n_rows = cnt; + hv.rows = cnt ? (rowbuf + (uint32_t)bse * OTA_HAVE_ROW_BYTES) : nullptr; + uint8_t b[MAX_PACKET_PAYLOAD]; + emit(b, encode_have(b, sizeof(b), hv), true); // broadcast: all neighbours cache it + } +} + void OtaManager::handleGetManifest(const uint8_t* m, uint16_t n) { GetManifestMsg gm; - if (!decode_get_manifest(m, n, gm) || !_has_serve) return; - if (memcmp(gm.manifest_id, _sm.merkle_root, 4) != 0) return; - // manifest-minus-leaves == bytes [manifest_start, leaves) - uint32_t mfl = (uint32_t)(_sm.leaves - _sm.manifest_start); - uint8_t b[MAX_PACKET_PAYLOAD]; - ManifestMsg mm; - memcpy(mm.manifest_id, _sm.merkle_root, 4); - mm.frag_idx = 0; mm.frag_total = 1; // fits one fragment (signed manifest <= ~165 B) - mm.bytes = _sm.manifest_start; mm.len = (uint16_t)mfl; - emit(b, encode_manifest(b, sizeof(b), mm), false); + if (!decode_get_manifest(m, n, gm)) return; + ServeView* v = resolve(gm.manifest_id); + if (!v) return; + // A signed v2 manifest (with hw_id[32]) exceeds one LoRa packet, so send it as fragments. Each carries + // up to OTA_MF_FRAG manifest bytes; the client reassembles by frag_idx. (Re-sent in full on a retry.) + uint32_t mfl = v->mfl; + const uint8_t* src = v->m.manifest_start; + uint8_t ftotal = (uint8_t)((mfl + OTA_MF_FRAG - 1) / OTA_MF_FRAG); if (ftotal == 0) ftotal = 1; + for (uint8_t fi = 0; fi < ftotal; fi++) { + uint32_t off = (uint32_t)fi * OTA_MF_FRAG; + uint32_t fl = mfl - off; if (fl > OTA_MF_FRAG) fl = OTA_MF_FRAG; + ManifestMsg mm; + memcpy(mm.manifest_id, v->m.merkle_root, 4); + mm.frag_idx = fi; mm.frag_total = ftotal; + mm.bytes = src + off; mm.len = (uint16_t)fl; + uint8_t b[MAX_PACKET_PAYLOAD]; + emit(b, encode_manifest(b, sizeof(b), mm), false); + } } void OtaManager::handleReq(const uint8_t* m, uint16_t n) { ReqMsg rq; - if (!decode_req(m, n, rq) || !_has_serve) return; - if (memcmp(rq.manifest_id, _sm.merkle_root, 4) != 0) return; - uint32_t bs = _sm.block_size(); + if (!decode_req(m, n, rq)) return; + ServeView* v = resolve(rq.manifest_id); + if (!v) return; + uint32_t bs = v->m.block_size(); for (uint32_t k = 0; k < rq.count; k++) { uint32_t idx = rq.start_block + k; - if (idx >= _sm.block_count) break; + if (idx >= v->m.block_count) break; uint32_t off = idx * bs; - uint32_t blen = (off + bs <= _sm.payload_size) ? bs : (_sm.payload_size - off); - uint8_t proof[32 * 4]; - uint8_t np = merkle_gen_proof(_sm.leaves, _sm.block_count, idx, _scratch, proof); - DataMsg dm; - memcpy(dm.manifest_id, _sm.merkle_root, 4); - dm.block_idx = (uint16_t)idx; dm.frag_idx = 0; dm.frag_total = 1; - dm.n_proof = np; dm.proof = proof; - dm.data = _sm.payload + off; dm.data_len = (uint16_t)blen; - uint8_t b[MAX_PACKET_PAYLOAD]; - emit(b, encode_data(b, sizeof(b), dm), false); + uint32_t blen = (off + bs <= v->m.payload_size) ? bs : (v->m.payload_size - off); + uint8_t blk[OTA_MAX_BLOCK]; + const uint8_t* data; + if (v->read) { if (!v->read(v->read_ctx, off, blk, blen)) break; data = blk; } + else { data = v->m.payload + off; } + // send the block's data as self-describing fragments (frag_off); the proof is fetched separately + for (uint32_t fo = 0; fo < blen; fo += OTA_FRAG_DATA) { + uint32_t fl = (fo + OTA_FRAG_DATA <= blen) ? OTA_FRAG_DATA : (blen - fo); + DataMsg dm; + memcpy(dm.manifest_id, v->m.merkle_root, 4); + dm.block_idx = (uint16_t)idx; dm.frag_off = (uint16_t)fo; + dm.data = data + fo; dm.data_len = (uint16_t)fl; + uint8_t b[MAX_PACKET_PAYLOAD]; + emit(b, encode_data(b, sizeof(b), dm), false); + } } } +void OtaManager::handleReqProof(const uint8_t* m, uint16_t n) { + ReqProofMsg rp; + if (!decode_req_proof(m, n, rp)) return; + ServeView* v = resolve(rp.manifest_id); + if (!v) return; + if (rp.block_idx >= v->m.block_count) return; + if ((uint64_t)v->m.block_count * 4 > v->scratch_sz) return; // proof-gen needs block_count*4 scratch + uint8_t proof[32 * 4]; + uint8_t np = merkle_gen_proof(v->m.leaves, v->m.block_count, rp.block_idx, v->scratch, proof); + ProofMsg pm; + memcpy(pm.manifest_id, v->m.merkle_root, 4); + pm.block_idx = rp.block_idx; pm.n_proof = np; pm.proof = proof; + uint8_t b[MAX_PACKET_PAYLOAD]; + emit(b, encode_proof(b, sizeof(b), pm), false); +} + // ---------------- fetch ---------------- +// A tiny per-node BEACON: record the source; ask it for its catalog (OTA_QUERY) only when we're +// interested AND its set-digest is one we haven't catalogued yet (so a stable mesh is query-free). void OtaManager::handleAdv(const uint8_t* m, uint16_t n) { AdvMsg a; if (!decode_adv(m, n, a)) return; - // auto-fetch matches our own target; a manual `want(T)` override accepts target T instead. - uint32_t accept = _desired_target ? _desired_target : _target; - if (a.target_id != accept) return; // not the firmware we're (auto/manually) after - if (!codecOk(a.codec_id)) return; // fw we can't apply on this platform — don't fetch + bool have_sid = (_seeder_id[0] | _seeder_id[1] | _seeder_id[2] | _seeder_id[3]) != 0; + if (have_sid && memcmp(a.seeder_id, _seeder_id, 4) == 0) return; // our own beacon, re-flooded + if (a.n_motas == 0) return; // source offers nothing + + int slot = -1, lru = 0; // find/insert the source (LRU evict) + for (int i = 0; i < _n_src; i++) { + if (memcmp(_sources[i].seeder, a.seeder_id, 4) == 0) { slot = i; break; } + if (_sources[i].last_ms < _sources[lru].last_ms) lru = i; + } + bool fresh = (slot < 0); + if (fresh) { slot = (_n_src < OTA_MAX_SOURCES) ? _n_src++ : lru; _sources[slot] = Source{}; } + Source& s = _sources[slot]; + bool changed = fresh || memcmp(s.digest, a.set_digest, 4) != 0; + memcpy(s.seeder, a.seeder_id, 4); memcpy(s.digest, a.set_digest, 4); + s.n_motas = a.n_motas; s.last_ms = _now_ms; + if (changed) s.have_catalog = false; + + // interested = auto-fetch enabled, or a manual pull/want is pending. (Browsing queries via queryAll().) + bool interested = (_autofetch != AUTOFETCH_OFF) || _have_desired_mid || _desired_target; + if (interested && !s.have_catalog) scheduleQuery(a.seeder_id, a.set_digest); // jittered + suppressible +} + +// Schedule a catalog query after a random jitter (id ⊕ digest, so neighbours pick different delays). The +// node with the shortest jitter sends; the rest overhear that QUERY (or the broadcast HAVE) and suppress. +void OtaManager::scheduleQuery(const uint8_t* seeder, const uint8_t* digest) { + if (_pq_active && memcmp(_pq_seeder, seeder, 4) == 0 && memcmp(_pq_digest, digest, 4) == 0) return; // already pending + memcpy(_pq_seeder, seeder, 4); memcpy(_pq_digest, digest, 4); + uint32_t j = (rd_u32(seeder) ^ rd_u32(digest) ^ rd_u32(_seeder_id)) % OTA_QUERY_SPREAD_MS; + _pq_at = _now_ms + OTA_QUERY_MIN_MS + j; + _pq_active = true; +} + +void OtaManager::sendQuery(const uint8_t* seeder, const uint8_t* digest, uint32_t filter_target) { + QueryMsg q; memcpy(q.seeder_id, seeder, 4); memcpy(q.set_digest, digest, 4); q.filter_target = filter_target; + uint8_t b[16]; + emit(b, encode_query(b, sizeof(b), q), true); // FLOODED so neighbours overhear it and suppress +} + +// User-initiated browse (`ota neighbors`): ask every known source now (no jitter — infrequent + explicit). +void OtaManager::queryAll() { for (uint8_t i = 0; i < _n_src; i++) sendQuery(_sources[i].seeder, _sources[i].digest, 0); } + +// A catalog reply: record each mOTA (deduped by mid; distinct-source count for the UI), and if a row +// matches our fetch interest (auto-fetch own-target, or a pending pull/want), begin fetching it. +void OtaManager::handleHave(const uint8_t* m, uint16_t n) { + HaveMsg hv; + if (!decode_have(m, n, hv)) return; + bool have_sid = (_seeder_id[0] | _seeder_id[1] | _seeder_id[2] | _seeder_id[3]) != 0; + if (have_sid && memcmp(hv.seeder_id, _seeder_id, 4) == 0) return; // our own catalog + // PASSIVE: any overheard HAVE marks its source catalogued + cancels a pending query for it (storm + // suppression) — every node caches the rows below, even one that never queried. + for (uint8_t i = 0; i < _n_src; i++) + if (memcmp(_sources[i].seeder, hv.seeder_id, 4) == 0 && memcmp(_sources[i].digest, hv.set_digest, 4) == 0) + _sources[i].have_catalog = true; + if (_pq_active && memcmp(_pq_seeder, hv.seeder_id, 4) == 0 && memcmp(_pq_digest, hv.set_digest, 4) == 0) + _pq_active = false; + for (uint8_t r = 0; r < hv.n_rows && hv.rows; r++) { + const uint8_t* row = hv.rows + (uint32_t)r * OTA_HAVE_ROW_BYTES; + const uint8_t* mid = row; + uint32_t target = rd_u32(row + 4), fwver = rd_u32(row + 8); + uint8_t codec = row[12], flags = row[13]; + int slot = -1, lru = 0; // upsert into the catalog (dedup by mid) + for (int i = 0; i < _n_cat; i++) { + if (memcmp(_catalog[i].mid, mid, 4) == 0) { slot = i; break; } + if (_catalog[i].last_ms < _catalog[lru].last_ms) lru = i; + } + if (slot < 0) { + slot = (_n_cat < OTA_MAX_CATALOG) ? _n_cat++ : lru; + _catalog[slot] = CatRow{}; + memcpy(_catalog[slot].mid, mid, 4); memcpy(_catalog[slot].seeder0, hv.seeder_id, 4); + _catalog[slot].n_seeders = 1; + } else if (memcmp(_catalog[slot].seeder0, hv.seeder_id, 4) != 0 && _catalog[slot].n_seeders < 255) { + _catalog[slot].n_seeders++; // another distinct source has it + } + CatRow& c = _catalog[slot]; + c.target_id = target; c.fw_version = fwver; c.codec = codec; c.flags = flags; c.last_ms = _now_ms; + if (wantRow(mid, target, codec, flags)) startFetch(mid, target); + } +} + +bool OtaManager::wantRow(const uint8_t* mid, uint32_t target, uint8_t codec, uint8_t flags) const { + if (!_fetch || _fstate == FETCHING || _fstate == WANT_MANIFEST) return false; // busy with a session + if (_fstate == COMPLETE && memcmp(mid, _fid, 4) == 0) return false; // already have it + if (!codecOk(codec)) return false; // can't apply this codec + if (_have_desired_mid) // manual pull of a specific mid + return memcmp(mid, _desired_mid, 4) == 0 && (_desired_target == 0 || target == _desired_target); + if (_desired_target) return target == _desired_target; // cross-target want (role switch) + if (_autofetch == AUTOFETCH_OFF) return false; // discover only + if (target != _target) return false; // auto-fetch = our own target + if (_autofetch == AUTOFETCH_SIGNED && !(flags & MFLAG_SIGNED)) return false; // signed-only policy + return true; +} + +// Begin (or resume) fetching a chosen mid: try a staged-partial resume first, else request the manifest. +void OtaManager::startFetch(const uint8_t* mid, uint32_t target) { + (void)target; if (!_fetch || _fstate == FETCHING || _fstate == WANT_MANIFEST) return; - if (_fstate == COMPLETE && memcmp(a.manifest_id, _fid, 4) == 0) return; // already have it - // interested: ask for the manifest - memcpy(_fid, a.manifest_id, 4); + if (resumeStaged(mid)) return; // resume a partial container left in flash + memcpy(_fid, mid, 4); _fstate = WANT_MANIFEST; + _mf_total = 0; _mf_mask = 0; _mf_len = 0; // fresh manifest reassembly GetManifestMsg gm; memcpy(gm.manifest_id, _fid, 4); uint8_t b[16]; emit(b, encode_get_manifest(b, sizeof(b), gm), false); @@ -98,16 +387,27 @@ void OtaManager::handleManifest(const uint8_t* m, uint16_t n) { ManifestMsg mm; if (!decode_manifest(m, n, mm) || !_fetch) return; if (_fstate != WANT_MANIFEST || memcmp(mm.manifest_id, _fid, 4) != 0) return; - if (mm.frag_total != 1) return; // multi-fragment manifest not supported yet + if (mm.frag_total == 0 || mm.frag_total > OTA_MF_MAXFRAG || mm.frag_idx >= mm.frag_total) return; - const uint8_t* mf = mm.bytes; // manifest-minus-leaves - uint32_t mfl = mm.len; - if (mfl < 57) { _fstate = FAILED; return; } + // reassemble the (possibly multi-fragment) manifest into _mf_buf; place fragment frag_idx at its offset + uint32_t foff = (uint32_t)mm.frag_idx * OTA_MF_FRAG; + if (foff + mm.len > sizeof(_mf_buf)) return; + if (mm.frag_total != _mf_total) { _mf_total = mm.frag_total; _mf_mask = 0; _mf_len = 0; } // (re)start + memcpy(_mf_buf + foff, mm.bytes, mm.len); + _mf_mask |= (uint16_t)(1u << mm.frag_idx); + if (mm.frag_idx == mm.frag_total - 1) _mf_len = foff + mm.len; // last fragment fixes the length + uint16_t full = (mm.frag_total >= 16) ? 0xFFFF : (uint16_t)((1u << mm.frag_total) - 1); + if (_mf_mask != full || _mf_len == 0) return; // wait until every fragment is in + + const uint8_t* mf = _mf_buf; // fully reassembled manifest-minus-leaves + uint32_t mfl = _mf_len; + if (mfl < 89) { _fstate = FAILED; return; } // fixed head incl. hw_id[32] if (!codecOk(mf[56])) { _fstate = IDLE; return; } // codec we can't apply (lying/stale ADV) — abort uint32_t payload_size = rd_u32(mf + 15); uint8_t bsl = mf[19]; uint32_t bs = 1u << bsl; - if (bs == 0 || payload_size == 0) { _fstate = FAILED; return; } + // a block must fit our reassembly buffer (and be non-empty) — reject an oversized block_size up front + if (bs == 0 || bs > OTA_MAX_BLOCK || payload_size == 0) { _fstate = FAILED; return; } uint32_t bc = (payload_size + bs - 1) / bs; memcpy(_froot, mf + 20, 4); @@ -115,6 +415,11 @@ void OtaManager::handleManifest(const uint8_t* m, uint16_t n) { uint32_t payload_off = leaves_off + bc * 4; uint32_t total = payload_off + payload_size + 5; + // Hand the store the parsed layout BEFORE begin(), so a partition-backed store (ESP32) can choose + // placement and refuse an unfittable fetch up front: a FULL payload streams to the inactive slot, + // a delta's whole container is staged together. (image_size at mf+11, is_full from flags at mf+1.) + bool is_full = (mf[1] & MFLAG_FULL) != 0; + if (!_fetch->plan_layout(is_full, rd_u32(mf + 11), payload_off, payload_size)) { _fstate = FAILED; return; } if (!_fetch->begin(total)) { _fstate = FAILED; return; } // declare the metadata extent so a flash store can pin it (leaves are written all transfer long) if (!_fetch->set_meta_size(payload_off)) { _fstate = FAILED; return; } @@ -125,12 +430,63 @@ void OtaManager::handleManifest(const uint8_t* m, uint16_t n) { !_fetch->write(8, mf, mfl) || !_fetch->write(total - 5, MOTA_TRAILER, 5)) { _fstate = FAILED; return; } + _fflags = mf[1]; // manifest flags (FULL/SIGNED) of the fetch in progress (auto-install gate) _fpoff = payload_off; _floff = leaves_off; _fpsize = payload_size; _fbc = bc; _fbs = bs; _ftotal = total; _have = 0; _fstate = FETCHING; + // fresh transfer: clear any per-block reassembly state from a prior session + _reasm_block = 0xFFFFFFFFu; _reasm_mask = 0; _reasm_need = 0; _awaiting_proof = false; + _loop_last_have = 0; _loop_last_mask = 0; OTA_DBG("OTA: FETCHING bc=%u bs=%u total=%u\n", (unsigned)bc, (unsigned)bs, (unsigned)total); requestMissing(); } +bool OtaManager::resumeStaged(const uint8_t* want_mid) { + if (!_fetch || _fstate == FETCHING || _fstate == WANT_MANIFEST) return false; + if (!_fetch->reopen()) return false; // nothing persisted in the store + uint32_t total = _fetch->staged_size(); + uint8_t hdr[8]; + if (total < 13 || !_fetch->read(0, hdr, 8) || memcmp(hdr, MOTA_MAGIC, 4) != 0) return false; + // read + parse the stored manifest (everything before leaves[]) to recompute the geometry + uint8_t mbuf[256]; + uint32_t mread = total - 8; if (mread > sizeof(mbuf)) mread = sizeof(mbuf); + MotaManifest m; + if (!_fetch->read(8, mbuf, mread) || !mota_parse_manifest(mbuf, mread, m)) return false; + if (want_mid && memcmp(m.merkle_root, want_mid, 4) != 0) return false; // a different fw is staged + if (!codecOk(m.codec_id)) return false; + uint32_t mfl = (uint32_t)(m.approval - m.manifest_start) + 4; // manifest-minus-leaves length + uint32_t bs = m.block_size(); + if (bs == 0 || bs > OTA_MAX_BLOCK) return false; + uint32_t bc = m.block_count; + uint32_t leaves_off = 8 + mfl; + uint32_t payload_off = leaves_off + bc * 4; + if ((uint64_t)payload_off + m.payload_size + 5 != total) return false; // geometry must match the header + + memcpy(_fid, m.merkle_root, 4); + memcpy(_froot, m.merkle_root, 4); + _fflags = m.flags; + _fpoff = payload_off; _floff = leaves_off; _fpsize = m.payload_size; _fbc = bc; _fbs = bs; + _ftotal = total; + _have = 0; + for (uint32_t i = 0; i < bc; i++) if (blockPresent(i)) _have++; // count blocks whose leaf survived + _reasm_block = 0xFFFFFFFFu; _reasm_mask = 0; _reasm_need = 0; _awaiting_proof = false; + _loop_last_have = 0; _loop_last_mask = 0; + OTA_DBG("OTA: RESUME have=%u/%u total=%u\n", (unsigned)_have, (unsigned)bc, (unsigned)total); + + if (_have >= bc) { // already complete -> verify root + finalize + if (bc * 4 <= sizeof(_scratch) && _fetch->read(_floff, _scratch, bc * 4)) { + uint8_t root[4]; merkle_root(root, _scratch, bc); + _fstate = (memcmp(root, _froot, 4) == 0) ? COMPLETE : FAILED; + } else { + _fstate = COMPLETE; + } + if (_fstate == COMPLETE) _fetch->finalize(); + return true; + } + _fstate = FETCHING; // resume fetching the holes + requestMissing(); + return true; +} + uint32_t OtaManager::blockLen(uint32_t i) const { uint32_t off = i * _fbs; return (off + _fbs <= _fpsize) ? _fbs : (_fpsize - off); @@ -146,69 +502,92 @@ void OtaManager::handleData(const uint8_t* m, uint16_t n) { DataMsg dm; if (!decode_data(m, n, dm) || !_fetch) return; if (_fstate != FETCHING || memcmp(dm.manifest_id, _fid, 4) != 0) return; - if (dm.frag_total != 1) return; // single-fragment blocks only (v1) if (dm.block_idx >= _fbc) return; - if (blockPresent(dm.block_idx)) return; // already have it - - uint32_t want = blockLen(dm.block_idx); - if (dm.data_len != want) return; - - // verify the block against the (signed) root via its proof — reject forged/corrupt data - if (!merkle_verify(dm.data, dm.data_len, dm.block_idx, dm.proof, dm.n_proof, _froot, _fbc)) return; - - // commit: payload block first, then the leaf (the commit marker) - if (!_fetch->write(_fpoff + dm.block_idx * _fbs, dm.data, dm.data_len)) return; - uint8_t leaf[4]; - merkle_leaf(leaf, dm.data, dm.data_len); - if (!_fetch->write(_floff + dm.block_idx * 4, leaf, 4)) return; + if (blockPresent(dm.block_idx)) return; // already stored + verified + uint32_t blen = blockLen(dm.block_idx); + if (dm.frag_off % OTA_FRAG_DATA != 0) return; // canonical FRAG_DATA-aligned slices only + if ((uint32_t)dm.frag_off + dm.data_len > blen) return; // slice out of the block + if (dm.block_idx != _reasm_block) { // (re)start reassembly for this block + _reasm_block = dm.block_idx; _reasm_mask = 0; _awaiting_proof = false; + uint32_t nf = (blen + OTA_FRAG_DATA - 1) / OTA_FRAG_DATA; + _reasm_need = (nf >= 16) ? 0xFFFF : (uint16_t)((1u << nf) - 1); + } + uint32_t kf = dm.frag_off / OTA_FRAG_DATA; + if (kf >= 16) return; + memcpy(_reasm_buf + dm.frag_off, dm.data, dm.data_len); + _reasm_mask |= (uint16_t)(1u << kf); + if (_reasm_mask != _reasm_need || _awaiting_proof) return; // wait for all slices (or proof already asked) + // block fully reassembled -> request its proof (data + proof are fetched separately) + _awaiting_proof = true; + ReqProofMsg rp; memcpy(rp.manifest_id, _fid, 4); rp.block_idx = (uint16_t)_reasm_block; + uint8_t b[16]; emit(b, encode_req_proof(b, sizeof(b), rp), false); +} +void OtaManager::handleProof(const uint8_t* m, uint16_t n) { + ProofMsg pm; + if (!decode_proof(m, n, pm) || !_fetch) return; + if (_fstate != FETCHING || memcmp(pm.manifest_id, _fid, 4) != 0) return; + if (!_awaiting_proof || pm.block_idx != _reasm_block) return; // not the block we're verifying + uint32_t blen = blockLen(_reasm_block); + if (!merkle_verify(_reasm_buf, blen, _reasm_block, pm.proof, pm.n_proof, _froot, _fbc)) { + _reasm_block = 0xFFFFFFFFu; _reasm_mask = 0; _awaiting_proof = false; // bad -> drop, re-fetch the block + return; + } + // verified -> commit the payload block, then its leaf (the present marker) + if (!_fetch->write(_fpoff + (uint32_t)_reasm_block * _fbs, _reasm_buf, blen)) return; + uint8_t leaf[4]; merkle_leaf(leaf, _reasm_buf, blen); + if (!_fetch->write(_floff + (uint32_t)_reasm_block * 4, leaf, 4)) return; _have++; - OTA_DBG("OTA: block %u OK have=%u/%u\n", (unsigned)dm.block_idx, (unsigned)_have, (unsigned)_fbc); - - // if the current request window is fully received, immediately ask for the next one - // (paces the transfer to the link rate instead of flooding the whole image at once) - if (_have < _fbc) { - bool window_done = true; - for (uint32_t i = _req_start; i < _req_start + _req_count && i < _fbc; i++) { - if (!blockPresent(i)) { window_done = false; break; } - } - if (window_done) requestMissing(); - } - - if (_have >= _fbc) { - // recompute the root over all stored leaves as a final cross-check - // (read leaves into the scratch buffer; bounded by OTA_PROOFGEN_SCRATCH) - if (_fbc * 4 <= sizeof(_scratch) && _fetch->read(_floff, _scratch, _fbc * 4)) { - uint8_t root[4]; - merkle_root(root, _scratch, _fbc); - _fstate = (memcmp(root, _froot, 4) == 0) ? COMPLETE : FAILED; - } else { - _fstate = COMPLETE; // per-block proofs already guaranteed integrity vs the root - } - if (_fstate == COMPLETE) _fetch->finalize(); // commit the staged container to persistent storage - OTA_DBG("OTA: transfer %s\n", _fstate == COMPLETE ? "COMPLETE" : "FAILED(root)"); + OTA_DBG("OTA: block %u OK have=%u/%u\n", (unsigned)_reasm_block, (unsigned)_have, (unsigned)_fbc); + _reasm_block = 0xFFFFFFFFu; _reasm_mask = 0; _awaiting_proof = false; + // periodically persist progress (meta/leaf page + open payload) so a reboot can resume (no-op for RAM); + // cadence is runtime-tunable via `ota config checkpoint ` (0 = never) + if (_checkpoint_blocks && _have % _checkpoint_blocks == 0) _fetch->checkpoint(); + if (_have < _fbc) { requestMissing(); return; } // next block + // all blocks present -> final root cross-check + finalize + if (_fbc * 4 <= sizeof(_scratch) && _fetch->read(_floff, _scratch, _fbc * 4)) { + uint8_t root[4]; merkle_root(root, _scratch, _fbc); + _fstate = (memcmp(root, _froot, 4) == 0) ? COMPLETE : FAILED; + } else { + _fstate = COMPLETE; // per-block proofs already guaranteed integrity vs the root } + if (_fstate == COMPLETE) _fetch->finalize(); // commit the staged container to persistent storage + OTA_DBG("OTA: transfer %s\n", _fstate == COMPLETE ? "COMPLETE" : "FAILED(root)"); } void OtaManager::requestMissing() { if (_fstate != FETCHING) return; - // request a small WINDOW of the next missing blocks (keeps the server's TX queue small, - // so OTA never floods/saturates the mesh — docs/ota_protocol.md §8) + // Per-block serial flow (split data/proof). If the current block's data is fully reassembled and we + // are waiting on its proof, (re)send the proof request rather than re-fetching the data — this also + // recovers from a lost PROOF reply. + if (_awaiting_proof && _reasm_block != 0xFFFFFFFFu) { + ReqProofMsg rp; memcpy(rp.manifest_id, _fid, 4); rp.block_idx = (uint16_t)_reasm_block; + uint8_t b[16]; emit(b, encode_req_proof(b, sizeof(b), rp), false); + OTA_DBG("OTA: REQ_PROOF block=%u (have=%u/%u)\n", + (unsigned)_reasm_block, (unsigned)_have, (unsigned)_fbc); + return; + } + // Otherwise request the DATA fragments of the next missing block. One block at a time keeps the + // server's TX queue tiny so OTA never floods the mesh (docs/ota_protocol.md §8); a block's fragments + // are self-describing (frag_off) so they may be served by ANY peer, BitTorrent-style. uint32_t start = 0; while (start < _fbc && blockPresent(start)) start++; if (start >= _fbc) return; - uint32_t count = _fbc - start; - if (count > OTA_REQ_WINDOW) count = OTA_REQ_WINDOW; - _req_start = start; _req_count = count; + _req_start = start; _req_count = 1; ReqMsg rq; memcpy(rq.manifest_id, _fid, 4); - rq.start_block = (uint16_t)start; rq.count = (uint8_t)count; + rq.start_block = (uint16_t)start; rq.count = 1; uint8_t b[16]; - OTA_DBG("OTA: REQ start=%u count=%u (have=%u/%u)\n", - (unsigned)start, (unsigned)count, (unsigned)_have, (unsigned)_fbc); + OTA_DBG("OTA: REQ block=%u (have=%u/%u mask=%04x)\n", + (unsigned)start, (unsigned)_have, (unsigned)_fbc, (unsigned)_reasm_mask); emit(b, encode_req(b, sizeof(b), rq), false); } void OtaManager::loop() { + // fire a scheduled catalog query once its jitter has elapsed (unless overhearing already suppressed it) + if (_pq_active && (int32_t)(_now_ms - _pq_at) >= 0) { + _pq_active = false; + sendQuery(_pq_seeder, _pq_digest, 0); // unfiltered: one broadcast HAVE serves everyone + } if (_fstate == WANT_MANIFEST) { // the MANIFEST reply may have been lost on a marginal link — retry GET_MANIFEST GetManifestMsg gm; memcpy(gm.manifest_id, _fid, 4); @@ -217,9 +596,11 @@ void OtaManager::loop() { return; } if (_fstate != FETCHING) return; - // retry only when a tick passed with no progress (avoids re-request spam during active flow) - if (_have == _loop_last_have) requestMissing(); + // retry only when a whole tick passed with NO progress — neither a committed block nor a new fragment + // of the in-flight block. This avoids re-request spam while a block's fragments are still streaming in. + if (_have == _loop_last_have && _reasm_mask == _loop_last_mask) requestMissing(); _loop_last_have = _have; + _loop_last_mask = _reasm_mask; } // ---------------- dispatch ---------------- @@ -227,10 +608,14 @@ void OtaManager::loop() { void OtaManager::on_message(const uint8_t* msg, uint16_t len) { switch (ota_msg_type(msg, len)) { case OTA_ADV: handleAdv(msg, len); break; + case OTA_QUERY: handleQuery(msg, len); break; + case OTA_HAVE: handleHave(msg, len); break; case OTA_GET_MANIFEST: handleGetManifest(msg, len); break; case OTA_MANIFEST: handleManifest(msg, len); break; case OTA_REQ: handleReq(msg, len); break; case OTA_DATA: handleData(msg, len); break; + case OTA_REQ_PROOF: handleReqProof(msg, len); break; + case OTA_PROOF: handleProof(msg, len); break; default: break; } } diff --git a/src/helpers/ota/OtaManager.h b/src/helpers/ota/OtaManager.h index 4121ba18..7a77e2cc 100644 --- a/src/helpers/ota/OtaManager.h +++ b/src/helpers/ota/OtaManager.h @@ -5,14 +5,16 @@ #include "OtaFormat.h" #include "OtaStore.h" #include "MotaContainer.h" +#include "OtaSource.h" // Transport-agnostic OTA session engine (docs/ota_protocol.md §5/§8). It SERVES a complete `.mota` // (answering GET_MANIFEST / REQ) and/or FETCHES one into an OtaStore (verifying every block against // the signed merkle root via proofs). It is portable (no Arduino / radio / Ed25519) so it can be // driven by a host simulation; a thin Mesh adapter wires it to PAYLOAD_TYPE_OTA on device. // -// v1 assumes single-fragment blocks (block_size small enough to fit one packet). Multi-fragment -// reassembly (for 1 KB blocks) is a later optimization; the wire format already carries frag fields. +// Transfer is per-block and 2-phase: a 1 KB logical block is fetched as self-describing DATA fragments +// (frag_off, so any peer can serve any fragment — BitTorrent-style), reassembled, then its merkle PROOF +// is requested separately and verified against the signed root before the block is committed. namespace mesh { namespace ota { @@ -20,12 +22,49 @@ namespace ota { // Emit an OTA message (one packet payload). `flood`=true for announce/query, false for direct replies. typedef void (*OtaSend)(void* ctx, const uint8_t* msg, uint16_t len, bool flood); +// Read `len` payload bytes at offset `off` from the serve source (flash-backed self-serve); false on +// error. nullptr means the payload is a contiguous RAM buffer (the staged `.mota`). +typedef bool (*ServeReadFn)(void* ctx, uint32_t off, uint8_t* buf, uint32_t len); + #ifndef OTA_PROOFGEN_SCRATCH #define OTA_PROOFGEN_SCRATCH 4096 // server proof-gen working buffer (supports up to 1024 blocks) #endif -#ifndef OTA_REQ_WINDOW -#define OTA_REQ_WINDOW 6 // blocks requested per REQ (keeps the server's TX queue small) +#ifndef OTA_MAX_BLOCK +#define OTA_MAX_BLOCK 1024 // largest logical block (merkle leaf unit) = reassembly buffer size +#endif +#ifndef OTA_CHECKPOINT_BLOCKS +#define OTA_CHECKPOINT_BLOCKS 32 // persist progress (store.checkpoint) every N committed blocks (resume) +#endif +#ifndef OTA_MF_FRAG +#define OTA_MF_FRAG 176 // manifest bytes per OTA_MANIFEST fragment (<= MAX_PACKET_PAYLOAD - header) +#endif +#ifndef OTA_MF_MAXFRAG +#define OTA_MF_MAXFRAG 4 // max manifest fragments (a signed v2 manifest is ~2) +#endif +#ifndef OTA_MAX_SOURCES +#define OTA_MAX_SOURCES 12 // heard OTA sources (beacon senders) tracked (LRU); ~12 B each +#endif +#ifndef OTA_MAX_SERVE +#define OTA_MAX_SERVE 12 // mOTAs THIS node offers (own fw + external folder); == one HAVE fragment +#endif +#ifndef OTA_MAX_SOURCE_OBJ +#define OTA_MAX_SOURCE_OBJ 4 // external MotaSource objects (folders/transports) attached at once +#endif +#ifndef OTA_SRC_MANIFEST_MAX +#define OTA_SRC_MANIFEST_MAX 256 // manifest-minus-leaves buffer for the loaded source mota (head+sig+approval) +#endif +#ifndef OTA_MAX_CATALOG +#define OTA_MAX_CATALOG 12 // distinct mOTAs catalogued from OTA_HAVE replies (LRU) +#endif +#ifndef OTA_QUERY_MIN_MS +#define OTA_QUERY_MIN_MS 300 // min delay before sending a catalog query (overhear-suppression window) +#endif +#ifndef OTA_QUERY_SPREAD_MS +#define OTA_QUERY_SPREAD_MS 4000 // random jitter span so 50 neighbours don't all query at once (storm) +#endif +#ifndef OTA_FRAG_DATA +#define OTA_FRAG_DATA 160 // data bytes per DATA fragment (<= MAX_PACKET_PAYLOAD - 9-byte header) #endif // nRF52 note: a flash page-erase halts the CPU (~85 ms, code runs from flash) and starves the LoRa RX, // so writing to flash on every received packet drops in-flight DATA and the transfer stalls. The SD-safe @@ -40,43 +79,167 @@ class OtaManager { public: enum FetchState : uint8_t { IDLE, WANT_MANIFEST, FETCHING, COMPLETE, FAILED }; + // --- multi-mota serve --- A ServeView is everything a serve handler needs for ONE mota. Two can be + // resident: view0 = our own fw / a RAM `.mota` (always loaded), plus one on-demand source view that is + // (re)loaded from a MotaSource when a request targets a different external mota. Requests dispatch by + // manifest_id (carried in every fetch message) -> the matching ServeView via resolve(). + struct ServeView { + bool valid = false; + MotaManifest m; // parsed manifest (fields/pointers into the backing buffers) + uint16_t mfl = 0; // manifest-minus-leaves length (the OTA_MANIFEST payload) + ServeReadFn read = nullptr; // payload reader (nullptr => m.payload is contiguous in RAM) + void* read_ctx = nullptr; + uint8_t* scratch = nullptr; // proof-gen working buffer (>= block_count*4) + uint32_t scratch_sz = 0; + }; + // A lightweight catalog entry: what we advertise per mota + how to load its ServeView on demand. + struct ServeEntry { + uint8_t mid[4]; + uint32_t target_id, fw_version; + uint8_t codec_id, flags; + bool is_self; // true => entry is view0 (our own fw / RAM mota) + MotaSource* src; // else: load from this external source ... + uint8_t src_idx; // ... at this index + MotaDesc desc; // cached region offsets (source entries) + }; + // Context for the source-payload reader trampoline (maps a payload-relative offset to a source read). + struct SrcReadCtx { MotaSource* src; uint8_t idx; uint32_t payload_off; }; + void begin(uint32_t my_target_id, OtaSend send, void* ctx); // --- serve --- Provide a complete, contiguous `.mota` to serve (caller keeps it alive). bool serve(const uint8_t* mota, uint32_t len); - void announce(); // broadcast OTA_ADV for the served .mota + // Serve from a non-contiguous source (e.g. our own firmware in flash): a pre-assembled manifest + // (manifest-minus-leaves, `mfl` bytes), the pre-computed merkle `leaves` (kept alive by caller), and a + // `read` callback for payload blocks. Lets a node host its own image without holding it in RAM. + bool serve_self(const uint8_t* manifest, uint16_t mfl, const uint8_t* leaves, uint32_t block_count, + uint8_t* proof_scratch, uint32_t proof_scratch_sz, ServeReadFn read, void* ctx); + // Attach an external "folder" of `.mota` images (USB-serial daemon, BLE, WiFi URLs, NFS/samba, ...). + // The node then advertises + RELAYS them transparently alongside its own fw — peers just see more mOTAs. + // Re-enumerates the source into the serve registry. Returns false if no slots remain. (Trustless: the + // fetcher verifies merkle+signature, so the source is never trusted — see OtaSource.h.) + bool add_source(MotaSource* src); + // Re-read every attached source's catalog (call when the folder's contents change). Rebuilds entries + // [1..] from the sources; entry 0 (our own fw) is preserved. + void refresh_sources(); + // Drop all external sources (keep serving our own fw). + void clear_sources(); + uint8_t servedCount() const { return _n_serve; } // total mOTAs we offer (own fw + folder) + // Read-only view of one served entry (for `ota serve` listing): mid/target/fwver/codec/flags + is_self. + const ServeEntry* servedEntry(uint8_t i) const { return i < _n_serve ? &_serve[i] : nullptr; } + + // Broadcast the tiny per-node BEACON (OTA_ADV): seeder_id + count + set-digest of everything we serve. + // Constant size regardless of how many mOTAs — peers ask for the catalog via OTA_QUERY only on interest. + void announce(); // --- fetch --- Provide the staging store; fetching starts on a matching OTA_ADV. void set_fetch_store(OtaStore* s) { _fetch = s; } + // Resume a fetch from a container already persisted in the store (after a reboot). want_mid=nullptr + // accepts whatever is staged; otherwise only resumes if the staged manifest_id matches. Re-parses the + // stored manifest, recomputes geometry, counts present blocks, and continues FETCHING the holes (or goes + // straight to COMPLETE if all blocks are present). Returns true if it adopted a staged container. + bool resumeStaged(const uint8_t* want_mid); + // Manual cross-target override (decision: deliberate role switch, e.g. companion -> repeater on the // same hardware). Normally a node only auto-fetches its OWN target_id; `want(T)` makes it accept an // ADV for target T instead (T=0 restores auto). The user takes responsibility for HW compatibility; // a hw_id brick-safety check is the planned safety layer (see docs/ota_protocol.md / plan). - void want(uint32_t target_id) { _desired_target = target_id; } + void want(uint32_t target_id) { _desired_target = target_id; reDiscover(); } uint32_t wanted() const { return _desired_target; } + uint32_t target() const { return _target; } // this node's own OTA target_id (set in begin) + + // Pull a SPECIFIC advertised mOTA by manifest_id (e.g. `ota pull <#>` picks the one more peers have), + // not just any firmware for the target. mid=nullptr clears the filter (accept any mid for the target). + void want_mid(const uint8_t* mid) { + if (mid) { for (int i = 0; i < 4; i++) _desired_mid[i] = mid[i]; _have_desired_mid = true; } + else _have_desired_mid = false; + reDiscover(); + } + + // Begin fetching a chosen mid now (sets want + starts the manifest fetch / resume). Used by `ota pull` + // once the user picks a catalogued mOTA (the source is reached via the flooded GET_MANIFEST). + void pull(const uint8_t* mid, uint32_t target) { want(target); want_mid(mid); startFetch(mid, target); } + // Ask every known source for its catalog (populates `ota neighbors`). Async — rows arrive via OTA_HAVE. + void queryAll(); + // Coarse clock for source/catalog ages + LRU (the Mesh adapter feeds millis; 0 in host tests is fine). + void set_clock(uint32_t ms) { _now_ms = ms; } // Codec compatibility: a node only fetches/accepts fw it can actually apply. CODEC_FULL is always // acceptable; the platform's single delta codec is set here (ESP32 A/B -> sequential, nRF52 single- // slot -> in-place). A mismatching `.mota` is rejected at OTA_ADV time, before fetching anything. void set_apply_codec(uint8_t c) { _apply_codec = c; } - bool codecOk(uint8_t c) const { return c == CODEC_FULL || c == _apply_codec; } + // A platform may apply MORE than one delta codec (ESP32 does both sequential AND in-place, so a single + // in-place `.mota` can target both ESP32 and nRF52). 0xFF = unset. + void set_apply_codec2(uint8_t c) { _apply_codec2 = c; } + bool codecOk(uint8_t c) const { return c == CODEC_FULL || c == _apply_codec || c == _apply_codec2; } + + // Auto-fetch policy (manual `ota pull` always works regardless): 0=off (discover only), 1=any + // compatible own-target advert, 2=only signed adverts. Conservative default = off. + static const uint8_t AUTOFETCH_OFF = 0, AUTOFETCH_ANY = 1, AUTOFETCH_SIGNED = 2; + void set_autofetch(uint8_t p) { _autofetch = p; reDiscover(); } + uint8_t autofetch() const { return _autofetch; } + + // Resume checkpoint cadence (runtime-tunable, persisted in NodePrefs): persist progress every N + // committed blocks. 0 = never (resume only from a finalized container). Default OTA_CHECKPOINT_BLOCKS. + void set_checkpoint_blocks(uint16_t n) { _checkpoint_blocks = n; } + uint16_t checkpoint_blocks() const { return _checkpoint_blocks; } + bool fetched_is_signed() const { return (_fflags & MFLAG_SIGNED) != 0; } // flags of the fetched manifest + + // This node's id (pubkey[0:4]), stamped into adverts we send so receivers can count distinct seeders. + void set_seeder_id(const uint8_t* id4) { if (id4) for (int i = 0; i < 4; i++) _seeder_id[i] = id4[i]; } void on_message(const uint8_t* msg, uint16_t len); // feed one received OTA message void loop(); // drive fetch (re-request missing blocks) + // Drop the current fetch session back to IDLE (so a fresh `ota pull` / advert starts a new one). + void reset_session() { + _fstate = IDLE; _have = 0; _req_count = 0; + _reasm_block = 0xFFFFFFFFu; _reasm_mask = 0; _reasm_need = 0; _awaiting_proof = false; + _loop_last_have = 0; _loop_last_mask = 0; + _mf_total = 0; _mf_mask = 0; _mf_len = 0; + } + FetchState fetchState() const { return _fstate; } uint32_t blocksHave() const { return _have; } uint32_t blocksTotal() const { return _fbc; } const uint8_t* fetchManifestId() const { return _fid; } + // --- discovery catalog (for `ota neighbors`): mOTAs heard around us via OTA_HAVE, deduped by mid --- + struct CatRow { + uint8_t mid[4]; + uint32_t target_id, fw_version; + uint8_t codec, flags; + uint8_t seeder0[4]; // first source that advertised this mid + uint8_t n_seeders; // distinct sources advertising it (saturates) — "N nodes have it" + uint32_t last_ms; + }; + uint8_t catalogCount() const { return _n_cat; } + const CatRow* catalogRow(uint8_t i) const { return i < _n_cat ? &_catalog[i] : nullptr; } + uint8_t sourceCount() const { return _n_src; } // distinct OTA sources (beacon senders) heard + private: void emit(const uint8_t* b, uint16_t n, bool flood) { if (_send && n) _send(_ctx, b, n, flood); } - void handleAdv(const uint8_t* m, uint16_t n); + void handleAdv(const uint8_t* m, uint16_t n); // beacon -> sources table (+ query if interested) + void handleQuery(const uint8_t* m, uint16_t n); // serve: reply OTA_HAVE catalog + void handleHave(const uint8_t* m, uint16_t n); // peer: catalog rows (+ startFetch if a row matches) void handleGetManifest(const uint8_t* m, uint16_t n); void handleManifest(const uint8_t* m, uint16_t n); void handleReq(const uint8_t* m, uint16_t n); void handleData(const uint8_t* m, uint16_t n); + void handleReqProof(const uint8_t* m, uint16_t n); + void handleProof(const uint8_t* m, uint16_t n); + void startFetch(const uint8_t* mid, uint32_t target); // begin/resume a fetch of a chosen mid + bool wantRow(const uint8_t* mid, uint32_t target, uint8_t codec, uint8_t flags) const; // fetch this row? + int serveEntryIndex(const uint8_t* mid) const; // registry slot serving this mid (-1 if none) + ServeView* resolve(const uint8_t* mid); // pick/load the ServeView for this mid (nullptr) + bool loadSource(const ServeEntry& e); // load an external mota into _srcv (head+leaves) + void registerSelfEntry(); // (re)build entry[0] from view0 + static bool srcReadTramp(void* c, uint32_t off, uint8_t* buf, uint32_t len); // source payload reader + void sendQuery(const uint8_t* seeder, const uint8_t* digest, uint32_t filter_target); // ask a source for its catalog + void scheduleQuery(const uint8_t* seeder, const uint8_t* digest); // jittered + suppressible + void reDiscover() { for (uint8_t i = 0; i < _n_src; i++) _sources[i].have_catalog = false; _pq_active = false; } + void setDigest(uint8_t out[4]) const; // sha2-256:4 over our served mids bool blockPresent(uint32_t i) const; void requestMissing(); uint32_t blockLen(uint32_t i) const; @@ -85,12 +248,18 @@ private: OtaSend _send = nullptr; void* _ctx = nullptr; - // serve - bool _has_serve = false; - const uint8_t* _serve_buf = nullptr; - uint32_t _serve_len = 0; - MotaManifest _sm; - uint8_t _scratch[OTA_PROOFGEN_SCRATCH]; + // serve (multi-mota): view0 = our own fw / a RAM `.mota`; _srcv = the currently-loaded external mota. + ServeView _view0; + ServeView _srcv; + uint8_t _srcv_mid[4] = {0}; + SrcReadCtx _srcv_rdctx = {nullptr, 0, 0}; + ServeEntry _serve[OTA_MAX_SERVE]; // catalog (what we advertise) — entry 0 is view0 + uint8_t _n_serve = 0; + MotaSource* _src_list[OTA_MAX_SOURCE_OBJ] = {nullptr}; + uint8_t _n_src_obj = 0; + uint8_t _src_manifest[OTA_SRC_MANIFEST_MAX]; // manifest-minus-leaves of the loaded source mota + uint8_t _src_leaves[OTA_PROOFGEN_SCRATCH]; // leaves[] of the loaded source mota (<=1024 blocks) + uint8_t _scratch[OTA_PROOFGEN_SCRATCH]; // proof-gen / fetch root-check working buffer // fetch OtaStore* _fetch = nullptr; @@ -99,10 +268,42 @@ private: uint8_t _froot[4] = {0}; uint32_t _ftotal = 0, _fpoff = 0, _floff = 0, _fpsize = 0, _fbc = 0, _fbs = 0; uint32_t _have = 0; - uint32_t _req_start = 0, _req_count = 0; // current outstanding request window + uint32_t _req_start = 0, _req_count = 0; // last block requested (per-block serial flow; telemetry) uint32_t _loop_last_have = 0; // for stall detection in loop() uint32_t _desired_target = 0; // manual cross-target override (0 = auto / own target) + uint8_t _desired_mid[4] = {0,0,0,0}; // pull a specific manifest_id (see want_mid) + bool _have_desired_mid = false; uint8_t _apply_codec = CODEC_DETOOLS_SEQUENTIAL; // platform's delta codec (OtaContext sets it) + uint8_t _apply_codec2 = 0xFF; // optional 2nd accepted delta codec (ESP32: in-place) + uint8_t _seeder_id[4] = {0,0,0,0}; // our node id (pubkey[0:4]) for advert seeder counting + uint8_t _autofetch = AUTOFETCH_OFF; // auto-fetch policy (persisted in NodePrefs) + uint16_t _checkpoint_blocks = OTA_CHECKPOINT_BLOCKS; // resume checkpoint cadence (persisted) + uint8_t _fflags = 0; // flags of the manifest currently being fetched + // multi-fragment reassembly of the current block (per-block 2-phase: fetch data, then its proof) + uint32_t _reasm_block = 0xFFFFFFFFu; // block being reassembled / awaiting proof (none) + uint16_t _reasm_mask = 0; // received FRAG_DATA-slice bitmap (bit k = slice @ k*FRAG_DATA) + uint16_t _reasm_need = 0; // full mask once all slices of the current block are in + bool _awaiting_proof = false; // data complete; REQ_PROOF sent, verify on PROOF + uint16_t _loop_last_mask = 0; // fragment-level stall detection in loop() + uint8_t _reasm_buf[OTA_MAX_BLOCK]; + // multi-fragment manifest reassembly (a signed v2 manifest exceeds one packet) + uint8_t _mf_buf[256]; + uint8_t _mf_total = 0; // frag_total of the manifest being reassembled (0 = none) + uint16_t _mf_mask = 0; // received manifest-fragment bitmap + uint32_t _mf_len = 0; // assembled manifest length (set by the last fragment) + + // discovery: heard sources (beacon senders) + the catalog assembled from their OTA_HAVE replies + struct Source { uint8_t seeder[4]; uint8_t digest[4]; uint8_t n_motas; uint32_t last_ms; bool have_catalog; }; + Source _sources[OTA_MAX_SOURCES]; + uint8_t _n_src = 0; + CatRow _catalog[OTA_MAX_CATALOG]; + uint8_t _n_cat = 0; + uint32_t _now_ms = 0; // coarse clock (fed by set_clock; for ages/LRU/jitter) + // pending catalog query (jittered + suppressed on overhearing a matching QUERY/HAVE — anti-storm) + bool _pq_active = false; + uint8_t _pq_seeder[4] = {0}; + uint8_t _pq_digest[4] = {0}; + uint32_t _pq_at = 0; // _now_ms deadline to actually send the query }; } // namespace ota diff --git a/src/helpers/ota/OtaProtocol.cpp b/src/helpers/ota/OtaProtocol.cpp index a4929d30..50e61476 100644 --- a/src/helpers/ota/OtaProtocol.cpp +++ b/src/helpers/ota/OtaProtocol.cpp @@ -25,16 +25,41 @@ struct R { }; } // namespace -uint16_t encode_adv(uint8_t* buf, uint16_t cap, const AdvMsg& m) { - W w(buf, cap); w.u8(OTA_ADV); w.u32(m.target_id); w.u32(m.fw_version); - w.raw(m.manifest_id, 4); w.u8(m.flags); w.u8(m.have_all); w.u8(m.codec_id); +uint16_t encode_adv(uint8_t* buf, uint16_t cap, const AdvMsg& m) { // tiny per-node beacon + W w(buf, cap); w.u8(OTA_ADV); w.raw(m.seeder_id, 4); w.u8(m.n_motas); w.raw(m.set_digest, 4); return w.ok ? w.n : 0; } bool decode_adv(const uint8_t* buf, uint16_t len, AdvMsg& m) { R r(buf, len); if (r.u8() != OTA_ADV) return false; - m.target_id = r.u32(); m.fw_version = r.u32(); - const uint8_t* id = r.raw(4); if (id) memcpy(m.manifest_id, id, 4); - m.flags = r.u8(); m.have_all = r.u8(); m.codec_id = r.u8(); + const uint8_t* sid = r.raw(4); if (sid) memcpy(m.seeder_id, sid, 4); + m.n_motas = r.u8(); + const uint8_t* d = r.raw(4); if (d) memcpy(m.set_digest, d, 4); + return r.ok; +} + +uint16_t encode_query(uint8_t* buf, uint16_t cap, const QueryMsg& m) { + W w(buf, cap); w.u8(OTA_QUERY); w.raw(m.seeder_id, 4); w.raw(m.set_digest, 4); w.u32(m.filter_target); + return w.ok ? w.n : 0; +} +bool decode_query(const uint8_t* buf, uint16_t len, QueryMsg& m) { + R r(buf, len); if (r.u8() != OTA_QUERY) return false; + const uint8_t* sid = r.raw(4); if (sid) memcpy(m.seeder_id, sid, 4); + const uint8_t* dg = r.raw(4); if (dg) memcpy(m.set_digest, dg, 4); + m.filter_target = r.u32(); + return r.ok; +} + +uint16_t encode_have(uint8_t* buf, uint16_t cap, const HaveMsg& m) { + W w(buf, cap); w.u8(OTA_HAVE); w.raw(m.seeder_id, 4); w.raw(m.set_digest, 4); + w.u8(m.frag_idx); w.u8(m.frag_total); w.u8(m.n_rows); w.raw(m.rows, (uint16_t)m.n_rows * OTA_HAVE_ROW_BYTES); + return w.ok ? w.n : 0; +} +bool decode_have(const uint8_t* buf, uint16_t len, HaveMsg& m) { + R r(buf, len); if (r.u8() != OTA_HAVE) return false; + const uint8_t* sid = r.raw(4); if (sid) memcpy(m.seeder_id, sid, 4); + const uint8_t* dg = r.raw(4); if (dg) memcpy(m.set_digest, dg, 4); + m.frag_idx = r.u8(); m.frag_total = r.u8(); m.n_rows = r.u8(); + m.rows = r.raw((uint16_t)m.n_rows * OTA_HAVE_ROW_BYTES); return r.ok; } @@ -73,21 +98,40 @@ bool decode_req(const uint8_t* buf, uint16_t len, ReqMsg& m) { } uint16_t encode_data(uint8_t* buf, uint16_t cap, const DataMsg& m) { - W w(buf, cap); w.u8(OTA_DATA); w.raw(m.manifest_id, 4); w.u16(m.block_idx); - w.u8(m.frag_idx); w.u8(m.frag_total); - if (m.frag_idx == 0) { w.u8(m.n_proof); w.raw(m.proof, (uint16_t)m.n_proof * 4); } + W w(buf, cap); w.u8(OTA_DATA); w.raw(m.manifest_id, 4); w.u16(m.block_idx); w.u16(m.frag_off); w.raw(m.data, m.data_len); return w.ok ? w.n : 0; } bool decode_data(const uint8_t* buf, uint16_t len, DataMsg& m) { R r(buf, len); if (r.u8() != OTA_DATA) return false; const uint8_t* id = r.raw(4); if (id) memcpy(m.manifest_id, id, 4); - m.block_idx = r.u16(); m.frag_idx = r.u8(); m.frag_total = r.u8(); - m.n_proof = 0; m.proof = nullptr; - if (m.frag_idx == 0) { m.n_proof = r.u8(); m.proof = r.raw((uint16_t)m.n_proof * 4); } + m.block_idx = r.u16(); m.frag_off = r.u16(); m.data_len = r.remaining(); m.data = r.raw(m.data_len); return r.ok; } +uint16_t encode_req_proof(uint8_t* buf, uint16_t cap, const ReqProofMsg& m) { + W w(buf, cap); w.u8(OTA_REQ_PROOF); w.raw(m.manifest_id, 4); w.u16(m.block_idx); + return w.ok ? w.n : 0; +} +bool decode_req_proof(const uint8_t* buf, uint16_t len, ReqProofMsg& m) { + R r(buf, len); if (r.u8() != OTA_REQ_PROOF) return false; + const uint8_t* id = r.raw(4); if (id) memcpy(m.manifest_id, id, 4); + m.block_idx = r.u16(); + return r.ok; +} + +uint16_t encode_proof(uint8_t* buf, uint16_t cap, const ProofMsg& m) { + W w(buf, cap); w.u8(OTA_PROOF); w.raw(m.manifest_id, 4); w.u16(m.block_idx); + w.u8(m.n_proof); w.raw(m.proof, (uint16_t)m.n_proof * 4); + return w.ok ? w.n : 0; +} +bool decode_proof(const uint8_t* buf, uint16_t len, ProofMsg& m) { + R r(buf, len); if (r.u8() != OTA_PROOF) return false; + const uint8_t* id = r.raw(4); if (id) memcpy(m.manifest_id, id, 4); + m.block_idx = r.u16(); m.n_proof = r.u8(); m.proof = r.raw((uint16_t)m.n_proof * 4); + return r.ok; +} + } // namespace ota } // namespace mesh diff --git a/src/helpers/ota/OtaProtocol.h b/src/helpers/ota/OtaProtocol.h index e17af2ed..5f2fc96c 100644 --- a/src/helpers/ota/OtaProtocol.h +++ b/src/helpers/ota/OtaProtocol.h @@ -12,17 +12,38 @@ namespace mesh { namespace ota { -// ---- OTA_ADV: "I have (part of) fw X for target T" (flood, periodic + on demand) ---- +// ---- OTA_ADV: tiny per-NODE beacon (flood, periodic). CONSTANT size regardless of how many mOTAs a +// node serves — it just says "I'm a source, here's how many + a digest of my set". A peer that's +// interested asks for the catalog via OTA_QUERY. (Replaces the old per-mOTA advert so a folder node with +// N images costs one 10-byte beacon, not N adverts.) ---- struct AdvMsg { - uint32_t target_id; - uint32_t fw_version; - uint8_t manifest_id[4]; // = merkle_root - uint8_t flags; // manifest flags (FULL/SIGNED) - uint8_t have_all; // 1 = holder has the complete payload - uint8_t codec_id; // manifest codec (0=full,1=detools-seq,2=detools-inplace) — lets a - // receiver reject fw it can't apply before fetching anything + uint8_t seeder_id[4]; // advertiser's node id = pubkey[0:4]; the QUERY address + distinct-source id + uint8_t n_motas; // # of complete, servable mOTAs (saturates at 255) + uint8_t set_digest[4]; // sha2-256:4 over the sorted set of served mids; "did my offering change?" }; +// ---- OTA_QUERY: "list what you serve" — addressed to a source by seeder_id, FLOODED so neighbours +// overhear it (storm suppression). set_digest identifies the offering being asked about (so an overhearer +// can suppress its own pending query for the same {source,digest}). filter_target=0 = everything. ---- +struct QueryMsg { + uint8_t seeder_id[4]; // which source this query is for (the source matches its own id) + uint8_t set_digest[4]; // the offering digest we're asking about (for overhear-suppression) + uint32_t filter_target; // 0 = all (the scalable default); else only mOTAs for this target_id +}; + +// ---- OTA_HAVE: the compact catalog (source -> mesh), FLOODED + tagged with set_digest so EVERY node +// that overhears it caches the rows (passive, no query needed). Fragmented. ---- +// body: seeder_id(4) set_digest(4) frag_idx(1) frag_total(1) n_rows(1) rows[ mid(4) target(4) fwver(4) codec(1) flags(1) ] +struct HaveRow { uint8_t mid[4]; uint32_t target_id; uint32_t fw_version; uint8_t codec_id; uint8_t flags; }; +struct HaveMsg { + uint8_t seeder_id[4]; + uint8_t set_digest[4]; // the offering this catalog describes (overhearers cache by it) + uint8_t frag_idx, frag_total; + uint8_t n_rows; // rows in THIS fragment + const uint8_t* rows; // points into buf: n_rows * OTA_HAVE_ROW_BYTES +}; +static const uint8_t OTA_HAVE_ROW_BYTES = 14; // mid4 + target4 + fwver4 + codec1 + flags1 + // ---- OTA_GET_MANIFEST: request the manifest for a content id (direct) ---- struct GetManifestMsg { uint8_t manifest_id[4]; }; @@ -37,23 +58,35 @@ struct ManifestMsg { // ---- OTA_REQ: request a window of blocks (direct) ---- struct ReqMsg { uint8_t manifest_id[4]; uint16_t start_block; uint8_t count; }; -// ---- OTA_DATA: one (fragment of a) block (direct) ---- -// body: manifest_id(4) block_idx(2) frag_idx(1) frag_total(1) [frag0: n_proof(1) proof(n_proof*4)] data[] +// ---- OTA_DATA: one self-describing fragment of a block's data (proof is fetched separately) ---- +// body: manifest_id(4) block_idx(2) frag_off(2) data[] +// `frag_off` is the byte offset of `data` within block `block_idx` (global position = block_idx*block_size +// + frag_off), so a fragment is self-placing and can be requested from ANY peer (BitTorrent-style). struct DataMsg { uint8_t manifest_id[4]; uint16_t block_idx; - uint8_t frag_idx, frag_total; - uint8_t n_proof; // only meaningful on frag_idx==0 - const uint8_t* proof; // n_proof*4 bytes (frag0 only) + uint16_t frag_off; const uint8_t* data; uint16_t data_len; }; +// ---- OTA_REQ_PROOF: request the merkle proof for one (reassembled) block (direct) ---- +struct ReqProofMsg { uint8_t manifest_id[4]; uint16_t block_idx; }; + +// ---- OTA_PROOF: the merkle proof (ordered sibling digests) for one block (direct) ---- +struct ProofMsg { uint8_t manifest_id[4]; uint16_t block_idx; uint8_t n_proof; const uint8_t* proof; }; + // Each encode_* returns the total payload length (incl. the leading msg-type byte), 0 on overflow. // Each decode_* returns true on success (and points struct fields into `buf`). uint16_t encode_adv(uint8_t* buf, uint16_t cap, const AdvMsg& m); bool decode_adv(const uint8_t* buf, uint16_t len, AdvMsg& m); +uint16_t encode_query(uint8_t* buf, uint16_t cap, const QueryMsg& m); +bool decode_query(const uint8_t* buf, uint16_t len, QueryMsg& m); + +uint16_t encode_have(uint8_t* buf, uint16_t cap, const HaveMsg& m); +bool decode_have(const uint8_t* buf, uint16_t len, HaveMsg& m); + uint16_t encode_get_manifest(uint8_t* buf, uint16_t cap, const GetManifestMsg& m); bool decode_get_manifest(const uint8_t* buf, uint16_t len, GetManifestMsg& m); @@ -66,6 +99,12 @@ bool decode_req(const uint8_t* buf, uint16_t len, ReqMsg& m); uint16_t encode_data(uint8_t* buf, uint16_t cap, const DataMsg& m); bool decode_data(const uint8_t* buf, uint16_t len, DataMsg& m); +uint16_t encode_req_proof(uint8_t* buf, uint16_t cap, const ReqProofMsg& m); +bool decode_req_proof(const uint8_t* buf, uint16_t len, ReqProofMsg& m); + +uint16_t encode_proof(uint8_t* buf, uint16_t cap, const ProofMsg& m); +bool decode_proof(const uint8_t* buf, uint16_t len, ProofMsg& m); + inline uint8_t ota_msg_type(const uint8_t* buf, uint16_t len) { return len ? buf[0] : 0xFF; } } // namespace ota diff --git a/src/helpers/ota/OtaSelf.cpp b/src/helpers/ota/OtaSelf.cpp index dbcb4e91..30b0f732 100644 --- a/src/helpers/ota/OtaSelf.cpp +++ b/src/helpers/ota/OtaSelf.cpp @@ -9,6 +9,16 @@ #include "OtaFlashLayout_nrf52.h" #endif +#if defined(ESP32_PLATFORM) || defined(NRF52_PLATFORM) + #include "OtaContext.h" // serve our own fw from flash (cache leaves, read payload on demand) + #include "MerkleTree.h" + #include + #include + #ifndef OTA_SELF_LEAVES_MAX + #define OTA_SELF_LEAVES_MAX 65536u // cap heap for cached leaves (~16k blocks @1 KB = up to ~16 MB image) + #endif +#endif + namespace mesh { namespace ota { @@ -60,5 +70,79 @@ bool ota_self_firmware(SelfFwInfo& out) { } #endif +#if defined(ESP32_PLATFORM) +bool ota_self_read(uint32_t off, uint8_t* buf, uint32_t len) { + const esp_partition_t* p = esp_ota_get_running_partition(); + return p && esp_partition_read(p, off, buf, len) == ESP_OK; +} +#elif defined(NRF52_PLATFORM) +bool ota_self_read(uint32_t off, uint8_t* buf, uint32_t len) { + if ((uint64_t)MOTA_NRF52_APP_BASE + off + len > MOTA_NRF52_FS_START) return false; + memcpy(buf, (const uint8_t*)(uintptr_t)(MOTA_NRF52_APP_BASE + off), len); + return true; +} +#else +bool ota_self_read(uint32_t, uint8_t*, uint32_t) { return false; } +#endif + +#if defined(ESP32_PLATFORM) || defined(NRF52_PLATFORM) +static bool self_read_cb(void* ctx, uint32_t off, uint8_t* buf, uint32_t len) { + (void)ctx; return ota_self_read(off, buf, len); +} +static void wr_u32le(uint8_t* p, uint32_t v) { + p[0] = (uint8_t)v; p[1] = (uint8_t)(v >> 8); p[2] = (uint8_t)(v >> 16); p[3] = (uint8_t)(v >> 24); +} + +// Build (once) the full-image manifest + merkle leaves for the running firmware, cache them in `c`, and +// hand the manager a flash-read callback for the payload. The image is read ONCE here to compute the +// leaves + image_hash; thereafter a block REQ reads only that block (proof comes from the cached leaves). +bool ota_serve_self(OtaContext& c, uint32_t fw_version) { + SelfFwInfo fi; + if (!ota_self_firmware(fi) || !fi.valid) return false; + // 1 KB logical blocks (delivered as multiple LoRa fragments): 8x fewer merkle leaves than 128 B, so a + // ~530 KB image is ~518 blocks (proof-gen scratch ~2 KB) instead of ~4150 (which overflowed the scratch). + const uint32_t image_size = fi.image_len, BS = OTA_DEFAULT_BLOCK_SIZE; + const uint32_t bc = (image_size + BS - 1) / BS; + if ((uint64_t)bc * 4 > OTA_SELF_LEAVES_MAX) return false; + + free(c.serve_self_leaves); free(c.serve_self_proof); + c.serve_self_leaves = (uint8_t*)malloc((size_t)bc * 4); + c.serve_self_proof = (uint8_t*)malloc((size_t)bc * 4); // proof-gen working buffer (sized to OUR image) + if (!c.serve_self_leaves || !c.serve_self_proof) { + free(c.serve_self_leaves); free(c.serve_self_proof); + c.serve_self_leaves = c.serve_self_proof = nullptr; + return false; + } + + SHA256 sha; uint8_t blk[BS]; + for (uint32_t i = 0, off = 0; i < bc; i++, off += BS) { + uint32_t blen = (off + BS <= image_size) ? BS : (image_size - off); + if (!ota_self_read(off, blk, blen)) { + free(c.serve_self_leaves); free(c.serve_self_proof); + c.serve_self_leaves = c.serve_self_proof = nullptr; + return false; + } + merkle_leaf(c.serve_self_leaves + (size_t)i * 4, blk, blen); + sha.update(blk, blen); + } + uint8_t image_hash[32]; sha.finalize(image_hash, 32); + uint8_t root[4]; merkle_root(root, c.serve_self_leaves, bc); + + uint8_t* m = c.serve_self_manifest; // assemble v2 manifest-minus-leaves (full, unsigned) = 93 bytes + memset(m, 0, 96); + m[0] = MOTA_FORMAT_VER; m[1] = MFLAG_FULL; m[2] = HASH_ALGO_SHA256; + wr_u32le(m + 3, c.manager.target()); wr_u32le(m + 7, fw_version); + wr_u32le(m + 11, image_size); wr_u32le(m + 15, image_size); // full: payload == image + m[19] = 10; // block_size_log2 = 10 (1024 B logical block) + memcpy(m + 20, root, 4); + memcpy(m + 24, image_hash, 32); + m[56] = CODEC_FULL; + memcpy(m + 57, c.hw_id, strlen(c.hw_id) < 32 ? strlen(c.hw_id) : 32); // hw_id[32] (NUL-padded by memset) + memcpy(m + 89, APPROVAL_NOT, 4); // approval marker (fetching device's apply-gate handles it) + return c.manager.serve_self(m, 93, c.serve_self_leaves, bc, + c.serve_self_proof, (size_t)bc * 4, self_read_cb, nullptr); +} +#endif + } // namespace ota } // namespace mesh diff --git a/src/helpers/ota/OtaSelf.h b/src/helpers/ota/OtaSelf.h index e3373657..df855c6f 100644 --- a/src/helpers/ota/OtaSelf.h +++ b/src/helpers/ota/OtaSelf.h @@ -14,5 +14,15 @@ namespace ota { // platform or no valid EndF is present (e.g. firmware built without the EndF build hook). bool ota_self_firmware(SelfFwInfo& out); +// Read `len` bytes of the running firmware image at offset `off` (ESP32: running partition via +// esp_partition_read; nRF52: memory-mapped app region). false on unsupported platforms. +bool ota_self_read(uint32_t off, uint8_t* buf, uint32_t len); + +// Compute (once) + cache our running firmware's manifest + merkle leaves in `c`, then serve it from +// flash as a full `.mota` (payload read on demand per block; only metadata held in RAM). Returns false +// if no EndF / image too big / OOM. Device platforms only. +struct OtaContext; +bool ota_serve_self(OtaContext& c, uint32_t fw_version); // target = this node's own (c.manager.target()) + } // namespace ota } // namespace mesh diff --git a/src/helpers/ota/OtaSource.h b/src/helpers/ota/OtaSource.h new file mode 100644 index 00000000..64c8fce6 --- /dev/null +++ b/src/helpers/ota/OtaSource.h @@ -0,0 +1,55 @@ +#pragma once + +#include +#include +#include "OtaFormat.h" + +// Transport-agnostic "folder of firmware" abstraction (docs/ota_protocol.md §9). A node can RELAY +// `.mota` images it does not hold in flash — a user drops several `.mota` (different architectures) into +// some external store and the node advertises + serves them as if it held them. Peers just see "this node +// has N mOTAs"; the node knows they are external. The store is reached through a MotaSource, so the SAME +// serve code drives a USB-serial host daemon, BLE, a WiFi URL list, an NFS/samba mount, ... — only the +// `read()` plumbing differs per transport. +// +// The relay is TRUSTLESS: the fetcher verifies every block against the signed merkle root, so a source is +// never trusted. A wrong descriptor or wrong bytes simply makes the fetch fail its merkle/signature check +// — a malicious or buggy source cannot forge firmware, only deny it. + +namespace mesh { +namespace ota { + +// A parsed top-level descriptor of one `.mota` a source provides: enough to advertise it in the catalog +// AND to locate every region for serving, WITHOUT holding the whole image in RAM. Offsets are absolute +// byte positions within the `.mota` container (which always begins MAGIC(4) total(4) manifest...). +struct MotaDesc { + uint8_t mid[4] = {0}; // merkle_root (the content id peers fetch by) + uint32_t target_id = 0; + uint32_t fw_version = 0; + uint8_t codec_id = 0; + uint8_t flags = 0; + uint32_t total_size = 0; // full `.mota` length (bytes) + uint32_t leaves_off = 0; // byte offset of the merkle leaves[] (manifest-minus-leaves = [8, leaves_off)) + uint32_t block_count = 0; // == number of leaves (== number of payload blocks) + uint32_t payload_off = 0; // byte offset of the payload + uint32_t payload_size = 0; +}; + +// One or more complete `.mota` images, reachable as random-access bytes. Implementations are device-side +// and transport-specific (the engine in OtaManager is portable and never includes this directly for I/O; +// it only calls through the interface). +class MotaSource { +public: + virtual ~MotaSource() {} + // Number of complete, servable mOTAs this source currently offers (may change as the folder changes; + // the manager re-enumerates on add_source / refresh). + virtual uint8_t count() = 0; + // Cheap metadata + region offsets for mota `idx`. False if idx is out of range or unparsable. + virtual bool describe(uint8_t idx, MotaDesc& out) = 0; + // Random-access read of `len` bytes at absolute offset `off` of mota `idx` into `buf`. Returns true iff + // exactly `len` bytes were produced. May block on the transport (serial round-trip); OTA is lowest + // priority so latency here is acceptable. + virtual bool read(uint8_t idx, uint32_t off, uint8_t* buf, uint32_t len) = 0; +}; + +} // namespace ota +} // namespace mesh diff --git a/src/helpers/ota/OtaStore.h b/src/helpers/ota/OtaStore.h index 3a75e852..27f2fb29 100644 --- a/src/helpers/ota/OtaStore.h +++ b/src/helpers/ota/OtaStore.h @@ -3,6 +3,7 @@ #include #include #include +#include "OtaFormat.h" // MOTA_MAGIC (resume: detect a persisted partial container) // Staging backend for an in-transit `.mota` (docs/ota_protocol.md §7). Blocks may arrive out of order // and progress must survive reboots, so the store is random-access. The transfer/verify logic is @@ -34,13 +35,37 @@ public: // reaches COMPLETE (radio idle), so a flash store does its page writes off the RX critical path. // After this returns, a flash store's data() view is coherent. No-op for purely in-RAM stores. virtual void finalize() {} + + // Optional: persist in-progress state (the metadata/leaf-progress page + any open payload buffer) so a + // reboot mid-transfer can resume. Called by OtaManager every OTA_CHECKPOINT_BLOCKS committed blocks. + // A flash store flushes its pinned meta page + the open payload page (consistency: payload-before-leaves) + // so every block whose leaf is persisted also has its payload in flash. No-op for RAM stores. Infrequent + // (at LoRa block rates, ~once per many minutes) so the extra erases don't matter. + virtual void checkpoint() {} + + // Optional: re-attach to a container ALREADY persisted in the backing store (after a reboot), WITHOUT + // erasing. Returns true if a syntactically valid container (MOTA_MAGIC header + plausible total) is + // present and the store is now set up to read/continue-writing it; false if none (caller starts fresh). + // OtaManager then reads + parses the stored manifest to recompute geometry and resume the fetch. + virtual bool reopen() { return false; } + + // Optional: declare the container's logical layout once the manifest is parsed, BEFORE begin(), so a + // store backed by a single spare A/B partition (ESP32) can choose placement and reject an unfittable + // fetch up front. A FULL image's payload IS the final firmware (no decode), so it can stream straight + // to the inactive slot's offset 0 while the small meta/leaves/trailer persist elsewhere; a delta's + // whole container is staged together (the decoder reads the patch from it at apply). image_size is the + // reconstructed image; [payload_off, payload_off+payload_size) is the payload region in the container. + // Return false if it cannot fit the backing store (the transfer is then refused before any block). + virtual bool plan_layout(bool is_full, uint32_t image_size, uint32_t payload_off, uint32_t payload_size) { + (void)is_full; (void)image_size; (void)payload_off; (void)payload_size; return true; + } }; // Fixed-capacity RAM store — for native tests and device bring-up of the transfer/verify path. // (Does NOT survive reboot; a persistent flash store replaces it for production — see D1.) template class OtaStoreRam : public OtaStore { - uint8_t _buf[CAP]; + uint8_t _buf[CAP] = {}; // zero-init so a never-written store's reopen() finds no MOTA_MAGIC uint32_t _total = 0; public: bool begin(uint32_t total_size) override { @@ -62,6 +87,15 @@ public: uint32_t capacity() const override { return CAP; } uint32_t staged_size() const override { return _total; } void clear() override { _total = 0; } + // RAM doesn't survive a real reboot, but the buffer persists within a process — enough to exercise the + // manager's resume path in native tests. Recover `total` from the stored header so read() bounds work. + bool reopen() override { + if (memcmp(_buf, MOTA_MAGIC, 4) != 0) return false; + uint32_t t = (uint32_t)_buf[4] | ((uint32_t)_buf[5] << 8) | ((uint32_t)_buf[6] << 16) | ((uint32_t)_buf[7] << 24); + if (t < 13 || t > CAP) return false; + _total = t; + return true; + } const uint8_t* data() const { return _buf; } // contiguous view (RAM store only) }; diff --git a/src/helpers/ota/OtaStoreFlashEsp32.cpp b/src/helpers/ota/OtaStoreFlashEsp32.cpp new file mode 100644 index 00000000..439f8685 --- /dev/null +++ b/src/helpers/ota/OtaStoreFlashEsp32.cpp @@ -0,0 +1,247 @@ +#include "OtaStoreFlashEsp32.h" + +#if defined(ESP32_PLATFORM) && defined(OTA_FLASH_STORE) + +#include "OtaDebug.h" +#include "MotaContainer.h" // mota_parse_manifest (reopen: rebuild geometry from the staged manifest) +#include +#include // malloc/free (the meta buffer is sized per fetch) +#include "esp_ota_ops.h" // esp_ota_get_next_update_partition (the inactive A/B slot) + +namespace mesh { +namespace ota { + +static inline uint32_t round_up_sec(uint32_t x) { + const uint32_t S = 4096; + return (x + S - 1) & ~(S - 1); +} + +OtaStoreFlashEsp32::~OtaStoreFlashEsp32() { free(_meta); } + +bool OtaStoreFlashEsp32::acquire() { + if (!_part) { + _part = esp_ota_get_next_update_partition(nullptr); + _psize = _part ? _part->size : 0; + } + return _part != nullptr; +} + +// Compute the slot placement from _full/_image_size/_meta_bytes/_pay_size (already set). Returns false if +// it won't fit. Shared by plan_layout (fresh fetch) and reopen (resume) so both derive identical geometry. +bool OtaStoreFlashEsp32::layout() { + uint32_t total = _meta_bytes + _pay_size + 5; + if (_full) { + // payload streams to slot offset 0 (it IS the image); header+manifest+leaves+trailer persist at the + // bottom so the container survives a reboot (resume / re-serve). + _meta_span = _meta_bytes; // routing boundary: [0,meta) -> RAM meta buffer + _meta_flush = round_up_sec(_meta_bytes + 5); // meta + 5-byte trailer, whole sectors + if (_meta_flush > OTA_ESP32_META_CAP) return false; + uint32_t bottom = (_psize - _meta_flush) & ~(SEC - 1); + _meta_part = bottom; + _pay_log0 = _meta_bytes; _pay_part0 = 0; + _write_start = 0; + if (_image_size > bottom) return false; // image would overrun the bottom meta region + } else { + // whole container staged bottom-aligned; the decoded image fills the slot from offset 0. + _meta_span = round_up_sec(_meta_bytes); // pin whole sectors covering meta (+ spillover payload) + _meta_flush = _meta_span; + if (_meta_flush > OTA_ESP32_META_CAP) return false; + if (total > _psize) return false; + _write_start = (_psize - total) & ~(SEC - 1); + _meta_part = _write_start; + _pay_log0 = _meta_span; _pay_part0 = _write_start + _meta_span; + if (_image_size > _write_start) return false; // decoded output would overlap the staged container + } + _total = total; + return true; +} + +// Choose placement from the parsed manifest and refuse anything that won't fit, BEFORE any block is +// staged. (See the header for the delta-vs-full layout rationale.) +bool OtaStoreFlashEsp32::plan_layout(bool is_full, uint32_t image_size, uint32_t payload_off, uint32_t payload_size) { + if (!acquire()) return false; + _full = is_full; _image_size = image_size; _meta_bytes = payload_off; _pay_size = payload_size; + bool ok = layout(); + OTA_DBG("OTA esp32: plan %s total=%u image=%u meta=%u write_start=%u meta_part=%u ok=%d\n", + is_full ? "FULL" : "DELTA", (unsigned)_total, (unsigned)image_size, (unsigned)_meta_bytes, + (unsigned)_write_start, (unsigned)_meta_part, (int)ok); + return ok; +} + +bool OtaStoreFlashEsp32::begin(uint32_t total_size) { + if (!_part || _total == 0 || total_size != _total) return false; // plan_layout must have run + agreed + free(_meta); + _meta = (uint8_t*)malloc(_meta_flush); // header+manifest+leaves(+full trailer) + if (!_meta) { _total = 0; return false; } + memset(_meta, 0xFF, _meta_flush); + memset(_trailer, 0xFF, sizeof(_trailer)); + _pay_open = false; _pay_sec = 0; _pay_max_sec = 0; _flushed = false; + _io_ok = true; + return true; +} + +void OtaStoreFlashEsp32::clear() { + free(_meta); _meta = nullptr; + _total = 0; _pay_open = false; _flushed = false; // _part kept (re-acquire is fine) +} + +uint8_t* OtaStoreFlashEsp32::meta_slot(uint32_t L) { + if (in_trailer(L)) return _full ? (_meta + _meta_bytes + (L - (_total - 5))) + : (_trailer + (L - (_total - 5))); + if (L < _meta_span) return _meta + L; + return nullptr; // payload -> sliding sector / flash +} +const uint8_t* OtaStoreFlashEsp32::meta_slot_c(uint32_t L) const { + if (in_trailer(L)) return _full ? (_meta + _meta_bytes + (L - (_total - 5))) + : (_trailer + (L - (_total - 5))); + if (L < _meta_span) return _meta + L; + return nullptr; +} + +// Bytes from `pos` that stay in one region, and (for payload) one flash sector. +uint32_t OtaStoreFlashEsp32::run(uint32_t pos, uint32_t remain) const { + if (pos >= _total - 5) return remain; // trailer (<=5, one buffer) + if (pos < _meta_span) { uint32_t c = _meta_span - pos; return remain < c ? remain : c; } + uint32_t poff = pay_part(pos); + uint32_t to_sec = SEC - (poff % SEC); + uint32_t to_end = (_total - 5) - pos; // don't cross into the trailer + uint32_t c = remain; + if (to_sec < c) c = to_sec; + if (to_end < c) c = to_end; + return c; +} + +void OtaStoreFlashEsp32::flush_sector(uint32_t slot_off, const uint8_t* buf, uint32_t n) { + if (!_io_ok) return; + if (esp_partition_erase_range(_part, slot_off, n) != ESP_OK) { _io_ok = false; return; } + if (esp_partition_write(_part, slot_off, buf, n) != ESP_OK) { _io_ok = false; return; } + OTA_DBG("OTA esp32: flushed %u B @ slot+%u\n", (unsigned)n, (unsigned)slot_off); +} + +void OtaStoreFlashEsp32::flush_pay() { + if (_pay_open) { flush_sector((uint32_t)_pay_sec * SEC, _pay, SEC); _pay_open = false; } +} + +void OtaStoreFlashEsp32::open_pay(uint32_t sec) { + if (_pay_open) flush_pay(); + if (sec < _pay_max_sec) { + // revisiting an already-flushed sector (out-of-order block) -> read it back so the gaps we don't + // touch are preserved (they were programmed as 0xFF or earlier block data); we erase+reprogram on flush. + if (esp_partition_read(_part, (size_t)sec * SEC, _pay, SEC) != ESP_OK) _io_ok = false; + } else { + memset(_pay, 0xFF, SEC); // fresh sector + _pay_max_sec = sec; + } + _pay_sec = sec; _pay_open = true; +} + +bool OtaStoreFlashEsp32::write(uint32_t offset, const uint8_t* d, uint32_t len) { + if ((uint64_t)offset + len > _total || !_io_ok) return false; + for (uint32_t pos = offset, end = offset + len; pos < end; ) { + uint32_t n = run(pos, end - pos); + if (uint8_t* dst = meta_slot(pos)) { // meta / leaves / trailer -> pinned RAM + memcpy(dst, d, n); + } else { // payload -> sliding sector + uint32_t poff = pay_part(pos); + uint32_t sec = poff / SEC; + if (!_pay_open || sec != _pay_sec) open_pay(sec); + memcpy(_pay + (poff % SEC), d, n); + } + pos += n; d += n; + if (!_io_ok) return false; + } + return true; +} + +bool OtaStoreFlashEsp32::read(uint32_t offset, uint8_t* buf, uint32_t len) const { + if ((uint64_t)offset + len > _total) return false; + for (uint32_t pos = offset, end = offset + len; pos < end; ) { + uint32_t n = run(pos, end - pos); + if (const uint8_t* src = meta_slot_c(pos)) { + memcpy(buf, src, n); + } else { + uint32_t poff = pay_part(pos); + if (_pay_open && poff / SEC == _pay_sec) memcpy(buf, _pay + (poff % SEC), n); // still in RAM + else if (esp_partition_read(_part, poff, buf, n) != ESP_OK) return false; // flushed -> flash + } + pos += n; buf += n; + } + return true; +} + +void OtaStoreFlashEsp32::finalize() { + if (_flushed || _total == 0) return; + if (!_full) { + // delta: drop the 5-byte trailer into the sliding payload sector(s) so it flushes with them (the + // trailer sits right after the payload; this also covers the rare case it spills into a fresh sector). + uint32_t tpoff = _write_start + (_total - 5); + for (uint32_t off = 0; off < 5; ) { + uint32_t sec = (tpoff + off) / SEC; + if (!_pay_open || sec != _pay_sec) open_pay(sec); + uint32_t in = SEC - ((tpoff + off) % SEC); if (in > 5 - off) in = 5 - off; + memcpy(_pay + ((tpoff + off) % SEC), _trailer + off, in); + off += in; + } + } + flush_pay(); // last payload sector(s) (+ delta trailer) + flush_sector(_meta_part, _meta, _meta_flush); // meta (+ trailer for full) + _flushed = true; + OTA_DBG("OTA esp32: finalize %s io_ok=%d\n", _full ? "FULL" : "DELTA", (int)_io_ok); +} + +// Persist mid-transfer progress so a reboot can resume. Flush the open payload sector (KEEP it buffered so +// continued in-order writes aren't lost), then the meta region (leaves). Payload-before-leaves keeps it +// consistent: every block whose leaf is now in flash also has its payload in flash. Infrequent. +void OtaStoreFlashEsp32::checkpoint() { + if (_total == 0 || _flushed || !_io_ok) return; + if (_pay_open) flush_sector((uint32_t)_pay_sec * SEC, _pay, SEC); // flush but leave _pay/_pay_open intact + flush_sector(_meta_part, _meta, _meta_flush); // header + manifest + leaves (+full trailer) +} + +// Re-attach to a container already staged in the slot (after a reboot), WITHOUT erasing. The header +// (MOTA_MAGIC + total) sits at a sector boundary (meta_part for full, write_start for delta — both +// sector-aligned), so scan sector starts from the bottom up. A candidate is accepted only if: magic + +// plausible total, the manifest parses, the container geometry is self-consistent with the total, AND the +// recomputed placement lands the meta exactly where we found the magic. Any miss -> false (fetch fresh), +// so a stray/stale match can only cost a restart, never a corrupt adopt. +bool OtaStoreFlashEsp32::reopen() { + if (!acquire() || _psize < SEC) return false; + uint8_t hb[8]; + // the meta/container is staged at the BOTTOM of the slot, so scan upward from there; cap the scan (a + // miss just means "fetch fresh"). 128 sectors (512 KB) covers any full image's meta + typical deltas. + uint32_t scanned = 0; + for (uint32_t o = (_psize - SEC) & ~(SEC - 1); ; o -= SEC) { + if (esp_partition_read(_part, o, hb, 8) == ESP_OK && memcmp(hb, MOTA_MAGIC, 4) == 0) { + uint32_t total = (uint32_t)hb[4] | ((uint32_t)hb[5] << 8) | ((uint32_t)hb[6] << 16) | ((uint32_t)hb[7] << 24); + if (total >= 13 && total <= _psize) { + uint8_t mbuf[256]; uint32_t mread = total - 8; if (mread > sizeof(mbuf)) mread = sizeof(mbuf); + MotaManifest m; + if (esp_partition_read(_part, o + 8, mbuf, mread) == ESP_OK && mota_parse_manifest(mbuf, mread, m)) { + uint32_t mfl = (uint32_t)(m.approval - m.manifest_start) + 4; + uint32_t payload_off = 8 + mfl + m.block_count * 4; + if ((uint64_t)payload_off + m.payload_size + 5 == total) { + _full = m.is_full(); _image_size = m.image_size; _meta_bytes = payload_off; _pay_size = m.payload_size; + if (layout() && _meta_part == o) { // geometry agrees AND magic is where we'd place meta + free(_meta); _meta = (uint8_t*)malloc(_meta_flush); + if (!_meta) { _total = 0; return false; } + if (esp_partition_read(_part, _meta_part, _meta, _meta_flush) != ESP_OK) { + free(_meta); _meta = nullptr; _total = 0; return false; } + memset(_trailer, 0xFF, sizeof(_trailer)); // delta trailer (re-written at finalize); full reads it from _meta + _pay_open = false; _pay_sec = 0; _flushed = false; _io_ok = true; + _pay_max_sec = (_pay_part0 + _pay_size + SEC) / SEC; // treat all payload sectors as seen -> RMW preserves committed blocks + OTA_DBG("OTA esp32: reopen %s total=%u meta_part=%u\n", _full ? "FULL" : "DELTA", (unsigned)total, (unsigned)o); + return true; + } + } + } + } + } + if (o == 0 || ++scanned >= 128) break; + } + return false; +} + +} // namespace ota +} // namespace mesh + +#endif diff --git a/src/helpers/ota/OtaStoreFlashEsp32.h b/src/helpers/ota/OtaStoreFlashEsp32.h new file mode 100644 index 00000000..f0eee558 --- /dev/null +++ b/src/helpers/ota/OtaStoreFlashEsp32.h @@ -0,0 +1,116 @@ +#pragma once + +#if defined(ESP32_PLATFORM) && defined(OTA_FLASH_STORE) + +#include "OtaStore.h" +#include "esp_partition.h" + +// Persistent flash-backed OtaStore for ESP32 (A/B). Stages the received `.mota` in the INACTIVE OTA +// slot, so a delta/full of any size is bounded to O(one sector) of RAM, never O(mota) -- lifting the old +// RAM store's ~16 KB ceiling (a full 1 MB+ image now fetches over the air). Placement is chosen from the +// parsed manifest (plan_layout), keyed only on full-vs-delta: +// +// - DELTA (codec sequential/in-place): the whole container is staged bottom-aligned in the slot. At +// apply the decoder reads the patch from it -- sequential: base from the running slot -> output to +// the inactive slot from offset 0; in-place: copy running->slot then patch in place. The decoded +// image fills the slot from offset 0, so plan_layout refuses unless `image_size + container` fits +// (the output must never reach the bottom-staged container; the fit makes them disjoint). +// +// - FULL (codec full): the payload IS the final image (no decode), so it streams straight to slot +// offset 0 while the small header+manifest+merkle-leaves and the trailer persist at the bottom of the +// slot (so the whole container survives a reboot -- for fetch-resume and for re-serving to other +// nodes). One default for every A/B full payload; plan_layout refuses unless `image_size + meta` +// fits, and if that doesn't fit nothing would. +// +// RX-safety: an ESP32 flash erase+write disables the XIP instruction cache and stalls code running from +// flash (the dispatcher loop), exactly like the nRF52 page erase that starved the LoRa RX. So writes are +// COALESCED to the 4 KB sector and each sector is programmed once. The meta region (header+manifest+ +// leaves -- written all transfer long as blocks/leaves arrive, often out of order) is PINNED in RAM and +// flushed at finalize() with the radio idle; the bulk payload streams through ONE sliding sector buffer, +// flushing the sector it leaves behind (~1 flush per 4 KB, off the per-packet path). OTA is also the +// lowest-priority TX, so a brief stall yields to real traffic. A small delta whose container fits the +// pinned meta region does ZERO flash I/O until COMPLETE. + +namespace mesh { +namespace ota { + +#ifndef OTA_ESP32_META_CAP +#define OTA_ESP32_META_CAP 65536 // max heap for header+manifest+merkle leaves (4 B/block) -> ~16k blocks +#endif // (a 1.27 MB full image at 128 B LoRa blocks ~= 40 KB of leaves) + +class OtaStoreFlashEsp32 : public OtaStore { + static const uint32_t SEC = 4096; // ESP32 NOR flash erase unit + + const esp_partition_t* _part = nullptr; // inactive OTA slot (acquired in plan_layout/begin) + uint32_t _psize = 0; // slot size + + // container geometry (from plan_layout / handleManifest) + uint32_t _total = 0; // container size (0 = none staged) + uint32_t _meta_bytes = 0; // header+manifest+leaves (== payload offset in the container) + uint32_t _pay_size = 0; // payload bytes + uint32_t _image_size = 0; // reconstructed image (delta) / == _pay_size (full) + bool _full = false; + + // logical->partition placement (see header doc). For delta everything is contiguous at _write_start; + // for full the payload lands at slot 0 and meta+trailer at the bottom. + uint32_t _write_start = 0; // delta: container offset 0 in the slot (sector-aligned) + uint32_t _meta_span = 0; // container bytes held in the RAM meta buffer (whole sectors) + uint32_t _meta_part = 0; // slot offset the meta buffer flushes to + uint32_t _pay_log0 = 0; // first container offset that streams to the payload region + uint32_t _pay_part0 = 0; // slot offset of that first payload byte + uint32_t _trailer_part = 0; // slot offset of the 5-byte trailer + + // RX-safe staging buffers + uint8_t* _meta = nullptr; // heap, sized per fetch: header+manifest+leaves(+full trailer) + uint8_t _pay[SEC]; // one sliding payload sector (slot-sector aligned) + uint32_t _pay_sec = 0; // slot sector index currently in _pay (0 = none open) + uint8_t _trailer[5]; + uint32_t _meta_flush = 0; // whole-sector byte count to program for the meta buffer + uint32_t _pay_max_sec = 0; // highest payload slot-sector opened (out-of-order detection) + bool _pay_open = false; // a sliding sector is currently buffered in _pay + bool _flushed = false; + bool _io_ok = true; // cleared if any flash erase/write/read fails + + bool acquire(); // resolve the inactive slot (idempotent) + bool layout(); // compute placement from _full/_image_size/_meta_bytes/_pay_size + uint32_t pay_part(uint32_t L) const { return _pay_part0 + (L - _pay_log0); } // payload slot offset + bool in_trailer(uint32_t L) const { return L >= _total - 5; } + uint32_t run(uint32_t pos, uint32_t remain) const; // bytes from `pos` that stay in one region+sector + uint8_t* meta_slot(uint32_t L); // RAM home of a meta/trailer byte (nullptr if it's payload) + const uint8_t* meta_slot_c(uint32_t L) const; + void open_pay(uint32_t sec); // make `sec` the buffered sliding sector (RMW if revisited) + void flush_pay(); // erase+write the open sliding sector + void flush_sector(uint32_t slot_off, const uint8_t* buf, uint32_t n); // erase+program sector(s) + +public: + ~OtaStoreFlashEsp32() override; + bool plan_layout(bool is_full, uint32_t image_size, uint32_t payload_off, uint32_t payload_size) override; + bool begin(uint32_t total_size) override; + bool write(uint32_t offset, const uint8_t* data, uint32_t len) override; + bool read(uint32_t offset, uint8_t* buf, uint32_t len) const override; + uint32_t capacity() const override { return _psize; } // loose bound; plan_layout does the real check + uint32_t staged_size() const override { return _total; } + void clear() override; + bool set_meta_size(uint32_t meta_bytes) override { return meta_bytes < OTA_ESP32_META_CAP; } + void finalize() override; + void checkpoint() override; // persist meta(leaves) + open payload sector so a reboot can resume + bool reopen() override; // re-attach to a container already staged in the slot (scan + rebuild geometry) + + // No contiguous RAM/mmap view: the staged container lives in the slot (split for FULL). The ESP32 + // apply path reads what it needs via read()/esp_partition_read instead of a data() pointer, so this + // returns nullptr (kept for interface parity with the nRF52 store, whose flash is memory-mapped). + const uint8_t* data() const { return nullptr; } + + // Apply-path accessors: where the staged container physically lives in the inactive slot. + const esp_partition_t* partition() const { return _part; } + uint32_t write_start() const { return _write_start; } // delta: container offset 0 in the slot + bool is_full() const { return _full; } + uint32_t image_size() const { return _image_size; } + uint32_t meta_bytes() const { return _meta_bytes; } + uint32_t payload_slot_off(uint32_t k) const { return _pay_part0 + k; } // slot off of payload byte k +}; + +} // namespace ota +} // namespace mesh + +#endif diff --git a/src/helpers/ota/OtaStoreFlashNrf52.cpp b/src/helpers/ota/OtaStoreFlashNrf52.cpp index 5bce71fe..f8991819 100644 --- a/src/helpers/ota/OtaStoreFlashNrf52.cpp +++ b/src/helpers/ota/OtaStoreFlashNrf52.cpp @@ -115,6 +115,43 @@ void OtaStoreFlashNrf52::finalize() { _flushed = true; } +// Persist mid-transfer progress so a reboot can resume. Order matters for consistency: flush the open +// payload page FIRST, then page 0 (the leaf-progress markers) -- so every block whose leaf is now in flash +// also has its payload in flash. Infrequent (every OTA_CHECKPOINT_BLOCKS blocks), so the 2 extra page +// erases don't matter; at LoRa block rates it's roughly once per many minutes. +void OtaStoreFlashNrf52::checkpoint() { + if (_total == 0 || _flushed) return; + flush_pay(); // keep _pay_idx open (it may still receive writes); just re-flush its bytes + flush_page(0, _meta_page); // header + manifest + leaves accumulated so far +} + +// Re-attach to a container already staged in flash (after a reboot), without erasing. The container is +// bottom-aligned (begin: start = (FS_START - total) & ~(PG-1)) and flash is memory-mapped, so scan page +// starts from just below FS_START down to the app end for MOTA_MAGIC with a self-consistent total; adopt +// the first match (highest address = most recent for the common single-container case). The manager then +// parses the loaded manifest and validates geometry/root, so a stale leftover is rejected there. +bool OtaStoreFlashNrf52::reopen() { + uint32_t app_end = MOTA_NRF52_APP_BASE; + SelfFwInfo fi; + if (ota_self_firmware(fi) && fi.valid) app_end = MOTA_NRF52_APP_BASE + fi.image_len; + for (uint32_t start = (MOTA_NRF52_FS_START - PG) & ~(PG - 1); start >= app_end; start -= PG) { + const uint8_t* p = (const uint8_t*)(uintptr_t)start; + if (memcmp(p, MOTA_MAGIC, 4) != 0) continue; + uint32_t total = (uint32_t)p[4] | ((uint32_t)p[5] << 8) | ((uint32_t)p[6] << 16) | ((uint32_t)p[7] << 24); + if (total < 13 || total > capacity()) continue; + if (((MOTA_NRF52_FS_START - total) & ~(PG - 1)) != start) continue; // must match begin()'s placement + _write_start = start; + _total = total; + memcpy(_meta_page, p, PG); // load page 0 (header+manifest+leaves) into RAM to continue + memcpy(_trailer, p + (total - 5), 5); // recover the trailer tail (flushed at last finalize, if any) + _pay_idx = 0; + _flushed = false; + OTA_DBG("OTA flash: reopen total=%u start=%08x\n", (unsigned)total, (unsigned)start); + return true; + } + return false; +} + } // namespace ota } // namespace mesh diff --git a/src/helpers/ota/OtaStoreFlashNrf52.h b/src/helpers/ota/OtaStoreFlashNrf52.h index 16268b62..37d64cef 100644 --- a/src/helpers/ota/OtaStoreFlashNrf52.h +++ b/src/helpers/ota/OtaStoreFlashNrf52.h @@ -62,6 +62,8 @@ public: void clear() override { _total = 0; _pay_idx = 0; _flushed = false; } bool set_meta_size(uint32_t meta_bytes) override { return meta_bytes <= PG; } // leaves must fit page 0 void finalize() override; + void checkpoint() override; // persist page 0 (leaves) + the open payload page so a reboot can resume + bool reopen() override; // re-attach to a container already staged in flash (scan for it) // Contiguous view (flash is memory-mapped). VALID ONLY AFTER finalize() — before that, page 0 and the // tail are still in RAM. OtaManager/OtaCli/verify use this only once the transfer is COMPLETE. diff --git a/test/test_ota/mota_vectors.h b/test/test_ota/mota_vectors.h index 06571730..6c97172b 100644 --- a/test/test_ota/mota_vectors.h +++ b/test/test_ota/mota_vectors.h @@ -3,9 +3,9 @@ #pragma once #include -static const uint8_t MOTA_VEC[5371] = {109,79,84,65,251,20,0,0,1,1,18,68,51,34,17,0,0,16,1,153,20,0,0,153,20,0,0,10,175,252,9,108,103,145,119,80,26,44,231,124,151,8,14,131,41,90,36,227,134,142,35,227,246,136,63,104,34,211,80,66,55,32,79,153,0,255,255,255,255,136,178,44,23,110,140,98,143,139,154,17,11,47,111,103,108,222,151,146,129,34,221,184,64,163,28,6,189,70,62,57,35,188,26,173,189,228,139,22,151,108,8,7,23,55,59,129,154,6,143,50,183,166,179,139,107,56,114,150,71,207,222,1,194,206,40,178,108,87,71,39,55,245,195,86,26,23,97,24,91,216,88,154,67,206,11,186,117,137,31,249,236,96,20,141,75,212,160,158,226,220,92,147,49,180,17,11,169,58,197,74,252,20,218,59,221,25,97,71,116,162,213,93,41,94,90,53,171,68,179,239,174,165,18,155,162,43,136,186,62,41,118,97,69,253,236,163,176,142,56,175,83,215,196,198,14,58,210,8,206,80,102,68,16,54,233,241,145,224,183,80,54,167,127,101,226,234,164,117,36,67,35,63,190,143,137,67,191,149,109,229,149,102,92,56,255,255,35,130,126,23,193,12,220,28,39,160,40,202,174,108,152,16,98,97,152,255,119,135,64,248,141,220,241,2,174,184,29,174,226,137,192,68,196,164,87,28,75,111,40,116,0,244,184,224,184,67,248,128,195,45,129,233,27,222,160,76,215,163,129,155,50,39,95,195,41,138,244,199,236,135,235,0,153,82,125,4,28,237,92,224,252,212,206,78,61,14,61,224,145,242,20,21,187,124,208,17,250,194,136,196,32,32,168,121,242,140,42,67,135,223,155,108,246,54,237,138,193,186,176,51,182,79,102,254,171,166,95,112,230,132,115,30,63,57,16,86,5,150,141,58,150,56,1,18,181,161,15,58,17,231,8,220,84,18,131,60,71,171,124,54,138,33,185,239,225,146,147,121,62,200,121,206,104,48,24,24,168,110,90,108,105,119,221,186,13,172,167,251,165,25,15,103,186,86,204,220,27,63,49,48,137,114,35,108,46,71,118,63,223,236,19,113,206,220,219,140,25,12,166,255,138,214,3,248,23,237,192,217,60,42,104,124,123,54,221,102,231,15,42,97,0,252,99,67,237,200,200,116,73,108,178,245,187,254,200,142,169,183,124,39,48,75,55,247,14,148,188,138,15,191,80,14,12,149,122,128,235,218,135,40,14,245,130,20,217,47,17,152,17,172,220,60,103,30,241,227,145,63,148,152,10,158,20,107,168,149,144,133,80,239,66,52,171,183,80,61,67,101,33,171,165,76,117,80,237,192,239,18,2,117,159,255,144,255,25,18,137,54,129,67,33,238,89,225,17,225,62,94,72,40,112,213,139,180,77,156,251,252,206,167,135,2,170,209,141,76,238,169,26,240,224,34,67,29,227,27,190,141,39,69,72,154,53,183,87,52,175,162,218,67,129,125,64,231,232,216,13,23,162,108,212,70,11,0,85,197,33,163,250,67,41,189,113,141,180,109,143,2,28,19,241,226,176,231,38,139,9,213,94,149,141,37,110,32,10,78,93,230,238,203,248,220,10,230,91,53,174,63,170,26,90,199,143,226,223,104,249,158,191,39,236,238,60,221,41,249,204,207,45,225,105,6,45,188,236,85,200,238,105,205,171,221,188,207,63,68,40,201,179,27,97,223,9,219,120,56,51,209,235,117,89,78,210,203,223,58,57,6,168,49,102,84,71,221,17,247,197,71,89,164,130,102,173,251,215,137,84,240,7,29,224,248,66,45,148,246,251,67,9,27,152,111,88,186,201,80,111,155,251,130,29,98,230,147,48,65,11,181,111,0,133,236,206,137,175,184,240,189,188,171,50,93,110,17,242,170,235,84,159,80,169,217,31,184,230,76,129,79,170,104,83,103,178,75,141,32,49,107,170,240,97,173,191,231,44,157,145,77,103,140,213,0,77,73,53,110,201,148,155,167,82,119,113,113,172,54,130,121,203,230,245,203,188,43,168,21,72,131,169,162,158,85,23,209,243,192,60,172,79,57,206,50,37,6,11,62,251,121,156,217,196,18,116,106,226,161,147,49,183,178,98,126,102,62,37,167,176,1,228,192,220,197,226,27,199,108,56,45,205,245,178,132,118,12,142,63,234,217,31,116,34,205,118,170,135,252,143,152,81,243,193,228,113,156,208,184,228,129,109,212,232,140,114,229,40,190,220,121,115,66,192,63,215,163,70,196,199,133,124,160,61,70,112,19,182,73,60,69,85,81,228,138,20,35,38,59,98,177,39,180,54,16,106,104,84,138,119,106,15,52,213,107,99,231,197,149,242,178,5,219,225,195,147,97,122,1,241,90,76,192,99,218,228,244,213,107,137,191,188,139,204,154,229,56,124,56,69,111,124,7,99,86,171,173,204,103,185,42,215,119,235,32,251,159,136,6,232,100,151,144,169,6,21,164,109,34,221,118,46,12,66,97,83,54,116,83,86,194,225,97,71,192,243,212,107,64,213,20,120,4,191,138,13,255,243,89,57,166,17,199,245,166,10,193,7,243,63,51,214,5,159,39,61,32,121,171,29,144,242,55,119,179,65,196,94,42,155,155,246,191,183,29,199,209,41,246,79,27,148,6,237,79,147,173,232,245,96,101,241,183,50,19,151,176,212,160,62,26,178,197,77,217,175,153,206,30,203,251,144,200,10,88,136,109,169,94,17,129,165,87,3,217,107,210,125,27,110,245,92,162,228,212,117,181,39,111,45,187,133,247,166,69,157,206,235,137,198,123,119,111,211,187,151,68,82,218,62,212,239,22,71,225,115,62,192,118,145,156,171,97,86,7,126,217,83,46,124,54,90,204,66,87,71,225,152,179,225,70,142,2,132,242,48,21,61,184,104,125,142,194,61,176,121,165,182,125,114,202,4,23,75,56,103,177,62,78,169,148,94,121,141,135,88,108,255,190,140,84,90,179,116,69,78,64,59,30,184,49,80,30,190,137,243,195,176,47,49,55,189,123,70,185,150,250,194,134,152,72,251,25,213,49,75,58,92,45,77,3,181,136,32,70,11,249,13,141,74,178,241,32,163,222,192,125,26,223,3,146,72,120,122,112,87,47,247,13,64,240,220,122,29,210,16,102,125,18,147,161,175,13,38,38,207,144,242,77,21,254,63,30,142,195,106,155,152,202,158,57,198,133,97,115,232,113,76,220,150,253,109,78,145,158,15,156,245,189,25,242,195,53,160,54,67,169,20,40,61,44,141,19,40,0,104,115,176,152,120,74,8,59,73,180,72,179,220,116,18,175,59,236,67,201,202,160,150,169,205,239,50,108,29,139,57,165,38,232,68,211,36,18,15,42,202,78,152,191,211,145,235,73,112,31,119,176,77,179,103,241,69,128,138,126,112,20,153,10,227,110,188,82,154,64,6,23,58,246,172,214,220,147,150,243,5,255,195,172,210,68,147,10,195,193,44,120,132,166,113,234,71,46,255,149,111,162,208,125,248,23,120,89,104,85,82,171,26,219,41,84,105,177,126,73,169,241,102,208,194,140,9,116,22,80,64,82,29,248,197,103,221,131,211,252,0,168,222,138,118,105,13,48,132,92,159,193,127,160,113,194,13,52,68,140,33,237,73,112,225,178,124,31,7,249,161,155,204,61,181,40,79,141,3,141,104,23,57,254,215,233,29,118,242,30,165,213,39,127,238,183,74,130,180,69,106,213,123,250,120,62,116,141,37,98,48,235,153,130,191,225,34,221,17,70,197,202,218,106,87,239,201,129,68,210,0,72,185,76,214,150,148,255,168,125,221,38,114,137,123,88,85,141,195,139,96,116,238,82,222,48,251,178,61,146,98,59,219,198,105,11,81,190,121,180,233,207,97,98,253,169,202,210,166,251,38,126,246,9,32,128,247,151,84,222,25,223,216,112,25,134,233,116,3,184,36,104,222,167,248,39,19,120,200,248,67,86,159,177,101,166,20,218,84,218,172,219,136,97,244,81,160,183,227,194,124,223,138,9,158,17,60,161,175,235,73,255,58,191,23,111,250,25,194,162,180,223,25,113,42,177,76,231,7,11,83,203,14,75,91,95,110,37,62,135,105,144,174,202,46,43,44,20,156,222,97,158,174,61,127,233,149,36,59,118,163,65,117,65,170,2,230,205,119,230,73,173,139,40,18,113,241,88,252,150,76,163,246,108,176,64,116,216,77,50,255,98,218,123,27,60,97,146,91,147,75,254,179,75,5,250,212,168,101,70,2,144,221,175,199,190,249,12,233,155,190,127,213,231,231,73,198,204,58,155,205,90,56,162,48,158,64,173,193,184,196,168,174,214,35,160,24,231,160,165,10,79,201,112,8,148,93,187,33,23,232,75,83,191,106,44,51,33,201,138,224,248,93,135,128,233,69,212,42,65,233,211,241,123,247,206,75,191,222,86,205,29,119,246,19,36,193,247,57,220,173,185,172,250,101,247,216,205,142,93,23,202,101,3,67,137,31,116,94,172,191,172,67,149,97,210,163,240,95,27,172,59,120,6,158,226,241,143,83,234,156,56,165,16,162,210,118,232,179,77,166,104,29,35,11,242,9,77,254,126,29,24,60,227,137,34,99,116,94,171,243,190,178,242,138,107,150,190,186,39,226,106,167,25,213,125,157,104,240,243,71,8,176,94,55,113,113,243,60,218,92,25,251,175,94,139,230,250,165,91,15,101,70,48,247,31,242,217,210,116,23,169,54,164,163,152,248,5,12,201,85,62,253,32,201,144,52,17,212,195,141,53,150,55,208,222,59,84,198,37,201,230,152,0,70,219,251,37,252,33,138,64,204,44,28,169,221,6,33,3,91,202,201,60,150,82,4,44,67,13,32,189,107,134,29,190,16,121,114,199,92,131,151,27,115,128,56,242,157,11,186,200,232,221,168,133,77,117,164,246,7,15,255,122,216,102,109,175,27,125,182,232,113,18,230,20,82,155,37,16,32,70,159,162,149,140,182,83,97,254,152,135,75,116,129,154,110,25,203,179,29,218,167,166,224,196,141,184,221,55,110,115,227,58,105,86,211,116,102,106,186,24,80,109,80,170,65,95,244,39,175,236,121,17,23,212,21,23,110,24,190,189,95,207,33,142,15,150,244,143,143,84,171,31,105,90,223,170,240,192,108,222,234,184,13,247,73,153,79,90,26,147,129,54,39,168,123,57,216,27,89,216,142,94,29,195,71,146,57,206,109,216,143,249,196,209,159,157,172,164,142,6,155,237,168,212,177,68,7,46,69,179,195,79,235,86,89,1,46,222,36,144,168,102,17,36,189,162,248,7,23,191,135,55,96,107,116,87,40,94,79,184,83,198,241,145,152,21,226,13,39,40,193,158,12,172,20,69,113,169,108,124,155,113,106,69,55,193,131,29,88,110,28,72,173,173,151,124,134,170,78,11,56,101,252,153,14,1,52,77,242,54,196,35,195,65,74,83,30,1,127,191,110,44,33,97,136,180,58,128,143,213,171,206,90,18,101,220,189,10,111,4,117,235,19,220,80,147,109,146,103,181,163,106,74,29,103,5,247,83,43,205,242,158,117,212,176,235,92,22,111,216,27,62,111,150,102,134,20,101,222,79,190,86,56,85,199,43,19,130,162,29,135,130,49,231,198,89,89,186,245,209,165,208,37,60,26,37,65,50,44,154,39,194,194,167,19,45,243,197,160,126,118,193,144,194,148,114,174,236,225,144,164,162,252,159,82,221,248,160,80,38,112,17,120,113,161,77,203,70,151,14,90,129,18,79,118,115,9,14,94,212,73,19,165,221,250,218,23,157,152,129,98,118,148,141,244,202,189,229,10,115,232,207,146,166,48,82,154,121,128,38,245,15,115,26,207,230,214,87,251,182,21,129,165,44,10,63,181,112,253,112,134,133,156,40,93,95,234,72,99,104,198,86,173,153,13,202,161,165,85,16,84,24,142,173,98,72,64,185,218,168,246,232,154,223,38,85,20,149,169,36,234,89,79,247,167,178,169,100,33,152,181,240,21,79,143,96,164,202,84,208,32,171,179,212,242,189,255,175,233,134,23,165,171,108,130,92,4,92,79,46,243,54,87,242,196,124,49,57,255,35,39,19,75,216,201,25,129,197,138,213,189,226,134,9,169,86,224,196,158,33,152,96,39,41,46,212,177,197,159,207,231,42,184,112,11,105,93,173,184,60,248,113,156,72,192,191,200,114,59,136,61,79,247,207,200,120,231,213,49,94,173,242,146,252,112,118,196,72,199,97,128,135,107,247,41,209,51,205,154,35,223,64,13,164,123,223,95,141,239,26,182,216,132,217,31,72,21,195,41,69,115,231,131,37,212,111,23,242,233,56,209,115,226,89,238,6,106,13,101,128,95,60,98,254,20,95,57,7,81,238,25,214,182,166,85,202,37,35,9,73,234,212,120,178,212,35,194,180,120,114,157,1,231,20,4,65,55,213,38,140,240,186,155,135,108,28,198,73,60,77,31,12,61,107,163,203,159,117,16,28,214,231,127,152,137,4,161,131,147,61,183,36,74,109,0,157,90,61,146,106,47,170,171,21,134,249,92,17,244,134,139,129,201,253,129,141,5,99,223,120,11,162,99,251,95,64,191,4,91,201,17,88,61,187,168,160,26,197,148,188,193,85,34,11,90,139,86,208,164,44,212,199,175,118,251,178,122,161,46,207,34,16,183,198,241,117,9,75,51,11,202,51,226,10,80,238,79,131,101,253,208,139,121,64,9,192,165,48,73,91,220,199,12,221,167,84,69,31,204,94,111,227,102,190,112,229,244,98,86,249,47,127,177,127,94,236,204,132,68,205,21,186,108,20,110,154,254,210,46,139,75,82,26,20,83,169,75,78,114,154,183,109,42,176,113,89,114,10,186,222,233,90,157,255,111,70,163,250,202,242,14,19,171,163,103,93,131,205,191,173,40,243,7,36,217,155,173,200,112,8,32,17,60,199,165,93,92,98,243,145,8,154,39,173,115,242,94,95,113,195,19,146,35,135,93,101,80,166,71,63,245,29,6,188,47,127,132,99,233,143,30,67,198,66,180,114,54,255,156,73,177,234,255,125,51,31,34,218,18,115,44,230,182,113,255,22,207,174,247,216,252,81,170,88,181,16,140,138,74,228,76,217,40,182,181,237,179,163,44,203,92,130,57,31,252,51,202,35,60,202,126,6,92,141,146,94,119,205,251,141,33,156,226,22,16,79,101,255,183,184,122,134,105,196,104,210,147,18,32,248,81,164,18,115,119,174,132,88,32,224,212,199,141,163,150,46,196,247,33,110,128,233,222,14,212,31,132,39,77,42,41,82,239,181,57,88,242,240,132,229,72,216,20,64,50,162,244,141,70,32,160,77,157,136,23,128,164,43,151,241,148,39,43,168,159,184,230,154,86,215,236,144,10,211,221,7,20,11,242,164,197,147,67,166,53,196,146,106,158,163,7,127,227,160,139,74,164,244,77,123,62,206,206,175,103,76,116,18,176,15,40,112,106,123,118,52,87,155,36,80,220,183,81,187,252,220,88,249,102,33,194,94,131,143,27,81,61,119,31,68,115,63,36,24,12,74,242,98,221,157,107,63,246,221,230,40,208,83,239,147,184,80,48,195,40,127,254,131,119,127,225,78,127,5,23,241,100,129,117,247,61,55,149,90,12,12,72,126,152,225,215,167,172,120,73,137,2,216,27,110,34,225,67,186,93,195,103,93,11,102,13,145,143,49,92,141,73,18,98,129,115,195,140,71,211,253,159,174,156,30,32,249,24,100,95,203,252,86,142,240,93,193,36,50,154,130,102,128,10,11,9,35,182,85,205,121,132,116,38,155,228,131,35,83,238,156,81,41,100,253,157,189,215,76,151,86,129,212,130,136,125,181,144,76,121,208,4,94,84,172,28,250,106,149,78,203,230,185,223,176,161,6,152,121,67,247,167,200,248,198,148,147,58,184,13,149,122,43,134,161,184,158,198,215,97,37,210,174,62,8,146,242,179,28,48,4,112,80,107,38,105,176,52,105,128,198,156,235,120,223,217,188,186,15,180,35,132,53,143,83,255,169,122,134,96,80,244,44,117,233,136,87,139,90,173,197,222,184,174,164,205,177,67,156,123,49,245,63,71,142,76,57,241,249,251,76,197,73,180,53,176,180,125,81,122,89,143,239,239,203,184,70,73,31,146,173,139,97,229,250,101,209,88,244,197,205,37,74,10,73,244,182,20,88,236,113,167,65,191,122,54,51,211,137,69,238,143,178,69,35,27,157,189,150,61,62,12,171,231,135,57,163,59,13,25,105,84,183,120,25,174,197,35,1,246,140,250,237,40,104,167,239,225,224,121,122,166,51,193,246,73,82,73,165,15,232,196,22,166,146,59,136,189,185,217,239,9,233,235,44,106,225,214,45,239,235,9,255,214,101,201,126,47,239,191,246,223,237,74,224,9,2,76,145,154,27,237,251,85,72,116,253,164,139,134,126,227,240,34,217,129,119,69,49,207,28,84,41,187,117,165,65,183,47,3,188,86,202,75,145,172,193,49,44,156,219,163,229,103,211,109,131,83,22,102,171,24,47,251,35,122,82,239,63,1,66,98,60,114,192,68,244,84,77,149,185,146,2,66,167,92,177,60,15,170,30,119,78,40,103,175,128,236,229,227,180,197,79,176,30,163,234,240,75,94,157,56,56,245,34,122,39,116,191,253,155,95,106,179,140,233,120,193,137,205,170,211,55,195,63,174,193,152,223,201,20,134,114,135,180,92,19,234,144,28,15,212,140,231,129,51,146,137,38,42,83,218,133,113,29,174,52,183,149,125,23,230,130,114,207,14,116,33,131,106,116,144,14,143,118,172,206,78,185,5,101,65,209,0,190,55,148,18,11,108,88,179,16,138,254,15,239,228,17,252,239,120,8,73,104,46,196,34,196,164,250,186,165,246,107,95,254,228,97,114,222,234,232,96,96,20,174,246,169,223,138,34,167,220,89,30,45,254,137,100,135,32,186,250,57,213,0,193,5,250,76,118,172,184,139,108,136,97,210,58,63,117,88,39,70,48,239,224,185,195,28,8,207,169,107,157,196,239,226,227,4,61,52,17,25,152,8,114,153,172,180,223,12,62,189,11,102,112,59,138,55,193,221,198,14,35,128,254,74,59,208,234,187,147,81,147,153,197,172,209,82,60,77,224,36,252,169,133,56,105,76,70,15,142,242,151,225,188,233,44,160,173,109,142,126,12,248,88,241,164,171,97,201,134,81,178,106,104,38,76,96,47,193,137,121,61,217,57,76,219,181,36,206,118,234,14,143,105,247,106,142,135,34,99,62,65,52,84,165,20,236,115,216,94,23,137,185,212,48,13,68,96,172,154,154,10,223,18,48,205,194,150,185,171,143,55,122,53,222,232,85,77,244,232,3,54,239,48,246,189,30,191,255,193,122,234,62,178,154,180,52,101,234,61,141,82,198,72,97,119,136,166,91,78,66,92,131,225,127,119,25,205,251,184,120,194,214,81,234,52,94,80,105,11,144,221,56,189,37,4,66,141,239,149,148,184,106,75,39,50,84,58,97,145,213,62,127,140,167,241,175,86,65,195,210,125,247,185,164,189,125,117,43,187,203,90,43,35,184,139,125,47,234,227,138,253,164,245,15,134,8,214,216,19,241,209,171,12,195,1,105,35,215,161,59,17,181,38,2,55,129,116,95,15,158,163,170,239,157,233,123,168,124,4,1,136,141,105,3,4,135,184,70,137,250,73,4,128,208,178,172,110,206,240,232,45,27,235,24,134,38,61,49,158,134,64,208,90,68,203,101,20,95,245,103,117,144,62,253,178,57,76,175,211,217,20,167,253,219,166,194,8,23,103,97,96,141,121,14,163,2,179,43,21,127,216,111,165,200,84,144,250,219,249,24,229,135,235,10,58,54,230,222,177,227,145,121,69,11,236,19,175,236,71,230,139,144,168,8,45,237,217,80,4,246,53,150,36,192,210,182,210,101,237,19,76,41,144,61,145,213,217,99,173,229,138,84,98,193,189,35,202,253,176,185,20,128,190,249,88,13,25,111,59,214,19,87,154,196,157,244,152,101,248,198,83,7,162,69,200,254,115,125,58,91,141,240,96,110,47,174,149,169,97,21,197,158,75,204,63,182,18,21,68,39,97,182,200,162,39,189,99,81,92,27,23,1,241,78,113,92,194,69,26,33,22,47,110,114,142,142,131,104,26,6,22,90,141,23,152,153,200,83,221,98,3,74,105,99,199,21,185,230,143,231,254,250,62,146,133,43,175,97,43,35,68,77,68,126,37,16,42,111,70,107,76,123,200,19,92,64,241,63,184,160,126,152,157,50,117,27,34,77,1,242,101,85,215,158,97,205,220,84,112,85,110,208,210,220,166,249,152,34,76,82,154,242,177,51,122,80,45,246,101,247,81,74,188,177,162,125,247,147,200,62,83,96,71,209,201,100,93,29,238,144,51,151,255,139,46,174,196,140,6,243,186,118,241,181,53,112,204,74,212,177,17,209,217,203,203,104,172,127,35,162,77,61,64,168,39,183,108,202,96,18,114,253,153,122,149,102,136,129,236,235,222,177,107,139,9,202,247,92,179,229,207,137,152,163,234,21,27,196,63,168,170,90,42,165,156,11,144,251,165,173,165,102,247,192,84,247,203,110,27,2,25,66,56,130,191,132,142,148,176,147,56,114,95,100,118,197,173,150,176,128,38,88,255,6,123,26,75,106,235,246,21,29,212,240,186,36,89,79,87,116,200,52,133,123,89,121,24,112,184,178,115,81,17,76,11,207,181,29,5,217,87,165,27,173,204,42,238,251,189,62,132,44,141,40,84,143,109,245,118,59,204,103,161,47,47,163,168,110,101,7,188,157,226,50,115,151,109,99,1,180,54,52,71,192,180,207,203,16,147,26,204,223,137,47,93,83,50,117,29,67,171,220,125,135,247,163,80,152,99,156,100,150,29,89,90,216,117,158,44,208,172,180,204,76,235,157,151,21,172,34,80,30,61,78,29,46,95,177,36,130,99,107,152,35,147,98,108,47,124,162,137,177,235,165,238,255,44,142,42,125,73,35,47,80,215,115,158,13,221,91,243,2,124,34,49,208,98,246,143,129,167,126,104,175,125,106,181,215,113,125,42,21,144,7,203,194,56,74,8,70,57,137,73,43,199,117,144,190,197,196,126,140,130,28,146,29,68,198,139,210,253,93,138,210,193,10,194,184,112,139,55,253,108,26,188,212,167,192,63,76,223,255,8,115,67,89,220,209,22,112,221,254,30,198,207,60,53,207,188,150,176,89,220,181,156,161,109,42,157,35,200,52,208,52,206,15,145,89,136,71,152,137,43,82,250,180,74,74,146,68,243,219,131,252,230,173,208,24,34,246,192,201,105,235,15,254,70,221,167,32,179,221,33,63,37,182,82,212,63,194,215,173,100,125,36,147,161,68,160,106,96,115,19,162,203,225,196,23,103,131,191,71,177,239,224,94,116,243,124,83,148,0,222,198,216,187,23,187,248,117,162,170,178,91,217,16,203,136,101,55,247,109,211,54,126,68,82,212,72,86,140,147,33,144,218,124,201,87,228,174,195,12,11,25,160,200,214,117,4,31,237,219,40,112,116,0,253,245,109,51,254,176,232,33,225,166,77,40,223,238,224,70,23,167,92,64,21,95,170,231,166,42,13,101,160,78,185,179,193,59,109,167,23,180,24,0,54,122,19,33,151,57,132,174,113,2,2,178,87,209,30,252,220,108,177,33,122,18,58,98,22,187,206,26,26,80,94,225,76,35,97,198,208,192,223,34,164,254,173,36,17,135,144,2,156,166,42,112,89,184,54,160,191,38,235,105,157,175,113,220,55,22,229,25,35,192,31,150,186,98,89,246,109,80,202,35,63,70,165,21,63,141,153,153,184,155,72,199,240,176,6,217,216,168,224,77,52,132,155,130,48,191,100,75,165,13,200,229,203,244,61,126,98,28,61,127,163,152,18,135,227,250,3,252,92,239,69,110,100,70,137,20,0,0,122,7,213,179,185,90,134,189,118,107,52,57,54}; +static const uint8_t MOTA_VEC[5403] = {109,79,84,65,27,21,0,0,2,1,18,68,51,34,17,0,0,16,1,153,20,0,0,153,20,0,0,10,175,252,9,108,103,145,119,80,26,44,231,124,151,8,14,131,41,90,36,227,134,142,35,227,246,136,63,104,34,211,80,66,55,32,79,153,0,84,69,83,84,72,87,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,136,178,44,23,110,140,98,143,139,154,17,11,47,111,103,108,222,151,146,129,34,221,184,64,163,28,6,189,70,62,57,35,188,26,173,189,228,139,22,151,108,8,7,23,55,59,129,154,6,143,50,183,166,179,139,107,56,114,150,71,207,222,1,194,206,40,178,108,87,71,39,55,245,195,86,26,23,97,24,91,216,88,154,67,206,11,186,117,137,31,249,236,96,20,141,75,212,160,158,226,220,92,147,49,180,17,11,169,58,197,74,252,20,218,59,221,25,97,71,116,162,213,93,41,94,90,53,171,68,179,239,174,165,18,155,162,43,136,186,62,41,118,97,69,253,236,163,176,142,56,175,83,215,196,198,14,58,210,8,206,80,102,68,16,54,233,241,145,224,183,80,54,167,127,101,226,234,164,117,36,67,35,63,190,143,137,67,191,149,109,229,149,102,92,56,255,255,35,130,126,23,193,12,220,28,39,160,40,202,174,108,152,16,98,97,152,255,119,135,64,248,141,220,241,2,174,184,29,174,226,137,192,68,196,164,87,28,75,111,40,116,0,244,184,224,184,67,248,128,195,45,129,233,27,222,160,76,215,163,129,155,50,39,95,195,41,138,244,199,236,135,235,0,153,82,125,4,28,237,92,224,252,212,206,78,61,14,61,224,145,242,20,21,187,124,208,17,250,194,136,196,32,32,168,121,242,140,42,67,135,223,155,108,246,54,237,138,193,186,176,51,182,79,102,254,171,166,95,112,230,132,115,30,63,57,16,86,5,150,141,58,150,56,1,18,181,161,15,58,17,231,8,220,84,18,131,60,71,171,124,54,138,33,185,239,225,146,147,121,62,200,121,206,104,48,24,24,168,110,90,108,105,119,221,186,13,172,167,251,165,25,15,103,186,86,204,220,27,63,49,48,137,114,35,108,46,71,118,63,223,236,19,113,206,220,219,140,25,12,166,255,138,214,3,248,23,237,192,217,60,42,104,124,123,54,221,102,231,15,42,97,0,252,99,67,237,200,200,116,73,108,178,245,187,254,200,142,169,183,124,39,48,75,55,247,14,148,188,138,15,191,80,14,12,149,122,128,235,218,135,40,14,245,130,20,217,47,17,152,17,172,220,60,103,30,241,227,145,63,148,152,10,158,20,107,168,149,144,133,80,239,66,52,171,183,80,61,67,101,33,171,165,76,117,80,237,192,239,18,2,117,159,255,144,255,25,18,137,54,129,67,33,238,89,225,17,225,62,94,72,40,112,213,139,180,77,156,251,252,206,167,135,2,170,209,141,76,238,169,26,240,224,34,67,29,227,27,190,141,39,69,72,154,53,183,87,52,175,162,218,67,129,125,64,231,232,216,13,23,162,108,212,70,11,0,85,197,33,163,250,67,41,189,113,141,180,109,143,2,28,19,241,226,176,231,38,139,9,213,94,149,141,37,110,32,10,78,93,230,238,203,248,220,10,230,91,53,174,63,170,26,90,199,143,226,223,104,249,158,191,39,236,238,60,221,41,249,204,207,45,225,105,6,45,188,236,85,200,238,105,205,171,221,188,207,63,68,40,201,179,27,97,223,9,219,120,56,51,209,235,117,89,78,210,203,223,58,57,6,168,49,102,84,71,221,17,247,197,71,89,164,130,102,173,251,215,137,84,240,7,29,224,248,66,45,148,246,251,67,9,27,152,111,88,186,201,80,111,155,251,130,29,98,230,147,48,65,11,181,111,0,133,236,206,137,175,184,240,189,188,171,50,93,110,17,242,170,235,84,159,80,169,217,31,184,230,76,129,79,170,104,83,103,178,75,141,32,49,107,170,240,97,173,191,231,44,157,145,77,103,140,213,0,77,73,53,110,201,148,155,167,82,119,113,113,172,54,130,121,203,230,245,203,188,43,168,21,72,131,169,162,158,85,23,209,243,192,60,172,79,57,206,50,37,6,11,62,251,121,156,217,196,18,116,106,226,161,147,49,183,178,98,126,102,62,37,167,176,1,228,192,220,197,226,27,199,108,56,45,205,245,178,132,118,12,142,63,234,217,31,116,34,205,118,170,135,252,143,152,81,243,193,228,113,156,208,184,228,129,109,212,232,140,114,229,40,190,220,121,115,66,192,63,215,163,70,196,199,133,124,160,61,70,112,19,182,73,60,69,85,81,228,138,20,35,38,59,98,177,39,180,54,16,106,104,84,138,119,106,15,52,213,107,99,231,197,149,242,178,5,219,225,195,147,97,122,1,241,90,76,192,99,218,228,244,213,107,137,191,188,139,204,154,229,56,124,56,69,111,124,7,99,86,171,173,204,103,185,42,215,119,235,32,251,159,136,6,232,100,151,144,169,6,21,164,109,34,221,118,46,12,66,97,83,54,116,83,86,194,225,97,71,192,243,212,107,64,213,20,120,4,191,138,13,255,243,89,57,166,17,199,245,166,10,193,7,243,63,51,214,5,159,39,61,32,121,171,29,144,242,55,119,179,65,196,94,42,155,155,246,191,183,29,199,209,41,246,79,27,148,6,237,79,147,173,232,245,96,101,241,183,50,19,151,176,212,160,62,26,178,197,77,217,175,153,206,30,203,251,144,200,10,88,136,109,169,94,17,129,165,87,3,217,107,210,125,27,110,245,92,162,228,212,117,181,39,111,45,187,133,247,166,69,157,206,235,137,198,123,119,111,211,187,151,68,82,218,62,212,239,22,71,225,115,62,192,118,145,156,171,97,86,7,126,217,83,46,124,54,90,204,66,87,71,225,152,179,225,70,142,2,132,242,48,21,61,184,104,125,142,194,61,176,121,165,182,125,114,202,4,23,75,56,103,177,62,78,169,148,94,121,141,135,88,108,255,190,140,84,90,179,116,69,78,64,59,30,184,49,80,30,190,137,243,195,176,47,49,55,189,123,70,185,150,250,194,134,152,72,251,25,213,49,75,58,92,45,77,3,181,136,32,70,11,249,13,141,74,178,241,32,163,222,192,125,26,223,3,146,72,120,122,112,87,47,247,13,64,240,220,122,29,210,16,102,125,18,147,161,175,13,38,38,207,144,242,77,21,254,63,30,142,195,106,155,152,202,158,57,198,133,97,115,232,113,76,220,150,253,109,78,145,158,15,156,245,189,25,242,195,53,160,54,67,169,20,40,61,44,141,19,40,0,104,115,176,152,120,74,8,59,73,180,72,179,220,116,18,175,59,236,67,201,202,160,150,169,205,239,50,108,29,139,57,165,38,232,68,211,36,18,15,42,202,78,152,191,211,145,235,73,112,31,119,176,77,179,103,241,69,128,138,126,112,20,153,10,227,110,188,82,154,64,6,23,58,246,172,214,220,147,150,243,5,255,195,172,210,68,147,10,195,193,44,120,132,166,113,234,71,46,255,149,111,162,208,125,248,23,120,89,104,85,82,171,26,219,41,84,105,177,126,73,169,241,102,208,194,140,9,116,22,80,64,82,29,248,197,103,221,131,211,252,0,168,222,138,118,105,13,48,132,92,159,193,127,160,113,194,13,52,68,140,33,237,73,112,225,178,124,31,7,249,161,155,204,61,181,40,79,141,3,141,104,23,57,254,215,233,29,118,242,30,165,213,39,127,238,183,74,130,180,69,106,213,123,250,120,62,116,141,37,98,48,235,153,130,191,225,34,221,17,70,197,202,218,106,87,239,201,129,68,210,0,72,185,76,214,150,148,255,168,125,221,38,114,137,123,88,85,141,195,139,96,116,238,82,222,48,251,178,61,146,98,59,219,198,105,11,81,190,121,180,233,207,97,98,253,169,202,210,166,251,38,126,246,9,32,128,247,151,84,222,25,223,216,112,25,134,233,116,3,184,36,104,222,167,248,39,19,120,200,248,67,86,159,177,101,166,20,218,84,218,172,219,136,97,244,81,160,183,227,194,124,223,138,9,158,17,60,161,175,235,73,255,58,191,23,111,250,25,194,162,180,223,25,113,42,177,76,231,7,11,83,203,14,75,91,95,110,37,62,135,105,144,174,202,46,43,44,20,156,222,97,158,174,61,127,233,149,36,59,118,163,65,117,65,170,2,230,205,119,230,73,173,139,40,18,113,241,88,252,150,76,163,246,108,176,64,116,216,77,50,255,98,218,123,27,60,97,146,91,147,75,254,179,75,5,250,212,168,101,70,2,144,221,175,199,190,249,12,233,155,190,127,213,231,231,73,198,204,58,155,205,90,56,162,48,158,64,173,193,184,196,168,174,214,35,160,24,231,160,165,10,79,201,112,8,148,93,187,33,23,232,75,83,191,106,44,51,33,201,138,224,248,93,135,128,233,69,212,42,65,233,211,241,123,247,206,75,191,222,86,205,29,119,246,19,36,193,247,57,220,173,185,172,250,101,247,216,205,142,93,23,202,101,3,67,137,31,116,94,172,191,172,67,149,97,210,163,240,95,27,172,59,120,6,158,226,241,143,83,234,156,56,165,16,162,210,118,232,179,77,166,104,29,35,11,242,9,77,254,126,29,24,60,227,137,34,99,116,94,171,243,190,178,242,138,107,150,190,186,39,226,106,167,25,213,125,157,104,240,243,71,8,176,94,55,113,113,243,60,218,92,25,251,175,94,139,230,250,165,91,15,101,70,48,247,31,242,217,210,116,23,169,54,164,163,152,248,5,12,201,85,62,253,32,201,144,52,17,212,195,141,53,150,55,208,222,59,84,198,37,201,230,152,0,70,219,251,37,252,33,138,64,204,44,28,169,221,6,33,3,91,202,201,60,150,82,4,44,67,13,32,189,107,134,29,190,16,121,114,199,92,131,151,27,115,128,56,242,157,11,186,200,232,221,168,133,77,117,164,246,7,15,255,122,216,102,109,175,27,125,182,232,113,18,230,20,82,155,37,16,32,70,159,162,149,140,182,83,97,254,152,135,75,116,129,154,110,25,203,179,29,218,167,166,224,196,141,184,221,55,110,115,227,58,105,86,211,116,102,106,186,24,80,109,80,170,65,95,244,39,175,236,121,17,23,212,21,23,110,24,190,189,95,207,33,142,15,150,244,143,143,84,171,31,105,90,223,170,240,192,108,222,234,184,13,247,73,153,79,90,26,147,129,54,39,168,123,57,216,27,89,216,142,94,29,195,71,146,57,206,109,216,143,249,196,209,159,157,172,164,142,6,155,237,168,212,177,68,7,46,69,179,195,79,235,86,89,1,46,222,36,144,168,102,17,36,189,162,248,7,23,191,135,55,96,107,116,87,40,94,79,184,83,198,241,145,152,21,226,13,39,40,193,158,12,172,20,69,113,169,108,124,155,113,106,69,55,193,131,29,88,110,28,72,173,173,151,124,134,170,78,11,56,101,252,153,14,1,52,77,242,54,196,35,195,65,74,83,30,1,127,191,110,44,33,97,136,180,58,128,143,213,171,206,90,18,101,220,189,10,111,4,117,235,19,220,80,147,109,146,103,181,163,106,74,29,103,5,247,83,43,205,242,158,117,212,176,235,92,22,111,216,27,62,111,150,102,134,20,101,222,79,190,86,56,85,199,43,19,130,162,29,135,130,49,231,198,89,89,186,245,209,165,208,37,60,26,37,65,50,44,154,39,194,194,167,19,45,243,197,160,126,118,193,144,194,148,114,174,236,225,144,164,162,252,159,82,221,248,160,80,38,112,17,120,113,161,77,203,70,151,14,90,129,18,79,118,115,9,14,94,212,73,19,165,221,250,218,23,157,152,129,98,118,148,141,244,202,189,229,10,115,232,207,146,166,48,82,154,121,128,38,245,15,115,26,207,230,214,87,251,182,21,129,165,44,10,63,181,112,253,112,134,133,156,40,93,95,234,72,99,104,198,86,173,153,13,202,161,165,85,16,84,24,142,173,98,72,64,185,218,168,246,232,154,223,38,85,20,149,169,36,234,89,79,247,167,178,169,100,33,152,181,240,21,79,143,96,164,202,84,208,32,171,179,212,242,189,255,175,233,134,23,165,171,108,130,92,4,92,79,46,243,54,87,242,196,124,49,57,255,35,39,19,75,216,201,25,129,197,138,213,189,226,134,9,169,86,224,196,158,33,152,96,39,41,46,212,177,197,159,207,231,42,184,112,11,105,93,173,184,60,248,113,156,72,192,191,200,114,59,136,61,79,247,207,200,120,231,213,49,94,173,242,146,252,112,118,196,72,199,97,128,135,107,247,41,209,51,205,154,35,223,64,13,164,123,223,95,141,239,26,182,216,132,217,31,72,21,195,41,69,115,231,131,37,212,111,23,242,233,56,209,115,226,89,238,6,106,13,101,128,95,60,98,254,20,95,57,7,81,238,25,214,182,166,85,202,37,35,9,73,234,212,120,178,212,35,194,180,120,114,157,1,231,20,4,65,55,213,38,140,240,186,155,135,108,28,198,73,60,77,31,12,61,107,163,203,159,117,16,28,214,231,127,152,137,4,161,131,147,61,183,36,74,109,0,157,90,61,146,106,47,170,171,21,134,249,92,17,244,134,139,129,201,253,129,141,5,99,223,120,11,162,99,251,95,64,191,4,91,201,17,88,61,187,168,160,26,197,148,188,193,85,34,11,90,139,86,208,164,44,212,199,175,118,251,178,122,161,46,207,34,16,183,198,241,117,9,75,51,11,202,51,226,10,80,238,79,131,101,253,208,139,121,64,9,192,165,48,73,91,220,199,12,221,167,84,69,31,204,94,111,227,102,190,112,229,244,98,86,249,47,127,177,127,94,236,204,132,68,205,21,186,108,20,110,154,254,210,46,139,75,82,26,20,83,169,75,78,114,154,183,109,42,176,113,89,114,10,186,222,233,90,157,255,111,70,163,250,202,242,14,19,171,163,103,93,131,205,191,173,40,243,7,36,217,155,173,200,112,8,32,17,60,199,165,93,92,98,243,145,8,154,39,173,115,242,94,95,113,195,19,146,35,135,93,101,80,166,71,63,245,29,6,188,47,127,132,99,233,143,30,67,198,66,180,114,54,255,156,73,177,234,255,125,51,31,34,218,18,115,44,230,182,113,255,22,207,174,247,216,252,81,170,88,181,16,140,138,74,228,76,217,40,182,181,237,179,163,44,203,92,130,57,31,252,51,202,35,60,202,126,6,92,141,146,94,119,205,251,141,33,156,226,22,16,79,101,255,183,184,122,134,105,196,104,210,147,18,32,248,81,164,18,115,119,174,132,88,32,224,212,199,141,163,150,46,196,247,33,110,128,233,222,14,212,31,132,39,77,42,41,82,239,181,57,88,242,240,132,229,72,216,20,64,50,162,244,141,70,32,160,77,157,136,23,128,164,43,151,241,148,39,43,168,159,184,230,154,86,215,236,144,10,211,221,7,20,11,242,164,197,147,67,166,53,196,146,106,158,163,7,127,227,160,139,74,164,244,77,123,62,206,206,175,103,76,116,18,176,15,40,112,106,123,118,52,87,155,36,80,220,183,81,187,252,220,88,249,102,33,194,94,131,143,27,81,61,119,31,68,115,63,36,24,12,74,242,98,221,157,107,63,246,221,230,40,208,83,239,147,184,80,48,195,40,127,254,131,119,127,225,78,127,5,23,241,100,129,117,247,61,55,149,90,12,12,72,126,152,225,215,167,172,120,73,137,2,216,27,110,34,225,67,186,93,195,103,93,11,102,13,145,143,49,92,141,73,18,98,129,115,195,140,71,211,253,159,174,156,30,32,249,24,100,95,203,252,86,142,240,93,193,36,50,154,130,102,128,10,11,9,35,182,85,205,121,132,116,38,155,228,131,35,83,238,156,81,41,100,253,157,189,215,76,151,86,129,212,130,136,125,181,144,76,121,208,4,94,84,172,28,250,106,149,78,203,230,185,223,176,161,6,152,121,67,247,167,200,248,198,148,147,58,184,13,149,122,43,134,161,184,158,198,215,97,37,210,174,62,8,146,242,179,28,48,4,112,80,107,38,105,176,52,105,128,198,156,235,120,223,217,188,186,15,180,35,132,53,143,83,255,169,122,134,96,80,244,44,117,233,136,87,139,90,173,197,222,184,174,164,205,177,67,156,123,49,245,63,71,142,76,57,241,249,251,76,197,73,180,53,176,180,125,81,122,89,143,239,239,203,184,70,73,31,146,173,139,97,229,250,101,209,88,244,197,205,37,74,10,73,244,182,20,88,236,113,167,65,191,122,54,51,211,137,69,238,143,178,69,35,27,157,189,150,61,62,12,171,231,135,57,163,59,13,25,105,84,183,120,25,174,197,35,1,246,140,250,237,40,104,167,239,225,224,121,122,166,51,193,246,73,82,73,165,15,232,196,22,166,146,59,136,189,185,217,239,9,233,235,44,106,225,214,45,239,235,9,255,214,101,201,126,47,239,191,246,223,237,74,224,9,2,76,145,154,27,237,251,85,72,116,253,164,139,134,126,227,240,34,217,129,119,69,49,207,28,84,41,187,117,165,65,183,47,3,188,86,202,75,145,172,193,49,44,156,219,163,229,103,211,109,131,83,22,102,171,24,47,251,35,122,82,239,63,1,66,98,60,114,192,68,244,84,77,149,185,146,2,66,167,92,177,60,15,170,30,119,78,40,103,175,128,236,229,227,180,197,79,176,30,163,234,240,75,94,157,56,56,245,34,122,39,116,191,253,155,95,106,179,140,233,120,193,137,205,170,211,55,195,63,174,193,152,223,201,20,134,114,135,180,92,19,234,144,28,15,212,140,231,129,51,146,137,38,42,83,218,133,113,29,174,52,183,149,125,23,230,130,114,207,14,116,33,131,106,116,144,14,143,118,172,206,78,185,5,101,65,209,0,190,55,148,18,11,108,88,179,16,138,254,15,239,228,17,252,239,120,8,73,104,46,196,34,196,164,250,186,165,246,107,95,254,228,97,114,222,234,232,96,96,20,174,246,169,223,138,34,167,220,89,30,45,254,137,100,135,32,186,250,57,213,0,193,5,250,76,118,172,184,139,108,136,97,210,58,63,117,88,39,70,48,239,224,185,195,28,8,207,169,107,157,196,239,226,227,4,61,52,17,25,152,8,114,153,172,180,223,12,62,189,11,102,112,59,138,55,193,221,198,14,35,128,254,74,59,208,234,187,147,81,147,153,197,172,209,82,60,77,224,36,252,169,133,56,105,76,70,15,142,242,151,225,188,233,44,160,173,109,142,126,12,248,88,241,164,171,97,201,134,81,178,106,104,38,76,96,47,193,137,121,61,217,57,76,219,181,36,206,118,234,14,143,105,247,106,142,135,34,99,62,65,52,84,165,20,236,115,216,94,23,137,185,212,48,13,68,96,172,154,154,10,223,18,48,205,194,150,185,171,143,55,122,53,222,232,85,77,244,232,3,54,239,48,246,189,30,191,255,193,122,234,62,178,154,180,52,101,234,61,141,82,198,72,97,119,136,166,91,78,66,92,131,225,127,119,25,205,251,184,120,194,214,81,234,52,94,80,105,11,144,221,56,189,37,4,66,141,239,149,148,184,106,75,39,50,84,58,97,145,213,62,127,140,167,241,175,86,65,195,210,125,247,185,164,189,125,117,43,187,203,90,43,35,184,139,125,47,234,227,138,253,164,245,15,134,8,214,216,19,241,209,171,12,195,1,105,35,215,161,59,17,181,38,2,55,129,116,95,15,158,163,170,239,157,233,123,168,124,4,1,136,141,105,3,4,135,184,70,137,250,73,4,128,208,178,172,110,206,240,232,45,27,235,24,134,38,61,49,158,134,64,208,90,68,203,101,20,95,245,103,117,144,62,253,178,57,76,175,211,217,20,167,253,219,166,194,8,23,103,97,96,141,121,14,163,2,179,43,21,127,216,111,165,200,84,144,250,219,249,24,229,135,235,10,58,54,230,222,177,227,145,121,69,11,236,19,175,236,71,230,139,144,168,8,45,237,217,80,4,246,53,150,36,192,210,182,210,101,237,19,76,41,144,61,145,213,217,99,173,229,138,84,98,193,189,35,202,253,176,185,20,128,190,249,88,13,25,111,59,214,19,87,154,196,157,244,152,101,248,198,83,7,162,69,200,254,115,125,58,91,141,240,96,110,47,174,149,169,97,21,197,158,75,204,63,182,18,21,68,39,97,182,200,162,39,189,99,81,92,27,23,1,241,78,113,92,194,69,26,33,22,47,110,114,142,142,131,104,26,6,22,90,141,23,152,153,200,83,221,98,3,74,105,99,199,21,185,230,143,231,254,250,62,146,133,43,175,97,43,35,68,77,68,126,37,16,42,111,70,107,76,123,200,19,92,64,241,63,184,160,126,152,157,50,117,27,34,77,1,242,101,85,215,158,97,205,220,84,112,85,110,208,210,220,166,249,152,34,76,82,154,242,177,51,122,80,45,246,101,247,81,74,188,177,162,125,247,147,200,62,83,96,71,209,201,100,93,29,238,144,51,151,255,139,46,174,196,140,6,243,186,118,241,181,53,112,204,74,212,177,17,209,217,203,203,104,172,127,35,162,77,61,64,168,39,183,108,202,96,18,114,253,153,122,149,102,136,129,236,235,222,177,107,139,9,202,247,92,179,229,207,137,152,163,234,21,27,196,63,168,170,90,42,165,156,11,144,251,165,173,165,102,247,192,84,247,203,110,27,2,25,66,56,130,191,132,142,148,176,147,56,114,95,100,118,197,173,150,176,128,38,88,255,6,123,26,75,106,235,246,21,29,212,240,186,36,89,79,87,116,200,52,133,123,89,121,24,112,184,178,115,81,17,76,11,207,181,29,5,217,87,165,27,173,204,42,238,251,189,62,132,44,141,40,84,143,109,245,118,59,204,103,161,47,47,163,168,110,101,7,188,157,226,50,115,151,109,99,1,180,54,52,71,192,180,207,203,16,147,26,204,223,137,47,93,83,50,117,29,67,171,220,125,135,247,163,80,152,99,156,100,150,29,89,90,216,117,158,44,208,172,180,204,76,235,157,151,21,172,34,80,30,61,78,29,46,95,177,36,130,99,107,152,35,147,98,108,47,124,162,137,177,235,165,238,255,44,142,42,125,73,35,47,80,215,115,158,13,221,91,243,2,124,34,49,208,98,246,143,129,167,126,104,175,125,106,181,215,113,125,42,21,144,7,203,194,56,74,8,70,57,137,73,43,199,117,144,190,197,196,126,140,130,28,146,29,68,198,139,210,253,93,138,210,193,10,194,184,112,139,55,253,108,26,188,212,167,192,63,76,223,255,8,115,67,89,220,209,22,112,221,254,30,198,207,60,53,207,188,150,176,89,220,181,156,161,109,42,157,35,200,52,208,52,206,15,145,89,136,71,152,137,43,82,250,180,74,74,146,68,243,219,131,252,230,173,208,24,34,246,192,201,105,235,15,254,70,221,167,32,179,221,33,63,37,182,82,212,63,194,215,173,100,125,36,147,161,68,160,106,96,115,19,162,203,225,196,23,103,131,191,71,177,239,224,94,116,243,124,83,148,0,222,198,216,187,23,187,248,117,162,170,178,91,217,16,203,136,101,55,247,109,211,54,126,68,82,212,72,86,140,147,33,144,218,124,201,87,228,174,195,12,11,25,160,200,214,117,4,31,237,219,40,112,116,0,253,245,109,51,254,176,232,33,225,166,77,40,223,238,224,70,23,167,92,64,21,95,170,231,166,42,13,101,160,78,185,179,193,59,109,167,23,180,24,0,54,122,19,33,151,57,132,174,113,2,2,178,87,209,30,252,220,108,177,33,122,18,58,98,22,187,206,26,26,80,94,225,76,35,97,198,208,192,223,34,164,254,173,36,17,135,144,2,156,166,42,112,89,184,54,160,191,38,235,105,157,175,113,220,55,22,229,25,35,192,31,150,186,98,89,246,109,80,202,35,63,70,165,21,63,141,153,153,184,155,72,199,240,176,6,217,216,168,224,77,52,132,155,130,48,191,100,75,165,13,200,229,203,244,61,126,98,28,61,127,163,152,18,135,227,250,3,252,92,239,69,110,100,70,137,20,0,0,122,7,213,179,185,90,134,189,118,107,52,57,54}; -static const uint32_t MOTA_VEC_LEN = 5371; +static const uint32_t MOTA_VEC_LEN = 5403; static const uint32_t EXP_TARGET_ID = 0x11223344u; static const uint32_t EXP_FW_VERSION = 0x01100000u; static const uint32_t EXP_IMAGE_SIZE = 5273u; @@ -17,6 +17,8 @@ static const uint8_t EXP_MERKLE_ROOT[4] = {175,252,9,108}; static const uint8_t EXP_IMAGE_HASH[32] = {103,145,119,80,26,44,231,124,151,8,14,131,41,90,36,227,134,142,35,227,246,136,63,104,34,211,80,66,55,32,79,153}; +static const uint8_t EXP_HW_ID[32] = {84,69,83,84,72,87,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}; + static const uint32_t PROOF_INDEX = 2u; static const uint8_t PROOF_NSIB = 3; static const uint8_t PROOF_SIBLINGS[12] = {47,111,103,108,64,78,181,132,227,162,219,122}; @@ -88,10 +90,15 @@ struct ProofCase { uint32_t count; const uint8_t* leaves; const uint8_t* root; c static const ProofCase PROOF_CASES[] = {{T0_COUNT,T0_LEAVES,T0_ROOT,T0_POFF,T0_PNSIB,T0_PBLOB},{T1_COUNT,T1_LEAVES,T1_ROOT,T1_POFF,T1_PNSIB,T1_PBLOB},{T2_COUNT,T2_LEAVES,T2_ROOT,T2_POFF,T2_PNSIB,T2_PBLOB},{T3_COUNT,T3_LEAVES,T3_ROOT,T3_POFF,T3_PNSIB,T3_PBLOB},{T4_COUNT,T4_LEAVES,T4_ROOT,T4_POFF,T4_PNSIB,T4_PBLOB},{T5_COUNT,T5_LEAVES,T5_ROOT,T5_POFF,T5_PNSIB,T5_PBLOB},{T6_COUNT,T6_LEAVES,T6_ROOT,T6_POFF,T6_PNSIB,T6_PBLOB}}; static const int N_PROOF_CASES = 7; // small-block signed .mota for the OtaManager host transfer simulation -static const uint8_t SIM_MOTA[2250] = {109,79,84,65,202,8,0,0,1,3,18,190,186,254,202,0,0,0,3,224,7,0,0,224,7,0,0,7,85,156,8,12,169,195,206,175,23,28,248,7,221,23,193,55,91,158,160,26,154,55,205,197,206,22,121,30,239,123,194,48,85,211,13,168,0,3,161,7,191,243,206,16,190,29,112,221,24,231,75,192,153,103,228,214,48,155,165,13,95,29,220,134,100,18,85,49,184,226,220,156,104,50,61,218,240,202,68,220,194,22,110,88,30,125,55,174,55,215,104,138,31,250,53,196,215,49,137,139,13,160,147,113,17,10,159,226,81,187,200,59,244,246,151,12,196,105,154,73,105,89,89,213,132,192,193,157,48,111,4,186,15,255,255,255,255,95,197,98,90,98,17,7,154,48,52,162,55,106,72,59,84,136,235,135,81,243,8,241,172,4,174,251,120,121,213,89,254,16,213,235,145,157,150,188,132,125,7,184,29,126,203,177,170,175,71,66,193,125,16,43,58,126,48,74,90,174,73,65,111,103,97,51,153,45,58,63,34,194,22,64,186,98,135,175,179,137,22,240,159,125,51,107,184,156,150,55,202,230,201,95,210,99,220,174,54,38,118,169,45,217,145,86,21,242,183,135,173,198,22,120,80,103,10,68,142,1,108,52,92,253,238,136,20,118,132,230,50,155,118,250,13,91,33,55,243,235,183,41,191,202,113,152,236,251,45,241,128,169,45,9,28,5,43,121,175,209,107,20,239,108,161,22,36,79,158,1,97,84,34,133,103,51,230,201,249,14,163,54,113,65,139,103,211,195,32,90,240,177,121,236,14,33,189,221,183,153,199,191,38,155,240,104,31,24,30,36,75,44,253,45,104,37,236,53,225,143,81,197,127,204,59,216,65,30,219,210,92,47,173,18,128,73,2,149,213,12,86,55,96,102,86,210,207,102,98,106,208,94,136,23,46,225,9,236,95,184,219,102,197,113,58,79,218,167,57,167,184,79,27,223,176,184,80,55,195,207,182,172,182,63,98,75,112,192,245,20,225,112,107,246,232,65,17,128,75,232,175,223,82,201,122,136,32,238,229,65,110,225,74,104,247,168,211,156,40,72,97,173,173,154,220,252,158,170,182,112,49,97,86,227,50,88,1,170,90,177,75,246,217,49,228,170,170,43,202,163,66,168,106,190,192,221,180,59,104,220,220,88,168,102,0,28,128,136,51,0,133,79,243,172,250,191,253,42,130,200,206,113,238,52,67,191,217,189,106,119,143,135,160,158,196,161,212,195,155,196,73,51,189,5,255,26,206,78,19,148,8,246,14,106,76,94,248,142,160,242,181,150,147,75,49,193,25,253,221,203,101,102,202,44,67,117,84,32,224,33,230,114,222,217,177,179,99,107,143,244,183,45,92,64,53,70,118,146,216,227,179,97,47,211,233,239,255,122,146,228,160,227,234,121,170,43,250,19,136,61,255,117,133,3,29,84,203,126,18,85,167,187,1,145,174,136,70,162,3,149,81,105,66,42,34,150,235,12,101,9,12,87,130,58,142,193,74,40,214,112,14,178,198,106,58,206,48,29,124,43,69,70,168,93,18,143,235,50,198,143,44,179,90,167,245,208,136,20,190,173,29,185,32,211,35,38,139,22,8,194,131,165,97,42,90,222,200,183,59,157,155,194,247,156,41,87,197,13,228,87,207,160,111,48,78,45,189,28,37,127,164,247,133,187,238,3,59,96,234,174,203,24,200,203,253,60,145,113,187,202,154,223,227,20,224,209,41,224,227,214,42,198,113,95,64,80,150,11,149,101,5,76,242,40,33,241,2,225,73,207,142,75,74,31,126,109,205,18,10,90,41,155,174,81,216,85,169,83,206,212,247,8,96,208,69,156,58,140,38,177,172,4,195,220,240,73,189,102,107,131,183,230,198,58,44,134,104,253,13,54,61,165,66,41,215,145,24,111,1,144,241,7,214,51,33,230,244,46,161,76,15,253,225,65,46,148,195,110,38,0,238,164,9,128,101,239,253,250,114,18,42,150,59,87,111,33,122,234,156,23,94,128,40,59,187,146,124,53,183,218,25,111,156,51,231,204,44,34,121,188,229,63,44,215,175,65,42,166,132,172,204,129,234,222,217,139,19,60,61,134,58,175,180,107,101,134,143,18,61,64,82,196,144,85,126,48,24,203,160,7,241,9,111,15,80,48,236,12,169,74,47,68,230,236,171,65,2,147,135,75,50,185,93,29,40,88,99,36,178,78,135,66,50,115,14,241,245,6,90,59,36,155,20,213,220,97,4,91,240,81,227,96,62,137,39,199,18,5,7,45,54,56,87,158,65,194,241,18,165,0,111,29,168,111,20,177,216,249,186,94,127,108,142,224,96,196,143,231,165,67,83,182,88,206,29,69,135,62,16,238,8,56,68,83,158,26,69,224,78,159,159,62,54,113,170,113,133,4,154,190,67,19,198,98,31,188,4,72,126,195,241,230,206,180,113,109,203,237,193,82,130,255,60,154,177,136,236,223,221,27,234,73,101,198,53,17,151,236,71,116,190,125,86,255,152,21,178,5,12,144,21,162,33,220,104,49,108,226,169,168,150,148,4,5,31,204,82,55,52,170,163,254,27,3,255,110,212,240,224,73,46,214,34,209,198,176,73,255,45,180,106,26,79,215,47,192,107,98,161,24,19,68,242,134,77,64,217,252,186,40,192,70,186,31,8,191,154,212,17,64,121,100,32,227,74,153,123,55,216,255,220,197,128,78,212,114,64,128,64,162,47,117,72,38,48,84,130,32,205,128,58,157,90,154,132,18,58,142,111,200,37,146,0,86,185,201,187,102,148,203,21,2,108,22,108,117,27,21,1,49,222,238,22,198,61,76,49,29,37,187,66,181,79,44,211,142,173,164,48,22,157,28,82,6,247,214,55,76,103,85,129,10,113,166,247,64,122,138,157,89,55,89,33,11,179,83,178,233,141,241,172,233,59,217,53,207,204,160,133,154,108,183,121,245,41,154,88,142,45,186,132,216,204,136,78,5,4,155,151,88,39,32,111,11,17,136,81,244,172,142,74,213,92,44,141,84,177,244,197,176,25,186,140,75,8,170,232,87,108,50,162,40,10,44,160,23,121,100,109,222,139,139,97,95,151,164,213,236,96,66,26,235,52,251,199,90,34,16,67,174,25,235,1,94,99,80,151,60,151,31,154,170,250,74,254,238,70,104,229,33,34,170,227,251,215,33,73,248,179,169,200,196,219,242,6,140,130,136,67,36,63,105,134,141,32,67,77,111,190,98,23,214,246,88,7,229,125,58,122,166,16,127,225,77,61,199,129,253,9,32,116,207,202,17,236,90,143,244,33,247,164,237,253,90,198,189,137,250,10,16,3,212,55,185,188,238,3,115,51,164,134,195,117,127,162,183,0,55,186,11,155,56,39,252,163,110,47,227,32,248,220,234,206,140,117,73,213,155,5,197,212,184,208,8,77,14,160,8,175,63,145,100,217,95,240,194,127,195,34,72,146,89,178,228,38,176,235,8,201,84,147,210,95,137,215,51,170,106,244,173,67,45,131,65,122,215,20,99,181,105,184,253,66,245,155,77,122,0,142,44,197,249,7,179,64,59,44,231,172,224,244,182,215,4,40,135,220,56,76,65,82,100,91,102,41,226,67,95,200,42,1,39,140,118,118,71,188,214,123,161,81,187,161,45,83,145,36,176,157,12,128,102,142,171,150,39,203,245,119,54,128,30,203,176,101,252,84,197,108,136,80,84,250,55,180,16,22,57,249,160,194,63,203,253,98,40,248,62,182,104,245,244,58,87,20,47,15,185,14,22,161,120,97,55,186,249,67,112,86,129,1,244,46,167,142,254,163,109,152,223,164,111,81,70,188,245,43,159,236,36,123,145,95,176,215,34,244,123,253,113,118,46,116,175,132,124,152,152,76,137,114,157,118,199,25,123,8,247,235,230,172,116,49,31,88,125,2,97,111,227,242,80,212,15,186,50,32,224,200,31,238,232,140,109,243,138,238,193,253,222,0,28,32,150,190,48,34,244,56,197,196,64,5,88,180,249,15,245,11,196,51,9,49,56,240,154,254,52,54,132,192,1,47,118,67,121,90,212,93,88,234,230,86,62,74,4,165,115,26,42,148,244,10,210,223,239,225,54,204,223,102,234,204,72,197,208,214,94,87,47,181,173,120,85,167,77,201,165,122,165,126,3,120,7,65,137,154,23,84,27,251,239,7,243,108,182,237,236,95,176,233,237,180,0,44,95,91,93,150,86,172,69,245,81,234,190,228,243,239,236,67,99,42,154,211,59,170,79,213,233,246,137,20,22,171,123,18,201,20,174,51,28,88,99,220,82,250,56,219,62,13,214,33,141,64,205,143,101,42,215,166,1,222,87,80,48,96,68,127,188,203,45,43,224,181,148,200,224,171,5,168,15,39,8,78,252,90,120,37,15,35,73,166,90,148,66,63,80,237,61,15,63,64,110,238,28,211,153,21,206,143,45,0,163,151,175,49,81,125,98,92,35,255,157,214,250,223,198,59,54,238,109,208,130,116,71,106,246,221,224,50,114,241,58,227,192,72,132,9,183,55,223,87,248,119,200,188,52,25,141,180,183,9,44,8,253,50,69,220,217,111,3,18,22,43,4,63,22,31,19,17,20,4,185,220,235,68,204,223,209,95,199,95,44,113,98,153,12,109,46,120,50,131,118,82,236,19,90,44,176,79,168,210,78,153,10,163,55,171,51,11,131,141,80,191,166,60,156,119,28,69,220,141,106,190,54,31,8,41,123,205,30,202,192,46,51,33,43,160,109,215,19,150,232,243,219,67,20,200,61,53,213,132,196,237,137,4,173,157,112,173,155,116,158,69,190,147,248,64,132,112,33,143,197,191,249,118,137,88,239,250,192,122,192,62,202,131,155,222,27,224,34,17,222,231,164,17,43,154,60,226,223,135,32,134,149,107,194,168,74,204,4,72,98,47,171,92,224,231,153,13,18,153,50,192,130,191,64,92,33,243,39,161,69,110,100,70,208,7,0,0,250,86,224,170,102,212,176,242,118,107,52,57,54}; +static const uint8_t SIM_MOTA[2282] = {109,79,84,65,234,8,0,0,2,3,18,190,186,254,202,0,0,0,3,224,7,0,0,224,7,0,0,7,85,156,8,12,169,195,206,175,23,28,248,7,221,23,193,55,91,158,160,26,154,55,205,197,206,22,121,30,239,123,194,48,85,211,13,168,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,161,7,191,243,206,16,190,29,112,221,24,231,75,192,153,103,228,214,48,155,165,13,95,29,220,134,100,18,85,49,184,10,236,152,1,18,58,147,234,161,120,150,207,56,96,162,64,219,140,87,35,208,226,207,51,131,232,66,53,29,184,184,194,188,102,219,160,122,189,147,137,106,15,117,213,226,47,113,74,225,6,38,149,121,212,104,94,79,103,89,198,78,159,234,5,255,255,255,255,95,197,98,90,98,17,7,154,48,52,162,55,106,72,59,84,136,235,135,81,243,8,241,172,4,174,251,120,121,213,89,254,16,213,235,145,157,150,188,132,125,7,184,29,126,203,177,170,175,71,66,193,125,16,43,58,126,48,74,90,174,73,65,111,103,97,51,153,45,58,63,34,194,22,64,186,98,135,175,179,137,22,240,159,125,51,107,184,156,150,55,202,230,201,95,210,99,220,174,54,38,118,169,45,217,145,86,21,242,183,135,173,198,22,120,80,103,10,68,142,1,108,52,92,253,238,136,20,118,132,230,50,155,118,250,13,91,33,55,243,235,183,41,191,202,113,152,236,251,45,241,128,169,45,9,28,5,43,121,175,209,107,20,239,108,161,22,36,79,158,1,97,84,34,133,103,51,230,201,249,14,163,54,113,65,139,103,211,195,32,90,240,177,121,236,14,33,189,221,183,153,199,191,38,155,240,104,31,24,30,36,75,44,253,45,104,37,236,53,225,143,81,197,127,204,59,216,65,30,219,210,92,47,173,18,128,73,2,149,213,12,86,55,96,102,86,210,207,102,98,106,208,94,136,23,46,225,9,236,95,184,219,102,197,113,58,79,218,167,57,167,184,79,27,223,176,184,80,55,195,207,182,172,182,63,98,75,112,192,245,20,225,112,107,246,232,65,17,128,75,232,175,223,82,201,122,136,32,238,229,65,110,225,74,104,247,168,211,156,40,72,97,173,173,154,220,252,158,170,182,112,49,97,86,227,50,88,1,170,90,177,75,246,217,49,228,170,170,43,202,163,66,168,106,190,192,221,180,59,104,220,220,88,168,102,0,28,128,136,51,0,133,79,243,172,250,191,253,42,130,200,206,113,238,52,67,191,217,189,106,119,143,135,160,158,196,161,212,195,155,196,73,51,189,5,255,26,206,78,19,148,8,246,14,106,76,94,248,142,160,242,181,150,147,75,49,193,25,253,221,203,101,102,202,44,67,117,84,32,224,33,230,114,222,217,177,179,99,107,143,244,183,45,92,64,53,70,118,146,216,227,179,97,47,211,233,239,255,122,146,228,160,227,234,121,170,43,250,19,136,61,255,117,133,3,29,84,203,126,18,85,167,187,1,145,174,136,70,162,3,149,81,105,66,42,34,150,235,12,101,9,12,87,130,58,142,193,74,40,214,112,14,178,198,106,58,206,48,29,124,43,69,70,168,93,18,143,235,50,198,143,44,179,90,167,245,208,136,20,190,173,29,185,32,211,35,38,139,22,8,194,131,165,97,42,90,222,200,183,59,157,155,194,247,156,41,87,197,13,228,87,207,160,111,48,78,45,189,28,37,127,164,247,133,187,238,3,59,96,234,174,203,24,200,203,253,60,145,113,187,202,154,223,227,20,224,209,41,224,227,214,42,198,113,95,64,80,150,11,149,101,5,76,242,40,33,241,2,225,73,207,142,75,74,31,126,109,205,18,10,90,41,155,174,81,216,85,169,83,206,212,247,8,96,208,69,156,58,140,38,177,172,4,195,220,240,73,189,102,107,131,183,230,198,58,44,134,104,253,13,54,61,165,66,41,215,145,24,111,1,144,241,7,214,51,33,230,244,46,161,76,15,253,225,65,46,148,195,110,38,0,238,164,9,128,101,239,253,250,114,18,42,150,59,87,111,33,122,234,156,23,94,128,40,59,187,146,124,53,183,218,25,111,156,51,231,204,44,34,121,188,229,63,44,215,175,65,42,166,132,172,204,129,234,222,217,139,19,60,61,134,58,175,180,107,101,134,143,18,61,64,82,196,144,85,126,48,24,203,160,7,241,9,111,15,80,48,236,12,169,74,47,68,230,236,171,65,2,147,135,75,50,185,93,29,40,88,99,36,178,78,135,66,50,115,14,241,245,6,90,59,36,155,20,213,220,97,4,91,240,81,227,96,62,137,39,199,18,5,7,45,54,56,87,158,65,194,241,18,165,0,111,29,168,111,20,177,216,249,186,94,127,108,142,224,96,196,143,231,165,67,83,182,88,206,29,69,135,62,16,238,8,56,68,83,158,26,69,224,78,159,159,62,54,113,170,113,133,4,154,190,67,19,198,98,31,188,4,72,126,195,241,230,206,180,113,109,203,237,193,82,130,255,60,154,177,136,236,223,221,27,234,73,101,198,53,17,151,236,71,116,190,125,86,255,152,21,178,5,12,144,21,162,33,220,104,49,108,226,169,168,150,148,4,5,31,204,82,55,52,170,163,254,27,3,255,110,212,240,224,73,46,214,34,209,198,176,73,255,45,180,106,26,79,215,47,192,107,98,161,24,19,68,242,134,77,64,217,252,186,40,192,70,186,31,8,191,154,212,17,64,121,100,32,227,74,153,123,55,216,255,220,197,128,78,212,114,64,128,64,162,47,117,72,38,48,84,130,32,205,128,58,157,90,154,132,18,58,142,111,200,37,146,0,86,185,201,187,102,148,203,21,2,108,22,108,117,27,21,1,49,222,238,22,198,61,76,49,29,37,187,66,181,79,44,211,142,173,164,48,22,157,28,82,6,247,214,55,76,103,85,129,10,113,166,247,64,122,138,157,89,55,89,33,11,179,83,178,233,141,241,172,233,59,217,53,207,204,160,133,154,108,183,121,245,41,154,88,142,45,186,132,216,204,136,78,5,4,155,151,88,39,32,111,11,17,136,81,244,172,142,74,213,92,44,141,84,177,244,197,176,25,186,140,75,8,170,232,87,108,50,162,40,10,44,160,23,121,100,109,222,139,139,97,95,151,164,213,236,96,66,26,235,52,251,199,90,34,16,67,174,25,235,1,94,99,80,151,60,151,31,154,170,250,74,254,238,70,104,229,33,34,170,227,251,215,33,73,248,179,169,200,196,219,242,6,140,130,136,67,36,63,105,134,141,32,67,77,111,190,98,23,214,246,88,7,229,125,58,122,166,16,127,225,77,61,199,129,253,9,32,116,207,202,17,236,90,143,244,33,247,164,237,253,90,198,189,137,250,10,16,3,212,55,185,188,238,3,115,51,164,134,195,117,127,162,183,0,55,186,11,155,56,39,252,163,110,47,227,32,248,220,234,206,140,117,73,213,155,5,197,212,184,208,8,77,14,160,8,175,63,145,100,217,95,240,194,127,195,34,72,146,89,178,228,38,176,235,8,201,84,147,210,95,137,215,51,170,106,244,173,67,45,131,65,122,215,20,99,181,105,184,253,66,245,155,77,122,0,142,44,197,249,7,179,64,59,44,231,172,224,244,182,215,4,40,135,220,56,76,65,82,100,91,102,41,226,67,95,200,42,1,39,140,118,118,71,188,214,123,161,81,187,161,45,83,145,36,176,157,12,128,102,142,171,150,39,203,245,119,54,128,30,203,176,101,252,84,197,108,136,80,84,250,55,180,16,22,57,249,160,194,63,203,253,98,40,248,62,182,104,245,244,58,87,20,47,15,185,14,22,161,120,97,55,186,249,67,112,86,129,1,244,46,167,142,254,163,109,152,223,164,111,81,70,188,245,43,159,236,36,123,145,95,176,215,34,244,123,253,113,118,46,116,175,132,124,152,152,76,137,114,157,118,199,25,123,8,247,235,230,172,116,49,31,88,125,2,97,111,227,242,80,212,15,186,50,32,224,200,31,238,232,140,109,243,138,238,193,253,222,0,28,32,150,190,48,34,244,56,197,196,64,5,88,180,249,15,245,11,196,51,9,49,56,240,154,254,52,54,132,192,1,47,118,67,121,90,212,93,88,234,230,86,62,74,4,165,115,26,42,148,244,10,210,223,239,225,54,204,223,102,234,204,72,197,208,214,94,87,47,181,173,120,85,167,77,201,165,122,165,126,3,120,7,65,137,154,23,84,27,251,239,7,243,108,182,237,236,95,176,233,237,180,0,44,95,91,93,150,86,172,69,245,81,234,190,228,243,239,236,67,99,42,154,211,59,170,79,213,233,246,137,20,22,171,123,18,201,20,174,51,28,88,99,220,82,250,56,219,62,13,214,33,141,64,205,143,101,42,215,166,1,222,87,80,48,96,68,127,188,203,45,43,224,181,148,200,224,171,5,168,15,39,8,78,252,90,120,37,15,35,73,166,90,148,66,63,80,237,61,15,63,64,110,238,28,211,153,21,206,143,45,0,163,151,175,49,81,125,98,92,35,255,157,214,250,223,198,59,54,238,109,208,130,116,71,106,246,221,224,50,114,241,58,227,192,72,132,9,183,55,223,87,248,119,200,188,52,25,141,180,183,9,44,8,253,50,69,220,217,111,3,18,22,43,4,63,22,31,19,17,20,4,185,220,235,68,204,223,209,95,199,95,44,113,98,153,12,109,46,120,50,131,118,82,236,19,90,44,176,79,168,210,78,153,10,163,55,171,51,11,131,141,80,191,166,60,156,119,28,69,220,141,106,190,54,31,8,41,123,205,30,202,192,46,51,33,43,160,109,215,19,150,232,243,219,67,20,200,61,53,213,132,196,237,137,4,173,157,112,173,155,116,158,69,190,147,248,64,132,112,33,143,197,191,249,118,137,88,239,250,192,122,192,62,202,131,155,222,27,224,34,17,222,231,164,17,43,154,60,226,223,135,32,134,149,107,194,168,74,204,4,72,98,47,171,92,224,231,153,13,18,153,50,192,130,191,64,92,33,243,39,161,69,110,100,70,208,7,0,0,250,86,224,170,102,212,176,242,118,107,52,57,54}; -static const uint32_t SIM_MOTA_LEN = 2250; +static const uint32_t SIM_MOTA_LEN = 2282; static const uint32_t SIM_TARGET_ID = 0xcafebabeu; +// 1 KB-block signed .mota (multi-fragment per block) for the reassembly transfer test +static const uint8_t SIM_MOTA_1K[2226] = {109,79,84,65,178,8,0,0,2,3,18,190,186,254,202,0,0,0,3,224,7,0,0,224,7,0,0,10,141,137,148,65,169,195,206,175,23,28,248,7,221,23,193,55,91,158,160,26,154,55,205,197,206,22,121,30,239,123,194,48,85,211,13,168,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,161,7,191,243,206,16,190,29,112,221,24,231,75,192,153,103,228,214,48,155,165,13,95,29,220,134,100,18,85,49,184,86,37,208,73,247,78,10,175,137,157,152,31,13,197,254,154,244,76,218,60,107,47,204,6,35,236,179,222,6,186,219,31,25,4,204,133,252,129,92,9,170,81,104,32,162,53,42,224,0,80,253,196,208,99,214,76,157,180,57,31,197,116,45,1,255,255,255,255,226,102,124,83,175,206,204,60,103,97,51,153,45,58,63,34,194,22,64,186,98,135,175,179,137,22,240,159,125,51,107,184,156,150,55,202,230,201,95,210,99,220,174,54,38,118,169,45,217,145,86,21,242,183,135,173,198,22,120,80,103,10,68,142,1,108,52,92,253,238,136,20,118,132,230,50,155,118,250,13,91,33,55,243,235,183,41,191,202,113,152,236,251,45,241,128,169,45,9,28,5,43,121,175,209,107,20,239,108,161,22,36,79,158,1,97,84,34,133,103,51,230,201,249,14,163,54,113,65,139,103,211,195,32,90,240,177,121,236,14,33,189,221,183,153,199,191,38,155,240,104,31,24,30,36,75,44,253,45,104,37,236,53,225,143,81,197,127,204,59,216,65,30,219,210,92,47,173,18,128,73,2,149,213,12,86,55,96,102,86,210,207,102,98,106,208,94,136,23,46,225,9,236,95,184,219,102,197,113,58,79,218,167,57,167,184,79,27,223,176,184,80,55,195,207,182,172,182,63,98,75,112,192,245,20,225,112,107,246,232,65,17,128,75,232,175,223,82,201,122,136,32,238,229,65,110,225,74,104,247,168,211,156,40,72,97,173,173,154,220,252,158,170,182,112,49,97,86,227,50,88,1,170,90,177,75,246,217,49,228,170,170,43,202,163,66,168,106,190,192,221,180,59,104,220,220,88,168,102,0,28,128,136,51,0,133,79,243,172,250,191,253,42,130,200,206,113,238,52,67,191,217,189,106,119,143,135,160,158,196,161,212,195,155,196,73,51,189,5,255,26,206,78,19,148,8,246,14,106,76,94,248,142,160,242,181,150,147,75,49,193,25,253,221,203,101,102,202,44,67,117,84,32,224,33,230,114,222,217,177,179,99,107,143,244,183,45,92,64,53,70,118,146,216,227,179,97,47,211,233,239,255,122,146,228,160,227,234,121,170,43,250,19,136,61,255,117,133,3,29,84,203,126,18,85,167,187,1,145,174,136,70,162,3,149,81,105,66,42,34,150,235,12,101,9,12,87,130,58,142,193,74,40,214,112,14,178,198,106,58,206,48,29,124,43,69,70,168,93,18,143,235,50,198,143,44,179,90,167,245,208,136,20,190,173,29,185,32,211,35,38,139,22,8,194,131,165,97,42,90,222,200,183,59,157,155,194,247,156,41,87,197,13,228,87,207,160,111,48,78,45,189,28,37,127,164,247,133,187,238,3,59,96,234,174,203,24,200,203,253,60,145,113,187,202,154,223,227,20,224,209,41,224,227,214,42,198,113,95,64,80,150,11,149,101,5,76,242,40,33,241,2,225,73,207,142,75,74,31,126,109,205,18,10,90,41,155,174,81,216,85,169,83,206,212,247,8,96,208,69,156,58,140,38,177,172,4,195,220,240,73,189,102,107,131,183,230,198,58,44,134,104,253,13,54,61,165,66,41,215,145,24,111,1,144,241,7,214,51,33,230,244,46,161,76,15,253,225,65,46,148,195,110,38,0,238,164,9,128,101,239,253,250,114,18,42,150,59,87,111,33,122,234,156,23,94,128,40,59,187,146,124,53,183,218,25,111,156,51,231,204,44,34,121,188,229,63,44,215,175,65,42,166,132,172,204,129,234,222,217,139,19,60,61,134,58,175,180,107,101,134,143,18,61,64,82,196,144,85,126,48,24,203,160,7,241,9,111,15,80,48,236,12,169,74,47,68,230,236,171,65,2,147,135,75,50,185,93,29,40,88,99,36,178,78,135,66,50,115,14,241,245,6,90,59,36,155,20,213,220,97,4,91,240,81,227,96,62,137,39,199,18,5,7,45,54,56,87,158,65,194,241,18,165,0,111,29,168,111,20,177,216,249,186,94,127,108,142,224,96,196,143,231,165,67,83,182,88,206,29,69,135,62,16,238,8,56,68,83,158,26,69,224,78,159,159,62,54,113,170,113,133,4,154,190,67,19,198,98,31,188,4,72,126,195,241,230,206,180,113,109,203,237,193,82,130,255,60,154,177,136,236,223,221,27,234,73,101,198,53,17,151,236,71,116,190,125,86,255,152,21,178,5,12,144,21,162,33,220,104,49,108,226,169,168,150,148,4,5,31,204,82,55,52,170,163,254,27,3,255,110,212,240,224,73,46,214,34,209,198,176,73,255,45,180,106,26,79,215,47,192,107,98,161,24,19,68,242,134,77,64,217,252,186,40,192,70,186,31,8,191,154,212,17,64,121,100,32,227,74,153,123,55,216,255,220,197,128,78,212,114,64,128,64,162,47,117,72,38,48,84,130,32,205,128,58,157,90,154,132,18,58,142,111,200,37,146,0,86,185,201,187,102,148,203,21,2,108,22,108,117,27,21,1,49,222,238,22,198,61,76,49,29,37,187,66,181,79,44,211,142,173,164,48,22,157,28,82,6,247,214,55,76,103,85,129,10,113,166,247,64,122,138,157,89,55,89,33,11,179,83,178,233,141,241,172,233,59,217,53,207,204,160,133,154,108,183,121,245,41,154,88,142,45,186,132,216,204,136,78,5,4,155,151,88,39,32,111,11,17,136,81,244,172,142,74,213,92,44,141,84,177,244,197,176,25,186,140,75,8,170,232,87,108,50,162,40,10,44,160,23,121,100,109,222,139,139,97,95,151,164,213,236,96,66,26,235,52,251,199,90,34,16,67,174,25,235,1,94,99,80,151,60,151,31,154,170,250,74,254,238,70,104,229,33,34,170,227,251,215,33,73,248,179,169,200,196,219,242,6,140,130,136,67,36,63,105,134,141,32,67,77,111,190,98,23,214,246,88,7,229,125,58,122,166,16,127,225,77,61,199,129,253,9,32,116,207,202,17,236,90,143,244,33,247,164,237,253,90,198,189,137,250,10,16,3,212,55,185,188,238,3,115,51,164,134,195,117,127,162,183,0,55,186,11,155,56,39,252,163,110,47,227,32,248,220,234,206,140,117,73,213,155,5,197,212,184,208,8,77,14,160,8,175,63,145,100,217,95,240,194,127,195,34,72,146,89,178,228,38,176,235,8,201,84,147,210,95,137,215,51,170,106,244,173,67,45,131,65,122,215,20,99,181,105,184,253,66,245,155,77,122,0,142,44,197,249,7,179,64,59,44,231,172,224,244,182,215,4,40,135,220,56,76,65,82,100,91,102,41,226,67,95,200,42,1,39,140,118,118,71,188,214,123,161,81,187,161,45,83,145,36,176,157,12,128,102,142,171,150,39,203,245,119,54,128,30,203,176,101,252,84,197,108,136,80,84,250,55,180,16,22,57,249,160,194,63,203,253,98,40,248,62,182,104,245,244,58,87,20,47,15,185,14,22,161,120,97,55,186,249,67,112,86,129,1,244,46,167,142,254,163,109,152,223,164,111,81,70,188,245,43,159,236,36,123,145,95,176,215,34,244,123,253,113,118,46,116,175,132,124,152,152,76,137,114,157,118,199,25,123,8,247,235,230,172,116,49,31,88,125,2,97,111,227,242,80,212,15,186,50,32,224,200,31,238,232,140,109,243,138,238,193,253,222,0,28,32,150,190,48,34,244,56,197,196,64,5,88,180,249,15,245,11,196,51,9,49,56,240,154,254,52,54,132,192,1,47,118,67,121,90,212,93,88,234,230,86,62,74,4,165,115,26,42,148,244,10,210,223,239,225,54,204,223,102,234,204,72,197,208,214,94,87,47,181,173,120,85,167,77,201,165,122,165,126,3,120,7,65,137,154,23,84,27,251,239,7,243,108,182,237,236,95,176,233,237,180,0,44,95,91,93,150,86,172,69,245,81,234,190,228,243,239,236,67,99,42,154,211,59,170,79,213,233,246,137,20,22,171,123,18,201,20,174,51,28,88,99,220,82,250,56,219,62,13,214,33,141,64,205,143,101,42,215,166,1,222,87,80,48,96,68,127,188,203,45,43,224,181,148,200,224,171,5,168,15,39,8,78,252,90,120,37,15,35,73,166,90,148,66,63,80,237,61,15,63,64,110,238,28,211,153,21,206,143,45,0,163,151,175,49,81,125,98,92,35,255,157,214,250,223,198,59,54,238,109,208,130,116,71,106,246,221,224,50,114,241,58,227,192,72,132,9,183,55,223,87,248,119,200,188,52,25,141,180,183,9,44,8,253,50,69,220,217,111,3,18,22,43,4,63,22,31,19,17,20,4,185,220,235,68,204,223,209,95,199,95,44,113,98,153,12,109,46,120,50,131,118,82,236,19,90,44,176,79,168,210,78,153,10,163,55,171,51,11,131,141,80,191,166,60,156,119,28,69,220,141,106,190,54,31,8,41,123,205,30,202,192,46,51,33,43,160,109,215,19,150,232,243,219,67,20,200,61,53,213,132,196,237,137,4,173,157,112,173,155,116,158,69,190,147,248,64,132,112,33,143,197,191,249,118,137,88,239,250,192,122,192,62,202,131,155,222,27,224,34,17,222,231,164,17,43,154,60,226,223,135,32,134,149,107,194,168,74,204,4,72,98,47,171,92,224,231,153,13,18,153,50,192,130,191,64,92,33,243,39,161,69,110,100,70,208,7,0,0,250,86,224,170,102,212,176,242,118,107,52,57,54}; + +static const uint32_t SIM_MOTA_1K_LEN = 2226; +static const uint32_t SIM_MOTA_1K_BLOCKS = 2; // detools sequential+crle delta: apply DT_PATCH to DT_BASE -> DT_TARGET static const uint8_t DT_BASE[3016] = {120,46,186,148,77,51,227,185,104,193,183,194,67,136,62,162,208,188,127,90,106,134,186,157,246,55,79,139,180,84,132,19,187,198,255,221,52,176,192,186,119,236,181,212,223,167,37,136,54,222,105,250,14,197,89,160,106,119,31,185,190,35,195,83,99,84,88,203,51,83,109,106,81,145,54,231,222,104,58,52,10,191,57,195,4,248,221,66,216,129,81,197,245,145,205,180,107,157,28,84,217,167,155,199,59,60,254,118,93,34,51,94,126,152,214,160,36,67,99,159,86,85,240,181,255,182,119,220,43,175,178,196,220,33,84,236,52,148,175,16,25,240,215,44,1,230,38,112,180,60,89,58,20,50,205,72,61,177,118,154,67,123,134,225,111,169,248,106,51,215,18,77,77,71,34,144,169,187,64,134,25,126,55,228,50,200,99,45,131,217,57,81,90,192,179,228,204,11,148,230,238,173,139,96,239,189,184,243,162,18,30,58,14,132,32,241,212,53,232,162,157,236,22,242,129,44,60,124,149,204,187,42,41,22,32,158,26,207,241,152,143,207,254,154,161,7,152,27,143,46,139,178,80,0,31,71,7,46,15,26,162,219,159,172,158,187,53,148,53,165,48,118,47,121,80,69,187,116,162,112,213,183,206,210,55,102,150,221,114,221,107,152,179,34,225,53,41,79,101,50,192,45,91,116,175,3,30,85,172,0,197,57,192,129,107,168,249,9,54,155,118,141,127,140,206,12,110,85,2,130,87,141,249,230,240,224,65,170,187,40,57,154,248,27,211,203,216,110,17,245,225,34,44,6,220,86,78,242,234,64,126,41,92,213,119,237,111,241,125,157,83,40,9,197,31,30,93,108,162,244,46,129,180,24,13,168,111,228,6,207,233,227,240,69,59,30,81,139,222,145,35,54,145,149,27,110,26,241,153,180,178,68,111,143,40,195,59,240,0,131,31,50,96,137,146,104,114,146,201,44,212,165,236,63,141,235,195,74,230,208,70,151,91,115,163,28,103,101,194,72,81,128,133,58,60,210,199,206,91,58,107,201,119,146,79,73,245,172,175,236,49,119,165,138,13,64,97,211,166,53,67,105,132,34,167,80,72,176,137,206,241,34,195,23,129,56,118,155,71,75,63,165,132,99,189,72,244,47,246,228,233,247,122,206,93,247,7,152,165,96,177,16,193,185,231,34,25,108,156,82,48,255,196,244,244,19,194,148,65,8,206,163,198,66,171,217,133,48,241,218,204,111,42,49,182,120,211,68,17,118,31,25,151,68,33,191,98,200,250,150,212,165,25,57,193,149,60,42,75,166,229,35,209,177,238,206,98,175,27,249,33,86,105,84,161,179,85,140,210,192,60,5,154,71,97,35,145,68,43,129,204,230,65,55,225,230,140,33,179,109,189,138,48,32,208,33,172,177,59,64,6,163,157,173,28,251,108,135,107,8,121,39,70,182,92,118,88,74,123,227,173,148,114,232,141,165,8,174,233,72,47,98,166,229,126,163,92,128,124,93,240,40,16,129,188,248,249,141,68,50,46,112,119,83,159,1,36,188,29,108,12,72,193,168,191,20,181,224,21,171,122,118,241,53,134,128,172,191,216,59,171,163,169,80,167,131,162,77,124,75,148,14,173,54,172,186,138,121,182,62,79,166,255,80,64,84,10,35,159,137,202,138,66,88,88,38,13,89,3,158,80,192,80,50,47,239,72,206,116,135,12,251,40,25,201,123,163,67,100,13,105,51,174,34,34,93,3,115,92,192,106,231,63,31,87,218,156,115,83,1,188,172,152,212,206,133,105,183,160,198,113,51,32,213,29,130,254,173,239,245,104,126,255,101,127,115,186,56,236,11,217,178,248,114,215,168,163,134,209,19,244,68,229,206,194,29,94,160,221,137,217,92,17,83,227,202,123,155,151,121,220,191,199,202,241,205,234,70,164,104,185,211,96,128,163,65,230,28,4,63,219,134,230,237,183,181,229,109,41,192,38,48,3,205,3,69,15,27,85,41,249,58,60,229,132,70,199,19,39,247,213,215,10,64,234,230,209,250,91,21,134,94,143,43,27,92,64,200,3,206,207,147,130,194,137,110,114,158,192,62,200,9,173,197,36,195,222,90,68,139,0,117,232,231,62,93,112,14,37,53,86,17,150,199,31,117,20,200,185,21,58,0,193,20,229,210,41,161,119,53,225,179,50,59,120,212,104,243,159,148,20,197,167,106,194,135,192,98,133,68,210,210,227,227,161,183,180,36,62,92,189,50,39,122,5,177,246,255,185,32,70,105,173,233,213,61,174,111,102,212,174,40,98,96,113,237,146,163,30,186,69,139,67,157,37,83,30,161,170,92,20,88,126,31,148,49,32,133,35,206,117,26,155,154,102,119,184,196,144,146,104,188,112,247,229,124,82,237,146,81,215,157,134,60,215,44,249,56,157,202,243,236,105,6,130,117,190,131,93,11,78,190,251,41,176,233,229,147,70,59,56,145,165,174,147,111,242,160,241,201,203,118,88,132,232,178,22,117,168,133,225,76,86,240,4,246,158,51,191,121,245,52,164,72,112,109,131,246,21,37,123,166,143,107,76,69,128,184,87,200,23,39,48,21,138,248,161,140,8,151,174,127,172,55,217,205,164,191,41,40,21,65,102,38,231,245,193,137,82,79,200,219,185,83,168,181,87,125,247,47,112,61,195,99,115,20,131,186,193,11,9,106,72,43,175,52,216,11,212,247,27,151,35,162,53,237,20,208,201,47,218,30,26,86,4,185,26,142,255,250,59,219,212,222,152,155,131,117,49,122,51,2,78,187,225,215,90,99,196,255,71,7,19,218,205,190,132,213,178,193,114,94,81,104,244,138,51,254,116,38,23,73,190,214,169,31,220,41,172,172,205,57,159,93,237,199,135,219,63,101,42,222,90,192,55,33,98,171,182,18,47,63,214,206,6,76,251,28,178,150,174,154,63,127,87,170,172,107,230,55,12,253,165,15,70,55,191,80,114,158,203,222,55,160,212,2,147,59,21,136,61,126,64,193,123,132,221,255,44,162,133,94,13,143,204,9,115,165,105,81,111,100,16,20,44,80,204,209,171,181,144,67,195,225,253,33,209,171,193,155,183,25,142,150,232,170,231,94,127,241,58,4,97,162,46,174,96,95,35,15,98,225,46,228,222,216,179,230,92,153,238,220,253,247,20,57,60,168,242,236,186,70,34,78,184,1,123,95,111,107,91,156,13,3,71,10,125,232,250,215,249,13,223,48,132,48,156,61,129,121,46,148,222,129,228,3,189,217,19,25,6,29,125,180,13,205,152,222,79,198,73,205,97,155,156,229,15,195,235,128,254,9,137,30,63,146,187,223,104,18,14,16,87,252,21,24,8,109,65,199,160,20,169,27,205,213,146,211,166,109,129,203,224,196,235,198,154,251,215,11,228,122,121,254,118,28,97,98,132,36,48,194,97,45,127,162,252,193,144,38,51,235,54,12,75,128,236,76,100,184,5,223,113,92,189,7,229,144,154,126,214,210,14,217,255,3,240,73,227,232,82,220,33,93,55,112,7,32,76,178,247,134,245,50,157,101,68,55,237,191,114,53,220,235,56,124,190,163,8,192,216,1,26,101,153,181,14,174,1,213,255,111,61,46,65,109,153,124,203,111,65,13,206,176,115,10,245,43,152,144,152,119,211,90,150,27,127,253,111,51,198,47,247,9,224,245,233,188,100,81,18,83,249,180,142,84,249,109,215,178,75,102,203,163,250,154,185,204,231,27,239,194,79,230,120,16,249,205,89,189,108,124,23,74,196,171,118,88,103,14,22,9,250,115,195,156,95,122,201,54,2,16,21,23,8,89,182,241,168,220,85,132,206,169,166,61,26,8,208,0,235,19,187,18,27,197,95,7,147,13,48,151,71,243,218,70,3,189,109,122,228,228,24,168,225,138,111,120,81,184,200,93,61,118,227,105,174,242,24,113,22,201,161,12,120,202,28,112,5,43,46,172,168,46,226,229,44,48,20,91,10,59,228,230,236,190,97,0,217,185,27,113,192,7,100,14,89,93,107,66,95,179,55,124,165,105,168,237,38,3,204,188,169,189,35,154,233,228,249,76,132,68,255,95,118,181,182,132,234,105,157,178,176,132,195,183,64,62,170,40,20,135,99,6,238,75,241,135,101,245,19,28,239,79,117,251,11,161,103,148,230,156,97,82,245,248,36,29,77,232,24,61,115,206,10,85,185,234,141,140,183,158,86,215,114,111,36,207,92,252,210,111,241,157,99,97,115,199,170,90,232,213,30,185,191,109,54,216,177,170,225,232,90,48,170,58,215,86,94,202,54,39,191,150,47,189,120,234,161,59,91,219,95,38,160,190,181,2,118,54,254,2,89,50,63,108,80,84,9,115,125,140,243,178,117,100,5,38,237,219,200,62,254,182,2,127,93,146,47,79,13,150,88,221,178,75,232,71,43,46,81,92,163,53,180,207,202,243,40,229,120,166,221,150,6,20,155,26,22,24,92,163,85,117,214,210,191,112,30,227,92,77,38,111,245,111,152,252,10,35,129,158,153,153,20,150,170,209,86,177,142,81,169,6,55,32,170,218,25,23,47,109,30,135,107,196,169,200,79,178,229,12,27,72,239,20,61,227,166,234,174,86,177,184,197,72,161,19,162,9,20,149,29,178,171,11,75,218,23,33,14,131,126,156,122,48,129,25,221,92,199,2,61,248,38,79,17,27,76,70,170,222,166,233,192,96,154,178,148,64,21,136,4,86,222,208,52,174,19,163,74,215,130,27,118,18,129,120,192,188,202,75,171,253,246,36,163,136,10,247,100,10,119,218,67,158,103,253,48,145,245,89,110,244,154,197,9,238,221,133,47,78,160,159,204,225,4,169,209,223,136,127,179,234,176,178,32,244,109,158,123,117,143,142,122,200,37,130,29,87,226,67,209,10,52,142,29,49,199,189,107,161,95,142,101,182,230,0,3,181,31,212,100,211,194,68,88,239,122,38,199,231,62,143,195,76,198,217,49,152,198,98,169,6,111,161,208,229,37,157,26,175,58,235,194,12,130,208,47,58,96,40,177,162,78,24,167,121,80,70,17,255,86,54,223,60,120,243,107,155,52,51,131,168,140,230,94,207,167,67,162,146,199,15,19,23,147,144,161,222,118,19,234,109,214,155,142,65,191,43,26,239,28,102,167,172,222,237,26,148,36,49,105,211,242,73,60,84,80,26,221,208,83,138,48,87,250,120,37,33,218,171,246,97,171,6,247,86,59,64,65,8,160,226,204,127,68,59,77,12,12,224,140,114,52,166,11,25,44,233,41,5,3,182,252,41,105,251,215,25,153,44,189,144,150,73,1,142,202,175,232,240,68,218,90,200,93,130,163,19,144,168,137,22,244,71,172,30,111,151,230,127,28,105,156,154,140,249,220,201,203,136,93,28,117,47,165,141,138,122,248,160,217,119,113,167,209,245,182,1,252,150,25,54,36,62,194,197,136,4,190,12,126,29,230,234,246,252,254,36,99,6,237,175,42,95,94,164,0,220,157,137,123,47,38,152,42,140,245,31,54,38,211,14,178,164,2,211,234,250,121,117,69,98,190,78,20,238,181,194,96,144,209,138,137,232,226,205,225,115,24,57,129,221,89,191,35,96,149,25,151,73,139,213,145,7,162,175,53,142,12,248,115,235,28,27,222,249,253,184,233,157,176,24,138,48,219,6,132,62,41,213,69,73,110,37,72,178,91,57,202,151,2,180,18,216,169,25,181,105,47,144,143,2,187,101,193,163,107,54,42,18,254,14,108,234,166,29,33,219,251,212,119,76,247,204,242,159,49,224,204,189,75,30,135,183,182,147,252,14,187,185,86,6,228,160,111,45,122,79,195,199,30,106,201,237,247,201,72,29,186,226,152,54,181,133,122,118,173,45,197,146,66,247,83,25,176,166,117,207,96,149,25,198,175,41,154,59,109,225,184,107,236,150,25,34,202,13,43,92,96,249,85,73,54,22,172,123,6,174,149,76,59,19,63,195,115,254,12,143,253,116,104,204,9,75,118,200,248,23,178,80,193,61,48,128,232,47,156,18,18,158,148,238,118,11,167,70,41,112,70,48,178,1,174,82,110,22,16,245,110,154,81,216,72,211,37,132,72,185,141,157,102,134,78,205,218,30,109,59,236,102,25,38,221,165,122,246,131,120,66,137,225,50,184,190,74,195,26,32,155,162,132,142,156,141,32,2,209,213,139,198,59,168,211,168,199,46,189,74,203,219,127,108,221,43,124,185,228,187,250,239,195,8,221,31,84,161,24,80,133,244,168,50,199,166,173,233,92,225,38,1,54,145,7,160,122,105,0,32,234,2,89,146,61,78,132,101,85,31,164,226,151,123,53,197,167,56,212,124,226,220,252,7,161,98,26,46,140,40,136,42,158,224,188,172,96,245,228,231,224,19,99,127,123,162,38,111,213,4,169,9,38,253,178,208,83,237,62,224,131,82,90,104,235,13,88,191,215,199,182,210,225,25,64,216,125,102,252,136,181,125,116,52,11,149,169,37,139,183,73,178,160,210,184,143,164,202,70,104,52,141,189,138,2,105,15,214,66,15,180,136,205,72,233,248,103,141,34,179,176,190,125,226,194,107,143,147,164,226,90,12,17,135,137,31,15,146,15,78,151,177,88,61,19,157,195,159,16,1,144,109,100,25,169,117,64,189,36,176,62,18,199,120,137,164,82,192,209,137,235,200,110,117,175,96,198,31,20,251,69,110,100,70,184,11,0,0,206,99,32,159,72,64,138,233}; diff --git a/test/test_ota/test_ota_core.cpp b/test/test_ota/test_ota_core.cpp index d747c300..5b77ec40 100644 --- a/test/test_ota/test_ota_core.cpp +++ b/test/test_ota/test_ota_core.cpp @@ -48,6 +48,8 @@ TEST(OtaParse, ParsesReferenceContainer) { EXPECT_EQ(m.codec_id, EXP_CODEC_ID); EXPECT_EQ(0, memcmp(m.merkle_root, EXP_MERKLE_ROOT, 4)); EXPECT_EQ(0, memcmp(m.image_hash, EXP_IMAGE_HASH, 32)); + ASSERT_NE(m.hw_id, nullptr); + EXPECT_EQ(0, memcmp(m.hw_id, EXP_HW_ID, 32)); // v2 hardware tag ("TESTHW" NUL-padded) EXPECT_EQ(0, memcmp(m.approval, APPROVAL_NOT, 4)); // distributed = not approved EXPECT_FALSE(m.is_approved()); } @@ -304,14 +306,35 @@ TEST(OtaMerkle, GenProofMatchesPythonAndVerifies) { TEST(OtaProtocol, CodecRoundTrips) { uint8_t buf[200]; - AdvMsg adv{0x11223344, 0x02000000, {0x29,0x17,0xe4,0xf7}, MFLAG_FULL | MFLAG_SIGNED, 1, CODEC_DETOOLS_INPLACE}; + // OTA_ADV is now a tiny per-node beacon: seeder_id + n_motas + set_digest + AdvMsg adv{{0x29,0x17,0xe4,0xf7}, 7, {0xde,0xad,0xbe,0xef}}; uint16_t n = encode_adv(buf, sizeof(buf), adv); - ASSERT_GT(n, 0); EXPECT_EQ(ota_msg_type(buf, n), OTA_ADV); + ASSERT_GT(n, 0); EXPECT_EQ(ota_msg_type(buf, n), OTA_ADV); EXPECT_EQ(n, 10); AdvMsg a2; ASSERT_TRUE(decode_adv(buf, n, a2)); - EXPECT_EQ(a2.target_id, adv.target_id); EXPECT_EQ(a2.fw_version, adv.fw_version); - EXPECT_EQ(0, memcmp(a2.manifest_id, adv.manifest_id, 4)); - EXPECT_EQ(a2.flags, adv.flags); EXPECT_EQ(a2.have_all, 1); - EXPECT_EQ(a2.codec_id, CODEC_DETOOLS_INPLACE); + EXPECT_EQ(0, memcmp(a2.seeder_id, adv.seeder_id, 4)); + EXPECT_EQ(a2.n_motas, 7); + EXPECT_EQ(0, memcmp(a2.set_digest, adv.set_digest, 4)); + + // OTA_QUERY: ask a source (by seeder_id) for the offering set_digest, optionally filtered to a target + QueryMsg qy{{0x29,0x17,0xe4,0xf7}, {0xd1,0xd2,0xd3,0xd4}, 0x11223344}; + n = encode_query(buf, sizeof(buf), qy); + ASSERT_GT(n, 0); EXPECT_EQ(ota_msg_type(buf, n), OTA_QUERY); + QueryMsg q2; ASSERT_TRUE(decode_query(buf, n, q2)); + EXPECT_EQ(0, memcmp(q2.seeder_id, qy.seeder_id, 4)); + EXPECT_EQ(0, memcmp(q2.set_digest, qy.set_digest, 4)); + EXPECT_EQ(q2.filter_target, 0x11223344u); + + // OTA_HAVE: a 2-row catalog (mid, target, fwver, codec, flags per row) tagged with the offering digest + uint8_t rows[2 * 14]; + for (int i = 0; i < 2 * 14; i++) rows[i] = (uint8_t)(i + 1); + HaveMsg hv{{0x29,0x17,0xe4,0xf7}, {0xd1,0xd2,0xd3,0xd4}, 0, 1, 2, rows}; + n = encode_have(buf, sizeof(buf), hv); + ASSERT_GT(n, 0); EXPECT_EQ(ota_msg_type(buf, n), OTA_HAVE); + HaveMsg h2; ASSERT_TRUE(decode_have(buf, n, h2)); + EXPECT_EQ(0, memcmp(h2.seeder_id, hv.seeder_id, 4)); + EXPECT_EQ(0, memcmp(h2.set_digest, hv.set_digest, 4)); + EXPECT_EQ(h2.frag_total, 1); EXPECT_EQ(h2.n_rows, 2); + EXPECT_EQ(0, memcmp(h2.rows, rows, 2 * 14)); GetManifestMsg gm{{1,2,3,4}}; n = encode_get_manifest(buf, sizeof(buf), gm); @@ -330,20 +353,36 @@ TEST(OtaProtocol, CodecRoundTrips) { ReqMsg r2; ASSERT_TRUE(decode_req(buf, n, r2)); EXPECT_EQ(r2.start_block, 7); EXPECT_EQ(r2.count, 5); - uint8_t proof[12]; for (int i = 0; i < 12; i++) proof[i] = (uint8_t)(0xA0 + i); + // DATA is one self-describing fragment of a block (frag_off places it; proof is fetched separately) uint8_t data[100]; for (int i = 0; i < 100; i++) data[i] = (uint8_t)(i * 3); - DataMsg dm{{0,1,2,3}, 42, 0, 1, 3, proof, data, 100}; + DataMsg dm{{0,1,2,3}, 42, 0, data, 100}; // block 42, fragment at offset 0 n = encode_data(buf, sizeof(buf), dm); DataMsg d2; ASSERT_TRUE(decode_data(buf, n, d2)); - EXPECT_EQ(d2.block_idx, 42); EXPECT_EQ(d2.frag_idx, 0); EXPECT_EQ(d2.n_proof, 3); - EXPECT_EQ(0, memcmp(d2.proof, proof, 12)); + EXPECT_EQ(d2.block_idx, 42); EXPECT_EQ(d2.frag_off, 0); EXPECT_EQ(d2.data_len, 100); EXPECT_EQ(0, memcmp(d2.data, data, 100)); - // a non-frag0 DATA carries no proof - DataMsg dm2{{0,1,2,3}, 42, 2, 6, 0, nullptr, data, 50}; + // a later slice of the same block (non-zero frag_off) + DataMsg dm2{{0,1,2,3}, 42, 160, data, 50}; n = encode_data(buf, sizeof(buf), dm2); DataMsg d3; ASSERT_TRUE(decode_data(buf, n, d3)); - EXPECT_EQ(d3.frag_idx, 2); EXPECT_EQ(d3.n_proof, 0); EXPECT_EQ(d3.data_len, 50); + EXPECT_EQ(d3.block_idx, 42); EXPECT_EQ(d3.frag_off, 160); EXPECT_EQ(d3.data_len, 50); + + // REQ_PROOF: request the merkle proof for one (reassembled) block + ReqProofMsg rp{{7,7,8,8}, 13}; + n = encode_req_proof(buf, sizeof(buf), rp); + ASSERT_GT(n, 0); EXPECT_EQ(ota_msg_type(buf, n), OTA_REQ_PROOF); + ReqProofMsg rp2; ASSERT_TRUE(decode_req_proof(buf, n, rp2)); + EXPECT_EQ(0, memcmp(rp2.manifest_id, rp.manifest_id, 4)); EXPECT_EQ(rp2.block_idx, 13); + + // PROOF: ordered sibling digests for one block + uint8_t proof[12]; for (int i = 0; i < 12; i++) proof[i] = (uint8_t)(0xA0 + i); + ProofMsg pm{{7,7,8,8}, 13, 3, proof}; + n = encode_proof(buf, sizeof(buf), pm); + ASSERT_GT(n, 0); EXPECT_EQ(ota_msg_type(buf, n), OTA_PROOF); + ProofMsg pm2; ASSERT_TRUE(decode_proof(buf, n, pm2)); + EXPECT_EQ(0, memcmp(pm2.manifest_id, pm.manifest_id, 4)); + EXPECT_EQ(pm2.block_idx, 13); EXPECT_EQ(pm2.n_proof, 3); + EXPECT_EQ(0, memcmp(pm2.proof, proof, 12)); } // --- full transfer simulation between two OtaManagers (P4b) ------------------------------------ @@ -355,6 +394,54 @@ struct SendTo { OtaManager* dest; }; static void sim_send(void* ctx, const uint8_t* msg, uint16_t len, bool /*flood*/) { g_q.push_back({((SendTo*)ctx)->dest, std::vector(msg, msg + len)}); } +// Drive the bus to quiescence: deliver queued messages; when idle, advance the client's clock (monotonic +// across calls, so a jittered query scheduled in a prior pump still comes due) and call loop() (fires the +// scheduled catalog query / block re-requests). Two idle ticks in a row = quiescent. +static uint32_t g_clk = 0; +static void pump(OtaManager& client, int guard_max = 200000) { + int idle = 0, guard = 0; + while (guard++ < guard_max) { + if (!g_q.empty()) { + SimMsg m = std::move(g_q.front()); g_q.erase(g_q.begin()); + m.dest->on_message(m.bytes.data(), (uint16_t)m.bytes.size()); + idle = 0; + } else { + g_clk += 5000; client.set_clock(g_clk); client.loop(); + if (!g_q.empty()) { idle = 0; continue; } + if (++idle >= 2) break; + } + } +} + +// A test MotaSource backing an external "folder" with one or more complete `.mota` images held in RAM — +// the simplest concrete transport (a real device uses serial/BLE/WiFi/FS, same interface). describe() +// parses each container for the catalog + region offsets; read() is a bounds-checked memcpy. +class RamMotaSource : public mesh::ota::MotaSource { +public: + void add(const uint8_t* buf, uint32_t len) { if (_n < 8) { _buf[_n] = buf; _len[_n] = len; _n++; } } + uint8_t count() override { return _n; } + bool describe(uint8_t idx, mesh::ota::MotaDesc& d) override { + if (idx >= _n) return false; + MotaManifest m; + if (!mota_parse(_buf[idx], _len[idx], m)) return false; + std::memcpy(d.mid, m.merkle_root, 4); + d.target_id = m.target_id; d.fw_version = m.fw_version; + d.codec_id = m.codec_id; d.flags = m.flags; + d.total_size = _len[idx]; + d.leaves_off = (uint32_t)(m.leaves - _buf[idx]); + d.block_count = m.block_count; + d.payload_off = (uint32_t)(m.payload - _buf[idx]); + d.payload_size = m.payload_size; + return true; + } + bool read(uint8_t idx, uint32_t off, uint8_t* out, uint32_t len) override { + if (idx >= _n || (uint64_t)off + len > _len[idx]) return false; + std::memcpy(out, _buf[idx] + off, len); + return true; + } +private: + const uint8_t* _buf[8] = {nullptr}; uint32_t _len[8] = {0}; uint8_t _n = 0; +}; } TEST(OtaTransfer, TwoManagersFullTransfer) { @@ -366,18 +453,12 @@ TEST(OtaTransfer, TwoManagersFullTransfer) { server.begin(/*server's own target irrelevant for serving*/ 0, sim_send, &to_client); client.begin(SIM_TARGET_ID, sim_send, &to_server); client.set_fetch_store(&store); + client.set_autofetch(OtaManager::AUTOFETCH_ANY); // tests exercise fetch-on-advert; policy default is OFF ASSERT_TRUE(server.serve(SIM_MOTA, SIM_MOTA_LEN)); - server.announce(); // -> client hears the ADV and starts fetching + server.announce(); // -> client hears the beacon, queries, catalogs, then fetches - // drain the message bus until the client completes (event cascade does the whole transfer) - int guard = 0; - while (!g_q.empty() && guard++ < 100000) { - SimMsg m = std::move(g_q.front()); - g_q.erase(g_q.begin()); - m.dest->on_message(m.bytes.data(), (uint16_t)m.bytes.size()); - if (g_q.empty() && client.fetchState() == OtaManager::FETCHING) client.loop(); - } + pump(client); // beacon -> query -> have -> startFetch -> full transfer EXPECT_EQ(client.fetchState(), OtaManager::COMPLETE); EXPECT_EQ(client.blocksHave(), client.blocksTotal()); @@ -394,6 +475,138 @@ TEST(OtaTransfer, TwoManagersFullTransfer) { EXPECT_TRUE(mota_check_image_hash_full(m)); } +// Same end-to-end transfer, but with 1 KB logical blocks: each block is delivered as several +// self-describing DATA fragments (frag_off), reassembled by the client, then its merkle PROOF is +// requested + verified separately before the block is committed. Exercises the multi-fragment path. +TEST(OtaTransfer, MultiFragmentBlocks) { + g_q.clear(); + OtaManager server, client; + OtaStoreRam<4096> store; + SendTo to_client{&client}, to_server{&server}; + + server.begin(0, sim_send, &to_client); + client.begin(SIM_TARGET_ID, sim_send, &to_server); + client.set_fetch_store(&store); + client.set_autofetch(OtaManager::AUTOFETCH_ANY); + + ASSERT_TRUE(server.serve(SIM_MOTA_1K, SIM_MOTA_1K_LEN)); + server.announce(); + + pump(client); + + EXPECT_EQ(client.fetchState(), OtaManager::COMPLETE); + EXPECT_EQ(client.blocksTotal(), SIM_MOTA_1K_BLOCKS); // 1 KB blocks => fewer, larger blocks + EXPECT_EQ(client.blocksHave(), client.blocksTotal()); + + ASSERT_EQ(store.staged_size(), SIM_MOTA_1K_LEN); + EXPECT_EQ(0, std::memcmp(store.data(), SIM_MOTA_1K, SIM_MOTA_1K_LEN)); + MotaManifest m; + ASSERT_TRUE(mota_parse(store.data(), store.staged_size(), m)); + EXPECT_TRUE(mota_check_root(m)); + EXPECT_TRUE(mota_check_image_hash_full(m)); +} + +// Multi-mota folder serve: a node serves its OWN fw (view0) PLUS an external folder (RamMotaSource) of +// other `.mota`. Peers discover BOTH via the tiny beacon -> query -> broadcast HAVE catalog, then fetch an +// external mota end-to-end. The relaying node never holds the folder image in RAM — it streams the +// manifest/leaves/blocks from the source on demand (loadSource + srcReadTramp + proof-gen from read +// leaves). The fetched bytes must equal the original `.mota` (proves the trustless relay is byte-exact). +TEST(OtaFolder, ServesSelfPlusFolderAndFetchesExternal) { + g_q.clear(); + OtaManager server, client; + OtaStoreRam<4096> store; + SendTo to_client{&client}, to_server{&server}; + + server.begin(/*own target irrelevant for serving*/ 0, sim_send, &to_client); + uint8_t srv_id[4] = {0xAB, 0xCD, 0xEF, 0x01}; server.set_seeder_id(srv_id); + client.begin(SIM_TARGET_ID, sim_send, &to_server); + client.set_fetch_store(&store); + + MotaManifest mSelf, mExt; + ASSERT_TRUE(mota_parse(SIM_MOTA, SIM_MOTA_LEN, mSelf)); // served as our own fw (view0) + ASSERT_TRUE(mota_parse(SIM_MOTA_1K, SIM_MOTA_1K_LEN, mExt)); // served from the external folder + + ASSERT_TRUE(server.serve(SIM_MOTA, SIM_MOTA_LEN)); // entry 0 = self + static RamMotaSource folder; + folder.add(SIM_MOTA_1K, SIM_MOTA_1K_LEN); // an external image (different mid) + folder.add(SIM_MOTA, SIM_MOTA_LEN); // same as self -> must be DEDUPED + ASSERT_TRUE(server.add_source(&folder)); + EXPECT_EQ(server.servedCount(), 2); // self + 1 distinct folder mota (dedup) + + // discovery: beacon -> the client catalogs the source, queries it, and the broadcast HAVE fills the + // catalog with BOTH served mids. + server.announce(); + pump(client); + client.queryAll(); + pump(client); + EXPECT_EQ(client.catalogCount(), 2); + + // fetch the EXTERNAL (folder) mota by mid -> served via the source, relayed block-by-block. + client.pull(mExt.merkle_root, mExt.target_id); + pump(client); + + EXPECT_EQ(client.fetchState(), OtaManager::COMPLETE); + EXPECT_EQ(client.blocksHave(), client.blocksTotal()); + ASSERT_EQ(store.staged_size(), SIM_MOTA_1K_LEN); + EXPECT_EQ(0, std::memcmp(store.data(), SIM_MOTA_1K, SIM_MOTA_1K_LEN)); // byte-exact relay + MotaManifest got; + ASSERT_TRUE(mota_parse(store.data(), store.staged_size(), got)); + EXPECT_TRUE(mota_check_root(got)); + EXPECT_TRUE(mota_check_image_hash_full(got)); +} + +// Fetch-resume across a reboot: a client commits some blocks, "reboots" (a fresh OtaManager on the SAME +// persisted store), and resumeStaged() re-adopts the partial container and finishes the remaining blocks — +// without re-fetching the manifest or the blocks already present. +TEST(OtaTransfer, ResumeAfterReboot) { + g_q.clear(); + OtaManager server, client; + OtaStoreRam<4096> store; + SendTo to_client{&client}, to_server{&server}; + + server.begin(0, sim_send, &to_client); + client.begin(SIM_TARGET_ID, sim_send, &to_server); + client.set_fetch_store(&store); + client.set_autofetch(OtaManager::AUTOFETCH_ANY); + + ASSERT_TRUE(server.serve(SIM_MOTA_1K, SIM_MOTA_1K_LEN)); + server.announce(); + + // drive only until the first block commits, then "crash" + int idle = 0, guard = 0; + while (guard++ < 100000) { + if (!g_q.empty()) { + SimMsg msg = std::move(g_q.front()); g_q.erase(g_q.begin()); + msg.dest->on_message(msg.bytes.data(), (uint16_t)msg.bytes.size()); + idle = 0; + } else { + g_clk += 5000; client.set_clock(g_clk); client.loop(); + if (!g_q.empty()) { idle = 0; } else if (++idle >= 2) break; + } + if (client.blocksHave() >= 1) break; + } + ASSERT_GE(client.blocksHave(), 1u); + ASSERT_LT(client.blocksHave(), client.blocksTotal()); // genuinely partial + uint32_t had = client.blocksHave(); + g_q.clear(); // in-flight packets are lost in the "reboot" + + // "reboot": a brand-new manager on the SAME store (its bytes survived) resumes the partial + OtaManager client2; + to_client.dest = &client2; // server now replies to the rebooted client + SendTo to_server2{&server}; + client2.begin(SIM_TARGET_ID, sim_send, &to_server2); + client2.set_fetch_store(&store); + ASSERT_TRUE(client2.resumeStaged(nullptr)); // adopt whatever is staged + EXPECT_EQ(client2.blocksHave(), had); // resumed exactly where we left off + EXPECT_EQ(client2.fetchState(), OtaManager::FETCHING); + EXPECT_EQ(client2.blocksTotal(), SIM_MOTA_1K_BLOCKS); + + pump(client2); + EXPECT_EQ(client2.fetchState(), OtaManager::COMPLETE); + ASSERT_EQ(store.staged_size(), SIM_MOTA_1K_LEN); + EXPECT_EQ(0, std::memcmp(store.data(), SIM_MOTA_1K, SIM_MOTA_1K_LEN)); // byte-identical to the original +} + TEST(OtaTransfer, ClientRejectsWrongTarget) { g_q.clear(); OtaManager server, client; @@ -402,13 +615,10 @@ TEST(OtaTransfer, ClientRejectsWrongTarget) { server.begin(0, sim_send, &to_client); client.begin(SIM_TARGET_ID ^ 0x1u, sim_send, &to_server); // different target -> not interested client.set_fetch_store(&store); + client.set_autofetch(OtaManager::AUTOFETCH_ANY); // tests exercise fetch-on-advert; policy default is OFF ASSERT_TRUE(server.serve(SIM_MOTA, SIM_MOTA_LEN)); server.announce(); - int guard = 0; - while (!g_q.empty() && guard++ < 1000) { - SimMsg m = std::move(g_q.front()); g_q.erase(g_q.begin()); - m.dest->on_message(m.bytes.data(), (uint16_t)m.bytes.size()); - } + pump(client); // catalogs the row but wantRow rejects it (wrong target) -> never fetches EXPECT_EQ(client.fetchState(), OtaManager::IDLE); // never started } @@ -422,46 +632,55 @@ TEST(OtaTransfer, ManualCrossTargetFetch) { server.begin(0, sim_send, &to_client); client.begin(SIM_TARGET_ID ^ 0xABCDu, sim_send, &to_server); // DIFFERENT own target client.set_fetch_store(&store); + client.set_autofetch(OtaManager::AUTOFETCH_ANY); // tests exercise fetch-on-advert; policy default is OFF ASSERT_TRUE(server.serve(SIM_MOTA, SIM_MOTA_LEN)); - // without the override: ignores the ADV (wrong target) + // without the override: catalogs the row but won't fetch (wrong target) server.announce(); - for (int g = 0; !g_q.empty() && g < 1000; g++) { SimMsg m = std::move(g_q.front()); g_q.erase(g_q.begin()); m.dest->on_message(m.bytes.data(), (uint16_t)m.bytes.size()); } + pump(client); EXPECT_EQ(client.fetchState(), OtaManager::IDLE); // with want(): deliberately fetch the different-target firmware to completion client.want(SIM_TARGET_ID); server.announce(); - int guard = 0; - while (!g_q.empty() && guard++ < 100000) { - SimMsg m = std::move(g_q.front()); g_q.erase(g_q.begin()); - m.dest->on_message(m.bytes.data(), (uint16_t)m.bytes.size()); - if (g_q.empty() && client.fetchState() == OtaManager::FETCHING) client.loop(); - } + pump(client); EXPECT_EQ(client.fetchState(), OtaManager::COMPLETE); ASSERT_EQ(store.staged_size(), SIM_MOTA_LEN); EXPECT_EQ(0, std::memcmp(store.data(), SIM_MOTA, SIM_MOTA_LEN)); } -// A node must not fetch firmware it can't apply: an ADV whose codec the platform can't decode is -// rejected at ADV time (never requests the manifest). FULL + the platform's delta codec are accepted. +// Encode a 1-row OTA_HAVE catalog (the discovery reply a peer acts on). +static uint16_t make_have1(uint8_t* buf, uint16_t cap, const uint8_t mid[4], + uint32_t target, uint32_t fwver, uint8_t codec, uint8_t flags) { + uint8_t row[OTA_HAVE_ROW_BYTES]; + memcpy(row, mid, 4); + row[4]=target; row[5]=target>>8; row[6]=target>>16; row[7]=target>>24; + row[8]=fwver; row[9]=fwver>>8; row[10]=fwver>>16; row[11]=fwver>>24; + row[12]=codec; row[13]=flags; + HaveMsg hv{{0xAA,0xBB,0xCC,0xDD}, {0,0,0,0}, 0, 1, 1, row}; + return encode_have(buf, cap, hv); +} + +// A node must not fetch firmware it can't apply: a catalog row whose codec the platform can't decode is +// not fetched. FULL + the platform's delta codec(s) are accepted. TEST(OtaTransfer, RejectsIncompatibleCodec) { + g_q.clear(); OtaManager client; OtaStoreRam<4096> store; SendTo to_server{&client}; // dest unused (we only check client state) client.begin(SIM_TARGET_ID, sim_send, &to_server); client.set_fetch_store(&store); - client.set_apply_codec(CODEC_DETOOLS_INPLACE); // nRF52-style: accepts only full + in-place - uint8_t b[32]; + client.set_autofetch(OtaManager::AUTOFETCH_ANY); + client.set_apply_codec(CODEC_DETOOLS_INPLACE); // nRF52-style: accepts only full + in-place + uint8_t b[64]; - // our target, but a SEQUENTIAL delta -> incompatible -> ignored (stays IDLE, no GET_MANIFEST) - AdvMsg seq{SIM_TARGET_ID, 0x01000000, {1,2,3,4}, 0, 1, CODEC_DETOOLS_SEQUENTIAL}; - client.on_message(b, encode_adv(b, sizeof(b), seq)); + // a SEQUENTIAL delta for our target -> incompatible -> not fetched (stays IDLE) + uint8_t midA[4] = {1,2,3,4}; + client.on_message(b, make_have1(b, sizeof(b), midA, SIM_TARGET_ID, 0x01000000, CODEC_DETOOLS_SEQUENTIAL, 0)); EXPECT_EQ(client.fetchState(), OtaManager::IDLE); - EXPECT_TRUE(g_q.empty()); - // our target, IN-PLACE delta -> compatible -> proceeds to request the manifest - AdvMsg ip{SIM_TARGET_ID, 0x01000000, {5,6,7,8}, 0, 1, CODEC_DETOOLS_INPLACE}; - client.on_message(b, encode_adv(b, sizeof(b), ip)); + // an IN-PLACE delta for our target -> compatible -> begins fetching (requests the manifest) + uint8_t midB[4] = {5,6,7,8}; + client.on_message(b, make_have1(b, sizeof(b), midB, SIM_TARGET_ID, 0x01000000, CODEC_DETOOLS_INPLACE, 0)); EXPECT_EQ(client.fetchState(), OtaManager::WANT_MANIFEST); g_q.clear(); } diff --git a/tools/mota/README.md b/tools/mota/README.md index 2720f59e..59d20bfe 100644 --- a/tools/mota/README.md +++ b/tools/mota/README.md @@ -1,9 +1,8 @@ # `mota` — MeshCore OTA packaging tool Host-side tooling for building and validating `.mota` firmware-update containers. -Implements the wire spec in [`docs/ota_protocol.md`](../../docs/ota_protocol.md) (v1). - -Part of the OTA-over-LoRa work — see `OTA_PLAN.md` (milestone **P0**). +Implements the wire spec in [`docs/ota_protocol.md`](../../docs/ota_protocol.md) — the single source of +truth for the `.mota` format and the OTA-over-LoRa protocol. ## Setup @@ -64,6 +63,42 @@ $PY tools/mota/mota.py verify fw_v1.16.0_delta.mota --pub signer.priv.pub --bas # signing, tamper detection, approval enforcement ``` +## Folder serve (`mota_seeder.py`) — relay many `.mota` from a host + +A node can advertise + serve `.mota` it does **not** hold in flash, by relaying them from a folder on a +host computer. Drop several `.mota` (any architecture) into a folder; the node then shows up to peers as +having all of them. The relay is **trustless** — fetchers verify the merkle root + signature, so the +host/daemon never needs the signing keys and a bad file simply fails the fetch. + +```bash +# just connect the MeshCore node to the PC over its normal USB — no extra hardware: +pip install pyserial +./tools/mota/mota_seeder.py --port /dev/ttyACM0 --baud 115200 --dir ./my_firmware/ -v +``` + +The daemon owns the port, auto-sends `ota folder on` to the node, then answers the node's byte requests. +It speaks a tiny binary request/response protocol (`src/helpers/ota/MotaSeederProto.h`) over the **same +USB serial the CLI uses** — the node only emits request frames *while actively serving a fetch* and reads +the reply synchronously, so the binary frames coexist with the text CLI / logs (the daemon resyncs on a +magic + checksum and surfaces device text as `[dev]` lines). Peers discover the folder mOTAs via the +normal beacon → query → HAVE catalog and `ota pull ` fetches them block-by-block straight from the +host folder. Verified on HW: a RAK4631 relayed a host folder to a Heltec V3, which fetched a folder mota +to COMPLETE (every block merkle-checked) over the single USB. + +`OTA_FOLDER_SERIAL` is already enabled on the stock RAK4631 / ESP32 OTA repeater builds (inert until you +run `ota folder on` — which the daemon does for you). To point the relay at a *dedicated* UART instead of +the console, override in `platformio.ini`: + +```ini +build_flags = + -D OTA_FOLDER_SERIAL_STREAM=Serial1 ; default is the USB console `Serial` + -D OTA_FOLDER_SERIAL_BEGIN ; call .begin() on it (console is already initialized) + -D OTA_FOLDER_SERIAL_BAUD=115200 +``` + +CLI on the node: `ota folder on` (attach + announce), `ota folder` (list served mOTAs, `*`=own fw), +`ota folder off` (detach). + ## `EndF` build integration `EndF` must live in the **flashed** firmware (not just inside the `.mota`), because a node serves its diff --git a/tools/mota/gen_vectors.py b/tools/mota/gen_vectors.py index 25158dfd..35b1a702 100644 --- a/tools/mota/gen_vectors.py +++ b/tools/mota/gen_vectors.py @@ -30,7 +30,7 @@ def build_full(): m = ml.build_manifest( target_id=0x11223344, fw_version=ml.pack_version("1.16.0"), image_size=len(image), payload=image, block_size=1024, - image_hash=ml.mh32(image), codec_id=ml.CODEC_FULL, is_full=True) + image_hash=ml.mh32(image), codec_id=ml.CODEC_FULL, is_full=True, hw_id="TESTHW") return ml.build_container(m, image), m, image @@ -86,6 +86,7 @@ def main(): f"static const uint8_t EXP_CODEC_ID = {m.codec_id};", _carr("EXP_MERKLE_ROOT", m.merkle_root), _carr("EXP_IMAGE_HASH", m.image_hash), + _carr("EXP_HW_ID", m.hw_id), f"static const uint32_t PROOF_INDEX = {proof_idx}u;", f"static const uint8_t PROOF_NSIB = {len(siblings)//4};", _carr("PROOF_SIBLINGS", siblings), @@ -119,6 +120,18 @@ def main(): lines.append(f"static const uint32_t SIM_MOTA_LEN = {len(sim_blob)};") lines.append(f"static const uint32_t SIM_TARGET_ID = 0x{sm.target_id:08x}u;") + # 1 KB-block signed .mota: each logical block spans MULTIPLE LoRa DATA fragments (1024 / 160 = 7), + # so this exercises the multi-fragment reassembly + split proof path the device actually uses. + sm1k = ml.build_manifest(target_id=0xCAFEBABE, fw_version=ml.pack_version("3.0.0"), + image_size=len(sim_image), payload=sim_image, block_size=1024, + image_hash=ml.mh32(sim_image), codec_id=ml.CODEC_FULL, + is_full=True, sign_priv=sim_priv) + sim1k_blob = ml.build_container(sm1k, sim_image) + lines.append("// 1 KB-block signed .mota (multi-fragment per block) for the reassembly transfer test") + lines.append(_carr("SIM_MOTA_1K", sim1k_blob)) + lines.append(f"static const uint32_t SIM_MOTA_1K_LEN = {len(sim1k_blob)};") + lines.append(f"static const uint32_t SIM_MOTA_1K_BLOCKS = {sm1k.block_count};") + # detools sequential+crle delta vector: base image, the real detools 0.53.0 patch, and the # expected target image. The native test applies DT_PATCH to DT_BASE with the *vendored detools # C decoder* (src/helpers/ota/detools) and must reproduce DT_TARGET byte-for-byte -- proving the diff --git a/tools/mota/mota.py b/tools/mota/mota.py index 0bcea1bb..9c87d3d3 100644 --- a/tools/mota/mota.py +++ b/tools/mota/mota.py @@ -100,12 +100,14 @@ def cmd_build(args): is_full=is_full, base_hash=base_hash, sign_priv=sign_priv, + hw_id=args.hw_id, ) blob = ml.build_container(manifest, payload) Path(args.out).write_bytes(blob) print(f"wrote {args.out} ({len(blob)} bytes)") print(f" codec : {ml.CODEC_NAMES[codec_id]}") + print(f" hw_id : {manifest.hw_id.rstrip(bytes([0])).decode('ascii', 'replace') or '(none)'}") print(f" payload : {len(payload)} bytes ({manifest.block_count} blocks of {args.block_size})") print(f" image_size : {image_size} bytes (BODY+EndF)") print(f" merkle_root : {manifest.merkle_root.hex()}") @@ -201,6 +203,8 @@ def main(argv=None): help="delta patch compression (decode-cheap 'crle' default; must be supported by " "the applier. Ignored for --codec full, whose payload is the raw flashable image)") b.add_argument("--block-size", type=int, default=ml.DEFAULT_BLOCK_SIZE) + b.add_argument("--hw-id", default="", help="hardware tag (<=32 ASCII chars, e.g. RAK4631) the firmware " + "can boot on; the device refuses a .mota whose hw_id differs from its own. Empty = unset.") b.add_argument("--sign", help="Ed25519 private key file (hex) to sign the manifest") b.add_argument("--inplace-memory", type=int, default=4096, help="detools in-place memory_size") b.add_argument("--inplace-segment", type=int, default=4096, help="detools in-place segment_size") diff --git a/tools/mota/mota_seeder.py b/tools/mota/mota_seeder.py new file mode 100755 index 00000000..6e937d68 --- /dev/null +++ b/tools/mota/mota_seeder.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +"""mota-seeder: serve a FOLDER of .mota firmware to a MeshCore node over a serial link. + +The node (a SerialMotaSource) pulls the catalog + bytes on demand and RELAYS them into the mesh as if it +held them — peers just see "this node has N mOTAs". Drop several .mota (any architecture) into a folder +and point this daemon at the node's seeder UART; the node advertises + serves them all. The relay is +trustless (fetchers verify merkle+signature), so this daemon never needs the signing keys. + +Protocol: src/helpers/ota/MotaSeederProto.h (little-endian, XOR-checksummed, resync on magic). + request (node -> here): 'M''S' op(1) args... xsum + response (here -> node): 'm''s' op(1) status(1) payload... xsum + OP_COUNT 0x01 -> count(1) ; OP_DESCRIBE 0x02 idx -> MotaDesc(38) ; OP_READ 0x03 idx off(4) len(2) -> bytes + +Usage: mota_seeder.py --port /dev/ttyUSB0 --baud 115200 --dir ./firmware_folder [--watch] +Requires: pyserial, and motalib.py (this same tools/mota dir) for parsing. +""" +import argparse, glob, os, struct, sys, time +import serial # pyserial +import motalib + +REQ_MAGIC = b"MS" +RSP_MAGIC = b"ms" +OP_COUNT, OP_DESCRIBE, OP_READ = 0x01, 0x02, 0x03 +ST_OK, ST_ERR = 0x00, 0x01 +DESC_WIRE = 38 + +HEAD = 89 # fixed manifest head incl hw_id[32] + + +def mota_offsets(blob: bytes): + """Parse a .mota and return its catalog descriptor + region offsets (mirrors MotaContainer.cpp).""" + p = motalib.parse_container(blob) + m = p.manifest + base = 0 if m.is_full else 8 + sig = 96 if m.is_signed else 0 + mfl = HEAD + base + sig + 4 # manifest-minus-leaves: head + base_hash? + sig? + approval + leaves_off = 8 + mfl # container = MAGIC(4) total(4) manifest... + bc = m.block_count + payload_off = leaves_off + bc * 4 + return { + "mid": bytes(m.merkle_root), + "target_id": m.target_id, + "fw_version": m.fw_version, + "codec_id": m.codec_id, + "flags": m.flags, + "total_size": len(blob), + "leaves_off": leaves_off, + "block_count": bc, + "payload_off": payload_off, + "payload_size": m.payload_size, + } + + +def desc_wire(d) -> bytes: + w = bytearray(DESC_WIRE) + w[0:4] = d["mid"] + struct.pack_into(" int: + x = seed + for b in data: + x ^= b + return x & 0xFF + + +def send_rsp(ser, op, status, payload=b""): + body = bytes([op, status]) + payload + frame = RSP_MAGIC + body + bytes([xor(RSP_MAGIC + body)]) + ser.write(frame) + ser.flush() + + +def read_exact(ser, n): + buf = bytearray() + while len(buf) < n: + chunk = ser.read(n - len(buf)) + if not chunk: + return None + buf += chunk + return bytes(buf) + + +def handle_one(ser, items, verbose): + """Read one request body (magic already consumed) and send its response.""" + op = read_exact(ser, 1) + if op is None: + return + op = op[0] + if op == OP_COUNT: + args = b"" + elif op == OP_DESCRIBE: + args = read_exact(ser, 1) + elif op == OP_READ: + args = read_exact(ser, 7) + else: + return + if args is None: + return + xs = read_exact(ser, 1) + if xs is None or xs[0] != xor(args, op): + return # bad checksum -> ignore; node retries + + if op == OP_COUNT: + send_rsp(ser, op, ST_OK, bytes([min(len(items), 255)])) + if verbose: + print(f" COUNT -> {len(items)}") + elif op == OP_DESCRIBE: + idx = args[0] + if idx < len(items): + send_rsp(ser, op, ST_OK, desc_wire(items[idx]["desc"])) + if verbose: + print(f" DESCRIBE {idx} -> {os.path.basename(items[idx]['path'])}") + else: + send_rsp(ser, op, ST_ERR) + elif op == OP_READ: + idx = args[0] + off, lo, hi = struct.unpack(" bytes: + """Pack a hardware tag (str or bytes) into the fixed 32-byte NUL-padded field.""" + if s is None: + return b"\0" * 32 + raw = s.encode("ascii") if isinstance(s, str) else bytes(s) + if len(raw) > 32: + raise ValueError("hw_id must be <= 32 bytes") + return raw + b"\0" * (32 - len(raw)) + + def _validate_lengths(m: Manifest): assert len(m.merkle_root) == 4 assert len(m.image_hash) == 32 + assert len(m.hw_id) == 32 assert len(m.approval) == 4 if not m.is_full: assert m.base_hash is not None and len(m.base_hash) == 8, "delta requires 8-byte base_hash" @@ -298,7 +311,7 @@ def _validate_lengths(m: Manifest): def build_manifest(*, target_id: int, fw_version: int, image_size: int, payload: bytes, block_size: int, image_hash: bytes, codec_id: int, is_full: bool, - base_hash: Optional[bytes] = None, sign_priv=None) -> Manifest: + base_hash: Optional[bytes] = None, sign_priv=None, hw_id=None) -> Manifest: assert (block_size & (block_size - 1)) == 0, "block_size must be a power of two" leaves = leaf_hashes(payload, block_size) m = Manifest( @@ -311,6 +324,7 @@ def build_manifest(*, target_id: int, fw_version: int, image_size: int, payload: merkle_root=merkle_root(leaves), image_hash=image_hash, codec_id=codec_id, + hw_id=hw_id_bytes(hw_id), base_hash=None if is_full else base_hash, leaves=leaves, ) @@ -367,6 +381,7 @@ def parse_container(blob: bytes) -> Parsed: m.merkle_root = take(4) m.image_hash = take(32) m.codec_id = take(1)[0] + m.hw_id = take(32) if not m.is_full: m.base_hash = take(8) if m.is_signed: diff --git a/tools/mota/test_mota.py b/tools/mota/test_mota.py index fb4fa6f9..f9b68660 100644 --- a/tools/mota/test_mota.py +++ b/tools/mota/test_mota.py @@ -101,6 +101,31 @@ def test_full_build_parse_verify(): assert ml.verify(parsed) == [] +def test_hw_id_roundtrip_and_signed(): + # the v2 hw_id is a 32-byte NUL-padded ASCII tag in the SIGNED head; it must round-trip + be covered + # by the signature (tampering it breaks verification). + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + fw = _fw(77, 4 * 1024) + image, _ = ml.ensure_endf(fw) + priv = Ed25519PrivateKey.from_private_bytes(bytes(range(32))) + m = ml.build_manifest( + target_id=0xABCD, fw_version=ml.pack_version("2.0.0"), + image_size=len(image), payload=image, block_size=1024, + image_hash=ml.mh32(image), codec_id=ml.CODEC_FULL, is_full=True, + sign_priv=priv, hw_id="RAK4631") + blob = ml.build_container(m, image) + parsed = ml.parse_container(blob) + assert parsed.manifest.format_ver == 2 + assert parsed.manifest.hw_id == b"RAK4631" + b"\0" * (32 - 7) + assert parsed.manifest.hw_id.rstrip(b"\0").decode() == "RAK4631" + assert ml.verify(parsed) == [] + # flip a byte of the on-wire hw_id -> signature must fail (it's in the signed region) + bad = bytearray(blob) + hw_off = 8 + 57 # MAGIC(4)+total(4) + fixed head up to codec(57) = start of hw_id + bad[hw_off] ^= 0xFF + assert ml.verify(ml.parse_container(bytes(bad))) != [] + + def test_tampered_payload_detected(): fw = _fw(11, 10 * 1024) image, _ = ml.ensure_endf(fw) diff --git a/variants/heltec_v3/platformio.ini b/variants/heltec_v3/platformio.ini index 60259948..e20c408e 100644 --- a/variants/heltec_v3/platformio.ini +++ b/variants/heltec_v3/platformio.ini @@ -6,6 +6,7 @@ build_flags = ${sensor_base.build_flags} -I variants/heltec_v3 -D HELTEC_LORA_V3 + -D MOTA_HW_ID='"Heltec_v3"' ; OTA hardware tag (apply refuses a .mota for different hw) -D ESP32_CPU_FREQ=80 -D P_LORA_DIO_1=14 -D P_LORA_NSS=8 diff --git a/variants/rak4631/platformio.ini b/variants/rak4631/platformio.ini index 2a6349f3..0d2ca7dc 100644 --- a/variants/rak4631/platformio.ini +++ b/variants/rak4631/platformio.ini @@ -10,6 +10,7 @@ build_flags = ${nrf52_base.build_flags} -I variants/rak4631 -D RAK_4631 -D RAK_BOARD + -D MOTA_HW_ID='"RAK4631"' ; OTA hardware tag (apply refuses a .mota for different hw) -D NRF52_POWER_MANAGEMENT -D PIN_BOARD_SCL=14 -D PIN_BOARD_SDA=13 @@ -28,6 +29,7 @@ build_flags = ${nrf52_base.build_flags} -D ENV_INCLUDE_BME680_BSEC=1 -D ENABLE_OTA=1 ; OTA delta updates on every RAK4631 role (single-slot, bootloader-applied) -D OTA_FLASH_STORE=1 ; stage the received .mota in flash (survives reboot into the bootloader) + -D OTA_FOLDER_SERIAL ; `ota folder on` relays a host folder of .mota over the USB console (no extra HW) build_src_filter = ${nrf52_base.build_src_filter} +<../variants/rak4631> +