Merge pull request #180 from jpirnay/feat-pr1786

feat: SD-card firmware update + X3 bootloader compatibility (upstrem 1786 by eunchurn)
This commit is contained in:
jpirnay
2026-05-07 20:10:16 +02:00
committed by GitHub
17 changed files with 938 additions and 77 deletions
+15 -1
View File
@@ -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"
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"
+7 -2
View File
@@ -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<std::monostate, WifiResult, KeyboardResult, MenuResult, ChapterResult, PercentResult,
PageResult, SyncResult, NetworkModeResult, FootnoteResult, StarredPageResult>;
using ResultVariant =
std::variant<std::monostate, WifiResult, KeyboardResult, MenuResult, ChapterResult, PercentResult, PageResult,
SyncResult, NetworkModeResult, FootnoteResult, FilePathResult, StarredPageResult>;
struct ActivityResult {
bool isCancelled = false;
+41 -14
View File
@@ -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();
+8 -1
View File
@@ -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;
@@ -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<int>(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<int>(hintLines.size()) * lh10 + sp // hint
+ QR_SIZE + sp // QR code
+ static_cast<int>(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<int>(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), "", "");
+15 -3
View File
@@ -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);
@@ -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();
@@ -0,0 +1,244 @@
#include "SdFirmwareUpdateActivity.h"
#include <Arduino.h>
#include <GfxRenderer.h>
#include <HalStorage.h>
#include <I18n.h>
#include <Logging.h>
#include <esp_ota_ops.h>
#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<FileBrowserActivity>(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<FilePathResult>(&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<unsigned>(firmwareSize),
static_cast<unsigned>(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<ConfirmationActivity>(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<unsigned>(firmwareSize));
auto progressCb = +[](size_t written, size_t total, void* ctx) {
auto* self = static_cast<SdFirmwareUpdateActivity*>(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<unsigned int>((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<int>(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();
}
@@ -0,0 +1,56 @@
#pragma once
#include <string>
#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();
};
@@ -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<Activity> createActivityForAction(SettingAction action, GfxRende
return std::make_unique<ClearCacheActivity>(renderer, mappedInput);
case SettingAction::CheckForUpdates:
return std::make_unique<OtaUpdateActivity>(renderer, mappedInput);
case SettingAction::SdFirmwareUpdate:
return std::make_unique<SdFirmwareUpdateActivity>(renderer, mappedInput);
case SettingAction::Language:
return std::make_unique<LanguageSelectActivity>(renderer, mappedInput);
case SettingAction::Weather:
+1
View File
@@ -23,6 +23,7 @@ enum class SettingAction {
Network,
ClearCache,
CheckForUpdates,
SdFirmwareUpdate,
Language,
SystemInfo,
DetectTimezone,
@@ -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)));
+26 -4
View File
@@ -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<SdFirmwareUpdateActivity>(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
+300
View File
@@ -0,0 +1,300 @@
#include "FirmwareFlasher.h"
#include <Arduino.h>
#include <HalStorage.h>
#include <Logging.h>
#include <esp_ota_ops.h>
#include <esp_partition.h>
#include <mbedtls/sha256.h>
#include <spi_flash_mmap.h>
#include <algorithm>
#include <cstring>
#include <memory>
#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<size_t>(CHUNK, remaining);
const int got = file.read(buf, want);
if (got <= 0 || static_cast<size_t>(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<unsigned>(fileSize));
file.close();
return Result::TOO_SMALL;
}
if (partitionSize > 0 && fileSize > partitionSize) {
LOG_ERR("FLASH", "validate: too large: %u > %u", static_cast<unsigned>(fileSize),
static_cast<unsigned>(partitionSize));
file.close();
return Result::TOO_LARGE;
}
uint8_t header[HEADER_SIZE];
if (file.read(header, HEADER_SIZE) != static_cast<int>(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<uint8_t[]>(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<unsigned>(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<int>(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<unsigned>(pos),
static_cast<unsigned>(dataLen), static_cast<unsigned>(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<size_t>(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<unsigned>(padEnd),
static_cast<unsigned>(hashAppended ? SHA_TRAILER : 0), static_cast<unsigned>(expectedTotal),
static_cast<unsigned>(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<int>(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<int>(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<unsigned>(firmwareSize), dest->label,
static_cast<unsigned>(dest->address), static_cast<unsigned>(dest->size));
auto buffer = std::unique_ptr<uint8_t[]>(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<size_t>(BLK, dest->size - streamPos);
eraseLen = (eraseLen + SEC - 1) & ~(SEC - 1);
eraseLen = std::min<size_t>(eraseLen, dest->size - streamPos);
if (esp_partition_erase_range(dest, streamPos, eraseLen) != ESP_OK) {
LOG_ERR("FLASH", "erase @%u (len=%u) failed", static_cast<unsigned>(streamPos),
static_cast<unsigned>(eraseLen));
file.close();
return Result::ERASE_FAIL;
}
erasedUpto = streamPos + eraseLen;
}
const size_t want = std::min<size_t>(CHUNK, firmwareSize - streamPos);
const int read = file.read(buffer.get(), want);
if (read <= 0 || static_cast<size_t>(read) != want) {
LOG_ERR("FLASH", "read @%u: got=%d want=%u", static_cast<unsigned>(streamPos), read, static_cast<unsigned>(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<unsigned>(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
+60
View File
@@ -0,0 +1,60 @@
#pragma once
#include <cstddef>
#include <cstdint>
// 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
+86
View File
@@ -0,0 +1,86 @@
#include "OtaBootSwitch.h"
#include <Logging.h>
#include <esp_rom_crc.h>
#include <spi_flash_mmap.h>
#include <string.h>
namespace ota_boot {
uint32_t computeSeqCrc(uint32_t seq) {
return esp_rom_crc32_le(UINT32_MAX, reinterpret_cast<const uint8_t*>(&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<unsigned>(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<unsigned>(activeSeq));
// ota_seq encoding: (seq - 1) % NUM_OTA_PARTITIONS picks the partition.
const uint32_t destOtaIdx =
static_cast<uint32_t>(dest->subtype) - static_cast<uint32_t>(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<size_t>(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<unsigned>(newSeq),
static_cast<unsigned>(next.crc), dest->label);
return true;
}
} // namespace ota_boot
+43
View File
@@ -0,0 +1,43 @@
#pragma once
#include <esp_partition.h>
#include <cstddef>
#include <cstdint>
// 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