Files
pablo2048andClaude Opus 4.8 59add9eac7 feat: standalone ReleaseUpdater library v1.1.0
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>
2026-07-15 14:02:41 +02:00

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`.