Harden LoRa OTA and enable unattended targets

This commit is contained in:
mikecarper committed 2026-07-14 15:24:35 -07:00
1 parent 8599cf0acb
commit 11aa4bc191
17 files changed
+204 -86

No files matched your search

+9
View File
@@ -253,9 +253,15 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
OtaStore* store; const char* dname;
if (strncmp(dst, "flash", 5) == 0) {
store = &c.fetch_store; c.fetch_store.clear(); dname = "flash"; validate = false; // seed lives in the folder
#if defined(NRF52_PLATFORM)
c.manager.set_accept_full(false); // nRF52 flash can install only in-place deltas
#endif
} else if (strncmp(dst, "folder", 6) == 0) {
if (!c.folder_dest) { strcpy(reply, "ERR no folder connected (run motatool serve --tcp/--serial)"); return true; }
c.folder_dest->set_mid(selmid); store = c.folder_dest; dname = validate ? "folder+validate" : "folder";
#if defined(NRF52_PLATFORM)
c.manager.set_accept_full(true); // capture can store a full image; it is not applied
#endif
} else { strcpy(reply, "ERR destination must be `flash` or `folder`"); return true; }
c.manager.reset_session();
c.manager.set_fetch_store(store); // stage this pull to the chosen destination
@@ -270,6 +276,9 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
if (fs != OtaManager::IDLE) mesh::Utils::toHex(midhx, c.manager.fetchManifestId(), 4);
c.manager.reset_session(); c.manager.want(0); c.manager.want_mid(nullptr);
c.manager.set_fetch_store(&c.fetch_store); // revert to the default flash store (a folder pull switched it)
#if defined(NRF52_PLATFORM)
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);
+9 -1
View File
@@ -40,7 +40,13 @@ namespace ota {
class FolderMotaStore; // pull destination over the seeder link (full type only where instantiated/used)
#ifndef OTA_SERVE_BUF_SIZE
#define OTA_SERVE_BUF_SIZE 16384
// nRF52 self-serving streams from flash; this buffer is only for the manual `ota dev stage` helper.
// Keep it to one flash page so the OTA singleton does not consume another 16 KB of scarce SRAM.
#if defined(NRF52_PLATFORM) && defined(OTA_FLASH_STORE)
#define OTA_SERVE_BUF_SIZE 4096
#else
#define OTA_SERVE_BUF_SIZE 16384
#endif
#endif
#ifndef OTA_FETCH_BUF_SIZE
#define OTA_FETCH_BUF_SIZE 16384
@@ -191,8 +197,10 @@ struct OtaContext {
if (hw) { strncpy(hw_id, hw, sizeof(hw_id) - 1); hw_id[sizeof(hw_id) - 1] = 0; }
// a node only fetches firmware it can apply: ESP32 A/B -> sequential, nRF52 single-slot -> in-place
#if defined(NRF52_PLATFORM)
manager.set_accept_full(false); // single-slot bootloader applies deltas only
manager.set_apply_codec(CODEC_DETOOLS_INPLACE);
#elif defined(ESP32_PLATFORM)
manager.set_accept_full(true);
manager.set_apply_codec(CODEC_DETOOLS_SEQUENTIAL); // preferred (streams straight to the slot)
manager.set_apply_codec2(CODEC_DETOOLS_INPLACE); // also accepted -> a single in-place .mota fits both
#endif
+45 -22
View File
@@ -1,33 +1,41 @@
#pragma once
// Shared OTA flash-layout constants for the nRF52840 (RAK4631) single-slot delta-apply path.
// SINGLE SOURCE OF TRUTH — keep byte-identical with the bootloader's src/ota_layout.h.
// Shared OTA flash-layout constants for the nRF52840 single-slot delta-apply path.
//
// The running app occupies [APP_BASE, app_end]; the primary LittleFS (InternalFS) starts at FS_START.
// MeshCore stages a verified+approved `.mota` in the free flash below FS_START (bottom-aligned), then
// sets GPREGRET_OTA_APPLY and resets; the bootloader scans [APP_BASE, FS_START) for it and applies it
// in place. These must match the bootloader and the running SoftDevice's app base.
// in place. APP_BASE is obtained from the linker so S140 v6 (0x26000) and v7 (0x27000) both work. The
// bootloader independently uses DFU_BANK_0_REGION_START, which resolves to the same address.
#include <stdint.h>
namespace mesh {
namespace ota {
static const uint32_t MOTA_NRF52_APP_BASE = 0x00026000u; // S140 end (== CODE_REGION_1_START)
// Staging ceiling: the lowest filesystem region above the app. RAK4631 companion builds use the
// extrafs ldscript with ExtraFS at 0xD4000..0xED000 (and InternalFS at 0xED000), while the repeater
// uses the default ldscript (InternalFS at 0xED000, 0xD4000..0xED000 free). 0xD4000 is the safe
// universal ceiling for ALL RAK4631 roles: staging below it never touches ExtraFS or InternalFS, and
// the app (~520 KB) sits well below 0xD4000 either way.
static const uint32_t MOTA_NRF52_APP_BASE_S140_V6 = 0x00026000u;
static const uint32_t MOTA_NRF52_APP_BASE_S140_V7 = 0x00027000u;
#if defined(NRF52_PLATFORM)
extern "C" uint32_t __flash_arduino_start[]; // nrf52_common.ld: ORIGIN(FLASH)
inline uint32_t mota_nrf52_app_base() {
return (uint32_t)(uintptr_t)__flash_arduino_start;
}
#else
// Native geometry tests have no linker script; default their runtime helper to the v6 layout.
inline uint32_t mota_nrf52_app_base() { return MOTA_NRF52_APP_BASE_S140_V6; }
#endif
// Staging ceiling: the lowest filesystem region above the app. nRF52840 ExtraFS linker scripts place
// ExtraFS at 0xD4000..0xED000 (and InternalFS at 0xED000), while default scripts leave that range free.
// Staging below 0xD4000 therefore never touches either filesystem.
static const uint32_t MOTA_NRF52_FS_START = 0x000D4000u; // ExtraFS start (universal staging ceiling)
static const uint32_t MOTA_NRF52_FLASH_PAGE = 4096u;
static const uint8_t GPREGRET_OTA_APPLY = 0x6Au; // distinct from DFU magics 0x57/0x4E/0xA8
// In-place patches are built with --inplace-memory = this (the apply workspace, from APP_BASE up).
// It must hold the new image (~520 KB) yet leave the staged mota room below FS_START: workspace ends
// at APP_BASE+this = 0xBE000, leaving 0xBE000..0xD4000 (~88 KB) for the staged delta. The bootloader
// also bounds writes to < the (scanned) mota start, so a mis-sized memory still fails safe.
static const uint32_t MOTA_NRF52_INPLACE_MEMORY = 0x00098000u; // 608 KB (APP_BASE .. 0xBE000)
// at APP_BASE+this = 0xBE000 (S140 v6) or 0xBF000 (v7), leaving 88/84 KB for the staged delta. The
// bootloader also bounds writes to < the (scanned) mota start, so a mis-sized memory still fails safe.
static const uint32_t MOTA_NRF52_INPLACE_MEMORY = 0x00098000u; // 608 KB
// Bootloader flash region (nRF52840: 39 KB ending just below the CF2/MBR-params pages). The app scans
// this for the bootloader capability marker (OtaBlInfo.h) to know whether THIS device's bootloader can
@@ -37,28 +45,43 @@ static const uint32_t MOTA_NRF52_BL_END = 0x000FE000u;
// Compile-time layout-ordering invariants. If a constant above is edited inconsistently these fail the
// BUILD rather than silently letting a stage/apply corrupt the filesystem (user prefs) or the app.
static_assert((MOTA_NRF52_APP_BASE % MOTA_NRF52_FLASH_PAGE) == 0, "APP_BASE must be page-aligned");
static_assert((MOTA_NRF52_APP_BASE_S140_V6 % MOTA_NRF52_FLASH_PAGE) == 0, "S140 v6 base must be page-aligned");
static_assert((MOTA_NRF52_APP_BASE_S140_V7 % MOTA_NRF52_FLASH_PAGE) == 0, "S140 v7 base must be page-aligned");
static_assert((MOTA_NRF52_FS_START % MOTA_NRF52_FLASH_PAGE) == 0, "FS_START must be page-aligned");
static_assert(MOTA_NRF52_APP_BASE < MOTA_NRF52_FS_START, "app must precede the staging ceiling");
static_assert(MOTA_NRF52_FS_START < MOTA_NRF52_BL_START, "staging (+FS) must end below the bootloader");
static_assert(MOTA_NRF52_BL_START < MOTA_NRF52_BL_END, "bootloader region must be non-empty");
// The in-place apply workspace [APP_BASE, APP_BASE+INPLACE_MEMORY) must end at/below the staging ceiling,
// so an in-place apply never writes into ExtraFS/InternalFS (where user prefs live).
static_assert(MOTA_NRF52_APP_BASE + MOTA_NRF52_INPLACE_MEMORY <= MOTA_NRF52_FS_START,
static_assert(MOTA_NRF52_APP_BASE_S140_V7 + MOTA_NRF52_INPLACE_MEMORY <= MOTA_NRF52_FS_START,
"in-place apply workspace must end at or below the staging ceiling");
// Plan where to stage a received `.mota` of `total_size` bytes. It is placed bottom-aligned so its
// 5-byte trailer ends exactly at FS_START (the bootloader scans downward from there), and it must sit
// ENTIRELY within [app_end, FS_START): above the running image (`app_end` = APP_BASE + its EndF image_len)
// and below the filesystem region (ExtraFS/InternalFS — where user prefs live, assumed immutable).
inline bool mota_nrf52_layout_valid(uint32_t app_base) {
return (app_base % MOTA_NRF52_FLASH_PAGE) == 0 && app_base < MOTA_NRF52_FS_START &&
(uint64_t)app_base + MOTA_NRF52_INPLACE_MEMORY <= MOTA_NRF52_FS_START;
}
inline uint32_t mota_nrf52_stage_capacity(uint32_t app_base) {
return mota_nrf52_layout_valid(app_base)
? MOTA_NRF52_FS_START - (app_base + MOTA_NRF52_INPLACE_MEMORY) : 0;
}
// Plan where to stage a received `.mota` of `total_size` bytes. It is placed bottom-aligned within the
// highest flash page below FS_START (the bootloader scans downward from there), and it must sit
// ENTIRELY above both the running image and the in-place decoder workspace, and below the filesystem
// region (ExtraFS/InternalFS — where user prefs live, assumed immutable). Reserving the full workspace
// here prevents accepting a large container that the bootloader would later reject as overlapping it.
// Returns false (and leaves out_start untouched) if it does not fit. This is the SINGLE place the FS
// ceiling + app-collision bounds are enforced; begin()/reopen() both go through it. Pure — no flash I/O —
// so it is unit-tested natively in test/test_ota/test_ota_flashplan.cpp.
inline bool mota_nrf52_stage_plan(uint32_t total_size, uint32_t app_end, uint32_t& out_start) {
const uint32_t capacity = MOTA_NRF52_FS_START - MOTA_NRF52_APP_BASE;
inline bool mota_nrf52_stage_plan(uint32_t total_size, uint32_t app_base, uint32_t app_end,
uint32_t& out_start) {
if (!mota_nrf52_layout_valid(app_base) || app_end < app_base ||
app_end > app_base + MOTA_NRF52_INPLACE_MEMORY) return false;
const uint32_t workspace_end = app_base + MOTA_NRF52_INPLACE_MEMORY;
const uint32_t capacity = MOTA_NRF52_FS_START - workspace_end;
if (total_size < 13 || total_size > capacity) return false; // 13 = header(8)+trailer(5); must fit below FS
uint32_t start = (MOTA_NRF52_FS_START - total_size) & ~(MOTA_NRF52_FLASH_PAGE - 1); // bottom-align down
if (start < app_end) return false; // would overlap the running image
if (start < app_end || start < workspace_end) return false; // would overlap app / decoder workspace
out_start = start;
return true;
}
+8 -4
View File
@@ -217,14 +217,17 @@ public:
// Coarse clock for source/catalog ages + LRU (the Mesh adapter feeds millis; 0 in host tests is fine).
void set_clock(uint32_t ms) { _now_ms = ms; }
// Codec compatibility: a node only fetches/accepts fw it can actually apply. CODEC_FULL is always
// acceptable; the platform's single delta codec is set here (ESP32 A/B -> sequential, nRF52 single-
// slot -> in-place). A mismatching `.mota` is rejected at OTA_ADV time, before fetching anything.
// Codec compatibility: a node only fetches/accepts firmware it can actually apply. ESP32 A/B accepts
// full images; nRF52 single-slot does not and disables them (it requires an in-place delta). A manual
// pull to an external folder may temporarily allow full images because it is capture, not install.
void set_apply_codec(uint8_t c) { _apply_codec = c; }
// A platform may apply MORE than one delta codec (ESP32 does both sequential AND in-place, so a single
// in-place `.mota` can target both ESP32 and nRF52). 0xFF = unset.
void set_apply_codec2(uint8_t c) { _apply_codec2 = c; }
bool codecOk(uint8_t c) const { return c == CODEC_FULL || c == _apply_codec || c == _apply_codec2; }
void set_accept_full(bool on) { _accept_full = on; }
bool codecOk(uint8_t c) const {
return (c == CODEC_FULL && _accept_full) || c == _apply_codec || c == _apply_codec2;
}
// Auto-fetch policy (manual `ota pull` always works regardless): 0=off (discover only), 1=any
// compatible own-target advert, 2=only signed adverts. Conservative default = off.
@@ -351,6 +354,7 @@ private:
bool _have_desired_mid = false;
uint8_t _apply_codec = CODEC_DETOOLS_SEQUENTIAL; // platform's delta codec (OtaContext sets it)
uint8_t _apply_codec2 = 0xFF; // optional 2nd accepted delta codec (ESP32: in-place)
bool _accept_full = true; // false on nRF52 flash (single-slot cannot apply full)
uint8_t _seeder_id[4] = {0,0,0,0}; // our node id (pubkey[0:4]) for advert seeder counting
uint8_t _autofetch = AUTOFETCH_OFF; // auto-fetch policy (persisted in NodePrefs)
uint16_t _checkpoint_blocks = OTA_CHECKPOINT_BLOCKS; // resume checkpoint cadence (persisted)
+7 -4
View File
@@ -67,8 +67,10 @@ bool ota_self_firmware(SelfFwInfo& out) {
// APP_BASE; find_self_firmware() picks the EndF whose stored body_len equals its offset (the running
// firmware's own trailer), ignoring any staged `.mota` (which carries its own embedded EndF) higher up.
bool ota_self_firmware(SelfFwInfo& out) {
const uint8_t* region = (const uint8_t*)(uintptr_t)MOTA_NRF52_APP_BASE;
uint32_t region_len = MOTA_NRF52_FS_START - MOTA_NRF52_APP_BASE;
const uint32_t app_base = mota_nrf52_app_base();
if (!mota_nrf52_layout_valid(app_base)) { out = SelfFwInfo(); return false; }
const uint8_t* region = (const uint8_t*)(uintptr_t)app_base;
uint32_t region_len = MOTA_NRF52_FS_START - app_base;
return find_self_firmware(region, region_len, out, /*verify_body=*/true);
}
#else
@@ -86,8 +88,9 @@ bool ota_self_read(uint32_t off, uint8_t* buf, uint32_t len) {
}
#elif defined(NRF52_PLATFORM)
bool ota_self_read(uint32_t off, uint8_t* buf, uint32_t len) {
if ((uint64_t)MOTA_NRF52_APP_BASE + off + len > MOTA_NRF52_FS_START) return false;
memcpy(buf, (const uint8_t*)(uintptr_t)(MOTA_NRF52_APP_BASE + off), len);
const uint32_t app_base = mota_nrf52_app_base();
if (!mota_nrf52_layout_valid(app_base) || (uint64_t)app_base + off + len > MOTA_NRF52_FS_START) return false;
memcpy(buf, (const uint8_t*)(uintptr_t)(app_base + off), len);
return true;
}
#else
+18 -8
View File
@@ -73,14 +73,19 @@ bool OtaStoreFlashNrf52::begin(uint32_t total_size) {
clear();
// never collide with the running application image (its extent comes from its EndF trailer)
uint32_t app_end = MOTA_NRF52_APP_BASE;
const uint32_t app_base = mota_nrf52_app_base();
if (!mota_nrf52_layout_valid(app_base)) return false;
uint32_t app_end = app_base;
SelfFwInfo fi;
if (ota_self_firmware(fi) && fi.valid) app_end = MOTA_NRF52_APP_BASE + fi.image_len;
if (ota_self_firmware(fi) && fi.valid) {
if ((uint64_t)app_base + fi.image_len > MOTA_NRF52_FS_START) return false;
app_end = app_base + fi.image_len;
}
// bottom-align below FS_START + reject if it won't fit above the running image (the FS/prefs-safe
// bounds check; pure + unit-tested in test/test_ota/test_ota_flashplan.cpp)
// Bottom-align below FS_START and reject unless it sits above the running image AND the full detools
// workspace (the FS/prefs-safe bounds check; pure + unit-tested in the native OTA suite).
uint32_t start;
if (!mota_nrf52_stage_plan(total_size, app_end, start)) return false;
if (!mota_nrf52_stage_plan(total_size, app_base, app_end, start)) return false;
_write_start = start;
_total = total_size;
@@ -156,15 +161,20 @@ void OtaStoreFlashNrf52::checkpoint() {
// the first match (highest address = most recent for the common single-container case). The manager then
// parses the loaded manifest and validates geometry/root, so a stale leftover is rejected there.
bool OtaStoreFlashNrf52::reopen() {
uint32_t app_end = MOTA_NRF52_APP_BASE;
const uint32_t app_base = mota_nrf52_app_base();
if (!mota_nrf52_layout_valid(app_base)) return false;
uint32_t app_end = app_base;
SelfFwInfo fi;
if (ota_self_firmware(fi) && fi.valid) app_end = MOTA_NRF52_APP_BASE + fi.image_len;
if (ota_self_firmware(fi) && fi.valid) {
if ((uint64_t)app_base + fi.image_len > MOTA_NRF52_FS_START) return false;
app_end = app_base + fi.image_len;
}
for (uint32_t start = align_down(MOTA_NRF52_FS_START - PG, PG); start >= app_end; start -= PG) {
const uint8_t* p = (const uint8_t*)(uintptr_t)start;
if (memcmp(p, MOTA_MAGIC, 4) != 0) continue;
uint32_t total = rd_u32le(p + 4);
uint32_t want; // must be valid + placed exactly where begin() would have staged it (same bounds fn)
if (!mota_nrf52_stage_plan(total, app_end, want) || want != start) continue;
if (!mota_nrf52_stage_plan(total, app_base, app_end, want) || want != start) continue;
_write_start = start;
_total = total;
memcpy(_meta_page, p, PG); // load page 0 (header+manifest+leaves) into RAM to continue
+2 -2
View File
@@ -5,7 +5,7 @@
#include "OtaStore.h"
#include "OtaFlashLayout_nrf52.h"
// Persistent flash-backed OtaStore for nRF52 (RAK4631). Stages the received `.mota` in the free flash
// Persistent flash-backed OtaStore for nRF52840. Stages the received `.mota` in the free flash
// below the primary LittleFS (FS_START), bottom-aligned so its trailer ends at FS_START and the
// bootloader can scan for it. Survives reboot — the whole point — so the bootloader can apply the
// staged delta on the next boot.
@@ -58,7 +58,7 @@ public:
bool begin(uint32_t total_size) override;
bool write(uint32_t offset, const uint8_t* data, uint32_t len) override;
bool read(uint32_t offset, uint8_t* buf, uint32_t len) const override;
uint32_t capacity() const override { return MOTA_NRF52_FS_START - MOTA_NRF52_APP_BASE; }
uint32_t capacity() const override { return mota_nrf52_stage_capacity(mota_nrf52_app_base()); }
uint32_t staged_size() const override { return _total; }
void clear() override { _total = 0; _pay_idx = 0; _flushed = false; _io_ok = true; }
bool set_meta_size(uint32_t meta_bytes) override { return meta_bytes <= PG; } // leaves must fit page 0