feat: Allow OTA update to beta releases

This commit is contained in:
Joel Goguen
2026-05-07 23:29:15 -04:00
parent 0b93445450
commit 3a612df4b5
14 changed files with 398 additions and 103 deletions
+2
View File
@@ -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".
+2
View File
@@ -229,6 +229,8 @@ inline const std::vector<SettingInfo> 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
@@ -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;
@@ -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)));
+26 -3
View File
@@ -2,21 +2,31 @@
#include <climits>
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<int>(remaining);
size_t availableBytes = remaining > INT_MAX ? INT_MAX : static_cast<size_t>(remaining);
if (maxBytes > 0) {
const size_t remainingLimit = bytesRead >= maxBytes ? 0 : maxBytes - bytesRead;
if (availableBytes > remainingLimit) {
availableBytes = remainingLimit;
}
}
return static_cast<int>(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<int>(length));
if (readLen == 0) {
endOfStream = true;
+4 -1
View File
@@ -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;
};
+190 -74
View File
@@ -1,10 +1,13 @@
#include "OtaUpdater.h"
#include <Arduino.h>
#include <ArduinoJson.h>
#include <Logging.h>
#include <cstdio>
#include <cstring>
#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<unsigned int>(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<JsonArrayConst>()) {
for (JsonObjectConst release : doc.as<JsonArrayConst>()) {
if (release["draft"] | false) {
continue;
}
return release;
}
return JsonVariantConst();
}
if (doc.is<JsonObjectConst>()) {
return doc.as<JsonObjectConst>();
}
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<int64_t>(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<std::string>()) {
if (!release["tag_name"].is<std::string>()) {
LOG_ERR("OTA", "No tag_name found");
return JSON_PARSE_ERROR;
}
if (!doc["assets"].is<JsonArray>()) {
if (!release["assets"].is<JsonArrayConst>()) {
LOG_ERR("OTA", "No assets found");
return JSON_PARSE_ERROR;
}
latestVersion = doc["tag_name"].as<std::string>();
latestVersion = release["tag_name"].as<std::string>();
for (JsonObjectConst asset : doc["assets"].as<JsonArrayConst>()) {
const char* name = asset["name"] | "";
if (strcmp(name, "firmware.bin") == 0) {
for (JsonObjectConst asset : release["assets"].as<JsonArrayConst>()) {
if (asset["name"] == "firmware.bin") {
otaUrl = asset["browser_download_url"].as<std::string>();
otaSize = asset["size"].as<size_t>();
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", &currentMajor, &currentMinor, &currentPatch);
// 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.<hash>" 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", &currentMajor, &currentMinor, &currentPatch, &currentBetaRelease,
&currentBetaBuild);
/*
* 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,
+5
View File
@@ -3,6 +3,10 @@
#include <functional>
#include <string>
#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,