mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-09-26 16:27:57 +00:00
Merge branch 'keymindCascade' of github.com:mikecarper/MeshCore into keymindCascade
# Conflicts: # test/test_nrf52_ble_startup.py
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build verified in-place Wi-Fi and LoRa ESP32 partition-migration ZIPs.
|
||||
|
||||
Run in Linux/WSL with PlatformIO installed. PlatformIO steps are deliberately
|
||||
serial: build.sh cleans the shared .pio/build tree between Full targets.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import zipfile
|
||||
|
||||
from package_esp32_partition_migration import BOARDS
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def git_output(*args: str) -> str:
|
||||
# This Windows checkout uses core.autocrlf=input. WSL often lacks that
|
||||
# global setting and would misreport unchanged CRLF files as dirty.
|
||||
return subprocess.check_output(
|
||||
["git", "-c", "core.autocrlf=input", *args], cwd=ROOT, text=True
|
||||
).strip()
|
||||
|
||||
|
||||
def build_steps(boards: list[str], version: str, radio_preset: str,
|
||||
profile: str, jobs: int) -> list[tuple[list[str], bool]]:
|
||||
steps: list[tuple[list[str], bool]] = []
|
||||
for name in boards:
|
||||
steps.append(([
|
||||
"bash", "build.sh", "build-firmware", BOARDS[name]["target"],
|
||||
"--full-exact", "--firmware-version", version,
|
||||
"--radio-preset", radio_preset, "--profile", profile,
|
||||
"--require-ota", "--resume",
|
||||
], True))
|
||||
# Full builds can clear .pio/build; every bridge must be built afterwards.
|
||||
built_bridges: set[str] = set()
|
||||
for name in boards:
|
||||
for key in ("wifi_bridge", "lora_bridge", "expander_bridge"):
|
||||
bridge = BOARDS[name].get(key)
|
||||
if bridge and bridge not in built_bridges:
|
||||
steps.append((["pio", "run", "-e", bridge,
|
||||
"-j", str(jobs)], False))
|
||||
built_bridges.add(bridge)
|
||||
return steps
|
||||
|
||||
|
||||
def verify_archive(path: Path, board: str, version: str, source: str) -> None:
|
||||
with zipfile.ZipFile(path) as archive:
|
||||
damaged = archive.testzip()
|
||||
if damaged:
|
||||
raise ValueError(f"{path}: damaged ZIP entry {damaged}")
|
||||
manifest = json.loads(archive.read("manifest.json"))
|
||||
if (manifest["board"] != board or manifest["firmware_version"] != version
|
||||
or manifest["source_commit"] != source):
|
||||
raise ValueError(f"{path}: package identity does not match recipe")
|
||||
for filename, expected in manifest["files"].items():
|
||||
data = archive.read(filename)
|
||||
if (len(data) != expected["bytes"]
|
||||
or hashlib.sha256(data).hexdigest() != expected["sha256"]):
|
||||
raise ValueError(f"{path}: invalid {filename}")
|
||||
|
||||
|
||||
def bundle_release(output_root: Path, package_dir: Path, boards: list[str],
|
||||
version: str, source: str, radio_preset: str,
|
||||
profile: str) -> Path:
|
||||
"""Atomically publish the complete, exact-source migration set."""
|
||||
packages = []
|
||||
for board in boards:
|
||||
matches = list(package_dir.glob(f"{board}-{version}-{source}-migration.zip"))
|
||||
if len(matches) != 1:
|
||||
raise ValueError(f"release is missing {board}")
|
||||
verify_archive(matches[0], board, version, source)
|
||||
data = matches[0].read_bytes()
|
||||
packages.append((matches[0].name, data))
|
||||
manifest = {
|
||||
"format": "meshcore-esp32-partition-migration-release-v1",
|
||||
"scope": "configured-legacy-layout-board-role-recipes",
|
||||
"historical_firmware_binaries_included": False,
|
||||
"source_commit": source,
|
||||
"firmware_version": version,
|
||||
"radio_preset": radio_preset,
|
||||
"profile": profile,
|
||||
"boards": boards,
|
||||
"board_roles": {name: BOARDS[name]["role"] for name in boards},
|
||||
"roles": sorted({BOARDS[name]["role"] for name in boards}),
|
||||
"packages": {name: {"bytes": len(data),
|
||||
"sha256": hashlib.sha256(data).hexdigest()}
|
||||
for name, data in packages},
|
||||
}
|
||||
archive = output_root / f"esp32-partition-migration-{version}-{source}-release.zip"
|
||||
with tempfile.NamedTemporaryFile(prefix="migration-release-", suffix=".zip",
|
||||
dir=output_root, delete=False) as temporary:
|
||||
temporary_path = Path(temporary.name)
|
||||
try:
|
||||
with zipfile.ZipFile(temporary_path, "w", compression=zipfile.ZIP_STORED) as result:
|
||||
result.writestr("release-manifest.json", json.dumps(manifest, indent=2) + "\n")
|
||||
for name, data in packages:
|
||||
result.writestr(name, data)
|
||||
with zipfile.ZipFile(temporary_path) as result:
|
||||
if result.testzip() or set(result.namelist()) != {"release-manifest.json"} | {
|
||||
name for name, _ in packages}:
|
||||
raise ValueError("release ZIP failed integrity check")
|
||||
if archive.exists():
|
||||
if hashlib.sha256(archive.read_bytes()).digest() != \
|
||||
hashlib.sha256(temporary_path.read_bytes()).digest():
|
||||
raise ValueError(f"existing release differs: {archive}")
|
||||
else:
|
||||
temporary_path.replace(archive)
|
||||
finally:
|
||||
temporary_path.unlink(missing_ok=True)
|
||||
return archive
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--version", help="firmware release version")
|
||||
parser.add_argument("--radio-preset",
|
||||
help="radio preset passed to build.sh")
|
||||
parser.add_argument("--profile", choices=("default", "cascade"),
|
||||
default="default", help="embedded runtime profile")
|
||||
parser.add_argument("--board", action="append", choices=tuple(BOARDS),
|
||||
help="repeat to select board/role recipes (default: all configured recipes)")
|
||||
parser.add_argument("--role", choices=("repeater", "room-server", "sensor"),
|
||||
help="build every configured board for this role")
|
||||
parser.add_argument("--jobs", type=int, default=4,
|
||||
help="workers within each serial PlatformIO build")
|
||||
parser.add_argument("--output-root", type=Path,
|
||||
help="release folder (default: .releases/esp32-expanded-<commit>)")
|
||||
parser.add_argument("--dry-run", action="store_true",
|
||||
help="show the build order without writing files")
|
||||
parser.add_argument("--list-boards", action="store_true",
|
||||
help="print configured board/role keys and exit")
|
||||
args = parser.parse_args()
|
||||
if args.list_boards:
|
||||
for name in BOARDS:
|
||||
print(name)
|
||||
return
|
||||
if not args.version or not args.radio_preset:
|
||||
parser.error("--version and --radio-preset are required for a build")
|
||||
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._+\-]*", args.version):
|
||||
parser.error("--version must be a filename-safe version token")
|
||||
if args.jobs < 1:
|
||||
parser.error("--jobs must be positive")
|
||||
if args.board and args.role:
|
||||
parser.error("choose --board or --role, not both")
|
||||
if os.name == "nt" and not args.dry_run:
|
||||
parser.error("run this recipe inside Linux/WSL, where bash and pio are available")
|
||||
|
||||
source = git_output("rev-parse", "--short=8", "HEAD")
|
||||
if args.role:
|
||||
boards = [name for name, spec in BOARDS.items() if spec["role"] == args.role]
|
||||
else:
|
||||
boards = list(dict.fromkeys(args.board or BOARDS))
|
||||
requested_root = args.output_root or Path(".releases") / f"esp32-expanded-{source}"
|
||||
output_root = (requested_root if requested_root.is_absolute()
|
||||
else ROOT / requested_root).resolve()
|
||||
build_dir = output_root / "builds"
|
||||
package_dir = output_root / "packages"
|
||||
steps = build_steps(boards, args.version, args.radio_preset, args.profile, args.jobs)
|
||||
for command, full in steps:
|
||||
prefix = f"OUTPUT_DIR={shlex.quote(str(build_dir))} " if full else ""
|
||||
print(prefix + shlex.join(command), flush=True)
|
||||
print(f"Package {', '.join(boards)} into {package_dir}", flush=True)
|
||||
if set(boards) == set(BOARDS):
|
||||
print(f"Bundle all configured board/role recipes (not historical binaries) in {output_root}", flush=True)
|
||||
if args.dry_run:
|
||||
return
|
||||
|
||||
if git_output("status", "--porcelain", "--untracked-files=normal"):
|
||||
parser.error("commit or remove working-tree changes before building a versioned package")
|
||||
for executable in ("bash", "pio"):
|
||||
if not shutil.which(executable):
|
||||
parser.error(f"{executable} is unavailable in this environment")
|
||||
|
||||
output_root.mkdir(parents=True, exist_ok=True)
|
||||
for command, full in steps:
|
||||
environment = os.environ.copy()
|
||||
if full:
|
||||
environment["OUTPUT_DIR"] = str(build_dir)
|
||||
subprocess.run(command, cwd=ROOT, env=environment, check=True)
|
||||
|
||||
# Package in a fresh directory so an interruption never leaves a partial
|
||||
# release ZIP at the final path. Existing identical ZIPs are reusable.
|
||||
with tempfile.TemporaryDirectory(prefix="migration-package-", dir=output_root) as temporary:
|
||||
staging = Path(temporary) / "packages"
|
||||
command = [
|
||||
sys.executable, "-B", "scripts/package_esp32_partition_migration.py",
|
||||
"--build-dir", str(build_dir), "--output-dir", str(staging),
|
||||
"--version", args.version, "--source", source,
|
||||
]
|
||||
for name in boards:
|
||||
command.extend(("--board", name))
|
||||
subprocess.run(command, cwd=ROOT, check=True)
|
||||
archives = sorted(staging.glob("*.zip"))
|
||||
if len(archives) != len(boards):
|
||||
raise ValueError("packager did not produce one ZIP per selected board")
|
||||
package_dir.mkdir(parents=True, exist_ok=True)
|
||||
for name in boards:
|
||||
matches = [path for path in archives if path.name.startswith(name + "-")]
|
||||
if len(matches) != 1:
|
||||
raise ValueError(f"expected exactly one {name} ZIP")
|
||||
staged = matches[0]
|
||||
verify_archive(staged, name, args.version, source)
|
||||
destination = package_dir / staged.name
|
||||
if destination.exists():
|
||||
if hashlib.sha256(destination.read_bytes()).digest() != \
|
||||
hashlib.sha256(staged.read_bytes()).digest():
|
||||
raise ValueError(f"existing package differs: {destination}")
|
||||
else:
|
||||
staged.replace(destination)
|
||||
print(f"Ready: {destination}", flush=True)
|
||||
if set(boards) == set(BOARDS):
|
||||
print("Release: " + str(bundle_release(
|
||||
output_root, package_dir, boards, args.version, source,
|
||||
args.radio_preset, args.profile)), flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+164
@@ -0,0 +1,164 @@
|
||||
#!/bin/sh
|
||||
# Interactive front end for the verified ESP32 in-place migration recipe.
|
||||
set -eu
|
||||
|
||||
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
recipe=$script_dir/build_esp32_partition_migration.py
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: scripts/build_esp32_partition_migration.sh [options]
|
||||
|
||||
With no arguments, choose one ESP32 board/role or the complete configured
|
||||
set from a menu. The complete set creates one ZIP per board/role plus a release ZIP.
|
||||
|
||||
--board KEY Build one board (repeatable)
|
||||
--role ROLE Build every repeater, room-server, or sensor board
|
||||
--all Build all configured repeater, room-server and sensor roles
|
||||
--version VERSION Firmware version (required without the menu)
|
||||
--radio-preset NAME Radio preset (required without the menu)
|
||||
--profile NAME default or cascade (default: cascade)
|
||||
--jobs N PlatformIO workers within each serial build
|
||||
--output-root PATH Release output folder
|
||||
--dry-run Print commands without building
|
||||
--list-boards Show configured board/role keys
|
||||
--help Show this help
|
||||
|
||||
Boards with unrelated source layout plans are not offered. A name appearing
|
||||
in an old firmware release is not proof that OTA migration is safe on that
|
||||
hardware. The bridge checks the live layout, and no release ZIP is published
|
||||
unless every selected package passes the recipe's checks.
|
||||
EOF
|
||||
}
|
||||
|
||||
if ! command -v python3 >/dev/null 2>&1; then
|
||||
echo "python3 is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
version=
|
||||
radio_preset=
|
||||
profile=cascade
|
||||
jobs=
|
||||
output_root=
|
||||
boards=
|
||||
role=
|
||||
all=0
|
||||
dry_run=0
|
||||
interactive=1
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
interactive=0
|
||||
case "$1" in
|
||||
--board|--role|--version|--radio-preset|--profile|--jobs|--output-root)
|
||||
if [ "$#" -lt 2 ]; then
|
||||
echo "Missing value for $1" >&2
|
||||
exit 2
|
||||
fi
|
||||
case "$1" in
|
||||
--board) boards="${boards}${boards:+ }$2" ;;
|
||||
--role) role=$2 ;;
|
||||
--version) version=$2 ;;
|
||||
--radio-preset) radio_preset=$2 ;;
|
||||
--profile) profile=$2 ;;
|
||||
--jobs) jobs=$2 ;;
|
||||
--output-root) output_root=$2 ;;
|
||||
esac
|
||||
shift 2 ;;
|
||||
--all) all=1; shift ;;
|
||||
--dry-run) dry_run=1; shift ;;
|
||||
--list-boards) exec python3 -B "$recipe" --list-boards ;;
|
||||
--help|-h) usage; exit 0 ;;
|
||||
*) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
board_keys=$(python3 -B "$recipe" --list-boards)
|
||||
if [ -z "$board_keys" ]; then
|
||||
echo "No boards are configured" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$interactive" -eq 1 ]; then
|
||||
echo "ESP32 in-place partition migration"
|
||||
echo "Repeater, room-server and sensor recipes are listed."
|
||||
index=1
|
||||
for board in $board_keys; do
|
||||
echo " $index) $board"
|
||||
index=$((index + 1))
|
||||
done
|
||||
echo " a) Build all configured board/role recipes and create a release ZIP"
|
||||
echo " r) Build all repeaters"
|
||||
echo " m) Build all room servers"
|
||||
echo " s) Build all sensors"
|
||||
echo " q) Quit"
|
||||
printf 'Choose: '
|
||||
IFS= read -r choice
|
||||
case "$choice" in
|
||||
a|A|all) all=1 ;;
|
||||
r|R) role=repeater ;;
|
||||
m|M) role=room-server ;;
|
||||
s|S) role=sensor ;;
|
||||
q|Q|quit) exit 0 ;;
|
||||
*)
|
||||
index=1
|
||||
for board in $board_keys; do
|
||||
if [ "$choice" = "$index" ]; then
|
||||
boards=$board
|
||||
break
|
||||
fi
|
||||
index=$((index + 1))
|
||||
done
|
||||
if [ -z "$boards" ]; then
|
||||
echo "Invalid selection" >&2
|
||||
exit 2
|
||||
fi ;;
|
||||
esac
|
||||
printf 'Firmware version: '
|
||||
IFS= read -r version
|
||||
printf 'Radio preset [usa-cascadia]: '
|
||||
IFS= read -r radio_preset
|
||||
radio_preset=${radio_preset:-usa-cascadia}
|
||||
printf 'Profile [cascade]: '
|
||||
IFS= read -r selected_profile
|
||||
profile=${selected_profile:-cascade}
|
||||
fi
|
||||
|
||||
if { [ "$all" -eq 1 ] && { [ -n "$boards" ] || [ -n "$role" ]; }; } \
|
||||
|| { [ -n "$boards" ] && [ -n "$role" ]; }; then
|
||||
echo "Choose only one of --all, --board, or --role" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [ -z "$version" ] || [ -z "$radio_preset" ]; then
|
||||
echo "Firmware version and radio preset are required" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [ "$profile" != default ] && [ "$profile" != cascade ]; then
|
||||
echo "Profile must be default or cascade" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [ -n "$role" ] && [ "$role" != repeater ] \
|
||||
&& [ "$role" != room-server ] && [ "$role" != sensor ]; then
|
||||
echo "Role must be repeater, room-server, or sensor" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
set -- "$recipe" --version "$version" --radio-preset "$radio_preset" --profile "$profile"
|
||||
if [ -n "$boards" ]; then
|
||||
for board in $boards; do
|
||||
found=0
|
||||
for known in $board_keys; do
|
||||
if [ "$board" = "$known" ]; then found=1; break; fi
|
||||
done
|
||||
if [ "$found" -ne 1 ]; then
|
||||
echo "Board/role is not configured for this recipe: $board" >&2
|
||||
exit 2
|
||||
fi
|
||||
set -- "$@" --board "$board"
|
||||
done
|
||||
fi
|
||||
if [ -n "$role" ]; then set -- "$@" --role "$role"; fi
|
||||
if [ -n "$jobs" ]; then set -- "$@" --jobs "$jobs"; fi
|
||||
if [ -n "$output_root" ]; then set -- "$@" --output-root "$output_root"; fi
|
||||
if [ "$dry_run" -eq 1 ]; then set -- "$@" --dry-run; fi
|
||||
exec python3 -B "$@"
|
||||
@@ -0,0 +1,624 @@
|
||||
#!/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_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
|
||||
|
||||
|
||||
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")
|
||||
|
||||
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"
|
||||
|
||||
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("<II", entry, 4)
|
||||
label = entry[12:28].split(b"\0", 1)[0].decode("ascii")
|
||||
entries[label] = (address, size)
|
||||
return entries
|
||||
|
||||
|
||||
def mota_full(image: bytes, identity: FwIdent, block_size: int) -> 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:
|
||||
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`.
|
||||
"""
|
||||
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 <id> 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 <id> 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 or USB connection is used.
|
||||
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}
|
||||
{expander_instructions}
|
||||
{lora_instructions}
|
||||
"""
|
||||
|
||||
|
||||
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)
|
||||
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 = (ROOT / ".pio" / "build" / spec["wifi_bridge"] / "firmware.bin").read_bytes()
|
||||
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,
|
||||
"full-application.bin": full_image,
|
||||
"target-partitions.bin": table,
|
||||
"capabilities.json": capability_path.read_bytes(),
|
||||
}
|
||||
if spec.get("expander_bridge"):
|
||||
expander_body = (ROOT / ".pio" / "build" / 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 = (ROOT / ".pio" / "build" / 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,
|
||||
"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("--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()
|
||||
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)
|
||||
for name in selected]
|
||||
for archive in archives:
|
||||
print(f"{archive}: {archive.stat().st_size} bytes")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user