fix: harden LoRa OTA chain and storage

This commit is contained in:
mikecarper committed 2026-08-29 06:58:48 -07:00
1 parent 400aa710a2
commit b40d2e6cb2
29 files changed
+7497 -528

No files matched your search

+44 -4
View File
@@ -647,19 +647,45 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
} else if (is_cmd(a, "drop|cancel|stop", &rest)) {
if (c.apply_pending) { strcpy(reply, "ERR update is armed; reboot is pending"); return true; }
OtaManager::FetchState fs = c.manager.fetchState();
const bool was_folder = c.fetch_to_folder;
bool was_sd_archive = false;
char midhx[9]; strcpy(midhx, "-");
if (fs != OtaManager::IDLE) mesh::Utils::toHex(midhx, c.manager.fetchManifestId(), 4);
#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
was_sd_archive = c.sdCacheFetching();
c.stopSdCacheFetch();
#endif
c.manager.reset_session(); c.manager.want(0); c.manager.want_mid(nullptr);
if (was_folder && c.folder_dest) c.folder_dest->clear();
c.fetch_to_folder = false;
c.manager.set_fetch_store(&c.fetch_store); // revert to the default flash store (a folder pull switched it)
#if defined(NRF52_PLATFORM) && !defined(OTA_SD_STORE) && !defined(OTA_QSPI_STORE)
c.manager.set_accept_full(false);
#endif
c.fetch_store.clear(); c.serving = false; c.serve_expected = 0; c.session_started_ms = 0;
snprintf(reply, 160, "OK dropped session (was %c mid=%s); slot free for a new pull", fstate_char(fs), midhx);
const bool discarded = !was_folder && !was_sd_archive &&
c.fetch_store.discard();
// Fetch cancellation and serving are independent. In particular, a
// manual ESP32 serve view can point into serve_buf, so leave the manager
// view and its caller-owned buffer intact until `ota dev clear` (which
// detaches the view before releasing the buffer).
c.session_started_ms = 0;
if (was_folder) {
snprintf(reply, 160,
"OK dropped folder session (was %c mid=%s); host file left untouched",
fstate_char(fs), midhx);
} else if (was_sd_archive) {
snprintf(reply, 160,
"OK dropped SD archive session (was %c mid=%s); partial retained for resume",
fstate_char(fs), midhx);
} else if (!discarded) {
snprintf(reply, 160,
"ERR dropped live session (was %c mid=%s), but persistent OTA slot invalidation failed",
fstate_char(fs), midhx);
} else {
snprintf(reply, 160,
"OK dropped session (was %c mid=%s); OTA receive slot confirmed clear",
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). ----
@@ -1141,13 +1167,27 @@ static bool handle_dev(const char* d, char* reply, OtaContext& c) {
}
} else if (strncmp(d, "clear", 5) == 0) {
const bool was_folder = c.fetch_to_folder;
bool was_sd_archive = false;
#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
was_sd_archive = c.sdCacheFetching();
c.stopSdCacheFetch();
#endif
c.manager.clear_primary();
c.serve_expected = 0; c.serving = false; c.releaseServeBuffer();
c.fetch_store.clear(); c.manager.reset_session(); c.fetch_to_folder = false;
strcpy(reply, "OK cleared");
c.manager.reset_session();
if (was_folder && c.folder_dest) c.folder_dest->clear();
c.fetch_to_folder = false;
c.manager.set_fetch_store(&c.fetch_store);
if (was_folder) {
strcpy(reply, "OK cleared folder session; host file left untouched");
} else if (was_sd_archive) {
strcpy(reply, "OK cleared SD archive session; partial retained for resume");
} else {
const bool discarded = c.fetch_store.discard();
strcpy(reply, discarded ? "OK OTA receive slot confirmed clear"
: "ERR RAM state cleared; persistent OTA slot invalidation failed");
}
} else {
strcpy(reply, "ota dev: stage|recv|serve|announce|resume [mid8]|verify|want|apply|clear");
+129
View File
@@ -0,0 +1,129 @@
#pragma once
#include <stdint.h>
namespace mesh {
namespace ota {
// Physical placement of one ESP32 flash-store container. Keep this independent
// of ESP-IDF so reopen/discard geometry can be tested on the native host.
struct MotaEsp32StageLayout {
uint32_t total = 0;
uint32_t meta_span = 0;
uint32_t meta_flush = 0;
uint32_t write_start = 0;
uint32_t meta_part = 0;
uint32_t pay_log0 = 0;
uint32_t pay_part0 = 0;
};
inline bool mota_esp32_align_up(uint32_t value, uint32_t alignment,
uint32_t& result) {
if (alignment == 0 || value > UINT32_MAX - (alignment - 1u)) return false;
result = ((value + alignment - 1u) / alignment) * alignment;
return true;
}
inline uint32_t mota_esp32_align_down(uint32_t value,
uint32_t alignment) {
return alignment ? (value / alignment) * alignment : 0u;
}
inline bool mota_esp32_stage_layout(
uint32_t partition_size, uint32_t sector_size, uint32_t meta_capacity,
bool is_full, uint32_t image_size, uint32_t meta_bytes,
uint32_t payload_size, MotaEsp32StageLayout& out) {
if (sector_size == 0 || partition_size < sector_size ||
partition_size % sector_size != 0) {
return false;
}
const uint64_t total64 =
(uint64_t)meta_bytes + (uint64_t)payload_size + 5u;
if (total64 > UINT32_MAX) return false;
MotaEsp32StageLayout layout;
layout.total = (uint32_t)total64;
if (is_full) {
// A FULL payload is the final application image, byte for byte. Reject a
// malformed manifest before begin() can erase or stream anything, and
// prove that the complete logical container fits the inactive partition.
if (payload_size != image_size || layout.total > partition_size) {
return false;
}
layout.meta_span = meta_bytes;
if (meta_bytes > UINT32_MAX - 5u ||
!mota_esp32_align_up(meta_bytes + 5u, sector_size,
layout.meta_flush) ||
layout.meta_flush > meta_capacity ||
layout.meta_flush > partition_size) {
return false;
}
layout.meta_part = mota_esp32_align_down(
partition_size - layout.meta_flush, sector_size);
layout.pay_log0 = meta_bytes;
layout.pay_part0 = 0;
layout.write_start = 0;
if (image_size > layout.meta_part) return false;
} else {
if (!mota_esp32_align_up(meta_bytes, sector_size,
layout.meta_span) ||
layout.meta_span > meta_capacity || layout.total > partition_size) {
return false;
}
layout.meta_flush = layout.meta_span;
layout.write_start = mota_esp32_align_down(
partition_size - layout.total, sector_size);
layout.meta_part = layout.write_start;
layout.pay_log0 = layout.meta_span;
if (layout.write_start > UINT32_MAX - layout.meta_span) return false;
layout.pay_part0 = layout.write_start + layout.meta_span;
if (image_size > layout.write_start) return false;
}
out = layout;
return true;
}
// The probe separates ordinary non-candidates from partition I/O failure:
// true/false is I/O success, while `reopenable` says whether this sector holds
// a header that the store's reopen path would adopt.
typedef bool (*MotaEsp32ProbeStagedHeader)(void* context, uint32_t offset,
bool& reopenable);
typedef bool (*MotaEsp32InvalidateStagedHeader)(void* context,
uint32_t offset);
inline bool mota_esp32_discard_staged_headers(
uint32_t partition_size, uint32_t sector_size, void* context,
MotaEsp32ProbeStagedHeader probe_header,
MotaEsp32InvalidateStagedHeader invalidate_header,
uint32_t* invalidated_count = nullptr) {
if (invalidated_count) *invalidated_count = 0;
if (sector_size == 0 || partition_size < sector_size ||
partition_size % sector_size != 0 || !probe_header ||
!invalidate_header) {
return false;
}
bool ok = true;
uint32_t count = 0;
uint32_t offset = mota_esp32_align_down(
partition_size - sector_size, sector_size);
for (;;) {
bool reopenable = false;
if (!probe_header(context, offset, reopenable)) {
ok = false;
} else if (reopenable) {
if (invalidate_header(context, offset)) {
++count;
} else {
ok = false;
}
}
if (offset < sector_size) break;
offset -= sector_size;
}
if (invalidated_count) *invalidated_count = count;
return ok;
}
} // namespace ota
} // namespace mesh
+67
View File
@@ -47,6 +47,7 @@ static const uint32_t MOTA_NRF52_STAGE_CEILING_EXPANDED = MOTA_NRF52_APP_END;
// explicit ceiling so it cannot accidentally cross an Internal ExtraFS.
static const uint32_t MOTA_NRF52_FS_START = MOTA_NRF52_EXTRAFS_START;
static const uint32_t MOTA_NRF52_FLASH_PAGE = 4096u;
static const uint32_t MOTA_NRF52_CONTAINER_MIN_SIZE = 8u + 197u + 5u;
static const uint8_t GPREGRET_OTA_APPLY = 0x6Au; // distinct from DFU magics 0x57/0x4E/0xA8
static const uint8_t GPREGRET_OTA_BOOTLOADER_UPDATE = 0x6Bu;
static const uint8_t GPREGRET2_OTA_STAGE_LEGACY = 0xD4u;
@@ -272,5 +273,71 @@ inline bool mota_nrf52_stage_plan(uint32_t total_size, uint32_t app_base, uint32
mota_nrf52_layout_stage_ceiling(), out_start);
}
// Durable internal-store cancellation has to work even when the live
// OtaStore object is empty (for example, autofetch is off after a reboot). It
// also has to invalidate every older bottom-aligned header: invalidating only
// the highest container could expose a larger stale container below it on the
// next reopen scan. Keep the page walk and layout recognition pure so native
// tests can exercise fresh-object and multiple-header cases without nRF flash.
typedef bool (*MotaNrf52ReadStagedHeader)(void* context, uint32_t address,
uint32_t& total_size);
typedef bool (*MotaNrf52InvalidateStagedHeader)(void* context,
uint32_t address);
inline bool mota_nrf52_staged_header_matches_layout(
uint32_t address, uint32_t total_size, uint32_t app_base,
uint32_t app_end, uint32_t effective_stage_ceiling) {
uint32_t planned = 0;
if (mota_nrf52_stage_plan(total_size, app_base, app_end,
effective_stage_ceiling, planned) &&
planned == address) {
return true;
}
// A device that moved from the legacy D4000 ceiling to the expanded ED000
// ceiling can still contain an older, otherwise reopenable legacy header.
// The expanded layout has no filesystem in that range, so it is safe to
// invalidate both geometries. A legacy-only build never scans above D4000.
return effective_stage_ceiling == MOTA_NRF52_STAGE_CEILING_EXPANDED &&
mota_nrf52_stage_plan(total_size, app_base, app_end,
MOTA_NRF52_STAGE_CEILING_LEGACY, planned) &&
planned == address;
}
inline bool mota_nrf52_discard_staged_headers(
uint32_t app_base, uint32_t app_end, uint32_t effective_stage_ceiling,
void* context, MotaNrf52ReadStagedHeader read_header,
MotaNrf52InvalidateStagedHeader invalidate_header,
uint32_t* invalidated_count = nullptr) {
if (invalidated_count) *invalidated_count = 0;
if (!read_header || !invalidate_header ||
!mota_nrf52_layout_valid(app_base, effective_stage_ceiling) ||
app_end < app_base || app_end > effective_stage_ceiling) {
return false;
}
bool ok = true;
uint32_t count = 0;
uint32_t address = effective_stage_ceiling - MOTA_NRF52_FLASH_PAGE;
while (address >= app_end) {
uint32_t total_size = 0;
if (read_header(context, address, total_size) &&
total_size >= MOTA_NRF52_CONTAINER_MIN_SIZE &&
mota_nrf52_staged_header_matches_layout(
address, total_size, app_base, app_end,
effective_stage_ceiling)) {
if (invalidate_header(context, address)) {
++count;
} else {
ok = false;
}
}
if (address < MOTA_NRF52_FLASH_PAGE) break;
address -= MOTA_NRF52_FLASH_PAGE;
}
if (invalidated_count) *invalidated_count = count;
return ok;
}
} // namespace ota
} // namespace mesh
+19
View File
@@ -24,6 +24,17 @@ public:
virtual uint32_t staged_size() const = 0; // total_size from begin(), 0 if none
virtual void clear() = 0;
// Durably discard a staged container. `clear()` resets the store object's
// live session state and is intentionally cheap enough for begin()/retry
// paths; a persistent backend may therefore leave its resumable bytes in
// place. User-facing cancellation needs the stronger operation: after a
// successful discard, a fresh store object must not be able to reopen the
// old container. The conservative default clears only the live object and
// returns false: a backend must opt in only when it can prove that its
// durable copy is gone. A false result means cancellation must not be
// reported as having cleared the persistent slot.
virtual bool discard() { clear(); return false; }
// 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
@@ -90,9 +101,17 @@ public:
uint32_t capacity() const override { return CAP; }
uint32_t staged_size() const override { return _total; }
void clear() override { _total = 0; }
bool discard() override {
_total = 0;
const size_t marker_bytes =
CAP < sizeof(MOTA_MAGIC) ? CAP : sizeof(MOTA_MAGIC);
memset(_buf, 0, marker_bytes);
return true;
}
// 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 (CAP < 8u) return false;
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;
+152 -50
View File
@@ -3,7 +3,7 @@
#if defined(ESP32_PLATFORM) && defined(OTA_FLASH_STORE)
#include "OtaDebug.h"
#include "OtaByteIO.h" // align_up / align_down (flash-sector geometry)
#include "OtaByteIO.h" // rd_u32le (persisted header total)
#include "MotaContainer.h" // mota_parse_manifest (reopen: rebuild geometry from the staged manifest)
#include <string.h>
#include <stdlib.h> // malloc/free (the meta buffer is sized per fetch)
@@ -22,33 +22,29 @@ bool OtaStoreFlashEsp32::acquire() {
return _part != nullptr;
}
void OtaStoreFlashEsp32::set_layout(const MotaEsp32StageLayout& layout) {
_total = layout.total;
_meta_span = layout.meta_span;
_meta_flush = layout.meta_flush;
_write_start = layout.write_start;
_meta_part = layout.meta_part;
_pay_log0 = layout.pay_log0;
_pay_part0 = layout.pay_part0;
_trailer_part = _full ? _meta_part + _meta_bytes
: _write_start + _total - 5u;
}
// 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.
// it won't fit. Shared by plan_layout (fresh fetch), reopen (resume), and discard validation so all three
// 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
MotaEsp32StageLayout planned;
if (!mota_esp32_stage_layout(
_psize, SEC, OTA_ESP32_META_CAP, _full, _image_size,
_meta_bytes, _pay_size, planned)) {
return false;
}
_total = total;
set_layout(planned);
return true;
}
@@ -84,6 +80,120 @@ void OtaStoreFlashEsp32::clear() {
_total = 0; _pay_open = false; _flushed = false; // _part kept (re-acquire is fine)
}
OtaStoreFlashEsp32::CandidateProbe OtaStoreFlashEsp32::probe_candidate(
uint32_t offset, StagedCandidate& candidate) const {
if (!_part || _psize < SEC || offset > _psize - SEC) {
return CandidateProbe::INVALID;
}
uint8_t header[8];
if (esp_partition_read(_part, offset, header, sizeof(header)) != ESP_OK) {
return CandidateProbe::IO_ERROR;
}
if (memcmp(header, MOTA_MAGIC, sizeof(MOTA_MAGIC)) != 0) {
return CandidateProbe::INVALID;
}
const uint32_t total = rd_u32le(header + sizeof(MOTA_MAGIC));
if (total < 8u + MOTA_MFL + 5u || total > _psize) {
return CandidateProbe::INVALID;
}
uint8_t manifest_bytes[256];
uint32_t manifest_read = total - 8u;
if (manifest_read > sizeof(manifest_bytes)) {
manifest_read = sizeof(manifest_bytes);
}
if (esp_partition_read(_part, offset + 8u, manifest_bytes,
manifest_read) != ESP_OK) {
return CandidateProbe::IO_ERROR;
}
MotaManifest manifest;
if (!mota_parse_manifest(manifest_bytes, manifest_read, manifest)) {
return CandidateProbe::INVALID;
}
const uint32_t manifest_len =
(uint32_t)(manifest.approval - manifest.manifest_start) +
sizeof(APPROVAL_YES);
const uint64_t payload_off64 =
8u + (uint64_t)manifest_len + (uint64_t)manifest.block_count * 4u;
if (payload_off64 > UINT32_MAX ||
payload_off64 + (uint64_t)manifest.payload_size + 5u != total) {
return CandidateProbe::INVALID;
}
MotaEsp32StageLayout planned;
if (!mota_esp32_stage_layout(
_psize, SEC, OTA_ESP32_META_CAP, manifest.is_full(),
manifest.image_size, (uint32_t)payload_off64,
manifest.payload_size, planned) ||
planned.meta_part != offset || planned.total != total) {
return CandidateProbe::INVALID;
}
candidate.layout = planned;
candidate.full = manifest.is_full();
candidate.image_size = manifest.image_size;
candidate.meta_bytes = (uint32_t)payload_off64;
candidate.pay_size = manifest.payload_size;
return CandidateProbe::VALID;
}
bool OtaStoreFlashEsp32::probe_staged_header(
void* context, uint32_t offset, bool& reopenable) {
OtaStoreFlashEsp32* store = static_cast<OtaStoreFlashEsp32*>(context);
StagedCandidate candidate;
const CandidateProbe result = store->probe_candidate(offset, candidate);
reopenable = result == CandidateProbe::VALID;
return result != CandidateProbe::IO_ERROR;
}
bool OtaStoreFlashEsp32::invalidate_staged_header(
void* context, uint32_t offset) {
OtaStoreFlashEsp32* store = static_cast<OtaStoreFlashEsp32*>(context);
if (!store->_part || store->_psize < SEC || offset > store->_psize - SEC ||
esp_partition_read(store->_part, offset, store->_pay, SEC) != ESP_OK) {
return false;
}
// Both markers are inside the already-validated first metadata sector.
// One sector RMW avoids erasing a delta's adjacent metadata/payload twice.
memset(store->_pay, 0, sizeof(MOTA_MAGIC));
memset(store->_pay + 8u + MOTA_OFF_APPROVAL, 0,
sizeof(APPROVAL_YES));
if (esp_partition_erase_range(store->_part, offset, SEC) != ESP_OK ||
esp_partition_write(store->_part, offset, store->_pay, SEC) != ESP_OK) {
return false;
}
uint8_t check[sizeof(MOTA_MAGIC)];
if (esp_partition_read(store->_part, offset, check, sizeof(check)) != ESP_OK ||
memcmp(check, store->_pay, sizeof(check)) != 0 ||
esp_partition_read(store->_part, offset + 8u + MOTA_OFF_APPROVAL,
check, sizeof(check)) != ESP_OK ||
memcmp(check, store->_pay + 8u + MOTA_OFF_APPROVAL,
sizeof(check)) != 0) {
return false;
}
return true;
}
bool OtaStoreFlashEsp32::discard() {
bool ok = acquire();
uint32_t invalidated = 0;
if (ok) {
ok = mota_esp32_discard_staged_headers(
_psize, SEC, this, probe_staged_header,
invalidate_staged_header, &invalidated);
}
OTA_DBG("OTA esp32: discard invalidated=%u ok=%d\n",
(unsigned)invalidated, (int)ok);
clear();
_io_ok = ok;
return ok;
}
uint8_t* OtaStoreFlashEsp32::meta_slot(uint32_t L) {
if (in_trailer(L)) return _full ? (_meta + _meta_bytes + (L - (_total - 5)))
: (_trailer + (L - (_total - 5)));
@@ -207,34 +317,26 @@ void OtaStoreFlashEsp32::checkpoint() {
// 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. Large delta containers can begin well above
// the final 512 KB, so scan the complete partition rather than silently losing resumability after reboot.
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;
}
}
}
for (uint32_t o = mota_esp32_align_down(_psize - SEC, SEC); ; o -= SEC) {
StagedCandidate candidate;
if (probe_candidate(o, candidate) == CandidateProbe::VALID) {
_full = candidate.full;
_image_size = candidate.image_size;
_meta_bytes = candidate.meta_bytes;
_pay_size = candidate.pay_size;
set_layout(candidate.layout);
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) break;
}
+18 -1
View File
@@ -3,6 +3,7 @@
#if defined(ESP32_PLATFORM) && defined(OTA_FLASH_STORE)
#include "OtaStore.h"
#include "OtaFlashLayout_esp32.h"
#include "esp_partition.h"
// Persistent flash-backed OtaStore for ESP32 (A/B). Stages the received `.mota` in the INACTIVE OTA
@@ -41,6 +42,15 @@ namespace ota {
class OtaStoreFlashEsp32 : public OtaStore {
static const uint32_t SEC = 4096; // ESP32 NOR flash erase unit
enum class CandidateProbe : uint8_t { INVALID, VALID, IO_ERROR };
struct StagedCandidate {
MotaEsp32StageLayout layout;
bool full = false;
uint32_t image_size = 0;
uint32_t meta_bytes = 0;
uint32_t pay_size = 0;
};
const esp_partition_t* _part = nullptr; // inactive OTA slot (acquired in plan_layout/begin)
uint32_t _psize = 0; // slot size
@@ -62,7 +72,7 @@ class OtaStoreFlashEsp32 : public OtaStore {
// 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)
alignas(4) 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
@@ -73,6 +83,12 @@ class OtaStoreFlashEsp32 : public OtaStore {
bool acquire(); // resolve the inactive slot (idempotent)
bool layout(); // compute placement from _full/_image_size/_meta_bytes/_pay_size
void set_layout(const MotaEsp32StageLayout& layout);
CandidateProbe probe_candidate(uint32_t offset,
StagedCandidate& candidate) const;
static bool probe_staged_header(void* context, uint32_t offset,
bool& reopenable);
static bool invalidate_staged_header(void* context, uint32_t offset);
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
@@ -92,6 +108,7 @@ public:
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 discard() override;
bool set_meta_size(uint32_t meta_bytes) override { return meta_bytes < OTA_ESP32_META_CAP; }
bool finalize() override;
void checkpoint() override; // persist meta(leaves) + open payload sector so a reboot can resume
+66 -1
View File
@@ -13,6 +13,18 @@
namespace mesh {
namespace ota {
void OtaStoreFlashNrf52::reset_session() {
_write_start = 0;
_stage_ceiling = MOTA_NRF52_STAGE_CEILING_LEGACY;
_total = 0;
_pay_idx = 0;
_flushed = false;
_io_ok = true;
_planned_bootloader = false;
_planned_total = 0;
_planned_start = 0;
}
// A valid EndF gives the exact live-image extent. Legacy app-only builds keep
// the conservative rescue fallback. Shared internal bootloader-update builds
// reject every package kind without EndF because their normal linker may place
@@ -73,6 +85,59 @@ static bool flash_matches(uint32_t addr, const uint8_t* expected, uint32_t n) {
return true;
}
bool OtaStoreFlashNrf52::read_staged_header(
void*, uint32_t address, uint32_t& total_size) {
const uint8_t* header = (const uint8_t*)(uintptr_t)address;
if (memcmp(header, MOTA_MAGIC, sizeof(MOTA_MAGIC)) != 0) return false;
total_size = rd_u32le(header + sizeof(MOTA_MAGIC));
return true;
}
bool OtaStoreFlashNrf52::invalidate_staged_header(
void* context, uint32_t address) {
OtaStoreFlashNrf52* store = static_cast<OtaStoreFlashNrf52*>(context);
const uint8_t* page = (const uint8_t*)(uintptr_t)address;
memcpy(store->_meta_page, page, PG);
// Magic prevents application reopen; approval prevents an explicitly
// triggered legacy bootloader from accepting a half-cleared container.
// Rewrite the page once so both durable markers are consumed together.
memset(store->_meta_page, 0, sizeof(MOTA_MAGIC));
memset(store->_meta_page + 8u + MOTA_OFF_APPROVAL, 0,
sizeof(APPROVAL_YES));
if (flash_nrf5x_write(address, store->_meta_page, PG) < 0) {
store->_io_ok = false;
return false;
}
flash_nrf5x_flush();
if (!flash_matches(address, store->_meta_page, PG)) {
store->_io_ok = false;
return false;
}
return true;
}
bool OtaStoreFlashNrf52::discard() {
const uint32_t app_base = mota_nrf52_app_base();
const uint32_t stage_ceiling = ota_nrf52_effective_stage_ceiling();
uint32_t app_end = 0;
if (!protected_app_end(app_base, stage_ceiling, app_end)) {
reset_session();
_io_ok = false;
return false;
}
uint32_t invalidated = 0;
const bool ok = mota_nrf52_discard_staged_headers(
app_base, app_end, stage_ceiling, this, read_staged_header,
invalidate_staged_header, &invalidated);
OTA_DBG("OTA flash: discard invalidated=%u ok=%d\n",
(unsigned)invalidated, (int)ok);
reset_session();
_io_ok = ok;
return ok;
}
bool OtaStoreFlashNrf52::flush_page(uint32_t page_idx, const uint8_t* buf) {
if (!_io_ok || _write_start > _stage_ceiling - PG ||
page_idx > (_stage_ceiling - PG - _write_start) / PG) {
@@ -133,7 +198,7 @@ bool OtaStoreFlashNrf52::begin(uint32_t total_size) {
const bool planned_bootloader = _planned_bootloader;
const uint32_t planned_total = _planned_total;
const uint32_t planned_start = _planned_start;
clear();
reset_session();
// never collide with the running application image (its extent comes from its EndF trailer)
const uint32_t app_base = mota_nrf52_app_base();
+6 -5
View File
@@ -57,6 +57,10 @@ class OtaStoreFlashNrf52 : public OtaStore {
uint8_t* write_slot(uint32_t pos);
bool flush_pay(); // commit _pay_page to flash (erase + program, one page)
bool flush_page(uint32_t page_idx, const uint8_t* buf); // write + verify a full page
void reset_session(); // RAM bookkeeping only; never touches staged flash
static bool read_staged_header(void* context, uint32_t address,
uint32_t& total_size);
static bool invalidate_staged_header(void* context, uint32_t address);
public:
bool plan_layout(bool is_full, uint32_t image_size, uint32_t payload_off,
@@ -66,11 +70,8 @@ public:
bool read(uint32_t offset, uint8_t* buf, uint32_t len) const override;
uint32_t capacity() const override;
uint32_t staged_size() const override { return _total; }
void clear() override {
_stage_ceiling = MOTA_NRF52_STAGE_CEILING_LEGACY;
_total = 0; _pay_idx = 0; _flushed = false; _io_ok = true;
_planned_bootloader = false; _planned_total = 0; _planned_start = 0;
}
void clear() override { reset_session(); }
bool discard() override;
bool set_meta_size(uint32_t meta_bytes) override { return meta_bytes <= PG; } // leaves must fit page 0
bool finalize() override;
void checkpoint() override; // persist page 0 (leaves) + the open payload page so a reboot can resume
+9 -2
View File
@@ -864,7 +864,7 @@ bool OtaStoreQspiNrf52::reopen() {
return true;
}
void OtaStoreQspiNrf52::clear() {
bool OtaStoreQspiNrf52::discard() {
bool ok = ensureFlash();
uint8_t zero[4] = { 0, 0, 0, 0 };
uint8_t check[sizeof(zero)];
@@ -872,7 +872,11 @@ void OtaStoreQspiNrf52::clear() {
ok = rawWrite(0, zero, sizeof(zero)) && rawRead(0, check, sizeof(check)) &&
memcmp(check, zero, sizeof(zero)) == 0;
}
if (ok && _total >= 8 + MOTA_OFF_APPROVAL + sizeof(zero)) {
// The raw QSPI store is dedicated to OTA, and APRV has a fixed manifest
// offset. A fresh store object has _total == 0 even when an older approved
// container remains in flash, so invalidate both gates from flash capacity
// rather than trusting this session's RAM state.
if (ok && _flash_size >= 8 + MOTA_OFF_APPROVAL + sizeof(zero)) {
const uint32_t approval = 8 + MOTA_OFF_APPROVAL;
ok = rawWrite(approval, zero, sizeof(zero)) && rawRead(approval, check, sizeof(check)) &&
memcmp(check, zero, sizeof(zero)) == 0;
@@ -883,8 +887,11 @@ void OtaStoreQspiNrf52::clear() {
resetSession();
if (!ok) fail(saved_error[0] ? saved_error : "QSPI container invalidation failed");
releaseFlash();
return ok;
}
void OtaStoreQspiNrf52::clear() { (void)discard(); }
bool OtaStoreQspiNrf52::approve_for_bootloader() {
if (!finalize()) return false;
const uint32_t approval = 8 + MOTA_OFF_APPROVAL;
+1
View File
@@ -155,6 +155,7 @@ public:
uint32_t capacity() const override;
uint32_t staged_size() const override { return _total; }
void clear() override;
bool discard() override;
bool set_meta_size(uint32_t meta_bytes) override;
bool finalize() override;
void checkpoint() override;
+18 -3
View File
@@ -509,17 +509,32 @@ bool OtaStoreSdNrf52::reopen() {
return true;
}
void OtaStoreSdNrf52::clear() {
bool OtaStoreSdNrf52::discard() {
_total = 0;
_planned_bootloader = false;
_first_sector = 0;
_allocated_sectors = 0;
clear_sd_auth_record();
if (!mount()) return;
if (!mount()) return false;
if (_file && *_file) _file->close();
_sd->remove(PATH);
if (_sd->exists(PATH) && !_sd->remove(PATH)) {
fail("SD OTA file removal failed");
return false;
}
if (_sd->exists(PATH)) {
fail("SD OTA file still exists after removal");
return false;
}
SdCard* const card = _sd->card();
if (card && !card->syncDevice()) {
fail("SD OTA removal sync failed");
return false;
}
return true;
}
void OtaStoreSdNrf52::clear() { (void)discard(); }
bool OtaStoreSdNrf52::approve_for_bootloader(
const uint8_t expected_boot_image_hash[32],
const uint8_t authorized_container_hash[32]) {
+1
View File
@@ -30,6 +30,7 @@ public:
uint32_t capacity() const override;
uint32_t staged_size() const override { return _total; }
void clear() override;
bool discard() override;
bool set_meta_size(uint32_t meta_bytes) override;
bool finalize() override;
void checkpoint() override;
@@ -172,6 +172,17 @@ static RAK12035_SoilMoisture RAK12035;
#endif
#ifdef RAK_WISBLOCK_GPS
// Release capability manifests scan the linked firmware image rather than
// trusting build flags. Give the actual WisBlock provider a unique marker and
// keep an observable reference from begin() so LTO/section GC cannot discard
// it. The marker lives inside this provider guard, so generic GPS CLI text
// cannot produce a false positive.
extern "C" {
extern const char meshcore_capability_rak_wisblock_gps[];
const char meshcore_capability_rak_wisblock_gps[] __attribute__((used)) =
"meshcore.capability.rak_wisblock_gps.v1";
}
// -1 = no enable pin; out-of-range values are no-ops in pinMode/digitalWrite,
// while 0 would be a real GPIO (P0.00 = LFXO crystal on nRF52)
static uint32_t gpsResetPin = -1;
@@ -702,6 +713,10 @@ static const size_t SENSOR_TABLE_SIZE = (sizeof(SENSOR_TABLE) / sizeof(SENSOR_TA
bool EnvironmentSensorManager::begin() {
#if ENV_INCLUDE_GPS
#ifdef RAK_WISBLOCK_GPS
// A volatile read anchors the externally linked marker even under LTO.
const volatile char* capability_marker =
meshcore_capability_rak_wisblock_gps;
(void)*capability_marker;
rakGPSInit();
#else
initBasicGPS();