diff --git a/boards/nrf52840_s140_v6_mota64.ld b/boards/nrf52840_s140_v6_mota64.ld new file mode 100644 index 00000000..a77daf7f --- /dev/null +++ b/boards/nrf52840_s140_v6_mota64.ld @@ -0,0 +1,80 @@ +/* S140 v6 application layout with a reset-retained 64 KiB mOTA arena. */ + +SEARCH_DIR(.) +GROUP(-lgcc -lc -lnosys) + +MEMORY +{ + FLASH (rx) : ORIGIN = 0x26000, LENGTH = 0xED000 - 0x26000 + + /* 8-byte retained clock state followed by the 72-byte MOTAHYB1 record. */ + PERSISTENT_RAM (rwx) : ORIGIN = 0x20006000, LENGTH = 80 + + /* Normal application data, heap, and stack end below the mOTA arena. */ + RAM (rwx) : ORIGIN = 0x20006000 + 80, LENGTH = 0x20030000 - 0x20006000 - 80 + + /* Fixed staging arena retained across the software-reset bootloader handoff. */ + MOTA_RAM (rwx) : ORIGIN = 0x20030000, LENGTH = 0x10000 +} + +SECTIONS +{ + . = ALIGN(4); + .persistent (NOLOAD) : + { + KEEP(*(.persistent_magic)) + KEEP(*(.persistent_data)) + . = ALIGN(4); + } > PERSISTENT_RAM + + /* Export the direct-access handoff ABI even though no input section owns it. */ + __mota_hybrid_handoff_start__ = ADDR(.persistent) + SIZEOF(.persistent); + __mota_hybrid_handoff_end__ = ORIGIN(PERSISTENT_RAM) + LENGTH(PERSISTENT_RAM); + + ASSERT(ADDR(.persistent) == 0x20006000, + "mOTA retained clock prefix start must remain fixed") + ASSERT(SIZEOF(.persistent) == 8, + "mOTA hybrid retained clock prefix must remain exactly 8 bytes") + ASSERT(__mota_hybrid_handoff_start__ == 0x20006008, + "mOTA hybrid handoff start must remain fixed") + ASSERT(__mota_hybrid_handoff_end__ == 0x20006050, + "mOTA hybrid handoff end must remain fixed") + ASSERT(__mota_hybrid_handoff_end__ - __mota_hybrid_handoff_start__ == 72, + "mOTA hybrid handoff must remain exactly 72 bytes") + ASSERT(ORIGIN(RAM) == __mota_hybrid_handoff_end__, + "normal RAM must start immediately after the mOTA handoff") + + . = ALIGN(4); + .svc_data : + { + PROVIDE(__start_svc_data = .); + KEEP(*(.svc_data)) + PROVIDE(__stop_svc_data = .); + } > RAM + + .fs_data : + { + PROVIDE(__start_fs_data = .); + KEEP(*(.fs_data)) + PROVIDE(__stop_fs_data = .); + } > RAM + + /* No input section owns this memory. These symbols are the direct-access ABI. */ + .mota_ram (NOLOAD) : + { + PROVIDE(__mota_ram_start__ = .); + . += LENGTH(MOTA_RAM); + PROVIDE(__mota_ram_end__ = .); + } > MOTA_RAM + + ASSERT(ORIGIN(MOTA_RAM) == 0x20030000, + "mOTA RAM arena start must remain fixed") + ASSERT(LENGTH(MOTA_RAM) == 0x10000, + "mOTA RAM arena must remain exactly 64 KiB") + ASSERT(ORIGIN(RAM) + LENGTH(RAM) == ORIGIN(MOTA_RAM), + "normal RAM must end at the mOTA arena") + ASSERT(ORIGIN(MOTA_RAM) + LENGTH(MOTA_RAM) == 0x20040000, + "mOTA RAM arena must end at physical SRAM top") +} + +INCLUDE "nrf52_common.ld" diff --git a/boards/nrf52840_s140_v7_mota64.ld b/boards/nrf52840_s140_v7_mota64.ld new file mode 100644 index 00000000..2c95b47e --- /dev/null +++ b/boards/nrf52840_s140_v7_mota64.ld @@ -0,0 +1,80 @@ +/* S140 v7 application layout with a reset-retained 64 KiB mOTA arena. */ + +SEARCH_DIR(.) +GROUP(-lgcc -lc -lnosys) + +MEMORY +{ + FLASH (rx) : ORIGIN = 0x27000, LENGTH = 0xED000 - 0x27000 + + /* 8-byte retained clock state followed by the 72-byte MOTAHYB1 record. */ + PERSISTENT_RAM (rwx) : ORIGIN = 0x20006000, LENGTH = 80 + + /* Normal application data, heap, and stack end below the mOTA arena. */ + RAM (rwx) : ORIGIN = 0x20006000 + 80, LENGTH = 0x20030000 - 0x20006000 - 80 + + /* Fixed staging arena retained across the software-reset bootloader handoff. */ + MOTA_RAM (rwx) : ORIGIN = 0x20030000, LENGTH = 0x10000 +} + +SECTIONS +{ + . = ALIGN(4); + .persistent (NOLOAD) : + { + KEEP(*(.persistent_magic)) + KEEP(*(.persistent_data)) + . = ALIGN(4); + } > PERSISTENT_RAM + + /* Export the direct-access handoff ABI even though no input section owns it. */ + __mota_hybrid_handoff_start__ = ADDR(.persistent) + SIZEOF(.persistent); + __mota_hybrid_handoff_end__ = ORIGIN(PERSISTENT_RAM) + LENGTH(PERSISTENT_RAM); + + ASSERT(ADDR(.persistent) == 0x20006000, + "mOTA retained clock prefix start must remain fixed") + ASSERT(SIZEOF(.persistent) == 8, + "mOTA hybrid retained clock prefix must remain exactly 8 bytes") + ASSERT(__mota_hybrid_handoff_start__ == 0x20006008, + "mOTA hybrid handoff start must remain fixed") + ASSERT(__mota_hybrid_handoff_end__ == 0x20006050, + "mOTA hybrid handoff end must remain fixed") + ASSERT(__mota_hybrid_handoff_end__ - __mota_hybrid_handoff_start__ == 72, + "mOTA hybrid handoff must remain exactly 72 bytes") + ASSERT(ORIGIN(RAM) == __mota_hybrid_handoff_end__, + "normal RAM must start immediately after the mOTA handoff") + + . = ALIGN(4); + .svc_data : + { + PROVIDE(__start_svc_data = .); + KEEP(*(.svc_data)) + PROVIDE(__stop_svc_data = .); + } > RAM + + .fs_data : + { + PROVIDE(__start_fs_data = .); + KEEP(*(.fs_data)) + PROVIDE(__stop_fs_data = .); + } > RAM + + /* No input section owns this memory. These symbols are the direct-access ABI. */ + .mota_ram (NOLOAD) : + { + PROVIDE(__mota_ram_start__ = .); + . += LENGTH(MOTA_RAM); + PROVIDE(__mota_ram_end__ = .); + } > MOTA_RAM + + ASSERT(ORIGIN(MOTA_RAM) == 0x20030000, + "mOTA RAM arena start must remain fixed") + ASSERT(LENGTH(MOTA_RAM) == 0x10000, + "mOTA RAM arena must remain exactly 64 KiB") + ASSERT(ORIGIN(RAM) + LENGTH(RAM) == ORIGIN(MOTA_RAM), + "normal RAM must end at the mOTA arena") + ASSERT(ORIGIN(MOTA_RAM) + LENGTH(MOTA_RAM) == 0x20040000, + "mOTA RAM arena must end at physical SRAM top") +} + +INCLUDE "nrf52_common.ld" diff --git a/docs/ota_nrf52_bootloader_update.md b/docs/ota_nrf52_bootloader_update.md index 179f7423..092e6daf 100644 --- a/docs/ota_nrf52_bootloader_update.md +++ b/docs/ota_nrf52_bootloader_update.md @@ -12,7 +12,7 @@ provisioned locally before either application or bootloader OTA. | Layout | Application limit | Staged boot package | Work area | Handoff / capability | | --- | ---: | ---: | ---: | --- | | XIAO-module raw QSPI | below `0xE0000` | external QSPI offset 0 | dedicated internal `0xE0000..0xEA000` scratch | source `0x51`, flags `0x0E` | -| Qualified internal-flash target | normal `0xED000` limit | shared internal slot, exact start `0xE2000` | the same eleven-page slot; no second reservation | source `0xED`, flags `0x0A` | +| Qualified internal-flash target | normal `0xED000` flash limit; top 64 KiB SRAM reserved for application-delta staging | shared internal slot, exact start `0xE2000` | the same eleven-page slot; no second flash reservation | source `0xED`, flags `0x0A` | | MeshTower V2 microSD | normal `0xED000` limit | contiguous `/meshcore-ota.mota` | dynamic internal `0xE0000..0xEA000` scratch; live image must end by `0xE0000` | source `0x53`, flags `0x09` | The exact SD target is @@ -53,10 +53,13 @@ ownership. Upgrade preview.12 through USB/BLE DFU or SWD. Both fmt2 application and fmt3 bootloader OTA then require the installed BLM2 metadata to match the live SoftDevice/application layout; neither MeshCore nor OTAFIX uses sector 1. -The internal path does not change the application linker or permanently set -aside separate app-OTA, boot-package, and scratch regions. The ordinary -bottom-aligned internal store holds one container at a time: either an -application delta or a bootloader package. +The internal path does not change the application's `0xED000` flash limit or +permanently set aside separate app-OTA, boot-package, and flash-scratch +regions. Qualified applications do use a dedicated linker that reserves the +top 64 KiB of SRAM for hybrid application-delta staging and a retained +authorization record. That SRAM is never part of a bootloader-update package. +The ordinary bottom-aligned internal flash store holds one container prefix at +a time: either an application delta or the complete bootloader package. The exact bootloader container is 41,330 bytes: 365 bytes of signed mOTA metadata, a 40 KiB payload, and the five-byte trailer. Below the normal @@ -68,10 +71,13 @@ erasing and compacts the payload forward in place to the page-aligned raw range MBR to copy that image over `0xF4000..0xFE000`. An ordinary application delta can be smaller or larger than this eleven-page -shape. It bottom-aligns dynamically below `0xED000` and may begin below -`0xE2000`; its detools workspace must stop at its actual container start, while -the reconstructed application must stop below `0xED000`. The two package kinds -are mutually exclusive because they use the same store. +shape. On the qualified profile, a delta larger than one page uses the minimum +page-aligned flash prefix ending at `0xED000` and keeps up to 64 KiB of its +logical tail in reset-retained SRAM; its detools workspace must stop at the +actual flash-prefix start. A one-page-or-smaller delta stays wholly in flash. +In either case the reconstructed application must stop below `0xED000`. The +two package kinds are mutually exclusive because they use the same flash +store, and a bootloader package never uses the volatile SRAM suffix. For internal-self-update builds, an absent or corrupt live `EndF` disables **all** internal staging before the first erase. The older 608 KiB rescue diff --git a/docs/ota_protocol.md b/docs/ota_protocol.md index 936e5742..10bb6861 100644 --- a/docs/ota_protocol.md +++ b/docs/ota_protocol.md @@ -394,7 +394,9 @@ There is no separate availability structure. **Block `i` is present <=> `leaves[ **Commit order per block (crash-safe):** (1) verify proof, (2) write block payload to its offset, (3) write `leaves[i]` **last**. A power loss before step 3 leaves the slot erased -> the block is simply re-fetched -(idempotent). On boot a node rebuilds an in-RAM present-bitmap by scanning `leaves[]`. +(idempotent). On boot a node rebuilds an in-RAM present-bitmap by scanning `leaves[]` for a persistent, +reopenable store. A hybrid nRF52 transfer is the deliberate exception: its SRAM-backed payload suffix is +volatile, so the application refuses to adopt that staged header instead of rebuilding partial progress. **Resume (`OtaManager::resumeStaged` + `OtaStore::checkpoint`/`reopen`):** an interrupted fetch resumes from the staged container after a reboot - re-parse the stored manifest, recompute geometry, count present @@ -407,11 +409,14 @@ package and keeps target `0` as a MID-only wildcard. Stores keep `leaves[]` in R auto-GC, preserving resumable progress. The debug/operator equivalent is `ota dev resume `; after a reboot the MID is mandatory, while a no-argument form may only reuse a still-active session MID. It never uses the `nullptr` automatic-adoption path, so a malformed MID or no active MID fails closed. +Hybrid nRF52 staging cannot enter this resume path after an application restart, even if its flash prefix +still contains metadata; the complete logical container must be fetched again. **Flash-store note (RX-safe writes):** a flash page-erase halts the CPU (~85 ms on nRF52) and starves LoRa RX, so the flash stores (`OtaStoreFlashNrf52`/`OtaStoreFlashEsp32`) **coalesce writes to the erase unit** -(4 KB page / sector) and commit each once off the per-packet path - RAM stays O(one page), not O(image). A -small delta that fits page 0 does zero flash I/O until COMPLETE. +(4 KB page / sector) and commit each once off the per-packet path. Ordinary stores keep RAM at O(one page), +not O(image); a qualified hybrid nRF52 profile additionally reserves one fixed 64 KiB staging arena. A small +delta that fits page 0 does zero flash I/O until COMPLETE. --- @@ -932,16 +937,26 @@ retains its `.part` file; it does not erase the unrelated manual-install store. - **nRF52 internal staging ceiling:** an internal-store application derives the ceiling from facts available in every build, not a board-name list. A companion that actually links the internal ExtraFS datastore stays below `0xD4000`; a default linker region or a role that does not mount ExtraFS can reclaim the unused - 100 KiB through `0xED000`. An internal bootloader-self-update target keeps that normal linker and ceiling; - it does not reserve a second boot-package or scratch region. The application uses a larger-than-legacy - window only when the - installed bootloader advertises the GPREGRET2 ceiling-handoff capability. The bootloader treats every - unknown/legacy handoff value as - `0xD4000`, and accepts a container only at the bottom-aligned position for the selected ceiling. + 100 KiB through `0xED000`. A qualified internal bootloader-self-update target keeps that normal flash + ceiling and does not reserve a second boot-package or flash-scratch region, but it uses a dedicated + application linker that reserves the top 64 KiB of SRAM (`0x20030000..0x20040000`) plus a 72-byte + retained authorization record after the existing persistent clock bytes. Application deltas larger than + one flash page use a deterministic page-aligned flash prefix ending at `0xED000` and place up to 64 KiB + of the logical container tail in that SRAM. Packages of one page or less, and bootloader-update packages, + remain wholly in flash. Hybrid staging is enabled only when the installed bootloader contains exactly one + valid `MOTARAMA` capability marker; otherwise a larger hybrid-profile fetch fails before erase. Immediately + before a software reset the application publishes a valid-last `MOTAHYB1` record binding the split geometry + and normalized container hash. The bootloader consumes it once and rejects power-loss, stale-reset, corrupt, + or mismatched-RAM cases before its first application write. A hybrid transfer cannot resume after an + application restart because its suffix is deliberately volatile. The bootloader treats every unknown/legacy + GPREGRET2 handoff value as `0xD4000`, and accepts a flash-only container only at the bottom-aligned position + for the selected ceiling. - **nRF52 dynamic apply window:** the post-build hook records the resolved app base, linked app end, - internal-ExtraFS/SD/QSPI storage flags, and desired staging ceiling immediately before `EndF`. `motatool` reads that authenticated - firmware record and chooses `memory_size` from the actual patch size and bottom-aligned stage address; - firmware without the record retains the conservative `0x98000` default. Before writing `APRV`, an + internal-ExtraFS/SD/QSPI/hybrid-RAM storage flags, and desired staging ceiling immediately before `EndF`. + `motatool` reads that authenticated firmware record and chooses `memory_size` from the actual patch size + and bottom-aligned stage address. For a hybrid base it charges only the deterministic flash prefix against + that workspace; the retained-RAM suffix is still part of the same hashed logical container. + Firmware without the record retains the conservative `0x98000` default. Before writing `APRV`, an internal-store app validates the staged-address bound; an external SD/QSPI app validates the full detools geometry against the application workspace. The bootloader independently parses and validates the same geometry before its first application write. Expanded auto-sized packages require a bootloader @@ -1013,10 +1028,11 @@ retains its `.part` file; it does not erase the unrelated manual-install store. It holds either an ordinary app delta or the exact 41,330-byte v3 container, never both. The boot package bottom-aligns at `0xE2000`; a hash-valid live `EndF` must prove the complete running image ends at or below that address before the first erase. OTAFIX reads each source window before erasing and compacts the - payload forward in the same eleven pages to raw `0xE2000..0xEC000`; no separate scratch bank or special - application linker exists. GPREGRET `0x6B` plus GPREGRET2 `0xED` selects boot update, while ordinary app - apply uses GPREGRET `0x6A` plus the same storage source. Exact installed/candidate capability flags are - `0x0A` (`STAGE_CEILING|BOOT_UPDATE`). Ordinary deltas remain dynamically sized, may start below + payload forward in the same eleven pages to raw `0xE2000..0xEC000`; no separate flash scratch bank exists. + The qualified application linker reserves a 64 KiB SRAM arena only for hybrid application deltas; the + bootloader package remains wholly in flash. GPREGRET `0x6B` plus GPREGRET2 `0xED` selects boot update, + while ordinary app apply uses GPREGRET `0x6A` plus the same storage source. Exact installed/candidate + capability flags are `0x0A` (`STAGE_CEILING|BOOT_UPDATE`). Ordinary deltas remain dynamically sized, may start below `0xE2000`, and reconstruct only below the normal `0xED000` app ceiling. The same signature, explicit confirmation, exact identity, vector, CRC, and single-marker rules as the XIAO path apply. Bootloader FULL admission is isolated from ordinary application FULL policy, and privileged partials are never resumed diff --git a/scripts/nrf52_internal_bootloader_link.py b/scripts/nrf52_internal_bootloader_link.py index 87e6b0c7..2b459317 100644 --- a/scripts/nrf52_internal_bootloader_link.py +++ b/scripts/nrf52_internal_bootloader_link.py @@ -1,9 +1,10 @@ """Enable shared-slot internal bootloader update support on qualified nRF52840 targets. The bootloader package uses the ordinary bottom-aligned internal OTA store -below 0xED000; no second linker reservation exists. This pre-script only binds -the feature macro to the exact release inventory and fails closed if a target -does not use the normal S140 v6/v7 1 MiB application layout. +below 0xED000. Qualified internal-only targets also use a dedicated linker that +reserves the top 64 KiB of SRAM for hybrid flash-prefix/RAM-suffix staging. +This pre-script binds both features to the exact release inventory and fails +closed if a target does not use the normal S140 v6/v7 1 MiB application layout. """ Import("env") @@ -30,7 +31,10 @@ requested = ( if requested: # The macro is injected here so direct PIO and build.sh synthetic aliases # cannot silently differ. Package admission is runtime-capacity based. - env.AppendUnique(CPPDEFINES=["OTA_INTERNAL_BOOTLOADER_UPDATE"]) + env.AppendUnique(CPPDEFINES=[ + "OTA_INTERNAL_BOOTLOADER_UPDATE", + "OTA_HYBRID_RAM_STORE", + ]) board = env.BoardConfig() # Most variants inherit the framework's top-level ``build.ldscript``. # A few otherwise ordinary Adafruit nRF52840 boards (notably Keepteen LT1) @@ -40,17 +44,34 @@ if requested: board.get("build.ldscript", "") or board.get("build.arduino.ldscript", "") ) + linker_versions = { + "nrf52840_s140_v6.ld": "v6", + "nrf52840_s140_v7.ld": "v7", + } + # Compare the complete filename, not merely a suffix: a custom linker such + # as ``board_nrf52840_s140_v6.ld`` must not be mistaken for the qualified + # normal map and silently replaced with the mOTA layout. + current_name = current.replace("\\", "/").rsplit("/", 1)[-1] + suffix = linker_versions.get(current_name) mcu = str(board.get("build.mcu", "")).lower() - if mcu != "nrf52840" or not ( - current.endswith("nrf52840_s140_v6.ld") - or current.endswith("nrf52840_s140_v7.ld") - ): + if mcu != "nrf52840" or suffix is None: raise RuntimeError( "shared-slot bootloader update requires an nRF52840 with the normal " f"S140 v6/v7 non-ExtraFS linker; found mcu={mcu or 'none'} " f"linker={current or 'none'}" ) - print( - "nRF52 shared-slot bootloader update: normal linker %s; runtime EndF headroom required" - % current + mota_linker = Path(env["PROJECT_DIR"]) / "boards" / ( + "nrf52840_s140_%s_mota64.ld" % suffix + ) + if not mota_linker.is_file(): + raise RuntimeError("missing mOTA hybrid linker: %s" % mota_linker) + + # The framework builder normally resolves LDSCRIPT_PATH from BoardConfig. + # Set both so this remains deterministic whether PlatformIO invokes this + # pre-script before or after that framework step. + board.update("build.ldscript", str(mota_linker)) + env.Replace(LDSCRIPT_PATH=str(mota_linker)) + print( + "nRF52 shared-slot bootloader update: %s; fixed 64 KiB mOTA RAM arena; runtime EndF headroom required" + % mota_linker.name ) diff --git a/src/helpers/ota/OtaApply.cpp b/src/helpers/ota/OtaApply.cpp index 5a33b4c5..2aa4fc83 100644 --- a/src/helpers/ota/OtaApply.cpp +++ b/src/helpers/ota/OtaApply.cpp @@ -458,6 +458,39 @@ bool ota_apply_verify_slot(ApplyState&) { return false; } bool ota_apply_arm() { return false; } bool ota_apply_detools_mota(const uint8_t*, uint32_t, const SignerAllowlist&, ApplyState& st, char* msg) { st = ApplyState(); strcpy(msg, "use ota_apply_mota_nrf52"); return false; } +#if defined(OTA_FLASH_STORE) +// Points at OtaContext's long-lived member only after the application package +// has passed every gate and APRV was committed. The retained descriptor must +// not be published until the command reply has drained and reset is imminent. +static OtaStoreFlashNrf52* g_nrf52_apply_store = nullptr; +#endif + +static void ota_nrf52_set_reset_handoff(uint8_t request, uint8_t source) { + uint8_t sd_en = 0; + sd_softdevice_is_enabled(&sd_en); + if (sd_en) { // POWER is SD-restricted while the SoftDevice runs + sd_power_gpregret_clr(1, 0xFFFFFFFF); + sd_power_gpregret_set(1, source); + sd_power_gpregret_clr(0, 0xFFFFFFFF); + if (request) sd_power_gpregret_set(0, request); + } else { + NRF_POWER->GPREGRET2 = source; + NRF_POWER->GPREGRET = request; + } +} + +static bool ota_nrf52_clear_reset_reasons() { + uint8_t sd_en = 0; + if (sd_softdevice_is_enabled(&sd_en) != NRF_SUCCESS) return false; + if (sd_en) { + return sd_power_reset_reason_clr(0xFFFFFFFFu) == NRF_SUCCESS; + } + NRF_POWER->RESETREAS = NRF_POWER->RESETREAS; + __DMB(); + __DSB(); + return NRF_POWER->RESETREAS == 0u; +} + void ota_reboot_to_apply() { // public: set the apply magic + reset (does not return) uint8_t stage_handoff = GPREGRET2_OTA_STAGE_LEGACY; #if defined(OTA_SD_STORE) @@ -465,19 +498,24 @@ void ota_reboot_to_apply() { // public: set the apply magic + #elif defined(OTA_QSPI_STORE) stage_handoff = GPREGRET2_OTA_STAGE_QSPI; #elif defined(OTA_FLASH_STORE) - stage_handoff = mota_nrf52_flash_stage_handoff(ota_nrf52_effective_stage_ceiling()); -#endif - uint8_t sd_en = 0; - sd_softdevice_is_enabled(&sd_en); - if (sd_en) { // POWER is SD-restricted while the SoftDevice runs - sd_power_gpregret_clr(1, 0xFFFFFFFF); - sd_power_gpregret_set(1, stage_handoff); - sd_power_gpregret_clr(0, 0xFFFFFFFF); - sd_power_gpregret_set(0, GPREGRET_OTA_APPLY); + if (g_nrf52_apply_store && g_nrf52_apply_store->is_hybrid()) { + // The bootloader admits retained SRAM only after a clean software-reset + // handoff. Clear every prior reset reason immediately before publishing + // the valid-last descriptor, then make A6/6A the only apply trigger. + if (!ota_nrf52_clear_reset_reasons() || + !g_nrf52_apply_store->publish_hybrid_handoff()) { + // Never fall back to scanning the flash prefix as a legacy container: + // it intentionally advertises a total larger than its flash span. + ota_nrf52_set_reset_handoff(0u, 0xBDu); + NVIC_SystemReset(); + return; + } + stage_handoff = GPREGRET2_OTA_STAGE_HYBRID; } else { - NRF_POWER->GPREGRET2 = stage_handoff; - NRF_POWER->GPREGRET = GPREGRET_OTA_APPLY; + stage_handoff = mota_nrf52_flash_stage_handoff(ota_nrf52_effective_stage_ceiling()); } +#endif + ota_nrf52_set_reset_handoff(GPREGRET_OTA_APPLY, stage_handoff); NVIC_SystemReset(); // does not return } @@ -514,6 +552,9 @@ static bool ota_apply_mota_nrf52_impl(const uint8_t* buf, uint32_t len, const uint8_t* rescue_base_hash, uint32_t local_target_id, ApplyState& st, char* msg) { +#if defined(OTA_FLASH_STORE) + g_nrf52_apply_store = nullptr; +#endif st = ApplyState(); MotaManifest m; if (!mota_parse(buf, len, m)) { strcpy(msg, "parse failed"); return false; } @@ -638,6 +679,164 @@ bool ota_rescue_mota_nrf52(const uint8_t* buf, uint32_t len, const SignerAllowli return ota_apply_mota_nrf52_impl(buf, len, allow, operator_base_hash, local_target_id, st, msg); } +#if defined(OTA_FLASH_STORE) +bool ota_apply_mota_nrf52(OtaStoreFlashNrf52& store, + const SignerAllowlist& allow, + ApplyState& st, char* msg) { + static const size_t CAP = 96; + g_nrf52_apply_store = nullptr; + st = ApplyState(); + + uint8_t header[8]; + uint8_t manifest[MOTA_MFL]; + const uint32_t total = store.staged_size(); + if (total < MOTA_NRF52_CONTAINER_MIN_SIZE || + !store.read(0, header, sizeof(header)) || + memcmp(header, MOTA_MAGIC, sizeof(MOTA_MAGIC)) != 0 || + rd_u32le(header + 4u) != total || + !store.read(8u, manifest, sizeof(manifest))) { + strcpy(msg, "internal container parse failed"); + return false; + } + + MotaManifest m; + if (!mota_parse_manifest(manifest, sizeof(manifest), m)) { + strcpy(msg, "internal manifest parse failed"); + return false; + } + if (m.is_bootloader()) { + strcpy(msg, "use explicit ota bootloader install"); + return false; + } + if (m.is_full() || m.codec_id != CODEC_DETOOLS_INPLACE) { + strcpy(msg, "not an in-place delta"); + return false; + } + + const uint64_t payload_off64 = + 8u + MOTA_MFL + (uint64_t)m.block_count * 4u; + if (payload_off64 > UINT32_MAX || + payload_off64 + m.payload_size + 5u != total) { + strcpy(msg, "internal container layout mismatch"); + return false; + } + const uint32_t payload_off = (uint32_t)payload_off64; + const uint32_t app_base = mota_nrf52_app_base(); + const uint32_t app_ceiling = mota_nrf52_application_ceiling(); + if (!mota_nrf52_target_image_fits(app_base, m.image_size, + app_ceiling)) { + strcpy(msg, "target image exceeds linked 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; + + const OtaBlCaps bl = ota_bootloader_app_caps(); + if (!bl.present) { + strcpy(msg, "bootloader has no OTA-apply support; update it first"); + return false; + } + if (bl.apply_abi < m.format_ver || + !(bl.codec_mask & (1u << m.codec_id))) { + snprintf(msg, CAP, + "bootloader cannot apply update (abi=%u codecs=0x%x; need %u/%u)", + bl.apply_abi, bl.codec_mask, m.format_ver, m.codec_id); + return false; + } + if (store.is_hybrid() && + !ota_bootloader_supports_hybrid(ota_bootloader_ram_caps())) { + strcpy(msg, "bootloader lacks hybrid RAM staging support"); + return false; + } + + // Verify through the logical OtaStore. A hybrid container is deliberately + // non-contiguous; data() is never consulted by this path. The resulting SHA + // is normalized across APRV bytes and is the handoff-record hash contract. + VerifyResult vr = ota_verify( + static_cast(store), allow, manifest); + st.sig_ok = vr.sig_ok; + st.trusted = vr.trusted; + if (!vr.root_ok || !vr.payload_ok || !vr.image_ok || + !vr.container_hash_ok) { + strcpy(msg, "payload hash mismatch (incomplete or corrupt .mota)"); + return false; + } + + 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, 8u) != 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; + } + } + + uint8_t patch_header[32]; + const uint32_t patch_header_len = + m.payload_size < sizeof(patch_header) + ? m.payload_size : (uint32_t)sizeof(patch_header); + InplacePatchDims d; + if (!store.read(payload_off, patch_header, patch_header_len) || + !parse_inplace_patch_dims(patch_header, patch_header_len, d)) { + strcpy(msg, "bad in-place patch header"); + return false; + } + if (d.memory == 0u || d.segment != MOTA_NRF52_FLASH_PAGE || + d.shift > d.memory || d.shift % d.segment != 0u || + d.from > d.memory - d.shift || d.from != fi.image_len || + d.to > d.memory || d.to != m.image_size || + !mota_nrf52_internal_patch_workspace_valid( + d.memory, app_base, store.write_start(), m.image_size, + app_ceiling)) { + strcpy(msg, "invalid in-place patch geometry"); + return false; + } + + if (store.is_hybrid()) { + uint32_t planned_start = 0, planned_flash = 0, planned_ram = 0; + if (fi.image_len > UINT32_MAX - app_base || + !mota_nrf52_hybrid_stage_plan( + total, app_base, app_base + fi.image_len, + MOTA_NRF52_STAGE_CEILING_EXPANDED, + planned_start, planned_flash, planned_ram) || + planned_start != store.write_start() || + planned_flash != store.flash_len() || + planned_ram != store.ram_len()) { + strcpy(msg, "hybrid staging geometry mismatch"); + return false; + } + } else if (store.ram_len() != 0u) { + strcpy(msg, "internal staging geometry mismatch"); + return false; + } + + if (!store.approve_for_application(vr.container_hash)) { + strcpy(msg, "internal approval/handoff preparation failed"); + return false; + } + g_nrf52_apply_store = &store; + snprintf(msg, CAP, + "verified%s%s; rebooting into bootloader after this reply", + vr.is_signed ? " (signer trusted)" : " (unsigned)", + store.is_hybrid() ? " in flash+RAM" : ""); + return true; +} +#endif + #if defined(OTA_SD_STORE) || defined(OTA_QSPI_STORE) static const size_t NRF52_APPLY_MSG_CAP = 96; diff --git a/src/helpers/ota/OtaApply.h b/src/helpers/ota/OtaApply.h index 0423fb78..6da29137 100644 --- a/src/helpers/ota/OtaApply.h +++ b/src/helpers/ota/OtaApply.h @@ -80,6 +80,13 @@ 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_FLASH_STORE) +class OtaStoreFlashNrf52; +// Streaming internal-store path. Required for the hybrid source because its +// flash prefix and retained-SRAM suffix have no single contiguous address. +bool ota_apply_mota_nrf52(OtaStoreFlashNrf52& store, + const SignerAllowlist& allow, ApplyState& st, char* msg); +#endif // Recovery for failed app-side EndF validation. This is available only through the explicit // `ota rescue install ` CLI command and never through normal or automatic installation. bool ota_rescue_mota_nrf52(const uint8_t* buf, uint32_t len, @@ -115,7 +122,6 @@ bool ota_prepare_bootloader_update_nrf52(OtaStoreQspiNrf52& store, #endif #if defined(NRF52_PLATFORM) && defined(OTA_FLASH_STORE) && \ defined(OTA_INTERNAL_BOOTLOADER_UPDATE) -class OtaStoreFlashNrf52; bool ota_prepare_bootloader_update_nrf52(OtaStoreFlashNrf52& store, const SignerAllowlist& allow, const OtaBootloaderIdentity& installed, @@ -137,7 +143,7 @@ bool ota_installed_bootloader_identity(OtaBootloaderIdentity& out); inline uint8_t ota_nrf52_boot_result_or_zero(uint8_t value) { return ((value >= 0x90u && value <= 0x9Fu) || // B0..BC are the ordinary apply progress/failure/success results; - // SD-backed OTAFIX additionally uses BD for an authorization failure. + // Retained-source OTAFIX paths use BD for authorization/handoff failure. (value >= 0xB0u && value <= 0xBDu) || (value >= 0xC0u && value <= 0xCFu)) ? value : 0u; } diff --git a/src/helpers/ota/OtaBlInfo.h b/src/helpers/ota/OtaBlInfo.h index 0d6cf35e..d025caed 100644 --- a/src/helpers/ota/OtaBlInfo.h +++ b/src/helpers/ota/OtaBlInfo.h @@ -15,6 +15,7 @@ #if defined(NRF52_PLATFORM) #include "OtaFlashLayout_nrf52.h" #endif +#include "OtaHybridHandoff.h" namespace mesh { namespace ota { @@ -29,6 +30,56 @@ struct OtaBlCaps { uint8_t storage_flags = 0; // OTA_BL_STORAGE_* capability bits }; +// Independent marker for the reset-retained hybrid source. Keep this separate +// from MOTABLDR: bootloader self-update policy relies on the exact deployed +// storage_flags byte, and older applications must continue to parse it. +struct OtaRamCaps { + bool present = false; + uint16_t abi = 0; + uint16_t record_len = 0; + uint32_t arena_size = 0; +}; + +inline bool ota_ram_caps_marker_parse(const uint8_t raw[16], + OtaRamCaps& out) { + out = OtaRamCaps(); + if (!raw || memcmp(raw, MOTA_RAM_CAP_MAGIC, + sizeof(MOTA_RAM_CAP_MAGIC)) != 0) { + return false; + } + const uint16_t abi = mota_hybrid_rd16(raw + 8u); + const uint16_t record_len = mota_hybrid_rd16(raw + 10u); + const uint32_t arena_size = mota_hybrid_rd32(raw + 12u); + if (abi != MOTA_RAM_CAP_ABI || record_len != MOTA_HYBRID_AUTH_LEN || + arena_size != MOTA_NRF52_HYBRID_RAM_SIZE) { + return false; + } + out.present = true; + out.abi = abi; + out.record_len = record_len; + out.arena_size = arena_size; + return true; +} + +inline OtaRamCaps ota_ram_caps_scan_aligned(const uint8_t* bytes, size_t len) { + OtaRamCaps result; + if (!bytes) return result; + uint8_t matches = 0; + for (size_t off = 0; off + 16u <= len; off += 4u) { + OtaRamCaps candidate; + if (!ota_ram_caps_marker_parse(bytes + off, candidate)) continue; + if (++matches != 1u) return OtaRamCaps(); + result = candidate; + } + return matches == 1u ? result : OtaRamCaps(); +} + +inline bool ota_bootloader_supports_hybrid(const OtaRamCaps& caps) { + return caps.present && caps.abi == MOTA_RAM_CAP_ABI && + caps.record_len == MOTA_HYBRID_AUTH_LEN && + caps.arena_size == MOTA_NRF52_HYBRID_RAM_SIZE; +} + static const uint8_t OTA_BL_STORAGE_SD = 0x01; static const uint8_t OTA_BL_STORAGE_STAGE_CEILING = 0x02; static const uint8_t OTA_BL_STORAGE_QSPI = 0x04; @@ -184,6 +235,16 @@ inline OtaBlCaps ota_bootloader_app_caps() { #endif } +inline OtaRamCaps ota_bootloader_ram_caps() { +#if defined(NRF52_PLATFORM) && defined(OTA_HYBRID_RAM_STORE) + const uint8_t* lo = (const uint8_t*)(uintptr_t)MOTA_NRF52_BL_START; + const uint8_t* hi = (const uint8_t*)(uintptr_t)MOTA_NRF52_BL_END; + return ota_ram_caps_scan_aligned(lo, (size_t)(hi - lo)); +#else + return OtaRamCaps(); +#endif +} + // Scan the same region using the stricter, fail-closed rules required before // replacing the bootloader itself. Legacy bootloaders remain valid for normal // application deltas, but can never enter this privileged path. diff --git a/src/helpers/ota/OtaCli.cpp b/src/helpers/ota/OtaCli.cpp index e2166eb4..f1f54761 100644 --- a/src/helpers/ota/OtaCli.cpp +++ b/src/helpers/ota/OtaCli.cpp @@ -1127,7 +1127,8 @@ static bool handle_dev(const char* d, char* reply, OtaContext& c) { strcpy(reply, "ERR completed fetch is in the host folder, not local verification storage"); return true; } -#if defined(NRF52_PLATFORM) && (defined(OTA_SD_STORE) || defined(OTA_QSPI_STORE)) +#if defined(NRF52_PLATFORM) && \ + (defined(OTA_SD_STORE) || defined(OTA_QSPI_STORE) || defined(OTA_FLASH_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", @@ -1137,7 +1138,8 @@ static bool handle_dev(const char* d, char* reply, OtaContext& c) { } #endif const uint8_t* buf; uint32_t len; -#if defined(NRF52_PLATFORM) && (defined(OTA_SD_STORE) || defined(OTA_QSPI_STORE)) +#if defined(NRF52_PLATFORM) && \ + (defined(OTA_SD_STORE) || defined(OTA_QSPI_STORE) || defined(OTA_FLASH_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(); } @@ -1184,6 +1186,10 @@ static bool handle_dev(const char* d, char* reply, OtaContext& c) { } } else if (strncmp(d, "clear", 5) == 0) { + if (c.apply_pending) { + strcpy(reply, "ERR update is armed; reboot is pending"); + return true; + } const bool was_folder = c.fetch_to_folder; bool was_sd_archive = false; #if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE) diff --git a/src/helpers/ota/OtaContext.h b/src/helpers/ota/OtaContext.h index 079df3d8..a49984da 100644 --- a/src/helpers/ota/OtaContext.h +++ b/src/helpers/ota/OtaContext.h @@ -258,13 +258,21 @@ struct OtaContext { bool ok; #if defined(NRF52_PLATFORM) && (defined(OTA_SD_STORE) || defined(OTA_QSPI_STORE)) ok = ota_apply_mota_nrf52(fetch_store, allow, apply_st, msg); -#elif defined(NRF52_PLATFORM) +#elif defined(NRF52_PLATFORM) && defined(OTA_FLASH_STORE) if (rescue_base_hash) { - ok = ota_rescue_mota_nrf52(fetch_store.data(), fetch_store.staged_size(), allow, - rescue_base_hash, manager.target(), apply_st, msg); + if (fetch_store.is_hybrid()) { + strncpy(msg, "rescue cannot use volatile hybrid staging", 96); + msg[95] = 0; + ok = false; + } else { + ok = ota_rescue_mota_nrf52(fetch_store.data(), fetch_store.staged_size(), allow, + rescue_base_hash, manager.target(), apply_st, msg); + } } else { - ok = ota_apply_mota_nrf52(fetch_store.data(), fetch_store.staged_size(), allow, apply_st, msg); + 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); #else diff --git a/src/helpers/ota/OtaFlashLayout_nrf52.h b/src/helpers/ota/OtaFlashLayout_nrf52.h index e8bf6432..640606d3 100644 --- a/src/helpers/ota/OtaFlashLayout_nrf52.h +++ b/src/helpers/ota/OtaFlashLayout_nrf52.h @@ -15,7 +15,9 @@ namespace ota { static const uint32_t MOTA_NRF52_APP_BASE_S140_V6 = 0x00026000u; static const uint32_t MOTA_NRF52_APP_BASE_S140_V7 = 0x00027000u; -#if defined(NRF52_PLATFORM) +#if defined(MOTA_NRF52_TEST_APP_BASE) +inline uint32_t mota_nrf52_app_base() { return MOTA_NRF52_TEST_APP_BASE; } +#elif defined(NRF52_PLATFORM) extern "C" uint32_t __flash_arduino_start[]; // nrf52_common.ld: ORIGIN(FLASH) extern "C" uint32_t __flash_arduino_end[]; // nrf52_common.ld: ORIGIN(FLASH) + LENGTH(FLASH) extern "C" const uint8_t g_meshcore_internal_extrafs __attribute__((weak)); @@ -54,6 +56,13 @@ static const uint8_t GPREGRET2_OTA_STAGE_LEGACY = 0xD4u; static const uint8_t GPREGRET2_OTA_STAGE_EXPANDED = 0xEDu; static const uint8_t GPREGRET2_OTA_STAGE_QSPI = 0x51u; static const uint8_t GPREGRET2_OTA_STAGE_SD = 0x53u; +static const uint8_t GPREGRET2_OTA_STAGE_HYBRID = 0xA6u; + +// Fixed SRAM reserved by the dedicated internal-only mOTA linker. A hybrid +// container is one logical stream: a page-aligned flash prefix followed by a +// reset-retained suffix at this address. +static const uint32_t MOTA_NRF52_HYBRID_RAM_START = 0x20030000u; +static const uint32_t MOTA_NRF52_HYBRID_RAM_SIZE = 0x00010000u; inline uint8_t mota_nrf52_flash_stage_handoff(uint32_t effective_stage_ceiling) { if (effective_stage_ceiling == MOTA_NRF52_STAGE_CEILING_EXPANDED) @@ -123,7 +132,9 @@ inline uint32_t mota_nrf52_stage_ceiling_for_layout(uint32_t linked_app_end, } inline uint32_t mota_nrf52_layout_stage_ceiling() { -#if defined(NRF52_PLATFORM) +#if defined(MOTA_NRF52_TEST_LAYOUT_STAGE_CEILING) + return MOTA_NRF52_TEST_LAYOUT_STAGE_CEILING; +#elif defined(NRF52_PLATFORM) // DataStore.cpp defines this symbol only for a companion that really constructs internal ExtraFS. // An undefined weak symbol has address zero, so other roles do not inherit nrf52_base's broad EXTRAFS // feature define as a false-positive storage reservation. @@ -273,6 +284,51 @@ inline bool mota_nrf52_stage_plan(uint32_t total_size, uint32_t app_base, uint32 mota_nrf52_layout_stage_ceiling(), out_start); } +// Plan the exact split used by a hybrid-capable profile for every application +// delta larger than one page. Reserve as little flash as possible (but always +// one complete page for the header/manifest/approval), leaving the largest +// possible detools workspace below it. The remainder is the reset-retained +// SRAM suffix. +inline bool mota_nrf52_hybrid_stage_plan(uint32_t total_size, + uint32_t app_base, + uint32_t app_end, + uint32_t stage_ceiling, + uint32_t& out_start, + uint32_t& out_flash_len, + uint32_t& out_ram_len) { + if (stage_ceiling != MOTA_NRF52_STAGE_CEILING_EXPANDED || + (app_base != MOTA_NRF52_APP_BASE_S140_V6 && + app_base != MOTA_NRF52_APP_BASE_S140_V7) || + !mota_nrf52_layout_valid(app_base, stage_ceiling) || + app_end < app_base || app_end > stage_ceiling || + total_size < MOTA_NRF52_CONTAINER_MIN_SIZE) { + return false; + } + + uint32_t flash_needed = total_size > MOTA_NRF52_HYBRID_RAM_SIZE + ? total_size - MOTA_NRF52_HYBRID_RAM_SIZE : 0u; + if (flash_needed < MOTA_NRF52_FLASH_PAGE) + flash_needed = MOTA_NRF52_FLASH_PAGE; + if (flash_needed > UINT32_MAX - (MOTA_NRF52_FLASH_PAGE - 1u)) + return false; + const uint32_t flash_len = + (flash_needed + MOTA_NRF52_FLASH_PAGE - 1u) & + ~(MOTA_NRF52_FLASH_PAGE - 1u); + if (flash_len >= total_size || flash_len > stage_ceiling - app_end) + return false; + + const uint32_t start = stage_ceiling - flash_len; + const uint32_t ram_len = total_size - flash_len; + if (start < app_end || ram_len == 0u || + ram_len > MOTA_NRF52_HYBRID_RAM_SIZE) + return false; + + out_start = start; + out_flash_len = flash_len; + out_ram_len = ram_len; + return true; +} + // Durable internal-store cancellation has to work even when the live // OtaStore object is empty (for example, autofetch is off after a reboot). It // also has to invalidate every older bottom-aligned header: invalidating only @@ -294,6 +350,15 @@ inline bool mota_nrf52_staged_header_matches_layout( return true; } + uint32_t hybrid_flash_len = 0, hybrid_ram_len = 0; + if (effective_stage_ceiling == MOTA_NRF52_STAGE_CEILING_EXPANDED && + mota_nrf52_hybrid_stage_plan( + total_size, app_base, app_end, effective_stage_ceiling, + planned, hybrid_flash_len, hybrid_ram_len) && + planned == address) { + return true; + } + // A device that moved from the legacy D4000 ceiling to the expanded ED000 // ceiling can still contain an older, otherwise reopenable legacy header. // The expanded layout has no filesystem in that range, so it is safe to diff --git a/src/helpers/ota/OtaHybridHandoff.h b/src/helpers/ota/OtaHybridHandoff.h new file mode 100644 index 00000000..1fa6a9dc --- /dev/null +++ b/src/helpers/ota/OtaHybridHandoff.h @@ -0,0 +1,149 @@ +#pragma once + +#include +#include +#include + +#include "OtaFlashLayout_nrf52.h" + +// Reset-retained authorization for the nRF52840 hybrid internal-flash/SRAM +// staging source. The application publishes this record only after streaming +// verification and approval. OTAFIX copies and consumes it before trusting any +// retained bytes. A power loss destroys the SRAM suffix and therefore makes +// the hash check fail closed before the first application write. + +namespace mesh { +namespace ota { + +static const uintptr_t MOTA_HYBRID_AUTH_ADDR = 0x20006008u; +static const uint16_t MOTA_HYBRID_AUTH_VERSION = 1u; +static const uint16_t MOTA_HYBRID_AUTH_LEN = 72u; +static const uint8_t MOTA_HYBRID_AUTH_PURPOSE_APP = 1u; +static const uint8_t MOTA_HYBRID_AUTH_FORMAT_APP = 2u; +static const uint8_t MOTA_HYBRID_AUTH_MAGIC[8] = { + 'M', 'O', 'T', 'A', 'H', 'Y', 'B', '1'}; + +static const uint8_t MOTA_RAM_CAP_MAGIC[8] = { + 'M', 'O', 'T', 'A', 'R', 'A', 'M', 'A'}; +static const uint16_t MOTA_RAM_CAP_ABI = 1u; +static const uint16_t MOTA_RAM_CAP_RECORD_LEN = MOTA_HYBRID_AUTH_LEN; + +static_assert(MOTA_HYBRID_AUTH_ADDR + MOTA_HYBRID_AUTH_LEN == 0x20006050u, + "hybrid auth must occupy the dedicated retained-RAM tail"); +static_assert(MOTA_NRF52_HYBRID_RAM_START + MOTA_NRF52_HYBRID_RAM_SIZE == + 0x20040000u, + "hybrid arena must occupy the top 64 KiB of nRF52840 SRAM"); + +inline uint16_t mota_hybrid_rd16(const uint8_t* p) { + return (uint16_t)p[0] | ((uint16_t)p[1] << 8); +} + +inline uint32_t mota_hybrid_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_hybrid_wr16(uint8_t* p, uint16_t value) { + p[0] = (uint8_t)value; + p[1] = (uint8_t)(value >> 8); +} + +inline void mota_hybrid_wr32(uint8_t* p, uint32_t value) { + p[0] = (uint8_t)value; + p[1] = (uint8_t)(value >> 8); + p[2] = (uint8_t)(value >> 16); + p[3] = (uint8_t)(value >> 24); +} + +inline uint32_t mota_hybrid_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 < 8u; ++bit) + crc = (crc >> 1) ^ + (0xEDB88320u & (uint32_t)-(int32_t)(crc & 1u)); + } + return ~crc; +} + +inline bool mota_hybrid_auth_geometry_valid(uint32_t total, + uint32_t flash_start, + uint32_t flash_len, + uint32_t ram_len) { + if (total < MOTA_NRF52_CONTAINER_MIN_SIZE || + flash_len < MOTA_NRF52_FLASH_PAGE || + (flash_start & (MOTA_NRF52_FLASH_PAGE - 1u)) != 0u || + (flash_len & (MOTA_NRF52_FLASH_PAGE - 1u)) != 0u || + flash_start > UINT32_MAX - flash_len || + (uint64_t)flash_len + ram_len != total || ram_len == 0u || + ram_len > MOTA_NRF52_HYBRID_RAM_SIZE) { + return false; + } + + uint32_t flash_needed = total > MOTA_NRF52_HYBRID_RAM_SIZE + ? total - MOTA_NRF52_HYBRID_RAM_SIZE : 0u; + if (flash_needed < MOTA_NRF52_FLASH_PAGE) + flash_needed = MOTA_NRF52_FLASH_PAGE; + if (flash_needed > UINT32_MAX - (MOTA_NRF52_FLASH_PAGE - 1u)) + return false; + const uint32_t expected_flash_len = + (flash_needed + MOTA_NRF52_FLASH_PAGE - 1u) & + ~(MOTA_NRF52_FLASH_PAGE - 1u); + if (flash_len != expected_flash_len || flash_len >= total) + return false; + // Hybrid staging is qualified only for dedicated no-ExtraFS profiles. It + // must never reinterpret a legacy D4000 handoff as permission to use SRAM. + return flash_start + flash_len == + MOTA_NRF52_STAGE_CEILING_EXPANDED; +} + +inline bool mota_hybrid_auth_encode( + uint8_t out[MOTA_HYBRID_AUTH_LEN], uint32_t total, + uint32_t flash_start, uint32_t flash_len, uint32_t ram_len, + const uint8_t normalized_container_sha256[32]) { + if (!out || !normalized_container_sha256 || + !mota_hybrid_auth_geometry_valid( + total, flash_start, flash_len, ram_len)) { + return false; + } + memset(out, 0, MOTA_HYBRID_AUTH_LEN); + memcpy(out, MOTA_HYBRID_AUTH_MAGIC, sizeof(MOTA_HYBRID_AUTH_MAGIC)); + mota_hybrid_wr16(out + 8u, MOTA_HYBRID_AUTH_VERSION); + mota_hybrid_wr16(out + 10u, MOTA_HYBRID_AUTH_LEN); + out[12] = MOTA_HYBRID_AUTH_PURPOSE_APP; + out[13] = MOTA_HYBRID_AUTH_FORMAT_APP; + mota_hybrid_wr32(out + 16u, total); + mota_hybrid_wr32(out + 20u, flash_start); + mota_hybrid_wr32(out + 24u, flash_len); + mota_hybrid_wr32(out + 28u, ram_len); + memcpy(out + 32u, normalized_container_sha256, 32u); + const uint32_t crc = mota_hybrid_crc32(out, 64u); + mota_hybrid_wr32(out + 64u, crc); + mota_hybrid_wr32(out + 68u, ~crc); + return true; +} + +inline bool mota_hybrid_auth_valid( + const uint8_t record[MOTA_HYBRID_AUTH_LEN]) { + if (!record || + memcmp(record, MOTA_HYBRID_AUTH_MAGIC, + sizeof(MOTA_HYBRID_AUTH_MAGIC)) != 0 || + mota_hybrid_rd16(record + 8u) != MOTA_HYBRID_AUTH_VERSION || + mota_hybrid_rd16(record + 10u) != MOTA_HYBRID_AUTH_LEN || + record[12] != MOTA_HYBRID_AUTH_PURPOSE_APP || + record[13] != MOTA_HYBRID_AUTH_FORMAT_APP || + mota_hybrid_rd16(record + 14u) != 0u) { + return false; + } + const uint32_t crc = mota_hybrid_rd32(record + 64u); + return mota_hybrid_auth_geometry_valid( + mota_hybrid_rd32(record + 16u), + mota_hybrid_rd32(record + 20u), + mota_hybrid_rd32(record + 24u), + mota_hybrid_rd32(record + 28u)) && + mota_hybrid_crc32(record, 64u) == crc && + mota_hybrid_rd32(record + 68u) == ~crc; +} + +} // namespace ota +} // namespace mesh diff --git a/src/helpers/ota/OtaStoreFlashNrf52.cpp b/src/helpers/ota/OtaStoreFlashNrf52.cpp index ee567b8b..27173983 100644 --- a/src/helpers/ota/OtaStoreFlashNrf52.cpp +++ b/src/helpers/ota/OtaStoreFlashNrf52.cpp @@ -7,22 +7,79 @@ #include "MotaContainer.h" #include "OtaDebug.h" #include "OtaByteIO.h" // align_down / rd_u32le (flash-page geometry + header read) +#include "OtaHybridHandoff.h" #include +#include "nrf.h" #include "flash/flash_nrf5x.h" // Adafruit core internal-flash driver (SoftDevice-safe; LittleFS path) namespace mesh { namespace ota { +namespace { + +static void clear_hybrid_auth_record() { +#if defined(OTA_HYBRID_RAM_STORE) + volatile uint32_t* const retained = + reinterpret_cast(MOTA_HYBRID_AUTH_ADDR); + for (uint32_t i = 0; i < MOTA_HYBRID_AUTH_LEN / 4u; ++i) + retained[i] = 0u; + __DMB(); + __DSB(); +#endif +} + +static bool publish_hybrid_auth_record( + const uint8_t record[MOTA_HYBRID_AUTH_LEN]) { +#if defined(OTA_HYBRID_RAM_STORE) + if (!mota_hybrid_auth_valid(record)) return false; + volatile uint32_t* const retained = + reinterpret_cast(MOTA_HYBRID_AUTH_ADDR); + // Invalidate first, publish the body, then make the first magic word live. + // Any reset during this sequence leaves a record OTAFIX rejects. + retained[0] = 0u; + __DMB(); + for (uint32_t i = 1u; i < MOTA_HYBRID_AUTH_LEN / 4u; ++i) + retained[i] = mota_hybrid_rd32(record + i * 4u); + __DMB(); + retained[0] = mota_hybrid_rd32(record); + __DMB(); + __DSB(); + for (uint32_t i = 0; i < MOTA_HYBRID_AUTH_LEN / 4u; ++i) + if (retained[i] != mota_hybrid_rd32(record + i * 4u)) return false; + return true; +#else + (void)record; + return false; +#endif +} + +} // namespace + +OtaStoreFlashNrf52::OtaStoreFlashNrf52() { + // A descriptor is valid for one immediate software-reset handoff only. + // OTAFIX consumes it before an application starts; clear any stale bytes if + // a reset reached this constructor without entering the hybrid path. + clear_hybrid_auth_record(); +} + void OtaStoreFlashNrf52::reset_session() { _write_start = 0; _stage_ceiling = MOTA_NRF52_STAGE_CEILING_LEGACY; + _flash_len = 0; + _ram_len = 0; _total = 0; + _hybrid = false; _pay_idx = 0; _flushed = false; _io_ok = true; _planned_bootloader = false; + _planned_hybrid = false; _planned_total = 0; _planned_start = 0; + _planned_flash_len = 0; + _planned_ram_len = 0; + memset(_hybrid_handoff, 0, sizeof(_hybrid_handoff)); + _hybrid_handoff_ready = false; } // A valid EndF gives the exact live-image extent. Legacy app-only builds keep @@ -46,11 +103,54 @@ bool OtaStoreFlashNrf52::plan_layout(bool is_full, uint32_t image_size, uint32_t payload_off, uint32_t payload_size, bool is_bootloader) { _planned_bootloader = false; + _planned_hybrid = false; _planned_total = 0; _planned_start = 0; - if (!is_bootloader) return true; -#if defined(OTA_INTERNAL_BOOTLOADER_UPDATE) + _planned_flash_len = 0; + _planned_ram_len = 0; const uint64_t total64 = (uint64_t)payload_off + payload_size + 5u; + if (total64 > UINT32_MAX) return false; + + if (!is_bootloader) { +#if defined(OTA_HYBRID_RAM_STORE) + const uint32_t total = (uint32_t)total64; + const uint32_t app_base = mota_nrf52_app_base(); + const uint32_t stage_ceiling = ota_nrf52_effective_stage_ceiling(); + uint32_t app_end = 0; + uint32_t start = 0; + if (!protected_app_end(app_base, stage_ceiling, app_end)) return false; + uint32_t flash_len = 0, ram_len = 0; + const OtaRamCaps ram_caps = ota_bootloader_ram_caps(); + // The layout record tells the offline packager to size detools' workspace + // against this exact split. Use it deterministically for every application + // delta larger than the mandatory flash page; choosing a smaller pure- + // flash start merely because the bytes fit could invalidate that geometry. + if (!is_full && ota_bootloader_supports_hybrid(ram_caps) && + stage_ceiling == MOTA_NRF52_STAGE_CEILING_EXPANDED && + mota_nrf52_hybrid_stage_plan( + total, app_base, app_end, stage_ceiling, + start, flash_len, ram_len)) { + _planned_hybrid = true; + _planned_total = total; + _planned_start = start; + _planned_flash_len = flash_len; + _planned_ram_len = ram_len; + return true; + } + // A container no larger than one page cannot have a non-empty SRAM suffix + // under the frozen split formula. Preserve the established flash path for + // that edge case; all larger hybrid-profile application packages fail + // closed if the installed bootloader lacks MOTARAMA. + if (total <= MOTA_NRF52_FLASH_PAGE) + return mota_nrf52_stage_plan( + total, app_base, app_end, stage_ceiling, start); + return false; +#else + (void)is_full; + return true; +#endif + } +#if defined(OTA_INTERNAL_BOOTLOADER_UPDATE) if (!is_full || image_size != 40u * 1024u || payload_size != 40u * 1024u || payload_off != 365u || total64 != MOTA_NRF52_BOOT_CONTAINER_SIZE || !ota_bootloader_self_update_caps_valid(ota_bootloader_update_caps())) @@ -118,6 +218,7 @@ bool OtaStoreFlashNrf52::invalidate_staged_header( } bool OtaStoreFlashNrf52::discard() { + clear_hybrid_auth_record(); const uint32_t app_base = mota_nrf52_app_base(); const uint32_t stage_ceiling = ota_nrf52_effective_stage_ceiling(); uint32_t app_end = 0; @@ -139,7 +240,8 @@ bool OtaStoreFlashNrf52::discard() { } bool OtaStoreFlashNrf52::flush_page(uint32_t page_idx, const uint8_t* buf) { - if (!_io_ok || _write_start > _stage_ceiling - PG || + if (!_io_ok || _flash_len < PG || page_idx >= _flash_len / PG || + _write_start > _stage_ceiling - PG || page_idx > (_stage_ceiling - PG - _write_start) / PG) { _io_ok = false; return false; @@ -156,8 +258,18 @@ uint32_t OtaStoreFlashNrf52::capacity() const { const uint32_t app_base = mota_nrf52_app_base(); const uint32_t stage_ceiling = ota_nrf52_effective_stage_ceiling(); uint32_t app_end; - return protected_app_end(app_base, stage_ceiling, app_end) - ? mota_nrf52_stage_capacity(app_base, app_end, stage_ceiling) : 0; + if (!protected_app_end(app_base, stage_ceiling, app_end)) return 0; + const uint32_t flash_capacity = + mota_nrf52_stage_capacity(app_base, app_end, stage_ceiling); +#if defined(OTA_HYBRID_RAM_STORE) + const OtaRamCaps ram_caps = ota_bootloader_ram_caps(); + if (stage_ceiling == MOTA_NRF52_STAGE_CEILING_EXPANDED && + flash_capacity >= PG && ota_bootloader_supports_hybrid(ram_caps) && + flash_capacity <= UINT32_MAX - MOTA_NRF52_HYBRID_RAM_SIZE) { + return flash_capacity + MOTA_NRF52_HYBRID_RAM_SIZE; + } +#endif + return flash_capacity; } bool OtaStoreFlashNrf52::flush_pay() { @@ -168,14 +280,20 @@ uint32_t OtaStoreFlashNrf52::run(uint32_t pos, uint32_t remain) const { uint32_t trailer = _total - 5; // container is always >= 13 bytes (begin checks) if (pos >= trailer) return remain; // tail: caller bounds remain to <= 5 already uint32_t end = pos + remain; - uint32_t page_end = (pos / PG + 1) * PG; - if (end > page_end) end = page_end; // a run stays within one flash page, + if (!_hybrid || pos < _flash_len) { + uint32_t page_end = (pos / PG + 1) * PG; + if (end > page_end) end = page_end; // a run stays within one flash page, + if (_hybrid && end > _flash_len) end = _flash_len; // and one backing region, + } if (end > trailer) end = trailer; // and never crosses into the trailer tail return end - pos; } const uint8_t* OtaStoreFlashNrf52::read_slot(uint32_t pos) const { if (pos >= _total - 5) return _trailer + (pos - (_total - 5)); // trailer tail (RAM until finalize) + if (_hybrid && pos >= _flash_len) + return (const uint8_t*)(uintptr_t)(MOTA_NRF52_HYBRID_RAM_START + + pos - _flash_len); uint32_t page = pos / PG; if (page == 0) return _meta_page + pos; // pinned page 0 (incl. leaves) if (page == _pay_idx) return _pay_page + (pos - page * PG); // current sliding payload page @@ -184,6 +302,9 @@ const uint8_t* OtaStoreFlashNrf52::read_slot(uint32_t pos) const { uint8_t* OtaStoreFlashNrf52::write_slot(uint32_t pos) { if (pos >= _total - 5) return _trailer + (pos - (_total - 5)); + if (_hybrid && pos >= _flash_len) + return (uint8_t*)(uintptr_t)(MOTA_NRF52_HYBRID_RAM_START + + pos - _flash_len); uint32_t page = pos / PG; if (page == 0) return _meta_page + pos; if (page > _pay_idx) { @@ -196,8 +317,12 @@ uint8_t* OtaStoreFlashNrf52::write_slot(uint32_t pos) { bool OtaStoreFlashNrf52::begin(uint32_t total_size) { const bool planned_bootloader = _planned_bootloader; + const bool planned_hybrid = _planned_hybrid; const uint32_t planned_total = _planned_total; const uint32_t planned_start = _planned_start; + const uint32_t planned_flash_len = _planned_flash_len; + const uint32_t planned_ram_len = _planned_ram_len; + clear_hybrid_auth_record(); reset_session(); // never collide with the running application image (its extent comes from its EndF trailer) @@ -224,23 +349,57 @@ bool OtaStoreFlashNrf52::begin(uint32_t total_size) { // Bottom-align below the selected ceiling and reject unless it sits above the running image. The // approval path later verifies the patch's detools workspace ends at/below this exact start. - uint32_t start; - if (!mota_nrf52_stage_plan(total_size, app_base, app_end, stage_ceiling, start)) return false; - if (planned_bootloader && start != planned_start) return false; + uint32_t start = 0; + uint32_t flash_len = 0; + uint32_t ram_len = 0; + if (planned_hybrid) { +#if defined(OTA_HYBRID_RAM_STORE) + if (planned_bootloader || total_size != planned_total || + stage_ceiling != MOTA_NRF52_STAGE_CEILING_EXPANDED || + !ota_bootloader_supports_hybrid(ota_bootloader_ram_caps()) || + !mota_nrf52_hybrid_stage_plan( + total_size, app_base, app_end, stage_ceiling, + start, flash_len, ram_len) || + start != planned_start || flash_len != planned_flash_len || + ram_len != planned_ram_len) { + return false; + } +#else + return false; +#endif + } else { + if (!mota_nrf52_stage_plan( + total_size, app_base, app_end, stage_ceiling, start)) { + return false; + } + if (planned_bootloader && start != planned_start) return false; + flash_len = stage_ceiling - start; + } _write_start = start; _stage_ceiling = stage_ceiling; + _flash_len = flash_len; + _ram_len = ram_len; _total = total_size; + _hybrid = planned_hybrid; memset(_meta_page, 0xFF, PG); // assemble page 0 in RAM; 0xFF = erased sentinel (unfilled leaf slots) memset(_trailer, 0xFF, sizeof(_trailer)); +#if defined(OTA_HYBRID_RAM_STORE) + if (_hybrid) + memset((void*)(uintptr_t)MOTA_NRF52_HYBRID_RAM_START, 0xFF, _ram_len); +#endif _pay_idx = 0; _flushed = false; _io_ok = true; _planned_bootloader = planned_bootloader; + _planned_hybrid = planned_hybrid; _planned_total = planned_total; _planned_start = planned_start; - OTA_DBG("OTA flash: begin total=%u start=%08x app_end=%08x ceiling=%08x\n", - (unsigned)total_size, (unsigned)start, (unsigned)app_end, (unsigned)stage_ceiling); + _planned_flash_len = planned_flash_len; + _planned_ram_len = planned_ram_len; + OTA_DBG("OTA flash: begin total=%u start=%08x flash=%u ram=%u app_end=%08x ceiling=%08x\n", + (unsigned)total_size, (unsigned)start, (unsigned)flash_len, + (unsigned)ram_len, (unsigned)app_end, (unsigned)stage_ceiling); return true; // no pre-erase: each page is erased by its own (single) flush } @@ -282,17 +441,90 @@ bool OtaStoreFlashNrf52::finalize() { OTA_DBG("OTA flash: finalize total=%u\n", (unsigned)_total); if (!flush_pay()) return false; // the last (highest) payload page, if one is open if (!flush_page(0, _meta_page)) return false; // header + manifest + leaves + first payload bytes - uint32_t trailer_addr = _write_start + _total - 5; - if (flash_nrf5x_write(trailer_addr, _trailer, 5) < 0) { _io_ok = false; return false; } - flash_nrf5x_flush(); - if (!flash_matches(trailer_addr, _trailer, 5)) { _io_ok = false; return false; } + const uint32_t trailer_off = _total - sizeof(_trailer); + uint32_t flash_bytes = sizeof(_trailer); + if (_hybrid) { + flash_bytes = trailer_off < _flash_len ? _flash_len - trailer_off : 0u; + if (flash_bytes > sizeof(_trailer)) flash_bytes = sizeof(_trailer); + } + if (flash_bytes != 0u) { + const uint32_t trailer_addr = _write_start + trailer_off; + if (flash_nrf5x_write(trailer_addr, _trailer, flash_bytes) < 0) { + _io_ok = false; + return false; + } + flash_nrf5x_flush(); + if (!flash_matches(trailer_addr, _trailer, flash_bytes)) { + _io_ok = false; + return false; + } + } + if (_hybrid && flash_bytes < sizeof(_trailer)) { + const uint32_t logical = trailer_off + flash_bytes; + uint8_t* const dst = (uint8_t*)(uintptr_t)( + MOTA_NRF52_HYBRID_RAM_START + logical - _flash_len); + memcpy(dst, _trailer + flash_bytes, sizeof(_trailer) - flash_bytes); + __DMB(); + __DSB(); + if (memcmp(dst, _trailer + flash_bytes, + sizeof(_trailer) - flash_bytes) != 0) { + _io_ok = false; + return false; + } + } _flushed = true; return true; } +bool OtaStoreFlashNrf52::approve_for_application( + const uint8_t normalized_container_hash[32]) { + clear_hybrid_auth_record(); + _hybrid_handoff_ready = false; + if (!finalize() || _total < 8u + MOTA_MFL + 5u) return false; + const uint32_t offset = 8u + MOTA_OFF_APPROVAL; + if (offset + sizeof(APPROVAL_YES) > _flash_len) return false; + const uint32_t address = _write_start + offset; + if (flash_nrf5x_write(address, APPROVAL_YES, sizeof(APPROVAL_YES)) < 0) { + _io_ok = false; + return false; + } + flash_nrf5x_flush(); + if (!flash_matches(address, APPROVAL_YES, sizeof(APPROVAL_YES))) { + _io_ok = false; + return false; + } + memcpy(_meta_page + offset, APPROVAL_YES, sizeof(APPROVAL_YES)); + if (!_hybrid) return true; +#if defined(OTA_HYBRID_RAM_STORE) + if (!normalized_container_hash || + !mota_hybrid_auth_encode( + _hybrid_handoff, _total, _write_start, _flash_len, _ram_len, + normalized_container_hash)) { + _io_ok = false; + return false; + } + _hybrid_handoff_ready = true; + return true; +#else + return false; +#endif +} + +bool OtaStoreFlashNrf52::publish_hybrid_handoff() { +#if defined(OTA_HYBRID_RAM_STORE) + if (!_hybrid || !_hybrid_handoff_ready || !_flushed || !_io_ok || + !mota_hybrid_auth_valid(_hybrid_handoff)) { + return false; + } + return publish_hybrid_auth_record(_hybrid_handoff); +#else + return false; +#endif +} + #if defined(OTA_INTERNAL_BOOTLOADER_UPDATE) bool OtaStoreFlashNrf52::approve_for_bootloader() { - if (!finalize() || _total < 8u + MOTA_MFL + 5u) return false; + if (_hybrid || !finalize() || _total < 8u + MOTA_MFL + 5u) return false; const uint32_t offset = 8u + MOTA_OFF_APPROVAL; if (offset + sizeof(APPROVAL_YES) > _total) return false; const uint32_t address = _write_start + offset; @@ -326,6 +558,10 @@ void OtaStoreFlashNrf52::checkpoint() { // the first match (highest address = most recent for the common single-container case). The manager then // parses the loaded manifest and validates geometry/root, so a stale leftover is rejected there. bool OtaStoreFlashNrf52::reopen() { + // A retained-SRAM suffix cannot survive a power cycle or app restart as a + // resumable store. Reset all in-memory split state and adopt only a complete + // flash span whose advertised total fits before the selected ceiling. + reset_session(); const uint32_t app_base = mota_nrf52_app_base(); const uint32_t stage_ceiling = ota_nrf52_effective_stage_ceiling(); uint32_t app_end; @@ -358,7 +594,10 @@ bool OtaStoreFlashNrf52::reopen() { } _write_start = start; _stage_ceiling = stage_ceiling; + _flash_len = stage_ceiling - start; + _ram_len = 0; _total = total; + _hybrid = false; memcpy(_meta_page, p, PG); // load page 0 (header+manifest+leaves) into RAM to continue memcpy(_trailer, p + (total - 5), 5); // recover the trailer tail (flushed at last finalize, if any) _pay_idx = 0; diff --git a/src/helpers/ota/OtaStoreFlashNrf52.h b/src/helpers/ota/OtaStoreFlashNrf52.h index 14f759a3..6af60a77 100644 --- a/src/helpers/ota/OtaStoreFlashNrf52.h +++ b/src/helpers/ota/OtaStoreFlashNrf52.h @@ -4,6 +4,7 @@ #include "OtaStore.h" #include "OtaFlashLayout_nrf52.h" +#include "OtaHybridHandoff.h" // Persistent flash-backed OtaStore for nRF52840. Stages the received `.mota` below the selected // filesystem-safe ceiling, bottom-aligned so the bootloader can find it. Internal ExtraFS roles stop @@ -37,12 +38,18 @@ class OtaStoreFlashNrf52 : public OtaStore { uint32_t _write_start = 0; // flash address of container offset 0 (page-aligned) uint32_t _stage_ceiling = MOTA_NRF52_STAGE_CEILING_LEGACY; + uint32_t _flash_len = 0; // logical prefix backed by internal flash + uint32_t _ram_len = 0; // logical suffix backed by the fixed SRAM arena uint32_t _total = 0; // staged container size (0 = none) + bool _hybrid = false; // flash prefix + reset-retained SRAM suffix bool _flushed = false; // finalize() committed everything to flash bool _io_ok = true; // cleared on a failed/out-of-bounds flash write or readback mismatch bool _planned_bootloader = false; // manifest-kind decision made before begin()/any erase + bool _planned_hybrid = false; uint32_t _planned_total = 0; uint32_t _planned_start = 0; + uint32_t _planned_flash_len = 0; + uint32_t _planned_ram_len = 0; uint8_t _meta_page[PG]; // pinned flash page 0 (header + manifest + leaves + 1st payload) uint8_t _pay_page[PG]; // sliding buffer for one payload page (index _pay_idx) @@ -63,6 +70,7 @@ class OtaStoreFlashNrf52 : public OtaStore { static bool invalidate_staged_header(void* context, uint32_t address); public: + OtaStoreFlashNrf52(); bool plan_layout(bool is_full, uint32_t image_size, uint32_t payload_off, uint32_t payload_size, bool is_bootloader) override; bool begin(uint32_t total_size) override; @@ -77,6 +85,12 @@ public: void checkpoint() override; // persist page 0 (leaves) + the open payload page so a reboot can resume bool reopen() override; // re-attach to a container already staged in flash (scan for it) + // Mark a verified application package approved. For a hybrid package this + // also prepares (but does not yet publish) the reset-retained authorization; + // publication happens immediately before the software-reset handoff. + bool approve_for_application(const uint8_t normalized_container_hash[32]); + bool publish_hybrid_handoff(); + #if defined(OTA_INTERNAL_BOOTLOADER_UPDATE) // Clear the manifest approval word only after the privileged signed-image gates pass. OTAFIX scans // this page-aligned shared container after the ED handoff and repeats the structural, identity, @@ -88,8 +102,18 @@ public: // Contiguous view (flash is memory-mapped). VALID ONLY AFTER finalize() - before that, page 0 and the // tail are still in RAM. OtaManager/OtaCli/verify use this only once the transfer is COMPLETE. - const uint8_t* data() const { return (_flushed && _io_ok) ? (const uint8_t*)(uintptr_t)_write_start : nullptr; } + const uint8_t* data() const { + return (_flushed && _io_ok && !_hybrid) + ? (const uint8_t*)(uintptr_t)_write_start : nullptr; + } uint32_t write_start() const { return _write_start; } + uint32_t flash_len() const { return _flash_len; } + uint32_t ram_len() const { return _ram_len; } + bool is_hybrid() const { return _hybrid; } + +private: + uint8_t _hybrid_handoff[MOTA_HYBRID_AUTH_LEN] = {0}; + bool _hybrid_handoff_ready = false; }; } // namespace ota diff --git a/test/fixtures/ota_store_flash_nrf52_hybrid/mocks/flash/flash_nrf5x.h b/test/fixtures/ota_store_flash_nrf52_hybrid/mocks/flash/flash_nrf5x.h new file mode 100644 index 00000000..248692da --- /dev/null +++ b/test/fixtures/ota_store_flash_nrf52_hybrid/mocks/flash/flash_nrf5x.h @@ -0,0 +1,14 @@ +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +int flash_nrf5x_write(uint32_t address, const void* data, uint32_t length); +void flash_nrf5x_flush(void); + +#ifdef __cplusplus +} +#endif diff --git a/test/fixtures/ota_store_flash_nrf52_hybrid/mocks/nrf.h b/test/fixtures/ota_store_flash_nrf52_hybrid/mocks/nrf.h new file mode 100644 index 00000000..137d29f0 --- /dev/null +++ b/test/fixtures/ota_store_flash_nrf52_hybrid/mocks/nrf.h @@ -0,0 +1,6 @@ +#pragma once + +#include + +#define __DMB() std::atomic_signal_fence(std::memory_order_seq_cst) +#define __DSB() std::atomic_signal_fence(std::memory_order_seq_cst) diff --git a/test/fixtures/ota_store_flash_nrf52_hybrid/test_ota_store_flash_nrf52_hybrid.cpp b/test/fixtures/ota_store_flash_nrf52_hybrid/test_ota_store_flash_nrf52_hybrid.cpp new file mode 100644 index 00000000..980d5be7 --- /dev/null +++ b/test/fixtures/ota_store_flash_nrf52_hybrid/test_ota_store_flash_nrf52_hybrid.cpp @@ -0,0 +1,251 @@ +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#include +#else +#include +#endif + +// Exercise the production nRF52 store, including its fixed-address flash and +// reset-retained SRAM bridge, with process-local mappings at the target +// addresses. Compile-time test overrides model the application linker bounds +// without relying on platform-specific absolute linker symbols. + +static bool g_fail_next_flash_write = false; +static uint32_t g_flash_writes = 0; + +extern "C" int flash_nrf5x_write(uint32_t address, const void* data, + uint32_t length) { + ++g_flash_writes; + if (g_fail_next_flash_write) { + g_fail_next_flash_write = false; + return -1; + } + std::memcpy(reinterpret_cast(static_cast(address)), data, + length); + return static_cast(length); +} + +extern "C" void flash_nrf5x_flush(void) {} + +#include "../../../src/helpers/ota/OtaStoreFlashNrf52.cpp" + +namespace mesh { +namespace ota { + +bool ota_self_firmware(SelfFwInfo& out) { + out = SelfFwInfo(); + out.valid = true; + out.image_len = 0x00080000u; + return true; +} + +bool mota_parse_manifest(const uint8_t*, uint32_t, MotaManifest&) { + return false; +} + +} // namespace ota +} // namespace mesh + +#define CHECK(condition) do { if (!(condition)) { \ + std::fprintf(stderr, "FAIL line %d: %s\n", __LINE__, #condition); \ + std::exit(1); \ +} } while (0) + +static constexpr uintptr_t FLASH_MAP_START = 0x00020000u; +static constexpr size_t FLASH_MAP_SIZE = 0x000E0000u; +static constexpr uintptr_t RAM_MAP_START = 0x20000000u; +static constexpr size_t RAM_MAP_SIZE = 0x00040000u; + +static void map_target_region(uintptr_t address, size_t length) { +#if defined(_WIN32) + void* const mapped = VirtualAlloc(reinterpret_cast(address), length, + MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE); +#else + void* const mapped = mmap(reinterpret_cast(address), length, + PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED, -1, 0); + if (mapped == MAP_FAILED) { + std::perror("mmap"); + std::exit(1); + } +#endif + CHECK(mapped == reinterpret_cast(address)); +} + +static void install_bootloader_capabilities() { + using namespace mesh::ota; + auto* const bootloader = reinterpret_cast( + static_cast(MOTA_NRF52_BL_START)); + std::memset(bootloader, 0xFF, + MOTA_NRF52_BL_END - MOTA_NRF52_BL_START); + + std::memcpy(bootloader, OTA_BL_MAGIC, sizeof(OTA_BL_MAGIC)); + std::memset(bootloader + 8, 0, 8); + bootloader[8] = 3; + bootloader[10] = 1u << 2; + bootloader[12] = OTA_BL_STORAGE_STAGE_CEILING; + + uint8_t* const ram_cap = bootloader + 32; + std::memcpy(ram_cap, MOTA_RAM_CAP_MAGIC, sizeof(MOTA_RAM_CAP_MAGIC)); + mota_hybrid_wr16(ram_cap + 8, MOTA_RAM_CAP_ABI); + mota_hybrid_wr16(ram_cap + 10, MOTA_HYBRID_AUTH_LEN); + mota_hybrid_wr32(ram_cap + 12, MOTA_NRF52_HYBRID_RAM_SIZE); +} + +static std::vector patterned_container(uint32_t total) { + using namespace mesh::ota; + std::vector bytes(total); + for (uint32_t i = 0; i < total; ++i) + bytes[i] = static_cast((i * 37u + 11u) & 0xFFu); + std::memcpy(bytes.data(), MOTA_MAGIC, sizeof(MOTA_MAGIC)); + bytes[4] = static_cast(total); + bytes[5] = static_cast(total >> 8); + bytes[6] = static_cast(total >> 16); + bytes[7] = static_cast(total >> 24); + return bytes; +} + +static void reset_target_memory() { + std::memset(reinterpret_cast(FLASH_MAP_START), 0xFF, FLASH_MAP_SIZE); + std::memset(reinterpret_cast(RAM_MAP_START), 0, RAM_MAP_SIZE); + install_bootloader_capabilities(); + g_fail_next_flash_write = false; + g_flash_writes = 0; +} + +static void full_hybrid_lifecycle_crosses_the_backing_boundary() { + using namespace mesh::ota; + reset_target_memory(); + const uint32_t total = MOTA_NRF52_HYBRID_RAM_SIZE + + MOTA_NRF52_FLASH_PAGE + 123u; + const uint32_t payload_offset = 205u; + const uint32_t payload_size = total - payload_offset - 5u; + const auto bytes = patterned_container(total); + uint8_t hash[32]; + for (uint8_t i = 0; i < sizeof(hash); ++i) hash[i] = i; + + OtaStoreFlashNrf52 store; + CHECK(store.plan_layout(false, 0x81000u, payload_offset, payload_size, false)); + CHECK(store.begin(total)); + CHECK(store.is_hybrid()); + CHECK(store.write_start() == 0x000EB000u); + CHECK(store.flash_len() == 2u * MOTA_NRF52_FLASH_PAGE); + CHECK(store.ram_len() == total - store.flash_len()); + + CHECK(store.write(0, bytes.data(), total)); + std::vector readback(total); + CHECK(store.read(0, readback.data(), total)); + CHECK(readback == bytes); + CHECK(store.finalize()); + CHECK(store.data() == nullptr); + CHECK(store.read(0, readback.data(), total)); + CHECK(readback == bytes); + + auto* const auth = reinterpret_cast(MOTA_HYBRID_AUTH_ADDR); + for (uint32_t i = 0; i < MOTA_HYBRID_AUTH_LEN; ++i) CHECK(auth[i] == 0); + CHECK(store.approve_for_application(hash)); + CHECK(std::memcmp(reinterpret_cast(store.write_start() + 8u + + MOTA_OFF_APPROVAL), APPROVAL_YES, + sizeof(APPROVAL_YES)) == 0); + for (uint32_t i = 0; i < MOTA_HYBRID_AUTH_LEN; ++i) CHECK(auth[i] == 0); + CHECK(store.publish_hybrid_handoff()); + CHECK(mota_hybrid_auth_valid(auth)); + CHECK(mota_hybrid_rd32(auth + 16u) == total); + CHECK(mota_hybrid_rd32(auth + 20u) == store.write_start()); + CHECK(mota_hybrid_rd32(auth + 24u) == store.flash_len()); + CHECK(mota_hybrid_rd32(auth + 28u) == store.ram_len()); + CHECK(std::memcmp(auth + 32u, hash, sizeof(hash)) == 0); +} + +static void split_trailer_is_committed_to_flash_and_ram() { + using namespace mesh::ota; + reset_target_memory(); + const uint32_t total = MOTA_NRF52_FLASH_PAGE + 1u; + const uint32_t payload_offset = 205u; + const auto bytes = patterned_container(total); + uint8_t hash[32] = {0xA5}; + + OtaStoreFlashNrf52 store; + CHECK(store.plan_layout(false, 0x80000u, payload_offset, + total - payload_offset - 5u, false)); + CHECK(store.begin(total)); + CHECK(store.flash_len() == MOTA_NRF52_FLASH_PAGE); + CHECK(store.ram_len() == 1u); + CHECK(store.write(0, bytes.data(), total)); + CHECK(store.finalize()); + + const uint32_t trailer = total - 5u; + CHECK(std::memcmp(reinterpret_cast(store.write_start() + trailer), + bytes.data() + trailer, 4u) == 0); + CHECK(*reinterpret_cast(MOTA_NRF52_HYBRID_RAM_START) == + bytes.back()); + CHECK(store.approve_for_application(hash)); + CHECK(store.publish_hybrid_handoff()); +} + +static void failed_persistence_never_publishes_a_handoff() { + using namespace mesh::ota; + reset_target_memory(); + const uint32_t total = MOTA_NRF52_FLASH_PAGE + 1u; + const uint32_t payload_offset = 205u; + const auto bytes = patterned_container(total); + uint8_t hash[32] = {0x33}; + + OtaStoreFlashNrf52 store; + CHECK(store.plan_layout(false, 0x80000u, payload_offset, + total - payload_offset - 5u, false)); + CHECK(store.begin(total)); + CHECK(store.write(0, bytes.data(), total)); + g_fail_next_flash_write = true; + CHECK(!store.finalize()); + CHECK(!store.approve_for_application(hash)); + CHECK(!store.publish_hybrid_handoff()); + const auto* const auth = reinterpret_cast( + MOTA_HYBRID_AUTH_ADDR); + for (uint32_t i = 0; i < MOTA_HYBRID_AUTH_LEN; ++i) CHECK(auth[i] == 0); +} + +static void hybrid_state_is_one_shot_and_not_reopened_after_restart() { + using namespace mesh::ota; + reset_target_memory(); + const uint32_t total = MOTA_NRF52_FLASH_PAGE + 1u; + const uint32_t payload_offset = 205u; + const auto bytes = patterned_container(total); + uint8_t hash[32] = {0x77}; + + OtaStoreFlashNrf52 store; + CHECK(store.plan_layout(false, 0x80000u, payload_offset, + total - payload_offset - 5u, false)); + CHECK(store.begin(total)); + CHECK(store.write(0, bytes.data(), total)); + CHECK(store.approve_for_application(hash)); + CHECK(store.publish_hybrid_handoff()); + CHECK(mota_hybrid_auth_valid(reinterpret_cast( + MOTA_HYBRID_AUTH_ADDR))); + + OtaStoreFlashNrf52 after_restart; + CHECK(!mota_hybrid_auth_valid(reinterpret_cast( + MOTA_HYBRID_AUTH_ADDR))); + CHECK(!after_restart.reopen()); + CHECK(after_restart.staged_size() == 0u); +} + +int main() { + map_target_region(FLASH_MAP_START, FLASH_MAP_SIZE); + map_target_region(RAM_MAP_START, RAM_MAP_SIZE); + full_hybrid_lifecycle_crosses_the_backing_boundary(); + std::puts("PASS: hybrid boundary lifecycle"); + split_trailer_is_committed_to_flash_and_ram(); + std::puts("PASS: split trailer"); + failed_persistence_never_publishes_a_handoff(); + std::puts("PASS: persistence failure stays closed"); + hybrid_state_is_one_shot_and_not_reopened_after_restart(); + std::puts("PASS: restart cannot reopen volatile suffix"); + std::puts("4 OtaStoreFlashNrf52 hybrid lifecycle checks passed"); + return 0; +} diff --git a/test/test_nrf52_internal_bootloader_link.py b/test/test_nrf52_internal_bootloader_link.py new file mode 100644 index 00000000..397b467a --- /dev/null +++ b/test/test_nrf52_internal_bootloader_link.py @@ -0,0 +1,283 @@ +#!/usr/bin/env python3 +"""Build-wiring and memory-map contracts for the nRF52840 hybrid mOTA linkers.""" + +import ast +import contextlib +import io +import os +from pathlib import Path +import re +import runpy +import unittest +from unittest import mock + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "nrf52_internal_bootloader_link.py" +V6_TARGET = "RAK_3401_repeater_lora_ota_no_external_sensors" +V7_TARGET = "KeepteenLT1_repeater_lora_ota_no_external_sensors" + + +class FakeBoardConfig: + def __init__(self, values): + self.values = dict(values) + self.updates = [] + + def get(self, key, default=None): + return self.values.get(key, default) + + def update(self, key, value): + self.values[key] = value + self.updates.append((key, value)) + + +class FakeEnvironment: + def __init__(self, project, pioenv, board): + self.values = { + "PROJECT_DIR": str(project), + "PIOENV": pioenv, + } + self.board = board + self.cppdefines = [] + self.replacements = {} + + def __getitem__(self, key): + return self.values[key] + + def BoardConfig(self): + return self.board + + def AppendUnique(self, **values): + self.assert_only_key(values, "CPPDEFINES") + for value in values["CPPDEFINES"]: + if value not in self.cppdefines: + self.cppdefines.append(value) + + def Replace(self, **values): + self.replacements.update(values) + + @staticmethod + def assert_only_key(values, expected): + if set(values) != {expected}: + raise AssertionError(f"unexpected fake-SCons call: {values!r}") + + +def run_selector(*, pioenv, board_values, project=ROOT, requested=False): + board = FakeBoardConfig(board_values) + environment = FakeEnvironment(project, pioenv, board) + process_environment = ( + {"MESHCORE_NRF52_INTERNAL_BOOTLOADER_UPDATE": "1"} + if requested else {} + ) + output = io.StringIO() + with mock.patch.dict(os.environ, process_environment, clear=True): + with contextlib.redirect_stdout(output): + runpy.run_path( + str(SCRIPT), + init_globals={ + "Import": lambda name: None, + "env": environment, + }, + ) + return environment, output.getvalue() + + +def integer_expression(expression): + """Evaluate the integer +/- expressions used by these MEMORY declarations.""" + tree = ast.parse(expression.strip(), mode="eval") + + def evaluate(node): + if isinstance(node, ast.Expression): + return evaluate(node.body) + if isinstance(node, ast.Constant) and type(node.value) is int: + return node.value + if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.UAdd, ast.USub)): + value = evaluate(node.operand) + return value if isinstance(node.op, ast.UAdd) else -value + if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Add, ast.Sub)): + left = evaluate(node.left) + right = evaluate(node.right) + return left + right if isinstance(node.op, ast.Add) else left - right + raise ValueError(f"unsupported linker expression: {expression!r}") + + return evaluate(tree) + + +def memory_regions(path): + text = path.read_text(encoding="utf-8") + match = re.search(r"\bMEMORY\s*\{(?P.*?)\}\s*SECTIONS\b", text, re.S) + if not match: + raise AssertionError(f"no MEMORY block in {path}") + regions = {} + pattern = re.compile( + r"^\s*(?P[A-Za-z_]\w*)\s*\([^)]*\)\s*:\s*" + r"ORIGIN\s*=\s*(?P.*?),\s*LENGTH\s*=\s*(?P.*?)\s*$", + re.M, + ) + for region in pattern.finditer(match.group("body")): + regions[region.group("name")] = ( + integer_expression(region.group("origin")), + integer_expression(region.group("length")), + ) + return regions + + +def c_integer(path, name): + text = path.read_text(encoding="utf-8") + match = re.search( + rf"\b{re.escape(name)}\s*=\s*(0x[0-9A-Fa-f]+|[0-9]+)u?\s*;", + text, + ) + if not match: + raise AssertionError(f"cannot find {name} in {path}") + return int(match.group(1), 0) + + +class Nrf52InternalBootloaderSelectorTest(unittest.TestCase): + def test_direct_v6_target_selects_hybrid_linker_and_both_features(self): + environment, output = run_selector( + pioenv=V6_TARGET, + board_values={ + "build.mcu": "nrf52840", + "build.ldscript": "boards/nrf52840_s140_v6.ld", + }, + ) + expected = str(ROOT / "boards" / "nrf52840_s140_v6_mota64.ld") + self.assertEqual( + environment.cppdefines, + ["OTA_INTERNAL_BOOTLOADER_UPDATE", "OTA_HYBRID_RAM_STORE"], + ) + self.assertEqual(environment.board.values["build.ldscript"], expected) + self.assertEqual(environment.replacements, {"LDSCRIPT_PATH": expected}) + self.assertIn("nrf52840_s140_v6_mota64.ld", output) + + def test_arduino_v7_fallback_selects_v7_hybrid_linker(self): + environment, output = run_selector( + pioenv=V7_TARGET, + board_values={ + "build.mcu": "NRF52840", + "build.arduino.ldscript": "nrf52840_s140_v7.ld", + }, + ) + expected = str(ROOT / "boards" / "nrf52840_s140_v7_mota64.ld") + self.assertEqual(environment.board.updates, [("build.ldscript", expected)]) + self.assertEqual(environment.replacements["LDSCRIPT_PATH"], expected) + self.assertIn("nrf52840_s140_v7_mota64.ld", output) + + def test_build_sh_environment_flag_activates_a_synthetic_alias(self): + environment, _ = run_selector( + pioenv="Heltec_tower_v2_repeater", + requested=True, + board_values={ + "build.mcu": "nrf52840", + "build.ldscript": "boards/nrf52840_s140_v6.ld", + }, + ) + self.assertIn("OTA_HYBRID_RAM_STORE", environment.cppdefines) + self.assertTrue(environment.replacements["LDSCRIPT_PATH"].endswith("_v6_mota64.ld")) + + def test_unselected_nrf52_environment_is_untouched(self): + environment, output = run_selector( + pioenv="RAK_3401_repeater", + board_values={ + "build.mcu": "nrf52840", + "build.ldscript": "boards/nrf52840_s140_v6.ld", + }, + ) + self.assertEqual(environment.cppdefines, []) + self.assertEqual(environment.board.updates, []) + self.assertEqual(environment.replacements, {}) + self.assertEqual(output, "") + + def test_wrong_mcu_and_nonstandard_linkers_fail_closed(self): + cases = ( + ("nrf52832", "boards/nrf52840_s140_v6.ld"), + ("nrf52840", "boards/nrf52840_s140_v6_extrafs.ld"), + ("nrf52840", "boards/custom_nrf52840_s140_v6.ld"), + ("nrf52840", ""), + ) + for mcu, linker in cases: + with self.subTest(mcu=mcu, linker=linker): + with self.assertRaisesRegex(RuntimeError, "normal S140 v6/v7"): + run_selector( + pioenv=V6_TARGET, + board_values={ + "build.mcu": mcu, + "build.ldscript": linker, + }, + ) + + def test_missing_selected_linker_fails_before_mutating_board(self): + board = FakeBoardConfig({ + "build.mcu": "nrf52840", + "build.ldscript": "nrf52840_s140_v6.ld", + }) + environment = FakeEnvironment(ROOT, V6_TARGET, board) + with mock.patch.object(Path, "is_file", return_value=False): + with mock.patch.dict(os.environ, {}, clear=True): + with self.assertRaisesRegex(RuntimeError, "missing mOTA hybrid linker"): + runpy.run_path( + str(SCRIPT), + init_globals={ + "Import": lambda name: None, + "env": environment, + }, + ) + self.assertEqual(board.updates, []) + self.assertEqual(environment.replacements, {}) + + +class Nrf52HybridLinkerContractTest(unittest.TestCase): + def test_v6_and_v7_maps_match_runtime_handoff_abi(self): + handoff = ROOT / "src" / "helpers" / "ota" / "OtaHybridHandoff.h" + layout = ROOT / "src" / "helpers" / "ota" / "OtaFlashLayout_nrf52.h" + auth_start = c_integer(handoff, "MOTA_HYBRID_AUTH_ADDR") + auth_len = c_integer(handoff, "MOTA_HYBRID_AUTH_LEN") + arena_start = c_integer(layout, "MOTA_NRF52_HYBRID_RAM_START") + arena_len = c_integer(layout, "MOTA_NRF52_HYBRID_RAM_SIZE") + + for version, flash_start in (("v6", 0x26000), ("v7", 0x27000)): + with self.subTest(version=version): + hybrid_path = ROOT / "boards" / f"nrf52840_s140_{version}_mota64.ld" + normal_path = ROOT / "boards" / f"nrf52840_s140_{version}.ld" + hybrid = memory_regions(hybrid_path) + normal = memory_regions(normal_path) + + self.assertEqual(hybrid["FLASH"], normal["FLASH"]) + self.assertEqual(hybrid["FLASH"], (flash_start, 0xED000 - flash_start)) + self.assertEqual(hybrid["PERSISTENT_RAM"], (0x20006000, 8 + auth_len)) + self.assertEqual(auth_start, 0x20006000 + 8) + self.assertEqual(hybrid["RAM"][0], auth_start + auth_len) + self.assertEqual(hybrid["RAM"][0] + hybrid["RAM"][1], arena_start) + self.assertEqual(hybrid["MOTA_RAM"], (arena_start, arena_len)) + self.assertEqual(arena_start + arena_len, 0x20040000) + self.assertEqual( + normal["RAM"][1] - hybrid["RAM"][1], + arena_len + auth_len, + ) + + def test_linkers_export_and_assert_every_fixed_handoff_boundary(self): + required = ( + "__mota_hybrid_handoff_start__ = ADDR(.persistent) + SIZEOF(.persistent);", + "__mota_hybrid_handoff_end__ = ORIGIN(PERSISTENT_RAM) + LENGTH(PERSISTENT_RAM);", + "ASSERT(ADDR(.persistent) == 0x20006000,", + "ASSERT(SIZEOF(.persistent) == 8,", + "ASSERT(__mota_hybrid_handoff_start__ == 0x20006008,", + "ASSERT(__mota_hybrid_handoff_end__ == 0x20006050,", + "ASSERT(__mota_hybrid_handoff_end__ - __mota_hybrid_handoff_start__ == 72,", + "ASSERT(ORIGIN(RAM) == __mota_hybrid_handoff_end__,", + "ASSERT(ORIGIN(RAM) + LENGTH(RAM) == ORIGIN(MOTA_RAM),", + "ASSERT(ORIGIN(MOTA_RAM) + LENGTH(MOTA_RAM) == 0x20040000,", + ".mota_ram (NOLOAD)", + 'INCLUDE "nrf52_common.ld"', + ) + for version in ("v6", "v7"): + with self.subTest(version=version): + path = ROOT / "boards" / f"nrf52840_s140_{version}_mota64.ld" + text = path.read_text(encoding="utf-8") + for contract in required: + self.assertIn(contract, text) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_ota/test_ota_core.cpp b/test/test_ota/test_ota_core.cpp index ed979ba1..cffdafa9 100644 --- a/test/test_ota/test_ota_core.cpp +++ b/test/test_ota/test_ota_core.cpp @@ -39,7 +39,7 @@ TEST(OtaBootResult, AcceptsOnlyOtafixApplyDiagnostics) { EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xB0), 0xB0); EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xB8), 0xB8); EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xBC), 0xBC); - EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xBD), 0xBD); // SD authorization failure + EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xBD), 0xBD); // retained-source authorization/handoff failure EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xC0), 0xC0); EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xC8), 0xC8); EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xCF), 0xCF); @@ -673,6 +673,96 @@ TEST(OtaBootPackage, CapabilityScannerRejectsAnOtherwiseValidUnalignedMarker) { EXPECT_FALSE(ota_bl_caps_scan_aligned(image, sizeof(image), true, qspi_profile).present); } +TEST(OtaHybridCapability, RequiresOneExactAlignedMOTARAMARecord) { + uint8_t marker[16] = {0}; + memcpy(marker, MOTA_RAM_CAP_MAGIC, sizeof(MOTA_RAM_CAP_MAGIC)); + mota_hybrid_wr16(marker + 8u, MOTA_RAM_CAP_ABI); + mota_hybrid_wr16(marker + 10u, MOTA_HYBRID_AUTH_LEN); + mota_hybrid_wr32(marker + 12u, MOTA_NRF52_HYBRID_RAM_SIZE); + + OtaRamCaps parsed; + ASSERT_TRUE(ota_ram_caps_marker_parse(marker, parsed)); + EXPECT_TRUE(ota_bootloader_supports_hybrid(parsed)); + EXPECT_EQ(parsed.abi, MOTA_RAM_CAP_ABI); + EXPECT_EQ(parsed.record_len, MOTA_HYBRID_AUTH_LEN); + EXPECT_EQ(parsed.arena_size, MOTA_NRF52_HYBRID_RAM_SIZE); + + uint8_t image[64]; + memset(image, 0xFF, sizeof(image)); + memcpy(image + 1u, marker, sizeof(marker)); + EXPECT_FALSE(ota_ram_caps_scan_aligned(image, sizeof(image)).present); + + memset(image, 0xFF, sizeof(image)); + memcpy(image + 4u, marker, sizeof(marker)); + EXPECT_TRUE(ota_bootloader_supports_hybrid( + ota_ram_caps_scan_aligned(image, sizeof(image)))); + + // Two valid capabilities are ambiguous; a malformed decoy is ignored. + memcpy(image + 24u, marker, sizeof(marker)); + EXPECT_FALSE(ota_ram_caps_scan_aligned(image, sizeof(image)).present); + image[24u + 12u] ^= 1u; + EXPECT_TRUE(ota_ram_caps_scan_aligned(image, sizeof(image)).present); + + uint8_t bad[sizeof(marker)]; + memcpy(bad, marker, sizeof(bad)); + bad[8] = 2u; + EXPECT_FALSE(ota_ram_caps_marker_parse(bad, parsed)); + memcpy(bad, marker, sizeof(bad)); + bad[10] = (uint8_t)(MOTA_HYBRID_AUTH_LEN - 1u); + EXPECT_FALSE(ota_ram_caps_marker_parse(bad, parsed)); + memcpy(bad, marker, sizeof(bad)); + bad[12] ^= 1u; + EXPECT_FALSE(ota_ram_caps_marker_parse(bad, parsed)); +} + +TEST(OtaHybridHandoff, EncodesExactGeometryAndRejectsCorruption) { + const uint32_t total = 17u * MOTA_NRF52_FLASH_PAGE; + uint32_t start = 0, flash = 0, ram = 0; + ASSERT_TRUE(mota_nrf52_hybrid_stage_plan( + total, MOTA_NRF52_APP_BASE_S140_V6, + MOTA_NRF52_APP_BASE_S140_V6 + 512u * 1024u, + MOTA_NRF52_STAGE_CEILING_EXPANDED, start, flash, ram)); + ASSERT_EQ(flash, MOTA_NRF52_FLASH_PAGE); + ASSERT_EQ(ram, MOTA_NRF52_HYBRID_RAM_SIZE); + + uint8_t hash[32]; + for (uint8_t i = 0; i < sizeof(hash); ++i) hash[i] = (uint8_t)(i * 7u); + uint8_t record[MOTA_HYBRID_AUTH_LEN]; + ASSERT_TRUE(mota_hybrid_auth_encode( + record, total, start, flash, ram, hash)); + EXPECT_TRUE(mota_hybrid_auth_valid(record)); + EXPECT_EQ(memcmp(record, MOTA_HYBRID_AUTH_MAGIC, 8u), 0); + EXPECT_EQ(mota_hybrid_rd16(record + 8u), MOTA_HYBRID_AUTH_VERSION); + EXPECT_EQ(mota_hybrid_rd16(record + 10u), MOTA_HYBRID_AUTH_LEN); + EXPECT_EQ(record[12], MOTA_HYBRID_AUTH_PURPOSE_APP); + EXPECT_EQ(record[13], MOTA_HYBRID_AUTH_FORMAT_APP); + EXPECT_EQ(mota_hybrid_rd32(record + 16u), total); + EXPECT_EQ(mota_hybrid_rd32(record + 20u), start); + EXPECT_EQ(mota_hybrid_rd32(record + 24u), flash); + EXPECT_EQ(mota_hybrid_rd32(record + 28u), ram); + EXPECT_EQ(memcmp(record + 32u, hash, sizeof(hash)), 0); + const uint32_t crc = mota_hybrid_rd32(record + 64u); + EXPECT_EQ(crc, mota_hybrid_crc32(record, 64u)); + EXPECT_EQ(mota_hybrid_rd32(record + 68u), ~crc); + + record[32] ^= 1u; + EXPECT_FALSE(mota_hybrid_auth_valid(record)); + record[32] ^= 1u; + record[68] ^= 1u; + EXPECT_FALSE(mota_hybrid_auth_valid(record)); + + // The descriptor accepts only the frozen minimum flash charge and ED000 + // ceiling. Legacy-D4000 or overcharged splits cannot be authorized. + EXPECT_FALSE(mota_hybrid_auth_encode( + record, total, start - MOTA_NRF52_FLASH_PAGE, + flash + MOTA_NRF52_FLASH_PAGE, + ram - MOTA_NRF52_FLASH_PAGE, hash)); + EXPECT_FALSE(mota_hybrid_auth_encode( + record, total, + MOTA_NRF52_STAGE_CEILING_LEGACY - MOTA_NRF52_FLASH_PAGE, + MOTA_NRF52_FLASH_PAGE, ram, hash)); +} + TEST(OtaBootPackage, LegacyAndCurrentBootloadersHaveSeparateCapabilityViews) { const uint8_t internal_profile = OTA_BL_PROFILE_INTERNAL_BOOT_UPDATE; const uint8_t legacy_marker[16] = { diff --git a/test/test_ota/test_ota_flashplan.cpp b/test/test_ota/test_ota_flashplan.cpp index 15449ba5..2ce6278b 100644 --- a/test/test_ota/test_ota_flashplan.cpp +++ b/test/test_ota/test_ota_flashplan.cpp @@ -183,6 +183,60 @@ TEST(OtaFlashPlan, ExpandedCeilingAddsExactly100KiBForV6AndV7) { EXPECT_EQ(start + CAP_V7_EXPANDED, EXPANDED); } +TEST(OtaFlashPlan, HybridUsesTheFrozenFlashChargeAndRamSuffix) { + uint32_t start = 0, flash = 0, ram = 0; + const uint32_t page = MOTA_NRF52_FLASH_PAGE; + ASSERT_TRUE(mota_nrf52_hybrid_stage_plan( + page + 1u, APP_V6, APP_END_V6, EXPANDED, start, flash, ram)); + EXPECT_EQ(start, EXPANDED - page); + EXPECT_EQ(flash, page); + EXPECT_EQ(ram, 1u); + + ASSERT_TRUE(mota_nrf52_hybrid_stage_plan( + 17u * page, APP_V6, APP_END_V6, EXPANDED, start, flash, ram)); + EXPECT_EQ(start, EXPANDED - page); + EXPECT_EQ(flash, page); + EXPECT_EQ(ram, MOTA_NRF52_HYBRID_RAM_SIZE); + + ASSERT_TRUE(mota_nrf52_hybrid_stage_plan( + 17u * page + 1u, APP_V6, APP_END_V6, + EXPANDED, start, flash, ram)); + EXPECT_EQ(start, EXPANDED - 2u * page); + EXPECT_EQ(flash, 2u * page); + EXPECT_EQ(ram, 15u * page + 1u); + + ASSERT_TRUE(mota_nrf52_hybrid_stage_plan( + 17u * page, APP_V7, APP_END_V7, + EXPANDED, start, flash, ram)); + EXPECT_EQ(start, EXPANDED - page); + EXPECT_EQ(flash, page); + EXPECT_EQ(ram, MOTA_NRF52_HYBRID_RAM_SIZE); +} + +TEST(OtaFlashPlan, HybridFailsClosedOutsideItsExactProfile) { + uint32_t start = 0xAAAAAAAAu, flash = 0xBBBBBBBBu, ram = 0xCCCCCCCCu; + const uint32_t page = MOTA_NRF52_FLASH_PAGE; + EXPECT_FALSE(mota_nrf52_hybrid_stage_plan( + MOTA_NRF52_CONTAINER_MIN_SIZE, APP_V6, APP_END_V6, + EXPANDED, start, flash, ram)); + EXPECT_FALSE(mota_nrf52_hybrid_stage_plan( + page, APP_V6, APP_END_V6, EXPANDED, start, flash, ram)); + EXPECT_FALSE(mota_nrf52_hybrid_stage_plan( + 17u * page, APP_V6, APP_END_V6, LEGACY, start, flash, ram)); + EXPECT_FALSE(mota_nrf52_hybrid_stage_plan( + 17u * page + 1u, APP_V6, EXPANDED - page, + EXPANDED, start, flash, ram)); + EXPECT_FALSE(mota_nrf52_hybrid_stage_plan( + 17u * page, 0x00028000u, 0x00028000u, + EXPANDED, start, flash, ram)); + EXPECT_FALSE(mota_nrf52_hybrid_stage_plan( + UINT32_MAX, APP_V6, APP_END_V6, + EXPANDED, start, flash, ram)); + EXPECT_EQ(start, 0xAAAAAAAAu); + EXPECT_EQ(flash, 0xBBBBBBBBu); + EXPECT_EQ(ram, 0xCCCCCCCCu); +} + TEST(OtaFlashPlan, RejectsAppOutsideSelectedRegion) { uint32_t start = 0x1234ABCD; EXPECT_FALSE(mota_nrf52_stage_plan(4096, APP_V6, LEGACY + 1, LEGACY, start)); diff --git a/test/test_ota_dev_reboot_guard.py b/test/test_ota_dev_reboot_guard.py index 3d117ff0..19ee74ce 100644 --- a/test/test_ota_dev_reboot_guard.py +++ b/test/test_ota_dev_reboot_guard.py @@ -61,6 +61,32 @@ class OtaDevRebootGuardContractTest(unittest.TestCase): reboot_at = maintenance.index("ota::ota_reboot_to_apply()") self.assertLess(guard_at, reboot_at) + def test_dev_clear_cannot_mutate_an_armed_update(self): + cli = (ROOT / "src/helpers/ota/OtaCli.cpp").read_text() + dev = function_body( + cli, + "static bool handle_dev(const char* d, char* reply, OtaContext& c) {", + ) + + clear_at = dev.index('strncmp(d, "clear", 5)') + clear = dev[ + clear_at : dev.index( + '} else {\n strcpy(reply, "ota dev:', clear_at + ) + ] + guard_at = clear.index("if (c.apply_pending)") + mutations = ( + "c.manager.clear_primary()", + "c.releaseServeBuffer()", + "c.manager.reset_session()", + "c.fetch_store.discard()", + ) + for mutation in mutations: + self.assertLess(guard_at, clear.index(mutation), mutation) + guard = clear[guard_at : clear.index("const bool was_folder")] + self.assertIn('strcpy(reply, "ERR update is armed; reboot is pending")', guard) + self.assertIn("return true", guard) + if __name__ == "__main__": unittest.main() diff --git a/test/test_ota_store_flash_nrf52_hybrid.py b/test/test_ota_store_flash_nrf52_hybrid.py new file mode 100644 index 00000000..a3e7cad0 --- /dev/null +++ b/test/test_ota_store_flash_nrf52_hybrid.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Execute the real nRF52 flash/SRAM hybrid staging store on host mappings.""" + +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +FIXTURE = ROOT / "test/fixtures/ota_store_flash_nrf52_hybrid" + + +class OtaStoreFlashNrf52HybridTest(unittest.TestCase): + def test_real_store_hybrid_lifecycle(self): + compiler = shutil.which("g++") or shutil.which("clang++") + if compiler is None: + self.skipTest("a host C++17 compiler is required") + # Build outside the checkout. On Windows an application-control policy + # may deny linker output or execution from newly-created repository + # subdirectories, and a denied executable can leave that directory + # locked during TemporaryDirectory cleanup. + with tempfile.TemporaryDirectory(prefix="meshcore-ota-hybrid-") as directory: + binary = Path(directory) / "ota-store-hybrid.exe" + compiled = subprocess.run( + [ + compiler, + "-std=c++17", + "-Wall", + "-Wextra", + "-Werror", + "-DNRF52_PLATFORM=1", + "-DOTA_FLASH_STORE=1", + "-DOTA_HYBRID_RAM_STORE=1", + "-DMOTA_NRF52_TEST_APP_BASE=0x00026000u", + "-DMOTA_NRF52_TEST_LAYOUT_STAGE_CEILING=0x000ED000u", + f"-I{FIXTURE / 'mocks'}", + f"-I{ROOT / 'src'}", + str(FIXTURE / "test_ota_store_flash_nrf52_hybrid.cpp"), + "-o", + str(binary), + ], + capture_output=True, + text=True, + timeout=60, + ) + self.assertEqual(compiled.returncode, 0, compiled.stdout + compiled.stderr) + checked = subprocess.run( + [str(binary)], capture_output=True, text=True, timeout=10 + ) + self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr) + self.assertIn( + "4 OtaStoreFlashNrf52 hybrid lifecycle checks passed", + checked.stdout, + ) + self.assertEqual(checked.stdout.count("PASS:"), 4) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/mota/motalib.py b/tools/mota/motalib.py index 506791b1..49307501 100644 --- a/tools/mota/motalib.py +++ b/tools/mota/motalib.py @@ -73,6 +73,7 @@ NRF52_APP_END = 0x000ED000 NRF52_BOOT_CONTAINER_SIZE = 41330 NRF52_SHARED_BOOT_STAGE_START = 0x000E2000 NRF52_FLASH_PAGE = 4096 +NRF52_HYBRID_RAM_SIZE = 65536 # A validated nRF52 firmware carries this record immediately before EndF. It lets an offline packager # derive the actual app base and staging ceiling from the built artifact, without a board-name allowlist. @@ -84,6 +85,7 @@ NRF52_LAYOUT_FLAG_SD = 0x01 NRF52_LAYOUT_FLAG_INTERNAL_EXTRAFS = 0x02 NRF52_LAYOUT_FLAG_QSPI = 0x04 NRF52_LAYOUT_FLAG_BOOTLOADER_SCRATCH = 0x08 +NRF52_LAYOUT_FLAG_HYBRID_RAM = 0x10 XIAO_BOOT_BOARD_ID_BASE = 0x28860044 XIAO_BOOT_BOARD_ID_SENSE = 0x28860045 @@ -302,6 +304,10 @@ class Nrf52Layout: def bootloader_scratch(self) -> bool: return bool(self.flags & NRF52_LAYOUT_FLAG_BOOTLOADER_SCRATCH) + @property + def hybrid_ram(self) -> bool: + return bool(self.flags & NRF52_LAYOUT_FLAG_HYBRID_RAM) + def nrf52_stage_ceiling_for_layout(linked_app_end: int, uses_internal_extrafs: bool) -> int: """Select a safe staging ceiling from linker geometry and actual secondary-storage type.""" if uses_internal_extrafs: @@ -311,6 +317,33 @@ def nrf52_stage_ceiling_for_layout(linked_app_end: int, uses_internal_extrafs: b return NRF52_EXTRAFS_START +def nrf52_hybrid_stage_plan(total_size: int, app_base: int, app_end: int, + stage_ceiling: int) -> Optional[Tuple[int, int, int]]: + """Return the frozen (flash_start, flash_len, ram_len) split, or None. + + Keep this byte-for-byte equivalent to + mota_nrf52_hybrid_stage_plan() in OtaFlashLayout_nrf52.h. Hybrid is an + expanded ED000-only profile; at least one complete flash page contains the + header, manifest, leaves, and APRV word. + """ + if (stage_ceiling != NRF52_APP_END or + app_base not in (NRF52_APP_BASE_S140_V6, + NRF52_APP_BASE_S140_V7) or + not (app_base <= app_end <= stage_ceiling) or + total_size < 8 + 197 + 5 or total_size > 0xFFFFFFFF): + return None + flash_needed = max(NRF52_FLASH_PAGE, + max(0, total_size - NRF52_HYBRID_RAM_SIZE)) + flash_len = ((flash_needed + NRF52_FLASH_PAGE - 1) // + NRF52_FLASH_PAGE) * NRF52_FLASH_PAGE + if flash_len >= total_size or flash_len > stage_ceiling - app_end: + return None + ram_len = total_size - flash_len + if not (0 < ram_len <= NRF52_HYBRID_RAM_SIZE): + return None + return stage_ceiling - flash_len, flash_len, ram_len + + def build_nrf52_layout(layout: Nrf52Layout) -> bytes: if layout.app_base not in (NRF52_APP_BASE_S140_V6, NRF52_APP_BASE_S140_V7): raise ValueError(f"unsupported nRF52 app base 0x{layout.app_base:X}") @@ -322,13 +355,22 @@ def build_nrf52_layout(layout: Nrf52Layout) -> bytes: if not (layout.app_base < layout.linked_app_end <= NRF52_APP_END): raise ValueError("invalid nRF52 app region") known_flags = (NRF52_LAYOUT_FLAG_SD | NRF52_LAYOUT_FLAG_INTERNAL_EXTRAFS | - NRF52_LAYOUT_FLAG_QSPI | NRF52_LAYOUT_FLAG_BOOTLOADER_SCRATCH) + NRF52_LAYOUT_FLAG_QSPI | NRF52_LAYOUT_FLAG_BOOTLOADER_SCRATCH | + NRF52_LAYOUT_FLAG_HYBRID_RAM) if layout.flags & ~known_flags: raise ValueError(f"unsupported nRF52 layout flags 0x{layout.flags:X}") if layout.sd_backed and layout.qspi_backed: raise ValueError("nRF52 layout cannot use both SD and QSPI staging") if layout.external_backed and layout.flags & NRF52_LAYOUT_FLAG_INTERNAL_EXTRAFS: raise ValueError("nRF52 external staging cannot also reserve internal ExtraFS") + if layout.hybrid_ram: + if (layout.external_backed or + layout.flags & NRF52_LAYOUT_FLAG_INTERNAL_EXTRAFS): + raise ValueError("nRF52 hybrid RAM staging requires internal flash without ExtraFS") + if (layout.linked_app_end != NRF52_APP_END or + layout.stage_ceiling != NRF52_APP_END): + raise ValueError( + "nRF52 hybrid RAM staging requires the exact 0xED000 internal-only profile") if layout.bootloader_scratch: qspi_shape = (layout.qspi_backed and not layout.sd_backed and layout.linked_app_end == NRF52_BOOT_SCRATCH_START) diff --git a/tools/mota/pio_endf.py b/tools/mota/pio_endf.py index 75b4c045..5a3b9010 100644 --- a/tools/mota/pio_endf.py +++ b/tools/mota/pio_endf.py @@ -91,6 +91,57 @@ def _cppdef(name): # value of a -D= return None +def _normalized_linker_path(value, project_dir): + """Resolve one SCons linker-path value without guessing among candidates.""" + if isinstance(value, (list, tuple)): + if len(value) != 1: + return "" + value = value[0] + if hasattr(value, "get_abspath"): + value = value.get_abspath() + raw = str(value or "").strip().strip('"').strip("'") + if not raw: + return "" + if not os.path.isabs(raw): + raw = os.path.join(str(project_dir), raw) + return os.path.normcase(os.path.abspath(raw)) + + +def _validate_nrf52_hybrid_profile(app_start, linked_app_end, + hybrid_ram, internal_bootloader_update, + active_linker, configured_linker, + project_dir): + """Fail closed unless both macros and the dedicated SRAM linker agree.""" + if hybrid_ram != internal_bootloader_update: + raise RuntimeError( + "nRF52 hybrid RAM staging and internal bootloader update must be enabled together") + if not hybrid_ram: + return + + suffix_by_base = { + ml.NRF52_APP_BASE_S140_V6: "v6", + ml.NRF52_APP_BASE_S140_V7: "v7", + } + suffix = suffix_by_base.get(app_start) + if suffix is None or linked_app_end != ml.NRF52_APP_END: + raise RuntimeError( + "nRF52 hybrid RAM staging requires the exact S140 v6/v7 0xED000 profile") + + expected = os.path.join( + str(project_dir), "boards", f"nrf52840_s140_{suffix}_mota64.ld") + expected = _normalized_linker_path(expected, project_dir) + if not expected or not os.path.isfile(expected): + raise RuntimeError("nRF52 hybrid RAM linker is missing") + for label, value in (("active", active_linker), + ("configured", configured_linker)): + actual = _normalized_linker_path(value, project_dir) + if actual != expected: + shown = str(value or "none") + raise RuntimeError( + f"nRF52 hybrid RAM staging requires {expected}; " + f"{label} linker is {shown}") + + def _board_maximum_size(build_env): """Return PlatformIO's resolved application-size limit, including per-env overrides.""" try: @@ -215,8 +266,14 @@ def _append_endf_hex(source, target, env): # Intel-HEX path (nRF52: app f sd_backed = _cppdef("OTA_SD_STORE") is not None qspi_backed = _cppdef("OTA_QSPI_STORE") is not None qspi_bootloader_update = _cppdef("OTA_QSPI_BOOTLOADER_UPDATE") is not None + hybrid_ram = _cppdef("OTA_HYBRID_RAM_STORE") is not None internal_bootloader_update = _cppdef("OTA_INTERNAL_BOOTLOADER_UPDATE") is not None sd_bootloader_update = _cppdef("OTA_SD_BOOTLOADER_UPDATE") is not None + board = env.BoardConfig() + _validate_nrf52_hybrid_profile( + app_start, linked_app_end, hybrid_ram, internal_bootloader_update, + env.get("LDSCRIPT_PATH", ""), board.get("build.ldscript", ""), + env["PROJECT_DIR"]) if sd_backed and qspi_backed: raise RuntimeError("nRF52 build cannot enable both SD and QSPI OTA stores") if qspi_backed and _cppdef("QSPIFLASH") is not None: @@ -242,7 +299,8 @@ def _append_endf_hex(source, target, env): # Intel-HEX path (nRF52: app f layout_flags = ((ml.NRF52_LAYOUT_FLAG_SD if sd_backed else 0) | (ml.NRF52_LAYOUT_FLAG_QSPI if qspi_backed else 0) | (ml.NRF52_LAYOUT_FLAG_INTERNAL_EXTRAFS if internal_extrafs else 0) | - (ml.NRF52_LAYOUT_FLAG_BOOTLOADER_SCRATCH if qspi_bootloader_update else 0)) + (ml.NRF52_LAYOUT_FLAG_BOOTLOADER_SCRATCH if qspi_bootloader_update else 0) | + (ml.NRF52_LAYOUT_FLAG_HYBRID_RAM if hybrid_ram else 0)) layout = ml.Nrf52Layout(app_start, linked_app_end, stage_ceiling, layout_flags) body = ml.ensure_nrf52_layout(raw_body, layout) ident = _firmware_ident() diff --git a/tools/mota/test_mota.py b/tools/mota/test_mota.py index f378c16b..53cee8ad 100644 --- a/tools/mota/test_mota.py +++ b/tools/mota/test_mota.py @@ -60,6 +60,13 @@ def test_internal_bootloader_target_wiring_is_central_and_not_duplicated(): } assert gat_targets <= set(inventory) assert not any("GAT562_Mesh_Watch13" in target for target in inventory) + assert not any("xiao" in target.lower() for target in inventory) + assert not any("sdcard" in target.lower() for target in inventory) + assert { + "Heltec_t096_repeater_lora_ota_no_external_sensors", + "Heltec_t114_repeater_lora_ota_no_external_sensors", + "RAK_3401_repeater_lora_ota_no_external_sensors", + } <= set(inventory) target_header = (root / "src/helpers/ota/OtaTargets.h").read_text(encoding="utf-8") for target in inventory: @@ -75,6 +82,83 @@ def test_internal_bootloader_target_wiring_is_central_and_not_duplicated(): assert "scripts/nrf52_internal_bootloader_link.py" not in recipe assert "export MESHCORE_NRF52_INTERNAL_BOOTLOADER_UPDATE=1" in recipe + selector = (root / "scripts/nrf52_internal_bootloader_link.py").read_text( + encoding="utf-8") + assert '"OTA_HYBRID_RAM_STORE"' in selector + assert "nrf52840_s140_%s_mota64.ld" in selector + assert "env[\"PIOENV\"] in direct_targets" in selector + + for version, app_base in (("v6", "0x26000"), ("v7", "0x27000")): + linker = (root / f"boards/nrf52840_s140_{version}_mota64.ld").read_text( + encoding="utf-8") + assert f"ORIGIN = {app_base}" in linker + assert "PERSISTENT_RAM (rwx) : ORIGIN = 0x20006000, LENGTH = 80" in linker + assert "MOTA_RAM (rwx) : ORIGIN = 0x20030000, LENGTH = 0x10000" in linker + assert ".mota_ram (NOLOAD)" in linker + ordinary = (root / f"boards/nrf52840_s140_{version}.ld").read_text( + encoding="utf-8") + assert "MOTA_RAM" not in ordinary + + # Explicit external-storage and ExtraFS profiles remain on their existing + # linkers and are absent from the exact activation inventory. + tower = (root / "variants/heltec_tower_v2/platformio.ini").read_text( + encoding="utf-8") + assert "[env:Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors]" in tower + assert "board_build.ldscript = boards/nrf52840_s140_v6_sd_ota.ld" in tower + assert "board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld" in tower + xiao = (root / "variants/xiao_nrf52/platformio.ini").read_text( + encoding="utf-8") + assert "board_build.ldscript = boards/nrf52840_s140_v7.ld" in xiao + + +def test_nrf52_hybrid_endf_profile_fails_closed(): + root = Path(__file__).resolve().parents[2] + script = root / "tools/mota/pio_endf.py" + spec = importlib.util.spec_from_file_location("pio_endf_policy_test", script) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + module.Import = lambda _name: None + module.env = {"PROJECT_DIR": str(root), "CPPDEFINES": []} + spec.loader.exec_module(module) + + validate = module._validate_nrf52_hybrid_profile + correct = root / "boards/nrf52840_s140_v6_mota64.ld" + validate(ml.NRF52_APP_BASE_S140_V6, ml.NRF52_APP_END, + True, True, correct, correct, root) + correct_v7 = root / "boards/nrf52840_s140_v7_mota64.ld" + validate(ml.NRF52_APP_BASE_S140_V7, ml.NRF52_APP_END, + True, True, [correct_v7], correct_v7, root) + # Ordinary profiles do not need a linker proof. + validate(ml.NRF52_APP_BASE_S140_V6, ml.NRF52_EXTRAFS_START, + False, False, "", "", root) + + def rejected(*args): + try: + validate(*args) + assert False, "invalid nRF52 hybrid build profile accepted" + except RuntimeError as exc: + return str(exc) + + assert "enabled together" in rejected( + ml.NRF52_APP_BASE_S140_V6, ml.NRF52_APP_END, + True, False, correct, correct, root) + assert "enabled together" in rejected( + ml.NRF52_APP_BASE_S140_V6, ml.NRF52_APP_END, + False, True, correct, correct, root) + assert "exact S140" in rejected( + ml.NRF52_APP_BASE_S140_V6, ml.NRF52_EXTRAFS_START, + True, True, correct, correct, root) + assert "exact S140" in rejected( + 0x00028000, ml.NRF52_APP_END, + True, True, correct, correct, root) + ordinary = root / "boards/nrf52840_s140_v6.ld" + assert "active linker" in rejected( + ml.NRF52_APP_BASE_S140_V6, ml.NRF52_APP_END, + True, True, ordinary, correct, root) + assert "configured linker" in rejected( + ml.NRF52_APP_BASE_S140_V6, ml.NRF52_APP_END, + True, True, correct, ordinary, root) + def test_full_esp32_profile_unifies_usb_logging_and_wifi_mqtt(): root = Path(__file__).resolve().parents[2] @@ -437,10 +521,9 @@ def test_measured_full_companion_promotions_are_exact_and_bounded(): assert "+" in profile assert "meshadventurer_sx1262_companion_radio_full" in profile assert "meshadventurer_sx1268_companion_radio_full" in profile - assert "-DMAX_CONTACTS=160" in profile - assert "-DMAX_GROUP_CHANNELS=30" in profile - assert "-DOFFLINE_QUEUE_SIZE=64" in profile - assert "160 contacts, 30 channels, and 64 queued frames" in profile + assert ("-DMAX_CONTACTS=100 -DMAX_GROUP_CHANNELS=30 " + "-DOFFLINE_QUEUE_SIZE=16") in profile + assert "100 contacts, 30 channels, and 16 queued frames" in profile def test_full_companion_wireless_startup_and_psram_contacts_are_resilient(): @@ -1083,6 +1166,19 @@ def test_nrf52_layout_record_roundtrip_and_policy(): ml.ensure_nrf52_layout(_fw(9, 2048), shared_internal)) assert ml.parse_nrf52_layout(shared_internal_image) == shared_internal assert not shared_internal.bootloader_scratch + hybrid = ml.Nrf52Layout( + ml.NRF52_APP_BASE_S140_V6, ml.NRF52_APP_END, + ml.NRF52_APP_END, ml.NRF52_LAYOUT_FLAG_HYBRID_RAM) + hybrid_image, _ = ml.ensure_endf( + ml.ensure_nrf52_layout(_fw(10, 2048), hybrid)) + assert ml.parse_nrf52_layout(hybrid_image) == hybrid + assert hybrid.hybrid_ram and not hybrid.external_backed + assert ml.build_nrf52_layout(hybrid) == bytes.fromhex( + "6d4f54414c617931011018000060020000d00e0000d00e00") + hybrid_v7 = ml.Nrf52Layout( + ml.NRF52_APP_BASE_S140_V7, ml.NRF52_APP_END, + ml.NRF52_APP_END, ml.NRF52_LAYOUT_FLAG_HYBRID_RAM) + assert ml.build_nrf52_layout(hybrid_v7) assert ((ml.NRF52_APP_END - ml.NRF52_BOOT_CONTAINER_SIZE) & ~(ml.NRF52_FLASH_PAGE - 1)) == ml.NRF52_SHARED_BOOT_STAGE_START try: @@ -1095,7 +1191,10 @@ def test_nrf52_layout_record_roundtrip_and_policy(): for flags in ( ml.NRF52_LAYOUT_FLAG_SD | ml.NRF52_LAYOUT_FLAG_QSPI, ml.NRF52_LAYOUT_FLAG_QSPI | ml.NRF52_LAYOUT_FLAG_INTERNAL_EXTRAFS, - 0x10, + ml.NRF52_LAYOUT_FLAG_HYBRID_RAM | ml.NRF52_LAYOUT_FLAG_SD, + ml.NRF52_LAYOUT_FLAG_HYBRID_RAM | ml.NRF52_LAYOUT_FLAG_QSPI, + ml.NRF52_LAYOUT_FLAG_HYBRID_RAM | + ml.NRF52_LAYOUT_FLAG_INTERNAL_EXTRAFS, ): try: ml.build_nrf52_layout(ml.Nrf52Layout( @@ -1105,6 +1204,50 @@ def test_nrf52_layout_record_roundtrip_and_policy(): except ValueError: pass + # The flag is also a promise that the application used the dedicated + # mota64 RAM linker. A shorter flash linker with no ExtraFS mounted may + # ordinarily reclaim ED000 for staging, but it cannot reserve SRAM. + for linked_end, ceiling in ( + (ml.NRF52_EXTRAFS_START, ml.NRF52_APP_END), + (ml.NRF52_APP_END, ml.NRF52_EXTRAFS_START), + ): + try: + ml.build_nrf52_layout(ml.Nrf52Layout( + ml.NRF52_APP_BASE_S140_V6, linked_end, ceiling, + ml.NRF52_LAYOUT_FLAG_HYBRID_RAM)) + assert False, "non-mota64 hybrid layout accepted" + except ValueError: + pass + + +def test_nrf52_hybrid_stage_plan_matches_frozen_geometry(): + app = ml.NRF52_APP_BASE_S140_V6 + app_end = app + 512 * 1024 + page = ml.NRF52_FLASH_PAGE + ceiling = ml.NRF52_APP_END + + assert ml.nrf52_hybrid_stage_plan(8 + 197 + 5, app, app_end, ceiling) is None + assert ml.nrf52_hybrid_stage_plan(page, app, app_end, ceiling) is None + assert ml.nrf52_hybrid_stage_plan(page + 1, app, app_end, ceiling) == ( + ceiling - page, page, 1) + assert ml.nrf52_hybrid_stage_plan(17 * page, app, app_end, ceiling) == ( + ceiling - page, page, 16 * page) + assert ml.nrf52_hybrid_stage_plan(17 * page + 1, app, app_end, ceiling) == ( + ceiling - 2 * page, 2 * page, 15 * page + 1) + + # Hybrid is never admitted against the D4000/ExtraFS ceiling, and exact + # runtime app headroom must fit the charged flash prefix. + assert ml.nrf52_hybrid_stage_plan( + 17 * page, app, app_end, ml.NRF52_EXTRAFS_START) is None + assert ml.nrf52_hybrid_stage_plan( + 17 * page + 1, app, ceiling - page, ceiling) is None + assert ml.nrf52_hybrid_stage_plan( + 17 * page, 0x00028000, app_end, ceiling) is None + v7 = ml.NRF52_APP_BASE_S140_V7 + assert ml.nrf52_hybrid_stage_plan( + 17 * page, v7, v7 + 512 * 1024, ceiling) == ( + ceiling - page, page, 16 * page) + def _write_boot_continuity(image, offset, *, version=0x0117010D, family=ml.BOOT_CONTINUITY_FAMILY_S140,