From a09127fefb7f26a04f606424eeb5cc4bff3df04b Mon Sep 17 00:00:00 2001 From: mikecarper Date: Fri, 14 Aug 2026 04:25:44 -0700 Subject: [PATCH] Publish accelerated RAK3401 mOTA chain --- .gitattributes | 2 + docs/rak3401_mota_chain.md | 98 ++- .../build_rak3401_accelerated_bridges.py | 363 ++++++++++ ...rak3401_four_component_endf_backport.patch | 16 + tools/lora_ota/rak3401_mota_chain.py | 142 +++- tools/lora_ota/rak3401_selective_os.py | 180 +++++ .../rak3401_terminal_ota_backport.patch | 52 ++ .../rak3401_terminal_ota_mesh_guarded.patch | 18 + .../rak3401_terminal_ota_mesh_legacy.patch | 20 + tools/lora_ota/rebuild_rak3401_bundle.py | 640 ++++++++++++++++++ tools/lora_ota/test_lora_ota.py | 34 +- 11 files changed, 1494 insertions(+), 71 deletions(-) create mode 100644 .gitattributes create mode 100755 tools/lora_ota/build_rak3401_accelerated_bridges.py create mode 100644 tools/lora_ota/rak3401_four_component_endf_backport.patch create mode 100755 tools/lora_ota/rak3401_selective_os.py create mode 100644 tools/lora_ota/rak3401_terminal_ota_backport.patch create mode 100644 tools/lora_ota/rak3401_terminal_ota_mesh_guarded.patch create mode 100644 tools/lora_ota/rak3401_terminal_ota_mesh_legacy.patch create mode 100755 tools/lora_ota/rebuild_rak3401_bundle.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..9c0e2607 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +tools/lora_ota/rak3401_four_component_endf_backport.patch whitespace=-blank-at-eol,-blank-at-eof +tools/lora_ota/rak3401_terminal_ota_backport.patch whitespace=-blank-at-eol,-blank-at-eof diff --git a/docs/rak3401_mota_chain.md b/docs/rak3401_mota_chain.md index 48c2a480..9cebc485 100644 --- a/docs/rak3401_mota_chain.md +++ b/docs/rak3401_mota_chain.md @@ -1,25 +1,28 @@ # RAK3401 1W repeater LoRa update chain -> Status: physically passed. The exact 29-step chain completed from the -> c1caa5ad v1.16.7.0 test image through v1.17.01 on a RAK3401 1W. Every step -> passed its post-boot version, EndF, rescue-command, and watchdog checks. +> Status: accelerated release candidate. All 30 exact transitions passed +> independent offline reconstruction, container verification, and stable +> bootloader simulation. The byte-identical first bridge and the superseded +> 29-step baseline completed physical RAK3401 testing; the new complete 30-step +> byte sequence still needs its final full-chain hardware qualification. The release is [`rak3401-mota-v1.16.07-c1caa5ad-to-v1.17.01-c96bdd6e`](https://github.com/mikecarper/MeshCore/releases/tag/rak3401-mota-v1.16.07-c1caa5ad-to-v1.17.01-c96bdd6e). Use this asset: ```text -RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.01-cd824765.zip +RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.01-8c5262c6.zip ``` -- ZIP SHA-256: `eac67a0be12690b7e22c4d1f6a15bfdeb5bd627c4850b246b1be4220e5607b34` -- inner `SHA256SUMS.txt` SHA-256: `8097d75c5d11b9e32e3ebd4971068bf743eaa108054ab39eb0049016f89d185d` -- endpoint image SHA-256: `5c8d94bb23e87c23b0374ffb5a46e0c1205d6eebcb9d8bae3be6fda2613f9f79` -- endpoint EndF body hash: `DD45E7A23438B051` +- ZIP SHA-256: `b2781e02460b200a7c37bfae352bad81618716e550d1d042dca8aa29bfc73c29` +- inner `SHA256SUMS.txt` SHA-256: `1f7add658ae5771451cd66a0e5c58a5e461983c1b36029ef480093ae5d5f1020` +- endpoint image SHA-256: `a08b5791419410c760f31c26bb45c77d776eb7bbf68fde656e19bcd616a6227d` +- endpoint EndF body hash: `3DD88C3DD7B8A35B` -The older v1.17.01 candidates and the v1.17.02 chain are retained only for -diagnosis. The runner recognizes their hashes and refuses live installation -before opening a device or reading a password. +The superseded 29-step v1.17.01 release, earlier v1.17.01 candidates, and the +v1.17.02 chain remain useful only for provenance or diagnosis. The runner +recognizes their hashes and refuses live installation before opening a device +or reading a password. ## Exact destination @@ -30,15 +33,15 @@ target: - hardware: `RAK_3401` - role: `RAK_3401_repeater_lora_ota_no_external_sensors` - start: `v1.16.7.0-c1caa5ad`, EndF `71F4026CBE4B8B74` -- endpoint: `v1.17.01-halo-keymind-cascade-dev-cd824765`, EndF `DD45E7A23438B051` +- endpoint: `v1.17.01-halo-keymind-cascade-dev-8c5262c6`, EndF `3DD88C3DD7B8A35B` - deployment target key: `63d8df6387eaffd2e25db7d2a8ad967a65202182a48d681d7e7a9260f917280d` Do not use the chain on another target ID, hardware family, starting image, or firmware body hash. The runner checks all four. -## What was tested +## Validation and speed design -The direct physical run used: +The direct physical baseline used: - a Heltec V4.3 OLED 2 MB Full Companion on `/dev/ttyACM0`; - the same Full Companion at `192.168.1.51`, with mOTA seeding on TCP `5001` @@ -47,23 +50,40 @@ The direct physical run used: `8ce031bc322f3cf77376eeacfe8800b30d8c5dfe927f3636d801f3455a2dd4cf`; - one direct RF link to `RAK3401 1W Repeater`; - temporary radio `909.950 MHz`, `500 kHz`, `SF5`, `CR5`; -- all 29 freshly rebuilt 1 KiB-block mOTA packages. +- all 29 packages in the now-superseded `cd824765` chain. -The first verified transition completed at `2026-08-14T01:50:24Z`; the final -endpoint completed at `2026-08-14T06:41:36Z`. The ZIP contains the complete -29-row physical record as `PHYSICAL-TEST.jsonl`. +The baseline started at `2026-08-14T01:50:24Z` and reached its endpoint at +`2026-08-14T06:41:36Z`. This ZIP preserves that 29-row record as +`PHYSICAL-BASELINE-cd824765.jsonl`; it does not claim those rows describe the +new bridge bytes. -The largest package is 89,844 bytes and leaves 268 bytes of staging margin. -Every delta was also reconstructed offline with both zero-filled and -`0xFF`-filled workspaces and verified independently with `motatool`. Step 25 is -an intentional extra bridge that keeps the compiler-layout transition small. +All 30 new containers were freshly generated. Every delta was reconstructed +with both zero-filled and `0xFF`-filled workspaces, verified independently by +`motatool`, and exercised by the stable OTAFIX apply simulator. The largest +package is the byte-identical, physically passed step 1 at 89,844 bytes, which +leaves 268 bytes of the fixed 90,112-byte staging region. Step 2 is the earliest +safe place for the terminal-consumption backport: adding it directly to step 1 +would exceed that staging limit. Step 26 remains an intentional compiler-layout +bridge. -Step 1 installs both the guarded rescue command and the accelerated four-block -primary requester. The old requester used for the first transfer is serial, so -step 1 is expected to be slower. Steps 2 through 29 use the accelerated -requester. Some historical bridge text still says `low priority`; those -images were built with `OTA_TX_PRIORITY=0`, so their transfer traffic is -actually primary. +The speedup keeps 1 KiB logical blocks. Firmware currently accepts at most 1 +KiB, and larger logical blocks would still fragment into the same roughly +160-byte LoRa packets while increasing the amount at risk when a block must be +retried. Instead, one request can open an adaptive flight of multiple 1 KiB +blocks. It begins at one block, grows through two and three to four after clean +flights, and reduces the width after missing data or proofs. Retry timing is +derived from actual LoRa airtime and path length. Active transfer packets use +primary priority; periodic discovery remains background traffic. + +Step 1 installs the guarded rescue command, the four-block adaptive requester, +and primary transfer priority. The starting firmware still requests step 1 +serially. Step 2 installs terminal OTA bulk-packet consumption in the target, +so the echo/dispatch reduction takes effect for transfers 3 through 30. A +direct endpoint comparison confirmed the intended behavior: the older target +needed 147 host block reads for a 48-block package and reported ready after +267 seconds; the current target needed 82 reads for a larger 68-block package +and reported ready after 98 seconds. These differently sized transfers are a +protocol comparison, not a formal throughput benchmark. ## Host requirements @@ -86,17 +106,21 @@ No password or device is needed: ```bash python3 tools/lora_ota/rak3401_mota_chain.py \ - --bundle /path/to/RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.01-cd824765.zip \ + --bundle /path/to/RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.01-8c5262c6.zip \ --work-dir ./rak3401-mota-chain-work \ --motatool /path/to/motatool \ --verify-only ``` When `--bundle` is omitted, the runner downloads the pinned release asset. It -checks the outer ZIP hash, complete inner checksum coverage, all 29 manifests, +checks the outer ZIP hash, complete inner checksum coverage, all 30 manifests, chain continuity, audited image anchors, final recovery image, and every mOTA container with `motatool`. +Offline verification does not need `--accept-test-candidate`. Live preflight or +installation does: the flag explicitly acknowledges that the exact 30-step +sequence has exhaustive offline validation but not yet a complete physical run. + ## Direct recoverable bench run: BW500/SF5 Restore the test RAK locally with the ZIP's @@ -110,7 +134,7 @@ For the tested direct topology: export MESHCORE_ADMIN_PASSWORD='password' python3 tools/lora_ota/rak3401_mota_chain.py \ - --bundle /path/to/RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.01-cd824765.zip \ + --bundle /path/to/RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.01-8c5262c6.zip \ --work-dir ./rak3401-mota-chain-work \ --controller-serial /dev/ttyACM0 \ --source-tcp 192.168.1.51:5001 \ @@ -120,6 +144,7 @@ python3 tools/lora_ota/rak3401_mota_chain.py \ --temp-radio 909.950,500,5,5,120 \ --ota-hops 1 \ --motatool /path/to/motatool \ + --accept-test-candidate \ --yes ``` @@ -145,7 +170,7 @@ Run a non-mutating preflight first: export MESHCORE_ADMIN_PASSWORD='your-destination-and-relay-password' python3 tools/lora_ota/rak3401_mota_chain.py \ - --bundle /path/to/RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.01-cd824765.zip \ + --bundle /path/to/RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.01-8c5262c6.zip \ --work-dir ./rak3401-mota-chain-work \ --controller-serial /dev/ttyACM0 \ --source-tcp SOURCE_IP:5001 \ @@ -157,6 +182,7 @@ python3 tools/lora_ota/rak3401_mota_chain.py \ --temp-radio 909.950,250,5,5,120 \ --ota-hops 3 \ --motatool /path/to/motatool \ + --accept-test-candidate \ --preflight-only ``` @@ -168,7 +194,7 @@ Arm the relay chain farthest-to-nearest while all nodes are still reachable on the ordinary mesh. The runner then moves the source and controller to the same temporary tuple. Keep the private TempRadio network reserved for OTA during the run; public-mesh flooding does not improve this transfer. A 120-minute -window is the minimum used here—raise it if the selected timeouts and relay +window is the minimum used here; raise it if the selected timeouts and relay count require more time. ## Guarded no-EndF rescue @@ -204,12 +230,12 @@ The runner performs the following guarded sequence: prove the rescue command is present before exposing that bridge to another step. Relayed runs keep the same 10-second probe cadence until the relays' mandatory return-to-normal window has elapsed. -5. Re-enable the watchdog only after the exact step-29 endpoint boots. +5. Re-enable the watchdog only after the exact step-30 endpoint boots. Success requires all of the following: -- version `v1.17.01-halo-keymind-cascade-dev-cd824765`; -- EndF body hash `DD45E7A23438B051`; +- version `v1.17.01-halo-keymind-cascade-dev-8c5262c6`; +- EndF body hash `3DD88C3DD7B8A35B`; - target `2FA509C1`, hardware `RAK_3401`; - normal radio `910.525 MHz`, `62.5 kHz`, `SF7`, `CR5`; - `system.watchdog` verified `on`. diff --git a/tools/lora_ota/build_rak3401_accelerated_bridges.py b/tools/lora_ota/build_rak3401_accelerated_bridges.py new file mode 100755 index 00000000..79bba2ee --- /dev/null +++ b/tools/lora_ota/build_rak3401_accelerated_bridges.py @@ -0,0 +1,363 @@ +#!/usr/bin/env python3 +"""Build terminal-suppressed RAK3401 images for the historical mOTA chain. + +The input commits are pinned because each image is a deliberate binary bridge. +The script uses one temporary Git worktree, applies the reviewed backport +patches without committing them, and emits a machine-readable image manifest. +""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import zipfile + + +SCRIPT_DIR = Path(__file__).resolve().parent +REPO_ROOT = SCRIPT_DIR.parent.parent +sys.path.insert(0, str(REPO_ROOT / "tools" / "mota")) + +import motalib # noqa: E402 + + +ENV_NAME = "RAK_3401_repeater_lora_ota_no_external_sensors" +VERSION_SUFFIX = "halo-keymind-cascade-mota-fast" +EXPECTED_TARGET_ID = 0x2FA509C1 +EXPECTED_HARDWARE = "RAK_3401" +COMMON_PATCH = SCRIPT_DIR / "rak3401_terminal_ota_backport.patch" +LEGACY_MESH_PATCH = SCRIPT_DIR / "rak3401_terminal_ota_mesh_legacy.patch" +GUARDED_MESH_PATCH = SCRIPT_DIR / "rak3401_terminal_ota_mesh_guarded.patch" +VERSION_PATCH = SCRIPT_DIR / "rak3401_four_component_endf_backport.patch" +SELECTIVE_OS_HOOK = SCRIPT_DIR / "rak3401_selective_os.py" + + +@dataclass(frozen=True) +class Target: + version: str + source_commit: str + bridge_stage: int = 0 + os_stage: int = 0 + split_stage5: str = "" + mymesh_opt: str = "" + flood_rule_engine: bool = True + + +TARGETS = ( + Target("1.16.7.10", "90abbd110ef4fa7c96fca30205bbc566bf5c966c", bridge_stage=6), + Target("1.16.7.11", "90abbd110ef4fa7c96fca30205bbc566bf5c966c", bridge_stage=7), + Target("1.16.7.12", "90abbd110ef4fa7c96fca30205bbc566bf5c966c", bridge_stage=8), + Target("1.16.7.13", "804f45303ac615362ab56f44da0789301bb3de11"), + Target("1.16.8.0", "cd89b5400cbc16b31a87079a793bc88600369c80"), + Target("1.16.8.7", "1fa940aa3a5d98d41a7320a25d60798edeae4921"), + Target("1.16.8.8", "bbc2c905a1befb83365cccec583c527a24ebb104"), + Target("1.16.8.9", "bec98977389bae5116d675107fa6c11eeab1a3f7"), + Target("1.16.9.0", "1f7f2a8034f0fc74b378dfb44126ef869c2a9344"), + Target("1.16.9.102", "63ab53e4445d726945ce32d530252e34ecf6d1b1"), + Target("1.16.9.104", "262542cf3411bfd093978c502ebb94ba3ba3cc0c", flood_rule_engine=False), + Target("1.16.9.105", "352d70465276d66cda5cbda955aa3dfffee39bbb", flood_rule_engine=False), + Target("1.16.9.108", "352d70465276d66cda5cbda955aa3dfffee39bbb"), + Target("1.16.9.109", "03edc027b19086918f567561cab673fe3724b9b8"), + Target("1.16.9.110", "07a624af00cea922ae495612b728e4abf51f9f87"), + Target("1.16.9.111", "4efd561dcdbafd345641f2a07689d4657b103eed", os_stage=1), + Target("1.16.9.112", "217b78aac01f538cac49afe79886371a8b000528", os_stage=1), + Target("1.16.9.113", "217b78aac01f538cac49afe79886371a8b000528", os_stage=2), + Target("1.16.9.114", "217b78aac01f538cac49afe79886371a8b000528", os_stage=3), + Target("1.16.9.115", "217b78aac01f538cac49afe79886371a8b000528", os_stage=4), + Target( + "1.16.9.116", "217b78aac01f538cac49afe79886371a8b000528", + os_stage=5, split_stage5="first", + ), + Target("1.16.9.117", "217b78aac01f538cac49afe79886371a8b000528", os_stage=5), + Target("1.16.9.118", "217b78aac01f538cac49afe79886371a8b000528", os_stage=7), + Target( + "1.16.9.119", "217b78aac01f538cac49afe79886371a8b000528", + os_stage=8, mymesh_opt="O2_SIZE_1_INLINE_HOIST", + ), + Target( + "1.16.9.120", "217b78aac01f538cac49afe79886371a8b000528", + os_stage=8, mymesh_opt="O2_SIZE_1_HOIST", + ), + Target( + "1.16.9.121", "217b78aac01f538cac49afe79886371a8b000528", + os_stage=8, mymesh_opt="-Os", + ), + Target( + "1.16.9.122", "217b78aac01f538cac49afe79886371a8b000528", + os_stage=12, mymesh_opt="-Os", + ), + Target( + "1.16.10.0", "217b78aac01f538cac49afe79886371a8b000528", + os_stage=13, mymesh_opt="-Os", + ), +) + +LEGACY_MESH_COMMITS = { + "90abbd110ef4fa7c96fca30205bbc566bf5c966c", + "804f45303ac615362ab56f44da0789301bb3de11", +} + + +class BuildError(RuntimeError): + pass + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def run( + command: list[str], + label: str, + cwd: Path = REPO_ROOT, + timeout: int = 900, + env: dict[str, str] | None = None, +) -> str: + try: + result = subprocess.run( + command, + cwd=cwd, + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + timeout=timeout, + check=False, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise BuildError(f"{label} failed: {exc}") from exc + if result.returncode != 0: + raise BuildError( + f"{label} exited {result.returncode}:\n{result.stdout[-6000:]}" + ) + return result.stdout + + +def read_image(path: Path) -> bytes: + with zipfile.ZipFile(path) as archive: + members = [item for item in archive.infolist() if item.filename == "firmware.bin"] + if len(members) != 1: + raise BuildError(f"{path} does not contain one root firmware.bin") + return archive.read(members[0]) + + +def verify_image(path: Path, target: Target) -> dict[str, object]: + image = read_image(path) + if not motalib.has_endf(image): + raise BuildError(f"{path} has no valid EndF") + ident = motalib.parse_endf_ident(image) + assert ident is not None + version = motalib.unpack_version(ident.fw_version) + if version != target.version: + raise BuildError(f"{path} version is {version}, expected {target.version}") + if ident.target_id != EXPECTED_TARGET_ID or ident.hw_id != EXPECTED_HARDWARE: + raise BuildError( + f"{path} identity is target={ident.target_id:08X} hw={ident.hw_id!r}" + ) + body, body_hash = motalib.parse_endf(image) + return { + "version": target.version, + "source_commit": target.source_commit, + "zip": path.name, + "firmware_size": len(image), + "firmware_sha256": hashlib.sha256(image).hexdigest(), + "body_size": len(body), + "body_hash": body_hash.hex(), + } + + +def apply_backport(source: Path, commit: str) -> None: + required = ( + COMMON_PATCH, + LEGACY_MESH_PATCH if commit in LEGACY_MESH_COMMITS else GUARDED_MESH_PATCH, + ) + for patch in required: + run(["git", "apply", "--check", str(patch)], f"check {patch.name}", source) + run(["git", "apply", str(patch)], f"apply {patch.name}", source) + version_check = subprocess.run( + ["git", "apply", "--check", str(VERSION_PATCH)], + cwd=source, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + if version_check.returncode == 0: + run(["git", "apply", str(VERSION_PATCH)], f"apply {VERSION_PATCH.name}", source) + else: + run( + ["git", "apply", "--reverse", "--check", str(VERSION_PATCH)], + f"confirm existing {VERSION_PATCH.name}", + source, + ) + run(["git", "diff", "--check"], "backport whitespace check", source) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--work-dir", type=Path, required=True) + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--keep-worktree", action="store_true") + args = parser.parse_args() + + for patch in (COMMON_PATCH, LEGACY_MESH_PATCH, GUARDED_MESH_PATCH, VERSION_PATCH): + if not patch.is_file(): + raise BuildError(f"missing backport patch: {patch}") + if not SELECTIVE_OS_HOOK.is_file(): + raise BuildError(f"missing selective optimizer hook: {SELECTIVE_OS_HOOK}") + if args.work_dir.exists(): + raise BuildError(f"work directory already exists: {args.work_dir}") + if args.output_dir.exists(): + raise BuildError(f"output directory already exists: {args.output_dir}") + args.work_dir.mkdir(parents=True) + args.output_dir.mkdir(parents=True) + log_dir = args.work_dir / "logs" + log_dir.mkdir() + source = args.work_dir / "source" + + run( + ["git", "worktree", "add", "--detach", str(source), TARGETS[0].source_commit], + "create build worktree", + ) + current_commit = "" + records: list[dict[str, object]] = [] + succeeded = False + try: + for index, target in enumerate(TARGETS, 1): + if target.source_commit != current_commit: + if current_commit: + run( + [ + "git", "restore", "--source=HEAD", "--staged", "--worktree", "--", + "src/Mesh.cpp", "src/helpers/ota/OtaManager.cpp", + "src/helpers/ota/OtaManager.h", "tools/mota/pio_endf.py", + ], + "restore prior backport", + source, + ) + run( + ["git", "checkout", "--detach", target.source_commit], + f"checkout {target.source_commit}", + source, + ) + apply_backport(source, target.source_commit) + current_commit = target.source_commit + + firmware_version = f"v{target.version}-{VERSION_SUFFIX}" + build_env = dict(os.environ) + for name in ( + "MOTA_BRIDGE_OS_STAGE", "MOTA_BRIDGE_SPLIT_STAGE5", + "MOTA_BRIDGE_MY_MESH_OPT", "MOTA_BRIDGE_OS_VERBOSE", + "PLATFORMIO_BUILD_FLAGS", "PLATFORMIO_EXTRA_SCRIPTS", + ): + build_env.pop(name, None) + build_env["DISABLE_DEBUG"] = "1" + flags = [ + "-DLORA_FREQ=910.525", "-DLORA_BW=62.5", "-DLORA_SF=7", + "-DLORA_CR=5", "-DOTA_FETCH_PIPELINE=4", "-DOTA_TX_PRIORITY=0", + ] + if not target.flood_rule_engine: + flags.append("-DMESH_ENABLE_FLOOD_RULE_ENGINE=0") + if target.bridge_stage: + flags.append(f"-DMOTA_BRIDGE_586_STAGE={target.bridge_stage}") + if target.os_stage: + build_env["MOTA_BRIDGE_OS_STAGE"] = str(target.os_stage) + build_env["PLATFORMIO_EXTRA_SCRIPTS"] = f"pre:{SELECTIVE_OS_HOOK}" + if target.split_stage5: + build_env["MOTA_BRIDGE_SPLIT_STAGE5"] = target.split_stage5 + if target.mymesh_opt: + build_env["MOTA_BRIDGE_MY_MESH_OPT"] = target.mymesh_opt + build_env["PLATFORMIO_BUILD_FLAGS"] = " ".join(flags) + run( + ["pio", "run", "-e", ENV_NAME, "-t", "clean"], + f"clean {target.version}", + source, + ) + output = run( + [ + "bash", "build.sh", "build-firmware", ENV_NAME, + "--profile", "cascade", "--firmware-version", firmware_version, + ], + f"build {target.version}", + source, + env=build_env, + ) + (log_dir / f"build-{index:02d}-v{target.version}.log").write_text( + output, encoding="utf-8" + ) + for required in (*flags, "-DMOTA_TARGET_ID=0x2fa509c1", "SUCCESS"): + if required not in output: + raise BuildError(f"build {target.version} log lacks {required}") + if target.os_stage and f"selective optimizer stage {target.os_stage}/13" not in output: + raise BuildError(f"build {target.version} did not run the selective optimizer") + stem = f"{ENV_NAME}-ota-{firmware_version}-{target.source_commit[:8]}" + built_zip = source / "out" / f"{stem}.zip" + built_uf2 = source / "out" / f"{stem}.uf2" + if not built_zip.is_file() or not built_uf2.is_file(): + raise BuildError(f"build {target.version} did not emit the expected ZIP and UF2") + output_zip = args.output_dir / built_zip.name + output_uf2 = args.output_dir / built_uf2.name + shutil.copy2(built_zip, output_zip) + shutil.copy2(built_uf2, output_uf2) + record = verify_image(output_zip, target) + record["uf2"] = output_uf2.name + record["zip_sha256"] = sha256_file(output_zip) + record["uf2_sha256"] = sha256_file(output_uf2) + record["build_flags"] = flags + record["bridge_stage"] = target.bridge_stage + record["os_stage"] = target.os_stage + record["split_stage5"] = target.split_stage5 + record["mymesh_opt"] = target.mymesh_opt + record["flood_rule_engine"] = target.flood_rule_engine + records.append(record) + print( + f"[build] {index:02d}/{len(TARGETS)} v{target.version} " + f"image={record['firmware_size']} sha={str(record['firmware_sha256'])[:16]}", + flush=True, + ) + + manifest = { + "environment": ENV_NAME, + "target_id": f"{EXPECTED_TARGET_ID:08X}", + "hardware": EXPECTED_HARDWARE, + "common_patch": COMMON_PATCH.name, + "common_patch_sha256": sha256_file(COMMON_PATCH), + "legacy_mesh_patch": LEGACY_MESH_PATCH.name, + "legacy_mesh_patch_sha256": sha256_file(LEGACY_MESH_PATCH), + "guarded_mesh_patch": GUARDED_MESH_PATCH.name, + "guarded_mesh_patch_sha256": sha256_file(GUARDED_MESH_PATCH), + "version_patch": VERSION_PATCH.name, + "version_patch_sha256": sha256_file(VERSION_PATCH), + "selective_os_hook": SELECTIVE_OS_HOOK.name, + "selective_os_hook_sha256": sha256_file(SELECTIVE_OS_HOOK), + "targets": records, + } + (args.output_dir / "images.json").write_text( + json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="ascii" + ) + succeeded = True + print(f"[manifest] {args.output_dir / 'images.json'}", flush=True) + finally: + if succeeded and not args.keep_worktree: + run( + ["git", "worktree", "remove", "--force", str(source)], + "remove build worktree", + ) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except BuildError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + raise SystemExit(2) diff --git a/tools/lora_ota/rak3401_four_component_endf_backport.patch b/tools/lora_ota/rak3401_four_component_endf_backport.patch new file mode 100644 index 00000000..40d122ed --- /dev/null +++ b/tools/lora_ota/rak3401_four_component_endf_backport.patch @@ -0,0 +1,16 @@ +diff --git a/tools/mota/pio_endf.py b/tools/mota/pio_endf.py +--- a/tools/mota/pio_endf.py ++++ b/tools/mota/pio_endf.py +@@ -117,8 +117,10 @@ def _firmware_ident(): + ver_s = (_cppdef("FIRMWARE_VERSION") or "").replace("\\", "").strip().strip('"').strip("'") + if not ver_s: # not a -D -> read the header MeshCore ships + ver_s = _version_from_headers() +- m = re.search(r"(\d+)\.(\d+)(?:\.(\d+))?", ver_s) +- fw_version = ml.pack_version(f"{m.group(1)}.{m.group(2)}.{m.group(3) or 0}") if m else 0 ++ m = re.search(r"(\d+)\.(\d+)(?:\.(\d+))?(?:\.(\d+))?", ver_s) ++ fw_version = ml.pack_version( ++ f"{m.group(1)}.{m.group(2)}.{m.group(3) or 0}.{m.group(4) or 0}" ++ ) if m else 0 + return ml.FwIdent(fw_version=fw_version, target_id=target_id, hw_id=hw_id) + + diff --git a/tools/lora_ota/rak3401_mota_chain.py b/tools/lora_ota/rak3401_mota_chain.py index 287cb6cc..58330216 100755 --- a/tools/lora_ota/rak3401_mota_chain.py +++ b/tools/lora_ota/rak3401_mota_chain.py @@ -30,14 +30,28 @@ except ImportError: RELEASE_TAG = "rak3401-mota-v1.16.07-c1caa5ad-to-v1.17.01-c96bdd6e" RELEASE_URL = f"https://github.com/mikecarper/MeshCore/releases/tag/{RELEASE_TAG}" -ASSET_NAME = "RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.01-cd824765.zip" +ASSET_NAME = "RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.01-8c5262c6.zip" ASSET_URL = ( f"https://github.com/mikecarper/MeshCore/releases/download/{RELEASE_TAG}/" f"{ASSET_NAME}" ) -ASSET_SHA256 = "eac67a0be12690b7e22c4d1f6a15bfdeb5bd627c4850b246b1be4220e5607b34" -CHECKSUM_LIST_SHA256 = "8097d75c5d11b9e32e3ebd4971068bf743eaa108054ab39eb0049016f89d185d" -BUNDLE_ROOT_NAME = "RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.01-cd824765" +ASSET_SHA256 = "b2781e02460b200a7c37bfae352bad81618716e550d1d042dca8aa29bfc73c29" +CHECKSUM_LIST_SHA256 = "1f7add658ae5771451cd66a0e5c58a5e461983c1b36029ef480093ae5d5f1020" +BUNDLE_ROOT_NAME = "RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.01-8c5262c6" + +# This exact 29-step release completed its full direct physical-board test. It +# is now superseded because its historical targets echo terminal OTA bulk +# packets back into mesh dispatch. Keep it available for offline provenance, +# but require the rebuilt 30-step chain for any new live run. +SUPERSEDED_29_ASSET_SHA256 = ( + "eac67a0be12690b7e22c4d1f6a15bfdeb5bd627c4850b246b1be4220e5607b34" +) +SUPERSEDED_29_CHECKSUM_LIST_SHA256 = ( + "8097d75c5d11b9e32e3ebd4971068bf743eaa108054ab39eb0049016f89d185d" +) +SUPERSEDED_29_ROOT_NAME = ( + "RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.01-cd824765" +) # This 27-step candidate passed steps 1 and 2, then its test was deliberately # stopped so the adaptive primary requester could be backported into every @@ -93,7 +107,7 @@ DEFAULT_TARGET_KEY = ( ) EXPECTED_TARGET_ID = 0x2FA509C1 EXPECTED_HARDWARE = "RAK_3401" -EXPECTED_STEP_COUNT = 29 +EXPECTED_STEP_COUNT = 30 EXPECTED_START_VERSION = "1.16.7.0" EXPECTED_FINAL_VERSION = "1.17.1.0" MIN_MESHCLI_VERSION = (1, 6, 0) @@ -105,8 +119,10 @@ WATCHDOG_STABILITY_WAIT_SECONDS = 90 # steps 1 through 14, but the first v1.17.01 candidate failed at step 15 because # that retained 386ae4a5 bridge still used unchecked CC310 SHA. Keep both # bundles useful for offline diagnosis, but fail closed before a live -# connection. The exact 29-step replacement below passed its complete direct -# physical-board run on 14-Aug-2026. +# connection. A prior exact 29-step replacement passed its complete direct +# physical-board run on 14-Aug-2026. The pinned 30-step release is rebuilt and +# exhaustively verified offline, but still requires an explicit candidate +# acknowledgement until that exact byte sequence completes a physical run. KNOWN_UNSAFE_STEP = 6 KNOWN_UNSAFE_VERSION = "1.16.8.7" KNOWN_UNSAFE_IMAGE_SHA256 = ( @@ -141,10 +157,8 @@ SUPERSEDED_27_FINAL_IMAGE_SHA256 = ( "e1376869da043c05792b3458e505e67e818f58d94d3fc050d22ab68578a2f2e8" ) -# Exact anchors for the physically passed 29-step release. The pinned checksum -# list covers every byte; these anchors also make the live gate fail closed if -# its structural checks are ever reused independently. -PHYSICALLY_PASSED_ANCHORS = ( +# Exact anchors for the superseded, physically passed 29-step release. +SUPERSEDED_29_ANCHORS = ( (1, "8364257a2b3a219905e870fad6fbb2040a96ca4b4bb7201b2867534cc2b45530"), (6, "4909b5cd50ca86e00b1583bf9ca50e0fc69808a4bebbec8b5e368304145e5d43"), (11, "28ba025251b9cf11376e09c2dc91619d9cc216de9f4998a5eeec7438b689d1c4"), @@ -156,6 +170,43 @@ PHYSICALLY_PASSED_ANCHORS = ( (26, "c9a4887774d4ca8d20f3cec8611ba7ce28611dfedd9bbed624e9578f83a2c85c"), (29, "5c8d94bb23e87c23b0374ffb5a46e0c1205d6eebcb9d8bae3be6fda2613f9f79"), ) + +# Exact anchors for every image in the accelerated 30-step release. The outer +# asset and inner checksum-list pins cover every byte as well; the complete +# anchor table keeps structural validation fail-closed if those layers are +# ever reused independently. +PINNED_RELEASE_ANCHORS = ( + (1, "8364257a2b3a219905e870fad6fbb2040a96ca4b4bb7201b2867534cc2b45530"), + (2, "e98d286b493570b25c361521f7287bcd70ee1ab15f74cb7936582736694dd330"), + (3, "25ab4bee0eabc782c507311e32a08382cc74c612c8d8570582e2fb9546c2f34c"), + (4, "149528bec52820f48dda92969a077ec2ba15afce05fbd8c208b9200fcbfa6607"), + (5, "9512c7aaec3fe2958a28b52e152c85f3f581b8eb2513ab18969c822ada5c545b"), + (6, "8a1df70b769db9e87d944d7a48300138151ca0c9b37d71ff320778cefe899652"), + (7, "d2f75a6cdbab4eed1fce6677fbbce933b72f8353c57258c9b0adf1dbcb97da03"), + (8, "067f92aa93e38cdead54d3d3c76327bf1474ef7b9459d8cb53cd0d8a2d3252ed"), + (9, "a4ddc5ca224710f07894044c119e66019976e61e2fc4c4f3a0878bcbe5c6be74"), + (10, "cbb16110f73fbf5835acab7565d1d30305417ad0f9ec5293ca252238024a5cff"), + (11, "75e5c1f6571f7ffe3c441431de97f4f69ed8b732415b694841b85cf7ab359144"), + (12, "99e7eb953585a1ff4075a5e375320544a1127089e1f801a793fc65ec021104b1"), + (13, "9391d954bba77378dd84d04c0aaef344305cab311d71889caedfa48f3c3ea71b"), + (14, "93c5c9498a1099c79f79d1de48621964c559b3d437d308883ecafa03d2300840"), + (15, "523eadb3328d74ad3021e89201fab356559b678a7df17a9c10ab3c4ebbd3b615"), + (16, "c8355b3bfd5327419b06389af5903641a3c4b55bb2084f92e4c5b4cbf34fb09e"), + (17, "2019e3cb1dcb6d065a3f7f3e8c4c1b9a86338dac125a5bb0e69fe5f0cc79f69f"), + (18, "69da55eda227ec22e5777f04a08bf9af89e49abc89fbc8312119daa797ceb393"), + (19, "f20fbcc4a7140e85d81393beebb2df60f343f869905a8cf06ce82c0d52f61275"), + (20, "5c1d6c8a92722fd88bc37a104b1804814d528b1a04663ffd42dece0d79474278"), + (21, "5ee82703357377ff55a39d808749133bca0ef101ab0569e71f9fad54a706d570"), + (22, "036a8614d47e0a532d8092a30958edd0bb28fdb2c23d5b4b773e5e141a054377"), + (23, "22993f7a228caed4a8d625972d61b5307c528ac2013362199c8747d1348fd2ec"), + (24, "c0e3192471cb23d2fb6c2df6e97a0adddb659420edfe3637d864c747cfd660da"), + (25, "a25f3e94b967d88846dfc4664d34ce80ac0108c12cd5c88efe9e4b70a71ec3e5"), + (26, "37b2687e5f53b82b6785ebcae26d4a465b94d02db4875b5784aae64fcdf6d7d4"), + (27, "ade7ac8c7195c3e3287c3fdf5a4bd03e548f40f338488249bae45f9f75665be3"), + (28, "12f4fe3a2bc59c28d11cb241fe669eea228b4db4fb5001f1878585b1320b2fe5"), + (29, "166b8555b354103bd7a467f9d97cd78dd91a4dd00505c6e2c5ae0e06cc1cdf7f"), + (30, "a08b5791419410c760f31c26bb45c77d776eb7bbf68fde656e19bcd616a6227d"), +) KNOWN_UNSAFE_RELEASE_MESSAGE = ( f"live installation of {KNOWN_UNSAFE_RELEASE_TAG} is disabled: a physical RAK3401 " f"test reached step {KNOWN_UNSAFE_STEP} (v{KNOWN_UNSAFE_VERSION}) but " @@ -183,7 +234,19 @@ SUPERSEDED_27_MESSAGE = ( "live installation of the superseded 27-step candidate is disabled: its " "physical test was stopped after step 2 so the adaptive primary requester " "could be moved into every historical bridge. Use --verify-only for that " - "bundle and use the physically passed 29-step release." + "bundle and use the pinned 30-step release." +) +SUPERSEDED_29_MESSAGE = ( + "live installation of the superseded 29-step release is disabled: that " + "exact chain passed its direct physical run, but its historical targets " + "do not consume terminal OTA bulk packets before mesh dispatch. Use " + "--verify-only for provenance and deploy the accelerated 30-step release." +) +CANDIDATE_ACK_MESSAGE = ( + "live installation of the accelerated 30-step release requires " + "--accept-test-candidate: all 30 transitions passed independent offline " + "reconstruction and bootloader simulation, but this exact byte sequence " + "has not yet completed a full physical-chain run" ) @@ -196,14 +259,27 @@ def require_live_release_safe( steps: list[ChainStep], ) -> None: if len(steps) == EXPECTED_STEP_COUNT: - for number, expected_sha256 in PHYSICALLY_PASSED_ANCHORS: + for number, expected_sha256 in PINNED_RELEASE_ANCHORS: if steps[number - 1].target_sha256 != expected_sha256: raise KnownUnsafeReleaseError( - "live installation is disabled: the 29-step release has " + "live installation is disabled: the 30-step release has " f"an unrecognized step-{number} image" ) + if not getattr(args, "accept_test_candidate", False): + raise KnownUnsafeReleaseError(CANDIDATE_ACK_MESSAGE) return + if len(steps) == 29: + if all( + steps[number - 1].target_sha256 == expected_sha256 + for number, expected_sha256 in SUPERSEDED_29_ANCHORS + ): + raise KnownUnsafeReleaseError(SUPERSEDED_29_MESSAGE) + raise KnownUnsafeReleaseError( + "live installation is disabled: this is an unrecognized variant " + "of the superseded 29-step release" + ) + step6_sha256 = steps[KNOWN_UNSAFE_STEP - 1].target_sha256 if step6_sha256 == KNOWN_UNSAFE_IMAGE_SHA256: raise KnownUnsafeReleaseError(KNOWN_UNSAFE_RELEASE_MESSAGE) @@ -326,6 +402,7 @@ def download_release_asset(destination: Path) -> None: def extract_bundle(archive_path: Path, destination: Path) -> Path: root_names = ( BUNDLE_ROOT_NAME, + SUPERSEDED_29_ROOT_NAME, KNOWN_FAILED_V11701_ROOT_NAME, KNOWN_UNSAFE_ROOT_NAME, ) @@ -378,6 +455,7 @@ def locate_bundle(args: argparse.Namespace, work_dir: Path) -> Path: nested_roots = [ supplied / name for name in ( BUNDLE_ROOT_NAME, + SUPERSEDED_29_ROOT_NAME, KNOWN_FAILED_V11701_ROOT_NAME, KNOWN_UNSAFE_ROOT_NAME, ) @@ -393,6 +471,7 @@ def locate_bundle(args: argparse.Namespace, work_dir: Path) -> Path: actual = sha256_file(supplied) if actual not in { ASSET_SHA256, + SUPERSEDED_29_ASSET_SHA256, SUPERSEDED_27_ASSET_SHA256, KNOWN_FAILED_V11701_ASSET_SHA256, KNOWN_FAILED_V11701_STEP16_ASSET_SHA256, @@ -411,6 +490,7 @@ def verify_checksum_list(bundle_root: Path) -> None: checksum_digest = sha256_file(checksum_path) expected_lists = { CHECKSUM_LIST_SHA256, + SUPERSEDED_29_CHECKSUM_LIST_SHA256, SUPERSEDED_27_CHECKSUM_LIST_SHA256, KNOWN_FAILED_V11701_CHECKSUM_LIST_SHA256, KNOWN_FAILED_V11701_STEP16_CHECKSUM_LIST_SHA256, @@ -467,9 +547,9 @@ def parse_chain(bundle_root: Path) -> tuple[list[ChainStep], bytes]: rows = list(csv.DictReader(source)) except OSError as exc: raise ota.OtaError(f"cannot read {chain_path}: {exc}") from exc - if len(rows) not in (26, 27, EXPECTED_STEP_COUNT): + if len(rows) not in (26, 27, 29, EXPECTED_STEP_COUNT): raise ota.OtaError( - f"chain contains {len(rows)} steps, expected 26, 27, or " + f"chain contains {len(rows)} steps, expected 26, 27, 29, or " f"{EXPECTED_STEP_COUNT}" ) @@ -521,11 +601,17 @@ def parse_chain(bundle_root: Path) -> tuple[list[ChainStep], bytes]: if steps[0].from_version != EXPECTED_START_VERSION: raise ota.OtaError("chain has an unexpected starting version") if len(steps) == EXPECTED_STEP_COUNT: - for number, expected_sha256 in PHYSICALLY_PASSED_ANCHORS: + for number, expected_sha256 in PINNED_RELEASE_ANCHORS: if steps[number - 1].target_sha256 != expected_sha256: raise ota.OtaError( - f"physically passed step {number} does not match its audited " - "image pin" + f"pinned release step {number} does not match its audited image pin" + ) + expected_final_version = EXPECTED_FINAL_VERSION + elif len(steps) == 29: + for number, expected_sha256 in SUPERSEDED_29_ANCHORS: + if steps[number - 1].target_sha256 != expected_sha256: + raise ota.OtaError( + f"superseded step {number} does not match its audited image pin" ) expected_final_version = EXPECTED_FINAL_VERSION else: @@ -969,8 +1055,8 @@ def run_step( def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description=( - "Download, verify, resume, and install the exact 29-step RAK3401 " - "mOTA chain from c1caa5ad to cd824765." + "Download, verify, resume, and install the exact accelerated " + "30-step RAK3401 mOTA chain from c1caa5ad to 8c5262c6." ) ) parser.add_argument( @@ -989,8 +1075,9 @@ def build_parser() -> argparse.ArgumentParser: "--accept-test-candidate", action="store_true", help=( - "deprecated compatibility flag; the exact physically passed 29-step " - "release no longer requires it, and it never overrides a failed bundle" + "acknowledge that the exact accelerated 30-step byte sequence is " + "offline-validated but awaits its complete physical-chain run; this " + "never overrides a failed or superseded bundle" ), ) @@ -1166,7 +1253,9 @@ def main(argv: list[str] | None = None) -> int: verify_motatool(args, steps) if args.verify_only: print(f"Verified release bundle: {bundle_root}") - if steps[KNOWN_UNSAFE_STEP - 1].target_sha256 == KNOWN_UNSAFE_IMAGE_SHA256: + if len(steps) == 29: + print(f"WARNING: {SUPERSEDED_29_MESSAGE}", file=sys.stderr) + elif steps[KNOWN_UNSAFE_STEP - 1].target_sha256 == KNOWN_UNSAFE_IMAGE_SHA256: print(f"WARNING: {KNOWN_UNSAFE_RELEASE_MESSAGE}", file=sys.stderr) elif ( steps[KNOWN_FAILED_V11701_STEP - 1].target_sha256 @@ -1188,8 +1277,9 @@ def main(argv: list[str] | None = None) -> int: print(f"WARNING: {SUPERSEDED_27_MESSAGE}", file=sys.stderr) else: print( - "Physically passed release verified offline: all 29 direct " - "BW500/SF5 transitions passed on a RAK3401." + "Accelerated release verified offline: all 30 transitions " + "passed zero-filled and erased-workspace reconstruction, " + "independent motatool verification, and bootloader simulation." ) return 0 diff --git a/tools/lora_ota/rak3401_selective_os.py b/tools/lora_ota/rak3401_selective_os.py new file mode 100755 index 00000000..2f484f71 --- /dev/null +++ b/tools/lora_ota/rak3401_selective_os.py @@ -0,0 +1,180 @@ +"""PlatformIO pre-hook for cumulative, per-translation-unit ``-Os`` bridges. + +Stage 1 size-optimizes crypto/Curve25519 units first. The remaining C/C++ +units are assigned deterministically to stages 2 through 13. A stage includes +all earlier stages, so every intermediate is a normal bootable build and stage +13 is equivalent to globally selecting ``-Os``. The last two primary buckets +are subdivided because their measured nRF52 deltas exceeded internal staging. +""" + +import hashlib +import os +import re + +Import("env") + + +try: + STAGE = int(os.environ.get("MOTA_BRIDGE_OS_STAGE", "0")) +except ValueError as exc: + raise RuntimeError("MOTA_BRIDGE_OS_STAGE must be an integer") from exc + +if STAGE < 1 or STAGE > 13: + raise RuntimeError("MOTA_BRIDGE_OS_STAGE must be in 1..13") + +CRYPTO_MARKERS = ( + "ed25519", + "curve25519", + "nrf52crypto", + "adafruit_nrfcrypto", +) + + +def _normalized_unit_path(path, construction_env): + absolute = os.path.abspath(path) + project = os.path.abspath(construction_env.subst("$PROJECT_DIR")) + relative = os.path.relpath(absolute, project) + if relative != ".." and not relative.startswith(".." + os.sep): + return relative.replace("\\", "/").lower() + normalized = absolute.replace("\\", "/").lower() + marker = "/.platformio/" + if marker in normalized: + return ".platformio/" + normalized.split(marker, 1)[1] + return "/".join(normalized.rsplit("/", 4)[-4:]) + + +def _unit_stage(path, construction_env): + normalized = _normalized_unit_path(path, construction_env) + if any(marker in normalized for marker in CRYPTO_MARKERS): + return 1 + digest = hashlib.sha256(normalized.encode("utf-8")).digest() + primary = digest[0] % 7 + if primary <= 4: + return 2 + primary + if primary == 5: + secondary = digest[1] % 3 + if secondary == 0: + return 7 + if secondary == 1: + if digest[2] % 2 == 0: + return 8 + digest[3] % 2 + return 10 + return 11 + return 12 + digest[1] % 2 + + +def _size_optimize_selected(env, node): + path = node.get_abspath() + if not re.search(r"\.(?:c|cc|cpp|cxx)$", path, re.IGNORECASE): + return node + normalized_path = _normalized_unit_path(path, env) + forced_opt = os.environ.get("MOTA_BRIDGE_MY_MESH_OPT", "") + if normalized_path.endswith("/examples/simple_repeater/mymesh.cpp") and forced_opt: + profiles = { + "-O3": ["-O3"], + "-O2": ["-O2"], + "-O1": ["-O1"], + "-Os": ["-Os"], + "O2_NO_ALIGN": [ + "-O2", + "-fno-align-functions", + "-fno-align-jumps", + "-fno-align-labels", + "-fno-align-loops", + ], + "O2_SIZE_1": [ + "-O2", "-fno-align-functions", "-fno-align-jumps", + "-fno-align-labels", "-fno-align-loops", + "-fno-optimize-strlen", + ], + "O2_SIZE_1_HOIST": [ + "-O2", "-fno-align-functions", "-fno-align-jumps", + "-fno-align-labels", "-fno-align-loops", + "-fno-optimize-strlen", "-fcode-hoisting", + ], + "O2_SIZE_1_INLINE": [ + "-O2", "-fno-align-functions", "-fno-align-jumps", + "-fno-align-labels", "-fno-align-loops", + "-fno-optimize-strlen", "-finline-functions", + ], + "O2_SIZE_1_INLINE_HOIST": [ + "-O2", "-fno-align-functions", "-fno-align-jumps", + "-fno-align-labels", "-fno-align-loops", + "-fno-optimize-strlen", "-finline-functions", + "-fcode-hoisting", + ], + "O2_SIZE_1_NOSCHED": [ + "-O2", "-fno-align-functions", "-fno-align-jumps", + "-fno-align-labels", "-fno-align-loops", + "-fno-optimize-strlen", "-fno-schedule-insns", + ], + "O2_SIZE_2": [ + "-O2", "-fno-align-functions", "-fno-align-jumps", + "-fno-align-labels", "-fno-align-loops", + "-fno-optimize-strlen", "-freorder-blocks-algorithm=simple", + ], + "O2_SIZE_3": [ + "-O2", "-fno-align-functions", "-fno-align-jumps", + "-fno-align-labels", "-fno-align-loops", + "-fno-optimize-strlen", "-freorder-blocks-algorithm=simple", + "-fno-schedule-insns", + ], + "O2_SIZE_4": [ + "-O2", "-fno-align-functions", "-fno-align-jumps", + "-fno-align-labels", "-fno-align-loops", + "-fno-optimize-strlen", "-freorder-blocks-algorithm=simple", + "-fno-schedule-insns", "-fno-tree-loop-vectorize", + ], + "O2_SIZE_FLAGS": [ + "-O2", + "-fno-align-functions", + "-fno-align-jumps", + "-fno-align-labels", + "-fno-align-loops", + "-fno-optimize-strlen", + "-freorder-blocks-algorithm=simple", + "-fno-schedule-insns", + "-fno-tree-loop-vectorize", + "-fno-tree-slp-vectorize", + ], + } + if forced_opt not in profiles: + raise RuntimeError("unsupported MOTA_BRIDGE_MY_MESH_OPT profile") + flags = [ + str(flag) + for flag in env.get("CCFLAGS", []) + if not re.fullmatch(r"-O(?:0|1|2|3|g|s|fast)", str(flag)) + ] + flags.extend(profiles[forced_opt]) + print(f"MOTA bridge MyMesh optimizer: {forced_opt}") + return env.Object(node, CCFLAGS=flags) + + unit_stage = _unit_stage(path, env) + if unit_stage > STAGE: + return node + if ( + STAGE == 5 + and unit_stage == 5 + and os.environ.get("MOTA_BRIDGE_SPLIT_STAGE5") == "first" + ): + normalized = _normalized_unit_path(path, env) + digest = hashlib.sha256(normalized.encode("utf-8")).digest() + if digest[1] % 2: + return node + + if os.environ.get("MOTA_BRIDGE_OS_VERBOSE") == "1" and unit_stage == STAGE: + print(f"MOTA bridge stage {STAGE} unit: {normalized_path}") + + flags = [ + str(flag) + for flag in env.get("CCFLAGS", []) + if not re.fullmatch(r"-O(?:0|1|2|3|g|s|fast)", str(flag)) + ] + flags.append("-Os") + return env.Object(node, CCFLAGS=flags) + + +print(f"MOTA bridge selective optimizer stage {STAGE}/13") +if STAGE == 5 and os.environ.get("MOTA_BRIDGE_SPLIT_STAGE5") == "first": + print("MOTA bridge stage 5 split: first half") +env.AddBuildMiddleware(_size_optimize_selected) diff --git a/tools/lora_ota/rak3401_terminal_ota_backport.patch b/tools/lora_ota/rak3401_terminal_ota_backport.patch new file mode 100644 index 00000000..dad734f2 --- /dev/null +++ b/tools/lora_ota/rak3401_terminal_ota_backport.patch @@ -0,0 +1,52 @@ +diff --git a/src/helpers/ota/OtaManager.cpp b/src/helpers/ota/OtaManager.cpp +--- a/src/helpers/ota/OtaManager.cpp ++++ b/src/helpers/ota/OtaManager.cpp +@@ -913,6 +913,35 @@ void OtaManager::loop() { + + // ---------------- dispatch ---------------- + ++bool OtaManager::terminallyConsumes(const uint8_t* msg, uint16_t len) { ++ switch (ota_msg_type(msg, len)) { ++ case OTA_REQ: { ++ ReqMsg rq; ++ if (!decode_req(msg, len, rq)) return false; ++ ServeView* v = resolve(rq.manifest_id); ++ return v && rq.block_idx < v->m.block_count; ++ } ++ case OTA_REQ_PROOF: { ++ ReqProofMsg rp; ++ if (!decode_req_proof(msg, len, rp)) return false; ++ ServeView* v = resolve(rp.manifest_id); ++ return v && rp.block_idx < v->m.block_count; ++ } ++ case OTA_DATA: { ++ DataMsg dm; ++ return decode_data(msg, len, dm) && _fetch && _fstate != IDLE ++ && dm.block_idx < _fbc && memcmp(dm.manifest_id, _fid, 4) == 0; ++ } ++ case OTA_PROOF: { ++ ProofMsg pm; ++ return decode_proof(msg, len, pm) && _fetch && _fstate != IDLE ++ && pm.block_idx < _fbc && memcmp(pm.manifest_id, _fid, 4) == 0; ++ } ++ default: ++ return false; ++ } ++} ++ + void OtaManager::on_message(const uint8_t* msg, uint16_t len) { + switch (ota_msg_type(msg, len)) { + case OTA_ADV: handleAdv(msg, len); break; +diff --git a/src/helpers/ota/OtaManager.h b/src/helpers/ota/OtaManager.h +--- a/src/helpers/ota/OtaManager.h ++++ b/src/helpers/ota/OtaManager.h +@@ -287,6 +287,9 @@ public: + // This node's id (pubkey[0:4]), stamped into adverts we send so receivers can count distinct seeders. + void set_seeder_id(const uint8_t* id4) { if (id4) for (int i = 0; i < 4; i++) _seeder_id[i] = id4[i]; } + ++ // True when this node is the source or destination for a bulk message. The ++ // mesh adapter uses this to avoid re-flooding traffic already consumed here. ++ bool terminallyConsumes(const uint8_t* msg, uint16_t len); + void on_message(const uint8_t* msg, uint16_t len); // feed one received OTA message + void loop(); // drive fetch (re-request missing blocks) + diff --git a/tools/lora_ota/rak3401_terminal_ota_mesh_guarded.patch b/tools/lora_ota/rak3401_terminal_ota_mesh_guarded.patch new file mode 100644 index 00000000..b15cc4f2 --- /dev/null +++ b/tools/lora_ota/rak3401_terminal_ota_mesh_guarded.patch @@ -0,0 +1,18 @@ +diff --git a/src/Mesh.cpp b/src/Mesh.cpp +--- a/src/Mesh.cpp ++++ b/src/Mesh.cpp +@@ -899,8 +899,10 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { + // ONLY to avoid re-flooding the same packet more than once. + bool seen = _tables->wasSeen(pkt); + if (!seen) _tables->markSeen(pkt); ++ bool terminal_ota = false; + #if defined(ENABLE_OTA) + ota::ota_ctx().manager.set_clock(_ms->getMillis()); // discovery jitter/ages ++ terminal_ota = ota::ota_ctx().manager.terminallyConsumes(pkt->payload, pkt->payload_len); + ota::ota_ctx().manager.on_message(pkt->payload, pkt->payload_len); // central OTA receive (beacon/query/ + // have/manifest/data/proof; all roles) + ota::ota_ctx().track_session(ota::ota_ctx().manager.fetchState(), _ms->getMillis()); +@@ -913,2 +915,2 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { +- if (!seen && pkt->isRouteFlood() && !pkt->isMarkedDoNotRetransmit() ++ if (!terminal_ota && !seen && pkt->isRouteFlood() && !pkt->isMarkedDoNotRetransmit() + #if defined(ENABLE_OTA) diff --git a/tools/lora_ota/rak3401_terminal_ota_mesh_legacy.patch b/tools/lora_ota/rak3401_terminal_ota_mesh_legacy.patch new file mode 100644 index 00000000..b61762ef --- /dev/null +++ b/tools/lora_ota/rak3401_terminal_ota_mesh_legacy.patch @@ -0,0 +1,20 @@ +diff --git a/src/Mesh.cpp b/src/Mesh.cpp +--- a/src/Mesh.cpp ++++ b/src/Mesh.cpp +@@ -892,6 +892,7 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { + bool seen = _tables->wasSeen(pkt); + if (!seen) _tables->markSeen(pkt); + ota::ota_ctx().manager.set_clock(_ms->getMillis()); // discovery jitter/ages ++ bool terminal_ota = ota::ota_ctx().manager.terminallyConsumes(pkt->payload, pkt->payload_len); + ota::ota_ctx().manager.on_message(pkt->payload, pkt->payload_len); // central OTA receive (beacon/query/ + // have/manifest/data/proof; all roles) + ota::ota_ctx().track_session(ota::ota_ctx().manager.fetchState(), _ms->getMillis()); +@@ -899,7 +900,7 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { + // Re-flood at the LOWEST priority and only while still under the hop limit, so OTA never competes with + // mesh traffic. The free-pool guard keeps heavy OTA from monopolising the shared packet pool - dropping + // a relay is safe (OTA is best-effort; the source retries). +- if (!seen && pkt->isRouteFlood() && !pkt->isMarkedDoNotRetransmit() ++ if (!terminal_ota && !seen && pkt->isRouteFlood() && !pkt->isMarkedDoNotRetransmit() + && n < getOtaHopLimit() + && (n + 1) * pkt->getPathHashSize() <= MAX_PATH_SIZE + && _mgr->getFreeCount() > OTA_FWD_MIN_FREE diff --git a/tools/lora_ota/rebuild_rak3401_bundle.py b/tools/lora_ota/rebuild_rak3401_bundle.py new file mode 100755 index 00000000..fac27ba7 --- /dev/null +++ b/tools/lora_ota/rebuild_rak3401_bundle.py @@ -0,0 +1,640 @@ +#!/usr/bin/env python3 +"""Freshly rebuild the audited RAK3401 chain with a new final endpoint. + +The historical bridge images are reconstructed from the pinned, physically +passed chain and checked against CHAIN.csv. Every output .mota is then built +again from its base/target image pair and independently applied with both an +erased (0xff) and zero-filled detools workspace. +""" + +from __future__ import annotations + +import argparse +import csv +from datetime import datetime, timezone +import hashlib +import json +from pathlib import Path, PurePosixPath +import re +import shutil +import stat +import subprocess +import sys +import zipfile + + +SCRIPT_DIR = Path(__file__).resolve().parent +REPO_ROOT = SCRIPT_DIR.parent.parent +sys.path.insert(0, str(REPO_ROOT / "tools" / "mota")) + +import motalib # noqa: E402 + + +PREVIOUS_BUNDLE_SHA256 = ( + "eac67a0be12690b7e22c4d1f6a15bfdeb5bd627c4850b246b1be4220e5607b34" +) +EXPECTED_TARGET_ID = 0x2FA509C1 +EXPECTED_HARDWARE = "RAK_3401" +EXPECTED_INPUT_STEPS = 29 +EXPECTED_STEPS = 30 +EXPECTED_ACCELERATED_VERSIONS = ( + "1.16.7.10", "1.16.7.11", "1.16.7.12", "1.16.7.13", "1.16.8.0", + "1.16.8.7", "1.16.8.8", "1.16.8.9", "1.16.9.0", "1.16.9.102", + "1.16.9.104", "1.16.9.105", "1.16.9.108", "1.16.9.109", "1.16.9.110", + "1.16.9.111", "1.16.9.112", "1.16.9.113", "1.16.9.114", "1.16.9.115", + "1.16.9.116", "1.16.9.117", "1.16.9.118", "1.16.9.119", "1.16.9.120", + "1.16.9.121", "1.16.9.122", "1.16.10.0", +) +STAGING_LIMIT = 90112 +WORKSPACE_SIZE = 0x98000 +SEGMENT_SIZE = 4096 +MAX_ZIP_BYTES = 64 * 1024 * 1024 + + +class RebuildError(RuntimeError): + pass + + +def sha256_bytes(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def safe_relative(value: str, label: str) -> PurePosixPath: + path = PurePosixPath(value) + if ( + not value + or path.is_absolute() + or any(part in ("", ".", "..") for part in path.parts) + or "\\" in value + ): + raise RebuildError(f"unsafe {label}: {value!r}") + return path + + +def extract_zip(archive_path: Path, destination: Path) -> Path: + if destination.exists(): + raise RebuildError(f"extraction destination already exists: {destination}") + destination.mkdir(parents=True) + total = 0 + roots: set[str] = set() + with zipfile.ZipFile(archive_path) as archive: + for member in archive.infolist(): + relative = safe_relative(member.filename.rstrip("/"), "ZIP member") + roots.add(relative.parts[0]) + total += member.file_size + if total > MAX_ZIP_BYTES: + raise RebuildError("input bundle expands beyond 64 MiB") + mode = (member.external_attr >> 16) & 0o170000 + if stat.S_ISLNK(mode): + raise RebuildError(f"input bundle contains a symlink: {member.filename}") + if len(roots) != 1: + raise RebuildError(f"input bundle must contain one root directory: {sorted(roots)}") + archive.extractall(destination) + root = destination / next(iter(roots)) + if not (root / "CHAIN.csv").is_file(): + raise RebuildError("input bundle root does not contain CHAIN.csv") + return root + + +def read_firmware_zip(path: Path) -> bytes: + with zipfile.ZipFile(path) as archive: + members = [member for member in archive.infolist() if member.filename == "firmware.bin"] + if len(members) != 1: + raise RebuildError(f"{path} must contain one root firmware.bin") + member = members[0] + if member.file_size > WORKSPACE_SIZE: + raise RebuildError(f"firmware.bin is too large in {path}") + return archive.read(member) + + +def run(command: list[str], label: str, timeout: int = 900) -> str: + try: + result = subprocess.run( + command, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + timeout=timeout, + check=False, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise RebuildError(f"{label} failed: {exc}") from exc + if result.returncode != 0: + raise RebuildError( + f"{label} exited {result.returncode}:\n{result.stdout[-4000:]}" + ) + return result.stdout + + +def apply_in_place( + detools: Path, + base: bytes, + patch: bytes, + image_size: int, + work: Path, + label: str, + fill: int, +) -> bytes: + if len(base) > WORKSPACE_SIZE or image_size > WORKSPACE_SIZE: + raise RebuildError(f"{label} exceeds the 0x{WORKSPACE_SIZE:x} workspace") + memory_path = work / f"{label}-{fill:02x}.memory" + patch_path = work / f"{label}.patch" + memory = bytearray([fill]) * WORKSPACE_SIZE + memory[:len(base)] = base + memory_path.write_bytes(memory) + if not patch_path.exists(): + patch_path.write_bytes(patch) + run( + [str(detools), "apply_patch_in_place", str(memory_path), str(patch_path)], + f"apply {label} with fill 0x{fill:02x}", + ) + rebuilt = memory_path.read_bytes()[:image_size] + memory_path.unlink() + return rebuilt + + +def read_chain(path: Path) -> list[dict[str, str]]: + with path.open(newline="", encoding="ascii") as source: + rows = list(csv.DictReader(source)) + if len(rows) != EXPECTED_INPUT_STEPS: + raise RebuildError( + f"input chain has {len(rows)} steps, expected {EXPECTED_INPUT_STEPS}" + ) + return rows + + +def firmware_identity(image: bytes) -> tuple[str, str, int, str]: + if not motalib.has_endf(image): + raise RebuildError("firmware image has no valid EndF") + ident = motalib.parse_endf_ident(image) + assert ident is not None + if ident.target_id != EXPECTED_TARGET_ID or ident.hw_id != EXPECTED_HARDWARE: + raise RebuildError( + f"firmware identity is target={ident.target_id:08X} hw={ident.hw_id!r}" + ) + body, body_hash = motalib.parse_endf(image) + return motalib.unpack_version(ident.fw_version), body_hash.hex(), len(body), ident.hw_id + + +def reconstruct_historical_images( + old_root: Path, + rows: list[dict[str, str]], + detools: Path, + work: Path, +) -> list[bytes]: + start_zip = old_root / "recovery" / "test-start" / "RAK3401-test-start-v1.16.7-c1caa5ad.zip" + if not start_zip.is_file(): + raise RebuildError(f"missing test-start recovery ZIP: {start_zip}") + images = [read_firmware_zip(start_zip)] + if sha256_bytes(images[0]) == rows[0]["target_sha256"].lower(): + raise RebuildError("test-start image unexpectedly equals step 1") + firmware_identity(images[0]) + + reconstruction = work / "reconstruction" + reconstruction.mkdir() + for index, row in enumerate(rows[:28], 1): + package_path = old_root.joinpath(*safe_relative(row["mota_file"], "mOTA path").parts) + parsed = motalib.parse_container(package_path.read_bytes()) + expected_base = bytes.fromhex(row["base_body_hash"]) + _body, actual_base = motalib.parse_endf(images[-1]) + if actual_base != expected_base or parsed.manifest.base_hash != expected_base: + raise RebuildError(f"step {index} base hash mismatch") + expected_size = int(row["target_image_size"]) + zero = apply_in_place( + detools, images[-1], parsed.payload, expected_size, + reconstruction, f"old-step-{index:02d}", 0x00, + ) + erased = apply_in_place( + detools, images[-1], parsed.payload, expected_size, + reconstruction, f"old-step-{index:02d}", 0xFF, + ) + expected_sha = row["target_sha256"].lower() + if zero != erased or sha256_bytes(zero) != expected_sha: + raise RebuildError(f"step {index} historical image reconstruction mismatch") + firmware_identity(zero) + images.append(zero) + print(f"[reconstruct] {index:02d}/28 {expected_sha[:16]}", flush=True) + return images + + +def read_accelerated_images(manifest_path: Path) -> tuple[list[bytes], list[str]]: + try: + manifest = json.loads(manifest_path.read_text(encoding="ascii")) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise RebuildError(f"cannot read accelerated image manifest: {exc}") from exc + records = manifest.get("targets") + if not isinstance(records, list) or len(records) != len(EXPECTED_ACCELERATED_VERSIONS): + raise RebuildError( + f"accelerated image manifest must contain {len(EXPECTED_ACCELERATED_VERSIONS)} targets" + ) + images: list[bytes] = [] + sources: list[str] = [] + for index, (record, expected_version) in enumerate( + zip(records, EXPECTED_ACCELERATED_VERSIONS), 1 + ): + if not isinstance(record, dict) or record.get("version") != expected_version: + raise RebuildError( + f"accelerated image {index} is not expected version {expected_version}" + ) + filename = record.get("zip") + if not isinstance(filename, str): + raise RebuildError(f"accelerated image {index} has no ZIP filename") + relative = safe_relative(filename, f"accelerated image {index} ZIP") + if len(relative.parts) != 1: + raise RebuildError(f"accelerated image {index} ZIP must be a basename") + path = manifest_path.parent / relative.name + if not path.is_file(): + raise RebuildError(f"accelerated image {index} ZIP does not exist: {path}") + expected_zip_sha = record.get("zip_sha256") + if expected_zip_sha != sha256_file(path): + raise RebuildError(f"accelerated image {index} ZIP checksum mismatch") + image = read_firmware_zip(path) + version, body_hash, body_size, _hardware = firmware_identity(image) + if version != expected_version: + raise RebuildError( + f"accelerated image {index} identity is {version}, expected {expected_version}" + ) + if record.get("firmware_sha256") != sha256_bytes(image): + raise RebuildError(f"accelerated image {index} firmware checksum mismatch") + if record.get("firmware_size") != len(image): + raise RebuildError(f"accelerated image {index} firmware size mismatch") + if record.get("body_hash") != body_hash.lower() or record.get("body_size") != body_size: + raise RebuildError(f"accelerated image {index} EndF body metadata mismatch") + source_commit = record.get("source_commit") + if not isinstance(source_commit, str) or not re.fullmatch(r"[0-9a-f]{40}", source_commit): + raise RebuildError(f"accelerated image {index} source commit is invalid") + images.append(image) + sources.append(source_commit) + print( + f"[accelerated] {index:02d}/{len(EXPECTED_ACCELERATED_VERSIONS)} " + f"v{version} {sha256_bytes(image)[:16]}", + flush=True, + ) + return images, sources + + +def write_chain(path: Path, rows: list[dict[str, object]]) -> None: + fields = [ + "step", "from_version", "to_version", "mota_file", "mota_size", + "staging_margin", "target_image_size", "base_body_hash", + "target_body_hash", "target_sha256", "source_commit", + ] + with path.open("w", newline="", encoding="ascii") as output: + writer = csv.DictWriter(output, fieldnames=fields, lineterminator="\n") + writer.writeheader() + writer.writerows(rows) + + +def write_bundle_docs( + root: Path, + asset_name: str, + source_commit: str, + endpoint: bytes, + old_asset_sha: str, + bridge_manifest_sha: str, + max_package: int, + min_margin: int, +) -> None: + endpoint_sha = sha256_bytes(endpoint) + endpoint_version, endpoint_body, _body_size, _hw = firmware_identity(endpoint) + (root / "README.md").write_text( + f"""# RAK3401 mOTA update chain: v1.16.7 to v1.17.01 + +This archive contains 30 mandatory, freshly generated 1 KiB-block mOTA +containers for the exact `RAK_3401` target `0x{EXPECTED_TARGET_ID:08X}`. + +Status: all 30 transitions passed independent offline reconstruction, +container verification, and stable bootloader simulation. The superseded +29-step baseline passed physically, but this exact 30-step byte sequence still +needs its complete physical-chain qualification. The pinned runner therefore +requires an explicit `--accept-test-candidate` for live use. + +- Start: `1.16.7.0-c1caa5ad` +- Endpoint: `{endpoint_version}` from `{source_commit}` +- Endpoint image SHA-256: `{endpoint_sha}` +- Endpoint EndF body hash: `{endpoint_body.upper()}` +- Largest package: {max_package:,} bytes +- Smallest staging margin: {min_margin:,} bytes + +Step 1 installs the byte-identical first bridge from the physically passed +cd824765 chain. Step 2 is the earliest safe transition that installs terminal +bulk-packet consumption; a direct patched step 1 exceeded the fixed 90,112-byte +staging limit. Steps 2-29 were rebuilt from pinned historical source commits +with the terminal-consumption backport and the original staged compiler +profiles. Step 30 installs the current endpoint. + +No mOTA file was copied. Every delta was generated in this run, then applied +independently with zero-filled and erased (`0xFF`) workspaces and verified +against its target image. The previous physical record is retained as +`PHYSICAL-BASELINE-cd824765.jsonl` and is explicitly a baseline, not a claim +that this new byte sequence was part of that older run. + +Use the pinned runner in `tools/lora_ota/rak3401_mota_chain.py`; never install +files out of order. See `docs/rak3401_mota_chain.md` for direct and two-relay +commands, recovery, watchdog handling, and the guarded no-EndF rescue command. + +Original audited bundle SHA-256 used as reconstruction input: +`{old_asset_sha}`. + +Accelerated bridge-image manifest SHA-256: `{bridge_manifest_sha}`. +""", + encoding="utf-8", + ) + (root / "PROVENANCE.md").write_text( + f"""# Build and validation provenance + +The endpoint was built from MeshCore commit `{source_commit}` with embedded +version `{endpoint_version}`, target `0x{EXPECTED_TARGET_ID:08X}`, hardware +`{EXPECTED_HARDWARE}`, 1 KiB logical mOTA blocks, and a `0x{WORKSPACE_SIZE:X}` +in-place workspace. + +All 30 mOTA containers were generated in this run by Rust `motatool`; none was +copied from the input archive. The complete historical chain was reconstructed +from the pinned physically passed bundle and checked against its CHAIN.csv +SHA-256 anchors before its exact first bridge was selected. Accelerated images +2-29 were rebuilt with pinned feature/optimizer stages and terminal packet +consumption. Every new package passed `motatool verify` and two independent +detools in-place applications (workspace fills `0x00` and `0xFF`). + +Input bundle SHA-256: `{old_asset_sha}` +Bridge-image manifest SHA-256: `{bridge_manifest_sha}` +Output asset name: `{asset_name}` +""", + encoding="utf-8", + ) + (root / "RUNBOOK.md").write_text( + """# Runbook + +Verify the outer release checksum, then use the repository runner with this +ZIP and a persistent work directory. The runner enforces exact target, start +body hash, package order, watchdog state, post-boot identity, and normal-radio +restoration. + +Direct bench: `--temp-radio 909.950,500,5,5,120 --ota-hops 1`. + +Live private network with two intermediate relays: use +`--temp-radio 909.950,250,5,5,120 --ota-hops 3`, plus two `--relay` arguments +listed farthest-to-nearest. Both live modes require +`--accept-test-candidate`. Run `--preflight-only` before `--yes`. + +See `docs/rak3401_mota_chain.md` in the matching MeshCore repository commit +for complete commands and the guarded `ota rescue install ` flow. +""", + encoding="utf-8", + ) + + +def write_checksums(root: Path) -> str: + checksum_path = root / "SHA256SUMS.txt" + lines = [] + for path in sorted(item for item in root.rglob("*") if item.is_file() and item != checksum_path): + relative = path.relative_to(root).as_posix() + lines.append(f"{sha256_file(path)} {relative}\n") + checksum_path.write_text("".join(lines), encoding="ascii") + return sha256_file(checksum_path) + + +def make_zip(root: Path, output: Path) -> None: + output.parent.mkdir(parents=True, exist_ok=True) + if output.exists(): + raise RebuildError(f"output already exists: {output}") + with zipfile.ZipFile(output, "x", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive: + for path in sorted(item for item in root.rglob("*") if item.is_file()): + archive.write(path, (Path(root.name) / path.relative_to(root)).as_posix()) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--previous-bundle", type=Path, required=True) + parser.add_argument("--endpoint-zip", type=Path, required=True) + parser.add_argument("--endpoint-uf2", type=Path, required=True) + parser.add_argument("--accelerated-images", type=Path, required=True) + parser.add_argument("--work-dir", type=Path, required=True) + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--motatool", type=Path, required=True) + parser.add_argument("--detools", type=Path, required=True) + parser.add_argument("--apply-sim", type=Path) + parser.add_argument("--source-commit", required=True) + args = parser.parse_args() + + for path, label in ( + (args.previous_bundle, "previous bundle"), + (args.endpoint_zip, "endpoint ZIP"), + (args.endpoint_uf2, "endpoint UF2"), + (args.accelerated_images, "accelerated image manifest"), + (args.motatool, "motatool"), + (args.detools, "detools"), + ): + if not path.is_file(): + raise RebuildError(f"{label} does not exist: {path}") + if args.apply_sim is not None and not args.apply_sim.is_file(): + raise RebuildError(f"apply simulator does not exist: {args.apply_sim}") + if not re.fullmatch(r"[0-9a-f]{40}", args.source_commit): + raise RebuildError("source commit must be a full lowercase 40-hex commit") + resolved_commit = run( + [ + "git", "-C", str(REPO_ROOT), "rev-parse", "--verify", + f"{args.source_commit}^{{commit}}", + ], + "resolve endpoint source commit", + ).strip() + if resolved_commit != args.source_commit: + raise RebuildError( + f"endpoint source resolves to {resolved_commit}, expected {args.source_commit}" + ) + if args.work_dir.exists(): + raise RebuildError(f"work directory already exists: {args.work_dir}") + args.work_dir.mkdir(parents=True) + + old_asset_sha = sha256_file(args.previous_bundle) + if old_asset_sha != PREVIOUS_BUNDLE_SHA256: + raise RebuildError( + f"previous bundle SHA-256 is {old_asset_sha}, expected {PREVIOUS_BUNDLE_SHA256}" + ) + old_root = extract_zip(args.previous_bundle, args.work_dir / "old") + old_rows = read_chain(old_root / "CHAIN.csv") + historical_images = reconstruct_historical_images( + old_root, old_rows, args.detools, args.work_dir + ) + accelerated_images, accelerated_sources = read_accelerated_images( + args.accelerated_images + ) + + endpoint = read_firmware_zip(args.endpoint_zip) + endpoint_version, _endpoint_body, _body_size, _hw = firmware_identity(endpoint) + if endpoint_version != "1.17.1.0": + raise RebuildError(f"endpoint version is {endpoint_version}, expected 1.17.1.0") + if f"-{args.source_commit[:8]}".encode("ascii") not in endpoint: + raise RebuildError("endpoint firmware does not contain the source commit suffix") + images = historical_images[:2] + accelerated_images + [endpoint] + target_sources = [old_rows[0]["source_commit"], *accelerated_sources, args.source_commit] + if len(images) != EXPECTED_STEPS + 1: + raise RebuildError(f"internal image count is {len(images)}") + if len(target_sources) != EXPECTED_STEPS: + raise RebuildError(f"internal source count is {len(target_sources)}") + + short_commit = args.source_commit[:8] + root_name = f"RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.01-{short_commit}" + root = args.work_dir / root_name + motas = root / "motas" + recovery_start = root / "recovery" / "test-start" + recovery_final = root / "recovery" / "final" + motas.mkdir(parents=True) + recovery_start.mkdir(parents=True) + recovery_final.mkdir(parents=True) + validation_work = args.work_dir / "new-validation" + validation_work.mkdir() + + output_rows: list[dict[str, object]] = [] + validation_steps: list[dict[str, object]] = [] + for index in range(1, EXPECTED_STEPS + 1): + from_version, _from_body, _from_size, _from_hw = firmware_identity(images[index - 1]) + to_version, _to_body, _to_size, _to_hw = firmware_identity(images[index]) + filename = f"step-{index:02d}__v{from_version}-to-v{to_version}.mota" + output_path = motas / filename + # Write exact base/target inputs immediately before invoking motatool; + # keeping this explicit makes a retained work directory auditable. + base_path = validation_work / f"base-{index:02d}.bin" + target_path = validation_work / f"target-{index:02d}.bin" + base_path.write_bytes(images[index - 1]) + target_path.write_bytes(images[index]) + build_output = run( + [ + str(args.motatool), "build", "--fw", str(target_path), + "--base", str(base_path), "--patch-type", "in-place", + "--inplace-memory", hex(WORKSPACE_SIZE), + "--segment-size", str(SEGMENT_SIZE), "--block-size", "1024", + "--out", str(output_path), + ], + f"build step {index}", + ) + run([str(args.motatool), "verify", str(output_path)], f"verify step {index}") + parsed = motalib.parse_container(output_path.read_bytes()) + zero = apply_in_place( + args.detools, images[index - 1], parsed.payload, len(images[index]), + validation_work, f"new-step-{index:02d}", 0x00, + ) + erased = apply_in_place( + args.detools, images[index - 1], parsed.payload, len(images[index]), + validation_work, f"new-step-{index:02d}", 0xFF, + ) + target_sha = sha256_bytes(images[index]) + if zero != images[index] or erased != images[index]: + raise RebuildError(f"new step {index} does not reconstruct its target") + if parsed.manifest.image_hash.hex() != target_sha: + raise RebuildError(f"new step {index} manifest image hash mismatch") + if parsed.manifest.target_id != EXPECTED_TARGET_ID: + raise RebuildError(f"new step {index} target ID mismatch") + if args.apply_sim is not None: + run( + [str(args.apply_sim), str(base_path), str(output_path), str(target_path)], + f"OTAFIX C simulation step {index}", + ) + _base_body, base_hash = motalib.parse_endf(images[index - 1]) + target_version, target_body, _target_body_size, _target_hw = firmware_identity(images[index]) + if target_version != to_version: + raise RebuildError( + f"step {index} target version {target_version} != {to_version}" + ) + size = output_path.stat().st_size + if size > STAGING_LIMIT: + raise RebuildError( + f"step {index} is {size} bytes, {size - STAGING_LIMIT} over staging capacity" + ) + manifest_id = parsed.manifest.merkle_root.hex().upper() + output_rows.append({ + "step": index, + "from_version": from_version, + "to_version": to_version, + "mota_file": f"motas/{filename}", + "mota_size": size, + "staging_margin": STAGING_LIMIT - size, + "target_image_size": len(images[index]), + "base_body_hash": base_hash.hex(), + "target_body_hash": target_body, + "target_sha256": target_sha, + "source_commit": target_sources[index - 1], + }) + validation_steps.append({ + "step": index, + "manifest_id": manifest_id, + "mota_sha256": sha256_file(output_path), + "mota_size": size, + "target_sha256": target_sha, + "zero_fill_apply": "passed", + "erased_fill_apply": "passed", + "otafix_c_sim": "passed" if args.apply_sim is not None else "not-run", + }) + print( + f"[build] {index:02d}/{EXPECTED_STEPS} mid={manifest_id} " + f"size={size} margin={STAGING_LIMIT - size}", + flush=True, + ) + if not build_output.strip(): + raise RebuildError(f"motatool produced no build output for step {index}") + + write_chain(root / "CHAIN.csv", output_rows) + shutil.copy2( + old_root / "recovery" / "test-start" / "RAK3401-test-start-v1.16.7-c1caa5ad.zip", + recovery_start, + ) + shutil.copy2( + old_root / "recovery" / "test-start" / "RAK3401-test-start-v1.16.7-c1caa5ad.uf2", + recovery_start, + ) + shutil.copy2(args.accelerated_images, root / "BRIDGE-IMAGES.json") + final_zip_name = args.endpoint_zip.name + final_uf2_name = args.endpoint_uf2.name + shutil.copy2(args.endpoint_zip, recovery_final / final_zip_name) + shutil.copy2(args.endpoint_uf2, recovery_final / final_uf2_name) + baseline = old_root / "PHYSICAL-TEST.jsonl" + if baseline.is_file(): + shutil.copy2(baseline, root / "PHYSICAL-BASELINE-cd824765.jsonl") + + asset_name = f"{root_name}.zip" + max_package = max(int(row["mota_size"]) for row in output_rows) + min_margin = min(int(row["staging_margin"]) for row in output_rows) + write_bundle_docs( + root, asset_name, args.source_commit, endpoint, old_asset_sha, + sha256_file(args.accelerated_images), + max_package, min_margin, + ) + validation = { + "generated_at": datetime.now(timezone.utc).isoformat(), + "generator": "tools/lora_ota/rebuild_rak3401_bundle.py", + "source_commit": args.source_commit, + "previous_bundle_sha256": old_asset_sha, + "bridge_images_sha256": sha256_file(args.accelerated_images), + "workspace_size": WORKSPACE_SIZE, + "segment_size": SEGMENT_SIZE, + "block_size": 1024, + "steps": validation_steps, + } + (root / "validation-results.json").write_text( + json.dumps(validation, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + checksum_sha = write_checksums(root) + output_path = args.output_dir / asset_name + make_zip(root, output_path) + print(f"[bundle] {output_path}") + print(f"[bundle] sha256={sha256_file(output_path)}") + print(f"[bundle] inner_sha256={checksum_sha}") + print(f"[bundle] endpoint_sha256={sha256_bytes(endpoint)}") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except RebuildError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + raise SystemExit(2) diff --git a/tools/lora_ota/test_lora_ota.py b/tools/lora_ota/test_lora_ota.py index d367350b..e834163f 100644 --- a/tools/lora_ota/test_lora_ota.py +++ b/tools/lora_ota/test_lora_ota.py @@ -1497,20 +1497,24 @@ class Rak3401KnownUnsafeReleaseTests(unittest.TestCase): argparse.Namespace(accept_test_candidate=True), steps ) - def test_physically_passed_29_step_release_needs_no_lab_gate(self) -> None: - steps = [mock.Mock(target_sha256="") for _ in range(29)] - for number, image_sha256 in rak_chain.PHYSICALLY_PASSED_ANCHORS: + def test_accelerated_30_step_release_requires_candidate_ack(self) -> None: + steps = [mock.Mock(target_sha256="") for _ in range(30)] + for number, image_sha256 in rak_chain.PINNED_RELEASE_ANCHORS: steps[number - 1].target_sha256 = image_sha256 - rak_chain.require_live_release_safe( - argparse.Namespace(accept_test_candidate=False), steps - ) + with self.assertRaisesRegex( + rak_chain.KnownUnsafeReleaseError, + "requires --accept-test-candidate", + ): + rak_chain.require_live_release_safe( + argparse.Namespace(accept_test_candidate=False), steps + ) rak_chain.require_live_release_safe( argparse.Namespace(accept_test_candidate=True), steps ) - def test_physically_passed_29_step_release_rejects_changed_anchor(self) -> None: - steps = [mock.Mock(target_sha256="") for _ in range(29)] - for number, image_sha256 in rak_chain.PHYSICALLY_PASSED_ANCHORS: + def test_accelerated_30_step_release_rejects_changed_anchor(self) -> None: + steps = [mock.Mock(target_sha256="") for _ in range(30)] + for number, image_sha256 in rak_chain.PINNED_RELEASE_ANCHORS: steps[number - 1].target_sha256 = image_sha256 steps[10].target_sha256 = "00" * 32 with self.assertRaisesRegex( @@ -1521,6 +1525,18 @@ class Rak3401KnownUnsafeReleaseTests(unittest.TestCase): argparse.Namespace(accept_test_candidate=True), steps ) + def test_superseded_physically_passed_29_step_release_is_blocked(self) -> None: + steps = [mock.Mock(target_sha256="") for _ in range(29)] + for number, image_sha256 in rak_chain.SUPERSEDED_29_ANCHORS: + steps[number - 1].target_sha256 = image_sha256 + with self.assertRaisesRegex( + rak_chain.KnownUnsafeReleaseError, + "superseded 29-step release", + ): + rak_chain.require_live_release_safe( + argparse.Namespace(accept_test_candidate=True), steps + ) + def test_superseded_27_step_candidate_is_always_blocked(self) -> None: steps = [mock.Mock(target_sha256="") for _ in range(27)] steps[5].target_sha256 = rak_chain.SUPERSEDED_27_STEP6_IMAGE_SHA256