From d4c475470bc511553c619f7736583962f09f7066 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Tue, 21 Apr 2026 14:26:21 +0200 Subject: [PATCH 1/7] Allow cancellation of download, dont block the device --- src/activities/settings/OtaUpdateActivity.cpp | 55 +++++-- src/network/OtaUpdater.cpp | 136 +++++++++++------- src/network/OtaUpdater.h | 13 ++ 3 files changed, 143 insertions(+), 61 deletions(-) diff --git a/src/activities/settings/OtaUpdateActivity.cpp b/src/activities/settings/OtaUpdateActivity.cpp index c20a7f88..716f8c0f 100644 --- a/src/activities/settings/OtaUpdateActivity.cpp +++ b/src/activities/settings/OtaUpdateActivity.cpp @@ -140,20 +140,15 @@ void OtaUpdateActivity::loop() { // TODO @ngxson : refactor this logic later if (updater.getRender()) { requestUpdate(); + updater.clearRender(); } if (state == WAITING_CONFIRMATION) { if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { LOG_DBG("OTA", "New update available, starting download..."); - { - RenderLock lock(*this); - state = UPDATE_IN_PROGRESS; - } - requestUpdateAndWait(); - const auto res = updater.installUpdate(); - - if (res != OtaUpdater::OK) { - LOG_DBG("OTA", "Update failed: %d", res); + const auto beginResult = updater.beginInstallUpdate(); + if (beginResult != OtaUpdater::UPDATE_IN_PROGRESS) { + LOG_DBG("OTA", "Update begin failed: %d", beginResult); { RenderLock lock(*this); state = FAILED; @@ -164,9 +159,10 @@ void OtaUpdateActivity::loop() { { RenderLock lock(*this); - state = FINISHED; + state = UPDATE_IN_PROGRESS; } requestUpdate(); + return; } if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { @@ -176,6 +172,45 @@ void OtaUpdateActivity::loop() { return; } + if (state == UPDATE_IN_PROGRESS) { + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + updater.cancelUpdate(); + finish(); + return; + } + + const auto res = updater.performInstallUpdateStep(); + if (res == OtaUpdater::UPDATE_IN_PROGRESS) { + if (updater.getRender()) { + requestUpdate(); + updater.clearRender(); + } + return; + } + + if (res == OtaUpdater::OK) { + { + RenderLock lock(*this); + state = FINISHED; + } + requestUpdate(); + return; + } + + if (res == OtaUpdater::UPDATE_CANCELLED) { + finish(); + return; + } + + LOG_DBG("OTA", "Update failed: %d", res); + { + RenderLock lock(*this); + state = FAILED; + } + requestUpdate(); + return; + } + if (state == FAILED) { if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { finish(); diff --git a/src/network/OtaUpdater.cpp b/src/network/OtaUpdater.cpp index 871b49f9..5f53cbec 100644 --- a/src/network/OtaUpdater.cpp +++ b/src/network/OtaUpdater.cpp @@ -28,34 +28,25 @@ esp_err_t http_client_set_header_cb(esp_http_client_handle_t http_client) { } esp_err_t event_handler(esp_http_client_event_t* event) { - /* We do interested in only HTTP_EVENT_ON_DATA event only */ + /* We are only interested in HTTP_EVENT_ON_DATA event */ if (event->event_id != HTTP_EVENT_ON_DATA) return ESP_OK; - if (!esp_http_client_is_chunked_response(event->client)) { - int content_len = esp_http_client_get_content_length(event->client); - int copy_len = 0; - - if (local_buf == NULL) { - /* local_buf life span is tracked by caller checkForUpdate */ - local_buf = static_cast(calloc(content_len + 1, sizeof(char))); - output_len = 0; - if (local_buf == NULL) { - LOG_ERR("OTA", "HTTP Client Out of Memory Failed, Allocation %d", content_len); - return ESP_ERR_NO_MEM; - } - } - copy_len = min(event->data_len, (content_len - output_len)); - if (copy_len) { - memcpy(local_buf + output_len, event->data, copy_len); - } - output_len += copy_len; - } else { - /* Code might be hits here, It happened once (for version checking) but I need more logs to handle that */ - int chunked_len; - esp_http_client_get_chunk_length(event->client, &chunked_len); - LOG_DBG("OTA", "esp_http_client_is_chunked_response failed, chunked_len: %d", chunked_len); + if (event->data == nullptr || event->data_len == 0) { + return ESP_OK; } + const int newSize = output_len + event->data_len + 1; + char* newBuf = static_cast(realloc(local_buf, static_cast(newSize))); + if (newBuf == nullptr) { + LOG_ERR("OTA", "HTTP Client Out of Memory Failed, Allocation %d", newSize); + return ESP_ERR_NO_MEM; + } + + local_buf = newBuf; + memcpy(local_buf + output_len, event->data, event->data_len); + output_len += event->data_len; + local_buf[output_len] = '\0'; + return ESP_OK; } /* event_handler */ } /* namespace */ @@ -68,6 +59,7 @@ OtaUpdater::OtaUpdaterError OtaUpdater::checkForUpdate() { esp_http_client_config_t client_config = { .url = latestReleaseUrl, .event_handler = event_handler, + .timeout_ms = 10000, /* Default HTTP client buffer size 512 byte only */ .buffer_size = 8192, .buffer_size_tx = 8192, @@ -199,28 +191,38 @@ bool OtaUpdater::isUpdateNewer() const { const std::string& OtaUpdater::getLatestVersion() const { return latestVersion; } -OtaUpdater::OtaUpdaterError OtaUpdater::installUpdate() { +void OtaUpdater::cleanupUpdate() { + if (otaHandle) { + esp_https_ota_finish(otaHandle); + otaHandle = nullptr; + } + cancelRequested = false; + esp_wifi_set_ps(WIFI_PS_MIN_MODEM); +} + +void OtaUpdater::cancelUpdate() { + if (otaHandle) { + cleanupUpdate(); + } else { + cancelRequested = true; + } +} + +OtaUpdater::OtaUpdaterError OtaUpdater::beginInstallUpdate() { if (!isUpdateNewer()) { return UPDATE_OLDER_ERROR; } - esp_https_ota_handle_t ota_handle = NULL; - esp_err_t esp_err; - /* Signal for OtaUpdateActivity */ + cleanupUpdate(); render = false; + cancelRequested = false; esp_http_client_config_t client_config = { .url = otaUrl.c_str(), - .timeout_ms = 30000, - /* Default HTTP client buffer size 512 byte only - * not sufficient to handle URL redirection cases or - * parsing of large HTTP headers. - */ + .timeout_ms = 10000, .max_redirection_count = 5, .buffer_size = 8192, .buffer_size_tx = 8192, - /* GitHub release assets redirect to objects.githubusercontent.com CDN. - * Without max_redirection_count, esp_https_ota downloads 0 bytes and stalls. */ .crt_bundle_attach = esp_crt_bundle_attach, .keep_alive_enable = true, }; @@ -233,41 +235,73 @@ OtaUpdater::OtaUpdaterError OtaUpdater::installUpdate() { /* For better timing and connectivity, we disable power saving for WiFi */ esp_wifi_set_ps(WIFI_PS_NONE); - esp_err = esp_https_ota_begin(&ota_config, &ota_handle); + esp_err_t esp_err = esp_https_ota_begin(&ota_config, &otaHandle); if (esp_err != ESP_OK) { LOG_DBG("OTA", "HTTP OTA Begin Failed: %s", esp_err_to_name(esp_err)); + cleanupUpdate(); return INTERNAL_UPDATE_ERROR; } - do { - esp_err = esp_https_ota_perform(ota_handle); - processedSize = esp_https_ota_get_image_len_read(ota_handle); - /* Sent signal to OtaUpdateActivity */ - render = true; - delay(100); // TODO: should we replace this with something better? - } while (esp_err == ESP_ERR_HTTPS_OTA_IN_PROGRESS); + return UPDATE_IN_PROGRESS; +} + +OtaUpdater::OtaUpdaterError OtaUpdater::performInstallUpdateStep() { + if (cancelRequested) { + cleanupUpdate(); + return UPDATE_CANCELLED; + } + + if (!otaHandle) { + return INTERNAL_UPDATE_ERROR; + } + + esp_err_t esp_err = esp_https_ota_perform(otaHandle); + processedSize = esp_https_ota_get_image_len_read(otaHandle); + render = true; + + if (esp_err == ESP_ERR_HTTPS_OTA_IN_PROGRESS) { + return UPDATE_IN_PROGRESS; + } - /* Return back to default power saving for WiFi in case of failing */ esp_wifi_set_ps(WIFI_PS_MIN_MODEM); if (esp_err != ESP_OK) { LOG_ERR("OTA", "esp_https_ota_perform Failed: %s", esp_err_to_name(esp_err)); - esp_https_ota_finish(ota_handle); + cleanupUpdate(); return HTTP_ERROR; } - if (!esp_https_ota_is_complete_data_received(ota_handle)) { - LOG_ERR("OTA", "esp_https_ota_is_complete_data_received Failed: %s", esp_err_to_name(esp_err)); - esp_https_ota_finish(ota_handle); + if (!esp_https_ota_is_complete_data_received(otaHandle)) { + LOG_ERR("OTA", "esp_https_ota_is_complete_data_received Failed"); + cleanupUpdate(); return INTERNAL_UPDATE_ERROR; } - esp_err = esp_https_ota_finish(ota_handle); - if (esp_err != ESP_OK) { - LOG_ERR("OTA", "esp_https_ota_finish Failed: %s", esp_err_to_name(esp_err)); + esp_err_t finish_err = esp_https_ota_finish(otaHandle); + otaHandle = nullptr; + if (finish_err != ESP_OK) { + LOG_ERR("OTA", "esp_https_ota_finish Failed: %s", esp_err_to_name(finish_err)); + cleanupUpdate(); return INTERNAL_UPDATE_ERROR; } LOG_INF("OTA", "Update completed"); return OK; } + +OtaUpdater::OtaUpdaterError OtaUpdater::installUpdate() { + const auto beginResult = beginInstallUpdate(); + if (beginResult != UPDATE_IN_PROGRESS) { + return beginResult; + } + + OtaUpdaterError result; + do { + result = performInstallUpdateStep(); + if (result == UPDATE_IN_PROGRESS) { + delay(100); + } + } while (result == UPDATE_IN_PROGRESS); + + return result; +} diff --git a/src/network/OtaUpdater.h b/src/network/OtaUpdater.h index 24e04cf5..e627cf7e 100644 --- a/src/network/OtaUpdater.h +++ b/src/network/OtaUpdater.h @@ -3,6 +3,8 @@ #include #include +#include "esp_https_ota.h" + class OtaUpdater { bool updateAvailable = false; std::string latestVersion; @@ -11,6 +13,8 @@ class OtaUpdater { size_t processedSize = 0; size_t totalSize = 0; bool render = false; + esp_https_ota_handle_t otaHandle = nullptr; + bool cancelRequested = false; public: enum OtaUpdaterError { @@ -21,6 +25,8 @@ class OtaUpdater { UPDATE_OLDER_ERROR, INTERNAL_UPDATE_ERROR, OOM_ERROR, + UPDATE_CANCELLED, + UPDATE_IN_PROGRESS, }; size_t getOtaSize() const { return otaSize; } @@ -30,10 +36,17 @@ class OtaUpdater { size_t getTotalSize() const { return totalSize; } bool getRender() const { return render; } + void clearRender() { render = false; } + + bool isUpdateInProgress() const { return otaHandle != nullptr; } OtaUpdater() = default; bool isUpdateNewer() const; const std::string& getLatestVersion() const; OtaUpdaterError checkForUpdate(); + OtaUpdaterError beginInstallUpdate(); + OtaUpdaterError performInstallUpdateStep(); + void cancelUpdate(); + void cleanupUpdate(); OtaUpdaterError installUpdate(); }; From b7928a86e31275092833410bf451fc5b74a67b73 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Tue, 21 Apr 2026 17:15:56 +0200 Subject: [PATCH 2/7] Add hotel Wifi connection --- lib/I18n/translations/english.yaml | 4 + .../network/WifiSelectionActivity.cpp | 139 ++++++++++++++++++ .../network/WifiSelectionActivity.h | 7 +- 3 files changed, 149 insertions(+), 1 deletion(-) diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index b7836f90..8b21277c 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -534,3 +534,7 @@ STR_STAR_PAGE: "Star Page" STR_NO_STARRED_PAGES: "No starred pages" STR_RENAME: "Rename" STR_PAGE_PREFIX: "p" +STR_CAPTIVE_PORTAL_DETECTED: "Login Required" +STR_CAPTIVE_PORTAL_HINT_1: "Network requires browser login. On another device," +STR_CAPTIVE_PORTAL_HINT_2: "visit the URL below to authorize, then press OK." +STR_CAPTIVE_PORTAL_DONE: "I'm authorized" diff --git a/src/activities/network/WifiSelectionActivity.cpp b/src/activities/network/WifiSelectionActivity.cpp index 66eed43f..5235ea4c 100644 --- a/src/activities/network/WifiSelectionActivity.cpp +++ b/src/activities/network/WifiSelectionActivity.cpp @@ -1,8 +1,10 @@ #include "WifiSelectionActivity.h" #include +#include #include #include +#include #include #include @@ -13,6 +15,7 @@ #include "activities/util/KeyboardEntryActivity.h" #include "components/UITheme.h" #include "fontIds.h" +#include "util/QrUtils.h" namespace { @@ -263,6 +266,30 @@ void WifiSelectionActivity::attemptConnection() { } } +bool WifiSelectionActivity::checkCaptivePortal() { + // Probe a known HTTP endpoint that returns 204 on open internet. + // Captive portals intercept this and return a redirect (3xx) or 200 with a login page. + NetworkClient client; + HTTPClient http; + http.setFollowRedirects(HTTPC_DISABLE_FOLLOW_REDIRECTS); + http.setTimeout(5000); + if (!http.begin(client, "http://connectivitycheck.gstatic.com/generate_204")) { + return false; + } + const int code = http.GET(); + String location = http.getLocation(); + http.end(); + + if (code == 204) { + return false; // Open internet, no captive portal + } + + // Any redirect or unexpected 200 means a captive portal is intercepting. + captivePortalUrl = location.length() > 0 ? location.c_str() : "http://connectivitycheck.gstatic.com/generate_204"; + LOG_DBG("WIFI", "Captive portal detected (HTTP %d), URL: %s", code, captivePortalUrl.c_str()); + return true; +} + void WifiSelectionActivity::checkConnectionStatus() { if (state != WifiSelectionState::CONNECTING && state != WifiSelectionState::AUTO_CONNECTING) { return; @@ -285,6 +312,13 @@ void WifiSelectionActivity::checkConnectionStatus() { WIFI_STORE.setLastConnectedSsid(selectedSSID); } + // Check for captive portal before declaring success + if (checkCaptivePortal()) { + state = WifiSelectionState::CAPTIVE_PORTAL; + requestUpdate(); + return; + } + // If we entered a new password, ask if user wants to save it // Otherwise, immediately complete so parent can start web server if (!usedSavedPassword && !enteredPassword.empty()) { @@ -404,6 +438,24 @@ void WifiSelectionActivity::loop() { return; } + // Handle captive portal state - user must authorize on another device + if (state == WifiSelectionState::CAPTIVE_PORTAL) { + if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { + // User says they've completed browser auth - proceed as connected + if (!usedSavedPassword && !enteredPassword.empty()) { + state = WifiSelectionState::SAVE_PROMPT; + savePromptSelection = 0; + requestUpdate(); + } else { + onComplete(true); + } + } else if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + WiFi.disconnect(); + startWifiScan(); + } + return; + } + // Handle connected state (should not normally be reached - connection // completes immediately) if (state == WifiSelectionState::CONNECTED) { @@ -539,6 +591,9 @@ void WifiSelectionActivity::render(RenderLock&&) { case WifiSelectionState::FORGET_PROMPT: renderForgetPrompt(); break; + case WifiSelectionState::CAPTIVE_PORTAL: + renderCaptivePortal(); + break; } renderer.displayBuffer(); @@ -719,6 +774,90 @@ void WifiSelectionActivity::renderForgetPrompt() const { GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); } +void WifiSelectionActivity::renderCaptivePortal() const { + const auto& metrics = UITheme::getInstance().getMetrics(); + const Rect contentRect = UITheme::getContentRect(renderer, true, false); + + const int pageWidth = renderer.getScreenWidth(); + const int maxWidth = pageWidth - metrics.contentSidePadding * 2; + const int lh12 = renderer.getLineHeight(UI_12_FONT_ID); + const int lh10 = renderer.getLineHeight(UI_10_FONT_ID); + const int lhSmall = renderer.getLineHeight(SMALL_FONT_ID); + const int sp = metrics.verticalSpacing; + constexpr int QR_SIZE = 320; + + // Pre-compute URL line count so we can vertically centre everything + const char* url = captivePortalUrl.c_str(); + int urlLineCount = 0; + { + int rem = static_cast(captivePortalUrl.size()); + int off = 0; + while (rem > 0) { + int lo = 1, hi = rem; + while (lo < hi) { + const int mid = (lo + hi + 1) / 2; + char tmp[512]; + snprintf(tmp, sizeof(tmp), "%.*s", mid, url + off); + if (renderer.getTextWidth(SMALL_FONT_ID, tmp) <= maxWidth) + lo = mid; + else + hi = mid - 1; + } + urlLineCount++; + off += lo; + rem -= lo; + } + } + + const int totalHeight = lh12 + sp // title + + lh10 // hint line 1 + + lh10 + sp // hint line 2 + + QR_SIZE + sp // QR code + + urlLineCount * lhSmall; + + // contentRect covers the full screen minus button hints; subtract the header + // and sub-header that render() always draws above us. + const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight; + const int contentBottom = contentRect.y + contentRect.height; + int y = contentTop + (contentBottom - contentTop - totalHeight) / 2; + + renderer.drawCenteredText(UI_12_FONT_ID, y, tr(STR_CAPTIVE_PORTAL_DETECTED), true, EpdFontFamily::BOLD); + y += lh12 + sp; + renderer.drawCenteredText(UI_10_FONT_ID, y, tr(STR_CAPTIVE_PORTAL_HINT_1)); + y += lh10; + renderer.drawCenteredText(UI_10_FONT_ID, y, tr(STR_CAPTIVE_PORTAL_HINT_2)); + y += lh10 + sp; + + const int qrX = contentRect.x + (contentRect.width - QR_SIZE) / 2; + QrUtils::drawQrCode(renderer, Rect{qrX, y, QR_SIZE, QR_SIZE}, captivePortalUrl); + y += QR_SIZE + sp; + + // Split URL into as many lines as needed + int remaining = static_cast(captivePortalUrl.size()); + int offset = 0; + while (remaining > 0) { + int lo = 1, hi = remaining; + while (lo < hi) { + const int mid = (lo + hi + 1) / 2; + char tmp[512]; + snprintf(tmp, sizeof(tmp), "%.*s", mid, url + offset); + if (renderer.getTextWidth(SMALL_FONT_ID, tmp) <= maxWidth) + lo = mid; + else + hi = mid - 1; + } + char line[512]; + snprintf(line, sizeof(line), "%.*s", lo, url + offset); + renderer.drawCenteredText(SMALL_FONT_ID, y, line); + y += lhSmall; + offset += lo; + remaining -= lo; + } + + const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_CAPTIVE_PORTAL_DONE), "", ""); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); +} + void WifiSelectionActivity::onComplete(const bool connected) { ActivityResult result; result.isCancelled = !connected; diff --git a/src/activities/network/WifiSelectionActivity.h b/src/activities/network/WifiSelectionActivity.h index 1ea7fc0a..42d37862 100644 --- a/src/activities/network/WifiSelectionActivity.h +++ b/src/activities/network/WifiSelectionActivity.h @@ -28,7 +28,8 @@ enum class WifiSelectionState { CONNECTED, // Successfully connected SAVE_PROMPT, // Asking user if they want to save the password CONNECTION_FAILED, // Connection failed - FORGET_PROMPT // Asking user if they want to forget the network + FORGET_PROMPT, // Asking user if they want to forget the network + CAPTIVE_PORTAL // Connected but network requires web-based login }; /** @@ -87,14 +88,18 @@ class WifiSelectionActivity final : public Activity { void renderSavePrompt() const; void renderConnectionFailed() const; void renderForgetPrompt() const; + void renderCaptivePortal() const; void startWifiScan(); void processWifiScanResults(); void selectNetwork(int index); void attemptConnection(); void checkConnectionStatus(); + bool checkCaptivePortal(); std::string getSignalStrengthIndicator(int32_t rssi) const; + std::string captivePortalUrl; + void onComplete(bool connected); public: From df171560db2e8602fd358958f5ff3326be9745b5 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Tue, 21 Apr 2026 17:16:19 +0200 Subject: [PATCH 3/7] Fix firmware download --- src/activities/settings/OtaUpdateActivity.cpp | 32 +++++++++++++++++++ src/activities/settings/OtaUpdateActivity.h | 1 + src/network/OtaUpdater.cpp | 9 +++++- src/network/OtaUpdater.h | 5 ++- 4 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/activities/settings/OtaUpdateActivity.cpp b/src/activities/settings/OtaUpdateActivity.cpp index 716f8c0f..943abd6e 100644 --- a/src/activities/settings/OtaUpdateActivity.cpp +++ b/src/activities/settings/OtaUpdateActivity.cpp @@ -31,6 +31,7 @@ void OtaUpdateActivity::onWifiSelectionComplete(const bool success) { LOG_DBG("OTA", "Update check failed: %d", res); { RenderLock lock(*this); + failureReason = res; state = FAILED; } return; @@ -126,6 +127,35 @@ void OtaUpdateActivity::render(RenderLock&&) { GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); } else if (state == FAILED) { renderer.drawCenteredText(UI_10_FONT_ID, top, tr(STR_UPDATE_FAILED), true, EpdFontFamily::BOLD); + const char* reason = ""; + switch (failureReason) { + case OtaUpdater::HTTP_ERROR: + reason = "Network error (HTTP request failed)"; + break; + case OtaUpdater::JSON_PARSE_ERROR: + reason = "Could not parse release info"; + break; + case OtaUpdater::UPDATE_OLDER_ERROR: + reason = "Available version is not newer"; + break; + case OtaUpdater::OOM_ERROR: + reason = "Out of memory"; + break; + case OtaUpdater::INTERNAL_UPDATE_ERROR: + reason = "Internal update error"; + break; + case OtaUpdater::NO_UPDATE: + reason = "No firmware asset found"; + break; + case OtaUpdater::VALIDATE_FAILED: + reason = "Image validation failed - please flash via USB"; + break; + default: + break; + } + if (reason[0] != '\0') { + renderer.drawCenteredText(SMALL_FONT_ID, top + height + metrics.verticalSpacing, reason); + } const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); } else if (state == FINISHED) { @@ -151,6 +181,7 @@ void OtaUpdateActivity::loop() { LOG_DBG("OTA", "Update begin failed: %d", beginResult); { RenderLock lock(*this); + failureReason = beginResult; state = FAILED; } requestUpdate(); @@ -205,6 +236,7 @@ void OtaUpdateActivity::loop() { LOG_DBG("OTA", "Update failed: %d", res); { RenderLock lock(*this); + failureReason = res; state = FAILED; } requestUpdate(); diff --git a/src/activities/settings/OtaUpdateActivity.h b/src/activities/settings/OtaUpdateActivity.h index 12afc80d..afd26d2b 100644 --- a/src/activities/settings/OtaUpdateActivity.h +++ b/src/activities/settings/OtaUpdateActivity.h @@ -21,6 +21,7 @@ class OtaUpdateActivity : public Activity { State state = WIFI_SELECTION; unsigned int lastUpdaterPercentage = UNINITIALIZED_PERCENTAGE; OtaUpdater updater; + OtaUpdater::OtaUpdaterError failureReason = OtaUpdater::OK; void onWifiSelectionComplete(bool success); diff --git a/src/network/OtaUpdater.cpp b/src/network/OtaUpdater.cpp index 5f53cbec..cdd1f293 100644 --- a/src/network/OtaUpdater.cpp +++ b/src/network/OtaUpdater.cpp @@ -52,14 +52,18 @@ esp_err_t event_handler(esp_http_client_event_t* event) { } /* namespace */ OtaUpdater::OtaUpdaterError OtaUpdater::checkForUpdate() { + // Reset globals so retries start clean regardless of previous outcome + local_buf = nullptr; + output_len = 0; + JsonDocument filter; esp_err_t esp_err; JsonDocument doc; esp_http_client_config_t client_config = { .url = latestReleaseUrl, - .event_handler = event_handler, .timeout_ms = 10000, + .event_handler = event_handler, /* Default HTTP client buffer size 512 byte only */ .buffer_size = 8192, .buffer_size_tx = 8192, @@ -282,6 +286,9 @@ OtaUpdater::OtaUpdaterError OtaUpdater::performInstallUpdateStep() { if (finish_err != ESP_OK) { LOG_ERR("OTA", "esp_https_ota_finish Failed: %s", esp_err_to_name(finish_err)); cleanupUpdate(); + if (finish_err == ESP_ERR_OTA_VALIDATE_FAILED) { + return VALIDATE_FAILED; + } return INTERNAL_UPDATE_ERROR; } diff --git a/src/network/OtaUpdater.h b/src/network/OtaUpdater.h index e627cf7e..8a24d17b 100644 --- a/src/network/OtaUpdater.h +++ b/src/network/OtaUpdater.h @@ -3,7 +3,9 @@ #include #include -#include "esp_https_ota.h" +// Avoid pulling in esp_https_ota.h here — it transitively includes lwip/sockets.h +// which defines INADDR_NONE as a numeric macro, conflicting with Arduino's IPAddress.h. +typedef void* esp_https_ota_handle_t; class OtaUpdater { bool updateAvailable = false; @@ -27,6 +29,7 @@ class OtaUpdater { OOM_ERROR, UPDATE_CANCELLED, UPDATE_IN_PROGRESS, + VALIDATE_FAILED, }; size_t getOtaSize() const { return otaSize; } From f6f18dcf15a0452de6ae5eed4c005dbee1768671 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Tue, 21 Apr 2026 18:27:59 +0200 Subject: [PATCH 4/7] Write directly --- src/activities/settings/OtaUpdateActivity.cpp | 2 +- src/network/OtaUpdater.cpp | 78 ++++++++++++++++++- src/network/OtaUpdater.h | 3 + 3 files changed, 78 insertions(+), 5 deletions(-) diff --git a/src/activities/settings/OtaUpdateActivity.cpp b/src/activities/settings/OtaUpdateActivity.cpp index 943abd6e..dd47e472 100644 --- a/src/activities/settings/OtaUpdateActivity.cpp +++ b/src/activities/settings/OtaUpdateActivity.cpp @@ -148,7 +148,7 @@ void OtaUpdateActivity::render(RenderLock&&) { reason = "No firmware asset found"; break; case OtaUpdater::VALIDATE_FAILED: - reason = "Image validation failed - please flash via USB"; + reason = "Bootloader incompatible - reflash via USB with PlatformIO"; break; default: break; diff --git a/src/network/OtaUpdater.cpp b/src/network/OtaUpdater.cpp index cdd1f293..e08ecac4 100644 --- a/src/network/OtaUpdater.cpp +++ b/src/network/OtaUpdater.cpp @@ -3,8 +3,12 @@ #include #include +#include "bootloader_common.h" +#include "esp_flash_partitions.h" #include "esp_http_client.h" #include "esp_https_ota.h" +#include "esp_ota_ops.h" +#include "esp_partition.h" #include "esp_wifi.h" namespace { @@ -249,6 +253,64 @@ OtaUpdater::OtaUpdaterError OtaUpdater::beginInstallUpdate() { return UPDATE_IN_PROGRESS; } +/* Writes the otadata entry to boot from the most recently flashed OTA partition, + * bypassing esp_ota_set_boot_partition()'s image_validate() call. + * Used when esp_https_ota_finish() returns ESP_ERR_OTA_VALIDATE_FAILED on + * unsigned Arduino builds (boot_comm efuse revision check false-positive). */ +int OtaUpdater::forceSetOtaBootPartition() { + const esp_partition_t* newPartition = esp_ota_get_next_update_partition(nullptr); + if (newPartition == nullptr) { + return ESP_ERR_NOT_FOUND; + } + + const esp_partition_t* otaDataPartition = + esp_partition_find_first(ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_OTA, nullptr); + if (otaDataPartition == nullptr) { + return ESP_ERR_NOT_FOUND; + } + + esp_ota_select_entry_t otadata[2]; + esp_err_t err = esp_partition_read(otaDataPartition, 0, &otadata[0], sizeof(esp_ota_select_entry_t)); + if (err != ESP_OK) return err; + err = esp_partition_read(otaDataPartition, otaDataPartition->erase_size, &otadata[1], sizeof(esp_ota_select_entry_t)); + if (err != ESP_OK) return err; + + int activeSlot = bootloader_common_get_active_otadata(otadata); + int nextSlot = (activeSlot == -1) ? 0 : (~activeSlot & 1); + + uint8_t otaAppCount = 0; + while (esp_partition_find_first(ESP_PARTITION_TYPE_APP, + static_cast(ESP_PARTITION_SUBTYPE_APP_OTA_MIN + otaAppCount), + nullptr) != nullptr) { + otaAppCount++; + } + if (otaAppCount == 0) return ESP_ERR_NOT_FOUND; + + const uint8_t subTypeId = newPartition->subtype & 0x0F; + uint32_t newSeq; + if (activeSlot == -1) { + newSeq = subTypeId + 1; + } else { + uint32_t currentSeq = otadata[activeSlot].ota_seq; + newSeq = currentSeq; + while (newSeq % otaAppCount != static_cast(subTypeId)) { + newSeq++; + } + if (newSeq == currentSeq) newSeq += otaAppCount; + } + + otadata[nextSlot].ota_seq = newSeq; + otadata[nextSlot].ota_state = ESP_OTA_IMG_VALID; + otadata[nextSlot].crc = bootloader_common_ota_select_crc(&otadata[nextSlot]); + + err = esp_partition_erase_range(otaDataPartition, otaDataPartition->erase_size * static_cast(nextSlot), + otaDataPartition->erase_size); + if (err != ESP_OK) return err; + + return esp_partition_write(otaDataPartition, otaDataPartition->erase_size * static_cast(nextSlot), + &otadata[nextSlot], sizeof(esp_ota_select_entry_t)); +} + OtaUpdater::OtaUpdaterError OtaUpdater::performInstallUpdateStep() { if (cancelRequested) { cleanupUpdate(); @@ -283,12 +345,20 @@ OtaUpdater::OtaUpdaterError OtaUpdater::performInstallUpdateStep() { esp_err_t finish_err = esp_https_ota_finish(otaHandle); otaHandle = nullptr; - if (finish_err != ESP_OK) { - LOG_ERR("OTA", "esp_https_ota_finish Failed: %s", esp_err_to_name(finish_err)); - cleanupUpdate(); - if (finish_err == ESP_ERR_OTA_VALIDATE_FAILED) { + if (finish_err == ESP_ERR_OTA_VALIDATE_FAILED) { + /* Arduino unsigned builds fail boot_comm validation even though the image + * is fully written. Force the boot partition to the new OTA slot by writing + * the otadata entry directly, bypassing image_validate(). */ + LOG_INF("OTA", "Validation failed (expected for unsigned Arduino builds) - forcing boot partition"); + finish_err = forceSetOtaBootPartition(); + if (finish_err != ESP_OK) { + LOG_ERR("OTA", "forceSetOtaBootPartition failed: %s", esp_err_to_name(finish_err)); + cleanupUpdate(); return VALIDATE_FAILED; } + } else if (finish_err != ESP_OK) { + LOG_ERR("OTA", "esp_https_ota_finish Failed: %s", esp_err_to_name(finish_err)); + cleanupUpdate(); return INTERNAL_UPDATE_ERROR; } diff --git a/src/network/OtaUpdater.h b/src/network/OtaUpdater.h index 8a24d17b..26812fa3 100644 --- a/src/network/OtaUpdater.h +++ b/src/network/OtaUpdater.h @@ -52,4 +52,7 @@ class OtaUpdater { void cancelUpdate(); void cleanupUpdate(); OtaUpdaterError installUpdate(); + + private: + static int forceSetOtaBootPartition(); }; From 4efbc035bac1b68474d7a8e8be3f6d4ec8fbf7e6 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Tue, 21 Apr 2026 21:47:30 +0200 Subject: [PATCH 5/7] Try resetting the version info --- .github/workflows/release.yml | 22 ++++++++++++++++++++++ .github/workflows/release_candidate.yml | 14 ++++++++++++++ platformio.ini | 2 +- src/main.cpp | 2 ++ 4 files changed, 39 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aae332c1..360e9076 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,6 +28,28 @@ jobs: - name: Build CrossPoint run: pio run -e gh_release + - name: Patch min_chip_rev_full to 0 + run: | + python3 - <<'EOF' + import struct, sys + # esp_image_header_t (8 bytes) + extended header: + # offset 0: wp_pin (B) + # offset 1: clk/q drv (B) + # offset 2: d/cs drv (B) + # offset 3: hd/wp drv (B) + # offset 4: chip_id (H, 2 bytes) + # offset 6: min_rev (B) + # offset 7: min_rev_full (H, 2 bytes) <-- patch target + MIN_REV_FULL_OFFSET = 8 + 7 + path = ".pio/build/gh_release/firmware.bin" + with open(path, "r+b") as f: + f.seek(MIN_REV_FULL_OFFSET) + old = struct.unpack(" 0.0") + f.seek(MIN_REV_FULL_OFFSET) + f.write(struct.pack(" 0.0") + f.seek(MIN_REV_FULL_OFFSET) + f.write(struct.pack(" Date: Tue, 21 Apr 2026 22:04:07 +0200 Subject: [PATCH 6/7] Address cppcheck issue --- src/main.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main.cpp b/src/main.cpp index be50ab2e..456d2bb3 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include @@ -26,11 +27,11 @@ #include "activities/Activity.h" #include "activities/ActivityManager.h" #include "components/UITheme.h" -#include "esp_ota_ops.h" #include "fontIds.h" #include "util/ButtonNavigator.h" #include "util/ScreenshotUtil.h" + MappedInputManager mappedInputManager(gpio); GfxRenderer renderer(display); ActivityManager activityManager(renderer, mappedInputManager); From 7da8b335ab973ce99a8fdae1d41b3af234921688 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Tue, 21 Apr 2026 22:16:23 +0200 Subject: [PATCH 7/7] Review comments --- .github/workflows/release.yml | 21 +----- .github/workflows/release_candidate.yml | 13 +--- scripts/patch_min_chip_rev.py | 64 +++++++++++++++++++ .../network/WifiSelectionActivity.cpp | 5 ++ src/main.cpp | 9 ++- src/network/OtaUpdater.cpp | 22 ++----- src/network/OtaUpdater.h | 1 - 7 files changed, 82 insertions(+), 53 deletions(-) create mode 100644 scripts/patch_min_chip_rev.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 360e9076..bcd73969 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,26 +29,7 @@ jobs: run: pio run -e gh_release - name: Patch min_chip_rev_full to 0 - run: | - python3 - <<'EOF' - import struct, sys - # esp_image_header_t (8 bytes) + extended header: - # offset 0: wp_pin (B) - # offset 1: clk/q drv (B) - # offset 2: d/cs drv (B) - # offset 3: hd/wp drv (B) - # offset 4: chip_id (H, 2 bytes) - # offset 6: min_rev (B) - # offset 7: min_rev_full (H, 2 bytes) <-- patch target - MIN_REV_FULL_OFFSET = 8 + 7 - path = ".pio/build/gh_release/firmware.bin" - with open(path, "r+b") as f: - f.seek(MIN_REV_FULL_OFFSET) - old = struct.unpack(" 0.0") - f.seek(MIN_REV_FULL_OFFSET) - f.write(struct.pack(" 0.0") - f.seek(MIN_REV_FULL_OFFSET) - f.write(struct.pack(" None: + with open(path, "r+b") as f: + data = bytearray(f.read()) + + if data[0] != 0xE9: + print(f"ERROR: {path}: not a valid ESP image (magic=0x{data[0]:02x})", file=sys.stderr) + sys.exit(1) + + old = struct.unpack_from(" v0.0") + struct.pack_into("", file=sys.stderr) + sys.exit(1) + patch(sys.argv[1]) diff --git a/src/activities/network/WifiSelectionActivity.cpp b/src/activities/network/WifiSelectionActivity.cpp index 5235ea4c..1c45441b 100644 --- a/src/activities/network/WifiSelectionActivity.cpp +++ b/src/activities/network/WifiSelectionActivity.cpp @@ -280,6 +280,11 @@ bool WifiSelectionActivity::checkCaptivePortal() { String location = http.getLocation(); http.end(); + if (code < 0) { + LOG_DBG("WIFI", "Captive portal probe failed (connection error %d)", code); + return false; + } + if (code == 204) { return false; // Open internet, no captive portal } diff --git a/src/main.cpp b/src/main.cpp index 456d2bb3..f1914ee5 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -31,7 +31,6 @@ #include "util/ButtonNavigator.h" #include "util/ScreenshotUtil.h" - MappedInputManager mappedInputManager(gpio); GfxRenderer renderer(display); ActivityManager activityManager(renderer, mappedInputManager); @@ -182,7 +181,13 @@ void setupDisplayAndFonts() { } void setup() { - esp_ota_mark_app_valid_cancel_rollback(); + { + esp_ota_img_states_t otaState; + const esp_partition_t* running = esp_ota_get_running_partition(); + if (esp_ota_get_state_partition(running, &otaState) == ESP_OK && otaState == ESP_OTA_IMG_PENDING_VERIFY) { + esp_ota_mark_app_valid_cancel_rollback(); + } + } HalSystem::begin(); gpio.begin(); powerManager.begin(); diff --git a/src/network/OtaUpdater.cpp b/src/network/OtaUpdater.cpp index e08ecac4..732b2e3f 100644 --- a/src/network/OtaUpdater.cpp +++ b/src/network/OtaUpdater.cpp @@ -201,7 +201,10 @@ const std::string& OtaUpdater::getLatestVersion() const { return latestVersion; void OtaUpdater::cleanupUpdate() { if (otaHandle) { - esp_https_ota_finish(otaHandle); + const esp_err_t err = esp_https_ota_finish(otaHandle); + if (err != ESP_OK) { + LOG_ERR("OTA", "esp_https_ota_finish on cleanup: %s", esp_err_to_name(err)); + } otaHandle = nullptr; } cancelRequested = false; @@ -365,20 +368,3 @@ OtaUpdater::OtaUpdaterError OtaUpdater::performInstallUpdateStep() { LOG_INF("OTA", "Update completed"); return OK; } - -OtaUpdater::OtaUpdaterError OtaUpdater::installUpdate() { - const auto beginResult = beginInstallUpdate(); - if (beginResult != UPDATE_IN_PROGRESS) { - return beginResult; - } - - OtaUpdaterError result; - do { - result = performInstallUpdateStep(); - if (result == UPDATE_IN_PROGRESS) { - delay(100); - } - } while (result == UPDATE_IN_PROGRESS); - - return result; -} diff --git a/src/network/OtaUpdater.h b/src/network/OtaUpdater.h index 26812fa3..4ed2b1a9 100644 --- a/src/network/OtaUpdater.h +++ b/src/network/OtaUpdater.h @@ -51,7 +51,6 @@ class OtaUpdater { OtaUpdaterError performInstallUpdateStep(); void cancelUpdate(); void cleanupUpdate(); - OtaUpdaterError installUpdate(); private: static int forceSetOtaBootPartition();