diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 93a96246..be8c8384 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -197,6 +197,9 @@ jobs: python3 -B test/test_ota_store_resume.py -v python3 -B test/test_ota_catalog_lifetime.py -v python3 -B test/test_ota_store_flash_nrf52_hybrid.py -v + python3 -B test/test_tower_bootloader_caps.py -v + python3 -B test/test_tower_auto_storage.py -v + python3 -B test/test_tower_auto_integration.py -v python3 -B test/test_ota_dev_staging.py -v python3 -B test/test_ota_dev_reboot_guard.py -v @@ -374,6 +377,7 @@ jobs: python3 -B test/test_full_build_flag_dedup.py python3 -B test/test_full_logging_compile.py python3 -B test/test_full_logging_resume.py + python3 -B test/test_xiao_canonical_logging.py -v python3 -B test/test_firmware_build_recipe.py -v python3 -B test/test_firmware_capabilities.py MESHCORE_TEST_REAL_PIO_PATHS=1 python3 -B test/test_esp32_partition_migration_build_dir.py -v diff --git a/build_legacy.sh b/build_legacy.sh index 24fdae24..d658b8bc 100755 --- a/build_legacy.sh +++ b/build_legacy.sh @@ -215,7 +215,9 @@ Environment Variables: offered directly as the editable default. A single custom version suffix found in existing OUTPUT_DIR artifacts is carried forward after the new numeric version. - DISABLE_DEBUG=1: Disables all debug logging flags (MESH_DEBUG, MESH_PACKET_LOGGING, etc.) + DISABLE_DEBUG=1: Disables build-time debug logging. Full Companion retains its + runtime-gated diagnostics; canonical XIAO QSPI repeaters retain + runtime-gated USB packet logging while MESH_DEBUG stays disabled. If not set, debug flags from variant platformio.ini files are used. RESUME_BUILD_OUTPUT=1: Preserves out/ and skips targets whose expected output artifacts already exist. Option 3 resumes by default. @@ -1977,6 +1979,11 @@ print_release_firmware_targets() { ;; get-repeater-firmwares-to-build) get_pio_envs_ending_with_string "_repeater" + # MeshTower keeps its deployed SD target identity as the one canonical + # repeater recipe, including when SD staging falls back to internal staging. + if is_supported_build_env "Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors"; then + printf '%s\n' "Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors" + fi # These full-sensor targets are distinct hardware/bootloader contracts, # not generated lean OTA aliases, so tagged repeater releases must ship # them explicitly alongside the canonical standard repeaters. @@ -2683,6 +2690,10 @@ disable_debug_flags() { # apparent order in PLATFORMIO_BUILD_FLAGS. if [ -n "$env_name" ] && is_companion_radio_full_target "$env_name"; then usb_logging_undefs="" + elif [ -n "$env_name" ] && is_xiao_qspi_canonical_repeater_build "$env_name"; then + # Its combined artifact promises runtime USB packet logging. Keep the + # logger linked without retaining optional MESH_DEBUG output. + usb_logging_undefs="-UMESH_DEBUG" fi if [ "${DISABLE_DEBUG:-0}" == "1" ]; then @@ -2714,9 +2725,13 @@ apply_mqtt_bridge_override() { apply_debug_overrides() { local env_name=${1:-} local preserve_full_companion_logging=0 + local preserve_runtime_packet_logging=0 if [ -n "$env_name" ] && is_companion_radio_full_target "$env_name"; then preserve_full_companion_logging=1 + preserve_runtime_packet_logging=1 + elif [ -n "$env_name" ] && is_xiao_qspi_canonical_repeater_build "$env_name"; then + preserve_runtime_packet_logging=1 fi case "${MESHDEBUG_OVERRIDE,,}" in @@ -2735,7 +2750,7 @@ apply_debug_overrides() { export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DMESH_PACKET_LOGGING=1" ;; off) - if [ "$preserve_full_companion_logging" -eq 0 ]; then + if [ "$preserve_runtime_packet_logging" -eq 0 ]; then export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -UMESH_PACKET_LOGGING" fi ;; @@ -2788,13 +2803,22 @@ apply_merged_standard_usb_logging_profile() { uses_merged_standard_usb_logging "$env_name" || return 0 + # Canonical XIAO QSPI repeaters include packet logging in the immutable + # combined artifact. The saved usb.logging switch controls its live stream. + local preserve_runtime_packet_logging=0 + if is_xiao_qspi_canonical_repeater_build "$env_name"; then + preserve_runtime_packet_logging=1 + export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DMESH_PACKET_LOGGING=1 -DMESH_USB_LOGGING_MERGED=1" + fi + # Explicit diagnostic overrides retain their documented meaning. Canonical # builds otherwise compile packet/debug output into the ordinary artifact; # get/set usb.logging controls the live Serial stream at runtime. if [ "${DISABLE_DEBUG:-0}" = "1" ]; then return 0 fi - if [ "${PACKET_LOGGING_OVERRIDE,,}" != "off" ]; then + if [ "$preserve_runtime_packet_logging" -eq 0 ] \ + && [ "${PACKET_LOGGING_OVERRIDE,,}" != "off" ]; then export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DMESH_PACKET_LOGGING=1 -DMESH_USB_LOGGING_MERGED=1" fi if [ "${MESHDEBUG_OVERRIDE,,}" != "off" ] \ @@ -3054,6 +3078,11 @@ is_nrf52_sensor_ota_pair_target() { get_nrf52_sensor_ota_pair_target() { local target=$1 local candidate + # The former internal-only MeshTower names are build aliases for the SD + # primary. Their old OTA IDs are not compatible aliases on deployed nodes. + if candidate=$(get_unified_meshtower_repeater_replacement "$target"); then + target=$candidate + fi is_nrf52_sensor_ota_pair_target "$target" || return 1 # Preserve an explicit/deployed OTA identity and every external-storage # contract. Ordinary internal targets keep the historical auto-build ID of @@ -3074,6 +3103,14 @@ normalize_nrf52_sensor_ota_pair_targets() { local -a normalized=() local -A seen=() for target in "${RESOLVED_BUILD_TARGETS[@]}"; do + if candidate=$(get_unified_meshtower_repeater_replacement "$target"); then + if ! is_supported_build_env "$candidate"; then + echo "MeshTower V2 primary build target is unavailable: ${candidate}" >&2 + return 1 + fi + echo "MeshTower V2 build alias ${target} resolves to ${candidate}. Old internal-only bootloader/OTA identities require local USB/BLE DFU or SWD migration; this is not a same-target LoRa upgrade." >&2 + target=$candidate + fi candidate=$(get_nrf52_sensor_ota_pair_target "$target") || candidate=$target if [ -z "${seen[$candidate]+x}" ]; then normalized+=("$candidate") @@ -3986,8 +4023,14 @@ apply_lora_ota_override() { append_platformio_build_unflags "-UENABLE_OTA -DDISABLE_LORA_OTA=1 -DOTA_FLASH_STORE=1 -DOTA_SD_STORE=1" export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -UDISABLE_LORA_OTA -DENABLE_OTA=1 -UOTA_FLASH_STORE -UOTA_SD_STORE -DOTA_QSPI_STORE=1 -DOTA_FOLDER_SERIAL" elif [ "${PIO_ENV_SD_OTA_BY_NAME[$env_name]:-0}" = "1" ]; then - append_platformio_build_unflags "-UENABLE_OTA -DDISABLE_LORA_OTA=1 -DOTA_FLASH_STORE=1" - export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -UDISABLE_LORA_OTA -DENABLE_OTA=1 -UOTA_FLASH_STORE -DOTA_SD_STORE=1 -DOTA_FOLDER_SERIAL" + if [ "$env_name" = Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors ] \ + && pio_env_option_contains "$env_name" build_flags "OTA_TOWER_AUTO_STORE=1"; then + append_platformio_build_unflags "-UENABLE_OTA -DDISABLE_LORA_OTA=1" + export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -UDISABLE_LORA_OTA -DENABLE_OTA=1 -DOTA_FLASH_STORE=1 -DOTA_SD_STORE=1 -DOTA_TOWER_AUTO_STORE=1 -DOTA_FOLDER_SERIAL" + else + append_platformio_build_unflags "-UENABLE_OTA -DDISABLE_LORA_OTA=1 -DOTA_FLASH_STORE=1" + export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -UDISABLE_LORA_OTA -DENABLE_OTA=1 -UOTA_FLASH_STORE -DOTA_SD_STORE=1 -DOTA_FOLDER_SERIAL" + fi else append_platformio_build_unflags "-UENABLE_OTA -DDISABLE_LORA_OTA=1" export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -UDISABLE_LORA_OTA -DENABLE_OTA=1 -DOTA_FLASH_STORE=1 -DOTA_FOLDER_SERIAL" @@ -5388,6 +5431,17 @@ get_combined_usb_ble_companion_replacement() { esac } +get_unified_meshtower_repeater_replacement() { + case "${1,,}" in + heltec_tower_v2_repeater|heltec_tower_v2_repeater_lora_ota_no_external_sensors) + printf '%s\n' Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors + ;; + *) + return 1 + ;; + esac +} + get_merged_rs232_repeater_replacement() { case "${1,,}" in heltec_t096_repeater_bridge_rs232) @@ -5447,7 +5501,8 @@ get_merged_rs232_repeater_replacement() { } is_firmware_role_replaced_by_canonical_artifact() { - get_full_companion_replacement "$1" >/dev/null 2>&1 \ + get_unified_meshtower_repeater_replacement "$1" >/dev/null \ + || get_full_companion_replacement "$1" >/dev/null 2>&1 \ || get_terminal_chat_companion_replacement "$1" >/dev/null \ || get_combined_usb_ble_companion_replacement "$1" >/dev/null \ || get_merged_rs232_repeater_replacement "$1" >/dev/null @@ -5534,7 +5589,12 @@ resolve_full_companion_firmwares() { } resolve_repeater_firmwares() { - get_pio_envs_for_variant_role repeater + local env_name + while IFS= read -r env_name; do + if ! get_unified_meshtower_repeater_replacement "$env_name" >/dev/null; then + printf '%s\n' "$env_name" + fi + done < <(get_pio_envs_for_variant_role repeater) } resolve_room_server_firmwares() { @@ -5738,7 +5798,7 @@ resolve_command_targets() { # Base and reduced aliases resolve to one exact-identity pair; external # storage and deployed compatibility identities deliberately stay separate. - normalize_nrf52_sensor_ota_pair_targets + normalize_nrf52_sensor_ota_pair_targets || return $? # Keep one queue so parallel workers stay saturated. The scheduler may pull # a later target forward when a generated alias shares an active PlatformIO diff --git a/docs/cli_build_matrix.md b/docs/cli_build_matrix.md index f1555a64..180f24b4 100644 --- a/docs/cli_build_matrix.md +++ b/docs/cli_build_matrix.md @@ -185,12 +185,34 @@ available from a canonical image: releases. Wio-E5 remains separate because its normal image has only 916 bytes free, while the combined image exceeds the fixed 240 KiB application partition by 2,192 bytes. +- MeshTower V2 repeaters use + `Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors` as the one + canonical target. Its SD OTA target `0A9DBBF0`, hardware ID + `Heltec_tower_v2`, layout, and filenames stay unchanged, with separate + required Full and Reduced sensor profiles. Both + `Heltec_tower_v2_repeater` and + `Heltec_tower_v2_repeater_lora_ota_no_external_sensors` resolve to this primary + in `build-firmware` and matching builds. Old internal-only bootloader/OTA + identities require one local USB/BLE DFU or SWD migration; the build aliases + do not make the new image a same-target LoRa upgrade for those installed nodes. + Existing SD users need no configuration change. The application selects SD + first and falls back automatically for a missing or unmountable card only + when the matching published combined OTAFIX profile declares exact + privileged storage `09` and optional application storage `02` in its separate + `MOTASTOR` record. Internal fallback accepts flash-staged signed application + in-place deltas from trusted signers that fit its smaller workspace; + SD-generated large deltas may be rejected. Full images, bootloader updates, + and archive capture remain SD-only. Storage stays pinned through a transfer: + use `ota cancel` before + selecting a different backend. The application does not use an `APPHYBRID` + retained-RAM staging arena. See [MeshTower SD OTA](ota_meshtower_v2_sdcard.md). The old aliases still work with `build-firmware` and `build-matching-firmwares`. Dedicated LoRa OTA repeater images are not -collapsed; they retain their exact storage, bootloader, role, and target -identity contracts. Companion boards keep transport-specific canonical images -only when no exact Full recipe has passed the combined flash/RAM qualification. +collapsed outside the MeshTower consolidation above; they retain their exact +storage, bootloader, role, and target identity contracts. Companion boards +keep transport-specific canonical images only when no exact Full recipe has +passed the combined flash/RAM qualification. ESP32 deliberately uses one TTY: Binary Companion and plaintext USB logging are mutually exclusive there. nRF52 retains its optional second CDC port. diff --git a/docs/ota_meshtower_v2_sdcard.md b/docs/ota_meshtower_v2_sdcard.md index e793cff0..c7a92767 100644 --- a/docs/ota_meshtower_v2_sdcard.md +++ b/docs/ota_meshtower_v2_sdcard.md @@ -1,20 +1,42 @@ # MeshTower V2 microSD LoRa OTA -For **1.17.1.5**, use the exact board/storage profile from -[OTAFIX 2.4.6](https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.11.0-OTAFIX2.4.6) -for nRF52 OTAFIX installations. New internal-flash hybrid receivers require -its 64 KiB retained-RAM handoff; QSPI and microSD targets require their own -matching bootloader profiles. Earlier preview versions mentioned below -describe compatibility/migration history, not the current recommended download. -Full Companion is a MOTA source and normally updates itself over USB. +`Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors` is the one +canonical MeshTower V2 repeater target. Its SD OTA target ID `0A9DBBF0`, +hardware ID `Heltec_tower_v2`, application layout, and artifact names remain +unchanged. The required Full and Reduced sensor choices use that same target. +Existing SD users need no configuration change. +The build aliases `Heltec_tower_v2_repeater` and +`Heltec_tower_v2_repeater_lora_ota_no_external_sensors` resolve to this primary +target. An installed internal-only image has a different OTA identity and +bootloader/layout contract: migrate it once with the matching combined OTAFIX +profile and application over local USB/BLE DFU or SWD. An alias does not make +that migration a same-target LoRa update. -The `Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors` target uses the MeshTower V2 onboard -microSD socket as persistent storage for its own LoRa OTA downloads. It accepts -full application images, in-place application deltas, and explicitly selected -signed v3 bootloader packages. After verification, the matching SD-aware OTAFIX -bootloader reads the staged file from the card and programs the selected -nRF52840 application or bootloader region. +## Automatic storage selection + +The onboard microSD socket remains primary. A mountable card accepts full +application images, in-place application deltas, and explicitly selected +signed v3 bootloader packages. After verification, the SD-aware OTAFIX +bootloader reads the staged file and programs the application or bootloader. + +With a missing or unmountable card, the same application can select internal +flash for signed application in-place deltas only. The signer must be trusted +by the node, as on the SD application path. This fallback requires the +published combined MeshTower SD/internal profile in +[OTAFIX 2.4.11](https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/v0.11.0-OTAFIX2.4.11): +the privileged storage profile must remain exactly `09` (`SD|BOOT_UPDATE`), +with a separate positive `MOTASTOR` optional-application storage record of +`02` (`STAGE_CEILING`). A missing record or an internal-only bootloader does +not authorize fallback. Full images, bootloader packages, SD archiving, and +automatic own-image offering remain unavailable on internal storage. + +Selection is deferred until OTA use so card probing does not delay USB setup. +The selected backend stays pinned through the transfer, including a paused or +failed transfer. Removing a card produces a storage failure; it does not move +the partial download into internal flash. Use `ota cancel` before selecting +storage again after changing the card. The fallback uses internal flash only; +it does not reserve or use the 64 KiB `APPHYBRID` retained-RAM arena. The pin assignment follows the [Heltec MeshTower V2 partial reference circuit](https://resource.heltec.cn/download/MeshTower-V2/schematic/MeshTower_V2_Partial_Reference_Circuit.pdf): @@ -44,9 +66,9 @@ Preview.12 must first be upgraded over USB/BLE DFU or SWD. ## Capacity and update types -The card removes the internal-flash staging limit. The `.mota` container may be -much larger than the old internal staging gap, and either a full image or an -in-place delta may be downloaded. The installed firmware itself must still fit +When SD is selected, the card removes the internal-flash staging limit. The +`.mota` container may be much larger than the old internal staging gap, and +either a full image or an in-place delta may be downloaded. The installed firmware itself must still fit the nRF52840 application region below InternalFS (ending at `0xED000`); SD storage does not increase the MCU's executable flash. @@ -74,7 +96,14 @@ motatool build \ motatool verify ./motas/*.mota ``` -The download is resumable because the partial `.mota` stays on the card. +The internal fallback has a smaller flash staging gap and in-place workspace. +A delta generated for the larger SD workspace can be rejected even though it +updates the same application and target. Keep a usable card inserted for that +package, or generate an exact-base delta that fits the fallback's available +workspace and staging capacity. The firmware refuses an oversized package; +it does not switch backends during its download. + +An SD download is resumable because the partial `.mota` stays on the card. Once it reaches `ready`, `ota install` performs the final verification, publishes a one-reset authorization record, and reboots. SD application installation requires a valid signature from a key in the node's allowlist. @@ -83,14 +112,16 @@ Keep the card inserted through the reboot and installation. The BLM2 retained-auth SD-aware bootloader is mandatory for application OTA. `ota install` refuses to reboot if its continuity metadata does not match the running S140 family/FWID/application layout or if its capability marker does -not advertise SD staging and the selected codec. Existing -`Heltec_tower_v2_repeater` firmware continues to use the internal-flash delta -path and is unchanged. +not advertise SD staging and the selected codec. Earlier internal-only +MeshTower firmware retains its old identity until the deliberate local +migration described above. ## Signed bootloader update -Only this exact SD build exposes the privileged LoRa bootloader-update command. -It requires an already installed exact-board ABI-3 OTAFIX bootloader whose one +Only the canonical SD target with SD selected exposes a usable privileged +LoRa bootloader-update path. Internal application fallback does not stage +bootloader packages. It requires an already installed exact-board ABI-3 +OTAFIX bootloader whose one unambiguous capability marker is exactly `0x09` (`SD|BOOT_UPDATE`) and whose codec mask is `0x0005` (`FULL|INPLACE`). Requiring both application codecs prevents a bootloader self-update from disabling either normal SD application @@ -196,7 +227,9 @@ get sdcard dir 3 `format` creates a new FAT16, FAT32, or exFAT filesystem according to card size. `erase` first uses the card's raw media erase command and then formats it, so a successful erase finishes with a usable filesystem. Both operations -destroy all data on the card and cancel any staged OTA download. +destroy all data on the card and cancel a staged SD OTA download. If the +internal fallback slot is occupied, card maintenance is refused until an +explicit `ota cancel`; it does not abandon an internal download to format SD. The firmware records successful format and erase completion times in RAM. Repeating the same operation within five minutes is rejected unless `--force` @@ -213,7 +246,7 @@ file size. The header reports the selected page, total pages, and total files. ## Persistent OTA archive and seeder -On the SD-backed target, automatic OTA archiving is on by default. While the +With SD selected, automatic OTA archiving is on by default. While the temporary OTA radio is active, the node requests full catalogs from seeders and saves every complete mOTA it discovers, including firmware for other hardware targets and codecs that this node cannot install. Archive downloads use the diff --git a/scripts/build_local_release.py b/scripts/build_local_release.py index 5a305eaa..4b8090d7 100644 --- a/scripts/build_local_release.py +++ b/scripts/build_local_release.py @@ -109,12 +109,25 @@ def select_ordinary_full_records(records: list[dict]) -> list[dict]: their separately built migration packages or direct --full-exact builds. This also removes stale sibling artifacts when a matrix build is resumed. nRF52 Full/reduced sensor OTA records always pass through independently, - even when their logical target ID and storage layout are the same. + even when their logical target ID and storage layout are the same. The + MeshTower SD primary replaces stale internal-only artifacts on resume; + those files must never be relabeled with the primary's different OTA ID. """ + tower_primary = "heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors" + tower_legacy = { + "heltec_tower_v2_repeater", + "heltec_tower_v2_repeater_lora_ota_no_external_sensors", + } + targets = {record["manifest"]["target"].lower() for record in records} + if targets & tower_legacy and tower_primary not in targets: + raise ValueError("MeshTower V2 release requires the SD primary; old internal-only " + "artifacts cannot substitute for its distinct OTA identity") chosen: dict[str, tuple[int, dict]] = {} passthrough: list[dict] = [] for record in records: manifest = record["manifest"] + if manifest["target"].lower() in tower_legacy: + continue if manifest["platform"] != "ESP32_PLATFORM" or manifest["build_profile"] != "full": passthrough.append(record) continue diff --git a/src/Mesh.cpp b/src/Mesh.cpp index cf17afb8..d9e3194c 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -497,6 +497,11 @@ void __attribute__((noinline)) Mesh::serviceLoopMaintenance() { _next_ota_tick = futureMillis(ota_loop_interval); } if (millisHasNowPassed(_next_ota_tick)) { +#if defined(NRF52_PLATFORM) && defined(OTA_TOWER_AUTO_STORE) + // Establish storage policy before resume and codec selection. Startup + // deliberately performs no SD mount so the USB console is ready first. + ota::ota_ctx().prepareTowerStorage(); +#endif // one-shot on first tick: resume an interrupted fetch left staged in flash before a reboot. Only adopt // a PARTIAL container (continue fetching the holes); a COMPLETE one is left for manual/auto-install, // not re-adopted at boot. requestMissing() (inside resumeStaged) drives the rest via REQ/DATA. @@ -518,6 +523,9 @@ void __attribute__((noinline)) Mesh::serviceLoopMaintenance() { } if (_ota_announce_timer.ready(_ms->getMillis())) { // auto-advertise so peers discover us (tiny beacon) ota::OtaContext& oc = ota::ota_ctx(); +#if defined(NRF52_PLATFORM) && defined(OTA_TOWER_AUTO_STORE) + oc.prepareTowerStorage(); +#endif bool in_burst = _ota_announce_count < OTA_ANNOUNCE_BURST; uint32_t mins = oc.manager.advert_mins(); // periodic cadence in minutes; 0 = disabled (boot burst only) if (in_burst || mins != 0) { diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 40887b61..1bd2fb49 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -3294,6 +3294,17 @@ bool CommonCLI::handleSdCardSetCmd(const char* config, char* reply) { strcpy(reply, "Error: OTA apply is pending"); return true; } +#if defined(OTA_TOWER_AUTO_STORE) + // Card maintenance must never abandon a fallback update stored internally. + if (context.fetch_store.usesInternal() && + (context.manager.fetchState() != mesh::ota::OtaManager::IDLE || + context.fetch_store.staged_size() != 0)) { + strcpy(reply, "Error: internal OTA slot is occupied; use ota cancel first"); + return true; + } +#endif + + mesh::ota::OtaStoreSdNrf52& card = context.sdStagingStore(); // A card-wide destructive operation invalidates any staged fetch. First let // an archive capture checkpoint and close its shared card handles. @@ -3305,9 +3316,9 @@ bool CommonCLI::handleSdCardSetCmd(const char* config, char* reply) { context.prev_fstate = mesh::ota::OtaManager::IDLE; if (is_format) { - if (!context.fetch_store.formatCard(*_board)) { + if (!card.formatCard(*_board)) { char error[80]; - strncpy(error, context.fetch_store.last_error(), sizeof(error) - 1); + strncpy(error, card.last_error(), sizeof(error) - 1); error[sizeof(error) - 1] = 0; context.finishSdCardReset(millis()); snprintf(reply, 160, "Error: SD card format failed: %s", @@ -3321,9 +3332,9 @@ bool CommonCLI::handleSdCardSetCmd(const char* config, char* reply) { return true; } - if (!context.fetch_store.eraseCard(*_board)) { + if (!card.eraseCard(*_board)) { char error[80]; - strncpy(error, context.fetch_store.last_error(), sizeof(error) - 1); + strncpy(error, card.last_error(), sizeof(error) - 1); error[sizeof(error) - 1] = 0; context.finishSdCardReset(millis()); snprintf(reply, 160, "Error: SD card erase failed: %s", @@ -3333,9 +3344,9 @@ bool CommonCLI::handleSdCardSetCmd(const char* config, char* reply) { _sdcard_erase_recorded = true; _sdcard_erase_at = millis(); - if (!context.fetch_store.formatCard(*_board)) { + if (!card.formatCard(*_board)) { char error[80]; - strncpy(error, context.fetch_store.last_error(), sizeof(error) - 1); + strncpy(error, card.last_error(), sizeof(error) - 1); error[sizeof(error) - 1] = 0; context.finishSdCardReset(millis()); snprintf(reply, 160, "Error: SD card erased but format failed: %s", @@ -3378,7 +3389,7 @@ bool CommonCLI::handleSdCardGetCmd(const char* config, char* reply) { } else if (strcmp(query, "free") == 0) { uint64_t used_bytes = 0; uint64_t free_bytes = 0; - mesh::ota::OtaStoreSdNrf52& store = mesh::ota::ota_ctx().fetch_store; + mesh::ota::OtaStoreSdNrf52& store = mesh::ota::ota_ctx().sdStagingStore(); if (!store.getSpace(*_board, used_bytes, free_bytes)) { snprintf(reply, 160, "Error: SD card space query failed: %s", store.last_error()); @@ -3407,7 +3418,7 @@ bool CommonCLI::handleSdCardGetCmd(const char* config, char* reply) { } page = parsed; } - mesh::ota::OtaStoreSdNrf52& store = mesh::ota::ota_ctx().fetch_store; + mesh::ota::OtaStoreSdNrf52& store = mesh::ota::ota_ctx().sdStagingStore(); if (!store.listFiles(*_board, (uint16_t)page, reply, 160)) { snprintf(reply, 160, "Error: SD card list failed: %s", store.last_error()); } diff --git a/src/helpers/StorageLayout.cpp b/src/helpers/StorageLayout.cpp index c990821c..9906dc9f 100644 --- a/src/helpers/StorageLayout.cpp +++ b/src/helpers/StorageLayout.cpp @@ -106,7 +106,7 @@ void formatStorageLayout(MainBoard& board, char* reply, size_t reply_size) { #elif defined(ENABLE_OTA) && defined(OTA_SD_STORE) uint64_t used_bytes = 0; uint64_t free_bytes = 0; - mesh::ota::OtaStoreSdNrf52& store = mesh::ota::ota_ctx().fetch_store; + mesh::ota::OtaStoreSdNrf52& store = mesh::ota::ota_ctx().sdStagingStore(); if (store.getSpace(board, used_bytes, free_bytes)) { char total[24]; char used[24]; diff --git a/src/helpers/ota/OtaApply.cpp b/src/helpers/ota/OtaApply.cpp index bbdd2b11..1e6834ca 100644 --- a/src/helpers/ota/OtaApply.cpp +++ b/src/helpers/ota/OtaApply.cpp @@ -497,7 +497,7 @@ static bool ota_nrf52_clear_reset_reasons() { 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_RAK_AUTO_STORE) +#if defined(OTA_RAK_AUTO_STORE) || defined(OTA_TOWER_AUTO_STORE) if (g_nrf52_apply_store) { if (g_nrf52_apply_store->is_hybrid()) { if (!ota_nrf52_clear_reset_reasons() || @@ -511,7 +511,11 @@ void ota_reboot_to_apply() { // public: set the apply magic + stage_handoff = mota_nrf52_flash_stage_handoff(ota_nrf52_effective_stage_ceiling()); } } else { +#if defined(OTA_TOWER_AUTO_STORE) + stage_handoff = GPREGRET2_OTA_STAGE_SD; +#else stage_handoff = g_nrf52_qspi_handoff; +#endif } #elif defined(OTA_SD_STORE) stage_handoff = GPREGRET2_OTA_STAGE_SD; @@ -1114,10 +1118,21 @@ static bool ota_prepare_bootloader_update_external(Store& store, OtaBootloaderCapsMarker candidate_caps; if (!ota_bootloader_external_image_metadata( store, payload_off, ota_bootloader_update_storage_flags(), - candidate, candidate_caps) || + candidate, candidate_caps, +#if defined(OTA_TOWER_AUTO_STORE) + current_caps.optional_app_storage +#else + 0 +#endif + ) || !ota_bootloader_identity_matches(installed, candidate)) { strcpy(msg, "candidate bootloader identity/capability/CRC mismatch"); return false; } +#if defined(OTA_TOWER_AUTO_STORE) + if (current_caps.optional_app_storage && ota_boot_rd32(vectors) > MOTA_NRF52_HYBRID_RAM_START) { + strcpy(msg, "candidate must retain the combined bootloader RAM arena"); return false; + } +#endif const OtaBootloaderContinuityGate continuity = ota_bootloader_continuity_gate( installed, candidate, m.fw_version, OTA_BOOT_CONTINUITY_FAMILY_S140, ota_runtime_softdevice_fwid(), mota_nrf52_app_base(), @@ -1140,6 +1155,9 @@ static bool ota_prepare_bootloader_update_external(Store& store, #if defined(OTA_SD_STORE) bool ota_apply_mota_nrf52(OtaStoreSdNrf52& store, const SignerAllowlist& allow, ApplyState& st, char* msg) { +#if defined(OTA_TOWER_AUTO_STORE) + g_nrf52_apply_store = nullptr; +#endif #if defined(OTA_SD_BOOTLOADER_UPDATE) OtaBootloaderIdentity installed; if (!ota_installed_bootloader_identity(installed) || @@ -1163,6 +1181,9 @@ bool ota_prepare_bootloader_update_nrf52(OtaStoreSdNrf52& store, const uint8_t operator_mid[4], const uint8_t operator_hash8[8], ApplyState& st, char* msg) { +#if defined(OTA_TOWER_AUTO_STORE) + g_nrf52_apply_store = nullptr; +#endif if (!ota_bootloader_sd_retained_auth_ready( installed, OTA_BOOT_CONTINUITY_FAMILY_S140, ota_runtime_softdevice_fwid(), mota_nrf52_app_base(), diff --git a/src/helpers/ota/OtaBlInfo.h b/src/helpers/ota/OtaBlInfo.h index 641dac02..74cf0a37 100644 --- a/src/helpers/ota/OtaBlInfo.h +++ b/src/helpers/ota/OtaBlInfo.h @@ -28,7 +28,7 @@ struct OtaBlCaps { uint16_t apply_abi = 0; // max .mota format_ver the bootloader can apply uint16_t codec_mask = 0; // bit i set => can apply codec_id i (in-place delta = bit 2) uint8_t storage_flags = 0; // OTA_BL_STORAGE_* capability bits - uint8_t optional_app_storage = 0; // separate MOTASTOR record, never a boot-update backend + uint8_t optional_app_storage = 0; // separate MOTASTOR record, scoped to its qualified primary profile }; // Independent marker for the reset-retained hybrid source. Keep this separate @@ -217,10 +217,10 @@ inline OtaBlCaps ota_bl_legacy_app_caps_scan_halfword(const uint8_t* bytes, return c; } -// Unified RAK loaders preserve the deployed board's privileged MOTABLDR=0x0A -// contract. A separate record advertises optional APPLICATION storage. It is -// adjacent to the unchanged, unique 64 KiB RAM marker and grants no new -// bootloader staging backend. +// Unified loaders preserve the deployed primary MOTABLDR contract: 0x0A for +// RAK internal storage and 0x09 for Tower SD. A separate record advertises +// optional application storage, adjacent to the unchanged, unique RAM marker. +// Never infer optional storage from the primary profile alone. inline uint8_t ota_bl_optional_app_storage(const uint8_t* bytes, size_t len) { static const uint8_t record[32] = { 'M', 'O', 'T', 'A', 'R', 'A', 'M', 'A', 1, 0, 72, 0, 0, 0, 1, 0, @@ -228,17 +228,39 @@ inline uint8_t ota_bl_optional_app_storage(const uint8_t* bytes, size_t len) { }; if (!bytes || !ota_bootloader_supports_hybrid(ota_ram_caps_scan_aligned(bytes, len))) return 0; unsigned matches = 0; - for (size_t off = 0; off + sizeof(record) <= len; off += 4) { - if (memcmp(bytes + off, record, sizeof(record)) == 0 && ++matches > 1) return 0; + uint8_t result = 0; + for (size_t off = 0; off + 8u <= len; off += 4u) { + if (memcmp(bytes + off, record + 16u, 8u) != 0) continue; + // One aligned STOR record must immediately follow the one valid RAM + // record. A stray, malformed, truncated or duplicate declaration fails + // closed instead of allowing another valid record to hide it. + if (++matches != 1u || off < 16u || off + 16u > len || + memcmp(bytes + off - 16u, record, 28u) != 0 || + bytes[off + 13u] || bytes[off + 14u] || bytes[off + 15u]) return 0; + const uint8_t flags = bytes[off + 12u]; + if (flags != (OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_HEADER_W25) && + flags != OTA_BL_STORAGE_STAGE_CEILING) return 0; + result = flags; } - return matches == 1 ? OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_HEADER_W25 : 0; + return matches == 1u ? result : 0; +} + +inline uint8_t ota_bl_profile_optional_app_storage(const uint8_t* bytes, + size_t len, + uint8_t primary_storage) { + const uint8_t optional = ota_bl_optional_app_storage(bytes, len); + if (primary_storage == OTA_BL_PROFILE_INTERNAL_BOOT_UPDATE && + optional == (OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_HEADER_W25)) return optional; + if (primary_storage == OTA_BL_PROFILE_SD_BOOT_UPDATE && + optional == OTA_BL_STORAGE_STAGE_CEILING) return optional; + return 0; } inline OtaBlCaps ota_bl_app_caps_scan(const uint8_t* bytes, size_t len) { OtaBlCaps aligned = ota_bl_caps_scan_aligned(bytes, len, false); - if (aligned.present && aligned.apply_abi >= 3 && - aligned.storage_flags == OTA_BL_PROFILE_INTERNAL_BOOT_UPDATE) { - aligned.optional_app_storage = ota_bl_optional_app_storage(bytes, len); + if (aligned.present && aligned.apply_abi >= 3) { + aligned.optional_app_storage = ota_bl_profile_optional_app_storage( + bytes, len, aligned.storage_flags); aligned.storage_flags |= aligned.optional_app_storage; } if (aligned.present) return aligned; @@ -248,7 +270,14 @@ inline OtaBlCaps ota_bl_app_caps_scan(const uint8_t* bytes, size_t len) { inline OtaBlCaps ota_bl_update_caps_scan_aligned(const uint8_t* bytes, size_t len, uint8_t exact_storage_flags) { if (exact_storage_flags == 0) return OtaBlCaps(); - return ota_bl_caps_scan_aligned(bytes, len, true, exact_storage_flags); + OtaBlCaps caps = ota_bl_caps_scan_aligned(bytes, len, true, exact_storage_flags); + if (caps.present) { + // Preserve the exact privileged storage byte; callers may independently + // require an existing optional application capability in a successor. + caps.optional_app_storage = ota_bl_profile_optional_app_storage( + bytes, len, caps.storage_flags); + } + return caps; } // Scan the bootloader flash region for ordinary application-update capabilities. diff --git a/src/helpers/ota/OtaBootloaderUpdate.h b/src/helpers/ota/OtaBootloaderUpdate.h index a5adfccb..22fb385d 100644 --- a/src/helpers/ota/OtaBootloaderUpdate.h +++ b/src/helpers/ota/OtaBootloaderUpdate.h @@ -10,6 +10,19 @@ #include "OtaBlInfo.h" #include "OtaByteIO.h" +#if defined(OTA_TOWER_AUTO_STORE) + #if !defined(NRF52_PLATFORM) || !defined(HELTEC_TOWER_V2_SDCARD) || \ + !defined(OTA_SD_STORE) || !defined(OTA_FLASH_STORE) || \ + !defined(OTA_SD_BOOTLOADER_UPDATE) + #error "OTA_TOWER_AUTO_STORE requires the qualified nRF52 MeshTower SD-primary OTA profile" + #endif + #if defined(OTA_QSPI_STORE) || defined(QSPIFLASH) || \ + defined(OTA_RAK_AUTO_STORE) || defined(OTA_INTERNAL_BOOTLOADER_UPDATE) || \ + defined(OTA_QSPI_BOOTLOADER_UPDATE) || defined(OTA_HYBRID_RAM_STORE) + #error "MeshTower automatic storage retains the full-RAM SD profile and SD-only bootloader staging" + #endif +#endif + #if defined(OTA_QSPI_BOOTLOADER_UPDATE) #if !defined(NRF52_PLATFORM) || !defined(OTA_QSPI_STORE) #error "OTA_QSPI_BOOTLOADER_UPDATE requires nRF52 raw-QSPI staging" @@ -39,7 +52,8 @@ #if !defined(HELTEC_TOWER_V2_SDCARD) #error "OTA_SD_BOOTLOADER_UPDATE is restricted to the qualified MeshTower V2 SD target" #endif - #if defined(OTA_FLASH_STORE) || defined(OTA_QSPI_STORE) || defined(QSPIFLASH) + #if (defined(OTA_FLASH_STORE) && !defined(OTA_TOWER_AUTO_STORE)) || \ + defined(OTA_QSPI_STORE) || defined(QSPIFLASH) #error "SD bootloader staging cannot share another OTA/filesystem store" #endif #endif @@ -556,15 +570,25 @@ inline bool ota_bootloader_external_crc_ok( template inline bool ota_bootloader_external_image_metadata( Store& store, uint32_t payload_off, uint8_t exact_storage_flags, - OtaBootloaderIdentity& candidate, OtaBootloaderCapsMarker& caps) { + OtaBootloaderIdentity& candidate, OtaBootloaderCapsMarker& caps, + uint8_t required_optional_storage = 0) { candidate = OtaBootloaderIdentity(); caps = OtaBootloaderCapsMarker(); if (exact_storage_flags == 0u) return false; + if (required_optional_storage != 0u && + !((exact_storage_flags == OTA_BL_PROFILE_SD_BOOT_UPDATE && + required_optional_storage == OTA_BL_STORAGE_STAGE_CEILING) || + (exact_storage_flags == OTA_BL_PROFILE_INTERNAL_BOOT_UPDATE && + required_optional_storage == (OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_HEADER_W25)))) + return false; static const uint32_t STEP = 512u; uint8_t buf[STEP + OTA_BOOT_ENVELOPE_SIZE - 1u]; uint8_t valid_identities = 0; uint8_t valid_caps = 0; + uint8_t valid_ram = 0; + uint8_t storage_records = 0; + uint32_t optional_record_offset = 0; for (uint32_t base = 0; base < OTA_BOOT_IMAGE_SIZE; base += STEP) { uint32_t len = OTA_BOOT_IMAGE_SIZE - base; if (len > sizeof(buf)) len = sizeof(buf); @@ -573,7 +597,22 @@ inline bool ota_bootloader_external_image_metadata( ? OTA_BOOT_IMAGE_SIZE - base : STEP; for (uint32_t local = 0; local < starts; ++local) { const uint32_t absolute = base + local; + if (required_optional_storage && (absolute & 3u) == 0u && + local + 8u <= len && memcmp(buf + local, "MOTASTOR", 8u) == 0) { + // Scan each aligned start once, including chunk boundaries. A valid + // RAM record is encountered first and binds its immediately following + // STOR; no stray or duplicate declaration may hide beside that pair. + if (++storage_records != 1u || valid_ram != 1u || + absolute != optional_record_offset) return false; + } if ((absolute & 3u) == 0u && local + 16u <= len) { + OtaRamCaps ram; + if (required_optional_storage && ota_ram_caps_marker_parse(buf + local, ram)) { + if (++valid_ram != 1u || local + 32u > len || + ota_bl_optional_app_storage(buf + local, 32u) != required_optional_storage) + return false; + optional_record_offset = absolute + 16u; + } OtaBootloaderCapsMarker parsed; if (ota_bootloader_caps_marker_parse(buf + local, parsed) && parsed.apply_abi >= MOTA_BOOT_FORMAT_VER && @@ -596,7 +635,8 @@ inline bool ota_bootloader_external_image_metadata( } } } - if (valid_identities != 1u || valid_caps != 1u || !caps.present || + if ((required_optional_storage && (valid_ram != 1u || storage_records != 1u)) || + valid_identities != 1u || valid_caps != 1u || !caps.present || candidate.manifest_offset > OTA_BOOT_IMAGE_SIZE - OTA_BOOT_MANIFEST_SIZE) return false; const uint32_t available = OTA_BOOT_IMAGE_SIZE - candidate.manifest_offset; diff --git a/src/helpers/ota/OtaCli.cpp b/src/helpers/ota/OtaCli.cpp index 224d926c..18c50688 100644 --- a/src/helpers/ota/OtaCli.cpp +++ b/src/helpers/ota/OtaCli.cpp @@ -158,6 +158,9 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board OtaContext& c = ota_ctx(); c.manager.set_clock(millis()); c.manager.set_speed(speedFactor()); +#if defined(NRF52_PLATFORM) && defined(OTA_TOWER_AUTO_STORE) + c.prepareTowerStorage(); +#endif const char* rest = a; // ---- raw / internal primitives, tucked under `ota dev ...` ---- @@ -196,6 +199,11 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board "folder | config | key"); #endif #elif defined(NRF52_PLATFORM) && defined(OTA_SD_STORE) +#if defined(OTA_TOWER_AUTO_STORE) + strcpy(reply, + "OTA: status | stats | ls | get flash | install | bootloader | cancel | announce | self | serve | " + "storage | folder | cache | config | key"); +#else #if defined(OTA_SD_BOOTLOADER_UPDATE) strcpy(reply, "OTA: status | stats | ls | get flash | install | bootloader | cancel | announce | self | serve | " @@ -205,6 +213,7 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board "OTA: status | stats | ls | get flash | install | cancel | announce | self | serve | folder | " "cache | config | key"); #endif +#endif #else snprintf(reply, 160, "OTA: status | stats | ls | get flash | install | cancel | announce | self | serve | folder | " @@ -253,7 +262,11 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board // nRF52 applies via the bootloader - show (cached) whether it can, so `ota get`/`install` won't surprise. // blrc = the bootloader's last apply code (diagnostic; 0xB8=success, see ota_delta.c). const OtaBlCaps& bl = c.bootloaderAppCaps(); -#if defined(OTA_RAK_AUTO_STORE) +#if defined(OTA_TOWER_AUTO_STORE) + const char* bl_state = !bl.present ? "NONE" : + c.fetch_store.usesExternal() ? "SD" : + c.fetch_store.usesInternal() ? "internal" : "storage-ERR"; +#elif defined(OTA_RAK_AUTO_STORE) const char* bl_state = !bl.present ? "NONE" : c.fetch_store.usesExternal() ? "QSPI" : c.fetch_store.usesInternal() ? "internal" : "storage-ERR"; @@ -415,6 +428,9 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board ota_bootloader_self_update_caps_valid(list_bl_update); #if defined(OTA_SD_BOOTLOADER_UPDATE) installable = installable && list_sd_headroom; +#if defined(OTA_TOWER_AUTO_STORE) + installable = installable && c.fetch_store.usesExternal(); +#endif #endif #endif fit = installable ? "yours" : "bootloader unsupported"; @@ -423,7 +439,11 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board #if defined(NRF52_PLATFORM) installable = installable && list_bl.present && list_bl.apply_abi >= MOTA_FORMAT_VER && h->codec < 16 && (list_bl.codec_mask & (1u << h->codec)); -#if defined(OTA_SD_STORE) +#if defined(OTA_TOWER_AUTO_STORE) + installable = installable && c.fetch_store.compatible(); + if (h->codec == CODEC_FULL) + installable = installable && c.fetch_store.usesExternal(); +#elif defined(OTA_SD_STORE) installable = installable && (list_bl.storage_flags & OTA_BL_STORAGE_SD); #elif defined(OTA_RAK_AUTO_STORE) if (c.fetch_store.usesExternal()) @@ -567,6 +587,12 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board return true; } #if defined(OTA_SD_BOOTLOADER_UPDATE) +#if defined(OTA_TOWER_AUTO_STORE) + if (!c.fetch_store.usesExternal()) { + strcpy(reply, "ERR bootloader packages require a usable SD card"); + return true; + } +#endif SelfFwInfo sd_self; if (!ota_self_firmware(sd_self) || !ota_bootloader_scratch_headroom_valid( @@ -602,7 +628,12 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board need_abi, (unsigned)selcodec, bl.apply_abi, bl.codec_mask); return true; } -#if defined(OTA_SD_STORE) +#if defined(OTA_TOWER_AUTO_STORE) + if (!c.fetch_store.compatible()) { + snprintf(reply, 160, "ERR OTA storage unsafe: %s", c.fetch_store.selectionReason()); + return true; + } +#elif defined(OTA_SD_STORE) if (!(bl.storage_flags & OTA_BL_STORAGE_SD)) { strcpy(reply, "ERR bootloader cannot apply an update staged on SD; update it over USB first"); return true; @@ -709,6 +740,12 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board #endif const bool discarded = !was_folder && !was_sd_archive && c.fetch_store.discard(); +#if defined(NRF52_PLATFORM) && defined(OTA_TOWER_AUTO_STORE) + if (discarded || (!c.fetch_store.usesExternal() && !c.fetch_store.usesInternal())) { + c.fetch_store.resetSelection(); + c.prepareTowerStorage(); + } +#endif // Fetch cancellation and serving are independent. In particular, a // manual serve view can point into serve_buf, so leave the manager // view and its caller-owned buffer intact until `ota dev clear` (which @@ -769,7 +806,10 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board // ---- raw-QSPI staging diagnostics (read-only probe; preserves a latched fetch failure) ---- } else if (is_cmd(a, "qspi|storage", &rest)) { -#if defined(NRF52_PLATFORM) && defined(OTA_RAK_AUTO_STORE) +#if defined(NRF52_PLATFORM) && defined(OTA_TOWER_AUTO_STORE) + snprintf(reply, 160, "OTA storage: %s; capacity=%luK; SD primary, internal application deltas only", + c.fetch_store.selectionReason(), (unsigned long)(c.fetch_store.capacity() / 1024)); +#elif defined(NRF52_PLATFORM) && defined(OTA_RAK_AUTO_STORE) if (c.fetch_store.usesExternal()) { const uint32_t qspi_capacity = c.fetch_store.qspiCapacity(); snprintf(reply, 160, "QSPI %s jedec=%06lX size=%luK sr1=%02X stage=%s%s%s", @@ -814,7 +854,11 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board #if defined(NRF52_PLATFORM) // nRF52 applies via the bootloader, so surface whether THIS device's bootloader can install this store. const OtaBlCaps& bl = c.bootloaderAppCaps(); // cached (flash scanned once) -#if defined(OTA_RAK_AUTO_STORE) +#if defined(OTA_TOWER_AUTO_STORE) + snprintf(reply + n, 160 - n, " | store:%s | bootloader:%s", + c.fetch_store.usesExternal() ? "SD" : c.fetch_store.usesInternal() ? "internal" : "unsafe", + c.fetch_store.compatible() ? "apply OK" : "unsupported"); +#elif defined(OTA_RAK_AUTO_STORE) if (c.fetch_store.usesExternal()) { const uint32_t qspi_capacity = c.fetch_store.qspiCapacity(); snprintf(reply + n, 160 - n, " | QSPI store:%s%uK | bootloader: %s", @@ -1323,6 +1367,12 @@ static bool handle_dev(const char* d, char* reply, OtaContext& c) { strcpy(reply, "OK cleared SD archive session; partial retained for resume"); } else { const bool discarded = c.fetch_store.discard(); +#if defined(NRF52_PLATFORM) && defined(OTA_TOWER_AUTO_STORE) + if (discarded || (!c.fetch_store.usesExternal() && !c.fetch_store.usesInternal())) { + c.fetch_store.resetSelection(); + c.prepareTowerStorage(); + } +#endif strcpy(reply, discarded ? "OK OTA receive slot confirmed clear" : "ERR RAM state cleared; persistent OTA slot invalidation failed"); } diff --git a/src/helpers/ota/OtaContext.h b/src/helpers/ota/OtaContext.h index f2c9fc96..4b4e00a6 100644 --- a/src/helpers/ota/OtaContext.h +++ b/src/helpers/ota/OtaContext.h @@ -27,7 +27,10 @@ #define OTA_DYNAMIC_CONTEXT 0 #endif -#if defined(NRF52_PLATFORM) && defined(OTA_RAK_AUTO_STORE) +#if defined(NRF52_PLATFORM) && defined(OTA_TOWER_AUTO_STORE) + #include "OtaStoreTowerNrf52.h" + #include "OtaCacheSdNrf52.h" +#elif defined(NRF52_PLATFORM) && defined(OTA_RAK_AUTO_STORE) #include "OtaStoreAdaptiveNrf52.h" #elif defined(NRF52_PLATFORM) && defined(OTA_QSPI_STORE) #include "OtaStoreQspiNrf52.h" @@ -110,6 +113,9 @@ struct OtaContext { // store object for OtaManager, while folder captures replace it with the // host-backed FolderMotaStore for the duration of the pull. OtaStoreRam<1> fetch_store; +#elif defined(NRF52_PLATFORM) && defined(OTA_TOWER_AUTO_STORE) + OtaStoreTowerNrf52 fetch_store; // SD primary, qualified internal delta fallback + OtaCacheSdNrf52 sd_cache; // always owns the stable SD store #elif defined(NRF52_PLATFORM) && defined(OTA_RAK_AUTO_STORE) OtaStoreAdaptiveNrf52 fetch_store; #elif defined(NRF52_PLATFORM) && defined(OTA_QSPI_STORE) @@ -145,9 +151,9 @@ struct OtaContext { #endif uint32_t serve_expected = 0; // size declared by `ota stage` bool serving = false; // manager.serve() succeeded - // Capture application-storage eligibility once. An adaptive nRF52 may later - // switch to internal flash to stage a bootloader without changing its normal - // ability to offer its running application. + // RAK application-storage eligibility survives privileged staging changes. + // Tower eligibility is established after its deferred SD probe and changes + // only when an operator ends the pinned transfer and selects storage again. bool self_serve_supported = false; // flash-backed self-serve: cached merkle leaves (heap, freed on re-serve) + assembled manifest of our // own running firmware. The payload is read from flash per block; only the metadata is held in RAM. @@ -291,6 +297,12 @@ struct OtaContext { strncpy(msg, "refused: use ota bootloader install ", 96); msg[95] = 0; return false; } +#if defined(OTA_TOWER_AUTO_STORE) + if (!mm.is_signed()) { + strncpy(msg, "refused: MeshTower application OTA requires a trusted signature", 96); + msg[95] = 0; return false; + } +#endif if (trusted_auto) { SelfFwInfo self; if (!mm.is_signed()) { @@ -314,7 +326,7 @@ struct OtaContext { } } bool ok; -#if defined(NRF52_PLATFORM) && defined(OTA_RAK_AUTO_STORE) +#if defined(NRF52_PLATFORM) && (defined(OTA_RAK_AUTO_STORE) || defined(OTA_TOWER_AUTO_STORE)) ok = fetch_store.usesExternal() ? ota_apply_mota_nrf52(fetch_store.externalStore(), allow, apply_st, msg) : fetch_store.usesInternal() @@ -363,7 +375,15 @@ struct OtaContext { msg[95] = 0; return false; } const OtaBootloaderIdentity& installed = bootloaderIdentity(); -#if defined(OTA_RAK_AUTO_STORE) +#if defined(OTA_TOWER_AUTO_STORE) + if (!fetch_store.usesExternal()) { + strncpy(msg, "bootloader package must be staged on SD", 96); + msg[95] = 0; return false; + } + bool ok = ota_prepare_bootloader_update_nrf52( + fetch_store.sdStore(), allow, installed, manager.fetchManifestId(), operator_mid, + operator_hash8, apply_st, msg); +#elif defined(OTA_RAK_AUTO_STORE) if (!fetch_store.usesInternal()) { strncpy(msg, "bootloader package must be staged in internal flash", 96); msg[95] = 0; return false; @@ -545,10 +565,45 @@ struct OtaContext { } #if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE) + OtaStoreSdNrf52& sdStagingStore() { +#if defined(OTA_TOWER_AUTO_STORE) + return fetch_store.sdStore(); +#else + return fetch_store; +#endif + } + +#if defined(OTA_TOWER_AUTO_STORE) + // Probe only after startup or an explicit OTA command. Selection is latched + // until operator cancellation, including a failed/paused transfer. + bool prepareTowerStorage() { + const bool usable = fetch_store.selectStorage(); + const bool external = usable && fetch_store.usesExternal(); + const bool was_self_serve_supported = self_serve_supported; + manager.set_accept_full(external); + manager.set_accept_bootloader(external && + ota_bootloader_self_update_caps_valid(bootloaderUpdateCaps())); + self_serve_supported = ota_self_serve_supported(external); + if (was_self_serve_supported && !self_serve_supported && + manager.servingPrimaryManifest(serve_self_manifest)) { + manager.clear_primary(); + serving = false; + } +#if MESHCORE_OTA_DEVICE_DEFLATE + manager.set_transport_deflate_encoder(self_serve_supported ? ota_transport_deflate : nullptr); +#endif + if (!external) manager.set_archive_interest(false); + return usable; + } +#endif + // Initialize and attach the persistent source lazily. This lets Mesh::begin finish quickly when no card // is inserted, while archive interest is already enabled so early OTA advertisements still get queried. bool ensureSdCache() { - sd_cache.attach(fetch_store); + sd_cache.attach(sdStagingStore()); +#if defined(OTA_TOWER_AUTO_STORE) + if (!prepareTowerStorage() || !fetch_store.usesExternal()) return false; +#endif if (!sd_cache.initialize()) return false; if (!_sd_cache_source_attached) { if (!manager.add_source(&sd_cache)) return false; @@ -598,11 +653,21 @@ struct OtaContext { void finishSdCardReset(uint32_t now) { sd_cache.resetMedia(); +#if defined(OTA_TOWER_AUTO_STORE) + fetch_store.resetSelection(); +#endif _sd_cache_init_retry_at = now; ensureSdCache(); } void serviceSdCache(uint32_t now) { +#if defined(OTA_TOWER_AUTO_STORE) + if (!prepareTowerStorage() || !fetch_store.usesExternal()) { + if (_sd_cache_source_attached) manager.remove_source(&sd_cache); + _sd_cache_source_attached = false; + return; + } +#endif if (!_sd_cache_source_attached) { if (_sd_cache_init_retry_at && (int32_t)(now - _sd_cache_init_retry_at) < 0) return; if (!ensureSdCache()) { @@ -694,6 +759,11 @@ struct OtaContext { manager.set_accept_full(true); manager.set_autofetch(OtaManager::AUTOFETCH_OFF); autoinstall = AUTOINSTALL_OFF; +#elif defined(NRF52_PLATFORM) && defined(OTA_TOWER_AUTO_STORE) + // An SD probe can block when the socket is empty. Bring up USB and the + // radio before determining the optional storage backend in the main loop. + manager.set_accept_full(false); + manager.set_apply_codec(CODEC_DETOOLS_INPLACE); #elif defined(NRF52_PLATFORM) && defined(OTA_RAK_AUTO_STORE) manager.set_accept_full(fetch_store.usesExternal()); manager.set_apply_codec(CODEC_DETOOLS_INPLACE); @@ -711,7 +781,9 @@ struct OtaContext { #if defined(NRF52_PLATFORM) && \ (defined(OTA_QSPI_BOOTLOADER_UPDATE) || defined(OTA_INTERNAL_BOOTLOADER_UPDATE) || \ defined(OTA_SD_BOOTLOADER_UPDATE)) -#if defined(OTA_RAK_AUTO_STORE) +#if defined(OTA_TOWER_AUTO_STORE) + manager.set_accept_bootloader(false); +#elif defined(OTA_RAK_AUTO_STORE) manager.set_accept_bootloader( ota_bootloader_self_update_caps_valid(ota_bootloader_update_caps())); #else @@ -721,7 +793,9 @@ struct OtaContext { manager.set_accept_bootloader(false); #endif manager.set_fetch_store(&fetch_store); -#if defined(NRF52_PLATFORM) && defined(OTA_RAK_AUTO_STORE) && !defined(OTA_SEEDER_ONLY) +#if defined(NRF52_PLATFORM) && defined(OTA_TOWER_AUTO_STORE) + self_serve_supported = false; +#elif defined(NRF52_PLATFORM) && defined(OTA_RAK_AUTO_STORE) && !defined(OTA_SEEDER_ONLY) self_serve_supported = ota_self_serve_supported(fetch_store.usesExternal()); #else self_serve_supported = ota_self_serve_supported(); @@ -733,7 +807,7 @@ struct OtaContext { manager.set_transport_deflate_encoder(self_serve_supported ? ota_transport_deflate : nullptr); #endif #if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE) - sd_cache.attach(fetch_store); + sd_cache.attach(sdStagingStore()); manager.set_archive_interest(true); // default on; the SD marker can turn it off at first mount #endif } diff --git a/src/helpers/ota/OtaSelfServePolicy.h b/src/helpers/ota/OtaSelfServePolicy.h index 29420565..0487ccda 100644 --- a/src/helpers/ota/OtaSelfServePolicy.h +++ b/src/helpers/ota/OtaSelfServePolicy.h @@ -5,7 +5,7 @@ namespace mesh { namespace ota { // Default own-image offering is for full-image platforms. Internal-only nRF52 // nodes retain the explicit diagnostic export used to capture delta bases, but // do not automatically offer a full image their ordinary peers cannot install. -// Adaptive RAK builds must use the qualified application backend, not merely +// Adaptive RAK and Tower builds must use the qualified application backend, not merely // the presence of a NOR chip (or the temporary bootloader staging backend). inline bool ota_self_serve_supported(bool external_application_storage = false) { #if defined(OTA_SEEDER_ONLY) @@ -14,7 +14,7 @@ inline bool ota_self_serve_supported(bool external_application_storage = false) #elif defined(ESP32_PLATFORM) (void)external_application_storage; return true; -#elif defined(NRF52_PLATFORM) && defined(OTA_RAK_AUTO_STORE) +#elif defined(NRF52_PLATFORM) && (defined(OTA_RAK_AUTO_STORE) || defined(OTA_TOWER_AUTO_STORE)) return external_application_storage; #elif defined(NRF52_PLATFORM) && (defined(OTA_QSPI_STORE) || defined(OTA_SD_STORE)) (void)external_application_storage; diff --git a/src/helpers/ota/OtaStoreSdNrf52.h b/src/helpers/ota/OtaStoreSdNrf52.h index 1b8bc68d..fcfc1bcf 100644 --- a/src/helpers/ota/OtaStoreSdNrf52.h +++ b/src/helpers/ota/OtaStoreSdNrf52.h @@ -52,6 +52,9 @@ public: bool eraseCard(MainBoard& board); bool getSpace(MainBoard& board, uint64_t& used_bytes, uint64_t& free_bytes); bool listFiles(MainBoard& board, uint16_t page, char* reply, size_t cap); + // Mount-only probe for the MeshTower selector. Never opens/removes staging + // files or changes their live session state. + bool probeMedia() { return mount() && capacity() != 0u; } const char* last_error() const { return _error; } private: diff --git a/src/helpers/ota/OtaStoreTowerNrf52.h b/src/helpers/ota/OtaStoreTowerNrf52.h new file mode 100644 index 00000000..e55b37bf --- /dev/null +++ b/src/helpers/ota/OtaStoreTowerNrf52.h @@ -0,0 +1,216 @@ +#pragma once + +#if defined(OTA_TOWER_AUTO_STORE) +#if !defined(NRF52_PLATFORM) || !defined(HELTEC_TOWER_V2_SDCARD) || \ + !defined(OTA_SD_STORE) || !defined(OTA_FLASH_STORE) +#error "MeshTower automatic OTA storage requires the qualified nRF52 SD primary" +#endif + +#include "OtaBlInfo.h" +#include "OtaStoreFlashNrf52.h" +#include "OtaStoreSdNrf52.h" +#include "MotaContainer.h" +#include + +namespace mesh { +namespace ota { + +// Keep the SD volume object stable because the archive shares its mounted +// filesystem. Internal construction is deferred: its constructor invalidates +// the same retained authorization bytes used by the SD application handoff. +// Selecting media never opens/removes a staging file, and an operation failure +// never redirects an already selected transfer to a different medium. +class OtaStoreTowerNrf52 : public OtaStore { + enum Mode : uint8_t { UNSELECTED, SD_MODE, INTERNAL_MODE, UNAVAILABLE }; + OtaStoreSdNrf52 _sd; + alignas(OtaStoreFlashNrf52) uint8_t _internal_bytes[sizeof(OtaStoreFlashNrf52)]; + OtaStoreFlashNrf52* _internal = nullptr; + Mode _mode = UNSELECTED; + bool _compatible = false; + bool _has_stage = false; + bool _layout_rejected = false; + bool _layout_accepted = false; + const char* _reason = "not selected"; + const char* _error = nullptr; + + OtaStore* active() { + return _mode == SD_MODE ? static_cast(&_sd) : + _mode == INTERNAL_MODE ? _internal : nullptr; + } + const OtaStore* active() const { + return _mode == SD_MODE ? static_cast(&_sd) : + _mode == INTERNAL_MODE ? _internal : nullptr; + } + bool resumeMatches(const uint8_t* mid, uint32_t target) { + uint8_t raw[MOTA_MFL]; + MotaManifest manifest; + const OtaStore* s = active(); + if (!s || !s->read(8u, raw, sizeof(raw)) || + !mota_parse_manifest(raw, sizeof(raw), manifest)) { + _error = "staged manifest is invalid"; + return false; + } + if ((mid && memcmp(manifest.merkle_root, mid, 4u) != 0) || + (target != 0u && manifest.target_id != target)) { + _error = "staged MID/target does not match"; + return false; + } + if (_mode == INTERNAL_MODE && (manifest.is_full() || manifest.is_bootloader())) { + _error = "internal fallback accepts application deltas only"; + return false; + } + return true; + } + +public: + OtaStoreTowerNrf52() = default; + OtaStoreTowerNrf52(const OtaStoreTowerNrf52&) = delete; + OtaStoreTowerNrf52& operator=(const OtaStoreTowerNrf52&) = delete; + ~OtaStoreTowerNrf52() override { + if (_internal) _internal->~OtaStoreFlashNrf52(); + } + + // Call from the deferred OTA preparation path, after normal board startup. + // Getter/status calls deliberately cannot turn USB startup into an SD probe. + bool selectStorage() { + if (_mode != UNSELECTED) return _compatible; + const bool sd_ready = _sd.probeMedia(); + const OtaBlCaps caps = ota_bootloader_app_caps(); + const bool sd_profile = caps.present && (caps.storage_flags & OTA_BL_STORAGE_SD) != 0u; + if (sd_ready) { + _mode = SD_MODE; + _compatible = sd_profile; + _reason = sd_profile ? "SD staging" : "SD present; incompatible OTAFIX bootloader"; + } else if (sd_profile && caps.optional_app_storage == OTA_BL_STORAGE_STAGE_CEILING) { + _internal = new (_internal_bytes) OtaStoreFlashNrf52(); + _mode = INTERNAL_MODE; + _compatible = true; + _reason = "SD unavailable; internal delta staging"; + } else { + _mode = UNAVAILABLE; + _reason = "SD unavailable; OTAFIX lacks qualified internal fallback"; + } + return _compatible; + } + // The caller must finish/cancel the transfer first. Do not discard durable + // staged bytes here: a following reopen may intentionally resume them. + void resetSelection() { + if (_internal) { + _internal->~OtaStoreFlashNrf52(); + _internal = nullptr; + } + _mode = UNSELECTED; + _compatible = false; + _has_stage = false; + _layout_rejected = false; + _layout_accepted = false; + _reason = "not selected"; + _error = nullptr; + } + bool usesExternalStore() const { return _mode == SD_MODE; } + bool usesExternal() const { return usesExternalStore(); } + bool usesInternal() const { return _mode == INTERNAL_MODE; } + bool compatible() const { return _compatible; } + const char* selectionReason() const { return _reason; } + OtaStoreSdNrf52& sdStore() { return _sd; } + OtaStoreSdNrf52& externalStore() { return _sd; } + // Only use after usesInternal() succeeds. This accessor must not construct + // an internal store while an SD authorization is being prepared/published. + OtaStoreFlashNrf52& internalStore() { return *_internal; } + const char* last_error() const { + if (_error) return _error; + return _mode == SD_MODE ? _sd.last_error() : _reason; + } + + bool begin(uint32_t n) override { + _has_stage = false; + if (!selectStorage() || _layout_rejected) return false; + if (_mode == INTERNAL_MODE && !_layout_accepted) { + _error = "internal fallback requires a planned application delta"; + return false; + } + _error = nullptr; + _has_stage = active()->begin(n); + if (!_has_stage && _mode == INTERNAL_MODE) _error = "internal OTA begin failed"; + return _has_stage; + } + bool write(uint32_t p, const uint8_t* d, uint32_t n) override { + return _has_stage && active()->write(p, d, n); + } + bool read(uint32_t p, uint8_t* d, uint32_t n) const override { + return _has_stage && active()->read(p, d, n); + } + uint32_t capacity() const override { + const OtaStore* s = active(); + return _compatible && s ? s->capacity() : 0u; + } + uint32_t staged_size() const override { + return _has_stage ? active()->staged_size() : 0u; + } + void clear() override { + OtaStore* s = active(); + if (s) s->clear(); + _has_stage = false; + _layout_rejected = false; + _layout_accepted = false; + _error = nullptr; + } + bool discard() override { + if (!selectStorage()) return false; + const bool ok = active()->discard(); + _has_stage = false; + _layout_rejected = false; + _layout_accepted = false; + if (!ok && _mode == INTERNAL_MODE) _error = "internal OTA discard failed"; + return ok; + } + bool set_meta_size(uint32_t n) override { + return _has_stage && active()->set_meta_size(n); + } + bool finalize() override { return _has_stage && active()->finalize(); } + void checkpoint() override { if (_has_stage) active()->checkpoint(); } + bool reopen() override { return reopenFor(nullptr, 0u); } + bool reopenFor(const uint8_t* mid, uint32_t target) override { + _has_stage = false; + _layout_rejected = false; + _layout_accepted = false; + _error = nullptr; + if (!selectStorage()) return false; + OtaStore* s = active(); + const bool reopened = _mode == SD_MODE ? _sd.reopen() : s->reopenFor(mid, target); + // Rejecting a mismatched SD container must not call SD clear(): that + // method durably deletes the very file a later explicit pull may need. + _has_stage = reopened && resumeMatches(mid, target); + return _has_stage; + } + bool plan_layout(bool full, uint32_t image, uint32_t offset, + uint32_t payload, bool bootloader) override { + _layout_rejected = true; + _layout_accepted = false; + _error = nullptr; + if (!selectStorage()) return false; + if (_mode == INTERNAL_MODE && (full || bootloader)) { + _error = "internal fallback accepts application deltas only"; + return false; + } + _layout_rejected = !active()->plan_layout(full, image, offset, payload, bootloader); + _layout_accepted = !_layout_rejected; + return !_layout_rejected; + } + + // Management always addresses the physical SD card. These operations never + // switch a selected application transfer between SD and internal storage. + bool formatCard(MainBoard& board) { return _sd.formatCard(board); } + bool eraseCard(MainBoard& board) { return _sd.eraseCard(board); } + bool getSpace(MainBoard& board, uint64_t& used, uint64_t& free) { + return _sd.getSpace(board, used, free); + } + bool listFiles(MainBoard& board, uint16_t page, char* reply, size_t cap) { + return _sd.listFiles(board, page, reply, cap); + } +}; + +} // namespace ota +} // namespace mesh + +#endif diff --git a/test/test_local_release_full_selection.py b/test/test_local_release_full_selection.py index 522b2b95..1994206d 100644 --- a/test/test_local_release_full_selection.py +++ b/test/test_local_release_full_selection.py @@ -15,6 +15,34 @@ def record(target: str, profile: str = "full", platform: str = "ESP32_PLATFORM") class ReleaseFullSelectionTest(unittest.TestCase): + def test_meshtower_sd_primary_filters_stale_internal_images_without_relabeling(self): + primary = "Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors" + variants = [] + for sensor in ("full", "reduced"): + item = record(primary, "auto", "NRF52_PLATFORM") + item["manifest"].update(artifact_target=primary + "-" + sensor + "-ota", + capabilities=["sensor.profile." + sensor]) + variants.append(item) + companion = record("Heltec_tower_v2_companion_radio_full", platform="NRF52_PLATFORM") + room = record("Heltec_tower_v2_room_server_lora_ota_no_external_sensors", + platform="NRF52_PLATFORM") + legacy = [record(target, "auto", "NRF52_PLATFORM") for target in ( + "Heltec_tower_v2_repeater", + "Heltec_tower_v2_repeater_lora_ota_no_external_sensors", + )] + selected = select_ordinary_full_records(legacy + variants + [companion, room]) + self.assertEqual(selected, variants + [companion, room]) + for before, after in zip(variants, selected): + self.assertIs(before, after) + self.assertEqual(after["manifest"]["target"], primary) + + def test_meshtower_internal_images_cannot_substitute_for_missing_primary(self): + for target in ("Heltec_tower_v2_repeater", + "Heltec_tower_v2_repeater_lora_ota_no_external_sensors"): + with self.subTest(target=target): + with self.assertRaisesRegex(ValueError, "requires the SD primary"): + select_ordinary_full_records([record(target, "auto", "NRF52_PLATFORM")]) + def test_nrf52_full_and_reduced_ota_pair_is_never_collapsed(self): for role in ('repeater', 'room_server', 'sensor'): target = 'RAK_3401_' + role diff --git a/test/test_nrf52_ota_profiles.sh b/test/test_nrf52_ota_profiles.sh index d309121f..e86c8d61 100644 --- a/test/test_nrf52_ota_profiles.sh +++ b/test/test_nrf52_ota_profiles.sh @@ -4,6 +4,11 @@ set -euo pipefail cd "$(dirname "$0")/.." source build.sh fail() { echo "test_nrf52_ota_profiles: $*" >&2; exit 1; } +test_output=$(mktemp -d) +trap 'rm -rf -- "$test_output"' EXIT +tower_primary=Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors +tower_legacy=Heltec_tower_v2_repeater_lora_ota_no_external_sensors +tower_base=Heltec_tower_v2_repeater # This suite deliberately has no PlatformIO dependency. A mocked one-profile # worker exercises the actual single/bulk/matrix dispatch and fail-closed pair @@ -12,7 +17,7 @@ SUPPORTED_PIO_ENVS=( nrf_repeater nrf_repeater_lora_ota_no_external_sensors nrf_room_server nrf_room_server_lora_ota_no_external_sensors nrf_sensor nrf_sensor_lora_ota_no_external_sensors - Xiao_nrf52_repeater Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors + Xiao_nrf52_repeater "$tower_primary" "$tower_base" "$tower_legacy" RAK_3401_repeater_unified_lora_ota RAK_3401_sensor RAK_3401_sensor_lora_ota_no_external_sensors nrf_kiss_modem nrf_companion_radio_usb nrf_companion_radio_full nrf_terminal_chat @@ -31,6 +36,9 @@ done PIO_ENV_QSPI_OTA_BY_NAME[Xiao_nrf52_repeater]=1 PIO_ENV_QSPI_OTA_BY_NAME[RAK_3401_repeater_unified_lora_ota]=1 PIO_ENV_SD_OTA_BY_NAME[Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors]=1 +ALL_PIO_ENVS=("$tower_base" "$tower_primary") +PIO_ENV_BUILD_BASE_BY_NAME[$tower_legacy]=$tower_base +PIO_ENV_COMPLETE_OTA_BASE_BY_NAME[$tower_legacy]=$tower_base PIO_CONFIG_JSON='[]' for target in nrf_repeater nrf_room_server nrf_sensor Xiao_nrf52_repeater \ @@ -54,13 +62,88 @@ done Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors ] \ || fail "SD target lost its exact storage identity" +# The old base and generated internal-only build names now select the exact +# SD primary. A direct alias must warn about its different installed OTA ID, +# and all bulk/tagged lists must retain only one Full/Reduced target pair. +for alias in "$tower_base" "$tower_legacy"; do + [ "$(get_nrf52_sensor_ota_pair_target "$alias")" = "$tower_primary" ] \ + || fail "$alias did not resolve to the SD primary" + resolve_command_targets build-firmware "$alias" > /dev/null \ + 2> "$test_output/tower-alias-warning" + [ "${#RESOLVED_BUILD_TARGETS[@]}" -eq 1 ] \ + && [ "${RESOLVED_BUILD_TARGETS[0]}" = "$tower_primary" ] \ + || fail "$alias direct dispatch retained an internal-only identity" + warning=$(cat "$test_output/tower-alias-warning") + [[ "$warning" == *"require local USB/BLE DFU or SWD migration"* \ + && "$warning" == *"not a same-target LoRa upgrade"* ]] \ + || fail "$alias omitted its installed-identity migration warning" + is_redundant_bulk_build_target "$alias" \ + || fail "$alias remained a second canonical artifact" +done +for resolver in resolve_all_firmwares resolve_repeater_firmwares; do + mapfile -t tower_targets < <("$resolver") + target_list=" ${tower_targets[*]} " + [[ "$target_list" == *" $tower_primary "* \ + && "$target_list" != *" $tower_base "* \ + && "$target_list" != *" $tower_legacy "* ]] \ + || fail "$resolver did not publish only the Tower SD primary" +done +mapfile -t tower_targets < <(print_release_firmware_targets get-repeater-firmwares-to-build) +target_list=" ${tower_targets[*]} " +[[ "$target_list" == *" $tower_primary "* \ + && "$target_list" != *" $tower_base "* \ + && "$target_list" != *" $tower_legacy "* ]] \ + || fail "tagged repeater list omitted the SD primary or retained a duplicate" +for unchanged in Heltec_tower_v2_room_server Heltec_tower_v2_companion_radio_full; do + if get_unified_meshtower_repeater_replacement "$unchanged" >/dev/null; then + fail "$unchanged was redirected outside the repeater role" + fi +done + RESOLVED_BUILD_TARGETS=(nrf_repeater nrf_repeater_lora_ota_no_external_sensors \ nrf_sensor nrf_sensor_lora_ota_no_external_sensors Xiao_nrf52_repeater \ - Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors) -normalize_nrf52_sensor_ota_pair_targets + "$tower_primary" "$tower_base" "$tower_legacy") +normalize_nrf52_sensor_ota_pair_targets 2> "$test_output/tower-alias-warning" [ "${#RESOLVED_BUILD_TARGETS[@]}" -eq 4 ] \ || fail "ordinary/reduced aliases did not deduplicate into exactly one pair" +# Do not silently resurrect an incompatible internal recipe if the canonical +# SD target is absent from a narrowed/misconfigured build inventory. +unset 'PIO_ENV_PLATFORM_BY_NAME[Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors]' +RESOLVED_BUILD_TARGETS=("$tower_legacy") +if normalize_nrf52_sensor_ota_pair_targets > /dev/null 2>&1; then + fail "unavailable SD primary silently fell back to the old internal recipe" +fi +PIO_ENV_PLATFORM_BY_NAME[$tower_primary]=NRF52_PLATFORM + +# The build overlay must not undefine the exact primary's internal fallback. +# Other SD targets retain their old SD-only contract even if metadata happens +# to contain the Tower macro; no generic SD layout is made dual-storage. +NRF52_OTA_SENSOR_PROFILE=full +PIO_CONFIG_JSON='[["env:Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors", [["build_flags", ["-DOTA_SD_STORE=1", "-DOTA_FLASH_STORE=1", "-DOTA_TOWER_AUTO_STORE=1"]]]]]' +PLATFORMIO_BUILD_FLAGS="" +PLATFORMIO_BUILD_UNFLAGS="" +apply_lora_ota_override "$tower_primary" +[[ "$PLATFORMIO_BUILD_FLAGS" == *"-DOTA_FLASH_STORE=1"* \ + && "$PLATFORMIO_BUILD_FLAGS" == *"-DOTA_SD_STORE=1"* \ + && "$PLATFORMIO_BUILD_FLAGS" == *"-DOTA_TOWER_AUTO_STORE=1"* \ + && "$PLATFORMIO_BUILD_FLAGS" != *"-UOTA_FLASH_STORE"* \ + && "$PLATFORMIO_BUILD_UNFLAGS" != *"OTA_FLASH_STORE"* ]] \ + || fail "SD primary overlay disabled its qualified internal fallback" +other_sd_target=other_sd_repeater_lora_ota_no_external_sensors +PIO_ENV_PLATFORM_BY_NAME[$other_sd_target]=NRF52_PLATFORM +PIO_ENV_SD_OTA_BY_NAME[$other_sd_target]=1 +PIO_CONFIG_JSON='[["env:other_sd_repeater_lora_ota_no_external_sensors", [["build_flags", ["-DOTA_SD_STORE=1", "-DOTA_TOWER_AUTO_STORE=1"]]]]]' +PLATFORMIO_BUILD_FLAGS="" +PLATFORMIO_BUILD_UNFLAGS="" +apply_lora_ota_override "$other_sd_target" +[[ "$PLATFORMIO_BUILD_FLAGS" == *"-UOTA_FLASH_STORE"* \ + && "$PLATFORMIO_BUILD_FLAGS" != *"-DOTA_TOWER_AUTO_STORE"* \ + && "$PLATFORMIO_BUILD_UNFLAGS" == *"-D OTA_FLASH_STORE=1"* ]] \ + || fail "unrelated SD target gained the Tower internal fallback" +PIO_CONFIG_JSON='[]' +NRF52_OTA_SENSOR_PROFILE="" + for selected in auto standard full; do BUILD_PROFILE_OVERRIDE=$selected BUILD_PROFILE_EXPLICIT=1 @@ -93,13 +176,17 @@ FIRMWARE_FILENAME_INFIX=outer SKIP_DECLARED_REDUCTIONS=0 NRF52_OTA_SENSOR_PROFILE="" for target in nrf_repeater nrf_room_server nrf_sensor Xiao_nrf52_repeater \ - Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors; do + "$tower_primary" "$tower_base" "$tower_legacy"; do calls=() build_firmware "$target" >/dev/null [ "${#calls[@]}" -eq 2 ] || fail "$target did not build exactly two options" [[ "${calls[0]}" == *:full:full-ota:1:1:1 ]] || fail "$target full flags/OTA requirement are wrong" [[ "${calls[1]}" == *:reduced:reduced-ota:0:0:1 ]] || fail "$target reduced flags/OTA requirement are wrong" [ "${calls[0]%%:*}" = "${calls[1]%%:*}" ] || fail "$target profiles have different OTA IDs" + if [[ "$target" == Heltec_tower_v2* ]]; then + [ "${calls[0]%%:*}" = "$tower_primary" ] \ + || fail "$target profile worker built the obsolete internal identity" + fi done [ "$ESP32_FULL_BUILD:$BUILD_PROFILE_EFFECTIVE:$REQUIRE_OTA_UPDATES:$FIRMWARE_FILENAME_INFIX:$NRF52_OTA_SENSOR_PROFILE" = \ '1:standard:0:outer:' ] || fail "pair state leaked into other platform/Companion builds" @@ -117,8 +204,6 @@ failed_profile="" calls=() run_resolved_build_targets nrf_repeater nrf_sensor Xiao_nrf52_repeater >/dev/null [ "${#calls[@]}" -eq 6 ] || fail "bulk builds did not publish every pair" -test_output=$(mktemp -d) -trap 'rm -rf -- "$test_output"' EXIT OUTPUT_DIR=$test_output FIRMWARE_FILENAME_INFIX="" calls=() diff --git a/test/test_ota_self_serve_policy.py b/test/test_ota_self_serve_policy.py index 290ee4a4..4b44adb4 100644 --- a/test/test_ota_self_serve_policy.py +++ b/test/test_ota_self_serve_policy.py @@ -39,6 +39,8 @@ class OtaSelfServePolicyTest(unittest.TestCase): (("NRF52_PLATFORM=1", "OTA_QSPI_STORE=1"), True, True), (("NRF52_PLATFORM=1", "OTA_SD_STORE=1"), True, True), (("NRF52_PLATFORM=1", "OTA_RAK_AUTO_STORE=1"), False, True), + (("NRF52_PLATFORM=1", "OTA_TOWER_AUTO_STORE=1", "OTA_SD_STORE=1", + "OTA_FLASH_STORE=1"), False, True), (("NRF52_PLATFORM=1", "OTA_RAK_AUTO_STORE=1", "OTA_QSPI_STORE=1"), False, True), (("NRF52_PLATFORM=1", "OTA_RAK_AUTO_STORE=1", @@ -116,6 +118,7 @@ struct Context { uint8_t autoinstall = AUTOINSTALL_OFF; char hw_id[33] = {}; struct Cache { void attach(Store&) {} } sd_cache; + Store& sdStagingStore() { return fetch_store; } #include "begin.h" }; static bool send(void*, const uint8_t*, uint16_t, bool) { return true; } diff --git a/test/test_tower_auto_integration.py b/test/test_tower_auto_integration.py new file mode 100644 index 00000000..81bdb7d7 --- /dev/null +++ b/test/test_tower_auto_integration.py @@ -0,0 +1,326 @@ +"""Compile production Tower context policy and reboot dispatch on the host.""" + +from pathlib import Path +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] + + +def method(source, signature): + start = source.index(signature) + opening = source.index("{", start) + depth = 1 + end = opening + 1 + while depth: + depth += (source[end] == "{") - (source[end] == "}") + end += 1 + return source[start:end] + + +CONTEXT_SETUP = r''' +#include +#include +#include +#define NRF52_PLATFORM 1 +#define OTA_SD_STORE 1 +#define OTA_FLASH_STORE 1 +#define OTA_SD_BOOTLOADER_UPDATE 1 +#define OTA_TOWER_AUTO_STORE 1 +#define MOTA_MIGRATION_TARGET_ID 0x77 +#define MESHCORE_OTA_DEVICE_DEFLATE 1 +#include "helpers/ota/OtaSelfServePolicy.h" +using namespace mesh::ota; +using OtaSend = void (*)(); +using CodecFunction = void (*)(); +static constexpr uint8_t CODEC_DETOOLS_INPLACE = 2; +static constexpr size_t MOTA_MFL = 197; +static void ota_transport_inflate() {} +static void ota_transport_deflate() {} +struct SelfFwInfo { + bool valid = false; + uint32_t target_id = 0, fw_version = 0; + char hw_id[33] = {}; +}; +static SelfFwInfo current; +static bool ota_self_firmware(SelfFwInfo& out) { out = current; return current.valid; } +struct OtaBlCaps { bool valid = true; }; +static bool ota_bootloader_self_update_caps_valid(const OtaBlCaps& caps) { return caps.valid; } +struct OtaStoreSdNrf52 {}; +struct TowerStore { + enum Mode { Unselected, Sd, Internal, Unsafe }; + Mode mode = Unselected, fitted = Sd; + bool usable = true; + unsigned selection_calls = 0, probes = 0; + OtaStoreSdNrf52 sd; + bool selectStorage() { + ++selection_calls; + if (mode == Unselected) { ++probes; mode = fitted; } + return usable && mode != Unsafe; + } + bool usesExternal() const { return mode == Sd; } + bool usesInternal() const { return mode == Internal; } + OtaStoreSdNrf52& sdStore() { return sd; } + void resetSelection() { mode = Unselected; } +}; +struct Cache { + OtaStoreSdNrf52* owner = nullptr; + unsigned attachments = 0; + void attach(OtaStoreSdNrf52& store) { ++attachments; owner = &store; } +}; +struct Manager { + bool full = true, bootloader = true, archive = false, enforce_version = false; + uint8_t codec = 0xFF; + uint32_t target = 0, migration = 0, floor = 0; + CodecFunction encoder = ota_transport_deflate, decoder = nullptr; + TowerStore* destination = nullptr; + const uint8_t* primary = nullptr; + unsigned clear_calls = 0, folder_sources = 3; + void begin(uint32_t id, OtaSend, void*) { target = id; } + void set_auto_migration_target(uint32_t id) { migration = id; } + void set_transport_deflate_decoder(CodecFunction fn) { decoder = fn; } + void set_transport_deflate_encoder(CodecFunction fn) { encoder = fn; } + void set_auto_version_floor(uint32_t version, bool enforce) { floor = version; enforce_version = enforce; } + void set_accept_full(bool value) { full = value; } + void set_accept_bootloader(bool value) { bootloader = value; } + void set_apply_codec(uint8_t value) { codec = value; } + void set_fetch_store(TowerStore* value) { destination = value; } + void set_archive_interest(bool value) { archive = value; } + bool servingPrimaryManifest(const uint8_t* manifest) const { return primary == manifest; } + void clear_primary() { ++clear_calls; primary = nullptr; } +}; +struct Context { + TowerStore fetch_store; + Cache sd_cache; + Manager manager; + bool fetch_to_folder = true, self_serve_supported = true, serving = false; + char hw_id[33] = {}; + uint8_t serve_self_manifest[MOTA_MFL] = {}; + OtaBlCaps caps; + unsigned caps_reads = 0; + const OtaBlCaps& bootloaderUpdateCaps() { ++caps_reads; return caps; } +''' + +CONTEXT_CASES = r''' +}; +static void send() {} +int main() { + // Startup adopts authenticated self identity without selecting/mounting SD + // or enabling a receiver capability that depends on the absent probe. + current.valid = true; current.target_id = 0x1234; current.fw_version = 0x01170109; + strcpy(current.hw_id, "Heltec_tower_v2"); + Context sd; + sd.begin(0x55, send, nullptr, "fallback"); + assert(sd.fetch_store.probes == 0 && sd.fetch_store.selection_calls == 0); + assert(sd.caps_reads == 0 && !sd.manager.full && !sd.manager.bootloader); + assert(!sd.self_serve_supported && sd.manager.encoder == nullptr); + assert(sd.manager.decoder == ota_transport_inflate && sd.manager.codec == 2); + assert(sd.manager.target == 0x1234 && sd.manager.floor == 0x01170109); + assert(sd.manager.enforce_version && sd.manager.migration == 0x77); + assert(!sd.fetch_to_folder && strcmp(sd.hw_id, "Heltec_tower_v2") == 0); + assert(sd.manager.destination == &sd.fetch_store); + assert(sd.sd_cache.owner == &sd.fetch_store.sd && sd.sd_cache.attachments == 1); + + assert(sd.prepareTowerStorage()); + assert(sd.fetch_store.probes == 1 && sd.manager.full && sd.manager.bootloader); + assert(sd.self_serve_supported && sd.manager.encoder == ota_transport_deflate); + assert(sd.manager.archive && sd.manager.clear_calls == 0); + sd.manager.primary = sd.serve_self_manifest; sd.serving = true; + // Mid-session disappearance does not change the selected backend or clear + // a successfully offered image: the actual store handles subsequent I/O. + sd.fetch_store.fitted = TowerStore::Internal; + for (unsigned i = 0; i < 5; ++i) assert(sd.prepareTowerStorage()); + assert(sd.fetch_store.mode == TowerStore::Sd && sd.fetch_store.probes == 1); + assert(sd.serving && sd.manager.primary == sd.serve_self_manifest); + + // A legacy app-capable SD loader does not receive privileged packages. + sd.caps.valid = false; + assert(sd.prepareTowerStorage()); + assert(sd.manager.full && sd.self_serve_supported && !sd.manager.bootloader); + + // A deliberate selection reset removes only our automatic primary image. + // Attached folder sources are independent and must remain available. + sd.fetch_store.resetSelection(); + assert(sd.prepareTowerStorage()); + assert(sd.fetch_store.mode == TowerStore::Internal && sd.fetch_store.probes == 2); + assert(!sd.manager.full && !sd.manager.bootloader && !sd.manager.archive); + assert(!sd.self_serve_supported && sd.manager.encoder == nullptr); + assert(!sd.serving && sd.manager.primary == nullptr && sd.manager.clear_calls == 1); + assert(sd.manager.folder_sources == 3); + for (unsigned i = 0; i < 5; ++i) assert(sd.prepareTowerStorage()); + assert(sd.manager.clear_calls == 1 && sd.fetch_store.probes == 2); + + // `ota dev serve self` deliberately exports an internal-fallback base for + // delta capture. Repeated maintenance/status preparation must preserve that + // explicit diagnostic offer while automatic self-serving stays disabled. + sd.manager.primary = sd.serve_self_manifest; sd.serving = true; + for (unsigned i = 0; i < 5; ++i) assert(sd.prepareTowerStorage()); + assert(sd.serving && sd.manager.primary == sd.serve_self_manifest); + assert(!sd.self_serve_supported && sd.manager.encoder == nullptr); + assert(!sd.manager.full && !sd.manager.bootloader && !sd.manager.archive); + assert(sd.manager.clear_calls == 1 && sd.fetch_store.probes == 2); + + uint8_t manual_manifest[MOTA_MFL] = {}; + sd.manager.primary = manual_manifest; sd.serving = true; + assert(sd.prepareTowerStorage()); + assert(sd.manager.primary == manual_manifest && sd.serving); + assert(sd.manager.clear_calls == 1 && sd.manager.folder_sources == 3); + + // Unknown storage and a failed compatibility check disable every optional + // capability even if the store still reports a physically present SD card. + Context unsafe; + unsafe.fetch_store.fitted = TowerStore::Unsafe; + unsafe.begin(0x55, send, nullptr); + assert(!unsafe.prepareTowerStorage()); + assert(!unsafe.manager.full && !unsafe.manager.bootloader && !unsafe.manager.archive); + assert(!unsafe.self_serve_supported && unsafe.manager.encoder == nullptr); + Context incompatible; + incompatible.fetch_store.fitted = TowerStore::Sd; + incompatible.fetch_store.usable = false; + incompatible.begin(0x55, send, nullptr); + assert(!incompatible.prepareTowerStorage()); + assert(!incompatible.manager.full && !incompatible.manager.bootloader); + assert(!incompatible.self_serve_supported && incompatible.manager.encoder == nullptr); + + current = SelfFwInfo(); + Context legacy; + legacy.begin(0x55, send, nullptr, "fallback"); + assert(legacy.manager.target == 0x55 && legacy.manager.floor == 0); + assert(strcmp(legacy.hw_id, "fallback") == 0 && legacy.fetch_store.probes == 0); +} +''' + +HANDOFF_SETUP = r''' +#include +#include +#define OTA_TOWER_AUTO_STORE 1 +#define OTA_SD_STORE 1 +#define OTA_FLASH_STORE 1 +#define OTA_SD_BOOTLOADER_UPDATE 1 +static constexpr uint8_t GPREGRET2_OTA_STAGE_LEGACY = 0xD4; +static constexpr uint8_t GPREGRET2_OTA_STAGE_EXPANDED = 0xED; +static constexpr uint8_t GPREGRET2_OTA_STAGE_SD = 0x53; +static constexpr uint8_t GPREGRET2_OTA_STAGE_HYBRID = 0xA6; +static constexpr uint8_t GPREGRET2_OTA_STAGE_QSPI = 0x51; +static constexpr uint8_t GPREGRET_OTA_APPLY = 0x6A; +static constexpr uint8_t GPREGRET_OTA_BOOTLOADER_UPDATE = 0x6B; +struct Store { + bool hybrid = false, authorized = true; + unsigned published = 0; + bool is_hybrid() const { return hybrid; } + bool publish_hybrid_handoff() { ++published; return authorized; } +}; +static Store* g_nrf52_apply_store = nullptr; +static uint32_t ceiling = 0xED000; +static bool reset_cleared = true; +static uint8_t request = 0, source = 0; +static unsigned resets = 0; +[[maybe_unused]] static uint32_t ota_nrf52_effective_stage_ceiling() { return ceiling; } +[[maybe_unused]] static uint8_t mota_nrf52_flash_stage_handoff(uint32_t n) { return n == 0xED000 ? 0xED : 0xD4; } +[[maybe_unused]] static bool ota_nrf52_clear_reset_reasons() { return reset_cleared; } +[[maybe_unused]] static void ota_nrf52_set_reset_handoff(uint8_t r, uint8_t s) { request = r; source = s; } +[[maybe_unused]] static void NVIC_SystemReset() { ++resets; } +''' + +HANDOFF_CASES = r''' +int main() { + ota_reboot_to_apply(); + assert(request == 0x6A && source == 0x53 && resets == 1); + Store internal; + g_nrf52_apply_store = &internal; + ota_reboot_to_apply(); + assert(request == 0x6A && source == 0xED && resets == 2); + ceiling = 0xD4000; + ota_reboot_to_apply(); + assert(request == 0x6A && source == 0xD4 && resets == 3); + g_nrf52_apply_store = nullptr; + ota_reboot_to_apply(); + assert(request == 0x6A && source == 0x53 && resets == 4); + // Shared legacy handling remains fail closed if a retained source cannot be + // authorized. Tower's qualified application never enables this RAM store. + internal.hybrid = true; internal.authorized = false; + g_nrf52_apply_store = &internal; + ota_reboot_to_apply(); + assert(request == 0 && source == 0xBD && resets == 5); + internal.authorized = true; reset_cleared = false; + ota_reboot_to_apply(); + assert(request == 0 && source == 0xBD && resets == 6 && internal.published == 1); +} +''' + + +class TowerAutoIntegrationTest(unittest.TestCase): + def compile_run(self, source, expected=True): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) + cpp = path / "integration.cpp" + cpp.write_text(source, encoding="ascii") + executable = path / "integration" + subprocess.run(["g++", "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-I", str(ROOT / "src"), str(cpp), "-o", str(executable)], + check=True, timeout=30) + result = subprocess.run([str(executable)], cwd=path, capture_output=True, + text=True, timeout=10) + self.assertEqual(result.returncode == 0, expected, result.stderr) + if not expected: + self.assertIn("Assertion", result.stderr) + + def test_production_context_startup_and_deferred_storage_policy(self): + context = (ROOT / "src/helpers/ota/OtaContext.h").read_text() + methods = "\n".join(method(context, signature) for signature in ( + "OtaStoreSdNrf52& sdStagingStore()", + "bool prepareTowerStorage()", + "void begin(uint32_t target_id, OtaSend send, void* ctx", + )) + source = CONTEXT_SETUP + methods + CONTEXT_CASES + self.compile_run(source) + # Negative controls prove the fixture catches the original startup + # probe hazard and policy that accidentally advertises incompatible SD. + eager = methods.replace("manager.begin(target_id, send, ctx);", + "fetch_store.selectStorage(); manager.begin(target_id, send, ctx);") + self.compile_run(CONTEXT_SETUP + eager + CONTEXT_CASES, expected=False) + permissive = methods.replace("usable && fetch_store.usesExternal()", + "fetch_store.usesExternal()") + self.compile_run(CONTEXT_SETUP + permissive + CONTEXT_CASES, expected=False) + # The old unconditional cleanup erased explicit internal diagnostic + # offers on the very next maintenance loop, blocking base capture. + unconditional_clear = methods.replace( + " const bool was_self_serve_supported = self_serve_supported;\n", "").replace( + "was_self_serve_supported && !self_serve_supported &&", + "!self_serve_supported &&") + self.assertNotEqual(unconditional_clear, methods) + self.compile_run(CONTEXT_SETUP + unconditional_clear + CONTEXT_CASES, + expected=False) + + def test_production_reset_handoff_follows_approved_application_store(self): + apply = (ROOT / "src/helpers/ota/OtaApply.cpp").read_text() + production = method(apply, "void ota_reboot_to_apply() { // public:") + self.compile_run(HANDOFF_SETUP + production + HANDOFF_CASES) + old_dispatch = production.replace( + "#if defined(OTA_RAK_AUTO_STORE) || defined(OTA_TOWER_AUTO_STORE)", + "#if defined(OTA_RAK_AUTO_STORE)", 1) + # Removing Tower from adaptive dispatch must break the runtime fixture; + # it would always request SD even after internal-flash approval. + self.compile_run(HANDOFF_SETUP + old_dispatch + HANDOFF_CASES, expected=False) + + def test_selection_precedes_live_resume_and_cli_codec_checks(self): + mesh = (ROOT / "src/Mesh.cpp").read_text() + maintenance = method(mesh, "void __attribute__((noinline)) Mesh::serviceLoopMaintenance()") + self.assertLess(maintenance.index("prepareTowerStorage()"), + maintenance.index("resumeStaged(nullptr)")) + cli = (ROOT / "src/helpers/ota/OtaCli.cpp").read_text() + self.assertLess(cli.index("c.prepareTowerStorage()"), cli.index("c.manager.codecOk(selcodec)")) + context = (ROOT / "src/helpers/ota/OtaContext.h").read_text() + boot_apply = method(context, "bool apply_fetched_bootloader(") + self.assertIn("if (!fetch_store.usesExternal())", boot_apply) + self.assertIn("fetch_store.sdStore(), allow", boot_apply) + apply = (ROOT / "src/helpers/ota/OtaApply.cpp").read_text() + sd_apply = method(apply, "bool ota_apply_mota_nrf52(OtaStoreSdNrf52& store") + self.assertLess(sd_apply.index("g_nrf52_apply_store = nullptr"), + sd_apply.index("ota_apply_mota_nrf52_external")) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_tower_auto_storage.py b/test/test_tower_auto_storage.py new file mode 100644 index 00000000..76a88145 --- /dev/null +++ b/test/test_tower_auto_storage.py @@ -0,0 +1,440 @@ +#!/usr/bin/env python3 +"""Run the MeshTower selector with peripheral stores and the real manifest parser.""" + +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +OTA = ROOT / "src/helpers/ota" +FLAGS = ["-DNRF52_PLATFORM=1", "-DHELTEC_TOWER_V2_SDCARD=1", + "-DOTA_SD_STORE=1", "-DOTA_FLASH_STORE=1", "-DOTA_TOWER_AUTO_STORE=1"] + +CAPS = r''' +#pragma once +#include +namespace mesh { namespace ota { +static const uint8_t OTA_BL_STORAGE_SD = 1; +static const uint8_t OTA_BL_STORAGE_STAGE_CEILING = 2; +struct OtaBlCaps { + bool present = false; + uint16_t apply_abi = 3, codec_mask = 5; + uint8_t storage_flags = 9, optional_app_storage = 2; +}; +extern OtaBlCaps installed; +extern int cap_reads; +inline OtaBlCaps ota_bootloader_app_caps() { ++cap_reads; return installed; } +} } +''' + +STORES = r''' +#pragma once +#include "OtaStore.h" +#include +namespace mesh { +class MainBoard {}; +namespace ota { +struct Medium { + int constructors = 0, destructors = 0, probes = 0; + int begin_calls = 0, plan_calls = 0, read_calls = 0, write_calls = 0; + int clear_calls = 0, discard_calls = 0, reopen_calls = 0; + int meta_calls = 0, finalize_calls = 0, checkpoints = 0; + int format_calls = 0, erase_calls = 0, space_calls = 0, list_calls = 0; + bool present = true, mountable = true, begin_ok = true, layout_ok = true; + bool io_ok = true, discard_ok = true, staged = false; + uint32_t total = 0, last_target = 0; + const uint8_t* last_mid = nullptr; + uint8_t bytes[512] = {0}; +}; +extern Medium sd_medium, flash_medium; +extern int auth_clears; +class FakeStore : public OtaStore { +protected: + Medium& media; + uint32_t total = 0; +public: + explicit FakeStore(Medium& m) : media(m) { ++media.constructors; } + ~FakeStore() override { ++media.destructors; } + bool begin(uint32_t n) override { + ++media.begin_calls; + total = 0; + if (!media.present || !media.begin_ok) return false; + total = media.total = n; media.staged = true; return true; + } + bool write(uint32_t p, const uint8_t* d, uint32_t n) override { + ++media.write_calls; + if (!media.present || !media.io_ok || p + n > total || p + n > sizeof(media.bytes)) return false; + memcpy(media.bytes + p, d, n); return true; + } + bool read(uint32_t p, uint8_t* d, uint32_t n) const override { + ++media.read_calls; + if (!media.present || !media.io_ok || p + n > total || p + n > sizeof(media.bytes)) return false; + memcpy(d, media.bytes + p, n); return true; + } + uint32_t capacity() const override { return media.present ? 8192u : 0u; } + uint32_t staged_size() const override { return total; } + void clear() override { ++media.clear_calls; total = 0; } + bool discard() override { + ++media.discard_calls; total = 0; + if (!media.present || !media.discard_ok) return false; + media.staged = false; media.total = 0; return true; + } + bool set_meta_size(uint32_t) override { ++media.meta_calls; return media.io_ok; } + bool finalize() override { ++media.finalize_calls; return media.present && media.io_ok; } + void checkpoint() override { ++media.checkpoints; } + bool reopen() override { + ++media.reopen_calls; + total = media.present && media.staged ? media.total : 0; + return total != 0; + } + bool reopenFor(const uint8_t* mid, uint32_t target) override { + media.last_mid = mid; media.last_target = target; return reopen(); + } + bool plan_layout(bool, uint32_t, uint32_t, uint32_t, bool) override { + ++media.plan_calls; return media.layout_ok; + } +}; +class OtaStoreSdNrf52 : public FakeStore { +public: + OtaStoreSdNrf52() : FakeStore(sd_medium) { ++auth_clears; } + bool probeMedia() { ++media.probes; return media.present && media.mountable; } + void clear() override { ++media.clear_calls; total = 0; media.staged = false; } + const char* last_error() const { return "SD peripheral error"; } + bool formatCard(MainBoard&) { ++media.format_calls; return media.present; } + bool eraseCard(MainBoard&) { ++media.erase_calls; return media.present; } + bool getSpace(MainBoard&, uint64_t& used, uint64_t& free) { + ++media.space_calls; used = 1; free = 2; return media.present; + } + bool listFiles(MainBoard&, uint16_t, char*, size_t) { ++media.list_calls; return media.present; } +}; +class OtaStoreFlashNrf52 : public FakeStore { +public: + OtaStoreFlashNrf52() : FakeStore(flash_medium) { ++auth_clears; } +}; +} } +''' + +DRIVER = r''' +#include "OtaStoreTowerNrf52.h" +#include "OtaByteIO.h" +#include +#include +using namespace mesh::ota; +namespace mesh { namespace ota { +OtaBlCaps installed; +Medium sd_medium, flash_medium; +int cap_reads = 0, auth_clears = 0; +} } + +static void defaults() { + installed.present = true; + installed.storage_flags = 9; + installed.optional_app_storage = 2; +} +static void manifest(Medium& m, uint8_t flags = 0) { + m.staged = true; + memset(m.bytes, 0, sizeof(m.bytes)); + uint8_t* p = m.bytes + 8; + const bool boot = flags & MFLAG_BOOTLOADER; + p[0] = boot ? MOTA_BOOT_FORMAT_VER : MOTA_APP_FORMAT_VER; + p[1] = flags; p[2] = HASH_ALGO_SHA256; + wr_u32le(p + 3, 11); wr_u32le(p + 7, 1); + wr_u32le(p + 11, boot ? 0xA000u : 1024u); + wr_u32le(p + 15, boot ? 0xA000u : 64u); + p[19] = 10; + p[20] = 1; p[21] = 2; p[22] = 3; p[23] = 4; + p[56] = flags & MFLAG_FULL ? CODEC_FULL : CODEC_DETOOLS_INPLACE; + m.total = boot ? 41330u : 278u; +} +static void startup() { + OtaStoreTowerNrf52 store; + assert(sd_medium.constructors == 1 && flash_medium.constructors == 0 && auth_clears == 1); + OtaStoreSdNrf52* owner = &store.sdStore(); + assert(owner == &store.externalStore()); + assert(!store.usesExternalStore() && !store.usesExternal() && !store.usesInternal()); + assert(!store.compatible() && store.capacity() == 0 && store.staged_size() == 0); + uint8_t byte = 0; + assert(!store.read(0, &byte, 1) && !store.write(0, &byte, 1)); + assert(!store.set_meta_size(1) && !store.finalize()); + store.checkpoint(); store.clear(); store.resetSelection(); + assert(owner == &store.sdStore()); + assert(sd_medium.probes == 0 && cap_reads == 0 && flash_medium.constructors == 0); + assert(sd_medium.clear_calls == 0 && sd_medium.reopen_calls == 0); +} +static void primary() { + defaults(); OtaStoreTowerNrf52 store; + assert(store.selectStorage() && store.usesExternalStore() && store.compatible()); + assert(sd_medium.probes == 1 && sd_medium.reopen_calls == 0 && flash_medium.constructors == 0); + assert(store.plan_layout(true, 1024, 209, 64, false)); + assert(store.begin(278)); + uint8_t data[4] = {1, 2, 3, 4}, got[4] = {0}; + assert(store.write(0, data, 4) && store.read(0, got, 4) && memcmp(data, got, 4) == 0); + assert(store.set_meta_size(16) && store.finalize()); store.checkpoint(); + assert(store.staged_size() == 278 && store.capacity() == 8192); + assert(store.selectStorage() && sd_medium.probes == 1 && cap_reads == 1); + assert(sd_medium.begin_calls == 1 && sd_medium.meta_calls == 1 && sd_medium.finalize_calls == 1); + assert(sd_medium.checkpoints == 1 && flash_medium.begin_calls == 0 && auth_clears == 1); + assert(store.plan_layout(true, 0xA000, 365, 0xA000, true)); +} +static void fallback(bool unmountable) { + defaults(); sd_medium.present = unmountable; sd_medium.mountable = false; + OtaStoreTowerNrf52 store; + assert(store.selectStorage() && store.usesInternal() && !store.usesExternal()); + assert(flash_medium.constructors == 1 && auth_clears == 2); + assert(!store.begin(278) && flash_medium.begin_calls == 0); + assert(!store.plan_layout(true, 1024, 209, 64, false)); + assert(!store.begin(278) && flash_medium.begin_calls == 0); + assert(!store.plan_layout(true, 0xA000, 365, 0xA000, true)); + assert(!store.begin(41330) && flash_medium.begin_calls == 0); + assert(store.plan_layout(false, 1024, 209, 64, false) && store.begin(278)); + assert(flash_medium.plan_calls == 1 && flash_medium.begin_calls == 1); + uint8_t data = 7, got = 0; + assert(store.write(0, &data, 1) && store.read(0, &got, 1) && got == data); + assert(store.set_meta_size(16) && store.finalize()); store.checkpoint(); + sd_medium.present = sd_medium.mountable = true; + assert(store.selectStorage() && store.usesInternal() && sd_medium.probes == 1); + assert(sd_medium.begin_calls == 0 && sd_medium.plan_calls == 0); + store.clear(); + assert(store.usesInternal() && !store.begin(278) && flash_medium.begin_calls == 1); + assert(store.plan_layout(false, 1024, 209, 64, false) && store.begin(278)); +} +static void old_or_unqualified() { + defaults(); sd_medium.present = false; + for (uint8_t option : {0u, 1u, 4u, 6u}) { + installed.optional_app_storage = option; + OtaStoreTowerNrf52 store; + assert(!store.selectStorage() && !store.usesInternal() && !store.usesExternal()); + assert(!store.begin(278) && store.capacity() == 0); + } + installed.optional_app_storage = 2; installed.storage_flags = 2; + { OtaStoreTowerNrf52 store; assert(!store.selectStorage()); } + installed.storage_flags = 9; installed.present = false; + { OtaStoreTowerNrf52 store; assert(!store.selectStorage()); } + assert(flash_medium.constructors == 0 && flash_medium.begin_calls == 0); + installed.present = true; installed.optional_app_storage = 0; sd_medium.present = true; + { OtaStoreTowerNrf52 store; assert(store.selectStorage() && store.usesExternal()); } + installed.present = false; + { OtaStoreTowerNrf52 store; + assert(!store.selectStorage() && store.usesExternal() && !store.compatible()); + assert(!store.begin(278)); } +} +static void failures() { + defaults(); OtaStoreTowerNrf52 store; + assert(store.selectStorage()); + sd_medium.layout_ok = false; + assert(!store.plan_layout(false, 1024, 209, 64, false) && !store.begin(278)); + assert(sd_medium.begin_calls == 0 && store.usesExternal() && flash_medium.constructors == 0); + sd_medium.layout_ok = true; sd_medium.begin_ok = false; + assert(store.plan_layout(false, 1024, 209, 64, false) && !store.begin(278)); + assert(sd_medium.begin_calls == 1 && flash_medium.constructors == 0); + sd_medium.begin_ok = true; assert(store.begin(278)); + sd_medium.present = false; uint8_t byte = 0; + assert(!store.write(0, &byte, 1) && !store.read(0, &byte, 1) && !store.finalize()); + assert(store.usesExternal() && store.selectStorage() && sd_medium.probes == 1); + assert(flash_medium.constructors == 0 && !store.discard()); + store.resetSelection(); + assert(store.selectStorage() && store.usesInternal() && flash_medium.constructors == 1); +} +static void resume(bool internal) { + defaults(); sd_medium.present = !internal; + Medium& m = internal ? flash_medium : sd_medium; + manifest(m); + OtaStoreTowerNrf52 store; + uint8_t good[4] = {1, 2, 3, 4}, wrong[4] = {9, 9, 9, 9}; + assert(!store.reopenFor(wrong, 11) && store.staged_size() == 0); + assert(m.staged && m.clear_calls == 0 && m.discard_calls == 0); + assert(!store.reopenFor(nullptr, 22) && store.staged_size() == 0); + assert(store.reopenFor(good, 11) && store.staged_size() == m.total); + if (internal) assert(m.last_mid == good && m.last_target == 11); + assert(store.reopen() && store.staged_size() == m.total); + manifest(m, MFLAG_FULL); + assert(store.reopen() == !internal); + manifest(m, MFLAG_FULL | MFLAG_SIGNED | MFLAG_BOOTLOADER); + assert(store.reopen() == !internal); + m.bytes[8] = 99; + assert(!store.reopen()); + assert(m.staged && m.clear_calls == 0 && m.discard_calls == 0); + assert(sd_medium.probes == 1); +} +static void cancellation() { + defaults(); sd_medium.present = false; + OtaStoreTowerNrf52 store; OtaStoreSdNrf52* owner = &store.sdStore(); + assert(store.plan_layout(false, 1024, 209, 64, false) && store.begin(278)); + store.clear(); assert(store.staged_size() == 0 && flash_medium.staged); + manifest(flash_medium); assert(store.reopen()); + flash_medium.discard_ok = false; assert(!store.discard() && flash_medium.staged); + flash_medium.discard_ok = true; assert(store.discard() && !flash_medium.staged); + sd_medium.present = true; store.resetSelection(); + assert(flash_medium.destructors == 1 && owner == &store.sdStore()); + assert(store.capacity() == 0 && store.selectStorage() && store.usesExternal()); + assert(store.begin(278)); store.clear(); + assert(store.usesExternal() && !sd_medium.staged && sd_medium.clear_calls == 1); + assert(sd_medium.probes == 2); +} +static void management() { + defaults(); sd_medium.present = false; OtaStoreTowerNrf52 store; + assert(store.selectStorage() && store.usesInternal()); + mesh::MainBoard board; uint64_t used = 0, free = 0; char out[20]; + assert(!store.formatCard(board) && !store.eraseCard(board)); + assert(!store.getSpace(board, used, free) && !store.listFiles(board, 0, out, sizeof(out))); + assert(sd_medium.format_calls == 1 && sd_medium.erase_calls == 1); + assert(sd_medium.space_calls == 1 && sd_medium.list_calls == 1); + sd_medium.present = true; + assert(store.formatCard(board) && store.eraseCard(board)); + assert(store.getSpace(board, used, free) && used == 1 && free == 2); + assert(store.listFiles(board, 1, out, sizeof(out)) && store.usesInternal()); + assert(flash_medium.begin_calls == 0 && sd_medium.probes == 1); +} +int main(int argc, char** argv) { + assert(argc == 2); + if (!strcmp(argv[1], "startup")) startup(); + else if (!strcmp(argv[1], "primary")) primary(); + else if (!strcmp(argv[1], "missing")) fallback(false); + else if (!strcmp(argv[1], "unmountable")) fallback(true); + else if (!strcmp(argv[1], "legacy")) old_or_unqualified(); + else if (!strcmp(argv[1], "failures")) failures(); + else if (!strcmp(argv[1], "resume-sd")) resume(false); + else if (!strcmp(argv[1], "resume-internal")) resume(true); + else if (!strcmp(argv[1], "cancel")) cancellation(); + else if (!strcmp(argv[1], "management")) management(); + else return 2; +} +''' + +PROBE = r''' +#include "OtaStoreSdNrf52.h" +#include +#include +using namespace mesh::ota; +static bool mountable; +static unsigned mounts, capacity_calls; +static uint32_t sectors; +namespace mesh { namespace ota { +OtaStoreSdNrf52::OtaStoreSdNrf52() {} +OtaStoreSdNrf52::~OtaStoreSdNrf52() {} +bool OtaStoreSdNrf52::mount() { ++mounts; return mountable; } +uint32_t OtaStoreSdNrf52::capacity() const { ++capacity_calls; return sectors * 512u; } +bool OtaStoreSdNrf52::begin(uint32_t) { abort(); } +bool OtaStoreSdNrf52::write(uint32_t, const uint8_t*, uint32_t) { abort(); } +bool OtaStoreSdNrf52::read(uint32_t, uint8_t*, uint32_t) const { abort(); } +void OtaStoreSdNrf52::clear() { abort(); } +bool OtaStoreSdNrf52::discard() { abort(); } +bool OtaStoreSdNrf52::set_meta_size(uint32_t) { abort(); } +bool OtaStoreSdNrf52::finalize() { abort(); } +void OtaStoreSdNrf52::checkpoint() { abort(); } +bool OtaStoreSdNrf52::reopen() { abort(); } +bool OtaStoreSdNrf52::plan_layout(bool, uint32_t, uint32_t, uint32_t, bool) { abort(); } +} } +int main() { + OtaStoreSdNrf52 store; + assert(mounts == 0 && capacity_calls == 0); + mountable = false; sectors = 10; + assert(!store.probeMedia() && mounts == 1 && capacity_calls == 0); + mountable = true; sectors = 0; + assert(!store.probeMedia() && mounts == 2 && capacity_calls == 1); + sectors = 10; + assert(store.probeMedia() && mounts == 3 && capacity_calls == 2); +} +''' + + +class TowerAutoStorageTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.compiler = shutil.which("c++") + if not cls.compiler: + raise unittest.SkipTest("C++ compiler unavailable") + cls.temp = tempfile.TemporaryDirectory(prefix="tower-auto-storage-") + cls.work = Path(cls.temp.name) + for name in ("OtaStore.h", "OtaFormat.h", "MotaContainer.h", "OtaByteIO.h"): + shutil.copyfile(OTA / name, cls.work / name) + (cls.work / "OtaBlInfo.h").write_text(CAPS) + (cls.work / "stores.h").write_text(STORES) + for name in ("OtaStoreFlashNrf52.h", "OtaStoreSdNrf52.h"): + (cls.work / name).write_text('#include "stores.h"\n') + (cls.work / "Utils.h").write_text(""" +#pragma once +#include +#include +namespace mesh { class Utils { public: +static void sha256(uint8_t*, size_t, const uint8_t*, int); +static void sha256(uint8_t*, size_t, const uint8_t*, int, const uint8_t*, int); +}; } +""") + (cls.work / "test.cpp").write_text('#include \n' + DRIVER) + cls.header = (OTA / "OtaStoreTowerNrf52.h").read_text() + cls.exe = cls.compile(cls.header, "selector") + + @classmethod + def tearDownClass(cls): + cls.temp.cleanup() + + @classmethod + def compile(cls, header, name): + (cls.work / "OtaStoreTowerNrf52.h").write_text(header) + exe = cls.work / name + subprocess.run([cls.compiler, "-std=c++11", "-Wall", "-Wextra", "-Werror", + "-ffunction-sections", "-fdata-sections", "-Wl,--gc-sections", + *FLAGS, "-I", str(cls.work), "-I", str(OTA), + str(cls.work / "test.cpp"), str(OTA / "MotaContainer.cpp"), + "-o", str(exe)], check=True, capture_output=True, text=True) + return exe + + def test_selection_and_transfer_boundaries(self): + for scenario in ("startup", "primary", "missing", "unmountable", "legacy", + "failures", "resume-sd", "resume-internal", "cancel", "management"): + with self.subTest(scenario=scenario): + subprocess.run([str(self.exe), scenario], check=True, capture_output=True) + + def test_exact_real_sd_probe_never_opens_or_removes_files(self): + # Keep the source away from the selector's peripheral doubles so its + # quoted include resolves to the real production SD header. + probe_dir = self.work / "real-probe" + probe_dir.mkdir() + source = probe_dir / "probe.cpp" + source.write_text(PROBE) + exe = self.work / "probe" + subprocess.run([self.compiler, "-std=c++11", "-Wall", "-Wextra", "-Werror", + "-DNRF52_PLATFORM=1", "-DOTA_SD_STORE=1", "-I", str(OTA), + str(source), "-o", str(exe)], check=True, capture_output=True, text=True) + subprocess.run([str(exe)], check=True, capture_output=True) + + def test_unsupported_recipe_cannot_enable_tower_selection(self): + (self.work / "OtaStoreTowerNrf52.h").write_text(self.header) + source = self.work / "guard.cpp" + source.write_text('#include "OtaStoreTowerNrf52.h"\n') + for missing in FLAGS[:-1]: + with self.subTest(missing=missing): + flags = [flag for flag in FLAGS if flag != missing] + result = subprocess.run([self.compiler, "-std=c++11", "-fsyntax-only", + *flags, "-I", str(self.work), str(source)], + capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn("requires the qualified nRF52 SD primary", result.stderr) + + def test_negative_controls_expose_unsafe_selection_and_resume(self): + mutations = [ + ("startup-probe", "OtaStoreTowerNrf52() = default;", + "OtaStoreTowerNrf52() { selectStorage(); }", "startup"), + ("legacy-fallback", "sd_profile && caps.optional_app_storage == OTA_BL_STORAGE_STAGE_CEILING", + "sd_profile", "legacy"), + ("internal-full", "_mode == INTERNAL_MODE && (full || bootloader)", + "false", "missing"), + ("missing-layout", "_mode == INTERNAL_MODE && !_layout_accepted", + "false", "missing"), + ("resume-identity", "reopened && resumeMatches(mid, target)", + "reopened", "resume-sd"), + ] + for name, old, new, scenario in mutations: + with self.subTest(mutation=name): + self.assertEqual(self.header.count(old), 1) + exe = self.compile(self.header.replace(old, new), name) + result = subprocess.run([str(exe), scenario], capture_output=True) + self.assertNotEqual(result.returncode, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_tower_bootloader_caps.py b/test/test_tower_bootloader_caps.py new file mode 100644 index 00000000..4c192a5f --- /dev/null +++ b/test/test_tower_bootloader_caps.py @@ -0,0 +1,335 @@ +"""Check actual nRF52 capability parsing for SD-primary Tower storage.""" + +from pathlib import Path +import struct +import subprocess +import sys +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "tools/mota")) +import motalib as ml +from test_mota import _generic_bootloader_image, _rewrite_boot_image + + +SOURCE = r''' +#include +#include +#include +#include "helpers/ota/OtaBlInfo.h" + +using namespace mesh::ota; + +static const uint8_t bundle[32] = { + 'M','O','T','A','R','A','M','A',1,0,72,0,0,0,1,0, + 'M','O','T','A','S','T','O','R',1,0,16,0,2,0,0,0, +}; + +static void imageFor(uint8_t* image, uint8_t primary, uint8_t optional) { + memset(image, 0, 256); + const uint8_t boot[16] = { + 'M','O','T','A','B','L','D','R',3,0,5,0,0,0,0,0, + }; + memcpy(image, boot, sizeof(boot)); + image[12] = primary; + if (optional) { + memcpy(image + 32, bundle, sizeof(bundle)); + image[60] = optional; + } +} + +static void expectViews(const uint8_t* image, uint8_t primary, + uint8_t application, uint8_t optional) { + const OtaBlCaps app = ota_bl_app_caps_scan(image, 256); + assert(app.present && app.storage_flags == application); + assert(app.optional_app_storage == optional); + const OtaBlCaps update = ota_bl_update_caps_scan_aligned(image, 256, primary); + assert(update.present && update.storage_flags == primary); + assert(update.optional_app_storage == optional); +} + +int main() { + uint8_t image[256]; + imageFor(image, 0x09, 0x02); + expectViews(image, 0x09, 0x0B, 0x02); + assert(ota_bootloader_supports_expanded_stage(ota_bl_app_caps_scan(image, 256))); + assert(ota_nrf52_effective_stage_ceiling(ota_bl_app_caps_scan(image, 256)) == 0xED000); + + // An ordinary SD loader remains valid for SD and privileged SD upgrades, + // but its primary marker must never be mistaken for internal support. + imageFor(image, 0x09, 0); + expectViews(image, 0x09, 0x09, 0); + assert(!ota_bootloader_supports_expanded_stage(ota_bl_app_caps_scan(image, 256))); + assert(ota_nrf52_effective_stage_ceiling(ota_bl_app_caps_scan(image, 256)) == 0xD4000); + + // RAK keeps the deployed internal privileged contract and optional NOR. + imageFor(image, 0x0A, 0x14); + expectViews(image, 0x0A, 0x1E, 0x14); + imageFor(image, 0x0A, 0); + expectViews(image, 0x0A, 0x0A, 0); + + // Optional records are meaningful only under their exact primary profile. + for (uint8_t primary : {uint8_t(0x09), uint8_t(0x0A), uint8_t(0x0E), uint8_t(0x1E)}) { + for (uint8_t optional : {uint8_t(0x02), uint8_t(0x14)}) { + imageFor(image, primary, optional); + const bool qualified = (primary == 0x09 && optional == 2) || + (primary == 0x0A && optional == 0x14); + expectViews(image, primary, qualified ? primary | optional : primary, + qualified ? optional : 0); + } + } + + // Every field of the frozen adjacent bundle is enforced. A malformed + // optional declaration cannot remove the still-valid SD primary profile. + for (unsigned byte = 0; byte < sizeof(bundle); ++byte) { + imageFor(image, 0x09, 0x02); + image[32 + byte] ^= 1; + expectViews(image, 0x09, 0x09, 0); + } + imageFor(image, 0x09, 0x02); + memcpy(image + 80, bundle, sizeof(bundle)); + expectViews(image, 0x09, 0x09, 0); // duplicate RAM and STOR + imageFor(image, 0x09, 0x02); + memcpy(image + 80, bundle, 16); + expectViews(image, 0x09, 0x09, 0); // duplicate RAM alone + imageFor(image, 0x09, 0x02); + memcpy(image + 80, bundle + 16, 16); + expectViews(image, 0x09, 0x09, 0); // duplicate STOR alone + imageFor(image, 0x09, 0x02); + memmove(image + 52, image + 48, 16); + memset(image + 48, 0, 4); + expectViews(image, 0x09, 0x09, 0); // separated records + imageFor(image, 0x09, 0x02); + memmove(image + 33, image + 32, 32); + image[32] = 0; + expectViews(image, 0x09, 0x09, 0); // byte-shifted bundle + imageFor(image, 0x09, 0x02); + assert(ota_bl_optional_app_storage(image, 63) == 0); + assert(ota_bl_optional_app_storage(nullptr, 256) == 0); + + // A second privileged marker must not be hidden by optional metadata. + memcpy(image + 128, image, 16); + assert(!ota_bl_update_caps_scan_aligned(image, 256, 0x09).present); + + // Exercise the production memory-mapped entry points without defining the + // retained-RAM application flag: this app retains the full SD RAM layout. + void* flash = mmap(reinterpret_cast(uintptr_t(0xF4000)), 0xA000, + PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED, + -1, 0); + assert(flash == reinterpret_cast(uintptr_t(0xF4000))); + memset(flash, 0, 0xA000); +#if defined(OTA_INTERNAL_BOOTLOADER_UPDATE) + imageFor(static_cast(flash), 0x0A, 0x14); + assert(ota_bootloader_update_storage_flags() == 0x0A); + assert(ota_bootloader_app_caps().storage_flags == 0x1E); + assert(ota_bootloader_update_caps().storage_flags == 0x0A); +#else + imageFor(static_cast(flash), 0x09, 0x02); + assert(ota_bootloader_update_storage_flags() == 0x09); + assert(ota_bootloader_app_caps().storage_flags == 0x0B); + OtaBlCaps update = ota_bootloader_update_caps(); + assert(update.storage_flags == 0x09 && update.optional_app_storage == 2); + assert(ota_bootloader_self_update_caps_valid(update)); + imageFor(static_cast(flash), 0x09, 0); + update = ota_bootloader_update_caps(); + assert(update.storage_flags == 0x09 && update.optional_app_storage == 0); + assert(ota_bootloader_self_update_caps_valid(update)); +#endif + assert(!ota_bootloader_ram_caps().present); + assert(munmap(flash, 0xA000) == 0); +} +''' + +STREAM_SOURCE = r''' +#include +#include +#include +#include +#include +#include +#include "helpers/ota/OtaBootloaderUpdate.h" +using namespace mesh::ota; +struct Store { + std::vector bytes; + bool fail_read = false; + bool read(uint32_t off, uint8_t* out, uint32_t len) { + if (uint64_t(off) + len > bytes.size() || (fail_read && off == 37 + 512)) return false; + memcpy(out, bytes.data() + off, len); + return true; + } +}; +int main(int argc, char** argv) { + assert(argc == 6); + std::ifstream input(argv[1], std::ios::binary); + Store store; + store.bytes.resize(37, 0xA5); + store.bytes.insert(store.bytes.end(), std::istreambuf_iterator(input), {}); + assert(store.bytes.size() == 37 + OTA_BOOT_IMAGE_SIZE); + store.fail_read = std::stoul(argv[5]) != 0; + OtaBootloaderIdentity identity; + OtaBootloaderCapsMarker caps; + const bool actual = ota_bootloader_external_image_metadata( + store, 37, uint8_t(std::stoul(argv[2])), identity, caps, + uint8_t(std::stoul(argv[3]))); + assert(actual == (std::stoul(argv[4]) != 0)); + if (actual) { + assert(identity.present && identity.crc_ok && identity.continuity_present); + assert(identity.manifest_offset == OTA_BOOT_CANDIDATE_MANIFEST_OFFSET); + assert(caps.storage_flags == std::stoul(argv[2])); + } +} +''' + +UTILS_DECLARATIONS = """#pragma once +#include +#include +namespace mesh { struct Utils { + static void sha256(uint8_t*, size_t, const uint8_t*, int); + static void sha256(uint8_t*, size_t, const uint8_t*, int, const uint8_t*, int); +}; } +""" + + +class TowerBootloaderCapsTest(unittest.TestCase): + def test_sd_primary_and_rak_capability_views(self): + for profile in ("OTA_SD_BOOTLOADER_UPDATE", "OTA_INTERNAL_BOOTLOADER_UPDATE"): + with self.subTest(profile=profile), tempfile.TemporaryDirectory() as directory: + path = Path(directory) + source = path / "caps.cpp" + source.write_text("#include \n" + SOURCE, encoding="ascii") + executable = path / "caps" + subprocess.run([ + "g++", "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-DNRF52_PLATFORM=1", f"-D{profile}=1", + "-DMOTA_NRF52_TEST_LAYOUT_STAGE_CEILING=0xED000u", + "-I", str(ROOT / "src"), str(source), "-o", str(executable), + ], check=True, timeout=30) + subprocess.run([str(executable)], check=True, timeout=10) + + def test_auto_storage_guard_allows_only_sd_primary_tower(self): + required = ["NRF52_PLATFORM", "HELTEC_TOWER_V2_SDCARD", "OTA_SD_STORE", + "OTA_FLASH_STORE", "OTA_SD_BOOTLOADER_UPDATE"] + forbidden = ["OTA_QSPI_STORE", "QSPIFLASH", "OTA_RAK_AUTO_STORE", + "OTA_INTERNAL_BOOTLOADER_UPDATE", "OTA_QSPI_BOOTLOADER_UPDATE", + "OTA_HYBRID_RAM_STORE"] + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) + (path / "Utils.h").write_text(UTILS_DECLARATIONS, encoding="ascii") + source = path / "guard.cpp" + source.write_text('#include "helpers/ota/OtaBootloaderUpdate.h"\n', encoding="ascii") + cases = [(required + ["OTA_TOWER_AUTO_STORE"], True), + ([item for item in required if item != "OTA_FLASH_STORE"], True), + (required, False)] + cases += [([item for item in required if item != missing] + + ["OTA_TOWER_AUTO_STORE"], False) for missing in required] + cases += [(required + ["OTA_TOWER_AUTO_STORE", item], False) for item in forbidden] + for flags, expected in cases: + with self.subTest(flags=flags): + result = subprocess.run([ + "g++", "-std=c++17", "-fsyntax-only", "-I", str(path), + "-I", str(ROOT / "src"), *[f"-D{flag}=1" for flag in flags], + str(source), + ], capture_output=True, text=True, timeout=30) + self.assertEqual(result.returncode == 0, expected, result.stderr) + if not expected: + self.assertIn("#error", result.stderr) + + def test_streamed_successors_retain_unique_adjacent_optional_storage(self): + base = _generic_bootloader_image(0x239A0071, "TOWER_V2_OTA", ml.BOOT_STORAGE_SD_UPDATE) + image = self.with_optional(base, 2) + ram, optional = image[0x90:0xA0], image[0xA0:0xB0] + cases = [(base, 9, 0, True, False), (base, 9, 2, False, False), + (image, 9, 2, True, False), (image, 9, 0, True, False), + (image, 9, 0x14, False, False), (image, 9, 2, False, True)] + for offset in (480, 496, 500, 508, 512, 1020): + def move_pair(data, offset=offset): + data[0x90:0xB0] = b"\xff" * 32 + data[offset:offset + 32] = ram + optional + cases.append((_rewrite_boot_image(image, move_pair), 9, 2, True, False)) + mutations = [ + lambda data: data.__setitem__(slice(0x200, 0x210), ram), + lambda data: data.__setitem__(slice(0x200, 0x210), optional), + lambda data: data.__setitem__(slice(0x70, 0x80), optional), + lambda data: data.__setitem__(0xAC, 0x14), + lambda data: data.__setitem__(0xAD, 1), + lambda data: data.__setitem__(0x98, 2), + lambda data: data.__setitem__(slice(0x90, 0xA0), b"\xff" * 16), + ] + for mutation in mutations: + candidate = _rewrite_boot_image(image, mutation) + cases += [(candidate, 9, 2, False, False), (candidate, 9, 0, True, False)] + rak = self.with_optional(_generic_bootloader_image(), 0x14) + cases += [(rak, 0x0A, 0x14, True, False), (rak, 0x0A, 2, False, False)] + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) + (path / "Utils.h").write_text(UTILS_DECLARATIONS, encoding="ascii") + source = path / "stream.cpp" + source.write_text(STREAM_SOURCE, encoding="ascii") + executable = path / "stream" + subprocess.run([ + "g++", "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-I", str(path), "-I", str(ROOT / "src"), str(source), + "-o", str(executable), + ], check=True, timeout=30) + for index, (candidate, primary, required, expected, fail_read) in enumerate(cases): + with self.subTest(case=index): + payload = path / "image.bin" + payload.write_bytes(candidate) + subprocess.run([str(executable), str(payload), str(primary), str(required), + str(int(expected)), str(int(fail_read))], check=True, timeout=10) + + @staticmethod + def with_optional(image, optional): + def add_storage(data): + struct.pack_into(" bool: def bootloader_optional_app_storage(image: bytes) -> int: - record = (b"MOTARAMA" + struct.pack("