SD-card firmware update + X3 bootloader compatibility (#1786)

This commit is contained in:
jpirnay
2026-05-07 19:38:12 +02:00
parent 82448e856e
commit a0aee0ba1b
14 changed files with 882 additions and 22 deletions
+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;
@@ -0,0 +1,233 @@
#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);
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();
}
@@ -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)));