diff --git a/examples/esp32_partition_migrator/main.cpp b/examples/esp32_partition_migrator/main.cpp index 5ed9c5ac5..d88805ce2 100644 --- a/examples/esp32_partition_migrator/main.cpp +++ b/examples/esp32_partition_migrator/main.cpp @@ -11,9 +11,6 @@ #include #if defined(MESHCORE_MIGRATION_RESUME_OTA) #include -#ifndef MESHCORE_MIGRATION_EXPECTED_TARGET_ID -#error "LoRa migration requires the exact legacy OTA target ID" -#endif #endif #include @@ -237,6 +234,22 @@ bool copyAndVerify(const esp_partition_t& source, uint32_t destination_address, // both layouts. The source file format is exactly the historical // IdentityStore layout: 32 public-key bytes followed by 64 private-key bytes. bool stageLegacyIdentity() { + // A previous attempt may have reached the future app1 copy before power + // failed. That copy can overlap old SPIFFS on 8/16 MiB boards. Never read + // a regenerated or damaged legacy file over the already staged old key. + Preferences prior_stage; + if (prior_stage.begin(kMigrationNvsNamespace, true)) { + const bool pending = prior_stage.getBool(kMigrationIdentityPendingKey, false); + const size_t saved = pending + ? prior_stage.getBytes(kMigrationIdentityKey, copy_buffer, + kIdentityFileBytes) : 0; + prior_stage.end(); + if (pending) { + if (saved == kIdentityFileBytes) return true; + strcpy(status_text, "Refused: previously staged private key is incomplete"); + return false; + } + } if (!SPIFFS.begin(false)) { strcpy(status_text, "Could not mount legacy SPIFFS to save identity"); return false; @@ -315,8 +328,8 @@ bool restoreStagedIdentity() { strcpy(status_text, "Could not finalize NVS identity recovery"); return false; } - migration_nvs.remove(kMigrationIdentityKey); const bool cleared = migration_nvs.remove(kMigrationIdentityPendingKey); + if (cleared) migration_nvs.remove(kMigrationIdentityKey); migration_nvs.end(); if (!cleared) { strcpy(status_text, "Private key restored; NVS cleanup needs retry"); @@ -333,13 +346,20 @@ uint32_t readLe32(const uint8_t* bytes) { | (static_cast(bytes[3]) << 24); } -// The preserved app must really be the LoRa OTA receiver for this exact -// package. A valid EndF is emitted only for OTA-enabled MeshCore builds, and -// its body hash prevents a stale marker from authorizing a damaged image. -bool validLegacyOtaReceiver(const esp_partition_t& source) { +struct OtaImageIdentity { + uint32_t target_id = 0; + uint8_t hardware_id[32] = {}; +}; + +// The package appends an exact-target EndF to this bridge. Read and verify +// it from the running image, then require the preserved old receiver to have +// that same target and hardware identity. A generic chip-family bridge is +// therefore reusable without accepting a cross-board LoRa handoff. +bool readVerifiedOtaIdentity(const esp_partition_t& source, + OtaImageIdentity& identity) { uint8_t header = 0; if (esp_partition_read(&source, 0, &header, 1) != ESP_OK || header != 0xE9) { - strcpy(status_text, "Refused: old LoRa application image is invalid"); + strcpy(status_text, "Refused: LoRa application image is invalid"); return false; } uint8_t trailer[kEndfBytes]; @@ -354,8 +374,7 @@ bool validLegacyOtaReceiver(const esp_partition_t& source) { if (marker + kEndfBytes > source.size || esp_partition_read(&source, marker, trailer, sizeof(trailer)) != ESP_OK || memcmp(trailer, "EndF", 4) != 0 - || readLe32(trailer + 4) != marker - || readLe32(trailer + 20) != MESHCORE_MIGRATION_EXPECTED_TARGET_ID) { + || readLe32(trailer + 4) != marker) { continue; } mbedtls_sha256_context hash; @@ -372,15 +391,36 @@ bool validLegacyOtaReceiver(const esp_partition_t& source) { valid = valid && mbedtls_sha256_finish_ret(&hash, digest) == 0 && memcmp(digest, trailer + 8, 8) == 0; mbedtls_sha256_free(&hash); - if (valid) return true; - strcpy(status_text, "Refused: old LoRa firmware EndF hash is invalid"); + if (valid) { + identity.target_id = readLe32(trailer + 20); + memcpy(identity.hardware_id, trailer + 24, sizeof(identity.hardware_id)); + if (identity.target_id != 0) return true; + strcpy(status_text, "Refused: LoRa firmware EndF has no target ID"); + return false; + } + strcpy(status_text, "Refused: LoRa firmware EndF hash is invalid"); return false; } } - strcpy(status_text, "Refused: old LoRa firmware has no matching valid EndF"); + strcpy(status_text, "Refused: LoRa firmware has no valid EndF"); return false; } +bool validLegacyOtaReceiver(const esp_partition_t& bridge, + const esp_partition_t& receiver) { + OtaImageIdentity bridge_identity; + OtaImageIdentity receiver_identity; + if (!readVerifiedOtaIdentity(bridge, bridge_identity) + || !readVerifiedOtaIdentity(receiver, receiver_identity)) return false; + if (bridge_identity.target_id != receiver_identity.target_id + || memcmp(bridge_identity.hardware_id, receiver_identity.hardware_id, + sizeof(bridge_identity.hardware_id)) != 0) { + strcpy(status_text, "Refused: old LoRa receiver target does not match bridge"); + return false; + } + return true; +} + bool stageResumeSlot(uint8_t slot) { Preferences migration_nvs; const bool opened = migration_nvs.begin(kMigrationNvsNamespace, false); @@ -507,13 +547,13 @@ void runMigration() { const migration::LoRaResumePlan resume = migration::planLoRaResume( geometry, plan->layout, running->address); if (!resume.valid) { - strcpy(status_text, "Refused: legacy slots cannot preserve LoRa receiver"); + strcpy(status_text, "Refused: LoRa bridge/receiver slot layout is unsafe"); Serial.println(status_text); return; } const esp_partition_t* old_receiver = resume.resume_slot == 0 ? refs.app0 : refs.app1; - if (!validLegacyOtaReceiver(*old_receiver)) { + if (!validLegacyOtaReceiver(*running, *old_receiver)) { Serial.println(status_text); return; } @@ -530,12 +570,16 @@ void runMigration() { // keeps the old application in the other expanded slot, restores identity, // then boots that old application to receive the final image by LoRa. #if defined(MESHCORE_MIGRATION_RESUME_OTA) - Serial.println("Migration: preserving legacy app1 in expanded app1"); - if (!copyAndVerify(*refs.app1, plan->layout.app1_address, - refs.app1->size, "future-app1") - || !stageResumeSlot(resume.resume_slot)) { - Serial.println(status_text); - return; + if (resume.copy_app1) { + Serial.println("Migration: preserving legacy app1 in expanded app1"); + if (!copyAndVerify(*refs.app1, plan->layout.app1_address, + refs.app1->size, "future-app1") + || !stageResumeSlot(resume.resume_slot)) { + Serial.println(status_text); + return; + } + } else { + Serial.println("Migration: old receiver remains in app0; Full firmware will restore the key"); } #else // Never erase a running source range while copying it. @@ -561,23 +605,24 @@ void runMigration() { } #endif - // The OTA-select data records a slot identity. Select the bridge's currently - // registered legacy slot; after the table change, that slot identity points - // at the verified bridge copy under the expanded layout. + // The OTA-select data records a slot identity. On 8/16 MiB LoRa and Wi-Fi + // paths it selects the bridge; the 4 MiB LoRa path selects old receiver A + // because old bridge B cannot survive in either expanded slot. #if defined(MESHCORE_MIGRATION_RESUME_OTA) - const bool bridge_in_app0 = resume.bridge_slot == 0; + const bool boot_in_app0 = resume.copy_app1 + ? resume.bridge_slot == 0 : resume.resume_slot == 0; #else - const bool bridge_in_app0 = true; + const bool boot_in_app0 = true; #endif const esp_err_t select_result = esp_ota_set_boot_partition( - bridge_in_app0 ? refs.app0 : refs.app1); + boot_in_app0 ? refs.app0 : refs.app1); if (select_result != ESP_OK) { - snprintf(status_text, sizeof(status_text), "Could not select bridge slot: %s", + snprintf(status_text, sizeof(status_text), "Could not select migration boot slot: %s", errName(select_result)); Serial.println(status_text); return; } - Serial.println("Migration: bridge slot selected for restart"); + Serial.println("Migration: boot slot selected for restart"); Serial.println("Migration: publishing expanded partition table"); // This bridge is built without native USB CDC, so a connected USB host cannot @@ -600,7 +645,9 @@ void runMigration() { strcpy(status_text, "Partition table verified; waiting for test reboot"); } else { #if defined(MESHCORE_MIGRATION_RESUME_OTA) - strcpy(status_text, "Migration complete; restarting LoRa bridge"); + strcpy(status_text, resume.copy_app1 + ? "Migration complete; restarting LoRa bridge" + : "Migration complete; resuming old LoRa receiver"); #else strcpy(status_text, "Migration complete; restarting Wi-Fi uploader"); #endif diff --git a/scripts/package_esp32_partition_migration.py b/scripts/package_esp32_partition_migration.py index 9b90a168e..1e94567a6 100644 --- a/scripts/package_esp32_partition_migration.py +++ b/scripts/package_esp32_partition_migration.py @@ -117,9 +117,9 @@ def role_for_target(target: str) -> str: 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: +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") @@ -148,8 +148,8 @@ def add_wifi_only_roles(family: str, flash_mib: int, # 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 +# 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. @@ -237,35 +237,37 @@ for family, flash_mib, roles in ( ("thinknode-m9", 16, {"repeater": "ThinkNode_M9_repeater_", "room-server": "ThinkNode_M9_room_server_"}), ): - add_wifi_only_roles(family, flash_mib, roles) + add_roles(family, flash_mib, roles) -add_wifi_only_roles("heltec-v2", 8, { +add_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, { +add_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, { +add_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, { +add_roles("tenstar-c3-sx1262", 4, { "repeater": "Tenstar_C3_sx1262_repeater", }, bridge="esp32_c3_4mb_partition_migrator") -add_wifi_only_roles("tenstar-c3-sx1268", 4, { +add_roles("tenstar-c3-sx1268", 4, { "repeater": "Tenstar_C3_sx1268_repeater", }, bridge="esp32_c3_4mb_partition_migrator") -add_wifi_only_roles("generic-e22-sx1262", 4, { +add_roles("generic-e22-sx1262", 4, { "repeater": "Generic_E22_sx1262_repeater", }, bridge="esp32_4mb_partition_migrator") -add_wifi_only_roles("generic-e22-sx1268", 4, { +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 @@ -287,10 +289,11 @@ def partition_entries(table: bytes) -> dict[str, tuple[int, int]]: def mota_full(image: bytes, identity: FwIdent, block_size: int) -> bytes: - # Device-side seeders keep at most 1024 Merkle leaves in 4 KiB scratch. - # The bridge fits 1 KiB blocks; a roughly 2 MiB Full image needs 2 KiB. - if (len(image) + block_size - 1) // block_size > 1024: - raise ValueError("mOTA image exceeds the device seeder's 1024-block limit") + # 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, @@ -320,7 +323,7 @@ def check_esp32_stage(package: bytes, image: bytes, slot_bytes: int) -> None: def readme(board: str, spec: dict, version: str, source: str, - has_full_mota: bool = True) -> str: + has_full_mota: bool = True, full_mota_blocks: int = 0) -> str: wifi_instructions = f"""## Wi-Fi route 1. On the old node, run `start ota ap` through its authenticated terminal. @@ -331,20 +334,47 @@ def readme(board: str, spec: dict, version: str, source: str, 3. At that page's `/update`, upload `full-application.bin`. """ if spec["lora_bridge"]: - lora_instructions = """## LoRa route + 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 repeater must already have working MeshCore LoRa mOTA, a valid EndF, +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. +{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 - repeater use `ota ls`, `ota pull flash`, then `ota install` after the + node use `ota ls`, `ota pull flash`, then `ota install` after the complete download. A manual install handles an equal-version bridge. -2. Keep power stable. The bridge expands the table, restores the private key, - and reboots into the preserved old LoRa repeater in the opposite slot. +2. Keep power stable. The bridge expands the table; {handoff}. It then reboots + into the preserved old LoRa receiver in the opposite slot. 3. Serve `full-application.mota`; again use `ota ls`, `ota pull flash`, - then `ota install`. The old repeater now sees the expanded inactive slot. + 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 @@ -381,9 +411,11 @@ 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 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; other SPIFFS settings can be recreated. The stock bootloader has no atomic backup partition table: power loss during @@ -447,7 +479,8 @@ def package_board(name: str, spec: dict, build_dir: Path, output_dir: Path, "target-partitions.bin": table, "capabilities.json": capability_path.read_bytes(), } - if (len(full_image) + 2047) // 2048 <= 1024: + 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) @@ -466,7 +499,8 @@ def package_board(name: str, spec: dict, build_dir: Path, output_dir: Path, 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() + "full-application.mota" in files, + full_mota_blocks).encode() manifest = { "board": name, "role": spec["role"], @@ -479,6 +513,10 @@ def package_board(name: str, spec: dict, build_dir: Path, output_dir: Path, "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"), + "full_mota_seeder_scratch_bytes": full_mota_blocks * 4, "files": {filename: {"bytes": len(data), "sha256": sha256(data)} for filename, data in files.items()}, } diff --git a/src/helpers/ESP32PartitionMigrationPolicy.h b/src/helpers/ESP32PartitionMigrationPolicy.h index a77747549..d10674263 100644 --- a/src/helpers/ESP32PartitionMigrationPolicy.h +++ b/src/helpers/ESP32PartitionMigrationPolicy.h @@ -267,14 +267,17 @@ inline bool canMigrateGeneric(uint32_t flash_bytes, && !sameGeometry(geometry, plan->layout); } -// A LoRa migration keeps the old OTA-capable application alive while the -// bridge restores SPIFFS identity. Legacy app0 stays at its address; legacy -// app1 is copied to the new app1 before the table is published. The bridge -// boots first, then hands off to whichever slot contains the old application. +// On 8/16 MiB flash the bridge can copy legacy app1 to its new address, +// reboot, restore the staged identity, and hand off to the old receiver. +// On 4 MiB flash that copy would overwrite legacy app1. The only safe LoRa +// route there starts with the old receiver in app0 and the bridge in app1; +// the new Full application must restore the NVS-staged identity on first boot. struct LoRaResumePlan { bool valid; uint8_t bridge_slot; uint8_t resume_slot; + bool copy_app1; + bool restore_identity_in_full; }; constexpr bool overlaps(uint32_t a, uint32_t a_size, @@ -288,19 +291,28 @@ inline LoRaResumePlan planLoRaResume(const PartitionGeometry& source, uint32_t running_address) { const bool in_app0 = running_address == source.app0_address; const bool in_app1 = running_address == source.app1_address; + const bool common = (in_app0 || in_app1) + && source.app0_address == target.app0_address + && source.app0_size <= target.app0_size + && source.app1_size <= target.app1_size + && !overlaps(source.app0_address, source.app0_size, + source.app1_address, source.app1_size) + && !overlaps(source.app0_address, source.app0_size, + target.app1_address, target.app1_size); + const bool can_copy = common + && !overlaps(source.app1_address, source.app1_size, + target.app1_address, source.app1_size); + const bool direct_4mb = common && in_app1 + && target.app1_address == kExpanded4MBLayout.app1_address + && target.app1_size == kExpanded4MBLayout.app1_size + && !overlaps(source.app0_address, source.app0_size, + target.spiffs_address, target.spiffs_size); return { - (in_app0 || in_app1) - && source.app0_address == target.app0_address - && source.app0_size <= target.app0_size - && source.app1_size <= target.app1_size - && !overlaps(source.app0_address, source.app0_size, - source.app1_address, source.app1_size) - && !overlaps(source.app0_address, source.app0_size, - target.app1_address, source.app1_size) - && !overlaps(source.app1_address, source.app1_size, - target.app1_address, source.app1_size), + can_copy || direct_4mb, static_cast(in_app0 ? 0 : 1), static_cast(in_app0 ? 1 : 0), + can_copy, + direct_4mb && !can_copy, }; } diff --git a/src/helpers/IdentityStore.cpp b/src/helpers/IdentityStore.cpp index 1ba4a9aac..feb24bb25 100644 --- a/src/helpers/IdentityStore.cpp +++ b/src/helpers/IdentityStore.cpp @@ -1,6 +1,9 @@ #include "IdentityStore.h" #include "FilePresence.h" +#if defined(ESP32_PLATFORM) +#include +#endif #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) #include "AtomicFileWriter.h" #else @@ -43,6 +46,47 @@ IdentityLoadResult IdentityStore::loadResult( char filename[40]; if (snprintf(filename, sizeof(filename), "%s/%s.id", _dir, name) >= (int)sizeof(filename)) return IdentityLoadResult::Unreadable; +#if defined(ESP32_PLATFORM) + // A 4 MiB LoRa partition migration must boot the old app0 receiver before + // the new Full image can run. The bridge staged the original key in stable + // NVS; that old receiver may have formatted the newly sized SPIFFS and + // generated a temporary key. Restore the original before normal startup + // accepts either that temporary key or a missing identity. + if (strcmp(_dir, "/identity") == 0 && strcmp(name, "_main") == 0) { + Preferences migration_nvs; + if (migration_nvs.begin("mesh-pt-migrate", true)) { + const bool pending = migration_nvs.getBool("id-pending", false); + uint8_t file_bytes[PUB_KEY_SIZE + PRV_KEY_SIZE]; + const size_t staged = pending + ? migration_nvs.getBytes("identity", file_bytes, sizeof(file_bytes)) : 0; + migration_nvs.end(); + if (pending) { + if (staged != sizeof(file_bytes)) return IdentityLoadResult::Unreadable; + uint8_t export_bytes[sizeof(file_bytes)]; + memcpy(export_bytes, file_bytes + PUB_KEY_SIZE, PRV_KEY_SIZE); + memcpy(export_bytes + PRV_KEY_SIZE, file_bytes, PUB_KEY_SIZE); + mesh::LocalIdentity original; + original.readFrom(export_bytes, sizeof(export_bytes)); + if (!saveWithRetry(name, original)) return IdentityLoadResult::Unreadable; + File restored = _fs->open(filename, "r"); + uint8_t verified[sizeof(file_bytes)]; + const bool matches = restored + && restored.read(verified, sizeof(verified)) == sizeof(verified) + && memcmp(file_bytes, verified, sizeof(file_bytes)) == 0; + if (restored) restored.close(); + if (!matches) return IdentityLoadResult::Unreadable; + if (!migration_nvs.begin("mesh-pt-migrate", false)) + return IdentityLoadResult::Unreadable; + // Clear the pending flag first: a power cut cannot then leave a + // pending flag pointing at already removed key bytes. + const bool cleared = migration_nvs.remove("id-pending"); + if (cleared) migration_nvs.remove("identity"); + migration_nvs.end(); + if (!cleared) return IdentityLoadResult::Unreadable; + } + } + } +#endif for (unsigned attempt = 0; attempt < IO_ATTEMPTS; ++attempt) { if (!recover(name)) continue; bool present = false; diff --git a/src/helpers/ota/OtaManager.h b/src/helpers/ota/OtaManager.h index a519e8f46..310eb630b 100644 --- a/src/helpers/ota/OtaManager.h +++ b/src/helpers/ota/OtaManager.h @@ -62,7 +62,9 @@ typedef bool (*ServeDeflateReadFn)(void* ctx, uint16_t block, uint8_t* dst, uint16_t dst_cap, uint16_t* dst_len); #ifndef OTA_PROOFGEN_SCRATCH - #if defined(OTA_SD_STORE) + #if defined(ESP32_PLATFORM) + #define OTA_PROOFGEN_SCRATCH 16384 // heap-backed; can seed <=4096 blocks (8 MiB at 2 KiB/block) + #elif defined(OTA_SD_STORE) #define OTA_PROOFGEN_SCRATCH 8192 // SD archive can seed <=2048 blocks (about 4 MiB at 2 KiB/block) #else #define OTA_PROOFGEN_SCRATCH 4096 // server proof-gen working buffer (supports up to 1024 blocks) diff --git a/test/test_esp32_partition_migration_policy/test_esp32_partition_migration_policy.cpp b/test/test_esp32_partition_migration_policy/test_esp32_partition_migration_policy.cpp index 2de7fdd32..f03fb5e8d 100644 --- a/test/test_esp32_partition_migration_policy/test_esp32_partition_migration_policy.cpp +++ b/test/test_esp32_partition_migration_policy/test_esp32_partition_migration_policy.cpp @@ -72,12 +72,16 @@ TEST(Esp32PartitionMigrationPolicy, LoRaResumePreservesTheOldApplicationInEither EXPECT_TRUE(from_a.valid); EXPECT_EQ(0, from_a.bridge_slot); EXPECT_EQ(1, from_a.resume_slot); + EXPECT_TRUE(from_a.copy_app1); + EXPECT_FALSE(from_a.restore_identity_in_full); const auto from_b = policy::planLoRaResume(policy::kLegacyLayout, target, policy::kLegacyLayout.app1_address); EXPECT_TRUE(from_b.valid); EXPECT_EQ(1, from_b.bridge_slot); EXPECT_EQ(0, from_b.resume_slot); + EXPECT_TRUE(from_b.copy_app1); + EXPECT_FALSE(from_b.restore_identity_in_full); } auto overlapping = policy::kExpanded8MBLayout; @@ -89,6 +93,14 @@ TEST(Esp32PartitionMigrationPolicy, LoRaResumePreservesTheOldApplicationInEither EXPECT_FALSE(policy::planLoRaResume(policy::kLegacyLayout, policy::kExpanded4MBLayout, policy::kLegacyLayout.app0_address).valid); + const auto small_from_b = policy::planLoRaResume( + policy::kLegacyLayout, policy::kExpanded4MBLayout, + policy::kLegacyLayout.app1_address); + EXPECT_TRUE(small_from_b.valid); + EXPECT_EQ(1, small_from_b.bridge_slot); + EXPECT_EQ(0, small_from_b.resume_slot); + EXPECT_FALSE(small_from_b.copy_app1); + EXPECT_TRUE(small_from_b.restore_identity_in_full); } TEST(Esp32PartitionMigrationPolicy, GeneratedTablePrefixContainsAllSixEntriesAndMd5) { diff --git a/test/test_esp32_partition_migration_recipe.py b/test/test_esp32_partition_migration_recipe.py index 83210fe27..2ca406d22 100644 --- a/test/test_esp32_partition_migration_recipe.py +++ b/test/test_esp32_partition_migration_recipe.py @@ -17,7 +17,8 @@ sys.path.insert(0, str(ROOT / "scripts")) from build_esp32_partition_migration import ( # noqa: E402 build_steps, bundle_release, verify_archive, ) -from package_esp32_partition_migration import BOARDS, readme # noqa: E402 +from package_esp32_partition_migration import BOARDS, mota_full, readme # noqa: E402 +from motalib import FwIdent # noqa: E402 class Esp32MigrationRecipeTest(unittest.TestCase): @@ -47,6 +48,7 @@ class Esp32MigrationRecipeTest(unittest.TestCase): with self.subTest(board=board): self.assertIn(spec["target"], env_names) self.assertIn(spec["wifi_bridge"], env_names) + self.assertIn(spec["lora_bridge"], env_names) self.assertEqual(expected_slots[spec["flash_bytes"]], spec["slot_bytes"]) @@ -96,30 +98,38 @@ class Esp32MigrationRecipeTest(unittest.TestCase): "xiao_s3_partition_migrator_lora_repeater", ], [command[3] for command, _ in steps[2:]]) - def test_wifi_only_4mb_roles_share_one_bridge(self): + def test_4mb_roles_share_two_chip_family_bridges(self): steps = build_steps(["thinknode-m2-repeater", "thinknode-m2-room-server"], "v1.17.1.7-test", "usa-cascadia", "cascade", 4) - self.assertEqual([True, True, False], [full for _, full in steps]) + self.assertEqual([True, True, False, False], [full for _, full in steps]) self.assertEqual("ThinkNode_M2_Repeater", steps[0][0][3]) self.assertEqual("ThinkNode_M2_room_server", steps[1][0][3]) self.assertEqual("esp32_s3_4mb_partition_migrator", steps[2][0][3]) + self.assertEqual("esp32_s3_4mb_partition_migrator_lora", steps[3][0][3]) guide = readme("thinknode-m2-repeater", BOARDS["thinknode-m2-repeater"], - "v1.17.1.7-test", "01234567") - self.assertIn("no LoRa bridge", guide) + "v1.17.1.7-test", "01234567", full_mota_blocks=900) + self.assertIn("old slot B", guide) + self.assertIn("original private key from NVS", guide) self.assertIn("power loss during", guide) self.assertIn("exact\n `Started:` URL", guide) - self.assertNotIn("ota pull ", guide) + self.assertIn("ota pull ", guide) large_guide = readme("heltec-v3-sensor", BOARDS["heltec-v3-sensor"], - "v1.17.1.7-test", "01234567", has_full_mota=False) - self.assertIn("does not include `full-application.mota`", large_guide) + "v1.17.1.7-test", "01234567", full_mota_blocks=1400) + self.assertIn("5600 bytes of proof scratch", large_guide) def test_sensor_and_room_roles_share_the_8mb_bridge(self): steps = build_steps(["xiao-s3-wio-sensor", "xiao-s3-wio-room-server"], "v1.17.1.7-test", "usa-cascadia", "cascade", 4) - self.assertEqual([True, True, False], [full for _, full in steps]) + self.assertEqual([True, True, False, False], [full for _, full in steps]) self.assertEqual("Xiao_S3_WIO_sensor", steps[0][0][3]) self.assertEqual("Xiao_S3_WIO_room_server", steps[1][0][3]) self.assertEqual("esp32_s3_8mb_partition_migrator", steps[2][0][3]) + self.assertEqual("esp32_s3_8mb_partition_migrator_lora", steps[3][0][3]) + + def test_full_mota_can_exceed_old_seeder_scratch_limit(self): + image = b"\xE9" + b"\0" * (1024 * 2048) + package = mota_full(image, FwIdent(0x01020304, 0xAABBCCDD, "TEST"), 2048) + self.assertGreater(len(package), len(image)) def test_dry_run_has_no_build_side_effects(self): with tempfile.TemporaryDirectory(prefix="esp32-migration-plan-") as temporary: diff --git a/variants/esp32_partition_migrator/platformio.ini b/variants/esp32_partition_migrator/platformio.ini index b0a54f643..12a6a02bd 100644 --- a/variants/esp32_partition_migrator/platformio.ini +++ b/variants/esp32_partition_migrator/platformio.ini @@ -35,6 +35,27 @@ extends = env:esp32_s3_4mb_partition_migrator board_upload.flash_size = 8MB board_upload.maximum_size = 8388608 +; LoRa first-stage variants derive the exact board/role target from the EndF +; appended by the packager and verify the old receiver's EndF before changing +; flash. The bridge itself uses no board-specific radio GPIO. +[env:esp32_s3_4mb_partition_migrator_lora] +extends = env:esp32_s3_4mb_partition_migrator +build_flags = + ${env:esp32_s3_4mb_partition_migrator.build_flags} + -D MESHCORE_MIGRATION_RESUME_OTA=1 + +[env:esp32_s3_8mb_partition_migrator_lora] +extends = env:esp32_s3_8mb_partition_migrator +build_flags = + ${env:esp32_s3_8mb_partition_migrator.build_flags} + -D MESHCORE_MIGRATION_RESUME_OTA=1 + +[env:esp32_s3_16mb_partition_migrator_lora] +extends = env:esp32_s3_16mb_partition_migrator +build_flags = + ${env:esp32_s3_16mb_partition_migrator.build_flags} + -D MESHCORE_MIGRATION_RESUME_OTA=1 + ; Heltec V2 and other first-generation ESP32 (non-S3) 8 MiB boards need a ; matching chip-family image. The migration logic still verifies the live ; partition table and flash size before touching flash. @@ -42,6 +63,12 @@ board_upload.maximum_size = 8388608 extends = env:esp32_s3_8mb_partition_migrator board = esp32dev +[env:esp32_8mb_partition_migrator_lora] +extends = env:esp32_8mb_partition_migrator +build_flags = + ${env:esp32_8mb_partition_migrator.build_flags} + -D MESHCORE_MIGRATION_RESUME_OTA=1 + ; The 4 MiB C3 bridge covers historical default.csv images such as the ; Heltec CT62. It never assumes the old app slots are exactly 1.25 MiB: the ; live table must still pass the common geometry and identity checks. @@ -49,6 +76,18 @@ board = esp32dev extends = env:esp32_s3_4mb_partition_migrator board = esp32-c3-devkitm-1 +[env:esp32_c3_4mb_partition_migrator_lora] +extends = env:esp32_c3_4mb_partition_migrator +build_flags = + ${env:esp32_c3_4mb_partition_migrator.build_flags} + -D MESHCORE_MIGRATION_RESUME_OTA=1 + [env:esp32_4mb_partition_migrator] extends = env:esp32_s3_4mb_partition_migrator board = esp32dev + +[env:esp32_4mb_partition_migrator_lora] +extends = env:esp32_4mb_partition_migrator +build_flags = + ${env:esp32_4mb_partition_migrator.build_flags} + -D MESHCORE_MIGRATION_RESUME_OTA=1 diff --git a/variants/heltec_v4/platformio.ini b/variants/heltec_v4/platformio.ini index 46ba414ae..7027caf0c 100644 --- a/variants/heltec_v4/platformio.ini +++ b/variants/heltec_v4/platformio.ini @@ -902,7 +902,6 @@ extends = env:heltec_v4_partition_migrator build_flags = ${env:heltec_v4_partition_migrator.build_flags} -D MESHCORE_MIGRATION_RESUME_OTA=1 - -D MESHCORE_MIGRATION_EXPECTED_TARGET_ID=0xE792A051 ; Disposable-hardware harness: leaves a ten-minute Wi-Fi OTA window for ; installing the production bridge into legacy slot B. This is not a release diff --git a/variants/xiao_s3_wio/platformio.ini b/variants/xiao_s3_wio/platformio.ini index 15491ce33..2faf86f78 100644 --- a/variants/xiao_s3_wio/platformio.ini +++ b/variants/xiao_s3_wio/platformio.ini @@ -87,7 +87,6 @@ extends = env:xiao_s3_partition_migrator build_flags = ${env:xiao_s3_partition_migrator.build_flags} -D MESHCORE_MIGRATION_RESUME_OTA=1 - -D MESHCORE_MIGRATION_EXPECTED_TARGET_ID=0x9F19BCD1 ; Disposable-hardware seed. It reproduces a real legacy identity file and ; exposes browser OTA without trying to squeeze a current full repeater into