"""Embed an exact published OTAFIX image in an opt-in infrastructure build.""" from __future__ import annotations import binascii import hashlib import io import json from pathlib import Path import re import stat import struct import sys import tempfile from urllib.request import urlopen import zipfile from nrf52_legacy_bootloaders import legacy_bootloaders_for ROOT = Path(__file__).resolve().parents[1] VERSION = "2.4.12" BOOT_VERSION = 0x02040CFF MANIFEST_SHA256 = "d2f62a4ec9b7c5643d8ed787d9e1710a2fabac34607a0bbb6ea5883a458e6df3" LEGACY_SHA256 = "64bfe69d56eefc2d0fe51770902aff70ea0cc06cad261d3d219e4a3c0efac8dc" BOOT_BASE, BOOT_SIZE = 0xF4000, 0xA000 REPOSITORY = "mikecarper/Adafruit_nRF52_Bootloader_OTAFIX" TAG = "v0.11.0-OTAFIX" + VERSION MAX_ZIP_SIZE = 2 * 1024 * 1024 def _unique_object(pairs): result = {} for key, value in pairs: if key in result: raise ValueError("duplicate JSON key: " + key) result[key] = value return result def _json(data): return json.loads(data.decode("utf-8"), object_pairs_hook=_unique_object) def load_manifest(project=ROOT): data = (Path(project) / "docs/_data/bootloader_manifest.json").read_bytes() if hashlib.sha256(data).hexdigest() != MANIFEST_SHA256: raise ValueError("embedded bootloader requires the pinned OTAFIX 2.4.12 download manifest") manifest = _json(data) if (manifest.get("schemaVersion") != 1 or manifest.get("repository") != REPOSITORY or manifest.get("tag") != TAG or manifest.get("version") != VERSION or manifest.get("prerelease") is not False): raise ValueError("unexpected embedded bootloader release identity") return manifest # A PA/module choice does not change the MCU bootloader or its flash wiring. # This map must never be used for application hardware_id or MOTA target IDs. BOOTLOADER_HARDWARE = { "ProMicro_m33s": "ProMicro", "ProMicro_400m33s": "ProMicro", "Xiao_nrf52_m33s": "Xiao_nrf52", "Xiao_nrf52_400m33s": "Xiao_nrf52", "ikoka_handheld_nrf_e22_33dbm": "Xiao_nrf52", "ikoka_handheld_nrf_e22_33dbm_096": "Xiao_nrf52", "ikoka_handheld_nrf_e22_400m33s": "Xiao_nrf52", "ikoka_handheld_nrf_e22_400m33s_096": "Xiao_nrf52", "ikoka_nano_nrf_400m33s": "Xiao_nrf52", "ikoka_stick_nrf_400m33s": "Xiao_nrf52", "solarxiao_400M33S": "Xiao_nrf52", } def select_profile(manifest, target): from generate_mesh_america_release import target_profiles parsed = target_profiles([target])[target] if parsed["role"] not in ("repeater", "room"): raise ValueError("embedded bootloader is limited to repeater and room-server roles") names = {BOOTLOADER_HARDWARE.get(name, name) for name in (parsed["sourceHardware"], parsed["hardware"])} if any(parsed["sourceHardware"] in item["meshcoreHardware"] for item in manifest.get("unavailableProfiles", ())): raise ValueError("this exact hardware has no qualified OTAFIX download") matches = [item for item in manifest["profiles"] if names.intersection(item["meshcoreHardware"])] if len(matches) != 1: raise ValueError("hardware must map to one exact published bootloader profile") return matches[0], parsed def _asset(profile): asset = profile["files"]["zip"] name, size, digest = asset["name"], asset["size"], asset["sha256"] if (not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*\.zip", name) or type(size) is not int or not 0 < size <= MAX_ZIP_SIZE or not re.fullmatch(r"[0-9a-f]{64}", digest) or asset["url"] != f"https://github.com/{REPOSITORY}/releases/download/{TAG}/{name}"): raise ValueError("invalid pinned bootloader ZIP metadata") return asset def download_zip(profile, cache_dir): asset = _asset(profile) cache = Path(cache_dir) cache.mkdir(parents=True, exist_ok=True) path = cache / (asset["sha256"] + "-" + asset["name"]) def valid(data): return len(data) == asset["size"] and hashlib.sha256(data).hexdigest() == asset["sha256"] if path.is_file() and path.stat().st_size == asset["size"]: data = path.read_bytes() if valid(data): return data with urlopen(asset["url"], timeout=60) as response: data = response.read(asset["size"] + 1) if not valid(data): raise ValueError("downloaded bootloader ZIP size/SHA-256 mismatch") temporary = None try: with tempfile.NamedTemporaryFile(dir=cache, prefix="otafix-", delete=False) as stream: temporary = Path(stream.name) stream.write(data) temporary.replace(path) finally: if temporary is not None: temporary.unlink(missing_ok=True) return data def _unsigned_init(packet, metadata, firmware): if len(packet) < 12 or len(packet) > 4096: raise ValueError("invalid Legacy DFU init-packet length") device, revision, app_version, count = struct.unpack_from(" 64 or len(packet) != 12 + count * 2: raise ValueError("invalid Legacy DFU SoftDevice requirements") requirements = list(struct.unpack_from(f"<{count}H", packet, 10)) crc = struct.unpack_from("> 16) not in (0, stat.S_IFREG) or not 0 < entry.file_size <= (512 * 1024 if entry.filename.endswith(".bin") else 65536)): raise ValueError("unsupported bootloader ZIP member") document = _json(archive.read("manifest.json")) if set(document) != {"manifest"}: raise ValueError("invalid bootloader ZIP manifest root") manifest = document["manifest"] if set(manifest) != {"dfu_version", "softdevice_bootloader"} or manifest["dfu_version"] != 0.5: raise ValueError("expected an unsigned Legacy SoftDevice/bootloader package") entry = manifest["softdevice_bootloader"] if set(entry) != {"bin_file", "dat_file", "sd_size", "bl_size", "init_packet_data"}: raise ValueError("invalid combined bootloader manifest entry") if set(names) != {"manifest.json", entry["bin_file"], entry["dat_file"]}: raise ValueError("bootloader ZIP references do not match its members") if not entry["bin_file"].endswith(".bin") or not entry["dat_file"].endswith(".dat"): raise ValueError("bootloader ZIP has incorrect member extensions") firmware = archive.read(entry["bin_file"]) packet = archive.read(entry["dat_file"]) sd_size, bl_size = entry["sd_size"], entry["bl_size"] if (type(sd_size) is not int or type(bl_size) is not int or sd_size <= 0 or sd_size % 4 or bl_size != BOOT_SIZE or sd_size + bl_size != len(firmware)): raise ValueError("invalid SoftDevice/bootloader payload geometry") _unsigned_init(packet, entry["init_packet_data"], firmware) image = firmware[sd_size:] sys.path.insert(0, str(Path(project) / "tools/mota")) import motalib as ml identity = ml.parse_bootloader_identity(image) sp, reset = struct.unpack_from(" 32: raise ValueError("too many reviewed stock bootloader identities") output = Path(output) output.mkdir(parents=True, exist_ok=True) arrays = {"migration_candidate": image, "migration_candidate_sha256": hashlib.sha256(image).digest(), "migration_legacy_sha256": hashlib.sha256(legacy).digest() if legacy else bytes(32)} header = "#pragma once\n#include \n" source = '#include "BootloaderMigrationResource.h"\n' for name, data in arrays.items(): header += f"extern const uint8_t {name}[{len(data)}];\n" source += f"alignas(4) const uint8_t {name}[{len(data)}] = {{\n" source += "".join(" " + ", ".join(f"0x{value:02X}" for value in data[index:index + 16]) + ",\n" for index in range(0, len(data), 16)) + "};\n" # A zero-count resource retains one inert row so C++ never needs a # non-standard zero-length array. The count, not the sentinel, authorizes it. stock_hashes = [bytes.fromhex(item["image_sha256"]) for item in stock_legacy] header += f"extern const uint8_t migration_stock_sha256[{max(1, len(stock_hashes))}][32];\n" source += f"alignas(4) const uint8_t migration_stock_sha256[{max(1, len(stock_hashes))}][32] = {{\n" source += "".join(" {" + ", ".join(f"0x{value:02X}" for value in digest) + "},\n" for digest in (stock_hashes or [bytes(32)])) + "};\n" values = {"migration_has_legacy": ("bool", "true" if legacy else "false"), "migration_stock_count": ("uint32_t", str(len(stock_hashes))), "migration_app_base": ("uint32_t", hex(identity.app_base)), "migration_boot_version": ("uint32_t", hex(identity.boot_version)), "migration_board_id": ("uint32_t", hex(identity.board_id)), "migration_softdevice_fwid": ("uint16_t", hex(identity.softdevice_fwid))} for name, (kind, value) in values.items(): header += f"extern const {kind} {name};\n" source += f"const {kind} {name} = {value};\n" header += "extern const char migration_device_name[16];\n" source += "const char migration_device_name[16] = " + json.dumps(identity.device_name) + ";\n" (output / "BootloaderMigrationResource.h").write_text(header, encoding="ascii") (output / "BootloaderMigrationResource.cpp").write_text(source, encoding="ascii") (output / "BootloaderMigrationResource.bin").write_bytes(image) metadata = {"target": target, "hardware": parsed["sourceHardware"], "role": parsed["role"], "profile": profile["id"], "release": VERSION, "version": VERSION, "manifest_sha256": MANIFEST_SHA256, "asset_sha256": profile["files"]["zip"]["sha256"], "source_zip_sha256": profile["files"]["zip"]["sha256"], "candidate_sha256": hashlib.sha256(image).hexdigest(), "legacy_sha256": hashlib.sha256(legacy).hexdigest() if legacy else None, "stock_legacy": stock_legacy, "image_size": BOOT_SIZE, "app_base": identity.app_base, "boot_version": identity.boot_version, "board_id": identity.board_id, "softdevice_fwid": identity.softdevice_fwid, "device_name": identity.device_name} (output / "BootloaderMigrationResource.json").write_text(json.dumps(metadata, indent=2) + "\n", encoding="ascii") return metadata