Include OTAFIX bootloader variants in complete release builds
This commit is contained in:
1 parent
bbe29fbf95
commit
0d93f73837
13 files changed
+1527
-61
No files matched your search
+200
-10
@@ -22,12 +22,19 @@ from package_cascade_release import (category, collect_artifacts, nrf52_sensor_p
|
||||
from station_g3_profiles import omit_redundant_slot_records
|
||||
from t114_profiles import omit_legacy_display_records
|
||||
from consolidated_profiles import omit_redundant_records
|
||||
from generate_mesh_america_release import target_profiles
|
||||
from nrf52_embedded_bootloader import (VERSION as BOOTLOADER_VERSION,
|
||||
MANIFEST_SHA256 as BOOTLOADER_MANIFEST_SHA256,
|
||||
load_manifest as load_bootloader_manifest,
|
||||
select_profile as select_bootloader_profile)
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DEFAULT_VERSION = "v1.17.1.9-halo-keymind-cascade-dev"
|
||||
DEFAULT_VERSION = "v1.18.0.1-halo-keymind-cascade-dev"
|
||||
RADIO = {"frequency_mhz": 910.525, "bandwidth_khz": 62.5,
|
||||
"spreading_factor": 7, "coding_rate": 5}
|
||||
BOOTLOADER_ENVIRONMENT = ("MESHCORE_NRF52_EMBED_BOOTLOADER",
|
||||
"MESHCORE_NRF52_LEGACY_BOOTLOADER")
|
||||
|
||||
|
||||
def git(*args: str) -> str:
|
||||
@@ -208,12 +215,154 @@ def migration_bridges() -> list[str]:
|
||||
spec.get("expander_bridge")) if name})
|
||||
|
||||
|
||||
def ordinary_build_environment(environment: dict[str, str]) -> dict[str, str]:
|
||||
"""An inherited opt-in must not relabel the ordinary release or utilities."""
|
||||
result = environment.copy()
|
||||
for name in BOOTLOADER_ENVIRONMENT:
|
||||
result.pop(name, None)
|
||||
return result
|
||||
|
||||
|
||||
def has_embedded_bootloader_variant(manifest: dict) -> bool:
|
||||
return (manifest.get("embedded_bootloader") is not None
|
||||
or "-with-bootloader-" in str(manifest.get("artifact_target", "")))
|
||||
|
||||
|
||||
def require_separate_build_directories(ordinary: Path, embedded: Path) -> None:
|
||||
ordinary, embedded = ordinary.resolve(), embedded.resolve()
|
||||
if ordinary == embedded or ordinary in embedded.parents or embedded in ordinary.parents:
|
||||
raise ValueError("ordinary and embedded bootloader builds require separate output directories")
|
||||
|
||||
|
||||
def embedded_bootloader_selection(records: list[dict]) -> tuple[list[str], list[dict]]:
|
||||
"""Exclude only exact hardware explicitly unavailable in the pinned catalog."""
|
||||
targets = sorted({record["manifest"]["target"] for record in records
|
||||
if record["manifest"]["platform"] == "NRF52_PLATFORM"
|
||||
and re.search(r"_(?:repeater|room_server|room_svr)(?:_|$)",
|
||||
record["manifest"]["target"], re.I)})
|
||||
if not targets:
|
||||
return [], []
|
||||
catalog = load_bootloader_manifest()
|
||||
parsed = target_profiles(targets)
|
||||
supported, exclusions = [], []
|
||||
for target in targets:
|
||||
identity = parsed.get(target)
|
||||
if (not isinstance(identity, dict) or identity.get("role") not in ("repeater", "room")
|
||||
or not isinstance(identity.get("sourceHardware"), str)
|
||||
or not identity["sourceHardware"] or not isinstance(identity.get("hardware"), str)):
|
||||
raise ValueError(f"{target}: invalid embedded bootloader hardware/role metadata")
|
||||
unavailable = [profile for profile in catalog.get("unavailableProfiles", [])
|
||||
if identity["sourceHardware"] in profile.get("meshcoreHardware", [])]
|
||||
if unavailable:
|
||||
names = {identity["sourceHardware"], identity["hardware"]}
|
||||
available = [profile for profile in catalog.get("profiles", [])
|
||||
if names.intersection(profile.get("meshcoreHardware", []))]
|
||||
if len(unavailable) != 1 or available:
|
||||
raise ValueError(f"{target}: ambiguous available/unavailable bootloader catalog mapping")
|
||||
profile = unavailable[0]
|
||||
status = profile.get("qualification", {}).get("status")
|
||||
reason, profile_id = profile.get("reason"), profile.get("id")
|
||||
if (not isinstance(profile_id, str) or not re.fullmatch(r"[a-z0-9_]+", profile_id)
|
||||
or status not in ("retired", "source-pending", "source-blocked")
|
||||
or not isinstance(reason, str) or not reason.strip() or not reason.isascii()):
|
||||
raise ValueError(f"{target}: invalid unavailable bootloader catalog metadata")
|
||||
exclusions.append({"target": target, "profile": profile_id,
|
||||
"source_hardware": identity["sourceHardware"],
|
||||
"status": status, "reason": reason})
|
||||
continue
|
||||
# The shared selector checks role and one exact normal board/storage
|
||||
# profile. An unmapped/ambiguous board is not a declared exclusion.
|
||||
select_bootloader_profile(catalog, target)
|
||||
supported.append(target)
|
||||
return supported, exclusions
|
||||
|
||||
|
||||
def embedded_bootloader_targets(records: list[dict]) -> list[str]:
|
||||
"""Select exact catalog-qualified infrastructure, never Companion/Sensor."""
|
||||
return embedded_bootloader_selection(records)[0]
|
||||
|
||||
|
||||
def build_embedded_bootloader_artifacts(ordinary_work: Path, work: Path,
|
||||
version: str, short_source: str,
|
||||
environment: dict[str, str]) -> None:
|
||||
require_separate_build_directories(ordinary_work, work)
|
||||
records = collect_artifacts(ordinary_work, f"{version}-{short_source}")
|
||||
if any(has_embedded_bootloader_variant(record["manifest"]) for record in records):
|
||||
raise ValueError("ordinary firmware directory contains embedded bootloader variants")
|
||||
records = select_ordinary_full_records(records)
|
||||
targets, exclusions = embedded_bootloader_selection(records)
|
||||
for exclusion in exclusions:
|
||||
print(f"No embedded bootloader variant for {exclusion['target']}: "
|
||||
f"{exclusion['profile']} ({exclusion['status']}); {exclusion['reason']}", flush=True)
|
||||
environment = ordinary_build_environment(environment)
|
||||
environment["OUTPUT_DIR"] = str(work)
|
||||
work.mkdir(parents=True, exist_ok=True)
|
||||
for target in targets:
|
||||
# Each invocation and its Full/Reduced pair finish before the next
|
||||
# starts. The existing wrapper owns the shared PlatformIO lock and
|
||||
# checks the exact source/recipe/package before reusing --resume data.
|
||||
run_logged([
|
||||
"bash", "build_legacy.sh", "build-firmware", target,
|
||||
"--with-bootloader", BOOTLOADER_VERSION,
|
||||
"--firmware-version", version,
|
||||
"--radio-preset", "usa-cascade-fixed", "--profile", "cascade",
|
||||
"--require-ota", "--resume",
|
||||
], work / "build-logs" / f"embedded-{target}.log", environment)
|
||||
|
||||
|
||||
def validate_embedded_bootloader_records(ordinary: list[dict], embedded: list[dict]) -> dict:
|
||||
"""Require every requested variant beside its unchanged ordinary package."""
|
||||
targets, exclusions = embedded_bootloader_selection(ordinary)
|
||||
catalog = load_bootloader_manifest()
|
||||
expected = {}
|
||||
for record in ordinary:
|
||||
manifest = record["manifest"]
|
||||
if manifest["target"] not in targets:
|
||||
continue
|
||||
sensor = nrf52_sensor_profile(manifest)
|
||||
artifact_target = manifest["artifact_target"]
|
||||
suffix = "-" + sensor + "-ota" if sensor else ""
|
||||
if suffix and not artifact_target.endswith(suffix):
|
||||
raise ValueError(f"{artifact_target}: ordinary sensor publication suffix is invalid")
|
||||
base = artifact_target[:-len(suffix)] if suffix else artifact_target
|
||||
variant = base + "-with-bootloader-" + BOOTLOADER_VERSION + suffix
|
||||
if variant in expected:
|
||||
raise ValueError(f"{variant}: duplicate ordinary bootloader variant identity")
|
||||
expected[variant] = manifest
|
||||
observed = set()
|
||||
for record in embedded:
|
||||
manifest = record["manifest"]
|
||||
artifact_target = manifest["artifact_target"]
|
||||
ordinary_manifest = expected.get(artifact_target)
|
||||
metadata = manifest.get("embedded_bootloader")
|
||||
if ordinary_manifest is None or artifact_target in observed:
|
||||
raise ValueError(f"{artifact_target}: unexpected or duplicate embedded bootloader variant")
|
||||
profile, _ = select_bootloader_profile(catalog, ordinary_manifest["target"])
|
||||
if (manifest["platform"] != "NRF52_PLATFORM"
|
||||
or manifest["target"] != ordinary_manifest["target"]
|
||||
or manifest["build_profile"] != ordinary_manifest["build_profile"]
|
||||
or nrf52_sensor_profile(manifest) != nrf52_sensor_profile(ordinary_manifest)
|
||||
or not isinstance(metadata, dict)
|
||||
or metadata.get("release") != BOOTLOADER_VERSION
|
||||
or metadata.get("profile") != profile["id"]
|
||||
or metadata.get("manifest_sha256") != BOOTLOADER_MANIFEST_SHA256
|
||||
or metadata.get("asset_sha256") != profile["files"]["zip"]["sha256"]
|
||||
or metadata.get("legacy_sha256") is not None):
|
||||
raise ValueError(f"{artifact_target}: embedded bootloader qualification differs from its ordinary target/catalog")
|
||||
observed.add(artifact_target)
|
||||
if observed != set(expected):
|
||||
raise ValueError("missing embedded bootloader variants: " + ", ".join(sorted(set(expected) - observed)))
|
||||
return {"release": BOOTLOADER_VERSION,
|
||||
"manifest_sha256": BOOTLOADER_MANIFEST_SHA256,
|
||||
"targets": targets, "firmware_count": len(embedded), "exclusions": exclusions}
|
||||
|
||||
|
||||
def build_migration_artifacts(work: Path, version: str, short_source: str,
|
||||
jobs: int, environment: dict[str, str],
|
||||
pio_build_dir: Path | None = None) -> None:
|
||||
utility_build_dir = resolve_pio_build_dir(
|
||||
pio_build_dir, environment=environment, project_dir=ROOT)
|
||||
environment = environment.copy()
|
||||
environment = ordinary_build_environment(environment)
|
||||
environment["PLATFORMIO_BUILD_DIR"] = str(utility_build_dir)
|
||||
for target in migration_targets():
|
||||
matches = list(work.glob(full_image_pattern(target, version, short_source)))
|
||||
@@ -458,19 +607,36 @@ def select_ordinary_full_records(records: list[dict]) -> list[dict]:
|
||||
|
||||
|
||||
def stage_release(work: Path, migration_work: Path, destination: Path, version: str,
|
||||
source: str, pio_build_dir: Path | None = None) -> None:
|
||||
source: str, pio_build_dir: Path | None = None, *,
|
||||
embedded_work: Path | None = None) -> None:
|
||||
if destination.exists():
|
||||
raise FileExistsError(f"release already exists: {destination}")
|
||||
utility_build_dir = resolve_pio_build_dir(pio_build_dir, project_dir=ROOT)
|
||||
package_environment = os.environ.copy()
|
||||
package_environment = ordinary_build_environment(os.environ.copy())
|
||||
package_environment["PLATFORMIO_BUILD_DIR"] = str(utility_build_dir)
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
short_source = source[:8]
|
||||
tooling_source = git("rev-parse", "HEAD")
|
||||
artifact_version = f"{version}-{short_source}"
|
||||
records = select_ordinary_full_records(collect_artifacts(work, artifact_version))
|
||||
records = collect_artifacts(work, artifact_version)
|
||||
if any(has_embedded_bootloader_variant(record["manifest"]) for record in records):
|
||||
raise ValueError("ordinary firmware directory contains embedded bootloader variants")
|
||||
records = select_ordinary_full_records(records)
|
||||
if not records:
|
||||
raise ValueError("firmware matrix produced no qualified artifacts")
|
||||
variants = None
|
||||
if embedded_work is not None:
|
||||
require_separate_build_directories(work, embedded_work)
|
||||
# Apply retirement and consolidation within each variant only. An
|
||||
# embedded successor must never hide a missing ordinary sensor pair.
|
||||
embedded_records = []
|
||||
if (any(embedded_work.glob("*.capabilities.json"))
|
||||
or any(path.suffix in (".bin", ".uf2", ".hex", ".zip")
|
||||
for path in embedded_work.glob("*"))):
|
||||
embedded_records = select_ordinary_full_records(
|
||||
collect_artifacts(embedded_work, artifact_version))
|
||||
variants = validate_embedded_bootloader_records(records, embedded_records)
|
||||
records.extend(embedded_records)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix=".staging-", dir=destination.parent) as temp:
|
||||
staging = Path(temp)
|
||||
@@ -492,9 +658,15 @@ def stage_release(work: Path, migration_work: Path, destination: Path, version:
|
||||
entries.append({"target": manifest["target"], "artifact_target": manifest["artifact_target"],
|
||||
"platform": manifest["platform"], "profile": manifest["build_profile"],
|
||||
"sensor_profile": nrf52_sensor_profile(manifest),
|
||||
"embedded_bootloader": manifest.get("embedded_bootloader"),
|
||||
"ota_update_methods": manifest.get("ota_update_methods", []),
|
||||
"files": files})
|
||||
|
||||
bootloaders = None
|
||||
if embedded_work is not None:
|
||||
from package_nrf52_bootloaders import stage_bootloader_catalog
|
||||
bootloaders = stage_bootloader_catalog(staging / "nrf52-bootloaders", project_root=ROOT)
|
||||
|
||||
migrations = staging / "esp32-partition-migration"
|
||||
migrations.mkdir()
|
||||
with tempfile.TemporaryDirectory(prefix="migration-package-", dir=staging) as temp_packages:
|
||||
@@ -528,9 +700,19 @@ def stage_release(work: Path, migration_work: Path, destination: Path, version:
|
||||
"publication": "local-only",
|
||||
"firmware_target_count": len(entries),
|
||||
"migration_board_role_count": len(BOARDS),
|
||||
"nrf52_bootloader_variants": variants,
|
||||
"nrf52_bootloaders": bootloaders,
|
||||
"firmware": entries,
|
||||
}
|
||||
(staging / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="ascii")
|
||||
bootloader_note = (
|
||||
"Normal nRF52 application images preserve the installed bootloader. Separate\n"
|
||||
"with-bootloader variants for supported Repeater/Room targets embed exact-board\n"
|
||||
"OTAFIX 2.4.12 and check it for installation 70 seconds after startup. Standalone\n"
|
||||
"exact-board bootloader files for all normal profiles are under nrf52-bootloaders;\n"
|
||||
"do not substitute another board/storage image.\n" if bootloaders is not None else
|
||||
"nRF52 bootloader updates require the matching OTAFIX board/storage image\n"
|
||||
"before application firmware.\n")
|
||||
(staging / "README.md").write_text(
|
||||
f"# MeshCore local release {version}\n\n"
|
||||
f"Firmware source commit: `{source}`. Release tooling commit: `{tooling_source}`.\n"
|
||||
@@ -539,8 +721,7 @@ def stage_release(work: Path, migration_work: Path, destination: Path, version:
|
||||
"Select firmware by exact board, radio, storage, and role. ESP32 merged images install\n"
|
||||
"bootloader, partition table, and application over USB. Existing 1.25 MiB ESP32\n"
|
||||
"layouts require their exact board/role package under esp32-partition-migration\n"
|
||||
"before a Full image can use the expanded layout. nRF52 bootloader updates require\n"
|
||||
"the matching OTAFIX board/storage image before application firmware.\n",
|
||||
"before a Full image can use the expanded layout.\n" + bootloader_note,
|
||||
encoding="ascii")
|
||||
files = sorted(path for path in staging.rglob("*") if path.is_file()
|
||||
and path.name != "SHA256SUMS.txt")
|
||||
@@ -568,18 +749,23 @@ def main() -> None:
|
||||
parser.error("commit or remove worktree changes before building a versioned release")
|
||||
label = f"{args.firmware_version}-{short_source}"
|
||||
work = ROOT / ".releases" / f".build-{label}"
|
||||
embedded_work = ROOT / ".releases" / f".bootloader-build-{label}"
|
||||
destination = ROOT / ".releases" / label
|
||||
if destination.exists():
|
||||
parser.error(f"local release already exists: {destination}")
|
||||
if work.exists() and any(work.iterdir()) and not args.resume and not args.dry_run:
|
||||
parser.error(f"work directory already has files: {work}; pass --resume to reuse them")
|
||||
if embedded_work.exists() and any(embedded_work.iterdir()) and not args.resume and not args.dry_run:
|
||||
parser.error(f"embedded bootloader work directory already has files: {embedded_work}; "
|
||||
"pass --resume to reuse them")
|
||||
print(f"Source: {source}")
|
||||
print(f"Firmware targets: canonical matrix; migration roles: {len(BOARDS)}; "
|
||||
print(f"Firmware targets: canonical matrix plus supported nRF52 Repeater/Room "
|
||||
f"OTAFIX {BOOTLOADER_VERSION} variants; migration roles: {len(BOARDS)}; "
|
||||
f"migration utilities: {len(migration_bridges())}")
|
||||
print(f"Output: {destination}", flush=True)
|
||||
if args.dry_run:
|
||||
return
|
||||
environment = os.environ.copy()
|
||||
environment = ordinary_build_environment(os.environ.copy())
|
||||
environment["OPTION3_PIO_JOBS"] = str(args.pio_jobs)
|
||||
try:
|
||||
utility_build_dir = resolve_pio_build_dir(environment=environment, project_dir=ROOT)
|
||||
@@ -603,6 +789,10 @@ def main() -> None:
|
||||
"--radio-preset", "usa-cascade-fixed", "--profile", "cascade",
|
||||
"--require-ota", "--skip-kiss", "--resume",
|
||||
], work / "release-build.log", environment)
|
||||
# Embedding is an application variant, not a replacement bootloader/SD
|
||||
# package. Keep its recipe and resume artifacts outside the ordinary pass.
|
||||
build_embedded_bootloader_artifacts(work, embedded_work, args.firmware_version,
|
||||
short_source, environment)
|
||||
# Keep exact-identity migration inputs outside the ordinary firmware
|
||||
# matrix. collect_artifacts() walks recursively, so nesting these under
|
||||
# work would still publish a second Full image for the same G2 role.
|
||||
@@ -612,7 +802,7 @@ def main() -> None:
|
||||
build_migration_artifacts(migration_work, args.firmware_version, short_source,
|
||||
args.pio_jobs, migration_environment, utility_build_dir)
|
||||
stage_release(work, migration_work, destination, args.firmware_version, source,
|
||||
utility_build_dir)
|
||||
utility_build_dir, embedded_work=embedded_work)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -11,6 +11,7 @@ from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
from urllib.parse import quote
|
||||
from package_cascade_release import embedded_bootloader_variant
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -84,6 +85,7 @@ def read_stage(stage):
|
||||
logical_target = row["artifact_target"]
|
||||
if row.get("verified") is not True or row.get("source_commit", source) != source:
|
||||
raise ValueError("unqualified or wrong-source release profile")
|
||||
embedded_bootloader_variant(row)
|
||||
files = []
|
||||
for name in row["files"]:
|
||||
if Path(name).name != name or name not in checksums:
|
||||
@@ -173,6 +175,9 @@ def generate(catalog, plan, family, records, profiles, repo):
|
||||
"Reduced optional sensors + LoRa OTA" if sensor == "reduced" else
|
||||
"Full profile" if row["build_profile"] == "full" or parsed["mode"] == "full" else
|
||||
"Standard profile")
|
||||
bootloader = row.get("embedded_bootloader")
|
||||
if bootloader:
|
||||
profile += " + OTAFIX " + bootloader["release"] + " bootloader"
|
||||
url = f"https://github.com/{repo}/releases/download/{tag}/"
|
||||
cap = next(name for name in row["files"] if name.endswith(".capabilities.json"))
|
||||
memory = next(name for name in row["files"] if name.endswith(".memory.json"))
|
||||
@@ -184,7 +189,10 @@ def generate(catalog, plan, family, records, profiles, repo):
|
||||
if platform == "esp32":
|
||||
notes.append("INSTALL - The merged .bin installs the bootloader, partitions and application over USB. An app-only .bin requires a compatible existing partition layout. Use the exact board and role migration ZIP from the utility release when expanding partitions over Wi-Fi or LoRa; follow its README and install its supplied Full application.")
|
||||
elif platform == "nrf52":
|
||||
notes.append("INSTALL - UF2 and Serial DFU ZIP files contain application firmware. LoRa updates require the matching board and storage OTAFIX bootloader. Use the firmware picker's exact bootloader mapping.")
|
||||
if bootloader:
|
||||
notes.append("INSTALL - This application carries the exact-board OTAFIX " + bootloader["release"] + " bootloader and checks it for installation 70 seconds after startup. Match the board and storage profile; recovery and unrecognized bootloaders require the documented migration route.")
|
||||
else:
|
||||
notes.append("INSTALL - Ordinary UF2 and Serial DFU ZIP files contain application firmware and preserve the installed bootloader. LoRa updates require the matching board and storage OTAFIX bootloader. Use the firmware picker's exact bootloader mapping.")
|
||||
if row.get("reductions"):
|
||||
notes.append("CAPACITY - " + "; ".join(row["reductions"]))
|
||||
if "-full-usb-wifi" in target:
|
||||
|
||||
@@ -33,6 +33,47 @@ def is_companion(target):
|
||||
return "companion" in target.lower() or "comp_radio" in target.lower()
|
||||
|
||||
|
||||
def embedded_bootloader_variant(manifest):
|
||||
"""Keep ordinary and exact, qualified bootloader-carrying images distinct."""
|
||||
metadata = manifest.get("embedded_bootloader")
|
||||
target = manifest["target"]
|
||||
artifact = manifest.get("artifact_target", target)
|
||||
capability = "bootloader.embedded"
|
||||
if metadata is None:
|
||||
if "-with-bootloader-" in artifact or capability in manifest.get("capabilities", []):
|
||||
raise ValueError(f"{artifact}: embedded bootloader lacks qualified metadata")
|
||||
return ""
|
||||
from nrf52_embedded_bootloader import (BOOT_SIZE, LEGACY_SHA256, MANIFEST_SHA256,
|
||||
VERSION, load_manifest, select_profile)
|
||||
if not isinstance(metadata, dict):
|
||||
raise ValueError(f"{artifact}: invalid embedded bootloader metadata")
|
||||
try:
|
||||
profile, _ = select_profile(load_manifest(), target)
|
||||
except (ValueError, KeyError) as exc:
|
||||
raise ValueError(f"{artifact}: embedded bootloader requires an exact supported profile") from exc
|
||||
verified = any(check.get("capability") == capability
|
||||
and check.get("present") is True
|
||||
and check.get("source") == "packaged application"
|
||||
for check in manifest.get("verification", []))
|
||||
offset = metadata.get("app_offset")
|
||||
if (manifest.get("platform") != "NRF52_PLATFORM"
|
||||
or metadata.get("release") != VERSION
|
||||
or metadata.get("profile") != profile["id"]
|
||||
or metadata.get("manifest_sha256") != MANIFEST_SHA256
|
||||
or metadata.get("asset_sha256") != profile["files"]["zip"]["sha256"]
|
||||
or not isinstance(metadata.get("candidate_sha256"), str)
|
||||
or not re.fullmatch(r"[0-9a-f]{64}", metadata["candidate_sha256"])
|
||||
or metadata.get("legacy_sha256") not in (None, LEGACY_SHA256)
|
||||
or metadata.get("image_bytes") != BOOT_SIZE
|
||||
or type(offset) is not int or offset < 0 or offset % 4
|
||||
or metadata.get("activation") != "ota bootloader update"
|
||||
or metadata.get("automatic_delay_seconds") != 70
|
||||
or capability not in manifest.get("capabilities", []) or not verified
|
||||
or not artifact.startswith(target + "-with-bootloader-" + VERSION + "-")):
|
||||
raise ValueError(f"{artifact}: embedded bootloader qualification disagrees with its pinned release")
|
||||
return VERSION
|
||||
|
||||
|
||||
def nrf52_sensor_profile(manifest):
|
||||
"""Read the qualified sensor policy, never infer it from an artifact name."""
|
||||
choices = {value.removeprefix("sensor.profile.")
|
||||
@@ -91,42 +132,51 @@ def omit_retired_onboard_qspi_records(records):
|
||||
# collect_artifacts() has already checked the packaged OTA identity
|
||||
# and layout-matched sensor pair. Do not let retired leftovers
|
||||
# conceal a missing normal release pair for this exact hardware.
|
||||
replacements = [record["manifest"] for record in records
|
||||
if record["manifest"]["target"].lower() == primary]
|
||||
error = (legacy + ": ordinary onboard-QSPI release requires the qualified "
|
||||
"external-QSPI Full/Reduced pair for " + primary
|
||||
+ "; retired artifacts cannot substitute for its distinct OTA identity")
|
||||
try:
|
||||
profiles = [nrf52_sensor_profile(manifest) for manifest in replacements]
|
||||
except ValueError as cause:
|
||||
raise ValueError(error) from cause
|
||||
if (sorted(profiles) != ["full", "reduced"]
|
||||
or any("hardware.qualification.pending" in manifest.get("capabilities", [])
|
||||
or (manifest.get("ota_update_requirements") or {})
|
||||
.get("lora", {}).get("storage") != "external_qspi"
|
||||
for manifest in replacements)
|
||||
or replacements[0].get("source_commit") != replacements[1].get("source_commit")
|
||||
or replacements[0].get("ota_update_requirements") !=
|
||||
replacements[1].get("ota_update_requirements")):
|
||||
raise ValueError(error)
|
||||
if legacy in bridge_legacy:
|
||||
# The ordinary QSPI repeater may retire the separate UART role
|
||||
# only when both sensor profiles retain the linked driver and
|
||||
# prove its runtime controls in the packaged application.
|
||||
required = {"bridge.rs232": "linked image",
|
||||
"bridge.rs232.merged": "packaged application"}
|
||||
for manifest in replacements:
|
||||
proven = {check.get("capability") for check in manifest.get("verification", [])
|
||||
if check.get("present") is True
|
||||
and required.get(check.get("capability")) == check.get("source")
|
||||
and isinstance(check.get("evidence"), str) and check["evidence"].strip()}
|
||||
if (not required.keys() <= set(manifest.get("capabilities", []))
|
||||
or not required.keys() <= proven):
|
||||
raise ValueError(error + "; replacement requires a verified runtime RS232 bridge")
|
||||
variants = {embedded_bootloader_variant(record["manifest"]) for record in records
|
||||
if record["manifest"]["target"].lower() == legacy}
|
||||
for variant in variants:
|
||||
replacements = [record["manifest"] for record in records
|
||||
if record["manifest"]["target"].lower() == primary
|
||||
and embedded_bootloader_variant(record["manifest"]) == variant]
|
||||
_require_onboard_qspi_replacement(legacy, primary, replacements, bridge_legacy)
|
||||
return [record for record in records
|
||||
if record["manifest"]["target"].lower() not in legacy_targets]
|
||||
|
||||
|
||||
def _require_onboard_qspi_replacement(legacy, primary, replacements, bridge_legacy):
|
||||
# A bootloader-carrying pair cannot retire an ordinary pair, or vice versa.
|
||||
error = (legacy + ": ordinary onboard-QSPI release requires the qualified "
|
||||
"external-QSPI Full/Reduced pair for " + primary
|
||||
+ "; retired artifacts cannot substitute for its distinct OTA identity")
|
||||
try:
|
||||
profiles = [nrf52_sensor_profile(manifest) for manifest in replacements]
|
||||
except ValueError as cause:
|
||||
raise ValueError(error) from cause
|
||||
if (sorted(profiles) != ["full", "reduced"]
|
||||
or any("hardware.qualification.pending" in manifest.get("capabilities", [])
|
||||
or (manifest.get("ota_update_requirements") or {})
|
||||
.get("lora", {}).get("storage") != "external_qspi"
|
||||
for manifest in replacements)
|
||||
or replacements[0].get("source_commit") != replacements[1].get("source_commit")
|
||||
or replacements[0].get("ota_update_requirements") !=
|
||||
replacements[1].get("ota_update_requirements")):
|
||||
raise ValueError(error)
|
||||
if legacy in bridge_legacy:
|
||||
# The ordinary QSPI repeater may retire the separate UART role
|
||||
# only when both sensor profiles retain the linked driver and
|
||||
# prove its runtime controls in the packaged application.
|
||||
required = {"bridge.rs232": "linked image",
|
||||
"bridge.rs232.merged": "packaged application"}
|
||||
for manifest in replacements:
|
||||
proven = {check.get("capability") for check in manifest.get("verification", [])
|
||||
if check.get("present") is True
|
||||
and required.get(check.get("capability")) == check.get("source")
|
||||
and isinstance(check.get("evidence"), str) and check["evidence"].strip()}
|
||||
if (not required.keys() <= set(manifest.get("capabilities", []))
|
||||
or not required.keys() <= proven):
|
||||
raise ValueError(error + "; replacement requires a verified runtime RS232 bridge")
|
||||
|
||||
|
||||
def omit_internal_solar_room_records(records):
|
||||
"""Compatibility entry point for the shared onboard-QSPI retirement policy."""
|
||||
return omit_retired_onboard_qspi_records(records)
|
||||
@@ -135,8 +185,11 @@ def omit_internal_solar_room_records(records):
|
||||
def release_profile_label(manifest):
|
||||
sensor = nrf52_sensor_profile(manifest)
|
||||
if sensor:
|
||||
return ("Full supported sensors" if sensor == "full" else "Reduced sensors") + " + LoRa OTA"
|
||||
return manifest["build_profile"]
|
||||
label = ("Full supported sensors" if sensor == "full" else "Reduced sensors") + " + LoRa OTA"
|
||||
else:
|
||||
label = manifest["build_profile"]
|
||||
bootloader = embedded_bootloader_variant(manifest)
|
||||
return label + " + OTAFIX " + bootloader + " bootloader" if bootloader else label
|
||||
|
||||
|
||||
def validate_nrf52_sensor_pairs(records):
|
||||
@@ -149,6 +202,7 @@ def validate_nrf52_sensor_pairs(records):
|
||||
groups = {}
|
||||
for record in records:
|
||||
manifest = record["manifest"]
|
||||
bootloader = embedded_bootloader_variant(manifest)
|
||||
sensor = nrf52_sensor_profile(manifest)
|
||||
artifact_target = manifest.get("artifact_target", manifest["target"])
|
||||
hint = (manifest.get("platform") == "NRF52_PLATFORM"
|
||||
@@ -161,6 +215,8 @@ def validate_nrf52_sensor_pairs(records):
|
||||
continue
|
||||
if not artifact_target.endswith("-" + sensor + "-ota"):
|
||||
raise ValueError(f"{artifact_target}: sensor pair publication suffix disagrees with metadata")
|
||||
if bootloader and artifact_target != manifest["target"] + "-with-bootloader-" + bootloader + "-" + sensor + "-ota":
|
||||
raise ValueError(f"{artifact_target}: embedded bootloader sensor pair publication suffix disagrees")
|
||||
if any(not path.name.startswith(artifact_target + "-v") for path in record["files"]
|
||||
if path.suffix in FIRMWARE_SUFFIXES):
|
||||
raise ValueError(f"{artifact_target}: sensor pair artifact filename disagrees with metadata")
|
||||
@@ -181,11 +237,22 @@ def validate_nrf52_sensor_pairs(records):
|
||||
raise ValueError(f"{artifact_target}: sensor pair packaged target ID disagrees with logical target")
|
||||
if len(application) > layout.linked_app_end - layout.app_base:
|
||||
raise ValueError(f"{artifact_target}: sensor pair application exceeds its linked flash region")
|
||||
embedded = manifest.get("embedded_bootloader")
|
||||
if embedded:
|
||||
offset, size = embedded["app_offset"], embedded["image_bytes"]
|
||||
candidate = application[offset:offset + size]
|
||||
if (len(candidate) != size
|
||||
or hashlib.sha256(candidate).hexdigest() != embedded["candidate_sha256"]
|
||||
or b"BL embedded 2.4.12" not in application):
|
||||
raise ValueError(f"{artifact_target}: packaged embedded bootloader differs from qualification")
|
||||
proof = (identity.target_id, identity.hw_id, identity.fw_version,
|
||||
layout.app_base, layout.linked_app_end, layout.stage_ceiling, layout.flags,
|
||||
manifest.get("ota_update_requirements", {}), manifest.get("source_commit"))
|
||||
groups.setdefault(manifest["target"], []).append((sensor, proof))
|
||||
for target, pair in groups.items():
|
||||
manifest.get("ota_update_requirements", {}), manifest.get("source_commit"),
|
||||
tuple((key, embedded.get(key)) for key in ("profile", "candidate_sha256", "legacy_sha256"))
|
||||
if embedded else ())
|
||||
groups.setdefault((manifest["target"], bootloader), []).append((sensor, proof))
|
||||
for (target, bootloader), pair in groups.items():
|
||||
target += "-with-bootloader-" + bootloader if bootloader else ""
|
||||
if sorted(item[0] for item in pair) != ["full", "reduced"]:
|
||||
raise ValueError(f"{target}: sensor pair requires exactly one full and one reduced profile")
|
||||
if pair[0][1] != pair[1][1]:
|
||||
@@ -200,6 +267,7 @@ def validate_manifest(manifest):
|
||||
raise ValueError(f"{target}: firmware capability qualification failed")
|
||||
if not is_companion(target) and not manifest.get("ota_update_verified"):
|
||||
raise ValueError(f"{target}: infrastructure has no verified wireless updater")
|
||||
embedded_bootloader_variant(manifest)
|
||||
nrf52_sensor_profile(manifest)
|
||||
if "companion_radio_full" in target.lower():
|
||||
required = {"companion.usb_mota_source", "companion.mota_sender",
|
||||
@@ -400,6 +468,7 @@ def collect_local_release(directory, version, source):
|
||||
or qualified.get("source_commit", source) != source
|
||||
or qualified["platform"] != entry["platform"]
|
||||
or qualified["build_profile"] != entry["profile"]
|
||||
or qualified.get("embedded_bootloader") != entry.get("embedded_bootloader")
|
||||
or set(entry["files"]) != {
|
||||
str(p.relative_to(directory)) for p in record["files"]}):
|
||||
raise ValueError("local release manifest disagrees with qualified files")
|
||||
@@ -420,6 +489,26 @@ def collect_local_release(directory, version, source):
|
||||
if hashlib.sha256(archive.read(path.name)).hexdigest() != sha256(path):
|
||||
raise ValueError("migration bundle differs from individual packages")
|
||||
extras.extend(bundles)
|
||||
bootloader_directory = directory / "nrf52-bootloaders"
|
||||
if (manifest.get("nrf52_bootloader_variants") is not None
|
||||
or manifest.get("nrf52_bootloaders") is not None
|
||||
or bootloader_directory.exists()
|
||||
or any(record["manifest"].get("embedded_bootloader") is not None for record in records)):
|
||||
if (manifest.get("nrf52_bootloader_variants") is None
|
||||
or manifest.get("nrf52_bootloaders") is None):
|
||||
raise ValueError("local release bootloader inventories are incomplete")
|
||||
from build_local_release import validate_embedded_bootloader_records
|
||||
from package_nrf52_bootloaders import validate_bootloader_catalog
|
||||
ordinary = [record for record in records
|
||||
if not embedded_bootloader_variant(record["manifest"])]
|
||||
embedded = [record for record in records
|
||||
if embedded_bootloader_variant(record["manifest"])]
|
||||
if manifest["nrf52_bootloader_variants"] != validate_embedded_bootloader_records(ordinary, embedded):
|
||||
raise ValueError("local release embedded bootloader inventory disagrees with qualified files")
|
||||
bootloaders = bootloader_directory
|
||||
if manifest.get("nrf52_bootloaders") != validate_bootloader_catalog(bootloaders):
|
||||
raise ValueError("local release standalone bootloader inventory disagrees with pinned files")
|
||||
extras.extend(sorted(path for path in bootloaders.iterdir() if path.is_file()))
|
||||
return records, extras, {"frequency": "910.525", "bandwidth": "62.5", "sf": "7", "cr": "5"}
|
||||
|
||||
|
||||
@@ -573,7 +662,7 @@ def main():
|
||||
f"This page contains **{len(summaries)} qualified firmware profiles**. Full Companions send MOTA and normally update over USB. Infrastructure has a verified wireless update path; see each capability manifest.\n\n"
|
||||
f"[Feature on/off and update directions]({source_url}/docs/full_companion_features.md) | "
|
||||
f"[Release details]({source_url}/docs/releases/{args.version}.md)\n\n"
|
||||
"Match the exact board, radio, display, and storage variant. ESP32 WiFi updates use the application `.bin`; the merged image installs boot/partition data over USB. nRF52 `.zip` files are native application DFU packages. LoRa MOTA installation requires an exact destination package; nRF52 also requires its matching OTAFIX bootloader. Full Companions do not LoRa-install onto themselves.\n\n"
|
||||
"Match the exact board, radio, display, and storage variant. ESP32 WiFi updates use the application `.bin`; the merged image installs boot/partition data over USB. nRF52 firmware `.zip` files are native application DFU packages. Separate `with-bootloader-2.4.12` firmware embeds its exact-board OTAFIX image and checks it for installation after 70 seconds; ordinary firmware preserves the installed bootloader. Standalone bootloader downloads are separate utilities. LoRa MOTA installation requires an exact destination package and matching bootloader. Full Companions do not LoRa-install onto themselves.\n\n"
|
||||
"Firmware and capability checks passed in the build matrix. Hardware update testing across every board was not performed.\n\n"
|
||||
+ partial_note + portable_note + links + "\n")
|
||||
(args.output / (group["key"] + "-notes.md")).write_text(body)
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Stage the pinned normal OTAFIX catalog with exact-board standalone assets."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import struct
|
||||
import tempfile
|
||||
from urllib.request import urlopen
|
||||
|
||||
from intelhex import IntelHex
|
||||
|
||||
import nrf52_embedded_bootloader as embedded
|
||||
|
||||
|
||||
ROOT = embedded.ROOT
|
||||
MANIFEST_NAME = "bootloader-manifest.json"
|
||||
INVENTORY_NAME = "OTAFIX-BOOTLOADERS-" + embedded.VERSION + ".json"
|
||||
CHECKSUMS_NAME = "OTAFIX-BOOTLOADERS-" + embedded.VERSION + ".SHA256SUMS.txt"
|
||||
README_NAME = "OTAFIX-BOOTLOADERS-" + embedded.VERSION + ".md"
|
||||
ASSET_KINDS = ("hex", "uf2", "zip")
|
||||
MAX_ASSET_SIZE = 2 * 1024 * 1024
|
||||
BOOTLOADER_UF2_FAMILY = 0xD663823C
|
||||
|
||||
|
||||
def _digest(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def _asset(profile: dict, kind: str) -> dict:
|
||||
asset = profile["files"][kind]
|
||||
if not isinstance(asset, dict):
|
||||
raise ValueError("invalid bootloader asset metadata")
|
||||
name, size, digest = asset.get("name"), asset.get("size"), asset.get("sha256")
|
||||
prefix = profile["id"] + "_bootloader-" + embedded.TAG
|
||||
expected = (re.escape("update-" + prefix + "_mbr.uf2") if kind == "uf2" else
|
||||
re.escape(prefix) + r"_s140_(?:6\.1\.1|7\.3\.0)\." + kind)
|
||||
if (not isinstance(name, str) or not re.fullmatch(
|
||||
expected, name)
|
||||
or type(size) is not int or not 0 < size <= MAX_ASSET_SIZE
|
||||
or not isinstance(digest, str) or not re.fullmatch(r"[0-9a-f]{64}", digest)
|
||||
or asset.get("url") !=
|
||||
f"https://github.com/{embedded.REPOSITORY}/releases/download/{embedded.TAG}/{name}"):
|
||||
raise ValueError("invalid exact-profile bootloader asset metadata")
|
||||
return asset
|
||||
|
||||
|
||||
def _validate_catalog(manifest: dict) -> list[dict]:
|
||||
profiles = manifest.get("profiles")
|
||||
coverage = manifest.get("coverage")
|
||||
if not isinstance(profiles, list) or not profiles or not isinstance(coverage, dict):
|
||||
raise ValueError("bootloader catalog lacks normal-profile coverage")
|
||||
ids, hardware, names = set(), set(), set()
|
||||
unavailable = manifest.get("unavailableProfiles", [])
|
||||
if not isinstance(unavailable, list):
|
||||
raise ValueError("invalid unavailable bootloader catalog")
|
||||
if any(not isinstance(item, dict) or not isinstance(item.get("id"), str)
|
||||
or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]*", item["id"])
|
||||
for item in unavailable):
|
||||
raise ValueError("invalid unavailable bootloader profile")
|
||||
unavailable_ids = {item["id"] for item in unavailable}
|
||||
if len(unavailable_ids) != len(unavailable):
|
||||
raise ValueError("duplicate unavailable bootloader profile")
|
||||
for profile in profiles:
|
||||
if (not isinstance(profile, dict)
|
||||
or not isinstance(profile.get("id"), str)
|
||||
or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]*", profile["id"])
|
||||
or profile["id"] in ids or profile["id"] in unavailable_ids
|
||||
or profile.get("storage") not in ("internal", "qspi", "sd", "adaptive")
|
||||
or not isinstance(profile.get("label"), str) or not profile["label"]
|
||||
or not isinstance(profile.get("deviceName"), str)
|
||||
or not re.fullmatch(r"[A-Za-z0-9_]{1,15}", profile["deviceName"])
|
||||
or not isinstance(profile.get("files"), dict)
|
||||
or set(profile["files"]) != set(ASSET_KINDS)):
|
||||
raise ValueError("invalid or duplicate normal bootloader profile")
|
||||
aliases = profile.get("meshcoreHardware")
|
||||
if (not isinstance(aliases, list) or not aliases
|
||||
or any(not isinstance(name, str) or not re.fullmatch(
|
||||
r"[A-Za-z0-9][A-Za-z0-9_.+-]*", name) for name in aliases)
|
||||
or len(set(aliases)) != len(aliases) or hardware.intersection(aliases)):
|
||||
raise ValueError("ambiguous bootloader hardware mapping")
|
||||
ids.add(profile["id"])
|
||||
hardware.update(aliases)
|
||||
for kind in ASSET_KINDS:
|
||||
asset = _asset(profile, kind)
|
||||
if asset["name"] in names:
|
||||
raise ValueError("duplicate bootloader asset name")
|
||||
names.add(asset["name"])
|
||||
if (type(coverage.get("releasedProfiles")) is not int
|
||||
or coverage["releasedProfiles"] != len(ids)
|
||||
or type(coverage.get("releasedHardware")) is not int
|
||||
or coverage["releasedHardware"] != len(hardware)):
|
||||
raise ValueError("normal bootloader coverage differs from its catalog")
|
||||
return sorted(profiles, key=lambda item: item["id"])
|
||||
|
||||
|
||||
def _verified(data: bytes, asset: dict) -> bool:
|
||||
return len(data) == asset["size"] and _digest(data) == asset["sha256"]
|
||||
|
||||
|
||||
def _download_asset(asset: dict, cache_dir: Path) -> bytes:
|
||||
"""Cache only complete hash-matched files; never expose partial cache data."""
|
||||
path = cache_dir / (asset["sha256"] + "-" + asset["name"])
|
||||
if path.is_symlink():
|
||||
raise ValueError("bootloader cache entry must not be a symlink")
|
||||
if path.is_file() and path.stat().st_size == asset["size"]:
|
||||
data = path.read_bytes()
|
||||
if _verified(data, asset):
|
||||
return data
|
||||
with urlopen(asset["url"], timeout=60) as response:
|
||||
data = response.read(asset["size"] + 1)
|
||||
if not _verified(data, asset):
|
||||
raise ValueError("downloaded bootloader asset size/SHA-256 mismatch")
|
||||
temporary = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(dir=cache_dir, prefix="otafix-", delete=False) as stream:
|
||||
temporary = Path(stream.name)
|
||||
stream.write(data)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
temporary.replace(path)
|
||||
finally:
|
||||
if temporary is not None:
|
||||
temporary.unlink(missing_ok=True)
|
||||
return data
|
||||
|
||||
|
||||
def _validate_hex(data: bytes, candidate: bytes) -> None:
|
||||
try:
|
||||
image = IntelHex(io.StringIO(data.decode("ascii")))
|
||||
boot = bytes(image.tobinarray(start=embedded.BOOT_BASE, size=embedded.BOOT_SIZE))
|
||||
except Exception as error:
|
||||
raise ValueError("invalid standalone bootloader HEX") from error
|
||||
if boot != candidate:
|
||||
raise ValueError("standalone HEX does not contain the exact profile bootloader")
|
||||
|
||||
|
||||
def _validate_uf2(data: bytes, candidate: bytes) -> None:
|
||||
if not data or len(data) % 512:
|
||||
raise ValueError("invalid standalone bootloader UF2 length")
|
||||
count = len(data) // 512
|
||||
# Published UF2 files omit erased padding between executable data and CF2.
|
||||
# Reconstruct that padding exactly as HEX/Legacy DFU do, rather than
|
||||
# requiring writes for bytes whose verified value is already erased.
|
||||
boot = bytearray([0xFF]) * embedded.BOOT_SIZE
|
||||
written = set()
|
||||
previous_end = -1
|
||||
for index in range(count):
|
||||
offset = index * 512
|
||||
magic0, magic1, flags, address, size, block, blocks, family = struct.unpack_from(
|
||||
"<8I", data, offset)
|
||||
end = address + size
|
||||
in_boot = embedded.BOOT_BASE <= address and end <= embedded.BOOT_BASE + embedded.BOOT_SIZE
|
||||
allowed = in_boot or (address < 0x1000 and end <= 0x1000) or (
|
||||
0x10001000 <= address and end <= 0x10002000)
|
||||
if (magic0 != 0x0A324655 or magic1 != 0x9E5D5157
|
||||
or struct.unpack_from("<I", data, offset + 508)[0] != 0x0AB16F30
|
||||
or flags != 0x2000 or family != BOOTLOADER_UF2_FAMILY
|
||||
or size != 256 or address % 256 or address < previous_end
|
||||
or block != index or blocks != count or not allowed):
|
||||
raise ValueError("invalid standalone bootloader UF2 geometry")
|
||||
previous_end = end
|
||||
if in_boot:
|
||||
position = address - embedded.BOOT_BASE
|
||||
boot[position:position + size] = data[offset + 32:offset + 32 + size]
|
||||
written.add(position)
|
||||
if not written or bytes(boot) != candidate:
|
||||
raise ValueError("standalone UF2 does not contain the exact complete profile bootloader")
|
||||
|
||||
|
||||
def _catalog(project_root: Path) -> tuple[dict, bytes, list[dict]]:
|
||||
manifest = embedded.load_manifest(project_root)
|
||||
raw = (project_root / "docs/_data/bootloader_manifest.json").read_bytes()
|
||||
if _digest(raw) != embedded.MANIFEST_SHA256:
|
||||
raise ValueError("pinned bootloader manifest changed during staging")
|
||||
return manifest, raw, _validate_catalog(manifest)
|
||||
|
||||
|
||||
def _json_bytes(document: dict) -> bytes:
|
||||
return (json.dumps(document, indent=2, sort_keys=True) + "\n").encode("ascii")
|
||||
|
||||
|
||||
def _readme() -> bytes:
|
||||
return (f"# OTAFIX {embedded.VERSION} standalone bootloaders\n\n"
|
||||
f"Source: https://github.com/{embedded.REPOSITORY}/releases/tag/{embedded.TAG}\n\n"
|
||||
"These normal, exact-board bootloaders apply to every application role.\n"
|
||||
"Select the physical board and storage profile from bootloader-manifest.json.\n"
|
||||
"No recovery, retired, blocked or hardware-pending image is included.\n\n"
|
||||
"- UF2 updates an existing compatible bootloader and requires valid hash-bound\n"
|
||||
" application EndF metadata for its staging safety checks.\n"
|
||||
"- DFU ZIP contains the SoftDevice and bootloader; reinstall the application\n"
|
||||
" afterward. This is not an application DFU ZIP.\n"
|
||||
"- HEX is for SWD installation or recovery.\n\n"
|
||||
"Historical incompatible identities require their documented local migration.\n"
|
||||
"A completed copy is not proof of activation: verify the bootloader version.\n"
|
||||
"Ordinary MeshCore firmware does not install these bootloaders automatically.\n"
|
||||
"Separate with-bootloader application variants have their own role, identity\n"
|
||||
"and layout restrictions; this standalone catalog does not bypass them.\n").encode("ascii")
|
||||
|
||||
|
||||
def _collect(manifest: dict, profiles: list[dict], project_root: Path,
|
||||
read_asset, write_asset=None) -> dict:
|
||||
inventory = {
|
||||
"schema_version": 1,
|
||||
"kind": "nrf52-standalone-bootloader-catalog",
|
||||
"version": embedded.VERSION,
|
||||
"tag": embedded.TAG,
|
||||
"source": {"repository": embedded.REPOSITORY,
|
||||
"release_url": manifest["releaseUrl"],
|
||||
"manifest_path": MANIFEST_NAME,
|
||||
"manifest_sha256": embedded.MANIFEST_SHA256},
|
||||
"profile_count": len(profiles),
|
||||
"hardware_count": manifest["coverage"]["releasedHardware"],
|
||||
"profiles": [],
|
||||
"assets": [],
|
||||
"excluded_profiles": sorted(item["id"] for item in manifest.get("unavailableProfiles", [])),
|
||||
"inventory_path": INVENTORY_NAME,
|
||||
"checksums_path": CHECKSUMS_NAME,
|
||||
"readme_path": README_NAME,
|
||||
}
|
||||
for profile in profiles:
|
||||
archive = read_asset(profile, "zip")
|
||||
candidate, identity = embedded.validate_candidate_zip(archive, profile, project_root)
|
||||
if len(candidate) != embedded.BOOT_SIZE or identity.device_name != profile["deviceName"]:
|
||||
raise ValueError("standalone bootloader profile proof is incomplete")
|
||||
record = {"id": profile["id"], "label": profile["label"],
|
||||
"storage": profile["storage"], "device_name": profile["deviceName"],
|
||||
"meshcore_hardware": sorted(profile["meshcoreHardware"]),
|
||||
"bootloader_sha256": _digest(candidate), "image_bytes": len(candidate),
|
||||
"board_id": identity.board_id, "app_base": identity.app_base,
|
||||
"softdevice_fwid": identity.softdevice_fwid, "files": {}}
|
||||
for kind in ASSET_KINDS:
|
||||
asset = _asset(profile, kind)
|
||||
data = archive if kind == "zip" else read_asset(profile, kind)
|
||||
if not _verified(data, asset):
|
||||
raise ValueError("standalone bootloader asset failed final verification")
|
||||
if kind == "hex":
|
||||
_validate_hex(data, candidate)
|
||||
elif kind == "uf2":
|
||||
_validate_uf2(data, candidate)
|
||||
if write_asset is not None:
|
||||
write_asset(asset["name"], data)
|
||||
entry = {"kind": kind, "profile": profile["id"], "name": asset["name"],
|
||||
"path": asset["name"], "size": asset["size"], "sha256": asset["sha256"]}
|
||||
inventory["assets"].append(entry)
|
||||
record["files"][kind] = dict(entry)
|
||||
inventory["profiles"].append(record)
|
||||
inventory["assets"].sort(key=lambda entry: entry["path"])
|
||||
return inventory
|
||||
|
||||
|
||||
def _checksums(directory: Path) -> bytes:
|
||||
return "".join(_digest(path.read_bytes()) + " " + path.name + "\n"
|
||||
for path in sorted(directory.iterdir(), key=lambda path: path.name)
|
||||
if path.name != CHECKSUMS_NAME).encode("ascii")
|
||||
|
||||
|
||||
def validate_bootloader_catalog(directory: Path, *, project_root: Path = ROOT) -> dict:
|
||||
"""Recheck an existing catalog against pinned assets without network access."""
|
||||
directory, project_root = Path(directory), Path(project_root)
|
||||
manifest, raw, profiles = _catalog(project_root)
|
||||
expected_names = {MANIFEST_NAME, INVENTORY_NAME, CHECKSUMS_NAME, README_NAME}
|
||||
expected_names.update(_asset(profile, kind)["name"]
|
||||
for profile in profiles for kind in ASSET_KINDS)
|
||||
if (directory.is_symlink() or not directory.is_dir()
|
||||
or {path.name for path in directory.iterdir()} != expected_names
|
||||
or any(path.is_symlink() or not path.is_file() for path in directory.iterdir())):
|
||||
raise ValueError("standalone bootloader inventory is incomplete or contains unexpected files")
|
||||
if (directory / MANIFEST_NAME).read_bytes() != raw:
|
||||
raise ValueError("standalone bootloader manifest differs from its pin")
|
||||
|
||||
def read_asset(profile, kind):
|
||||
asset = _asset(profile, kind)
|
||||
path = directory / asset["name"]
|
||||
if path.stat().st_size != asset["size"]:
|
||||
raise ValueError("standalone bootloader asset size mismatch")
|
||||
data = path.read_bytes()
|
||||
if not _verified(data, asset):
|
||||
raise ValueError("standalone bootloader asset SHA-256 mismatch")
|
||||
return data
|
||||
|
||||
inventory = _collect(manifest, profiles, project_root, read_asset)
|
||||
if (directory / INVENTORY_NAME).read_bytes() != _json_bytes(inventory):
|
||||
raise ValueError("standalone bootloader metadata differs from verified assets")
|
||||
if (directory / README_NAME).read_bytes() != _readme():
|
||||
raise ValueError("standalone bootloader installation notes differ")
|
||||
if (directory / CHECKSUMS_NAME).read_bytes() != _checksums(directory):
|
||||
raise ValueError("standalone bootloader checksum inventory differs")
|
||||
return inventory
|
||||
|
||||
|
||||
def stage_bootloader_catalog(destination: Path, *, project_root: Path = ROOT,
|
||||
cache_dir: Path | None = None) -> dict:
|
||||
"""Publish a complete normal catalog into an empty destination, or no files."""
|
||||
project_root, destination = Path(project_root), Path(destination)
|
||||
if destination.is_symlink() or (destination.exists() and (
|
||||
not destination.is_dir() or any(destination.iterdir()))):
|
||||
raise ValueError("bootloader destination must be absent or an empty directory")
|
||||
manifest, raw, profiles = _catalog(project_root)
|
||||
cache = Path(cache_dir) if cache_dir is not None else project_root / ".pio/otafix-cache"
|
||||
if cache.is_symlink():
|
||||
raise ValueError("bootloader cache must not be a symlink")
|
||||
cache.mkdir(parents=True, exist_ok=True)
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def read_asset(profile, kind):
|
||||
asset = _asset(profile, kind)
|
||||
path = cache / (asset["sha256"] + "-" + asset["name"])
|
||||
if path.is_symlink():
|
||||
raise ValueError("bootloader cache entry must not be a symlink")
|
||||
return (embedded.download_zip(profile, cache) if kind == "zip"
|
||||
else _download_asset(asset, cache))
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="." + destination.name + "-", dir=destination.parent) as work:
|
||||
staging = Path(work) / "catalog"
|
||||
staging.mkdir()
|
||||
(staging / MANIFEST_NAME).write_bytes(raw)
|
||||
inventory = _collect(manifest, profiles, project_root, read_asset,
|
||||
lambda name, data: (staging / name).write_bytes(data))
|
||||
(staging / INVENTORY_NAME).write_bytes(_json_bytes(inventory))
|
||||
(staging / README_NAME).write_bytes(_readme())
|
||||
(staging / CHECKSUMS_NAME).write_bytes(_checksums(staging))
|
||||
validate_bootloader_catalog(staging, project_root=project_root)
|
||||
# Rename the complete directory on the same filesystem. If an existing
|
||||
# empty destination changed while downloading, refuse to remove it.
|
||||
if destination.exists():
|
||||
destination.rmdir()
|
||||
staging.rename(destination)
|
||||
return inventory
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("destination", type=Path)
|
||||
parser.add_argument("--project-root", type=Path, default=ROOT)
|
||||
parser.add_argument("--cache-dir", type=Path)
|
||||
args = parser.parse_args()
|
||||
receipt = stage_bootloader_catalog(args.destination, project_root=args.project_root,
|
||||
cache_dir=args.cache_dir)
|
||||
print(json.dumps(receipt, indent=2, sort_keys=True))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user