Verify staged OTA payloads and flash writes

This commit is contained in:
mikecarper committed 2026-07-14 14:42:26 -07:00
1 parent 8127c1f08c
commit 8599cf0acb
18 files changed
+120 -45

No files matched your search

+2
View File
@@ -70,6 +70,7 @@ extra_scripts = merge-bin.py
post:tools/mota/pio_endf.py
build_flags = ${arduino_base.build_flags}
-D ESP32_PLATFORM
-D ENABLE_OTA=1
-D OTA_FLASH_STORE=1
-D OTA_FOLDER_SERIAL ; `ota folder on` relays a host folder of .mota over the USB console (no extra HW)
; -D ESP32_CPU_FREQ=80 ; change it to your need
@@ -120,6 +121,7 @@ extends = nrf52_base
extra_scripts = ${nrf52_base.extra_scripts}
post:tools/mota/pio_endf.py
build_flags = ${nrf52_base.build_flags}
-D ENABLE_OTA=1
-D OTA_FLASH_STORE=1
-D OTA_FOLDER_SERIAL ; `ota folder on` relays a host folder of .mota over the USB console (no extra HW)
build_src_filter = ${nrf52_base.build_src_filter}
+2 -2
View File
@@ -95,8 +95,8 @@ bool FolderMotaStore::read(uint32_t off, uint8_t* buf, uint32_t len) const {
return true;
}
void FolderMotaStore::finalize() {
txn(MS_OP_FIN, _mid, 4, nullptr, 0); // publish <midhex>.mota.part as <midhex>.mota (best-effort)
bool FolderMotaStore::finalize() {
return txn(MS_OP_FIN, _mid, 4, nullptr, 0); // publish <midhex>.mota.part as <midhex>.mota
}
bool FolderMotaStore::reopen() {
+1 -1
View File
@@ -33,7 +33,7 @@ public:
uint32_t capacity() const override { return 0xF0000000u; } // host disk — effectively unbounded
uint32_t staged_size() const override { return _total; }
void clear() override { _total = 0; }
void finalize() override;
bool finalize() override;
bool reopen() override; // OP_STAT: adopt an existing file for resume
// A host-backed store is fully random-access, so it needs no pinned-meta RAM page and no layout planning.
+16
View File
@@ -72,6 +72,22 @@ bool mota_check_root(const MotaManifest& m) {
return memcmp(root, m.merkle_root, 4) == 0;
}
bool mota_check_payload(const MotaManifest& m) {
if (!m.payload || !m.leaves || m.block_count == 0) return false;
uint32_t off = 0;
uint32_t bs = m.block_size();
for (uint32_t i = 0; i < m.block_count; i++) {
if (off >= m.payload_size) return false;
uint32_t len = m.payload_size - off;
if (len > bs) len = bs;
uint8_t leaf[4];
merkle_leaf(leaf, m.payload + off, len);
if (memcmp(leaf, m.leaves + i * 4, 4) != 0) return false;
off += len;
}
return off == m.payload_size;
}
bool mota_check_image_hash_full(const MotaManifest& m) {
if (!m.is_full() || !m.payload || !m.image_hash) return false;
uint8_t h[32];
+5
View File
@@ -57,6 +57,11 @@ bool mota_parse_manifest(const uint8_t* mf, uint32_t len, MotaManifest& out);
// Recompute the merkle root from the manifest's leaves[] and compare to the merkle_root field.
bool mota_check_root(const MotaManifest& m);
// Rehash every actual payload block and compare it with the corresponding stored leaf. This must be
// checked in addition to mota_check_root(): a self-consistent leaves[] array alone says nothing about
// bytes lost or corrupted while programming the staging flash.
bool mota_check_payload(const MotaManifest& m);
// For FULL images only: check sha2-256:32(payload) == image_hash.
bool mota_check_image_hash_full(const MotaManifest& m);
+3 -1
View File
@@ -462,7 +462,9 @@ bool ota_apply_mota_nrf52(const uint8_t* buf, uint32_t len, const SignerAllowlis
st.sig_ok = vr.sig_ok; st.trusted = vr.trusted;
// 1) downloaded payload: the fetched blocks must match the manifest's merkle root (intact + complete)
if (!vr.root_ok || !vr.image_ok) { strcpy(msg, "payload hash mismatch (incomplete or corrupt .mota)"); return false; }
if (!vr.root_ok || !vr.payload_ok || !vr.image_ok) {
strcpy(msg, "payload hash mismatch (incomplete or corrupt .mota)"); return false;
}
// 2) target firmware: the delta must be built against THIS running image (base_hash == our EndF body
// hash). The resulting image_hash is re-checked by the bootloader after the in-place decode -- a
+5 -3
View File
@@ -433,7 +433,8 @@ static bool handle_dev(const char* d, char* reply, OtaContext& c) {
c.serving = c.manager.serve(c.serve_buf, c.serve_expected);
if (!c.serving) { strcpy(reply, "ERR serve (bad .mota)"); return true; }
VerifyResult r = ota_verify(c.serve_buf, c.serve_expected, c.allow);
sprintf(reply, "OK serving | root=%d img=%d sig=%d trust=%d", r.root_ok, r.image_ok, r.sig_ok, r.trusted);
sprintf(reply, "OK serving | root=%d payload=%d img=%d sig=%d trust=%d",
r.root_ok, r.payload_ok, r.image_ok, r.sig_ok, r.trusted);
} else if (strncmp(d, "resume", 6) == 0) { // re-adopt a container already staged in flash (test/debug)
bool ok = c.manager.resumeStaged(nullptr);
@@ -451,8 +452,9 @@ static bool handle_dev(const char* d, char* reply, OtaContext& c) {
else { buf = c.serve_buf; len = c.serve_expected; }
if (len == 0 || !buf) { strcpy(reply, "ERR nothing to verify (flash-staged: applydelta verifies)"); return true; }
VerifyResult r = ota_verify(buf, len, c.allow);
sprintf(reply, "verify parsed=%d root=%d img=%d signed=%d sig=%d trust=%d | ok=%d auto=%d",
r.parsed, r.root_ok, r.image_ok, r.is_signed, r.sig_ok, r.trusted, r.integrity_ok(), r.auto_appliable());
sprintf(reply, "verify parsed=%d root=%d payload=%d img=%d signed=%d sig=%d trust=%d | ok=%d auto=%d",
r.parsed, r.root_ok, r.payload_ok, r.image_ok, r.is_signed, r.sig_ok, r.trusted,
r.integrity_ok(), r.auto_appliable());
} else if (strncmp(d, "want ", 5) == 0) {
const char* p = d + 5; while (*p == ' ') p++;
+5 -5
View File
@@ -585,7 +585,7 @@ void OtaManager::diffStep() {
OTA_DBG("OTA: leaf-diff %u/%u already valid; fetching the rest\n", (unsigned)_have, (unsigned)_fbc);
freeLeaves(); // clears _diffing + frees the buffer
if (_have >= _fbc) { // seed covered the whole image
_fstate = COMPLETE; _fetch->finalize();
_fstate = _fetch->finalize() ? COMPLETE : FAILED;
return;
}
_fstate = FETCHING; requestMissing();
@@ -630,7 +630,7 @@ bool OtaManager::resumeStaged(const uint8_t* want_mid) {
} else {
_fstate = COMPLETE;
}
if (_fstate == COMPLETE) _fetch->finalize();
if (_fstate == COMPLETE && !_fetch->finalize()) _fstate = FAILED;
return true;
}
_fstate = FETCHING; // resume fetching the holes
@@ -687,7 +687,7 @@ void OtaManager::handleProof(const uint8_t* m, uint16_t n) {
// verified -> commit the payload block, then its leaf (the present marker). A write failure here means a
// FOLDER destination's seeder link dropped mid-transfer: PAUSE (hold progress on the host, stop
// requesting, do NOT fall back to RAM/flash). The block is left uncommitted (its leaf stays 0xFF), so on
// reconnect resumeStaged() re-requests exactly it. (A flash store never fails these writes.)
// reconnect resumeStaged() re-requests exactly it. Flash failures also pause instead of being ignored.
uint8_t leaf[4]; merkle_leaf(leaf, _reasm_buf, blen);
if (!_fetch->write(_fpoff + (uint32_t)_reasm_block * _fbs, _reasm_buf, blen) ||
!_fetch->write(_floff + (uint32_t)_reasm_block * 4, leaf, 4)) {
@@ -707,8 +707,8 @@ void OtaManager::handleProof(const uint8_t* m, uint16_t n) {
} else {
_fstate = COMPLETE; // per-block proofs already guaranteed integrity vs the root
}
if (_fstate == COMPLETE) _fetch->finalize();
OTA_DBG("OTA: transfer %s\n", _fstate == COMPLETE ? "COMPLETE" : "FAILED(root)");
if (_fstate == COMPLETE && !_fetch->finalize()) _fstate = FAILED;
OTA_DBG("OTA: transfer %s\n", _fstate == COMPLETE ? "COMPLETE" : "FAILED(integrity/storage)");
}
void OtaManager::requestMissing() {
+3 -2
View File
@@ -33,8 +33,9 @@ public:
// Optional: commit any RAM-buffered data to persistent storage. Called once when the transfer
// reaches COMPLETE (radio idle), so a flash store does its page writes off the RX critical path.
// After this returns, a flash store's data() view is coherent. No-op for purely in-RAM stores.
virtual void finalize() {}
// After a true return, a flash store's data() view is coherent. False means persistence failed and the
// transfer must not be marked COMPLETE or approved for installation. No-op for purely in-RAM stores.
virtual bool finalize() { return true; }
// Optional: persist in-progress state (the metadata/leaf-progress page + any open payload buffer) so a
// reboot mid-transfer can resume. Called by OtaManager every OTA_CHECKPOINT_BLOCKS committed blocks.
+5 -3
View File
@@ -165,8 +165,9 @@ bool OtaStoreFlashEsp32::read(uint32_t offset, uint8_t* buf, uint32_t len) const
return true;
}
void OtaStoreFlashEsp32::finalize() {
if (_flushed || _total == 0) return;
bool OtaStoreFlashEsp32::finalize() {
if (_flushed) return _io_ok;
if (_total == 0 || !_io_ok) return false;
if (!_full) {
// delta: drop the 5-byte trailer into the sliding payload sector(s) so it flushes with them (the
// trailer sits right after the payload; this also covers the rare case it spills into a fresh sector).
@@ -181,8 +182,9 @@ void OtaStoreFlashEsp32::finalize() {
}
flush_pay(); // last payload sector(s) (+ delta trailer)
flush_sector(_meta_part, _meta, _meta_flush); // meta (+ trailer for full)
_flushed = true;
_flushed = _io_ok;
OTA_DBG("OTA esp32: finalize %s io_ok=%d\n", _full ? "FULL" : "DELTA", (int)_io_ok);
return _io_ok;
}
// Persist mid-transfer progress so a reboot can resume. Flush the open payload sector (KEEP it buffered so
+1 -1
View File
@@ -92,7 +92,7 @@ public:
uint32_t staged_size() const override { return _total; }
void clear() override;
bool set_meta_size(uint32_t meta_bytes) override { return meta_bytes < OTA_ESP32_META_CAP; }
void finalize() override;
bool finalize() override;
void checkpoint() override; // persist meta(leaves) + open payload sector so a reboot can resume
bool reopen() override; // re-attach to a container already staged in the slot (scan + rebuild geometry)
+46 -21
View File
@@ -15,16 +15,28 @@ namespace ota {
// past the container, so the program is clean. The last container page ends exactly at FS_START (both
// FS_START and _write_start are page-aligned and the container ends <= FS_START), so a full-page write
// never reaches into ExtraFS.
void OtaStoreFlashNrf52::flush_page(uint32_t page_idx, const uint8_t* buf) {
uint32_t addr = _write_start + page_idx * PG;
if (addr + PG > MOTA_NRF52_FS_START) return; // defensive: never cross the staging ceiling
OTA_DBG("OTA flash: write page %u @ %08x\n", (unsigned)page_idx, (unsigned)addr);
flash_nrf5x_write(addr, buf, PG);
flash_nrf5x_flush();
static bool flash_matches(uint32_t addr, const uint8_t* expected, uint32_t n) {
const volatile uint8_t* actual = (const volatile uint8_t*)(uintptr_t)addr;
for (uint32_t i = 0; i < n; i++) if (actual[i] != expected[i]) return false;
return true;
}
void OtaStoreFlashNrf52::flush_pay() {
if (_pay_idx != 0) flush_page(_pay_idx, _pay_page); // _pay_idx 0 == no payload page open
bool OtaStoreFlashNrf52::flush_page(uint32_t page_idx, const uint8_t* buf) {
if (!_io_ok || _write_start > MOTA_NRF52_FS_START - PG ||
page_idx > (MOTA_NRF52_FS_START - PG - _write_start) / PG) {
_io_ok = false;
return false;
}
uint32_t addr = _write_start + page_idx * PG;
OTA_DBG("OTA flash: write page %u @ %08x\n", (unsigned)page_idx, (unsigned)addr);
if (flash_nrf5x_write(addr, buf, PG) < 0) { _io_ok = false; return false; }
flash_nrf5x_flush();
if (!flash_matches(addr, buf, PG)) { _io_ok = false; return false; }
return true;
}
bool OtaStoreFlashNrf52::flush_pay() {
return _pay_idx == 0 || flush_page(_pay_idx, _pay_page); // _pay_idx 0 == no payload page open
}
uint32_t OtaStoreFlashNrf52::run(uint32_t pos, uint32_t remain) const {
@@ -49,7 +61,10 @@ uint8_t* OtaStoreFlashNrf52::write_slot(uint32_t pos) {
if (pos >= _total - 5) return _trailer + (pos - (_total - 5));
uint32_t page = pos / PG;
if (page == 0) return _meta_page + pos;
if (page > _pay_idx) { flush_pay(); _pay_idx = page; memset(_pay_page, 0xFF, PG); } // advance, fresh page
if (page > _pay_idx) {
if (!flush_pay()) return nullptr;
_pay_idx = page; memset(_pay_page, 0xFF, PG); // advance, fresh page
}
if (page == _pay_idx) return _pay_page + (pos - page * PG);
return nullptr; // page < _pay_idx: already flushed
}
@@ -73,23 +88,28 @@ bool OtaStoreFlashNrf52::begin(uint32_t total_size) {
memset(_trailer, 0xFF, sizeof(_trailer));
_pay_idx = 0;
_flushed = false;
_io_ok = true;
OTA_DBG("OTA flash: begin total=%u start=%08x app_end=%08x\n",
(unsigned)total_size, (unsigned)start, (unsigned)app_end);
return true; // no pre-erase: each page is erased by its own (single) flush
}
bool OtaStoreFlashNrf52::write(uint32_t offset, const uint8_t* d, uint32_t len) {
if ((uint64_t)offset + len > _total) return false;
if ((uint64_t)offset + len > _total || !_io_ok) return false;
for (uint32_t pos = offset, end = offset + len; pos < end; ) {
uint32_t n = run(pos, end - pos);
if (uint8_t* dst = write_slot(pos)) {
uint8_t* dst = write_slot(pos);
if (!_io_ok) return false;
if (dst) {
memcpy(dst, d, n);
} else {
// out-of-order write to an already-flushed page: read-modify-write straight to flash. Safe -- the
// driver erases the page before programming, so re-touching it never breaks writes-per-word.
OTA_DBG("OTA flash: RMW page %u (out-of-order) @ off %u\n", (unsigned)(pos / PG), (unsigned)pos);
if (flash_nrf5x_write(_write_start + pos, d, n) < 0) return false;
uint32_t addr = _write_start + pos;
if (flash_nrf5x_write(addr, d, n) < 0) { _io_ok = false; return false; }
flash_nrf5x_flush();
if (!flash_matches(addr, d, n)) { _io_ok = false; return false; }
}
pos += n; d += n;
}
@@ -97,7 +117,7 @@ bool OtaStoreFlashNrf52::write(uint32_t offset, const uint8_t* d, uint32_t len)
}
bool OtaStoreFlashNrf52::read(uint32_t offset, uint8_t* buf, uint32_t len) const {
if ((uint64_t)offset + len > _total) return false;
if ((uint64_t)offset + len > _total || !_io_ok) return false;
for (uint32_t pos = offset, end = offset + len; pos < end; ) {
uint32_t n = run(pos, end - pos);
memcpy(buf, read_slot(pos), n);
@@ -106,14 +126,18 @@ bool OtaStoreFlashNrf52::read(uint32_t offset, uint8_t* buf, uint32_t len) const
return true;
}
void OtaStoreFlashNrf52::finalize() {
if (_flushed || _total == 0) return;
bool OtaStoreFlashNrf52::finalize() {
if (_flushed) return _io_ok;
if (_total == 0 || !_io_ok) return false;
OTA_DBG("OTA flash: finalize total=%u\n", (unsigned)_total);
flush_pay(); // the last (highest) payload page, if one is open
flush_page(0, _meta_page); // page 0: header + manifest + leaves + first payload bytes
flash_nrf5x_write(_write_start + _total - 5, _trailer, 5); // trailer tail (radio idle at COMPLETE)
if (!flush_pay()) return false; // the last (highest) payload page, if one is open
if (!flush_page(0, _meta_page)) return false; // header + manifest + leaves + first payload bytes
uint32_t trailer_addr = _write_start + _total - 5;
if (flash_nrf5x_write(trailer_addr, _trailer, 5) < 0) { _io_ok = false; return false; }
flash_nrf5x_flush();
if (!flash_matches(trailer_addr, _trailer, 5)) { _io_ok = false; return false; }
_flushed = true;
return true;
}
// Persist mid-transfer progress so a reboot can resume. Order matters for consistency: flush the open
@@ -121,9 +145,9 @@ void OtaStoreFlashNrf52::finalize() {
// also has its payload in flash. Infrequent (every OTA_CHECKPOINT_BLOCKS blocks), so the 2 extra page
// erases don't matter; at LoRa block rates it's roughly once per many minutes.
void OtaStoreFlashNrf52::checkpoint() {
if (_total == 0 || _flushed) return;
flush_pay(); // keep _pay_idx open (it may still receive writes); just re-flush its bytes
flush_page(0, _meta_page); // header + manifest + leaves accumulated so far
if (_total == 0 || _flushed || !_io_ok) return;
if (!flush_pay()) return; // keep _pay_idx open; it may still receive writes
flush_page(0, _meta_page); // header + manifest + leaves accumulated so far
}
// Re-attach to a container already staged in flash (after a reboot), without erasing. The container is
@@ -147,6 +171,7 @@ bool OtaStoreFlashNrf52::reopen() {
memcpy(_trailer, p + (total - 5), 5); // recover the trailer tail (flushed at last finalize, if any)
_pay_idx = 0;
_flushed = false;
_io_ok = true;
OTA_DBG("OTA flash: reopen total=%u start=%08x\n", (unsigned)total, (unsigned)start);
return true;
}
+6 -5
View File
@@ -38,6 +38,7 @@ class OtaStoreFlashNrf52 : public OtaStore {
uint32_t _write_start = 0; // flash address of container offset 0 (page-aligned)
uint32_t _total = 0; // staged container size (0 = none)
bool _flushed = false; // finalize() committed everything to flash
bool _io_ok = true; // cleared on a failed/out-of-bounds flash write or readback mismatch
uint8_t _meta_page[PG]; // pinned flash page 0 (header + manifest + leaves + 1st payload)
uint8_t _pay_page[PG]; // sliding buffer for one payload page (index _pay_idx)
@@ -50,8 +51,8 @@ class OtaStoreFlashNrf52 : public OtaStore {
// opens/advances the sliding payload page and returns nullptr if `pos` is in an already-flushed page.
const uint8_t* read_slot(uint32_t pos) const;
uint8_t* write_slot(uint32_t pos);
void flush_pay(); // commit _pay_page to flash (erase + program, one page)
void flush_page(uint32_t page_idx, const uint8_t* buf); // write a full page to flash
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
public:
bool begin(uint32_t total_size) override;
@@ -59,15 +60,15 @@ public:
bool read(uint32_t offset, uint8_t* buf, uint32_t len) const override;
uint32_t capacity() const override { return MOTA_NRF52_FS_START - MOTA_NRF52_APP_BASE; }
uint32_t staged_size() const override { return _total; }
void clear() override { _total = 0; _pay_idx = 0; _flushed = false; }
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
void finalize() override;
bool finalize() override;
void checkpoint() override; // persist page 0 (leaves) + the open payload page so a reboot can resume
bool reopen() override; // re-attach to a container already staged in flash (scan for it)
// Contiguous view (flash is memory-mapped). VALID ONLY AFTER finalize() — before that, page 0 and the
// tail are still in RAM. OtaManager/OtaCli/verify use this only once the transfer is COMPLETE.
const uint8_t* data() const { return (const uint8_t*)(uintptr_t)_write_start; }
const uint8_t* data() const { return (_flushed && _io_ok) ? (const uint8_t*)(uintptr_t)_write_start : nullptr; }
uint32_t write_start() const { return _write_start; }
};
+1
View File
@@ -12,6 +12,7 @@ VerifyResult ota_verify(const uint8_t* buf, uint32_t len, const SignerAllowlist&
if (!mota_parse(buf, len, m)) return r;
r.parsed = true;
r.root_ok = mota_check_root(m);
r.payload_ok = mota_check_payload(m);
r.image_ok = m.is_full() ? mota_check_image_hash_full(m)
: true; // delta image_hash needs the base; verified at apply time
r.is_signed = m.is_signed();
+2 -1
View File
@@ -12,13 +12,14 @@ namespace ota {
struct VerifyResult {
bool parsed = false; // container + manifest parsed
bool root_ok = false; // merkle_root recomputed from leaves[] matches
bool payload_ok = false; // every staged payload block hashes to its stored merkle leaf
bool image_ok = false; // full: sha2-256(payload)==image_hash; delta: deferred to apply (set true)
bool is_signed = false;
bool sig_ok = false; // Ed25519 signature valid for signer_pubkey
bool trusted = false; // signer_pubkey is in the allowlist
// Integrity holds (safe to keep/serve). For a signed image, the signature must also verify.
bool integrity_ok() const { return parsed && root_ok && image_ok && (!is_signed || sig_ok); }
bool integrity_ok() const { return parsed && root_ok && payload_ok && image_ok && (!is_signed || sig_ok); }
// Eligible for AUTO-apply: integrity + signed by an allowlisted key (decision D2).
bool auto_appliable() const { return integrity_ok() && is_signed && sig_ok && trusted; }
};
+15
View File
@@ -115,6 +115,21 @@ TEST(OtaMerkle, RootMatchesVectorAndLeaves) {
merkle_leaf(leaf, m.payload + off, len);
EXPECT_EQ(0, memcmp(leaf, m.leaves + i * 4, 4)) << "leaf " << i;
}
EXPECT_TRUE(mota_check_payload(m));
}
TEST(OtaMerkle, PayloadCheckDetectsCorruptionNotVisibleInStoredLeaves) {
std::vector<uint8_t> b(MOTA_VEC, MOTA_VEC + MOTA_VEC_LEN);
MotaManifest m;
ASSERT_TRUE(mota_parse(b.data(), b.size(), m));
ASSERT_TRUE(mota_check_root(m));
ASSERT_TRUE(mota_check_payload(m));
size_t payload_off = (size_t)(m.payload - b.data());
b[payload_off + m.payload_size / 2] ^= 0x01;
ASSERT_TRUE(mota_parse(b.data(), b.size(), m));
EXPECT_TRUE(mota_check_root(m)); // leaves[] and its root are still self-consistent
EXPECT_FALSE(mota_check_payload(m)); // actual staged payload no longer matches those leaves
}
TEST(OtaMerkle, FullImageHashMatches) {
+1
View File
@@ -7,6 +7,7 @@ board = heltec_t114
board_build.ldscript = boards/nrf52840_s140_v6.ld
build_flags = ${rak4631_hw.build_flags}
${sensor_base.build_flags}
-D MOTA_HW_ID='"Heltec_t114"' ; OTA hardware tag (apply refuses a .mota built for other hardware)
-I lib/nrf52/s140_nrf52_6.1.1_API/include
-I lib/nrf52/s140_nrf52_6.1.1_API/include/nrf52
-I variants/heltec_t114
+1
View File
@@ -28,6 +28,7 @@ build_flags = ${nrf52_base.build_flags}
-D ENV_INCLUDE_RAK12035=1
-UENV_INCLUDE_BME680
-D ENV_INCLUDE_BME680_BSEC=1
-D ENABLE_OTA=1 ; LoRa OTA on every RAK4631 role (single-slot, bootloader-applied)
-D OTA_FLASH_STORE=1 ; stage the received .mota in flash (survives reboot into the bootloader)
-D OTA_FOLDER_SERIAL ; `ota folder on` relays a host folder of .mota over the USB console (no extra HW)
build_src_filter = ${nrf52_base.build_src_filter}