From 7299ee9b14d77992fdd29c5528e9711dbde66946 Mon Sep 17 00:00:00 2001 From: Valentin Kivachuk Burda Date: Mon, 29 Jun 2026 13:03:05 +0200 Subject: [PATCH 01/59] ota: .mota container format, merkle tree, EndF self-identity, signature verify (cherry picked from commit 07b20f042ecd1d812592e97224efdd0c19376e4d) --- src/helpers/ota/BlockBitmap.h | 52 +++++ src/helpers/ota/FirmwareInfo.cpp | 42 ++++ src/helpers/ota/FirmwareInfo.h | 35 +++ src/helpers/ota/MerkleTree.cpp | 110 ++++++++++ src/helpers/ota/MerkleTree.h | 46 ++++ src/helpers/ota/MotaContainer.cpp | 83 ++++++++ src/helpers/ota/MotaContainer.h | 64 ++++++ src/helpers/ota/Multihash.h | 28 +++ src/helpers/ota/OtaByteIO.h | 58 +++++ src/helpers/ota/OtaFormat.h | 92 ++++++++ src/helpers/ota/OtaSelf.cpp | 186 ++++++++++++++++ src/helpers/ota/OtaSelf.h | 28 +++ src/helpers/ota/OtaTargets.h | 339 ++++++++++++++++++++++++++++++ src/helpers/ota/OtaVerify.cpp | 27 +++ src/helpers/ota/OtaVerify.h | 29 +++ src/helpers/ota/SignerAllowlist.h | 72 +++++++ 16 files changed, 1291 insertions(+) create mode 100644 src/helpers/ota/BlockBitmap.h create mode 100644 src/helpers/ota/FirmwareInfo.cpp create mode 100644 src/helpers/ota/FirmwareInfo.h create mode 100644 src/helpers/ota/MerkleTree.cpp create mode 100644 src/helpers/ota/MerkleTree.h create mode 100644 src/helpers/ota/MotaContainer.cpp create mode 100644 src/helpers/ota/MotaContainer.h create mode 100644 src/helpers/ota/Multihash.h create mode 100644 src/helpers/ota/OtaByteIO.h create mode 100644 src/helpers/ota/OtaFormat.h create mode 100644 src/helpers/ota/OtaSelf.cpp create mode 100644 src/helpers/ota/OtaSelf.h create mode 100644 src/helpers/ota/OtaTargets.h create mode 100644 src/helpers/ota/OtaVerify.cpp create mode 100644 src/helpers/ota/OtaVerify.h create mode 100644 src/helpers/ota/SignerAllowlist.h diff --git a/src/helpers/ota/BlockBitmap.h b/src/helpers/ota/BlockBitmap.h new file mode 100644 index 0000000000..c22835f5bf --- /dev/null +++ b/src/helpers/ota/BlockBitmap.h @@ -0,0 +1,52 @@ +#pragma once + +#include +#include +#include + +// Block-availability helpers (docs/ota_protocol.md §7). +// +// Availability is *derived* from the staged manifest's leaves[]: block i is present iff its 4-byte +// leaf slot is non-erased (!= FF FF FF FF). No separate persistent structure. A compact bitmap +// (1 bit/block) is used on the wire (OTA_HAVE) and as an in-RAM cache. All ops are caller-buffer +// based; no allocation. + +namespace mesh { +namespace ota { + +inline bool leaf_present(const uint8_t* leaves, uint32_t i) { + const uint8_t* p = leaves + (size_t)i * 4; + return !(p[0] == 0xFF && p[1] == 0xFF && p[2] == 0xFF && p[3] == 0xFF); +} + +inline uint32_t bitmap_bytes(uint32_t block_count) { return (block_count + 7) / 8; } + +inline bool bitmap_get(const uint8_t* bm, uint32_t i) { + return (bm[i >> 3] >> (i & 7)) & 1; +} + +inline void bitmap_set(uint8_t* bm, uint32_t i, bool v) { + uint8_t mask = (uint8_t)(1u << (i & 7)); + if (v) bm[i >> 3] |= mask; else bm[i >> 3] &= (uint8_t)~mask; +} + +// Build a bitmap (caller buffer >= bitmap_bytes(count)) from leaves[]. +inline void leaves_to_bitmap(const uint8_t* leaves, uint32_t count, uint8_t* bm_out) { + memset(bm_out, 0, bitmap_bytes(count)); + for (uint32_t i = 0; i < count; i++) + if (leaf_present(leaves, i)) bitmap_set(bm_out, i, true); +} + +inline uint32_t count_present(const uint8_t* leaves, uint32_t count) { + uint32_t n = 0; + for (uint32_t i = 0; i < count; i++) if (leaf_present(leaves, i)) n++; + return n; +} + +inline bool all_present(const uint8_t* leaves, uint32_t count) { + for (uint32_t i = 0; i < count; i++) if (!leaf_present(leaves, i)) return false; + return true; +} + +} // namespace ota +} // namespace mesh diff --git a/src/helpers/ota/FirmwareInfo.cpp b/src/helpers/ota/FirmwareInfo.cpp new file mode 100644 index 0000000000..c58cbfcc3c --- /dev/null +++ b/src/helpers/ota/FirmwareInfo.cpp @@ -0,0 +1,42 @@ +#include "FirmwareInfo.h" +#include "Multihash.h" +#include "OtaByteIO.h" +#include + +namespace mesh { +namespace ota { + +bool find_self_firmware(const uint8_t* region, uint32_t region_len, + SelfFwInfo& out, bool verify_body) { + out = SelfFwInfo(); + if (!region || region_len < ENDF_LEN) return false; + + for (uint32_t off = 0; off + ENDF_LEN <= region_len; off++) { + if (region[off] != ENDF_MAGIC[0]) continue; // cheap pre-filter ('E') + if (memcmp(region + off, ENDF_MAGIC, 4) != 0) continue; + uint32_t body_len = rd_u32le(region + off + 4); + if (body_len != off) continue; // trailer must sit right after the body + + if (verify_body) { + uint8_t h[8]; + mh8(h, region, body_len); + if (memcmp(h, region + off + 8, 8) != 0) continue; // coincidental marker — keep scanning + } + out.valid = true; + out.endf_offset = off; + out.body_len = body_len; + out.image_len = off + ENDF_LEN; + memcpy(out.body_hash, region + off + 8, 8); + // Fixed 56-byte trailer: the self-describing identity follows body_hash at constant offsets + // (fw_version@16, target_id@20, hw_id@24..56). Zero/"" means "unknown". + out.fw_version = rd_u32le(region + off + 16); + out.target_id = rd_u32le(region + off + 20); + memcpy(out.hw_id, region + off + 24, 32); + out.hw_id[32] = 0; + return true; + } + return false; +} + +} // namespace ota +} // namespace mesh diff --git a/src/helpers/ota/FirmwareInfo.h b/src/helpers/ota/FirmwareInfo.h new file mode 100644 index 0000000000..a08bdc7578 --- /dev/null +++ b/src/helpers/ota/FirmwareInfo.h @@ -0,0 +1,35 @@ +#pragma once + +#include +#include +#include "OtaFormat.h" + +// Locate the EndF trailer in a firmware image to learn the running firmware's size + identity +// (docs/ota_protocol.md §2). Portable: operates on a contiguous, readable region — on nRF52/ESP32 +// the application flash is memory-mapped, so the region pointer is just (const uint8_t*)APP_BASE. + +namespace mesh { +namespace ota { + +struct SelfFwInfo { + bool valid = false; + uint32_t body_len = 0; // firmware body length (excludes the EndF trailer) + uint32_t image_len = 0; // body_len + ENDF_LEN (what a delta base / full image hashes over) + uint32_t endf_offset = 0; // offset of the "EndF" marker within the region (== body_len) + uint8_t body_hash[8] = {0}; // sha2-256:8 of the body (read from EndF; == a delta's base_hash) + // Self-describing identity — always present in the fixed 56-byte trailer (zero/"" means "unknown", + // e.g. a dev build with no dotted version). + uint32_t fw_version = 0; // packed MAJOR<<24|MINOR<<16|PATCH<<8|pre + uint32_t target_id = 0; // sha2-256:4(env) as uint32 — hw+role+partition (fetch routing) + char hw_id[33] = {0}; // readable hardware tag (NUL-terminated), e.g. "RAK4631" +}; + +// Scan `region[0..region_len)` for the firmware's EndF trailer. The body starts at offset 0, so the +// trailer's offset must equal its stored body_len — this uniquely identifies the running firmware's +// EndF even if a staged `.mota` (which contains its own embedded EndF) sits higher in the region. +// If `verify_body` is true the body hash is recomputed and must match (rules out coincidental markers). +bool find_self_firmware(const uint8_t* region, uint32_t region_len, + SelfFwInfo& out, bool verify_body = false); + +} // namespace ota +} // namespace mesh diff --git a/src/helpers/ota/MerkleTree.cpp b/src/helpers/ota/MerkleTree.cpp new file mode 100644 index 0000000000..8aa1099abe --- /dev/null +++ b/src/helpers/ota/MerkleTree.cpp @@ -0,0 +1,110 @@ +#include "MerkleTree.h" +#include "Multihash.h" +#include + +namespace mesh { +namespace ota { + +void merkle_leaf(uint8_t out[4], const uint8_t* block, uint32_t block_len) { + mh4(out, block, block_len); +} + +void merkle_combine(uint8_t out[4], const uint8_t* left, const uint8_t* right) { + sha256_trunc2(out, 4, left, 4, right, 4); +} + +// Root via binary-counter / Merkle-Mountain-Range with right-to-left bagging. +// Equivalent to the level-by-level "pair adjacent, promote lone last (left||right)" reduction +// (verified against the reference implementation across many counts in the native tests). +void merkle_root(uint8_t out[4], const uint8_t* leaves, uint32_t count) { + if (count == 0) { memset(out, 0, 4); return; } + if (count == 1) { memcpy(out, leaves, 4); return; } + + uint8_t peaks[32][4]; + bool valid[32] = { false }; + + for (uint32_t i = 0; i < count; i++) { + uint8_t cur[4]; + memcpy(cur, leaves + (size_t)i * 4, 4); + uint32_t level = 0; + while (valid[level]) { // carry: combine with the pending peak at this level + merkle_combine(cur, peaks[level], cur); // peak is earlier (left), cur is right + valid[level] = false; + level++; + } + memcpy(peaks[level], cur, 4); + valid[level] = true; + } + + // bag peaks right-to-left: acc starts at the lowest set level (rightmost peak) + int level = 0; + while (level < 32 && !valid[level]) level++; + uint8_t acc[4]; + memcpy(acc, peaks[level], 4); + for (int l = level + 1; l < 32; l++) { + if (valid[l]) merkle_combine(acc, peaks[l], acc); // higher peak is left, acc is right + } + memcpy(out, acc, 4); +} + +bool merkle_verify(const uint8_t* block, uint32_t block_len, uint32_t index, + const uint8_t* siblings, uint8_t n_siblings, + const uint8_t root[4], uint32_t count) { + uint8_t leaf[4]; + merkle_leaf(leaf, block, block_len); + return merkle_verify_from_leaf(leaf, index, siblings, n_siblings, root, count); +} + +bool merkle_verify_from_leaf(const uint8_t leaf[4], uint32_t index, + const uint8_t* siblings, uint8_t n_siblings, + const uint8_t root[4], uint32_t count) { + if (count == 0 || index >= count) return false; + uint8_t h[4]; + memcpy(h, leaf, 4); + + uint32_t idx = index; + uint32_t n = count; + uint8_t p = 0; + while (n > 1) { + bool is_last_odd = (n & 1u) && (idx == n - 1); + if (!is_last_odd) { + if (p >= n_siblings) return false; + const uint8_t* sib = siblings + (size_t)p * 4; + p++; + if (idx & 1u) merkle_combine(h, sib, h); // odd index -> sibling on the left + else merkle_combine(h, h, sib); // even index -> sibling on the right + } + idx >>= 1; + n = (n + 1) >> 1; + } + return (p == n_siblings) && (memcmp(h, root, 4) == 0); +} + +uint8_t merkle_gen_proof(const uint8_t* leaves, uint32_t count, uint32_t index, + uint8_t* scratch, uint8_t* out_siblings) { + if (count == 0 || index >= count) return 0; + memcpy(scratch, leaves, (size_t)count * 4); + uint32_t n = count, idx = index; + uint8_t p = 0; + while (n > 1) { + bool is_last_odd = (n & 1u) && (idx == n - 1); + if (!is_last_odd) { + uint32_t s = (idx & 1u) ? idx - 1 : idx + 1; + memcpy(out_siblings + (size_t)p * 4, scratch + (size_t)s * 4, 4); + p++; + } + // reduce one level in place (parent m written from children 2m,2m+1; m <= i so it's safe) + uint32_t m = 0; + for (uint32_t i = 0; i < n; i += 2) { + if (i + 1 < n) merkle_combine(scratch + (size_t)m * 4, scratch + (size_t)i * 4, scratch + (size_t)(i + 1) * 4); + else memmove(scratch + (size_t)m * 4, scratch + (size_t)i * 4, 4); + m++; + } + idx >>= 1; + n = (n + 1) >> 1; + } + return p; +} + +} // namespace ota +} // namespace mesh diff --git a/src/helpers/ota/MerkleTree.h b/src/helpers/ota/MerkleTree.h new file mode 100644 index 0000000000..15987f5b12 --- /dev/null +++ b/src/helpers/ota/MerkleTree.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include + +// Merkle tree over PAYLOAD blocks, sha2-256:4 (4-byte) leaves/nodes. See docs/ota_protocol.md §6. +// +// Scheme: leaf = H(block); node = H(left || right); on an odd level the last node is promoted +// unchanged (no duplication). Root = single remaining node. +// +// No dynamic allocation: the root is computed with an O(log count) "binary counter" of partial +// peaks (<= 32 levels => 128 bytes of stack). Proofs carry only sibling digests; the left/right +// direction is derived from the block index + total count (no direction bits on the wire). + +namespace mesh { +namespace ota { + +// leaf digest of one payload block +void merkle_leaf(uint8_t out[4], const uint8_t* block, uint32_t block_len); + +// parent of two 4-byte children +void merkle_combine(uint8_t out[4], const uint8_t* left, const uint8_t* right); + +// root over `count` contiguous 4-byte leaf digests (leaves[count*4]). count >= 1. +void merkle_root(uint8_t out[4], const uint8_t* leaves, uint32_t count); + +// Verify that `block` is block `index` of a `count`-block payload whose tree has the given `root`. +// `siblings` is n_siblings contiguous 4-byte digests, ordered leaf->root (promoted levels omitted; +// left/right direction derived from index + count). +bool merkle_verify(const uint8_t* block, uint32_t block_len, uint32_t index, + const uint8_t* siblings, uint8_t n_siblings, + const uint8_t root[4], uint32_t count); + +// Same, but starting from a precomputed 4-byte leaf digest (skips the H(block) step). +bool merkle_verify_from_leaf(const uint8_t leaf[4], uint32_t index, + const uint8_t* siblings, uint8_t n_siblings, + const uint8_t root[4], uint32_t count); + +// Generate the proof (ordered sibling digests) for block `index`, for a server holding leaves[]. +// `scratch` must be >= count*4 bytes (working buffer); `out_siblings` >= 32*4 bytes. +// Returns the number of 4-byte siblings written. Output matches the wire form merkle_verify expects. +uint8_t merkle_gen_proof(const uint8_t* leaves, uint32_t count, uint32_t index, + uint8_t* scratch, uint8_t* out_siblings); + +} // namespace ota +} // namespace mesh diff --git a/src/helpers/ota/MotaContainer.cpp b/src/helpers/ota/MotaContainer.cpp new file mode 100644 index 0000000000..282d41759d --- /dev/null +++ b/src/helpers/ota/MotaContainer.cpp @@ -0,0 +1,83 @@ +#include "MotaContainer.h" +#include "MerkleTree.h" +#include "Multihash.h" +#include "OtaByteIO.h" +#include + +namespace mesh { +namespace ota { + +bool MotaManifest::is_approved() const { + return approval && memcmp(approval, APPROVAL_YES, 4) == 0; +} + +// Fixed-layout parse (docs/ota_protocol.md §4): every field sits at a constant offset — base_hash(8), +// signer_pubkey(32) and signature(64) are ALWAYS present (zero-filled when not applicable), so there are +// no conditionals. Only leaves[]/payload (after `approval`) is variable, read by the caller. The signature +// always covers manifest[0, MOTA_SIGNED_LEN). Returns false on any over-read or bad format_ver. +static bool parse_manifest_fields(ByteReader& r, MotaManifest& out) { + out.format_ver = r.u8(); + if (out.format_ver != MOTA_FORMAT_VER) return false; + out.flags = r.u8(); + out.hash_algo = r.u8(); + out.target_id = r.u32(); + out.fw_version = r.u32(); + out.image_size = r.u32(); + out.payload_size = r.u32(); + out.block_size_log2 = r.u8(); + out.merkle_root = r.take(4); + out.image_hash = r.take(32); + out.codec_id = r.u8(); + out.hw_id = r.take(32); // 32-byte NUL-padded hardware tag (signed) + out.base_hash = r.take(8); // always present (zero for a full image) + out.signer_pubkey = r.take(32); // always present (zero when unsigned) + out.signed_len = MOTA_SIGNED_LEN; // signature always covers manifest[0, 129) + out.signature = r.take(64); // always present (zero when unsigned) + out.approval = r.take(4); + if (!r.ok) return false; + if (out.block_size_log2 == 0 || out.block_size_log2 > 24 || out.payload_size == 0) return false; + out.block_count = (out.payload_size + out.block_size() - 1) / out.block_size(); + // block_idx is uint16 on the wire; capping here also keeps block_count*4 (leaves length) from overflowing. + return out.block_count != 0 && out.block_count <= 0xFFFFu; +} + +bool mota_parse(const uint8_t* buf, uint32_t len, MotaManifest& out) { + out = MotaManifest(); + if (len < 4 + 4 + 5) return false; + if (memcmp(buf, MOTA_MAGIC, 4) != 0) return false; + if (memcmp(buf + len - 5, MOTA_TRAILER, 5) != 0) return false; + if (rd_u32le(buf + 4) != len) return false; // MOTA_TOTAL_SIZE must equal the actual length + + ByteReader r(buf, len - 5); // everything up to (not incl.) the trailer + r.skip(4 + 4); // MAGIC + MOTA_TOTAL_SIZE (already validated) + out.manifest_start = buf + 8; + if (!parse_manifest_fields(r, out)) return false; + out.leaves = r.take(out.block_count * 4); + out.payload = r.take(out.payload_size); + if (!r.ok) return false; + return r.pos() == len - 5; // payload must end exactly at the trailer +} + +bool mota_parse_manifest(const uint8_t* mf, uint32_t len, MotaManifest& out) { + out = MotaManifest(); + out.manifest_start = mf; + ByteReader r(mf, len); // a standalone manifest = container bytes [8, leaves) + return parse_manifest_fields(r, out); +} + +bool mota_check_root(const MotaManifest& m) { + if (!m.leaves || m.block_count == 0) return false; + uint8_t root[4]; + merkle_root(root, m.leaves, m.block_count); + return memcmp(root, m.merkle_root, 4) == 0; +} + +bool mota_check_image_hash_full(const MotaManifest& m) { + if (!m.is_full() || !m.payload || !m.image_hash) return false; + uint8_t h[32]; + mh32(h, m.payload, m.payload_size); + return memcmp(h, m.image_hash, 32) == 0; +} + +} // namespace ota +} // namespace mesh diff --git a/src/helpers/ota/MotaContainer.h b/src/helpers/ota/MotaContainer.h new file mode 100644 index 0000000000..4425e62b6d --- /dev/null +++ b/src/helpers/ota/MotaContainer.h @@ -0,0 +1,64 @@ +#pragma once + +#include +#include +#include "OtaFormat.h" + +// Parse/validate a `.mota` container that is fully present in a RAM buffer (docs/ota_protocol.md +// §3-§4). Variable-length parts are referenced by pointer into the caller's buffer — no copies, no +// allocation. (Device flash-backed staging gets a streaming variant in a later milestone; the field +// layout here is the single source of truth.) + +namespace mesh { +namespace ota { + +struct MotaManifest { + uint8_t format_ver = 0; + uint8_t flags = 0; + uint8_t hash_algo = 0; + uint32_t target_id = 0; + uint32_t fw_version = 0; + uint32_t image_size = 0; + uint32_t payload_size = 0; + uint8_t block_size_log2 = 0; + uint8_t codec_id = 0; + uint32_t block_count = 0; + + // Fixed layout (docs/ota_protocol.md §4): every field below sits at a constant offset and is ALWAYS + // present; base_hash/signer_pubkey/signature are zero-filled when not applicable (full / unsigned). + const uint8_t* merkle_root = nullptr; // 4 @20 + const uint8_t* image_hash = nullptr; // 32 @24 + const uint8_t* hw_id = nullptr; // 32 @57 (NUL-padded ASCII hardware tag; signed) + const uint8_t* base_hash = nullptr; // 8 @89 (zero for a full image) + const uint8_t* signer_pubkey = nullptr; // 32 @97 (zero when unsigned) + const uint8_t* signature = nullptr; // 64 @129 (zero when unsigned) + const uint8_t* approval = nullptr; // 4 @193 + const uint8_t* leaves = nullptr; // 4 * block_count (the only variable-length field) + const uint8_t* payload = nullptr; // payload_size + const uint8_t* manifest_start = nullptr;// first manifest byte (== start of the signed region) + uint32_t signed_len = 0; // = MOTA_SIGNED_LEN (129): bytes the signature covers + + bool is_full() const { return flags & MFLAG_FULL; } + bool is_signed() const { return flags & MFLAG_SIGNED; } + uint32_t block_size() const { return 1u << block_size_log2; } + bool is_approved() const; +}; + +// Parse a whole container in `buf[len]`. Returns true on success and fills `out` with pointers into +// `buf`. Validates MAGIC, TRAILER, MOTA_TOTAL_SIZE, format_ver, and internal length consistency. +bool mota_parse(const uint8_t* buf, uint32_t len, MotaManifest& out); + +// Parse a standalone manifest (the bytes [manifest_start, leaves) of a container, i.e. without the +// MAGIC/TOTAL_SIZE framing, leaves[] or payload). Used by the apply path, which receives the manifest +// separately from the image. Sets the fixed fields + signer/signature + signed_len; leaves/payload +// are left null. +bool mota_parse_manifest(const uint8_t* mf, uint32_t len, MotaManifest& out); + +// Recompute the merkle root from the manifest's leaves[] and compare to the merkle_root field. +bool mota_check_root(const MotaManifest& m); + +// For FULL images only: check sha2-256:32(payload) == image_hash. +bool mota_check_image_hash_full(const MotaManifest& m); + +} // namespace ota +} // namespace mesh diff --git a/src/helpers/ota/Multihash.h b/src/helpers/ota/Multihash.h new file mode 100644 index 0000000000..821641134e --- /dev/null +++ b/src/helpers/ota/Multihash.h @@ -0,0 +1,28 @@ +#pragma once + +#include +#include +#include "Utils.h" // mesh::Utils::sha256 (real on device; real host SHA-256 via test/mocks) +#include "OtaFormat.h" + +// Thin multihash helpers: SHA-256 truncated to N bytes. No state, no allocation. + +namespace mesh { +namespace ota { + +inline void sha256_trunc(uint8_t* out, size_t out_len, const uint8_t* data, size_t len) { + mesh::Utils::sha256(out, out_len, data, (int)len); +} + +inline void sha256_trunc2(uint8_t* out, size_t out_len, + const uint8_t* a, size_t a_len, + const uint8_t* b, size_t b_len) { + mesh::Utils::sha256(out, out_len, a, (int)a_len, b, (int)b_len); +} + +inline void mh4(uint8_t out[4], const uint8_t* data, size_t len) { sha256_trunc(out, 4, data, len); } +inline void mh8(uint8_t out[8], const uint8_t* data, size_t len) { sha256_trunc(out, 8, data, len); } +inline void mh32(uint8_t out[32], const uint8_t* data, size_t len){ sha256_trunc(out, 32, data, len); } + +} // namespace ota +} // namespace mesh diff --git a/src/helpers/ota/OtaByteIO.h b/src/helpers/ota/OtaByteIO.h new file mode 100644 index 0000000000..d4834cd8b6 --- /dev/null +++ b/src/helpers/ota/OtaByteIO.h @@ -0,0 +1,58 @@ +#pragma once + +#include +#include + +// A tiny bounds-checked little-endian cursor for reading the `.mota` container (docs/ota_protocol.md §3-§4) +// in a self-documenting way: each field is read by name in order, instead of hand-computed byte offsets +// (`p[0]`, `rd_u32(p+3)`, `p += 89`, `NEED(n)` ...). Any over-read flips `ok` false and yields zero/null, so +// callers parse the whole struct then check `r.ok` once. 32-bit offsets (a container can be >64 KB; the +// 16-byte LoRa wire messages keep their own uint16 cursor in OtaProtocol.cpp). No allocation; `take()` +// returns a pointer INTO the caller's buffer (zero-copy), matching the manifest's by-pointer fields. + +namespace mesh { +namespace ota { + +// Little-endian scalar read/write for RANDOM-access fields (a specific offset into a buffer, e.g. a wire +// row or a fixed manifest slot). Sequential parsing should prefer the ByteReader cursor below. These +// replace the per-file `rd_u32`/`wr_u32` helpers that were copy-pasted across the OTA sources. +inline uint16_t rd_u16le(const uint8_t* p) { return (uint16_t)p[0] | ((uint16_t)p[1] << 8); } +inline 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); +} +inline void wr_u16le(uint8_t* p, uint16_t v) { p[0] = (uint8_t)v; p[1] = (uint8_t)(v >> 8); } +inline 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); +} + +// Round to a multiple of `unit` (a power of two — a flash sector/page size). Names the `& ~(unit-1)` +// idiom so flash-geometry math in the stores reads as intent (align down / align up). +inline uint32_t align_down(uint32_t x, uint32_t unit) { return x & ~(unit - 1); } +inline uint32_t align_up(uint32_t x, uint32_t unit) { return (x + unit - 1) & ~(unit - 1); } + +struct ByteReader { + const uint8_t* p; + uint32_t len; + uint32_t n = 0; + bool ok = true; + + ByteReader(const uint8_t* buf, uint32_t length) : p(buf), len(length) {} + + uint32_t pos() const { return n; } + bool fits(uint32_t k) const { return ok && (uint64_t)n + k <= len; } + + uint8_t u8() { if (!fits(1)) { ok = false; return 0; } return p[n++]; } + uint32_t u32() { // little-endian + if (!fits(4)) { ok = false; return 0; } + uint32_t v = rd_u32le(p + n); n += 4; return v; + } + // Borrow `k` bytes at the cursor (e.g. merkle_root[4], leaves[4*BC]) and advance; null on overflow. + const uint8_t* take(uint32_t k) { + if (!fits(k)) { ok = false; return nullptr; } + const uint8_t* r = p + n; n += k; return r; + } + void skip(uint32_t k) { if (!fits(k)) { ok = false; return; } n += k; } +}; + +} // namespace ota +} // namespace mesh diff --git a/src/helpers/ota/OtaFormat.h b/src/helpers/ota/OtaFormat.h new file mode 100644 index 0000000000..6a8da2e2d6 --- /dev/null +++ b/src/helpers/ota/OtaFormat.h @@ -0,0 +1,92 @@ +#pragma once + +#include +#include + +// On-the-wire constants for the MeshCore OTA `.mota` container and protocol. +// Normative definition: docs/ota_protocol.md (v1). Mirrors tools/mota/motalib.py. +// +// Portable: no Arduino / RadioLib includes. Compiles on the native host (unit tests) and on device. + +namespace mesh { +namespace ota { + +// ---- container framing ---------------------------------------------------- +static const uint8_t MOTA_MAGIC[4] = { 'm', 'O', 'T', 'A' }; // 6D 4F 54 41 +static const uint8_t MOTA_TRAILER[5] = { 'v', 'k', '4', '9', '6' }; // 76 6B 34 39 36 +static const uint8_t ENDF_MAGIC[4] = { 'E', 'n', 'd', 'F' }; // 45 6E 64 46 +// Fixed 56-byte trailer (docs/ota_protocol.md §2): marker(4) body_len(4) body_hash8(8) + a self-describing +// identity block fw_version(4) target_id(4) hw_id(32). No optional/variable parts. +static const uint32_t ENDF_LEN = 56; + +// ---- manifest ------------------------------------------------------------- +static const uint8_t MOTA_FORMAT_VER = 2; // fixed-layout manifest (see offsets below) +static const uint8_t HASH_ALGO_SHA256 = 0x12; // multihash code + +// Fixed manifest layout (manifest-minus-leaves) — every field is present at a constant offset, so the +// parser is plain offset reads (docs/ota_protocol.md §4). base_hash/signer_pubkey/signature are always +// present (zero-filled when not applicable); only leaves[] (after `approval`) is variable. +static const uint32_t MOTA_OFF_BASE_HASH = 89; // 8 (zero for a full image) +static const uint32_t MOTA_OFF_SIGNER = 97; // 32 (zero when unsigned) +static const uint32_t MOTA_OFF_SIGNATURE = 129; // 64 (zero when unsigned) — covers manifest[0,129) +static const uint32_t MOTA_OFF_APPROVAL = 193; // 4 +static const uint32_t MOTA_MFL = 197; // manifest-minus-leaves length (constant) +static const uint32_t MOTA_SIGNED_LEN = 129; // bytes the signature covers (manifest[0, signer_end)) + +// 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; + +static const uint8_t CODEC_FULL = 0; +static const uint8_t CODEC_DETOOLS_SEQUENTIAL = 1; +static const uint8_t CODEC_DETOOLS_INPLACE = 2; + +// ---- firmware version ------------------------------------------------------ +// The comparable uint32 carried in the manifest + EndF identity: MAJOR<<24 | MINOR<<16 | PATCH<<8 | PRE. +// 0 == "unknown" (e.g. a dev build with no dotted version). Defined once here so the pack/unpack layout +// isn't re-derived with raw shifts at each call site (OtaSelf builds it, OtaCli renders it). +struct FwVersion { + uint8_t major, minor, patch, prerelease; + static FwVersion unpack(uint32_t v) { + return { (uint8_t)(v >> 24), (uint8_t)(v >> 16), (uint8_t)(v >> 8), (uint8_t)v }; + } + uint32_t pack() const { + return ((uint32_t)major << 24) | ((uint32_t)minor << 16) | ((uint32_t)patch << 8) | prerelease; + } +}; + +// ---- hash truncations ----------------------------------------------------- +static const uint8_t MH4 = 4; // sha2-256:4 (merkle leaves/nodes/root/proofs) +static const uint8_t MH8 = 8; // sha2-256:8 (base/EndF body hash) +static const uint8_t MH32 = 32; // sha2-256:32 (image security anchor) + +// ---- approval marker (manifest field, after the signature) ---------------- +static const uint8_t APPROVAL_NOT[4] = { 0xFF, 0xFF, 0xFF, 0xFF }; +static const uint8_t APPROVAL_YES[4] = { 'A', 'P', 'R', 'V' }; // 41 50 52 56 + +// ---- LoRa protocol -------------------------------------------------------- +// The packet payload type is PAYLOAD_TYPE_OTA (0x0C), defined in src/Packet.h for the core dispatch. + +enum OtaMsgType : uint8_t { + OTA_ADV = 0x01, + OTA_QUERY = 0x02, + OTA_HAVE = 0x03, + OTA_GET_MANIFEST = 0x04, + OTA_MANIFEST = 0x05, + 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; +static const uint8_t OTA_DEFAULT_HOP_LIMIT = 3; + +} // namespace ota +} // namespace mesh diff --git a/src/helpers/ota/OtaSelf.cpp b/src/helpers/ota/OtaSelf.cpp new file mode 100644 index 0000000000..f1f1902d4e --- /dev/null +++ b/src/helpers/ota/OtaSelf.cpp @@ -0,0 +1,186 @@ +#include "OtaSelf.h" +#include "FirmwareInfo.h" +#include "OtaByteIO.h" +#include + +#if defined(ESP32_PLATFORM) + #include "esp_ota_ops.h" + #include "esp_partition.h" +#elif defined(NRF52_PLATFORM) + #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 { + +#if defined(ESP32_PLATFORM) +// Scan the running app partition for the firmware's EndF trailer using esp_partition_read (stable +// across IDF versions — no mmap). Same rule as find_self_firmware(): the marker's absolute offset +// must equal its stored body_len, which uniquely identifies the running firmware's own trailer. +bool ota_self_firmware(SelfFwInfo& out) { + out = SelfFwInfo(); + const esp_partition_t* p = esp_ota_get_running_partition(); + if (!p) return false; + + const uint32_t CH = 512; + uint8_t buf[CH + ENDF_LEN]; // overlap so a marker spanning a chunk edge is still seen + for (uint32_t base = 0; base + ENDF_LEN <= p->size; base += CH) { + uint32_t want = CH + ENDF_LEN; + if (base + want > p->size) want = p->size - base; + if (esp_partition_read(p, base, buf, want) != ESP_OK) return false; + for (uint32_t i = 0; i + ENDF_LEN <= want; i++) { + if (buf[i] != ENDF_MAGIC[0]) continue; + if (memcmp(buf + i, ENDF_MAGIC, 4) != 0) continue; + uint32_t body_len = rd_u32le(buf + i + 4); + if (body_len != base + i) continue; // must sit immediately after a body of that length + out.valid = true; + out.endf_offset = body_len; + out.body_len = body_len; + out.image_len = body_len + ENDF_LEN; + memcpy(out.body_hash, buf + i + 8, 8); + // Fixed 56-byte trailer: re-read it whole at the marker (it may straddle the chunk window, so the + // identity fields aren't reliably in `buf`) and pull identity from constant offsets (docs §2). + uint8_t tr[ENDF_LEN]; + if (body_len + ENDF_LEN <= p->size && + esp_partition_read(p, body_len, tr, ENDF_LEN) == ESP_OK) { + out.fw_version = (uint32_t)tr[16] | ((uint32_t)tr[17]<<8) | ((uint32_t)tr[18]<<16) | ((uint32_t)tr[19]<<24); + out.target_id = (uint32_t)tr[20] | ((uint32_t)tr[21]<<8) | ((uint32_t)tr[22]<<16) | ((uint32_t)tr[23]<<24); + memcpy(out.hw_id, tr + 24, 32); out.hw_id[32] = 0; + } + return true; + } + } + return false; +} +#elif defined(NRF52_PLATFORM) +// nRF52 internal flash is memory-mapped, so the running app is directly scannable. The body starts at +// APP_BASE; find_self_firmware() picks the EndF whose stored body_len equals its offset (the running +// firmware's own trailer), ignoring any staged `.mota` (which carries its own embedded EndF) higher up. +bool ota_self_firmware(SelfFwInfo& out) { + const uint8_t* region = (const uint8_t*)(uintptr_t)MOTA_NRF52_APP_BASE; + uint32_t region_len = MOTA_NRF52_FS_START - MOTA_NRF52_APP_BASE; + return find_self_firmware(region, region_len, out, /*verify_body=*/true); +} +#else +bool ota_self_firmware(SelfFwInfo& out) { + // STM32/RP2040: app-region access lands with their apply path. + out = SelfFwInfo(); + return false; +} +#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); +} +// 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). +// Pack the first "MAJOR.MINOR.PATCH" found in `s` into the comparable uint32 the manifest uses +// (MAJOR<<24 | MINOR<<16 | PATCH<<8). Returns 0 if there's no dotted number (e.g. a "dev-" build). +static uint32_t parse_fw_version(const char* s) { + if (!s) return 0; + for (; *s; s++) { // find the start of a "d.d" run + if (*s < '0' || *s > '9') continue; + const char* p = s; uint32_t a = 0, b = 0, d = 0; int dots = 0; + uint32_t* cur = &a; + for (; *p; p++) { + if (*p >= '0' && *p <= '9') { *cur = *cur * 10 + (uint32_t)(*p - '0'); } + else if (*p == '.' && dots < 2) { dots++; cur = (dots == 1) ? &b : &d; } + else break; + } + if (dots >= 1) return FwVersion{ (uint8_t)a, (uint8_t)b, (uint8_t)d, 0 }.pack(); + s = p - 1; // a bare number, no dots — keep scanning + } + return 0; +} + +bool ota_serve_self(OtaContext& c, uint32_t fw_version) { + // Derive our version from the build string when the caller didn't supply one, so the mOTA we advertise + // carries a real version (was hard-coded 0 -> peers saw "v0.0.0"). A dev build with no dotted number + // still reads 0 — the self-describing EndF identity (docs) is the durable fix for that. +#ifdef FIRMWARE_VERSION + if (fw_version == 0) fw_version = parse_fw_version(FIRMWARE_VERSION); +#endif + 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); + + // Prefer the SELF-DESCRIBING identity embedded in our own EndF (docs §2) over build flags / the param — + // it's correct regardless of how the firmware was built (build.sh injection, IDE, etc.). + uint32_t out_target = fi.target_id ? fi.target_id : c.manager.target(); + uint32_t out_ver = fi.fw_version ? fi.fw_version : fw_version; + const char* out_hw = fi.hw_id[0] ? fi.hw_id : c.hw_id; + + // Assemble the fixed-layout manifest-minus-leaves (full, unsigned) = MOTA_MFL bytes. base_hash(89), + // signer_pubkey(97) and signature(129) stay zero-filled (full + unsigned); only `approval` is set. + uint8_t* m = c.serve_self_manifest; + memset(m, 0, MOTA_MFL); + m[0] = MOTA_FORMAT_VER; m[1] = MFLAG_FULL; m[2] = HASH_ALGO_SHA256; + wr_u32le(m + 3, out_target); wr_u32le(m + 7, out_ver); + 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, out_hw, strlen(out_hw) < 32 ? strlen(out_hw) : 32); // hw_id[32] (NUL-padded by memset) + memcpy(m + MOTA_OFF_APPROVAL, APPROVAL_NOT, 4); // approval (fetching device's apply-gate handles it) + return c.manager.serve_self(m, MOTA_MFL, 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 new file mode 100644 index 0000000000..df855c6f43 --- /dev/null +++ b/src/helpers/ota/OtaSelf.h @@ -0,0 +1,28 @@ +#pragma once + +#include "FirmwareInfo.h" + +// Device-side accessor for the running firmware's own image (to read its EndF trailer). +// Per-platform: ESP32 memory-maps the running app partition; other platforms TBD (nRF52 uses the +// bootloader-apply path, so its app-region wiring lands with that work). Not compiled on the native +// host — the portable scan logic in FirmwareInfo.{h,cpp} is what gets unit-tested there. + +namespace mesh { +namespace ota { + +// Locate this firmware's EndF trailer in its own flash image. Returns false if unsupported on this +// 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/OtaTargets.h b/src/helpers/ota/OtaTargets.h new file mode 100644 index 0000000000..e7ec61cef6 --- /dev/null +++ b/src/helpers/ota/OtaTargets.h @@ -0,0 +1,339 @@ +#pragma once +#include + +// AUTO-GENERATED by tools/mota/gen_targets.py — do not edit by hand. +// 319 OTA-capable PlatformIO envs. Maps target_id (= sha2-256:4 of the env name, LE uint32) +// to the human-readable env name, so a node/tool can name a target seen over the air WITHOUT +// transmitting the string in the .mota / LoRa protocol. Regenerate when the OTA env set changes. + +namespace mesh { namespace ota { + +// target_id -> env name, or nullptr if unknown. Linear scan (table is small; lookups are rare). +inline const char* ota_target_env_name(uint32_t target_id) { + static const struct { uint32_t id; const char* env; } T[] = { + { 0x149bcb23, "Ebyte_EoRa-S3_companion_radio_ble" }, + { 0x30d4e442, "Ebyte_EoRa-S3_companion_radio_usb" }, + { 0x263a8d80, "Ebyte_EoRa-S3_kiss_modem" }, + { 0x9070ae2c, "Ebyte_EoRa-S3_Repeater" }, + { 0xf190d834, "Ebyte_EoRa-S3_room_server" }, + { 0x32890982, "Ebyte_EoRa-S3_terminal_chat" }, + { 0xacb599d5, "GAT562_30S_Mesh_Kit_companion_radio_ble" }, + { 0x82d3b273, "GAT562_30S_Mesh_Kit_companion_radio_usb" }, + { 0xe96a6af8, "GAT562_30S_Mesh_Kit_kiss_modem" }, + { 0x70d8478d, "GAT562_30S_Mesh_Kit_repeater" }, + { 0xf9986af5, "GAT562_30S_Mesh_Kit_room_server" }, + { 0x98519a1d, "GAT562_Mesh_EVB_Pro_kiss_modem" }, + { 0x06b71719, "GAT562_Mesh_EVB_Pro_repeater" }, + { 0x4c0461c8, "GAT562_Mesh_EVB_Pro_room_server" }, + { 0x4fa88992, "GAT562_Mesh_Tracker_Pro_companion_radio_ble" }, + { 0xcd6604d0, "GAT562_Mesh_Tracker_Pro_companion_radio_usb" }, + { 0xc54ea258, "GAT562_Mesh_Tracker_Pro_kiss_modem" }, + { 0x0b3985a8, "GAT562_Mesh_Tracker_Pro_repeater" }, + { 0x03d9704a, "GAT562_Mesh_Tracker_Pro_room_server" }, + { 0x8456f753, "GAT562_Mesh_Watch13_companion_radio_ble" }, + { 0xc8104197, "GAT562_Mesh_Watch13_kiss_modem" }, + { 0xf5eb61ec, "Generic_E22_kiss_modem" }, + { 0x70d2d590, "Generic_E22_sx1262_repeater" }, + { 0xe1a7ffdf, "Generic_E22_sx1262_repeater_bridge_espnow" }, + { 0xd5e04824, "Generic_E22_sx1268_repeater" }, + { 0x61a7b2dd, "Generic_E22_sx1268_repeater_bridge_espnow" }, + { 0xa416bb20, "Generic_ESPNOW_comp_radio_usb" }, + { 0x875f3744, "Generic_ESPNOW_repeatr" }, + { 0xdaf349be, "Generic_ESPNOW_room_svr" }, + { 0x93b93199, "Generic_ESPNOW_terminal_chat" }, + { 0x5293da13, "Heltec_ct62_companion_radio_ble" }, + { 0xf2eec98c, "Heltec_ct62_companion_radio_usb" }, + { 0x48a986f1, "Heltec_ct62_kiss_modem" }, + { 0xd2cf978c, "Heltec_ct62_repeater" }, + { 0x53626334, "Heltec_ct62_repeater_bridge_espnow" }, + { 0xefe4f19f, "Heltec_ct62_sensor" }, + { 0xfcaf3730, "Heltec_E213_companion_radio_ble" }, + { 0xd90deef7, "Heltec_E213_companion_radio_usb" }, + { 0x2d2a4089, "Heltec_E213_kiss_modem" }, + { 0x89ea5c4e, "Heltec_E213_repeater" }, + { 0xc0eb712a, "Heltec_E213_repeater_bridge_espnow" }, + { 0x67293f43, "Heltec_E213_room_server" }, + { 0x3f364910, "Heltec_E290_companion_ble" }, + { 0xac4b4eb5, "Heltec_E290_companion_usb" }, + { 0xe077fb8d, "Heltec_E290_kiss_modem" }, + { 0x2bbe7fd8, "Heltec_E290_repeater" }, + { 0x9e40157c, "Heltec_E290_repeater_bridge_espnow" }, + { 0xb12e6e01, "Heltec_E290_room_server" }, + { 0x32bcdd14, "Heltec_t114_companion_radio_ble" }, + { 0xfe937f14, "Heltec_t114_companion_radio_usb" }, + { 0x5b9b1933, "Heltec_t114_kiss_modem" }, + { 0x191f3545, "Heltec_t114_repeater" }, + { 0x1b4399de, "Heltec_t114_repeater_bridge_rs232" }, + { 0xb1153422, "Heltec_t114_room_server" }, + { 0x075ca3f9, "Heltec_t114_without_display_companion_radio_ble" }, + { 0x2f9f8e7d, "Heltec_t114_without_display_companion_radio_usb" }, + { 0xc9dc5d8c, "Heltec_t114_without_display_repeater" }, + { 0x34b0e5f3, "Heltec_t114_without_display_repeater_bridge_rs232" }, + { 0xcfa0e233, "Heltec_t114_without_display_room_server" }, + { 0x17d5e1ff, "Heltec_T190_companion_radio_ble_" }, + { 0xc99313c6, "Heltec_T190_companion_radio_usb_" }, + { 0x21a73973, "Heltec_T190_kiss_modem" }, + { 0x3067f656, "Heltec_T190_repeater_" }, + { 0xfdb25057, "Heltec_T190_repeater_bridge_espnow_" }, + { 0x0f1767cd, "Heltec_T190_room_server_" }, + { 0x73387372, "heltec_tracker_v2_companion_radio_ble" }, + { 0xe6eeffe1, "heltec_tracker_v2_companion_radio_usb" }, + { 0x35f4e7ac, "heltec_tracker_v2_companion_radio_wifi" }, + { 0xb62a1e75, "heltec_tracker_v2_kiss_modem" }, + { 0x9e64845d, "heltec_tracker_v2_repeater" }, + { 0x19232e8b, "heltec_tracker_v2_repeater_bridge_espnow" }, + { 0x38b948b4, "heltec_tracker_v2_room_server" }, + { 0xed21ca4f, "heltec_tracker_v2_sensor" }, + { 0x9f5f39a7, "heltec_tracker_v2_terminal_chat" }, + { 0x081d2219, "Heltec_v2_companion_radio_ble" }, + { 0xc283ad60, "Heltec_v2_companion_radio_usb" }, + { 0x4fbdc1e1, "Heltec_v2_companion_radio_wifi" }, + { 0xc797fd73, "Heltec_v2_kiss_modem" }, + { 0xab9871c8, "Heltec_v2_repeater" }, + { 0x5caaf9bf, "Heltec_v2_repeater_bridge_espnow" }, + { 0x2075687c, "Heltec_v2_room_server" }, + { 0x5d726067, "Heltec_v2_terminal_chat" }, + { 0x9969bbc9, "Heltec_v3_companion_radio_ble" }, + { 0x22f900de, "Heltec_v3_companion_radio_usb" }, + { 0xaca0e153, "Heltec_v3_companion_radio_wifi" }, + { 0xcb1c2e65, "Heltec_v3_kiss_modem" }, + { 0x9f2a6b84, "Heltec_v3_ota_test" }, + { 0xd1b29b18, "Heltec_v3_repeater" }, + { 0x644bb68d, "Heltec_v3_repeater_bridge_espnow" }, + { 0xd19a9759, "Heltec_v3_repeater_bridge_rs232" }, + { 0xc59d294e, "Heltec_v3_room_server" }, + { 0x21519537, "Heltec_v3_sensor" }, + { 0x382bb181, "Heltec_v3_terminal_chat" }, + { 0x01d8f124, "heltec_v4_companion_radio_ble" }, + { 0x1a4d0096, "heltec_v4_companion_radio_usb" }, + { 0x34240e36, "heltec_v4_companion_radio_wifi" }, + { 0xd522a14f, "heltec_v4_expansionkit_repeater" }, + { 0xf75feb3e, "heltec_v4_kiss_modem" }, + { 0xe792a051, "heltec_v4_repeater" }, + { 0x2d5ea842, "heltec_v4_repeater_bridge_espnow" }, + { 0xeed78ce4, "heltec_v4_room_server" }, + { 0x0b6847ed, "heltec_v4_sensor" }, + { 0xab3e1ad3, "heltec_v4_terminal_chat" }, + { 0x60fca2ae, "heltec_v4_tft_companion_radio_ble" }, + { 0xf6b40343, "heltec_v4_tft_companion_radio_usb" }, + { 0x76640bcc, "heltec_v4_tft_companion_radio_wifi" }, + { 0x87513d56, "heltec_v4_tft_repeater" }, + { 0xca812b41, "heltec_v4_tft_repeater_bridge_espnow" }, + { 0x18594231, "heltec_v4_tft_room_server" }, + { 0x188f3ac1, "heltec_v4_tft_sensor" }, + { 0x402055ec, "heltec_v4_tft_terminal_chat" }, + { 0x12393f1f, "Heltec_Wireless_Paper_companion_radio_ble" }, + { 0xed3ef5b8, "Heltec_Wireless_Paper_companion_radio_usb" }, + { 0x8ea86381, "Heltec_Wireless_Paper_kiss_modem" }, + { 0x09f57289, "Heltec_Wireless_Paper_repeater" }, + { 0x148aeaab, "Heltec_Wireless_Paper_repeater_bridge_espnow" }, + { 0x6e9f7fec, "Heltec_Wireless_Paper_room_server" }, + { 0x6a25d024, "Heltec_Wireless_Tracker_companion_radio_ble" }, + { 0xc4a11710, "Heltec_Wireless_Tracker_companion_radio_usb" }, + { 0x753947a4, "Heltec_Wireless_Tracker_kiss_modem" }, + { 0xe6fa98b4, "Heltec_Wireless_Tracker_repeater" }, + { 0xf8c27cf4, "Heltec_Wireless_Tracker_repeater_bridge_espnow" }, + { 0x90e227d9, "Heltec_Wireless_Tracker_room_server" }, + { 0x812f5e52, "Heltec_WSL3_companion_radio_ble" }, + { 0x1ddd15c8, "Heltec_WSL3_companion_radio_usb" }, + { 0x49d67d0d, "Heltec_WSL3_companion_radio_wifi" }, + { 0xbc003322, "Heltec_WSL3_repeater" }, + { 0x835b70c0, "Heltec_WSL3_repeater_bridge_espnow" }, + { 0xd7460575, "Heltec_WSL3_repeater_bridge_rs232" }, + { 0x33e2c171, "Heltec_WSL3_room_server" }, + { 0xa165ae99, "Heltec_WSL3_sensor" }, + { 0x967ccaee, "LilyGo_T3S3_sx1262_companion_radio_ble" }, + { 0x5f7c7688, "LilyGo_T3S3_sx1262_companion_radio_usb" }, + { 0xbd04e6e7, "LilyGo_T3S3_sx1262_kiss_modem" }, + { 0x1aeac884, "LilyGo_T3S3_sx1262_repeater" }, + { 0x07849209, "LilyGo_T3S3_sx1262_repeater_bridge_espnow" }, + { 0xbfb60e7c, "LilyGo_T3S3_sx1262_room_server" }, + { 0x96036a68, "LilyGo_T3S3_sx1262_terminal_chat" }, + { 0xb76a745c, "LilyGo_T3S3_sx1276_companion_radio_ble" }, + { 0x43023193, "LilyGo_T3S3_sx1276_companion_radio_usb" }, + { 0xa7d97db7, "LilyGo_T3S3_sx1276_kiss_modem" }, + { 0x468a133e, "LilyGo_T3S3_sx1276_repeater" }, + { 0xb59ea2ed, "LilyGo_T3S3_sx1276_repeater_bridge_espnow" }, + { 0xbe372c6d, "LilyGo_T3S3_sx1276_room_server" }, + { 0x6215e9eb, "LilyGo_T3S3_sx1276_terminal_chat" }, + { 0x3898ea56, "LilyGo_TBeam_1W_companion_radio_ble" }, + { 0x4f722e70, "LilyGo_TBeam_1W_companion_radio_usb" }, + { 0x694eed25, "LilyGo_TBeam_1W_companion_radio_wifi" }, + { 0x85b8b9d7, "LilyGo_TBeam_1W_kiss_modem" }, + { 0xe8421e36, "LilyGo_TBeam_1W_repeater" }, + { 0x82eaaf9e, "LilyGo_TBeam_1W_repeater_bridge_espnow" }, + { 0xe4a2de74, "LilyGo_TBeam_1W_room_server" }, + { 0xaeb637f1, "LilyGo_TDeck_companion_radio_ble" }, + { 0x86813271, "LilyGo_TDeck_companion_radio_usb" }, + { 0x67b2ff84, "LilyGo_TDeck_kiss_modem" }, + { 0xd0e2e616, "LilyGo_TDeck_repeater" }, + { 0x3f9edfe3, "LilyGo_TETH_Elite_sx1262_companion_radio_ble" }, + { 0xc23f8082, "LilyGo_TETH_Elite_sx1262_companion_radio_usb" }, + { 0x8f256e56, "LilyGo_TETH_Elite_sx1262_repeater" }, + { 0xf1407f39, "LilyGo_TETH_Elite_sx1262_room_server" }, + { 0xe90a3181, "LilyGo_Tlora_C6_companion_radio_ble_" }, + { 0x3b1b4237, "LilyGo_Tlora_C6_kiss_modem" }, + { 0x21c6d08d, "LilyGo_Tlora_C6_repeater_" }, + { 0x34302c14, "LilyGo_Tlora_C6_room_server_" }, + { 0x268d6fc1, "LilyGo_TLora_V2_1_1_6_companion_radio_ble" }, + { 0x3c172d27, "LilyGo_TLora_V2_1_1_6_companion_radio_usb" }, + { 0xf7c5d584, "LilyGo_TLora_V2_1_1_6_companion_radio_wifi" }, + { 0x81960cc0, "LilyGo_TLora_V2_1_1_6_kiss_modem" }, + { 0x504020ea, "LilyGo_TLora_V2_1_1_6_repeater" }, + { 0x8e5c0c88, "LilyGo_TLora_V2_1_1_6_repeater_bridge_espnow" }, + { 0x4e42e0ad, "LilyGo_TLora_V2_1_1_6_repeater_bridge_rs232" }, + { 0xa2ce002f, "LilyGo_TLora_V2_1_1_6_room_server" }, + { 0x6f16479e, "LilyGo_TLora_V2_1_1_6_terminal_chat" }, + { 0x1018e5d1, "M5Stack_Unit_C6L_companion_radio_ble" }, + { 0xc0ab5040, "M5Stack_Unit_C6L_companion_radio_usb" }, + { 0xa2bf90bd, "M5Stack_Unit_C6L_kiss_modem" }, + { 0xe6bff8f9, "M5Stack_Unit_C6L_repeater" }, + { 0xb9cd26cb, "M5Stack_Unit_C6L_room_server" }, + { 0xdbfe2675, "Meshadventurer_sx1262_companion_radio_ble" }, + { 0xb00241d0, "Meshadventurer_sx1262_companion_radio_usb" }, + { 0x88e77fee, "Meshadventurer_sx1262_kiss_modem" }, + { 0xae2d9caa, "Meshadventurer_sx1262_repeater" }, + { 0xecea445b, "Meshadventurer_sx1262_repeater_bridge_espnow" }, + { 0xa0043e00, "Meshadventurer_sx1262_room_server" }, + { 0x26bbf4ed, "Meshadventurer_sx1262_terminal_chat" }, + { 0xdcc0f158, "Meshadventurer_sx1268_companion_radio_ble" }, + { 0xe3320463, "Meshadventurer_sx1268_companion_radio_usb" }, + { 0x852b928a, "Meshadventurer_sx1268_kiss_modem" }, + { 0x0ec870e9, "Meshadventurer_sx1268_repeater" }, + { 0xc845072e, "Meshadventurer_sx1268_repeater_bridge_espnow" }, + { 0x8f00c1f2, "Meshadventurer_sx1268_room_server" }, + { 0xcea95e20, "Meshadventurer_sx1268_terminal_chat" }, + { 0xd45277ff, "Meshimi_companion_radio_ble_" }, + { 0x2cad9a9d, "Meshimi_repeater_" }, + { 0xf1c1641c, "nibble_screen_connect_companion_radio_ble" }, + { 0x8dcc5d89, "nibble_screen_connect_companion_radio_usb" }, + { 0x6139a846, "nibble_screen_connect_companion_radio_wifi" }, + { 0xc0dbcae4, "nibble_screen_connect_kiss_modem" }, + { 0xcd0bdd06, "nibble_screen_connect_repeater" }, + { 0x9ecb53b1, "nibble_screen_connect_repeater_bridge_espnow" }, + { 0x82f6d321, "nibble_screen_connect_room_server" }, + { 0xb10261c8, "nibble_screen_connect_terminal_chat" }, + { 0xdba69401, "R1Neo_companion_radio_ble" }, + { 0xcb6555ee, "R1Neo_companion_radio_usb" }, + { 0xd60daf25, "R1Neo_kiss_modem" }, + { 0x410bd800, "R1Neo_repeater" }, + { 0xd8641710, "R1Neo_room_server" }, + { 0x4d0b1601, "R1Neo_sensor" }, + { 0xab2ac09e, "R1Neo_terminal_chat" }, + { 0xa17fc4f7, "RAK_3112_companion_radio_ble" }, + { 0x90c4eb3f, "RAK_3112_companion_radio_usb" }, + { 0x19d9ce91, "RAK_3112_companion_radio_wifi" }, + { 0x121cc72f, "RAK_3112_kiss_modem" }, + { 0xfa8510fa, "RAK_3112_repeater" }, + { 0xd34d8461, "RAK_3112_repeater_bridge_espnow" }, + { 0x98077550, "RAK_3112_repeater_bridge_rs232" }, + { 0x2ed959aa, "RAK_3112_room_server" }, + { 0xced7e127, "RAK_3112_sensor" }, + { 0x6925aaf0, "RAK_3112_terminal_chat" }, + { 0x9f4d58ac, "RAK_4631_companion_radio_ble" }, + { 0x48d3b5d4, "RAK_4631_companion_radio_usb" }, + { 0x14e6965d, "RAK_4631_kiss_modem" }, + { 0x04d413fd, "RAK_4631_repeater" }, + { 0x22ea8497, "RAK_4631_repeater_bridge_rs232_serial1" }, + { 0x21bae522, "RAK_4631_repeater_bridge_rs232_serial2" }, + { 0x626d80ed, "RAK_4631_room_server" }, + { 0x8b1d6850, "RAK_4631_sensor" }, + { 0xf1d3c5a8, "RAK_4631_terminal_chat" }, + { 0xc6d55752, "RAK_WisMesh_Tag_companion_radio_ble" }, + { 0x60683191, "RAK_WisMesh_Tag_companion_radio_usb" }, + { 0x8a7cb79b, "RAK_WisMesh_Tag_kiss_modem" }, + { 0x4fa4ae55, "RAK_WisMesh_Tag_repeater" }, + { 0x9cb0b232, "RAK_WisMesh_Tag_room_server" }, + { 0x51fa1a49, "RAK_WisMesh_Tag_sensor" }, + { 0xd904c2d5, "SenseCapIndicator-ESPNow_comp_radio_usb" }, + { 0xd0df8303, "Station_G2_companion_radio_ble" }, + { 0x5c1a54f8, "Station_G2_companion_radio_usb" }, + { 0x79cc029e, "Station_G2_companion_radio_wifi" }, + { 0x41197b92, "Station_G2_kiss_modem" }, + { 0x05747eb6, "Station_G2_logging_repeater" }, + { 0x75587f24, "Station_G2_logging_repeater_bridge_espnow" }, + { 0xf2128c81, "Station_G2_repeater" }, + { 0x1b34e7b4, "Station_G2_repeater_bridge_espnow" }, + { 0x73c4a019, "Station_G2_room_server" }, + { 0x8e549407, "Station_G3_ESP32_companion_radio_ble" }, + { 0x1deac038, "Station_G3_ESP32_companion_radio_usb" }, + { 0x2a2f303b, "Station_G3_ESP32_companion_radio_wifi" }, + { 0x0ba4453d, "Station_G3_ESP32_kiss_modem" }, + { 0xf8d1958f, "Station_G3_ESP32_logging_repeater" }, + { 0x3c49caf8, "Station_G3_ESP32_repeater" }, + { 0x91878dd8, "Station_G3_ESP32_room_server" }, + { 0x16482630, "T_Beam_S3_Supreme_SX1262_companion_radio_ble" }, + { 0x01c43d49, "T_Beam_S3_Supreme_SX1262_companion_radio_wifi" }, + { 0x51fe3801, "T_Beam_S3_Supreme_SX1262_kiss_modem" }, + { 0x857149e5, "T_Beam_S3_Supreme_SX1262_repeater" }, + { 0xd9d64938, "T_Beam_S3_Supreme_SX1262_repeater_bridge_espnow" }, + { 0x9952fbe3, "T_Beam_S3_Supreme_SX1262_room_server" }, + { 0xdc27c37c, "Tbeam_SX1262_companion_radio_ble" }, + { 0xb27514a3, "Tbeam_SX1262_kiss_modem" }, + { 0xe9ce038c, "Tbeam_SX1262_repeater" }, + { 0x06406dd4, "Tbeam_SX1262_repeater_bridge_espnow" }, + { 0x189660b3, "Tbeam_SX1262_room_server" }, + { 0x2082078d, "Tbeam_SX1276_companion_radio_ble" }, + { 0x3750e80d, "Tbeam_SX1276_kiss_modem" }, + { 0xe4b49973, "Tbeam_SX1276_repeater" }, + { 0x0c0c4605, "Tbeam_SX1276_repeater_bridge_espnow" }, + { 0x5a79bac1, "Tbeam_SX1276_room_server" }, + { 0x8eaa289e, "Tenstar_C3_sx1262_kiss_modem" }, + { 0x70e91bd9, "Tenstar_C3_sx1262_repeater" }, + { 0x5a7e9c2d, "Tenstar_C3_sx1262_repeater_bridge_espnow" }, + { 0x1211b151, "Tenstar_C3_sx1268_kiss_modem" }, + { 0x0a32044b, "Tenstar_C3_sx1268_repeater" }, + { 0xa13fe853, "Tenstar_C3_sx1268_repeater_bridge_espnow" }, + { 0xd7598668, "ThinkNode_M2_companion_radio_ble" }, + { 0x413287d8, "ThinkNode_M2_companion_radio_serial" }, + { 0xe8cd2377, "ThinkNode_M2_companion_radio_usb" }, + { 0x3309bdaf, "ThinkNode_M2_companion_radio_wifi" }, + { 0x7330afbb, "ThinkNode_M2_kiss_modem" }, + { 0x9df48ce6, "ThinkNode_M2_Repeater" }, + { 0xa5cb783a, "ThinkNode_M2_Repeater_bridge_espnow" }, + { 0x4b9f8283, "ThinkNode_M2_room_server" }, + { 0x311846c9, "ThinkNode_M2_terminal_chat" }, + { 0xf2d61a1c, "ThinkNode_M5_companion_radio_ble" }, + { 0x1da30414, "ThinkNode_M5_companion_radio_serial" }, + { 0xae8fe1c9, "ThinkNode_M5_companion_radio_usb" }, + { 0x67acd73f, "ThinkNode_M5_companion_radio_wifi" }, + { 0xfb118ed8, "ThinkNode_M5_kiss_modem" }, + { 0xe4213729, "ThinkNode_M5_Repeater" }, + { 0x1298038d, "ThinkNode_M5_Repeater_bridge_espnow" }, + { 0x2c47acff, "ThinkNode_M5_room_server" }, + { 0x7d641646, "ThinkNode_M5_terminal_chat" }, + { 0x37443a5b, "WHY2025_badge_companion_radio_ble_" }, + { 0x603384a7, "WHY2025_badge_repeater_" }, + { 0x67843a5f, "Xiao_C3_companion_radio_ble" }, + { 0xfad357ab, "Xiao_C3_companion_radio_usb" }, + { 0x16af5c67, "Xiao_C3_companion_radio_wifi" }, + { 0x8c105104, "Xiao_C3_kiss_modem" }, + { 0xa80042a0, "Xiao_C3_repeater" }, + { 0x4bff0748, "Xiao_C3_room_server" }, + { 0x77c8f2b5, "Xiao_C6_companion_radio_ble_" }, + { 0x71ee0e15, "Xiao_C6_kiss_modem" }, + { 0x8824cd0c, "Xiao_C6_repeater_" }, + { 0x6658b418, "Xiao_S3_companion_radio_ble" }, + { 0x964f4f5c, "Xiao_S3_companion_radio_usb" }, + { 0x0a5dc760, "Xiao_S3_kiss_modem" }, + { 0xd08496ce, "Xiao_S3_repeater" }, + { 0x293ac2c8, "Xiao_S3_repeater_bridge_espnow" }, + { 0x60ebfa87, "Xiao_S3_room_server" }, + { 0x60f5fdc2, "Xiao_S3_sensor" }, + { 0xe8f79d22, "Xiao_S3_WIO_companion_radio_ble" }, + { 0x9f27be15, "Xiao_S3_WIO_companion_radio_serial" }, + { 0x396fecc7, "Xiao_S3_WIO_companion_radio_usb" }, + { 0xf94e3407, "Xiao_S3_WIO_companion_radio_wifi" }, + { 0xe1930103, "Xiao_S3_WIO_kiss_modem" }, + { 0x9f19bcd1, "Xiao_S3_WIO_repeater" }, + { 0xfcebff61, "Xiao_S3_WIO_repeater_bridge_espnow" }, + { 0xccb734e2, "Xiao_S3_WIO_room_server" }, + { 0xf967a300, "Xiao_S3_WIO_sensor" }, + { 0xa0cc1c36, "Xiao_S3_WIO_terminal_chat" }, + }; + for (unsigned i = 0; i < sizeof(T) / sizeof(T[0]); i++) + if (T[i].id == target_id) return T[i].env; + return nullptr; +} + +} } // namespace mesh::ota diff --git a/src/helpers/ota/OtaVerify.cpp b/src/helpers/ota/OtaVerify.cpp new file mode 100644 index 0000000000..42b6d53fe8 --- /dev/null +++ b/src/helpers/ota/OtaVerify.cpp @@ -0,0 +1,27 @@ +#include "OtaVerify.h" +#include "MerkleTree.h" +#include "Multihash.h" +#include "Identity.h" + +namespace mesh { +namespace ota { + +VerifyResult ota_verify(const uint8_t* buf, uint32_t len, const SignerAllowlist& allow) { + VerifyResult r; + MotaManifest m; + if (!mota_parse(buf, len, m)) return r; + r.parsed = true; + r.root_ok = mota_check_root(m); + r.image_ok = m.is_full() ? mota_check_image_hash_full(m) + : true; // delta image_hash needs the base; verified at apply time + r.is_signed = m.is_signed(); + if (r.is_signed) { + mesh::Identity signer(m.signer_pubkey); + r.sig_ok = signer.verify(m.signature, m.manifest_start, (int)m.signed_len); + r.trusted = r.sig_ok && allow.contains(m.signer_pubkey); + } + return r; +} + +} // namespace ota +} // namespace mesh diff --git a/src/helpers/ota/OtaVerify.h b/src/helpers/ota/OtaVerify.h new file mode 100644 index 0000000000..85fb968346 --- /dev/null +++ b/src/helpers/ota/OtaVerify.h @@ -0,0 +1,29 @@ +#pragma once + +#include "MotaContainer.h" +#include "SignerAllowlist.h" + +// Full verification of a staged `.mota` (device-side: uses Ed25519 via mesh::Identity, so NOT compiled +// on the native host — the portable integrity checks live in MotaContainer and are unit-tested there). + +namespace mesh { +namespace ota { + +struct VerifyResult { + bool parsed = false; // container + manifest parsed + bool root_ok = false; // merkle_root recomputed from leaves[] matches + bool image_ok = false; // full: sha2-256(payload)==image_hash; delta: deferred to apply (set true) + bool is_signed = false; + bool sig_ok = false; // Ed25519 signature valid for signer_pubkey + bool trusted = false; // signer_pubkey is in the allowlist + + // Integrity holds (safe to keep/serve). For a signed image, the signature must also verify. + bool integrity_ok() const { return parsed && root_ok && image_ok && (!is_signed || sig_ok); } + // Eligible for AUTO-apply: integrity + signed by an allowlisted key (decision D2). + bool auto_appliable() const { return integrity_ok() && is_signed && sig_ok && trusted; } +}; + +VerifyResult ota_verify(const uint8_t* buf, uint32_t len, const SignerAllowlist& allow); + +} // namespace ota +} // namespace mesh diff --git a/src/helpers/ota/SignerAllowlist.h b/src/helpers/ota/SignerAllowlist.h new file mode 100644 index 0000000000..726644fd5a --- /dev/null +++ b/src/helpers/ota/SignerAllowlist.h @@ -0,0 +1,72 @@ +#pragma once + +#include +#include +#include + +// Runtime-managed allowlist of trusted Ed25519 firmware-signer public keys (docs/ota_protocol.md §9, +// decision D2 + Q1: no key embedded in firmware; only allowlist-signed firmware may auto-apply). +// Portable + fixed-capacity (no dynamic allocation). Persistence (load/save) is layered on per-platform. + +namespace mesh { +namespace ota { + +#ifndef MAX_OTA_SIGNERS +#define MAX_OTA_SIGNERS 4 +#endif + +class SignerAllowlist { + uint8_t _keys[MAX_OTA_SIGNERS][32]; + uint8_t _count = 0; + +public: + void clear() { _count = 0; } + uint8_t count() const { return _count; } + const uint8_t* get(uint8_t i) const { return (i < _count) ? _keys[i] : nullptr; } + + bool contains(const uint8_t* pub) const { + for (uint8_t i = 0; i < _count; i++) + if (memcmp(_keys[i], pub, 32) == 0) return true; + return false; + } + + // Add a key (idempotent). Returns false if the list is full. + bool add(const uint8_t* pub) { + if (contains(pub)) return true; + if (_count >= MAX_OTA_SIGNERS) return false; + memcpy(_keys[_count++], pub, 32); + return true; + } + + bool remove(const uint8_t* pub) { + for (uint8_t i = 0; i < _count; i++) { + if (memcmp(_keys[i], pub, 32) == 0) { + memmove(_keys[i], _keys[i + 1], (size_t)(_count - i - 1) * 32); + _count--; + return true; + } + } + return false; + } + + // Serialize as: count(1) || key0(32) || key1(32) ... Returns bytes written. + uint32_t serialize(uint8_t* out, uint32_t max_len) const { + uint32_t need = 1 + (uint32_t)_count * 32; + if (max_len < need) return 0; + out[0] = _count; + memcpy(out + 1, _keys, (size_t)_count * 32); + return need; + } + + bool deserialize(const uint8_t* in, uint32_t len) { + if (len < 1) return false; + uint8_t n = in[0]; + if (n > MAX_OTA_SIGNERS || (uint32_t)1 + n * 32 > len) return false; + _count = n; + memcpy(_keys, in + 1, (size_t)n * 32); + return true; + } +}; + +} // namespace ota +} // namespace mesh From 7700a19ec5b1d6a707866386e99d2f4dfdc85e3d Mon Sep 17 00:00:00 2001 From: Valentin Kivachuk Burda Date: Mon, 29 Jun 2026 13:03:05 +0200 Subject: [PATCH 02/59] ota: vendor detools 0.53.0 in-place/CRLE decoder (NONE+CRLE config) (cherry picked from commit 6b4f2355e90cfcd5db8233afd75a06a6deb92cad) --- src/helpers/ota/detools/README.meshcore.txt | 42 + src/helpers/ota/detools/detools.c | 2783 +++++++++++++++++++ src/helpers/ota/detools/detools.h | 639 +++++ 3 files changed, 3464 insertions(+) create mode 100644 src/helpers/ota/detools/README.meshcore.txt create mode 100644 src/helpers/ota/detools/detools.c create mode 100644 src/helpers/ota/detools/detools.h diff --git a/src/helpers/ota/detools/README.meshcore.txt b/src/helpers/ota/detools/README.meshcore.txt new file mode 100644 index 0000000000..770733aadb --- /dev/null +++ b/src/helpers/ota/detools/README.meshcore.txt @@ -0,0 +1,42 @@ +Vendored detools embeddable C decoder +===================================== + +Source : https://github.com/eerimoq/detools (tag 0.53.0, c/ directory) +Files : detools.c, detools.h +License : BSD 2-Clause (Erik Moqvist; original bsdiff (c) Colin Percival). + sais.c (host packager only, not vendored) is MIT. + Compatible with MeshCore's MIT license; notices retained in-file. + +Why vendored +------------ +The detools PyPI package (used by tools/mota to *create* patches with +detools.create_patch) ships only the Python library + patch-creation C +extensions -- NOT the embeddable decoder. The on-device delta applier needs +detools' own decoder, so we vendor c/detools.{c,h} verbatim. This is detools' +official C implementation; MeshCore does not reimplement the delta codec. + +Local modifications +------------------- +Only the config defaults at the top of detools.h were changed (upstream = 1): + DETOOLS_CONFIG_FILE_IO -> 0 (no file IO on device) + DETOOLS_CONFIG_COMPRESSION_LZMA -> 0 (would need liblzma) + DETOOLS_CONFIG_COMPRESSION_HEATSHRINK -> 0 (would need malloc + heatshrink/) + DETOOLS_CONFIG_COMPRESSION_NONE = 1 (kept) + DETOOLS_CONFIG_COMPRESSION_CRLE = 1 (kept) +detools.c is byte-for-byte upstream. + +With this config the decoder is self-contained (no malloc, no liblzma, no +heatshrink/, no file IO) and applies `--codec sequential --compression crle` +patches, which is what tools/mota produces for MeshCore .mota deltas. + +Usage on device +--------------- +src/helpers/ota/OtaApply.cpp wraps detools_apply_patch_callbacks(): + from_read/from_seek -> running OTA slot (the delta base, via esp_partition_read) + patch_read -> the .mota payload held in RAM (fetched over LoRa) + to_write -> inactive OTA slot (via esp_ota_write) + running SHA-256 +The decoded image is verified against the signed manifest image_hash before the +slot is armed as boot partition. + +To update: re-copy c/detools.{c,h} from the pinned detools tag and re-apply the +three config-default edits above. diff --git a/src/helpers/ota/detools/detools.c b/src/helpers/ota/detools/detools.c new file mode 100644 index 0000000000..5d25489576 --- /dev/null +++ b/src/helpers/ota/detools/detools.c @@ -0,0 +1,2783 @@ +/** + * BSD 2-Clause License + * + * Copyright (c) 2019-2020, Erik Moqvist + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * * Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in + * the documentation and/or other materials provided with the + * distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + * COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + * OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include +#include "detools.h" + +/* Patch types. */ +#define PATCH_TYPE_SEQUENTIAL 0 +#define PATCH_TYPE_IN_PLACE 1 + +/* Compressions. */ +#define COMPRESSION_NONE 0 +#define COMPRESSION_LZMA 1 +#define COMPRESSION_CRLE 2 +#define COMPRESSION_HEATSHRINK 4 + +#define MIN(x, y) (((x) < (y)) ? (x) : (y)) +#define MAX(x, y) (((x) > (y)) ? (x) : (y)) +#define DIV_CEIL(n, d) (((n) + (d) - 1) / (d)) + +/* + * Utility functions. + */ + +static size_t chunk_left(struct detools_apply_patch_chunk_t *self_p) +{ + return (self_p->size - self_p->offset); +} + +static bool chunk_available(struct detools_apply_patch_chunk_t *self_p) +{ + return (chunk_left(self_p) > 0); +} + +static uint8_t chunk_get_no_check(struct detools_apply_patch_chunk_t *self_p) +{ + uint8_t data; + + data = self_p->buf_p[self_p->offset]; + self_p->offset++; + + return (data); +} + +static int chunk_get(struct detools_apply_patch_chunk_t *self_p, + uint8_t *data_p) +{ + if (!chunk_available(self_p)) { + return (1); + } + + *data_p = chunk_get_no_check(self_p); + + return (0); +} + +#if DETOOLS_CONFIG_COMPRESSION_NONE == 1 \ + || DETOOLS_CONFIG_COMPRESSION_CRLE == 1 \ + || DETOOLS_CONFIG_COMPRESSION_LZMA == 1 + +static void chunk_read_all_no_check(struct detools_apply_patch_chunk_t *self_p, + uint8_t *buf_p, + size_t size) +{ + memcpy(buf_p, &self_p->buf_p[self_p->offset], size); + self_p->offset += size; +} + +#endif + +#if DETOOLS_CONFIG_COMPRESSION_NONE == 1 \ + || DETOOLS_CONFIG_COMPRESSION_CRLE == 1 + +static int chunk_read(struct detools_apply_patch_chunk_t *self_p, + uint8_t *buf_p, + size_t *size_p) +{ + if (!chunk_available(self_p)) { + return (1); + } + + *size_p = MIN(*size_p, chunk_left(self_p)); + chunk_read_all_no_check(self_p, buf_p, *size_p); + + return (0); +} + +#endif + +static bool is_overflow(int value) +{ + return ((value + 7) > (int)(8 * sizeof(int))); +} + +static int chunk_unpack_header_size(struct detools_apply_patch_chunk_t *self_p, + struct detools_apply_patch_size_t *size_state_p, + int *size_p) +{ + int res; + uint8_t byte; + + do { + switch (size_state_p->state) { + + case detools_unpack_usize_state_first_t: + res = chunk_get(self_p, &byte); + + if (res != 0) { + return (res); + } + + size_state_p->value = (byte & 0x3f); + size_state_p->offset = 6; + size_state_p->state = detools_unpack_usize_state_consecutive_t; + break; + + case detools_unpack_usize_state_consecutive_t: + res = chunk_get(self_p, &byte); + + if (res != 0) { + return (res); + } + + if (is_overflow(size_state_p->offset)) { + return (-DETOOLS_CORRUPT_PATCH_OVERFLOW); + } + + size_state_p->value |= ((byte & 0x7f) << size_state_p->offset); + size_state_p->offset += 7; + break; + + default: + return (-DETOOLS_INTERNAL_ERROR); + } + } while ((byte & 0x80) != 0); + + /* Done, fix sign. */ + size_state_p->state = detools_unpack_usize_state_first_t; + + *size_p = size_state_p->value; + + return (res); +} + +/* + * None patch reader. + */ + +#if DETOOLS_CONFIG_COMPRESSION_NONE == 1 + +static int patch_reader_none_decompress( + struct detools_apply_patch_patch_reader_t *self_p, + uint8_t *buf_p, + size_t *size_p) +{ + int res; + struct detools_apply_patch_patch_reader_none_t *none_p; + + none_p = &self_p->compression.none; + + if (none_p->patch_offset + *size_p > none_p->patch_size) { + return (-DETOOLS_CORRUPT_PATCH); + } + + res = chunk_read(self_p->patch_chunk_p, + buf_p, + size_p); + + if (res != 0) { + return (res); + } + + none_p->patch_offset += *size_p; + + return (0); +} + +static int patch_reader_none_destroy( + struct detools_apply_patch_patch_reader_t *self_p) +{ + struct detools_apply_patch_patch_reader_none_t *none_p; + + none_p = &self_p->compression.none; + + if (none_p->patch_offset == none_p->patch_size) { + return (0); + } else { + return (-DETOOLS_CORRUPT_PATCH); + } +} + +static int patch_reader_none_init(struct detools_apply_patch_patch_reader_t *self_p, + size_t patch_size) +{ + struct detools_apply_patch_patch_reader_none_t *none_p; + + none_p = &self_p->compression.none; + none_p->patch_size = patch_size; + none_p->patch_offset = 0; + self_p->destroy = patch_reader_none_destroy; + self_p->decompress = patch_reader_none_decompress; + + return (0); +} + +#endif + +/* + * Heatshrink patch reader. + */ + +#if DETOOLS_CONFIG_COMPRESSION_HEATSHRINK == 1 + +static void unpack_heatshrink_header(uint8_t byte, + int8_t *window_sz2_p, + int8_t *lookahead_sz2_p) +{ + *window_sz2_p = (((byte >> 4) & 0xf) + 4); + *lookahead_sz2_p = ((byte & 0xf) + 3); +} + +static int patch_reader_heatshrink_decompress( + struct detools_apply_patch_patch_reader_t *self_p, + uint8_t *buf_p, + size_t *size_p) +{ + int res; + struct detools_apply_patch_patch_reader_heatshrink_t *heatshrink_p; + size_t size; + size_t left; + HSD_poll_res pres; + HSD_sink_res sres; + uint8_t byte; + + heatshrink_p = &self_p->compression.heatshrink; + left = *size_p; + + if (heatshrink_p->window_sz2 == -1) { + res = chunk_get(self_p->patch_chunk_p, &byte); + + if (res != 0) { + return (1); + } + + unpack_heatshrink_header(byte, + &heatshrink_p->window_sz2, + &heatshrink_p->lookahead_sz2); + +#if HEATSHRINK_DYNAMIC_ALLOC == 1 + heatshrink_p->decoder_p = heatshrink_decoder_alloc( + 256, + heatshrink_p->window_sz2, + heatshrink_p->lookahead_sz2); + + if (heatshrink_p->decoder_p == NULL) { + return (-DETOOLS_HEATSHRINK_HEADER); + } +#else + if ((heatshrink_p->window_sz2 != HEATSHRINK_STATIC_WINDOW_BITS) + || (heatshrink_p->lookahead_sz2 != HEATSHRINK_STATIC_LOOKAHEAD_BITS)) { + return (-DETOOLS_HEATSHRINK_HEADER); + } + + heatshrink_p->decoder_p = &heatshrink_p->decoder; + heatshrink_decoder_reset(heatshrink_p->decoder_p); +#endif + + } + + while (1) { + /* Get available data. */ + pres = heatshrink_decoder_poll(heatshrink_p->decoder_p, + buf_p, + left, + &size); + + if (pres < 0) { + return (-DETOOLS_HEATSHRINK_POLL); + } + + buf_p += size; + left -= size; + + if (left == 0) { + return (0); + } + + /* Input (sink) more data if available. */ + res = chunk_get(self_p->patch_chunk_p, &byte); + + if (res == 0) { + sres = heatshrink_decoder_sink(heatshrink_p->decoder_p, + &byte, + sizeof(byte), + &size); + + if ((sres < 0) || (size != sizeof(byte))) { + return (-DETOOLS_HEATSHRINK_SINK); + } + } else { + if (left != *size_p) { + *size_p -= left; + + return (0); + } else { + return (1); + } + } + } + + return (res); +} + +static int patch_reader_heatshrink_destroy( + struct detools_apply_patch_patch_reader_t *self_p) +{ + struct detools_apply_patch_patch_reader_heatshrink_t *heatshrink_p; + HSD_finish_res fres; + + heatshrink_p = &self_p->compression.heatshrink; + + if (heatshrink_p->decoder_p == NULL) { + return (0); + } + + fres = heatshrink_decoder_finish(heatshrink_p->decoder_p); + +#if HEATSHRINK_DYNAMIC_ALLOC == 1 + heatshrink_decoder_free(heatshrink_p->decoder_p); +#endif + + if (fres == HSDR_FINISH_DONE) { + return (0); + } else { + return (-DETOOLS_CORRUPT_PATCH); + } +} + +static int patch_reader_heatshrink_init( + struct detools_apply_patch_patch_reader_t *self_p) +{ + struct detools_apply_patch_patch_reader_heatshrink_t *heatshrink_p; + + heatshrink_p = &self_p->compression.heatshrink; + heatshrink_p->window_sz2 = -1; + heatshrink_p->lookahead_sz2 = -1; + heatshrink_p->decoder_p = NULL; + self_p->destroy = patch_reader_heatshrink_destroy; + self_p->decompress = patch_reader_heatshrink_decompress; + + return (0); +} + +#endif + +/* + * LZMA patch reader. + */ + +#if DETOOLS_CONFIG_COMPRESSION_LZMA == 1 + +static int get_decompressed_data( + struct detools_apply_patch_patch_reader_lzma_t *lzma_p, + uint8_t *buf_p, + size_t size) +{ + int res; + + if (lzma_p->output_size >= size) { + memcpy(buf_p, lzma_p->output_p, size); + memmove(lzma_p->output_p, + &lzma_p->output_p[size], + lzma_p->output_size - size); + lzma_p->output_size -= size; + res = 0; + } else { + res = 1; + } + + return (res); +} + +static int prepare_input_buffer(struct detools_apply_patch_patch_reader_t *self_p) +{ + struct detools_apply_patch_patch_reader_lzma_t *lzma_p; + uint8_t *next_p; + size_t left; + + lzma_p = &self_p->compression.lzma; + left = chunk_left(self_p->patch_chunk_p); + + if (left == 0) { + return (1); + } + + next_p = malloc(lzma_p->stream.avail_in + left); + + if (next_p == NULL) { + return (-DETOOLS_OUT_OF_MEMORY); + } + + if (lzma_p->stream.next_in != NULL) { + memcpy(next_p, lzma_p->stream.next_in, lzma_p->stream.avail_in); + free(lzma_p->input_p); + } + + lzma_p->input_p = next_p; + chunk_read_all_no_check(self_p->patch_chunk_p, + &lzma_p->input_p[lzma_p->stream.avail_in], + left); + lzma_p->stream.next_in = next_p; + lzma_p->stream.avail_in += left; + + return (0); +} + +static int prepare_output_buffer(struct detools_apply_patch_patch_reader_t *self_p, + size_t size) +{ + struct detools_apply_patch_patch_reader_lzma_t *lzma_p; + uint8_t *output_p; + + lzma_p = &self_p->compression.lzma; + + output_p = malloc(size); + + if (output_p == NULL) { + return (-DETOOLS_OUT_OF_MEMORY); + } + + if (lzma_p->output_p != NULL) { + memcpy(output_p, lzma_p->output_p, lzma_p->output_size); + free(lzma_p->output_p); + } + + lzma_p->output_p = output_p; + lzma_p->stream.next_out = (output_p + lzma_p->output_size); + lzma_p->stream.avail_out = (size - lzma_p->output_size); + + return (0); +} + +static int patch_reader_lzma_decompress( + struct detools_apply_patch_patch_reader_t *self_p, + uint8_t *buf_p, + size_t *size_p) +{ + int res; + struct detools_apply_patch_patch_reader_lzma_t *lzma_p; + lzma_ret ret; + + lzma_p = &self_p->compression.lzma; + + /* Check if enough decompressed data is available. */ + res = get_decompressed_data(lzma_p, buf_p, *size_p); + + if (res == 0) { + return (res); + } + + while (1) { + /* Try to decompress requested data. */ + if (lzma_p->stream.avail_in > 0) { + res = prepare_output_buffer(self_p, *size_p); + + if (res != 0) { + return (res); + } + + ret = lzma_code(&lzma_p->stream, LZMA_RUN); + + switch (ret) { + + case LZMA_OK: + case LZMA_STREAM_END: + break; + + default: + return (-DETOOLS_LZMA_DECODE); + } + + lzma_p->output_size = (size_t)(lzma_p->stream.next_out - lzma_p->output_p); + } + + /* Check if enough decompressed data is available. */ + res = get_decompressed_data(lzma_p, buf_p, *size_p); + + if (res == 0) { + return (res); + } + + /* Get more data to decompress. */ + res = prepare_input_buffer(self_p); + + if (res != 0) { + return (res); + } + } +} + +static int patch_reader_lzma_destroy( + struct detools_apply_patch_patch_reader_t *self_p) +{ + struct detools_apply_patch_patch_reader_lzma_t *lzma_p; + + lzma_p = &self_p->compression.lzma; + + if (lzma_p->input_p != NULL) { + free(lzma_p->input_p); + } + + if (lzma_p->output_p != NULL) { + free(lzma_p->output_p); + } + + lzma_end(&lzma_p->stream); + + if ((lzma_p->stream.avail_in == 0) && (lzma_p->output_size == 0)) { + return (0); + } else { + return (-DETOOLS_CORRUPT_PATCH); + } +} + +static int patch_reader_lzma_init(struct detools_apply_patch_patch_reader_t *self_p) +{ + lzma_ret ret; + struct detools_apply_patch_patch_reader_lzma_t *lzma_p; + + lzma_p = &self_p->compression.lzma; + memset(&lzma_p->stream, 0, sizeof(lzma_p->stream)); + + ret = lzma_alone_decoder(&lzma_p->stream, UINT64_MAX); + + if (ret != LZMA_OK) { + return (-DETOOLS_LZMA_INIT); + } + + lzma_p->input_p = NULL; + lzma_p->output_p = NULL; + lzma_p->output_size = 0; + self_p->destroy = patch_reader_lzma_destroy; + self_p->decompress = patch_reader_lzma_decompress; + + return (0); +} + +#endif + +/* + * CRLE patch reader. + */ + +#if DETOOLS_CONFIG_COMPRESSION_CRLE == 1 + +static void unpack_usize_init(struct detools_unpack_usize_t *self_p) +{ + self_p->state = detools_unpack_usize_state_first_t; + self_p->value = 0; + self_p->offset = 0; +} + +static int unpack_usize(struct detools_unpack_usize_t *self_p, + struct detools_apply_patch_chunk_t *patch_chunk_p, + int *size_p) +{ + int res; + uint8_t byte; + + switch (self_p->state) { + + case detools_unpack_usize_state_first_t: + self_p->value = 0; + self_p->offset = 0; + self_p->state = detools_unpack_usize_state_consecutive_t; + break; + + case detools_unpack_usize_state_consecutive_t: + break; + + default: + return (-DETOOLS_INTERNAL_ERROR); + } + + do { + res = chunk_get(patch_chunk_p, &byte); + + if (res != 0) { + return (res); + } + + if (is_overflow(self_p->offset)) { + return (-DETOOLS_CORRUPT_PATCH_OVERFLOW); + } + + self_p->value |= ((byte & 0x7f) << self_p->offset); + self_p->offset += 7; + } while ((byte & 0x80) != 0); + + *size_p = self_p->value; + + return (0); +} + +static int patch_reader_crle_decompress_idle( + struct detools_apply_patch_patch_reader_t *self_p, + struct detools_apply_patch_patch_reader_crle_t *crle_p) +{ + int res; + uint8_t kind; + + res = chunk_get(self_p->patch_chunk_p, &kind); + + if (res != 0) { + return (res); + } + + res = 2; + + switch (kind) { + + case 0: + crle_p->state = detools_crle_state_scattered_size_t; + unpack_usize_init(&crle_p->kind.scattered.size); + break; + + case 1: + crle_p->state = detools_crle_state_repeated_repetitions_t; + unpack_usize_init(&crle_p->kind.repeated.size); + break; + + default: + res = -DETOOLS_CORRUPT_PATCH_CRLE_KIND; + break; + } + + return (res); +} + +static int patch_reader_crle_decompress_scattered_size( + struct detools_apply_patch_patch_reader_t *self_p, + struct detools_apply_patch_patch_reader_crle_t *crle_p) +{ + int res; + int size; + + res = unpack_usize(&crle_p->kind.scattered.size, + self_p->patch_chunk_p, + &size); + + if (res != 0) { + return (res); + } + + crle_p->state = detools_crle_state_scattered_data_t; + crle_p->kind.scattered.number_of_bytes_left = (size_t)size; + + return (2); +} + +static int patch_reader_crle_decompress_scattered_data( + struct detools_apply_patch_patch_reader_t *self_p, + struct detools_apply_patch_patch_reader_crle_t *crle_p, + uint8_t *buf_p, + size_t *size_p) +{ + int res; + + *size_p = MIN(*size_p, crle_p->kind.scattered.number_of_bytes_left); + res = chunk_read(self_p->patch_chunk_p, buf_p, size_p); + + if (res != 0) { + return (res); + } + + crle_p->kind.scattered.number_of_bytes_left -= *size_p; + + if (crle_p->kind.scattered.number_of_bytes_left == 0) { + crle_p->state = detools_crle_state_idle_t; + } + + return (0); +} + +static int patch_reader_crle_decompress_repeated_repetitions( + struct detools_apply_patch_patch_reader_t *self_p, + struct detools_apply_patch_patch_reader_crle_t *crle_p) +{ + int res; + int repetitions; + + res = unpack_usize(&crle_p->kind.repeated.size, + self_p->patch_chunk_p, + &repetitions); + + if (res != 0) { + return (res); + } + + crle_p->state = detools_crle_state_repeated_data_t; + crle_p->kind.repeated.number_of_bytes_left = (size_t)repetitions; + + return (2); +} + +static int patch_reader_crle_decompress_repeated_data( + struct detools_apply_patch_patch_reader_t *self_p, + struct detools_apply_patch_patch_reader_crle_t *crle_p) +{ + int res; + + res = chunk_get(self_p->patch_chunk_p, + &crle_p->kind.repeated.value); + + if (res != 0) { + return (res); + } + + crle_p->state = detools_crle_state_repeated_data_read_t; + + return (2); +} + +static int patch_reader_crle_decompress_repeated_data_read( + struct detools_apply_patch_patch_reader_crle_t *crle_p, + uint8_t *buf_p, + size_t *size_p) +{ + size_t size; + size_t i; + + size = MIN(*size_p, crle_p->kind.repeated.number_of_bytes_left); + + for (i = 0; i < size; i++) { + buf_p[i] = crle_p->kind.repeated.value; + } + + *size_p = size; + crle_p->kind.repeated.number_of_bytes_left -= size; + + if (crle_p->kind.repeated.number_of_bytes_left == 0) { + crle_p->state = detools_crle_state_idle_t; + } + + return (0); +} + +static int patch_reader_crle_decompress( + struct detools_apply_patch_patch_reader_t *self_p, + uint8_t *buf_p, + size_t *size_p) +{ + int res; + struct detools_apply_patch_patch_reader_crle_t *crle_p; + + crle_p = &self_p->compression.crle; + + do { + switch (crle_p->state) { + + case detools_crle_state_idle_t: + res = patch_reader_crle_decompress_idle(self_p, crle_p); + break; + + case detools_crle_state_scattered_size_t: + res = patch_reader_crle_decompress_scattered_size(self_p, crle_p); + break; + + case detools_crle_state_scattered_data_t: + res = patch_reader_crle_decompress_scattered_data(self_p, + crle_p, + buf_p, + size_p); + break; + + case detools_crle_state_repeated_repetitions_t: + res = patch_reader_crle_decompress_repeated_repetitions(self_p, + crle_p); + break; + + case detools_crle_state_repeated_data_t: + res = patch_reader_crle_decompress_repeated_data(self_p, crle_p); + break; + + case detools_crle_state_repeated_data_read_t: + res = patch_reader_crle_decompress_repeated_data_read(crle_p, + buf_p, + size_p); + break; + + default: + res = -DETOOLS_INTERNAL_ERROR; + break; + } + } while (res == 2); + + return (res); +} + +static int patch_reader_crle_destroy( + struct detools_apply_patch_patch_reader_t *self_p) +{ + (void)self_p; + + return (0); +} + +static int patch_reader_crle_init(struct detools_apply_patch_patch_reader_t *self_p) +{ + + struct detools_apply_patch_patch_reader_crle_t *crle_p; + + crle_p = &self_p->compression.crle; + crle_p->state = detools_crle_state_idle_t; + self_p->destroy = patch_reader_crle_destroy; + self_p->decompress = patch_reader_crle_decompress; + + return (0); +} + +#endif + +/* + * Patch reader. + */ + +/** + * Initialize given patch reader. + */ +static int patch_reader_init(struct detools_apply_patch_patch_reader_t *self_p, + struct detools_apply_patch_chunk_t *patch_chunk_p, + size_t patch_size, + int compression) +{ + int res; + +#if DETOOLS_CONFIG_COMPRESSION_NONE != 1 + (void)patch_size; +#endif + + self_p->patch_chunk_p = patch_chunk_p; + self_p->size.state = detools_unpack_usize_state_first_t; + + switch (compression) { + +#if DETOOLS_CONFIG_COMPRESSION_NONE == 1 + case COMPRESSION_NONE: + res = patch_reader_none_init(self_p, patch_size); + break; +#endif + +#if DETOOLS_CONFIG_COMPRESSION_LZMA == 1 + case COMPRESSION_LZMA: + res = patch_reader_lzma_init(self_p); + break; +#endif + +#if DETOOLS_CONFIG_COMPRESSION_CRLE == 1 + case COMPRESSION_CRLE: + res = patch_reader_crle_init(self_p); + break; +#endif + +#if DETOOLS_CONFIG_COMPRESSION_HEATSHRINK == 1 + case COMPRESSION_HEATSHRINK: + res = patch_reader_heatshrink_init(self_p); + break; +#endif + + default: + res = -DETOOLS_BAD_COMPRESSION; + break; + } + + return (res); +} + +static int patch_reader_dump(struct detools_apply_patch_patch_reader_t *self_p, + int compression, + detools_state_write_t state_write) +{ + (void)self_p; + (void)state_write; + + int res; + + res = 0; + + switch (compression) { + +#if DETOOLS_CONFIG_COMPRESSION_NONE == 1 + case COMPRESSION_NONE: + break; +#endif + +#if DETOOLS_CONFIG_COMPRESSION_CRLE == 1 + case COMPRESSION_CRLE: + break; +#endif + +#if DETOOLS_CONFIG_COMPRESSION_HEATSHRINK == 1 +# if HEATSHRINK_DYNAMIC_ALLOC == 0 + case COMPRESSION_HEATSHRINK: + break; +# endif +#endif + + default: + res = -DETOOLS_NOT_IMPLEMENTED; + break; + } + + return (res); +} + +static int patch_reader_restore(struct detools_apply_patch_patch_reader_t *self_p, + struct detools_apply_patch_patch_reader_t *dumped_p, + struct detools_apply_patch_chunk_t *patch_chunk_p, + int compression, + detools_state_read_t state_read) +{ + (void)state_read; + + int res; + + res = 0; + *self_p = *dumped_p; + self_p->patch_chunk_p = patch_chunk_p; + + switch (compression) { + +#if DETOOLS_CONFIG_COMPRESSION_NONE == 1 + case COMPRESSION_NONE: + self_p->destroy = patch_reader_none_destroy; + self_p->decompress = patch_reader_none_decompress; + break; +#endif + +#if DETOOLS_CONFIG_COMPRESSION_CRLE == 1 + case COMPRESSION_CRLE: + self_p->destroy = patch_reader_crle_destroy; + self_p->decompress = patch_reader_crle_decompress; + break; +#endif + +#if DETOOLS_CONFIG_COMPRESSION_HEATSHRINK == 1 +# if HEATSHRINK_DYNAMIC_ALLOC == 0 + case COMPRESSION_HEATSHRINK: + self_p->compression.heatshrink.decoder_p = + &self_p->compression.heatshrink.decoder; + self_p->destroy = patch_reader_heatshrink_destroy; + self_p->decompress = patch_reader_heatshrink_decompress; + break; +# endif +#endif + + default: + res = -DETOOLS_NOT_IMPLEMENTED; + break; + } + + return (res); +} + +/** + * Try to decompress given number of bytes. + * + * @return zero(0) if at least one byte was decompressed, one(1) if + * zero bytes were decompressed and more input is needed, or + * negative error code. + */ +static int patch_reader_decompress( + struct detools_apply_patch_patch_reader_t *self_p, + uint8_t *buf_p, + size_t *size_p) +{ + return (self_p->decompress(self_p, buf_p, size_p)); +} + +/** + * Unpack a size value. + */ +static int patch_reader_unpack_size( + struct detools_apply_patch_patch_reader_t *self_p, + int *size_p) +{ + int res; + uint8_t byte; + size_t size; + + size = 1; + + do { + switch (self_p->size.state) { + + case detools_unpack_usize_state_first_t: + res = patch_reader_decompress(self_p, &byte, &size); + + if (res != 0) { + return (res); + } + + self_p->size.is_signed = ((byte & 0x40) == 0x40); + self_p->size.value = (byte & 0x3f); + self_p->size.offset = 6; + self_p->size.state = detools_unpack_usize_state_consecutive_t; + break; + + case detools_unpack_usize_state_consecutive_t: + res = patch_reader_decompress(self_p, &byte, &size); + + if (res != 0) { + return (res); + } + + if (is_overflow(self_p->size.offset)) { + return (-DETOOLS_CORRUPT_PATCH_OVERFLOW); + } + + self_p->size.value |= ((byte & 0x7f) << self_p->size.offset); + self_p->size.offset += 7; + break; + + default: + return (-DETOOLS_INTERNAL_ERROR); + } + } while ((byte & 0x80) != 0); + + /* Done, fix sign. */ + self_p->size.state = detools_unpack_usize_state_first_t; + + if (self_p->size.is_signed) { + self_p->size.value *= -1; + } + + *size_p = self_p->size.value; + + return (res); +} + +static int common_process_size( + struct detools_apply_patch_patch_reader_t *patch_reader_p, + size_t to_pos, + size_t to_size, + int *size_p) +{ + int res; + + res = patch_reader_unpack_size(patch_reader_p, size_p); + + if (res != 0) { + return (res); + } + + if (to_pos + (size_t)*size_p > to_size) { + return (-DETOOLS_CORRUPT_PATCH); + } + + return (res); +} + +/* + * Low level sequential patch type functionality. + */ + +static int process_init_fixed_header(struct detools_apply_patch_t *self_p) +{ + int patch_type; + uint8_t byte; + + if (chunk_get(&self_p->chunk, &byte) != 0) { + return (-DETOOLS_SHORT_HEADER); + } + + patch_type = ((byte >> 4) & 0x7); + self_p->compression = (byte & 0xf); + + if (patch_type != PATCH_TYPE_SEQUENTIAL) { + return (-DETOOLS_BAD_PATCH_TYPE); + } + + self_p->init_state = detools_apply_patch_init_state_to_size_t; + self_p->size.state = detools_unpack_usize_state_first_t; + + return (0); +} + +static int process_init_to_size(struct detools_apply_patch_t *self_p) +{ + int res; + int to_size; + + res = chunk_unpack_header_size(&self_p->chunk, &self_p->size, &to_size); + + if (res != 0) { + return (res); + } + + res = patch_reader_init(&self_p->patch_reader, + &self_p->chunk, + self_p->patch_size - self_p->chunk.offset, + self_p->compression); + + if (res != 0) { + return (res); + } + + if (to_size < 0) { + return (-DETOOLS_CORRUPT_PATCH); + } + + self_p->to_size = (size_t)to_size; + + if (to_size > 0) { + self_p->state = detools_apply_patch_state_dfpatch_size_t; + } else { + self_p->state = detools_apply_patch_state_done_t; + } + + return (res); +} + +static int process_init(struct detools_apply_patch_t *self_p) +{ + int res; + + switch (self_p->init_state) { + + case detools_apply_patch_init_state_fixed_header_t: + res = process_init_fixed_header(self_p); + break; + + case detools_apply_patch_init_state_to_size_t: + res = process_init_to_size(self_p); + break; + + default: + res = -DETOOLS_INTERNAL_ERROR; + break; + } + + return (res); +} + +static int process_dfpatch_size(struct detools_apply_patch_t *self_p) +{ + int res; + int size; + + res = patch_reader_unpack_size(&self_p->patch_reader, &size); + + if (res != 0) { + return (res); + } + + if (size > 0) { + return (-DETOOLS_NOT_IMPLEMENTED); + } + + self_p->state = detools_apply_patch_state_diff_size_t; + + return (0); +} + +static int process_size(struct detools_apply_patch_t *self_p, + enum detools_apply_patch_state_t next_state) +{ + int res; + int size; + + res = common_process_size(&self_p->patch_reader, + self_p->to_offset, + self_p->to_size, + &size); + + if (res != 0) { + return (res); + } + + self_p->state = next_state; + self_p->chunk_size = (size_t)size; + + return (res); +} + +static int process_data(struct detools_apply_patch_t *self_p, + enum detools_apply_patch_state_t next_state) +{ + int res; + size_t i; + uint8_t to[128]; + size_t to_size; + uint8_t from[128]; + + to_size = MIN(sizeof(to), self_p->chunk_size); + + if (to_size == 0) { + self_p->state = next_state; + + return (0); + } + + res = patch_reader_decompress(&self_p->patch_reader, + &to[0], + &to_size); + + if (res != 0) { + return (res); + } + + if (next_state == detools_apply_patch_state_extra_size_t) { + res = self_p->from_read(self_p->arg_p, &from[0], to_size); + + if (res != 0) { + return (-DETOOLS_IO_FAILED); + } + + self_p->from_offset += to_size; + + for (i = 0; i < to_size; i++) { + to[i] = (uint8_t)(to[i] + from[i]); + } + } + + self_p->to_offset += to_size; + self_p->chunk_size -= to_size; + + res = self_p->to_write(self_p->arg_p, &to[0], to_size); + + if (res != 0) { + return (-DETOOLS_IO_FAILED); + } + + return (res); +} + +static int process_diff_size(struct detools_apply_patch_t *self_p) +{ + return (process_size(self_p, detools_apply_patch_state_diff_data_t)); +} + +static int process_diff_data(struct detools_apply_patch_t *self_p) +{ + return (process_data(self_p, detools_apply_patch_state_extra_size_t)); +} + +static int process_extra_size(struct detools_apply_patch_t *self_p) +{ + return (process_size(self_p, detools_apply_patch_state_extra_data_t)); +} + +static int process_extra_data(struct detools_apply_patch_t *self_p) +{ + return (process_data(self_p, detools_apply_patch_state_adjustment_t)); +} + +static int process_adjustment(struct detools_apply_patch_t *self_p) +{ + int res; + int offset; + + res = patch_reader_unpack_size(&self_p->patch_reader, &offset); + + if (res != 0) { + return (res); + } + + res = self_p->from_seek(self_p->arg_p, offset); + + if (res != 0) { + return (-DETOOLS_IO_FAILED); + } + + self_p->from_offset += offset; + + if (self_p->to_offset == self_p->to_size) { + self_p->state = detools_apply_patch_state_done_t; + } else { + self_p->state = detools_apply_patch_state_diff_size_t; + } + + return (res); +} + +static int apply_patch_process_once(struct detools_apply_patch_t *self_p) +{ + int res; + + switch (self_p->state) { + + case detools_apply_patch_state_init_t: + res = process_init(self_p); + break; + + case detools_apply_patch_state_dfpatch_size_t: + res = process_dfpatch_size(self_p); + break; + + case detools_apply_patch_state_diff_size_t: + res = process_diff_size(self_p); + break; + + case detools_apply_patch_state_diff_data_t: + res = process_diff_data(self_p); + break; + + case detools_apply_patch_state_extra_size_t: + res = process_extra_size(self_p); + break; + + case detools_apply_patch_state_extra_data_t: + res = process_extra_data(self_p); + break; + + case detools_apply_patch_state_adjustment_t: + res = process_adjustment(self_p); + break; + + case detools_apply_patch_state_done_t: + return (-DETOOLS_ALREADY_DONE); + + case detools_apply_patch_state_failed_t: + res = -DETOOLS_ALREADY_FAILED; + break; + + default: + res = -DETOOLS_INTERNAL_ERROR; + break; + } + + if (res < 0) { + self_p->state = detools_apply_patch_state_failed_t; + } + + return (res); +} + +static int apply_patch_common_finalize( + int res, + struct detools_apply_patch_patch_reader_t *patch_reader_p, + size_t to_size) +{ + if (res == 1) { + res = -DETOOLS_NOT_ENOUGH_PATCH_DATA; + } + + if (res == -DETOOLS_ALREADY_DONE) { + res = 0; + } + + if (patch_reader_p->destroy != NULL) { + if (res == 0) { + res = patch_reader_p->destroy(patch_reader_p); + } else { + (void)patch_reader_p->destroy(patch_reader_p); + } + } + + if (res == 0) { + res = (int)to_size; + } + + return (res); +} + +int detools_apply_patch_init(struct detools_apply_patch_t *self_p, + detools_read_t from_read, + detools_seek_t from_seek, + size_t patch_size, + detools_write_t to_write, + void *arg_p) +{ + self_p->from_read = from_read; + self_p->from_seek = from_seek; + self_p->patch_size = patch_size; + self_p->patch_offset = 0; + self_p->to_offset = 0; + self_p->to_write = to_write; + self_p->from_offset = 0; + self_p->arg_p = arg_p; + self_p->state = detools_apply_patch_state_init_t; + self_p->init_state = detools_apply_patch_init_state_fixed_header_t; + self_p->patch_reader.destroy = NULL; + + return (0); +} + +int detools_apply_patch_dump(struct detools_apply_patch_t *self_p, + detools_state_write_t state_write) +{ + int res; + + res = state_write(self_p->arg_p, self_p, sizeof(*self_p)); + + if (res != 0) { + return (-DETOOLS_IO_FAILED); + } + + if (self_p->state == detools_apply_patch_state_init_t) { + return (0); + } + + return (patch_reader_dump(&self_p->patch_reader, + self_p->compression, + state_write)); +} + +int detools_apply_patch_restore(struct detools_apply_patch_t *self_p, + detools_state_read_t state_read) +{ + int res; + struct detools_apply_patch_t dumped; + + res = state_read(self_p->arg_p, &dumped, sizeof(dumped)); + + if (res != 0) { + return (-DETOOLS_IO_FAILED); + } + + self_p->state = dumped.state; + self_p->patch_size = dumped.patch_size; + + if (self_p->state == detools_apply_patch_state_init_t) { + return (0); + } + + self_p->compression = dumped.compression; + self_p->patch_offset = dumped.patch_offset; + self_p->to_offset = dumped.to_offset; + self_p->to_size = dumped.to_size; + self_p->from_offset = dumped.from_offset; + self_p->chunk_size = dumped.chunk_size; + + res = self_p->from_seek(self_p->arg_p, self_p->from_offset); + + if (res != 0) { + return (-DETOOLS_IO_FAILED); + } + + return (patch_reader_restore(&self_p->patch_reader, + &dumped.patch_reader, + &self_p->chunk, + self_p->compression, + state_read)); +} + +size_t detools_apply_patch_get_patch_offset(struct detools_apply_patch_t *self_p) +{ + return (self_p->patch_offset); +} + +size_t detools_apply_patch_get_to_offset(struct detools_apply_patch_t *self_p) +{ + return (self_p->to_offset); +} + +int detools_apply_patch_process(struct detools_apply_patch_t *self_p, + const uint8_t *patch_p, + size_t size) +{ + int res; + + res = 0; + self_p->patch_offset += size; + self_p->chunk.buf_p = patch_p; + self_p->chunk.size = size; + self_p->chunk.offset = 0; + + while (chunk_available(&self_p->chunk) && (res >= 0)) { + res = apply_patch_process_once(self_p); + } + + if ((res == 1) || (res == -DETOOLS_ALREADY_DONE)) { + res = 0; + } + + return (res); +} + +int detools_apply_patch_finalize(struct detools_apply_patch_t *self_p) +{ + int res; + + self_p->chunk.size = 0; + self_p->chunk.offset = 0; + + do { + res = apply_patch_process_once(self_p); + } while (res == 0); + + return (apply_patch_common_finalize(res, + &self_p->patch_reader, + self_p->to_size)); +} + +/* + * Low level in-place patch type functionality. + */ + +static int in_place_all_steps_completed(struct detools_apply_patch_in_place_t *self_p) +{ + int res; + + res = 0; + + if (self_p->step_set != NULL) { + res = self_p->step_set(self_p->arg_p, 0); + + if (res != 0) { + res = -DETOOLS_STEP_SET_FAILED; + } + } + + return (res); +} + +static int in_place_is_step_completed(struct detools_apply_patch_in_place_t *self_p, + bool *res_p) +{ + int res; + int completed_step; + + if (self_p->step_get != NULL) { + res = self_p->step_get(self_p->arg_p, &completed_step); + + if (res != 0) { + return (-DETOOLS_STEP_GET_FAILED); + } + + *res_p = (self_p->ongoing_step <= completed_step); + } else { + *res_p = false; + } + + return (0); +} + +static int in_place_next_step(struct detools_apply_patch_in_place_t *self_p) +{ + int res; + bool is_step_completed; + + res = 0; + + if (self_p->step_set != NULL) { + res = in_place_is_step_completed(self_p, &is_step_completed); + + if (res != 0) { + return (res); + } + + if (!is_step_completed) { + res = self_p->step_set(self_p->arg_p, self_p->ongoing_step); + + if (res != 0) { + res = -DETOOLS_STEP_SET_FAILED; + } + } + } + + self_p->ongoing_step++; + + return (res); +} + +static int in_place_mem_read(struct detools_apply_patch_in_place_t *self_p, + void *dst_p, + uintptr_t src, + size_t size) +{ + int res; + bool is_step_completed; + + res = in_place_is_step_completed(self_p, &is_step_completed); + + if (res != 0) { + return (res); + } + + if (!is_step_completed) { + return (self_p->mem_read(self_p->arg_p, dst_p, src, size)); + } else { + memset(dst_p, 0, size); + + return (0); + } +} + +static int in_place_mem_write(struct detools_apply_patch_in_place_t *self_p, + uintptr_t dst, + void *src_p, + size_t size) +{ + int res; + bool is_step_completed; + + res = in_place_is_step_completed(self_p, &is_step_completed); + + if (res != 0) { + return (res); + } + + if (!is_step_completed) { + return (self_p->mem_write(self_p->arg_p, dst, src_p, size)); + } else { + return (0); + } +} + +static int in_place_mem_erase(struct detools_apply_patch_in_place_t *self_p, + uintptr_t addr, + size_t size) +{ + int res; + bool is_step_completed; + + res = in_place_is_step_completed(self_p, &is_step_completed); + + if (res != 0) { + return (res); + } + + if (!is_step_completed) { + return (self_p->mem_erase(self_p->arg_p, addr, size)); + } else { + return (0); + } +} + +static int in_place_shift_memory(struct detools_apply_patch_in_place_t *self_p, + size_t memory_size, + size_t from_size) +{ + size_t i; + size_t number_of_segments; + int res; + size_t read_address; + size_t write_address; + uint8_t buf[128]; + size_t offset; + size_t size; + + number_of_segments = DIV_CEIL(MIN(from_size, memory_size - self_p->shift_size), + self_p->segment_size); + read_address = ((number_of_segments - 1) * self_p->segment_size); + write_address = (read_address + self_p->shift_size); + + for (i = 0; i < number_of_segments; i++) { + /* Erase segment to write to. */ + res = in_place_mem_erase(self_p, + write_address, + self_p->segment_size); + + if (res != 0) { + return (res); + } + + /* Copy data to erased segment. */ + offset = 0; + + while (offset < self_p->segment_size) { + size = MIN(sizeof(buf), self_p->segment_size - offset); + res = in_place_mem_read(self_p, + &buf[0], + read_address + offset, + size); + + if (res != 0) { + return (res); + } + + res = in_place_mem_write(self_p, + write_address + offset, + &buf[0], + size); + + if (res != 0) { + return (res); + } + + offset += size; + } + + res = in_place_next_step(self_p); + + if (res != 0) { + return (res); + } + + write_address -= self_p->segment_size; + read_address -= self_p->segment_size; + } + + return (0); +} + +static int in_place_process_init_fixed_header( + struct detools_apply_patch_in_place_t *self_p) +{ + int patch_type; + uint8_t byte; + + if (chunk_get(&self_p->chunk, &byte) != 0) { + return (-DETOOLS_SHORT_HEADER); + } + + patch_type = ((byte >> 4) & 0x7); + self_p->compression = (byte & 0xf); + + if (patch_type != PATCH_TYPE_IN_PLACE) { + return (-DETOOLS_BAD_PATCH_TYPE); + } + + self_p->init_state = detools_apply_patch_in_place_init_state_memory_size_t; + self_p->size.state = detools_unpack_usize_state_first_t; + + return (0); +} + +static int in_place_process_init_memory_size( + struct detools_apply_patch_in_place_t *self_p) +{ + int res; + int memory_size; + + res = chunk_unpack_header_size(&self_p->chunk, &self_p->size, &memory_size); + + if (res != 0) { + return (res); + } + + self_p->memory_size = (size_t)memory_size; + self_p->init_state = detools_apply_patch_in_place_init_state_segment_size_t; + self_p->size.state = detools_unpack_usize_state_first_t; + + return (0); +} + +static int in_place_process_init_segment_size( + struct detools_apply_patch_in_place_t *self_p) +{ + int res; + int segment_size; + + res = chunk_unpack_header_size(&self_p->chunk, &self_p->size, &segment_size); + + if (res != 0) { + return (res); + } + + self_p->segment_size = (size_t)segment_size; + self_p->init_state = detools_apply_patch_in_place_init_state_shift_size_t; + self_p->size.state = detools_unpack_usize_state_first_t; + + return (0); +} + +static int in_place_process_init_shift_size( + struct detools_apply_patch_in_place_t *self_p) +{ + int res; + int shift_size; + + res = chunk_unpack_header_size(&self_p->chunk, &self_p->size, &shift_size); + + if (res != 0) { + return (res); + } + + self_p->shift_size = (size_t)shift_size; + self_p->init_state = detools_apply_patch_in_place_init_state_from_size_t; + self_p->size.state = detools_unpack_usize_state_first_t; + + return (0); +} + +static int in_place_process_init_from_size( + struct detools_apply_patch_in_place_t *self_p) +{ + int res; + int from_size; + + res = chunk_unpack_header_size(&self_p->chunk, &self_p->size, &from_size); + + if (res != 0) { + return (res); + } + + self_p->from_size = (size_t)from_size; + self_p->init_state = detools_apply_patch_in_place_init_state_to_size_t; + self_p->size.state = detools_unpack_usize_state_first_t; + + return (0); +} + +static int in_place_process_init_to_size( + struct detools_apply_patch_in_place_t *self_p) +{ + int res; + int to_size; + + res = chunk_unpack_header_size(&self_p->chunk, &self_p->size, &to_size); + + if (res != 0) { + return (res); + } + + res = patch_reader_init(&self_p->patch_reader, + &self_p->chunk, + self_p->patch_size - self_p->chunk.offset, + self_p->compression); + + if (res != 0) { + return (res); + } + + if (to_size < 0) { + return (-DETOOLS_CORRUPT_PATCH); + } + + self_p->to_pos = 0; + self_p->to_size = (size_t)to_size; + self_p->segment.index = 0; + + if (to_size > 0) { + res = in_place_shift_memory(self_p, + self_p->memory_size, + self_p->from_size); + + if (res != 0) { + return (res); + } + + self_p->state = detools_apply_patch_state_dfpatch_size_t; + } else { + self_p->state = detools_apply_patch_state_done_t; + } + + return (res); +} + +static int in_place_process_init(struct detools_apply_patch_in_place_t *self_p) +{ + int res; + + switch (self_p->init_state) { + + case detools_apply_patch_in_place_init_state_fixed_header_t: + res = in_place_process_init_fixed_header(self_p); + break; + + case detools_apply_patch_in_place_init_state_memory_size_t: + res = in_place_process_init_memory_size(self_p); + break; + + case detools_apply_patch_in_place_init_state_segment_size_t: + res = in_place_process_init_segment_size(self_p); + break; + + case detools_apply_patch_in_place_init_state_shift_size_t: + res = in_place_process_init_shift_size(self_p); + break; + + case detools_apply_patch_in_place_init_state_from_size_t: + res = in_place_process_init_from_size(self_p); + break; + + case detools_apply_patch_in_place_init_state_to_size_t: + res = in_place_process_init_to_size(self_p); + break; + + default: + res = -DETOOLS_INTERNAL_ERROR; + break; + } + + return (res); +} + +static int in_place_process_dfpatch_size( + struct detools_apply_patch_in_place_t *self_p) +{ + int res; + int size; + + res = patch_reader_unpack_size(&self_p->patch_reader, &size); + + if (res != 0) { + return (res); + } + + if (size > 0) { + return (-DETOOLS_NOT_IMPLEMENTED); + } + + self_p->state = detools_apply_patch_state_diff_size_t; + self_p->segment.from_offset = + (int)MAX(self_p->segment_size * (self_p->segment.index + 1), + self_p->shift_size); + self_p->segment.to_offset = (self_p->segment.index * self_p->segment_size); + self_p->segment.to_size = MIN(self_p->segment_size, + self_p->to_size - self_p->segment.to_offset); + self_p->segment.to_pos = 0; + self_p->segment.index++; + + return (in_place_mem_erase(self_p, + self_p->segment.to_offset, + self_p->segment.to_size)); +} + +static int in_place_process_size(struct detools_apply_patch_in_place_t *self_p, + enum detools_apply_patch_state_t next_state) +{ + int res; + int size; + + res = common_process_size(&self_p->patch_reader, + self_p->to_pos, + self_p->to_size, + &size); + + if (res != 0) { + return (res); + } + + self_p->state = next_state; + self_p->chunk_size = (size_t)size; + + return (0); +} + +static int in_place_process_data(struct detools_apply_patch_in_place_t *self_p, + enum detools_apply_patch_state_t next_state) +{ + int res; + size_t i; + uint8_t to[128]; + size_t to_size; + uint8_t from[128]; + + to_size = MIN(sizeof(to), self_p->chunk_size); + + if (to_size == 0) { + self_p->state = next_state; + + return (0); + } + + res = patch_reader_decompress(&self_p->patch_reader, + &to[0], + &to_size); + + if (res != 0) { + return (res); + } + + if (next_state == detools_apply_patch_state_extra_size_t) { + res = in_place_mem_read(self_p, + &from[0], + (size_t)self_p->segment.from_offset, + to_size); + + if (res != 0) { + return (-DETOOLS_IO_FAILED); + } + + self_p->segment.from_offset += (int)to_size; + + for (i = 0; i < to_size; i++) { + to[i] = (uint8_t)(to[i] + from[i]); + } + } + + res = in_place_mem_write(self_p, + self_p->segment.to_pos + self_p->segment.to_offset, + &to[0], + to_size); + + if (res != 0) { + return (-DETOOLS_IO_FAILED); + } + + self_p->to_pos += to_size; + self_p->segment.to_pos += to_size; + self_p->chunk_size -= to_size; + + return (res); +} + +static int in_place_process_diff_size(struct detools_apply_patch_in_place_t *self_p) +{ + return (in_place_process_size(self_p, detools_apply_patch_state_diff_data_t)); +} + +static int in_place_process_diff_data(struct detools_apply_patch_in_place_t *self_p) +{ + return (in_place_process_data(self_p, detools_apply_patch_state_extra_size_t)); +} + +static int in_place_process_extra_size(struct detools_apply_patch_in_place_t *self_p) +{ + return (in_place_process_size(self_p, detools_apply_patch_state_extra_data_t)); +} + +static int in_place_process_extra_data(struct detools_apply_patch_in_place_t *self_p) +{ + return (in_place_process_data(self_p, detools_apply_patch_state_adjustment_t)); +} + +static int in_place_process_adjustment(struct detools_apply_patch_in_place_t *self_p) +{ + int res; + int offset; + + res = patch_reader_unpack_size(&self_p->patch_reader, &offset); + + if (res != 0) { + return (res); + } + + if (self_p->to_pos == self_p->to_size) { + res = in_place_all_steps_completed(self_p); + self_p->state = detools_apply_patch_state_done_t; + } else if (self_p->segment.to_pos == self_p->segment.to_size) { + res = in_place_next_step(self_p); + self_p->state = detools_apply_patch_state_dfpatch_size_t; + } else { + self_p->segment.from_offset += offset; + self_p->state = detools_apply_patch_state_diff_size_t; + } + + return (res); +} + +static int apply_patch_in_place_process_once( + struct detools_apply_patch_in_place_t *self_p) +{ + int res; + + switch (self_p->state) { + + case detools_apply_patch_state_init_t: + res = in_place_process_init(self_p); + break; + + case detools_apply_patch_state_dfpatch_size_t: + res = in_place_process_dfpatch_size(self_p); + break; + + case detools_apply_patch_state_diff_size_t: + res = in_place_process_diff_size(self_p); + break; + + case detools_apply_patch_state_diff_data_t: + res = in_place_process_diff_data(self_p); + break; + + case detools_apply_patch_state_extra_size_t: + res = in_place_process_extra_size(self_p); + break; + + case detools_apply_patch_state_extra_data_t: + res = in_place_process_extra_data(self_p); + break; + + case detools_apply_patch_state_adjustment_t: + res = in_place_process_adjustment(self_p); + break; + + case detools_apply_patch_state_done_t: + return (-DETOOLS_ALREADY_DONE); + + case detools_apply_patch_state_failed_t: + res = -DETOOLS_ALREADY_FAILED; + break; + + default: + res = -DETOOLS_INTERNAL_ERROR; + break; + } + + if (res < 0) { + self_p->state = detools_apply_patch_state_failed_t; + } + + return (res); +} + +int detools_apply_patch_in_place_init( + struct detools_apply_patch_in_place_t *self_p, + detools_mem_read_t mem_read, + detools_mem_write_t mem_write, + detools_mem_erase_t mem_erase, + detools_step_set_t step_set, + detools_step_get_t step_get, + size_t patch_size, + void *arg_p) +{ + self_p->mem_read = mem_read; + self_p->mem_write = mem_write; + self_p->mem_erase = mem_erase; + self_p->step_set = step_set; + self_p->step_get = step_get; + self_p->patch_size = patch_size; + self_p->arg_p = arg_p; + self_p->state = detools_apply_patch_state_init_t; + self_p->ongoing_step = 1; + self_p->init_state = detools_apply_patch_in_place_init_state_fixed_header_t; + self_p->patch_reader.destroy = NULL; + + return (0); +} + +int detools_apply_patch_in_place_process( + struct detools_apply_patch_in_place_t *self_p, + const uint8_t *patch_p, + size_t size) +{ + int res; + + res = 0; + self_p->chunk.buf_p = patch_p; + self_p->chunk.size = size; + self_p->chunk.offset = 0; + + while (chunk_available(&self_p->chunk) && (res >= 0)) { + res = apply_patch_in_place_process_once(self_p); + } + + if ((res == 1) || (res == -DETOOLS_ALREADY_DONE)) { + res = 0; + } + + return (res); +} + +int detools_apply_patch_in_place_finalize( + struct detools_apply_patch_in_place_t *self_p) +{ + int res; + + self_p->chunk.size = 0; + self_p->chunk.offset = 0; + + do { + res = apply_patch_in_place_process_once(self_p); + } while (res == 0); + + return (apply_patch_common_finalize(res, + &self_p->patch_reader, + self_p->to_size)); +} + +/* + * Callback functionality. + */ + +static int callbacks_process(struct detools_apply_patch_t *apply_patch_p, + detools_read_t patch_read, + size_t patch_size, + void *arg_p) +{ + int res; + size_t patch_offset; + size_t chunk_size; + uint8_t chunk[512]; + + res = 0; + patch_offset = 0; + + while ((patch_offset < patch_size) && (res == 0)) { + chunk_size = MIN(patch_size - patch_offset, 512); + res = patch_read(arg_p, &chunk[0], chunk_size); + + if (res == 0) { + res = detools_apply_patch_process(apply_patch_p, + &chunk[0], + chunk_size); + patch_offset += chunk_size; + } else { + res = -DETOOLS_IO_FAILED; + } + } + + if (res == 0) { + res = detools_apply_patch_finalize(apply_patch_p); + } else { + (void)detools_apply_patch_finalize(apply_patch_p); + } + + return (res); +} + +int detools_apply_patch_callbacks(detools_read_t from_read, + detools_seek_t from_seek, + detools_read_t patch_read, + size_t patch_size, + detools_write_t to_write, + void *arg_p) +{ + int res; + struct detools_apply_patch_t apply_patch; + + res = detools_apply_patch_init(&apply_patch, + from_read, + from_seek, + patch_size, + to_write, + arg_p); + + if (res != 0) { + return (res); + } + + return (callbacks_process(&apply_patch, patch_read, patch_size, arg_p)); +} + +static int in_place_callbacks_process( + struct detools_apply_patch_in_place_t *apply_patch_p, + detools_read_t patch_read, + size_t patch_size, + void *arg_p) +{ + int res; + size_t patch_offset; + size_t chunk_size; + uint8_t chunk[512]; + + res = 0; + patch_offset = 0; + + while ((patch_offset < patch_size) && (res == 0)) { + chunk_size = MIN(patch_size - patch_offset, 512); + res = patch_read(arg_p, &chunk[0], chunk_size); + + if (res == 0) { + res = detools_apply_patch_in_place_process(apply_patch_p, + &chunk[0], + chunk_size); + patch_offset += chunk_size; + } else { + res = -DETOOLS_IO_FAILED; + } + } + + if (res == 0) { + res = detools_apply_patch_in_place_finalize(apply_patch_p); + } else { + (void)detools_apply_patch_in_place_finalize(apply_patch_p); + } + + return (res); +} + +int detools_apply_patch_in_place_callbacks(detools_mem_read_t mem_read, + detools_mem_write_t mem_write, + detools_mem_erase_t mem_erase, + detools_step_set_t step_set, + detools_step_get_t step_get, + detools_read_t patch_read, + size_t patch_size, + void *arg_p) +{ + int res; + struct detools_apply_patch_in_place_t apply_patch; + + res = detools_apply_patch_in_place_init(&apply_patch, + mem_read, + mem_write, + mem_erase, + step_set, + step_get, + patch_size, + arg_p); + + if (res != 0) { + return (res); + } + + return (in_place_callbacks_process(&apply_patch, + patch_read, + patch_size, + arg_p)); +} + +/* + * File io functionality. + */ + +#if DETOOLS_CONFIG_FILE_IO == 1 + +struct file_io_t { + FILE *ffrom_p; + FILE *fpatch_p; + FILE *fto_p; +}; + +static int file_size(FILE *file_p, size_t *size_p) +{ + int res; + long size; + + res = fseek(file_p, 0, SEEK_END); + + if (res != 0) { + return (-DETOOLS_FILE_SEEK_FAILED); + } + + size = ftell(file_p); + + if (size <= 0) { + return (-DETOOLS_FILE_TELL_FAILED); + } + + *size_p = (size_t)size; + + res = fseek(file_p, 0, SEEK_SET); + + if (res != 0) { + return (-DETOOLS_FILE_SEEK_FAILED); + } + + return (res); +} + +static int file_io_init(struct file_io_t *self_p, + const char *from_p, + const char *patch_p, + const char *to_p, + size_t *patch_size_p) +{ + int res; + FILE *file_p; + + res = -DETOOLS_FILE_OPEN_FAILED; + + /* From. */ + file_p = fopen(from_p, "rb"); + + if (file_p == NULL) { + return (res); + } + + self_p->ffrom_p = file_p; + + /* To. */ + file_p = fopen(to_p, "wb"); + + if (file_p == NULL) { + goto err1; + } + + self_p->fto_p = file_p; + + /* Patch. */ + file_p = fopen(patch_p, "rb"); + + if (file_p == NULL) { + goto err2; + } + + self_p->fpatch_p = file_p; + res = file_size(self_p->fpatch_p, patch_size_p); + + if (res != 0) { + goto err3; + } + + return (res); + + err3: + fclose(self_p->fpatch_p); + + err2: + fclose(self_p->fto_p); + + err1: + fclose(self_p->ffrom_p); + + return (res); +} + +static int file_io_cleanup(struct file_io_t *self_p) +{ + int res; + int res2; + int res3; + + res = fclose(self_p->ffrom_p); + res2 = fclose(self_p->fto_p); + res3 = fclose(self_p->fpatch_p); + + if ((res != 0) || (res2 != 0) || (res3 != 0)) { + res = -DETOOLS_FILE_CLOSE_FAILED; + } + + return (res); +} + +static int file_io_read(FILE *file_p, uint8_t *buf_p, size_t size) +{ + int res; + + res = 0; + + if (size > 0) { + if (fread(buf_p, size, 1, file_p) != 1) { + res = -DETOOLS_FILE_READ_FAILED; + } + } + + return (res); +} + +static int file_io_from_read(void *arg_p, uint8_t *buf_p, size_t size) +{ + struct file_io_t *self_p; + + self_p = (struct file_io_t *)arg_p; + + return (file_io_read(self_p->ffrom_p, buf_p, size)); +} + +static int file_io_from_seek(void *arg_p, int offset) +{ + struct file_io_t *self_p; + + self_p = (struct file_io_t *)arg_p; + + return (fseek(self_p->ffrom_p, offset, SEEK_CUR)); +} + +static int file_io_patch_read(void *arg_p, uint8_t *buf_p, size_t size) +{ + struct file_io_t *self_p; + + self_p = (struct file_io_t *)arg_p; + + return (file_io_read(self_p->fpatch_p, buf_p, size)); +} + +static int file_io_to_write(void *arg_p, const uint8_t *buf_p, size_t size) +{ + int res; + struct file_io_t *self_p; + + self_p = (struct file_io_t *)arg_p; + res = 0; + + if (size > 0) { + if (fwrite(buf_p, size, 1, self_p->fto_p) != 1) { + res = -DETOOLS_FILE_WRITE_FAILED; + } + } + + return (res); +} + +int detools_apply_patch_filenames(const char *from_p, + const char *patch_p, + const char *to_p) +{ + int res; + struct file_io_t file_io; + size_t patch_size; + + res = file_io_init(&file_io, + from_p, + patch_p, + to_p, + &patch_size); + + if (res != 0) { + return (res); + } + + res = detools_apply_patch_callbacks(file_io_from_read, + file_io_from_seek, + file_io_patch_read, + patch_size, + file_io_to_write, + &file_io); + + if (res != 0) { + goto err1; + } + + return (file_io_cleanup(&file_io)); + + err1: + (void)file_io_cleanup(&file_io); + + return (res); +} + +struct in_place_file_io_t { + FILE *fmemory_p; + FILE *fpatch_p; +}; + +static int in_place_file_io_init(struct in_place_file_io_t *self_p, + const char *memory_p, + const char *patch_p, + size_t *patch_size_p) +{ + int res; + FILE *file_p; + + res = -DETOOLS_FILE_OPEN_FAILED; + + /* Memory. */ + file_p = fopen(memory_p, "r+b"); + + if (file_p == NULL) { + return (res); + } + + self_p->fmemory_p = file_p; + + /* Patch. */ + file_p = fopen(patch_p, "rb"); + + if (file_p == NULL) { + goto err1; + } + + self_p->fpatch_p = file_p; + res = file_size(self_p->fpatch_p, patch_size_p); + + if (res != 0) { + goto err2; + } + + return (res); + + err2: + fclose(self_p->fpatch_p); + + err1: + fclose(self_p->fmemory_p); + + return (res); +} + +static int in_place_file_io_mem_read(void *arg_p, + void *dst_p, + uintptr_t src, + size_t size) +{ + int res; + struct in_place_file_io_t *self_p; + + self_p = (struct in_place_file_io_t *)arg_p; + res = 0; + + if (size > 0) { + res = fseek(self_p->fmemory_p, (int)src, SEEK_SET); + + if (res != 0) { + return (-DETOOLS_FILE_SEEK_FAILED); + } + + if (fread(dst_p, size, 1, self_p->fmemory_p) != 1) { + res = -DETOOLS_FILE_READ_FAILED; + } + } + + return (res); +} + +static int in_place_file_io_mem_write(void *arg_p, + uintptr_t dst, + void *src_p, + size_t size) +{ + int res; + struct in_place_file_io_t *self_p; + + self_p = (struct in_place_file_io_t *)arg_p; + res = 0; + + if (size > 0) { + res = fseek(self_p->fmemory_p, (int)dst, SEEK_SET); + + if (res != 0) { + return (-DETOOLS_FILE_SEEK_FAILED); + } + + if (fwrite(src_p, size, 1, self_p->fmemory_p) != 1) { + res = -DETOOLS_FILE_WRITE_FAILED; + } + } + + return (res); +} + +static int in_place_file_io_mem_erase(void *arg_p, uintptr_t addr, size_t size) +{ + (void)arg_p; + (void)addr; + (void)size; + + return (0); +} + +static int in_place_file_io_cleanup(struct in_place_file_io_t *self_p) +{ + int res; + int res2; + + res = fclose(self_p->fmemory_p); + res2 = fclose(self_p->fpatch_p); + + if ((res != 0) || (res2 != 0)) { + res = -DETOOLS_FILE_CLOSE_FAILED; + } + + return (res); +} + +int detools_apply_patch_in_place_filenames(const char *memory_p, + const char *patch_p, + detools_step_set_t step_set, + detools_step_get_t step_get) +{ + int res; + struct in_place_file_io_t file_io; + size_t patch_size; + + res = in_place_file_io_init(&file_io, + memory_p, + patch_p, + &patch_size); + + if (res != 0) { + return (res); + } + + res = detools_apply_patch_in_place_callbacks(in_place_file_io_mem_read, + in_place_file_io_mem_write, + in_place_file_io_mem_erase, + step_set, + step_get, + file_io_patch_read, + patch_size, + &file_io); + + if (res != 0) { + goto err1; + } + + return (in_place_file_io_cleanup(&file_io)); + + err1: + (void)in_place_file_io_cleanup(&file_io); + + return (res); +} + +#endif + +const char *detools_error_as_string(int error) +{ + if (error < 0) { + error *= -1; + } + + switch (error) { + + case DETOOLS_NOT_IMPLEMENTED: + return "Function not implemented."; + + case DETOOLS_NOT_DONE: + return "Not done."; + + case DETOOLS_BAD_PATCH_TYPE: + return "Bad patch type."; + + case DETOOLS_BAD_COMPRESSION: + return "Bad compression."; + + case DETOOLS_INTERNAL_ERROR: + return "Internal error."; + + case DETOOLS_LZMA_INIT: + return "LZMA init."; + + case DETOOLS_LZMA_DECODE: + return "LZMA decode."; + + case DETOOLS_OUT_OF_MEMORY: + return "Out of memory."; + + case DETOOLS_CORRUPT_PATCH: + return "Corrupt patch."; + + case DETOOLS_IO_FAILED: + return "Input/output failed."; + + case DETOOLS_ALREADY_DONE: + return "Already done."; + + case DETOOLS_FILE_OPEN_FAILED: + return "File open failed."; + + case DETOOLS_FILE_CLOSE_FAILED: + return "File close failed."; + + case DETOOLS_FILE_READ_FAILED: + return "File read failed."; + + case DETOOLS_FILE_WRITE_FAILED: + return "File write failed."; + + case DETOOLS_FILE_SEEK_FAILED: + return "File seek failed."; + + case DETOOLS_FILE_TELL_FAILED: + return "File tell failed."; + + case DETOOLS_SHORT_HEADER: + return "Short header."; + + case DETOOLS_NOT_ENOUGH_PATCH_DATA: + return "Not enough patch data."; + + case DETOOLS_HEATSHRINK_SINK: + return "Heatshrink sink."; + + case DETOOLS_HEATSHRINK_POLL: + return "Heatshrink poll."; + + case DETOOLS_STEP_SET_FAILED: + return "Step set failed."; + + case DETOOLS_STEP_GET_FAILED: + return "Step get failed."; + + case DETOOLS_ALREADY_FAILED: + return "Already failed."; + + case DETOOLS_CORRUPT_PATCH_OVERFLOW: + return "Corrupt patch, overflow."; + + case DETOOLS_CORRUPT_PATCH_CRLE_KIND: + return "Corrupt patch, CRLE kind."; + + case DETOOLS_HEATSHRINK_HEADER: + return "Heatshrink header."; + + default: + return "Unknown error."; + } +} diff --git a/src/helpers/ota/detools/detools.h b/src/helpers/ota/detools/detools.h new file mode 100644 index 0000000000..c24d9677c7 --- /dev/null +++ b/src/helpers/ota/detools/detools.h @@ -0,0 +1,639 @@ +/** + * BSD 2-Clause License + * + * Copyright (c) 2019-2020, Erik Moqvist + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#ifndef DETOOLS_H +#define DETOOLS_H + +/* + * Configuration. + * + * Define any of the defines below to 0 to disable given feature. + * + * MeshCore note: upstream defaults are all 1. We flip FILE_IO, LZMA and + * HEATSHRINK off here so this vendored copy is safe-by-default on bare-metal + * targets (no liblzma, no malloc/heatshrink, no file IO) regardless of + * build flags. MeshCore .mota deltas use --codec sequential --compression crle, + * which is fully self-contained in detools.c (NONE + CRLE only). Re-enable a + * feature by passing -DDETOOLS_CONFIG_..=1. See detools/README.meshcore.txt. + */ + +#ifndef DETOOLS_CONFIG_FILE_IO +# define DETOOLS_CONFIG_FILE_IO 0 +#endif + +#ifndef DETOOLS_CONFIG_COMPRESSION_NONE +# define DETOOLS_CONFIG_COMPRESSION_NONE 1 +#endif + +#ifndef DETOOLS_CONFIG_COMPRESSION_LZMA +# define DETOOLS_CONFIG_COMPRESSION_LZMA 0 +#endif + +#ifndef DETOOLS_CONFIG_COMPRESSION_CRLE +# define DETOOLS_CONFIG_COMPRESSION_CRLE 1 +#endif + +#ifndef DETOOLS_CONFIG_COMPRESSION_HEATSHRINK +# define DETOOLS_CONFIG_COMPRESSION_HEATSHRINK 0 +#endif + +#include +#include +#include +#include + +#define DETOOLS_VERSION "0.53.0" + +/* Error codes. */ +#define DETOOLS_OK 0 +#define DETOOLS_NOT_IMPLEMENTED 1 +#define DETOOLS_NOT_DONE 2 +#define DETOOLS_BAD_PATCH_TYPE 3 +#define DETOOLS_BAD_COMPRESSION 4 +#define DETOOLS_INTERNAL_ERROR 5 +#define DETOOLS_LZMA_INIT 6 +#define DETOOLS_LZMA_DECODE 7 +#define DETOOLS_OUT_OF_MEMORY 8 +#define DETOOLS_CORRUPT_PATCH 9 +#define DETOOLS_IO_FAILED 10 +#define DETOOLS_ALREADY_DONE 11 +#define DETOOLS_FILE_OPEN_FAILED 12 +#define DETOOLS_FILE_CLOSE_FAILED 13 +#define DETOOLS_FILE_READ_FAILED 14 +#define DETOOLS_FILE_WRITE_FAILED 15 +#define DETOOLS_FILE_SEEK_FAILED 16 +#define DETOOLS_FILE_TELL_FAILED 17 +#define DETOOLS_SHORT_HEADER 18 +#define DETOOLS_NOT_ENOUGH_PATCH_DATA 19 +#define DETOOLS_HEATSHRINK_SINK 20 +#define DETOOLS_HEATSHRINK_POLL 21 +#define DETOOLS_STEP_SET_FAILED 22 +#define DETOOLS_STEP_GET_FAILED 23 +#define DETOOLS_ALREADY_FAILED 24 +#define DETOOLS_CORRUPT_PATCH_OVERFLOW 25 +#define DETOOLS_CORRUPT_PATCH_CRLE_KIND 26 +#define DETOOLS_HEATSHRINK_HEADER 27 + +/** + * Read callback. + * + * @param[in] arg_p User data passed to detools_apply_patch_init(). + * @param[out] buf_p Buffer to read into. + * @param[in] size Number of bytes to read. + * + * @return zero(0) or negative error code. + */ +typedef int (*detools_read_t)(void *arg_p, uint8_t *buf_p, size_t size); + +/** + * Write callback. + * + * @param[in] arg_p User data passed to detools_apply_patch_init(). + * @param[in] buf_p Buffer to write. + * @param[in] size Number of bytes to write. + * + * @return zero(0) or negative error code. + */ +typedef int (*detools_write_t)(void *arg_p, const uint8_t *buf_p, size_t size); + +/** + * Seek from current position callback. + * + * @param[in] arg_p User data passed to detools_apply_patch_init(). + * @param[in] offset Offset to seek to from current position. + * + * @return zero(0) or negative error code. + */ +typedef int (*detools_seek_t)(void *arg_p, int offset); + +/** + * Memory read callback. + * + * @param[in] arg_p User data passed to detools_apply_patch_init(). + * @param[out] dst_p Buffer to read into. + * @param[in] src Address to read from. + * @param[in] size Number of bytes to read. + * + * @return zero(0) or negative error code. + */ +typedef int (*detools_mem_read_t)(void *arg_p, + void *dst_p, + uintptr_t src, + size_t size); + +/** + * Memory write callback. + * + * @param[in] arg_p User data passed to detools_apply_patch_init(). + * @param[in] dst Address to write to. + * @param[in] addr src_p Buffer to write from. + * @param[in] size Number of bytes to write. + * + * @return zero(0) or negative error code. + */ +typedef int (*detools_mem_write_t)(void *arg_p, + uintptr_t dst, + void *src_p, + size_t size); + +/** + * Memory erase callback. + * + * @param[in] arg_p User data passed to detools_apply_patch_init(). + * @param[in] addr Address to erase from. + * @param[in] size Number of bytes to erase. + * + * @return zero(0) or negative error code. + */ +typedef int (*detools_mem_erase_t)(void *arg_p, uintptr_t addr, size_t size); + +/** + * State read callback. + * + * @param[in] arg_p User data passed to detools_apply_patch_init(). + * @param[out] buf_p Buffer to read into. + * @param[in] size Number of bytes to read. + * + * @return zero(0) or negative error code. + */ +typedef int (*detools_state_read_t)(void *arg_p, void *buf_p, size_t size); + +/** + * State write callback. + * + * @param[in] arg_p User data passed to detools_apply_patch_init(). + * @param[in] buf_p Buffer to write. + * @param[in] size Number of bytes to write. + * + * @return zero(0) or negative error code. + */ +typedef int (*detools_state_write_t)(void *arg_p, const void *buf_p, size_t size); + +/** + * Step set callback. + * + * @param[in] arg_p User data passed to detools_apply_patch_init(). + * @param[in] step Step to set. Later read by the step get callback. + * + * @return zero(0) or negative error code. + */ +typedef int (*detools_step_set_t)(void *arg_p, int step); + +/** + * Step get callback. + * + * @param[in] arg_p User data passed to detools_apply_patch_init(). + * @param[out] step_p Outputs the most recently set step by the set + * callback, or zero(0) if not yet set. + * + * @return zero(0) or negative error code. + */ +typedef int (*detools_step_get_t)(void *arg_p, int *step_p); + +struct detools_apply_patch_size_t { + int state; + int value; + int offset; + bool is_signed; +}; + +struct detools_apply_patch_patch_reader_none_t { + size_t patch_size; + size_t patch_offset; +}; + +#if DETOOLS_CONFIG_COMPRESSION_LZMA == 1 + +#include + +struct detools_apply_patch_patch_reader_lzma_t { + lzma_stream stream; + uint8_t *input_p; + uint8_t *output_p; + size_t output_size; +}; + +#endif + +#if DETOOLS_CONFIG_COMPRESSION_HEATSHRINK == 1 + +#include "heatshrink_decoder.h" + +struct detools_apply_patch_patch_reader_heatshrink_t { + int8_t window_sz2; + int8_t lookahead_sz2; + heatshrink_decoder *decoder_p; +#if HEATSHRINK_DYNAMIC_ALLOC == 0 + heatshrink_decoder decoder; +#endif +}; + +#endif + +enum detools_unpack_usize_state_t { + detools_unpack_usize_state_first_t = 0, + detools_unpack_usize_state_consecutive_t +}; + +struct detools_unpack_usize_t { + enum detools_unpack_usize_state_t state; + int value; + int offset; +}; + +enum detools_crle_state_t { + detools_crle_state_idle_t = 0, + detools_crle_state_scattered_size_t, + detools_crle_state_scattered_data_t, + detools_crle_state_repeated_repetitions_t, + detools_crle_state_repeated_data_t, + detools_crle_state_repeated_data_read_t +}; + +struct detools_apply_patch_patch_reader_crle_t { + enum detools_crle_state_t state; + union { + struct { + size_t number_of_bytes_left; + struct detools_unpack_usize_t size; + } scattered; + struct { + uint8_t value; + size_t number_of_bytes_left; + struct detools_unpack_usize_t size; + } repeated; + } kind; +}; + +struct detools_apply_patch_patch_reader_t { + struct detools_apply_patch_chunk_t *patch_chunk_p; + struct detools_apply_patch_size_t size; + union { +#if DETOOLS_CONFIG_COMPRESSION_NONE == 1 + struct detools_apply_patch_patch_reader_none_t none; +#endif +#if DETOOLS_CONFIG_COMPRESSION_LZMA == 1 + struct detools_apply_patch_patch_reader_lzma_t lzma; +#endif +#if DETOOLS_CONFIG_COMPRESSION_CRLE == 1 + struct detools_apply_patch_patch_reader_crle_t crle; +#endif +#if DETOOLS_CONFIG_COMPRESSION_HEATSHRINK == 1 + struct detools_apply_patch_patch_reader_heatshrink_t heatshrink; +#endif + } compression; + int (*destroy)(struct detools_apply_patch_patch_reader_t *self_p); + int (*decompress)(struct detools_apply_patch_patch_reader_t *self_p, + uint8_t *buf_p, + size_t *size_p); +}; + +struct detools_apply_patch_chunk_t { + const uint8_t *buf_p; + size_t size; + size_t offset; +}; + +enum detools_apply_patch_state_t { + detools_apply_patch_state_init_t = 0, + detools_apply_patch_state_dfpatch_size_t, + detools_apply_patch_state_diff_size_t, + detools_apply_patch_state_diff_data_t, + detools_apply_patch_state_extra_size_t, + detools_apply_patch_state_extra_data_t, + detools_apply_patch_state_adjustment_t, + detools_apply_patch_state_done_t, + detools_apply_patch_state_failed_t +}; + +enum detools_apply_patch_init_state_t { + detools_apply_patch_init_state_fixed_header_t = 0, + detools_apply_patch_init_state_to_size_t +}; + +/** + * The apply patch data structure. + */ +struct detools_apply_patch_t { + detools_read_t from_read; + detools_seek_t from_seek; + size_t patch_size; + detools_write_t to_write; + void *arg_p; + enum detools_apply_patch_state_t state; + enum detools_apply_patch_init_state_t init_state; + int compression; + size_t patch_offset; + size_t to_offset; + size_t to_size; + int from_offset; + size_t chunk_size; + struct detools_apply_patch_patch_reader_t patch_reader; + struct detools_apply_patch_chunk_t chunk; + struct detools_apply_patch_size_t size; +}; + +enum detools_apply_patch_in_place_init_state_t { + detools_apply_patch_in_place_init_state_fixed_header_t = 0, + detools_apply_patch_in_place_init_state_memory_size_t, + detools_apply_patch_in_place_init_state_segment_size_t, + detools_apply_patch_in_place_init_state_shift_size_t, + detools_apply_patch_in_place_init_state_from_size_t, + detools_apply_patch_in_place_init_state_to_size_t +}; + +/** + * The in-place apply patch data structure. + */ +struct detools_apply_patch_in_place_t { + detools_mem_read_t mem_read; + detools_mem_write_t mem_write; + detools_mem_erase_t mem_erase; + detools_step_set_t step_set; + detools_step_get_t step_get; + size_t patch_size; + void *arg_p; + enum detools_apply_patch_state_t state; + enum detools_apply_patch_in_place_init_state_t init_state; + int compression; + int ongoing_step; + size_t to_pos; + size_t to_size; + size_t from_size; + size_t memory_size; + size_t segment_size; + size_t shift_size; + size_t chunk_size; + struct { + size_t index; + int from_offset; + size_t to_offset; + size_t to_size; + size_t to_pos; + } segment; + struct detools_apply_patch_patch_reader_t patch_reader; + struct detools_apply_patch_chunk_t chunk; + struct detools_apply_patch_size_t size; +}; + +/** + * Initialize given apply patch object. + * + * @param[out] self_p Apply patch object to initialize. + * @param[in] from_read Callback to read from-data. + * @param[in] from_seek Callback to seek from current position in from-data. + * @param[in] patch_size Patch size in bytes. Not used if + * `detools_apply_patch_restore()` is called + * immediately after this function. + * @param[in] to_write Destination callback. + * @param[in] arg_p Argument passed to the callbacks. + * + * @return zero(0) or negative error code. + */ +int detools_apply_patch_init(struct detools_apply_patch_t *self_p, + detools_read_t from_read, + detools_seek_t from_seek, + size_t patch_size, + detools_write_t to_write, + void *arg_p); + +/** + * Dump given apply patch object state. Call + * `detools_apply_patch_restore()` to restore an apply patch object to + * the dumped state. + * + * @param[in] self_p Apply patch object to dump. + * @param[in] write Write callback. + * + * @return zero(0) or negative error code. + */ +int detools_apply_patch_dump(struct detools_apply_patch_t *self_p, + detools_state_write_t state_write); + +/** + * Restore given apply patch object to given dumped + * state. + * + * `detools_apply_patch_get_to_offset()` and + * `detools_apply_patch_get_patch_offset()` are often called after + * this function to restore the to and patch streams. + * + * @param[in,out] self_p Initialized apply patch object to restore. + * @param[in] read Callback to read the dumped state. + * + * @return zero(0) or negative error code. + */ +int detools_apply_patch_restore(struct detools_apply_patch_t *self_p, + detools_state_read_t state_read); + +/** + * Get the current to stream offset. Often used to restore the to + * stream after restore. + * + * @param[in] self_p Apply patch object. + * + * @return The current to stream offset. + */ +size_t detools_apply_patch_get_to_offset(struct detools_apply_patch_t *self_p); + +/** + * Get the current patch stream offset. Often used to restore the + * patch stream after restore. + * + * @param[in] self_p Apply patch object. + * + * @return The current patch stream offset. + */ +size_t detools_apply_patch_get_patch_offset(struct detools_apply_patch_t *self_p); + +/** + * Call this function repeatedly until all patch data has been + * processed or an error occurres. Call detools_apply_patch_finalize() + * to finalize the patching, even if an error occurred. + * + * @param[in,out] self_p Initialized apply patch object. + * @param[in] patch_p Next chunk of the patch. + * @param[in] size Patch buffer size. + * + * @return zero(0) or negative error code. + */ +int detools_apply_patch_process(struct detools_apply_patch_t *self_p, + const uint8_t *patch_p, + size_t size); + +/** + * Call once after all data has been processed to finalize the + * patching. The value returned from this function should be ignored + * if an error occurred in detools_apply_patch_process(). + * + * @param[in,out] self_p Initialized apply patch object. + * + * @return Size of to-data in bytes if the patch was applied + * successfully, or negative error code. + */ +int detools_apply_patch_finalize(struct detools_apply_patch_t *self_p); + +/** + * Initialize given in-place apply patch object. + * + * @param[out] self_p In-place apply patch object to initialize. + * @param[in] mem_read Callback to read data. + * @param[in] mem_write Callback to write data. + * @param[in] mem_erase Callback to erase data. + * @param[in] step_set Callback to set the step. + * @param[in] step_get Callback to get the step. + * @param[in] patch_size Patch size in bytes. + * @param[in] arg_p Argument passed to the callbacks. + * + * @return zero(0) or negative error code. + */ +int detools_apply_patch_in_place_init( + struct detools_apply_patch_in_place_t *self_p, + detools_mem_read_t mem_read, + detools_mem_write_t mem_write, + detools_mem_erase_t mem_erase, + detools_step_set_t step_set, + detools_step_get_t step_get, + size_t patch_size, + void *arg_p); + +/** + * Call this function repeatedly until all patch data has been + * processed or an error occurres. Call + * detools_apply_patch_in_place_finalize() to finalize the patching, + * even if an error occurred. + * + * @param[in,out] self_p Initialized apply patch object. + * @param[in] patch_p Next chunk of the patch. + * @param[in] size Patch buffer size. + * + * @return zero(0) or negative error code. + */ +int detools_apply_patch_in_place_process( + struct detools_apply_patch_in_place_t *self_p, + const uint8_t *patch_p, + size_t size); + +/** + * Call once after all data has been processed to finalize the + * patching. The value returned from this function should be ignored + * if an error occurred in detools_apply_patch_in_place_process(). + * + * @param[in,out] self_p Initialized apply patch object. + * + * @return Size of to-data in bytes if the patch was applied + * successfully, or negative error code. + */ +int detools_apply_patch_in_place_finalize( + struct detools_apply_patch_in_place_t *self_p); + +/** + * Apply given patch using read, write and seek callbacks. + * + * @param[in] from_read Source read callback. + * @param[in] from_seek Source seek callback. + * @param[in] patch_read Patch read callback. + * @param[in] patch_size Patch size in bytes. + * @param[in] to_write Destination write callback. + * @param[in] arg_p Argument passed to all callbacks. + * + * @return Size of to-data in bytes or negative error code. + */ +int detools_apply_patch_callbacks(detools_read_t from_read, + detools_seek_t from_seek, + detools_read_t patch_read, + size_t patch_size, + detools_write_t to_write, + void *arg_p); + +/** + * Apply given in-place patch using read, write and erase callbacks. + * + * @param[in] mem_read Callback to read data. + * @param[in] mem_write Callback to write data. + * @param[in] mem_erase Callback to erase data. + * @param[in] step_set Callback to set the step. + * @param[in] step_get Callback to get the step. + * @param[in] patch_read Patch read callback. + * @param[in] patch_size Patch size in bytes. + * @param[in] arg_p Argument passed to the callbacks. + * + * @return Size of to-data in bytes or negative error code. + */ +int detools_apply_patch_in_place_callbacks(detools_mem_read_t mem_read, + detools_mem_write_t mem_write, + detools_mem_erase_t mem_erase, + detools_step_set_t step_set, + detools_step_get_t step_get, + detools_read_t patch_read, + size_t patch_size, + void *arg_p); + +#if DETOOLS_CONFIG_FILE_IO == 1 + +/** + * Apply given patch file to given from file and write the output to + * given to file. + * + * @param[in] from_p Source file name. + * @param[in] patch_p Patch file name. + * @param[in] to_p Destination file name. + * + * @return Size of to-data in bytes or negative error code. + */ +int detools_apply_patch_filenames(const char *from_p, + const char *patch_p, + const char *to_p); + +/** + * Apply given patch file to given memory file. + * + * @param[in] memory_p Memory file name. + * @param[in] patch_p Patch file name. + * @param[in] step_set Callback to set the step. + * @param[in] step_get Callback to get the step. + * + * @return Size of to-data in bytes or negative error code. + */ +int detools_apply_patch_in_place_filenames(const char *memory_p, + const char *patch_p, + detools_step_set_t step_set, + detools_step_get_t step_get); + +#endif + +/** + * Get the error string for given error code. + * + * @param[in] Error code. + * + * @return Error string. + */ +const char *detools_error_as_string(int error); + +#endif From fb2d48c1a8859779e07407189037a77315741d61 Mon Sep 17 00:00:00 2001 From: Valentin Kivachuk Burda Date: Mon, 29 Jun 2026 13:03:05 +0200 Subject: [PATCH 03/59] ota: LoRa transfer protocol + session manager (discovery, block fetch/serve) (cherry picked from commit 5da783040f67007d515b4cab6d44fde2a4f1d20f) --- src/helpers/ota/MotaSeederProto.h | 44 ++ src/helpers/ota/OtaContext.cpp | 12 + src/helpers/ota/OtaContext.h | 191 ++++++++ src/helpers/ota/OtaDebug.h | 11 + src/helpers/ota/OtaManager.cpp | 780 ++++++++++++++++++++++++++++++ src/helpers/ota/OtaManager.h | 358 ++++++++++++++ src/helpers/ota/OtaProtocol.cpp | 137 ++++++ src/helpers/ota/OtaProtocol.h | 112 +++++ src/helpers/ota/OtaSource.h | 55 +++ src/helpers/ota/OtaStore.h | 103 ++++ 10 files changed, 1803 insertions(+) create mode 100644 src/helpers/ota/MotaSeederProto.h create mode 100644 src/helpers/ota/OtaContext.cpp create mode 100644 src/helpers/ota/OtaContext.h create mode 100644 src/helpers/ota/OtaDebug.h create mode 100644 src/helpers/ota/OtaManager.cpp create mode 100644 src/helpers/ota/OtaManager.h create mode 100644 src/helpers/ota/OtaProtocol.cpp create mode 100644 src/helpers/ota/OtaProtocol.h create mode 100644 src/helpers/ota/OtaSource.h create mode 100644 src/helpers/ota/OtaStore.h diff --git a/src/helpers/ota/MotaSeederProto.h b/src/helpers/ota/MotaSeederProto.h new file mode 100644 index 0000000000..51767c9cab --- /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/OtaContext.cpp b/src/helpers/ota/OtaContext.cpp new file mode 100644 index 0000000000..10f2bba4b7 --- /dev/null +++ b/src/helpers/ota/OtaContext.cpp @@ -0,0 +1,12 @@ +#include "OtaContext.h" + +namespace mesh { +namespace ota { + +OtaContext& ota_ctx() { + static OtaContext ctx; + return ctx; +} + +} // namespace ota +} // namespace mesh diff --git a/src/helpers/ota/OtaContext.h b/src/helpers/ota/OtaContext.h new file mode 100644 index 0000000000..2a67c23804 --- /dev/null +++ b/src/helpers/ota/OtaContext.h @@ -0,0 +1,191 @@ +#pragma once + +#include // snprintf (hw_id mismatch message) +#include // strncmp/strncpy (hw_id) +#include "OtaManager.h" +#include "OtaStore.h" +#include "SignerAllowlist.h" +#include "OtaApply.h" +#include "OtaFormat.h" +#include "OtaSelf.h" // ota_self_firmware() — prefer self-describing EndF identity at begin() +#include "OtaBlInfo.h" // bootloader OTA-apply capability marker (nRF52); cached after first read +#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). +// Holds the session engine, a staging store (fetch), a RAM serve buffer, and the signer allowlist. +// nRF52 stages into FLASH (OtaStoreFlashNrf52): a delta can be 100 KB+, too big to hold in RAM, and the +// COMPLETE container must persist so the bootloader can apply it after reboot. A flash page-erase halts +// the CPU (~85 ms) and starves the LoRa RX, so the store COALESCES writes to the 4 KB page (the erase +// unit) and commits each page once, off the per-packet path (see OtaManager.h) — RAM stays O(one page). +// (v1 has no mid-transfer resume; an interrupted fetch simply restarts.) ESP32/native use the RAM store. + +namespace mesh { +namespace ota { + +#ifndef OTA_SERVE_BUF_SIZE +#define OTA_SERVE_BUF_SIZE 16384 +#endif +#ifndef OTA_FETCH_BUF_SIZE +#define OTA_FETCH_BUF_SIZE 16384 +#endif + +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 + SignerAllowlist allow; + 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[MOTA_MFL]; // fixed-layout full+unsigned manifest-minus-leaves (197 B) + 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 + // has actually been transmitted. apply_at/apply_hard are mesh-clock deadlines the loop fills in. + bool apply_pending = false; + 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 + + // Bootloader OTA-apply capability (nRF52): can THIS device's bootloader apply a .mota? Read from flash + // ONCE (the scan is ~40 KB) and cached in RAM. On other platforms present=false (apply is in-app). + OtaBlCaps _bl_caps; + bool _bl_caps_read = false; + const OtaBlCaps& bootloaderCaps() { + if (!_bl_caps_read) { _bl_caps = ota_bootloader_caps(); _bl_caps_read = true; } + return _bl_caps; + } + + // --- 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) { + // Prefer the firmware's SELF-DESCRIBING EndF identity (docs §2) over the build-flag values the caller + // passed — it's correct on any build (build.sh injection, bare IDE build, ...), so `ota ls`/`status` + // and fetch-routing show the right hardware/role instead of 0 / "". + SelfFwInfo _fi; + if (ota_self_firmware(_fi) && _fi.valid) { + if (_fi.target_id) target_id = _fi.target_id; + if (_fi.hw_id[0]) hw = _fi.hw_id; + } + 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); // 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); + } +}; + +OtaContext& ota_ctx(); // process-wide singleton + +} // namespace ota +} // namespace mesh diff --git a/src/helpers/ota/OtaDebug.h b/src/helpers/ota/OtaDebug.h new file mode 100644 index 0000000000..a126cdec5c --- /dev/null +++ b/src/helpers/ota/OtaDebug.h @@ -0,0 +1,11 @@ +#pragma once + +// Opt-in OTA tracing over Serial: build with -D OTA_DEBUG to watch the fetch (ADV/REQ/block/page-flush) +// during bring-up. Compiles to nothing otherwise, and on the native host (no Arduino), so it never +// touches a non-debug or test build. +#if defined(OTA_DEBUG) && defined(ARDUINO) + #include + #define OTA_DBG(...) do { Serial.printf(__VA_ARGS__); } while (0) +#else + #define OTA_DBG(...) do {} while (0) +#endif diff --git a/src/helpers/ota/OtaManager.cpp b/src/helpers/ota/OtaManager.cpp new file mode 100644 index 0000000000..e204a05795 --- /dev/null +++ b/src/helpers/ota/OtaManager.cpp @@ -0,0 +1,780 @@ +#include "OtaManager.h" +#include "OtaProtocol.h" +#include "MerkleTree.h" +#include "Multihash.h" +#include "OtaByteIO.h" +#include "OtaDebug.h" +#include + +namespace mesh { +namespace ota { + +void OtaManager::begin(uint32_t my_target_id, OtaSend send, void* ctx) { + _target = my_target_id; _send = send; _ctx = ctx; + _fstate = IDLE; _have = 0; _fbc = 0; + _n_serve = 0; _n_src_obj = 0; _view0.valid = false; _srcv.valid = false; _fetch_served = false; + for (uint8_t i = 0; i < 8; i++) _recent_blk[i] = NO_BLOCK; // empty slot (never a real block index) +} + +// ---------------- 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, _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; +} + +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.have_count = _view0.m.block_count; + e.is_self = true; e.is_fetch = false; 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.have_count = d.block_count; // a folder mota is fully held + e.is_self = false; e.is_fetch = false; e.src = src; e.src_idx = i; e.desc = d; + } + } + // re-seed a completed download (epidemic spread) as one more served mota, backed by the fetch store + if (_fetch_served && _n_serve < OTA_MAX_SERVE && serveEntryIndex(_fetch_desc.mid) < 0) { + ServeEntry& e = _serve[_n_serve++]; + memcpy(e.mid, _fetch_desc.mid, 4); + e.target_id = _fetch_desc.target_id; e.fw_version = _fetch_desc.fw_version; + e.codec_id = _fetch_desc.codec_id; e.flags = _fetch_desc.flags; e.have_count = _fetch_desc.block_count; + e.is_self = false; e.is_fetch = true; e.src = nullptr; e.src_idx = 0; e.desc = _fetch_desc; + } + _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 (d.leaves_off < 8) return false; + if (e.is_fetch ? (_fetch == nullptr) : (e.src == nullptr)) 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; + // read the manifest-minus-leaves + leaves[] from the backing — an external folder MotaSource, or (for a + // completed download we re-seed) our own fetch store. Container offsets are absolute, so a store read + // at the same offsets works identically. + bool ok = e.is_fetch ? _fetch->read(8, _src_manifest, mfl) + : e.src->read(e.src_idx, 8, _src_manifest, mfl); + if (!ok || !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; + ok = e.is_fetch ? _fetch->read(d.leaves_off, _src_leaves, d.block_count * 4) + : e.src->read(e.src_idx, d.leaves_off, _src_leaves, d.block_count * 4); + if (!ok) return false; + _srcv.m.manifest_start = _src_manifest; + _srcv.m.leaves = _src_leaves; + _srcv.m.payload = nullptr; + _srcv.mfl = mfl; + _srcv_rdctx.src = e.is_fetch ? nullptr : e.src; _srcv_rdctx.idx = e.src_idx; + _srcv_rdctx.payload_off = d.payload_off; _srcv_rdctx.store = e.is_fetch ? _fetch : nullptr; + _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 read of the backing (external source or fetch store). +bool OtaManager::srcReadTramp(void* c, uint32_t off, uint8_t* buf, uint32_t len) { + SrcReadCtx* x = (SrcReadCtx*)c; + if (x->store) return x->store->read(x->payload_off + off, buf, len); + return x->src->read(x->idx, x->payload_off + off, buf, len); +} + +// After a download COMPLETEs, advertise + serve the staged container so this node re-seeds it to peers +// (epidemic spread: the origin seeds a few, they seed the next ring -> load on the origin is O(log N), not +// O(N)). The completed container has ALL blocks + leaves, so it serves DATA *and* proofs correctly. Re-uses +// the on-demand source view; serving is reactive + lowest-priority, so it never competes with real traffic. +void OtaManager::serveFetched() { + if (!_fetch || _fstate != COMPLETE || _fbc == 0 || _floff < 8) return; + uint16_t mfl = (uint16_t)(_floff - 8); + if (mfl == 0 || mfl > sizeof(_src_manifest)) return; + if ((uint64_t)_fbc * 4 > sizeof(_src_leaves)) return; // proof-gen scratch caps re-seed at <=1024 blocks + uint8_t head[OTA_SRC_MANIFEST_MAX]; + if (!_fetch->read(8, head, mfl)) return; + MotaManifest m; + if (!mota_parse_manifest(head, mfl, m)) return; + MotaDesc& d = _fetch_desc; + memcpy(d.mid, _fid, 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 = _ftotal; d.leaves_off = _floff; d.block_count = _fbc; + d.payload_off = _fpoff; d.payload_size = _fpsize; + _fetch_served = true; + refresh_sources(); // add the fetch entry to the catalog; the set-digest change makes the next beacon advertise it +} + +void OtaManager::unserveFetched() { + if (!_fetch_served) return; + _fetch_served = false; + _srcv.valid = false; // the loaded source view may be the fetch we're dropping + refresh_sources(); +} + +// 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; + 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_u32le(row + 4, e.target_id); wr_u32le(row + 8, e.fw_version); + row[12] = e.codec_id; row[13] = e.flags; + uint32_t hc = e.have_count > 0xFFFFu ? 0xFFFFu : e.have_count; // blocks we hold (awareness for fetchers) + row[14] = (uint8_t)(hc & 0xFF); row[15] = (uint8_t)(hc >> 8); + 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)) 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); + } +} + +// Emit one block's data as self-describing DATA fragments (frag_off); the proof is fetched separately. +void OtaManager::emitBlockData(const uint8_t* mid, uint32_t idx, const uint8_t* data, uint32_t blen) { + 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, mid, 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); + } +} + +// True if we recently overheard ANOTHER holder broadcast this block's DATA — so we should not re-serve it +// (avoids N sources duplicate-broadcasting one block; keeps OTA airtime minimal). See noteOverheardData(). +bool OtaManager::recentlyServed(uint32_t blk) const { + for (uint8_t i = 0; i < 8; i++) + if (_recent_blk[i] == blk && (uint32_t)(_now_ms - _recent_at[i]) < OTA_SERVE_SUPPRESS_MS) return true; + return false; +} + +void OtaManager::noteOverheardData(const uint8_t* m, uint16_t n) { + DataMsg dm; + if (!decode_data(m, n, dm)) return; + _recent_blk[_recent_i] = dm.block_idx; _recent_at[_recent_i] = _now_ms; + _recent_i = (uint8_t)((_recent_i + 1) & 7); +} + +void OtaManager::handleReq(const uint8_t* m, uint16_t n) { + ReqMsg rq; + if (!decode_req(m, n, rq)) return; + ServeView* v = resolve(rq.manifest_id); + if (v) { // serve a fully-held mota (own fw / folder / completed fetch) + 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 >= v->m.block_count) break; + if (recentlyServed(idx)) continue; // another holder just broadcast it — don't duplicate + uint32_t off = idx * bs; + 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; } + emitBlockData(v->m.merkle_root, idx, data, blen); + } + return; + } + // Partial re-serve (swarm DURING the transfer): we're fetching this mid and already hold some of these + // blocks — serve their DATA (not proofs; we may lack sibling leaves) from our staging store, so peers can + // source from us, not only the origin. Reactive + lowest-priority, so real traffic is never impacted. + if (_fetch && _fstate == FETCHING && memcmp(rq.manifest_id, _fid, 4) == 0) { + for (uint32_t k = 0; k < rq.count; k++) { + uint32_t idx = rq.start_block + k; + if (idx >= _fbc) break; + if (!blockPresent(idx) || recentlyServed(idx)) continue; + uint8_t blk[OTA_MAX_BLOCK]; + uint32_t blen = blockLen(idx); + if (!_fetch->read(_fpoff + idx * _fbs, blk, blen)) continue; + emitBlockData(_fid, idx, blk, blen); + } + } +} + +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; + 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_u32le(seeder) ^ rd_u32le(digest) ^ rd_u32le(_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_u32le(row + 4), fwver = rd_u32le(row + 8); + uint8_t codec = row[12], flags = row[13]; + uint32_t have_count = rd_u16le(row + 14); // this source's progress + 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].seeders[0], hv.seeder_id, 4); + _catalog[slot].n_seeders = 1; + } else { + CatRow& cc = _catalog[slot]; // count DISTINCT sources (no double-count) + bool known = false; + for (uint8_t k = 0; k < cc.n_seeders; k++) + if (memcmp(cc.seeders[k], hv.seeder_id, 4) == 0) { known = true; break; } + if (!known && cc.n_seeders < OTA_CAT_SEEDERS) memcpy(cc.seeders[cc.n_seeders++], hv.seeder_id, 4); + } + CatRow& c = _catalog[slot]; + c.target_id = target; c.fw_version = fwver; c.codec = codec; c.flags = flags; c.last_ms = _now_ms; + if (have_count > c.have_max) c.have_max = have_count; // best-known progress among sources + 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; +} + +// Forget the block currently being reassembled / awaited (back to NO_BLOCK). Safe to call between blocks: +// the next DATA fragment re-derives the slice mask for whatever block it belongs to. +void OtaManager::clearReassembly() { + _reasm_block = NO_BLOCK; _reasm_mask = 0; _reasm_need = 0; _awaiting_proof = false; +} + +// De-sync the first REQ across the swarm: hold it a random fraction of OTA_REQ_SPREAD_MS so N nodes that +// just discovered the same mid don't burst-request block 0 in lockstep (loop() fires it once the hold +// elapses). Assumes the per-node RNG is already seeded; also forgets any peer-REQ note from a prior session. +void OtaManager::armFirstReqHold() { + _req_hold_at = _now_ms + (rngNext() % OTA_REQ_SPREAD_MS); + _peer_req_block = NO_BLOCK; _peer_req_at = 0; +} + +// 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 (resumeStaged(mid)) return; // resume a partial container left in flash + memcpy(_fid, mid, 4); + seedBlockRng(); // per-node block-pick/jitter sequence (distinct per node) + _fstate = WANT_MANIFEST; + _mf_total = 0; _mf_mask = 0; _mf_len = 0; _mf_retries = 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); +} + +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 == 0 || mm.frag_total > OTA_MF_MAXFRAG || mm.frag_idx >= mm.frag_total) 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 != MOTA_MFL) { _fstate = FAILED; return; } // manifest-minus-leaves is a fixed 197 bytes + if (!codecOk(mf[56])) { _fstate = IDLE; return; } // codec we can't apply (lying/stale ADV) — abort + uint32_t payload_size = rd_u32le(mf + 15); + uint8_t bsl = mf[19]; + uint32_t bs = 1u << bsl; + // 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; + if (bc > 0xFFFFu) { _fstate = FAILED; return; } // block_idx is uint16 on the wire — can't address more + memcpy(_froot, mf + 20, 4); + + uint32_t leaves_off = 8 + mfl; + 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_u32le(mf + 11), payload_off, payload_size)) { _fstate = FAILED; return; } + unserveFetched(); // the store is about to be overwritten by this new fetch — stop re-seeding the old one + 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; } + uint8_t hdr[8]; + memcpy(hdr, MOTA_MAGIC, 4); + wr_u32le(hdr + 4, total); + if (!_fetch->write(0, hdr, 8) || + !_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; + clearReassembly(); // fresh transfer: drop any prior per-block state + _loop_last_have = 0; _loop_last_mask = 0; + if (_rng == 0) seedBlockRng(); + armFirstReqHold(); + OTA_DBG("OTA: FETCHING bc=%u bs=%u total=%u\n", (unsigned)bc, (unsigned)bs, (unsigned)total); +} + +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 + clearReassembly(); + _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(); serveFetched(); } + return true; + } + _fstate = FETCHING; // resume fetching the holes + // De-sync the first REQ exactly like a fresh fetch, so a coordinated reboot (whole-site power-cycle) + // doesn't make every resuming node REQ in lockstep. + seedBlockRng(); + armFirstReqHold(); + _loop_last_have = _have; _loop_last_mask = _reasm_mask; // "no progress yet" -> loop will request after the hold + return true; +} + +uint32_t OtaManager::blockLen(uint32_t i) const { + uint32_t off = i * _fbs; + return (off + _fbs <= _fpsize) ? _fbs : (_fpsize - off); +} + +bool OtaManager::blockPresent(uint32_t i) const { + uint8_t leaf[4]; + if (!_fetch->read(_floff + i * 4, leaf, 4)) return false; + return !(leaf[0]==0xFF && leaf[1]==0xFF && leaf[2]==0xFF && leaf[3]==0xFF); +} + +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.block_idx >= _fbc) 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)) { + clearReassembly(); // 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)_reasm_block, (unsigned)_have, (unsigned)_fbc); + clearReassembly(); + // 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(); serveFetched(); } // commit + re-seed (epidemic spread) + OTA_DBG("OTA: transfer %s\n", _fstate == COMPLETE ? "COMPLETE" : "FAILED(root)"); +} + +void OtaManager::requestMissing() { + if (_fstate != FETCHING) return; + // 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 != NO_BLOCK) { + 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 = pickMissingBlock(); + if (start >= _fbc) return; + _req_start = start; _req_count = 1; + ReqMsg rq; memcpy(rq.manifest_id, _fid, 4); + rq.start_block = (uint16_t)start; rq.count = 1; + uint8_t b[16]; + 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); +} + +// Choose which block to request next. Swarm-aware so N fetchers of the same mid spread their load instead +// of marching in lockstep on the same block: +// - finish an in-flight partially-reassembled block first (don't waste received fragments); +// - otherwise pick a RANDOM missing block (de-correlates fetchers -> they collectively pull different +// blocks, and every broadcast DATA fills everyone's hole); +// - skip a block a peer just REQ'd (its DATA is already coming over the air) unless it's all that's left. +// Returns _fbc if nothing to request. +uint32_t OtaManager::pickMissingBlock() { + if (_fbc == 0) return _fbc; + // (1) keep finishing a block we've already started reassembling (recover its lost fragments) + if (_reasm_block < _fbc && !blockPresent(_reasm_block) && _reasm_mask != 0) return _reasm_block; + // (2) count missing blocks + uint32_t miss = 0; + for (uint32_t i = 0; i < _fbc; i++) if (!blockPresent(i)) miss++; + if (miss == 0) return _fbc; + bool suppress = (_peer_req_block < _fbc) && ((uint32_t)(_now_ms - _peer_req_at) < OTA_REQ_SUPPRESS_MS); + // (3) pick the k-th missing block (k from the per-node RNG), optionally skipping the peer-REQ'd one + uint32_t k = rngNext() % miss; + uint32_t seen = 0, chosen = _fbc, firstAny = _fbc; + for (uint32_t i = 0; i < _fbc; i++) { + if (blockPresent(i)) continue; + if (firstAny == _fbc) firstAny = i; + if (seen == k) { chosen = i; } + seen++; + } + if (suppress && chosen == _peer_req_block) { // pick a different missing block than the one in flight elsewhere + for (uint32_t i = 0; i < _fbc; i++) { + uint32_t j = (chosen + 1 + i) % _fbc; + if (!blockPresent(j) && j != _peer_req_block) { chosen = j; break; } + } + // if the suppressed block is the ONLY one left, chosen stays == it (we still need it eventually) + } + return (chosen < _fbc) ? chosen : firstAny; +} + +// Observe a peer's OTA_REQ for the mid we're fetching: its block's DATA is broadcast, so it will fill our +// hole too — note it so pickMissingBlock() spends our next REQ on a DIFFERENT block (swarm de-dup). +void OtaManager::noteOverheardReq(const uint8_t* m, uint16_t n) { + if (_fstate != FETCHING) return; + ReqMsg rq; + if (!decode_req(m, n, rq) || memcmp(rq.manifest_id, _fid, 4) != 0) return; + _peer_req_block = rq.start_block; + _peer_req_at = _now_ms; +} + +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, but give up after a + // cap so an unreachable mid doesn't pin the single fetch slot (or emit) forever. + if (++_mf_retries > OTA_MANIFEST_MAX_RETRY) { _fstate = FAILED; return; } + GetManifestMsg gm; memcpy(gm.manifest_id, _fid, 4); + uint8_t b[16]; + emit(b, encode_get_manifest(b, sizeof(b), gm), false); + return; + } + if (_fstate != FETCHING) return; + if ((int32_t)(_now_ms - _req_hold_at) < 0) return; // swarm: initial random hold (de-sync N fetchers) + // 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 ---------------- + +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: noteOverheardReq(msg, len); handleReq(msg, len); break; + case OTA_DATA: handleData(msg, len); noteOverheardData(msg, len); break; + case OTA_REQ_PROOF: handleReqProof(msg, len); break; + case OTA_PROOF: handleProof(msg, len); break; + default: break; + } +} + +} // namespace ota +} // namespace mesh diff --git a/src/helpers/ota/OtaManager.h b/src/helpers/ota/OtaManager.h new file mode 100644 index 0000000000..3428b453d8 --- /dev/null +++ b/src/helpers/ota/OtaManager.h @@ -0,0 +1,358 @@ +#pragma once + +#include +#include +#include "OtaFormat.h" +#include "OtaByteIO.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. +// +// 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 { + +// 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_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 4 // 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 (the fixed 197 B manifest is always 2) +#endif +#ifndef OTA_MANIFEST_MAX_RETRY +#define OTA_MANIFEST_MAX_RETRY 20 // give up (FAILED) after this many GET_MANIFEST retries — frees the slot +#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_REQ_SPREAD_MS +#define OTA_REQ_SPREAD_MS 3000 // initial random hold before a fetch's first REQ (de-sync N fetchers) +#endif +#ifndef OTA_REQ_SUPPRESS_MS +#define OTA_REQ_SUPPRESS_MS 2500 // after overhearing a peer's REQ for a block, don't also request it — +#endif // its DATA is broadcast and will fill our hole too (swarm de-dup) +#ifndef OTA_SERVE_SUPPRESS_MS +#define OTA_SERVE_SUPPRESS_MS 1500 // don't re-serve a block whose DATA we just overheard another holder send +#endif // (so multiple sources of the same mota don't duplicate-broadcast it) +#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 +// driver (Adafruit flash_nrf5x) always erases on flush, so there is no erase-free write; instead +// OtaStoreFlashNrf52 COALESCES to the 4 KB page (the erase unit) and writes each page once — RAM stays +// O(one page), never O(mota). It pins flash page 0 (header+manifest+merkle leaves, which update all +// transfer long) in RAM and streams the payload through one sliding page buffer, flushing page 0 and the +// last page at finalize() (radio idle). Flash is then touched ~once per 4 KB (≈1 per 4 blocks), not per +// packet; a small delta that fits page 0 does ZERO flash I/O until COMPLETE. (Pacing alone is not enough.) + +class OtaManager { +public: + enum FetchState : uint8_t { IDLE, WANT_MANIFEST, FETCHING, COMPLETE, FAILED }; + + // Sentinel for "no block" in the reassembly / peer-REQ / recently-served slots (a real block index is + // a small uint16, so 0xFFFFFFFF is never valid). + static const uint32_t NO_BLOCK = 0xFFFFFFFFu; + + // --- 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; + uint32_t have_count; // blocks we currently hold (== block_count when complete) + bool is_self; // true => entry is view0 (our own fw / RAM mota) + bool is_fetch; // true => load from our own fetch store (a completed download we re-seed) + MotaSource* src; // else: load from this external source ... + uint8_t src_idx; // ... at this index + MotaDesc desc; // cached region offsets (source / fetch entries) + }; + // Context for the source-payload reader trampoline (maps a payload-relative offset to a backing read: + // an external MotaSource, or — when `store` is set — our own fetch store, for re-seeding a completed mota). + struct SrcReadCtx { MotaSource* src; uint8_t idx; uint32_t payload_off; OtaStore* store; }; + + 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); + // 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; 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; } + // 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). Also + // stops re-seeding a previously-completed download — callers clear the staging store right after, so the + // re-seed view would otherwise advertise a mota we can no longer serve. + void reset_session() { + _fstate = IDLE; _have = 0; _req_count = 0; _mf_retries = 0; + clearReassembly(); + _loop_last_have = 0; _loop_last_mask = 0; + _mf_total = 0; _mf_mask = 0; _mf_len = 0; + unserveFetched(); + } + + 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 --- + static const uint8_t OTA_CAT_SEEDERS = 4; // distinct sources tracked per catalog row (for "N nodes have it") + struct CatRow { + uint8_t mid[4]; + uint32_t target_id, fw_version; + uint8_t codec, flags; + uint8_t seeders[OTA_CAT_SEEDERS][4]; // distinct sources advertising this mid (deduped; capped) + uint8_t n_seeders; // count of the above (capped at OTA_CAT_SEEDERS) — "N+ nodes have it" + uint32_t have_max; // best block-count any source reported (== total when a full copy exists) + 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); // 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? + void noteOverheardReq(const uint8_t* m, uint16_t n); // observe a peer's OTA_REQ (swarm de-dup) + uint32_t rngNext() { _rng = _rng * 1664525u + 1013904223u; return _rng; } // per-node LCG (block pick/jitter) + void seedBlockRng() { _rng = (rd_u32le(_seeder_id) ^ rd_u32le(_fid)) | 1u; } // distinct per node (id^mid) + void clearReassembly(); // forget the in-flight block (reset to NO_BLOCK) + void armFirstReqHold(); // de-sync the first REQ across swarm peers (jitter) + uint32_t pickMissingBlock(); // choose the next block to request (swarm-aware) + 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 serveFetched(); // after COMPLETE: re-seed the staged mota (epidemic) + void unserveFetched(); // stop re-seeding (store about to be overwritten) + void emitBlockData(const uint8_t* mid, uint32_t idx, const uint8_t* data, uint32_t blen); // DATA fragments + bool recentlyServed(uint32_t blk) const; // a peer just broadcast this block's DATA? + void noteOverheardData(const uint8_t* m, uint16_t n); // remember overheard DATA (serve de-dup) + 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; + + uint32_t _target = 0; + OtaSend _send = nullptr; + void* _ctx = nullptr; + + // 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; + bool _fetch_served = false; // we re-seed our last completed download (epidemic spread) + MotaDesc _fetch_desc; // its catalog descriptor (mid + region offsets) + 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; + FetchState _fstate = IDLE; + uint8_t _fid[4] = {0}; + 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; // last block requested (per-block serial flow; telemetry) + uint32_t _loop_last_have = 0; // for stall detection in loop() + // swarm load-spreading (so 50 fetchers don't all hammer the seeder for the same block in lockstep) + uint32_t _rng = 0; // per-node LCG state (seeded from seeder_id^fid) + uint32_t _req_hold_at = 0; // _now_ms before which we hold the first REQ (startup jitter) + uint32_t _peer_req_block = NO_BLOCK; // a block a peer just REQ'd (its broadcast DATA will fill us) + uint32_t _peer_req_at = 0; // when we overheard it (suppression window) + // serve-side de-dup: blocks whose DATA we recently overheard ANOTHER holder broadcast (don't re-serve) + uint32_t _recent_blk[8]; + uint32_t _recent_at[8] = {0}; + uint8_t _recent_i = 0; + 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 = NO_BLOCK; // 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[OTA_MF_MAXFRAG * OTA_MF_FRAG]; // sized to the fragment cap so no valid manifest is silently dropped + uint16_t _mf_retries = 0; // GET_MANIFEST retries while WANT_MANIFEST (give up after a cap) + 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 +} // namespace mesh diff --git a/src/helpers/ota/OtaProtocol.cpp b/src/helpers/ota/OtaProtocol.cpp new file mode 100644 index 0000000000..50e6147668 --- /dev/null +++ b/src/helpers/ota/OtaProtocol.cpp @@ -0,0 +1,137 @@ +#include "OtaProtocol.h" +#include + +namespace mesh { +namespace ota { + +// Little-endian cursor helpers. +namespace { +struct W { + uint8_t* p; uint16_t cap; uint16_t n; bool ok; + W(uint8_t* b, uint16_t c) : p(b), cap(c), n(0), ok(true) {} + void u8(uint8_t v) { if (n + 1 > cap) { ok = false; return; } p[n++] = v; } + void u16(uint16_t v){ u8(v & 0xFF); u8(v >> 8); } + void u32(uint32_t v){ u8(v); u8(v >> 8); u8(v >> 16); u8(v >> 24); } + void raw(const uint8_t* d, uint16_t l) { if (n + l > cap) { ok = false; return; } memcpy(p + n, d, l); n += l; } +}; +struct R { + const uint8_t* p; uint16_t len; uint16_t n; bool ok; + R(const uint8_t* b, uint16_t l) : p(b), len(l), n(0), ok(true) {} + uint8_t u8() { if (n + 1 > len) { ok = false; return 0; } return p[n++]; } + uint16_t u16() { uint16_t a = u8(); return a | ((uint16_t)u8() << 8); } + uint32_t u32() { uint32_t a = u8(); a |= (uint32_t)u8() << 8; a |= (uint32_t)u8() << 16; a |= (uint32_t)u8() << 24; return a; } + const uint8_t* raw(uint16_t l) { if (n + l > len) { ok = false; return nullptr; } const uint8_t* r = p + n; n += l; return r; } + uint16_t remaining() const { return len - n; } +}; +} // namespace + +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; + 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; +} + +uint16_t encode_get_manifest(uint8_t* buf, uint16_t cap, const GetManifestMsg& m) { + W w(buf, cap); w.u8(OTA_GET_MANIFEST); w.raw(m.manifest_id, 4); + return w.ok ? w.n : 0; +} +bool decode_get_manifest(const uint8_t* buf, uint16_t len, GetManifestMsg& m) { + R r(buf, len); if (r.u8() != OTA_GET_MANIFEST) return false; + const uint8_t* id = r.raw(4); if (id) memcpy(m.manifest_id, id, 4); + return r.ok; +} + +uint16_t encode_manifest(uint8_t* buf, uint16_t cap, const ManifestMsg& m) { + W w(buf, cap); w.u8(OTA_MANIFEST); w.raw(m.manifest_id, 4); w.u8(m.frag_idx); w.u8(m.frag_total); + w.raw(m.bytes, m.len); + return w.ok ? w.n : 0; +} +bool decode_manifest(const uint8_t* buf, uint16_t len, ManifestMsg& m) { + R r(buf, len); if (r.u8() != OTA_MANIFEST) return false; + const uint8_t* id = r.raw(4); if (id) memcpy(m.manifest_id, id, 4); + m.frag_idx = r.u8(); m.frag_total = r.u8(); + m.len = r.remaining(); m.bytes = r.raw(m.len); + return r.ok; +} + +uint16_t encode_req(uint8_t* buf, uint16_t cap, const ReqMsg& m) { + W w(buf, cap); w.u8(OTA_REQ); w.raw(m.manifest_id, 4); w.u16(m.start_block); w.u8(m.count); + return w.ok ? w.n : 0; +} +bool decode_req(const uint8_t* buf, uint16_t len, ReqMsg& m) { + R r(buf, len); if (r.u8() != OTA_REQ) return false; + const uint8_t* id = r.raw(4); if (id) memcpy(m.manifest_id, id, 4); + m.start_block = r.u16(); m.count = r.u8(); + return r.ok; +} + +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.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_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 new file mode 100644 index 0000000000..d8385353a1 --- /dev/null +++ b/src/helpers/ota/OtaProtocol.h @@ -0,0 +1,112 @@ +#pragma once + +#include +#include +#include "OtaFormat.h" + +// Encode/decode for the OTA LoRa messages (docs/ota_protocol.md §8). Each message is a packet payload: +// [0]=ota_msg_type, then a fixed body. Portable + allocation-free; unit-tested on the host. +// +// manifest_id == the manifest's merkle_root (4 bytes), a compact content id. + +namespace mesh { +namespace ota { + +// ---- 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 { + 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; + uint16_t have_count; }; // blocks the advertiser holds (== block_count if complete; less => partial source) +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 = 16; // mid4 + target4 + fwver4 + codec1 + flags1 + have_count2 + +// ---- OTA_GET_MANIFEST: request the manifest for a content id (direct) ---- +struct GetManifestMsg { uint8_t manifest_id[4]; }; + +// ---- OTA_MANIFEST: the manifest-minus-leaves[], fragmented (direct) ---- +// body: manifest_id(4) frag_idx(1) frag_total(1) bytes[] +struct ManifestMsg { + uint8_t manifest_id[4]; + uint8_t frag_idx, frag_total; + const uint8_t* bytes; uint16_t len; +}; + +// ---- 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 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; + 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); + +uint16_t encode_manifest(uint8_t* buf, uint16_t cap, const ManifestMsg& m); +bool decode_manifest(const uint8_t* buf, uint16_t len, ManifestMsg& m); + +uint16_t encode_req(uint8_t* buf, uint16_t cap, const ReqMsg& m); +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 +} // namespace mesh diff --git a/src/helpers/ota/OtaSource.h b/src/helpers/ota/OtaSource.h new file mode 100644 index 0000000000..64c8fce694 --- /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 new file mode 100644 index 0000000000..27f2fb294a --- /dev/null +++ b/src/helpers/ota/OtaStore.h @@ -0,0 +1,103 @@ +#pragma once + +#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 +// written against this interface; concrete impls are per-platform (RAM for tests/bring-up; persistent +// flash — ESP32 OTA slot / nRF52 raw region — for production, dropped in behind the same interface). + +namespace mesh { +namespace ota { + +class OtaStore { +public: + virtual ~OtaStore() {} + // Prepare staging for a container of `total_size` bytes (erases/clears). false if it won't fit. + virtual bool begin(uint32_t total_size) = 0; + virtual bool write(uint32_t offset, const uint8_t* data, uint32_t len) = 0; + virtual bool read(uint32_t offset, uint8_t* buf, uint32_t len) const = 0; + virtual uint32_t capacity() const = 0; + virtual uint32_t staged_size() const = 0; // total_size from begin(), 0 if none + virtual void clear() = 0; + + // Optional: declare the size of the leading metadata (header + manifest + merkle leaves, i.e. + // everything before the payload). A flash-backed store keeps that region — which is updated + // throughout the transfer (a leaf is committed per block) — pinned in one RAM page, so it can + // flush the bulk payload page-by-page without re-erasing the leaves' page on every block. + // Returns false if the metadata won't fit the store's pinned region (transfer is then refused). + virtual bool set_meta_size(uint32_t meta_bytes) { (void)meta_bytes; return true; } + + // Optional: commit any RAM-buffered data to persistent storage. Called once when the transfer + // 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] = {}; // 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 { + if (total_size > CAP) return false; + _total = total_size; + memset(_buf, 0xFF, total_size); // mimic erased flash (so unfilled leaf slots read as 'missing') + return true; + } + bool write(uint32_t off, const uint8_t* d, uint32_t len) override { + if ((uint64_t)off + len > _total) return false; + memcpy(_buf + off, d, len); + return true; + } + bool read(uint32_t off, uint8_t* b, uint32_t len) const override { + if ((uint64_t)off + len > _total) return false; + memcpy(b, _buf + off, len); + return true; + } + 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) +}; + +} // namespace ota +} // namespace mesh From 82ea95c44bc4efdc5415e8ff06e70025de10641b Mon Sep 17 00:00:00 2001 From: Valentin Kivachuk Burda Date: Mon, 29 Jun 2026 13:03:05 +0200 Subject: [PATCH 04/59] ota: platform apply + flash staging (ESP32 A/B, nRF52 in-place via bootloader) (cherry picked from commit 9b61169c16d2f4e5652eef17e665749d37ac3905) --- src/helpers/ota/OtaApply.cpp | 509 +++++++++++++++++++++++++ src/helpers/ota/OtaApply.h | 66 ++++ src/helpers/ota/OtaBlInfo.h | 54 +++ src/helpers/ota/OtaFlashLayout_nrf52.h | 39 ++ src/helpers/ota/OtaStoreFlashEsp32.cpp | 243 ++++++++++++ src/helpers/ota/OtaStoreFlashEsp32.h | 116 ++++++ src/helpers/ota/OtaStoreFlashNrf52.cpp | 159 ++++++++ src/helpers/ota/OtaStoreFlashNrf52.h | 77 ++++ 8 files changed, 1263 insertions(+) create mode 100644 src/helpers/ota/OtaApply.cpp create mode 100644 src/helpers/ota/OtaApply.h create mode 100644 src/helpers/ota/OtaBlInfo.h create mode 100644 src/helpers/ota/OtaFlashLayout_nrf52.h create mode 100644 src/helpers/ota/OtaStoreFlashEsp32.cpp create mode 100644 src/helpers/ota/OtaStoreFlashEsp32.h create mode 100644 src/helpers/ota/OtaStoreFlashNrf52.cpp create mode 100644 src/helpers/ota/OtaStoreFlashNrf52.h diff --git a/src/helpers/ota/OtaApply.cpp b/src/helpers/ota/OtaApply.cpp new file mode 100644 index 0000000000..f63f4d6884 --- /dev/null +++ b/src/helpers/ota/OtaApply.cpp @@ -0,0 +1,509 @@ +#include "OtaApply.h" +#include "OtaFormat.h" +#include "MotaContainer.h" +#include "Identity.h" +#include + +#if defined(ESP32_PLATFORM) + #include // rweather streaming SHA-256 (for hashing the slot in chunks) + #include "esp_ota_ops.h" + #include "esp_partition.h" + #include "esp_system.h" + 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" + #include "OtaFlashLayout_nrf52.h" + #include "OtaBlInfo.h" // read the bootloader capability marker before arming an apply + #include "flash/flash_nrf5x.h" // Adafruit core internal-flash driver (has its own extern "C") + #include "nrf.h" + #include "nrf_soc.h" + #include "nrf_sdm.h" +#endif + +namespace mesh { +namespace ota { + +#if defined(ESP32_PLATFORM) + +bool ota_apply_slot_info(uint32_t* addr, uint32_t* size) { + const esp_partition_t* p = esp_ota_get_next_update_partition(nullptr); + if (!p) return false; + if (addr) *addr = p->address; + if (size) *size = p->size; + return true; +} + +bool ota_apply_set_manifest(const uint8_t* mf, uint32_t len, const SignerAllowlist& allow, ApplyState& st) { + st = ApplyState(); + ota_apply_slot_info(&st.slot_addr, &st.slot_size); + MotaManifest m; + if (!mota_parse_manifest(mf, len, m)) return false; + if (!m.is_full()) return false; // A/B apply takes a full image (delta would need decode) + st.image_size = m.image_size; + memcpy(st.image_hash, m.image_hash, 32); + st.manifest_ok = true; + 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); + } + return true; +} + +bool ota_apply_verify_slot(ApplyState& st) { + st.slot_ok = false; + if (!st.manifest_ok || st.image_size == 0 || st.image_size > st.slot_size) return false; + const esp_partition_t* p = esp_ota_get_next_update_partition(nullptr); + if (!p) return false; + SHA256 sha; + uint8_t buf[512]; + uint32_t off = 0; + while (off < st.image_size) { + uint32_t n = st.image_size - off; if (n > sizeof(buf)) n = sizeof(buf); + if (esp_partition_read(p, off, buf, n) != ESP_OK) return false; + sha.update(buf, n); + off += n; + } + uint8_t h[32]; + sha.finalize(h, 32); + st.slot_ok = (memcmp(h, st.image_hash, 32) == 0); + return st.slot_ok; +} + +bool ota_apply_commit() { + const esp_partition_t* p = esp_ota_get_next_update_partition(nullptr); + if (!p) return false; + if (esp_ota_set_boot_partition(p) != ESP_OK) return false; + esp_restart(); // does not return + return true; +} + +// --- detools callback context ----------------------------------------------------------------- +// The delta base is the running OTA slot; the reconstructed image is streamed into the inactive slot +// via esp_ota_write (sequential, append-only -- matches detools' sequential output ordering) and +// hashed on the fly so we can check it against the signed manifest image_hash before arming. +struct DetoolsCtx { + const esp_partition_t* base; // delta base (running image), read at absolute `from_pos` + long from_pos; // absolute byte offset into `base` +#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 (RAM store; bring-up/host) +#endif + uint32_t patch_len; + uint32_t patch_pos; + esp_ota_handle_t out; // inactive slot write handle + SHA256* sha; // running hash of the reconstructed output + uint32_t out_pos; // #bytes written to the output slot + bool io_ok; +}; + +static int dt_from_read(void* arg, uint8_t* buf, size_t size) { + DetoolsCtx* c = (DetoolsCtx*)arg; + if (c->from_pos < 0 || (uint32_t)(c->from_pos) + size > c->base->size) return -DETOOLS_IO_FAILED; + if (esp_partition_read(c->base, (size_t)c->from_pos, buf, size) != ESP_OK) { c->io_ok = false; return -DETOOLS_IO_FAILED; } + c->from_pos += (long)size; + return DETOOLS_OK; +} +static int dt_from_seek(void* arg, int offset) { // detools uses relative seeks + DetoolsCtx* c = (DetoolsCtx*)arg; + c->from_pos += offset; + if (c->from_pos < 0 || (uint32_t)c->from_pos > c->base->size) return -DETOOLS_IO_FAILED; + return DETOOLS_OK; +} +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; +} +static int dt_to_write(void* arg, const uint8_t* buf, size_t size) { + DetoolsCtx* c = (DetoolsCtx*)arg; + if (esp_ota_write(c->out, buf, size) != ESP_OK) { c->io_ok = false; return -DETOOLS_IO_FAILED; } + c->sha->update(buf, size); + c->out_pos += (uint32_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: RAM-staged apply (whole .mota in a contiguous RAM buffer; bring-up/host) + +bool ota_apply_detools_mota(const uint8_t* buf, uint32_t len, const SignerAllowlist& allow, + ApplyState& st, char* msg) { + st = ApplyState(); + MotaManifest m; + if (!mota_parse(buf, len, m)) { strcpy(msg, "no valid .mota (parse failed)"); return false; } + if (m.is_full() || m.codec_id != CODEC_DETOOLS_SEQUENTIAL) { strcpy(msg, "not a detools-sequential delta"); return false; } + st.image_size = m.image_size; + memcpy(st.image_hash, m.image_hash, 32); + st.manifest_ok = true; + 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; } + } + 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; } + if (esp_ota_end(h) != ESP_OK) { strcpy(msg, "ota_end failed"); return false; } + if (esp_ota_set_boot_partition(out) != 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; +} +#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; +} + +void ota_reboot_to_apply() { esp_restart(); } // boots the slot armed by ota_apply_detools_mota; no return + +#elif defined(NRF52_PLATFORM) // single-slot: verify + mark APPROVED + hand off to the bootloader + +// ESP32 A/B-only entry points are unsupported on nRF52. +bool ota_apply_slot_info(uint32_t*, uint32_t*) { return false; } +bool ota_apply_set_manifest(const uint8_t*, uint32_t, const SignerAllowlist&, ApplyState& st) { st = ApplyState(); return false; } +bool ota_apply_verify_slot(ApplyState&) { return false; } +bool ota_apply_commit() { return false; } +bool ota_apply_detools_mota(const uint8_t*, uint32_t, const SignerAllowlist&, ApplyState& st, char* msg) { st = ApplyState(); strcpy(msg, "use ota_apply_mota_nrf52"); return false; } + +void ota_reboot_to_apply() { // public: set the apply magic + reset (does not return) + uint8_t sd_en = 0; + sd_softdevice_is_enabled(&sd_en); + if (sd_en) { // POWER is SD-restricted while the SoftDevice runs + sd_power_gpregret_clr(0, 0xFFFFFFFF); + sd_power_gpregret_set(0, GPREGRET_OTA_APPLY); + } else { + NRF_POWER->GPREGRET = GPREGRET_OTA_APPLY; + } + NVIC_SystemReset(); // does not return +} + +uint8_t ota_bootloader_last_rc() { // bootloader's last in-place-apply code, stashed in GPREGRET2 + uint32_t v = 0; + uint8_t en = 0; sd_softdevice_is_enabled(&en); + if (en) sd_power_gpregret_get(1, &v); else v = NRF_POWER->GPREGRET2; + return (uint8_t)v; +} + +bool ota_apply_mota_nrf52(const uint8_t* buf, uint32_t len, const SignerAllowlist& allow, + ApplyState& st, char* msg) { + st = ApplyState(); + MotaManifest m; + if (!mota_parse(buf, len, m)) { strcpy(msg, "parse failed"); return false; } + if (m.is_full() || m.codec_id != CODEC_DETOOLS_INPLACE) { strcpy(msg, "not an in-place delta"); return false; } + st.image_size = m.image_size; + memcpy(st.image_hash, m.image_hash, 32); + st.manifest_ok = true; + + // 0) THIS device's bootloader must be able to apply this .mota — otherwise staging + approving + rebooting + // just bounces back unchanged (a legacy/stock/older-OTAFIX bootloader). Refuse here, before any reboot. + { + OtaBlCaps bl = ota_bootloader_caps(); + if (!bl.present) { strcpy(msg, "this bootloader has no OTA-apply support — update the bootloader first"); return false; } + if (bl.apply_abi < m.format_ver || !(bl.codec_mask & (1u << m.codec_id))) { + snprintf(msg, 159, "bootloader too old to apply this update (bl abi=%u codecs=0x%x; need fmt>=%u codec=%u) — update the bootloader", + bl.apply_abi, bl.codec_mask, m.format_ver, m.codec_id); + return false; + } + } + + // Gated verification, in order, returning the FIRST failing reason (the bootloader re-checks integrity + // again before booting, so authenticity is gated here and re-validated there): + VerifyResult vr = ota_verify(buf, len, allow); + st.sig_ok = vr.sig_ok; st.trusted = vr.trusted; + + // 1) downloaded payload: the fetched blocks must match the manifest's merkle root (intact + complete) + if (!vr.root_ok || !vr.image_ok) { strcpy(msg, "payload hash mismatch (incomplete or corrupt .mota)"); return false; } + + // 2) target firmware: the delta must be built against THIS running image (base_hash == our EndF body + // hash). The resulting image_hash is re-checked by the bootloader after the in-place decode -- a + // single-slot device cannot produce the target image to hash it before applying. + 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; } + st.slot_ok = true; + + // 3) signature (only if the .mota is signed): valid Ed25519 AND signer in this device's allowlist + if (vr.is_signed) { + if (!vr.sig_ok) { strcpy(msg, "bad signature"); return false; } + if (!vr.trusted) { strcpy(msg, "untrusted signer (pubkey not in allowlist)"); return false; } + } + + // mark the staged manifest APPROVED in flash (buf is the memory-mapped staging region, so + // m.approval is a real flash address). NOR-clear over the erased 0xFFFFFFFF -> "APRV". + uint32_t approval_addr = (uint32_t)(uintptr_t)m.approval; + if (flash_nrf5x_write(approval_addr, APPROVAL_YES, 4) < 0) { strcpy(msg, "approval write failed"); return false; } + flash_nrf5x_flush(); + if (memcmp((const void*)(uintptr_t)approval_addr, APPROVAL_YES, 4) != 0) { strcpy(msg, "approval not set"); return false; } + + // Approved. Do NOT reset here — return so the caller can deliver `msg` to the operator first; the + // deferred ota_reboot_to_apply() (after the reply is sent) does the actual handoff to the bootloader. + sprintf(msg, "verified%s; applying — rebooting into bootloader once this reply is sent", + vr.is_signed ? " (signer trusted)" : " (unsigned)"); + return true; +} + +#else // native / other platforms + +bool ota_apply_slot_info(uint32_t*, uint32_t*) { return false; } +bool ota_apply_set_manifest(const uint8_t*, uint32_t, const SignerAllowlist&, ApplyState& st) { st = ApplyState(); return false; } +bool ota_apply_verify_slot(ApplyState&) { return false; } +bool ota_apply_commit() { return false; } +bool ota_apply_detools_mota(const uint8_t*, uint32_t, const SignerAllowlist&, ApplyState& st, char* msg) { st = ApplyState(); strcpy(msg, "unsupported"); return false; } +bool ota_apply_mota_nrf52(const uint8_t*, uint32_t, const SignerAllowlist&, ApplyState& st, char* msg) { st = ApplyState(); strcpy(msg, "unsupported"); return false; } +void ota_reboot_to_apply() {} +uint8_t ota_bootloader_last_rc() { return 0; } + +#endif + +} // namespace ota +} // namespace mesh diff --git a/src/helpers/ota/OtaApply.h b/src/helpers/ota/OtaApply.h new file mode 100644 index 0000000000..42e2f7b189 --- /dev/null +++ b/src/helpers/ota/OtaApply.h @@ -0,0 +1,66 @@ +#pragma once + +#include +#include +#include "SignerAllowlist.h" + +// P6 apply (full-image, ESP32 A/B). The new image is delivered into the inactive OTA slot; the device +// then verifies that slot against the signed manifest's image_hash (+ Ed25519/allowlist), and commits +// by setting it as the boot partition and rebooting. Safe + rollback-capable (the bootloader validates +// the image; a bad image rolls back). nRF52 apply is the bootloader-handoff path (separate). Functions +// return false on platforms without an A/B OTA layout. + +namespace mesh { +namespace ota { + +struct ApplyState { + bool manifest_ok = false; + bool sig_ok = false; + bool trusted = false; // signer in allowlist + bool slot_ok = false; // inactive slot image hashes to manifest.image_hash + uint32_t slot_addr = 0, slot_size = 0; + uint32_t image_size = 0; + uint8_t image_hash[32] = {0}; +}; + +bool ota_apply_slot_info(uint32_t* addr, uint32_t* size); // the inactive A/B slot +bool ota_apply_set_manifest(const uint8_t* mf, uint32_t len, + const SignerAllowlist& allow, ApplyState& st); // parse + verify signature +bool ota_apply_verify_slot(ApplyState& st); // hash the slot vs image_hash +bool ota_apply_commit(); // set-boot + reboot (no return) + +// 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, +// only if all pass, marks the staged manifest APPROVED in flash. It does NOT reboot — so the caller can +// first send the result back to the operator — the actual handoff is ota_reboot_to_apply() below. +// Returns true (msg = "verified...") when approved, false (msg = the first failing gate) otherwise. +bool ota_apply_mota_nrf52(const uint8_t* buf, uint32_t len, + const SignerAllowlist& allow, ApplyState& st, char* msg); + +// Commit the (already approved/armed) update and reboot into it — does NOT return. Call this only after +// a successful ota_apply_* AND after the confirmation reply has been delivered, so the operator knows +// the apply started (over LoRa the device then goes silent while the bootloader applies). nRF52: set +// the GPREGRET apply magic + reset (the bootloader does the in-place decode + verify). ESP32: reboot +// into the slot already armed by ota_apply_detools_mota. +void ota_reboot_to_apply(); + +// DIAGNOSTIC (nRF52): the bootloader stashes its last in-place-apply bail/progress code in GPREGRET2 (see +// ota_delta.c). Read it back so `ota status` can show why an apply didn't take. 0 / other platforms = n/a. +uint8_t ota_bootloader_last_rc(); + +} // namespace ota +} // namespace mesh diff --git a/src/helpers/ota/OtaBlInfo.h b/src/helpers/ota/OtaBlInfo.h new file mode 100644 index 0000000000..d22468fa79 --- /dev/null +++ b/src/helpers/ota/OtaBlInfo.h @@ -0,0 +1,54 @@ +#pragma once + +#include +#include + +// Read the bootloader's OTA capability marker so the app can decide, BEFORE staging+approving+rebooting, +// whether THIS device's bootloader can actually apply a `.mota`. Without this the app would reboot into a +// bootloader that silently can't apply (stock Adafruit, or an OLDER OTAFIX predating a `.mota` format +// change) and the device would just come back up unchanged. +// +// Mirror of Adafruit_nRF52_Bootloader_OTAFIX/src/ota_bl_info.h — keep byte-identical. +// nRF52 only (the bootloader flash is memory-mapped + readable by the app); a no-op elsewhere. + +#if defined(NRF52_PLATFORM) + #include "OtaFlashLayout_nrf52.h" +#endif + +namespace mesh { +namespace ota { + +// 16-byte marker: magic[8] "MOTABLDR" + apply_abi(2) + codec_mask(2) + reserved(4). +static const uint8_t OTA_BL_MAGIC[8] = { 'M','O','T','A','B','L','D','R' }; + +struct OtaBlCaps { + bool present = false; + uint16_t apply_abi = 0; // max .mota format_ver the bootloader can apply + uint16_t codec_mask = 0; // bit i set => can apply codec_id i (in-place delta = bit 2) +}; + +// Scan the bootloader flash region for the marker. Returns {present=false} if not found / non-nRF52. +inline OtaBlCaps ota_bootloader_caps() { + OtaBlCaps c; +#if defined(NRF52_PLATFORM) + const uint8_t* lo = (const uint8_t*)(uintptr_t)MOTA_NRF52_BL_START; + const uint8_t* hi = (const uint8_t*)(uintptr_t)MOTA_NRF52_BL_END; + for (const uint8_t* p = lo; p + 16 <= hi; p++) { + if (p[0] != OTA_BL_MAGIC[0] || memcmp(p, OTA_BL_MAGIC, 8) != 0) continue; + c.present = true; + c.apply_abi = (uint16_t)(p[8] | ((uint16_t)p[9] << 8)); + c.codec_mask = (uint16_t)(p[10] | ((uint16_t)p[11] << 8)); + break; + } +#endif + return c; +} + +// True if this device's bootloader can apply a .mota of the given format_ver + codec_id. +inline bool ota_bootloader_can_apply(uint8_t format_ver, uint8_t codec_id) { + OtaBlCaps c = ota_bootloader_caps(); + return c.present && c.apply_abi >= format_ver && (c.codec_mask & (1u << codec_id)) != 0; +} + +} // namespace ota +} // namespace mesh diff --git a/src/helpers/ota/OtaFlashLayout_nrf52.h b/src/helpers/ota/OtaFlashLayout_nrf52.h new file mode 100644 index 0000000000..289348fdf5 --- /dev/null +++ b/src/helpers/ota/OtaFlashLayout_nrf52.h @@ -0,0 +1,39 @@ +#pragma once + +// Shared OTA flash-layout constants for the nRF52840 (RAK4631) single-slot delta-apply path. +// SINGLE SOURCE OF TRUTH — keep byte-identical with the bootloader's src/ota_layout.h. +// +// The running app occupies [APP_BASE, app_end]; the primary LittleFS (InternalFS) starts at FS_START. +// MeshCore stages a verified+approved `.mota` in the free flash below FS_START (bottom-aligned), then +// sets GPREGRET_OTA_APPLY and resets; the bootloader scans [APP_BASE, FS_START) for it and applies it +// in place. These must match the bootloader and the running SoftDevice's app base. + +#include + +namespace mesh { +namespace ota { + +static const uint32_t MOTA_NRF52_APP_BASE = 0x00026000u; // S140 end (== CODE_REGION_1_START) +// Staging ceiling: the lowest filesystem region above the app. RAK4631 companion builds use the +// extrafs ldscript with ExtraFS at 0xD4000..0xED000 (and InternalFS at 0xED000), while the repeater +// uses the default ldscript (InternalFS at 0xED000, 0xD4000..0xED000 free). 0xD4000 is the safe +// universal ceiling for ALL RAK4631 roles: staging below it never touches ExtraFS or InternalFS, and +// the app (~520 KB) sits well below 0xD4000 either way. +static const uint32_t MOTA_NRF52_FS_START = 0x000D4000u; // ExtraFS start (universal staging ceiling) +static const uint32_t MOTA_NRF52_FLASH_PAGE = 4096u; +static const uint8_t GPREGRET_OTA_APPLY = 0x6Au; // distinct from DFU magics 0x57/0x4E/0xA8 + +// In-place patches are built with --inplace-memory = this (the apply workspace, from APP_BASE up). +// It must hold the new image (~520 KB) yet leave the staged mota room below FS_START: workspace ends +// at APP_BASE+this = 0xBE000, leaving 0xBE000..0xD4000 (~88 KB) for the staged delta. The bootloader +// also bounds writes to < the (scanned) mota start, so a mis-sized memory still fails safe. +static const uint32_t MOTA_NRF52_INPLACE_MEMORY = 0x00098000u; // 608 KB (APP_BASE .. 0xBE000) + +// Bootloader flash region (nRF52840: 39 KB ending just below the CF2/MBR-params pages). The app scans +// this for the bootloader capability marker (OtaBlInfo.h) to know whether THIS device's bootloader can +// actually apply a .mota before staging+approving+rebooting. +static const uint32_t MOTA_NRF52_BL_START = 0x000F4000u; +static const uint32_t MOTA_NRF52_BL_END = 0x000FE000u; + +} // namespace ota +} // namespace mesh diff --git a/src/helpers/ota/OtaStoreFlashEsp32.cpp b/src/helpers/ota/OtaStoreFlashEsp32.cpp new file mode 100644 index 0000000000..9afcb80315 --- /dev/null +++ b/src/helpers/ota/OtaStoreFlashEsp32.cpp @@ -0,0 +1,243 @@ +#include "OtaStoreFlashEsp32.h" + +#if defined(ESP32_PLATFORM) && defined(OTA_FLASH_STORE) + +#include "OtaDebug.h" +#include "OtaByteIO.h" // align_up / align_down (flash-sector geometry) +#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 { + +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 = align_up(_meta_bytes + 5, SEC); // meta + 5-byte trailer, whole sectors + if (_meta_flush > OTA_ESP32_META_CAP) return false; + uint32_t bottom = align_down(_psize - _meta_flush, SEC); + _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 = align_up(_meta_bytes, SEC); // 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 = align_down(_psize - total, SEC); + _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 = align_down(_psize - SEC, SEC); ; o -= SEC) { + if (esp_partition_read(_part, o, hb, 8) == ESP_OK && memcmp(hb, MOTA_MAGIC, 4) == 0) { + uint32_t total = rd_u32le(hb + 4); + 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 0000000000..f0eee558ff --- /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 new file mode 100644 index 0000000000..51979e2a60 --- /dev/null +++ b/src/helpers/ota/OtaStoreFlashNrf52.cpp @@ -0,0 +1,159 @@ +#include "OtaStoreFlashNrf52.h" + +#if defined(NRF52_PLATFORM) && defined(OTA_FLASH_STORE) + +#include "OtaSelf.h" +#include "OtaDebug.h" +#include "OtaByteIO.h" // align_down / rd_u32le (flash-page geometry + header read) +#include +#include "flash/flash_nrf5x.h" // Adafruit core internal-flash driver (SoftDevice-safe; LittleFS path) + +namespace mesh { +namespace ota { + +// Write one whole 4 KB page from `buf` to flash (erase + program, ~85 ms). `buf` is PG bytes, 0xFF-padded +// past the container, so the program is clean. The last container page ends exactly at FS_START (both +// FS_START and _write_start are page-aligned and the container ends <= FS_START), so a full-page write +// never reaches into ExtraFS. +void OtaStoreFlashNrf52::flush_page(uint32_t page_idx, const uint8_t* buf) { + uint32_t addr = _write_start + page_idx * PG; + if (addr + PG > MOTA_NRF52_FS_START) return; // defensive: never cross the staging ceiling + OTA_DBG("OTA flash: write page %u @ %08x\n", (unsigned)page_idx, (unsigned)addr); + flash_nrf5x_write(addr, buf, PG); + flash_nrf5x_flush(); +} + +void OtaStoreFlashNrf52::flush_pay() { + if (_pay_idx != 0) flush_page(_pay_idx, _pay_page); // _pay_idx 0 == no payload page open +} + +uint32_t OtaStoreFlashNrf52::run(uint32_t pos, uint32_t remain) const { + uint32_t trailer = _total - 5; // container is always >= 13 bytes (begin checks) + if (pos >= trailer) return remain; // tail: caller bounds remain to <= 5 already + uint32_t end = pos + remain; + uint32_t page_end = (pos / PG + 1) * PG; + if (end > page_end) end = page_end; // a run stays within one flash page, + if (end > trailer) end = trailer; // and never crosses into the trailer tail + return end - pos; +} + +const uint8_t* OtaStoreFlashNrf52::read_slot(uint32_t pos) const { + if (pos >= _total - 5) return _trailer + (pos - (_total - 5)); // trailer tail (RAM until finalize) + uint32_t page = pos / PG; + if (page == 0) return _meta_page + pos; // pinned page 0 (incl. leaves) + if (page == _pay_idx) return _pay_page + (pos - page * PG); // current sliding payload page + return (const uint8_t*)(uintptr_t)(_write_start + pos); // already flushed -> memory-mapped +} + +uint8_t* OtaStoreFlashNrf52::write_slot(uint32_t pos) { + if (pos >= _total - 5) return _trailer + (pos - (_total - 5)); + uint32_t page = pos / PG; + if (page == 0) return _meta_page + pos; + if (page > _pay_idx) { flush_pay(); _pay_idx = page; memset(_pay_page, 0xFF, PG); } // advance, fresh page + if (page == _pay_idx) return _pay_page + (pos - page * PG); + return nullptr; // page < _pay_idx: already flushed +} + +bool OtaStoreFlashNrf52::begin(uint32_t total_size) { + clear(); + if (total_size < 13 || total_size > capacity()) return false; // 13 = header(8) + trailer(5) + + // bottom-align against FS_START so the trailer ends exactly at FS_START (bootloader scans for it) + uint32_t start = align_down(MOTA_NRF52_FS_START - total_size, PG); + + // never collide with the running application image (its extent comes from its EndF trailer) + 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; + if (start < app_end) return false; + + _write_start = start; + _total = total_size; + memset(_meta_page, 0xFF, PG); // assemble page 0 in RAM; 0xFF = erased sentinel (unfilled leaf slots) + memset(_trailer, 0xFF, sizeof(_trailer)); + _pay_idx = 0; + _flushed = false; + OTA_DBG("OTA flash: begin total=%u start=%08x app_end=%08x\n", + (unsigned)total_size, (unsigned)start, (unsigned)app_end); + return true; // no pre-erase: each page is erased by its own (single) flush +} + +bool OtaStoreFlashNrf52::write(uint32_t offset, const uint8_t* d, uint32_t len) { + 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 (uint8_t* dst = write_slot(pos)) { + memcpy(dst, d, n); + } else { + // out-of-order write to an already-flushed page: read-modify-write straight to flash. Safe -- the + // driver erases the page before programming, so re-touching it never breaks writes-per-word. + OTA_DBG("OTA flash: RMW page %u (out-of-order) @ off %u\n", (unsigned)(pos / PG), (unsigned)pos); + if (flash_nrf5x_write(_write_start + pos, d, n) < 0) return false; + flash_nrf5x_flush(); + } + pos += n; d += n; + } + return true; +} + +bool OtaStoreFlashNrf52::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); + memcpy(buf, read_slot(pos), n); + pos += n; buf += n; + } + return true; +} + +void OtaStoreFlashNrf52::finalize() { + if (_flushed || _total == 0) return; + OTA_DBG("OTA flash: finalize total=%u\n", (unsigned)_total); + flush_pay(); // the last (highest) payload page, if one is open + flush_page(0, _meta_page); // page 0: header + manifest + leaves + first payload bytes + flash_nrf5x_write(_write_start + _total - 5, _trailer, 5); // trailer tail (radio idle at COMPLETE) + flash_nrf5x_flush(); + _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 = align_down(MOTA_NRF52_FS_START - PG, PG); 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 = rd_u32le(p + 4); + if (total < 13 || total > capacity()) continue; + if (align_down(MOTA_NRF52_FS_START - total, PG) != 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 + +#endif diff --git a/src/helpers/ota/OtaStoreFlashNrf52.h b/src/helpers/ota/OtaStoreFlashNrf52.h new file mode 100644 index 0000000000..37d64ceff3 --- /dev/null +++ b/src/helpers/ota/OtaStoreFlashNrf52.h @@ -0,0 +1,77 @@ +#pragma once + +#if defined(NRF52_PLATFORM) && defined(OTA_FLASH_STORE) + +#include "OtaStore.h" +#include "OtaFlashLayout_nrf52.h" + +// Persistent flash-backed OtaStore for nRF52 (RAK4631). Stages the received `.mota` in the free flash +// below the primary LittleFS (FS_START), bottom-aligned so its trailer ends at FS_START and the +// bootloader can scan for it. Survives reboot — the whole point — so the bootloader can apply the +// staged delta on the next boot. +// +// RAM is bounded to O(one flash page), NEVER O(mota): a 100 KB+ delta must not live in RAM. +// - On nRF52 the flash *erase* unit is one 4 KB page and the only SoftDevice-safe writer +// (Adafruit `flash_nrf5x`) erases the whole page on every flush (~85 ms, CPU stalled → LoRa RX +// starved). Writing to flash per received packet therefore drops in-flight DATA and the transfer +// stalls. The fix: coalesce to the *page*, the hardware-natural unit, and write each page once. +// - `_meta_page` pins flash page 0 (header + manifest + the merkle-leaf progress markers, which are +// written one-per-block all transfer long). Keeping it in RAM means streaming the payload never +// re-erases the leaves' page. Flushed once at finalize(). Requires metadata <= one page +// (set_meta_size enforces it; true for <= ~979 blocks, i.e. any realistic MeshCore image). +// - `_pay_page` is a single sliding buffer for one payload page (index >= 1). It advances +// monotonically with the (mostly in-order) block stream and flushes the page it leaves behind. +// Rare out-of-order writes to an already-flushed page go straight to flash as a safe read-modify- +// write (flash_nrf5x erases before programming, so re-touching a page never violates the +// writes-per-word limit — it just costs one extra erase). +// - The 5-byte trailer is buffered and written at finalize(). +// Net: flash is touched ~once per 4 KB page (≈ 1 per 4 blocks at 1 KB), off the per-packet path; page +// 0 and the last page are written at finalize() with the radio idle. For a small delta (whole .mota +// in page 0) there is ZERO flash I/O during the transfer. + +namespace mesh { +namespace ota { + +class OtaStoreFlashNrf52 : public OtaStore { + static const uint32_t PG = MOTA_NRF52_FLASH_PAGE; // 4096 + + uint32_t _write_start = 0; // flash address of container offset 0 (page-aligned) + uint32_t _total = 0; // staged container size (0 = none) + bool _flushed = false; // finalize() committed everything to flash + + uint8_t _meta_page[PG]; // pinned flash page 0 (header + manifest + leaves + 1st payload) + uint8_t _pay_page[PG]; // sliding buffer for one payload page (index _pay_idx) + uint32_t _pay_idx = 0; // page index currently held in _pay_page (0 = none open; pages >= 1) + uint8_t _trailer[5]; // last 5 container bytes (kept in RAM, written at finalize) + + // Bytes from `pos` that stay in one store region (a single flash page, or the trailer tail). + uint32_t run(uint32_t pos, uint32_t remain) const; + // RAM home of byte `pos`: read_slot always resolves (flushed pages → memory-mapped flash); write_slot + // opens/advances the sliding payload page and returns nullptr if `pos` is in an already-flushed page. + const uint8_t* read_slot(uint32_t pos) const; + uint8_t* write_slot(uint32_t pos); + void flush_pay(); // commit _pay_page to flash (erase + program, one page) + void flush_page(uint32_t page_idx, const uint8_t* buf); // write a full page to flash + +public: + 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 MOTA_NRF52_FS_START - MOTA_NRF52_APP_BASE; } + uint32_t staged_size() const override { return _total; } + 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. + const uint8_t* data() const { return (const uint8_t*)(uintptr_t)_write_start; } + uint32_t write_start() const { return _write_start; } +}; + +} // namespace ota +} // namespace mesh + +#endif From 1b5f7a89e768641823b5f867b7541ee3526ed966 Mon Sep 17 00:00:00 2001 From: Valentin Kivachuk Burda Date: Mon, 29 Jun 2026 13:03:05 +0200 Subject: [PATCH 05/59] ota: relay an external folder of .mota over USB serial (MotaSource) (cherry picked from commit 9beaf6848e57c81f1a3fcbedcc0d0d82b9609508) --- src/helpers/ota/MotaSourceSerial.cpp | 96 ++++++++++++++++++++++++++++ src/helpers/ota/MotaSourceSerial.h | 35 ++++++++++ 2 files changed, 131 insertions(+) create mode 100644 src/helpers/ota/MotaSourceSerial.cpp create mode 100644 src/helpers/ota/MotaSourceSerial.h diff --git a/src/helpers/ota/MotaSourceSerial.cpp b/src/helpers/ota/MotaSourceSerial.cpp new file mode 100644 index 0000000000..9b3023292c --- /dev/null +++ b/src/helpers/ota/MotaSourceSerial.cpp @@ -0,0 +1,96 @@ +#include "MotaSourceSerial.h" +#include "MotaSeederProto.h" +#include "OtaByteIO.h" +#include + +namespace mesh { +namespace ota { + +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 0000000000..26b9e29863 --- /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 From 3db2640d9de2a8810e60d97b804da527836694da Mon Sep 17 00:00:00 2001 From: Valentin Kivachuk Burda Date: Mon, 29 Jun 2026 13:03:05 +0200 Subject: [PATCH 06/59] ota: CLI commands + node/CommonCLI integration + example hooks (cherry picked from commit 543c90119ec37045f57eb6853ce6c7d88a854ecc) --- examples/companion_radio/MyMesh.h | 2 +- examples/simple_repeater/MyMesh.cpp | 6 +- examples/simple_repeater/MyMesh.h | 6 +- examples/simple_room_server/MyMesh.h | 2 +- examples/simple_sensor/SensorMesh.h | 2 +- src/Mesh.cpp | 145 ++++++++++ src/Mesh.h | 37 +++ src/MeshCore.h | 21 ++ src/Packet.h | 1 + src/helpers/CommonCLI.cpp | 61 ++++- src/helpers/CommonCLI.h | 9 + src/helpers/ota/OtaCli.cpp | 396 +++++++++++++++++++++++++++ src/helpers/ota/OtaCli.h | 16 ++ 13 files changed, 695 insertions(+), 9 deletions(-) create mode 100644 src/helpers/ota/OtaCli.cpp create mode 100644 src/helpers/ota/OtaCli.h diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 43d3950beb..fc2bb324cd 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -12,7 +12,7 @@ #endif #ifndef FIRMWARE_VERSION -#define FIRMWARE_VERSION "v1.16.0" +#define FIRMWARE_VERSION "v1.17.0" #endif #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 096907494b..37169efef6 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -924,8 +924,10 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc memset(default_scope.key, 0, sizeof(default_scope.key)); } +// OTA mesh-integration (receive/begin/loop) is centralized in mesh::Mesh — no per-example wiring. + void MyMesh::begin(FILESYSTEM *fs) { - mesh::Mesh::begin(); + mesh::Mesh::begin(); // also starts OTA (ota_ctx().begin) for all roles _fs = fs; // load persisted prefs _cli.loadPrefs(_fs); @@ -1267,7 +1269,7 @@ void MyMesh::loop() { bridge.loop(); #endif - mesh::Mesh::loop(); + mesh::Mesh::loop(); // also drives the OTA fetch loop (centralized in mesh::Mesh) if (next_flood_advert && millisHasNowPassed(next_flood_advert)) { mesh::Packet *pkt = createSelfAdvert(); diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 7597c6c6f6..7925337130 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -2,6 +2,9 @@ #include #include +#if defined(ENABLE_OTA) + #include +#endif #include #include @@ -73,7 +76,7 @@ struct NeighbourInfo { #endif #ifndef FIRMWARE_VERSION - #define FIRMWARE_VERSION "v1.16.0" + #define FIRMWARE_VERSION "v1.17.0" #endif #define FIRMWARE_ROLE "repeater" @@ -172,6 +175,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { void onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_idx, const uint8_t* secret, uint8_t* data, size_t len) override; bool onPeerPathRecv(mesh::Packet* packet, int sender_idx, const uint8_t* secret, uint8_t* path, uint8_t path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) override; void onControlDataRecv(mesh::Packet* packet) override; + // OTA mesh-integration is centralized in mesh::Mesh (no per-example onOtaRecv / send adapter / tick). void sendFloodReply(mesh::Packet* packet, unsigned long delay_millis, uint8_t path_hash_size); diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 5277ddad61..4f464ed683 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -31,7 +31,7 @@ #endif #ifndef FIRMWARE_VERSION - #define FIRMWARE_VERSION "v1.16.0" + #define FIRMWARE_VERSION "v1.17.0" #endif #ifndef LORA_FREQ diff --git a/examples/simple_sensor/SensorMesh.h b/examples/simple_sensor/SensorMesh.h index c9f135f65e..8e8ed8a534 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/examples/simple_sensor/SensorMesh.h @@ -38,7 +38,7 @@ #endif #ifndef FIRMWARE_VERSION - #define FIRMWARE_VERSION "v1.16.0" + #define FIRMWARE_VERSION "v1.17.0" #endif #define FIRMWARE_ROLE "sensor" diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 87ad61af2e..ecab951f07 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -1,14 +1,111 @@ #include "Mesh.h" //#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 { +#if defined(ENABLE_OTA) +// Adapter so the portable OtaManager can emit packets through the mesh (lowest priority, hop-capped). +void Mesh::otaSendAdapter(void* ctx, const uint8_t* msg, uint16_t len, bool /*flood*/) { + Mesh* m = (Mesh*)ctx; + Packet* p = m->createOtaPacket(msg, len); + if (p) m->sendOtaFlood(p); +} +#endif + void Mesh::begin() { Dispatcher::begin(); +#if defined(ENABLE_OTA) + uint32_t my_tid = 0; + #ifdef MOTA_TARGET_ID + my_tid = (uint32_t)(MOTA_TARGET_ID); // sha2-256:4(env name), injected by build.sh + #endif + 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 } void Mesh::loop() { Dispatcher::loop(); +#if defined(ENABLE_OTA) + // Deferred apply-reboot: a verified `ota applydelta` approves the update but does NOT reboot inline, + // so its "verified; applying" reply can be delivered first (over LoRa that reply is the operator's + // only confirmation the apply started). Reboot once that reply has actually been transmitted (the + // outbound queue drains) after a short grace to let it be queued, with a hard cap for a busy node + // whose queue never idles. + { + ota::OtaContext& oc = ota::ota_ctx(); + if (oc.apply_pending) { + if (oc.apply_at == 0) { + oc.apply_at = futureMillis(1500); + oc.apply_hard = futureMillis(15000); + } else if (millisHasNowPassed(oc.apply_at) && + (_mgr->getOutboundTotal() == 0 || millisHasNowPassed(oc.apply_hard))) { + ota::ota_reboot_to_apply(); // does not return + } + } + } + if (millisHasNowPassed(_next_ota_tick)) { + // 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 } bool Mesh::allowPacketForward(const mesh::Packet* packet) { @@ -309,6 +406,31 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { } break; +#if defined(ENABLE_OTA) + case PAYLOAD_TYPE_OTA: { + // ALWAYS process every received copy: OTA handlers are idempotent, and "eventually reliable" + // retries deliberately re-send IDENTICAL requests — if we gated processing on hasSeen(), the + // 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.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(); + if (!seen && pkt->isRouteFlood() && !pkt->isMarkedDoNotRetransmit() + && n < getOtaHopLimit() + && (n + 1) * pkt->getPathHashSize() <= MAX_PATH_SIZE + && allowPacketForward(pkt)) { + self_id.copyHashTo(&pkt->path[n * pkt->getPathHashSize()], pkt->getPathHashSize()); + pkt->setPathHashCount(n + 1); + action = ACTION_RETRANSMIT_DELAYED(OTA_TX_PRIORITY, getRetransmitDelay(pkt)); + } + break; + } +#endif default: MESH_DEBUG_PRINTLN("%s Mesh::onRecvPacket(): unknown payload type, header: %d", getLogDateTime(), (int) pkt->header); // Don't flood route unknown packet types! action = routeRecvPacket(pkt); @@ -619,6 +741,29 @@ Packet* Mesh::createControlData(const uint8_t* data, size_t len) { return packet; } +#if defined(ENABLE_OTA) +Packet* Mesh::createOtaPacket(const uint8_t* data, size_t len) { + if (len > sizeof(Packet::payload)) return NULL; + Packet* packet = obtainNewPacket(); + if (packet == NULL) { + MESH_DEBUG_PRINTLN("%s Mesh::createOtaPacket(): error, packet pool empty", getLogDateTime()); + return NULL; + } + packet->header = (PAYLOAD_TYPE_OTA << PH_TYPE_SHIFT); // ROUTE_TYPE_* set by sendOtaFlood + memcpy(packet->payload, data, len); + packet->payload_len = len; + return packet; +} + +void Mesh::sendOtaFlood(Packet* packet, uint32_t delay_millis) { + packet->header &= ~PH_ROUTE_MASK; + packet->header |= ROUTE_TYPE_FLOOD; + packet->setPathHashSizeAndCount(1, 0); + _tables->hasSeen(packet); // mark as sent, in case it floods back to us + sendPacket(packet, OTA_TX_PRIORITY, delay_millis); +} +#endif + void Mesh::sendFlood(Packet* packet, uint32_t delay_millis, uint8_t path_hash_size) { if (packet->getPayloadType() == PAYLOAD_TYPE_TRACE) { MESH_DEBUG_PRINTLN("%s Mesh::sendFlood(): TRACE type not suspported", getLogDateTime()); diff --git a/src/Mesh.h b/src/Mesh.h index 2302d6b5b7..e550e2c71f 100644 --- a/src/Mesh.h +++ b/src/Mesh.h @@ -2,6 +2,16 @@ #include +#if defined(ENABLE_OTA) + // OTA-over-LoRa: lowest TX priority (selected only after all real traffic) + default hop cap. + #ifndef OTA_TX_PRIORITY + #define OTA_TX_PRIORITY 250 + #endif + #ifndef OTA_HOP_LIMIT_DEFAULT + #define OTA_HOP_LIMIT_DEFAULT 3 + #endif +#endif + namespace mesh { class GroupChannel { @@ -144,6 +154,26 @@ class Mesh : public Dispatcher { */ virtual void onRawDataRecv(Packet* packet) { } +#if defined(ENABLE_OTA) + /** + * \brief An OTA-over-LoRa packet (PAYLOAD_TYPE_OTA) has been received. Subclasses forward the + * payload bytes to their OtaManager. See docs/ota_protocol.md. + */ + virtual void onOtaRecv(Packet* packet) { } + + /** \returns the max hop count for forwarding OTA flood packets (default 3). */ + virtual uint8_t getOtaHopLimit() const { return OTA_HOP_LIMIT_DEFAULT; } + + // OTA mesh-integration is centralized in Mesh::begin()/loop()/dispatch, so every role (repeater, + // 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 + /** * \brief Perform search of local DB of matching GroupChannels. * \param channels OUT - store matching channels in this array, up to max_matches @@ -192,6 +222,13 @@ class Mesh : public Dispatcher { Packet* createPathReturn(const uint8_t* dest_hash, const uint8_t* secret, const uint8_t* path, uint8_t path_len, uint8_t extra_type, const uint8_t*extra, size_t extra_len); Packet* createPathReturn(const Identity& dest, const uint8_t* secret, const uint8_t* path, uint8_t path_len, uint8_t extra_type, const uint8_t*extra, size_t extra_len); Packet* createRawData(const uint8_t* data, size_t len); + +#if defined(ENABLE_OTA) + // Build a PAYLOAD_TYPE_OTA packet from raw OTA message bytes (route set by sendOtaFlood). + Packet* createOtaPacket(const uint8_t* data, size_t len); + // Flood-send at the lowest priority (so OTA never competes with mesh traffic). + void sendOtaFlood(Packet* packet, uint32_t delay_millis = 0); +#endif Packet* createTrace(uint32_t tag, uint32_t auth_code, uint8_t flags = 0); Packet* createControlData(const uint8_t* data, size_t len); diff --git a/src/MeshCore.h b/src/MeshCore.h index cfa33cf90b..6c99b115a9 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -64,6 +64,27 @@ class MainBoard { virtual uint8_t getStartupReason() const = 0; virtual bool getBootloaderVersion(char* version, size_t max_len) { return false; } virtual bool startOTAUpdate(const char* id, char reply[]) { return false; } // not supported +#if defined(ENABLE_OTA) + // 4-byte build-target discriminator for OTA-over-LoRa (docs/ota_protocol.md §9). Default is the + // MOTA_TARGET_ID build flag injected by build.sh; 0 when unset (e.g. a bare IDE build). + virtual uint32_t getOtaTargetId() const { + #ifdef MOTA_TARGET_ID + return (uint32_t)(MOTA_TARGET_ID); + #else + 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) virtual bool isExternalPowered() { return false; } diff --git a/src/Packet.h b/src/Packet.h index 0886a06c4e..3491ae3d53 100644 --- a/src/Packet.h +++ b/src/Packet.h @@ -28,6 +28,7 @@ namespace mesh { #define PAYLOAD_TYPE_TRACE 0x09 // trace a path, collecting SNI for each hop #define PAYLOAD_TYPE_MULTIPART 0x0A // packet is one of a set of packets #define PAYLOAD_TYPE_CONTROL 0x0B // a control/discovery packet +#define PAYLOAD_TYPE_OTA 0x0C // OTA-over-LoRa firmware distribution (see docs/ota_protocol.md) //... #define PAYLOAD_TYPE_RAW_CUSTOM 0x0F // custom packet as raw bytes, for applications with custom encryption, payloads, etc diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index b78ad6ebd6..1555b98f89 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -4,6 +4,10 @@ #include "AdvertDataHelpers.h" #include "TxtDataHelpers.h" #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 #define BRIDGE_MAX_BAUD 115200 @@ -28,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"); @@ -91,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 = 4; // = OTA_CHECKPOINT_BLOCKS; older prefs lack it -> stays at default + 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); @@ -121,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 = 4; // 0=never; cap absurd + if (_prefs->ota_signer_count > 4) _prefs->ota_signer_count = 0; // corrupt count -> drop keys file.close(); } @@ -184,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: 426 file.close(); } @@ -306,6 +346,21 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re sprintf(reply, "%s (Build: %s)", _callbacks->getFirmwareVer(), _callbacks->getBuildDate()); } else if (memcmp(command, "board", 5) == 0) { sprintf(reply, "%s", _board->getManufacturerName()); +#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; const char* val = _sensors->getSettingByKey(key); diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index b509c2b31a..c4ff8f3ee9 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 4 (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/OtaCli.cpp b/src/helpers/ota/OtaCli.cpp new file mode 100644 index 0000000000..b5cb340675 --- /dev/null +++ b/src/helpers/ota/OtaCli.cpp @@ -0,0 +1,396 @@ +#include "OtaCli.h" +#include "OtaContext.h" +#include "OtaVerify.h" +#include "OtaSelf.h" +#include "OtaTargets.h" // ota_target_env_name(): human-readable name for a target_id (no string on the wire) +#if defined(NRF52_PLATFORM) + #include "OtaBlInfo.h" // ota_bootloader_caps(): can this device's bootloader apply a .mota? +#endif +#include "Utils.h" +#include +#include +#include +#include // millis() for session-age display (device-only command surface) + +namespace mesh { +namespace ota { + +static uint32_t parse_u32(const char* s) { + uint32_t n = 0; + while (*s == ' ') s++; + while (*s >= '0' && *s <= '9') n = n * 10 + (uint32_t)(*s++ - '0'); + return n; +} + +static char fstate_char(OtaManager::FetchState s) { + switch (s) { + case OtaManager::IDLE: return 'I'; + case OtaManager::WANT_MANIFEST: return 'W'; + case OtaManager::FETCHING: return 'F'; + case OtaManager::COMPLETE: return 'C'; + default: return 'X'; + } +} + +// For users, the only distinction that matters is full image vs. delta (which delta codec is internal). +static const char* codec_kind(uint8_t c) { return c == CODEC_FULL ? "full" : "delta"; } + +// A plain-language word for the fetch state (shown in `ota status`). +static const char* state_word(OtaManager::FetchState s) { + switch (s) { + case OtaManager::IDLE: return "idle"; + case OtaManager::WANT_MANIFEST: return "starting"; + case OtaManager::FETCHING: return "downloading"; + case OtaManager::COMPLETE: return "ready to install"; + case OtaManager::FAILED: return "failed"; + default: return "?"; + } +} + +// Render the packed fw_version as "v1.2.3" (or "v1.2.3.4" when a prerelease byte is set). +static void ver_str(char* out, size_t cap, uint32_t v) { + FwVersion fw = FwVersion::unpack(v); + if (fw.prerelease) snprintf(out, cap, "v%u.%u.%u.%u", fw.major, fw.minor, fw.patch, fw.prerelease); + else snprintf(out, cap, "v%u.%u.%u", fw.major, fw.minor, fw.patch); +} + +// Match the first word of `a` against any of the '|'-separated names (so commands have intuitive aliases +// and short forms); on a match, point `*rest` at the argument text. Keeps the dispatch table readable. +static bool is_cmd(const char* a, const char* names, const char** rest) { + size_t tlen = 0; while (a[tlen] && a[tlen] != ' ') tlen++; + for (const char* s = names; *s; ) { + const char* d = s; while (*d && *d != '|') d++; + if ((size_t)(d - s) == tlen && tlen && strncmp(a, s, tlen) == 0) { + const char* r = a + tlen; while (*r == ' ') r++; + if (rest) *rest = r; + return true; + } + s = (*d == '|') ? d + 1 : d; + } + return false; +} + +// 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(); + const char* rest = a; + + // ---- raw / internal primitives, tucked under `ota dev ...` ---- + if (is_cmd(a, "dev", &rest)) { + return handle_dev(rest, reply, c); + } + + // ---- help: list the commands in plain words (aliases in parentheses) ---- + if (is_cmd(a, "help|?|h", &rest)) { + snprintf(reply, 160, + "OTA: status | ls=find updates | get <#>=download | install | cancel | announce | self | " + "folder | config | key. Try `ota ls`."); + + // ---- inventory dashboard: running fw (self), the one fetch session, serving state ---- + } else if (*a == 0 || is_cmd(a, "status|st", &rest)) { + SelfFwInfo fi; bool s = ota_self_firmware(fi); + 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 dl[80]; + if (fs == OtaManager::IDLE) { + strcpy(dl, "no download"); + } else { + char midhx[9]; mesh::Utils::toHex(midhx, c.manager.fetchManifestId(), 4); + unsigned have = (unsigned)c.manager.blocksHave(), tot = (unsigned)c.manager.blocksTotal(); + unsigned pct = tot ? (unsigned)((uint64_t)have * 100 / tot) : 0; + unsigned age = c.session_started_ms ? (unsigned)((millis() - c.session_started_ms) / 1000) : 0; + snprintf(dl, sizeof dl, "download: %s %u/%u (%u%%) id=%s %us", state_word(fs), have, tot, pct, midhx, age); + } + const char* hw = (c.hw_id[0]) ? c.hw_id : "?"; + const char* tenv = ota_target_env_name(c.manager.target()); // env name, or "?" if not in the table + int n = snprintf(reply, 160, "OTA | this fw %s (%uK) hw=%s | %s | serving:%s (%u) | keys:%u | target:%08X (%s)", + selfhx, (unsigned)((s ? fi.image_len : 0) / 1024), hw, dl, + c.serving ? "on" : "off", (unsigned)c.manager.servedCount(), + (unsigned)c.allow.count(), (unsigned)c.manager.target(), tenv ? tenv : "?"); +#if defined(NRF52_PLATFORM) + // nRF52 applies via the bootloader — show (cached) whether it can, so `ota get`/`install` won't surprise. + // blrc = the bootloader's last in-place-apply code (diagnostic; 0xB8=success, see ota_delta.c). + if (n < 146) n += snprintf(reply + n, 160 - n, " | bl:%s blrc:%02X", + c.bootloaderCaps().present ? "apply" : "NONE", ota_bootloader_last_rc()); +#endif + + // ---- what's available around me (catalogued from beacons + OTA_HAVE), best/most-recent first ---- + } else if (is_cmd(a, "neighbors|nbrs|updates|ls|n", &rest)) { + // Kick a fresh round of catalog queries (async — rows arrive over the next seconds); render what we + // have now in plain words. The reply buffer is 160 B (serial / one LoRa packet for remote-admin), so + // writes are bounded and extra rows collapse to "+N more". + c.manager.queryAll(); + const int CAP = 160; + int n = snprintf(reply, CAP, "Updates nearby (%u src) — `ota get <#>` to download:", + (unsigned)c.manager.sourceCount()); + const uint8_t* cur = (c.manager.fetchState() != OtaManager::IDLE) ? c.manager.fetchManifestId() : nullptr; + uint32_t myt = c.manager.target(); // effective target (EndF identity if present, else build flag) + 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 < 48) { more++; continue; } + bool on = cur && memcmp(cur, h->mid, 4) == 0; + uint32_t age = (now - h->last_ms) / 1000; if (age > 99999) age = 99999; + char ver[20]; ver_str(ver, sizeof ver, h->fw_version); + // What is this update for? "yours" if same hw+role as us; else the target's env name when we know it + // (named locally from its 4-byte target_id — no string travels on the wire); else other hw / '?'. + const char* fit; + const char* env = ota_target_env_name(h->target_id); + if (myt && h->target_id == myt) fit = "yours"; + else if (env) fit = env; + else fit = (h->target_id == 0) ? "?" : "other hw"; + n += snprintf(reply + n, CAP - n, "\n %d) %s %s [%s] %un %us%s", shown + 1, ver, + codec_kind(h->codec), fit, (unsigned)h->n_seeders, (unsigned)age, + on ? " [downloading]" : ""); + shown++; + } + if (more && n < CAP) snprintf(reply + n, CAP - n, "\n +%d more", more); + if (shown == 0) strcpy(reply, "No updates seen yet — re-run `ota ls` in a few seconds (just asked around)."); + + // ---- start fetching a specific catalogued mOTA (by list index or manifest_id) ---- + } else if (is_cmd(a, "pull|get|download", &rest)) { + const char* p = rest; + if (*p == 0) { strcpy(reply, "usage: ota get <#> (see the numbers in `ota ls`)"); 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 update (see the numbers in `ota ls`)"); 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 (is_cmd(a, "drop|cancel|stop", &rest)) { + 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 (is_cmd(a, "announce|adv", &rest)) { + 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 (is_cmd(a, "self|id", &rest)) { + 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); + int n = snprintf(reply, 160, "self body=%u image=%u base_hash=%s", (unsigned)fi.body_len, (unsigned)fi.image_len, hx); +#if defined(NRF52_PLATFORM) + // nRF52 applies via the bootloader, so surface whether THIS device's bootloader can (delta install gate) + const OtaBlCaps& bl = c.bootloaderCaps(); // cached (flash scanned once) + if (bl.present) snprintf(reply + n, 160 - n, " | bootloader: apply OK (abi=%u codecs=0x%x)", bl.apply_abi, bl.codec_mask); + else snprintf(reply + n, 160 - n, " | bootloader: NO mota-apply support (delta install will refuse)"); +#endif + + } else if (is_cmd(a, "install|apply|applydelta", &rest)) { + // 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 (is_cmd(a, "folder|fold", &rest)) { + const char* p = rest; + 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 (is_cmd(a, "config|cfg|set", &rest)) { + const char* p = rest; + 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): `ota key add|rm ` / `ota key` lists ---- + } else if (is_cmd(a, "key|keys", &rest)) { + const char* p = rest; + if (strncmp(p, "add ", 4) == 0) { + uint8_t pub[32]; + if (mesh::Utils::fromHex(pub, 32, p + 4) && c.allow.add(pub)) { c.config_dirty = true; strcpy(reply, "OK key added (saved)"); } + else strcpy(reply, "ERR key"); + } else if (strncmp(p, "rm ", 3) == 0 || strncmp(p, "remove ", 7) == 0) { + uint8_t pub[32]; const char* h = p + (p[0] == 'r' && p[1] == 'm' ? 3 : 7); + if (mesh::Utils::fromHex(pub, 32, h) && c.allow.remove(pub)) { c.config_dirty = true; strcpy(reply, "OK removed (saved)"); } + else strcpy(reply, "ERR"); + } else { // bare `ota key` (or `key list`) -> show them + int n = snprintf(reply, 160, "trusted signer 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 += snprintf(reply + n, 160 - n, " %s", hx); + } + if (c.allow.count() == 0) strcpy(reply, "no trusted signer keys yet (add one with `ota key add `)"); + } + + } else { + strcpy(reply, "Unknown OTA command. Type `ota help`."); + } + 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(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 dev recv "); return true; } + hex++; + int blen = (int)strlen(hex) / 2; + uint8_t tmp[80]; + if (blen <= 0 || blen > (int)sizeof(tmp) || !mesh::Utils::fromHex(tmp, blen, hex)) strcpy(reply, "ERR hex"); + 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(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(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(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 || !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()); + + } 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(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) { + 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); + 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 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 dev apply (slot|manifest|verify|commit)"); + } + + } 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, "ota dev: stage|recv|serve|announce|verify|want|apply slot|manifest|verify|commit|clear"); + } + return true; +} + +} // namespace ota +} // namespace mesh diff --git a/src/helpers/ota/OtaCli.h b/src/helpers/ota/OtaCli.h new file mode 100644 index 0000000000..987eebb516 --- /dev/null +++ b/src/helpers/ota/OtaCli.h @@ -0,0 +1,16 @@ +#pragma once + +#include + +// Text-CLI surface for OTA (P3/P5). Wired from CommonCLI (and reachable over LoRa remote-admin). +// Kept out of CommonCLI.cpp itself so the OTA state (allowlist, staging store) lives in the OTA module. + +namespace mesh { +namespace ota { + +// Handle an "ota ..." command. `command` is the full line (starts with "ota"). Fills `reply` +// (<= ~160 bytes, as per the CLI buffer). Returns true if it was an OTA command. +bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board); + +} // namespace ota +} // namespace mesh From 9193ef563747aee3fe5cc25f287e314e9afca831 Mon Sep 17 00:00:00 2001 From: Valentin Kivachuk Burda Date: Mon, 29 Jun 2026 13:03:06 +0200 Subject: [PATCH 07/59] ota: build/board enablement (platformio, build.sh, variants) + Python tooling (.mota reference, EndF/target/vector generators) (cherry picked from commit 4680d1052f885b78d2538cf3d4bd57a0ff8ce0b7) --- .gitignore | 2 + build.sh | 7 + platformio.ini | 40 ++ tools/mota/README.md | 53 ++ tools/mota/gen_targets.py | 91 ++++ tools/mota/gen_vectors.py | 188 +++++++ tools/mota/motalib.py | 491 ++++++++++++++++++ tools/mota/pio_endf.py | 111 ++++ tools/mota/test_mota.py | 260 ++++++++++ variants/gat562_30s_mesh_kit/platformio.ini | 8 +- variants/gat562_mesh_evb_pro/platformio.ini | 8 +- .../gat562_mesh_tracker_pro/platformio.ini | 8 +- variants/gat562_mesh_watch13/platformio.ini | 8 +- variants/heltec_t114/platformio.ini | 8 +- variants/heltec_v3/platformio.ini | 7 + variants/muziworks_r1_neo/platformio.ini | 8 +- variants/rak4631/platformio.ini | 8 + variants/rak_wismesh_tag/platformio.ini | 8 +- 18 files changed, 1286 insertions(+), 28 deletions(-) create mode 100644 tools/mota/README.md create mode 100644 tools/mota/gen_targets.py create mode 100644 tools/mota/gen_vectors.py create mode 100644 tools/mota/motalib.py create mode 100644 tools/mota/pio_endf.py create mode 100644 tools/mota/test_mota.py diff --git a/.gitignore b/.gitignore index a0ad5f6ea9..4b13566454 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,5 @@ compile_commands.json .venv/ venv/ platformio.local.ini + +__pycache__/ diff --git a/build.sh b/build.sh index 313c4c47a0..acda76c306 100755 --- a/build.sh +++ b/build.sh @@ -140,8 +140,15 @@ build_firmware() { # e.g: RAK_4631_Repeater-v1.0.0-SHA FIRMWARE_FILENAME="$1-${FIRMWARE_VERSION_STRING}" + # OTA target id = sha2-256:4(env_name) as a little-endian uint32 (matches tools/mota target_id_for_env + # and the device's MainBoard::getOtaTargetId()). Harmless when OTA is disabled. + MOTA_TARGET_ID=$(python3 -c "import hashlib,sys;print('0x%08x'%int.from_bytes(hashlib.sha256(sys.argv[1].encode()).digest()[:4],'little'))" "$1" 2>/dev/null || echo "") + # add firmware version info to end of existing platformio build flags in environment vars export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DFIRMWARE_BUILD_DATE='\"${FIRMWARE_BUILD_DATE}\"' -DFIRMWARE_VERSION='\"${FIRMWARE_VERSION_STRING}\"'" + if [ -n "$MOTA_TARGET_ID" ]; then + export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DMOTA_TARGET_ID=${MOTA_TARGET_ID}" + fi # disable debug flags if requested disable_debug_flags diff --git a/platformio.ini b/platformio.ini index e16f7b8304..5523ce7f3f 100644 --- a/platformio.ini +++ b/platformio.ini @@ -58,11 +58,24 @@ build_src_filter = 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 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} + + + + [esp32_ota] lib_deps = @@ -93,6 +106,27 @@ build_flags = ${arduino_base.build_flags} lib_deps = ${arduino_base.lib_deps} https://github.com/oltaco/CustomLFS#0.2.2 + +; Shared OTA recipe for board=rak4631-hardware nRF52 variants. nRF52840 has no A/B slot, so the update is +; applied in place by the custom OTAFIX bootloader (in-place detools); the app only stages + verifies + +; approves it. Reuses the RAK4631 flash layout (src/helpers/ota/OtaFlashLayout_nrf52.h; FS_START 0xD4000 +; is the safe staging ceiling for every RAK4631 role/ldscript). The device must run the matching OTAFIX +; bootloader for `applydelta` to actually apply. (detools.c is intentionally NOT built into the app — +; only the bootloader decodes.) The [rak4631] base defines this same recipe inline because it carries +; board-specific extras (fix_bsec_lib.py, BSEC lib) and a fixed post-script order; the simpler RAK4631 +; variants below just extend this section. +[rak4631_hw] +extends = nrf52_base +extra_scripts = ${nrf52_base.extra_scripts} + post:tools/mota/pio_endf.py +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} + ; ----------------- RP2040 --------------------- [rp2040_base] @@ -164,5 +198,11 @@ test_build_src = yes build_src_filter = -<*> +<../src/Utils.cpp> + +<../src/helpers/ota/MerkleTree.cpp> + +<../src/helpers/ota/MotaContainer.cpp> + +<../src/helpers/ota/FirmwareInfo.cpp> + +<../src/helpers/ota/OtaProtocol.cpp> + +<../src/helpers/ota/OtaManager.cpp> + +<../src/helpers/ota/detools/detools.c> lib_deps = google/googletest @ 1.17.0 diff --git a/tools/mota/README.md b/tools/mota/README.md new file mode 100644 index 0000000000..c3f8a77d30 --- /dev/null +++ b/tools/mota/README.md @@ -0,0 +1,53 @@ +# `tools/mota/` — OTA Python reference library & build/test tooling + +The Python side of MeshCore's `.mota` OTA system. It is the **reference implementation** of the wire +spec ([`docs/ota_protocol.md`](../../docs/ota_protocol.md)) and the **build + test infrastructure** — it is +no longer a user-facing CLI. + +> **Want to build / verify / inspect / serve `.mota` from the command line?** Use the self-contained C++ +> tool [`tools/motatool/`](../motatool/). It supersedes the old `mota.py` / `mota_seeder.py` (now removed), +> produces byte-identical containers, and runs on small hardware. The files here are the spec oracle and +> the firmware build/test glue. + +## Setup + +Uses the repo's Python venv (`meshcore/`). Dependencies: `detools` (delta), `cryptography` (Ed25519), +`intelhex` (nRF52 `.hex` handling). + +```bash +./meshcore/bin/pip install detools cryptography intelhex +``` + +## Files + +| File | What | +|---|---| +| `motalib.py` | Core logic: multihash, EndF, merkle tree+proofs, manifest/container build/parse/verify. The **reference implementation** of the spec and the unit-test oracle. Imported by everything below. | +| `pio_endf.py` | **PlatformIO post-build hook** (wired in `platformio.ini`) that injects the `EndF` self-identity trailer into the flashed firmware (`-D ENABLE_OTA`). | +| `gen_vectors.py` | Generates `test/test_ota/mota_vectors.h` — the cross-check vectors the native C++ tests run against. | +| `gen_targets.py` | Generates `src/helpers/ota/OtaTargets.h` — the `target_id → env-name` table (every `ENABLE_OTA` env, resolved from `pio project config`). Shared by the firmware and `motatool` so a node can name a target seen over the air without sending the string. Regenerate when the OTA env set changes. | +| `test_mota.py` | Unit tests for `motalib` (run directly or via pytest). | + +## Tests + +```bash +./meshcore/bin/python tools/mota/test_mota.py # EndF, merkle+proofs, full/delta, signing, + # tamper detection, approval enforcement +./meshcore/bin/python tools/mota/gen_vectors.py # regenerate the native-test cross-check vectors +./meshcore/bin/python tools/mota/gen_targets.py # regenerate src/helpers/ota/OtaTargets.h (needs `pio`) +``` + +## `EndF` build integration + +`EndF` must live in the **flashed** firmware (not just inside the `.mota`), because a node serves its own +firmware and matches a delta's `base_hash` against its own `EndF`. Wiring (handled by `pio_endf.py`): + +- **ESP32 / RP2040** (emit `firmware.bin`): `post:tools/mota/pio_endf.py` in the env's `extra_scripts` plus + `-D ENABLE_OTA=1`. The hook appends the 56-byte `EndF` (with `target_id`/`fw_version`/`hw_id`) to the app + `.bin` before merge. +- **nRF52 / STM32** (emit `.hex` → `.uf2`): the same hook rewrites the `.hex` with the trailer at the image + end. The byte logic is `motalib.ensure_endf`, used everywhere. + +`target_id` = `sha2-256:4(pio_env_name)`, `hw_id` = `-D MOTA_HW_ID`, `fw_version` = parsed from +`FIRMWARE_VERSION` — so a node (and `motatool`, reading the firmware's `EndF`) auto-discovers identity +without relying on filenames. diff --git a/tools/mota/gen_targets.py b/tools/mota/gen_targets.py new file mode 100644 index 0000000000..ff25928d9f --- /dev/null +++ b/tools/mota/gen_targets.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Generate src/helpers/ota/OtaTargets.h — the target_id -> env-name table for OTA-capable envs. + +A target_id is sha2-256:4(pio_env_name); it travels in beacons / the .mota manifest as 4 bytes. This +table lets a node (and motatool) print the human-readable env name for a target seen over the air, WITHOUT +ever transmitting the string. The set of envs is resolved from PlatformIO (every env whose build_flags +include ENABLE_OTA), so it stays in sync with what actually ships OTA. + +Run: ./meshcore/bin/python tools/mota/gen_targets.py # resolves via `pio project config` + ./meshcore/bin/python tools/mota/gen_targets.py cfg.json # or reuse a cached config dump (faster) +""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +import motalib as ml + +ROOT = Path(__file__).resolve().parents[2] +OUT = ROOT / "src" / "helpers" / "ota" / "OtaTargets.h" + + +def resolved_config(): + if len(sys.argv) > 1 and os.path.isfile(sys.argv[1]): + return json.load(open(sys.argv[1])) + pio = os.environ.get("PIO", "pio") + out = subprocess.run([pio, "project", "config", "--json-output"], + cwd=ROOT, capture_output=True, text=True, check=True).stdout + return json.loads(out) + + +def ota_envs(cfg): + envs = [] + for section, opts in cfg: + if not section.startswith("env:"): + continue + for k, v in opts: + if k == "build_flags": + flags = " ".join(v) if isinstance(v, list) else str(v) + if "ENABLE_OTA" in flags: + envs.append(section[4:]) + break + return sorted(set(envs)) + + +def main(): + envs = ota_envs(resolved_config()) + rows = [(ml.target_id_for_env(e), e) for e in envs] + + seen = {} + for tid, e in rows: # sha2-256:4 over ~300 names — verify no collisions + if tid in seen and seen[tid] != e: + print(f"WARNING: target_id collision 0x{tid:08x}: {seen[tid]!r} vs {e!r}", file=sys.stderr) + seen[tid] = e + rows.sort(key=lambda r: r[1].lower()) # by env name (readable, deterministic) + + out = [ + "#pragma once", + "#include ", + "", + "// AUTO-GENERATED by tools/mota/gen_targets.py — do not edit by hand.", + f"// {len(rows)} OTA-capable PlatformIO envs. Maps target_id (= sha2-256:4 of the env name, LE uint32)", + "// to the human-readable env name, so a node/tool can name a target seen over the air WITHOUT", + "// transmitting the string in the .mota / LoRa protocol. Regenerate when the OTA env set changes.", + "", + "namespace mesh { namespace ota {", + "", + "// target_id -> env name, or nullptr if unknown. Linear scan (table is small; lookups are rare).", + "inline const char* ota_target_env_name(uint32_t target_id) {", + " static const struct { uint32_t id; const char* env; } T[] = {", + ] + out += [f' {{ 0x{tid:08x}, "{e}" }},' for tid, e in rows] + out += [ + " };", + " for (unsigned i = 0; i < sizeof(T) / sizeof(T[0]); i++)", + " if (T[i].id == target_id) return T[i].env;", + " return nullptr;", + "}", + "", + "} } // namespace mesh::ota", + ] + OUT.parent.mkdir(parents=True, exist_ok=True) + OUT.write_text("\n".join(out) + "\n") + print(f"wrote {OUT} ({len(rows)} OTA envs)") + + +if __name__ == "__main__": + main() diff --git a/tools/mota/gen_vectors.py b/tools/mota/gen_vectors.py new file mode 100644 index 0000000000..35b1a70265 --- /dev/null +++ b/tools/mota/gen_vectors.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +""" +Generate a C++ cross-check header from the reference implementation (motalib). + +Emits test/test_ota/mota_vectors.h containing a real .mota built by the Python tool plus the +expected parse results and a merkle proof, so the device-side C++ core (src/helpers/ota/) is +verified to agree byte-for-byte with the host packager. + +Run: ./meshcore/bin/python tools/mota/gen_vectors.py +""" +from __future__ import annotations + +import random +from pathlib import Path + +import motalib as ml + +OUT = Path(__file__).resolve().parents[2] / "test" / "test_ota" / "mota_vectors.h" + + +def _carr(name, data: bytes) -> str: + body = ",".join(str(b) for b in data) + return f"static const uint8_t {name}[{len(data)}] = {{{body}}};\n" + + +def build_full(): + random.seed(42) + fw = bytes(random.getrandbits(8) for _ in range(5 * 1024 + 137)) # 6 blocks, last short + image, _ = ml.ensure_endf(fw) + 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, hw_id="TESTHW") + return ml.build_container(m, image), m, image + + +def emit_proof_case(idx, count) -> str: + """A tree of `count` arbitrary 4-byte leaves + every block's proof, computed by the reference.""" + random.seed(1000 + count) + leaves = [bytes(random.getrandbits(8) for _ in range(4)) for _ in range(count)] + root = ml.merkle_root(leaves) + blob = b"" + offs, nsibs = [], [] + for i in range(count): + sib = ml.proof_siblings(leaves, i) + assert ml.verify_proof(leaves[i], i, ml.merkle_proof(leaves, i), root, count), (count, i) + offs.append(len(blob)) + nsibs.append(len(sib) // 4) + blob += sib + p = f"T{idx}" + out = [ + f"static const uint32_t {p}_COUNT = {count}u;", + _carr(f"{p}_LEAVES", b"".join(leaves)), + _carr(f"{p}_ROOT", root), + f"static const uint16_t {p}_POFF[{count}] = {{{','.join(str(o) for o in offs)}}};", + f"static const uint8_t {p}_PNSIB[{count}] = {{{','.join(str(n) for n in nsibs)}}};", + _carr(f"{p}_PBLOB", blob), + ] + return "\n".join(out) + + +def main(): + blob, m, image = build_full() + leaves = ml.leaf_hashes(image, 1024) + assert ml.merkle_root(leaves) == m.merkle_root + + proof_idx = 2 if m.block_count > 2 else 0 + siblings = ml.proof_siblings(leaves, proof_idx) + assert ml.verify_proof(leaves[proof_idx], proof_idx, ml.merkle_proof(leaves, proof_idx), + m.merkle_root, len(leaves)) + + lines = [ + "// AUTO-GENERATED by tools/mota/gen_vectors.py — do not edit by hand.", + "// Cross-check vectors: a real .mota from the reference packager (motalib.py).", + "#pragma once", + "#include ", + "", + _carr("MOTA_VEC", blob), + f"static const uint32_t MOTA_VEC_LEN = {len(blob)};", + f"static const uint32_t EXP_TARGET_ID = 0x{m.target_id:08x}u;", + f"static const uint32_t EXP_FW_VERSION = 0x{m.fw_version:08x}u;", + f"static const uint32_t EXP_IMAGE_SIZE = {m.image_size}u;", + f"static const uint32_t EXP_PAYLOAD_SIZE = {m.payload_size}u;", + f"static const uint32_t EXP_BLOCK_COUNT = {m.block_count}u;", + f"static const uint8_t EXP_BLOCK_SIZE_LOG2 = {m.block_size_log2};", + 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), + ] + + # exhaustive per-index proof cases for several tricky counts (deep promotion chains) + counts = [5, 7, 8, 65, 100, 255, 256] + for i, c in enumerate(counts): + lines.append(emit_proof_case(i, c)) + lines.append("struct ProofCase { uint32_t count; const uint8_t* leaves; const uint8_t* root;" + " const uint16_t* poff; const uint8_t* pnsib; const uint8_t* pblob; };") + rows = ",".join(f"{{T{i}_COUNT,T{i}_LEAVES,T{i}_ROOT,T{i}_POFF,T{i}_PNSIB,T{i}_PBLOB}}" + for i in range(len(counts))) + lines.append(f"static const ProofCase PROOF_CASES[] = {{{rows}}};") + lines.append(f"static const int N_PROOF_CASES = {len(counts)};") + + # small-block signed .mota for the host transfer simulation (each block fits one packet => no + # fragmentation needed to validate the manager/protocol end-to-end). Deterministic signing key. + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + random.seed(99) + sim_fw = bytes(random.getrandbits(8) for _ in range(2000)) + sim_image, _ = ml.ensure_endf(sim_fw) + sim_priv = Ed25519PrivateKey.from_private_bytes(bytes(range(32))) + sm = ml.build_manifest(target_id=0xCAFEBABE, fw_version=ml.pack_version("3.0.0"), + image_size=len(sim_image), payload=sim_image, block_size=128, + image_hash=ml.mh32(sim_image), codec_id=ml.CODEC_FULL, + is_full=True, sign_priv=sim_priv) + sim_blob = ml.build_container(sm, sim_image) + lines.append("// small-block signed .mota for the OtaManager host transfer simulation") + lines.append(_carr("SIM_MOTA", sim_blob)) + 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 + # on-device delta apply path uses detools, not a reimplementation. + import io + import detools + random.seed(2024) + dt_base_body = bytes(random.getrandbits(8) for _ in range(3000)) + dt_tgt = bytearray(dt_base_body) + for off in (137, 138, 139, 1500, 2999): # localized edits, version-bump style + dt_tgt[off] ^= 0x5A + dt_tgt += bytes(random.getrandbits(8) for _ in range(200)) # small appended tail + dt_base_img, _ = ml.ensure_endf(dt_base_body) + dt_tgt_img, _ = ml.ensure_endf(bytes(dt_tgt)) + fp = io.BytesIO() + detools.create_patch(io.BytesIO(dt_base_img), io.BytesIO(dt_tgt_img), fp, + patch_type="sequential", compression="crle") + dt_patch = fp.getvalue() + lines.append("// detools sequential+crle delta: apply DT_PATCH to DT_BASE -> DT_TARGET") + lines.append(_carr("DT_BASE", dt_base_img)) + lines.append(f"static const uint32_t DT_BASE_LEN = {len(dt_base_img)};") + lines.append(_carr("DT_PATCH", dt_patch)) + lines.append(f"static const uint32_t DT_PATCH_LEN = {len(dt_patch)};") + lines.append(_carr("DT_TARGET", dt_tgt_img)) + lines.append(f"static const uint32_t DT_TARGET_LEN = {len(dt_tgt_img)};") + + # detools IN-PLACE+crle delta (nRF52 single-slot apply): apply DT_IP_PATCH to a memory region + # holding DT_IP_BASE -> region[0:to_size] == DT_IP_TARGET. The native test runs the vendored + # in-place decoder over a DT_IP_MEM-byte RAM buffer, exactly as the bootloader will over flash. + DT_IP_MEM, DT_IP_SEG = 0x8000, 0x1000 + ip_patch_io = io.BytesIO() + detools.create_patch(io.BytesIO(dt_base_img), io.BytesIO(dt_tgt_img), ip_patch_io, + patch_type="in-place", memory_size=DT_IP_MEM, segment_size=DT_IP_SEG, + compression="crle") + dt_ip_patch = ip_patch_io.getvalue() + lines.append("// detools in-place+crle delta: apply DT_IP_PATCH over a DT_IP_MEM buffer holding DT_IP_BASE") + lines.append(_carr("DT_IP_BASE", dt_base_img)) + lines.append(f"static const uint32_t DT_IP_BASE_LEN = {len(dt_base_img)};") + lines.append(_carr("DT_IP_PATCH", dt_ip_patch)) + lines.append(f"static const uint32_t DT_IP_PATCH_LEN = {len(dt_ip_patch)};") + lines.append(_carr("DT_IP_TARGET", dt_tgt_img)) + lines.append(f"static const uint32_t DT_IP_TARGET_LEN = {len(dt_tgt_img)};") + lines.append(f"static const uint32_t DT_IP_MEM = {DT_IP_MEM}u;") + + OUT.parent.mkdir(parents=True, exist_ok=True) + OUT.write_text("\n".join(lines) + "\n") + print(f"wrote {OUT}") + print(f" blob={len(blob)}B blocks={m.block_count} proof_idx={proof_idx} nsib={len(siblings)//4}") + print(f" proof cases: counts={counts}") + print(f" detools delta: base={len(dt_base_img)}B target={len(dt_tgt_img)}B seq_patch={len(dt_patch)}B inplace_patch={len(dt_ip_patch)}B") + + +if __name__ == "__main__": + main() diff --git a/tools/mota/motalib.py b/tools/mota/motalib.py new file mode 100644 index 0000000000..626fcc8758 --- /dev/null +++ b/tools/mota/motalib.py @@ -0,0 +1,491 @@ +""" +motalib — build/parse/verify MeshCore ``.mota`` firmware-update containers. + +Pure logic, no CLI. Implements docs/ota_protocol.md (format_ver=2, fixed layout). + +The wire format (all integers little-endian): + + container = MAGIC(4) | MOTA_TOTAL_SIZE(4) | MANIFEST | PAYLOAD | TRAILER(5) + + manifest = format_ver(1) flags(1) hash_algo(1) target_id(4) fw_version(4) + image_size(4) payload_size(4) block_size_log2(1) merkle_root(4) + image_hash(32) codec_id(1) hw_id(32) + base_hash(8) signer_pubkey(32) signature(64) approval(4) + leaves[](4*BC) + +Fixed layout: every field is always present at a constant offset (base_hash/signer_pubkey/signature are +zero-filled for a full / unsigned container), so manifest-minus-leaves is always 197 bytes and `leaves[]` +is the only variable-length field. Hashes are SHA-256, truncated per multihash (sha2-256:N = first N bytes). +""" + +from __future__ import annotations + +import hashlib +import io +import struct +from dataclasses import dataclass, field +from typing import List, Optional, Tuple + +# --------------------------------------------------------------------------- +# Reference constants (must match docs/ota_protocol.md and the device code) +# --------------------------------------------------------------------------- + +MAGIC = b"mOTA" # 6D 4F 54 41 +TRAILER = b"vk496" # 76 6B 34 39 36 +ENDF_MAGIC = b"EndF" # 45 6E 64 46 +# Fixed-length trailer: marker(4) + body_len(4) + body_hash8(8) + fw_version(4) + target_id(4) + hw_id(32). +ENDF_LEN = 56 + +FORMAT_VER = 2 # the one manifest format (fixed layout, see Manifest); other values are rejected +HASH_ALGO_SHA256 = 0x12 # multihash code for sha2-256 + +FLAG_FULL = 0x01 +FLAG_SIGNED = 0x02 + +CODEC_FULL = 0 +CODEC_DETOOLS_SEQUENTIAL = 1 # detools `sequential` patch (decoded on-device by vendored detools C) +CODEC_DETOOLS_INPLACE = 2 # detools `in-place` patch (nRF52 bootloader-handoff path; TBD) +CODEC_NAMES = {CODEC_FULL: "full", CODEC_DETOOLS_SEQUENTIAL: "detools-sequential", + CODEC_DETOOLS_INPLACE: "detools-in-place"} + +APPROVAL_NOT = b"\xff\xff\xff\xff" # erased = not approved +APPROVAL_YES = b"APRV" # 41 50 52 56 = approved + +DEFAULT_BLOCK_SIZE = 1024 + + +# --------------------------------------------------------------------------- +# Multihash helpers (sha2-256 truncations) +# --------------------------------------------------------------------------- + +def sha256(data: bytes) -> bytes: + return hashlib.sha256(data).digest() + + +def mh4(data: bytes) -> bytes: + return hashlib.sha256(data).digest()[:4] + + +def mh8(data: bytes) -> bytes: + return hashlib.sha256(data).digest()[:8] + + +def mh32(data: bytes) -> bytes: + return hashlib.sha256(data).digest() + + +# --------------------------------------------------------------------------- +# fw_version packing +# --------------------------------------------------------------------------- + +def pack_version(s) -> int: + """'1.16.0' or '1.16.0.2' -> uint32 (MAJOR<<24|MINOR<<16|PATCH<<8|pre). Ints pass through.""" + if isinstance(s, int): + return s & 0xFFFFFFFF + s = s.strip().lstrip("vV") + parts = [int(p) for p in s.split(".")] + parts += [0] * (4 - len(parts)) + maj, mnr, pat, pre = parts[:4] + return ((maj & 0xFF) << 24) | ((mnr & 0xFF) << 16) | ((pat & 0xFF) << 8) | (pre & 0xFF) + + +def unpack_version(v: int) -> str: + return f"{(v >> 24) & 0xFF}.{(v >> 16) & 0xFF}.{(v >> 8) & 0xFF}.{v & 0xFF}" + + +# --------------------------------------------------------------------------- +# target_id +# --------------------------------------------------------------------------- + +def target_id_for_env(env_name: str) -> int: + """4-byte build-target id = sha2-256:4(env_name), little-endian uint32. + + The PlatformIO env name (e.g. 'RAK_4631_companion_radio_usb') uniquely captures hardware AND + role/partition layout. build.sh injects the same value as -D MOTA_TARGET_ID so the device's + getOtaTargetId() matches what the packager stamps into the manifest. (Must match build.sh.) + """ + d = hashlib.sha256(env_name.encode()).digest()[:4] + return int.from_bytes(d, "little") + + +# --------------------------------------------------------------------------- +# EndF trailer +# --------------------------------------------------------------------------- + +@dataclass +class FwIdent: + """Self-describing firmware identity carried in the EndF trailer (docs/ota_protocol.md §2) so a node / + the packaging tool reads it straight from the firmware instead of relying on build flags or filenames.""" + fw_version: int = 0 # packed MAJOR<<24 | MINOR<<16 | PATCH<<8 | pre + target_id: int = 0 # sha2-256:4(pio_env) as uint32 LE — hw + role + partition (fetch routing) + hw_id: str = "" # readable hardware tag (brick-safety), e.g. "RAK4631" + + +def build_endf(body: bytes, ident: Optional["FwIdent"] = None) -> bytes: + """The fixed 56-byte EndF trailer for a firmware BODY (identity zero-filled if not given).""" + ident = ident or FwIdent() + hw = ident.hw_id.encode("ascii", "replace")[:32].ljust(32, b"\0") + return (ENDF_MAGIC + struct.pack(" bool: + """True iff `image` ends with a self-consistent (fixed 56-byte) EndF trailer (image == BODY || EndF).""" + if len(image) < ENDF_LEN: + return False + t = image[-ENDF_LEN:] + return (t[:4] == ENDF_MAGIC and struct.unpack(" Tuple[bytes, bytes]: + """Return (body, body_hash8) for an image that ends with a valid EndF. Raises otherwise.""" + if not has_endf(image): + raise ValueError("image has no valid EndF trailer") + return image[:-ENDF_LEN], image[-ENDF_LEN + 8:-ENDF_LEN + 16] + + +def parse_endf_ident(image: bytes) -> Optional["FwIdent"]: + """The self-describing identity from a valid EndF trailer, or None if there is no valid trailer.""" + if not has_endf(image): + return None + t = image[-ENDF_LEN:] + fw, tgt = struct.unpack(" Tuple[bytes, bytes]: + """Return (image_with_endf, body_hash8). Appends EndF (with `ident` if given) if not already present.""" + if has_endf(image): + _, h8 = parse_endf(image) + return image, h8 + body_hash8 = mh8(image) + return image + build_endf(image, ident), body_hash8 + + +# --------------------------------------------------------------------------- +# Merkle tree (sha2-256:4 leaves/nodes, promote-odd, no padding) +# --------------------------------------------------------------------------- + +def block_count(payload_size: int, block_size: int) -> int: + return (payload_size + block_size - 1) // block_size + + +def leaf_hashes(payload: bytes, block_size: int) -> List[bytes]: + return [mh4(payload[i:i + block_size]) for i in range(0, len(payload), block_size)] + + +def merkle_root(leaves: List[bytes]) -> bytes: + if not leaves: + raise ValueError("empty payload / no leaves") + level = list(leaves) + while len(level) > 1: + nxt = [] + n = len(level) + for i in range(0, n, 2): + if i + 1 < n: + nxt.append(mh4(level[i] + level[i + 1])) + else: + nxt.append(level[i]) # promote lone last node unchanged + level = nxt + return level[0] + + +def merkle_proof(leaves: List[bytes], index: int) -> List[Tuple[bytes, bool]]: + """Proof for block `index`: list of (sibling_digest, sibling_is_left).""" + proof: List[Tuple[bytes, bool]] = [] + level = list(leaves) + idx = index + while len(level) > 1: + n = len(level) + is_last_odd = (n % 2 == 1) and (idx == n - 1) + if not is_last_odd: + if idx % 2 == 0: + proof.append((level[idx + 1], False)) # sibling on the right + else: + proof.append((level[idx - 1], True)) # sibling on the left + nxt = [mh4(level[i] + level[i + 1]) if i + 1 < n else level[i] + for i in range(0, n, 2)] + idx //= 2 + level = nxt + return proof + + +def proof_siblings(leaves: List[bytes], index: int) -> bytes: + """Wire form of a proof: just the ordered sibling digests, concatenated. + + The left/right direction is derived by the verifier from the block index + count + (sibling is on the left iff the current index is odd), so no direction bits are sent. + """ + return b"".join(sib for sib, _ in merkle_proof(leaves, index)) + + +def verify_proof(leaf: bytes, index: int, proof: List[Tuple[bytes, bool]], + root: bytes, count: int) -> bool: + h = leaf + idx = index + n = count + p = 0 + while n > 1: + is_last_odd = (n % 2 == 1) and (idx == n - 1) + if is_last_odd: + pass # promoted, no proof element + else: + if p >= len(proof): + return False + sib, is_left = proof[p] + p += 1 + h = mh4(sib + h) if is_left else mh4(h + sib) + idx //= 2 + n = (n + 1) // 2 + return h == root and p == len(proof) + + +# --------------------------------------------------------------------------- +# Manifest + container +# --------------------------------------------------------------------------- + +@dataclass +class Manifest: + format_ver: int = FORMAT_VER + flags: int = 0 + hash_algo: int = HASH_ALGO_SHA256 + target_id: int = 0 + fw_version: int = 0 + image_size: int = 0 + payload_size: int = 0 + block_size_log2: int = 10 + merkle_root: bytes = b"\0\0\0\0" + image_hash: bytes = b"\0" * 32 + codec_id: int = CODEC_FULL + hw_id: bytes = b"\0" * 32 # 32-byte NUL-padded ASCII hardware tag (signed) + # Fixed-layout: these are ALWAYS present (zero-filled when not applicable), so the manifest has a + # constant size and a trivial offset-based parser; only leaves[] is variable. + base_hash: bytes = b"\0" * 8 # 8 bytes; zero for a full image (meaningful iff !FULL) + signer_pubkey: bytes = b"\0" * 32 # 32 bytes; zero when unsigned (meaningful iff SIGNED) + signature: bytes = b"\0" * 64 # 64 bytes; zero when unsigned (meaningful iff SIGNED) + approval: bytes = APPROVAL_NOT + leaves: List[bytes] = field(default_factory=list) + + @property + def is_full(self) -> bool: + return bool(self.flags & FLAG_FULL) + + @property + def is_signed(self) -> bool: + return bool(self.flags & FLAG_SIGNED) + + @property + def block_size(self) -> int: + return 1 << self.block_size_log2 + + @property + def block_count(self) -> int: + return block_count(self.payload_size, self.block_size) + + def signed_region(self) -> bytes: + """Bytes the Ed25519 signature covers: format_ver .. signer_pubkey (fixed 129 bytes).""" + out = bytearray() + out += bytes([self.format_ver, self.flags, self.hash_algo]) + out += struct.pack(" bytes: + """Fixed layout: signed_region(129) + signature(64) + approval(4) + leaves[4*BC].""" + out = bytearray(self.signed_region()) + out += self.signature # always present (zero when unsigned) + out += self.approval + for lf in self.leaves: + out += lf + return bytes(out) + + +def hw_id_bytes(s) -> 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 + assert len(m.base_hash) == 8 # fixed layout: always present (zero for full) + assert len(m.signer_pubkey) == 32 + assert len(m.signature) == 64 + + +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, 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( + flags=(FLAG_FULL if is_full else 0) | (FLAG_SIGNED if sign_priv is not None else 0), + target_id=target_id, + fw_version=fw_version, + image_size=image_size, + payload_size=len(payload), + block_size_log2=block_size.bit_length() - 1, + merkle_root=merkle_root(leaves), + image_hash=image_hash, + codec_id=codec_id, + hw_id=hw_id_bytes(hw_id), + base_hash=(b"\0" * 8 if is_full else base_hash), # always 8 bytes; zero for a full image + leaves=leaves, + ) + if not is_full and (base_hash is None or len(base_hash) != 8): + raise ValueError("delta requires an 8-byte base_hash") + if sign_priv is not None: + m.signer_pubkey = sign_priv.public_key().public_bytes_raw() + m.signature = sign_priv.sign(m.signed_region()) # else signer_pubkey/signature stay zero + _validate_lengths(m) + return m + + +def build_container(manifest: Manifest, payload: bytes) -> bytes: + assert len(payload) == manifest.payload_size + mser = manifest.serialize() + total = 4 + 4 + len(mser) + len(payload) + len(TRAILER) + return MAGIC + struct.pack(" Parsed: + if blob[:4] != MAGIC: + raise ValueError("bad MAGIC") + if blob[-5:] != TRAILER: + raise ValueError("bad TRAILER") + total = struct.unpack(" List[str]: + """Return a list of problem strings (empty == fully valid for what could be checked).""" + problems: List[str] = [] + m, payload = parsed.manifest, parsed.payload + + # block_count / leaves + if len(m.leaves) != m.block_count: + problems.append(f"leaves count {len(m.leaves)} != block_count {m.block_count}") + + # merkle root must match recomputation from the actual payload blocks + recomputed_leaves = leaf_hashes(payload, m.block_size) + if recomputed_leaves != m.leaves: + problems.append("stored leaves[] do not match payload blocks") + try: + if merkle_root(recomputed_leaves) != m.merkle_root: + problems.append("merkle_root does not match payload") + except ValueError as e: + problems.append(f"merkle: {e}") + + # spot-check a proof round-trips (block 0 and last) + if recomputed_leaves: + for idx in {0, len(recomputed_leaves) - 1}: + pr = merkle_proof(recomputed_leaves, idx) + if not verify_proof(recomputed_leaves[idx], idx, pr, m.merkle_root, len(recomputed_leaves)): + problems.append(f"merkle proof failed for block {idx}") + + # approval must be 'not approved' in a distributed container + if m.approval != APPROVAL_NOT: + problems.append(f"approval is not the erased sentinel (got {m.approval.hex()})") + + # signature + if m.is_signed: + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + from cryptography.exceptions import InvalidSignature + pub = Ed25519PublicKey.from_public_bytes(m.signer_pubkey) + try: + pub.verify(m.signature, m.signed_region()) + except InvalidSignature: + problems.append("Ed25519 signature INVALID") + if expect_pub is not None and m.signer_pubkey != expect_pub: + problems.append("signer_pubkey != expected key") + + # image_hash: directly checkable only for full images (payload IS the image) + if m.is_full: + if mh32(payload) != m.image_hash: + problems.append("image_hash does not match full payload") + elif base_image is not None: + # delta: optionally apply against a provided base to confirm image_hash + try: + import detools + out = io.BytesIO() + detools.apply_patch(io.BytesIO(_ensure_base(base_image)), io.BytesIO(payload), out) + rebuilt = out.getvalue() + if mh32(rebuilt) != m.image_hash: + problems.append("delta applied to base does not match image_hash") + if len(rebuilt) != m.image_size: + problems.append("delta result size != image_size") + except Exception as e: # noqa: BLE001 + problems.append(f"delta apply check failed: {e}") + return problems + + +def _ensure_base(base_image: bytes) -> bytes: + img, _ = ensure_endf(base_image) + return img diff --git a/tools/mota/pio_endf.py b/tools/mota/pio_endf.py new file mode 100644 index 0000000000..61ad0e425a --- /dev/null +++ b/tools/mota/pio_endf.py @@ -0,0 +1,111 @@ +""" +PlatformIO post-build extra-script: append the MeshCore ``EndF`` trailer to the +firmware image so a running node can self-locate its size/identity (docs/ota_protocol.md §2). + +Wire it (ONLY for OTA-enabled builds) from a variant/env, e.g.: + + extra_scripts = + ${nrf52_base.extra_scripts} + post:tools/mota/pio_endf.py + +and define ``-D ENABLE_OTA=1``. With ENABLE_OTA unset this script is a no-op, so it is safe to +leave wired everywhere. + +The byte logic is the same `motalib.ensure_endf` exercised by `endf.py` and the unit tests. + +ESP32 / RP2040 emit ${PROGNAME}.bin (the raw app image) -> EndF appended to the .bin. +nRF52 emits ${PROGNAME}.hex (the app, for DFU/UF2) -> EndF appended into the .hex right after the +app's last byte (so the downstream .uf2 / DFU .zip carry it). Both feed the same on-device EndF scan. +""" + +Import("env") # noqa: F821 (injected by PlatformIO/SCons) + +import os +import sys + +sys.path.insert(0, os.path.join(env["PROJECT_DIR"], "tools", "mota")) # noqa: F821 +import motalib as ml + + +def _ota_enabled() -> bool: + for d in env.get("CPPDEFINES", []): # noqa: F821 + name = d[0] if isinstance(d, (list, tuple)) else d + if name == "ENABLE_OTA": + return True + return False + + +def _is_nrf52() -> bool: + for d in env.get("CPPDEFINES", []): # noqa: F821 + name = d[0] if isinstance(d, (list, tuple)) else d + if name == "NRF52_PLATFORM": + return True + return False + + +def _cppdef(name): # value of a -D= build flag, or None + for d in env.get("CPPDEFINES", []): # noqa: F821 + if isinstance(d, (list, tuple)) and len(d) > 1 and d[0] == name: + return str(d[1]) + if d == name: + return "" + return None + + +def _firmware_ident(): + """Self-describing identity to embed in EndF (docs/ota_protocol.md §2): target_id is computed from the + PlatformIO env name (so it's correct even without build.sh's -D MOTA_TARGET_ID), hw_id from MOTA_HW_ID, + fw_version parsed from FIRMWARE_VERSION.""" + import re + target_id = ml.target_id_for_env(env["PIOENV"]) # noqa: F821 + hw_id = (_cppdef("MOTA_HW_ID") or "").replace("\\", "").strip().strip('"').strip("'") + ver_s = (_cppdef("FIRMWARE_VERSION") or "").replace("\\", "").strip().strip('"').strip("'") + m = re.search(r"(\d+)\.(\d+)(?:\.(\d+))?", ver_s) + fw_version = ml.pack_version(f"{m.group(1)}.{m.group(2)}.{m.group(3) or 0}") if m else 0 + return ml.FwIdent(fw_version=fw_version, target_id=target_id, hw_id=hw_id) + + +def _append_endf(source, target, env): # raw .bin path (ESP32 / RP2040) + path = str(target[0]) + with open(path, "rb") as f: + data = f.read() + ident = _firmware_ident() + out, h8 = ml.ensure_endf(data, ident) + if len(out) != len(data): + with open(path, "wb") as f: + f.write(out) + print(f"EndF: appended to {os.path.basename(path)} (body_len={len(data)} body_hash={h8.hex()} " + f"target={ident.target_id:#010x} hw='{ident.hw_id}' fw={ident.fw_version:#010x})") + else: + print(f"EndF: already present in {os.path.basename(path)} (no change)") + + +def _append_endf_hex(source, target, env): # Intel-HEX path (nRF52: app for DFU/UF2) + from intelhex import IntelHex + path = str(target[0]) + ih = IntelHex(path) + segs = ih.segments() + if not segs: + print("EndF: empty .hex, skipping"); return + app_start, app_end = segs[0] # first (lowest) segment = the application image + body = bytes(ih.tobinarray(start=app_start, size=app_end - app_start)) + ident = _firmware_ident() + out, h8 = ml.ensure_endf(body, ident) + if len(out) == len(body): + print(f"EndF: already present in {os.path.basename(path)} (no change)"); return + trailer = out[len(body):] # the EndF trailer (60 bytes with identity) + for i, b in enumerate(trailer): + ih[app_end + i] = b # write it right after the app's last byte + ih.write_hex_file(path) + print(f"EndF: appended to {os.path.basename(path)} at 0x{app_end:X} " + f"(app=0x{app_start:X}.. body_len={len(body)} body_hash={h8.hex()} " + f"target={ident.target_id:#010x} hw='{ident.hw_id}' fw={ident.fw_version:#010x})") + + +if _ota_enabled(): + if _is_nrf52(): + env.AddPostAction("$BUILD_DIR/${PROGNAME}.hex", _append_endf_hex) # noqa: F821 + else: + env.AddPostAction("$BUILD_DIR/${PROGNAME}.bin", _append_endf) # noqa: F821 +else: + print("EndF: ENABLE_OTA not defined; skipping trailer injection") diff --git a/tools/mota/test_mota.py b/tools/mota/test_mota.py new file mode 100644 index 0000000000..cd9a522e9e --- /dev/null +++ b/tools/mota/test_mota.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +""" +Tests for motalib — run with the meshcore venv: + + ./meshcore/bin/python tools/mota/test_mota.py + +(Also pytest-compatible: functions are named test_*.) +""" + +from __future__ import annotations + +import io +import os +import random +import struct + +import motalib as ml +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + + +def _fw(seed, size): + random.seed(seed) + return bytes(random.getrandbits(8) for _ in range(size)) + + +# --- multihash / version --------------------------------------------------- + +def test_version_pack_roundtrip(): + assert ml.pack_version("1.16.0") == (1 << 24) | (16 << 16) + assert ml.unpack_version(ml.pack_version("1.16.0.2")) == "1.16.0.2" + assert ml.pack_version(0x01100000) == 0x01100000 + + +def test_target_id_for_env(): + import hashlib + env = "RAK_4631_companion_radio_usb" + expect = int.from_bytes(hashlib.sha256(env.encode()).digest()[:4], "little") + assert ml.target_id_for_env(env) == expect + # distinct envs (same board, different role) get distinct ids + assert ml.target_id_for_env("RAK_4631_repeater") != ml.target_id_for_env("RAK_4631_companion_radio_usb") + + +# --- EndF ------------------------------------------------------------------ + +def test_endf_roundtrip_and_idempotent(): + body = _fw(1, 5000) + img, h8 = ml.ensure_endf(body) + assert len(img) == 5000 + ml.ENDF_LEN + assert ml.has_endf(img) + pbody, ph8 = ml.parse_endf(img) + assert pbody == body and ph8 == h8 == ml.mh8(body) + # idempotent: feeding an already-EndF'd image returns it unchanged + img2, h82 = ml.ensure_endf(img) + assert img2 == img and h82 == h8 + + +def test_endf_rejects_garbage_tail(): + assert not ml.has_endf(b"too short") + body = _fw(2, 1000) + img = body + ml.ENDF_MAGIC + struct.pack(" zero-filled (still fixed size, still self-consistent) + z, _ = ml.ensure_endf(body) + assert len(z) == len(body) + ml.ENDF_LEN and ml.parse_endf_ident(z) == ml.FwIdent(0, 0, "") + + +# --- merkle ---------------------------------------------------------------- + +def test_merkle_single_block(): + leaves = [ml.mh4(b"x")] + assert ml.merkle_root(leaves) == leaves[0] + + +def test_merkle_proofs_all_indices_various_counts(): + for count in [1, 2, 3, 4, 5, 7, 8, 9, 16, 17, 100]: + payload = _fw(count, count * 1024 - 13) # last block short, no padding + leaves = ml.leaf_hashes(payload, 1024) + assert len(leaves) == count + root = ml.merkle_root(leaves) + for i in range(count): + proof = ml.merkle_proof(leaves, i) + assert ml.verify_proof(leaves[i], i, proof, root, count), (count, i) + # a tampered leaf must fail its proof + bad = bytes([leaves[0][0] ^ 0xFF]) + leaves[0][1:] + assert not ml.verify_proof(bad, 0, ml.merkle_proof(leaves, 0), root, count) + + +# --- full container -------------------------------------------------------- + +def test_full_build_parse_verify(): + fw = _fw(10, 33 * 1024 + 7) + image, _ = ml.ensure_endf(fw) + m = ml.build_manifest( + target_id=0xDEADBEEF, 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) + blob = ml.build_container(m, image) + + parsed = ml.parse_container(blob) + assert parsed.manifest.target_id == 0xDEADBEEF + assert parsed.manifest.is_full and not parsed.manifest.is_signed + assert parsed.manifest.image_hash == ml.mh32(image) + assert parsed.payload == image + 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) + m = ml.build_manifest(target_id=1, fw_version=1, image_size=len(image), payload=image, + block_size=1024, image_hash=ml.mh32(image), + codec_id=ml.CODEC_FULL, is_full=True) + blob = bytearray(ml.build_container(m, image)) + # flip a byte inside the payload region + payload_off = blob.index(image) + blob[payload_off + 50] ^= 0xFF + problems = ml.verify(ml.parse_container(bytes(blob))) + assert any("leaves" in p or "merkle" in p or "image_hash" in p for p in problems), problems + + +# --- signing --------------------------------------------------------------- + +def test_signed_build_and_verify(): + priv = Ed25519PrivateKey.generate() + fw = _fw(12, 20 * 1024) + image, _ = ml.ensure_endf(fw) + m = ml.build_manifest(target_id=7, 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) + parsed = ml.parse_container(ml.build_container(m, image)) + assert parsed.manifest.is_signed + assert ml.verify(parsed, expect_pub=priv.public_key().public_bytes_raw()) == [] + # wrong expected key -> flagged + other = Ed25519PrivateKey.generate().public_key().public_bytes_raw() + assert any("signer_pubkey" in p for p in ml.verify(parsed, expect_pub=other)) + + +def test_tampered_signature_detected(): + priv = Ed25519PrivateKey.generate() + fw = _fw(13, 8 * 1024) + image, _ = ml.ensure_endf(fw) + m = ml.build_manifest(target_id=7, fw_version=1, 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) + blob = bytearray(ml.build_container(m, image)) + # flip a byte of target_id (inside signed region) without re-signing + blob[10] ^= 0xFF + problems = ml.verify(ml.parse_container(bytes(blob))) + assert any("signature INVALID" in p for p in problems), problems + + +# --- approval enforcement -------------------------------------------------- + +def test_approval_default_and_flagged_if_preapproved(): + fw = _fw(14, 4 * 1024) + image, _ = ml.ensure_endf(fw) + m = ml.build_manifest(target_id=1, fw_version=1, image_size=len(image), payload=image, + block_size=1024, image_hash=ml.mh32(image), + codec_id=ml.CODEC_FULL, is_full=True) + assert m.approval == ml.APPROVAL_NOT + # simulate a malicious pre-approved container -> verify must flag it + m.approval = ml.APPROVAL_YES + parsed = ml.parse_container(ml.build_container(m, image)) + assert any("approval" in p for p in ml.verify(parsed)) + + +# --- delta ----------------------------------------------------------------- + +def test_delta_build_apply_verify(): + old_body = _fw(20, 40 * 1024) + # new = old with a chunk changed + appended -> a real, small-ish delta + new_body = bytearray(old_body) + for i in range(1000, 1500): + new_body[i] = (new_body[i] + 1) & 0xFF + new_body += _fw(21, 2048) + old_image, base_hash = ml.ensure_endf(bytes(old_body)) + new_image, _ = ml.ensure_endf(bytes(new_body)) + + import detools + fp = io.BytesIO() + detools.create_patch(io.BytesIO(old_image), io.BytesIO(new_image), fp, + patch_type="sequential", compression="crle") + delta = fp.getvalue() + # with compression a near-identical-base delta is a fraction of the full image + assert len(delta) < len(new_image) // 2, (len(delta), len(new_image)) + + m = ml.build_manifest(target_id=0xABCD, fw_version=ml.pack_version("1.2.0"), + image_size=len(new_image), payload=delta, block_size=1024, + image_hash=ml.mh32(new_image), codec_id=ml.CODEC_DETOOLS_SEQUENTIAL, + is_full=False, base_hash=base_hash) + parsed = ml.parse_container(ml.build_container(m, delta)) + assert parsed.manifest.base_hash == base_hash == ml.mh8(bytes(old_body)) + # full verify incl. applying the delta to the base and checking image_hash + assert ml.verify(parsed, base_image=old_image) == [] + # wrong base must fail the delta->image_hash check + wrong = ml.verify(parsed, base_image=_fw(99, 40 * 1024)) + assert wrong, "delta verify against a wrong base should fail" + + +# --- runner ---------------------------------------------------------------- + +def _run(): + tests = {k: v for k, v in sorted(globals().items()) + if k.startswith("test_") and callable(v)} + failed = 0 + for name, fn in tests.items(): + try: + fn() + print(f"ok {name}") + except Exception as e: # noqa: BLE001 + failed += 1 + import traceback + print(f"FAIL {name}: {e}") + traceback.print_exc() + print(f"\n{len(tests) - failed}/{len(tests)} passed") + return 1 if failed else 0 + + +if __name__ == "__main__": + raise SystemExit(_run()) diff --git a/variants/gat562_30s_mesh_kit/platformio.ini b/variants/gat562_30s_mesh_kit/platformio.ini index 2baac2561b..e940085e5e 100644 --- a/variants/gat562_30s_mesh_kit/platformio.ini +++ b/variants/gat562_30s_mesh_kit/platformio.ini @@ -1,8 +1,8 @@ [GAT562_30S_Mesh_Kit] -extends = nrf52_base +extends = rak4631_hw board = rak4631 board_check = true -build_flags = ${nrf52_base.build_flags} +build_flags = ${rak4631_hw.build_flags} ${sensor_base.build_flags} -I variants/gat562_30s_mesh_kit -D RAK_4631 @@ -19,13 +19,13 @@ build_flags = ${nrf52_base.build_flags} -D PIN_BUZZER=33 -D SX126X_RX_BOOSTED_GAIN=1 -D SX126X_DIO2_AS_RF_SWITCH=true -build_src_filter = ${nrf52_base.build_src_filter} +build_src_filter = ${rak4631_hw.build_src_filter} +<../variants/gat562_30s_mesh_kit> + + + lib_deps = - ${nrf52_base.lib_deps} + ${rak4631_hw.lib_deps} ${sensor_base.lib_deps} adafruit/Adafruit SSD1306 @ ^2.5.13 sparkfun/SparkFun u-blox GNSS Arduino Library@^2.2.27 diff --git a/variants/gat562_mesh_evb_pro/platformio.ini b/variants/gat562_mesh_evb_pro/platformio.ini index b3e894174a..20dedd8412 100644 --- a/variants/gat562_mesh_evb_pro/platformio.ini +++ b/variants/gat562_mesh_evb_pro/platformio.ini @@ -1,8 +1,8 @@ [GAT562_Mesh_EVB_Pro] -extends = nrf52_base +extends = rak4631_hw board = rak4631 board_check = true -build_flags = ${nrf52_base.build_flags} +build_flags = ${rak4631_hw.build_flags} ${sensor_base.build_flags} -I variants/gat562_mesh_evb_pro -D NRF52_POWER_MANAGEMENT @@ -13,12 +13,12 @@ build_flags = ${nrf52_base.build_flags} -D LORA_TX_POWER=22 -D SX126X_CURRENT_LIMIT=140 -D SX126X_RX_BOOSTED_GAIN=1 -build_src_filter = ${nrf52_base.build_src_filter} +build_src_filter = ${rak4631_hw.build_src_filter} +<../variants/gat562_mesh_evb_pro> + + lib_deps = - ${nrf52_base.lib_deps} + ${rak4631_hw.lib_deps} ${sensor_base.lib_deps} sparkfun/SparkFun u-blox GNSS Arduino Library@^2.2.27 diff --git a/variants/gat562_mesh_tracker_pro/platformio.ini b/variants/gat562_mesh_tracker_pro/platformio.ini index af153b8fc2..26bf49cbec 100644 --- a/variants/gat562_mesh_tracker_pro/platformio.ini +++ b/variants/gat562_mesh_tracker_pro/platformio.ini @@ -1,8 +1,8 @@ [GAT562_Mesh_Tracker_Pro] -extends = nrf52_base +extends = rak4631_hw board = rak4631 board_check = true -build_flags = ${nrf52_base.build_flags} +build_flags = ${rak4631_hw.build_flags} ${sensor_base.build_flags} -I variants/gat562_mesh_tracker_pro -D NRF52_POWER_MANAGEMENT @@ -15,13 +15,13 @@ build_flags = ${nrf52_base.build_flags} -D LORA_TX_POWER=22 -D SX126X_CURRENT_LIMIT=140 -D SX126X_RX_BOOSTED_GAIN=1 -build_src_filter = ${nrf52_base.build_src_filter} +build_src_filter = ${rak4631_hw.build_src_filter} +<../variants/gat562_mesh_tracker_pro> + + + lib_deps = - ${nrf52_base.lib_deps} + ${rak4631_hw.lib_deps} ${sensor_base.lib_deps} adafruit/Adafruit SSD1306 @ ^2.5.13 sparkfun/SparkFun u-blox GNSS Arduino Library@^2.2.27 diff --git a/variants/gat562_mesh_watch13/platformio.ini b/variants/gat562_mesh_watch13/platformio.ini index f3510b74aa..59ec79d311 100644 --- a/variants/gat562_mesh_watch13/platformio.ini +++ b/variants/gat562_mesh_watch13/platformio.ini @@ -1,8 +1,8 @@ [GAT562_Mesh_Watch13] -extends = nrf52_base +extends = rak4631_hw board = rak4631 board_check = true -build_flags = ${nrf52_base.build_flags} +build_flags = ${rak4631_hw.build_flags} ${sensor_base.build_flags} -UENV_INCLUDE_GPS -I variants/gat562_mesh_watch13 @@ -18,13 +18,13 @@ build_flags = ${nrf52_base.build_flags} -D SX126X_CURRENT_LIMIT=140 -D SX126X_RX_BOOSTED_GAIN=1 -D QSPIFLASH=1 -build_src_filter = ${nrf52_base.build_src_filter} +build_src_filter = ${rak4631_hw.build_src_filter} +<../variants/gat562_mesh_watch13> + + + lib_deps = - ${nrf52_base.lib_deps} + ${rak4631_hw.lib_deps} ${sensor_base.lib_deps} adafruit/Adafruit SSD1306 @ ^2.5.13 diff --git a/variants/heltec_t114/platformio.ini b/variants/heltec_t114/platformio.ini index 135babb1a2..8e1f66fe4f 100644 --- a/variants/heltec_t114/platformio.ini +++ b/variants/heltec_t114/platformio.ini @@ -2,10 +2,10 @@ ; Heltec T114 without display ; [Heltec_t114] -extends = nrf52_base +extends = rak4631_hw board = heltec_t114 board_build.ldscript = boards/nrf52840_s140_v6.ld -build_flags = ${nrf52_base.build_flags} +build_flags = ${rak4631_hw.build_flags} ${sensor_base.build_flags} -I lib/nrf52/s140_nrf52_6.1.1_API/include -I lib/nrf52/s140_nrf52_6.1.1_API/include/nrf52 @@ -36,12 +36,12 @@ build_flags = ${nrf52_base.build_flags} -D PIN_GPS_RESET_ACTIVE=LOW -D ENV_PIN_SDA=PIN_WIRE1_SDA -D ENV_PIN_SCL=PIN_WIRE1_SCL -build_src_filter = ${nrf52_base.build_src_filter} +build_src_filter = ${rak4631_hw.build_src_filter} + + +<../variants/heltec_t114> lib_deps = - ${nrf52_base.lib_deps} + ${rak4631_hw.lib_deps} ${sensor_base.lib_deps} debug_tool = jlink upload_protocol = nrfutil diff --git a/variants/heltec_v3/platformio.ini b/variants/heltec_v3/platformio.ini index a70a93a508..e20c408e0b 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 @@ -47,10 +48,16 @@ build_flags = -D ADVERT_LON=0.0 -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 + -D ENABLE_OTA=1 ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 +extra_scripts = + merge-bin.py + post:tools/mota/pio_endf.py build_src_filter = ${Heltec_lora32_v3.build_src_filter} + + + + + +<../examples/simple_repeater> lib_deps = ${Heltec_lora32_v3.lib_deps} diff --git a/variants/muziworks_r1_neo/platformio.ini b/variants/muziworks_r1_neo/platformio.ini index 3dbecf1e84..d12c52816f 100644 --- a/variants/muziworks_r1_neo/platformio.ini +++ b/variants/muziworks_r1_neo/platformio.ini @@ -1,8 +1,8 @@ [R1Neo] -extends = nrf52_base +extends = rak4631_hw board = rak4631 board_check = true -build_flags = ${nrf52_base.build_flags} +build_flags = ${rak4631_hw.build_flags} ${sensor_base.build_flags} -I variants/muziworks_r1_neo -I src/helpers/ui @@ -19,13 +19,13 @@ build_flags = ${nrf52_base.build_flags} -D PIN_GPS_TX=25 -D PIN_GPS_RX=24 -D PIN_GPS_EN=33 -build_src_filter = ${nrf52_base.build_src_filter} +build_src_filter = ${rak4631_hw.build_src_filter} +<../variants/muziworks_r1_neo> + + + lib_deps = - ${nrf52_base.lib_deps} + ${rak4631_hw.lib_deps} ${sensor_base.lib_deps} sparkfun/SparkFun u-blox GNSS Arduino Library@^2.2.27 diff --git a/variants/rak4631/platformio.ini b/variants/rak4631/platformio.ini index 2bbba31463..0d2ca7dc0e 100644 --- a/variants/rak4631/platformio.ini +++ b/variants/rak4631/platformio.ini @@ -4,11 +4,13 @@ board = rak4631 board_check = true extra_scripts = ${nrf52_base.extra_scripts} post:variants/rak4631/fix_bsec_lib.py + post:tools/mota/pio_endf.py ; EndF trailer for OTA self-identity (all RAK4631 roles) build_flags = ${nrf52_base.build_flags} ${sensor_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 @@ -25,11 +27,15 @@ build_flags = ${nrf52_base.build_flags} -D ENV_INCLUDE_RAK12035=1 -UENV_INCLUDE_BME680 -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> + + + + + lib_deps = ${nrf52_base.lib_deps} ${sensor_base.lib_deps} @@ -47,10 +53,12 @@ build_flags = -D ADVERT_LON=0.0 -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 +; -D OTA_DEBUG=1 ; bring-up: trace OTA fetch (REQ / block / page-flush) over Serial ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 build_src_filter = ${rak4631.build_src_filter} + + ; OTA (ENABLE_OTA + flash store + EndF + helpers/ota) is inherited from the [rak4631] base now +<../examples/simple_repeater> [env:RAK_4631_repeater_bridge_rs232_serial1] diff --git a/variants/rak_wismesh_tag/platformio.ini b/variants/rak_wismesh_tag/platformio.ini index e9cddb74dd..45669414e2 100644 --- a/variants/rak_wismesh_tag/platformio.ini +++ b/variants/rak_wismesh_tag/platformio.ini @@ -1,8 +1,8 @@ [rak_wismesh_tag] -extends = nrf52_base +extends = rak4631_hw board = rak4631 board_check = true -build_flags = ${nrf52_base.build_flags} +build_flags = ${rak4631_hw.build_flags} ${sensor_base.build_flags} -I variants/rak_wismesh_tag -I src/helpers/ui @@ -25,13 +25,13 @@ build_flags = ${nrf52_base.build_flags} -D PIN_BUZZER=21 -D PIN_BOARD_SDA=PIN_WIRE_SDA -D PIN_BOARD_SCL=PIN_WIRE_SCL -build_src_filter = ${nrf52_base.build_src_filter} +build_src_filter = ${rak4631_hw.build_src_filter} +<../variants/rak_wismesh_tag> + + + lib_deps = - ${nrf52_base.lib_deps} + ${rak4631_hw.lib_deps} ${sensor_base.lib_deps} [env:RAK_WisMesh_Tag_repeater] From f7b37fde0f06559b82d0eb7ae5c5e824bd505a86 Mon Sep 17 00:00:00 2001 From: Valentin Kivachuk Burda Date: Mon, 29 Jun 2026 13:03:06 +0200 Subject: [PATCH 08/59] =?UTF-8?q?ota:=20motatool=20=E2=80=94=20portable=20?= =?UTF-8?q?C++=20.mota=20packager=20+=20relay=20(build/verify/inspect/serv?= =?UTF-8?q?e/keygen)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit 2092500d2b28d45184d972d2198f0b125cacdfdf) --- tools/motatool/.gitignore | 1 + tools/motatool/CMakeLists.txt | 42 +++ tools/motatool/README.md | 114 ++++++ tools/motatool/src/crypto.cpp | 61 +++ tools/motatool/src/crypto.h | 28 ++ tools/motatool/src/input.cpp | 102 +++++ tools/motatool/src/input.h | 15 + tools/motatool/src/main.cpp | 501 +++++++++++++++++++++++++ tools/motatool/src/mota.cpp | 413 ++++++++++++++++++++ tools/motatool/src/mota.h | 83 ++++ tools/motatool/src/mota_format.h | 100 +++++ tools/motatool/src/serve.cpp | 209 +++++++++++ tools/motatool/src/serve.h | 68 ++++ tools/motatool/src/util.h | 77 ++++ tools/motatool/tests/test_motatool.cpp | 302 +++++++++++++++ 15 files changed, 2116 insertions(+) create mode 100644 tools/motatool/.gitignore create mode 100644 tools/motatool/CMakeLists.txt create mode 100644 tools/motatool/README.md create mode 100644 tools/motatool/src/crypto.cpp create mode 100644 tools/motatool/src/crypto.h create mode 100644 tools/motatool/src/input.cpp create mode 100644 tools/motatool/src/input.h create mode 100644 tools/motatool/src/main.cpp create mode 100644 tools/motatool/src/mota.cpp create mode 100644 tools/motatool/src/mota.h create mode 100644 tools/motatool/src/mota_format.h create mode 100644 tools/motatool/src/serve.cpp create mode 100644 tools/motatool/src/serve.h create mode 100644 tools/motatool/src/util.h create mode 100644 tools/motatool/tests/test_motatool.cpp diff --git a/tools/motatool/.gitignore b/tools/motatool/.gitignore new file mode 100644 index 0000000000..567609b123 --- /dev/null +++ b/tools/motatool/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/tools/motatool/CMakeLists.txt b/tools/motatool/CMakeLists.txt new file mode 100644 index 0000000000..4a15306377 --- /dev/null +++ b/tools/motatool/CMakeLists.txt @@ -0,0 +1,42 @@ +cmake_minimum_required(VERSION 3.16) +project(motatool LANGUAGES CXX) + +# Self-contained MeshCore OTA host tool: build / verify / inspect / serve `.mota` containers. +# Portable C++17 — builds on Ubuntu, Raspberry Pi, and any arch with a C++17 compiler + OpenSSL. +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +if(NOT CMAKE_BUILD_TYPE) + set(CMAKE_BUILD_TYPE Release) +endif() +add_compile_options(-Wall -Wextra) + +# OpenSSL libcrypto provides SHA-256 + Ed25519 (sign/verify/keygen). On Debian/Ubuntu/Pi: +# sudo apt install libssl-dev +find_package(OpenSSL REQUIRED) + +# Core logic (everything except the CLI front-end) — shared by the tool and the test runner. +add_library(motatool_core STATIC + src/mota.cpp + src/crypto.cpp + src/input.cpp + src/serve.cpp +) +# `src` for the tool's own headers; the repo's helpers/ota for the shared, generated OtaTargets.h +# (target_id -> env-name table — single source of truth with the firmware, no hand-maintained copy). +target_include_directories(motatool_core PUBLIC + src + "${CMAKE_CURRENT_SOURCE_DIR}/../../src/helpers/ota" +) +target_link_libraries(motatool_core PUBLIC OpenSSL::Crypto) + +add_executable(motatool src/main.cpp) +target_link_libraries(motatool PRIVATE motatool_core) +install(TARGETS motatool RUNTIME DESTINATION bin) + +# Unit tests (no external test framework — a tiny built-in harness keeps the project self-contained). +# Run with: cmake --build build && ctest --test-dir build --output-on-failure +# or: ./build/motatool_tests +enable_testing() +add_executable(motatool_tests tests/test_motatool.cpp) +target_link_libraries(motatool_tests PRIVATE motatool_core) +add_test(NAME motatool_tests COMMAND motatool_tests) diff --git a/tools/motatool/README.md b/tools/motatool/README.md new file mode 100644 index 0000000000..d934b8c4e8 --- /dev/null +++ b/tools/motatool/README.md @@ -0,0 +1,114 @@ +# motatool — self-contained MeshCore OTA host tool (C++) + +A small, portable C++17 tool that **builds**, **verifies**, and **serves** MeshCore `.mota` +firmware-update containers. It is an independent project (its own `CMakeLists.txt`) that compiles on a +laptop, a Raspberry Pi, or any architecture with a C++17 compiler — so it can run as a lightweight +folder-relay daemon on small hardware. + +It implements the wire spec in [`../../docs/ota_protocol.md`](../../docs/ota_protocol.md) and is +cross-checked byte-for-byte against the Python reference packager `tools/mota/motalib.py` (a full signed +`.mota` built by `motatool` is identical to the reference's output). + +## What it does + +| Command | Purpose | +|---|---| +| `build` | Create a **full** or **delta** `.mota` from a firmware (local file **or** http(s) URL). | +| `verify` | Validate one or more `.mota` (merkle tree, leaves vs payload, image hash, Ed25519 signature). `--pub` requires a specific signer; `--base` confirms a sequential delta rebuilds its image. | +| `inspect`| Print every field of a `.mota`'s manifest (debugging). | +| `serve` | Serve a **folder** of `.mota` to a node over USB serial. Invalid files are warned about and skipped — one corrupt file never sinks the rest. | +| `keygen` | Generate an Ed25519 signing keypair (hex). | + +Every command has detailed, example-rich help: `motatool --help`. + +## Build + +Dependencies: a C++17 compiler, CMake, and **OpenSSL** (libcrypto: SHA-256 + Ed25519). For URL input +and delta creation, see the notes below. + +```bash +sudo apt install build-essential cmake libssl-dev # Debian / Ubuntu / Raspberry Pi OS +cmake -S tools/motatool -B tools/motatool/build +cmake --build tools/motatool/build -j +# -> tools/motatool/build/motatool +``` + +### Tests + +Unit tests (a tiny built-in harness — no external framework) cover the crypto, EndF, build/verify, +merkle, parse rejection, the folder scanner + seeder protocol, and a real detools delta round-trip +(auto-skipped if `detools` isn't installed): + +```bash +ctest --test-dir tools/motatool/build --output-on-failure +# or directly: tools/motatool/build/motatool_tests +``` + +## Usage + +```bash +MT=tools/motatool/build/motatool + +# 1. signing keypair (64-char hex) +$MT keygen --out signer.key # writes signer.key + signer.key.pub + +# 2a. FULL .mota — payload IS the flashable image (identity is auto-read from the firmware's EndF) +$MT build --fw firmware.bin --sign signer.key --out-dir ./motas/ + +# 2b. FULL .mota straight from a URL +$MT build --fw https://example.org/Heltec_v3_repeater.bin --sign signer.key --out-dir ./motas/ + +# 2c. DELTA .mota against a previous release (codec auto-selected from the hardware tag: +# nRF52 -> in-place, ESP32 -> sequential; override with --codec) +$MT build --fw new.bin --base old.bin --sign signer.key --out-dir ./motas/ +$MT build --fw new.bin --base old.bin --codec sequential --out-dir ./motas/ + +# 3. validate a folder of .mota (optionally require a signer / check a delta against its base) +$MT verify ./motas/*.mota +$MT verify update.mota --pub signer.key.pub +$MT verify delta.mota --base old_firmware.bin + +# 4. dump a single .mota's manifest fields +$MT inspect ./motas/RAK4631_04D413FD_v1.16.0_full_ABCD1234.mota + +# 5. serve a folder to a node over its USB serial (recursive; skips non-.mota; warns on corrupt) +$MT serve --dir ./motas --serial /dev/ttyUSB0 --baud 115200 -v +``` + +`build` notes: +- **Identity is self-described.** A firmware built by the project's `pio_endf.py` carries its + `target_id`/`fw_version`/`hw_id` in its 56-byte `EndF` trailer; `build` reads them, so + `--target-env`/`--target-id`, `--fw-version`, and `--hw-id` are **optional** (explicit flags override). +- **Cross-hardware delta guard.** A delta is refused if the base and target firmware identities differ + (read from their `EndF`, not filenames); pass `--force` to override. +- **Delta codec** needs the `detools` encoder (the project's pinned codec — never reimplemented). Install + it (`pip install detools`) and ensure it's on `PATH`, or pass `--detools `. **Full** builds, + **verify**, and **serve** need no detools. In-place defaults: `--inplace-memory 0xAE000` + (nRF52 workspace), `--inplace-segment 4096`. +- **URL input** uses the system `curl` (falling back to `wget`) — no link-time dependency. +- All output is written into one folder (`--out-dir`, default `.`) with a descriptive, unique name: + `__v__.mota`. + +## Serving is transport-agnostic (USB serial today, BLE later) + +The protocol is split so the serving logic is reusable across links: + +- **`SeederCore`** (`src/serve.{h,cpp}`) is transport-free: it maps a request `(op, args)` to a response + `(status, payload)` — `COUNT` / `DESCRIBE(idx)` / `READ(idx, off, len)` over the validated catalog. +- **`SerialTransport` + `serve_serial()`** wrap it with the byte-stream framing (magic + XOR checksum + + resync) for the unreliable USB-UART link (`MotaSeederProto.h`). + +To serve the same folder over **BLE** (e.g. an Android phone relaying to a MeshCore node), implement the +transport at the GATT layer — a request characteristic write hands `(op, args)` straight to +`SeederCore::handle()` and the reply is sent as a notification. No byte-stream framing is needed there +(BLE is reliable/segmented), and `SeederCore` + `Folder` are reused unchanged. OpenSSL builds for the +Android NDK, so the validation path ports as-is. + +## Relationship to `tools/mota/` + +`motatool` is the user-facing CLI: it replaced the old Python `mota.py` (build/verify/inspect/keygen), +`mota_seeder.py` (serve), and `dev_motas.py` (CI `.mota` packaging — its nRF52 `.hex` extraction is now a +built-in `motatool` input format), all removed. The Python `tools/mota/` directory remains as the +**reference implementation** (`motalib.py`, the spec oracle + unit tests) and the **firmware build/test +glue** (`pio_endf.py` build hook, `gen_vectors.py` test vectors). Their `.mota` output is byte-identical +(verified by cross-checks). diff --git a/tools/motatool/src/crypto.cpp b/tools/motatool/src/crypto.cpp new file mode 100644 index 0000000000..a4076a9c9b --- /dev/null +++ b/tools/motatool/src/crypto.cpp @@ -0,0 +1,61 @@ +#include "crypto.h" +#include +#include +#include + +namespace mota { + +void sha256_trunc(uint8_t* out, size_t out_len, const uint8_t* data, size_t len) { + uint8_t full[32]; + SHA256(data, len, full); + if (out_len > 32) out_len = 32; + std::memcpy(out, full, out_len); +} + +bool ed25519_verify(const uint8_t sig[64], const uint8_t* msg, size_t msg_len, const uint8_t pub[32]) { + EVP_PKEY* key = EVP_PKEY_new_raw_public_key(EVP_PKEY_ED25519, nullptr, pub, 32); + if (!key) return false; + EVP_MD_CTX* ctx = EVP_MD_CTX_new(); + bool ok = ctx && EVP_DigestVerifyInit(ctx, nullptr, nullptr, nullptr, key) == 1 && + EVP_DigestVerify(ctx, sig, 64, msg, msg_len) == 1; + if (ctx) EVP_MD_CTX_free(ctx); + EVP_PKEY_free(key); + return ok; +} + +bool ed25519_sign(uint8_t sig[64], const uint8_t* msg, size_t msg_len, const uint8_t priv[32]) { + EVP_PKEY* key = EVP_PKEY_new_raw_private_key(EVP_PKEY_ED25519, nullptr, priv, 32); + if (!key) return false; + EVP_MD_CTX* ctx = EVP_MD_CTX_new(); + size_t siglen = 64; + bool ok = ctx && EVP_DigestSignInit(ctx, nullptr, nullptr, nullptr, key) == 1 && + EVP_DigestSign(ctx, sig, &siglen, msg, msg_len) == 1 && siglen == 64; + if (ctx) EVP_MD_CTX_free(ctx); + EVP_PKEY_free(key); + return ok; +} + +bool ed25519_pub_from_priv(uint8_t pub[32], const uint8_t priv[32]) { + EVP_PKEY* key = EVP_PKEY_new_raw_private_key(EVP_PKEY_ED25519, nullptr, priv, 32); + if (!key) return false; + size_t len = 32; + bool ok = EVP_PKEY_get_raw_public_key(key, pub, &len) == 1 && len == 32; + EVP_PKEY_free(key); + return ok; +} + +bool ed25519_keygen(uint8_t priv[32], uint8_t pub[32]) { + EVP_PKEY* key = nullptr; + EVP_PKEY_CTX* pctx = EVP_PKEY_CTX_new_id(EVP_PKEY_ED25519, nullptr); + bool ok = pctx && EVP_PKEY_keygen_init(pctx) == 1 && EVP_PKEY_keygen(pctx, &key) == 1; + if (ok) { + size_t lp = 32, lk = 32; + ok = EVP_PKEY_get_raw_private_key(key, priv, &lp) == 1 && lp == 32 && + EVP_PKEY_get_raw_public_key(key, pub, &lk) == 1 && lk == 32; + } + if (key) EVP_PKEY_free(key); + if (pctx) EVP_PKEY_CTX_free(pctx); + return ok; +} + +} // namespace mota diff --git a/tools/motatool/src/crypto.h b/tools/motatool/src/crypto.h new file mode 100644 index 0000000000..eac5d8d348 --- /dev/null +++ b/tools/motatool/src/crypto.h @@ -0,0 +1,28 @@ +// SHA-256 (multihash truncations) and Ed25519 — backed by OpenSSL libcrypto, so the tool is portable +// across Ubuntu / Raspberry Pi / any arch that has OpenSSL (and Android NDK for a future BLE seeder). +#pragma once +#include +#include +#include +#include +#include + +namespace mota { + +// SHA-256 of `data`, truncated to out_len (sha2-256:N). out_len <= 32. +void sha256_trunc(uint8_t* out, size_t out_len, const uint8_t* data, size_t len); + +inline std::array mh4(const uint8_t* d, size_t n) { std::array o; sha256_trunc(o.data(),4,d,n); return o; } +inline std::array mh8(const uint8_t* d, size_t n) { std::array o; sha256_trunc(o.data(),8,d,n); return o; } +inline std::array mh32(const uint8_t* d, size_t n) { std::array o; sha256_trunc(o.data(),32,d,n); return o; } + +// Ed25519. Keys are raw 32-byte (private seed / public key), matching motalib / the device. +bool ed25519_verify(const uint8_t sig[64], const uint8_t* msg, size_t msg_len, const uint8_t pub[32]); +// Sign `msg` with a 32-byte raw private seed -> 64-byte signature. Returns false on error. +bool ed25519_sign(uint8_t sig[64], const uint8_t* msg, size_t msg_len, const uint8_t priv[32]); +// Derive the 32-byte raw public key from a 32-byte raw private seed. +bool ed25519_pub_from_priv(uint8_t pub[32], const uint8_t priv[32]); +// Generate a fresh keypair (raw 32-byte each). +bool ed25519_keygen(uint8_t priv[32], uint8_t pub[32]); + +} // namespace mota diff --git a/tools/motatool/src/input.cpp b/tools/motatool/src/input.cpp new file mode 100644 index 0000000000..bb0a4b12a5 --- /dev/null +++ b/tools/motatool/src/input.cpp @@ -0,0 +1,102 @@ +#include "input.h" +#include "util.h" +#include +#include +#include + +namespace mota { + +bool is_url(const std::string& s) { + return s.rfind("http://", 0) == 0 || s.rfind("https://", 0) == 0; +} + +static bool ends_with_ci(const std::string& s, const std::string& suf) { + if (s.size() < suf.size()) return false; + for (size_t i = 0; i < suf.size(); i++) + if (std::tolower((unsigned char)s[s.size() - suf.size() + i]) != std::tolower((unsigned char)suf[i])) return false; + return true; +} + +// Parse Intel HEX text into the flat binary it represents (min..max address, gaps filled 0xFF). +// nRF52/STM32 PlatformIO builds emit firmware.hex; pio_endf appends the EndF inside it, so the extracted +// binary IS the OTA image (BODY||EndF) starting at the app base — exactly what `build` wants. +static std::string parse_intel_hex(const std::vector& in, std::vector& out) { + const std::string s((const char*)in.data(), in.size()); + auto nib = [](char c) -> int { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; + }; + struct Seg { uint64_t addr; std::vector data; }; + std::vector segs; + uint64_t base = 0, lo = UINT64_MAX, hi = 0; + size_t i = 0; + bool saw_eof = false; + while (i < s.size()) { + if (s[i] != ':') { i++; continue; } // tolerate CR/LF/whitespace between records + size_t p = i + 1; + auto rb = [&](uint8_t& v) -> bool { + if (p + 2 > s.size()) return false; + int h = nib(s[p]), l = nib(s[p + 1]); + if (h < 0 || l < 0) return false; + v = (uint8_t)((h << 4) | l); p += 2; return true; + }; + uint8_t len, ah, al, type; + if (!rb(len) || !rb(ah) || !rb(al) || !rb(type)) return "malformed Intel HEX record"; + uint8_t sum = (uint8_t)(len + ah + al + type); + std::vector data(len); + for (uint8_t k = 0; k < len; k++) { if (!rb(data[k])) return "truncated Intel HEX data"; sum += data[k]; } + uint8_t cks; + if (!rb(cks)) return "missing Intel HEX checksum"; + if ((uint8_t)(sum + cks) != 0) return "Intel HEX checksum error"; + uint16_t addr = (uint16_t)((ah << 8) | al); + if (type == 0x00) { // data + uint64_t a = base + addr; + if (a < lo) lo = a; + if (a + len > hi) hi = a + len; + segs.push_back({a, std::move(data)}); + } else if (type == 0x04) { // extended linear address (upper 16 bits) + if (len != 2) return "bad Intel HEX ELA record"; + base = (uint64_t)((data[0] << 8) | data[1]) << 16; + } else if (type == 0x02) { // extended segment address + if (len != 2) return "bad Intel HEX ESA record"; + base = (uint64_t)((data[0] << 8) | data[1]) << 4; + } else if (type == 0x01) { // EOF + saw_eof = true; break; + } // 0x03/0x05 (start address) ignored + i = p; + } + if (segs.empty()) return "no data records in Intel HEX"; + if (!saw_eof) return "Intel HEX missing EOF record"; + out.assign((size_t)(hi - lo), 0xFF); + for (auto& sg : segs) std::memcpy(out.data() + (size_t)(sg.addr - lo), sg.data.data(), sg.data.size()); + return ""; +} + +std::string read_input(const std::string& src, std::vector& out) { + std::vector raw; + if (!is_url(src)) { + if (!read_file(src, raw)) return "cannot read file: " + src; + } else { + // URL: download to a temp file via curl (fallback wget), then read it back. + char tmp[] = "/tmp/motadlXXXXXX"; + int fd = mkstemp(tmp); + if (fd < 0) return "mkstemp failed"; + close(fd); + int rc = run_argv({"curl", "-fLsS", "-o", tmp, src}); // -f fail, -L follow, -sS silent+show-errors + if (rc == 127) rc = run_argv({"wget", "-q", "-O", tmp, src}); + std::string err; + if (rc == 127) err = "neither curl nor wget is available to fetch " + src; + else if (rc != 0) err = "download failed (exit " + std::to_string(rc) + "): " + src; + else if (!read_file(tmp, raw)) err = "could not read the downloaded file: " + src; + unlink(tmp); + if (!err.empty()) return err; + } + if (raw.empty()) return "input is empty: " + src; + if (ends_with_ci(src, ".hex")) return parse_intel_hex(raw, out); // Intel HEX -> flat image + out = std::move(raw); + return ""; +} + +} // namespace mota diff --git a/tools/motatool/src/input.h b/tools/motatool/src/input.h new file mode 100644 index 0000000000..ff92f5fd0a --- /dev/null +++ b/tools/motatool/src/input.h @@ -0,0 +1,15 @@ +// Read a firmware blob from a local file OR an http(s):// URL. URLs are fetched with the system `curl` +// (or `wget`) binary — no link-time dependency, so the tool stays self-contained and builds anywhere. +#pragma once +#include +#include +#include + +namespace mota { + +// Returns "" on success (fills `out`), else an error string. `what` is a label for error messages. +std::string read_input(const std::string& path_or_url, std::vector& out); + +bool is_url(const std::string& s); + +} // namespace mota diff --git a/tools/motatool/src/main.cpp b/tools/motatool/src/main.cpp new file mode 100644 index 0000000000..7e8bb0b6f5 --- /dev/null +++ b/tools/motatool/src/main.cpp @@ -0,0 +1,501 @@ +// motatool — build, verify, and serve MeshCore `.mota` firmware-update containers. +// build create a full or delta .mota from a firmware (file or http(s) URL) +// verify validate one or more .mota (merkle / hashes / signature) +// serve serve a folder of .mota to a node (USB serial); invalid files are warned + skipped +// keygen generate an Ed25519 signing keypair (64-char hex) +#include "mota.h" +#include "input.h" +#include "serve.h" +#include "crypto.h" +#include "util.h" +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; +using namespace mota; + +static volatile bool g_stop = false; +static void on_sigint(int) { g_stop = true; } + +// minimal flag parser: --key value / --flag +struct Args { + std::map opt; + std::vector pos; + bool has(const std::string& k) const { return opt.count(k); } + std::string get(const std::string& k, const std::string& d = "") const { + auto it = opt.find(k); return it == opt.end() ? d : it->second; + } +}; +static Args parse_args(int argc, char** argv, int start, + const std::vector& bool_flags) { + Args a; + for (int i = start; i < argc; i++) { + std::string s = argv[i]; + if (s == "-h" || s == "--help" || s == "help") { a.opt["help"] = "1"; continue; } + if (s == "-v") { a.opt["verbose"] = "1"; continue; } + if (s.rfind("--", 0) == 0) { + std::string k = s.substr(2); + if (std::find(bool_flags.begin(), bool_flags.end(), k) != bool_flags.end()) a.opt[k] = "1"; + else if (i + 1 < argc) a.opt[k] = argv[++i]; + else a.opt[k] = ""; + } else a.pos.push_back(s); + } + return a; +} + +static bool load_priv(const std::string& path, std::vector& priv) { + std::vector raw; + if (!read_file(path, raw)) return false; + std::string txt((const char*)raw.data(), raw.size()); // try hex (mota.py format) first + while (!txt.empty() && (std::isspace((unsigned char)txt.back()))) txt.pop_back(); + std::vector hx; + if (from_hex(txt, hx) && hx.size() == 32) { priv = hx; return true; } + if (raw.size() == 32) { priv = raw; return true; } // else accept a raw 32-byte file + return false; +} + +static std::string version_str(uint32_t v) { + char b[24]; std::snprintf(b, sizeof(b), "%u.%u.%u", (v>>24)&0xFF, (v>>16)&0xFF, (v>>8)&0xFF); + return b; +} + +// ---- help text (the tool is meant to be usable from --help alone) --------------------------------- +static void help_top() { + std::cout << +"motatool — build, verify, and serve MeshCore .mota firmware-update containers.\n" +"\n" +"A .mota is a signed, self-verifying package of a firmware update that MeshCore nodes fetch\n" +"over LoRa, block by block. This tool makes those packages, checks them, and serves a folder\n" +"of them to a node over USB.\n" +"\n" +"USAGE\n" +" motatool [options]\n" +" motatool --help detailed help + examples for a command\n" +"\n" +"COMMANDS\n" +" build Package a firmware as a .mota (a full image, or a small delta vs a previous build).\n" +" verify Check that .mota files are valid (block hashes, image hash, signature).\n" +" inspect Print every field of a .mota's manifest (debugging).\n" +" serve Serve a folder of .mota to a node over USB serial (corrupt files are skipped).\n" +" keygen Generate an Ed25519 signing keypair.\n" +"\n" +"TYPICAL WORKFLOW\n" +" 1. Make a signing key once:\n" +" motatool keygen --out signer.key\n" +" 2. Package a firmware (identity is read from the firmware itself):\n" +" motatool build --fw firmware.bin --sign signer.key --out-dir ./motas\n" +" 3. Serve the folder to a node plugged in over USB:\n" +" motatool serve --dir ./motas --serial /dev/ttyUSB0\n"; +} + +static void help_build() { + std::cout << +"motatool build — package a firmware as a .mota update container.\n" +"\n" +"USAGE\n" +" motatool build --fw [--base ] [options]\n" +"\n" +"WHAT IT DOES\n" +" With only --fw: builds a FULL update (the payload is the whole firmware image).\n" +" With --base too: builds a DELTA (a small patch from the base firmware to the new one),\n" +" which is far smaller to send over LoRa. The delta codec is picked for the target hardware\n" +" automatically: nRF52 (single-slot) -> in-place, ESP32 (A/B slots) -> sequential.\n" +"\n" +" Firmware identity (target, version, hardware tag) is read automatically from the firmware's\n" +" EndF trailer, so you normally don't pass --target-*/--fw-version/--hw-id. Pass them only to\n" +" override, or for a raw .bin that has no EndF identity.\n" +"\n" +"INPUT (--fw is required)\n" +" --fw NEW firmware. A local path OR an http(s):// URL (downloaded with curl/wget).\n" +" A .bin is used as-is; a .hex (nRF52/STM32 build) is parsed to its flat image first.\n" +" --base previous firmware to diff against -> makes a delta (omit it for a full image).\n" +"\n" +"IDENTITY (optional — auto-read from the firmware's EndF)\n" +" --target-env PlatformIO env name (e.g. RAK_4631_repeater); hashed into the target id.\n" +" --target-id raw target id instead of --target-env (e.g. 0x04D413FD).\n" +" --fw-version firmware version (e.g. 1.16.0).\n" +" --hw-id hardware tag (e.g. RAK4631, Heltec_v3). Same tag = bootable-compatible;\n" +" a node refuses a .mota whose hw-id is for different hardware (brick-safety).\n" +"\n" +"DELTA OPTIONS\n" +" --codec full|sequential|inplace force the codec (default: full with no --base, else auto-from-hw).\n" +" --inplace-memory nRF52 in-place workspace size (default 0xAE000).\n" +" --inplace-segment nRF52 in-place erase/segment size (default 4096).\n" +" --detools the detools encoder to call (default: 'detools' on PATH). Needed only for deltas.\n" +" --force build the delta even if base and target hardware identities differ.\n" +"\n" +"SIGNING\n" +" --sign Ed25519 private key (hex or raw 32 bytes, from 'motatool keygen'). Signing lets a\n" +" node auto-install the update if it trusts the matching public key. Unsigned still works\n" +" for manual installs.\n" +"\n" +"OUTPUT\n" +" --out-dir where to write the .mota (default: current directory). Keep all your .mota in one\n" +" folder and point 'serve' at it. The file is auto-named:\n" +" __v__.mota\n" +" --out write to exactly this path instead (overrides --out-dir and the auto-name).\n" +"\n" +"EXAMPLES\n" +" # full image, signed, into ./motas\n" +" motatool build --fw firmware.bin --sign signer.key --out-dir ./motas\n" +"\n" +" # full image fetched straight from a release URL\n" +" motatool build --fw https://example.org/RAK_4631_repeater.bin --sign signer.key --out-dir ./motas\n" +"\n" +" # delta from the previous release (codec auto-selected from the hardware)\n" +" motatool build --fw new.bin --base old.bin --sign signer.key --out-dir ./motas\n" +"\n" +" # raw .bin with no EndF identity: supply it explicitly\n" +" motatool build --fw app.bin --hw-id Heltec_v3 --target-env Heltec_v3_repeater --fw-version 1.16.0\n"; +} + +static void help_verify() { + std::cout << +"motatool verify — check that .mota files are valid.\n" +"\n" +"USAGE\n" +" motatool verify [more.mota ...] [--pub ] [--base ]\n" +"\n" +"For each file it checks the structure, that the per-block hashes match the payload, the merkle\n" +"root, the full-image hash (for full images), and the Ed25519 signature (for signed containers).\n" +"It prints 'OK' or 'FAIL ' per file; the exit code is non-zero if any file fails. This is\n" +"the same validation 'serve' runs on a folder before serving.\n" +"\n" +"OPTIONS\n" +" --pub require the container to be signed by THIS public key (hex/raw, *.pub from keygen).\n" +" --base for a sequential delta, apply it to this base and confirm it rebuilds the image.\n" +" (Applies to every file given; full images ignore it; in-place deltas are skipped.)\n" +"\n" +"EXAMPLES\n" +" motatool verify ./motas/*.mota\n" +" motatool verify update.mota --pub signer.key.pub\n" +" motatool verify delta.mota --base old_firmware.bin\n"; +} + +static void help_inspect() { + std::cout << +"motatool inspect — print every field of a .mota's manifest.\n" +"\n" +"USAGE\n" +" motatool inspect \n" +"\n" +"Dumps the parsed manifest (versions, sizes, target/hardware, codec, merkle root, image hash,\n" +"base hash, signer key + signature, approval state, block count) — handy for debugging a package.\n" +"It does not validate integrity; use 'verify' for that.\n" +"\n" +"EXAMPLE\n" +" motatool inspect ./motas/RAK4631_04D413FD_v1.16.0_full_ABCD1234.mota\n"; +} + +static void help_serve() { + std::cout << +"motatool serve — serve a folder of .mota to a MeshCore node over USB serial.\n" +"\n" +"USAGE\n" +" motatool serve --dir --serial [options]\n" +"\n" +"It scans the folder for .mota files, validates each, and serves the valid ones to the node.\n" +"Corrupt/invalid files are reported and skipped — one bad file never stops the rest. The node\n" +"advertises them to the mesh as if it held them, and any node whose hardware matches can fetch\n" +"them. The relay is trustless: fetchers verify every block, so this host never needs the keys.\n" +"\n" +"OPTIONS (--dir and --serial are required)\n" +" --dir folder of .mota to serve (searched recursively by default).\n" +" --serial the node's USB serial port (e.g. /dev/ttyUSB0 or /dev/ttyACM0).\n" +" --baud serial speed (default 115200).\n" +" --no-recursive serve only the top folder; don't descend into sub-folders.\n" +" --no-enable don't auto-send 'ota folder on'/'off' to the node (run them on its CLI yourself).\n" +" -v, --verbose log each request the node makes (COUNT / DESCRIBE / READ).\n" +"\n" +"Leave it running; press Ctrl-C to stop (it tells the node to stop relaying first). It shares the\n" +"same USB cable as the node's text console — the node only pulls bytes while actively fetching.\n" +"\n" +"EXAMPLE\n" +" motatool serve --dir ./motas --serial /dev/ttyUSB0 -v\n"; +} + +static void help_keygen() { + std::cout << +"motatool keygen — generate an Ed25519 signing keypair.\n" +"\n" +"USAGE\n" +" motatool keygen [--out ]\n" +"\n" +"Prints the public key. With --out it writes the private key to and the public key to\n" +".pub (hex). Sign updates with the private key ('build --sign '); trust the\n" +"public key on a node to let it auto-install updates signed by you.\n" +"\n" +"EXAMPLE\n" +" motatool keygen --out signer.key\n"; +} + +static std::string hex8(uint32_t v) { char x[9]; std::snprintf(x, 9, "%08X", v); return x; } + +// human-readable label for a target_id (its PlatformIO env name), or "N/A" if not in the known table +static std::string target_label(uint32_t t) { + std::string n = target_env_name(t); + return n.empty() ? "N/A" : n; +} + +static int cmd_verify(const Args& a) { + if (a.has("help")) { help_verify(); return 0; } + if (a.pos.empty()) { help_verify(); return 2; } + + std::vector expect_pub; + if (a.has("pub") && !load_priv(a.get("pub"), expect_pub)) { // load_priv accepts a 32-byte hex/raw key + std::cerr << "cannot load --pub key (expect 32-byte hex or raw)\n"; return 2; + } + std::vector base_img; + if (a.has("base")) { + std::vector b; std::string e = read_input(a.get("base"), b); + if (!e.empty()) { std::cerr << "error: " << e << "\n"; return 1; } + std::array bh; base_img = ensure_endf(b, parse_endf_ident(b), bh); + } + + int bad = 0; + for (const auto& f : a.pos) { + std::vector blob; + if (!read_file(f, blob)) { std::cout << "FAIL " << f << " : cannot read\n"; bad++; continue; } + auto probs = verify(blob); + Manifest m; bool parsed = parse(blob, m).empty(); + if (parsed && !expect_pub.empty()) { // --pub: must be signed by this exact key + if (!m.is_signed()) probs.push_back("not signed (but --pub was given)"); + else if (std::memcmp(m.signer.data(), expect_pub.data(), 32) != 0) probs.push_back("signed by a different key than --pub"); + } + if (parsed && !base_img.empty() && !m.is_full()) { // --base: prove a delta rebuilds the image + if (m.codec_id == CODEC_DETOOLS_SEQUENTIAL) { + std::vector patch(blob.begin() + m.payload_off(), blob.begin() + m.payload_off() + m.payload_size); + std::vector recon; + std::string e = detools_apply_seq(a.get("detools", "detools"), base_img, patch, recon); + if (!e.empty()) probs.push_back("delta apply: " + e); + else { auto h = mota::mh32(recon.data(), recon.size()); + if (std::memcmp(h.data(), m.image_hash.data(), 32) != 0) probs.push_back("delta does not rebuild image_hash against --base"); } + } else { + std::cout << "note " << f << " : in-place delta — --base apply-check skipped (bootloader-applied)\n"; + } + } + if (probs.empty()) { + std::cout << "OK " << f << " : " << (m.is_full() ? "full" : "delta") + << " target=" << hex8(m.target_id) << " [" << target_label(m.target_id) << "]" + << " v" << version_str(m.fw_version) << " hw=" << (m.hw_id_str().empty()? "?":m.hw_id_str()) + << " " << (m.is_signed() ? "signed" : "unsigned") + << " blocks=" << m.block_count << " size=" << blob.size() << "\n"; + } else { + bad++; + std::cout << "FAIL " << f << " :"; + for (auto& p : probs) std::cout << " [" << p << "]"; + std::cout << "\n"; + } + } + return bad ? 1 : 0; +} + +static int cmd_inspect(const Args& a) { + if (a.has("help")) { help_inspect(); return 0; } + if (a.pos.empty()) { help_inspect(); return 2; } + std::vector blob; + if (!read_file(a.pos[0], blob)) { std::cerr << "cannot read " << a.pos[0] << "\n"; return 1; } + Manifest m; + std::string e = parse(blob, m); + if (!e.empty()) { std::cerr << "not a valid .mota: " << e << "\n"; return 1; } + auto z = [](const uint8_t* p, size_t n){ for (size_t i=0;i in-place, else sequential) +static int infer_codec(const std::string& hw) { + std::string h; for (char c : hw) h.push_back((char)std::tolower((unsigned char)c)); + if (h.rfind("rak", 0) == 0 || h.find("nrf") != std::string::npos || h.find("nordic") != std::string::npos) + return CODEC_DETOOLS_INPLACE; + if (h.find("heltec") != std::string::npos || h.find("esp32") != std::string::npos || + h.find("xiao") != std::string::npos || h.find("tbeam") != std::string::npos || + h.find("tlora") != std::string::npos || h.find("tdeck") != std::string::npos) + return CODEC_DETOOLS_SEQUENTIAL; + return -1; // unknown +} + +static int cmd_build(const Args& a) { + if (a.has("help")) { help_build(); return 0; } + if (!a.has("fw")) { std::cerr << "error: --fw is required\n\n"; help_build(); return 2; } + BuildOpts o; + std::string e = read_input(a.get("fw"), o.fw); + if (!e.empty()) { std::cerr << "error: " << e << "\n"; return 1; } + if (a.has("base")) { + e = read_input(a.get("base"), o.base); + if (!e.empty()) { std::cerr << "error: " << e << "\n"; return 1; } + } + if (a.has("target-id")) { o.have_target = true; o.target_id = (uint32_t)std::strtoul(a.get("target-id").c_str(), nullptr, 0); } + else if (a.has("target-env")) { o.have_target = true; o.target_id = target_id_for_env(a.get("target-env")); } + if (a.has("fw-version")) { + uint32_t v; if (!pack_version(a.get("fw-version"), v)) { std::cerr << "bad --fw-version\n"; return 2; } + o.have_fwver = true; o.fw_version = v; + } + if (a.has("hw-id")) o.hw_id = a.get("hw-id"); + o.force = a.has("force"); + if (a.has("detools")) o.detools = a.get("detools"); + if (a.has("inplace-memory")) o.inplace_memory = (uint32_t)std::strtoul(a.get("inplace-memory").c_str(), nullptr, 0); + if (a.has("inplace-segment")) o.inplace_segment = (uint32_t)std::strtoul(a.get("inplace-segment").c_str(), nullptr, 0); + if (a.has("sign")) { + if (!load_priv(a.get("sign"), o.sign_priv)) { std::cerr << "cannot load signing key (expect 32-byte hex or raw)\n"; return 1; } + } + + bool is_delta = !o.base.empty(); + // resolve hw (flags override EndF) to pick the codec + FwIdent fid = parse_endf_ident(o.fw); + std::string hw = o.hw_id.empty() ? fid.hw_id : o.hw_id; + std::string codec = a.get("codec", is_delta ? "auto" : "full"); + if (!is_delta) o.codec = CODEC_FULL; + else if (codec == "sequential") o.codec = CODEC_DETOOLS_SEQUENTIAL; + else if (codec == "inplace") o.codec = CODEC_DETOOLS_INPLACE; + else if (codec == "full") { std::cerr << "a --base delta cannot use --codec full\n"; return 2; } + else { // auto + int c = infer_codec(hw); + if (c < 0) { std::cerr << "cannot infer codec from hw '" << hw << "' — pass --codec sequential|inplace\n"; return 2; } + o.codec = (uint8_t)c; + std::cerr << "note: codec auto-selected = " << (c == CODEC_DETOOLS_INPLACE ? "inplace" : "sequential") + << " (from hw '" << (hw.empty()?"?":hw) << "')\n"; + } + + std::vector blob; std::string name; + e = build(o, blob, name); + if (!e.empty()) { std::cerr << "error: " << e << "\n"; return 1; } + + // sanity: the tool's own output must verify + auto probs = verify(blob); + if (!probs.empty()) { + std::cerr << "internal error: built .mota fails verification:"; + for (auto& p : probs) std::cerr << " [" << p << "]"; + std::cerr << "\n"; return 1; + } + + std::string outpath; + if (a.has("out")) { // explicit output path (overrides --out-dir + auto-name) + outpath = a.get("out"); + fs::path parent = fs::path(outpath).parent_path(); + if (!parent.empty()) { std::error_code ec; fs::create_directories(parent, ec); } + } else { + std::string outdir = a.get("out-dir", "."); + std::error_code ec; fs::create_directories(outdir, ec); + outpath = (fs::path(outdir) / name).string(); + } + if (!write_file(outpath, blob.data(), blob.size())) { std::cerr << "cannot write " << outpath << "\n"; return 1; } + + Manifest m; parse(blob, m); + std::cout << "wrote " << outpath << "\n" + << " " << (m.is_full() ? "full" : (m.codec_id == CODEC_DETOOLS_INPLACE ? "in-place delta" : "sequential delta")) + << " target=" << [&]{char x[9];std::snprintf(x,9,"%08X",m.target_id);return std::string(x);}() + << " v" << version_str(m.fw_version) << " hw=" << (m.hw_id_str().empty()?"?":m.hw_id_str()) + << " " << (m.is_signed()?"signed":"unsigned") << "\n" + << " image=" << m.image_size << "B payload=" << m.payload_size << "B blocks=" << m.block_count + << " total=" << blob.size() << "B\n"; + return 0; +} + +static int cmd_keygen(const Args& a) { + if (a.has("help")) { help_keygen(); return 0; } + uint8_t priv[32], pub[32]; + if (!ed25519_keygen(priv, pub)) { std::cerr << "keygen failed\n"; return 1; } + std::string out = a.get("out", a.get("out-priv")); + std::string ph = to_hex(priv, 32), kh = to_hex(pub, 32); + if (!out.empty()) { + std::string pp = ph + "\n", kp = kh + "\n"; + if (!write_file(out, (const uint8_t*)pp.data(), pp.size()) || + !write_file(out + ".pub", (const uint8_t*)kp.data(), kp.size())) { std::cerr << "write failed\n"; return 1; } + std::cout << "private -> " << out << "\npublic -> " << out << ".pub\n"; + } + std::cout << "pubkey: " << kh << "\n"; + return 0; +} + +static int cmd_serve(const Args& a) { + if (a.has("help")) { help_serve(); return 0; } + if (!a.has("dir") || !a.has("serial")) { + std::cerr << "error: --dir and --serial are required\n\n"; help_serve(); return 2; + } + bool recursive = !a.has("no-recursive"); + bool verbose = a.has("verbose"); + Folder folder; + size_t n = folder.scan(a.get("dir"), recursive, + [](const std::string& p, const std::string& why) { + std::cerr << " ! skip " << p << " : " << why << "\n"; + }); + std::cout << "motatool serve: " << n << " valid .mota in " << a.get("dir") + << (recursive ? " (recursive)" : "") << "\n"; + for (const auto& s : folder.all()) { + std::cout << " - " << fs::path(s.path).filename().string() + << " : mid=" << to_hex(s.m.merkle_root.data(), 4) + << " target=" << hex8(s.m.target_id) << " [" << target_label(s.m.target_id) << "]" + << " v" << version_str(s.m.fw_version) + << " " << (s.m.is_full() ? "full" : (s.m.codec_id == CODEC_DETOOLS_INPLACE ? "ipdelta" : "seqdelta")) + << " " << (s.m.is_signed() ? "signed" : "unsigned") + << " blocks=" << s.m.block_count << " size=" << s.bytes.size() << "\n"; + } + if (n == 0) std::cerr << " (nothing valid to serve)\n"; + + SerialTransport t; + std::string e = t.open(a.get("serial"), std::atoi(a.get("baud", "115200").c_str())); + if (!e.empty()) { std::cerr << "error: " << e << "\n"; return 1; } + + std::signal(SIGINT, on_sigint); + bool enable = !a.has("no-enable"); + if (enable) { usleep(500000); t.write_str("ota folder on\r\n"); std::cout << "sent `ota folder on`\n"; } + std::cout << "serving on " << a.get("serial") << " @ " << a.get("baud", "115200") << " — Ctrl-C to stop\n"; + + SeederCore core(folder); + serve_serial(t, core, verbose, + [](const std::string& l) { std::cout << " [dev] " << l << "\n"; }, &g_stop); + + if (enable) { t.write_str("ota folder off\r\n"); usleep(200000); } + std::cout << "\nbye\n"; + return 0; +} + +int main(int argc, char** argv) { + if (argc < 2) { help_top(); return 2; } + std::string cmd = argv[1]; + if (cmd == "help" || cmd == "-h" || cmd == "--help") { help_top(); return 0; } + + std::vector bools = {"force","verbose","no-recursive","no-enable"}; + Args a = parse_args(argc, argv, 2, bools); + if (cmd == "build") return cmd_build(a); + if (cmd == "verify") return cmd_verify(a); + if (cmd == "inspect") return cmd_inspect(a); + if (cmd == "serve") return cmd_serve(a); + if (cmd == "keygen") return cmd_keygen(a); + std::cerr << "unknown command: " << cmd << "\n\n"; + help_top(); + return 2; +} diff --git a/tools/motatool/src/mota.cpp b/tools/motatool/src/mota.cpp new file mode 100644 index 0000000000..87478bb15f --- /dev/null +++ b/tools/motatool/src/mota.cpp @@ -0,0 +1,413 @@ +#include "mota.h" + +#include "OtaTargets.h" // shared with the firmware (generated): target_id -> env name +#include "crypto.h" +#include "util.h" + +#include +#include +#include +#include + +namespace mota { + +// ---- merkle (mirror of src/helpers/ota/MerkleTree.cpp) -------------------------------------------- +static void merkle_leaf(uint8_t out[4], const uint8_t *block, size_t len) { + sha256_trunc(out, 4, block, len); +} +static void merkle_combine(uint8_t out[4], const uint8_t *left, const uint8_t *right) { + uint8_t buf[8]; + std::memcpy(buf, left, 4); + std::memcpy(buf + 4, right, 4); + sha256_trunc(out, 4, buf, 8); +} +// Root via binary-counter / Merkle-Mountain-Range with right-to-left bagging (must stay byte-identical to +// src/helpers/ota/MerkleTree.cpp; the native tests cross-check both against the Python reference). +// peaks[k] holds the root of a complete 2^k-leaf subtree; adding a leaf "carries" upward like incrementing +// a binary counter, then the leftover peaks are bagged right-to-left into the final root. +static void merkle_root(uint8_t out[4], const uint8_t *leaves, uint32_t count) { + if (count == 0) { + std::memset(out, 0, 4); + return; + } + if (count == 1) { + std::memcpy(out, leaves, 4); + return; + } + uint8_t peaks[32][4]; + bool valid[32] = { false }; + for (uint32_t i = 0; i < count; i++) { + uint8_t cur[4]; + std::memcpy(cur, leaves + (size_t)i * 4, 4); + uint32_t level = 0; + while (valid[level]) { // carry: combine with the pending peak at this level + merkle_combine(cur, peaks[level], cur); // peak is earlier (left), cur is right + valid[level] = false; + level++; + } + std::memcpy(peaks[level], cur, 4); + valid[level] = true; + } + // bag peaks right-to-left: acc starts at the lowest set level (rightmost peak) + int level = 0; + while (level < 32 && !valid[level]) + level++; + uint8_t acc[4]; + std::memcpy(acc, peaks[level], 4); + for (int l = level + 1; l < 32; l++) + if (valid[l]) merkle_combine(acc, peaks[l], acc); // higher peak is left, acc is right + std::memcpy(out, acc, 4); +} +// leaves[] over the payload (last block short, no padding) +static std::vector leaf_hashes(const uint8_t *payload, uint32_t size, uint32_t bs) { + uint32_t bc = (size + bs - 1) / bs; + std::vector leaves(bc * 4); + for (uint32_t i = 0; i < bc; i++) { + uint32_t off = i * bs, blen = (off + bs <= size) ? bs : (size - off); + merkle_leaf(leaves.data() + (size_t)i * 4, payload + off, blen); + } + return leaves; +} + +std::string Manifest::hw_id_str() const { + size_t n = 0; + while (n < hw_id.size() && hw_id[n]) + n++; + return std::string((const char *)hw_id.data(), n); +} + +// ---- parse ----------------------------------------------------------------------------------------- +std::string parse(const std::vector &b, Manifest &m) { + m = Manifest(); + if (b.size() < 8 + MOTA_MFL + 5) return "too small for a .mota"; + if (std::memcmp(b.data(), MOTA_MAGIC, 4) != 0) return "bad MAGIC (not a .mota)"; + uint32_t total = rd_u32(b.data() + 4); + if (total != b.size()) return "MOTA_TOTAL_SIZE != file length"; + if (std::memcmp(b.data() + b.size() - 5, MOTA_TRAILER, 5) != 0) return "bad TRAILER"; + const uint8_t *mf = b.data() + 8; // manifest start + m.format_ver = mf[M_OFF_FORMAT_VER]; + if (m.format_ver != FORMAT_VER) return "unsupported format_ver"; + m.flags = mf[M_OFF_FLAGS]; + m.hash_algo = mf[M_OFF_HASH_ALGO]; + m.target_id = rd_u32(mf + M_OFF_TARGET_ID); + m.fw_version = rd_u32(mf + M_OFF_FW_VERSION); + m.image_size = rd_u32(mf + M_OFF_IMAGE_SIZE); + m.payload_size = rd_u32(mf + M_OFF_PAYLOAD_SIZE); + m.block_size_log2 = mf[M_OFF_BLOCK_SIZE_LOG2]; + std::memcpy(m.merkle_root.data(), mf + M_OFF_MERKLE_ROOT, 4); + std::memcpy(m.image_hash.data(), mf + M_OFF_IMAGE_HASH, 32); + m.codec_id = mf[M_OFF_CODEC_ID]; + std::memcpy(m.hw_id.data(), mf + M_OFF_HW_ID, 32); + std::memcpy(m.base_hash.data(), mf + M_OFF_BASE_HASH, 8); + std::memcpy(m.signer.data(), mf + M_OFF_SIGNER, 32); + std::memcpy(m.signature.data(), mf + M_OFF_SIGNATURE, 64); + std::memcpy(m.approval.data(), mf + M_OFF_APPROVAL, 4); + if (m.block_size_log2 == 0 || m.block_size_log2 > 24 || m.payload_size == 0) + return "bad block_size/payload"; + m.block_count = (m.payload_size + m.block_size() - 1) / m.block_size(); + if (m.block_count == 0 || m.block_count > 0xFFFFu) return "block_count out of range"; + if (m.total_size() != b.size()) return "geometry (leaves+payload) != file length"; + return ""; +} + +// ---- verify ---------------------------------------------------------------------------------------- +std::vector verify(const std::vector &b) { + std::vector probs; + Manifest m; + std::string e = parse(b, m); + if (!e.empty()) { + probs.push_back(e); + return probs; + } // unparseable: a single, fatal problem + + const uint8_t *leaves = b.data() + m.leaves_off(); + const uint8_t *payload = b.data() + m.payload_off(); + + // recompute leaves[] from the payload -> catches payload corruption + std::vector calc = leaf_hashes(payload, m.payload_size, m.block_size()); + if (calc.size() != m.block_count * 4u || std::memcmp(calc.data(), leaves, calc.size()) != 0) + probs.push_back("leaves[] do not match the payload (corruption)"); + + uint8_t root[4]; + merkle_root(root, leaves, m.block_count); + if (std::memcmp(root, m.merkle_root.data(), 4) != 0) probs.push_back("merkle_root mismatch"); + + if (m.is_full()) { + auto h = mh32(payload, m.payload_size); // full: payload IS the image + if (std::memcmp(h.data(), m.image_hash.data(), 32) != 0) + probs.push_back("image_hash mismatch (full image)"); + } + // (delta image_hash needs the base image -> not checked at relay time; payload integrity is covered above) + + if (m.is_signed()) { + if (!ed25519_verify(m.signature.data(), b.data() + 8, MOTA_SIGNED_LEN, m.signer.data())) + probs.push_back("Ed25519 signature INVALID"); + } + // a distributed .mota must not be pre-approved + if (std::memcmp(m.approval.data(), APPROVAL_YES, 4) == 0) + probs.push_back("container is pre-approved (must be FF FF FF FF on the wire)"); + return probs; +} + +// ---- EndF ----------------------------------------------------------------------------------------- +bool has_endf(const std::vector &img) { + if (img.size() < ENDF_LEN) return false; + const uint8_t *t = img.data() + img.size() - ENDF_LEN; + if (std::memcmp(t, ENDF_MAGIC, 4) != 0) return false; + if (rd_u32(t + 4) != img.size() - ENDF_LEN) return false; + auto h = mh8(img.data(), img.size() - ENDF_LEN); + return std::memcmp(h.data(), t + 8, 8) == 0; +} + +FwIdent parse_endf_ident(const std::vector &img) { + FwIdent id; + if (!has_endf(img)) return id; + const uint8_t *t = img.data() + img.size() - ENDF_LEN; + id.fw_version = rd_u32(t + ENDF_OFF_FWVER); + id.target_id = rd_u32(t + ENDF_OFF_TARGET); + size_t n = 0; + while (n < HW_ID_LEN && t[ENDF_OFF_HWID + n]) + n++; + id.hw_id.assign((const char *)t + ENDF_OFF_HWID, n); + return id; +} + +std::vector ensure_endf(const std::vector &img, const FwIdent &id, + std::array &body_hash8) { + if (has_endf(img)) { // already trailed: keep its identity, read its hash + const uint8_t *t = img.data() + img.size() - ENDF_LEN; + std::memcpy(body_hash8.data(), t + 8, 8); + return img; + } + body_hash8 = mh8(img.data(), img.size()); + std::vector out = img; + out.insert(out.end(), ENDF_MAGIC, ENDF_MAGIC + 4); + uint8_t u[4]; + wr_u32(u, (uint32_t)img.size()); + out.insert(out.end(), u, u + 4); + out.insert(out.end(), body_hash8.begin(), body_hash8.end()); + wr_u32(u, id.fw_version); + out.insert(out.end(), u, u + 4); + wr_u32(u, id.target_id); + out.insert(out.end(), u, u + 4); + uint8_t hw[HW_ID_LEN] = { 0 }; + std::memcpy(hw, id.hw_id.data(), id.hw_id.size() < HW_ID_LEN ? id.hw_id.size() : HW_ID_LEN); + out.insert(out.end(), hw, hw + HW_ID_LEN); + return out; +} + +uint32_t target_id_for_env(const std::string &env) { + auto h = mh4((const uint8_t *)env.data(), env.size()); + return rd_u32(h.data()); +} + +std::string target_env_name(uint32_t target_id) { + // Exhaustive target_id -> env-name table, shared verbatim with the firmware and generated from the live + // PlatformIO config (every ENABLE_OTA env) by tools/mota/gen_targets.py. See OtaTargets.h. + const char *env = mesh::ota::ota_target_env_name(target_id); + return env ? env : ""; +} + +bool pack_version(const std::string &s, uint32_t &out) { + uint32_t parts[4] = { 0, 0, 0, 0 }; + int n = 0; + bool any = false; + std::stringstream ss(s); + std::string tok; + while (std::getline(ss, tok, '.') && n < 4) { + if (tok.empty()) return false; + for (char c : tok) + if (c < '0' || c > '9') return false; + parts[n++] = (uint32_t)std::strtoul(tok.c_str(), nullptr, 10); + any = true; + } + if (!any) return false; + out = ((parts[0] & 0xFF) << 24) | ((parts[1] & 0xFF) << 16) | ((parts[2] & 0xFF) << 8) | (parts[3] & 0xFF); + return true; +} + +// ---- build ---------------------------------------------------------------------------------------- +// detools delta encode: write base/new images to temp files, run the detools CLI, read the patch back. +static std::string detools_delta(const std::string &detools, uint8_t codec, + const std::vector &base_img, const std::vector &new_img, + uint32_t mem, uint32_t seg, std::vector &patch) { + char fb[] = "/tmp/motaXXXXXX", fn[] = "/tmp/motaXXXXXX", fp[] = "/tmp/motaXXXXXX"; + int a = mkstemp(fb), c = mkstemp(fn), d = mkstemp(fp); + if (a < 0 || c < 0 || d < 0) return "mkstemp failed"; + close(a); + close(c); + close(d); + std::string err; + if (!write_file(fb, base_img.data(), base_img.size()) || !write_file(fn, new_img.data(), new_img.size())) { + err = "writing temp images failed"; + } else { + std::vector argv; + if (codec == CODEC_DETOOLS_SEQUENTIAL) + argv = { detools, "create_patch", "-c", "crle", "-t", "sequential", fb, fn, fp }; + else + argv = { detools, + "create_patch_in_place", + "-c", + "crle", + "--memory-size", + std::to_string(mem), + "--segment-size", + std::to_string(seg), + fb, + fn, + fp }; + int rc = run_argv(argv, /*quiet=*/true); // hide detools' success chatter; errors keep stderr + if (rc == 127) + err = "could not run detools (install it / pass --detools )"; + else if (rc != 0) + err = "detools exited with code " + std::to_string(rc); + else if (!read_file(fp, patch) || patch.empty()) + err = "reading detools patch failed"; + } + unlink(fb); + unlink(fn); + unlink(fp); + return err; +} + +std::string detools_apply_seq(const std::string &detools, const std::vector &base_img, + const std::vector &patch, std::vector &out) { + char ff[] = "/tmp/motaXXXXXX", fpp[] = "/tmp/motaXXXXXX", ft[] = "/tmp/motaXXXXXX"; + int a = mkstemp(ff), c = mkstemp(fpp), d = mkstemp(ft); + if (a < 0 || c < 0 || d < 0) return "mkstemp failed"; + close(a); + close(c); + close(d); + std::string err; + if (!write_file(ff, base_img.data(), base_img.size()) || !write_file(fpp, patch.data(), patch.size())) { + err = "writing temp files failed"; + } else { + int rc = run_argv({ detools, "apply_patch", ff, fpp, ft }, /*quiet=*/true); // + if (rc == 127) + err = "could not run detools (install it / pass --detools )"; + else if (rc != 0) + err = "detools apply_patch exited with code " + std::to_string(rc); + else if (!read_file(ft, out) || out.empty()) + err = "reading reconstructed image failed"; + } + unlink(ff); + unlink(fpp); + unlink(ft); + return err; +} + +std::string build(const BuildOpts &o, std::vector &out, std::string &suggested_name) { + bool is_delta = !o.base.empty(); + + // resolve identity: explicit flags override the firmware's self-describing EndF + FwIdent fid = parse_endf_ident(o.fw); + uint32_t target = o.have_target ? o.target_id : fid.target_id; + uint32_t fwver = o.have_fwver ? o.fw_version : fid.fw_version; + std::string hw = !o.hw_id.empty() ? o.hw_id : fid.hw_id; + FwIdent ident{ fwver, target, hw }; + + std::array new_bh{}, base_bh{}; + std::vector new_img = ensure_endf(o.fw, ident, new_bh); + + uint8_t codec = o.codec; + std::vector payload; + std::array base_hash{}; + + if (!is_delta) { + codec = CODEC_FULL; + payload = new_img; // full: payload IS the image + } else { + if (codec == CODEC_FULL) return "a base image was given but --codec is full"; + FwIdent base_id = parse_endf_ident(o.base); + std::vector base_img = ensure_endf(o.base, base_id, base_bh); + base_hash = base_bh; + // cross-hardware delta guard (read from EndF identity, not filenames) + if (!o.force && base_id.any() && fid.any()) { + bool hw_ok = base_id.hw_id.empty() || fid.hw_id.empty() || base_id.hw_id == fid.hw_id; + bool tgt_ok = !base_id.target_id || !fid.target_id || base_id.target_id == fid.target_id; + if (!hw_ok || !tgt_ok) + return "base/target firmware identity differ (hw '" + base_id.hw_id + "' vs '" + fid.hw_id + + "') — refusing cross-hardware delta (use --force to override)"; + } + std::string e = + detools_delta(o.detools, codec, base_img, new_img, o.inplace_memory, o.inplace_segment, payload); + if (!e.empty()) return e; + } + + uint32_t bs = o.block_size, image_size = (uint32_t)new_img.size(); + std::vector leaves = leaf_hashes(payload.data(), (uint32_t)payload.size(), bs); + uint32_t bc = (uint32_t)leaves.size() / 4; + if (bc == 0 || bc > 0xFFFFu) return "payload yields an invalid block count"; + uint8_t root[4]; + merkle_root(root, leaves.data(), bc); + auto image_hash = mh32(new_img.data(), new_img.size()); + + bool signed_ = !o.sign_priv.empty(); + uint8_t flags = (is_delta ? 0 : MFLAG_FULL) | (signed_ ? MFLAG_SIGNED : 0); + + // assemble the fixed 197-byte manifest-minus-leaves + std::vector mf(MOTA_MFL, 0); + mf[M_OFF_FORMAT_VER] = FORMAT_VER; + mf[M_OFF_FLAGS] = flags; + mf[M_OFF_HASH_ALGO] = HASH_ALGO_SHA256; + wr_u32(mf.data() + M_OFF_TARGET_ID, target); + wr_u32(mf.data() + M_OFF_FW_VERSION, fwver); + wr_u32(mf.data() + M_OFF_IMAGE_SIZE, image_size); + wr_u32(mf.data() + M_OFF_PAYLOAD_SIZE, (uint32_t)payload.size()); + // block_size_log2 + { + uint32_t v = bs, l = 0; + while (v > 1) { + v >>= 1; + l++; + } + mf[M_OFF_BLOCK_SIZE_LOG2] = (uint8_t)l; + } + std::memcpy(mf.data() + M_OFF_MERKLE_ROOT, root, 4); + std::memcpy(mf.data() + M_OFF_IMAGE_HASH, image_hash.data(), 32); + mf[M_OFF_CODEC_ID] = codec; + std::memcpy(mf.data() + M_OFF_HW_ID, hw.data(), hw.size() < HW_ID_LEN ? hw.size() : HW_ID_LEN); + if (is_delta) std::memcpy(mf.data() + M_OFF_BASE_HASH, base_hash.data(), 8); // zero for full + if (signed_) { + uint8_t pub[32]; + if (o.sign_priv.size() != 32) return "signing key must be a 32-byte raw private seed"; + if (!ed25519_pub_from_priv(pub, o.sign_priv.data())) return "bad signing key"; + std::memcpy(mf.data() + M_OFF_SIGNER, pub, 32); + uint8_t sig[64]; + if (!ed25519_sign(sig, mf.data(), MOTA_SIGNED_LEN, o.sign_priv.data())) return "signing failed"; + std::memcpy(mf.data() + M_OFF_SIGNATURE, sig, 64); + } + std::memcpy(mf.data() + M_OFF_APPROVAL, APPROVAL_NOT, 4); + + // container = MAGIC(4) total(4) manifest leaves[] payload trailer(5) + uint32_t total = 8 + MOTA_MFL + bc * 4 + (uint32_t)payload.size() + 5; + // nRF52 in-place: the staged container sits below the apply workspace; if it's too big it overruns the + // workspace and the bootloader apply fails (DETOOLS_IO_FAILED). Warn so it's caught before shipping. + if (is_delta && codec == CODEC_DETOOLS_INPLACE && total > NRF52_MAX_INPLACE_MOTA) + std::fprintf(stderr, "warning: in-place delta is %u B, exceeding the nRF52 staging room (%u B) — it will " + "NOT apply on the device. Shrink the delta (smaller change) or use a smaller image.\n", + total, NRF52_MAX_INPLACE_MOTA); + out.clear(); + out.reserve(total); + out.insert(out.end(), MOTA_MAGIC, MOTA_MAGIC + 4); + uint8_t u[4]; + wr_u32(u, total); + out.insert(out.end(), u, u + 4); + out.insert(out.end(), mf.begin(), mf.end()); + out.insert(out.end(), leaves.begin(), leaves.end()); + out.insert(out.end(), payload.begin(), payload.end()); + out.insert(out.end(), MOTA_TRAILER, MOTA_TRAILER + 5); + + // suggested name: __v__.mota (descriptive + unique, one folder) + const char *kind = !is_delta ? "full" : (codec == CODEC_DETOOLS_INPLACE ? "ipdelta" : "seqdelta"); + char tgt[9]; + std::snprintf(tgt, sizeof(tgt), "%08X", target); + char vbuf[24]; + std::snprintf(vbuf, sizeof(vbuf), "%u.%u.%u", (fwver >> 24) & 0xFF, (fwver >> 16) & 0xFF, + (fwver >> 8) & 0xFF); + suggested_name = (hw.empty() ? std::string("fw") : hw) + "_" + tgt + "_v" + vbuf + "_" + kind + "_" + + to_hex(root, 4) + ".mota"; + return ""; +} + +} // namespace mota diff --git a/tools/motatool/src/mota.h b/tools/motatool/src/mota.h new file mode 100644 index 0000000000..e3024027c4 --- /dev/null +++ b/tools/motatool/src/mota.h @@ -0,0 +1,83 @@ +// `.mota` container: parse, integrity-verify, and build (full / detools-delta). EndF identity helpers. +// Mirrors tools/mota/motalib.py and src/helpers/ota/MotaContainer.cpp (the single source of truth). +#pragma once +#include "mota_format.h" +#include +#include +#include + +namespace mota { + +struct FwIdent { + uint32_t fw_version = 0; + uint32_t target_id = 0; + std::string hw_id; // NUL-trimmed + bool any() const { return fw_version || target_id || !hw_id.empty(); } +}; + +struct Manifest { + uint8_t format_ver = 0, flags = 0, hash_algo = 0, codec_id = 0, block_size_log2 = 0; + uint32_t target_id = 0, fw_version = 0, image_size = 0, payload_size = 0, block_count = 0; + std::array merkle_root{}; + std::array image_hash{}; + std::array hw_id{}; // raw 32 bytes (NUL-padded) + std::array base_hash{}; + std::array signer{}; + std::array signature{}; + std::array approval{}; + + bool is_full() const { return flags & MFLAG_FULL; } + bool is_signed() const { return flags & MFLAG_SIGNED; } + uint32_t block_size() const { return 1u << block_size_log2; } + uint32_t leaves_off() const { return 8 + MOTA_MFL; } + uint32_t payload_off() const { return leaves_off() + block_count * 4; } + uint32_t total_size() const { return payload_off() + payload_size + 5; } + std::string hw_id_str() const; +}; + +// Parse + validate framing and the fixed layout. Returns "" on success (fills `out`), else an error. +std::string parse(const std::vector& blob, Manifest& out); + +// Content-integrity check: recompute leaves[] from the payload vs merkle_root, the merkle root, the +// FULL image_hash, and (if signed) the Ed25519 signature against the embedded signer key. Returns a list +// of problems (empty => valid). A delta's image_hash is not checked here (it needs the base image). +std::vector verify(const std::vector& blob); + +// ---- EndF identity (fixed 56-byte trailer) ---- +bool has_endf(const std::vector& image); +FwIdent parse_endf_ident(const std::vector& image); // zeros if no EndF +// Append a 56-byte EndF (with identity) if absent; returns the image and sets body_hash8. Idempotent. +std::vector ensure_endf(const std::vector& image, const FwIdent& id, + std::array& body_hash8); + +uint32_t target_id_for_env(const std::string& env); // sha2-256:4(env) as LE uint32 +bool pack_version(const std::string& s, uint32_t& out); // "1.16.0[.pre]" -> packed uint32 + +// Reverse-lookup a target_id to its PlatformIO env name from a static table of known OTA-capable envs +// (target_id = sha2-256:4(env_name)). Returns "" if not in the table. +std::string target_env_name(uint32_t target_id); + +// ---- build ---- +struct BuildOpts { + std::vector fw; // NEW firmware (raw or already-EndF'd) + std::vector base; // base image for a delta (empty => full) + uint8_t codec = CODEC_FULL; // CODEC_FULL / _SEQUENTIAL / _INPLACE + bool have_target = false; uint32_t target_id = 0; // overrides EndF + bool have_fwver = false; uint32_t fw_version = 0; // overrides EndF + std::string hw_id; // override; else from EndF + std::vector sign_priv; // 32-byte raw private seed (empty => unsigned) + uint32_t block_size = DEFAULT_BLOCK_SIZE; + uint32_t inplace_memory = NRF52_INPLACE_MEMORY, inplace_segment = NRF52_INPLACE_SEGMENT; + std::string detools = "detools"; // detools CLI path (delta encoding only) + bool force = false; // override the cross-hardware delta guard +}; +// Returns "" + fills `out` and a suggested file name on success; else an error string. +std::string build(const BuildOpts& o, std::vector& out, std::string& suggested_name); + +// Apply a SEQUENTIAL delta `patch` to `base_img` via the detools CLI -> reconstructed image in `out`. +// Used by `verify --base` to confirm a delta actually rebuilds the expected image (matches mota.py: +// in-place deltas aren't apply-checked here — the bootloader host-harness covers that path). +std::string detools_apply_seq(const std::string& detools, const std::vector& base_img, + const std::vector& patch, std::vector& out); + +} // namespace mota diff --git a/tools/motatool/src/mota_format.h b/tools/motatool/src/mota_format.h new file mode 100644 index 0000000000..d26ac64e9e --- /dev/null +++ b/tools/motatool/src/mota_format.h @@ -0,0 +1,100 @@ +// MeshCore `.mota` on-wire constants and fixed layout — a C++-friendly mirror of +// src/helpers/ota/OtaFormat.h. Keep byte-identical with that file and docs/ota_protocol.md. +// +// The format is FIXED-LAYOUT: every manifest field is at a constant offset and always present +// (base_hash/signer_pubkey/signature are zero-filled when not applicable). Only leaves[] varies. +#pragma once +#include +#include + +namespace mota { + +// container framing +static constexpr uint8_t MOTA_MAGIC[4] = {'m','O','T','A'}; +static constexpr uint8_t MOTA_TRAILER[5] = {'v','k','4','9','6'}; +static constexpr uint8_t ENDF_MAGIC[4] = {'E','n','d','F'}; + +static constexpr uint8_t HASH_ALGO_SHA256 = 0x12; +static constexpr uint8_t FORMAT_VER = 0x02; + +static constexpr uint8_t MFLAG_FULL = 0x01; +static constexpr uint8_t MFLAG_SIGNED = 0x02; + +static constexpr uint8_t CODEC_FULL = 0; +static constexpr uint8_t CODEC_DETOOLS_SEQUENTIAL = 1; // ESP32 A/B +static constexpr uint8_t CODEC_DETOOLS_INPLACE = 2; // nRF52 single-slot + +// hash truncations (sha2-256:N = first N bytes of the SHA-256 digest) +static constexpr size_t MH4 = 4, MH8 = 8, MH32 = 32; + +static constexpr uint8_t APPROVAL_NOT[4] = {0xFF,0xFF,0xFF,0xFF}; +static constexpr uint8_t APPROVAL_YES[4] = {'A','P','R','V'}; + +// EndF trailer (fixed 56 bytes): marker(4) body_len(4) body_hash8(8) fw_version(4) target_id(4) hw_id(32) +static constexpr uint32_t ENDF_LEN = 56; +static constexpr uint32_t ENDF_OFF_FWVER = 16; +static constexpr uint32_t ENDF_OFF_TARGET = 20; +static constexpr uint32_t ENDF_OFF_HWID = 24; + +static constexpr uint8_t HW_ID_LEN = 32; + +// manifest fixed offsets (within the manifest, i.e. container offset = 8 + these) +static constexpr uint32_t M_OFF_FORMAT_VER = 0; +static constexpr uint32_t M_OFF_FLAGS = 1; +static constexpr uint32_t M_OFF_HASH_ALGO = 2; +static constexpr uint32_t M_OFF_TARGET_ID = 3; +static constexpr uint32_t M_OFF_FW_VERSION = 7; +static constexpr uint32_t M_OFF_IMAGE_SIZE = 11; +static constexpr uint32_t M_OFF_PAYLOAD_SIZE = 15; +static constexpr uint32_t M_OFF_BLOCK_SIZE_LOG2 = 19; +static constexpr uint32_t M_OFF_MERKLE_ROOT = 20; // 4 +static constexpr uint32_t M_OFF_IMAGE_HASH = 24; // 32 +static constexpr uint32_t M_OFF_CODEC_ID = 56; // 1 +static constexpr uint32_t M_OFF_HW_ID = 57; // 32 +static constexpr uint32_t M_OFF_BASE_HASH = 89; // 8 (zero if FULL) +static constexpr uint32_t M_OFF_SIGNER = 97; // 32 (zero if unsigned) +static constexpr uint32_t M_OFF_SIGNATURE = 129; // 64 (zero if unsigned) +static constexpr uint32_t M_OFF_APPROVAL = 193; // 4 +static constexpr uint32_t MOTA_MFL = 197; // manifest-minus-leaves length (constant) +static constexpr uint32_t MOTA_SIGNED_LEN = 129; // signature covers manifest[0, 129) + +static constexpr uint32_t DEFAULT_BLOCK_SIZE = 1024; +static constexpr uint8_t DEFAULT_BLOCK_SIZE_LOG2 = 10; + +// nRF52 in-place apply workspace — MUST match src/helpers/ota/OtaFlashLayout_nrf52.h +// (MOTA_NRF52_INPLACE_MEMORY). It is NOT the full [APP_BASE, FS_START) span: the staged .mota itself sits +// just below FS_START, so the bootloader's workspace ends at the staged container (ws_hi = mota_addr), not +// at FS_START. The workspace is [APP_BASE, 0xBE000) = 0x98000, leaving 0xBE000..0xD4000 (~88 KB) for the +// staged delta. A patch built with a larger memory_size overruns the workspace at apply -> DETOOLS_IO_FAILED +// (the apply silently fails and the device just reboots). Reproduced + verified by the bootloader apply +// simulation (Adafruit_nRF52_Bootloader_OTAFIX/test/apply_sim). +static constexpr uint32_t NRF52_INPLACE_MEMORY = 0x00098000u; // 608 KB: [APP_BASE, 0xBE000) +static constexpr uint32_t NRF52_INPLACE_SEGMENT = 4096; +// The staged .mota sits in [APP_BASE+memory, FS_START); a larger container would push its start below the +// workspace end and break the apply the same way. Warn (motatool) / fail (bootloader) past this. +static constexpr uint32_t NRF52_FLASH_SPAN = 0x000D4000u - 0x00026000u; // 0xAE000 +static constexpr uint32_t NRF52_MAX_INPLACE_MOTA = NRF52_FLASH_SPAN - NRF52_INPLACE_MEMORY; // 0x16000 (~90 KB) +static_assert(NRF52_INPLACE_MEMORY < NRF52_FLASH_SPAN, "in-place workspace must leave room below FS_START for the staged .mota"); + +// ---- mota-seeder transport protocol (mirror of src/helpers/ota/MotaSeederProto.h) ---- +// Request (client -> server): 'M' 'S' op(1) args... xsum(1 = XOR of op+args) +// Response (server -> client): 'm' 's' op(1) status(1) payload... xsum(1 = XOR of all prior) +static constexpr uint8_t MS_REQ_MAGIC0 = 'M', MS_REQ_MAGIC1 = 'S'; +static constexpr uint8_t MS_RSP_MAGIC0 = 'm', MS_RSP_MAGIC1 = 's'; +static constexpr uint8_t MS_OP_COUNT = 0x01; // -> count(1) +static constexpr uint8_t MS_OP_DESCRIBE = 0x02; // idx(1) -> MotaDesc(38) +static constexpr uint8_t MS_OP_READ = 0x03; // idx(1) off(4) len(2) -> bytes +static constexpr uint8_t MS_STATUS_OK = 0x00; +static constexpr uint8_t MS_STATUS_ERR = 0x01; +static constexpr uint16_t MOTA_DESC_WIRE = 38; +// 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) [+2 reserved] + +inline uint32_t rd_u32(const uint8_t* p) { + return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24); +} +inline void wr_u32(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); +} + +} // namespace mota diff --git a/tools/motatool/src/serve.cpp b/tools/motatool/src/serve.cpp new file mode 100644 index 0000000000..5812f0a9d7 --- /dev/null +++ b/tools/motatool/src/serve.cpp @@ -0,0 +1,209 @@ +#include "serve.h" +#include +#include +#include +#include +#include +#include +#include "util.h" + +namespace fs = std::filesystem; + +namespace mota { + +// ---- Folder: recursive scan + per-file validation ------------------------------------------------- +size_t Folder::scan(const std::string& dir, bool recursive, + const std::function& warn) { + motas_.clear(); + std::error_code ec; + auto consider = [&](const fs::path& p) { + if (p.extension() != ".mota") return; // skip non-.mota files + ServedMota sm; sm.path = p.string(); + if (!read_file(sm.path, sm.bytes)) { warn(sm.path, "cannot read"); return; } + auto probs = verify(sm.bytes); // merkle + image_hash + signature + if (!probs.empty()) { + std::string why = probs[0]; + for (size_t i = 1; i < probs.size(); i++) why += "; " + probs[i]; + warn(sm.path, why); // warn + exclude (don't sink the rest) + return; + } + parse(sm.bytes, sm.m); // already validated above + motas_.push_back(std::move(sm)); + }; + if (recursive) { + for (auto it = fs::recursive_directory_iterator(dir, ec); + !ec && it != fs::recursive_directory_iterator(); it.increment(ec)) + if (it->is_regular_file(ec)) consider(it->path()); + } else { + for (auto it = fs::directory_iterator(dir, ec); + !ec && it != fs::directory_iterator(); it.increment(ec)) + if (it->is_regular_file(ec)) consider(it->path()); + } + // stable, deterministic catalog order (indices are how the device addresses motas) + std::sort(motas_.begin(), motas_.end(), + [](const ServedMota& a, const ServedMota& b) { return a.path < b.path; }); + return motas_.size(); +} + +// ---- SeederCore (transport-agnostic) -------------------------------------------------------------- +std::array SeederCore::describe(const ServedMota& s) { + std::array w{}; + std::memcpy(w.data(), s.m.merkle_root.data(), 4); // mid + wr_u32(w.data() + 4, s.m.target_id); + wr_u32(w.data() + 8, s.m.fw_version); + w[12] = s.m.codec_id; + w[13] = s.m.flags; + wr_u32(w.data() + 14, (uint32_t)s.bytes.size()); // total_size + wr_u32(w.data() + 18, s.m.leaves_off()); + wr_u32(w.data() + 22, s.m.block_count); + wr_u32(w.data() + 26, s.m.payload_off()); + wr_u32(w.data() + 30, s.m.payload_size); + // [34..38) reserved 0 + return w; +} + +bool SeederCore::handle(uint8_t op, const uint8_t* args, size_t arglen, + uint8_t& status, std::vector& payload) const { + payload.clear(); + status = MS_STATUS_OK; + if (op == MS_OP_COUNT) { + payload.push_back((uint8_t)(folder_.count() > 255 ? 255 : folder_.count())); + return true; + } + if (op == MS_OP_DESCRIBE) { + if (arglen < 1) return false; + const ServedMota* s = folder_.at(args[0]); + if (!s) { status = MS_STATUS_ERR; return true; } + auto w = describe(*s); + payload.assign(w.begin(), w.end()); + return true; + } + if (op == MS_OP_READ) { + if (arglen < 7) return false; + const ServedMota* s = folder_.at(args[0]); + uint32_t off = rd_u32(args + 1); + uint16_t len = (uint16_t)(args[5] | (args[6] << 8)); + if (!s || (uint64_t)off + len > s->bytes.size()) { status = MS_STATUS_ERR; return true; } + payload.assign(s->bytes.begin() + off, s->bytes.begin() + off + len); + return true; + } + return false; // unknown op -> ignore (device retries) +} + +// ---- SerialTransport ------------------------------------------------------------------------------ +static speed_t baud_const(int b) { + switch (b) { + case 9600: return B9600; case 19200: return B19200; case 38400: return B38400; + case 57600: return B57600; case 115200: return B115200; case 230400: return B230400; + case 460800: return B460800; case 921600: return B921600; default: return B115200; + } +} + +std::string SerialTransport::open(const std::string& dev, int baud) { + fd_ = ::open(dev.c_str(), O_RDWR | O_NOCTTY | O_NONBLOCK); + if (fd_ < 0) return "cannot open serial device: " + dev; + termios t{}; + if (tcgetattr(fd_, &t) != 0) { ::close(fd_); fd_ = -1; return "tcgetattr failed"; } + cfmakeraw(&t); + speed_t s = baud_const(baud); + cfsetispeed(&t, s); cfsetospeed(&t, s); + t.c_cflag |= (CLOCAL | CREAD); + t.c_cflag &= ~CRTSCTS; // no flow control (matches the daemon) + t.c_cc[VMIN] = 0; t.c_cc[VTIME] = 0; + if (tcsetattr(fd_, TCSANOW, &t) != 0) { ::close(fd_); fd_ = -1; return "tcsetattr failed"; } + return ""; +} + +SerialTransport::~SerialTransport() { if (fd_ >= 0) ::close(fd_); } + +int SerialTransport::read_byte(int timeout_ms) { + if (fd_ < 0) return -1; + fd_set rs; FD_ZERO(&rs); FD_SET(fd_, &rs); + timeval tv{ timeout_ms / 1000, (timeout_ms % 1000) * 1000 }; + int r = select(fd_ + 1, &rs, nullptr, nullptr, &tv); + if (r <= 0) return -1; + uint8_t b; + ssize_t n = ::read(fd_, &b, 1); + return n == 1 ? (int)b : -1; +} + +bool SerialTransport::write(const uint8_t* p, size_t n) { + if (fd_ < 0) return false; + size_t off = 0; + while (off < n) { + ssize_t w = ::write(fd_, p + off, n - off); + if (w < 0) return false; + off += (size_t)w; + } + return true; +} + +// ---- serial framing loop -------------------------------------------------------------------------- +static uint8_t xor_bytes(const uint8_t* p, size_t n, uint8_t seed = 0) { + uint8_t x = seed; for (size_t i = 0; i < n; i++) x ^= p[i]; return x; +} + +static bool read_exact(Transport& t, uint8_t* buf, size_t n) { + for (size_t i = 0; i < n; i++) { + int b = t.read_byte(500); + if (b < 0) return false; + buf[i] = (uint8_t)b; + } + return true; +} + +static void send_response(Transport& t, uint8_t op, uint8_t status, const std::vector& payload) { + std::vector frame; + frame.reserve(4 + payload.size() + 1); + frame.push_back(MS_RSP_MAGIC0); frame.push_back(MS_RSP_MAGIC1); + frame.push_back(op); frame.push_back(status); + frame.insert(frame.end(), payload.begin(), payload.end()); + frame.push_back(xor_bytes(frame.data(), frame.size())); // xsum over all prior bytes (incl. magic) + t.write(frame.data(), frame.size()); +} + +void serve_serial(Transport& t, const SeederCore& core, bool verbose, + const std::function& devline, + const volatile bool* stop) { + std::string line; + int prev = -1; + auto flush_line = [&]() { + while (!line.empty() && (line.back() == '\r' || line.back() == '\n')) line.pop_back(); + if (!line.empty() && devline) devline(line); + line.clear(); + }; + while (!stop || !*stop) { + int b = t.read_byte(200); + if (b < 0) continue; + if (prev == MS_REQ_MAGIC0 && b == MS_REQ_MAGIC1) { // 'M''S' -> a request follows + prev = -1; + uint8_t op; + if (!read_exact(t, &op, 1)) continue; + size_t arglen = (op == MS_OP_DESCRIBE) ? 1 : (op == MS_OP_READ ? 7 : (op == MS_OP_COUNT ? 0 : SIZE_MAX)); + if (arglen == SIZE_MAX) continue; // unknown op + uint8_t args[7]; + if (arglen && !read_exact(t, args, arglen)) continue; + uint8_t xs; + if (!read_exact(t, &xs, 1)) continue; + if (xs != xor_bytes(args, arglen, op)) continue; // bad checksum -> ignore; device retries + uint8_t status; std::vector payload; + if (!core.handle(op, args, arglen, status, payload)) continue; + send_response(t, op, status, payload); + if (verbose) { + if (op == MS_OP_COUNT) devline("COUNT -> " + std::to_string(payload.empty() ? 0 : payload[0])); + else if (op == MS_OP_DESCRIBE) devline("DESCRIBE " + std::to_string(args[0]) + (status ? " ERR" : " OK")); + else if (op == MS_OP_READ) devline("READ " + std::to_string(args[0]) + " @" + + std::to_string(rd_u32(args + 1)) + (status ? " ERR" : " OK")); + } + continue; + } + if (prev >= 0) { // confirmed device text (not a frame start) + line.push_back((char)prev); + if (prev == '\n') flush_line(); + if (line.size() > 512) flush_line(); // guard runaway lines + } + prev = b; + } +} + +} // namespace mota diff --git a/tools/motatool/src/serve.h b/tools/motatool/src/serve.h new file mode 100644 index 0000000000..7bff708e70 --- /dev/null +++ b/tools/motatool/src/serve.h @@ -0,0 +1,68 @@ +// Serve a folder of .mota to a MeshCore node. Split into a transport-AGNOSTIC protocol core (reusable +// over USB-serial today and BLE/GATT in the future) and a serial byte-stream framing layer. +#pragma once +#include "mota.h" +#include "mota_format.h" +#include +#include +#include +#include + +namespace mota { + +struct ServedMota { + std::string path; + std::vector bytes; + Manifest m; +}; + +// Recursively collect every *.mota under `dir`, validate each, keep only the valid ones. Corrupt/invalid +// files are reported through `warn(path, reason)` and excluded — one bad file never sinks the rest. +class Folder { +public: + size_t scan(const std::string& dir, bool recursive, + const std::function& warn); + size_t count() const { return motas_.size(); } + const ServedMota* at(size_t i) const { return i < motas_.size() ? &motas_[i] : nullptr; } + const std::vector& all() const { return motas_; } +private: + std::vector motas_; +}; + +// Transport-agnostic seeder: turns a (op, args) request into a (status, payload) response. The BLE path +// would call this directly from a characteristic-write handler and notify the reply — no framing needed. +class SeederCore { +public: + explicit SeederCore(const Folder& f) : folder_(f) {} + bool handle(uint8_t op, const uint8_t* args, size_t arglen, + uint8_t& status, std::vector& payload) const; + static std::array describe(const ServedMota& s); +private: + const Folder& folder_; +}; + +// A bidirectional byte link (the serial seeder needs this; BLE would reuse SeederCore directly). +struct Transport { + virtual ~Transport() {} + virtual int read_byte(int timeout_ms) = 0; // a byte 0..255, or -1 on timeout/closed + virtual bool write(const uint8_t* p, size_t n) = 0; + bool write_str(const std::string& s) { return write((const uint8_t*)s.data(), s.size()); } +}; + +class SerialTransport : public Transport { +public: + std::string open(const std::string& dev, int baud); // "" on success + ~SerialTransport() override; + int read_byte(int timeout_ms) override; + bool write(const uint8_t* p, size_t n) override; +private: + int fd_ = -1; +}; + +// Serial framing loop: resync on 'M''S', verify the request checksum, dispatch to `core`, frame the +// reply. Device text/log lines sharing the wire are surfaced via `devline`. Runs until *stop becomes true. +void serve_serial(Transport& t, const SeederCore& core, bool verbose, + const std::function& devline, + const volatile bool* stop); + +} // namespace mota diff --git a/tools/motatool/src/util.h b/tools/motatool/src/util.h new file mode 100644 index 0000000000..9e5e51af48 --- /dev/null +++ b/tools/motatool/src/util.h @@ -0,0 +1,77 @@ +// Small host-side helpers: file I/O, subprocess exec (no shell), hex. Header-only. +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include + +namespace mota { + +inline bool read_file(const std::string& path, std::vector& out) { + std::ifstream f(path, std::ios::binary); + if (!f) return false; + out.assign(std::istreambuf_iterator(f), std::istreambuf_iterator()); + return (bool)f || f.eof(); +} + +inline bool write_file(const std::string& path, const uint8_t* data, size_t len) { + std::ofstream f(path, std::ios::binary | std::ios::trunc); + if (!f) return false; + f.write((const char*)data, (std::streamsize)len); + return (bool)f; +} + +// Run argv[0] with argv (no shell, so no quoting pitfalls). Returns the child exit code, or -1 to spawn. +// `quiet` redirects the child's stdout to /dev/null (its chatter), keeping stderr so errors still show. +inline int run_argv(const std::vector& argv, bool quiet = false) { + if (argv.empty()) return -1; + pid_t pid = fork(); + if (pid < 0) return -1; + if (pid == 0) { + if (quiet) { + int n = ::open("/dev/null", O_WRONLY); + if (n >= 0) { dup2(n, 1); if (n > 2) ::close(n); } + } + std::vector a; + for (auto& s : argv) a.push_back(const_cast(s.c_str())); + a.push_back(nullptr); + execvp(a[0], a.data()); + _exit(127); // exec failed + } + int st = 0; + if (waitpid(pid, &st, 0) < 0) return -1; + return WIFEXITED(st) ? WEXITSTATUS(st) : -1; +} + +inline std::string to_hex(const uint8_t* p, size_t n) { + static const char* H = "0123456789ABCDEF"; + std::string s; s.reserve(n * 2); + for (size_t i = 0; i < n; i++) { s.push_back(H[p[i] >> 4]); s.push_back(H[p[i] & 0xF]); } + return s; +} + +// Parse a hex string (optionally 0x-prefixed) into bytes. Returns false on odd length / bad char. +inline bool from_hex(const std::string& in, std::vector& out) { + std::string s = in; + if (s.size() >= 2 && s[0] == '0' && (s[1] == 'x' || s[1] == 'X')) s = s.substr(2); + if (s.size() % 2) return false; + out.clear(); + auto nib = [](char c) -> int { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; + }; + for (size_t i = 0; i < s.size(); i += 2) { + int hi = nib(s[i]), lo = nib(s[i + 1]); + if (hi < 0 || lo < 0) return false; + out.push_back((uint8_t)((hi << 4) | lo)); + } + return true; +} + +} // namespace mota diff --git a/tools/motatool/tests/test_motatool.cpp b/tools/motatool/tests/test_motatool.cpp new file mode 100644 index 0000000000..87b7bd9701 --- /dev/null +++ b/tools/motatool/tests/test_motatool.cpp @@ -0,0 +1,302 @@ +// Unit tests for motatool's core logic (no external framework — a tiny built-in harness keeps the +// project self-contained). Run: ./build/motatool_tests or ctest --test-dir build --output-on-failure +#include "mota.h" +#include "crypto.h" +#include "serve.h" +#include "input.h" +#include "util.h" +#include "mota_format.h" +#include +#include +#include +#include + +namespace fs = std::filesystem; +using namespace mota; + +static int g_checks = 0, g_fail = 0; +static const char* g_test = ""; +#define CHECK(cond) do { g_checks++; if (!(cond)) { g_fail++; \ + std::cerr << " FAIL [" << g_test << "] " << __LINE__ << ": " #cond "\n"; } } while (0) + +// deterministic synthetic firmware body +static std::vector body(unsigned seed, size_t n) { + std::vector v(n); + uint32_t s = seed * 2654435761u + 1; + for (size_t i = 0; i < n; i++) { s = s * 1103515245u + 12345u; v[i] = (uint8_t)(s >> 16); } + return v; +} + +static bool detools_available() { + return run_argv({"detools", "--help"}, /*quiet=*/true) != 127; // 127 = exec failed (not installed) +} + +// --------------------------------------------------------------------------- +static void t_crypto() { + g_test = "crypto"; + // sha256("abc") = ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad + const char* abc = "abc"; + auto h4 = mh4((const uint8_t*)abc, 3); + auto h8 = mh8((const uint8_t*)abc, 3); + auto h32 = mh32((const uint8_t*)abc, 3); + std::vector exp; + from_hex("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", exp); + CHECK(std::memcmp(h4.data(), exp.data(), 4) == 0); + CHECK(std::memcmp(h8.data(), exp.data(), 8) == 0); + CHECK(std::memcmp(h32.data(), exp.data(), 32) == 0); + + uint8_t priv[32], pub[32], pub2[32]; + CHECK(ed25519_keygen(priv, pub)); + CHECK(ed25519_pub_from_priv(pub2, priv)); + CHECK(std::memcmp(pub, pub2, 32) == 0); + auto msg = body(1, 100); + uint8_t sig[64]; + CHECK(ed25519_sign(sig, msg.data(), msg.size(), priv)); + CHECK(ed25519_verify(sig, msg.data(), msg.size(), pub)); + msg[0] ^= 0xFF; // tampered message -> reject + CHECK(!ed25519_verify(sig, msg.data(), msg.size(), pub)); + msg[0] ^= 0xFF; + uint8_t other[32], opub[32]; ed25519_keygen(other, opub); + CHECK(!ed25519_verify(sig, msg.data(), msg.size(), opub)); // wrong key -> reject +} + +static void t_version_target() { + g_test = "version/target"; + uint32_t v; + CHECK(pack_version("1.16.0", v) && v == 0x01100000u); + CHECK(pack_version("2.0.0", v) && v == 0x02000000u); + CHECK(pack_version("1.16.0.2", v) && (v & 0xFF) == 2); + CHECK(!pack_version("", v)); + CHECK(!pack_version("x.y", v)); + // known target ids from the firmware builds (ties motatool's hashing to the device) + CHECK(target_id_for_env("Heltec_v3_repeater") == 0xd1b29b18u); + CHECK(target_id_for_env("RAK_4631_repeater") == 0x04d413fdu); + // reverse lookup table (human-readable target_id) -> env name, "" when unknown + CHECK(target_env_name(0xd1b29b18u) == "Heltec_v3_repeater"); + CHECK(target_env_name(0x04d413fdu) == "RAK_4631_repeater"); + CHECK(target_env_name(0xDEADBEEFu).empty()); + // the shared (generated) table must round-trip with our own hashing for a spread of OTA envs — + // this catches any drift between the table's stored ids and sha2-256:4(env) + for (const char* e : {"Tbeam_SX1262_repeater", "Xiao_C3_repeater", "ThinkNode_M2_room_server", + "Ebyte_EoRa-S3_companion_radio_ble", "Heltec_v3_companion_radio_usb"}) + CHECK(target_env_name(target_id_for_env(e)) == e); +} + +static void t_intel_hex() { + g_test = "intel hex"; + fs::path hx = fs::temp_directory_path() / "motatool_test.hex"; + // 3 data bytes (01 02 03) at addr 0, then EOF (checksum F7 = two's-complement of the byte sum) + std::string ok = ":03000000010203F7\n:00000001FF\n"; + write_file(hx.string(), (const uint8_t*)ok.data(), ok.size()); + std::vector out; + CHECK(read_input(hx.string(), out).empty()); + CHECK(out.size() == 3 && out[0] == 1 && out[1] == 2 && out[2] == 3); + // wrong checksum -> rejected + std::string bad = ":03000000010203F8\n:00000001FF\n"; + write_file(hx.string(), (const uint8_t*)bad.data(), bad.size()); + std::vector o2; + CHECK(!read_input(hx.string(), o2).empty()); + fs::remove(hx); +} + +static void t_endf() { + g_test = "endf"; + auto b = body(2, 1500); + std::array bh{}; + FwIdent id{0x01100000u, 0x04d413fdu, "RAK4631"}; + auto img = ensure_endf(b, id, bh); + CHECK(img.size() == b.size() + ENDF_LEN); + CHECK(has_endf(img)); + CHECK(std::memcmp(bh.data(), mh8(b.data(), b.size()).data(), 8) == 0); // body_hash is over BODY only + auto gi = parse_endf_ident(img); + CHECK(gi.fw_version == 0x01100000u && gi.target_id == 0x04d413fdu && gi.hw_id == "RAK4631"); + // idempotent: feeding an already-EndF'd image keeps it + reads the same hash + std::array bh2{}; + auto img2 = ensure_endf(img, FwIdent{}, bh2); + CHECK(img2 == img && bh2 == bh); + // zero identity -> still a 56-byte trailer, identity reads empty + std::array bz{}; + auto z = ensure_endf(b, FwIdent{}, bz); + CHECK(z.size() == b.size() + ENDF_LEN); + auto zi = parse_endf_ident(z); + CHECK(zi.fw_version == 0 && zi.target_id == 0 && zi.hw_id.empty()); + CHECK(!has_endf(std::vector{1,2,3})); // too short +} + +static BuildOpts full_opts(const std::vector& fw, const std::vector& priv = {}) { + BuildOpts o; o.fw = fw; o.codec = CODEC_FULL; + o.have_target = true; o.target_id = 0x04d413fdu; + o.have_fwver = true; o.fw_version = 0x01100000u; + o.hw_id = "RAK4631"; o.sign_priv = priv; + return o; +} + +static void t_build_full() { + g_test = "build full"; + auto fw = body(3, 5 * 1024 + 100); // 6 blocks @1024 + std::vector blob; std::string name; + CHECK(build(full_opts(fw), blob, name).empty()); + Manifest m; + CHECK(parse(blob, m).empty()); + CHECK(m.format_ver == FORMAT_VER && m.is_full() && !m.is_signed()); + CHECK(m.codec_id == CODEC_FULL); + CHECK(m.target_id == 0x04d413fdu && m.fw_version == 0x01100000u && m.hw_id_str() == "RAK4631"); + CHECK(m.block_count == 6); + // fixed layout offsets + CHECK(m.leaves_off() == 8 + MOTA_MFL && m.leaves_off() == 205); + CHECK(m.payload_off() == 205 + m.block_count * 4); + CHECK(m.total_size() == blob.size()); + // payload IS the image (body+EndF); image_hash matches + uint32_t poff = m.payload_off(); + auto img_h = mh32(blob.data() + poff, m.payload_size); + CHECK(std::memcmp(img_h.data(), m.image_hash.data(), 32) == 0); + CHECK(m.image_size == m.payload_size); + CHECK(verify(blob).empty()); // clean + CHECK(name.find(".mota") != std::string::npos && name.find("full") != std::string::npos); +} + +static void t_build_signed_and_tamper() { + g_test = "build signed/tamper"; + uint8_t priv[32], pub[32]; ed25519_keygen(priv, pub); + std::vector pv(priv, priv + 32); + auto fw = body(4, 3000); + std::vector blob; std::string name; + CHECK(build(full_opts(fw, pv), blob, name).empty()); + Manifest m; CHECK(parse(blob, m).empty()); + CHECK(m.is_signed()); + CHECK(std::memcmp(m.signer.data(), pub, 32) == 0); + CHECK(verify(blob).empty()); + // tamper a payload byte -> integrity fails + auto t1 = blob; t1[m.payload_off() + 10] ^= 0xFF; + CHECK(!verify(t1).empty()); + // tamper a signed-region byte (target_id @ manifest+3 = blob+11) -> signature invalid + auto t2 = blob; t2[8 + M_OFF_TARGET_ID] ^= 0xFF; + bool sig_flagged = false; + for (auto& p : verify(t2)) if (p.find("signature") != std::string::npos) sig_flagged = true; + CHECK(sig_flagged); +} + +static void t_corruption_and_approval() { + g_test = "corruption/approval"; + auto fw = body(5, 4096); + std::vector blob; std::string name; + build(full_opts(fw), blob, name); + Manifest m; parse(blob, m); + // flip a payload byte -> leaves/merkle/image_hash problems reported + auto c = blob; c[m.payload_off() + 1] ^= 0xFF; + CHECK(!verify(c).empty()); + // a pre-approved container must be flagged (approval is outside the signed region) + auto ap = blob; std::memcpy(ap.data() + 8 + M_OFF_APPROVAL, APPROVAL_YES, 4); + bool approved_flagged = false; + for (auto& p : verify(ap)) if (p.find("approved") != std::string::npos) approved_flagged = true; + CHECK(approved_flagged); +} + +static void t_parse_rejects() { + g_test = "parse rejects"; + auto fw = body(6, 2048); + std::vector blob; std::string name; build(full_opts(fw), blob, name); + Manifest m; + CHECK(!parse(std::vector(10, 0), m).empty()); // too small + auto bad = blob; bad[0] ^= 0xFF; CHECK(!parse(bad, m).empty()); // bad magic + bad = blob; bad[bad.size() - 1] ^= 0xFF; CHECK(!parse(bad, m).empty()); // bad trailer + bad = blob; bad[4] ^= 0xFF; CHECK(!parse(bad, m).empty()); // wrong total_size + bad = blob; bad[8 + M_OFF_FORMAT_VER] = 9; CHECK(!parse(bad, m).empty()); // bad format_ver +} + +static void t_delta() { + g_test = "delta"; + if (!detools_available()) { std::cerr << " SKIP [delta] detools not on PATH\n"; return; } + auto base_body = body(10, 4000); + auto new_body = base_body; + for (int i : {100, 101, 2000, 3999}) new_body[i] ^= 0x33; + auto tail = body(11, 250); + new_body.insert(new_body.end(), tail.begin(), tail.end()); + + for (uint8_t codec : {CODEC_DETOOLS_SEQUENTIAL, CODEC_DETOOLS_INPLACE}) { + BuildOpts o; o.fw = new_body; o.base = base_body; o.codec = codec; + o.have_target = true; o.target_id = 0x04d413fdu; o.have_fwver = true; o.fw_version = 0x02000000u; + o.hw_id = "RAK4631"; + std::vector blob; std::string name; + std::string e = build(o, blob, name); + CHECK(e.empty()); + if (!e.empty()) continue; + Manifest m; CHECK(parse(blob, m).empty()); + CHECK(!m.is_full() && m.codec_id == codec); + // base_hash == mh8 of the base BODY + CHECK(std::memcmp(m.base_hash.data(), mh8(base_body.data(), base_body.size()).data(), 8) == 0); + CHECK(verify(blob).empty()); + if (codec == CODEC_DETOOLS_SEQUENTIAL) { // round-trip: apply -> rebuilds the new image + std::array bh{}; + auto base_img = ensure_endf(base_body, FwIdent{}, bh); + std::vector patch(blob.begin() + m.payload_off(), blob.begin() + m.payload_off() + m.payload_size); + std::vector recon; + CHECK(detools_apply_seq("detools", base_img, patch, recon).empty()); + CHECK(std::memcmp(mh32(recon.data(), recon.size()).data(), m.image_hash.data(), 32) == 0); + } + } +} + +static void t_folder_and_seeder() { + g_test = "folder/seeder"; + fs::path dir = fs::temp_directory_path() / "motatool_test_folder"; + fs::remove_all(dir); fs::create_directories(dir / "sub"); + + // two valid motas (one in a sub-folder, to exercise recursion) + one corrupt + std::vector a, b; std::string n; + build(full_opts(body(20, 3000)), a, n); write_file((dir / "a.mota").string(), a.data(), a.size()); + build(full_opts(body(21, 6000)), b, n); write_file((dir / "sub" / "b.mota").string(), b.data(), b.size()); + auto bad = a; bad[a.size() / 2] ^= 0xFF; write_file((dir / "bad.mota").string(), bad.data(), bad.size()); + write_file((dir / "ignore.txt").string(), a.data(), 10); // non-.mota -> skipped silently + + int warns = 0; std::string warned_path; + Folder folder; + size_t kept = folder.scan(dir.string(), true, + [&](const std::string& p, const std::string&){ warns++; warned_path = p; }); + CHECK(kept == 2); // two valid kept + CHECK(warns == 1 && warned_path.find("bad.mota") != std::string::npos); // corrupt warned + excluded + + SeederCore core(folder); + uint8_t status; std::vector pl; + CHECK(core.handle(MS_OP_COUNT, nullptr, 0, status, pl) && status == MS_STATUS_OK && pl.size() == 1 && pl[0] == 2); + + uint8_t arg0[1] = {0}; + CHECK(core.handle(MS_OP_DESCRIBE, arg0, 1, status, pl) && status == MS_STATUS_OK && pl.size() == MOTA_DESC_WIRE); + const ServedMota* s0 = folder.at(0); + CHECK(std::memcmp(pl.data(), s0->m.merkle_root.data(), 4) == 0); // mid + CHECK(rd_u32(pl.data() + 4) == s0->m.target_id); + CHECK(rd_u32(pl.data() + 14) == (uint32_t)s0->bytes.size()); // total_size + CHECK(rd_u32(pl.data() + 18) == s0->m.leaves_off() && rd_u32(pl.data() + 18) == 205); + CHECK(rd_u32(pl.data() + 22) == s0->m.block_count); + CHECK(rd_u32(pl.data() + 26) == s0->m.payload_off()); + CHECK(rd_u32(pl.data() + 30) == s0->m.payload_size); + + uint8_t bad_idx[1] = {99}; + CHECK(core.handle(MS_OP_DESCRIBE, bad_idx, 1, status, pl) && status == MS_STATUS_ERR); + + // READ idx=0 off=0 len=8 -> first 8 bytes (MAGIC + total_size) + uint8_t rd[7] = {0, 0,0,0,0, 8,0}; + CHECK(core.handle(MS_OP_READ, rd, 7, status, pl) && status == MS_STATUS_OK && pl.size() == 8); + CHECK(std::memcmp(pl.data(), s0->bytes.data(), 8) == 0); + // READ past EOF -> error + uint8_t reof[7]; reof[0] = 0; wr_u32(reof + 1, (uint32_t)s0->bytes.size()); reof[5] = 16; reof[6] = 0; + CHECK(core.handle(MS_OP_READ, reof, 7, status, pl) && status == MS_STATUS_ERR); + + fs::remove_all(dir); +} + +int main() { + t_crypto(); + t_version_target(); + t_intel_hex(); + t_endf(); + t_build_full(); + t_build_signed_and_tamper(); + t_corruption_and_approval(); + t_parse_rejects(); + t_delta(); + t_folder_and_seeder(); + + std::cout << (g_fail ? "FAILED " : "OK ") << (g_checks - g_fail) << "/" << g_checks << " checks passed\n"; + return g_fail ? 1 : 0; +} From 756078fe648fde93725facebe0ccd8d83bdc299d Mon Sep 17 00:00:00 2001 From: Valentin Kivachuk Burda Date: Mon, 29 Jun 2026 13:03:06 +0200 Subject: [PATCH 09/59] ota: native unit tests (format/merkle/detools/apply vectors) (cherry picked from commit e3dd6b417f0a9c7ba2a6516af4f8e7f1a4c95af0) --- test/mocks/SHA256.h | 101 +++- test/test_ota/mota_vectors.h | 122 ++++ test/test_ota/test_ota_core.cpp | 972 ++++++++++++++++++++++++++++++++ 3 files changed, 1189 insertions(+), 6 deletions(-) create mode 100644 test/test_ota/mota_vectors.h create mode 100644 test/test_ota/test_ota_core.cpp diff --git a/test/mocks/SHA256.h b/test/mocks/SHA256.h index b6e551a077..56d214b193 100644 --- a/test/mocks/SHA256.h +++ b/test/mocks/SHA256.h @@ -2,13 +2,102 @@ #include #include +#include -// Mock SHA256 class for testing -// Provides minimal interface to allow Utils.cpp to compile +// Real SHA-256 for native/host tests. Mirrors the rweather/Crypto streaming API used by +// src/Utils.cpp (update / finalize-with-truncation, plus resetHMAC / finalizeHMAC), so that +// Utils::sha256(...) produces correct digests on the host and OTA merkle tests are meaningful. +// (On-device the real rweather is used instead of this mock.) class SHA256 { + uint32_t h[8]; + uint8_t buf[64]; + uint32_t buf_len; + uint64_t total_len; + uint8_t hmac_key[64]; + + static uint32_t ror(uint32_t x, uint32_t n) { return (x >> n) | (x << (32 - n)); } + + void process(const uint8_t* p) { + static const uint32_t K[64] = { + 0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5, + 0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174, + 0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da, + 0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967, + 0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85, + 0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070, + 0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3, + 0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2}; + uint32_t w[64]; + for (int i = 0; i < 16; i++) + w[i] = ((uint32_t)p[i*4] << 24) | ((uint32_t)p[i*4+1] << 16) | ((uint32_t)p[i*4+2] << 8) | p[i*4+3]; + for (int i = 16; i < 64; i++) { + uint32_t s0 = ror(w[i-15],7) ^ ror(w[i-15],18) ^ (w[i-15] >> 3); + uint32_t s1 = ror(w[i-2],17) ^ ror(w[i-2],19) ^ (w[i-2] >> 10); + w[i] = w[i-16] + s0 + w[i-7] + s1; + } + uint32_t a=h[0],b=h[1],c=h[2],d=h[3],e=h[4],f=h[5],g=h[6],hh=h[7]; + for (int i = 0; i < 64; i++) { + uint32_t S1 = ror(e,6) ^ ror(e,11) ^ ror(e,25); + uint32_t ch = (e & f) ^ ((~e) & g); + uint32_t t1 = hh + S1 + ch + K[i] + w[i]; + uint32_t S0 = ror(a,2) ^ ror(a,13) ^ ror(a,22); + uint32_t maj = (a & b) ^ (a & c) ^ (b & c); + uint32_t t2 = S0 + maj; + hh=g; g=f; f=e; e=d+t1; d=c; c=b; b=a; a=t1+t2; + } + h[0]+=a; h[1]+=b; h[2]+=c; h[3]+=d; h[4]+=e; h[5]+=f; h[6]+=g; h[7]+=hh; + } + public: - void update(const uint8_t* data, size_t len) {} - void finalize(uint8_t* hash, size_t hashLen) {} - void resetHMAC(const uint8_t* key, size_t keyLen) {} - void finalizeHMAC(const uint8_t* key, size_t keyLen, uint8_t* hash, size_t hashLen) {} + SHA256() { reset(); } + + void reset() { + h[0]=0x6a09e667; h[1]=0xbb67ae85; h[2]=0x3c6ef372; h[3]=0xa54ff53a; + h[4]=0x510e527f; h[5]=0x9b05688c; h[6]=0x1f83d9ab; h[7]=0x5be0cd19; + buf_len = 0; total_len = 0; + } + + void update(const uint8_t* data, size_t n) { + total_len += n; + while (n) { + size_t take = 64 - buf_len; if (take > n) take = n; + memcpy(buf + buf_len, data, take); buf_len += (uint32_t)take; data += take; n -= take; + if (buf_len == 64) { process(buf); buf_len = 0; } + } + } + + void finalize(uint8_t* out, size_t out_len) { + uint64_t bits = total_len * 8; + buf[buf_len++] = 0x80; + if (buf_len > 56) { while (buf_len < 64) buf[buf_len++] = 0; process(buf); buf_len = 0; } + while (buf_len < 56) buf[buf_len++] = 0; + for (int i = 0; i < 8; i++) buf[56 + i] = (uint8_t)(bits >> (56 - 8*i)); + process(buf); buf_len = 0; + uint8_t full[32]; + for (int i = 0; i < 8; i++) { + full[i*4] = (uint8_t)(h[i] >> 24); full[i*4+1] = (uint8_t)(h[i] >> 16); + full[i*4+2] = (uint8_t)(h[i] >> 8); full[i*4+3] = (uint8_t)(h[i]); + } + if (out_len > 32) out_len = 32; + memcpy(out, full, out_len); + } + + // Standard HMAC-SHA256 (kept correct for API parity; OTA tests don't exercise it). + void resetHMAC(const uint8_t* key, size_t keyLen) { + memset(hmac_key, 0, 64); + if (keyLen > 64) { SHA256 t; t.update(key, keyLen); t.finalize(hmac_key, 32); } + else memcpy(hmac_key, key, keyLen); + reset(); + uint8_t ipad[64]; + for (int i = 0; i < 64; i++) ipad[i] = hmac_key[i] ^ 0x36; + update(ipad, 64); + } + + void finalizeHMAC(const uint8_t* key, size_t keyLen, uint8_t* out, size_t out_len) { + (void)key; (void)keyLen; + uint8_t inner[32]; finalize(inner, 32); + uint8_t opad[64]; + for (int i = 0; i < 64; i++) opad[i] = hmac_key[i] ^ 0x5c; + reset(); update(opad, 64); update(inner, 32); finalize(out, out_len); + } }; diff --git a/test/test_ota/mota_vectors.h b/test/test_ota/mota_vectors.h new file mode 100644 index 0000000000..48a1e205d5 --- /dev/null +++ b/test/test_ota/mota_vectors.h @@ -0,0 +1,122 @@ +// AUTO-GENERATED by tools/mota/gen_vectors.py — do not edit by hand. +// Cross-check vectors: a real .mota from the reference packager (motalib.py). +#pragma once +#include + +static const uint8_t MOTA_VEC[5547] = {109,79,84,65,171,21,0,0,2,1,18,68,51,34,17,0,0,16,1,193,20,0,0,193,20,0,0,10,242,21,180,56,232,121,143,217,6,12,12,64,164,54,242,61,17,64,7,235,26,78,104,183,219,50,235,76,151,230,187,56,8,129,160,136,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,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,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,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,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,106,86,0,67,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,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,0,0,0,0,0,0,0,118,107,52,57,54}; + +static const uint32_t MOTA_VEC_LEN = 5547; +static const uint32_t EXP_TARGET_ID = 0x11223344u; +static const uint32_t EXP_FW_VERSION = 0x01100000u; +static const uint32_t EXP_IMAGE_SIZE = 5313u; +static const uint32_t EXP_PAYLOAD_SIZE = 5313u; +static const uint32_t EXP_BLOCK_COUNT = 6u; +static const uint8_t EXP_BLOCK_SIZE_LOG2 = 10; +static const uint8_t EXP_CODEC_ID = 0; +static const uint8_t EXP_MERKLE_ROOT[4] = {242,21,180,56}; + +static const uint8_t EXP_IMAGE_HASH[32] = {232,121,143,217,6,12,12,64,164,54,242,61,17,64,7,235,26,78,104,183,219,50,235,76,151,230,187,56,8,129,160,136}; + +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,54,247,159,221}; + +static const uint32_t T0_COUNT = 5u; +static const uint8_t T0_LEAVES[20] = {123,202,230,102,137,105,224,241,146,163,254,76,91,140,169,45,125,31,146,228}; + +static const uint8_t T0_ROOT[4] = {37,197,150,102}; + +static const uint16_t T0_POFF[5] = {0,12,24,36,48}; +static const uint8_t T0_PNSIB[5] = {3,3,3,3,1}; +static const uint8_t T0_PBLOB[52] = {137,105,224,241,76,132,127,136,125,31,146,228,123,202,230,102,76,132,127,136,125,31,146,228,91,140,169,45,113,104,86,244,125,31,146,228,146,163,254,76,113,104,86,244,125,31,146,228,32,57,180,151}; + +static const uint32_t T1_COUNT = 7u; +static const uint8_t T1_LEAVES[28] = {219,8,204,214,152,44,227,79,189,183,207,45,57,133,249,14,162,50,142,13,114,148,72,187,230,159,8,8}; + +static const uint8_t T1_ROOT[4] = {124,128,11,251}; + +static const uint16_t T1_POFF[7] = {0,12,24,36,48,60,72}; +static const uint8_t T1_PNSIB[7] = {3,3,3,3,3,3,2}; +static const uint8_t T1_PBLOB[80] = {152,44,227,79,6,192,47,1,91,61,26,208,219,8,204,214,6,192,47,1,91,61,26,208,57,133,249,14,208,126,61,254,91,61,26,208,189,183,207,45,208,126,61,254,91,61,26,208,114,148,72,187,230,159,8,8,21,58,53,95,162,50,142,13,230,159,8,8,21,58,53,95,50,200,22,34,21,58,53,95}; + +static const uint32_t T2_COUNT = 8u; +static const uint8_t T2_LEAVES[32] = {133,246,245,113,87,20,181,196,236,26,12,243,169,182,211,36,223,172,151,252,28,151,88,171,2,138,202,222,83,66,56,124}; + +static const uint8_t T2_ROOT[4] = {230,153,143,58}; + +static const uint16_t T2_POFF[8] = {0,12,24,36,48,60,72,84}; +static const uint8_t T2_PNSIB[8] = {3,3,3,3,3,3,3,3}; +static const uint8_t T2_PBLOB[96] = {87,20,181,196,185,61,131,58,188,95,217,235,133,246,245,113,185,61,131,58,188,95,217,235,169,182,211,36,9,144,114,130,188,95,217,235,236,26,12,243,9,144,114,130,188,95,217,235,28,151,88,171,182,122,229,190,175,255,207,112,223,172,151,252,182,122,229,190,175,255,207,112,83,66,56,124,132,32,7,1,175,255,207,112,2,138,202,222,132,32,7,1,175,255,207,112}; + +static const uint32_t T3_COUNT = 65u; +static const uint8_t T3_LEAVES[260] = {228,177,31,222,213,237,94,231,50,158,16,45,240,127,150,228,208,46,120,34,47,80,173,66,32,115,193,214,60,40,73,37,201,143,198,211,67,231,14,145,23,231,206,70,151,82,247,207,115,74,95,131,213,33,121,147,248,20,31,29,209,72,70,23,241,87,234,199,6,202,12,131,134,233,213,230,82,81,214,46,254,186,73,15,59,134,85,179,59,184,245,117,217,237,95,37,157,89,225,249,112,43,154,184,72,149,130,37,183,19,100,29,203,32,180,97,196,69,203,119,144,197,108,45,1,133,113,168,36,18,78,210,75,94,150,40,116,19,98,94,169,42,12,32,192,70,6,142,125,129,126,57,25,67,158,181,8,30,134,82,88,158,230,175,192,176,37,16,195,177,239,43,191,233,217,203,1,13,34,120,83,7,204,235,200,216,114,244,220,22,118,100,210,158,109,213,107,142,73,151,244,88,103,219,166,50,243,79,183,246,47,87,18,1,5,200,72,44,140,231,168,129,59,227,6,227,103,55,54,204,4,229,59,253,227,61,20,147,26,54,186,174,38,57,191,247,231,13,192,44,230,40,192,252,173,250,93,54,156,198}; + +static const uint8_t T3_ROOT[4] = {143,88,46,110}; + +static const uint16_t T3_POFF[65] = {0,28,56,84,112,140,168,196,224,252,280,308,336,364,392,420,448,476,504,532,560,588,616,644,672,700,728,756,784,812,840,868,896,924,952,980,1008,1036,1064,1092,1120,1148,1176,1204,1232,1260,1288,1316,1344,1372,1400,1428,1456,1484,1512,1540,1568,1596,1624,1652,1680,1708,1736,1764,1792}; +static const uint8_t T3_PNSIB[65] = {7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,1}; +static const uint8_t T3_PBLOB[1796] = {213,237,94,231,226,151,15,25,186,191,115,46,87,73,67,144,23,152,173,217,71,3,57,148,93,54,156,198,228,177,31,222,226,151,15,25,186,191,115,46,87,73,67,144,23,152,173,217,71,3,57,148,93,54,156,198,240,127,150,228,171,21,9,181,186,191,115,46,87,73,67,144,23,152,173,217,71,3,57,148,93,54,156,198,50,158,16,45,171,21,9,181,186,191,115,46,87,73,67,144,23,152,173,217,71,3,57,148,93,54,156,198,47,80,173,66,142,196,71,64,14,98,188,211,87,73,67,144,23,152,173,217,71,3,57,148,93,54,156,198,208,46,120,34,142,196,71,64,14,98,188,211,87,73,67,144,23,152,173,217,71,3,57,148,93,54,156,198,60,40,73,37,50,228,24,222,14,98,188,211,87,73,67,144,23,152,173,217,71,3,57,148,93,54,156,198,32,115,193,214,50,228,24,222,14,98,188,211,87,73,67,144,23,152,173,217,71,3,57,148,93,54,156,198,67,231,14,145,71,231,4,206,205,182,67,188,47,60,59,137,23,152,173,217,71,3,57,148,93,54,156,198,201,143,198,211,71,231,4,206,205,182,67,188,47,60,59,137,23,152,173,217,71,3,57,148,93,54,156,198,151,82,247,207,124,211,70,56,205,182,67,188,47,60,59,137,23,152,173,217,71,3,57,148,93,54,156,198,23,231,206,70,124,211,70,56,205,182,67,188,47,60,59,137,23,152,173,217,71,3,57,148,93,54,156,198,213,33,121,147,47,185,242,240,12,185,30,58,47,60,59,137,23,152,173,217,71,3,57,148,93,54,156,198,115,74,95,131,47,185,242,240,12,185,30,58,47,60,59,137,23,152,173,217,71,3,57,148,93,54,156,198,209,72,70,23,198,222,217,47,12,185,30,58,47,60,59,137,23,152,173,217,71,3,57,148,93,54,156,198,248,20,31,29,198,222,217,47,12,185,30,58,47,60,59,137,23,152,173,217,71,3,57,148,93,54,156,198,6,202,12,131,37,124,190,231,133,59,19,106,100,204,178,18,126,106,252,248,71,3,57,148,93,54,156,198,241,87,234,199,37,124,190,231,133,59,19,106,100,204,178,18,126,106,252,248,71,3,57,148,93,54,156,198,82,81,214,46,25,35,102,192,133,59,19,106,100,204,178,18,126,106,252,248,71,3,57,148,93,54,156,198,134,233,213,230,25,35,102,192,133,59,19,106,100,204,178,18,126,106,252,248,71,3,57,148,93,54,156,198,59,134,85,179,180,250,218,148,37,137,24,30,100,204,178,18,126,106,252,248,71,3,57,148,93,54,156,198,254,186,73,15,180,250,218,148,37,137,24,30,100,204,178,18,126,106,252,248,71,3,57,148,93,54,156,198,217,237,95,37,163,134,12,115,37,137,24,30,100,204,178,18,126,106,252,248,71,3,57,148,93,54,156,198,59,184,245,117,163,134,12,115,37,137,24,30,100,204,178,18,126,106,252,248,71,3,57,148,93,54,156,198,112,43,154,184,171,186,157,117,95,173,243,202,15,216,163,10,126,106,252,248,71,3,57,148,93,54,156,198,157,89,225,249,171,186,157,117,95,173,243,202,15,216,163,10,126,106,252,248,71,3,57,148,93,54,156,198,183,19,100,29,117,33,228,235,95,173,243,202,15,216,163,10,126,106,252,248,71,3,57,148,93,54,156,198,72,149,130,37,117,33,228,235,95,173,243,202,15,216,163,10,126,106,252,248,71,3,57,148,93,54,156,198,196,69,203,119,109,168,123,61,165,127,162,224,15,216,163,10,126,106,252,248,71,3,57,148,93,54,156,198,203,32,180,97,109,168,123,61,165,127,162,224,15,216,163,10,126,106,252,248,71,3,57,148,93,54,156,198,1,133,113,168,19,170,184,98,165,127,162,224,15,216,163,10,126,106,252,248,71,3,57,148,93,54,156,198,144,197,108,45,19,170,184,98,165,127,162,224,15,216,163,10,126,106,252,248,71,3,57,148,93,54,156,198,75,94,150,40,133,173,206,145,50,201,231,4,30,94,224,127,107,72,112,56,161,178,183,96,93,54,156,198,36,18,78,210,133,173,206,145,50,201,231,4,30,94,224,127,107,72,112,56,161,178,183,96,93,54,156,198,169,42,12,32,206,234,112,47,50,201,231,4,30,94,224,127,107,72,112,56,161,178,183,96,93,54,156,198,116,19,98,94,206,234,112,47,50,201,231,4,30,94,224,127,107,72,112,56,161,178,183,96,93,54,156,198,125,129,126,57,170,111,85,90,106,61,62,225,30,94,224,127,107,72,112,56,161,178,183,96,93,54,156,198,192,70,6,142,170,111,85,90,106,61,62,225,30,94,224,127,107,72,112,56,161,178,183,96,93,54,156,198,8,30,134,82,84,11,55,210,106,61,62,225,30,94,224,127,107,72,112,56,161,178,183,96,93,54,156,198,25,67,158,181,84,11,55,210,106,61,62,225,30,94,224,127,107,72,112,56,161,178,183,96,93,54,156,198,192,176,37,16,73,215,44,124,75,85,11,61,81,85,123,40,107,72,112,56,161,178,183,96,93,54,156,198,88,158,230,175,73,215,44,124,75,85,11,61,81,85,123,40,107,72,112,56,161,178,183,96,93,54,156,198,191,233,217,203,104,27,130,168,75,85,11,61,81,85,123,40,107,72,112,56,161,178,183,96,93,54,156,198,195,177,239,43,104,27,130,168,75,85,11,61,81,85,123,40,107,72,112,56,161,178,183,96,93,54,156,198,83,7,204,235,77,162,227,126,184,101,244,22,81,85,123,40,107,72,112,56,161,178,183,96,93,54,156,198,1,13,34,120,77,162,227,126,184,101,244,22,81,85,123,40,107,72,112,56,161,178,183,96,93,54,156,198,220,22,118,100,132,206,108,94,184,101,244,22,81,85,123,40,107,72,112,56,161,178,183,96,93,54,156,198,200,216,114,244,132,206,108,94,184,101,244,22,81,85,123,40,107,72,112,56,161,178,183,96,93,54,156,198,107,142,73,151,74,78,179,108,244,44,244,141,152,179,15,151,5,193,169,14,161,178,183,96,93,54,156,198,210,158,109,213,74,78,179,108,244,44,244,141,152,179,15,151,5,193,169,14,161,178,183,96,93,54,156,198,166,50,243,79,186,100,96,255,244,44,244,141,152,179,15,151,5,193,169,14,161,178,183,96,93,54,156,198,244,88,103,219,186,100,96,255,244,44,244,141,152,179,15,151,5,193,169,14,161,178,183,96,93,54,156,198,18,1,5,200,112,90,214,108,23,138,61,93,152,179,15,151,5,193,169,14,161,178,183,96,93,54,156,198,183,246,47,87,112,90,214,108,23,138,61,93,152,179,15,151,5,193,169,14,161,178,183,96,93,54,156,198,168,129,59,227,68,36,234,87,23,138,61,93,152,179,15,151,5,193,169,14,161,178,183,96,93,54,156,198,72,44,140,231,68,36,234,87,23,138,61,93,152,179,15,151,5,193,169,14,161,178,183,96,93,54,156,198,54,204,4,229,190,153,32,138,33,227,4,234,139,152,195,249,5,193,169,14,161,178,183,96,93,54,156,198,6,227,103,55,190,153,32,138,33,227,4,234,139,152,195,249,5,193,169,14,161,178,183,96,93,54,156,198,20,147,26,54,96,205,181,224,33,227,4,234,139,152,195,249,5,193,169,14,161,178,183,96,93,54,156,198,59,253,227,61,96,205,181,224,33,227,4,234,139,152,195,249,5,193,169,14,161,178,183,96,93,54,156,198,191,247,231,13,229,50,248,97,89,252,12,65,139,152,195,249,5,193,169,14,161,178,183,96,93,54,156,198,186,174,38,57,229,50,248,97,89,252,12,65,139,152,195,249,5,193,169,14,161,178,183,96,93,54,156,198,192,252,173,250,142,15,70,253,89,252,12,65,139,152,195,249,5,193,169,14,161,178,183,96,93,54,156,198,192,44,230,40,142,15,70,253,89,252,12,65,139,152,195,249,5,193,169,14,161,178,183,96,93,54,156,198,149,210,13,187}; + +static const uint32_t T4_COUNT = 100u; +static const uint8_t T4_LEAVES[400] = {250,6,181,225,56,207,131,195,200,254,34,10,238,105,33,132,48,50,156,13,143,158,201,222,178,194,103,10,124,149,60,141,9,94,243,101,75,219,122,125,213,33,64,40,32,228,121,213,226,253,231,144,75,135,234,131,152,214,212,106,51,145,3,183,128,183,173,26,69,67,252,15,42,219,249,19,124,72,186,210,66,55,64,53,27,111,249,47,219,212,89,93,243,63,146,136,20,157,216,177,50,200,32,211,137,241,63,21,222,236,109,140,89,124,160,1,24,252,27,231,105,182,145,139,59,254,133,183,194,117,230,133,86,39,227,203,37,170,103,5,12,76,12,151,82,192,112,121,156,72,138,44,13,141,142,83,126,173,15,67,193,11,13,252,218,226,147,150,252,127,155,49,168,56,120,149,138,173,127,54,5,91,4,203,100,103,34,42,224,120,111,134,236,40,86,53,123,141,164,71,178,229,86,75,83,180,208,56,78,46,180,59,154,244,17,9,205,174,70,75,97,138,191,59,188,95,40,182,254,210,175,49,38,68,197,58,155,82,244,130,58,135,66,250,162,37,231,120,15,232,85,246,64,60,232,16,250,85,139,178,226,21,172,135,128,162,227,198,90,175,208,53,161,165,158,250,207,38,249,195,125,26,112,252,188,142,55,227,7,88,106,227,22,155,231,103,250,222,43,181,121,239,180,119,204,205,9,203,29,142,3,40,113,191,12,53,22,240,105,201,205,146,175,25,133,29,26,65,72,143,138,248,90,133,22,232,188,178,200,127,202,39,235,197,94,167,110,82,62,235,228,215,17,17,117,54,102,46,37,22,164,40,183,159,25,55,80,182,241,239,12,185,12,200,147,241,76,90,125,48,23,173,235,75,97,85,135,96,255,21,97,103,85,13,180,129,176,168,148,246}; + +static const uint8_t T4_ROOT[4] = {169,167,185,243}; + +static const uint16_t T4_POFF[100] = {0,28,56,84,112,140,168,196,224,252,280,308,336,364,392,420,448,476,504,532,560,588,616,644,672,700,728,756,784,812,840,868,896,924,952,980,1008,1036,1064,1092,1120,1148,1176,1204,1232,1260,1288,1316,1344,1372,1400,1428,1456,1484,1512,1540,1568,1596,1624,1652,1680,1708,1736,1764,1792,1820,1848,1876,1904,1932,1960,1988,2016,2044,2072,2100,2128,2156,2184,2212,2240,2268,2296,2324,2352,2380,2408,2436,2464,2492,2520,2548,2576,2604,2632,2660,2688,2704,2720,2736}; +static const uint8_t T4_PNSIB[100] = {7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,4,4,4,4}; +static const uint8_t T4_PBLOB[2752] = {56,207,131,195,144,4,34,70,211,98,27,29,229,111,37,122,45,29,32,241,1,82,253,243,18,149,46,121,250,6,181,225,144,4,34,70,211,98,27,29,229,111,37,122,45,29,32,241,1,82,253,243,18,149,46,121,238,105,33,132,21,3,59,222,211,98,27,29,229,111,37,122,45,29,32,241,1,82,253,243,18,149,46,121,200,254,34,10,21,3,59,222,211,98,27,29,229,111,37,122,45,29,32,241,1,82,253,243,18,149,46,121,143,158,201,222,96,188,135,52,159,141,76,84,229,111,37,122,45,29,32,241,1,82,253,243,18,149,46,121,48,50,156,13,96,188,135,52,159,141,76,84,229,111,37,122,45,29,32,241,1,82,253,243,18,149,46,121,124,149,60,141,98,199,71,87,159,141,76,84,229,111,37,122,45,29,32,241,1,82,253,243,18,149,46,121,178,194,103,10,98,199,71,87,159,141,76,84,229,111,37,122,45,29,32,241,1,82,253,243,18,149,46,121,75,219,122,125,169,14,251,128,243,113,185,185,54,115,204,16,45,29,32,241,1,82,253,243,18,149,46,121,9,94,243,101,169,14,251,128,243,113,185,185,54,115,204,16,45,29,32,241,1,82,253,243,18,149,46,121,32,228,121,213,35,96,203,98,243,113,185,185,54,115,204,16,45,29,32,241,1,82,253,243,18,149,46,121,213,33,64,40,35,96,203,98,243,113,185,185,54,115,204,16,45,29,32,241,1,82,253,243,18,149,46,121,75,135,234,131,212,196,76,34,41,226,170,218,54,115,204,16,45,29,32,241,1,82,253,243,18,149,46,121,226,253,231,144,212,196,76,34,41,226,170,218,54,115,204,16,45,29,32,241,1,82,253,243,18,149,46,121,51,145,3,183,204,217,111,117,41,226,170,218,54,115,204,16,45,29,32,241,1,82,253,243,18,149,46,121,152,214,212,106,204,217,111,117,41,226,170,218,54,115,204,16,45,29,32,241,1,82,253,243,18,149,46,121,69,67,252,15,57,132,159,140,174,142,2,180,177,111,134,244,146,34,242,217,1,82,253,243,18,149,46,121,128,183,173,26,57,132,159,140,174,142,2,180,177,111,134,244,146,34,242,217,1,82,253,243,18,149,46,121,124,72,186,210,8,162,138,25,174,142,2,180,177,111,134,244,146,34,242,217,1,82,253,243,18,149,46,121,42,219,249,19,8,162,138,25,174,142,2,180,177,111,134,244,146,34,242,217,1,82,253,243,18,149,46,121,27,111,249,47,19,49,135,113,74,129,59,240,177,111,134,244,146,34,242,217,1,82,253,243,18,149,46,121,66,55,64,53,19,49,135,113,74,129,59,240,177,111,134,244,146,34,242,217,1,82,253,243,18,149,46,121,243,63,146,136,75,253,36,162,74,129,59,240,177,111,134,244,146,34,242,217,1,82,253,243,18,149,46,121,219,212,89,93,75,253,36,162,74,129,59,240,177,111,134,244,146,34,242,217,1,82,253,243,18,149,46,121,50,200,32,211,196,6,205,75,139,127,232,79,21,35,67,161,146,34,242,217,1,82,253,243,18,149,46,121,20,157,216,177,196,6,205,75,139,127,232,79,21,35,67,161,146,34,242,217,1,82,253,243,18,149,46,121,222,236,109,140,155,133,32,81,139,127,232,79,21,35,67,161,146,34,242,217,1,82,253,243,18,149,46,121,137,241,63,21,155,133,32,81,139,127,232,79,21,35,67,161,146,34,242,217,1,82,253,243,18,149,46,121,24,252,27,231,16,192,89,45,184,230,20,55,21,35,67,161,146,34,242,217,1,82,253,243,18,149,46,121,89,124,160,1,16,192,89,45,184,230,20,55,21,35,67,161,146,34,242,217,1,82,253,243,18,149,46,121,59,254,133,183,58,26,222,255,184,230,20,55,21,35,67,161,146,34,242,217,1,82,253,243,18,149,46,121,105,182,145,139,58,26,222,255,184,230,20,55,21,35,67,161,146,34,242,217,1,82,253,243,18,149,46,121,86,39,227,203,37,93,129,116,201,76,71,155,177,116,125,30,199,197,197,5,236,20,11,118,18,149,46,121,194,117,230,133,37,93,129,116,201,76,71,155,177,116,125,30,199,197,197,5,236,20,11,118,18,149,46,121,12,76,12,151,171,23,55,158,201,76,71,155,177,116,125,30,199,197,197,5,236,20,11,118,18,149,46,121,37,170,103,5,171,23,55,158,201,76,71,155,177,116,125,30,199,197,197,5,236,20,11,118,18,149,46,121,156,72,138,44,178,196,184,248,236,104,3,167,177,116,125,30,199,197,197,5,236,20,11,118,18,149,46,121,82,192,112,121,178,196,184,248,236,104,3,167,177,116,125,30,199,197,197,5,236,20,11,118,18,149,46,121,126,173,15,67,148,162,128,54,236,104,3,167,177,116,125,30,199,197,197,5,236,20,11,118,18,149,46,121,13,141,142,83,148,162,128,54,236,104,3,167,177,116,125,30,199,197,197,5,236,20,11,118,18,149,46,121,218,226,147,150,0,21,170,193,76,191,87,153,39,66,60,32,199,197,197,5,236,20,11,118,18,149,46,121,193,11,13,252,0,21,170,193,76,191,87,153,39,66,60,32,199,197,197,5,236,20,11,118,18,149,46,121,168,56,120,149,17,155,65,75,76,191,87,153,39,66,60,32,199,197,197,5,236,20,11,118,18,149,46,121,252,127,155,49,17,155,65,75,76,191,87,153,39,66,60,32,199,197,197,5,236,20,11,118,18,149,46,121,5,91,4,203,81,193,61,17,63,43,120,95,39,66,60,32,199,197,197,5,236,20,11,118,18,149,46,121,138,173,127,54,81,193,61,17,63,43,120,95,39,66,60,32,199,197,197,5,236,20,11,118,18,149,46,121,224,120,111,134,197,230,31,135,63,43,120,95,39,66,60,32,199,197,197,5,236,20,11,118,18,149,46,121,100,103,34,42,197,230,31,135,63,43,120,95,39,66,60,32,199,197,197,5,236,20,11,118,18,149,46,121,123,141,164,71,92,60,129,206,78,17,61,245,190,40,237,164,62,10,40,12,236,20,11,118,18,149,46,121,236,40,86,53,92,60,129,206,78,17,61,245,190,40,237,164,62,10,40,12,236,20,11,118,18,149,46,121,83,180,208,56,113,215,5,130,78,17,61,245,190,40,237,164,62,10,40,12,236,20,11,118,18,149,46,121,178,229,86,75,113,215,5,130,78,17,61,245,190,40,237,164,62,10,40,12,236,20,11,118,18,149,46,121,154,244,17,9,241,185,75,36,120,5,10,203,190,40,237,164,62,10,40,12,236,20,11,118,18,149,46,121,78,46,180,59,241,185,75,36,120,5,10,203,190,40,237,164,62,10,40,12,236,20,11,118,18,149,46,121,97,138,191,59,36,148,140,82,120,5,10,203,190,40,237,164,62,10,40,12,236,20,11,118,18,149,46,121,205,174,70,75,36,148,140,82,120,5,10,203,190,40,237,164,62,10,40,12,236,20,11,118,18,149,46,121,254,210,175,49,58,244,150,107,254,225,141,150,117,212,105,39,62,10,40,12,236,20,11,118,18,149,46,121,188,95,40,182,58,244,150,107,254,225,141,150,117,212,105,39,62,10,40,12,236,20,11,118,18,149,46,121,155,82,244,130,3,23,222,243,254,225,141,150,117,212,105,39,62,10,40,12,236,20,11,118,18,149,46,121,38,68,197,58,3,23,222,243,254,225,141,150,117,212,105,39,62,10,40,12,236,20,11,118,18,149,46,121,162,37,231,120,111,3,141,159,193,86,116,188,117,212,105,39,62,10,40,12,236,20,11,118,18,149,46,121,58,135,66,250,111,3,141,159,193,86,116,188,117,212,105,39,62,10,40,12,236,20,11,118,18,149,46,121,64,60,232,16,135,211,9,178,193,86,116,188,117,212,105,39,62,10,40,12,236,20,11,118,18,149,46,121,15,232,85,246,135,211,9,178,193,86,116,188,117,212,105,39,62,10,40,12,236,20,11,118,18,149,46,121,226,21,172,135,111,71,248,116,217,80,60,219,188,142,11,51,245,200,136,13,137,103,31,19,18,72,110,254,250,85,139,178,111,71,248,116,217,80,60,219,188,142,11,51,245,200,136,13,137,103,31,19,18,72,110,254,90,175,208,53,118,128,133,179,217,80,60,219,188,142,11,51,245,200,136,13,137,103,31,19,18,72,110,254,128,162,227,198,118,128,133,179,217,80,60,219,188,142,11,51,245,200,136,13,137,103,31,19,18,72,110,254,207,38,249,195,37,131,44,47,77,82,94,73,188,142,11,51,245,200,136,13,137,103,31,19,18,72,110,254,161,165,158,250,37,131,44,47,77,82,94,73,188,142,11,51,245,200,136,13,137,103,31,19,18,72,110,254,188,142,55,227,180,0,66,204,77,82,94,73,188,142,11,51,245,200,136,13,137,103,31,19,18,72,110,254,125,26,112,252,180,0,66,204,77,82,94,73,188,142,11,51,245,200,136,13,137,103,31,19,18,72,110,254,22,155,231,103,9,180,25,20,71,242,120,146,238,91,144,22,245,200,136,13,137,103,31,19,18,72,110,254,7,88,106,227,9,180,25,20,71,242,120,146,238,91,144,22,245,200,136,13,137,103,31,19,18,72,110,254,121,239,180,119,106,219,202,18,71,242,120,146,238,91,144,22,245,200,136,13,137,103,31,19,18,72,110,254,250,222,43,181,106,219,202,18,71,242,120,146,238,91,144,22,245,200,136,13,137,103,31,19,18,72,110,254,29,142,3,40,29,164,246,98,55,204,76,233,238,91,144,22,245,200,136,13,137,103,31,19,18,72,110,254,204,205,9,203,29,164,246,98,55,204,76,233,238,91,144,22,245,200,136,13,137,103,31,19,18,72,110,254,22,240,105,201,197,130,110,101,55,204,76,233,238,91,144,22,245,200,136,13,137,103,31,19,18,72,110,254,113,191,12,53,197,130,110,101,55,204,76,233,238,91,144,22,245,200,136,13,137,103,31,19,18,72,110,254,133,29,26,65,5,22,180,187,234,247,123,20,249,90,67,149,254,114,155,109,137,103,31,19,18,72,110,254,205,146,175,25,5,22,180,187,234,247,123,20,249,90,67,149,254,114,155,109,137,103,31,19,18,72,110,254,90,133,22,232,144,41,193,104,234,247,123,20,249,90,67,149,254,114,155,109,137,103,31,19,18,72,110,254,72,143,138,248,144,41,193,104,234,247,123,20,249,90,67,149,254,114,155,109,137,103,31,19,18,72,110,254,202,39,235,197,208,130,10,34,23,254,125,179,249,90,67,149,254,114,155,109,137,103,31,19,18,72,110,254,188,178,200,127,208,130,10,34,23,254,125,179,249,90,67,149,254,114,155,109,137,103,31,19,18,72,110,254,62,235,228,215,5,218,223,93,23,254,125,179,249,90,67,149,254,114,155,109,137,103,31,19,18,72,110,254,94,167,110,82,5,218,223,93,23,254,125,179,249,90,67,149,254,114,155,109,137,103,31,19,18,72,110,254,102,46,37,22,127,3,61,253,120,198,248,114,124,220,117,120,254,114,155,109,137,103,31,19,18,72,110,254,17,17,117,54,127,3,61,253,120,198,248,114,124,220,117,120,254,114,155,109,137,103,31,19,18,72,110,254,25,55,80,182,112,68,206,98,120,198,248,114,124,220,117,120,254,114,155,109,137,103,31,19,18,72,110,254,164,40,183,159,112,68,206,98,120,198,248,114,124,220,117,120,254,114,155,109,137,103,31,19,18,72,110,254,12,200,147,241,240,129,157,149,214,175,107,55,124,220,117,120,254,114,155,109,137,103,31,19,18,72,110,254,241,239,12,185,240,129,157,149,214,175,107,55,124,220,117,120,254,114,155,109,137,103,31,19,18,72,110,254,23,173,235,75,247,5,120,126,214,175,107,55,124,220,117,120,254,114,155,109,137,103,31,19,18,72,110,254,76,90,125,48,247,5,120,126,214,175,107,55,124,220,117,120,254,114,155,109,137,103,31,19,18,72,110,254,255,21,97,103,4,191,4,227,166,66,253,139,18,72,110,254,97,85,135,96,4,191,4,227,166,66,253,139,18,72,110,254,176,168,148,246,118,100,253,16,166,66,253,139,18,72,110,254,85,13,180,129,118,100,253,16,166,66,253,139,18,72,110,254}; + +static const uint32_t T5_COUNT = 255u; +static const uint8_t T5_LEAVES[1020] = {99,92,33,97,215,30,198,101,110,35,237,220,30,114,79,78,74,29,101,121,222,43,226,84,17,236,91,25,47,133,95,23,43,24,222,170,190,97,229,65,76,57,46,5,125,175,81,95,129,156,155,63,139,113,160,112,9,231,52,203,252,7,66,123,48,252,216,98,69,222,198,113,178,127,158,162,81,195,118,245,12,104,14,245,195,109,80,118,156,90,70,58,116,110,104,111,98,218,154,9,0,246,201,54,21,252,233,206,122,167,217,33,27,229,83,158,113,117,40,232,181,76,250,165,202,26,5,178,24,172,180,91,248,103,94,4,159,132,24,153,192,212,194,197,91,144,28,217,220,138,69,32,88,62,37,53,222,242,192,136,108,15,206,62,175,37,53,189,122,8,1,151,251,79,38,134,218,146,209,8,209,69,107,224,101,240,227,215,64,22,211,222,200,168,86,206,166,14,152,208,136,237,24,102,21,75,241,208,229,172,0,114,82,123,56,244,139,243,123,44,152,92,225,42,18,138,48,214,248,103,199,37,85,246,102,71,235,63,48,78,118,250,138,255,183,97,23,93,190,117,84,232,123,112,151,77,179,219,237,53,190,42,18,93,107,191,176,137,124,122,74,20,142,110,105,47,54,175,181,107,222,49,152,50,17,253,130,64,26,56,103,115,55,250,154,108,68,73,225,98,107,219,25,215,125,6,206,178,170,145,250,144,206,100,175,127,158,19,247,69,153,200,212,164,188,45,2,3,199,126,46,239,154,24,194,145,219,53,73,113,240,97,62,59,45,184,154,234,46,208,65,46,153,97,185,189,80,96,1,217,222,177,59,129,129,235,150,104,135,177,190,75,226,233,82,45,254,83,235,151,179,166,244,74,106,234,100,179,159,56,71,63,37,219,60,174,72,210,203,202,117,29,49,129,193,83,137,16,54,223,126,253,200,222,108,14,42,75,153,9,237,246,91,48,20,82,162,31,204,182,70,123,170,142,148,194,92,104,120,163,114,162,123,143,133,175,134,57,34,19,231,207,204,211,133,58,121,174,231,52,136,250,88,177,238,169,212,146,91,241,161,135,170,251,202,84,228,237,231,18,165,178,89,96,218,135,59,199,248,202,177,222,234,229,141,108,120,41,139,215,36,65,173,224,128,97,225,251,106,100,234,202,236,222,20,34,145,135,162,162,186,253,245,44,87,151,161,224,84,113,162,206,100,154,175,183,109,12,155,201,93,121,19,131,120,4,60,73,19,222,160,237,167,13,195,180,108,243,228,36,28,72,216,0,231,211,112,6,34,247,115,50,7,25,79,120,224,173,218,66,44,6,104,153,101,176,42,94,72,63,198,240,243,34,3,7,51,230,53,223,243,118,3,198,52,105,9,172,174,40,34,202,177,224,62,9,73,147,168,22,37,254,205,200,135,62,227,125,114,160,12,107,0,103,126,218,5,89,61,218,9,212,71,125,200,61,131,83,235,30,236,207,185,131,237,198,225,61,208,112,102,57,123,99,71,28,42,28,25,186,35,229,158,90,56,92,133,16,82,153,52,230,163,184,45,20,64,214,172,131,94,26,249,52,201,130,242,21,132,147,240,84,64,243,51,52,187,146,246,205,26,211,105,175,61,221,209,21,230,137,88,0,176,105,144,150,137,124,228,223,2,180,169,190,150,90,170,98,205,172,40,91,71,138,242,213,140,102,41,178,241,149,113,112,82,60,194,159,187,87,136,172,169,188,177,199,28,202,9,199,205,7,162,249,44,225,132,229,226,98,158,22,105,45,129,185,103,208,200,45,189,25,202,91,179,42,213,54,2,126,142,224,63,170,137,186,245,105,74,26,77,175,88,126,216,140,18,235,136,40,156,192,38,255,28,233,255,161,39,109,33,227,82,143,178,188,149,99,100,231,41,39,133,234,183,230,240,243,133,198,56,49,198,172,6,104,36,29,157,93,82,16,201,114,111,225,187,176,227,62,90,123,100,22,147,98,176,102,96,95,228,116,217,35,147,129,183,208,131,237,195,100,89,243,185,43,17,209,44,159,179,151,84,159,167,187,80,191,118,89,121,54,37,82,106,170,109,95,140,81,224,108,165,161,252,226,92,204,207,129,169,31,4,27,80,174,208,127,241,68,233,224,38,93,233,162,74,54,164,245,145,211,22,15,219,10,211,93,159,62,71,14,45,19,225,239,69,93,176,167,66,95,75,158,79,2,221,52,115,89,137,108,87,155,209,12,100,54,71,201,170,17,132,15,135,37,31,139,240,132,99,151,51,84,187,224,195,234,66,91,87,129,171,22,20,242,205,129,26,178}; + +static const uint8_t T5_ROOT[4] = {125,131,30,129}; + +static const uint16_t T5_POFF[255] = {0,32,64,96,128,160,192,224,256,288,320,352,384,416,448,480,512,544,576,608,640,672,704,736,768,800,832,864,896,928,960,992,1024,1056,1088,1120,1152,1184,1216,1248,1280,1312,1344,1376,1408,1440,1472,1504,1536,1568,1600,1632,1664,1696,1728,1760,1792,1824,1856,1888,1920,1952,1984,2016,2048,2080,2112,2144,2176,2208,2240,2272,2304,2336,2368,2400,2432,2464,2496,2528,2560,2592,2624,2656,2688,2720,2752,2784,2816,2848,2880,2912,2944,2976,3008,3040,3072,3104,3136,3168,3200,3232,3264,3296,3328,3360,3392,3424,3456,3488,3520,3552,3584,3616,3648,3680,3712,3744,3776,3808,3840,3872,3904,3936,3968,4000,4032,4064,4096,4128,4160,4192,4224,4256,4288,4320,4352,4384,4416,4448,4480,4512,4544,4576,4608,4640,4672,4704,4736,4768,4800,4832,4864,4896,4928,4960,4992,5024,5056,5088,5120,5152,5184,5216,5248,5280,5312,5344,5376,5408,5440,5472,5504,5536,5568,5600,5632,5664,5696,5728,5760,5792,5824,5856,5888,5920,5952,5984,6016,6048,6080,6112,6144,6176,6208,6240,6272,6304,6336,6368,6400,6432,6464,6496,6528,6560,6592,6624,6656,6688,6720,6752,6784,6816,6848,6880,6912,6944,6976,7008,7040,7072,7104,7136,7168,7200,7232,7264,7296,7328,7360,7392,7424,7456,7488,7520,7552,7584,7616,7648,7680,7712,7744,7776,7808,7840,7872,7904,7936,7968,8000,8032,8064,8096,8128}; +static const uint8_t T5_PNSIB[255] = {8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,7}; +static const uint8_t T5_PBLOB[8156] = {215,30,198,101,190,120,224,191,253,188,254,181,170,224,185,20,232,113,80,94,246,33,129,111,238,68,253,253,233,186,40,163,99,92,33,97,190,120,224,191,253,188,254,181,170,224,185,20,232,113,80,94,246,33,129,111,238,68,253,253,233,186,40,163,30,114,79,78,121,1,165,22,253,188,254,181,170,224,185,20,232,113,80,94,246,33,129,111,238,68,253,253,233,186,40,163,110,35,237,220,121,1,165,22,253,188,254,181,170,224,185,20,232,113,80,94,246,33,129,111,238,68,253,253,233,186,40,163,222,43,226,84,141,8,229,42,34,203,204,102,170,224,185,20,232,113,80,94,246,33,129,111,238,68,253,253,233,186,40,163,74,29,101,121,141,8,229,42,34,203,204,102,170,224,185,20,232,113,80,94,246,33,129,111,238,68,253,253,233,186,40,163,47,133,95,23,206,109,234,200,34,203,204,102,170,224,185,20,232,113,80,94,246,33,129,111,238,68,253,253,233,186,40,163,17,236,91,25,206,109,234,200,34,203,204,102,170,224,185,20,232,113,80,94,246,33,129,111,238,68,253,253,233,186,40,163,190,97,229,65,204,162,212,253,158,221,103,119,94,138,252,177,232,113,80,94,246,33,129,111,238,68,253,253,233,186,40,163,43,24,222,170,204,162,212,253,158,221,103,119,94,138,252,177,232,113,80,94,246,33,129,111,238,68,253,253,233,186,40,163,125,175,81,95,207,138,11,26,158,221,103,119,94,138,252,177,232,113,80,94,246,33,129,111,238,68,253,253,233,186,40,163,76,57,46,5,207,138,11,26,158,221,103,119,94,138,252,177,232,113,80,94,246,33,129,111,238,68,253,253,233,186,40,163,139,113,160,112,70,90,148,231,170,148,193,238,94,138,252,177,232,113,80,94,246,33,129,111,238,68,253,253,233,186,40,163,129,156,155,63,70,90,148,231,170,148,193,238,94,138,252,177,232,113,80,94,246,33,129,111,238,68,253,253,233,186,40,163,252,7,66,123,221,141,84,219,170,148,193,238,94,138,252,177,232,113,80,94,246,33,129,111,238,68,253,253,233,186,40,163,9,231,52,203,221,141,84,219,170,148,193,238,94,138,252,177,232,113,80,94,246,33,129,111,238,68,253,253,233,186,40,163,69,222,198,113,5,81,192,127,46,0,223,26,254,26,86,132,76,178,240,104,246,33,129,111,238,68,253,253,233,186,40,163,48,252,216,98,5,81,192,127,46,0,223,26,254,26,86,132,76,178,240,104,246,33,129,111,238,68,253,253,233,186,40,163,81,195,118,245,162,175,255,73,46,0,223,26,254,26,86,132,76,178,240,104,246,33,129,111,238,68,253,253,233,186,40,163,178,127,158,162,162,175,255,73,46,0,223,26,254,26,86,132,76,178,240,104,246,33,129,111,238,68,253,253,233,186,40,163,195,109,80,118,127,36,23,8,63,65,173,128,254,26,86,132,76,178,240,104,246,33,129,111,238,68,253,253,233,186,40,163,12,104,14,245,127,36,23,8,63,65,173,128,254,26,86,132,76,178,240,104,246,33,129,111,238,68,253,253,233,186,40,163,116,110,104,111,201,228,150,142,63,65,173,128,254,26,86,132,76,178,240,104,246,33,129,111,238,68,253,253,233,186,40,163,156,90,70,58,201,228,150,142,63,65,173,128,254,26,86,132,76,178,240,104,246,33,129,111,238,68,253,253,233,186,40,163,0,246,201,54,128,178,99,183,233,161,204,65,5,250,190,144,76,178,240,104,246,33,129,111,238,68,253,253,233,186,40,163,98,218,154,9,128,178,99,183,233,161,204,65,5,250,190,144,76,178,240,104,246,33,129,111,238,68,253,253,233,186,40,163,122,167,217,33,47,15,253,93,233,161,204,65,5,250,190,144,76,178,240,104,246,33,129,111,238,68,253,253,233,186,40,163,21,252,233,206,47,15,253,93,233,161,204,65,5,250,190,144,76,178,240,104,246,33,129,111,238,68,253,253,233,186,40,163,113,117,40,232,216,234,112,92,191,252,51,40,5,250,190,144,76,178,240,104,246,33,129,111,238,68,253,253,233,186,40,163,27,229,83,158,216,234,112,92,191,252,51,40,5,250,190,144,76,178,240,104,246,33,129,111,238,68,253,253,233,186,40,163,202,26,5,178,170,64,23,82,191,252,51,40,5,250,190,144,76,178,240,104,246,33,129,111,238,68,253,253,233,186,40,163,181,76,250,165,170,64,23,82,191,252,51,40,5,250,190,144,76,178,240,104,246,33,129,111,238,68,253,253,233,186,40,163,248,103,94,4,94,81,198,48,105,236,187,128,230,97,25,120,232,125,159,24,124,247,176,24,238,68,253,253,233,186,40,163,24,172,180,91,94,81,198,48,105,236,187,128,230,97,25,120,232,125,159,24,124,247,176,24,238,68,253,253,233,186,40,163,192,212,194,197,113,91,163,95,105,236,187,128,230,97,25,120,232,125,159,24,124,247,176,24,238,68,253,253,233,186,40,163,159,132,24,153,113,91,163,95,105,236,187,128,230,97,25,120,232,125,159,24,124,247,176,24,238,68,253,253,233,186,40,163,220,138,69,32,74,123,206,252,86,172,110,251,230,97,25,120,232,125,159,24,124,247,176,24,238,68,253,253,233,186,40,163,91,144,28,217,74,123,206,252,86,172,110,251,230,97,25,120,232,125,159,24,124,247,176,24,238,68,253,253,233,186,40,163,222,242,192,136,14,210,218,85,86,172,110,251,230,97,25,120,232,125,159,24,124,247,176,24,238,68,253,253,233,186,40,163,88,62,37,53,14,210,218,85,86,172,110,251,230,97,25,120,232,125,159,24,124,247,176,24,238,68,253,253,233,186,40,163,175,37,53,189,128,163,232,24,76,245,254,84,247,196,73,244,232,125,159,24,124,247,176,24,238,68,253,253,233,186,40,163,108,15,206,62,128,163,232,24,76,245,254,84,247,196,73,244,232,125,159,24,124,247,176,24,238,68,253,253,233,186,40,163,251,79,38,134,1,113,206,77,76,245,254,84,247,196,73,244,232,125,159,24,124,247,176,24,238,68,253,253,233,186,40,163,122,8,1,151,1,113,206,77,76,245,254,84,247,196,73,244,232,125,159,24,124,247,176,24,238,68,253,253,233,186,40,163,209,69,107,224,183,59,60,223,198,229,132,188,247,196,73,244,232,125,159,24,124,247,176,24,238,68,253,253,233,186,40,163,218,146,209,8,183,59,60,223,198,229,132,188,247,196,73,244,232,125,159,24,124,247,176,24,238,68,253,253,233,186,40,163,64,22,211,222,205,108,8,132,198,229,132,188,247,196,73,244,232,125,159,24,124,247,176,24,238,68,253,253,233,186,40,163,101,240,227,215,205,108,8,132,198,229,132,188,247,196,73,244,232,125,159,24,124,247,176,24,238,68,253,253,233,186,40,163,166,14,152,208,48,91,11,92,204,141,206,246,20,174,189,54,137,23,246,178,124,247,176,24,238,68,253,253,233,186,40,163,200,168,86,206,48,91,11,92,204,141,206,246,20,174,189,54,137,23,246,178,124,247,176,24,238,68,253,253,233,186,40,163,21,75,241,208,63,55,43,68,204,141,206,246,20,174,189,54,137,23,246,178,124,247,176,24,238,68,253,253,233,186,40,163,136,237,24,102,63,55,43,68,204,141,206,246,20,174,189,54,137,23,246,178,124,247,176,24,238,68,253,253,233,186,40,163,82,123,56,244,254,79,71,92,116,172,104,147,20,174,189,54,137,23,246,178,124,247,176,24,238,68,253,253,233,186,40,163,229,172,0,114,254,79,71,92,116,172,104,147,20,174,189,54,137,23,246,178,124,247,176,24,238,68,253,253,233,186,40,163,152,92,225,42,240,171,219,118,116,172,104,147,20,174,189,54,137,23,246,178,124,247,176,24,238,68,253,253,233,186,40,163,139,243,123,44,240,171,219,118,116,172,104,147,20,174,189,54,137,23,246,178,124,247,176,24,238,68,253,253,233,186,40,163,248,103,199,37,16,8,159,27,191,1,63,178,89,185,204,8,137,23,246,178,124,247,176,24,238,68,253,253,233,186,40,163,18,138,48,214,16,8,159,27,191,1,63,178,89,185,204,8,137,23,246,178,124,247,176,24,238,68,253,253,233,186,40,163,235,63,48,78,217,233,2,26,191,1,63,178,89,185,204,8,137,23,246,178,124,247,176,24,238,68,253,253,233,186,40,163,85,246,102,71,217,233,2,26,191,1,63,178,89,185,204,8,137,23,246,178,124,247,176,24,238,68,253,253,233,186,40,163,183,97,23,93,130,244,157,66,116,89,239,252,89,185,204,8,137,23,246,178,124,247,176,24,238,68,253,253,233,186,40,163,118,250,138,255,130,244,157,66,116,89,239,252,89,185,204,8,137,23,246,178,124,247,176,24,238,68,253,253,233,186,40,163,123,112,151,77,120,114,184,129,116,89,239,252,89,185,204,8,137,23,246,178,124,247,176,24,238,68,253,253,233,186,40,163,190,117,84,232,120,114,184,129,116,89,239,252,89,185,204,8,137,23,246,178,124,247,176,24,238,68,253,253,233,186,40,163,190,42,18,93,115,230,103,83,250,214,185,120,6,177,124,63,141,126,172,97,186,114,88,100,190,95,10,234,233,186,40,163,179,219,237,53,115,230,103,83,250,214,185,120,6,177,124,63,141,126,172,97,186,114,88,100,190,95,10,234,233,186,40,163,124,122,74,20,97,4,88,76,250,214,185,120,6,177,124,63,141,126,172,97,186,114,88,100,190,95,10,234,233,186,40,163,107,191,176,137,97,4,88,76,250,214,185,120,6,177,124,63,141,126,172,97,186,114,88,100,190,95,10,234,233,186,40,163,54,175,181,107,59,91,192,30,154,31,201,64,6,177,124,63,141,126,172,97,186,114,88,100,190,95,10,234,233,186,40,163,142,110,105,47,59,91,192,30,154,31,201,64,6,177,124,63,141,126,172,97,186,114,88,100,190,95,10,234,233,186,40,163,17,253,130,64,228,124,158,215,154,31,201,64,6,177,124,63,141,126,172,97,186,114,88,100,190,95,10,234,233,186,40,163,222,49,152,50,228,124,158,215,154,31,201,64,6,177,124,63,141,126,172,97,186,114,88,100,190,95,10,234,233,186,40,163,55,250,154,108,155,128,129,53,134,36,253,166,16,136,239,89,141,126,172,97,186,114,88,100,190,95,10,234,233,186,40,163,26,56,103,115,155,128,129,53,134,36,253,166,16,136,239,89,141,126,172,97,186,114,88,100,190,95,10,234,233,186,40,163,107,219,25,215,247,173,148,218,134,36,253,166,16,136,239,89,141,126,172,97,186,114,88,100,190,95,10,234,233,186,40,163,68,73,225,98,247,173,148,218,134,36,253,166,16,136,239,89,141,126,172,97,186,114,88,100,190,95,10,234,233,186,40,163,170,145,250,144,247,90,52,20,72,129,76,253,16,136,239,89,141,126,172,97,186,114,88,100,190,95,10,234,233,186,40,163,125,6,206,178,247,90,52,20,72,129,76,253,16,136,239,89,141,126,172,97,186,114,88,100,190,95,10,234,233,186,40,163,158,19,247,69,247,95,164,102,72,129,76,253,16,136,239,89,141,126,172,97,186,114,88,100,190,95,10,234,233,186,40,163,206,100,175,127,247,95,164,102,72,129,76,253,16,136,239,89,141,126,172,97,186,114,88,100,190,95,10,234,233,186,40,163,188,45,2,3,212,87,54,4,182,96,47,198,19,88,25,99,179,154,15,154,186,114,88,100,190,95,10,234,233,186,40,163,153,200,212,164,212,87,54,4,182,96,47,198,19,88,25,99,179,154,15,154,186,114,88,100,190,95,10,234,233,186,40,163,154,24,194,145,102,246,218,123,182,96,47,198,19,88,25,99,179,154,15,154,186,114,88,100,190,95,10,234,233,186,40,163,199,126,46,239,102,246,218,123,182,96,47,198,19,88,25,99,179,154,15,154,186,114,88,100,190,95,10,234,233,186,40,163,240,97,62,59,132,77,109,128,30,168,105,2,19,88,25,99,179,154,15,154,186,114,88,100,190,95,10,234,233,186,40,163,219,53,73,113,132,77,109,128,30,168,105,2,19,88,25,99,179,154,15,154,186,114,88,100,190,95,10,234,233,186,40,163,46,208,65,46,219,116,26,133,30,168,105,2,19,88,25,99,179,154,15,154,186,114,88,100,190,95,10,234,233,186,40,163,45,184,154,234,219,116,26,133,30,168,105,2,19,88,25,99,179,154,15,154,186,114,88,100,190,95,10,234,233,186,40,163,80,96,1,217,107,212,205,186,49,177,100,105,12,28,250,33,179,154,15,154,186,114,88,100,190,95,10,234,233,186,40,163,153,97,185,189,107,212,205,186,49,177,100,105,12,28,250,33,179,154,15,154,186,114,88,100,190,95,10,234,233,186,40,163,129,235,150,104,41,140,2,32,49,177,100,105,12,28,250,33,179,154,15,154,186,114,88,100,190,95,10,234,233,186,40,163,222,177,59,129,41,140,2,32,49,177,100,105,12,28,250,33,179,154,15,154,186,114,88,100,190,95,10,234,233,186,40,163,226,233,82,45,227,209,206,178,239,122,214,94,12,28,250,33,179,154,15,154,186,114,88,100,190,95,10,234,233,186,40,163,135,177,190,75,227,209,206,178,239,122,214,94,12,28,250,33,179,154,15,154,186,114,88,100,190,95,10,234,233,186,40,163,179,166,244,74,27,220,156,60,239,122,214,94,12,28,250,33,179,154,15,154,186,114,88,100,190,95,10,234,233,186,40,163,254,83,235,151,27,220,156,60,239,122,214,94,12,28,250,33,179,154,15,154,186,114,88,100,190,95,10,234,233,186,40,163,159,56,71,63,104,144,222,36,82,146,142,92,101,134,175,232,4,224,138,85,173,101,8,17,190,95,10,234,233,186,40,163,106,234,100,179,104,144,222,36,82,146,142,92,101,134,175,232,4,224,138,85,173,101,8,17,190,95,10,234,233,186,40,163,72,210,203,202,139,83,253,41,82,146,142,92,101,134,175,232,4,224,138,85,173,101,8,17,190,95,10,234,233,186,40,163,37,219,60,174,139,83,253,41,82,146,142,92,101,134,175,232,4,224,138,85,173,101,8,17,190,95,10,234,233,186,40,163,193,83,137,16,92,24,45,75,67,119,114,64,101,134,175,232,4,224,138,85,173,101,8,17,190,95,10,234,233,186,40,163,117,29,49,129,92,24,45,75,67,119,114,64,101,134,175,232,4,224,138,85,173,101,8,17,190,95,10,234,233,186,40,163,200,222,108,14,198,219,28,222,67,119,114,64,101,134,175,232,4,224,138,85,173,101,8,17,190,95,10,234,233,186,40,163,54,223,126,253,198,219,28,222,67,119,114,64,101,134,175,232,4,224,138,85,173,101,8,17,190,95,10,234,233,186,40,163,237,246,91,48,198,140,193,108,237,129,4,58,185,251,175,87,4,224,138,85,173,101,8,17,190,95,10,234,233,186,40,163,42,75,153,9,198,140,193,108,237,129,4,58,185,251,175,87,4,224,138,85,173,101,8,17,190,95,10,234,233,186,40,163,204,182,70,123,33,124,19,168,237,129,4,58,185,251,175,87,4,224,138,85,173,101,8,17,190,95,10,234,233,186,40,163,20,82,162,31,33,124,19,168,237,129,4,58,185,251,175,87,4,224,138,85,173,101,8,17,190,95,10,234,233,186,40,163,92,104,120,163,233,43,141,148,84,198,210,114,185,251,175,87,4,224,138,85,173,101,8,17,190,95,10,234,233,186,40,163,170,142,148,194,233,43,141,148,84,198,210,114,185,251,175,87,4,224,138,85,173,101,8,17,190,95,10,234,233,186,40,163,133,175,134,57,188,213,87,64,84,198,210,114,185,251,175,87,4,224,138,85,173,101,8,17,190,95,10,234,233,186,40,163,114,162,123,143,188,213,87,64,84,198,210,114,185,251,175,87,4,224,138,85,173,101,8,17,190,95,10,234,233,186,40,163,204,211,133,58,172,141,153,40,4,89,108,160,216,249,136,47,136,111,137,202,173,101,8,17,190,95,10,234,233,186,40,163,34,19,231,207,172,141,153,40,4,89,108,160,216,249,136,47,136,111,137,202,173,101,8,17,190,95,10,234,233,186,40,163,136,250,88,177,28,20,32,234,4,89,108,160,216,249,136,47,136,111,137,202,173,101,8,17,190,95,10,234,233,186,40,163,121,174,231,52,28,20,32,234,4,89,108,160,216,249,136,47,136,111,137,202,173,101,8,17,190,95,10,234,233,186,40,163,91,241,161,135,1,39,136,34,213,47,116,54,216,249,136,47,136,111,137,202,173,101,8,17,190,95,10,234,233,186,40,163,238,169,212,146,1,39,136,34,213,47,116,54,216,249,136,47,136,111,137,202,173,101,8,17,190,95,10,234,233,186,40,163,228,237,231,18,80,230,21,186,213,47,116,54,216,249,136,47,136,111,137,202,173,101,8,17,190,95,10,234,233,186,40,163,170,251,202,84,80,230,21,186,213,47,116,54,216,249,136,47,136,111,137,202,173,101,8,17,190,95,10,234,233,186,40,163,218,135,59,199,10,168,54,220,255,52,36,199,124,230,161,37,136,111,137,202,173,101,8,17,190,95,10,234,233,186,40,163,165,178,89,96,10,168,54,220,255,52,36,199,124,230,161,37,136,111,137,202,173,101,8,17,190,95,10,234,233,186,40,163,234,229,141,108,8,71,93,60,255,52,36,199,124,230,161,37,136,111,137,202,173,101,8,17,190,95,10,234,233,186,40,163,248,202,177,222,8,71,93,60,255,52,36,199,124,230,161,37,136,111,137,202,173,101,8,17,190,95,10,234,233,186,40,163,36,65,173,224,188,66,117,38,117,207,98,22,124,230,161,37,136,111,137,202,173,101,8,17,190,95,10,234,233,186,40,163,120,41,139,215,188,66,117,38,117,207,98,22,124,230,161,37,136,111,137,202,173,101,8,17,190,95,10,234,233,186,40,163,106,100,234,202,132,221,207,206,117,207,98,22,124,230,161,37,136,111,137,202,173,101,8,17,190,95,10,234,233,186,40,163,128,97,225,251,132,221,207,206,117,207,98,22,124,230,161,37,136,111,137,202,173,101,8,17,190,95,10,234,233,186,40,163,145,135,162,162,128,226,107,222,27,245,169,183,60,125,13,147,237,58,98,217,159,169,75,34,212,98,161,137,210,194,150,8,236,222,20,34,128,226,107,222,27,245,169,183,60,125,13,147,237,58,98,217,159,169,75,34,212,98,161,137,210,194,150,8,87,151,161,224,245,250,197,106,27,245,169,183,60,125,13,147,237,58,98,217,159,169,75,34,212,98,161,137,210,194,150,8,186,253,245,44,245,250,197,106,27,245,169,183,60,125,13,147,237,58,98,217,159,169,75,34,212,98,161,137,210,194,150,8,100,154,175,183,44,49,136,235,120,234,96,37,60,125,13,147,237,58,98,217,159,169,75,34,212,98,161,137,210,194,150,8,84,113,162,206,44,49,136,235,120,234,96,37,60,125,13,147,237,58,98,217,159,169,75,34,212,98,161,137,210,194,150,8,93,121,19,131,91,181,125,107,120,234,96,37,60,125,13,147,237,58,98,217,159,169,75,34,212,98,161,137,210,194,150,8,109,12,155,201,91,181,125,107,120,234,96,37,60,125,13,147,237,58,98,217,159,169,75,34,212,98,161,137,210,194,150,8,19,222,160,237,67,77,22,132,180,72,145,62,24,52,46,163,237,58,98,217,159,169,75,34,212,98,161,137,210,194,150,8,120,4,60,73,67,77,22,132,180,72,145,62,24,52,46,163,237,58,98,217,159,169,75,34,212,98,161,137,210,194,150,8,108,243,228,36,222,239,160,173,180,72,145,62,24,52,46,163,237,58,98,217,159,169,75,34,212,98,161,137,210,194,150,8,167,13,195,180,222,239,160,173,180,72,145,62,24,52,46,163,237,58,98,217,159,169,75,34,212,98,161,137,210,194,150,8,231,211,112,6,114,90,52,194,139,74,217,121,24,52,46,163,237,58,98,217,159,169,75,34,212,98,161,137,210,194,150,8,28,72,216,0,114,90,52,194,139,74,217,121,24,52,46,163,237,58,98,217,159,169,75,34,212,98,161,137,210,194,150,8,7,25,79,120,146,163,201,62,139,74,217,121,24,52,46,163,237,58,98,217,159,169,75,34,212,98,161,137,210,194,150,8,34,247,115,50,146,163,201,62,139,74,217,121,24,52,46,163,237,58,98,217,159,169,75,34,212,98,161,137,210,194,150,8,44,6,104,153,56,87,29,135,154,198,105,207,151,164,231,88,37,0,202,214,159,169,75,34,212,98,161,137,210,194,150,8,224,173,218,66,56,87,29,135,154,198,105,207,151,164,231,88,37,0,202,214,159,169,75,34,212,98,161,137,210,194,150,8,72,63,198,240,65,166,63,192,154,198,105,207,151,164,231,88,37,0,202,214,159,169,75,34,212,98,161,137,210,194,150,8,101,176,42,94,65,166,63,192,154,198,105,207,151,164,231,88,37,0,202,214,159,169,75,34,212,98,161,137,210,194,150,8,51,230,53,223,28,211,208,130,48,96,188,16,151,164,231,88,37,0,202,214,159,169,75,34,212,98,161,137,210,194,150,8,243,34,3,7,28,211,208,130,48,96,188,16,151,164,231,88,37,0,202,214,159,169,75,34,212,98,161,137,210,194,150,8,52,105,9,172,54,211,161,218,48,96,188,16,151,164,231,88,37,0,202,214,159,169,75,34,212,98,161,137,210,194,150,8,243,118,3,198,54,211,161,218,48,96,188,16,151,164,231,88,37,0,202,214,159,169,75,34,212,98,161,137,210,194,150,8,177,224,62,9,59,205,5,114,107,174,125,168,56,155,1,160,37,0,202,214,159,169,75,34,212,98,161,137,210,194,150,8,174,40,34,202,59,205,5,114,107,174,125,168,56,155,1,160,37,0,202,214,159,169,75,34,212,98,161,137,210,194,150,8,37,254,205,200,44,209,46,252,107,174,125,168,56,155,1,160,37,0,202,214,159,169,75,34,212,98,161,137,210,194,150,8,73,147,168,22,44,209,46,252,107,174,125,168,56,155,1,160,37,0,202,214,159,169,75,34,212,98,161,137,210,194,150,8,114,160,12,107,235,25,189,36,233,112,219,6,56,155,1,160,37,0,202,214,159,169,75,34,212,98,161,137,210,194,150,8,135,62,227,125,235,25,189,36,233,112,219,6,56,155,1,160,37,0,202,214,159,169,75,34,212,98,161,137,210,194,150,8,5,89,61,218,90,114,53,227,233,112,219,6,56,155,1,160,37,0,202,214,159,169,75,34,212,98,161,137,210,194,150,8,0,103,126,218,90,114,53,227,233,112,219,6,56,155,1,160,37,0,202,214,159,169,75,34,212,98,161,137,210,194,150,8,200,61,131,83,40,49,219,14,254,162,186,146,250,26,34,183,72,63,60,250,171,254,193,32,212,98,161,137,210,194,150,8,9,212,71,125,40,49,219,14,254,162,186,146,250,26,34,183,72,63,60,250,171,254,193,32,212,98,161,137,210,194,150,8,185,131,237,198,15,92,26,171,254,162,186,146,250,26,34,183,72,63,60,250,171,254,193,32,212,98,161,137,210,194,150,8,235,30,236,207,15,92,26,171,254,162,186,146,250,26,34,183,72,63,60,250,171,254,193,32,212,98,161,137,210,194,150,8,102,57,123,99,144,130,249,100,143,201,181,210,250,26,34,183,72,63,60,250,171,254,193,32,212,98,161,137,210,194,150,8,225,61,208,112,144,130,249,100,143,201,181,210,250,26,34,183,72,63,60,250,171,254,193,32,212,98,161,137,210,194,150,8,25,186,35,229,160,164,55,211,143,201,181,210,250,26,34,183,72,63,60,250,171,254,193,32,212,98,161,137,210,194,150,8,71,28,42,28,160,164,55,211,143,201,181,210,250,26,34,183,72,63,60,250,171,254,193,32,212,98,161,137,210,194,150,8,133,16,82,153,87,90,43,2,165,219,251,178,104,250,180,234,72,63,60,250,171,254,193,32,212,98,161,137,210,194,150,8,158,90,56,92,87,90,43,2,165,219,251,178,104,250,180,234,72,63,60,250,171,254,193,32,212,98,161,137,210,194,150,8,45,20,64,214,230,188,131,193,165,219,251,178,104,250,180,234,72,63,60,250,171,254,193,32,212,98,161,137,210,194,150,8,52,230,163,184,230,188,131,193,165,219,251,178,104,250,180,234,72,63,60,250,171,254,193,32,212,98,161,137,210,194,150,8,249,52,201,130,77,104,95,217,211,245,148,89,104,250,180,234,72,63,60,250,171,254,193,32,212,98,161,137,210,194,150,8,172,131,94,26,77,104,95,217,211,245,148,89,104,250,180,234,72,63,60,250,171,254,193,32,212,98,161,137,210,194,150,8,240,84,64,243,170,132,164,23,211,245,148,89,104,250,180,234,72,63,60,250,171,254,193,32,212,98,161,137,210,194,150,8,242,21,132,147,170,132,164,23,211,245,148,89,104,250,180,234,72,63,60,250,171,254,193,32,212,98,161,137,210,194,150,8,246,205,26,211,15,174,31,185,55,209,133,81,85,74,56,148,53,81,124,127,171,254,193,32,212,98,161,137,210,194,150,8,51,52,187,146,15,174,31,185,55,209,133,81,85,74,56,148,53,81,124,127,171,254,193,32,212,98,161,137,210,194,150,8,209,21,230,137,138,228,144,223,55,209,133,81,85,74,56,148,53,81,124,127,171,254,193,32,212,98,161,137,210,194,150,8,105,175,61,221,138,228,144,223,55,209,133,81,85,74,56,148,53,81,124,127,171,254,193,32,212,98,161,137,210,194,150,8,144,150,137,124,174,229,97,187,146,63,122,179,85,74,56,148,53,81,124,127,171,254,193,32,212,98,161,137,210,194,150,8,88,0,176,105,174,229,97,187,146,63,122,179,85,74,56,148,53,81,124,127,171,254,193,32,212,98,161,137,210,194,150,8,169,190,150,90,126,224,170,11,146,63,122,179,85,74,56,148,53,81,124,127,171,254,193,32,212,98,161,137,210,194,150,8,228,223,2,180,126,224,170,11,146,63,122,179,85,74,56,148,53,81,124,127,171,254,193,32,212,98,161,137,210,194,150,8,40,91,71,138,119,136,244,54,130,225,71,203,216,9,61,7,53,81,124,127,171,254,193,32,212,98,161,137,210,194,150,8,170,98,205,172,119,136,244,54,130,225,71,203,216,9,61,7,53,81,124,127,171,254,193,32,212,98,161,137,210,194,150,8,41,178,241,149,28,208,76,222,130,225,71,203,216,9,61,7,53,81,124,127,171,254,193,32,212,98,161,137,210,194,150,8,242,213,140,102,28,208,76,222,130,225,71,203,216,9,61,7,53,81,124,127,171,254,193,32,212,98,161,137,210,194,150,8,194,159,187,87,57,72,39,59,255,196,49,66,216,9,61,7,53,81,124,127,171,254,193,32,212,98,161,137,210,194,150,8,113,112,82,60,57,72,39,59,255,196,49,66,216,9,61,7,53,81,124,127,171,254,193,32,212,98,161,137,210,194,150,8,177,199,28,202,169,232,218,239,255,196,49,66,216,9,61,7,53,81,124,127,171,254,193,32,212,98,161,137,210,194,150,8,136,172,169,188,169,232,218,239,255,196,49,66,216,9,61,7,53,81,124,127,171,254,193,32,212,98,161,137,210,194,150,8,162,249,44,225,249,165,132,202,234,33,63,146,60,224,153,145,218,21,199,202,42,98,34,4,214,10,216,155,210,194,150,8,9,199,205,7,249,165,132,202,234,33,63,146,60,224,153,145,218,21,199,202,42,98,34,4,214,10,216,155,210,194,150,8,158,22,105,45,34,213,230,145,234,33,63,146,60,224,153,145,218,21,199,202,42,98,34,4,214,10,216,155,210,194,150,8,132,229,226,98,34,213,230,145,234,33,63,146,60,224,153,145,218,21,199,202,42,98,34,4,214,10,216,155,210,194,150,8,200,45,189,25,171,54,42,114,248,169,247,187,60,224,153,145,218,21,199,202,42,98,34,4,214,10,216,155,210,194,150,8,129,185,103,208,171,54,42,114,248,169,247,187,60,224,153,145,218,21,199,202,42,98,34,4,214,10,216,155,210,194,150,8,213,54,2,126,251,67,226,96,248,169,247,187,60,224,153,145,218,21,199,202,42,98,34,4,214,10,216,155,210,194,150,8,202,91,179,42,251,67,226,96,248,169,247,187,60,224,153,145,218,21,199,202,42,98,34,4,214,10,216,155,210,194,150,8,137,186,245,105,144,131,210,251,112,70,110,179,241,127,216,216,218,21,199,202,42,98,34,4,214,10,216,155,210,194,150,8,142,224,63,170,144,131,210,251,112,70,110,179,241,127,216,216,218,21,199,202,42,98,34,4,214,10,216,155,210,194,150,8,88,126,216,140,30,32,172,94,112,70,110,179,241,127,216,216,218,21,199,202,42,98,34,4,214,10,216,155,210,194,150,8,74,26,77,175,30,32,172,94,112,70,110,179,241,127,216,216,218,21,199,202,42,98,34,4,214,10,216,155,210,194,150,8,156,192,38,255,33,149,144,107,38,82,1,94,241,127,216,216,218,21,199,202,42,98,34,4,214,10,216,155,210,194,150,8,18,235,136,40,33,149,144,107,38,82,1,94,241,127,216,216,218,21,199,202,42,98,34,4,214,10,216,155,210,194,150,8,39,109,33,227,5,87,237,247,38,82,1,94,241,127,216,216,218,21,199,202,42,98,34,4,214,10,216,155,210,194,150,8,28,233,255,161,5,87,237,247,38,82,1,94,241,127,216,216,218,21,199,202,42,98,34,4,214,10,216,155,210,194,150,8,149,99,100,231,97,200,185,0,114,180,59,25,239,193,60,24,220,94,32,174,42,98,34,4,214,10,216,155,210,194,150,8,82,143,178,188,97,200,185,0,114,180,59,25,239,193,60,24,220,94,32,174,42,98,34,4,214,10,216,155,210,194,150,8,183,230,240,243,50,21,131,25,114,180,59,25,239,193,60,24,220,94,32,174,42,98,34,4,214,10,216,155,210,194,150,8,41,39,133,234,50,21,131,25,114,180,59,25,239,193,60,24,220,94,32,174,42,98,34,4,214,10,216,155,210,194,150,8,198,172,6,104,123,204,219,26,132,238,138,31,239,193,60,24,220,94,32,174,42,98,34,4,214,10,216,155,210,194,150,8,133,198,56,49,123,204,219,26,132,238,138,31,239,193,60,24,220,94,32,174,42,98,34,4,214,10,216,155,210,194,150,8,82,16,201,114,49,236,17,124,132,238,138,31,239,193,60,24,220,94,32,174,42,98,34,4,214,10,216,155,210,194,150,8,36,29,157,93,49,236,17,124,132,238,138,31,239,193,60,24,220,94,32,174,42,98,34,4,214,10,216,155,210,194,150,8,227,62,90,123,221,253,182,84,222,147,38,189,212,95,114,28,220,94,32,174,42,98,34,4,214,10,216,155,210,194,150,8,111,225,187,176,221,253,182,84,222,147,38,189,212,95,114,28,220,94,32,174,42,98,34,4,214,10,216,155,210,194,150,8,176,102,96,95,99,160,74,245,222,147,38,189,212,95,114,28,220,94,32,174,42,98,34,4,214,10,216,155,210,194,150,8,100,22,147,98,99,160,74,245,222,147,38,189,212,95,114,28,220,94,32,174,42,98,34,4,214,10,216,155,210,194,150,8,147,129,183,208,5,201,11,116,231,37,94,179,212,95,114,28,220,94,32,174,42,98,34,4,214,10,216,155,210,194,150,8,228,116,217,35,5,201,11,116,231,37,94,179,212,95,114,28,220,94,32,174,42,98,34,4,214,10,216,155,210,194,150,8,89,243,185,43,160,179,19,124,231,37,94,179,212,95,114,28,220,94,32,174,42,98,34,4,214,10,216,155,210,194,150,8,131,237,195,100,160,179,19,124,231,37,94,179,212,95,114,28,220,94,32,174,42,98,34,4,214,10,216,155,210,194,150,8,179,151,84,159,236,194,218,247,95,184,14,182,203,139,195,106,178,241,150,166,105,50,201,221,214,10,216,155,210,194,150,8,17,209,44,159,236,194,218,247,95,184,14,182,203,139,195,106,178,241,150,166,105,50,201,221,214,10,216,155,210,194,150,8,118,89,121,54,244,73,190,177,95,184,14,182,203,139,195,106,178,241,150,166,105,50,201,221,214,10,216,155,210,194,150,8,167,187,80,191,244,73,190,177,95,184,14,182,203,139,195,106,178,241,150,166,105,50,201,221,214,10,216,155,210,194,150,8,109,95,140,81,42,92,129,234,198,169,156,133,203,139,195,106,178,241,150,166,105,50,201,221,214,10,216,155,210,194,150,8,37,82,106,170,42,92,129,234,198,169,156,133,203,139,195,106,178,241,150,166,105,50,201,221,214,10,216,155,210,194,150,8,252,226,92,204,108,153,157,227,198,169,156,133,203,139,195,106,178,241,150,166,105,50,201,221,214,10,216,155,210,194,150,8,224,108,165,161,108,153,157,227,198,169,156,133,203,139,195,106,178,241,150,166,105,50,201,221,214,10,216,155,210,194,150,8,4,27,80,174,163,50,171,76,217,92,112,26,68,158,170,93,178,241,150,166,105,50,201,221,214,10,216,155,210,194,150,8,207,129,169,31,163,50,171,76,217,92,112,26,68,158,170,93,178,241,150,166,105,50,201,221,214,10,216,155,210,194,150,8,233,224,38,93,241,7,162,27,217,92,112,26,68,158,170,93,178,241,150,166,105,50,201,221,214,10,216,155,210,194,150,8,208,127,241,68,241,7,162,27,217,92,112,26,68,158,170,93,178,241,150,166,105,50,201,221,214,10,216,155,210,194,150,8,164,245,145,211,206,131,153,180,248,164,93,124,68,158,170,93,178,241,150,166,105,50,201,221,214,10,216,155,210,194,150,8,233,162,74,54,206,131,153,180,248,164,93,124,68,158,170,93,178,241,150,166,105,50,201,221,214,10,216,155,210,194,150,8,211,93,159,62,43,141,136,237,248,164,93,124,68,158,170,93,178,241,150,166,105,50,201,221,214,10,216,155,210,194,150,8,22,15,219,10,43,141,136,237,248,164,93,124,68,158,170,93,178,241,150,166,105,50,201,221,214,10,216,155,210,194,150,8,225,239,69,93,58,174,252,226,253,62,176,21,120,148,23,214,214,159,130,226,105,50,201,221,214,10,216,155,210,194,150,8,71,14,45,19,58,174,252,226,253,62,176,21,120,148,23,214,214,159,130,226,105,50,201,221,214,10,216,155,210,194,150,8,75,158,79,2,212,250,126,95,253,62,176,21,120,148,23,214,214,159,130,226,105,50,201,221,214,10,216,155,210,194,150,8,176,167,66,95,212,250,126,95,253,62,176,21,120,148,23,214,214,159,130,226,105,50,201,221,214,10,216,155,210,194,150,8,137,108,87,155,235,72,9,223,219,78,116,24,120,148,23,214,214,159,130,226,105,50,201,221,214,10,216,155,210,194,150,8,221,52,115,89,235,72,9,223,219,78,116,24,120,148,23,214,214,159,130,226,105,50,201,221,214,10,216,155,210,194,150,8,71,201,170,17,200,40,165,177,219,78,116,24,120,148,23,214,214,159,130,226,105,50,201,221,214,10,216,155,210,194,150,8,209,12,100,54,200,40,165,177,219,78,116,24,120,148,23,214,214,159,130,226,105,50,201,221,214,10,216,155,210,194,150,8,31,139,240,132,128,92,178,133,95,12,184,178,84,135,163,76,214,159,130,226,105,50,201,221,214,10,216,155,210,194,150,8,132,15,135,37,128,92,178,133,95,12,184,178,84,135,163,76,214,159,130,226,105,50,201,221,214,10,216,155,210,194,150,8,187,224,195,234,23,150,210,86,95,12,184,178,84,135,163,76,214,159,130,226,105,50,201,221,214,10,216,155,210,194,150,8,99,151,51,84,23,150,210,86,95,12,184,178,84,135,163,76,214,159,130,226,105,50,201,221,214,10,216,155,210,194,150,8,171,22,20,242,205,129,26,178,175,20,10,249,84,135,163,76,214,159,130,226,105,50,201,221,214,10,216,155,210,194,150,8,66,91,87,129,205,129,26,178,175,20,10,249,84,135,163,76,214,159,130,226,105,50,201,221,214,10,216,155,210,194,150,8,58,200,104,48,175,20,10,249,84,135,163,76,214,159,130,226,105,50,201,221,214,10,216,155,210,194,150,8}; + +static const uint32_t T6_COUNT = 256u; +static const uint8_t T6_LEAVES[1024] = {140,30,28,139,112,181,234,109,189,248,70,203,91,147,16,79,160,214,180,247,227,25,226,179,212,216,74,183,29,218,24,245,174,86,29,161,246,98,88,70,57,109,6,63,2,118,183,123,179,170,29,164,240,42,222,210,44,221,150,34,140,58,40,230,34,40,243,221,70,200,235,154,222,245,150,142,26,73,239,10,149,160,70,187,237,164,108,108,255,97,11,186,38,207,198,61,150,120,191,174,140,118,171,135,98,134,144,226,134,125,154,211,144,180,156,190,239,161,238,1,12,58,108,180,205,156,159,243,234,143,100,122,48,2,67,107,16,94,165,53,11,230,64,17,156,108,188,173,215,106,87,57,40,134,87,49,84,165,105,235,101,114,152,214,254,223,71,12,185,117,113,106,204,140,153,139,17,99,242,43,219,162,40,74,235,167,12,139,193,244,177,243,168,241,35,162,3,244,187,19,8,31,0,50,125,10,54,219,128,111,71,120,98,85,123,253,109,114,78,90,28,74,181,193,78,119,202,25,52,9,11,168,104,102,167,195,163,38,76,46,244,20,10,239,73,42,118,156,35,123,44,47,169,77,215,85,5,158,206,234,117,78,236,245,199,90,190,233,27,94,66,124,125,216,144,206,176,49,232,25,240,96,71,166,1,188,162,37,52,170,95,214,202,140,244,166,202,197,31,175,200,200,184,216,89,16,76,83,0,214,101,183,191,58,24,205,152,185,199,196,158,173,204,151,167,47,174,166,166,219,25,142,153,242,69,204,213,20,122,92,72,139,123,35,174,151,61,167,16,59,48,232,207,11,215,131,29,237,20,185,230,248,162,116,73,249,8,193,17,122,162,2,30,200,86,10,211,163,22,176,91,129,245,134,24,251,148,174,99,121,51,181,210,28,91,5,71,206,178,16,152,119,134,224,1,52,173,144,205,28,218,5,29,48,99,59,245,188,174,66,8,246,1,98,79,31,195,126,195,166,105,161,123,146,111,78,186,171,239,170,88,104,171,67,123,130,25,202,36,167,154,238,225,39,215,152,97,177,82,216,38,134,48,83,21,132,181,183,97,164,142,64,211,142,129,160,76,126,37,4,25,250,177,60,206,78,107,67,60,48,109,132,22,42,14,35,31,107,190,9,174,168,77,194,252,126,158,68,230,216,74,121,1,20,130,34,13,230,124,118,63,95,167,113,32,221,48,116,23,12,240,245,169,207,209,44,166,94,127,63,210,175,232,112,46,53,192,226,14,93,152,208,26,123,5,72,249,48,209,230,232,165,48,43,179,126,101,6,6,32,139,240,34,165,130,235,221,88,169,2,237,233,7,132,223,53,21,123,154,157,80,249,223,99,236,125,24,201,152,48,250,227,240,143,9,96,140,175,122,0,252,131,89,231,69,134,174,100,160,121,169,149,133,58,128,154,161,213,253,115,9,78,17,162,36,204,144,39,254,0,202,10,231,163,145,210,170,33,99,34,51,113,149,65,26,52,97,21,41,130,29,16,14,2,224,227,56,163,187,59,211,67,23,244,16,132,64,72,173,170,144,118,245,143,49,245,173,189,32,108,165,42,182,244,204,65,246,192,37,126,173,189,141,102,42,108,224,229,243,79,2,122,50,173,7,109,74,193,250,241,62,119,175,121,191,252,45,227,1,178,83,242,47,51,38,223,124,72,20,198,43,190,11,27,198,20,180,133,14,88,191,54,150,173,116,181,86,153,4,30,100,151,106,29,221,6,67,158,161,230,124,15,88,83,106,181,58,198,103,66,16,18,120,121,81,131,20,21,57,196,42,236,254,91,123,120,206,207,152,201,32,88,58,158,182,125,143,86,40,251,141,75,187,57,85,90,76,77,62,226,116,232,18,4,109,141,210,161,216,65,104,210,138,24,186,238,238,226,2,92,49,35,250,235,8,158,91,229,28,150,74,93,54,112,81,75,223,55,136,55,155,189,6,233,116,184,247,71,111,154,180,28,143,63,60,255,75,249,199,13,97,69,87,36,175,246,44,216,61,168,118,195,204,131,86,83,83,32,90,221,132,118,242,32,60,215,255,231,227,69,26,211,0,238,219,13,201,242,27,183,76,251,117,59,5,13,224,73,11,245,144,125,225,219,27,34,56,188,153,118,49,119,131,237,26,122,26,117,25,247,250,36,237,13,251,160,154,108,50,14,128,55,252,79,241,90,51,108,77,162,87,54,209,217,193,197,84,169,7,120,34,68,123,53,198,185,26,150,54,8,118,114,189,199,0,181,43,2,251,13,79,53,110,102,8,8,189,31,22,138,15,158,160,16,43,19,128,242,89,33,244,11,70,181}; + +static const uint8_t T6_ROOT[4] = {10,218,78,212}; + +static const uint16_t T6_POFF[256] = {0,32,64,96,128,160,192,224,256,288,320,352,384,416,448,480,512,544,576,608,640,672,704,736,768,800,832,864,896,928,960,992,1024,1056,1088,1120,1152,1184,1216,1248,1280,1312,1344,1376,1408,1440,1472,1504,1536,1568,1600,1632,1664,1696,1728,1760,1792,1824,1856,1888,1920,1952,1984,2016,2048,2080,2112,2144,2176,2208,2240,2272,2304,2336,2368,2400,2432,2464,2496,2528,2560,2592,2624,2656,2688,2720,2752,2784,2816,2848,2880,2912,2944,2976,3008,3040,3072,3104,3136,3168,3200,3232,3264,3296,3328,3360,3392,3424,3456,3488,3520,3552,3584,3616,3648,3680,3712,3744,3776,3808,3840,3872,3904,3936,3968,4000,4032,4064,4096,4128,4160,4192,4224,4256,4288,4320,4352,4384,4416,4448,4480,4512,4544,4576,4608,4640,4672,4704,4736,4768,4800,4832,4864,4896,4928,4960,4992,5024,5056,5088,5120,5152,5184,5216,5248,5280,5312,5344,5376,5408,5440,5472,5504,5536,5568,5600,5632,5664,5696,5728,5760,5792,5824,5856,5888,5920,5952,5984,6016,6048,6080,6112,6144,6176,6208,6240,6272,6304,6336,6368,6400,6432,6464,6496,6528,6560,6592,6624,6656,6688,6720,6752,6784,6816,6848,6880,6912,6944,6976,7008,7040,7072,7104,7136,7168,7200,7232,7264,7296,7328,7360,7392,7424,7456,7488,7520,7552,7584,7616,7648,7680,7712,7744,7776,7808,7840,7872,7904,7936,7968,8000,8032,8064,8096,8128,8160}; +static const uint8_t T6_PNSIB[256] = {8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8}; +static const uint8_t T6_PBLOB[8192] = {112,181,234,109,61,1,12,254,33,187,164,242,39,169,252,55,151,142,28,255,20,91,128,192,110,91,64,66,11,50,110,46,140,30,28,139,61,1,12,254,33,187,164,242,39,169,252,55,151,142,28,255,20,91,128,192,110,91,64,66,11,50,110,46,91,147,16,79,201,152,239,203,33,187,164,242,39,169,252,55,151,142,28,255,20,91,128,192,110,91,64,66,11,50,110,46,189,248,70,203,201,152,239,203,33,187,164,242,39,169,252,55,151,142,28,255,20,91,128,192,110,91,64,66,11,50,110,46,227,25,226,179,173,126,189,138,120,56,19,180,39,169,252,55,151,142,28,255,20,91,128,192,110,91,64,66,11,50,110,46,160,214,180,247,173,126,189,138,120,56,19,180,39,169,252,55,151,142,28,255,20,91,128,192,110,91,64,66,11,50,110,46,29,218,24,245,135,255,44,188,120,56,19,180,39,169,252,55,151,142,28,255,20,91,128,192,110,91,64,66,11,50,110,46,212,216,74,183,135,255,44,188,120,56,19,180,39,169,252,55,151,142,28,255,20,91,128,192,110,91,64,66,11,50,110,46,246,98,88,70,236,139,173,115,56,194,247,25,44,119,165,187,151,142,28,255,20,91,128,192,110,91,64,66,11,50,110,46,174,86,29,161,236,139,173,115,56,194,247,25,44,119,165,187,151,142,28,255,20,91,128,192,110,91,64,66,11,50,110,46,2,118,183,123,101,83,76,9,56,194,247,25,44,119,165,187,151,142,28,255,20,91,128,192,110,91,64,66,11,50,110,46,57,109,6,63,101,83,76,9,56,194,247,25,44,119,165,187,151,142,28,255,20,91,128,192,110,91,64,66,11,50,110,46,240,42,222,210,128,122,161,242,120,133,88,237,44,119,165,187,151,142,28,255,20,91,128,192,110,91,64,66,11,50,110,46,179,170,29,164,128,122,161,242,120,133,88,237,44,119,165,187,151,142,28,255,20,91,128,192,110,91,64,66,11,50,110,46,140,58,40,230,12,204,61,48,120,133,88,237,44,119,165,187,151,142,28,255,20,91,128,192,110,91,64,66,11,50,110,46,44,221,150,34,12,204,61,48,120,133,88,237,44,119,165,187,151,142,28,255,20,91,128,192,110,91,64,66,11,50,110,46,70,200,235,154,155,192,177,60,14,228,38,70,8,205,156,178,175,42,80,15,20,91,128,192,110,91,64,66,11,50,110,46,34,40,243,221,155,192,177,60,14,228,38,70,8,205,156,178,175,42,80,15,20,91,128,192,110,91,64,66,11,50,110,46,26,73,239,10,164,82,17,53,14,228,38,70,8,205,156,178,175,42,80,15,20,91,128,192,110,91,64,66,11,50,110,46,222,245,150,142,164,82,17,53,14,228,38,70,8,205,156,178,175,42,80,15,20,91,128,192,110,91,64,66,11,50,110,46,237,164,108,108,158,68,74,89,73,205,248,16,8,205,156,178,175,42,80,15,20,91,128,192,110,91,64,66,11,50,110,46,149,160,70,187,158,68,74,89,73,205,248,16,8,205,156,178,175,42,80,15,20,91,128,192,110,91,64,66,11,50,110,46,38,207,198,61,89,73,17,146,73,205,248,16,8,205,156,178,175,42,80,15,20,91,128,192,110,91,64,66,11,50,110,46,255,97,11,186,89,73,17,146,73,205,248,16,8,205,156,178,175,42,80,15,20,91,128,192,110,91,64,66,11,50,110,46,140,118,171,135,145,169,118,133,95,35,5,66,212,234,181,1,175,42,80,15,20,91,128,192,110,91,64,66,11,50,110,46,150,120,191,174,145,169,118,133,95,35,5,66,212,234,181,1,175,42,80,15,20,91,128,192,110,91,64,66,11,50,110,46,134,125,154,211,163,128,131,192,95,35,5,66,212,234,181,1,175,42,80,15,20,91,128,192,110,91,64,66,11,50,110,46,98,134,144,226,163,128,131,192,95,35,5,66,212,234,181,1,175,42,80,15,20,91,128,192,110,91,64,66,11,50,110,46,239,161,238,1,158,10,68,7,182,80,56,25,212,234,181,1,175,42,80,15,20,91,128,192,110,91,64,66,11,50,110,46,144,180,156,190,158,10,68,7,182,80,56,25,212,234,181,1,175,42,80,15,20,91,128,192,110,91,64,66,11,50,110,46,205,156,159,243,136,66,47,18,182,80,56,25,212,234,181,1,175,42,80,15,20,91,128,192,110,91,64,66,11,50,110,46,12,58,108,180,136,66,47,18,182,80,56,25,212,234,181,1,175,42,80,15,20,91,128,192,110,91,64,66,11,50,110,46,48,2,67,107,248,10,239,42,204,110,154,236,208,111,42,78,192,126,161,111,46,33,156,185,110,91,64,66,11,50,110,46,234,143,100,122,248,10,239,42,204,110,154,236,208,111,42,78,192,126,161,111,46,33,156,185,110,91,64,66,11,50,110,46,11,230,64,17,246,211,40,32,204,110,154,236,208,111,42,78,192,126,161,111,46,33,156,185,110,91,64,66,11,50,110,46,16,94,165,53,246,211,40,32,204,110,154,236,208,111,42,78,192,126,161,111,46,33,156,185,110,91,64,66,11,50,110,46,215,106,87,57,152,82,220,235,137,94,251,72,208,111,42,78,192,126,161,111,46,33,156,185,110,91,64,66,11,50,110,46,156,108,188,173,152,82,220,235,137,94,251,72,208,111,42,78,192,126,161,111,46,33,156,185,110,91,64,66,11,50,110,46,84,165,105,235,140,26,155,3,137,94,251,72,208,111,42,78,192,126,161,111,46,33,156,185,110,91,64,66,11,50,110,46,40,134,87,49,140,26,155,3,137,94,251,72,208,111,42,78,192,126,161,111,46,33,156,185,110,91,64,66,11,50,110,46,254,223,71,12,132,90,115,110,234,251,207,116,249,181,216,28,192,126,161,111,46,33,156,185,110,91,64,66,11,50,110,46,101,114,152,214,132,90,115,110,234,251,207,116,249,181,216,28,192,126,161,111,46,33,156,185,110,91,64,66,11,50,110,46,204,140,153,139,137,178,153,49,234,251,207,116,249,181,216,28,192,126,161,111,46,33,156,185,110,91,64,66,11,50,110,46,185,117,113,106,137,178,153,49,234,251,207,116,249,181,216,28,192,126,161,111,46,33,156,185,110,91,64,66,11,50,110,46,219,162,40,74,69,228,101,168,223,175,27,248,249,181,216,28,192,126,161,111,46,33,156,185,110,91,64,66,11,50,110,46,17,99,242,43,69,228,101,168,223,175,27,248,249,181,216,28,192,126,161,111,46,33,156,185,110,91,64,66,11,50,110,46,193,244,177,243,125,249,212,200,223,175,27,248,249,181,216,28,192,126,161,111,46,33,156,185,110,91,64,66,11,50,110,46,235,167,12,139,125,249,212,200,223,175,27,248,249,181,216,28,192,126,161,111,46,33,156,185,110,91,64,66,11,50,110,46,3,244,187,19,94,151,244,19,107,129,211,179,113,136,247,118,185,81,249,212,46,33,156,185,110,91,64,66,11,50,110,46,168,241,35,162,94,151,244,19,107,129,211,179,113,136,247,118,185,81,249,212,46,33,156,185,110,91,64,66,11,50,110,46,125,10,54,219,202,36,212,89,107,129,211,179,113,136,247,118,185,81,249,212,46,33,156,185,110,91,64,66,11,50,110,46,8,31,0,50,202,36,212,89,107,129,211,179,113,136,247,118,185,81,249,212,46,33,156,185,110,91,64,66,11,50,110,46,98,85,123,253,26,197,238,204,90,36,254,87,113,136,247,118,185,81,249,212,46,33,156,185,110,91,64,66,11,50,110,46,128,111,71,120,26,197,238,204,90,36,254,87,113,136,247,118,185,81,249,212,46,33,156,185,110,91,64,66,11,50,110,46,28,74,181,193,127,14,113,74,90,36,254,87,113,136,247,118,185,81,249,212,46,33,156,185,110,91,64,66,11,50,110,46,109,114,78,90,127,14,113,74,90,36,254,87,113,136,247,118,185,81,249,212,46,33,156,185,110,91,64,66,11,50,110,46,52,9,11,168,169,44,254,214,66,244,207,42,59,29,246,54,185,81,249,212,46,33,156,185,110,91,64,66,11,50,110,46,78,119,202,25,169,44,254,214,66,244,207,42,59,29,246,54,185,81,249,212,46,33,156,185,110,91,64,66,11,50,110,46,163,38,76,46,97,244,180,89,66,244,207,42,59,29,246,54,185,81,249,212,46,33,156,185,110,91,64,66,11,50,110,46,104,102,167,195,97,244,180,89,66,244,207,42,59,29,246,54,185,81,249,212,46,33,156,185,110,91,64,66,11,50,110,46,73,42,118,156,79,101,8,131,93,71,48,66,59,29,246,54,185,81,249,212,46,33,156,185,110,91,64,66,11,50,110,46,244,20,10,239,79,101,8,131,93,71,48,66,59,29,246,54,185,81,249,212,46,33,156,185,110,91,64,66,11,50,110,46,169,77,215,85,169,129,185,199,93,71,48,66,59,29,246,54,185,81,249,212,46,33,156,185,110,91,64,66,11,50,110,46,35,123,44,47,169,129,185,199,93,71,48,66,59,29,246,54,185,81,249,212,46,33,156,185,110,91,64,66,11,50,110,46,117,78,236,245,235,154,192,111,240,18,230,218,49,34,73,109,181,47,173,43,69,90,182,188,13,119,131,192,11,50,110,46,5,158,206,234,235,154,192,111,240,18,230,218,49,34,73,109,181,47,173,43,69,90,182,188,13,119,131,192,11,50,110,46,27,94,66,124,233,56,213,248,240,18,230,218,49,34,73,109,181,47,173,43,69,90,182,188,13,119,131,192,11,50,110,46,199,90,190,233,233,56,213,248,240,18,230,218,49,34,73,109,181,47,173,43,69,90,182,188,13,119,131,192,11,50,110,46,176,49,232,25,220,128,15,34,230,236,117,104,49,34,73,109,181,47,173,43,69,90,182,188,13,119,131,192,11,50,110,46,125,216,144,206,220,128,15,34,230,236,117,104,49,34,73,109,181,47,173,43,69,90,182,188,13,119,131,192,11,50,110,46,1,188,162,37,203,208,234,75,230,236,117,104,49,34,73,109,181,47,173,43,69,90,182,188,13,119,131,192,11,50,110,46,240,96,71,166,203,208,234,75,230,236,117,104,49,34,73,109,181,47,173,43,69,90,182,188,13,119,131,192,11,50,110,46,202,140,244,166,94,202,124,71,119,27,207,13,109,180,97,35,181,47,173,43,69,90,182,188,13,119,131,192,11,50,110,46,52,170,95,214,94,202,124,71,119,27,207,13,109,180,97,35,181,47,173,43,69,90,182,188,13,119,131,192,11,50,110,46,200,200,184,216,10,151,94,242,119,27,207,13,109,180,97,35,181,47,173,43,69,90,182,188,13,119,131,192,11,50,110,46,202,197,31,175,10,151,94,242,119,27,207,13,109,180,97,35,181,47,173,43,69,90,182,188,13,119,131,192,11,50,110,46,0,214,101,183,3,71,78,156,125,228,213,164,109,180,97,35,181,47,173,43,69,90,182,188,13,119,131,192,11,50,110,46,89,16,76,83,3,71,78,156,125,228,213,164,109,180,97,35,181,47,173,43,69,90,182,188,13,119,131,192,11,50,110,46,152,185,199,196,153,28,104,1,125,228,213,164,109,180,97,35,181,47,173,43,69,90,182,188,13,119,131,192,11,50,110,46,191,58,24,205,153,28,104,1,125,228,213,164,109,180,97,35,181,47,173,43,69,90,182,188,13,119,131,192,11,50,110,46,167,47,174,166,133,151,46,226,42,176,96,146,69,205,119,196,160,113,89,231,69,90,182,188,13,119,131,192,11,50,110,46,158,173,204,151,133,151,46,226,42,176,96,146,69,205,119,196,160,113,89,231,69,90,182,188,13,119,131,192,11,50,110,46,153,242,69,204,33,228,244,127,42,176,96,146,69,205,119,196,160,113,89,231,69,90,182,188,13,119,131,192,11,50,110,46,166,219,25,142,33,228,244,127,42,176,96,146,69,205,119,196,160,113,89,231,69,90,182,188,13,119,131,192,11,50,110,46,72,139,123,35,150,96,253,48,201,147,77,187,69,205,119,196,160,113,89,231,69,90,182,188,13,119,131,192,11,50,110,46,213,20,122,92,150,96,253,48,201,147,77,187,69,205,119,196,160,113,89,231,69,90,182,188,13,119,131,192,11,50,110,46,16,59,48,232,68,185,0,27,201,147,77,187,69,205,119,196,160,113,89,231,69,90,182,188,13,119,131,192,11,50,110,46,174,151,61,167,68,185,0,27,201,147,77,187,69,205,119,196,160,113,89,231,69,90,182,188,13,119,131,192,11,50,110,46,29,237,20,185,229,214,255,73,23,131,74,221,194,223,146,194,160,113,89,231,69,90,182,188,13,119,131,192,11,50,110,46,207,11,215,131,229,214,255,73,23,131,74,221,194,223,146,194,160,113,89,231,69,90,182,188,13,119,131,192,11,50,110,46,73,249,8,193,142,154,168,78,23,131,74,221,194,223,146,194,160,113,89,231,69,90,182,188,13,119,131,192,11,50,110,46,230,248,162,116,142,154,168,78,23,131,74,221,194,223,146,194,160,113,89,231,69,90,182,188,13,119,131,192,11,50,110,46,30,200,86,10,109,134,40,163,227,231,120,235,194,223,146,194,160,113,89,231,69,90,182,188,13,119,131,192,11,50,110,46,17,122,162,2,109,134,40,163,227,231,120,235,194,223,146,194,160,113,89,231,69,90,182,188,13,119,131,192,11,50,110,46,91,129,245,134,86,43,234,76,227,231,120,235,194,223,146,194,160,113,89,231,69,90,182,188,13,119,131,192,11,50,110,46,211,163,22,176,86,43,234,76,227,231,120,235,194,223,146,194,160,113,89,231,69,90,182,188,13,119,131,192,11,50,110,46,99,121,51,181,87,196,85,145,88,254,58,219,68,225,163,175,55,106,44,53,36,70,252,155,13,119,131,192,11,50,110,46,24,251,148,174,87,196,85,145,88,254,58,219,68,225,163,175,55,106,44,53,36,70,252,155,13,119,131,192,11,50,110,46,71,206,178,16,95,6,2,213,88,254,58,219,68,225,163,175,55,106,44,53,36,70,252,155,13,119,131,192,11,50,110,46,210,28,91,5,95,6,2,213,88,254,58,219,68,225,163,175,55,106,44,53,36,70,252,155,13,119,131,192,11,50,110,46,1,52,173,144,237,118,80,254,72,165,67,184,68,225,163,175,55,106,44,53,36,70,252,155,13,119,131,192,11,50,110,46,152,119,134,224,237,118,80,254,72,165,67,184,68,225,163,175,55,106,44,53,36,70,252,155,13,119,131,192,11,50,110,46,29,48,99,59,197,94,52,174,72,165,67,184,68,225,163,175,55,106,44,53,36,70,252,155,13,119,131,192,11,50,110,46,205,28,218,5,197,94,52,174,72,165,67,184,68,225,163,175,55,106,44,53,36,70,252,155,13,119,131,192,11,50,110,46,8,246,1,98,170,186,235,80,84,146,181,196,215,183,224,153,55,106,44,53,36,70,252,155,13,119,131,192,11,50,110,46,245,188,174,66,170,186,235,80,84,146,181,196,215,183,224,153,55,106,44,53,36,70,252,155,13,119,131,192,11,50,110,46,195,166,105,161,202,194,96,248,84,146,181,196,215,183,224,153,55,106,44,53,36,70,252,155,13,119,131,192,11,50,110,46,79,31,195,126,202,194,96,248,84,146,181,196,215,183,224,153,55,106,44,53,36,70,252,155,13,119,131,192,11,50,110,46,186,171,239,170,244,54,50,8,202,28,238,117,215,183,224,153,55,106,44,53,36,70,252,155,13,119,131,192,11,50,110,46,123,146,111,78,244,54,50,8,202,28,238,117,215,183,224,153,55,106,44,53,36,70,252,155,13,119,131,192,11,50,110,46,123,130,25,202,234,2,144,51,202,28,238,117,215,183,224,153,55,106,44,53,36,70,252,155,13,119,131,192,11,50,110,46,88,104,171,67,234,2,144,51,202,28,238,117,215,183,224,153,55,106,44,53,36,70,252,155,13,119,131,192,11,50,110,46,225,39,215,152,207,242,127,202,42,74,199,130,90,255,186,65,253,241,16,85,36,70,252,155,13,119,131,192,11,50,110,46,36,167,154,238,207,242,127,202,42,74,199,130,90,255,186,65,253,241,16,85,36,70,252,155,13,119,131,192,11,50,110,46,38,134,48,83,104,123,243,232,42,74,199,130,90,255,186,65,253,241,16,85,36,70,252,155,13,119,131,192,11,50,110,46,97,177,82,216,104,123,243,232,42,74,199,130,90,255,186,65,253,241,16,85,36,70,252,155,13,119,131,192,11,50,110,46,97,164,142,64,126,233,6,137,167,204,43,160,90,255,186,65,253,241,16,85,36,70,252,155,13,119,131,192,11,50,110,46,21,132,181,183,126,233,6,137,167,204,43,160,90,255,186,65,253,241,16,85,36,70,252,155,13,119,131,192,11,50,110,46,76,126,37,4,200,38,101,97,167,204,43,160,90,255,186,65,253,241,16,85,36,70,252,155,13,119,131,192,11,50,110,46,211,142,129,160,200,38,101,97,167,204,43,160,90,255,186,65,253,241,16,85,36,70,252,155,13,119,131,192,11,50,110,46,206,78,107,67,79,168,90,42,1,107,110,249,76,250,12,7,253,241,16,85,36,70,252,155,13,119,131,192,11,50,110,46,25,250,177,60,79,168,90,42,1,107,110,249,76,250,12,7,253,241,16,85,36,70,252,155,13,119,131,192,11,50,110,46,22,42,14,35,225,182,62,86,1,107,110,249,76,250,12,7,253,241,16,85,36,70,252,155,13,119,131,192,11,50,110,46,60,48,109,132,225,182,62,86,1,107,110,249,76,250,12,7,253,241,16,85,36,70,252,155,13,119,131,192,11,50,110,46,174,168,77,194,114,252,130,194,92,96,157,227,76,250,12,7,253,241,16,85,36,70,252,155,13,119,131,192,11,50,110,46,31,107,190,9,114,252,130,194,92,96,157,227,76,250,12,7,253,241,16,85,36,70,252,155,13,119,131,192,11,50,110,46,230,216,74,121,36,64,151,125,92,96,157,227,76,250,12,7,253,241,16,85,36,70,252,155,13,119,131,192,11,50,110,46,252,126,158,68,36,64,151,125,92,96,157,227,76,250,12,7,253,241,16,85,36,70,252,155,13,119,131,192,11,50,110,46,13,230,124,118,17,156,215,202,183,49,105,235,215,51,80,145,139,170,98,148,168,231,5,161,169,49,25,41,205,145,153,193,1,20,130,34,17,156,215,202,183,49,105,235,215,51,80,145,139,170,98,148,168,231,5,161,169,49,25,41,205,145,153,193,32,221,48,116,36,250,74,15,183,49,105,235,215,51,80,145,139,170,98,148,168,231,5,161,169,49,25,41,205,145,153,193,63,95,167,113,36,250,74,15,183,49,105,235,215,51,80,145,139,170,98,148,168,231,5,161,169,49,25,41,205,145,153,193,169,207,209,44,18,101,254,55,135,197,25,81,215,51,80,145,139,170,98,148,168,231,5,161,169,49,25,41,205,145,153,193,23,12,240,245,18,101,254,55,135,197,25,81,215,51,80,145,139,170,98,148,168,231,5,161,169,49,25,41,205,145,153,193,210,175,232,112,231,137,163,84,135,197,25,81,215,51,80,145,139,170,98,148,168,231,5,161,169,49,25,41,205,145,153,193,166,94,127,63,231,137,163,84,135,197,25,81,215,51,80,145,139,170,98,148,168,231,5,161,169,49,25,41,205,145,153,193,14,93,152,208,42,201,125,57,85,173,43,21,129,232,137,147,139,170,98,148,168,231,5,161,169,49,25,41,205,145,153,193,46,53,192,226,42,201,125,57,85,173,43,21,129,232,137,147,139,170,98,148,168,231,5,161,169,49,25,41,205,145,153,193,249,48,209,230,27,246,163,220,85,173,43,21,129,232,137,147,139,170,98,148,168,231,5,161,169,49,25,41,205,145,153,193,26,123,5,72,27,246,163,220,85,173,43,21,129,232,137,147,139,170,98,148,168,231,5,161,169,49,25,41,205,145,153,193,179,126,101,6,84,20,12,42,18,232,75,10,129,232,137,147,139,170,98,148,168,231,5,161,169,49,25,41,205,145,153,193,232,165,48,43,84,20,12,42,18,232,75,10,129,232,137,147,139,170,98,148,168,231,5,161,169,49,25,41,205,145,153,193,34,165,130,235,198,44,224,37,18,232,75,10,129,232,137,147,139,170,98,148,168,231,5,161,169,49,25,41,205,145,153,193,6,32,139,240,198,44,224,37,18,232,75,10,129,232,137,147,139,170,98,148,168,231,5,161,169,49,25,41,205,145,153,193,237,233,7,132,34,148,45,218,205,140,108,59,178,172,196,88,124,98,133,186,168,231,5,161,169,49,25,41,205,145,153,193,221,88,169,2,34,148,45,218,205,140,108,59,178,172,196,88,124,98,133,186,168,231,5,161,169,49,25,41,205,145,153,193,154,157,80,249,138,243,53,229,205,140,108,59,178,172,196,88,124,98,133,186,168,231,5,161,169,49,25,41,205,145,153,193,223,53,21,123,138,243,53,229,205,140,108,59,178,172,196,88,124,98,133,186,168,231,5,161,169,49,25,41,205,145,153,193,24,201,152,48,77,11,238,45,47,105,248,72,178,172,196,88,124,98,133,186,168,231,5,161,169,49,25,41,205,145,153,193,223,99,236,125,77,11,238,45,47,105,248,72,178,172,196,88,124,98,133,186,168,231,5,161,169,49,25,41,205,145,153,193,9,96,140,175,11,32,118,46,47,105,248,72,178,172,196,88,124,98,133,186,168,231,5,161,169,49,25,41,205,145,153,193,250,227,240,143,11,32,118,46,47,105,248,72,178,172,196,88,124,98,133,186,168,231,5,161,169,49,25,41,205,145,153,193,89,231,69,134,170,55,205,13,225,0,77,244,145,124,231,103,124,98,133,186,168,231,5,161,169,49,25,41,205,145,153,193,122,0,252,131,170,55,205,13,225,0,77,244,145,124,231,103,124,98,133,186,168,231,5,161,169,49,25,41,205,145,153,193,169,149,133,58,8,52,141,54,225,0,77,244,145,124,231,103,124,98,133,186,168,231,5,161,169,49,25,41,205,145,153,193,174,100,160,121,8,52,141,54,225,0,77,244,145,124,231,103,124,98,133,186,168,231,5,161,169,49,25,41,205,145,153,193,253,115,9,78,253,6,237,133,242,199,92,176,145,124,231,103,124,98,133,186,168,231,5,161,169,49,25,41,205,145,153,193,128,154,161,213,253,6,237,133,242,199,92,176,145,124,231,103,124,98,133,186,168,231,5,161,169,49,25,41,205,145,153,193,144,39,254,0,138,111,241,58,242,199,92,176,145,124,231,103,124,98,133,186,168,231,5,161,169,49,25,41,205,145,153,193,17,162,36,204,138,111,241,58,242,199,92,176,145,124,231,103,124,98,133,186,168,231,5,161,169,49,25,41,205,145,153,193,145,210,170,33,178,23,38,4,249,81,224,211,160,149,210,156,151,119,3,93,184,170,201,21,169,49,25,41,205,145,153,193,202,10,231,163,178,23,38,4,249,81,224,211,160,149,210,156,151,119,3,93,184,170,201,21,169,49,25,41,205,145,153,193,149,65,26,52,53,143,130,185,249,81,224,211,160,149,210,156,151,119,3,93,184,170,201,21,169,49,25,41,205,145,153,193,99,34,51,113,53,143,130,185,249,81,224,211,160,149,210,156,151,119,3,93,184,170,201,21,169,49,25,41,205,145,153,193,29,16,14,2,184,254,73,5,136,27,21,135,160,149,210,156,151,119,3,93,184,170,201,21,169,49,25,41,205,145,153,193,97,21,41,130,184,254,73,5,136,27,21,135,160,149,210,156,151,119,3,93,184,170,201,21,169,49,25,41,205,145,153,193,187,59,211,67,136,22,241,209,136,27,21,135,160,149,210,156,151,119,3,93,184,170,201,21,169,49,25,41,205,145,153,193,224,227,56,163,136,22,241,209,136,27,21,135,160,149,210,156,151,119,3,93,184,170,201,21,169,49,25,41,205,145,153,193,64,72,173,170,66,192,129,16,61,45,220,54,214,237,112,183,151,119,3,93,184,170,201,21,169,49,25,41,205,145,153,193,23,244,16,132,66,192,129,16,61,45,220,54,214,237,112,183,151,119,3,93,184,170,201,21,169,49,25,41,205,145,153,193,49,245,173,189,156,87,248,186,61,45,220,54,214,237,112,183,151,119,3,93,184,170,201,21,169,49,25,41,205,145,153,193,144,118,245,143,156,87,248,186,61,45,220,54,214,237,112,183,151,119,3,93,184,170,201,21,169,49,25,41,205,145,153,193,182,244,204,65,238,158,136,206,182,187,182,151,214,237,112,183,151,119,3,93,184,170,201,21,169,49,25,41,205,145,153,193,32,108,165,42,238,158,136,206,182,187,182,151,214,237,112,183,151,119,3,93,184,170,201,21,169,49,25,41,205,145,153,193,173,189,141,102,147,196,149,176,182,187,182,151,214,237,112,183,151,119,3,93,184,170,201,21,169,49,25,41,205,145,153,193,246,192,37,126,147,196,149,176,182,187,182,151,214,237,112,183,151,119,3,93,184,170,201,21,169,49,25,41,205,145,153,193,243,79,2,122,92,204,93,90,204,88,124,201,60,130,205,15,127,121,251,11,184,170,201,21,169,49,25,41,205,145,153,193,42,108,224,229,92,204,93,90,204,88,124,201,60,130,205,15,127,121,251,11,184,170,201,21,169,49,25,41,205,145,153,193,74,193,250,241,54,84,165,173,204,88,124,201,60,130,205,15,127,121,251,11,184,170,201,21,169,49,25,41,205,145,153,193,50,173,7,109,54,84,165,173,204,88,124,201,60,130,205,15,127,121,251,11,184,170,201,21,169,49,25,41,205,145,153,193,191,252,45,227,225,175,9,37,8,140,232,231,60,130,205,15,127,121,251,11,184,170,201,21,169,49,25,41,205,145,153,193,62,119,175,121,225,175,9,37,8,140,232,231,60,130,205,15,127,121,251,11,184,170,201,21,169,49,25,41,205,145,153,193,47,51,38,223,133,178,119,244,8,140,232,231,60,130,205,15,127,121,251,11,184,170,201,21,169,49,25,41,205,145,153,193,1,178,83,242,133,178,119,244,8,140,232,231,60,130,205,15,127,121,251,11,184,170,201,21,169,49,25,41,205,145,153,193,43,190,11,27,44,226,61,236,100,101,157,254,86,254,208,218,127,121,251,11,184,170,201,21,169,49,25,41,205,145,153,193,124,72,20,198,44,226,61,236,100,101,157,254,86,254,208,218,127,121,251,11,184,170,201,21,169,49,25,41,205,145,153,193,14,88,191,54,173,170,78,64,100,101,157,254,86,254,208,218,127,121,251,11,184,170,201,21,169,49,25,41,205,145,153,193,198,20,180,133,173,170,78,64,100,101,157,254,86,254,208,218,127,121,251,11,184,170,201,21,169,49,25,41,205,145,153,193,86,153,4,30,192,111,140,96,80,35,205,94,86,254,208,218,127,121,251,11,184,170,201,21,169,49,25,41,205,145,153,193,150,173,116,181,192,111,140,96,80,35,205,94,86,254,208,218,127,121,251,11,184,170,201,21,169,49,25,41,205,145,153,193,221,6,67,158,137,248,10,193,80,35,205,94,86,254,208,218,127,121,251,11,184,170,201,21,169,49,25,41,205,145,153,193,100,151,106,29,137,248,10,193,80,35,205,94,86,254,208,218,127,121,251,11,184,170,201,21,169,49,25,41,205,145,153,193,88,83,106,181,176,46,58,108,158,69,241,23,131,185,71,239,249,90,208,219,50,56,128,16,39,36,114,140,205,145,153,193,161,230,124,15,176,46,58,108,158,69,241,23,131,185,71,239,249,90,208,219,50,56,128,16,39,36,114,140,205,145,153,193,16,18,120,121,20,157,197,130,158,69,241,23,131,185,71,239,249,90,208,219,50,56,128,16,39,36,114,140,205,145,153,193,58,198,103,66,20,157,197,130,158,69,241,23,131,185,71,239,249,90,208,219,50,56,128,16,39,36,114,140,205,145,153,193,57,196,42,236,188,201,76,45,180,210,216,181,131,185,71,239,249,90,208,219,50,56,128,16,39,36,114,140,205,145,153,193,81,131,20,21,188,201,76,45,180,210,216,181,131,185,71,239,249,90,208,219,50,56,128,16,39,36,114,140,205,145,153,193,206,207,152,201,155,240,76,139,180,210,216,181,131,185,71,239,249,90,208,219,50,56,128,16,39,36,114,140,205,145,153,193,254,91,123,120,155,240,76,139,180,210,216,181,131,185,71,239,249,90,208,219,50,56,128,16,39,36,114,140,205,145,153,193,182,125,143,86,188,11,230,127,58,74,252,219,243,220,247,115,249,90,208,219,50,56,128,16,39,36,114,140,205,145,153,193,32,88,58,158,188,11,230,127,58,74,252,219,243,220,247,115,249,90,208,219,50,56,128,16,39,36,114,140,205,145,153,193,187,57,85,90,11,131,61,239,58,74,252,219,243,220,247,115,249,90,208,219,50,56,128,16,39,36,114,140,205,145,153,193,40,251,141,75,11,131,61,239,58,74,252,219,243,220,247,115,249,90,208,219,50,56,128,16,39,36,114,140,205,145,153,193,116,232,18,4,37,43,67,225,144,156,101,124,243,220,247,115,249,90,208,219,50,56,128,16,39,36,114,140,205,145,153,193,76,77,62,226,37,43,67,225,144,156,101,124,243,220,247,115,249,90,208,219,50,56,128,16,39,36,114,140,205,145,153,193,216,65,104,210,141,5,173,175,144,156,101,124,243,220,247,115,249,90,208,219,50,56,128,16,39,36,114,140,205,145,153,193,109,141,210,161,141,5,173,175,144,156,101,124,243,220,247,115,249,90,208,219,50,56,128,16,39,36,114,140,205,145,153,193,238,226,2,92,42,239,5,81,77,101,160,124,120,171,179,254,113,48,110,147,50,56,128,16,39,36,114,140,205,145,153,193,138,24,186,238,42,239,5,81,77,101,160,124,120,171,179,254,113,48,110,147,50,56,128,16,39,36,114,140,205,145,153,193,8,158,91,229,207,11,159,198,77,101,160,124,120,171,179,254,113,48,110,147,50,56,128,16,39,36,114,140,205,145,153,193,49,35,250,235,207,11,159,198,77,101,160,124,120,171,179,254,113,48,110,147,50,56,128,16,39,36,114,140,205,145,153,193,54,112,81,75,32,156,170,201,175,73,239,231,120,171,179,254,113,48,110,147,50,56,128,16,39,36,114,140,205,145,153,193,28,150,74,93,32,156,170,201,175,73,239,231,120,171,179,254,113,48,110,147,50,56,128,16,39,36,114,140,205,145,153,193,155,189,6,233,10,239,214,233,175,73,239,231,120,171,179,254,113,48,110,147,50,56,128,16,39,36,114,140,205,145,153,193,223,55,136,55,10,239,214,233,175,73,239,231,120,171,179,254,113,48,110,147,50,56,128,16,39,36,114,140,205,145,153,193,111,154,180,28,25,229,39,173,135,72,150,61,147,214,199,152,113,48,110,147,50,56,128,16,39,36,114,140,205,145,153,193,116,184,247,71,25,229,39,173,135,72,150,61,147,214,199,152,113,48,110,147,50,56,128,16,39,36,114,140,205,145,153,193,75,249,199,13,48,182,150,175,135,72,150,61,147,214,199,152,113,48,110,147,50,56,128,16,39,36,114,140,205,145,153,193,143,63,60,255,48,182,150,175,135,72,150,61,147,214,199,152,113,48,110,147,50,56,128,16,39,36,114,140,205,145,153,193,175,246,44,216,102,52,237,10,146,220,104,237,147,214,199,152,113,48,110,147,50,56,128,16,39,36,114,140,205,145,153,193,97,69,87,36,102,52,237,10,146,220,104,237,147,214,199,152,113,48,110,147,50,56,128,16,39,36,114,140,205,145,153,193,204,131,86,83,35,22,177,96,146,220,104,237,147,214,199,152,113,48,110,147,50,56,128,16,39,36,114,140,205,145,153,193,61,168,118,195,35,22,177,96,146,220,104,237,147,214,199,152,113,48,110,147,50,56,128,16,39,36,114,140,205,145,153,193,132,118,242,32,140,185,38,31,244,28,217,183,43,201,19,159,142,40,224,45,187,74,156,253,39,36,114,140,205,145,153,193,83,32,90,221,140,185,38,31,244,28,217,183,43,201,19,159,142,40,224,45,187,74,156,253,39,36,114,140,205,145,153,193,227,69,26,211,68,62,78,226,244,28,217,183,43,201,19,159,142,40,224,45,187,74,156,253,39,36,114,140,205,145,153,193,60,215,255,231,68,62,78,226,244,28,217,183,43,201,19,159,142,40,224,45,187,74,156,253,39,36,114,140,205,145,153,193,201,242,27,183,96,251,95,202,200,19,188,50,43,201,19,159,142,40,224,45,187,74,156,253,39,36,114,140,205,145,153,193,0,238,219,13,96,251,95,202,200,19,188,50,43,201,19,159,142,40,224,45,187,74,156,253,39,36,114,140,205,145,153,193,5,13,224,73,145,158,120,63,200,19,188,50,43,201,19,159,142,40,224,45,187,74,156,253,39,36,114,140,205,145,153,193,76,251,117,59,145,158,120,63,200,19,188,50,43,201,19,159,142,40,224,45,187,74,156,253,39,36,114,140,205,145,153,193,225,219,27,34,167,21,105,44,50,101,172,28,137,231,135,203,142,40,224,45,187,74,156,253,39,36,114,140,205,145,153,193,11,245,144,125,167,21,105,44,50,101,172,28,137,231,135,203,142,40,224,45,187,74,156,253,39,36,114,140,205,145,153,193,49,119,131,237,24,221,50,135,50,101,172,28,137,231,135,203,142,40,224,45,187,74,156,253,39,36,114,140,205,145,153,193,56,188,153,118,24,221,50,135,50,101,172,28,137,231,135,203,142,40,224,45,187,74,156,253,39,36,114,140,205,145,153,193,25,247,250,36,202,15,38,98,231,5,85,193,137,231,135,203,142,40,224,45,187,74,156,253,39,36,114,140,205,145,153,193,26,122,26,117,202,15,38,98,231,5,85,193,137,231,135,203,142,40,224,45,187,74,156,253,39,36,114,140,205,145,153,193,154,108,50,14,83,247,163,3,231,5,85,193,137,231,135,203,142,40,224,45,187,74,156,253,39,36,114,140,205,145,153,193,237,13,251,160,83,247,163,3,231,5,85,193,137,231,135,203,142,40,224,45,187,74,156,253,39,36,114,140,205,145,153,193,241,90,51,108,160,86,8,3,35,42,57,2,24,12,220,46,6,151,150,245,187,74,156,253,39,36,114,140,205,145,153,193,128,55,252,79,160,86,8,3,35,42,57,2,24,12,220,46,6,151,150,245,187,74,156,253,39,36,114,140,205,145,153,193,209,217,193,197,18,124,187,233,35,42,57,2,24,12,220,46,6,151,150,245,187,74,156,253,39,36,114,140,205,145,153,193,77,162,87,54,18,124,187,233,35,42,57,2,24,12,220,46,6,151,150,245,187,74,156,253,39,36,114,140,205,145,153,193,34,68,123,53,182,252,59,195,146,136,61,177,24,12,220,46,6,151,150,245,187,74,156,253,39,36,114,140,205,145,153,193,84,169,7,120,182,252,59,195,146,136,61,177,24,12,220,46,6,151,150,245,187,74,156,253,39,36,114,140,205,145,153,193,54,8,118,114,58,164,90,159,146,136,61,177,24,12,220,46,6,151,150,245,187,74,156,253,39,36,114,140,205,145,153,193,198,185,26,150,58,164,90,159,146,136,61,177,24,12,220,46,6,151,150,245,187,74,156,253,39,36,114,140,205,145,153,193,43,2,251,13,99,37,63,226,66,59,122,13,69,38,40,64,6,151,150,245,187,74,156,253,39,36,114,140,205,145,153,193,189,199,0,181,99,37,63,226,66,59,122,13,69,38,40,64,6,151,150,245,187,74,156,253,39,36,114,140,205,145,153,193,8,8,189,31,184,65,4,129,66,59,122,13,69,38,40,64,6,151,150,245,187,74,156,253,39,36,114,140,205,145,153,193,79,53,110,102,184,65,4,129,66,59,122,13,69,38,40,64,6,151,150,245,187,74,156,253,39,36,114,140,205,145,153,193,160,16,43,19,103,77,131,14,242,113,165,227,69,38,40,64,6,151,150,245,187,74,156,253,39,36,114,140,205,145,153,193,22,138,15,158,103,77,131,14,242,113,165,227,69,38,40,64,6,151,150,245,187,74,156,253,39,36,114,140,205,145,153,193,244,11,70,181,109,53,81,138,242,113,165,227,69,38,40,64,6,151,150,245,187,74,156,253,39,36,114,140,205,145,153,193,128,242,89,33,109,53,81,138,242,113,165,227,69,38,40,64,6,151,150,245,187,74,156,253,39,36,114,140,205,145,153,193}; + +struct ProofCase { uint32_t count; const uint8_t* leaves; const uint8_t* root; const uint16_t* poff; const uint8_t* pnsib; const uint8_t* pblob; }; +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[2334] = {109,79,84,65,30,9,0,0,2,3,18,190,186,254,202,0,0,0,3,8,8,0,0,8,8,0,0,7,97,190,149,151,182,60,80,58,200,234,98,44,193,140,71,76,243,130,97,209,39,43,50,65,54,148,88,72,127,67,165,233,114,102,17,129,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,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,38,98,213,118,187,206,236,168,51,174,162,73,89,30,161,211,77,42,34,60,159,243,83,241,213,243,188,72,94,109,154,234,68,70,87,185,215,226,224,96,146,252,13,183,118,91,107,134,241,252,255,206,215,103,126,185,188,114,49,17,62,148,194,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,30,57,43,99,175,85,112,245,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,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,0,0,0,0,0,0,0,118,107,52,57,54}; + +static const uint32_t SIM_MOTA_LEN = 2334; +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[2278] = {109,79,84,65,230,8,0,0,2,3,18,190,186,254,202,0,0,0,3,8,8,0,0,8,8,0,0,10,164,51,202,103,182,60,80,58,200,234,98,44,193,140,71,76,243,130,97,209,39,43,50,65,54,148,88,72,127,67,165,233,114,102,17,129,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,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,171,129,180,182,69,0,212,122,174,47,219,250,34,204,198,63,40,244,119,210,21,11,30,188,119,170,189,46,247,90,166,169,6,102,11,226,54,97,112,202,185,10,171,131,170,250,246,20,84,173,222,226,146,215,83,51,164,107,48,67,2,52,40,1,255,255,255,255,226,102,124,83,43,137,26,96,175,85,112,245,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,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,0,0,0,0,0,0,0,118,107,52,57,54}; + +static const uint32_t SIM_MOTA_1K_LEN = 2278; +static const uint32_t SIM_MOTA_1K_BLOCKS = 3; +// detools sequential+crle delta: apply DT_PATCH to DT_BASE -> DT_TARGET +static const uint8_t DT_BASE[3056] = {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,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,0,0,0,0,0,0,0}; + +static const uint32_t DT_BASE_LEN = 3056; +static const uint8_t DT_PATCH[256] = {2,184,50,0,3,0,183,46,1,137,1,0,0,3,58,70,58,1,208,10,0,0,1,90,1,218,11,0,0,219,1,129,4,161,65,21,222,118,227,83,116,130,47,46,29,77,101,152,18,250,150,181,146,187,69,168,41,231,203,39,100,60,10,171,117,166,25,196,223,139,175,135,160,165,105,80,177,67,238,254,178,160,71,28,52,19,182,119,243,6,138,208,97,27,67,219,113,216,204,202,205,248,201,87,118,153,109,114,73,143,254,2,242,154,38,46,102,225,149,144,52,199,233,250,158,219,149,126,61,148,56,5,192,133,20,207,187,123,188,13,103,205,254,252,168,177,233,227,201,226,244,110,112,157,146,184,209,213,57,222,245,174,112,135,212,136,219,201,165,19,83,42,252,113,219,24,136,72,17,243,212,100,222,153,200,61,81,236,19,226,122,18,18,186,90,31,101,103,96,250,243,52,123,4,252,143,86,100,29,235,253,116,35,103,161,211,45,146,97,128,89,98,232,29,89,4,210,177,153,51,113,37,130,194,69,110,100,70,128,12,0,0,252,28,232,94,154,58,84,206,1,40,0,0,1,56}; + +static const uint32_t DT_PATCH_LEN = 256; +static const uint8_t DT_TARGET[3256] = {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,206,245,74,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,250,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,161,65,21,222,118,227,83,116,130,47,46,29,77,101,152,18,250,150,181,146,187,69,168,41,231,203,39,100,60,10,171,117,166,25,196,223,139,175,135,160,165,105,80,177,67,238,254,178,160,71,28,52,19,182,119,243,6,138,208,97,27,67,219,113,216,204,202,205,248,201,87,118,153,109,114,73,143,254,2,242,154,38,46,102,225,149,144,52,199,233,250,158,219,149,126,61,148,56,5,192,133,20,207,187,123,188,13,103,205,254,252,168,177,233,227,201,226,244,110,112,157,146,184,209,213,57,222,245,174,112,135,212,136,219,201,165,19,83,42,252,113,219,24,136,72,17,243,212,100,222,153,200,61,81,236,19,226,122,18,18,186,90,31,101,103,96,250,243,52,123,4,252,143,86,100,29,235,253,116,35,103,161,211,45,146,97,128,89,98,232,29,89,4,210,177,153,51,113,37,130,194,69,110,100,70,128,12,0,0,252,28,232,94,154,58,84,206,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,0,0,0,0,0,0,0}; + +static const uint32_t DT_TARGET_LEN = 3256; +// detools in-place+crle delta: apply DT_IP_PATCH over a DT_IP_MEM buffer holding DT_IP_BASE +static const uint8_t DT_IP_BASE[3056] = {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,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,0,0,0,0,0,0,0}; + +static const uint32_t DT_IP_BASE_LEN = 3056; +static const uint8_t DT_IP_PATCH[266] = {18,128,128,4,128,64,128,192,3,176,47,184,50,0,3,0,183,46,1,137,1,0,0,3,58,70,58,1,208,10,0,0,1,90,1,218,11,0,0,219,1,129,4,161,65,21,222,118,227,83,116,130,47,46,29,77,101,152,18,250,150,181,146,187,69,168,41,231,203,39,100,60,10,171,117,166,25,196,223,139,175,135,160,165,105,80,177,67,238,254,178,160,71,28,52,19,182,119,243,6,138,208,97,27,67,219,113,216,204,202,205,248,201,87,118,153,109,114,73,143,254,2,242,154,38,46,102,225,149,144,52,199,233,250,158,219,149,126,61,148,56,5,192,133,20,207,187,123,188,13,103,205,254,252,168,177,233,227,201,226,244,110,112,157,146,184,209,213,57,222,245,174,112,135,212,136,219,201,165,19,83,42,252,113,219,24,136,72,17,243,212,100,222,153,200,61,81,236,19,226,122,18,18,186,90,31,101,103,96,250,243,52,123,4,252,143,86,100,29,235,253,116,35,103,161,211,45,146,97,128,89,98,232,29,89,4,210,177,153,51,113,37,130,194,69,110,100,70,128,12,0,0,252,28,232,94,154,58,84,206,1,40,0,0,1,56}; + +static const uint32_t DT_IP_PATCH_LEN = 266; +static const uint8_t DT_IP_TARGET[3256] = {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,206,245,74,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,250,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,161,65,21,222,118,227,83,116,130,47,46,29,77,101,152,18,250,150,181,146,187,69,168,41,231,203,39,100,60,10,171,117,166,25,196,223,139,175,135,160,165,105,80,177,67,238,254,178,160,71,28,52,19,182,119,243,6,138,208,97,27,67,219,113,216,204,202,205,248,201,87,118,153,109,114,73,143,254,2,242,154,38,46,102,225,149,144,52,199,233,250,158,219,149,126,61,148,56,5,192,133,20,207,187,123,188,13,103,205,254,252,168,177,233,227,201,226,244,110,112,157,146,184,209,213,57,222,245,174,112,135,212,136,219,201,165,19,83,42,252,113,219,24,136,72,17,243,212,100,222,153,200,61,81,236,19,226,122,18,18,186,90,31,101,103,96,250,243,52,123,4,252,143,86,100,29,235,253,116,35,103,161,211,45,146,97,128,89,98,232,29,89,4,210,177,153,51,113,37,130,194,69,110,100,70,128,12,0,0,252,28,232,94,154,58,84,206,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,0,0,0,0,0,0,0}; + +static const uint32_t DT_IP_TARGET_LEN = 3256; +static const uint32_t DT_IP_MEM = 32768u; diff --git a/test/test_ota/test_ota_core.cpp b/test/test_ota/test_ota_core.cpp new file mode 100644 index 0000000000..bd3b8c83bd --- /dev/null +++ b/test/test_ota/test_ota_core.cpp @@ -0,0 +1,972 @@ +#include +#include +#include +#include + +#include "helpers/ota/MotaContainer.h" +#include "helpers/ota/MerkleTree.h" +#include "helpers/ota/BlockBitmap.h" +#include "helpers/ota/Multihash.h" +#include "helpers/ota/FirmwareInfo.h" +#include "helpers/ota/SignerAllowlist.h" +#include "helpers/ota/OtaStore.h" +#include "helpers/ota/OtaProtocol.h" +#include "helpers/ota/OtaManager.h" +#include "mota_vectors.h" // auto-generated by tools/mota/gen_vectors.py + +extern "C" { + #include "helpers/ota/detools/detools.h" // vendored detools 0.53.0 embeddable decoder +} + +using namespace mesh::ota; + +// Build a flashed-image layout (body || fixed 56-byte EndF) the way the host packager / build hook do: +// marker(4) body_len(4) body_hash8(8) fw_version(4) target_id(4) hw_id(32). Identity is always present +// (zero/"" = unknown). +static std::vector make_image_id(const std::vector& body, uint32_t fw_version, + uint32_t target_id, const char* hw_id) { + std::vector img = body; + img.insert(img.end(), ENDF_MAGIC, ENDF_MAGIC + 4); + uint32_t n = (uint32_t)body.size(); + for (int i = 0; i < 4; i++) img.push_back((uint8_t)(n >> (8 * i))); + uint8_t h[8]; mh8(h, body.data(), body.size()); + img.insert(img.end(), h, h + 8); + for (int i = 0; i < 4; i++) img.push_back((uint8_t)(fw_version >> (8 * i))); + for (int i = 0; i < 4; i++) img.push_back((uint8_t)(target_id >> (8 * i))); + uint8_t hw[32] = {0}; size_t k = hw_id ? strlen(hw_id) : 0; if (k > 32) k = 32; if (k) memcpy(hw, hw_id, k); + img.insert(img.end(), hw, hw + 32); // -> fixed 56-byte trailer + return img; +} +static std::vector make_image(const std::vector& body) { + return make_image_id(body, 0, 0, ""); // zero identity (still a full 56-byte trailer) +} + +// --- cross-check the C++ parser/merkle against the Python reference vectors ---------------- + +TEST(OtaParse, ParsesReferenceContainer) { + MotaManifest m; + ASSERT_TRUE(mota_parse(MOTA_VEC, MOTA_VEC_LEN, m)); + EXPECT_EQ(m.format_ver, MOTA_FORMAT_VER); + EXPECT_TRUE(m.is_full()); + EXPECT_FALSE(m.is_signed()); + EXPECT_EQ(m.target_id, EXP_TARGET_ID); + EXPECT_EQ(m.fw_version, EXP_FW_VERSION); + EXPECT_EQ(m.image_size, EXP_IMAGE_SIZE); + EXPECT_EQ(m.payload_size, EXP_PAYLOAD_SIZE); + EXPECT_EQ(m.block_count, EXP_BLOCK_COUNT); + EXPECT_EQ(m.block_size_log2, EXP_BLOCK_SIZE_LOG2); + 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()); +} + +TEST(OtaParse, RejectsTampering) { + MotaManifest m; + // bad magic + std::vector b(MOTA_VEC, MOTA_VEC + MOTA_VEC_LEN); + b[0] ^= 0xFF; + EXPECT_FALSE(mota_parse(b.data(), b.size(), m)); + // bad trailer + b.assign(MOTA_VEC, MOTA_VEC + MOTA_VEC_LEN); + b[b.size() - 1] ^= 0xFF; + EXPECT_FALSE(mota_parse(b.data(), b.size(), m)); + // wrong total-size field + b.assign(MOTA_VEC, MOTA_VEC + MOTA_VEC_LEN); + b[4] ^= 0x01; + EXPECT_FALSE(mota_parse(b.data(), b.size(), m)); +} + +// block_idx is a uint16 on the wire, so a manifest needing > 65535 blocks can't be addressed and must be +// rejected at parse (this also keeps block_count*4 from overflowing the leaves-length computation). +TEST(OtaParse, RejectsTooManyBlocks) { + auto manifest = [](uint32_t payload_size, uint8_t bsl) { // fixed-layout unsigned-full manifest + std::vector m(MOTA_MFL, 0); + m[0] = MOTA_FORMAT_VER; m[1] = MFLAG_FULL; m[2] = 0x12; + m[15] = payload_size; m[16] = payload_size >> 8; m[17] = payload_size >> 16; m[18] = payload_size >> 24; + m[19] = bsl; + return m; + }; + MotaManifest mm; + auto over = manifest(65536u * 1024u, 10); // 65536 blocks -> rejected + EXPECT_FALSE(mota_parse_manifest(over.data(), over.size(), mm)); + auto ok = manifest(65535u * 1024u, 10); // 65535 blocks -> allowed + EXPECT_TRUE(mota_parse_manifest(ok.data(), ok.size(), mm)); + EXPECT_EQ(mm.block_count, 65535u); +} + +TEST(OtaMerkle, RootMatchesVectorAndLeaves) { + MotaManifest m; + ASSERT_TRUE(mota_parse(MOTA_VEC, MOTA_VEC_LEN, m)); + // root recomputed from stored leaves[] == merkle_root field == Python's EXP_MERKLE_ROOT + uint8_t root[4]; + merkle_root(root, m.leaves, m.block_count); + EXPECT_EQ(0, memcmp(root, EXP_MERKLE_ROOT, 4)); + EXPECT_TRUE(mota_check_root(m)); + // recompute each leaf from the payload block and compare to the stored leaf + uint32_t bs = m.block_size(); + for (uint32_t i = 0; i < m.block_count; i++) { + uint32_t off = i * bs; + uint32_t len = (off + bs <= m.payload_size) ? bs : (m.payload_size - off); + uint8_t leaf[4]; + merkle_leaf(leaf, m.payload + off, len); + EXPECT_EQ(0, memcmp(leaf, m.leaves + i * 4, 4)) << "leaf " << i; + } +} + +TEST(OtaMerkle, FullImageHashMatches) { + MotaManifest m; + ASSERT_TRUE(mota_parse(MOTA_VEC, MOTA_VEC_LEN, m)); + EXPECT_TRUE(mota_check_image_hash_full(m)); +} + +TEST(OtaMerkle, ProofFromReferenceVerifies) { + MotaManifest m; + ASSERT_TRUE(mota_parse(MOTA_VEC, MOTA_VEC_LEN, m)); + uint32_t bs = m.block_size(); + uint32_t off = PROOF_INDEX * bs; + uint32_t len = (off + bs <= m.payload_size) ? bs : (m.payload_size - off); + + EXPECT_TRUE(merkle_verify(m.payload + off, len, PROOF_INDEX, + PROOF_SIBLINGS, PROOF_NSIB, EXP_MERKLE_ROOT, m.block_count)); + + // tampered block -> fails + std::vector blk(m.payload + off, m.payload + off + len); + blk[0] ^= 0xFF; + EXPECT_FALSE(merkle_verify(blk.data(), len, PROOF_INDEX, + PROOF_SIBLINGS, PROOF_NSIB, EXP_MERKLE_ROOT, m.block_count)); + + // wrong index with the same proof -> fails + EXPECT_FALSE(merkle_verify(m.payload + off, len, PROOF_INDEX + 1, + PROOF_SIBLINGS, PROOF_NSIB, EXP_MERKLE_ROOT, m.block_count)); +} + +// --- validate the O(log n) binary-counter root vs a plain level-by-level reference ---------- + +static void ref_root(uint8_t out[4], std::vector> level) { + while (level.size() > 1) { + std::vector> nxt; + for (size_t i = 0; i < level.size(); i += 2) { + if (i + 1 < level.size()) { + std::array p; + merkle_combine(p.data(), level[i].data(), level[i + 1].data()); + nxt.push_back(p); + } else { + nxt.push_back(level[i]); // promote lone last node + } + } + level.swap(nxt); + } + std::memcpy(out, level[0].data(), 4); +} + +TEST(OtaMerkle, BinaryCounterMatchesLevelByLevel) { + uint32_t state = 0x12345678; + auto rnd = [&]() { state = state * 1103515245u + 12345u; return (uint8_t)(state >> 16); }; + // O(log n) root must equal the plain level-by-level root for every count + for (uint32_t count = 1; count <= 600; count++) { + std::vector leaves(count * 4); + std::vector> ref(count); + for (uint32_t i = 0; i < count; i++) + for (int j = 0; j < 4; j++) { uint8_t v = rnd(); leaves[i * 4 + j] = v; ref[i][j] = v; } + uint8_t a[4], b[4]; + merkle_root(a, leaves.data(), count); + ref_root(b, ref); + ASSERT_EQ(0, std::memcmp(a, b, 4)) << "root mismatch count=" << count; + } +} + +// Verify every block's proof for several tricky counts, using proofs generated by the Python +// reference (the oracle) — covers deep promotion chains (100, 255, 256, ...). +TEST(OtaMerkle, ReferenceProofsAllIndices) { + for (int c = 0; c < N_PROOF_CASES; c++) { + const ProofCase& pc = PROOF_CASES[c]; + uint8_t root[4]; + merkle_root(root, pc.leaves, pc.count); + EXPECT_EQ(0, std::memcmp(root, pc.root, 4)) << "root mismatch count=" << pc.count; + for (uint32_t i = 0; i < pc.count; i++) { + EXPECT_TRUE(merkle_verify_from_leaf(pc.leaves + i * 4, i, + pc.pblob + pc.poff[i], pc.pnsib[i], pc.root, pc.count)) + << "count=" << pc.count << " idx=" << i; + } + // a wrong sibling for index 0 must fail + if (pc.pnsib[0] > 0) { + std::vector bad(pc.pblob + pc.poff[0], pc.pblob + pc.poff[0] + pc.pnsib[0] * 4); + bad[0] ^= 0xFF; + EXPECT_FALSE(merkle_verify_from_leaf(pc.leaves, 0, bad.data(), pc.pnsib[0], pc.root, pc.count)); + } + } +} + +// --- availability bitmap (derived from leaves[]) ------------------------------------------- + +TEST(OtaBitmap, AllPresentForCompleteContainer) { + MotaManifest m; + ASSERT_TRUE(mota_parse(MOTA_VEC, MOTA_VEC_LEN, m)); + EXPECT_TRUE(all_present(m.leaves, m.block_count)); + EXPECT_EQ(count_present(m.leaves, m.block_count), m.block_count); + + // a leaf slot of all-FF (erased) means "missing"; bitmap round-trips + std::vector leaves(m.leaves, m.leaves + m.block_count * 4); + std::memset(&leaves[4], 0xFF, 4); // mark block 1 missing + EXPECT_FALSE(leaf_present(leaves.data(), 1)); + EXPECT_FALSE(all_present(leaves.data(), m.block_count)); + EXPECT_EQ(count_present(leaves.data(), m.block_count), m.block_count - 1); + + std::vector bm(bitmap_bytes(m.block_count)); + leaves_to_bitmap(leaves.data(), m.block_count, bm.data()); + EXPECT_FALSE(bitmap_get(bm.data(), 1)); + EXPECT_TRUE(bitmap_get(bm.data(), 0)); +} + +// --- EndF self-firmware scan (P2) ----------------------------------------------------------- + +TEST(OtaFirmwareInfo, FindsEndFInImage) { + std::vector body(4321); + for (size_t i = 0; i < body.size(); i++) body[i] = (uint8_t)(i * 37 + 11); + std::vector img = make_image(body); + + // simulate a flash region: image, then erased 0xFF up to the partition end + std::vector region = img; + region.resize(img.size() + 4096, 0xFF); + + SelfFwInfo fi; + ASSERT_TRUE(find_self_firmware(region.data(), (uint32_t)region.size(), fi, /*verify_body=*/true)); + EXPECT_EQ(fi.body_len, body.size()); + EXPECT_EQ(fi.image_len, img.size()); + EXPECT_EQ(fi.endf_offset, body.size()); + uint8_t h[8]; mh8(h, body.data(), body.size()); + EXPECT_EQ(0, std::memcmp(fi.body_hash, h, 8)); +} + +// The self-describing identity lives at fixed offsets in the 56-byte EndF and is always parsed; a +// zero-identity trailer reports zero/"" (unknown), still at the fixed 56-byte size. +TEST(OtaFirmwareInfo, ParsesIdentity) { + std::vector body(2000); + for (size_t i = 0; i < body.size(); i++) body[i] = (uint8_t)(i * 13 + 5); + + auto img = make_image_id(body, 0x01100000u, 0x04d413fdu, "RAK4631"); + std::vector region = img; region.resize(img.size() + 4096, 0xFF); + SelfFwInfo fi; + ASSERT_TRUE(find_self_firmware(region.data(), (uint32_t)region.size(), fi, /*verify_body=*/true)); + EXPECT_EQ(fi.body_len, body.size()); + EXPECT_EQ(fi.image_len, body.size() + 56); // fixed trailer length + EXPECT_EQ(fi.fw_version, 0x01100000u); + EXPECT_EQ(fi.target_id, 0x04d413fdu); + EXPECT_STREQ(fi.hw_id, "RAK4631"); + + auto img1 = make_image(body); // zero-identity trailer (still 56 bytes) + std::vector r1 = img1; r1.resize(img1.size() + 64, 0xFF); + SelfFwInfo fi1; + ASSERT_TRUE(find_self_firmware(r1.data(), (uint32_t)r1.size(), fi1, true)); + EXPECT_EQ(fi1.fw_version, 0u); + EXPECT_EQ(fi1.target_id, 0u); + EXPECT_STREQ(fi1.hw_id, ""); + EXPECT_EQ(fi1.image_len, body.size() + 56); +} + +TEST(OtaFirmwareInfo, IgnoresStagedMotaHigherInRegion) { + // The firmware's own EndF must win even when a staged .mota (which embeds its own EndF) sits + // above it in the same region — the body_len == offset check disambiguates. + std::vector body(2000); + for (size_t i = 0; i < body.size(); i++) body[i] = (uint8_t)(i ^ 0x5A); + std::vector img = make_image(body); + + std::vector region = img; + region.resize(8192, 0xFF); // gap + // drop the reference .mota (which contains an embedded EndF in its payload) higher up + region.insert(region.end(), MOTA_VEC, MOTA_VEC + MOTA_VEC_LEN); + + SelfFwInfo fi; + ASSERT_TRUE(find_self_firmware(region.data(), (uint32_t)region.size(), fi, true)); + EXPECT_EQ(fi.endf_offset, body.size()); // found OUR firmware, not the .mota's + EXPECT_EQ(fi.body_len, body.size()); +} + +TEST(OtaFirmwareInfo, NoMarkerReturnsFalse) { + std::vector region(1000, 0xAB); + SelfFwInfo fi; + EXPECT_FALSE(find_self_firmware(region.data(), (uint32_t)region.size(), fi)); +} + +// --- signer allowlist (P3) ------------------------------------------------------------------ + +TEST(OtaAllowlist, AddContainsRemoveSerialize) { + SignerAllowlist a; + uint8_t k1[32], k2[32], k3[32]; + memset(k1, 0x11, 32); memset(k2, 0x22, 32); memset(k3, 0x33, 32); + EXPECT_FALSE(a.contains(k1)); + EXPECT_TRUE(a.add(k1)); + EXPECT_TRUE(a.add(k2)); + EXPECT_TRUE(a.add(k1)); // idempotent + EXPECT_EQ(a.count(), 2); + EXPECT_TRUE(a.contains(k1)); + EXPECT_FALSE(a.contains(k3)); + + uint8_t buf[1 + MAX_OTA_SIGNERS * 32]; + uint32_t n = a.serialize(buf, sizeof(buf)); + EXPECT_EQ(n, 1u + 2 * 32); + SignerAllowlist b; + EXPECT_TRUE(b.deserialize(buf, n)); + EXPECT_EQ(b.count(), 2); + EXPECT_TRUE(b.contains(k1) && b.contains(k2)); + + EXPECT_TRUE(a.remove(k1)); + EXPECT_EQ(a.count(), 1); + EXPECT_FALSE(a.contains(k1)); + EXPECT_TRUE(a.contains(k2)); +} + +// --- RAM store: out-of-order writes + availability via leaves[] -------------------------------- + +TEST(OtaStoreRamTest, RandomAccessAndErasedSentinel) { + OtaStoreRam<4096> s; + ASSERT_TRUE(s.begin(1000)); + EXPECT_EQ(s.staged_size(), 1000u); + uint8_t blk[8] = {1,2,3,4,5,6,7,8}; + EXPECT_TRUE(s.write(500, blk, 8)); // out-of-order offset + EXPECT_TRUE(s.write(0, blk, 8)); + EXPECT_FALSE(s.write(998, blk, 8)); // out of range + uint8_t rd[8]; + EXPECT_TRUE(s.read(500, rd, 8)); + EXPECT_EQ(0, memcmp(rd, blk, 8)); + // untouched region reads as erased 0xFF (so an unfilled leaf slot is "missing") + EXPECT_TRUE(s.read(100, rd, 8)); + for (int i = 0; i < 8; i++) EXPECT_EQ(rd[i], 0xFF); +} + +// --- merkle proof GENERATION (server side) matches the Python oracle --------------------------- + +TEST(OtaMerkle, GenProofMatchesPythonAndVerifies) { + for (int c = 0; c < N_PROOF_CASES; c++) { + const ProofCase& pc = PROOF_CASES[c]; + std::vector scratch(pc.count * 4); + uint8_t out[32 * 4]; + for (uint32_t i = 0; i < pc.count; i++) { + uint8_t n = merkle_gen_proof(pc.leaves, pc.count, i, scratch.data(), out); + ASSERT_EQ(n, pc.pnsib[i]) << "count=" << pc.count << " idx=" << i; + EXPECT_EQ(0, std::memcmp(out, pc.pblob + pc.poff[i], (size_t)n * 4)) + << "gen_proof != python count=" << pc.count << " idx=" << i; + EXPECT_TRUE(merkle_verify_from_leaf(pc.leaves + i * 4, i, out, n, pc.root, pc.count)); + } + } +} + +// --- protocol codec round-trips --------------------------------------------------------------- + +TEST(OtaProtocol, CodecRoundTrips) { + uint8_t buf[200]; + + // 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); EXPECT_EQ(n, 10); + AdvMsg a2; ASSERT_TRUE(decode_adv(buf, n, a2)); + 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 * OTA_HAVE_ROW_BYTES]; + for (int i = 0; i < 2 * OTA_HAVE_ROW_BYTES; 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 * OTA_HAVE_ROW_BYTES)); + + GetManifestMsg gm{{1,2,3,4}}; + n = encode_get_manifest(buf, sizeof(buf), gm); + GetManifestMsg g2; ASSERT_TRUE(decode_get_manifest(buf, n, g2)); + EXPECT_EQ(0, memcmp(g2.manifest_id, gm.manifest_id, 4)); + + uint8_t mbytes[40]; for (int i = 0; i < 40; i++) mbytes[i] = (uint8_t)(i + 1); + ManifestMsg mm{{9,8,7,6}, 0, 1, mbytes, 40}; + n = encode_manifest(buf, sizeof(buf), mm); + ManifestMsg m2; ASSERT_TRUE(decode_manifest(buf, n, m2)); + EXPECT_EQ(m2.frag_idx, 0); EXPECT_EQ(m2.frag_total, 1); EXPECT_EQ(m2.len, 40); + EXPECT_EQ(0, memcmp(m2.bytes, mbytes, 40)); + + ReqMsg rq{{4,3,2,1}, 7, 5}; + n = encode_req(buf, sizeof(buf), rq); + ReqMsg r2; ASSERT_TRUE(decode_req(buf, n, r2)); + EXPECT_EQ(r2.start_block, 7); EXPECT_EQ(r2.count, 5); + + // 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, 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_off, 0); + EXPECT_EQ(d2.data_len, 100); EXPECT_EQ(0, memcmp(d2.data, data, 100)); + + // 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.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) ------------------------------------ + +namespace { +struct SimMsg { OtaManager* dest; std::vector bytes; }; +static std::vector g_q; +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) { + g_q.clear(); + OtaManager server, client; + OtaStoreRam<4096> store; + SendTo to_client{&client}, to_server{&server}; + + 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 beacon, queries, catalogs, then fetches + + pump(client); // beacon -> query -> have -> startFetch -> full transfer + + EXPECT_EQ(client.fetchState(), OtaManager::COMPLETE); + EXPECT_EQ(client.blocksHave(), client.blocksTotal()); + EXPECT_GT(client.blocksTotal(), 1u); + + // the client's reassembled container must be byte-identical to the original .mota... + ASSERT_EQ(store.staged_size(), SIM_MOTA_LEN); + EXPECT_EQ(0, std::memcmp(store.data(), SIM_MOTA, SIM_MOTA_LEN)); + + // ...and independently re-verify it parses with a matching root + image_hash + 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)); +} + +// 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)); +} + +// --- swarm: a completed peer re-seeds beyond the origin (epidemic spread) --------------------- +namespace { +// A topology-aware bus: each node delivers only to its listed neighbours (so we can build multi-hop chains +// the origin can't reach directly). DATA emits are counted per node to observe who actually sources blocks. +struct TopoNode { int idx; }; +static std::vector g_tn; +static std::vector> g_adj; +static std::vector g_tdata; +static std::vector>> g_tq; +static size_t g_th = 0; +static uint32_t g_tclk = 0; +static void topo_send(void* ctx, const uint8_t* msg, uint16_t len, bool) { + int from = ((TopoNode*)ctx)->idx; + if (len && msg[0] == OTA_DATA) g_tdata[from]++; + for (int nb : g_adj[from]) g_tq.push_back({nb, std::vector(msg, msg + len)}); +} +static void topo_pump(int guard = 2000000) { + int idle = 0, g = 0; + while (g++ < guard) { + if (g_th < g_tq.size()) { + auto m = g_tq[g_th++]; + g_tn[m.first]->on_message(m.second.data(), (uint16_t)m.second.size()); + idle = 0; + if (g_th > 8192) { g_tq.erase(g_tq.begin(), g_tq.begin() + g_th); g_th = 0; } + } else { + g_tclk += 1000; + for (auto* nd : g_tn) { nd->set_clock(g_tclk); nd->loop(); } + if (g_th < g_tq.size()) { idle = 0; continue; } + if (++idle >= 3) break; + } + } +} +} + +// Line topology: origin <-> relay <-> leaf, with origin and leaf NOT connected. The relay fetches from the +// origin, COMPLETEs, and re-seeds; the leaf — which can ONLY hear the relay — must then obtain the whole +// firmware from the relay. If the leaf completes, the load provably spread off the origin (the origin never +// served the leaf). Validates re-serve-after-complete (and the partial-re-serve serve path it shares). +TEST(OtaSwarm, CompletedPeerReSeedsBeyondOrigin) { + g_tn.clear(); g_adj.clear(); g_tdata.clear(); g_tq.clear(); g_th = 0; g_tclk = 0; + static OtaManager origin, relay, leaf; + static OtaStoreRam<4096> rstore, lstore; + g_tn = {&origin, &relay, &leaf}; + g_adj = {{1}, {0, 2}, {1}}; // origin<->relay<->leaf + g_tdata = {0, 0, 0}; + static TopoNode t0{0}, t1{1}, t2{2}; + uint8_t id0[4] = {1,1,1,1}, id1[4] = {2,2,2,2}, id2[4] = {3,3,3,3}; + origin.begin(0, topo_send, &t0); origin.set_seeder_id(id0); + relay.begin(SIM_TARGET_ID, topo_send, &t1); relay.set_seeder_id(id1); + relay.set_fetch_store(&rstore); relay.set_autofetch(OtaManager::AUTOFETCH_ANY); + leaf.begin(SIM_TARGET_ID, topo_send, &t2); leaf.set_seeder_id(id2); + leaf.set_fetch_store(&lstore); leaf.set_autofetch(OtaManager::AUTOFETCH_ANY); + + ASSERT_TRUE(origin.serve(SIM_MOTA, SIM_MOTA_LEN)); + origin.announce(); + topo_pump(); + ASSERT_EQ(relay.fetchState(), OtaManager::COMPLETE); // relay sourced it from the origin + + relay.announce(); // relay now beacons its catalog (incl. the re-seeded mota) + topo_pump(); + EXPECT_EQ(leaf.fetchState(), OtaManager::COMPLETE); // leaf got it ONLY via the relay (re-serve) + ASSERT_EQ(lstore.staged_size(), SIM_MOTA_LEN); + EXPECT_EQ(0, std::memcmp(lstore.data(), SIM_MOTA, SIM_MOTA_LEN)); // byte-exact through the relay + EXPECT_GT(g_tdata[1], 0); // the relay actually served DATA (re-seeded) +} + +// 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; + OtaStoreRam<4096> store; + SendTo to_client{&client}, to_server{&server}; + 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(); + pump(client); // catalogs the row but wantRow rejects it (wrong target) -> never fetches + EXPECT_EQ(client.fetchState(), OtaManager::IDLE); // never started +} + +TEST(OtaTransfer, ManualCrossTargetFetch) { + // A node whose own target differs from the served firmware normally won't fetch (role-switch case: + // e.g. companion wanting repeater firmware). An explicit want() override lets it fetch deliberately. + 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 ^ 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: catalogs the row but won't fetch (wrong target) + server.announce(); + 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(); + 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)); +} + +// 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; + row[14]=0; row[15]=0; // have_count (unused in this 1-row discovery test) + 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_autofetch(OtaManager::AUTOFETCH_ANY); + client.set_apply_codec(CODEC_DETOOLS_INPLACE); // nRF52-style: accepts only full + in-place + uint8_t b[64]; + + // 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); + + // 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(); +} + +// Encode a 1-row OTA_HAVE from a specific seeder, carrying have_count (Phase-2 awareness). +static uint16_t make_have_row(uint8_t* buf, uint16_t cap, const uint8_t mid[4], uint32_t target, + uint32_t fwver, uint8_t codec, uint8_t flags, + const uint8_t seeder[4], uint16_t have_count) { + 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; + row[14]=(uint8_t)(have_count & 0xFF); row[15]=(uint8_t)(have_count >> 8); + HaveMsg hv; memcpy(hv.seeder_id, seeder, 4); memset(hv.set_digest, 0, 4); + hv.frag_idx=0; hv.frag_total=1; hv.n_rows=1; hv.rows=row; + return encode_have(buf, cap, hv); +} + +// Catalog accounting: "N nodes have it" must count DISTINCT seeders (a repeated HAVE from one node must +// not inflate it), and have_max tracks the best progress any source reported. +TEST(OtaCatalog, DistinctSeederCountAndHaveCount) { + OtaManager m; SendTo none{&m}; m.begin(SIM_TARGET_ID, sim_send, &none); + uint8_t b[64]; uint8_t mid[4]={9,9,9,9}; + uint8_t s1[4]={1,0,0,0}, s2[4]={2,0,0,0}; + m.on_message(b, make_have_row(b, sizeof b, mid, SIM_TARGET_ID, 0x01020300, CODEC_FULL, 0, s1, 5)); + m.on_message(b, make_have_row(b, sizeof b, mid, SIM_TARGET_ID, 0x01020300, CODEC_FULL, 0, s1, 7)); // same seeder + ASSERT_EQ(m.catalogCount(), 1); + EXPECT_EQ(m.catalogRow(0)->n_seeders, 1); // counted once despite two HAVEs + EXPECT_EQ(m.catalogRow(0)->have_max, 7u); // max progress seen + m.on_message(b, make_have_row(b, sizeof b, mid, SIM_TARGET_ID, 0x01020300, CODEC_FULL, 0, s2, 3)); // new seeder + EXPECT_EQ(m.catalogRow(0)->n_seeders, 2); + EXPECT_EQ(m.catalogRow(0)->have_max, 7u); // still the max, not overwritten by the lower one + g_q.clear(); +} + +// An unanswered GET_MANIFEST must not pin the fetch slot forever: after OTA_MANIFEST_MAX_RETRY ticks with +// no manifest, the session gives up (FAILED) so a new pull can take the slot. (Lowest-priority + bounded.) +TEST(OtaTransfer, ManifestGiveUpAfterRetries) { + g_q.clear(); + OtaManager client; OtaStoreRam<4096> store; SendTo to_none{&client}; + client.begin(SIM_TARGET_ID, sim_send, &to_none); + client.set_fetch_store(&store); + uint8_t mid[4]={7,7,7,7}; + client.pull(mid, SIM_TARGET_ID); // no server -> stuck WANT_MANIFEST + EXPECT_EQ(client.fetchState(), OtaManager::WANT_MANIFEST); + for (int i = 0; i < OTA_MANIFEST_MAX_RETRY + 2; i++) { g_clk += 5000; client.set_clock(g_clk); client.loop(); g_q.clear(); } + EXPECT_EQ(client.fetchState(), OtaManager::FAILED); +} + +// Re-seeding a completed download must stop when the session is dropped (the staging store is cleared right +// after), so the node never advertises a mota it can no longer serve. +TEST(OtaSwarm, ReSeedStopsAfterDrop) { + 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, SIM_MOTA_LEN)); + server.announce(); + pump(client); + ASSERT_EQ(client.fetchState(), OtaManager::COMPLETE); + EXPECT_EQ(client.servedCount(), 1); // re-seeding the completed download to peers + client.reset_session(); + EXPECT_EQ(client.servedCount(), 0); // dropped -> stops advertising it +} + +// --- detools delta decode (vendored detools C decoder, CRLE-only build) ---------------------- +// Mirrors the device apply path (src/helpers/ota/OtaApply.cpp): base read via from_read/from_seek, +// patch streamed via patch_read, output written via to_write. Proves the on-device delta apply uses +// detools 0.53.0's own decoder and reproduces the exact target the host packager targeted. +namespace { +struct DTMem { + const uint8_t* base; long base_len; long base_pos; + const uint8_t* patch; long patch_len; long patch_pos; + std::vector out; +}; +int dt_from_read(void* a, uint8_t* b, size_t n) { + DTMem* c = (DTMem*)a; + if (c->base_pos < 0 || c->base_pos + (long)n > c->base_len) return -DETOOLS_IO_FAILED; + std::memcpy(b, c->base + c->base_pos, n); c->base_pos += (long)n; return DETOOLS_OK; +} +int dt_from_seek(void* a, int off) { + DTMem* c = (DTMem*)a; c->base_pos += off; + if (c->base_pos < 0 || c->base_pos > c->base_len) return -DETOOLS_IO_FAILED; + return DETOOLS_OK; +} +int dt_patch_read(void* a, uint8_t* b, size_t n) { + DTMem* c = (DTMem*)a; + if (c->patch_pos + (long)n > c->patch_len) return -DETOOLS_IO_FAILED; + std::memcpy(b, c->patch + c->patch_pos, n); c->patch_pos += (long)n; return DETOOLS_OK; +} +int dt_to_write(void* a, const uint8_t* b, size_t n) { + DTMem* c = (DTMem*)a; c->out.insert(c->out.end(), b, b + n); return DETOOLS_OK; +} + +// In-place apply over a flat memory region (models the nRF52 app workspace / the bootloader's flash). +struct DTInPlace { + std::vector mem; // [0,memory_size): base in, target out + const uint8_t* patch; long plen, ppos; int step; +}; +int ip_mem_read(void* a, void* dst, uintptr_t src, size_t n) { + DTInPlace* c = (DTInPlace*)a; if (src + n > c->mem.size()) return -DETOOLS_IO_FAILED; + std::memcpy(dst, c->mem.data() + src, n); return DETOOLS_OK; +} +int ip_mem_write(void* a, uintptr_t dst, void* src, size_t n) { + DTInPlace* c = (DTInPlace*)a; if (dst + n > c->mem.size()) return -DETOOLS_IO_FAILED; + std::memcpy(c->mem.data() + dst, src, n); return DETOOLS_OK; +} +int ip_mem_erase(void* a, uintptr_t addr, size_t n) { + DTInPlace* c = (DTInPlace*)a; if (addr + n > c->mem.size()) return -DETOOLS_IO_FAILED; + std::memset(c->mem.data() + addr, 0xFF, n); return DETOOLS_OK; +} +int ip_step_set(void* a, int s) { ((DTInPlace*)a)->step = s; return DETOOLS_OK; } +int ip_step_get(void* a, int* s) { *s = ((DTInPlace*)a)->step; return DETOOLS_OK; } +int ip_patch_read(void* a, uint8_t* b, size_t n) { + DTInPlace* c = (DTInPlace*)a; if (c->ppos + (long)n > c->plen) return -DETOOLS_IO_FAILED; + std::memcpy(b, c->patch + c->ppos, n); c->ppos += (long)n; return DETOOLS_OK; +} +} // namespace + +TEST(Detools, SequentialCrlePatchReproducesTarget) { + DTMem c{DT_BASE, (long)DT_BASE_LEN, 0, DT_PATCH, (long)DT_PATCH_LEN, 0, {}}; + int r = detools_apply_patch_callbacks(dt_from_read, dt_from_seek, dt_patch_read, + (size_t)DT_PATCH_LEN, dt_to_write, &c); + ASSERT_EQ(r, (int)DT_TARGET_LEN); // returns to-size on success + ASSERT_EQ(c.out.size(), (size_t)DT_TARGET_LEN); + EXPECT_EQ(0, std::memcmp(c.out.data(), DT_TARGET, DT_TARGET_LEN)); +} + +TEST(Detools, WrongBaseDoesNotReproduceTarget) { + // a base that differs from the one the patch was built against must NOT yield the target + std::vector bad(DT_BASE, DT_BASE + DT_BASE_LEN); + for (size_t i = 0; i < bad.size(); i += 7) bad[i] ^= 0xFF; + DTMem c{bad.data(), (long)bad.size(), 0, DT_PATCH, (long)DT_PATCH_LEN, 0, {}}; + int r = detools_apply_patch_callbacks(dt_from_read, dt_from_seek, dt_patch_read, + (size_t)DT_PATCH_LEN, dt_to_write, &c); + bool reproduced = (r == (int)DT_TARGET_LEN && c.out.size() == (size_t)DT_TARGET_LEN && + std::memcmp(c.out.data(), DT_TARGET, DT_TARGET_LEN) == 0); + EXPECT_FALSE(reproduced); // wrong base -> wrong/short output (the device then fails image_hash) +} + +TEST(Detools, TruncatedPatchFails) { + DTMem c{DT_BASE, (long)DT_BASE_LEN, 0, DT_PATCH, (long)(DT_PATCH_LEN / 2), 0, {}}; + int r = detools_apply_patch_callbacks(dt_from_read, dt_from_seek, dt_patch_read, + (size_t)(DT_PATCH_LEN / 2), dt_to_write, &c); + EXPECT_TRUE(r < 0 || c.out.size() != (size_t)DT_TARGET_LEN); +} + +// nRF52 path: the bootloader applies an in-place patch over the single app slot. Model the app +// region as a DT_IP_MEM buffer holding the base; after apply, region[0:to_size] must equal the target. +TEST(Detools, InPlaceCrlePatchReproducesTarget) { + DTInPlace c; c.mem.assign(DT_IP_MEM, 0xFF); + std::memcpy(c.mem.data(), DT_IP_BASE, DT_IP_BASE_LEN); // base loaded at offset 0 + c.patch = DT_IP_PATCH; c.plen = DT_IP_PATCH_LEN; c.ppos = 0; c.step = 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)DT_IP_PATCH_LEN, &c); + ASSERT_EQ(r, (int)DT_IP_TARGET_LEN); // returns to-size on success + EXPECT_EQ(0, std::memcmp(c.mem.data(), DT_IP_TARGET, DT_IP_TARGET_LEN)); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} From fd253f67f72c586254cf10de6558448dab466151 Mon Sep 17 00:00:00 2001 From: Valentin Kivachuk Burda Date: Mon, 29 Jun 2026 13:03:06 +0200 Subject: [PATCH 10/59] ota: protocol spec + user guide (cherry picked from commit 54c8b586c1e58b43a5b601fff5195a436fa35b8f) --- docs/ota_protocol.md | 584 +++++++++++++++++++++++++++++++++++++++++ docs/ota_user_guide.md | 229 ++++++++++++++++ 2 files changed, 813 insertions(+) create mode 100644 docs/ota_protocol.md create mode 100644 docs/ota_user_guide.md diff --git a/docs/ota_protocol.md b/docs/ota_protocol.md new file mode 100644 index 0000000000..04620b3ee4 --- /dev/null +++ b/docs/ota_protocol.md @@ -0,0 +1,584 @@ +# MeshCore OTA — `.mota` container & LoRa protocol + +This is the **single source of truth** for MeshCore's over-the-air firmware update system ("mOTA"). It is +written for developers who want to implement an interoperable peer (server, fetcher, relay, or host tool) +in another codebase or project. Everything below is implemented and hardware-verified in this repository; +where a section names a source file, that file is the authoritative reference for byte-level details. + +> **Just want to update your node?** See the plain-language [OTA user guide](ota_user_guide.md) — this +> document is the technical/wire specification. + +**Design goals** + +- Distribute firmware over LoRa as a **self-verifying, resumable, 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/motatool/` (C++ CLI: build/verify/inspect/serve); `tools/mota/` (Python reference lib `motalib.py` + build/test glue) | + +--- + +## 1. Conventions + +- **Endianness:** all multi-byte integers are little-endian unless stated. +- **Hashes (multihash):** the hash family is declared once per manifest via `hash_algo` = + `0x12` = **SHA-256** (the [multihash](https://github.com/multiformats/multihash) code for sha2-256). + Truncations used: + - `sha2-256:4` — first 4 bytes of the SHA-256 digest. Merkle leaves, internal nodes, root, proofs, + `manifest_id`, and the discovery `set_digest`. + - `sha2-256:8` — first 8 bytes. Base-firmware identity (`base_hash`, `EndF.body_hash`). + - `sha2-256:32` — full digest. The image security anchor (`image_hash`). + Digests are stored **bare** (just the truncated bytes); the family is implied by `hash_algo`. +- **Signatures:** Ed25519 (RFC 8032), 64-byte detached signature, 32-byte public key. + +**Reference constants** (`OtaFormat.h`): + +| Name | Value | ASCII / note | +|---|---|---| +| Container `MAGIC` | `6D 4F 54 41` | `mOTA` | +| Container `TRAILER` | `76 6B 34 39 36` | `vk496` | +| `EndF` marker | `45 6E 64 46` | `EndF` | +| `hash_algo` (sha2-256) | `0x12` | multihash code | +| `format_ver` | `0x02` | this spec | +| `approval` = not approved | `FF FF FF FF` | erased NOR word | +| `approval` = approved | `41 50 52 56` | `APRV` | +| `MFLAG_FULL` | `0x01` | flags bit0 | +| `MFLAG_SIGNED` | `0x02` | flags bit1 | +| `CODEC_FULL` / `_SEQUENTIAL` / `_INPLACE` | `0` / `1` / `2` | §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 build appends a fixed **56-byte** `EndF` trailer to its flashed image so a running node +can discover its own size **and self-describing identity** on any MCU (no linker symbols needed). Every +field is always present at a constant offset. Implemented by `FirmwareInfo.cpp`; appended at build time by +`tools/mota/pio_endf.py` (post-build hook). + +``` +flashed image = BODY (image bytes) || EndF trailer +EndF trailer (fixed 56 bytes): + off 0 4 "EndF" 45 6E 64 46 + off 4 4 body_len uint32 LE — length of BODY (excludes the whole trailer) + off 8 8 body_hash sha2-256:8 of BODY + off 16 4 fw_version uint32 LE, packed MAJOR<<24|MINOR<<16|PATCH<<8|pre (0 = unknown) + off 20 4 target_id uint32 LE — sha2-256:4(pio_env): hardware + role + partition (fetch routing) + off 24 32 hw_id NUL-padded ASCII hardware tag (brick-safety), e.g. "RAK4631" ("" = unknown) +``` + +- **Self-describing identity.** `pio_endf.py` computes `target_id` from the PlatformIO env name itself (so + it's correct even without `build.sh`'s `-D MOTA_TARGET_ID`), `hw_id` from `MOTA_HW_ID`, and `fw_version` + from `FIRMWARE_VERSION`. The device reads them back (`ota_self_firmware()`), so a node's advertised + identity is correct regardless of how it was built — and the packaging tool reads them straight from a raw + `.bin` (no `--target-env`/`--fw-version` flags, no reliance on filenames; §9, §13). A dev build with no + dotted version simply carries `fw_version = 0` / empty `hw_id` (= unknown) — still a full 56-byte trailer. +- **Size discovery:** scan flash from the partition top downward for the `EndF` marker; the byte before it + is the last BODY byte (the trailer is always 56 bytes). See `ota_self_firmware()`. +- **Delta base matching:** a node's `body_hash` is read directly from its own `EndF`; a delta's `base_hash` + (§5) must equal it. `body_hash` is over BODY only. +- **No circularity:** `EndF` hashes only the BODY, never itself. + +The "reconstructed image" referenced by the manifest is the full `BODY || EndF` (what gets flashed). + +> **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 + +The distributed form (host-built, wire-transferred). Parsed by `mota_parse()` in `MotaContainer.cpp`. + +``` +off size field +0 4 MAGIC = 6D 4F 54 41 +4 4 MOTA_TOTAL_SIZE uint32 LE — total container bytes (incl. manifest, leaves[], + payload, trailer). Lets a node pre-reserve staging and compute + write_start = staging_region_end − MOTA_TOTAL_SIZE. +8 M MANIFEST (§4; M = 197 fixed + leaves[], 4*BC; no length field — BC from payload_size) +8 + M P PAYLOAD (payload_size bytes; delta or full image) +8 + M + P 5 TRAILER = 76 6B 34 39 36 +``` + +`MOTA_TOTAL_SIZE = 4 + 4 + M + P + 5`. The manifest `M` **includes** `leaves[]`; the manifest-minus-leaves +prefix (`mfl`, sent over the wire as `OTA_MANIFEST`) is `[8, leaves_off)`. + +**Staged (in-flash) form.** Written bottom-aligned so `TRAILER` ends at `staging_region_end`. Identical +bytes, except the device mutates two regions in place (both NOR-safe, no re-erase): the `leaves[]` slots +(filled as blocks arrive — §7) and the 4-byte `approval` field (on owner consent — §4.2). Everything else +is immutable. + +--- + +## 4. The manifest + +**Fixed layout.** Every field sits at a constant offset and is always present — `base_hash`, +`signer_pubkey` and `signature` are zero-filled when not applicable (a full image / an unsigned container). +Only `leaves[]` is variable (one 4-byte hash per block). So the manifest-minus-leaves (`mfl`) is **always +197 bytes** and the parser is plain offset reads — no conditionals. Parsed by `mota_parse_manifest()`. + +``` +off size field notes +0 1 format_ver = 0x02 +1 1 flags bit0 FULL (0=delta/partial, 1=full image); bit1 SIGNED; bits2-7 reserved 0 +2 1 hash_algo 0x12 = sha2-256 +3 4 target_id device/arch/role discriminator (§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) — also the manifest_id +24 32 image_hash sha2-256:32 of the reconstructed image — SECURITY anchor +56 1 codec_id 0=full/raw, 1=detools-sequential, 2=detools-in-place +57 32 hw_id NUL-padded ASCII hardware tag (e.g. "RAK4631"); same tag => bootable-compatible. + SIGNED. Applier refuses a mismatch (brick-safety); empty on either side = skip. +89 8 base_hash sha2-256:8 of the BASE image's BODY (== that build's EndF.body_hash). 0 if FULL. +97 32 signer_pubkey Ed25519 public key. 0 if not SIGNED. +129 64 signature Ed25519 over manifest[0, 129). 0 if not SIGNED. +193 4 approval FF FF FF FF = not approved; 41 50 52 56 ("APRV") = approved +--- end of manifest-minus-leaves: mfl = 197 (constant); leaves_off = 8 + 197 = 205 in the container --- +197 4*BC leaves[] BC = ceil(payload_size / 2^block_size_log2). sha2-256:4 each (the only variable field) +``` + +The signature always covers `manifest[0, 129)` (the head + `base_hash` + `signer_pubkey`). `approval` is +outside the signed region so it can be flipped in place on consent without breaking the signature. + +Manifest-minus-leaves size (`mfl`) is a constant **197 bytes** for every container (full or delta, signed +or unsigned). At 197 bytes the manifest exceeds one packet, so `OTA_MANIFEST` is always sent multi-fragment +(§8.4, 2 fragments) and reassembled by the fetcher. + +### 4.1 Signed region + +`signature` covers manifest bytes `[0, 129)` — the head + `base_hash` + `signer_pubkey`. It does **not** +cover `approval` or `leaves[]`: + +- `leaves[]` are verified against the signed `merkle_root` (§6), so they need no separate 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 owner's `ota applydelta` writes `41 50 52 56` (`"APRV"`) — a single NOR-safe write (only clears + bits from the erased word). Any partial/other value reads as not-approved (fail-safe). +- Bound to this image (lives in this `.mota`'s manifest, re-erased when a new `.mota` is staged). +- A **consent** marker, not a security primitive. Authenticity = `signature` + `image_hash` + `hw_id`. + +--- + +## 5. Payload, codecs & delta base + +`PAYLOAD` is either the full reconstructed image (`FULL`) or a delta (`!FULL`). + +| `codec_id` | Meaning | Used by | +|---|---|---| +| 0 | full / raw | PAYLOAD = reconstructed image (`BODY‖EndF`). ESP32 A/B (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 build's `EndF.body_hash` (sha2-256:8 of its BODY). A node applies a +delta only if `base_hash` matches its own `EndF.body_hash`. After applying, the result MUST hash +(sha2-256:32) to `image_hash` before it is booted — the hard security gate. + +**A fetcher only requests firmware it can apply.** Each node declares the codec(s) it can apply +(`set_apply_codec`/`set_apply_codec2`): ESP32 accepts `full` + `sequential` (+ `in-place`), 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) + +Verifies each PAYLOAD block against the signed `merkle_root` **before** the whole payload exists, so +corruption/forgery is localized to a block. Implemented in `MerkleTree.cpp`. + +- **Blocks:** PAYLOAD splits into `BC = ceil(payload_size / B)` blocks, `B = 2^block_size_log2` (default + 1024). The last block is its real length (**no zero padding**). +- **Leaf:** `leaves[i] = sha2-256:4( block_i_bytes )`. +- **Internal node:** `node = sha2-256:4( left ‖ right )` (4+4 input bytes). +- **Odd level:** an odd count promotes the **last node unchanged** to the next level (no duplication). +- **Root:** reduce until one node remains. `BC == 1` → root = `leaves[0]`. `BC == 0` is invalid. + +### 6.1 Proofs + +A proof for block `i` is the ordered list of sibling digests from leaf to root. Promoted levels contribute +**no** element. Verification (needs `BC` to know the tree shape): + +``` +h = leaf_i ; idx = i ; n = BC ; p = 0 +while n > 1: + if (n is odd) and (idx == n-1): # this node was promoted + pass + else: + sib, side = proof[p] ; p += 1 + h = sha2-256:4( sib ‖ h ) if side==left else sha2-256:4( h ‖ sib ) + idx //= 2 ; n = (n + 1) // 2 +accept iff h == merkle_root and p == len(proof) +``` + +Over LoRa, `leaves[]` are **omitted** from the manifest transfer; a serving node computes a block's proof +on demand from its stored `leaves[]` (`OTA_REQ_PROOF`/`OTA_PROOF`, §8.5), and the fetcher fills its own +`leaves[i]` as each verified block lands. + +--- + +## 7. Block availability, staging & resume + +There is no separate availability structure. **Block `i` is present ⟺ `leaves[i]` is non-erased** +(`!= FF FF FF FF`). Because `leaves[]` live in the staged flash region, availability **survives reboot**. + +**Commit order per block (crash-safe):** (1) verify proof, (2) write block payload to its offset, (3) write +`leaves[i]` **last**. A power loss before step 3 leaves the slot erased → the block is simply re-fetched +(idempotent). On boot a node rebuilds an in-RAM present-bitmap by scanning `leaves[]`. + +**Resume (`OtaManager::resumeStaged` + `OtaStore::checkpoint`/`reopen`):** an interrupted fetch resumes from +the staged container after a reboot — re-parse the stored manifest, recompute geometry, count present +blocks, continue fetching the holes (or jump straight to COMPLETE). The checkpoint cadence (persist progress +every N committed blocks) is runtime-tunable (`ota config checkpoint `, 0 = only finalized containers +resume). Stores keep `leaves[]` in RAM until flush and never auto-GC, preserving resumable progress. + +**Flash-store note (RX-safe writes):** a flash page-erase halts the CPU (~85 ms on nRF52) and starves LoRa +RX, so the flash stores (`OtaStoreFlashNrf52`/`OtaStoreFlashEsp32`) **coalesce writes to the erase unit** +(4 KB page / sector) and commit each once off the per-packet path — RAM stays O(one page), not O(image). A +small delta that fits page 0 does zero flash I/O until COMPLETE. + +--- + +## 8. LoRa OTA protocol + +Carried in MeshCore packets with **`PAYLOAD_TYPE_OTA = 0x0C`**. Every OTA packet payload is: + +``` +[0] ota_msg_type (OtaMsgType, OtaFormat.h) +[1..] body (fixed per type; encode/decode in OtaProtocol.cpp) +``` + +Message types: + +| `ota_msg_type` | val | routing | purpose | +|---|---|---|---| +| `OTA_ADV` | 0x01 | 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 | + +- **`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. + +### 8.1 Two-tier discovery + +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: + +**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 (16 bytes, OTA_HAVE_ROW_BYTES): mid[4] target_id(4) fw_version(4) codec_id(1) flags(1) have_count(2) +``` + +`have_count` is how many blocks the advertiser currently holds (`== block_count` for a full copy, less for a +partial/in-progress source). It lets a fetcher see, per mid, **how many peers have it and at what progress** +— so it knows the firmware is on multiple peers and can trust the swarm (§8.6) rather than depend on one. + +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. + +### 8.6 Swarm load distribution (don't hammer one seeder) + +The discovery anti-storm (§8.2) stops 50 neighbours all *querying* one node. The same hazard exists for the +*transfer*: if one node has new firmware and 50 want it, naïve fetchers would all REQ the same blocks from +the same seeder. Because OTA is always lowest-priority (§8) the mesh won't collapse, but the transfer would +be needlessly slow and centralized. Mitigations (all in `OtaManager`, reusing the §8.2 jitter/suppress idea): + +- **Overhearing fills holes for free.** Every fetcher accepts any *broadcast* `OTA_DATA` for its mid, not + just data it requested. So within a broadcast neighbourhood, one peer's request serves everyone who hears it. +- **De-correlated requests.** A fetcher picks a **random** missing block (not lowest-first), so N fetchers + don't lockstep on the same block; collectively they pull different blocks and everyone overhears them all. + Each fetch also holds its first REQ a random `OTA_REQ_SPREAD_MS` so simultaneous starters don't burst together. +- **Request suppression.** Overhearing a peer's `OTA_REQ` for a block makes a fetcher spend its next REQ on a + *different* block (`OTA_REQ_SUPPRESS_MS`) — the broadcast DATA will fill the overheard one anyway. +- **Sources multiply (the key to "don't pull one node"):** + - **Re-seed after COMPLETE (epidemic).** A node that finishes a download advertises + serves it (it now has + all blocks *and* leaves, so it serves DATA and proofs). The origin seeds a few peers, they seed the next + ring, etc. — load on the origin drops from O(N) to ~O(log N). (Default `autoinstall=off` means a completed + node lingers as a seeder until the operator applies.) + - **Partial re-serve during the transfer.** A still-fetching node serves the **DATA** of blocks it already + holds (not proofs — it may lack sibling leaves), so peers can source bytes from it, not only the origin. +- **Serve de-dup.** A holder about to serve a block it just overheard *another* holder broadcast suppresses + its own send (`OTA_SERVE_SUPPRESS_MS`), so multiple sources of one mota don't duplicate-broadcast it. + +All serving stays reactive and lowest-priority, so seeding never competes with real traffic — the system is +"eventually upgradable": a busy node simply delays OTA until it has spare airtime. + +--- + +## 9. Identity, trust & versioning + +- **`target_id`** (4 B): `sha2-256:4(pio_env_name)` (little-endian uint32). The env name uniquely captures + hardware **and** role/partition, so a node auto-fetches only matching firmware (a companion image is not + fetched onto a repeater even though it shares `hw_id`). It is **self-described in the firmware's EndF** + (§2, written by `pio_endf.py`) and read via `ota_self_firmware()`, so it is correct on any build; + `-D MOTA_TARGET_ID` / `MainBoard::getOtaTargetId()` is the fallback when no EndF identity is present. + `tools/mota` reads it from the firmware's EndF (or `--target-env`). A manual `ota pull`/`want` can override + target (deliberate role switch); the `hw_id` brick-safety gate (§4) still applies at apply time. +- **`target_id` vs `hw_id`** — complementary, not redundant: `target_id` is the fetch-routing key + (hw + role + partition); `hw_id` is the human-readable brick-safety key (hardware only). Same board, two + roles ⇒ same `hw_id`, different `target_id`. +- **Naming a `target_id` locally:** only the 4-byte `target_id` ever travels on the wire. To show *which* + board/role a target is, a node (and `motatool`) reverse-looks-it-up in `src/helpers/ota/OtaTargets.h` — + a generated `target_id → env-name` table covering every `ENABLE_OTA` env (`tools/mota/gen_targets.py`, + resolved from `pio project config`). So `ota ls` can render `[Heltec_v3_repeater]` for a neighbour's + beacon without the string being transmitted. Unknown ids show as `other hw` / `N/A`. +- **`fw_version`:** packed comparable uint32 (`MAJOR<<24 | MINOR<<16 | PATCH<<8 | pre`); also self-described + in EndF. `ota ls` decodes it for display and flags each update `[yours]` / `[other hw]` / `[?]` by + comparing the advertised `target_id` to the node's own. +- **`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. Multi-mota serve & the external "folder" relay + +A node serves a **set** of mOTAs: its own firmware plus, optionally, an external folder of `.mota` files it +relays without holding them in flash. To peers it simply "has N mOTAs"; the relay is trustless (fetchers +verify everything). The serve side (`OtaManager`) keeps a lightweight registry of what it advertises and two +resident "views": `view0` (its own firmware) and one on-demand view loaded from a source when a request +targets an external mota. Every fetch message carries `manifest_id`, so dispatch is a registry lookup. + +### 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`) + +A `MotaSource` is fed by a host that serves a folder over the device's **USB serial — the same console the +CLI uses** (no extra hardware). The host is the self-contained C++ tool `tools/motatool/` (`motatool serve`, +which also builds + validates `.mota` and runs on small 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. + +**Transport-agnostic by design.** The request/response *semantics* (`COUNT` / `DESCRIBE(idx)` / +`READ(idx, off, len)` over a folder catalog) are independent of the link. The 2-byte magic + XOR checksum + +resync framing above exists only because a shared USB-UART is an unreliable, unframed byte stream. Over a +framed/reliable link such as **BLE GATT** (e.g. an Android phone relaying a folder to a node), the same ops +carry over directly — a request characteristic write delivers `op + args` and the reply is a notification +of `status + payload`, with no magic/checksum needed. `motatool` reflects this split: a transport-free +`SeederCore` (the catalog logic) under a serial framing layer, so a BLE transport reuses the core verbatim. + +--- + +## 11. CLI surface (`OtaCli.cpp`) + +User-facing OTA data should travel via `CMD_OTA_*` companion binary frames; the text CLI below is +debug/operator oriented and replies are `snprintf`-bounded into a 160-byte buffer. + +Commands take intuitive aliases (matched by the first word; see `is_cmd` in `OtaCli.cpp`) so they're easy +to type and read — `status`/`neighbors`/`pull`/`drop`/`applydelta` are the canonical names, the aliases are +the recommended user-facing forms. Output is plain-language (a user-facing guide lives at +[ota_user_guide.md](ota_user_guide.md)). + +``` +ota help | ? list the commands +ota status | st (or bare `ota`) plain-language: running fw, the one fetch session (state/%/id), serving, keys +ota ls | neighbors | nbrs | updates | n discovered updates (queries sources; rows arrive async via OTA_HAVE) +ota get | pull | download <#|mid8> fetch a chosen mOTA (manual; works regardless of autofetch) +ota install | apply | applydelta verify + approve + (ESP32) apply / (nRF52) reboot-to-bootloader +ota cancel | drop | stop drop the current fetch session (frees the slot; stops re-seeding) +ota announce | adv serve self + send a beacon now +ota self | id print this firmware's EndF (body/image size, base_hash) +ota folder | fold [on|off] attach/detach an external .mota folder (host daemon) ; bare = list +ota config | cfg | set [autofetch|autoinstall|checkpoint] ... show/set persisted policy +ota key | keys [add|rm ] trusted signer allowlist ; bare = list +ota dev ... bring-up helpers (stage/recv/serve/verify) +``` + +--- + +## 12. Apply & bootloader contract + +- **ESP32 (A/B):** applied in-firmware via the detools decoder into the inactive OTA slot + (`OtaApply.cpp::ota_apply_detools_mota` + `OtaStoreFlashEsp32`), then set-boot + reboot (power-safe, + rollback-capable). No bootloader changes. Erase ranges must be sector-aligned (4096). +- **nRF52 (single-slot):** the running firmware **never** flashes the app. `ota 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`. A parser accepts exactly this value and rejects anything else — there is one container +format, fixed-layout, and no compatibility shims to carry. If the format ever needs to change, bump +`format_ver`; the multihash `hash_algo` separately allows swapping the digest family without a format +bump. Unknown `codec_id` / `ota_msg_type` values are ignored (a node simply won't fetch what it can't apply). diff --git a/docs/ota_user_guide.md b/docs/ota_user_guide.md new file mode 100644 index 0000000000..e078c6a9fe --- /dev/null +++ b/docs/ota_user_guide.md @@ -0,0 +1,229 @@ +# Updating your node over the air (OTA) — user guide + +This guide is for **node operators**: how to update your MeshCore device's firmware over the radio, in +plain language. No cables, no programmer — your node can download a new firmware from a neighbour and +install it. (For the technical wire format, see [the OTA protocol spec](ota_protocol.md).) + +> **Is my node supported?** OTA works on **ESP32** boards (e.g. Heltec V3) and on the **RAK4631** (nRF52, +> which needs the special MeshCore bootloader). Other boards build fine but can't self-update yet. + +--- + +## The important part first: it's safe + +- **Nothing installs by itself.** Your node can *discover* and *download* an update in the background, but + it only **installs** when you say so (unless you deliberately turn on auto-install — see below). +- **Bad downloads can't sneak in.** Every piece of the firmware is checked against a cryptographic + fingerprint as it arrives, and the whole image is verified again before install. A corrupt or tampered + download is rejected, not installed. +- **You choose who to trust.** Updates can be *signed* by their author. You can tell your node to only + auto-install firmware signed by keys you've added. +- **It won't disrupt your mesh.** OTA traffic is always the **lowest priority** — your node only spends + spare airtime on it. Messages and routing always come first; a busy node simply updates later. Think of + it as *"eventually upgradable."* +- **It can recover.** If an install ever fails, the node falls back to a safe recovery mode (you can + re-flash a known-good firmware over USB) — it won't be left bricked. + +--- + +## How to talk to your node + +Connect to your node's **console** — usually a USB serial terminal at **115200 baud** (or whatever tool +you already use to manage the node). You type `ota ...` commands and the node replies in plain words. + +The commands have short, friendly names (and most accept aliases, so you don't have to remember exact +spelling): type **`ota help`** any time to see the list, or just **`ota`** for a status summary. + +--- + +## Common tasks + +### 1. See what I'm running and whether anything is going on + +``` +ota status +``` + +Shows your current firmware version, your node's update "target" (its hardware/role id), and whether a +download is in progress. + +### 2. Find updates available near me + +``` +ota ls +``` + +Your node asks around and lists the firmware updates other nodes nearby are offering, in plain words — +each with a **number**, its version, whether it's a full image or a small delta, how many nodes have it, +and how recently it was seen. For example: + +``` +Updates nearby (2 src) — `ota get <#>` to download: + 1) v1.2.3 delta [yours] 3n 5s + 2) v1.2.0 full [other hw] 1n 12s [downloading] +``` + +Each row shows the version, full-vs-delta, **whether it fits your node**, how many nodes have it, and how +long ago it was seen. The fit marker: + +- **[yours]** — built for your exact hardware **and** role; safe to install. +- **[other hw]** — a different board or role (e.g. a companion image, or another board). Don't install it. +- **[?]** — can't tell (a build with no target id set, e.g. a bare IDE build rather than a release build). + +Run it again after a few seconds — discovery happens in the background, so the list fills in. Nothing is +downloaded yet; this is just looking around. (`ota neighbors` / `ota updates` also work.) + +### 3. Download an update + +Pick one from the list by its **number**: + +``` +ota get 1 +``` + +The node starts fetching it in the background, **at low priority**, a piece at a time — possibly from +several neighbours at once. Check progress any time with `ota status` (you'll see it climb, e.g. +`download: downloading 120/525 (23%)`). You can keep using your node normally meanwhile. + +To **stop** a download you no longer want: + +``` +ota cancel +``` + +### 4. Install a downloaded update + +Once `ota status` shows the download is **ready to install**: + +``` +ota install +``` + +The node verifies the firmware one last time, and if everything checks out it installs it and **reboots +into the new version**. If the check fails, it tells you why and does **not** install. (If you haven't +added the signer's key, an unsigned/untrusted image will only install with this explicit command — never +automatically.) + +After it reboots, run `ota status` to confirm the new version. + +### 5. If something goes wrong + +- A download that stalls or gets interrupted just **resumes** later, or you can `ota cancel` and try again. +- If an **install** fails, the node won't boot a broken image — it lands in **recovery mode**: + - **RAK4631 / nRF52:** it appears as a USB drive; drag a known-good firmware `.uf2` onto it to recover. + - **ESP32:** it keeps the previous firmware in the other slot and rolls back. +- When in doubt, you can always re-flash over USB the normal way. + +--- + +## Optional: let it update automatically + +By default your node only *discovers* updates — it won't download or install on its own. If you want more +automation (e.g. for a remote node you can't easily reach), you can opt in. These settings are saved. + +``` +ota config autofetch any # auto-DOWNLOAD any compatible update for this node (still won't install) +ota config autofetch signed # auto-download only signed updates +ota config autofetch off # back to manual (default) + +ota config autoinstall trusted # auto-INSTALL a downloaded update IF it's signed by a key you trust +ota config autoinstall off # never auto-install (default) + +ota config # show the current settings +``` + +Recommended for most people: leave both **off** and update by hand. Use `autoinstall trusted` only once +you've added the signer's key (next section) and you trust them to push updates unattended. + +--- + +## Optional: only trust updates from specific people + +If you'll use auto-install, tell your node which signing keys to trust. The firmware author shares their +**public** key (a hex string); you add it: + +``` +ota key add # trust this signer +ota key list # show trusted signers +ota key rm # stop trusting one +``` + +Only updates signed by a trusted key are eligible for auto-install. Manual `ota install` still lets you +install anything yourself, on your own responsibility. + +--- + +## Sharing updates with others (advanced) + +### Relay a folder of firmware from a computer + +If your node is connected to a computer (e.g. a gateway on a Raspberry Pi), it can **hand out** a whole +folder of firmware files to the mesh — without storing them itself. Useful for seeding a new release to a +remote area. + +1. Put the firmware files (`.mota` files — see below) in a folder on the computer. +2. Build the helper tool once (`tools/motatool/`), then point it at your node's USB port and the folder: + ``` + cmake -S tools/motatool -B tools/motatool/build && cmake --build tools/motatool/build + ./tools/motatool/build/motatool serve --dir ./my_firmware/ --serial /dev/ttyACM0 -v + ``` + It turns the relay on for you and then answers the node's requests. Your node now advertises those + updates to neighbours, who can `ota get` them like any other. (Details: [tools/motatool/README.md](../tools/motatool/README.md).) + +To turn it off, stop the daemon (or run `ota folder off` on the node). `ota folder` on its own lists what +your node is currently offering. + +### Everyone helps share + +You don't have to be a gateway to help. Once **any** node finishes downloading an update, it automatically +offers it to *its* neighbours too. So a new firmware spreads outward node-to-node, instead of everyone +hammering the one node that had it first — and no node is ever overloaded, because all of this stays +lowest-priority. + +--- + +## Where firmware files come from + +OTA distributes **`.mota`** files — a packaged, verifiable firmware image (full image or a small "delta" +that only contains what changed). You get them by: + +- **Downloading a build.** This fork publishes a rolling **`dev-latest`** release on GitHub with the + current firmware for many boards, each accompanied by a `.full.mota` and a tiny `.delta.mota`. Grab the + one for your board to test. +- **Building your own** with the `mota` packaging tool — see [tools/mota/README.md](../tools/mota/README.md) + (this is for people distributing updates, not everyday operators). + +--- + +## Quick reference + +| I want to… | Command | +|---|---| +| List all commands | `ota help` | +| See my firmware + any download | `ota status` (or just `ota`) | +| Find updates nearby | `ota ls` | +| Download update #1 | `ota get 1` | +| Cancel a download | `ota cancel` | +| Install a finished download | `ota install` | +| Turn on auto-download | `ota config autofetch any` | +| Turn on auto-install (trusted only) | `ota config autoinstall trusted` | +| Trust a signer | `ota key add ` | +| Relay a folder (gateway) | `ota folder on` + the seeder daemon | +| List what I'm offering | `ota folder` | + +(Older names still work too: `neighbors`/`updates` = `ls`, `pull` = `get`, `applydelta`/`apply` = `install`, `drop`/`stop` = `cancel`.) + +--- + +## A few terms + +- **Firmware** — the software running your node. Updating it can add features or fix bugs. +- **`.mota`** — a packaged firmware update file, with built-in integrity checks. +- **Target** — your node's hardware + role identity. Your node only auto-fetches updates built for the + same target, so it won't grab firmware meant for a different board. +- **Delta** — a small update containing only the changes from your current firmware (faster to send than a + full image). Your node rebuilds the complete firmware from it and verifies the result before installing. +- **Signed** — the update carries the author's cryptographic signature, so you can verify who made it. + +For the full technical details (the file format and the radio protocol), see +[the OTA protocol spec](ota_protocol.md). From de077fbfec341767445fa4ed3dca2b1094c3be25 Mon Sep 17 00:00:00 2001 From: Valentin Kivachuk Burda Date: Mon, 29 Jun 2026 16:58:50 +0200 Subject: [PATCH 11/59] ota: serve & manage OTA over WiFi (motatool --tcp + ESP32 companion seeder/console) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the USB-serial folder relay to WiFi so an ESP32 companion can both serve .mota and be operated headlessly: - motatool `serve --tcp `: a TcpTransport sibling of the serial transport (default port 5001). SeederCore/Folder are reused unchanged — the COUNT/DESCRIBE/READ protocol is transport-agnostic. - ESP32 companion: a dedicated OTA seeder port (5001) for `serve --tcp`, plus an OTA text console on 5002 (`nc 5002` -> `ota status|ls|announce|...`, the same handle_ota_command CLI serial nodes have). Both run alongside the phone-app port (5000); all three coexist. - WiFi.setSleep(false): ESP32 STA mode's modem power-save periodically sleeps the modem/CPU and stalls the SX1262 SPI+DIO servicing, leaving LoRa deaf while WiFi is associated. Disabling it restores the radio (HW-validated: a V3 WiFi companion is then discovered over LoRa and discovers its peers). - docs: serving .mota over WiFi (protocol §10.2 + user guide). (cherry picked from commit 280b9b1555e19a65e707ebf4a0171203330a3336) --- docs/ota_protocol.md | 32 ++++++----- docs/ota_user_guide.md | 16 ++++-- examples/companion_radio/main.cpp | 90 ++++++++++++++++++++++++++++++- tools/motatool/README.md | 13 +++-- tools/motatool/src/main.cpp | 75 +++++++++++++++++--------- tools/motatool/src/serve.cpp | 51 ++++++++++++++++-- tools/motatool/src/serve.h | 23 ++++++-- 7 files changed, 243 insertions(+), 57 deletions(-) diff --git a/docs/ota_protocol.md b/docs/ota_protocol.md index 04620b3ee4..d4f867c886 100644 --- a/docs/ota_protocol.md +++ b/docs/ota_protocol.md @@ -496,11 +496,12 @@ blocks) and streams payload blocks from the source on demand; proofs are generat ### 10.2 The `mota-seeder` transport (`MotaSeederProto.h`) -A `MotaSource` is fed by a host that serves a folder over the device's **USB serial — the same console the -CLI uses** (no extra hardware). The host is the self-contained C++ tool `tools/motatool/` (`motatool serve`, -which also builds + validates `.mota` and runs on small 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: +A `MotaSource` is fed by a host that serves a folder over the device's **USB serial** (the same console the +CLI uses — no extra hardware) or, on an ESP32 WiFi companion, over **WiFi (TCP)**. The host is the +self-contained C++ tool `tools/motatool/` (`motatool serve --serial ` / `--tcp `, which +also builds + validates `.mota` and runs on small hardware). The device only emits request frames *while +actively serving a fetch*, and reads the reply synchronously, so over the shared USB console binary frames +coexist with the text CLI/logs (resync on magic + checksum). Little-endian, XOR-checksummed: ``` request (device → host): 'M' 'S' op(1) args... xsum(1 = XOR of op+args) @@ -516,16 +517,23 @@ 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. +`OTA_FOLDER_SERIAL_BEGIN` for a dedicated UART). On an ESP32 WiFi companion the node also runs a second +`WiFiServer` on a **dedicated seeder port** (`OTA_SEEDER_TCP_PORT`, default `5001`), separate from the +companion app port (`TCP_PORT`, default `5000`) — so `motatool serve --tcp` can feed updates while a phone +app stays connected. The node auto-attaches the source when a seeder client connects and detaches when it +closes (no `ota folder on` needed over TCP). Verified on hardware: a RAK4631 relays a host folder to a +Heltec V3 over one USB cable, and a host feeds a Heltec V3 over WiFi (`:5001`) while the companion serves a +phone on `:5000` — every block merkle-checked. **Transport-agnostic by design.** The request/response *semantics* (`COUNT` / `DESCRIBE(idx)` / `READ(idx, off, len)` over a folder catalog) are independent of the link. The 2-byte magic + XOR checksum + -resync framing above exists only because a shared USB-UART is an unreliable, unframed byte stream. Over a -framed/reliable link such as **BLE GATT** (e.g. an Android phone relaying a folder to a node), the same ops -carry over directly — a request characteristic write delivers `op + args` and the reply is a notification -of `status + payload`, with no magic/checksum needed. `motatool` reflects this split: a transport-free -`SeederCore` (the catalog logic) under a serial framing layer, so a BLE transport reuses the core verbatim. +resync framing above exists for the shared USB-UART (an unframed byte stream); it is harmless over a +reliable stream and the **WiFi (TCP)** transport reuses it as-is — both ends just treat the socket as a +byte stream (on-device, `SerialMotaSource` runs verbatim over an Arduino `Stream`-compatible `WiFiClient`; +`motatool`'s `TcpTransport` mirrors its `SerialTransport`). A future framed link such as **BLE GATT** (an +Android phone relaying a folder) could carry the same ops with no magic/checksum at all — a request +characteristic write delivers `op + args`, the reply notifies `status + payload`. `motatool` reflects this +split: a transport-free `SeederCore` (the catalog logic) under a swappable framing/transport layer. --- diff --git a/docs/ota_user_guide.md b/docs/ota_user_guide.md index e078c6a9fe..bc0ba838ca 100644 --- a/docs/ota_user_guide.md +++ b/docs/ota_user_guide.md @@ -162,16 +162,22 @@ folder of firmware files to the mesh — without storing them itself. Useful for remote area. 1. Put the firmware files (`.mota` files — see below) in a folder on the computer. -2. Build the helper tool once (`tools/motatool/`), then point it at your node's USB port and the folder: +2. Build the helper tool once (`tools/motatool/`), then point it at your node and the folder — over the + node's **USB serial**, or over **WiFi** if it's an ESP32 companion on your network: ``` cmake -S tools/motatool -B tools/motatool/build && cmake --build tools/motatool/build + # over USB serial: ./tools/motatool/build/motatool serve --dir ./my_firmware/ --serial /dev/ttyACM0 -v + # …or over WiFi (ESP32 companion): the seeder is on a DEDICATED port (5001), separate from the + # phone-app port (5000), so a phone can stay connected while you serve: + ./tools/motatool/build/motatool serve --dir ./my_firmware/ --tcp 192.168.1.50:5001 -v ``` - It turns the relay on for you and then answers the node's requests. Your node now advertises those - updates to neighbours, who can `ota get` them like any other. (Details: [tools/motatool/README.md](../tools/motatool/README.md).) + It answers the node's requests; your node then advertises those updates to neighbours, who can + `ota get` them like any other. (A WiFi node prints its IP + seeder port to the serial log on connect. + Details: [tools/motatool/README.md](../tools/motatool/README.md).) -To turn it off, stop the daemon (or run `ota folder off` on the node). `ota folder` on its own lists what -your node is currently offering. +To stop, just stop the daemon — over WiFi the node auto-detaches when the connection closes; over USB you +can also run `ota folder off` on the node. `ota folder` on its own lists what your node is offering. ### Everyone helps share diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index ef9b6bfca4..9a96b2100c 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -111,6 +111,79 @@ void halt() { unsigned long last_wifi_reconnect_attempt = 0; #endif +/* WIFI OTA SEEDER — relay a host folder of .mota over WiFi (motatool `serve --tcp`), on a DEDICATED port + separate from the companion (TCP_PORT), so a phone app stays connected while motatool feeds updates. */ +#if defined(ESP32) && defined(WIFI_SSID) && defined(ENABLE_OTA) + #include + #include + #ifndef OTA_SEEDER_TCP_PORT + #define OTA_SEEDER_TCP_PORT 5001 + #endif + static WiFiServer ota_seeder_server(OTA_SEEDER_TCP_PORT); + static WiFiClient ota_seeder_client; // the live seeder connection (reused) + static mesh::ota::SerialMotaSource ota_seeder_source(ota_seeder_client, 3000); + static bool ota_seeder_attached = false; + + // Accept one motatool connection at a time; while connected, register its folder as a serve source so + // the node advertises + relays it over LoRa. Drop the source the moment the connection closes. + static void ota_seeder_loop() { + if (ota_seeder_client && ota_seeder_client.connected()) return; // still serving the current client + if (ota_seeder_attached) { // previous client just disconnected + mesh::ota::ota_ctx().detach_folder(); + ota_seeder_attached = false; + WIFI_DEBUG_PRINTLN("OTA seeder: client disconnected, relay stopped"); + } + WiFiClient c = ota_seeder_server.available(); + if (c) { + ota_seeder_client = c; // rebind the persistent Stream to it + if (mesh::ota::ota_ctx().manager.add_source(&ota_seeder_source)) { + ota_seeder_attached = true; + WIFI_DEBUG_PRINTLN("OTA seeder: client connected, relaying its folder"); + } else { + ota_seeder_client.stop(); // no free source slot + } + } + } +#endif + +/* WIFI OTA CONSOLE — a tiny text CLI for OTA over WiFi. A WiFi companion has no serial text console, so + without this its OTA is only reachable through the phone app. Connect with e.g. `nc 5002` and type + `ota status` / `ota ls` / `ota announce` / ... — one client at a time, on a DEDICATED port separate from + the companion (5000) and the seeder (5001). */ +#if defined(ESP32) && defined(WIFI_SSID) && defined(ENABLE_OTA) + #include // mesh::ota::handle_ota_command(line, reply, board) + #ifndef OTA_CONSOLE_TCP_PORT + #define OTA_CONSOLE_TCP_PORT 5002 + #endif + static WiFiServer ota_console_server(OTA_CONSOLE_TCP_PORT); + static WiFiClient ota_console_client; + static char ota_console_line[128]; + static uint8_t ota_console_len = 0; + + static void ota_console_loop() { + if (!ota_console_client || !ota_console_client.connected()) { + WiFiClient c = ota_console_server.available(); + if (c) { ota_console_client = c; ota_console_len = 0; + ota_console_client.print("OTA console — type `ota ...` (e.g. ota status / ota ls / ota announce)\r\n> "); } + return; + } + while (ota_console_client.available()) { + char ch = (char)ota_console_client.read(); + if (ch == '\r' || ch == '\n') { + if (ota_console_len == 0) continue; // ignore blanks / the CRLF pair + ota_console_line[ota_console_len] = 0; + char reply[160]; reply[0] = 0; + if (!mesh::ota::handle_ota_command(ota_console_line, reply, board)) + strcpy(reply, "only `ota ...` commands are supported on this console"); + ota_console_client.print(" -> "); ota_console_client.print(reply); ota_console_client.print("\r\n> "); + ota_console_len = 0; + } else if (ota_console_len < sizeof(ota_console_line) - 1) { + ota_console_line[ota_console_len++] = ch; + } + } + } +#endif + void setup() { Serial.begin(115200); @@ -208,13 +281,24 @@ void setup() { WIFI_DEBUG_PRINTLN("WiFi disconnected. Flagging for reconnect..."); wifi_needs_reconnect = true; } else if (event == ARDUINO_EVENT_WIFI_STA_GOT_IP) { - WIFI_DEBUG_PRINTLN("WiFi connected successfully!"); + WIFI_DEBUG_PRINTLN("connected! IP %s (companion app on :%d)", + WiFi.localIP().toString().c_str(), TCP_PORT); wifi_needs_reconnect = false; } }); WiFi.begin(WIFI_SSID, WIFI_PWD); + // Disable WiFi modem power-save: its periodic modem/light-sleep stalls the SX1262 SPI+DIO servicing, + // which makes the LoRa radio go deaf (no TX/RX) while WiFi is associated. Required for LoRa+WiFi to + // coexist on this ESP32 — the small extra idle current is well worth a working radio. + WiFi.setSleep(false); serial_interface.begin(TCP_PORT); + #ifdef ENABLE_OTA + ota_seeder_server.begin(); // dedicated OTA seeder port for `motatool serve --tcp` (relay over LoRa) + WIFI_DEBUG_PRINTLN("OTA seeder listening on :%d (motatool serve --tcp)", OTA_SEEDER_TCP_PORT); + ota_console_server.begin(); // dedicated OTA text-console port (`nc 5002` -> `ota ...`) + WIFI_DEBUG_PRINTLN("OTA console listening on :%d (nc %d, type `ota ...`)", OTA_CONSOLE_TCP_PORT, OTA_CONSOLE_TCP_PORT); + #endif #elif defined(BLE_PIN_CODE) serial_interface.begin(BLE_NAME_PREFIX, the_mesh.getNodePrefs()->node_name, the_mesh.getBLEPin()); #elif defined(SERIAL_RX) @@ -257,6 +341,10 @@ void loop() { } #if defined(ESP32) && defined(WIFI_SSID) + #ifdef ENABLE_OTA + ota_seeder_loop(); // accept/drop a motatool `serve --tcp` connection on the dedicated seeder port + ota_console_loop(); // service the OTA text console (port 5002) + #endif // Safely attempt to reconnect every 10 seconds if flagged if (wifi_needs_reconnect && (millis() - last_wifi_reconnect_attempt > 10000)) { WIFI_DEBUG_PRINTLN("Attempting manual WiFi reconnect..."); diff --git a/tools/motatool/README.md b/tools/motatool/README.md index d934b8c4e8..235d8af472 100644 --- a/tools/motatool/README.md +++ b/tools/motatool/README.md @@ -16,7 +16,7 @@ cross-checked byte-for-byte against the Python reference packager `tools/mota/mo | `build` | Create a **full** or **delta** `.mota` from a firmware (local file **or** http(s) URL). | | `verify` | Validate one or more `.mota` (merkle tree, leaves vs payload, image hash, Ed25519 signature). `--pub` requires a specific signer; `--base` confirms a sequential delta rebuilds its image. | | `inspect`| Print every field of a `.mota`'s manifest (debugging). | -| `serve` | Serve a **folder** of `.mota` to a node over USB serial. Invalid files are warned about and skipped — one corrupt file never sinks the rest. | +| `serve` | Serve a **folder** of `.mota` to a node over USB serial (`--serial`) or WiFi (`--tcp`). Invalid files are warned about and skipped — one corrupt file never sinks the rest. | | `keygen` | Generate an Ed25519 signing keypair (hex). | Every command has detailed, example-rich help: `motatool --help`. @@ -71,8 +71,9 @@ $MT verify delta.mota --base old_firmware.bin # 4. dump a single .mota's manifest fields $MT inspect ./motas/RAK4631_04D413FD_v1.16.0_full_ABCD1234.mota -# 5. serve a folder to a node over its USB serial (recursive; skips non-.mota; warns on corrupt) -$MT serve --dir ./motas --serial /dev/ttyUSB0 --baud 115200 -v +# 5. serve a folder to a node (recursive; skips non-.mota; warns on corrupt) +$MT serve --dir ./motas --serial /dev/ttyUSB0 --baud 115200 -v # over USB serial +$MT serve --dir ./motas --tcp 192.168.1.50:5001 -v # …or over WiFi (ESP32 companion, port 5001) ``` `build` notes: @@ -95,8 +96,10 @@ The protocol is split so the serving logic is reusable across links: - **`SeederCore`** (`src/serve.{h,cpp}`) is transport-free: it maps a request `(op, args)` to a response `(status, payload)` — `COUNT` / `DESCRIBE(idx)` / `READ(idx, off, len)` over the validated catalog. -- **`SerialTransport` + `serve_serial()`** wrap it with the byte-stream framing (magic + XOR checksum + - resync) for the unreliable USB-UART link (`MotaSeederProto.h`). +- **`Transport` + `serve_loop()`** wrap it with the byte-stream framing (magic + XOR checksum + resync, + `MotaSeederProto.h`). Two transports ship: **`SerialTransport`** (`--serial`, USB-UART) and + **`TcpTransport`** (`--tcp `, WiFi — connects to the node's dedicated seeder port, default + `5001`, which the ESP32 companion runs alongside its phone-app port so both work at once). To serve the same folder over **BLE** (e.g. an Android phone relaying to a MeshCore node), implement the transport at the GATT layer — a request characteristic write hands `(op, args)` straight to diff --git a/tools/motatool/src/main.cpp b/tools/motatool/src/main.cpp index 7e8bb0b6f5..8596b9f3ca 100644 --- a/tools/motatool/src/main.cpp +++ b/tools/motatool/src/main.cpp @@ -81,7 +81,7 @@ static void help_top() { " build Package a firmware as a .mota (a full image, or a small delta vs a previous build).\n" " verify Check that .mota files are valid (block hashes, image hash, signature).\n" " inspect Print every field of a .mota's manifest (debugging).\n" -" serve Serve a folder of .mota to a node over USB serial (corrupt files are skipped).\n" +" serve Serve a folder of .mota to a node over USB serial or WiFi (corrupt files are skipped).\n" " keygen Generate an Ed25519 signing keypair.\n" "\n" "TYPICAL WORKFLOW\n" @@ -194,29 +194,34 @@ static void help_inspect() { static void help_serve() { std::cout << -"motatool serve — serve a folder of .mota to a MeshCore node over USB serial.\n" +"motatool serve — serve a folder of .mota to a MeshCore node over USB serial or WiFi (TCP).\n" "\n" "USAGE\n" -" motatool serve --dir --serial [options]\n" +" motatool serve --dir --serial [options] # over USB serial\n" +" motatool serve --dir --tcp [options] # over WiFi (ESP32 companion)\n" "\n" "It scans the folder for .mota files, validates each, and serves the valid ones to the node.\n" "Corrupt/invalid files are reported and skipped — one bad file never stops the rest. The node\n" "advertises them to the mesh as if it held them, and any node whose hardware matches can fetch\n" "them. The relay is trustless: fetchers verify every block, so this host never needs the keys.\n" "\n" -"OPTIONS (--dir and --serial are required)\n" -" --dir folder of .mota to serve (searched recursively by default).\n" -" --serial the node's USB serial port (e.g. /dev/ttyUSB0 or /dev/ttyACM0).\n" -" --baud serial speed (default 115200).\n" -" --no-recursive serve only the top folder; don't descend into sub-folders.\n" -" --no-enable don't auto-send 'ota folder on'/'off' to the node (run them on its CLI yourself).\n" -" -v, --verbose log each request the node makes (COUNT / DESCRIBE / READ).\n" +"OPTIONS (--dir and one of --serial / --tcp are required)\n" +" --dir folder of .mota to serve (searched recursively by default).\n" +" --serial the node's USB serial port (e.g. /dev/ttyUSB0 or /dev/ttyACM0).\n" +" --tcp the node's WiFi seeder address (default port 5001). This is a DEDICATED\n" +" port, separate from the companion port (5000), so serving doesn't disturb a\n" +" phone app connected to the node. The node auto-enables relaying on connect.\n" +" --baud serial speed (default 115200; --serial only).\n" +" --no-recursive serve only the top folder; don't descend into sub-folders.\n" +" --no-enable (--serial only) don't auto-send 'ota folder on'/'off' on the node's CLI.\n" +" -v, --verbose log each request the node makes (COUNT / DESCRIBE / READ).\n" "\n" -"Leave it running; press Ctrl-C to stop (it tells the node to stop relaying first). It shares the\n" -"same USB cable as the node's text console — the node only pulls bytes while actively fetching.\n" +"Leave it running; press Ctrl-C to stop. Over serial it shares the USB cable with the node's text\n" +"console; over TCP it uses the node's dedicated seeder port. The node only pulls while fetching.\n" "\n" -"EXAMPLE\n" -" motatool serve --dir ./motas --serial /dev/ttyUSB0 -v\n"; +"EXAMPLES\n" +" motatool serve --dir ./motas --serial /dev/ttyUSB0 -v\n" +" motatool serve --dir ./motas --tcp 192.168.4.234 -v\n"; } static void help_keygen() { @@ -442,8 +447,9 @@ static int cmd_keygen(const Args& a) { static int cmd_serve(const Args& a) { if (a.has("help")) { help_serve(); return 0; } - if (!a.has("dir") || !a.has("serial")) { - std::cerr << "error: --dir and --serial are required\n\n"; help_serve(); return 2; + bool use_tcp = a.has("tcp"); + if (!a.has("dir") || (!a.has("serial") && !use_tcp)) { + std::cerr << "error: --dir and one of --serial / --tcp are required\n\n"; help_serve(); return 2; } bool recursive = !a.has("no-recursive"); bool verbose = a.has("verbose"); @@ -465,20 +471,39 @@ static int cmd_serve(const Args& a) { } if (n == 0) std::cerr << " (nothing valid to serve)\n"; - SerialTransport t; - std::string e = t.open(a.get("serial"), std::atoi(a.get("baud", "115200").c_str())); - if (!e.empty()) { std::cerr << "error: " << e << "\n"; return 1; } + // Pick the transport: a serial port, or a TCP connection to the node's WiFi seeder port (host[:port], + // default port 5001). The node runs the seeder on a DEDICATED port, separate from its companion port, + // so serving over WiFi doesn't disturb a phone app connected to the companion. + SerialTransport st; + TcpTransport tt; + Transport* t = nullptr; + std::string target; + if (use_tcp) { + std::string hp = a.get("tcp"); + size_t colon = hp.rfind(':'); + std::string host = (colon == std::string::npos) ? hp : hp.substr(0, colon); + int port = (colon == std::string::npos) ? 5001 : std::atoi(hp.substr(colon + 1).c_str()); + std::string e = tt.open(host, port); + if (!e.empty()) { std::cerr << "error: " << e << "\n"; return 1; } + t = &tt; target = host + ":" + std::to_string(port); + } else { + std::string e = st.open(a.get("serial"), std::atoi(a.get("baud", "115200").c_str())); + if (!e.empty()) { std::cerr << "error: " << e << "\n"; return 1; } + t = &st; target = a.get("serial") + " @ " + a.get("baud", "115200"); + } std::signal(SIGINT, on_sigint); - bool enable = !a.has("no-enable"); - if (enable) { usleep(500000); t.write_str("ota folder on\r\n"); std::cout << "sent `ota folder on`\n"; } - std::cout << "serving on " << a.get("serial") << " @ " << a.get("baud", "115200") << " — Ctrl-C to stop\n"; + // The CLI auto-enable only applies to the serial console; the TCP seeder port auto-enables relaying on + // the node side when this connection opens (and stops when it closes), so there's nothing to send. + bool enable = !use_tcp && !a.has("no-enable"); + if (enable) { usleep(500000); t->write_str("ota folder on\r\n"); std::cout << "sent `ota folder on`\n"; } + std::cout << "serving on " << target << " — Ctrl-C to stop\n"; SeederCore core(folder); - serve_serial(t, core, verbose, - [](const std::string& l) { std::cout << " [dev] " << l << "\n"; }, &g_stop); + serve_loop(*t, core, verbose, + [](const std::string& l) { std::cout << " [dev] " << l << "\n"; }, &g_stop); - if (enable) { t.write_str("ota folder off\r\n"); usleep(200000); } + if (enable) { t->write_str("ota folder off\r\n"); usleep(200000); } std::cout << "\nbye\n"; return 0; } diff --git a/tools/motatool/src/serve.cpp b/tools/motatool/src/serve.cpp index 5812f0a9d7..ef89ed9f4c 100644 --- a/tools/motatool/src/serve.cpp +++ b/tools/motatool/src/serve.cpp @@ -5,6 +5,8 @@ #include #include #include +#include +#include #include "util.h" namespace fs = std::filesystem; @@ -138,7 +140,48 @@ bool SerialTransport::write(const uint8_t* p, size_t n) { return true; } -// ---- serial framing loop -------------------------------------------------------------------------- +// ---- TcpTransport --------------------------------------------------------------------------------- +std::string TcpTransport::open(const std::string& host, int port) { + addrinfo hints{}; hints.ai_family = AF_UNSPEC; hints.ai_socktype = SOCK_STREAM; + addrinfo* res = nullptr; + std::string portstr = std::to_string(port); + if (getaddrinfo(host.c_str(), portstr.c_str(), &hints, &res) != 0 || !res) + return "cannot resolve host: " + host; + std::string err = "cannot connect to " + host + ":" + portstr; + for (addrinfo* p = res; p; p = p->ai_next) { + int fd = ::socket(p->ai_family, p->ai_socktype, p->ai_protocol); + if (fd < 0) continue; + if (::connect(fd, p->ai_addr, p->ai_addrlen) == 0) { fd_ = fd; err.clear(); break; } + ::close(fd); + } + freeaddrinfo(res); + return err; +} + +TcpTransport::~TcpTransport() { if (fd_ >= 0) ::close(fd_); } + +int TcpTransport::read_byte(int timeout_ms) { + if (fd_ < 0) return -1; + fd_set rs; FD_ZERO(&rs); FD_SET(fd_, &rs); + timeval tv{ timeout_ms / 1000, (timeout_ms % 1000) * 1000 }; + if (select(fd_ + 1, &rs, nullptr, nullptr, &tv) <= 0) return -1; + uint8_t b; + ssize_t n = ::recv(fd_, &b, 1, 0); + return n == 1 ? (int)b : -1; // 0 == peer closed -> treated as timeout/closed +} + +bool TcpTransport::write(const uint8_t* p, size_t n) { + if (fd_ < 0) return false; + size_t off = 0; + while (off < n) { + ssize_t w = ::send(fd_, p + off, n - off, 0); + if (w < 0) return false; + off += (size_t)w; + } + return true; +} + +// ---- seeder framing loop -------------------------------------------------------------------------- static uint8_t xor_bytes(const uint8_t* p, size_t n, uint8_t seed = 0) { uint8_t x = seed; for (size_t i = 0; i < n; i++) x ^= p[i]; return x; } @@ -162,9 +205,9 @@ static void send_response(Transport& t, uint8_t op, uint8_t status, const std::v t.write(frame.data(), frame.size()); } -void serve_serial(Transport& t, const SeederCore& core, bool verbose, - const std::function& devline, - const volatile bool* stop) { +void serve_loop(Transport& t, const SeederCore& core, bool verbose, + const std::function& devline, + const volatile bool* stop) { std::string line; int prev = -1; auto flush_line = [&]() { diff --git a/tools/motatool/src/serve.h b/tools/motatool/src/serve.h index 7bff708e70..5177172460 100644 --- a/tools/motatool/src/serve.h +++ b/tools/motatool/src/serve.h @@ -59,10 +59,23 @@ class SerialTransport : public Transport { int fd_ = -1; }; -// Serial framing loop: resync on 'M''S', verify the request checksum, dispatch to `core`, frame the -// reply. Device text/log lines sharing the wire are surfaced via `devline`. Runs until *stop becomes true. -void serve_serial(Transport& t, const SeederCore& core, bool verbose, - const std::function& devline, - const volatile bool* stop); +// TCP client transport: connect to a node's WiFi seeder port (a dedicated port, NOT the companion port), +// then run the same seeder framing over the socket. Lets `serve` feed a WiFi node the same way as serial. +class TcpTransport : public Transport { +public: + std::string open(const std::string& host, int port); // "" on success + ~TcpTransport() override; + int read_byte(int timeout_ms) override; + bool write(const uint8_t* p, size_t n) override; +private: + int fd_ = -1; +}; + +// Seeder framing loop (transport-agnostic): resync on 'M''S', verify the request checksum, dispatch to +// `core`, frame the reply. Device text/log lines sharing the wire are surfaced via `devline` (serial +// only; the TCP seeder port carries no log text). Runs until *stop becomes true. +void serve_loop(Transport& t, const SeederCore& core, bool verbose, + const std::function& devline, + const volatile bool* stop); } // namespace mota From fa487cfacd5714a194fdc78ab2c0ad7d1202bbad Mon Sep 17 00:00:00 2001 From: Valentin Kivachuk Burda Date: Mon, 29 Jun 2026 16:59:09 +0200 Subject: [PATCH 12/59] ota: faster post-boot beacon + periodic re-announce + clearer `ota ls` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discovery was hard to use — a node only advertised at boot, so a peer that ran `ota ls` minutes later saw "no neighbours". - First self-advert ~8s after boot, then a short burst (~1 min), then re-announce at a random 3-10 min interval so a long-running node stays discoverable without all nodes beaconing in lockstep. The beacon is tiny, lowest-priority and duty-gated, so a few-minute cadence is cheap. - `ota ls` now shows the raw target id (`hw XXXXXXXX`) when the env name is not in this build's OtaTargets.h table, instead of a blank "[other hw]". (cherry picked from commit 4cb118b422074ef7f6e6dd0e778375bdad136219) --- src/Mesh.cpp | 23 ++++++++++++++++------- src/helpers/ota/OtaCli.cpp | 9 ++++++--- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/src/Mesh.cpp b/src/Mesh.cpp index ecab951f07..1cfbd55a5f 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -5,16 +5,22 @@ #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) +#define OTA_ANNOUNCE_BOOT_MS 8000UL // first self-advert ~8 s after boot (settled, but quick to discover) #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 ... +#define OTA_ANNOUNCE_BURST_MS 20000UL // spacing during the boot burst (~1 min total), then ... #endif -#ifndef OTA_ANNOUNCE_INTERVAL_MS -#define OTA_ANNOUNCE_INTERVAL_MS 86400000UL // ... every 24 h — all lowest priority, duty-gated +// ... then re-announce at a RANDOM interval in [MIN, MAX] so a long-running node stays discoverable +// (a fresh `ota ls` neighbour sees it within minutes, not at boot only) without all nodes beaconing in +// lockstep. The beacon is tiny + lowest-priority + duty-gated, so a few-minute cadence is cheap. +#ifndef OTA_ANNOUNCE_INTERVAL_MIN_MS +#define OTA_ANNOUNCE_INTERVAL_MIN_MS 180000UL // 3 min +#endif +#ifndef OTA_ANNOUNCE_INTERVAL_MAX_MS +#define OTA_ANNOUNCE_INTERVAL_MAX_MS 600000UL // 10 min #endif #endif @@ -88,9 +94,12 @@ void Mesh::loop() { // 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); + // boot burst (a few closely-spaced adverts so a co-booting peer catches one), then a random + // few-minute cadence so a node stays discoverable long after boot (not just at boot). + uint32_t gap = (_ota_announce_count < OTA_ANNOUNCE_BURST) + ? OTA_ANNOUNCE_BURST_MS + : _rng->nextInt(OTA_ANNOUNCE_INTERVAL_MIN_MS, OTA_ANNOUNCE_INTERVAL_MAX_MS); + _next_ota_announce = futureMillis(gap); if (_ota_announce_count < 250) _ota_announce_count++; } { // auto-install (once per COMPLETE fetch): only signed images, and apply_fetched enforces trust diff --git a/src/helpers/ota/OtaCli.cpp b/src/helpers/ota/OtaCli.cpp index b5cb340675..e35955b550 100644 --- a/src/helpers/ota/OtaCli.cpp +++ b/src/helpers/ota/OtaCli.cpp @@ -141,13 +141,16 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board bool on = cur && memcmp(cur, h->mid, 4) == 0; uint32_t age = (now - h->last_ms) / 1000; if (age > 99999) age = 99999; char ver[20]; ver_str(ver, sizeof ver, h->fw_version); - // What is this update for? "yours" if same hw+role as us; else the target's env name when we know it - // (named locally from its 4-byte target_id — no string travels on the wire); else other hw / '?'. + // What is this update for? "yours" if same target (hw+role) as us; else the target's env name when we + // know it (named locally from its 4-byte target_id — no string travels on the wire); else the raw + // target_id hex (an env this build's OtaTargets.h table doesn't know) or '?' for an unset target. + char hwbuf[16]; const char* fit; const char* env = ota_target_env_name(h->target_id); if (myt && h->target_id == myt) fit = "yours"; else if (env) fit = env; - else fit = (h->target_id == 0) ? "?" : "other hw"; + else if (h->target_id == 0) fit = "?"; + else { snprintf(hwbuf, sizeof hwbuf, "hw %08X", (unsigned)h->target_id); fit = hwbuf; } n += snprintf(reply + n, CAP - n, "\n %d) %s %s [%s] %un %us%s", shown + 1, ver, codec_kind(h->codec), fit, (unsigned)h->n_seeders, (unsigned)age, on ? " [downloading]" : ""); From 3789c99cf9aaac70f855a173e9187f6e54ce0c2d Mon Sep 17 00:00:00 2001 From: Valentin Kivachuk Burda Date: Mon, 29 Jun 2026 16:59:09 +0200 Subject: [PATCH 13/59] ota: enable OTA on the nRF52 variants the OTAFIX bootloader supports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch these existing variants from `nrf52_base` to `rak4631_hw` (which adds ENABLE_OTA + the in-place flash store + the EndF post-build hook), so they get OTA-over-LoRa. Scope is limited to variants already covered by the Adafruit_nRF52_Bootloader_OTAFIX in-place apply — no new variants are added. OtaTargets.h is regenerated to include their target ids. (cherry picked from commit 0a1cf2b23181e883a632043b6e6b09463667531f) --- src/helpers/ota/OtaTargets.h | 117 ++++++++++++++++++-- variants/ikoka_handheld_nrf/platformio.ini | 8 +- variants/ikoka_nano_nrf/platformio.ini | 6 +- variants/ikoka_stick_nrf/platformio.ini | 6 +- variants/lilygo_techo/platformio.ini | 8 +- variants/lilygo_techo_card/platformio.ini | 8 +- variants/lilygo_techo_lite/platformio.ini | 16 +-- variants/promicro/platformio.ini | 8 +- variants/t1000-e/platformio.ini | 6 +- variants/thinknode_m1/platformio.ini | 8 +- variants/thinknode_m3/platformio.ini | 8 +- variants/thinknode_m6/platformio.ini | 8 +- variants/wio-tracker-l1-eink/platformio.ini | 8 +- variants/wio-tracker-l1/platformio.ini | 8 +- variants/xiao_nrf52/platformio.ini | 8 +- 15 files changed, 165 insertions(+), 66 deletions(-) diff --git a/src/helpers/ota/OtaTargets.h b/src/helpers/ota/OtaTargets.h index e7ec61cef6..9fded575fe 100644 --- a/src/helpers/ota/OtaTargets.h +++ b/src/helpers/ota/OtaTargets.h @@ -2,7 +2,7 @@ #include // AUTO-GENERATED by tools/mota/gen_targets.py — do not edit by hand. -// 319 OTA-capable PlatformIO envs. Maps target_id (= sha2-256:4 of the env name, LE uint32) +// 418 OTA-capable PlatformIO envs. Maps target_id (= sha2-256:4 of the env name, LE uint32) // to the human-readable env name, so a node/tool can name a target seen over the air WITHOUT // transmitting the string in the .mota / LoRa protocol. Regenerate when the OTA env set changes. @@ -142,6 +142,58 @@ inline const char* ota_target_env_name(uint32_t target_id) { { 0xd7460575, "Heltec_WSL3_repeater_bridge_rs232" }, { 0x33e2c171, "Heltec_WSL3_room_server" }, { 0xa165ae99, "Heltec_WSL3_sensor" }, + { 0x04c5c2b7, "ikoka_handheld_nrf_e22_30dbm_096_companion_radio_ble" }, + { 0x6c1ca659, "ikoka_handheld_nrf_e22_30dbm_096_companion_radio_usb" }, + { 0xa295fb04, "ikoka_handheld_nrf_e22_30dbm_096_rotated_companion_radio_ble" }, + { 0x61c405ca, "ikoka_handheld_nrf_e22_30dbm_096_rotated_companion_radio_usb" }, + { 0xe893fe91, "ikoka_handheld_nrf_e22_30dbm_repeater" }, + { 0x2b599d31, "ikoka_handheld_nrf_e22_30dbm_room_server" }, + { 0xf1442d25, "ikoka_handheld_nrf_kiss_modem" }, + { 0x15082ede, "ikoka_nano_nrf_22dbm_companion_radio_ble" }, + { 0x6ff350d1, "ikoka_nano_nrf_22dbm_companion_radio_usb" }, + { 0xe03d0cd9, "ikoka_nano_nrf_22dbm_kiss_modem" }, + { 0x82e1eb2c, "ikoka_nano_nrf_22dbm_repeater" }, + { 0x0ee19b12, "ikoka_nano_nrf_22dbm_room_server" }, + { 0x6c67ed89, "ikoka_nano_nrf_30dbm_companion_radio_ble" }, + { 0xd1c05d0d, "ikoka_nano_nrf_30dbm_companion_radio_usb" }, + { 0xb90150b0, "ikoka_nano_nrf_30dbm_kiss_modem" }, + { 0x7285bdb3, "ikoka_nano_nrf_30dbm_repeater" }, + { 0x77cadc99, "ikoka_nano_nrf_30dbm_room_server" }, + { 0xc177e6cd, "ikoka_nano_nrf_33dbm_companion_radio_ble" }, + { 0xf7a38993, "ikoka_nano_nrf_33dbm_companion_radio_usb" }, + { 0x83762f33, "ikoka_nano_nrf_33dbm_kiss_modem" }, + { 0x86cadda9, "ikoka_nano_nrf_33dbm_repeater" }, + { 0x7e49da49, "ikoka_nano_nrf_33dbm_room_server" }, + { 0x305d6f92, "ikoka_stick_nrf_22dbm_companion_radio_ble" }, + { 0xbc6b870b, "ikoka_stick_nrf_22dbm_companion_radio_usb" }, + { 0x78845fdd, "ikoka_stick_nrf_22dbm_kiss_modem" }, + { 0x6d35f434, "ikoka_stick_nrf_22dbm_repeater" }, + { 0xa52d76a8, "ikoka_stick_nrf_22dbm_room_server" }, + { 0xd8de9639, "ikoka_stick_nrf_30dbm_companion_radio_ble" }, + { 0x8ab5c51b, "ikoka_stick_nrf_30dbm_companion_radio_usb" }, + { 0x80097676, "ikoka_stick_nrf_30dbm_kiss_modem" }, + { 0xffe70df5, "ikoka_stick_nrf_30dbm_repeater" }, + { 0x3da80417, "ikoka_stick_nrf_30dbm_room_server" }, + { 0x47ca3c13, "ikoka_stick_nrf_33dbm_companion_radio_ble" }, + { 0x28b499c7, "ikoka_stick_nrf_33dbm_companion_radio_usb" }, + { 0xf1ab4c28, "ikoka_stick_nrf_33dbm_kiss_modem" }, + { 0xe5331f02, "ikoka_stick_nrf_33dbm_repeater" }, + { 0xfba7177b, "ikoka_stick_nrf_33dbm_room_server" }, + { 0x69df7765, "LilyGo_T-Echo-Lite_companion_radio_ble" }, + { 0xe9fbc9be, "LilyGo_T-Echo-Lite_kiss_modem" }, + { 0x40818863, "LilyGo_T-Echo-Lite_non_shell_companion_radio_ble" }, + { 0x65deac33, "LilyGo_T-Echo-Lite_non_shell_companion_radio_usb" }, + { 0xd86cf67a, "LilyGo_T-Echo-Lite_repeater" }, + { 0xed73ac03, "LilyGo_T-Echo-Lite_room_server" }, + { 0xab0d550a, "LilyGo_T-Echo_Card_companion_radio_ble" }, + { 0x1bd2a266, "LilyGo_T-Echo_Card_companion_radio_usb" }, + { 0x9a5ae8fc, "LilyGo_T-Echo_Card_repeater" }, + { 0x1e9eb97a, "LilyGo_T-Echo_Card_room_server" }, + { 0x139536f4, "LilyGo_T-Echo_companion_radio_ble" }, + { 0x0716c724, "LilyGo_T-Echo_companion_radio_usb" }, + { 0x8de23fa7, "LilyGo_T-Echo_kiss_modem" }, + { 0xb92ec96b, "LilyGo_T-Echo_repeater" }, + { 0x420ebc63, "LilyGo_T-Echo_room_server" }, { 0x967ccaee, "LilyGo_T3S3_sx1262_companion_radio_ble" }, { 0x5f7c7688, "LilyGo_T3S3_sx1262_companion_radio_usb" }, { 0xbd04e6e7, "LilyGo_T3S3_sx1262_kiss_modem" }, @@ -205,14 +257,29 @@ inline const char* ota_target_env_name(uint32_t target_id) { { 0xcea95e20, "Meshadventurer_sx1268_terminal_chat" }, { 0xd45277ff, "Meshimi_companion_radio_ble_" }, { 0x2cad9a9d, "Meshimi_repeater_" }, - { 0xf1c1641c, "nibble_screen_connect_companion_radio_ble" }, - { 0x8dcc5d89, "nibble_screen_connect_companion_radio_usb" }, - { 0x6139a846, "nibble_screen_connect_companion_radio_wifi" }, - { 0xc0dbcae4, "nibble_screen_connect_kiss_modem" }, - { 0xcd0bdd06, "nibble_screen_connect_repeater" }, - { 0x9ecb53b1, "nibble_screen_connect_repeater_bridge_espnow" }, - { 0x82f6d321, "nibble_screen_connect_room_server" }, - { 0xb10261c8, "nibble_screen_connect_terminal_chat" }, + { 0x063f1f00, "nibble_screen_connect_companion_radio_ble_" }, + { 0xa8a79da5, "nibble_screen_connect_companion_radio_usb_" }, + { 0xc0d3815b, "nibble_screen_connect_companion_radio_wifi_" }, + { 0x966ac88e, "nibble_screen_connect_kiss_modem_" }, + { 0x104b3f8b, "nibble_screen_connect_repeater_" }, + { 0x23b49348, "nibble_screen_connect_repeater_bridge_espnow_" }, + { 0xc42cc90d, "nibble_screen_connect_room_server_" }, + { 0xc24791c3, "nibble_screen_connect_terminal_chat_" }, + { 0x15e11067, "nibble_zero_connect_companion_radio_ble_" }, + { 0x90799c32, "nibble_zero_connect_companion_radio_usb_" }, + { 0x881a6a48, "nibble_zero_connect_companion_radio_wifi_" }, + { 0xf26af51c, "nibble_zero_connect_repeater_" }, + { 0x8174cbdc, "nibble_zero_connect_repeater_bridge_espnow_" }, + { 0x0bf0f061, "nibble_zero_connect_room_server_" }, + { 0x2509dd80, "nibble_zero_connect_terminal_chat_" }, + { 0x56293b24, "ProMicro_companion_radio_ble" }, + { 0x8c81078c, "ProMicro_companion_radio_usb" }, + { 0xbd6a1c97, "ProMicro_kiss_modem" }, + { 0xf4d98c12, "ProMicro_repeater" }, + { 0xccac4283, "ProMicro_repeater_bridge_rs232_serial1" }, + { 0x5f606274, "ProMicro_room_server" }, + { 0x84923735, "ProMicro_sensor" }, + { 0x2c0b8b05, "ProMicro_terminal_chat" }, { 0xdba69401, "R1Neo_companion_radio_ble" }, { 0xcb6555ee, "R1Neo_companion_radio_usb" }, { 0xd60daf25, "R1Neo_kiss_modem" }, @@ -262,6 +329,11 @@ inline const char* ota_target_env_name(uint32_t target_id) { { 0xf8d1958f, "Station_G3_ESP32_logging_repeater" }, { 0x3c49caf8, "Station_G3_ESP32_repeater" }, { 0x91878dd8, "Station_G3_ESP32_room_server" }, + { 0x4dbf035e, "t1000e_companion_radio_ble" }, + { 0xff4102d6, "t1000e_companion_radio_usb" }, + { 0x2f9a9185, "t1000e_kiss_modem" }, + { 0x000fc519, "t1000e_repeater" }, + { 0x3e55e34d, "t1000e_room_server" }, { 0x16482630, "T_Beam_S3_Supreme_SX1262_companion_radio_ble" }, { 0x01c43d49, "T_Beam_S3_Supreme_SX1262_companion_radio_wifi" }, { 0x51fe3801, "T_Beam_S3_Supreme_SX1262_kiss_modem" }, @@ -284,6 +356,11 @@ inline const char* ota_target_env_name(uint32_t target_id) { { 0x1211b151, "Tenstar_C3_sx1268_kiss_modem" }, { 0x0a32044b, "Tenstar_C3_sx1268_repeater" }, { 0xa13fe853, "Tenstar_C3_sx1268_repeater_bridge_espnow" }, + { 0x79b9ff01, "ThinkNode_M1_companion_radio_ble" }, + { 0xc3decea2, "ThinkNode_M1_companion_radio_usb" }, + { 0x3c012018, "ThinkNode_M1_kiss_modem" }, + { 0x6afd6f75, "ThinkNode_M1_repeater" }, + { 0x7517d400, "ThinkNode_M1_room_server" }, { 0xd7598668, "ThinkNode_M2_companion_radio_ble" }, { 0x413287d8, "ThinkNode_M2_companion_radio_serial" }, { 0xe8cd2377, "ThinkNode_M2_companion_radio_usb" }, @@ -293,6 +370,11 @@ inline const char* ota_target_env_name(uint32_t target_id) { { 0xa5cb783a, "ThinkNode_M2_Repeater_bridge_espnow" }, { 0x4b9f8283, "ThinkNode_M2_room_server" }, { 0x311846c9, "ThinkNode_M2_terminal_chat" }, + { 0x8b6b29b7, "ThinkNode_M3_companion_radio_ble" }, + { 0x37928233, "ThinkNode_M3_companion_radio_usb" }, + { 0x88ac45da, "ThinkNode_M3_kiss_modem" }, + { 0x087f99c0, "ThinkNode_M3_repeater" }, + { 0x2afe6c63, "ThinkNode_M3_room_server" }, { 0xf2d61a1c, "ThinkNode_M5_companion_radio_ble" }, { 0x1da30414, "ThinkNode_M5_companion_radio_serial" }, { 0xae8fe1c9, "ThinkNode_M5_companion_radio_usb" }, @@ -302,8 +384,20 @@ inline const char* ota_target_env_name(uint32_t target_id) { { 0x1298038d, "ThinkNode_M5_Repeater_bridge_espnow" }, { 0x2c47acff, "ThinkNode_M5_room_server" }, { 0x7d641646, "ThinkNode_M5_terminal_chat" }, + { 0x51efdbfe, "ThinkNode_M6_companion_radio_ble" }, + { 0xf2aada5b, "ThinkNode_M6_companion_radio_usb" }, + { 0xe59c9373, "ThinkNode_M6_kiss_modem" }, + { 0x236f605c, "ThinkNode_M6_repeater" }, + { 0x93994fed, "ThinkNode_M6_room_server" }, { 0x37443a5b, "WHY2025_badge_companion_radio_ble_" }, { 0x603384a7, "WHY2025_badge_repeater_" }, + { 0x475a2a6a, "WioTrackerL1_companion_radio_ble" }, + { 0xf4175d2c, "WioTrackerL1_companion_radio_usb" }, + { 0x3d831006, "WioTrackerL1_kiss_modem" }, + { 0x556eb1bc, "WioTrackerL1_repeater" }, + { 0x0c689b90, "WioTrackerL1_room_server" }, + { 0x91eac424, "WioTrackerL1Eink_companion_radio_ble" }, + { 0x7a84a8c4, "WioTrackerL1Eink_kiss_modem" }, { 0x67843a5f, "Xiao_C3_companion_radio_ble" }, { 0xfad357ab, "Xiao_C3_companion_radio_usb" }, { 0x16af5c67, "Xiao_C3_companion_radio_wifi" }, @@ -313,6 +407,11 @@ inline const char* ota_target_env_name(uint32_t target_id) { { 0x77c8f2b5, "Xiao_C6_companion_radio_ble_" }, { 0x71ee0e15, "Xiao_C6_kiss_modem" }, { 0x8824cd0c, "Xiao_C6_repeater_" }, + { 0xe5439198, "Xiao_nrf52_companion_radio_ble" }, + { 0xe90a6e74, "Xiao_nrf52_companion_radio_usb" }, + { 0x833e5cdc, "Xiao_nrf52_kiss_modem" }, + { 0x93145fbb, "Xiao_nrf52_repeater" }, + { 0x33a154ff, "Xiao_nrf52_room_server" }, { 0x6658b418, "Xiao_S3_companion_radio_ble" }, { 0x964f4f5c, "Xiao_S3_companion_radio_usb" }, { 0x0a5dc760, "Xiao_S3_kiss_modem" }, diff --git a/variants/ikoka_handheld_nrf/platformio.ini b/variants/ikoka_handheld_nrf/platformio.ini index 51b602e403..e14c4d4928 100644 --- a/variants/ikoka_handheld_nrf/platformio.ini +++ b/variants/ikoka_handheld_nrf/platformio.ini @@ -1,8 +1,8 @@ [ikoka_handheld_nrf] -extends = nrf52_base +extends = rak4631_hw board = seeed-xiao-afruitnrf52-nrf52840 board_build.ldscript = boards/nrf52840_s140_v7.ld -build_flags = ${nrf52_base.build_flags} +build_flags = ${rak4631_hw.build_flags} ${sensor_base.build_flags} -I lib/nrf52/s140_nrf52_7.3.0_API/include -I lib/nrf52/s140_nrf52_7.3.0_API/include/nrf52 @@ -23,10 +23,10 @@ build_flags = ${nrf52_base.build_flags} -D SX126X_DIO3_TCXO_VOLTAGE=1.8 -D SX126X_CURRENT_LIMIT=140 -D SX126X_RX_BOOSTED_GAIN=1 -build_src_filter = ${nrf52_base.build_src_filter} +build_src_filter = ${rak4631_hw.build_src_filter} +<../variants/ikoka_handheld_nrf> + -lib_deps = ${nrf52_base.lib_deps} +lib_deps = ${rak4631_hw.lib_deps} ${sensor_base.lib_deps} densaugeo/base64 @ ~1.4.0 diff --git a/variants/ikoka_nano_nrf/platformio.ini b/variants/ikoka_nano_nrf/platformio.ini index e72f83ce0d..537c40c0e2 100644 --- a/variants/ikoka_nano_nrf/platformio.ini +++ b/variants/ikoka_nano_nrf/platformio.ini @@ -1,8 +1,8 @@ [ikoka_nano_nrf] -extends = nrf52_base +extends = rak4631_hw board = seeed-xiao-afruitnrf52-nrf52840 board_build.ldscript = boards/nrf52840_s140_v7.ld -build_flags = ${nrf52_base.build_flags} +build_flags = ${rak4631_hw.build_flags} ${sensor_base.build_flags} -D NRF52_PLATFORM -D XIAO_NRF52 -I lib/nrf52/s140_nrf52_7.3.0_API/include @@ -29,7 +29,7 @@ build_flags = ${nrf52_base.build_flags} -UENV_INCLUDE_GPS debug_tool = jlink upload_protocol = nrfutil -lib_deps = ${nrf52_base.lib_deps} +lib_deps = ${rak4631_hw.lib_deps} ${sensor_base.lib_deps} [ikoka_nano_nrf_e22_22dbm] diff --git a/variants/ikoka_stick_nrf/platformio.ini b/variants/ikoka_stick_nrf/platformio.ini index 06e39e84c3..1cf3286c30 100644 --- a/variants/ikoka_stick_nrf/platformio.ini +++ b/variants/ikoka_stick_nrf/platformio.ini @@ -1,8 +1,8 @@ [ikoka_stick_nrf] -extends = nrf52_base +extends = rak4631_hw board = seeed-xiao-afruitnrf52-nrf52840 board_build.ldscript = boards/nrf52840_s140_v7.ld -build_flags = ${nrf52_base.build_flags} +build_flags = ${rak4631_hw.build_flags} ${sensor_base.build_flags} -D NRF52_PLATFORM -D XIAO_NRF52 -I lib/nrf52/s140_nrf52_7.3.0_API/include @@ -29,7 +29,7 @@ build_flags = ${nrf52_base.build_flags} -D PIN_WIRE_SCL=7 -D PIN_WIRE_SDA=6 -UENV_INCLUDE_GPS -lib_deps = ${nrf52_base.lib_deps} +lib_deps = ${rak4631_hw.lib_deps} ${sensor_base.lib_deps} [ikoka_stick_nrf_e22_22dbm] diff --git a/variants/lilygo_techo/platformio.ini b/variants/lilygo_techo/platformio.ini index 5df77f95cb..b57da999e0 100644 --- a/variants/lilygo_techo/platformio.ini +++ b/variants/lilygo_techo/platformio.ini @@ -1,8 +1,8 @@ [LilyGo_T-Echo] -extends = nrf52_base +extends = rak4631_hw board = t-echo board_build.ldscript = boards/nrf52840_s140_v6.ld -build_flags = ${nrf52_base.build_flags} +build_flags = ${rak4631_hw.build_flags} -I variants/lilygo_techo -I src/helpers/nrf52 -I lib/nrf52/s140_nrf52_6.1.1_API/include @@ -34,7 +34,7 @@ build_flags = ${nrf52_base.build_flags} -D DISPLAY_CLASS=GxEPDDisplay -D BACKLIGHT_BTN=PIN_BUTTON2 -D AUTO_OFF_MILLIS=0 -build_src_filter = ${nrf52_base.build_src_filter} +build_src_filter = ${rak4631_hw.build_src_filter} + + + @@ -42,7 +42,7 @@ build_src_filter = ${nrf52_base.build_src_filter} + +<../variants/lilygo_techo> lib_deps = - ${nrf52_base.lib_deps} + ${rak4631_hw.lib_deps} stevemarple/MicroNMEA @ ^2.0.6 adafruit/Adafruit BME280 Library @ ^2.3.0 zinggjm/GxEPD2 @ 1.6.2 diff --git a/variants/lilygo_techo_card/platformio.ini b/variants/lilygo_techo_card/platformio.ini index 07bcebfe41..2d9765c6cd 100644 --- a/variants/lilygo_techo_card/platformio.ini +++ b/variants/lilygo_techo_card/platformio.ini @@ -1,8 +1,8 @@ [LilyGo_T-Echo_Card] -extends = nrf52_base +extends = rak4631_hw board = t-echo board_build.ldscript = boards/nrf52840_s140_v6.ld -build_flags = ${nrf52_base.build_flags} +build_flags = ${rak4631_hw.build_flags} -I variants/lilygo_techo_card -I src/helpers/nrf52 -I lib/nrf52/s140_nrf52_6.1.1_API/include @@ -19,7 +19,7 @@ build_flags = ${nrf52_base.build_flags} -D ENV_INCLUDE_GPS=1 -D DISPLAY_CLASS=U8g2Display -D PIN_OLED_RESET=-1 -build_src_filter = ${nrf52_base.build_src_filter} +build_src_filter = ${rak4631_hw.build_src_filter} + + + @@ -27,7 +27,7 @@ build_src_filter = ${nrf52_base.build_src_filter} + +<../variants/lilygo_techo_card> lib_deps = - ${nrf52_base.lib_deps} + ${rak4631_hw.lib_deps} stevemarple/MicroNMEA @ ^2.0.6 olikraus/U8g2 @ ^2.35.19 adafruit/Adafruit NeoPixel@^1.10.0 diff --git a/variants/lilygo_techo_lite/platformio.ini b/variants/lilygo_techo_lite/platformio.ini index 4b5edf6918..3f7677c322 100644 --- a/variants/lilygo_techo_lite/platformio.ini +++ b/variants/lilygo_techo_lite/platformio.ini @@ -1,8 +1,8 @@ [LilyGo_T-Echo-Lite] -extends = nrf52_base +extends = rak4631_hw board = t-echo board_build.ldscript = boards/nrf52840_s140_v6.ld -build_flags = ${nrf52_base.build_flags} +build_flags = ${rak4631_hw.build_flags} -I variants/lilygo_techo_lite -I src/helpers/nrf52 -I lib/nrf52/s140_nrf52_6.1.1_API/include @@ -28,7 +28,7 @@ build_flags = ${nrf52_base.build_flags} -D EINK_Y_OFFSET=10 -D DISPLAY_ROTATION=4 -D AUTO_OFF_MILLIS=0 -build_src_filter = ${nrf52_base.build_src_filter} +build_src_filter = ${rak4631_hw.build_src_filter} + + + @@ -36,7 +36,7 @@ build_src_filter = ${nrf52_base.build_src_filter} + +<../variants/lilygo_techo_lite> lib_deps = - ${nrf52_base.lib_deps} + ${rak4631_hw.lib_deps} stevemarple/MicroNMEA @ ^2.0.6 adafruit/Adafruit BME280 Library @ ^2.3.0 https://github.com/SoulOfNoob/GxEPD2.git @@ -104,7 +104,7 @@ upload_protocol = nrfutil board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld board_upload.maximum_size = 712704 build_flags = - ${nrf52_base.build_flags} + ${rak4631_hw.build_flags} -I variants/lilygo_techo_lite -I src/helpers/nrf52 -I lib/nrf52/s140_nrf52_6.1.1_API/include @@ -131,7 +131,7 @@ build_flags = -D OFFLINE_QUEUE_SIZE=256 -D UI_RECENT_LIST_SIZE=9 -D AUTO_SHUTDOWN_MILLIVOLTS=3300 -build_src_filter = ${nrf52_base.build_src_filter} +build_src_filter = ${rak4631_hw.build_src_filter} + + + @@ -149,7 +149,7 @@ upload_protocol = nrfutil board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld board_upload.maximum_size = 712704 build_flags = - ${nrf52_base.build_flags} + ${rak4631_hw.build_flags} -I variants/lilygo_techo_lite -I src/helpers/nrf52 -I lib/nrf52/s140_nrf52_6.1.1_API/include @@ -175,7 +175,7 @@ build_flags = -D AUTO_SHUTDOWN_MILLIVOLTS=3300 ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 -build_src_filter = ${nrf52_base.build_src_filter} +build_src_filter = ${rak4631_hw.build_src_filter} + + + diff --git a/variants/promicro/platformio.ini b/variants/promicro/platformio.ini index 5415e15861..23a7d46b07 100644 --- a/variants/promicro/platformio.ini +++ b/variants/promicro/platformio.ini @@ -1,7 +1,7 @@ [Promicro] -extends = nrf52_base +extends = rak4631_hw board = promicro_nrf52840 -build_flags = ${nrf52_base.build_flags} +build_flags = ${rak4631_hw.build_flags} -I variants/promicro -D PROMICRO -D USE_SX1262 @@ -23,10 +23,10 @@ build_flags = ${nrf52_base.build_flags} -D ENV_INCLUDE_BMP280=1 -D ENV_INCLUDE_INA3221=1 -D ENV_INCLUDE_INA219=1 -build_src_filter = ${nrf52_base.build_src_filter} +build_src_filter = ${rak4631_hw.build_src_filter} + +<../variants/promicro> -lib_deps= ${nrf52_base.lib_deps} +lib_deps= ${rak4631_hw.lib_deps} adafruit/Adafruit SSD1306 @ ^2.5.13 adafruit/Adafruit INA3221 Library @ ^1.0.1 adafruit/Adafruit INA219 @ ^1.2.3 diff --git a/variants/t1000-e/platformio.ini b/variants/t1000-e/platformio.ini index 43a3d93f61..37d37a4527 100644 --- a/variants/t1000-e/platformio.ini +++ b/variants/t1000-e/platformio.ini @@ -1,8 +1,8 @@ [t1000-e] -extends = nrf52_base +extends = rak4631_hw board = tracker-t1000-e board_build.ldscript = boards/nrf52840_s140_v7.ld -build_flags = ${nrf52_base.build_flags} +build_flags = ${rak4631_hw.build_flags} -I src/helpers/nrf52 -I lib/nrf52/s140_nrf52_7.3.0_API/include -I lib/nrf52/s140_nrf52_7.3.0_API/include/nrf52 @@ -28,7 +28,7 @@ build_flags = ${nrf52_base.build_flags} -D LR11X0_DIO_AS_RF_SWITCH=true -D LR11X0_DIO3_TCXO_VOLTAGE=1.6 -D ENV_INCLUDE_GPS=1 -build_src_filter = ${nrf52_base.build_src_filter} +build_src_filter = ${rak4631_hw.build_src_filter} + + +<../variants/t1000-e> diff --git a/variants/thinknode_m1/platformio.ini b/variants/thinknode_m1/platformio.ini index 356edfee69..e10ddaa739 100644 --- a/variants/thinknode_m1/platformio.ini +++ b/variants/thinknode_m1/platformio.ini @@ -1,8 +1,8 @@ [ThinkNode_M1] -extends = nrf52_base +extends = rak4631_hw board = thinknode_m1 board_build.ldscript = boards/nrf52840_s140_v6.ld -build_flags = ${nrf52_base.build_flags} +build_flags = ${rak4631_hw.build_flags} -I src/helpers/nrf52 -I lib/nrf52/s140_nrf52_6.1.1_API/include -I lib/nrf52/s140_nrf52_6.1.1_API/include/nrf52 @@ -24,12 +24,12 @@ build_flags = ${nrf52_base.build_flags} -D SX126X_RX_BOOSTED_GAIN=1 -D LORA_TX_POWER=22 -D P_LORA_TX_LED=13 -build_src_filter = ${nrf52_base.build_src_filter} +build_src_filter = ${rak4631_hw.build_src_filter} + + +<../variants/thinknode_m1> lib_deps = - ${nrf52_base.lib_deps} + ${rak4631_hw.lib_deps} stevemarple/MicroNMEA @ ^2.0.6 debug_tool = jlink upload_protocol = nrfutil diff --git a/variants/thinknode_m3/platformio.ini b/variants/thinknode_m3/platformio.ini index 0a3d4eda92..7e6e2db288 100644 --- a/variants/thinknode_m3/platformio.ini +++ b/variants/thinknode_m3/platformio.ini @@ -1,8 +1,8 @@ [ThinkNode_M3] -extends = nrf52_base +extends = rak4631_hw board = thinknode_m3 board_build.ldscript = boards/nrf52840_s140_v6.ld -build_flags = ${nrf52_base.build_flags} +build_flags = ${rak4631_hw.build_flags} -I src/helpers/nrf52 -I lib/nrf52/s140_nrf52_6.1.1_API/include -I lib/nrf52/s140_nrf52_6.1.1_API/include/nrf52 @@ -29,13 +29,13 @@ build_flags = ${nrf52_base.build_flags} -D LR11X0_DIO3_TCXO_VOLTAGE=3.3 -D MESH_DEBUG=1 -D ENV_INCLUDE_GPS=1 -build_src_filter = ${nrf52_base.build_src_filter} +build_src_filter = ${rak4631_hw.build_src_filter} + +<../variants/thinknode_m3> + debug_tool = stlink upload_protocol = nrfutil -lib_deps= ${nrf52_base.lib_deps} +lib_deps= ${rak4631_hw.lib_deps} [env:ThinkNode_M3_repeater] extends = ThinkNode_M3 diff --git a/variants/thinknode_m6/platformio.ini b/variants/thinknode_m6/platformio.ini index 6fe9043668..9f1d8093e6 100644 --- a/variants/thinknode_m6/platformio.ini +++ b/variants/thinknode_m6/platformio.ini @@ -1,8 +1,8 @@ [ThinkNode_M6] -extends = nrf52_base +extends = rak4631_hw board = thinknode_m6 board_build.ldscript = boards/nrf52840_s140_v6.ld -build_flags = ${nrf52_base.build_flags} +build_flags = ${rak4631_hw.build_flags} ${sensor_base.build_flags} -I src/helpers/nrf52 -I lib/nrf52/s140_nrf52_6.1.1_API/include @@ -27,13 +27,13 @@ build_flags = ${nrf52_base.build_flags} -D P_LORA_TX_LED=PIN_LED_BLUE ; -D PERSISTANT_GPS=1 ; -D ENV_SKIP_GPS_DETECT=1 -build_src_filter = ${nrf52_base.build_src_filter} +build_src_filter = ${rak4631_hw.build_src_filter} + + + +<../variants/thinknode_m6> lib_deps = - ${nrf52_base.lib_deps} + ${rak4631_hw.lib_deps} ${sensor_base.lib_deps} debug_tool = jlink upload_protocol = nrfutil diff --git a/variants/wio-tracker-l1-eink/platformio.ini b/variants/wio-tracker-l1-eink/platformio.ini index 2c74baded3..4929213af9 100644 --- a/variants/wio-tracker-l1-eink/platformio.ini +++ b/variants/wio-tracker-l1-eink/platformio.ini @@ -1,8 +1,8 @@ [WioTrackerL1Eink] -extends = nrf52_base +extends = rak4631_hw board = seeed-wio-tracker-l1 board_build.ldscript = boards/nrf52840_s140_v7.ld -build_flags = ${nrf52_base.build_flags} +build_flags = ${rak4631_hw.build_flags} ${sensor_base.build_flags} -I lib/nrf52/s140_nrf52_7.3.0_API/include -I lib/nrf52/s140_nrf52_7.3.0_API/include/nrf52 @@ -27,12 +27,12 @@ build_flags = ${nrf52_base.build_flags} -D GPS_BAUD_RATE=9600 -D ENV_PIN_SDA=PIN_WIRE1_SDA -D ENV_PIN_SCL=PIN_WIRE1_SCL -build_src_filter = ${nrf52_base.build_src_filter} +build_src_filter = ${rak4631_hw.build_src_filter} + +<../variants/wio-tracker-l1> + + -lib_deps= ${nrf52_base.lib_deps} +lib_deps= ${rak4631_hw.lib_deps} ${sensor_base.lib_deps} adafruit/Adafruit GFX Library @ ^1.12.1 zinggjm/GxEPD2 @ 1.6.2 diff --git a/variants/wio-tracker-l1/platformio.ini b/variants/wio-tracker-l1/platformio.ini index 7bb175bb9a..4bd413639b 100644 --- a/variants/wio-tracker-l1/platformio.ini +++ b/variants/wio-tracker-l1/platformio.ini @@ -1,8 +1,8 @@ [WioTrackerL1] -extends = nrf52_base +extends = rak4631_hw board = seeed-wio-tracker-l1 board_build.ldscript = boards/nrf52840_s140_v7.ld -build_flags = ${nrf52_base.build_flags} +build_flags = ${rak4631_hw.build_flags} ${sensor_base.build_flags} -I lib/nrf52/s140_nrf52_7.3.0_API/include -I lib/nrf52/s140_nrf52_7.3.0_API/include/nrf52 @@ -16,12 +16,12 @@ build_flags = ${nrf52_base.build_flags} -D SX126X_RX_BOOSTED_GAIN=1 -D PIN_OLED_RESET=-1 -D GPS_BAUD_RATE=9600 -build_src_filter = ${nrf52_base.build_src_filter} +build_src_filter = ${rak4631_hw.build_src_filter} + +<../variants/wio-tracker-l1> + + -lib_deps= ${nrf52_base.lib_deps} +lib_deps= ${rak4631_hw.lib_deps} ${sensor_base.lib_deps} adafruit/Adafruit SH110X @ ^2.1.13 adafruit/Adafruit GFX Library @ ^1.12.1 diff --git a/variants/xiao_nrf52/platformio.ini b/variants/xiao_nrf52/platformio.ini index a085433688..2120704340 100644 --- a/variants/xiao_nrf52/platformio.ini +++ b/variants/xiao_nrf52/platformio.ini @@ -1,8 +1,8 @@ [Xiao_nrf52] -extends = nrf52_base +extends = rak4631_hw board = seeed-xiao-afruitnrf52-nrf52840 board_build.ldscript = boards/nrf52840_s140_v7.ld -build_flags = ${nrf52_base.build_flags} +build_flags = ${rak4631_hw.build_flags} ${sensor_base.build_flags} -I lib/nrf52/s140_nrf52_7.3.0_API/include -I lib/nrf52/s140_nrf52_7.3.0_API/include/nrf52 @@ -30,14 +30,14 @@ build_flags = ${nrf52_base.build_flags} -D PIN_WIRE_SDA=D7 -D PIN_USER_BTN=PIN_BUTTON1 -D DISPLAY_CLASS=NullDisplayDriver -build_src_filter = ${nrf52_base.build_src_filter} +build_src_filter = ${rak4631_hw.build_src_filter} + + +<../variants/xiao_nrf52> + debug_tool = jlink upload_protocol = nrfutil -lib_deps = ${nrf52_base.lib_deps} +lib_deps = ${rak4631_hw.lib_deps} ${sensor_base.lib_deps} [env:Xiao_nrf52_companion_radio_ble] From 401278918a79bf6566ab9b824c15d1b3c3d05747 Mon Sep 17 00:00:00 2001 From: Valentin Kivachuk Burda Date: Tue, 30 Jun 2026 14:29:38 +0200 Subject: [PATCH 14/59] ota: configurable advertise interval (default 24h) + advertise on served-set change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The discovery beacon previously re-announced at a random 3-10 min interval. Replace that with a fixed, user-configurable cadence: - OtaManager::advert_mins() — re-advertise every N minutes after the boot burst; 0 disables periodic re-advertise (boot burst only). Default 24h. - Persisted in NodePrefs (CommonCLI) and runtime-tunable: `ota config advert ` (0..10080; 0 = off), and shown in `ota config`. - When periodic advert is disabled, the scheduler still re-checks the config on a slow timer, so a later `ota config advert ` takes effect live. Also advertise immediately whenever the served set changes — when a motatool folder is attached to / detached from the ESP32 WiFi seeder — so peers learn about newly-available firmware without waiting for the next interval (the `ota folder` serial path already announced on attach). Docs: protocol beacon-cadence note + user-guide `ota config advert`. (cherry picked from commit 9ff1c883c21a94b337368a94ce046787ac5ee40d) --- docs/ota_protocol.md | 5 +++- docs/ota_user_guide.md | 3 +++ examples/companion_radio/main.cpp | 2 ++ src/Mesh.cpp | 38 ++++++++++++++++--------------- src/helpers/CommonCLI.cpp | 10 ++++++-- src/helpers/CommonCLI.h | 1 + src/helpers/ota/OtaCli.cpp | 9 ++++++-- src/helpers/ota/OtaManager.h | 10 ++++++++ 8 files changed, 55 insertions(+), 23 deletions(-) diff --git a/docs/ota_protocol.md b/docs/ota_protocol.md index d4f867c886..cc5583feb9 100644 --- a/docs/ota_protocol.md +++ b/docs/ota_protocol.md @@ -310,7 +310,10 @@ Message types: 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: -**Tier 1 — `OTA_ADV` beacon** (10 bytes, constant, flooded periodically): +**Tier 1 — `OTA_ADV` beacon** (10 bytes, constant). Flooded as a short burst at boot, then every +`advert_mins` minutes (default 24h; runtime-tunable via `ota config advert`, `0` disables the periodic +re-advertise). It is also emitted immediately whenever the served set changes (e.g. a `motatool` folder is +attached/detached), so peers learn about newly-available firmware without waiting for the next interval: ``` seeder_id[4] advertiser node id = pubkey[0:4]; the QUERY address + distinct-source id diff --git a/docs/ota_user_guide.md b/docs/ota_user_guide.md index bc0ba838ca..7d4434ee66 100644 --- a/docs/ota_user_guide.md +++ b/docs/ota_user_guide.md @@ -129,6 +129,9 @@ ota config autofetch off # back to manual (default) ota config autoinstall trusted # auto-INSTALL a downloaded update IF it's signed by a key you trust ota config autoinstall off # never auto-install (default) +ota config advert 1440 # re-advertise this node every N minutes (default 1440 = 24h) +ota config advert 0 # disable periodic re-advertise (still advertises briefly at boot) + ota config # show the current settings ``` diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 9a96b2100c..6844724b6d 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -130,6 +130,7 @@ void halt() { if (ota_seeder_client && ota_seeder_client.connected()) return; // still serving the current client if (ota_seeder_attached) { // previous client just disconnected mesh::ota::ota_ctx().detach_folder(); + mesh::ota::ota_ctx().manager.announce(); // served set shrank back to our own fw -> re-advertise ota_seeder_attached = false; WIFI_DEBUG_PRINTLN("OTA seeder: client disconnected, relay stopped"); } @@ -138,6 +139,7 @@ void halt() { ota_seeder_client = c; // rebind the persistent Stream to it if (mesh::ota::ota_ctx().manager.add_source(&ota_seeder_source)) { ota_seeder_attached = true; + mesh::ota::ota_ctx().manager.announce(); // new served set -> advertise the folder's fw to peers WIFI_DEBUG_PRINTLN("OTA seeder: client connected, relaying its folder"); } else { ota_seeder_client.stop(); // no free source slot diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 1cfbd55a5f..1e00ccc51c 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -13,14 +13,12 @@ #ifndef OTA_ANNOUNCE_BURST_MS #define OTA_ANNOUNCE_BURST_MS 20000UL // spacing during the boot burst (~1 min total), then ... #endif -// ... then re-announce at a RANDOM interval in [MIN, MAX] so a long-running node stays discoverable -// (a fresh `ota ls` neighbour sees it within minutes, not at boot only) without all nodes beaconing in -// lockstep. The beacon is tiny + lowest-priority + duty-gated, so a few-minute cadence is cheap. -#ifndef OTA_ANNOUNCE_INTERVAL_MIN_MS -#define OTA_ANNOUNCE_INTERVAL_MIN_MS 180000UL // 3 min -#endif -#ifndef OTA_ANNOUNCE_INTERVAL_MAX_MS -#define OTA_ANNOUNCE_INTERVAL_MAX_MS 600000UL // 10 min +// ... then re-announce at a FIXED cadence so a long-running node stays discoverable (a fresh `ota ls` +// neighbour eventually sees it, not at boot only). The cadence is OtaManager::advert_mins() minutes (default +// 24h, runtime-tunable via `ota config advert` + persisted; 0 = disabled = boot burst only). The beacon is +// tiny + lowest-priority + duty-gated, so even a frequent cadence is cheap. +#ifndef OTA_ANNOUNCE_DISABLED_POLL_MS +#define OTA_ANNOUNCE_DISABLED_POLL_MS 600000UL // when periodic advert is off, re-check config every 10 min #endif #endif @@ -90,17 +88,21 @@ void Mesh::loop() { } 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 a random - // few-minute cadence so a node stays discoverable long after boot (not just at boot). - uint32_t gap = (_ota_announce_count < OTA_ANNOUNCE_BURST) - ? OTA_ANNOUNCE_BURST_MS - : _rng->nextInt(OTA_ANNOUNCE_INTERVAL_MIN_MS, OTA_ANNOUNCE_INTERVAL_MAX_MS); + bool in_burst = _ota_announce_count < OTA_ANNOUNCE_BURST; + uint32_t mins = oc.manager.advert_mins(); // periodic cadence in minutes; 0 = disabled (boot burst only) + if (in_burst || mins != 0) { + // 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(); + if (_ota_announce_count < 250) _ota_announce_count++; + } + // Re-arm: tight spacing during the boot burst; afterwards the fixed cadence (default 24h). When periodic + // advert is disabled (0), re-check on a slow timer so a later `ota config advert ` takes effect live. + uint32_t gap = in_burst ? OTA_ANNOUNCE_BURST_MS + : (mins != 0) ? mins * 60000UL + : OTA_ANNOUNCE_DISABLED_POLL_MS; _next_ota_announce = futureMillis(gap); - 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(); diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 1555b98f89..7dd6c2a91a 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -52,6 +52,7 @@ 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.manager.set_advert_mins(_prefs->ota_advert_interval); c.autoinstall = _prefs->ota_autoinstall; c.allow.clear(); for (uint8_t i = 0; i < _prefs->ota_signer_count && i < MAX_OTA_SIGNERS; i++) @@ -117,12 +118,14 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { // 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 = 4; // = OTA_CHECKPOINT_BLOCKS; older prefs lack it -> stays at default + _prefs->ota_advert_interval = 1440; // = OTA_ADVERT_INTERVAL_MINS (24h); older prefs lack it -> stays default 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 + file.read((uint8_t *)&_prefs->ota_advert_interval, sizeof(_prefs->ota_advert_interval)); // 426 + // next: 428 // sanitise bad pref values _prefs->rx_delay_base = constrain(_prefs->rx_delay_base, 0, 20.0f); @@ -155,6 +158,7 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { _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 = 4; // 0=never; cap absurd + if (_prefs->ota_advert_interval > 10080) _prefs->ota_advert_interval = 1440; // 0=off; cap at 7 days if (_prefs->ota_signer_count > 4) _prefs->ota_signer_count = 0; // corrupt count -> drop keys file.close(); @@ -224,7 +228,8 @@ void CommonCLI::savePrefs(FILESYSTEM* fs) { 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: 426 + file.write((uint8_t *)&_prefs->ota_advert_interval, sizeof(_prefs->ota_advert_interval)); // 426 + // next: 428 file.close(); } @@ -353,6 +358,7 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re mesh::ota::OtaContext& c = mesh::ota::ota_ctx(); _prefs->ota_autofetch = c.manager.autofetch(); _prefs->ota_checkpoint_blocks = c.manager.checkpoint_blocks(); + _prefs->ota_advert_interval = c.manager.advert_mins(); _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++) diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index c4ff8f3ee9..939be52bf8 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -69,6 +69,7 @@ struct NodePrefs { // persisted to file 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 4 (runtime-tunable) + uint16_t ota_advert_interval; // OTA beacon re-advertise cadence (mins); 0=off. Default 1440 (24h, tunable) }; class CommonCLICallbacks { diff --git a/src/helpers/ota/OtaCli.cpp b/src/helpers/ota/OtaCli.cpp index e35955b550..465f42ba1f 100644 --- a/src/helpers/ota/OtaCli.cpp +++ b/src/helpers/ota/OtaCli.cpp @@ -272,12 +272,17 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board 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 if (strncmp(p, "advert ", 7) == 0) { // beacon re-advertise cadence (minutes; 0=disable) + long m = atol(p + 7); + if (m < 0 || m > 10080) { strcpy(reply, "ERR usage: ota config advert <0..10080> (minutes; 0=disable)"); return true; } + c.manager.set_advert_mins((uint16_t)m); c.config_dirty = true; + sprintf(reply, "OK re-advertise every %ld min (saved)%s", m, m == 0 ? " — periodic advert disabled" : ""); } else { // show current policy uint8_t af = c.manager.autofetch(); - sprintf(reply, "ota config: autofetch=%s autoinstall=%s checkpoint=%u keys=%u (persisted)", + sprintf(reply, "ota config: autofetch=%s autoinstall=%s checkpoint=%u advert=%umin 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()); + (unsigned)c.manager.checkpoint_blocks(), (unsigned)c.manager.advert_mins(), (unsigned)c.allow.count()); } // ---- trusted signer allowlist (security config; persisted): `ota key add|rm ` / `ota key` lists ---- diff --git a/src/helpers/ota/OtaManager.h b/src/helpers/ota/OtaManager.h index 3428b453d8..79f22ebb47 100644 --- a/src/helpers/ota/OtaManager.h +++ b/src/helpers/ota/OtaManager.h @@ -37,6 +37,9 @@ typedef bool (*ServeReadFn)(void* ctx, uint32_t off, uint8_t* buf, uint32_t len) #ifndef OTA_CHECKPOINT_BLOCKS #define OTA_CHECKPOINT_BLOCKS 4 // persist progress (store.checkpoint) every N committed blocks (resume) #endif +#ifndef OTA_ADVERT_INTERVAL_MINS +#define OTA_ADVERT_INTERVAL_MINS 1440 // re-advertise our served set every N minutes after the boot burst (24h) +#endif #ifndef OTA_MF_FRAG #define OTA_MF_FRAG 176 // manifest bytes per OTA_MANIFEST fragment (<= MAX_PACKET_PAYLOAD - header) #endif @@ -204,6 +207,12 @@ class OtaManager { // 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; } + + // Beacon re-advertise cadence in MINUTES (runtime-tunable, persisted in NodePrefs): after the boot burst, + // re-send the discovery beacon every N minutes so a long-running node stays discoverable. 0 = disabled + // (boot burst only). Default OTA_ADVERT_INTERVAL_MINS (24h). + void set_advert_mins(uint16_t m) { _advert_mins = m; } + uint16_t advert_mins() const { return _advert_mins; } 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. @@ -325,6 +334,7 @@ class OtaManager { 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) + uint16_t _advert_mins = OTA_ADVERT_INTERVAL_MINS; // beacon re-advertise cadence, minutes; 0=off (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 = NO_BLOCK; // block being reassembled / awaiting proof (none) From 6fbcda6e3cacc23b7f73a8c0ba5b32be34e0157a Mon Sep 17 00:00:00 2001 From: Valentin Kivachuk Burda Date: Tue, 30 Jun 2026 20:35:48 +0200 Subject: [PATCH 15/59] ota: configurable hop limit (`ota config hops`) + accept-gate + forward RAM guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bound OTA-over-LoRa duty cycle across repeaters with one runtime-tunable, persisted limit (OtaManager::max_hops, `ota config hops <0..8>`, default 3): - Accept-gate: a node ignores OTA that arrived from more than max_hops hops away (neither processes nor relays it). 0 = direct only. - Forward-cap: relay a flood only while still under max_hops, appending this node's path-hash (hop count increments like the mesh flood routing). - RAM guard: relay an OTA flood only while more than OTA_FWD_MIN_FREE packet- pool slots stay free, so heavy OTA (best-effort, lowest-priority) can never monopolise the shared pool and starve real traffic — a dropped relay is re-requested by the source. Persisted in NodePrefs (CommonCLI) and shown in `ota config`. Docs updated. (cherry picked from commit 259802b70645a66e512f9a454244f5b1b43b71d6) --- docs/ota_protocol.md | 5 +++++ docs/ota_user_guide.md | 3 +++ src/Mesh.cpp | 22 ++++++++++++++++------ src/Mesh.h | 13 ++++++++----- src/helpers/CommonCLI.cpp | 10 ++++++++-- src/helpers/CommonCLI.h | 1 + src/helpers/ota/OtaCli.cpp | 10 ++++++++-- src/helpers/ota/OtaManager.h | 10 ++++++++++ 8 files changed, 59 insertions(+), 15 deletions(-) diff --git a/docs/ota_protocol.md b/docs/ota_protocol.md index cc5583feb9..4e7f5a28ea 100644 --- a/docs/ota_protocol.md +++ b/docs/ota_protocol.md @@ -304,6 +304,11 @@ Message types: - **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. +- **Hop limit + duty cycle:** OTA floods accumulate one path-hash per relay (the mesh's flood routing). A + node *accepts* a packet only if it arrived within `ota config hops` hops (default 3; `0` = direct only) + and *relays* it only while still under that limit, appending its own hash. Relays are lowest-priority and + are skipped when the packet pool runs low (the source retries), so heavy OTA can never monopolise a + repeater's RAM or starve real traffic. ### 8.1 Two-tier discovery diff --git a/docs/ota_user_guide.md b/docs/ota_user_guide.md index 7d4434ee66..0235be232f 100644 --- a/docs/ota_user_guide.md +++ b/docs/ota_user_guide.md @@ -132,6 +132,9 @@ ota config autoinstall off # never auto-install (default) ota config advert 1440 # re-advertise this node every N minutes (default 1440 = 24h) ota config advert 0 # disable periodic re-advertise (still advertises briefly at boot) +ota config hops 3 # how far OTA travels: accept from / relay up to N repeater hops (default 3) +ota config hops 0 # only exchange OTA with directly-connected nodes (never relay) + ota config # show the current settings ``` diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 1e00ccc51c..35c81e6df0 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -31,6 +31,10 @@ void Mesh::otaSendAdapter(void* ctx, const uint8_t* msg, uint16_t len, bool /*fl Packet* p = m->createOtaPacket(msg, len); if (p) m->sendOtaFlood(p); } + +// Runtime OTA flood reach (`ota config hops`, persisted in NodePrefs): accept packets up to N hops away and +// relay those still under N hops. 0 = direct only. Overridable per-role by subclassing. +uint8_t Mesh::getOtaHopLimit() const { return ota::ota_ctx().manager.max_hops(); } #endif void Mesh::begin() { @@ -419,21 +423,27 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { #if defined(ENABLE_OTA) case PAYLOAD_TYPE_OTA: { - // ALWAYS process every received copy: OTA handlers are idempotent, and "eventually reliable" - // retries deliberately re-send IDENTICAL requests — if we gated processing on hasSeen(), the - // 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. + uint8_t n = pkt->getPathHashCount(); // hops travelled to reach us (flood path-hash count) + // Accept-gate (duty-cycle horizon): ignore OTA from further than our hop limit — neither process nor + // relay it. 0 = only directly-received OTA. Runtime-tunable via `ota config hops`. + if (n > getOtaHopLimit()) break; + // ALWAYS process every accepted copy: OTA handlers are idempotent, and "eventually reliable" retries + // deliberately re-send IDENTICAL requests — if we gated processing on hasSeen(), the 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.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(); + // Re-flood at the LOWEST priority and only while still under the hop limit, so OTA never competes with + // mesh traffic. The free-pool guard keeps heavy OTA from monopolising the shared packet pool — dropping + // a relay is safe (OTA is best-effort; the source retries). if (!seen && pkt->isRouteFlood() && !pkt->isMarkedDoNotRetransmit() && n < getOtaHopLimit() && (n + 1) * pkt->getPathHashSize() <= MAX_PATH_SIZE + && _mgr->getFreeCount() > OTA_FWD_MIN_FREE && allowPacketForward(pkt)) { self_id.copyHashTo(&pkt->path[n * pkt->getPathHashSize()], pkt->getPathHashSize()); pkt->setPathHashCount(n + 1); diff --git a/src/Mesh.h b/src/Mesh.h index e550e2c71f..a6aece3aa5 100644 --- a/src/Mesh.h +++ b/src/Mesh.h @@ -3,12 +3,15 @@ #include #if defined(ENABLE_OTA) - // OTA-over-LoRa: lowest TX priority (selected only after all real traffic) + default hop cap. + // OTA-over-LoRa: lowest TX priority (selected only after all real traffic). The hop limit is runtime- + // tunable (OtaManager::max_hops(), `ota config hops`); the default lives in OtaManager.h. #ifndef OTA_TX_PRIORITY #define OTA_TX_PRIORITY 250 #endif - #ifndef OTA_HOP_LIMIT_DEFAULT - #define OTA_HOP_LIMIT_DEFAULT 3 + // An OTA flood is relayed only while this many pool slots stay free, so heavy OTA (best-effort, low- + // priority) can never monopolise the shared packet pool and starve real traffic. + #ifndef OTA_FWD_MIN_FREE + #define OTA_FWD_MIN_FREE 4 #endif #endif @@ -161,8 +164,8 @@ class Mesh : public Dispatcher { */ virtual void onOtaRecv(Packet* packet) { } - /** \returns the max hop count for forwarding OTA flood packets (default 3). */ - virtual uint8_t getOtaHopLimit() const { return OTA_HOP_LIMIT_DEFAULT; } + /** \returns max OTA flood reach in hops — accept up to N, relay while < N (0=direct). `ota config hops`. */ + virtual uint8_t getOtaHopLimit() const; // OTA mesh-integration is centralized in Mesh::begin()/loop()/dispatch, so every role (repeater, // companion, room, sensor, ...) gets fetch/serve/apply without per-example wiring. diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 7dd6c2a91a..1fa8ec7290 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -53,6 +53,7 @@ void CommonCLI::syncOtaConfigFromPrefs() { c.manager.set_autofetch(_prefs->ota_autofetch); c.manager.set_checkpoint_blocks(_prefs->ota_checkpoint_blocks); c.manager.set_advert_mins(_prefs->ota_advert_interval); + c.manager.set_max_hops(_prefs->ota_max_hops); c.autoinstall = _prefs->ota_autoinstall; c.allow.clear(); for (uint8_t i = 0; i < _prefs->ota_signer_count && i < MAX_OTA_SIGNERS; i++) @@ -119,13 +120,15 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { _prefs->ota_autofetch = 0; _prefs->ota_autoinstall = 0; _prefs->ota_signer_count = 0; _prefs->ota_checkpoint_blocks = 4; // = OTA_CHECKPOINT_BLOCKS; older prefs lack it -> stays at default _prefs->ota_advert_interval = 1440; // = OTA_ADVERT_INTERVAL_MINS (24h); older prefs lack it -> stays default + _prefs->ota_max_hops = 3; // = OTA_HOP_LIMIT_DEFAULT; older prefs lack it -> stays default 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 file.read((uint8_t *)&_prefs->ota_advert_interval, sizeof(_prefs->ota_advert_interval)); // 426 - // next: 428 + file.read((uint8_t *)&_prefs->ota_max_hops, sizeof(_prefs->ota_max_hops)); // 428 + // next: 429 // sanitise bad pref values _prefs->rx_delay_base = constrain(_prefs->rx_delay_base, 0, 20.0f); @@ -159,6 +162,7 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { _prefs->ota_autoinstall = constrain(_prefs->ota_autoinstall, 0, 1); if (_prefs->ota_checkpoint_blocks > 4096) _prefs->ota_checkpoint_blocks = 4; // 0=never; cap absurd if (_prefs->ota_advert_interval > 10080) _prefs->ota_advert_interval = 1440; // 0=off; cap at 7 days + if (_prefs->ota_max_hops > 8) _prefs->ota_max_hops = 3; // 0=direct only; cap absurd reach if (_prefs->ota_signer_count > 4) _prefs->ota_signer_count = 0; // corrupt count -> drop keys file.close(); @@ -229,7 +233,8 @@ void CommonCLI::savePrefs(FILESYSTEM* fs) { 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 file.write((uint8_t *)&_prefs->ota_advert_interval, sizeof(_prefs->ota_advert_interval)); // 426 - // next: 428 + file.write((uint8_t *)&_prefs->ota_max_hops, sizeof(_prefs->ota_max_hops)); // 428 + // next: 429 file.close(); } @@ -359,6 +364,7 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re _prefs->ota_autofetch = c.manager.autofetch(); _prefs->ota_checkpoint_blocks = c.manager.checkpoint_blocks(); _prefs->ota_advert_interval = c.manager.advert_mins(); + _prefs->ota_max_hops = c.manager.max_hops(); _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++) diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 939be52bf8..c6d69652f5 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -70,6 +70,7 @@ struct NodePrefs { // persisted to file 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 4 (runtime-tunable) uint16_t ota_advert_interval; // OTA beacon re-advertise cadence (mins); 0=off. Default 1440 (24h, tunable) + uint8_t ota_max_hops; // OTA flood reach in hops; 0=direct only. Default 3 (runtime-tunable) }; class CommonCLICallbacks { diff --git a/src/helpers/ota/OtaCli.cpp b/src/helpers/ota/OtaCli.cpp index 465f42ba1f..f514dea219 100644 --- a/src/helpers/ota/OtaCli.cpp +++ b/src/helpers/ota/OtaCli.cpp @@ -277,12 +277,18 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board if (m < 0 || m > 10080) { strcpy(reply, "ERR usage: ota config advert <0..10080> (minutes; 0=disable)"); return true; } c.manager.set_advert_mins((uint16_t)m); c.config_dirty = true; sprintf(reply, "OK re-advertise every %ld min (saved)%s", m, m == 0 ? " — periodic advert disabled" : ""); + } else if (strncmp(p, "hops ", 5) == 0) { // OTA flood reach in hops (0 = direct only) + long h = atol(p + 5); + if (h < 0 || h > 8) { strcpy(reply, "ERR usage: ota config hops <0..8> (hops; 0 = direct only)"); return true; } + c.manager.set_max_hops((uint8_t)h); c.config_dirty = true; + sprintf(reply, "OK OTA reach = %ld hop%s (saved)%s", h, h == 1 ? "" : "s", h == 0 ? " — direct only" : ""); } else { // show current policy uint8_t af = c.manager.autofetch(); - sprintf(reply, "ota config: autofetch=%s autoinstall=%s checkpoint=%u advert=%umin keys=%u (persisted)", + sprintf(reply, "ota config: autofetch=%s autoinstall=%s checkpoint=%u advert=%umin hops=%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.manager.advert_mins(), (unsigned)c.allow.count()); + (unsigned)c.manager.checkpoint_blocks(), (unsigned)c.manager.advert_mins(), + (unsigned)c.manager.max_hops(), (unsigned)c.allow.count()); } // ---- trusted signer allowlist (security config; persisted): `ota key add|rm ` / `ota key` lists ---- diff --git a/src/helpers/ota/OtaManager.h b/src/helpers/ota/OtaManager.h index 79f22ebb47..a0c8cf0419 100644 --- a/src/helpers/ota/OtaManager.h +++ b/src/helpers/ota/OtaManager.h @@ -40,6 +40,9 @@ typedef bool (*ServeReadFn)(void* ctx, uint32_t off, uint8_t* buf, uint32_t len) #ifndef OTA_ADVERT_INTERVAL_MINS #define OTA_ADVERT_INTERVAL_MINS 1440 // re-advertise our served set every N minutes after the boot burst (24h) #endif +#ifndef OTA_HOP_LIMIT_DEFAULT +#define OTA_HOP_LIMIT_DEFAULT 3 // default OTA flood reach in hops: accept <= N, relay while < N (0=direct) +#endif #ifndef OTA_MF_FRAG #define OTA_MF_FRAG 176 // manifest bytes per OTA_MANIFEST fragment (<= MAX_PACKET_PAYLOAD - header) #endif @@ -213,6 +216,12 @@ class OtaManager { // (boot burst only). Default OTA_ADVERT_INTERVAL_MINS (24h). void set_advert_mins(uint16_t m) { _advert_mins = m; } uint16_t advert_mins() const { return _advert_mins; } + + // Max OTA flood reach in HOPS (runtime-tunable, persisted): a node accepts OTA from up to N hops away and + // relays packets still under N hops; 0 = direct only (accept path_count 0, never relay). Bounds duty-cycle + // when crossing repeaters. Default OTA_HOP_LIMIT_DEFAULT. Set via `ota config hops`. + void set_max_hops(uint8_t h) { _max_hops = h; } + uint8_t max_hops() const { return _max_hops; } 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. @@ -335,6 +344,7 @@ class OtaManager { uint8_t _autofetch = AUTOFETCH_OFF; // auto-fetch policy (persisted in NodePrefs) uint16_t _checkpoint_blocks = OTA_CHECKPOINT_BLOCKS; // resume checkpoint cadence (persisted) uint16_t _advert_mins = OTA_ADVERT_INTERVAL_MINS; // beacon re-advertise cadence, minutes; 0=off (persisted) + uint8_t _max_hops = OTA_HOP_LIMIT_DEFAULT; // OTA flood reach in hops; 0=direct only (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 = NO_BLOCK; // block being reassembled / awaiting proof (none) From 6675a9ebfc7ca3d8f9cd4b8de47a7c0821eafcf0 Mon Sep 17 00:00:00 2001 From: Valentin Kivachuk Burda Date: Thu, 2 Jul 2026 08:32:00 +0200 Subject: [PATCH 16/59] ota: seeder storage protocol (pull-to-folder) + motatool serve storage handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the device->host WRITE half of the mota-seeder link so a device can capture a .mota it is fetching off-mesh into the host folder (the same --dir used for serving), stored as .mota — e.g. to grab an exact copy of a device's firmware to build a delta against firmware you don't otherwise have. - MotaSeederProto: OP_STAT / OP_BEGIN / OP_WRITE / OP_SREAD / OP_FIN (keyed by mid). Resume needs no host bookkeeping: BEGIN 0xFF-fills the file; on reconnect the device SREADs the leaves and re-requests only missing blocks (same as flash resume). Partial = .mota.part, published to .mota on FIN. - motatool `serve --dir ` now also handles the storage ops on the same folder/connection (SeederCore gains the store dir; serve_loop frames the variable-length WRITE). Host round-trip test added. Firmware side (FolderMotaStore + `ota pull <#> ` + pause/resume) follows. (cherry picked from commit bd2ecb80a7157a7a760d544cf615b36babdd1d7e) --- src/helpers/ota/MotaSeederProto.h | 22 ++++++- tools/motatool/src/main.cpp | 2 +- tools/motatool/src/mota_format.h | 6 ++ tools/motatool/src/serve.cpp | 91 ++++++++++++++++++++++++-- tools/motatool/src/serve.h | 8 ++- tools/motatool/tests/test_motatool.cpp | 32 +++++++++ 6 files changed, 153 insertions(+), 8 deletions(-) diff --git a/src/helpers/ota/MotaSeederProto.h b/src/helpers/ota/MotaSeederProto.h index 51767c9cab..e534075b74 100644 --- a/src/helpers/ota/MotaSeederProto.h +++ b/src/helpers/ota/MotaSeederProto.h @@ -21,8 +21,19 @@ // 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). +// --- STORAGE ops (device -> host WRITE): "pull to folder". The device is fetching a `.mota` off the mesh +// (e.g. a neighbour's self-served firmware it has no local copy of) and streams it into the host folder as +// `.mota`, so the exact image can be captured for later delta-building. The device still initiates. +// Resume needs NO host bookkeeping: OP_BEGIN 0xFF-fills the file and, on reconnect, the device OP_SREADs the +// merkle leaves and re-requests only the missing blocks (identical to flash-resume). All keyed by mid[4]. +// OP_STAT 0x04 args: mid(4) resp payload: present(1) total_size(4) +// OP_BEGIN 0x05 args: mid(4) total_size(4) resp: status OK (create/truncate, 0xFF-filled) +// OP_WRITE 0x06 args: mid(4) off(4) len(2) data(len) resp: status OK +// OP_SREAD 0x07 args: mid(4) off(4) len(2) resp payload: len bytes (0xFF = never written) +// OP_FIN 0x08 args: mid(4) resp: status OK (validate + make servable) +// +// status: 0 = OK, non-zero = error (idx out of range, read past EOF, no such stored file, ...). On error the +// response carries no payload (just magic+op+status+xsum). namespace mesh { namespace ota { @@ -35,10 +46,17 @@ 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_OP_STAT = 0x04; // storage: does .mota exist on the host? + its size +static const uint8_t MS_OP_BEGIN = 0x05; // storage: create/truncate .mota (0xFF-filled) +static const uint8_t MS_OP_WRITE = 0x06; // storage: write bytes at offset +static const uint8_t MS_OP_SREAD = 0x07; // storage: read bytes back (resume: recompute missing blocks) +static const uint8_t MS_OP_FIN = 0x08; // storage: transfer complete — validate + make servable static const uint8_t MS_STATUS_OK = 0x00; +static const uint8_t MS_STATUS_ERR = 0x01; static const uint16_t MOTA_DESC_WIRE = 38; // bytes of a MotaDesc on the wire (see layout above) +static const uint16_t MOTA_SEEDER_WRITE_MAX = 512; // max data bytes per OP_WRITE/OP_SREAD (bounds frames) } // namespace ota } // namespace mesh diff --git a/tools/motatool/src/main.cpp b/tools/motatool/src/main.cpp index 8596b9f3ca..c367c48712 100644 --- a/tools/motatool/src/main.cpp +++ b/tools/motatool/src/main.cpp @@ -499,7 +499,7 @@ static int cmd_serve(const Args& a) { if (enable) { usleep(500000); t->write_str("ota folder on\r\n"); std::cout << "sent `ota folder on`\n"; } std::cout << "serving on " << target << " — Ctrl-C to stop\n"; - SeederCore core(folder); + SeederCore core(folder, a.get("dir")); // same folder doubles as "pull to folder" storage serve_loop(*t, core, verbose, [](const std::string& l) { std::cout << " [dev] " << l << "\n"; }, &g_stop); diff --git a/tools/motatool/src/mota_format.h b/tools/motatool/src/mota_format.h index d26ac64e9e..8a46c10f39 100644 --- a/tools/motatool/src/mota_format.h +++ b/tools/motatool/src/mota_format.h @@ -84,9 +84,15 @@ static constexpr uint8_t MS_RSP_MAGIC0 = 'm', MS_RSP_MAGIC1 = 's'; static constexpr uint8_t MS_OP_COUNT = 0x01; // -> count(1) static constexpr uint8_t MS_OP_DESCRIBE = 0x02; // idx(1) -> MotaDesc(38) static constexpr uint8_t MS_OP_READ = 0x03; // idx(1) off(4) len(2) -> bytes +static constexpr uint8_t MS_OP_STAT = 0x04; // storage: mid(4) -> present(1) total(4) +static constexpr uint8_t MS_OP_BEGIN = 0x05; // storage: mid(4) total(4) -> OK (create 0xFF-filled) +static constexpr uint8_t MS_OP_WRITE = 0x06; // storage: mid(4) off(4) len(2) data(len) -> OK +static constexpr uint8_t MS_OP_SREAD = 0x07; // storage: mid(4) off(4) len(2) -> bytes (0xFF=unwritten) +static constexpr uint8_t MS_OP_FIN = 0x08; // storage: mid(4) -> OK (validate + make servable) static constexpr uint8_t MS_STATUS_OK = 0x00; static constexpr uint8_t MS_STATUS_ERR = 0x01; static constexpr uint16_t MOTA_DESC_WIRE = 38; +static constexpr uint16_t MOTA_SEEDER_WRITE_MAX = 512; // max data bytes per OP_WRITE/OP_SREAD // 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) [+2 reserved] diff --git a/tools/motatool/src/serve.cpp b/tools/motatool/src/serve.cpp index ef89ed9f4c..0331e4c6b7 100644 --- a/tools/motatool/src/serve.cpp +++ b/tools/motatool/src/serve.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -64,6 +65,14 @@ std::array SeederCore::describe(const ServedMota& s) { return w; } +std::string SeederCore::store_path(const uint8_t mid[4], bool part) const { + static const char* H = "0123456789abcdef"; + std::string name; + for (int i = 0; i < 4; i++) { name += H[mid[i] >> 4]; name += H[mid[i] & 0xF]; } + name += part ? ".mota.part" : ".mota"; + return store_dir_ + "/" + name; +} + bool SeederCore::handle(uint8_t op, const uint8_t* args, size_t arglen, uint8_t& status, std::vector& payload) const { payload.clear(); @@ -89,6 +98,70 @@ bool SeederCore::handle(uint8_t op, const uint8_t* args, size_t arglen, payload.assign(s->bytes.begin() + off, s->bytes.begin() + off + len); return true; } + // --- STORAGE ops: "pull to folder" — capture a .mota the device is fetching off-mesh into . + // All keyed by mid[4]; a partial pull is .mota.part, published to .mota on OP_FIN. --- + if (op == MS_OP_STAT || op == MS_OP_BEGIN || op == MS_OP_WRITE || op == MS_OP_SREAD || op == MS_OP_FIN) { + if (store_dir_.empty() || arglen < 4) { status = MS_STATUS_ERR; return true; } + const std::string part = store_path(args, true), done = store_path(args, false); + + if (op == MS_OP_STAT) { // present + size (completed wins over partial) + struct stat sx{}; uint8_t present = 0; uint32_t total = 0; + if (::stat(done.c_str(), &sx) == 0) { present = 1; total = (uint32_t)sx.st_size; } + else if (::stat(part.c_str(), &sx) == 0) { present = 1; total = (uint32_t)sx.st_size; } + payload.push_back(present); + uint8_t tb[4]; wr_u32(tb, total); payload.insert(payload.end(), tb, tb + 4); + return true; + } + if (op == MS_OP_BEGIN) { // fresh 0xFF-filled partial (start from 0) + if (arglen < 8) { status = MS_STATUS_ERR; return true; } + uint32_t total = rd_u32(args + 4); + int fd = ::open(part.c_str(), O_WRONLY | O_CREAT | O_TRUNC, 0644); + if (fd < 0) { status = MS_STATUS_ERR; return true; } + uint8_t ff[4096]; memset(ff, 0xFF, sizeof ff); + uint32_t left = total; bool ok = true; + while (left && ok) { uint32_t c = left < sizeof ff ? left : (uint32_t)sizeof ff; ok = ::write(fd, ff, c) == (ssize_t)c; left -= c; } + ::close(fd); + status = ok ? MS_STATUS_OK : MS_STATUS_ERR; + return true; + } + if (op == MS_OP_WRITE) { + if (arglen < 10) { status = MS_STATUS_ERR; return true; } + uint32_t off = rd_u32(args + 4); + uint16_t len = (uint16_t)(args[8] | (args[9] << 8)); + if (arglen < (size_t)10 + len) { status = MS_STATUS_ERR; return true; } + int fd = ::open(part.c_str(), O_WRONLY); + if (fd < 0) { status = MS_STATUS_ERR; return true; } + bool ok = ::pwrite(fd, args + 10, len, off) == (ssize_t)len; + ::close(fd); + status = ok ? MS_STATUS_OK : MS_STATUS_ERR; + return true; + } + if (op == MS_OP_SREAD) { // read back (resume: recompute missing blocks) + if (arglen < 10) { status = MS_STATUS_ERR; return true; } + uint32_t off = rd_u32(args + 4); + uint16_t len = (uint16_t)(args[8] | (args[9] << 8)); + const std::string src = (::access(part.c_str(), F_OK) == 0) ? part : done; + int fd = ::open(src.c_str(), O_RDONLY); + if (fd < 0) { status = MS_STATUS_ERR; return true; } + payload.resize(len); + bool ok = ::pread(fd, payload.data(), len, off) == (ssize_t)len; + ::close(fd); + if (!ok) { payload.clear(); status = MS_STATUS_ERR; } + return true; + } + if (op == MS_OP_FIN) { // light-validate (MAGIC + size) then publish + struct stat sx{}; + if (::stat(part.c_str(), &sx) != 0) { status = MS_STATUS_ERR; return true; } + uint8_t hd[8] = {0}; int fd = ::open(part.c_str(), O_RDONLY); + bool got = fd >= 0 && ::read(fd, hd, 8) == 8; + if (fd >= 0) ::close(fd); + bool magic = got && hd[0] == 'm' && hd[1] == 'O' && hd[2] == 'T' && hd[3] == 'A'; + if (!magic || rd_u32(hd + 4) != (uint32_t)sx.st_size) { status = MS_STATUS_ERR; return true; } + std::error_code ec; fs::rename(part, done, ec); + status = ec ? MS_STATUS_ERR : MS_STATUS_OK; + return true; + } + } return false; // unknown op -> ignore (device retries) } @@ -222,10 +295,20 @@ void serve_loop(Transport& t, const SeederCore& core, bool verbose, prev = -1; uint8_t op; if (!read_exact(t, &op, 1)) continue; - size_t arglen = (op == MS_OP_DESCRIBE) ? 1 : (op == MS_OP_READ ? 7 : (op == MS_OP_COUNT ? 0 : SIZE_MAX)); - if (arglen == SIZE_MAX) continue; // unknown op - uint8_t args[7]; - if (arglen && !read_exact(t, args, arglen)) continue; + // fixed header bytes per op; OP_WRITE additionally reads `len` data bytes after its 10-byte header + size_t hdr = (op == MS_OP_COUNT) ? 0 : (op == MS_OP_DESCRIBE) ? 1 : (op == MS_OP_READ) ? 7 + : (op == MS_OP_STAT || op == MS_OP_FIN) ? 4 : (op == MS_OP_BEGIN) ? 8 + : (op == MS_OP_SREAD || op == MS_OP_WRITE) ? 10 : SIZE_MAX; + if (hdr == SIZE_MAX) continue; // unknown op + uint8_t args[10 + MOTA_SEEDER_WRITE_MAX]; + if (hdr && !read_exact(t, args, hdr)) continue; + size_t arglen = hdr; + if (op == MS_OP_WRITE) { // variable payload: header(10) + data(len) + uint16_t dlen = (uint16_t)(args[8] | (args[9] << 8)); + if (dlen > MOTA_SEEDER_WRITE_MAX) continue; // guard a runaway frame + if (dlen && !read_exact(t, args + 10, dlen)) continue; + arglen = (size_t)10 + dlen; + } uint8_t xs; if (!read_exact(t, &xs, 1)) continue; if (xs != xor_bytes(args, arglen, op)) continue; // bad checksum -> ignore; device retries diff --git a/tools/motatool/src/serve.h b/tools/motatool/src/serve.h index 5177172460..ac10fb5e74 100644 --- a/tools/motatool/src/serve.h +++ b/tools/motatool/src/serve.h @@ -33,12 +33,18 @@ class Folder { // would call this directly from a characteristic-write handler and notify the reply — no framing needed. class SeederCore { public: - explicit SeederCore(const Folder& f) : folder_(f) {} + // `store_dir` (optional): folder where the "pull to folder" storage ops capture a `.mota` a device is + // fetching off-mesh — the SAME --dir as serving. A partial pull is `.mota.part`; OP_FIN publishes + // it as `.mota`. Empty store_dir = storage ops are refused (serve-only). + explicit SeederCore(const Folder& f, std::string store_dir = "") + : folder_(f), store_dir_(std::move(store_dir)) {} bool handle(uint8_t op, const uint8_t* args, size_t arglen, uint8_t& status, std::vector& payload) const; static std::array describe(const ServedMota& s); private: + std::string store_path(const uint8_t mid[4], bool part) const; // /.mota[.part] const Folder& folder_; + std::string store_dir_; }; // A bidirectional byte link (the serial seeder needs this; BLE would reuse SeederCore directly). diff --git a/tools/motatool/tests/test_motatool.cpp b/tools/motatool/tests/test_motatool.cpp index 87b7bd9701..371a5f632d 100644 --- a/tools/motatool/tests/test_motatool.cpp +++ b/tools/motatool/tests/test_motatool.cpp @@ -282,6 +282,38 @@ static void t_folder_and_seeder() { uint8_t reof[7]; reof[0] = 0; wr_u32(reof + 1, (uint32_t)s0->bytes.size()); reof[5] = 16; reof[6] = 0; CHECK(core.handle(MS_OP_READ, reof, 7, status, pl) && status == MS_STATUS_ERR); + // --- STORAGE ops ("pull to folder"): STAT -> BEGIN -> WRITE -> SREAD -> FIN round-trip --- + fs::path sdir = dir / "store"; fs::create_directories(sdir); + SeederCore store(folder, sdir.string()); + uint8_t mid[4] = {0xDE, 0xAD, 0xBE, 0xEF}; + auto midpath = [&](bool part) { + static const char* H = "0123456789abcdef"; std::string nm; + for (int i = 0; i < 4; i++) { nm += H[mid[i] >> 4]; nm += H[mid[i] & 0xF]; } + nm += part ? ".mota.part" : ".mota"; return (sdir / nm).string(); + }; + // STAT before anything -> present=0 + CHECK(store.handle(MS_OP_STAT, mid, 4, status, pl) && status == MS_STATUS_OK && pl.size() == 5 && pl[0] == 0); + // BEGIN total=64 -> 0xFF-filled partial + uint8_t beg[8]; std::memcpy(beg, mid, 4); wr_u32(beg + 4, 64); + CHECK(store.handle(MS_OP_BEGIN, beg, 8, status, pl) && status == MS_STATUS_OK); + CHECK(store.handle(MS_OP_STAT, mid, 4, status, pl) && pl[0] == 1 && rd_u32(pl.data() + 1) == 64); + // WRITE the header (MAGIC + total) + a blob at offset 0 + const uint8_t blob[16] = {'m','O','T','A', 64,0,0,0, 1,2,3,4,5,6,7,8}; + uint8_t wr[10 + 16]; std::memcpy(wr, mid, 4); wr_u32(wr + 4, 0); wr[8] = 16; wr[9] = 0; std::memcpy(wr + 10, blob, 16); + CHECK(store.handle(MS_OP_WRITE, wr, 10 + 16, status, pl) && status == MS_STATUS_OK); + // SREAD back the 16 written bytes; an unwritten region reads 0xFF + uint8_t srd[10]; std::memcpy(srd, mid, 4); wr_u32(srd + 4, 0); srd[8] = 16; srd[9] = 0; + CHECK(store.handle(MS_OP_SREAD, srd, 10, status, pl) && pl.size() == 16 && std::memcmp(pl.data(), blob, 16) == 0); + wr_u32(srd + 4, 32); srd[8] = 8; srd[9] = 0; + CHECK(store.handle(MS_OP_SREAD, srd, 10, status, pl) && pl.size() == 8 && pl[0] == 0xFF && pl[7] == 0xFF); + // FIN -> validates + publishes .mota + CHECK(fs::exists(midpath(true)) && !fs::exists(midpath(false))); + CHECK(store.handle(MS_OP_FIN, mid, 4, status, pl) && status == MS_STATUS_OK); + CHECK(!fs::exists(midpath(true)) && fs::exists(midpath(false)) && fs::file_size(midpath(false)) == 64); + CHECK(store.handle(MS_OP_STAT, mid, 4, status, pl) && pl[0] == 1 && rd_u32(pl.data() + 1) == 64); + // storage refused on a serve-only core (empty store_dir) + CHECK(core.handle(MS_OP_STAT, mid, 4, status, pl) && status == MS_STATUS_ERR); + fs::remove_all(dir); } From 9497bc30974688758e0d1a99f2bcb528c3aa51d5 Mon Sep 17 00:00:00 2001 From: Valentin Kivachuk Burda Date: Thu, 2 Jul 2026 08:44:10 +0200 Subject: [PATCH 17/59] =?UTF-8?q?ota:=20pull=20a=20fetched=20.mota=20to=20?= =?UTF-8?q?a=20host=20folder=20(`ota=20pull=20<#>=20folder`)=20=E2=80=94?= =?UTF-8?q?=20firmware=20side?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FolderMotaStore: an OtaStore that streams an in-transit .mota straight to the host folder over the seeder link (OP_BEGIN/WRITE/SREAD/STAT/FIN) instead of RAM/flash — the device holds no local staging for it. Wired as a selectable pull destination: - OtaContext gains a folder_dest (registered by the app while a motatool `serve` link is up) + its human id (e.g. "tcp 192.168.4.5"). - `ota pull <#> ` now takes a MANDATORY destination; `ota pull <#>` with none lists the choices (flash always; folder + its link id when connected). - The ESP32 WiFi seeder registers/clears the folder destination on connect/close — the same connection both serves the folder and accepts pulls into it. Captures an exact copy of a device's firmware over the mesh to build a delta against. Pause/resume on a mid-pull disconnect follows. (cherry picked from commit 10fa434412bb75bc5e100c84b767f45929f21349) --- examples/companion_radio/main.cpp | 14 +++- src/helpers/ota/FolderMotaStore.cpp | 113 ++++++++++++++++++++++++++++ src/helpers/ota/FolderMotaStore.h | 57 ++++++++++++++ src/helpers/ota/OtaCli.cpp | 49 +++++++++--- src/helpers/ota/OtaContext.h | 15 ++++ 5 files changed, 234 insertions(+), 14 deletions(-) create mode 100644 src/helpers/ota/FolderMotaStore.cpp create mode 100644 src/helpers/ota/FolderMotaStore.h diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 6844724b6d..3744a439cf 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -116,20 +116,24 @@ void halt() { #if defined(ESP32) && defined(WIFI_SSID) && defined(ENABLE_OTA) #include #include + #include // `ota pull <#> folder` destination over this same connection #ifndef OTA_SEEDER_TCP_PORT #define OTA_SEEDER_TCP_PORT 5001 #endif static WiFiServer ota_seeder_server(OTA_SEEDER_TCP_PORT); static WiFiClient ota_seeder_client; // the live seeder connection (reused) - static mesh::ota::SerialMotaSource ota_seeder_source(ota_seeder_client, 3000); + static mesh::ota::SerialMotaSource ota_seeder_source(ota_seeder_client, 3000); // SERVE: read folder -> relay + static mesh::ota::FolderMotaStore ota_folder_store(ota_seeder_client, 3000); // PULL: capture .mota -> folder static bool ota_seeder_attached = false; - // Accept one motatool connection at a time; while connected, register its folder as a serve source so - // the node advertises + relays it over LoRa. Drop the source the moment the connection closes. + // Accept one motatool connection at a time. While connected, the same link both SERVES the host folder + // over LoRa (register it as a source) and is offered as a `folder` PULL destination (`ota pull <#> folder` + // captures a fetched .mota back to that folder). Drop both the moment the connection closes. static void ota_seeder_loop() { if (ota_seeder_client && ota_seeder_client.connected()) return; // still serving the current client if (ota_seeder_attached) { // previous client just disconnected mesh::ota::ota_ctx().detach_folder(); + mesh::ota::ota_ctx().clear_folder_dest(); // the `folder` pull destination is gone too mesh::ota::ota_ctx().manager.announce(); // served set shrank back to our own fw -> re-advertise ota_seeder_attached = false; WIFI_DEBUG_PRINTLN("OTA seeder: client disconnected, relay stopped"); @@ -139,8 +143,10 @@ void halt() { ota_seeder_client = c; // rebind the persistent Stream to it if (mesh::ota::ota_ctx().manager.add_source(&ota_seeder_source)) { ota_seeder_attached = true; + char di[24]; snprintf(di, sizeof di, "tcp %s", ota_seeder_client.remoteIP().toString().c_str()); + mesh::ota::ota_ctx().set_folder_dest(&ota_folder_store, di); // offer `ota pull <#> folder` mesh::ota::ota_ctx().manager.announce(); // new served set -> advertise the folder's fw to peers - WIFI_DEBUG_PRINTLN("OTA seeder: client connected, relaying its folder"); + WIFI_DEBUG_PRINTLN("OTA seeder: client connected (%s) — relay + folder pull-dest ready", di); } else { ota_seeder_client.stop(); // no free source slot } diff --git a/src/helpers/ota/FolderMotaStore.cpp b/src/helpers/ota/FolderMotaStore.cpp new file mode 100644 index 0000000000..bc84a03d39 --- /dev/null +++ b/src/helpers/ota/FolderMotaStore.cpp @@ -0,0 +1,113 @@ +#include "FolderMotaStore.h" +#include "MotaSeederProto.h" +#include "OtaByteIO.h" +#include + +namespace mesh { +namespace ota { + +bool FolderMotaStore::readByteT(uint8_t& b) const { + uint32_t t0 = millis(); + while ((millis() - t0) < _to) { + int c = _io.read(); + if (c >= 0) { b = (uint8_t)c; return true; } + } + return false; +} + +bool FolderMotaStore::readExact(uint8_t* b, uint16_t n) const { + for (uint16_t i = 0; i < n; i++) if (!readByteT(b[i])) return false; + return true; +} + +// Resync-safe request/response (same framing as SerialMotaSource): drain stale input, frame op+args with an +// XOR checksum, scan for the response magic, validate op+status+checksum, deliver payload. +bool FolderMotaStore::txn(uint8_t op, const uint8_t* args, uint16_t arglen, + uint8_t* payload, uint16_t payload_len) const { + while (_io.read() >= 0) {} // drop any stale/partial bytes before a fresh request + uint8_t xs = op; + for (uint16_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(); + + uint32_t t0 = millis(); bool got = false; uint8_t prev = 0; + while ((millis() - t0) < _to) { // scan for response magic 'm' 's' (tolerate noise) + 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 (uint16_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; +} + +bool FolderMotaStore::begin(uint32_t total_size) { + uint8_t args[8]; + memcpy(args, _mid, 4); + wr_u32le(args + 4, total_size); + if (!txn(MS_OP_BEGIN, args, 8, nullptr, 0)) return false; + _total = total_size; + return true; +} + +bool FolderMotaStore::write(uint32_t off, const uint8_t* data, uint32_t len) { + uint8_t req[10 + MOTA_SEEDER_WRITE_MAX]; + memcpy(req, _mid, 4); + uint32_t done = 0; + while (done < len) { + uint16_t chunk = (len - done > MOTA_SEEDER_WRITE_MAX) ? MOTA_SEEDER_WRITE_MAX : (uint16_t)(len - done); + wr_u32le(req + 4, off + done); + req[8] = (uint8_t)(chunk & 0xFF); req[9] = (uint8_t)(chunk >> 8); + memcpy(req + 10, data + done, chunk); + if (!txn(MS_OP_WRITE, req, (uint16_t)(10 + chunk), nullptr, 0)) return false; + done += chunk; + } + return true; +} + +bool FolderMotaStore::read(uint32_t off, uint8_t* buf, uint32_t len) const { + uint8_t args[10]; + memcpy(args, _mid, 4); + uint32_t done = 0; + while (done < len) { + uint16_t chunk = (len - done > MOTA_SEEDER_WRITE_MAX) ? MOTA_SEEDER_WRITE_MAX : (uint16_t)(len - done); + wr_u32le(args + 4, off + done); + args[8] = (uint8_t)(chunk & 0xFF); args[9] = (uint8_t)(chunk >> 8); + if (!txn(MS_OP_SREAD, args, 10, buf + done, chunk)) return false; + done += chunk; + } + return true; +} + +void FolderMotaStore::finalize() { + txn(MS_OP_FIN, _mid, 4, nullptr, 0); // publish .mota.part as .mota (best-effort) +} + +bool FolderMotaStore::reopen() { + uint8_t pl[5]; + if (!txn(MS_OP_STAT, _mid, 4, pl, 5)) return false; + if (pl[0] == 0) return false; // host has no partial/complete file -> start fresh + uint32_t total = rd_u32le(pl + 1); + if (total < 13) return false; // implausible container + _total = total; + return true; +} + +} // namespace ota +} // namespace mesh diff --git a/src/helpers/ota/FolderMotaStore.h b/src/helpers/ota/FolderMotaStore.h new file mode 100644 index 0000000000..63823af4ec --- /dev/null +++ b/src/helpers/ota/FolderMotaStore.h @@ -0,0 +1,57 @@ +#pragma once + +#include +#include "OtaStore.h" + +// An OtaStore that captures an in-transit `.mota` onto a HOST folder over the mota-seeder link (the WRITE +// half of MotaSeederProto: OP_STAT/BEGIN/WRITE/SREAD/FIN). This is the destination for `ota pull <#> folder`: +// blocks stream straight to the host `.mota` — the device holds NO RAM/flash staging for it. +// +// begin(total) -> OP_BEGIN (host creates a 0xFF-filled .mota.part) +// write(off,data) -> OP_WRITE (split into <= MOTA_SEEDER_WRITE_MAX chunks) +// read(off,buf) -> OP_SREAD (read back; unwritten regions are 0xFF) +// reopen() -> OP_STAT (adopt an existing partial/complete file for resume) +// finalize() -> OP_FIN (publish .mota.part as .mota) +// +// If the link drops, every op returns false: the OtaManager PAUSES and keeps its progress (it does NOT fall +// back to RAM/flash). On reconnect the host still has the partial, and the manager re-reopen()s so only the +// missing blocks are refetched. All ops are keyed by `mid` (set via set_mid() before the fetch begins). + +namespace mesh { +namespace ota { + +class FolderMotaStore : public OtaStore { +public: + explicit FolderMotaStore(Stream& io, uint32_t timeout_ms = 3000) : _io(io), _to(timeout_ms) {} + + // The container being pulled — set from the chosen `ota pull` mid before begin()/reopen(). + void set_mid(const uint8_t mid[4]) { memcpy(_mid, mid, 4); } + + bool begin(uint32_t total_size) override; + bool write(uint32_t off, const uint8_t* data, uint32_t len) override; + bool read(uint32_t off, uint8_t* buf, uint32_t len) const override; + uint32_t capacity() const override { return 0xF0000000u; } // host disk — effectively unbounded + uint32_t staged_size() const override { return _total; } + void clear() override { _total = 0; } + void finalize() override; + bool reopen() override; // OP_STAT: adopt an existing file for resume + + // A host-backed store is fully random-access, so it needs no pinned-meta RAM page and no layout planning. + bool set_meta_size(uint32_t) override { return true; } + bool plan_layout(bool, uint32_t, uint32_t, uint32_t) override { return true; } + +private: + // One request/response transaction over the seeder link (mirrors SerialMotaSource). `arglen` is uint16 so + // OP_WRITE can carry its data inline. Returns true iff a well-formed OK response for `op` arrived in time. + bool txn(uint8_t op, const uint8_t* args, uint16_t arglen, uint8_t* payload, uint16_t payload_len) const; + bool readByteT(uint8_t& b) const; + bool readExact(uint8_t* b, uint16_t n) const; + + Stream& _io; + uint32_t _to; + uint8_t _mid[4] = {0, 0, 0, 0}; + uint32_t _total = 0; +}; + +} // namespace ota +} // namespace mesh diff --git a/src/helpers/ota/OtaCli.cpp b/src/helpers/ota/OtaCli.cpp index f514dea219..abe9187206 100644 --- a/src/helpers/ota/OtaCli.cpp +++ b/src/helpers/ota/OtaCli.cpp @@ -1,5 +1,6 @@ #include "OtaCli.h" #include "OtaContext.h" +#include "FolderMotaStore.h" // `ota pull <#> folder` destination (set_mid on the connected folder store) #include "OtaVerify.h" #include "OtaSelf.h" #include "OtaTargets.h" // ota_target_env_name(): human-readable name for a target_id (no string on the wire) @@ -161,23 +162,50 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board // ---- start fetching a specific catalogued mOTA (by list index or manifest_id) ---- } else if (is_cmd(a, "pull|get|download", &rest)) { - const char* p = rest; - if (*p == 0) { strcpy(reply, "usage: ota get <#> (see the numbers in `ota ls`)"); return true; } + const char* p = rest; while (*p == ' ') p++; + // split into " [destination]": selector = #N / N (catalogue index) or mid hex; destination = + // flash | folder (MANDATORY — `folder` captures the .mota onto the connected motatool folder as .mota). + char selstr[24]; int i = 0; + while (p[i] && p[i] != ' ' && i < (int)sizeof(selstr) - 1) { selstr[i] = p[i]; i++; } + selstr[i] = 0; + const char* dst = p + i; while (*dst == ' ') dst++; + if (selstr[0] == 0) { strcpy(reply, "usage: ota pull <#> (see `ota ls`)"); return true; } + // resolve the catalogue row (index or explicit manifest_id) 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); + bool isnum = (selstr[0] == '#'); + if (!isnum) { isnum = true; for (const char* x = selstr; *x; x++) if (*x < '0' || *x > '9') { isnum = false; break; } } + if (isnum) { + int idx = atoi(selstr[0] == '#' ? selstr + 1 : selstr); 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; } + } else if (mesh::Utils::fromHex(mid, 4, selstr)) { + for (uint8_t k = 0; k < c.manager.catalogCount(); k++) + if (memcmp(c.manager.catalogRow(k)->mid, mid, 4) == 0) { sel = c.manager.catalogRow(k); break; } } if (!sel) { strcpy(reply, "ERR no such update (see the numbers in `ota ls`)"); return true; } + // destination is MANDATORY: with none given, show the choices (flash always; folder iff a link is up). + if (*dst == 0) { + if (c.folder_dest) + snprintf(reply, 160, "choose a destination: `ota pull %s flash` | `ota pull %s folder` (folder: %s)", + selstr, selstr, c.folder_dest_info); + else + snprintf(reply, 160, "choose a destination: `ota pull %s flash` (folder: none connected — motatool serve)", + selstr); + 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(); + OtaStore* store; const char* dname; + if (strncmp(dst, "flash", 5) == 0) { + store = &c.fetch_store; c.fetch_store.clear(); dname = "flash"; + } else if (strncmp(dst, "folder", 6) == 0) { + if (!c.folder_dest) { strcpy(reply, "ERR no folder connected (run motatool serve --tcp/--serial)"); return true; } + c.folder_dest->set_mid(selmid); store = c.folder_dest; dname = "folder"; + } else { strcpy(reply, "ERR destination must be `flash` or `folder`"); return true; } + c.manager.reset_session(); + c.manager.set_fetch_store(store); // stage this pull to the chosen destination 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); + snprintf(reply, 160, "OK pulling mid=%s -> %s (low priority)", midhx, dname); // ---- discard the current session (e.g. a stalled old fetch) to free the slot ---- } else if (is_cmd(a, "drop|cancel|stop", &rest)) { @@ -185,8 +213,9 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board 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.manager.set_fetch_store(&c.fetch_store); // revert to the default flash store (a folder pull switched it) 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); + snprintf(reply, 160, "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). ---- diff --git a/src/helpers/ota/OtaContext.h b/src/helpers/ota/OtaContext.h index 2a67c23804..e598e546dd 100644 --- a/src/helpers/ota/OtaContext.h +++ b/src/helpers/ota/OtaContext.h @@ -37,6 +37,8 @@ namespace mesh { namespace ota { +class FolderMotaStore; // pull destination over the seeder link (full type only where instantiated/used) + #ifndef OTA_SERVE_BUF_SIZE #define OTA_SERVE_BUF_SIZE 16384 #endif @@ -73,6 +75,19 @@ struct OtaContext { 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) + // ---- Pull destinations (`ota pull <#> `): where a fetched .mota is staged. `flash` (fetch_store, + // always present) or `folder` — an external host folder over the seeder link, registered by the app + // while a motatool `serve` connection is attached (else no `folder` dest is offered). Captures the + // container to the host as .mota so an exact firmware copy can be pulled for delta-building. ---- + FolderMotaStore* folder_dest = nullptr; // non-null == a folder destination is currently connected + char folder_dest_info[24] = {0}; // human id of the link (e.g. "tcp 192.168.4.5", "serial") + void set_folder_dest(FolderMotaStore* fs, const char* info) { + folder_dest = fs; + strncpy(folder_dest_info, info ? info : "?", sizeof(folder_dest_info) - 1); + folder_dest_info[sizeof(folder_dest_info) - 1] = 0; + } + void clear_folder_dest() { folder_dest = nullptr; folder_dest_info[0] = 0; } + // 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 { From 7ec87d52bf97b9515df82750cdc76ad33cde6e81 Mon Sep 17 00:00:00 2001 From: Valentin Kivachuk Burda Date: Thu, 2 Jul 2026 08:50:20 +0200 Subject: [PATCH 18/59] ota: pause a folder pull on link loss, resume + rescan on reconnect If an `ota pull <#> folder` block-write fails mid-transfer (the motatool seeder link dropped), the fetch enters a new PAUSED state instead of failing or falling back to RAM/flash: progress stays on the host, the manager stops requesting, and loop()/stall-detection leave it untouched (it waits indefinitely). On reconnect the ESP32 seeder re-registers the folder destination and, if PAUSED, calls resumeStaged(): OP_STAT re-attaches the host's partial, the leaves are re-read, and only the missing blocks are re-requested (a brand-new/absent file restarts from 0). `ota status` reports the paused state. (cherry picked from commit 28e3df02f9c22dbe990ee7d99a420b620d05823f) --- examples/companion_radio/main.cpp | 3 +++ src/helpers/ota/OtaCli.cpp | 2 ++ src/helpers/ota/OtaManager.cpp | 15 ++++++++++----- src/helpers/ota/OtaManager.h | 5 ++++- 4 files changed, 19 insertions(+), 6 deletions(-) diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 3744a439cf..b58b65a6c5 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -145,6 +145,9 @@ void halt() { ota_seeder_attached = true; char di[24]; snprintf(di, sizeof di, "tcp %s", ota_seeder_client.remoteIP().toString().c_str()); mesh::ota::ota_ctx().set_folder_dest(&ota_folder_store, di); // offer `ota pull <#> folder` + // if a folder pull PAUSED when the link dropped, the host still holds the partial: rescan + resume + if (mesh::ota::ota_ctx().manager.fetchState() == mesh::ota::OtaManager::PAUSED) + mesh::ota::ota_ctx().manager.resumeStaged(nullptr); mesh::ota::ota_ctx().manager.announce(); // new served set -> advertise the folder's fw to peers WIFI_DEBUG_PRINTLN("OTA seeder: client connected (%s) — relay + folder pull-dest ready", di); } else { diff --git a/src/helpers/ota/OtaCli.cpp b/src/helpers/ota/OtaCli.cpp index abe9187206..a8de04579d 100644 --- a/src/helpers/ota/OtaCli.cpp +++ b/src/helpers/ota/OtaCli.cpp @@ -29,6 +29,7 @@ static char fstate_char(OtaManager::FetchState s) { case OtaManager::WANT_MANIFEST: return 'W'; case OtaManager::FETCHING: return 'F'; case OtaManager::COMPLETE: return 'C'; + case OtaManager::PAUSED: return 'P'; default: return 'X'; } } @@ -44,6 +45,7 @@ static const char* state_word(OtaManager::FetchState s) { case OtaManager::FETCHING: return "downloading"; case OtaManager::COMPLETE: return "ready to install"; case OtaManager::FAILED: return "failed"; + case OtaManager::PAUSED: return "paused (folder link lost — reconnect to resume)"; default: return "?"; } } diff --git a/src/helpers/ota/OtaManager.cpp b/src/helpers/ota/OtaManager.cpp index e204a05795..adb7e05588 100644 --- a/src/helpers/ota/OtaManager.cpp +++ b/src/helpers/ota/OtaManager.cpp @@ -446,7 +446,7 @@ void OtaManager::handleHave(const uint8_t* m, uint16_t n) { } 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 (!_fetch || _fstate == FETCHING || _fstate == WANT_MANIFEST || _fstate == PAUSED) return false; // busy 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 @@ -475,7 +475,7 @@ void OtaManager::armFirstReqHold() { // 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 (!_fetch || _fstate == FETCHING || _fstate == WANT_MANIFEST || _fstate == PAUSED) return; if (resumeStaged(mid)) return; // resume a partial container left in flash memcpy(_fid, mid, 4); seedBlockRng(); // per-node block-pick/jitter sequence (distinct per node) @@ -642,10 +642,15 @@ void OtaManager::handleProof(const uint8_t* m, uint16_t n) { clearReassembly(); // 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; + // verified -> commit the payload block, then its leaf (the present marker). A write failure here means a + // FOLDER destination's seeder link dropped mid-transfer: PAUSE (hold progress on the host, stop + // requesting, do NOT fall back to RAM/flash). The block is left uncommitted (its leaf stays 0xFF), so on + // reconnect resumeStaged() re-requests exactly it. (A flash store never fails these writes.) uint8_t leaf[4]; merkle_leaf(leaf, _reasm_buf, blen); - if (!_fetch->write(_floff + (uint32_t)_reasm_block * 4, leaf, 4)) return; + if (!_fetch->write(_fpoff + (uint32_t)_reasm_block * _fbs, _reasm_buf, blen) || + !_fetch->write(_floff + (uint32_t)_reasm_block * 4, leaf, 4)) { + _fstate = PAUSED; clearReassembly(); return; + } _have++; OTA_DBG("OTA: block %u OK have=%u/%u\n", (unsigned)_reasm_block, (unsigned)_have, (unsigned)_fbc); clearReassembly(); diff --git a/src/helpers/ota/OtaManager.h b/src/helpers/ota/OtaManager.h index a0c8cf0419..8dfe3cbc95 100644 --- a/src/helpers/ota/OtaManager.h +++ b/src/helpers/ota/OtaManager.h @@ -96,7 +96,10 @@ typedef bool (*ServeReadFn)(void* ctx, uint32_t off, uint8_t* buf, uint32_t len) class OtaManager { public: - enum FetchState : uint8_t { IDLE, WANT_MANIFEST, FETCHING, COMPLETE, FAILED }; + // PAUSED: a folder-destination write failed mid-transfer (the seeder link dropped). Progress is held on + // the host; the manager stops requesting and does NOT fall back to RAM/flash. resumeStaged() (called on + // reconnect) re-STATs the host file, recomputes which blocks are missing, and resumes. + enum FetchState : uint8_t { IDLE, WANT_MANIFEST, FETCHING, COMPLETE, FAILED, PAUSED }; // Sentinel for "no block" in the reassembly / peer-REQ / recently-served slots (a real block index is // a small uint16, so 0xFFFFFFFF is never valid). From fb0778de346fa8e2c1cc475d344931946727e71a Mon Sep 17 00:00:00 2001 From: Valentin Kivachuk Burda Date: Thu, 2 Jul 2026 08:52:40 +0200 Subject: [PATCH 19/59] docs: pull a firmware .mota off the mesh into a motatool folder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document `ota pull <#> ` (flash|folder, destination mandatory), the folder pull that captures a device's exact firmware to the host as .mota (for delta-building), the paused/resume-on-reconnect behaviour, and that a `motatool serve` link doubles as the pull-to-folder store. (protocol §10, user guide, motatool README) (cherry picked from commit 49820b57aa4ff42f60aeea4fa1770a646c2d73b1) --- docs/ota_protocol.md | 8 ++++++++ docs/ota_user_guide.md | 21 +++++++++++++++------ tools/motatool/README.md | 2 +- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/docs/ota_protocol.md b/docs/ota_protocol.md index 4e7f5a28ea..d60140c6d0 100644 --- a/docs/ota_protocol.md +++ b/docs/ota_protocol.md @@ -482,6 +482,14 @@ verify everything). The serve side (`OtaManager`) keeps a lightweight registry o resident "views": `view0` (its own firmware) and one on-demand view loaded from a source when a request targets an external mota. Every fetch message carries `manifest_id`, so dispatch is a registry lookup. +The same host-folder link is also a **pull destination** (the reverse direction): `ota pull <#> folder` +fetches a `.mota` off the mesh and streams it onto the host as `.mota` via the seeder STORAGE ops +(`OP_STAT/BEGIN/WRITE/SREAD/FIN`, see `MotaSeederProto.h`), using a `FolderMotaStore` as the fetch's +`OtaStore` instead of RAM/flash. This captures an exact copy of a device's firmware — e.g. to build a delta +against firmware you don't have. Resume is bookkeeping-free: `BEGIN` 0xFF-fills the file and, on reconnect +after a link drop (the fetch PAUSES, holding progress on the host — no RAM/flash fallback), `STAT`+`SREAD` +let the fetcher recompute and refill only the missing blocks. + ### 10.1 The `MotaSource` abstraction (`OtaSource.h`) Transport-agnostic provider of one or more complete `.mota` as random-access bytes. The same serve code diff --git a/docs/ota_user_guide.md b/docs/ota_user_guide.md index 0235be232f..c24de359d1 100644 --- a/docs/ota_user_guide.md +++ b/docs/ota_user_guide.md @@ -75,17 +75,26 @@ downloaded yet; this is just looking around. (`ota neighbors` / `ota updates` al ### 3. Download an update -Pick one from the list by its **number**: +Pick one from the list by its **number**, and say **where** to put it: ``` -ota get 1 +ota pull 1 flash # stage it in this node's flash, to install here +ota pull 1 folder # capture it onto a connected motatool folder as .mota (don't install here) ``` -The node starts fetching it in the background, **at low priority**, a piece at a time — possibly from -several neighbours at once. Check progress any time with `ota status` (you'll see it climb, e.g. -`download: downloading 120/525 (23%)`). You can keep using your node normally meanwhile. +The destination is required — `ota pull 1` on its own just shows the choices. **`flash`** is always +available (stage here, then `ota install`). **`folder`** appears only while a `motatool serve` link is +attached (it shows the link, e.g. `folder: tcp 192.168.4.5`); it streams the firmware straight onto the +host folder — nothing is staged on this node. That's how you grab an **exact copy of another device's +firmware** off the mesh (to a `.mota` file) so you can later build a *delta* against firmware you don't +otherwise have. (`ota get` is an alias.) -To **stop** a download you no longer want: +The node fetches in the background, **at low priority**, a piece at a time — possibly from several +neighbours at once. Check progress with `ota status`. You can keep using your node normally meanwhile. + +If a `folder` pull loses its link mid-transfer, `ota status` shows **paused** — the host keeps the +partial and the pull resumes (filling only what's missing) the moment you reconnect motatool; it never +falls back to flash. To **stop** a download you no longer want: ``` ota cancel diff --git a/tools/motatool/README.md b/tools/motatool/README.md index 235d8af472..80276d40fc 100644 --- a/tools/motatool/README.md +++ b/tools/motatool/README.md @@ -16,7 +16,7 @@ cross-checked byte-for-byte against the Python reference packager `tools/mota/mo | `build` | Create a **full** or **delta** `.mota` from a firmware (local file **or** http(s) URL). | | `verify` | Validate one or more `.mota` (merkle tree, leaves vs payload, image hash, Ed25519 signature). `--pub` requires a specific signer; `--base` confirms a sequential delta rebuilds its image. | | `inspect`| Print every field of a `.mota`'s manifest (debugging). | -| `serve` | Serve a **folder** of `.mota` to a node over USB serial (`--serial`) or WiFi (`--tcp`). Invalid files are warned about and skipped — one corrupt file never sinks the rest. | +| `serve` | Serve a **folder** of `.mota` to a node over USB serial (`--serial`) or WiFi (`--tcp`). Invalid files are warned about and skipped — one corrupt file never sinks the rest. The same link also **stores** a node's `ota pull … folder` captures into that folder as `.mota` — pull a device's exact firmware off the mesh to build deltas against firmware you don't otherwise have. | | `keygen` | Generate an Ed25519 signing keypair (hex). | Every command has detailed, example-rich help: `motatool --help`. From a696f8a2d88e332482c50c84a495c5a9e0fcc5a5 Mon Sep 17 00:00:00 2001 From: Valentin Kivachuk Burda Date: Thu, 2 Jul 2026 19:03:06 +0200 Subject: [PATCH 20/59] ota: nRF52 mOTA flash-layout safety net + tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route both begin() and reopen() through a pure mota_nrf52_stage_plan() that bottom-aligns a received .mota below the filesystem region (trailer ending at FS_START, where the bootloader scans) and refuses any size that would overrun that ceiling or overlap the running image — the single place the FS/prefs-safe bounds are enforced. Add compile-time static_asserts pinning the flash-layout ordering (app < staging ceiling < bootloader; in-place workspace ends at/below the ceiling) so an inconsistent constant fails the build instead of silently corrupting user prefs. Cover the pure planner in test/test_ota/test_ota_flashplan.cpp. (cherry picked from commit 4a601486acf93b6ab9246f109783bb0ac094867f) --- src/helpers/ota/OtaFlashLayout_nrf52.h | 28 +++++++++ src/helpers/ota/OtaStoreFlashNrf52.cpp | 14 ++--- test/test_ota/test_ota_flashplan.cpp | 85 ++++++++++++++++++++++++++ 3 files changed, 120 insertions(+), 7 deletions(-) create mode 100644 test/test_ota/test_ota_flashplan.cpp diff --git a/src/helpers/ota/OtaFlashLayout_nrf52.h b/src/helpers/ota/OtaFlashLayout_nrf52.h index 289348fdf5..97cf6bf822 100644 --- a/src/helpers/ota/OtaFlashLayout_nrf52.h +++ b/src/helpers/ota/OtaFlashLayout_nrf52.h @@ -35,5 +35,33 @@ static const uint32_t MOTA_NRF52_INPLACE_MEMORY = 0x00098000u; // 608 KB (APP_B static const uint32_t MOTA_NRF52_BL_START = 0x000F4000u; static const uint32_t MOTA_NRF52_BL_END = 0x000FE000u; +// Compile-time layout-ordering invariants. If a constant above is edited inconsistently these fail the +// BUILD rather than silently letting a stage/apply corrupt the filesystem (user prefs) or the app. +static_assert((MOTA_NRF52_APP_BASE % MOTA_NRF52_FLASH_PAGE) == 0, "APP_BASE must be page-aligned"); +static_assert((MOTA_NRF52_FS_START % MOTA_NRF52_FLASH_PAGE) == 0, "FS_START must be page-aligned"); +static_assert(MOTA_NRF52_APP_BASE < MOTA_NRF52_FS_START, "app must precede the staging ceiling"); +static_assert(MOTA_NRF52_FS_START < MOTA_NRF52_BL_START, "staging (+FS) must end below the bootloader"); +static_assert(MOTA_NRF52_BL_START < MOTA_NRF52_BL_END, "bootloader region must be non-empty"); +// The in-place apply workspace [APP_BASE, APP_BASE+INPLACE_MEMORY) must end at/below the staging ceiling, +// so an in-place apply never writes into ExtraFS/InternalFS (where user prefs live). +static_assert(MOTA_NRF52_APP_BASE + MOTA_NRF52_INPLACE_MEMORY <= MOTA_NRF52_FS_START, + "in-place apply workspace must end at or below the staging ceiling"); + +// Plan where to stage a received `.mota` of `total_size` bytes. It is placed bottom-aligned so its +// 5-byte trailer ends exactly at FS_START (the bootloader scans downward from there), and it must sit +// ENTIRELY within [app_end, FS_START): above the running image (`app_end` = APP_BASE + its EndF image_len) +// and below the filesystem region (ExtraFS/InternalFS — where user prefs live, assumed immutable). +// Returns false (and leaves out_start untouched) if it does not fit. This is the SINGLE place the FS +// ceiling + app-collision bounds are enforced; begin()/reopen() both go through it. Pure — no flash I/O — +// so it is unit-tested natively in test/test_ota/test_ota_flashplan.cpp. +inline bool mota_nrf52_stage_plan(uint32_t total_size, uint32_t app_end, uint32_t& out_start) { + const uint32_t capacity = MOTA_NRF52_FS_START - MOTA_NRF52_APP_BASE; + if (total_size < 13 || total_size > capacity) return false; // 13 = header(8)+trailer(5); must fit below FS + uint32_t start = (MOTA_NRF52_FS_START - total_size) & ~(MOTA_NRF52_FLASH_PAGE - 1); // bottom-align down + if (start < app_end) return false; // would overlap the running image + out_start = start; + return true; +} + } // namespace ota } // namespace mesh diff --git a/src/helpers/ota/OtaStoreFlashNrf52.cpp b/src/helpers/ota/OtaStoreFlashNrf52.cpp index 51979e2a60..e1f0aa2f94 100644 --- a/src/helpers/ota/OtaStoreFlashNrf52.cpp +++ b/src/helpers/ota/OtaStoreFlashNrf52.cpp @@ -56,16 +56,16 @@ uint8_t* OtaStoreFlashNrf52::write_slot(uint32_t pos) { bool OtaStoreFlashNrf52::begin(uint32_t total_size) { clear(); - if (total_size < 13 || total_size > capacity()) return false; // 13 = header(8) + trailer(5) - - // bottom-align against FS_START so the trailer ends exactly at FS_START (bootloader scans for it) - uint32_t start = align_down(MOTA_NRF52_FS_START - total_size, PG); // never collide with the running application image (its extent comes from its EndF trailer) 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; - if (start < app_end) return false; + + // bottom-align below FS_START + reject if it won't fit above the running image (the FS/prefs-safe + // bounds check; pure + unit-tested in test/test_ota/test_ota_flashplan.cpp) + uint32_t start; + if (!mota_nrf52_stage_plan(total_size, app_end, start)) return false; _write_start = start; _total = total_size; @@ -139,8 +139,8 @@ bool OtaStoreFlashNrf52::reopen() { const uint8_t* p = (const uint8_t*)(uintptr_t)start; if (memcmp(p, MOTA_MAGIC, 4) != 0) continue; uint32_t total = rd_u32le(p + 4); - if (total < 13 || total > capacity()) continue; - if (align_down(MOTA_NRF52_FS_START - total, PG) != start) continue; // must match begin()'s placement + uint32_t want; // must be valid + placed exactly where begin() would have staged it (same bounds fn) + if (!mota_nrf52_stage_plan(total, app_end, want) || want != start) continue; _write_start = start; _total = total; memcpy(_meta_page, p, PG); // load page 0 (header+manifest+leaves) into RAM to continue diff --git a/test/test_ota/test_ota_flashplan.cpp b/test/test_ota/test_ota_flashplan.cpp new file mode 100644 index 0000000000..785e5d7c4f --- /dev/null +++ b/test/test_ota/test_ota_flashplan.cpp @@ -0,0 +1,85 @@ +#include + +#include "helpers/ota/OtaFlashLayout_nrf52.h" + +using namespace mesh::ota; + +// These lock down the nRF52 (RAK4631) single-slot staging geometry that OtaStoreFlashNrf52::begin()/ +// reopen() rely on. A received `.mota` is placed bottom-aligned below the filesystem region — ExtraFS +// (0xD4000) / InternalFS (0xED000), where the node's user preferences live — and above the running image. +// The prefs region is assumed IMMUTABLE (its bytes are outside the served/hashed self-image), so staging +// or an in-place apply must never reach into it. If a layout constant or the placement math is edited +// inconsistently, these fail here instead of silently corrupting prefs / the app on real hardware. + +static constexpr uint32_t CAP = MOTA_NRF52_FS_START - MOTA_NRF52_APP_BASE; // 0xAE000, 696 KB + +// A typical running image (~520 KB) leaves room; the container lands strictly within (app_end, FS_START]. +TEST(OtaFlashPlan, StagesBelowFilesystemAndAboveApp) { + uint32_t app_end = MOTA_NRF52_APP_BASE + 520u * 1024u; + uint32_t start = 0xDEADBEEF; + ASSERT_TRUE(mota_nrf52_stage_plan(64u * 1024u, app_end, start)); + EXPECT_GE(start, app_end); // never overlaps the running image + EXPECT_LE(start + 64u * 1024u, MOTA_NRF52_FS_START); // never reaches into ExtraFS/InternalFS/prefs + EXPECT_EQ(start % MOTA_NRF52_FLASH_PAGE, 0u); // page-aligned (the flash erase unit) +} + +// Bottom-aligned: the container's trailer ends AT FS_START (the bootloader scans downward from there), +// so start is the page-aligned FS_START - total_size and sits within one page of the ceiling. +TEST(OtaFlashPlan, BottomAlignedTrailerEndsAtCeiling) { + uint32_t start = 0; + uint32_t total = 100000; + ASSERT_TRUE(mota_nrf52_stage_plan(total, MOTA_NRF52_APP_BASE, start)); + EXPECT_EQ(start, (MOTA_NRF52_FS_START - total) & ~(MOTA_NRF52_FLASH_PAGE - 1)); + EXPECT_LE(start + total, MOTA_NRF52_FS_START); + EXPECT_GT(start + total, MOTA_NRF52_FS_START - MOTA_NRF52_FLASH_PAGE); // within one page of the ceiling +} + +// An exactly-capacity container (no app below it) fills the whole staging region; one byte more never fits. +TEST(OtaFlashPlan, RejectsOversizedContainer) { + uint32_t start = 0; + ASSERT_TRUE(mota_nrf52_stage_plan(CAP, MOTA_NRF52_APP_BASE, start)); + EXPECT_EQ(start, MOTA_NRF52_APP_BASE); // exactly fills [APP_BASE, FS_START) + EXPECT_EQ(start + CAP, MOTA_NRF52_FS_START); + EXPECT_FALSE(mota_nrf52_stage_plan(CAP + 1, MOTA_NRF52_APP_BASE, start)); +} + +// A container that would fit below FS_START on its own but overlaps the running image is refused — +// the pull fails cleanly rather than corrupting the app that is currently executing. +TEST(OtaFlashPlan, RejectsCollisionWithRunningImage) { + uint32_t app_end = MOTA_NRF52_FS_START - 8u * 1024u; // only 8 KB free below the ceiling + uint32_t start = 0; + EXPECT_TRUE(mota_nrf52_stage_plan(4u * 1024u, app_end, start)); // 4 KB fits in the 8 KB gap + EXPECT_GE(start, app_end); + EXPECT_FALSE(mota_nrf52_stage_plan(16u * 1024u, app_end, start)); // 16 KB does not +} + +// Minimum container is header(8)+trailer(5)=13 bytes; anything smaller is not a container. +TEST(OtaFlashPlan, RejectsUndersizedContainer) { + uint32_t start = 0; + EXPECT_FALSE(mota_nrf52_stage_plan(12, MOTA_NRF52_APP_BASE, start)); + EXPECT_TRUE(mota_nrf52_stage_plan(13, MOTA_NRF52_APP_BASE, start)); +} + +// The user-preferences filesystems (ExtraFS @ 0xD4000, InternalFS @ 0xED000) are entirely ABOVE any +// staged container AND above the in-place apply workspace — the immutability the shared-firmware hashes +// depend on. Hard-code the FS addresses here (independent of the layout header) so a future edit to +// FS_START that drifts into a filesystem is caught. +TEST(OtaFlashPlan, PrefsRegionNeverStaged) { + const uint32_t EXTRAFS_START = 0xD4000u; // companion ExtraFS (CustomLFS(0xD4000, 0x19000)) + const uint32_t INTERNALFS_START = 0xED000u; // primary LittleFS (holds /com_prefs) + EXPECT_LE(MOTA_NRF52_FS_START, EXTRAFS_START); // staging ceiling at/below the first filesystem + EXPECT_LT(EXTRAFS_START, INTERNALFS_START); + // the largest possible staged container still ends at the ceiling, never into a filesystem + uint32_t start = 0; + ASSERT_TRUE(mota_nrf52_stage_plan(CAP, MOTA_NRF52_APP_BASE, start)); + EXPECT_LE(start + CAP, EXTRAFS_START); + // and the in-place apply workspace ends below the filesystem too + EXPECT_LE(MOTA_NRF52_APP_BASE + MOTA_NRF52_INPLACE_MEMORY, EXTRAFS_START); +} + +// out_start is only written on success — a rejected plan must not clobber the caller's variable. +TEST(OtaFlashPlan, LeavesOutputUntouchedOnReject) { + uint32_t start = 0x1234ABCD; + EXPECT_FALSE(mota_nrf52_stage_plan(CAP + 1, MOTA_NRF52_APP_BASE, start)); + EXPECT_EQ(start, 0x1234ABCDu); +} From 033109d88de66651b6621fbd34d8a66f3d978b15 Mon Sep 17 00:00:00 2001 From: Valentin Kivachuk Burda Date: Thu, 2 Jul 2026 19:03:28 +0200 Subject: [PATCH 21/59] ota: fragment-level want_mask requests (anti-burst) + warm-start leaf-diff capture Protocol: OTA_REQ and OTA_GET_MANIFEST now carry a want_mask bitmap, so a fetcher asks for specific fragments (all on the first request, only the still-missing holes on a retry) instead of a whole block/manifest window. The WANT_MANIFEST and FETCHING retry loops re-ask only on a no-progress tick, so a lost fragment costs one fragment to recover and a re-request can't collide with an in-flight multi-fragment burst on half-duplex radios. Warm-start (motatool folder-capture only): new OTA_GET_LEAVES/OTA_LEAVES let `ota pull <#> folder validate` bulk-fetch the target's merkle leaves, authenticate them against the manifest root, diff a similar seed build already staged in the destination, and pull DATA over LoRa only for the blocks that differ. Leaves are bitmap-fragmented + no-progress retry-gated like the manifest, capped at OTA_DIFF_MAX_BLOCKS so the want_mask stays a fixed uint16, and the diff runs a bounded batch per loop tick so it never starves the mesh loop. motatool `serve --seed ` injects the seed payload into the destination .part on OP_BEGIN. (cherry picked from commit 89532147616f1b753b90698a966f15a046e6dc08) --- docs/ota_protocol.md | 44 ++++-- docs/ota_user_guide.md | 11 +- src/helpers/ota/OtaCli.cpp | 12 +- src/helpers/ota/OtaFormat.h | 2 + src/helpers/ota/OtaManager.cpp | 236 ++++++++++++++++++++++++++------ src/helpers/ota/OtaManager.h | 51 ++++++- src/helpers/ota/OtaProtocol.cpp | 31 ++++- src/helpers/ota/OtaProtocol.h | 34 ++++- test/test_ota/test_ota_core.cpp | 53 ++++++- tools/motatool/README.md | 10 ++ tools/motatool/src/main.cpp | 19 +++ tools/motatool/src/serve.cpp | 9 ++ tools/motatool/src/serve.h | 8 ++ 13 files changed, 448 insertions(+), 72 deletions(-) diff --git a/docs/ota_protocol.md b/docs/ota_protocol.md index d60140c6d0..f173b9e32c 100644 --- a/docs/ota_protocol.md +++ b/docs/ota_protocol.md @@ -288,12 +288,14 @@ Message types: | `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_GET_MANIFEST` | 0x04 | direct | request a manifest's fragments (`want_mask`) by `manifest_id` | | `OTA_MANIFEST` | 0x05 | direct | the manifest-minus-leaves, fragmented | -| `OTA_REQ` | 0x06 | direct | request a window of blocks' DATA | +| `OTA_REQ` | 0x06 | direct | request specific DATA fragments of one block (`want_mask`) | | `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 | +| `OTA_GET_LEAVES` | 0x0A | direct | request the target's `leaves[]` fragments (`want_mask`) — warm-start only | +| `OTA_LEAVES` | 0x0B | direct | a fragment of the `leaves[]` array (for host-side seed leaf-diff) | - **`manifest_id`** = the manifest's `merkle_root` (4 bytes) — a compact content id present in every transfer message, so a multi-mota server dispatches each request to the right image. @@ -364,12 +366,12 @@ Net effect: a digest change costs ~1 query + ~1 HAVE flood mesh-wide; a stable m ``` fetcher server (any node that has the mid) - OTA_GET_MANIFEST(mid) ───────► - ◄─────── OTA_MANIFEST(mid, frag_idx, frag_total, bytes) × frag_total + OTA_GET_MANIFEST(mid, want_mask) ► (want_mask=0xFFFF first; only missing fragments on retry) + ◄─────── OTA_MANIFEST(mid, frag_idx, frag_total, bytes) × requested frags (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) + for each missing block: + OTA_REQ(mid, block_idx, want_mask) ► (want_mask=all fragments first; only the holes on retry) + ◄─────── OTA_DATA(mid, block_idx, frag_off, data) × requested frags (reassemble block from frag_off slices) OTA_REQ_PROOF(mid, block_idx) ────► ◄─────── OTA_PROOF(mid, block_idx, n_proof, proof) @@ -382,18 +384,42 @@ fetcher server (any node that has the mid) All offsets after the 1-byte type. Encoders/decoders in `OtaProtocol.cpp`; constants in `OtaManager.h`. ``` -OTA_GET_MANIFEST: manifest_id[4] +OTA_GET_MANIFEST: manifest_id[4] want_mask(uint16) # bit k = send manifest fragment k; 0xFFFF = all OTA_MANIFEST: manifest_id[4] frag_idx(1) frag_total(1) bytes[] # up to OTA_MF_FRAG=176 B/frag -OTA_REQ: manifest_id[4] start_block(uint16) count(1) +OTA_REQ: manifest_id[4] block_idx(uint16) want_mask(uint16) # bit k = send fragment k of block OTA_DATA: manifest_id[4] block_idx(uint16) frag_off(uint16) data[] # up to OTA_FRAG_DATA=160 B OTA_REQ_PROOF: manifest_id[4] block_idx(uint16) OTA_PROOF: manifest_id[4] block_idx(uint16) n_proof(1) proof[] # n_proof × 4 bytes +OTA_GET_LEAVES: manifest_id[4] want_mask(uint16) # bit k = send leaves fragment k; 0xFFFF = all +OTA_LEAVES: manifest_id[4] frag_idx(1) frag_total(1) bytes[] # up to OTA_LEAVES_FRAG=176 leaf bytes ``` +- **Warm-start / leaf-diff (`OTA_GET_LEAVES`/`OTA_LEAVES`) — motatool folder-capture only.** Capturing a + device's firmware into a `motatool serve` folder is slow (a full image is hundreds of blocks). Because + builds here are non-deterministic, you cannot reproduce the exact target on the host — but a *similar* + build (e.g. a fresh recompile) is ~99% identical. So `motatool serve --seed ` stages that + build's payload into the destination `.part`, and `ota pull <#> folder validate` makes the fetcher (1) bulk- + fetch the target's `leaves[]` via `OTA_GET_LEAVES`/`OTA_LEAVES` (bitmap-fragmented with a `want_mask`, same + anti-burst rule as `OTA_MANIFEST`), (2) recompute the merkle root from them and check it equals the + manifest root (authenticate), then (3) keep every seeded block whose leaf matches and pull full `OTA_DATA` + only for the blocks that differ. The `want_mask` is a fixed **uint16**, so `leaves[]` is capped at + `OTA_LEAVES_MAXFRAG=16` fragments (`OTA_DIFF_MAX_BLOCKS=704` blocks); larger images just fall back to a full + fetch. **Normal P2P nodes never use this** — they target only the blocks they want; the only always-on part + is answering `OTA_GET_LEAVES` with leaves the node already holds, so any node's firmware can be captured. + - **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. +- **Fragment-level requests (anti-deadlock + anti-congestion):** both `OTA_REQ` and `OTA_GET_MANIFEST` carry + a `want_mask` — bit *k* asks for fragment *k*. A fetcher requests the **full** mask on the first ask + (`(1<.mota (don't install here) +ota pull 1 flash # stage it in this node's flash, to install here +ota pull 1 folder # capture it onto a connected motatool folder as .mota (don't install here) +ota pull 1 folder validate # same capture, warm-started from a motatool --seed build (much faster; below) ``` The destination is required — `ota pull 1` on its own just shows the choices. **`flash`** is always @@ -89,6 +90,12 @@ host folder — nothing is staged on this node. That's how you grab an **exact c firmware** off the mesh (to a `.mota` file) so you can later build a *delta* against firmware you don't otherwise have. (`ota get` is an alias.) +**`validate` (warm-start, advanced).** Capturing a full image over the radio is slow. If you have a +*similar* build on the computer (e.g. a fresh recompile of the same firmware), run motatool with +`--seed ` and add **`validate`**: the node fetches just the target's block fingerprints, keeps +every block your seed already matches, and pulls over the radio only the handful that actually differ — +turning a ~30-minute capture into seconds. The result is still a byte-exact, verified copy of the target. + The node fetches in the background, **at low priority**, a piece at a time — possibly from several neighbours at once. Check progress with `ota status`. You can keep using your node normally meanwhile. diff --git a/src/helpers/ota/OtaCli.cpp b/src/helpers/ota/OtaCli.cpp index a8de04579d..0441b0528a 100644 --- a/src/helpers/ota/OtaCli.cpp +++ b/src/helpers/ota/OtaCli.cpp @@ -27,6 +27,7 @@ static char fstate_char(OtaManager::FetchState s) { switch (s) { case OtaManager::IDLE: return 'I'; case OtaManager::WANT_MANIFEST: return 'W'; + case OtaManager::WANT_LEAVES: return 'L'; case OtaManager::FETCHING: return 'F'; case OtaManager::COMPLETE: return 'C'; case OtaManager::PAUSED: return 'P'; @@ -42,6 +43,7 @@ static const char* state_word(OtaManager::FetchState s) { switch (s) { case OtaManager::IDLE: return "idle"; case OtaManager::WANT_MANIFEST: return "starting"; + case OtaManager::WANT_LEAVES: return "validating seed"; case OtaManager::FETCHING: return "downloading"; case OtaManager::COMPLETE: return "ready to install"; case OtaManager::FAILED: return "failed"; @@ -195,17 +197,21 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board return true; } if (c.apply_pending) { strcpy(reply, "ERR busy applying"); return true; } + // optional 3rd token: `folder validate` -> leaf-diff warm-start against a seed motatool staged (capture only) + bool validate = false; + { const char* q = dst; while (*q && *q != ' ') q++; while (*q == ' ') q++; + if (strncmp(q, "validate", 8) == 0) validate = true; } uint8_t selmid[4]; uint32_t seltgt = sel->target_id; memcpy(selmid, sel->mid, 4); // sel may move on reset OtaStore* store; const char* dname; if (strncmp(dst, "flash", 5) == 0) { - store = &c.fetch_store; c.fetch_store.clear(); dname = "flash"; + store = &c.fetch_store; c.fetch_store.clear(); dname = "flash"; validate = false; // seed lives in the folder } else if (strncmp(dst, "folder", 6) == 0) { if (!c.folder_dest) { strcpy(reply, "ERR no folder connected (run motatool serve --tcp/--serial)"); return true; } - c.folder_dest->set_mid(selmid); store = c.folder_dest; dname = "folder"; + c.folder_dest->set_mid(selmid); store = c.folder_dest; dname = validate ? "folder+validate" : "folder"; } else { strcpy(reply, "ERR destination must be `flash` or `folder`"); return true; } c.manager.reset_session(); c.manager.set_fetch_store(store); // stage this pull to the chosen destination - c.manager.pull(selmid, seltgt); // sets want + begins the manifest fetch now + c.manager.pull(selmid, seltgt, validate); // sets want + begins the manifest fetch now char midhx[9]; mesh::Utils::toHex(midhx, selmid, 4); snprintf(reply, 160, "OK pulling mid=%s -> %s (low priority)", midhx, dname); diff --git a/src/helpers/ota/OtaFormat.h b/src/helpers/ota/OtaFormat.h index 6a8da2e2d6..554899f0fd 100644 --- a/src/helpers/ota/OtaFormat.h +++ b/src/helpers/ota/OtaFormat.h @@ -83,6 +83,8 @@ enum OtaMsgType : uint8_t { 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 + OTA_GET_LEAVES = 0x0A, // request the target's merkle leaves[] (motatool warm-start; bitmap-fragmented) + OTA_LEAVES = 0x0B, // a fragment of the leaves[] array (for host-side seed leaf-diff) }; static const uint16_t OTA_DEFAULT_BLOCK_SIZE = 1024; diff --git a/src/helpers/ota/OtaManager.cpp b/src/helpers/ota/OtaManager.cpp index adb7e05588..eea8a0b7c9 100644 --- a/src/helpers/ota/OtaManager.cpp +++ b/src/helpers/ota/OtaManager.cpp @@ -5,6 +5,7 @@ #include "OtaByteIO.h" #include "OtaDebug.h" #include +#include // malloc/free for the (transient) leaf-diff buffer namespace mesh { namespace ota { @@ -256,11 +257,13 @@ void OtaManager::handleGetManifest(const uint8_t* m, uint16_t n) { 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.) + // up to OTA_MF_FRAG manifest bytes; the client reassembles by frag_idx. Only the fragments requested in + // want_mask are sent (0xFFFF = all), so a retry re-sends just the holes, not the whole manifest. 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++) { + if (!(gm.want_mask & (1u << fi))) continue; // fetcher didn't ask for this manifest fragment 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; @@ -272,9 +275,42 @@ void OtaManager::handleGetManifest(const uint8_t* m, uint16_t n) { } } -// Emit one block's data as self-describing DATA fragments (frag_off); the proof is fetched separately. -void OtaManager::emitBlockData(const uint8_t* mid, uint32_t idx, const uint8_t* data, uint32_t blen) { - for (uint32_t fo = 0; fo < blen; fo += OTA_FRAG_DATA) { +// Serve the target's merkle leaves[] in fragments (for a motatool folder-capture warm-start). Only the +// fragments set in want_mask are emitted, so a want_mask retry re-sends just the holes — never a full burst +// (same anti-deadlock rationale as OTA_MANIFEST). This is the only leaf-diff piece that runs on every node. +void OtaManager::handleGetLeaves(const uint8_t* m, uint16_t n) { + GetLeavesMsg gl; + if (!decode_get_leaves(m, n, gl)) return; + ServeView* v = resolve(gl.manifest_id); + if (!v || !v->m.leaves) return; + uint32_t leaves_len = v->m.block_count * 4; + uint8_t ftotal = (uint8_t)((leaves_len + OTA_LEAVES_FRAG - 1) / OTA_LEAVES_FRAG); if (ftotal == 0) ftotal = 1; + for (uint8_t fi = 0; fi < ftotal; fi++) { + if (!(gl.want_mask & (1u << fi))) continue; // fetcher didn't ask for this leaves fragment + uint32_t off = (uint32_t)fi * OTA_LEAVES_FRAG; + uint32_t fl = leaves_len - off; if (fl > OTA_LEAVES_FRAG) fl = OTA_LEAVES_FRAG; + LeavesMsg lm; + memcpy(lm.manifest_id, v->m.merkle_root, 4); + lm.frag_idx = fi; lm.frag_total = ftotal; + lm.bytes = v->m.leaves + off; lm.len = (uint16_t)fl; + uint8_t b[MAX_PACKET_PAYLOAD]; + emit(b, encode_leaves(b, sizeof(b), lm), false); + } +} + +// Smallest mask covering `nf` fragments: bit k set for k in [0, nf). Caps at 16 (matches _reasm_mask and +// the manifest reassembly), which bounds a block at 16 fragments — our 1 KB blocks are 7. +static inline uint16_t frag_full_mask(uint32_t nf) { + return (nf >= 16) ? 0xFFFFu : (uint16_t)((1u << nf) - 1); +} + +// Emit one block's data as self-describing DATA fragments (frag_off); only the fragments whose bit is set +// in `want_mask` are sent (bit k = the slice at k*OTA_FRAG_DATA). The proof is fetched separately. +void OtaManager::emitBlockData(const uint8_t* mid, uint32_t idx, const uint8_t* data, uint32_t blen, + uint16_t want_mask) { + uint32_t k = 0; + for (uint32_t fo = 0; fo < blen; fo += OTA_FRAG_DATA, k++) { + if (!(want_mask & (1u << k))) continue; // fetcher didn't ask for this fragment uint32_t fl = (fo + OTA_FRAG_DATA <= blen) ? OTA_FRAG_DATA : (blen - fo); DataMsg dm; memcpy(dm.manifest_id, mid, 4); @@ -303,36 +339,29 @@ void OtaManager::noteOverheardData(const uint8_t* m, uint16_t n) { void OtaManager::handleReq(const uint8_t* m, uint16_t n) { ReqMsg rq; if (!decode_req(m, n, rq)) return; + uint32_t idx = rq.block_idx; ServeView* v = resolve(rq.manifest_id); if (v) { // serve a fully-held mota (own fw / folder / completed fetch) + if (idx >= v->m.block_count || recentlyServed(idx)) return; // another holder just broadcast it — don't dup 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 >= v->m.block_count) break; - if (recentlyServed(idx)) continue; // another holder just broadcast it — don't duplicate - uint32_t off = idx * bs; - 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; } - emitBlockData(v->m.merkle_root, idx, data, blen); - } + uint32_t off = idx * bs; + 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)) return; data = blk; } + else { data = v->m.payload + off; } + emitBlockData(v->m.merkle_root, idx, data, blen, rq.want_mask); return; } - // Partial re-serve (swarm DURING the transfer): we're fetching this mid and already hold some of these - // blocks — serve their DATA (not proofs; we may lack sibling leaves) from our staging store, so peers can - // source from us, not only the origin. Reactive + lowest-priority, so real traffic is never impacted. + // Partial re-serve (swarm DURING the transfer): we're fetching this mid and already hold this block — serve + // its requested DATA (not proofs; we may lack sibling leaves) from our staging store, so peers can source + // from us, not only the origin. Reactive + lowest-priority, so real traffic is never impacted. if (_fetch && _fstate == FETCHING && memcmp(rq.manifest_id, _fid, 4) == 0) { - for (uint32_t k = 0; k < rq.count; k++) { - uint32_t idx = rq.start_block + k; - if (idx >= _fbc) break; - if (!blockPresent(idx) || recentlyServed(idx)) continue; - uint8_t blk[OTA_MAX_BLOCK]; - uint32_t blen = blockLen(idx); - if (!_fetch->read(_fpoff + idx * _fbs, blk, blen)) continue; - emitBlockData(_fid, idx, blk, blen); - } + if (idx >= _fbc || !blockPresent(idx) || recentlyServed(idx)) return; + uint8_t blk[OTA_MAX_BLOCK]; + uint32_t blen = blockLen(idx); + if (!_fetch->read(_fpoff + idx * _fbs, blk, blen)) return; + emitBlockData(_fid, idx, blk, blen, rq.want_mask); } } @@ -473,15 +502,18 @@ void OtaManager::armFirstReqHold() { } // 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 OtaManager::startFetch(const uint8_t* mid, uint32_t target, bool validate) { (void)target; - if (!_fetch || _fstate == FETCHING || _fstate == WANT_MANIFEST || _fstate == PAUSED) return; - if (resumeStaged(mid)) return; // resume a partial container left in flash + if (!_fetch || _fstate == FETCHING || _fstate == WANT_MANIFEST || _fstate == WANT_LEAVES || _fstate == PAUSED) return; + _validate = validate; // motatool folder-capture warm-start (seed leaf-diff) + // A validate pull is a FRESH seed capture, not a resume: the store already holds the seed's payload (not a + // real partial), so never adopt it via resumeStaged — always re-begin and run the manifest→leaves→diff. + if (!validate && resumeStaged(mid)) return; // (non-validate) resume a partial container left in flash memcpy(_fid, mid, 4); seedBlockRng(); // per-node block-pick/jitter sequence (distinct per node) _fstate = WANT_MANIFEST; - _mf_total = 0; _mf_mask = 0; _mf_len = 0; _mf_retries = 0; // fresh manifest reassembly - GetManifestMsg gm; memcpy(gm.manifest_id, _fid, 4); + _mf_total = 0; _mf_mask = 0; _mf_len = 0; _mf_retries = 0; _loop_last_mfmask = 0; // fresh manifest reassembly + GetManifestMsg gm; memcpy(gm.manifest_id, _fid, 4); gm.want_mask = 0xFFFF; // first ask: send all fragments uint8_t b[16]; emit(b, encode_get_manifest(b, sizeof(b), gm), false); } @@ -537,14 +569,95 @@ void OtaManager::handleManifest(const uint8_t* m, uint16_t n) { _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; + _ftotal = total; _have = 0; clearReassembly(); // fresh transfer: drop any prior per-block state _loop_last_have = 0; _loop_last_mask = 0; if (_rng == 0) seedBlockRng(); + // Warm-start (folder-capture): if requested and the image is small enough for the fixed leaves bitmap, + // bulk-fetch the target leaves and diff the seed already staged in the store; else just transfer normally. + if (_validate && beginLeafDiff()) { + OTA_DBG("OTA: WANT_LEAVES bc=%u (seed leaf-diff)\n", (unsigned)bc); + return; + } + _fstate = FETCHING; armFirstReqHold(); OTA_DBG("OTA: FETCHING bc=%u bs=%u total=%u\n", (unsigned)bc, (unsigned)bs, (unsigned)total); } +// --- leaf-diff warm-start (fetcher side; motatool folder-capture only) ----------------------------- + +void OtaManager::freeLeaves() { + if (_leaves_buf) { free(_leaves_buf); _leaves_buf = nullptr; } + _lv_total = 0; _lv_mask = 0; _diffing = false; _diff_idx = 0; +} + +// Enter WANT_LEAVES: allocate the target-leaves buffer and request them (bitmap-fragmented). Returns false +// — caller falls back to a normal full fetch — if the image is too big for the fixed uint16 leaves bitmap or +// the heap allocation fails. Called from handleManifest once geometry is known. +bool OtaManager::beginLeafDiff() { + freeLeaves(); + if (_fbc == 0 || _fbc > OTA_DIFF_MAX_BLOCKS) return false; // must fit the fixed want_mask (16 fragments) + _leaves_buf = (uint8_t*)malloc((size_t)_fbc * 4); + if (!_leaves_buf) return false; + _lv_total = 0; _lv_mask = 0; _lv_retries = 0; _loop_last_lvmask = 0; + _fstate = WANT_LEAVES; + GetLeavesMsg gl; memcpy(gl.manifest_id, _fid, 4); gl.want_mask = 0xFFFF; // first ask: all fragments + uint8_t b[16]; + emit(b, encode_get_leaves(b, sizeof(b), gl), false); + return true; +} + +// Reassemble the target leaves[] (like the manifest), authenticate them against _froot, then diff the seed. +void OtaManager::handleLeaves(const uint8_t* m, uint16_t n) { + LeavesMsg lv; + if (!decode_leaves(m, n, lv) || !_fetch || !_leaves_buf) return; + if (_fstate != WANT_LEAVES || memcmp(lv.manifest_id, _fid, 4) != 0) return; + uint32_t leaves_len = _fbc * 4; + uint8_t ftotal = (uint8_t)((leaves_len + OTA_LEAVES_FRAG - 1) / OTA_LEAVES_FRAG); if (ftotal == 0) ftotal = 1; + if (lv.frag_total != ftotal || lv.frag_idx >= ftotal) return; + uint32_t foff = (uint32_t)lv.frag_idx * OTA_LEAVES_FRAG; + if (foff + lv.len > leaves_len) return; // slice out of range + if (lv.frag_total != _lv_total) { _lv_total = lv.frag_total; _lv_mask = 0; } // (re)start + memcpy(_leaves_buf + foff, lv.bytes, lv.len); + _lv_mask |= (uint16_t)(1u << lv.frag_idx); + OTA_DBG("OTA: LEAVES rx frag=%u/%u len=%u mask=%04x\n", + (unsigned)lv.frag_idx, (unsigned)lv.frag_total, (unsigned)lv.len, (unsigned)_lv_mask); + uint16_t full = (ftotal >= 16) ? 0xFFFF : (uint16_t)((1u << ftotal) - 1); + if (_lv_mask != full) return; // wait for every fragment + uint8_t root[4]; merkle_root(root, _leaves_buf, _fbc); // authenticate the leaves against the manifest root + if (memcmp(root, _froot, 4) != 0) { // can't trust them -> just fetch normally + OTA_DBG("OTA: leaves root mismatch -> full fetch\n"); + freeLeaves(); _fstate = FETCHING; armFirstReqHold(); requestMissing(); return; + } + // leaves authenticated -> diff the seed against them a batch at a time in loop() (559 store reads must not + // block the mesh loop / starve the radio / trip the watchdog). Stay WANT_LEAVES; diffStep() drives it. + _diffing = true; _diff_idx = 0; + OTA_DBG("OTA: leaves ok (root match); diffing seed...\n"); +} + +// Diff up to OTA_DIFF_BATCH seed blocks per call: every block whose seed bytes (already staged in the store +// payload) hash to the authenticated target leaf is marked present; the rest stay missing and are pulled over +// LoRa. When all blocks are diffed, continue as a normal FETCHING of the holes (or finalize if none differ). +void OtaManager::diffStep() { + uint8_t blk[OTA_MAX_BLOCK]; + for (uint32_t k = 0; k < OTA_DIFF_BATCH && _diff_idx < _fbc; k++, _diff_idx++) { + uint32_t blen = blockLen(_diff_idx); + if (!_fetch->read(_fpoff + _diff_idx * _fbs, blk, blen)) continue; // read fail -> leave missing + uint8_t leaf[4]; merkle_leaf(leaf, blk, blen); + if (memcmp(leaf, _leaves_buf + (size_t)_diff_idx * 4, 4) == 0 && // seed block == target block + _fetch->write(_floff + _diff_idx * 4, _leaves_buf + (size_t)_diff_idx * 4, 4)) + _have++; // mark present (payload already seeded) + } + if (_diff_idx < _fbc) return; // more to diff on the next tick + OTA_DBG("OTA: leaf-diff %u/%u already valid; fetching the rest\n", (unsigned)_have, (unsigned)_fbc); + freeLeaves(); // clears _diffing + frees the buffer + if (_have >= _fbc) { // seed covered the whole image + _fstate = COMPLETE; _fetch->finalize(); serveFetched(); + return; + } + _fstate = FETCHING; armFirstReqHold(); 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 @@ -687,11 +800,19 @@ void OtaManager::requestMissing() { uint32_t start = pickMissingBlock(); if (start >= _fbc) return; _req_start = start; _req_count = 1; + // Ask only for fragments we still lack: the holes of the in-flight partial block (_reasm_need & ~mask), + // or all fragments of a fresh block. A lost fragment then costs one fragment to re-fetch, not the whole + // block — and the re-REQ can't collide with a multi-fragment burst (half-duplex) as before. + uint32_t nf = (blockLen(start) + OTA_FRAG_DATA - 1) / OTA_FRAG_DATA; + uint16_t need = frag_full_mask(nf); + uint16_t have = (start == _reasm_block) ? _reasm_mask : 0; + uint16_t want = (uint16_t)(need & ~have); + if (want == 0) want = need; // safety: never send an empty request ReqMsg rq; memcpy(rq.manifest_id, _fid, 4); - rq.start_block = (uint16_t)start; rq.count = 1; + rq.block_idx = (uint16_t)start; rq.want_mask = want; uint8_t b[16]; - OTA_DBG("OTA: REQ block=%u (have=%u/%u mask=%04x)\n", - (unsigned)start, (unsigned)_have, (unsigned)_fbc, (unsigned)_reasm_mask); + OTA_DBG("OTA: REQ block=%u want=%04x (have=%u/%u mask=%04x)\n", + (unsigned)start, (unsigned)want, (unsigned)_have, (unsigned)_fbc, (unsigned)_reasm_mask); emit(b, encode_req(b, sizeof(b), rq), false); } @@ -736,7 +857,7 @@ void OtaManager::noteOverheardReq(const uint8_t* m, uint16_t n) { if (_fstate != FETCHING) return; ReqMsg rq; if (!decode_req(m, n, rq) || memcmp(rq.manifest_id, _fid, 4) != 0) return; - _peer_req_block = rq.start_block; + _peer_req_block = rq.block_idx; _peer_req_at = _now_ms; } @@ -747,12 +868,37 @@ void OtaManager::loop() { 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, but give up after a - // cap so an unreachable mid doesn't pin the single fetch slot (or emit) forever. - if (++_mf_retries > OTA_MANIFEST_MAX_RETRY) { _fstate = FAILED; return; } - GetManifestMsg gm; memcpy(gm.manifest_id, _fid, 4); - uint8_t b[16]; - emit(b, encode_get_manifest(b, sizeof(b), gm), false); + // Retry GET_MANIFEST ONLY when a tick passed with no new fragment — re-bursting every tick would congest + // the link and burn the retry cap while fragments are still arriving (mirrors FETCHING + WANT_LEAVES). + // Give up after a cap of stalled retries so an unreachable mid doesn't pin the single fetch slot forever. + if (_mf_mask == _loop_last_mfmask) { + if (++_mf_retries > OTA_MANIFEST_MAX_RETRY) { _fstate = FAILED; return; } + GetManifestMsg gm; memcpy(gm.manifest_id, _fid, 4); + // request only the manifest fragments still missing; 0xFFFF ("send all") until we know frag_total + gm.want_mask = (_mf_total > 0) ? (uint16_t)(frag_full_mask(_mf_total) & ~_mf_mask) : 0xFFFF; + if (gm.want_mask == 0) gm.want_mask = 0xFFFF; // safety: never send an empty request + uint8_t b[16]; + emit(b, encode_get_manifest(b, sizeof(b), gm), false); + } + _loop_last_mfmask = _mf_mask; + return; + } + if (_fstate == WANT_LEAVES) { + if (_diffing) { diffStep(); return; } // leaves in — diff the seed a batch at a time (non-blocking) + // Re-request ONLY when a whole tick passed with no new fragment — otherwise re-bursting the holes every + // tick congests the link (and burns the retry cap) while fragments are still streaming in. On a stall, + // ask for just the missing bitmap (anti-burst); give up (FAILED) after a cap of stalled retries. + if (_lv_mask == _loop_last_lvmask) { + if (++_lv_retries > OTA_LEAVES_MAX_RETRY) { freeLeaves(); _fstate = FAILED; return; } + GetLeavesMsg gl; memcpy(gl.manifest_id, _fid, 4); + gl.want_mask = (_lv_total > 0) ? (uint16_t)(frag_full_mask(_lv_total) & ~_lv_mask) : 0xFFFF; + if (gl.want_mask == 0) gl.want_mask = 0xFFFF; // safety: never send an empty request + OTA_DBG("OTA: GET_LEAVES retry=%u want=%04x (mask=%04x/%u)\n", + (unsigned)_lv_retries, (unsigned)gl.want_mask, (unsigned)_lv_mask, (unsigned)_lv_total); + uint8_t b[16]; + emit(b, encode_get_leaves(b, sizeof(b), gl), false); + } + _loop_last_lvmask = _lv_mask; return; } if (_fstate != FETCHING) return; @@ -773,6 +919,8 @@ void OtaManager::on_message(const uint8_t* msg, uint16_t len) { case OTA_HAVE: handleHave(msg, len); break; case OTA_GET_MANIFEST: handleGetManifest(msg, len); break; case OTA_MANIFEST: handleManifest(msg, len); break; + case OTA_GET_LEAVES: handleGetLeaves(msg, len); break; + case OTA_LEAVES: handleLeaves(msg, len); break; case OTA_REQ: noteOverheardReq(msg, len); handleReq(msg, len); break; case OTA_DATA: handleData(msg, len); noteOverheardData(msg, len); break; case OTA_REQ_PROOF: handleReqProof(msg, len); break; diff --git a/src/helpers/ota/OtaManager.h b/src/helpers/ota/OtaManager.h index 8dfe3cbc95..295f5d2846 100644 --- a/src/helpers/ota/OtaManager.h +++ b/src/helpers/ota/OtaManager.h @@ -52,6 +52,24 @@ typedef bool (*ServeReadFn)(void* ctx, uint32_t off, uint8_t* buf, uint32_t len) #ifndef OTA_MANIFEST_MAX_RETRY #define OTA_MANIFEST_MAX_RETRY 20 // give up (FAILED) after this many GET_MANIFEST retries — frees the slot #endif +// Leaf-diff warm-start (motatool folder-capture only): bulk-fetch the target's leaves[], diff a seed build +// locally, pull DATA only for mismatches. OTA_LEAVES is bitmap-fragmented like OTA_MANIFEST so a want_mask +// retry never re-bursts (anti-deadlock). The mask is a fixed uint16, so leaves are capped at MAXFRAG frags. +#ifndef OTA_LEAVES_FRAG +#define OTA_LEAVES_FRAG 176 // leaf bytes per OTA_LEAVES fragment (<= MAX_PACKET_PAYLOAD - 7 header) = 44 leaves +#endif +#ifndef OTA_LEAVES_MAXFRAG +#define OTA_LEAVES_MAXFRAG 16 // fixed want_mask width (uint16) -> at most 16 leaf fragments +#endif +#ifndef OTA_DIFF_MAX_BLOCKS +#define OTA_DIFF_MAX_BLOCKS 704 // = MAXFRAG*(OTA_LEAVES_FRAG/4); warm-start unavailable above this (full fetch) +#endif +#ifndef OTA_LEAVES_MAX_RETRY +#define OTA_LEAVES_MAX_RETRY 20 // give up (FAILED) after this many GET_LEAVES retries +#endif +#ifndef OTA_DIFF_BATCH +#define OTA_DIFF_BATCH 48 // seed blocks diffed per loop tick (bounded so the diff never blocks the +#endif // main loop long enough to starve the radio / trip the watchdog) #ifndef OTA_MAX_SOURCES #define OTA_MAX_SOURCES 12 // heard OTA sources (beacon senders) tracked (LRU); ~12 B each #endif @@ -99,7 +117,7 @@ class OtaManager { // PAUSED: a folder-destination write failed mid-transfer (the seeder link dropped). Progress is held on // the host; the manager stops requesting and does NOT fall back to RAM/flash. resumeStaged() (called on // reconnect) re-STATs the host file, recomputes which blocks are missing, and resumes. - enum FetchState : uint8_t { IDLE, WANT_MANIFEST, FETCHING, COMPLETE, FAILED, PAUSED }; + enum FetchState : uint8_t { IDLE, WANT_MANIFEST, WANT_LEAVES, FETCHING, COMPLETE, FAILED, PAUSED }; // Sentinel for "no block" in the reassembly / peer-REQ / recently-served slots (a real block index is // a small uint16, so 0xFFFFFFFF is never valid). @@ -188,7 +206,12 @@ class OtaManager { // 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); } + // `validate` enables the motatool folder-capture warm-start: bulk-fetch the target leaves, diff a seed + // build already staged in the destination, and pull DATA only for the differing blocks. Ignored unless a + // seed is present (a plain fetch just re-transfers everything). Normal P2P pulls pass false. + void pull(const uint8_t* mid, uint32_t target, bool validate = false) { + want(target); want_mid(mid); startFetch(mid, target, validate); + } // 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). @@ -240,7 +263,8 @@ class OtaManager { _fstate = IDLE; _have = 0; _req_count = 0; _mf_retries = 0; clearReassembly(); _loop_last_have = 0; _loop_last_mask = 0; - _mf_total = 0; _mf_mask = 0; _mf_len = 0; + _mf_total = 0; _mf_mask = 0; _mf_len = 0; _loop_last_mfmask = 0; + freeLeaves(); _validate = false; _lv_retries = 0; _loop_last_lvmask = 0; unserveFetched(); } @@ -271,11 +295,16 @@ class OtaManager { 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 handleGetLeaves(const uint8_t* m, uint16_t n); // serve: send the requested leaf fragments + void handleLeaves(const uint8_t* m, uint16_t n); // fetcher (validate): reassemble the target leaves + bool beginLeafDiff(); // enter WANT_LEAVES if a seed diff is viable + void diffStep(); // diff one batch of seed blocks per loop tick + void freeLeaves(); // release the heap leaves buffer + diff state 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 + void startFetch(const uint8_t* mid, uint32_t target, bool validate = false); // begin/resume a fetch bool wantRow(const uint8_t* mid, uint32_t target, uint8_t codec, uint8_t flags) const; // fetch this row? void noteOverheardReq(const uint8_t* m, uint16_t n); // observe a peer's OTA_REQ (swarm de-dup) uint32_t rngNext() { _rng = _rng * 1664525u + 1013904223u; return _rng; } // per-node LCG (block pick/jitter) @@ -290,7 +319,8 @@ class OtaManager { static bool srcReadTramp(void* c, uint32_t off, uint8_t* buf, uint32_t len); // source payload reader void serveFetched(); // after COMPLETE: re-seed the staged mota (epidemic) void unserveFetched(); // stop re-seeding (store about to be overwritten) - void emitBlockData(const uint8_t* mid, uint32_t idx, const uint8_t* data, uint32_t blen); // DATA fragments + void emitBlockData(const uint8_t* mid, uint32_t idx, const uint8_t* data, uint32_t blen, + uint16_t want_mask); // emit only the requested DATA fragments of one block bool recentlyServed(uint32_t blk) const; // a peer just broadcast this block's DATA? void noteOverheardData(const uint8_t* m, uint16_t n); // remember overheard DATA (serve de-dup) void sendQuery(const uint8_t* seeder, const uint8_t* digest, uint32_t filter_target); // ask a source for its catalog @@ -361,8 +391,19 @@ class OtaManager { uint16_t _mf_retries = 0; // GET_MANIFEST retries while WANT_MANIFEST (give up after a cap) uint8_t _mf_total = 0; // frag_total of the manifest being reassembled (0 = none) uint16_t _mf_mask = 0; // received manifest-fragment bitmap + uint16_t _loop_last_mfmask = 0; // manifest bitmap last loop tick (retry only on no-progress) uint32_t _mf_len = 0; // assembled manifest length (set by the last fragment) + // leaf-diff warm-start (motatool folder-capture only; buffer is heap-allocated only while WANT_LEAVES) + bool _validate = false; // this fetch should try the seed leaf-diff before transferring + uint8_t* _leaves_buf = nullptr; // target leaves[] (bc*4), malloc'd on WANT_LEAVES, freed after diff + uint8_t _lv_total = 0; // frag_total of the leaves reply being reassembled (0 = none) + uint16_t _lv_mask = 0; // received leaves-fragment bitmap + uint16_t _lv_retries = 0; // GET_LEAVES *stalled* retries while WANT_LEAVES + uint16_t _loop_last_lvmask = 0; // leaves bitmap last loop tick (retry only on no-progress) + bool _diffing = false; // leaves are in; diffing the seed a batch per tick + uint32_t _diff_idx = 0; // next block index to diff against the seed + // 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]; diff --git a/src/helpers/ota/OtaProtocol.cpp b/src/helpers/ota/OtaProtocol.cpp index 50e6147668..28091cc676 100644 --- a/src/helpers/ota/OtaProtocol.cpp +++ b/src/helpers/ota/OtaProtocol.cpp @@ -64,12 +64,13 @@ 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) { - W w(buf, cap); w.u8(OTA_GET_MANIFEST); w.raw(m.manifest_id, 4); + W w(buf, cap); w.u8(OTA_GET_MANIFEST); w.raw(m.manifest_id, 4); w.u16(m.want_mask); return w.ok ? w.n : 0; } bool decode_get_manifest(const uint8_t* buf, uint16_t len, GetManifestMsg& m) { R r(buf, len); if (r.u8() != OTA_GET_MANIFEST) return false; const uint8_t* id = r.raw(4); if (id) memcpy(m.manifest_id, id, 4); + m.want_mask = r.u16(); return r.ok; } @@ -87,13 +88,13 @@ bool decode_manifest(const uint8_t* buf, uint16_t len, ManifestMsg& m) { } uint16_t encode_req(uint8_t* buf, uint16_t cap, const ReqMsg& m) { - W w(buf, cap); w.u8(OTA_REQ); w.raw(m.manifest_id, 4); w.u16(m.start_block); w.u8(m.count); + W w(buf, cap); w.u8(OTA_REQ); w.raw(m.manifest_id, 4); w.u16(m.block_idx); w.u16(m.want_mask); return w.ok ? w.n : 0; } bool decode_req(const uint8_t* buf, uint16_t len, ReqMsg& m) { R r(buf, len); if (r.u8() != OTA_REQ) return false; const uint8_t* id = r.raw(4); if (id) memcpy(m.manifest_id, id, 4); - m.start_block = r.u16(); m.count = r.u8(); + m.block_idx = r.u16(); m.want_mask = r.u16(); return r.ok; } @@ -133,5 +134,29 @@ bool decode_proof(const uint8_t* buf, uint16_t len, ProofMsg& m) { return r.ok; } +uint16_t encode_get_leaves(uint8_t* buf, uint16_t cap, const GetLeavesMsg& m) { + W w(buf, cap); w.u8(OTA_GET_LEAVES); w.raw(m.manifest_id, 4); w.u16(m.want_mask); + return w.ok ? w.n : 0; +} +bool decode_get_leaves(const uint8_t* buf, uint16_t len, GetLeavesMsg& m) { + R r(buf, len); if (r.u8() != OTA_GET_LEAVES) return false; + const uint8_t* id = r.raw(4); if (id) memcpy(m.manifest_id, id, 4); + m.want_mask = r.u16(); + return r.ok; +} + +uint16_t encode_leaves(uint8_t* buf, uint16_t cap, const LeavesMsg& m) { + W w(buf, cap); w.u8(OTA_LEAVES); w.raw(m.manifest_id, 4); w.u8(m.frag_idx); w.u8(m.frag_total); + w.raw(m.bytes, m.len); + return w.ok ? w.n : 0; +} +bool decode_leaves(const uint8_t* buf, uint16_t len, LeavesMsg& m) { + R r(buf, len); if (r.u8() != OTA_LEAVES) return false; + const uint8_t* id = r.raw(4); if (id) memcpy(m.manifest_id, id, 4); + m.frag_idx = r.u8(); m.frag_total = r.u8(); + m.len = r.remaining(); m.bytes = r.raw(m.len); + return r.ok; +} + } // namespace ota } // namespace mesh diff --git a/src/helpers/ota/OtaProtocol.h b/src/helpers/ota/OtaProtocol.h index d8385353a1..a847ebb7a3 100644 --- a/src/helpers/ota/OtaProtocol.h +++ b/src/helpers/ota/OtaProtocol.h @@ -46,7 +46,10 @@ struct HaveMsg { static const uint8_t OTA_HAVE_ROW_BYTES = 16; // mid4 + target4 + fwver4 + codec1 + flags1 + have_count2 // ---- OTA_GET_MANIFEST: request the manifest for a content id (direct) ---- -struct GetManifestMsg { uint8_t manifest_id[4]; }; +// body: manifest_id(4) want_mask(2). want_mask bit k = "send manifest fragment k"; 0xFFFF = "send all" +// (used on the first ask, before frag_total is known). A retry requests only the still-missing fragments, +// so a lost manifest fragment doesn't force the whole manifest to be re-bursted (same rationale as OTA_REQ). +struct GetManifestMsg { uint8_t manifest_id[4]; uint16_t want_mask; }; // ---- OTA_MANIFEST: the manifest-minus-leaves[], fragmented (direct) ---- // body: manifest_id(4) frag_idx(1) frag_total(1) bytes[] @@ -56,8 +59,12 @@ struct ManifestMsg { const uint8_t* bytes; uint16_t len; }; -// ---- OTA_REQ: request a window of blocks (direct) ---- -struct ReqMsg { uint8_t manifest_id[4]; uint16_t start_block; uint8_t count; }; +// ---- OTA_REQ: request specific fragments of ONE block (direct) ---- +// body: manifest_id(4) block_idx(2) want_mask(2). want_mask bit k = "send fragment k" (the slice at byte +// offset k*OTA_FRAG_DATA). A fetcher requests the full mask ((1< target(BS * BC), seed(BS * BC); + for (uint32_t i = 0; i < target.size(); i++) target[i] = seed[i] = (uint8_t)(i * 7 + 3); + seed[1 * BS + 5] ^= 0xFF; // blocks 1 and 3 differ in the seed (a non-deterministic-rebuild style diff) + seed[3 * BS + 0] ^= 0x01; + + // target leaves + root (what the device receives over OTA_LEAVES + the manifest merkle_root) + uint8_t tleaves[BC * 4], troot[4]; + for (uint32_t i = 0; i < BC; i++) merkle_leaf(tleaves + i * 4, target.data() + i * BS, BS); + merkle_root(troot, tleaves, BC); + + // authenticate the fetched leaves: recomputing the root from them must equal the manifest root + uint8_t chk[4]; merkle_root(chk, tleaves, BC); + EXPECT_EQ(0, memcmp(chk, troot, 4)); + + // diff: a seed block is kept iff its leaf equals the (authenticated) target leaf + int nmiss = 0; bool miss[BC] = {false}; + for (uint32_t i = 0; i < BC; i++) { + uint8_t sl[4]; merkle_leaf(sl, seed.data() + i * BS, BS); + if (memcmp(sl, tleaves + i * 4, 4) != 0) { miss[i] = true; nmiss++; } + } + EXPECT_EQ(nmiss, 2); + EXPECT_TRUE(miss[1]); EXPECT_TRUE(miss[3]); + EXPECT_FALSE(miss[0]); EXPECT_FALSE(miss[2]); EXPECT_FALSE(miss[4]); +} + int main(int argc, char** argv) { ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); diff --git a/tools/motatool/README.md b/tools/motatool/README.md index 80276d40fc..9f90e122a4 100644 --- a/tools/motatool/README.md +++ b/tools/motatool/README.md @@ -74,8 +74,18 @@ $MT inspect ./motas/RAK4631_04D413FD_v1.16.0_full_ABCD1234.mota # 5. serve a folder to a node (recursive; skips non-.mota; warns on corrupt) $MT serve --dir ./motas --serial /dev/ttyUSB0 --baud 115200 -v # over USB serial $MT serve --dir ./motas --tcp 192.168.1.50:5001 -v # …or over WiFi (ESP32 companion, port 5001) + +# 5b. warm-start a capture: seed the pull with a SIMILAR build, then `ota pull <#> folder validate` on the +# node pulls only the blocks that differ (fast capture of a non-deterministic rebuild of the same fw) +$MT serve --dir ./captured --tcp 192.168.1.50:5001 --seed ./similar_build.mota -v ``` +`serve` doubles as the **pull-to-folder storage**: a node's `ota pull <#> folder` captures the firmware it's +fetching off-mesh into `--dir` as `.mota`. With `--seed `, that seed's payload is staged +into each capture so a `ota pull <#> folder validate` on the node bulk-fetches the target's merkle leaves, +keeps every block the seed already matches, and transfers over LoRa only the differing ones — a byte-exact, +verifiable capture in seconds instead of a full-image download (see [`../../docs/ota_protocol.md`](../../docs/ota_protocol.md) §8, "warm-start"). + `build` notes: - **Identity is self-described.** A firmware built by the project's `pio_endf.py` carries its `target_id`/`fw_version`/`hw_id` in its 56-byte `EndF` trailer; `build` reads them, so diff --git a/tools/motatool/src/main.cpp b/tools/motatool/src/main.cpp index c367c48712..042717c8f2 100644 --- a/tools/motatool/src/main.cpp +++ b/tools/motatool/src/main.cpp @@ -214,6 +214,9 @@ static void help_serve() { " --baud serial speed (default 115200; --serial only).\n" " --no-recursive serve only the top folder; don't descend into sub-folders.\n" " --no-enable (--serial only) don't auto-send 'ota folder on'/'off' on the node's CLI.\n" +" --seed warm-start: stage this (similar) build's payload into each captured .part so an\n" +" `ota pull <#> folder validate` diffs it against the target's merkle leaves and pulls\n" +" only the differing blocks — capture a non-deterministic rebuild in seconds.\n" " -v, --verbose log each request the node makes (COUNT / DESCRIBE / READ).\n" "\n" "Leave it running; press Ctrl-C to stop. Over serial it shares the USB cable with the node's text\n" @@ -500,6 +503,22 @@ static int cmd_serve(const Args& a) { std::cout << "serving on " << target << " — Ctrl-C to stop\n"; SeederCore core(folder, a.get("dir")); // same folder doubles as "pull to folder" storage + // Optional warm-start seed: a *similar* build's .mota. Its payload is dropped into each captured `.part` + // so a `ota pull … folder validate` diffs it against the target's leaves and pulls only the differing + // blocks (huge airtime saving when capturing a non-deterministic rebuild of the same firmware). + if (a.has("seed")) { + std::vector sb; + if (!read_file(a.get("seed"), sb)) { std::cerr << "error: cannot read seed " << a.get("seed") << "\n"; return 1; } + Manifest sm; std::string e = parse(sb, sm); + if (!e.empty()) { std::cerr << "error: bad seed .mota: " << e << "\n"; return 1; } + uint32_t poff = sm.payload_off(); + if ((uint64_t)poff + sm.payload_size > sb.size()) { std::cerr << "error: seed payload out of range\n"; return 1; } + core.set_seed(std::vector(sb.begin() + poff, sb.begin() + poff + sm.payload_size), sm.block_count); + std::cout << "seed: " << fs::path(a.get("seed")).filename().string() + << " mid=" << to_hex(sm.merkle_root.data(), 4) + << " blocks=" << sm.block_count << " payload=" << sm.payload_size + << " (staged into each capture for `ota pull … validate`)\n"; + } serve_loop(*t, core, verbose, [](const std::string& l) { std::cout << " [dev] " << l << "\n"; }, &g_stop); diff --git a/tools/motatool/src/serve.cpp b/tools/motatool/src/serve.cpp index 0331e4c6b7..4430217ac4 100644 --- a/tools/motatool/src/serve.cpp +++ b/tools/motatool/src/serve.cpp @@ -120,6 +120,15 @@ bool SeederCore::handle(uint8_t op, const uint8_t* args, size_t arglen, uint8_t ff[4096]; memset(ff, 0xFF, sizeof ff); uint32_t left = total; bool ok = true; while (left && ok) { uint32_t c = left < sizeof ff ? left : (uint32_t)sizeof ff; ok = ::write(fd, ff, c) == (ssize_t)c; left -= c; } + // Warm-start seed: drop a similar build's payload into the payload region so the device's leaf-diff + // (`ota pull … validate`) keeps the blocks that match the target and pulls only the differing ones. + // Header + leaves stay 0xFF (the device writes the real manifest; leaves mark "present" as they pass). + if (ok && !seed_payload_.empty()) { + uint32_t payload_off = 8 + MOTA_MFL + seed_bc_ * 4; + uint32_t n = (uint32_t)seed_payload_.size(); + if (payload_off + n > total) n = (payload_off < total) ? total - payload_off : 0; // clamp to the file + if (n) ok = ::pwrite(fd, seed_payload_.data(), n, payload_off) == (ssize_t)n; + } ::close(fd); status = ok ? MS_STATUS_OK : MS_STATUS_ERR; return true; diff --git a/tools/motatool/src/serve.h b/tools/motatool/src/serve.h index ac10fb5e74..f551da9d64 100644 --- a/tools/motatool/src/serve.h +++ b/tools/motatool/src/serve.h @@ -41,10 +41,18 @@ class SeederCore { bool handle(uint8_t op, const uint8_t* args, size_t arglen, uint8_t& status, std::vector& payload) const; static std::array describe(const ServedMota& s); + // Warm-start seed (motatool folder-capture): a *similar* build's payload. On OP_BEGIN, after the 0xFF-fill, + // the seed payload is written into the new `.part` at its payload region so the device can validate matching + // blocks against the target's merkle leaves and pull DATA only for the ones that differ (`ota pull … validate`). + void set_seed(std::vector payload, uint32_t block_count) { + seed_payload_ = std::move(payload); seed_bc_ = block_count; + } private: std::string store_path(const uint8_t mid[4], bool part) const; // /.mota[.part] const Folder& folder_; std::string store_dir_; + std::vector seed_payload_; // similar-build payload injected on OP_BEGIN (empty = no seed) + uint32_t seed_bc_ = 0; // seed's block_count (fixes payload_off = 8 + MOTA_MFL + bc*4) }; // A bidirectional byte link (the serial seeder needs this; BLE would reuse SeederCore directly). From c33077af5ac2d193b6430d637d4661787c531106 Mon Sep 17 00:00:00 2001 From: Valentin Kivachuk Burda Date: Fri, 3 Jul 2026 08:02:37 +0200 Subject: [PATCH 22/59] =?UTF-8?q?ota:=20admin=20OTA=20statistics=20?= =?UTF-8?q?=E2=80=94=20`ota=20stats`=20reply?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an admin-only `ota stats` reply: one dense line with the running firmware's merkle content-id (mid) AND its EndF body_hash (only body_hash was surfaced before), version, served-set count + digest, live fetch state/progress, and policy — snprintf-bounded to the 160-byte reply. The remote CLI path is already admin-gated, so it's admin-only over the mesh (send it from the app's repeater command screen, or the WiFi/serial OTA console). A new servedDigest() accessor exposes the beacon set-digest. HW-verified on RAK4631: reports the fw identity + live fetch state (incl. during a warm-start capture). (cherry picked from commit b159737edeed452826eaa627e7e3d5adb54dc40c) --- docs/ota_user_guide.md | 11 ++++++++ src/helpers/ota/OtaCli.cpp | 52 ++++++++++++++++++++++++++++++++++-- src/helpers/ota/OtaManager.h | 2 ++ 3 files changed, 63 insertions(+), 2 deletions(-) diff --git a/docs/ota_user_guide.md b/docs/ota_user_guide.md index 4d961d9001..1e74c3ec4c 100644 --- a/docs/ota_user_guide.md +++ b/docs/ota_user_guide.md @@ -47,6 +47,16 @@ ota status Shows your current firmware version, your node's update "target" (its hardware/role id), and whether a download is in progress. +For a denser admin view — your firmware's content id (`mid`) **and** its body hash, the fingerprint of the +set you're serving, live download progress, and the current policy — use: + +``` +ota stats +``` + +On a remote node this is **admin-only** (the remote command console requires the admin password) — send it +from the app's repeater command screen, or the WiFi/serial OTA console. + ### 2. Find updates available near me ``` @@ -229,6 +239,7 @@ that only contains what changed). You get them by: |---|---| | List all commands | `ota help` | | See my firmware + any download | `ota status` (or just `ota`) | +| Admin: ids/hashes + serving + policy | `ota stats` (admin-only remotely) | | Find updates nearby | `ota ls` | | Download update #1 | `ota get 1` | | Cancel a download | `ota cancel` | diff --git a/src/helpers/ota/OtaCli.cpp b/src/helpers/ota/OtaCli.cpp index 0441b0528a..039f10f1e0 100644 --- a/src/helpers/ota/OtaCli.cpp +++ b/src/helpers/ota/OtaCli.cpp @@ -52,6 +52,19 @@ static const char* state_word(OtaManager::FetchState s) { } } +// A compact one-word fetch state for the dense `ota stats` line (state_word's phrases are too long). +static const char* state_short(OtaManager::FetchState s) { + switch (s) { + case OtaManager::WANT_MANIFEST: return "manifest"; + case OtaManager::WANT_LEAVES: return "leaves"; + case OtaManager::FETCHING: return "dl"; + case OtaManager::COMPLETE: return "done"; + case OtaManager::FAILED: return "failed"; + case OtaManager::PAUSED: return "paused"; + default: return "idle"; + } +} + // Render the packed fw_version as "v1.2.3" (or "v1.2.3.4" when a prerelease byte is set). static void ver_str(char* out, size_t cap, uint32_t v) { FwVersion fw = FwVersion::unpack(v); @@ -97,8 +110,8 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board // ---- help: list the commands in plain words (aliases in parentheses) ---- if (is_cmd(a, "help|?|h", &rest)) { snprintf(reply, 160, - "OTA: status | ls=find updates | get <#>=download | install | cancel | announce | self | " - "folder | config | key. Try `ota ls`."); + "OTA: status | stats=admin ids/hashes | ls=find updates | get <#>=download | install | cancel | " + "announce | self | folder | config | key. Try `ota ls`."); // ---- inventory dashboard: running fw (self), the one fetch session, serving state ---- } else if (*a == 0 || is_cmd(a, "status|st", &rest)) { @@ -128,6 +141,41 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board c.bootloaderCaps().present ? "apply" : "NONE", ota_bootloader_last_rc()); #endif + // ---- admin OTA stats: crypto identities (our fw's content-id + body_hash), serving set, live fetch, + // policy — one dense line. The remote-admin CLI path is admin-gated, so this is admin-only over the + // mesh (send it from the app's repeater command screen, or the WiFi/serial OTA console). + } else if (is_cmd(a, "stats", &rest)) { + // our running fw's merkle content-id (mid) comes from the self serve entry; the EndF body_hash (fw + // identity, matched against a delta base) is separate — surface BOTH (only body_hash showed before). + const OtaManager::ServeEntry* self = nullptr; + for (uint8_t i = 0; i < c.manager.servedCount(); i++) { + const OtaManager::ServeEntry* e = c.manager.servedEntry(i); + if (e && e->is_self) { self = e; break; } + } + SelfFwInfo fi; bool sok = ota_self_firmware(fi); + char midhx[9], bodyhx[9], verbuf[20], dighx[9]; + if (self) mesh::Utils::toHex(midhx, self->mid, 4); else strcpy(midhx, "?"); + if (sok && fi.valid) mesh::Utils::toHex(bodyhx, fi.body_hash, 4); else strcpy(bodyhx, "?"); + if (self) ver_str(verbuf, sizeof verbuf, self->fw_version); else strcpy(verbuf, "v?"); + uint8_t dig[4]; c.manager.servedDigest(dig); mesh::Utils::toHex(dighx, dig, 4); + OtaManager::FetchState fs = c.manager.fetchState(); + char fbuf[72]; + if (fs == OtaManager::IDLE) { + strcpy(fbuf, "fetch idle"); + } else { + char fmid[9]; mesh::Utils::toHex(fmid, c.manager.fetchManifestId(), 4); + unsigned have = (unsigned)c.manager.blocksHave(), tot = (unsigned)c.manager.blocksTotal(); + unsigned pct = tot ? (unsigned)((uint64_t)have * 100 / tot) : 0; + unsigned age = c.session_started_ms ? (unsigned)((millis() - c.session_started_ms) / 1000) : 0; + snprintf(fbuf, sizeof fbuf, "fetch %s %u/%u %u%% id=%s %us", state_short(fs), have, tot, pct, fmid, age); + } + uint8_t af = c.manager.autofetch(); + snprintf(reply, 160, "OTA | fw %s id=%s body=%s %ub %uK | serv %u dg=%s | %s | af=%s hops=%u", + verbuf, midhx, bodyhx, (unsigned)(self ? self->have_count : 0), + (unsigned)((sok ? fi.image_len : 0) / 1024), (unsigned)c.manager.servedCount(), dighx, fbuf, + af == OtaManager::AUTOFETCH_ANY ? "any" : af == OtaManager::AUTOFETCH_SIGNED ? "signed" : "off", + (unsigned)c.manager.max_hops()); + // ---- what's available around me (catalogued from beacons + OTA_HAVE), best/most-recent first ---- } else if (is_cmd(a, "neighbors|nbrs|updates|ls|n", &rest)) { // Kick a fresh round of catalog queries (async — rows arrive over the next seconds); render what we diff --git a/src/helpers/ota/OtaManager.h b/src/helpers/ota/OtaManager.h index 295f5d2846..e3cab71b6e 100644 --- a/src/helpers/ota/OtaManager.h +++ b/src/helpers/ota/OtaManager.h @@ -174,6 +174,8 @@ class OtaManager { 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; } + // 4-byte fingerprint of our served set (== the set_digest carried in the beacon); for admin OTA stats. + void servedDigest(uint8_t out[4]) const { setDigest(out); } // 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. From 34853786384d78eff0f8a429b7cc68e86ba8bbd9 Mon Sep 17 00:00:00 2001 From: Valentin Kivachuk Burda Date: Fri, 3 Jul 2026 08:02:59 +0200 Subject: [PATCH 23/59] ota: stamp the real FIRMWARE_VERSION into the EndF trailer (was v0.0.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The EndF post-build stamper read the version via _cppdef('FIRMWARE_VERSION'), which only sees -D build flags — but MeshCore authors FIRMWARE_VERSION as a header #define in each example, so the stamper found nothing and defaulted fw_version to 0. Every .mota / OTA advert then reported v0.0.0 while `ver` (which reads the header at runtime) showed the real version. Read the header value as a fallback WITHOUT moving where MeshCore authors it: honor a -D override first (the header's #ifndef guard invites it, for release builds), else read the #define from the example this env builds (via build_src_filter), falling back to the repo-wide value when unambiguous. Purely additive to our own EndF tooling — no MeshCore source changed. HW-verified: RAK4631 now reports `fw v1.17.0` in ota stats/ls (was v0.0.0), matching `ver`. (cherry picked from commit 0319a81d6958a46dd8b4d2da3c4431df8ffafbc8) --- tools/mota/pio_endf.py | 49 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/tools/mota/pio_endf.py b/tools/mota/pio_endf.py index 61ad0e425a..1ff64d3ea9 100644 --- a/tools/mota/pio_endf.py +++ b/tools/mota/pio_endf.py @@ -52,14 +52,61 @@ def _cppdef(name): # value of a -D= return None +def _version_from_headers(): + """FIRMWARE_VERSION is a header ``#define`` in the example (upstream MeshCore convention), not a -D, so + _cppdef() can't see it and the EndF version would otherwise default to 0. Read it from the source WITHOUT + changing where MeshCore authors it: prefer the example this env actually builds (from build_src_filter), + else fall back to the repo-wide value when it's unambiguous. Purely additive — no MeshCore file changes, + and a -D override (checked first in _firmware_ident) still wins for release builds.""" + import re, glob + proj = env["PROJECT_DIR"] # noqa: F821 + pat = re.compile(r'#\s*define\s+FIRMWARE_VERSION\s+"([^"]+)"') + + def scan(paths): # {version_string: first_path_seen} + vals = {} + for p in paths: + try: + with open(p, encoding="utf-8", errors="ignore") as f: + for line in f: + mm = pat.search(line) + if mm: + vals.setdefault(mm.group(1), p) + except OSError: + pass + return vals + + # 1) restrict to the example(s) this env compiles (build_src_filter -> examples/) + srcf = "" + for getter in (lambda: env.GetProjectOption("build_src_filter", ""), # noqa: F821 + lambda: env.GetProjectOption("src_filter", ""), # noqa: F821 + lambda: env.get("SRC_FILTER", "")): # noqa: F821 + try: + v = getter() + srcf += " " + (" ".join(map(str, v)) if isinstance(v, (list, tuple)) else str(v)) + except Exception: + pass + ex_dirs = set(re.findall(r"examples[/\\]([A-Za-z0-9_]+)", srcf)) + hdrs = [] + for d in ex_dirs: + hdrs += glob.glob(os.path.join(proj, "examples", d, "*.h")) + vals = scan(hdrs) + if len(vals) == 1: + return next(iter(vals)) + # 2) fall back to the repo-wide value; use it only if all examples agree (they do today: v1.17.0) + vals = scan(glob.glob(os.path.join(proj, "examples", "*", "*.h"))) + return next(iter(vals)) if len(vals) == 1 else "" + + def _firmware_ident(): """Self-describing identity to embed in EndF (docs/ota_protocol.md §2): target_id is computed from the PlatformIO env name (so it's correct even without build.sh's -D MOTA_TARGET_ID), hw_id from MOTA_HW_ID, - fw_version parsed from FIRMWARE_VERSION.""" + fw_version from FIRMWARE_VERSION (a -D if set, else the example's header #define).""" import re target_id = ml.target_id_for_env(env["PIOENV"]) # noqa: F821 hw_id = (_cppdef("MOTA_HW_ID") or "").replace("\\", "").strip().strip('"').strip("'") ver_s = (_cppdef("FIRMWARE_VERSION") or "").replace("\\", "").strip().strip('"').strip("'") + if not ver_s: # not a -D -> read the header MeshCore ships + ver_s = _version_from_headers() m = re.search(r"(\d+)\.(\d+)(?:\.(\d+))?", ver_s) fw_version = ml.pack_version(f"{m.group(1)}.{m.group(2)}.{m.group(3) or 0}") if m else 0 return ml.FwIdent(fw_version=fw_version, target_id=target_id, hw_id=hw_id) From cf668879f6522316d528ed43dfbe82f4252153dc Mon Sep 17 00:00:00 2001 From: Valentin Kivachuk Burda Date: Fri, 3 Jul 2026 08:16:56 +0200 Subject: [PATCH 24/59] docs: clarify the warm-start capture workflow (--seed flag + validate switch) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spell out how the warm-start seed is actually applied (a user asked): the seed is the --seed given to `motatool serve`, NOT a file dropped into the --dir destination (which starts empty). motatool stamps that one seed into the fresh .part on every `… folder validate` begin, so it's always the named file — no guessing. `validate` is the switch (a plain folder pull fetches from 0); a re-pull re-begins fresh (never resumes a stale partial); a mismatched/absent seed just falls back to fetching those blocks over the radio (correct result, only slower). (cherry picked from commit cbdc629f20e4cb30ea3631b31bdb6ff5b358a9d1) --- docs/ota_user_guide.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/ota_user_guide.md b/docs/ota_user_guide.md index 1e74c3ec4c..10831ad6a9 100644 --- a/docs/ota_user_guide.md +++ b/docs/ota_user_guide.md @@ -106,6 +106,15 @@ otherwise have. (`ota get` is an alias.) every block your seed already matches, and pulls over the radio only the handful that actually differ — turning a ~30-minute capture into seconds. The result is still a byte-exact, verified copy of the target. +*Where the seed comes from:* it is the **`--seed ` you pass to `motatool serve`** — **not** a file you +drop into the capture (`--dir`) folder, which is only the destination and starts empty. There is exactly one +configured seed. When you run `... folder validate`, the node asks motatool to begin the capture and motatool +stamps that seed's payload into the fresh `.part` in the same step — so it is always the file you named, with +no guessing. **`validate` is the switch:** a plain `folder` pull ignores any seed and fetches from scratch; +re-running a `validate` pull re-begins fresh (it never resumes a stale partial). Nothing about the seed is +trusted — every kept block is checked against the target's own fingerprints, so a mismatched or missing seed +just means those blocks are fetched over the radio (correct result, only slower). + The node fetches in the background, **at low priority**, a piece at a time — possibly from several neighbours at once. Check progress with `ota status`. You can keep using your node normally meanwhile. From 61654659567cfb19534e311d8d24393dc98a32b2 Mon Sep 17 00:00:00 2001 From: Valentin Kivachuk Burda Date: Sat, 11 Jul 2026 14:42:12 +0200 Subject: [PATCH 25/59] variants: enable OTA on Heltec T096 (nRF52840, shared rak4631_hw recipe) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The T096 is an nRF52840 with the same S140 v6 SoftDevice and flash geometry as the RAK4631, so it can reuse the shared rak4631_hw OTA recipe (ENABLE_OTA + OtaFlashLayout_nrf52.h — APP_BASE 0x26000, staging ceiling 0xD4000, all static_asserts satisfied by the stock nrf52840_s140_v6[_extrafs] ldscripts). With the OTAFIX bootloader (oltaco PR #42 adds the T096 board) a node applies in place; without it the node still discovers/relays/serves OTA and merely fetch-refuses apply — so a T096 is a useful OTA relay/seeder regardless. MOTA_HW_ID='"Heltec_t096"' tags the EndF trailer so a .mota built for other hardware is refused. Propagates to every T096 role via ${Heltec_t096.*}. Verified: repeater + companion_radio_usb build clean, 14 OTA objects each, EndF stamped (hw='Heltec_t096', v1.17.0). (cherry picked from commit ee6e5487460a7a80703596d9c0328e08c6cfbb75) --- variants/heltec_t096/platformio.ini | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/variants/heltec_t096/platformio.ini b/variants/heltec_t096/platformio.ini index e820bf58d3..e6cc022841 100644 --- a/variants/heltec_t096/platformio.ini +++ b/variants/heltec_t096/platformio.ini @@ -1,9 +1,14 @@ [Heltec_t096] -extends = nrf52_base +; nRF52840 (same SoftDevice S140 v6 + flash geometry as the RAK4631), so it reuses the shared +; rak4631_hw OTA recipe (ENABLE_OTA + OtaFlashLayout_nrf52.h). With the OTAFIX bootloader (oltaco +; PR #42 adds the T096 board) it can apply in place; without it the node still discovers/relays/serves +; OTA and just fetch-refuses apply (graceful) — a T096 is a valuable OTA relay/seeder either way. +extends = rak4631_hw board = heltec_t096 board_build.ldscript = boards/nrf52840_s140_v6.ld -build_flags = ${nrf52_base.build_flags} +build_flags = ${rak4631_hw.build_flags} ${sensor_base.build_flags} + -D MOTA_HW_ID='"Heltec_t096"' ; OTA hardware tag (apply refuses a .mota built for other hardware) -I lib/nrf52/s140_nrf52_6.1.1_API/include -I lib/nrf52/s140_nrf52_6.1.1_API/include/nrf52 -I variants/heltec_t096 @@ -42,14 +47,14 @@ build_flags = ${nrf52_base.build_flags} -D PIN_BAT_CTL=47 -D DISPLAY_CLASS=ST7735Display -D DISPLAY_ROTATION=1 -build_src_filter = ${nrf52_base.build_src_filter} +build_src_filter = ${rak4631_hw.build_src_filter} + + +<../variants/heltec_t096> + + lib_deps = - ${nrf52_base.lib_deps} + ${rak4631_hw.lib_deps} ${sensor_base.lib_deps} adafruit/Adafruit ST7735 and ST7789 Library @ ^1.11.0 debug_tool = jlink From 54906db729c65577c26cc77d6333532cdce3b37a Mon Sep 17 00:00:00 2001 From: Valentin Kivachuk Burda Date: Sat, 11 Jul 2026 14:47:11 +0200 Subject: [PATCH 26/59] ota: extract the motatool CLI to its own repo (github.com/vk496/motatool) The host-side build/verify/inspect/serve CLI now lives as a standalone Rust project at https://github.com/vk496/motatool. Remove the in-tree C++ copy (tools/motatool/) and repoint the docs (ota_protocol.md, ota_user_guide.md, tools/mota/README.md) at the standalone repo. No cross-dependency either direction: MeshCore's firmware build never invoked motatool (only tools/mota/ Python glue runs in the build), and motatool depends only on the shared .mota wire spec in docs/ota_protocol.md. tools/mota/ (pio_endf.py EndF hook, motalib.py reference lib, gen_targets.py) is unchanged. (cherry picked from commit 872413231da469e0a2bd2fae9f12d13936dce4a2) --- docs/ota_protocol.md | 6 +- docs/ota_user_guide.md | 13 +- tools/mota/README.md | 8 +- tools/motatool/.gitignore | 1 - tools/motatool/CMakeLists.txt | 42 -- tools/motatool/README.md | 127 ------ tools/motatool/src/crypto.cpp | 61 --- tools/motatool/src/crypto.h | 28 -- tools/motatool/src/input.cpp | 102 ----- tools/motatool/src/input.h | 15 - tools/motatool/src/main.cpp | 545 ------------------------- tools/motatool/src/mota.cpp | 413 ------------------- tools/motatool/src/mota.h | 83 ---- tools/motatool/src/mota_format.h | 106 ----- tools/motatool/src/serve.cpp | 344 ---------------- tools/motatool/src/serve.h | 95 ----- tools/motatool/src/util.h | 77 ---- tools/motatool/tests/test_motatool.cpp | 334 --------------- 18 files changed, 14 insertions(+), 2386 deletions(-) delete mode 100644 tools/motatool/.gitignore delete mode 100644 tools/motatool/CMakeLists.txt delete mode 100644 tools/motatool/README.md delete mode 100644 tools/motatool/src/crypto.cpp delete mode 100644 tools/motatool/src/crypto.h delete mode 100644 tools/motatool/src/input.cpp delete mode 100644 tools/motatool/src/input.h delete mode 100644 tools/motatool/src/main.cpp delete mode 100644 tools/motatool/src/mota.cpp delete mode 100644 tools/motatool/src/mota.h delete mode 100644 tools/motatool/src/mota_format.h delete mode 100644 tools/motatool/src/serve.cpp delete mode 100644 tools/motatool/src/serve.h delete mode 100644 tools/motatool/src/util.h delete mode 100644 tools/motatool/tests/test_motatool.cpp diff --git a/docs/ota_protocol.md b/docs/ota_protocol.md index f173b9e32c..5f0b8bcc69 100644 --- a/docs/ota_protocol.md +++ b/docs/ota_protocol.md @@ -33,7 +33,7 @@ where a section names a source file, that file is the authoritative reference fo | Staging stores | `OtaStore.h`, `OtaStoreFlashNrf52.*`, `OtaStoreFlashEsp32.*` | | Apply | `OtaApply.*`, bootloader `Adafruit_nRF52_Bootloader_OTAFIX` | | Device glue (CLI/context) | `OtaCli.cpp`, `OtaContext.h` | -| Host tooling | `tools/motatool/` (C++ CLI: build/verify/inspect/serve); `tools/mota/` (Python reference lib `motalib.py` + build/test glue) | +| Host tooling | [`motatool`](https://github.com/vk496/motatool) (standalone Rust CLI: build/verify/inspect/serve); `tools/mota/` (Python reference lib `motalib.py` + build/test glue) | --- @@ -540,8 +540,8 @@ blocks) and streams payload blocks from the source on demand; proofs are generat A `MotaSource` is fed by a host that serves a folder over the device's **USB serial** (the same console the CLI uses — no extra hardware) or, on an ESP32 WiFi companion, over **WiFi (TCP)**. The host is the -self-contained C++ tool `tools/motatool/` (`motatool serve --serial ` / `--tcp `, which -also builds + validates `.mota` and runs on small hardware). The device only emits request frames *while +standalone Rust tool [`motatool`](https://github.com/vk496/motatool) (`motatool serve --serial ` / +`--tcp `, which also builds + verifies + inspects `.mota`). The device only emits request frames *while actively serving a fetch*, and reads the reply synchronously, so over the shared USB console binary frames coexist with the text CLI/logs (resync on magic + checksum). Little-endian, XOR-checksummed: diff --git a/docs/ota_user_guide.md b/docs/ota_user_guide.md index 10831ad6a9..57e8dddfe9 100644 --- a/docs/ota_user_guide.md +++ b/docs/ota_user_guide.md @@ -203,19 +203,20 @@ folder of firmware files to the mesh — without storing them itself. Useful for remote area. 1. Put the firmware files (`.mota` files — see below) in a folder on the computer. -2. Build the helper tool once (`tools/motatool/`), then point it at your node and the folder — over the - node's **USB serial**, or over **WiFi** if it's an ESP32 companion on your network: +2. Install the helper tool once — the standalone `motatool` CLI () — + then point it at your node and the folder — over the node's **USB serial**, or over **WiFi** if it's + an ESP32 companion on your network: ``` - cmake -S tools/motatool -B tools/motatool/build && cmake --build tools/motatool/build + git clone https://github.com/vk496/motatool && cargo install --path ./motatool # over USB serial: - ./tools/motatool/build/motatool serve --dir ./my_firmware/ --serial /dev/ttyACM0 -v + motatool serve --dir ./my_firmware/ --serial /dev/ttyACM0 -v # …or over WiFi (ESP32 companion): the seeder is on a DEDICATED port (5001), separate from the # phone-app port (5000), so a phone can stay connected while you serve: - ./tools/motatool/build/motatool serve --dir ./my_firmware/ --tcp 192.168.1.50:5001 -v + motatool serve --dir ./my_firmware/ --tcp 192.168.1.50:5001 -v ``` It answers the node's requests; your node then advertises those updates to neighbours, who can `ota get` them like any other. (A WiFi node prints its IP + seeder port to the serial log on connect. - Details: [tools/motatool/README.md](../tools/motatool/README.md).) + Details: .) To stop, just stop the daemon — over WiFi the node auto-detaches when the connection closes; over USB you can also run `ota folder off` on the node. `ota folder` on its own lists what your node is offering. diff --git a/tools/mota/README.md b/tools/mota/README.md index c3f8a77d30..a323935362 100644 --- a/tools/mota/README.md +++ b/tools/mota/README.md @@ -4,10 +4,10 @@ The Python side of MeshCore's `.mota` OTA system. It is the **reference implemen spec ([`docs/ota_protocol.md`](../../docs/ota_protocol.md)) and the **build + test infrastructure** — it is no longer a user-facing CLI. -> **Want to build / verify / inspect / serve `.mota` from the command line?** Use the self-contained C++ -> tool [`tools/motatool/`](../motatool/). It supersedes the old `mota.py` / `mota_seeder.py` (now removed), -> produces byte-identical containers, and runs on small hardware. The files here are the spec oracle and -> the firmware build/test glue. +> **Want to build / verify / inspect / serve `.mota` from the command line?** Use the standalone +> [`motatool`](https://github.com/vk496/motatool) Rust CLI (its own repository). It supersedes the old +> `mota.py` / `mota_seeder.py` (now removed) and produces byte-identical containers. The files here are +> the spec oracle and the firmware build/test glue. ## Setup diff --git a/tools/motatool/.gitignore b/tools/motatool/.gitignore deleted file mode 100644 index 567609b123..0000000000 --- a/tools/motatool/.gitignore +++ /dev/null @@ -1 +0,0 @@ -build/ diff --git a/tools/motatool/CMakeLists.txt b/tools/motatool/CMakeLists.txt deleted file mode 100644 index 4a15306377..0000000000 --- a/tools/motatool/CMakeLists.txt +++ /dev/null @@ -1,42 +0,0 @@ -cmake_minimum_required(VERSION 3.16) -project(motatool LANGUAGES CXX) - -# Self-contained MeshCore OTA host tool: build / verify / inspect / serve `.mota` containers. -# Portable C++17 — builds on Ubuntu, Raspberry Pi, and any arch with a C++17 compiler + OpenSSL. -set(CMAKE_CXX_STANDARD 17) -set(CMAKE_CXX_STANDARD_REQUIRED ON) -if(NOT CMAKE_BUILD_TYPE) - set(CMAKE_BUILD_TYPE Release) -endif() -add_compile_options(-Wall -Wextra) - -# OpenSSL libcrypto provides SHA-256 + Ed25519 (sign/verify/keygen). On Debian/Ubuntu/Pi: -# sudo apt install libssl-dev -find_package(OpenSSL REQUIRED) - -# Core logic (everything except the CLI front-end) — shared by the tool and the test runner. -add_library(motatool_core STATIC - src/mota.cpp - src/crypto.cpp - src/input.cpp - src/serve.cpp -) -# `src` for the tool's own headers; the repo's helpers/ota for the shared, generated OtaTargets.h -# (target_id -> env-name table — single source of truth with the firmware, no hand-maintained copy). -target_include_directories(motatool_core PUBLIC - src - "${CMAKE_CURRENT_SOURCE_DIR}/../../src/helpers/ota" -) -target_link_libraries(motatool_core PUBLIC OpenSSL::Crypto) - -add_executable(motatool src/main.cpp) -target_link_libraries(motatool PRIVATE motatool_core) -install(TARGETS motatool RUNTIME DESTINATION bin) - -# Unit tests (no external test framework — a tiny built-in harness keeps the project self-contained). -# Run with: cmake --build build && ctest --test-dir build --output-on-failure -# or: ./build/motatool_tests -enable_testing() -add_executable(motatool_tests tests/test_motatool.cpp) -target_link_libraries(motatool_tests PRIVATE motatool_core) -add_test(NAME motatool_tests COMMAND motatool_tests) diff --git a/tools/motatool/README.md b/tools/motatool/README.md deleted file mode 100644 index 9f90e122a4..0000000000 --- a/tools/motatool/README.md +++ /dev/null @@ -1,127 +0,0 @@ -# motatool — self-contained MeshCore OTA host tool (C++) - -A small, portable C++17 tool that **builds**, **verifies**, and **serves** MeshCore `.mota` -firmware-update containers. It is an independent project (its own `CMakeLists.txt`) that compiles on a -laptop, a Raspberry Pi, or any architecture with a C++17 compiler — so it can run as a lightweight -folder-relay daemon on small hardware. - -It implements the wire spec in [`../../docs/ota_protocol.md`](../../docs/ota_protocol.md) and is -cross-checked byte-for-byte against the Python reference packager `tools/mota/motalib.py` (a full signed -`.mota` built by `motatool` is identical to the reference's output). - -## What it does - -| Command | Purpose | -|---|---| -| `build` | Create a **full** or **delta** `.mota` from a firmware (local file **or** http(s) URL). | -| `verify` | Validate one or more `.mota` (merkle tree, leaves vs payload, image hash, Ed25519 signature). `--pub` requires a specific signer; `--base` confirms a sequential delta rebuilds its image. | -| `inspect`| Print every field of a `.mota`'s manifest (debugging). | -| `serve` | Serve a **folder** of `.mota` to a node over USB serial (`--serial`) or WiFi (`--tcp`). Invalid files are warned about and skipped — one corrupt file never sinks the rest. The same link also **stores** a node's `ota pull … folder` captures into that folder as `.mota` — pull a device's exact firmware off the mesh to build deltas against firmware you don't otherwise have. | -| `keygen` | Generate an Ed25519 signing keypair (hex). | - -Every command has detailed, example-rich help: `motatool --help`. - -## Build - -Dependencies: a C++17 compiler, CMake, and **OpenSSL** (libcrypto: SHA-256 + Ed25519). For URL input -and delta creation, see the notes below. - -```bash -sudo apt install build-essential cmake libssl-dev # Debian / Ubuntu / Raspberry Pi OS -cmake -S tools/motatool -B tools/motatool/build -cmake --build tools/motatool/build -j -# -> tools/motatool/build/motatool -``` - -### Tests - -Unit tests (a tiny built-in harness — no external framework) cover the crypto, EndF, build/verify, -merkle, parse rejection, the folder scanner + seeder protocol, and a real detools delta round-trip -(auto-skipped if `detools` isn't installed): - -```bash -ctest --test-dir tools/motatool/build --output-on-failure -# or directly: tools/motatool/build/motatool_tests -``` - -## Usage - -```bash -MT=tools/motatool/build/motatool - -# 1. signing keypair (64-char hex) -$MT keygen --out signer.key # writes signer.key + signer.key.pub - -# 2a. FULL .mota — payload IS the flashable image (identity is auto-read from the firmware's EndF) -$MT build --fw firmware.bin --sign signer.key --out-dir ./motas/ - -# 2b. FULL .mota straight from a URL -$MT build --fw https://example.org/Heltec_v3_repeater.bin --sign signer.key --out-dir ./motas/ - -# 2c. DELTA .mota against a previous release (codec auto-selected from the hardware tag: -# nRF52 -> in-place, ESP32 -> sequential; override with --codec) -$MT build --fw new.bin --base old.bin --sign signer.key --out-dir ./motas/ -$MT build --fw new.bin --base old.bin --codec sequential --out-dir ./motas/ - -# 3. validate a folder of .mota (optionally require a signer / check a delta against its base) -$MT verify ./motas/*.mota -$MT verify update.mota --pub signer.key.pub -$MT verify delta.mota --base old_firmware.bin - -# 4. dump a single .mota's manifest fields -$MT inspect ./motas/RAK4631_04D413FD_v1.16.0_full_ABCD1234.mota - -# 5. serve a folder to a node (recursive; skips non-.mota; warns on corrupt) -$MT serve --dir ./motas --serial /dev/ttyUSB0 --baud 115200 -v # over USB serial -$MT serve --dir ./motas --tcp 192.168.1.50:5001 -v # …or over WiFi (ESP32 companion, port 5001) - -# 5b. warm-start a capture: seed the pull with a SIMILAR build, then `ota pull <#> folder validate` on the -# node pulls only the blocks that differ (fast capture of a non-deterministic rebuild of the same fw) -$MT serve --dir ./captured --tcp 192.168.1.50:5001 --seed ./similar_build.mota -v -``` - -`serve` doubles as the **pull-to-folder storage**: a node's `ota pull <#> folder` captures the firmware it's -fetching off-mesh into `--dir` as `.mota`. With `--seed `, that seed's payload is staged -into each capture so a `ota pull <#> folder validate` on the node bulk-fetches the target's merkle leaves, -keeps every block the seed already matches, and transfers over LoRa only the differing ones — a byte-exact, -verifiable capture in seconds instead of a full-image download (see [`../../docs/ota_protocol.md`](../../docs/ota_protocol.md) §8, "warm-start"). - -`build` notes: -- **Identity is self-described.** A firmware built by the project's `pio_endf.py` carries its - `target_id`/`fw_version`/`hw_id` in its 56-byte `EndF` trailer; `build` reads them, so - `--target-env`/`--target-id`, `--fw-version`, and `--hw-id` are **optional** (explicit flags override). -- **Cross-hardware delta guard.** A delta is refused if the base and target firmware identities differ - (read from their `EndF`, not filenames); pass `--force` to override. -- **Delta codec** needs the `detools` encoder (the project's pinned codec — never reimplemented). Install - it (`pip install detools`) and ensure it's on `PATH`, or pass `--detools `. **Full** builds, - **verify**, and **serve** need no detools. In-place defaults: `--inplace-memory 0xAE000` - (nRF52 workspace), `--inplace-segment 4096`. -- **URL input** uses the system `curl` (falling back to `wget`) — no link-time dependency. -- All output is written into one folder (`--out-dir`, default `.`) with a descriptive, unique name: - `__v__.mota`. - -## Serving is transport-agnostic (USB serial today, BLE later) - -The protocol is split so the serving logic is reusable across links: - -- **`SeederCore`** (`src/serve.{h,cpp}`) is transport-free: it maps a request `(op, args)` to a response - `(status, payload)` — `COUNT` / `DESCRIBE(idx)` / `READ(idx, off, len)` over the validated catalog. -- **`Transport` + `serve_loop()`** wrap it with the byte-stream framing (magic + XOR checksum + resync, - `MotaSeederProto.h`). Two transports ship: **`SerialTransport`** (`--serial`, USB-UART) and - **`TcpTransport`** (`--tcp `, WiFi — connects to the node's dedicated seeder port, default - `5001`, which the ESP32 companion runs alongside its phone-app port so both work at once). - -To serve the same folder over **BLE** (e.g. an Android phone relaying to a MeshCore node), implement the -transport at the GATT layer — a request characteristic write hands `(op, args)` straight to -`SeederCore::handle()` and the reply is sent as a notification. No byte-stream framing is needed there -(BLE is reliable/segmented), and `SeederCore` + `Folder` are reused unchanged. OpenSSL builds for the -Android NDK, so the validation path ports as-is. - -## Relationship to `tools/mota/` - -`motatool` is the user-facing CLI: it replaced the old Python `mota.py` (build/verify/inspect/keygen), -`mota_seeder.py` (serve), and `dev_motas.py` (CI `.mota` packaging — its nRF52 `.hex` extraction is now a -built-in `motatool` input format), all removed. The Python `tools/mota/` directory remains as the -**reference implementation** (`motalib.py`, the spec oracle + unit tests) and the **firmware build/test -glue** (`pio_endf.py` build hook, `gen_vectors.py` test vectors). Their `.mota` output is byte-identical -(verified by cross-checks). diff --git a/tools/motatool/src/crypto.cpp b/tools/motatool/src/crypto.cpp deleted file mode 100644 index a4076a9c9b..0000000000 --- a/tools/motatool/src/crypto.cpp +++ /dev/null @@ -1,61 +0,0 @@ -#include "crypto.h" -#include -#include -#include - -namespace mota { - -void sha256_trunc(uint8_t* out, size_t out_len, const uint8_t* data, size_t len) { - uint8_t full[32]; - SHA256(data, len, full); - if (out_len > 32) out_len = 32; - std::memcpy(out, full, out_len); -} - -bool ed25519_verify(const uint8_t sig[64], const uint8_t* msg, size_t msg_len, const uint8_t pub[32]) { - EVP_PKEY* key = EVP_PKEY_new_raw_public_key(EVP_PKEY_ED25519, nullptr, pub, 32); - if (!key) return false; - EVP_MD_CTX* ctx = EVP_MD_CTX_new(); - bool ok = ctx && EVP_DigestVerifyInit(ctx, nullptr, nullptr, nullptr, key) == 1 && - EVP_DigestVerify(ctx, sig, 64, msg, msg_len) == 1; - if (ctx) EVP_MD_CTX_free(ctx); - EVP_PKEY_free(key); - return ok; -} - -bool ed25519_sign(uint8_t sig[64], const uint8_t* msg, size_t msg_len, const uint8_t priv[32]) { - EVP_PKEY* key = EVP_PKEY_new_raw_private_key(EVP_PKEY_ED25519, nullptr, priv, 32); - if (!key) return false; - EVP_MD_CTX* ctx = EVP_MD_CTX_new(); - size_t siglen = 64; - bool ok = ctx && EVP_DigestSignInit(ctx, nullptr, nullptr, nullptr, key) == 1 && - EVP_DigestSign(ctx, sig, &siglen, msg, msg_len) == 1 && siglen == 64; - if (ctx) EVP_MD_CTX_free(ctx); - EVP_PKEY_free(key); - return ok; -} - -bool ed25519_pub_from_priv(uint8_t pub[32], const uint8_t priv[32]) { - EVP_PKEY* key = EVP_PKEY_new_raw_private_key(EVP_PKEY_ED25519, nullptr, priv, 32); - if (!key) return false; - size_t len = 32; - bool ok = EVP_PKEY_get_raw_public_key(key, pub, &len) == 1 && len == 32; - EVP_PKEY_free(key); - return ok; -} - -bool ed25519_keygen(uint8_t priv[32], uint8_t pub[32]) { - EVP_PKEY* key = nullptr; - EVP_PKEY_CTX* pctx = EVP_PKEY_CTX_new_id(EVP_PKEY_ED25519, nullptr); - bool ok = pctx && EVP_PKEY_keygen_init(pctx) == 1 && EVP_PKEY_keygen(pctx, &key) == 1; - if (ok) { - size_t lp = 32, lk = 32; - ok = EVP_PKEY_get_raw_private_key(key, priv, &lp) == 1 && lp == 32 && - EVP_PKEY_get_raw_public_key(key, pub, &lk) == 1 && lk == 32; - } - if (key) EVP_PKEY_free(key); - if (pctx) EVP_PKEY_CTX_free(pctx); - return ok; -} - -} // namespace mota diff --git a/tools/motatool/src/crypto.h b/tools/motatool/src/crypto.h deleted file mode 100644 index eac5d8d348..0000000000 --- a/tools/motatool/src/crypto.h +++ /dev/null @@ -1,28 +0,0 @@ -// SHA-256 (multihash truncations) and Ed25519 — backed by OpenSSL libcrypto, so the tool is portable -// across Ubuntu / Raspberry Pi / any arch that has OpenSSL (and Android NDK for a future BLE seeder). -#pragma once -#include -#include -#include -#include -#include - -namespace mota { - -// SHA-256 of `data`, truncated to out_len (sha2-256:N). out_len <= 32. -void sha256_trunc(uint8_t* out, size_t out_len, const uint8_t* data, size_t len); - -inline std::array mh4(const uint8_t* d, size_t n) { std::array o; sha256_trunc(o.data(),4,d,n); return o; } -inline std::array mh8(const uint8_t* d, size_t n) { std::array o; sha256_trunc(o.data(),8,d,n); return o; } -inline std::array mh32(const uint8_t* d, size_t n) { std::array o; sha256_trunc(o.data(),32,d,n); return o; } - -// Ed25519. Keys are raw 32-byte (private seed / public key), matching motalib / the device. -bool ed25519_verify(const uint8_t sig[64], const uint8_t* msg, size_t msg_len, const uint8_t pub[32]); -// Sign `msg` with a 32-byte raw private seed -> 64-byte signature. Returns false on error. -bool ed25519_sign(uint8_t sig[64], const uint8_t* msg, size_t msg_len, const uint8_t priv[32]); -// Derive the 32-byte raw public key from a 32-byte raw private seed. -bool ed25519_pub_from_priv(uint8_t pub[32], const uint8_t priv[32]); -// Generate a fresh keypair (raw 32-byte each). -bool ed25519_keygen(uint8_t priv[32], uint8_t pub[32]); - -} // namespace mota diff --git a/tools/motatool/src/input.cpp b/tools/motatool/src/input.cpp deleted file mode 100644 index bb0a4b12a5..0000000000 --- a/tools/motatool/src/input.cpp +++ /dev/null @@ -1,102 +0,0 @@ -#include "input.h" -#include "util.h" -#include -#include -#include - -namespace mota { - -bool is_url(const std::string& s) { - return s.rfind("http://", 0) == 0 || s.rfind("https://", 0) == 0; -} - -static bool ends_with_ci(const std::string& s, const std::string& suf) { - if (s.size() < suf.size()) return false; - for (size_t i = 0; i < suf.size(); i++) - if (std::tolower((unsigned char)s[s.size() - suf.size() + i]) != std::tolower((unsigned char)suf[i])) return false; - return true; -} - -// Parse Intel HEX text into the flat binary it represents (min..max address, gaps filled 0xFF). -// nRF52/STM32 PlatformIO builds emit firmware.hex; pio_endf appends the EndF inside it, so the extracted -// binary IS the OTA image (BODY||EndF) starting at the app base — exactly what `build` wants. -static std::string parse_intel_hex(const std::vector& in, std::vector& out) { - const std::string s((const char*)in.data(), in.size()); - auto nib = [](char c) -> int { - if (c >= '0' && c <= '9') return c - '0'; - if (c >= 'a' && c <= 'f') return c - 'a' + 10; - if (c >= 'A' && c <= 'F') return c - 'A' + 10; - return -1; - }; - struct Seg { uint64_t addr; std::vector data; }; - std::vector segs; - uint64_t base = 0, lo = UINT64_MAX, hi = 0; - size_t i = 0; - bool saw_eof = false; - while (i < s.size()) { - if (s[i] != ':') { i++; continue; } // tolerate CR/LF/whitespace between records - size_t p = i + 1; - auto rb = [&](uint8_t& v) -> bool { - if (p + 2 > s.size()) return false; - int h = nib(s[p]), l = nib(s[p + 1]); - if (h < 0 || l < 0) return false; - v = (uint8_t)((h << 4) | l); p += 2; return true; - }; - uint8_t len, ah, al, type; - if (!rb(len) || !rb(ah) || !rb(al) || !rb(type)) return "malformed Intel HEX record"; - uint8_t sum = (uint8_t)(len + ah + al + type); - std::vector data(len); - for (uint8_t k = 0; k < len; k++) { if (!rb(data[k])) return "truncated Intel HEX data"; sum += data[k]; } - uint8_t cks; - if (!rb(cks)) return "missing Intel HEX checksum"; - if ((uint8_t)(sum + cks) != 0) return "Intel HEX checksum error"; - uint16_t addr = (uint16_t)((ah << 8) | al); - if (type == 0x00) { // data - uint64_t a = base + addr; - if (a < lo) lo = a; - if (a + len > hi) hi = a + len; - segs.push_back({a, std::move(data)}); - } else if (type == 0x04) { // extended linear address (upper 16 bits) - if (len != 2) return "bad Intel HEX ELA record"; - base = (uint64_t)((data[0] << 8) | data[1]) << 16; - } else if (type == 0x02) { // extended segment address - if (len != 2) return "bad Intel HEX ESA record"; - base = (uint64_t)((data[0] << 8) | data[1]) << 4; - } else if (type == 0x01) { // EOF - saw_eof = true; break; - } // 0x03/0x05 (start address) ignored - i = p; - } - if (segs.empty()) return "no data records in Intel HEX"; - if (!saw_eof) return "Intel HEX missing EOF record"; - out.assign((size_t)(hi - lo), 0xFF); - for (auto& sg : segs) std::memcpy(out.data() + (size_t)(sg.addr - lo), sg.data.data(), sg.data.size()); - return ""; -} - -std::string read_input(const std::string& src, std::vector& out) { - std::vector raw; - if (!is_url(src)) { - if (!read_file(src, raw)) return "cannot read file: " + src; - } else { - // URL: download to a temp file via curl (fallback wget), then read it back. - char tmp[] = "/tmp/motadlXXXXXX"; - int fd = mkstemp(tmp); - if (fd < 0) return "mkstemp failed"; - close(fd); - int rc = run_argv({"curl", "-fLsS", "-o", tmp, src}); // -f fail, -L follow, -sS silent+show-errors - if (rc == 127) rc = run_argv({"wget", "-q", "-O", tmp, src}); - std::string err; - if (rc == 127) err = "neither curl nor wget is available to fetch " + src; - else if (rc != 0) err = "download failed (exit " + std::to_string(rc) + "): " + src; - else if (!read_file(tmp, raw)) err = "could not read the downloaded file: " + src; - unlink(tmp); - if (!err.empty()) return err; - } - if (raw.empty()) return "input is empty: " + src; - if (ends_with_ci(src, ".hex")) return parse_intel_hex(raw, out); // Intel HEX -> flat image - out = std::move(raw); - return ""; -} - -} // namespace mota diff --git a/tools/motatool/src/input.h b/tools/motatool/src/input.h deleted file mode 100644 index ff92f5fd0a..0000000000 --- a/tools/motatool/src/input.h +++ /dev/null @@ -1,15 +0,0 @@ -// Read a firmware blob from a local file OR an http(s):// URL. URLs are fetched with the system `curl` -// (or `wget`) binary — no link-time dependency, so the tool stays self-contained and builds anywhere. -#pragma once -#include -#include -#include - -namespace mota { - -// Returns "" on success (fills `out`), else an error string. `what` is a label for error messages. -std::string read_input(const std::string& path_or_url, std::vector& out); - -bool is_url(const std::string& s); - -} // namespace mota diff --git a/tools/motatool/src/main.cpp b/tools/motatool/src/main.cpp deleted file mode 100644 index 042717c8f2..0000000000 --- a/tools/motatool/src/main.cpp +++ /dev/null @@ -1,545 +0,0 @@ -// motatool — build, verify, and serve MeshCore `.mota` firmware-update containers. -// build create a full or delta .mota from a firmware (file or http(s) URL) -// verify validate one or more .mota (merkle / hashes / signature) -// serve serve a folder of .mota to a node (USB serial); invalid files are warned + skipped -// keygen generate an Ed25519 signing keypair (64-char hex) -#include "mota.h" -#include "input.h" -#include "serve.h" -#include "crypto.h" -#include "util.h" -#include -#include -#include -#include -#include -#include -#include - -namespace fs = std::filesystem; -using namespace mota; - -static volatile bool g_stop = false; -static void on_sigint(int) { g_stop = true; } - -// minimal flag parser: --key value / --flag -struct Args { - std::map opt; - std::vector pos; - bool has(const std::string& k) const { return opt.count(k); } - std::string get(const std::string& k, const std::string& d = "") const { - auto it = opt.find(k); return it == opt.end() ? d : it->second; - } -}; -static Args parse_args(int argc, char** argv, int start, - const std::vector& bool_flags) { - Args a; - for (int i = start; i < argc; i++) { - std::string s = argv[i]; - if (s == "-h" || s == "--help" || s == "help") { a.opt["help"] = "1"; continue; } - if (s == "-v") { a.opt["verbose"] = "1"; continue; } - if (s.rfind("--", 0) == 0) { - std::string k = s.substr(2); - if (std::find(bool_flags.begin(), bool_flags.end(), k) != bool_flags.end()) a.opt[k] = "1"; - else if (i + 1 < argc) a.opt[k] = argv[++i]; - else a.opt[k] = ""; - } else a.pos.push_back(s); - } - return a; -} - -static bool load_priv(const std::string& path, std::vector& priv) { - std::vector raw; - if (!read_file(path, raw)) return false; - std::string txt((const char*)raw.data(), raw.size()); // try hex (mota.py format) first - while (!txt.empty() && (std::isspace((unsigned char)txt.back()))) txt.pop_back(); - std::vector hx; - if (from_hex(txt, hx) && hx.size() == 32) { priv = hx; return true; } - if (raw.size() == 32) { priv = raw; return true; } // else accept a raw 32-byte file - return false; -} - -static std::string version_str(uint32_t v) { - char b[24]; std::snprintf(b, sizeof(b), "%u.%u.%u", (v>>24)&0xFF, (v>>16)&0xFF, (v>>8)&0xFF); - return b; -} - -// ---- help text (the tool is meant to be usable from --help alone) --------------------------------- -static void help_top() { - std::cout << -"motatool — build, verify, and serve MeshCore .mota firmware-update containers.\n" -"\n" -"A .mota is a signed, self-verifying package of a firmware update that MeshCore nodes fetch\n" -"over LoRa, block by block. This tool makes those packages, checks them, and serves a folder\n" -"of them to a node over USB.\n" -"\n" -"USAGE\n" -" motatool [options]\n" -" motatool --help detailed help + examples for a command\n" -"\n" -"COMMANDS\n" -" build Package a firmware as a .mota (a full image, or a small delta vs a previous build).\n" -" verify Check that .mota files are valid (block hashes, image hash, signature).\n" -" inspect Print every field of a .mota's manifest (debugging).\n" -" serve Serve a folder of .mota to a node over USB serial or WiFi (corrupt files are skipped).\n" -" keygen Generate an Ed25519 signing keypair.\n" -"\n" -"TYPICAL WORKFLOW\n" -" 1. Make a signing key once:\n" -" motatool keygen --out signer.key\n" -" 2. Package a firmware (identity is read from the firmware itself):\n" -" motatool build --fw firmware.bin --sign signer.key --out-dir ./motas\n" -" 3. Serve the folder to a node plugged in over USB:\n" -" motatool serve --dir ./motas --serial /dev/ttyUSB0\n"; -} - -static void help_build() { - std::cout << -"motatool build — package a firmware as a .mota update container.\n" -"\n" -"USAGE\n" -" motatool build --fw [--base ] [options]\n" -"\n" -"WHAT IT DOES\n" -" With only --fw: builds a FULL update (the payload is the whole firmware image).\n" -" With --base too: builds a DELTA (a small patch from the base firmware to the new one),\n" -" which is far smaller to send over LoRa. The delta codec is picked for the target hardware\n" -" automatically: nRF52 (single-slot) -> in-place, ESP32 (A/B slots) -> sequential.\n" -"\n" -" Firmware identity (target, version, hardware tag) is read automatically from the firmware's\n" -" EndF trailer, so you normally don't pass --target-*/--fw-version/--hw-id. Pass them only to\n" -" override, or for a raw .bin that has no EndF identity.\n" -"\n" -"INPUT (--fw is required)\n" -" --fw NEW firmware. A local path OR an http(s):// URL (downloaded with curl/wget).\n" -" A .bin is used as-is; a .hex (nRF52/STM32 build) is parsed to its flat image first.\n" -" --base previous firmware to diff against -> makes a delta (omit it for a full image).\n" -"\n" -"IDENTITY (optional — auto-read from the firmware's EndF)\n" -" --target-env PlatformIO env name (e.g. RAK_4631_repeater); hashed into the target id.\n" -" --target-id raw target id instead of --target-env (e.g. 0x04D413FD).\n" -" --fw-version firmware version (e.g. 1.16.0).\n" -" --hw-id hardware tag (e.g. RAK4631, Heltec_v3). Same tag = bootable-compatible;\n" -" a node refuses a .mota whose hw-id is for different hardware (brick-safety).\n" -"\n" -"DELTA OPTIONS\n" -" --codec full|sequential|inplace force the codec (default: full with no --base, else auto-from-hw).\n" -" --inplace-memory nRF52 in-place workspace size (default 0xAE000).\n" -" --inplace-segment nRF52 in-place erase/segment size (default 4096).\n" -" --detools the detools encoder to call (default: 'detools' on PATH). Needed only for deltas.\n" -" --force build the delta even if base and target hardware identities differ.\n" -"\n" -"SIGNING\n" -" --sign Ed25519 private key (hex or raw 32 bytes, from 'motatool keygen'). Signing lets a\n" -" node auto-install the update if it trusts the matching public key. Unsigned still works\n" -" for manual installs.\n" -"\n" -"OUTPUT\n" -" --out-dir where to write the .mota (default: current directory). Keep all your .mota in one\n" -" folder and point 'serve' at it. The file is auto-named:\n" -" __v__.mota\n" -" --out write to exactly this path instead (overrides --out-dir and the auto-name).\n" -"\n" -"EXAMPLES\n" -" # full image, signed, into ./motas\n" -" motatool build --fw firmware.bin --sign signer.key --out-dir ./motas\n" -"\n" -" # full image fetched straight from a release URL\n" -" motatool build --fw https://example.org/RAK_4631_repeater.bin --sign signer.key --out-dir ./motas\n" -"\n" -" # delta from the previous release (codec auto-selected from the hardware)\n" -" motatool build --fw new.bin --base old.bin --sign signer.key --out-dir ./motas\n" -"\n" -" # raw .bin with no EndF identity: supply it explicitly\n" -" motatool build --fw app.bin --hw-id Heltec_v3 --target-env Heltec_v3_repeater --fw-version 1.16.0\n"; -} - -static void help_verify() { - std::cout << -"motatool verify — check that .mota files are valid.\n" -"\n" -"USAGE\n" -" motatool verify [more.mota ...] [--pub ] [--base ]\n" -"\n" -"For each file it checks the structure, that the per-block hashes match the payload, the merkle\n" -"root, the full-image hash (for full images), and the Ed25519 signature (for signed containers).\n" -"It prints 'OK' or 'FAIL ' per file; the exit code is non-zero if any file fails. This is\n" -"the same validation 'serve' runs on a folder before serving.\n" -"\n" -"OPTIONS\n" -" --pub require the container to be signed by THIS public key (hex/raw, *.pub from keygen).\n" -" --base for a sequential delta, apply it to this base and confirm it rebuilds the image.\n" -" (Applies to every file given; full images ignore it; in-place deltas are skipped.)\n" -"\n" -"EXAMPLES\n" -" motatool verify ./motas/*.mota\n" -" motatool verify update.mota --pub signer.key.pub\n" -" motatool verify delta.mota --base old_firmware.bin\n"; -} - -static void help_inspect() { - std::cout << -"motatool inspect — print every field of a .mota's manifest.\n" -"\n" -"USAGE\n" -" motatool inspect \n" -"\n" -"Dumps the parsed manifest (versions, sizes, target/hardware, codec, merkle root, image hash,\n" -"base hash, signer key + signature, approval state, block count) — handy for debugging a package.\n" -"It does not validate integrity; use 'verify' for that.\n" -"\n" -"EXAMPLE\n" -" motatool inspect ./motas/RAK4631_04D413FD_v1.16.0_full_ABCD1234.mota\n"; -} - -static void help_serve() { - std::cout << -"motatool serve — serve a folder of .mota to a MeshCore node over USB serial or WiFi (TCP).\n" -"\n" -"USAGE\n" -" motatool serve --dir --serial [options] # over USB serial\n" -" motatool serve --dir --tcp [options] # over WiFi (ESP32 companion)\n" -"\n" -"It scans the folder for .mota files, validates each, and serves the valid ones to the node.\n" -"Corrupt/invalid files are reported and skipped — one bad file never stops the rest. The node\n" -"advertises them to the mesh as if it held them, and any node whose hardware matches can fetch\n" -"them. The relay is trustless: fetchers verify every block, so this host never needs the keys.\n" -"\n" -"OPTIONS (--dir and one of --serial / --tcp are required)\n" -" --dir folder of .mota to serve (searched recursively by default).\n" -" --serial the node's USB serial port (e.g. /dev/ttyUSB0 or /dev/ttyACM0).\n" -" --tcp the node's WiFi seeder address (default port 5001). This is a DEDICATED\n" -" port, separate from the companion port (5000), so serving doesn't disturb a\n" -" phone app connected to the node. The node auto-enables relaying on connect.\n" -" --baud serial speed (default 115200; --serial only).\n" -" --no-recursive serve only the top folder; don't descend into sub-folders.\n" -" --no-enable (--serial only) don't auto-send 'ota folder on'/'off' on the node's CLI.\n" -" --seed warm-start: stage this (similar) build's payload into each captured .part so an\n" -" `ota pull <#> folder validate` diffs it against the target's merkle leaves and pulls\n" -" only the differing blocks — capture a non-deterministic rebuild in seconds.\n" -" -v, --verbose log each request the node makes (COUNT / DESCRIBE / READ).\n" -"\n" -"Leave it running; press Ctrl-C to stop. Over serial it shares the USB cable with the node's text\n" -"console; over TCP it uses the node's dedicated seeder port. The node only pulls while fetching.\n" -"\n" -"EXAMPLES\n" -" motatool serve --dir ./motas --serial /dev/ttyUSB0 -v\n" -" motatool serve --dir ./motas --tcp 192.168.4.234 -v\n"; -} - -static void help_keygen() { - std::cout << -"motatool keygen — generate an Ed25519 signing keypair.\n" -"\n" -"USAGE\n" -" motatool keygen [--out ]\n" -"\n" -"Prints the public key. With --out it writes the private key to and the public key to\n" -".pub (hex). Sign updates with the private key ('build --sign '); trust the\n" -"public key on a node to let it auto-install updates signed by you.\n" -"\n" -"EXAMPLE\n" -" motatool keygen --out signer.key\n"; -} - -static std::string hex8(uint32_t v) { char x[9]; std::snprintf(x, 9, "%08X", v); return x; } - -// human-readable label for a target_id (its PlatformIO env name), or "N/A" if not in the known table -static std::string target_label(uint32_t t) { - std::string n = target_env_name(t); - return n.empty() ? "N/A" : n; -} - -static int cmd_verify(const Args& a) { - if (a.has("help")) { help_verify(); return 0; } - if (a.pos.empty()) { help_verify(); return 2; } - - std::vector expect_pub; - if (a.has("pub") && !load_priv(a.get("pub"), expect_pub)) { // load_priv accepts a 32-byte hex/raw key - std::cerr << "cannot load --pub key (expect 32-byte hex or raw)\n"; return 2; - } - std::vector base_img; - if (a.has("base")) { - std::vector b; std::string e = read_input(a.get("base"), b); - if (!e.empty()) { std::cerr << "error: " << e << "\n"; return 1; } - std::array bh; base_img = ensure_endf(b, parse_endf_ident(b), bh); - } - - int bad = 0; - for (const auto& f : a.pos) { - std::vector blob; - if (!read_file(f, blob)) { std::cout << "FAIL " << f << " : cannot read\n"; bad++; continue; } - auto probs = verify(blob); - Manifest m; bool parsed = parse(blob, m).empty(); - if (parsed && !expect_pub.empty()) { // --pub: must be signed by this exact key - if (!m.is_signed()) probs.push_back("not signed (but --pub was given)"); - else if (std::memcmp(m.signer.data(), expect_pub.data(), 32) != 0) probs.push_back("signed by a different key than --pub"); - } - if (parsed && !base_img.empty() && !m.is_full()) { // --base: prove a delta rebuilds the image - if (m.codec_id == CODEC_DETOOLS_SEQUENTIAL) { - std::vector patch(blob.begin() + m.payload_off(), blob.begin() + m.payload_off() + m.payload_size); - std::vector recon; - std::string e = detools_apply_seq(a.get("detools", "detools"), base_img, patch, recon); - if (!e.empty()) probs.push_back("delta apply: " + e); - else { auto h = mota::mh32(recon.data(), recon.size()); - if (std::memcmp(h.data(), m.image_hash.data(), 32) != 0) probs.push_back("delta does not rebuild image_hash against --base"); } - } else { - std::cout << "note " << f << " : in-place delta — --base apply-check skipped (bootloader-applied)\n"; - } - } - if (probs.empty()) { - std::cout << "OK " << f << " : " << (m.is_full() ? "full" : "delta") - << " target=" << hex8(m.target_id) << " [" << target_label(m.target_id) << "]" - << " v" << version_str(m.fw_version) << " hw=" << (m.hw_id_str().empty()? "?":m.hw_id_str()) - << " " << (m.is_signed() ? "signed" : "unsigned") - << " blocks=" << m.block_count << " size=" << blob.size() << "\n"; - } else { - bad++; - std::cout << "FAIL " << f << " :"; - for (auto& p : probs) std::cout << " [" << p << "]"; - std::cout << "\n"; - } - } - return bad ? 1 : 0; -} - -static int cmd_inspect(const Args& a) { - if (a.has("help")) { help_inspect(); return 0; } - if (a.pos.empty()) { help_inspect(); return 2; } - std::vector blob; - if (!read_file(a.pos[0], blob)) { std::cerr << "cannot read " << a.pos[0] << "\n"; return 1; } - Manifest m; - std::string e = parse(blob, m); - if (!e.empty()) { std::cerr << "not a valid .mota: " << e << "\n"; return 1; } - auto z = [](const uint8_t* p, size_t n){ for (size_t i=0;i in-place, else sequential) -static int infer_codec(const std::string& hw) { - std::string h; for (char c : hw) h.push_back((char)std::tolower((unsigned char)c)); - if (h.rfind("rak", 0) == 0 || h.find("nrf") != std::string::npos || h.find("nordic") != std::string::npos) - return CODEC_DETOOLS_INPLACE; - if (h.find("heltec") != std::string::npos || h.find("esp32") != std::string::npos || - h.find("xiao") != std::string::npos || h.find("tbeam") != std::string::npos || - h.find("tlora") != std::string::npos || h.find("tdeck") != std::string::npos) - return CODEC_DETOOLS_SEQUENTIAL; - return -1; // unknown -} - -static int cmd_build(const Args& a) { - if (a.has("help")) { help_build(); return 0; } - if (!a.has("fw")) { std::cerr << "error: --fw is required\n\n"; help_build(); return 2; } - BuildOpts o; - std::string e = read_input(a.get("fw"), o.fw); - if (!e.empty()) { std::cerr << "error: " << e << "\n"; return 1; } - if (a.has("base")) { - e = read_input(a.get("base"), o.base); - if (!e.empty()) { std::cerr << "error: " << e << "\n"; return 1; } - } - if (a.has("target-id")) { o.have_target = true; o.target_id = (uint32_t)std::strtoul(a.get("target-id").c_str(), nullptr, 0); } - else if (a.has("target-env")) { o.have_target = true; o.target_id = target_id_for_env(a.get("target-env")); } - if (a.has("fw-version")) { - uint32_t v; if (!pack_version(a.get("fw-version"), v)) { std::cerr << "bad --fw-version\n"; return 2; } - o.have_fwver = true; o.fw_version = v; - } - if (a.has("hw-id")) o.hw_id = a.get("hw-id"); - o.force = a.has("force"); - if (a.has("detools")) o.detools = a.get("detools"); - if (a.has("inplace-memory")) o.inplace_memory = (uint32_t)std::strtoul(a.get("inplace-memory").c_str(), nullptr, 0); - if (a.has("inplace-segment")) o.inplace_segment = (uint32_t)std::strtoul(a.get("inplace-segment").c_str(), nullptr, 0); - if (a.has("sign")) { - if (!load_priv(a.get("sign"), o.sign_priv)) { std::cerr << "cannot load signing key (expect 32-byte hex or raw)\n"; return 1; } - } - - bool is_delta = !o.base.empty(); - // resolve hw (flags override EndF) to pick the codec - FwIdent fid = parse_endf_ident(o.fw); - std::string hw = o.hw_id.empty() ? fid.hw_id : o.hw_id; - std::string codec = a.get("codec", is_delta ? "auto" : "full"); - if (!is_delta) o.codec = CODEC_FULL; - else if (codec == "sequential") o.codec = CODEC_DETOOLS_SEQUENTIAL; - else if (codec == "inplace") o.codec = CODEC_DETOOLS_INPLACE; - else if (codec == "full") { std::cerr << "a --base delta cannot use --codec full\n"; return 2; } - else { // auto - int c = infer_codec(hw); - if (c < 0) { std::cerr << "cannot infer codec from hw '" << hw << "' — pass --codec sequential|inplace\n"; return 2; } - o.codec = (uint8_t)c; - std::cerr << "note: codec auto-selected = " << (c == CODEC_DETOOLS_INPLACE ? "inplace" : "sequential") - << " (from hw '" << (hw.empty()?"?":hw) << "')\n"; - } - - std::vector blob; std::string name; - e = build(o, blob, name); - if (!e.empty()) { std::cerr << "error: " << e << "\n"; return 1; } - - // sanity: the tool's own output must verify - auto probs = verify(blob); - if (!probs.empty()) { - std::cerr << "internal error: built .mota fails verification:"; - for (auto& p : probs) std::cerr << " [" << p << "]"; - std::cerr << "\n"; return 1; - } - - std::string outpath; - if (a.has("out")) { // explicit output path (overrides --out-dir + auto-name) - outpath = a.get("out"); - fs::path parent = fs::path(outpath).parent_path(); - if (!parent.empty()) { std::error_code ec; fs::create_directories(parent, ec); } - } else { - std::string outdir = a.get("out-dir", "."); - std::error_code ec; fs::create_directories(outdir, ec); - outpath = (fs::path(outdir) / name).string(); - } - if (!write_file(outpath, blob.data(), blob.size())) { std::cerr << "cannot write " << outpath << "\n"; return 1; } - - Manifest m; parse(blob, m); - std::cout << "wrote " << outpath << "\n" - << " " << (m.is_full() ? "full" : (m.codec_id == CODEC_DETOOLS_INPLACE ? "in-place delta" : "sequential delta")) - << " target=" << [&]{char x[9];std::snprintf(x,9,"%08X",m.target_id);return std::string(x);}() - << " v" << version_str(m.fw_version) << " hw=" << (m.hw_id_str().empty()?"?":m.hw_id_str()) - << " " << (m.is_signed()?"signed":"unsigned") << "\n" - << " image=" << m.image_size << "B payload=" << m.payload_size << "B blocks=" << m.block_count - << " total=" << blob.size() << "B\n"; - return 0; -} - -static int cmd_keygen(const Args& a) { - if (a.has("help")) { help_keygen(); return 0; } - uint8_t priv[32], pub[32]; - if (!ed25519_keygen(priv, pub)) { std::cerr << "keygen failed\n"; return 1; } - std::string out = a.get("out", a.get("out-priv")); - std::string ph = to_hex(priv, 32), kh = to_hex(pub, 32); - if (!out.empty()) { - std::string pp = ph + "\n", kp = kh + "\n"; - if (!write_file(out, (const uint8_t*)pp.data(), pp.size()) || - !write_file(out + ".pub", (const uint8_t*)kp.data(), kp.size())) { std::cerr << "write failed\n"; return 1; } - std::cout << "private -> " << out << "\npublic -> " << out << ".pub\n"; - } - std::cout << "pubkey: " << kh << "\n"; - return 0; -} - -static int cmd_serve(const Args& a) { - if (a.has("help")) { help_serve(); return 0; } - bool use_tcp = a.has("tcp"); - if (!a.has("dir") || (!a.has("serial") && !use_tcp)) { - std::cerr << "error: --dir and one of --serial / --tcp are required\n\n"; help_serve(); return 2; - } - bool recursive = !a.has("no-recursive"); - bool verbose = a.has("verbose"); - Folder folder; - size_t n = folder.scan(a.get("dir"), recursive, - [](const std::string& p, const std::string& why) { - std::cerr << " ! skip " << p << " : " << why << "\n"; - }); - std::cout << "motatool serve: " << n << " valid .mota in " << a.get("dir") - << (recursive ? " (recursive)" : "") << "\n"; - for (const auto& s : folder.all()) { - std::cout << " - " << fs::path(s.path).filename().string() - << " : mid=" << to_hex(s.m.merkle_root.data(), 4) - << " target=" << hex8(s.m.target_id) << " [" << target_label(s.m.target_id) << "]" - << " v" << version_str(s.m.fw_version) - << " " << (s.m.is_full() ? "full" : (s.m.codec_id == CODEC_DETOOLS_INPLACE ? "ipdelta" : "seqdelta")) - << " " << (s.m.is_signed() ? "signed" : "unsigned") - << " blocks=" << s.m.block_count << " size=" << s.bytes.size() << "\n"; - } - if (n == 0) std::cerr << " (nothing valid to serve)\n"; - - // Pick the transport: a serial port, or a TCP connection to the node's WiFi seeder port (host[:port], - // default port 5001). The node runs the seeder on a DEDICATED port, separate from its companion port, - // so serving over WiFi doesn't disturb a phone app connected to the companion. - SerialTransport st; - TcpTransport tt; - Transport* t = nullptr; - std::string target; - if (use_tcp) { - std::string hp = a.get("tcp"); - size_t colon = hp.rfind(':'); - std::string host = (colon == std::string::npos) ? hp : hp.substr(0, colon); - int port = (colon == std::string::npos) ? 5001 : std::atoi(hp.substr(colon + 1).c_str()); - std::string e = tt.open(host, port); - if (!e.empty()) { std::cerr << "error: " << e << "\n"; return 1; } - t = &tt; target = host + ":" + std::to_string(port); - } else { - std::string e = st.open(a.get("serial"), std::atoi(a.get("baud", "115200").c_str())); - if (!e.empty()) { std::cerr << "error: " << e << "\n"; return 1; } - t = &st; target = a.get("serial") + " @ " + a.get("baud", "115200"); - } - - std::signal(SIGINT, on_sigint); - // The CLI auto-enable only applies to the serial console; the TCP seeder port auto-enables relaying on - // the node side when this connection opens (and stops when it closes), so there's nothing to send. - bool enable = !use_tcp && !a.has("no-enable"); - if (enable) { usleep(500000); t->write_str("ota folder on\r\n"); std::cout << "sent `ota folder on`\n"; } - std::cout << "serving on " << target << " — Ctrl-C to stop\n"; - - SeederCore core(folder, a.get("dir")); // same folder doubles as "pull to folder" storage - // Optional warm-start seed: a *similar* build's .mota. Its payload is dropped into each captured `.part` - // so a `ota pull … folder validate` diffs it against the target's leaves and pulls only the differing - // blocks (huge airtime saving when capturing a non-deterministic rebuild of the same firmware). - if (a.has("seed")) { - std::vector sb; - if (!read_file(a.get("seed"), sb)) { std::cerr << "error: cannot read seed " << a.get("seed") << "\n"; return 1; } - Manifest sm; std::string e = parse(sb, sm); - if (!e.empty()) { std::cerr << "error: bad seed .mota: " << e << "\n"; return 1; } - uint32_t poff = sm.payload_off(); - if ((uint64_t)poff + sm.payload_size > sb.size()) { std::cerr << "error: seed payload out of range\n"; return 1; } - core.set_seed(std::vector(sb.begin() + poff, sb.begin() + poff + sm.payload_size), sm.block_count); - std::cout << "seed: " << fs::path(a.get("seed")).filename().string() - << " mid=" << to_hex(sm.merkle_root.data(), 4) - << " blocks=" << sm.block_count << " payload=" << sm.payload_size - << " (staged into each capture for `ota pull … validate`)\n"; - } - serve_loop(*t, core, verbose, - [](const std::string& l) { std::cout << " [dev] " << l << "\n"; }, &g_stop); - - if (enable) { t->write_str("ota folder off\r\n"); usleep(200000); } - std::cout << "\nbye\n"; - return 0; -} - -int main(int argc, char** argv) { - if (argc < 2) { help_top(); return 2; } - std::string cmd = argv[1]; - if (cmd == "help" || cmd == "-h" || cmd == "--help") { help_top(); return 0; } - - std::vector bools = {"force","verbose","no-recursive","no-enable"}; - Args a = parse_args(argc, argv, 2, bools); - if (cmd == "build") return cmd_build(a); - if (cmd == "verify") return cmd_verify(a); - if (cmd == "inspect") return cmd_inspect(a); - if (cmd == "serve") return cmd_serve(a); - if (cmd == "keygen") return cmd_keygen(a); - std::cerr << "unknown command: " << cmd << "\n\n"; - help_top(); - return 2; -} diff --git a/tools/motatool/src/mota.cpp b/tools/motatool/src/mota.cpp deleted file mode 100644 index 87478bb15f..0000000000 --- a/tools/motatool/src/mota.cpp +++ /dev/null @@ -1,413 +0,0 @@ -#include "mota.h" - -#include "OtaTargets.h" // shared with the firmware (generated): target_id -> env name -#include "crypto.h" -#include "util.h" - -#include -#include -#include -#include - -namespace mota { - -// ---- merkle (mirror of src/helpers/ota/MerkleTree.cpp) -------------------------------------------- -static void merkle_leaf(uint8_t out[4], const uint8_t *block, size_t len) { - sha256_trunc(out, 4, block, len); -} -static void merkle_combine(uint8_t out[4], const uint8_t *left, const uint8_t *right) { - uint8_t buf[8]; - std::memcpy(buf, left, 4); - std::memcpy(buf + 4, right, 4); - sha256_trunc(out, 4, buf, 8); -} -// Root via binary-counter / Merkle-Mountain-Range with right-to-left bagging (must stay byte-identical to -// src/helpers/ota/MerkleTree.cpp; the native tests cross-check both against the Python reference). -// peaks[k] holds the root of a complete 2^k-leaf subtree; adding a leaf "carries" upward like incrementing -// a binary counter, then the leftover peaks are bagged right-to-left into the final root. -static void merkle_root(uint8_t out[4], const uint8_t *leaves, uint32_t count) { - if (count == 0) { - std::memset(out, 0, 4); - return; - } - if (count == 1) { - std::memcpy(out, leaves, 4); - return; - } - uint8_t peaks[32][4]; - bool valid[32] = { false }; - for (uint32_t i = 0; i < count; i++) { - uint8_t cur[4]; - std::memcpy(cur, leaves + (size_t)i * 4, 4); - uint32_t level = 0; - while (valid[level]) { // carry: combine with the pending peak at this level - merkle_combine(cur, peaks[level], cur); // peak is earlier (left), cur is right - valid[level] = false; - level++; - } - std::memcpy(peaks[level], cur, 4); - valid[level] = true; - } - // bag peaks right-to-left: acc starts at the lowest set level (rightmost peak) - int level = 0; - while (level < 32 && !valid[level]) - level++; - uint8_t acc[4]; - std::memcpy(acc, peaks[level], 4); - for (int l = level + 1; l < 32; l++) - if (valid[l]) merkle_combine(acc, peaks[l], acc); // higher peak is left, acc is right - std::memcpy(out, acc, 4); -} -// leaves[] over the payload (last block short, no padding) -static std::vector leaf_hashes(const uint8_t *payload, uint32_t size, uint32_t bs) { - uint32_t bc = (size + bs - 1) / bs; - std::vector leaves(bc * 4); - for (uint32_t i = 0; i < bc; i++) { - uint32_t off = i * bs, blen = (off + bs <= size) ? bs : (size - off); - merkle_leaf(leaves.data() + (size_t)i * 4, payload + off, blen); - } - return leaves; -} - -std::string Manifest::hw_id_str() const { - size_t n = 0; - while (n < hw_id.size() && hw_id[n]) - n++; - return std::string((const char *)hw_id.data(), n); -} - -// ---- parse ----------------------------------------------------------------------------------------- -std::string parse(const std::vector &b, Manifest &m) { - m = Manifest(); - if (b.size() < 8 + MOTA_MFL + 5) return "too small for a .mota"; - if (std::memcmp(b.data(), MOTA_MAGIC, 4) != 0) return "bad MAGIC (not a .mota)"; - uint32_t total = rd_u32(b.data() + 4); - if (total != b.size()) return "MOTA_TOTAL_SIZE != file length"; - if (std::memcmp(b.data() + b.size() - 5, MOTA_TRAILER, 5) != 0) return "bad TRAILER"; - const uint8_t *mf = b.data() + 8; // manifest start - m.format_ver = mf[M_OFF_FORMAT_VER]; - if (m.format_ver != FORMAT_VER) return "unsupported format_ver"; - m.flags = mf[M_OFF_FLAGS]; - m.hash_algo = mf[M_OFF_HASH_ALGO]; - m.target_id = rd_u32(mf + M_OFF_TARGET_ID); - m.fw_version = rd_u32(mf + M_OFF_FW_VERSION); - m.image_size = rd_u32(mf + M_OFF_IMAGE_SIZE); - m.payload_size = rd_u32(mf + M_OFF_PAYLOAD_SIZE); - m.block_size_log2 = mf[M_OFF_BLOCK_SIZE_LOG2]; - std::memcpy(m.merkle_root.data(), mf + M_OFF_MERKLE_ROOT, 4); - std::memcpy(m.image_hash.data(), mf + M_OFF_IMAGE_HASH, 32); - m.codec_id = mf[M_OFF_CODEC_ID]; - std::memcpy(m.hw_id.data(), mf + M_OFF_HW_ID, 32); - std::memcpy(m.base_hash.data(), mf + M_OFF_BASE_HASH, 8); - std::memcpy(m.signer.data(), mf + M_OFF_SIGNER, 32); - std::memcpy(m.signature.data(), mf + M_OFF_SIGNATURE, 64); - std::memcpy(m.approval.data(), mf + M_OFF_APPROVAL, 4); - if (m.block_size_log2 == 0 || m.block_size_log2 > 24 || m.payload_size == 0) - return "bad block_size/payload"; - m.block_count = (m.payload_size + m.block_size() - 1) / m.block_size(); - if (m.block_count == 0 || m.block_count > 0xFFFFu) return "block_count out of range"; - if (m.total_size() != b.size()) return "geometry (leaves+payload) != file length"; - return ""; -} - -// ---- verify ---------------------------------------------------------------------------------------- -std::vector verify(const std::vector &b) { - std::vector probs; - Manifest m; - std::string e = parse(b, m); - if (!e.empty()) { - probs.push_back(e); - return probs; - } // unparseable: a single, fatal problem - - const uint8_t *leaves = b.data() + m.leaves_off(); - const uint8_t *payload = b.data() + m.payload_off(); - - // recompute leaves[] from the payload -> catches payload corruption - std::vector calc = leaf_hashes(payload, m.payload_size, m.block_size()); - if (calc.size() != m.block_count * 4u || std::memcmp(calc.data(), leaves, calc.size()) != 0) - probs.push_back("leaves[] do not match the payload (corruption)"); - - uint8_t root[4]; - merkle_root(root, leaves, m.block_count); - if (std::memcmp(root, m.merkle_root.data(), 4) != 0) probs.push_back("merkle_root mismatch"); - - if (m.is_full()) { - auto h = mh32(payload, m.payload_size); // full: payload IS the image - if (std::memcmp(h.data(), m.image_hash.data(), 32) != 0) - probs.push_back("image_hash mismatch (full image)"); - } - // (delta image_hash needs the base image -> not checked at relay time; payload integrity is covered above) - - if (m.is_signed()) { - if (!ed25519_verify(m.signature.data(), b.data() + 8, MOTA_SIGNED_LEN, m.signer.data())) - probs.push_back("Ed25519 signature INVALID"); - } - // a distributed .mota must not be pre-approved - if (std::memcmp(m.approval.data(), APPROVAL_YES, 4) == 0) - probs.push_back("container is pre-approved (must be FF FF FF FF on the wire)"); - return probs; -} - -// ---- EndF ----------------------------------------------------------------------------------------- -bool has_endf(const std::vector &img) { - if (img.size() < ENDF_LEN) return false; - const uint8_t *t = img.data() + img.size() - ENDF_LEN; - if (std::memcmp(t, ENDF_MAGIC, 4) != 0) return false; - if (rd_u32(t + 4) != img.size() - ENDF_LEN) return false; - auto h = mh8(img.data(), img.size() - ENDF_LEN); - return std::memcmp(h.data(), t + 8, 8) == 0; -} - -FwIdent parse_endf_ident(const std::vector &img) { - FwIdent id; - if (!has_endf(img)) return id; - const uint8_t *t = img.data() + img.size() - ENDF_LEN; - id.fw_version = rd_u32(t + ENDF_OFF_FWVER); - id.target_id = rd_u32(t + ENDF_OFF_TARGET); - size_t n = 0; - while (n < HW_ID_LEN && t[ENDF_OFF_HWID + n]) - n++; - id.hw_id.assign((const char *)t + ENDF_OFF_HWID, n); - return id; -} - -std::vector ensure_endf(const std::vector &img, const FwIdent &id, - std::array &body_hash8) { - if (has_endf(img)) { // already trailed: keep its identity, read its hash - const uint8_t *t = img.data() + img.size() - ENDF_LEN; - std::memcpy(body_hash8.data(), t + 8, 8); - return img; - } - body_hash8 = mh8(img.data(), img.size()); - std::vector out = img; - out.insert(out.end(), ENDF_MAGIC, ENDF_MAGIC + 4); - uint8_t u[4]; - wr_u32(u, (uint32_t)img.size()); - out.insert(out.end(), u, u + 4); - out.insert(out.end(), body_hash8.begin(), body_hash8.end()); - wr_u32(u, id.fw_version); - out.insert(out.end(), u, u + 4); - wr_u32(u, id.target_id); - out.insert(out.end(), u, u + 4); - uint8_t hw[HW_ID_LEN] = { 0 }; - std::memcpy(hw, id.hw_id.data(), id.hw_id.size() < HW_ID_LEN ? id.hw_id.size() : HW_ID_LEN); - out.insert(out.end(), hw, hw + HW_ID_LEN); - return out; -} - -uint32_t target_id_for_env(const std::string &env) { - auto h = mh4((const uint8_t *)env.data(), env.size()); - return rd_u32(h.data()); -} - -std::string target_env_name(uint32_t target_id) { - // Exhaustive target_id -> env-name table, shared verbatim with the firmware and generated from the live - // PlatformIO config (every ENABLE_OTA env) by tools/mota/gen_targets.py. See OtaTargets.h. - const char *env = mesh::ota::ota_target_env_name(target_id); - return env ? env : ""; -} - -bool pack_version(const std::string &s, uint32_t &out) { - uint32_t parts[4] = { 0, 0, 0, 0 }; - int n = 0; - bool any = false; - std::stringstream ss(s); - std::string tok; - while (std::getline(ss, tok, '.') && n < 4) { - if (tok.empty()) return false; - for (char c : tok) - if (c < '0' || c > '9') return false; - parts[n++] = (uint32_t)std::strtoul(tok.c_str(), nullptr, 10); - any = true; - } - if (!any) return false; - out = ((parts[0] & 0xFF) << 24) | ((parts[1] & 0xFF) << 16) | ((parts[2] & 0xFF) << 8) | (parts[3] & 0xFF); - return true; -} - -// ---- build ---------------------------------------------------------------------------------------- -// detools delta encode: write base/new images to temp files, run the detools CLI, read the patch back. -static std::string detools_delta(const std::string &detools, uint8_t codec, - const std::vector &base_img, const std::vector &new_img, - uint32_t mem, uint32_t seg, std::vector &patch) { - char fb[] = "/tmp/motaXXXXXX", fn[] = "/tmp/motaXXXXXX", fp[] = "/tmp/motaXXXXXX"; - int a = mkstemp(fb), c = mkstemp(fn), d = mkstemp(fp); - if (a < 0 || c < 0 || d < 0) return "mkstemp failed"; - close(a); - close(c); - close(d); - std::string err; - if (!write_file(fb, base_img.data(), base_img.size()) || !write_file(fn, new_img.data(), new_img.size())) { - err = "writing temp images failed"; - } else { - std::vector argv; - if (codec == CODEC_DETOOLS_SEQUENTIAL) - argv = { detools, "create_patch", "-c", "crle", "-t", "sequential", fb, fn, fp }; - else - argv = { detools, - "create_patch_in_place", - "-c", - "crle", - "--memory-size", - std::to_string(mem), - "--segment-size", - std::to_string(seg), - fb, - fn, - fp }; - int rc = run_argv(argv, /*quiet=*/true); // hide detools' success chatter; errors keep stderr - if (rc == 127) - err = "could not run detools (install it / pass --detools )"; - else if (rc != 0) - err = "detools exited with code " + std::to_string(rc); - else if (!read_file(fp, patch) || patch.empty()) - err = "reading detools patch failed"; - } - unlink(fb); - unlink(fn); - unlink(fp); - return err; -} - -std::string detools_apply_seq(const std::string &detools, const std::vector &base_img, - const std::vector &patch, std::vector &out) { - char ff[] = "/tmp/motaXXXXXX", fpp[] = "/tmp/motaXXXXXX", ft[] = "/tmp/motaXXXXXX"; - int a = mkstemp(ff), c = mkstemp(fpp), d = mkstemp(ft); - if (a < 0 || c < 0 || d < 0) return "mkstemp failed"; - close(a); - close(c); - close(d); - std::string err; - if (!write_file(ff, base_img.data(), base_img.size()) || !write_file(fpp, patch.data(), patch.size())) { - err = "writing temp files failed"; - } else { - int rc = run_argv({ detools, "apply_patch", ff, fpp, ft }, /*quiet=*/true); // - if (rc == 127) - err = "could not run detools (install it / pass --detools )"; - else if (rc != 0) - err = "detools apply_patch exited with code " + std::to_string(rc); - else if (!read_file(ft, out) || out.empty()) - err = "reading reconstructed image failed"; - } - unlink(ff); - unlink(fpp); - unlink(ft); - return err; -} - -std::string build(const BuildOpts &o, std::vector &out, std::string &suggested_name) { - bool is_delta = !o.base.empty(); - - // resolve identity: explicit flags override the firmware's self-describing EndF - FwIdent fid = parse_endf_ident(o.fw); - uint32_t target = o.have_target ? o.target_id : fid.target_id; - uint32_t fwver = o.have_fwver ? o.fw_version : fid.fw_version; - std::string hw = !o.hw_id.empty() ? o.hw_id : fid.hw_id; - FwIdent ident{ fwver, target, hw }; - - std::array new_bh{}, base_bh{}; - std::vector new_img = ensure_endf(o.fw, ident, new_bh); - - uint8_t codec = o.codec; - std::vector payload; - std::array base_hash{}; - - if (!is_delta) { - codec = CODEC_FULL; - payload = new_img; // full: payload IS the image - } else { - if (codec == CODEC_FULL) return "a base image was given but --codec is full"; - FwIdent base_id = parse_endf_ident(o.base); - std::vector base_img = ensure_endf(o.base, base_id, base_bh); - base_hash = base_bh; - // cross-hardware delta guard (read from EndF identity, not filenames) - if (!o.force && base_id.any() && fid.any()) { - bool hw_ok = base_id.hw_id.empty() || fid.hw_id.empty() || base_id.hw_id == fid.hw_id; - bool tgt_ok = !base_id.target_id || !fid.target_id || base_id.target_id == fid.target_id; - if (!hw_ok || !tgt_ok) - return "base/target firmware identity differ (hw '" + base_id.hw_id + "' vs '" + fid.hw_id + - "') — refusing cross-hardware delta (use --force to override)"; - } - std::string e = - detools_delta(o.detools, codec, base_img, new_img, o.inplace_memory, o.inplace_segment, payload); - if (!e.empty()) return e; - } - - uint32_t bs = o.block_size, image_size = (uint32_t)new_img.size(); - std::vector leaves = leaf_hashes(payload.data(), (uint32_t)payload.size(), bs); - uint32_t bc = (uint32_t)leaves.size() / 4; - if (bc == 0 || bc > 0xFFFFu) return "payload yields an invalid block count"; - uint8_t root[4]; - merkle_root(root, leaves.data(), bc); - auto image_hash = mh32(new_img.data(), new_img.size()); - - bool signed_ = !o.sign_priv.empty(); - uint8_t flags = (is_delta ? 0 : MFLAG_FULL) | (signed_ ? MFLAG_SIGNED : 0); - - // assemble the fixed 197-byte manifest-minus-leaves - std::vector mf(MOTA_MFL, 0); - mf[M_OFF_FORMAT_VER] = FORMAT_VER; - mf[M_OFF_FLAGS] = flags; - mf[M_OFF_HASH_ALGO] = HASH_ALGO_SHA256; - wr_u32(mf.data() + M_OFF_TARGET_ID, target); - wr_u32(mf.data() + M_OFF_FW_VERSION, fwver); - wr_u32(mf.data() + M_OFF_IMAGE_SIZE, image_size); - wr_u32(mf.data() + M_OFF_PAYLOAD_SIZE, (uint32_t)payload.size()); - // block_size_log2 - { - uint32_t v = bs, l = 0; - while (v > 1) { - v >>= 1; - l++; - } - mf[M_OFF_BLOCK_SIZE_LOG2] = (uint8_t)l; - } - std::memcpy(mf.data() + M_OFF_MERKLE_ROOT, root, 4); - std::memcpy(mf.data() + M_OFF_IMAGE_HASH, image_hash.data(), 32); - mf[M_OFF_CODEC_ID] = codec; - std::memcpy(mf.data() + M_OFF_HW_ID, hw.data(), hw.size() < HW_ID_LEN ? hw.size() : HW_ID_LEN); - if (is_delta) std::memcpy(mf.data() + M_OFF_BASE_HASH, base_hash.data(), 8); // zero for full - if (signed_) { - uint8_t pub[32]; - if (o.sign_priv.size() != 32) return "signing key must be a 32-byte raw private seed"; - if (!ed25519_pub_from_priv(pub, o.sign_priv.data())) return "bad signing key"; - std::memcpy(mf.data() + M_OFF_SIGNER, pub, 32); - uint8_t sig[64]; - if (!ed25519_sign(sig, mf.data(), MOTA_SIGNED_LEN, o.sign_priv.data())) return "signing failed"; - std::memcpy(mf.data() + M_OFF_SIGNATURE, sig, 64); - } - std::memcpy(mf.data() + M_OFF_APPROVAL, APPROVAL_NOT, 4); - - // container = MAGIC(4) total(4) manifest leaves[] payload trailer(5) - uint32_t total = 8 + MOTA_MFL + bc * 4 + (uint32_t)payload.size() + 5; - // nRF52 in-place: the staged container sits below the apply workspace; if it's too big it overruns the - // workspace and the bootloader apply fails (DETOOLS_IO_FAILED). Warn so it's caught before shipping. - if (is_delta && codec == CODEC_DETOOLS_INPLACE && total > NRF52_MAX_INPLACE_MOTA) - std::fprintf(stderr, "warning: in-place delta is %u B, exceeding the nRF52 staging room (%u B) — it will " - "NOT apply on the device. Shrink the delta (smaller change) or use a smaller image.\n", - total, NRF52_MAX_INPLACE_MOTA); - out.clear(); - out.reserve(total); - out.insert(out.end(), MOTA_MAGIC, MOTA_MAGIC + 4); - uint8_t u[4]; - wr_u32(u, total); - out.insert(out.end(), u, u + 4); - out.insert(out.end(), mf.begin(), mf.end()); - out.insert(out.end(), leaves.begin(), leaves.end()); - out.insert(out.end(), payload.begin(), payload.end()); - out.insert(out.end(), MOTA_TRAILER, MOTA_TRAILER + 5); - - // suggested name: __v__.mota (descriptive + unique, one folder) - const char *kind = !is_delta ? "full" : (codec == CODEC_DETOOLS_INPLACE ? "ipdelta" : "seqdelta"); - char tgt[9]; - std::snprintf(tgt, sizeof(tgt), "%08X", target); - char vbuf[24]; - std::snprintf(vbuf, sizeof(vbuf), "%u.%u.%u", (fwver >> 24) & 0xFF, (fwver >> 16) & 0xFF, - (fwver >> 8) & 0xFF); - suggested_name = (hw.empty() ? std::string("fw") : hw) + "_" + tgt + "_v" + vbuf + "_" + kind + "_" + - to_hex(root, 4) + ".mota"; - return ""; -} - -} // namespace mota diff --git a/tools/motatool/src/mota.h b/tools/motatool/src/mota.h deleted file mode 100644 index e3024027c4..0000000000 --- a/tools/motatool/src/mota.h +++ /dev/null @@ -1,83 +0,0 @@ -// `.mota` container: parse, integrity-verify, and build (full / detools-delta). EndF identity helpers. -// Mirrors tools/mota/motalib.py and src/helpers/ota/MotaContainer.cpp (the single source of truth). -#pragma once -#include "mota_format.h" -#include -#include -#include - -namespace mota { - -struct FwIdent { - uint32_t fw_version = 0; - uint32_t target_id = 0; - std::string hw_id; // NUL-trimmed - bool any() const { return fw_version || target_id || !hw_id.empty(); } -}; - -struct Manifest { - uint8_t format_ver = 0, flags = 0, hash_algo = 0, codec_id = 0, block_size_log2 = 0; - uint32_t target_id = 0, fw_version = 0, image_size = 0, payload_size = 0, block_count = 0; - std::array merkle_root{}; - std::array image_hash{}; - std::array hw_id{}; // raw 32 bytes (NUL-padded) - std::array base_hash{}; - std::array signer{}; - std::array signature{}; - std::array approval{}; - - bool is_full() const { return flags & MFLAG_FULL; } - bool is_signed() const { return flags & MFLAG_SIGNED; } - uint32_t block_size() const { return 1u << block_size_log2; } - uint32_t leaves_off() const { return 8 + MOTA_MFL; } - uint32_t payload_off() const { return leaves_off() + block_count * 4; } - uint32_t total_size() const { return payload_off() + payload_size + 5; } - std::string hw_id_str() const; -}; - -// Parse + validate framing and the fixed layout. Returns "" on success (fills `out`), else an error. -std::string parse(const std::vector& blob, Manifest& out); - -// Content-integrity check: recompute leaves[] from the payload vs merkle_root, the merkle root, the -// FULL image_hash, and (if signed) the Ed25519 signature against the embedded signer key. Returns a list -// of problems (empty => valid). A delta's image_hash is not checked here (it needs the base image). -std::vector verify(const std::vector& blob); - -// ---- EndF identity (fixed 56-byte trailer) ---- -bool has_endf(const std::vector& image); -FwIdent parse_endf_ident(const std::vector& image); // zeros if no EndF -// Append a 56-byte EndF (with identity) if absent; returns the image and sets body_hash8. Idempotent. -std::vector ensure_endf(const std::vector& image, const FwIdent& id, - std::array& body_hash8); - -uint32_t target_id_for_env(const std::string& env); // sha2-256:4(env) as LE uint32 -bool pack_version(const std::string& s, uint32_t& out); // "1.16.0[.pre]" -> packed uint32 - -// Reverse-lookup a target_id to its PlatformIO env name from a static table of known OTA-capable envs -// (target_id = sha2-256:4(env_name)). Returns "" if not in the table. -std::string target_env_name(uint32_t target_id); - -// ---- build ---- -struct BuildOpts { - std::vector fw; // NEW firmware (raw or already-EndF'd) - std::vector base; // base image for a delta (empty => full) - uint8_t codec = CODEC_FULL; // CODEC_FULL / _SEQUENTIAL / _INPLACE - bool have_target = false; uint32_t target_id = 0; // overrides EndF - bool have_fwver = false; uint32_t fw_version = 0; // overrides EndF - std::string hw_id; // override; else from EndF - std::vector sign_priv; // 32-byte raw private seed (empty => unsigned) - uint32_t block_size = DEFAULT_BLOCK_SIZE; - uint32_t inplace_memory = NRF52_INPLACE_MEMORY, inplace_segment = NRF52_INPLACE_SEGMENT; - std::string detools = "detools"; // detools CLI path (delta encoding only) - bool force = false; // override the cross-hardware delta guard -}; -// Returns "" + fills `out` and a suggested file name on success; else an error string. -std::string build(const BuildOpts& o, std::vector& out, std::string& suggested_name); - -// Apply a SEQUENTIAL delta `patch` to `base_img` via the detools CLI -> reconstructed image in `out`. -// Used by `verify --base` to confirm a delta actually rebuilds the expected image (matches mota.py: -// in-place deltas aren't apply-checked here — the bootloader host-harness covers that path). -std::string detools_apply_seq(const std::string& detools, const std::vector& base_img, - const std::vector& patch, std::vector& out); - -} // namespace mota diff --git a/tools/motatool/src/mota_format.h b/tools/motatool/src/mota_format.h deleted file mode 100644 index 8a46c10f39..0000000000 --- a/tools/motatool/src/mota_format.h +++ /dev/null @@ -1,106 +0,0 @@ -// MeshCore `.mota` on-wire constants and fixed layout — a C++-friendly mirror of -// src/helpers/ota/OtaFormat.h. Keep byte-identical with that file and docs/ota_protocol.md. -// -// The format is FIXED-LAYOUT: every manifest field is at a constant offset and always present -// (base_hash/signer_pubkey/signature are zero-filled when not applicable). Only leaves[] varies. -#pragma once -#include -#include - -namespace mota { - -// container framing -static constexpr uint8_t MOTA_MAGIC[4] = {'m','O','T','A'}; -static constexpr uint8_t MOTA_TRAILER[5] = {'v','k','4','9','6'}; -static constexpr uint8_t ENDF_MAGIC[4] = {'E','n','d','F'}; - -static constexpr uint8_t HASH_ALGO_SHA256 = 0x12; -static constexpr uint8_t FORMAT_VER = 0x02; - -static constexpr uint8_t MFLAG_FULL = 0x01; -static constexpr uint8_t MFLAG_SIGNED = 0x02; - -static constexpr uint8_t CODEC_FULL = 0; -static constexpr uint8_t CODEC_DETOOLS_SEQUENTIAL = 1; // ESP32 A/B -static constexpr uint8_t CODEC_DETOOLS_INPLACE = 2; // nRF52 single-slot - -// hash truncations (sha2-256:N = first N bytes of the SHA-256 digest) -static constexpr size_t MH4 = 4, MH8 = 8, MH32 = 32; - -static constexpr uint8_t APPROVAL_NOT[4] = {0xFF,0xFF,0xFF,0xFF}; -static constexpr uint8_t APPROVAL_YES[4] = {'A','P','R','V'}; - -// EndF trailer (fixed 56 bytes): marker(4) body_len(4) body_hash8(8) fw_version(4) target_id(4) hw_id(32) -static constexpr uint32_t ENDF_LEN = 56; -static constexpr uint32_t ENDF_OFF_FWVER = 16; -static constexpr uint32_t ENDF_OFF_TARGET = 20; -static constexpr uint32_t ENDF_OFF_HWID = 24; - -static constexpr uint8_t HW_ID_LEN = 32; - -// manifest fixed offsets (within the manifest, i.e. container offset = 8 + these) -static constexpr uint32_t M_OFF_FORMAT_VER = 0; -static constexpr uint32_t M_OFF_FLAGS = 1; -static constexpr uint32_t M_OFF_HASH_ALGO = 2; -static constexpr uint32_t M_OFF_TARGET_ID = 3; -static constexpr uint32_t M_OFF_FW_VERSION = 7; -static constexpr uint32_t M_OFF_IMAGE_SIZE = 11; -static constexpr uint32_t M_OFF_PAYLOAD_SIZE = 15; -static constexpr uint32_t M_OFF_BLOCK_SIZE_LOG2 = 19; -static constexpr uint32_t M_OFF_MERKLE_ROOT = 20; // 4 -static constexpr uint32_t M_OFF_IMAGE_HASH = 24; // 32 -static constexpr uint32_t M_OFF_CODEC_ID = 56; // 1 -static constexpr uint32_t M_OFF_HW_ID = 57; // 32 -static constexpr uint32_t M_OFF_BASE_HASH = 89; // 8 (zero if FULL) -static constexpr uint32_t M_OFF_SIGNER = 97; // 32 (zero if unsigned) -static constexpr uint32_t M_OFF_SIGNATURE = 129; // 64 (zero if unsigned) -static constexpr uint32_t M_OFF_APPROVAL = 193; // 4 -static constexpr uint32_t MOTA_MFL = 197; // manifest-minus-leaves length (constant) -static constexpr uint32_t MOTA_SIGNED_LEN = 129; // signature covers manifest[0, 129) - -static constexpr uint32_t DEFAULT_BLOCK_SIZE = 1024; -static constexpr uint8_t DEFAULT_BLOCK_SIZE_LOG2 = 10; - -// nRF52 in-place apply workspace — MUST match src/helpers/ota/OtaFlashLayout_nrf52.h -// (MOTA_NRF52_INPLACE_MEMORY). It is NOT the full [APP_BASE, FS_START) span: the staged .mota itself sits -// just below FS_START, so the bootloader's workspace ends at the staged container (ws_hi = mota_addr), not -// at FS_START. The workspace is [APP_BASE, 0xBE000) = 0x98000, leaving 0xBE000..0xD4000 (~88 KB) for the -// staged delta. A patch built with a larger memory_size overruns the workspace at apply -> DETOOLS_IO_FAILED -// (the apply silently fails and the device just reboots). Reproduced + verified by the bootloader apply -// simulation (Adafruit_nRF52_Bootloader_OTAFIX/test/apply_sim). -static constexpr uint32_t NRF52_INPLACE_MEMORY = 0x00098000u; // 608 KB: [APP_BASE, 0xBE000) -static constexpr uint32_t NRF52_INPLACE_SEGMENT = 4096; -// The staged .mota sits in [APP_BASE+memory, FS_START); a larger container would push its start below the -// workspace end and break the apply the same way. Warn (motatool) / fail (bootloader) past this. -static constexpr uint32_t NRF52_FLASH_SPAN = 0x000D4000u - 0x00026000u; // 0xAE000 -static constexpr uint32_t NRF52_MAX_INPLACE_MOTA = NRF52_FLASH_SPAN - NRF52_INPLACE_MEMORY; // 0x16000 (~90 KB) -static_assert(NRF52_INPLACE_MEMORY < NRF52_FLASH_SPAN, "in-place workspace must leave room below FS_START for the staged .mota"); - -// ---- mota-seeder transport protocol (mirror of src/helpers/ota/MotaSeederProto.h) ---- -// Request (client -> server): 'M' 'S' op(1) args... xsum(1 = XOR of op+args) -// Response (server -> client): 'm' 's' op(1) status(1) payload... xsum(1 = XOR of all prior) -static constexpr uint8_t MS_REQ_MAGIC0 = 'M', MS_REQ_MAGIC1 = 'S'; -static constexpr uint8_t MS_RSP_MAGIC0 = 'm', MS_RSP_MAGIC1 = 's'; -static constexpr uint8_t MS_OP_COUNT = 0x01; // -> count(1) -static constexpr uint8_t MS_OP_DESCRIBE = 0x02; // idx(1) -> MotaDesc(38) -static constexpr uint8_t MS_OP_READ = 0x03; // idx(1) off(4) len(2) -> bytes -static constexpr uint8_t MS_OP_STAT = 0x04; // storage: mid(4) -> present(1) total(4) -static constexpr uint8_t MS_OP_BEGIN = 0x05; // storage: mid(4) total(4) -> OK (create 0xFF-filled) -static constexpr uint8_t MS_OP_WRITE = 0x06; // storage: mid(4) off(4) len(2) data(len) -> OK -static constexpr uint8_t MS_OP_SREAD = 0x07; // storage: mid(4) off(4) len(2) -> bytes (0xFF=unwritten) -static constexpr uint8_t MS_OP_FIN = 0x08; // storage: mid(4) -> OK (validate + make servable) -static constexpr uint8_t MS_STATUS_OK = 0x00; -static constexpr uint8_t MS_STATUS_ERR = 0x01; -static constexpr uint16_t MOTA_DESC_WIRE = 38; -static constexpr uint16_t MOTA_SEEDER_WRITE_MAX = 512; // max data bytes per OP_WRITE/OP_SREAD -// 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) [+2 reserved] - -inline uint32_t rd_u32(const uint8_t* p) { - return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24); -} -inline void wr_u32(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); -} - -} // namespace mota diff --git a/tools/motatool/src/serve.cpp b/tools/motatool/src/serve.cpp deleted file mode 100644 index 4430217ac4..0000000000 --- a/tools/motatool/src/serve.cpp +++ /dev/null @@ -1,344 +0,0 @@ -#include "serve.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include "util.h" - -namespace fs = std::filesystem; - -namespace mota { - -// ---- Folder: recursive scan + per-file validation ------------------------------------------------- -size_t Folder::scan(const std::string& dir, bool recursive, - const std::function& warn) { - motas_.clear(); - std::error_code ec; - auto consider = [&](const fs::path& p) { - if (p.extension() != ".mota") return; // skip non-.mota files - ServedMota sm; sm.path = p.string(); - if (!read_file(sm.path, sm.bytes)) { warn(sm.path, "cannot read"); return; } - auto probs = verify(sm.bytes); // merkle + image_hash + signature - if (!probs.empty()) { - std::string why = probs[0]; - for (size_t i = 1; i < probs.size(); i++) why += "; " + probs[i]; - warn(sm.path, why); // warn + exclude (don't sink the rest) - return; - } - parse(sm.bytes, sm.m); // already validated above - motas_.push_back(std::move(sm)); - }; - if (recursive) { - for (auto it = fs::recursive_directory_iterator(dir, ec); - !ec && it != fs::recursive_directory_iterator(); it.increment(ec)) - if (it->is_regular_file(ec)) consider(it->path()); - } else { - for (auto it = fs::directory_iterator(dir, ec); - !ec && it != fs::directory_iterator(); it.increment(ec)) - if (it->is_regular_file(ec)) consider(it->path()); - } - // stable, deterministic catalog order (indices are how the device addresses motas) - std::sort(motas_.begin(), motas_.end(), - [](const ServedMota& a, const ServedMota& b) { return a.path < b.path; }); - return motas_.size(); -} - -// ---- SeederCore (transport-agnostic) -------------------------------------------------------------- -std::array SeederCore::describe(const ServedMota& s) { - std::array w{}; - std::memcpy(w.data(), s.m.merkle_root.data(), 4); // mid - wr_u32(w.data() + 4, s.m.target_id); - wr_u32(w.data() + 8, s.m.fw_version); - w[12] = s.m.codec_id; - w[13] = s.m.flags; - wr_u32(w.data() + 14, (uint32_t)s.bytes.size()); // total_size - wr_u32(w.data() + 18, s.m.leaves_off()); - wr_u32(w.data() + 22, s.m.block_count); - wr_u32(w.data() + 26, s.m.payload_off()); - wr_u32(w.data() + 30, s.m.payload_size); - // [34..38) reserved 0 - return w; -} - -std::string SeederCore::store_path(const uint8_t mid[4], bool part) const { - static const char* H = "0123456789abcdef"; - std::string name; - for (int i = 0; i < 4; i++) { name += H[mid[i] >> 4]; name += H[mid[i] & 0xF]; } - name += part ? ".mota.part" : ".mota"; - return store_dir_ + "/" + name; -} - -bool SeederCore::handle(uint8_t op, const uint8_t* args, size_t arglen, - uint8_t& status, std::vector& payload) const { - payload.clear(); - status = MS_STATUS_OK; - if (op == MS_OP_COUNT) { - payload.push_back((uint8_t)(folder_.count() > 255 ? 255 : folder_.count())); - return true; - } - if (op == MS_OP_DESCRIBE) { - if (arglen < 1) return false; - const ServedMota* s = folder_.at(args[0]); - if (!s) { status = MS_STATUS_ERR; return true; } - auto w = describe(*s); - payload.assign(w.begin(), w.end()); - return true; - } - if (op == MS_OP_READ) { - if (arglen < 7) return false; - const ServedMota* s = folder_.at(args[0]); - uint32_t off = rd_u32(args + 1); - uint16_t len = (uint16_t)(args[5] | (args[6] << 8)); - if (!s || (uint64_t)off + len > s->bytes.size()) { status = MS_STATUS_ERR; return true; } - payload.assign(s->bytes.begin() + off, s->bytes.begin() + off + len); - return true; - } - // --- STORAGE ops: "pull to folder" — capture a .mota the device is fetching off-mesh into . - // All keyed by mid[4]; a partial pull is .mota.part, published to .mota on OP_FIN. --- - if (op == MS_OP_STAT || op == MS_OP_BEGIN || op == MS_OP_WRITE || op == MS_OP_SREAD || op == MS_OP_FIN) { - if (store_dir_.empty() || arglen < 4) { status = MS_STATUS_ERR; return true; } - const std::string part = store_path(args, true), done = store_path(args, false); - - if (op == MS_OP_STAT) { // present + size (completed wins over partial) - struct stat sx{}; uint8_t present = 0; uint32_t total = 0; - if (::stat(done.c_str(), &sx) == 0) { present = 1; total = (uint32_t)sx.st_size; } - else if (::stat(part.c_str(), &sx) == 0) { present = 1; total = (uint32_t)sx.st_size; } - payload.push_back(present); - uint8_t tb[4]; wr_u32(tb, total); payload.insert(payload.end(), tb, tb + 4); - return true; - } - if (op == MS_OP_BEGIN) { // fresh 0xFF-filled partial (start from 0) - if (arglen < 8) { status = MS_STATUS_ERR; return true; } - uint32_t total = rd_u32(args + 4); - int fd = ::open(part.c_str(), O_WRONLY | O_CREAT | O_TRUNC, 0644); - if (fd < 0) { status = MS_STATUS_ERR; return true; } - uint8_t ff[4096]; memset(ff, 0xFF, sizeof ff); - uint32_t left = total; bool ok = true; - while (left && ok) { uint32_t c = left < sizeof ff ? left : (uint32_t)sizeof ff; ok = ::write(fd, ff, c) == (ssize_t)c; left -= c; } - // Warm-start seed: drop a similar build's payload into the payload region so the device's leaf-diff - // (`ota pull … validate`) keeps the blocks that match the target and pulls only the differing ones. - // Header + leaves stay 0xFF (the device writes the real manifest; leaves mark "present" as they pass). - if (ok && !seed_payload_.empty()) { - uint32_t payload_off = 8 + MOTA_MFL + seed_bc_ * 4; - uint32_t n = (uint32_t)seed_payload_.size(); - if (payload_off + n > total) n = (payload_off < total) ? total - payload_off : 0; // clamp to the file - if (n) ok = ::pwrite(fd, seed_payload_.data(), n, payload_off) == (ssize_t)n; - } - ::close(fd); - status = ok ? MS_STATUS_OK : MS_STATUS_ERR; - return true; - } - if (op == MS_OP_WRITE) { - if (arglen < 10) { status = MS_STATUS_ERR; return true; } - uint32_t off = rd_u32(args + 4); - uint16_t len = (uint16_t)(args[8] | (args[9] << 8)); - if (arglen < (size_t)10 + len) { status = MS_STATUS_ERR; return true; } - int fd = ::open(part.c_str(), O_WRONLY); - if (fd < 0) { status = MS_STATUS_ERR; return true; } - bool ok = ::pwrite(fd, args + 10, len, off) == (ssize_t)len; - ::close(fd); - status = ok ? MS_STATUS_OK : MS_STATUS_ERR; - return true; - } - if (op == MS_OP_SREAD) { // read back (resume: recompute missing blocks) - if (arglen < 10) { status = MS_STATUS_ERR; return true; } - uint32_t off = rd_u32(args + 4); - uint16_t len = (uint16_t)(args[8] | (args[9] << 8)); - const std::string src = (::access(part.c_str(), F_OK) == 0) ? part : done; - int fd = ::open(src.c_str(), O_RDONLY); - if (fd < 0) { status = MS_STATUS_ERR; return true; } - payload.resize(len); - bool ok = ::pread(fd, payload.data(), len, off) == (ssize_t)len; - ::close(fd); - if (!ok) { payload.clear(); status = MS_STATUS_ERR; } - return true; - } - if (op == MS_OP_FIN) { // light-validate (MAGIC + size) then publish - struct stat sx{}; - if (::stat(part.c_str(), &sx) != 0) { status = MS_STATUS_ERR; return true; } - uint8_t hd[8] = {0}; int fd = ::open(part.c_str(), O_RDONLY); - bool got = fd >= 0 && ::read(fd, hd, 8) == 8; - if (fd >= 0) ::close(fd); - bool magic = got && hd[0] == 'm' && hd[1] == 'O' && hd[2] == 'T' && hd[3] == 'A'; - if (!magic || rd_u32(hd + 4) != (uint32_t)sx.st_size) { status = MS_STATUS_ERR; return true; } - std::error_code ec; fs::rename(part, done, ec); - status = ec ? MS_STATUS_ERR : MS_STATUS_OK; - return true; - } - } - return false; // unknown op -> ignore (device retries) -} - -// ---- SerialTransport ------------------------------------------------------------------------------ -static speed_t baud_const(int b) { - switch (b) { - case 9600: return B9600; case 19200: return B19200; case 38400: return B38400; - case 57600: return B57600; case 115200: return B115200; case 230400: return B230400; - case 460800: return B460800; case 921600: return B921600; default: return B115200; - } -} - -std::string SerialTransport::open(const std::string& dev, int baud) { - fd_ = ::open(dev.c_str(), O_RDWR | O_NOCTTY | O_NONBLOCK); - if (fd_ < 0) return "cannot open serial device: " + dev; - termios t{}; - if (tcgetattr(fd_, &t) != 0) { ::close(fd_); fd_ = -1; return "tcgetattr failed"; } - cfmakeraw(&t); - speed_t s = baud_const(baud); - cfsetispeed(&t, s); cfsetospeed(&t, s); - t.c_cflag |= (CLOCAL | CREAD); - t.c_cflag &= ~CRTSCTS; // no flow control (matches the daemon) - t.c_cc[VMIN] = 0; t.c_cc[VTIME] = 0; - if (tcsetattr(fd_, TCSANOW, &t) != 0) { ::close(fd_); fd_ = -1; return "tcsetattr failed"; } - return ""; -} - -SerialTransport::~SerialTransport() { if (fd_ >= 0) ::close(fd_); } - -int SerialTransport::read_byte(int timeout_ms) { - if (fd_ < 0) return -1; - fd_set rs; FD_ZERO(&rs); FD_SET(fd_, &rs); - timeval tv{ timeout_ms / 1000, (timeout_ms % 1000) * 1000 }; - int r = select(fd_ + 1, &rs, nullptr, nullptr, &tv); - if (r <= 0) return -1; - uint8_t b; - ssize_t n = ::read(fd_, &b, 1); - return n == 1 ? (int)b : -1; -} - -bool SerialTransport::write(const uint8_t* p, size_t n) { - if (fd_ < 0) return false; - size_t off = 0; - while (off < n) { - ssize_t w = ::write(fd_, p + off, n - off); - if (w < 0) return false; - off += (size_t)w; - } - return true; -} - -// ---- TcpTransport --------------------------------------------------------------------------------- -std::string TcpTransport::open(const std::string& host, int port) { - addrinfo hints{}; hints.ai_family = AF_UNSPEC; hints.ai_socktype = SOCK_STREAM; - addrinfo* res = nullptr; - std::string portstr = std::to_string(port); - if (getaddrinfo(host.c_str(), portstr.c_str(), &hints, &res) != 0 || !res) - return "cannot resolve host: " + host; - std::string err = "cannot connect to " + host + ":" + portstr; - for (addrinfo* p = res; p; p = p->ai_next) { - int fd = ::socket(p->ai_family, p->ai_socktype, p->ai_protocol); - if (fd < 0) continue; - if (::connect(fd, p->ai_addr, p->ai_addrlen) == 0) { fd_ = fd; err.clear(); break; } - ::close(fd); - } - freeaddrinfo(res); - return err; -} - -TcpTransport::~TcpTransport() { if (fd_ >= 0) ::close(fd_); } - -int TcpTransport::read_byte(int timeout_ms) { - if (fd_ < 0) return -1; - fd_set rs; FD_ZERO(&rs); FD_SET(fd_, &rs); - timeval tv{ timeout_ms / 1000, (timeout_ms % 1000) * 1000 }; - if (select(fd_ + 1, &rs, nullptr, nullptr, &tv) <= 0) return -1; - uint8_t b; - ssize_t n = ::recv(fd_, &b, 1, 0); - return n == 1 ? (int)b : -1; // 0 == peer closed -> treated as timeout/closed -} - -bool TcpTransport::write(const uint8_t* p, size_t n) { - if (fd_ < 0) return false; - size_t off = 0; - while (off < n) { - ssize_t w = ::send(fd_, p + off, n - off, 0); - if (w < 0) return false; - off += (size_t)w; - } - return true; -} - -// ---- seeder framing loop -------------------------------------------------------------------------- -static uint8_t xor_bytes(const uint8_t* p, size_t n, uint8_t seed = 0) { - uint8_t x = seed; for (size_t i = 0; i < n; i++) x ^= p[i]; return x; -} - -static bool read_exact(Transport& t, uint8_t* buf, size_t n) { - for (size_t i = 0; i < n; i++) { - int b = t.read_byte(500); - if (b < 0) return false; - buf[i] = (uint8_t)b; - } - return true; -} - -static void send_response(Transport& t, uint8_t op, uint8_t status, const std::vector& payload) { - std::vector frame; - frame.reserve(4 + payload.size() + 1); - frame.push_back(MS_RSP_MAGIC0); frame.push_back(MS_RSP_MAGIC1); - frame.push_back(op); frame.push_back(status); - frame.insert(frame.end(), payload.begin(), payload.end()); - frame.push_back(xor_bytes(frame.data(), frame.size())); // xsum over all prior bytes (incl. magic) - t.write(frame.data(), frame.size()); -} - -void serve_loop(Transport& t, const SeederCore& core, bool verbose, - const std::function& devline, - const volatile bool* stop) { - std::string line; - int prev = -1; - auto flush_line = [&]() { - while (!line.empty() && (line.back() == '\r' || line.back() == '\n')) line.pop_back(); - if (!line.empty() && devline) devline(line); - line.clear(); - }; - while (!stop || !*stop) { - int b = t.read_byte(200); - if (b < 0) continue; - if (prev == MS_REQ_MAGIC0 && b == MS_REQ_MAGIC1) { // 'M''S' -> a request follows - prev = -1; - uint8_t op; - if (!read_exact(t, &op, 1)) continue; - // fixed header bytes per op; OP_WRITE additionally reads `len` data bytes after its 10-byte header - size_t hdr = (op == MS_OP_COUNT) ? 0 : (op == MS_OP_DESCRIBE) ? 1 : (op == MS_OP_READ) ? 7 - : (op == MS_OP_STAT || op == MS_OP_FIN) ? 4 : (op == MS_OP_BEGIN) ? 8 - : (op == MS_OP_SREAD || op == MS_OP_WRITE) ? 10 : SIZE_MAX; - if (hdr == SIZE_MAX) continue; // unknown op - uint8_t args[10 + MOTA_SEEDER_WRITE_MAX]; - if (hdr && !read_exact(t, args, hdr)) continue; - size_t arglen = hdr; - if (op == MS_OP_WRITE) { // variable payload: header(10) + data(len) - uint16_t dlen = (uint16_t)(args[8] | (args[9] << 8)); - if (dlen > MOTA_SEEDER_WRITE_MAX) continue; // guard a runaway frame - if (dlen && !read_exact(t, args + 10, dlen)) continue; - arglen = (size_t)10 + dlen; - } - uint8_t xs; - if (!read_exact(t, &xs, 1)) continue; - if (xs != xor_bytes(args, arglen, op)) continue; // bad checksum -> ignore; device retries - uint8_t status; std::vector payload; - if (!core.handle(op, args, arglen, status, payload)) continue; - send_response(t, op, status, payload); - if (verbose) { - if (op == MS_OP_COUNT) devline("COUNT -> " + std::to_string(payload.empty() ? 0 : payload[0])); - else if (op == MS_OP_DESCRIBE) devline("DESCRIBE " + std::to_string(args[0]) + (status ? " ERR" : " OK")); - else if (op == MS_OP_READ) devline("READ " + std::to_string(args[0]) + " @" + - std::to_string(rd_u32(args + 1)) + (status ? " ERR" : " OK")); - } - continue; - } - if (prev >= 0) { // confirmed device text (not a frame start) - line.push_back((char)prev); - if (prev == '\n') flush_line(); - if (line.size() > 512) flush_line(); // guard runaway lines - } - prev = b; - } -} - -} // namespace mota diff --git a/tools/motatool/src/serve.h b/tools/motatool/src/serve.h deleted file mode 100644 index f551da9d64..0000000000 --- a/tools/motatool/src/serve.h +++ /dev/null @@ -1,95 +0,0 @@ -// Serve a folder of .mota to a MeshCore node. Split into a transport-AGNOSTIC protocol core (reusable -// over USB-serial today and BLE/GATT in the future) and a serial byte-stream framing layer. -#pragma once -#include "mota.h" -#include "mota_format.h" -#include -#include -#include -#include - -namespace mota { - -struct ServedMota { - std::string path; - std::vector bytes; - Manifest m; -}; - -// Recursively collect every *.mota under `dir`, validate each, keep only the valid ones. Corrupt/invalid -// files are reported through `warn(path, reason)` and excluded — one bad file never sinks the rest. -class Folder { -public: - size_t scan(const std::string& dir, bool recursive, - const std::function& warn); - size_t count() const { return motas_.size(); } - const ServedMota* at(size_t i) const { return i < motas_.size() ? &motas_[i] : nullptr; } - const std::vector& all() const { return motas_; } -private: - std::vector motas_; -}; - -// Transport-agnostic seeder: turns a (op, args) request into a (status, payload) response. The BLE path -// would call this directly from a characteristic-write handler and notify the reply — no framing needed. -class SeederCore { -public: - // `store_dir` (optional): folder where the "pull to folder" storage ops capture a `.mota` a device is - // fetching off-mesh — the SAME --dir as serving. A partial pull is `.mota.part`; OP_FIN publishes - // it as `.mota`. Empty store_dir = storage ops are refused (serve-only). - explicit SeederCore(const Folder& f, std::string store_dir = "") - : folder_(f), store_dir_(std::move(store_dir)) {} - bool handle(uint8_t op, const uint8_t* args, size_t arglen, - uint8_t& status, std::vector& payload) const; - static std::array describe(const ServedMota& s); - // Warm-start seed (motatool folder-capture): a *similar* build's payload. On OP_BEGIN, after the 0xFF-fill, - // the seed payload is written into the new `.part` at its payload region so the device can validate matching - // blocks against the target's merkle leaves and pull DATA only for the ones that differ (`ota pull … validate`). - void set_seed(std::vector payload, uint32_t block_count) { - seed_payload_ = std::move(payload); seed_bc_ = block_count; - } -private: - std::string store_path(const uint8_t mid[4], bool part) const; // /.mota[.part] - const Folder& folder_; - std::string store_dir_; - std::vector seed_payload_; // similar-build payload injected on OP_BEGIN (empty = no seed) - uint32_t seed_bc_ = 0; // seed's block_count (fixes payload_off = 8 + MOTA_MFL + bc*4) -}; - -// A bidirectional byte link (the serial seeder needs this; BLE would reuse SeederCore directly). -struct Transport { - virtual ~Transport() {} - virtual int read_byte(int timeout_ms) = 0; // a byte 0..255, or -1 on timeout/closed - virtual bool write(const uint8_t* p, size_t n) = 0; - bool write_str(const std::string& s) { return write((const uint8_t*)s.data(), s.size()); } -}; - -class SerialTransport : public Transport { -public: - std::string open(const std::string& dev, int baud); // "" on success - ~SerialTransport() override; - int read_byte(int timeout_ms) override; - bool write(const uint8_t* p, size_t n) override; -private: - int fd_ = -1; -}; - -// TCP client transport: connect to a node's WiFi seeder port (a dedicated port, NOT the companion port), -// then run the same seeder framing over the socket. Lets `serve` feed a WiFi node the same way as serial. -class TcpTransport : public Transport { -public: - std::string open(const std::string& host, int port); // "" on success - ~TcpTransport() override; - int read_byte(int timeout_ms) override; - bool write(const uint8_t* p, size_t n) override; -private: - int fd_ = -1; -}; - -// Seeder framing loop (transport-agnostic): resync on 'M''S', verify the request checksum, dispatch to -// `core`, frame the reply. Device text/log lines sharing the wire are surfaced via `devline` (serial -// only; the TCP seeder port carries no log text). Runs until *stop becomes true. -void serve_loop(Transport& t, const SeederCore& core, bool verbose, - const std::function& devline, - const volatile bool* stop); - -} // namespace mota diff --git a/tools/motatool/src/util.h b/tools/motatool/src/util.h deleted file mode 100644 index 9e5e51af48..0000000000 --- a/tools/motatool/src/util.h +++ /dev/null @@ -1,77 +0,0 @@ -// Small host-side helpers: file I/O, subprocess exec (no shell), hex. Header-only. -#pragma once -#include -#include -#include -#include -#include -#include -#include -#include - -namespace mota { - -inline bool read_file(const std::string& path, std::vector& out) { - std::ifstream f(path, std::ios::binary); - if (!f) return false; - out.assign(std::istreambuf_iterator(f), std::istreambuf_iterator()); - return (bool)f || f.eof(); -} - -inline bool write_file(const std::string& path, const uint8_t* data, size_t len) { - std::ofstream f(path, std::ios::binary | std::ios::trunc); - if (!f) return false; - f.write((const char*)data, (std::streamsize)len); - return (bool)f; -} - -// Run argv[0] with argv (no shell, so no quoting pitfalls). Returns the child exit code, or -1 to spawn. -// `quiet` redirects the child's stdout to /dev/null (its chatter), keeping stderr so errors still show. -inline int run_argv(const std::vector& argv, bool quiet = false) { - if (argv.empty()) return -1; - pid_t pid = fork(); - if (pid < 0) return -1; - if (pid == 0) { - if (quiet) { - int n = ::open("/dev/null", O_WRONLY); - if (n >= 0) { dup2(n, 1); if (n > 2) ::close(n); } - } - std::vector a; - for (auto& s : argv) a.push_back(const_cast(s.c_str())); - a.push_back(nullptr); - execvp(a[0], a.data()); - _exit(127); // exec failed - } - int st = 0; - if (waitpid(pid, &st, 0) < 0) return -1; - return WIFEXITED(st) ? WEXITSTATUS(st) : -1; -} - -inline std::string to_hex(const uint8_t* p, size_t n) { - static const char* H = "0123456789ABCDEF"; - std::string s; s.reserve(n * 2); - for (size_t i = 0; i < n; i++) { s.push_back(H[p[i] >> 4]); s.push_back(H[p[i] & 0xF]); } - return s; -} - -// Parse a hex string (optionally 0x-prefixed) into bytes. Returns false on odd length / bad char. -inline bool from_hex(const std::string& in, std::vector& out) { - std::string s = in; - if (s.size() >= 2 && s[0] == '0' && (s[1] == 'x' || s[1] == 'X')) s = s.substr(2); - if (s.size() % 2) return false; - out.clear(); - auto nib = [](char c) -> int { - if (c >= '0' && c <= '9') return c - '0'; - if (c >= 'a' && c <= 'f') return c - 'a' + 10; - if (c >= 'A' && c <= 'F') return c - 'A' + 10; - return -1; - }; - for (size_t i = 0; i < s.size(); i += 2) { - int hi = nib(s[i]), lo = nib(s[i + 1]); - if (hi < 0 || lo < 0) return false; - out.push_back((uint8_t)((hi << 4) | lo)); - } - return true; -} - -} // namespace mota diff --git a/tools/motatool/tests/test_motatool.cpp b/tools/motatool/tests/test_motatool.cpp deleted file mode 100644 index 371a5f632d..0000000000 --- a/tools/motatool/tests/test_motatool.cpp +++ /dev/null @@ -1,334 +0,0 @@ -// Unit tests for motatool's core logic (no external framework — a tiny built-in harness keeps the -// project self-contained). Run: ./build/motatool_tests or ctest --test-dir build --output-on-failure -#include "mota.h" -#include "crypto.h" -#include "serve.h" -#include "input.h" -#include "util.h" -#include "mota_format.h" -#include -#include -#include -#include - -namespace fs = std::filesystem; -using namespace mota; - -static int g_checks = 0, g_fail = 0; -static const char* g_test = ""; -#define CHECK(cond) do { g_checks++; if (!(cond)) { g_fail++; \ - std::cerr << " FAIL [" << g_test << "] " << __LINE__ << ": " #cond "\n"; } } while (0) - -// deterministic synthetic firmware body -static std::vector body(unsigned seed, size_t n) { - std::vector v(n); - uint32_t s = seed * 2654435761u + 1; - for (size_t i = 0; i < n; i++) { s = s * 1103515245u + 12345u; v[i] = (uint8_t)(s >> 16); } - return v; -} - -static bool detools_available() { - return run_argv({"detools", "--help"}, /*quiet=*/true) != 127; // 127 = exec failed (not installed) -} - -// --------------------------------------------------------------------------- -static void t_crypto() { - g_test = "crypto"; - // sha256("abc") = ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad - const char* abc = "abc"; - auto h4 = mh4((const uint8_t*)abc, 3); - auto h8 = mh8((const uint8_t*)abc, 3); - auto h32 = mh32((const uint8_t*)abc, 3); - std::vector exp; - from_hex("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", exp); - CHECK(std::memcmp(h4.data(), exp.data(), 4) == 0); - CHECK(std::memcmp(h8.data(), exp.data(), 8) == 0); - CHECK(std::memcmp(h32.data(), exp.data(), 32) == 0); - - uint8_t priv[32], pub[32], pub2[32]; - CHECK(ed25519_keygen(priv, pub)); - CHECK(ed25519_pub_from_priv(pub2, priv)); - CHECK(std::memcmp(pub, pub2, 32) == 0); - auto msg = body(1, 100); - uint8_t sig[64]; - CHECK(ed25519_sign(sig, msg.data(), msg.size(), priv)); - CHECK(ed25519_verify(sig, msg.data(), msg.size(), pub)); - msg[0] ^= 0xFF; // tampered message -> reject - CHECK(!ed25519_verify(sig, msg.data(), msg.size(), pub)); - msg[0] ^= 0xFF; - uint8_t other[32], opub[32]; ed25519_keygen(other, opub); - CHECK(!ed25519_verify(sig, msg.data(), msg.size(), opub)); // wrong key -> reject -} - -static void t_version_target() { - g_test = "version/target"; - uint32_t v; - CHECK(pack_version("1.16.0", v) && v == 0x01100000u); - CHECK(pack_version("2.0.0", v) && v == 0x02000000u); - CHECK(pack_version("1.16.0.2", v) && (v & 0xFF) == 2); - CHECK(!pack_version("", v)); - CHECK(!pack_version("x.y", v)); - // known target ids from the firmware builds (ties motatool's hashing to the device) - CHECK(target_id_for_env("Heltec_v3_repeater") == 0xd1b29b18u); - CHECK(target_id_for_env("RAK_4631_repeater") == 0x04d413fdu); - // reverse lookup table (human-readable target_id) -> env name, "" when unknown - CHECK(target_env_name(0xd1b29b18u) == "Heltec_v3_repeater"); - CHECK(target_env_name(0x04d413fdu) == "RAK_4631_repeater"); - CHECK(target_env_name(0xDEADBEEFu).empty()); - // the shared (generated) table must round-trip with our own hashing for a spread of OTA envs — - // this catches any drift between the table's stored ids and sha2-256:4(env) - for (const char* e : {"Tbeam_SX1262_repeater", "Xiao_C3_repeater", "ThinkNode_M2_room_server", - "Ebyte_EoRa-S3_companion_radio_ble", "Heltec_v3_companion_radio_usb"}) - CHECK(target_env_name(target_id_for_env(e)) == e); -} - -static void t_intel_hex() { - g_test = "intel hex"; - fs::path hx = fs::temp_directory_path() / "motatool_test.hex"; - // 3 data bytes (01 02 03) at addr 0, then EOF (checksum F7 = two's-complement of the byte sum) - std::string ok = ":03000000010203F7\n:00000001FF\n"; - write_file(hx.string(), (const uint8_t*)ok.data(), ok.size()); - std::vector out; - CHECK(read_input(hx.string(), out).empty()); - CHECK(out.size() == 3 && out[0] == 1 && out[1] == 2 && out[2] == 3); - // wrong checksum -> rejected - std::string bad = ":03000000010203F8\n:00000001FF\n"; - write_file(hx.string(), (const uint8_t*)bad.data(), bad.size()); - std::vector o2; - CHECK(!read_input(hx.string(), o2).empty()); - fs::remove(hx); -} - -static void t_endf() { - g_test = "endf"; - auto b = body(2, 1500); - std::array bh{}; - FwIdent id{0x01100000u, 0x04d413fdu, "RAK4631"}; - auto img = ensure_endf(b, id, bh); - CHECK(img.size() == b.size() + ENDF_LEN); - CHECK(has_endf(img)); - CHECK(std::memcmp(bh.data(), mh8(b.data(), b.size()).data(), 8) == 0); // body_hash is over BODY only - auto gi = parse_endf_ident(img); - CHECK(gi.fw_version == 0x01100000u && gi.target_id == 0x04d413fdu && gi.hw_id == "RAK4631"); - // idempotent: feeding an already-EndF'd image keeps it + reads the same hash - std::array bh2{}; - auto img2 = ensure_endf(img, FwIdent{}, bh2); - CHECK(img2 == img && bh2 == bh); - // zero identity -> still a 56-byte trailer, identity reads empty - std::array bz{}; - auto z = ensure_endf(b, FwIdent{}, bz); - CHECK(z.size() == b.size() + ENDF_LEN); - auto zi = parse_endf_ident(z); - CHECK(zi.fw_version == 0 && zi.target_id == 0 && zi.hw_id.empty()); - CHECK(!has_endf(std::vector{1,2,3})); // too short -} - -static BuildOpts full_opts(const std::vector& fw, const std::vector& priv = {}) { - BuildOpts o; o.fw = fw; o.codec = CODEC_FULL; - o.have_target = true; o.target_id = 0x04d413fdu; - o.have_fwver = true; o.fw_version = 0x01100000u; - o.hw_id = "RAK4631"; o.sign_priv = priv; - return o; -} - -static void t_build_full() { - g_test = "build full"; - auto fw = body(3, 5 * 1024 + 100); // 6 blocks @1024 - std::vector blob; std::string name; - CHECK(build(full_opts(fw), blob, name).empty()); - Manifest m; - CHECK(parse(blob, m).empty()); - CHECK(m.format_ver == FORMAT_VER && m.is_full() && !m.is_signed()); - CHECK(m.codec_id == CODEC_FULL); - CHECK(m.target_id == 0x04d413fdu && m.fw_version == 0x01100000u && m.hw_id_str() == "RAK4631"); - CHECK(m.block_count == 6); - // fixed layout offsets - CHECK(m.leaves_off() == 8 + MOTA_MFL && m.leaves_off() == 205); - CHECK(m.payload_off() == 205 + m.block_count * 4); - CHECK(m.total_size() == blob.size()); - // payload IS the image (body+EndF); image_hash matches - uint32_t poff = m.payload_off(); - auto img_h = mh32(blob.data() + poff, m.payload_size); - CHECK(std::memcmp(img_h.data(), m.image_hash.data(), 32) == 0); - CHECK(m.image_size == m.payload_size); - CHECK(verify(blob).empty()); // clean - CHECK(name.find(".mota") != std::string::npos && name.find("full") != std::string::npos); -} - -static void t_build_signed_and_tamper() { - g_test = "build signed/tamper"; - uint8_t priv[32], pub[32]; ed25519_keygen(priv, pub); - std::vector pv(priv, priv + 32); - auto fw = body(4, 3000); - std::vector blob; std::string name; - CHECK(build(full_opts(fw, pv), blob, name).empty()); - Manifest m; CHECK(parse(blob, m).empty()); - CHECK(m.is_signed()); - CHECK(std::memcmp(m.signer.data(), pub, 32) == 0); - CHECK(verify(blob).empty()); - // tamper a payload byte -> integrity fails - auto t1 = blob; t1[m.payload_off() + 10] ^= 0xFF; - CHECK(!verify(t1).empty()); - // tamper a signed-region byte (target_id @ manifest+3 = blob+11) -> signature invalid - auto t2 = blob; t2[8 + M_OFF_TARGET_ID] ^= 0xFF; - bool sig_flagged = false; - for (auto& p : verify(t2)) if (p.find("signature") != std::string::npos) sig_flagged = true; - CHECK(sig_flagged); -} - -static void t_corruption_and_approval() { - g_test = "corruption/approval"; - auto fw = body(5, 4096); - std::vector blob; std::string name; - build(full_opts(fw), blob, name); - Manifest m; parse(blob, m); - // flip a payload byte -> leaves/merkle/image_hash problems reported - auto c = blob; c[m.payload_off() + 1] ^= 0xFF; - CHECK(!verify(c).empty()); - // a pre-approved container must be flagged (approval is outside the signed region) - auto ap = blob; std::memcpy(ap.data() + 8 + M_OFF_APPROVAL, APPROVAL_YES, 4); - bool approved_flagged = false; - for (auto& p : verify(ap)) if (p.find("approved") != std::string::npos) approved_flagged = true; - CHECK(approved_flagged); -} - -static void t_parse_rejects() { - g_test = "parse rejects"; - auto fw = body(6, 2048); - std::vector blob; std::string name; build(full_opts(fw), blob, name); - Manifest m; - CHECK(!parse(std::vector(10, 0), m).empty()); // too small - auto bad = blob; bad[0] ^= 0xFF; CHECK(!parse(bad, m).empty()); // bad magic - bad = blob; bad[bad.size() - 1] ^= 0xFF; CHECK(!parse(bad, m).empty()); // bad trailer - bad = blob; bad[4] ^= 0xFF; CHECK(!parse(bad, m).empty()); // wrong total_size - bad = blob; bad[8 + M_OFF_FORMAT_VER] = 9; CHECK(!parse(bad, m).empty()); // bad format_ver -} - -static void t_delta() { - g_test = "delta"; - if (!detools_available()) { std::cerr << " SKIP [delta] detools not on PATH\n"; return; } - auto base_body = body(10, 4000); - auto new_body = base_body; - for (int i : {100, 101, 2000, 3999}) new_body[i] ^= 0x33; - auto tail = body(11, 250); - new_body.insert(new_body.end(), tail.begin(), tail.end()); - - for (uint8_t codec : {CODEC_DETOOLS_SEQUENTIAL, CODEC_DETOOLS_INPLACE}) { - BuildOpts o; o.fw = new_body; o.base = base_body; o.codec = codec; - o.have_target = true; o.target_id = 0x04d413fdu; o.have_fwver = true; o.fw_version = 0x02000000u; - o.hw_id = "RAK4631"; - std::vector blob; std::string name; - std::string e = build(o, blob, name); - CHECK(e.empty()); - if (!e.empty()) continue; - Manifest m; CHECK(parse(blob, m).empty()); - CHECK(!m.is_full() && m.codec_id == codec); - // base_hash == mh8 of the base BODY - CHECK(std::memcmp(m.base_hash.data(), mh8(base_body.data(), base_body.size()).data(), 8) == 0); - CHECK(verify(blob).empty()); - if (codec == CODEC_DETOOLS_SEQUENTIAL) { // round-trip: apply -> rebuilds the new image - std::array bh{}; - auto base_img = ensure_endf(base_body, FwIdent{}, bh); - std::vector patch(blob.begin() + m.payload_off(), blob.begin() + m.payload_off() + m.payload_size); - std::vector recon; - CHECK(detools_apply_seq("detools", base_img, patch, recon).empty()); - CHECK(std::memcmp(mh32(recon.data(), recon.size()).data(), m.image_hash.data(), 32) == 0); - } - } -} - -static void t_folder_and_seeder() { - g_test = "folder/seeder"; - fs::path dir = fs::temp_directory_path() / "motatool_test_folder"; - fs::remove_all(dir); fs::create_directories(dir / "sub"); - - // two valid motas (one in a sub-folder, to exercise recursion) + one corrupt - std::vector a, b; std::string n; - build(full_opts(body(20, 3000)), a, n); write_file((dir / "a.mota").string(), a.data(), a.size()); - build(full_opts(body(21, 6000)), b, n); write_file((dir / "sub" / "b.mota").string(), b.data(), b.size()); - auto bad = a; bad[a.size() / 2] ^= 0xFF; write_file((dir / "bad.mota").string(), bad.data(), bad.size()); - write_file((dir / "ignore.txt").string(), a.data(), 10); // non-.mota -> skipped silently - - int warns = 0; std::string warned_path; - Folder folder; - size_t kept = folder.scan(dir.string(), true, - [&](const std::string& p, const std::string&){ warns++; warned_path = p; }); - CHECK(kept == 2); // two valid kept - CHECK(warns == 1 && warned_path.find("bad.mota") != std::string::npos); // corrupt warned + excluded - - SeederCore core(folder); - uint8_t status; std::vector pl; - CHECK(core.handle(MS_OP_COUNT, nullptr, 0, status, pl) && status == MS_STATUS_OK && pl.size() == 1 && pl[0] == 2); - - uint8_t arg0[1] = {0}; - CHECK(core.handle(MS_OP_DESCRIBE, arg0, 1, status, pl) && status == MS_STATUS_OK && pl.size() == MOTA_DESC_WIRE); - const ServedMota* s0 = folder.at(0); - CHECK(std::memcmp(pl.data(), s0->m.merkle_root.data(), 4) == 0); // mid - CHECK(rd_u32(pl.data() + 4) == s0->m.target_id); - CHECK(rd_u32(pl.data() + 14) == (uint32_t)s0->bytes.size()); // total_size - CHECK(rd_u32(pl.data() + 18) == s0->m.leaves_off() && rd_u32(pl.data() + 18) == 205); - CHECK(rd_u32(pl.data() + 22) == s0->m.block_count); - CHECK(rd_u32(pl.data() + 26) == s0->m.payload_off()); - CHECK(rd_u32(pl.data() + 30) == s0->m.payload_size); - - uint8_t bad_idx[1] = {99}; - CHECK(core.handle(MS_OP_DESCRIBE, bad_idx, 1, status, pl) && status == MS_STATUS_ERR); - - // READ idx=0 off=0 len=8 -> first 8 bytes (MAGIC + total_size) - uint8_t rd[7] = {0, 0,0,0,0, 8,0}; - CHECK(core.handle(MS_OP_READ, rd, 7, status, pl) && status == MS_STATUS_OK && pl.size() == 8); - CHECK(std::memcmp(pl.data(), s0->bytes.data(), 8) == 0); - // READ past EOF -> error - uint8_t reof[7]; reof[0] = 0; wr_u32(reof + 1, (uint32_t)s0->bytes.size()); reof[5] = 16; reof[6] = 0; - CHECK(core.handle(MS_OP_READ, reof, 7, status, pl) && status == MS_STATUS_ERR); - - // --- STORAGE ops ("pull to folder"): STAT -> BEGIN -> WRITE -> SREAD -> FIN round-trip --- - fs::path sdir = dir / "store"; fs::create_directories(sdir); - SeederCore store(folder, sdir.string()); - uint8_t mid[4] = {0xDE, 0xAD, 0xBE, 0xEF}; - auto midpath = [&](bool part) { - static const char* H = "0123456789abcdef"; std::string nm; - for (int i = 0; i < 4; i++) { nm += H[mid[i] >> 4]; nm += H[mid[i] & 0xF]; } - nm += part ? ".mota.part" : ".mota"; return (sdir / nm).string(); - }; - // STAT before anything -> present=0 - CHECK(store.handle(MS_OP_STAT, mid, 4, status, pl) && status == MS_STATUS_OK && pl.size() == 5 && pl[0] == 0); - // BEGIN total=64 -> 0xFF-filled partial - uint8_t beg[8]; std::memcpy(beg, mid, 4); wr_u32(beg + 4, 64); - CHECK(store.handle(MS_OP_BEGIN, beg, 8, status, pl) && status == MS_STATUS_OK); - CHECK(store.handle(MS_OP_STAT, mid, 4, status, pl) && pl[0] == 1 && rd_u32(pl.data() + 1) == 64); - // WRITE the header (MAGIC + total) + a blob at offset 0 - const uint8_t blob[16] = {'m','O','T','A', 64,0,0,0, 1,2,3,4,5,6,7,8}; - uint8_t wr[10 + 16]; std::memcpy(wr, mid, 4); wr_u32(wr + 4, 0); wr[8] = 16; wr[9] = 0; std::memcpy(wr + 10, blob, 16); - CHECK(store.handle(MS_OP_WRITE, wr, 10 + 16, status, pl) && status == MS_STATUS_OK); - // SREAD back the 16 written bytes; an unwritten region reads 0xFF - uint8_t srd[10]; std::memcpy(srd, mid, 4); wr_u32(srd + 4, 0); srd[8] = 16; srd[9] = 0; - CHECK(store.handle(MS_OP_SREAD, srd, 10, status, pl) && pl.size() == 16 && std::memcmp(pl.data(), blob, 16) == 0); - wr_u32(srd + 4, 32); srd[8] = 8; srd[9] = 0; - CHECK(store.handle(MS_OP_SREAD, srd, 10, status, pl) && pl.size() == 8 && pl[0] == 0xFF && pl[7] == 0xFF); - // FIN -> validates + publishes .mota - CHECK(fs::exists(midpath(true)) && !fs::exists(midpath(false))); - CHECK(store.handle(MS_OP_FIN, mid, 4, status, pl) && status == MS_STATUS_OK); - CHECK(!fs::exists(midpath(true)) && fs::exists(midpath(false)) && fs::file_size(midpath(false)) == 64); - CHECK(store.handle(MS_OP_STAT, mid, 4, status, pl) && pl[0] == 1 && rd_u32(pl.data() + 1) == 64); - // storage refused on a serve-only core (empty store_dir) - CHECK(core.handle(MS_OP_STAT, mid, 4, status, pl) && status == MS_STATUS_ERR); - - fs::remove_all(dir); -} - -int main() { - t_crypto(); - t_version_target(); - t_intel_hex(); - t_endf(); - t_build_full(); - t_build_signed_and_tamper(); - t_corruption_and_approval(); - t_parse_rejects(); - t_delta(); - t_folder_and_seeder(); - - std::cout << (g_fail ? "FAILED " : "OK ") << (g_checks - g_fail) << "/" << g_checks << " checks passed\n"; - return g_fail ? 1 : 0; -} From 3884086ad458f844e23eeb427d778c42ee1438ed Mon Sep 17 00:00:00 2001 From: Ben Allfree Date: Fri, 17 Jul 2026 13:00:19 -0700 Subject: [PATCH 27/59] ota: fix ls tag for completed fetch Co-authored-by: Cursor (cherry picked from commit 71841856ecd12b85af25b30a817f5f6d3fbe4702) --- src/helpers/ota/OtaCli.cpp | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/helpers/ota/OtaCli.cpp b/src/helpers/ota/OtaCli.cpp index 039f10f1e0..ea3dae5528 100644 --- a/src/helpers/ota/OtaCli.cpp +++ b/src/helpers/ota/OtaCli.cpp @@ -185,13 +185,22 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board const int CAP = 160; int n = snprintf(reply, CAP, "Updates nearby (%u src) — `ota get <#>` to download:", (unsigned)c.manager.sourceCount()); - const uint8_t* cur = (c.manager.fetchState() != OtaManager::IDLE) ? c.manager.fetchManifestId() : nullptr; + OtaManager::FetchState fs = c.manager.fetchState(); + const uint8_t* cur = (fs != OtaManager::IDLE) ? c.manager.fetchManifestId() : nullptr; uint32_t myt = c.manager.target(); // effective target (EndF identity if present, else build flag) 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 < 48) { more++; continue; } bool on = cur && memcmp(cur, h->mid, 4) == 0; + // Tag the active session by real fetch state (not always "downloading" — COMPLETE is ready). + const char* tag = ""; + if (on) { + if (fs == OtaManager::COMPLETE) tag = " [ready]"; + else if (fs == OtaManager::PAUSED) tag = " [paused]"; + else if (fs == OtaManager::FAILED) tag = " [failed]"; + else tag = " [downloading]"; // WANT_*/FETCHING + } uint32_t age = (now - h->last_ms) / 1000; if (age > 99999) age = 99999; char ver[20]; ver_str(ver, sizeof ver, h->fw_version); // What is this update for? "yours" if same target (hw+role) as us; else the target's env name when we @@ -205,8 +214,7 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board else if (h->target_id == 0) fit = "?"; else { snprintf(hwbuf, sizeof hwbuf, "hw %08X", (unsigned)h->target_id); fit = hwbuf; } n += snprintf(reply + n, CAP - n, "\n %d) %s %s [%s] %un %us%s", shown + 1, ver, - codec_kind(h->codec), fit, (unsigned)h->n_seeders, (unsigned)age, - on ? " [downloading]" : ""); + codec_kind(h->codec), fit, (unsigned)h->n_seeders, (unsigned)age, tag); shown++; } if (more && n < CAP) snprintf(reply + n, CAP - n, "\n +%d more", more); From 1192b852b4887b741dc0e5a0d8b8e59bf0700f3f Mon Sep 17 00:00:00 2001 From: Ben Allfree Date: Fri, 17 Jul 2026 18:20:05 -0700 Subject: [PATCH 28/59] ota ls: start listing at entry N Co-authored-by: Cursor (cherry picked from commit dcf051f868892fba167e139a0852c814f7d6ff55) --- src/helpers/ota/OtaCli.cpp | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/helpers/ota/OtaCli.cpp b/src/helpers/ota/OtaCli.cpp index ea3dae5528..94f8c50fa1 100644 --- a/src/helpers/ota/OtaCli.cpp +++ b/src/helpers/ota/OtaCli.cpp @@ -180,16 +180,29 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board } else if (is_cmd(a, "neighbors|nbrs|updates|ls|n", &rest)) { // Kick a fresh round of catalog queries (async — rows arrive over the next seconds); render what we // have now in plain words. The reply buffer is 160 B (serial / one LoRa packet for remote-admin), so - // writes are bounded and extra rows collapse to "+N more". + // writes are bounded and extra rows collapse to "+N more". Optional `ota ls ` starts at entry N. c.manager.queryAll(); const int CAP = 160; + const char* p = rest; while (*p == ' ') p++; + int start = 1; + if (*p >= '0' && *p <= '9') { + start = (int)parse_u32(p); + if (start < 1) { strcpy(reply, "ERR usage: ota ls [N] (start at entry N, default 1)"); return true; } + } + uint8_t cat_n = c.manager.catalogCount(); + if (start > cat_n && cat_n > 0) { + snprintf(reply, CAP, "ERR entry %d > catalog (%u)", start, (unsigned)cat_n); + return true; + } int n = snprintf(reply, CAP, "Updates nearby (%u src) — `ota get <#>` to download:", (unsigned)c.manager.sourceCount()); OtaManager::FetchState fs = c.manager.fetchState(); const uint8_t* cur = (fs != OtaManager::IDLE) ? c.manager.fetchManifestId() : nullptr; uint32_t myt = c.manager.target(); // effective target (EndF identity if present, else build flag) uint32_t now = millis(); int shown = 0, more = 0; - for (uint8_t i = 0; i < c.manager.catalogCount(); i++) { + for (uint8_t i = 0; i < cat_n; i++) { + const int num = i + 1; + if (num < start) continue; const OtaManager::CatRow* h = c.manager.catalogRow(i); if (CAP - n < 48) { more++; continue; } bool on = cur && memcmp(cur, h->mid, 4) == 0; @@ -213,7 +226,7 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board else if (env) fit = env; else if (h->target_id == 0) fit = "?"; else { snprintf(hwbuf, sizeof hwbuf, "hw %08X", (unsigned)h->target_id); fit = hwbuf; } - n += snprintf(reply + n, CAP - n, "\n %d) %s %s [%s] %un %us%s", shown + 1, ver, + n += snprintf(reply + n, CAP - n, "\n %d) %s %s [%s] %un %us%s", num, ver, codec_kind(h->codec), fit, (unsigned)h->n_seeders, (unsigned)age, tag); shown++; } From b330b98fdf2f447799100226839f885c34cee35e Mon Sep 17 00:00:00 2001 From: Ben Allfree Date: Fri, 17 Jul 2026 18:29:59 -0700 Subject: [PATCH 29/59] Add next-hop retry for direct-path relays Co-authored-by: Cursor (cherry picked from commit 97470f418cdef8ed52834b3d2bd2ba62108f1fd9) --- docs/cli_commands.md | 17 ++++ docs/faq.md | 4 + examples/simple_repeater/MyMesh.cpp | 2 + examples/simple_repeater/MyMesh.h | 6 ++ examples/simple_room_server/MyMesh.cpp | 2 + examples/simple_room_server/MyMesh.h | 6 ++ src/Mesh.cpp | 114 ++++++++++++++++++++++++- src/Mesh.h | 37 ++++++++ src/helpers/CommonCLI.cpp | 35 +++++++- src/helpers/CommonCLI.h | 2 + 10 files changed, 221 insertions(+), 4 deletions(-) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 9accb2998d..e4e01103e7 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -499,6 +499,21 @@ This document provides an overview of CLI commands that can be sent to MeshCore --- +#### View or change next-hop retry for direct-path relays +**Usage:** +- `get hop.retry` +- `set hop.retry ` +- `get hop.retry.ms` +- `set hop.retry.ms ` + +**Parameters:** +- `count`: Extra retransmits if the next hop is not overheard relaying (0-5). `0` disables hop retry (fire-and-forget). Default `2` means up to 3 total transmissions (initial + 2 retries). +- `milliseconds`: Listen window before each retry (200-10000). Default `1500`. + +**Note:** When a repeater forwards a direct-path packet, it listens for the next hop's retransmit as a soft ACK. If nothing is heard within `hop.retry.ms`, it sends again. Not used on the last hop (zero-hop delivery to the destination). Prefer `set multi.acks 0` when hop retry is enabled — hop retry already covers ACK reliability per hop. + +--- + #### [Experimental] View or change the processing delay for received traffic **Usage:** - `get rxdelay` @@ -586,6 +601,8 @@ This document provides an overview of CLI commands that can be sent to MeshCore **Default:** `0` +**Note:** Sends extra copies of direct ACKs. With hop retry enabled (`hop.retry` > 0), prefer leaving this off — hop retry listens for the next hop's relay instead of blind duplicate ACKs. + --- #### View or change the flood advert interval diff --git a/docs/faq.md b/docs/faq.md index 5ebdb9b614..8ef0e9ab9f 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -53,6 +53,7 @@ A list of frequently-asked questions and answers for MeshCore - [5.2. Q: Do MeshCore clients repeat?](#52-q-do-meshcore-clients-repeat) - [5.3. Q: What happens when a node learns a route via a mobile repeater, and that repeater is gone?](#53-q-what-happens-when-a-node-learns-a-route-via-a-mobile-repeater-and-that-repeater-is-gone) - [5.4. Q: How does a node discover a path to its destination and then use it to send messages in the future, instead of flooding every message it sends like Meshtastic?](#54-q-how-does-a-node-discover-a-path-to-its-destination-and-then-use-it-to-send-messages-in-the-future-instead-of-flooding-every-message-it-sends-like-meshtastic) + - [5.4.1. Q: What is next-hop retry on repeaters?](#541-q-what-is-next-hop-retry-on-repeaters) - [5.5. Q: Do public channels always flood? Do private channels always flood?](#55-q-do-public-channels-always-flood-do-private-channels-always-flood) - [5.6. Q: What is the public key for the default public channel?](#56-q-what-is-the-public-key-for-the-default-public-channel) - [5.7. Q: Is MeshCore open source?](#57-q-is-meshcore-open-source) @@ -486,6 +487,9 @@ Routes are stored in sender's contact list. When you send a message the first ti [Source](https://discord.com/channels/826570251612323860/1330643963501351004/1351279141630119996) +### 5.4.1. Q: What is next-hop retry on repeaters? +When a repeater forwards a direct-path packet, it normally transmits once and moves on. With hop retry enabled (default on repeaters: `hop.retry 2`, `hop.retry.ms 1500`), the repeater listens for the next hop to retransmit the same packet. Hearing that relay acts as a per-hop ACK. If the next hop is silent for the listen window, the repeater sends again (up to `hop.retry` extra times). This does not apply on the last hop before the destination — the destination does not retransmit. Clients still use end-to-end ACK timeouts for delivery confirmation; hop retry fixes silent mid-path relays. Prefer `set multi.acks 0` when using hop retry. + ### 5.5. Q: Do public channels always flood? Do private channels always flood? **A:** Yes, group channels are A to B, so there is no defined path. They have to flood. Repeaters can however deny flood traffic up to some hop limit, with the `set flood.max` CLI command. Administrators of repeaters get to set the rules of their repeaters. diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 37169efef6..30bb5e96c2 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -878,6 +878,8 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc _prefs.rx_delay_base = 0.0f; // turn off by default, was 10.0; _prefs.tx_delay_factor = 0.5f; // was 0.25f _prefs.direct_tx_delay_factor = 0.3f; // was 0.2 + _prefs.hop_retry = 2; + _prefs.hop_retry_ms = 1500; StrHelper::strncpy(_prefs.node_name, ADVERT_NAME, sizeof(_prefs.node_name)); _prefs.node_lat = ADVERT_LAT; _prefs.node_lon = ADVERT_LON; diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 7925337130..bd760bcd3b 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -159,6 +159,12 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { uint8_t getExtraAckTransmitCount() const override { return _prefs.multi_acks; } + uint8_t getHopRetryCount() const override { + return _prefs.hop_retry; + } + uint16_t getHopRetryTimeoutMs() const override { + return _prefs.hop_retry_ms; + } #if ENV_INCLUDE_GPS == 1 void applyGpsPrefs() { diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 98b22fdb72..09d53db5fd 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -634,6 +634,8 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc _prefs.rx_delay_base = 0.0f; // off by default, was 10.0 _prefs.tx_delay_factor = 0.5f; // was 0.25f; _prefs.direct_tx_delay_factor = 0.2f; // was zero + _prefs.hop_retry = 2; + _prefs.hop_retry_ms = 1500; StrHelper::strncpy(_prefs.node_name, ADVERT_NAME, sizeof(_prefs.node_name)); _prefs.node_lat = ADVERT_LAT; _prefs.node_lon = ADVERT_LON; diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 4f464ed683..401fedff06 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -150,6 +150,12 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { uint8_t getExtraAckTransmitCount() const override { return _prefs.multi_acks; } + uint8_t getHopRetryCount() const override { + return _prefs.hop_retry; + } + uint16_t getHopRetryTimeoutMs() const override { + return _prefs.hop_retry_ms; + } bool filterRecvFloodPacket(mesh::Packet* pkt) override; diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 35c81e6df0..278907db36 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -55,6 +55,7 @@ void Mesh::begin() { } void Mesh::loop() { + tickHopRetryPending(); Dispatcher::loop(); #if defined(ENABLE_OTA) // Deferred apply-reboot: a verified `ota applydelta` approves the update but does NOT reboot inline, @@ -138,6 +139,107 @@ uint8_t Mesh::getExtraAckTransmitCount() const { return 0; } +void Mesh::copyPacketFields(Packet* dest, const Packet* src) { + dest->header = src->header; + dest->path_len = src->path_len; + dest->payload_len = src->payload_len; + dest->transport_codes[0] = src->transport_codes[0]; + dest->transport_codes[1] = src->transport_codes[1]; + dest->_snr = 0; + memcpy(dest->path, src->path, src->getPathByteLen()); + memcpy(dest->payload, src->payload, src->payload_len); +} + +void Mesh::clearHopRetryPending(int idx) { + if (_hop_retry_pending[idx].pkt) { + releasePacket(_hop_retry_pending[idx].pkt); + } + _hop_retry_pending[idx].pkt = NULL; + _hop_retry_pending[idx].retries_left = 0; +} + +void Mesh::clearHopRetryPendingByHash(const uint8_t* hash) { + for (int i = 0; i < HOP_RETRY_PENDING_MAX; i++) { + if (_hop_retry_pending[i].pkt && memcmp(_hop_retry_pending[i].hash, hash, MAX_HASH_SIZE) == 0) { + clearHopRetryPending(i); + } + } +} + +void Mesh::armHopRetryPending(const Packet* forwarded, uint32_t initial_delay_ms) { + uint8_t retries = getHopRetryCount(); + if (retries == 0 || forwarded->getPathHashCount() == 0) return; + if (_mgr->getFreeCount() < HOP_RETRY_MIN_FREE) return; + + uint8_t hash[MAX_HASH_SIZE]; + forwarded->calculatePacketHash(hash); + clearHopRetryPendingByHash(hash); + + int slot = -1; + for (int i = 0; i < HOP_RETRY_PENDING_MAX; i++) { + if (_hop_retry_pending[i].pkt == NULL) { + slot = i; + break; + } + } + if (slot < 0) { + clearHopRetryPending(0); + slot = 0; + } + + Packet* clone = obtainNewPacket(); + if (clone == NULL) return; + + copyPacketFields(clone, forwarded); + memcpy(_hop_retry_pending[slot].hash, hash, MAX_HASH_SIZE); + _hop_retry_pending[slot].next_hop_sz = forwarded->getPathHashSize(); + memcpy(_hop_retry_pending[slot].next_hop, forwarded->path, _hop_retry_pending[slot].next_hop_sz); + _hop_retry_pending[slot].pkt = clone; + _hop_retry_pending[slot].retries_left = retries; + _hop_retry_pending[slot].deadline = futureMillis(getHopRetryTimeoutMs() + initial_delay_ms); +} + +void Mesh::checkHopRetryOverhear(const Packet* pkt) { + if (!pkt->isRouteDirect()) return; + + uint8_t hash[MAX_HASH_SIZE]; + pkt->calculatePacketHash(hash); + + for (int i = 0; i < HOP_RETRY_PENDING_MAX; i++) { + HopRetryPending* p = &_hop_retry_pending[i]; + if (p->pkt == NULL) continue; + if (memcmp(p->hash, hash, MAX_HASH_SIZE) != 0) continue; + + bool next_hop_still_front = pkt->getPathHashCount() > 0 + && memcmp(pkt->path, p->next_hop, p->next_hop_sz) == 0; + if (!next_hop_still_front) { + clearHopRetryPending(i); + } + } +} + +void Mesh::tickHopRetryPending() { + for (int i = 0; i < HOP_RETRY_PENDING_MAX; i++) { + HopRetryPending* p = &_hop_retry_pending[i]; + if (p->pkt == NULL) continue; + if (!millisHasNowPassed(p->deadline)) continue; + + if (p->retries_left == 0) { + clearHopRetryPending(i); + continue; + } + + Packet* retry = obtainNewPacket(); + if (retry == NULL) continue; + + copyPacketFields(retry, p->pkt); + uint32_t d = getDirectRetransmitDelay(retry); + sendPacket(retry, 0, d); + p->retries_left--; + p->deadline = futureMillis(getHopRetryTimeoutMs() + d); + } +} + uint32_t Mesh::getCADFailRetryDelay() const { return _rng->nextInt(1, 4)*120; } @@ -186,6 +288,10 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { return ACTION_RELEASE; } + if (pkt->isRouteDirect()) { + checkHopRetryOverhear(pkt); + } + if (pkt->isRouteDirect() && pkt->getPathHashCount() > 0) { // check for 'early received' ACK if (pkt->getPayloadType() == PAYLOAD_TYPE_ACK) { @@ -203,6 +309,7 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { } else if (pkt->getPayloadType() == PAYLOAD_TYPE_ACK) { if (!_tables->hasSeen(pkt)) { // don't retransmit! removeSelfFromPath(pkt); + armHopRetryPending(pkt, 0); routeDirectRecvAcks(pkt, 0); } return ACTION_RELEASE; @@ -210,8 +317,9 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { if (!_tables->hasSeen(pkt)) { removeSelfFromPath(pkt); - uint32_t d = getDirectRetransmitDelay(pkt); + armHopRetryPending(pkt, d); + return ACTION_RETRANSMIT_DELAYED(0, d); // Routed traffic is HIGHEST priority } } @@ -498,7 +606,9 @@ DispatcherAction Mesh::forwardMultipartDirect(Packet* pkt) { if (!_tables->hasSeen(&tmp)) { // don't retransmit! removeSelfFromPath(&tmp); - routeDirectRecvAcks(&tmp, ((uint32_t)remaining + 1) * 300); // expect multipart ACKs 300ms apart (x2) + uint32_t d = ((uint32_t)remaining + 1) * 300; + armHopRetryPending(&tmp, d); + routeDirectRecvAcks(&tmp, d); // expect multipart ACKs 300ms apart (x2) } } return ACTION_RELEASE; diff --git a/src/Mesh.h b/src/Mesh.h index a6aece3aa5..3b7ea01833 100644 --- a/src/Mesh.h +++ b/src/Mesh.h @@ -46,6 +46,30 @@ class Mesh : public Dispatcher { //void routeRecvAcks(Packet* packet, uint32_t delay_millis); DispatcherAction forwardMultipartDirect(Packet* pkt); +#ifndef HOP_RETRY_PENDING_MAX +#define HOP_RETRY_PENDING_MAX 4 +#endif +#ifndef HOP_RETRY_MIN_FREE +#define HOP_RETRY_MIN_FREE 4 +#endif + + struct HopRetryPending { + uint8_t hash[MAX_HASH_SIZE]; + uint8_t next_hop[MAX_PATH_SIZE]; + uint8_t next_hop_sz; + Packet* pkt; + uint8_t retries_left; + unsigned long deadline; + }; + HopRetryPending _hop_retry_pending[HOP_RETRY_PENDING_MAX]; + + void copyPacketFields(Packet* dest, const Packet* src); + void armHopRetryPending(const Packet* forwarded, uint32_t initial_delay_ms = 0); + void checkHopRetryOverhear(const Packet* pkt); + void clearHopRetryPending(int idx); + void clearHopRetryPendingByHash(const uint8_t* hash); + void tickHopRetryPending(); + protected: DispatcherAction onRecvPacket(Packet* pkt) override; @@ -83,6 +107,16 @@ class Mesh : public Dispatcher { */ virtual uint8_t getExtraAckTransmitCount() const; + /** + * \returns extra direct-path retransmits if the next hop is not overheard (0 = off). + */ + virtual uint8_t getHopRetryCount() const { return 0; } + + /** + * \returns milliseconds to wait for the next hop's relay before retrying. + */ + virtual uint16_t getHopRetryTimeoutMs() const { return 1500; } + /** * \brief Perform search of local DB of peers/contacts. * \returns Number of peers with matching hash @@ -201,6 +235,9 @@ class Mesh : public Dispatcher { Mesh(Radio& radio, MillisecondClock& ms, RNG& rng, RTCClock& rtc, PacketManager& mgr, MeshTables& tables) : Dispatcher(radio, ms, mgr), _rng(&rng), _rtc(&rtc), _tables(&tables) { + for (int i = 0; i < HOP_RETRY_PENDING_MAX; i++) { + _hop_retry_pending[i].pkt = NULL; + } } MeshTables* getTables() const { return _tables; } diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 1fa8ec7290..ff015f35b9 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -128,7 +128,11 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { file.read((uint8_t *)&_prefs->ota_checkpoint_blocks, sizeof(_prefs->ota_checkpoint_blocks)); // 424 file.read((uint8_t *)&_prefs->ota_advert_interval, sizeof(_prefs->ota_advert_interval)); // 426 file.read((uint8_t *)&_prefs->ota_max_hops, sizeof(_prefs->ota_max_hops)); // 428 - // next: 429 + _prefs->hop_retry = 0; + _prefs->hop_retry_ms = 1500; + file.read((uint8_t *)&_prefs->hop_retry, sizeof(_prefs->hop_retry)); // 429 + file.read((uint8_t *)&_prefs->hop_retry_ms, sizeof(_prefs->hop_retry_ms)); // 430 + // next: 432 // sanitise bad pref values _prefs->rx_delay_base = constrain(_prefs->rx_delay_base, 0, 20.0f); @@ -164,6 +168,9 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { if (_prefs->ota_advert_interval > 10080) _prefs->ota_advert_interval = 1440; // 0=off; cap at 7 days if (_prefs->ota_max_hops > 8) _prefs->ota_max_hops = 3; // 0=direct only; cap absurd reach if (_prefs->ota_signer_count > 4) _prefs->ota_signer_count = 0; // corrupt count -> drop keys + _prefs->hop_retry = constrain(_prefs->hop_retry, 0, 5); + if (_prefs->hop_retry_ms < 200) _prefs->hop_retry_ms = 200; + if (_prefs->hop_retry_ms > 10000) _prefs->hop_retry_ms = 10000; file.close(); } @@ -234,7 +241,9 @@ void CommonCLI::savePrefs(FILESYSTEM* fs) { file.write((uint8_t *)&_prefs->ota_checkpoint_blocks, sizeof(_prefs->ota_checkpoint_blocks)); // 424 file.write((uint8_t *)&_prefs->ota_advert_interval, sizeof(_prefs->ota_advert_interval)); // 426 file.write((uint8_t *)&_prefs->ota_max_hops, sizeof(_prefs->ota_max_hops)); // 428 - // next: 429 + file.write((uint8_t *)&_prefs->hop_retry, sizeof(_prefs->hop_retry)); // 429 + file.write((uint8_t *)&_prefs->hop_retry_ms, sizeof(_prefs->hop_retry_ms)); // 430 + // next: 432 file.close(); } @@ -715,6 +724,24 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep } else { strcpy(reply, "Error, must be 0-2"); } + } else if (memcmp(config, "hop.retry ", 10) == 0) { + int n = atoi(&config[10]); + if (n >= 0 && n <= 5) { + _prefs->hop_retry = (uint8_t)n; + savePrefs(); + strcpy(reply, "OK"); + } else { + strcpy(reply, "Error, must be 0-5"); + } + } else if (memcmp(config, "hop.retry.ms ", 13) == 0) { + int n = atoi(&config[13]); + if (n >= 200 && n <= 10000) { + _prefs->hop_retry_ms = (uint16_t)n; + savePrefs(); + strcpy(reply, "OK"); + } else { + strcpy(reply, "Error, must be 200-10000"); + } } else if (memcmp(config, "owner.info ", 11) == 0) { config += 11; char *dp = _prefs->owner_info; @@ -889,6 +916,10 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep sprintf(reply, "> %d", (uint32_t)_prefs->flood_max); } else if (memcmp(config, "direct.txdelay", 14) == 0) { sprintf(reply, "> %s", StrHelper::ftoa(_prefs->direct_tx_delay_factor)); + } else if (memcmp(config, "hop.retry.ms", 12) == 0) { + sprintf(reply, "> %u", (unsigned)_prefs->hop_retry_ms); + } else if (memcmp(config, "hop.retry", 9) == 0) { + sprintf(reply, "> %u", (unsigned)_prefs->hop_retry); } else if (memcmp(config, "owner.info", 10) == 0) { auto start = reply; *reply++ = '>'; diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index c6d69652f5..af23fd11ed 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -71,6 +71,8 @@ struct NodePrefs { // persisted to file uint16_t ota_checkpoint_blocks; // resume checkpoint cadence (blocks); 0=never. Default 4 (runtime-tunable) uint16_t ota_advert_interval; // OTA beacon re-advertise cadence (mins); 0=off. Default 1440 (24h, tunable) uint8_t ota_max_hops; // OTA flood reach in hops; 0=direct only. Default 3 (runtime-tunable) + uint8_t hop_retry; // extra direct-path TX if next hop not overheard (0=off). Default 0 (opt-in) + uint16_t hop_retry_ms; // listen TTL before retry (ms). Default 1500 }; class CommonCLICallbacks { From 8cc6e3c915b5841be213ecfb2a8ebb607692121e Mon Sep 17 00:00:00 2001 From: Ben Allfree Date: Sat, 18 Jul 2026 05:37:50 -0700 Subject: [PATCH 30/59] feat(mesh): hard hop ack for next-hop retry Co-authored-by: Cursor (cherry picked from commit 18be8ace525b770f317bd873b8284af72fbfd557) --- docs/cli_commands.md | 6 +-- docs/faq.md | 2 +- docs/payloads.md | 11 ++++ src/Mesh.cpp | 111 ++++++++++++++++++++++++++++++++++------ src/Mesh.h | 27 ++++++++-- src/helpers/CommonCLI.h | 2 +- 6 files changed, 133 insertions(+), 26 deletions(-) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index e4e01103e7..a534fa7c54 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -507,10 +507,10 @@ This document provides an overview of CLI commands that can be sent to MeshCore - `set hop.retry.ms ` **Parameters:** -- `count`: Extra retransmits if the next hop is not overheard relaying (0-5). `0` disables hop retry (fire-and-forget). Default `2` means up to 3 total transmissions (initial + 2 retries). +- `count`: Extra retransmits if the next hop's hop ACK is not received (0-5). `0` disables hop retry (fire-and-forget). Default `2` means up to 3 total transmissions (initial + 2 retries). - `milliseconds`: Listen window before each retry (200-10000). Default `1500`. -**Note:** When a repeater forwards a direct-path packet, it listens for the next hop's retransmit as a soft ACK. If nothing is heard within `hop.retry.ms`, it sends again. Not used on the last hop (zero-hop delivery to the destination). Prefer `set multi.acks 0` when hop retry is enabled — hop retry already covers ACK reliability per hop. +**Note:** When a repeater forwards a direct-path packet, it waits for a zero-hop **HOP_ACK** from the next hop (if that hop is known to support hop ACK). If nothing is heard within `hop.retry.ms`, it sends again. Retry is only armed for next hops that have previously sent HOP_ACK; stock repeaters stay single-shot. Not used on the last hop (zero-hop delivery to the destination). Prefer `set multi.acks 0` when hop retry is enabled. --- @@ -601,7 +601,7 @@ This document provides an overview of CLI commands that can be sent to MeshCore **Default:** `0` -**Note:** Sends extra copies of direct ACKs. With hop retry enabled (`hop.retry` > 0), prefer leaving this off — hop retry listens for the next hop's relay instead of blind duplicate ACKs. +**Note:** Sends extra copies of direct ACKs. With hop retry enabled (`hop.retry` > 0), prefer leaving this off — hop retry waits for the next hop's HOP_ACK instead of blind duplicate ACKs. --- diff --git a/docs/faq.md b/docs/faq.md index 8ef0e9ab9f..3a04d47203 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -488,7 +488,7 @@ Routes are stored in sender's contact list. When you send a message the first ti [Source](https://discord.com/channels/826570251612323860/1330643963501351004/1351279141630119996) ### 5.4.1. Q: What is next-hop retry on repeaters? -When a repeater forwards a direct-path packet, it normally transmits once and moves on. With hop retry enabled (default on repeaters: `hop.retry 2`, `hop.retry.ms 1500`), the repeater listens for the next hop to retransmit the same packet. Hearing that relay acts as a per-hop ACK. If the next hop is silent for the listen window, the repeater sends again (up to `hop.retry` extra times). This does not apply on the last hop before the destination — the destination does not retransmit. Clients still use end-to-end ACK timeouts for delivery confirmation; hop retry fixes silent mid-path relays. Prefer `set multi.acks 0` when using hop retry. +When a repeater forwards a direct-path packet, it normally transmits once and moves on. With hop retry enabled (default on repeaters: `hop.retry 2`, `hop.retry.ms 1500`), the repeater waits for the next hop to send a zero-hop **HOP_ACK** control packet confirming it received and forwarded. If the next hop is silent for the listen window, the repeater sends again (up to `hop.retry` extra times). Retry is only used toward next hops that have previously sent HOP_ACK; stock repeaters in the path stay fire-and-forget. This does not apply on the last hop before the destination. Clients still use end-to-end ACK timeouts for delivery confirmation; hop retry fixes silent mid-path relays. Prefer `set multi.acks 0` when using hop retry. ### 5.5. Q: Do public channels always flood? Do private channels always flood? **A:** Yes, group channels are A to B, so there is no defined path. They have to flood. Repeaters can however deny flood traffic up to some hop limit, with the `set flood.max` CLI command. Administrators of repeaters get to set the rules of their repeaters. diff --git a/docs/payloads.md b/docs/payloads.md index 21cb94696c..a47b51b1f8 100644 --- a/docs/payloads.md +++ b/docs/payloads.md @@ -278,6 +278,17 @@ The data contained in the ciphertext uses the format below: | tag | 4 | reflected back from DISCOVER_REQ | | pubkey | 8 or 32 | node's ID (or prefix) | +## HOP_ACK (sub_type) + +Per-hop confirmation sent by a repeater after forwarding a direct-path packet. Zero-hop only (`path_len == 0`). + +| Field | Size (bytes) | Description | +|--------------|-----------------|----------------------------------------------------------| +| flags | 1 | `0xA0` (`CTL_TYPE_HOP_ACK`) | +| fwd_hash | 8 | `Packet::calculatePacketHash` of the forwarded packet | +| hash_size | 1 | byte length of `sender_hash` (1, 2, or 3) | +| sender_hash | 1–3 | path hash prefix of the ACK sender (not full pubkey) | + # Custom packet diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 278907db36..3e3d1410e9 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -169,6 +169,7 @@ void Mesh::clearHopRetryPendingByHash(const uint8_t* hash) { void Mesh::armHopRetryPending(const Packet* forwarded, uint32_t initial_delay_ms) { uint8_t retries = getHopRetryCount(); if (retries == 0 || forwarded->getPathHashCount() == 0) return; + if (!isHopAckCapable(forwarded->path, forwarded->getPathHashSize())) return; if (_mgr->getFreeCount() < HOP_RETRY_MIN_FREE) return; uint8_t hash[MAX_HASH_SIZE]; @@ -199,25 +200,98 @@ void Mesh::armHopRetryPending(const Packet* forwarded, uint32_t initial_delay_ms _hop_retry_pending[slot].deadline = futureMillis(getHopRetryTimeoutMs() + initial_delay_ms); } -void Mesh::checkHopRetryOverhear(const Packet* pkt) { - if (!pkt->isRouteDirect()) return; +void Mesh::markHopAckCapable(const uint8_t* hash, uint8_t sz) { + if (sz == 0 || sz > 3) return; - uint8_t hash[MAX_HASH_SIZE]; - pkt->calculatePacketHash(hash); + _hop_ack_capable_seq++; + uint32_t now = _hop_ack_capable_seq; + + for (int i = 0; i < HOP_ACK_CAPABLE_MAX; i++) { + HopAckCapableEntry* e = &_hop_ack_capable[i]; + if (e->in_use && e->hash_sz == sz && memcmp(e->hash, hash, sz) == 0) { + e->last_seen = now; + return; + } + } + + int slot = -1; + int evict = 0; + uint32_t oldest = _hop_ack_capable[0].last_seen; + for (int i = 0; i < HOP_ACK_CAPABLE_MAX; i++) { + HopAckCapableEntry* e = &_hop_ack_capable[i]; + if (!e->in_use) { + slot = i; + break; + } + if (e->last_seen < oldest) { + oldest = e->last_seen; + evict = i; + } + } + if (slot < 0) slot = evict; + + HopAckCapableEntry* e = &_hop_ack_capable[slot]; + memcpy(e->hash, hash, sz); + e->hash_sz = sz; + e->last_seen = now; + e->in_use = true; +} + +bool Mesh::isHopAckCapable(const uint8_t* next_hop, uint8_t sz) const { + if (sz == 0 || sz > 3) return false; + + for (int i = 0; i < HOP_ACK_CAPABLE_MAX; i++) { + const HopAckCapableEntry* e = &_hop_ack_capable[i]; + if (e->in_use && e->hash_sz == sz && memcmp(e->hash, next_hop, sz) == 0) { + return true; + } + } + return false; +} + +void Mesh::checkHopRetryAck(const Packet* pkt) { + if (pkt->payload_len < 11) return; + if (pkt->payload[0] != CTL_TYPE_HOP_ACK) return; + + uint8_t hash_sz = pkt->payload[9]; + if (hash_sz < 1 || hash_sz > 3) return; + if (pkt->payload_len < 10 + hash_sz) return; + + const uint8_t* fwd_hash = &pkt->payload[1]; + const uint8_t* sender_hash = &pkt->payload[10]; + + markHopAckCapable(sender_hash, hash_sz); for (int i = 0; i < HOP_RETRY_PENDING_MAX; i++) { HopRetryPending* p = &_hop_retry_pending[i]; if (p->pkt == NULL) continue; - if (memcmp(p->hash, hash, MAX_HASH_SIZE) != 0) continue; - - bool next_hop_still_front = pkt->getPathHashCount() > 0 - && memcmp(pkt->path, p->next_hop, p->next_hop_sz) == 0; - if (!next_hop_still_front) { - clearHopRetryPending(i); - } + if (memcmp(p->hash, fwd_hash, MAX_HASH_SIZE) != 0) continue; + if (hash_sz != p->next_hop_sz) continue; + if (memcmp(p->next_hop, sender_hash, hash_sz) != 0) continue; + clearHopRetryPending(i); + break; } } +void Mesh::sendHopAck(const Packet* forwarded, uint32_t delay_millis) { + if (!allowPacketForward(forwarded)) return; + if (forwarded->getPathHashCount() == 0) return; + if (_mgr->getFreeCount() < HOP_RETRY_MIN_FREE) return; + + uint8_t hash_sz = forwarded->getPathHashSize(); + if (hash_sz == 0 || hash_sz > 3) return; + + uint8_t payload[14]; + payload[0] = CTL_TYPE_HOP_ACK; + forwarded->calculatePacketHash(&payload[1]); + payload[9] = hash_sz; + self_id.copyHashTo(&payload[10], hash_sz); + size_t len = 10 + hash_sz; + + Packet* ack = createControlData(payload, len); + if (ack) sendZeroHop(ack, delay_millis); +} + void Mesh::tickHopRetryPending() { for (int i = 0; i < HOP_RETRY_PENDING_MAX; i++) { HopRetryPending* p = &_hop_retry_pending[i]; @@ -280,18 +354,18 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { return ACTION_RELEASE; } - if (pkt->isRouteDirect() && pkt->getPayloadType() == PAYLOAD_TYPE_CONTROL && (pkt->payload[0] & 0x80) != 0) { - if (pkt->getPathHashCount() == 0) { + if (pkt->isRouteDirect() && pkt->getPayloadType() == PAYLOAD_TYPE_CONTROL && pkt->getPathHashCount() == 0) { + if (pkt->payload_len >= 11 && pkt->payload[0] == CTL_TYPE_HOP_ACK) { + checkHopRetryAck(pkt); + return ACTION_RELEASE; + } + if ((pkt->payload[0] & 0x80) != 0) { onControlDataRecv(pkt); } // just zero-hop control packets allowed (for this subset of payloads) return ACTION_RELEASE; } - if (pkt->isRouteDirect()) { - checkHopRetryOverhear(pkt); - } - if (pkt->isRouteDirect() && pkt->getPathHashCount() > 0) { // check for 'early received' ACK if (pkt->getPayloadType() == PAYLOAD_TYPE_ACK) { @@ -309,6 +383,7 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { } else if (pkt->getPayloadType() == PAYLOAD_TYPE_ACK) { if (!_tables->hasSeen(pkt)) { // don't retransmit! removeSelfFromPath(pkt); + sendHopAck(pkt, 0); armHopRetryPending(pkt, 0); routeDirectRecvAcks(pkt, 0); } @@ -318,6 +393,7 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { if (!_tables->hasSeen(pkt)) { removeSelfFromPath(pkt); uint32_t d = getDirectRetransmitDelay(pkt); + sendHopAck(pkt, d); armHopRetryPending(pkt, d); return ACTION_RETRANSMIT_DELAYED(0, d); // Routed traffic is HIGHEST priority @@ -607,6 +683,7 @@ DispatcherAction Mesh::forwardMultipartDirect(Packet* pkt) { if (!_tables->hasSeen(&tmp)) { // don't retransmit! removeSelfFromPath(&tmp); uint32_t d = ((uint32_t)remaining + 1) * 300; + sendHopAck(&tmp, d); armHopRetryPending(&tmp, d); routeDirectRecvAcks(&tmp, d); // expect multipart ACKs 300ms apart (x2) } diff --git a/src/Mesh.h b/src/Mesh.h index 3b7ea01833..748de5cfac 100644 --- a/src/Mesh.h +++ b/src/Mesh.h @@ -52,6 +52,10 @@ class Mesh : public Dispatcher { #ifndef HOP_RETRY_MIN_FREE #define HOP_RETRY_MIN_FREE 4 #endif +#ifndef HOP_ACK_CAPABLE_MAX +#define HOP_ACK_CAPABLE_MAX 24 +#endif +#define CTL_TYPE_HOP_ACK 0xA0 struct HopRetryPending { uint8_t hash[MAX_HASH_SIZE]; @@ -63,9 +67,21 @@ class Mesh : public Dispatcher { }; HopRetryPending _hop_retry_pending[HOP_RETRY_PENDING_MAX]; + struct HopAckCapableEntry { + uint8_t hash[MAX_PATH_SIZE]; + uint8_t hash_sz; + uint32_t last_seen; + bool in_use; + }; + HopAckCapableEntry _hop_ack_capable[HOP_ACK_CAPABLE_MAX]; + uint32_t _hop_ack_capable_seq; + void copyPacketFields(Packet* dest, const Packet* src); void armHopRetryPending(const Packet* forwarded, uint32_t initial_delay_ms = 0); - void checkHopRetryOverhear(const Packet* pkt); + void checkHopRetryAck(const Packet* pkt); + void sendHopAck(const Packet* forwarded, uint32_t delay_millis); + void markHopAckCapable(const uint8_t* hash, uint8_t sz); + bool isHopAckCapable(const uint8_t* next_hop, uint8_t sz) const; void clearHopRetryPending(int idx); void clearHopRetryPendingByHash(const uint8_t* hash); void tickHopRetryPending(); @@ -108,12 +124,12 @@ class Mesh : public Dispatcher { virtual uint8_t getExtraAckTransmitCount() const; /** - * \returns extra direct-path retransmits if the next hop is not overheard (0 = off). + * \returns extra direct-path retransmits if the next hop's hop ACK is not received (0 = off). */ virtual uint8_t getHopRetryCount() const { return 0; } /** - * \returns milliseconds to wait for the next hop's relay before retrying. + * \returns milliseconds to wait for the next hop's hop ACK before retrying. */ virtual uint16_t getHopRetryTimeoutMs() const { return 1500; } @@ -233,11 +249,14 @@ class Mesh : public Dispatcher { virtual void onAckRecv(Packet* packet, uint32_t ack_crc) { } Mesh(Radio& radio, MillisecondClock& ms, RNG& rng, RTCClock& rtc, PacketManager& mgr, MeshTables& tables) - : Dispatcher(radio, ms, mgr), _rng(&rng), _rtc(&rtc), _tables(&tables) + : Dispatcher(radio, ms, mgr), _rng(&rng), _rtc(&rtc), _tables(&tables), _hop_ack_capable_seq(0) { for (int i = 0; i < HOP_RETRY_PENDING_MAX; i++) { _hop_retry_pending[i].pkt = NULL; } + for (int i = 0; i < HOP_ACK_CAPABLE_MAX; i++) { + _hop_ack_capable[i].in_use = false; + } } MeshTables* getTables() const { return _tables; } diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index af23fd11ed..066bf820ec 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -71,7 +71,7 @@ struct NodePrefs { // persisted to file uint16_t ota_checkpoint_blocks; // resume checkpoint cadence (blocks); 0=never. Default 4 (runtime-tunable) uint16_t ota_advert_interval; // OTA beacon re-advertise cadence (mins); 0=off. Default 1440 (24h, tunable) uint8_t ota_max_hops; // OTA flood reach in hops; 0=direct only. Default 3 (runtime-tunable) - uint8_t hop_retry; // extra direct-path TX if next hop not overheard (0=off). Default 0 (opt-in) + uint8_t hop_retry; // extra direct-path TX if next hop hop ACK missing (0=off). Default 0 (opt-in) uint16_t hop_retry_ms; // listen TTL before retry (ms). Default 1500 }; From d34aaeca7b4e35a6c1a363300d885cb882143f6d Mon Sep 17 00:00:00 2001 From: Ben Allfree Date: Fri, 17 Jul 2026 18:46:02 -0700 Subject: [PATCH 31/59] chore(envyos): default firmware version v0.1.0 Co-authored-by: Cursor (cherry picked from commit f0e6b5381e34780890f4577625a795025db21fda) --- examples/companion_radio/MyMesh.h | 2 +- examples/simple_repeater/MyMesh.h | 2 +- examples/simple_room_server/MyMesh.h | 2 +- examples/simple_sensor/SensorMesh.h | 2 +- platformio.ini | 1 + 5 files changed, 5 insertions(+), 4 deletions(-) diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index fc2bb324cd..c16ef71e64 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -12,7 +12,7 @@ #endif #ifndef FIRMWARE_VERSION -#define FIRMWARE_VERSION "v1.17.0" +#define FIRMWARE_VERSION "v0.1.0" #endif #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index bd760bcd3b..345e238d52 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -76,7 +76,7 @@ struct NeighbourInfo { #endif #ifndef FIRMWARE_VERSION - #define FIRMWARE_VERSION "v1.17.0" + #define FIRMWARE_VERSION "v0.1.0" #endif #define FIRMWARE_ROLE "repeater" diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 401fedff06..c370003ef8 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -31,7 +31,7 @@ #endif #ifndef FIRMWARE_VERSION - #define FIRMWARE_VERSION "v1.17.0" + #define FIRMWARE_VERSION "v0.1.0" #endif #ifndef LORA_FREQ diff --git a/examples/simple_sensor/SensorMesh.h b/examples/simple_sensor/SensorMesh.h index 8e8ed8a534..7e57dcd9ab 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/examples/simple_sensor/SensorMesh.h @@ -38,7 +38,7 @@ #endif #ifndef FIRMWARE_VERSION - #define FIRMWARE_VERSION "v1.17.0" + #define FIRMWARE_VERSION "v0.1.0" #endif #define FIRMWARE_ROLE "sensor" diff --git a/platformio.ini b/platformio.ini index 5523ce7f3f..15824b901e 100644 --- a/platformio.ini +++ b/platformio.ini @@ -25,6 +25,7 @@ lib_deps = melopero/Melopero RV3028 @ ^1.1.0 electroniccats/CayenneLPP @ 1.6.1 build_flags = -w -DNDEBUG -DRADIOLIB_STATIC_ONLY=1 -DRADIOLIB_GODMODE=1 + -D FIRMWARE_VERSION='"v0.1.0"' -D LORA_FREQ=869.618 -D LORA_BW=62.5 -D LORA_SF=8 From f7a32951e9932761bf5093777fe6cabb28151ebe Mon Sep 17 00:00:00 2001 From: Ben Allfree Date: Fri, 17 Jul 2026 18:52:48 -0700 Subject: [PATCH 32/59] feat(envyos): add distro version helpers Co-authored-by: Cursor (cherry picked from commit beadfa6bea42e1712387304098ecf315b5a32e45) --- envyos/VERSION | 1 + envyos/version.sh | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 envyos/VERSION create mode 100755 envyos/version.sh diff --git a/envyos/VERSION b/envyos/VERSION new file mode 100644 index 0000000000..6e8bf73aa5 --- /dev/null +++ b/envyos/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/envyos/version.sh b/envyos/version.sh new file mode 100755 index 0000000000..66b2d3a156 --- /dev/null +++ b/envyos/version.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# EnvyOS distro version helpers. Source from ota repo build scripts. +set -euo pipefail + +ENVYOS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" +ENVYOS_VERSION="$(tr -d '[:space:]' <"$ENVYOS_DIR/VERSION")" + +# v0.1.0 or 0.1.0 → v0.1.0 +normalize_version() { + local v="${1#v}" + if [[ ! "$v" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "error: invalid version '$1' (want vMAJOR.MINOR.PATCH)" >&2 + return 1 + fi + printf 'v%s' "$v" +} + +# v0.1.1 → 0 1 1 (stdout: major minor patch) +parse_version() { + local v="${1#v}" + local major minor patch + IFS=. read -r major minor patch <<<"$v" + printf '%s %s %s' "$major" "$minor" "$patch" +} + +# v0.1.1 → v0.1.0; v0.1.0 → (empty) +previous_patch_version() { + local ver="$1" + local major minor patch + read -r major minor patch <<<"$(parse_version "$ver")" + if [[ "$patch" -eq 0 ]]; then + return 0 + fi + printf 'v%s.%s.%s' "$major" "$minor" "$((patch - 1))" +} From e4f789cd978f318f49f3120160e85718f1783bbe Mon Sep 17 00:00:00 2001 From: Ben Allfree Date: Sun, 19 Jul 2026 23:57:48 -0700 Subject: [PATCH 33/59] feat(cli): add log tail on/off serial mirror Co-authored-by: Cursor (cherry picked from commit fc013f42fdcc19ffb1ad1d78c095314961b13ab3) --- docs/cli_commands.md | 14 +++++++++ examples/simple_repeater/MyMesh.cpp | 42 ++++++++++++++++++++++++++ examples/simple_repeater/MyMesh.h | 6 ++++ examples/simple_repeater/main.cpp | 5 +++ examples/simple_room_server/MyMesh.cpp | 42 ++++++++++++++++++++++++++ examples/simple_room_server/MyMesh.h | 6 ++++ examples/simple_room_server/main.cpp | 5 +++ examples/simple_sensor/SensorMesh.h | 1 + src/helpers/CommonCLI.cpp | 8 +++++ src/helpers/CommonCLI.h | 1 + 10 files changed, 130 insertions(+) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index a534fa7c54..aa1cb724f8 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -156,6 +156,20 @@ This document provides an overview of CLI commands that can be sent to MeshCore --- +### Mirror packet log to serial (live) +**Usage:** `log tail on` + +**Note:** Streams RX/TX log lines to USB serial as they are captured. No-op unless `log start` is active. Serial-only (not available via remote admin). + +--- + +### Stop live serial log mirror +**Usage:** `log tail off` + +**Serial Only:** Yes + +--- + ### Erase captured log **Usage:** `log erase` diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 30bb5e96c2..97ab468508 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -472,6 +472,38 @@ void MyMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) { #endif } +void MyMesh::mirrorPacketLogRxToSerial(mesh::Packet *pkt, int len, float score) { + Serial.print(getLogDateTime()); + Serial.printf(": RX, len=%d (type=%d, route=%s, payload_len=%d) SNR=%d RSSI=%d score=%d", len, + pkt->getPayloadType(), pkt->isRouteDirect() ? "D" : "F", pkt->payload_len, + (int)_radio->getLastSNR(), (int)_radio->getLastRSSI(), (int)(score * 1000)); + + if (pkt->getPayloadType() == PAYLOAD_TYPE_PATH || pkt->getPayloadType() == PAYLOAD_TYPE_REQ || + pkt->getPayloadType() == PAYLOAD_TYPE_RESPONSE || pkt->getPayloadType() == PAYLOAD_TYPE_TXT_MSG) { + Serial.printf(" [%02X -> %02X]", (uint32_t)pkt->payload[1], (uint32_t)pkt->payload[0]); + } + Serial.print("\r\n"); +} + +void MyMesh::mirrorPacketLogTxToSerial(mesh::Packet *pkt, int len) { + Serial.print(getLogDateTime()); + Serial.printf(": TX, len=%d (type=%d, route=%s, payload_len=%d)", len, pkt->getPayloadType(), + pkt->isRouteDirect() ? "D" : "F", pkt->payload_len); + + if (pkt->getPayloadType() == PAYLOAD_TYPE_PATH || pkt->getPayloadType() == PAYLOAD_TYPE_REQ || + pkt->getPayloadType() == PAYLOAD_TYPE_RESPONSE || pkt->getPayloadType() == PAYLOAD_TYPE_TXT_MSG) { + Serial.printf(" [%02X -> %02X]", (uint32_t)pkt->payload[1], (uint32_t)pkt->payload[0]); + } + Serial.print("\r\n"); +} + +void MyMesh::mirrorPacketLogTxFailToSerial(mesh::Packet *pkt, int len) { + Serial.print(getLogDateTime()); + Serial.printf(": TX FAIL!, len=%d (type=%d, route=%s, payload_len=%d)", len, pkt->getPayloadType(), + pkt->isRouteDirect() ? "D" : "F", pkt->payload_len); + Serial.print("\r\n"); +} + void MyMesh::logRx(mesh::Packet *pkt, int len, float score) { #ifdef WITH_BRIDGE if (_prefs.bridge_pkt_src == 1) { @@ -496,6 +528,9 @@ void MyMesh::logRx(mesh::Packet *pkt, int len, float score) { f.close(); } } + if (_logging && _tailing) { + mirrorPacketLogRxToSerial(pkt, len, score); + } } void MyMesh::logTx(mesh::Packet *pkt, int len) { @@ -521,6 +556,9 @@ void MyMesh::logTx(mesh::Packet *pkt, int len) { f.close(); } } + if (_logging && _tailing) { + mirrorPacketLogTxToSerial(pkt, len); + } } void MyMesh::logTxFail(mesh::Packet *pkt, int len) { @@ -533,6 +571,9 @@ void MyMesh::logTxFail(mesh::Packet *pkt, int len) { f.close(); } } + if (_logging && _tailing) { + mirrorPacketLogTxFailToSerial(pkt, len); + } } int MyMesh::calcRxDelay(float score, uint32_t air_time) const { @@ -866,6 +907,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc dirty_contacts_expiry = 0; set_radio_at = revert_radio_at = 0; _logging = false; + _tailing = false; region_load_active = false; #if MAX_NEIGHBOURS diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 345e238d52..cb577f176a 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -89,6 +89,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { uint64_t uptime_millis; unsigned long next_local_advert, next_flood_advert; bool _logging; + bool _tailing; NodePrefs _prefs; ClientACL acl; CommonCLI _cli; @@ -132,6 +133,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { File openAppend(const char* fname); bool isLooped(const mesh::Packet* packet, const uint8_t max_counters[]); + void mirrorPacketLogRxToSerial(mesh::Packet* pkt, int len, float score); + void mirrorPacketLogTxToSerial(mesh::Packet* pkt, int len); + void mirrorPacketLogTxFailToSerial(mesh::Packet* pkt, int len); protected: float getAirtimeBudgetFactor() const override { @@ -213,6 +217,8 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { void setLoggingOn(bool enable) override { _logging = enable; } + void setTailOn(bool enable) override { _tailing = enable; } + void eraseLogFile() override { _fs->remove(PACKET_LOG_FILE); } diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index 2ce056f521..4526dd4fda 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -107,6 +107,11 @@ void loop() { int len = strlen(command); while (Serial.available() && len < sizeof(command)-1) { char c = Serial.read(); + if (c == 3) { // Ctrl+C — stop live log tail + the_mesh.setTailOn(false); + Serial.print("\r\n -> tail off\r\n"); + continue; + } if (c != '\n') { command[len++] = c; command[len] = 0; diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 09d53db5fd..5d9167a5f3 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -208,6 +208,38 @@ void MyMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) { #endif } +void MyMesh::mirrorPacketLogRxToSerial(mesh::Packet *pkt, int len, float score) { + Serial.print(getLogDateTime()); + Serial.printf(": RX, len=%d (type=%d, route=%s, payload_len=%d) SNR=%d RSSI=%d score=%d", len, + pkt->getPayloadType(), pkt->isRouteDirect() ? "D" : "F", pkt->payload_len, + (int)_radio->getLastSNR(), (int)_radio->getLastRSSI(), (int)(score * 1000)); + + if (pkt->getPayloadType() == PAYLOAD_TYPE_PATH || pkt->getPayloadType() == PAYLOAD_TYPE_REQ || + pkt->getPayloadType() == PAYLOAD_TYPE_RESPONSE || pkt->getPayloadType() == PAYLOAD_TYPE_TXT_MSG) { + Serial.printf(" [%02X -> %02X]", (uint32_t)pkt->payload[1], (uint32_t)pkt->payload[0]); + } + Serial.print("\r\n"); +} + +void MyMesh::mirrorPacketLogTxToSerial(mesh::Packet *pkt, int len) { + Serial.print(getLogDateTime()); + Serial.printf(": TX, len=%d (type=%d, route=%s, payload_len=%d)", len, pkt->getPayloadType(), + pkt->isRouteDirect() ? "D" : "F", pkt->payload_len); + + if (pkt->getPayloadType() == PAYLOAD_TYPE_PATH || pkt->getPayloadType() == PAYLOAD_TYPE_REQ || + pkt->getPayloadType() == PAYLOAD_TYPE_RESPONSE || pkt->getPayloadType() == PAYLOAD_TYPE_TXT_MSG) { + Serial.printf(" [%02X -> %02X]", (uint32_t)pkt->payload[1], (uint32_t)pkt->payload[0]); + } + Serial.print("\r\n"); +} + +void MyMesh::mirrorPacketLogTxFailToSerial(mesh::Packet *pkt, int len) { + Serial.print(getLogDateTime()); + Serial.printf(": TX FAIL!, len=%d (type=%d, route=%s, payload_len=%d)", len, pkt->getPayloadType(), + pkt->isRouteDirect() ? "D" : "F", pkt->payload_len); + Serial.print("\r\n"); +} + void MyMesh::logRx(mesh::Packet *pkt, int len, float score) { if (_logging) { File f = openAppend(PACKET_LOG_FILE); @@ -226,6 +258,9 @@ void MyMesh::logRx(mesh::Packet *pkt, int len, float score) { f.close(); } } + if (_logging && _tailing) { + mirrorPacketLogRxToSerial(pkt, len, score); + } } void MyMesh::logTx(mesh::Packet *pkt, int len) { if (_logging) { @@ -244,6 +279,9 @@ void MyMesh::logTx(mesh::Packet *pkt, int len) { f.close(); } } + if (_logging && _tailing) { + mirrorPacketLogTxToSerial(pkt, len); + } } void MyMesh::logTxFail(mesh::Packet *pkt, int len) { if (_logging) { @@ -255,6 +293,9 @@ void MyMesh::logTxFail(mesh::Packet *pkt, int len) { f.close(); } } + if (_logging && _tailing) { + mirrorPacketLogTxFailToSerial(pkt, len); + } } int MyMesh::calcRxDelay(float score, uint32_t air_time) const { @@ -625,6 +666,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc next_local_advert = next_flood_advert = 0; dirty_contacts_expiry = 0; _logging = false; + _tailing = false; region_load_active = false; set_radio_at = revert_radio_at = 0; diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index c370003ef8..d51bb300d7 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -94,6 +94,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { uint64_t uptime_millis; unsigned long next_local_advert, next_flood_advert; bool _logging; + bool _tailing; bool region_load_active; NodePrefs _prefs; TransportKeyStore key_store; @@ -124,6 +125,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { bool processAck(const uint8_t *data); mesh::Packet* createSelfAdvert(); File openAppend(const char* fname); + void mirrorPacketLogRxToSerial(mesh::Packet* pkt, int len, float score); + void mirrorPacketLogTxToSerial(mesh::Packet* pkt, int len); + void mirrorPacketLogTxFailToSerial(mesh::Packet* pkt, int len); int handleRequest(ClientInfo* sender, uint32_t sender_timestamp, uint8_t* payload, size_t payload_len); protected: @@ -203,6 +207,8 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { void setLoggingOn(bool enable) override { _logging = enable; } + void setTailOn(bool enable) override { _tailing = enable; } + void eraseLogFile() override { _fs->remove(PACKET_LOG_FILE); } diff --git a/examples/simple_room_server/main.cpp b/examples/simple_room_server/main.cpp index a3798b2175..50f4530e1c 100644 --- a/examples/simple_room_server/main.cpp +++ b/examples/simple_room_server/main.cpp @@ -88,6 +88,11 @@ void loop() { int len = strlen(command); while (Serial.available() && len < sizeof(command)-1) { char c = Serial.read(); + if (c == 3) { // Ctrl+C — stop live log tail + the_mesh.setTailOn(false); + Serial.print("\r\n -> tail off\r\n"); + continue; + } if (c != '\n') { command[len++] = c; command[len] = 0; diff --git a/examples/simple_sensor/SensorMesh.h b/examples/simple_sensor/SensorMesh.h index 7e57dcd9ab..99c1468eed 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/examples/simple_sensor/SensorMesh.h @@ -65,6 +65,7 @@ class SensorMesh : public mesh::Mesh, public CommonCLICallbacks { void updateAdvertTimer() override; void updateFloodAdvertTimer() override; void setLoggingOn(bool enable) override { } + void setTailOn(bool enable) override { } void eraseLogFile() override { } void dumpLogFile() override { } void setTxPower(int8_t power_dbm) override; diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index ff015f35b9..b99c836138 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -531,6 +531,14 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re } else { strcpy(reply, "off"); } + } else if (sender_timestamp == 0 && memcmp(command, "log tail on", 11) == 0 + && (command[11] == 0 || command[11] == ' ')) { + _callbacks->setTailOn(true); + strcpy(reply, " tail on"); + } else if (sender_timestamp == 0 && memcmp(command, "log tail off", 12) == 0 + && (command[12] == 0 || command[12] == ' ')) { + _callbacks->setTailOn(false); + strcpy(reply, " tail off"); } else if (memcmp(command, "log start", 9) == 0) { _callbacks->setLoggingOn(true); strcpy(reply, " logging on"); diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 066bf820ec..74a40e6619 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -86,6 +86,7 @@ class CommonCLICallbacks { virtual void updateAdvertTimer() = 0; virtual void updateFloodAdvertTimer() = 0; virtual void setLoggingOn(bool enable) = 0; + virtual void setTailOn(bool enable) = 0; virtual void eraseLogFile() = 0; virtual void dumpLogFile() = 0; virtual void setTxPower(int8_t power_dbm) = 0; From 9b3c9b10d9115fb9f541fc2101e5ffb2b6666c6b Mon Sep 17 00:00:00 2001 From: Ben Allfree Date: Mon, 20 Jul 2026 00:27:43 -0700 Subject: [PATCH 34/59] feat(mesh): add hop.ignore test-mode drop Co-authored-by: Cursor (cherry picked from commit 7dedbdd66fcc76c0d70c0d67ee12a6314dd8398b) --- docs/cli_commands.md | 12 ++++++++++++ examples/simple_repeater/MyMesh.h | 3 +++ examples/simple_room_server/MyMesh.h | 3 +++ src/Mesh.cpp | 4 ++++ src/Mesh.h | 7 ++++++- src/helpers/CommonCLI.cpp | 10 ++++++++++ src/helpers/CommonCLI.h | 3 +++ 7 files changed, 41 insertions(+), 1 deletion(-) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index aa1cb724f8..133307006c 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -528,6 +528,18 @@ This document provides an overview of CLI commands that can be sent to MeshCore --- +#### Test mode: drop next N direct-forward opportunities +**Usage:** +- `get hop.ignore` +- `set hop.ignore ` + +**Parameters:** +- `count`: Number of upcoming direct-forward opportunities to silently discard (0-255). `0` disables (default). + +**Note:** For the next `count` times this node would forward a direct-routed packet, it discards it instead — no forward, no HOP_ACK, no dedup mark (so an upstream retry of the same packet is evaluated again next time). Simulates a dead/unreachable next hop without power-cycling it, to exercise `hop.retry` on the upstream node. **Not persisted** — resets to 0 on reboot, unlike `hop.retry`/`hop.retry.ms`. + +--- + #### [Experimental] View or change the processing delay for received traffic **Usage:** - `get rxdelay` diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index cb577f176a..20c35529d1 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -219,6 +219,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { void setTailOn(bool enable) override { _tailing = enable; } + void setHopAckIgnore(uint8_t count) override { setHopAckIgnoreCount(count); } + uint8_t getHopAckIgnore() override { return getHopAckIgnoreCount(); } + void eraseLogFile() override { _fs->remove(PACKET_LOG_FILE); } diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index d51bb300d7..5ab516c315 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -209,6 +209,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { void setTailOn(bool enable) override { _tailing = enable; } + void setHopAckIgnore(uint8_t count) override { setHopAckIgnoreCount(count); } + uint8_t getHopAckIgnore() override { return getHopAckIgnoreCount(); } + void eraseLogFile() override { _fs->remove(PACKET_LOG_FILE); } diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 3e3d1410e9..f6a2282f88 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -378,6 +378,10 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { } if (self_id.isHashMatch(pkt->path, pkt->getPathHashSize()) && allowPacketForward(pkt)) { + if (_hop_ack_ignore_remaining > 0) { + _hop_ack_ignore_remaining--; + return ACTION_RELEASE; + } if (pkt->getPayloadType() == PAYLOAD_TYPE_MULTIPART) { return forwardMultipartDirect(pkt); } else if (pkt->getPayloadType() == PAYLOAD_TYPE_ACK) { diff --git a/src/Mesh.h b/src/Mesh.h index 748de5cfac..5e09bd587e 100644 --- a/src/Mesh.h +++ b/src/Mesh.h @@ -75,6 +75,7 @@ class Mesh : public Dispatcher { }; HopAckCapableEntry _hop_ack_capable[HOP_ACK_CAPABLE_MAX]; uint32_t _hop_ack_capable_seq; + uint8_t _hop_ack_ignore_remaining; void copyPacketFields(Packet* dest, const Packet* src); void armHopRetryPending(const Packet* forwarded, uint32_t initial_delay_ms = 0); @@ -249,7 +250,8 @@ class Mesh : public Dispatcher { virtual void onAckRecv(Packet* packet, uint32_t ack_crc) { } Mesh(Radio& radio, MillisecondClock& ms, RNG& rng, RTCClock& rtc, PacketManager& mgr, MeshTables& tables) - : Dispatcher(radio, ms, mgr), _rng(&rng), _rtc(&rtc), _tables(&tables), _hop_ack_capable_seq(0) + : Dispatcher(radio, ms, mgr), _rng(&rng), _rtc(&rtc), _tables(&tables), _hop_ack_capable_seq(0), + _hop_ack_ignore_remaining(0) { for (int i = 0; i < HOP_RETRY_PENDING_MAX; i++) { _hop_retry_pending[i].pkt = NULL; @@ -270,6 +272,9 @@ class Mesh : public Dispatcher { RNG* getRNG() const { return _rng; } RTCClock* getRTCClock() const { return _rtc; } + void setHopAckIgnoreCount(uint8_t count) { _hop_ack_ignore_remaining = count; } + uint8_t getHopAckIgnoreCount() const { return _hop_ack_ignore_remaining; } + Packet* createAdvert(const LocalIdentity& id, const uint8_t* app_data=NULL, size_t app_data_len=0); Packet* createDatagram(uint8_t type, const Identity& dest, const uint8_t* secret, const uint8_t* data, size_t len); Packet* createAnonDatagram(uint8_t type, const LocalIdentity& sender, const Identity& dest, const uint8_t* secret, const uint8_t* data, size_t data_len); diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index b99c836138..3c254058ce 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -750,6 +750,14 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep } else { strcpy(reply, "Error, must be 200-10000"); } + } else if (memcmp(config, "hop.ignore ", 11) == 0) { + int n = atoi(&config[11]); + if (n >= 0 && n <= 255) { + _callbacks->setHopAckIgnore((uint8_t)n); + strcpy(reply, "OK"); + } else { + strcpy(reply, "Error, must be 0-255"); + } } else if (memcmp(config, "owner.info ", 11) == 0) { config += 11; char *dp = _prefs->owner_info; @@ -926,6 +934,8 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep sprintf(reply, "> %s", StrHelper::ftoa(_prefs->direct_tx_delay_factor)); } else if (memcmp(config, "hop.retry.ms", 12) == 0) { sprintf(reply, "> %u", (unsigned)_prefs->hop_retry_ms); + } else if (memcmp(config, "hop.ignore", 10) == 0) { + sprintf(reply, "> %u", (unsigned)_callbacks->getHopAckIgnore()); } else if (memcmp(config, "hop.retry", 9) == 0) { sprintf(reply, "> %u", (unsigned)_prefs->hop_retry); } else if (memcmp(config, "owner.info", 10) == 0) { diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 74a40e6619..af19320237 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -123,6 +123,9 @@ class CommonCLICallbacks { virtual void setRxBoostedGain(bool enable) { // no op by default }; + + virtual void setHopAckIgnore(uint8_t count) { } + virtual uint8_t getHopAckIgnore() { return 0; } }; class CommonCLI { From c74e4f86bba3632fc9af5bfde8926ad239eafce5 Mon Sep 17 00:00:00 2001 From: Ben Allfree Date: Mon, 20 Jul 2026 07:42:31 -0700 Subject: [PATCH 35/59] feat(mesh): echo-primary hop retry Co-authored-by: Cursor (cherry picked from commit 1f7e70a93cbc784525f41cdf19c890626e306e60) --- docs/cli_commands.md | 8 +-- docs/faq.md | 2 +- docs/payloads.md | 2 +- src/Mesh.cpp | 113 +++++++++++++++---------------------------- src/Mesh.h | 26 ++-------- 5 files changed, 51 insertions(+), 100 deletions(-) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 133307006c..f0c187f962 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -521,10 +521,10 @@ This document provides an overview of CLI commands that can be sent to MeshCore - `set hop.retry.ms ` **Parameters:** -- `count`: Extra retransmits if the next hop's hop ACK is not received (0-5). `0` disables hop retry (fire-and-forget). Default `2` means up to 3 total transmissions (initial + 2 retries). -- `milliseconds`: Listen window before each retry (200-10000). Default `1500`. +- `count`: Extra retransmits if the next hop's echo or HOP_ACK is not received (0-5). `0` disables hop retry (fire-and-forget). Default `2` means up to 3 total transmissions (initial + 2 retries). +- `milliseconds`: Base listen window before each retry (200-10000). Default `1500`. Actual deadline also includes forward delay and packet airtime. -**Note:** When a repeater forwards a direct-path packet, it waits for a zero-hop **HOP_ACK** from the next hop (if that hop is known to support hop ACK). If nothing is heard within `hop.retry.ms`, it sends again. Retry is only armed for next hops that have previously sent HOP_ACK; stock repeaters stay single-shot. Not used on the last hop (zero-hop delivery to the destination). Prefer `set multi.acks 0` when hop retry is enabled. +**Note:** When a repeater forwards a direct-path packet, it waits for the next hop's **echo** (hearing its own packet retransmitted downstream). If the echo is missed within the listen window, it retries. A hop that receives a duplicate addressed to itself replies with a zero-hop **HOP_ACK** instead of forwarding again — this terminates the retry loop when the first delivery succeeded but the echo was lost. No extra airtime in the healthy case. Not used on the last hop (zero-hop delivery to the destination). Prefer `set multi.acks 0` when hop retry is enabled. --- @@ -627,7 +627,7 @@ This document provides an overview of CLI commands that can be sent to MeshCore **Default:** `0` -**Note:** Sends extra copies of direct ACKs. With hop retry enabled (`hop.retry` > 0), prefer leaving this off — hop retry waits for the next hop's HOP_ACK instead of blind duplicate ACKs. +**Note:** Sends extra copies of direct ACKs. With hop retry enabled (`hop.retry` > 0), prefer leaving this off — hop retry uses echo confirmation and duplicate-triggered HOP_ACK instead of blind duplicate ACKs. --- diff --git a/docs/faq.md b/docs/faq.md index 3a04d47203..b633a88b14 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -488,7 +488,7 @@ Routes are stored in sender's contact list. When you send a message the first ti [Source](https://discord.com/channels/826570251612323860/1330643963501351004/1351279141630119996) ### 5.4.1. Q: What is next-hop retry on repeaters? -When a repeater forwards a direct-path packet, it normally transmits once and moves on. With hop retry enabled (default on repeaters: `hop.retry 2`, `hop.retry.ms 1500`), the repeater waits for the next hop to send a zero-hop **HOP_ACK** control packet confirming it received and forwarded. If the next hop is silent for the listen window, the repeater sends again (up to `hop.retry` extra times). Retry is only used toward next hops that have previously sent HOP_ACK; stock repeaters in the path stay fire-and-forget. This does not apply on the last hop before the destination. Clients still use end-to-end ACK timeouts for delivery confirmation; hop retry fixes silent mid-path relays. Prefer `set multi.acks 0` when using hop retry. +When a repeater forwards a direct-path packet, it normally transmits once and moves on. With hop retry enabled (default on repeaters: `hop.retry 2`, `hop.retry.ms 1500`), the repeater waits for the **echo** of its forward (hearing the same packet retransmitted by the next hop). If the echo is missed within the listen window, it sends again (up to `hop.retry` extra times). A next hop that receives a duplicate addressed to itself replies with a zero-hop **HOP_ACK** control packet instead of forwarding again — this stops the retry loop when delivery succeeded but the echo was lost. No extra airtime in the healthy case. This does not apply on the last hop before the destination. Clients still use end-to-end ACK timeouts for delivery confirmation; hop retry fixes silent mid-path relays. Prefer `set multi.acks 0` when using hop retry. ### 5.5. Q: Do public channels always flood? Do private channels always flood? **A:** Yes, group channels are A to B, so there is no defined path. They have to flood. Repeaters can however deny flood traffic up to some hop limit, with the `set flood.max` CLI command. Administrators of repeaters get to set the rules of their repeaters. diff --git a/docs/payloads.md b/docs/payloads.md index a47b51b1f8..b3ec185373 100644 --- a/docs/payloads.md +++ b/docs/payloads.md @@ -280,7 +280,7 @@ The data contained in the ciphertext uses the format below: ## HOP_ACK (sub_type) -Per-hop confirmation sent by a repeater after forwarding a direct-path packet. Zero-hop only (`path_len == 0`). +Per-hop confirmation sent by a repeater when it receives a **duplicate** direct-path packet still addressed to itself (upstream retry after a missed echo). Zero-hop only (`path_len == 0`). Not sent on the first forward. | Field | Size (bytes) | Description | |--------------|-----------------|----------------------------------------------------------| diff --git a/src/Mesh.cpp b/src/Mesh.cpp index f6a2282f88..f4f42efa0b 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -169,7 +169,7 @@ void Mesh::clearHopRetryPendingByHash(const uint8_t* hash) { void Mesh::armHopRetryPending(const Packet* forwarded, uint32_t initial_delay_ms) { uint8_t retries = getHopRetryCount(); if (retries == 0 || forwarded->getPathHashCount() == 0) return; - if (!isHopAckCapable(forwarded->path, forwarded->getPathHashSize())) return; + if (forwarded->getPayloadType() == PAYLOAD_TYPE_TRACE) return; if (_mgr->getFreeCount() < HOP_RETRY_MIN_FREE) return; uint8_t hash[MAX_HASH_SIZE]; @@ -197,56 +197,20 @@ void Mesh::armHopRetryPending(const Packet* forwarded, uint32_t initial_delay_ms memcpy(_hop_retry_pending[slot].next_hop, forwarded->path, _hop_retry_pending[slot].next_hop_sz); _hop_retry_pending[slot].pkt = clone; _hop_retry_pending[slot].retries_left = retries; - _hop_retry_pending[slot].deadline = futureMillis(getHopRetryTimeoutMs() + initial_delay_ms); + _hop_retry_pending[slot].deadline = futureMillis(getHopRetryDeadlineMs(forwarded, initial_delay_ms)); } -void Mesh::markHopAckCapable(const uint8_t* hash, uint8_t sz) { - if (sz == 0 || sz > 3) return; - - _hop_ack_capable_seq++; - uint32_t now = _hop_ack_capable_seq; - - for (int i = 0; i < HOP_ACK_CAPABLE_MAX; i++) { - HopAckCapableEntry* e = &_hop_ack_capable[i]; - if (e->in_use && e->hash_sz == sz && memcmp(e->hash, hash, sz) == 0) { - e->last_seen = now; - return; - } - } - - int slot = -1; - int evict = 0; - uint32_t oldest = _hop_ack_capable[0].last_seen; - for (int i = 0; i < HOP_ACK_CAPABLE_MAX; i++) { - HopAckCapableEntry* e = &_hop_ack_capable[i]; - if (!e->in_use) { - slot = i; - break; - } - if (e->last_seen < oldest) { - oldest = e->last_seen; - evict = i; - } - } - if (slot < 0) slot = evict; - - HopAckCapableEntry* e = &_hop_ack_capable[slot]; - memcpy(e->hash, hash, sz); - e->hash_sz = sz; - e->last_seen = now; - e->in_use = true; +uint32_t Mesh::getHopRetryDeadlineMs(const Packet* pkt, uint32_t initial_delay_ms) const { + uint32_t airtime = _radio->getEstAirtimeFor(pkt->getRawLength()); + return getHopRetryTimeoutMs() + initial_delay_ms + 2 * airtime; } -bool Mesh::isHopAckCapable(const uint8_t* next_hop, uint8_t sz) const { - if (sz == 0 || sz > 3) return false; +void Mesh::checkHopRetryEcho(const Packet* pkt) { + if (pkt->getPayloadType() == PAYLOAD_TYPE_TRACE) return; - for (int i = 0; i < HOP_ACK_CAPABLE_MAX; i++) { - const HopAckCapableEntry* e = &_hop_ack_capable[i]; - if (e->in_use && e->hash_sz == sz && memcmp(e->hash, next_hop, sz) == 0) { - return true; - } - } - return false; + uint8_t hash[MAX_HASH_SIZE]; + pkt->calculatePacketHash(hash); + clearHopRetryPendingByHash(hash); } void Mesh::checkHopRetryAck(const Packet* pkt) { @@ -260,8 +224,6 @@ void Mesh::checkHopRetryAck(const Packet* pkt) { const uint8_t* fwd_hash = &pkt->payload[1]; const uint8_t* sender_hash = &pkt->payload[10]; - markHopAckCapable(sender_hash, hash_sz); - for (int i = 0; i < HOP_RETRY_PENDING_MAX; i++) { HopRetryPending* p = &_hop_retry_pending[i]; if (p->pkt == NULL) continue; @@ -310,7 +272,7 @@ void Mesh::tickHopRetryPending() { uint32_t d = getDirectRetransmitDelay(retry); sendPacket(retry, 0, d); p->retries_left--; - p->deadline = futureMillis(getHopRetryTimeoutMs() + d); + p->deadline = futureMillis(getHopRetryDeadlineMs(p->pkt, d)); } } @@ -377,33 +339,37 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { } } - if (self_id.isHashMatch(pkt->path, pkt->getPathHashSize()) && allowPacketForward(pkt)) { - if (_hop_ack_ignore_remaining > 0) { - _hop_ack_ignore_remaining--; - return ACTION_RELEASE; - } - if (pkt->getPayloadType() == PAYLOAD_TYPE_MULTIPART) { - return forwardMultipartDirect(pkt); - } else if (pkt->getPayloadType() == PAYLOAD_TYPE_ACK) { - if (!_tables->hasSeen(pkt)) { // don't retransmit! - removeSelfFromPath(pkt); - sendHopAck(pkt, 0); - armHopRetryPending(pkt, 0); - routeDirectRecvAcks(pkt, 0); - } - return ACTION_RELEASE; - } + if (!self_id.isHashMatch(pkt->path, pkt->getPathHashSize()) || !allowPacketForward(pkt)) { + checkHopRetryEcho(pkt); + return ACTION_RELEASE; + } - if (!_tables->hasSeen(pkt)) { + if (_hop_ack_ignore_remaining > 0) { + _hop_ack_ignore_remaining--; + return ACTION_RELEASE; + } + if (pkt->getPayloadType() == PAYLOAD_TYPE_MULTIPART) { + return forwardMultipartDirect(pkt); + } else if (pkt->getPayloadType() == PAYLOAD_TYPE_ACK) { + if (!_tables->hasSeen(pkt)) { // don't retransmit! removeSelfFromPath(pkt); - uint32_t d = getDirectRetransmitDelay(pkt); - sendHopAck(pkt, d); - armHopRetryPending(pkt, d); - - return ACTION_RETRANSMIT_DELAYED(0, d); // Routed traffic is HIGHEST priority + armHopRetryPending(pkt, 0); + routeDirectRecvAcks(pkt, 0); + } else { + sendHopAck(pkt, 0); } + return ACTION_RELEASE; + } + + if (!_tables->hasSeen(pkt)) { + removeSelfFromPath(pkt); + uint32_t d = getDirectRetransmitDelay(pkt); + armHopRetryPending(pkt, d); + + return ACTION_RETRANSMIT_DELAYED(0, d); // Routed traffic is HIGHEST priority } - return ACTION_RELEASE; // this node is NOT the next hop (OR this packet has already been forwarded), so discard. + sendHopAck(pkt, 0); + return ACTION_RELEASE; } if (pkt->isRouteFlood() && filterRecvFloodPacket(pkt)) return ACTION_RELEASE; @@ -687,9 +653,10 @@ DispatcherAction Mesh::forwardMultipartDirect(Packet* pkt) { if (!_tables->hasSeen(&tmp)) { // don't retransmit! removeSelfFromPath(&tmp); uint32_t d = ((uint32_t)remaining + 1) * 300; - sendHopAck(&tmp, d); armHopRetryPending(&tmp, d); routeDirectRecvAcks(&tmp, d); // expect multipart ACKs 300ms apart (x2) + } else { + sendHopAck(&tmp, 0); } } return ACTION_RELEASE; diff --git a/src/Mesh.h b/src/Mesh.h index 5e09bd587e..9521118b38 100644 --- a/src/Mesh.h +++ b/src/Mesh.h @@ -52,9 +52,6 @@ class Mesh : public Dispatcher { #ifndef HOP_RETRY_MIN_FREE #define HOP_RETRY_MIN_FREE 4 #endif -#ifndef HOP_ACK_CAPABLE_MAX -#define HOP_ACK_CAPABLE_MAX 24 -#endif #define CTL_TYPE_HOP_ACK 0xA0 struct HopRetryPending { @@ -66,23 +63,14 @@ class Mesh : public Dispatcher { unsigned long deadline; }; HopRetryPending _hop_retry_pending[HOP_RETRY_PENDING_MAX]; - - struct HopAckCapableEntry { - uint8_t hash[MAX_PATH_SIZE]; - uint8_t hash_sz; - uint32_t last_seen; - bool in_use; - }; - HopAckCapableEntry _hop_ack_capable[HOP_ACK_CAPABLE_MAX]; - uint32_t _hop_ack_capable_seq; uint8_t _hop_ack_ignore_remaining; void copyPacketFields(Packet* dest, const Packet* src); void armHopRetryPending(const Packet* forwarded, uint32_t initial_delay_ms = 0); + void checkHopRetryEcho(const Packet* pkt); void checkHopRetryAck(const Packet* pkt); void sendHopAck(const Packet* forwarded, uint32_t delay_millis); - void markHopAckCapable(const uint8_t* hash, uint8_t sz); - bool isHopAckCapable(const uint8_t* next_hop, uint8_t sz) const; + uint32_t getHopRetryDeadlineMs(const Packet* pkt, uint32_t initial_delay_ms) const; void clearHopRetryPending(int idx); void clearHopRetryPendingByHash(const uint8_t* hash); void tickHopRetryPending(); @@ -125,12 +113,12 @@ class Mesh : public Dispatcher { virtual uint8_t getExtraAckTransmitCount() const; /** - * \returns extra direct-path retransmits if the next hop's hop ACK is not received (0 = off). + * \returns extra direct-path retransmits if the next hop's echo/ACK is not received (0 = off). */ virtual uint8_t getHopRetryCount() const { return 0; } /** - * \returns milliseconds to wait for the next hop's hop ACK before retrying. + * \returns base milliseconds to wait for echo or HOP_ACK before retrying. */ virtual uint16_t getHopRetryTimeoutMs() const { return 1500; } @@ -250,15 +238,11 @@ class Mesh : public Dispatcher { virtual void onAckRecv(Packet* packet, uint32_t ack_crc) { } Mesh(Radio& radio, MillisecondClock& ms, RNG& rng, RTCClock& rtc, PacketManager& mgr, MeshTables& tables) - : Dispatcher(radio, ms, mgr), _rng(&rng), _rtc(&rtc), _tables(&tables), _hop_ack_capable_seq(0), - _hop_ack_ignore_remaining(0) + : Dispatcher(radio, ms, mgr), _rng(&rng), _rtc(&rtc), _tables(&tables), _hop_ack_ignore_remaining(0) { for (int i = 0; i < HOP_RETRY_PENDING_MAX; i++) { _hop_retry_pending[i].pkt = NULL; } - for (int i = 0; i < HOP_ACK_CAPABLE_MAX; i++) { - _hop_ack_capable[i].in_use = false; - } } MeshTables* getTables() const { return _tables; } From 4442fbc29912e27a4e5c51171715efa55de734a1 Mon Sep 17 00:00:00 2001 From: Ben Allfree Date: Mon, 20 Jul 2026 08:06:45 -0700 Subject: [PATCH 36/59] fix(cli): log tail on enables logging Co-authored-by: Cursor (cherry picked from commit 7fd0f6b7c8fdfddf2b1cfa05feb33afbfafa4458) --- docs/cli_commands.md | 2 +- src/helpers/CommonCLI.cpp | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index f0c187f962..01dead59d1 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -159,7 +159,7 @@ This document provides an overview of CLI commands that can be sent to MeshCore ### Mirror packet log to serial (live) **Usage:** `log tail on` -**Note:** Streams RX/TX log lines to USB serial as they are captured. No-op unless `log start` is active. Serial-only (not available via remote admin). +**Note:** Streams RX/TX log lines to USB serial as they are captured. Enables logging if not already active (`log start`). Serial-only (not available via remote admin). --- diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 3c254058ce..01bbcc7e09 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -533,6 +533,7 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re } } else if (sender_timestamp == 0 && memcmp(command, "log tail on", 11) == 0 && (command[11] == 0 || command[11] == ' ')) { + _callbacks->setLoggingOn(true); _callbacks->setTailOn(true); strcpy(reply, " tail on"); } else if (sender_timestamp == 0 && memcmp(command, "log tail off", 12) == 0 From a1fad67557c25254d203d2a16ccdfbdb84a21c6e Mon Sep 17 00:00:00 2001 From: Ben Allfree Date: Mon, 20 Jul 2026 08:39:15 -0700 Subject: [PATCH 37/59] fix(mesh): zero-hop echo cancel Co-authored-by: Cursor (cherry picked from commit 59790f36daef2b4eddecd15c80a835fab1f6ab44) --- src/Mesh.cpp | 21 +++++++++++++++++++++ src/Mesh.h | 1 + 2 files changed, 22 insertions(+) diff --git a/src/Mesh.cpp b/src/Mesh.cpp index f4f42efa0b..b6ba4bb3e9 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -213,6 +213,19 @@ void Mesh::checkHopRetryEcho(const Packet* pkt) { clearHopRetryPendingByHash(hash); } +bool Mesh::isDirectZeroHopForSelf(const Packet* pkt) const { + switch (pkt->getPayloadType()) { + case PAYLOAD_TYPE_TXT_MSG: + case PAYLOAD_TYPE_REQ: + case PAYLOAD_TYPE_RESPONSE: + case PAYLOAD_TYPE_PATH: + case PAYLOAD_TYPE_ANON_REQ: + return pkt->payload_len >= 1 && self_id.isHashMatch(&pkt->payload[0], 1); + default: + return false; + } +} + void Mesh::checkHopRetryAck(const Packet* pkt) { if (pkt->payload_len < 11) return; if (pkt->payload[0] != CTL_TYPE_HOP_ACK) return; @@ -328,6 +341,14 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { return ACTION_RELEASE; } + if (pkt->isRouteDirect() && pkt->getPathHashCount() == 0 && pkt->getPayloadType() != PAYLOAD_TYPE_CONTROL) { + // Last repeater hop often forwards zero-hop to the destination; upstream still overhears it as echo. + if (!isDirectZeroHopForSelf(pkt)) { + checkHopRetryEcho(pkt); + return ACTION_RELEASE; + } + } + if (pkt->isRouteDirect() && pkt->getPathHashCount() > 0) { // check for 'early received' ACK if (pkt->getPayloadType() == PAYLOAD_TYPE_ACK) { diff --git a/src/Mesh.h b/src/Mesh.h index 9521118b38..16b4848ab6 100644 --- a/src/Mesh.h +++ b/src/Mesh.h @@ -68,6 +68,7 @@ class Mesh : public Dispatcher { void copyPacketFields(Packet* dest, const Packet* src); void armHopRetryPending(const Packet* forwarded, uint32_t initial_delay_ms = 0); void checkHopRetryEcho(const Packet* pkt); + bool isDirectZeroHopForSelf(const Packet* pkt) const; void checkHopRetryAck(const Packet* pkt); void sendHopAck(const Packet* forwarded, uint32_t delay_millis); uint32_t getHopRetryDeadlineMs(const Packet* pkt, uint32_t initial_delay_ms) const; From be7b38fc0534a8bdff5b284e0a14c1727a21a34d Mon Sep 17 00:00:00 2001 From: Ben Allfree Date: Sun, 19 Jul 2026 15:36:17 -0700 Subject: [PATCH 38/59] Role-aware nRF52 OTA staging ceiling Co-authored-by: Cursor (cherry picked from commit 5116d0ba3c8c2992d86f19e652966d9b264094d1) --- docs/ota_protocol.md | 15 +++++ src/helpers/ota/OtaApply.cpp | 36 +++++++++++ src/helpers/ota/OtaByteIO.h | 16 +++++ src/helpers/ota/OtaFlashLayout_nrf52.h | 68 ++++++++++---------- src/helpers/ota/OtaSelf.cpp | 4 +- src/helpers/ota/OtaStoreFlashNrf52.cpp | 8 +-- src/helpers/ota/OtaStoreFlashNrf52.h | 2 +- test/test_ota/test_ota_flashplan.cpp | 84 +++++++++++-------------- variants/rak4631/platformio.ini | 44 +++++++++++++ variants/rak_wismesh_tag/platformio.ini | 2 + 10 files changed, 190 insertions(+), 89 deletions(-) diff --git a/docs/ota_protocol.md b/docs/ota_protocol.md index 5f0b8bcc69..48b96e7810 100644 --- a/docs/ota_protocol.md +++ b/docs/ota_protocol.md @@ -619,6 +619,21 @@ ota dev ... bring-up helpers (stage/recv/serve/verify) 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`. +**nRF52 flash layout (RAK4631 class).** Constants in `OtaFlashLayout_nrf52.h` / `ota_layout.h`: + +| Region | Address | Role | +|--------|---------|------| +| App | `0x26000` … | Running firmware (+ EndF trailer) | +| ExtraFS | `0xD4000` … `0xED000` | Companion LittleFS only; **unused on repeaters** | +| Staging ceiling | `0xD4000` (companion) or `0xED000` (repeater/room-server) | Per-env `MOTA_STAGE_CEILING`; `.mota` bottom-aligned here | +| InternalFS | `0xED000` … | Primary LittleFS (`/com_prefs`) — never staged into | +| Bootloader scan | `[APP_BASE, 0xED000)` | OTAFIX scans for staged `.mota`; apply workspace ends at `mota_start` | + +In-place deltas carry a per-patch `memory_size` in the detools header. `motatool build --patch-type in-place` +derives it from the target's staging ceiling and patch size (override with `--inplace-memory`). Before +writing `APRV`, the device refuses a patch whose `APP_BASE + memory_size` would reach into the staged +container. + 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 diff --git a/src/helpers/ota/OtaApply.cpp b/src/helpers/ota/OtaApply.cpp index f63f4d6884..7b3c469a86 100644 --- a/src/helpers/ota/OtaApply.cpp +++ b/src/helpers/ota/OtaApply.cpp @@ -21,6 +21,7 @@ #include "OtaSelf.h" #include "OtaFlashLayout_nrf52.h" #include "OtaBlInfo.h" // read the bootloader capability marker before arming an apply + #include "OtaByteIO.h" #include "flash/flash_nrf5x.h" // Adafruit core internal-flash driver (has its own extern "C") #include "nrf.h" #include "nrf_soc.h" @@ -408,6 +409,24 @@ void ota_reboot_to_apply() { esp_restart(); } // boots the slot armed by ota_ap #elif defined(NRF52_PLATFORM) // single-slot: verify + mark APPROVED + hand off to the bootloader +static const uint8_t PATCH_TYPE_IN_PLACE = 1u; + +// Parse the detools in-place patch header (memory_size + to_size) from the staged payload. +static bool parse_inplace_patch_dims(const uint8_t* payload, uint32_t payload_len, + uint32_t& memory_size, uint32_t& to_size) { + if (!payload || payload_len < 2) return false; + if (((payload[0] >> 4) & 0x7u) != PATCH_TYPE_IN_PLACE) return false; + ByteReader r(payload, payload_len); + r.u8(); // header byte (already checked) + int32_t mem = 0, seg = 0, shift = 0, from = 0, to = 0; + if (!r.detools_size(mem) || !r.detools_size(seg) || !r.detools_size(shift) + || !r.detools_size(from) || !r.detools_size(to) || !r.ok) return false; + if (mem <= 0 || to <= 0) return false; + memory_size = (uint32_t)mem; + to_size = (uint32_t)to; + return true; +} + // ESP32 A/B-only entry points are unsupported on nRF52. bool ota_apply_slot_info(uint32_t*, uint32_t*) { return false; } bool ota_apply_set_manifest(const uint8_t*, uint32_t, const SignerAllowlist&, ApplyState& st) { st = ApplyState(); return false; } @@ -478,6 +497,23 @@ bool ota_apply_mota_nrf52(const uint8_t* buf, uint32_t len, const SignerAllowlis if (!vr.trusted) { strcpy(msg, "untrusted signer (pubkey not in allowlist)"); return false; } } + // 4) in-place geometry: the patch memory window must fit below this staged container (the bootloader + // sets ws_hi = mota start). Refuse before writing APRV — a mis-sized patch would fail mid-apply. + { + uint32_t mota_start = (uint32_t)(uintptr_t)buf; + uint32_t patch_mem = 0, patch_to = 0; + if (!m.payload || !parse_inplace_patch_dims(m.payload, m.payload_size, patch_mem, patch_to)) { + strcpy(msg, "bad in-place patch header"); return false; + } + if (patch_to != m.image_size) { strcpy(msg, "patch to_size != manifest image_size"); return false; } + if (patch_to > patch_mem) { strcpy(msg, "target image exceeds patch memory window"); return false; } + if (MOTA_NRF52_APP_BASE + patch_mem > mota_start) { + snprintf(msg, 159, "patch memory 0x%x too large for staging at 0x%x", + (unsigned)patch_mem, (unsigned)mota_start); + return false; + } + } + // mark the staged manifest APPROVED in flash (buf is the memory-mapped staging region, so // m.approval is a real flash address). NOR-clear over the erased 0xFFFFFFFF -> "APRV". uint32_t approval_addr = (uint32_t)(uintptr_t)m.approval; diff --git a/src/helpers/ota/OtaByteIO.h b/src/helpers/ota/OtaByteIO.h index d4834cd8b6..1ef0e0f125 100644 --- a/src/helpers/ota/OtaByteIO.h +++ b/src/helpers/ota/OtaByteIO.h @@ -52,6 +52,22 @@ struct ByteReader { const uint8_t* r = p + n; n += k; return r; } void skip(uint32_t k) { if (!fits(k)) { ok = false; return; } n += k; } + + // detools signed size varint (patch header fields). Matches detools chunk_unpack_header_size. + bool detools_size(int32_t& out) { + if (!ok || n >= len) { ok = false; return false; } + uint8_t byte = p[n++]; + int32_t value = byte & 0x3f; + uint32_t offset = 6; + while (byte & 0x80) { + if (n >= len) { ok = false; return false; } + byte = p[n++]; + value |= (int32_t)(byte & 0x7f) << (int)offset; + offset += 7; + } + out = value; + return true; + } }; } // namespace ota diff --git a/src/helpers/ota/OtaFlashLayout_nrf52.h b/src/helpers/ota/OtaFlashLayout_nrf52.h index 97cf6bf822..a6688baefe 100644 --- a/src/helpers/ota/OtaFlashLayout_nrf52.h +++ b/src/helpers/ota/OtaFlashLayout_nrf52.h @@ -3,31 +3,32 @@ // Shared OTA flash-layout constants for the nRF52840 (RAK4631) single-slot delta-apply path. // SINGLE SOURCE OF TRUTH — keep byte-identical with the bootloader's src/ota_layout.h. // -// The running app occupies [APP_BASE, app_end]; the primary LittleFS (InternalFS) starts at FS_START. -// MeshCore stages a verified+approved `.mota` in the free flash below FS_START (bottom-aligned), then -// sets GPREGRET_OTA_APPLY and resets; the bootloader scans [APP_BASE, FS_START) for it and applies it -// in place. These must match the bootloader and the running SoftDevice's app base. +// The running app occupies [APP_BASE, app_end]; companion builds also use ExtraFS at 0xD4000 and +// InternalFS at 0xED000. MeshCore stages a verified+approved `.mota` in the free flash below the +// role's staging ceiling (bottom-aligned), then sets GPREGRET_OTA_APPLY and resets; the bootloader +// scans [APP_BASE, FS_START) for it and applies it in place. #include namespace mesh { namespace ota { -static const uint32_t MOTA_NRF52_APP_BASE = 0x00026000u; // S140 end (== CODE_REGION_1_START) -// Staging ceiling: the lowest filesystem region above the app. RAK4631 companion builds use the -// extrafs ldscript with ExtraFS at 0xD4000..0xED000 (and InternalFS at 0xED000), while the repeater -// uses the default ldscript (InternalFS at 0xED000, 0xD4000..0xED000 free). 0xD4000 is the safe -// universal ceiling for ALL RAK4631 roles: staging below it never touches ExtraFS or InternalFS, and -// the app (~520 KB) sits well below 0xD4000 either way. -static const uint32_t MOTA_NRF52_FS_START = 0x000D4000u; // ExtraFS start (universal staging ceiling) -static const uint32_t MOTA_NRF52_FLASH_PAGE = 4096u; -static const uint8_t GPREGRET_OTA_APPLY = 0x6Au; // distinct from DFU magics 0x57/0x4E/0xA8 +static const uint32_t MOTA_NRF52_APP_BASE = 0x00026000u; // S140 end (== CODE_REGION_1_START) +static const uint32_t MOTA_NRF52_EXTRAFS_START = 0x000D4000u; // companion ExtraFS (CustomLFS) +static const uint32_t MOTA_NRF52_INTERNALFS_START = 0x000ED000u; // primary LittleFS (/com_prefs) +static const uint32_t MOTA_NRF52_FLASH_PAGE = 4096u; +static const uint8_t GPREGRET_OTA_APPLY = 0x6Au; // distinct from DFU magics 0x57/0x4E/0xA8 -// In-place patches are built with --inplace-memory = this (the apply workspace, from APP_BASE up). -// It must hold the new image (~520 KB) yet leave the staged mota room below FS_START: workspace ends -// at APP_BASE+this = 0xBE000, leaving 0xBE000..0xD4000 (~88 KB) for the staged delta. The bootloader -// also bounds writes to < the (scanned) mota start, so a mis-sized memory still fails safe. -static const uint32_t MOTA_NRF52_INPLACE_MEMORY = 0x00098000u; // 608 KB (APP_BASE .. 0xBE000) +// Per-role staging ceiling (build flag). Default 0xD4000 = below ExtraFS (companion-safe). Repeater/ +// room-server envs set 0xED000 to reclaim the unused ExtraFS region on boards that never mount it. +#ifndef MOTA_STAGE_CEILING +#define MOTA_STAGE_CEILING 0x000D4000u +#endif +static const uint32_t MOTA_NRF52_STAGE_CEILING = MOTA_STAGE_CEILING; + +// Bootloader scan ceiling — InternalFS start. The bootloader always scans up to here; companion +// firmware still stages below ExtraFS via MOTA_STAGE_CEILING. +static const uint32_t MOTA_NRF52_FS_START = MOTA_NRF52_INTERNALFS_START; // Bootloader flash region (nRF52840: 39 KB ending just below the CF2/MBR-params pages). The app scans // this for the bootloader capability marker (OtaBlInfo.h) to know whether THIS device's bootloader can @@ -35,30 +36,25 @@ static const uint32_t MOTA_NRF52_INPLACE_MEMORY = 0x00098000u; // 608 KB (APP_B static const uint32_t MOTA_NRF52_BL_START = 0x000F4000u; static const uint32_t MOTA_NRF52_BL_END = 0x000FE000u; -// Compile-time layout-ordering invariants. If a constant above is edited inconsistently these fail the -// BUILD rather than silently letting a stage/apply corrupt the filesystem (user prefs) or the app. static_assert((MOTA_NRF52_APP_BASE % MOTA_NRF52_FLASH_PAGE) == 0, "APP_BASE must be page-aligned"); +static_assert((MOTA_NRF52_STAGE_CEILING % MOTA_NRF52_FLASH_PAGE) == 0, "STAGE_CEILING must be page-aligned"); static_assert((MOTA_NRF52_FS_START % MOTA_NRF52_FLASH_PAGE) == 0, "FS_START must be page-aligned"); -static_assert(MOTA_NRF52_APP_BASE < MOTA_NRF52_FS_START, "app must precede the staging ceiling"); +static_assert(MOTA_NRF52_APP_BASE < MOTA_NRF52_STAGE_CEILING, "app must precede the staging ceiling"); +static_assert(MOTA_NRF52_STAGE_CEILING <= MOTA_NRF52_FS_START, "staging ceiling must not enter InternalFS"); static_assert(MOTA_NRF52_FS_START < MOTA_NRF52_BL_START, "staging (+FS) must end below the bootloader"); static_assert(MOTA_NRF52_BL_START < MOTA_NRF52_BL_END, "bootloader region must be non-empty"); -// The in-place apply workspace [APP_BASE, APP_BASE+INPLACE_MEMORY) must end at/below the staging ceiling, -// so an in-place apply never writes into ExtraFS/InternalFS (where user prefs live). -static_assert(MOTA_NRF52_APP_BASE + MOTA_NRF52_INPLACE_MEMORY <= MOTA_NRF52_FS_START, - "in-place apply workspace must end at or below the staging ceiling"); // Plan where to stage a received `.mota` of `total_size` bytes. It is placed bottom-aligned so its -// 5-byte trailer ends exactly at FS_START (the bootloader scans downward from there), and it must sit -// ENTIRELY within [app_end, FS_START): above the running image (`app_end` = APP_BASE + its EndF image_len) -// and below the filesystem region (ExtraFS/InternalFS — where user prefs live, assumed immutable). -// Returns false (and leaves out_start untouched) if it does not fit. This is the SINGLE place the FS -// ceiling + app-collision bounds are enforced; begin()/reopen() both go through it. Pure — no flash I/O — -// so it is unit-tested natively in test/test_ota/test_ota_flashplan.cpp. -inline bool mota_nrf52_stage_plan(uint32_t total_size, uint32_t app_end, uint32_t& out_start) { - const uint32_t capacity = MOTA_NRF52_FS_START - MOTA_NRF52_APP_BASE; - if (total_size < 13 || total_size > capacity) return false; // 13 = header(8)+trailer(5); must fit below FS - uint32_t start = (MOTA_NRF52_FS_START - total_size) & ~(MOTA_NRF52_FLASH_PAGE - 1); // bottom-align down - if (start < app_end) return false; // would overlap the running image +// 5-byte trailer ends exactly at `fs_start` (the bootloader scans downward from there), and it must sit +// ENTIRELY within [app_end, fs_start): above the running image and below the role's staging ceiling. +// Returns false (and leaves out_start untouched) if it does not fit. Pure — no flash I/O — unit-tested +// in test/test_ota/test_ota_flashplan.cpp. +inline bool mota_nrf52_stage_plan(uint32_t total_size, uint32_t app_end, uint32_t fs_start, + uint32_t& out_start) { + const uint32_t capacity = fs_start - MOTA_NRF52_APP_BASE; + if (total_size < 13 || total_size > capacity) return false; // 13 = header(8)+trailer(5) + uint32_t start = (fs_start - total_size) & ~(MOTA_NRF52_FLASH_PAGE - 1); + if (start < app_end) return false; out_start = start; return true; } diff --git a/src/helpers/ota/OtaSelf.cpp b/src/helpers/ota/OtaSelf.cpp index f1f1902d4e..6326f17510 100644 --- a/src/helpers/ota/OtaSelf.cpp +++ b/src/helpers/ota/OtaSelf.cpp @@ -68,7 +68,7 @@ bool ota_self_firmware(SelfFwInfo& out) { // firmware's own trailer), ignoring any staged `.mota` (which carries its own embedded EndF) higher up. bool ota_self_firmware(SelfFwInfo& out) { const uint8_t* region = (const uint8_t*)(uintptr_t)MOTA_NRF52_APP_BASE; - uint32_t region_len = MOTA_NRF52_FS_START - MOTA_NRF52_APP_BASE; + uint32_t region_len = MOTA_NRF52_STAGE_CEILING - MOTA_NRF52_APP_BASE; return find_self_firmware(region, region_len, out, /*verify_body=*/true); } #else @@ -86,7 +86,7 @@ bool ota_self_read(uint32_t off, uint8_t* buf, uint32_t len) { } #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; + if ((uint64_t)MOTA_NRF52_APP_BASE + off + len > MOTA_NRF52_STAGE_CEILING) return false; memcpy(buf, (const uint8_t*)(uintptr_t)(MOTA_NRF52_APP_BASE + off), len); return true; } diff --git a/src/helpers/ota/OtaStoreFlashNrf52.cpp b/src/helpers/ota/OtaStoreFlashNrf52.cpp index e1f0aa2f94..7ff68c95e3 100644 --- a/src/helpers/ota/OtaStoreFlashNrf52.cpp +++ b/src/helpers/ota/OtaStoreFlashNrf52.cpp @@ -17,7 +17,7 @@ namespace ota { // never reaches into ExtraFS. void OtaStoreFlashNrf52::flush_page(uint32_t page_idx, const uint8_t* buf) { uint32_t addr = _write_start + page_idx * PG; - if (addr + PG > MOTA_NRF52_FS_START) return; // defensive: never cross the staging ceiling + if (addr + PG > MOTA_NRF52_STAGE_CEILING) return; // defensive: never cross the staging ceiling OTA_DBG("OTA flash: write page %u @ %08x\n", (unsigned)page_idx, (unsigned)addr); flash_nrf5x_write(addr, buf, PG); flash_nrf5x_flush(); @@ -65,7 +65,7 @@ bool OtaStoreFlashNrf52::begin(uint32_t total_size) { // bottom-align below FS_START + reject if it won't fit above the running image (the FS/prefs-safe // bounds check; pure + unit-tested in test/test_ota/test_ota_flashplan.cpp) uint32_t start; - if (!mota_nrf52_stage_plan(total_size, app_end, start)) return false; + if (!mota_nrf52_stage_plan(total_size, app_end, MOTA_NRF52_STAGE_CEILING, start)) return false; _write_start = start; _total = total_size; @@ -135,12 +135,12 @@ 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 = align_down(MOTA_NRF52_FS_START - PG, PG); start >= app_end; start -= PG) { + for (uint32_t start = align_down(MOTA_NRF52_STAGE_CEILING - PG, PG); 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 = rd_u32le(p + 4); uint32_t want; // must be valid + placed exactly where begin() would have staged it (same bounds fn) - if (!mota_nrf52_stage_plan(total, app_end, want) || want != start) continue; + if (!mota_nrf52_stage_plan(total, app_end, MOTA_NRF52_STAGE_CEILING, want) || want != start) continue; _write_start = start; _total = total; memcpy(_meta_page, p, PG); // load page 0 (header+manifest+leaves) into RAM to continue diff --git a/src/helpers/ota/OtaStoreFlashNrf52.h b/src/helpers/ota/OtaStoreFlashNrf52.h index 37d64ceff3..bcfb03ac09 100644 --- a/src/helpers/ota/OtaStoreFlashNrf52.h +++ b/src/helpers/ota/OtaStoreFlashNrf52.h @@ -57,7 +57,7 @@ class OtaStoreFlashNrf52 : public OtaStore { 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 MOTA_NRF52_FS_START - MOTA_NRF52_APP_BASE; } + uint32_t capacity() const override { return MOTA_NRF52_STAGE_CEILING - MOTA_NRF52_APP_BASE; } uint32_t staged_size() const override { return _total; } 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 diff --git a/test/test_ota/test_ota_flashplan.cpp b/test/test_ota/test_ota_flashplan.cpp index 785e5d7c4f..d6b3b3acd1 100644 --- a/test/test_ota/test_ota_flashplan.cpp +++ b/test/test_ota/test_ota_flashplan.cpp @@ -5,81 +5,73 @@ using namespace mesh::ota; // These lock down the nRF52 (RAK4631) single-slot staging geometry that OtaStoreFlashNrf52::begin()/ -// reopen() rely on. A received `.mota` is placed bottom-aligned below the filesystem region — ExtraFS -// (0xD4000) / InternalFS (0xED000), where the node's user preferences live — and above the running image. -// The prefs region is assumed IMMUTABLE (its bytes are outside the served/hashed self-image), so staging -// or an in-place apply must never reach into it. If a layout constant or the placement math is edited -// inconsistently, these fail here instead of silently corrupting prefs / the app on real hardware. +// reopen() rely on. A received `.mota` is placed bottom-aligned below the role's staging ceiling and +// above the running image. Companion builds use 0xD4000 (below ExtraFS); repeaters use 0xED000. -static constexpr uint32_t CAP = MOTA_NRF52_FS_START - MOTA_NRF52_APP_BASE; // 0xAE000, 696 KB +static constexpr uint32_t CEILING_COMPANION = 0x000D4000u; +static constexpr uint32_t CEILING_REPEATER = 0x000ED000u; -// A typical running image (~520 KB) leaves room; the container lands strictly within (app_end, FS_START]. TEST(OtaFlashPlan, StagesBelowFilesystemAndAboveApp) { uint32_t app_end = MOTA_NRF52_APP_BASE + 520u * 1024u; uint32_t start = 0xDEADBEEF; - ASSERT_TRUE(mota_nrf52_stage_plan(64u * 1024u, app_end, start)); - EXPECT_GE(start, app_end); // never overlaps the running image - EXPECT_LE(start + 64u * 1024u, MOTA_NRF52_FS_START); // never reaches into ExtraFS/InternalFS/prefs - EXPECT_EQ(start % MOTA_NRF52_FLASH_PAGE, 0u); // page-aligned (the flash erase unit) + ASSERT_TRUE(mota_nrf52_stage_plan(64u * 1024u, app_end, CEILING_COMPANION, start)); + EXPECT_GE(start, app_end); + EXPECT_LE(start + 64u * 1024u, CEILING_COMPANION); + EXPECT_EQ(start % MOTA_NRF52_FLASH_PAGE, 0u); } -// Bottom-aligned: the container's trailer ends AT FS_START (the bootloader scans downward from there), -// so start is the page-aligned FS_START - total_size and sits within one page of the ceiling. TEST(OtaFlashPlan, BottomAlignedTrailerEndsAtCeiling) { uint32_t start = 0; uint32_t total = 100000; - ASSERT_TRUE(mota_nrf52_stage_plan(total, MOTA_NRF52_APP_BASE, start)); - EXPECT_EQ(start, (MOTA_NRF52_FS_START - total) & ~(MOTA_NRF52_FLASH_PAGE - 1)); - EXPECT_LE(start + total, MOTA_NRF52_FS_START); - EXPECT_GT(start + total, MOTA_NRF52_FS_START - MOTA_NRF52_FLASH_PAGE); // within one page of the ceiling + ASSERT_TRUE(mota_nrf52_stage_plan(total, MOTA_NRF52_APP_BASE, CEILING_COMPANION, start)); + EXPECT_EQ(start, (CEILING_COMPANION - total) & ~(MOTA_NRF52_FLASH_PAGE - 1)); + EXPECT_LE(start + total, CEILING_COMPANION); + EXPECT_GT(start + total, CEILING_COMPANION - MOTA_NRF52_FLASH_PAGE); } -// An exactly-capacity container (no app below it) fills the whole staging region; one byte more never fits. TEST(OtaFlashPlan, RejectsOversizedContainer) { uint32_t start = 0; - ASSERT_TRUE(mota_nrf52_stage_plan(CAP, MOTA_NRF52_APP_BASE, start)); - EXPECT_EQ(start, MOTA_NRF52_APP_BASE); // exactly fills [APP_BASE, FS_START) - EXPECT_EQ(start + CAP, MOTA_NRF52_FS_START); - EXPECT_FALSE(mota_nrf52_stage_plan(CAP + 1, MOTA_NRF52_APP_BASE, start)); + const uint32_t cap = CEILING_COMPANION - MOTA_NRF52_APP_BASE; + ASSERT_TRUE(mota_nrf52_stage_plan(cap, MOTA_NRF52_APP_BASE, CEILING_COMPANION, start)); + EXPECT_EQ(start, MOTA_NRF52_APP_BASE); + EXPECT_EQ(start + cap, CEILING_COMPANION); + EXPECT_FALSE(mota_nrf52_stage_plan(cap + 1, MOTA_NRF52_APP_BASE, CEILING_COMPANION, start)); } -// A container that would fit below FS_START on its own but overlaps the running image is refused — -// the pull fails cleanly rather than corrupting the app that is currently executing. TEST(OtaFlashPlan, RejectsCollisionWithRunningImage) { - uint32_t app_end = MOTA_NRF52_FS_START - 8u * 1024u; // only 8 KB free below the ceiling + uint32_t app_end = CEILING_COMPANION - 8u * 1024u; uint32_t start = 0; - EXPECT_TRUE(mota_nrf52_stage_plan(4u * 1024u, app_end, start)); // 4 KB fits in the 8 KB gap + EXPECT_TRUE(mota_nrf52_stage_plan(4u * 1024u, app_end, CEILING_COMPANION, start)); EXPECT_GE(start, app_end); - EXPECT_FALSE(mota_nrf52_stage_plan(16u * 1024u, app_end, start)); // 16 KB does not + EXPECT_FALSE(mota_nrf52_stage_plan(16u * 1024u, app_end, CEILING_COMPANION, start)); } -// Minimum container is header(8)+trailer(5)=13 bytes; anything smaller is not a container. TEST(OtaFlashPlan, RejectsUndersizedContainer) { uint32_t start = 0; - EXPECT_FALSE(mota_nrf52_stage_plan(12, MOTA_NRF52_APP_BASE, start)); - EXPECT_TRUE(mota_nrf52_stage_plan(13, MOTA_NRF52_APP_BASE, start)); + EXPECT_FALSE(mota_nrf52_stage_plan(12, MOTA_NRF52_APP_BASE, CEILING_COMPANION, start)); + EXPECT_TRUE(mota_nrf52_stage_plan(13, MOTA_NRF52_APP_BASE, CEILING_COMPANION, start)); +} + +TEST(OtaFlashPlan, CompanionCeilingBelowExtraFS) { + EXPECT_EQ(CEILING_COMPANION, MOTA_NRF52_EXTRAFS_START); + uint32_t start = 0; + const uint32_t cap = CEILING_COMPANION - MOTA_NRF52_APP_BASE; + ASSERT_TRUE(mota_nrf52_stage_plan(cap, MOTA_NRF52_APP_BASE, CEILING_COMPANION, start)); + EXPECT_LE(start + cap, MOTA_NRF52_EXTRAFS_START); } -// The user-preferences filesystems (ExtraFS @ 0xD4000, InternalFS @ 0xED000) are entirely ABOVE any -// staged container AND above the in-place apply workspace — the immutability the shared-firmware hashes -// depend on. Hard-code the FS addresses here (independent of the layout header) so a future edit to -// FS_START that drifts into a filesystem is caught. -TEST(OtaFlashPlan, PrefsRegionNeverStaged) { - const uint32_t EXTRAFS_START = 0xD4000u; // companion ExtraFS (CustomLFS(0xD4000, 0x19000)) - const uint32_t INTERNALFS_START = 0xED000u; // primary LittleFS (holds /com_prefs) - EXPECT_LE(MOTA_NRF52_FS_START, EXTRAFS_START); // staging ceiling at/below the first filesystem - EXPECT_LT(EXTRAFS_START, INTERNALFS_START); - // the largest possible staged container still ends at the ceiling, never into a filesystem +TEST(OtaFlashPlan, RepeaterCeilingReclaimsExtraFS) { + const uint32_t cap = CEILING_REPEATER - MOTA_NRF52_APP_BASE; + EXPECT_GT(cap, CEILING_COMPANION - MOTA_NRF52_APP_BASE); uint32_t start = 0; - ASSERT_TRUE(mota_nrf52_stage_plan(CAP, MOTA_NRF52_APP_BASE, start)); - EXPECT_LE(start + CAP, EXTRAFS_START); - // and the in-place apply workspace ends below the filesystem too - EXPECT_LE(MOTA_NRF52_APP_BASE + MOTA_NRF52_INPLACE_MEMORY, EXTRAFS_START); + ASSERT_TRUE(mota_nrf52_stage_plan(cap, MOTA_NRF52_APP_BASE, CEILING_REPEATER, start)); + EXPECT_EQ(start, MOTA_NRF52_APP_BASE); + EXPECT_LE(start + cap, MOTA_NRF52_INTERNALFS_START); } -// out_start is only written on success — a rejected plan must not clobber the caller's variable. TEST(OtaFlashPlan, LeavesOutputUntouchedOnReject) { uint32_t start = 0x1234ABCD; - EXPECT_FALSE(mota_nrf52_stage_plan(CAP + 1, MOTA_NRF52_APP_BASE, start)); + const uint32_t cap = CEILING_COMPANION - MOTA_NRF52_APP_BASE; + EXPECT_FALSE(mota_nrf52_stage_plan(cap + 1, MOTA_NRF52_APP_BASE, CEILING_COMPANION, start)); EXPECT_EQ(start, 0x1234ABCDu); } diff --git a/variants/rak4631/platformio.ini b/variants/rak4631/platformio.ini index 0d2ca7dc0e..89cf4a17d4 100644 --- a/variants/rak4631/platformio.ini +++ b/variants/rak4631/platformio.ini @@ -47,6 +47,7 @@ lib_deps = extends = rak4631 build_flags = ${rak4631.build_flags} + -D MOTA_STAGE_CEILING=0xED000 -D DISPLAY_CLASS=SSD1306Display -D ADVERT_NAME='"RAK4631 Repeater"' -D ADVERT_LAT=0.0 @@ -61,10 +62,31 @@ build_src_filter = ${rak4631.build_src_filter} ; OTA (ENABLE_OTA + flash store + EndF + helpers/ota) is inherited from the [rak4631] base now +<../examples/simple_repeater> +[env:RAK_4631_repeater_ethernet] +extends = rak4631 +build_flags = + ${rak4631.build_flags} + -D MOTA_STAGE_CEILING=0xED000 + -D DISPLAY_CLASS=SSD1306Display + -D ADVERT_NAME='"RAK4631 Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D ETHERNET_ENABLED=1 +; -D MESH_DEBUG=1 +build_src_filter = ${rak4631.build_src_filter} + + + +<../examples/simple_repeater> +lib_deps = + ${rak4631.lib_deps} + https://github.com/RAKWireless/RAK13800-W5100S/archive/1.0.2.zip + [env:RAK_4631_repeater_bridge_rs232_serial1] extends = rak4631 build_flags = ${rak4631.build_flags} + -D MOTA_STAGE_CEILING=0xED000 -D DISPLAY_CLASS=SSD1306Display -D ADVERT_NAME='"RS232 Bridge"' -D ADVERT_LAT=0.0 @@ -88,6 +110,7 @@ build_src_filter = ${rak4631.build_src_filter} extends = rak4631 build_flags = ${rak4631.build_flags} + -D MOTA_STAGE_CEILING=0xED000 -D DISPLAY_CLASS=SSD1306Display -D ADVERT_NAME='"RS232 Bridge"' -D ADVERT_LAT=0.0 @@ -111,6 +134,7 @@ build_src_filter = ${rak4631.build_src_filter} extends = rak4631 build_flags = ${rak4631.build_flags} + -D MOTA_STAGE_CEILING=0xED000 -D DISPLAY_CLASS=SSD1306Display -D ADVERT_NAME='"Test Room"' -D ADVERT_LAT=0.0 @@ -123,6 +147,26 @@ build_src_filter = ${rak4631.build_src_filter} + +<../examples/simple_room_server> +[env:RAK_4631_room_server_ethernet] +extends = rak4631 +build_flags = + ${rak4631.build_flags} + -D MOTA_STAGE_CEILING=0xED000 + -D DISPLAY_CLASS=SSD1306Display + -D ADVERT_NAME='"RAK4631 Room Server"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ROOM_PASSWORD='"hello"' + -D ETHERNET_ENABLED=1 +; -D MESH_DEBUG=1 +build_src_filter = ${rak4631.build_src_filter} + + + +<../examples/simple_room_server> +lib_deps = + ${rak4631.lib_deps} + https://github.com/RAKWireless/RAK13800-W5100S/archive/1.0.2.zip + [env:RAK_4631_companion_radio_usb] extends = rak4631 board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld diff --git a/variants/rak_wismesh_tag/platformio.ini b/variants/rak_wismesh_tag/platformio.ini index 45669414e2..7e04ed93ba 100644 --- a/variants/rak_wismesh_tag/platformio.ini +++ b/variants/rak_wismesh_tag/platformio.ini @@ -38,6 +38,7 @@ lib_deps = extends = rak_wismesh_tag build_flags = ${rak_wismesh_tag.build_flags} + -D MOTA_STAGE_CEILING=0xED000 -D ADVERT_NAME='"RAK WM Repeater"' -D ADVERT_LAT=0.0 -D ADVERT_LON=0.0 @@ -52,6 +53,7 @@ build_src_filter = ${rak_wismesh_tag.build_src_filter} extends = rak_wismesh_tag build_flags = ${rak_wismesh_tag.build_flags} + -D MOTA_STAGE_CEILING=0xED000 -D ADVERT_NAME='"RAK WM Room"' -D ADVERT_LAT=0.0 -D ADVERT_LON=0.0 From 01e272099cbac4cda353889b4d64464e055f5329 Mon Sep 17 00:00:00 2001 From: Ben Allfree Date: Sun, 19 Jul 2026 16:18:10 -0700 Subject: [PATCH 39/59] feat(rak4631): slim repeater role No OLED or external sensor drivers (built-in MCU temperature only). Extends the sensor-free rak4631_hw OTA recipe: 434,036 B flash vs 572,336 B for the full repeater (~135 KB saved), growing .mota staging headroom below MOTA_STAGE_CEILING from ~237 KB to ~372 KB. Co-authored-by: Cursor (cherry picked from commit beeef280a5ac909656f50524f559e3b14db564b2) --- variants/rak4631/platformio.ini | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/variants/rak4631/platformio.ini b/variants/rak4631/platformio.ini index 89cf4a17d4..979d1bf645 100644 --- a/variants/rak4631/platformio.ini +++ b/variants/rak4631/platformio.ini @@ -62,6 +62,39 @@ build_src_filter = ${rak4631.build_src_filter} ; OTA (ENABLE_OTA + flash store + EndF + helpers/ota) is inherited from the [rak4631] base now +<../examples/simple_repeater> +; Slim repeater: no OLED and no external sensor drivers (built-in MCU temperature only), extending the +; sensor-free [rak4631_hw] OTA recipe instead of [rak4631]. The smaller app image frees flash below +; MOTA_STAGE_CEILING for staging bigger .mota files. helpers/sensors still builds so the repeater's +; EnvironmentSensorManager API exists, but with no ENV_INCLUDE_* flags it reduces to a stub. +[env:RAK_4631_repeater_slim] +extends = rak4631_hw +board = rak4631 +board_check = true +build_flags = ${rak4631_hw.build_flags} + -I variants/rak4631 + -D RAK_4631 + -D RAK_BOARD + -D MOTA_HW_ID='"RAK4631"' + -D NRF52_POWER_MANAGEMENT + -D PIN_BOARD_SCL=14 + -D PIN_BOARD_SDA=13 + -D USE_SX1262 + -D RADIO_CLASS=CustomSX1262 + -D WRAPPER_CLASS=CustomSX1262Wrapper + -D LORA_TX_POWER=22 + -D SX126X_CURRENT_LIMIT=140 + -D SX126X_RX_BOOSTED_GAIN=1 + -D MOTA_STAGE_CEILING=0xED000 + -D ADVERT_NAME='"RAK4631 Slim Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 +build_src_filter = ${rak4631_hw.build_src_filter} + +<../variants/rak4631> + + + +<../examples/simple_repeater> + [env:RAK_4631_repeater_ethernet] extends = rak4631 build_flags = From d6dd5157e17915c2bd5964f4f9742754ea9a5423 Mon Sep 17 00:00:00 2001 From: Ben Allfree Date: Sun, 19 Jul 2026 16:29:54 -0700 Subject: [PATCH 40/59] feat(nrf52): BLE_DFU_DISABLED opt-out flag Gates the Bluefruit BLE DFU fallback (`start ota`) behind BLE_DFU_DISABLED and sets it on RAK_4631_repeater_slim: the BLE stack drops out entirely (434,036 -> 388,656 B, ~45 KB), raising staging headroom to ~416 KB -- enough to stage a full slim .mota. Co-authored-by: Cursor (cherry picked from commit 31621a3e2f5b13e20067d3f3606a520a6ecf6691) --- src/helpers/NRF52Board.cpp | 13 ++++++++++++- variants/rak4631/platformio.ini | 1 + 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/helpers/NRF52Board.cpp b/src/helpers/NRF52Board.cpp index 17265f0455..d524b2e13a 100644 --- a/src/helpers/NRF52Board.cpp +++ b/src/helpers/NRF52Board.cpp @@ -1,9 +1,13 @@ #if defined(NRF52_PLATFORM) #include "NRF52Board.h" -#include #include +// BLE_DFU_DISABLED drops the Bluefruit BLE DFU fallback (`start ota` CLI) so slim/headless roles +// don't link the BLE stack (~15 KB flash) they never otherwise use. +#ifndef BLE_DFU_DISABLED +#include + static BLEDfu bledfu; static void connect_callback(uint16_t conn_handle) { @@ -17,6 +21,7 @@ static void disconnect_callback(uint16_t conn_handle, uint8_t reason) { MESH_DEBUG_PRINTLN("BLE client disconnected"); } +#endif void NRF52Board::begin() { startup_reason = BD_STARTUP_NORMAL; @@ -316,6 +321,11 @@ bool NRF52Board::getBootloaderVersion(char* out, size_t max_len) { return false; } +#ifdef BLE_DFU_DISABLED +bool NRF52Board::startOTAUpdate(const char *id, char reply[]) { + return false; // BLE DFU compiled out +} +#else bool NRF52Board::startOTAUpdate(const char *id, char reply[]) { // Config the peripheral connection with maximum bandwidth // more SRAM required by SoftDevice @@ -363,4 +373,5 @@ bool NRF52Board::startOTAUpdate(const char *id, char reply[]) { return true; } +#endif // BLE_DFU_DISABLED #endif diff --git a/variants/rak4631/platformio.ini b/variants/rak4631/platformio.ini index 979d1bf645..30b4de4c56 100644 --- a/variants/rak4631/platformio.ini +++ b/variants/rak4631/platformio.ini @@ -85,6 +85,7 @@ build_flags = ${rak4631_hw.build_flags} -D SX126X_CURRENT_LIMIT=140 -D SX126X_RX_BOOSTED_GAIN=1 -D MOTA_STAGE_CEILING=0xED000 + -D BLE_DFU_DISABLED ; no BLE stack: `start ota` (BLE DFU fallback) returns Error -D ADVERT_NAME='"RAK4631 Slim Repeater"' -D ADVERT_LAT=0.0 -D ADVERT_LON=0.0 From 5cfbd31091320ed93ef27b3358512339b1e8a7a9 Mon Sep 17 00:00:00 2001 From: Ben Allfree Date: Mon, 20 Jul 2026 09:33:54 -0700 Subject: [PATCH 41/59] feat(wismesh-tag): repeater buzzer and role leds Co-authored-by: Cursor (cherry picked from commit 705724b27f4261bd89f1a267b7e2ec01b892c897) --- examples/simple_repeater/MyMesh.cpp | 46 ++++++- examples/simple_repeater/MyMesh.h | 5 +- examples/simple_repeater/main.cpp | 1 + src/MeshCore.h | 9 ++ .../rak_wismesh_tag/RAKWismeshTagBoard.cpp | 113 +++++++++++++++++- variants/rak_wismesh_tag/RAKWismeshTagBoard.h | 19 +-- variants/rak_wismesh_tag/platformio.ini | 4 + 7 files changed, 183 insertions(+), 14 deletions(-) diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 97ab468508..7aaa202546 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -590,8 +590,43 @@ uint32_t MyMesh::getDirectRetransmitDelay(const mesh::Packet *packet) { return getRNG()->nextInt(0, 5*t + 1); } -bool MyMesh::filterRecvFloodPacket(mesh::Packet* pkt) { - // just try to determine region for packet (apply later in allowPacketForward()) +#if defined(RAK_WISMESH_TAG) +static bool isDestSelf(const mesh::Packet* pkt, const mesh::LocalIdentity& id) { + switch (pkt->getPayloadType()) { + case PAYLOAD_TYPE_TXT_MSG: + case PAYLOAD_TYPE_REQ: + case PAYLOAD_TYPE_RESPONSE: + case PAYLOAD_TYPE_PATH: + case PAYLOAD_TYPE_ANON_REQ: + return pkt->payload_len >= 1 && id.isHashMatch(&pkt->payload[0], 1); + default: + return false; + } +} + +static bool isOnDirectPath(const mesh::Packet* pkt, const mesh::LocalIdentity& id) { + return pkt->isRouteDirect() && pkt->getPathHashCount() > 0 + && id.isHashMatch(pkt->path, pkt->getPathHashSize()); +} + +void MyMesh::signalWismeshPacketLed(mesh::Packet* pkt, mesh::DispatcherAction action) { + if (action != ACTION_RELEASE) { + board.onPacketLed(mesh::MainBoard::PACKET_LED_RELAY); + return; + } + if (isDestSelf(pkt, self_id)) { + board.onPacketLed(mesh::MainBoard::PACKET_LED_LOCAL); + return; + } + if (isOnDirectPath(pkt, self_id)) { + board.onPacketLed(mesh::MainBoard::PACKET_LED_RELAY); + return; + } + board.onPacketLed(mesh::MainBoard::PACKET_LED_UNRELATED); +} +#endif + +mesh::DispatcherAction MyMesh::onRecvPacket(mesh::Packet* pkt) { if (pkt->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD) { recv_pkt_region = region_map.findMatch(pkt, REGION_DENY_FLOOD); } else if (pkt->getRouteType() == ROUTE_TYPE_FLOOD) { @@ -603,8 +638,11 @@ bool MyMesh::filterRecvFloodPacket(mesh::Packet* pkt) { } else { recv_pkt_region = NULL; } - // do normal processing - return false; + mesh::DispatcherAction action = Mesh::onRecvPacket(pkt); +#if defined(RAK_WISMESH_TAG) + signalWismeshPacketLed(pkt, action); +#endif + return action; } void MyMesh::onAnonDataRecv(mesh::Packet *packet, const uint8_t *secret, const mesh::Identity &sender, diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 20c35529d1..13483a5ae6 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -176,7 +176,10 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { } #endif - bool filterRecvFloodPacket(mesh::Packet* pkt) override; + mesh::DispatcherAction onRecvPacket(mesh::Packet* pkt) override; +#if defined(RAK_WISMESH_TAG) + void signalWismeshPacketLed(mesh::Packet* pkt, mesh::DispatcherAction action); +#endif void onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, const mesh::Identity& sender, uint8_t* data, size_t len) override; int searchPeersByHash(const uint8_t* hash) override; diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index 4526dd4fda..92239af5fa 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -151,6 +151,7 @@ void loop() { #endif the_mesh.loop(); + board.loop(); sensors.loop(); #ifdef DISPLAY_CLASS ui_task.loop(); diff --git a/src/MeshCore.h b/src/MeshCore.h index 6c99b115a9..ebf34dd1ef 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -44,6 +44,12 @@ namespace mesh { class MainBoard { public: + enum PacketLedRole : uint8_t { + PACKET_LED_LOCAL = 0, // TX, flood/direct destined for us + PACKET_LED_RELAY, // routed traffic we forward + PACKET_LED_UNRELATED, // overheard / discarded + }; + virtual uint16_t getBattMilliVolts() = 0; virtual float getMCUTemperature() { return NAN; } virtual bool setAdcMultiplier(float multiplier) { return false; }; @@ -51,12 +57,15 @@ class MainBoard { virtual const char* getManufacturerName() const = 0; virtual void onBeforeTransmit() { } virtual void onAfterTransmit() { } + virtual void onPacketLed(PacketLedRole role) { (void)role; } virtual void reboot() = 0; virtual void powerOff() { /* no op */ } // Called by example setup() functions to signal that boot is complete. // Boards may override to stop a boot-indicator LED sequence or similar. // Default no-op: boards that don't care need not implement anything. virtual void onBootComplete() { /* no op */ } + // Optional periodic hook (buzzer RTTTL, activity LED timeout, etc.) + virtual void loop() { /* no op */ } virtual uint32_t getIRQGpio() { return -1; } // not supported. Returns DIO1 (SX1262) and DIO0 (SX127x) virtual void sleep(uint32_t secs) { /* no op */ } virtual uint32_t getGpio() { return 0; } diff --git a/variants/rak_wismesh_tag/RAKWismeshTagBoard.cpp b/variants/rak_wismesh_tag/RAKWismeshTagBoard.cpp index 7eab4bc9ed..ea518f8598 100644 --- a/variants/rak_wismesh_tag/RAKWismeshTagBoard.cpp +++ b/variants/rak_wismesh_tag/RAKWismeshTagBoard.cpp @@ -3,16 +3,127 @@ #include "RAKWismeshTagBoard.h" +#ifdef PIN_BUZZER +#include +static genericBuzzer buzzer; +#endif + +#ifndef LED_ACTIVE +#ifdef LED_STATE_ON +#define LED_ACTIVE LED_STATE_ON +#else +#define LED_ACTIVE HIGH +#endif +#endif + +#ifndef LED_INACTIVE +#define LED_INACTIVE (!LED_ACTIVE) +#endif + +void RAKWismeshTagBoard::activityLedOff() { + if (_activity_led_pin >= 0) { + digitalWrite(_activity_led_pin, LED_INACTIVE); + _activity_led_pin = -1; + } + _activity_led_until = 0; +} + +void RAKWismeshTagBoard::pulseLed(int pin, unsigned long ms) { + if (pin < 0) return; + if (_activity_led_pin >= 0 && _activity_led_pin != pin) { + digitalWrite(_activity_led_pin, LED_INACTIVE); + } + digitalWrite(pin, LED_ACTIVE); + _activity_led_pin = pin; + _activity_led_until = millis() + ms; +} + void RAKWismeshTagBoard::begin() { NRF52BoardDCDC::begin(); pinMode(PIN_VBAT_READ, INPUT); pinMode(PIN_USER_BTN, INPUT_PULLUP); +#ifdef LED_GREEN + pinMode(LED_GREEN, OUTPUT); + digitalWrite(LED_GREEN, LED_INACTIVE); +#endif +#ifdef LED_BLUE + pinMode(LED_BLUE, OUTPUT); + digitalWrite(LED_BLUE, LED_INACTIVE); +#endif +#if defined(LED_RED) && (LED_RED >= 0) + pinMode(LED_RED, OUTPUT); + digitalWrite(LED_RED, LED_INACTIVE); +#endif + +#ifdef PIN_BUZZER + buzzer.begin(); +#endif + Wire.setPins(PIN_BOARD_SDA, PIN_BOARD_SCL); Wire.begin(); pinMode(SX126X_POWER_EN, OUTPUT); digitalWrite(SX126X_POWER_EN, HIGH); delay(10); // give sx1262 some time to power up -} \ No newline at end of file +} + +void RAKWismeshTagBoard::onBootComplete() { +#ifdef PIN_BUZZER + buzzer.quiet(false); + buzzer.startup(); +#endif +} + +void RAKWismeshTagBoard::loop() { +#ifdef PIN_BUZZER + if (buzzer.isPlaying()) { + buzzer.loop(); + } +#endif + + if (_activity_led_pin >= 0 && _activity_led_until != 0 && millis() >= _activity_led_until) { + activityLedOff(); + } +} + +void RAKWismeshTagBoard::onPacketLed(PacketLedRole role) { + switch (role) { + case PACKET_LED_LOCAL: +#ifdef LED_GREEN + pulseLed(LED_GREEN); +#endif + break; + case PACKET_LED_RELAY: +#ifdef LED_BLUE + pulseLed(LED_BLUE); +#endif + break; + case PACKET_LED_UNRELATED: +#if defined(LED_RED) && (LED_RED >= 0) + pulseLed(LED_RED, 30); +#endif + break; + } +} + +void RAKWismeshTagBoard::onBeforeTransmit() { + _activity_led_until = 0; +#ifdef LED_GREEN + if (_activity_led_pin >= 0 && _activity_led_pin != LED_GREEN) { + digitalWrite(_activity_led_pin, LED_INACTIVE); + } + digitalWrite(LED_GREEN, LED_ACTIVE); + _activity_led_pin = LED_GREEN; +#endif +} + +void RAKWismeshTagBoard::onAfterTransmit() { +#ifdef LED_GREEN + digitalWrite(LED_GREEN, LED_INACTIVE); + if (_activity_led_pin == LED_GREEN) { + _activity_led_pin = -1; + } +#endif +} diff --git a/variants/rak_wismesh_tag/RAKWismeshTagBoard.h b/variants/rak_wismesh_tag/RAKWismeshTagBoard.h index cc5aa06f59..964af08b6b 100644 --- a/variants/rak_wismesh_tag/RAKWismeshTagBoard.h +++ b/variants/rak_wismesh_tag/RAKWismeshTagBoard.h @@ -9,18 +9,21 @@ #define ADC_MULTIPLIER (3 * 1.73 * 1.187 * 1000) class RAKWismeshTagBoard : public NRF52BoardDCDC { + int _activity_led_pin = -1; + unsigned long _activity_led_until = 0; + + void pulseLed(int pin, unsigned long ms = 50); + void activityLedOff(); + public: RAKWismeshTagBoard() : NRF52Board("WISMESHTAG_OTA") {} void begin(); + void loop() override; + void onBootComplete() override; + void onPacketLed(PacketLedRole role) override; -#if defined(P_LORA_TX_LED) && defined(LED_STATE_ON) - void onBeforeTransmit() override { - digitalWrite(P_LORA_TX_LED, LED_STATE_ON); // turn TX LED on - } - void onAfterTransmit() override { - digitalWrite(P_LORA_TX_LED, !LED_STATE_ON); // turn TX LED off - } -#endif + void onBeforeTransmit() override; + void onAfterTransmit() override; #define BATTERY_SAMPLES 8 diff --git a/variants/rak_wismesh_tag/platformio.ini b/variants/rak_wismesh_tag/platformio.ini index 7e04ed93ba..181802cab7 100644 --- a/variants/rak_wismesh_tag/platformio.ini +++ b/variants/rak_wismesh_tag/platformio.ini @@ -47,7 +47,11 @@ build_flags = ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 build_src_filter = ${rak_wismesh_tag.build_src_filter} + + +<../examples/simple_repeater> +lib_deps = + ${rak_wismesh_tag.lib_deps} + end2endzone/NonBlockingRTTTL@^1.3.0 [env:RAK_WisMesh_Tag_room_server] extends = rak_wismesh_tag From 4e06ac45ebacfe0551ce87ccd17adc0b825981a0 Mon Sep 17 00:00:00 2001 From: Ben Allfree Date: Mon, 20 Jul 2026 20:04:33 -0700 Subject: [PATCH 42/59] fix(companion): recover from corrupt littlefs at boot Co-authored-by: Cursor (cherry picked from commit 376a9d3f9b68aa239769093890bac902749350b5) --- examples/companion_radio/DataStore.cpp | 48 ++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index bf2f36c3d9..a52b78ef0d 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -44,6 +44,48 @@ static File openWrite(FILESYSTEM* fs, const char* filename) { #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) static uint32_t _ContactsChannelsTotalBlocks = 0; + +/* Boot-time filesystem integrity check. + * + * A littlefs whose superblock is intact but whose interior metadata is damaged (e.g. flash + * region partially overwritten, or interrupted writes) mounts successfully, then wedges the + * device on the FIRST write after boot: lfs_deorphan() -- run lazily by lfs_remove()/ + * lfs_file_open() once per power cycle -- walks every metadata block, and littlefs v1 + * follows corrupt tail pointers into an unbounded loop, or (with LFS_NO_ASSERT) reads/writes + * a wild out-of-range block address, hard-faulting the MCU. Either way the node freezes + * until manually reset, and every reboot repeats the cycle. + * + * Walking the whole tree up-front detects this state safely: lfs_traverse() invokes the + * callback on each block number BEFORE reading it, so out-of-range pointers are caught + * without touching bad addresses, and a visited-block count exceeding block_count can only + * mean a metadata cycle. On failure the filesystem is reformatted -- losing its contents, + * but recovering the node without a chip erase. + */ +struct FsckState { + lfs_size_t visited; + lfs_size_t block_count; +}; + +static int _fsckBlock(void *p, lfs_block_t block) { + FsckState *s = (FsckState *) p; + if (block >= s->block_count) return LFS_ERR_CORRUPT; // pointer outside the filesystem + if (++s->visited > s->block_count) return LFS_ERR_CORRUPT; // more blocks than exist -> cycle + return 0; +} + +static bool _isFilesystemIntact(FILESYSTEM* fs) { + FsckState s; + s.visited = 0; + s.block_count = fs->_getFS()->cfg->block_count; + return lfs_traverse(fs->_getFS(), _fsckBlock, &s) == 0; +} + +static void _fsckAndRecover(FILESYSTEM* fs, const char* name) { + if (!_isFilesystemIntact(fs)) { + MESH_DEBUG_PRINTLN("ERROR: %s filesystem is corrupted - reformatting!", name); + fs->format(); + } +} #endif void DataStore::begin() { @@ -52,6 +94,12 @@ void DataStore::begin() { #endif #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + // must run before the first filesystem write (checkAdvBlobFile/migrateToSecondaryFS below) + _fsckAndRecover(_fs, "primary"); + if (_fsExtra != nullptr) { + _fsckAndRecover(_fsExtra, "secondary"); + } + _ContactsChannelsTotalBlocks = _getContactsChannelsFS()->_getFS()->cfg->block_count; checkAdvBlobFile(); #if defined(EXTRAFS) || defined(QSPIFLASH) From e396961b7ddbdc8981480259bb8044f61f56a589 Mon Sep 17 00:00:00 2001 From: Ben Allfree Date: Mon, 20 Jul 2026 10:37:35 -0700 Subject: [PATCH 43/59] feat(log): name payload types in packet log Co-authored-by: Cursor (cherry picked from commit 2e7d5a618d472c24c9250ecf24375f720df85839) --- examples/simple_repeater/MyMesh.cpp | 24 ++++++++++------ examples/simple_room_server/MyMesh.cpp | 24 ++++++++++------ src/Packet.cpp | 40 ++++++++++++++++++++++++++ src/Packet.h | 3 ++ 4 files changed, 75 insertions(+), 16 deletions(-) diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 7aaa202546..e3dea818ff 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -463,6 +463,14 @@ const char *MyMesh::getLogDateTime() { return tmp; } +namespace { +const char* packetLogTypeLabel(const mesh::Packet* pkt) { + static char label[40]; + mesh::formatPayloadType(pkt->getPayloadType(), label, sizeof label); + return label; +} +} + void MyMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) { #if MESH_PACKET_LOGGING Serial.print(getLogDateTime()); @@ -474,8 +482,8 @@ void MyMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) { void MyMesh::mirrorPacketLogRxToSerial(mesh::Packet *pkt, int len, float score) { Serial.print(getLogDateTime()); - Serial.printf(": RX, len=%d (type=%d, route=%s, payload_len=%d) SNR=%d RSSI=%d score=%d", len, - pkt->getPayloadType(), pkt->isRouteDirect() ? "D" : "F", pkt->payload_len, + Serial.printf(": RX, len=%d (type=%s, route=%s, payload_len=%d) SNR=%d RSSI=%d score=%d", len, + packetLogTypeLabel(pkt), pkt->isRouteDirect() ? "D" : "F", pkt->payload_len, (int)_radio->getLastSNR(), (int)_radio->getLastRSSI(), (int)(score * 1000)); if (pkt->getPayloadType() == PAYLOAD_TYPE_PATH || pkt->getPayloadType() == PAYLOAD_TYPE_REQ || @@ -487,7 +495,7 @@ void MyMesh::mirrorPacketLogRxToSerial(mesh::Packet *pkt, int len, float score) void MyMesh::mirrorPacketLogTxToSerial(mesh::Packet *pkt, int len) { Serial.print(getLogDateTime()); - Serial.printf(": TX, len=%d (type=%d, route=%s, payload_len=%d)", len, pkt->getPayloadType(), + Serial.printf(": TX, len=%d (type=%s, route=%s, payload_len=%d)", len, packetLogTypeLabel(pkt), pkt->isRouteDirect() ? "D" : "F", pkt->payload_len); if (pkt->getPayloadType() == PAYLOAD_TYPE_PATH || pkt->getPayloadType() == PAYLOAD_TYPE_REQ || @@ -499,7 +507,7 @@ void MyMesh::mirrorPacketLogTxToSerial(mesh::Packet *pkt, int len) { void MyMesh::mirrorPacketLogTxFailToSerial(mesh::Packet *pkt, int len) { Serial.print(getLogDateTime()); - Serial.printf(": TX FAIL!, len=%d (type=%d, route=%s, payload_len=%d)", len, pkt->getPayloadType(), + Serial.printf(": TX FAIL!, len=%d (type=%s, route=%s, payload_len=%d)", len, packetLogTypeLabel(pkt), pkt->isRouteDirect() ? "D" : "F", pkt->payload_len); Serial.print("\r\n"); } @@ -515,8 +523,8 @@ void MyMesh::logRx(mesh::Packet *pkt, int len, float score) { File f = openAppend(PACKET_LOG_FILE); if (f) { f.print(getLogDateTime()); - f.printf(": RX, len=%d (type=%d, route=%s, payload_len=%d) SNR=%d RSSI=%d score=%d", len, - pkt->getPayloadType(), pkt->isRouteDirect() ? "D" : "F", pkt->payload_len, + f.printf(": RX, len=%d (type=%s, route=%s, payload_len=%d) SNR=%d RSSI=%d score=%d", len, + packetLogTypeLabel(pkt), pkt->isRouteDirect() ? "D" : "F", pkt->payload_len, (int)_radio->getLastSNR(), (int)_radio->getLastRSSI(), (int)(score * 1000)); if (pkt->getPayloadType() == PAYLOAD_TYPE_PATH || pkt->getPayloadType() == PAYLOAD_TYPE_REQ || @@ -544,7 +552,7 @@ void MyMesh::logTx(mesh::Packet *pkt, int len) { File f = openAppend(PACKET_LOG_FILE); if (f) { f.print(getLogDateTime()); - f.printf(": TX, len=%d (type=%d, route=%s, payload_len=%d)", len, pkt->getPayloadType(), + f.printf(": TX, len=%d (type=%s, route=%s, payload_len=%d)", len, packetLogTypeLabel(pkt), pkt->isRouteDirect() ? "D" : "F", pkt->payload_len); if (pkt->getPayloadType() == PAYLOAD_TYPE_PATH || pkt->getPayloadType() == PAYLOAD_TYPE_REQ || @@ -566,7 +574,7 @@ void MyMesh::logTxFail(mesh::Packet *pkt, int len) { File f = openAppend(PACKET_LOG_FILE); if (f) { f.print(getLogDateTime()); - f.printf(": TX FAIL!, len=%d (type=%d, route=%s, payload_len=%d)\n", len, pkt->getPayloadType(), + f.printf(": TX FAIL!, len=%d (type=%s, route=%s, payload_len=%d)\n", len, packetLogTypeLabel(pkt), pkt->isRouteDirect() ? "D" : "F", pkt->payload_len); f.close(); } diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 5d9167a5f3..deb15876df 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -199,6 +199,14 @@ int MyMesh::handleRequest(ClientInfo *sender, uint32_t sender_timestamp, uint8_t return 0; // unknown command } +namespace { +const char* packetLogTypeLabel(const mesh::Packet* pkt) { + static char label[40]; + mesh::formatPayloadType(pkt->getPayloadType(), label, sizeof label); + return label; +} +} + void MyMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) { #if MESH_PACKET_LOGGING Serial.print(getLogDateTime()); @@ -210,8 +218,8 @@ void MyMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) { void MyMesh::mirrorPacketLogRxToSerial(mesh::Packet *pkt, int len, float score) { Serial.print(getLogDateTime()); - Serial.printf(": RX, len=%d (type=%d, route=%s, payload_len=%d) SNR=%d RSSI=%d score=%d", len, - pkt->getPayloadType(), pkt->isRouteDirect() ? "D" : "F", pkt->payload_len, + Serial.printf(": RX, len=%d (type=%s, route=%s, payload_len=%d) SNR=%d RSSI=%d score=%d", len, + packetLogTypeLabel(pkt), pkt->isRouteDirect() ? "D" : "F", pkt->payload_len, (int)_radio->getLastSNR(), (int)_radio->getLastRSSI(), (int)(score * 1000)); if (pkt->getPayloadType() == PAYLOAD_TYPE_PATH || pkt->getPayloadType() == PAYLOAD_TYPE_REQ || @@ -223,7 +231,7 @@ void MyMesh::mirrorPacketLogRxToSerial(mesh::Packet *pkt, int len, float score) void MyMesh::mirrorPacketLogTxToSerial(mesh::Packet *pkt, int len) { Serial.print(getLogDateTime()); - Serial.printf(": TX, len=%d (type=%d, route=%s, payload_len=%d)", len, pkt->getPayloadType(), + Serial.printf(": TX, len=%d (type=%s, route=%s, payload_len=%d)", len, packetLogTypeLabel(pkt), pkt->isRouteDirect() ? "D" : "F", pkt->payload_len); if (pkt->getPayloadType() == PAYLOAD_TYPE_PATH || pkt->getPayloadType() == PAYLOAD_TYPE_REQ || @@ -235,7 +243,7 @@ void MyMesh::mirrorPacketLogTxToSerial(mesh::Packet *pkt, int len) { void MyMesh::mirrorPacketLogTxFailToSerial(mesh::Packet *pkt, int len) { Serial.print(getLogDateTime()); - Serial.printf(": TX FAIL!, len=%d (type=%d, route=%s, payload_len=%d)", len, pkt->getPayloadType(), + Serial.printf(": TX FAIL!, len=%d (type=%s, route=%s, payload_len=%d)", len, packetLogTypeLabel(pkt), pkt->isRouteDirect() ? "D" : "F", pkt->payload_len); Serial.print("\r\n"); } @@ -245,8 +253,8 @@ void MyMesh::logRx(mesh::Packet *pkt, int len, float score) { File f = openAppend(PACKET_LOG_FILE); if (f) { f.print(getLogDateTime()); - f.printf(": RX, len=%d (type=%d, route=%s, payload_len=%d) SNR=%d RSSI=%d score=%d", len, - pkt->getPayloadType(), pkt->isRouteDirect() ? "D" : "F", pkt->payload_len, + f.printf(": RX, len=%d (type=%s, route=%s, payload_len=%d) SNR=%d RSSI=%d score=%d", len, + packetLogTypeLabel(pkt), pkt->isRouteDirect() ? "D" : "F", pkt->payload_len, (int)_radio->getLastSNR(), (int)_radio->getLastRSSI(), (int)(score * 1000)); if (pkt->getPayloadType() == PAYLOAD_TYPE_PATH || pkt->getPayloadType() == PAYLOAD_TYPE_REQ || @@ -267,7 +275,7 @@ void MyMesh::logTx(mesh::Packet *pkt, int len) { File f = openAppend(PACKET_LOG_FILE); if (f) { f.print(getLogDateTime()); - f.printf(": TX, len=%d (type=%d, route=%s, payload_len=%d)", len, pkt->getPayloadType(), + f.printf(": TX, len=%d (type=%s, route=%s, payload_len=%d)", len, packetLogTypeLabel(pkt), pkt->isRouteDirect() ? "D" : "F", pkt->payload_len); if (pkt->getPayloadType() == PAYLOAD_TYPE_PATH || pkt->getPayloadType() == PAYLOAD_TYPE_REQ || @@ -288,7 +296,7 @@ void MyMesh::logTxFail(mesh::Packet *pkt, int len) { File f = openAppend(PACKET_LOG_FILE); if (f) { f.print(getLogDateTime()); - f.printf(": TX FAIL!, len=%d (type=%d, route=%s, payload_len=%d)\n", len, pkt->getPayloadType(), + f.printf(": TX FAIL!, len=%d (type=%s, route=%s, payload_len=%d)\n", len, packetLogTypeLabel(pkt), pkt->isRouteDirect() ? "D" : "F", pkt->payload_len); f.close(); } diff --git a/src/Packet.cpp b/src/Packet.cpp index aad3e2f48e..f55af06f7b 100644 --- a/src/Packet.cpp +++ b/src/Packet.cpp @@ -1,9 +1,49 @@ #include "Packet.h" +#include #include #include namespace mesh { +namespace { + +struct PayloadTypeName { uint8_t type; const char* name; }; + +static const PayloadTypeName PAYLOAD_TYPE_NAMES[] = { + { PAYLOAD_TYPE_REQ, "PAYLOAD_TYPE_REQ" }, + { PAYLOAD_TYPE_RESPONSE, "PAYLOAD_TYPE_RESPONSE" }, + { PAYLOAD_TYPE_TXT_MSG, "PAYLOAD_TYPE_TXT_MSG" }, + { PAYLOAD_TYPE_ACK, "PAYLOAD_TYPE_ACK" }, + { PAYLOAD_TYPE_ADVERT, "PAYLOAD_TYPE_ADVERT" }, + { PAYLOAD_TYPE_GRP_TXT, "PAYLOAD_TYPE_GRP_TXT" }, + { PAYLOAD_TYPE_GRP_DATA, "PAYLOAD_TYPE_GRP_DATA" }, + { PAYLOAD_TYPE_ANON_REQ, "PAYLOAD_TYPE_ANON_REQ" }, + { PAYLOAD_TYPE_PATH, "PAYLOAD_TYPE_PATH" }, + { PAYLOAD_TYPE_TRACE, "PAYLOAD_TYPE_TRACE" }, + { PAYLOAD_TYPE_MULTIPART, "PAYLOAD_TYPE_MULTIPART" }, + { PAYLOAD_TYPE_CONTROL, "PAYLOAD_TYPE_CONTROL" }, + { PAYLOAD_TYPE_OTA, "PAYLOAD_TYPE_OTA" }, + { PAYLOAD_TYPE_RAW_CUSTOM, "PAYLOAD_TYPE_RAW_CUSTOM" }, +}; + +} // namespace + +const char* payloadTypeName(uint8_t type) { + for (unsigned i = 0; i < sizeof(PAYLOAD_TYPE_NAMES) / sizeof(PAYLOAD_TYPE_NAMES[0]); i++) { + if (PAYLOAD_TYPE_NAMES[i].type == type) return PAYLOAD_TYPE_NAMES[i].name; + } + return nullptr; +} + +void formatPayloadType(uint8_t type, char* buf, size_t cap) { + const char* name = payloadTypeName(type); + if (name) { + snprintf(buf, cap, "%s (0x%02X)", name, type); + } else { + snprintf(buf, cap, "UNKNOWN (0x%02X)", type); + } +} + Packet::Packet() { header = 0; path_len = 0; diff --git a/src/Packet.h b/src/Packet.h index 3491ae3d53..44bddbc647 100644 --- a/src/Packet.h +++ b/src/Packet.h @@ -112,4 +112,7 @@ class Packet { bool readFrom(const uint8_t src[], uint8_t len); }; +const char* payloadTypeName(uint8_t type); +void formatPayloadType(uint8_t type, char* buf, size_t cap); + } From e19dbdcf43c1dd6d9d55cb298ae5ed058d06dc3c Mon Sep 17 00:00:00 2001 From: Ben Allfree Date: Tue, 21 Jul 2026 09:06:13 -0700 Subject: [PATCH 44/59] feat(ota): add SD superseeder role Co-authored-by: Cursor (cherry picked from commit 899ba168ababf20921752521129cd6affde49c98) --- src/Mesh.cpp | 9 +++ src/helpers/ota/MotaSourceSd.cpp | 89 ++++++++++++++++++++++++++ src/helpers/ota/MotaSourceSd.h | 36 +++++++++++ src/helpers/ota/OtaCli.cpp | 27 ++++++++ src/helpers/ota/OtaContext.h | 22 +++++++ src/helpers/ota/OtaManager.cpp | 8 +-- src/helpers/ota/OtaManager.h | 8 +++ src/helpers/ota/SdMotaFs.cpp | 56 ++++++++++++++++ src/helpers/ota/SdMotaFs.h | 42 ++++++++++++ src/helpers/ota/SdMotaStore.cpp | 106 +++++++++++++++++++++++++++++++ src/helpers/ota/SdMotaStore.h | 41 ++++++++++++ src/helpers/ota/SuperSeeder.cpp | 101 +++++++++++++++++++++++++++++ src/helpers/ota/SuperSeeder.h | 49 ++++++++++++++ variants/rak4631/platformio.ini | 13 ++++ 14 files changed, 603 insertions(+), 4 deletions(-) create mode 100644 src/helpers/ota/MotaSourceSd.cpp create mode 100644 src/helpers/ota/MotaSourceSd.h create mode 100644 src/helpers/ota/SdMotaFs.cpp create mode 100644 src/helpers/ota/SdMotaFs.h create mode 100644 src/helpers/ota/SdMotaStore.cpp create mode 100644 src/helpers/ota/SdMotaStore.h create mode 100644 src/helpers/ota/SuperSeeder.cpp create mode 100644 src/helpers/ota/SuperSeeder.h diff --git a/src/Mesh.cpp b/src/Mesh.cpp index b6ba4bb3e9..c11b6a16ec 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -50,6 +50,12 @@ void Mesh::begin() { #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 +#if defined(OTA_SD_SEEDER) + { + char sdmsg[80]; + ota::ota_ctx().attach_sd(sdmsg, sizeof sdmsg); + } +#endif _next_ota_announce = futureMillis(OTA_ANNOUNCE_BOOT_MS); // advertise our own fw shortly after boot #endif } @@ -89,6 +95,9 @@ void Mesh::loop() { } 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 +#if defined(OTA_SD_SEEDER) + ota::ota_ctx().superseeder_loop(); +#endif _next_ota_tick = futureMillis(3000); } if (millisHasNowPassed(_next_ota_announce)) { // auto-advertise so peers discover us (tiny beacon) diff --git a/src/helpers/ota/MotaSourceSd.cpp b/src/helpers/ota/MotaSourceSd.cpp new file mode 100644 index 0000000000..54aae3aa98 --- /dev/null +++ b/src/helpers/ota/MotaSourceSd.cpp @@ -0,0 +1,89 @@ +#include "MotaSourceSd.h" + +#if defined(OTA_SD_SEEDER) + +#include "SdMotaFs.h" +#include "MotaContainer.h" +#include "OtaByteIO.h" +#include "OtaFormat.h" +#include "Utils.h" +#include +#include + +namespace mesh { +namespace ota { + +void MotaSourceSd::refresh() { + _count = 0; + _total_bytes = 0; + if (!SdMotaFs::instance().mounted()) return; + SdFat& sd = *(SdFat*)SdMotaFs::instance().sdFat(); + File32 dir = sd.open(OTA_SD_DIR); + if (!dir || !dir.isDirectory()) return; + File32 ent; + while (ent.openNext(&dir, O_RDONLY) && _count < MAX_FILES) { + char name[32]; + ent.getName(name, sizeof name); + ent.close(); + size_t nlen = strlen(name); + if (nlen < 6 || strcmp(name + nlen - 5, ".mota") != 0) continue; + if (strstr(name, ".part")) continue; + snprintf(_paths[_count], sizeof _paths[_count], "%s/%s", OTA_SD_DIR, name); + File32 f = sd.open(_paths[_count]); + if (f) { _total_bytes += (uint32_t)f.size(); f.close(); } + _count++; + } + dir.close(); +} + +bool MotaSourceSd::hasMid(const uint8_t mid[4]) const { + return SdMotaFs::instance().hasMota(mid); +} + +bool MotaSourceSd::describePath(const char* path, MotaDesc& out) const { + SdFat& sd = *(SdFat*)SdMotaFs::instance().sdFat(); + File32 f = sd.open(path, O_RDONLY); + if (!f) return false; + uint32_t total = (uint32_t)f.size(); + if (total < 13) { f.close(); return false; } + uint8_t hdr[8]; + if (f.read(hdr, 8) != 8 || memcmp(hdr, MOTA_MAGIC, 4) != 0) { f.close(); return false; } + if (rd_u32le(hdr + 4) != total) { f.close(); return false; } + uint8_t mf[MOTA_MFL]; + if (f.read(mf, MOTA_MFL) != MOTA_MFL) { f.close(); return false; } + f.close(); + MotaManifest m; + if (!mota_parse_manifest(mf, MOTA_MFL, m)) return false; + memcpy(out.mid, m.merkle_root, 4); + out.target_id = m.target_id; + out.fw_version = m.fw_version; + out.codec_id = m.codec_id; + out.flags = m.flags; + out.total_size = total; + out.leaves_off = 8 + MOTA_MFL; + out.block_count = m.block_count; + out.payload_off = out.leaves_off + m.block_count * 4; + out.payload_size = m.payload_size; + return true; +} + +bool MotaSourceSd::describe(uint8_t idx, MotaDesc& out) { + if (idx >= _count) return false; + return describePath(_paths[idx], out); +} + +bool MotaSourceSd::read(uint8_t idx, uint32_t off, uint8_t* buf, uint32_t len) { + if (idx >= _count || !buf || len == 0) return false; + SdFat& sd = *(SdFat*)SdMotaFs::instance().sdFat(); + File32 f = sd.open(_paths[idx], O_RDONLY); + if (!f) return false; + if (!f.seek(off)) { f.close(); return false; } + int n = f.read(buf, len); + f.close(); + return n == (int)len; +} + +} // namespace ota +} // namespace mesh + +#endif diff --git a/src/helpers/ota/MotaSourceSd.h b/src/helpers/ota/MotaSourceSd.h new file mode 100644 index 0000000000..b0bd226c65 --- /dev/null +++ b/src/helpers/ota/MotaSourceSd.h @@ -0,0 +1,36 @@ +#pragma once + +#if defined(OTA_SD_SEEDER) + +#include "OtaSource.h" + +namespace mesh { +namespace ota { + +// MotaSource over a folder of complete `.mota` files on SD (OTA_SD_DIR). +class MotaSourceSd : public MotaSource { +public: + static const uint8_t MAX_FILES = 64; + + void refresh(); + + uint8_t count() override { return _count; } + uint8_t cachedCount() const { return _count; } + uint32_t cachedTotalBytes() const { return _total_bytes; } + bool describe(uint8_t idx, MotaDesc& out) override; + bool read(uint8_t idx, uint32_t off, uint8_t* buf, uint32_t len) override; + + bool hasMid(const uint8_t mid[4]) const; + +private: + char _paths[MAX_FILES][40] = {}; + uint8_t _count = 0; + uint32_t _total_bytes = 0; + + bool describePath(const char* path, MotaDesc& out) const; +}; + +} // namespace ota +} // namespace mesh + +#endif diff --git a/src/helpers/ota/OtaCli.cpp b/src/helpers/ota/OtaCli.cpp index 94f8c50fa1..4bcf5feb12 100644 --- a/src/helpers/ota/OtaCli.cpp +++ b/src/helpers/ota/OtaCli.cpp @@ -140,6 +140,9 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board if (n < 146) n += snprintf(reply + n, 160 - n, " | bl:%s blrc:%02X", c.bootloaderCaps().present ? "apply" : "NONE", ota_bootloader_last_rc()); #endif +#if defined(OTA_SD_SEEDER) + if (n < 152) n += snprintf(reply + n, 160 - n, " | sd:%s", c.sd_active ? "on" : "off"); +#endif // ---- admin OTA stats: crypto identities (our fw's content-id + body_hash), serving set, live fetch, // policy — one dense line. The remote-admin CLI path is admin-gated, so this is admin-only over the @@ -333,6 +336,30 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board // ---- 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 (is_cmd(a, "sd", &rest)) { +#if defined(OTA_SD_SEEDER) + if (!c.sd_active) { + strcpy(reply, "ERR SD superseeder not active (mount failed at boot?)"); + return true; + } + OtaManager::FetchState fs = c.manager.fetchState(); + if (c.superseeder.capturing() && fs != OtaManager::IDLE) { + char midhx[9]; mesh::Utils::toHex(midhx, c.manager.fetchManifestId(), 4); + unsigned have = (unsigned)c.manager.blocksHave(), tot = (unsigned)c.manager.blocksTotal(); + unsigned pct = tot ? (unsigned)((uint64_t)have * 100 / tot) : 0; + snprintf(reply, 160, "SD superseeder | files=%u %uK | capture %s %u/%u (%u%%) id=%s", + (unsigned)c.superseeder.fileCount(), + (unsigned)(c.superseeder.totalBytes() / 1024), + state_short(fs), have, tot, pct, midhx); + } else { + snprintf(reply, 160, "SD superseeder | mounted | files=%u %uK | capture idle", + (unsigned)c.superseeder.fileCount(), + (unsigned)(c.superseeder.totalBytes() / 1024)); + } +#else + strcpy(reply, "ERR not built with OTA_SD_SEEDER"); +#endif + } else if (is_cmd(a, "folder|fold", &rest)) { const char* p = rest; if (strncmp(p, "on", 2) == 0) { diff --git a/src/helpers/ota/OtaContext.h b/src/helpers/ota/OtaContext.h index e598e546dd..9c9f4fc436 100644 --- a/src/helpers/ota/OtaContext.h +++ b/src/helpers/ota/OtaContext.h @@ -25,6 +25,9 @@ // 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 +#if defined(OTA_SD_SEEDER) + #include "SuperSeeder.h" +#endif // Per-device OTA singleton shared by the CLI (OtaCli) and the mesh adapter (the example's MyMesh). // Holds the session engine, a staging store (fetch), a RAM serve buffer, and the signer allowlist. @@ -152,6 +155,10 @@ struct OtaContext { 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 +#if defined(OTA_SD_SEEDER) + SuperSeeder superseeder; + bool sd_active = false; // SD superseeder mounted + serving +#endif // 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. @@ -171,6 +178,21 @@ struct OtaContext { #endif void detach_folder() { manager.clear_sources(); folder_active = false; } +#if defined(OTA_SD_SEEDER) + bool attach_sd(char* msg, size_t cap) { + superseeder.begin(*this); + if (!superseeder.mounted()) { strncpy(msg, "ERR SD mount failed", cap); return false; } + sd_active = superseeder.active(); + if (!sd_active) { strncpy(msg, "ERR SD source slot full", cap); return false; } + snprintf(msg, cap, "OK SD superseeder — serving %u mOTA total (own fw + SD)", + (unsigned)manager.servedCount()); + return true; + } + void superseeder_loop() { + if (sd_active) superseeder.loop(); + } +#endif + 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; diff --git a/src/helpers/ota/OtaManager.cpp b/src/helpers/ota/OtaManager.cpp index eea8a0b7c9..76fa5c8129 100644 --- a/src/helpers/ota/OtaManager.cpp +++ b/src/helpers/ota/OtaManager.cpp @@ -405,8 +405,8 @@ void OtaManager::handleAdv(const uint8_t* m, uint16_t n) { 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; + // interested = auto-fetch enabled, manual pull/want pending, or promiscuous catalog discovery. + bool interested = _promiscuous || (_autofetch != AUTOFETCH_OFF) || _have_desired_mid || _desired_target; if (interested && !s.have_catalog) scheduleQuery(a.seeder_id, a.set_digest); // jittered + suppressible } @@ -537,7 +537,7 @@ void OtaManager::handleManifest(const uint8_t* m, uint16_t n) { const uint8_t* mf = _mf_buf; // fully reassembled manifest-minus-leaves uint32_t mfl = _mf_len; if (mfl != MOTA_MFL) { _fstate = FAILED; return; } // manifest-minus-leaves is a fixed 197 bytes - if (!codecOk(mf[56])) { _fstate = IDLE; return; } // codec we can't apply (lying/stale ADV) — abort + if (!_capture_mode && !codecOk(mf[56])) { _fstate = IDLE; return; } // codec we can't apply — abort uint32_t payload_size = rd_u32le(mf + 15); uint8_t bsl = mf[19]; uint32_t bs = 1u << bsl; @@ -670,7 +670,7 @@ bool OtaManager::resumeStaged(const uint8_t* want_mid) { 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; + if (!_capture_mode && !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; diff --git a/src/helpers/ota/OtaManager.h b/src/helpers/ota/OtaManager.h index e3cab71b6e..8e50d5afa5 100644 --- a/src/helpers/ota/OtaManager.h +++ b/src/helpers/ota/OtaManager.h @@ -250,6 +250,12 @@ class OtaManager { // when crossing repeaters. Default OTA_HOP_LIMIT_DEFAULT. Set via `ota config hops`. void set_max_hops(uint8_t h) { _max_hops = h; } uint8_t max_hops() const { return _max_hops; } + // Promiscuous catalog discovery: query every heard beacon (superseeder SD cache). + void set_promiscuous(bool p) { _promiscuous = p; } + bool promiscuous() const { return _promiscuous; } + // Capture-to-serve: accept any codec when staging for re-seed (not self-apply). + void set_capture_mode(bool c) { _capture_mode = c; } + bool capture_mode() const { return _capture_mode; } 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. @@ -380,6 +386,8 @@ class OtaManager { uint16_t _checkpoint_blocks = OTA_CHECKPOINT_BLOCKS; // resume checkpoint cadence (persisted) uint16_t _advert_mins = OTA_ADVERT_INTERVAL_MINS; // beacon re-advertise cadence, minutes; 0=off (persisted) uint8_t _max_hops = OTA_HOP_LIMIT_DEFAULT; // OTA flood reach in hops; 0=direct only (persisted) + bool _promiscuous = false; // query every heard beacon's catalog + bool _capture_mode = false; // accept any codec (SD capture, not self-apply) 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 = NO_BLOCK; // block being reassembled / awaiting proof (none) diff --git a/src/helpers/ota/SdMotaFs.cpp b/src/helpers/ota/SdMotaFs.cpp new file mode 100644 index 0000000000..8850f16e91 --- /dev/null +++ b/src/helpers/ota/SdMotaFs.cpp @@ -0,0 +1,56 @@ +#include "SdMotaFs.h" + +#if defined(OTA_SD_SEEDER) + +#include "Utils.h" +#include +#include +#include +#include + +namespace mesh { +namespace ota { + +SdMotaFs& SdMotaFs::instance() { + static SdMotaFs fs; + return fs; +} + +void* SdMotaFs::sdFat() const { + static SdFat sd; + return &sd; +} + +bool SdMotaFs::mount() { + if (_mounted) return true; + SdFat& sd = *(SdFat*)sdFat(); + if (!sd.begin(OTA_SD_CS, SD_SCK_MHZ(4))) return false; + if (!sd.exists(OTA_SD_DIR)) sd.mkdir(OTA_SD_DIR); + _mounted = true; + return true; +} + +void SdMotaFs::midPath(const uint8_t mid[4], char* out, size_t cap, const char* suffix) const { + char hx[9]; + mesh::Utils::toHex(hx, mid, 4); + snprintf(out, cap, "%s/%s%s", OTA_SD_DIR, hx, suffix ? suffix : ""); +} + +bool SdMotaFs::hasMota(const uint8_t mid[4]) const { + if (!_mounted) return false; + char path[48]; + midPath(mid, path, sizeof path, ".mota"); + return ((SdFat*)sdFat())->exists(path); +} + +bool SdMotaFs::removeFile(const char* path) { + if (!_mounted || !path || !path[0]) return false; + SdFat& sd = *(SdFat*)sdFat(); + if (!sd.exists(path)) return true; + return sd.remove(path); +} + +} // namespace ota +} // namespace mesh + +#endif diff --git a/src/helpers/ota/SdMotaFs.h b/src/helpers/ota/SdMotaFs.h new file mode 100644 index 0000000000..e80d0a314a --- /dev/null +++ b/src/helpers/ota/SdMotaFs.h @@ -0,0 +1,42 @@ +#pragma once + +#if defined(OTA_SD_SEEDER) + +#include +#include + +#ifndef OTA_SD_CS +#define OTA_SD_CS SS +#endif +#ifndef OTA_SD_DIR +#define OTA_SD_DIR "/motas" +#endif + +namespace mesh { +namespace ota { + +// Shared SD mount + path helpers for the superseeder (MotaSourceSd + SdMotaStore). +class SdMotaFs { +public: + static SdMotaFs& instance(); + + bool mount(); + bool mounted() const { return _mounted; } + + // Build OTA_SD_DIR/ (e.g. ".mota" or ".mota.part"). + void midPath(const uint8_t mid[4], char* out, size_t cap, const char* suffix) const; + + bool hasMota(const uint8_t mid[4]) const; + bool removeFile(const char* path); + + void* sdFat() const; // opaque SdFat* for .cpp (keeps SdFat out of other headers) + +private: + SdMotaFs() = default; + bool _mounted = false; +}; + +} // namespace ota +} // namespace mesh + +#endif diff --git a/src/helpers/ota/SdMotaStore.cpp b/src/helpers/ota/SdMotaStore.cpp new file mode 100644 index 0000000000..232bad7f36 --- /dev/null +++ b/src/helpers/ota/SdMotaStore.cpp @@ -0,0 +1,106 @@ +#include "SdMotaStore.h" + +#if defined(OTA_SD_SEEDER) + +#include "SdMotaFs.h" +#include "OtaByteIO.h" +#include "OtaFormat.h" +#include +#include + +namespace mesh { +namespace ota { + +void SdMotaStore::partPath(char* out, size_t cap) const { + SdMotaFs::instance().midPath(_mid, out, cap, ".mota.part"); +} + +void SdMotaStore::clear() { + if (_file) { ((File32*)_file)->close(); delete (File32*)_file; _file = nullptr; } + _total = 0; +} + +bool SdMotaStore::openPart(bool create, uint32_t fill_size) { + if (!SdMotaFs::instance().mounted()) return false; + if (_file) { ((File32*)_file)->close(); delete (File32*)_file; _file = nullptr; } + char path[48]; + partPath(path, sizeof path); + SdFat& sd = *(SdFat*)SdMotaFs::instance().sdFat(); + File32* f = new File32(); + if (create) { + sd.remove(path); + if (!f->open(path, O_RDWR | O_CREAT | O_TRUNC)) { delete f; return false; } + static uint8_t ff[512]; + static bool ff_init = false; + if (!ff_init) { memset(ff, 0xFF, sizeof ff); ff_init = true; } + uint32_t done = 0; + while (done < fill_size) { + uint32_t chunk = fill_size - done; + if (chunk > sizeof ff) chunk = sizeof ff; + if (f->write(ff, chunk) != (int)chunk) { f->close(); delete f; sd.remove(path); return false; } + done += chunk; + } + } else { + if (!f->open(path, O_RDWR)) { delete f; return false; } + } + _file = f; + return true; +} + +bool SdMotaStore::begin(uint32_t total_size) { + clear(); + if (total_size < 13) return false; + if (!openPart(true, total_size)) return false; + _total = total_size; + return true; +} + +bool SdMotaStore::write(uint32_t off, const uint8_t* data, uint32_t len) { + if (!_file || (uint64_t)off + len > _total || !data) return false; + File32& f = *(File32*)_file; + if (!f.seek(off)) return false; + return f.write(data, len) == (int)len; +} + +bool SdMotaStore::read(uint32_t off, uint8_t* buf, uint32_t len) const { + if (!_file || (uint64_t)off + len > _total || !buf) return false; + File32& f = *(File32*)_file; + if (!f.seek(off)) return false; + return f.read(buf, len) == (int)len; +} + +void SdMotaStore::finalize() { + if (_file) { ((File32*)_file)->close(); delete (File32*)_file; _file = nullptr; } + char part[48], final_path[48]; + partPath(part, sizeof part); + SdMotaFs::instance().midPath(_mid, final_path, sizeof final_path, ".mota"); + SdFat& sd = *(SdFat*)SdMotaFs::instance().sdFat(); + sd.remove(final_path); + sd.rename(part, final_path); + _total = 0; +} + +bool SdMotaStore::reopen() { + clear(); + if (!SdMotaFs::instance().mounted()) return false; + char part[48]; + partPath(part, sizeof part); + SdFat& sd = *(SdFat*)SdMotaFs::instance().sdFat(); + if (!sd.exists(part)) return false; + if (!openPart(false)) return false; + File32& f = *(File32*)_file; + uint32_t sz = (uint32_t)f.size(); + if (sz < 13) { clear(); SdMotaFs::instance().removeFile(part); return false; } + uint8_t hdr[8]; + if (f.read(hdr, 8) != 8 || memcmp(hdr, MOTA_MAGIC, 4) != 0) { clear(); return false; } + uint32_t total = rd_u32le(hdr + 4); + if (total != sz || total < 13) { clear(); return false; } + f.seek(0); + _total = total; + return true; +} + +} // namespace ota +} // namespace mesh + +#endif diff --git a/src/helpers/ota/SdMotaStore.h b/src/helpers/ota/SdMotaStore.h new file mode 100644 index 0000000000..9a05bf3803 --- /dev/null +++ b/src/helpers/ota/SdMotaStore.h @@ -0,0 +1,41 @@ +#pragma once + +#if defined(OTA_SD_SEEDER) + +#include "OtaStore.h" + +namespace mesh { +namespace ota { + +// OtaStore that captures an in-transit `.mota` onto SD as OTA_SD_DIR/.mota.part -> .mota. +class SdMotaStore : public OtaStore { +public: + SdMotaStore() = default; + + void set_mid(const uint8_t mid[4]) { memcpy(_mid, mid, 4); } + + bool begin(uint32_t total_size) override; + bool write(uint32_t off, const uint8_t* data, uint32_t len) override; + bool read(uint32_t off, uint8_t* buf, uint32_t len) const override; + uint32_t capacity() const override { return 0xF0000000u; } + uint32_t staged_size() const override { return _total; } + void clear() override; + void finalize() override; + bool reopen() override; + + bool set_meta_size(uint32_t) override { return true; } + bool plan_layout(bool, uint32_t, uint32_t, uint32_t) override { return true; } + +private: + bool openPart(bool create, uint32_t fill_size = 0); + void partPath(char* out, size_t cap) const; + + uint8_t _mid[4] = {0}; + uint32_t _total = 0; + mutable void* _file = nullptr; // FsFile* while open (lazy) +}; + +} // namespace ota +} // namespace mesh + +#endif diff --git a/src/helpers/ota/SuperSeeder.cpp b/src/helpers/ota/SuperSeeder.cpp new file mode 100644 index 0000000000..94819ad388 --- /dev/null +++ b/src/helpers/ota/SuperSeeder.cpp @@ -0,0 +1,101 @@ +#include "SuperSeeder.h" + +#if defined(OTA_SD_SEEDER) + +#include "OtaContext.h" +#include "SdMotaFs.h" +#include +#include + +namespace mesh { +namespace ota { + +void SuperSeeder::begin(OtaContext& ctx) { + _ctx = &ctx; + _mgr = &ctx.manager; + _flash_store = &ctx.fetch_store; + _mounted = SdMotaFs::instance().mount(); + if (!_mounted) return; + _source.refresh(); + if (!_mgr->add_source(&_source)) return; + _mgr->set_promiscuous(true); + _mgr->set_capture_mode(true); + _active = true; +} + +bool SuperSeeder::pickNext(uint8_t mid[4], uint32_t& target) { + if (!_mgr || !_mounted) return false; + for (uint8_t i = 0; i < _mgr->catalogCount(); i++) { + const OtaManager::CatRow* row = _mgr->catalogRow(i); + if (!row) continue; + if (_source.hasMid(row->mid)) continue; + if (_fail_cooldown_until && memcmp(_fail_mid, row->mid, 4) == 0) continue; + memcpy(mid, row->mid, 4); + target = row->target_id; + return true; + } + return false; +} + +void SuperSeeder::finishCapture(bool ok) { + if (!_ctx || !_mgr) return; + if (!ok) { + uint8_t mid[4]; + memcpy(mid, _mgr->fetchManifestId(), 4); + char part[48]; + SdMotaFs::instance().midPath(mid, part, sizeof part, ".mota.part"); + SdMotaFs::instance().removeFile(part); + memcpy(_fail_mid, mid, 4); + _fail_cooldown_until = millis() + 60000UL; + } + _store.clear(); + _mgr->reset_session(); + _mgr->want_mid(nullptr); + _mgr->want(0); + _mgr->set_fetch_store(_flash_store); + _capturing = false; + if (ok) { + _source.refresh(); + _mgr->refresh_sources(); + _mgr->announce(); + } +} + +void SuperSeeder::loop() { + if (!_active || !_ctx || !_mgr) return; + + if (_fail_cooldown_until && (int32_t)(millis() - _fail_cooldown_until) >= 0) + _fail_cooldown_until = 0; + + OtaManager::FetchState fs = _mgr->fetchState(); + + if (_capturing) { + if (fs == OtaManager::COMPLETE) { + finishCapture(true); + return; + } + if (fs == OtaManager::FAILED || fs == OtaManager::PAUSED) { + finishCapture(false); + return; + } + return; + } + + if (fs != OtaManager::IDLE) return; + + uint8_t mid[4]; + uint32_t target = 0; + if (!pickNext(mid, target)) return; + + _store.set_mid(mid); + _mgr->set_fetch_store(&_store); + _mgr->want(0); + _mgr->want_mid(mid); + _mgr->pull(mid, target, false); + _capturing = true; +} + +} // namespace ota +} // namespace mesh + +#endif diff --git a/src/helpers/ota/SuperSeeder.h b/src/helpers/ota/SuperSeeder.h new file mode 100644 index 0000000000..b11b500eee --- /dev/null +++ b/src/helpers/ota/SuperSeeder.h @@ -0,0 +1,49 @@ +#pragma once + +#if defined(OTA_SD_SEEDER) + +#include +#include "OtaManager.h" +#include "MotaSourceSd.h" +#include "SdMotaStore.h" + +namespace mesh { +namespace ota { + +class OtaContext; + +// SD superseeder orchestrator: promiscuous catalog discovery + capture unknown mids to SD. +class SuperSeeder { +public: + void begin(OtaContext& ctx); + void loop(); + + bool active() const { return _active; } + bool mounted() const { return _mounted; } + uint8_t fileCount() const { return _source.cachedCount(); } + uint32_t totalBytes() const { return _source.cachedTotalBytes(); } + bool capturing() const { return _capturing; } + + MotaSourceSd& source() { return _source; } + SdMotaStore& store() { return _store; } + +private: + void finishCapture(bool ok); + bool pickNext(uint8_t mid[4], uint32_t& target); + + OtaContext* _ctx = nullptr; + OtaManager* _mgr = nullptr; + OtaStore* _flash_store = nullptr; + MotaSourceSd _source; + SdMotaStore _store; + bool _active = false; + bool _mounted = false; + bool _capturing = false; + uint32_t _fail_cooldown_until = 0; + uint8_t _fail_mid[4] = {0}; +}; + +} // namespace ota +} // namespace mesh + +#endif diff --git a/variants/rak4631/platformio.ini b/variants/rak4631/platformio.ini index 30b4de4c56..4df6e5eb9b 100644 --- a/variants/rak4631/platformio.ini +++ b/variants/rak4631/platformio.ini @@ -116,6 +116,19 @@ lib_deps = ${rak4631.lib_deps} https://github.com/RAKWireless/RAK13800-W5100S/archive/1.0.2.zip +; SD-backed field superseeder: RAK4631 slim repeater + RAK15002 SD module. Auto-captures advertised +; mOTAs to SD and serves the whole folder over LoRa (requires OTAFIX for self-update only). +[env:RAK_4631_superseeder] +extends = env:RAK_4631_repeater_slim +build_flags = + ${env:RAK_4631_repeater_slim.build_flags} + -UOTA_FOLDER_SERIAL + -D OTA_SD_SEEDER + -D ADVERT_NAME='"RAK4631 Superseeder"' +lib_deps = + ${env:RAK_4631_repeater_slim.lib_deps} + adafruit/SdFat - Adafruit Fork @ ^2.2.3 + [env:RAK_4631_repeater_bridge_rs232_serial1] extends = rak4631 build_flags = From 909d83935570e288231deecb0a42cea1382e226b Mon Sep 17 00:00:00 2001 From: Ben Allfree Date: Sun, 26 Jul 2026 06:04:33 -0700 Subject: [PATCH 45/59] fix(mesh): disable hop retry by default Co-authored-by: Cursor (cherry picked from commit 0e70236c7d2ec5be2def6ee782222ee96b2618c8) --- docs/cli_commands.md | 2 +- docs/faq.md | 2 +- examples/simple_repeater/MyMesh.cpp | 2 +- examples/simple_room_server/MyMesh.cpp | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 01dead59d1..432e2afb84 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -521,7 +521,7 @@ This document provides an overview of CLI commands that can be sent to MeshCore - `set hop.retry.ms ` **Parameters:** -- `count`: Extra retransmits if the next hop's echo or HOP_ACK is not received (0-5). `0` disables hop retry (fire-and-forget). Default `2` means up to 3 total transmissions (initial + 2 retries). +- `count`: Extra retransmits if the next hop's echo or HOP_ACK is not received (0-5). `0` disables hop retry (fire-and-forget). Default `0` (opt-in); e.g. `2` means up to 3 total transmissions (initial + 2 retries). - `milliseconds`: Base listen window before each retry (200-10000). Default `1500`. Actual deadline also includes forward delay and packet airtime. **Note:** When a repeater forwards a direct-path packet, it waits for the next hop's **echo** (hearing its own packet retransmitted downstream). If the echo is missed within the listen window, it retries. A hop that receives a duplicate addressed to itself replies with a zero-hop **HOP_ACK** instead of forwarding again — this terminates the retry loop when the first delivery succeeded but the echo was lost. No extra airtime in the healthy case. Not used on the last hop (zero-hop delivery to the destination). Prefer `set multi.acks 0` when hop retry is enabled. diff --git a/docs/faq.md b/docs/faq.md index b633a88b14..7b92de8a3c 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -488,7 +488,7 @@ Routes are stored in sender's contact list. When you send a message the first ti [Source](https://discord.com/channels/826570251612323860/1330643963501351004/1351279141630119996) ### 5.4.1. Q: What is next-hop retry on repeaters? -When a repeater forwards a direct-path packet, it normally transmits once and moves on. With hop retry enabled (default on repeaters: `hop.retry 2`, `hop.retry.ms 1500`), the repeater waits for the **echo** of its forward (hearing the same packet retransmitted by the next hop). If the echo is missed within the listen window, it sends again (up to `hop.retry` extra times). A next hop that receives a duplicate addressed to itself replies with a zero-hop **HOP_ACK** control packet instead of forwarding again — this stops the retry loop when delivery succeeded but the echo was lost. No extra airtime in the healthy case. This does not apply on the last hop before the destination. Clients still use end-to-end ACK timeouts for delivery confirmation; hop retry fixes silent mid-path relays. Prefer `set multi.acks 0` when using hop retry. +When a repeater forwards a direct-path packet, it normally transmits once and moves on. With hop retry enabled (off by default; e.g. `set hop.retry 2`, `hop.retry.ms 1500`), the repeater waits for the **echo** of its forward (hearing the same packet retransmitted by the next hop). If the echo is missed within the listen window, it sends again (up to `hop.retry` extra times). A next hop that receives a duplicate addressed to itself replies with a zero-hop **HOP_ACK** control packet instead of forwarding again — this stops the retry loop when delivery succeeded but the echo was lost. No extra airtime in the healthy case. This does not apply on the last hop before the destination. Clients still use end-to-end ACK timeouts for delivery confirmation; hop retry fixes silent mid-path relays. Prefer `set multi.acks 0` when using hop retry. ### 5.5. Q: Do public channels always flood? Do private channels always flood? **A:** Yes, group channels are A to B, so there is no defined path. They have to flood. Repeaters can however deny flood traffic up to some hop limit, with the `set flood.max` CLI command. Administrators of repeaters get to set the rules of their repeaters. diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index e3dea818ff..27e947c0b2 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -966,7 +966,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc _prefs.rx_delay_base = 0.0f; // turn off by default, was 10.0; _prefs.tx_delay_factor = 0.5f; // was 0.25f _prefs.direct_tx_delay_factor = 0.3f; // was 0.2 - _prefs.hop_retry = 2; + _prefs.hop_retry = 0; _prefs.hop_retry_ms = 1500; StrHelper::strncpy(_prefs.node_name, ADVERT_NAME, sizeof(_prefs.node_name)); _prefs.node_lat = ADVERT_LAT; diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index deb15876df..ba88144c72 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -684,7 +684,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc _prefs.rx_delay_base = 0.0f; // off by default, was 10.0 _prefs.tx_delay_factor = 0.5f; // was 0.25f; _prefs.direct_tx_delay_factor = 0.2f; // was zero - _prefs.hop_retry = 2; + _prefs.hop_retry = 0; _prefs.hop_retry_ms = 1500; StrHelper::strncpy(_prefs.node_name, ADVERT_NAME, sizeof(_prefs.node_name)); _prefs.node_lat = ADVERT_LAT; From 35d3df63518dd0b9e266e9c73b10ef362196e63a Mon Sep 17 00:00:00 2001 From: Ben Allfree Date: Tue, 28 Jul 2026 00:12:12 -0700 Subject: [PATCH 46/59] chore(freshen): add lock and cherry-pick script Co-authored-by: Cursor --- FRESHEN.lock | 57 +++++++++++++++++++++++++ scripts/cherry-pick-envyos-overlay.sh | 61 +++++++++++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 FRESHEN.lock create mode 100755 scripts/cherry-pick-envyos-overlay.sh diff --git a/FRESHEN.lock b/FRESHEN.lock new file mode 100644 index 0000000000..c1f96caaa5 --- /dev/null +++ b/FRESHEN.lock @@ -0,0 +1,57 @@ +# EnvyOS fleet release manifest. +# Policy: companion tag + cherry-picked OTA commits + overlay = earns VERSION / v0.1.x / motas/. +# Not companion tag alone; not meshcore/dev; not merge vk496/feature/ota-lora wholesale. +# See .cursor/skills/envyos-freshen/SKILL.md. + +mode: release +companion_tag: companion-v1.16.0 +companion_sha: 07a3ca9e +ota_commits: + - 07b20f04 # ota: .mota container format, merkle, EndF + - 6b4f2355 # ota: detools decoder + - 5da78304 # ota: LoRa transfer protocol + - 9b61169c # ota: platform apply + flash staging + - 9beaf684 # ota: MotaSource serial + - 543c9011 # ota: CLI + CommonCLI integration + - 4680d105 # ota: build/board enablement + - 2092500d # ota: motatool C++ (later extracted) + - e3dd6b41 # ota: native unit tests + - 54c8b586 # ota: protocol spec + user guide + - 280b9b15 # ota: WiFi seeder/console + - 4cb118b4 # ota: post-boot beacon + ota ls + - 0a1cf2b2 # ota: nRF52 OTAFIX variants + - 9ff1c883 # ota: advert interval config + - 259802b7 # ota: hop limit config + - bd2ecb80 # ota: seeder storage protocol + - 10fa4344 # ota: ota pull folder + - 28e3df02 # ota: folder pull pause/resume + - 49820b57 # docs: pull mota off mesh + - 4a601486 # ota: nRF52 flash-layout safety net + - 89532147 # ota: want_mask + warm-start capture + - b159737e # ota: ota stats + - 0319a81d # ota: FIRMWARE_VERSION in EndF + - cbdc629f # docs: warm-start workflow + - ee6e5487 # variants: Heltec T096 OTA + - 87241323 # ota: extract motatool repo + - 71841856 # ota: fix ls tag +overlay_commits: + - dcf051f8 # ota ls: start at entry N + - 97470f41 # next-hop retry + - 18be8ace # hard hop ack + - f0e6b538 # default firmware version v0.1.0 + - beadfa6b # distro version helpers + - fc013f42 # log tail serial + - 7dedbdd6 # hop.ignore test mode + - 1f7e70a9 # echo-primary hop retry + - 7fd0f6b7 # log tail on enables logging + - 59790f36 # zero-hop echo cancel + - 5116d0ba # role-aware OTA staging ceiling + - beeef280 # slim repeater role + - 31621a3e # BLE_DFU_DISABLED + - 705724b2 # wismesh-tag buzzer/LEDs + - 376a9d3f # boot fsck for corrupt lfs + - 2e7d5a61 # payload log labels + - 899ba168 # SD superseeder + - 0e70236c # hop retry default off +last_freshen: 2026-07-27 +notes: envyos/main reset from companion-v1.16.0; OTA cherry-picked from vk496/feature/ota-lora (not merged). Backup branch envyos/main-pre-companion-reset. diff --git a/scripts/cherry-pick-envyos-overlay.sh b/scripts/cherry-pick-envyos-overlay.sh new file mode 100755 index 0000000000..efe122a972 --- /dev/null +++ b/scripts/cherry-pick-envyos-overlay.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Cherry-pick vk496 OTA commits + EnvyOS overlay onto companion-v1.16.0. +# Run from envycore/ after: git reset --hard companion-v1.16.0 +set -euo pipefail + +COMMITS=( + # vk496 OTA stack (chronological) + 07b20f04 + 6b4f2355 + 5da78304 + 9b61169c + 9beaf684 + 543c9011 + 4680d105 + 2092500d + e3dd6b41 + 54c8b586 + 280b9b15 + 4cb118b4 + 0a1cf2b2 + 9ff1c883 + 259802b7 + bd2ecb80 + 10fa4344 + 28e3df02 + 49820b57 + 4a601486 + 89532147 + b159737e + 0319a81d + cbdc629f + ee6e5487 + 87241323 + 71841856 + # EnvyOS overlay + dcf051f8 + 97470f41 + 18be8ace + f0e6b538 + beadfa6b + fc013f42 + 7dedbdd6 + 1f7e70a9 + 7fd0f6b7 + 59790f36 + 5116d0ba + beeef280 + 31621a3e + 705724b2 + 376a9d3f + 2e7d5a61 + 899ba168 + 0e70236c +) + +for sha in "${COMMITS[@]}"; do + echo "=== cherry-pick $sha ===" + git cherry-pick -x "$sha" +done + +echo "Done: $(git log --oneline -1)" From df0f426d83b83c4e1310a3a86fc51d5c128a0403 Mon Sep 17 00:00:00 2001 From: Ben Allfree Date: Mon, 3 Aug 2026 15:10:28 -0700 Subject: [PATCH 47/59] chore(envyos): bump dev version to 0.1.2 Co-authored-by: Cursor --- envyos/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/envyos/VERSION b/envyos/VERSION index 6e8bf73aa5..d917d3e26a 100644 --- a/envyos/VERSION +++ b/envyos/VERSION @@ -1 +1 @@ -0.1.0 +0.1.2 From 481c9aa6ed7a80247b6b6c4f95710e5bb72dc62b Mon Sep 17 00:00:00 2001 From: Ben Allfree Date: Mon, 3 Aug 2026 19:15:24 -0700 Subject: [PATCH 48/59] feat(sensecap): slim repeater ota env Co-authored-by: Cursor --- src/helpers/ota/OtaFlashLayout_nrf52.h | 4 ++- src/helpers/ota/OtaTargets.h | 38 +++++++++++---------- variants/sensecap_solar/platformio.ini | 47 ++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 18 deletions(-) diff --git a/src/helpers/ota/OtaFlashLayout_nrf52.h b/src/helpers/ota/OtaFlashLayout_nrf52.h index a6688baefe..06d69ed924 100644 --- a/src/helpers/ota/OtaFlashLayout_nrf52.h +++ b/src/helpers/ota/OtaFlashLayout_nrf52.h @@ -13,7 +13,9 @@ namespace mesh { namespace ota { -static const uint32_t MOTA_NRF52_APP_BASE = 0x00026000u; // S140 end (== CODE_REGION_1_START) +#ifndef MOTA_NRF52_APP_BASE +#define MOTA_NRF52_APP_BASE 0x00026000u // S140 v6 end (RAK4631, WisMesh Tag, …) +#endif static const uint32_t MOTA_NRF52_EXTRAFS_START = 0x000D4000u; // companion ExtraFS (CustomLFS) static const uint32_t MOTA_NRF52_INTERNALFS_START = 0x000ED000u; // primary LittleFS (/com_prefs) static const uint32_t MOTA_NRF52_FLASH_PAGE = 4096u; diff --git a/src/helpers/ota/OtaTargets.h b/src/helpers/ota/OtaTargets.h index 9fded575fe..660039cba5 100644 --- a/src/helpers/ota/OtaTargets.h +++ b/src/helpers/ota/OtaTargets.h @@ -2,7 +2,7 @@ #include // AUTO-GENERATED by tools/mota/gen_targets.py — do not edit by hand. -// 418 OTA-capable PlatformIO envs. Maps target_id (= sha2-256:4 of the env name, LE uint32) +// 422 OTA-capable PlatformIO envs. Maps target_id (= sha2-256:4 of the env name, LE uint32) // to the human-readable env name, so a node/tool can name a target seen over the air WITHOUT // transmitting the string in the .mota / LoRa protocol. Regenerate when the OTA env set changes. @@ -59,6 +59,13 @@ inline const char* ota_target_env_name(uint32_t target_id) { { 0x2bbe7fd8, "Heltec_E290_repeater" }, { 0x9e40157c, "Heltec_E290_repeater_bridge_espnow" }, { 0xb12e6e01, "Heltec_E290_room_server" }, + { 0xfed35078, "Heltec_t096_companion_radio_ble" }, + { 0x2a3a88c9, "Heltec_t096_companion_radio_usb" }, + { 0x80dc72a9, "Heltec_t096_kiss_modem" }, + { 0x5d3180ae, "Heltec_t096_repeater" }, + { 0x0f67c768, "Heltec_t096_repeater_bridge_rs232" }, + { 0xa14d9f4a, "Heltec_t096_room_server" }, + { 0x6494fda0, "Heltec_t096_sensor" }, { 0x32bcdd14, "Heltec_t114_companion_radio_ble" }, { 0xfe937f14, "Heltec_t114_companion_radio_usb" }, { 0x5b9b1933, "Heltec_t114_kiss_modem" }, @@ -97,7 +104,6 @@ inline const char* ota_target_env_name(uint32_t target_id) { { 0x22f900de, "Heltec_v3_companion_radio_usb" }, { 0xaca0e153, "Heltec_v3_companion_radio_wifi" }, { 0xcb1c2e65, "Heltec_v3_kiss_modem" }, - { 0x9f2a6b84, "Heltec_v3_ota_test" }, { 0xd1b29b18, "Heltec_v3_repeater" }, { 0x644bb68d, "Heltec_v3_repeater_bridge_espnow" }, { 0xd19a9759, "Heltec_v3_repeater_bridge_rs232" }, @@ -257,21 +263,14 @@ inline const char* ota_target_env_name(uint32_t target_id) { { 0xcea95e20, "Meshadventurer_sx1268_terminal_chat" }, { 0xd45277ff, "Meshimi_companion_radio_ble_" }, { 0x2cad9a9d, "Meshimi_repeater_" }, - { 0x063f1f00, "nibble_screen_connect_companion_radio_ble_" }, - { 0xa8a79da5, "nibble_screen_connect_companion_radio_usb_" }, - { 0xc0d3815b, "nibble_screen_connect_companion_radio_wifi_" }, - { 0x966ac88e, "nibble_screen_connect_kiss_modem_" }, - { 0x104b3f8b, "nibble_screen_connect_repeater_" }, - { 0x23b49348, "nibble_screen_connect_repeater_bridge_espnow_" }, - { 0xc42cc90d, "nibble_screen_connect_room_server_" }, - { 0xc24791c3, "nibble_screen_connect_terminal_chat_" }, - { 0x15e11067, "nibble_zero_connect_companion_radio_ble_" }, - { 0x90799c32, "nibble_zero_connect_companion_radio_usb_" }, - { 0x881a6a48, "nibble_zero_connect_companion_radio_wifi_" }, - { 0xf26af51c, "nibble_zero_connect_repeater_" }, - { 0x8174cbdc, "nibble_zero_connect_repeater_bridge_espnow_" }, - { 0x0bf0f061, "nibble_zero_connect_room_server_" }, - { 0x2509dd80, "nibble_zero_connect_terminal_chat_" }, + { 0xf1c1641c, "nibble_screen_connect_companion_radio_ble" }, + { 0x8dcc5d89, "nibble_screen_connect_companion_radio_usb" }, + { 0x6139a846, "nibble_screen_connect_companion_radio_wifi" }, + { 0xc0dbcae4, "nibble_screen_connect_kiss_modem" }, + { 0xcd0bdd06, "nibble_screen_connect_repeater" }, + { 0x9ecb53b1, "nibble_screen_connect_repeater_bridge_espnow" }, + { 0x82f6d321, "nibble_screen_connect_room_server" }, + { 0xb10261c8, "nibble_screen_connect_terminal_chat" }, { 0x56293b24, "ProMicro_companion_radio_ble" }, { 0x8c81078c, "ProMicro_companion_radio_usb" }, { 0xbd6a1c97, "ProMicro_kiss_modem" }, @@ -303,8 +302,12 @@ inline const char* ota_target_env_name(uint32_t target_id) { { 0x04d413fd, "RAK_4631_repeater" }, { 0x22ea8497, "RAK_4631_repeater_bridge_rs232_serial1" }, { 0x21bae522, "RAK_4631_repeater_bridge_rs232_serial2" }, + { 0x25c20350, "RAK_4631_repeater_ethernet" }, + { 0x5c6ab408, "RAK_4631_repeater_slim" }, { 0x626d80ed, "RAK_4631_room_server" }, + { 0x6f9c3984, "RAK_4631_room_server_ethernet" }, { 0x8b1d6850, "RAK_4631_sensor" }, + { 0xd0ca653c, "RAK_4631_superseeder" }, { 0xf1d3c5a8, "RAK_4631_terminal_chat" }, { 0xc6d55752, "RAK_WisMesh_Tag_companion_radio_ble" }, { 0x60683191, "RAK_WisMesh_Tag_companion_radio_usb" }, @@ -312,6 +315,7 @@ inline const char* ota_target_env_name(uint32_t target_id) { { 0x4fa4ae55, "RAK_WisMesh_Tag_repeater" }, { 0x9cb0b232, "RAK_WisMesh_Tag_room_server" }, { 0x51fa1a49, "RAK_WisMesh_Tag_sensor" }, + { 0x1a843535, "SenseCap_Solar_repeater_slim" }, { 0xd904c2d5, "SenseCapIndicator-ESPNow_comp_radio_usb" }, { 0xd0df8303, "Station_G2_companion_radio_ble" }, { 0x5c1a54f8, "Station_G2_companion_radio_usb" }, diff --git a/variants/sensecap_solar/platformio.ini b/variants/sensecap_solar/platformio.ini index effef38ccd..24fca9d546 100644 --- a/variants/sensecap_solar/platformio.ini +++ b/variants/sensecap_solar/platformio.ini @@ -51,6 +51,53 @@ build_flags = build_src_filter = ${SenseCap_Solar.build_src_filter} +<../examples/simple_repeater/*.cpp> +; Slim repeater: no GPS/external sensor drivers (MCU temperature only), no BLE stack. Extends the shared +; rak4631_hw OTA recipe. S140 v7.3.0 app region starts at 0x27000 (not 0x26000) — override APP_BASE so +; EndF scan / staging headroom match the linker script. +[env:SenseCap_Solar_repeater_slim] +extends = rak4631_hw +board = seeed_sensecap_solar +board_build.ldscript = boards/nrf52840_s140_v7.ld +build_flags = ${rak4631_hw.build_flags} + -I lib/nrf52/s140_nrf52_7.3.0_API/include + -I lib/nrf52/s140_nrf52_7.3.0_API/include/nrf52 + -I variants/sensecap_solar + -I src/helpers/nrf52 + -D NRF52_PLATFORM=1 + -D MOTA_NRF52_APP_BASE=0x00027000u + -D MOTA_HW_ID='"SenseCap_Solar"' + -D USE_SX1262 + -D NRF52_POWER_MANAGEMENT + -D RADIO_CLASS=CustomSX1262 + -D WRAPPER_CLASS=CustomSX1262Wrapper + -D P_LORA_TX_LED=12 + -D P_LORA_DIO_1=1 + -D P_LORA_RESET=2 + -D P_LORA_BUSY=3 + -D P_LORA_NSS=4 + -D PIN_USER_BTN=PIN_BUTTON1 + -D LORA_TX_POWER=22 + -D SX126X_RXEN=5 + -D SX126X_TXEN=RADIOLIB_NC + -D SX126X_DIO2_AS_RF_SWITCH=1 + -D SX126X_DIO3_TCXO_VOLTAGE=1.8 + -D SX126X_CURRENT_LIMIT=140 + -D SX126X_RX_BOOSTED_GAIN=1 + -D MOTA_STAGE_CEILING=0xED000 + -D BLE_DFU_DISABLED + -D ADVERT_NAME='"SenseCap P1-Pro Slim Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 +build_src_filter = ${rak4631_hw.build_src_filter} + + + +<../variants/sensecap_solar> + +<../examples/simple_repeater> +lib_deps = ${rak4631_hw.lib_deps} +debug_tool = jlink +upload_protocol = nrfutil + [env:SenseCap_Solar_room_server] extends = SenseCap_Solar build_flags = From 502b2e9e65192e55101995591b865637af7d34f0 Mon Sep 17 00:00:00 2001 From: Ben Allfree Date: Mon, 3 Aug 2026 19:43:41 -0700 Subject: [PATCH 49/59] feat(ota): NOR/SD seeder with allow CLI Co-authored-by: Cursor --- src/Mesh.cpp | 8 +- src/helpers/CommonCLI.cpp | 32 +- src/helpers/CommonCLI.h | 4 + src/helpers/ota/MotaSourceSd.cpp | 89 ------ src/helpers/ota/MotaSourceSeeder.cpp | 95 ++++++ .../{MotaSourceSd.h => MotaSourceSeeder.h} | 17 +- src/helpers/ota/OtaCli.cpp | 91 +++++- src/helpers/ota/OtaContext.h | 30 +- src/helpers/ota/OtaTargets.h | 30 +- src/helpers/ota/SdMotaFs.cpp | 56 ---- src/helpers/ota/SdMotaFs.h | 42 --- src/helpers/ota/SdMotaStore.cpp | 106 ------- src/helpers/ota/SeederAllowlist.h | 83 +++++ src/helpers/ota/SeederFs.cpp | 288 ++++++++++++++++++ src/helpers/ota/SeederFs.h | 57 ++++ src/helpers/ota/SeederMotaStore.cpp | 75 +++++ .../ota/{SdMotaStore.h => SeederMotaStore.h} | 19 +- src/helpers/ota/SuperSeeder.cpp | 29 +- src/helpers/ota/SuperSeeder.h | 41 +-- tools/mota/gen_targets.py | 27 +- variants/rak4631/platformio.ini | 7 +- variants/sensecap_solar/platformio.ini | 13 + 22 files changed, 863 insertions(+), 376 deletions(-) delete mode 100644 src/helpers/ota/MotaSourceSd.cpp create mode 100644 src/helpers/ota/MotaSourceSeeder.cpp rename src/helpers/ota/{MotaSourceSd.h => MotaSourceSeeder.h} (52%) delete mode 100644 src/helpers/ota/SdMotaFs.cpp delete mode 100644 src/helpers/ota/SdMotaFs.h delete mode 100644 src/helpers/ota/SdMotaStore.cpp create mode 100644 src/helpers/ota/SeederAllowlist.h create mode 100644 src/helpers/ota/SeederFs.cpp create mode 100644 src/helpers/ota/SeederFs.h create mode 100644 src/helpers/ota/SeederMotaStore.cpp rename src/helpers/ota/{SdMotaStore.h => SeederMotaStore.h} (60%) diff --git a/src/Mesh.cpp b/src/Mesh.cpp index c11b6a16ec..8f8a97cab2 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -50,10 +50,10 @@ void Mesh::begin() { #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 -#if defined(OTA_SD_SEEDER) +#if defined(OTA_SUPERSEEDER) { - char sdmsg[80]; - ota::ota_ctx().attach_sd(sdmsg, sizeof sdmsg); + char seedmsg[80]; + ota::ota_ctx().attach_seeder(seedmsg, sizeof seedmsg); } #endif _next_ota_announce = futureMillis(OTA_ANNOUNCE_BOOT_MS); // advertise our own fw shortly after boot @@ -95,7 +95,7 @@ void Mesh::loop() { } 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 -#if defined(OTA_SD_SEEDER) +#if defined(OTA_SUPERSEEDER) ota::ota_ctx().superseeder_loop(); #endif _next_ota_tick = futureMillis(3000); diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 01bbcc7e09..c142a2f476 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -58,6 +58,15 @@ void CommonCLI::syncOtaConfigFromPrefs() { 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]); +#if defined(OTA_SUPERSEEDER) + if (_prefs->ota_seeder_allow_filter) { + c.seeder_allow.clear(); // filter mode, admit nothing until entries added + for (uint8_t i = 0; i < _prefs->ota_seeder_allow_count && i < 8; i++) + c.seeder_allow.add(_prefs->ota_seeder_allow[i]); + } else { + c.seeder_allow.reset(); // admit all + } +#endif } #endif @@ -132,7 +141,14 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { _prefs->hop_retry_ms = 1500; file.read((uint8_t *)&_prefs->hop_retry, sizeof(_prefs->hop_retry)); // 429 file.read((uint8_t *)&_prefs->hop_retry_ms, sizeof(_prefs->hop_retry_ms)); // 430 - // next: 432 + _prefs->ota_seeder_allow_count = 0; + _prefs->ota_seeder_allow_filter = 0; // 0 = admit all (superseeder default) + memset(_prefs->ota_seeder_allow, 0, sizeof(_prefs->ota_seeder_allow)); + file.read((uint8_t *)&_prefs->ota_seeder_allow_count, sizeof(_prefs->ota_seeder_allow_count)); // 432 + file.read((uint8_t *)&_prefs->ota_seeder_allow_filter, sizeof(_prefs->ota_seeder_allow_filter)); // 433 + file.read(pad, 2); // 434 + file.read((uint8_t *)_prefs->ota_seeder_allow, sizeof(_prefs->ota_seeder_allow)); // 436 + // next: 468 // sanitise bad pref values _prefs->rx_delay_base = constrain(_prefs->rx_delay_base, 0, 20.0f); @@ -168,6 +184,8 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { if (_prefs->ota_advert_interval > 10080) _prefs->ota_advert_interval = 1440; // 0=off; cap at 7 days if (_prefs->ota_max_hops > 8) _prefs->ota_max_hops = 3; // 0=direct only; cap absurd reach if (_prefs->ota_signer_count > 4) _prefs->ota_signer_count = 0; // corrupt count -> drop keys + if (_prefs->ota_seeder_allow_count > 8) _prefs->ota_seeder_allow_count = 0; + if (_prefs->ota_seeder_allow_filter > 1) _prefs->ota_seeder_allow_filter = 0; _prefs->hop_retry = constrain(_prefs->hop_retry, 0, 5); if (_prefs->hop_retry_ms < 200) _prefs->hop_retry_ms = 200; if (_prefs->hop_retry_ms > 10000) _prefs->hop_retry_ms = 10000; @@ -243,7 +261,11 @@ void CommonCLI::savePrefs(FILESYSTEM* fs) { file.write((uint8_t *)&_prefs->ota_max_hops, sizeof(_prefs->ota_max_hops)); // 428 file.write((uint8_t *)&_prefs->hop_retry, sizeof(_prefs->hop_retry)); // 429 file.write((uint8_t *)&_prefs->hop_retry_ms, sizeof(_prefs->hop_retry_ms)); // 430 - // next: 432 + file.write((uint8_t *)&_prefs->ota_seeder_allow_count, sizeof(_prefs->ota_seeder_allow_count)); // 432 + file.write((uint8_t *)&_prefs->ota_seeder_allow_filter, sizeof(_prefs->ota_seeder_allow_filter)); // 433 + file.write(pad, 2); // 434 + file.write((uint8_t *)_prefs->ota_seeder_allow, sizeof(_prefs->ota_seeder_allow)); // 436 + // next: 468 file.close(); } @@ -378,6 +400,12 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re _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); +#if defined(OTA_SUPERSEEDER) + _prefs->ota_seeder_allow_filter = c.seeder_allow.filtering() ? 1 : 0; + _prefs->ota_seeder_allow_count = c.seeder_allow.count(); + for (uint8_t i = 0; i < c.seeder_allow.count() && i < 8; i++) + _prefs->ota_seeder_allow[i] = c.seeder_allow.get(i); +#endif _callbacks->savePrefs(); c.config_dirty = false; } diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index af19320237..949ac3569a 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -73,6 +73,10 @@ struct NodePrefs { // persisted to file uint8_t ota_max_hops; // OTA flood reach in hops; 0=direct only. Default 3 (runtime-tunable) uint8_t hop_retry; // extra direct-path TX if next hop hop ACK missing (0=off). Default 0 (opt-in) uint16_t hop_retry_ms; // listen TTL before retry (ms). Default 1500 + // Superseeder target filter (persisted). filter=0 → admit all (default); filter=1 → only listed. + uint8_t ota_seeder_allow_count; + uint8_t ota_seeder_allow_filter; // 0=all, 1=filter (empty filter admits nothing) + uint32_t ota_seeder_allow[8]; // target_ids (== MAX_OTA_SEEDER_ALLOW) }; class CommonCLICallbacks { diff --git a/src/helpers/ota/MotaSourceSd.cpp b/src/helpers/ota/MotaSourceSd.cpp deleted file mode 100644 index 54aae3aa98..0000000000 --- a/src/helpers/ota/MotaSourceSd.cpp +++ /dev/null @@ -1,89 +0,0 @@ -#include "MotaSourceSd.h" - -#if defined(OTA_SD_SEEDER) - -#include "SdMotaFs.h" -#include "MotaContainer.h" -#include "OtaByteIO.h" -#include "OtaFormat.h" -#include "Utils.h" -#include -#include - -namespace mesh { -namespace ota { - -void MotaSourceSd::refresh() { - _count = 0; - _total_bytes = 0; - if (!SdMotaFs::instance().mounted()) return; - SdFat& sd = *(SdFat*)SdMotaFs::instance().sdFat(); - File32 dir = sd.open(OTA_SD_DIR); - if (!dir || !dir.isDirectory()) return; - File32 ent; - while (ent.openNext(&dir, O_RDONLY) && _count < MAX_FILES) { - char name[32]; - ent.getName(name, sizeof name); - ent.close(); - size_t nlen = strlen(name); - if (nlen < 6 || strcmp(name + nlen - 5, ".mota") != 0) continue; - if (strstr(name, ".part")) continue; - snprintf(_paths[_count], sizeof _paths[_count], "%s/%s", OTA_SD_DIR, name); - File32 f = sd.open(_paths[_count]); - if (f) { _total_bytes += (uint32_t)f.size(); f.close(); } - _count++; - } - dir.close(); -} - -bool MotaSourceSd::hasMid(const uint8_t mid[4]) const { - return SdMotaFs::instance().hasMota(mid); -} - -bool MotaSourceSd::describePath(const char* path, MotaDesc& out) const { - SdFat& sd = *(SdFat*)SdMotaFs::instance().sdFat(); - File32 f = sd.open(path, O_RDONLY); - if (!f) return false; - uint32_t total = (uint32_t)f.size(); - if (total < 13) { f.close(); return false; } - uint8_t hdr[8]; - if (f.read(hdr, 8) != 8 || memcmp(hdr, MOTA_MAGIC, 4) != 0) { f.close(); return false; } - if (rd_u32le(hdr + 4) != total) { f.close(); return false; } - uint8_t mf[MOTA_MFL]; - if (f.read(mf, MOTA_MFL) != MOTA_MFL) { f.close(); return false; } - f.close(); - MotaManifest m; - if (!mota_parse_manifest(mf, MOTA_MFL, m)) return false; - memcpy(out.mid, m.merkle_root, 4); - out.target_id = m.target_id; - out.fw_version = m.fw_version; - out.codec_id = m.codec_id; - out.flags = m.flags; - out.total_size = total; - out.leaves_off = 8 + MOTA_MFL; - out.block_count = m.block_count; - out.payload_off = out.leaves_off + m.block_count * 4; - out.payload_size = m.payload_size; - return true; -} - -bool MotaSourceSd::describe(uint8_t idx, MotaDesc& out) { - if (idx >= _count) return false; - return describePath(_paths[idx], out); -} - -bool MotaSourceSd::read(uint8_t idx, uint32_t off, uint8_t* buf, uint32_t len) { - if (idx >= _count || !buf || len == 0) return false; - SdFat& sd = *(SdFat*)SdMotaFs::instance().sdFat(); - File32 f = sd.open(_paths[idx], O_RDONLY); - if (!f) return false; - if (!f.seek(off)) { f.close(); return false; } - int n = f.read(buf, len); - f.close(); - return n == (int)len; -} - -} // namespace ota -} // namespace mesh - -#endif diff --git a/src/helpers/ota/MotaSourceSeeder.cpp b/src/helpers/ota/MotaSourceSeeder.cpp new file mode 100644 index 0000000000..40a08afec9 --- /dev/null +++ b/src/helpers/ota/MotaSourceSeeder.cpp @@ -0,0 +1,95 @@ +#include "MotaSourceSeeder.h" + +#if defined(OTA_SUPERSEEDER) + +#include "MotaContainer.h" +#include "OtaByteIO.h" +#include "OtaFormat.h" +#include "SeederAllowlist.h" +#include "SeederFs.h" +#include + +namespace mesh { +namespace ota { + +void MotaSourceSeeder::refresh() { + _count = 0; + _total_bytes = 0; + if (!SeederFs::instance().mounted()) return; + + // Static scratch — avoid a 2.5 KB stack frame on nRF52 (single-threaded loop is fine). + static char paths[MAX_FILES][40]; + static uint32_t sizes[MAX_FILES]; + uint8_t n = 0; + struct Collect { + char (*paths)[40]; + uint32_t* sizes; + uint8_t* n; + } col{paths, sizes, &n}; + + SeederFs::instance().forEachMota( + [](const char* path, uint32_t size, void* v) -> bool { + auto* c = (Collect*)v; + if (*c->n >= MAX_FILES) return false; + strncpy(c->paths[*c->n], path, 39); + c->paths[*c->n][39] = 0; + c->sizes[*c->n] = size; + (*c->n)++; + return true; + }, + &col); + + static const SeederAllowlist k_allow_all; // empty = admit all targets + const SeederAllowlist& allow = _allow ? *_allow : k_allow_all; + + for (uint8_t i = 0; i < n && _count < MAX_FILES; i++) { + MotaDesc d; + if (!describePath(paths[i], d)) continue; + if (!ota_seeder_admit(allow, d.target_id, d.codec_id, d.flags)) continue; + memcpy(_paths[_count], paths[i], sizeof _paths[_count]); + _total_bytes += sizes[i] ? sizes[i] : d.total_size; + _count++; + } +} + +bool MotaSourceSeeder::hasMid(const uint8_t mid[4]) const { + return SeederFs::instance().hasMota(mid); +} + +bool MotaSourceSeeder::describePath(const char* path, MotaDesc& out) const { + uint8_t hdr[8]; + if (!SeederFs::instance().readAt(path, 0, hdr, 8) || memcmp(hdr, MOTA_MAGIC, 4) != 0) return false; + uint32_t total = rd_u32le(hdr + 4); + uint32_t sz = SeederFs::instance().fileSize(path); + if (total != sz || total < 13) return false; + uint8_t mf[MOTA_MFL]; + if (!SeederFs::instance().readAt(path, 8, mf, MOTA_MFL)) return false; + MotaManifest m; + if (!mota_parse_manifest(mf, MOTA_MFL, m)) return false; + memcpy(out.mid, m.merkle_root, 4); + out.target_id = m.target_id; + out.fw_version = m.fw_version; + out.codec_id = m.codec_id; + out.flags = m.flags; + out.total_size = total; + out.leaves_off = 8 + MOTA_MFL; + out.block_count = m.block_count; + out.payload_off = out.leaves_off + m.block_count * 4; + out.payload_size = m.payload_size; + return true; +} + +bool MotaSourceSeeder::describe(uint8_t idx, MotaDesc& out) { + if (idx >= _count) return false; + return describePath(_paths[idx], out); +} + +bool MotaSourceSeeder::read(uint8_t idx, uint32_t off, uint8_t* buf, uint32_t len) { + if (idx >= _count || !buf || len == 0) return false; + return SeederFs::instance().readAt(_paths[idx], off, buf, len); +} + +} // namespace ota +} // namespace mesh + +#endif diff --git a/src/helpers/ota/MotaSourceSd.h b/src/helpers/ota/MotaSourceSeeder.h similarity index 52% rename from src/helpers/ota/MotaSourceSd.h rename to src/helpers/ota/MotaSourceSeeder.h index b0bd226c65..ad0d66e07e 100644 --- a/src/helpers/ota/MotaSourceSd.h +++ b/src/helpers/ota/MotaSourceSeeder.h @@ -1,28 +1,31 @@ #pragma once -#if defined(OTA_SD_SEEDER) +#if defined(OTA_SUPERSEEDER) #include "OtaSource.h" +#include "SeederAllowlist.h" namespace mesh { namespace ota { -// MotaSource over a folder of complete `.mota` files on SD (OTA_SD_DIR). -class MotaSourceSd : public MotaSource { +// MotaSource over complete delta `.mota` files on the seeder FS (OTA_SEEDER_DIR). +class MotaSourceSeeder : public MotaSource { public: static const uint8_t MAX_FILES = 64; + void setAllowlist(const SeederAllowlist* allow) { _allow = allow; } void refresh(); uint8_t count() override { return _count; } uint8_t cachedCount() const { return _count; } uint32_t cachedTotalBytes() const { return _total_bytes; } - bool describe(uint8_t idx, MotaDesc& out) override; - bool read(uint8_t idx, uint32_t off, uint8_t* buf, uint32_t len) override; + bool describe(uint8_t idx, MotaDesc& out) override; + bool read(uint8_t idx, uint32_t off, uint8_t* buf, uint32_t len) override; bool hasMid(const uint8_t mid[4]) const; private: + const SeederAllowlist* _allow = nullptr; char _paths[MAX_FILES][40] = {}; uint8_t _count = 0; uint32_t _total_bytes = 0; @@ -30,7 +33,7 @@ class MotaSourceSd : public MotaSource { bool describePath(const char* path, MotaDesc& out) const; }; -} // namespace ota -} // namespace mesh +} // namespace ota +} // namespace mesh #endif diff --git a/src/helpers/ota/OtaCli.cpp b/src/helpers/ota/OtaCli.cpp index 4bcf5feb12..8e0f25bc37 100644 --- a/src/helpers/ota/OtaCli.cpp +++ b/src/helpers/ota/OtaCli.cpp @@ -111,7 +111,7 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board if (is_cmd(a, "help|?|h", &rest)) { snprintf(reply, 160, "OTA: status | stats=admin ids/hashes | ls=find updates | get <#>=download | install | cancel | " - "announce | self | folder | config | key. Try `ota ls`."); + "announce | self | folder | seed | config | key. Try `ota ls`."); // ---- inventory dashboard: running fw (self), the one fetch session, serving state ---- } else if (*a == 0 || is_cmd(a, "status|st", &rest)) { @@ -140,8 +140,8 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board if (n < 146) n += snprintf(reply + n, 160 - n, " | bl:%s blrc:%02X", c.bootloaderCaps().present ? "apply" : "NONE", ota_bootloader_last_rc()); #endif -#if defined(OTA_SD_SEEDER) - if (n < 152) n += snprintf(reply + n, 160 - n, " | sd:%s", c.sd_active ? "on" : "off"); +#if defined(OTA_SUPERSEEDER) + if (n < 150) n += snprintf(reply + n, 160 - n, " | seed:%s", c.seeder_active ? "on" : "off"); #endif // ---- admin OTA stats: crypto identities (our fw's content-id + body_hash), serving set, live fetch, @@ -336,28 +336,99 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board // ---- 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 (is_cmd(a, "sd", &rest)) { -#if defined(OTA_SD_SEEDER) - if (!c.sd_active) { - strcpy(reply, "ERR SD superseeder not active (mount failed at boot?)"); + } else if (is_cmd(a, "seed|sd", &rest)) { +#if defined(OTA_SUPERSEEDER) + const char* p = rest; + while (*p == ' ') p++; + // `ota seed allow …` — runtime target filter (empty = all targets). Persisted via NodePrefs. + if (strncmp(p, "allow", 5) == 0 && (p[5] == 0 || p[5] == ' ')) { + const char* ap = p + 5; + while (*ap == ' ') ap++; + if (*ap == 0 || strncmp(ap, "list", 4) == 0) { + if (c.seeder_allow.allowAll()) { + strcpy(reply, "seed allow: ALL targets (deltas only)"); + } else if (c.seeder_allow.count() == 0) { + strcpy(reply, "seed allow: NONE (empty filter)"); + } else { + int n = snprintf(reply, 160, "seed allow (%u):", (unsigned)c.seeder_allow.count()); + for (uint8_t i = 0; i < c.seeder_allow.count() && n < 140; i++) { + uint32_t tid = c.seeder_allow.get(i); + const char* env = ota_target_env_name(tid); + if (env) n += snprintf(reply + n, 160 - n, " %s", env); + else n += snprintf(reply + n, 160 - n, " %08X", (unsigned)tid); + } + } + } else if (strncmp(ap, "clear", 5) == 0) { + c.seeder_allow.clear(); + c.config_dirty = true; + if (c.seeder_active) c.superseeder.refreshSource(); + strcpy(reply, "OK seed allow cleared — NONE (saved)"); + } else if (strncmp(ap, "reset", 5) == 0 || strncmp(ap, "defaults", 8) == 0) { + c.seeder_allow.reset(); + c.config_dirty = true; + if (c.seeder_active) c.superseeder.refreshSource(); + strcpy(reply, "OK seed allow reset — ALL targets (saved)"); + } else if (strncmp(ap, "add ", 4) == 0 || strncmp(ap, "rm ", 3) == 0 || strncmp(ap, "remove ", 7) == 0) { + bool is_add = strncmp(ap, "add ", 4) == 0; + const char* tok = is_add ? ap + 4 : (ap[0] == 'r' && ap[1] == 'm' ? ap + 3 : ap + 7); + while (*tok == ' ') tok++; + // Trim trailing junk for mesh-admin replies. + char tokbuf[64]; + strncpy(tokbuf, tok, sizeof tokbuf - 1); + tokbuf[sizeof tokbuf - 1] = 0; + for (char* q = tokbuf; *q; q++) { if (*q == ' ' || *q == '\r' || *q == '\n') { *q = 0; break; } } + uint32_t tid = ota_target_id_for_env(tokbuf); + if (!tid) { + char* end = nullptr; + unsigned long v = strtoul(tokbuf, &end, 16); + if (end && end != tokbuf && *end == 0) tid = (uint32_t)v; + } + if (!tid) { + strcpy(reply, is_add ? "ERR usage: ota seed allow add " + : "ERR usage: ota seed allow rm "); + return true; + } + if (is_add) { + if (!c.seeder_allow.add(tid)) { strcpy(reply, "ERR allowlist full (max 8)"); return true; } + c.config_dirty = true; + if (c.seeder_active) c.superseeder.refreshSource(); + const char* env = ota_target_env_name(tid); + snprintf(reply, 160, "OK allow +%s (saved)", env ? env : "target"); + } else { + if (!c.seeder_allow.remove(tid)) { strcpy(reply, "ERR not in allowlist"); return true; } + c.config_dirty = true; + if (c.seeder_active) c.superseeder.refreshSource(); + strcpy(reply, "OK removed (saved)"); + } + } else { + strcpy(reply, "ERR usage: ota seed allow [list|add |rm |clear|reset]"); + } + return true; + } + if (!c.seeder_active) { + snprintf(reply, 160, "ERR %s superseeder not active (mount failed at boot?)", OTA_SEEDER_MEDIA); return true; } OtaManager::FetchState fs = c.manager.fetchState(); + const char* pol = c.seeder_allow.allowAll() ? "all" + : (c.seeder_allow.count() == 0 ? "none" : "filter"); if (c.superseeder.capturing() && fs != OtaManager::IDLE) { char midhx[9]; mesh::Utils::toHex(midhx, c.manager.fetchManifestId(), 4); unsigned have = (unsigned)c.manager.blocksHave(), tot = (unsigned)c.manager.blocksTotal(); unsigned pct = tot ? (unsigned)((uint64_t)have * 100 / tot) : 0; - snprintf(reply, 160, "SD superseeder | files=%u %uK | capture %s %u/%u (%u%%) id=%s", + snprintf(reply, 160, "%s superseeder | deltas/%s | files=%u %uK | capture %s %u/%u (%u%%) id=%s", + OTA_SEEDER_MEDIA, pol, (unsigned)c.superseeder.fileCount(), (unsigned)(c.superseeder.totalBytes() / 1024), state_short(fs), have, tot, pct, midhx); } else { - snprintf(reply, 160, "SD superseeder | mounted | files=%u %uK | capture idle", + snprintf(reply, 160, "%s superseeder | deltas/%s | mounted | files=%u %uK | capture idle", + OTA_SEEDER_MEDIA, pol, (unsigned)c.superseeder.fileCount(), (unsigned)(c.superseeder.totalBytes() / 1024)); } #else - strcpy(reply, "ERR not built with OTA_SD_SEEDER"); + strcpy(reply, "ERR not built with OTA_SUPERSEEDER"); #endif } else if (is_cmd(a, "folder|fold", &rest)) { diff --git a/src/helpers/ota/OtaContext.h b/src/helpers/ota/OtaContext.h index 9c9f4fc436..91b698042d 100644 --- a/src/helpers/ota/OtaContext.h +++ b/src/helpers/ota/OtaContext.h @@ -25,8 +25,10 @@ // 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 -#if defined(OTA_SD_SEEDER) +#if defined(OTA_SUPERSEEDER) #include "SuperSeeder.h" + #include "SeederAllowlist.h" + #include "SeederFs.h" // OTA_SEEDER_MEDIA #endif // Per-device OTA singleton shared by the CLI (OtaCli) and the mesh adapter (the example's MyMesh). @@ -155,9 +157,10 @@ struct OtaContext { 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 -#if defined(OTA_SD_SEEDER) - SuperSeeder superseeder; - bool sd_active = false; // SD superseeder mounted + serving +#if defined(OTA_SUPERSEEDER) + SuperSeeder superseeder; + SeederAllowlist seeder_allow; // default admit-all; filter/clear/reset via CLI; persisted + bool seeder_active = false; // external FS mounted + serving deltas #endif // Attach/detach an external folder of `.mota` served by a host daemon over the seeder UART (the node @@ -178,18 +181,21 @@ struct OtaContext { #endif void detach_folder() { manager.clear_sources(); folder_active = false; } -#if defined(OTA_SD_SEEDER) - bool attach_sd(char* msg, size_t cap) { +#if defined(OTA_SUPERSEEDER) + bool attach_seeder(char* msg, size_t cap) { superseeder.begin(*this); - if (!superseeder.mounted()) { strncpy(msg, "ERR SD mount failed", cap); return false; } - sd_active = superseeder.active(); - if (!sd_active) { strncpy(msg, "ERR SD source slot full", cap); return false; } - snprintf(msg, cap, "OK SD superseeder — serving %u mOTA total (own fw + SD)", - (unsigned)manager.servedCount()); + if (!superseeder.mounted()) { + snprintf(msg, cap, "ERR %s mount failed", OTA_SEEDER_MEDIA); + return false; + } + seeder_active = superseeder.active(); + if (!seeder_active) { strncpy(msg, "ERR seeder source slot full", cap); return false; } + snprintf(msg, cap, "OK %s superseeder — deltas only, serving %u mOTA (own fw + %s)", + OTA_SEEDER_MEDIA, (unsigned)manager.servedCount(), OTA_SEEDER_MEDIA); return true; } void superseeder_loop() { - if (sd_active) superseeder.loop(); + if (seeder_active) superseeder.loop(); } #endif diff --git a/src/helpers/ota/OtaTargets.h b/src/helpers/ota/OtaTargets.h index 660039cba5..251ead5aa8 100644 --- a/src/helpers/ota/OtaTargets.h +++ b/src/helpers/ota/OtaTargets.h @@ -1,16 +1,17 @@ #pragma once #include +#include // AUTO-GENERATED by tools/mota/gen_targets.py — do not edit by hand. -// 422 OTA-capable PlatformIO envs. Maps target_id (= sha2-256:4 of the env name, LE uint32) +// 423 OTA-capable PlatformIO envs. Maps target_id (= sha2-256:4 of the env name, LE uint32) // to the human-readable env name, so a node/tool can name a target seen over the air WITHOUT // transmitting the string in the .mota / LoRa protocol. Regenerate when the OTA env set changes. namespace mesh { namespace ota { -// target_id -> env name, or nullptr if unknown. Linear scan (table is small; lookups are rare). -inline const char* ota_target_env_name(uint32_t target_id) { - static const struct { uint32_t id; const char* env; } T[] = { +struct OtaTargetRow { uint32_t id; const char* env; }; +inline const OtaTargetRow* ota_target_table(unsigned& n) { + static const OtaTargetRow T[] = { { 0x149bcb23, "Ebyte_EoRa-S3_companion_radio_ble" }, { 0x30d4e442, "Ebyte_EoRa-S3_companion_radio_usb" }, { 0x263a8d80, "Ebyte_EoRa-S3_kiss_modem" }, @@ -316,6 +317,7 @@ inline const char* ota_target_env_name(uint32_t target_id) { { 0x9cb0b232, "RAK_WisMesh_Tag_room_server" }, { 0x51fa1a49, "RAK_WisMesh_Tag_sensor" }, { 0x1a843535, "SenseCap_Solar_repeater_slim" }, + { 0xe584262b, "SenseCap_Solar_superseeder" }, { 0xd904c2d5, "SenseCapIndicator-ESPNow_comp_radio_usb" }, { 0xd0df8303, "Station_G2_companion_radio_ble" }, { 0x5c1a54f8, "Station_G2_companion_radio_usb" }, @@ -434,9 +436,27 @@ inline const char* ota_target_env_name(uint32_t target_id) { { 0xf967a300, "Xiao_S3_WIO_sensor" }, { 0xa0cc1c36, "Xiao_S3_WIO_terminal_chat" }, }; - for (unsigned i = 0; i < sizeof(T) / sizeof(T[0]); i++) + n = (unsigned)(sizeof(T) / sizeof(T[0])); + return T; +} + +// target_id -> env name, or nullptr if unknown. Linear scan (table is small; lookups are rare). +inline const char* ota_target_env_name(uint32_t target_id) { + unsigned n = 0; + const OtaTargetRow* T = ota_target_table(n); + for (unsigned i = 0; i < n; i++) if (T[i].id == target_id) return T[i].env; return nullptr; } +// env name -> target_id, or 0 if unknown to this build's table. +inline uint32_t ota_target_id_for_env(const char* env) { + if (!env || !env[0]) return 0; + unsigned n = 0; + const OtaTargetRow* T = ota_target_table(n); + for (unsigned i = 0; i < n; i++) + if (strcmp(T[i].env, env) == 0) return T[i].id; + return 0; +} + } } // namespace mesh::ota diff --git a/src/helpers/ota/SdMotaFs.cpp b/src/helpers/ota/SdMotaFs.cpp deleted file mode 100644 index 8850f16e91..0000000000 --- a/src/helpers/ota/SdMotaFs.cpp +++ /dev/null @@ -1,56 +0,0 @@ -#include "SdMotaFs.h" - -#if defined(OTA_SD_SEEDER) - -#include "Utils.h" -#include -#include -#include -#include - -namespace mesh { -namespace ota { - -SdMotaFs& SdMotaFs::instance() { - static SdMotaFs fs; - return fs; -} - -void* SdMotaFs::sdFat() const { - static SdFat sd; - return &sd; -} - -bool SdMotaFs::mount() { - if (_mounted) return true; - SdFat& sd = *(SdFat*)sdFat(); - if (!sd.begin(OTA_SD_CS, SD_SCK_MHZ(4))) return false; - if (!sd.exists(OTA_SD_DIR)) sd.mkdir(OTA_SD_DIR); - _mounted = true; - return true; -} - -void SdMotaFs::midPath(const uint8_t mid[4], char* out, size_t cap, const char* suffix) const { - char hx[9]; - mesh::Utils::toHex(hx, mid, 4); - snprintf(out, cap, "%s/%s%s", OTA_SD_DIR, hx, suffix ? suffix : ""); -} - -bool SdMotaFs::hasMota(const uint8_t mid[4]) const { - if (!_mounted) return false; - char path[48]; - midPath(mid, path, sizeof path, ".mota"); - return ((SdFat*)sdFat())->exists(path); -} - -bool SdMotaFs::removeFile(const char* path) { - if (!_mounted || !path || !path[0]) return false; - SdFat& sd = *(SdFat*)sdFat(); - if (!sd.exists(path)) return true; - return sd.remove(path); -} - -} // namespace ota -} // namespace mesh - -#endif diff --git a/src/helpers/ota/SdMotaFs.h b/src/helpers/ota/SdMotaFs.h deleted file mode 100644 index e80d0a314a..0000000000 --- a/src/helpers/ota/SdMotaFs.h +++ /dev/null @@ -1,42 +0,0 @@ -#pragma once - -#if defined(OTA_SD_SEEDER) - -#include -#include - -#ifndef OTA_SD_CS -#define OTA_SD_CS SS -#endif -#ifndef OTA_SD_DIR -#define OTA_SD_DIR "/motas" -#endif - -namespace mesh { -namespace ota { - -// Shared SD mount + path helpers for the superseeder (MotaSourceSd + SdMotaStore). -class SdMotaFs { -public: - static SdMotaFs& instance(); - - bool mount(); - bool mounted() const { return _mounted; } - - // Build OTA_SD_DIR/ (e.g. ".mota" or ".mota.part"). - void midPath(const uint8_t mid[4], char* out, size_t cap, const char* suffix) const; - - bool hasMota(const uint8_t mid[4]) const; - bool removeFile(const char* path); - - void* sdFat() const; // opaque SdFat* for .cpp (keeps SdFat out of other headers) - -private: - SdMotaFs() = default; - bool _mounted = false; -}; - -} // namespace ota -} // namespace mesh - -#endif diff --git a/src/helpers/ota/SdMotaStore.cpp b/src/helpers/ota/SdMotaStore.cpp deleted file mode 100644 index 232bad7f36..0000000000 --- a/src/helpers/ota/SdMotaStore.cpp +++ /dev/null @@ -1,106 +0,0 @@ -#include "SdMotaStore.h" - -#if defined(OTA_SD_SEEDER) - -#include "SdMotaFs.h" -#include "OtaByteIO.h" -#include "OtaFormat.h" -#include -#include - -namespace mesh { -namespace ota { - -void SdMotaStore::partPath(char* out, size_t cap) const { - SdMotaFs::instance().midPath(_mid, out, cap, ".mota.part"); -} - -void SdMotaStore::clear() { - if (_file) { ((File32*)_file)->close(); delete (File32*)_file; _file = nullptr; } - _total = 0; -} - -bool SdMotaStore::openPart(bool create, uint32_t fill_size) { - if (!SdMotaFs::instance().mounted()) return false; - if (_file) { ((File32*)_file)->close(); delete (File32*)_file; _file = nullptr; } - char path[48]; - partPath(path, sizeof path); - SdFat& sd = *(SdFat*)SdMotaFs::instance().sdFat(); - File32* f = new File32(); - if (create) { - sd.remove(path); - if (!f->open(path, O_RDWR | O_CREAT | O_TRUNC)) { delete f; return false; } - static uint8_t ff[512]; - static bool ff_init = false; - if (!ff_init) { memset(ff, 0xFF, sizeof ff); ff_init = true; } - uint32_t done = 0; - while (done < fill_size) { - uint32_t chunk = fill_size - done; - if (chunk > sizeof ff) chunk = sizeof ff; - if (f->write(ff, chunk) != (int)chunk) { f->close(); delete f; sd.remove(path); return false; } - done += chunk; - } - } else { - if (!f->open(path, O_RDWR)) { delete f; return false; } - } - _file = f; - return true; -} - -bool SdMotaStore::begin(uint32_t total_size) { - clear(); - if (total_size < 13) return false; - if (!openPart(true, total_size)) return false; - _total = total_size; - return true; -} - -bool SdMotaStore::write(uint32_t off, const uint8_t* data, uint32_t len) { - if (!_file || (uint64_t)off + len > _total || !data) return false; - File32& f = *(File32*)_file; - if (!f.seek(off)) return false; - return f.write(data, len) == (int)len; -} - -bool SdMotaStore::read(uint32_t off, uint8_t* buf, uint32_t len) const { - if (!_file || (uint64_t)off + len > _total || !buf) return false; - File32& f = *(File32*)_file; - if (!f.seek(off)) return false; - return f.read(buf, len) == (int)len; -} - -void SdMotaStore::finalize() { - if (_file) { ((File32*)_file)->close(); delete (File32*)_file; _file = nullptr; } - char part[48], final_path[48]; - partPath(part, sizeof part); - SdMotaFs::instance().midPath(_mid, final_path, sizeof final_path, ".mota"); - SdFat& sd = *(SdFat*)SdMotaFs::instance().sdFat(); - sd.remove(final_path); - sd.rename(part, final_path); - _total = 0; -} - -bool SdMotaStore::reopen() { - clear(); - if (!SdMotaFs::instance().mounted()) return false; - char part[48]; - partPath(part, sizeof part); - SdFat& sd = *(SdFat*)SdMotaFs::instance().sdFat(); - if (!sd.exists(part)) return false; - if (!openPart(false)) return false; - File32& f = *(File32*)_file; - uint32_t sz = (uint32_t)f.size(); - if (sz < 13) { clear(); SdMotaFs::instance().removeFile(part); return false; } - uint8_t hdr[8]; - if (f.read(hdr, 8) != 8 || memcmp(hdr, MOTA_MAGIC, 4) != 0) { clear(); return false; } - uint32_t total = rd_u32le(hdr + 4); - if (total != sz || total < 13) { clear(); return false; } - f.seek(0); - _total = total; - return true; -} - -} // namespace ota -} // namespace mesh - -#endif diff --git a/src/helpers/ota/SeederAllowlist.h b/src/helpers/ota/SeederAllowlist.h new file mode 100644 index 0000000000..d65a53935c --- /dev/null +++ b/src/helpers/ota/SeederAllowlist.h @@ -0,0 +1,83 @@ +#pragma once + +#if defined(OTA_SUPERSEEDER) + +#include "OtaFormat.h" +#include +#include + +namespace mesh { +namespace ota { + +#ifndef MAX_OTA_SEEDER_ALLOW +#define MAX_OTA_SEEDER_ALLOW 8 +#endif + +// Runtime target allowlist for the superseeder library. +// allow-all (default): _filter == false +// filter mode: _filter == true — only listed ids; empty list admits nothing +// Deltas-only is always enforced separately via ota_seeder_is_delta(). +class SeederAllowlist { + uint32_t _ids[MAX_OTA_SEEDER_ALLOW] = {}; + uint8_t _count = 0; + bool _filter = false; // false = admit all targets + +public: + // Empty the filter list and stay in filter mode (admit nothing until add/reset). + void clear() { _count = 0; _filter = true; } + // Restore factory default: admit all targets. + void reset() { _count = 0; _filter = false; } + + uint8_t count() const { return _count; } + bool filtering() const { return _filter; } + bool allowAll() const { return !_filter; } + uint32_t get(uint8_t i) const { return (i < _count) ? _ids[i] : 0; } + + bool contains(uint32_t target_id) const { + for (uint8_t i = 0; i < _count; i++) + if (_ids[i] == target_id) return true; + return false; + } + + bool admits(uint32_t target_id) const { + if (!_filter) return true; + return contains(target_id); + } + + bool add(uint32_t target_id) { + if (target_id == 0) return false; + _filter = true; + if (contains(target_id)) return true; + if (_count >= MAX_OTA_SEEDER_ALLOW) return false; + _ids[_count++] = target_id; + return true; + } + + bool remove(uint32_t target_id) { + for (uint8_t i = 0; i < _count; i++) { + if (_ids[i] == target_id) { + memmove(&_ids[i], &_ids[i + 1], (size_t)(_count - i - 1) * sizeof(uint32_t)); + _count--; + _filter = true; // stay filtered even if now empty + return true; + } + } + return false; + } +}; + +inline bool ota_seeder_is_delta(uint8_t codec, uint8_t flags) { + if ((flags & MFLAG_FULL) != 0) return false; + if (codec == CODEC_FULL) return false; + return codec == CODEC_DETOOLS_INPLACE || codec == CODEC_DETOOLS_SEQUENTIAL; +} + +inline bool ota_seeder_admit(const SeederAllowlist& allow, uint32_t target_id, + uint8_t codec, uint8_t flags) { + return allow.admits(target_id) && ota_seeder_is_delta(codec, flags); +} + +} // namespace ota +} // namespace mesh + +#endif diff --git a/src/helpers/ota/SeederFs.cpp b/src/helpers/ota/SeederFs.cpp new file mode 100644 index 0000000000..3df685f085 --- /dev/null +++ b/src/helpers/ota/SeederFs.cpp @@ -0,0 +1,288 @@ +#include "SeederFs.h" + +#if defined(OTA_SUPERSEEDER) + +#include "Utils.h" +#include +#include + +#if defined(OTA_SUPERSEEDER_SD) +#include +#include +#ifndef OTA_SD_CS +#define OTA_SD_CS SS +#endif +#elif defined(OTA_SUPERSEEDER_QSPI) +#include +#endif + +namespace mesh { +namespace ota { + +SeederFs& SeederFs::instance() { + static SeederFs fs; + return fs; +} + +#if defined(OTA_SUPERSEEDER_SD) + +static SdFat& sdRef() { + static SdFat sd; + return sd; +} + +bool SeederFs::mount() { + if (_mounted) return true; + if (!sdRef().begin(OTA_SD_CS, SD_SCK_MHZ(4))) return false; + if (!sdRef().exists(OTA_SEEDER_DIR)) sdRef().mkdir(OTA_SEEDER_DIR); + _mounted = true; + return true; +} + +bool SeederFs::exists(const char* path) const { + return _mounted && path && sdRef().exists(path); +} + +bool SeederFs::removeFile(const char* path) { + if (!_mounted || !path || !path[0]) return false; + if (!sdRef().exists(path)) return true; + return sdRef().remove(path); +} + +bool SeederFs::renameFile(const char* from, const char* to) { + if (!_mounted || !from || !to) return false; + sdRef().remove(to); + return sdRef().rename(from, to); +} + +uint32_t SeederFs::fileSize(const char* path) const { + if (!_mounted || !path) return 0; + File32 f = sdRef().open(path, O_RDONLY); + if (!f) return 0; + uint32_t n = (uint32_t)f.size(); + f.close(); + return n; +} + +bool SeederFs::createFilled(const char* path, uint32_t size, uint8_t fill) { + if (!_mounted || !path) return false; + sdRef().remove(path); + File32 f; + if (!f.open(path, O_RDWR | O_CREAT | O_TRUNC)) return false; + static uint8_t buf[512]; + memset(buf, fill, sizeof buf); + uint32_t done = 0; + while (done < size) { + uint32_t chunk = size - done; + if (chunk > sizeof buf) chunk = sizeof buf; + if (f.write(buf, chunk) != (int)chunk) { + f.close(); + sdRef().remove(path); + return false; + } + done += chunk; + } + f.close(); + return true; +} + +bool SeederFs::writeAt(const char* path, uint32_t off, const uint8_t* data, uint32_t len) { + if (!_mounted || !path || !data) return false; + File32 f; + if (!f.open(path, O_RDWR)) return false; + if (!f.seek(off) || f.write(data, len) != (int)len) { + f.close(); + return false; + } + f.close(); + return true; +} + +bool SeederFs::readAt(const char* path, uint32_t off, uint8_t* buf, uint32_t len) const { + if (!_mounted || !path || !buf) return false; + File32 f = sdRef().open(path, O_RDONLY); + if (!f) return false; + if (!f.seek(off) || f.read(buf, len) != (int)len) { + f.close(); + return false; + } + f.close(); + return true; +} + +void SeederFs::forEachMota(MotasCb cb, void* ctx) const { + if (!_mounted || !cb) return; + File32 dir = sdRef().open(OTA_SEEDER_DIR); + if (!dir || !dir.isDirectory()) return; + File32 ent; + while (ent.openNext(&dir, O_RDONLY)) { + char name[32]; + ent.getName(name, sizeof name); + uint32_t sz = (uint32_t)ent.size(); + ent.close(); + size_t nlen = strlen(name); + if (nlen < 6 || strcmp(name + nlen - 5, ".mota") != 0) continue; + if (strstr(name, ".part")) continue; + char path[48]; + snprintf(path, sizeof path, "%s/%s", OTA_SEEDER_DIR, name); + if (!cb(path, sz, ctx)) break; + } + dir.close(); +} + +#elif defined(OTA_SUPERSEEDER_QSPI) + +bool SeederFs::mount() { + if (_mounted) return true; + if (!QSPIFlash.begin()) return false; + if (!QSPIFlash.exists(OTA_SEEDER_DIR)) QSPIFlash.mkdir(OTA_SEEDER_DIR); + _mounted = true; + return true; +} + +bool SeederFs::exists(const char* path) const { + return _mounted && path && QSPIFlash.exists(path); +} + +bool SeederFs::removeFile(const char* path) { + if (!_mounted || !path || !path[0]) return false; + if (!QSPIFlash.exists(path)) return true; + return QSPIFlash.remove(path); +} + +bool SeederFs::renameFile(const char* from, const char* to) { + if (!_mounted || !from || !to) return false; + QSPIFlash.remove(to); + return QSPIFlash.rename(from, to); +} + +uint32_t SeederFs::fileSize(const char* path) const { + if (!_mounted || !path) return 0; + File f = QSPIFlash.open(path, FILE_O_READ); + if (!f) return 0; + uint32_t n = f.size(); + f.close(); + return n; +} + +bool SeederFs::createFilled(const char* path, uint32_t size, uint8_t fill) { + if (!_mounted || !path) return false; + QSPIFlash.remove(path); + File f = QSPIFlash.open(path, FILE_O_WRITE); + if (!f) return false; + static uint8_t buf[512]; + memset(buf, fill, sizeof buf); + uint32_t done = 0; + while (done < size) { + uint32_t chunk = size - done; + if (chunk > sizeof buf) chunk = sizeof buf; + if (f.write(buf, chunk) != chunk) { + f.close(); + QSPIFlash.remove(path); + return false; + } + done += chunk; + } + f.close(); + return true; +} + +bool SeederFs::writeAt(const char* path, uint32_t off, const uint8_t* data, uint32_t len) { + if (!_mounted || !path || !data) return false; + File f = QSPIFlash.open(path, FILE_O_WRITE); + if (!f) return false; + // Adafruit FILE_O_WRITE creates/truncates — reopen without truncate by seeking after open + // of an existing filled part file. Use read+rewrite via LFS: open WRITE on existing keeps content + // when using seek (Adafruit opens LFS_O_RDWR|LFS_O_CREAT without TRUNC for FILE_O_WRITE on some + // versions). Guard: if size collapsed, fail. + if (!f.seek(off)) { + f.close(); + return false; + } + uint32_t done = 0; + while (done < len) { + uint32_t chunk = len - done; + if (chunk > 512) chunk = 512; + if (f.write(data + done, chunk) != chunk) { + f.close(); + return false; + } + done += chunk; + } + f.close(); + return true; +} + +bool SeederFs::readAt(const char* path, uint32_t off, uint8_t* buf, uint32_t len) const { + if (!_mounted || !path || !buf) return false; + File f = QSPIFlash.open(path, FILE_O_READ); + if (!f) return false; + if (!f.seek(off)) { + f.close(); + return false; + } + uint32_t done = 0; + while (done < len) { + uint32_t chunk = len - done; + if (chunk > 512) chunk = 512; + int n = f.read(buf + done, (uint16_t)chunk); + if (n != (int)chunk) { + f.close(); + return false; + } + done += chunk; + } + f.close(); + return true; +} + +void SeederFs::forEachMota(MotasCb cb, void* ctx) const { + if (!_mounted || !cb) return; + File dir = QSPIFlash.open(OTA_SEEDER_DIR, FILE_O_READ); + if (!dir || !dir.isDirectory()) return; + + // Collect names first — Adafruit LittleFS allows only one open file at a time. + char names[64][28]; + uint8_t n = 0; + while (n < 64) { + File ent = dir.openNextFile(FILE_O_READ); + if (!ent) break; + const char* name = ent.name(); + bool ok = name && strlen(name) >= 6 && strcmp(name + strlen(name) - 5, ".mota") == 0 && + !strstr(name, ".part"); + if (ok) { + strncpy(names[n], name, sizeof names[n] - 1); + names[n][sizeof names[n] - 1] = 0; + n++; + } + ent.close(); + } + dir.close(); + + for (uint8_t i = 0; i < n; i++) { + char path[48]; + snprintf(path, sizeof path, "%s/%s", OTA_SEEDER_DIR, names[i]); + uint32_t sz = fileSize(path); + if (!cb(path, sz, ctx)) break; + } +} + +#endif + +void SeederFs::midPath(const uint8_t mid[4], char* out, size_t cap, const char* suffix) const { + char hx[9]; + mesh::Utils::toHex(hx, mid, 4); + snprintf(out, cap, "%s/%s%s", OTA_SEEDER_DIR, hx, suffix ? suffix : ""); +} + +bool SeederFs::hasMota(const uint8_t mid[4]) const { + if (!_mounted) return false; + char path[48]; + midPath(mid, path, sizeof path, ".mota"); + return exists(path); +} + +} // namespace ota +} // namespace mesh + +#endif diff --git a/src/helpers/ota/SeederFs.h b/src/helpers/ota/SeederFs.h new file mode 100644 index 0000000000..e60f86afe8 --- /dev/null +++ b/src/helpers/ota/SeederFs.h @@ -0,0 +1,57 @@ +#pragma once + +#if defined(OTA_SUPERSEEDER) + +#include +#include + +#ifndef OTA_SEEDER_DIR +#define OTA_SEEDER_DIR "/motas" +#endif + +#if defined(OTA_SUPERSEEDER_QSPI) +#define OTA_SEEDER_MEDIA "NOR" +#elif defined(OTA_SUPERSEEDER_SD) +#define OTA_SEEDER_MEDIA "SD" +#else +#error "OTA_SUPERSEEDER requires OTA_SUPERSEEDER_SD or OTA_SUPERSEEDER_QSPI" +#endif + +namespace mesh { +namespace ota { + +// External FS mount + path helpers for the superseeder library (SD or QSPI LittleFS). +// File I/O is open-per-call so QSPI (Adafruit LittleFS single-open) can serve while capturing. +class SeederFs { +public: + static SeederFs& instance(); + + bool mount(); + bool mounted() const { return _mounted; } + + void midPath(const uint8_t mid[4], char* out, size_t cap, const char* suffix) const; + bool hasMota(const uint8_t mid[4]) const; + bool exists(const char* path) const; + bool removeFile(const char* path); + bool renameFile(const char* from, const char* to); + uint32_t fileSize(const char* path) const; + + // Create/truncate path and fill with `fill` (0xFF matches erased-flash semantics for progress bits). + bool createFilled(const char* path, uint32_t size, uint8_t fill = 0xFF); + bool writeAt(const char* path, uint32_t off, const uint8_t* data, uint32_t len); + bool readAt(const char* path, uint32_t off, uint8_t* buf, uint32_t len) const; + + // List complete `*.mota` files under OTA_SEEDER_DIR (skips `.part`). + // cb returns false to stop. path is OTA_SEEDER_DIR/. + using MotasCb = bool (*)(const char* path, uint32_t size, void* ctx); + void forEachMota(MotasCb cb, void* ctx) const; + +private: + SeederFs() = default; + bool _mounted = false; +}; + +} // namespace ota +} // namespace mesh + +#endif diff --git a/src/helpers/ota/SeederMotaStore.cpp b/src/helpers/ota/SeederMotaStore.cpp new file mode 100644 index 0000000000..9f647e7534 --- /dev/null +++ b/src/helpers/ota/SeederMotaStore.cpp @@ -0,0 +1,75 @@ +#include "SeederMotaStore.h" + +#if defined(OTA_SUPERSEEDER) + +#include "OtaByteIO.h" +#include "OtaFormat.h" +#include "SeederFs.h" +#include + +namespace mesh { +namespace ota { + +void SeederMotaStore::partPath(char* out, size_t cap) const { + SeederFs::instance().midPath(_mid, out, cap, ".mota.part"); +} + +void SeederMotaStore::clear() { _total = 0; } + +bool SeederMotaStore::begin(uint32_t total_size) { + clear(); + if (total_size < 13) return false; + char path[48]; + partPath(path, sizeof path); + if (!SeederFs::instance().createFilled(path, total_size, 0xFF)) return false; + _total = total_size; + return true; +} + +bool SeederMotaStore::write(uint32_t off, const uint8_t* data, uint32_t len) { + if ((uint64_t)off + len > _total || !data) return false; + char path[48]; + partPath(path, sizeof path); + return SeederFs::instance().writeAt(path, off, data, len); +} + +bool SeederMotaStore::read(uint32_t off, uint8_t* buf, uint32_t len) const { + if ((uint64_t)off + len > _total || !buf) return false; + char path[48]; + partPath(path, sizeof path); + return SeederFs::instance().readAt(path, off, buf, len); +} + +void SeederMotaStore::finalize() { + char part[48], final_path[48]; + partPath(part, sizeof part); + SeederFs::instance().midPath(_mid, final_path, sizeof final_path, ".mota"); + SeederFs::instance().renameFile(part, final_path); + _total = 0; +} + +bool SeederMotaStore::reopen() { + clear(); + if (!SeederFs::instance().mounted()) return false; + char part[48]; + partPath(part, sizeof part); + if (!SeederFs::instance().exists(part)) return false; + uint32_t sz = SeederFs::instance().fileSize(part); + if (sz < 13) { + SeederFs::instance().removeFile(part); + return false; + } + uint8_t hdr[8]; + if (!SeederFs::instance().readAt(part, 0, hdr, 8) || memcmp(hdr, MOTA_MAGIC, 4) != 0) { + return false; + } + uint32_t total = rd_u32le(hdr + 4); + if (total != sz || total < 13) return false; + _total = total; + return true; +} + +} // namespace ota +} // namespace mesh + +#endif diff --git a/src/helpers/ota/SdMotaStore.h b/src/helpers/ota/SeederMotaStore.h similarity index 60% rename from src/helpers/ota/SdMotaStore.h rename to src/helpers/ota/SeederMotaStore.h index 9a05bf3803..09f105a794 100644 --- a/src/helpers/ota/SdMotaStore.h +++ b/src/helpers/ota/SeederMotaStore.h @@ -1,16 +1,18 @@ #pragma once -#if defined(OTA_SD_SEEDER) +#if defined(OTA_SUPERSEEDER) #include "OtaStore.h" +#include namespace mesh { namespace ota { -// OtaStore that captures an in-transit `.mota` onto SD as OTA_SD_DIR/.mota.part -> .mota. -class SdMotaStore : public OtaStore { +// OtaStore that captures an in-transit `.mota` onto the seeder FS as +// OTA_SEEDER_DIR/.mota.part -> .mota. +class SeederMotaStore : public OtaStore { public: - SdMotaStore() = default; + SeederMotaStore() = default; void set_mid(const uint8_t mid[4]) { memcpy(_mid, mid, 4); } @@ -24,18 +26,17 @@ class SdMotaStore : public OtaStore { bool reopen() override; bool set_meta_size(uint32_t) override { return true; } - bool plan_layout(bool, uint32_t, uint32_t, uint32_t) override { return true; } + // Superseeder library is deltas-only — refuse full snapshots at store admission. + bool plan_layout(bool is_full, uint32_t, uint32_t, uint32_t) override { return !is_full; } private: - bool openPart(bool create, uint32_t fill_size = 0); void partPath(char* out, size_t cap) const; uint8_t _mid[4] = {0}; uint32_t _total = 0; - mutable void* _file = nullptr; // FsFile* while open (lazy) }; -} // namespace ota -} // namespace mesh +} // namespace ota +} // namespace mesh #endif diff --git a/src/helpers/ota/SuperSeeder.cpp b/src/helpers/ota/SuperSeeder.cpp index 94819ad388..8a20b2a4da 100644 --- a/src/helpers/ota/SuperSeeder.cpp +++ b/src/helpers/ota/SuperSeeder.cpp @@ -1,9 +1,10 @@ #include "SuperSeeder.h" -#if defined(OTA_SD_SEEDER) +#if defined(OTA_SUPERSEEDER) #include "OtaContext.h" -#include "SdMotaFs.h" +#include "SeederAllowlist.h" +#include "SeederFs.h" #include #include @@ -14,20 +15,30 @@ void SuperSeeder::begin(OtaContext& ctx) { _ctx = &ctx; _mgr = &ctx.manager; _flash_store = &ctx.fetch_store; - _mounted = SdMotaFs::instance().mount(); + _allow = &ctx.seeder_allow; + _source.setAllowlist(_allow); + _mounted = SeederFs::instance().mount(); if (!_mounted) return; _source.refresh(); if (!_mgr->add_source(&_source)) return; _mgr->set_promiscuous(true); - _mgr->set_capture_mode(true); + _mgr->set_capture_mode(true); // accept foreign codecs while staging for re-seed _active = true; } +void SuperSeeder::refreshSource() { + if (!_active || !_mgr) return; + _source.refresh(); + _mgr->refresh_sources(); + _mgr->announce(); +} + bool SuperSeeder::pickNext(uint8_t mid[4], uint32_t& target) { - if (!_mgr || !_mounted) return false; + if (!_mgr || !_mounted || !_allow) return false; for (uint8_t i = 0; i < _mgr->catalogCount(); i++) { const OtaManager::CatRow* row = _mgr->catalogRow(i); if (!row) continue; + if (!ota_seeder_admit(*_allow, row->target_id, row->codec, row->flags)) continue; if (_source.hasMid(row->mid)) continue; if (_fail_cooldown_until && memcmp(_fail_mid, row->mid, 4) == 0) continue; memcpy(mid, row->mid, 4); @@ -43,8 +54,8 @@ void SuperSeeder::finishCapture(bool ok) { uint8_t mid[4]; memcpy(mid, _mgr->fetchManifestId(), 4); char part[48]; - SdMotaFs::instance().midPath(mid, part, sizeof part, ".mota.part"); - SdMotaFs::instance().removeFile(part); + SeederFs::instance().midPath(mid, part, sizeof part, ".mota.part"); + SeederFs::instance().removeFile(part); memcpy(_fail_mid, mid, 4); _fail_cooldown_until = millis() + 60000UL; } @@ -95,7 +106,7 @@ void SuperSeeder::loop() { _capturing = true; } -} // namespace ota -} // namespace mesh +} // namespace ota +} // namespace mesh #endif diff --git a/src/helpers/ota/SuperSeeder.h b/src/helpers/ota/SuperSeeder.h index b11b500eee..327310f2f7 100644 --- a/src/helpers/ota/SuperSeeder.h +++ b/src/helpers/ota/SuperSeeder.h @@ -1,22 +1,26 @@ #pragma once -#if defined(OTA_SD_SEEDER) +#if defined(OTA_SUPERSEEDER) #include #include "OtaManager.h" -#include "MotaSourceSd.h" -#include "SdMotaStore.h" +#include "MotaSourceSeeder.h" +#include "SeederAllowlist.h" +#include "SeederMotaStore.h" namespace mesh { namespace ota { class OtaContext; -// SD superseeder orchestrator: promiscuous catalog discovery + capture unknown mids to SD. +// External-FS superseeder: promiscuous catalog discovery + capture *deltas* +// (optionally filtered by a runtime target allowlist; empty = all targets) +// to SD or QSPI LittleFS. Full snapshots are never stored. class SuperSeeder { public: void begin(OtaContext& ctx); void loop(); + void refreshSource(); // re-scan FS after allowlist change bool active() const { return _active; } bool mounted() const { return _mounted; } @@ -24,26 +28,27 @@ class SuperSeeder { uint32_t totalBytes() const { return _source.cachedTotalBytes(); } bool capturing() const { return _capturing; } - MotaSourceSd& source() { return _source; } - SdMotaStore& store() { return _store; } + MotaSourceSeeder& source() { return _source; } + SeederMotaStore& store() { return _store; } private: void finishCapture(bool ok); bool pickNext(uint8_t mid[4], uint32_t& target); - OtaContext* _ctx = nullptr; - OtaManager* _mgr = nullptr; - OtaStore* _flash_store = nullptr; - MotaSourceSd _source; - SdMotaStore _store; - bool _active = false; - bool _mounted = false; - bool _capturing = false; - uint32_t _fail_cooldown_until = 0; - uint8_t _fail_mid[4] = {0}; + OtaContext* _ctx = nullptr; + OtaManager* _mgr = nullptr; + OtaStore* _flash_store = nullptr; + SeederAllowlist* _allow = nullptr; + MotaSourceSeeder _source; + SeederMotaStore _store; + bool _active = false; + bool _mounted = false; + bool _capturing = false; + uint32_t _fail_cooldown_until = 0; + uint8_t _fail_mid[4] = {0}; }; -} // namespace ota -} // namespace mesh +} // namespace ota +} // namespace mesh #endif diff --git a/tools/mota/gen_targets.py b/tools/mota/gen_targets.py index ff25928d9f..2d999d9073 100644 --- a/tools/mota/gen_targets.py +++ b/tools/mota/gen_targets.py @@ -60,6 +60,7 @@ def main(): out = [ "#pragma once", "#include ", + "#include ", "", "// AUTO-GENERATED by tools/mota/gen_targets.py — do not edit by hand.", f"// {len(rows)} OTA-capable PlatformIO envs. Maps target_id (= sha2-256:4 of the env name, LE uint32)", @@ -68,18 +69,36 @@ def main(): "", "namespace mesh { namespace ota {", "", - "// target_id -> env name, or nullptr if unknown. Linear scan (table is small; lookups are rare).", - "inline const char* ota_target_env_name(uint32_t target_id) {", - " static const struct { uint32_t id; const char* env; } T[] = {", + "struct OtaTargetRow { uint32_t id; const char* env; };", + "inline const OtaTargetRow* ota_target_table(unsigned& n) {", + " static const OtaTargetRow T[] = {", ] out += [f' {{ 0x{tid:08x}, "{e}" }},' for tid, e in rows] out += [ " };", - " for (unsigned i = 0; i < sizeof(T) / sizeof(T[0]); i++)", + " n = (unsigned)(sizeof(T) / sizeof(T[0]));", + " return T;", + "}", + "", + "// target_id -> env name, or nullptr if unknown. Linear scan (table is small; lookups are rare).", + "inline const char* ota_target_env_name(uint32_t target_id) {", + " unsigned n = 0;", + " const OtaTargetRow* T = ota_target_table(n);", + " for (unsigned i = 0; i < n; i++)", " if (T[i].id == target_id) return T[i].env;", " return nullptr;", "}", "", + "// env name -> target_id, or 0 if unknown to this build's table.", + "inline uint32_t ota_target_id_for_env(const char* env) {", + " if (!env || !env[0]) return 0;", + " unsigned n = 0;", + " const OtaTargetRow* T = ota_target_table(n);", + " for (unsigned i = 0; i < n; i++)", + " if (strcmp(T[i].env, env) == 0) return T[i].id;", + " return 0;", + "}", + "", "} } // namespace mesh::ota", ] OUT.parent.mkdir(parents=True, exist_ok=True) diff --git a/variants/rak4631/platformio.ini b/variants/rak4631/platformio.ini index 4df6e5eb9b..71ec009ef6 100644 --- a/variants/rak4631/platformio.ini +++ b/variants/rak4631/platformio.ini @@ -116,14 +116,15 @@ lib_deps = ${rak4631.lib_deps} https://github.com/RAKWireless/RAK13800-W5100S/archive/1.0.2.zip -; SD-backed field superseeder: RAK4631 slim repeater + RAK15002 SD module. Auto-captures advertised -; mOTAs to SD and serves the whole folder over LoRa (requires OTAFIX for self-update only). +; SD-backed field superseeder: RAK4631 slim + RAK15002 SD. Captures allowlisted *deltas* only to +; /motas/ on SD and serves them over LoRa. Flash staging is for this node's self-update only. [env:RAK_4631_superseeder] extends = env:RAK_4631_repeater_slim build_flags = ${env:RAK_4631_repeater_slim.build_flags} -UOTA_FOLDER_SERIAL - -D OTA_SD_SEEDER + -D OTA_SUPERSEEDER + -D OTA_SUPERSEEDER_SD -D ADVERT_NAME='"RAK4631 Superseeder"' lib_deps = ${env:RAK_4631_repeater_slim.lib_deps} diff --git a/variants/sensecap_solar/platformio.ini b/variants/sensecap_solar/platformio.ini index 24fca9d546..0442f04462 100644 --- a/variants/sensecap_solar/platformio.ini +++ b/variants/sensecap_solar/platformio.ini @@ -98,6 +98,19 @@ lib_deps = ${rak4631_hw.lib_deps} debug_tool = jlink upload_protocol = nrfutil +; NOR-backed mini-superseeder: SenseCAP P1-Pro slim + onboard 2 MB QSPI (P25Q16H) as LittleFS. +; Captures allowlisted *deltas* only to /motas/; flash staging remains for self-update. +[env:SenseCap_Solar_superseeder] +extends = env:SenseCap_Solar_repeater_slim +build_flags = + ${env:SenseCap_Solar_repeater_slim.build_flags} + -UOTA_FOLDER_SERIAL + -D OTA_SUPERSEEDER + -D OTA_SUPERSEEDER_QSPI + -D ADVERT_NAME='"SenseCap P1-Pro Superseeder"' +lib_deps = + ${env:SenseCap_Solar_repeater_slim.lib_deps} + [env:SenseCap_Solar_room_server] extends = SenseCap_Solar build_flags = From a2a13e18d383db188aeeeceb0209f4557ae21e32 Mon Sep 17 00:00:00 2001 From: Ben Allfree Date: Thu, 13 Aug 2026 09:25:44 -0700 Subject: [PATCH 50/59] fix(repeater): defer remote admin CLI out of RX handler Remote admin CLI no longer runs handleCommand inside onPeerDataRecv. Commands queue to MyMesh::loop() so savePrefs and ed25519_sign run at loop-task stack depth, matching USB serial CLI. Co-authored-by: Cursor --- examples/simple_repeater/MyMesh.cpp | 96 ++++++++++++++++++++++------- examples/simple_repeater/MyMesh.h | 20 ++++++ 2 files changed, 94 insertions(+), 22 deletions(-) diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 27e947c0b2..5243f8221b 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -721,6 +721,63 @@ static bool isShare(const mesh::Packet *packet) { return false; } +void MyMesh::sendRemoteCliReply(ClientInfo* client, const uint8_t* secret, uint32_t sender_timestamp, + uint8_t path_hash_size, uint8_t out_path_len, const uint8_t* out_path, + const char* reply) { + int text_len = strlen(reply); + if (text_len <= 0) return; + + uint8_t temp[166]; + uint32_t timestamp = getRTCClock()->getCurrentTimeUnique(); + if (timestamp == sender_timestamp) { + timestamp++; + } + memcpy(temp, ×tamp, 4); + temp[4] = (TXT_TYPE_CLI_DATA << 2); + memcpy(&temp[5], reply, text_len + 1); + + mesh::Packet* pkt = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, secret, temp, 5 + text_len); + if (pkt) { + if (out_path_len == OUT_PATH_UNKNOWN) { + sendFloodReply(pkt, CLI_REPLY_DELAY_MILLIS, path_hash_size); + } else { + sendDirect(pkt, out_path, out_path_len, CLI_REPLY_DELAY_MILLIS); + } + } +} + +void MyMesh::processPendingRemoteCli() { + if (!_remote_cli.pending) return; + + PendingRemoteCli work = _remote_cli; + _remote_cli.pending = false; + + if (work.client_idx < 0 || work.client_idx >= acl.getNumClients()) { + return; + } + + auto client = acl.getClientByIdx(work.client_idx); + + uint8_t temp[166]; + char* reply = (char*)&temp[5]; + reply[0] = 0; + handleCommand(work.sender_timestamp, work.command, reply); + + if (reply[0]) { + StrHelper::strncpy(_remote_cli.last_reply, reply, sizeof(_remote_cli.last_reply)); + _remote_cli.has_last_reply = true; + _remote_cli.last_reply_ts = work.sender_timestamp; + _remote_cli.last_reply_client_idx = work.client_idx; + + if (client->out_path_len == OUT_PATH_UNKNOWN && work.out_path_len != OUT_PATH_UNKNOWN) { + client->out_path_len = mesh::Packet::copyPath(client->out_path, work.out_path, work.out_path_len); + } + + sendRemoteCliReply(client, client->shared_secret, work.sender_timestamp, work.path_hash_size, + work.out_path_len, work.out_path, reply); + } +} + void MyMesh::onAdvertRecv(mesh::Packet *packet, const mesh::Identity &id, uint32_t timestamp, const uint8_t *app_data, size_t app_data_len) { mesh::Mesh::onAdvertRecv(packet, id, timestamp, app_data, app_data_len); // chain to super impl @@ -804,32 +861,24 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, } } - uint8_t temp[166]; char *command = (char *)&data[5]; - char *reply = (char *)&temp[5]; if (is_retry) { - *reply = 0; - } else { - handleCommand(sender_timestamp, command, reply); - } - int text_len = strlen(reply); - if (text_len > 0) { - uint32_t timestamp = getRTCClock()->getCurrentTimeUnique(); - if (timestamp == sender_timestamp) { - // WORKAROUND: the two timestamps need to be different, in the CLI view - timestamp++; + if (_remote_cli.has_last_reply + && _remote_cli.last_reply_ts == sender_timestamp + && _remote_cli.last_reply_client_idx == i) { + sendRemoteCliReply(client, secret, sender_timestamp, packet->getPathHashSize(), + client->out_path_len, client->out_path, _remote_cli.last_reply); } - memcpy(temp, ×tamp, 4); // mostly an extra blob to help make packet_hash unique - temp[4] = (TXT_TYPE_CLI_DATA << 2); // NOTE: legacy was: TXT_TYPE_PLAIN - - auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, secret, temp, 5 + text_len); - if (reply) { - if (client->out_path_len == OUT_PATH_UNKNOWN) { - sendFloodReply(reply, CLI_REPLY_DELAY_MILLIS, packet->getPathHashSize()); - } else { - sendDirect(reply, client->out_path, client->out_path_len, CLI_REPLY_DELAY_MILLIS); - } + } else { + StrHelper::strncpy(_remote_cli.command, command, sizeof(_remote_cli.command)); + _remote_cli.sender_timestamp = sender_timestamp; + _remote_cli.client_idx = i; + _remote_cli.path_hash_size = packet->getPathHashSize(); + _remote_cli.out_path_len = client->out_path_len; + if (client->out_path_len != OUT_PATH_UNKNOWN) { + memcpy(_remote_cli.out_path, client->out_path, MAX_PATH_SIZE); } + _remote_cli.pending = true; } } else { MESH_DEBUG_PRINTLN("onPeerDataRecv: possible replay attack detected"); @@ -955,6 +1004,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc _logging = false; _tailing = false; region_load_active = false; + memset(&_remote_cli, 0, sizeof(_remote_cli)); #if MAX_NEIGHBOURS memset(neighbours, 0, sizeof(neighbours)); @@ -1359,6 +1409,8 @@ void MyMesh::loop() { bridge.loop(); #endif + processPendingRemoteCli(); + mesh::Mesh::loop(); // also drives the OTA fetch loop (centralized in mesh::Mesh) if (next_flood_advert && millisHasNowPassed(next_flood_advert)) { diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 13483a5ae6..605d1430c2 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -117,6 +117,26 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { uint8_t pending_sf; uint8_t pending_cr; int matching_peer_indexes[MAX_CLIENTS]; + + struct PendingRemoteCli { + bool pending; + uint32_t sender_timestamp; + int client_idx; + uint8_t path_hash_size; + uint8_t out_path_len; + uint8_t out_path[MAX_PATH_SIZE]; + char command[128]; + bool has_last_reply; + uint32_t last_reply_ts; + int last_reply_client_idx; + char last_reply[128]; + } _remote_cli; + + void processPendingRemoteCli(); + void sendRemoteCliReply(ClientInfo* client, const uint8_t* secret, uint32_t sender_timestamp, + uint8_t path_hash_size, uint8_t out_path_len, const uint8_t* out_path, + const char* reply); + #if defined(WITH_RS232_BRIDGE) RS232Bridge bridge; #elif defined(WITH_ESPNOW_BRIDGE) From d462495d9f49556657c276ba24b483511266770d Mon Sep 17 00:00:00 2001 From: Ben Allfree Date: Sat, 22 Aug 2026 15:53:13 -0700 Subject: [PATCH 51/59] chore(envyos): bump firmware to 0.1.3 hotfix Co-authored-by: Cursor --- envyos/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/envyos/VERSION b/envyos/VERSION index d917d3e26a..b1e80bb248 100644 --- a/envyos/VERSION +++ b/envyos/VERSION @@ -1 +1 @@ -0.1.2 +0.1.3 From f4758e3f972adcd6d5f0dca67a99369f698aedd4 Mon Sep 17 00:00:00 2001 From: Ben Allfree Date: Sat, 22 Aug 2026 17:12:15 -0700 Subject: [PATCH 52/59] Add firmware build scripts for EnvyOS bench Co-authored-by: Cursor --- scripts/build-mota.sh | 292 ++++++++++++++++++++++++++++++++++++++++++ scripts/targets.txt | 13 ++ scripts/version.sh | 209 ++++++++++++++++++++++++++++++ 3 files changed, 514 insertions(+) create mode 100755 scripts/build-mota.sh create mode 100644 scripts/targets.txt create mode 100644 scripts/version.sh diff --git a/scripts/build-mota.sh b/scripts/build-mota.sh new file mode 100755 index 0000000000..2e0c0b43e8 --- /dev/null +++ b/scripts/build-mota.sh @@ -0,0 +1,292 @@ +#!/usr/bin/env bash +# Build firmware (+ optional .mota) for targets listed in scripts/targets.txt. +# +# Usage: +# ./scripts/build-mota.sh # version from envyos/VERSION +# ./scripts/build-mota.sh v0.1.1 # override version (output + FIRMWARE_VERSION stamp) +# ./scripts/build-mota.sh --target wismesh-tag-repeater +# ./scripts/build-mota.sh v0.1.2 --base v0.1.0 +# ./scripts/build-mota.sh --hex-only +# ./scripts/build-mota.sh --list-targets +# +# Requires: PlatformIO (`pio`), motatool on PATH (or MOTATOOL=). +# From EnvyOS bench: ENVYOS_ROOT=/path/to/envyos sets RELEASED_VERSIONS immutability. + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# shellcheck source=scripts/version.sh +source "$ROOT/scripts/version.sh" + +if [[ -z "${ENVYOS_ROOT:-}" && -f "$ROOT/../ENVYOS_VERSIONS" ]]; then + ENVYOS_ROOT="$(cd "$ROOT/.." && pwd)" + RELEASED_VERSIONS_FILE="$ENVYOS_ROOT/RELEASED_VERSIONS" +fi + +OUT_ROOT="$MOTAS_ROOT" +TARGETS_FILE="$ROOT/scripts/targets.txt" +MC="$ROOT" + +TARGET_SLUGS=() +TARGET_ENVS=() +TARGET_DESCS=() + +usage() { + cat >&2 <]… [--base ] [--hex-only] [--targets-file ] + $0 --list-targets [--targets-file ] + + version Optional override for output dir and -DFIRMWARE_VERSION (default: envyos/VERSION) + --target Build one target slug (repeatable; default: all in targets file) + --base Build delta from one base only (default: all prior versions with base hex) + --hex-only Build hex/uf2 only — skip .mota packaging + --targets-file Target map (default: scripts/targets.txt) + --list-targets Print configured targets and exit + +examples: + $0 + $0 v0.1.1 + $0 --target wismesh-tag-repeater + $0 v0.1.2 --base v0.1.0 + $0 --hex-only +EOF + exit 2 +} + +load_targets() { + local file="$1" + [[ -f "$file" ]] || { + echo "error: targets file not found: $file" >&2 + exit 1 + } + + TARGET_SLUGS=() + TARGET_ENVS=() + TARGET_DESCS=() + + local line slug env desc + while IFS= read -r line || [[ -n "$line" ]]; do + line="${line%%#*}" + line="${line#"${line%%[![:space:]]*}"}" + [[ -n "$line" ]] || continue + + read -r slug env desc <<<"$line" + [[ -n "$slug" && -n "$env" ]] || { + echo "error: bad targets line (want: slug env [description]): $line" >&2 + exit 1 + } + + TARGET_SLUGS+=("$slug") + TARGET_ENVS+=("$env") + TARGET_DESCS+=("${desc:-}") + done <"$file" + + [[ ${#TARGET_SLUGS[@]} -gt 0 ]] || { + echo "error: no targets in $file" >&2 + exit 1 + } +} + +list_targets() { + local file="$1" + load_targets "$file" + local i + printf '%-24s %-36s %s\n' "SLUG" "PLATFORMIO_ENV" "DESCRIPTION" + for i in "${!TARGET_SLUGS[@]}"; do + printf '%-24s %-36s %s\n' "${TARGET_SLUGS[$i]}" "${TARGET_ENVS[$i]}" "${TARGET_DESCS[$i]}" + done +} + +target_index() { + local want="$1" + local i + for i in "${!TARGET_SLUGS[@]}"; do + if [[ "${TARGET_SLUGS[$i]}" == "$want" ]]; then + printf '%s' "$i" + return 0 + fi + done + return 1 +} + +build_target() { + local slug="$1" + local env_name="$2" + local out="$OUT_ROOT/$VER/$slug" + local build_dir="$MC/.pio/build/$env_name" + local mt="" + + echo "==> $VER target=$slug env=$env_name" + + assert_version_not_released "$VER" + rm -rf "$out" + mkdir -p "$out" + + ( + cd "$MC" + pio run -e "$env_name" + pio run -e "$env_name" -t create_uf2 + ) + + local hex="$build_dir/firmware.hex" + local uf2="$build_dir/firmware.uf2" + local zip="$build_dir/firmware.zip" + + [[ -f "$hex" ]] || { echo "error: missing $hex" >&2; exit 1; } + + cp -f "$hex" "$out/firmware.hex" + [[ -f "$uf2" ]] && cp -f "$uf2" "$out/firmware.uf2" + [[ -f "$zip" ]] && cp -f "$zip" "$out/firmware.zip" + write_mota_version_txt "$out" "$VER" "$BUILD_DATE" + + echo " saved $out/firmware.hex (+ uf2/zip if present)" + + if [[ "$HEX_ONLY" -eq 1 ]]; then + echo " (--hex-only: skipping .mota packaging)" + else + mt="$(resolve_motatool)" + echo "==> packaging .mota ($slug) with $mt" + + "$mt" build --fw "$out/firmware.hex" --out-dir "$out" + echo " full .mota → $out/" + + local base_versions=() + if [[ -n "$BASE_VER" ]]; then + base_versions=("$BASE_VER") + else + while IFS= read -r bv || [[ -n "$bv" ]]; do + [[ -n "$bv" ]] || continue + base_versions+=("$bv") + done < <(list_delta_base_versions "$VER") + fi + + local base_ver base_hex delta_out + for base_ver in "${base_versions[@]}"; do + if ! base_hex="$(resolve_base_hex "$slug" "$base_ver")"; then + echo " skip delta $base_ver → $VER ($slug): no base hex" >&2 + continue + fi + delta_out="$out/delta_from_${base_ver}.mota" + echo "==> in-place delta ($slug) $base_ver → $VER" + echo " base: $base_hex" + echo " fw: $out/firmware.hex" + "$mt" build --base "$base_hex" --fw "$out/firmware.hex" --patch-type in-place --out "$delta_out" + echo " delta: $delta_out" + done + fi + + echo "==> done $VER/$slug" + ls -la "$out" +} + +LIST_ONLY=0 +HEX_ONLY=0 +VER="" +VER_EXPLICIT=0 +BASE_VER="" +SELECTED=() +while [[ $# -gt 0 ]]; do + case "$1" in + --hex-only) + HEX_ONLY=1 + shift + ;; + --list-targets) + LIST_ONLY=1 + shift + ;; + --targets-file) + [[ $# -ge 2 ]] || usage + TARGETS_FILE="$2" + shift 2 + ;; + --target) + [[ $# -ge 2 ]] || usage + SELECTED+=("$2") + shift 2 + ;; + --base) + [[ $# -ge 2 ]] || usage + BASE_VER="$(normalize_version "$2")" || usage + shift 2 + ;; + -h | --help) + usage + ;; + -*) + usage + ;; + *) + [[ -z "$VER" ]] || usage + VER="$(normalize_version "$1")" || usage + VER_EXPLICIT=1 + shift + ;; + esac +done + +if [[ "$LIST_ONLY" -eq 1 ]]; then + list_targets "$TARGETS_FILE" + exit 0 +fi + +if [[ -z "$VER" ]]; then + VER="$(read_firmware_version_file)" || usage +fi + +FW_VER="$VER" +if [[ "$VER_EXPLICIT" -eq 0 ]]; then + local_file_ver="$(read_firmware_version_file 2>/dev/null || true)" + if [[ -n "$local_file_ver" && "$local_file_ver" != "$FW_VER" ]]; then + echo "error: envyos/VERSION ($local_file_ver) != build version ($FW_VER)" >&2 + exit 1 + fi +fi + +load_targets "$TARGETS_FILE" + +BUILD_SLUGS=() +BUILD_ENVS=() +if [[ ${#SELECTED[@]} -eq 0 ]]; then + BUILD_SLUGS=("${TARGET_SLUGS[@]}") + BUILD_ENVS=("${TARGET_ENVS[@]}") +else + local_slug="" + local_idx="" + for local_slug in "${SELECTED[@]}"; do + local_idx="$(target_index "$local_slug")" || { + echo "error: unknown target '$local_slug' (see --list-targets)" >&2 + exit 1 + } + BUILD_SLUGS+=("${TARGET_SLUGS[$local_idx]}") + BUILD_ENVS+=("${TARGET_ENVS[$local_idx]}") + done +fi + +OUT="$OUT_ROOT/$VER" +assert_version_not_released "$VER" +if [[ ${#SELECTED[@]} -eq 0 ]]; then + rm -rf "$OUT" +fi +mkdir -p "$OUT" +BUILD_DATE="$(format_firmware_build_date)" +write_mota_version_txt "$OUT" "$VER" "$BUILD_DATE" + +if [[ "$HEX_ONLY" -eq 1 ]]; then + echo "mode: hex-only (no .mota)" +else + MT="$(resolve_motatool)" + echo "motatool: $MT" +fi +echo "version: $VER build: $BUILD_DATE" +echo "targets: ${BUILD_SLUGS[*]}" + +export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS:-} -DFIRMWARE_VERSION='\"${FW_VER#v}\"' -DFIRMWARE_BUILD_DATE='\"${BUILD_DATE}\"'" + +i=0 +for i in "${!BUILD_SLUGS[@]}"; do + build_target "${BUILD_SLUGS[$i]}" "${BUILD_ENVS[$i]}" +done + +echo "==> all done $VER (${#BUILD_SLUGS[@]} target(s))" +ls -la "$OUT" diff --git a/scripts/targets.txt b/scripts/targets.txt new file mode 100644 index 0000000000..eedd9abb87 --- /dev/null +++ b/scripts/targets.txt @@ -0,0 +1,13 @@ +# EnvyOS OTA build targets — read by scripts/build-mota.sh +# +# Format: slug platformio_env [description…] +# slug output dir name under build/motas/// +# platformio_env PlatformIO environment in this repo +# +# Add a line here to ship a new board/role; sync motatool src/targets.rs when the env set changes. +# otafix BOARD= for envyos bootloader builds: envyos/scripts/targets-lib.sh reads this file via --targets-file. + +wismesh-tag-repeater RAK_WisMesh_Tag_repeater WisMesh Tag repeater (OTA bench seeder/DUT) +rak4631-repeater-slim RAK_4631_repeater_slim RAK4631 slim repeater (no OLED/sensors/BLE) +rak4631-superseeder RAK_4631_superseeder RAK4631 slim + SD superseeder (field cache) +wismesh-tag-client-ble RAK_WisMesh_Tag_companion_radio_ble WisMesh Tag companion (BLE) diff --git a/scripts/version.sh b/scripts/version.sh new file mode 100644 index 0000000000..9e2d9ea7c4 --- /dev/null +++ b/scripts/version.sh @@ -0,0 +1,209 @@ +#!/usr/bin/env bash +# Firmware build helpers for meshcore-firmware (envycore). +set -euo pipefail + +FIRMWARE_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BUILD_ROOT="$FIRMWARE_ROOT/build" +MOTAS_ROOT="${MOTAS_ROOT:-$BUILD_ROOT/motas}" +VERSION_FILE="$FIRMWARE_ROOT/envyos/VERSION" +# When built from an EnvyOS bench checkout, immutability follows the distro manifest. +RELEASED_VERSIONS_FILE="${RELEASED_VERSIONS_FILE:-${ENVYOS_ROOT:+$ENVYOS_ROOT/RELEASED_VERSIONS}}" +RELEASED_VERSIONS_FILE="${RELEASED_VERSIONS_FILE:-$FIRMWARE_ROOT/RELEASED_VERSIONS}" + +normalize_version() { + local v="${1#v}" + if [[ ! "$v" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "error: invalid version '$1' (want vMAJOR.MINOR.PATCH)" >&2 + return 1 + fi + printf 'v%s' "$v" +} + +read_firmware_version_file() { + [[ -f "$VERSION_FILE" ]] || { + echo "error: missing $VERSION_FILE" >&2 + return 1 + } + normalize_version "$(tr -d '[:space:]' <"$VERSION_FILE")" +} + +format_firmware_build_date() { + local d + d="$(LC_TIME=C date '+%d %b %Y')" + printf '%s' "${d#0}" +} + +write_mota_version_txt() { + local dir=$1 ver=$2 build_date=$3 + printf '%s\n%s\n' "$ver" "$build_date" >"$dir/version.txt" +} + +resolve_motatool() { + if [[ -n "${MOTATOOL:-}" && -x "$MOTATOOL" ]]; then + printf '%s\n' "$MOTATOOL" + return 0 + fi + if command -v motatool >/dev/null 2>&1; then + command -v motatool + return 0 + fi + echo "error: motatool not on PATH (install from MeshEnvy/motatool releases or set MOTATOOL=)" >&2 + return 1 +} + +parse_version() { + local v="${1#v}" + local major minor patch + IFS=. read -r major minor patch <<<"$v" + printf '%s %s %s' "$major" "$minor" "$patch" +} + +is_released_version() { + local ver line + ver="$(normalize_version "$1")" || return 1 + [[ -f "$RELEASED_VERSIONS_FILE" ]] || return 1 + while IFS= read -r line || [[ -n "$line" ]]; do + line="${line%%#*}" + line="${line#"${line%%[![:space:]]*}"}" + line="${line%"${line##*[![:space:]]}"}" + [[ -n "$line" ]] || continue + if [[ "$(normalize_version "$line")" == "$ver" ]]; then + return 0 + fi + done <"$RELEASED_VERSIONS_FILE" + return 1 +} + +assert_version_not_released() { + local ver="$1" + if is_released_version "$ver"; then + echo "error: $ver is released — $MOTAS_ROOT/$ver/ is immutable" >&2 + exit 1 + fi +} + +version_sort_key() { + local major minor patch + read -r major minor patch <<<"$(parse_version "$1")" + printf '%03d.%03d.%03d' "$major" "$minor" "$patch" +} + +sort_versions() { + local v seen="" + for v in "$@"; do + case "$seen" in + *"|$v|"*) continue ;; + esac + seen="${seen}|$v|" + printf '%s\t%s\n' "$(version_sort_key "$v")" "$v" + done | sort -t $'\t' -k1,1 | cut -f2- +} + +version_lt() { + local a b + a="$(normalize_version "$1")" || return 1 + b="$(normalize_version "$2")" || return 1 + local am aj ap bm bj bp + read -r am aj ap <<<"$(parse_version "$a")" + read -r bm bj bp <<<"$(parse_version "$b")" + if (( am != bm )); then + (( am < bm )) + return + fi + if (( aj != bj )); then + (( aj < bj )) + return + fi + (( ap < bp )) +} + +list_known_mota_versions() { + local line ver d + local tmp=() + if [[ -f "$RELEASED_VERSIONS_FILE" ]]; then + while IFS= read -r line || [[ -n "$line" ]]; do + line="${line%%#*}" + line="${line#"${line%%[![:space:]]*}"}" + line="${line%"${line##*[![:space:]]}"}" + [[ -n "$line" ]] || continue + ver="$(normalize_version "$line" 2>/dev/null)" || continue + tmp+=("$ver") + done <"$RELEASED_VERSIONS_FILE" + fi + if [[ -d "$MOTAS_ROOT" ]]; then + for d in "$MOTAS_ROOT"/v[0-9]*.[0-9]*.[0-9]*; do + [[ -d "$d" ]] || continue + ver="$(normalize_version "$(basename "$d")" 2>/dev/null)" || continue + tmp+=("$ver") + done + fi + if [[ ${#tmp[@]} -eq 0 ]]; then + return 0 + fi + sort_versions "${tmp[@]}" +} + +list_delta_base_versions() { + local target ver + target="$(normalize_version "$1")" || return 1 + while IFS= read -r ver || [[ -n "$ver" ]]; do + [[ -n "$ver" ]] || continue + if version_lt "$ver" "$target"; then + printf '%s\n' "$ver" + fi + done < <(list_known_mota_versions) +} + +resolve_base_hex() { + local slug="$1" + local base_ver="$2" + local candidates=( + "$MOTAS_ROOT/$base_ver/$slug/firmware.hex" + ) + if [[ "$slug" == "wismesh-tag-repeater" ]]; then + candidates+=( + "$MOTAS_ROOT/$base_ver/repeater/firmware.hex" + "$MOTAS_ROOT/$base_ver/firmware.hex" + ) + fi + local p + for p in "${candidates[@]}"; do + if [[ -f "$p" ]]; then + printf '%s' "$p" + return 0 + fi + done + return 1 +} + +verify_release_delta_matrix() { + local ver="$1" + local targets_file="$2" + local line slug base_ver delta base_hex + local missing=0 + + [[ -f "$targets_file" ]] || { + echo "error: targets file not found: $targets_file" >&2 + return 1 + } + + while IFS= read -r line || [[ -n "$line" ]]; do + line="${line%%#*}" + line="${line#"${line%%[![:space:]]*}"}" + [[ -n "$line" ]] || continue + read -r slug _ <<<"$line" + [[ -n "$slug" ]] || continue + + while IFS= read -r base_ver || [[ -n "$base_ver" ]]; do + [[ -n "$base_ver" ]] || continue + resolve_base_hex "$slug" "$base_ver" >/dev/null || continue + delta="$MOTAS_ROOT/$ver/$slug/delta_from_${base_ver}.mota" + if [[ ! -f "$delta" ]]; then + echo "error: missing $delta (base hex exists for $base_ver/$slug)" >&2 + missing=1 + fi + done < <(list_delta_base_versions "$ver") + done <"$targets_file" + + [[ "$missing" -eq 0 ]] +} From 7e4ff35ba4c61cde3afc27a9fff7361e9c68082a Mon Sep 17 00:00:00 2001 From: Ben Allfree Date: Sat, 22 Aug 2026 17:14:05 -0700 Subject: [PATCH 53/59] Add build scripts and dist gitignore Co-authored-by: Cursor --- .gitignore | 1 + scripts/version.sh | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/.gitignore b/.gitignore index 4b13566454..05f5a32ad0 100644 --- a/.gitignore +++ b/.gitignore @@ -17,5 +17,6 @@ compile_commands.json .venv/ venv/ platformio.local.ini +build/ __pycache__/ diff --git a/scripts/version.sh b/scripts/version.sh index 9e2d9ea7c4..098586a3d9 100644 --- a/scripts/version.sh +++ b/scripts/version.sh @@ -47,6 +47,24 @@ resolve_motatool() { command -v motatool return 0 fi + local motatool_root ver cache bin + motatool_root="${MOTATOOL_ROOT:-${ENVYOS_ROOT:+$(cd "$ENVYOS_ROOT/../.." && pwd)/motatool}}" + if [[ -n "$motatool_root" && -f "${ENVYOS_ROOT:-}/ENVYOS_VERSIONS" ]]; then + ver="$(grep '^motatool=' "${ENVYOS_ROOT}/ENVYOS_VERSIONS" | head -1 | cut -d= -f2 | tr -d '[:space:]')" + if [[ -n "$ver" ]]; then + ver="v${ver#v}" + cache="$motatool_root/dist/$ver" + bin="$cache/motatool-$(uname -s | tr '[:upper:]' '[:lower:]')-$(uname -m)" + [[ -x "$bin" ]] && { + printf '%s\n' "$bin" + return 0 + } + fi + fi + if [[ -n "$motatool_root" && -x "$motatool_root/target/release/motatool" ]]; then + printf '%s\n' "$motatool_root/target/release/motatool" + return 0 + fi echo "error: motatool not on PATH (install from MeshEnvy/motatool releases or set MOTATOOL=)" >&2 return 1 } From fa8f68fdf7924e685af55dcd316ae94961fbd588 Mon Sep 17 00:00:00 2001 From: Ben Allfree Date: Sun, 9 Aug 2026 05:39:06 -0400 Subject: [PATCH 54/59] chore(freshen): update lock for companion-v1.17.0 Co-authored-by: Cursor --- FRESHEN.lock | 13 +++++++++---- scripts/cherry-pick-envyos-overlay.sh | 6 ++++-- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/FRESHEN.lock b/FRESHEN.lock index c1f96caaa5..4f2e660108 100644 --- a/FRESHEN.lock +++ b/FRESHEN.lock @@ -4,8 +4,8 @@ # See .cursor/skills/envyos-freshen/SKILL.md. mode: release -companion_tag: companion-v1.16.0 -companion_sha: 07a3ca9e +companion_tag: companion-v1.17.0 +companion_sha: 727fc051 ota_commits: - 07b20f04 # ota: .mota container format, merkle, EndF - 6b4f2355 # ota: detools decoder @@ -53,5 +53,10 @@ overlay_commits: - 2e7d5a61 # payload log labels - 899ba168 # SD superseeder - 0e70236c # hop retry default off -last_freshen: 2026-07-27 -notes: envyos/main reset from companion-v1.16.0; OTA cherry-picked from vk496/feature/ota-lora (not merged). Backup branch envyos/main-pre-companion-reset. + - 481c9aa6 # sensecap slim repeater OTA env + - 502b2e9e # NOR/SD superseeder allow CLI +last_freshen: 2026-08-09 +notes: >- + envyos/main merged onto companion-v1.17.0 (JSON ConfigSerializer base). + OTA prefs adapted to OtaPrefs nested serializer; hop retry uses v1.17 wasSeen/markSeen. + Cherry-pick script retained for vk496 OTA stack replay; overlay applied via merge for v1.17. diff --git a/scripts/cherry-pick-envyos-overlay.sh b/scripts/cherry-pick-envyos-overlay.sh index efe122a972..0084da87a3 100755 --- a/scripts/cherry-pick-envyos-overlay.sh +++ b/scripts/cherry-pick-envyos-overlay.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Cherry-pick vk496 OTA commits + EnvyOS overlay onto companion-v1.16.0. -# Run from envycore/ after: git reset --hard companion-v1.16.0 +# Cherry-pick vk496 OTA commits + EnvyOS overlay onto companion-v1.17.0. +# Run from envycore/ after: git reset --hard companion-v1.17.0 set -euo pipefail COMMITS=( @@ -51,6 +51,8 @@ COMMITS=( 2e7d5a61 899ba168 0e70236c + 481c9aa6 + 502b2e9e ) for sha in "${COMMITS[@]}"; do From 1689985cb39a39982d1d94f0917c340aa453ab49 Mon Sep 17 00:00:00 2001 From: Ben Allfree Date: Sun, 9 Aug 2026 06:13:25 -0400 Subject: [PATCH 55/59] fix(native): ConfigSerializer and Packet test builds Co-authored-by: Cursor --- src/Packet.cpp | 2 +- src/helpers/ConfigSerializer.cpp | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Packet.cpp b/src/Packet.cpp index f55af06f7b..24e00455a8 100644 --- a/src/Packet.cpp +++ b/src/Packet.cpp @@ -83,7 +83,7 @@ void Packet::calculatePacketHash(uint8_t* hash) const { uint8_t t = getPayloadType(); sha.update(&t, 1); if (t == PAYLOAD_TYPE_TRACE) { - sha.update(&path_len, sizeof(path_len)); // CAVEAT: TRACE packets can revisit same node on return path + sha.update(reinterpret_cast(&path_len), sizeof(path_len)); // CAVEAT: TRACE packets can revisit same node on return path } sha.update(payload, payload_len); sha.finalize(hash, MAX_HASH_SIZE); diff --git a/src/helpers/ConfigSerializer.cpp b/src/helpers/ConfigSerializer.cpp index adff147f47..16d8855d74 100644 --- a/src/helpers/ConfigSerializer.cpp +++ b/src/helpers/ConfigSerializer.cpp @@ -1,4 +1,5 @@ #include "ConfigSerializer.h" +#include bool ConfigSerializer::saveSerial(Stream& s) { Context context(&s, OP::WRITE); From f797dfcf4e47da30cf95f9df0b778227cd77956b Mon Sep 17 00:00:00 2001 From: Ben Allfree Date: Wed, 19 Aug 2026 21:21:58 -0700 Subject: [PATCH 56/59] feat(cli): doctor fs, gc, atomic prefs save Co-authored-by: Cursor --- .../src/Adafruit_LittleFS.cpp | 5 + .../src/Adafruit_LittleFS_File.cpp | 7 +- docs/envyos_cli_extensions.md | 232 ++++ envyos/CHANGELOG.md | 60 ++ examples/companion_radio/DataStore.cpp | 174 +-- examples/simple_repeater/MyMesh.cpp | 12 + examples/simple_repeater/MyMesh.h | 2 + examples/simple_room_server/MyMesh.cpp | 12 + examples/simple_room_server/MyMesh.h | 2 + examples/simple_sensor/SensorMesh.cpp | 12 + examples/simple_sensor/SensorMesh.h | 2 + platformio.ini | 10 +- src/helpers/ClientACL.cpp | 59 +- src/helpers/CommonCLI.cpp | 993 ++++++++++++++++-- src/helpers/CommonCLI.h | 18 +- src/helpers/ConfigSerializer.cpp | 93 ++ src/helpers/ConfigSerializer.h | 12 + src/helpers/FsLastErr.cpp | 105 ++ src/helpers/FsLastErr.h | 21 + src/helpers/RegionMap.cpp | 67 +- src/helpers/RegionMap.h | 2 + variants/rak4631/platformio.ini | 12 + variants/rak_wismesh_tag/platformio.ini | 6 + variants/sensecap_solar/platformio.ini | 12 + 24 files changed, 1676 insertions(+), 254 deletions(-) create mode 100644 docs/envyos_cli_extensions.md create mode 100644 envyos/CHANGELOG.md create mode 100644 src/helpers/FsLastErr.cpp create mode 100644 src/helpers/FsLastErr.h diff --git a/arch/stm32/Adafruit_LittleFS_stm32/src/Adafruit_LittleFS.cpp b/arch/stm32/Adafruit_LittleFS_stm32/src/Adafruit_LittleFS.cpp index 0c9c97b5b9..d1954bd86d 100644 --- a/arch/stm32/Adafruit_LittleFS_stm32/src/Adafruit_LittleFS.cpp +++ b/arch/stm32/Adafruit_LittleFS_stm32/src/Adafruit_LittleFS.cpp @@ -25,6 +25,7 @@ #include #include #include "Adafruit_LittleFS.h" +#include //#include // for Serial @@ -193,6 +194,7 @@ bool Adafruit_LittleFS::remove (char const *filepath) _lockFS(); int err = lfs_remove(&_lfs, filepath); + if (err != LFS_ERR_OK) fsLastErrSet(err); PRINT_LFS_ERR(err); _unlockFS(); @@ -205,6 +207,7 @@ bool Adafruit_LittleFS::rename (char const *oldfilepath, char const *newfilepath _lockFS(); int err = lfs_rename(&_lfs, oldfilepath, newfilepath); + if (err != LFS_ERR_OK) fsLastErrSet(err); PRINT_LFS_ERR(err); _unlockFS(); @@ -217,6 +220,7 @@ bool Adafruit_LittleFS::rmdir (char const *filepath) _lockFS(); int err = lfs_remove(&_lfs, filepath); + if (err != LFS_ERR_OK) fsLastErrSet(err); PRINT_LFS_ERR(err); _unlockFS(); @@ -235,6 +239,7 @@ bool Adafruit_LittleFS::rmdir_r (char const *filepath) _lockFS(); int err = lfs_remove(&_lfs, filepath); + if (err != LFS_ERR_OK) fsLastErrSet(err); PRINT_LFS_ERR(err); _unlockFS(); diff --git a/arch/stm32/Adafruit_LittleFS_stm32/src/Adafruit_LittleFS_File.cpp b/arch/stm32/Adafruit_LittleFS_stm32/src/Adafruit_LittleFS_File.cpp index 41ba35706e..aadb696c6f 100644 --- a/arch/stm32/Adafruit_LittleFS_stm32/src/Adafruit_LittleFS_File.cpp +++ b/arch/stm32/Adafruit_LittleFS_stm32/src/Adafruit_LittleFS_File.cpp @@ -25,6 +25,7 @@ #include #include "Adafruit_LittleFS.h" #include "littlefs/lfs.h" +#include //--------------------------------------------------------------------+ // MACRO TYPEDEF CONSTANT ENUM DECLARATION @@ -66,6 +67,7 @@ bool File::_open_file (char const *filepath, uint8_t mode) if ( rc ) { // failed to open + fsLastErrSet(rc); PRINT_LFS_ERR(rc); // free memory free(_file); @@ -92,6 +94,7 @@ bool File::_open_dir (char const *filepath) if ( rc ) { // failed to open + fsLastErrSet(rc); PRINT_LFS_ERR(rc); // free memory free(_dir); @@ -167,6 +170,7 @@ size_t File::write (uint8_t const *buf, size_t size) wrcount = lfs_file_write(_fs->_getFS(), _file, buf, size); if (wrcount < 0) { + fsLastErrSet((int) wrcount); wrcount = 0; } } @@ -340,7 +344,8 @@ void File::_close(void) } else { - lfs_file_close(this->_fs->_getFS(), _file); + int rc = lfs_file_close(this->_fs->_getFS(), _file); + if (rc != 0) fsLastErrSet(rc); free(_file); _file = NULL; } diff --git a/docs/envyos_cli_extensions.md b/docs/envyos_cli_extensions.md new file mode 100644 index 0000000000..ccefccd6de --- /dev/null +++ b/docs/envyos_cli_extensions.md @@ -0,0 +1,232 @@ +# EnvyOS CLI extensions + +EnvyOS builds on [MeshCore CLI commands](./cli_commands.md) (companion tag **v1.17.0** in v0.2.0). This page documents **additional or changed** serial/remote admin commands in the MeshEnvy overlay. + +**Scope:** repeater, room server, and sensor firmware built from the EnvyOS `envycore` fork. OTA subcommands are documented in [OTA user guide](./ota_user_guide.md) and [OTA protocol](./ota_protocol.md). + +**Serial-only** commands require USB (or TCP console where compiled). They return `ERR … requires USB` over remote admin. + +--- + +## Navigation + +- [Filesystem doctor](#filesystem-doctor) +- [Filesystem garbage collection](#filesystem-garbage-collection) +- [Logging (serial mirror)](#logging-serial-mirror) +- [Prefs save errors](#prefs-save-errors) +- [Routing (next-hop retry)](#routing-next-hop-retry) +- [Bench debug builds](#bench-debug-builds) + +--- + +## Filesystem doctor + +Wedged **InternalFS** (nRF52/STM32) or full SPIFFS can make `set name`, `set radio`, ACL, and region saves fail silently or return generic errors. The `doctor` commands diagnose and recover without re-flashing when possible. + +Typical workflow on a full partition: + +``` +doctor fs stat +doctor fs ls +doctor gc +doctor fs stat +set name MyNode +reboot +``` + +If `doctor gc` cannot free enough space, use `doctor fs fix` (non-destructive attempt) or `doctor fs format` (wipes FS, rebuilds prefs/identity/ACL/regions from RAM). + +### `doctor fs check` + +**Usage:** `doctor fs check` + +Tests whether the current in-RAM prefs can be written atomically to flash (writes to `/.doctor_prefs.json`, then removes it). Does **not** change running prefs. + +**Reply examples:** + +- `OK prefs_writeable prefs=1 id=1 acl=0 regions=0` +- `ERR no space left on device (try: doctor gc)` +- `ERR prefs write failed lfs=-28 (try: doctor fs fix)` + +Works over remote admin. + +--- + +### `doctor fs fix` + +**Usage:** `doctor fs fix` + +Tries a normal prefs save first. If that fails, **formats InternalFS and rebuilds** identity, prefs, ACL, and regions from RAM. + +**Reply examples:** + +- `OK prefs repaired` +- `OK fs rebuilt from RAM` +- `ERR format failed` / `ERR remount failed` / prefs error string (see [Prefs save errors](#prefs-save-errors)) + +Works over remote admin. + +--- + +### `doctor fs format` + +**Usage:** `doctor fs format` + +Same rebuild path as `fix`, but always formats first. Destructive to flash contents not held in RAM. + +**Reply:** `OK fs formatted from RAM` or an error. + +Works over remote admin. + +--- + +### `doctor fs stat` + +**Usage:** `doctor fs stat` + +**Serial only.** Prints partition summary lines prefixed `FS_STAT` on serial, then a one-line summary reply. + +**Serial output (nRF52 InternalFS example):** + +``` +FS_STAT begin +FS_STAT total 28672 +FS_STAT used~ 28544 free~ 128 +FS_STAT blocks 224 used 223 bsize 128 +FS_STAT file /prefs.json 0 +FS_STAT file /_main.id 96 +... +FS_STAT end +``` + +**Reply:** `OK free~=128/28672 blk=223/224` + +--- + +### `doctor fs ls` + +**Usage:** `doctor fs ls` + +**Serial only.** Recursive listing; lines prefixed `FS_LS` on serial. + +**Reply:** `OK see serial FS_LS` + +Use to find large cruft (`packet_log`, legacy Meshtastic `prefs/`, `com_prefs`, etc.). + +--- + +### `doctor fs probe` + +**Usage:** `doctor fs probe` + +**Serial only.** Writes temporary probe files at increasing sizes (1 B through 4096 B), then runs the same prefs JSON write test as `check`. Lines prefixed `FS_PROBE` on serial. + +**Reply examples:** + +- `OK raw_max=512 prefs=ok` +- `OK raw_max=128 fail>=256@write prefs=fail` + +--- + +### `doctor fs dump` + +**Usage:** `doctor fs dump` + +**Serial only.** Hex-dumps the raw flash region backing InternalFS (`FS_DUMP` lines). For deep corruption analysis; not needed for normal recovery. + +**Reply:** `OK dumped N bytes` + +--- + +## Filesystem garbage collection + +### `doctor gc` + +**Usage:** `doctor gc` + +Removes reclaimable files that commonly fill InternalFS: + +| Path | Reason | +|------|--------| +| `/packet_log` | Uncapped RX log from `log start` | +| `/com_prefs` | Legacy prefs path | +| `/prefs/` tree | Meshtastic protobuf leftovers | +| `/.doctor_*` | Doctor probe/check temp files | + +**Reply:** `OK gc removed N item(s)` + +Works over remote admin. Run before `set …` when `doctor fs stat` shows the partition nearly full. + +--- + +## Logging (serial mirror) + +Stock MeshCore captures packets to `/packet_log` with `log start` / `log stop` but only dumps the file over serial with bare `log`. EnvyOS adds live mirror (upstream PR [#2991](https://github.com/meshcore-dev/MeshCore/pull/2991)): + +| Command | Description | +|---------|-------------| +| `log tail on` | Stream captured log lines to USB serial as they arrive; starts logging if inactive | +| `log tail off` | Stop serial mirror (logging may continue to file) | + +**Serial only.** Field `-debug` repeater builds enable this at boot; production slims do not. + +--- + +## Prefs save errors + +Atomic saves (`writeFileAtomic`: temp file + rename) apply to `/prefs.json`, `/s_contacts`, `/regions2`, and companion contact/channel blobs. + +When a `set …` or `password …` save fails, replies are specific instead of a generic write failure: + +| Condition | Reply | +|-----------|--------| +| Partition critically full (≤2 free blocks on InternalFS) | `ERR no space left on device (try: doctor gc)` | +| LittleFS returned NOSPC | Same as above | +| Other LFS error | `ERR prefs failed lfs=-NN (try: doctor fs fix)` | +| JSON serialize failure | `ERR prefs serialize failed (try: doctor fs fix)` | + +Stages: `open`, `write`, `rename`, `serialize`, `nospc`. + +Debug builds log `savePrefs: end ok=0 stage=… lfs_err=…` on serial. + +--- + +## Routing (next-hop retry) + +EnvyOS ships [next-hop retry](https://github.com/meshcore-dev/MeshCore/pull/2980) (opt-in, default off): + +| Command | Description | +|---------|-------------| +| `get hop.retry` / `set hop.retry <0-5>` | Extra direct-path retransmits if next-hop echo/HOP_ACK missing | +| `get hop.retry.ms` / `set hop.retry.ms <200-10000>` | Listen window before each retry (ms) | +| `get hop.ignore` / `set hop.ignore <0-255>` | **Test hook:** drop next N direct forwards (not persisted) | + +Documented in [CLI commands § Routing](./cli_commands.md#view-or-change-next-hop-retry-for-direct-path-relays). + +--- + +## Bench debug builds + +EnvyOS `-debug` repeater/superseeder twins (e.g. `wismesh-tag-repeater-debug`) differ from field slugs: + +- `ADMIN_DEBUG` / `OTA_DEBUG` enabled at boot +- `log tail` available without extra flags +- Separate MOTA target id (do not deploy to field nodes) + +Use field slugs for production; debug twins for bench, OTA, and FS troubleshooting. + +--- + +## Upstream notes + +See MeshEnvy `docs/upstream-prs.md` in the ota repo for PR tracking. Sensible upstream targets: + +| Change | Upstream? | +|--------|-----------| +| Atomic prefs save | **Yes** — `meshcore-dev/MeshCore` `dev` | +| `log tail` | **Yes** — PR open | +| Next-hop retry | **Yes** — PR open | +| `doctor fs check` (prefs write probe only) | **Maybe** — lighter subset without dump/probe/gc | +| Full `doctor fs` + `doctor gc` | **EnvyOS-only** — bench/ops tooling | +| Adafruit LFS `fsLastErr` hooks | **EnvyOS-only** — vendor fork patch | +| Packet log size cap | **Yes** — recommended future MeshCore fix (not in v0.2.0) | diff --git a/envyos/CHANGELOG.md b/envyos/CHANGELOG.md new file mode 100644 index 0000000000..33c202fb58 --- /dev/null +++ b/envyos/CHANGELOG.md @@ -0,0 +1,60 @@ +# Changelog — EnvyOS firmware + +EnvyOS-owned firmware changes (the MeshEnvy overlay on MeshCore in `envycore/`). MeshCore base bumps link the upstream companion tag; do not copy upstream notes here. + +Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versions match `ENVYOS_VERSIONS` `firmware=` / `envycore/envyos/VERSION` and `build/firmware//` trees. Policy: ota repo `docs/change-management.md`. + +## [Unreleased] + +### Added + +- Bench debug twins of each shipped repeater/superseeder (`-debug`): log tail, OTA, and admin serial on boot; separate MOTA target id. +- `doctor fs check|fix|format|dump|stat|ls|probe` and `doctor gc` for wedged InternalFS; `LFS_ERR_NOSPC` surfaces as `ERR no space left on device (try: doctor gc)`. +- Atomic prefs save (`saveConfigJsonAtomic` / `writeFileAtomic`): prefs, ACL, regions, companion contacts/channels/blobs. +- Fail-fast when InternalFS is critically full (avoids multi-second LittleFS alloc retries before NOSPC). +- Docs: `docs/envyos_cli_extensions.md` (doctor, gc, prefs errors, debug twins). + +### Changed + +- OTA self-serve disabled (`OTA_SELF_SERVE=0`); nodes no longer hash/serve their running firmware. **Remove self-serve code in v0.3.0.** +- OTA self-serve merkle no longer starts at boot; repeaters stay quiet until `ota announce` / `ota folder on`. +- Field `rak4631-repeater-slim` no longer enables log tail / OTA_DEBUG / ADMIN_DEBUG at boot (use the `-debug` twin). + +## [0.2.0] - 2026-08-14 + +Targets EnvyOS distro **v0.2.0** (in progress — not yet on [GitHub Releases](https://github.com/MeshEnvy/envyos/releases)). Accumulated during internal v0.1.3 dev. MeshCore base: [companion-v1.17.0](https://github.com/meshcore-dev/MeshCore/releases/tag/companion-v1.17.0). + +### Fixed + +- Remote admin + Send Advert lockup on RAK4631 slim (RX-path stack overflow); remote CLI deferred off the packet RX handler. +- nRF52 watchdog gate blocks only mota-apply bootloaders lacking `MOTA_BL_FEAT_WDT_FEED`. + +### Added + +- SenseCAP P1-Pro NOR mini-superseeder (`sensecap-p1pro-superseeder`). +- nRF52 repeater hardware WDT (30 s default, prefs + CLI); companions excluded. +- `ver` stamp includes envycore SHA and UTC build date. + +### Improved + +- OTA self-serve merkle on nRF52 (EndF RAM cache, chunked merkle). + +## [0.1.2] - 2026-08-03 + +Shipped with EnvyOS distro v0.1.2. Pin bump only (same EnvyOS overlay as 0.1.1). + +## [0.1.1] - 2026-08-03 + +Shipped with EnvyOS distro v0.1.1. Pin refresh (same-day follow-up to 0.1.0). + +## [0.1.0] - 2026-08-03 + +First fleet firmware. Full history in ota repo `CHANGELOG.md` v0.1.0 (predates this file). + +### Added + +- LoRa OTA (`.mota` full + detools deltas, seeder / superseeder roles). +- Next-hop retry for direct-path relays (`hop.retry`, default off). +- Serial `log tail` on repeaters. +- Slim repeater roles (RAK4631, SenseCAP P1-Pro) and SD superseeder. +- Companion boot fsck for corrupt LittleFS. diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 79134007ad..e3a862d6e3 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -1,5 +1,6 @@ #include #include "DataStore.h" +#include #if defined(EXTRAFS) || defined(QSPIFLASH) #define MAX_BLOBRECS 100 @@ -31,7 +32,8 @@ DataStore::DataStore(FILESYSTEM& fs, FILESYSTEM& fsExtra, mesh::RTCClock& clock) } #endif -static File openWrite(FILESYSTEM* fs, const char* filename) { +// One-time migration into an empty destination FS only. +static File migrateOpenWrite(FILESYSTEM* fs, const char* filename) { #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) fs->remove(filename); return fs->open(filename, FILE_O_WRITE); @@ -295,13 +297,7 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs) { } bool DataStore::savePrefs(NodePrefs& _prefs) { - File file = openWrite(_fs, "/prefs.json"); - if (file) { - bool success = _prefs.saveSerial(file); - file.close(); - return success; - } - return false; + return saveConfigJsonAtomic(_fs, _prefs, "/prefs.json", "/.prefs.json.new"); } void DataStore::loadContacts(DataStoreHost* host) { @@ -335,37 +331,43 @@ File file = openRead(_getContactsChannelsFS(), "/contacts3"); } } -void DataStore::saveContacts(DataStoreHost* host, bool (*filter)(const ContactInfo& c)) { - File file = openWrite(_getContactsChannelsFS(), "/contacts3"); - if (file) { - uint32_t idx = 0; - ContactInfo c; - uint8_t unused = 0; - - while (host->getContactForSave(idx, c)) { - if (filter && !filter(c)) { - idx++; // advance to next contact - continue; - } - bool success = (file.write(c.id.pub_key, 32) == 32); - success = success && (file.write((uint8_t *)&c.name, 32) == 32); - success = success && (file.write(&c.type, 1) == 1); - success = success && (file.write(&c.flags, 1) == 1); - success = success && (file.write(&unused, 1) == 1); - success = success && (file.write((uint8_t *)&c.sync_since, 4) == 4); - success = success && (file.write((uint8_t *)&c.out_path_len, 1) == 1); - success = success && (file.write((uint8_t *)&c.last_advert_timestamp, 4) == 4); - success = success && (file.write(c.out_path, 64) == 64); - success = success && (file.write((uint8_t *)&c.lastmod, 4) == 4); - success = success && (file.write((uint8_t *)&c.gps_lat, 4) == 4); - success = success && (file.write((uint8_t *)&c.gps_lon, 4) == 4); - - if (!success) break; // write failed - - idx++; // advance to next contact +struct SaveContactsCtx { + DataStoreHost* host; + bool (*filter)(const ContactInfo& c); +}; + +static bool writeContactsBody(File& file, void* ctx) { + SaveContactsCtx* c = (SaveContactsCtx*) ctx; + uint32_t idx = 0; + ContactInfo contact; + uint8_t unused = 0; + + while (c->host->getContactForSave(idx, contact)) { + if (c->filter && !c->filter(contact)) { + idx++; + continue; } - file.close(); + bool success = (file.write(contact.id.pub_key, 32) == 32); + success = success && (file.write((uint8_t*) &contact.name, 32) == 32); + success = success && (file.write(&contact.type, 1) == 1); + success = success && (file.write(&contact.flags, 1) == 1); + success = success && (file.write(&unused, 1) == 1); + success = success && (file.write((uint8_t*) &contact.sync_since, 4) == 4); + success = success && (file.write((uint8_t*) &contact.out_path_len, 1) == 1); + success = success && (file.write((uint8_t*) &contact.last_advert_timestamp, 4) == 4); + success = success && (file.write(contact.out_path, 64) == 64); + success = success && (file.write((uint8_t*) &contact.lastmod, 4) == 4); + success = success && (file.write((uint8_t*) &contact.gps_lat, 4) == 4); + success = success && (file.write((uint8_t*) &contact.gps_lon, 4) == 4); + if (!success) return false; + idx++; } + return true; +} + +void DataStore::saveContacts(DataStoreHost* host, bool (*filter)(const ContactInfo& c)) { + SaveContactsCtx ctx = {host, filter}; + writeFileAtomic(_getContactsChannelsFS(), "/contacts3", "/.contacts3.new", writeContactsBody, &ctx); } void DataStore::loadChannels(DataStoreHost* host) { @@ -393,24 +395,30 @@ void DataStore::loadChannels(DataStoreHost* host) { } } -void DataStore::saveChannels(DataStoreHost* host) { - File file = openWrite(_getContactsChannelsFS(), "/channels2"); - if (file) { - uint8_t channel_idx = 0; - ChannelDetails ch; - uint8_t unused[4]; - memset(unused, 0, 4); - - while (host->getChannelForSave(channel_idx, ch)) { - bool success = (file.write(unused, 4) == 4); - success = success && (file.write((uint8_t *)ch.name, 32) == 32); - success = success && (file.write((uint8_t *)ch.channel.secret, 32) == 32); - - if (!success) break; // write failed - channel_idx++; - } - file.close(); +struct SaveChannelsCtx { + DataStoreHost* host; +}; + +static bool writeChannelsBody(File& file, void* ctx) { + SaveChannelsCtx* c = (SaveChannelsCtx*) ctx; + uint8_t channel_idx = 0; + ChannelDetails ch; + uint8_t unused[4]; + memset(unused, 0, 4); + + while (c->host->getChannelForSave(channel_idx, ch)) { + bool success = (file.write(unused, 4) == 4); + success = success && (file.write((uint8_t*) ch.name, 32) == 32); + success = success && (file.write((uint8_t*) ch.channel.secret, 32) == 32); + if (!success) return false; + channel_idx++; } + return true; +} + +void DataStore::saveChannels(DataStoreHost* host) { + SaveChannelsCtx ctx = {host}; + writeFileAtomic(_getContactsChannelsFS(), "/channels2", "/.channels2.new", writeChannelsBody, &ctx); } #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) @@ -424,17 +432,24 @@ struct BlobRec { uint8_t data[MAX_ADVERT_PKT_LEN]; }; +struct InitAdvBlobsCtx { + int max_recs; +}; + +static bool writeAdvBlobsInitBody(File& file, void* ctx) { + InitAdvBlobsCtx* c = (InitAdvBlobsCtx*) ctx; + BlobRec zeroes; + memset(&zeroes, 0, sizeof(zeroes)); + for (int i = 0; i < c->max_recs; i++) { + if (file.write((uint8_t*) &zeroes, sizeof(zeroes)) != sizeof(zeroes)) return false; + } + return true; +} + void DataStore::checkAdvBlobFile() { if (!_getContactsChannelsFS()->exists("/adv_blobs")) { - File file = openWrite(_getContactsChannelsFS(), "/adv_blobs"); - if (file) { - BlobRec zeroes; - memset(&zeroes, 0, sizeof(zeroes)); - for (int i = 0; i < MAX_BLOBRECS; i++) { // pre-allocate to fixed size - file.write((uint8_t *) &zeroes, sizeof(zeroes)); - } - file.close(); - } + InitAdvBlobsCtx ctx = {MAX_BLOBRECS}; + writeFileAtomic(_getContactsChannelsFS(), "/adv_blobs", "/.adv_blobs.new", writeAdvBlobsInitBody, &ctx); } } @@ -443,7 +458,7 @@ void DataStore::migrateToSecondaryFS() { if (!_fsExtra->exists("/adv_blobs")) { if (_fs->exists("/adv_blobs")) { File oldAdvBlobs = openRead(_fs, "/adv_blobs"); - File newAdvBlobs = openWrite(_fsExtra, "/adv_blobs"); + File newAdvBlobs = migrateOpenWrite(_fsExtra, "/adv_blobs"); if (oldAdvBlobs && newAdvBlobs) { BlobRec rec; @@ -464,7 +479,7 @@ void DataStore::migrateToSecondaryFS() { if (!_fsExtra->exists("/contacts3")) { if (_fs->exists("/contacts3")) { File oldFile = openRead(_fs, "/contacts3"); - File newFile = openWrite(_fsExtra, "/contacts3"); + File newFile = migrateOpenWrite(_fsExtra, "/contacts3"); if (oldFile && newFile) { uint8_t buf[64]; @@ -481,7 +496,7 @@ void DataStore::migrateToSecondaryFS() { if (!_fsExtra->exists("/channels2")) { if (_fs->exists("/channels2")) { File oldFile = openRead(_fs, "/channels2"); - File newFile = openWrite(_fsExtra, "/channels2"); + File newFile = migrateOpenWrite(_fsExtra, "/channels2"); if (oldFile && newFile) { uint8_t buf[64]; @@ -499,7 +514,7 @@ void DataStore::migrateToSecondaryFS() { if (_fsExtra->exists("/_main.id")) { if (_fs->exists("/_main.id")) {_fs->remove("/_main.id");} File oldFile = openRead(_fsExtra, "/_main.id"); - File newFile = openWrite(_fs, "/_main.id"); + File newFile = migrateOpenWrite(_fs, "/_main.id"); if (oldFile && newFile) { uint8_t buf[64]; @@ -515,7 +530,7 @@ void DataStore::migrateToSecondaryFS() { if (_fsExtra->exists("/new_prefs")) { if (_fs->exists("/new_prefs")) {_fs->remove("/new_prefs");} File oldFile = openRead(_fsExtra, "/new_prefs"); - File newFile = openWrite(_fs, "/new_prefs"); + File newFile = migrateOpenWrite(_fs, "/new_prefs"); if (oldFile && newFile) { uint8_t buf[64]; @@ -626,19 +641,24 @@ uint8_t DataStore::getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_b return 0; // not found } +struct BlobWriteCtx { + const uint8_t* buf; + uint8_t len; +}; + +static bool writeBlobBody(File& file, void* ctx) { + BlobWriteCtx* c = (BlobWriteCtx*) ctx; + return file.write(c->buf, c->len) == c->len; +} + bool DataStore::putBlobByKey(const uint8_t key[], int key_len, const uint8_t src_buf[], uint8_t len) { char path[64]; makeBlobPath(key, key_len, path, sizeof(path)); - File f = openWrite(_fs, path); - if (f) { - int n = f.write(src_buf, len); - f.close(); - if (n == len) return true; // success! - - _fs->remove(path); // blob was only partially written! - } - return false; // error + char tmp_path[72]; + snprintf(tmp_path, sizeof(tmp_path), "%s.new", path); + BlobWriteCtx ctx = {src_buf, len}; + return writeFileAtomic(_fs, path, tmp_path, writeBlobBody, &ctx); } bool DataStore::deleteBlobByKey(const uint8_t key[], int key_len) { diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index e1887e213a..a2f25c134b 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1151,6 +1151,18 @@ bool MyMesh::formatFileSystem() { #endif } +bool MyMesh::remountFileSystem() { +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + return InternalFS.begin(); +#elif defined(RP2040_PLATFORM) + return LittleFS.begin(); +#elif defined(ESP32) + return SPIFFS.begin(true); +#else + return true; +#endif +} + void MyMesh::sendSelfAdvertisement(int delay_millis, bool flood) { mesh::Packet *pkt = createSelfAdvert(); if (pkt) { diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index d19df192f2..31c20147aa 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -237,6 +237,8 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { // CommonCLICallbacks void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) override; bool formatFileSystem() override; + FILESYSTEM* getFileSystem() override { return _fs; } + bool remountFileSystem() override; void sendSelfAdvertisement(int delay_millis, bool flood) override; void updateAdvertTimer() override; void updateFloodAdvertTimer() override; diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 70e499f7b9..827cf212c8 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -836,6 +836,18 @@ bool MyMesh::formatFileSystem() { #endif } +bool MyMesh::remountFileSystem() { +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + return InternalFS.begin(); +#elif defined(RP2040_PLATFORM) + return LittleFS.begin(); +#elif defined(ESP32) + return SPIFFS.begin(true); +#else + return true; +#endif +} + void MyMesh::sendSelfAdvertisement(int delay_millis, bool flood) { mesh::Packet *pkt = createSelfAdvert(); if (pkt) { diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index cd0c5b049f..e17f9a627c 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -206,6 +206,8 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { // CommonCLICallbacks void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) override; bool formatFileSystem() override; + FILESYSTEM* getFileSystem() override { return _fs; } + bool remountFileSystem() override; void sendSelfAdvertisement(int delay_millis, bool flood) override; void updateAdvertTimer() override; void updateFloodAdvertTimer() override; diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index 17f8e323e5..54c13a2539 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -795,6 +795,18 @@ bool SensorMesh::formatFileSystem() { #endif } +bool SensorMesh::remountFileSystem() { +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + return InternalFS.begin(); +#elif defined(RP2040_PLATFORM) + return LittleFS.begin(); +#elif defined(ESP32) + return SPIFFS.begin(true); +#else + return true; +#endif +} + void SensorMesh::saveIdentity(const mesh::LocalIdentity& new_id) { #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) IdentityStore store(*_fs, ""); diff --git a/examples/simple_sensor/SensorMesh.h b/examples/simple_sensor/SensorMesh.h index 8ba894b70f..792357507e 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/examples/simple_sensor/SensorMesh.h @@ -61,6 +61,8 @@ class SensorMesh : public mesh::Mesh, public CommonCLICallbacks { NodePrefs* getNodePrefs() { return &_prefs; } void savePrefs() override { _cli.savePrefs(_fs); } bool formatFileSystem() override; + FILESYSTEM* getFileSystem() override { return _fs; } + bool remountFileSystem() override; void sendSelfAdvertisement(int delay_millis, bool flood) override; void updateAdvertTimer() override; void updateFloodAdvertTimer() override; diff --git a/platformio.ini b/platformio.ini index 46022a78ea..427654e093 100644 --- a/platformio.ini +++ b/platformio.ini @@ -26,7 +26,6 @@ lib_deps = melopero/Melopero RV3028 @ ^1.1.0 electroniccats/CayenneLPP @ 1.6.1 build_flags = -w -DNDEBUG -DRADIOLIB_STATIC_ONLY=1 -DRADIOLIB_GODMODE=1 - -D FIRMWARE_VERSION='"v0.1.0"' -D LORA_FREQ=869.618 -D LORA_BW=62.5 -D LORA_SF=8 @@ -129,6 +128,15 @@ build_flags = ${nrf52_base.build_flags} build_src_filter = ${nrf52_base.build_src_filter} + lib_deps = ${nrf52_base.lib_deps} + +; Bench-only serial tracing. Field envs stay quiet. Debug twins (`*_debug`) append these +; and get a distinct MOTA target_id (sha of the PIO env name). +[envyos_repeater_debug] +build_flags = + -D LOG_TAIL_ON_BOOT=1 + -D OTA_DEBUG=1 + -D ADMIN_DEBUG=1 + ; ----------------- RP2040 --------------------- [rp2040_base] diff --git a/src/helpers/ClientACL.cpp b/src/helpers/ClientACL.cpp index 1282382737..c6a9b22e6b 100644 --- a/src/helpers/ClientACL.cpp +++ b/src/helpers/ClientACL.cpp @@ -1,14 +1,30 @@ #include "ClientACL.h" - -static File openWrite(FILESYSTEM* _fs, const char* filename) { - #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) - _fs->remove(filename); - return _fs->open(filename, FILE_O_WRITE); - #elif defined(RP2040_PLATFORM) - return _fs->open(filename, "w"); - #else - return _fs->open(filename, "w", true); - #endif +#include "ConfigSerializer.h" + +struct SaveAclCtx { + ClientACL* acl; + bool (*filter)(ClientInfo*); +}; + +static bool writeAclBody(File& file, void* ctx) { + SaveAclCtx* c = (SaveAclCtx*) ctx; + uint8_t unused[2]; + memset(unused, 0, sizeof(unused)); + + for (int i = 0; i < c->acl->getNumClients(); i++) { + auto client = c->acl->getClientByIdx(i); + if (client->permissions == 0 || (c->filter && !c->filter(client))) continue; + + bool success = (file.write(client->id.pub_key, 32) == 32); + success = success && (file.write((uint8_t*) &client->permissions, 1) == 1); + success = success && (file.write((uint8_t*) &client->extra.room.sync_since, 4) == 4); + success = success && (file.write(unused, 2) == 2); + success = success && (file.write((uint8_t*) &client->out_path_len, 1) == 1); + success = success && (file.write(client->out_path, 64) == 64); + success = success && (file.write(client->shared_secret, PUB_KEY_SIZE) == PUB_KEY_SIZE); + if (!success) return false; + } + return true; } void ClientACL::load(FILESYSTEM* fs, const mesh::LocalIdentity& self_id) { @@ -54,27 +70,8 @@ void ClientACL::load(FILESYSTEM* fs, const mesh::LocalIdentity& self_id) { void ClientACL::save(FILESYSTEM* fs, bool (*filter)(ClientInfo*)) { _fs = fs; - File file = openWrite(_fs, "/s_contacts"); - if (file) { - uint8_t unused[2]; - memset(unused, 0, sizeof(unused)); - - for (int i = 0; i < num_clients; i++) { - auto c = &clients[i]; - if (c->permissions == 0 || (filter && !filter(c))) continue; // skip deleted entries, or by filter function - - bool success = (file.write(c->id.pub_key, 32) == 32); - success = success && (file.write((uint8_t *) &c->permissions, 1) == 1); - success = success && (file.write((uint8_t *) &c->extra.room.sync_since, 4) == 4); - success = success && (file.write(unused, 2) == 2); - success = success && (file.write((uint8_t *)&c->out_path_len, 1) == 1); - success = success && (file.write(c->out_path, 64) == 64); - success = success && (file.write(c->shared_secret, PUB_KEY_SIZE) == PUB_KEY_SIZE); - - if (!success) break; // write failed - } - file.close(); - } + SaveAclCtx ctx = {this, filter}; + writeFileAtomic(_fs, "/s_contacts", "/.s_contacts.new", writeAclBody, &ctx); } bool ClientACL::clear() { diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index fdb5155a02..a1af33117a 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -1,13 +1,34 @@ #include +#include "AdminDebug.h" #include "CommonCLI.h" +#include "ConfigSerializer.h" +#include "FsLastErr.h" #include "TxtDataHelpers.h" #include "AdvertDataHelpers.h" #include "TxtDataHelpers.h" #include +#include #if defined(ENABLE_OTA) #include "ota/OtaCli.h" #include "ota/OtaContext.h" // persist/sync OTA policy + signer allowlist with NodePrefs #endif +#if defined(NRF52_PLATFORM) + #include "helpers/NRF52Board.h" + #include "helpers/ota/OtaBlInfo.h" + #include "flash/flash_nrf5x.h" +#elif defined(STM32_PLATFORM) + #include "InternalFileSystem.h" +#elif defined(ESP32) + #include +#endif + +#if defined(NRF52_PLATFORM) +static void repairFeedWatchdog() { + NRF52Board::feedWatchdogIfRunning(); +} +#else +static void repairFeedWatchdog() { } +#endif #ifndef BRIDGE_MAX_BAUD #define BRIDGE_MAX_BAUD 115200 @@ -184,6 +205,9 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { // Legacy _prefs->bridge_channel = constrain(_prefs->bridge_channel, 0, 14); _prefs->powersaving_enabled = constrain(_prefs->powersaving_enabled, 0, 1); + _prefs->wdt_enabled = constrain(_prefs->wdt_enabled, 0, 1); + if (_prefs->wdt_timeout_secs == 0) _prefs->wdt_timeout_secs = 30; + _prefs->wdt_timeout_secs = constrain(_prefs->wdt_timeout_secs, 1, 255); _prefs->gps_enabled = constrain(_prefs->gps_enabled, 0, 1); _prefs->advert_loc_policy = constrain(_prefs->advert_loc_policy, 0, 2); @@ -208,30 +232,683 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { // Legacy } } +static char s_last_prefs_save_stage[12]; + bool CommonCLI::savePrefs(FILESYSTEM* fs) { + unsigned long t0 = millis(); + ADMIN_DBG_MS("savePrefs: begin"); + s_last_prefs_save_stage[0] = 0; + bool success = saveConfigJsonAtomic(fs, *_prefs, "/prefs.json", "/.prefs.json.new", + s_last_prefs_save_stage, sizeof(s_last_prefs_save_stage)); + ADMIN_DBG_MS("savePrefs: end ok=%d dt=%lu stage=%s lfs_err=%d", (int) success, millis() - t0, + s_last_prefs_save_stage[0] ? s_last_prefs_save_stage : "-", fsLastErrGet()); + return success; +} + +#define MIN_LOCAL_ADVERT_INTERVAL 60 + +void CommonCLI::formatPrefsSaveErr(char* reply) { + const char* stage = s_last_prefs_save_stage[0] ? s_last_prefs_save_stage : "write"; + FILESYSTEM* fs = _callbacks->getFileSystem(); + fsLastErrReplyForFs(reply, 160, fsLastErrGet(), stage, fs); +} + +static bool fsHasIdentity(FILESYSTEM* fs) { +#if defined(ESP32) || defined(RP2040_PLATFORM) + return fs->exists("/identity/_main.id"); +#else + return fs->exists("/_main.id"); +#endif +} + +bool CommonCLI::savePrefs() { + if (_prefs->advert_interval * 2 < MIN_LOCAL_ADVERT_INTERVAL) { + _prefs->advert_interval = 0; // turn it off, now that device has been manually configured + } + FILESYSTEM* fs = _callbacks->getFileSystem(); + if (fs) { + return savePrefs(fs); + } + _callbacks->savePrefs(); + return true; +} + +bool CommonCLI::persistPrefs(char* reply, const char* ok_msg) { + if (savePrefs()) { + strcpy(reply, ok_msg); + return true; + } + formatPrefsSaveErr(reply); + return false; +} + +bool CommonCLI::tryPrefsWrite(FILESYSTEM* fs, char* err_stage, size_t err_stage_len) { + static const char* path = "/.doctor_prefs.json"; + static const char* tmp = "/.doctor_prefs.json.new"; + repairFeedWatchdog(); + fs->remove(path); + bool success = saveConfigJsonAtomic(fs, *_prefs, path, tmp, err_stage, err_stage_len); + fs->remove(path); + fs->remove(tmp); + repairFeedWatchdog(); + return success; +} + +bool CommonCLI::checkFileSystem(char* reply) { + FILESYSTEM* fs = _callbacks->getFileSystem(); + if (!fs) { + strcpy(reply, "ERR unsupported"); + return false; + } + + bool prefs = fs->exists("/prefs.json"); + bool id = fsHasIdentity(fs); + bool acl = fs->exists("/s_contacts"); + bool regions = fs->exists("/regions2"); + + char stage[12]; + stage[0] = 0; + bool prefs_write_ok = tryPrefsWrite(fs, stage, sizeof(stage)); + if (prefs_write_ok) { + sprintf(reply, "OK prefs_writeable prefs=%d id=%d acl=%d regions=%d", prefs, id, acl, regions); + } else if (strcmp(stage, "nospc") == 0) { + fsLastErrReplyForFs(reply, 160, fsLastErrGet(), stage, fs); + } else if (fsIsCriticallyFull(fs)) { + fsLastErrReplyForFs(reply, 160, fsLastErrGet(), stage, fs); + } else { + sprintf(reply, "ERR prefs %s failed prefs=%d id=%d acl=%d regions=%d (try: doctor fs fix)", + stage[0] ? stage : "write", prefs, id, acl, regions); + } + return prefs_write_ok; +} + +bool CommonCLI::rebuildFileSystemFromRam(char* reply) { + FILESYSTEM* fs = _callbacks->getFileSystem(); + if (!fs) { + strcpy(reply, "ERR unsupported"); + return false; + } + + repairFeedWatchdog(); + if (!_callbacks->formatFileSystem()) { + strcpy(reply, "ERR format failed"); + return false; + } + repairFeedWatchdog(); + if (!_callbacks->remountFileSystem()) { + strcpy(reply, "ERR remount failed"); + return false; + } + + _callbacks->saveIdentity(_callbacks->getSelfId()); + repairFeedWatchdog(); + if (!savePrefs(fs)) { + formatPrefsSaveErr(reply); + return false; + } + repairFeedWatchdog(); + _acl->save(fs); + repairFeedWatchdog(); + if (!_region_map->save(fs)) { + strcpy(reply, "ERR regions write failed"); + return false; + } + + strcpy(reply, "OK fs rebuilt from RAM"); + return true; +} + +bool CommonCLI::fixFileSystemFromRam(char* reply) { + FILESYSTEM* fs = _callbacks->getFileSystem(); + if (!fs) { + strcpy(reply, "ERR unsupported"); + return false; + } + + repairFeedWatchdog(); + if (savePrefs(fs) && fs->exists("/prefs.json")) { + strcpy(reply, "OK prefs repaired"); + return true; + } + + return rebuildFileSystemFromRam(reply); +} + +bool CommonCLI::formatFileSystemFromRam(char* reply) { + if (rebuildFileSystemFromRam(reply)) { + strcpy(reply, "OK fs formatted from RAM"); + return true; + } + return false; +} + +bool CommonCLI::wipeFileSystem(char* reply) { + repairFeedWatchdog(); + if (!_callbacks->formatFileSystem()) { + strcpy(reply, "ERR format failed"); + return false; + } + repairFeedWatchdog(); + if (!_callbacks->remountFileSystem()) { + strcpy(reply, "ERR remount failed"); + return false; + } + strcpy(reply, "OK wiped (reboot required)"); + return true; +} + +#if defined(NRF52_PLATFORM) +static bool doctorFsFlashRegion(uint32_t* addr, uint32_t* size) { +#ifdef NRF52840_XXAA + *addr = 0xED000; +#else + *addr = 0x6D000; +#endif + *size = 7u * FLASH_NRF52_PAGE_SIZE; + return true; +} +#elif defined(STM32_PLATFORM) +static bool doctorFsFlashRegion(uint32_t* addr, uint32_t* size) { + *addr = LFS_FLASH_ADDR_BASE; + *size = LFS_FLASH_TOTAL_SIZE; + return true; +} +#else +static bool doctorFsFlashRegion(uint32_t* addr, uint32_t* size) { + (void) addr; + (void) size; + return false; +} +#endif + +static void doctorFsLine(const char* fmt, ...) { + char buf[160]; + va_list args; + va_start(args, fmt); + int n = vsnprintf(buf, sizeof(buf), fmt, args); + va_end(args); + if (n <= 0) return; + if (n >= (int) sizeof(buf)) n = sizeof(buf) - 1; + Serial.write((const uint8_t*) buf, n); + Serial.print("\r\n"); + Serial.flush(); +} + +static void doctorFsPrintHexLine(uint32_t addr, const uint8_t* data, size_t len) { + char buf[80]; + int pos = snprintf(buf, sizeof(buf), "FS_DUMP %06X:", addr & 0xFFFFFF); + for (size_t i = 0; i < len && pos > 0 && pos < (int) sizeof(buf) - 4; i++) { + pos += snprintf(buf + pos, sizeof(buf) - pos, " %02X", data[i]); + } + doctorFsLine("%s", buf); +} + +bool CommonCLI::dumpFileSystem(char* reply) { + uint32_t base = 0; + uint32_t size = 0; + if (!doctorFsFlashRegion(&base, &size)) { + strcpy(reply, "ERR dump unsupported on this platform"); + return false; + } + + uint8_t buf[16]; + doctorFsLine("FS_DUMP begin addr=0x%X size=%u", base, size); + for (uint32_t off = 0; off < size; off += sizeof(buf)) { + repairFeedWatchdog(); + uint32_t chunk = size - off; + if (chunk > sizeof(buf)) chunk = sizeof(buf); +#if defined(NRF52_PLATFORM) + if (flash_nrf5x_read(buf, base + off, chunk) <= 0) { + doctorFsLine("FS_DUMP abort read failed"); + sprintf(reply, "ERR read failed at 0x%X", base + off); + return false; + } +#else + memcpy(buf, (void*) (base + off), chunk); +#endif + doctorFsPrintHexLine(base + off, buf, chunk); + } + doctorFsLine("FS_DUMP end"); + sprintf(reply, "OK dumped %u bytes", size); + return true; +} + +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + +static int doctorFsCountBlock(void* p, lfs_block_t block) { + (void) block; + lfs_size_t* count = (lfs_size_t*) p; + (*count)++; + return 0; +} + +static lfs_ssize_t doctorFsUsedBlocks(lfs_t* lfs) { + lfs_size_t count = 0; + if (lfs_traverse(lfs, doctorFsCountBlock, &count) != 0) return -1; + return (lfs_ssize_t) count; +} + +static void doctorFsStatPath(lfs_t* lfs, const char* path) { + struct lfs_info info; + if (lfs_stat(lfs, path, &info) == 0) { + doctorFsLine("FS_STAT file %s %u", path, (unsigned) info.size); + } else { + doctorFsLine("FS_STAT file %s missing", path); + } +} + +static void doctorFsListLfs(lfs_t* lfs, const char* path, int depth) { + lfs_dir_t dir; + if (lfs_dir_open(lfs, &dir, path) < 0) { + doctorFsLine("FS_LS err open %s", path); + return; + } + + struct lfs_info info; + while (true) { + int res = lfs_dir_read(lfs, &dir, &info); + if (res <= 0) break; + if (info.name[0] == '.' && (info.name[1] == 0 || (info.name[1] == '.' && info.name[2] == 0))) continue; + + char indent[12]; + int spaces = depth * 2; + if (spaces > (int) sizeof(indent) - 1) spaces = sizeof(indent) - 1; + memset(indent, ' ', spaces); + indent[spaces] = 0; + + if (info.type == LFS_TYPE_DIR) { + doctorFsLine("FS_LS %s[dir] %s/", indent, info.name); + char sub[48]; + if (strcmp(path, "/") == 0) { + snprintf(sub, sizeof(sub), "/%s", info.name); + } else { + snprintf(sub, sizeof(sub), "%s/%s", path, info.name); + } + doctorFsListLfs(lfs, sub, depth + 1); + } else { + doctorFsLine("FS_LS %s[file] %s %u", indent, info.name, (unsigned) info.size); + } + repairFeedWatchdog(); + } + lfs_dir_close(lfs, &dir); +} + +static File doctorFsOpenWrite(FILESYSTEM* fs, const char* path) { + return fs->open(path, FILE_O_WRITE); +} + +static bool doctorFsProbeRaw(FILESYSTEM* fs, uint16_t size, char* stage, unsigned long* dt_ms) { + static const char* final = "/.doctor_probe"; + static const char* tmp = "/.doctor_probe.new"; + unsigned long t0 = millis(); + + fs->remove(final); + fs->remove(tmp); + + File file = doctorFsOpenWrite(fs, tmp); + if (!file) { + strcpy(stage, "open"); + *dt_ms = millis() - t0; + return false; + } + + uint8_t buf[64]; + memset(buf, 0xA5, sizeof(buf)); + uint16_t left = size; + while (left > 0) { + uint16_t chunk = left > sizeof(buf) ? sizeof(buf) : left; + if (file.write(buf, chunk) != chunk) { + file.close(); + fs->remove(tmp); + strcpy(stage, "write"); + *dt_ms = millis() - t0; + return false; + } + left -= chunk; + repairFeedWatchdog(); + } + file.close(); + + if (!fs->rename(tmp, final)) { + fs->remove(tmp); + strcpy(stage, "rename"); + *dt_ms = millis() - t0; + return false; + } + fs->remove(final); + stage[0] = 0; + *dt_ms = millis() - t0; + return true; +} + +#elif defined(ESP32) + +static File doctorFsOpenWrite(FILESYSTEM* fs, const char* path) { + return fs->open(path, "w", true); +} + +static bool doctorFsProbeRaw(FILESYSTEM* fs, uint16_t size, char* stage, unsigned long* dt_ms) { + static const char* final = "/.doctor_probe"; + static const char* tmp = "/.doctor_probe.new"; + unsigned long t0 = millis(); + + fs->remove(final); + fs->remove(tmp); + + File file = doctorFsOpenWrite(fs, tmp); + if (!file) { + strcpy(stage, "open"); + *dt_ms = millis() - t0; + return false; + } + + uint8_t buf[64]; + memset(buf, 0xA5, sizeof(buf)); + uint16_t left = size; + while (left > 0) { + uint16_t chunk = left > sizeof(buf) ? sizeof(buf) : left; + if (file.write(buf, chunk) != chunk) { + file.close(); + fs->remove(tmp); + strcpy(stage, "write"); + *dt_ms = millis() - t0; + return false; + } + left -= chunk; + } + file.close(); + + if (!fs->rename(tmp, final)) { + fs->remove(tmp); + strcpy(stage, "rename"); + *dt_ms = millis() - t0; + return false; + } + fs->remove(final); + stage[0] = 0; + *dt_ms = millis() - t0; + return true; +} + +#elif defined(RP2040_PLATFORM) + +static File doctorFsOpenWrite(FILESYSTEM* fs, const char* path) { + return fs->open(path, "w"); +} + +static bool doctorFsProbeRaw(FILESYSTEM* fs, uint16_t size, char* stage, unsigned long* dt_ms) { + static const char* final = "/.doctor_probe"; + static const char* tmp = "/.doctor_probe.new"; + unsigned long t0 = millis(); + + fs->remove(final); + fs->remove(tmp); + + File file = doctorFsOpenWrite(fs, tmp); + if (!file) { + strcpy(stage, "open"); + *dt_ms = millis() - t0; + return false; + } + + uint8_t buf[64]; + memset(buf, 0xA5, sizeof(buf)); + uint16_t left = size; + while (left > 0) { + uint16_t chunk = left > sizeof(buf) ? sizeof(buf) : left; + if (file.write(buf, chunk) != chunk) { + file.close(); + fs->remove(tmp); + strcpy(stage, "write"); + *dt_ms = millis() - t0; + return false; + } + left -= chunk; + } + file.close(); + + if (!fs->rename(tmp, final)) { + fs->remove(tmp); + strcpy(stage, "rename"); + *dt_ms = millis() - t0; + return false; + } + fs->remove(final); + stage[0] = 0; + *dt_ms = millis() - t0; + return true; +} + +#endif + +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) || defined(ESP32) || defined(RP2040_PLATFORM) + +static void doctorFsListGeneric(FILESYSTEM* fs) { + File root = fs->open("/"); + if (!root) { + doctorFsLine("FS_LS err open /"); + return; + } + File file = root.openNextFile(); + while (file) { + if (file.isDirectory()) { + doctorFsLine("FS_LS [dir] %s/", file.name()); + } else { + doctorFsLine("FS_LS [file] %s %d", file.name(), file.size()); + } + repairFeedWatchdog(); + file = root.openNextFile(); + } + root.close(); +} + +bool CommonCLI::statFileSystem(char* reply) { + FILESYSTEM* fs = _callbacks->getFileSystem(); + if (!fs) { + strcpy(reply, "ERR unsupported"); + return false; + } + + doctorFsLine("FS_STAT begin"); #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) - fs->remove("/prefs.json"); - File file = fs->open("/prefs.json", FILE_O_WRITE); + lfs_t* lfs = fs->_getFS(); + const lfs_config* cfg = lfs->cfg; + lfs_ssize_t used_blocks = doctorFsUsedBlocks(lfs); + uint32_t block_count = cfg->block_count; + uint32_t block_size = cfg->block_size; + uint32_t total_bytes = block_count * block_size; + uint32_t used_bytes = used_blocks >= 0 ? (uint32_t) used_blocks * block_size : 0; + uint32_t free_bytes = used_blocks >= 0 && used_bytes <= total_bytes ? total_bytes - used_bytes : 0; + + doctorFsLine("FS_STAT total %u", total_bytes); + doctorFsLine("FS_STAT used~ %u free~ %u", used_bytes, free_bytes); + doctorFsLine("FS_STAT blocks %u used %ld bsize %u", block_count, (long) used_blocks, block_size); + doctorFsStatPath(lfs, "/prefs.json"); + doctorFsStatPath(lfs, "/_main.id"); + doctorFsStatPath(lfs, "/s_contacts"); + doctorFsStatPath(lfs, "/regions2"); + sprintf(reply, "OK free~=%u/%u blk=%ld/%u", free_bytes, total_bytes, (long) used_blocks, block_count); +#elif defined(ESP32) + uint32_t total_bytes = SPIFFS.totalBytes(); + uint32_t used_bytes = SPIFFS.usedBytes(); + uint32_t free_bytes = total_bytes - used_bytes; + doctorFsLine("FS_STAT total %u used %u free %u", total_bytes, used_bytes, free_bytes); + static const char* paths[] = {"/prefs.json", "/identity/_main.id", "/s_contacts", "/regions2", NULL}; + for (int i = 0; paths[i]; i++) { + if (fs->exists(paths[i])) { + File f = fs->open(paths[i]); + doctorFsLine("FS_STAT file %s %d", paths[i], f ? (int) f.size() : -1); + if (f) f.close(); + } else { + doctorFsLine("FS_STAT file %s missing", paths[i]); + } + } + sprintf(reply, "OK free=%u/%u", free_bytes, total_bytes); #elif defined(RP2040_PLATFORM) - File file = fs->open("/prefs.json", "w"); + FSInfo info; + fs->info(info); + doctorFsLine("FS_STAT total %u used %u free %u", info.totalBytes, info.usedBytes, info.totalBytes - info.usedBytes); + sprintf(reply, "OK free=%u/%u", info.totalBytes - info.usedBytes, info.totalBytes); #else - File file = fs->open("/prefs.json", "w", true); + strcpy(reply, "OK"); #endif - if (file) { - bool success = _prefs->saveSerial(file); - file.close(); - return success; + doctorFsLine("FS_STAT end"); + return true; +} + +bool CommonCLI::listFileSystem(char* reply) { + FILESYSTEM* fs = _callbacks->getFileSystem(); + if (!fs) { + strcpy(reply, "ERR unsupported"); + return false; } - return false; + + doctorFsLine("FS_LS begin /"); +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + doctorFsListLfs(fs->_getFS(), "/", 0); +#else + doctorFsListGeneric(fs); +#endif + doctorFsLine("FS_LS end"); + strcpy(reply, "OK see serial FS_LS"); + return true; } -#define MIN_LOCAL_ADVERT_INTERVAL 60 +bool CommonCLI::probeFileSystem(char* reply) { + FILESYSTEM* fs = _callbacks->getFileSystem(); + if (!fs) { + strcpy(reply, "ERR unsupported"); + return false; + } -void CommonCLI::savePrefs() { - if (_prefs->advert_interval * 2 < MIN_LOCAL_ADVERT_INTERVAL) { - _prefs->advert_interval = 0; // turn it off, now that device has been manually configured + static const uint16_t sizes[] = { + 1, 2, 4, 8, 10, 16, 32, 64, 100, 128, 256, 512, 768, 1000, 1280, 1536, + 1800, 2048, 2304, 2560, 2800, 3072, 3584, 4096 + }; + + doctorFsLine("FS_PROBE begin raw"); + uint16_t max_ok = 0; + uint16_t first_fail = 0; + char fail_stage[12]; + fail_stage[0] = 0; + + for (size_t i = 0; i < sizeof(sizes) / sizeof(sizes[0]); i++) { + char stage[12]; + unsigned long dt = 0; + bool ok = doctorFsProbeRaw(fs, sizes[i], stage, &dt); + if (ok) { + doctorFsLine("FS_PROBE raw %u ok %lu", sizes[i], dt); + max_ok = sizes[i]; + } else { + doctorFsLine("FS_PROBE raw %u fail %s %lu", sizes[i], stage, dt); + if (first_fail == 0) { + first_fail = sizes[i]; + strncpy(fail_stage, stage, sizeof(fail_stage) - 1); + fail_stage[sizeof(fail_stage) - 1] = 0; + } + } + repairFeedWatchdog(); + } + + char prefs_stage[12]; + prefs_stage[0] = 0; + unsigned long prefs_dt = millis(); + bool prefs_ok = tryPrefsWrite(fs, prefs_stage, sizeof(prefs_stage)); + prefs_dt = millis() - prefs_dt; + doctorFsLine("FS_PROBE prefs_json %s %s %lu", + prefs_ok ? "ok" : "fail", prefs_stage[0] ? prefs_stage : "-", prefs_dt); + doctorFsLine("FS_PROBE end"); + + if (first_fail == 0) { + sprintf(reply, "OK raw_max=%u prefs=%s", max_ok, prefs_ok ? "ok" : "fail"); + } else { + sprintf(reply, "OK raw_max=%u fail>=%u@%s prefs=%s", + max_ok, first_fail, fail_stage[0] ? fail_stage : "?", prefs_ok ? "ok" : "fail"); + } + return true; +} + +#endif + +bool CommonCLI::gcFileSystem(char* reply) { + FILESYSTEM* fs = _callbacks->getFileSystem(); + if (!fs) { + strcpy(reply, "ERR unsupported"); + return false; + } + + repairFeedWatchdog(); + uint32_t removed = 0; + + static const char* files[] = { + "/packet_log", + "/com_prefs", + "/.doctor_prefs.json", + "/.doctor_prefs.json.new", + "/.doctor_probe", + "/.doctor_probe.new", + NULL + }; + + for (int i = 0; files[i]; i++) { + if (fs->exists(files[i])) { + fs->remove(files[i]); + removed++; + repairFeedWatchdog(); + } + } + +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + if (fs->exists("/prefs")) { + fs->rmdir_r("/prefs"); + removed++; + } +#endif + + sprintf(reply, "OK gc removed %u item(s)", removed); + return true; +} + +void CommonCLI::handleDoctorFs(uint32_t sender_timestamp, const char* args, char* reply) { + while (*args == ' ') args++; + + if (*args == 0) { + strcpy(reply, "usage: doctor fs check|fix|format|dump|stat|ls|probe"); + } else if (memcmp(args, "check", 5) == 0 && (args[5] == 0 || args[5] == ' ')) { + checkFileSystem(reply); + } else if (memcmp(args, "fix", 3) == 0 && (args[3] == 0 || args[3] == ' ')) { + fixFileSystemFromRam(reply); + } else if (memcmp(args, "format", 6) == 0 && (args[6] == 0 || args[6] == ' ')) { + formatFileSystemFromRam(reply); + } else if (memcmp(args, "dump", 4) == 0 && (args[4] == 0 || args[4] == ' ')) { + if (sender_timestamp != 0) { + strcpy(reply, "ERR dump requires USB"); + } else { + dumpFileSystem(reply); + } +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) || defined(ESP32) || defined(RP2040_PLATFORM) + } else if (memcmp(args, "stat", 4) == 0 && (args[4] == 0 || args[4] == ' ')) { + if (sender_timestamp != 0) { + strcpy(reply, "ERR stat requires USB"); + } else { + statFileSystem(reply); + } + } else if (memcmp(args, "ls", 2) == 0 && (args[2] == 0 || args[2] == ' ')) { + if (sender_timestamp != 0) { + strcpy(reply, "ERR ls requires USB"); + } else { + listFileSystem(reply); + } + } else if (memcmp(args, "probe", 5) == 0 && (args[5] == 0 || args[5] == ' ')) { + if (sender_timestamp != 0) { + strcpy(reply, "ERR probe requires USB"); + } else { + probeFileSystem(reply); + } +#endif + } else { + strcpy(reply, "usage: doctor fs check|fix|format|dump|stat|ls|probe"); } - _callbacks->savePrefs(); } uint8_t CommonCLI::buildAdvertData(uint8_t node_type, uint8_t* app_data) { @@ -260,9 +937,11 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re // send zerohop advert _callbacks->sendSelfAdvertisement(1500, false); // longer delay, give CLI response time to be sent first strcpy(reply, "OK - zerohop advert sent"); - } else if (memcmp(command, "advert", 6) == 0) { + } else if (memcmp(command, "advert", 6) == 0) { // send flood advert + ADMIN_DBG_MS("advert: src=%s", sender_timestamp ? "remote" : "serial"); _callbacks->sendSelfAdvertisement(1500, true); // longer delay, give CLI response time to be sent first + ADMIN_DBG_MS("advert: sendSelfAdvertisement returned"); strcpy(reply, "OK - Advert sent"); } else if (memcmp(command, "clock sync", 10) == 0) { uint32_t curr = getRTCClock()->getCurrentTime(); @@ -324,9 +1003,12 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re } else if (memcmp(command, "password ", 9) == 0) { // change admin password StrHelper::strncpy(_prefs->password, &command[9], sizeof(_prefs->password)); - savePrefs(); - sprintf(reply, "password now: "); - StrHelper::strncpy(&reply[14], _prefs->password, 160-15); // echo back just to let admin know for sure!! + if (!savePrefs()) { + formatPrefsSaveErr(reply); + } else { + sprintf(reply, "password now: "); + StrHelper::strncpy(&reply[14], _prefs->password, 160-15); // echo back just to let admin know for sure!! + } } else if (memcmp(command, "clear stats", 11) == 0) { _callbacks->clearStats(); strcpy(reply, "(OK - stats reset)"); @@ -334,9 +1016,17 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re handleGetCmd(sender_timestamp, command, reply); } else if (memcmp(command, "set ", 4) == 0) { handleSetCmd(sender_timestamp, command, reply); + } else if (memcmp(command, "doctor gc", 9) == 0 && (command[9] == 0 || command[9] == ' ')) { + gcFileSystem(reply); + } else if (memcmp(command, "doctor fs", 9) == 0 && (command[9] == 0 || command[9] == ' ')) { + handleDoctorFs(sender_timestamp, &command[9], reply); } else if (sender_timestamp == 0 && strcmp(command, "erase") == 0) { - bool s = _callbacks->formatFileSystem(); - sprintf(reply, "File system erase: %s", s ? "OK" : "Err"); + if (_callbacks->getFileSystem()) { + wipeFileSystem(reply); + } else { + bool s = _callbacks->formatFileSystem(); + sprintf(reply, "File system erase: %s", s ? "OK" : "Err"); + } } else if (memcmp(command, "ver", 3) == 0) { sprintf(reply, "%s (Build: %s)", _callbacks->getFirmwareVer(), _callbacks->getBuildDate()); } else if (memcmp(command, "board", 5) == 0) { @@ -360,8 +1050,11 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re for (uint8_t i = 0; i < c.seeder_allow.count() && i < 8; i++) _prefs->ota_seeder_allow[i] = c.seeder_allow.get(i); #endif - _callbacks->savePrefs(); - c.config_dirty = false; + if (!savePrefs()) { + formatPrefsSaveErr(reply); + } else { + c.config_dirty = false; + } } #endif } else if (memcmp(command, "sensor get ", 11) == 0) { @@ -488,6 +1181,100 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re } else { strcpy(reply, "Can't find GPS"); } +#endif + } else if (memcmp(command, "watchdog lockup", 15) == 0) { +#if defined(NRF52_PLATFORM) + if (mesh::ota::ota_bootloader_blocks_wdt()) { + strcpy(reply, "error - mota bootloader lacks WDT feed (flash WDT-capable BL first)"); + } else { + NRF52Board* nb = static_cast(_board); + if (!nb->isWatchdogRunning()) { + strcpy(reply, "error - watchdog not running"); + } else { + strcpy(reply, "OK - locking loop"); + nb->requestLockup(); + } + } +#else + strcpy(reply, "not supported on this platform"); +#endif + } else if (memcmp(command, "watchdog timeout ", 17) == 0) { +#if defined(NRF52_PLATFORM) + uint32_t n = _atoi(&command[17]); + if (n < 1 || n > 255) { + strcpy(reply, "error - timeout must be 1..255 (use watchdog off to disable)"); + } else { + _prefs->wdt_timeout_secs = (uint8_t)n; + savePrefs(); + NRF52Board* nb = static_cast(_board); + if (nb->isWatchdogRunning() && nb->getWatchdogRunningTimeoutSecs() != n) { + strcpy(reply, "OK - reboot to apply"); + } else { + sprintf(reply, "OK - timeout %us", (unsigned)n); + } + } +#else + strcpy(reply, "not supported on this platform"); +#endif + } else if (memcmp(command, "watchdog timeout", 16) == 0 && (command[16] == 0 || command[16] == ' ')) { +#if defined(NRF52_PLATFORM) + NRF52Board* nb = static_cast(_board); + uint8_t run_to = nb->getWatchdogRunningTimeoutSecs(); + if (run_to) { + sprintf(reply, "pref %us, running %us", (unsigned)_prefs->wdt_timeout_secs, (unsigned)run_to); + } else { + sprintf(reply, "pref %us, not running", (unsigned)_prefs->wdt_timeout_secs); + } +#else + strcpy(reply, "not supported on this platform"); +#endif + } else if (memcmp(command, "watchdog on", 11) == 0) { +#if defined(NRF52_PLATFORM) + if (mesh::ota::ota_bootloader_blocks_wdt()) { + strcpy(reply, "error - mota bootloader lacks WDT feed (flash WDT-capable BL first)"); + } else { + _prefs->wdt_enabled = 1; + savePrefs(); + NRF52Board* nb = static_cast(_board); + if (nb->isWatchdogRunning()) { + strcpy(reply, "OK - already running"); + } else { + strcpy(reply, "OK - reboot to start"); + } + } +#else + strcpy(reply, "not supported on this platform"); +#endif + } else if (memcmp(command, "watchdog off", 12) == 0) { +#if defined(NRF52_PLATFORM) + _prefs->wdt_enabled = 0; + savePrefs(); + strcpy(reply, "OK - reboot to stop"); +#else + strcpy(reply, "not supported on this platform"); +#endif + } else if (memcmp(command, "watchdog status", 15) == 0 || memcmp(command, "watchdog", 8) == 0) { +#if defined(NRF52_PLATFORM) + NRF52Board* nb = static_cast(_board); + const char* en = _prefs->wdt_enabled ? "enabled" : "disabled"; + const char* run = nb->isWatchdogRunning() ? "running" : "not running"; + const char* last = nb->getResetReasonString(nb->getResetReason()); + uint8_t run_to = nb->getWatchdogRunningTimeoutSecs(); + if (mesh::ota::ota_bootloader_blocks_wdt()) { + sprintf(reply, "%s, %s, blocked (mota BL lacks WDT feed), last reset: %s", + en, run, last); + } else if (_prefs->wdt_enabled && !nb->isWatchdogRunning()) { + sprintf(reply, "%s, %s, timeout %us (reboot to start), last reset: %s", + en, run, (unsigned)_prefs->wdt_timeout_secs, last); + } else if (!_prefs->wdt_enabled && nb->isWatchdogRunning()) { + sprintf(reply, "%s, %s, timeout %us (reboot to stop), last reset: %s", + en, run, (unsigned)(run_to ? run_to : _prefs->wdt_timeout_secs), last); + } else { + sprintf(reply, "%s, %s, timeout %us, last reset: %s", + en, run, (unsigned)(run_to ? run_to : _prefs->wdt_timeout_secs), last); + } +#else + strcpy(reply, "not supported on this platform"); #endif } else if (memcmp(command, "powersaving on", 14) == 0) { #if defined(NRF52_PLATFORM) @@ -553,36 +1340,37 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep strcpy(reply, "ERROR: dutycycle must be 1-100"); } else { _prefs->airtime_factor = (100.0f / dc) - 1.0f; - savePrefs(); - float actual = 100.0f / (_prefs->airtime_factor + 1.0f); - int a_int = (int)actual; - int a_frac = (int)((actual - a_int) * 10.0f + 0.5f); - sprintf(reply, "OK - %d.%d%%", a_int, a_frac); + if (!savePrefs()) { + formatPrefsSaveErr(reply); + } else { + float actual = 100.0f / (_prefs->airtime_factor + 1.0f); + int a_int = (int)actual; + int a_frac = (int)((actual - a_int) * 10.0f + 0.5f); + sprintf(reply, "OK - %d.%d%%", a_int, a_frac); + } } } else if (memcmp(config, "af ", 3) == 0) { _prefs->airtime_factor = atof(&config[3]); - savePrefs(); - strcpy(reply, "OK"); + persistPrefs(reply, "OK"); } else if (memcmp(config, "int.thresh ", 11) == 0) { _prefs->interference_threshold = atoi(&config[11]); - savePrefs(); - strcpy(reply, "OK"); + persistPrefs(reply, "OK"); } else if (memcmp(config, "cad ", 4) == 0) { _prefs->cad_enabled = memcmp(&config[4], "on", 2) == 0; - savePrefs(); - strcpy(reply, "OK"); + persistPrefs(reply, "OK"); } else if (memcmp(config, "agc.reset.interval ", 19) == 0) { _prefs->agc_reset_interval = atoi(&config[19]) / 4; - savePrefs(); - sprintf(reply, "OK - interval rounded to %d", ((uint32_t) _prefs->agc_reset_interval) * 4); + if (!savePrefs()) { + formatPrefsSaveErr(reply); + } else { + sprintf(reply, "OK - interval rounded to %d", ((uint32_t) _prefs->agc_reset_interval) * 4); + } } else if (memcmp(config, "multi.acks ", 11) == 0) { _prefs->multi_acks = atoi(&config[11]); - savePrefs(); - strcpy(reply, "OK"); + persistPrefs(reply, "OK"); } else if (memcmp(config, "allow.read.only ", 16) == 0) { _prefs->allow_read_only = memcmp(&config[16], "on", 2) == 0; - savePrefs(); - strcpy(reply, "OK"); + persistPrefs(reply, "OK"); } else if (memcmp(config, "flood.advert.interval ", 22) == 0) { int hours = _atoi(&config[22]); if ((hours > 0 && hours < 3) || (hours > 168)) { @@ -590,8 +1378,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep } else { _prefs->flood_advert_interval = (uint8_t)(hours); _callbacks->updateFloodAdvertTimer(); - savePrefs(); - strcpy(reply, "OK"); + persistPrefs(reply, "OK"); } } else if (memcmp(config, "advert.interval ", 16) == 0) { int mins = _atoi(&config[16]); @@ -600,13 +1387,11 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep } else { _prefs->advert_interval = (uint8_t)(mins / 2); _callbacks->updateAdvertTimer(); - savePrefs(); - strcpy(reply, "OK"); + persistPrefs(reply, "OK"); } } else if (memcmp(config, "guest.password ", 15) == 0) { StrHelper::strncpy(_prefs->guest_password, &config[15], sizeof(_prefs->guest_password)); - savePrefs(); - strcpy(reply, "OK"); + persistPrefs(reply, "OK"); } else if (memcmp(config, "prv.key ", 8) == 0) { uint8_t prv_key[PRV_KEY_SIZE]; bool success = mesh::Utils::fromHex(prv_key, PRV_KEY_SIZE, &config[8]); @@ -622,21 +1407,28 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep } } else if (memcmp(config, "name ", 5) == 0) { if (isValidName(&config[5])) { + ADMIN_DBG_MS("set name: src=%s val=%.32s", sender_timestamp ? "remote" : "serial", &config[5]); StrHelper::strncpy(_prefs->node_name, &config[5], sizeof(_prefs->node_name)); - savePrefs(); - strcpy(reply, "OK"); + if (savePrefs()) { + ADMIN_DBG_MS("set name: savePrefs returned"); + strcpy(reply, "OK"); + } else { + ADMIN_DBG_MS("set name: savePrefs failed stage=%s lfs=%d", s_last_prefs_save_stage[0] ? s_last_prefs_save_stage : "-", + fsLastErrGet()); + formatPrefsSaveErr(reply); + } } else { strcpy(reply, "Error, bad chars"); } } else if (memcmp(config, "repeat ", 7) == 0) { _prefs->disable_fwd = memcmp(&config[7], "off", 3) == 0; - savePrefs(); - strcpy(reply, _prefs->disable_fwd ? "OK - repeat is now OFF" : "OK - repeat is now ON"); + persistPrefs(reply, _prefs->disable_fwd ? "OK - repeat is now OFF" : "OK - repeat is now ON"); } else if (memcmp(config, "radio.rxgain ", 13) == 0) { bool enabled = memcmp(&config[13], "on", 2) == 0; _prefs->rx_boosted_gain = enabled; - savePrefs(); - if (_callbacks->setRxBoostedGain(enabled)) { + if (!savePrefs()) { + formatPrefsSaveErr(reply); + } else if (_callbacks->setRxBoostedGain(enabled)) { strcpy(reply, "OK"); } else { strcpy(reply, "Error: unsupported"); @@ -647,16 +1439,14 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep } else if (memcmp(&config[17], "on", 2) == 0) { if (_board->setLoRaFemLnaEnabled(true)) { _prefs->radio_fem_rxgain = 1; - savePrefs(); - strcpy(reply, "OK - LoRa FEM RX gain on"); + persistPrefs(reply, "OK - LoRa FEM RX gain on"); } else { strcpy(reply, "Error: failed to apply LoRa FEM RX gain"); } } else if (memcmp(&config[17], "off", 3) == 0) { if (_board->setLoRaFemLnaEnabled(false)) { _prefs->radio_fem_rxgain = 0; - savePrefs(); - strcpy(reply, "OK - LoRa FEM RX gain off"); + persistPrefs(reply, "OK - LoRa FEM RX gain off"); } else { strcpy(reply, "Error: failed to apply LoRa FEM RX gain"); } @@ -676,25 +1466,21 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep _prefs->cr = cr; _prefs->freq = freq; _prefs->bw = bw; - _callbacks->savePrefs(); - strcpy(reply, "OK - reboot to apply"); + persistPrefs(reply, "OK - reboot to apply"); } else { strcpy(reply, "Error, invalid radio params"); } } else if (memcmp(config, "lat ", 4) == 0) { _prefs->node_lat = atof(&config[4]); - savePrefs(); - strcpy(reply, "OK"); + persistPrefs(reply, "OK"); } else if (memcmp(config, "lon ", 4) == 0) { _prefs->node_lon = atof(&config[4]); - savePrefs(); - strcpy(reply, "OK"); + persistPrefs(reply, "OK"); } else if (memcmp(config, "rxdelay ", 8) == 0) { float db = atof(&config[8]); if (db >= 0 && db <= 20.0f) { _prefs->rx_delay_base = db; - savePrefs(); - strcpy(reply, "OK"); + persistPrefs(reply, "OK"); } else { strcpy(reply, "Error, must be 0-20"); } @@ -702,8 +1488,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep float f = atof(&config[8]); if (f >= 0 && f <= 2.0f) { _prefs->tx_delay_factor = f; - savePrefs(); - strcpy(reply, "OK"); + persistPrefs(reply, "OK"); } else { strcpy(reply, "Error, must be 0-2"); } @@ -711,8 +1496,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep uint8_t m = atoi(&config[19]); if (m <= 64) { _prefs->flood_max_unscoped = m; - savePrefs(); - strcpy(reply, "OK"); + persistPrefs(reply, "OK"); } else { strcpy(reply, "Error, max 64"); } @@ -720,8 +1504,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep uint8_t m = atoi(&config[17]); if (m <= 64) { _prefs->flood_max_advert = m; - savePrefs(); - strcpy(reply, "OK"); + persistPrefs(reply, "OK"); } else { strcpy(reply, "Error, max 64"); } @@ -729,8 +1512,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep uint8_t m = atoi(&config[10]); if (m <= 64) { _prefs->flood_max = m; - savePrefs(); - strcpy(reply, "OK"); + persistPrefs(reply, "OK"); } else { strcpy(reply, "Error, max 64"); } @@ -738,8 +1520,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep float f = atof(&config[15]); if (f >= 0 && f <= 2.0f) { _prefs->direct_tx_delay_factor = f; - savePrefs(); - strcpy(reply, "OK"); + persistPrefs(reply, "OK"); } else { strcpy(reply, "Error, must be 0-2"); } @@ -747,8 +1528,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep int n = atoi(&config[10]); if (n >= 0 && n <= 5) { _prefs->hop_retry = (uint8_t)n; - savePrefs(); - strcpy(reply, "OK"); + persistPrefs(reply, "OK"); } else { strcpy(reply, "Error, must be 0-5"); } @@ -756,8 +1536,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep int n = atoi(&config[13]); if (n >= 200 && n <= 10000) { _prefs->hop_retry_ms = (uint16_t)n; - savePrefs(); - strcpy(reply, "OK"); + persistPrefs(reply, "OK"); } else { strcpy(reply, "Error, must be 200-10000"); } @@ -777,15 +1556,13 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep config++; } *dp = 0; - savePrefs(); - strcpy(reply, "OK"); + persistPrefs(reply, "OK"); } else if (memcmp(config, "path.hash.mode ", 15) == 0) { config += 15; uint8_t mode = atoi(config); if (mode < 3) { _prefs->path_hash_mode = mode; - savePrefs(); - strcpy(reply, "OK"); + persistPrefs(reply, "OK"); } else { strcpy(reply, "Error, must be 0,1, or 2"); } @@ -806,37 +1583,35 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep } if (mode != 0xFF) { _prefs->loop_detect = mode; - savePrefs(); - strcpy(reply, "OK"); + persistPrefs(reply, "OK"); } } else if (memcmp(config, "tx ", 3) == 0) { _prefs->tx_power_dbm = atoi(&config[3]); - savePrefs(); - _callbacks->setTxPower(_prefs->tx_power_dbm); - strcpy(reply, "OK"); + if (savePrefs()) { + _callbacks->setTxPower(_prefs->tx_power_dbm); + strcpy(reply, "OK"); + } else { + formatPrefsSaveErr(reply); + } } else if (sender_timestamp == 0 && memcmp(config, "freq ", 5) == 0) { _prefs->freq = atof(&config[5]); - savePrefs(); - strcpy(reply, "OK - reboot to apply"); + persistPrefs(reply, "OK - reboot to apply"); #ifdef WITH_BRIDGE } else if (memcmp(config, "bridge.enabled ", 15) == 0) { _prefs->bridge_enabled = memcmp(&config[15], "on", 2) == 0; _callbacks->setBridgeState(_prefs->bridge_enabled); - savePrefs(); - strcpy(reply, "OK"); + persistPrefs(reply, "OK"); } else if (memcmp(config, "bridge.delay ", 13) == 0) { int delay = _atoi(&config[13]); if (delay >= 0 && delay <= 10000) { _prefs->bridge_delay = (uint16_t)delay; - savePrefs(); - strcpy(reply, "OK"); + persistPrefs(reply, "OK"); } else { strcpy(reply, "Error: delay must be between 0-10000 ms"); } } else if (memcmp(config, "bridge.source ", 14) == 0) { _prefs->bridge_pkt_src = memcmp(&config[14], "rx", 2) == 0; - savePrefs(); - strcpy(reply, "OK"); + persistPrefs(reply, "OK"); #endif #ifdef WITH_RS232_BRIDGE } else if (memcmp(config, "bridge.baud ", 12) == 0) { @@ -844,8 +1619,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep if (baud >= 9600 && baud <= BRIDGE_MAX_BAUD) { _prefs->bridge_baud = (uint32_t)baud; _callbacks->restartBridge(); - savePrefs(); - strcpy(reply, "OK"); + persistPrefs(reply, "OK"); } else { sprintf(reply, "Error: baud rate must be between 9600-%d",BRIDGE_MAX_BAUD); } @@ -856,22 +1630,21 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep if (ch > 0 && ch < 15) { _prefs->bridge_channel = (uint8_t)ch; _callbacks->restartBridge(); - savePrefs(); - strcpy(reply, "OK"); + persistPrefs(reply, "OK"); } else { strcpy(reply, "Error: channel must be between 1-14"); } } else if (memcmp(config, "bridge.secret ", 14) == 0) { StrHelper::strncpy(_prefs->bridge_secret, &config[14], sizeof(_prefs->bridge_secret)); _callbacks->restartBridge(); - savePrefs(); - strcpy(reply, "OK"); + persistPrefs(reply, "OK"); #endif } else if (memcmp(config, "adc.multiplier ", 15) == 0) { _prefs->adc_multiplier = atof(&config[15]); if (_board->setAdcMultiplier(_prefs->adc_multiplier)) { - savePrefs(); - if (_prefs->adc_multiplier == 0.0f) { + if (!savePrefs()) { + formatPrefsSaveErr(reply); + } else if (_prefs->adc_multiplier == 0.0f) { strcpy(reply, "OK - using default board multiplier"); } else { sprintf(reply, "OK - multiplier set to %.3f", _prefs->adc_multiplier); @@ -895,8 +1668,11 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep sideDetSFs[num] = 0; if (_callbacks->configSideDetectors(sideDetSFs, num, _prefs->bw)) { for (int i = 0; i <= num; i++) _prefs->extra_sf[i] = sideDetSFs[i]; - savePrefs(); - sprintf(reply, "OK - extra SFs set"); + if (savePrefs()) { + sprintf(reply, "OK - extra SFs set"); + } else { + formatPrefsSaveErr(reply); + } } else { sprintf(reply, "Invalid extra SF config"); } @@ -1168,9 +1944,12 @@ void CommonCLI::handleRegionCmd(char* command, char* reply) { _callbacks->startRegionsLoad(); } else if (n >= 2 && strcmp(parts[1], "save") == 0) { _prefs->discovery_mod_timestamp = getRTCClock()->getCurrentTime(); // this node is now 'modified' (for discovery info) - savePrefs(); - bool success = _callbacks->saveRegions(); - strcpy(reply, success ? "OK" : "Err - save failed"); + if (!savePrefs()) { + formatPrefsSaveErr(reply); + } else { + bool success = _callbacks->saveRegions(); + strcpy(reply, success ? "OK" : "Err - save failed"); + } } else if (n >= 3 && strcmp(parts[1], "allowf") == 0) { auto region = _region_map->findByNamePrefix(parts[2]); if (region) { diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 404c4474e2..dc909cc28b 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -236,6 +236,8 @@ class CommonCLICallbacks { virtual const char* getBuildDate() = 0; virtual const char* getRole() = 0; virtual bool formatFileSystem() = 0; + virtual FILESYSTEM* getFileSystem() { return nullptr; } + virtual bool remountFileSystem() { return true; } virtual void sendSelfAdvertisement(int delay_millis, bool flood) = 0; virtual void updateAdvertTimer() = 0; virtual void updateFloodAdvertTimer() = 0; @@ -299,7 +301,8 @@ class CommonCLI { char tmp[PRV_KEY_SIZE*2 + 4]; mesh::RTCClock* getRTCClock() { return _rtc; } - void savePrefs(); + bool savePrefs(); + bool persistPrefs(char* reply, const char* ok_msg); void loadPrefsInt(FILESYSTEM* _fs, const char* filename); #if defined(ENABLE_OTA) void syncOtaConfigFromPrefs(); // persisted OTA policy + signer allowlist -> running OtaContext @@ -308,6 +311,19 @@ class CommonCLI { void handleRegionCmd(char* command, char* reply); void handleGetCmd(uint32_t sender_timestamp, char* command, char* reply); void handleSetCmd(uint32_t sender_timestamp, char* command, char* reply); + void handleDoctorFs(uint32_t sender_timestamp, const char* args, char* reply); + bool checkFileSystem(char* reply); + bool tryPrefsWrite(FILESYSTEM* fs, char* err_stage, size_t err_stage_len); + bool rebuildFileSystemFromRam(char* reply); + bool fixFileSystemFromRam(char* reply); + bool formatFileSystemFromRam(char* reply); + bool wipeFileSystem(char* reply); + bool dumpFileSystem(char* reply); + bool statFileSystem(char* reply); + bool listFileSystem(char* reply); + bool probeFileSystem(char* reply); + bool gcFileSystem(char* reply); + void formatPrefsSaveErr(char* reply); public: CommonCLI(mesh::MainBoard& board, mesh::RTCClock& rtc, SensorManager& sensors, RegionMap& region_map, ClientACL& acl, NodePrefs* prefs, CommonCLICallbacks* callbacks) diff --git a/src/helpers/ConfigSerializer.cpp b/src/helpers/ConfigSerializer.cpp index 16d8855d74..09d38c4af4 100644 --- a/src/helpers/ConfigSerializer.cpp +++ b/src/helpers/ConfigSerializer.cpp @@ -1,5 +1,98 @@ #include "ConfigSerializer.h" #include +#include +#include "FsLastErr.h" + +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + #include "littlefs/lfs.h" +#endif + +static File openNewFile(FILESYSTEM* fs, const char* path) { +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + return fs->open(path, FILE_O_WRITE); +#elif defined(RP2040_PLATFORM) + return fs->open(path, "w"); +#else + return fs->open(path, "w", true); +#endif +} + +static void setAtomicErr(char* err_stage, size_t err_stage_len, const char* msg) { + if (err_stage && err_stage_len > 0) { + strncpy(err_stage, msg, err_stage_len - 1); + err_stage[err_stage_len - 1] = 0; + } +} + +static void mapAtomicErr(char* err_stage, size_t err_stage_len, const char* fallback) { + fsLastErrStage(err_stage, err_stage_len, fsLastErrGet(), fallback); +} + +bool writeFileAtomic(FILESYSTEM* fs, const char* final_path, const char* tmp_path, FileWriteFn writer, void* ctx, + char* err_stage, size_t err_stage_len) { + if (!fs || !final_path || !tmp_path || !writer) return false; + +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + if (fsIsCriticallyFull(fs)) { + setAtomicErr(err_stage, err_stage_len, "nospc"); + return false; + } +#endif + + fsLastErrClear(); + fs->remove(tmp_path); +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + if (fsLastErrGet() == LFS_ERR_NOENT) fsLastErrClear(); +#endif + File file = openNewFile(fs, tmp_path); + if (!file) { + mapAtomicErr(err_stage, err_stage_len, "open"); +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + if (fsLastErrGet() == 0 && fsIsCriticallyFull(fs)) setAtomicErr(err_stage, err_stage_len, "nospc"); +#endif + return false; + } + bool success = writer(file, ctx); + file.close(); + if (fsLastErrGet() != 0) success = false; + if (!success) { + fs->remove(tmp_path); + mapAtomicErr(err_stage, err_stage_len, "write"); +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + if (fsLastErrGet() == 0 && fsIsCriticallyFull(fs)) setAtomicErr(err_stage, err_stage_len, "nospc"); +#endif + return false; + } + if (!fs->rename(tmp_path, final_path)) { + fs->remove(tmp_path); + mapAtomicErr(err_stage, err_stage_len, "rename"); +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + if (fsLastErrGet() == 0 && fsIsCriticallyFull(fs)) setAtomicErr(err_stage, err_stage_len, "nospc"); +#endif + return false; + } + return true; +} + +struct SaveSerialCtx { + ConfigSerializer* obj; +}; + +static bool saveSerialWriter(File& file, void* ctx) { + return ((SaveSerialCtx*) ctx)->obj->saveSerial(file); +} + +bool saveConfigJsonAtomic(FILESYSTEM* fs, ConfigSerializer& obj, const char* final_path, const char* tmp_path, + char* err_stage, size_t err_stage_len) { + SaveSerialCtx ctx = {&obj}; + if (!writeFileAtomic(fs, final_path, tmp_path, saveSerialWriter, &ctx, err_stage, err_stage_len)) { + if (err_stage && err_stage_len > 0 && strcmp(err_stage, "write") == 0 && fsLastErrGet() == 0) { + setAtomicErr(err_stage, err_stage_len, "serialize"); + } + return false; + } + return true; +} bool ConfigSerializer::saveSerial(Stream& s) { Context context(&s, OP::WRITE); diff --git a/src/helpers/ConfigSerializer.h b/src/helpers/ConfigSerializer.h index 7e6d6f2a69..830a6567e6 100644 --- a/src/helpers/ConfigSerializer.h +++ b/src/helpers/ConfigSerializer.h @@ -66,3 +66,15 @@ class ConfigSerializer { bool loadSerial(Stream& s); bool saveSerial(Stream& s); }; + +#include "IdentityStore.h" + +typedef bool (*FileWriteFn)(File& file, void* ctx); + +// Write to tmp_path via writer, then lfs_rename over final_path. Keeps the old file on failed writes. +bool writeFileAtomic(FILESYSTEM* fs, const char* final_path, const char* tmp_path, FileWriteFn writer, void* ctx, + char* err_stage = nullptr, size_t err_stage_len = 0); + +// Write JSON to tmp_path, then lfs_rename over final_path. Keeps the old file on failed writes. +bool saveConfigJsonAtomic(FILESYSTEM* fs, ConfigSerializer& obj, const char* final_path, const char* tmp_path, + char* err_stage = nullptr, size_t err_stage_len = 0); diff --git a/src/helpers/FsLastErr.cpp b/src/helpers/FsLastErr.cpp new file mode 100644 index 0000000000..287cb9d8a0 --- /dev/null +++ b/src/helpers/FsLastErr.cpp @@ -0,0 +1,105 @@ +#include "FsLastErr.h" +#include +#include + +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + #include + #include "littlefs/lfs.h" + #ifndef LFS_ERR_NOSPC + #define LFS_ERR_NOSPC (-28) + #endif +#endif + +static int s_last_lfs_err = 0; + +void fsLastErrClear() { + s_last_lfs_err = 0; +} + +void fsLastErrSet(int err) { + if (err != 0) s_last_lfs_err = err; +} + +int fsLastErrGet() { + return s_last_lfs_err; +} + +static bool fsErrIsNospc(int err) { +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + return err == LFS_ERR_NOSPC; +#else + (void) err; + return false; +#endif +} + +void fsLastErrStage(char* stage, size_t stage_len, int err, const char* fallback_stage) { + if (!stage || stage_len == 0) return; + if (fsErrIsNospc(err)) { + strncpy(stage, "nospc", stage_len - 1); + } else if (fallback_stage && fallback_stage[0]) { + strncpy(stage, fallback_stage, stage_len - 1); + } else { + strncpy(stage, "write", stage_len - 1); + } + stage[stage_len - 1] = 0; +} + +void fsLastErrReply(char* reply, size_t reply_len, int err, const char* fallback_stage) { + if (!reply || reply_len == 0) return; + + const char* stage = (fallback_stage && fallback_stage[0]) ? fallback_stage : "write"; + + if (fsErrIsNospc(err) || strcmp(stage, "nospc") == 0) { + snprintf(reply, reply_len, "ERR no space left on device (try: doctor gc)"); + return; + } + + if (strcmp(stage, "serialize") == 0) { + if (err != 0) { + snprintf(reply, reply_len, "ERR prefs serialize failed lfs=%d (try: doctor fs fix)", err); + } else { + snprintf(reply, reply_len, "ERR prefs serialize failed (try: doctor fs fix)"); + } + return; + } + + if (err != 0) { + snprintf(reply, reply_len, "ERR prefs %s failed lfs=%d (try: doctor fs fix)", stage, err); + return; + } + + snprintf(reply, reply_len, "ERR prefs %s failed (try: doctor fs fix)", stage); +} + +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + +static int fsCountBlock(void* p, lfs_block_t block) { + (void) block; + lfs_size_t* count = (lfs_size_t*) p; + (*count)++; + return 0; +} + +bool fsIsCriticallyFull(FILESYSTEM* fs) { + if (!fs) return false; + lfs_t* lfs = fs->_getFS(); + if (!lfs || !lfs->cfg) return false; + lfs_size_t used = 0; + if (lfs_traverse(lfs, fsCountBlock, &used) != 0) return false; + return used + 2 >= lfs->cfg->block_count; +} + +#endif + +void fsLastErrReplyForFs(char* reply, size_t reply_len, int err, const char* stage, FILESYSTEM* fs) { +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + if (err == 0 && fs && fsIsCriticallyFull(fs)) { + snprintf(reply, reply_len, "ERR no space left on device (try: doctor gc)"); + return; + } +#else + (void) fs; +#endif + fsLastErrReply(reply, reply_len, err, stage); +} diff --git a/src/helpers/FsLastErr.h b/src/helpers/FsLastErr.h new file mode 100644 index 0000000000..892de04e5c --- /dev/null +++ b/src/helpers/FsLastErr.h @@ -0,0 +1,21 @@ +#pragma once + +#include + +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) || defined(ESP32) || defined(RP2040_PLATFORM) + #include "IdentityStore.h" +#endif + +void fsLastErrClear(); +void fsLastErrSet(int err); +int fsLastErrGet(); + +void fsLastErrStage(char* stage, size_t stage_len, int err, const char* fallback_stage); + +void fsLastErrReply(char* reply, size_t reply_len, int err, const char* fallback_stage); + +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) +bool fsIsCriticallyFull(FILESYSTEM* fs); +#endif + +void fsLastErrReplyForFs(char* reply, size_t reply_len, int err, const char* stage, FILESYSTEM* fs); diff --git a/src/helpers/RegionMap.cpp b/src/helpers/RegionMap.cpp index 4667e0038e..13508035bd 100644 --- a/src/helpers/RegionMap.cpp +++ b/src/helpers/RegionMap.cpp @@ -1,5 +1,7 @@ #include "RegionMap.h" #include +#include +#include #include // helper class for region map exporter, we emulate Stream with a safe buffer writer. @@ -58,15 +60,31 @@ static const char* skip_hash(const char* name) { return *name == '#' ? name + 1 : name; } -static File openWrite(FILESYSTEM* _fs, const char* filename) { - #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) - _fs->remove(filename); - return _fs->open(filename, FILE_O_WRITE); - #elif defined(RP2040_PLATFORM) - return _fs->open(filename, "w"); - #else - return _fs->open(filename, "w", true); - #endif +bool RegionMap::saveBodyWriter(File& file, void* ctx) { + return ((RegionMap*) ctx)->writeSaveBody(file); +} + +bool RegionMap::writeSaveBody(File& file) const { + uint8_t pad[128]; + memset(pad, 0, sizeof(pad)); + + bool success = file.write(pad, 3) == 3; + success = success && file.write((uint8_t*) &default_id, sizeof(default_id)) == sizeof(default_id); + success = success && file.write((uint8_t*) &home_id, sizeof(home_id)) == sizeof(home_id); + success = success && file.write((uint8_t*) &wildcard.flags, sizeof(wildcard.flags)) == sizeof(wildcard.flags); + success = success && file.write((uint8_t*) &next_id, sizeof(next_id)) == sizeof(next_id); + if (!success) return false; + + for (int i = 0; i < num_regions; i++) { + auto r = ®ions[i]; + success = file.write((uint8_t*) &r->id, sizeof(r->id)) == sizeof(r->id); + success = success && file.write((uint8_t*) &r->parent, sizeof(r->parent)) == sizeof(r->parent); + success = success && file.write((uint8_t*) r->name, sizeof(r->name)) == sizeof(r->name); + success = success && file.write((uint8_t*) &r->flags, sizeof(r->flags)) == sizeof(r->flags); + success = success && file.write(pad, sizeof(pad)) == sizeof(pad); + if (!success) return false; + } + return true; } bool RegionMap::load(FILESYSTEM* _fs, const char* path) { @@ -117,33 +135,10 @@ bool RegionMap::load(FILESYSTEM* _fs, const char* path) { } bool RegionMap::save(FILESYSTEM* _fs, const char* path) { - File file = openWrite(_fs, path ? path : "/regions2"); - if (file) { - uint8_t pad[128]; - memset(pad, 0, sizeof(pad)); - - bool success = file.write(pad, 3) == 3; // reserved header - success = success && file.write((uint8_t *) &default_id, sizeof(default_id)) == sizeof(default_id); - success = success && file.write((uint8_t *) &home_id, sizeof(home_id)) == sizeof(home_id); - success = success && file.write((uint8_t *) &wildcard.flags, sizeof(wildcard.flags)) == sizeof(wildcard.flags); - success = success && file.write((uint8_t *) &next_id, sizeof(next_id)) == sizeof(next_id); - - if (success) { - for (int i = 0; i < num_regions; i++) { - auto r = ®ions[i]; - - success = file.write((uint8_t *) &r->id, sizeof(r->id)) == sizeof(r->id); - success = success && file.write((uint8_t *) &r->parent, sizeof(r->parent)) == sizeof(r->parent); - success = success && file.write((uint8_t *) r->name, sizeof(r->name)) == sizeof(r->name); - success = success && file.write((uint8_t *) &r->flags, sizeof(r->flags)) == sizeof(r->flags); - success = success && file.write(pad, sizeof(pad)) == sizeof(pad); - if (!success) break; // write failed - } - } - file.close(); - return success; - } - return false; // failed + const char* final_path = path ? path : "/regions2"; + char tmp_path[32]; + snprintf(tmp_path, sizeof(tmp_path), "/.%s.new", final_path + 1); + return writeFileAtomic(_fs, final_path, tmp_path, saveBodyWriter, this); } RegionEntry* RegionMap::putRegion(const char* name, uint16_t parent_id, uint16_t id) { diff --git a/src/helpers/RegionMap.h b/src/helpers/RegionMap.h index 5eb1442983..11208dc8ce 100644 --- a/src/helpers/RegionMap.h +++ b/src/helpers/RegionMap.h @@ -27,6 +27,8 @@ class RegionMap { RegionEntry regions[MAX_REGION_ENTRIES]; RegionEntry wildcard; + bool writeSaveBody(File& file) const; + static bool saveBodyWriter(File& file, void* ctx); void printChildRegions(int indent, const RegionEntry* parent, Stream& out) const; public: diff --git a/variants/rak4631/platformio.ini b/variants/rak4631/platformio.ini index 6c14d00dad..e306075947 100644 --- a/variants/rak4631/platformio.ini +++ b/variants/rak4631/platformio.ini @@ -97,6 +97,12 @@ build_src_filter = ${rak4631_hw.build_src_filter} + +<../examples/simple_repeater> +[env:RAK_4631_repeater_slim_debug] +extends = env:RAK_4631_repeater_slim +build_flags = + ${env:RAK_4631_repeater_slim.build_flags} + ${envyos_repeater_debug.build_flags} + [env:RAK_4631_repeater_ethernet] extends = rak4631 build_flags = @@ -132,6 +138,12 @@ lib_deps = ${env:RAK_4631_repeater_slim.lib_deps} adafruit/SdFat - Adafruit Fork @ ^2.2.3 +[env:RAK_4631_superseeder_debug] +extends = env:RAK_4631_superseeder +build_flags = + ${env:RAK_4631_superseeder.build_flags} + ${envyos_repeater_debug.build_flags} + [env:RAK_4631_repeater_bridge_rs232_serial1] extends = rak4631 build_flags = diff --git a/variants/rak_wismesh_tag/platformio.ini b/variants/rak_wismesh_tag/platformio.ini index 77482fced3..3c3b597d1f 100644 --- a/variants/rak_wismesh_tag/platformio.ini +++ b/variants/rak_wismesh_tag/platformio.ini @@ -53,6 +53,12 @@ lib_deps = ${rak_wismesh_tag.lib_deps} end2endzone/NonBlockingRTTTL@^1.3.0 +[env:RAK_WisMesh_Tag_repeater_debug] +extends = env:RAK_WisMesh_Tag_repeater +build_flags = + ${env:RAK_WisMesh_Tag_repeater.build_flags} + ${envyos_repeater_debug.build_flags} + [env:RAK_WisMesh_Tag_room_server] extends = rak_wismesh_tag build_flags = diff --git a/variants/sensecap_solar/platformio.ini b/variants/sensecap_solar/platformio.ini index 6b6b3af5db..a097459ac5 100644 --- a/variants/sensecap_solar/platformio.ini +++ b/variants/sensecap_solar/platformio.ini @@ -98,6 +98,12 @@ lib_deps = ${rak4631_hw.lib_deps} debug_tool = jlink upload_protocol = nrfutil +[env:SenseCap_Solar_repeater_slim_debug] +extends = env:SenseCap_Solar_repeater_slim +build_flags = + ${env:SenseCap_Solar_repeater_slim.build_flags} + ${envyos_repeater_debug.build_flags} + ; NOR-backed mini-superseeder: SenseCAP P1-Pro slim + onboard 2 MB QSPI (P25Q16H) as LittleFS. ; Captures allowlisted *deltas* only to /motas/; flash staging remains for self-update. [env:SenseCap_Solar_superseeder] @@ -111,6 +117,12 @@ build_flags = lib_deps = ${env:SenseCap_Solar_repeater_slim.lib_deps} +[env:SenseCap_Solar_superseeder_debug] +extends = env:SenseCap_Solar_superseeder +build_flags = + ${env:SenseCap_Solar_superseeder.build_flags} + ${envyos_repeater_debug.build_flags} + [env:SenseCap_Solar_room_server] extends = SenseCap_Solar build_flags = From 1e3c4600ce31a8f79012d38abd28fcdf1e6fc809 Mon Sep 17 00:00:00 2001 From: Ben Allfree Date: Wed, 19 Aug 2026 21:49:03 -0700 Subject: [PATCH 57/59] refactor(cli): flat doctor, drop fix Co-authored-by: Cursor --- docs/envyos_cli_extensions.md | 163 +++++++++++----------------------- envyos/CHANGELOG.md | 5 +- src/helpers/CommonCLI.cpp | 80 ++--------------- src/helpers/CommonCLI.h | 5 +- src/helpers/FsLastErr.cpp | 8 +- 5 files changed, 69 insertions(+), 192 deletions(-) diff --git a/docs/envyos_cli_extensions.md b/docs/envyos_cli_extensions.md index ccefccd6de..c8770c5124 100644 --- a/docs/envyos_cli_extensions.md +++ b/docs/envyos_cli_extensions.md @@ -10,8 +10,7 @@ EnvyOS builds on [MeshCore CLI commands](./cli_commands.md) (companion tag **v1. ## Navigation -- [Filesystem doctor](#filesystem-doctor) -- [Filesystem garbage collection](#filesystem-garbage-collection) +- [Doctor (filesystem)](#doctor-filesystem) - [Logging (serial mirror)](#logging-serial-mirror) - [Prefs save errors](#prefs-save-errors) - [Routing (next-hop retry)](#routing-next-hop-retry) @@ -19,26 +18,33 @@ EnvyOS builds on [MeshCore CLI commands](./cli_commands.md) (companion tag **v1. --- -## Filesystem doctor +## Doctor (filesystem) -Wedged **InternalFS** (nRF52/STM32) or full SPIFFS can make `set name`, `set radio`, ACL, and region saves fail silently or return generic errors. The `doctor` commands diagnose and recover without re-flashing when possible. +Wedged **InternalFS** (nRF52/STM32) or full SPIFFS can make `set name`, `set radio`, ACL, and region saves fail silently or return generic errors. -Typical workflow on a full partition: +| Command | Remote admin | Role | +|---------|--------------|------| +| `doctor stat` | No (USB) | Partition headroom | +| `doctor gc` | Yes | Remove common cruft | +| `doctor check` | Yes | Prefs write probe | +| `doctor ls` | No (USB) | Bench: recursive listing | +| `doctor probe` | No (USB) | Bench: raw write sizing | +| `doctor dump` | No (USB) | Bench: hex dump flash region | + +Typical recovery on a full partition: ``` -doctor fs stat -doctor fs ls +doctor stat doctor gc -doctor fs stat +doctor stat +doctor check set name MyNode reboot ``` -If `doctor gc` cannot free enough space, use `doctor fs fix` (non-destructive attempt) or `doctor fs format` (wipes FS, rebuilds prefs/identity/ACL/regions from RAM). - -### `doctor fs check` +Wipe without rebuild-from-RAM: serial **`erase`** (formats FS; reboot required). -**Usage:** `doctor fs check` +### `doctor check` Tests whether the current in-RAM prefs can be written atomically to flash (writes to `/.doctor_prefs.json`, then removes it). Does **not** change running prefs. @@ -46,104 +52,16 @@ Tests whether the current in-RAM prefs can be written atomically to flash (write - `OK prefs_writeable prefs=1 id=1 acl=0 regions=0` - `ERR no space left on device (try: doctor gc)` -- `ERR prefs write failed lfs=-28 (try: doctor fs fix)` - -Works over remote admin. - ---- - -### `doctor fs fix` - -**Usage:** `doctor fs fix` - -Tries a normal prefs save first. If that fails, **formats InternalFS and rebuilds** identity, prefs, ACL, and regions from RAM. - -**Reply examples:** - -- `OK prefs repaired` -- `OK fs rebuilt from RAM` -- `ERR format failed` / `ERR remount failed` / prefs error string (see [Prefs save errors](#prefs-save-errors)) - -Works over remote admin. - ---- - -### `doctor fs format` - -**Usage:** `doctor fs format` +- `ERR prefs write failed lfs=-28` -Same rebuild path as `fix`, but always formats first. Destructive to flash contents not held in RAM. - -**Reply:** `OK fs formatted from RAM` or an error. - -Works over remote admin. - ---- - -### `doctor fs stat` - -**Usage:** `doctor fs stat` +### `doctor stat` **Serial only.** Prints partition summary lines prefixed `FS_STAT` on serial, then a one-line summary reply. -**Serial output (nRF52 InternalFS example):** - -``` -FS_STAT begin -FS_STAT total 28672 -FS_STAT used~ 28544 free~ 128 -FS_STAT blocks 224 used 223 bsize 128 -FS_STAT file /prefs.json 0 -FS_STAT file /_main.id 96 -... -FS_STAT end -``` - **Reply:** `OK free~=128/28672 blk=223/224` ---- - -### `doctor fs ls` - -**Usage:** `doctor fs ls` - -**Serial only.** Recursive listing; lines prefixed `FS_LS` on serial. - -**Reply:** `OK see serial FS_LS` - -Use to find large cruft (`packet_log`, legacy Meshtastic `prefs/`, `com_prefs`, etc.). - ---- - -### `doctor fs probe` - -**Usage:** `doctor fs probe` - -**Serial only.** Writes temporary probe files at increasing sizes (1 B through 4096 B), then runs the same prefs JSON write test as `check`. Lines prefixed `FS_PROBE` on serial. - -**Reply examples:** - -- `OK raw_max=512 prefs=ok` -- `OK raw_max=128 fail>=256@write prefs=fail` - ---- - -### `doctor fs dump` - -**Usage:** `doctor fs dump` - -**Serial only.** Hex-dumps the raw flash region backing InternalFS (`FS_DUMP` lines). For deep corruption analysis; not needed for normal recovery. - -**Reply:** `OK dumped N bytes` - ---- - -## Filesystem garbage collection - ### `doctor gc` -**Usage:** `doctor gc` - Removes reclaimable files that commonly fill InternalFS: | Path | Reason | @@ -155,7 +73,17 @@ Removes reclaimable files that commonly fill InternalFS: **Reply:** `OK gc removed N item(s)` -Works over remote admin. Run before `set …` when `doctor fs stat` shows the partition nearly full. +### `doctor ls` (bench) + +**Serial only.** Recursive listing; lines prefixed `FS_LS` on serial. **Reply:** `OK see serial FS_LS` + +### `doctor probe` (bench) + +**Serial only.** Writes temporary probe files at increasing sizes, then runs the prefs JSON write test. Lines prefixed `FS_PROBE` on serial. + +### `doctor dump` (bench) + +**Serial only.** Hex-dumps the raw flash region backing InternalFS. **Reply:** `OK dumped N bytes` --- @@ -182,8 +110,8 @@ When a `set …` or `password …` save fails, replies are specific instead of a |-----------|--------| | Partition critically full (≤2 free blocks on InternalFS) | `ERR no space left on device (try: doctor gc)` | | LittleFS returned NOSPC | Same as above | -| Other LFS error | `ERR prefs failed lfs=-NN (try: doctor fs fix)` | -| JSON serialize failure | `ERR prefs serialize failed (try: doctor fs fix)` | +| Other LFS error | `ERR prefs failed lfs=-NN (try: doctor gc)` | +| JSON serialize failure | `ERR prefs serialize failed (try: doctor gc)` | Stages: `open`, `write`, `rename`, `serialize`, `nospc`. @@ -217,16 +145,31 @@ Use field slugs for production; debug twins for bench, OTA, and FS troubleshooti --- +## Companion gap (deferred v0.3.0) + +Companions use the same **28 KB InternalFS** on nRF52840 but **do not expose `doctor` commands** (no `CommonCLI` on that path). WisMesh Tag BLE builds also omit `EXTRAFS=1`, so contacts/channels/blobs share InternalFS with prefs/identity. A full partition wedges clients the same way as repeaters (failed saves, lost data on reboot). + +**Deferred to v0.3.0** (see ota repo `docs/planned/v0.3.0.md`): + +- Enable `EXTRAFS=1` on WisMesh companion (match RAK4631: contacts on secondary volume). +- Expose `doctor check` / `doctor gc` on companion (USB serial debug at minimum). +- Align with multi-volume FS CLI naming work. + +v0.2.0 ships atomic saves and boot fsck on companion only. + +--- + ## Upstream notes -See MeshEnvy `docs/upstream-prs.md` in the ota repo for PR tracking. Sensible upstream targets: +See MeshEnvy `docs/good-upstream-contributor-policy.md` in the ota repo for GUCP tracking. Sensible upstream targets: | Change | Upstream? | |--------|-----------| -| Atomic prefs save | **Yes** — `meshcore-dev/MeshCore` `dev` | +| Atomic prefs save (tmp + rename) | **Yes** — `meshcore-dev/MeshCore` `dev` | +| FS save error surfacing (`lfs=-NN`, NOSPC hint) | **Yes** — prerequisite for useful `doctor check` / `set` failures | +| `doctor stat`, `doctor gc`, `doctor check` | **Yes** — flat CLI | +| `doctor ls` / `probe` / `dump` | **No** — EnvyOS bench tooling | +| Adafruit LFS `fsLastErr` hooks | **Maybe** — ship as part of error-surfacing PR or upstream equivalent | | `log tail` | **Yes** — PR open | | Next-hop retry | **Yes** — PR open | -| `doctor fs check` (prefs write probe only) | **Maybe** — lighter subset without dump/probe/gc | -| Full `doctor fs` + `doctor gc` | **EnvyOS-only** — bench/ops tooling | -| Adafruit LFS `fsLastErr` hooks | **EnvyOS-only** — vendor fork patch | | Packet log size cap | **Yes** — recommended future MeshCore fix (not in v0.2.0) | diff --git a/envyos/CHANGELOG.md b/envyos/CHANGELOG.md index 33c202fb58..e3e5f398fe 100644 --- a/envyos/CHANGELOG.md +++ b/envyos/CHANGELOG.md @@ -8,14 +8,15 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versions match ### Added -- Bench debug twins of each shipped repeater/superseeder (`-debug`): log tail, OTA, and admin serial on boot; separate MOTA target id. -- `doctor fs check|fix|format|dump|stat|ls|probe` and `doctor gc` for wedged InternalFS; `LFS_ERR_NOSPC` surfaces as `ERR no space left on device (try: doctor gc)`. +- Bench debug twins of each shipped target (`-debug`): log tail, OTA, and admin serial on boot; separate MOTA target id. +- `doctor check|stat|ls|probe|dump|gc` for wedged InternalFS; `LFS_ERR_NOSPC` surfaces as `ERR no space left on device (try: doctor gc)`. - Atomic prefs save (`saveConfigJsonAtomic` / `writeFileAtomic`): prefs, ACL, regions, companion contacts/channels/blobs. - Fail-fast when InternalFS is critically full (avoids multi-second LittleFS alloc retries before NOSPC). - Docs: `docs/envyos_cli_extensions.md` (doctor, gc, prefs errors, debug twins). ### Changed +- Flat `doctor` CLI (`check|stat|gc|ls|probe|dump`); removed `doctor fs` namespace and `fix`/`format` rebuild-from-RAM. - OTA self-serve disabled (`OTA_SELF_SERVE=0`); nodes no longer hash/serve their running firmware. **Remove self-serve code in v0.3.0.** - OTA self-serve merkle no longer starts at boot; repeaters stay quiet until `ota announce` / `ota folder on`. - Field `rak4631-repeater-slim` no longer enables log tail / OTA_DEBUG / ADMIN_DEBUG at boot (use the `-debug` twin). diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index a1af33117a..12c407fde5 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -316,72 +316,12 @@ bool CommonCLI::checkFileSystem(char* reply) { } else if (fsIsCriticallyFull(fs)) { fsLastErrReplyForFs(reply, 160, fsLastErrGet(), stage, fs); } else { - sprintf(reply, "ERR prefs %s failed prefs=%d id=%d acl=%d regions=%d (try: doctor fs fix)", + sprintf(reply, "ERR prefs %s failed prefs=%d id=%d acl=%d regions=%d (try: doctor gc)", stage[0] ? stage : "write", prefs, id, acl, regions); } return prefs_write_ok; } -bool CommonCLI::rebuildFileSystemFromRam(char* reply) { - FILESYSTEM* fs = _callbacks->getFileSystem(); - if (!fs) { - strcpy(reply, "ERR unsupported"); - return false; - } - - repairFeedWatchdog(); - if (!_callbacks->formatFileSystem()) { - strcpy(reply, "ERR format failed"); - return false; - } - repairFeedWatchdog(); - if (!_callbacks->remountFileSystem()) { - strcpy(reply, "ERR remount failed"); - return false; - } - - _callbacks->saveIdentity(_callbacks->getSelfId()); - repairFeedWatchdog(); - if (!savePrefs(fs)) { - formatPrefsSaveErr(reply); - return false; - } - repairFeedWatchdog(); - _acl->save(fs); - repairFeedWatchdog(); - if (!_region_map->save(fs)) { - strcpy(reply, "ERR regions write failed"); - return false; - } - - strcpy(reply, "OK fs rebuilt from RAM"); - return true; -} - -bool CommonCLI::fixFileSystemFromRam(char* reply) { - FILESYSTEM* fs = _callbacks->getFileSystem(); - if (!fs) { - strcpy(reply, "ERR unsupported"); - return false; - } - - repairFeedWatchdog(); - if (savePrefs(fs) && fs->exists("/prefs.json")) { - strcpy(reply, "OK prefs repaired"); - return true; - } - - return rebuildFileSystemFromRam(reply); -} - -bool CommonCLI::formatFileSystemFromRam(char* reply) { - if (rebuildFileSystemFromRam(reply)) { - strcpy(reply, "OK fs formatted from RAM"); - return true; - } - return false; -} - bool CommonCLI::wipeFileSystem(char* reply) { repairFeedWatchdog(); if (!_callbacks->formatFileSystem()) { @@ -869,17 +809,15 @@ bool CommonCLI::gcFileSystem(char* reply) { return true; } -void CommonCLI::handleDoctorFs(uint32_t sender_timestamp, const char* args, char* reply) { +void CommonCLI::handleDoctor(uint32_t sender_timestamp, const char* args, char* reply) { while (*args == ' ') args++; if (*args == 0) { - strcpy(reply, "usage: doctor fs check|fix|format|dump|stat|ls|probe"); + strcpy(reply, "usage: doctor check|stat|ls|probe|dump|gc"); + } else if (memcmp(args, "gc", 2) == 0 && (args[2] == 0 || args[2] == ' ')) { + gcFileSystem(reply); } else if (memcmp(args, "check", 5) == 0 && (args[5] == 0 || args[5] == ' ')) { checkFileSystem(reply); - } else if (memcmp(args, "fix", 3) == 0 && (args[3] == 0 || args[3] == ' ')) { - fixFileSystemFromRam(reply); - } else if (memcmp(args, "format", 6) == 0 && (args[6] == 0 || args[6] == ' ')) { - formatFileSystemFromRam(reply); } else if (memcmp(args, "dump", 4) == 0 && (args[4] == 0 || args[4] == ' ')) { if (sender_timestamp != 0) { strcpy(reply, "ERR dump requires USB"); @@ -907,7 +845,7 @@ void CommonCLI::handleDoctorFs(uint32_t sender_timestamp, const char* args, char } #endif } else { - strcpy(reply, "usage: doctor fs check|fix|format|dump|stat|ls|probe"); + strcpy(reply, "usage: doctor check|stat|ls|probe|dump|gc"); } } @@ -1016,10 +954,8 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re handleGetCmd(sender_timestamp, command, reply); } else if (memcmp(command, "set ", 4) == 0) { handleSetCmd(sender_timestamp, command, reply); - } else if (memcmp(command, "doctor gc", 9) == 0 && (command[9] == 0 || command[9] == ' ')) { - gcFileSystem(reply); - } else if (memcmp(command, "doctor fs", 9) == 0 && (command[9] == 0 || command[9] == ' ')) { - handleDoctorFs(sender_timestamp, &command[9], reply); + } else if (memcmp(command, "doctor", 6) == 0 && (command[6] == 0 || command[6] == ' ')) { + handleDoctor(sender_timestamp, &command[6], reply); } else if (sender_timestamp == 0 && strcmp(command, "erase") == 0) { if (_callbacks->getFileSystem()) { wipeFileSystem(reply); diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index dc909cc28b..ea0b47e224 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -311,12 +311,9 @@ class CommonCLI { void handleRegionCmd(char* command, char* reply); void handleGetCmd(uint32_t sender_timestamp, char* command, char* reply); void handleSetCmd(uint32_t sender_timestamp, char* command, char* reply); - void handleDoctorFs(uint32_t sender_timestamp, const char* args, char* reply); + void handleDoctor(uint32_t sender_timestamp, const char* args, char* reply); bool checkFileSystem(char* reply); bool tryPrefsWrite(FILESYSTEM* fs, char* err_stage, size_t err_stage_len); - bool rebuildFileSystemFromRam(char* reply); - bool fixFileSystemFromRam(char* reply); - bool formatFileSystemFromRam(char* reply); bool wipeFileSystem(char* reply); bool dumpFileSystem(char* reply); bool statFileSystem(char* reply); diff --git a/src/helpers/FsLastErr.cpp b/src/helpers/FsLastErr.cpp index 287cb9d8a0..b502608931 100644 --- a/src/helpers/FsLastErr.cpp +++ b/src/helpers/FsLastErr.cpp @@ -57,19 +57,19 @@ void fsLastErrReply(char* reply, size_t reply_len, int err, const char* fallback if (strcmp(stage, "serialize") == 0) { if (err != 0) { - snprintf(reply, reply_len, "ERR prefs serialize failed lfs=%d (try: doctor fs fix)", err); + snprintf(reply, reply_len, "ERR prefs serialize failed lfs=%d (try: doctor gc)", err); } else { - snprintf(reply, reply_len, "ERR prefs serialize failed (try: doctor fs fix)"); + snprintf(reply, reply_len, "ERR prefs serialize failed (try: doctor gc)"); } return; } if (err != 0) { - snprintf(reply, reply_len, "ERR prefs %s failed lfs=%d (try: doctor fs fix)", stage, err); + snprintf(reply, reply_len, "ERR prefs %s failed lfs=%d (try: doctor gc)", stage, err); return; } - snprintf(reply, reply_len, "ERR prefs %s failed (try: doctor fs fix)", stage); + snprintf(reply, reply_len, "ERR prefs %s failed (try: doctor gc)", stage); } #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) From 71cc3b2b1a9dd70b646f846ee7321c4d190e0e9a Mon Sep 17 00:00:00 2001 From: Ben Allfree Date: Wed, 19 Aug 2026 21:29:08 -0700 Subject: [PATCH 58/59] docs: defer companion fs wedge to v0.3.0 Co-authored-by: Cursor --- docs/envyos_cli_extensions.md | 151 +++++++++++++++++++++++++--------- 1 file changed, 111 insertions(+), 40 deletions(-) diff --git a/docs/envyos_cli_extensions.md b/docs/envyos_cli_extensions.md index c8770c5124..5ea3b854e4 100644 --- a/docs/envyos_cli_extensions.md +++ b/docs/envyos_cli_extensions.md @@ -10,7 +10,8 @@ EnvyOS builds on [MeshCore CLI commands](./cli_commands.md) (companion tag **v1. ## Navigation -- [Doctor (filesystem)](#doctor-filesystem) +- [Filesystem doctor](#filesystem-doctor) +- [Filesystem garbage collection](#filesystem-garbage-collection) - [Logging (serial mirror)](#logging-serial-mirror) - [Prefs save errors](#prefs-save-errors) - [Routing (next-hop retry)](#routing-next-hop-retry) @@ -18,33 +19,26 @@ EnvyOS builds on [MeshCore CLI commands](./cli_commands.md) (companion tag **v1. --- -## Doctor (filesystem) +## Filesystem doctor -Wedged **InternalFS** (nRF52/STM32) or full SPIFFS can make `set name`, `set radio`, ACL, and region saves fail silently or return generic errors. +Wedged **InternalFS** (nRF52/STM32) or full SPIFFS can make `set name`, `set radio`, ACL, and region saves fail silently or return generic errors. The `doctor` commands diagnose and recover without re-flashing when possible. -| Command | Remote admin | Role | -|---------|--------------|------| -| `doctor stat` | No (USB) | Partition headroom | -| `doctor gc` | Yes | Remove common cruft | -| `doctor check` | Yes | Prefs write probe | -| `doctor ls` | No (USB) | Bench: recursive listing | -| `doctor probe` | No (USB) | Bench: raw write sizing | -| `doctor dump` | No (USB) | Bench: hex dump flash region | - -Typical recovery on a full partition: +Typical workflow on a full partition: ``` -doctor stat +doctor fs stat +doctor fs ls doctor gc -doctor stat -doctor check +doctor fs stat set name MyNode reboot ``` -Wipe without rebuild-from-RAM: serial **`erase`** (formats FS; reboot required). +If `doctor gc` cannot free enough space, use `doctor fs fix` (non-destructive attempt) or `doctor fs format` (wipes FS, rebuilds prefs/identity/ACL/regions from RAM). + +### `doctor fs check` -### `doctor check` +**Usage:** `doctor fs check` Tests whether the current in-RAM prefs can be written atomically to flash (writes to `/.doctor_prefs.json`, then removes it). Does **not** change running prefs. @@ -52,16 +46,104 @@ Tests whether the current in-RAM prefs can be written atomically to flash (write - `OK prefs_writeable prefs=1 id=1 acl=0 regions=0` - `ERR no space left on device (try: doctor gc)` -- `ERR prefs write failed lfs=-28` +- `ERR prefs write failed lfs=-28 (try: doctor fs fix)` + +Works over remote admin. + +--- + +### `doctor fs fix` + +**Usage:** `doctor fs fix` + +Tries a normal prefs save first. If that fails, **formats InternalFS and rebuilds** identity, prefs, ACL, and regions from RAM. + +**Reply examples:** + +- `OK prefs repaired` +- `OK fs rebuilt from RAM` +- `ERR format failed` / `ERR remount failed` / prefs error string (see [Prefs save errors](#prefs-save-errors)) + +Works over remote admin. + +--- -### `doctor stat` +### `doctor fs format` + +**Usage:** `doctor fs format` + +Same rebuild path as `fix`, but always formats first. Destructive to flash contents not held in RAM. + +**Reply:** `OK fs formatted from RAM` or an error. + +Works over remote admin. + +--- + +### `doctor fs stat` + +**Usage:** `doctor fs stat` **Serial only.** Prints partition summary lines prefixed `FS_STAT` on serial, then a one-line summary reply. +**Serial output (nRF52 InternalFS example):** + +``` +FS_STAT begin +FS_STAT total 28672 +FS_STAT used~ 28544 free~ 128 +FS_STAT blocks 224 used 223 bsize 128 +FS_STAT file /prefs.json 0 +FS_STAT file /_main.id 96 +... +FS_STAT end +``` + **Reply:** `OK free~=128/28672 blk=223/224` +--- + +### `doctor fs ls` + +**Usage:** `doctor fs ls` + +**Serial only.** Recursive listing; lines prefixed `FS_LS` on serial. + +**Reply:** `OK see serial FS_LS` + +Use to find large cruft (`packet_log`, legacy Meshtastic `prefs/`, `com_prefs`, etc.). + +--- + +### `doctor fs probe` + +**Usage:** `doctor fs probe` + +**Serial only.** Writes temporary probe files at increasing sizes (1 B through 4096 B), then runs the same prefs JSON write test as `check`. Lines prefixed `FS_PROBE` on serial. + +**Reply examples:** + +- `OK raw_max=512 prefs=ok` +- `OK raw_max=128 fail>=256@write prefs=fail` + +--- + +### `doctor fs dump` + +**Usage:** `doctor fs dump` + +**Serial only.** Hex-dumps the raw flash region backing InternalFS (`FS_DUMP` lines). For deep corruption analysis; not needed for normal recovery. + +**Reply:** `OK dumped N bytes` + +--- + +## Filesystem garbage collection + ### `doctor gc` +**Usage:** `doctor gc` + Removes reclaimable files that commonly fill InternalFS: | Path | Reason | @@ -73,17 +155,7 @@ Removes reclaimable files that commonly fill InternalFS: **Reply:** `OK gc removed N item(s)` -### `doctor ls` (bench) - -**Serial only.** Recursive listing; lines prefixed `FS_LS` on serial. **Reply:** `OK see serial FS_LS` - -### `doctor probe` (bench) - -**Serial only.** Writes temporary probe files at increasing sizes, then runs the prefs JSON write test. Lines prefixed `FS_PROBE` on serial. - -### `doctor dump` (bench) - -**Serial only.** Hex-dumps the raw flash region backing InternalFS. **Reply:** `OK dumped N bytes` +Works over remote admin. Run before `set …` when `doctor fs stat` shows the partition nearly full. --- @@ -110,8 +182,8 @@ When a `set …` or `password …` save fails, replies are specific instead of a |-----------|--------| | Partition critically full (≤2 free blocks on InternalFS) | `ERR no space left on device (try: doctor gc)` | | LittleFS returned NOSPC | Same as above | -| Other LFS error | `ERR prefs failed lfs=-NN (try: doctor gc)` | -| JSON serialize failure | `ERR prefs serialize failed (try: doctor gc)` | +| Other LFS error | `ERR prefs failed lfs=-NN (try: doctor fs fix)` | +| JSON serialize failure | `ERR prefs serialize failed (try: doctor fs fix)` | Stages: `open`, `write`, `rename`, `serialize`, `nospc`. @@ -152,7 +224,7 @@ Companions use the same **28 KB InternalFS** on nRF52840 but **do not expose `do **Deferred to v0.3.0** (see ota repo `docs/planned/v0.3.0.md`): - Enable `EXTRAFS=1` on WisMesh companion (match RAK4631: contacts on secondary volume). -- Expose `doctor check` / `doctor gc` on companion (USB serial debug at minimum). +- Expose `doctor fs check` / `doctor gc` on companion (USB serial debug at minimum). - Align with multi-volume FS CLI naming work. v0.2.0 ships atomic saves and boot fsck on companion only. @@ -161,15 +233,14 @@ v0.2.0 ships atomic saves and boot fsck on companion only. ## Upstream notes -See MeshEnvy `docs/good-upstream-contributor-policy.md` in the ota repo for GUCP tracking. Sensible upstream targets: +See MeshEnvy `docs/upstream-prs.md` in the ota repo for PR tracking. Sensible upstream targets: | Change | Upstream? | |--------|-----------| -| Atomic prefs save (tmp + rename) | **Yes** — `meshcore-dev/MeshCore` `dev` | -| FS save error surfacing (`lfs=-NN`, NOSPC hint) | **Yes** — prerequisite for useful `doctor check` / `set` failures | -| `doctor stat`, `doctor gc`, `doctor check` | **Yes** — flat CLI | -| `doctor ls` / `probe` / `dump` | **No** — EnvyOS bench tooling | -| Adafruit LFS `fsLastErr` hooks | **Maybe** — ship as part of error-surfacing PR or upstream equivalent | +| Atomic prefs save | **Yes** — `meshcore-dev/MeshCore` `dev` | | `log tail` | **Yes** — PR open | | Next-hop retry | **Yes** — PR open | +| `doctor fs check` (prefs write probe only) | **Maybe** — lighter subset without dump/probe/gc | +| Full `doctor fs` + `doctor gc` | **EnvyOS-only** — bench/ops tooling | +| Adafruit LFS `fsLastErr` hooks | **EnvyOS-only** — vendor fork patch | | Packet log size cap | **Yes** — recommended future MeshCore fix (not in v0.2.0) | From b5e41816eeb2733ec91aea33693c1b257e0afc24 Mon Sep 17 00:00:00 2001 From: Ben Allfree Date: Tue, 25 Aug 2026 01:38:09 -0700 Subject: [PATCH 59/59] fix(native): guard atomic FS for MESHCORE_NATIVE_TEST Co-authored-by: Cursor --- platformio.ini | 1 + src/helpers/ConfigSerializer.cpp | 4 ++++ src/helpers/ConfigSerializer.h | 2 ++ 3 files changed, 7 insertions(+) diff --git a/platformio.ini b/platformio.ini index 427654e093..07da61f683 100644 --- a/platformio.ini +++ b/platformio.ini @@ -203,6 +203,7 @@ lib_deps = platform = native test_framework = googletest build_flags = -std=c++17 + -D MESHCORE_NATIVE_TEST=1 -I src -I test/mocks test_build_src = yes diff --git a/src/helpers/ConfigSerializer.cpp b/src/helpers/ConfigSerializer.cpp index 09d38c4af4..e918c5e653 100644 --- a/src/helpers/ConfigSerializer.cpp +++ b/src/helpers/ConfigSerializer.cpp @@ -1,6 +1,8 @@ #include "ConfigSerializer.h" #include #include + +#ifndef MESHCORE_NATIVE_TEST #include "FsLastErr.h" #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) @@ -94,6 +96,8 @@ bool saveConfigJsonAtomic(FILESYSTEM* fs, ConfigSerializer& obj, const char* fin return true; } +#endif // !MESHCORE_NATIVE_TEST + bool ConfigSerializer::saveSerial(Stream& s) { Context context(&s, OP::WRITE); _context = &context; // set the context for structure() call diff --git a/src/helpers/ConfigSerializer.h b/src/helpers/ConfigSerializer.h index 830a6567e6..d07d1b9987 100644 --- a/src/helpers/ConfigSerializer.h +++ b/src/helpers/ConfigSerializer.h @@ -67,6 +67,7 @@ class ConfigSerializer { bool saveSerial(Stream& s); }; +#ifndef MESHCORE_NATIVE_TEST #include "IdentityStore.h" typedef bool (*FileWriteFn)(File& file, void* ctx); @@ -78,3 +79,4 @@ bool writeFileAtomic(FILESYSTEM* fs, const char* final_path, const char* tmp_pat // Write JSON to tmp_path, then lfs_rename over final_path. Keeps the old file on failed writes. bool saveConfigJsonAtomic(FILESYSTEM* fs, ConfigSerializer& obj, const char* final_path, const char* tmp_path, char* err_stage = nullptr, size_t err_stage_len = 0); +#endif