# 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 the `native` environment. Handles version parsing, releases-JSON parsing (using ArduinoJson), and asset selection by board token. - **`ReleaseUpdater`** — ESP-only wrapper for TLS transport and flash operations. Uses `WiFiClientSecure` and `HTTPUpdate` from the Arduino-ESP32 framework. Guarded by `#ifdef ARDUINO` so 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 ```cpp /** * 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 ```cpp /** * 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 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: ```cpp #include 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()` — return `true` only when it is OK to interrupt for a flash (e.g. a screensaver is showing). Default: always `true`. Pair with `RuServiceConfig::idleMaxWaitS` to cap the wait. - `onState(const RuStateEvent&)` — drive a UI overlay and/or logging. States: `Checking`, `UpToDate`, `NewFound`, `Installing` (UI show point), `Downloading` (carries `pct`), `Flashed`, `Error` (carries `err`). The event carries the release `tag` from `NewFound` onward. - `withNetGuard(fn)` — run `fn` while holding the app's TLS-serialization guard, e.g. `[](const std::function& f){ NetGate::Lock lock; f(); }`. Default: run `fn` directly. `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) — Uses `WiFiClientSecure` with verification against public Certificate Authorities (GitHub, public Gitea, Let's-Encrypt-based hosts). - **`http://`** — Plain `WiFiClient`, 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): ```bash 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 ```cpp #include // 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`.