286 lines
15 KiB
Python
286 lines
15 KiB
Python
"""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
|
|
|
|
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
|
|
|
|
|
|
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 = {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("<HHIH", packet)
|
|
if not count or count > 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("<H", packet, 10 + count * 2)[0]
|
|
expected = {"device_type": device, "device_revision": revision,
|
|
"application_version": app_version, "softdevice_req": requirements,
|
|
"firmware_crc16": crc}
|
|
if (metadata != expected or device != 0x52 or revision != 52840
|
|
or crc != binascii.crc_hqx(firmware, 0xFFFF)):
|
|
raise ValueError("Legacy DFU init metadata/firmware CRC mismatch")
|
|
|
|
|
|
def validate_candidate_zip(data, profile, project=ROOT):
|
|
asset = _asset(profile)
|
|
if len(data) != asset["size"] or hashlib.sha256(data).hexdigest() != asset["sha256"]:
|
|
raise ValueError("bootloader ZIP does not match its pinned release asset")
|
|
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
|
entries = archive.infolist()
|
|
names = [entry.filename for entry in entries]
|
|
if len(entries) != 3 or len(set(names)) != 3 or "manifest.json" not in names:
|
|
raise ValueError("bootloader ZIP must contain exactly one manifest/BIN/DAT")
|
|
for entry in entries:
|
|
if (not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", entry.filename)
|
|
or entry.is_dir() or entry.flag_bits & 1
|
|
or entry.compress_type not in (zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED)
|
|
or stat.S_IFMT(entry.external_attr >> 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("<II", image)
|
|
if (identity is None or identity.manifest_offset != BOOT_SIZE - 76
|
|
or not (0x20000000 <= sp <= 0x20040000 and sp % 8 == 0 and reset & 1
|
|
and BOOT_BASE <= reset & ~1 < BOOT_BASE + BOOT_SIZE)
|
|
or identity.boot_version != BOOT_VERSION or identity.softdevice_family != 140
|
|
or identity.layout_abi != 1 or identity.compat_flags != 0
|
|
or identity.device_name != profile["deviceName"]):
|
|
raise ValueError("candidate bootloader identity/version/vector/CRC mismatch")
|
|
platforms = {"6.1.1": (0xB6, 0x26000), "7.3.0": (0x123, 0x27000)}
|
|
match = re.search(r"_s140_([0-9.]+)\.zip$", asset["name"])
|
|
expected = platforms.get(match[1]) if match else None
|
|
if expected is None or (identity.softdevice_fwid, identity.app_base) != expected:
|
|
raise ValueError("candidate SoftDevice/application layout differs from its release asset")
|
|
actual_storage = ml.bootloader_caps_storage(image)
|
|
expected_storage = {"internal": 0x0A, "qspi": 0x0E, "sd": 0x09, "adaptive": 0x0A}
|
|
if actual_storage != expected_storage.get(profile["storage"]):
|
|
raise ValueError("candidate OTA capability marker differs from its exact profile")
|
|
if profile["storage"] == "adaptive" and ml.bootloader_optional_app_storage(image) != 0x14:
|
|
raise ValueError("adaptive candidate lacks its exact optional storage contract")
|
|
# The pinned asset is authoritative for newly published hardware. Keep the
|
|
# existing signed-MOTA inventory strict; do not expand it for embedding.
|
|
if ml.bootloader_qualified_storage_profiles(identity.board_id, identity.device_name) is not None:
|
|
ml.validate_bootloader_image(image)
|
|
return image, identity
|
|
|
|
|
|
def application_base(project, linker):
|
|
if not linker:
|
|
return None
|
|
path = Path(linker)
|
|
path = path if path.is_absolute() else Path(project) / path
|
|
if path.is_file():
|
|
match = re.search(r"FLASH\s*\([^)]*\)\s*:\s*ORIGIN\s*=\s*(0x[0-9A-Fa-f]+)", path.read_text())
|
|
if match:
|
|
return int(match[1], 16)
|
|
match = re.fullmatch(r"nrf52840_s140_v([67])(?:_[A-Za-z0-9_]+)?\.ld", Path(str(linker).replace("\\", "/")).name)
|
|
if match:
|
|
return 0x26000 if match[1] == "6" else 0x27000
|
|
raise ValueError("cannot verify application base from the board linker")
|
|
|
|
|
|
def load_legacy(path):
|
|
path = Path(path)
|
|
if path.suffix.lower() == ".hex":
|
|
from intelhex import IntelHex
|
|
image = bytes(IntelHex(str(path)).tobinarray(start=BOOT_BASE, size=BOOT_SIZE))
|
|
else:
|
|
image = path.read_bytes()
|
|
if len(image) != BOOT_SIZE or hashlib.sha256(image).hexdigest() != LEGACY_SHA256:
|
|
raise ValueError("legacy override must match the qualified MeshEnvy RAK4631 full bootloader hash")
|
|
return image
|
|
|
|
|
|
def emit_resource(project, target, output, *, flags=(), cppdefines=(), src_filter=(),
|
|
linker=None, legacy_path=None, cache_dir=None):
|
|
from nrf52_flash_trim import flag_defines, source_selected
|
|
definitions = flag_defines(flags, cppdefines)
|
|
def enabled(key):
|
|
value = definitions.get(key)
|
|
return value is not None and str(value).strip("\"'() ") not in ("", "0")
|
|
if (not enabled("NRF52_PLATFORM") or not enabled("ENABLE_OTA")
|
|
or definitions.get("DISABLE_LORA_OTA") is not None
|
|
or definitions.get("OTA_SEEDER_ONLY") is not None):
|
|
raise ValueError("embedded bootloader requires an nRF52 OTA receiver")
|
|
manifest = load_manifest(project)
|
|
profile, parsed = select_profile(manifest, target)
|
|
role_sources = {"repeater": "../examples/simple_repeater/main.cpp",
|
|
"room": "../examples/simple_room_server/main.cpp"}
|
|
if (not source_selected(src_filter, role_sources[parsed["role"]])
|
|
or source_selected(src_filter, role_sources[
|
|
"room" if parsed["role"] == "repeater" else "repeater"])
|
|
or any(source_selected(src_filter, source) for source in (
|
|
"../examples/companion_radio/MyMesh.cpp", "../examples/simple_secure_chat/main.cpp",
|
|
"../examples/simple_sensor/main.cpp", "../examples/kiss_modem/main.cpp"))):
|
|
raise ValueError("embedded bootloader requires its exact repeater or room-server source tree")
|
|
if legacy_path and (parsed["sourceHardware"] != "RAK_4631"
|
|
or profile["id"] != "wiscore_rak4631_auto"):
|
|
raise ValueError("the qualified legacy bridge is restricted to RAK4631")
|
|
image, identity = validate_candidate_zip(download_zip(
|
|
profile, cache_dir or Path(project) / ".pio/otafix-cache"), profile, project)
|
|
linked_base = application_base(project, linker)
|
|
if linked_base is not None and linked_base != identity.app_base:
|
|
raise ValueError("candidate SoftDevice base does not match the application linker")
|
|
legacy = load_legacy(legacy_path) if legacy_path else None
|
|
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 <stdint.h>\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"
|
|
values = {"migration_has_legacy": ("bool", "true" if legacy else "false"),
|
|
"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,
|
|
"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
|