commit 59add9eac793495c448f5b2ea2912f180bb00950 Author: Pavel Brychta Date: Wed Jul 15 14:02:41 2026 +0200 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) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..75c36ea --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.pio/ +.vscode/ +*.o +*.d diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6e0fe7b --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e09a33b --- /dev/null +++ b/README.md @@ -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 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 + +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& 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 + +// 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`. diff --git a/keywords.txt b/keywords.txt new file mode 100644 index 0000000..866425f --- /dev/null +++ b/keywords.txt @@ -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 diff --git a/library.json b/library.json new file mode 100644 index 0000000..35fda78 --- /dev/null +++ b/library.json @@ -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 } +} diff --git a/library.properties b/library.properties new file mode 100644 index 0000000..37b9008 --- /dev/null +++ b/library.properties @@ -0,0 +1,10 @@ +name=ReleaseUpdater +version=1.1.0 +author=xPablo.cz +maintainer=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 diff --git a/src/ReleaseUpdater.cpp b/src/ReleaseUpdater.cpp new file mode 100644 index 0000000..8a26e59 --- /dev/null +++ b/src/ReleaseUpdater.cpp @@ -0,0 +1,89 @@ +/** @file ReleaseUpdater.cpp — ESP-only implementation (guarded so the native build skips it). */ +#include "ReleaseUpdater.hpp" +#if defined(ARDUINO) +#include +#include +#include +#include +#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 diff --git a/src/ReleaseUpdater.hpp b/src/ReleaseUpdater.hpp new file mode 100644 index 0000000..60e5773 --- /dev/null +++ b/src/ReleaseUpdater.hpp @@ -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 +#include + +/** 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 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 _progress; +}; +#endif // ARDUINO diff --git a/src/ReleaseUpdaterCore.hpp b/src/ReleaseUpdaterCore.hpp new file mode 100644 index 0000000..f48ea13 --- /dev/null +++ b/src/ReleaseUpdaterCore.hpp @@ -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 +#include +#include +#include + +/** 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 ab (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(); + 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(); + for (JsonObjectConst a : assets) { + const char* name = a["name"].as(); + const char* url = a["browser_download_url"].as(); + 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; +} diff --git a/src/ReleaseUpdaterService.cpp b/src/ReleaseUpdaterService.cpp new file mode 100644 index 0000000..cee29d7 --- /dev/null +++ b/src/ReleaseUpdaterService.cpp @@ -0,0 +1,97 @@ +/** @file ReleaseUpdaterService.cpp — background self-update loop; see ReleaseUpdaterService.hpp. */ +#include "ReleaseUpdaterService.hpp" +#if defined(ARDUINO) +#include +#include + +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& 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(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 diff --git a/src/ReleaseUpdaterService.hpp b/src/ReleaseUpdaterService.hpp new file mode 100644 index 0000000..a5e2697 --- /dev/null +++ b/src/ReleaseUpdaterService.hpp @@ -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 +#include + +/** 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 idleReady; + /** Lifecycle notifications for UI/logging. Default: no-op. */ + std::function 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& f){ MyTlsLock lock; f(); }`. */ + std::function&)> 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 diff --git a/src/ru_ca_roots.h b/src/ru_ca_roots.h new file mode 100644 index 0000000..d129129 --- /dev/null +++ b/src/ru_ca_roots.h @@ -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" +"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\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"; diff --git a/test/test_release_updater/test_release_updater.cpp b/test/test_release_updater/test_release_updater.cpp new file mode 100644 index 0000000..44cd60c --- /dev/null +++ b/test/test_release_updater/test_release_updater.cpp @@ -0,0 +1,129 @@ +/** @file Native unit tests for ReleaseUpdaterCore (version + release/asset parsing). */ +#include +#include "ReleaseUpdaterCore.hpp" +#include + +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(); +}