diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index a23195ad..8c5f0d88 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -248,6 +248,7 @@ STR_NO_UPDATE: "No update available" STR_UPDATE_FAILED: "Update failed" STR_UPDATE_COMPLETE: "Update complete" STR_POWER_ON_HINT: "Press and hold power button to turn back on" +STR_RESTARTING_HINT: "Restarting... If device does not restart, hold the power button for a few seconds." STR_NO_ENTRIES: "No entries found" STR_DOWNLOADING: "Downloading..." STR_DOWNLOAD_FAILED: "Download failed" @@ -625,4 +626,17 @@ STR_BIONIC_READING: "Bionic Reading" STR_BTN_ACT_TOGGLE_BIONIC_READING: "Toggle Bionic Reading" STR_BTN_ACT_CYCLE_FONT_SIZE: "Change Font Size" STR_BTN_ACT_CYCLE_ORIENTATION: "Change Orientation" -STR_RESTART_DEVICE: "Restart Device" \ No newline at end of file +STR_RESTART_DEVICE: "Restart Device" +STR_SD_FIRMWARE_UPDATE: "SD Card Firmware Update" +STR_SELECT_FIRMWARE_FILE: "Select firmware file (.bin)" +STR_NO_BIN_FILES: "No .bin files found" +STR_VALIDATING_FIRMWARE: "Validating firmware..." +STR_INVALID_FIRMWARE: "Invalid firmware file" +STR_FIRMWARE_TOO_LARGE: "Firmware too large for partition" +STR_FIRMWARE_TOO_SMALL: "Firmware file is too small" +STR_FIRMWARE_UPDATE_PROMPT: "Update firmware?" +STR_FIRMWARE_FILE_OPEN_FAILED: "Cannot open file" +STR_FIRMWARE_WRITE_FAILED: "Firmware write failed" +STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Do not power off!" +STR_RECOVERY_MODE: "Recovery Mode" +STR_RECOVERY_MODE_HINT: "Place firmware.bin on SD card root and select it" \ No newline at end of file diff --git a/src/activities/ActivityResult.h b/src/activities/ActivityResult.h index dfb3d769..df00c7eb 100644 --- a/src/activities/ActivityResult.h +++ b/src/activities/ActivityResult.h @@ -62,13 +62,18 @@ struct FootnoteResult { std::string href; }; +struct FilePathResult { + std::string path; +}; + struct StarredPageResult { int spineIndex = 0; int pageNumber = 0; }; -using ResultVariant = std::variant; +using ResultVariant = + std::variant; struct ActivityResult { bool isCancelled = false; diff --git a/src/activities/home/FileBrowserActivity.cpp b/src/activities/home/FileBrowserActivity.cpp index 8532d76c..0cb28fd2 100644 --- a/src/activities/home/FileBrowserActivity.cpp +++ b/src/activities/home/FileBrowserActivity.cpp @@ -93,10 +93,14 @@ void FileBrowserActivity::loadFiles() { files.emplace_back(std::string(name) + "/"); } else { std::string_view filename{name}; - if (FsHelpers::hasEpubExtension(filename) || FsHelpers::hasXtcExtension(filename) || - FsHelpers::hasTxtExtension(filename) || FsHelpers::hasMarkdownExtension(filename) || - FsHelpers::hasBmpExtension(filename) || FsHelpers::hasJpgExtension(filename) || - FsHelpers::hasPngExtension(filename)) { + if (mode == Mode::PickFirmware) { + if (FsHelpers::checkFileExtension(filename, ".bin")) { + files.emplace_back(filename); + } + } else if (FsHelpers::hasEpubExtension(filename) || FsHelpers::hasXtcExtension(filename) || + FsHelpers::hasTxtExtension(filename) || FsHelpers::hasMarkdownExtension(filename) || + FsHelpers::hasBmpExtension(filename) || FsHelpers::hasJpgExtension(filename) || + FsHelpers::hasPngExtension(filename)) { files.emplace_back(filename); } } @@ -143,10 +147,13 @@ void FileBrowserActivity::loop() { while (buttonEvents.consumeEvent(ev)) { if (ev.button == MappedInputManager::Button::Back) { if (ev.type == ButtonEventManager::PressType::Long) { - onGoHome(); - return; + if (mode == Mode::Books) { + onGoHome(); + return; + } + // PickFirmware: long Back = same as short Back (cancel / up dir) } - if (ev.type == ButtonEventManager::PressType::Short) { + if (ev.type == ButtonEventManager::PressType::Short || ev.type == ButtonEventManager::PressType::Long) { if (basepath != "/") { const std::string oldPath = basepath; basepath.replace(basepath.find_last_of('/'), std::string::npos, ""); @@ -157,6 +164,12 @@ void FileBrowserActivity::loop() { const size_t idx = findEntry(dirName); selectorIndex = (idx < files.size()) ? idx : 0; requestUpdate(); + } else if (mode == Mode::PickFirmware) { + // At root in PickFirmware: cancel back to caller. + ActivityResult res; + res.isCancelled = true; + setResult(std::move(res)); + finish(); } else { onGoHome(); } @@ -180,6 +193,15 @@ void FileBrowserActivity::loop() { loadFiles(); selectorIndex = 0; requestUpdate(); + } else if (mode == Mode::PickFirmware) { + // Firmware picker: return the selected path to the caller. + std::string cleanBasePath = basepath; + if (cleanBasePath.back() != '/') cleanBasePath += "/"; + ActivityResult res{FilePathResult{cleanBasePath + entry}}; + res.isCancelled = false; + setResult(std::move(res)); + finish(); + return; } else { std::string fullPath = basepath; if (fullPath.back() != '/') fullPath += "/"; @@ -300,15 +322,18 @@ void FileBrowserActivity::render(RenderLock&&) { const auto& metrics = UITheme::getInstance().getMetrics(); const Rect contentRect = UITheme::getContentRect(renderer, true, true); - std::string folderName = (basepath == "/") ? tr(STR_SD_CARD) : basepath.substr(basepath.rfind('/') + 1); + std::string folderName = + (mode == Mode::PickFirmware) + ? std::string(tr(STR_SELECT_FIRMWARE_FILE)) + : ((basepath == "/") ? std::string(tr(STR_SD_CARD)) : basepath.substr(basepath.rfind('/') + 1)); GUI.drawHeader(renderer, Rect{contentRect.x, metrics.topPadding, contentRect.width, metrics.headerHeight}, folderName.c_str()); const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; const int contentHeight = contentRect.height - contentTop - metrics.verticalSpacing; if (files.empty()) { - renderer.drawText(UI_10_FONT_ID, contentRect.x + metrics.contentSidePadding, contentTop + 20, - tr(STR_NO_FILES_FOUND)); + const char* emptyMsg = (mode == Mode::PickFirmware) ? tr(STR_NO_BIN_FILES) : tr(STR_NO_FILES_FOUND); + renderer.drawText(UI_10_FONT_ID, contentRect.x + metrics.contentSidePadding, contentTop + 20, emptyMsg); } else { GUI.drawList( renderer, Rect{contentRect.x, contentTop, contentRect.width, contentHeight}, files.size(), selectorIndex, @@ -319,12 +344,14 @@ void FileBrowserActivity::render(RenderLock&&) { // Side buttons (Up/Down) navigate; show their hints on the side GUI.drawSideButtonHints(renderer, tr(STR_DIR_UP), tr(STR_DIR_DOWN)); - // Front buttons: Back=Back(subdir)/Home(root), Confirm=Open, Left=hidden long-press delete, Right=Info + // Front buttons + const char* backLabel = (basepath == "/") ? (mode == Mode::PickFirmware ? tr(STR_BACK) : tr(STR_HOME)) : tr(STR_BACK); + const bool selectingFirmwareFile = mode == Mode::PickFirmware && !files.empty() && files[selectorIndex].back() != '/'; + const char* confirmLabel = files.empty() ? "" : (selectingFirmwareFile ? tr(STR_SELECT) : tr(STR_OPEN)); const bool hasInfo = - !files.empty() && files[selectorIndex].back() != '/' && + mode == Mode::Books && !files.empty() && files[selectorIndex].back() != '/' && (FsHelpers::hasEpubExtension(files[selectorIndex]) || FsHelpers::hasXtcExtension(files[selectorIndex])); - const auto labels = mappedInput.mapLabels(basepath == "/" ? tr(STR_HOME) : tr(STR_BACK), - files.empty() ? "" : tr(STR_OPEN), "", hasInfo ? tr(STR_INFO) : ""); + const auto labels = mappedInput.mapLabels(backLabel, confirmLabel, "", hasInfo ? tr(STR_INFO) : ""); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer(); diff --git a/src/activities/home/FileBrowserActivity.h b/src/activities/home/FileBrowserActivity.h index 991e1c55..53b04599 100644 --- a/src/activities/home/FileBrowserActivity.h +++ b/src/activities/home/FileBrowserActivity.h @@ -9,6 +9,10 @@ #include "util/ButtonNavigator.h" class FileBrowserActivity final : public Activity { + public: + // Books = standard reader browser; PickFirmware = filter to .bin only and return path via ActivityResult. + enum class Mode { Books, PickFirmware }; + private: // Deletion void clearFileMetadata(const std::string& fullPath); @@ -17,6 +21,8 @@ class FileBrowserActivity final : public Activity { size_t selectorIndex = 0; + Mode mode = Mode::Books; + // Files state std::string basepath = "/"; std::string focusName; // entry to select on first load (e.g. the file just returned from) @@ -28,8 +34,9 @@ class FileBrowserActivity final : public Activity { public: explicit FileBrowserActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string initialPath = "/", - std::string focusName = {}) + std::string focusName = {}, Mode mode = Mode::Books) : Activity("FileBrowser", renderer, mappedInput), + mode(mode), basepath(initialPath.empty() ? "/" : std::move(initialPath)), focusName(std::move(focusName)) {} void onEnter() override; diff --git a/src/activities/network/WifiSelectionActivity.cpp b/src/activities/network/WifiSelectionActivity.cpp index 0046b862..d373506b 100644 --- a/src/activities/network/WifiSelectionActivity.cpp +++ b/src/activities/network/WifiSelectionActivity.cpp @@ -862,7 +862,14 @@ void WifiSelectionActivity::renderForgetPrompt() const { } renderer.drawCenteredText(UI_10_FONT_ID, top, ssidInfo.c_str()); - renderer.drawCenteredText(UI_10_FONT_ID, top + 40, tr(STR_FORGET_AND_REMOVE)); + const auto& metrics = UITheme::getInstance().getMetrics(); + const int hintWidth = pageWidth - 2 * metrics.contentSidePadding; + const auto forgetLines = renderer.wrappedText(UI_10_FONT_ID, tr(STR_FORGET_AND_REMOVE), hintWidth, 3); + int forgetY = top + 40; + for (const auto& line : forgetLines) { + renderer.drawCenteredText(UI_10_FONT_ID, forgetY, line.c_str()); + forgetY += height; + } // Draw Cancel/Forget network buttons const int buttonY = top + 80; @@ -904,34 +911,15 @@ void WifiSelectionActivity::renderCaptivePortal() const { const int sp = metrics.verticalSpacing; constexpr int QR_SIZE = 320; - // Pre-compute URL line count so we can vertically centre everything - const char* url = captivePortalUrl.c_str(); - int urlLineCount = 0; - { - int rem = static_cast(captivePortalUrl.size()); - int off = 0; - while (rem > 0) { - int lo = 1, hi = rem; - while (lo < hi) { - const int mid = (lo + hi + 1) / 2; - char tmp[512]; - snprintf(tmp, sizeof(tmp), "%.*s", mid, url + off); - if (renderer.getTextWidth(SMALL_FONT_ID, tmp) <= maxWidth) - lo = mid; - else - hi = mid - 1; - } - urlLineCount++; - off += lo; - rem -= lo; - } - } + // Pre-compute wrapped hint and URL lines so we can vertically centre everything + const std::string hintText = std::string(tr(STR_CAPTIVE_PORTAL_HINT_1)) + " " + tr(STR_CAPTIVE_PORTAL_HINT_2); + const auto hintLines = renderer.wrappedText(UI_10_FONT_ID, hintText.c_str(), maxWidth, 4); + const auto urlLines = renderer.wrappedText(SMALL_FONT_ID, captivePortalUrl.c_str(), maxWidth, 10); - const int totalHeight = lh12 + sp // title - + lh10 // hint line 1 - + lh10 + sp // hint line 2 - + QR_SIZE + sp // QR code - + urlLineCount * lhSmall; + const int totalHeight = lh12 + sp // title + + static_cast(hintLines.size()) * lh10 + sp // hint + + QR_SIZE + sp // QR code + + static_cast(urlLines.size()) * lhSmall; // contentRect covers the full screen minus button hints; subtract the header // and sub-header that render() always draws above us. @@ -941,35 +929,19 @@ void WifiSelectionActivity::renderCaptivePortal() const { renderer.drawCenteredText(UI_12_FONT_ID, y, tr(STR_CAPTIVE_PORTAL_DETECTED), true, EpdFontFamily::BOLD); y += lh12 + sp; - renderer.drawCenteredText(UI_10_FONT_ID, y, tr(STR_CAPTIVE_PORTAL_HINT_1)); - y += lh10; - renderer.drawCenteredText(UI_10_FONT_ID, y, tr(STR_CAPTIVE_PORTAL_HINT_2)); - y += lh10 + sp; + for (const auto& line : hintLines) { + renderer.drawCenteredText(UI_10_FONT_ID, y, line.c_str()); + y += lh10; + } + y += sp; const int qrX = contentRect.x + (contentRect.width - QR_SIZE) / 2; QrUtils::drawQrCode(renderer, Rect{qrX, y, QR_SIZE, QR_SIZE}, captivePortalUrl); y += QR_SIZE + sp; - // Split URL into as many lines as needed - int remaining = static_cast(captivePortalUrl.size()); - int offset = 0; - while (remaining > 0) { - int lo = 1, hi = remaining; - while (lo < hi) { - const int mid = (lo + hi + 1) / 2; - char tmp[512]; - snprintf(tmp, sizeof(tmp), "%.*s", mid, url + offset); - if (renderer.getTextWidth(SMALL_FONT_ID, tmp) <= maxWidth) - lo = mid; - else - hi = mid - 1; - } - char line[512]; - snprintf(line, sizeof(line), "%.*s", lo, url + offset); - renderer.drawCenteredText(SMALL_FONT_ID, y, line); + for (const auto& line : urlLines) { + renderer.drawCenteredText(SMALL_FONT_ID, y, line.c_str()); y += lhSmall; - offset += lo; - remaining -= lo; } const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_CAPTIVE_PORTAL_DONE), "", ""); diff --git a/src/activities/settings/ClearCacheActivity.cpp b/src/activities/settings/ClearCacheActivity.cpp index 34216aba..698e0a46 100644 --- a/src/activities/settings/ClearCacheActivity.cpp +++ b/src/activities/settings/ClearCacheActivity.cpp @@ -28,11 +28,23 @@ void ClearCacheActivity::render(RenderLock&&) { tr(STR_CLEAR_READING_CACHE)); const int midY = contentRect.y + contentRect.height / 2; + const int lineHeight = renderer.getLineHeight(UI_10_FONT_ID); + const int warnWidth = contentRect.width - 2 * metrics.contentSidePadding; if (state == WARNING) { - renderer.drawCenteredText(UI_10_FONT_ID, midY - 60, tr(STR_CLEAR_CACHE_WARNING_1), true); + const auto warn1Lines = renderer.wrappedText(UI_10_FONT_ID, tr(STR_CLEAR_CACHE_WARNING_1), warnWidth, 3); + int y = midY - 60; + for (const auto& line : warn1Lines) { + renderer.drawCenteredText(UI_10_FONT_ID, y, line.c_str()); + y += lineHeight; + } renderer.drawCenteredText(UI_10_FONT_ID, midY - 30, tr(STR_CLEAR_CACHE_WARNING_2), true, EpdFontFamily::BOLD); - renderer.drawCenteredText(UI_10_FONT_ID, midY + 10, tr(STR_CLEAR_CACHE_WARNING_3), true); - renderer.drawCenteredText(UI_10_FONT_ID, midY + 30, tr(STR_CLEAR_CACHE_WARNING_4), true); + const std::string warn34 = std::string(tr(STR_CLEAR_CACHE_WARNING_3)) + " " + tr(STR_CLEAR_CACHE_WARNING_4); + const auto warn34Lines = renderer.wrappedText(UI_10_FONT_ID, warn34.c_str(), warnWidth, 3); + y = midY + 10; + for (const auto& line : warn34Lines) { + renderer.drawCenteredText(UI_10_FONT_ID, y, line.c_str()); + y += lineHeight; + } const auto labels = mappedInput.mapLabels(tr(STR_CANCEL), tr(STR_CLEAR_BUTTON), "", ""); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); diff --git a/src/activities/settings/OtaUpdateActivity.cpp b/src/activities/settings/OtaUpdateActivity.cpp index 4a8ff8db..d2943679 100644 --- a/src/activities/settings/OtaUpdateActivity.cpp +++ b/src/activities/settings/OtaUpdateActivity.cpp @@ -160,7 +160,13 @@ void OtaUpdateActivity::render(RenderLock&&) { GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); } else if (state == FINISHED) { renderer.drawCenteredText(UI_10_FONT_ID, top, tr(STR_UPDATE_COMPLETE), true, EpdFontFamily::BOLD); - renderer.drawCenteredText(UI_10_FONT_ID, top + height + metrics.verticalSpacing, tr(STR_POWER_ON_HINT)); + const int hintWidth = contentRect.width - 2 * metrics.contentSidePadding; + const auto hintLines = renderer.wrappedText(UI_10_FONT_ID, tr(STR_POWER_ON_HINT), hintWidth, 4); + int hintY = top + height + metrics.verticalSpacing; + for (const auto& line : hintLines) { + renderer.drawCenteredText(UI_10_FONT_ID, hintY, line.c_str()); + hintY += height; + } } renderer.displayBuffer(); diff --git a/src/activities/settings/SdFirmwareUpdateActivity.cpp b/src/activities/settings/SdFirmwareUpdateActivity.cpp new file mode 100644 index 00000000..1ffb72af --- /dev/null +++ b/src/activities/settings/SdFirmwareUpdateActivity.cpp @@ -0,0 +1,244 @@ +#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(); + LOG_INF("FW", "SdFirmwareUpdateActivity build=%s %s recovery=%d", __DATE__, __TIME__, recoveryMode ? 1 : 0); + state = State::PICKING; + launchPicker(); +} + +void SdFirmwareUpdateActivity::launchPicker() { + startActivityForResult(std::make_unique(renderer, mappedInput, "/", std::string{}, + FileBrowserActivity::Mode::PickFirmware), + [this](const ActivityResult& result) { onPickerResult(result); }); +} + +void SdFirmwareUpdateActivity::onPickerResult(const ActivityResult& result) { + if (result.isCancelled) { + if (recoveryMode) { + 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(); + + 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; + } + + 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; + } + std::string heading = tr(STR_FIRMWARE_UPDATE_PROMPT); + 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) { + 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; + self->requestUpdate(true); + }; + + 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) { + 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) { + 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; + renderer.drawCenteredText(UI_10_FONT_ID, y, (std::to_string(pct) + "%").c_str()); + 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); + const int hintWidth = pageWidth - 2 * metrics.contentSidePadding; + const auto hintLines = renderer.wrappedText(UI_10_FONT_ID, tr(STR_RESTARTING_HINT), hintWidth, 4); + int hintY = top + lineHeight + metrics.verticalSpacing; + for (const auto& line : hintLines) { + renderer.drawCenteredText(UI_10_FONT_ID, hintY, line.c_str()); + hintY += lineHeight; + } + } 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) { + const int hintWidth = pageWidth - 2 * metrics.contentSidePadding; + const auto hintLines = renderer.wrappedText(UI_10_FONT_ID, tr(STR_RECOVERY_MODE_HINT), hintWidth, 4); + int hintY = top; + for (const auto& line : hintLines) { + renderer.drawCenteredText(UI_10_FONT_ID, hintY, line.c_str()); + hintY += lineHeight; + } + } + } + + renderer.displayBuffer(); +} diff --git a/src/activities/settings/SdFirmwareUpdateActivity.h b/src/activities/settings/SdFirmwareUpdateActivity.h new file mode 100644 index 00000000..4de520b5 --- /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 raw esp_partition APIs, + * 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/SettingActionDispatch.cpp b/src/activities/settings/SettingActionDispatch.cpp index ba48c3ba..9c7c2d2d 100644 --- a/src/activities/settings/SettingActionDispatch.cpp +++ b/src/activities/settings/SettingActionDispatch.cpp @@ -10,6 +10,7 @@ #include "LanguageSelectActivity.h" #include "OpdsServerListActivity.h" #include "OtaUpdateActivity.h" +#include "SdFirmwareUpdateActivity.h" #include "StatusBarSettingsActivity.h" #include "SyncTimeActivity.h" #include "SystemInformationActivity.h" @@ -39,6 +40,8 @@ std::unique_ptr createActivityForAction(SettingAction action, GfxRende return std::make_unique(renderer, mappedInput); case SettingAction::CheckForUpdates: return std::make_unique(renderer, mappedInput); + case SettingAction::SdFirmwareUpdate: + return std::make_unique(renderer, mappedInput); case SettingAction::Language: return std::make_unique(renderer, mappedInput); case SettingAction::Weather: diff --git a/src/activities/settings/SettingInfo.h b/src/activities/settings/SettingInfo.h index 745e48d8..bc7a4f70 100644 --- a/src/activities/settings/SettingInfo.h +++ b/src/activities/settings/SettingInfo.h @@ -23,6 +23,7 @@ enum class SettingAction { Network, ClearCache, CheckForUpdates, + SdFirmwareUpdate, Language, SystemInfo, DetectTimezone, diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index e6f362a2..d21274a0 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -150,6 +150,9 @@ void SettingsActivity::onEnter() { addToMoved(systemSettings, lastSystemSub, std::move(SettingInfo::Action(StrId::STR_CHECK_UPDATES, SettingAction::CheckForUpdates) .withSubcategory(StrId::STR_MENU_SYS_SYSTEM))); + addToMoved(systemSettings, lastSystemSub, + std::move(SettingInfo::Action(StrId::STR_SD_FIRMWARE_UPDATE, SettingAction::SdFirmwareUpdate) + .withSubcategory(StrId::STR_MENU_SYS_SYSTEM))); addToMoved(systemSettings, lastSystemSub, std::move(SettingInfo::Action(StrId::STR_SYSTEM_INFO, SettingAction::SystemInfo) .withSubcategory(StrId::STR_MENU_SYS_SYSTEM))); diff --git a/src/main.cpp b/src/main.cpp index a80fd3dc..f8751668 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -30,6 +30,7 @@ #include "WeatherSettingsStore.h" #include "activities/Activity.h" #include "activities/ActivityManager.h" +#include "activities/settings/SdFirmwareUpdateActivity.h" #include "components/UITheme.h" #include "fontIds.h" #include "util/ButtonNavigator.h" @@ -235,6 +236,25 @@ void setup() { LOG_DBG("MAIN", "Power button verification passed, millis=%lu", millis()); } + // Recovery firmware mode: hold left side button (BTN_UP) together with the power button at + // boot to skip directly to the SD-card firmware update screen. Useful on devices where USB + // flashing has been locked down (e.g. recent X3 firmware). + bool recoveryFirmwareMode = false; + if (wakeupReason == HalGPIO::WakeupReason::PowerButton) { + // Refresh the cached button state a few times — isPressed() needs ~half a second to settle + // after boot per the HalGPIO contract. Use a millis-based deadline so we always wait the full + // settle window even if the loop body takes longer than expected on slow boots. + const unsigned long settleStart = millis(); + while (millis() - settleStart < 500) { + gpio.update(); + delay(10); + } + if (gpio.isPressed(HalGPIO::BTN_UP)) { + recoveryFirmwareMode = true; + LOG_INF("MAIN", "Recovery firmware mode (UP + POWER held at boot)"); + } + } + // SD Card Initialization // We need 6 open files concurrently when parsing a new chapter if (!Storage.begin()) { @@ -268,10 +288,12 @@ void setup() { RECENT_BOOKS.loadFromFile(); GLOBAL_BOOKMARKS.load(); - // Boot to home screen if no book is open, last sleep was not from reader, back button is held, or reader activity - // crashed (indicated by readerActivityLoadCount > 0) - if (APP_STATE.openEpubPath.empty() || !APP_STATE.lastSleepFromReader || - mappedInputManager.isPressed(MappedInputManager::Button::Back) || APP_STATE.readerActivityLoadCount > 0) { + if (recoveryFirmwareMode) { + // Skip normal home/reader routing: jump straight into the SD firmware picker. + activityManager.replaceActivity( + std::make_unique(renderer, mappedInputManager, /*recoveryMode=*/true)); + } else if (APP_STATE.openEpubPath.empty() || !APP_STATE.lastSleepFromReader || + mappedInputManager.isPressed(MappedInputManager::Button::Back) || APP_STATE.readerActivityLoadCount > 0) { activityManager.goHome(); } else { // Clear app state to avoid getting into a boot loop if the epub doesn't load diff --git a/src/network/FirmwareFlasher.cpp b/src/network/FirmwareFlasher.cpp new file mode 100644 index 00000000..316b03f8 --- /dev/null +++ b/src/network/FirmwareFlasher.cpp @@ -0,0 +1,300 @@ +#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`, feeding them through XOR-checksum and SHA256 accumulators. +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; + } + + 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) { + 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; + } + + 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. + 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 00000000..fd247498 --- /dev/null +++ b/src/network/FirmwareFlasher.h @@ -0,0 +1,60 @@ +#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 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. Streams the file in CHUNK-sized reads. +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 00000000..7c3ca762 --- /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 00000000..3c52f7be --- /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