Publish accelerated RAK3401 mOTA chain
This commit is contained in:
1 parent
8c5262c6f8
commit
a09127fefb
11 files changed
+1494
-71
No files matched your search
@@ -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
|
||||
+62
-36
@@ -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`.
|
||||
|
||||
+363
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Executable
+180
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Executable
+640
@@ -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 <base_hash>` 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)
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user