#!/usr/bin/env python3 """Verify promised firmware capabilities and emit a machine-readable manifest.""" from __future__ import annotations import argparse import json import os from pathlib import Path import sys import struct import zipfile sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools" / "mota")) from motalib import parse_nrf52_layout def nrf52_lora_details(application): """Inspect application bytes, not just an ELF symbol or an OTA CLI stub.""" if b"invalid in-place patch geometry" not in application or b"OTA: status" not in application: raise ValueError("nRF52 LoRa OTA receiver/apply implementation is missing") layout = parse_nrf52_layout(application) if layout is None: raise ValueError("nRF52 LoRa OTA requires a valid EndF and storage-layout record") if len(application) > layout.linked_app_end - layout.app_base: raise ValueError("nRF52 LoRa OTA application exceeds its linked flash region") storage = ("external_qspi" if layout.qspi_backed else "external_sd" if layout.sd_backed else "internal_flash_and_retained_ram" if layout.hybrid_ram else "internal_flash") notes = ["Use a destination-specific .mota package matching the board, target, and storage layout.", "An in-place delta requires the exact firmware currently running as its base.", "The package must fit the receiver's staging and apply workspace.", "Artifact inspection verifies compiled support; it does not verify the bootloader installed on a physical device."] if layout.hybrid_ram: notes.append("Requires OTAFIX 2.4.6 retained-RAM handoff support; a transfer cannot resume after the receiver restarts.") elif layout.external_backed: notes.append("Requires the matching external-storage hardware, wiring, and storage-aware OTAFIX bootloader.") else: notes.append("Internal storage accepts in-place application deltas, not full application packages.") return { "storage": storage, "package_types": ["full", "in_place_delta"] if layout.external_backed else ["in_place_delta"], "bootloader": "Matching board/storage OTAFIX bootloader", "bootloader_release": "https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.11.0-OTAFIX2.4.6", "notes": notes, } def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser() parser.add_argument("--image", required=True, type=Path) parser.add_argument("--output", required=True, type=Path) parser.add_argument("--target", required=True) parser.add_argument("--artifact-target") parser.add_argument("--platformio-env") parser.add_argument("--platform", required=True) parser.add_argument("--build-profile", required=True) parser.add_argument("--capability", action="append", default=[]) parser.add_argument("--reduction", action="append", default=[]) parser.add_argument("--require-ota", action="store_true") parser.add_argument("--firmware-bin", type=Path) parser.add_argument("--partitions", type=Path) parser.add_argument("--dfu-package", type=Path) parser.add_argument( "--expect", action="append", default=[], metavar="CAPABILITY=TEXT", help="Require TEXT to be present in the linked image.", ) return parser.parse_args() def stable_unique(values: list[str]) -> list[str]: return list(dict.fromkeys(value for value in values if value)) def verify_ota_artifacts(args, methods): """Check the actual update medium; source/seeder support cannot qualify.""" if not methods: return False, "no linked wireless self-update implementation" try: if args.platform == "ESP32_PLATFORM": if args.partitions is None or args.firmware_bin is None: return False, "ESP32 OTA requires firmware.bin and partitions.bin" table = args.partitions.read_bytes() slots = {} otadata = False for offset in range(0, len(table) - 31, 32): magic, kind, subtype, address, size = struct.unpack_from("= 0x2000: otadata = True if len(slots) != 2 or not otadata: return False, "ESP32 OTA requires ota_0, ota_1, and otadata" image_size = args.firmware_bin.stat().st_size ordered = sorted(slots.values()) if ordered[0][0] + ordered[0][1] > ordered[1][0]: return False, "ESP32 OTA application slots overlap" if image_size <= 0 or any(image_size > size for _, size in ordered): return False, "ESP32 firmware does not fit both OTA application slots" return True, "firmware fits both OTA application slots; otadata present" if args.platform == "NRF52_PLATFORM" and set(methods) <= {"bluetooth", "lora"}: if args.dfu_package is None: return False, "nRF52 OTA verification requires its application DFU ZIP" with zipfile.ZipFile(args.dfu_package) as package: manifest = json.loads(package.read("manifest.json"))["manifest"] application = manifest["application"] image = package.read(application["bin_file"]) if not image or not package.read(application["dat_file"]): return False, "Bluetooth DFU application ZIP is empty" if package.testzip() is not None: return False, "Bluetooth DFU application ZIP is corrupt" evidence = [] if "bluetooth" in methods: evidence.append("application DFU ZIP verified; matching BLE DFU bootloader required") if "lora" in methods: if "companion" in args.target.lower() or "comp_radio" in args.target.lower(): return False, "Companion MOTA sending does not qualify as LoRa self-update" details = nrf52_lora_details(image) evidence.append("LoRa mOTA receiver/apply code and EndF storage layout verified: " + details["storage"] + "; matching board/storage OTAFIX bootloader and compatible destination package required") return True, "; ".join(evidence) except (OSError, ValueError, KeyError, zipfile.BadZipFile) as exc: return False, str(exc) return False, "no qualified wireless update artifact for this platform" def main() -> int: args = parse_args() try: image = args.image.read_bytes() except OSError as exc: print(f"capability check: cannot read {args.image}: {exc}", file=sys.stderr) return 2 checks = [] malformed = False for expectation in args.expect: if "=" not in expectation: print( f"capability check: malformed --expect {expectation!r}", file=sys.stderr, ) malformed = True continue capability, needle = expectation.split("=", 1) present = bool(needle) and needle.encode("utf-8") in image checks.append( { "capability": capability, "evidence": needle, "present": present, } ) capabilities = stable_unique( args.capability + [check["capability"] for check in checks] ) missing = [check for check in checks if not check["present"]] ota_methods = stable_unique([ check["capability"].removeprefix("ota.update.") for check in checks if check["present"] and check["capability"].startswith("ota.update.") ]) ota_verified, ota_evidence = verify_ota_artifacts(args, ota_methods) if not ota_verified: capabilities = [value for value in capabilities if not value.startswith("ota.update.")] manifest = { "schema_version": 2, "target": args.target, "artifact_target": args.artifact_target or args.target, "platformio_env": args.platformio_env or args.target, "platform": args.platform, "build_profile": args.build_profile, "capabilities": capabilities, "reductions": stable_unique(args.reduction), "verification": checks, "ota_update_methods": ota_methods if ota_verified else [], "ota_update_verified": ota_verified, "ota_update_evidence": ota_evidence, "verified": not malformed and not missing and (not args.require_ota or ota_verified), } if ota_verified and args.platform == "NRF52_PLATFORM" and "lora" in ota_methods: with zipfile.ZipFile(args.dfu_package) as package: app = json.loads(package.read("manifest.json"))["manifest"]["application"] manifest["ota_update_requirements"] = {"lora": nrf52_lora_details(package.read(app["bin_file"]))} args.output.parent.mkdir(parents=True, exist_ok=True) temporary = args.output.with_name(f".{args.output.name}.{os.getpid()}.tmp") temporary.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n") temporary.replace(args.output) if malformed: return 2 if args.require_ota and not ota_verified: print(f"capability check failed: wireless self-update: {ota_evidence}", file=sys.stderr) return 1 if missing: for check in missing: print( "capability check failed: " f"{check['capability']} promised but {check['evidence']!r} " f"is absent from {args.image}", file=sys.stderr, ) return 1 print( f"Verified {len(checks)} capability marker(s); manifest: {args.output}" ) return 0 if __name__ == "__main__": raise SystemExit(main())