From d23020e268d15302147c0474fa8140d1aa6fba53 Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Mon, 22 Dec 2025 17:16:46 +1100 Subject: [PATCH] OTA updates (#96) ## Summary * Adds support for OTA * Gets latest firmware bin from latest GitHub release * I have noticed it be a little flaky unpacking the JSON and occasionally failing to start --- src/activities/settings/OtaUpdateActivity.cpp | 242 ++++++++++++++++++ src/activities/settings/OtaUpdateActivity.h | 43 ++++ src/activities/settings/SettingsActivity.cpp | 30 ++- src/activities/settings/SettingsActivity.h | 11 +- src/network/OtaUpdater.cpp | 169 ++++++++++++ src/network/OtaUpdater.h | 30 +++ 6 files changed, 514 insertions(+), 11 deletions(-) create mode 100644 src/activities/settings/OtaUpdateActivity.cpp create mode 100644 src/activities/settings/OtaUpdateActivity.h create mode 100644 src/network/OtaUpdater.cpp create mode 100644 src/network/OtaUpdater.h diff --git a/src/activities/settings/OtaUpdateActivity.cpp b/src/activities/settings/OtaUpdateActivity.cpp new file mode 100644 index 00000000..d31f4103 --- /dev/null +++ b/src/activities/settings/OtaUpdateActivity.cpp @@ -0,0 +1,242 @@ +#include "OtaUpdateActivity.h" + +#include +#include +#include + +#include "activities/network/WifiSelectionActivity.h" +#include "config.h" +#include "network/OtaUpdater.h" + +void OtaUpdateActivity::taskTrampoline(void* param) { + auto* self = static_cast(param); + self->displayTaskLoop(); +} + +void OtaUpdateActivity::onWifiSelectionComplete(const bool success) { + exitActivity(); + + if (!success) { + Serial.printf("[%lu] [OTA] WiFi connection failed, exiting\n", millis()); + goBack(); + return; + } + + Serial.printf("[%lu] [OTA] WiFi connected, checking for update\n", millis()); + + xSemaphoreTake(renderingMutex, portMAX_DELAY); + state = CHECKING_FOR_UPDATE; + xSemaphoreGive(renderingMutex); + updateRequired = true; + vTaskDelay(10 / portTICK_PERIOD_MS); + const auto res = updater.checkForUpdate(); + if (res != OtaUpdater::OK) { + Serial.printf("[%lu] [OTA] Update check failed: %d\n", millis(), res); + xSemaphoreTake(renderingMutex, portMAX_DELAY); + state = FAILED; + xSemaphoreGive(renderingMutex); + updateRequired = true; + return; + } + + if (!updater.isUpdateNewer()) { + Serial.printf("[%lu] [OTA] No new update available\n", millis()); + xSemaphoreTake(renderingMutex, portMAX_DELAY); + state = NO_UPDATE; + xSemaphoreGive(renderingMutex); + updateRequired = true; + return; + } + + xSemaphoreTake(renderingMutex, portMAX_DELAY); + state = WAITING_CONFIRMATION; + xSemaphoreGive(renderingMutex); + updateRequired = true; +} + +void OtaUpdateActivity::onEnter() { + ActivityWithSubactivity::onEnter(); + + renderingMutex = xSemaphoreCreateMutex(); + + xTaskCreate(&OtaUpdateActivity::taskTrampoline, "OtaUpdateActivityTask", + 2048, // Stack size + this, // Parameters + 1, // Priority + &displayTaskHandle // Task handle + ); + + // Turn on WiFi immediately + Serial.printf("[%lu] [OTA] Turning on WiFi...\n", millis()); + WiFi.mode(WIFI_STA); + + // Launch WiFi selection subactivity + Serial.printf("[%lu] [OTA] Launching WifiSelectionActivity...\n", millis()); + enterNewActivity(new WifiSelectionActivity(renderer, inputManager, + [this](const bool connected) { onWifiSelectionComplete(connected); })); +} + +void OtaUpdateActivity::onExit() { + ActivityWithSubactivity::onExit(); + + // Turn off wifi + WiFi.disconnect(false); // false = don't erase credentials, send disconnect frame + delay(100); // Allow disconnect frame to be sent + WiFi.mode(WIFI_OFF); + delay(100); // Allow WiFi hardware to fully power down + + // Wait until not rendering to delete task to avoid killing mid-instruction to EPD + xSemaphoreTake(renderingMutex, portMAX_DELAY); + if (displayTaskHandle) { + vTaskDelete(displayTaskHandle); + displayTaskHandle = nullptr; + } + vSemaphoreDelete(renderingMutex); + renderingMutex = nullptr; +} + +void OtaUpdateActivity::displayTaskLoop() { + while (true) { + if (updateRequired) { + updateRequired = false; + xSemaphoreTake(renderingMutex, portMAX_DELAY); + render(); + xSemaphoreGive(renderingMutex); + } + vTaskDelay(10 / portTICK_PERIOD_MS); + } +} + +void OtaUpdateActivity::render() { + if (subActivity) { + // Subactivity handles its own rendering + return; + } + + float updaterProgress = 0; + if (state == UPDATE_IN_PROGRESS) { + Serial.printf("[%lu] [OTA] Update progress: %d / %d\n", millis(), updater.processedSize, updater.totalSize); + updaterProgress = static_cast(updater.processedSize) / static_cast(updater.totalSize); + // Only update every 2% at the most + if (static_cast(updaterProgress * 50) == lastUpdaterPercentage / 2) { + return; + } + lastUpdaterPercentage = static_cast(updaterProgress * 100); + } + + const auto pageHeight = renderer.getScreenHeight(); + const auto pageWidth = renderer.getScreenWidth(); + + renderer.clearScreen(); + renderer.drawCenteredText(READER_FONT_ID, 10, "Update", true, BOLD); + + if (state == CHECKING_FOR_UPDATE) { + renderer.drawCenteredText(UI_FONT_ID, 300, "Checking for update...", true, BOLD); + renderer.displayBuffer(); + return; + } + + if (state == WAITING_CONFIRMATION) { + renderer.drawCenteredText(UI_FONT_ID, 200, "New update available!", true, BOLD); + renderer.drawText(UI_FONT_ID, 20, 250, "Current Version: " CROSSPOINT_VERSION); + renderer.drawText(UI_FONT_ID, 20, 270, ("New Version: " + updater.getLatestVersion()).c_str()); + + renderer.drawRect(25, pageHeight - 40, 106, 40); + renderer.drawText(UI_FONT_ID, 25 + (105 - renderer.getTextWidth(UI_FONT_ID, "Cancel")) / 2, pageHeight - 35, + "Cancel"); + + renderer.drawRect(130, pageHeight - 40, 106, 40); + renderer.drawText(UI_FONT_ID, 130 + (105 - renderer.getTextWidth(UI_FONT_ID, "Update")) / 2, pageHeight - 35, + "Update"); + renderer.displayBuffer(); + return; + } + + if (state == UPDATE_IN_PROGRESS) { + renderer.drawCenteredText(UI_FONT_ID, 310, "Updating...", true, BOLD); + renderer.drawRect(20, 350, pageWidth - 40, 50); + renderer.fillRect(24, 354, static_cast(updaterProgress * static_cast(pageWidth - 44)), 42); + renderer.drawCenteredText(UI_FONT_ID, 420, (std::to_string(static_cast(updaterProgress * 100)) + "%").c_str()); + renderer.drawCenteredText( + UI_FONT_ID, 440, (std::to_string(updater.processedSize) + " / " + std::to_string(updater.totalSize)).c_str()); + renderer.displayBuffer(); + return; + } + + if (state == NO_UPDATE) { + renderer.drawCenteredText(UI_FONT_ID, 300, "No update available", true, BOLD); + renderer.displayBuffer(); + return; + } + + if (state == FAILED) { + renderer.drawCenteredText(UI_FONT_ID, 300, "Update failed", true, BOLD); + renderer.displayBuffer(); + return; + } + + if (state == FINISHED) { + renderer.drawCenteredText(UI_FONT_ID, 300, "Update complete", true, BOLD); + renderer.drawCenteredText(UI_FONT_ID, 350, "Press and hold power button to turn back on"); + renderer.displayBuffer(); + state = SHUTTING_DOWN; + return; + } +} + +void OtaUpdateActivity::loop() { + if (subActivity) { + subActivity->loop(); + return; + } + + if (state == WAITING_CONFIRMATION) { + if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) { + Serial.printf("[%lu] [OTA] New update available, starting download...\n", millis()); + xSemaphoreTake(renderingMutex, portMAX_DELAY); + state = UPDATE_IN_PROGRESS; + xSemaphoreGive(renderingMutex); + updateRequired = true; + vTaskDelay(10 / portTICK_PERIOD_MS); + const auto res = updater.installUpdate([this](const size_t, const size_t) { updateRequired = true; }); + + if (res != OtaUpdater::OK) { + Serial.printf("[%lu] [OTA] Update failed: %d\n", millis(), res); + xSemaphoreTake(renderingMutex, portMAX_DELAY); + state = FAILED; + xSemaphoreGive(renderingMutex); + updateRequired = true; + return; + } + + xSemaphoreTake(renderingMutex, portMAX_DELAY); + state = FINISHED; + xSemaphoreGive(renderingMutex); + updateRequired = true; + } + + if (inputManager.wasPressed(InputManager::BTN_BACK)) { + goBack(); + } + + return; + } + + if (state == FAILED) { + if (inputManager.wasPressed(InputManager::BTN_BACK)) { + goBack(); + } + return; + } + + if (state == NO_UPDATE) { + if (inputManager.wasPressed(InputManager::BTN_BACK)) { + goBack(); + } + return; + } + + if (state == SHUTTING_DOWN) { + ESP.restart(); + } +} diff --git a/src/activities/settings/OtaUpdateActivity.h b/src/activities/settings/OtaUpdateActivity.h new file mode 100644 index 00000000..20be6faa --- /dev/null +++ b/src/activities/settings/OtaUpdateActivity.h @@ -0,0 +1,43 @@ +#pragma once +#include +#include +#include + +#include "activities/ActivityWithSubactivity.h" +#include "network/OtaUpdater.h" + +class OtaUpdateActivity : public ActivityWithSubactivity { + enum State { + WIFI_SELECTION, + CHECKING_FOR_UPDATE, + WAITING_CONFIRMATION, + UPDATE_IN_PROGRESS, + NO_UPDATE, + FAILED, + FINISHED, + SHUTTING_DOWN + }; + + // Can't initialize this to 0 or the first render doesn't happen + static constexpr unsigned int UNINITIALIZED_PERCENTAGE = 111; + + TaskHandle_t displayTaskHandle = nullptr; + SemaphoreHandle_t renderingMutex = nullptr; + bool updateRequired = false; + const std::function goBack; + State state = WIFI_SELECTION; + unsigned int lastUpdaterPercentage = UNINITIALIZED_PERCENTAGE; + OtaUpdater updater; + + void onWifiSelectionComplete(bool success); + static void taskTrampoline(void* param); + [[noreturn]] void displayTaskLoop(); + void render(); + + public: + explicit OtaUpdateActivity(GfxRenderer& renderer, InputManager& inputManager, const std::function& goBack) + : ActivityWithSubactivity("OtaUpdate", renderer, inputManager), goBack(goBack), updater() {} + void onEnter() override; + void onExit() override; + void loop() override; +}; diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index a6c77140..37f2e5a1 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -4,16 +4,19 @@ #include #include "CrossPointSettings.h" +#include "OtaUpdateActivity.h" #include "config.h" // Define the static settings list namespace { -constexpr int settingsCount = 3; +constexpr int settingsCount = 4; const SettingInfo settingsList[settingsCount] = { // Should match with SLEEP_SCREEN_MODE {"Sleep Screen", SettingType::ENUM, &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover"}}, {"Extra Paragraph Spacing", SettingType::TOGGLE, &CrossPointSettings::extraParagraphSpacing, {}}, - {"Short Power Button Click", SettingType::TOGGLE, &CrossPointSettings::shortPwrBtn, {}}}; + {"Short Power Button Click", SettingType::TOGGLE, &CrossPointSettings::shortPwrBtn, {}}, + {"Check for updates", SettingType::ACTION, nullptr, {}}, +}; } // namespace void SettingsActivity::taskTrampoline(void* param) { @@ -41,7 +44,7 @@ void SettingsActivity::onEnter() { } void SettingsActivity::onExit() { - Activity::onExit(); + ActivityWithSubactivity::onExit(); // Wait until not rendering to delete task to avoid killing mid-instruction to EPD xSemaphoreTake(renderingMutex, portMAX_DELAY); @@ -54,6 +57,11 @@ void SettingsActivity::onExit() { } void SettingsActivity::loop() { + if (subActivity) { + subActivity->loop(); + return; + } + // Handle actions with early return if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) { toggleCurrentSetting(); @@ -81,7 +89,7 @@ void SettingsActivity::loop() { } } -void SettingsActivity::toggleCurrentSetting() const { +void SettingsActivity::toggleCurrentSetting() { // Validate index if (selectedSettingIndex < 0 || selectedSettingIndex >= settingsCount) { return; @@ -96,6 +104,16 @@ void SettingsActivity::toggleCurrentSetting() const { } else if (setting.type == SettingType::ENUM && setting.valuePtr != nullptr) { const uint8_t currentValue = SETTINGS.*(setting.valuePtr); SETTINGS.*(setting.valuePtr) = (currentValue + 1) % static_cast(setting.enumValues.size()); + } else if (setting.type == SettingType::ACTION) { + if (std::string(setting.name) == "Check for updates") { + xSemaphoreTake(renderingMutex, portMAX_DELAY); + exitActivity(); + enterNewActivity(new OtaUpdateActivity(renderer, inputManager, [this] { + exitActivity(); + updateRequired = true; + })); + xSemaphoreGive(renderingMutex); + } } else { // Only toggle if it's a toggle type and has a value pointer return; @@ -107,7 +125,7 @@ void SettingsActivity::toggleCurrentSetting() const { void SettingsActivity::displayTaskLoop() { while (true) { - if (updateRequired) { + if (updateRequired && !subActivity) { updateRequired = false; xSemaphoreTake(renderingMutex, portMAX_DELAY); render(); @@ -152,6 +170,8 @@ void SettingsActivity::render() const { // Draw help text renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 30, "Press OK to toggle, BACK to save & exit"); + renderer.drawText(SMALL_FONT_ID, pageWidth - 20 - renderer.getTextWidth(SMALL_FONT_ID, CROSSPOINT_VERSION), + pageHeight - 30, CROSSPOINT_VERSION); // Always use standard refresh for settings screen renderer.displayBuffer(); diff --git a/src/activities/settings/SettingsActivity.h b/src/activities/settings/SettingsActivity.h index 6fe5db1f..d88dc85f 100644 --- a/src/activities/settings/SettingsActivity.h +++ b/src/activities/settings/SettingsActivity.h @@ -3,16 +3,15 @@ #include #include -#include #include #include #include -#include "../Activity.h" +#include "activities/ActivityWithSubactivity.h" class CrossPointSettings; -enum class SettingType { TOGGLE, ENUM }; +enum class SettingType { TOGGLE, ENUM, ACTION }; // Structure to hold setting information struct SettingInfo { @@ -22,7 +21,7 @@ struct SettingInfo { std::vector enumValues; }; -class SettingsActivity final : public Activity { +class SettingsActivity final : public ActivityWithSubactivity { TaskHandle_t displayTaskHandle = nullptr; SemaphoreHandle_t renderingMutex = nullptr; bool updateRequired = false; @@ -32,11 +31,11 @@ class SettingsActivity final : public Activity { static void taskTrampoline(void* param); [[noreturn]] void displayTaskLoop(); void render() const; - void toggleCurrentSetting() const; + void toggleCurrentSetting(); public: explicit SettingsActivity(GfxRenderer& renderer, InputManager& inputManager, const std::function& onGoHome) - : Activity("Settings", renderer, inputManager), onGoHome(onGoHome) {} + : ActivityWithSubactivity("Settings", renderer, inputManager), onGoHome(onGoHome) {} void onEnter() override; void onExit() override; void loop() override; diff --git a/src/network/OtaUpdater.cpp b/src/network/OtaUpdater.cpp new file mode 100644 index 00000000..249c4570 --- /dev/null +++ b/src/network/OtaUpdater.cpp @@ -0,0 +1,169 @@ +#include "OtaUpdater.h" + +#include +#include +#include +#include + +namespace { +constexpr char latestReleaseUrl[] = "https://api.github.com/repos/daveallie/crosspoint-reader/releases/latest"; +} + +OtaUpdater::OtaUpdaterError OtaUpdater::checkForUpdate() { + const std::unique_ptr client(new WiFiClientSecure); + client->setInsecure(); + HTTPClient http; + + Serial.printf("[%lu] [OTA] Fetching: %s\n", millis(), latestReleaseUrl); + + http.begin(*client, latestReleaseUrl); + http.addHeader("User-Agent", "CrossPoint-ESP32-" CROSSPOINT_VERSION); + + const int httpCode = http.GET(); + if (httpCode != HTTP_CODE_OK) { + Serial.printf("[%lu] [OTA] HTTP error: %d\n", millis(), httpCode); + http.end(); + return HTTP_ERROR; + } + + JsonDocument doc; + const DeserializationError error = deserializeJson(doc, *client); + http.end(); + if (error) { + Serial.printf("[%lu] [OTA] JSON parse failed: %s\n", millis(), error.c_str()); + return JSON_PARSE_ERROR; + } + + if (!doc["tag_name"].is()) { + Serial.printf("[%lu] [OTA] No tag_name found\n", millis()); + return JSON_PARSE_ERROR; + } + if (!doc["assets"].is()) { + Serial.printf("[%lu] [OTA] No assets found\n", millis()); + return JSON_PARSE_ERROR; + } + + latestVersion = doc["tag_name"].as(); + + for (int i = 0; i < doc["assets"].size(); i++) { + if (doc["assets"][i]["name"] == "firmware.bin") { + otaUrl = doc["assets"][i]["browser_download_url"].as(); + otaSize = doc["assets"][i]["size"].as(); + totalSize = otaSize; + updateAvailable = true; + break; + } + } + + if (!updateAvailable) { + Serial.printf("[%lu] [OTA] No firmware.bin asset found\n", millis()); + return NO_UPDATE; + } + + Serial.printf("[%lu] [OTA] Found update: %s\n", millis(), latestVersion.c_str()); + return OK; +} + +bool OtaUpdater::isUpdateNewer() { + if (!updateAvailable || latestVersion.empty() || latestVersion == CROSSPOINT_VERSION) { + return false; + } + + // semantic version check (only match on 3 segments) + const auto updateMajor = stoi(latestVersion.substr(0, latestVersion.find('.'))); + const auto updateMinor = stoi( + latestVersion.substr(latestVersion.find('.') + 1, latestVersion.find_last_of('.') - latestVersion.find('.') - 1)); + const auto updatePatch = stoi(latestVersion.substr(latestVersion.find_last_of('.') + 1)); + + std::string currentVersion = CROSSPOINT_VERSION; + const auto currentMajor = stoi(currentVersion.substr(0, currentVersion.find('.'))); + const auto currentMinor = stoi(currentVersion.substr( + currentVersion.find('.') + 1, currentVersion.find_last_of('.') - currentVersion.find('.') - 1)); + const auto currentPatch = stoi(currentVersion.substr(currentVersion.find_last_of('.') + 1)); + + if (updateMajor > currentMajor) { + return true; + } + if (updateMajor < currentMajor) { + return false; + } + + if (updateMinor > currentMinor) { + return true; + } + if (updateMinor < currentMinor) { + return false; + } + + if (updatePatch > currentPatch) { + return true; + } + return false; +} + +const std::string& OtaUpdater::getLatestVersion() { return latestVersion; } + +OtaUpdater::OtaUpdaterError OtaUpdater::installUpdate(const std::function& onProgress) { + if (!isUpdateNewer()) { + return UPDATE_OLDER_ERROR; + } + + const std::unique_ptr client(new WiFiClientSecure); + client->setInsecure(); + HTTPClient http; + + Serial.printf("[%lu] [OTA] Fetching: %s\n", millis(), otaUrl.c_str()); + + http.begin(*client, otaUrl.c_str()); + http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); + http.addHeader("User-Agent", "CrossPoint-ESP32-" CROSSPOINT_VERSION); + const int httpCode = http.GET(); + + if (httpCode != HTTP_CODE_OK) { + Serial.printf("[%lu] [OTA] Download failed: %d\n", millis(), httpCode); + http.end(); + return HTTP_ERROR; + } + + // 2. Get length and stream + const size_t contentLength = http.getSize(); + + if (contentLength != otaSize) { + Serial.printf("[%lu] [OTA] Invalid content length\n", millis()); + http.end(); + return HTTP_ERROR; + } + + // 3. Begin the ESP-IDF Update process + if (!Update.begin(otaSize)) { + Serial.printf("[%lu] [OTA] Not enough space. Error: %s\n", millis(), Update.errorString()); + http.end(); + return INTERNAL_UPDATE_ERROR; + } + + this->totalSize = otaSize; + Serial.printf("[%lu] [OTA] Update started\n", millis()); + Update.onProgress([this, onProgress](const size_t progress, const size_t total) { + this->processedSize = progress; + this->totalSize = total; + onProgress(progress, total); + }); + const size_t written = Update.writeStream(*client); + http.end(); + + if (written == otaSize) { + Serial.printf("[%lu] [OTA] Successfully written %u bytes\n", millis(), written); + } else { + Serial.printf("[%lu] [OTA] Written only %u/%u bytes. Error: %s\n", millis(), written, otaSize, + Update.errorString()); + return INTERNAL_UPDATE_ERROR; + } + + if (Update.end() && Update.isFinished()) { + Serial.printf("[%lu] [OTA] Update complete\n", millis()); + return OK; + } else { + Serial.printf("[%lu] [OTA] Error Occurred: %s\n", millis(), Update.errorString()); + return INTERNAL_UPDATE_ERROR; + } +} diff --git a/src/network/OtaUpdater.h b/src/network/OtaUpdater.h new file mode 100644 index 00000000..dfaee88a --- /dev/null +++ b/src/network/OtaUpdater.h @@ -0,0 +1,30 @@ +#pragma once + +#include +#include + +class OtaUpdater { + bool updateAvailable = false; + std::string latestVersion; + std::string otaUrl; + size_t otaSize = 0; + + public: + enum OtaUpdaterError { + OK = 0, + NO_UPDATE, + HTTP_ERROR, + JSON_PARSE_ERROR, + UPDATE_OLDER_ERROR, + INTERNAL_UPDATE_ERROR, + OOM_ERROR, + }; + size_t processedSize = 0; + size_t totalSize = 0; + + OtaUpdater() = default; + bool isUpdateNewer(); + const std::string& getLatestVersion(); + OtaUpdaterError checkForUpdate(); + OtaUpdaterError installUpdate(const std::function& onProgress); +};