From 510ce42a99c1e4cff33dcee78cf5811528ec1ce8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Martin?= Date: Tue, 6 Oct 2026 15:51:10 +0200 Subject: [PATCH] Updates from Gitea, step 3: install from Gitea, and IRC steps aside for it A release downloads straight into the inactive slot through the existing install path: the signature is checked after 160 bytes, before anything is written, the hash at the end. Tried on the device against the real server: a download cut short, a flipped byte in the signature and one in the image are each refused with the running firmware untouched; the real v0.10.0 installed, restarted, and confirmed itself on Probation. A TLS connection to Gitea peaks at about 52 KB of heap whether or not the certificate is verified. With IRC connected (66 KB free) a check left 3 KB and a download 836 bytes. A check, list or install a person asks for now makes IRC step aside (holdForUpdate) and come back after: the lowest free heap during a full download with IRC connected is 38 KB. The daily check never interrupts IRC; with IRC up it waits. A TLS connection starts with 80 KB free (it was 55). Debug Builds get test knobs: update probe , update damage cut|flip, update pretend . Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT --- src/apps/irc_app.cpp | 2 +- src/main.cpp | 18 ++++++ src/platform/https_get.cpp | 3 +- src/platform/https_get.h | 4 ++ src/services/gitea_releases.h | 1 + src/services/irc_service.cpp | 8 +++ src/services/irc_service.h | 6 ++ src/services/update_service.cpp | 55 +++++++++++++++++-- src/services/update_service.h | 20 ++++++- .../test_settings_menu/test_settings_menu.cpp | 13 +++++ 10 files changed, 120 insertions(+), 10 deletions(-) diff --git a/src/apps/irc_app.cpp b/src/apps/irc_app.cpp index 6635c10..098453b 100644 --- a/src/apps/irc_app.cpp +++ b/src/apps/irc_app.cpp @@ -16,7 +16,7 @@ const char* statusText(Status s) { case Status::Connecting: return "connecting..."; case Status::Registering: return "logging in..."; case Status::Online: return ""; - case Status::Paused: return "paused (Wi-Fi monitoring)"; + case Status::Paused: return "paused (Wi-Fi monitoring or an update)"; case Status::Retrying: return "retrying soon"; } return ""; diff --git a/src/main.cpp b/src/main.cpp index c306086..3a5ad6b 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -184,6 +184,7 @@ void setup() { update = new UpdateService(nvs, *wifi, *savedNetworks, *storageService, bus, settings); fileOps = new FileOps(*storageService, filesInUse); update->enableDailyCheck(); + update->holdMemory = [](bool hold) { irc->holdForUpdate(hold); }; // see UpdateService::holdMemory 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); }; @@ -466,6 +467,13 @@ static void printReleases(bool list) { static void updateStep() { if (!updateWatch || update->giteaBusy()) return; +#ifdef RORO_DEBUG + if (updateWatch == 3) { + console.printf("update: probe: %s\n", update->probeResult().c_str()); + updateWatch = 0; + return; + } +#endif printReleases(updateWatch == 2); updateWatch = 0; } @@ -492,6 +500,16 @@ static void updateCommand(const String& args) { 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("probe ")) { // update probe [path]: is that server's certificate accepted? + String rest = args.substring(6); + int space = rest.indexOf(' '); + update->requestProbe((space < 0 ? rest : rest.substring(0, space)).c_str(), space < 0 ? "/" : rest.substring(space + 1).c_str()); + updateWatch = 3; + } else if (args.startsWith("damage ")) { // update damage cut | flip : for the next download + long n = args.substring(args.lastIndexOf(' ') + 1).toInt(); + args.startsWith("damage cut") ? update->damageNextDownload(n, -1) : update->damageNextDownload(-1, n); + console.printf("update: the next download will be %s at byte %ld\n", args.startsWith("damage cut") ? "cut" : "damaged", n); + } 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()); diff --git a/src/platform/https_get.cpp b/src/platform/https_get.cpp index ae371d4..22d1c86 100644 --- a/src/platform/https_get.cpp +++ b/src/platform/https_get.cpp @@ -12,7 +12,6 @@ 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. @@ -30,7 +29,7 @@ std::string whyNotConnected(NetworkClientSecure& tls, const std::string& host) { 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"; + if (esp_get_free_heap_size() < kNeedFree) return "Not enough memory for a secure connection"; tls_.setCACert(kTrustedRootsPem); tls_.setTimeout(15); diff --git a/src/platform/https_get.h b/src/platform/https_get.h index 3cffcc8..bb904dc 100644 --- a/src/platform/https_get.h +++ b/src/platform/https_get.h @@ -16,6 +16,10 @@ namespace roro { // release. Redirects aren't followed: the device only goes where it was told to (Q163). class HttpsGet { public: + // A TLS connection to Gitea peaks at about 52 KB of heap (measured: the same with or without + // checking the certificate); with Q86's 20 KB to spare, it starts with at least this much free. + static constexpr size_t kNeedFree = 80 * 1024; + explicit HttpsGet(net::User user) : tls_(user) {} ~HttpsGet() { close(); } diff --git a/src/services/gitea_releases.h b/src/services/gitea_releases.h index a07c338..483880d 100644 --- a/src/services/gitea_releases.h +++ b/src/services/gitea_releases.h @@ -24,6 +24,7 @@ class GiteaReleases { bool fetchLatest(); bool fetchList(); + void fail(const std::string& error) { finish(false, error); } // something stopped it before it began Status status() const; std::string error() const; uint32_t seq() const; // grows with every answer that changed something diff --git a/src/services/irc_service.cpp b/src/services/irc_service.cpp index 63733dd..d4d7654 100644 --- a/src/services/irc_service.cpp +++ b/src/services/irc_service.cpp @@ -224,6 +224,14 @@ void IrcService::loop() { if (!wanted_) { if (open_) close("disconnected"); status_ = Status::Stopped; + } else if (held_) { + if (open_) { + conn_->print("QUIT :updating\r\n"); + vTaskDelay(pdMS_TO_TICKS(300)); + close("paused while the device updates"); + } + status_ = Status::Paused; + retryAtMs_ = now; // back at once when released } else if (!wifiUp) { if (open_) close(monitoring ? "paused for Wi-Fi monitoring" : "Wi-Fi lost"); status_ = monitoring ? Status::Paused : Status::WaitingForWifi; diff --git a/src/services/irc_service.h b/src/services/irc_service.h index 690c958..d806c38 100644 --- a/src/services/irc_service.h +++ b/src/services/irc_service.h @@ -38,6 +38,11 @@ class IrcService : public Service { void disconnect(); bool running() const { return wanted_; } bool stoppedByUser() const { return stoppedByUser_; } + + // A TLS connection to Gitea needs more memory than is left beside this one (#6, Q172): the + // Update Service asks IRC to step aside while it talks to the server, and to come back after. + // Unlike disconnect(), nothing is remembered as "stopped by the user". + void holdForUpdate(bool hold) { held_ = hold; } Status status() const { return status_; } // Read or act on the session while holding its lock: withSession([](IrcSession& s) { ... }). @@ -86,6 +91,7 @@ class IrcService : public Service { volatile bool stopRequested_ = false; bool quitSent_ = false; // /quit already queued its QUIT volatile bool restart_ = false; + volatile bool held_ = false; // stepping aside for an update volatile Status status_ = Status::Stopped; bool open_ = false; uint32_t retryAtMs_ = 0; diff --git a/src/services/update_service.cpp b/src/services/update_service.cpp index 355a98c..3ee0a63 100644 --- a/src/services/update_service.cpp +++ b/src/services/update_service.cpp @@ -67,15 +67,21 @@ class FileSource : public UpdateSource { // 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); } + GiteaSource(HttpsGet& get, long cutAfter, long flipAt) : get_(get), cut_(cutAfter), flip_(flipAt) {} + int read(uint8_t* buf, size_t len) override { + if (cut_ >= 0 && pos_ >= cut_) return -1; // Debug Builds: the connection "breaks" here + int n = get_.read(buf, len); + if (n > 0 && flip_ >= pos_ && flip_ < pos_ + n) buf[flip_ - pos_] ^= 1; // and a byte "arrives wrong" + if (n > 0) pos_ += n; + return n; + } private: HttpsGet& get_; + long cut_, flip_, pos_ = 0; }; constexpr time_t kClockSetAfter = 1700000000; // anything earlier is the clock's default -constexpr size_t kCheckFloor = 55 * 1024; // Q86, Q172 } // namespace @@ -106,7 +112,7 @@ void UpdateService::start() { store_.putString("ota_pending", ""); } - if (!task_) xTaskCreate(taskEntry, "update", 8192, this, 1, &task_); // TLS to Gitea (#6): measured below + if (!task_) xTaskCreate(taskEntry, "update", 7168, this, 1, &task_); // peak 5.4 KB: TLS to Gitea and the signature check (#6) } void UpdateService::bootGuard(KeyValueStore& store) { @@ -235,7 +241,8 @@ void UpdateService::scheduleDailyCheck(uint32_t nowMs) { nextCheckMs_ = nowMs + 3600000; // look again in an hour, in case the day has turned return; } - if (esp_get_free_heap_size() < kCheckFloor) { + // Never at IRC's expense: with IRC connected there isn't the room (Q172), so this waits. + if (esp_get_free_heap_size() < HttpsGet::kNeedFree) { nextCheckMs_ = nowMs + 600000; return; } @@ -243,11 +250,31 @@ void UpdateService::scheduleDailyCheck(uint32_t nowMs) { request_ = Request::BackgroundCheck; } +// What a person asked for (a check, a list, an install) may take IRC offline for a few seconds: +// a TLS connection needs about 80 KB and IRC's own holds 40 KB of what there is (R1, Q172). +bool UpdateService::makeRoom() { + if (esp_get_free_heap_size() >= HttpsGet::kNeedFree) return true; + if (!holdMemory) return false; + held_ = true; + holdMemory(true); + for (int i = 0; i < 40 && esp_get_free_heap_size() < HttpsGet::kNeedFree; i++) delay(100); // its TLS session goes + return esp_get_free_heap_size() >= HttpsGet::kNeedFree; +} + void UpdateService::serve() { Request r = request_; if (r == Request::None) return; request_ = Request::None; serving_ = true; + held_ = false; + if (r != Request::BackgroundCheck && r != Request::None) { + bool ok = makeRoom(); + if (!ok) { + gitea_.fail("Not enough memory: close a Gemini page"); + if (r == Request::Install) notify("Update refused: not enough memory", NotificationLevel::Warning); + r = Request::None; + } + } switch (r) { case Request::Check: case Request::BackgroundCheck: { @@ -268,8 +295,19 @@ void UpdateService::serve() { if (gitea_.find(installTag_, release)) installFromGitea(release); break; } +#ifdef RORO_DEBUG + case Request::Probe: { + HttpsGet get(net::User::Updates); + std::string why = get.open(probeHost_, probePath_, "*/*"); + probeResult_ = why.empty() ? "accepted, the server answered 200" : why; + break; + } +#endif case Request::None: break; } + // IRC comes back, unless the device is about to restart into an update. + if (held_ && phase_ != Phase::Installed && holdMemory) holdMemory(false); + held_ = false; serving_ = false; } @@ -287,7 +325,12 @@ void UpdateService::installFromGitea(const release::Release& r) { notify("Update refused: " + why, NotificationLevel::Warning); return; } - GiteaSource source(get); +#ifdef RORO_DEBUG + GiteaSource source(get, damageCut_, damageFlip_); + damageCut_ = damageFlip_ = -1; +#else + GiteaSource source(get, -1, -1); +#endif install(source, "Gitea"); } diff --git a/src/services/update_service.h b/src/services/update_service.h index d06c098..839e1f6 100644 --- a/src/services/update_service.h +++ b/src/services/update_service.h @@ -2,6 +2,7 @@ #include +#include #include #include "update_check.h" @@ -53,12 +54,22 @@ class UpdateService : public Service { // 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_; } + // Asked to make room for a TLS connection (R1, Q172): IRC steps aside while the Update Service + // talks to Gitea. Set by the main loop; `true` to step aside, `false` to come back. + std::function holdMemory; // 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 +#ifdef RORO_DEBUG + // Debug Builds only, to try the refusals on the real network path: one HTTPS GET to any host + // (is its certificate accepted?), and a download cut short or with one byte flipped. + void requestProbe(const std::string& host, const std::string& path) { probeHost_ = host; probePath_ = path; probeResult_ = "..."; request_ = Request::Probe; } + std::string probeResult() const { return probeResult_; } + void damageNextDownload(long cutAfter, long flipAt) { damageCut_ = cutAfter; damageFlip_ = flipAt; } +#endif // The main loop calls this once it has drawn a frame (part of Probation). void firstFrameDrawn() { firstFrame_ = true; } @@ -66,9 +77,10 @@ class UpdateService : public Service { bool rebootPending() const { return phase_ == Phase::Installed; } private: - enum class Request : uint8_t { None, Check, BackgroundCheck, List, Install }; + enum class Request : uint8_t { None, Check, BackgroundCheck, List, Install, Probe }; void serve(); // on this task: one request + bool makeRoom(); // true when a TLS connection can start; may have asked holdMemory void installFromGitea(const release::Release& release); void scheduleDailyCheck(uint32_t nowMs); // from tick() static void taskEntry(void* self); @@ -92,9 +104,15 @@ class UpdateService : public Service { GiteaReleases gitea_; volatile Request request_ = Request::None; volatile bool serving_ = false; + bool held_ = false; // IRC was asked to step aside, on this task std::string installTag_, fakeVersion_, announced_; bool dailyCheck_ = false; uint32_t nextCheckMs_ = 90000; // not before the device has settled +#ifdef RORO_DEBUG + std::string probeHost_, probePath_; + volatile long damageCut_ = -1, damageFlip_ = -1; + std::string probeResult_; +#endif }; } // namespace roro diff --git a/test/test_settings_menu/test_settings_menu.cpp b/test/test_settings_menu/test_settings_menu.cpp index 7506742..7ed777c 100644 --- a/test/test_settings_menu/test_settings_menu.cpp +++ b/test/test_settings_menu/test_settings_menu.cpp @@ -120,6 +120,18 @@ void test_pause_gnss_for_lora_is_off_by_default() { TEST_ASSERT_TRUE(f.settings.getBool(Setting::GnssEnabled)); // the GNSS switch itself is untouched } +// R1, Q165: the device looks for a newer release once a day. It installs nothing by itself, so it +// is on unless switched off. +void test_check_for_updates_is_on_by_default() { + Fixture f; + int row = f.row(SettingsMenu::Row::CheckUpdates); + TEST_ASSERT_EQUAL_STRING("Check for updates", f.menu.label(row).c_str()); + TEST_ASSERT_EQUAL_STRING("Daily", f.menu.value(row).c_str()); + f.menu.toggle(row); + TEST_ASSERT_FALSE(f.settings.getBool(Setting::CheckUpdates)); + TEST_ASSERT_EQUAL_STRING("Off", f.menu.value(row).c_str()); +} + void test_gnss_and_coordinate_rows_toggle() { Fixture f; int gnss = f.row(SettingsMenu::Row::Gnss), coords = f.row(SettingsMenu::Row::Coordinates); @@ -145,6 +157,7 @@ int main() { RUN_TEST(test_wifi_is_a_page); RUN_TEST(test_names_are_text_rows_with_their_byte_limits); RUN_TEST(test_pause_gnss_for_lora_is_off_by_default); + RUN_TEST(test_check_for_updates_is_on_by_default); RUN_TEST(test_gnss_and_coordinate_rows_toggle); return UNITY_END(); }