From 5754ae5b57ef9db4ec8d99727a909c40d276a0e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Martin?= Date: Tue, 6 Oct 2026 15:20:58 +0200 Subject: [PATCH] Updates from Gitea, step 2: the connection (check and list work on the device) The roots (ISRG X1 and X2), an HTTPS client that reads the answer as a stream, GiteaReleases (the latest and a list of ten), the Update Service's requests, install from Gitea through the existing install path, the daily check's schedule, the Check for updates setting, and console commands: update check | list | status | install . Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT --- lib/apps_model/src/settings_menu.cpp | 4 +- lib/apps_model/src/settings_menu.h | 2 +- lib/services/src/settings.cpp | 1 + lib/services/src/settings.h | 1 + src/main.cpp | 63 ++++++++++++ src/platform/ca_roots.h | 62 ++++++++++++ src/platform/https_get.cpp | 112 ++++++++++++++++++++++ src/platform/https_get.h | 47 +++++++++ src/services/gitea_releases.cpp | 137 +++++++++++++++++++++++++++ src/services/gitea_releases.h | 47 +++++++++ src/services/update_service.cpp | 110 ++++++++++++++++++++- src/services/update_service.h | 33 +++++++ 12 files changed, 616 insertions(+), 3 deletions(-) create mode 100644 src/platform/ca_roots.h create mode 100644 src/platform/https_get.cpp create mode 100644 src/platform/https_get.h create mode 100644 src/services/gitea_releases.cpp create mode 100644 src/services/gitea_releases.h diff --git a/lib/apps_model/src/settings_menu.cpp b/lib/apps_model/src/settings_menu.cpp index b1fab38..85fb09f 100644 --- a/lib/apps_model/src/settings_menu.cpp +++ b/lib/apps_model/src/settings_menu.cpp @@ -23,7 +23,7 @@ const RowDef kRows[] = { {Row::Gnss, Kind::Toggle, "GNSS"}, {Row::GnssQuiet, Kind::Toggle, "Pause GNSS for LoRa"}, {Row::Coordinates, Kind::Toggle, "Coordinates"}, {Row::ProbeMacs, Kind::Toggle, "Probe MACs"}, {Row::Wifi, Kind::Page, "Wi-Fi"}, - {Row::Firmware, Kind::Page, "Firmware"}, + {Row::CheckUpdates, Kind::Toggle, "Check for updates"}, {Row::Firmware, Kind::Page, "Firmware"}, {Row::About, Kind::Page, "About"}, }; @@ -82,6 +82,7 @@ std::string SettingsMenu::value(int i) const { case Row::Sound: return settings_.getBool(Setting::Sound) ? "On" : "Off"; case Row::Gnss: return settings_.getBool(Setting::GnssEnabled) ? "On" : "Off"; case Row::GnssQuiet: return settings_.getBool(Setting::GnssQuietForLora) ? "On" : "Off"; + case Row::CheckUpdates: return settings_.getBool(Setting::CheckUpdates) ? "Daily" : "Off"; case Row::Coordinates: return settings_.getBool(Setting::CoordinatesDms) ? "Deg min sec" : "Decimal"; case Row::ProbeMacs: return settings_.getBool(Setting::ProbeMacRaw) ? "Raw" : "Pseudonymised"; case Row::Wifi: return settings_.getBool(Setting::WifiEnabled) ? "On" : "Off"; @@ -128,6 +129,7 @@ void SettingsMenu::toggle(int i) { if (row(i) == Row::Sound) settings_.setBool(Setting::Sound, !settings_.getBool(Setting::Sound)); if (row(i) == Row::Gnss) settings_.setBool(Setting::GnssEnabled, !settings_.getBool(Setting::GnssEnabled)); if (row(i) == Row::GnssQuiet) settings_.setBool(Setting::GnssQuietForLora, !settings_.getBool(Setting::GnssQuietForLora)); + if (row(i) == Row::CheckUpdates) settings_.setBool(Setting::CheckUpdates, !settings_.getBool(Setting::CheckUpdates)); if (row(i) == Row::Coordinates) settings_.setBool(Setting::CoordinatesDms, !settings_.getBool(Setting::CoordinatesDms)); if (row(i) == Row::ProbeMacs) settings_.setBool(Setting::ProbeMacRaw, !settings_.getBool(Setting::ProbeMacRaw)); } diff --git a/lib/apps_model/src/settings_menu.h b/lib/apps_model/src/settings_menu.h index 09eb0a4..c6d0595 100644 --- a/lib/apps_model/src/settings_menu.h +++ b/lib/apps_model/src/settings_menu.h @@ -11,7 +11,7 @@ namespace roro { // values, choice lists and validation messages. Rendering and navigation live in the App. class SettingsMenu { public: - enum class Row { LongName, ShortName, Region, Timezone, Brightness, DimTimeout, OffTimeout, Sound, Gnss, GnssQuiet, Coordinates, ProbeMacs, Wifi, Firmware, About }; + enum class Row { LongName, ShortName, Region, Timezone, Brightness, DimTimeout, OffTimeout, Sound, Gnss, GnssQuiet, Coordinates, ProbeMacs, Wifi, CheckUpdates, Firmware, About }; enum class Kind { Text, Choice, Toggle, Slider, Page }; explicit SettingsMenu(Settings& settings) : settings_(settings) {} diff --git a/lib/services/src/settings.cpp b/lib/services/src/settings.cpp index 1bb9bfa..2307281 100644 --- a/lib/services/src/settings.cpp +++ b/lib/services/src/settings.cpp @@ -40,6 +40,7 @@ const Definition kDefinitions[] = { {"ntp1", Kind::String, 0, "pool.ntp.org", 1, 63}, {"ntp2", Kind::String, 0, "time.cloudflare.com", 0, 63}, {"gnss_quiet", Kind::Bool, 0, nullptr, 0, 1}, // off: GNSS stays on, as decided in M2 (Q58) + {"check_updates", Kind::Bool, 1, nullptr, 0, 1}, // on: it only looks, and says so (R1, Q165) }; static_assert(sizeof(kDefinitions) / sizeof(kDefinitions[0]) == static_cast(Setting::Count), "every Setting needs a definition"); diff --git a/lib/services/src/settings.h b/lib/services/src/settings.h index fc5d158..303f34c 100644 --- a/lib/services/src/settings.h +++ b/lib/services/src/settings.h @@ -30,6 +30,7 @@ enum class Setting : uint8_t { Ntp1, // string: the first NTP server, a host name or an IPv4 address (S1, Q110) Ntp2, // string: the second, or empty GnssQuietForLora, // bool: put the GNSS receiver in standby while the LoRa radio listens (issue #20) + CheckUpdates, // bool: look for a newer release on Gitea once a day (R1, Q165) Count }; diff --git a/src/main.cpp b/src/main.cpp index bd6a677..c306086 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -38,6 +38,7 @@ #include "services/clock_service.h" #include "services/debug_console.h" #include "services/file_ops.h" +#include "update_check.h" #include "services/gemini_service.h" #include "services/gnss_service.h" #include "services/power_service.h" @@ -182,6 +183,7 @@ void setup() { wifi = new WifiService(settings, *savedNetworks, *clockService); update = new UpdateService(nvs, *wifi, *savedNetworks, *storageService, bus, settings); fileOps = new FileOps(*storageService, filesInUse); + update->enableDailyCheck(); irc = new IrcService(nvs, "roro_" + identity::defaultShortName(), *wifi, *storageService, *clockService, bus); notifier = new Notifier(bus, settings); notifier->onShow = [](uint32_t now, uint32_t until) { power->onNotification(now, until); }; @@ -441,6 +443,64 @@ static void fileOpsStep() { else console.printf("%s: ok\n", name); } +// `update ...`: the project's releases on Gitea (R1, #6). The answers come from the Update Service's +// task a moment later; updateStep() prints them. +static int updateWatch = 0; // 1: a check, 2: a list + +static void printReleases(bool list) { + GiteaReleases& g = update->gitea(); + if (g.status() == GiteaReleases::Status::Failed) return (void)console.printf("update: %s\n", g.error().c_str()); + std::string running = update->runningVersion(); + if (!list) { + release::Release r; + if (!g.latest(r)) return (void)console.println("update: nothing known yet"); + console.printf("update: latest %s of %s, %u bytes: %s (running %s)\n", r.tag.c_str(), r.date().c_str(), (unsigned)r.otaSize, + release::isNewer(r, running) ? "newer" : "not newer", running.c_str()); + console.printf("update: %s\n", r.notes.c_str()); + return; + } + for (auto& r : g.list()) + console.printf("update: %-8s %s %8u B %s\n", r.tag.c_str(), r.date().c_str(), (unsigned)r.otaSize, r.notes.substr(0, 60).c_str()); + console.println("update: end of list"); +} + +static void updateStep() { + if (!updateWatch || update->giteaBusy()) return; + printReleases(updateWatch == 2); + updateWatch = 0; +} + +static void updateCommand(const String& args) { + if (args == "check" || args == "list") { + if (update->giteaBusy()) return (void)console.println("update: busy"); + args == "check" ? update->requestCheck() : update->requestList(); + updateWatch = args == "check" ? 1 : 2; + } else if (args == "status") { + GiteaReleases& g = update->gitea(); + console.printf("update: running %s, failed here before: %s, daily check %s, heap %u\n", update->runningVersion().c_str(), + update->failedVersion().empty() ? "none" : update->failedVersion().c_str(), + settings.getBool(Setting::CheckUpdates) ? "on" : "off", (unsigned)ESP.getFreeHeap()); + console.printf("update: gitea %s %s\n", g.status() == GiteaReleases::Status::Done ? "done" : g.status() == GiteaReleases::Status::Failed ? "failed" : g.status() == GiteaReleases::Status::Busy ? "busy" : "never asked", g.error().c_str()); + printReleases(false); + } else if (args.startsWith("install ")) { + String rest = args.substring(8); + bool force = rest.endsWith(" force"); + if (force) rest.remove(rest.length() - 6); +#ifndef RORO_DEBUG + force = false; // only the Debug Console may install on a Debug Build, which a release isn't +#endif + std::string why = update->requestInstall(rest.c_str(), force); + console.printf("update: %s\n", why.empty() ? "installing" : why.c_str()); +#ifdef RORO_DEBUG + } else if (args.startsWith("pretend ")) { // update pretend v0.9.0 | off: what the comparisons take as running + update->pretendVersion(args.substring(8) == "off" ? "" : args.substring(8).c_str()); + console.printf("update: running %s\n", update->runningVersion().c_str()); +#endif + } else { + console.println("update: check | list | status | install "); + } +} + static void fileCommand(const String& line) { int space = line.indexOf(' '); std::string command = line.substring(0, space).c_str(), rest = line.substring(space + 1).c_str(); @@ -507,6 +567,7 @@ static const char* const kHelp = "gemini get fetch a Gemini page and report header, size, certificate, heap\n" "irc start | irc stop | irc dump | irc say \n" "install Update from SD\n" + "update check | list | status | install the project's releases on Gitea\n" "sd card | sd list | cat | log | burst | sound on|off | short | normal\n" #ifdef RORO_DEBUG "crash abort|wdt crash on purpose (to test crash reports and Safe Mode)\n" @@ -571,6 +632,7 @@ static void runCommand(String line) { esp_log_level_set("*", static_cast(constrain(level, 0, 5))); console.printf("log level: %d\n", constrain(level, 0, 5)); } + if (line.startsWith("update ")) updateCommand(line.substring(7)); if (line.startsWith("cp ") || line.startsWith("mv ") || line.startsWith("rm ") || line.startsWith("mkdir ") || line.startsWith("du ") || line == "cancel") fileCommand(line); @@ -1010,6 +1072,7 @@ static void loopPass() { if (noiseTest) noiseTest->step(now); #endif noteStableOnce(now); + updateStep(); fileOpsStep(); uploadStep(); printListingWhenReady(); diff --git a/src/platform/ca_roots.h b/src/platform/ca_roots.h new file mode 100644 index 0000000..52ca9ee --- /dev/null +++ b/src/platform/ca_roots.h @@ -0,0 +1,62 @@ +#pragma once + +namespace roro { + +// The roots this device trusts for what it fetches over HTTPS (docs/milestones/R1.md, Q162): the two +// ISRG roots Let's Encrypt chains end in, not the framework's bundle of about 130 CAs. Public +// certificates, from https://letsencrypt.org/certs/ (SHA-256 fingerprints below). +// +// ISRG Root X1 RSA 4096 valid until 2035-06-04 +// 96:BC:EC:06:26:49:76:F3:74:60:77:9A:CF:28:C5:A7:CF:E8:A3:C0:AA:E1:1A:8F:FC:EE:05:C0:BD:DF:08:C6 +// ISRG Root X2 ECDSA P-384 valid until 2040-09-17 +// 69:72:9B:8E:15:A8:6E:FC:17:7A:57:AF:B7:17:1D:FC:64:AD:D2:8C:2F:CA:8C:F1:50:7E:34:45:3C:CB:14:70 +// +// If the server's CA ever changes, the next firmware has to carry the new root and come from the PC. +inline constexpr char kTrustedRootsPem[] = + "-----BEGIN CERTIFICATE-----\n" + "MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw\n" + "TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh\n" + "cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4\n" + "WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu\n" + "ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY\n" + "MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc\n" + "h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+\n" + "0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U\n" + "A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW\n" + "T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH\n" + "B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC\n" + "B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv\n" + "KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn\n" + "OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn\n" + "jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw\n" + "qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI\n" + "rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV\n" + "HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq\n" + "hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL\n" + "ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ\n" + "3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK\n" + "NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5\n" + "ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur\n" + "TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC\n" + "jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc\n" + "oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq\n" + "4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA\n" + "mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d\n" + "emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=\n" + "-----END CERTIFICATE-----\n" + "-----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"; + +} // namespace roro diff --git a/src/platform/https_get.cpp b/src/platform/https_get.cpp new file mode 100644 index 0000000..ae371d4 --- /dev/null +++ b/src/platform/https_get.cpp @@ -0,0 +1,112 @@ +#include "platform/https_get.h" + +#include +#include + +#include +#include + +#include "platform/ca_roots.h" +#include "version.h" + +namespace roro { + +namespace { +constexpr size_t kFloor = 55 * 1024; // Q86, Q172: no connection below this +constexpr time_t kClockSetAfter = 1700000000; // 2023-11: anything earlier is the clock's default + +// What mbedTLS says in the way it says it, for the cases a person can act on. +std::string whyNotConnected(NetworkClientSecure& tls, const std::string& host) { + char text[100] = ""; + int err = tls.lastError(text, sizeof text); + std::string detail = text; + if (detail.find("X509") != std::string::npos || detail.find("certificate") != std::string::npos) + return "The certificate of " + host + " isn't accepted"; + if (err != 0 && !detail.empty()) return "TLS to " + host + ": " + detail; + return "Can't connect to " + host; +} +} // namespace + +std::string HttpsGet::open(const std::string& host, const std::string& path, const char* accept) { + close(); + if (time(nullptr) < kClockSetAfter) return "The clock isn't set yet: certificates can't be checked"; + if (esp_get_free_heap_size() < kFloor) return "Not enough memory: close IRC or a Gemini page"; + + tls_.setCACert(kTrustedRootsPem); + tls_.setTimeout(15); + if (!tls_.connect(host.c_str(), 443)) return whyNotConnected(tls_, host); + + raw_.reset(new (std::nothrow) uint8_t[kRaw]); + if (!raw_) return "Not enough memory"; + tls_.print(("GET " + path + " HTTP/1.1\r\nHost: " + host + "\r\nUser-Agent: roro9stack/" + versionString() + + "\r\nAccept: " + accept + "\r\nAccept-Encoding: identity\r\nConnection: close\r\n\r\n") + .c_str()); + + release::HttpHeadParser parser; + while (!parser.complete()) { + int n = readRaw(raw_.get(), kRaw); + if (n <= 0) return "The server " + host + " stopped answering"; + size_t used = parser.feed(reinterpret_cast(raw_.get()), n); + if (parser.failed()) return host + " didn't answer with HTTP"; + if (parser.complete() && used < static_cast(n)) early_.assign(reinterpret_cast(raw_.get()) + used, n - used); + } + head_ = parser.head(); + if (head_.status != 200) return host + " answered " + std::to_string(head_.status) + (head_.status / 100 == 3 ? " (a redirect)" : ""); + left_ = head_.chunked ? -1 : head_.contentLength; + return ""; +} + +int HttpsGet::readRaw(uint8_t* into, size_t len) { + uint32_t since = millis(); + while (!tls_.available()) { + if (!tls_.connected()) return 0; + if (millis() - since > kStallMs) return -1; + delay(5); + } + return tls_.read(into, len); +} + +int HttpsGet::read(uint8_t* buf, size_t len) { + if (done_ || len == 0) return 0; + if (!head_.chunked && left_ == 0) return done_ = true, 0; + + for (;;) { + // Raw bytes: first those that came with the head, then the connection's. + size_t want = head_.chunked ? std::min(len, kRaw) : len; + if (!head_.chunked && left_ > 0) want = std::min(want, left_); + int n; + if (earlyAt_ < early_.size()) { + n = static_cast(std::min(want, early_.size() - earlyAt_)); + std::copy(early_.data() + earlyAt_, early_.data() + earlyAt_ + n, head_.chunked ? raw_.get() : buf); + earlyAt_ += n; + } else { + n = readRaw(head_.chunked ? raw_.get() : buf, want); + } + if (n < 0) return -1; + if (n == 0) { // the server closed: the end, if it's where it said it would be + done_ = true; + bool whole = head_.chunked ? chunks_.done() : left_ <= 0; + return whole ? 0 : -1; + } + if (!head_.chunked) { + if (left_ > 0) left_ -= n; + return n; + } + size_t out = chunks_.decode(raw_.get(), n, buf); + if (chunks_.failed()) return -1; + if (out > 0) return static_cast(out); + if (chunks_.done()) return done_ = true, 0; + } +} + +void HttpsGet::close() { + tls_.stop(); + raw_.reset(); + std::string().swap(early_); + earlyAt_ = 0; + done_ = false; + head_ = release::HttpHead(); + chunks_ = release::ChunkedDecoder(); +} + +} // namespace roro diff --git a/src/platform/https_get.h b/src/platform/https_get.h new file mode 100644 index 0000000..3cffcc8 --- /dev/null +++ b/src/platform/https_get.h @@ -0,0 +1,47 @@ +#pragma once + +#include + +#include +#include + +#include "http_head.h" +#include "platform/counted_client.h" + +namespace roro { + +// One HTTPS GET, read as a stream: the connection checks the server's chain and name against the +// roots in ca_roots.h, the head is parsed, and the body comes out whole whether the server sent it +// with a length or in chunks. Used by the Update Service to read Gitea's answers and to download a +// release. Redirects aren't followed: the device only goes where it was told to (Q163). +class HttpsGet { + public: + explicit HttpsGet(net::User user) : tls_(user) {} + ~HttpsGet() { close(); } + + // Connects and reads the head. "" when a 200 is on its way, or why not, in words for the screen. + std::string open(const std::string& host, const std::string& path, const char* accept); + long contentLength() const { return head_.contentLength; } // -1: not known + + // Body bytes into `buf`: how many, 0 at the end, -1 when the connection broke or stalled + // before the end. + int read(uint8_t* buf, size_t len); + void close(); + + private: + static constexpr size_t kRaw = 1024; + static constexpr uint32_t kStallMs = 10000; + + int readRaw(uint8_t* into, size_t len); // from the connection, waiting up to kStallMs + + Counted tls_; + release::HttpHead head_; + release::ChunkedDecoder chunks_; + std::unique_ptr raw_; + std::string early_; // body bytes that arrived with the head + size_t earlyAt_ = 0; + long left_ = -1; // body bytes still to come, if the server said how many + bool done_ = false; +}; + +} // namespace roro diff --git a/src/services/gitea_releases.cpp b/src/services/gitea_releases.cpp new file mode 100644 index 0000000..2b147fd --- /dev/null +++ b/src/services/gitea_releases.cpp @@ -0,0 +1,137 @@ +#include "services/gitea_releases.h" + +#include + +#include "platform/https_get.h" +#include "update_check.h" + +namespace roro { + +namespace { +struct Guard { + explicit Guard(SemaphoreHandle_t l) : l_(l) { xSemaphoreTake(l_, portMAX_DELAY); } + ~Guard() { xSemaphoreGive(l_); } + SemaphoreHandle_t l_; +}; + +std::string api(const std::string& what) { return std::string("/api/v1/repos/") + release::kGiteaRepo + "/releases" + what; } +} // namespace + +bool GiteaReleases::fetch(const std::string& path, size_t max, std::vector& out) { + HttpsGet get(net::User::Updates); + std::string why = get.open(release::kGiteaHost, path, "application/json"); + if (!why.empty()) { + finish(false, why); + return false; + } + release::ReleaseReader reader(max); + std::unique_ptr buf(new (std::nothrow) uint8_t[512]); + if (!buf) { + finish(false, "Not enough memory"); + return false; + } + for (;;) { + int n = get.read(buf.get(), 512); + if (n < 0) { + finish(false, "The connection broke off"); + return false; + } + if (n == 0) break; + reader.feed(reinterpret_cast(buf.get()), n); + if (!reader.ok()) break; + } + reader.end(); + if (!reader.ok() || !reader.complete()) { + finish(false, "Gitea's answer isn't what was expected"); + return false; + } + out = reader.releases(); + return true; +} + +void GiteaReleases::finish(bool ok, const std::string& error) { + Guard g(lock_); + status_ = ok ? Status::Done : Status::Failed; + error_ = error; + seq_++; +} + +bool GiteaReleases::fetchLatest() { + { + Guard g(lock_); + status_ = Status::Busy; + error_.clear(); + } + std::vector got; + if (!fetch(api("/latest"), 1, got)) return false; + if (got.empty() || !got[0].usable()) { + finish(false, "No release to install on the server"); + return false; + } + { + Guard g(lock_); + latest_ = got[0]; + haveLatest_ = true; + } + finish(true, ""); + return true; +} + +bool GiteaReleases::fetchList() { + { + Guard g(lock_); + status_ = Status::Busy; + error_.clear(); + } + std::vector got; + if (!fetch(api("?limit=" + std::to_string(kListSize) + "&draft=false&pre-release=false"), kListSize, got)) return false; + std::vector usable; + for (auto& r : got) + if (r.usable()) usable.push_back(std::move(r)); + { + Guard g(lock_); + list_ = std::move(usable); + if (!list_.empty() && (!haveLatest_ || release::versionNewer(list_[0].tag, latest_.tag))) { + latest_ = list_[0]; + haveLatest_ = true; + } + } + finish(true, ""); + return true; +} + +GiteaReleases::Status GiteaReleases::status() const { + Guard g(lock_); + return status_; +} + +std::string GiteaReleases::error() const { + Guard g(lock_); + return error_; +} + +uint32_t GiteaReleases::seq() const { + Guard g(lock_); + return seq_; +} + +bool GiteaReleases::latest(release::Release& out) const { + Guard g(lock_); + if (haveLatest_) out = latest_; + return haveLatest_; +} + +std::vector GiteaReleases::list() const { + Guard g(lock_); + return list_; +} + +bool GiteaReleases::find(const std::string& tag, release::Release& out) const { + Guard g(lock_); + for (const auto& r : list_) + if (r.tag == tag) return out = r, true; + if (haveLatest_ && latest_.tag == tag) return out = latest_, true; + return false; +} + +} // namespace roro diff --git a/src/services/gitea_releases.h b/src/services/gitea_releases.h new file mode 100644 index 0000000..a07c338 --- /dev/null +++ b/src/services/gitea_releases.h @@ -0,0 +1,47 @@ +#pragma once + +#include +#include + +#include +#include + +#include "release_info.h" + +namespace roro { + +// What the device knows of the project's releases on Gitea (docs/milestones/R1.md, #6): the latest, +// and a list of the last ten. The fetching runs on the Update Service's task; the screens read what +// came out, from the main loop. +class GiteaReleases { + public: + enum class Status : uint8_t { Never, Busy, Done, Failed }; + static constexpr size_t kListSize = 10; + + GiteaReleases() : lock_(xSemaphoreCreateMutex()) {} + + // On the Update Service's task. True when the answer was read; otherwise error() says why. + bool fetchLatest(); + bool fetchList(); + + Status status() const; + std::string error() const; + uint32_t seq() const; // grows with every answer that changed something + bool latest(release::Release& out) const; // false: not fetched yet + std::vector list() const; + bool find(const std::string& tag, release::Release& out) const; // in the list, or the latest + + private: + bool fetch(const std::string& path, size_t max, std::vector& out); + void finish(bool ok, const std::string& error); + + mutable SemaphoreHandle_t lock_; + Status status_ = Status::Never; + std::string error_; + uint32_t seq_ = 0; + bool haveLatest_ = false; + release::Release latest_; + std::vector list_; +}; + +} // namespace roro diff --git a/src/services/update_service.cpp b/src/services/update_service.cpp index 9a2f209..355a98c 100644 --- a/src/services/update_service.cpp +++ b/src/services/update_service.cpp @@ -7,6 +7,10 @@ #include +#include + +#include "http_head.h" +#include "platform/https_get.h" #include "platform/ota_device.h" #include "platform/system_info.h" #include "probation.h" @@ -60,6 +64,19 @@ class FileSource : public UpdateSource { File& f_; }; +// A release being downloaded from Gitea: the same bytes a push or a card would give. +class GiteaSource : public UpdateSource { + public: + explicit GiteaSource(HttpsGet& get) : get_(get) {} + int read(uint8_t* buf, size_t len) override { return get_.read(buf, len); } + + private: + HttpsGet& get_; +}; + +constexpr time_t kClockSetAfter = 1700000000; // anything earlier is the clock's default +constexpr size_t kCheckFloor = 55 * 1024; // Q86, Q172 + } // namespace UpdateService::UpdateService(KeyValueStore& store, WifiService& wifi, SavedNetworks& saved, StorageService& storage, @@ -85,10 +102,11 @@ void UpdateService::start() { store_.getString("ota_from", from); if (!pending.empty() && pending != versionString()) { notify("Update to " + pending + " failed, back on " + versionString(), NotificationLevel::Warning); + store_.putString("ota_failed", pending); // not announced again until there's a newer one (Q168) store_.putString("ota_pending", ""); } - if (!task_) xTaskCreate(taskEntry, "update", 5120, this, 1, &task_); // peak 3.4 KB (ECDSA check, M2) + if (!task_) xTaskCreate(taskEntry, "update", 8192, this, 1, &task_); // TLS to Gitea (#6): measured below } void UpdateService::bootGuard(KeyValueStore& store) { @@ -106,6 +124,7 @@ void UpdateService::bootGuard(KeyValueStore& store) { } void UpdateService::tick(uint32_t nowMs) { + scheduleDailyCheck(nowMs); if (!probation_) return; bool wifiConfigured = settings_.getBool(Setting::WifiEnabled) && saved_.count() > 0; bool wifiUp = wifi_.state() == WifiController::State::Connected; @@ -184,6 +203,94 @@ void UpdateService::installFromSd(const std::string& path) { }); } +std::string UpdateService::failedVersion() const { + std::string failed; + store_.getString("ota_failed", failed); + return failed; +} + +std::string UpdateService::requestInstall(const std::string& tag, bool force) { + if (phase_ != Phase::Idle || giteaBusy()) return "Busy with something else"; + if (!force && release::isDebugBuild(versionString())) return "A Debug Build keeps its console: update it from your PC"; + if (wifi_.state() != WifiController::State::Connected) return "No Wi-Fi"; + release::Release r; + if (!gitea_.find(tag, r)) return "Look for releases first"; + if (!release::trustedAssetUrl(r.otaUrl)) return "That download isn't on the project's server"; + installTag_ = tag; + request_ = Request::Install; + return ""; +} + +// Once a day while Wi-Fi is up and the Clock is set (Q165). Skipped, and tried again later, when +// something else is going on or memory is short; never during Probation or an install. +void UpdateService::scheduleDailyCheck(uint32_t nowMs) { + if (!dailyCheck_ || !settings_.getBool(Setting::CheckUpdates)) return; + if (static_cast(nowMs - nextCheckMs_) < 0 || probation_ || phase_ != Phase::Idle || giteaBusy()) return; + if (wifi_.state() != WifiController::State::Connected) return; + time_t now = time(nullptr); + if (now < kClockSetAfter) return; + int32_t today = static_cast(now / 86400), last = 0; + store_.getInt("rel_day", last); + if (today == last) { + nextCheckMs_ = nowMs + 3600000; // look again in an hour, in case the day has turned + return; + } + if (esp_get_free_heap_size() < kCheckFloor) { + nextCheckMs_ = nowMs + 600000; + return; + } + nextCheckMs_ = nowMs + 1800000; // if this one fails + request_ = Request::BackgroundCheck; +} + +void UpdateService::serve() { + Request r = request_; + if (r == Request::None) return; + request_ = Request::None; + serving_ = true; + switch (r) { + case Request::Check: + case Request::BackgroundCheck: { + if (!gitea_.fetchLatest()) break; + time_t now = time(nullptr); + if (now >= kClockSetAfter) store_.putInt("rel_day", static_cast(now / 86400)); + release::Release latest; + if (r == Request::BackgroundCheck && gitea_.latest(latest) && + release::shouldAnnounce(latest, runningVersion(), failedVersion(), announced_)) { + announced_ = latest.tag; + notify("Update " + latest.tag + " available: see Settings > Firmware", NotificationLevel::Info); + } + break; + } + case Request::List: gitea_.fetchList(); break; + case Request::Install: { + release::Release release; + if (gitea_.find(installTag_, release)) installFromGitea(release); + break; + } + case Request::None: break; + } + serving_ = false; +} + +void UpdateService::installFromGitea(const release::Release& r) { + release::Url url = release::parseUrl(r.otaUrl); + incoming_ = r.tag; + percent_ = 0; + phase_ = Phase::Receiving; + HttpsGet get(net::User::Updates); + std::string why = get.open(url.host, url.path, "application/octet-stream"); + if (why.empty() && r.otaSize && get.contentLength() >= 0 && static_cast(get.contentLength()) != r.otaSize) + why = "The file isn't the size the server listed"; + if (!why.empty()) { + phase_ = Phase::Idle; + notify("Update refused: " + why, NotificationLevel::Warning); + return; + } + GiteaSource source(get); + install(source, "Gitea"); +} + void UpdateService::taskEntry(void* self) { static_cast(self)->listen(); } void UpdateService::listen() { @@ -208,6 +315,7 @@ void UpdateService::listen() { esp_restart(); } } + if (phase_ == Phase::Idle) serve(); if (listening && phase_ == Phase::Idle) { Counted client(server.accept(), net::User::Updates); if (client) { diff --git a/src/services/update_service.h b/src/services/update_service.h index 066b77c..d06c098 100644 --- a/src/services/update_service.h +++ b/src/services/update_service.h @@ -4,9 +4,13 @@ #include +#include "update_check.h" +#include "version.h" + #include "event_bus.h" #include "key_value_store.h" #include "service.h" +#include "services/gitea_releases.h" #include "services/storage_service.h" #include "services/wifi_service.h" @@ -39,12 +43,34 @@ class UpdateService : public Service { // Installs an Update File from the SD card (runs on the storage task). void installFromSd(const std::string& path); + // Updates from Gitea (docs/milestones/R1.md, #6). The requests are done on this Service's task; + // the answers are read from gitea(). + GiteaReleases& gitea() { return gitea_; } + void requestCheck() { request_ = Request::Check; } + void requestList() { request_ = Request::List; } + // Downloads `tag` (a release known from the last check or list) into the inactive slot and + // installs it. "" when it started, or why not. A Debug Build doesn't install a release (Q171, + // it would take its Debug Console away) unless `force`, which only the Debug Console passes. + std::string requestInstall(const std::string& tag, bool force = false); + bool giteaBusy() const { return request_ != Request::None || serving_; } + // The daily check (Q165) only runs once the main loop says it may: not in Safe Mode. + void enableDailyCheck() { dailyCheck_ = true; } + // The version that counts as running for comparisons: a Debug Build can pretend to be older. + std::string runningVersion() const { return fakeVersion_.empty() ? versionString() : fakeVersion_; } + void pretendVersion(const std::string& v) { fakeVersion_ = v; } + std::string failedVersion() const; // a release that rolled back here, "" if none + // The main loop calls this once it has drawn a frame (part of Probation). void firstFrameDrawn() { firstFrame_ = true; } // True when an installed update is waiting to reboot; the main loop reboots when it's safe. bool rebootPending() const { return phase_ == Phase::Installed; } private: + enum class Request : uint8_t { None, Check, BackgroundCheck, List, Install }; + + void serve(); // on this task: one request + void installFromGitea(const release::Release& release); + void scheduleDailyCheck(uint32_t nowMs); // from tick() static void taskEntry(void* self); void listen(); void install(class UpdateSource& source, const char* via); @@ -62,6 +88,13 @@ class UpdateService : public Service { std::string incoming_; bool probation_ = false; volatile bool firstFrame_ = false; + + GiteaReleases gitea_; + volatile Request request_ = Request::None; + volatile bool serving_ = false; + std::string installTag_, fakeVersion_, announced_; + bool dailyCheck_ = false; + uint32_t nextCheckMs_ = 90000; // not before the device has settled }; } // namespace roro