feat(update): SD-card firmware update + X3 bootloader compatibility (#1786)
This commit is contained in:
@@ -171,6 +171,7 @@ STR_NO_UPDATE: "No update available"
|
|||||||
STR_UPDATE_FAILED: "Update failed"
|
STR_UPDATE_FAILED: "Update failed"
|
||||||
STR_UPDATE_COMPLETE: "Update complete"
|
STR_UPDATE_COMPLETE: "Update complete"
|
||||||
STR_POWER_ON_HINT: "Press and hold power button to turn back on"
|
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_NO_ENTRIES: "No entries found"
|
||||||
STR_DOWNLOADING: "Downloading..."
|
STR_DOWNLOADING: "Downloading..."
|
||||||
STR_DOWNLOAD_FAILED: "Download failed"
|
STR_DOWNLOAD_FAILED: "Download failed"
|
||||||
@@ -324,3 +325,16 @@ STR_KB_HINT_SECONDARY_CHAR: "Hold SELECT for secondary char"
|
|||||||
STR_KB_HINT_UPPER_SECONDARY: "Hold SELECT for UPPERCASE or secondary char"
|
STR_KB_HINT_UPPER_SECONDARY: "Hold SELECT for UPPERCASE or secondary char"
|
||||||
STR_KB_HINT_LOWER_SECONDARY: "Hold SELECT for lowercase or secondary char"
|
STR_KB_HINT_LOWER_SECONDARY: "Hold SELECT for lowercase or secondary char"
|
||||||
STR_KB_HINT_URL_SNIPPETS: "Press URL for snippets"
|
STR_KB_HINT_URL_SNIPPETS: "Press URL for snippets"
|
||||||
|
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"
|
||||||
|
|||||||
@@ -50,8 +50,12 @@ struct FootnoteResult {
|
|||||||
std::string href;
|
std::string href;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
struct FilePathResult {
|
||||||
|
std::string path;
|
||||||
|
};
|
||||||
|
|
||||||
using ResultVariant = std::variant<std::monostate, WifiResult, KeyboardResult, MenuResult, ChapterResult, PercentResult,
|
using ResultVariant = std::variant<std::monostate, WifiResult, KeyboardResult, MenuResult, ChapterResult, PercentResult,
|
||||||
PageResult, SyncResult, NetworkModeResult, FootnoteResult>;
|
PageResult, SyncResult, NetworkModeResult, FootnoteResult, FilePathResult>;
|
||||||
|
|
||||||
struct ActivityResult {
|
struct ActivityResult {
|
||||||
bool isCancelled = false;
|
bool isCancelled = false;
|
||||||
|
|||||||
@@ -91,9 +91,14 @@ void FileBrowserActivity::loadFiles() {
|
|||||||
files.emplace_back(std::string(name) + "/");
|
files.emplace_back(std::string(name) + "/");
|
||||||
} else {
|
} else {
|
||||||
std::string_view filename{name};
|
std::string_view filename{name};
|
||||||
if (FsHelpers::hasEpubExtension(filename) || FsHelpers::hasXtcExtension(filename) ||
|
if (mode == Mode::PickFirmware) {
|
||||||
FsHelpers::hasTxtExtension(filename) || FsHelpers::hasMarkdownExtension(filename) ||
|
// Firmware picker: only show .bin files.
|
||||||
FsHelpers::hasBmpExtension(filename)) {
|
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)) {
|
||||||
files.emplace_back(filename);
|
files.emplace_back(filename);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,6 +111,10 @@ void FileBrowserActivity::onEnter() {
|
|||||||
|
|
||||||
selectorIndex = 0;
|
selectorIndex = 0;
|
||||||
|
|
||||||
|
// If Confirm was held while this activity opened (typical when launched from a menu), ignore
|
||||||
|
// its release — otherwise we'd immediately auto-open whatever is at index 0.
|
||||||
|
lockNextConfirmRelease = mappedInput.isPressed(MappedInputManager::Button::Confirm);
|
||||||
|
|
||||||
auto root = Storage.open(basepath.c_str());
|
auto root = Storage.open(basepath.c_str());
|
||||||
if (!root) {
|
if (!root) {
|
||||||
basepath = "/";
|
basepath = "/";
|
||||||
@@ -141,11 +150,10 @@ void FileBrowserActivity::clearFileMetadata(const std::string& fullPath) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void FileBrowserActivity::loop() {
|
void FileBrowserActivity::loop() {
|
||||||
// Long press BACK (1s+) goes to root folder
|
// Long press BACK (1s+) goes to root folder (Books mode only).
|
||||||
// but Long press BACK (1s+) from ReaderActivity sends us here with the MappedInput already set.
|
// In firmware-pick mode we keep navigation simple: short Back = up dir / cancel.
|
||||||
// So ignore it the first time.
|
if (mode == Mode::Books && mappedInput.isPressed(MappedInputManager::Button::Back) &&
|
||||||
if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= GO_HOME_MS &&
|
mappedInput.getHeldTime() >= GO_HOME_MS && basepath != "/" && !lockLongPressBack) {
|
||||||
basepath != "/" && !lockLongPressBack) {
|
|
||||||
basepath = "/";
|
basepath = "/";
|
||||||
loadFiles();
|
loadFiles();
|
||||||
selectorIndex = 0;
|
selectorIndex = 0;
|
||||||
@@ -162,12 +170,27 @@ void FileBrowserActivity::loop() {
|
|||||||
const int pageItems = UITheme::getNumberOfItemsPerPage(renderer, true, false, true, false, pathReserved);
|
const int pageItems = UITheme::getNumberOfItemsPerPage(renderer, true, false, true, false, pathReserved);
|
||||||
|
|
||||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||||
|
if (lockNextConfirmRelease) {
|
||||||
|
lockNextConfirmRelease = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (files.empty()) return;
|
if (files.empty()) return;
|
||||||
|
|
||||||
const std::string& entry = files[selectorIndex];
|
const std::string& entry = files[selectorIndex];
|
||||||
bool isDirectory = (entry.back() == '/');
|
bool isDirectory = (entry.back() == '/');
|
||||||
|
|
||||||
if (mappedInput.getHeldTime() >= GO_HOME_MS && !isDirectory) {
|
// Firmware picker: select file -> return path; navigate into directories normally.
|
||||||
|
if (mode == Mode::PickFirmware && !isDirectory) {
|
||||||
|
std::string cleanBasePath = basepath;
|
||||||
|
if (cleanBasePath.back() != '/') cleanBasePath += "/";
|
||||||
|
ActivityResult res{FilePathResult{cleanBasePath + entry}};
|
||||||
|
res.isCancelled = false;
|
||||||
|
setResult(std::move(res));
|
||||||
|
finish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode == Mode::Books && mappedInput.getHeldTime() >= GO_HOME_MS && !isDirectory) {
|
||||||
// --- LONG PRESS ACTION: DELETE FILE ---
|
// --- LONG PRESS ACTION: DELETE FILE ---
|
||||||
std::string cleanBasePath = basepath;
|
std::string cleanBasePath = basepath;
|
||||||
if (cleanBasePath.back() != '/') cleanBasePath += "/";
|
if (cleanBasePath.back() != '/') cleanBasePath += "/";
|
||||||
@@ -231,6 +254,12 @@ void FileBrowserActivity::loop() {
|
|||||||
selectorIndex = findEntry(dirName);
|
selectorIndex = findEntry(dirName);
|
||||||
|
|
||||||
requestUpdate();
|
requestUpdate();
|
||||||
|
} else if (mode == Mode::PickFirmware) {
|
||||||
|
// Firmware picker at root: cancel back to caller instead of going home.
|
||||||
|
ActivityResult res;
|
||||||
|
res.isCancelled = true;
|
||||||
|
setResult(std::move(res));
|
||||||
|
finish();
|
||||||
} else {
|
} else {
|
||||||
onGoHome();
|
onGoHome();
|
||||||
}
|
}
|
||||||
@@ -286,7 +315,10 @@ void FileBrowserActivity::render(RenderLock&&) {
|
|||||||
const auto pageHeight = renderer.getScreenHeight();
|
const auto pageHeight = renderer.getScreenHeight();
|
||||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||||
|
|
||||||
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{0, metrics.topPadding, pageWidth, metrics.headerHeight}, folderName.c_str());
|
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, folderName.c_str());
|
||||||
|
|
||||||
const int pathLineHeight = renderer.getLineHeight(SMALL_FONT_ID);
|
const int pathLineHeight = renderer.getLineHeight(SMALL_FONT_ID);
|
||||||
@@ -295,7 +327,8 @@ void FileBrowserActivity::render(RenderLock&&) {
|
|||||||
const int contentHeight =
|
const int contentHeight =
|
||||||
pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing - pathReserved;
|
pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing - pathReserved;
|
||||||
if (files.empty()) {
|
if (files.empty()) {
|
||||||
renderer.drawText(UI_10_FONT_ID, 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, metrics.contentSidePadding, contentTop + 20, emptyMsg);
|
||||||
} else {
|
} else {
|
||||||
GUI.drawList(
|
GUI.drawList(
|
||||||
renderer, Rect{0, contentTop, pageWidth, contentHeight}, files.size(), selectorIndex,
|
renderer, Rect{0, contentTop, pageWidth, contentHeight}, files.size(), selectorIndex,
|
||||||
@@ -332,9 +365,13 @@ void FileBrowserActivity::render(RenderLock&&) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Help text
|
// Help text
|
||||||
const auto labels =
|
const char* backLabel = (basepath == "/") ? (mode == Mode::PickFirmware ? tr(STR_BACK) : tr(STR_HOME)) : tr(STR_BACK);
|
||||||
mappedInput.mapLabels(basepath == "/" ? tr(STR_HOME) : tr(STR_BACK), files.empty() ? "" : tr(STR_OPEN),
|
// In PickFirmware mode, Confirm on a .bin returns the path to the caller (not "open"); show
|
||||||
files.empty() ? "" : tr(STR_DIR_UP), files.empty() ? "" : tr(STR_DIR_DOWN));
|
// STR_SELECT instead. Directories in the same picker still descend, so keep STR_OPEN there.
|
||||||
|
const bool selectingFirmwareFile = mode == Mode::PickFirmware && !files.empty() && files[selectorIndex].back() != '/';
|
||||||
|
const char* confirmLabel = files.empty() ? "" : (selectingFirmwareFile ? tr(STR_SELECT) : tr(STR_OPEN));
|
||||||
|
const auto labels = mappedInput.mapLabels(backLabel, confirmLabel, files.empty() ? "" : tr(STR_DIR_UP),
|
||||||
|
files.empty() ? "" : tr(STR_DIR_DOWN));
|
||||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||||
|
|
||||||
renderer.displayBuffer();
|
renderer.displayBuffer();
|
||||||
|
|||||||
@@ -9,6 +9,10 @@
|
|||||||
#include "util/ButtonNavigator.h"
|
#include "util/ButtonNavigator.h"
|
||||||
|
|
||||||
class FileBrowserActivity final : public Activity {
|
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:
|
private:
|
||||||
// Deletion
|
// Deletion
|
||||||
void clearFileMetadata(const std::string& fullPath);
|
void clearFileMetadata(const std::string& fullPath);
|
||||||
@@ -18,6 +22,11 @@ class FileBrowserActivity final : public Activity {
|
|||||||
size_t selectorIndex = 0;
|
size_t selectorIndex = 0;
|
||||||
|
|
||||||
bool lockLongPressBack = false;
|
bool lockLongPressBack = false;
|
||||||
|
// True when this activity was entered while Confirm was already held; we must swallow the next
|
||||||
|
// release so we don't immediately auto-open the first entry.
|
||||||
|
bool lockNextConfirmRelease = false;
|
||||||
|
|
||||||
|
Mode mode = Mode::Books;
|
||||||
|
|
||||||
// Files state
|
// Files state
|
||||||
std::string basepath = "/";
|
std::string basepath = "/";
|
||||||
@@ -28,8 +37,11 @@ class FileBrowserActivity final : public Activity {
|
|||||||
size_t findEntry(const std::string& name) const;
|
size_t findEntry(const std::string& name) const;
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit FileBrowserActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string initialPath = "/")
|
explicit FileBrowserActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string initialPath = "/",
|
||||||
: Activity("FileBrowser", renderer, mappedInput), basepath(initialPath.empty() ? "/" : std::move(initialPath)) {}
|
Mode mode = Mode::Books)
|
||||||
|
: Activity("FileBrowser", renderer, mappedInput),
|
||||||
|
mode(mode),
|
||||||
|
basepath(initialPath.empty() ? "/" : std::move(initialPath)) {}
|
||||||
void onEnter() override;
|
void onEnter() override;
|
||||||
void onExit() override;
|
void onExit() override;
|
||||||
void loop() override;
|
void loop() override;
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
#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();
|
||||||
|
// Build-identity marker — confirms which firmware build owns the SD update flow.
|
||||||
|
LOG_INF("FW", "SdFirmwareUpdateActivity build=%s %s recovery=%d", __DATE__, __TIME__, recoveryMode ? 1 : 0);
|
||||||
|
state = State::PICKING;
|
||||||
|
launchPicker();
|
||||||
|
}
|
||||||
|
|
||||||
|
void SdFirmwareUpdateActivity::launchPicker() {
|
||||||
|
// Reuse the standard file browser, restricted to .bin files only.
|
||||||
|
startActivityForResult(
|
||||||
|
std::make_unique<FileBrowserActivity>(renderer, mappedInput, "/", FileBrowserActivity::Mode::PickFirmware),
|
||||||
|
[this](const ActivityResult& result) { onPickerResult(result); });
|
||||||
|
}
|
||||||
|
|
||||||
|
void SdFirmwareUpdateActivity::onPickerResult(const ActivityResult& result) {
|
||||||
|
if (result.isCancelled) {
|
||||||
|
if (recoveryMode) {
|
||||||
|
// Recovery mode: re-launch the picker so the user cannot escape into a half-initialised UI.
|
||||||
|
launchPicker();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
finish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto* path = std::get_if<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();
|
||||||
|
|
||||||
|
// Resolve the next-update partition directly via the OTA API. Previously this
|
||||||
|
// probed via Update.begin(firmwareSize)/Update.abort() to learn the partition
|
||||||
|
// size, which had the side effect of erasing partition state and was wasted
|
||||||
|
// work since we only need the size bound for validation here.
|
||||||
|
const esp_partition_t* dest = esp_ota_get_next_update_partition(nullptr);
|
||||||
|
if (!dest) {
|
||||||
|
LOG_ERR("FW", "no next-update partition available");
|
||||||
|
errorMessage = tr(STR_INVALID_FIRMWARE);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const size_t partitionLimit = dest->size;
|
||||||
|
if (firmwareSize > partitionLimit) {
|
||||||
|
LOG_ERR("FW", "firmware (%u bytes) exceeds partition (%u bytes)", static_cast<unsigned>(firmwareSize),
|
||||||
|
static_cast<unsigned>(partitionLimit));
|
||||||
|
errorMessage = tr(STR_FIRMWARE_TOO_LARGE);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the same end-to-end integrity check (header / segment table / XOR checksum / SHA256
|
||||||
|
// trailer) that the shared firmware-flasher applies right before raw-writing otadata. This
|
||||||
|
// catches truncated or corrupted .bin files at confirmation time, before the user ever sees
|
||||||
|
// the "Updating…" progress bar.
|
||||||
|
const auto vr = firmware_flash::validateImageFile(firmwarePath.c_str(), partitionLimit);
|
||||||
|
if (vr != firmware_flash::Result::OK) {
|
||||||
|
LOG_ERR("FW", "image validation failed: %s", firmware_flash::resultName(vr));
|
||||||
|
if (vr == firmware_flash::Result::TOO_LARGE) {
|
||||||
|
errorMessage = tr(STR_FIRMWARE_TOO_LARGE);
|
||||||
|
} else if (vr == firmware_flash::Result::TOO_SMALL) {
|
||||||
|
errorMessage = tr(STR_FIRMWARE_TOO_SMALL);
|
||||||
|
} else {
|
||||||
|
errorMessage = tr(STR_INVALID_FIRMWARE);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SdFirmwareUpdateActivity::promptConfirmation() {
|
||||||
|
{
|
||||||
|
RenderLock lock(*this);
|
||||||
|
state = State::CONFIRMING;
|
||||||
|
}
|
||||||
|
// Show "Update firmware?" with the file path as the body line.
|
||||||
|
std::string heading = tr(STR_FIRMWARE_UPDATE_PROMPT);
|
||||||
|
// Use the basename only to keep the body short.
|
||||||
|
std::string body = firmwarePath;
|
||||||
|
const auto pos = body.find_last_of('/');
|
||||||
|
if (pos != std::string::npos) body = body.substr(pos + 1);
|
||||||
|
|
||||||
|
startActivityForResult(std::make_unique<ConfirmationActivity>(renderer, mappedInput, heading, body),
|
||||||
|
[this](const ActivityResult& result) { onConfirmationResult(result); });
|
||||||
|
}
|
||||||
|
|
||||||
|
void SdFirmwareUpdateActivity::onConfirmationResult(const ActivityResult& result) {
|
||||||
|
if (result.isCancelled) {
|
||||||
|
if (recoveryMode) {
|
||||||
|
// Go back to the picker rather than exiting recovery.
|
||||||
|
launchPicker();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
finish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
RenderLock lock(*this);
|
||||||
|
state = State::UPDATING;
|
||||||
|
writtenBytes = 0;
|
||||||
|
lastRenderedPercent = 101;
|
||||||
|
}
|
||||||
|
requestUpdateAndWait();
|
||||||
|
performUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
void SdFirmwareUpdateActivity::performUpdate() {
|
||||||
|
LOG_INF("FW", "SD update: %s (%u bytes)", firmwarePath.c_str(), static_cast<unsigned>(firmwareSize));
|
||||||
|
|
||||||
|
auto progressCb = +[](size_t written, size_t total, void* ctx) {
|
||||||
|
auto* self = static_cast<SdFirmwareUpdateActivity*>(ctx);
|
||||||
|
self->writtenBytes = written;
|
||||||
|
self->firmwareSize = total;
|
||||||
|
// immediate=true: wake the render task directly. We're in a tight sync
|
||||||
|
// loop so the main loop won't drain the requestedUpdate flag for us.
|
||||||
|
self->requestUpdate(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Re-validate at flash time (TOCTOU): SD is removable, so don't trust the
|
||||||
|
// pre-confirmation pass. The alreadyValidated parameter on the API stays
|
||||||
|
// for callers (e.g. an OTA staging path) where the same byte stream was
|
||||||
|
// just hashed and there's no removable-media gap.
|
||||||
|
const auto result = firmware_flash::flashFromSdPath(firmwarePath.c_str(), progressCb, this);
|
||||||
|
if (result != firmware_flash::Result::OK) {
|
||||||
|
LOG_ERR("FW", "flash failed: %s", firmware_flash::resultName(result));
|
||||||
|
errorMessage = tr(STR_FIRMWARE_WRITE_FAILED);
|
||||||
|
RenderLock lock(*this);
|
||||||
|
state = State::FAILED;
|
||||||
|
requestUpdate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG_INF("FW", "SD firmware update complete, restarting");
|
||||||
|
{
|
||||||
|
RenderLock lock(*this);
|
||||||
|
state = State::SUCCESS;
|
||||||
|
}
|
||||||
|
requestUpdateAndWait();
|
||||||
|
delay(1500);
|
||||||
|
ESP.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
void SdFirmwareUpdateActivity::loop() {
|
||||||
|
if (state == State::FAILED) {
|
||||||
|
if (mappedInput.wasPressed(MappedInputManager::Button::Back) ||
|
||||||
|
mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||||
|
if (recoveryMode) {
|
||||||
|
// Go back to picker so user can try a different .bin
|
||||||
|
state = State::PICKING;
|
||||||
|
launchPicker();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
finish();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void SdFirmwareUpdateActivity::render(RenderLock&&) {
|
||||||
|
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||||
|
const auto pageWidth = renderer.getScreenWidth();
|
||||||
|
const auto pageHeight = renderer.getScreenHeight();
|
||||||
|
|
||||||
|
renderer.clearScreen();
|
||||||
|
|
||||||
|
const char* headerText = recoveryMode ? tr(STR_RECOVERY_MODE) : tr(STR_SD_FIRMWARE_UPDATE);
|
||||||
|
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, headerText);
|
||||||
|
|
||||||
|
const auto lineHeight = renderer.getLineHeight(UI_10_FONT_ID);
|
||||||
|
const auto top = (pageHeight - lineHeight) / 2;
|
||||||
|
|
||||||
|
if (state == State::VALIDATING) {
|
||||||
|
renderer.drawCenteredText(UI_10_FONT_ID, top, tr(STR_VALIDATING_FIRMWARE));
|
||||||
|
} else if (state == State::UPDATING) {
|
||||||
|
// Throttle redraws to once per percent.
|
||||||
|
const unsigned int pct = firmwareSize > 0 ? static_cast<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 the Arduino Update API,
|
||||||
|
* drawing a progress bar; on success ESP.restart().
|
||||||
|
*
|
||||||
|
* Used both from Settings -> System -> "SD Card Firmware Update", and as the only
|
||||||
|
* activity launched in boot recovery mode (left side button + power on X3).
|
||||||
|
*/
|
||||||
|
class SdFirmwareUpdateActivity : public Activity {
|
||||||
|
public:
|
||||||
|
enum class State {
|
||||||
|
PICKING,
|
||||||
|
VALIDATING,
|
||||||
|
CONFIRMING,
|
||||||
|
UPDATING,
|
||||||
|
SUCCESS,
|
||||||
|
FAILED,
|
||||||
|
};
|
||||||
|
|
||||||
|
explicit SdFirmwareUpdateActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, bool recoveryMode = false)
|
||||||
|
: Activity("SdFirmwareUpdate", renderer, mappedInput), recoveryMode(recoveryMode) {}
|
||||||
|
|
||||||
|
void onEnter() override;
|
||||||
|
void loop() override;
|
||||||
|
void render(RenderLock&&) override;
|
||||||
|
bool preventAutoSleep() override { return state == State::UPDATING || state == State::VALIDATING; }
|
||||||
|
bool skipLoopDelay() override { return state == State::UPDATING; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
State state = State::PICKING;
|
||||||
|
bool recoveryMode = false;
|
||||||
|
|
||||||
|
std::string firmwarePath;
|
||||||
|
size_t firmwareSize = 0;
|
||||||
|
size_t writtenBytes = 0;
|
||||||
|
unsigned int lastRenderedPercent = 101;
|
||||||
|
std::string errorMessage;
|
||||||
|
|
||||||
|
void launchPicker();
|
||||||
|
void onPickerResult(const ActivityResult& result);
|
||||||
|
bool validateFirmware();
|
||||||
|
void promptConfirmation();
|
||||||
|
void onConfirmationResult(const ActivityResult& result);
|
||||||
|
void performUpdate();
|
||||||
|
};
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
#include "MappedInputManager.h"
|
#include "MappedInputManager.h"
|
||||||
#include "OpdsServerListActivity.h"
|
#include "OpdsServerListActivity.h"
|
||||||
#include "OtaUpdateActivity.h"
|
#include "OtaUpdateActivity.h"
|
||||||
|
#include "SdFirmwareUpdateActivity.h"
|
||||||
#include "SettingsList.h"
|
#include "SettingsList.h"
|
||||||
#include "StatusBarSettingsActivity.h"
|
#include "StatusBarSettingsActivity.h"
|
||||||
#include "activities/network/WifiSelectionActivity.h"
|
#include "activities/network/WifiSelectionActivity.h"
|
||||||
@@ -51,6 +52,7 @@ void SettingsActivity::onEnter() {
|
|||||||
systemSettings.push_back(SettingInfo::Action(StrId::STR_OPDS_SERVERS, SettingAction::OPDSBrowser));
|
systemSettings.push_back(SettingInfo::Action(StrId::STR_OPDS_SERVERS, SettingAction::OPDSBrowser));
|
||||||
systemSettings.push_back(SettingInfo::Action(StrId::STR_CLEAR_READING_CACHE, SettingAction::ClearCache));
|
systemSettings.push_back(SettingInfo::Action(StrId::STR_CLEAR_READING_CACHE, SettingAction::ClearCache));
|
||||||
systemSettings.push_back(SettingInfo::Action(StrId::STR_CHECK_UPDATES, SettingAction::CheckForUpdates));
|
systemSettings.push_back(SettingInfo::Action(StrId::STR_CHECK_UPDATES, SettingAction::CheckForUpdates));
|
||||||
|
systemSettings.push_back(SettingInfo::Action(StrId::STR_SD_FIRMWARE_UPDATE, SettingAction::SdFirmwareUpdate));
|
||||||
systemSettings.push_back(SettingInfo::Action(StrId::STR_LANGUAGE, SettingAction::Language));
|
systemSettings.push_back(SettingInfo::Action(StrId::STR_LANGUAGE, SettingAction::Language));
|
||||||
readerSettings.push_back(SettingInfo::Action(StrId::STR_CUSTOMISE_STATUS_BAR, SettingAction::CustomiseStatusBar));
|
readerSettings.push_back(SettingInfo::Action(StrId::STR_CUSTOMISE_STATUS_BAR, SettingAction::CustomiseStatusBar));
|
||||||
|
|
||||||
@@ -189,6 +191,9 @@ void SettingsActivity::toggleCurrentSetting() {
|
|||||||
case SettingAction::CheckForUpdates:
|
case SettingAction::CheckForUpdates:
|
||||||
startActivityForResult(std::make_unique<OtaUpdateActivity>(renderer, mappedInput), resultHandler);
|
startActivityForResult(std::make_unique<OtaUpdateActivity>(renderer, mappedInput), resultHandler);
|
||||||
break;
|
break;
|
||||||
|
case SettingAction::SdFirmwareUpdate:
|
||||||
|
startActivityForResult(std::make_unique<SdFirmwareUpdateActivity>(renderer, mappedInput), resultHandler);
|
||||||
|
break;
|
||||||
case SettingAction::Language:
|
case SettingAction::Language:
|
||||||
startActivityForResult(std::make_unique<LanguageSelectActivity>(renderer, mappedInput), resultHandler);
|
startActivityForResult(std::make_unique<LanguageSelectActivity>(renderer, mappedInput), resultHandler);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ enum class SettingAction {
|
|||||||
Network,
|
Network,
|
||||||
ClearCache,
|
ClearCache,
|
||||||
CheckForUpdates,
|
CheckForUpdates,
|
||||||
|
SdFirmwareUpdate,
|
||||||
Language,
|
Language,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+25
-1
@@ -24,6 +24,7 @@
|
|||||||
#include "RecentBooksStore.h"
|
#include "RecentBooksStore.h"
|
||||||
#include "activities/Activity.h"
|
#include "activities/Activity.h"
|
||||||
#include "activities/ActivityManager.h"
|
#include "activities/ActivityManager.h"
|
||||||
|
#include "activities/settings/SdFirmwareUpdateActivity.h"
|
||||||
#include "components/UITheme.h"
|
#include "components/UITheme.h"
|
||||||
#include "fontIds.h"
|
#include "fontIds.h"
|
||||||
#include "util/ButtonNavigator.h"
|
#include "util/ButtonNavigator.h"
|
||||||
@@ -284,6 +285,25 @@ void setup() {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// First serial output only here to avoid timing inconsistencies for power button press duration verification
|
// First serial output only here to avoid timing inconsistencies for power button press duration verification
|
||||||
LOG_DBG("MAIN", "Starting CrossPoint version " CROSSPOINT_VERSION);
|
LOG_DBG("MAIN", "Starting CrossPoint version " CROSSPOINT_VERSION);
|
||||||
|
|
||||||
@@ -294,7 +314,11 @@ void setup() {
|
|||||||
APP_STATE.loadFromFile();
|
APP_STATE.loadFromFile();
|
||||||
RECENT_BOOKS.loadFromFile();
|
RECENT_BOOKS.loadFromFile();
|
||||||
|
|
||||||
if (HalSystem::isRebootFromPanic()) {
|
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 (HalSystem::isRebootFromPanic()) {
|
||||||
// If we rebooted from a panic, go to crash report screen to show the panic info
|
// If we rebooted from a panic, go to crash report screen to show the panic info
|
||||||
activityManager.goToCrashReport();
|
activityManager.goToCrashReport();
|
||||||
} else if (APP_STATE.openEpubPath.empty() || !APP_STATE.lastSleepFromReader ||
|
} else if (APP_STATE.openEpubPath.empty() || !APP_STATE.lastSleepFromReader ||
|
||||||
|
|||||||
@@ -0,0 +1,311 @@
|
|||||||
|
#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` starting at the current read offset, feeding them through
|
||||||
|
// both the XOR-checksum and SHA256 accumulators. Used by validateImageFile so the whole image
|
||||||
|
// is verified end-to-end without holding it in RAM (ESP32-C3 only has ~380 KB).
|
||||||
|
Result feedHashAndChecksum(HalFile& file, size_t length, uint8_t* xorAccum, mbedtls_sha256_context* sha, uint8_t* buf) {
|
||||||
|
size_t remaining = length;
|
||||||
|
while (remaining > 0) {
|
||||||
|
const size_t want = std::min<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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the padding bytes (which include the stored checksum at the last byte) into the SHA stream.
|
||||||
|
const size_t padLen = padEnd - pos;
|
||||||
|
uint8_t padBuf[16];
|
||||||
|
if (padLen > sizeof(padBuf)) {
|
||||||
|
mbedtls_sha256_free(&shaCtx);
|
||||||
|
file.close();
|
||||||
|
return Result::BAD_SIZE;
|
||||||
|
}
|
||||||
|
if (padLen > 0 && file.read(padBuf, padLen) != static_cast<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) {
|
||||||
|
// Resolve destination first so we can size-check during validation. The full image-integrity
|
||||||
|
// pass below verifies header, segment table, XOR checksum and SHA256 trailer end-to-end before
|
||||||
|
// we touch otadata, so a truncated/corrupted .bin can never become the next boot target.
|
||||||
|
const esp_partition_t* dest = esp_ota_get_next_update_partition(nullptr);
|
||||||
|
if (!dest) {
|
||||||
|
LOG_ERR("FLASH", "no next-update partition");
|
||||||
|
return Result::NO_PARTITION;
|
||||||
|
}
|
||||||
|
|
||||||
|
// When the caller already ran validateImageFile() against this same partition
|
||||||
|
// size (e.g. SdFirmwareUpdateActivity validates before the confirmation
|
||||||
|
// prompt), skip the redundant integrity scan. We still keep the partition
|
||||||
|
// lookup so the rest of the flashing path stays unchanged.
|
||||||
|
if (!alreadyValidated) {
|
||||||
|
const Result validateRes = validateImageFile(sdPath, dest->size);
|
||||||
|
if (validateRes != Result::OK) {
|
||||||
|
LOG_ERR("FLASH", "image validation failed: %s", resultName(validateRes));
|
||||||
|
return validateRes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HalFile file;
|
||||||
|
if (!Storage.openFileForRead("FLASH", sdPath, file) || !file) {
|
||||||
|
LOG_ERR("FLASH", "open failed: %s", sdPath);
|
||||||
|
return Result::OPEN_FAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
const size_t firmwareSize = file.fileSize();
|
||||||
|
LOG_INF("FLASH", "src=%s size=%u dest=%s @0x%x partsize=%u", sdPath, static_cast<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
|
||||||
|
// rather than stalling for several seconds during a single up-front erase.
|
||||||
|
size_t streamPos = 0;
|
||||||
|
size_t erasedUpto = 0;
|
||||||
|
while (streamPos < firmwareSize) {
|
||||||
|
if (streamPos >= erasedUpto) {
|
||||||
|
size_t eraseLen = std::min<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
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
#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 (any future entry point) keep
|
||||||
|
// the defense-in-depth check.
|
||||||
|
Result flashFromSdPath(const char* sdPath, ProgressCb onProgress, void* ctx, bool alreadyValidated = false);
|
||||||
|
|
||||||
|
// Full-image integrity check that mirrors the bootloader's verification:
|
||||||
|
// header magic, segment table walk, XOR checksum, and SHA256 trailer (when
|
||||||
|
// hash_appended == 1). Run this before flashing a candidate firmware so a
|
||||||
|
// truncated/corrupted .bin never reaches otadata.
|
||||||
|
//
|
||||||
|
// `partitionSize` is the size of the destination OTA partition; pass 0 to
|
||||||
|
// skip the size-fits-partition check (e.g. when validating ahead of partition
|
||||||
|
// lookup). Streams the file in CHUNK-sized reads; the file is rewound on
|
||||||
|
// success so the caller can immediately reread it for flashing.
|
||||||
|
Result validateImageFile(const char* sdPath, size_t partitionSize);
|
||||||
|
|
||||||
|
const char* resultName(Result r);
|
||||||
|
|
||||||
|
} // namespace firmware_flash
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user