Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ReleaseUpdater
A reusable Arduino ESP32 library for automatic firmware updates from GitHub or Gitea releases.
The device polls a repository's latest release on a configurable interval, downloads a matching firmware binary if a newer version is available, and flashes it over HTTPS/HTTP. Supports both public-CA and plaintext (private-CA) scenarios.
Architecture: Core vs. ESP
The library is split into two parts for testability and portability:
ReleaseUpdaterCore— pure C++ types and logic (no Arduino headers). Compiles and unit-tests on thenativeenvironment. Handles version parsing, releases-JSON parsing (using ArduinoJson), and asset selection by board token.ReleaseUpdater— ESP-only wrapper for TLS transport and flash operations. UsesWiFiClientSecureandHTTPUpdatefrom the Arduino-ESP32 framework. Guarded by#ifdef ARDUINOso it does not interfere with native builds.
This split mirrors the design of MickeyClock (pure math + widget layer) and allows
ReleaseUpdaterCore to be thoroughly tested without hardware or framework dependencies.
Public API
ReleaseUpdaterCore — Types & Functions
/**
* Semantic version triple (major.minor.patch).
* Build or user suffix in the tag is ignored.
*/
struct RuVersion {
int major = 0;
int minor = 0;
int patch = 0;
};
/**
* Parse a version tag: "v1.2.3", "1.2.3", "v1.2", "1.2", "v1", "1".
* Leading v/V is optional. Missing minor or patch default to 0.
* Returns false on malformed input (non-numeric, empty, etc.).
*/
bool ruVersionParse(const char* tag, RuVersion& out);
/**
* Compare two versions.
* Returns: -1 if a < b, 0 if equal, +1 if a > b.
* Comparison is major → minor → patch (standard semantic versioning).
*/
int ruVersionCompare(const RuVersion& a, const RuVersion& b);
/**
* One release's metadata plus the asset selected for this board.
*/
struct RuRelease {
RuVersion version;
char tag[32] = {0}; // "v1.2.3" or "1.2.3"
char assetName[96] = {0}; // "", if no asset matched the board token
char assetUrl[256] = {0}; // browser_download_url of the matched asset
};
/**
* Parse a GitHub/Gitea releases/latest JSON response.
* Selects the asset whose name contains assetToken and ends with ".bin".
*
* Args:
* json — the JSON document (as a C string).
* assetToken — board identifier, e.g. "sc01plus" or "jc3248w535".
* out — filled with the parsed release.
*
* Returns: false on JSON parse error or missing tag_name.
* If no asset matches the token, returns true but sets out.assetUrl[0] = '\0'.
*/
bool ruParseLatestRelease(const char* json, const char* assetToken, RuRelease& out);
ReleaseUpdater — ESP Configuration & Interface
/**
* Configuration for the update checker.
*/
struct RuConfig {
String apiBase; // "https://api.github.com" or "https://gitea.host/api/v1"
String owner; // repo owner / org
String repo; // repo name
String assetToken; // board token, e.g. "sc01plus"
RuVersion current; // running firmware version (from SW_VERSION_* macros)
};
/**
* State machine for the update process.
*/
enum class RuState {
Idle, // waiting for next check
Checking, // fetching latest release info
UpToDate, // no newer version available
Downloading,// downloading the asset
Flashed, // successfully flashed; device should reboot
Error // an error occurred (see error string)
};
/**
* Release updater — TLS transport and flash operations.
*
* Transport (TLS vs. plaintext) and CA verification are derived from the scheme
* in cfg.apiBase:
* - https:// → WiFiClientSecure with public-CA verification
* - http:// → plain WiFiClient (no TLS)
*/
class ReleaseUpdater {
public:
/**
* Constructor.
* Transport choice is baked into the config (cannot be changed post-creation).
*/
explicit ReleaseUpdater(const RuConfig& cfg);
/**
* Fetch the latest release from the configured repo.
*
* Args:
* rel — filled with the parsed release; assetUrl may be empty if no matching asset.
* err — filled with an error message on failure.
*
* Returns: true on success (even if assetUrl is empty), false on error.
*
* IMPORTANT: The caller must hold the NetGate lock to ensure TLS serialization
* (no concurrent IMAP/OTA TLS sessions).
*/
bool check(RuRelease& rel, String& err);
/**
* Check if the given release is newer than the running version.
*/
bool isNewer(const RuRelease& rel) const;
/**
* Download the release asset and flash it via HTTPUpdate.
*
* Args:
* rel — the release to flash (rel.assetUrl must be non-empty).
* err — filled with an error message on failure.
*
* Returns: true if the flash succeeded (device should reboot).
* false on error (download failed, image verification failed, etc.).
*
* Behaviour:
* - Does NOT automatically reboot (caller handles reboot timing).
* - Follows HTTP redirects (GitHub asset URLs 302-redirect).
* - Verifies image size and MD5 before switching partitions (HTTPUpdate built-in).
* - Replaces only the app partition; LittleFS (config, notes) is untouched.
*
* IMPORTANT: Must never run on the LVGL task or hold lvgl_port_lock.
*/
bool downloadAndFlash(const RuRelease& rel, String& err);
/**
* Register a progress callback: (bytesReceived, bytesTotal).
* Optional; useful for UI feedback during download.
*/
void setProgressCb(std::function<void(size_t, size_t)> cb);
};
Background Service (ReleaseUpdaterService)
For a turnkey background self-update, ReleaseUpdaterService runs the poll → idle-gate → flash →
reboot loop on its own FreeRTOS task. All app-specific behaviour is injected as optional hooks with
safe defaults, so a headless project injects nothing:
#include <ReleaseUpdaterService.hpp>
RuServiceConfig sc;
sc.ru.apiBase = "https://api.github.com";
sc.ru.owner = "myorg";
sc.ru.repo = "my-firmware";
sc.ru.assetToken = "myboard"; // must match the release asset's board token
sc.ru.current = { SW_VERSION_MAJOR, SW_VERSION_MINOR, SW_VERSION_PATCH };
sc.intervalMin = 60;
ReleaseUpdaterService service;
service.begin(sc); // headless: no hooks -> update as soon as one is found
Hooks (all optional):
idleReady()— returntrueonly when it is OK to interrupt for a flash (e.g. a screensaver is showing). Default: alwaystrue. Pair withRuServiceConfig::idleMaxWaitSto cap the wait.onState(const RuStateEvent&)— drive a UI overlay and/or logging. States:Checking,UpToDate,NewFound,Installing(UI show point),Downloading(carriespct),Flashed,Error(carrieserr). The event carries the releasetagfromNewFoundonward.withNetGuard(fn)— runfnwhile holding the app's TLS-serialization guard, e.g.[](const std::function<void()>& f){ NetGate::Lock lock; f(); }. Default: runfndirectly.
RuRebootPolicy::Auto (default) reboots the device itself after rebootDelayMs; Manual sets
pendingReboot() and stops the task so the app can reboot on its own schedule.
Transport Selection
The ota_api_base configuration field's URL scheme determines transport:
https://(default) — UsesWiFiClientSecurewith verification against public Certificate Authorities (GitHub, public Gitea, Let's-Encrypt-based hosts).http://— PlainWiFiClient, no TLS (for self-hosted Gitea with private or self-signed certificates).
No private-CA / self-signed certificate support is provided. Clients behind private CAs must use plaintext HTTP instead.
Public-CA Certificate Bundle
The library includes ru_ca_roots.h — a small, static PROGMEM bundle of concatenated PEM-encoded
public root certificates:
- ISRG Root X1 and ISRG Root X2 (Let's-Encrypt, used by many Gitea instances).
- DigiCert Global Root G2 and Sectigo/USERTrust root (GitHub's current TLS chain).
This bundle is automatically used by WiFiClientSecure::setCACert() when https:// is configured.
For self-hosted instances with private CAs, configure ota_api_base with http:// instead.
Native Unit Tests
Run the core logic tests on your development machine (no hardware needed):
pio test -e native -f test_release_updater
Tests cover:
- Version parsing: tags like
"v1.2.3","1.2","v1"and rejection of malformed input. - Version comparison: newer, equal, and older versions.
- JSON parsing: both GitHub and Gitea release responses with asset selection by board token.
- Asset matching: correct selection by token; fallback when no asset matches.
Integration Example
#include <ReleaseUpdater.hpp>
// During setup:
RuConfig cfg;
cfg.apiBase = "https://api.github.com";
cfg.owner = "myorg";
cfg.repo = "my-firmware";
cfg.assetToken = "sc01plus"; // or "jc3248w535"
cfg.current = {1, 0, 0}; // running version 1.0.0
ReleaseUpdater updater(cfg);
// In a task (with NetGate::lock() held):
RuRelease rel;
String err;
if (!updater.check(rel, err)) {
log("Update check failed: %s", err.c_str());
} else if (updater.isNewer(rel)) {
if (!updater.downloadAndFlash(rel, err)) {
log("Flash failed: %s", err.c_str());
} else {
log("Flash successful; rebooting...");
ESP.restart();
}
} else {
log("Up to date.");
}
Build Integration
The library is included in PlatformIO's library search path and requires:
- Arduino-ESP32 framework 3.x.
- ArduinoJson (already included in this project).
WiFiClientSecure,HTTPClient,HTTPUpdate(provided by Arduino-ESP32).
For native testing, ReleaseUpdaterCore.cpp compiles stand-alone; ReleaseUpdater.cpp is guarded
by #ifdef ARDUINO and excluded from native builds automatically via PlatformIO's lib_ignore.