diff --git a/src/activities/settings/SdFirmwareUpdateActivity.cpp b/src/activities/settings/SdFirmwareUpdateActivity.cpp new file mode 100644 index 0000000000..b0ca1f6340 --- /dev/null +++ b/src/activities/settings/SdFirmwareUpdateActivity.cpp @@ -0,0 +1,255 @@ +#include "SdFirmwareUpdateActivity.h" + +#include +#include +#include +#include +#include +#include + +#include "MappedInputManager.h" +#include "activities/home/FileBrowserActivity.h" +#include "activities/util/ConfirmationActivity.h" +#include "components/UITheme.h" +#include "fontIds.h" +#include "network/FirmwareFlasher.h" + +void SdFirmwareUpdateActivity::onEnter() { + Activity::onEnter(); + // Build-identity marker — confirms which firmware build owns the SD update flow. + LOG_INF("FW", "SdFirmwareUpdateActivity build=%s %s recovery=%d", __DATE__, __TIME__, recoveryMode ? 1 : 0); + state = State::PICKING; + launchPicker(); +} + +void SdFirmwareUpdateActivity::launchPicker() { + // Reuse the standard file browser, restricted to .bin files only. + startActivityForResult( + std::make_unique(renderer, mappedInput, "/", FileBrowserActivity::Mode::PickFirmware), + [this](const ActivityResult& result) { onPickerResult(result); }); +} + +void SdFirmwareUpdateActivity::onPickerResult(const ActivityResult& result) { + if (result.isCancelled) { + if (recoveryMode) { + // Recovery mode: re-launch the picker so the user cannot escape into a half-initialised UI. + launchPicker(); + return; + } + finish(); + return; + } + + const auto* path = std::get_if(&result.data); + if (!path) { + LOG_ERR("FW", "Picker returned no path"); + finish(); + return; + } + firmwarePath = path->path; + LOG_DBG("FW", "Selected: %s", firmwarePath.c_str()); + + { + RenderLock lock(*this); + state = State::VALIDATING; + } + requestUpdateAndWait(); + + if (!validateFirmware()) { + RenderLock lock(*this); + state = State::FAILED; + requestUpdate(); + return; + } + + promptConfirmation(); +} + +bool SdFirmwareUpdateActivity::validateFirmware() { + HalFile file; + if (!Storage.openFileForRead("FW", firmwarePath.c_str(), file) || !file) { + errorMessage = tr(STR_FIRMWARE_FILE_OPEN_FAILED); + return false; + } + firmwareSize = file.fileSize(); + file.close(); + + // Resolve the next-update partition directly via the OTA API. Previously this + // probed via Update.begin(firmwareSize)/Update.abort() to learn the partition + // size, which had the side effect of erasing partition state and was wasted + // work since we only need the size bound for validation here. + const esp_partition_t* dest = esp_ota_get_next_update_partition(nullptr); + if (!dest) { + LOG_ERR("FW", "no next-update partition available"); + errorMessage = tr(STR_INVALID_FIRMWARE); + return false; + } + const size_t partitionLimit = dest->size; + if (firmwareSize > partitionLimit) { + LOG_ERR("FW", "firmware (%u bytes) exceeds partition (%u bytes)", static_cast(firmwareSize), + static_cast(partitionLimit)); + errorMessage = tr(STR_FIRMWARE_TOO_LARGE); + return false; + } + + // Run the same end-to-end integrity check (header / segment table / XOR checksum / SHA256 + // trailer) that the shared firmware-flasher applies right before raw-writing otadata. This + // catches truncated or corrupted .bin files at confirmation time, before the user ever sees + // the "Updating…" progress bar. + const auto vr = firmware_flash::validateImageFile(firmwarePath.c_str(), partitionLimit); + if (vr != firmware_flash::Result::OK) { + LOG_ERR("FW", "image validation failed: %s", firmware_flash::resultName(vr)); + if (vr == firmware_flash::Result::TOO_LARGE) { + errorMessage = tr(STR_FIRMWARE_TOO_LARGE); + } else if (vr == firmware_flash::Result::TOO_SMALL) { + errorMessage = tr(STR_FIRMWARE_TOO_SMALL); + } else { + errorMessage = tr(STR_INVALID_FIRMWARE); + } + return false; + } + return true; +} + +void SdFirmwareUpdateActivity::promptConfirmation() { + { + RenderLock lock(*this); + state = State::CONFIRMING; + } + // Show "Update firmware?" with the file path as the body line. + std::string heading = tr(STR_FIRMWARE_UPDATE_PROMPT); + // Use the basename only to keep the body short. + std::string body = firmwarePath; + const auto pos = body.find_last_of('/'); + if (pos != std::string::npos) body = body.substr(pos + 1); + + startActivityForResult(std::make_unique(renderer, mappedInput, heading, body), + [this](const ActivityResult& result) { onConfirmationResult(result); }); +} + +void SdFirmwareUpdateActivity::onConfirmationResult(const ActivityResult& result) { + if (result.isCancelled) { + if (recoveryMode) { + // Go back to the picker rather than exiting recovery. + launchPicker(); + return; + } + finish(); + return; + } + + { + RenderLock lock(*this); + state = State::UPDATING; + writtenBytes = 0; + lastRenderedPercent = 101; + } + requestUpdateAndWait(); + performUpdate(); +} + +void SdFirmwareUpdateActivity::performUpdate() { + LOG_INF("FW", "SD update: %s (%u bytes)", firmwarePath.c_str(), static_cast(firmwareSize)); + + auto progressCb = +[](size_t written, size_t total, void* ctx) { + auto* self = static_cast(ctx); + self->writtenBytes = written; + self->firmwareSize = total; + // immediate=true: wake the render task directly. We're in a tight sync + // loop so the main loop won't drain the requestedUpdate flag for us. + self->requestUpdate(true); + }; + + // Re-validate at flash time (TOCTOU): SD is removable, so don't trust the + // pre-confirmation pass. The alreadyValidated parameter on the API stays + // for callers (e.g. an OTA staging path) where the same byte stream was + // just hashed and there's no removable-media gap. + const auto result = firmware_flash::flashFromSdPath(firmwarePath.c_str(), progressCb, this); + if (result != firmware_flash::Result::OK) { + LOG_ERR("FW", "flash failed: %s", firmware_flash::resultName(result)); + errorMessage = tr(STR_FIRMWARE_WRITE_FAILED); + RenderLock lock(*this); + state = State::FAILED; + requestUpdate(); + return; + } + + LOG_INF("FW", "SD firmware update complete, restarting"); + { + RenderLock lock(*this); + state = State::SUCCESS; + } + requestUpdateAndWait(); + delay(1500); + ESP.restart(); +} + +void SdFirmwareUpdateActivity::loop() { + if (state == State::FAILED) { + if (mappedInput.wasPressed(MappedInputManager::Button::Back) || + mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { + if (recoveryMode) { + // Go back to picker so user can try a different .bin + state = State::PICKING; + launchPicker(); + return; + } + finish(); + } + } +} + +void SdFirmwareUpdateActivity::render(RenderLock&&) { + const auto& metrics = UITheme::getInstance().getMetrics(); + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + + renderer.clearScreen(); + + const char* headerText = recoveryMode ? tr(STR_RECOVERY_MODE) : tr(STR_SD_FIRMWARE_UPDATE); + GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, headerText); + + const auto lineHeight = renderer.getLineHeight(UI_10_FONT_ID); + const auto top = (pageHeight - lineHeight) / 2; + + if (state == State::VALIDATING) { + renderer.drawCenteredText(UI_10_FONT_ID, top, tr(STR_VALIDATING_FIRMWARE)); + } else if (state == State::UPDATING) { + // Throttle redraws to once per percent. + const unsigned int pct = firmwareSize > 0 ? static_cast((writtenBytes * 100) / firmwareSize) : 0; + if (pct == lastRenderedPercent) { + return; + } + lastRenderedPercent = pct; + + renderer.drawCenteredText(UI_10_FONT_ID, top, tr(STR_UPDATING), true, EpdFontFamily::BOLD); + + int y = top + lineHeight + metrics.verticalSpacing; + GUI.drawProgressBar( + renderer, + Rect{metrics.contentSidePadding, y, pageWidth - metrics.contentSidePadding * 2, metrics.progressBarHeight}, + static_cast(pct), 100); + y += metrics.progressBarHeight + metrics.verticalSpacing; + // Percent label is drawn by BaseTheme::drawProgressBar; this slot is left intentionally empty + // so the do-not-power-off line below stays at the same Y as before. + y += lineHeight + metrics.verticalSpacing; + renderer.drawCenteredText(UI_10_FONT_ID, y, tr(STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF)); + } else if (state == State::SUCCESS) { + renderer.drawCenteredText(UI_10_FONT_ID, top, tr(STR_UPDATE_COMPLETE), true, EpdFontFamily::BOLD); + renderer.drawCenteredText(UI_10_FONT_ID, top + lineHeight + metrics.verticalSpacing, tr(STR_RESTARTING_HINT)); + } else if (state == State::FAILED) { + renderer.drawCenteredText(UI_10_FONT_ID, top, tr(STR_UPDATE_FAILED), true, EpdFontFamily::BOLD); + if (!errorMessage.empty()) { + renderer.drawCenteredText(UI_10_FONT_ID, top + lineHeight + metrics.verticalSpacing, errorMessage.c_str()); + } + const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + } else { + // PICKING / CONFIRMING: a sub-activity is on top, nothing to draw. + if (recoveryMode) { + renderer.drawCenteredText(UI_10_FONT_ID, top, tr(STR_RECOVERY_MODE_HINT)); + } + } + + renderer.displayBuffer(); +} diff --git a/src/activities/settings/SdFirmwareUpdateActivity.h b/src/activities/settings/SdFirmwareUpdateActivity.h new file mode 100644 index 0000000000..c6cec2c2f1 --- /dev/null +++ b/src/activities/settings/SdFirmwareUpdateActivity.h @@ -0,0 +1,56 @@ +#pragma once + +#include + +#include "activities/Activity.h" + +/** + * SD-card based firmware update activity. + * + * Flow: + * 1) onEnter -> push FileBrowserActivity in PickFirmware mode (only .bin files visible). + * 2) On result: validate the .bin (header magic, size fits OTA partition). + * 3) Push ConfirmationActivity ("Update firmware?"). + * 4) On confirm: stream the file into the OTA partition via the Arduino Update API, + * drawing a progress bar; on success ESP.restart(). + * + * Used both from Settings -> System -> "SD Card Firmware Update", and as the only + * activity launched in boot recovery mode (left side button + power on X3). + */ +class SdFirmwareUpdateActivity : public Activity { + public: + enum class State { + PICKING, + VALIDATING, + CONFIRMING, + UPDATING, + SUCCESS, + FAILED, + }; + + explicit SdFirmwareUpdateActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, bool recoveryMode = false) + : Activity("SdFirmwareUpdate", renderer, mappedInput), recoveryMode(recoveryMode) {} + + void onEnter() override; + void loop() override; + void render(RenderLock&&) override; + bool preventAutoSleep() override { return state == State::UPDATING || state == State::VALIDATING; } + bool skipLoopDelay() override { return state == State::UPDATING; } + + private: + State state = State::PICKING; + bool recoveryMode = false; + + std::string firmwarePath; + size_t firmwareSize = 0; + size_t writtenBytes = 0; + unsigned int lastRenderedPercent = 101; + std::string errorMessage; + + void launchPicker(); + void onPickerResult(const ActivityResult& result); + bool validateFirmware(); + void promptConfirmation(); + void onConfirmationResult(const ActivityResult& result); + void performUpdate(); +}; diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index cc7cbc2fef..8bf7cd62ac 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -18,6 +18,7 @@ #include "MappedInputManager.h" #include "OpdsServerListActivity.h" #include "OtaUpdateActivity.h" +#include "SdFirmwareUpdateActivity.h" #include "SettingsList.h" #include "StatusBarSettingsActivity.h" #include "activities/tools/SleepImagePickerActivity.h" @@ -365,6 +366,9 @@ void SettingsActivity::toggleCurrentSetting() { delay(500); ESP.restart(); break; + case SettingAction::SdFirmwareUpdate: + startActivityForResult(std::make_unique(renderer, mappedInput), resultHandler); + break; case SettingAction::None: // Do nothing break; diff --git a/src/network/FirmwareFlasher.cpp b/src/network/FirmwareFlasher.cpp new file mode 100644 index 0000000000..38369ee7a2 --- /dev/null +++ b/src/network/FirmwareFlasher.cpp @@ -0,0 +1,311 @@ +#include "FirmwareFlasher.h" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "OtaBootSwitch.h" + +namespace firmware_flash { + +namespace { +constexpr uint8_t ESP_IMAGE_MAGIC = 0xE9; +constexpr size_t MIN_FIRMWARE_SIZE = 64 * 1024; +constexpr size_t SEC = SPI_FLASH_SEC_SIZE; // 4 KiB +constexpr size_t BLK = 64 * 1024; // 64 KiB block-erase granularity +constexpr size_t CHUNK = 4096; +constexpr size_t SHA_TRAILER = 32; +constexpr uint8_t CHECKSUM_SEED = 0xEF; +constexpr size_t HEADER_SIZE = 24; +constexpr size_t SEG_HEADER_SIZE = 8; +} // namespace + +const char* resultName(Result r) { + switch (r) { + case Result::OK: + return "OK"; + case Result::OPEN_FAIL: + return "OPEN_FAIL"; + case Result::TOO_SMALL: + return "TOO_SMALL"; + case Result::TOO_LARGE: + return "TOO_LARGE"; + case Result::BAD_MAGIC: + return "BAD_MAGIC"; + case Result::BAD_SEGMENTS: + return "BAD_SEGMENTS"; + case Result::BAD_CHECKSUM: + return "BAD_CHECKSUM"; + case Result::BAD_SHA: + return "BAD_SHA"; + case Result::BAD_SIZE: + return "BAD_SIZE"; + case Result::NO_PARTITION: + return "NO_PARTITION"; + case Result::OOM: + return "OOM"; + case Result::READ_FAIL: + return "READ_FAIL"; + case Result::ERASE_FAIL: + return "ERASE_FAIL"; + case Result::WRITE_FAIL: + return "WRITE_FAIL"; + case Result::OTADATA_FAIL: + return "OTADATA_FAIL"; + } + return "?"; +} + +namespace { +// Stream `length` bytes from `file` starting at the current read offset, feeding them through +// both the XOR-checksum and SHA256 accumulators. Used by validateImageFile so the whole image +// is verified end-to-end without holding it in RAM (ESP32-C3 only has ~380 KB). +Result feedHashAndChecksum(HalFile& file, size_t length, uint8_t* xorAccum, mbedtls_sha256_context* sha, uint8_t* buf) { + size_t remaining = length; + while (remaining > 0) { + const size_t want = std::min(CHUNK, remaining); + const int got = file.read(buf, want); + if (got <= 0 || static_cast(got) != want) return Result::READ_FAIL; + if (sha) mbedtls_sha256_update(sha, buf, want); + if (xorAccum) { + uint8_t acc = *xorAccum; + for (size_t i = 0; i < want; i++) acc ^= buf[i]; + *xorAccum = acc; + } + remaining -= want; + } + return Result::OK; +} +} // namespace + +Result validateImageFile(const char* sdPath, size_t partitionSize) { + HalFile file; + if (!Storage.openFileForRead("FLASH", sdPath, file) || !file) { + LOG_ERR("FLASH", "validate: open failed: %s", sdPath); + return Result::OPEN_FAIL; + } + + const size_t fileSize = file.fileSize(); + if (fileSize < MIN_FIRMWARE_SIZE) { + LOG_ERR("FLASH", "validate: too small: %u", static_cast(fileSize)); + file.close(); + return Result::TOO_SMALL; + } + if (partitionSize > 0 && fileSize > partitionSize) { + LOG_ERR("FLASH", "validate: too large: %u > %u", static_cast(fileSize), + static_cast(partitionSize)); + file.close(); + return Result::TOO_LARGE; + } + + uint8_t header[HEADER_SIZE]; + if (file.read(header, HEADER_SIZE) != static_cast(HEADER_SIZE)) { + LOG_ERR("FLASH", "validate: header read failed"); + file.close(); + return Result::READ_FAIL; + } + if (header[0] != ESP_IMAGE_MAGIC) { + LOG_ERR("FLASH", "validate: bad magic 0x%02X", header[0]); + file.close(); + return Result::BAD_MAGIC; + } + const uint8_t segCount = header[1]; + const bool hashAppended = header[23] != 0; + + auto buf = std::unique_ptr(new (std::nothrow) uint8_t[CHUNK]); + if (!buf) { + file.close(); + return Result::OOM; + } + + mbedtls_sha256_context shaCtx; + mbedtls_sha256_init(&shaCtx); + mbedtls_sha256_starts(&shaCtx, /*is224=*/0); + mbedtls_sha256_update(&shaCtx, header, HEADER_SIZE); + + uint8_t xorAccum = CHECKSUM_SEED; + size_t pos = HEADER_SIZE; + + for (uint8_t i = 0; i < segCount; i++) { + if (pos + SEG_HEADER_SIZE > fileSize) { + LOG_ERR("FLASH", "validate: seg %u header overruns EOF at %u", i, static_cast(pos)); + mbedtls_sha256_free(&shaCtx); + file.close(); + return Result::BAD_SEGMENTS; + } + uint8_t segHdr[SEG_HEADER_SIZE]; + if (file.read(segHdr, SEG_HEADER_SIZE) != static_cast(SEG_HEADER_SIZE)) { + mbedtls_sha256_free(&shaCtx); + file.close(); + return Result::READ_FAIL; + } + mbedtls_sha256_update(&shaCtx, segHdr, SEG_HEADER_SIZE); + pos += SEG_HEADER_SIZE; + + uint32_t dataLen; + std::memcpy(&dataLen, segHdr + 4, sizeof(dataLen)); + if (pos + dataLen > fileSize) { + LOG_ERR("FLASH", "validate: seg %u data overruns EOF (%u + %u > %u)", i, static_cast(pos), + static_cast(dataLen), static_cast(fileSize)); + mbedtls_sha256_free(&shaCtx); + file.close(); + return Result::BAD_SEGMENTS; + } + + const Result feedRes = feedHashAndChecksum(file, dataLen, &xorAccum, &shaCtx, buf.get()); + if (feedRes != Result::OK) { + mbedtls_sha256_free(&shaCtx); + file.close(); + return feedRes; + } + pos += dataLen; + } + + // pad_end is the 16-byte aligned offset at which the checksum byte sits at pad_end - 1. + const size_t padEnd = (pos + 16) & ~static_cast(15); + const size_t expectedTotal = padEnd + (hashAppended ? SHA_TRAILER : 0); + if (expectedTotal != fileSize) { + LOG_ERR("FLASH", "validate: size mismatch body+pad=%u sha=%u expected=%u actual=%u", static_cast(padEnd), + static_cast(hashAppended ? SHA_TRAILER : 0), static_cast(expectedTotal), + static_cast(fileSize)); + mbedtls_sha256_free(&shaCtx); + file.close(); + return Result::BAD_SIZE; + } + + // Read the padding bytes (which include the stored checksum at the last byte) into the SHA stream. + const size_t padLen = padEnd - pos; + uint8_t padBuf[16]; + if (padLen > sizeof(padBuf)) { + mbedtls_sha256_free(&shaCtx); + file.close(); + return Result::BAD_SIZE; + } + if (padLen > 0 && file.read(padBuf, padLen) != static_cast(padLen)) { + mbedtls_sha256_free(&shaCtx); + file.close(); + return Result::READ_FAIL; + } + mbedtls_sha256_update(&shaCtx, padBuf, padLen); + + const uint8_t storedChecksum = padBuf[padLen - 1]; + if ((xorAccum & 0xFF) != storedChecksum) { + LOG_ERR("FLASH", "validate: checksum mismatch computed=0x%02X stored=0x%02X", xorAccum, storedChecksum); + mbedtls_sha256_free(&shaCtx); + file.close(); + return Result::BAD_CHECKSUM; + } + + if (hashAppended) { + uint8_t computed[SHA_TRAILER]; + mbedtls_sha256_finish(&shaCtx, computed); + uint8_t stored[SHA_TRAILER]; + if (file.read(stored, SHA_TRAILER) != static_cast(SHA_TRAILER)) { + mbedtls_sha256_free(&shaCtx); + file.close(); + return Result::READ_FAIL; + } + if (std::memcmp(computed, stored, SHA_TRAILER) != 0) { + LOG_ERR("FLASH", "validate: SHA256 mismatch"); + mbedtls_sha256_free(&shaCtx); + file.close(); + return Result::BAD_SHA; + } + } + + mbedtls_sha256_free(&shaCtx); + file.close(); + return Result::OK; +} + +Result flashFromSdPath(const char* sdPath, ProgressCb onProgress, void* ctx, bool alreadyValidated) { + // Resolve destination first so we can size-check during validation. The full image-integrity + // pass below verifies header, segment table, XOR checksum and SHA256 trailer end-to-end before + // we touch otadata, so a truncated/corrupted .bin can never become the next boot target. + const esp_partition_t* dest = esp_ota_get_next_update_partition(nullptr); + if (!dest) { + LOG_ERR("FLASH", "no next-update partition"); + return Result::NO_PARTITION; + } + + // When the caller already ran validateImageFile() against this same partition + // size (e.g. SdFirmwareUpdateActivity validates before the confirmation + // prompt), skip the redundant integrity scan. We still keep the partition + // lookup so the rest of the flashing path stays unchanged. + if (!alreadyValidated) { + const Result validateRes = validateImageFile(sdPath, dest->size); + if (validateRes != Result::OK) { + LOG_ERR("FLASH", "image validation failed: %s", resultName(validateRes)); + return validateRes; + } + } + + HalFile file; + if (!Storage.openFileForRead("FLASH", sdPath, file) || !file) { + LOG_ERR("FLASH", "open failed: %s", sdPath); + return Result::OPEN_FAIL; + } + + const size_t firmwareSize = file.fileSize(); + LOG_INF("FLASH", "src=%s size=%u dest=%s @0x%x partsize=%u", sdPath, static_cast(firmwareSize), dest->label, + static_cast(dest->address), static_cast(dest->size)); + + auto buffer = std::unique_ptr(new (std::nothrow) uint8_t[CHUNK]); + if (!buffer) { + LOG_ERR("FLASH", "OOM"); + file.close(); + return Result::OOM; + } + + // Interleave erase + write so the progress bar advances 0→100% smoothly + // rather than stalling for several seconds during a single up-front erase. + size_t streamPos = 0; + size_t erasedUpto = 0; + while (streamPos < firmwareSize) { + if (streamPos >= erasedUpto) { + size_t eraseLen = std::min(BLK, dest->size - streamPos); + eraseLen = (eraseLen + SEC - 1) & ~(SEC - 1); + eraseLen = std::min(eraseLen, dest->size - streamPos); + if (esp_partition_erase_range(dest, streamPos, eraseLen) != ESP_OK) { + LOG_ERR("FLASH", "erase @%u (len=%u) failed", static_cast(streamPos), + static_cast(eraseLen)); + file.close(); + return Result::ERASE_FAIL; + } + erasedUpto = streamPos + eraseLen; + } + + const size_t want = std::min(CHUNK, firmwareSize - streamPos); + const int read = file.read(buffer.get(), want); + if (read <= 0 || static_cast(read) != want) { + LOG_ERR("FLASH", "read @%u: got=%d want=%u", static_cast(streamPos), read, static_cast(want)); + file.close(); + return Result::READ_FAIL; + } + if (esp_partition_write(dest, streamPos, buffer.get(), want) != ESP_OK) { + LOG_ERR("FLASH", "write @%u failed", static_cast(streamPos)); + file.close(); + return Result::WRITE_FAIL; + } + streamPos += want; + if (onProgress) onProgress(streamPos, firmwareSize, ctx); + delay(1); + } + file.close(); + + if (!ota_boot::switchTo(dest)) { + LOG_ERR("FLASH", "otadata switch failed"); + return Result::OTADATA_FAIL; + } + return Result::OK; +} + +} // namespace firmware_flash diff --git a/src/network/FirmwareFlasher.h b/src/network/FirmwareFlasher.h new file mode 100644 index 0000000000..2d622c218e --- /dev/null +++ b/src/network/FirmwareFlasher.h @@ -0,0 +1,63 @@ +#pragma once + +#include +#include + +// Flash a firmware image from an SD-card path into the next OTA app +// partition, then switch otadata so the X3/X4 stock bootloader picks it up +// on next boot. Mirrors the web flasher: raw esp_partition_erase_range + +// esp_partition_write + ota_boot::switchTo (no Arduino Update class, no +// esp_image_verify — those reject our patched image on X4 silicon). +// +// Both the SD update activity and the OTA path land here. OTA first +// downloads the firmware to an SD-card cache file, then calls this. + +namespace firmware_flash { + +enum class Result { + OK, + OPEN_FAIL, + TOO_SMALL, + TOO_LARGE, + BAD_MAGIC, + BAD_SEGMENTS, // segment table malformed or runs past EOF + BAD_CHECKSUM, // ESP image XOR checksum mismatch + BAD_SHA, // SHA256 trailer mismatch (hash_appended images) + BAD_SIZE, // body+pad+sha length doesn't match file size + NO_PARTITION, + OOM, + READ_FAIL, + ERASE_FAIL, + WRITE_FAIL, + OTADATA_FAIL, +}; + +// Progress callback: called after every chunk write. `written`/`total` are bytes. +using ProgressCb = void (*)(size_t written, size_t total, void* ctx); + +// Open `sdPath`, validate it looks like an ESP32 image, then stream it into the +// next OTA app partition with interleaved 64 KiB erase + sector writes. On +// success switches otadata via ota_boot::switchTo. Caller is responsible for +// ESP.restart() afterwards. +// +// `alreadyValidated` lets callers that have just run `validateImageFile()` +// themselves (e.g. SdFirmwareUpdateActivity, which validates before showing +// the user the confirmation prompt) skip the redundant second pass. Defaults +// to false so callers without prior validation (any future entry point) keep +// the defense-in-depth check. +Result flashFromSdPath(const char* sdPath, ProgressCb onProgress, void* ctx, bool alreadyValidated = false); + +// Full-image integrity check that mirrors the bootloader's verification: +// header magic, segment table walk, XOR checksum, and SHA256 trailer (when +// hash_appended == 1). Run this before flashing a candidate firmware so a +// truncated/corrupted .bin never reaches otadata. +// +// `partitionSize` is the size of the destination OTA partition; pass 0 to +// skip the size-fits-partition check (e.g. when validating ahead of partition +// lookup). Streams the file in CHUNK-sized reads; the file is rewound on +// success so the caller can immediately reread it for flashing. +Result validateImageFile(const char* sdPath, size_t partitionSize); + +const char* resultName(Result r); + +} // namespace firmware_flash diff --git a/src/network/OtaBootSwitch.cpp b/src/network/OtaBootSwitch.cpp new file mode 100644 index 0000000000..7c3ca7624b --- /dev/null +++ b/src/network/OtaBootSwitch.cpp @@ -0,0 +1,86 @@ +#include "OtaBootSwitch.h" + +#include +#include +#include +#include + +namespace ota_boot { + +uint32_t computeSeqCrc(uint32_t seq) { + return esp_rom_crc32_le(UINT32_MAX, reinterpret_cast(&seq), kOtaSeqCrcLen); +} + +bool switchTo(const esp_partition_t* dest) { + if (!dest) return false; + + const esp_partition_t* otadata = + esp_partition_find_first(ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_OTA, nullptr); + if (!otadata) { + LOG_ERR("BOOT", "otadata partition not found"); + return false; + } + if (otadata->size < 2 * SPI_FLASH_SEC_SIZE) { + LOG_ERR("BOOT", "otadata too small: %u", static_cast(otadata->size)); + return false; + } + + SelectEntry slots[2] = {}; + if (esp_partition_read(otadata, 0, &slots[0], sizeof(SelectEntry)) != ESP_OK || + esp_partition_read(otadata, SPI_FLASH_SEC_SIZE, &slots[1], sizeof(SelectEntry)) != ESP_OK) { + LOG_ERR("BOOT", "otadata read failed"); + return false; + } + + // Pick the slot with valid CRC and highest seq, ignoring INVALID/ABORTED. + int activeIdx = -1; + uint32_t activeSeq = 0; + for (int i = 0; i < 2; ++i) { + if (slots[i].ota_seq == 0xFFFFFFFFu) continue; + if (slots[i].crc != computeSeqCrc(slots[i].ota_seq)) continue; + if (slots[i].ota_state == kOtaImgInvalid || slots[i].ota_state == kOtaImgAborted) continue; + if (activeIdx < 0 || slots[i].ota_seq > activeSeq) { + activeIdx = i; + activeSeq = slots[i].ota_seq; + } + } + LOG_INF("BOOT", "otadata: active slot=%d seq=%u", activeIdx, static_cast(activeSeq)); + + // ota_seq encoding: (seq - 1) % NUM_OTA_PARTITIONS picks the partition. + const uint32_t destOtaIdx = + static_cast(dest->subtype) - static_cast(ESP_PARTITION_SUBTYPE_APP_OTA_0); + if (destOtaIdx > 15) { + LOG_ERR("BOOT", "dest is not an OTA app partition (subtype=0x%02X)", dest->subtype); + return false; + } + + // Find smallest seq > activeSeq such that (seq-1) % 2 == destOtaIdx, + // assuming 2 OTA partitions (matches our partitions.csv with ota_0 + ota_1). + uint32_t newSeq = activeSeq + 1; + while (((newSeq - 1u) % 2u) != (destOtaIdx % 2u)) ++newSeq; + + SelectEntry next = {}; + next.ota_seq = newSeq; + memset(next.seq_label, 0xFF, sizeof(next.seq_label)); + next.ota_state = kOtaImgNew; + next.crc = computeSeqCrc(next.ota_seq); + + // Write to the OTHER slot (so the bootloader sees a higher seq there). + const int targetSlot = (activeIdx == 0) ? 1 : 0; + const size_t targetOff = static_cast(targetSlot) * SPI_FLASH_SEC_SIZE; + + if (esp_partition_erase_range(otadata, targetOff, SPI_FLASH_SEC_SIZE) != ESP_OK) { + LOG_ERR("BOOT", "otadata erase failed (slot=%d)", targetSlot); + return false; + } + if (esp_partition_write(otadata, targetOff, &next, sizeof(next)) != ESP_OK) { + LOG_ERR("BOOT", "otadata write failed (slot=%d)", targetSlot); + return false; + } + + LOG_INF("BOOT", "otadata: wrote slot=%d seq=%u crc=0x%08x -> %s", targetSlot, static_cast(newSeq), + static_cast(next.crc), dest->label); + return true; +} + +} // namespace ota_boot diff --git a/src/network/OtaBootSwitch.h b/src/network/OtaBootSwitch.h new file mode 100644 index 0000000000..3c52f7be48 --- /dev/null +++ b/src/network/OtaBootSwitch.h @@ -0,0 +1,43 @@ +#pragma once + +#include + +#include +#include + +// X4 (and X3) factory bootloaders accept our patch_firmware_image.py-patched +// firmware.bin (web flasher proves this), but the running ESP-IDF's +// esp_image_verify rejects with bogus efuse-blk-rev errors. Both SD-card and +// OTA update paths bypass that runtime check by writing the OTA app partition +// raw and updating otadata directly — same scheme as the web flasher +// (crosspoint-reader-docs/src/lib/flasher/OtaPartition.ts). +// +// Layout reference: esp_flash_partitions.h. CRC covers ota_seq (4 bytes) only. + +namespace ota_boot { + +struct __attribute__((packed)) SelectEntry { + uint32_t ota_seq; + uint8_t seq_label[20]; + uint32_t ota_state; + uint32_t crc; +}; +static_assert(sizeof(SelectEntry) == 32, "SelectEntry must be 32 bytes"); + +constexpr uint32_t kOtaImgNew = 0; // ESP_OTA_IMG_NEW +constexpr uint32_t kOtaImgInvalid = 3; // ESP_OTA_IMG_INVALID +constexpr uint32_t kOtaImgAborted = 4; // ESP_OTA_IMG_ABORTED +constexpr size_t kOtaSeqCrcLen = 4; + +// CRC32-LE over the 4-byte ota_seq, init UINT32_MAX. Matches IDF and web flasher. +uint32_t computeSeqCrc(uint32_t seq); + +// Switch the bootloader's selected app partition to `dest` by writing a fresh +// otadata entry into the inactive otadata slot. Bypasses esp_ota_set_boot_partition's +// esp_image_verify call. The bytes in `dest` must already be a valid app image +// (e.g. patch_firmware_image.py output) — caller is responsible for that. +// +// Returns true on success. +bool switchTo(const esp_partition_t* dest); + +} // namespace ota_boot