mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-10-23 15:42:29 +00:00
Add LoRa partition migration recipes for ESP32 infrastructure boards
This commit is contained in:
1 parent
e7f6d529a0
commit
c0ef07a968
10 files changed
+288
-86
No files matched your search
@@ -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
|
||||
|
||||
@@ -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()},
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user