diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aae332c1..bcd73969 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,6 +28,9 @@ jobs: - name: Build CrossPoint run: pio run -e gh_release + - name: Patch min_chip_rev_full to 0 + run: python3 scripts/patch_min_chip_rev.py .pio/build/gh_release/firmware.bin + - name: Upload Artifacts uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/release_candidate.yml b/.github/workflows/release_candidate.yml index ea3f10b2..2b333524 100644 --- a/.github/workflows/release_candidate.yml +++ b/.github/workflows/release_candidate.yml @@ -35,6 +35,9 @@ jobs: CROSSPOINT_RC_HASH: ${{ env.SHORT_SHA }} run: pio run -e gh_release_rc + - name: Patch min_chip_rev_full to 0 + run: python3 scripts/patch_min_chip_rev.py .pio/build/gh_release_rc/firmware.bin + - name: Upload Artifacts uses: actions/upload-artifact@v4 with: 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/platformio.ini b/platformio.ini index c989c1cc..c8498aed 100644 --- a/platformio.ini +++ b/platformio.ini @@ -3,7 +3,7 @@ default_envs = default extra_configs = platformio.local.ini [crosspoint] -version = 1.38 +version = 1.36 [base] platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.37/platform-espressif32.zip diff --git a/scripts/patch_min_chip_rev.py b/scripts/patch_min_chip_rev.py new file mode 100644 index 00000000..2d8ab619 --- /dev/null +++ b/scripts/patch_min_chip_rev.py @@ -0,0 +1,64 @@ +""" +Patch min_chip_rev_full to 0 in a firmware.bin and recompute the appended +SHA-256 digest so esp_image_verify() still passes. + +esp_image_header_t layout (24 bytes, all packed): + offset 0 magic (0xE9) + offset 1 segment_count + offset 2 spi_mode + offset 3 spi_speed:4 | spi_size:4 + offset 4-7 entry_addr (uint32 LE) + offset 8 wp_pin + offset 9-11 spi_pin_drv[3] + offset 12-13 chip_id (uint16 LE) + offset 14 min_chip_rev (uint8, legacy) + offset 15-16 min_chip_rev_full (uint16 LE) <-- patch target + offset 17-18 max_chip_rev_full (uint16 LE) + offset 19-22 reserved[4] + offset 23 hash_appended (uint8) + +When hash_appended == 1, the last 32 bytes of the image are the SHA-256 over +bytes [0 .. len-33] (everything except the digest itself). We recompute and +overwrite those 32 bytes after patching the header. +""" +import hashlib +import struct +import sys + +MIN_CHIP_REV_FULL_OFFSET = 15 # byte offset of min_chip_rev_full in esp_image_header_t +HASH_LEN = 32 +HASH_APPENDED_OFFSET = 23 # byte offset of hash_appended flag + + +def patch(path: str) -> 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 66eed43f..1c45441b 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,35 @@ 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 < 0) { + LOG_DBG("WIFI", "Captive portal probe failed (connection error %d)", code); + return false; + } + + 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 +317,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 +443,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 +596,9 @@ void WifiSelectionActivity::render(RenderLock&&) { case WifiSelectionState::FORGET_PROMPT: renderForgetPrompt(); break; + case WifiSelectionState::CAPTIVE_PORTAL: + renderCaptivePortal(); + break; } renderer.displayBuffer(); @@ -719,6 +779,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: diff --git a/src/activities/settings/OtaUpdateActivity.cpp b/src/activities/settings/OtaUpdateActivity.cpp index c20a7f88..dd47e472 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 = "Bootloader incompatible - reflash via USB with PlatformIO"; + 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) { @@ -140,22 +170,18 @@ 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); + failureReason = beginResult; state = FAILED; } requestUpdate(); @@ -164,9 +190,10 @@ void OtaUpdateActivity::loop() { { RenderLock lock(*this); - state = FINISHED; + state = UPDATE_IN_PROGRESS; } requestUpdate(); + return; } if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { @@ -176,6 +203,46 @@ 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); + failureReason = res; + state = FAILED; + } + requestUpdate(); + return; + } + if (state == FAILED) { if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { finish(); 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/main.cpp b/src/main.cpp index 67dd6127..f1914ee5 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include @@ -180,6 +181,13 @@ void setupDisplayAndFonts() { } void setup() { + { + 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 871b49f9..732b2e3f 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 { @@ -28,45 +32,41 @@ 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 */ 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, + .timeout_ms = 10000, .event_handler = event_handler, /* Default HTTP client buffer size 512 byte only */ .buffer_size = 8192, @@ -199,28 +199,41 @@ bool OtaUpdater::isUpdateNewer() const { const std::string& OtaUpdater::getLatestVersion() const { return latestVersion; } -OtaUpdater::OtaUpdaterError OtaUpdater::installUpdate() { +void OtaUpdater::cleanupUpdate() { + if (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; + 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,38 +246,122 @@ 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; +} + +/* 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(); + 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_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 24e04cf5..4ed2b1a9 100644 --- a/src/network/OtaUpdater.h +++ b/src/network/OtaUpdater.h @@ -3,6 +3,10 @@ #include #include +// 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; std::string latestVersion; @@ -11,6 +15,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 +27,9 @@ class OtaUpdater { UPDATE_OLDER_ERROR, INTERNAL_UPDATE_ERROR, OOM_ERROR, + UPDATE_CANCELLED, + UPDATE_IN_PROGRESS, + VALIDATE_FAILED, }; size_t getOtaSize() const { return otaSize; } @@ -30,10 +39,19 @@ 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 installUpdate(); + OtaUpdaterError beginInstallUpdate(); + OtaUpdaterError performInstallUpdateStep(); + void cancelUpdate(); + void cleanupUpdate(); + + private: + static int forceSetOtaBootPartition(); };