Fix bootloader version discovery and tolerate legacy OTA probes
This commit is contained in:
1 parent
aab46c7c84
commit
e5f8eb46a3
12 files changed
+723
-36
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
Reference in new issue
Block a user