Add external QSPI staging for nRF52 OTA
This commit is contained in:
1 parent
56afcb8fd9
commit
509f831442
40 files changed
+1671
-157
No files matched your search
+48
-10
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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)))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ----------------------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user