#!/usr/bin/env python3 """Exercise the nRF52 HEX post-build hook with the installed build dependencies.""" from contextlib import redirect_stdout import importlib.util import io import os from pathlib import Path import sys import tempfile import unittest from unittest import mock from intelhex import IntelHex ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "tools" / "mota")) import motalib as ml # noqa: E402 class BuildEnvironment(dict): def __init__(self, app_base): super().__init__( PROJECT_DIR=str(ROOT), PIOENV="nrf52_sensor_hex_test", CPPDEFINES=[ "ENABLE_OTA", "NRF52_PLATFORM", ("FIRMWARE_VERSION", "v1.17.1.9"), ("MOTA_TARGET_ID", "0x12345678"), ("MOTA_HW_ID", "NRF52_TEST"), ], ) self.app_base = app_base self.maximum_size = ml.NRF52_APP_END - app_base self.source_filter = "+" self.linker = "" self.post_actions = {} def AddPostAction(self, target, action): self.post_actions[target] = action def BoardConfig(self): return {"upload.maximum_size": self.maximum_size, "build.ldscript": self.linker} def GetProjectOption(self, name, default=""): if name in ("build_src_filter", "src_filter"): return self.source_filter return default def Replace(self, **values): self.update(values) def load_post_script(env): spec = importlib.util.spec_from_file_location( "nrf52_endf_hex_test", ROOT / "tools" / "mota" / "pio_endf.py") module = importlib.util.module_from_spec(spec) module.Import = lambda _name: None module.env = env spec.loader.exec_module(module) return module def read_application(path): image = IntelHex(str(path)) start, end = image.segments()[0] return bytes(image.tobinarray(start=start, size=end - start)) def stamp(action, path, env): with redirect_stdout(io.StringIO()): action([], [path], env) class Nrf52EndfHexTests(unittest.TestCase): def test_explicit_legacy_rak_sd_stamps_actual_flash_only_adaptive_capability(self): with tempfile.TemporaryDirectory() as directory: env = BuildEnvironment(ml.NRF52_APP_BASE_S140_V6) env["CPPDEFINES"].extend(["RAK_4631", "OTA_SD_STORE", "OTA_RAK15002_SD_STORE", "OTA_RAK_AUTO_STORE", "OTA_QSPI_STORE", "OTA_FLASH_STORE"]) env.linker = str(ROOT / "boards/nrf52840_s140_v6_sd_ota.ld") env["LDSCRIPT_PATH"] = env.linker load_post_script(env) path = Path(directory) / "legacy.hex" image = IntelHex() image.frombytes(bytes(range(256)), offset=env.app_base) image.write_hex_file(str(path)) with mock.patch.dict(os.environ, {"MESHCORE_NRF52_LEGACY_FLASH_OTA": "1"}): stamp(env.post_actions["$BUILD_DIR/${PROGNAME}.hex"], path, env) layout = ml.parse_nrf52_layout(read_application(path)) self.assertEqual(layout, ml.Nrf52Layout( env.app_base, ml.NRF52_APP_END, ml.NRF52_APP_END, ml.NRF52_LAYOUT_FLAG_AUTO_STORE)) self.assertTrue(layout.auto_store) self.assertFalse(layout.hybrid_ram) self.assertFalse(layout.external_backed) def test_legacy_adaptive_exception_rejects_wrong_flags_maps_and_flash_geometry(self): base_flags = ["RAK_4631", "OTA_SD_STORE", "OTA_RAK15002_SD_STORE", "OTA_RAK_AUTO_STORE", "OTA_QSPI_STORE", "OTA_FLASH_STORE"] cases = ( ("not_explicit", {}, {}, "RAK adaptive OTA requires"), ("missing_qspi", {"remove": "OTA_QSPI_STORE"}, {"MESHCORE_NRF52_LEGACY_FLASH_OTA": "1"}, "both stores"), ("missing_flash", {"remove": "OTA_FLASH_STORE"}, {"MESHCORE_NRF52_LEGACY_FLASH_OTA": "1"}, "both stores"), ("seeder", {"add": ["OTA_SEEDER_ONLY"]}, {"MESHCORE_NRF52_LEGACY_FLASH_OTA": "1"}, "requires a receiver"), ("wrong_active", {"active": "boards/nrf52840_s140_v6.ld"}, {"MESHCORE_NRF52_LEGACY_FLASH_OTA": "1"}, "active linker"), ("wrong_configured", {"configured": "boards/nrf52840_s140_v6.ld"}, {"MESHCORE_NRF52_LEGACY_FLASH_OTA": "1"}, "configured linker"), ("wrong_softdevice", {"base": ml.NRF52_APP_BASE_S140_V7}, {"MESHCORE_NRF52_LEGACY_FLASH_OTA": "1"}, "exact S140 v6"), ("wrong_flash_end", {"end": ml.NRF52_EXTRAFS_START}, {"MESHCORE_NRF52_LEGACY_FLASH_OTA": "1"}, "exact S140 v6"), ("hybrid_only", {"add": ["OTA_HYBRID_RAM_STORE"]}, {"MESHCORE_NRF52_LEGACY_FLASH_OTA": "1"}, "enabled together"), ("hybrid_pair", {"add": ["OTA_HYBRID_RAM_STORE", "OTA_INTERNAL_BOOTLOADER_UPDATE"], "configured": str(ROOT / "boards/nrf52840_s140_v6_mota64.ld"), "active": str(ROOT / "boards/nrf52840_s140_v6_mota64.ld")}, {"MESHCORE_NRF52_LEGACY_FLASH_OTA": "1"}, "without hybrid RAM"), ("unmarked_sd_role", {"remove": "OTA_RAK15002_SD_STORE"}, {"MESHCORE_NRF52_LEGACY_FLASH_OTA": "1"}, "both SD and QSPI"), ) with tempfile.TemporaryDirectory() as directory: path = Path(directory) / "legacy.hex" for name, changes, process_env, error in cases: with self.subTest(case=name): env = BuildEnvironment(changes.get("base", ml.NRF52_APP_BASE_S140_V6)) env["CPPDEFINES"].extend(base_flags) if "remove" in changes: env["CPPDEFINES"].remove(changes["remove"]) env["CPPDEFINES"].extend(changes.get("add", [])) env.maximum_size = changes.get("end", ml.NRF52_APP_END) - env.app_base env.linker = changes.get("configured", str(ROOT / "boards/nrf52840_s140_v6_sd_ota.ld")) env["LDSCRIPT_PATH"] = changes.get("active", str(ROOT / "boards/nrf52840_s140_v6_sd_ota.ld")) load_post_script(env) image = IntelHex() image.frombytes(bytes(range(256)), offset=env.app_base) image.write_hex_file(str(path)) before = path.read_bytes() with mock.patch.dict(os.environ, process_env, clear=True): with self.assertRaisesRegex(RuntimeError, error): stamp(env.post_actions["$BUILD_DIR/${PROGNAME}.hex"], path, env) self.assertEqual(path.read_bytes(), before) def test_hex_hook_appends_valid_identity_and_preserves_other_segments(self): for app_base in (ml.NRF52_APP_BASE_S140_V6, ml.NRF52_APP_BASE_S140_V7): with self.subTest(app_base=hex(app_base)), tempfile.TemporaryDirectory() as tmp: env = BuildEnvironment(app_base) load_post_script(env) action = env.post_actions["$BUILD_DIR/${PROGNAME}.hex"] path = Path(tmp) / "firmware.hex" firmware = bytes(range(256)) uicr_base = 0x10001080 uicr = b"\x12\x34\x56\x78" original = IntelHex() original.frombytes(firmware, offset=app_base) original.frombytes(uicr, offset=uicr_base) original.write_hex_file(str(path)) with redirect_stdout(io.StringIO()): action([], [path], env) result = IntelHex(str(path)) segments = result.segments() self.assertEqual(len(segments), 2) self.assertEqual(segments[0], ( app_base, app_base + len(firmware) + ml.NRF52_LAYOUT_LEN + ml.ENDF_LEN)) image = bytes(result.tobinarray(start=app_base, end=segments[0][1] - 1)) self.assertEqual(image[:len(firmware)], firmware) self.assertEqual(bytes(result.tobinarray( start=uicr_base, size=len(uicr))), uicr) self.assertTrue(ml.has_endf(image)) self.assertEqual(ml.parse_endf_ident(image), ml.FwIdent( ml.pack_version("1.17.1.9"), 0x12345678, "NRF52_TEST")) self.assertEqual(ml.parse_nrf52_layout(image), ml.Nrf52Layout( app_base, ml.NRF52_APP_END, ml.NRF52_APP_END)) first_pass = path.read_bytes() with redirect_stdout(io.StringIO()): action([], [path], env) self.assertEqual(path.read_bytes(), first_pass) def test_restamps_last_identity_overrides_without_growing_or_rewriting_unchanged_hex(self): for app_base in (ml.NRF52_APP_BASE_S140_V6, ml.NRF52_APP_BASE_S140_V7): with self.subTest(app_base=app_base), tempfile.TemporaryDirectory() as directory: env = BuildEnvironment(app_base) load_post_script(env) action = env.post_actions["$BUILD_DIR/${PROGNAME}.hex"] path = Path(directory) / "firmware.hex" body = bytes(range(256)) original = IntelHex() original.frombytes(body, offset=app_base) original.write_hex_file(str(path)) stamp(action, path, env) expected_length = len(body) + ml.NRF52_LAYOUT_LEN + ml.ENDF_LEN for index in range(3): env["CPPDEFINES"].extend([ ("FIRMWARE_VERSION", f"v1.17.1.{10 + index}"), ("MOTA_TARGET_ID", hex(0x87654321 + index)), ("MOTA_HW_ID", f"NRF52_CHANGED_{index}"), ]) stamp(action, path, env) application = read_application(path) self.assertEqual(len(application), expected_length) self.assertEqual(application[:len(body)], body) self.assertEqual(application.count(ml.ENDF_MAGIC), 1) self.assertEqual(application.count(ml.NRF52_LAYOUT_MAGIC), 1) self.assertEqual(ml.parse_endf_ident(application), ml.FwIdent( ml.pack_version(f"1.17.1.{10 + index}"), 0x87654321 + index, f"NRF52_CHANGED_{index}")) unchanged = path.read_bytes() os.utime(path, ns=(1_000_000_000, 1_000_000_000)) stamp(action, path, env) self.assertEqual(path.read_bytes(), unchanged) self.assertEqual(path.stat().st_mtime_ns, 1_000_000_000) def test_restamps_layout_flags_without_duplicate_record_and_keeps_other_segments(self): app_base = ml.NRF52_APP_BASE_S140_V7 with tempfile.TemporaryDirectory() as directory: env = BuildEnvironment(app_base) load_post_script(env) action = env.post_actions["$BUILD_DIR/${PROGNAME}.hex"] path = Path(directory) / "firmware.hex" body = bytes(range(256)) original = IntelHex() original.frombytes(body, offset=app_base) original.frombytes(b"separate-flash-data", offset=0x40000) original.frombytes(b"\x12\x34\x56\x78", offset=0x10001080) original.start_addr = {"EIP": app_base + 4} original.write_hex_file(str(path)) base_defines = list(env["CPPDEFINES"]) for extra, expected_flags in (([], 0), (["OTA_SD_STORE"], ml.NRF52_LAYOUT_FLAG_SD), (["OTA_QSPI_STORE"], ml.NRF52_LAYOUT_FLAG_QSPI), ([], 0)): env["CPPDEFINES"] = base_defines + extra stamp(action, path, env) application = read_application(path) self.assertEqual(ml.parse_nrf52_layout(application).flags, expected_flags) self.assertEqual(len(application), len(body) + ml.NRF52_LAYOUT_LEN + ml.ENDF_LEN) self.assertEqual(application.count(ml.NRF52_LAYOUT_MAGIC), 1) image = IntelHex(str(path)) self.assertEqual(bytes(image.tobinarray(start=0x40000, size=19)), b"separate-flash-data") self.assertEqual(bytes(image.tobinarray(start=0x10001080, size=4)), b"\x12\x34\x56\x78") self.assertEqual(image.start_addr, original.start_addr) def test_existing_trailer_without_layout_is_upgraded_in_place(self): app_base = ml.NRF52_APP_BASE_S140_V7 with tempfile.TemporaryDirectory() as directory: env = BuildEnvironment(app_base) load_post_script(env) path = Path(directory) / "old.hex" body = bytes(range(256)) old, _ = ml.ensure_endf(body, ml.FwIdent(1, 2, "OLD")) original = IntelHex() original.frombytes(old, offset=app_base) original.write_hex_file(str(path)) stamp(env.post_actions["$BUILD_DIR/${PROGNAME}.hex"], path, env) application = read_application(path) self.assertEqual(len(application), len(old) + ml.NRF52_LAYOUT_LEN) self.assertTrue(ml.has_endf(application)) self.assertEqual(application.count(ml.ENDF_MAGIC), 1) self.assertEqual(application[:len(body)], body) def test_stamped_hex_still_rejects_changed_unsafe_profiles_without_writes(self): app_base = ml.NRF52_APP_BASE_S140_V7 unsafe_profiles = ( ["OTA_SD_STORE", "OTA_QSPI_STORE"], ["OTA_QSPI_STORE", "QSPIFLASH"], ["OTA_HYBRID_RAM_STORE"], ["OTA_INTERNAL_BOOTLOADER_UPDATE"], ["OTA_QSPI_BOOTLOADER_UPDATE"], ["OTA_SD_BOOTLOADER_UPDATE"], ) with tempfile.TemporaryDirectory() as directory: env = BuildEnvironment(app_base) load_post_script(env) action = env.post_actions["$BUILD_DIR/${PROGNAME}.hex"] path = Path(directory) / "firmware.hex" original = IntelHex() original.frombytes(bytes(range(256)), offset=app_base) original.write_hex_file(str(path)) stamp(action, path, env) before = path.read_bytes() base_defines = list(env["CPPDEFINES"]) for flags in unsafe_profiles: with self.subTest(flags=flags): env["CPPDEFINES"] = base_defines + flags with self.assertRaises((RuntimeError, ValueError)): stamp(action, path, env) self.assertEqual(path.read_bytes(), before) env["CPPDEFINES"] = base_defines env.maximum_size = None with self.assertRaisesRegex(RuntimeError, "region is unavailable"): stamp(action, path, env) self.assertEqual(path.read_bytes(), before) def test_optional_rak_sd_preserves_adaptive_hybrid_and_companion_extrafs_layouts(self): app_base = ml.NRF52_APP_BASE_S140_V6 for companion in (False, True): with self.subTest(companion=companion), tempfile.TemporaryDirectory() as directory: env = BuildEnvironment(app_base) env["CPPDEFINES"].extend(["RAK_4631", "OTA_SD_STORE", "OTA_RAK15002_SD_STORE"]) if companion: env.source_filter = "+" env.maximum_size = ml.NRF52_EXTRAFS_START - app_base env["CPPDEFINES"].extend(["OTA_SEEDER_ONLY", "EXTRAFS"]) expected_flags = ml.NRF52_LAYOUT_FLAG_INTERNAL_EXTRAFS expected_ceiling = ml.NRF52_EXTRAFS_START else: env["CPPDEFINES"].extend(["OTA_RAK_AUTO_STORE", "OTA_QSPI_STORE", "OTA_FLASH_STORE", "OTA_HYBRID_RAM_STORE", "OTA_INTERNAL_BOOTLOADER_UPDATE"]) env.linker = str(ROOT / "boards/nrf52840_s140_v6_mota64.ld") env["LDSCRIPT_PATH"] = env.linker expected_flags = ml.NRF52_LAYOUT_FLAG_AUTO_STORE | ml.NRF52_LAYOUT_FLAG_HYBRID_RAM expected_ceiling = ml.NRF52_APP_END load_post_script(env) path = Path(directory) / "firmware.hex" original = IntelHex() original.frombytes(bytes(range(256)), offset=app_base) original.write_hex_file(str(path)) action = env.post_actions["$BUILD_DIR/${PROGNAME}.hex"] stamp(action, path, env) layout = ml.parse_nrf52_layout(read_application(path)) self.assertEqual(layout.flags, expected_flags) self.assertEqual(layout.stage_ceiling, expected_ceiling) before = path.read_bytes() for missing in ("RAK_4631", "OTA_SD_STORE"): env["CPPDEFINES"].remove(missing) with self.assertRaisesRegex(RuntimeError, "optional RAK15002"): stamp(action, path, env) self.assertEqual(path.read_bytes(), before) env["CPPDEFINES"].append(missing) env["CPPDEFINES"].remove("OTA_SEEDER_ONLY" if companion else "OTA_RAK_AUTO_STORE") with self.assertRaisesRegex(RuntimeError, "optional RAK15002"): stamp(action, path, env) self.assertEqual(path.read_bytes(), before) def test_hex_exact_size_limit_and_smaller_profile_revalidated_after_stamp(self): app_base = ml.NRF52_APP_BASE_S140_V7 with tempfile.TemporaryDirectory() as directory: env = BuildEnvironment(app_base) load_post_script(env) action = env.post_actions["$BUILD_DIR/${PROGNAME}.hex"] path = Path(directory) / "firmware.hex" body = b"\x55" * (env.maximum_size - ml.NRF52_LAYOUT_LEN - ml.ENDF_LEN) original = IntelHex() original.frombytes(body, offset=app_base) original.write_hex_file(str(path)) stamp(action, path, env) self.assertEqual(len(read_application(path)), env.maximum_size) before = path.read_bytes() env["CPPDEFINES"].append(("FIRMWARE_VERSION", "v1.17.1.10")) stamp(action, path, env) self.assertEqual(len(read_application(path)), env.maximum_size) before = path.read_bytes() env.maximum_size = ml.NRF52_EXTRAFS_START - app_base with self.assertRaisesRegex(RuntimeError, "limit is"): stamp(action, path, env) self.assertEqual(path.read_bytes(), before) def test_source_only_and_filesystem_staging_limits_remain_distinct(self): app_base = ml.NRF52_APP_BASE_S140_V7 with tempfile.TemporaryDirectory() as directory: env = BuildEnvironment(app_base) env.source_filter = "+" env["CPPDEFINES"].extend(["EXTRAFS", "OTA_SEEDER_ONLY"]) load_post_script(env) action = env.post_actions["$BUILD_DIR/${PROGNAME}.hex"] path = Path(directory) / "firmware.hex" original = IntelHex() original.frombytes(b"\x55" * (ml.NRF52_EXTRAFS_START - app_base), offset=app_base) original.write_hex_file(str(path)) stamp(action, path, env) layout = ml.parse_nrf52_layout(read_application(path)) self.assertEqual(layout.stage_ceiling, ml.NRF52_EXTRAFS_START) self.assertEqual(layout.flags, ml.NRF52_LAYOUT_FLAG_INTERNAL_EXTRAFS) before = path.read_bytes() env["CPPDEFINES"].remove("OTA_SEEDER_ONLY") with self.assertRaisesRegex(RuntimeError, "nRF52 OTA application limit"): stamp(action, path, env) self.assertEqual(path.read_bytes(), before) def test_trailer_growth_cannot_overwrite_another_hex_segment(self): app_base = ml.NRF52_APP_BASE_S140_V7 for following in (257, 256 + ml.NRF52_LAYOUT_LEN + ml.ENDF_LEN): with self.subTest(following=following), tempfile.TemporaryDirectory() as directory: env = BuildEnvironment(app_base) load_post_script(env) path = Path(directory) / "firmware.hex" original = IntelHex() original.frombytes(bytes(range(256)), offset=app_base) original.frombytes(b"untouched", offset=app_base + following) original.write_hex_file(str(path)) before = path.read_bytes() with self.assertRaisesRegex(RuntimeError, "separate HEX segment"): stamp(env.post_actions["$BUILD_DIR/${PROGNAME}.hex"], path, env) self.assertEqual(path.read_bytes(), before) def test_uicr_added_after_first_stamp_survives_an_identity_restamp(self): app_base = ml.NRF52_APP_BASE_S140_V7 with tempfile.TemporaryDirectory() as directory: env = BuildEnvironment(app_base) load_post_script(env) action = env.post_actions["$BUILD_DIR/${PROGNAME}.hex"] path = Path(directory) / "firmware.hex" original = IntelHex() original.frombytes(bytes(range(256)), offset=app_base) original.write_hex_file(str(path)) stamp(action, path, env) previous = read_application(path) augmented = IntelHex(str(path)) augmented.frombytes(b"new-uicr", offset=0x10001080) augmented.write_hex_file(str(path)) env["CPPDEFINES"].append(("FIRMWARE_VERSION", "v1.17.1.10")) stamp(action, path, env) application = read_application(path) self.assertEqual(len(application), len(previous)) self.assertEqual(application[:-ml.ENDF_LEN], previous[:-ml.ENDF_LEN]) self.assertEqual(ml.parse_endf_ident(application).fw_version, ml.pack_version("1.17.1.10")) self.assertEqual(bytes(IntelHex(str(path)).tobinarray(start=0x10001080, size=8)), b"new-uicr") def test_new_hex_over_linked_application_bound_is_rejected_even_for_source_only(self): for source_only in (False, True): with self.subTest(source_only=source_only), tempfile.TemporaryDirectory() as directory: env = BuildEnvironment(ml.NRF52_APP_BASE_S140_V7) if source_only: env["CPPDEFINES"].append("OTA_SEEDER_ONLY") load_post_script(env) path = Path(directory) / "firmware.hex" # Just one byte too large after adding layout and EndF. body = b"\x55" * (env.maximum_size - ml.NRF52_LAYOUT_LEN - ml.ENDF_LEN + 1) original = IntelHex() original.frombytes(body, offset=env.app_base) original.write_hex_file(str(path)) before = path.read_bytes() with self.assertRaisesRegex(RuntimeError, "limit is"): stamp(env.post_actions["$BUILD_DIR/${PROGNAME}.hex"], path, env) self.assertEqual(path.read_bytes(), before) if __name__ == "__main__": unittest.main()