Add MeshTower V2 SD-backed LoRa OTA

This commit is contained in:
mikecarper
2026-08-03 12:49:36 -07:00
parent 9b8c68d926
commit 4ed3307c6c
24 changed files with 833 additions and 37 deletions
+15 -3
View File
@@ -6,6 +6,7 @@ declare -A PIO_ENV_PLATFORM_BY_NAME=()
declare -A PIO_ENV_BOARD_BY_NAME=()
declare -A PIO_ENV_MQTT_BY_NAME=()
declare -A PIO_ENV_OTA_BY_NAME=()
declare -A PIO_ENV_SD_OTA_BY_NAME=()
declare -A PIO_ENV_BUILD_BASE_BY_NAME=()
declare -A PIO_ENV_FULL_BUILD_BY_NAME=()
declare -A PIO_ENV_FULL_WIFI_OTA_BY_NAME=()
@@ -166,7 +167,7 @@ init_project_context() {
fi
if [ ${#SUPPORTED_PIO_ENVS[@]} -eq 0 ]; then
while IFS=$'\t' read -r env_name env_platform env_mqtt env_ota env_full env_full_wifi env_board; do
while IFS=$'\t' read -r env_name env_platform env_mqtt env_ota env_sd_ota env_full env_full_wifi env_board; do
if [ -z "$env_name" ] || [ -z "$env_platform" ]; then
continue
fi
@@ -175,6 +176,7 @@ init_project_context() {
PIO_ENV_BOARD_BY_NAME["$env_name"]=$env_board
PIO_ENV_MQTT_BY_NAME["$env_name"]=$env_mqtt
PIO_ENV_OTA_BY_NAME["$env_name"]=$env_ota
PIO_ENV_SD_OTA_BY_NAME["$env_name"]=$env_sd_ota
PIO_ENV_FULL_BUILD_BY_NAME["$env_name"]=$env_full
PIO_ENV_FULL_WIFI_OTA_BY_NAME["$env_name"]=$env_full_wifi
done < <(
@@ -192,6 +194,7 @@ for section, options in data:
mqtt_enabled = False
ota_enabled = False
ota_disabled = False
sd_ota = False
admin_enabled = False
espnow_enabled = "bridge_espnow" in env_name.lower()
full_wifi_ota = False
@@ -217,6 +220,8 @@ for section, options in data:
admin_enabled = True
if "DISABLE_LORA_OTA" in str(flag):
ota_disabled = True
if "OTA_SD_STORE" in str(flag):
sd_ota = True
match = pattern.search(str(flag))
if match and platform is None:
platform = match.group(0)
@@ -228,6 +233,7 @@ for section, options in data:
print(
f"{env_name}\t{platform}\t{1 if mqtt_enabled else 0}"
f"\t{1 if ota_enabled and not ota_disabled else 0}"
f"\t{1 if sd_ota else 0}"
f"\t{1 if full_enabled else 0}\t{1 if full_wifi_ota else 0}"
f"\t{board_value}"
)
@@ -261,6 +267,7 @@ for section, options in data:
PIO_ENV_BOARD_BY_NAME["$ota_env"]="${PIO_ENV_BOARD_BY_NAME[$env_name]}"
PIO_ENV_MQTT_BY_NAME["$ota_env"]=0
PIO_ENV_OTA_BY_NAME["$ota_env"]=1
PIO_ENV_SD_OTA_BY_NAME["$ota_env"]="${PIO_ENV_SD_OTA_BY_NAME[$env_name]:-0}"
PIO_ENV_FULL_BUILD_BY_NAME["$ota_env"]=0
PIO_ENV_FULL_WIFI_OTA_BY_NAME["$ota_env"]=0
PIO_ENV_BUILD_BASE_BY_NAME["$ota_env"]="$env_name"
@@ -1938,8 +1945,13 @@ apply_lora_ota_override() {
local env_name=$1
if is_lora_ota_build "$env_name"; then
append_platformio_build_unflags "-UENABLE_OTA -DDISABLE_LORA_OTA=1"
export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -UDISABLE_LORA_OTA -DENABLE_OTA=1 -DOTA_FLASH_STORE=1 -DOTA_FOLDER_SERIAL"
if [ "${PIO_ENV_SD_OTA_BY_NAME[$env_name]:-0}" = "1" ]; then
append_platformio_build_unflags "-UENABLE_OTA -DDISABLE_LORA_OTA=1 -DOTA_FLASH_STORE=1"
export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -UDISABLE_LORA_OTA -DENABLE_OTA=1 -UOTA_FLASH_STORE -DOTA_SD_STORE=1 -DOTA_FOLDER_SERIAL"
else
append_platformio_build_unflags "-UENABLE_OTA -DDISABLE_LORA_OTA=1"
export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -UDISABLE_LORA_OTA -DENABLE_OTA=1 -DOTA_FLASH_STORE=1 -DOTA_FOLDER_SERIAL"
fi
else
append_platformio_build_unflags "-DENABLE_OTA=1"
export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -UENABLE_OTA"
+1
View File
@@ -8,6 +8,7 @@ Below are a few quick start guides.
- [CLI Commands](./cli_commands.md)
- [CLI Availability by Firmware Build](./cli_build_matrix.md)
- [Easy LoRa OTA: ESP32 full images and nRF52 deltas](./ota_easy.md)
- [MeshTower V2 microSD self-updates](./ota_meshtower_v2_sdcard.md)
- [GPS Tracking](./gps_tracking.md)
- [Companion Protocol](./companion_protocol.md)
- [Packet Format](./packet_format.md)
+6 -3
View File
@@ -7,9 +7,11 @@ LoRa. Choose the package type for the **destination** node:
| --- | --- | --- | --- |
| ESP32 | Full firmware | New non-merged application `.bin` | ESP32 A/B firmware slots |
| nRF52 | In-place delta | Exact running `firmware.hex` and new `firmware.hex` | Exact-board OTAFIX bootloader |
| MeshTower V2 SD target | Full firmware or in-place delta | New `firmware.hex`; a delta also needs the exact running `firmware.hex` | Matching SD-aware OTAFIX bootloader |
An nRF52 node cannot install an ESP32-style full-image container. It deliberately accepts only an in-place
delta built against its exact running firmware.
A normal nRF52 target cannot install a full-image container. It deliberately accepts only an in-place
delta built against its exact running firmware. The MeshTower V2 microSD target is the exception because
it stages the complete container off-chip; see [MeshTower V2 microSD LoRa OTA](ota_meshtower_v2_sdcard.md).
## Temporary OTA channel used in this guide
@@ -298,7 +300,8 @@ ota status
If it is the source or destination, install a supported `-ota-` build over WiFi or USB first. An intermediate
repeater does not need the OTA CLI and can relay opaquely while its matching `tempradio` window is active.
- **The update is marked `[other hw]`:** it is for a different board or firmware role. Do not install it.
- **An nRF52 node does not list a full update:** this is intentional. nRF52 accepts only an in-place delta.
- **An nRF52 node does not list a full update:** this is intentional for internal-flash targets. The
MeshTower V2 microSD target accepts full images with its matching SD-aware bootloader.
- **nRF52 reports no bootloader apply support:** install the exact-board in-place-delta OTAFIX bootloader
before trying LoRa OTA.
- **nRF52 reports a base mismatch:** the file passed to `--base` is not the exact application running on
+71
View File
@@ -0,0 +1,71 @@
# MeshTower V2 microSD LoRa OTA
The `Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors` target uses the MeshTower V2 onboard
microSD socket as persistent storage for its own LoRa OTA downloads. It accepts
both full `.mota` images and in-place delta `.mota` images. After verification,
the matching SD-aware OTAFIX bootloader reads the staged file from the card and
programs the nRF52840 application region.
The pin assignment follows the
[Heltec MeshTower V2 partial reference circuit](https://resource.heltec.cn/download/MeshTower-V2/schematic/MeshTower_V2_Partial_Reference_Circuit.pdf):
| Signal | nRF52840 pin | Arduino pin number |
|---|---:|---:|
| SD CS | P1.00 | 32 |
| SD MOSI | P1.01 | 33 |
| SD SCK | P0.06 | 6 |
| SD MISO | P0.26 | 26 |
The SD socket uses its own SPI peripheral, so card traffic does not change the
LoRa radio pinout.
## Card requirements
Use a FAT16, FAT32, or exFAT card with an MBR partition table whose first
partition starts after sector 1. This is the normal layout produced by most SD
formatters. GPT and unpartitioned "super-floppy" layouts are rejected.
MeshCore creates `/meshcore-ota.mota` as a contiguous file. Sector 1, which is
outside the partition, holds a checksummed bootloader handoff record. The
firmware validates this gap before writing it; an incompatible card layout
fails safely without modifying sector 1.
## Capacity and update types
The card removes the internal-flash staging limit. The `.mota` container may be
much larger than the old internal staging gap, and either a full image or an
in-place delta may be downloaded. The installed firmware itself must still fit
the nRF52840 application region below InternalFS (ending at `0xED000`); SD
storage does not increase the MCU's executable flash.
For this S140 v6 target, the maximum application image including its `EndF`
trailer is `0xC7000` bytes (815,104 bytes). To package a full self-update:
```bash
motatool build --fw ./Heltec_tower_v2_sdcard-new.hex --out-dir ./motas
motatool verify ./motas/*.mota
```
A delta uses the exact installed image as its base. The normal `0x98000`
workspace remains compatible. If either image is larger than that legacy
limit, build the patch with the SD target's larger workspace:
```bash
motatool build \
--base ./Heltec_tower_v2_sdcard-running.hex \
--fw ./Heltec_tower_v2_sdcard-new.hex \
--patch-type in-place \
--inplace-memory 0xC7000 \
--out-dir ./motas
motatool verify ./motas/*.mota
```
The download is resumable because the partial `.mota` stays on the card.
Once it reaches `ready`, `ota install` performs the final verification,
publishes the bootloader handoff, and reboots. Keep the card inserted through
the reboot and installation.
The SD-aware bootloader is mandatory. `ota install` refuses to reboot if the
bootloader capability marker does not advertise SD staging and the selected
codec. Existing `Heltec_tower_v2_repeater` firmware continues to use the
internal-flash delta path and is unchanged.
+10 -5
View File
@@ -254,7 +254,7 @@ cover `approval` or `leaves[]`:
| `codec_id` | Meaning | Used by |
|---|---|---|
| 0 | full / raw | PAYLOAD = reconstructed image (`BODY||EndF`). ESP32 A/B only. |
| 0 | full / raw | PAYLOAD = reconstructed image (`BODY||EndF`). ESP32 A/B or the SD-backed MeshTower V2 target. |
| 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. |
@@ -263,10 +263,11 @@ 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`), 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.
(`set_apply_codec`/`set_apply_codec2`): ESP32 accepts `full` + `sequential` (+ `in-place`). Normal nRF52
targets accept only `in-place` because internal flash cannot stage a full application image. The
MeshTower V2 SD target accepts `full` + `in-place` because the card holds the container. 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
@@ -670,6 +671,10 @@ ota dev ... bring-up helpers (stage/recv/serve/verify)
2. re-checks `TRAILER`, `image_hash`, `approval == "APRV"`, and that the delta's `base_hash` equals the
running firmware's `EndF.body_hash` (recomputed by scanning for `EndF` - never trust `bank_0_size`),
3. applies the in-place codec over the app region and boots only if the result hashes to `image_hash`.
- **MeshTower V2 SD nRF52:** the application stores a contiguous `/meshcore-ota.mota` on microSD and
publishes its raw sector range in a checksummed handoff record outside the MBR partition. The matching
bootloader reads the card without mounting FAT, supports either a full image or an in-place delta,
verifies the staged/full result hash, and never writes through `0xED000` where InternalFS begins.
The signature proves author authenticity; `approval` proves local owner consent - both required to apply.
+84
View File
@@ -2,6 +2,7 @@
#include "OtaFormat.h"
#include "MotaContainer.h"
#include "Identity.h"
#include "OtaByteIO.h"
#include <string.h>
#if defined(ESP32_PLATFORM)
@@ -25,6 +26,9 @@
#include "nrf.h"
#include "nrf_soc.h"
#include "nrf_sdm.h"
#if defined(OTA_SD_STORE)
#include "OtaStoreSdNrf52.h"
#endif
#endif
namespace mesh {
@@ -494,6 +498,86 @@ bool ota_apply_mota_nrf52(const uint8_t* buf, uint32_t len, const SignerAllowlis
return true;
}
#if defined(OTA_SD_STORE)
bool ota_apply_mota_nrf52(OtaStoreSdNrf52& store, const SignerAllowlist& allow,
ApplyState& st, char* msg) {
st = ApplyState();
uint8_t hdr[8], manifest[MOTA_MFL];
uint32_t total = store.staged_size();
if (total < 8 + MOTA_MFL + 5 || !store.read(0, hdr, sizeof(hdr)) ||
memcmp(hdr, MOTA_MAGIC, 4) != 0 || rd_u32le(hdr + 4) != total ||
!store.read(8, manifest, sizeof(manifest))) {
strcpy(msg, "SD container parse failed");
return false;
}
MotaManifest m;
if (!mota_parse_manifest(manifest, sizeof(manifest), m)) {
strcpy(msg, "SD manifest parse failed");
return false;
}
const bool full = m.is_full() && m.codec_id == CODEC_FULL &&
m.payload_size == m.image_size;
const bool delta = !m.is_full() && m.codec_id == CODEC_DETOOLS_INPLACE;
if (!full && !delta) {
strcpy(msg, "nRF52 SD bootloader accepts full or in-place delta only");
return false;
}
const uint32_t app_base = mota_nrf52_app_base();
if (m.image_size == 0 || app_base >= MOTA_NRF52_APP_END ||
m.image_size > MOTA_NRF52_APP_END - app_base) {
strcpy(msg, "image exceeds nRF52 application region");
return false;
}
st.image_size = m.image_size;
memcpy(st.image_hash, m.image_hash, sizeof(st.image_hash));
st.manifest_ok = true;
OtaBlCaps bl = ota_bootloader_caps();
if (!bl.present || !(bl.storage_flags & OTA_BL_STORAGE_SD)) {
strcpy(msg, "this bootloader has no SD OTA support - update the bootloader first");
return false;
}
if (bl.apply_abi < m.format_ver || !(bl.codec_mask & (1u << m.codec_id))) {
snprintf(msg, 159,
"bootloader cannot apply this SD update (abi=%u codecs=0x%x; need fmt=%u codec=%u)",
bl.apply_abi, bl.codec_mask, m.format_ver, m.codec_id);
return false;
}
VerifyResult vr = ota_verify(static_cast<const OtaStore&>(store), allow);
st.sig_ok = vr.sig_ok;
st.trusted = vr.trusted;
if (!vr.root_ok || !vr.payload_ok || !vr.image_ok) {
strcpy(msg, "payload hash mismatch (incomplete or corrupt SD .mota)");
return false;
}
if (delta) {
SelfFwInfo fi;
if (!ota_self_firmware(fi) || !fi.valid) {
strcpy(msg, "cannot read running firmware (no EndF)");
return false;
}
if (!m.base_hash || memcmp(m.base_hash, fi.body_hash, 8) != 0) {
strcpy(msg, "not built for the running firmware (base mismatch)");
return false;
}
}
st.slot_ok = true;
if (vr.is_signed) {
if (!vr.sig_ok) { strcpy(msg, "bad signature"); return false; }
if (!vr.trusted) { strcpy(msg, "untrusted signer (pubkey not in allowlist)"); return false; }
}
if (!store.approve_for_bootloader()) {
snprintf(msg, 159, "SD handoff failed: %s", store.last_error());
return false;
}
sprintf(msg, "verified%s %s image on SD; rebooting into bootloader once this reply is sent",
vr.is_signed ? " (signer trusted)" : " (unsigned)", full ? "full" : "delta");
return true;
}
#endif
#else // native / other platforms
bool ota_apply_slot_info(uint32_t*, uint32_t*) { return false; }
+5
View File
@@ -50,6 +50,11 @@ bool ota_apply_detools_mota(const uint8_t* buf, uint32_t len,
// Returns true (msg = "verified...") when approved, false (msg = the first failing gate) otherwise.
bool ota_apply_mota_nrf52(const uint8_t* buf, uint32_t len,
const SignerAllowlist& allow, ApplyState& st, char* msg);
#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
class OtaStoreSdNrf52;
bool ota_apply_mota_nrf52(OtaStoreSdNrf52& store,
const SignerAllowlist& allow, ApplyState& st, char* msg);
#endif
// Commit the (already approved/armed) update and reboot into it - does NOT return. Call this only after
// a successful ota_apply_* AND after the confirmation reply has been delivered, so the operator knows
+5 -1
View File
@@ -18,15 +18,18 @@
namespace mesh {
namespace ota {
// 16-byte marker: magic[8] "MOTABLDR" + apply_abi(2) + codec_mask(2) + reserved(4).
// 16-byte marker: magic[8] "MOTABLDR" + apply_abi(2) + codec_mask(2) + storage flags/reserved(4).
static const uint8_t OTA_BL_MAGIC[8] = { 'M','O','T','A','B','L','D','R' };
struct OtaBlCaps {
bool present = false;
uint16_t apply_abi = 0; // max .mota format_ver the bootloader can apply
uint16_t codec_mask = 0; // bit i set => can apply codec_id i (in-place delta = bit 2)
uint8_t storage_flags = 0; // bit 0 => raw-SD handoff/apply is supported
};
static const uint8_t OTA_BL_STORAGE_SD = 0x01;
// Scan the bootloader flash region for the marker. Returns {present=false} if not found / non-nRF52.
inline OtaBlCaps ota_bootloader_caps() {
OtaBlCaps c;
@@ -38,6 +41,7 @@ inline OtaBlCaps ota_bootloader_caps() {
c.present = true;
c.apply_abi = (uint16_t)(p[8] | ((uint16_t)p[9] << 8));
c.codec_mask = (uint16_t)(p[10] | ((uint16_t)p[11] << 8));
c.storage_flags = p[12];
break;
}
#endif
+32 -5
View File
@@ -136,9 +136,16 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
(unsigned)c.allow.count(), (unsigned)c.manager.target(), tenv ? tenv : "?");
#if defined(NRF52_PLATFORM)
// nRF52 applies via the bootloader - show (cached) whether it can, so `ota get`/`install` won't surprise.
// blrc = the bootloader's last in-place-apply code (diagnostic; 0xB8=success, see ota_delta.c).
// blrc = the bootloader's last apply code (diagnostic; 0xB8=success, see ota_delta.c).
const OtaBlCaps& bl = c.bootloaderCaps();
#if defined(OTA_SD_STORE)
const char* bl_state = !bl.present ? "NONE" :
(bl.storage_flags & OTA_BL_STORAGE_SD) ? "SD" : "NO-SD";
#else
const char* bl_state = bl.present ? "apply" : "NONE";
#endif
if (n < 146) n += snprintf(reply + n, 160 - n, " | bl:%s blrc:%02X",
c.bootloaderCaps().present ? "apply" : "NONE", ota_bootloader_last_rc());
bl_state, ota_bootloader_last_rc());
#endif
// ---- admin OTA stats: crypto identities (our fw's content-id + body_hash), serving set, live fetch,
@@ -261,7 +268,7 @@ 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)
#if defined(NRF52_PLATFORM) && !defined(OTA_SD_STORE)
c.manager.set_accept_full(false); // nRF52 flash can install only in-place deltas
#endif
} else if (strncmp(dst, "folder", 6) == 0) {
@@ -284,7 +291,7 @@ 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)
#if defined(NRF52_PLATFORM) && !defined(OTA_SD_STORE)
c.manager.set_accept_full(false);
#endif
c.fetch_store.clear(); c.serving = false; c.serve_expected = 0; c.session_started_ms = 0;
@@ -304,10 +311,17 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
char hx[17]; mesh::Utils::toHex(hx, fi.body_hash, 8);
int n = snprintf(reply, 160, "self body=%u image=%u base_hash=%s", (unsigned)fi.body_len, (unsigned)fi.image_len, hx);
#if defined(NRF52_PLATFORM)
// nRF52 applies via the bootloader, so surface whether THIS device's bootloader can (delta install gate)
// nRF52 applies via the bootloader, so surface whether THIS device's bootloader can install this store.
const OtaBlCaps& bl = c.bootloaderCaps(); // cached (flash scanned once)
#if defined(OTA_SD_STORE)
if (bl.present && (bl.storage_flags & OTA_BL_STORAGE_SD))
snprintf(reply + n, 160 - n, " | bootloader: SD apply OK (abi=%u codecs=0x%x)", bl.apply_abi, bl.codec_mask);
else
snprintf(reply + n, 160 - n, " | bootloader: NO SD mota-apply support (install will refuse)");
#else
if (bl.present) snprintf(reply + n, 160 - n, " | bootloader: apply OK (abi=%u codecs=0x%x)", bl.apply_abi, bl.codec_mask);
else snprintf(reply + n, 160 - n, " | bootloader: NO mota-apply support (delta install will refuse)");
#endif
#endif
} else if (is_cmd(a, "install|apply|applydelta", &rest)) {
@@ -470,9 +484,22 @@ static bool handle_dev(const char* d, char* reply, OtaContext& c) {
strcpy(reply, "OK announced");
} else if (strncmp(d, "verify", 6) == 0) {
#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
if (c.manager.fetchState() == OtaManager::COMPLETE) {
VerifyResult r = ota_verify(static_cast<const OtaStore&>(c.fetch_store), c.allow);
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());
return true;
}
#endif
const uint8_t* buf; uint32_t len;
#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
buf = c.serve_buf; len = c.serve_buf ? c.serve_expected : 0;
#else
if (c.manager.fetchState() == OtaManager::COMPLETE) { buf = c.fetch_store.data(); len = c.fetch_store.staged_size(); }
else { buf = c.serve_buf; len = c.serve_buf ? c.serve_expected : 0; }
#endif
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 payload=%d img=%d signed=%d sig=%d trust=%d | ok=%d auto=%d",
+14 -5
View File
@@ -10,7 +10,9 @@
#include "OtaFormat.h"
#include "OtaSelf.h" // ota_self_firmware() - prefer self-describing EndF identity at begin()
#include "OtaBlInfo.h" // bootloader OTA-apply capability marker (nRF52); cached after first read
#if defined(NRF52_PLATFORM) && defined(OTA_FLASH_STORE)
#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
#include "OtaStoreSdNrf52.h"
#elif defined(NRF52_PLATFORM) && defined(OTA_FLASH_STORE)
#include "OtaStoreFlashNrf52.h"
#elif defined(ESP32_PLATFORM) && defined(OTA_FLASH_STORE)
#include "OtaStoreFlashEsp32.h"
@@ -43,7 +45,7 @@ class FolderMotaStore; // pull destination over the seeder link (full type onl
#ifndef OTA_SERVE_BUF_SIZE
// 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)
#if defined(NRF52_PLATFORM) && (defined(OTA_FLASH_STORE) || defined(OTA_SD_STORE))
#define OTA_SERVE_BUF_SIZE 4096
#else
#define OTA_SERVE_BUF_SIZE 16384
@@ -55,7 +57,9 @@ class FolderMotaStore; // pull destination over the seeder link (full type onl
struct OtaContext {
OtaManager manager;
#if defined(NRF52_PLATFORM) && defined(OTA_FLASH_STORE)
#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
OtaStoreSdNrf52 fetch_store; // MeshTower V2: persistent SD staging, full + delta
#elif defined(NRF52_PLATFORM) && defined(OTA_FLASH_STORE)
OtaStoreFlashNrf52 fetch_store; // persistent flash staging (survives reboot; large deltas)
#elif defined(ESP32_PLATFORM) && defined(OTA_FLASH_STORE)
OtaStoreFlashEsp32 fetch_store; // stages in the inactive A/B slot (delta + full, RX-safe)
@@ -141,7 +145,9 @@ struct OtaContext {
}
}
bool ok;
#if defined(NRF52_PLATFORM)
#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
ok = ota_apply_mota_nrf52(fetch_store, allow, apply_st, msg);
#elif defined(NRF52_PLATFORM)
ok = ota_apply_mota_nrf52(fetch_store.data(), fetch_store.staged_size(), allow, apply_st, msg);
#elif defined(ESP32_PLATFORM) && defined(OTA_FLASH_STORE)
ok = ota_apply_detools_mota(fetch_store, allow, apply_st, msg);
@@ -245,7 +251,10 @@ struct OtaContext {
manager.begin(target_id, send, ctx);
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)
#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
manager.set_accept_full(true);
manager.set_apply_codec(CODEC_DETOOLS_INPLACE);
#elif 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)
+4
View File
@@ -28,6 +28,10 @@ inline uint32_t mota_nrf52_app_base() { return MOTA_NRF52_APP_BASE_S140_V6; }
// 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)
// End of the normal nRF52840 application region. The SD-backed MeshTower V2
// target can use this whole range because its staged container is off-chip.
// InternalFS begins here and must never be erased by the bootloader.
static const uint32_t MOTA_NRF52_APP_END = 0x000ED000u;
static const uint32_t MOTA_NRF52_FLASH_PAGE = 4096u;
static const uint8_t GPREGRET_OTA_APPLY = 0x6Au; // distinct from DFU magics 0x57/0x4E/0xA8
+64
View File
@@ -0,0 +1,64 @@
#pragma once
#include <stdint.h>
#include <stddef.h>
// On-card handoff shared with Adafruit_nRF52_Bootloader_OTAFIX. The staged
// .mota is a normal, contiguous filesystem file. Sector 1 is in the unused
// gap between the MBR and the first partition and tells the bootloader where
// that file's sectors live. Cards without such a gap are rejected.
namespace mesh {
namespace ota {
static const uint32_t MOTA_SD_SECTOR_SIZE = 512u;
static const uint32_t MOTA_SD_HANDOFF_SECTOR = 1u;
static const uint32_t MOTA_SD_HANDOFF_VERSION = 1u;
static const uint32_t MOTA_SD_HANDOFF_LEN = 36u;
static const uint8_t MOTA_SD_HANDOFF_MAGIC[8] = {
'M', 'O', 'T', 'A', 'S', 'D', '0', '1'
};
inline uint32_t mota_sd_rd32(const uint8_t* p) {
return (uint32_t)p[0] | ((uint32_t)p[1] << 8) |
((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24);
}
inline void mota_sd_wr32(uint8_t* p, uint32_t v) {
p[0] = (uint8_t)v;
p[1] = (uint8_t)(v >> 8);
p[2] = (uint8_t)(v >> 16);
p[3] = (uint8_t)(v >> 24);
}
inline uint32_t mota_sd_crc32(const uint8_t* data, size_t len) {
uint32_t crc = 0xFFFFFFFFu;
for (size_t i = 0; i < len; i++) {
crc ^= data[i];
for (uint8_t bit = 0; bit < 8; bit++) {
crc = (crc >> 1) ^ (0xEDB88320u & (uint32_t)-(int32_t)(crc & 1u));
}
}
return ~crc;
}
inline void mota_sd_encode_handoff(uint8_t sector[MOTA_SD_SECTOR_SIZE],
uint32_t first_sector,
uint32_t sector_count,
uint32_t total_size,
uint32_t card_sectors) {
// Only own the record bytes. The caller preserves the rest of sector 1 in
// case a card formatter placed non-partition metadata there.
for (uint32_t i = 0; i < MOTA_SD_HANDOFF_LEN; i++) sector[i] = 0xFF;
for (uint8_t i = 0; i < 8; i++) sector[i] = MOTA_SD_HANDOFF_MAGIC[i];
mota_sd_wr32(sector + 8, MOTA_SD_HANDOFF_VERSION);
mota_sd_wr32(sector + 12, first_sector);
mota_sd_wr32(sector + 16, sector_count);
mota_sd_wr32(sector + 20, total_size);
mota_sd_wr32(sector + 24, ~total_size);
mota_sd_wr32(sector + 28, card_sectors);
mota_sd_wr32(sector + 32, mota_sd_crc32(sector, 32));
}
} // namespace ota
} // namespace mesh
+10 -1
View File
@@ -70,7 +70,12 @@ bool ota_self_firmware(SelfFwInfo& out) {
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;
uint32_t region_len =
#if defined(OTA_SD_STORE)
MOTA_NRF52_APP_END - app_base;
#else
MOTA_NRF52_FS_START - app_base;
#endif
return find_self_firmware(region, region_len, out, /*verify_body=*/true);
}
#else
@@ -89,7 +94,11 @@ 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) {
const uint32_t app_base = mota_nrf52_app_base();
#if defined(OTA_SD_STORE)
if (app_base >= MOTA_NRF52_APP_END || (uint64_t)app_base + off + len > MOTA_NRF52_APP_END) return false;
#else
if (!mota_nrf52_layout_valid(app_base) || (uint64_t)app_base + off + len > MOTA_NRF52_FS_START) return false;
#endif
memcpy(buf, (const uint8_t*)(uintptr_t)(app_base + off), len);
return true;
}
+271
View File
@@ -0,0 +1,271 @@
#include "OtaStoreSdNrf52.h"
#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
#include <Arduino.h>
#include <SdFat.h>
#include <SPI.h>
#include <new>
#include <string.h>
#include "OtaByteIO.h"
#include "OtaFlashLayout_nrf52.h"
#include "OtaSdHandoff.h"
#ifndef OTA_SD_CS_PIN
#define OTA_SD_CS_PIN PIN_SPI1_NSS
#endif
#ifndef OTA_SD_SCK_MHZ
#define OTA_SD_SCK_MHZ 8
#endif
namespace mesh {
namespace ota {
const char* const OtaStoreSdNrf52::PATH = "/meshcore-ota.mota";
OtaStoreSdNrf52::OtaStoreSdNrf52()
: _sd(new (std::nothrow) SdFs()), _file(new (std::nothrow) FsFile()) {}
OtaStoreSdNrf52::~OtaStoreSdNrf52() {
if (_file) { _file->close(); delete _file; }
if (_sd) { _sd->end(); delete _sd; }
}
void OtaStoreSdNrf52::fail(const char* message) {
strncpy(_error, message ? message : "SD error", sizeof(_error) - 1);
_error[sizeof(_error) - 1] = 0;
}
bool OtaStoreSdNrf52::mount() {
if (_mounted) return true;
_error[0] = 0;
if (!_sd || !_file || !_sd->begin(SdSpiConfig(OTA_SD_CS_PIN, DEDICATED_SPI,
SD_SCK_MHZ(OTA_SD_SCK_MHZ), &SPI1))) {
fail("SD mount failed");
return false;
}
_mounted = true;
if (!inspect_mbr()) {
_sd->end();
_mounted = false;
return false;
}
return true;
}
bool OtaStoreSdNrf52::inspect_mbr() {
uint8_t sector[MOTA_SD_SECTOR_SIZE];
if (!_sd->card() || !_sd->card()->readSector(0, sector)) {
fail("SD MBR read failed");
return false;
}
if (sector[510] != 0x55 || sector[511] != 0xAA) {
fail("SD must use an MBR partition table");
return false;
}
// SdFs mounts the first usable MBR partition. Require its start to leave
// sector 1 unused; a protective GPT entry (type 0xEE) is not safe here.
for (uint8_t i = 0; i < 4; i++) {
const uint8_t* p = sector + 446 + (uint32_t)i * 16;
uint8_t type = p[4];
uint32_t start = mota_sd_rd32(p + 8);
uint32_t count = mota_sd_rd32(p + 12);
if (type == 0 || count == 0) continue;
if (type == 0xEE || start <= MOTA_SD_HANDOFF_SECTOR ||
start >= _sd->card()->sectorCount() ||
count > _sd->card()->sectorCount() - start) {
fail("SD needs an MBR partition starting after sector 1");
return false;
}
_partition_start = start;
_partition_end = start + count;
return true;
}
fail("SD has no usable MBR partition");
return false;
}
bool OtaStoreSdNrf52::invalidate_handoff() {
if (!_mounted || !_sd->card()) return false;
uint8_t sector[MOTA_SD_SECTOR_SIZE];
if (!_sd->card()->readSector(MOTA_SD_HANDOFF_SECTOR, sector)) return false;
memset(sector, 0xFF, MOTA_SD_HANDOFF_LEN);
return _sd->card()->writeSector(MOTA_SD_HANDOFF_SECTOR, sector) &&
_sd->card()->syncDevice();
}
bool OtaStoreSdNrf52::locate_file() {
Sector_t first = 0, last = 0;
if (!_file || !_file->contiguousRange(&first, &last) || last < first) {
fail("OTA file is not contiguous");
return false;
}
uint64_t need = ((uint64_t)_total + MOTA_SD_SECTOR_SIZE - 1) / MOTA_SD_SECTOR_SIZE;
uint64_t available = (uint64_t)last - first + 1;
if (need == 0 || need > available || first < _partition_start ||
(uint64_t)first + need > _partition_end) {
fail("OTA file sector range is invalid");
return false;
}
_first_sector = (uint32_t)first;
_allocated_sectors = (uint32_t)need;
return true;
}
bool OtaStoreSdNrf52::plan_layout(bool, uint32_t image_size,
uint32_t, uint32_t payload_size) {
const uint32_t app_base = mota_nrf52_app_base();
if (image_size == 0 || payload_size == 0 || app_base >= MOTA_NRF52_APP_END ||
image_size > MOTA_NRF52_APP_END - app_base) {
fail("image exceeds nRF52 application region");
return false;
}
return true;
}
bool OtaStoreSdNrf52::begin(uint32_t total_size) {
_total = 0;
_first_sector = 0;
_allocated_sectors = 0;
if (total_size < 13 || !mount()) return false;
if (!invalidate_handoff()) {
fail("SD handoff clear failed");
return false;
}
if (*_file) _file->close();
_sd->remove(PATH);
if (!_file->open(PATH, O_RDWR | O_CREAT | O_EXCL)) {
fail("SD OTA file create failed");
return false;
}
if (!_file->preAllocate(total_size)) {
fail("SD lacks contiguous space for update");
_file->close();
_sd->remove(PATH);
return false;
}
_total = total_size;
if (!locate_file()) {
_total = 0;
_file->close();
_sd->remove(PATH);
return false;
}
return true;
}
bool OtaStoreSdNrf52::set_meta_size(uint32_t meta_bytes) {
if (!_total || meta_bytes > _total - 5) return false;
uint8_t erased[512];
memset(erased, 0xFF, sizeof(erased));
for (uint32_t off = 0; off < meta_bytes; ) {
uint32_t n = meta_bytes - off;
if (n > sizeof(erased)) n = sizeof(erased);
if (!write(off, erased, n)) return false;
off += n;
}
return true;
}
bool OtaStoreSdNrf52::write(uint32_t offset, const uint8_t* data, uint32_t len) {
if (!_file || !*_file || (uint64_t)offset + len > _total || !_file->seekSet(offset) ||
_file->write(data, len) != len) {
fail("SD OTA write failed");
return false;
}
return true;
}
bool OtaStoreSdNrf52::read(uint32_t offset, uint8_t* buf, uint32_t len) const {
if (!_file || !*_file || (uint64_t)offset + len > _total || !_file->seekSet(offset) ||
_file->read(buf, len) != (int)len) {
return false;
}
return true;
}
uint32_t OtaStoreSdNrf52::capacity() const {
if (!_mounted || !_sd || !_sd->card()) return 0;
uint64_t bytes = (uint64_t)_sd->card()->sectorCount() * MOTA_SD_SECTOR_SIZE;
return bytes > UINT32_MAX ? UINT32_MAX : (uint32_t)bytes;
}
bool OtaStoreSdNrf52::finalize() {
if (!_file || !*_file || !_file->sync() || !_sd->card()->syncDevice()) {
fail("SD OTA sync failed");
return false;
}
return locate_file();
}
void OtaStoreSdNrf52::checkpoint() {
if (_file && *_file) {
_file->sync();
if (_sd->card()) _sd->card()->syncDevice();
}
}
bool OtaStoreSdNrf52::reopen() {
_total = 0;
if (!mount()) return false;
if (*_file) _file->close();
if (!_file->open(PATH, O_RDWR)) return false;
uint8_t hdr[8];
if (_file->fileSize() < 13 || !_file->seekSet(0) ||
_file->read(hdr, sizeof(hdr)) != (int)sizeof(hdr) ||
memcmp(hdr, MOTA_MAGIC, 4) != 0) {
_file->close();
return false;
}
uint32_t total = rd_u32le(hdr + 4);
if (total < 13 || total != _file->fileSize()) {
_file->close();
return false;
}
_total = total;
if (!locate_file()) {
_total = 0;
_file->close();
return false;
}
return true;
}
void OtaStoreSdNrf52::clear() {
_total = 0;
_first_sector = 0;
_allocated_sectors = 0;
if (!mount()) return;
invalidate_handoff();
if (_file && *_file) _file->close();
_sd->remove(PATH);
}
bool OtaStoreSdNrf52::approve_for_bootloader() {
if (!_total || !finalize()) return false;
if (!write(8 + MOTA_OFF_APPROVAL, APPROVAL_YES, sizeof(APPROVAL_YES)) ||
!_file->sync() || !_sd->card()->syncDevice()) {
fail("SD approval write failed");
return false;
}
uint8_t sector[MOTA_SD_SECTOR_SIZE];
if (!_sd->card()->readSector(MOTA_SD_HANDOFF_SECTOR, sector)) {
fail("SD handoff sector read failed");
return false;
}
mota_sd_encode_handoff(sector, _first_sector, _allocated_sectors,
_total, _sd->card()->sectorCount());
if (!_sd->card()->writeSector(MOTA_SD_HANDOFF_SECTOR, sector) ||
!_sd->card()->syncDevice()) {
fail("SD bootloader handoff write failed");
return false;
}
return true;
}
} // namespace ota
} // namespace mesh
#endif
+61
View File
@@ -0,0 +1,61 @@
#pragma once
#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
#include "OtaStore.h"
class SdFs;
class FsFile;
namespace mesh {
namespace ota {
// Persistent nRF52840 OTA store backed by the MeshTower V2 microSD socket.
// The .mota remains a normal file, but is preallocated contiguously so the
// bootloader can read it by raw sectors without embedding a FAT implementation.
class OtaStoreSdNrf52 : public OtaStore {
public:
OtaStoreSdNrf52();
~OtaStoreSdNrf52() override;
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;
uint32_t staged_size() const override { return _total; }
void clear() override;
bool set_meta_size(uint32_t meta_bytes) override;
bool finalize() override;
void checkpoint() override;
bool reopen() override;
bool plan_layout(bool is_full, uint32_t image_size,
uint32_t payload_off, uint32_t payload_size) override;
// Called only after the app has verified payload, base, signature and trust.
// Writes APRV into the file, then publishes the raw-sector handoff record.
bool approve_for_bootloader();
const char* last_error() const { return _error; }
private:
static const char* const PATH;
bool mount();
bool inspect_mbr();
bool locate_file();
bool invalidate_handoff();
void fail(const char* message);
SdFs* _sd = nullptr;
mutable FsFile* _file = nullptr;
bool _mounted = false;
uint32_t _total = 0;
uint32_t _first_sector = 0;
uint32_t _allocated_sectors = 0;
uint32_t _partition_start = 0;
uint32_t _partition_end = 0;
char _error[80] = {0};
};
} // namespace ota
} // namespace mesh
#endif
+9 -11
View File
@@ -2,7 +2,7 @@
#include <stdint.h>
// AUTO-GENERATED by tools/mota/gen_targets.py - do not edit by hand.
// 542 OTA-capable PlatformIO envs. Maps target_id (= sha2-256:4 of the env name, LE uint32)
// 540 OTA-capable PlatformIO envs. Maps target_id (= sha2-256:4 of the env name, LE uint32)
// to the human-readable env name, so a node/tool can name a target seen over the air WITHOUT
// transmitting the string in the .mota / LoRa protocol. Regenerate when the OTA env set changes.
@@ -105,6 +105,9 @@ inline const char* ota_target_env_name(uint32_t target_id) {
{ 0x57172fcc, "Heltec_T190_room_server_observer_mqtt" },
{ 0xa529b4f5, "Heltec_t1_repeater_lora_ota_no_external_sensors" },
{ 0x85a8c944, "Heltec_tower_v2_repeater" },
{ 0x0a9dbbf0, "Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors" },
{ 0xb519a89f, "heltec_tracker_v1_1_repeater_observer_mqtt" },
{ 0xa3e8d8ce, "heltec_tracker_v1_1_room_server_observer_mqtt" },
{ 0xb16009a0, "heltec_tracker_v2_companion_radio_ble_femoff" },
{ 0xdc780e80, "heltec_tracker_v2_companion_radio_ble_femon" },
{ 0x4aa63180, "heltec_tracker_v2_companion_radio_usb_femoff" },
@@ -114,7 +117,9 @@ inline const char* ota_target_env_name(uint32_t target_id) {
{ 0xb62a1e75, "heltec_tracker_v2_kiss_modem" },
{ 0x9e64845d, "heltec_tracker_v2_repeater" },
{ 0x19232e8b, "heltec_tracker_v2_repeater_bridge_espnow" },
{ 0xfaf164f4, "heltec_tracker_v2_repeater_observer_mqtt" },
{ 0x38b948b4, "heltec_tracker_v2_room_server" },
{ 0x462ddb9b, "heltec_tracker_v2_room_server_observer_mqtt" },
{ 0xed21ca4f, "heltec_tracker_v2_sensor" },
{ 0x9f5f39a7, "heltec_tracker_v2_terminal_chat" },
{ 0x081d2219, "Heltec_v2_companion_radio_ble" },
@@ -135,6 +140,7 @@ inline const char* ota_target_env_name(uint32_t target_id) {
{ 0x644bb68d, "Heltec_v3_repeater_bridge_espnow" },
{ 0xd19a9759, "Heltec_v3_repeater_bridge_rs232" },
{ 0xde40f56f, "Heltec_v3_repeater_observer_mqtt" },
{ 0x6dc15ab3, "Heltec_v3_repeater_observer_mqtt_sim" },
{ 0xc59d294e, "Heltec_v3_room_server" },
{ 0xceeddfaf, "Heltec_v3_room_server_observer_mqtt" },
{ 0x21519537, "Heltec_v3_sensor" },
@@ -311,9 +317,9 @@ inline const char* ota_target_env_name(uint32_t target_id) {
{ 0xf7c5d584, "LilyGo_TLora_V2_1_1_6_companion_radio_wifi" },
{ 0x81960cc0, "LilyGo_TLora_V2_1_1_6_kiss_modem" },
{ 0x504020ea, "LilyGo_TLora_V2_1_1_6_repeater" },
{ 0x9c652703, "LilyGo_TLora_V2_1_1_6_repeater_observer_mqtt" },
{ 0x9d8b60ae, "LilyGo_TLora_V2_1_1_6_repeater_observer_mqtt_" },
{ 0xa2ce002f, "LilyGo_TLora_V2_1_1_6_room_server" },
{ 0xdb780283, "LilyGo_TLora_V2_1_1_6_room_server_observer_mqtt" },
{ 0xba4f47f3, "LilyGo_TLora_V2_1_1_6_room_server_observer_mqtt_" },
{ 0x6f16479e, "LilyGo_TLora_V2_1_1_6_terminal_chat" },
{ 0x1018e5d1, "M5Stack_Unit_C6L_companion_radio_ble" },
{ 0xc0ab5040, "M5Stack_Unit_C6L_companion_radio_usb" },
@@ -387,7 +393,6 @@ inline const char* ota_target_env_name(uint32_t target_id) {
{ 0x76f3c984, "RAK_4631_repeater_bridge_rs232_serial1_lora_ota_no_external_sensors" },
{ 0x41e33a97, "RAK_4631_repeater_bridge_rs232_serial2_lora_ota_no_external_sensors" },
{ 0x29a0da19, "RAK_4631_repeater_lora_ota_no_external_sensors" },
{ 0x8a6dc63b, "RAK_4631_room_server_lora_ota_no_external_sensors" },
{ 0xf1d3c5a8, "RAK_4631_terminal_chat" },
{ 0xc6d55752, "RAK_WisMesh_Tag_companion_radio_ble" },
{ 0x60683191, "RAK_WisMesh_Tag_companion_radio_usb" },
@@ -409,17 +414,10 @@ inline const char* ota_target_env_name(uint32_t target_id) {
{ 0x3caf966e, "solarxiao_33S_repeater" },
{ 0xcb59ea4e, "solarxiao_33S_repeater_bridge_rs232" },
{ 0xe3a28284, "solarxiao_33S_room_server" },
{ 0xd0df8303, "Station_G2_companion_radio_ble" },
{ 0x5c1a54f8, "Station_G2_companion_radio_usb" },
{ 0x79cc029e, "Station_G2_companion_radio_wifi" },
{ 0x41197b92, "Station_G2_kiss_modem" },
{ 0x05747eb6, "Station_G2_logging_repeater" },
{ 0x75587f24, "Station_G2_logging_repeater_bridge_espnow" },
{ 0xf2128c81, "Station_G2_repeater" },
{ 0x1b34e7b4, "Station_G2_repeater_bridge_espnow" },
{ 0xdbb6a855, "Station_G2_repeater_observer_mqtt" },
{ 0x73c4a019, "Station_G2_room_server" },
{ 0xe4042b21, "Station_G2_room_server_observer_mqtt" },
{ 0x8e549407, "Station_G3_ESP32_companion_radio_ble" },
{ 0x1deac038, "Station_G3_ESP32_companion_radio_usb" },
{ 0x2a2f303b, "Station_G3_ESP32_companion_radio_wifi" },
+75
View File
@@ -2,6 +2,10 @@
#include "MerkleTree.h"
#include "Multihash.h"
#include "Identity.h"
#include "OtaByteIO.h"
#include <SHA256.h>
#include <stdlib.h>
#include <string.h>
namespace mesh {
namespace ota {
@@ -24,5 +28,76 @@ VerifyResult ota_verify(const uint8_t* buf, uint32_t len, const SignerAllowlist&
return r;
}
VerifyResult ota_verify(const OtaStore& store, const SignerAllowlist& allow) {
VerifyResult r;
const uint32_t total = store.staged_size();
uint8_t hdr[8], manifest[MOTA_MFL], trailer[5];
if (total < 8 + MOTA_MFL + 5 ||
!store.read(0, hdr, sizeof(hdr)) ||
memcmp(hdr, MOTA_MAGIC, 4) != 0 || rd_u32le(hdr + 4) != total ||
!store.read(8, manifest, sizeof(manifest)) ||
!store.read(total - 5, trailer, sizeof(trailer)) ||
memcmp(trailer, MOTA_TRAILER, sizeof(trailer)) != 0) return r;
MotaManifest m;
if (!mota_parse_manifest(manifest, sizeof(manifest), m)) return r;
const uint32_t leaves_off = 8 + MOTA_MFL;
const uint64_t payload_off64 = (uint64_t)leaves_off + (uint64_t)m.block_count * 4;
if (m.block_size() > OTA_DEFAULT_BLOCK_SIZE ||
payload_off64 > UINT32_MAX ||
payload_off64 + m.payload_size + 5 != total) return r;
const uint32_t payload_off = (uint32_t)payload_off64;
const size_t leaves_len = (size_t)m.block_count * 4;
uint8_t* leaves = static_cast<uint8_t*>(malloc(leaves_len));
uint8_t* block = static_cast<uint8_t*>(malloc(m.block_size()));
if (!leaves || !block || !store.read(leaves_off, leaves, leaves_len)) {
free(leaves); free(block); return r;
}
r.parsed = true;
uint8_t root[4];
merkle_root(root, leaves, m.block_count);
r.root_ok = memcmp(root, m.merkle_root, sizeof(root)) == 0;
SHA256 image_sha;
r.payload_ok = true;
uint32_t off = 0;
for (uint32_t i = 0; i < m.block_count; i++) {
uint32_t n = m.payload_size - off;
if (n > m.block_size()) n = m.block_size();
if (!store.read(payload_off + off, block, n)) {
r.payload_ok = false;
break;
}
uint8_t leaf[4];
merkle_leaf(leaf, block, n);
if (memcmp(leaf, leaves + (size_t)i * 4, 4) != 0) {
r.payload_ok = false;
break;
}
if (m.is_full()) image_sha.update(block, n);
off += n;
}
if (off != m.payload_size) r.payload_ok = false;
if (m.is_full() && r.payload_ok) {
uint8_t image_hash[32];
image_sha.finalize(image_hash, sizeof(image_hash));
r.image_ok = memcmp(image_hash, m.image_hash, sizeof(image_hash)) == 0;
} else {
r.image_ok = !m.is_full();
}
r.is_signed = m.is_signed();
if (r.is_signed) {
mesh::Identity signer(m.signer_pubkey);
r.sig_ok = signer.verify(m.signature, m.manifest_start, (int)m.signed_len);
r.trusted = r.sig_ok && allow.contains(m.signer_pubkey);
}
free(leaves);
free(block);
return r;
}
} // namespace ota
} // namespace mesh
+5
View File
@@ -2,6 +2,7 @@
#include "MotaContainer.h"
#include "SignerAllowlist.h"
#include "OtaStore.h"
// Full verification of a staged `.mota` (device-side: uses Ed25519 via mesh::Identity, so NOT compiled
// on the native host - the portable integrity checks live in MotaContainer and are unit-tested there).
@@ -26,5 +27,9 @@ struct VerifyResult {
VerifyResult ota_verify(const uint8_t* buf, uint32_t len, const SignerAllowlist& allow);
// Streaming verification for a persistent store that cannot expose one
// contiguous memory view (notably the MeshTower V2 SD-backed nRF52 store).
VerifyResult ota_verify(const OtaStore& store, const SignerAllowlist& allow);
} // namespace ota
} // namespace mesh
+21
View File
@@ -1,6 +1,8 @@
#include <gtest/gtest.h>
#include <cstring>
#include "helpers/ota/OtaFlashLayout_nrf52.h"
#include "helpers/ota/OtaSdHandoff.h"
using namespace mesh::ota;
@@ -95,3 +97,22 @@ TEST(OtaFlashPlan, LeavesOutputUntouchedOnReject) {
EXPECT_FALSE(mota_nrf52_stage_plan(CAP_V6 + 1, APP_V6, APP_V6, start));
EXPECT_EQ(start, 0x1234ABCDu);
}
TEST(OtaSdHandoff, EncodesChecksummedRecordAndPreservesSectorTail) {
uint8_t sector[MOTA_SD_SECTOR_SIZE];
std::memset(sector, 0xA5, sizeof(sector));
mota_sd_encode_handoff(sector, 2048, 1234, 630000, 8000000);
EXPECT_EQ(0, std::memcmp(sector, MOTA_SD_HANDOFF_MAGIC, 8));
EXPECT_EQ(mota_sd_rd32(sector + 8), MOTA_SD_HANDOFF_VERSION);
EXPECT_EQ(mota_sd_rd32(sector + 12), 2048u);
EXPECT_EQ(mota_sd_rd32(sector + 16), 1234u);
EXPECT_EQ(mota_sd_rd32(sector + 20), 630000u);
EXPECT_EQ(mota_sd_rd32(sector + 24), ~630000u);
EXPECT_EQ(mota_sd_rd32(sector + 28), 8000000u);
EXPECT_EQ(mota_sd_rd32(sector + 32), mota_sd_crc32(sector, 32));
EXPECT_EQ(sector[MOTA_SD_HANDOFF_LEN], 0xA5); // bytes outside our record are untouched
sector[20] ^= 1u;
EXPECT_NE(mota_sd_rd32(sector + 32), mota_sd_crc32(sector, 32));
}
+5
View File
@@ -58,6 +58,11 @@ DEFAULT_BLOCK_SIZE = 1024
# the staged .mota must begin above it. Keep in sync with OtaFlashLayout_nrf52.h / the OTAFIX bootloader.
NRF52_INPLACE_MEMORY = 0x00098000
# MeshTower V2's SD-backed OTA target keeps the staged .mota off-chip, so the application may use the
# complete S140 v6 application region up to InternalFS instead of leaving room for internal staging.
# This is deliberately target-specific: other nRF52 OTA builds still need NRF52_INPLACE_MEMORY above.
NRF52_SD_APP_MEMORY = 0x000C7000
# ---------------------------------------------------------------------------
# Multihash helpers (sha2-256 truncations)
+6 -3
View File
@@ -148,9 +148,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")
sd_backed = _cppdef("OTA_SD_STORE") is not None
image_limit = ml.NRF52_SD_APP_MEMORY if sd_backed else ml.NRF52_INPLACE_MEMORY
limit_name = "SD application" if sd_backed else "in-place"
if len(out) > image_limit:
raise RuntimeError(f"nRF52 OTA image is {len(out)} bytes; {limit_name} limit is "
f"{image_limit} 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 (56 bytes with identity)
+33
View File
@@ -8,6 +8,7 @@ build_flags = ${nrf52_base.build_flags}
-I lib/nrf52/s140_nrf52_6.1.1_API/include/nrf52
-I variants/heltec_tower_v2
-D HELTEC_TOWER_V2
-D MOTA_HW_ID='"Heltec_tower_v2"'
-D NRF52_POWER_MANAGEMENT
-D RADIO_CLASS=CustomSX1262
-D WRAPPER_CLASS=CustomSX1262Wrapper
@@ -44,6 +45,38 @@ build_flags =
; -D MESH_PACKET_LOGGING=1
; -D MESH_DEBUG=1
; MeshTower V2 self-update target using the onboard microSD socket as the
; persistent LoRa-OTA store. Requires the matching SD-aware OTAFIX bootloader.
; Unlike the internal-flash target above, this accepts both complete images
; and in-place deltas and does not reserve application flash for the container.
[Heltec_tower_v2_sdcard]
extends = Heltec_tower_v2
build_flags =
${Heltec_tower_v2.build_flags}
-D HELTEC_TOWER_V2_SDCARD=1
lib_deps =
${Heltec_tower_v2.lib_deps}
; SdFat 2.3.1 plus the tested full-exFAT mount fix from upstream beta.
https://github.com/greiman/SdFat-beta.git#a249cfa121e25a96ee14d8813abf01755f1f6a25
[env:Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors]
extends = Heltec_tower_v2_sdcard
extra_scripts = ${nrf52_lora_ota.extra_scripts}
build_src_filter = ${Heltec_tower_v2_sdcard.build_src_filter}
+<helpers/ota/*.cpp>
+<../examples/simple_repeater>
build_flags =
${Heltec_tower_v2_sdcard.build_flags}
-D ADVERT_NAME='"Heltec_Tower_V2 SD Repeater"'
-D ADVERT_LAT=0.0
-D ADVERT_LON=0.0
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=254
-D ENABLE_OTA=1
-D OTA_SD_STORE=1
-D OTA_FOLDER_SERIAL
[env:Heltec_tower_v2_room_server]
extends = Heltec_tower_v2
build_src_filter = ${Heltec_tower_v2.build_src_filter}
+6
View File
@@ -36,6 +36,12 @@ void variant_shutdown()
nrf_gpio_cfg_default(PIN_SPI_MISO);
nrf_gpio_cfg_default(PIN_SPI_MOSI);
nrf_gpio_cfg_default(PIN_SPI_SCK);
#if defined(HELTEC_TOWER_V2_SDCARD)
nrf_gpio_cfg_default(PIN_SPI1_NSS);
nrf_gpio_cfg_default(PIN_SPI1_MISO);
nrf_gpio_cfg_default(PIN_SPI1_MOSI);
nrf_gpio_cfg_default(PIN_SPI1_SCK);
#endif
nrf_gpio_cfg_default(PIN_LED);
detachInterrupt(PIN_GPS_PPS);
detachInterrupt(PIN_BUTTON1);
+20
View File
@@ -16,12 +16,26 @@
#define PIN_BOARD_SDA PIN_WIRE_SDA
#define PIN_BOARD_SCL PIN_WIRE_SCL
#if defined(HELTEC_TOWER_V2_SDCARD)
#define SPI_INTERFACES_COUNT (2)
#else
#define SPI_INTERFACES_COUNT (1)
#endif
#define PIN_SPI_MISO (0 + 23)
#define PIN_SPI_MOSI (0 + 22)
#define PIN_SPI_SCK (0 + 19)
#define PIN_SPI_NSS LORA_CS
// MeshTower V2 microSD socket (separate SPIM2 bus), from the Heltec partial
// reference circuit: CS=P1.00, MOSI=P1.01, SCK=P0.06, MISO=P0.26.
#if defined(HELTEC_TOWER_V2_SDCARD)
#define PIN_SPI1_MISO (0 + 26)
#define PIN_SPI1_MOSI (32 + 1)
#define PIN_SPI1_SCK (0 + 6)
#define PIN_SPI1_NSS (32 + 0)
#define OTA_SD_CS_PIN PIN_SPI1_NSS
#endif
#define LED_BUILTIN (32 + 15)
#define PIN_LED LED_BUILTIN
#define LED_RED (-1)
@@ -52,6 +66,12 @@
#define P_LORA_MOSI PIN_SPI_MOSI
#define P_LORA_SCLK PIN_SPI_SCK
#if defined(HELTEC_TOWER_V2_SDCARD)
static const uint8_t SS = PIN_SPI1_NSS;
#else
static const uint8_t SS = PIN_SPI_NSS;
#endif
#define USE_KCT8103L_PA_ONLY
#define LORA_KCT8103L_EN (0 + 15)
#define LORA_KCT8103L_TX_RX (0 + 16)