Merge branch 'keymindCascade' of github.com:mikecarper/MeshCore into keymindCascade

# Conflicts:
#	test/test_nrf52_ble_startup.py
This commit is contained in:
mikecarper
2026-09-24 17:36:09 -07:00
50 changed files with 3450 additions and 261 deletions
+231
View File
@@ -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()
+164
View File
@@ -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()