feat: BearSSL transport (RU_USE_BEARSSL) + RuApp::appUpdaterBegin wrapper (v1.3.0)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,3 +12,5 @@ pendingReboot KEYWORD2
|
||||
ruVersionParse KEYWORD2
|
||||
ruVersionCompare KEYWORD2
|
||||
ruParseLatestRelease KEYWORD2
|
||||
appUpdaterBegin KEYWORD2
|
||||
appUpdaterPendingReboot KEYWORD2
|
||||
|
||||
+29
-8
@@ -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
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
name=ReleaseUpdater
|
||||
version=1.1.0
|
||||
version=1.3.0
|
||||
author=xPablo.cz
|
||||
maintainer=xPablo.cz <https://git.xpablo.cz/xPablo.cz>
|
||||
sentence=Self-update an ESP32 from GitHub/Gitea releases.
|
||||
|
||||
+225
-2
@@ -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 <WiFi.h>
|
||||
#include <WiFiClient.h>
|
||||
#include <ESP_SSLClient.h>
|
||||
#include <Update.h>
|
||||
#include <esp_heap_caps.h>
|
||||
#include <functional>
|
||||
#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<bool(const uint8_t*, size_t)>& 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<bool(const uint8_t*, size_t)>& 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<bool(long)>& onBegin,
|
||||
const std::function<bool(const uint8_t*, size_t)>& 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<char*>(heap_caps_malloc(CAP, MALLOC_CAP_SPIRAM));
|
||||
if (!body) body = static_cast<char*>(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<uint8_t*>(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 <WiFiClientSecure.h>
|
||||
#include <WiFiClient.h>
|
||||
#include <HTTPClient.h>
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/** @file RuAppUpdater.cpp — reusable wrapper over ReleaseUpdaterService (see AppUpdater.hpp). */
|
||||
#include "RuAppUpdater.hpp"
|
||||
#if defined(ARDUINO)
|
||||
#include <cstdio>
|
||||
|
||||
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<uint32_t>(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
|
||||
@@ -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 <Arduino.h>
|
||||
#include <functional>
|
||||
|
||||
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<bool()> idleReady; ///< true when it is OK to install now.
|
||||
std::function<void(const std::function<void()>&)> withNetGuard; ///< run body under the app's TLS lock.
|
||||
std::function<void(const char* tag)> overlayShow; ///< show the install overlay (release tag).
|
||||
std::function<void(int pct)> overlayProgress; ///< update the progress bar (0..100).
|
||||
std::function<void()> overlayDone; ///< mark flashed (about to reboot).
|
||||
std::function<void()> overlayHide; ///< remove the overlay (on error).
|
||||
std::function<void(LogLevel, const char*)> 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
|
||||
Reference in New Issue
Block a user