Fix bootloader version discovery and tolerate legacy OTA probes

This commit is contained in:
mikecarper committed 2026-09-08 16:17:43 -07:00
1 parent aab46c7c84
commit e5f8eb46a3
12 files changed
+723 -36

No files matched your search

+30
View File
@@ -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:
+20
View File
@@ -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
+18 -2
View File
@@ -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
+13
View File
@@ -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:
+1 -1
View File
@@ -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 {
+10 -16
View File
@@ -1,6 +1,7 @@
#if defined(NRF52_PLATFORM)
#include "NRF52Board.h"
#include "PowerManagementUtils.h"
#include "Nrf52BootloaderVersion.h"
#include <target.h>
#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) {
+139
View File
@@ -0,0 +1,139 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#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
+16
View File
@@ -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
+183
View File
@@ -0,0 +1,183 @@
#include <cassert>
#include <cstring>
#include <fstream>
#include <iostream>
#include <iterator>
#include <string>
#include <vector>
#include "helpers/Nrf52BootloaderVersion.h"
using namespace mesh;
using namespace mesh::ota;
static void put16(std::vector<uint8_t>& image, size_t pos, uint16_t value) {
image[pos] = value; image[pos + 1] = value >> 8;
}
static void put32(std::vector<uint8_t>& image, size_t pos, uint32_t value) {
for (unsigned i = 0; i < 4; ++i) image[pos + i] = value >> (8 * i);
}
static std::vector<uint8_t> image(size_t size = 0xa000, uint32_t start = 0xf4000) {
std::vector<uint8_t> bytes(size, 0xff);
put32(bytes, 0, 0x20040000); put32(bytes, 4, start + 0x101);
return bytes;
}
static void text(std::vector<uint8_t>& 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<uint8_t>& 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<uint8_t>& 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<uint8_t>& 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<uint8_t> 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<char>(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";
}
+83
View File
@@ -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()
+40 -17
View File
@@ -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)
)
@@ -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()