diff --git a/keywords.txt b/keywords.txt index 866425f..3c4af2a 100644 --- a/keywords.txt +++ b/keywords.txt @@ -12,3 +12,5 @@ pendingReboot KEYWORD2 ruVersionParse KEYWORD2 ruVersionCompare KEYWORD2 ruParseLatestRelease KEYWORD2 +appUpdaterBegin KEYWORD2 +appUpdaterPendingReboot KEYWORD2 diff --git a/library.json b/library.json index 35fda78..3026ec0 100644 --- a/library.json +++ b/library.json @@ -1,12 +1,33 @@ { "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" }], + "version": "1.3.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. Optional RU_USE_BEARSSL transport (ESP_SSLClient, PSRAM TLS buffers) for memory-tight devices.", + "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 } -} + "platforms": [ + "espressif32" + ], + "dependencies": [ + { + "name": "ArduinoJson", + "version": "^7.0.0" + } + ], + "build": { + "libArchive": false + } +} \ No newline at end of file diff --git a/library.properties b/library.properties index 37b9008..67941de 100644 --- a/library.properties +++ b/library.properties @@ -1,5 +1,5 @@ name=ReleaseUpdater -version=1.1.0 +version=1.3.0 author=xPablo.cz maintainer=xPablo.cz sentence=Self-update an ESP32 from GitHub/Gitea releases. diff --git a/src/ReleaseUpdater.cpp b/src/ReleaseUpdater.cpp index 8a26e59..ce254db 100644 --- a/src/ReleaseUpdater.cpp +++ b/src/ReleaseUpdater.cpp @@ -1,6 +1,227 @@ -/** @file ReleaseUpdater.cpp — ESP-only implementation (guarded so the native build skips it). */ +/** @file ReleaseUpdater.cpp — ESP-only implementation (guarded so the native build skips it). + * + * Two TLS transports: + * - default: WiFiClientSecure (mbedTLS) + HTTPClient/HTTPUpdate. Simple, but mbedTLS keeps its ~16 KB + * RX/TX record buffers in INTERNAL RAM as two contiguous blocks — too much for a heavily loaded, + * fragmented device. + * - RU_USE_BEARSSL: ESP_SSLClient (BearSSL) with RX/TX buffers in PSRAM + a hand-rolled HTTP client + * that streams the firmware straight into Update. Frees internal RAM (matches this codebase's + * IcsClient pattern). Used on memory-tight devices (e.g. WorkspaceBuddy/InfoAssist). + */ #include "ReleaseUpdater.hpp" #if defined(ARDUINO) + +#if defined(RU_USE_BEARSSL) +// ================= BearSSL + PSRAM transport ================= +#include +#include +#include +#include +#include +#include +#include "ru_ca_roots.h" + +// PSRAM TLS buffer sizes. BearSSL advertises Maximum Fragment Length (MFLN) derived from the RX buffer, +// so a smaller RX means smaller TLS records when the server supports MFLN — override with -D on +// memory-critical builds. The buffers themselves live in PSRAM, so the default is comfortable. +#ifndef RU_BEARSSL_RX +# define RU_BEARSSL_RX 4096 +#endif +#ifndef RU_BEARSSL_TX +# define RU_BEARSSL_TX 2048 +#endif + +namespace { + +struct RuUrl { String host; String path; uint16_t port; bool https; }; + +bool ruParseUrl(const String& url, RuUrl& out) { + int s; + if (url.startsWith("https://")) { out.https = true; out.port = 443; s = 8; } + else if (url.startsWith("http://")) { out.https = false; out.port = 80; s = 7; } + else return false; + const int slash = url.indexOf('/', s); + String hostport = slash < 0 ? url.substring(s) : url.substring(s, slash); + out.path = slash < 0 ? "/" : url.substring(slash); + const int colon = hostport.indexOf(':'); + if (colon < 0) { out.host = hostport; } + else { out.host = hostport.substring(0, colon); out.port = (uint16_t) hostport.substring(colon + 1).toInt(); } + return out.host.length() > 0; +} + +/** Read one CRLF-terminated line (without the trailing CRLF). false on timeout. */ +bool ruReadLine(Client& c, String& out, uint32_t toMs) { + out = ""; + const uint32_t t0 = millis(); + while (millis() - t0 < toMs) { + const int ch = c.read(); + if (ch < 0) { delay(1); continue; } + if (ch == '\n') { if (out.endsWith("\r")) out.remove(out.length() - 1); return true; } + out += (char) ch; + if (out.length() > 4096) return true; // header safety cap + } + return false; +} + +/** Stream exactly `want` bytes from `c` to `sink`. Returns true iff all bytes were delivered. */ +bool ruReadExactToSink(Client& c, size_t want, const std::function& sink, + uint32_t toMs) { + static uint8_t buf[1460]; + size_t got = 0; + uint32_t last = millis(); + while (got < want) { + const size_t chunk = (want - got) < sizeof(buf) ? (want - got) : sizeof(buf); + const int n = c.read(buf, chunk); + if (n <= 0) { + if (millis() - last > toMs) return false; + delay(1); + continue; + } + last = millis(); + if (!sink(buf, (size_t) n)) return false; + got += (size_t) n; + } + return true; +} + +/** Stream a chunked-transfer-encoded body to `sink` (hex size line, data, CRLF, ... until 0). */ +bool ruReadChunkedToSink(Client& c, const std::function& sink, uint32_t toMs) { + for (;;) { + String szline; + if (!ruReadLine(c, szline, toMs)) return false; + const int semi = szline.indexOf(';'); // strip any chunk extension + if (semi >= 0) szline = szline.substring(0, semi); + szline.trim(); + const long sz = strtol(szline.c_str(), nullptr, 16); + if (sz < 0) return false; + if (sz == 0) { String trailer; ruReadLine(c, trailer, toMs); return true; } // final CRLF/trailers + if (!ruReadExactToSink(c, (size_t) sz, sink, toMs)) return false; + String crlf; + if (!ruReadLine(c, crlf, toMs)) return false; // CRLF after the chunk data + } +} + +/** + * @brief Hand-rolled HTTPS GET over BearSSL (PSRAM buffers, CA-validated). Follows up to 5 redirects. + * On a 200 with Content-Length, calls onBegin(len) once, then streams the body to onData(). + * @return true on a fully-delivered 200 body; false otherwise (err set). + */ +bool ruBsslGet(const String& startUrl, const RuConfig& cfg, + const std::function& onBegin, + const std::function& onData, + String& err) { + String url = startUrl; + for (int hop = 0; hop <= 5; ++hop) { + RuUrl u; + if (!ruParseUrl(url, u)) { err = "bad url"; return false; } + + WiFiClient base; + ESP_SSLClient ssl; + ssl.setClient(&base); + if (u.https) ssl.setCACert(RU_CA_ROOTS); // validate the chain (Let's-Encrypt/ISRG etc.) + ssl.setBufferSizes(RU_BEARSSL_RX, RU_BEARSSL_TX); // PSRAM IO buffers; RX size drives MFLN + ssl.setTimeout(20000); + if (!ssl.connect(u.host.c_str(), u.port)) { err = "TLS connect failed"; ssl.stop(); return false; } + + // keep-alive: with Connection: close BearSSL can lose the final record to the close_notify race + // (diagnosed in IcsClient). We read exactly Content-Length bytes and stop() ourselves. + ssl.print("GET "); ssl.print(u.path); ssl.print(" HTTP/1.1\r\n"); + ssl.print("Host: "); ssl.print(u.host); ssl.print("\r\n"); + ssl.print("User-Agent: "); ssl.print(cfg.userAgent); ssl.print("\r\n"); + ssl.print("Accept: application/octet-stream, application/vnd.github+json, */*\r\n"); + ssl.print("Accept-Encoding: identity\r\nConnection: keep-alive\r\n\r\n"); + + String line; + if (!ruReadLine(ssl, line, 20000) || line.length() < 12) { err = "no status line"; ssl.stop(); return false; } + const int sp = line.indexOf(' '); + const int code = sp > 0 ? line.substring(sp + 1, sp + 4).toInt() : 0; + + String location; + long contentLength = -1; + bool chunked = false; + for (;;) { + if (!ruReadLine(ssl, line, 20000)) { err = "header read failed"; ssl.stop(); return false; } + if (line.length() == 0) break; + String lower = line; lower.toLowerCase(); + if (lower.startsWith("location:")) location = line.substring(9); + else if (lower.startsWith("content-length:")) contentLength = strtol(line.substring(15).c_str(), nullptr, 10); + else if (lower.startsWith("transfer-encoding:") && lower.indexOf("chunked") >= 0) chunked = true; + } + + if (code >= 300 && code < 400 && location.length()) { + ssl.stop(); + location.trim(); + url = location.startsWith("http") ? location : ("https://" + u.host + location); + continue; // follow redirect (fresh TLS session) + } + if (code != 200) { err = "HTTP " + String(code); ssl.stop(); return false; } + if (!chunked && contentLength < 0) { err = "no content-length"; ssl.stop(); return false; } + + // onBegin needs a size to reserve the OTA partition; the API (check) is chunked with no + // onBegin, but a firmware asset download is always Content-Length framed, so this is safe. + if (onBegin && !onBegin(contentLength)) { err = "begin rejected"; ssl.stop(); return false; } + const bool ok = chunked ? ruReadChunkedToSink(ssl, onData, 20000) + : ruReadExactToSink(ssl, (size_t) contentLength, onData, 20000); + ssl.stop(); + if (!ok) { err = "short read"; return false; } + return true; + } + err = "too many redirects"; + return false; +} + +} // namespace + +bool ReleaseUpdater::check(RuRelease& rel, String& err) { + // Accumulate the (small) releases/latest JSON into a PSRAM buffer, then parse. + constexpr size_t CAP = 12 * 1024; + char* body = static_cast(heap_caps_malloc(CAP, MALLOC_CAP_SPIRAM)); + if (!body) body = static_cast(heap_caps_malloc(CAP, MALLOC_CAP_8BIT)); + if (!body) { err = "check: alloc failed"; return false; } + size_t len = 0; + auto sink = [&](const uint8_t* d, size_t n) -> bool { + if (len + n >= CAP) n = CAP - 1 - len; // truncate defensively; tag_name/assets are near the top + memcpy(body + len, d, n); len += n; + return true; + }; + const bool ok = ruBsslGet(_latestUrl(), _cfg, nullptr, sink, err); + if (ok) body[len] = '\0'; + bool parsed = false; + if (ok) parsed = ruParseLatestRelease(body, _cfg.assetToken.c_str(), rel); + heap_caps_free(body); + if (!ok) return false; + if (!parsed) { 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; } + bool begun = false; + size_t total = 0, written = 0; + auto onBegin = [&](long len) -> bool { + if (len <= 0) return false; + total = (size_t) len; + if (!Update.begin(total)) return false; // reserves the OTA partition (verifies size fits) + begun = true; + return true; + }; + auto onData = [&](const uint8_t* d, size_t n) -> bool { + if (Update.write(const_cast(d), n) != n) return false; + written += n; + if (_progress) _progress(written, total); + return true; + }; + const bool ok = ruBsslGet(String(rel.assetUrl), _cfg, onBegin, onData, err); + if (!ok) { if (begun) Update.abort(); return false; } + if (!Update.end(true)) { // finalises + verifies the image before switching + err = "Update.end failed (" + String(Update.getError()) + ")"; + return false; + } + return true; // caller reboots +} + +#else // !RU_USE_BEARSSL +// ================= WiFiClientSecure (mbedTLS) + HTTPClient/HTTPUpdate transport ================= #include #include #include @@ -86,4 +307,6 @@ bool ReleaseUpdater::downloadAndFlash(const RuRelease& rel, String& err) { err = "update failed: " + httpUpdate.getLastErrorString() + " (" + String(httpUpdate.getLastError()) + ")"; return false; // running firmware unchanged (HTTPUpdate verifies before switching) } -#endif // ARDUINO + +#endif // RU_USE_BEARSSL +#endif // ARDUINO diff --git a/src/RuAppUpdater.cpp b/src/RuAppUpdater.cpp new file mode 100644 index 0000000..76c1c35 --- /dev/null +++ b/src/RuAppUpdater.cpp @@ -0,0 +1,81 @@ +/** @file RuAppUpdater.cpp — reusable wrapper over ReleaseUpdaterService (see AppUpdater.hpp). */ +#include "RuAppUpdater.hpp" +#if defined(ARDUINO) +#include + +namespace RuApp { + +namespace { + ReleaseUpdaterService s_service; + + /** Fire the app log sink with a printf-formatted "[ota] ..." line, if a sink is set. */ + void logf(const AppHooks& h, LogLevel lvl, const char* fmt, ...) { + if (!h.log) return; + char buf[128]; + va_list ap; + va_start(ap, fmt); + vsnprintf(buf, sizeof(buf), fmt, ap); + va_end(ap); + h.log(lvl, buf); + } +} + +void appUpdaterBegin(const AppConfig& cfg, const AppHooks& hooks) { + if (!cfg.enabled) { logf(hooks, LogLevel::Warning, "[ota] disabled"); return; } + if (!cfg.owner || cfg.owner[0] == '\0' || !cfg.repo || cfg.repo[0] == '\0') { + logf(hooks, LogLevel::Warning, "[ota] enabled but owner/repo empty -> idle"); + return; + } + + RuServiceConfig sc; + sc.ru.apiBase = cfg.apiBase; + sc.ru.owner = cfg.owner; + sc.ru.repo = cfg.repo; + sc.ru.assetToken = cfg.assetToken; + sc.ru.current = cfg.current; + sc.ru.userAgent = cfg.userAgent; + sc.intervalMin = static_cast(cfg.intervalMin); + sc.idleMaxWaitS = cfg.idleMaxWaitS; + + RuServiceHooks h; + if (hooks.idleReady) h.idleReady = hooks.idleReady; + if (hooks.withNetGuard) h.withNetGuard = hooks.withNetGuard; + // Standard lifecycle handler: log + drive the optional overlay. Captures the app hooks by value. + h.onState = [hooks](const RuStateEvent& e) { + switch (e.state) { + case RuState::UpToDate: + logf(hooks, LogLevel::Debug, "[ota] up to date (%s)", e.tag); + break; + case RuState::NewFound: + logf(hooks, LogLevel::Info, "[ota] new release %s -> waiting for idle", e.tag); + break; + case RuState::Installing: + logf(hooks, LogLevel::Info, "[ota] installing %s", e.tag); + if (hooks.overlayShow) hooks.overlayShow(e.tag); + break; + case RuState::Downloading: + if (e.pct % 10 == 0) logf(hooks, LogLevel::Debug, "[ota] %d%%", e.pct); + if (hooks.overlayProgress) hooks.overlayProgress(e.pct); + break; + case RuState::Flashed: + logf(hooks, LogLevel::Info, "[ota] flashed %s; rebooting", e.tag); + if (hooks.overlayDone) hooks.overlayDone(); + break; + case RuState::Error: + logf(hooks, LogLevel::Error, "[ota] error: %s", e.err); + if (hooks.overlayHide) hooks.overlayHide(); + break; + default: + break; + } + }; + + s_service.begin(sc, h); + logf(hooks, LogLevel::Info, "[ota] task started (repo %s/%s, every %d min)", + cfg.owner, cfg.repo, cfg.intervalMin); +} + +bool appUpdaterPendingReboot() { return s_service.pendingReboot(); } + +} // namespace RuApp +#endif // ARDUINO diff --git a/src/RuAppUpdater.hpp b/src/RuAppUpdater.hpp new file mode 100644 index 0000000..98b8780 --- /dev/null +++ b/src/RuAppUpdater.hpp @@ -0,0 +1,59 @@ +/** + * @file RuAppUpdater.hpp + * @brief Reusable application wrapper over ReleaseUpdaterService. Encapsulates the boilerplate every + * project repeated by hand: assemble the RuServiceConfig from plain config values, and install a + * standard onState handler that logs the lifecycle (English, "[ota] ...") and drives an optional + * full-screen progress overlay. The project supplies only its config values + a few hooks (idle + * predicate, TLS guard, overlay callbacks, log sink), so there is one implementation instead of + * one copy per firmware. + */ +#pragma once +#include "ReleaseUpdaterService.hpp" +#if defined(ARDUINO) +#include +#include + +namespace RuApp { + +/** Log severity passed to AppHooks::log (maps to the app's own trace levels). */ +enum class LogLevel { Debug = 0, Info = 1, Warning = 2, Error = 3 }; + +/** Plain config values, read by the app from wherever it stores them (struct, key/value, ...). */ +struct AppConfig { + bool enabled = false; ///< Master on/off. + const char* apiBase = "https://api.github.com"; ///< Releases API base (scheme picks transport). + const char* owner = ""; ///< Repo owner/org. + const char* repo = ""; ///< Repo name. + int intervalMin = 60; ///< Release-check period (minutes). + const char* assetToken = ""; ///< Board token (HW_VARIANT) used to pick the asset. + RuVersion current = {}; ///< Running firmware version (SW_VERSION_*). + const char* userAgent = "ESP32-OTA"; ///< HTTP User-Agent. + uint32_t idleMaxWaitS = 30u * 60u; ///< Cap on waiting for idleReady() before flashing. +}; + +/** + * @brief App-supplied hooks. Every field is optional; unset overlay/log hooks are simply not called, + * an unset idleReady means "always ready", and an unset withNetGuard runs the flash directly. + */ +struct AppHooks { + std::function idleReady; ///< true when it is OK to install now. + std::function&)> withNetGuard; ///< run body under the app's TLS lock. + std::function overlayShow; ///< show the install overlay (release tag). + std::function overlayProgress; ///< update the progress bar (0..100). + std::function overlayDone; ///< mark flashed (about to reboot). + std::function overlayHide; ///< remove the overlay (on error). + std::function log; ///< lifecycle log sink (e.g. TRACE). +}; + +/** + * @brief Assemble the service config + the standard onState (log + overlay dispatch) and start the + * background updater task. No-op (logs via hooks.log) if disabled or owner/repo are empty. + * Idempotent — a second call is ignored. Call once after the GUI, TLS gate and config are ready. + */ +void appUpdaterBegin(const AppConfig& cfg, const AppHooks& hooks); + +/** @return true once an image has been flashed (the task reboots shortly after). */ +bool appUpdaterPendingReboot(); + +} // namespace RuApp +#endif // ARDUINO