From 4ed3307c6c091a91c6db28d5c8f5cd7808f8e3d3 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Mon, 3 Aug 2026 12:49:36 -0700 Subject: [PATCH] Add MeshTower V2 SD-backed LoRa OTA --- build.sh | 18 +- docs/index.md | 1 + docs/ota_easy.md | 9 +- docs/ota_meshtower_v2_sdcard.md | 71 +++++++ docs/ota_protocol.md | 15 +- src/helpers/ota/OtaApply.cpp | 84 ++++++++ src/helpers/ota/OtaApply.h | 5 + src/helpers/ota/OtaBlInfo.h | 6 +- src/helpers/ota/OtaCli.cpp | 37 +++- src/helpers/ota/OtaContext.h | 19 +- src/helpers/ota/OtaFlashLayout_nrf52.h | 4 + src/helpers/ota/OtaSdHandoff.h | 64 ++++++ src/helpers/ota/OtaSelf.cpp | 11 +- src/helpers/ota/OtaStoreSdNrf52.cpp | 271 ++++++++++++++++++++++++ src/helpers/ota/OtaStoreSdNrf52.h | 61 ++++++ src/helpers/ota/OtaTargets.h | 20 +- src/helpers/ota/OtaVerify.cpp | 75 +++++++ src/helpers/ota/OtaVerify.h | 5 + test/test_ota/test_ota_flashplan.cpp | 21 ++ tools/mota/motalib.py | 5 + tools/mota/pio_endf.py | 9 +- variants/heltec_tower_v2/platformio.ini | 33 +++ variants/heltec_tower_v2/variant.cpp | 6 + variants/heltec_tower_v2/variant.h | 20 ++ 24 files changed, 833 insertions(+), 37 deletions(-) create mode 100644 docs/ota_meshtower_v2_sdcard.md create mode 100644 src/helpers/ota/OtaSdHandoff.h create mode 100644 src/helpers/ota/OtaStoreSdNrf52.cpp create mode 100644 src/helpers/ota/OtaStoreSdNrf52.h diff --git a/build.sh b/build.sh index 8482fefd..fad2bb4f 100755 --- a/build.sh +++ b/build.sh @@ -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" diff --git a/docs/index.md b/docs/index.md index 8fcf56c3..80fdd780 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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) diff --git a/docs/ota_easy.md b/docs/ota_easy.md index 3d446f6a..9f930bf9 100644 --- a/docs/ota_easy.md +++ b/docs/ota_easy.md @@ -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 diff --git a/docs/ota_meshtower_v2_sdcard.md b/docs/ota_meshtower_v2_sdcard.md new file mode 100644 index 00000000..24c965fd --- /dev/null +++ b/docs/ota_meshtower_v2_sdcard.md @@ -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. diff --git a/docs/ota_protocol.md b/docs/ota_protocol.md index bf7affe8..ba40b1b7 100644 --- a/docs/ota_protocol.md +++ b/docs/ota_protocol.md @@ -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. diff --git a/src/helpers/ota/OtaApply.cpp b/src/helpers/ota/OtaApply.cpp index e06afd1c..b3bd342c 100644 --- a/src/helpers/ota/OtaApply.cpp +++ b/src/helpers/ota/OtaApply.cpp @@ -2,6 +2,7 @@ #include "OtaFormat.h" #include "MotaContainer.h" #include "Identity.h" +#include "OtaByteIO.h" #include #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(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; } diff --git a/src/helpers/ota/OtaApply.h b/src/helpers/ota/OtaApply.h index e6a6b1f1..3d58c34e 100644 --- a/src/helpers/ota/OtaApply.h +++ b/src/helpers/ota/OtaApply.h @@ -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 diff --git a/src/helpers/ota/OtaBlInfo.h b/src/helpers/ota/OtaBlInfo.h index 199cde38..d051d4b9 100644 --- a/src/helpers/ota/OtaBlInfo.h +++ b/src/helpers/ota/OtaBlInfo.h @@ -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 diff --git a/src/helpers/ota/OtaCli.cpp b/src/helpers/ota/OtaCli.cpp index d8ed90c7..612dc9c0 100644 --- a/src/helpers/ota/OtaCli.cpp +++ b/src/helpers/ota/OtaCli.cpp @@ -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(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", diff --git a/src/helpers/ota/OtaContext.h b/src/helpers/ota/OtaContext.h index 4b724b44..2062df8d 100644 --- a/src/helpers/ota/OtaContext.h +++ b/src/helpers/ota/OtaContext.h @@ -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) diff --git a/src/helpers/ota/OtaFlashLayout_nrf52.h b/src/helpers/ota/OtaFlashLayout_nrf52.h index a73b1a15..df07ff6a 100644 --- a/src/helpers/ota/OtaFlashLayout_nrf52.h +++ b/src/helpers/ota/OtaFlashLayout_nrf52.h @@ -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 diff --git a/src/helpers/ota/OtaSdHandoff.h b/src/helpers/ota/OtaSdHandoff.h new file mode 100644 index 00000000..38a51840 --- /dev/null +++ b/src/helpers/ota/OtaSdHandoff.h @@ -0,0 +1,64 @@ +#pragma once + +#include +#include + +// 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 diff --git a/src/helpers/ota/OtaSelf.cpp b/src/helpers/ota/OtaSelf.cpp index 16de730b..05060000 100644 --- a/src/helpers/ota/OtaSelf.cpp +++ b/src/helpers/ota/OtaSelf.cpp @@ -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; } diff --git a/src/helpers/ota/OtaStoreSdNrf52.cpp b/src/helpers/ota/OtaStoreSdNrf52.cpp new file mode 100644 index 00000000..f2286460 --- /dev/null +++ b/src/helpers/ota/OtaStoreSdNrf52.cpp @@ -0,0 +1,271 @@ +#include "OtaStoreSdNrf52.h" + +#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE) + +#include +#include +#include +#include +#include +#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 diff --git a/src/helpers/ota/OtaStoreSdNrf52.h b/src/helpers/ota/OtaStoreSdNrf52.h new file mode 100644 index 00000000..46312b99 --- /dev/null +++ b/src/helpers/ota/OtaStoreSdNrf52.h @@ -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 diff --git a/src/helpers/ota/OtaTargets.h b/src/helpers/ota/OtaTargets.h index 300c4c45..e8658006 100644 --- a/src/helpers/ota/OtaTargets.h +++ b/src/helpers/ota/OtaTargets.h @@ -2,7 +2,7 @@ #include // 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" }, diff --git a/src/helpers/ota/OtaVerify.cpp b/src/helpers/ota/OtaVerify.cpp index 5ac8b49c..73c304d7 100644 --- a/src/helpers/ota/OtaVerify.cpp +++ b/src/helpers/ota/OtaVerify.cpp @@ -2,6 +2,10 @@ #include "MerkleTree.h" #include "Multihash.h" #include "Identity.h" +#include "OtaByteIO.h" +#include +#include +#include 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(malloc(leaves_len)); + uint8_t* block = static_cast(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 diff --git a/src/helpers/ota/OtaVerify.h b/src/helpers/ota/OtaVerify.h index 1e54f341..869728a0 100644 --- a/src/helpers/ota/OtaVerify.h +++ b/src/helpers/ota/OtaVerify.h @@ -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 diff --git a/test/test_ota/test_ota_flashplan.cpp b/test/test_ota/test_ota_flashplan.cpp index 5d47447e..6f5dce18 100644 --- a/test/test_ota/test_ota_flashplan.cpp +++ b/test/test_ota/test_ota_flashplan.cpp @@ -1,6 +1,8 @@ #include +#include #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)); +} diff --git a/tools/mota/motalib.py b/tools/mota/motalib.py index 1d6bcaf0..6625875e 100644 --- a/tools/mota/motalib.py +++ b/tools/mota/motalib.py @@ -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) diff --git a/tools/mota/pio_endf.py b/tools/mota/pio_endf.py index 5cc66f9a..5f046784 100644 --- a/tools/mota/pio_endf.py +++ b/tools/mota/pio_endf.py @@ -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) diff --git a/variants/heltec_tower_v2/platformio.ini b/variants/heltec_tower_v2/platformio.ini index b44c6c7d..3ab535f3 100644 --- a/variants/heltec_tower_v2/platformio.ini +++ b/variants/heltec_tower_v2/platformio.ini @@ -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} + + + +<../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} diff --git a/variants/heltec_tower_v2/variant.cpp b/variants/heltec_tower_v2/variant.cpp index cfb88c4a..9cfe7f6f 100644 --- a/variants/heltec_tower_v2/variant.cpp +++ b/variants/heltec_tower_v2/variant.cpp @@ -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); diff --git a/variants/heltec_tower_v2/variant.h b/variants/heltec_tower_v2/variant.h index 14116c6f..4a1f9785 100644 --- a/variants/heltec_tower_v2/variant.h +++ b/variants/heltec_tower_v2/variant.h @@ -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)