Expand ESP32 OTA partition migration across infrastructure roles

This commit is contained in:
mikecarper committed 2026-09-24 10:01:14 -07:00
1 parent d68601b358
commit e7f6d529a0
11 files changed
+651 -101

No files matched your search

+20 -7
View File
@@ -45,10 +45,14 @@ def build_steps(boards: list[str], version: str, radio_preset: str,
"--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"):
steps.append((["pio", "run", "-e", BOARDS[name][key],
"-j", str(jobs)], False))
bridge = BOARDS[name][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
@@ -82,13 +86,15 @@ def bundle_release(output_root: Path, package_dir: Path, boards: list[str],
packages.append((matches[0].name, data))
manifest = {
"format": "meshcore-esp32-partition-migration-release-v1",
"scope": "currently-qualified-board-recipes-only",
"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},
@@ -125,7 +131,9 @@ def main() -> None:
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 boards (default: all supported 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,
@@ -133,7 +141,7 @@ def main() -> None:
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 qualified board keys and exit")
help="print configured board/role keys and exit")
args = parser.parse_args()
if args.list_boards:
for name in BOARDS:
@@ -145,11 +153,16 @@ def main() -> None:
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")
boards = list(dict.fromkeys(args.board or BOARDS))
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()
@@ -161,7 +174,7 @@ def main() -> None:
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 complete qualified set (not all historical ESP32 targets) in {output_root}", flush=True)
print(f"Bundle all configured board/role recipes (not historical binaries) in {output_root}", flush=True)
if args.dry_run:
return
+31 -15
View File
@@ -9,24 +9,25 @@ usage() {
cat <<'EOF'
Usage: scripts/build_esp32_partition_migration.sh [options]
With no arguments, choose one qualified ESP32 board or the complete qualified
set from a menu. The complete set creates one ZIP per board plus a release ZIP.
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)
--all Build the complete qualified set
--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 board keys that have verified migration recipes
--list-boards Show configured board/role keys
--help Show this help
Boards without an audited flash/partition/identity plan are not offered. A
name appearing in an old firmware release is not proof that OTA migration is
safe on that hardware. No release ZIP is published unless every selected
package passes the recipe's checks.
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
}
@@ -41,6 +42,7 @@ profile=cascade
jobs=
output_root=
boards=
role=
all=0
dry_run=0
interactive=1
@@ -48,13 +50,14 @@ interactive=1
while [ "$#" -gt 0 ]; do
interactive=0
case "$1" in
--board|--version|--radio-preset|--profile|--jobs|--output-root)
--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 ;;
@@ -72,24 +75,30 @@ done
board_keys=$(python3 -B "$recipe" --list-boards)
if [ -z "$board_keys" ]; then
echo "No qualified boards are configured" >&2
echo "No boards are configured" >&2
exit 1
fi
if [ "$interactive" -eq 1 ]; then
echo "ESP32 in-place partition migration"
echo "Only audited board/layout recipes are listed."
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 qualified boards and create a release ZIP"
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
@@ -115,8 +124,9 @@ if [ "$interactive" -eq 1 ]; then
profile=${selected_profile:-cascade}
fi
if [ "$all" -eq 1 ] && [ -n "$boards" ]; then
echo "Choose either --all or --board, not both" >&2
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
@@ -127,6 +137,11 @@ 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
@@ -136,12 +151,13 @@ if [ -n "$boards" ]; then
if [ "$board" = "$known" ]; then found=1; break; fi
done
if [ "$found" -ne 1 ]; then
echo "Board is not qualified for this recipe: $board" >&2
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
+307 -29
View File
@@ -39,7 +39,234 @@ BOARDS = {
"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_wifi_only_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 the chip-family Wi-Fi bridge. 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_wifi_only_roles(family, flash_mib, roles)
add_wifi_only_roles("heltec-v2", 8, {
"repeater": "Heltec_v2_repeater",
"room-server": "Heltec_v2_room_server",
}, bridge="esp32_8mb_partition_migrator")
add_wifi_only_roles("heltec-ct62", 4, {
"repeater": "Heltec_ct62_repeater",
"sensor": "Heltec_ct62_sensor",
}, bridge="esp32_c3_4mb_partition_migrator")
add_wifi_only_roles("xiao-c3", 4, {
"repeater": "Xiao_C3_repeater",
"room-server": "Xiao_C3_room_server",
}, bridge="esp32_c3_4mb_partition_migrator")
add_wifi_only_roles("tenstar-c3-sx1262", 4, {
"repeater": "Tenstar_C3_sx1262_repeater",
}, bridge="esp32_c3_4mb_partition_migrator")
add_wifi_only_roles("tenstar-c3-sx1268", 4, {
"repeater": "Tenstar_C3_sx1268_repeater",
}, bridge="esp32_c3_4mb_partition_migrator")
add_wifi_only_roles("generic-e22-sx1262", 4, {
"repeater": "Generic_E22_sx1262_repeater",
}, bridge="esp32_4mb_partition_migrator")
add_wifi_only_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"]))
LEGACY_SLOT_BYTES = 0x140000
@@ -92,29 +319,19 @@ def check_esp32_stage(package: bytes, image: bytes, slot_bytes: int) -> None:
raise ValueError("mOTA image cannot be staged in the inactive ESP32 slot")
def readme(board: str, spec: dict, version: str, source: str) -> str:
return f"""# {board} in-place partition migration
def readme(board: str, spec: dict, version: str, source: str,
has_full_mota: bool = True) -> str:
wifi_instructions = f"""## Wi-Fi route
Source: `{source}`; firmware version: `{version}`. This package is only for the
exact `{spec['target']}` board/role with {spec['flash_bytes'] // 1048576} MiB flash.
The current partition table must have stable NVS at 0x9000, OTA metadata at
0xE000, two OTA apps, and SPIFFS with `/identity/_main.id`.
The bridge checks the live layout and refuses unsupported boards. It stages
the 96-byte public/private identity in unchanged NVS, copies and verifies the
required application, changes the partition table, then restores and verifies
the identity in expanded SPIFFS. No full-chip erase or USB connection is used.
The table sector and inactive app sectors are erased as part of migration.
SPIFFS may be reformatted; other SPIFFS settings can be recreated.
## Wi-Fi route
1. Upload `wifi-bridge.bin` through the existing repeater's Wi-Fi OTA page.
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`.
## LoRa route
"""
if spec["lora_bridge"]:
lora_instructions = """## LoRa route
The old repeater must already have working MeshCore LoRa mOTA, a valid EndF,
and the exact target ID in `manifest.json`. A stock image without that updater
@@ -131,8 +348,51 @@ table and refuses if its image or target does not match.
A full-image LoRa transfer can take considerable time.
`full-application.bin` and `full-application.mota` are the same exact-target
Full repeater image. `target-partitions.bin` is included for verification only;
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;
its previous feature settings are not preserved. 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, copies and verifies the
required application, changes the partition table, then restores and verifies
the identity in expanded SPIFFS. No full-chip erase or USB connection is used.
The table sector and inactive app sectors are erased as part of migration.
SPIFFS may be reformatted; other SPIFFS settings can be recreated.
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}
{lora_instructions}
"""
@@ -141,7 +401,10 @@ def package_board(name: str, spec: dict, build_dir: Path, output_dir: Path,
target = spec["target"]
target_id = target_id_for_env(target)
hw_id = hardware_id_for_env(target)
stem = f"{target}-full-usb-wifi-ota-{version}-{source}"
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)
@@ -172,27 +435,41 @@ def package_board(name: str, spec: dict, build_dir: Path, output_dir: Path,
raise ValueError(f"{name}: Full image identity or partition fit failed")
wifi_bridge = (ROOT / ".pio" / "build" / spec["wifi_bridge"] / "firmware.bin").read_bytes()
lora_bridge_body = (ROOT / ".pio" / "build" / spec["lora_bridge"] / "firmware.bin").read_bytes()
bridge_ident = FwIdent(pack_version(version.split("-", 1)[0]), target_id, hw_id)
lora_bridge, _ = ensure_endf(lora_bridge_body, bridge_ident)
if max(len(wifi_bridge), len(lora_bridge)) > LEGACY_SLOT_BYTES:
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,
"lora-bridge.mota": mota_full(lora_bridge, bridge_ident, 1024),
"full-application.bin": full_image,
"full-application.mota": mota_full(full_image, full_ident, 2048),
"target-partitions.bin": table,
"capabilities.json": capability_path.read_bytes(),
}
check_esp32_stage(files["lora-bridge.mota"], lora_bridge, LEGACY_SLOT_BYTES)
check_esp32_stage(files["full-application.mota"], full_image, slot_bytes)
files["README.md"] = readme(name, spec, version, source).encode()
if (len(full_image) + 2047) // 2048 <= 1024:
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, _ = ensure_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).encode()
manifest = {
"board": name,
"role": spec["role"],
"target": target,
"target_id": f"0x{target_id:08x}",
"hardware_id": hw_id,
@@ -201,6 +478,7 @@ def package_board(name: str, spec: dict, build_dir: Path, output_dir: Path,
"flash_bytes": spec["flash_bytes"],
"legacy_slot_bytes": LEGACY_SLOT_BYTES,
"expanded_slot_bytes": slot_bytes,
"atomic_power_loss_recovery": False,
"files": {filename: {"bytes": len(data), "sha256": sha256(data)}
for filename, data in files.items()},
}