diff --git a/build_legacy.sh b/build_legacy.sh index 2c0ce786..5a85e824 100755 --- a/build_legacy.sh +++ b/build_legacy.sh @@ -4214,6 +4214,7 @@ collect_nrf52_artifacts() { local build_output_dir="${PIO_BUILD_DIR_OVERRIDE:-${PLATFORMIO_BUILD_DIR:-.pio/build}}/${pio_env_name}" python3 bin/uf2conv/uf2conv.py "${build_output_dir}/firmware.hex" -c -o "${build_output_dir}/firmware.uf2" -f 0xADA52840 || return $? + python3 scripts/check_nrf52_uf2.py "${build_output_dir}/firmware.uf2" || return $? copy_build_output "${build_output_dir}/firmware.uf2" "${OUTPUT_DIR}/${firmware_filename}.uf2" || return $? if [ -f "${build_output_dir}/firmware.zip" ]; then copy_build_output "${build_output_dir}/firmware.zip" "${OUTPUT_DIR}/${firmware_filename}.zip" || return $? @@ -4295,6 +4296,7 @@ PY ;; NRF52_PLATFORM) output_artifact_exists "${firmware_filename}.uf2" \ + && python3 scripts/check_nrf52_uf2.py "${OUTPUT_DIR}/${firmware_filename}.uf2" >/dev/null 2>&1 \ && { [ "${REQUIRE_OTA_UPDATES:-0}" != "1" ] \ || output_artifact_exists "${firmware_filename}.zip"; } ;; diff --git a/scripts/check_nrf52_uf2.py b/scripts/check_nrf52_uf2.py new file mode 100644 index 00000000..1b054324 --- /dev/null +++ b/scripts/check_nrf52_uf2.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Reject nRF52840 UF2 images that overlap the OTAFIX bootloader scratch area.""" + +from __future__ import annotations + +import argparse +from pathlib import Path +import struct + + +SECTOR_BYTES = 512 +PAYLOAD_BYTES = 256 +APP_FAMILY = 0xADA52840 +SAFE_APP_END = 0xE0000 +MAGIC_START = (0x0A324655, 0x9E5D5157) +MAGIC_END = 0x0AB16F30 +FAMILY_FLAG = 0x2000 + + +def check_uf2(path: Path, app_end: int = SAFE_APP_END) -> tuple[int, int, int]: + data = path.read_bytes() + if not data or len(data) % SECTOR_BYTES: + raise ValueError(f"{path}: incomplete UF2 sectors") + count = len(data) // SECTOR_BYTES + start = None + for index in range(count): + offset = index * SECTOR_BYTES + magic0, magic1, flags, address, size, block_no, blocks, family = struct.unpack_from( + "<8I", data, offset + ) + trailer = struct.unpack_from(" app_end: + raise ValueError( + f"{path}: application 0x{start:X}..0x{end:X} exceeds the " + f"OTAFIX-compatible limit 0x{app_end:X}" + ) + return start, end, count + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("uf2", type=Path) + args = parser.parse_args() + try: + start, end, blocks = check_uf2(args.uf2) + except (OSError, ValueError) as error: + parser.exit(1, f"nRF52 UF2 qualification failed: {error}\n") + print(f"nRF52 UF2 safe: 0x{start:X}..0x{end:X}, {blocks} blocks") + + +if __name__ == "__main__": + main() diff --git a/scripts/package_cascade_release.py b/scripts/package_cascade_release.py index 677be042..06fa6c36 100644 --- a/scripts/package_cascade_release.py +++ b/scripts/package_cascade_release.py @@ -15,6 +15,7 @@ from urllib.parse import quote, urljoin sys.path.insert(0, str(Path(__file__).resolve().parent)) from firmware_memory_manifest import validate_package +from check_nrf52_uf2 import check_uf2 ROOT = Path(__file__).resolve().parents[1] @@ -64,6 +65,7 @@ def collect_artifacts(directory, version): if platform == "NRF52_PLATFORM": if ".uf2" not in extensions or (manifest.get("ota_update_verified") and ".zip" not in extensions): raise ValueError(f"{stem}: nRF52 UF2/DFU artifacts incomplete") + check_uf2(directory / (stem + ".uf2")) if any(item.stat().st_size == 0 for item in files): raise ValueError(f"{stem}: empty firmware artifact") memory = validate_package(directory / stem) diff --git a/src/helpers/NRF52Board.cpp b/src/helpers/NRF52Board.cpp index 352b3f0f..8707bad1 100644 --- a/src/helpers/NRF52Board.cpp +++ b/src/helpers/NRF52Board.cpp @@ -641,11 +641,12 @@ bool NRF52Board::startOTAUpdate(const char *id, char reply[], bool force_ap) { } if (!ota_ble_started) { - // Config the peripheral connection with maximum bandwidth - // more SRAM required by SoftDevice - // Note: All config***() function must be called before begin() - Bluefruit.configPrphBandwidth(BANDWIDTH_MAX); - Bluefruit.configPrphConn(92, BLE_GAP_EVENT_LENGTH_MIN, 16, 16); + // The application only needs the buttonless DFU control connection; the + // bootloader handles firmware data. Large ATT and queue reservations can + // make SoftDevice startup fail on an otherwise healthy repeater. + // All config***() calls must precede begin(). + Bluefruit.configPrphConn(BLE_GATT_ATT_MTU_DEFAULT, + BLE_GAP_EVENT_LENGTH_MIN, 2, 1); if (!Bluefruit.begin(1, 0)) { return false; diff --git a/test/test_nrf52_uf2_slot.py b/test/test_nrf52_uf2_slot.py new file mode 100644 index 00000000..b11f00c6 --- /dev/null +++ b/test/test_nrf52_uf2_slot.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Keep manual UF2 images below the OTAFIX scratch bank on nRF52840.""" + +from __future__ import annotations + +from pathlib import Path +import struct +import sys +import tempfile +import unittest + + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) +from check_nrf52_uf2 import check_uf2 # noqa: E402 + + +def uf2(start: int, count: int, family: int = 0xADA52840) -> bytes: + image = bytearray(count * 512) + for index in range(count): + offset = index * 512 + struct.pack_into("<8I", image, offset, 0x0A324655, 0x9E5D5157, + 0x2000, start + index * 256, 256, index, count, family) + struct.pack_into(" tuple[int, int, int]: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "firmware.uf2" + path.write_bytes(data) + return check_uf2(path) + + def test_11717_rak4631_geometry_fits(self) -> None: + self.assertEqual(self.check(uf2(0x26000, 2117)), + (0x26000, 0x26000 + 2117 * 256, 2117)) + + def test_11716_rak4631_geometry_is_rejected(self) -> None: + with self.assertRaisesRegex(ValueError, "exceeds the OTAFIX-compatible limit"): + self.check(uf2(0x26000, 3138)) + + def test_wrong_family_is_rejected(self) -> None: + with self.assertRaisesRegex(ValueError, "wrong nRF52840 application family"): + self.check(uf2(0x26000, 1, 0xE48BFF56)) + + def test_missing_block_is_rejected(self) -> None: + data = bytearray(uf2(0x26000, 2)) + struct.pack_into("