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,4 @@
|
|||||||
|
.pio/
|
||||||
|
.vscode/
|
||||||
|
*.o
|
||||||
|
*.d
|
||||||
@@ -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.
|
||||||
@@ -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`.
|
||||||
@@ -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
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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";
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user