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 <tag>.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
2026-10-06 15:20:58 +02:00
co-authored by Claude Sonnet 5.5
parent b0e8226943
commit 5754ae5b57
12 changed files with 616 additions and 3 deletions
+3 -1
View File
@@ -23,7 +23,7 @@ const RowDef kRows[] = {
{Row::Gnss, Kind::Toggle, "GNSS"}, {Row::GnssQuiet, Kind::Toggle, "Pause GNSS for LoRa"}, {Row::Gnss, Kind::Toggle, "GNSS"}, {Row::GnssQuiet, Kind::Toggle, "Pause GNSS for LoRa"},
{Row::Coordinates, Kind::Toggle, "Coordinates"}, {Row::Coordinates, Kind::Toggle, "Coordinates"},
{Row::ProbeMacs, Kind::Toggle, "Probe MACs"}, {Row::Wifi, Kind::Page, "Wi-Fi"}, {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"}, {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::Sound: return settings_.getBool(Setting::Sound) ? "On" : "Off";
case Row::Gnss: return settings_.getBool(Setting::GnssEnabled) ? "On" : "Off"; case Row::Gnss: return settings_.getBool(Setting::GnssEnabled) ? "On" : "Off";
case Row::GnssQuiet: return settings_.getBool(Setting::GnssQuietForLora) ? "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::Coordinates: return settings_.getBool(Setting::CoordinatesDms) ? "Deg min sec" : "Decimal";
case Row::ProbeMacs: return settings_.getBool(Setting::ProbeMacRaw) ? "Raw" : "Pseudonymised"; case Row::ProbeMacs: return settings_.getBool(Setting::ProbeMacRaw) ? "Raw" : "Pseudonymised";
case Row::Wifi: return settings_.getBool(Setting::WifiEnabled) ? "On" : "Off"; 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::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::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::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::Coordinates) settings_.setBool(Setting::CoordinatesDms, !settings_.getBool(Setting::CoordinatesDms));
if (row(i) == Row::ProbeMacs) settings_.setBool(Setting::ProbeMacRaw, !settings_.getBool(Setting::ProbeMacRaw)); if (row(i) == Row::ProbeMacs) settings_.setBool(Setting::ProbeMacRaw, !settings_.getBool(Setting::ProbeMacRaw));
} }
+1 -1
View File
@@ -11,7 +11,7 @@ namespace roro {
// values, choice lists and validation messages. Rendering and navigation live in the App. // values, choice lists and validation messages. Rendering and navigation live in the App.
class SettingsMenu { class SettingsMenu {
public: 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 }; enum class Kind { Text, Choice, Toggle, Slider, Page };
explicit SettingsMenu(Settings& settings) : settings_(settings) {} explicit SettingsMenu(Settings& settings) : settings_(settings) {}
+1
View File
@@ -40,6 +40,7 @@ const Definition kDefinitions[] = {
{"ntp1", Kind::String, 0, "pool.ntp.org", 1, 63}, {"ntp1", Kind::String, 0, "pool.ntp.org", 1, 63},
{"ntp2", Kind::String, 0, "time.cloudflare.com", 0, 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) {"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<size_t>(Setting::Count), static_assert(sizeof(kDefinitions) / sizeof(kDefinitions[0]) == static_cast<size_t>(Setting::Count),
"every Setting needs a definition"); "every Setting needs a definition");
+1
View File
@@ -30,6 +30,7 @@ enum class Setting : uint8_t {
Ntp1, // string: the first NTP server, a host name or an IPv4 address (S1, Q110) Ntp1, // string: the first NTP server, a host name or an IPv4 address (S1, Q110)
Ntp2, // string: the second, or empty Ntp2, // string: the second, or empty
GnssQuietForLora, // bool: put the GNSS receiver in standby while the LoRa radio listens (issue #20) 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 Count
}; };
+63
View File
@@ -38,6 +38,7 @@
#include "services/clock_service.h" #include "services/clock_service.h"
#include "services/debug_console.h" #include "services/debug_console.h"
#include "services/file_ops.h" #include "services/file_ops.h"
#include "update_check.h"
#include "services/gemini_service.h" #include "services/gemini_service.h"
#include "services/gnss_service.h" #include "services/gnss_service.h"
#include "services/power_service.h" #include "services/power_service.h"
@@ -182,6 +183,7 @@ void setup() {
wifi = new WifiService(settings, *savedNetworks, *clockService); wifi = new WifiService(settings, *savedNetworks, *clockService);
update = new UpdateService(nvs, *wifi, *savedNetworks, *storageService, bus, settings); update = new UpdateService(nvs, *wifi, *savedNetworks, *storageService, bus, settings);
fileOps = new FileOps(*storageService, filesInUse); fileOps = new FileOps(*storageService, filesInUse);
update->enableDailyCheck();
irc = new IrcService(nvs, "roro_" + identity::defaultShortName(), *wifi, *storageService, *clockService, bus); irc = new IrcService(nvs, "roro_" + identity::defaultShortName(), *wifi, *storageService, *clockService, bus);
notifier = new Notifier(bus, settings); notifier = new Notifier(bus, settings);
notifier->onShow = [](uint32_t now, uint32_t until) { power->onNotification(now, until); }; 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); 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 <tag>");
}
}
static void fileCommand(const String& line) { static void fileCommand(const String& line) {
int space = line.indexOf(' '); int space = line.indexOf(' ');
std::string command = line.substring(0, space).c_str(), rest = line.substring(space + 1).c_str(); 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 <url> fetch a Gemini page and report header, size, certificate, heap\n" "gemini get <url> fetch a Gemini page and report header, size, certificate, heap\n"
"irc start | irc stop | irc dump | irc say <buffer> <text>\n" "irc start | irc stop | irc dump | irc say <buffer> <text>\n"
"install <path.ota> Update from SD\n" "install <path.ota> Update from SD\n"
"update check | list | status | install <tag> the project's releases on Gitea\n"
"sd card | sd list | cat <path> | log <text> | burst | sound on|off | short | normal\n" "sd card | sd list | cat <path> | log <text> | burst | sound on|off | short | normal\n"
#ifdef RORO_DEBUG #ifdef RORO_DEBUG
"crash abort|wdt crash on purpose (to test crash reports and Safe Mode)\n" "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<esp_log_level_t>(constrain(level, 0, 5))); esp_log_level_set("*", static_cast<esp_log_level_t>(constrain(level, 0, 5)));
console.printf("log level: %d\n", 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 ") || if (line.startsWith("cp ") || line.startsWith("mv ") || line.startsWith("rm ") || line.startsWith("mkdir ") ||
line.startsWith("du ") || line == "cancel") line.startsWith("du ") || line == "cancel")
fileCommand(line); fileCommand(line);
@@ -1010,6 +1072,7 @@ static void loopPass() {
if (noiseTest) noiseTest->step(now); if (noiseTest) noiseTest->step(now);
#endif #endif
noteStableOnce(now); noteStableOnce(now);
updateStep();
fileOpsStep(); fileOpsStep();
uploadStep(); uploadStep();
printListingWhenReady(); printListingWhenReady();
+62
View File
@@ -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
+112
View File
@@ -0,0 +1,112 @@
#include "platform/https_get.h"
#include <Arduino.h>
#include <esp_heap_caps.h>
#include <algorithm>
#include <ctime>
#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<const char*>(raw_.get()), n);
if (parser.failed()) return host + " didn't answer with HTTP";
if (parser.complete() && used < static_cast<size_t>(n)) early_.assign(reinterpret_cast<const char*>(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<size_t>(want, left_);
int n;
if (earlyAt_ < early_.size()) {
n = static_cast<int>(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<int>(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
+47
View File
@@ -0,0 +1,47 @@
#pragma once
#include <NetworkClientSecure.h>
#include <memory>
#include <string>
#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<NetworkClientSecure> tls_;
release::HttpHead head_;
release::ChunkedDecoder chunks_;
std::unique_ptr<uint8_t[]> 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
+137
View File
@@ -0,0 +1,137 @@
#include "services/gitea_releases.h"
#include <memory>
#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<release::Release>& 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<uint8_t[]> 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<const char*>(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<release::Release> 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<release::Release> got;
if (!fetch(api("?limit=" + std::to_string(kListSize) + "&draft=false&pre-release=false"), kListSize, got)) return false;
std::vector<release::Release> 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<release::Release> 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
+47
View File
@@ -0,0 +1,47 @@
#pragma once
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <string>
#include <vector>
#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<release::Release> 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<release::Release>& 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<release::Release> list_;
};
} // namespace roro
+109 -1
View File
@@ -7,6 +7,10 @@
#include <memory> #include <memory>
#include <ctime>
#include "http_head.h"
#include "platform/https_get.h"
#include "platform/ota_device.h" #include "platform/ota_device.h"
#include "platform/system_info.h" #include "platform/system_info.h"
#include "probation.h" #include "probation.h"
@@ -60,6 +64,19 @@ class FileSource : public UpdateSource {
File& f_; 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 } // namespace
UpdateService::UpdateService(KeyValueStore& store, WifiService& wifi, SavedNetworks& saved, StorageService& storage, UpdateService::UpdateService(KeyValueStore& store, WifiService& wifi, SavedNetworks& saved, StorageService& storage,
@@ -85,10 +102,11 @@ void UpdateService::start() {
store_.getString("ota_from", from); store_.getString("ota_from", from);
if (!pending.empty() && pending != versionString()) { if (!pending.empty() && pending != versionString()) {
notify("Update to " + pending + " failed, back on " + versionString(), NotificationLevel::Warning); 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", ""); 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) { void UpdateService::bootGuard(KeyValueStore& store) {
@@ -106,6 +124,7 @@ void UpdateService::bootGuard(KeyValueStore& store) {
} }
void UpdateService::tick(uint32_t nowMs) { void UpdateService::tick(uint32_t nowMs) {
scheduleDailyCheck(nowMs);
if (!probation_) return; if (!probation_) return;
bool wifiConfigured = settings_.getBool(Setting::WifiEnabled) && saved_.count() > 0; bool wifiConfigured = settings_.getBool(Setting::WifiEnabled) && saved_.count() > 0;
bool wifiUp = wifi_.state() == WifiController::State::Connected; 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<int32_t>(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<int32_t>(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<int32_t>(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<uint32_t>(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<UpdateService*>(self)->listen(); } void UpdateService::taskEntry(void* self) { static_cast<UpdateService*>(self)->listen(); }
void UpdateService::listen() { void UpdateService::listen() {
@@ -208,6 +315,7 @@ void UpdateService::listen() {
esp_restart(); esp_restart();
} }
} }
if (phase_ == Phase::Idle) serve();
if (listening && phase_ == Phase::Idle) { if (listening && phase_ == Phase::Idle) {
Counted<NetworkClient> client(server.accept(), net::User::Updates); Counted<NetworkClient> client(server.accept(), net::User::Updates);
if (client) { if (client) {
+33
View File
@@ -4,9 +4,13 @@
#include <string> #include <string>
#include "update_check.h"
#include "version.h"
#include "event_bus.h" #include "event_bus.h"
#include "key_value_store.h" #include "key_value_store.h"
#include "service.h" #include "service.h"
#include "services/gitea_releases.h"
#include "services/storage_service.h" #include "services/storage_service.h"
#include "services/wifi_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). // Installs an Update File from the SD card (runs on the storage task).
void installFromSd(const std::string& path); 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). // The main loop calls this once it has drawn a frame (part of Probation).
void firstFrameDrawn() { firstFrame_ = true; } void firstFrameDrawn() { firstFrame_ = true; }
// True when an installed update is waiting to reboot; the main loop reboots when it's safe. // True when an installed update is waiting to reboot; the main loop reboots when it's safe.
bool rebootPending() const { return phase_ == Phase::Installed; } bool rebootPending() const { return phase_ == Phase::Installed; }
private: 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); static void taskEntry(void* self);
void listen(); void listen();
void install(class UpdateSource& source, const char* via); void install(class UpdateSource& source, const char* via);
@@ -62,6 +88,13 @@ class UpdateService : public Service {
std::string incoming_; std::string incoming_;
bool probation_ = false; bool probation_ = false;
volatile bool firstFrame_ = 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 } // namespace roro