#!/usr/bin/env python3 """Package verified two-stage Wi-Fi, USB and LoRa ESP32 partition migrations.""" from __future__ import annotations import argparse import hashlib import json import os from pathlib import Path import struct import subprocess 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_endf, build_manifest, has_endf, hardware_id_for_env, pack_version, parse_container, parse_endf, 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 from package_meshtext_license import validate_artifact_notice as validate_meshtext_notice BOARDS = { "heltec-v4": { "target": "heltec_v4_repeater", "expander_bridge": "heltec_v4_partition_expander", "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", "expander_bridge": "Xiao_S3_WIO_partition_expander", "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, }, "station-g2-repeater": { "target": "Station_G2_repeater", "wifi_bridge": "esp32_s3_16mb_partition_migrator", "lora_bridge": None, "flash_bytes": 16 * 1024 * 1024, "slot_bytes": 0x640000, "slot1_address": 0x650000, }, "station-g2-room-server": { "target": "Station_G2_room_server", "wifi_bridge": "esp32_s3_16mb_partition_migrator", "lora_bridge": None, "flash_bytes": 16 * 1024 * 1024, "slot_bytes": 0x640000, "slot1_address": 0x650000, }, "t3s3-sx1262-repeater": { "target": "LilyGo_T3S3_sx1262_repeater", "wifi_bridge": "esp32_s3_4mb_partition_migrator", "lora_bridge": None, "flash_bytes": 4 * 1024 * 1024, "slot_bytes": 0x1F0000, "slot1_address": 0x200000, }, "t3s3-sx1262-room-server": { "target": "LilyGo_T3S3_sx1262_room_server", "wifi_bridge": "esp32_s3_4mb_partition_migrator", "lora_bridge": None, "flash_bytes": 4 * 1024 * 1024, "slot_bytes": 0x1F0000, "slot1_address": 0x200000, }, "t3s3-sx1276-repeater": { "target": "LilyGo_T3S3_sx1276_repeater", "wifi_bridge": "esp32_s3_4mb_partition_migrator", "lora_bridge": None, "flash_bytes": 4 * 1024 * 1024, "slot_bytes": 0x1F0000, "slot1_address": 0x200000, }, "t3s3-sx1276-room-server": { "target": "LilyGo_T3S3_sx1276_room_server", "wifi_bridge": "esp32_s3_4mb_partition_migrator", "lora_bridge": None, "flash_bytes": 4 * 1024 * 1024, "slot_bytes": 0x1F0000, "slot1_address": 0x200000, }, "thinknode-m2-repeater": { "target": "ThinkNode_M2_Repeater", "wifi_bridge": "esp32_s3_4mb_partition_migrator", "lora_bridge": None, "flash_bytes": 4 * 1024 * 1024, "slot_bytes": 0x1F0000, "slot1_address": 0x200000, }, "thinknode-m2-room-server": { "target": "ThinkNode_M2_room_server", "wifi_bridge": "esp32_s3_4mb_partition_migrator", "lora_bridge": None, "flash_bytes": 4 * 1024 * 1024, "slot_bytes": 0x1F0000, "slot1_address": 0x200000, }, } def role_for_target(target: str) -> str: lower = target.lower() if "_room_server" in lower: return "room-server" if "_repeater" in lower: return "repeater" if "_sensor" in lower: return "sensor" raise ValueError(f"migration target has no infrastructure role: {target}") def add_roles(family: str, flash_mib: int, targets: dict[str, str], bridge: str | None = None) -> None: """Register exact role identities; a family can have one or all roles.""" if flash_mib not in (4, 8, 16): raise ValueError(f"unsupported migration flash size: {flash_mib} MiB") slot_bytes, slot1_address = { 4: (0x1F0000, 0x200000), 8: (0x330000, 0x340000), 16: (0x640000, 0x650000), }[flash_mib] if bridge is None: bridge = f"esp32_s3_{flash_mib}mb_partition_migrator" for role, target in targets.items(): if role not in ("repeater", "room-server", "sensor"): raise ValueError(f"unsupported migration role: {role}") key = f"{family}-{role}" if key in BOARDS: raise ValueError(f"duplicate migration board role: {key}") BOARDS[key] = { "target": target, "role": role, "wifi_bridge": bridge, "lora_bridge": None, "flash_bytes": flash_mib * 1024 * 1024, "slot_bytes": slot_bytes, "slot1_address": slot1_address, } # The first two repeater keys above retain their published LoRa-capable bridge # names. Other role packages use a chip-family bridge for both Wi-Fi and LoRa. # These are the canonical ESP32 infrastructure identities on 4/8/16 MiB boards which can # have a deployed Arduino default.csv 1.25 MiB table, including historically # default.csv boards now using a larger layout. Other boards that were only # ever built with unrelated layouts are not represented as 1.25 MiB migrants. for family, flash_mib, roles in ( ("heltec-v4", 16, {"room-server": "heltec_v4_room_server", "sensor": "heltec_v4_sensor"}), ("heltec-v4-expansionkit", 16, { "repeater": "heltec_v4_expansionkit_repeater"}), ("xiao-s3-wio", 8, {"room-server": "Xiao_S3_WIO_room_server", "sensor": "Xiao_S3_WIO_sensor"}), ("nibble-zero", 4, {"repeater": "nibble_zero_connect_repeater_", "room-server": "nibble_zero_connect_room_server_"}), ("nibble-screen", 4, {"repeater": "nibble_screen_connect_repeater_", "room-server": "nibble_screen_connect_room_server_"}), ("ebyte-eora-s3", 4, {"repeater": "Ebyte_EoRa-S3_Repeater", "room-server": "Ebyte_EoRa-S3_room_server"}), ("thinknode-m5", 4, {"repeater": "ThinkNode_M5_Repeater", "room-server": "ThinkNode_M5_room_server"}), ("heltec-wireless-tracker", 8, { "repeater": "Heltec_Wireless_Tracker_repeater", "room-server": "Heltec_Wireless_Tracker_room_server"}), ("heltec-tracker-v2", 8, {"repeater": "heltec_tracker_v2_repeater", "room-server": "heltec_tracker_v2_room_server", "sensor": "heltec_tracker_v2_sensor"}), ("heltec-v3", 8, {"repeater": "Heltec_v3_repeater", "room-server": "Heltec_v3_room_server", "sensor": "Heltec_v3_sensor"}), ("heltec-wsl3", 8, {"repeater": "Heltec_WSL3_repeater", "room-server": "Heltec_WSL3_room_server", "sensor": "Heltec_WSL3_sensor"}), ("heltec-wireless-paper", 8, { "repeater": "Heltec_Wireless_Paper_repeater", "room-server": "Heltec_Wireless_Paper_room_server"}), ("t-beam-s3-supreme-sx1262", 8, { "repeater": "T_Beam_S3_Supreme_SX1262_repeater", "room-server": "T_Beam_S3_Supreme_SX1262_room_server"}), ("thinknode-m7", 8, {"repeater": "ThinkNode_M7_repeater", "room-server": "ThinkNode_M7_room_server"}), ("xiao-s3", 8, {"repeater": "Xiao_S3_repeater", "room-server": "Xiao_S3_room_server", "sensor": "Xiao_S3_sensor"}), ("mke-s3", 8, {"repeater": "MKE_s3_repeater", "room-server": "MKE_s3_room_server", "sensor": "MKE_s3_sensor"}), ("rak-3112", 8, {"repeater": "RAK_3112_repeater", "room-server": "RAK_3112_room_server", "sensor": "RAK_3112_sensor"}), ("heltec-e213", 16, {"repeater": "Heltec_E213_repeater", "room-server": "Heltec_E213_room_server"}), ("heltec-e290", 16, {"repeater": "Heltec_E290_repeater", "room-server": "Heltec_E290_room_server"}), ("heltec-rc32", 16, {"repeater": "heltec_rc32_repeater", "room-server": "heltec_rc32_room_server", "sensor": "heltec_rc32_sensor"}), ("heltec-rc32-no-display", 16, { "repeater": "heltec_rc32_without_display_repeater", "room-server": "heltec_rc32_without_display_room_server", "sensor": "heltec_rc32_without_display_sensor"}), ("heltec-t190", 16, {"repeater": "Heltec_T190_repeater_", "room-server": "Heltec_T190_room_server_"}), ("heltec-v4-tft", 16, {"repeater": "heltec_v4_tft_repeater", "room-server": "heltec_v4_tft_room_server", "sensor": "heltec_v4_tft_sensor"}), ("heltec-v4-r8", 16, {"repeater": "heltec_v4_r8_repeater", "room-server": "heltec_v4_r8_room_server", "sensor": "heltec_v4_r8_sensor"}), ("heltec-v4-r8-tft", 16, {"repeater": "heltec_v4_r8_tft_repeater", "room-server": "heltec_v4_r8_tft_room_server", "sensor": "heltec_v4_r8_tft_sensor"}), ("lilygo-tbeam-1w", 16, {"repeater": "LilyGo_TBeam_1W_repeater", "room-server": "LilyGo_TBeam_1W_room_server"}), ("lilygo-tdeck", 16, {"repeater": "LilyGo_TDeck_repeater"}), ("lilygo-teth-elite-sx1262", 16, { "repeater": "LilyGo_TETH_Elite_sx1262_repeater", "room-server": "LilyGo_TETH_Elite_sx1262_room_server"}), ("meshnology-w12", 16, {"repeater": "meshnology_w12_repeater", "room-server": "meshnology_w12_room_server", "sensor": "meshnology_w12_sensor"}), ("station-g3-esp32", 16, {"repeater": "Station_G3_ESP32_repeater", "room-server": "Station_G3_ESP32_room_server"}), ("station-g3-esp32-logging", 16, { "repeater": "Station_G3_ESP32_logging_repeater"}), ("station-g2-logging", 16, { "repeater": "Station_G2_logging_repeater"}), ("thinknode-m9", 16, {"repeater": "ThinkNode_M9_repeater_", "room-server": "ThinkNode_M9_room_server_"}), ): add_roles(family, flash_mib, roles) add_roles("heltec-v2", 8, { "repeater": "Heltec_v2_repeater", "room-server": "Heltec_v2_room_server", }, bridge="esp32_8mb_partition_migrator") add_roles("heltec-ct62", 4, { "repeater": "Heltec_ct62_repeater", "sensor": "Heltec_ct62_sensor", }, bridge="esp32_c3_4mb_partition_migrator") add_roles("xiao-c3", 4, { "repeater": "Xiao_C3_repeater", "room-server": "Xiao_C3_room_server", }, bridge="esp32_c3_4mb_partition_migrator") add_roles("tenstar-c3-sx1262", 4, { "repeater": "Tenstar_C3_sx1262_repeater", }, bridge="esp32_c3_4mb_partition_migrator") add_roles("tenstar-c3-sx1268", 4, { "repeater": "Tenstar_C3_sx1268_repeater", }, bridge="esp32_c3_4mb_partition_migrator") add_roles("generic-e22-sx1262", 4, { "repeater": "Generic_E22_sx1262_repeater", }, bridge="esp32_4mb_partition_migrator") add_roles("generic-e22-sx1268", 4, { "repeater": "Generic_E22_sx1268_repeater", }, bridge="esp32_4mb_partition_migrator") USB_BRIDGE_MARKER = b"MeshCore USB partition migration bridge v1" USB_NVS_GUARD_MARKER = b"MeshCore USB NVS no-autoerase guard v1" USB_BRIDGE_PROFILES = { (9, 4): "esp32_s3_4mb_partition_migrator_usb", (9, 8): "esp32_s3_8mb_partition_migrator_usb", (9, 16): "esp32_s3_16mb_partition_migrator_usb", (0, 4): "esp32_4mb_partition_migrator_usb", (0, 8): "esp32_8mb_partition_migrator_usb", (5, 4): "esp32_c3_4mb_partition_migrator_usb", } def usb_bridge_profile(spec: dict) -> tuple[int, str]: """Map reviewed chip-family bridges, never a generic S3 fallback.""" wifi = spec["wifi_bridge"] if wifi.startswith("esp32_c3_"): chip = 5 elif wifi.startswith("esp32_s3_") or wifi in ( "heltec_v4_partition_migrator", "xiao_s3_partition_migrator"): chip = 9 elif wifi.startswith("esp32_"): chip = 0 else: raise ValueError(f"unreviewed USB migration chip-family bridge: {wifi}") flash_mib = spec["flash_bytes"] // (1024 * 1024) if spec["flash_bytes"] != flash_mib * 1024 * 1024: raise ValueError("USB migration flash capacity must be exact MiB") profile = USB_BRIDGE_PROFILES.get((chip, flash_mib)) if profile is None: raise ValueError("no reviewed USB bridge for this chip and flash capacity") return chip, profile for specification in BOARDS.values(): specification.setdefault("role", role_for_target(specification["target"])) if specification["lora_bridge"] is None: specification["lora_bridge"] = specification["wifi_bridge"] + "_lora" specification["usb_chip"], specification["usb_bridge"] = usb_bridge_profile(specification) LEGACY_SLOT_BYTES = 0x140000 def sha256(data: bytes) -> str: return hashlib.sha256(data).hexdigest() def validate_usb_bridge(image: bytes, spec: dict) -> None: """Refuse stale Wi-Fi binaries and wrong-family/capacity utility builds.""" if len(image) < 24 or image[0] != 0xE9 or not 1 <= image[1] <= 16: raise ValueError("USB bridge is not a valid ESP32 application image") chip, profile = usb_bridge_profile(spec) image_chip = struct.unpack_from("> 4 != expected_flash: raise ValueError(f"USB bridge chip/flash header does not match {profile}") if image[2] != 2: raise ValueError("USB migration bridge must use the reviewed DIO flash mode") if USB_BRIDGE_MARKER not in image: raise ValueError("USB bridge lacks the automatic-ROM migration capability marker") if USB_NVS_GUARD_MARKER not in image: raise ValueError("USB bridge lacks the reviewed NVS no-autoerase guard") if len(image) > LEGACY_SLOT_BYTES: raise ValueError("USB bridge does not fit the 1.25 MiB legacy slot") def resolve_pio_build_dir(pio_build_dir: Path | None = None, *, environment: dict[str, str] | None = None, project_dir: Path | None = None) -> Path: """Use PlatformIO's resolved directory, never an unrelated default tree.""" root = (ROOT if project_dir is None else project_dir).resolve() try: if pio_build_dir is not None: selected = str(pio_build_dir) else: metadata_environment = (os.environ if environment is None else environment).copy() # Expose this defaulted option in project-config JSON without # overriding a configured workspace/build_dir. PIO ignores an # empty environment override but enumerates its option name. metadata_environment.setdefault("PLATFORMIO_BUILD_DIR", "") result = subprocess.run( ["pio", "project", "config", "--json-output", "--project-dir", str(root)], cwd=root, env=metadata_environment, capture_output=True, text=True, check=True, timeout=30, ) if result.returncode: raise ValueError("metadata query failed") options = json.loads(result.stdout) if (not isinstance(options, list) or any(not isinstance(entry, list) or len(entry) != 2 or not isinstance(entry[0], str) or not isinstance(entry[1], list) for entry in options)): raise ValueError("invalid metadata") sections = [entry for entry in options if entry[0] == "platformio"] if len(sections) != 1: raise ValueError("missing or duplicate platformio section") if any(not isinstance(entry, list) or len(entry) != 2 or not isinstance(entry[0], str) for entry in sections[0][1]): raise ValueError("invalid platformio options") directories = [entry[1] for entry in sections[0][1] if entry[0] == "build_dir"] if len(directories) != 1: raise ValueError("missing or duplicate build directory") selected = directories[0] if (not isinstance(selected, str) or not selected.strip() or "\0" in selected or "${" in selected): raise ValueError("invalid build directory") path = Path(selected).expanduser() return (path if path.is_absolute() else root / path).resolve() except (OSError, RuntimeError, ValueError, TypeError, subprocess.SubprocessError): # Config JSON and command diagnostics can contain WiFi credentials. # Never include their contents in the public error. raise ValueError("cannot resolve the PlatformIO build directory") from None 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: # The wire format supports more than the 1024 leaves held by older # 4 KiB-scratch seeders. The package records the required scratch size so # an operator can select a capable seeder before installing the bridge. if (len(image) + block_size - 1) // block_size > 4096: raise ValueError("mOTA image exceeds the supported 4096-block seeder 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 bridge_with_successor_endf(image: bytes, successor: FwIdent) -> bytes: """Bind a temporary role image to the old node's exact LoRa target. PlatformIO can already append EndF for the temporary role. Keeping that trailer would make the old repeater reject stage one on target mismatch. """ body = parse_endf(image)[0] if has_endf(image) else image return body + build_endf(body, successor) 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, has_full_mota: bool = True, full_mota_blocks: int = 0) -> str: usb_return = ( "On native ESP32-S3/C3, it then returns to the chip's ROM download " "mode in software." if spec["usb_chip"] != 0 else "On classic ESP32, it publishes settled completion status on UART; " "the host then uses the existing USB-UART bridge's DTR/RTS bootloader reset." ) expander_instructions = "" if spec.get("expander_bridge"): expander_instructions = f"""## Partition Expander route (automatic LoRa finish) For this exact **{spec['target']}** hardware/role, `partition-expander.bin` is an alternative to `wifi-bridge.bin` in step 1 above. It works from either legacy OTA slot. It stages and verifies the private identity, saved node name, radio profiles and ACL before changing the table. After **Expanded layout ready**, it listens on the saved primary radio profile and automatically fetches only this package's exact-target Full application. Serve `full-application.mota` on that profile; do not upload it to the browser. The bridge's status page reports the receiver profile and block progress. Keep only the intended Full build for this target on the seeder during the migration; the bridge pins the hardware/role target, not an individual MID. If installed on an already-expanded board, it returns to a verified Full image in the other slot. Without a recorded migration handoff or safe Full image, it does not fetch or replace an image; the status page explains whether Wi-Fi recovery is available. Firmware upload is disabled if identity verification failed. `partition-expander.mota` offers the same bridge as a first-stage LoRa update only if the installed old firmware already supports compatible mOTA and its target ID matches this repeater. A stock image without mOTA must use its existing Wi-Fi updater for stage one. Keep a compatible seeder available before installing the bridge; the bridge does not itself repeat normal traffic. """ wifi_instructions = f"""## Wi-Fi route 1. On the old node, run `start ota ap` through its authenticated terminal. Join its `MeshCore-OTA` Wi-Fi and upload `wifi-bridge.bin` at the exact `Started:` URL returned by the command (the port can vary by old build). 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`. """ usb_instructions = f"""## Host-managed USB route `usb-bridge.bin` is a separate, verified **{spec['usb_bridge']}** first stage for this chip family and flash capacity. Do not substitute `wifi-bridge.bin` or a USB diagnostic harness. The production USB bridge keeps application USB CDC disabled during cache-disabled flash operations; it does not enable the old unsafe Wi-Fi bridge's native CDC callback path. An identity-bound host flasher installs only this small app into the existing OTA slots, inactive first and active last, without erasing the chip. Keep power stable while the bridge expands the table and restores and verifies identity and supported configuration across both boots. {usb_return} Neither route needs physical BOOT/RESET access or a Wi-Fi network change, including on an elevated remote node. A classic board must have a working automatic USB-UART reset circuit; a direct UART adapter without that circuit is not sufficient. The host must reverify the same physical USB identity and original chip MAC, read the exact target table, stable NVS restore markers and valid OTA metadata, and refuse the final app unless all completion checks pass. A timer, USB reappearance or partition-table match alone is not success. On a failed migration the bridge can return to ROM for diagnosis; this likewise does not authorize the final app. After verified completion, write `full-application.bin` to the expanded OTA slots with the active slot last. Never write `target-partitions.bin` directly or erase NVS to force success. """ if spec["lora_bridge"]: slot_instructions = ( """On this **4 MiB** layout, the bridge must be installed in old slot B while a verified old LoRa receiver remains in old slot A. If the old node is running B, first install/boot a working same-target old receiver in A, then install the bridge in B. A bridge installed in A refuses migration. After the table switch the old receiver in A may use a temporary identity; the Full application restores the original private key from NVS on its first boot. Do not abandon the update between those two boots.\n\n""" if spec["flash_bytes"] == 4 * 1024 * 1024 else "The bridge may be installed in either old OTA slot.\n\n" ) handoff = ( "the original key stays staged in NVS until the Full firmware " "restores it on first boot" if spec["flash_bytes"] == 4 * 1024 * 1024 else "the bridge restores the private key before handing off" ) lora_instructions = f"""## LoRa route The old node 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. If this bridge is accidentally installed on a node that already has the expanded table, it verifies the other OTA slot's target, EndF image hash and bootability, then returns to that firmware without changing the table. It refuses a second migration bridge or an invalid other image; Wi-Fi recovery remains available if no safe LoRa image exists and identity restoration has completed. A failed identity restore disables firmware upload. Do not deliberately install the bridge on an already-expanded node; use the Full image directly. {slot_instructions}The Full image has {full_mota_blocks} blocks and needs a seeder with at least {full_mota_blocks * 4} bytes of proof scratch. Older 4 KiB-scratch seeders cannot serve more than 1024 blocks. Verify that a capable seeder lists the Full image before installing the bridge. The resized SPIFFS may reset saved radio settings. Before stage one, know the old receiver's compiled default radio profile and arrange a seeder on that profile for stage two; a remote node that cannot hear the seeder cannot finish its LoRa migration. 1. Make `lora-bridge.mota` available from a compatible mOTA seeder. On the old node 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; {handoff}. It then reboots into the preserved old LoRa receiver in the opposite slot. 3. Serve `full-application.mota`; again use `ota ls`, `ota pull flash`, then `ota install`. The old receiver 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 image. `target-partitions.bin` is included for verification only; do not upload it to the browser. Neither bridge is a USB merged image. """ else: full_mota_note = ( "Do not upload `full-application.mota` until the expanded layout " "and new Full image are in place." if has_full_mota else "This ZIP does not include `full-application.mota`: the Full image " "exceeds the current LoRa mOTA 2 KiB x 1024-block serving limit. " "Use `full-application.bin` through Wi-Fi." ) lora_instructions = f"""## LoRa limitation This package has **no LoRa bridge**. The Wi-Fi updater in the installed old firmware is required. On 4 MiB flash, the expanded app1 overlaps the old app1, so the current LoRa receiver-copy method cannot survive a power loss safely. Some older room-server images also have no LoRa mOTA receiver. Do not upload the Full image through LoRa before the new layout is active. {full_mota_note} `target-partitions.bin` is for verification only, not browser upload. """ return f"""# {board} in-place partition migration Source: `{source}`; firmware version: `{version}`. This package is only for the physical board/role represented by `{spec['target']}` with {spec['flash_bytes'] // 1048576} MiB flash. A legacy Wi-Fi-updatable observer or bridge variant on the same hardware may migrate to this canonical Full target; the Wi-Fi bridge stages its saved node/radio configuration and ACL when NVS has enough room, and refuses migration otherwise. The older LoRa handoff bridge preserves only the private identity. The LoRa route, where present, requires the old image's exact target ID. 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, preserves a bootable old receiver, and changes the partition table. On 8/16 MiB LoRa and Wi-Fi routes the bridge restores and verifies the identity in expanded SPIFFS; on the 4 MiB LoRa route the new Full firmware does so at first boot. No full-chip erase is used. The Wi-Fi/LoRa routes need no USB connection; the separate USB route below is managed by an identity-verified host flasher. The table sector and inactive app sectors are erased as part of migration. SPIFFS may be reformatted. The Wi-Fi bridge restores verified radio 1 and radio 2 settings, node name, ACL and login replay state, region keys, and selected network/OTA settings. Temporary radio sessions and logs are not preserved. The older LoRa handoff route may reset saved settings. The stock bootloader has no atomic backup partition table: power loss during the table-sector erase/write can still require cable recovery. Do not use this on an inaccessible node without accepting that risk and testing a sacrificial example of the same board first. {wifi_instructions} {usb_instructions} {expander_instructions} {lora_instructions} """ def package_board(name: str, spec: dict, build_dir: Path, output_dir: Path, version: str, source: str, pio_build_dir: Path | None = None) -> Path: utility_build_dir = resolve_pio_build_dir(pio_build_dir) target = spec["target"] target_id = target_id_for_env(target) hw_id = hardware_id_for_env(target) images = sorted(build_dir.glob(f"{target}-full-*-{version}-{source}.bin")) if len(images) != 1: raise ValueError(f"{name}: expected one exact-target Full image, found {len(images)}") stem = images[0].stem 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 = (utility_build_dir / spec["wifi_bridge"] / "firmware.bin").read_bytes() usb_bridge = (utility_build_dir / spec["usb_bridge"] / "firmware.bin").read_bytes() validate_usb_bridge(usb_bridge, spec) bridge_ident = FwIdent(pack_version(version.split("-", 1)[0]), target_id, hw_id) if len(wifi_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, "usb-bridge.bin": usb_bridge, "full-application.bin": full_image, "target-partitions.bin": table, "capabilities.json": capability_path.read_bytes(), } meshtext_notice = validate_meshtext_notice(build_dir / stem) if meshtext_notice is not None: files[meshtext_notice.name] = meshtext_notice.read_bytes() if spec.get("expander_bridge"): expander_body = (utility_build_dir / spec["expander_bridge"] / "firmware.bin").read_bytes() expander_image = bridge_with_successor_endf(expander_body, bridge_ident) if len(expander_image) > LEGACY_SLOT_BYTES: raise ValueError(f"{name}: Partition Expander does not fit the legacy slot") expander_mota = mota_full(expander_image, bridge_ident, 1024) check_esp32_stage(expander_mota, expander_image, LEGACY_SLOT_BYTES) files["partition-expander.bin"] = expander_body files["partition-expander.mota"] = expander_mota full_mota_blocks = (len(full_image) + 2047) // 2048 if full_mota_blocks <= 4096: candidate_mota = mota_full(full_image, full_ident, 2048) try: check_esp32_stage(candidate_mota, full_image, slot_bytes) except ValueError: if spec["lora_bridge"]: raise else: files["full-application.mota"] = candidate_mota elif spec["lora_bridge"]: raise ValueError(f"{name}: LoRa Full image exceeds the mOTA block limit") if spec["lora_bridge"]: lora_bridge_body = (utility_build_dir / spec["lora_bridge"] / "firmware.bin").read_bytes() lora_bridge = bridge_with_successor_endf(lora_bridge_body, bridge_ident) if len(lora_bridge) > LEGACY_SLOT_BYTES: raise ValueError(f"{name}: LoRa bridge does not fit the legacy slot") files["lora-bridge.mota"] = mota_full(lora_bridge, bridge_ident, 1024) check_esp32_stage(files["lora-bridge.mota"], lora_bridge, LEGACY_SLOT_BYTES) files["README.md"] = readme(name, spec, version, source, "full-application.mota" in files, full_mota_blocks).encode() manifest = { "board": name, "role": spec["role"], "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, "atomic_power_loss_recovery": False, "usb_migration": { "format": "meshcore-usb-partition-migration-v1", "bridge": "usb-bridge.bin", "environment": spec["usb_bridge"], "chip": spec["usb_chip"], "flash_bytes": spec["flash_bytes"], "automatic_rom_return": spec["usb_chip"] != 0, "uart_completion_status": spec["usb_chip"] == 0, "host_software_bootloader_entry": True, "application_usb_cdc": False, "completion": "verified-target-table-nvs-restoration-otadata", }, "lora_bridge_mode": ("slot-b-only-full-identity-recovery" if spec["flash_bytes"] == 4 * 1024 * 1024 else "either-slot-preserve-receiver"), "partition_expander": spec.get("expander_bridge"), "full_mota_seeder_scratch_bytes": full_mota_blocks * 4, "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("--pio-build-dir", type=Path, help="explicit utility build tree (default: actual resolved PlatformIO " "build directory; relative paths use the project root)") 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") parser.add_argument("--board", action="append", choices=tuple(BOARDS), help="board to package; repeat for multiple boards (default: all)") args = parser.parse_args() try: utility_build_dir = resolve_pio_build_dir(args.pio_build_dir) except ValueError as error: parser.error(str(error)) 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) selected = dict.fromkeys(args.board or BOARDS) archives = [package_board(name, BOARDS[name], args.build_dir, args.output_dir, args.version, args.source, utility_build_dir) for name in selected] for archive in archives: print(f"{archive}: {archive.stat().st_size} bytes") if __name__ == "__main__": main()