Promoted from DIYStickyNotes/lib/ReleaseUpdater (byte-identical src) into a reusable Gitea library. Adds LICENSE, .gitignore, library.properties, keywords.txt and carries the ReleaseUpdaterCore native unit test for downstream reuse. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
285 lines
9.9 KiB
Markdown
285 lines
9.9 KiB
Markdown
# 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<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:
|
|
|
|
```cpp
|
|
#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):
|
|
|
|
```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 <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`.
|