Add external QSPI staging for nRF52 OTA

This commit is contained in:
mikecarper committed 2026-08-20 17:51:52 -07:00
1 parent 56afcb8fd9
commit 509f831442
40 files changed
+1671 -157

No files matched your search

+48 -10
View File
@@ -151,6 +151,12 @@ class TargetInfo:
self_status: str
current_version: str | None = None
current_version_source: str | None = None
nrf_qspi: bool = False
@property
def nrf_external(self) -> bool:
"""Whether nRF52 OTA staging is outside internal application flash."""
return self.nrf_sd or self.nrf_qspi
@dataclass(frozen=True)
@@ -635,7 +641,7 @@ def compatible_mota(info: MotaInfo, target: TargetInfo) -> tuple[bool, str]:
if info.hw_id and target.hw_id and info.hw_id != target.hw_id:
return False, f"hardware {info.hw_id!r}, destination is {target.hw_id!r}"
if info.is_full:
if target.platform == "nrf52" and not target.nrf_sd:
if target.platform == "nrf52" and not target.nrf_external:
return False, "internal-flash nRF52 accepts only an in-place delta"
else:
if info.base_hash != target.base_hash:
@@ -943,7 +949,9 @@ def prepare_package(
"--out",
str(output),
]
if target.platform == "nrf52" and (not target.nrf_sd or args.base is not None):
if target.platform == "nrf52" and (
not target.nrf_external or args.base is not None
):
if args.base is None:
raise OtaError(
"this nRF52 ZIP contains raw firmware, not a ready delta mOTA; "
@@ -958,8 +966,12 @@ def prepare_package(
"--patch-type",
"in-place",
])
# External staging leaves the application region available as the
# detools workspace. 0xC6000 is safe for both S140 v7 (app starts
# at 0x27000) and older v6 layouts (0x26000). Internal staging
# retains its deliberately smaller legacy workspace.
inplace_memory = args.inplace_memory or (
"0xC7000" if target.nrf_sd else "0x98000"
"0xC6000" if target.nrf_external else "0x98000"
)
command.extend(["--inplace-memory", inplace_memory])
if args.sign_key:
@@ -1597,9 +1609,22 @@ def query_target(
nrf_sd = "SD apply OK" in combined or bool(
re.search(r"\bbl:SD\b", combined)
)
nrf_qspi = "QSPI apply OK" in combined or bool(
re.search(r"\bbl:QSPI\b", combined)
)
qspi_store = re.search(r"\bQSPI store:(ERR\s+)?(\d+)K\b", combined)
if nrf_qspi and qspi_store and (
qspi_store.group(1) is not None or int(qspi_store.group(2)) == 0
):
raise OtaError(
"destination bootloader supports QSPI apply, but the application "
"reports `QSPI store:ERR 0K`; check the exact-board firmware, "
"flash wiring, and flash power before downloading"
)
if platform == "nrf52" and (
"NO mota-apply" in combined
or "NO SD mota-apply" in combined
or "NO QSPI mota-apply" in combined
or bool(re.search(r"\bbl:NONE\b", combined))
):
version = bootloader_version or "unknown version"
@@ -1659,6 +1684,7 @@ def query_target(
self_status=self_status,
current_version=current_version,
current_version_source=current_version_source,
nrf_qspi=nrf_qspi,
)
@@ -2897,7 +2923,7 @@ def build_parser() -> argparse.ArgumentParser:
"--base", type=Path,
help=(
"exact running .bin/.hex/.zip/full.mota (required to build an "
"internal-flash nRF52 delta; optional for SD-backed nRF52)"
"internal-flash nRF52 delta; optional for external SD/QSPI nRF52)"
),
)
parser.add_argument("--zip-member", help="select one exact path inside PACKAGE ZIP")
@@ -2905,7 +2931,7 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument("--public-key", type=Path, help="require this signer when verifying")
parser.add_argument(
"--inplace-memory",
help="nRF52 OTAFIX workspace (auto: 0x98000 internal, 0xC7000 SD)",
help="nRF52 OTAFIX workspace (auto: 0x98000 internal, 0xC6000 external SD/QSPI)",
)
parser.add_argument(
"--platform", choices=("esp32", "nrf52"),
@@ -2987,6 +3013,10 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument("--target-id", help="8-hex target ID for --prepare-only")
parser.add_argument("--target-base-hash", help="16-hex EndF body hash for --prepare-only")
parser.add_argument("--nrf-sd", action="store_true", help="offline target uses nRF52 SD staging")
parser.add_argument(
"--nrf-qspi", action="store_true",
help="offline target uses nRF52 external QSPI staging",
)
parser.add_argument("--target-hw", help="hardware identity for --prepare-only")
parser.add_argument(
"--allow-non-upgrade", action="store_true",
@@ -3038,15 +3068,22 @@ def validate_args(args: argparse.Namespace, parser: argparse.ArgumentParser) ->
if args.prepare_only:
if not args.platform or not args.target_id:
parser.error("--prepare-only requires --platform and --target-id")
if args.platform == "nrf52" and not args.nrf_sd and not args.target_base_hash:
if (
args.platform == "nrf52"
and not (args.nrf_sd or args.nrf_qspi)
and not args.target_base_hash
):
parser.error("offline internal-flash nRF52 preparation requires --target-base-hash")
if args.nrf_sd and args.platform != "nrf52":
parser.error("--nrf-sd requires --platform nrf52")
if args.nrf_sd and args.nrf_qspi:
parser.error("--nrf-sd and --nrf-qspi are mutually exclusive")
if (args.nrf_sd or args.nrf_qspi) and args.platform != "nrf52":
parser.error("--nrf-sd/--nrf-qspi require --platform nrf52")
else:
if any((args.platform, args.target_id, args.target_base_hash, args.target_hw, args.nrf_sd)):
if any((args.platform, args.target_id, args.target_base_hash,
args.target_hw, args.nrf_sd, args.nrf_qspi)):
parser.error(
"--platform, --target-id, --target-base-hash, --target-hw, and "
"--nrf-sd are only valid with --prepare-only"
"--nrf-sd/--nrf-qspi are only valid with --prepare-only"
)
if not any((args.controller_serial, args.controller_tcp, args.controller_ble)):
parser.error("a controller connection is required")
@@ -3177,6 +3214,7 @@ def offline_target(args: argparse.Namespace) -> TargetInfo:
bootloader_codecs=None,
status="offline",
self_status="offline",
nrf_qspi=args.nrf_qspi,
)
+1 -1
View File
@@ -837,7 +837,7 @@ def query_live_target(
raise ota.OtaError(
f"live hardware is {target.hw_id!r}, expected {EXPECTED_HARDWARE!r}"
)
if target.platform != "nrf52" or target.nrf_sd:
if target.platform != "nrf52" or target.nrf_external:
raise ota.OtaError("live target is not the expected internal-flash nRF52 node")
if target.bootloader_abi is None or target.bootloader_abi < 2:
raise ota.OtaError("live target does not report OTAFIX mOTA ABI 2")
+82
View File
@@ -85,6 +85,7 @@ def target(
platform: str = "esp32",
base_hash: bytes = b"\0" * 8,
nrf_sd: bool = False,
nrf_qspi: bool = False,
boot_codecs: int | None = None,
boot_version: str | None = None,
current_version: str | None = None,
@@ -102,6 +103,7 @@ def target(
status="status",
self_status="self",
current_version=current_version,
nrf_qspi=nrf_qspi,
)
@@ -177,6 +179,23 @@ class FormatTests(unittest.TestCase):
])
ota.validate_args(args, parser)
def test_offline_qspi_nrf52_does_not_require_a_base_hash(self) -> None:
parser = ota.build_parser()
args = parser.parse_args([
"release.zip", "offline", "--prepare-only", "--platform", "nrf52",
"--target-id", f"{TARGET:08X}", "--nrf-qspi",
])
ota.validate_args(args, parser)
def test_nrf_external_stores_are_mutually_exclusive(self) -> None:
parser = ota.build_parser()
args = parser.parse_args([
"release.zip", "offline", "--prepare-only", "--platform", "nrf52",
"--target-id", f"{TARGET:08X}", "--nrf-sd", "--nrf-qspi",
])
with self.assertRaises(SystemExit), contextlib.redirect_stderr(io.StringIO()):
ota.validate_args(args, parser)
def test_nrf_sd_is_rejected_for_esp32(self) -> None:
parser = ota.build_parser()
args = parser.parse_args([
@@ -401,6 +420,11 @@ class CompatibilityTests(unittest.TestCase):
nrf = target(platform="nrf52", nrf_sd=True, boot_codecs=1)
self.assertTrue(ota.compatible_mota(full, nrf)[0])
def test_qspi_nrf52_accepts_full_when_bootloader_does(self) -> None:
full = ota.parse_mota(mota_blob(self.new_image))
nrf = target(platform="nrf52", nrf_qspi=True, boot_codecs=1)
self.assertTrue(ota.compatible_mota(full, nrf)[0])
def test_zip_prefers_equal_version_delta(self) -> None:
full = mota_blob(self.new_image)
delta = mota_blob(
@@ -697,6 +721,48 @@ class ReliabilityTests(unittest.TestCase):
["ota status", "get bootloader.ver", "ota self", "ota stats"],
)
def test_target_detects_qspi_staging(self) -> None:
class Controller:
def __init__(self) -> None:
self.replies = iter([
"OTA | no download | target:1234ABCD hw=Xiao_nrf52 | bl:QSPI blrc:B0",
"> 0.9.2-OTAFIX2.4",
"self body=1 image=2 base_hash=0011223344556677 | "
"bootloader: QSPI apply OK (abi=2 codecs=0x5)",
"OTA | fw v1.17.0 id=00112233",
])
def remote_command(self, *_args: object, **_kwargs: object) -> str:
return next(self.replies)
result = ota.query_target(
Controller(), argparse.Namespace(target="remote")
)
self.assertTrue(result.nrf_qspi)
self.assertTrue(result.nrf_external)
self.assertFalse(result.nrf_sd)
def test_target_rejects_unavailable_qspi_store(self) -> None:
class Controller:
def __init__(self) -> None:
self.replies = iter([
"OTA | no download | target:1234ABCD hw=Xiao_nrf52 | bl:QSPI blrc:B0",
"> 0.9.2-OTAFIX2.4",
"self body=1 image=2 base_hash=0011223344556677 | "
"QSPI store:ERR 0K | bootloader: QSPI apply OK "
"(abi=2 codecs=0x5)",
])
def remote_command(self, *_args: object, **_kwargs: object) -> str:
return next(self.replies)
with self.assertRaisesRegex(
ota.OtaError, "bootloader supports QSPI apply.*QSPI store:ERR 0K"
):
ota.query_target(
Controller(), argparse.Namespace(target="remote")
)
def test_stock_nrf52_bootloader_reports_required_action(self) -> None:
class Controller:
def __init__(self) -> None:
@@ -2030,6 +2096,22 @@ class MotatoolIntegrationTests(unittest.TestCase):
)
self.assertTrue(package.is_full)
def test_raw_qspi_nrf52_zip_becomes_full_without_base(self) -> None:
image = firmware(b"nrf-qspi-new" * 800, VERSION_NEW)
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
archive_path = root / "release.zip"
with zipfile.ZipFile(archive_path, "w") as archive:
archive.writestr("firmware.bin", image)
work = root / "work"
work.mkdir()
_path, package, _expected = ota.prepare_package(
prepare_args(archive_path, self.motatool),
target(platform="nrf52", nrf_qspi=True, boot_codecs=1),
work,
)
self.assertTrue(package.is_full)
if __name__ == "__main__":
unittest.main(verbosity=2)
+26 -2
View File
@@ -72,6 +72,7 @@ NRF52_LAYOUT_VERSION = 1
NRF52_LAYOUT_LEN = 24
NRF52_LAYOUT_FLAG_SD = 0x01
NRF52_LAYOUT_FLAG_INTERNAL_EXTRAFS = 0x02
NRF52_LAYOUT_FLAG_QSPI = 0x04
# MeshTower V2's SD-backed OTA target keeps the staged .mota off-chip, so the application may use the
# complete S140 v6 application region up to InternalFS instead of leaving room for internal staging.
@@ -140,6 +141,15 @@ def hardware_id_for_env(env_name: str) -> str:
possible, while a cross-board install is rejected. Long family names retain a short hash suffix to
avoid collisions inside EndF's fixed 32-byte field.
"""
# Optional storage that requires a different bootloader/application pair is
# a distinct hardware class even though it uses the same WisBlock Core.
if re.fullmatch(
r"RAK_4631_repeater_rak15001_slot_c_lora_ota",
env_name,
re.IGNORECASE,
):
return "RAK4631_RAK15001_C"
role = re.search(
r"[_-](?:repeater|repeatr|room_server|room_svr|sensor|terminal_chat|kiss_modem|"
r"companion_radio|companion|comp_radio)(?=[_-]|$)", env_name, re.IGNORECASE)
@@ -181,6 +191,14 @@ class Nrf52Layout:
def sd_backed(self) -> bool:
return bool(self.flags & NRF52_LAYOUT_FLAG_SD)
@property
def qspi_backed(self) -> bool:
return bool(self.flags & NRF52_LAYOUT_FLAG_QSPI)
@property
def external_backed(self) -> bool:
return self.sd_backed or self.qspi_backed
def nrf52_stage_ceiling_for_layout(linked_app_end: int, uses_internal_extrafs: bool) -> int:
"""Select a safe staging ceiling from linker geometry and actual secondary-storage type."""
@@ -200,9 +218,15 @@ def build_nrf52_layout(layout: Nrf52Layout) -> bytes:
raise ValueError(f"unsupported nRF52 staging ceiling 0x{layout.stage_ceiling:X}")
if not (layout.app_base < layout.linked_app_end <= NRF52_APP_END):
raise ValueError("invalid nRF52 app region")
if layout.flags & ~(NRF52_LAYOUT_FLAG_SD | NRF52_LAYOUT_FLAG_INTERNAL_EXTRAFS):
known_flags = (NRF52_LAYOUT_FLAG_SD | NRF52_LAYOUT_FLAG_INTERNAL_EXTRAFS |
NRF52_LAYOUT_FLAG_QSPI)
if layout.flags & ~known_flags:
raise ValueError(f"unsupported nRF52 layout flags 0x{layout.flags:X}")
expected_ceiling = (NRF52_APP_END if layout.sd_backed else
if layout.sd_backed and layout.qspi_backed:
raise ValueError("nRF52 layout cannot use both SD and QSPI staging")
if layout.external_backed and layout.flags & NRF52_LAYOUT_FLAG_INTERNAL_EXTRAFS:
raise ValueError("nRF52 external staging cannot also reserve internal ExtraFS")
expected_ceiling = (NRF52_APP_END if layout.external_backed else
nrf52_stage_ceiling_for_layout(
layout.linked_app_end,
bool(layout.flags & NRF52_LAYOUT_FLAG_INTERNAL_EXTRAFS)))
+7 -1
View File
@@ -178,9 +178,15 @@ def _append_endf_hex(source, target, env): # Intel-HEX path (nRF52: app f
internal_extrafs = (_cppdef("EXTRAFS") is not None and _cppdef("QSPIFLASH") is None
and _builds_companion_radio())
sd_backed = _cppdef("OTA_SD_STORE") is not None
stage_ceiling = (ml.NRF52_APP_END if sd_backed else
qspi_backed = _cppdef("OTA_QSPI_STORE") is not None
if sd_backed and qspi_backed:
raise RuntimeError("nRF52 build cannot enable both SD and QSPI OTA stores")
if qspi_backed and _cppdef("QSPIFLASH") is not None:
raise RuntimeError("raw QSPI OTA staging cannot share a chip with QSPIFLASH")
stage_ceiling = (ml.NRF52_APP_END if (sd_backed or qspi_backed) else
ml.nrf52_stage_ceiling_for_layout(linked_app_end, internal_extrafs))
layout_flags = ((ml.NRF52_LAYOUT_FLAG_SD if sd_backed else 0) |
(ml.NRF52_LAYOUT_FLAG_QSPI if qspi_backed else 0) |
(ml.NRF52_LAYOUT_FLAG_INTERNAL_EXTRAFS if internal_extrafs else 0))
layout = ml.Nrf52Layout(app_start, linked_app_end, stage_ceiling, layout_flags)
body = ml.ensure_nrf52_layout(raw_body, layout)
+25
View File
@@ -54,6 +54,14 @@ def test_ota_target_generation_honors_explicit_disable():
def test_hardware_id_for_env():
assert ml.hardware_id_for_env("RAK_4631_repeater") == "RAK4631"
assert ml.hardware_id_for_env("RAK_4631_companion_radio_usb") == "RAK4631"
assert (
ml.hardware_id_for_env("RAK_4631_repeater_rak15001_slot_c_lora_ota")
== "RAK4631_RAK15001_C"
)
assert (
ml.hardware_id_for_env("RAK_3401_repeater_rak15001_slot_c_lora_ota")
== "RAK_3401"
)
assert (
ml.hardware_id_for_env("Heltec_t114_without_display_repeater")
== "Heltec_t114"
@@ -128,6 +136,12 @@ def test_nrf52_layout_record_roundtrip_and_policy():
ml.NRF52_EXTRAFS_START, ml.NRF52_LAYOUT_FLAG_INTERNAL_EXTRAFS)
internal_image, _ = ml.ensure_endf(ml.ensure_nrf52_layout(_fw(6, 2048), internal))
assert ml.parse_nrf52_layout(internal_image) == internal
qspi = ml.Nrf52Layout(
ml.NRF52_APP_BASE_S140_V7, ml.NRF52_APP_END,
ml.NRF52_APP_END, ml.NRF52_LAYOUT_FLAG_QSPI)
qspi_image, _ = ml.ensure_endf(ml.ensure_nrf52_layout(_fw(7, 2048), qspi))
assert ml.parse_nrf52_layout(qspi_image) == qspi
assert qspi.qspi_backed and qspi.external_backed and not qspi.sd_backed
try:
ml.build_nrf52_layout(ml.Nrf52Layout(
ml.NRF52_APP_BASE_S140_V7, ml.NRF52_EXTRAFS_START,
@@ -135,6 +149,17 @@ def test_nrf52_layout_record_roundtrip_and_policy():
assert False, "inconsistent layout record accepted"
except ValueError:
pass
for flags in (
ml.NRF52_LAYOUT_FLAG_SD | ml.NRF52_LAYOUT_FLAG_QSPI,
ml.NRF52_LAYOUT_FLAG_QSPI | ml.NRF52_LAYOUT_FLAG_INTERNAL_EXTRAFS,
):
try:
ml.build_nrf52_layout(ml.Nrf52Layout(
ml.NRF52_APP_BASE_S140_V7, ml.NRF52_APP_END,
ml.NRF52_APP_END, flags))
assert False, "conflicting nRF52 layout flags accepted"
except ValueError:
pass
# --- merkle ----------------------------------------------------------------