add recovery firmware with OTA updater and recovery web interface

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
proddy
2026-10-06 22:57:25 +02:00
co-authored by Cursor
parent 1375f87163
commit 6411b6ffbd
59 changed files with 3595 additions and 339 deletions
+79 -96
View File
@@ -2,44 +2,13 @@
#include <emsesp.h>
#include <esp_app_format.h>
#include <esp_ota_ops.h>
static String getFilenameExtension(const String & filename) {
const auto pos = filename.lastIndexOf('.');
if (pos != -1) {
return filename.substring(static_cast<unsigned int>(pos) + 1);
}
return {};
}
// Accepts a raw 32-char hex digest, optionally with trailing newline or a GNU "hash filename" suffix.
static bool parseMd5Digest(const uint8_t * data, size_t len, std::array<char, 33> & out) {
size_t i = 0;
while (i < len && (data[i] == ' ' || data[i] == '\t' || data[i] == '\r' || data[i] == '\n')) {
++i;
}
if (len - i < 32) {
return false;
}
for (size_t n = 0; n < 32; n++) {
const char c = static_cast<char>(data[i + n]);
const bool hex = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F');
if (!hex) {
return false;
}
out[n] = (c >= 'A' && c <= 'F') ? static_cast<char>(c - 'A' + 'a') : c;
}
out[32] = '\0';
return true;
}
UploadFileService::UploadFileService(AsyncWebServer * server, SecurityManager * securityManager)
: _securityManager(securityManager)
, _is_firmware(false)
, _is_filesystem(false)
, _md5_applied(false)
, _md5() {
, _is_recovery(false) {
server->on(
UPLOAD_FILE_PATH,
HTTP_POST,
@@ -60,68 +29,60 @@ void UploadFileService::handleUpload(AsyncWebServerRequest * request, const Stri
// at init
if (!index) {
// check details of the file, to see if its a valid bin or json file
const String extension = getFilenameExtension(filename);
const std::size_t filesize = request->contentLength();
const std::size_t filesize = request->contentLength();
_is_firmware = false;
_is_filesystem = false;
_is_recovery = false;
if (extension == "bin" && filename.endsWith("littlefs.bin")) {
// LittleFS filesystem image
switch (OtaUpdater::classify(filename, filesize, emsesp::System::MIN_FIRMWARE_SIZE)) {
case OtaUpdater::FileType::FILESYSTEM:
_is_filesystem = true;
_md5[0] = '\0'; // clear any stale md5 so Update.end() doesn't compare against it
_md5_applied = false;
} else if ((extension == "bin") && (filesize >= emsesp::System::MIN_FIRMWARE_SIZE)) {
_ota.clearMd5(); // clear any stale md5 so Update.end() doesn't compare against it
break;
case OtaUpdater::FileType::FIRMWARE:
_is_firmware = true;
} else if (extension == "json") {
_md5[0] = '\0'; // clear md5
_md5_applied = false;
} else if (extension == "md5") {
if (!parseMd5Digest(data, len, _md5)) {
break;
case OtaUpdater::FileType::JSON:
_ota.clearMd5();
break;
case OtaUpdater::FileType::MD5:
if (!_ota.setMd5(data, len)) {
emsesp::EMSESP::logger().err("Invalid MD5 digest file");
handleError(request, 406); // Not Acceptable
} else {
emsesp::EMSESP::logger().info("MD5 digest received (%s). Now upload the firmware BIN", _md5.data());
emsesp::EMSESP::logger().info("MD5 digest received (%s). Now upload the firmware BIN", _ota.md5());
}
return;
} else {
_md5.front() = '\0';
_md5_applied = false;
default:
_ota.clearMd5();
emsesp::EMSESP::logger().err("Unsupported file type: %s, size: %u", filename.c_str(), filesize);
handleError(request, 406); // Not Acceptable - unsupported file type
return;
}
if (_is_firmware) {
// Check firmware header, 0xE9 magic offset 0 indicates esp bin, chip offset 12: esp32:0, S2:2, C3:5
#if CONFIG_IDF_TARGET_ESP32 // ESP32/PICO-D4
if (len > 12 && (data[0] != ESP_IMAGE_HEADER_MAGIC || data[12] != ESP_CHIP_ID_ESP32)) {
handleError(request, 503); // service unavailable
return;
}
#elif CONFIG_IDF_TARGET_ESP32S2
if (len > 12 && (data[0] != ESP_IMAGE_HEADER_MAGIC || data[12] != ESP_CHIP_ID_ESP32S2)) {
handleError(request, 503); // service unavailable
return;
}
#elif CONFIG_IDF_TARGET_ESP32C3
if (len > 12 && (data[0] != ESP_IMAGE_HEADER_MAGIC || data[12] != ESP_CHIP_ID_ESP32C3)) {
handleError(request, 503); // service unavailable
return;
}
#elif CONFIG_IDF_TARGET_ESP32S3
if (len > 12 && (data[0] != ESP_IMAGE_HEADER_MAGIC || data[12] != ESP_CHIP_ID_ESP32S3)) {
handleError(request, 503); // service unavailable
return;
}
#elif CONFIG_IDF_TARGET_ESP32C6
if (len > 12 && (data[0] != ESP_IMAGE_HEADER_MAGIC || data[12] != ESP_CHIP_ID_ESP32C6)) {
if (!OtaUpdater::isCompatibleFirmware(data, len)) {
handleError(request, 503); // service unavailable
return;
}
const esp_partition_t * target = nullptr;
#ifdef EMSESP_HAS_RECOVERY
if (OtaUpdater::isRecoveryFirmware(data, len)) {
target = esp_partition_find_first(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_FACTORY, nullptr);
if (target == nullptr || target == esp_ota_get_running_partition()) {
emsesp::EMSESP::logger().err("The recovery firmware can't be installed on this board");
handleError(request, 406);
return;
}
_is_recovery = true;
emsesp::EMSESP::logger().info("Recovery firmware uploading to the %s partition (size: %dKB). Please wait...", target->label, filesize / 1024);
} else
#endif
// it's firmware - initialize the ArduinoOTA updater
emsesp::EMSESP::logger().info("Firmware uploading (file %s, size: %dKB). Please wait...", filename.c_str(), filesize / 1024);
{
emsesp::EMSESP::logger().info("Firmware uploading (file %s, size: %dKB). Please wait...", filename.c_str(), filesize / 1024);
}
// turn off UART to prevent interference with the upload
emsesp::EMSuart::stop();
@@ -129,12 +90,9 @@ void UploadFileService::handleUpload(AsyncWebServerRequest * request, const Stri
// tell main loop we're uploading so services as paused (e.g. MQTT)
emsesp::EMSESP::system_.systemStatus(emsesp::SYSTEM_STATUS::SYSTEM_STATUS_UPLOADING);
if (Update.begin(filesize - sizeof(esp_image_header_t))) {
_md5_applied = false;
if (strlen(_md5.data()) == _md5.size() - 1) {
Update.setMD5(_md5.data());
_md5_applied = true;
emsesp::EMSESP::logger().info("Firmware MD5 check enabled (%s)", _md5.data());
if (_ota.beginFirmware(filesize, target, !_is_recovery)) {
if (_ota.md5Applied()) {
emsesp::EMSESP::logger().info("Firmware MD5 check enabled (%s)", _ota.md5());
}
request->onDisconnect([this] { handleDisconnect(); }); // success, let's make sure we end the update if the client hangs up
} else {
@@ -147,14 +105,10 @@ void UploadFileService::handleUpload(AsyncWebServerRequest * request, const Stri
emsesp::EMSuart::stop();
LittleFS.end(); // unmount LittleFS before we overwrite the partition under it
// request->contentLength() is the multipart HTTP body size, not the file size,
// so it can exceed the partition by a few hundred bytes. Use UPDATE_SIZE_UNKNOWN
// and let the Update library size against the whole partition.
if (Update.begin(UPDATE_SIZE_UNKNOWN, U_SPIFFS)) {
// emsesp::EMSESP::logger().info("Update.begin(U_SPIFFS) ok, partition size %u bytes", static_cast<unsigned>(Update.size()));
if (_ota.beginFilesystem()) {
request->onDisconnect([this] { handleDisconnect(); });
} else {
emsesp::EMSESP::logger().err("Update.begin(U_SPIFFS) failed: %s", Update.errorString());
emsesp::EMSESP::logger().err("Update.begin(U_SPIFFS) failed: %s", _ota.errorString());
handleError(request, 507);
return;
}
@@ -167,17 +121,18 @@ void UploadFileService::handleUpload(AsyncWebServerRequest * request, const Stri
if (_is_firmware || _is_filesystem) {
if (!request->_tempObject) {
//continue with the OTA update
if (Update.write(data, len) != len) {
if (!_ota.write(data, len)) {
emsesp::EMSESP::logger().err("OTA update failed at offset %u (chunk %u): %s",
static_cast<unsigned>(Update.progress()),
static_cast<unsigned>(_ota.progress()),
static_cast<unsigned>(len),
Update.errorString());
_ota.errorString());
_ota.abort();
handleError(request, 500); // internal error, failed
return;
}
if (final) {
if (!Update.end(true)) {
emsesp::EMSESP::logger().err("OTA update failed: %s", Update.errorString());
if (!_ota.end()) {
emsesp::EMSESP::logger().err("OTA update failed: %s", _ota.errorString());
handleError(request, 500); // internal error, failed
return;
}
@@ -206,21 +161,39 @@ void UploadFileService::uploadComplete(AsyncWebServerRequest * request) {
// check if it was a firmware or filesystem image upgrade
// if no error, send the success response and request a restart
#ifdef EMSESP_HAS_RECOVERY
if (_is_recovery && !request->_tempObject) {
emsesp::EMSESP::system_.recovery_installed();
auto * response = new emsesp::PsramAsyncJsonResponse(false);
JsonObject root = response->getRoot();
root["recovery"] = true;
if (_ota.md5Applied()) {
emsesp::EMSESP::logger().info("Firmware MD5 matches");
root["md5_ok"] = true;
}
response->setLength();
request->send(response);
_ota.clearMd5();
_is_recovery = false;
emsesp::EMSESP::system_.systemStatus(emsesp::SYSTEM_STATUS::SYSTEM_STATUS_NORMAL); // no restart needed
return;
}
#endif
if ((_is_firmware || _is_filesystem) && !request->_tempObject) {
if (_is_firmware) {
// set NVS to tell EMS-ESP this is a new fresh firmware on next restart
emsesp::EMSESP::nvs_.putBool(emsesp::EMSESP_NVS_BOOT_NEW_FIRMWARE, true);
}
if (_is_firmware && _md5_applied) {
if (_is_firmware && _ota.md5Applied()) {
emsesp::EMSESP::logger().info("Firmware MD5 matches");
auto * response = new emsesp::PsramAsyncJsonResponse(false);
JsonObject root = response->getRoot();
root["md5_ok"] = true;
response->setLength();
request->send(response);
_md5.front() = '\0';
_md5_applied = false;
_ota.clearMd5();
} else {
AsyncWebServerResponse * response = request->beginResponse(200);
request->send(response);
@@ -231,10 +204,10 @@ void UploadFileService::uploadComplete(AsyncWebServerRequest * request) {
}
// add MD5 to the response
if (strlen(_md5.data()) == _md5.size() - 1) {
if (_ota.hasMd5()) {
auto * response = new emsesp::PsramAsyncJsonResponse(false);
JsonObject root = response->getRoot();
root["md5"] = _md5.data();
root["md5"] = _ota.md5();
response->setLength();
request->send(response);
return;
@@ -262,7 +235,8 @@ void UploadFileService::handleError(AsyncWebServerRequest * request, int code) {
request->client()->close();
_is_firmware = false;
_is_filesystem = false;
Update.abort();
_is_recovery = false;
_ota.abort();
}
// if we aborted a filesystem upload, remount LittleFS so the device keeps working
@@ -275,6 +249,15 @@ void UploadFileService::handleDisconnect() {
emsesp::EMSESP::logger().info("Upload finished");
emsesp::EMSESP::system_.uart_init(); // re-enable UART
#ifdef EMSESP_HAS_RECOVERY
if (_is_recovery) {
// the client hung up before the upload completed
_ota.abort();
emsesp::EMSESP::system_.systemStatus(emsesp::SYSTEM_STATUS::SYSTEM_STATUS_NORMAL);
}
#endif
_is_firmware = false;
_is_filesystem = false;
_is_recovery = false;
}
+6 -8
View File
@@ -6,11 +6,9 @@
#include <Arduino.h>
#include <ESPAsyncWebServer.h>
#include <LittleFS.h>
#include <Update.h>
#include <OtaUpdater.h>
#include <WiFi.h>
#include <array>
#define UPLOAD_FILE_PATH "/rest/uploadFile"
#define TEMP_FILENAME_PATH "/pre_load.json" // for uploaded json files, handled by System::check_restore()
@@ -20,11 +18,11 @@ class UploadFileService {
UploadFileService(AsyncWebServer * server, SecurityManager * securityManager);
private:
SecurityManager * _securityManager;
bool _is_firmware;
bool _is_filesystem;
bool _md5_applied; // true if an MD5 digest was applied to the current firmware upload
std::array<char, 33> _md5;
SecurityManager * _securityManager;
bool _is_firmware;
bool _is_filesystem;
bool _is_recovery; // firmware for the factory partition, which doesn't change the boot partition
OtaUpdater _ota;
void handleUpload(AsyncWebServerRequest * request, const String & filename, size_t index, uint8_t * data, size_t len, bool final);
void uploadComplete(AsyncWebServerRequest * request);
+2
View File
@@ -1895,6 +1895,8 @@ void EMSESP::start() {
nvs_.begin("ems-esp", false, "nvs");
#endif
System::check_crash_loop();
// set valid GPIOs list based on ESP32 chip/platform type
system_.set_valid_system_gpios();
+2
View File
@@ -116,6 +116,8 @@ class EMSESPShell;
class Shower;
static constexpr const char * EMSESP_NVS_BOOT_NEW_FIRMWARE = "fresh_firmware"; // max 15 characters
// why EMS-ESP switched to the recovery firmware, read and removed by the recovery app (recovery/main.cpp)
static constexpr const char * EMSESP_NVS_RECOVERY_REASON = "rec_reason";
class EMSESP {
public:
+20
View File
@@ -18,12 +18,32 @@
#include "emsesp.h"
#ifdef EMSESP_CRASH_TEST
#include <esp_ota_ops.h>
#endif
using namespace emsesp;
static EMSESP application; // the main application
#ifdef EMSESP_CRASH_TEST
// test builds for the bootloader rollback and the recovery fallback, see [env:crashtest_s3_16M_P] in platformio.ini
static void crash_test() {
esp_ota_img_states_t state = ESP_OTA_IMG_UNDEFINED;
esp_ota_get_state_partition(esp_ota_get_running_partition(), &state);
if (EMSESP_CRASH_TEST == 1 || state == ESP_OTA_IMG_VALID) {
Serial.println("Crash test, aborting");
Serial.flush();
abort();
}
}
#endif
void setup() {
application.start();
#ifdef EMSESP_CRASH_TEST
crash_test();
#endif
}
void loop() {
+117
View File
@@ -27,6 +27,7 @@
#include <esp_mac.h>
#include "esp_efuse.h"
#include <nvs.h>
#include <Update.h>
#endif
#include <map>
@@ -43,10 +44,24 @@
#define ENABLE_SMTP
#include <ESP_SSLClient.h>
#include <ReadyMail.h>
#include <OtaUpdater.h>
// Keeps new firmware in the bootloader's pending verification state until System::check_firmware_health()
// confirms it, so a crash or restart before then rolls back to the previous image
extern "C" bool verifyRollbackLater() {
return true;
}
#endif
namespace emsesp {
#ifndef EMSESP_STANDALONE
// survives restarts, panics and watchdog resets but not a power cycle, hence the magic
static constexpr uint32_t CRASH_COUNT_MAGIC = 0x43524153;
RTC_NOINIT_ATTR static uint32_t crash_count_magic_;
RTC_NOINIT_ATTR static uint32_t crash_count_;
#endif
// Languages supported. Note: the order is important
// and must match locale_translations.h and common.h
#if defined(EMSESP_TEST)
@@ -517,6 +532,10 @@ void System::get_partition_info() {
}
}
#ifdef EMSESP_HAS_RECOVERY
// the recovery firmware is shown separately
is_valid = is_valid && !OtaUpdater::isRecoveryPartition(part);
#endif
// get the version from the NVS store, and add to map
if (is_valid) {
PartitionInfo p_info;
@@ -547,6 +566,31 @@ void System::get_partition_info() {
#endif
}
void System::recovery_installed() {
#ifdef EMSESP_HAS_RECOVERY
// drop the version info of an EMS-ESP firmware that was in the factory partition before
const esp_partition_t * factory = esp_partition_find_first(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_FACTORY, nullptr);
if (factory != nullptr) {
char c[20];
snprintf(c, sizeof(c), "d_%s", factory->label);
EMSESP::nvs_.remove(factory->label);
EMSESP::nvs_.remove(c);
partition_info_.erase(factory->label); // on the AsyncTCP task, same as the web handlers reading it
}
LOG_INFO("Recovery firmware v%s installed", recovery_version().c_str());
#endif
}
std::string System::recovery_version() const {
#ifdef EMSESP_HAS_RECOVERY
esp_app_desc_t desc;
if (OtaUpdater::isRecoveryPartition(esp_partition_find_first(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_FACTORY, nullptr), &desc)) {
return desc.version;
}
#endif
return "";
}
// set install time/date for the current partition, in UTC
// assumes NTP is connected and working
void System::set_partition_install_date() {
@@ -610,7 +654,13 @@ void System::system_restart(const char * partitionname) {
// Factory partition - label will be "factory"
const esp_partition_t * partition = esp_partition_find_first(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_FACTORY, NULL);
if (partition && !strcmp(partition->label, partitionname)) {
#ifdef EMSESP_HAS_RECOVERY
if (esp_ota_set_boot_partition(partition) == ESP_OK) {
EMSESP::nvs_.putString(EMSESP_NVS_RECOVERY_REASON, "request");
}
#else
esp_ota_set_boot_partition(partition);
#endif
} else
// try and find the partition by name
if (strcmp(esp_ota_get_running_partition()->label, partitionname)) {
@@ -762,11 +812,74 @@ void System::store_settings(WebSettings & settings) {
auto_fw_check_ = settings.auto_fw_check;
}
// counts consecutive crashes and falls back to the recovery firmware in the factory partition (16MB boards)
void System::check_crash_loop() {
#ifndef EMSESP_STANDALONE
if (crash_count_magic_ != CRASH_COUNT_MAGIC) {
crash_count_magic_ = CRASH_COUNT_MAGIC;
crash_count_ = 0;
}
switch (esp_reset_reason()) {
case ESP_RST_PANIC:
case ESP_RST_INT_WDT:
case ESP_RST_TASK_WDT:
case ESP_RST_WDT:
crash_count_++;
break;
default:
crash_count_ = 0;
return;
}
#ifdef EMSESP_HAS_RECOVERY
if (crash_count_ < CRASH_LOOP_LIMIT) {
return;
}
const esp_partition_t * factory = esp_partition_find_first(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_FACTORY, nullptr);
if (factory == nullptr || factory == esp_ota_get_running_partition()) {
return;
}
// fails if the factory partition doesn't hold a valid image, then keep trying the current one
if (esp_ota_set_boot_partition(factory) == ESP_OK) {
crash_count_ = 0;
EMSESP::nvs_.putString(EMSESP_NVS_RECOVERY_REASON, "crash");
Serial.printf("Crashed %d times in a row, restarting into the %s partition\n", CRASH_LOOP_LIMIT, factory->label);
Serial.flush();
esp_restart();
}
#endif
#endif
}
// once the firmware has run long enough, confirm it to the bootloader so it isn't rolled back
void System::check_firmware_health() {
#ifndef EMSESP_STANDALONE
if (firmware_healthy_ || uuid::get_uptime_sec() < FIRMWARE_HEALTHY_UPTIME) {
return;
}
firmware_healthy_ = true;
crash_count_ = 0;
if (OtaUpdater::confirmRunningApp()) {
LOG_INFO("New firmware confirmed as working");
}
#endif
}
// Starts up core services
void System::start() {
get_partition_info(); // get the partition info
#ifndef EMSESP_STANDALONE
if (crash_count_) {
LOG_WARNING("Restarted after a crash (%u in a row)", crash_count_);
}
const esp_partition_t * invalid = esp_ota_get_last_invalid_partition();
if (invalid != nullptr) {
LOG_WARNING("Firmware in partition %s failed to start and was rolled back", invalid->label);
}
// disable bluetooth module
// periph_module_disable(PERIPH_BT_MODULE);
if (low_clock_) {
@@ -1028,6 +1141,8 @@ void System::system_check() {
if (!last_system_check_ || ((uint32_t)(current_uptime - last_system_check_) >= SYSTEM_CHECK_FREQUENCY)) {
last_system_check_ = current_uptime;
check_firmware_health();
#ifndef EMSESP_STANDALONE
#if CONFIG_IDF_TARGET_ESP32S3 || CONFIG_IDF_TARGET_ESP32C3 || CONFIG_IDF_TARGET_ESP32S2
temperature_sensor_get_celsius(temperature_handle_, &temperature_);
@@ -3346,6 +3461,8 @@ bool System::uploadFirmwareURL(const char * url) {
return false; // error
}
OtaUpdater::confirmRunningApp();
// check we have enough space for the upload in the ota partition
if (!Update.begin(firmware_size)) {
LOG_ERROR("Firmware upload failed - no space");
+13
View File
@@ -81,6 +81,11 @@ class System {
void start();
bool loop(); // returns true if the LED flash is active
static void check_crash_loop(); // call at boot once NVS is open, may restart into the factory partition
void recovery_installed(); // called on the AsyncTCP task after the recovery firmware was written
std::string recovery_version() const; // empty if there's no recovery firmware
// commands
static bool command_read(const char * value, const int8_t id);
static bool command_send(const char * value, const int8_t id);
@@ -385,6 +390,14 @@ class System {
void set_partition_install_date();
// firmware is confirmed to the bootloader and the crash counter cleared once it has been up this long
static constexpr uint32_t FIRMWARE_HEALTHY_UPTIME = 120; // seconds
// consecutive crashes before falling back to the recovery firmware in the factory partition
static constexpr uint8_t CRASH_LOOP_LIMIT = 5;
bool firmware_healthy_ = false;
void check_firmware_health();
// button
static PButton myPButton_; // PButton instance
static void button_OnClick(PButton & b);
+3
View File
@@ -137,6 +137,9 @@ void WebStatusService::systemStatus(AsyncWebServerRequest * request) {
// check for a factory partition first
const esp_partition_t * partition = esp_partition_find_first(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_FACTORY, nullptr);
root["has_loader"] = partition != NULL && partition != esp_ota_get_running_partition();
if (partition != NULL) {
root["recovery_version"] = EMSESP::system_.recovery_version(); // empty if not installed
}
partition = esp_ota_get_next_update_partition(nullptr);
if (partition) {
uint64_t buffer;