Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
255 changes: 255 additions & 0 deletions src/activities/settings/SdFirmwareUpdateActivity.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
#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;
// Percent label is drawn by BaseTheme::drawProgressBar; this slot is left intentionally empty
// so the do-not-power-off line below stays at the same Y as before.
y += lineHeight + metrics.verticalSpacing;
renderer.drawCenteredText(UI_10_FONT_ID, y, tr(STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF));
} else if (state == State::SUCCESS) {
renderer.drawCenteredText(UI_10_FONT_ID, top, tr(STR_UPDATE_COMPLETE), true, EpdFontFamily::BOLD);
renderer.drawCenteredText(UI_10_FONT_ID, top + lineHeight + metrics.verticalSpacing, tr(STR_RESTARTING_HINT));
} else if (state == State::FAILED) {
renderer.drawCenteredText(UI_10_FONT_ID, top, tr(STR_UPDATE_FAILED), true, EpdFontFamily::BOLD);
if (!errorMessage.empty()) {
renderer.drawCenteredText(UI_10_FONT_ID, top + lineHeight + metrics.verticalSpacing, errorMessage.c_str());
}
const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", "");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
} else {
// PICKING / CONFIRMING: a sub-activity is on top, nothing to draw.
if (recoveryMode) {
renderer.drawCenteredText(UI_10_FONT_ID, top, tr(STR_RECOVERY_MODE_HINT));
}
}

renderer.displayBuffer();
}
56 changes: 56 additions & 0 deletions src/activities/settings/SdFirmwareUpdateActivity.h
Original file line number Diff line number Diff line change
@@ -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();
};
4 changes: 4 additions & 0 deletions src/activities/settings/SettingsActivity.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
#include "MappedInputManager.h"
#include "OpdsServerListActivity.h"
#include "OtaUpdateActivity.h"
#include "SdFirmwareUpdateActivity.h"
#include "SettingsList.h"
#include "StatusBarSettingsActivity.h"
#include "activities/tools/SleepImagePickerActivity.h"
Expand Down Expand Up @@ -365,6 +366,9 @@ void SettingsActivity::toggleCurrentSetting() {
delay(500);
ESP.restart();
break;
case SettingAction::SdFirmwareUpdate:
startActivityForResult(std::make_unique<SdFirmwareUpdateActivity>(renderer, mappedInput), resultHandler);
break;
case SettingAction::None:
// Do nothing
break;
Expand Down
Loading
Loading