From 1bc900c33efdefdc5d99e7898dabf9dd168e1b39 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 23 Sep 2026 23:33:29 -0700 Subject: [PATCH] Select registered OTA slots and verify LoRa staging geometry --- examples/esp32_partition_migrator/main.cpp | 39 +++++++------------- scripts/package_esp32_partition_migration.py | 24 ++++++++++-- test/test_heltec_v4_partition_migrator.py | 6 +-- 3 files changed, 36 insertions(+), 33 deletions(-) diff --git a/examples/esp32_partition_migrator/main.cpp b/examples/esp32_partition_migrator/main.cpp index 02026ccbb..182fb7b2c 100644 --- a/examples/esp32_partition_migrator/main.cpp +++ b/examples/esp32_partition_migrator/main.cpp @@ -152,14 +152,6 @@ esp_partition_t rawPartition(uint32_t address, uint32_t size, const char* label) return part; } -esp_partition_t rawAppPartition(uint32_t address, uint32_t size, - esp_partition_subtype_t subtype, const char* label) { - esp_partition_t part = rawPartition(address, size, label); - part.type = ESP_PARTITION_TYPE_APP; - part.subtype = subtype; - return part; -} - bool rangesOverlap(uint32_t first_address, uint32_t first_size, uint32_t second_address, uint32_t second_size) { const uint64_t first_end = static_cast(first_address) + first_size; @@ -398,7 +390,7 @@ bool stageResumeSlot(uint8_t slot) { return saved; } -bool resumeLegacyOtaReceiver(const migration::TargetPlan& plan) { +bool resumeLegacyOtaReceiver() { Preferences migration_nvs; if (!migration_nvs.begin(kMigrationNvsNamespace, false)) { strcpy(status_text, "Could not read old LoRa application slot"); @@ -410,12 +402,12 @@ bool resumeLegacyOtaReceiver(const migration::TargetPlan& plan) { strcpy(status_text, "Expanded layout ready; no LoRa resume record"); return false; } - const esp_partition_t old_app = slot == 0 - ? rawAppPartition(plan.layout.app0_address, plan.layout.app0_size, - ESP_PARTITION_SUBTYPE_APP_OTA_0, "app0") - : rawAppPartition(plan.layout.app1_address, plan.layout.app1_size, - ESP_PARTITION_SUBTYPE_APP_OTA_1, "app1"); - const esp_err_t selected = esp_ota_set_boot_partition(&old_app); + const esp_partition_t* old_app = esp_partition_find_first( + ESP_PARTITION_TYPE_APP, + slot == 0 ? ESP_PARTITION_SUBTYPE_APP_OTA_0 : ESP_PARTITION_SUBTYPE_APP_OTA_1, + nullptr); + const esp_err_t selected = old_app + ? esp_ota_set_boot_partition(old_app) : ESP_ERR_NOT_FOUND; if (selected != ESP_OK) { snprintf(status_text, sizeof(status_text), "Could not resume old LoRa app: %s", errName(selected)); @@ -569,20 +561,16 @@ void runMigration() { } #endif - // The OTA-select data records a slot identity. Select a descriptor for the - // bridge's expanded slot before replacing the table so the next boot runs - // the bridge at the correct address under the new layout. + // 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. #if defined(MESHCORE_MIGRATION_RESUME_OTA) const bool bridge_in_app0 = resume.bridge_slot == 0; #else const bool bridge_in_app0 = true; #endif - const esp_partition_t target_bridge = bridge_in_app0 - ? rawAppPartition(plan->layout.app0_address, plan->layout.app0_size, - ESP_PARTITION_SUBTYPE_APP_OTA_0, "app0") - : rawAppPartition(plan->layout.app1_address, plan->layout.app1_size, - ESP_PARTITION_SUBTYPE_APP_OTA_1, "app1"); - const esp_err_t select_result = esp_ota_set_boot_partition(&target_bridge); + const esp_err_t select_result = esp_ota_set_boot_partition( + bridge_in_app0 ? refs.app0 : refs.app1); if (select_result != ESP_OK) { snprintf(status_text, sizeof(status_text), "Could not select bridge slot: %s", errName(select_result)); @@ -700,8 +688,7 @@ void setup() { if (restoreStagedIdentity()) { strcpy(status_text, "Expanded layout ready"); #if defined(MESHCORE_MIGRATION_RESUME_OTA) - const migration::TargetPlan* plan = migration::targetForFlash(flash_bytes); - if (plan) resumeLegacyOtaReceiver(*plan); + resumeLegacyOtaReceiver(); #endif } } else if (findPartitions(refs, geometry) diff --git a/scripts/package_esp32_partition_migration.py b/scripts/package_esp32_partition_migration.py index 418193bf3..c9c24e9ce 100644 --- a/scripts/package_esp32_partition_migration.py +++ b/scripts/package_esp32_partition_migration.py @@ -61,13 +61,17 @@ def partition_entries(table: bytes) -> dict[str, tuple[int, int]]: return entries -def mota_full(image: bytes, identity: FwIdent) -> bytes: +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") manifest = build_manifest( target_id=identity.target_id, fw_version=identity.fw_version, image_size=len(image), payload=image, - block_size=1024, + block_size=block_size, image_hash=hashlib.sha256(image).digest(), codec_id=CODEC_FULL, is_full=True, @@ -80,6 +84,16 @@ def mota_full(image: bytes, identity: FwIdent) -> bytes: return package +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) -> str: return f"""# {board} in-place partition migration @@ -166,12 +180,14 @@ def package_board(name: str, spec: dict, build_dir: Path, output_dir: Path, files = { "wifi-bridge.bin": wifi_bridge, - "lora-bridge.mota": mota_full(lora_bridge, bridge_ident), + "lora-bridge.mota": mota_full(lora_bridge, bridge_ident, 1024), "full-application.bin": full_image, - "full-application.mota": mota_full(full_image, full_ident), + "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() manifest = { "board": name, diff --git a/test/test_heltec_v4_partition_migrator.py b/test/test_heltec_v4_partition_migrator.py index ac88d71ea..334f14b33 100644 --- a/test/test_heltec_v4_partition_migrator.py +++ b/test/test_heltec_v4_partition_migrator.py @@ -54,13 +54,13 @@ class HeltecV4PartitionMigratorTest(unittest.TestCase): migration_body = source[source.index("void runMigration()") :] self.assertLess(migration_body.index("stageLegacyIdentity()"), migration_body.index("copyAndVerify(*running")) - self.assertLess(migration_body.index("esp_ota_set_boot_partition(&target_bridge)"), + self.assertLess(migration_body.index("esp_ota_set_boot_partition(\n bridge_in_app0 ? refs.app0 : refs.app1)"), migration_body.index("publishExpandedPartitionTable(*plan)")) setup_body = source[source.index("void setup()") :] self.assertIn("restoreStagedIdentity()", setup_body) - self.assertIn("resumeLegacyOtaReceiver(*plan)", setup_body) + self.assertIn("resumeLegacyOtaReceiver()", setup_body) self.assertLess(setup_body.index("restoreStagedIdentity()"), - setup_body.index("resumeLegacyOtaReceiver(*plan)")) + setup_body.index("resumeLegacyOtaReceiver()")) self.assertIn("validLegacyOtaReceiver(*old_receiver)", migration_body) self.assertIn("copyAndVerify(*refs.app1", migration_body) self.assertIn("stageResumeSlot(resume.resume_slot)", migration_body)