Merge branch 'keymindCascade' of github.com:mikecarper/MeshCore into keymindCascade
# Conflicts: # test/test_nrf52_ble_startup.py
This commit is contained in:
commit
bbb5c14fba
50 files changed
+3450
-261
No files matched your search
@@ -2544,7 +2544,10 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply,
|
||||
|
||||
if (handleTxRoutingCommand(command, reply, reply_size)) return true;
|
||||
#if COMPANION_FEATURE_OTA_CLI
|
||||
if (mesh::ota::handleSpeedCommand(command, reply, reply_size)) return true;
|
||||
const auto* ota_context = mesh::ota::ota_context_if_active();
|
||||
const float adaptive_ota_pace = ota_context
|
||||
? ota_context->manager.adaptivePacketSpeed() : mesh::ota::OTA_SPEED_DEFAULT;
|
||||
if (mesh::ota::handleSpeedCommand(command, reply, reply_size, adaptive_ota_pace)) return true;
|
||||
#endif
|
||||
if (handleCompanionBluetoothCommand(command, reply, reply_size)) return true;
|
||||
if (handleCompanionWirelessCommand(command, reply, reply_size)) return true;
|
||||
|
||||
@@ -9,10 +9,19 @@
|
||||
#include <esp_attr.h>
|
||||
#include <esp_flash.h>
|
||||
#include <esp_partition.h>
|
||||
#include <esp_heap_caps.h>
|
||||
#if defined(MESHCORE_MIGRATION_RESUME_OTA) || defined(MOTA_MIGRATION_TARGET_ID)
|
||||
#include <mbedtls/sha256.h>
|
||||
#include <nvs.h>
|
||||
#endif
|
||||
|
||||
#include <helpers/ESP32PartitionMigrationPolicy.h>
|
||||
#include <helpers/esp32/WiFiRadioPolicy.h>
|
||||
|
||||
#if defined(MOTA_MIGRATION_TARGET_ID)
|
||||
String partitionExpanderStatus();
|
||||
#endif
|
||||
|
||||
namespace migration = mesh::esp32_partition_migration;
|
||||
|
||||
namespace {
|
||||
@@ -33,12 +42,61 @@ constexpr char kMigrationNvsNamespace[] = "mesh-pt-migrate";
|
||||
constexpr char kMigrationIdentityKey[] = "identity";
|
||||
// NVS key names are limited to 15 characters.
|
||||
constexpr char kMigrationIdentityPendingKey[] = "id-pending";
|
||||
constexpr char kMigrationConfigKey[] = "cfg-meta";
|
||||
constexpr char kMigrationConfigPendingKey[] = "cfg-pending";
|
||||
constexpr char kMigrationConfigRestoredKey[] = "cfg-restored";
|
||||
#if defined(MOTA_MIGRATION_TARGET_ID)
|
||||
constexpr char kExpanderHandoffKey[] = "full-handoff";
|
||||
#endif
|
||||
constexpr uint32_t kConfigStageMagic = 0x43464731U;
|
||||
constexpr size_t kMaxConfigFileBytes = 16384;
|
||||
// The common preferences hold node name, password and primary radio settings.
|
||||
// Other entries preserve access policy, extra radio profiles and the credentials
|
||||
// needed to remain manageable after the SPIFFS partition moves.
|
||||
struct ConfigFile { const char* path; const char* nvs_key; };
|
||||
constexpr ConfigFile kConfigFiles[] = {
|
||||
{"/com_prefs", "cfg00"}, {"/node_prefs", "cfg01"},
|
||||
{"/s_contacts", "cfg02"}, {"/s_login_replay", "cfg03"},
|
||||
{"/regions2", "cfg04"}, {"/radio_profiles", "cfg05"},
|
||||
{"/mqtt_prefs", "cfg06"}, {"/mqtt.json", "cfg07"},
|
||||
{"/ota_config", "cfg08"}, {"/flood_filter", "cfg09"},
|
||||
{"/flood_filter_bl", "cfg10"}, {"/flood_ch_scope", "cfg11"},
|
||||
{"/flood_ch_req", "cfg12"}, {"/flood_grp_mod", "cfg13"},
|
||||
{"/clock_sync", "cfg14"}, {"/display_prefs", "cfg15"},
|
||||
{"/telemetry_tx", "cfg16"}, {"/data_tx", "cfg17"},
|
||||
{"/com_prefs.bak", "cfg18"}, {"/radio_profiles.bak", "cfg19"},
|
||||
{"/s_contacts.bak", "cfg20"}, {"/mqtt_prefs.bak", "cfg21"},
|
||||
{"/regions2.bak", "cfg22"}, {"/prefs.json", "cfg23"},
|
||||
{"/management", "cfg24"}, {"/management.bak", "cfg25"},
|
||||
{"/s_login_replay.bak", "cfg26"}, {"/ota_config.bak", "cfg27"},
|
||||
{"/ota_speed", "cfg28"}, {"/flood_ch_block", "cfg29"},
|
||||
{"/bsec_state.bin", "cfg30"}, {"/display_prefs.bak", "cfg31"},
|
||||
};
|
||||
constexpr size_t kConfigFileCount = sizeof(kConfigFiles) / sizeof(kConfigFiles[0]);
|
||||
static_assert(kConfigFileCount <= 32, "config presence mask is 32 bits");
|
||||
struct ConfigStage {
|
||||
uint32_t magic;
|
||||
uint32_t present;
|
||||
uint32_t sizes[kConfigFileCount];
|
||||
uint32_t crcs[kConfigFileCount];
|
||||
};
|
||||
#if defined(MESHCORE_MIGRATION_RESUME_OTA)
|
||||
constexpr char kMigrationResumeSlotKey[] = "resume-slot";
|
||||
#endif
|
||||
#if defined(MESHCORE_MIGRATION_RESUME_OTA) || defined(MOTA_MIGRATION_TARGET_ID)
|
||||
constexpr size_t kEndfBytes = 56;
|
||||
constexpr char kBridgeImageMarker[] = "MeshCore ESP32 partition migration bridge image";
|
||||
// Earlier bridge packages predate the dedicated marker but contain this page title.
|
||||
constexpr char kLegacyBridgeImageMarker[] = "MeshCore Wi-Fi partition migration";
|
||||
#endif
|
||||
static_assert(sizeof(kMigrationNvsNamespace) - 1 <= 15,
|
||||
"ESP32 NVS namespace names are limited to 15 characters");
|
||||
|
||||
AsyncWebServer server(80);
|
||||
bool migration_started = false;
|
||||
bool migration_complete = false;
|
||||
bool expanded_layout_ready = false;
|
||||
bool identity_ready = false;
|
||||
bool reboot_requested = false;
|
||||
uint32_t migration_at = 0;
|
||||
uint32_t reboot_at = 0;
|
||||
@@ -142,13 +200,6 @@ esp_partition_t rawPartition(uint32_t address, uint32_t size, const char* label)
|
||||
return part;
|
||||
}
|
||||
|
||||
esp_partition_t rawApp0Partition(uint32_t address, uint32_t size, const char* label) {
|
||||
esp_partition_t part = rawPartition(address, size, label);
|
||||
part.type = ESP_PARTITION_TYPE_APP;
|
||||
part.subtype = ESP_PARTITION_SUBTYPE_APP_OTA_0;
|
||||
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<uint64_t>(first_address) + first_size;
|
||||
@@ -234,6 +285,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;
|
||||
@@ -312,8 +379,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");
|
||||
@@ -322,6 +389,439 @@ bool restoreStagedIdentity() {
|
||||
return true;
|
||||
}
|
||||
|
||||
#if !defined(MESHCORE_MIGRATION_RESUME_OTA)
|
||||
bool verifyExpandedIdentityFile() {
|
||||
if (!SPIFFS.begin(false)) {
|
||||
strcpy(status_text, "Refused: expanded identity filesystem is unavailable");
|
||||
return false;
|
||||
}
|
||||
File identity = SPIFFS.open("/identity/_main.id", "r");
|
||||
const bool available = identity && identity.size() >= kIdentityFileBytes
|
||||
&& identity.read(copy_buffer, kIdentityFileBytes) == kIdentityFileBytes;
|
||||
if (identity) identity.close();
|
||||
SPIFFS.end();
|
||||
if (!available) strcpy(status_text, "Refused: restored private key is unavailable");
|
||||
return available;
|
||||
}
|
||||
#endif
|
||||
|
||||
bool readConfigStage(Preferences& nvs, ConfigStage& stage) {
|
||||
if (nvs.getBytes(kMigrationConfigKey, &stage, sizeof(stage)) != sizeof(stage)
|
||||
|| stage.magic != kConfigStageMagic) {
|
||||
strcpy(status_text, "Refused: staged configuration manifest is incomplete");
|
||||
return false;
|
||||
}
|
||||
for (size_t i = 0; i < kConfigFileCount; ++i) {
|
||||
const bool present = (stage.present & (1UL << i)) != 0;
|
||||
if ((!present && stage.sizes[i] != 0)
|
||||
|| (present && (stage.sizes[i] == 0
|
||||
|| stage.sizes[i] > kMaxConfigFileBytes
|
||||
|| nvs.getBytesLength(kConfigFiles[i].nvs_key) != stage.sizes[i]))) {
|
||||
snprintf(status_text, sizeof(status_text),
|
||||
"Refused: staged %s is incomplete", kConfigFiles[i].path);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool verifyStagedConfigBlob(Preferences& nvs, const ConfigStage& stage,
|
||||
size_t i, uint8_t* buffer) {
|
||||
const size_t size = stage.sizes[i];
|
||||
return nvs.getBytes(kConfigFiles[i].nvs_key, buffer, size) == size
|
||||
&& (~crc32(buffer, size)) == stage.crcs[i];
|
||||
}
|
||||
|
||||
void clearConfigStageBlobs(Preferences& nvs) {
|
||||
nvs.remove(kMigrationConfigKey);
|
||||
for (const ConfigFile& file : kConfigFiles) nvs.remove(file.nvs_key);
|
||||
}
|
||||
|
||||
bool validateStagedConfig() {
|
||||
Preferences nvs;
|
||||
if (!nvs.begin(kMigrationNvsNamespace, true)) return true;
|
||||
const bool pending = nvs.getBool(kMigrationConfigPendingKey, false);
|
||||
if (!pending) { nvs.end(); return true; }
|
||||
ConfigStage stage = {};
|
||||
bool valid = readConfigStage(nvs, stage);
|
||||
for (size_t i = 0; valid && i < kConfigFileCount; ++i) {
|
||||
if (!(stage.present & (1UL << i))) continue;
|
||||
uint8_t* buffer = static_cast<uint8_t*>(heap_caps_malloc(
|
||||
stage.sizes[i], MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT));
|
||||
valid = buffer && verifyStagedConfigBlob(nvs, stage, i, buffer);
|
||||
heap_caps_free(buffer);
|
||||
if (!valid) snprintf(status_text, sizeof(status_text),
|
||||
"Refused: staged %s failed verification", kConfigFiles[i].path);
|
||||
}
|
||||
nvs.end();
|
||||
return valid;
|
||||
}
|
||||
|
||||
bool stageLegacyConfig() {
|
||||
Preferences nvs;
|
||||
if (!nvs.begin(kMigrationNvsNamespace, false)) {
|
||||
strcpy(status_text, "Could not open NVS configuration staging");
|
||||
return false;
|
||||
}
|
||||
if (nvs.getBool(kMigrationConfigPendingKey, false)) {
|
||||
nvs.end();
|
||||
return validateStagedConfig();
|
||||
}
|
||||
// A power failure before the commit marker can leave partial blobs. They
|
||||
// are never authoritative and would otherwise consume the small NVS area.
|
||||
if (!nvs.putBool(kMigrationConfigRestoredKey, false)) {
|
||||
nvs.end();
|
||||
strcpy(status_text, "Could not reset configuration handoff record");
|
||||
return false;
|
||||
}
|
||||
clearConfigStageBlobs(nvs);
|
||||
if (!SPIFFS.begin(false)) {
|
||||
nvs.end();
|
||||
strcpy(status_text, "Could not mount legacy configuration filesystem");
|
||||
return false;
|
||||
}
|
||||
ConfigStage stage = {};
|
||||
stage.magic = kConfigStageMagic;
|
||||
bool saved = true;
|
||||
for (size_t i = 0; saved && i < kConfigFileCount; ++i) {
|
||||
if (!SPIFFS.exists(kConfigFiles[i].path)) continue;
|
||||
File source = SPIFFS.open(kConfigFiles[i].path, "r");
|
||||
const size_t size = source ? source.size() : 0;
|
||||
if (size == 0 || size > kMaxConfigFileBytes) {
|
||||
snprintf(status_text, sizeof(status_text),
|
||||
"Refused: %s cannot fit NVS staging", kConfigFiles[i].path);
|
||||
saved = false;
|
||||
} else {
|
||||
uint8_t* buffer = static_cast<uint8_t*>(heap_caps_malloc(
|
||||
size, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT));
|
||||
saved = buffer && source.read(buffer, size) == size;
|
||||
if (saved) {
|
||||
stage.sizes[i] = size;
|
||||
stage.crcs[i] = ~crc32(buffer, size);
|
||||
saved = nvs.putBytes(kConfigFiles[i].nvs_key, buffer, size) == size
|
||||
&& verifyStagedConfigBlob(nvs, stage, i, buffer);
|
||||
}
|
||||
heap_caps_free(buffer);
|
||||
if (!saved) snprintf(status_text, sizeof(status_text),
|
||||
"Could not stage and verify %s", kConfigFiles[i].path);
|
||||
}
|
||||
if (source) source.close();
|
||||
if (saved) stage.present |= (1UL << i);
|
||||
delay(1);
|
||||
}
|
||||
SPIFFS.end();
|
||||
if (saved) {
|
||||
saved = nvs.putBytes(kMigrationConfigKey, &stage, sizeof(stage))
|
||||
== sizeof(stage)
|
||||
&& nvs.putBool(kMigrationConfigPendingKey, true);
|
||||
if (!saved) strcpy(status_text, "Could not finish NVS configuration staging");
|
||||
}
|
||||
if (!saved) clearConfigStageBlobs(nvs);
|
||||
nvs.end();
|
||||
return saved && validateStagedConfig();
|
||||
}
|
||||
|
||||
bool restoreStagedConfig() {
|
||||
Preferences nvs;
|
||||
if (!nvs.begin(kMigrationNvsNamespace, false)) return true;
|
||||
if (!nvs.getBool(kMigrationConfigPendingKey, false)) {
|
||||
// Cleanup may have been interrupted after the restored files were
|
||||
// committed. This does not require another SPIFFS write.
|
||||
clearConfigStageBlobs(nvs);
|
||||
nvs.end();
|
||||
return true;
|
||||
}
|
||||
ConfigStage stage = {};
|
||||
if (!readConfigStage(nvs, stage) || !validateStagedConfig()) {
|
||||
nvs.end();
|
||||
return false;
|
||||
}
|
||||
if (!SPIFFS.begin(false)) {
|
||||
nvs.end();
|
||||
strcpy(status_text, "Could not mount expanded configuration filesystem");
|
||||
return false;
|
||||
}
|
||||
bool restored = true;
|
||||
for (size_t i = 0; restored && i < kConfigFileCount; ++i) {
|
||||
if (!(stage.present & (1UL << i))) continue;
|
||||
const size_t size = stage.sizes[i];
|
||||
uint8_t* buffer = static_cast<uint8_t*>(heap_caps_malloc(
|
||||
size, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT));
|
||||
restored = buffer && verifyStagedConfigBlob(nvs, stage, i, buffer);
|
||||
if (restored) {
|
||||
File destination = SPIFFS.open(kConfigFiles[i].path, "w");
|
||||
restored = destination && destination.write(buffer, size) == size;
|
||||
if (destination) { destination.flush(); destination.close(); }
|
||||
File verify = SPIFFS.open(kConfigFiles[i].path, "r");
|
||||
restored = restored && verify && verify.size() == size
|
||||
&& verify.read(buffer, size) == size
|
||||
&& (~crc32(buffer, size)) == stage.crcs[i];
|
||||
if (verify) verify.close();
|
||||
}
|
||||
heap_caps_free(buffer);
|
||||
if (!restored) snprintf(status_text, sizeof(status_text),
|
||||
"Could not restore and verify %s", kConfigFiles[i].path);
|
||||
delay(1);
|
||||
}
|
||||
SPIFFS.end();
|
||||
if (restored) {
|
||||
// Record the verified restore before clearing the pending marker. A lost
|
||||
// pending flag cannot otherwise prove that saved settings survived.
|
||||
restored = nvs.putBool(kMigrationConfigRestoredKey, true);
|
||||
if (restored) restored = nvs.remove(kMigrationConfigPendingKey);
|
||||
if (restored) {
|
||||
clearConfigStageBlobs(nvs);
|
||||
} else {
|
||||
strcpy(status_text, "Configuration restored; NVS commit needs retry");
|
||||
}
|
||||
}
|
||||
nvs.end();
|
||||
return restored;
|
||||
}
|
||||
|
||||
#if defined(MESHCORE_MIGRATION_RESUME_OTA) || defined(MOTA_MIGRATION_TARGET_ID)
|
||||
uint32_t readLe32(const uint8_t* bytes) {
|
||||
return static_cast<uint32_t>(bytes[0])
|
||||
| (static_cast<uint32_t>(bytes[1]) << 8)
|
||||
| (static_cast<uint32_t>(bytes[2]) << 16)
|
||||
| (static_cast<uint32_t>(bytes[3]) << 24);
|
||||
}
|
||||
|
||||
struct OtaImageIdentity {
|
||||
uint32_t target_id = 0;
|
||||
uint32_t body_bytes = 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 opposite-slot firmware 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: LoRa application image is invalid");
|
||||
return false;
|
||||
}
|
||||
uint8_t trailer[kEndfBytes];
|
||||
for (uint32_t base = 0; base < source.size;
|
||||
base += sizeof(copy_buffer)) {
|
||||
const uint32_t count = source.size - base < sizeof(copy_buffer)
|
||||
? source.size - base : sizeof(copy_buffer);
|
||||
if (esp_partition_read(&source, base, copy_buffer, count) != ESP_OK) break;
|
||||
for (uint32_t index = 0; index < count; ++index) {
|
||||
if (copy_buffer[index] != 'E') continue;
|
||||
const uint32_t marker = base + index;
|
||||
if (marker + kEndfBytes > source.size
|
||||
|| esp_partition_read(&source, marker, trailer, sizeof(trailer)) != ESP_OK
|
||||
|| memcmp(trailer, "EndF", 4) != 0
|
||||
|| readLe32(trailer + 4) != marker) {
|
||||
continue;
|
||||
}
|
||||
mbedtls_sha256_context hash;
|
||||
mbedtls_sha256_init(&hash);
|
||||
bool valid = mbedtls_sha256_starts_ret(&hash, 0) == 0;
|
||||
for (uint32_t offset = 0; valid && offset < marker;) {
|
||||
const uint32_t length = marker - offset < sizeof(copy_buffer)
|
||||
? marker - offset : sizeof(copy_buffer);
|
||||
valid = esp_partition_read(&source, offset, copy_buffer, length) == ESP_OK
|
||||
&& mbedtls_sha256_update_ret(&hash, copy_buffer, length) == 0;
|
||||
offset += length;
|
||||
}
|
||||
uint8_t digest[32];
|
||||
valid = valid && mbedtls_sha256_finish_ret(&hash, digest) == 0
|
||||
&& memcmp(digest, trailer + 8, 8) == 0;
|
||||
mbedtls_sha256_free(&hash);
|
||||
if (valid) {
|
||||
identity.target_id = readLe32(trailer + 20);
|
||||
identity.body_bytes = marker;
|
||||
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: LoRa firmware has no valid EndF");
|
||||
return false;
|
||||
}
|
||||
|
||||
enum class BridgeMarkerResult { Missing, Present, ReadFailure };
|
||||
|
||||
BridgeMarkerResult containsBridgeMarker(const esp_partition_t& image,
|
||||
uint32_t body_bytes) {
|
||||
constexpr size_t marker_bytes = sizeof(kBridgeImageMarker) - 1;
|
||||
constexpr size_t legacy_marker_bytes = sizeof(kLegacyBridgeImageMarker) - 1;
|
||||
size_t matched = 0;
|
||||
size_t matched_legacy = 0;
|
||||
for (uint32_t offset = 0; offset < body_bytes;) {
|
||||
const uint32_t count = body_bytes - offset < sizeof(copy_buffer)
|
||||
? body_bytes - offset : sizeof(copy_buffer);
|
||||
if (esp_partition_read(&image, offset, copy_buffer, count) != ESP_OK) {
|
||||
strcpy(status_text, "Refused: could not inspect other LoRa image");
|
||||
return BridgeMarkerResult::ReadFailure;
|
||||
}
|
||||
for (uint32_t index = 0; index < count; ++index) {
|
||||
matched = copy_buffer[index] == kBridgeImageMarker[matched]
|
||||
? matched + 1
|
||||
: (copy_buffer[index] == kBridgeImageMarker[0] ? 1 : 0);
|
||||
matched_legacy = copy_buffer[index] == kLegacyBridgeImageMarker[matched_legacy]
|
||||
? matched_legacy + 1
|
||||
: (copy_buffer[index] == kLegacyBridgeImageMarker[0] ? 1 : 0);
|
||||
if (matched == marker_bytes || matched_legacy == legacy_marker_bytes)
|
||||
return BridgeMarkerResult::Present;
|
||||
}
|
||||
offset += count;
|
||||
delay(1);
|
||||
}
|
||||
return BridgeMarkerResult::Missing;
|
||||
}
|
||||
|
||||
bool validOtherLoRaFirmware(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: other LoRa firmware target does not match bridge");
|
||||
return false;
|
||||
}
|
||||
const BridgeMarkerResult marker = containsBridgeMarker(
|
||||
receiver, receiver_identity.body_bytes);
|
||||
if (marker != BridgeMarkerResult::Missing) {
|
||||
if (marker == BridgeMarkerResult::Present)
|
||||
strcpy(status_text, "Refused: other LoRa slot contains a migration bridge");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
#if defined(MOTA_MIGRATION_TARGET_ID)
|
||||
bool stageExpanderHandoff() {
|
||||
Preferences nvs;
|
||||
if (!nvs.begin(kMigrationNvsNamespace, false)) {
|
||||
strcpy(status_text, "Could not open Partition Expander handoff record");
|
||||
return false;
|
||||
}
|
||||
const bool saved = nvs.putUChar(kExpanderHandoffKey, 0xA5) == 1;
|
||||
nvs.end();
|
||||
if (!saved) strcpy(status_text, "Could not save Partition Expander handoff record");
|
||||
return saved;
|
||||
}
|
||||
|
||||
bool hasExpanderHandoff() {
|
||||
Preferences nvs;
|
||||
if (!nvs.begin(kMigrationNvsNamespace, true)) return false;
|
||||
const bool pending = nvs.getUChar(kExpanderHandoffKey, 0) == 0xA5;
|
||||
nvs.end();
|
||||
return pending;
|
||||
}
|
||||
|
||||
bool hasVerifiedConfigHandoff() {
|
||||
Preferences nvs;
|
||||
if (!nvs.begin(kMigrationNvsNamespace, true)) return false;
|
||||
const bool restored = nvs.getBool(kMigrationConfigRestoredKey, false);
|
||||
nvs.end();
|
||||
return restored;
|
||||
}
|
||||
|
||||
bool returnToVerifiedFull() {
|
||||
const esp_partition_t* running = esp_ota_get_running_partition();
|
||||
const esp_partition_t* other = esp_ota_get_next_update_partition(nullptr);
|
||||
if (!running || !other || running->address == other->address) return false;
|
||||
OtaImageIdentity bridge;
|
||||
OtaImageIdentity full;
|
||||
if (!readVerifiedOtaIdentity(*running, bridge)
|
||||
|| !readVerifiedOtaIdentity(*other, full)
|
||||
|| full.target_id != (uint32_t)MOTA_MIGRATION_TARGET_ID
|
||||
|| memcmp(full.hardware_id, bridge.hardware_id,
|
||||
sizeof(full.hardware_id)) != 0
|
||||
|| containsBridgeMarker(*other, full.body_bytes)
|
||||
!= BridgeMarkerResult::Missing) return false;
|
||||
const esp_err_t selected = esp_ota_set_boot_partition(other);
|
||||
if (selected != ESP_OK) {
|
||||
snprintf(status_text, sizeof(status_text),
|
||||
"Could not return to verified Full image: %s", errName(selected));
|
||||
return false;
|
||||
}
|
||||
strcpy(status_text, "Already expanded; returning to verified Full image");
|
||||
reboot_at = millis() + 1000;
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
|
||||
#if defined(MESHCORE_MIGRATION_RESUME_OTA)
|
||||
|
||||
bool stageResumeSlot(uint8_t slot) {
|
||||
Preferences migration_nvs;
|
||||
const bool opened = migration_nvs.begin(kMigrationNvsNamespace, false);
|
||||
const bool saved = opened && migration_nvs.putUChar(kMigrationResumeSlotKey, slot) == 1;
|
||||
if (opened) migration_nvs.end();
|
||||
if (!saved) strcpy(status_text, "Could not stage old LoRa application slot");
|
||||
return saved;
|
||||
}
|
||||
|
||||
bool resumeLegacyOtaReceiver(const migration::PartitionGeometry& geometry) {
|
||||
uint8_t slot = 0xFF;
|
||||
nvs_handle_t nvs_handle;
|
||||
const esp_err_t opened = nvs_open(kMigrationNvsNamespace, NVS_READONLY, &nvs_handle);
|
||||
if (opened == ESP_OK) {
|
||||
const esp_err_t read = nvs_get_u8(nvs_handle, kMigrationResumeSlotKey, &slot);
|
||||
nvs_close(nvs_handle);
|
||||
if (read != ESP_OK && read != ESP_ERR_NVS_NOT_FOUND) {
|
||||
snprintf(status_text, sizeof(status_text), "Refused: LoRa handoff NVS read failed: %s",
|
||||
errName(read));
|
||||
return false;
|
||||
}
|
||||
} else if (opened != ESP_ERR_NVS_NOT_FOUND) {
|
||||
snprintf(status_text, sizeof(status_text), "Refused: LoRa handoff NVS open failed: %s",
|
||||
errName(opened));
|
||||
return false;
|
||||
}
|
||||
const esp_partition_t* running = esp_ota_get_running_partition();
|
||||
const migration::LoRaHandoffPlan handoff = migration::planLoRaHandoff(
|
||||
geometry, running ? running->address : 0, slot);
|
||||
if (!handoff.valid) {
|
||||
strcpy(status_text, "Refused: LoRa handoff record or running slot is unsafe");
|
||||
return false;
|
||||
}
|
||||
const esp_partition_t* other_app = esp_partition_find_first(
|
||||
ESP_PARTITION_TYPE_APP,
|
||||
handoff.other_slot == 0 ? ESP_PARTITION_SUBTYPE_APP_OTA_0
|
||||
: ESP_PARTITION_SUBTYPE_APP_OTA_1,
|
||||
nullptr);
|
||||
if (!other_app || !running || other_app->address == running->address
|
||||
|| !validOtherLoRaFirmware(*running, *other_app)) {
|
||||
if (!other_app || !running || other_app->address == running->address)
|
||||
strcpy(status_text, "Refused: other LoRa application slot is unavailable");
|
||||
return false;
|
||||
}
|
||||
const esp_err_t selected = esp_ota_set_boot_partition(other_app);
|
||||
if (selected != ESP_OK) {
|
||||
snprintf(status_text, sizeof(status_text), "Could not boot verified LoRa app: %s",
|
||||
errName(selected));
|
||||
return false;
|
||||
}
|
||||
if (handoff.has_record) {
|
||||
Preferences migration_nvs;
|
||||
if (migration_nvs.begin(kMigrationNvsNamespace, false)) {
|
||||
migration_nvs.remove(kMigrationResumeSlotKey);
|
||||
migration_nvs.end();
|
||||
}
|
||||
}
|
||||
strcpy(status_text, "Expanded layout ready; returning to verified LoRa firmware");
|
||||
reboot_at = millis() + 1000;
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
#endif // MESHCORE_MIGRATION_RESUME_OTA || MOTA_MIGRATION_TARGET_ID
|
||||
|
||||
bool publishExpandedPartitionTable(const migration::TargetPlan& plan) {
|
||||
// Preserve ESP-IDF's normal OS flash hooks. In particular, their start/end
|
||||
// hooks suspend the other core and safely disable/re-enable caches. Do not
|
||||
@@ -354,11 +854,11 @@ bool publishExpandedPartitionTable(const migration::TargetPlan& plan) {
|
||||
if (result == ESP_OK) {
|
||||
result = esp_flash_write(chip, partition_table_bytes,
|
||||
migration::kPartitionTableAddress,
|
||||
sizeof(partition_table_bytes));
|
||||
plan.partition_table_prefix_bytes);
|
||||
if (result == ESP_OK) {
|
||||
result = esp_flash_read(chip, partition_table_verified,
|
||||
migration::kPartitionTableAddress,
|
||||
sizeof(partition_table_verified));
|
||||
plan.partition_table_prefix_bytes);
|
||||
ok = result == ESP_OK && memcmp(partition_table_verified,
|
||||
plan.partition_table_prefix, plan.partition_table_prefix_bytes) == 0;
|
||||
if (!ok) strcpy(status_text, "Partition table verification failed");
|
||||
@@ -402,16 +902,59 @@ void runMigration() {
|
||||
return;
|
||||
}
|
||||
|
||||
#if defined(MESHCORE_MIGRATION_RESUME_OTA)
|
||||
const migration::LoRaResumePlan resume = migration::planLoRaResume(
|
||||
geometry, plan->layout, running->address);
|
||||
if (!resume.valid) {
|
||||
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 (!validOtherLoRaFirmware(*running, *old_receiver)) {
|
||||
Serial.println(status_text);
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
|
||||
Serial.println("Migration: staging private key; do not interrupt power");
|
||||
if (!stageLegacyIdentity()) {
|
||||
Serial.println(status_text);
|
||||
return;
|
||||
}
|
||||
Serial.println("Migration: private key safely staged in NVS");
|
||||
#if !defined(MESHCORE_MIGRATION_RESUME_OTA)
|
||||
if (!stageLegacyConfig()) {
|
||||
Serial.println(status_text);
|
||||
return;
|
||||
}
|
||||
Serial.println("Migration: ACL, radio profiles and node configuration safely staged in NVS");
|
||||
#if defined(MOTA_MIGRATION_TARGET_ID)
|
||||
if (!stageExpanderHandoff()) {
|
||||
Serial.println(status_text);
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
|
||||
// Always make target app0 contain this migration image. If it was uploaded
|
||||
// into a different legacy slot, this preserves a Wi-Fi endpoint after the
|
||||
// table change. Never erase a running source range while copying it.
|
||||
// The Wi-Fi bridge always runs from target app0. The LoRa bridge instead
|
||||
// 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)
|
||||
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.
|
||||
if (running->address != plan->layout.app0_address) {
|
||||
if (running->size > plan->layout.app0_size) {
|
||||
strcpy(status_text, "Refused: migration slot is larger than future app0");
|
||||
@@ -432,29 +975,33 @@ void runMigration() {
|
||||
}
|
||||
Serial.println("Migration: bridge image copied to app0");
|
||||
}
|
||||
#endif
|
||||
|
||||
// The OTA-select data records a slot identity. Select a descriptor for the
|
||||
// target app0 before replacing the table so the next boot runs this bridge
|
||||
// at the target table's app0 address, never the blank new app1.
|
||||
const esp_partition_t target_app0 = rawApp0Partition(plan->layout.app0_address,
|
||||
plan->layout.app0_size,
|
||||
"app0");
|
||||
const esp_err_t select_result = esp_ota_set_boot_partition(&target_app0);
|
||||
// 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 boot_in_app0 = resume.copy_app1
|
||||
? resume.bridge_slot == 0 : resume.resume_slot == 0;
|
||||
#else
|
||||
const bool boot_in_app0 = true;
|
||||
#endif
|
||||
const esp_err_t select_result = esp_ota_set_boot_partition(
|
||||
boot_in_app0 ? refs.app0 : refs.app1);
|
||||
if (select_result != ESP_OK) {
|
||||
snprintf(status_text, sizeof(status_text), "Could not select app0: %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: app0 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
|
||||
// post a flash-backed event while the partition-sector operation disables the
|
||||
// flash cache. Stop UART0 as well: the S3's serial event path is otherwise
|
||||
// still able to interrupt the raw flash operation. The bridge itself is
|
||||
// deliberately Wi-Fi-only; the final normal repeater build restores its
|
||||
// standard USB behavior.
|
||||
// still able to interrupt the raw flash operation. The bridge has no native
|
||||
// USB CDC; the final normal repeater build restores its standard USB behavior.
|
||||
Serial.flush();
|
||||
Serial.end();
|
||||
const bool table_published = publishExpandedPartitionTable(*plan);
|
||||
@@ -469,7 +1016,13 @@ void runMigration() {
|
||||
if (kMigrationRestartDelayMs == 0) {
|
||||
strcpy(status_text, "Partition table verified; waiting for test reboot");
|
||||
} else {
|
||||
#if defined(MESHCORE_MIGRATION_RESUME_OTA)
|
||||
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
|
||||
reboot_at = millis() + kMigrationRestartDelayMs;
|
||||
}
|
||||
Serial.println(status_text);
|
||||
@@ -480,13 +1033,29 @@ void sendHome(AsyncWebServerRequest* request) {
|
||||
String page;
|
||||
page.reserve(1000);
|
||||
page += "<!doctype html><meta name=viewport content='width=device-width,initial-scale=1'>";
|
||||
#if defined(MESHCORE_MIGRATION_RESUME_OTA)
|
||||
page += "<!--";
|
||||
page += kBridgeImageMarker;
|
||||
page += "-->";
|
||||
#endif
|
||||
page += "<h2>MeshCore Wi-Fi partition migration</h2><p>";
|
||||
page += mode;
|
||||
page += "</p>";
|
||||
if (strstr(status_text, "ready") != nullptr) {
|
||||
page += "<p>The expanded partition layout is active and the private device "
|
||||
"identity was staged and restored before this uploader was exposed.</p>"
|
||||
"<p><a href='/update'>Upload the full application image for this board.</a>.</p>";
|
||||
#if defined(MOTA_MIGRATION_TARGET_ID)
|
||||
if (expanded_layout_ready && identity_ready) {
|
||||
page += "<p>";
|
||||
page += partitionExpanderStatus();
|
||||
page += "</p>";
|
||||
}
|
||||
#endif
|
||||
if (expanded_layout_ready && identity_ready) {
|
||||
page += "<p>The expanded partition layout is active. If the bridge could "
|
||||
"not return to a verified LoRa image, Wi-Fi recovery is available "
|
||||
"here: <a href='/update'>upload the correct Full application</a>. "
|
||||
"Check the status above before proceeding.</p>";
|
||||
} else if (expanded_layout_ready) {
|
||||
page += "<p>Private identity recovery did not complete. Firmware upload is "
|
||||
"disabled; restart the bridge and inspect the status before retrying.</p>";
|
||||
} else if (migration_complete) {
|
||||
page += "<p>Partition-table bytes were read back successfully. The test harness "
|
||||
"is waiting for an explicit reboot.</p><p><a href='/reboot'>Restart now</a></p>";
|
||||
@@ -536,7 +1105,7 @@ void startServer() {
|
||||
request->send(200, "text/plain", "Restarting migration bridge");
|
||||
reboot_requested = true;
|
||||
});
|
||||
AsyncElegantOTA.begin(&server);
|
||||
if (!expanded_layout_ready || identity_ready) AsyncElegantOTA.begin(&server);
|
||||
server.begin();
|
||||
}
|
||||
|
||||
@@ -551,8 +1120,18 @@ void setup() {
|
||||
const uint32_t flash_bytes = ESP.getFlashChipSize();
|
||||
if (findPartitions(refs, geometry)
|
||||
&& migration::isTargetLayout(flash_bytes, geometry)) {
|
||||
if (restoreStagedIdentity()) {
|
||||
expanded_layout_ready = true;
|
||||
if (validateStagedConfig() && restoreStagedIdentity()
|
||||
#if !defined(MESHCORE_MIGRATION_RESUME_OTA)
|
||||
&& restoreStagedConfig()
|
||||
&& verifyExpandedIdentityFile()
|
||||
#endif
|
||||
) {
|
||||
identity_ready = true;
|
||||
strcpy(status_text, "Expanded layout ready");
|
||||
#if defined(MESHCORE_MIGRATION_RESUME_OTA)
|
||||
resumeLegacyOtaReceiver(geometry);
|
||||
#endif
|
||||
}
|
||||
} else if (findPartitions(refs, geometry)
|
||||
&& migration::canMigrateGeneric(flash_bytes, geometry)) {
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
// A self-hosted partition bridge. Reuse the tested Wi-Fi migration engine so
|
||||
// both legacy OTA placements preserve the running image and node configuration.
|
||||
// After its restart into expanded app0, this role also receives the final
|
||||
// Full application over LoRa; the old application need not remain bootable.
|
||||
#define setup partitionMigrationSetup
|
||||
#define loop partitionMigrationLoop
|
||||
#include "../esp32_partition_migrator/main.cpp"
|
||||
#undef setup
|
||||
#undef loop
|
||||
|
||||
#include <Mesh.h>
|
||||
#include <target.h>
|
||||
#include <helpers/ArduinoHelpers.h>
|
||||
#include <helpers/ESP32TrueRandom.h>
|
||||
#include <helpers/CommonCLI.h>
|
||||
#include <helpers/IdentityStore.h>
|
||||
#include <helpers/SimpleMeshTables.h>
|
||||
#include <helpers/StaticPoolPacketManager.h>
|
||||
#include <helpers/ota/OtaContext.h>
|
||||
#include <helpers/ota/OtaApply.h>
|
||||
|
||||
#ifndef MOTA_MIGRATION_TARGET_ID
|
||||
#error "Partition Expander must pin the exact successor Full target ID"
|
||||
#endif
|
||||
|
||||
#ifndef LORA_FREQ
|
||||
#define LORA_FREQ 915.0
|
||||
#endif
|
||||
#ifndef LORA_BW
|
||||
#define LORA_BW 250.0
|
||||
#endif
|
||||
#ifndef LORA_SF
|
||||
#define LORA_SF 10
|
||||
#endif
|
||||
#ifndef LORA_CR
|
||||
#define LORA_CR 5
|
||||
#endif
|
||||
|
||||
namespace {
|
||||
|
||||
class ExpanderMesh final : public mesh::Mesh {
|
||||
public:
|
||||
ExpanderMesh(mesh::Radio& radio, mesh::MillisecondClock& millis_clock,
|
||||
mesh::RNG& rng, mesh::RTCClock& rtc,
|
||||
mesh::PacketManager& packets, mesh::MeshTables& tables)
|
||||
: mesh::Mesh(radio, millis_clock, rng, rtc, packets, tables) {}
|
||||
|
||||
bool isTempRadioActive() const override { return true; }
|
||||
};
|
||||
|
||||
ArduinoMillis uptime_clock;
|
||||
StdRNG fast_rng;
|
||||
StaticPoolPacketManager packets(32);
|
||||
SimpleMeshTables tables;
|
||||
ExpanderMesh the_mesh(radio_driver, uptime_clock, fast_rng, rtc_clock, packets, tables);
|
||||
bool lora_ready = false;
|
||||
bool install_attempted = false;
|
||||
mesh::RadioProfileParams active_profile;
|
||||
|
||||
// /com_prefs stores these fields at fixed offsets in both stock 1.17.1 and
|
||||
// keymindCascade. Keep the migration receiver on the user's saved radio1
|
||||
// channel so an existing LoRa seeder can reach it without a USB-only retune.
|
||||
bool loadPrimaryRadio(mesh::RadioProfileParams& profile) {
|
||||
bool ok = false;
|
||||
if (SPIFFS.exists("/com_prefs") || SPIFFS.exists("/node_prefs")) {
|
||||
File prefs = SPIFFS.open(SPIFFS.exists("/com_prefs")
|
||||
? "/com_prefs" : "/node_prefs", "r");
|
||||
if (prefs && prefs.size() >= 290) {
|
||||
ok = prefs.seek(72) && prefs.readBytes(reinterpret_cast<char*>(&profile.freq), 4) == 4;
|
||||
ok = ok && prefs.seek(112) && prefs.readBytes(reinterpret_cast<char*>(&profile.sf), 1) == 1;
|
||||
ok = ok && prefs.readBytes(reinterpret_cast<char*>(&profile.cr), 1) == 1;
|
||||
ok = ok && prefs.seek(116) && prefs.readBytes(reinterpret_cast<char*>(&profile.bw), 4) == 4;
|
||||
}
|
||||
if (prefs) prefs.close();
|
||||
} else if (SPIFFS.exists("/prefs.json")) {
|
||||
File prefs = SPIFFS.open("/prefs.json", "r");
|
||||
NodePrefs legacy;
|
||||
ok = prefs && legacy.loadSerial(prefs);
|
||||
if (prefs) prefs.close();
|
||||
if (ok) {
|
||||
profile.freq = legacy.freq;
|
||||
profile.bw = legacy.bw;
|
||||
profile.sf = legacy.sf;
|
||||
profile.cr = legacy.cr;
|
||||
}
|
||||
}
|
||||
return ok && profile.freq >= 100.0f && profile.freq <= 2500.0f
|
||||
&& profile.bw > 0.0f && profile.bw <= 500.0f
|
||||
&& profile.sf >= 5 && profile.sf <= 12
|
||||
&& profile.cr >= 5 && profile.cr <= 8;
|
||||
}
|
||||
|
||||
void startLoRaReceiver() {
|
||||
board.begin();
|
||||
if (!radio_init()) {
|
||||
Serial.println("Partition Expander: radio init failed; Wi-Fi recovery remains available");
|
||||
return;
|
||||
}
|
||||
fast_rng.begin(radio_driver.getRngSeed());
|
||||
|
||||
if (!SPIFFS.begin(false)) {
|
||||
Serial.println("Partition Expander: identity filesystem unavailable");
|
||||
return;
|
||||
}
|
||||
IdentityStore identity(SPIFFS, "/identity");
|
||||
if (identity.loadResult("_main", the_mesh.self_id) != IdentityLoadResult::Loaded) {
|
||||
Serial.println("Partition Expander: private identity unavailable; LoRa disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
mesh::RadioProfileParams profile;
|
||||
profile.freq = LORA_FREQ;
|
||||
profile.bw = LORA_BW;
|
||||
profile.sf = LORA_SF;
|
||||
profile.cr = LORA_CR;
|
||||
// HIL recipe: save 909.5 MHz / 500 kHz / SF5 / CR5 as radio1 on
|
||||
// both bench nodes before migration. Production recovery must still use
|
||||
// the node's own saved radio1, never a hard-coded lab frequency.
|
||||
if (!loadPrimaryRadio(profile)) {
|
||||
Serial.println("Partition Expander: saved radio1 unavailable; using build preset");
|
||||
}
|
||||
if (radio_driver.trySetPrimaryParams(profile, true)
|
||||
!= mesh::RadioParamApplyResult::APPLIED) {
|
||||
Serial.println("Partition Expander: OTA radio profile refused");
|
||||
return;
|
||||
}
|
||||
active_profile = profile;
|
||||
|
||||
the_mesh.begin();
|
||||
auto& ota = mesh::ota::ota_ctx();
|
||||
ota.manager.set_auto_migration_target((uint32_t)MOTA_MIGRATION_TARGET_ID);
|
||||
ota.manager.set_auto_version_floor(0, false);
|
||||
ota.manager.set_autofetch(mesh::ota::OtaManager::AUTOFETCH_ANY);
|
||||
lora_ready = true;
|
||||
Serial.println("Partition Expander: waiting for exact Full target over LoRa");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
String partitionExpanderStatus() {
|
||||
if (reboot_at) return "Returning to the verified Full image";
|
||||
if (!lora_ready) return "LoRa receiver unavailable; use Wi-Fi recovery";
|
||||
const auto& manager = mesh::ota::ota_ctx().manager;
|
||||
char detail[160];
|
||||
snprintf(detail, sizeof(detail),
|
||||
"LoRa receiver %.3f MHz / %.1f kHz / SF%u / CR%u; "
|
||||
"OTA state %u; blocks %lu/%lu; request window %u/%u",
|
||||
active_profile.freq, active_profile.bw,
|
||||
(unsigned)active_profile.sf, (unsigned)active_profile.cr,
|
||||
(unsigned)manager.fetchState(),
|
||||
(unsigned long)manager.blocksHave(),
|
||||
(unsigned long)manager.blocksTotal(),
|
||||
(unsigned)manager.fetchPipelineWidth(),
|
||||
(unsigned)manager.fetchPipelineCapacity());
|
||||
return detail;
|
||||
}
|
||||
|
||||
void setup() {
|
||||
partitionMigrationSetup();
|
||||
// Never start a LoRa fetch against the 1.25 MiB layout or before the key is
|
||||
// restored. The migration engine restarts after verifying the new table.
|
||||
if (!expanded_layout_ready || !identity_ready) return;
|
||||
// On an already-expanded board the temporary role may have landed next to
|
||||
// a valid Full image. Return to it instead of needlessly replacing it.
|
||||
if (returnToVerifiedFull()) return;
|
||||
if (!hasExpanderHandoff()) {
|
||||
strcpy(status_text,
|
||||
"Already expanded; no migration handoff. Wi-Fi recovery available");
|
||||
return;
|
||||
}
|
||||
if (!hasVerifiedConfigHandoff()) {
|
||||
strcpy(status_text,
|
||||
"Refused: saved configuration was not verified; Wi-Fi recovery available");
|
||||
return;
|
||||
}
|
||||
startLoRaReceiver();
|
||||
if (lora_ready) strcpy(status_text, "Expanded layout ready; LoRa receiver active");
|
||||
}
|
||||
|
||||
void loop() {
|
||||
partitionMigrationLoop();
|
||||
if (!lora_ready) return;
|
||||
the_mesh.loop();
|
||||
auto& ota = mesh::ota::ota_ctx();
|
||||
if (!install_attempted
|
||||
&& ota.manager.fetchState() == mesh::ota::OtaManager::COMPLETE) {
|
||||
install_attempted = true;
|
||||
uint8_t manifest_bytes[mesh::ota::MOTA_MFL];
|
||||
mesh::ota::MotaManifest manifest;
|
||||
if (!ota.fetch_store.read(8, manifest_bytes, sizeof(manifest_bytes))
|
||||
|| !mesh::ota::mota_parse_manifest(manifest_bytes,
|
||||
sizeof(manifest_bytes), manifest)
|
||||
|| !manifest.is_full()
|
||||
|| manifest.target_id != (uint32_t)MOTA_MIGRATION_TARGET_ID) {
|
||||
Serial.println("Partition Expander: staged image is not the pinned Full target");
|
||||
return;
|
||||
}
|
||||
char message[100] = {};
|
||||
const bool installed = ota.apply_fetched(message);
|
||||
Serial.println(message);
|
||||
if (installed) {
|
||||
// This role has no CLI reply queue or role-specific reboot scheduler.
|
||||
// apply_fetched() only arms the ESP32 OTA slot; it does not restart.
|
||||
Serial.flush();
|
||||
delay(200);
|
||||
mesh::ota::ota_reboot_to_apply();
|
||||
} else {
|
||||
Serial.println("Partition Expander: Full install refused; restart to retry");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user