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

/**
 * 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() — 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<void()>& 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):

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.

S
Description
Self-update an ESP32 from GitHub/Gitea releases (version compare, per-board asset selection, HTTPUpdate flashing, background service).
Readme MIT
57 KiB
Languages
C++ 82.3%
C 17.7%