Add LoRa partition migration recipes for ESP32 infrastructure boards

This commit is contained in:
mikecarper committed 2026-09-24 10:40:32 -07:00
1 parent e7f6d529a0
commit c0ef07a968
10 files changed
+288 -86

No files matched your search

+78 -31
View File
@@ -11,9 +11,6 @@
#include <esp_partition.h>
#if defined(MESHCORE_MIGRATION_RESUME_OTA)
#include <mbedtls/sha256.h>
#ifndef MESHCORE_MIGRATION_EXPECTED_TARGET_ID
#error "LoRa migration requires the exact legacy OTA target ID"
#endif
#endif
#include <helpers/ESP32PartitionMigrationPolicy.h>
@@ -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<uint32_t>(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
+67 -29
View File
@@ -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 <id> flash`, then `ota install` after the
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, 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 <id> 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()},
}
+26 -14
View File
@@ -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<uint8_t>(in_app0 ? 0 : 1),
static_cast<uint8_t>(in_app0 ? 1 : 0),
can_copy,
direct_4mb && !can_copy,
};
}
+44
View File
@@ -1,6 +1,9 @@
#include "IdentityStore.h"
#include "FilePresence.h"
#if defined(ESP32_PLATFORM)
#include <Preferences.h>
#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;
+3 -1
View File
@@ -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)
@@ -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) {
+19 -9
View File
@@ -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 <id>", guide)
self.assertIn("ota pull <id>", 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:
@@ -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
-1
View File
@@ -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
-1
View File
@@ -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