diff --git a/.github/workflows/release_candidate.yml b/.github/workflows/release_candidate.yml index eb663608..12c08f8a 100644 --- a/.github/workflows/release_candidate.yml +++ b/.github/workflows/release_candidate.yml @@ -32,14 +32,46 @@ jobs: - name: Extract env run: | - echo "SHORT_SHA=${GITHUB_SHA::7}" >> $GITHUB_ENV - echo "BRANCH_SUFFIX=${GITHUB_REF_NAME#release/}" >> $GITHUB_ENV - echo "RC_TAG=${GITHUB_REF_NAME#release/}-rc.${GITHUB_RUN_NUMBER}.${GITHUB_RUN_ATTEMPT}" >> $GITHUB_ENV + VERSION="$(python3 - <<'PY' + import configparser + + config = configparser.ConfigParser() + config.read("platformio.ini") + version = config["crosspoint"]["version"].strip() + if version.count(".") == 1: + version = f"{version}.0" + print(version) + PY + )" + + NEXT_RC="$(python3 - "$VERSION" <<'PY' + import re + import subprocess + import sys + + version = sys.argv[1] + pattern = re.compile(rf"^{re.escape(version)}-rc\.(\d+)(?:\.\d+)?$") + + highest = 0 + tags = subprocess.check_output( + ["git", "tag", "-l", f"{version}-rc.*"], + text=True, + ) + for tag in tags.splitlines(): + match = pattern.match(tag) + if match: + highest = max(highest, int(match.group(1))) + + print(highest + 1) + PY + )" + + echo "RC_TAG=${VERSION}-rc.${NEXT_RC}" >> $GITHUB_ENV echo "RELEASE_NOTES_PATH=${RUNNER_TEMP}/release-notes.md" >> $GITHUB_ENV - name: Build CrossPoint Release Candidate env: - CROSSPOINT_RC_HASH: ${{ env.SHORT_SHA }} + CROSSPOINT_RC_VERSION: ${{ env.RC_TAG }} run: pio run -e gh_release_rc - name: Patch min_chip_rev_full to 0 diff --git a/lib/Epub/Epub.cpp b/lib/Epub/Epub.cpp index f4fdcf3c..d3299836 100644 --- a/lib/Epub/Epub.cpp +++ b/lib/Epub/Epub.cpp @@ -107,12 +107,6 @@ bool Epub::parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata, OpfCac return false; } const unsigned long streamMs = millis() - streamStart; - LOG_DBG("EBP", - "content.opf stream=%lu ms, parser.write_calls=%zu, bytes=%zu, parse_buffer=%lu ms, manifest_open=%lu ms, " - "spine_open=%lu ms, guide_open=%lu ms, itemrefs=%zu, itemref_lookup=%lu ms, create_spine=%lu ms", - streamMs, opfParser.stats.writeCalls, opfParser.stats.bytesParsed, opfParser.stats.parseBufferMs, - opfParser.stats.manifestOpenMs, opfParser.stats.spineOpenMs, opfParser.stats.guideOpenMs, - opfParser.stats.itemRefCount, opfParser.stats.itemRefLookupMs, opfParser.stats.createSpineEntryMs); // Grab data from opfParser into epub bookMetadata.title = opfParser.title; diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index 8c5f0d88..ba744332 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -157,6 +157,7 @@ STR_REFRESH_FREQ: "Refresh Frequency" STR_REFRESH_AFTER_IMAGE_PAGES: "Refresh after image pages" STR_KOREADER_SYNC: "KOReader Sync" STR_CHECK_UPDATES: "Check for updates" +STR_INCLUDE_BETA_UPDATES: "Include beta updates" STR_LANGUAGE: "Language" STR_CLEAR_READING_CACHE: "Clear Reading Cache" STR_USERNAME: "Username" @@ -246,6 +247,7 @@ STR_NEW_VERSION: "New Version: " STR_UPDATING: "Updating..." STR_NO_UPDATE: "No update available" STR_UPDATE_FAILED: "Update failed" +STR_RELEASE_METADATA_TOO_LARGE: "Release metadata too large" STR_UPDATE_COMPLETE: "Update complete" STR_POWER_ON_HINT: "Press and hold power button to turn back on" STR_RESTARTING_HINT: "Restarting... If device does not restart, hold the power button for a few seconds." diff --git a/platformio.ini b/platformio.ini index c468397d..e9216995 100644 --- a/platformio.ini +++ b/platformio.ini @@ -88,9 +88,8 @@ build_flags = extends = base build_flags = ${base.build_flags} - -DCROSSPOINT_VERSION=\"${crosspoint.version}-rc+${sysenv.CROSSPOINT_RC_HASH}\" -DENABLE_SERIAL_LOG - -DLOG_LEVEL=1 ; Set log level to info for release candidate builds + -DLOG_LEVEL=1 ; Set log level to info for release candidate builds [env:slim] extends = base diff --git a/scripts/generate_release_notes.py b/scripts/generate_release_notes.py index 72fc11a3..435b2949 100644 --- a/scripts/generate_release_notes.py +++ b/scripts/generate_release_notes.py @@ -129,7 +129,7 @@ def categorize_commit(subject: str) -> tuple[str, str]: return "other", subject commit_type = match.group("type").lower() - description = match.group("description") + description = f'{commit_type}: {match.group("description")}' if match.group("breaking"): description = f"BREAKING: {description}" diff --git a/scripts/git_branch.py b/scripts/git_branch.py index 5ff74a9b..f9875326 100644 --- a/scripts/git_branch.py +++ b/scripts/git_branch.py @@ -1,13 +1,18 @@ """ -PlatformIO pre-build script: inject git branch into CROSSPOINT_VERSION for -the default (dev) environment. +PlatformIO pre-build script: inject Git metadata into preprocessor defines. -Results in a version string like: 1.1.0-dev+feat-koysnc-xpath -Release environments are unaffected; they set CROSSPOINT_VERSION in the ini. +- The default (dev) environment gets CROSSPOINT_VERSION with a branch suffix like: + 1.1.0-dev+feat-koysnc-xpath +- The gh_release_rc environment gets CROSSPOINT_VERSION with an RC tag from CI metadata + when available, or a local fallback like: 1.1.0-rc+local +- All environments get CROSSPOINT_GIT_REPOSITORY, resolved from CI metadata + or local Git remotes. A safe fallback is defined in src/network/OtaUpdater.h in case + resolution here fails. """ import configparser import os +import re import subprocess import sys @@ -16,18 +21,26 @@ def warn(msg): print(f'WARNING [git_branch.py]: {msg}', file=sys.stderr) -def get_git_branch(project_dir): +def run_git_command(*args: str, project_dir: str) -> str: try: - branch = subprocess.check_output( - ['git', 'rev-parse', '--abbrev-ref', 'HEAD'], + return subprocess.check_output( + ['git', *args], text=True, stderr=subprocess.PIPE, cwd=project_dir ).strip() + except FileNotFoundError: + warn('git not found on PATH') + raise + except subprocess.CalledProcessError as e: + warn(f'git command "git {" ".join(args)}" failed (exit {e.returncode}): {e.stderr.strip()}') + raise + + +def get_git_branch(project_dir): + try: + branch = run_git_command('rev-parse', '--abbrev-ref', 'HEAD', project_dir=project_dir) # Detached HEAD — show the short SHA instead if branch == 'HEAD': - branch = subprocess.check_output( - ['git', 'rev-parse', '--short', 'HEAD'], - text=True, stderr=subprocess.PIPE, cwd=project_dir - ).strip() + branch = run_git_command('rev-parse', '--short', 'HEAD', project_dir=project_dir) # Strip characters that would break a C string literal return ''.join(c for c in branch if c not in '"\\') except FileNotFoundError: @@ -41,6 +54,74 @@ def get_git_branch(project_dir): return 'unknown' +def get_all_remotes(project_dir: str) -> list[str]: + try: + remotes = run_git_command('remote', project_dir=project_dir) + return remotes.splitlines() + except FileNotFoundError: + warn('git not found on PATH; cannot read git remotes') + return [] + except subprocess.CalledProcessError as e: + warn(f'git command failed (exit {e.returncode}): {e.stderr.strip()}; cannot read git remotes') + return [] + except Exception as e: + warn(f'Unexpected error reading git remotes: {e}; cannot read git remotes') + return [] + + +def parse_git_repository(remote_url: str) -> str | None: + # Match strings like: + # - https://github.com/owner/repo.git + # - https://code.example.com/owner/repo + # - git+ssh://vcs.example.org:owner/repo.git + # - codeberg.org:owner/repo.git + match = re.search(r'^(?:.+)?(?:://)?[^:/]+[:/]([^/]+)/([^/]+?)(?:\.git)?$', remote_url.strip()) + if not match: + return None + owner = match.group(1) + repo = match.group(2) + if not owner or not repo: + return None + return f'{owner}/{repo}' + + +def get_git_remote_url(project_dir, remote_name): + try: + return run_git_command('remote', 'get-url', remote_name, project_dir=project_dir) + except (FileNotFoundError, subprocess.CalledProcessError): + return None + + +def get_git_repository(project_dir): + # Other CI systems (Forgejo, Codeberg) may set GITHUB_REPOSITORY for compatibility + # with GHA. We could also check for other CI-specific env vars to expand support + # later, such as: + # - FORGEJO_REPOSITORY + # - CI_REPOSITORY_URL (this one is a full URL, likely will work with parse_git_repository) + # - BITBUCKET_REPO_FULL_NAME + ci_repository = os.environ.get('GITHUB_REPOSITORY') + if ci_repository: + return ci_repository + + remotes = get_all_remotes(project_dir) + # 'origin' is most likely to be the primary remote, so always check for it first + if 'origin' in remotes: + remotes = ['origin'] + [r for r in remotes if r != 'origin'] + for remote_name in remotes: + remote_url = get_git_remote_url(project_dir, remote_name) + if not remote_url: + continue + repository = parse_git_repository(remote_url) + if repository: + return repository + + warn( + 'Could not resolve a repository from CI metadata or git remotes; ' + 'falling back to compile-time default.' + ) + return None + + def get_base_version(project_dir): ini_path = os.path.join(project_dir, 'platformio.ini') if not os.path.isfile(ini_path): @@ -54,13 +135,35 @@ def get_base_version(project_dir): return config.get('crosspoint', 'version') +def normalize_semver_patch(version: str) -> str: + version = version.strip() + if version.count('.') == 1: + return f'{version}.0' + return version + + def inject_version(env): + project_dir = env['PROJECT_DIR'] + git_repository = get_git_repository(project_dir) + if git_repository: + env.Append(CPPDEFINES=[('CROSSPOINT_GIT_REPOSITORY', f'\\"{git_repository}\\"')]) + print(f'CrossPoint Git repository: {git_repository}') + + # Release candidate builds use the CI-provided RC tag when available, but + # keep local gh_release_rc builds identifiable instead of leaving the + # firmware version empty. + if env['PIOENV'] == 'gh_release_rc': + base_version = normalize_semver_patch(get_base_version(project_dir)) + version_string = os.environ.get('CROSSPOINT_RC_VERSION') or f'{base_version}-rc.0+local' + env.Append(CPPDEFINES=[('CROSSPOINT_VERSION', f'\\"{version_string}\\"')]) + print(f'CrossPoint build version: {version_string}') + return + # Only applies to the dev (default) environment; release envs set the # version via build_flags in platformio.ini and are unaffected. if env['PIOENV'] != 'default': return - project_dir = env['PROJECT_DIR'] base_version = get_base_version(project_dir) branch = get_git_branch(project_dir) version_string = f'{base_version}-dev+{branch}' diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 1dfe164f..68e091f7 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -276,6 +276,8 @@ class CrossPointSettings { uint8_t useClock = 0; // Show the Weather home screen menu item (1 = enabled, 0 = hidden) uint8_t useWeather = 1; + // Include release candidate builds when checking for OTA updates. + uint8_t includeBetaUpdates = 0; // Configurable actions for short / double / long press on each logical button. // BTN_DEFAULT means "use the button's normal built-in behaviour". diff --git a/src/SettingsList.h b/src/SettingsList.h index 33410c06..ab6e22f2 100644 --- a/src/SettingsList.h +++ b/src/SettingsList.h @@ -229,6 +229,8 @@ inline const std::vector list = { StrId::STR_CAT_SYSTEM), SettingInfo::Toggle(StrId::STR_SHOW_FILE_EXTENSIONS, &CrossPointSettings::showFileExtensions, "showFileExtensions", StrId::STR_CAT_SYSTEM), + SettingInfo::Toggle(StrId::STR_INCLUDE_BETA_UPDATES, &CrossPointSettings::includeBetaUpdates, "includeRcUpdates", + StrId::STR_CAT_SYSTEM), // Will be dealt with separately , so do receive none of the main categories to be visible in the web UI but not the // device UI diff --git a/src/activities/settings/OtaUpdateActivity.cpp b/src/activities/settings/OtaUpdateActivity.cpp index d2943679..b777bf87 100644 --- a/src/activities/settings/OtaUpdateActivity.cpp +++ b/src/activities/settings/OtaUpdateActivity.cpp @@ -141,6 +141,9 @@ void OtaUpdateActivity::render(RenderLock&&) { case OtaUpdater::OOM_ERROR: reason = "Out of memory"; break; + case OtaUpdater::METADATA_TOO_LARGE_ERROR: + reason = tr(STR_RELEASE_METADATA_TOO_LARGE); + break; case OtaUpdater::INTERNAL_UPDATE_ERROR: reason = "Internal update error"; break; diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index d21274a0..b0f3bdb8 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -64,6 +64,8 @@ void SettingsActivity::onEnter() { bool sawReaderFontSection = false; bool insertedFontDownload = false; + bool sawIncludeBetaUpdates = false; + SettingInfo includeBetaUpdatesSetting{}; auto insertFontDownloadBelowFontSection = [&]() { auto fontDownload = SettingInfo::Action(StrId::STR_FONT_DOWNLOAD, SettingAction::DownloadFonts); @@ -88,6 +90,11 @@ void SettingsActivity::onEnter() { enriched.enumLabels.reserve(n); for (uint8_t i = 0; i < n; i++) enriched.enumLabels.push_back(fontFamilyOptionLabel(i)); } + if (enriched.nameId == StrId::STR_INCLUDE_BETA_UPDATES) { + includeBetaUpdatesSetting = enriched; + sawIncludeBetaUpdates = true; + continue; + } const bool isReaderFontEntry = enriched.category == StrId::STR_CAT_READER && (enriched.subcategory == StrId::STR_MENU_READER_FONT || enriched.submenu == StrId::STR_MENU_READER_FONT_SETTINGS); @@ -150,6 +157,10 @@ void SettingsActivity::onEnter() { addToMoved(systemSettings, lastSystemSub, std::move(SettingInfo::Action(StrId::STR_CHECK_UPDATES, SettingAction::CheckForUpdates) .withSubcategory(StrId::STR_MENU_SYS_SYSTEM))); + if (sawIncludeBetaUpdates) { + addToMoved(systemSettings, lastSystemSub, + std::move(includeBetaUpdatesSetting.withSubcategory(StrId::STR_MENU_SYS_SYSTEM))); + } addToMoved(systemSettings, lastSystemSub, std::move(SettingInfo::Action(StrId::STR_SD_FIRMWARE_UPDATE, SettingAction::SdFirmwareUpdate) .withSubcategory(StrId::STR_MENU_SYS_SYSTEM))); diff --git a/src/network/HttpClientStream.cpp b/src/network/HttpClientStream.cpp index b5115a2f..ed79e762 100644 --- a/src/network/HttpClientStream.cpp +++ b/src/network/HttpClientStream.cpp @@ -2,21 +2,31 @@ #include -HttpClientStream::HttpClientStream(esp_http_client_handle_t client, int64_t contentLength) - : client(client), contentLength(contentLength) {} +HttpClientStream::HttpClientStream(esp_http_client_handle_t client, int64_t contentLength, size_t maxBytes) + : client(client), contentLength(contentLength), maxBytes(maxBytes) {} int HttpClientStream::available() { if (hasError() || endOfStream) { return 0; } if (contentLength < 0) { + if (maxBytes > 0 && bytesRead >= maxBytes) { + return 0; + } return 1; } const int64_t remaining = contentLength - bytesRead; if (remaining <= 0) { return 0; } - return remaining > INT_MAX ? INT_MAX : static_cast(remaining); + size_t availableBytes = remaining > INT_MAX ? INT_MAX : static_cast(remaining); + if (maxBytes > 0) { + const size_t remainingLimit = bytesRead >= maxBytes ? 0 : maxBytes - bytesRead; + if (availableBytes > remainingLimit) { + availableBytes = remainingLimit; + } + } + return static_cast(availableBytes); } int HttpClientStream::read() { @@ -33,6 +43,19 @@ size_t HttpClientStream::readBytes(char* buffer, size_t length) { if (buffer == nullptr || length == 0) { return 0; } + + if (maxBytes > 0) { + if (bytesRead >= maxBytes) { + limitExceeded = true; + endOfStream = true; + return 0; + } + const size_t remainingLimit = maxBytes - bytesRead; + if (length > remainingLimit) { + length = remainingLimit; + } + } + const int readLen = esp_http_client_read(client, buffer, static_cast(length)); if (readLen == 0) { endOfStream = true; diff --git a/src/network/HttpClientStream.h b/src/network/HttpClientStream.h index e0197e5f..f418770d 100644 --- a/src/network/HttpClientStream.h +++ b/src/network/HttpClientStream.h @@ -8,7 +8,7 @@ class HttpClientStream final : public Stream { public: - explicit HttpClientStream(esp_http_client_handle_t client, int64_t contentLength); + explicit HttpClientStream(esp_http_client_handle_t client, int64_t contentLength, size_t maxBytes = 0); int available() override; int read() override; @@ -19,11 +19,14 @@ class HttpClientStream final : public Stream { size_t bytesReadCount() const { return bytesRead; } bool hasError() const { return lastReadError < 0; } int lastError() const { return lastReadError; } + bool isLimitExceeded() const { return limitExceeded; } private: esp_http_client_handle_t client; int64_t contentLength; + size_t maxBytes; size_t bytesRead = 0; int lastReadError = 0; bool endOfStream = false; + bool limitExceeded = false; }; diff --git a/src/network/OtaUpdater.cpp b/src/network/OtaUpdater.cpp index 788cf3dc..fde961e5 100644 --- a/src/network/OtaUpdater.cpp +++ b/src/network/OtaUpdater.cpp @@ -1,10 +1,13 @@ #include "OtaUpdater.h" +#include #include #include +#include #include +#include "CrossPointSettings.h" #include "HttpClientStream.h" #include "bootloader_common.h" #include "esp_flash_partitions.h" @@ -16,9 +19,13 @@ #include "esp_wifi.h" namespace { -constexpr char latestReleaseUrl[] = "https://api.github.com/repos/jpirnay/crosspoint-reader/releases/latest"; +constexpr char latestReleaseUrl[] = "https://api.github.com/repos/" CROSSPOINT_GIT_REPOSITORY "/releases/latest"; +constexpr char releaseListUrl[] = "https://api.github.com/repos/" CROSSPOINT_GIT_REPOSITORY "/releases?per_page=1"; constexpr int httpRxBufferSize = 2048; constexpr int httpTxBufferSize = 512; +constexpr int otaHttpMaxAttempts = 3; +constexpr unsigned long otaInitialRetryDelayMs = 1000; +constexpr size_t releaseMetadataMaxBytes = 128 * 1024; /* * When esp_crt_bundle.h included, it is pointing wrong header file @@ -41,6 +48,32 @@ struct HttpClientCleaner { } } }; + +const char* getReleaseApiUrl() { return SETTINGS.includeBetaUpdates ? releaseListUrl : latestReleaseUrl; } + +void delayBeforeRetry(const char* operation, int attempt) { + const unsigned long delayMs = otaInitialRetryDelayMs << static_cast(attempt - 1); + LOG_ERR("OTA", "%s failed on attempt %d/%d, retrying in %lu ms", operation, attempt, otaHttpMaxAttempts, delayMs); + delay(delayMs); +} + +JsonVariantConst selectRelease(const JsonDocument& doc) { + if (doc.is()) { + for (JsonObjectConst release : doc.as()) { + if (release["draft"] | false) { + continue; + } + return release; + } + return JsonVariantConst(); + } + + if (doc.is()) { + return doc.as(); + } + + return JsonVariantConst(); +} } /* namespace */ OtaUpdater::OtaUpdaterError OtaUpdater::checkForUpdate() { @@ -56,8 +89,10 @@ OtaUpdater::OtaUpdaterError OtaUpdater::checkForUpdate() { totalSize = 0; render = false; + const char* releaseApiUrl = getReleaseApiUrl(); + esp_http_client_config_t client_config = { - .url = latestReleaseUrl, + .url = releaseApiUrl, .timeout_ms = 10000, /* Default HTTP client buffer size 512 byte only */ .buffer_size = httpRxBufferSize, @@ -66,81 +101,129 @@ OtaUpdater::OtaUpdaterError OtaUpdater::checkForUpdate() { .keep_alive_enable = true, }; - esp_http_client_handle_t client_handle = esp_http_client_init(&client_config); - if (!client_handle) { - LOG_ERR("OTA", "HTTP Client Handle Failed"); - return INTERNAL_UPDATE_ERROR; - } - HttpClientCleaner clientCleaner = {client_handle}; - - esp_err = esp_http_client_set_header(client_handle, "User-Agent", "CrossPoint-ESP32-" CROSSPOINT_VERSION); - if (esp_err != ESP_OK) { - LOG_ERR("OTA", "esp_http_client_set_header Failed : %s", esp_err_to_name(esp_err)); - return INTERNAL_UPDATE_ERROR; + if (SETTINGS.includeBetaUpdates) { + filter[0]["tag_name"] = true; + filter[0]["draft"] = true; + filter[0]["assets"][0]["name"] = true; + filter[0]["assets"][0]["browser_download_url"] = true; + filter[0]["assets"][0]["size"] = true; + } else { + filter["tag_name"] = true; + filter["assets"][0]["name"] = true; + filter["assets"][0]["browser_download_url"] = true; + filter["assets"][0]["size"] = true; } - esp_err = esp_http_client_set_header(client_handle, "Accept", "application/vnd.github+json"); - if (esp_err != ESP_OK) { - LOG_ERR("OTA", "esp_http_client_set_header Failed : %s", esp_err_to_name(esp_err)); - return INTERNAL_UPDATE_ERROR; - } + for (int attempt = 1; attempt <= otaHttpMaxAttempts; ++attempt) { + doc.clear(); - esp_err = esp_http_client_open(client_handle, 0); - if (esp_err != ESP_OK) { - LOG_ERR("OTA", "esp_http_client_open Failed : %s", esp_err_to_name(esp_err)); - return HTTP_ERROR; - } + esp_http_client_handle_t client_handle = esp_http_client_init(&client_config); + if (!client_handle) { + LOG_ERR("OTA", "HTTP Client Handle Failed"); + return INTERNAL_UPDATE_ERROR; + } + HttpClientCleaner clientCleaner = {client_handle}; - const int64_t headerContentLength = esp_http_client_fetch_headers(client_handle); - if (headerContentLength < 0) { - LOG_ERR("OTA", "esp_http_client_fetch_headers Failed : %lld", headerContentLength); - return HTTP_ERROR; - } + esp_err = esp_http_client_set_header(client_handle, "User-Agent", "CrossPoint-ESP32-" CROSSPOINT_VERSION); + if (esp_err != ESP_OK) { + LOG_ERR("OTA", "esp_http_client_set_header Failed : %s", esp_err_to_name(esp_err)); + return INTERNAL_UPDATE_ERROR; + } - const int statusCode = esp_http_client_get_status_code(client_handle); - if (statusCode != 200) { - LOG_ERR("OTA", "Release metadata request failed: HTTP %d", statusCode); - return HTTP_ERROR; - } + esp_err = esp_http_client_set_header(client_handle, "Accept", "application/vnd.github+json"); + if (esp_err != ESP_OK) { + LOG_ERR("OTA", "esp_http_client_set_header Failed : %s", esp_err_to_name(esp_err)); + return INTERNAL_UPDATE_ERROR; + } - const bool chunked = esp_http_client_is_chunked_response(client_handle); - const int64_t contentLength = chunked ? -1 : esp_http_client_get_content_length(client_handle); - LOG_DBG("OTA", "Release metadata headers: content_length=%lld chunked=%s heap=%u largest=%u", contentLength, - chunked ? "yes" : "no", heap_caps_get_free_size(MALLOC_CAP_8BIT), - heap_caps_get_largest_free_block(MALLOC_CAP_8BIT)); - - filter["tag_name"] = true; - filter["assets"][0]["name"] = true; - filter["assets"][0]["browser_download_url"] = true; - filter["assets"][0]["size"] = true; - - HttpClientStream responseStream(client_handle, contentLength); - const DeserializationError error = deserializeJson(doc, responseStream, DeserializationOption::Filter(filter)); - if (error) { - if (responseStream.hasError()) { - LOG_ERR("OTA", "HTTP stream read failed after %zu bytes: %d", responseStream.bytesReadCount(), - responseStream.lastError()); + esp_err = esp_http_client_open(client_handle, 0); + if (esp_err != ESP_OK) { + LOG_ERR("OTA", "esp_http_client_open Failed on attempt %d/%d: %s", attempt, otaHttpMaxAttempts, + esp_err_to_name(esp_err)); + if (attempt < otaHttpMaxAttempts) { + delayBeforeRetry("Release metadata connection", attempt); + continue; + } return HTTP_ERROR; } - LOG_ERR("OTA", "JSON parse failed after %zu bytes: %s", responseStream.bytesReadCount(), error.c_str()); + + const int64_t headerContentLength = esp_http_client_fetch_headers(client_handle); + if (headerContentLength < 0) { + LOG_ERR("OTA", "esp_http_client_fetch_headers Failed on attempt %d/%d: %lld", attempt, otaHttpMaxAttempts, + headerContentLength); + if (attempt < otaHttpMaxAttempts) { + delayBeforeRetry("Release metadata headers", attempt); + continue; + } + return HTTP_ERROR; + } + + const int statusCode = esp_http_client_get_status_code(client_handle); + if (statusCode != 200) { + LOG_ERR("OTA", "Release metadata request failed on attempt %d/%d: HTTP %d", attempt, otaHttpMaxAttempts, + statusCode); + if (statusCode >= 500 && attempt < otaHttpMaxAttempts) { + delayBeforeRetry("Release metadata HTTP status", attempt); + continue; + } + return HTTP_ERROR; + } + + const bool chunked = esp_http_client_is_chunked_response(client_handle); + const int64_t contentLength = chunked ? -1 : esp_http_client_get_content_length(client_handle); + LOG_DBG("OTA", "Release metadata headers: content_length=%lld chunked=%s heap=%u largest=%u", contentLength, + chunked ? "yes" : "no", heap_caps_get_free_size(MALLOC_CAP_8BIT), + heap_caps_get_largest_free_block(MALLOC_CAP_8BIT)); + + if (contentLength > static_cast(releaseMetadataMaxBytes)) { + LOG_ERR("OTA", "Release metadata too large: %lld bytes", contentLength); + return METADATA_TOO_LARGE_ERROR; + } + + HttpClientStream responseStream(client_handle, contentLength, releaseMetadataMaxBytes); + const DeserializationError error = deserializeJson(doc, responseStream, DeserializationOption::Filter(filter)); + if (error) { + if (responseStream.isLimitExceeded() || error == DeserializationError::NoMemory) { + LOG_ERR("OTA", "Release metadata too large after %zu bytes: %s", responseStream.bytesReadCount(), + error.c_str()); + return METADATA_TOO_LARGE_ERROR; + } + if (responseStream.hasError()) { + LOG_ERR("OTA", "HTTP stream read failed on attempt %d/%d after %zu bytes: %d", attempt, otaHttpMaxAttempts, + responseStream.bytesReadCount(), responseStream.lastError()); + if (attempt < otaHttpMaxAttempts) { + delayBeforeRetry("Release metadata stream", attempt); + continue; + } + return HTTP_ERROR; + } + LOG_ERR("OTA", "JSON parse failed after %zu bytes: %s", responseStream.bytesReadCount(), error.c_str()); + return JSON_PARSE_ERROR; + } + + break; + } + + const JsonVariantConst release = selectRelease(doc); + if (release.isNull()) { + LOG_ERR("OTA", "No release found in response"); return JSON_PARSE_ERROR; } - if (!doc["tag_name"].is()) { + if (!release["tag_name"].is()) { LOG_ERR("OTA", "No tag_name found"); return JSON_PARSE_ERROR; } - if (!doc["assets"].is()) { + if (!release["assets"].is()) { LOG_ERR("OTA", "No assets found"); return JSON_PARSE_ERROR; } - latestVersion = doc["tag_name"].as(); + latestVersion = release["tag_name"].as(); - for (JsonObjectConst asset : doc["assets"].as()) { - const char* name = asset["name"] | ""; - if (strcmp(name, "firmware.bin") == 0) { + for (JsonObjectConst asset : release["assets"].as()) { + if (asset["name"] == "firmware.bin") { otaUrl = asset["browser_download_url"].as(); otaSize = asset["size"].as(); totalSize = otaSize; @@ -154,7 +237,7 @@ OtaUpdater::OtaUpdaterError OtaUpdater::checkForUpdate() { return NO_UPDATE; } - LOG_DBG("OTA", "Found update: %s", latestVersion.c_str()); + LOG_DBG("OTA", "Found %s update: %s", SETTINGS.includeBetaUpdates ? "beta" : "stable", latestVersion.c_str()); return OK; } @@ -163,14 +246,24 @@ bool OtaUpdater::isUpdateNewer() const { return false; } - int currentMajor, currentMinor, currentPatch; - int latestMajor, latestMinor, latestPatch; + int currentMajor = 0, currentMinor = 0, currentPatch = 0, currentBetaRelease = 0, currentBetaBuild = 0; + int latestMajor = 0, latestMinor = 0, latestPatch = 0, latestBetaRelease = 0, latestBetaBuild = 0; const auto currentVersion = CROSSPOINT_VERSION; + const bool currentIsBeta = strstr(currentVersion, "-rc.") != nullptr; + const bool latestIsBeta = latestVersion.find("-rc.") != std::string::npos; - // semantic version check (only match on 3 segments) - sscanf(latestVersion.c_str(), "%d.%d.%d", &latestMajor, &latestMinor, &latestPatch); - sscanf(currentVersion, "%d.%d.%d", ¤tMajor, ¤tMinor, ¤tPatch); + // Semantic version check with optional RC suffix. `sscanf()` will stop when + // it reaches part of the input string that doesn't match the format, so this + // format string works for versions like "1.31", "1.34.2", "1.35.0-rc.1", and + // "1.36.0-rc.2.5". + // This does not handle versions using the old "rc." format, but + // considering that people will need to manually install this release or later + // to get this functionality anyway that should be fine. + sscanf(latestVersion.c_str(), "%d.%d.%d-rc.%d.%d", &latestMajor, &latestMinor, &latestPatch, &latestBetaRelease, + &latestBetaBuild); + sscanf(currentVersion, "%d.%d.%d-rc.%d.%d", ¤tMajor, ¤tMinor, ¤tPatch, ¤tBetaRelease, + ¤tBetaBuild); /* * Compare major versions. @@ -191,13 +284,30 @@ bool OtaUpdater::isUpdateNewer() const { */ if (latestPatch != currentPatch) return latestPatch > currentPatch; - // If we reach here, it means all segments are equal. - // One final check, if we're on an RC build (contains "-rc"), we should consider the latest version as newer even if - // the segments are equal, since RC builds are pre-release versions. - if (strstr(currentVersion, "-rc") != nullptr) { + /* + * If we reach here, the stable version segments are equal. A stable release + * is newer than an RC with the same version. + */ + if (!latestIsBeta && currentIsBeta) { return true; } + if (latestIsBeta && !currentIsBeta) { + return false; + } + + /* + * If both versions are RCs, compare their RC release and build numbers. + */ + if (latestIsBeta && currentIsBeta) { + if (latestBetaRelease != currentBetaRelease) { + return latestBetaRelease > currentBetaRelease; + } + if (latestBetaBuild != currentBetaBuild) { + return latestBetaBuild > currentBetaBuild; + } + } + return false; } @@ -247,17 +357,23 @@ OtaUpdater::OtaUpdaterError OtaUpdater::beginInstallUpdate() { .http_client_init_cb = http_client_set_header_cb, }; - /* For better timing and connectivity, we disable power saving for WiFi */ - esp_wifi_set_ps(WIFI_PS_NONE); + for (int attempt = 1; attempt <= otaHttpMaxAttempts; ++attempt) { + /* For better timing and connectivity, we disable power saving for WiFi */ + esp_wifi_set_ps(WIFI_PS_NONE); - 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)); + esp_err_t esp_err = esp_https_ota_begin(&ota_config, &otaHandle); + if (esp_err == ESP_OK) { + return UPDATE_IN_PROGRESS; + } + + LOG_ERR("OTA", "HTTP OTA Begin Failed on attempt %d/%d: %s", attempt, otaHttpMaxAttempts, esp_err_to_name(esp_err)); cleanupUpdate(); - return INTERNAL_UPDATE_ERROR; + if (attempt < otaHttpMaxAttempts) { + delayBeforeRetry("Firmware OTA connection", attempt); + } } - return UPDATE_IN_PROGRESS; + return INTERNAL_UPDATE_ERROR; } /* Writes the otadata entry to boot from the most recently flashed OTA partition, diff --git a/src/network/OtaUpdater.h b/src/network/OtaUpdater.h index 4ed2b1a9..51d808ff 100644 --- a/src/network/OtaUpdater.h +++ b/src/network/OtaUpdater.h @@ -3,6 +3,10 @@ #include #include +#ifndef CROSSPOINT_GIT_REPOSITORY +#define CROSSPOINT_GIT_REPOSITORY "jpirnay/crosspoint-reader" +#endif + // 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; @@ -27,6 +31,7 @@ class OtaUpdater { UPDATE_OLDER_ERROR, INTERNAL_UPDATE_ERROR, OOM_ERROR, + METADATA_TOO_LARGE_ERROR, UPDATE_CANCELLED, UPDATE_IN_PROGRESS, VALIDATE_FAILED,