#!/usr/bin/env python3 """Package the two-stage Wi-Fi and LoRa ESP32 partition migrations.""" from __future__ import annotations import argparse import hashlib import json from pathlib import Path import struct import sys import zipfile ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "tools" / "mota")) from motalib import ( # noqa: E402 CODEC_FULL, FwIdent, build_container, build_manifest, ensure_endf, hardware_id_for_env, pack_version, parse_container, parse_endf_ident, target_id_for_env, verify, ) from check_esp32_app_size import app_partition_size # noqa: E402 from firmware_memory_manifest import validate_package # noqa: E402 BOARDS = { "heltec-v4": { "target": "heltec_v4_repeater", "wifi_bridge": "heltec_v4_partition_migrator", "lora_bridge": "heltec_v4_partition_migrator_lora_repeater", "flash_bytes": 16 * 1024 * 1024, "slot_bytes": 0x640000, "slot1_address": 0x650000, }, "xiao-s3-wio": { "target": "Xiao_S3_WIO_repeater", "wifi_bridge": "xiao_s3_partition_migrator", "lora_bridge": "xiao_s3_partition_migrator_lora_repeater", "flash_bytes": 8 * 1024 * 1024, "slot_bytes": 0x330000, "slot1_address": 0x340000, }, } LEGACY_SLOT_BYTES = 0x140000 def sha256(data: bytes) -> str: return hashlib.sha256(data).hexdigest() def partition_entries(table: bytes) -> dict[str, tuple[int, int]]: entries = {} for offset in range(0, len(table) - 31, 32): entry = table[offset:offset + 32] if entry[:2] != b"\xaa\x50": continue address, size = struct.unpack_from(" bytes: # Device-side seeders keep at most 1024 Merkle leaves in 4 KiB scratch. # The bridge fits 1 KiB blocks; a roughly 2 MiB Full image needs 2 KiB. if (len(image) + block_size - 1) // block_size > 1024: raise ValueError("mOTA image exceeds the device seeder's 1024-block limit") manifest = build_manifest( target_id=identity.target_id, fw_version=identity.fw_version, image_size=len(image), payload=image, block_size=block_size, image_hash=hashlib.sha256(image).digest(), codec_id=CODEC_FULL, is_full=True, hw_id=identity.hw_id, ) package = build_container(manifest, image) problems = verify(parse_container(package)) if problems: raise ValueError("mOTA verification failed: " + "; ".join(problems)) return package def check_esp32_stage(package: bytes, image: bytes, slot_bytes: int) -> None: # Mirror the ESP32 FULL staging geometry: the payload is written at the # start of the inactive app, with metadata in aligned sectors at its end. metadata_bytes = len(package) - len(image) - 5 metadata_flush = (metadata_bytes + 5 + 4095) & ~4095 if (len(package) > slot_bytes or metadata_flush > 65536 or len(image) > slot_bytes - metadata_flush): raise ValueError("mOTA image cannot be staged in the inactive ESP32 slot") def readme(board: str, spec: dict, version: str, source: str) -> str: return f"""# {board} in-place partition migration Source: `{source}`; firmware version: `{version}`. This package is only for the exact `{spec['target']}` board/role with {spec['flash_bytes'] // 1048576} MiB flash. The current partition table must have stable NVS at 0x9000, OTA metadata at 0xE000, two OTA apps, and SPIFFS with `/identity/_main.id`. The bridge checks the live layout and refuses unsupported boards. It stages the 96-byte public/private identity in unchanged NVS, copies and verifies the required application, changes the partition table, then restores and verifies the identity in expanded SPIFFS. No full-chip erase or USB connection is used. The table sector and inactive app sectors are erased as part of migration. SPIFFS may be reformatted; other SPIFFS settings can be recreated. ## Wi-Fi route 1. Upload `wifi-bridge.bin` through the existing repeater's Wi-Fi OTA page. 2. Keep power stable. Join `MeshCore-Migrate` (password `meshcore-migrate`), open `http://192.168.4.1/`, and wait for **Expanded layout ready**. 3. At that page's `/update`, upload `full-application.bin`. ## LoRa route The old repeater must already have working MeshCore LoRa mOTA, a valid EndF, and the exact target ID in `manifest.json`. A stock image without that updater cannot take this route. The bridge checks the old receiver before changing the table and refuses if its image or target does not match. 1. Make `lora-bridge.mota` available from a compatible mOTA seeder. On the old repeater use `ota ls`, `ota pull flash`, then `ota install` after the complete download. A manual install handles an equal-version bridge. 2. Keep power stable. The bridge expands the table, restores the private key, and reboots into the preserved old LoRa repeater in the opposite slot. 3. Serve `full-application.mota`; again use `ota ls`, `ota pull flash`, then `ota install`. The old repeater now sees the expanded inactive slot. A full-image LoRa transfer can take considerable time. `full-application.bin` and `full-application.mota` are the same exact-target Full repeater image. `target-partitions.bin` is included for verification only; do not upload it to the browser. Neither bridge is a USB merged image. """ def package_board(name: str, spec: dict, build_dir: Path, output_dir: Path, version: str, source: str) -> Path: target = spec["target"] target_id = target_id_for_env(target) hw_id = hardware_id_for_env(target) stem = f"{target}-full-usb-wifi-ota-{version}-{source}" full_path = build_dir / (stem + ".bin") capability_path = build_dir / (stem + ".capabilities.json") validate_package(build_dir / stem) capabilities = json.loads(capability_path.read_text()) if (capabilities.get("target") != target or capabilities.get("build_profile") != "full" or capabilities.get("platform") != "ESP32_PLATFORM" or not capabilities.get("ota_update_verified")): raise ValueError(f"{name}: Full build has the wrong target or lacks OTA") # build.sh may clean .pio/build between board builds. Its merged image # retains the exact bootloader/partition table/app bytes that were audited. merged = (build_dir / (stem + "-merged.bin")).read_bytes() if len(merged) < 0x9000 or merged[0x10000:] != full_path.read_bytes(): raise ValueError(f"{name}: merged image does not contain the release app") table = merged[0x8000:0x9000] entries = partition_entries(table) if (entries.get("nvs") != (0x9000, 0x5000) or entries.get("otadata") != (0xE000, 0x2000) or entries.get("app0") != (0x10000, spec["slot_bytes"]) or entries.get("app1") != (spec["slot1_address"], spec["slot_bytes"])): raise ValueError(f"{name}: built partition table differs from the migration plan") _label, slot_bytes = app_partition_size(table) full_image = full_path.read_bytes() full_ident = parse_endf_ident(full_image) if (full_ident is None or full_ident.target_id != target_id or full_ident.hw_id != hw_id or len(full_image) >= slot_bytes): raise ValueError(f"{name}: Full image identity or partition fit failed") wifi_bridge = (ROOT / ".pio" / "build" / spec["wifi_bridge"] / "firmware.bin").read_bytes() lora_bridge_body = (ROOT / ".pio" / "build" / spec["lora_bridge"] / "firmware.bin").read_bytes() bridge_ident = FwIdent(pack_version(version.split("-", 1)[0]), target_id, hw_id) lora_bridge, _ = ensure_endf(lora_bridge_body, bridge_ident) if max(len(wifi_bridge), len(lora_bridge)) > LEGACY_SLOT_BYTES: raise ValueError(f"{name}: bridge does not fit the 1.25 MiB legacy slot") if full_ident.fw_version != bridge_ident.fw_version: raise ValueError(f"{name}: bridge and Full image versions differ") files = { "wifi-bridge.bin": wifi_bridge, "lora-bridge.mota": mota_full(lora_bridge, bridge_ident, 1024), "full-application.bin": full_image, "full-application.mota": mota_full(full_image, full_ident, 2048), "target-partitions.bin": table, "capabilities.json": capability_path.read_bytes(), } check_esp32_stage(files["lora-bridge.mota"], lora_bridge, LEGACY_SLOT_BYTES) check_esp32_stage(files["full-application.mota"], full_image, slot_bytes) files["README.md"] = readme(name, spec, version, source).encode() manifest = { "board": name, "target": target, "target_id": f"0x{target_id:08x}", "hardware_id": hw_id, "source_commit": source, "firmware_version": version, "flash_bytes": spec["flash_bytes"], "legacy_slot_bytes": LEGACY_SLOT_BYTES, "expanded_slot_bytes": slot_bytes, "files": {filename: {"bytes": len(data), "sha256": sha256(data)} for filename, data in files.items()}, } files["manifest.json"] = (json.dumps(manifest, indent=2) + "\n").encode() files["SHA256SUMS.txt"] = "".join( f"{sha256(data)} {filename}\n" for filename, data in sorted(files.items()) ).encode() directory = output_dir / name directory.mkdir(parents=True) for filename, data in files.items(): (directory / filename).write_bytes(data) archive = output_dir / f"{name}-{version}-{source}-migration.zip" with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_STORED) as zip_file: for filename in sorted(files): zip_file.write(directory / filename, arcname=filename) return archive def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--build-dir", type=Path, required=True) parser.add_argument("--output-dir", type=Path, required=True) parser.add_argument("--version", required=True) parser.add_argument("--source", required=True, help="eight-character source commit") args = parser.parse_args() if args.output_dir.exists() and any(args.output_dir.iterdir()): parser.error("output directory must be empty") args.output_dir.mkdir(parents=True, exist_ok=True) archives = [package_board(name, spec, args.build_dir, args.output_dir, args.version, args.source) for name, spec in BOARDS.items()] for archive in archives: print(f"{archive}: {archive.stat().st_size} bytes") if __name__ == "__main__": main()