mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-09-01 22:48:17 +00:00
Harden LoRa OTA and enable unattended targets
This commit is contained in:
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user