diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 276c3e08b..cf915200b 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -10,6 +10,36 @@ on: workflow_dispatch: jobs: + bootloader-version: + runs-on: ubuntu-latest + steps: + - name: Clone Repo + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: '3.13' + + - name: Verify bootloader version regressions without hardware or downloads + run: python3 -B test/test_nrf52_bootloader_version.py -v + + - name: Install signed-package verification dependency + run: python3 -m pip install cryptography + + - name: Verify old-firmware compatibility in both OTA launchers + run: python3 -B -m unittest discover -s tools/lora_ota -p test_lora_ota_bootloader_version.py -v + + - name: Verify all signed OTAFIX 2.4.6 release images + env: + MESHCORE_BOOTLOADER_MOTA_ZIP: ${{ runner.temp }}/OTAFIX-2.4.6-bootloader-mota.zip + # The test pins the archive SHA-256, verifies every .mota signature, + # and checks the exact MeshTower V2 SD CRC from the bug report. + run: | + curl --fail --location --retry 3 --max-time 120 --output "$MESHCORE_BOOTLOADER_MOTA_ZIP" \ + https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/download/0.11.0-OTAFIX2.4.6/OTAFIX-2.4.6-bootloader-mota.zip + python3 -B test/test_nrf52_bootloader_version.py -v + test: runs-on: ubuntu-latest steps: diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 658b3bba3..d52f54101 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -4028,6 +4028,26 @@ This setting is used only by `bridge.format wrapped`; raw mode ignores it. #### View the bootloader version (nRF52 only) **Usage:** `get bootloader.ver` +Applies to every nRF52 board/role that exposes this getter, including Companion, +repeater, and room-server firmware. It reads the installed bootloader region +selected by the MBR/UICR boot address and bounded by the chip's flash geometry; +it does not search application firmware or staged update files. + +The reader preserves a complete Adafruit/UF2 version string, including OTAFIX, +preview, and vendor/build suffixes. If that string is absent or ambiguous, it +uses the existing whole-image CRC and unique BLMF/BLM2 validation to read the +embedded OTAFIX version (for example, `> OTAFIX2.4.6`). Some current release +images, including MeshTower V2 SD 2.4.6, omit the UF2 text entirely. As a last +fallback, the Adafruit core's startup-captured version is explicitly labelled +`(base)`; it cannot identify an OTAFIX/fork revision. + +`unknown` remains possible for a bootloader exposing none of these version +sources, invalid geometry/vectors, or unresolved ambiguity. There is no universal +version field shared by every third-party bootloader. This is diagnostic only: +displaying a version neither grants OTA capability nor bypasses install checks. +The fix is in MeshCore application firmware; installing another bootloader alone +does not update an older application's version reader. + --- #### View power management support diff --git a/docs/lora_ota_automation.md b/docs/lora_ota_automation.md index e26d1977b..591f38687 100644 --- a/docs/lora_ota_automation.md +++ b/docs/lora_ota_automation.md @@ -435,14 +435,30 @@ ota self ota stats ``` -The script uses `get bootloader.ver` to distinguish ESP32 from nRF52 and, for -nRF52, report the installed bootloader version. It then requires `ota self` to +The script uses `get bootloader.ver` as an optional platform/version hint, not +as an install gate. It makes one attempt without an interactive retry loop. +`unknown`, an unsupported/missing command, an unrecognized reply, or a lost reply +produces a warning and does not by itself block the run. In particular, older +MeshCore firmware can report `unknown` for the installed signed OTAFIX 2.4.6 +MeshTower V2 SD image because it omits the legacy UF2 version string. Both the +shell and PowerShell launchers use this same behavior; no application or +Companion firmware update is required for this host-side workaround. The runner +also accepts the new `OTAFIX2.4.6` and explicitly labelled `0.11.0 (base)` formats. + +The runner still requires `ota self` to report `bootloader: apply OK`, `bootloader: QSPI apply OK`, or `bootloader: SD apply OK` and checks the reported bootloader ABI and codec mask against the selected package. If the version command is unavailable on older firmware, the script warns and falls back to the legacy `ota self` platform marker. If an nRF52 bootloader lacks the required capabilities, install the exact-board OTAFIX bootloader first. +Positive nRF52 capability markers take precedence over a generic unsupported +getter response, so it cannot silently skip nRF52 safety checks. Bootloader +packages still require the independently checked `ota bootloader status` board, +target, ABI, codec and storage identity; missing version text never bypasses +package signature checks or the explicit bootloader-install workflow. The +separate hardware-qualified `rak3401_mota_chain.py` retains its exact deployed +version restriction; it is not a general OTAFIX 2.4.6 update runner. Current firmware also reports `maxblk:2048` near the front of both OTA replies. The runner treats a missing marker as the deployed 1 KiB receive limit, rejects a ready 2 KiB package for such a target before transfer, and passes the live diff --git a/docs/ota_meshtower_v2_sdcard.md b/docs/ota_meshtower_v2_sdcard.md index 12ecfbcd1..e793cff0c 100644 --- a/docs/ota_meshtower_v2_sdcard.md +++ b/docs/ota_meshtower_v2_sdcard.md @@ -163,6 +163,19 @@ Copy the MID and first 16 image-hash hex digits from `ota bootloader`. Ordinary application package. Keep the SD card inserted through the reboot. A later `ota status` value of `blup:C8` reports a successful bootloader replacement. +After reboot, `ota bootloader` displays the installed board identity, CRC and +capabilities. Its `mid` and `hash` fields describe a **currently staged download**, +not the installed bootloader, so `staged:none mid=- hash=-` is not an install +failure. `blrc:00` means no retained apply result is available; it does not by +itself prove either success or failure. + +Older MeshCore application firmware can report `get bootloader.ver` as `unknown` +for OTAFIX 2.4.6 because this bootloader image omits the legacy `UF2 Bootloader` +text. The updated getter also reads the CRC-validated BLM2 version. For the +official signed **MeshTower V2 SD 2.4.6** `.mota`, the installed identity is +`TOWER_V2_OTA`, CRC `5DACDB3D`, ABI `3`, and capabilities `09`. Compare the exact +board/storage package when interpreting these values; the non-SD image differs. + ## SD card CLI The SD-backed target provides these CLI commands: diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 85826f58f..2f7e0f51c 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -4675,7 +4675,7 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep #endif } else if (configKeyEquals(config, "bootloader.ver")) { #ifdef NRF52_PLATFORM - char ver[32]; + char ver[128]; // preserve preview/fork/build suffixes without truncation if (_board->getBootloaderVersion(ver, sizeof(ver))) { sprintf(reply, "> %s", ver); } else { diff --git a/src/helpers/NRF52Board.cpp b/src/helpers/NRF52Board.cpp index 0f30a08c8..ed27f3b3a 100644 --- a/src/helpers/NRF52Board.cpp +++ b/src/helpers/NRF52Board.cpp @@ -1,6 +1,7 @@ #if defined(NRF52_PLATFORM) #include "NRF52Board.h" #include "PowerManagementUtils.h" +#include "Nrf52BootloaderVersion.h" #include #ifdef USER_GPIO_CONTROL #include "UserGpioPinPolicy.h" @@ -598,22 +599,15 @@ void NRF52Board::powerOff() { } bool NRF52Board::getBootloaderVersion(char* out, size_t max_len) { - static const char BOOTLOADER_MARKER[] = "UF2 Bootloader "; - const uint8_t* flash = (const uint8_t*)0x000FB000; // earliest known info.txt location is 0xFB90B, latest is 0xFCC4B - - for (uint32_t i = 0; i < 0x3000 - (sizeof(BOOTLOADER_MARKER) - 1); i++) { - if (memcmp(&flash[i], BOOTLOADER_MARKER, sizeof(BOOTLOADER_MARKER) - 1) == 0) { - const char* ver = (const char*)&flash[i + sizeof(BOOTLOADER_MARKER) - 1]; - size_t len = 0; - while (len < max_len - 1 && ver[len] != '\0' && ver[len] != ' ' && ver[len] != '\n' && ver[len] != '\r') { - out[len] = ver[len]; - len++; - } - out[len] = '\0'; - return len > 0; // bootloader string is non-empty - } - } - return false; + if (!out || max_len == 0u) return false; + out[0] = 0; + mesh::Nrf52BootloaderRegion region; + const uint32_t mbr_start = *(const volatile uint32_t*)(uintptr_t)0xFF8u; + if (!mesh::nrf52BootloaderRegion(NRF_FICR->CODEPAGESIZE, NRF_FICR->CODESIZE, + mbr_start, NRF_UICR->NRFFW[0], region)) return false; + return mesh::nrf52BootloaderVersion((const uint8_t*)(uintptr_t)region.start, + region.end - region.start, region.start, + bootloaderVersion, out, max_len); } bool NRF52Board::startOTAUpdate(const char *id, char reply[], bool force_ap) { diff --git a/src/helpers/Nrf52BootloaderVersion.h b/src/helpers/Nrf52BootloaderVersion.h new file mode 100644 index 000000000..3e39a3498 --- /dev/null +++ b/src/helpers/Nrf52BootloaderVersion.h @@ -0,0 +1,139 @@ +#pragma once + +#include +#include +#include +#include +#include "ota/OtaBootloaderUpdate.h" + +namespace mesh { + +struct Nrf52BootloaderRegion { + uint32_t start = 0; + uint32_t end = 0; +}; + +// The MBR's flash word takes precedence over UICR, just as it does at boot. +// Exclude the MBR-parameter and settings pages. Never search the application, +// an OTA staging slot, external flash, or beyond the chip's physical flash. +inline bool nrf52BootloaderRegion(uint32_t page_size, uint32_t page_count, + uint32_t mbr_start, uint32_t uicr_start, + Nrf52BootloaderRegion& region) { + region = Nrf52BootloaderRegion(); + if (page_size != 4096u || page_count < 16u || page_count > 256u) return false; + const uint32_t flash_size = page_size * page_count; + const uint32_t end = flash_size - 2u * page_size; + uint32_t start = mbr_start != UINT32_MAX ? mbr_start : uicr_start; + // Historical Adafruit/OTAFIX installations can have both words erased. + // Their 40 KiB layout is accepted only with valid vectors by the reader. + if (start == UINT32_MAX) start = end - 0xA000u; + if (start < 0x10000u || start >= end || start % page_size != 0u) return false; + region.start = start; + region.end = end; + return true; +} + +inline bool nrf52BootloaderVectorsValid(const uint8_t* image, size_t size, + uint32_t start) { + if (!image || size < 8u || size > UINT32_MAX - start) return false; + const uint32_t sp = ota::ota_boot_rd32(image); + const uint32_t reset = ota::ota_boot_rd32(image + 4u); + return sp > 0x20000000u && sp <= 0x20040000u && (sp & 7u) == 0u && + (reset & 1u) != 0u && (reset & ~1u) >= start && + (reset & ~1u) < start + size; +} + +inline bool nrf52BootVersionChar(uint8_t c) { + return (c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || + (c >= 'a' && c <= 'z') || c == '.' || c == '-' || c == '_' || c == '+'; +} + +// Return the complete token, never a truncated (and possibly different) version. +// An embedded marker literal with no actual version is not an INFO_UF2 record. +inline size_t nrf52BootVersionTokenSize(const uint8_t* token, size_t available) { + size_t pos = available && token[0] == 'v' ? 1u : 0u; + for (unsigned component = 0; component < 3u; ++component) { + const size_t begin = pos; + while (pos < available && token[pos] >= '0' && token[pos] <= '9') ++pos; + if (pos == begin) return 0; + if (component != 2u && (pos >= available || token[pos++] != '.')) return 0; + } + size_t n = 0; + for (; n < available && n <= 127u; ++n) { + const uint8_t c = token[n]; + if (c == 0 || c == ' ' || c == '\t' || c == '\r' || c == '\n') + return n; + if (!nrf52BootVersionChar(c)) return 0; + } + return 0; // unterminated or too long +} + +inline bool nrf52BootloaderVersion(const uint8_t* image, size_t size, + uint32_t start, uint32_t cached_base_version, + char* out, size_t capacity) { + if (!out || capacity == 0u) return false; + out[0] = 0; + if (capacity < 2u || !nrf52BootloaderVectorsValid(image, size, start)) return false; + + static const char marker[] = "UF2 Bootloader "; + const size_t marker_size = sizeof(marker) - 1u; + const uint8_t* selected = nullptr; + size_t selected_size = 0; + bool conflicting = false; + for (size_t offset = 0; offset + marker_size < size; ++offset) { + if (image[offset] != 'U' || memcmp(image + offset, marker, marker_size) != 0) continue; + const uint8_t* token = image + offset + marker_size; + const size_t length = nrf52BootVersionTokenSize(token, size - offset - marker_size); + if (length == 0u) continue; + if (selected && (length != selected_size || memcmp(selected, token, length) != 0)) + conflicting = true; + selected = token; + selected_size = length; + } + if (selected && !conflicting) { + if (selected_size >= capacity) return false; + memcpy(out, selected, selected_size); + out[selected_size] = 0; + return true; + } + + // Reuse the installer's whole-image CRC and unique BLMF/BLM2 validation. + // Metadata contains the OTAFIX version, not Adafruit's base release number; + // do not fabricate a 0.x.y prefix when INFO_UF2 is absent or ambiguous. + ota::OtaBootloaderIdentity identity; + if (start == ota::OTA_BOOT_IMAGE_START && size == ota::OTA_BOOT_IMAGE_SIZE && + ota::ota_bootloader_identity_from_image(image, size, identity) && + identity.continuity_present) { + const uint32_t version = identity.boot_version; + const unsigned preview = version & 0xffu; + int written; + if (preview == 0xffu) { + written = snprintf(out, capacity, "OTAFIX%u.%u.%u", + (unsigned)(version >> 24), (unsigned)((version >> 16) & 0xffu), + (unsigned)((version >> 8) & 0xffu)); + } else { + written = snprintf(out, capacity, "OTAFIX%u.%u.%u-preview.%u", + (unsigned)(version >> 24), (unsigned)((version >> 16) & 0xffu), + (unsigned)((version >> 8) & 0xffu), preview); + } + if (written > 0 && (size_t)written < capacity) return true; + out[0] = 0; + return false; + } + if (conflicting) return false; + + // Adafruit's core captures TIMER2->CC[0] at startup, before the timer can + // be reused. This is only a base version (no OTAFIX/fork suffix). Never read + // the live timer here or reinterpret Nordic settings with a different layout. + if (cached_base_version != 0u && cached_base_version <= 0x00ffffffu) { + const int written = snprintf(out, capacity, "%u.%u.%u (base)", + (unsigned)(cached_base_version >> 16), + (unsigned)((cached_base_version >> 8) & 0xffu), + (unsigned)(cached_base_version & 0xffu)); + if (written > 0 && (size_t)written < capacity) return true; + out[0] = 0; + } + return false; +} + +} // namespace mesh diff --git a/test/README.md b/test/README.md index 60ff1b7cb..a114f145a 100644 --- a/test/README.md +++ b/test/README.md @@ -26,6 +26,7 @@ python3 test/test_companion_settings_persistence_contract.py # Atomic settings python3 test/test_webconfig_ui.py # Web controls, reboot handling and generated-page consistency python3 test/test_webconfig_ui_runtime.py # Real Chromium, including independent stealth toggle python3 test/test_nrf52_uf2reset_cli.py # all nRF52 text-CLI reset dispatchers +python3 test/test_nrf52_bootloader_version.py -v # real C++ version reader, metadata and firmware wiring python3 test/test_client_login_profile_contract.py # ACL login ordering/role contract python3 test/test_client_acl_spiffs.py # Actual ACL: first login, replay/reboot, failed storage python3 test/test_replay_reset_command.py # Strict full keys and one-use recovery confirmations @@ -53,6 +54,21 @@ With a sandboxed browser that cannot read `/tmp`, set `TMPDIR` to a writable directory visible to that browser before running it. The Bluetooth settings contracts and WebConfig suites also run in the unit-test GitHub workflow. +The bootloader-version regression compiles the production reader with a C++17 +`g++` (or `CXX`) compiler. Its offline cases include the MeshTower V2 SD 2.4.6 +missing-UF2-text failure, OTAFIX metadata versus cached base-version precedence, +stock/vendor/preview strings, bounds, truncation, invalid CRCs and malformed +metadata. A separate CI job also downloads the pinned OTAFIX 2.4.6 release +archive, verifies its SHA-256 and all 17 signed packages, and feeds their actual +bootloader payloads through the same C++ reader. No radio is accessed or flashed. +To repeat the release test locally, install Python `cryptography` and set +`MESHCORE_BOOTLOADER_MOTA_ZIP` to the downloaded +`OTAFIX-2.4.6-bootloader-mota.zip` before running the command above. +The same CI job runs `python3 -B -m unittest discover -s tools/lora_ota -p +test_lora_ota_bootloader_version.py -v`: simulated old-firmware replies and lost +optional probes must not block either OTA launcher, while required capability, +identity, storage, codec and package checks must still reject unsafe updates. + A green `[PASSED]` per suite means GoogleTest returned 0 (all assertions passed). PlatformIO's "0 test cases" line is just its Unity-style counter and does not reflect the GoogleTest count -- run the built binary directly diff --git a/test/fixtures/nrf52_bootloader_version/test_version.cpp b/test/fixtures/nrf52_bootloader_version/test_version.cpp new file mode 100644 index 000000000..f22c91b0d --- /dev/null +++ b/test/fixtures/nrf52_bootloader_version/test_version.cpp @@ -0,0 +1,183 @@ +#include +#include +#include +#include +#include +#include +#include +#include "helpers/Nrf52BootloaderVersion.h" + +using namespace mesh; +using namespace mesh::ota; + +static void put16(std::vector& image, size_t pos, uint16_t value) { + image[pos] = value; image[pos + 1] = value >> 8; +} +static void put32(std::vector& image, size_t pos, uint32_t value) { + for (unsigned i = 0; i < 4; ++i) image[pos + i] = value >> (8 * i); +} +static std::vector image(size_t size = 0xa000, uint32_t start = 0xf4000) { + std::vector bytes(size, 0xff); + put32(bytes, 0, 0x20040000); put32(bytes, 4, start + 0x101); + return bytes; +} +static void text(std::vector& bytes, size_t offset, const std::string& version) { + const std::string line = "UF2 Bootloader " + version + "\r\nModel: test\r\n"; + assert(offset + line.size() <= bytes.size()); + memcpy(bytes.data() + offset, line.data(), line.size()); +} +static void refreshManifestCrc(std::vector& bytes) { + const size_t crc_offset = OTA_BOOT_CANDIDATE_MANIFEST_OFFSET + 40; + put32(bytes, crc_offset, ota_boot_image_crc32(bytes.data(), bytes.size(), crc_offset)); +} +static void manifest(std::vector& bytes, uint32_t version) { + const size_t offset = OTA_BOOT_CANDIDATE_MANIFEST_OFFSET; + memset(bytes.data() + offset, 0, OTA_BOOT_ENVELOPE_SIZE); + put32(bytes, offset, OTA_BOOT_MANIFEST_MAGIC0); + put32(bytes, offset + 4, OTA_BOOT_MANIFEST_MAGIC1); + put16(bytes, offset + 8, 1); put16(bytes, offset + 10, 44); + put32(bytes, offset + 12, OTA_BOOT_IMAGE_START); + put32(bytes, offset + 16, OTA_BOOT_IMAGE_SIZE); + put32(bytes, offset + 20, 0x239a0071); + memcpy(bytes.data() + offset + 24, "TOWER_V2_OTA", 12); + put32(bytes, offset + 44, OTA_BOOT_CONTINUITY_MAGIC0); + put32(bytes, offset + 48, OTA_BOOT_CONTINUITY_MAGIC1); + put16(bytes, offset + 52, 2); put16(bytes, offset + 54, 32); + put32(bytes, offset + 56, version); + put16(bytes, offset + 60, 140); put16(bytes, offset + 62, 182); + put32(bytes, offset + 64, 0x26000); put16(bytes, offset + 68, 1); + refreshManifestCrc(bytes); +} +static void expect(const std::vector& bytes, const char* expected, + uint32_t start = 0xf4000, uint32_t base = 0) { + char output[128]; + memset(output, 0x55, sizeof(output)); + const bool ok = nrf52BootloaderVersion(bytes.data(), bytes.size(), start, base, output, sizeof(output)); + assert(ok == (expected != nullptr)); + assert(expected ? strcmp(output, expected) == 0 : output[0] == 0); +} + +int main(int argc, char** argv) { + if (argc > 1) { + std::vector bytes; + if (std::string(argv[1]) == "--hex") { + std::string hex; + std::cin >> hex; + if (hex.size() % 2) return 2; + for (size_t i = 0; i < hex.size(); i += 2) + bytes.push_back((uint8_t)std::stoul(hex.substr(i, 2), nullptr, 16)); + } else { + std::ifstream file(argv[1], std::ios::binary); + if (!file) return 2; + bytes.assign(std::istreambuf_iterator(file), {}); + } + char output[128]; + if (!nrf52BootloaderVersion(bytes.data(), bytes.size(), 0xf4000, 0, output, sizeof(output))) return 3; + std::cout << output << '\n'; + return 0; + } + Nrf52BootloaderRegion r; + assert(nrf52BootloaderRegion(4096, 256, UINT32_MAX, 0xf4000, r)); + assert(r.start == 0xf4000 && r.end == 0xfe000); + assert(nrf52BootloaderRegion(4096, 256, 0xf0000, 0xf4000, r) && r.start == 0xf0000); + assert(nrf52BootloaderRegion(4096, 128, UINT32_MAX, 0x74000, r)); + assert(r.start == 0x74000 && r.end == 0x7e000); // bounds only, not a supported-board claim + assert(nrf52BootloaderRegion(4096, 256, UINT32_MAX, UINT32_MAX, r) && r.start == 0xf4000); + for (uint32_t bad : {0u, 0x123u, 0xff000u, 0x100000u, 0x20000000u}) + assert(!nrf52BootloaderRegion(4096, 256, bad, 0xf4000, r)); // never ignore bad MBR priority + assert(!nrf52BootloaderRegion(0, 256, UINT32_MAX, UINT32_MAX, r)); + assert(!nrf52BootloaderRegion(4096, UINT32_MAX, UINT32_MAX, UINT32_MAX, r)); + assert(!nrf52BootloaderRegion(4096, 8, UINT32_MAX, UINT32_MAX, r)); + + // Test every byte alignment, both sides of the former FB000 window, and + // long release/preview/fork/build versions that the old 32-byte buffer cut. + const char* versions[] = {"0.6.4", "0.9.2", "0.11.0-OTAFIX2.4.2", "0.11.0-OTAFIX2.4.6", + "0.11.0-OTAFIX2.4.6-preview.12", "0.11.0-OTAFIX2.4.3-dirty-test-version-0x02040401", + "v0.9.2-Seeed", "0.8.2+build.abcdef", "0.6.1_RAK4631"}; + for (const char* version : versions) { + for (size_t offset : {0x100u, 0x101u, 0x102u, 0x103u, 0x6ff0u, 0x7000u, 0x9e00u}) { + auto bytes = image(); text(bytes, offset, version); expect(bytes, version); + } + } + auto bytes = image(); text(bytes, 0x100, "0.11.0"); text(bytes, 0x8000, "0.11.0"); + expect(bytes, "0.11.0"); // identical copies are harmless + text(bytes, 0x8000, "0.12.0"); expect(bytes, nullptr, 0xf4000, 0xb00); + bytes = image(); memcpy(bytes.data() + 0x100, "UF2 Bootloader ", 15); + text(bytes, 0x300, "0.11.0"); expect(bytes, "0.11.0"); // marker literal before real record + for (const char* bad : {"", "garbage", "1", "v..", "1.2/evil", "1.2\x01"}) { + bytes = image(); text(bytes, 0x100, bad); expect(bytes, nullptr); + } + bytes = image(); + const std::string boundary = "UF2 Bootloader 0.11.0"; + memcpy(bytes.data() + bytes.size() - boundary.size(), boundary.data(), boundary.size()); + expect(bytes, nullptr); // no terminator inside the region + bytes.back() = '\n'; expect(bytes, nullptr); // incomplete numeric version is not a match + bytes = image(); + const std::string terminated = boundary + "\n"; + memcpy(bytes.data() + bytes.size() - terminated.size(), terminated.data(), terminated.size()); + expect(bytes, "0.11.0"); // terminator at the final readable byte + bytes = image(); + const std::string longest = "1.2.3-" + std::string(121, 'a'); + text(bytes, 0x100, longest); expect(bytes, longest.c_str()); + bytes = image(); text(bytes, 0x100, longest + "a"); expect(bytes, nullptr); + + bytes = image(); text(bytes, 0x100, "0.11.0"); + char tiny[4] = {'x', 'x', 'x', '!'}; + assert(!nrf52BootloaderVersion(bytes.data(), bytes.size(), 0xf4000, 0, nullptr, 5)); + assert(!nrf52BootloaderVersion(bytes.data(), bytes.size(), 0xf4000, 0, tiny, 0) && tiny[0] == 'x'); + assert(!nrf52BootloaderVersion(bytes.data(), bytes.size(), 0xf4000, 0, tiny, 1) && tiny[0] == 0); + assert(!nrf52BootloaderVersion(bytes.data(), bytes.size(), 0xf4000, 0, tiny, 3) && tiny[0] == 0 && tiny[3] == '!'); + put32(bytes, 0, UINT32_MAX); expect(bytes, nullptr); + bytes = image(); text(bytes, 0x100, "0.11.0"); put32(bytes, 4, 0x27001); expect(bytes, nullptr); + bytes = image(); text(bytes, 0x100, "0.11.0"); put32(bytes, 4, 0xf4100); expect(bytes, nullptr); + bytes = image(0xe000, 0xf0000); text(bytes, 0x100, "0.9.2"); expect(bytes, "0.9.2", 0xf0000); + bytes = image(0xa000, 0x74000); text(bytes, 0x100, "0.6.4"); expect(bytes, "0.6.4", 0x74000); + + // Regression: the official MeshTower V2 SD 2.4.6 payload has NO UF2 text. + // Its BLMF/BLM2 fields below match the release (the synthetic body/CRC do not). + // It must report the OTAFIX release even if startup captured Adafruit 0.11.0. + bytes = image(); manifest(bytes, 0x020406ff); expect(bytes, "OTAFIX2.4.6"); + expect(bytes, "OTAFIX2.4.6", 0xf4000, 0xb00); + const std::string no_uf2(bytes.begin(), bytes.end()); + assert(no_uf2.find("UF2 Bootloader ") == std::string::npos); + OtaBootloaderIdentity identity; + assert(ota_bootloader_identity_from_image(bytes.data(), bytes.size(), identity)); + assert(identity.board_id == 0x239a0071 && identity.boot_version == 0x020406ff); + assert(identity.softdevice_family == 140 && identity.softdevice_fwid == 182); + assert(identity.app_base == 0x26000 && identity.layout_abi == 1); + assert(strcmp(identity.device_name, "TOWER_V2_OTA") == 0); + assert(!nrf52BootloaderVersion(bytes.data(), bytes.size(), 0xf4000, 0xb00, tiny, 3)); + assert(tiny[0] == 0 && tiny[3] == '!'); // no truncated metadata version + bytes = image(); manifest(bytes, 0x0204060c); expect(bytes, "OTAFIX2.4.6-preview.12"); + bytes = image(); manifest(bytes, 0x020406ff); bytes[0x100] ^= 1; expect(bytes, nullptr); + expect(bytes, "0.11.0 (base)", 0xf4000, 0xb00); // never claim the corrupted OTAFIX version + bytes = image(); manifest(bytes, 0x02040600); expect(bytes, nullptr); + bytes = image(); manifest(bytes, 0); expect(bytes, nullptr); + bytes = image(); manifest(bytes, UINT32_MAX); expect(bytes, nullptr); + // Even a fresh whole-image CRC must not legitimize malformed extension fields. + for (size_t field : {44u, 48u, 52u, 54u, 60u, 62u, 64u, 68u}) { + bytes = image(); manifest(bytes, 0x020406ff); + put16(bytes, OTA_BOOT_CANDIDATE_MANIFEST_OFFSET + field, 0); + if (field == 64u) put32(bytes, OTA_BOOT_CANDIDATE_MANIFEST_OFFSET + field, 0); + refreshManifestCrc(bytes); expect(bytes, nullptr); + } + for (size_t field : {70u, 72u}) { + bytes = image(); manifest(bytes, 0x020406ff); + put16(bytes, OTA_BOOT_CANDIDATE_MANIFEST_OFFSET + field, 1); + refreshManifestCrc(bytes); expect(bytes, nullptr); + } + bytes = image(); manifest(bytes, 0x020406ff); + memset(bytes.data() + OTA_BOOT_CANDIDATE_MANIFEST_OFFSET + 44, 0xff, 32); + refreshManifestCrc(bytes); + expect(bytes, nullptr); // legacy BLMF alone has no version + expect(bytes, "0.11.0 (base)", 0xf4000, 0xb00); + bytes = image(); manifest(bytes, 0x020406ff); + text(bytes, 0x100, "0.11.0-OTAFIX2.4.6"); manifest(bytes, 0x020406ff); + expect(bytes, "0.11.0-OTAFIX2.4.6"); // preserve the complete original text + bytes = image(); text(bytes, 0x100, "0.11.0"); text(bytes, 0x8000, "0.12.0"); + manifest(bytes, 0x020406ff); expect(bytes, "OTAFIX2.4.6"); + bytes = image(); expect(bytes, "0.11.0 (base)", 0xf4000, 0xb00); + expect(bytes, nullptr, 0xf4000, UINT32_MAX); + expect(bytes, nullptr, 0xf4000, 0); + std::cout << "bootloader version native cases passed\n"; +} diff --git a/test/test_nrf52_bootloader_version.py b/test/test_nrf52_bootloader_version.py new file mode 100644 index 000000000..a673a2f0d --- /dev/null +++ b/test/test_nrf52_bootloader_version.py @@ -0,0 +1,83 @@ +"""Compile the real version reader; optionally verify the pinned 2.4.6 release.""" +import hashlib +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest +import zipfile + +ROOT = Path(__file__).resolve().parents[1] + + +class BootloaderVersionTest(unittest.TestCase): + def test_firmware_uses_shared_reader(self): + board = (ROOT / "src/helpers/NRF52Board.cpp").read_text() + getter = board.split("bool NRF52Board::getBootloaderVersion(", 1)[1].split("\n}", 1)[0] + self.assertIn("mesh::nrf52BootloaderRegion(", getter) + self.assertIn("NRF_FICR->CODEPAGESIZE, NRF_FICR->CODESIZE", getter) + self.assertIn("mbr_start, NRF_UICR->NRFFW[0]", getter) + self.assertIn("mesh::nrf52BootloaderVersion(", getter) + self.assertIn("bootloaderVersion, out, max_len", getter) + self.assertNotIn("0x000FB000", getter) + common = (ROOT / "src/helpers/CommonCLI.cpp").read_text() + cli = common.split('configKeyEquals(config, "bootloader.ver")', 1)[1].split("} else {", 1)[0] + self.assertIn("char ver[128]", cli) + self.assertIn("_board->getBootloaderVersion(ver, sizeof(ver))", cli) + + def test_native_version_reader(self): + compiler = shutil.which(os.environ.get("CXX", "g++")) + self.assertIsNotNone(compiler, "g++ (or CXX) is required for this native test") + with tempfile.TemporaryDirectory(prefix="meshcore-boot-version-") as temp: + exe = Path(temp) / ("test.exe" if os.name == "nt" else "test") + command = [compiler, "-std=c++17", "-O2", "-Wall", "-Wextra", "-Werror", "-Wno-unused-parameter", + "-I" + str(ROOT / "src"), "-isystem", str(ROOT / "test/mocks"), + str(ROOT / "test/fixtures/nrf52_bootloader_version/test_version.cpp"), + "-o", str(exe)] + built = subprocess.run(command, capture_output=True, text=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + result = subprocess.run([str(exe)], capture_output=True, text=True, timeout=20) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("native cases passed", result.stdout) + # Optional local/release artifacts; never downloaded or flashed by + # this test. Each must be a raw bootloader image starting at F4000. + for path in os.environ.get("MESHCORE_BOOTLOADER_IMAGES", "").split(os.pathsep): + if path: + with self.subTest(image=path): + result = subprocess.run([str(exe), path], check=True, capture_output=True, + text=True, timeout=20) + self.assertRegex(result.stdout, r"\d+\.\d+\.\d+") + release = os.environ.get("MESHCORE_BOOTLOADER_MOTA_ZIP") + if release: + self.assertEqual(hashlib.sha256(Path(release).read_bytes()).hexdigest(), + "04fe6d1b11b0c3b926289d6384dae5cb878462da" + "a7fbbe9afb70f42d869f8003", "unexpected 2.4.6 release archive") + sys.path.insert(0, str(ROOT / "tools/mota")) + import motalib + with zipfile.ZipFile(release) as archive: + names = [name for name in archive.namelist() if name.endswith(".mota")] + self.assertEqual(len(names), 17) + sd_name = "mota/update-heltec_mesh_tower_v2_sdcard_bootloader-0.11.0-OTAFIX2.4.6.mota" + self.assertIn(sd_name, names) + for name in names: + with self.subTest(release_image=name): + parsed = motalib.parse_container(archive.read(name)) + self.assertEqual(motalib.verify(parsed), []) + if name == sd_name: + self.assertNotIn(b"UF2 Bootloader ", parsed.payload) + identity = motalib.parse_bootloader_identity(parsed.payload) + self.assertEqual(identity.crc32, 0x5DACDB3D) + self.assertEqual(identity.boot_version, 0x020406FF) + result = subprocess.run([str(exe), "--hex"], input=parsed.payload.hex(), + capture_output=True, text=True, timeout=20) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("OTAFIX2.4.6", result.stdout) + if name == sd_name: + self.assertEqual(result.stdout.strip(), "OTAFIX2.4.6") + print(f"Verified version reader against {len(names)} signed OTAFIX 2.4.6 release images") + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/lora_ota/lora_ota.py b/tools/lora_ota/lora_ota.py index 3a2d20b4c..61aa83351 100755 --- a/tools/lora_ota/lora_ota.py +++ b/tools/lora_ota/lora_ota.py @@ -1603,11 +1603,11 @@ def reply_matches_command(command_text: str, reply: str) -> bool: if command == "ota stats": return text.startswith("OTA | fw ") or is_unknown or needs_temp if command == "get bootloader.ver": - return ( - bool(re.fullmatch(r">\s*\S+", text)) - or is_unknown - or (is_error and "unsupported" in lowered) - ) + try: + parse_bootloader_version_reply(text) + return True + except OtaError: + return False if command == "ota ls": return ( text.startswith("Updates ") @@ -1681,13 +1681,20 @@ def parse_bootloader_version_reply( ) -> tuple[str | None, str | None]: """Return platform/version, or no platform for legacy firmware.""" text = reply.strip() - version_match = re.fullmatch(r">\s*(\S+)", text) + version_match = re.fullmatch(r">\s*(\S+(?:[ \t]+\(base\))?)", text) if version_match: version = version_match.group(1) + # Older getters use this generic response for an unknown config key; + # it is neither a bootloader version nor evidence of the platform. + if version.lower() == "unsupported": + return None, None return "nrf52", None if version.lower() == "unknown" else version if re.fullmatch(r"err(?:or)?:\s*unsupported", text, re.IGNORECASE): return "esp32", None - if text.lower().startswith(("unknown command", "command not found")): + if re.match( + r"^(?:err(?:or)?[: ,]\s*)?(?:unknown (?:command|config)\b|command not found\b)", + text, re.IGNORECASE, + ): return None, None raise OtaError( "could not interpret destination `get bootloader.ver` reply: " @@ -2787,12 +2794,20 @@ def query_target( if not match: raise OtaError("could not read destination target ID from `ota status`") target_id = int(match.group(1), 16) - bootloader_reply = controller.remote_command( - args.target, "get bootloader.ver" - ) - reported_platform, bootloader_version = parse_bootloader_version_reply( - bootloader_reply - ) + # Version text is diagnostic, not an install gate. In particular, deployed + # applications can report unknown for the signed OTAFIX 2.4.6 SD image, + # which has BLM2 metadata but no legacy UF2 string. One optional attempt; + # never enter interactive/unbounded retries for a missing getter. + reported_platform, bootloader_version = None, None + try: + bootloader_reply = controller.remote_command( + args.target, "get bootloader.ver", retry=False + ) + reported_platform, bootloader_version = parse_bootloader_version_reply( + bootloader_reply + ) + except OtaError as exc: + print(f"[warn] optional `get bootloader.ver` probe unavailable: {exc}") self_status = controller.remote_command(args.target, "ota self") hash_match = re.search(r"base_hash=([0-9A-Fa-f]{16})", self_status) if not hash_match: @@ -2800,18 +2815,26 @@ def query_target( base_hash = bytes.fromhex(hash_match.group(1)) max_block_size = parse_target_max_block_size(status, self_status) combined = f"{status} {self_status}" + has_nrf_markers = "bootloader:" in combined or "| bl:" in combined if reported_platform is None: platform = ( "nrf52" - if "bootloader:" in combined or "| bl:" in combined + if has_nrf_markers else "esp32" ) print( - "[warn] destination firmware does not implement " - "`get bootloader.ver`; using legacy `ota self` platform markers" + "[warn] destination bootloader version is unavailable; " + "using legacy `ota self` platform markers" ) else: - platform = reported_platform + # A generic unsupported response must not classify a positive nRF52 + # capability report as ESP32 and skip its ABI/codec checks. + platform = "nrf52" if has_nrf_markers else reported_platform + if platform == "nrf52" and bootloader_version is None: + print( + "[warn] bootloader version text is unavailable; continuing with " + "live `ota status`/`ota self` capabilities (ABI/codec checks still required)" + ) nrf_sd = "SD apply OK" in combined or bool( re.search(r"\bbl:SD\b", combined) ) diff --git a/tools/lora_ota/test_lora_ota_bootloader_version.py b/tools/lora_ota/test_lora_ota_bootloader_version.py new file mode 100644 index 000000000..657c00c9d --- /dev/null +++ b/tools/lora_ota/test_lora_ota_bootloader_version.py @@ -0,0 +1,170 @@ +"""Deployed bootloader-version compatibility. All radio replies are simulated.""" + +import argparse +import contextlib +from dataclasses import replace +import io +from pathlib import Path +import unittest +from unittest import mock + +import lora_ota as ota + + +class BootloaderVersionCompatibilityTests(unittest.TestCase): + def controller(self, version_reply, **overrides): + replies = { + # Exact status/identity from the MeshTower SD 2.4.6 bug report. + "ota status": ( + "OTA | this fw 6CC371C7 (485K) hw=Heltec_tower_v2 | " + "target:0A9DBBF0 | maxblk:2048 | bl:SD blrc:00 | no download | " + "serving:off (0) | keys:2 | env:Heltec_tower_" + ), + "get bootloader.ver": version_reply, + # ota self/stats were not in the report: synthetic valid replies. + "ota self": ( + "self body=100 image=156 base_hash=0011223344556677 | " + "bootloader: SD apply OK (abi=3 codecs=0x5)" + ), + "ota stats": "OTA | fw v1.17.1.5", + "ota bootloader status": ( + "BL board=239A0071 target=1150F50E name=TOWER_V2_OTA " + "crc=5DACDB3D abi=3 caps=09 | staged:none mid=- hash=-" + ), + } + replies.update(overrides) + + def remote_command(_target, command, **kwargs): + if command == "get bootloader.ver": + self.assertIs(kwargs.get("retry"), False) + value = replies[command] # Unexpected/mutating commands fail the test. + if isinstance(value, Exception): + raise value + return value + + controller = mock.Mock() + controller.remote_command.side_effect = remote_command + return controller + + def query(self, controller, kind="application"): + with contextlib.redirect_stdout(io.StringIO()) as output: + result = ota.query_target( + controller, argparse.Namespace(target="remote", package_kind=kind) + ) + return result, output.getvalue() + + def test_reply_formats_and_filter(self): + for reply, expected in ( + ("> unknown", ("nrf52", None)), + ("> unsupported", (None, None)), + ("Unknown command", (None, None)), + ("ERROR: unknown command: get bootloader.ver", (None, None)), + ("unknown config: bootloader.ver", (None, None)), + ("Command not found", (None, None)), + ("Error: unsupported", ("esp32", None)), + ("> 0.11.0-OTAFIX2.4.2", ("nrf52", "0.11.0-OTAFIX2.4.2")), + ("> OTAFIX2.4.6", ("nrf52", "OTAFIX2.4.6")), + ("> OTAFIX2.4.6-preview.12", ("nrf52", "OTAFIX2.4.6-preview.12")), + ("> 0.11.0 (base)", ("nrf52", "0.11.0 (base)")), + ): + with self.subTest(reply=reply): + self.assertEqual(ota.parse_bootloader_version_reply(reply), expected) + self.assertTrue(ota.reply_matches_command("get bootloader.ver", reply)) + for unrelated in ("OTA | target:1234ABCD", "self base_hash=0011223344556677", "", "> two words"): + with self.subTest(unrelated=unrelated): + self.assertFalse(ota.reply_matches_command("get bootloader.ver", unrelated)) + + def test_unavailable_getter_does_not_block_application_or_bootloader(self): + for kind in ("application", "bootloader"): + for reply in ( + "> unknown", "> unsupported", "Unknown command", + "ERROR: unknown command: get bootloader.ver", + "unknown config: bootloader.ver", "Error: unsupported", "unexpected reply", + ota.TransmissionError("no matching CLI reply"), + ota.OtaError("optional command unavailable"), + ): + with self.subTest(kind=kind, reply=reply): + controller = self.controller(reply) + result, warning = self.query(controller, kind) + self.assertEqual(result.platform, "nrf52") + self.assertTrue(result.nrf_sd) + self.assertIsNone(result.bootloader_version) + self.assertEqual(result.bootloader_abi, 3) + self.assertEqual(result.bootloader_codecs, 5) + self.assertEqual(result.current_version, "v1.17.1.5") + self.assertIn("[warn]", warning) + calls = controller.remote_command.call_args_list + self.assertEqual(sum(call.args[1] == "get bootloader.ver" for call in calls), 1) + if kind == "bootloader": + self.assertEqual(result.boot_target_id, 0x1150F50E) + self.assertEqual(result.boot_storage, 9) + else: + self.assertIsNone(result.boot_target_id) + self.assertNotIn(mock.call("remote", "ota bootloader status"), calls) + + def test_new_metadata_and_base_formats_work_with_old_host_topology(self): + for version in ("OTAFIX2.4.6", "OTAFIX2.4.6-preview.12", "0.11.0 (base)"): + with self.subTest(version=version): + result, _ = self.query(self.controller("> " + version), "bootloader") + self.assertEqual(result.bootloader_version, version) + self.assertEqual(result.boot_target_id, 0x1150F50E) + + def test_missing_version_never_bypasses_capability_or_identity_checks(self): + cases = ( + ({"ota self": "self base_hash=0011223344556677 | bootloader: SD apply OK"}, "ABI and codec"), + ({"ota self": "self base_hash=0011223344556677 | bootloader: NO SD mota-apply"}, "cannot apply"), + ({"ota self": "self missing hash | bootloader: SD apply OK"}, "base hash"), + ({"ota self": "self base_hash=0011223344556677 | maxblk:4096"}, "maxblk"), + ({"ota bootloader status": "Unknown command"}, "cannot stage"), + ({"ota bootloader status": ( + "BL board=239A0071 target=00000000 name=TOWER_V2_OTA " + "crc=5DACDB3D abi=3 caps=09 | staged:none mid=- hash=-" + )}, "target ID"), + ({"ota bootloader status": ( + "BL board=239A0071 target=1150F50E name=TOWER_V2_OTA " + "crc=5DACDB3D abi=2 caps=09 | staged:none mid=- hash=-" + )}, "bootloader ABI"), + ) + for reply in ("> unknown", ota.TransmissionError("lost optional reply"), "Error: unsupported"): + for overrides, expected in cases: + with self.subTest(reply=reply, expected=expected): + with self.assertRaisesRegex(ota.OtaError, expected): + self.query(self.controller(reply, **overrides), "bootloader") + + def test_missing_version_never_bypasses_bootloader_package_checks(self): + # Existing signed-package fixture/reference builder; no second parser. + from test_lora_ota_bootloader import boot_blob + package = ota.parse_mota(boot_blob(storage=9, version=0x020406FF)) + target, _ = self.query(self.controller("> unknown"), "bootloader") + self.assertEqual(ota.compatible_mota(package, target), (True, "")) + for changed in ( + replace(target, boot_target_id=0), replace(target, boot_hw_id="wrong"), + replace(target, bootloader_abi=2), replace(target, bootloader_codecs=4), + replace(target, boot_storage=10), replace(target, max_block_size=512), + ): + with self.subTest(target=changed): + self.assertFalse(ota.compatible_mota(package, changed)[0]) + + def test_esp32_unsupported_remains_supported(self): + result, _ = self.query(self.controller("Error: unsupported", **{ + "ota status": "OTA | target:1234ABCD hw=Heltec_v4 | maxblk:2048", + "ota self": "self base_hash=0011223344556677", + })) + self.assertEqual(result.platform, "esp32") + self.assertIsNone(result.bootloader_version) + + def test_required_probe_failure_is_not_hidden(self): + with self.assertRaisesRegex(ota.OtaError, "admin login failed"): + self.query(self.controller( + ota.OtaError("admin login failed"), + **{"ota self": ota.OtaError("admin login failed")}, + )) + + def test_both_launchers_use_the_shared_runner(self): + root = Path(__file__).resolve().parent + for name in ("lora_ota.sh", "lora_ota.ps1"): + self.assertIn("lora_ota.py", (root / name).read_text()) + + +if __name__ == "__main__": + unittest.main()