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:
2026-07-15 14:02:41 +02:00
co-authored by Claude Opus 4.8
commit 59add9eac7
13 changed files with 1043 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
.pio/
.vscode/
*.o
*.d
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 xPablo.cz
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+284
View File
@@ -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`.
+14
View File
@@ -0,0 +1,14 @@
ReleaseUpdater KEYWORD1
ReleaseUpdaterService KEYWORD1
RuConfig KEYWORD1
RuVersion KEYWORD1
RuRelease KEYWORD1
RuState KEYWORD1
check KEYWORD2
downloadAndFlash KEYWORD2
isNewer KEYWORD2
begin KEYWORD2
pendingReboot KEYWORD2
ruVersionParse KEYWORD2
ruVersionCompare KEYWORD2
ruParseLatestRelease KEYWORD2
+12
View File
@@ -0,0 +1,12 @@
{
"name": "ReleaseUpdater",
"version": "1.1.0",
"description": "Self-update an ESP32 from GitHub/Gitea releases: version compare, asset selection, HTTPUpdate flashing, and an optional background service loop with idle-gate and UI/TLS hooks.",
"keywords": ["esp32", "ota", "github", "gitea", "release", "self-update"],
"authors": [{ "name": "xPablo.cz", "url": "https://git.xpablo.cz/xPablo.cz" }],
"license": "MIT",
"frameworks": "arduino",
"platforms": ["espressif32"],
"dependencies": [{ "name": "ArduinoJson", "version": "^7.0.0" }],
"build": { "libArchive": false }
}
+10
View File
@@ -0,0 +1,10 @@
name=ReleaseUpdater
version=1.1.0
author=xPablo.cz
maintainer=xPablo.cz <https://git.xpablo.cz/xPablo.cz>
sentence=Self-update an ESP32 from GitHub/Gitea releases.
paragraph=Version compare, per-board asset selection, HTTPUpdate flashing, and an optional background service loop with idle-gate and UI/TLS hooks.
category=Communication
url=https://git.xpablo.cz/xPablo.cz/ReleaseUpdater
architectures=esp32
depends=ArduinoJson
+89
View File
@@ -0,0 +1,89 @@
/** @file ReleaseUpdater.cpp — ESP-only implementation (guarded so the native build skips it). */
#include "ReleaseUpdater.hpp"
#if defined(ARDUINO)
#include <WiFiClientSecure.h>
#include <WiFiClient.h>
#include <HTTPClient.h>
#include <HTTPUpdate.h>
#include "ru_ca_roots.h"
namespace {
/** Make the transport client for the configured scheme. Caller owns the returned pointer. */
WiFiClient* makeClient(bool https) {
if (https) {
auto* c = new WiFiClientSecure();
c->setCACert(RU_CA_ROOTS);
// Stream::setTimeout() is milliseconds; both HTTPClient::begin()->connect() and
// HTTPUpdate's internal HTTPClient reset this via their own connect-time timeout
// (HTTPClient::setConnectTimeout()/setTimeout()) before any read happens, so this
// initial value is mostly a safe default, not the effective one.
c->setTimeout(15000);
return c;
}
auto* c = new WiFiClient();
c->setTimeout(15000);
return c;
}
} // namespace
bool ReleaseUpdater::check(RuRelease& rel, String& err) {
WiFiClient* client = makeClient(_isHttps());
String body;
bool ok = false;
{
// LIFETIME: HTTPClient keeps a POINTER to `*client` (passed by reference to begin()), and its
// destructor / end() touch it (disconnect -> _client->stop()). It MUST therefore be destroyed
// BEFORE `delete client`. Keeping `http` in this inner scope guarantees ~HTTPClient() runs
// while `client` is still valid; `client` is deleted only after the scope closes. (Deleting the
// client while `http` was still alive was a use-after-free -> nondeterministic heap corruption
// and panic in ~HTTPClient().)
HTTPClient http;
http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
http.setConnectTimeout(15000);
if (!http.begin(*client, _latestUrl())) {
err = "http.begin failed";
} else {
http.addHeader("User-Agent", _cfg.userAgent);
http.addHeader("Accept", "application/vnd.github+json");
const int code = http.GET();
if (code != HTTP_CODE_OK) {
err = "HTTP " + String(code);
} else {
body = http.getString();
ok = true;
}
http.end();
}
} // ~HTTPClient() runs here, while `client` is still valid
delete client;
if (!ok) return false;
if (!ruParseLatestRelease(body.c_str(), _cfg.assetToken.c_str(), rel)) {
err = "parse failed"; return false;
}
return true;
}
bool ReleaseUpdater::downloadAndFlash(const RuRelease& rel, String& err) {
if (rel.assetUrl[0] == '\0') { err = "no asset for this board"; return false; }
WiFiClient* client = makeClient(_isHttps());
if (_progress) {
httpUpdate.onProgress([this](int cur, int total) { _progress((size_t) cur, (size_t) total); });
}
httpUpdate.rebootOnUpdate(false); // AppUpdater reboots when idle
httpUpdate.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
httpUpdate.setLedPin(-1);
// 3rd arg is `currentVersion` (HTTPUpdate.h), sent as the `x-ESP32-version` header when
// non-empty -- NOT a User-Agent (HTTPUpdate.cpp::handleUpdate() never sets one). Our static
// GitHub/Gitea release assets don't consult that header, and _cfg.userAgent is not a version
// string, so pass "" rather than sending a misleading value. The 4th arg is a request callback
// (HTTPUpdateRequestCB, HTTPUpdate.h:95) invoked on the internal HTTPClient before the GET --
// use it to set the User-Agent GitHub requires on the asset-download request too.
const t_httpUpdate_return r = httpUpdate.update(*client, String(rel.assetUrl), "",
[this](HTTPClient* h){ h->addHeader("User-Agent", _cfg.userAgent); });
httpUpdate.onProgress(nullptr); // don't leave a dangling 'this' on the shared singleton
delete client;
if (r == HTTP_UPDATE_OK) return true;
err = "update failed: " + httpUpdate.getLastErrorString() + " (" + String(httpUpdate.getLastError()) + ")";
return false; // running firmware unchanged (HTTPUpdate verifies before switching)
}
#endif // ARDUINO
+60
View File
@@ -0,0 +1,60 @@
/**
* @file ReleaseUpdater.hpp
* @brief ESP32 release self-updater: fetches {apiBase}/repos/{owner}/{repo}/releases/latest, selects
* this board's asset, and flashes it via HTTPUpdate. Transport (TLS vs plaintext) is derived
* from the apiBase URL scheme. Pure parsing lives in ReleaseUpdaterCore.hpp.
*/
#pragma once
#include "ReleaseUpdaterCore.hpp"
#if defined(ARDUINO)
#include <Arduino.h>
#include <functional>
/** Immutable config for one updater instance. */
struct RuConfig {
String apiBase; ///< "https://api.github.com" or "https://gitea.host/api/v1" (scheme picks transport).
String owner; ///< Repo owner/org.
String repo; ///< Repo name.
String assetToken; ///< Board token (HW_VARIANT), e.g. "sc01plus".
RuVersion current; ///< Running firmware version.
String userAgent = "DIYStickyNotes-OTA"; ///< GitHub requires a User-Agent.
};
enum class RuState {
Idle, ///< Waiting for the next check.
Checking, ///< Fetching latest release info.
UpToDate, ///< No newer version available.
NewFound, ///< A newer release was found; waiting for an idle moment before installing.
Installing, ///< Idle-gate resolved; download+flash starting (UI overlay show point).
Downloading, ///< Downloading/flashing the asset (progress events carry percent).
Flashed, ///< Successfully flashed; device should reboot.
Error ///< An error occurred (see the error string).
};
/** Fetch + compare + flash. One instance per updater task; not thread-safe. */
class ReleaseUpdater {
public:
explicit ReleaseUpdater(const RuConfig& cfg) : _cfg(cfg) {}
/** GET releases/latest, parse + select asset into `rel`. false + `err` on failure.
* Caller must hold NetGate::Lock. */
bool check(RuRelease& rel, String& err);
/** True if rel.version > cfg.current. */
bool isNewer(const RuRelease& rel) const { return ruVersionCompare(rel.version, _cfg.current) > 0; }
/** Download rel.assetUrl and flash via HTTPUpdate (no auto-reboot). true if flashed. */
bool downloadAndFlash(const RuRelease& rel, String& err);
void setProgressCb(std::function<void(size_t,size_t)> cb) { _progress = std::move(cb); }
private:
// URL schemes are case-insensitive per RFC 3986; lowercase a copy before comparing so
// "HTTPS://..." isn't silently downgraded to a plaintext WiFiClient.
bool _isHttps() const { String s = _cfg.apiBase; s.toLowerCase(); return s.startsWith("https:"); }
String _latestUrl() const { return _cfg.apiBase + "/repos/" + _cfg.owner + "/" + _cfg.repo + "/releases/latest"; }
RuConfig _cfg;
std::function<void(size_t,size_t)> _progress;
};
#endif // ARDUINO
+105
View File
@@ -0,0 +1,105 @@
/**
* @file ReleaseUpdaterCore.hpp
* @brief Pure, header-only core for the release updater: semantic-version parse/compare and
* GitHub/Gitea "releases/latest" JSON parsing + per-board asset selection. No Arduino/ESP
* headers (uses ArduinoJson, which is host-friendly) so it unit-tests on the native platform.
*/
#pragma once
#include <cstring>
#include <cstdlib>
#include <cstdio>
#include <ArduinoJson.h>
/** Semantic version triple (major.minor.patch); any build/user suffix is ignored. */
struct RuVersion { int major = 0; int minor = 0; int patch = 0; };
/**
* @brief Parse "vX.Y.Z" / "X.Y.Z" (leading v/V optional; missing minor/patch default to 0).
* @return false on empty input or any non-numeric component.
*/
inline bool ruVersionParse(const char* tag, RuVersion& out) {
if (!tag || !*tag) return false;
if (*tag == 'v' || *tag == 'V') ++tag;
out = RuVersion{};
int* fields[3] = { &out.major, &out.minor, &out.patch };
int idx = 0;
const char* p = tag;
while (idx < 3) {
if (*p < '0' || *p > '9') return false; // each present component must start with a digit
char* end = nullptr;
long val = std::strtol(p, &end, 10);
if (end == p) return false;
*fields[idx++] = (int) val;
if (*end == '\0' || idx == 3) return true; // end of string, or a build suffix after patch -> ignore
if (*end != '.') return false; // only '.' separates the numeric components
p = end + 1;
}
// Once all three numeric components are parsed, any trailing content ("-5-gabc", ".99", "x", ...)
// is ignored as a build/git-describe suffix.
return true;
}
/** @return -1 if a<b, 0 if equal, +1 if a>b (major, then minor, then patch). */
inline int ruVersionCompare(const RuVersion& a, const RuVersion& b) {
if (a.major != b.major) return a.major < b.major ? -1 : 1;
if (a.minor != b.minor) return a.minor < b.minor ? -1 : 1;
if (a.patch != b.patch) return a.patch < b.patch ? -1 : 1;
return 0;
}
/** One release plus the asset selected for this board (assetUrl[0]=='\0' if none matched). */
struct RuRelease {
RuVersion version;
char tag[32] = {0};
char assetName[96] = {0};
char assetUrl[256] = {0};
};
/** Case-sensitive "does haystack contain needle" (C-string). */
inline bool ruStrContains(const char* haystack, const char* needle) {
return haystack && needle && std::strstr(haystack, needle) != nullptr;
}
/** True if `name` ends with ".bin". */
inline bool ruEndsWithBin(const char* name) {
size_t n = name ? std::strlen(name) : 0;
return n >= 4 && std::strcmp(name + n - 4, ".bin") == 0;
}
/**
* @brief Parse a GitHub/Gitea releases/latest document; select the first asset whose name contains
* `assetToken` followed by "-firmware-" and ends in ".bin". Fills `out` (version from tag_name).
* @return false on JSON error or a missing/empty tag_name. A parse that finds no matching asset
* still returns true with out.assetUrl empty.
*/
inline bool ruParseLatestRelease(const char* json, const char* assetToken, RuRelease& out) {
out = RuRelease{};
JsonDocument doc;
if (deserializeJson(doc, json)) return false;
const char* tag = doc["tag_name"].as<const char*>();
if (!tag || !*tag) return false;
std::strncpy(out.tag, tag, sizeof(out.tag) - 1);
ruVersionParse(tag, out.version); // tag already validated non-empty; leaves 0.0.0 if odd form
// scripts/appcopy.py names the app image "{HW_NAME}-{HW_VARIANT}-firmware-{version}.bin", so the
// board token is ALWAYS immediately followed by "-firmware-" for the real app binary. Requiring
// that composite (instead of a bare token substring) rejects other board-token ".bin" assets that
// may ship in the same release (e.g. a littlefs/factory/combined image), which would brick the
// device if flashed to the app partition. NOTE: if APP_NAME is ever introduced, the "firmware"
// fwname component changes and this needle must be kept in sync.
char needle[96];
std::snprintf(needle, sizeof(needle), "%s-firmware-", assetToken);
JsonArrayConst assets = doc["assets"].as<JsonArrayConst>();
for (JsonObjectConst a : assets) {
const char* name = a["name"].as<const char*>();
const char* url = a["browser_download_url"].as<const char*>();
if (!name || !url) continue;
if (ruStrContains(name, needle) && ruEndsWithBin(name)) {
std::strncpy(out.assetName, name, sizeof(out.assetName) - 1);
std::strncpy(out.assetUrl, url, sizeof(out.assetUrl) - 1);
break;
}
}
return true;
}
+97
View File
@@ -0,0 +1,97 @@
/** @file ReleaseUpdaterService.cpp — background self-update loop; see ReleaseUpdaterService.hpp. */
#include "ReleaseUpdaterService.hpp"
#if defined(ARDUINO)
#include <WiFi.h>
#include <cstring>
bool ReleaseUpdaterService::begin(const RuServiceConfig& cfg, const RuServiceHooks& hooks) {
if (_started) return true;
_cfg = cfg;
_hooks = hooks;
// Fill in safe defaults for any hook the caller left unset.
if (!_hooks.idleReady) _hooks.idleReady = [] { return true; };
if (!_hooks.withNetGuard) _hooks.withNetGuard = [](const std::function<void()>& f) { f(); };
_started = true;
const BaseType_t ok = xTaskCreatePinnedToCore(&ReleaseUpdaterService::taskEntry, "ota",
_cfg.taskStackBytes, this, _cfg.taskPriority, nullptr, _cfg.taskCore);
if (ok != pdPASS) {
_started = false;
emit(RuState::Error, 0, "", "task create failed");
return false;
}
return true;
}
void ReleaseUpdaterService::taskEntry(void* self) {
static_cast<ReleaseUpdaterService*>(self)->run();
}
void ReleaseUpdaterService::emit(RuState st, int pct, const char* tag, const char* err) {
if (!_hooks.onState) return;
RuStateEvent e;
e.state = st;
e.pct = pct;
e.tag = tag ? tag : "";
e.err = err ? err : "";
_hooks.onState(e);
}
void ReleaseUpdaterService::run() {
_lastPct = -1;
vTaskDelay(pdMS_TO_TICKS(_cfg.bootDelayS * 1000u)); // let the network settle; never block boot
ReleaseUpdater updater(_cfg.ru);
// Progress -> Downloading events on whole-percent changes (consumer decides further throttling).
updater.setProgressCb([this](size_t cur, size_t total) {
const int pct = total ? (int) ((cur * 100) / total) : 0;
if (pct == _lastPct) return;
_lastPct = pct;
emit(RuState::Downloading, pct, _curTag, "");
});
for (;;) {
if (WiFi.status() == WL_CONNECTED) {
RuRelease rel;
String err;
emit(RuState::Checking, 0, "", "");
bool ok = false;
_hooks.withNetGuard([&] { ok = updater.check(rel, err); }); // TLS serialized with the app
if (!ok) {
emit(RuState::Error, 0, "", err.c_str());
} else if (updater.isNewer(rel)) {
emit(RuState::NewFound, 0, rel.tag, "");
// Idle-gate: wait until the app says it's OK (screensaver up, etc.), capped so a
// device in constant use (or with no idle concept) still updates eventually.
const uint32_t startMs = millis();
const uint64_t capMs = (uint64_t)_cfg.idleMaxWaitS * 1000u;
while (!_hooks.idleReady() &&
(_cfg.idleMaxWaitS == 0 || (millis() - startMs) < capMs))
vTaskDelay(pdMS_TO_TICKS(3000));
strncpy(_curTag, rel.tag, sizeof(_curTag) - 1);
_curTag[sizeof(_curTag) - 1] = '\0';
emit(RuState::Installing, 0, rel.tag, "");
bool flashed = false;
_hooks.withNetGuard([&] { flashed = updater.downloadAndFlash(rel, err); });
if (flashed) {
_pendingReboot = true;
emit(RuState::Flashed, 100, rel.tag, "");
if (_cfg.rebootPolicy == RuRebootPolicy::Auto) {
vTaskDelay(pdMS_TO_TICKS(_cfg.rebootDelayMs));
ESP.restart(); // does not return
}
vTaskDelete(nullptr); // Manual: flashed once; stop polling, let the app reboot
} else {
emit(RuState::Error, 0, rel.tag, err.c_str());
}
} else {
emit(RuState::UpToDate, 0, rel.tag, "");
}
}
vTaskDelay(pdMS_TO_TICKS(_cfg.intervalMin * 60u * 1000u));
}
}
#endif // ARDUINO
+82
View File
@@ -0,0 +1,82 @@
/**
* @file ReleaseUpdaterService.hpp
* @brief Reusable background self-update loop for ESP32/ESPF projects. Wraps ReleaseUpdater in a
* FreeRTOS task that polls on an interval, optionally waits for an app-defined idle moment,
* flashes, and reboots. All app couplings (idle predicate, UI/state reporting, TLS
* serialization) are injected as optional hooks with safe defaults, so a headless project
* with no screensaver injects nothing and it just polls + flashes.
*/
#pragma once
#include "ReleaseUpdater.hpp"
#if defined(ARDUINO)
#include <Arduino.h>
#include <functional>
/** Reboot handling after a successful flash. */
enum class RuRebootPolicy {
Auto, ///< Service reboots itself: onState(Flashed) -> wait rebootDelayMs -> ESP.restart().
Manual, ///< Service sets pendingReboot() true, stops polling, and lets the app reboot when it chooses.
};
/** One lifecycle event reported to RuServiceHooks::onState. */
struct RuStateEvent {
RuState state; ///< Current lifecycle state.
int pct = 0; ///< Download percent 0..100 (meaningful only in Downloading).
const char* tag = ""; ///< Release tag, set from NewFound onward (e.g. "v0.0.2").
const char* err = ""; ///< Error text, set only in Error.
};
/** Static configuration for one ReleaseUpdaterService instance. */
struct RuServiceConfig {
RuConfig ru; ///< Passed to the underlying ReleaseUpdater.
uint32_t intervalMin = 60; ///< Poll period in minutes.
uint32_t bootDelayS = 30; ///< Delay after task start before the first poll.
uint32_t idleMaxWaitS = 0; ///< Cap on waiting for idleReady() before flashing anyway. 0 = wait forever.
uint32_t rebootDelayMs = 1500; ///< Delay after Flashed before ESP.restart() (Auto policy).
RuRebootPolicy rebootPolicy = RuRebootPolicy::Auto;
uint32_t taskStackBytes = 12 * 1024; ///< FreeRTOS task stack (TLS + HTTPUpdate need headroom).
uint32_t taskCore = 0; ///< Core to pin the task to.
uint32_t taskPriority = 1; ///< FreeRTOS task priority.
};
/** Optional app hooks. Every hook has a safe default; a headless project may leave them all unset. */
struct RuServiceHooks {
/** @return true when it is OK to download+flash now. Default: always true (update immediately). */
std::function<bool()> idleReady;
/** Lifecycle notifications for UI/logging. Default: no-op. */
std::function<void(const RuStateEvent&)> onState;
/** Run @p body while holding the app's network/TLS serialization guard. Default: run it directly.
* Mirrors an RAII lock, e.g. `[](const std::function<void()>& f){ MyTlsLock lock; f(); }`. */
std::function<void(const std::function<void()>&)> withNetGuard;
};
/**
* @brief Background firmware self-updater. One instance owns one FreeRTOS task; not copyable.
*/
class ReleaseUpdaterService {
public:
/** Start the task with @p cfg and @p hooks. Idempotent — a second call is a no-op.
* @return true if the task was created (or was already running); false if task creation
* failed (e.g. OOM), in which case the service is not started and begin() may be retried. */
bool begin(const RuServiceConfig& cfg, const RuServiceHooks& hooks = {});
/** @return true once an image was flashed (Manual policy: the app should then reboot). */
bool pendingReboot() const { return _pendingReboot; }
ReleaseUpdaterService() = default;
ReleaseUpdaterService(const ReleaseUpdaterService&) = delete;
ReleaseUpdaterService& operator=(const ReleaseUpdaterService&) = delete;
private:
static void taskEntry(void* self);
void run(); ///< The poll/idle-gate/flash/reboot loop.
void emit(RuState st, int pct, const char* tag, const char* err); ///< Fire onState if set.
RuServiceConfig _cfg;
RuServiceHooks _hooks;
bool _started = false;
volatile bool _pendingReboot = false;
char _curTag[32] = {0}; ///< Tag of the release being installed (read by the progress cb).
int _lastPct = -1; ///< Last percent emitted (per-instance progress dedupe; reset each run()).
};
#endif // ARDUINO
+136
View File
@@ -0,0 +1,136 @@
/** @file Concatenated public root CAs for HTTPS release checks (GitHub + Let's-Encrypt Gitea).
*
* Roots (self-signed, verified via `openssl x509 -noout -subject -issuer` at fetch time):
* - ISRG Root X1 (RSA) -- fetched from https://letsencrypt.org/certs/isrgrootx1.pem
* - ISRG Root X2 (ECDSA) -- fetched from https://letsencrypt.org/certs/isrg-root-x2.pem
* - DigiCert Global Root G2 -- fetched from https://cacerts.digicert.com/DigiCertGlobalRootG2.crt.pem
* - USERTrust RSA Certification Authority -- fetched from curl's public CA bundle
* (https://curl.se/ca/cacert.pem), SHA-256 fingerprint
* E7:93:C9:B0:2F:D8:AA:13:E2:1C:31:22:8A:CC:B0:81:19:64:3B:74:9C:89:89:64:B1:74:6D:46:C3:D4:CB:D2.
* - USERTrust ECC Certification Authority -- fetched from curl's public CA bundle
* (https://curl.se/ca/cacert.pem), SHA-256 fingerprint
* 4F:F4:60:D5:4B:9C:86:DA:BF:BC:FC:57:12:E0:40:0D:2B:ED:3F:BC:4D:4F:BD:AA:86:E0:6A:DC:D2:A9:AD:7A.
* This is the root that api.github.com's LIVE chain terminates at today (Sectigo Public Server
* Authentication Root E46 is issued by it), so it is required for OTA-from-GitHub to verify.
*
* ISRG Root X1/X2 cover Let's-Encrypt-fronted Gitea instances; DigiCert Global Root G2 and USERTrust
* RSA/ECC cover GitHub's current and historical TLS chains (github.com / api.github.com /
* objects.githubusercontent.com release-asset redirects).
*/
#pragma once
static const char RU_CA_ROOTS[] =
// ---- ISRG Root X1 ----
"-----BEGIN CERTIFICATE-----\n"
"MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw\n"
"TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh\n"
"cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4\n"
"WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu\n"
"ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY\n"
"MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc\n"
"h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+\n"
"0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U\n"
"A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW\n"
"T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH\n"
"B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC\n"
"B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv\n"
"KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn\n"
"OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn\n"
"jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw\n"
"qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI\n"
"rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV\n"
"HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq\n"
"hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL\n"
"ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ\n"
"3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK\n"
"NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5\n"
"ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur\n"
"TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC\n"
"jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc\n"
"oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq\n"
"4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA\n"
"mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d\n"
"emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=\n"
"-----END CERTIFICATE-----\n"
// ---- ISRG Root X2 ----
"-----BEGIN CERTIFICATE-----\n"
"MIICGzCCAaGgAwIBAgIQQdKd0XLq7qeAwSxs6S+HUjAKBggqhkjOPQQDAzBPMQsw\n"
"CQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2gg\n"
"R3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMjAeFw0yMDA5MDQwMDAwMDBaFw00\n"
"MDA5MTcxNjAwMDBaME8xCzAJBgNVBAYTAlVTMSkwJwYDVQQKEyBJbnRlcm5ldCBT\n"
"ZWN1cml0eSBSZXNlYXJjaCBHcm91cDEVMBMGA1UEAxMMSVNSRyBSb290IFgyMHYw\n"
"EAYHKoZIzj0CAQYFK4EEACIDYgAEzZvVn4CDCuwJSvMWSj5cz3es3mcFDR0HttwW\n"
"+1qLFNvicWDEukWVEYmO6gbf9yoWHKS5xcUy4APgHoIYOIvXRdgKam7mAHf7AlF9\n"
"ItgKbppbd9/w+kHsOdx1ymgHDB/qo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0T\n"
"AQH/BAUwAwEB/zAdBgNVHQ4EFgQUfEKWrt5LSDv6kviejM9ti6lyN5UwCgYIKoZI\n"
"zj0EAwMDaAAwZQIwe3lORlCEwkSHRhtFcP9Ymd70/aTSVaYgLXTWNLxBo1BfASdW\n"
"tL4ndQavEi51mI38AjEAi/V3bNTIZargCyzuFJ0nN6T5U6VR5CmD1/iQMVtCnwr1\n"
"/q4AaOeMSQ+2b1tbFfLn\n"
"-----END CERTIFICATE-----\n"
// ---- DigiCert Global Root G2 ----
"-----BEGIN CERTIFICATE-----\n"
"MIIDjjCCAnagAwIBAgIQAzrx5qcRqaC7KGSxHQn65TANBgkqhkiG9w0BAQsFADBh\n"
"MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3\n"
"d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBH\n"
"MjAeFw0xMzA4MDExMjAwMDBaFw0zODAxMTUxMjAwMDBaMGExCzAJBgNVBAYTAlVT\n"
"MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j\n"
"b20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IEcyMIIBIjANBgkqhkiG\n"
"9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuzfNNNx7a8myaJCtSnX/RrohCgiN9RlUyfuI\n"
"2/Ou8jqJkTx65qsGGmvPrC3oXgkkRLpimn7Wo6h+4FR1IAWsULecYxpsMNzaHxmx\n"
"1x7e/dfgy5SDN67sH0NO3Xss0r0upS/kqbitOtSZpLYl6ZtrAGCSYP9PIUkY92eQ\n"
"q2EGnI/yuum06ZIya7XzV+hdG82MHauVBJVJ8zUtluNJbd134/tJS7SsVQepj5Wz\n"
"tCO7TG1F8PapspUwtP1MVYwnSlcUfIKdzXOS0xZKBgyMUNGPHgm+F6HmIcr9g+UQ\n"
"vIOlCsRnKPZzFBQ9RnbDhxSJITRNrw9FDKZJobq7nMWxM4MphQIDAQABo0IwQDAP\n"
"BgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAdBgNVHQ4EFgQUTiJUIBiV\n"
"5uNu5g/6+rkS7QYXjzkwDQYJKoZIhvcNAQELBQADggEBAGBnKJRvDkhj6zHd6mcY\n"
"1Yl9PMWLSn/pvtsrF9+wX3N3KjITOYFnQoQj8kVnNeyIv/iPsGEMNKSuIEyExtv4\n"
"NeF22d+mQrvHRAiGfzZ0JFrabA0UWTW98kndth/Jsw1HKj2ZL7tcu7XUIOGZX1NG\n"
"Fdtom/DzMNU+MeKNhJ7jitralj41E6Vf8PlwUHBHQRFXGU7Aj64GxJUTFy8bJZ91\n"
"8rGOmaFvE7FBcf6IKshPECBV1/MUReXgRPTqh5Uykw7+U0b6LJ3/iyK5S9kJRaTe\n"
"pLiaWN0bfVKfjllDiIGknibVb63dDcY3fe0Dkhvld1927jyNxF1WW6LZZm6zNTfl\n"
"MrY=\n"
"-----END CERTIFICATE-----\n"
// ---- USERTrust RSA Certification Authority ----
"-----BEGIN CERTIFICATE-----\n"
"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\n"
"Q+6IHdfGjjxDah2nGN59PRbxYvnKkKj9\n"
"-----END CERTIFICATE-----\n"
// ---- USERTrust ECC Certification Authority ----
"-----BEGIN CERTIFICATE-----\n"
"MIICjzCCAhWgAwIBAgIQXIuZxVqUxdJxVt7NiYDMJjAKBggqhkjOPQQDAzCBiDELMAkGA1UEBhMC\n"
"VVMxEzARBgNVBAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0plcnNleSBDaXR5MR4wHAYDVQQKExVU\n"
"aGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNVBAMTJVVTRVJUcnVzdCBFQ0MgQ2VydGlmaWNhdGlv\n"
"biBBdXRob3JpdHkwHhcNMTAwMjAxMDAwMDAwWhcNMzgwMTE4MjM1OTU5WjCBiDELMAkGA1UEBhMC\n"
"VVMxEzARBgNVBAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0plcnNleSBDaXR5MR4wHAYDVQQKExVU\n"
"aGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNVBAMTJVVTRVJUcnVzdCBFQ0MgQ2VydGlmaWNhdGlv\n"
"biBBdXRob3JpdHkwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQarFRaqfloI+d61SRvU8Za2EurxtW2\n"
"0eZzca7dnNYMYf3boIkDuAUU7FfO7l0/4iGzzvfUinngo4N+LZfQYcTxmdwlkWOrfzCjtHDix6Ez\n"
"nPO/LlxTsV+zfTJ/ijTjeXmjQjBAMB0GA1UdDgQWBBQ64QmG1M8ZwpZ2dEl23OA1xmNjmjAOBgNV\n"
"HQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAwNoADBlAjA2Z6EWCNzklwBB\n"
"HU6+4WMBzzuqQhFkoJ2UOQIReVx7Hfpkue4WQrO/isIJxOzksU0CMQDpKmFHjFJKS04YcPbWRNZu\n"
"9YO6bVi9JNlWSOrvxKJGgYhqOkbRqZtNyWHa0V1Xahg=\n"
"-----END CERTIFICATE-----\n";
@@ -0,0 +1,129 @@
/** @file Native unit tests for ReleaseUpdaterCore (version + release/asset parsing). */
#include <unity.h>
#include "ReleaseUpdaterCore.hpp"
#include <cstring>
static void test_version_parse_basic(void) {
RuVersion v;
TEST_ASSERT_TRUE(ruVersionParse("v1.2.3", v));
TEST_ASSERT_EQUAL_INT(1, v.major); TEST_ASSERT_EQUAL_INT(2, v.minor); TEST_ASSERT_EQUAL_INT(3, v.patch);
TEST_ASSERT_TRUE(ruVersionParse("1.2.3", v));
TEST_ASSERT_EQUAL_INT(3, v.patch);
TEST_ASSERT_TRUE(ruVersionParse("v10.0.1", v));
TEST_ASSERT_EQUAL_INT(10, v.major);
}
static void test_version_parse_short(void) {
RuVersion v;
TEST_ASSERT_TRUE(ruVersionParse("1.2", v));
TEST_ASSERT_EQUAL_INT(1, v.major); TEST_ASSERT_EQUAL_INT(2, v.minor); TEST_ASSERT_EQUAL_INT(0, v.patch);
TEST_ASSERT_TRUE(ruVersionParse("v1", v));
TEST_ASSERT_EQUAL_INT(1, v.major); TEST_ASSERT_EQUAL_INT(0, v.minor); TEST_ASSERT_EQUAL_INT(0, v.patch);
}
static void test_version_parse_reject(void) {
RuVersion v;
TEST_ASSERT_FALSE(ruVersionParse("", v));
TEST_ASSERT_FALSE(ruVersionParse("x", v));
TEST_ASSERT_FALSE(ruVersionParse("1.x.0", v));
TEST_ASSERT_FALSE(ruVersionParse("v.1.2", v));
}
static void test_version_parse_suffix_tolerated(void) {
RuVersion v;
// git-describe style build suffix after patch is ignored.
TEST_ASSERT_TRUE(ruVersionParse("v1.2.3-5-gabcdef", v));
TEST_ASSERT_EQUAL_INT(1, v.major); TEST_ASSERT_EQUAL_INT(2, v.minor); TEST_ASSERT_EQUAL_INT(3, v.patch);
// dotted trailing content after patch is also ignored (treated as build suffix).
TEST_ASSERT_TRUE(ruVersionParse("1.2.3.99", v));
TEST_ASSERT_EQUAL_INT(1, v.major); TEST_ASSERT_EQUAL_INT(2, v.minor); TEST_ASSERT_EQUAL_INT(3, v.patch);
}
static void test_version_compare(void) {
RuVersion a{1,2,3}, b{1,2,3};
TEST_ASSERT_EQUAL_INT(0, ruVersionCompare(a, b));
TEST_ASSERT_EQUAL_INT(1, ruVersionCompare(RuVersion{1,3,0}, RuVersion{1,2,9}));
TEST_ASSERT_EQUAL_INT(-1, ruVersionCompare(RuVersion{1,2,3}, RuVersion{2,0,0}));
TEST_ASSERT_EQUAL_INT(1, ruVersionCompare(RuVersion{1,2,4}, RuVersion{1,2,3}));
}
// Minimal GitHub/Gitea "releases/latest" shape (both share tag_name + assets[].{name,browser_download_url}).
static const char* SAMPLE_JSON =
"{\"tag_name\":\"v1.4.0\",\"assets\":["
"{\"name\":\"DIYStickyNotes-sc01plus-firmware-1.4.0.bin\",\"browser_download_url\":\"https://h/a/sc.bin\"},"
"{\"name\":\"DIYStickyNotes-JC-jc3248w535-firmware-1.4.0.bin\",\"browser_download_url\":\"https://h/a/jc.bin\"}"
"]}";
static void test_parse_selects_sc01(void) {
RuRelease r;
TEST_ASSERT_TRUE(ruParseLatestRelease(SAMPLE_JSON, "sc01plus", r));
TEST_ASSERT_EQUAL_STRING("v1.4.0", r.tag);
TEST_ASSERT_EQUAL_INT(1, r.version.major); TEST_ASSERT_EQUAL_INT(4, r.version.minor);
TEST_ASSERT_EQUAL_STRING("https://h/a/sc.bin", r.assetUrl);
}
static void test_parse_selects_jc_not_sc(void) {
RuRelease r;
// jc token must NOT cross-match the sc01 asset, and sc01 token must NOT match the jc asset.
TEST_ASSERT_TRUE(ruParseLatestRelease(SAMPLE_JSON, "jc3248w535", r));
TEST_ASSERT_EQUAL_STRING("https://h/a/jc.bin", r.assetUrl);
}
static void test_parse_no_matching_asset(void) {
RuRelease r;
TEST_ASSERT_TRUE(ruParseLatestRelease(SAMPLE_JSON, "nosuchboard", r)); // parse ok...
TEST_ASSERT_EQUAL_STRING("", r.assetUrl); // ...but no asset
}
// A same-token asset with a non-.bin extension listed BEFORE the real firmware must be skipped
// (locks the `&& ruEndsWithBin(name)` filter against a regression).
static const char* SAMPLE_JSON_NONBIN_FIRST =
"{\"tag_name\":\"v1.4.0\",\"assets\":["
"{\"name\":\"DIYStickyNotes-sc01plus-firmware-1.4.0.sha256\",\"browser_download_url\":\"https://h/a/sc.sha256\"},"
"{\"name\":\"DIYStickyNotes-sc01plus-firmware-1.4.0.bin\",\"browser_download_url\":\"https://h/a/sc.bin\"}"
"]}";
static void test_parse_skips_non_bin_asset(void) {
RuRelease r;
TEST_ASSERT_TRUE(ruParseLatestRelease(SAMPLE_JSON_NONBIN_FIRST, "sc01plus", r));
TEST_ASSERT_EQUAL_STRING("https://h/a/sc.bin", r.assetUrl); // .sha256 skipped, real .bin chosen
}
// A board-token ".bin" asset that is NOT the app image (missing the "-firmware-" segment, e.g. a
// LittleFS/factory/combined image) listed BEFORE the real firmware asset must be skipped (locks the
// "{token}-firmware-" needle against a regression that could flash a non-app binary to the app
// partition and brick the device).
static const char* SAMPLE_JSON_NONFIRMWARE_FIRST =
"{\"tag_name\":\"v1.4.0\",\"assets\":["
"{\"name\":\"DIYStickyNotes-sc01plus-littlefs-1.4.0.bin\",\"browser_download_url\":\"https://h/a/lfs.bin\"},"
"{\"name\":\"DIYStickyNotes-sc01plus-firmware-1.4.0.bin\",\"browser_download_url\":\"https://h/a/sc.bin\"}"
"]}";
static void test_parse_skips_non_firmware_bin_asset(void) {
RuRelease r;
TEST_ASSERT_TRUE(ruParseLatestRelease(SAMPLE_JSON_NONFIRMWARE_FIRST, "sc01plus", r));
TEST_ASSERT_EQUAL_STRING("https://h/a/sc.bin", r.assetUrl); // littlefs .bin skipped, real firmware chosen
}
static void test_parse_malformed(void) {
RuRelease r;
TEST_ASSERT_FALSE(ruParseLatestRelease("not json", "sc01plus", r));
TEST_ASSERT_FALSE(ruParseLatestRelease("{\"assets\":[]}", "sc01plus", r)); // missing tag_name
TEST_ASSERT_FALSE(ruParseLatestRelease("{\"tag_name\":\"\",\"assets\":[]}", "sc01plus", r)); // empty tag_name
}
int main(int, char**) {
UNITY_BEGIN();
RUN_TEST(test_version_parse_basic);
RUN_TEST(test_version_parse_short);
RUN_TEST(test_version_parse_reject);
RUN_TEST(test_version_parse_suffix_tolerated);
RUN_TEST(test_version_compare);
RUN_TEST(test_parse_selects_sc01);
RUN_TEST(test_parse_selects_jc_not_sc);
RUN_TEST(test_parse_no_matching_asset);
RUN_TEST(test_parse_skips_non_bin_asset);
RUN_TEST(test_parse_skips_non_firmware_bin_asset);
RUN_TEST(test_parse_malformed);
return UNITY_END();
}