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>
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
# 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`.
|
||||
Reference in New Issue
Block a user