Files
MeshCore/src/helpers/ota/OtaManager.cpp
T

2030 lines
88 KiB
C++

#include "OtaManager.h"
#include "OtaProtocol.h"
#include "MerkleTree.h"
#include "Multihash.h"
#include "OtaByteIO.h"
#include "OtaDebug.h"
#include <string.h>
#include <stdlib.h> // malloc/free for the (transient) leaf-diff buffer
namespace mesh {
namespace ota {
OtaManager::~OtaManager() {
free(_catalog_heap);
}
bool OtaManager::expandCatalog() {
if (_catalog_heap || OTA_INLINE_CATALOG >= OTA_MAX_CATALOG) return false;
CatRow* expanded = static_cast<CatRow*>(malloc(sizeof(CatRow) * OTA_MAX_CATALOG));
if (!expanded) return false;
memcpy(expanded, _catalog_inline, sizeof(CatRow) * _n_cat);
_catalog_heap = expanded;
return true;
}
uint8_t* OtaManager::ensureSourceLeaves() {
#if defined(ESP32_PLATFORM)
if (!_src_leaves) {
_src_leaves = static_cast<uint8_t*>(malloc(OTA_PROOFGEN_SCRATCH));
}
#endif
return _src_leaves;
}
uint8_t* OtaManager::ensureScratch() {
#if defined(ESP32_PLATFORM)
if (!_scratch) {
_scratch = static_cast<uint8_t*>(malloc(OTA_PROOFGEN_SCRATCH));
}
#endif
return _scratch;
}
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;
_fetch_error = FETCH_ERROR_NONE;
_archive_fetch = false; _validate = false;
clearFetchIntent();
_resume_verify_idx = 0; _resume_invalidated = false;
_resume_merkle.reset();
_n_serve = 0; _n_src_obj = 0; _view0.valid = false; _srcv.valid = false;
memset(_src_offered, 0, sizeof(_src_offered));
memset(_src_advertised, 0, sizeof(_src_advertised));
_n_src = 0; _n_cat = 0;
clearPendingEgress();
}
// ---------------- 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) {
uint8_t* scratch = ensureScratch();
if (!scratch) return false;
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.read_deflated = nullptr; _view0.deflate_ctx = nullptr;
_view0.scratch = scratch; _view0.scratch_sz = OTA_PROOFGEN_SCRATCH; // <=1024 blocks (RAM .mota is small)
_view0.valid = true;
clearPendingEgress();
if (_n_src_obj) refresh_sources(); else 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.read_deflated = nullptr; _view0.deflate_ctx = nullptr;
_view0.scratch = proof_scratch; _view0.scratch_sz = proof_scratch_sz; // sized for our (large) image
_view0.valid = true;
clearPendingEgress();
if (_n_src_obj) refresh_sources(); else 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;
if (_n_serve > 0 && !_serve[0].is_self) {
if (_n_serve < OTA_MAX_SERVE) _n_serve++;
for (uint8_t i = _n_serve - 1; i > 0; i--) {
_serve[i] = _serve[i - 1];
}
} else if (_n_serve == 0) {
_n_serve = 1;
}
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.src = nullptr; e.src_idx = 0;
}
bool OtaManager::add_source(MotaSource* src) {
if (!src) return false;
for (uint8_t i = 0; i < _n_src_obj; i++) {
if (_src_list[i] == src) { refresh_sources(); return true; }
}
if (_n_src_obj >= OTA_MAX_SOURCE_OBJ) return false;
_src_list[_n_src_obj] = src;
_src_offered[_n_src_obj] = 0;
_src_advertised[_n_src_obj] = 0;
_n_src_obj++;
refresh_sources();
return true;
}
bool OtaManager::remove_source(MotaSource* src) {
if (!src) return false;
for (uint8_t i = 0; i < _n_src_obj; i++) {
if (_src_list[i] != src) continue;
for (uint8_t j = i + 1; j < _n_src_obj; j++) {
_src_list[j - 1] = _src_list[j];
_src_offered[j - 1] = _src_offered[j];
_src_advertised[j - 1] = _src_advertised[j];
}
_n_src_obj--;
_src_list[_n_src_obj] = nullptr;
_src_offered[_n_src_obj] = 0;
_src_advertised[_n_src_obj] = 0;
refresh_sources();
return true;
}
return false;
}
void OtaManager::refresh_sources() {
clearPendingEgress();
_n_serve = 0;
if (_view0.valid) registerSelfEntry();
for (uint8_t s = 0; s < _n_src_obj; s++) {
_src_offered[s] = 0;
_src_advertised[s] = 0;
MotaSource* src = _src_list[s];
if (!src) continue;
uint8_t cnt = src->count();
_src_offered[s] = cnt;
for (uint8_t i = 0; i < cnt; i++) {
if (_n_serve >= OTA_MAX_SERVE) break;
MotaDesc d;
if (!src->describe(i, d)) continue;
if (d.block_count == 0 || (uint64_t)d.block_count * 4 > OTA_PROOFGEN_SCRATCH) continue;
// A legacy serial descriptor left the geometry byte zero. Inspect its fixed manifest once so it is
// either proven servable before advertisement or excluded from the registry.
if (d.block_size_log2 == 0) {
uint8_t mf[MOTA_MFL];
MotaManifest parsed;
if (!src->read(i, 8, mf, sizeof(mf)) || !mota_parse_manifest(mf, sizeof(mf), parsed) ||
memcmp(parsed.merkle_root, d.mid, 4) != 0 || parsed.block_count != d.block_count ||
parsed.payload_size != d.payload_size) continue;
d.block_size_log2 = parsed.block_size_log2;
}
if (d.block_size_log2 >= 32 || (1UL << d.block_size_log2) > OTA_MAX_BLOCK) continue;
if (d.leaves_off != 8 + MOTA_MFL ||
(uint64_t)d.leaves_off + (uint64_t)d.block_count * 4 != d.payload_off ||
(uint64_t)d.payload_off + d.payload_size + 5 != d.total_size) 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.src = src; e.src_idx = i; e.desc = d;
_src_advertised[s]++;
}
}
_srcv.valid = false; // a loaded source view may now be stale; reloads on demand
}
bool OtaManager::sourceStats(const MotaSource* src, uint16_t& offered, uint16_t& advertised) const {
offered = 0;
advertised = 0;
if (!src) return false;
for (uint8_t i = 0; i < _n_src_obj; i++) {
if (_src_list[i] != src) continue;
offered = _src_offered[i];
advertised = _src_advertised[i];
return true;
}
return false;
}
void OtaManager::clear_sources() {
clearPendingEgress();
_n_src_obj = 0; _srcv.valid = false;
memset(_src_list, 0, sizeof(_src_list));
memset(_src_offered, 0, sizeof(_src_offered));
memset(_src_advertised, 0, sizeof(_src_advertised));
_n_serve = 0;
if (_view0.valid) registerSelfEntry();
}
void OtaManager::clear_primary() {
clearPendingEgress();
_view0.valid = false;
if (_n_src_obj) refresh_sources();
else _n_serve = 0;
}
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 and the leaves, bounded by the configured
// proof-generation scratch size.)
bool OtaManager::loadSource(const ServeEntry& e) {
const MotaDesc& d = e.desc;
if (d.leaves_off < 8) return false;
if (!e.src) 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 > OTA_PROOFGEN_SCRATCH) return false;
uint8_t* src_leaves = ensureSourceLeaves();
uint8_t* scratch = ensureScratch();
if (!src_leaves || !scratch) return false;
bool ok = e.src->read(e.src_idx, 8, _src_manifest, mfl);
if (!ok || !mota_parse_manifest(_src_manifest, mfl, _srcv.m)) return false;
if (_srcv.m.block_size() == 0 || _srcv.m.block_size() > OTA_MAX_BLOCK) 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.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.src; _srcv_rdctx.idx = e.src_idx;
_srcv_rdctx.payload_off = d.payload_off;
_srcv.read = srcReadTramp; _srcv.read_ctx = &_srcv_rdctx;
_srcv.read_deflated = (d.source_caps & MOTA_SOURCE_CAP_DEFLATE_BLOCK)
? srcDeflateReadTramp : nullptr;
_srcv.deflate_ctx = _srcv.read_deflated ? &_srcv_rdctx : nullptr;
_srcv.scratch = scratch; _srcv.scratch_sz = OTA_PROOFGEN_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;
return x->src->read(x->idx, x->payload_off + off, buf, len);
}
bool OtaManager::srcDeflateReadTramp(void* c, uint16_t block, uint8_t* buf,
uint16_t cap, uint16_t* len) {
SrcReadCtx* x = (SrcReadCtx*)c;
return x->src->read_deflated_block(x->idx, block, buf, cap, len);
}
// sha2-256:4 over the SORTED set of mids we serve - peers use it to tell if our offering changed. Sorting
// makes it canonical across nodes regardless of insert order; for a single mota it is mh4(mid) (unchanged).
void OtaManager::setDigest(uint8_t out[4]) const {
if (_n_serve == 0) { memset(out, 0, 4); return; }
uint8_t order[OTA_MAX_SERVE];
for (uint8_t i = 0; i < _n_serve; i++) order[i] = i;
for (uint8_t i = 1; i < _n_serve; i++) { // insertion sort by mid
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;
// Suppress only a pending query whose scope is covered by the overheard request. A target-filtered or
// fragment-only query cannot stand in for our unfiltered initial request.
for (uint8_t i = 0; i < _n_src; i++) {
Source& s = _sources[i];
if (!s.query_pending || memcmp(s.seeder, q.seeder_id, 4) != 0 ||
memcmp(s.digest, q.set_digest, 4) != 0 || q.filter_target != 0) continue;
uint32_t missing = 0;
if (s.have_total) {
uint32_t full = s.have_total >= 32 ? UINT32_MAX : ((1UL << s.have_total) - 1);
missing = full & ~s.have_mask;
}
if (q.want_fragments == 0 || (missing != 0 && (q.want_fragments & missing) == missing)) {
s.query_pending = false;
s.query_retry_at = _now_ms + OTA_CATALOG_RETRY_MS;
}
}
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);
const uint8_t per = (uint8_t)((MAX_PACKET_PAYLOAD - 12) / OTA_HAVE_ROW_BYTES); // rows per HAVE fragment
uint8_t ftotal = (uint8_t)((_n_serve + per - 1) / per); if (ftotal == 0) ftotal = 1;
if (ftotal > OTA_HAVE_MAX_FRAGMENTS) return; // QueryMsg's recovery bitmap cannot represent it
// Fragment positions are canonical across rescans: digest-stable sets sort into the same pages even if
// FAT/folder enumeration order changed. filter_target removes rows from those pages but never renumbers
// fragments, so filtered and unfiltered HAVE floods cannot corrupt each other's completion bitmap.
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++) {
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;
}
for (uint8_t fi = 0; fi < ftotal; fi++) {
if (q.want_fragments != 0 && !(q.want_fragments & (1UL << fi))) continue;
const uint8_t first = (uint8_t)(fi * per);
uint8_t rowbuf[MAX_PACKET_PAYLOAD];
uint8_t cnt = 0;
uint16_t end = (uint16_t)first + per; if (end > _n_serve) end = _n_serve;
for (uint16_t pos = first; pos < end; pos++) {
const ServeEntry& e = _serve[order[pos]];
if (q.filter_target != 0 && q.filter_target != e.target_id) continue;
uint8_t* row = rowbuf + (uint32_t)cnt * 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;
row[14] = (uint8_t)(hc & 0xFF); row[15] = (uint8_t)(hc >> 8);
cnt++;
}
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 : nullptr;
uint8_t b[MAX_PACKET_PAYLOAD];
emit(b, encode_have(b, sizeof(b), hv), true); // broadcast: all neighbours cache it
}
}
bool OtaManager::handleGetManifest(const uint8_t* m, uint16_t n) {
GetManifestMsg gm;
if (!decode_get_manifest(m, n, gm)) return false;
ServeView* v = resolve(gm.manifest_id);
if (!v) return false;
OTA_DBG("OTA: GET_MANIFEST want=%04x pending=%u\n",
(unsigned)gm.want_mask, (unsigned)_n_manifest_jobs);
// Retain the request and let serviceEgress admit its fragments one at a time. In particular, a deployed
// receiver with the legacy 32-second SF5 flood delay must receive both fragments from the first request;
// serializing fragment 1 behind the receiver's next GET would exhaust its manifest retry window.
const bool queued = queueManifestJob(v->m.merkle_root, gm.want_mask);
if (!queued) {
OTA_DBG("OTA: GET_MANIFEST response queue full\n");
}
// A node that successfully queued the requested MID is the terminal source. Do not also re-flood the
// request; that echo competes with the manifest on the same temporary-radio channel. If the bounded queue
// is full, return false so another source or a later retry can service it.
return queued;
}
// 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 each pipeline
// slot 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);
}
// Retain a bounded descriptor instead of allocating every DATA packet inline. Repeated requests that arrive
// while a block is already queued merge only fragments not yet admitted to the radio queue; a later retry can
// enqueue the block again if one of those admitted packets was actually lost over the air.
bool OtaManager::queueServeJob(const uint8_t* mid, uint16_t block, uint16_t want_mask,
bool wire_v2, bool allow_deflate) {
for (uint8_t i = 0; i < _n_serve_jobs; i++) {
ServeJob& job = _serve_jobs[i];
if (job.block != block || memcmp(job.mid, mid, 4) != 0) continue;
// A proof-only request can complete whichever representation is already queued. DATA requests with
// different geometries remain distinct because their bitmap positions do not describe the same bytes.
if (want_mask != 0 && (job.wire_v2 != wire_v2 ||
(wire_v2 && job.allow_deflate != allow_deflate))) continue;
job.pending_mask |= (uint16_t)(want_mask & ~job.emitted_mask);
if (want_mask == 0) job.proof_requested = true;
return true;
}
if (_n_serve_jobs >= OTA_SERVE_QUEUE) return false;
ServeJob& job = _serve_jobs[_n_serve_jobs++];
memcpy(job.mid, mid, 4);
job.block = block;
job.pending_mask = want_mask;
job.emitted_mask = 0;
job.proof_ready_at = 0;
job.proof_requested = want_mask == 0;
job.wire_v2 = wire_v2;
job.allow_deflate = allow_deflate;
return true;
}
bool OtaManager::queueManifestJob(const uint8_t* mid, uint16_t want_mask) {
for (uint8_t i = 0; i < _n_manifest_jobs; i++) {
ManifestServeJob& job = _manifest_jobs[i];
if (memcmp(job.mid, mid, 4) != 0) continue;
job.pending_mask |= (uint16_t)(want_mask & ~job.emitted_mask);
return true;
}
if (_n_manifest_jobs >= OTA_MANIFEST_SERVE_QUEUE) return false;
ManifestServeJob& job = _manifest_jobs[_n_manifest_jobs++];
memcpy(job.mid, mid, 4);
job.pending_mask = want_mask;
job.emitted_mask = 0;
job.ready_at = _now_ms + manifestEgressGapMs();
return true;
}
uint32_t OtaManager::manifestEgressGapMs() const {
if (_radio_packet_airtime_ms == 0) return OTA_MANIFEST_EGRESS_MIN_GAP_MS;
uint64_t gap = (uint64_t)_radio_packet_airtime_ms * _tx_spacing_permille;
gap = (gap + 999u) / 1000u;
if (gap < OTA_MANIFEST_EGRESS_MIN_GAP_MS) gap = OTA_MANIFEST_EGRESS_MIN_GAP_MS;
if (gap > OTA_MANIFEST_EGRESS_MAX_GAP_MS) gap = OTA_MANIFEST_EGRESS_MAX_GAP_MS;
return (uint32_t)gap;
}
uint32_t OtaManager::proofEgressGapMs() const {
if (_radio_packet_airtime_ms == 0) return OTA_PROOF_EGRESS_MIN_GAP_MS;
// The adapter may have one response transmitting and two more admitted. Start this timer when the final
// DATA is accepted, so cover all three packet-service intervals before opening the legacy-request turn.
uint64_t gap = (uint64_t)_radio_packet_airtime_ms * _tx_spacing_permille
* OTA_PROOF_EGRESS_DRAIN_PACKETS;
gap = (gap + 999u) / 1000u;
if (gap < OTA_PROOF_EGRESS_MIN_GAP_MS) gap = OTA_PROOF_EGRESS_MIN_GAP_MS;
if (gap > OTA_PROOF_EGRESS_MAX_GAP_MS) gap = OTA_PROOF_EGRESS_MAX_GAP_MS;
return (uint32_t)gap;
}
void OtaManager::clearPendingEgress() {
_n_manifest_jobs = 0;
_n_serve_jobs = 0;
_serve_block_len = 0;
_serve_block_loaded = false;
_serve_wire_block = nullptr;
_serve_wire_len = 0;
_serve_wire_deflated = false;
memset(_serve_wire_id, 0, sizeof(_serve_wire_id));
}
void OtaManager::popManifestJob() {
if (_n_manifest_jobs == 0) return;
for (uint8_t i = 1; i < _n_manifest_jobs; i++) _manifest_jobs[i - 1] = _manifest_jobs[i];
_n_manifest_jobs--;
}
bool OtaManager::serviceManifestEgress() {
if (_n_manifest_jobs == 0) return false;
ManifestServeJob& job = _manifest_jobs[0];
if ((int32_t)(_now_ms - job.ready_at) < 0) {
return true;
}
ServeView* v = resolve(job.mid);
if (!v) {
popManifestJob();
return true;
}
const uint32_t mfl = v->mfl;
uint8_t ftotal = (uint8_t)((mfl + OTA_MF_FRAG - 1) / OTA_MF_FRAG);
if (ftotal == 0) ftotal = 1;
const uint16_t valid_mask = frag_full_mask(ftotal);
job.pending_mask &= valid_mask;
if (job.pending_mask == 0) {
popManifestJob();
return true;
}
uint8_t fragment = 0;
while (fragment < 16 && !(job.pending_mask & (1u << fragment))) fragment++;
if (fragment >= ftotal) {
popManifestJob();
return true;
}
const uint32_t off = (uint32_t)fragment * OTA_MF_FRAG;
uint32_t len = mfl - off;
if (len > OTA_MF_FRAG) len = OTA_MF_FRAG;
ManifestMsg message;
memcpy(message.manifest_id, v->m.merkle_root, 4);
message.frag_idx = fragment;
message.frag_total = ftotal;
message.bytes = v->m.manifest_start + off;
message.len = (uint16_t)len;
uint8_t wire[MAX_PACKET_PAYLOAD];
const uint16_t wire_len = encode_manifest(wire, sizeof(wire), message);
if (emit(wire, wire_len, false)) {
OTA_DBG("OTA: MANIFEST tx frag=%u/%u len=%u\n",
(unsigned)fragment, (unsigned)ftotal, (unsigned)len);
const uint16_t bit = (uint16_t)(1u << fragment);
job.pending_mask &= (uint16_t)~bit;
job.emitted_mask |= bit;
if (job.pending_mask == 0) {
popManifestJob();
} else {
// Follow the active packet airtime and dispatcher duty spacing while
// retaining a floor for fast radios' TX-to-RX turnaround.
job.ready_at = _now_ms + manifestEgressGapMs();
}
}
return true;
}
void OtaManager::popServeJob() {
if (_n_serve_jobs == 0) return;
for (uint8_t i = 1; i < _n_serve_jobs; i++) _serve_jobs[i - 1] = _serve_jobs[i];
_n_serve_jobs--;
_serve_block_len = 0;
_serve_block_loaded = false;
_serve_wire_block = nullptr;
_serve_wire_len = 0;
_serve_wire_deflated = false;
memset(_serve_wire_id, 0, sizeof(_serve_wire_id));
}
bool OtaManager::loadActiveServeBlock() {
if (_n_serve_jobs == 0) return false;
ServeJob& job = _serve_jobs[0];
ServeView* v = resolve(job.mid);
if (!v || job.block >= v->m.block_count) return false;
uint32_t bs = v->m.block_size();
if (bs == 0 || bs > OTA_MAX_BLOCK) return false;
uint32_t off = (uint32_t)job.block * bs;
uint32_t blen = (off + bs <= v->m.payload_size) ? bs : (v->m.payload_size - off);
if (v->read) {
if (!v->read(v->read_ctx, off, _serve_block, blen)) return false;
} else {
memcpy(_serve_block, v->m.payload + off, blen);
}
_serve_block_len = (uint16_t)blen;
_serve_wire_block = _serve_block;
_serve_wire_len = _serve_block_len;
_serve_wire_deflated = false;
// A host-backed MotaSource can provide a pre-compressed block without putting an encoder in firmware.
// Other sources may opt into a caller-supplied encoder. Both write into the proof scratch, which is safe
// until every DATA fragment has left; proof generation reuses it only after that point.
if (job.wire_v2 && job.allow_deflate && v->scratch && v->scratch_sz >= blen) {
uint16_t encoded_len = 0;
bool encoded = false;
if (v->read_deflated) {
encoded = v->read_deflated(v->deflate_ctx, job.block, v->scratch,
(uint16_t)blen, &encoded_len);
} else if (_deflate_encode) {
encoded = _deflate_encode(_deflate_encode_ctx, _serve_block, (uint16_t)blen,
v->scratch, (uint16_t)blen, &encoded_len);
}
if (encoded && encoded_len > 0 && encoded_len < blen) {
_serve_wire_block = v->scratch;
_serve_wire_len = encoded_len;
_serve_wire_deflated = true;
}
}
mh4(_serve_wire_id, _serve_wire_block, _serve_wire_len);
_serve_block_loaded = true;
return true;
}
bool OtaManager::handleReq(const uint8_t* m, uint16_t n) {
ReqWindowMsg rq;
if (!decode_req_window(m, n, rq)) return false;
ServeView* v = resolve(rq.manifest_id);
if (!v) return false;
bool accepted = false;
const uint32_t block_size = v->m.block_size();
for (uint8_t i = 0; i < rq.n_items; i++) {
const uint32_t idx = rq.items[i].block_idx;
if (idx >= v->m.block_count) continue;
uint32_t blen = block_size;
const uint32_t off = idx * block_size;
if (off + blen > v->m.payload_size) blen = v->m.payload_size - off;
const bool wire_v2 = ota_req_is_v2(rq.items[i].want_mask);
const bool allow_deflate = wire_v2 &&
(rq.items[i].want_mask & OTA_REQ_V2_ALLOW_DEFLATE) != 0;
const uint16_t valid_mask = frag_full_mask((blen +
(wire_v2 ? OTA_FRAG_DATA_V2 : OTA_FRAG_DATA) - 1) /
(wire_v2 ? OTA_FRAG_DATA_V2 : OTA_FRAG_DATA));
const uint16_t requested = wire_v2
? ota_req_v2_fragments(rq.items[i].want_mask) : rq.items[i].want_mask;
const uint16_t want = (uint16_t)(requested & valid_mask);
if (want != 0) accepted |= queueServeJob(v->m.merkle_root, (uint16_t)idx, want,
wire_v2, allow_deflate);
}
return accepted;
}
bool OtaManager::handleReqProof(const uint8_t* m, uint16_t n) {
ReqProofMsg rp;
if (!decode_req_proof(m, n, rp)) return false;
ServeView* v = resolve(rp.manifest_id);
if (!v || rp.block_idx >= v->m.block_count) return false;
if ((uint64_t)v->m.block_count * 4 > v->scratch_sz) return false;
return queueServeJob(v->m.merkle_root, rp.block_idx, 0); // proof-only, or merge with queued DATA
}
void OtaManager::serviceEgress() {
// Metadata comes first so an older receiver gets every manifest fragment before its retry horizon. The
// send callback supplies radio-queue backpressure, and a rejected fragment remains in this descriptor.
if (serviceManifestEgress()) return;
// A proactive proof normally follows the final DATA fragment. If it was lost, or the source is older and
// never sent one, issue the legacy proof request after a short grace. Stay RX-silent while another slot
// still expects DATA, and restart the grace on every new packet, so this fallback cannot collide with a
// legitimate multi-block response train. Admit at most one fallback per call.
if (_fstate == FETCHING && !flightHasPendingData()
&& (uint32_t)(_now_ms - _fetch_wait_since_ms) >= proofGraceMs()) {
for (uint8_t i = 0; i < OTA_FETCH_PIPELINE; i++) {
ReassemblySlot& slot = _reasm[i];
if (!slot.awaiting_proof || slot.proof_request_at == 0
|| (int32_t)(_now_ms - slot.proof_request_at) < 0) continue;
if (requestSlot(i)) return;
break;
}
}
if (_n_serve_jobs == 0) return;
ServeJob& job = _serve_jobs[0];
ServeView* v = resolve(job.mid);
if (!v || job.block >= v->m.block_count
|| (uint64_t)v->m.block_count * 4 > v->scratch_sz) {
popServeJob();
return;
}
if (job.pending_mask != 0) {
if (!_serve_block_loaded && !loadActiveServeBlock()) {
popServeJob();
return;
}
const uint16_t fragment_data = job.wire_v2 ? OTA_FRAG_DATA_V2 : OTA_FRAG_DATA;
const uint16_t valid_mask = frag_full_mask(
(_serve_wire_len + fragment_data - 1u) / fragment_data);
job.pending_mask &= valid_mask;
if (job.pending_mask == 0) {
job.proof_ready_at = _now_ms + proofEgressGapMs();
return;
}
uint8_t fragment = 0;
while (fragment < 16 && !(job.pending_mask & (1u << fragment))) fragment++;
uint32_t frag_off = (uint32_t)fragment * fragment_data;
if (fragment >= 16 || frag_off >= _serve_wire_len) {
job.pending_mask = 0;
return;
}
uint32_t frag_len = _serve_wire_len - frag_off;
if (frag_len > fragment_data) frag_len = fragment_data;
DataMsg dm;
memcpy(dm.manifest_id, job.mid, 4);
dm.block_idx = job.block;
uint8_t v2_data[OTA_DATA_V2_STREAM_ID_BYTES + OTA_FRAG_DATA_V2];
if (job.wire_v2) {
if (!ota_data_v2_pack(fragment, _serve_wire_len, _serve_wire_deflated, dm.frag_off)) {
popServeJob();
return;
}
memcpy(v2_data, _serve_wire_id, OTA_DATA_V2_STREAM_ID_BYTES);
memcpy(v2_data + OTA_DATA_V2_STREAM_ID_BYTES, _serve_wire_block + frag_off, frag_len);
dm.data = v2_data;
dm.data_len = (uint16_t)(OTA_DATA_V2_STREAM_ID_BYTES + frag_len);
} else {
dm.frag_off = (uint16_t)frag_off;
dm.data = _serve_block + frag_off;
dm.data_len = (uint16_t)frag_len;
}
uint8_t b[MAX_PACKET_PAYLOAD];
if (emit(b, encode_data(b, sizeof(b), dm), false)) {
const uint16_t bit = (uint16_t)(1u << fragment);
job.pending_mask &= (uint16_t)~bit;
job.emitted_mask |= bit;
if (job.pending_mask == 0) {
// Legacy receivers transmit REQ_PROOF as soon as the last DATA fragment arrives. Do not admit our
// proactive proof into the radio queue at the same instant: the two half-duplex transmissions would
// collide and force the receiver onto its multi-second retry tick. Include every response that the
// adapter can already have admitted ahead of this proof, then retain a bounded fast-link floor.
job.proof_ready_at = _now_ms + proofEgressGapMs();
}
}
return;
}
// An explicit legacy REQ_PROOF proves that the source has returned to RX and bypasses the proactive delay.
// A newer receiver sends no request unless the proof was lost, so its normal proactive path waits for the
// radio-aware gap and remains compatible with the existing proof-grace deadline.
if (!job.proof_requested && job.proof_ready_at != 0
&& (int32_t)(_now_ms - job.proof_ready_at) < 0) return;
uint8_t proof[32 * 4];
uint8_t np = merkle_gen_proof(v->m.leaves, v->m.block_count, job.block, v->scratch, proof);
ProofMsg pm;
memcpy(pm.manifest_id, job.mid, 4);
pm.block_idx = job.block;
pm.n_proof = np;
pm.proof = proof;
uint8_t b[MAX_PACKET_PAYLOAD];
if (emit(b, encode_proof(b, sizeof(b), pm), false)) popServeJob();
}
// ---------------- fetch ----------------
// A source's set digest is the lifetime of its catalog rows. When that digest changes (or the source table
// evicts the source), remove only that seeder's association from every row and recompute aggregate progress.
// Rows with no remaining source disappear, so numeric/MID selection cannot target firmware nobody offers.
void OtaManager::invalidateCatalogSeeder(const uint8_t* seeder) {
if (!seeder) return;
CatRow* catalog = catalogData();
for (uint16_t i = 0; i < _n_cat; ) {
CatRow& row = catalog[i];
int found = -1;
for (uint8_t k = 0; k < row.n_seeders; k++) {
if (memcmp(row.seeders[k], seeder, 4) == 0) { found = k; break; }
}
if (found < 0) { i++; continue; }
for (uint8_t k = (uint8_t)found + 1; k < row.n_seeders; k++) {
memcpy(row.seeders[k - 1], row.seeders[k], 4);
row.seeder_have[k - 1] = row.seeder_have[k];
row.seeder_last_ms[k - 1] = row.seeder_last_ms[k];
}
row.n_seeders--;
if (row.n_seeders == 0) {
for (uint16_t j = i + 1; j < _n_cat; j++) catalog[j - 1] = catalog[j];
_n_cat--;
continue;
}
row.have_max = 0;
row.last_ms = 0;
for (uint8_t k = 0; k < row.n_seeders; k++) {
if (row.seeder_have[k] > row.have_max) row.have_max = row.seeder_have[k];
if (row.seeder_last_ms[k] > row.last_ms) row.last_ms = row.seeder_last_ms[k];
}
i++;
}
}
// 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) { // source explicitly withdrew its set
invalidateCatalogSeeder(a.seeder_id);
for (uint8_t i = 0; i < _n_src; i++) {
if (memcmp(_sources[i].seeder, a.seeder_id, 4) != 0) continue;
for (uint8_t j = i + 1; j < _n_src; j++) _sources[j - 1] = _sources[j];
_n_src--;
break;
}
return;
}
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) {
// Passive HAVE traffic can leave rows for a source before its beacon is retained. Start its advertised
// digest with a clean association, and purge the evicted source when the fixed source table is full.
invalidateCatalogSeeder(a.seeder_id);
if (_n_src < OTA_MAX_SOURCES) slot = _n_src++;
else {
invalidateCatalogSeeder(_sources[lru].seeder);
slot = lru;
}
_sources[slot] = Source{};
}
Source& s = _sources[slot];
bool changed = fresh || memcmp(s.digest, a.set_digest, 4) != 0;
if (changed && !fresh) invalidateCatalogSeeder(s.seeder);
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;
s.have_total = 0;
s.have_mask = 0;
s.query_pending = false;
s.query_owned = false;
s.query_retries = 0;
s.query_retry_at = 0;
}
// interested = auto-fetch enabled, or a manual pull/want is pending. (Browsing queries via queryAll().)
bool interested = _archive_interest || (_autofetch != AUTOFETCH_OFF) ||
_have_desired_mid || _desired_target;
if (interested && !s.have_catalog) {
// A fresh ADV is also a new opportunity after the bounded retry series was exhausted.
if (s.query_retries >= OTA_CATALOG_MAX_RETRY) {
s.query_retries = 0;
s.query_retry_at = 0;
s.query_owned = false;
}
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) {
for (uint8_t i = 0; i < _n_src; i++) {
Source& s = _sources[i];
if (memcmp(s.seeder, seeder, 4) != 0 || memcmp(s.digest, digest, 4) != 0) continue;
if (s.query_pending || s.have_catalog ||
(s.query_owned && s.query_retry_at && (int32_t)(_now_ms - s.query_retry_at) < 0)) return;
uint32_t j = (rd_u32le(seeder) ^ rd_u32le(digest) ^ rd_u32le(_seeder_id)) % OTA_QUERY_SPREAD_MS;
s.query_at = _now_ms + OTA_QUERY_MIN_MS + j;
s.query_pending = true;
s.query_owned = true;
return;
}
}
void OtaManager::sendQuery(const uint8_t* seeder, const uint8_t* digest, uint32_t filter_target,
uint32_t want_fragments) {
QueryMsg q;
memcpy(q.seeder_id, seeder, 4); memcpy(q.set_digest, digest, 4);
q.filter_target = filter_target; q.want_fragments = want_fragments;
uint8_t b[24];
emit(b, encode_query(b, sizeof(b), q), true); // FLOODED so neighbours overhear it and suppress
}
// User-initiated browse (`ota neighbors`): immediately ask every incomplete/changed source. A complete
// digest-tagged catalog is already current, so paging through a 255-row list must not re-flood it each time.
void OtaManager::queryAll() {
for (uint8_t i = 0; i < _n_src; i++) {
Source& s = _sources[i];
if (s.have_catalog) continue;
s.query_pending = false;
s.query_owned = true;
s.query_retries = 0;
s.query_retry_at = _now_ms + OTA_CATALOG_RETRY_MS;
uint32_t want = 0;
if (s.have_total) {
uint32_t full = s.have_total >= 32 ? UINT32_MAX : ((1UL << s.have_total) - 1);
want = full & ~s.have_mask;
}
sendQuery(s.seeder, s.digest, 0, want);
}
}
// 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;
if (hv.frag_total == 0 || hv.frag_total > OTA_HAVE_MAX_FRAGMENTS || hv.frag_idx >= hv.frag_total) 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
// Once a newer beacon changed this source's digest, delayed HAVE fragments from its old set must not
// resurrect rows we just invalidated. Unknown sources remain cacheable because HAVE is broadcast/passive.
for (uint8_t i = 0; i < _n_src; i++) {
if (memcmp(_sources[i].seeder, hv.seeder_id, 4) != 0) continue;
if (memcmp(_sources[i].digest, hv.set_digest, 4) != 0) return;
break;
}
// PASSIVE: every node caches rows it overhears. A source is catalogued only after EVERY advertised
// fragment arrived; otherwise a timed recovery QUERY asks for just the missing bitmap.
for (uint8_t i = 0; i < _n_src; i++) {
Source& s = _sources[i];
if (memcmp(s.seeder, hv.seeder_id, 4) != 0 || memcmp(s.digest, hv.set_digest, 4) != 0) continue;
if (s.have_total != hv.frag_total) {
s.have_total = hv.frag_total;
s.have_mask = 0;
s.have_catalog = false;
}
s.have_mask |= 1UL << hv.frag_idx;
uint32_t full = hv.frag_total >= 32 ? UINT32_MAX : ((1UL << hv.frag_total) - 1);
s.have_catalog = s.have_mask == full;
if (s.have_catalog) {
s.query_pending = false;
s.query_owned = false;
s.query_retries = 0;
s.query_retry_at = 0;
} else {
s.query_retry_at = _now_ms + OTA_CATALOG_RETRY_MS;
}
}
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
CatRow* catalog = catalogData();
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) {
if (_n_cat >= catalogCapacity() && expandCatalog()) catalog = catalogData();
slot = (_n_cat < catalogCapacity()) ? _n_cat++ : lru;
catalog[slot] = CatRow{};
memcpy(catalog[slot].mid, mid, 4);
memcpy(catalog[slot].seeders[0], hv.seeder_id, 4);
catalog[slot].seeder_have[0] = (uint16_t)have_count;
catalog[slot].seeder_last_ms[0] = _now_ms;
catalog[slot].n_seeders = 1;
} else {
CatRow& cc = catalog[slot]; // count DISTINCT sources (no double-count)
int known = -1;
for (uint8_t k = 0; k < cc.n_seeders; k++)
if (memcmp(cc.seeders[k], hv.seeder_id, 4) == 0) { known = k; break; }
if (known < 0 && cc.n_seeders < OTA_CAT_SEEDERS) {
known = cc.n_seeders++;
memcpy(cc.seeders[known], hv.seeder_id, 4);
}
if (known >= 0) {
cc.seeder_have[known] = (uint16_t)have_count;
cc.seeder_last_ms[known] = _now_ms;
}
}
CatRow& c = catalog[slot];
c.target_id = target; c.fw_version = fwver; c.codec = codec; c.flags = flags;
c.have_max = 0; c.last_ms = 0;
for (uint8_t k = 0; k < c.n_seeders; k++) {
if (c.seeder_have[k] > c.have_max) c.have_max = c.seeder_have[k];
if (c.seeder_last_ms[k] > c.last_ms) c.last_ms = c.seeder_last_ms[k];
}
if (wantRow(mid, target, fwver, codec, flags)) startFetch(mid, target);
}
}
void OtaManager::deferCatalog(const uint8_t mid[4], uint32_t until_ms) {
if (!mid) return;
CatRow* catalog = catalogData();
for (uint8_t i = 0; i < _n_cat; i++) {
if (memcmp(catalog[i].mid, mid, 4) == 0) {
catalog[i].retry_after_ms = until_ms;
return;
}
}
}
bool OtaManager::wantRow(const uint8_t* mid, uint32_t target, uint32_t fw_version,
uint8_t codec, uint8_t flags) const {
if (!_fetch || _fstate == FETCHING || _fstate == WANT_MANIFEST
|| _fstate == WANT_LEAVES || _fstate == VERIFYING_STAGED
|| _fstate == PAUSED) return false; // busy
if (_fstate == COMPLETE && memcmp(mid, _fid, 4) == 0) return false; // already have it
if (flags & ~MFLAG_KNOWN) return false;
const bool bootloader = (flags & MFLAG_BOOTLOADER) != 0;
if (!_archive_fetch && !fetchCodecOk(codec, bootloader, _have_desired_mid))
return false; // can't or must not apply this class
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
if (_enforce_auto_version &&
!ota_trusted_auto_version_allows(_running_fw_version, fw_version)) return false;
return true;
}
void OtaManager::clearReassemblySlot(uint8_t slot) {
if (slot >= OTA_FETCH_PIPELINE) return;
_reasm[slot].block = NO_BLOCK;
_reasm[slot].mask = 0;
_reasm[slot].need = 0;
_reasm[slot].awaiting_proof = false;
_reasm[slot].proof_request_at = 0;
_reasm[slot].encoded_len = 0;
_reasm[slot].wire_v2 = false;
_reasm[slot].deflated = false;
memset(_reasm[slot].stream_id, 0, sizeof(_reasm[slot].stream_id));
_reasm[slot].wire_stalls = 0;
}
// Forget all blocks currently being reassembled or awaiting proofs.
void OtaManager::clearReassembly() {
for (uint8_t slot = 0; slot < OTA_FETCH_PIPELINE; slot++) clearReassemblySlot(slot);
_retry_slot = 0;
_req_count = 0;
_pipeline_width = OTA_FETCH_PIPELINE_INITIAL;
_flight_dirty = false;
noteFetchActivity();
}
bool OtaManager::anyWireDataReceived() const {
for (uint8_t slot = 0; slot < OTA_FETCH_PIPELINE; slot++) {
if (_reasm[slot].block != NO_BLOCK && _reasm[slot].mask != 0) return true;
}
return false;
}
void OtaManager::downgradeWireToLegacy() {
if (!_wire_v2_session) return;
_wire_v2_session = false;
_wire_v2_confirmed = false;
_wire_allow_deflate = false;
_wire_empty_stalls = 0;
for (uint8_t i = 0; i < OTA_FETCH_PIPELINE; i++) {
ReassemblySlot& slot = _reasm[i];
if (slot.block == NO_BLOCK) continue;
slot.mask = 0;
slot.need = frag_full_mask((blockLen(slot.block) + OTA_FRAG_DATA - 1) / OTA_FRAG_DATA);
slot.awaiting_proof = false;
slot.proof_request_at = 0;
slot.encoded_len = 0;
slot.wire_v2 = false;
slot.deflated = false;
memset(slot.stream_id, 0, sizeof(slot.stream_id));
slot.wire_stalls = 0;
}
OTA_DBG("OTA: source uses legacy 160-byte DATA geometry\n");
}
int OtaManager::findReassemblySlot(uint32_t block) const {
for (uint8_t slot = 0; slot < OTA_FETCH_PIPELINE; slot++) {
if (_reasm[slot].block == block) return slot;
}
return -1;
}
int OtaManager::findEmptyReassemblySlot() const {
for (uint8_t slot = 0; slot < _pipeline_width; slot++) {
if (_reasm[slot].block == NO_BLOCK) return slot;
}
return -1;
}
uint8_t OtaManager::activePipelineSlots() const {
uint8_t active = 0;
for (uint8_t slot = 0; slot < OTA_FETCH_PIPELINE; slot++) {
if (_reasm[slot].block != NO_BLOCK) active++;
}
return active;
}
bool OtaManager::flightHasPendingData() const {
for (uint8_t slot = 0; slot < OTA_FETCH_PIPELINE; slot++) {
if (_reasm[slot].block != NO_BLOCK && !_reasm[slot].awaiting_proof) return true;
}
return false;
}
static uint8_t count_mask_bits(uint16_t mask) {
uint8_t count = 0;
while (mask) { count += (uint8_t)(mask & 1u); mask >>= 1; }
return count;
}
uint32_t OtaManager::fetchRetryTimeoutMs() const {
if (_radio_packet_airtime_ms == 0) return OTA_FETCH_RETRY_FALLBACK_MS;
// Estimate every packet still owed in the current flight, plus the recovery request itself. DATA uses
// the measured maximum packet airtime; smaller REQ/PROOF packets are deliberately overestimated. Each
// packet must traverse the source+relay path and each transmitter is paced by its configured duty budget.
uint32_t packets = 1;
uint8_t active = 0;
for (uint8_t slot_index = 0; slot_index < OTA_FETCH_PIPELINE; slot_index++) {
const ReassemblySlot& slot = _reasm[slot_index];
if (slot.block == NO_BLOCK) continue;
active++;
packets += slot.awaiting_proof ? 1u
: (uint32_t)count_mask_bits((uint16_t)(slot.need & ~slot.mask)) + 1u;
}
if (active == 0) {
const uint32_t fragment_data = _wire_v2_session ? OTA_FRAG_DATA_V2 : OTA_FRAG_DATA;
const uint32_t fragments = (_fbs + fragment_data - 1) / fragment_data;
packets += (fragments + 1) * _pipeline_width;
}
uint32_t path_transmissions = _observed_path_transmissions;
if (path_transmissions == 0) path_transmissions = (uint32_t)_max_hops + 1u;
uint64_t service = (uint64_t)packets * _radio_packet_airtime_ms * path_transmissions;
service = (service * _tx_spacing_permille + 999u) / 1000u;
service = (service * 5u + 3u) / 4u; // 25% CAD/relay contention allowance
service += OTA_FETCH_RETRY_GUARD_MS;
if (service < OTA_FETCH_RETRY_MIN_MS) service = OTA_FETCH_RETRY_MIN_MS;
if (service > OTA_FETCH_RETRY_MAX_MS) service = OTA_FETCH_RETRY_MAX_MS;
return (uint32_t)service;
}
uint32_t OtaManager::proofGraceMs() const {
if (_radio_packet_airtime_ms == 0) return OTA_PROOF_GRACE_MS;
uint32_t path_transmissions = _observed_path_transmissions;
if (path_transmissions == 0) path_transmissions = (uint32_t)_max_hops + 1u;
uint64_t grace = (uint64_t)_radio_packet_airtime_ms * path_transmissions;
grace = (grace * _tx_spacing_permille + 999u) / 1000u;
grace += 100u;
if (grace < OTA_PROOF_GRACE_MS) grace = OTA_PROOF_GRACE_MS;
if (grace > OTA_FETCH_RETRY_MAX_MS) grace = OTA_FETCH_RETRY_MAX_MS;
return (uint32_t)grace;
}
void OtaManager::finishFlight() {
const uint8_t old_width = _pipeline_width;
if (_flight_dirty) {
if (_pipeline_width > OTA_FETCH_PIPELINE_INITIAL) {
_pipeline_width = (uint8_t)((_pipeline_width + 1u) / 2u);
if (_pipeline_width < OTA_FETCH_PIPELINE_INITIAL) _pipeline_width = OTA_FETCH_PIPELINE_INITIAL;
}
} else if (_pipeline_width < OTA_FETCH_PIPELINE) {
_pipeline_width++;
}
if (_pipeline_width != old_width) {
OTA_DBG("OTA: adaptive flight %s to %u/%u\n", _flight_dirty ? "shrunk" : "grew",
(unsigned)_pipeline_width, (unsigned)OTA_FETCH_PIPELINE);
}
_flight_dirty = false;
}
void OtaManager::notePipelineStall() {
if (!_flight_dirty) {
OTA_DBG("OTA: adaptive flight recovery at width %u/%u\n",
(unsigned)_pipeline_width, (unsigned)OTA_FETCH_PIPELINE);
}
_flight_dirty = true;
}
bool OtaManager::blockInPipeline(uint32_t block) const {
return findReassemblySlot(block) >= 0;
}
bool OtaManager::fetchActive() const {
return _fstate == FETCHING || _fstate == WANT_MANIFEST || _fstate == WANT_LEAVES
|| _fstate == VERIFYING_STAGED || _fstate == PAUSED;
}
void OtaManager::clearFetchIntent() {
_desired_target = 0;
_have_desired_mid = false;
memset(_desired_mid, 0, sizeof(_desired_mid));
_fexpected_target = 0;
}
void OtaManager::failFetch(FetchError error) {
_fetch_error = error;
_fstate = FAILED;
clearReassembly();
freeLeaves();
_validate = false;
_archive_fetch = false;
clearFetchIntent();
}
void OtaManager::completeFetch() {
_fetch_error = FETCH_ERROR_NONE;
_fstate = COMPLETE;
_validate = false;
_archive_fetch = false;
clearFetchIntent();
}
OtaManager::PullResult OtaManager::pull(const uint8_t* mid, uint32_t target, bool validate) {
if (!mid) return PULL_BAD_MID;
if (!_fetch) return PULL_NO_STORE;
if (fetchActive()) return PULL_BUSY;
_archive_fetch = false;
_desired_target = target;
memcpy(_desired_mid, mid, sizeof(_desired_mid));
_have_desired_mid = true;
reDiscover();
PullResult result = startFetch(mid, target, validate);
if (result != PULL_STARTED && result != PULL_RESUMED) clearFetchIntent();
return result;
}
OtaManager::PullResult OtaManager::pull_archive(const uint8_t* mid, uint32_t target, bool validate) {
if (!mid) return PULL_BAD_MID;
if (!_fetch) return PULL_NO_STORE;
if (fetchActive()) return PULL_BUSY;
_archive_fetch = true;
_desired_target = target;
memcpy(_desired_mid, mid, sizeof(_desired_mid));
_have_desired_mid = true;
reDiscover();
PullResult result = startFetch(mid, target, validate);
if (result != PULL_STARTED && result != PULL_RESUMED) {
_archive_fetch = false;
clearFetchIntent();
}
return result;
}
// Begin (or resume) fetching a chosen mid: try a staged-partial resume first, else request the manifest.
OtaManager::PullResult OtaManager::startFetch(const uint8_t* mid, uint32_t target, bool validate) {
if (!mid) return PULL_BAD_MID;
if (!_fetch) return PULL_NO_STORE;
if (fetchActive()) return PULL_BUSY;
_fetch_error = FETCH_ERROR_NONE;
_wire_v2_session = _wire_v2_enabled;
_wire_v2_confirmed = false;
_wire_allow_deflate = _wire_v2_session && _deflate_decode != nullptr;
_wire_empty_stalls = 0;
_fexpected_target = target;
_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 PULL_RESUMED; // adopt a partial container left in the store
memcpy(_fid, mid, 4);
_have = 0; _fbc = 0; _ftotal = 0; _fflags = 0;
_observed_path_transmissions = 0; // learn the actual source/relay path from this fetch's replies
_fstate = WANT_MANIFEST;
_mf_total = 0; _mf_mask = 0; _mf_len = 0; _mf_retries = 0; _loop_last_mfmask = 0; // fresh manifest reassembly
memset(_mf_buf, 0, sizeof(_mf_buf));
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);
return PULL_STARTED;
}
bool OtaManager::resumeStagedExplicit(const uint8_t* want_mid, uint32_t expected_target) {
if (!want_mid || !_fetch || fetchActive()) return false;
_fetch_error = FETCH_ERROR_NONE;
_wire_v2_session = _wire_v2_enabled;
_wire_v2_confirmed = false;
_wire_allow_deflate = _wire_v2_session && _deflate_decode != nullptr;
_wire_empty_stalls = 0;
_validate = false;
_archive_fetch = false;
_desired_target = expected_target;
memcpy(_desired_mid, want_mid, sizeof(_desired_mid));
_have_desired_mid = true;
_fexpected_target = expected_target;
const bool adopted = resumeStaged(want_mid);
if (!adopted) clearFetchIntent();
return adopted;
}
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;
const uint8_t expected_total = (uint8_t)((MOTA_MFL + OTA_MF_FRAG - 1) / OTA_MF_FRAG);
const uint16_t expected_len = mm.frag_idx + 1 == expected_total
? (uint16_t)(MOTA_MFL - (uint32_t)mm.frag_idx * OTA_MF_FRAG)
: (uint16_t)OTA_MF_FRAG;
if (mm.frag_total != expected_total || mm.len != expected_len) 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;
memset(_mf_buf, 0, sizeof(_mf_buf));
}
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) { failFetch(FETCH_ERROR_MANIFEST); return; } // fixed 197-byte manifest
MotaManifest parsed;
if (!mota_parse_manifest(mf, mfl, parsed)) { failFetch(FETCH_ERROR_MANIFEST); return; }
// The OTA_MANIFEST envelope is supplied by the peer and is not an authenticated description of the
// bytes inside it. Bind both identifiers from the parsed manifest to the catalog row / explicit pull
// that opened this receive slot before the store is planned, erased, or written. Otherwise a peer can
// label an unrelated manifest with the requested MID and make us stage a different target/package.
if (memcmp(parsed.merkle_root, _fid, sizeof(_fid)) != 0 ||
(_fexpected_target != 0 && parsed.target_id != _fexpected_target)) {
failFetch(FETCH_ERROR_MANIFEST); return;
}
// A HAVE row is advisory and may lie about its version. Repeat the
// forward-only check against the parsed manifest before touching storage.
const bool automatic_fetch = !_have_desired_mid && _desired_target == 0 && !_archive_fetch;
if (_enforce_auto_version && automatic_fetch &&
!ota_trusted_auto_version_allows(_running_fw_version, parsed.fw_version)) {
failFetch(FETCH_ERROR_VERSION); return;
}
if (automatic_fetch && _autofetch == AUTOFETCH_SIGNED && !parsed.is_signed()) {
failFetch(FETCH_ERROR_MANIFEST); return;
}
if (parsed.hash_algo != HASH_ALGO_SHA256) { failFetch(FETCH_ERROR_HASH_ALGO); return; }
if (!_archive_fetch &&
!fetchCodecOk(parsed.codec_id, parsed.is_bootloader(), _have_desired_mid)) {
failFetch(FETCH_ERROR_CODEC); return; // bootloader packages are manual-pull only
}
uint32_t payload_size = parsed.payload_size;
uint8_t bsl = parsed.block_size_log2;
if (bsl >= 32) { failFetch(FETCH_ERROR_GEOMETRY); return; }
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) { failFetch(FETCH_ERROR_GEOMETRY); return; }
uint32_t bc = (payload_size + bs - 1) / bs;
if (bc > 0xFFFFu) { failFetch(FETCH_ERROR_TOO_LARGE); return; } // uint16 block index on the wire
if (_archive_fetch && (uint64_t)bc * 4 > OTA_PROOFGEN_SCRATCH) {
failFetch(FETCH_ERROR_TOO_LARGE); return; // retaining an image we cannot seed is useless
}
memcpy(_froot, parsed.merkle_root, sizeof(_froot));
uint32_t leaves_off = 8 + mfl;
uint32_t payload_off = leaves_off + bc * 4;
uint64_t total64 = (uint64_t)payload_off + payload_size + 5;
if (total64 > UINT32_MAX) { failFetch(FETCH_ERROR_TOO_LARGE); return; }
uint32_t total = (uint32_t)total64;
// 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 = parsed.is_full();
if (!_fetch->plan_layout(is_full, parsed.image_size, payload_off, payload_size,
parsed.is_bootloader())) {
failFetch(FETCH_ERROR_STORAGE); return;
}
if (!_fetch->begin(total)) { failFetch(FETCH_ERROR_STORAGE); 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)) { failFetch(FETCH_ERROR_STORAGE); 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)) { failFetch(FETCH_ERROR_STORAGE); 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;
clearReassembly(); // fresh transfer: drop any prior per-block state
// 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;
OTA_DBG("OTA: FETCHING bc=%u bs=%u total=%u\n", (unsigned)bc, (unsigned)bs, (unsigned)total);
requestMissing(); // final manifest fragment arrived: open flight 1 immediately
}
// --- 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;
uint32_t expected_len = leaves_len - foff;
if (expected_len > OTA_LEAVES_FRAG) expected_len = OTA_LEAVES_FRAG;
if (lv.len != expected_len) return; // reject short/oversized slices
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; 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
if (!storedLeavesRootMatches()) failFetch(FETCH_ERROR_INTEGRITY);
else if (!_fetch->finalize()) failFetch(FETCH_ERROR_STORAGE);
else completeFetch();
return;
}
_fstate = FETCHING; requestMissing();
}
bool OtaManager::resumeStaged(const uint8_t* want_mid) {
if (!_fetch || _fstate == FETCHING || _fstate == WANT_MANIFEST
|| _fstate == WANT_LEAVES || _fstate == VERIFYING_STAGED) {
return false;
}
_wire_v2_session = _wire_v2_enabled;
_wire_v2_confirmed = false;
_wire_allow_deflate = _wire_v2_session && _deflate_decode != nullptr;
_wire_empty_stalls = 0;
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
const bool automatic_resume = want_mid == nullptr;
// A boot-time resume is an automatic fetch decision and therefore belongs
// only to this node. An explicit pull retains target=0 as an intentional
// MID-only wildcard instead of silently narrowing it to the local target.
const uint32_t expected_target = automatic_resume ? _target : _fexpected_target;
if (expected_target != 0 && m.target_id != expected_target) return false;
if (automatic_resume) {
if (_autofetch == AUTOFETCH_OFF) return false;
if (_autofetch == AUTOFETCH_SIGNED && !m.is_signed()) return false;
if (_enforce_auto_version &&
!ota_trusted_auto_version_allows(_running_fw_version, m.fw_version)) return false;
}
if (m.hash_algo != HASH_ALGO_SHA256) return false;
// A bootloader partial is resumed only as part of the same explicit MID pull.
// Boot-time resumeStaged(nullptr) must not silently re-adopt privileged data.
const bool manual = want_mid != nullptr && _have_desired_mid;
if (!_archive_fetch && !fetchCodecOk(m.codec_id, m.is_bootloader(), manual)) 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;
if (bc == 0 || bc > 0xFFFFu) return false;
if (_archive_fetch && (uint64_t)bc * 4 > OTA_PROOFGEN_SCRATCH) return false;
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;
clearReassembly();
_observed_path_transmissions = 0;
_fetch_error = FETCH_ERROR_NONE;
beginStagedVerification();
OTA_DBG("OTA: RESUME verifying %u blocks total=%u\n",
(unsigned)bc, (unsigned)total);
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);
}
bool OtaManager::storedLeavesRootMatches() const {
if (!_fetch || _fbc == 0) return false;
MerkleAccumulator accumulator;
for (uint32_t i = 0; i < _fbc; ++i) {
uint8_t leaf[4];
if (!_fetch->read(_floff + i * 4, leaf, sizeof(leaf))
|| (leaf[0] == 0xFF && leaf[1] == 0xFF
&& leaf[2] == 0xFF && leaf[3] == 0xFF)
|| !accumulator.add(leaf)) {
return false;
}
}
uint8_t root[4];
return accumulator.finish(root) && memcmp(root, _froot, 4) == 0;
}
void OtaManager::beginStagedVerification() {
_have = 0;
_resume_verify_idx = 0;
_resume_invalidated = false;
_resume_merkle.reset();
_fstate = VERIFYING_STAGED;
}
void OtaManager::verifyStagedStep() {
if (_fstate != VERIFYING_STAGED || !_fetch) return;
static const uint8_t missing_leaf[4] = {0xFF, 0xFF, 0xFF, 0xFF};
for (uint32_t checked = 0;
checked < OTA_DIFF_BATCH && _resume_verify_idx < _fbc;
++checked, ++_resume_verify_idx) {
uint8_t stored_leaf[4];
const uint32_t index = _resume_verify_idx;
if (!_fetch->read(_floff + index * 4,
stored_leaf, sizeof(stored_leaf))) {
failFetch(FETCH_ERROR_STORAGE);
return;
}
if (memcmp(stored_leaf, missing_leaf, sizeof(stored_leaf)) == 0) {
continue;
}
const uint32_t length = blockLen(index);
if (!_fetch->read(_fpoff + index * _fbs, _reasm[0].buf, length)) {
failFetch(FETCH_ERROR_STORAGE);
return;
}
uint8_t computed_leaf[4];
merkle_leaf(computed_leaf, _reasm[0].buf, length);
if (memcmp(computed_leaf, stored_leaf, sizeof(stored_leaf)) != 0) {
// Payload and marker disagree. Clear the marker so the normal fetch path
// requests this block again; a stale marker must never bless bad bytes.
if (!_fetch->write(_floff + index * 4,
missing_leaf, sizeof(missing_leaf))) {
failFetch(FETCH_ERROR_STORAGE);
return;
}
_resume_invalidated = true;
continue;
}
if (!_resume_merkle.add(computed_leaf)) {
failFetch(FETCH_ERROR_INTEGRITY);
return;
}
_have++;
}
if (_resume_verify_idx < _fbc) return;
clearReassembly();
if (_resume_invalidated) _fetch->checkpoint();
if (_have == _fbc) {
uint8_t root[4];
if (!_resume_merkle.finish(root) || memcmp(root, _froot, sizeof(root)) != 0) {
failFetch(FETCH_ERROR_INTEGRITY);
} else if (!_fetch->finalize()) {
failFetch(FETCH_ERROR_STORAGE);
} else {
completeFetch();
}
} else {
_fstate = FETCHING;
requestMissing();
}
_resume_merkle.reset();
}
bool OtaManager::handleData(const uint8_t* m, uint16_t n) {
DataMsg dm;
if (!decode_data(m, n, dm) || !_fetch) return false;
if (_fstate != FETCHING || memcmp(dm.manifest_id, _fid, 4) != 0) return false;
if (dm.block_idx >= _fbc) return false;
if (blockPresent(dm.block_idx)) return true; // late duplicate for this terminal
int slot_index = findReassemblySlot(dm.block_idx);
if (slot_index < 0) return false; // not one of this node's requested blocks
ReassemblySlot& slot = _reasm[slot_index];
uint32_t blen = blockLen(dm.block_idx);
if ((dm.frag_off & OTA_DATA_V2_MARK) == 0) {
if (dm.frag_off % OTA_FRAG_DATA != 0) return false; // canonical legacy-aligned slices only
if ((uint32_t)dm.frag_off + dm.data_len > blen) return false;
uint32_t expected_len = blen - dm.frag_off;
if (expected_len > OTA_FRAG_DATA) expected_len = OTA_FRAG_DATA;
if (dm.data_len != expected_len) return false; // do not mark a short slice as complete
uint32_t kf = dm.frag_off / OTA_FRAG_DATA;
if (kf >= 16) return false;
// A deployed source ignores the v2 request bits, masks them against its seven-fragment geometry, and
// responds here. Only a fully canonical legacy slice may select that profile; malformed matching-MID
// traffic must not force a session-wide downgrade. Never mix it with already accepted v2 bytes.
if (_wire_v2_session) {
if (_wire_v2_confirmed || anyWireDataReceived()) return false;
downgradeWireToLegacy();
}
const uint16_t bit = (uint16_t)(1u << kf);
if (slot.mask & bit) return true; // duplicate flood/retry copy is terminal too
memcpy(slot.buf + dm.frag_off, dm.data, dm.data_len);
slot.mask |= bit;
} else {
if (!_wire_v2_session || dm.data_len <= OTA_DATA_V2_STREAM_ID_BYTES) return false;
uint8_t fragment = 0;
uint16_t encoded_len = 0;
bool deflated = false;
if (!ota_data_v2_unpack(dm.frag_off, fragment, encoded_len, deflated)) return false;
if (encoded_len > blen || (deflated ? encoded_len >= blen : encoded_len != blen)) return false;
const uint16_t fragment_len = (uint16_t)(dm.data_len - OTA_DATA_V2_STREAM_ID_BYTES);
const uint32_t fragment_off = (uint32_t)fragment * OTA_FRAG_DATA_V2;
if (fragment_off >= encoded_len || fragment_off + fragment_len > encoded_len) return false;
uint32_t expected_len = encoded_len - fragment_off;
if (expected_len > OTA_FRAG_DATA_V2) expected_len = OTA_FRAG_DATA_V2;
if (fragment_len != expected_len) return false;
_wire_v2_confirmed = true;
const uint8_t* stream_id = dm.data;
if (slot.encoded_len == 0) {
slot.encoded_len = encoded_len;
slot.wire_v2 = true;
slot.deflated = deflated;
memcpy(slot.stream_id, stream_id, sizeof(slot.stream_id));
slot.mask = 0;
slot.need = frag_full_mask((encoded_len + OTA_FRAG_DATA_V2 - 1) / OTA_FRAG_DATA_V2);
} else if (!slot.wire_v2 || slot.encoded_len != encoded_len || slot.deflated != deflated ||
memcmp(slot.stream_id, stream_id, sizeof(slot.stream_id)) != 0) {
return false; // another seeder's representation; never mix
}
_wire_empty_stalls = 0; // a valid v2 source is still serving this flight
const uint16_t bit = (uint16_t)(1u << fragment);
if (!(slot.need & bit)) return false;
if (slot.mask & bit) return true;
memcpy(slot.buf + fragment_off, dm.data + OTA_DATA_V2_STREAM_ID_BYTES, fragment_len);
slot.mask |= bit;
slot.wire_stalls = 0;
}
noteFetchActivity();
if (slot.mask != slot.need || slot.awaiting_proof) return true;
if (slot.wire_v2) {
uint8_t stream_id[4];
mh4(stream_id, slot.buf, slot.encoded_len);
if (memcmp(stream_id, slot.stream_id, sizeof(stream_id)) != 0) {
slot.mask = 0; // complete bytes do not match their representation id
slot.encoded_len = 0;
slot.wire_v2 = false;
slot.deflated = false;
memset(slot.stream_id, 0, sizeof(slot.stream_id));
notePipelineStall();
return true;
}
if (slot.deflated) {
uint16_t decoded_len = 0;
bool decoded = _deflate_decode && _deflate_decode(
_deflate_decode_ctx, slot.buf, slot.encoded_len,
_serve_block, (uint16_t)blen, &decoded_len);
// _serve_block is also the outbound server cache. Invalidate it even on failure so a simultaneous
// serve job reloads its own authenticated block rather than transmitting decoder workspace.
_serve_block_loaded = false;
_serve_wire_block = nullptr;
_serve_wire_len = 0;
if (!decoded || decoded_len != blen) {
_wire_allow_deflate = false; // retry this/future blocks as raw v2
slot.mask = 0;
slot.need = frag_full_mask((blen + OTA_FRAG_DATA_V2 - 1) / OTA_FRAG_DATA_V2);
slot.encoded_len = 0;
slot.wire_v2 = false;
slot.deflated = false;
memset(slot.stream_id, 0, sizeof(slot.stream_id));
notePipelineStall();
return true;
}
memcpy(slot.buf, _serve_block, blen);
}
}
// The paced server sends a proof immediately after this block's requested DATA. Wait briefly so that proof
// can arrive without another request/response turn. An older server, or a lost proof, falls back through
// serviceEgress() to the existing REQ_PROOF wire message.
slot.awaiting_proof = true;
slot.proof_request_at = _now_ms + proofGraceMs();
return true;
}
bool OtaManager::handleProof(const uint8_t* m, uint16_t n) {
ProofMsg pm;
if (!decode_proof(m, n, pm) || !_fetch) return false;
if (_fstate != FETCHING || memcmp(pm.manifest_id, _fid, 4) != 0) return false;
if (pm.block_idx >= _fbc) return false;
int slot_index = findReassemblySlot(pm.block_idx);
if (slot_index < 0 || !_reasm[slot_index].awaiting_proof) return blockPresent(pm.block_idx);
ReassemblySlot& slot = _reasm[slot_index];
uint32_t block = slot.block;
uint32_t blen = blockLen(block);
noteFetchActivity();
if (!merkle_verify(slot.buf, blen, block, pm.proof, pm.n_proof, _froot, _fbc)) {
notePipelineStall();
clearReassemblySlot((uint8_t)slot_index); // bad -> drop and re-fetch only this block
if (activePipelineSlots() == 0) { finishFlight(); requestMissing(); }
return true;
}
// 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. Flash failures also pause instead of being ignored.
uint8_t leaf[4]; merkle_leaf(leaf, slot.buf, blen);
if (!_fetch->write(_fpoff + block * _fbs, slot.buf, blen) ||
!_fetch->write(_floff + block * 4, leaf, 4)) {
_fetch_error = FETCH_ERROR_STORAGE;
_fstate = PAUSED; clearReassembly(); return true;
}
_have++;
OTA_DBG("OTA: block %u OK have=%u/%u\n", (unsigned)block, (unsigned)_have, (unsigned)_fbc);
clearReassemblySlot((uint8_t)slot_index);
// 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 <N>` (0 = never)
if (_checkpoint_blocks && _have % _checkpoint_blocks == 0) _fetch->checkpoint();
if (_have < _fbc) {
// Do not continuously refill a partially completed flight: the remaining response packets are already
// queued at the source/relays. Staying silent here is what prevents the old threefold request amplification.
if (activePipelineSlots() == 0) { finishFlight(); requestMissing(); }
return true;
}
// Every leaf must be readable and collectively match the manifest root.
// A scratch allocation/read failure is an integrity failure, never success.
clearReassembly();
if (!storedLeavesRootMatches()) failFetch(FETCH_ERROR_INTEGRITY);
else if (!_fetch->finalize()) failFetch(FETCH_ERROR_STORAGE);
else completeFetch();
OTA_DBG("OTA: transfer %s\n", _fstate == COMPLETE ? "COMPLETE" : "FAILED(integrity/storage)");
return true;
}
uint16_t OtaManager::wireRequestMask(uint32_t block, uint16_t need, uint16_t received,
uint16_t encoded_len) const {
uint16_t want = (uint16_t)(need & ~received);
if (_wire_v2_session) {
// Until a v2 representation answers, include every legacy fragment bit. A deployed source ignores the
// high profile flags and can therefore return a complete seven-fragment block on this very first request.
if (encoded_len == 0) {
want = frag_full_mask((blockLen(block) + OTA_FRAG_DATA - 1) / OTA_FRAG_DATA);
}
if (want == 0) want = need;
return ota_req_make_v2(want, _wire_allow_deflate);
}
if (want == 0) want = need;
return want;
}
bool OtaManager::requestSlot(uint8_t slot_index) {
if (_fstate != FETCHING || slot_index >= OTA_FETCH_PIPELINE) return false;
ReassemblySlot& slot = _reasm[slot_index];
if (slot.block >= _fbc) return false;
_req_start = slot.block;
_req_count = activePipelineSlots();
if (slot.awaiting_proof) {
ReqProofMsg rp; memcpy(rp.manifest_id, _fid, 4); rp.block_idx = (uint16_t)slot.block;
uint8_t b[16];
bool sent = emit(b, encode_req_proof(b, sizeof(b), rp), false);
if (sent) {
slot.proof_request_at = 0;
noteFetchActivity();
OTA_DBG("OTA: REQ_PROOF fallback block=%u (have=%u/%u window=%u)\n",
(unsigned)slot.block, (unsigned)_have, (unsigned)_fbc, (unsigned)_req_count);
}
return sent;
}
uint16_t want = wireRequestMask(slot.block, slot.need, slot.mask, slot.encoded_len);
ReqMsg rq; memcpy(rq.manifest_id, _fid, 4);
rq.block_idx = (uint16_t)slot.block; rq.want_mask = want;
uint8_t b[16];
OTA_DBG("OTA: REQ block=%u want=%04x (have=%u/%u mask=%04x window=%u)\n",
(unsigned)slot.block, (unsigned)want, (unsigned)_have, (unsigned)_fbc,
(unsigned)slot.mask, (unsigned)_req_count);
bool sent = emit(b, encode_req(b, sizeof(b), rq), false);
if (sent) noteFetchActivity();
return sent;
}
bool OtaManager::requestFlight() {
if (_fstate != FETCHING) return false;
ReqWindowMsg request = {};
memcpy(request.manifest_id, _fid, 4);
for (uint8_t slot_index = 0; slot_index < OTA_FETCH_PIPELINE; slot_index++) {
const ReassemblySlot& slot = _reasm[slot_index];
if (slot.block == NO_BLOCK || slot.awaiting_proof) continue;
ReqItem& item = request.items[request.n_items++];
item.block_idx = (uint16_t)slot.block;
item.want_mask = wireRequestMask(slot.block, slot.need, slot.mask, slot.encoded_len);
}
if (request.n_items == 0) return false;
_req_start = request.items[0].block_idx;
_req_count = request.n_items;
uint8_t b[5 + OTA_REQ_MAX_ITEMS * 4];
OTA_DBG("OTA: REQ flight first=%u blocks=%u width=%u/%u\n",
(unsigned)_req_start, (unsigned)_req_count,
(unsigned)_pipeline_width, (unsigned)OTA_FETCH_PIPELINE);
bool sent = emit(b, encode_req_window(b, sizeof(b), request), false);
if (sent) noteFetchActivity();
return sent;
}
bool OtaManager::fillPipeline() {
if (activePipelineSlots() != 0) return false; // flights never overlap/refill
uint8_t assigned = 0;
while (true) {
int slot_index = findEmptyReassemblySlot();
if (slot_index < 0) break;
uint32_t block = pickMissingBlock();
if (block >= _fbc) break;
ReassemblySlot& slot = _reasm[slot_index];
slot.block = block;
slot.mask = 0;
const uint16_t fragment_data = _wire_v2_session ? OTA_FRAG_DATA_V2 : OTA_FRAG_DATA;
slot.need = frag_full_mask((blockLen(block) + fragment_data - 1) / fragment_data);
slot.awaiting_proof = false;
slot.proof_request_at = 0;
assigned++;
}
if (assigned == 0) return false;
_flight_dirty = false;
requestFlight();
return true;
}
void OtaManager::requestMissing() {
if (_fstate != FETCHING) return;
if (activePipelineSlots() == 0) { fillPipeline(); return; }
// No v2 DATA at all means the request may have reached only deployed sources which ignored its profile
// bits (or the first response burst was lost). Retry once in the universal legacy profile instead of
// pinning the session to a feature it has not observed on air.
if (_wire_v2_session && !_wire_v2_confirmed && !anyWireDataReceived()) {
downgradeWireToLegacy();
notePipelineStall();
requestFlight();
return;
}
// The airtime-aware deadline expired. Retry one slot's holes per deadline, round-robin, so a weak block
// cannot trigger a simultaneous multi-block re-burst on a half-duplex channel. Recovery dirties the
// flight; its successor is reduced (4->2, 3->2, 2->1) after all current slots finish.
notePipelineStall();
// Once v2 has been confirmed, the source can still disappear cleanly between blocks (or after a bad
// representation is discarded), leaving every active slot empty. Give the v2 flight one sparse retry,
// then clear the whole flight and ask again in the universal legacy profile so an old seeder can take over.
if (_wire_v2_session && _wire_v2_confirmed && !anyWireDataReceived() &&
++_wire_empty_stalls >= 2) {
downgradeWireToLegacy();
requestFlight();
return;
}
for (uint8_t offset = 0; offset < OTA_FETCH_PIPELINE; offset++) {
uint8_t slot = (uint8_t)((_retry_slot + offset) % OTA_FETCH_PIPELINE);
if (_reasm[slot].block == NO_BLOCK) continue;
ReassemblySlot& reassembly = _reasm[slot];
if (_wire_v2_session && reassembly.wire_v2 && reassembly.mask != 0 &&
!reassembly.awaiting_proof && ++reassembly.wire_stalls >= 2) {
// A v2 seeder may disappear after establishing a representation while only deployed legacy seeders
// remain. Keep one sparse retry, then fall back the whole flight to the universal profile. Clearing
// every slot prevents geometry/representation mixing and guarantees that old sources can take over.
downgradeWireToLegacy();
}
if (requestSlot(slot)) _retry_slot = (uint8_t)((slot + 1) % OTA_FETCH_PIPELINE);
return;
}
}
// Walk missing blocks in serial order, excluding blocks already assigned to a pipeline slot.
uint32_t OtaManager::pickMissingBlock() {
if (_fbc == 0) return _fbc;
for (uint32_t i = 0; i < _fbc; i++) {
if (!blockInPipeline(i) && !blockPresent(i)) return i;
}
return _fbc;
}
void OtaManager::loop() {
// Each source owns its own pending/retry state, so a burst of advertisements cannot overwrite another
// seeder's query. Initial requests ask for all; recovery asks only for missing HAVE fragments.
for (uint8_t i = 0; i < _n_src; i++) {
Source& s = _sources[i];
if (s.query_pending && (int32_t)(_now_ms - s.query_at) >= 0) {
s.query_pending = false;
uint32_t want = 0;
if (s.have_total) {
uint32_t full = s.have_total >= 32 ? UINT32_MAX : ((1UL << s.have_total) - 1);
want = full & ~s.have_mask;
}
sendQuery(s.seeder, s.digest, 0, want);
s.query_retry_at = _now_ms + OTA_CATALOG_RETRY_MS;
} else if (s.query_owned && !s.have_catalog && !s.query_pending && s.query_retry_at &&
(int32_t)(_now_ms - s.query_retry_at) >= 0 && s.query_retries < OTA_CATALOG_MAX_RETRY) {
uint32_t want = 0;
if (s.have_total) {
uint32_t full = s.have_total >= 32 ? UINT32_MAX : ((1UL << s.have_total) - 1);
want = full & ~s.have_mask;
}
s.query_retries++;
sendQuery(s.seeder, s.digest, 0, want);
s.query_retry_at = _now_ms + OTA_CATALOG_RETRY_MS;
}
}
if (_fstate == VERIFYING_STAGED) {
verifyStagedStep();
return;
}
if (_fstate == WANT_MANIFEST) {
// 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) { failFetch(FETCH_ERROR_MANIFEST_TIMEOUT); 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) { failFetch(FETCH_ERROR_LEAVES_TIMEOUT); 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;
if (activePipelineSlots() == 0) {
requestMissing();
return;
}
// The main loop may still tick each second for discovery, but block recovery waits for the calculated
// response train: current SF/BW airtime * outstanding packets * source/relay transmissions * duty pacing.
// Every newly accepted fragment/proof restarts the deadline; duplicate flood copies do not.
if ((uint32_t)(_now_ms - _fetch_wait_since_ms) >= fetchRetryTimeoutMs()) requestMissing();
}
// ---------------- dispatch ----------------
bool OtaManager::on_message(const uint8_t* msg, uint16_t len) {
switch (ota_msg_type(msg, len)) {
case OTA_ADV: handleAdv(msg, len); return false;
case OTA_QUERY: handleQuery(msg, len); return false;
case OTA_HAVE: handleHave(msg, len); return false;
case OTA_GET_MANIFEST: return handleGetManifest(msg, len);
case OTA_MANIFEST: handleManifest(msg, len); return false;
case OTA_GET_LEAVES: handleGetLeaves(msg, len); return false;
case OTA_LEAVES: handleLeaves(msg, len); return false;
case OTA_REQ: return handleReq(msg, len);
case OTA_DATA: return handleData(msg, len);
case OTA_REQ_PROOF: return handleReqProof(msg, len);
case OTA_PROOF: return handleProof(msg, len);
default: return false;
}
}
} // namespace ota
} // namespace mesh