mirror of
https://github.com/agessaman/MeshCore.git
synced 2026-09-17 01:34:55 +00:00
tools/append_provision.py appends [magic MCPV1\0][uint16-LE len][payload] [uint32-LE CRC32] to a built app .bin (replacing any existing trailer); esptool flashes the whole file so the trailer lands in the app partition right after the image. At boot, if neither /provision nor /provision_done exists, the firmware walks the running image's segment table to find the image end, validates magic + CRC + the provision header, and writes the payload to /provision — the existing auto-apply then runs it. 'provision' status reports trailer presence. Consequences documented in PROVISIONING.md: 'provision remove' (or erase) + reboot re-provisions from the trailer, and the trailer does not survive an OTA to the other slot. nRF52/RP2040 are explicitly unsupported (signed DFU / no UF2 side channel). CRC32 is a local zlib-compatible implementation so the firmware doesn't depend on which ROM CRC variant a given Arduino core exposes. The image- end walk was verified byte-exact against real esptool output for both Heltec_v3 build flavors.
111 lines
4.3 KiB
Python
111 lines
4.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Append (or replace) a MeshCore provision trailer on an ESP32 app .bin.
|
|
|
|
The trailer is appended verbatim to the end of the built image:
|
|
|
|
b"MCPV1\\x00" + uint16-LE payload length + payload + uint32-LE CRC32(payload)
|
|
|
|
esptool flashes the whole file, so the trailer lands in the app partition right
|
|
after the image. On first boot (no /provision, no /provision_done marker) the
|
|
firmware validates the trailer and copies the payload to /provision, then the
|
|
normal boot auto-apply runs it. See PROVISIONING.md.
|
|
|
|
ESP32-only: nRF52 DFU zips are signed against the exact image and RP2040 UF2
|
|
has no equivalent side channel.
|
|
|
|
Usage:
|
|
tools/append_provision.py firmware.bin region_defaults.txt # in place
|
|
tools/append_provision.py firmware.bin region_defaults.txt -o out.bin
|
|
"""
|
|
|
|
import argparse
|
|
import struct
|
|
import sys
|
|
import zlib
|
|
|
|
MAGIC = b"MCPV1\x00"
|
|
MAX_SIZE = 4096
|
|
MAX_LINE = 159
|
|
HEADER_PREFIX = b"#meshcore-provision v"
|
|
ESP_IMAGE_MAGIC = 0xE9
|
|
|
|
|
|
def die(msg):
|
|
print(f"error: {msg}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
def validate_provision(data: bytes, name: str):
|
|
if len(data) == 0:
|
|
die(f"{name} is empty")
|
|
if len(data) > MAX_SIZE:
|
|
die(f"{name} is {len(data)} bytes; the firmware caps /provision at {MAX_SIZE}")
|
|
lines = data.split(b"\n")
|
|
first = next((l.strip() for l in lines if l.strip()), None)
|
|
if first is None or not first.startswith(HEADER_PREFIX):
|
|
die(f"{name}: first non-blank line must start with '{HEADER_PREFIX.decode()}1'")
|
|
ver = first[len(HEADER_PREFIX):].split()[0] if len(first) > len(HEADER_PREFIX) else b""
|
|
if not ver.isdigit() or int(ver) != 1:
|
|
die(f"{name}: unsupported provision version '{ver.decode(errors='replace')}' (expected 1)")
|
|
for i, l in enumerate(lines, 1):
|
|
if len(l.rstrip(b"\r")) > MAX_LINE:
|
|
die(f"{name}: line {i} is {len(l.rstrip(b'\r'))} chars; the firmware caps lines at {MAX_LINE}")
|
|
|
|
|
|
def parse_trailer_at_end(blob: bytes):
|
|
"""Return the trailer's start offset if blob ends with a valid trailer, else None."""
|
|
# magic(6) + len(2) + payload + crc(4); search the tail window for candidates
|
|
window_start = max(0, len(blob) - (len(MAGIC) + 2 + MAX_SIZE + 4))
|
|
idx = blob.rfind(MAGIC, window_start)
|
|
while idx != -1:
|
|
cand = blob[idx:]
|
|
if len(cand) >= len(MAGIC) + 2 + 4:
|
|
(plen,) = struct.unpack_from("<H", cand, len(MAGIC))
|
|
if len(cand) == len(MAGIC) + 2 + plen + 4:
|
|
payload = cand[len(MAGIC) + 2:len(MAGIC) + 2 + plen]
|
|
(crc,) = struct.unpack_from("<I", cand, len(MAGIC) + 2 + plen)
|
|
if zlib.crc32(payload) & 0xFFFFFFFF == crc:
|
|
return idx
|
|
idx = blob.rfind(MAGIC, window_start, idx)
|
|
return None
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0],
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog="\n".join(__doc__.split("\n")[1:]))
|
|
ap.add_argument("bin", help="built ESP32 app image (.bin)")
|
|
ap.add_argument("provision", help="provision text file (#meshcore-provision v1 header)")
|
|
ap.add_argument("-o", "--output", help="write result here instead of modifying BIN in place")
|
|
ap.add_argument("--force", action="store_true",
|
|
help="skip the ESP32 image magic-byte sanity check")
|
|
args = ap.parse_args()
|
|
|
|
with open(args.bin, "rb") as f:
|
|
blob = f.read()
|
|
with open(args.provision, "rb") as f:
|
|
payload = f.read()
|
|
|
|
if not args.force and (len(blob) == 0 or blob[0] != ESP_IMAGE_MAGIC):
|
|
die(f"{args.bin} does not look like an ESP32 app image "
|
|
f"(first byte is not 0x{ESP_IMAGE_MAGIC:02X}); use --force to override")
|
|
|
|
validate_provision(payload, args.provision)
|
|
|
|
existing = parse_trailer_at_end(blob)
|
|
if existing is not None:
|
|
print(f"replacing existing trailer at offset {existing}")
|
|
blob = blob[:existing]
|
|
|
|
trailer = MAGIC + struct.pack("<H", len(payload)) + payload + \
|
|
struct.pack("<I", zlib.crc32(payload) & 0xFFFFFFFF)
|
|
out_path = args.output or args.bin
|
|
with open(out_path, "wb") as f:
|
|
f.write(blob + trailer)
|
|
print(f"wrote {out_path}: image {len(blob)} bytes + trailer {len(trailer)} bytes "
|
|
f"(payload {len(payload)} bytes)")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|