Harden LoRa OTA and enable unattended targets

This commit is contained in:
mikecarper
2026-07-14 15:24:35 -07:00
parent 8599cf0acb
commit 11aa4bc191
17 changed files with 204 additions and 86 deletions
+5 -2
View File
@@ -1466,12 +1466,13 @@ apply_debug_overrides() {
is_lora_ota_build() {
local env_name=$1
local env_name_lc=${env_name,,}
if [ "${PIO_ENV_OTA_BY_NAME[$env_name]:-0}" != "1" ]; then
return 1
fi
if [[ "$env_name" == *mqtt* ]] \
if [[ "$env_name_lc" == *mqtt* ]] \
|| is_mqtt_bridge_target "$env_name" \
|| [ "${MQTT_BRIDGE_OVERRIDE,,}" == "on" ] \
|| [ "${MESHDEBUG_OVERRIDE,,}" == "on" ] \
@@ -1480,7 +1481,9 @@ is_lora_ota_build() {
return 1
fi
case "$env_name" in
# PlatformIO environment names are not consistently cased (for example, several ESP32 targets use
# `_Repeater`). Match roles case-insensitively while keeping OTA limited to unattended deployments.
case "$env_name_lc" in
*repeater*|*repeatr*|*room_server*|*room_svr*|*sensor*) return 0 ;;
*) return 1 ;;
esac
+5 -5
View File
@@ -1,4 +1,4 @@
# Easy full-firmware update over LoRa
# Easy full-firmware update over LoRa (ESP32)
This guide shows the shortest manual path for sending a **full firmware image** from a computer to a
MeshCore node over LoRa. It uses this temporary OTA channel:
@@ -30,10 +30,10 @@ You need:
- A standard, non-logging Keymind OTA build on the source and destination nodes. Logging and MQTT builds do
not include LoRa OTA. Use the WiFi or USB connection to update those.
- An OTA-capable destination. ESP32 boards use their A/B firmware slots. nRF52 nodes also need the
MeshCore OTAFIX bootloader.
- The new, non-merged application firmware for the destination's exact board **and role**. Use `.bin` for
ESP32 or `.hex` for nRF52; do not package an ESP32 `-merged.bin` factory image.
- An OTA-capable ESP32 destination with an A/B partition table. nRF52 has a single application slot and
cannot install this guide's full-image container; it requires an in-place delta plus the OTAFIX bootloader.
- The new, non-merged `.bin` application firmware for the destination's exact board **and role**. Do not
package an ESP32 `-merged.bin` factory image.
- A source node connected to the computer by USB serial.
- Overlapping `tempradio` windows on the source, destination, and every repeater needed between them.
+5 -4
View File
@@ -195,7 +195,7 @@ cover `approval` or `leaves[]`:
| `codec_id` | Meaning | Used by |
|---|---|---|
| 0 | full / raw | PAYLOAD = reconstructed image (`BODY‖EndF`). ESP32 A/B (and any board for a full image). |
| 0 | full / raw | PAYLOAD = reconstructed image (`BODY‖EndF`). ESP32 A/B only. |
| 1 | detools **sequential** | random read of base + sequential write of result → ESP32 A→B inactive slot. |
| 2 | detools **in-place** | bounded scratch; rewrites the app region in place → nRF52 single-slot. |
@@ -204,9 +204,10 @@ delta only if `base_hash` matches its own `EndF.body_hash`. After applying, the
(sha2-256:32) to `image_hash` before it is booted — the hard security gate.
**A fetcher only requests firmware it can apply.** Each node declares the codec(s) it can apply
(`set_apply_codec`/`set_apply_codec2`): ESP32 accepts `full` + `sequential` (+ `in-place`), nRF52 accepts
`full` + `in-place`. `CODEC_FULL` is always acceptable. A `.mota` with an unsupported codec is rejected at
discovery time, before any blocks are requested.
(`set_apply_codec`/`set_apply_codec2`): ESP32 accepts `full` + `sequential` (+ `in-place`), while nRF52
accepts only `in-place` because its single slot cannot stage a full application image. A `.mota` with an
unsupported codec is rejected at discovery time, before any blocks are requested. A manual pull to an
external folder may accept other codecs because that path captures bytes and never installs them.
Compression is internal to the detools patch and must be supported by the applier. Patches are produced by
**detools 0.53.0** (`tools/mota``detools.create_patch`) and decoded on-device by detools' embeddable C
+1 -1
View File
@@ -256,7 +256,7 @@ that only contains what changed). You get them by:
| See my firmware + any download | `ota status` (or just `ota`) |
| Admin: ids/hashes + serving + policy | `ota stats` (admin-only remotely) |
| Find updates nearby | `ota ls` |
| Download update #1 | `ota get 1` |
| Download update #1 for installation | `ota get 1 flash` |
| Cancel a download | `ota cancel` |
| Install a finished download | `ota install` |
| Turn on auto-download | `ota config autofetch any` |
+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
+8 -2
View File
@@ -808,7 +808,7 @@ static uint16_t make_have1(uint8_t* buf, uint16_t cap, const uint8_t mid[4],
}
// A node must not fetch firmware it can't apply: a catalog row whose codec the platform can't decode is
// not fetched. FULL + the platform's delta codec(s) are accepted.
// not fetched. Full-image acceptance is platform-selectable (nRF52 single-slot disables it).
TEST(OtaTransfer, RejectsIncompatibleCodec) {
g_q.clear();
OtaManager client; OtaStoreRam<4096> store;
@@ -816,7 +816,8 @@ TEST(OtaTransfer, RejectsIncompatibleCodec) {
client.begin(SIM_TARGET_ID, sim_send, &to_server);
client.set_fetch_store(&store);
client.set_autofetch(OtaManager::AUTOFETCH_ANY);
client.set_apply_codec(CODEC_DETOOLS_INPLACE); // nRF52-style: accepts only full + in-place
client.set_apply_codec(CODEC_DETOOLS_INPLACE);
client.set_accept_full(false); // nRF52-style: in-place delta only
uint8_t b[64];
// a SEQUENTIAL delta for our target -> incompatible -> not fetched (stays IDLE)
@@ -824,6 +825,11 @@ TEST(OtaTransfer, RejectsIncompatibleCodec) {
client.on_message(b, make_have1(b, sizeof(b), midA, SIM_TARGET_ID, 0x01000000, CODEC_DETOOLS_SEQUENTIAL, 0));
EXPECT_EQ(client.fetchState(), OtaManager::IDLE);
// a FULL image cannot be installed in an nRF52 single slot -> do not spend hours fetching it
uint8_t midFull[4] = {2,3,4,5};
client.on_message(b, make_have1(b, sizeof(b), midFull, SIM_TARGET_ID, 0x01000000, CODEC_FULL, MFLAG_FULL));
EXPECT_EQ(client.fetchState(), OtaManager::IDLE);
// an IN-PLACE delta for our target -> compatible -> begins fetching (requests the manifest)
uint8_t midB[4] = {5,6,7,8};
client.on_message(b, make_have1(b, sizeof(b), midB, SIM_TARGET_ID, 0x01000000, CODEC_DETOOLS_INPLACE, 0));
+39 -27
View File
@@ -4,60 +4,61 @@
using namespace mesh::ota;
// These lock down the nRF52 (RAK4631) single-slot staging geometry that OtaStoreFlashNrf52::begin()/
// These lock down the nRF52 single-slot staging geometry that OtaStoreFlashNrf52::begin()/
// reopen() rely on. A received `.mota` is placed bottom-aligned below the filesystem region — ExtraFS
// (0xD4000) / InternalFS (0xED000), where the node's user preferences live — and above the running image.
// The prefs region is assumed IMMUTABLE (its bytes are outside the served/hashed self-image), so staging
// or an in-place apply must never reach into it. If a layout constant or the placement math is edited
// inconsistently, these fail here instead of silently corrupting prefs / the app on real hardware.
static constexpr uint32_t CAP = MOTA_NRF52_FS_START - MOTA_NRF52_APP_BASE; // 0xAE000, 696 KB
static constexpr uint32_t APP_V6 = MOTA_NRF52_APP_BASE_S140_V6;
static constexpr uint32_t APP_V7 = MOTA_NRF52_APP_BASE_S140_V7;
static constexpr uint32_t CAP_V6 = MOTA_NRF52_FS_START - (APP_V6 + MOTA_NRF52_INPLACE_MEMORY); // 88 KB
static constexpr uint32_t CAP_V7 = MOTA_NRF52_FS_START - (APP_V7 + MOTA_NRF52_INPLACE_MEMORY); // 84 KB
// A typical running image (~520 KB) leaves room; the container lands strictly within (app_end, FS_START].
TEST(OtaFlashPlan, StagesBelowFilesystemAndAboveApp) {
uint32_t app_end = MOTA_NRF52_APP_BASE + 520u * 1024u;
uint32_t app_end = APP_V6 + 520u * 1024u;
uint32_t start = 0xDEADBEEF;
ASSERT_TRUE(mota_nrf52_stage_plan(64u * 1024u, app_end, start));
ASSERT_TRUE(mota_nrf52_stage_plan(64u * 1024u, APP_V6, app_end, start));
EXPECT_GE(start, app_end); // never overlaps the running image
EXPECT_GE(start, APP_V6 + MOTA_NRF52_INPLACE_MEMORY); // never overlaps detools workspace
EXPECT_LE(start + 64u * 1024u, MOTA_NRF52_FS_START); // never reaches into ExtraFS/InternalFS/prefs
EXPECT_EQ(start % MOTA_NRF52_FLASH_PAGE, 0u); // page-aligned (the flash erase unit)
}
// Bottom-aligned: the container's trailer ends AT FS_START (the bootloader scans downward from there),
// so start is the page-aligned FS_START - total_size and sits within one page of the ceiling.
TEST(OtaFlashPlan, BottomAlignedTrailerEndsAtCeiling) {
// Bottom-aligned: start is the page-aligned FS_START - total_size, so the trailer sits within the
// highest page below the ceiling where the bootloader's downward scan finds it.
TEST(OtaFlashPlan, BottomAlignedBelowCeiling) {
uint32_t start = 0;
uint32_t total = 100000;
ASSERT_TRUE(mota_nrf52_stage_plan(total, MOTA_NRF52_APP_BASE, start));
uint32_t total = 60000;
ASSERT_TRUE(mota_nrf52_stage_plan(total, APP_V6, APP_V6, start));
EXPECT_EQ(start, (MOTA_NRF52_FS_START - total) & ~(MOTA_NRF52_FLASH_PAGE - 1));
EXPECT_LE(start + total, MOTA_NRF52_FS_START);
EXPECT_GT(start + total, MOTA_NRF52_FS_START - MOTA_NRF52_FLASH_PAGE); // within one page of the ceiling
}
// An exactly-capacity container (no app below it) fills the whole staging region; one byte more never fits.
// An exactly-capacity container fills the region above the decoder workspace; one byte more never fits.
TEST(OtaFlashPlan, RejectsOversizedContainer) {
uint32_t start = 0;
ASSERT_TRUE(mota_nrf52_stage_plan(CAP, MOTA_NRF52_APP_BASE, start));
EXPECT_EQ(start, MOTA_NRF52_APP_BASE); // exactly fills [APP_BASE, FS_START)
EXPECT_EQ(start + CAP, MOTA_NRF52_FS_START);
EXPECT_FALSE(mota_nrf52_stage_plan(CAP + 1, MOTA_NRF52_APP_BASE, start));
ASSERT_TRUE(mota_nrf52_stage_plan(CAP_V6, APP_V6, APP_V6, start));
EXPECT_EQ(start, APP_V6 + MOTA_NRF52_INPLACE_MEMORY);
EXPECT_EQ(start + CAP_V6, MOTA_NRF52_FS_START);
EXPECT_FALSE(mota_nrf52_stage_plan(CAP_V6 + 1, APP_V6, APP_V6, start));
}
// A container that would fit below FS_START on its own but overlaps the running image is refused —
// the pull fails cleanly rather than corrupting the app that is currently executing.
TEST(OtaFlashPlan, RejectsCollisionWithRunningImage) {
uint32_t app_end = MOTA_NRF52_FS_START - 8u * 1024u; // only 8 KB free below the ceiling
// A running image that exceeds detools' fixed in-place memory can never be a valid delta base.
TEST(OtaFlashPlan, RejectsAppLargerThanWorkspace) {
uint32_t app_end = APP_V6 + MOTA_NRF52_INPLACE_MEMORY + 1;
uint32_t start = 0;
EXPECT_TRUE(mota_nrf52_stage_plan(4u * 1024u, app_end, start)); // 4 KB fits in the 8 KB gap
EXPECT_GE(start, app_end);
EXPECT_FALSE(mota_nrf52_stage_plan(16u * 1024u, app_end, start)); // 16 KB does not
EXPECT_FALSE(mota_nrf52_stage_plan(4u * 1024u, APP_V6, app_end, start));
}
// Minimum container is header(8)+trailer(5)=13 bytes; anything smaller is not a container.
TEST(OtaFlashPlan, RejectsUndersizedContainer) {
uint32_t start = 0;
EXPECT_FALSE(mota_nrf52_stage_plan(12, MOTA_NRF52_APP_BASE, start));
EXPECT_TRUE(mota_nrf52_stage_plan(13, MOTA_NRF52_APP_BASE, start));
EXPECT_FALSE(mota_nrf52_stage_plan(12, APP_V6, APP_V6, start));
EXPECT_TRUE(mota_nrf52_stage_plan(13, APP_V6, APP_V6, start));
}
// The user-preferences filesystems (ExtraFS @ 0xD4000, InternalFS @ 0xED000) are entirely ABOVE any
@@ -71,15 +72,26 @@ TEST(OtaFlashPlan, PrefsRegionNeverStaged) {
EXPECT_LT(EXTRAFS_START, INTERNALFS_START);
// the largest possible staged container still ends at the ceiling, never into a filesystem
uint32_t start = 0;
ASSERT_TRUE(mota_nrf52_stage_plan(CAP, MOTA_NRF52_APP_BASE, start));
EXPECT_LE(start + CAP, EXTRAFS_START);
ASSERT_TRUE(mota_nrf52_stage_plan(CAP_V6, APP_V6, APP_V6, start));
EXPECT_LE(start + CAP_V6, EXTRAFS_START);
// and the in-place apply workspace ends below the filesystem too
EXPECT_LE(MOTA_NRF52_APP_BASE + MOTA_NRF52_INPLACE_MEMORY, EXTRAFS_START);
EXPECT_LE(APP_V6 + MOTA_NRF52_INPLACE_MEMORY, EXTRAFS_START);
}
// S140 v7 moves the app start by one page. Runtime linker-base discovery must leave a correspondingly
// smaller but still safe staging region rather than scanning the v6 address and missing EndF.
TEST(OtaFlashPlan, SupportsS140V7RuntimeBase) {
EXPECT_TRUE(mota_nrf52_layout_valid(APP_V7));
EXPECT_EQ(mota_nrf52_stage_capacity(APP_V7), CAP_V7);
uint32_t start = 0;
ASSERT_TRUE(mota_nrf52_stage_plan(CAP_V7, APP_V7, APP_V7 + 520u * 1024u, start));
EXPECT_EQ(start, APP_V7 + MOTA_NRF52_INPLACE_MEMORY);
EXPECT_EQ(start + CAP_V7, MOTA_NRF52_FS_START);
}
// out_start is only written on success — a rejected plan must not clobber the caller's variable.
TEST(OtaFlashPlan, LeavesOutputUntouchedOnReject) {
uint32_t start = 0x1234ABCD;
EXPECT_FALSE(mota_nrf52_stage_plan(CAP + 1, MOTA_NRF52_APP_BASE, start));
EXPECT_FALSE(mota_nrf52_stage_plan(CAP_V6 + 1, APP_V6, APP_V6, start));
EXPECT_EQ(start, 0x1234ABCDu);
}
+3 -3
View File
@@ -48,6 +48,6 @@ firmware and matches a delta's `base_hash` against its own `EndF`. Wiring (handl
- **nRF52 / STM32** (emit `.hex``.uf2`): the same hook rewrites the `.hex` with the trailer at the image
end. The byte logic is `motalib.ensure_endf`, used everywhere.
`target_id` = `sha2-256:4(pio_env_name)`, `hw_id` = `-D MOTA_HW_ID`, `fw_version` = parsed from
`FIRMWARE_VERSION` — so a node (and `motatool`, reading the firmware's `EndF`) auto-discovers identity
without relying on filenames.
`target_id` = `sha2-256:4(pio_env_name)`, `hw_id` = explicit `-D MOTA_HW_ID` or a role-stripped hardware
family derived from the environment name, and `fw_version` = parsed from `FIRMWARE_VERSION`. A node (and
`motatool`, reading the firmware's `EndF`) therefore auto-discovers identity without relying on filenames.
+22
View File
@@ -22,6 +22,7 @@ from __future__ import annotations
import hashlib
import io
import re
import struct
from dataclasses import dataclass, field
from typing import List, Optional, Tuple
@@ -53,6 +54,10 @@ APPROVAL_YES = b"APRV" # 41 50 52 56 = approved
DEFAULT_BLOCK_SIZE = 1024
# nRF52840 OTAFIX's detools in-place workspace. A firmware image (including EndF) must fit here, and
# the staged .mota must begin above it. Keep in sync with OtaFlashLayout_nrf52.h / the OTAFIX bootloader.
NRF52_INPLACE_MEMORY = 0x00098000
# ---------------------------------------------------------------------------
# Multihash helpers (sha2-256 truncations)
@@ -108,6 +113,23 @@ def target_id_for_env(env_name: str) -> int:
return int.from_bytes(d, "little")
def hardware_id_for_env(env_name: str) -> str:
"""Derive a stable hardware-family tag from a PlatformIO environment name.
Role/profile suffixes are removed so a deliberate role switch on the same physical board remains
possible, while a cross-board install is rejected. Long family names retain a short hash suffix to
avoid collisions inside EndF's fixed 32-byte field.
"""
role = re.search(
r"[_-](?:repeater|repeatr|room_server|room_svr|sensor|terminal_chat|kiss_modem|"
r"companion_radio|companion|comp_radio)(?=[_-]|$)", env_name, re.IGNORECASE)
family = (env_name[:role.start()] if role else env_name).strip("_-") or env_name.strip("_-")
if len(family) <= 32:
return family
suffix = hashlib.sha256(family.encode()).hexdigest()[:8]
return f"{family[:23].rstrip('_-')}-{suffix}"
# ---------------------------------------------------------------------------
# EndF trailer
# ---------------------------------------------------------------------------
+6 -1
View File
@@ -104,6 +104,8 @@ def _firmware_ident():
import re
target_id = ml.target_id_for_env(env["PIOENV"]) # noqa: F821
hw_id = (_cppdef("MOTA_HW_ID") or "").replace("\\", "").strip().strip('"').strip("'")
if not hw_id:
hw_id = ml.hardware_id_for_env(env["PIOENV"]) # noqa: F821
ver_s = (_cppdef("FIRMWARE_VERSION") or "").replace("\\", "").strip().strip('"').strip("'")
if not ver_s: # not a -D -> read the header MeshCore ships
ver_s = _version_from_headers()
@@ -138,9 +140,12 @@ def _append_endf_hex(source, target, env): # Intel-HEX path (nRF52: app f
body = bytes(ih.tobinarray(start=app_start, size=app_end - app_start))
ident = _firmware_ident()
out, h8 = ml.ensure_endf(body, ident)
if len(out) > ml.NRF52_INPLACE_MEMORY:
raise RuntimeError(f"nRF52 OTA image is {len(out)} bytes; in-place limit is "
f"{ml.NRF52_INPLACE_MEMORY} bytes")
if len(out) == len(body):
print(f"EndF: already present in {os.path.basename(path)} (no change)"); return
trailer = out[len(body):] # the EndF trailer (60 bytes with identity)
trailer = out[len(body):] # the EndF trailer (56 bytes with identity)
for i, b in enumerate(trailer):
ih[app_end + i] = b # write it right after the app's last byte
ih.write_hex_file(path)
+12
View File
@@ -40,6 +40,18 @@ def test_target_id_for_env():
assert ml.target_id_for_env("RAK_4631_repeater") != ml.target_id_for_env("RAK_4631_companion_radio_usb")
def test_hardware_id_for_env():
assert ml.hardware_id_for_env("RAK_4631_repeater") == "RAK_4631"
assert ml.hardware_id_for_env("RAK_4631_companion_radio_usb") == "RAK_4631"
assert ml.hardware_id_for_env("ThinkNode_M2_Repeater_bridge_espnow") == "ThinkNode_M2"
assert ml.hardware_id_for_env("wio-e5-repeater_bridge_rs232") == "wio-e5"
long_env = "ikoka_handheld_nrf_e22_30dbm_096_rotated_room_server"
tag = ml.hardware_id_for_env(long_env)
assert len(tag) <= 32 and tag.startswith("ikoka_handheld_nrf_e22")
assert tag == ml.hardware_id_for_env(long_env.replace("room_server", "companion_radio_usb"))
assert tag != ml.hardware_id_for_env("ikoka_handheld_nrf_e22_22dbm_096_rotated_room_server")
# --- EndF ------------------------------------------------------------------
def test_endf_roundtrip_and_idempotent():