diff --git a/README.md b/README.md index e2bc47a..862ea20 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,7 @@ With no PC and no card, the device can install the project's releases itself (do - **Latest release** checks the server (Enter, or `c`) and says `v0.11.0 (new)` or `(current)`. Enter again opens the release: its version, date, size and the tag's message, with **Install** when it's newer. The download goes straight into the inactive slot, so no card is needed; the signature is checked after the first 160 bytes, before anything is written, and the image's hash at the end. The new firmware then runs on Probation as for any update. - **Older releases** lists the last ten, newest first. Opening an older one offers to go back to it, with a different question. - **Settings → System → Check for updates** (on by default): once a day, with Wi-Fi up and the clock set, the device looks at the latest release and says `v0.11.0 is out: see Settings > System > Firmware`, once per version. It installs nothing by itself, and doesn't announce a version that already failed and rolled back on this device. +- **Settings → System → Install updates** (`When asked` by default; `By itself`, issue #52): a newer release found by the daily check is installed without being asked for. The device waits until no key was pressed for 2 minutes and nothing would be cut by a restart (a Track, a Capture, a copy, an upload, an SSH session, a web share), then asks on the screen for 30 seconds, lighting it if it was off: Cancel leaves it until the next day's check; Accept, or no answer, installs it and restarts. What was installed and when, and what was undone, is written in `/system/updates.log` on the card. The connection is checked against the two ISRG roots Let's Encrypt chains end in (ADR 0009), not the usual bundle of about 130 authorities. Whatever the connection, the Update File's own signature is what decides what gets installed. diff --git a/docs/milestones/R1.md b/docs/milestones/R1.md index 8f54df8..82fa6e9 100644 --- a/docs/milestones/R1.md +++ b/docs/milestones/R1.md @@ -1,6 +1,6 @@ # R1 — Releases -**Status:** in progress. Shipped: CI and signed releases on Gitea (issue #5), a release for every tag; updates from Gitea (#6, **v0.11.0**); one firmware with the Debug Console in it (#68, **v0.12.0**); CI in about a minute (#74, **v0.13.0**). Not started: the Issues App (#4), automatic installs (#52), release channels (#53), resuming a download (#54). +**Status:** in progress. Shipped: CI and signed releases on Gitea (issue #5), a release for every tag; updates from Gitea (#6, **v0.11.0**); one firmware with the Debug Console in it (#68, **v0.12.0**); CI in about a minute (#74, **v0.13.0**). Built, not released yet: installing a release by itself (#52). Not started: the Issues App (#4), release channels (#53), resuming a download (#54). **Goal:** a tag is a release, built the same way every time and published where a device can find it. @@ -124,6 +124,38 @@ The device looks at the project's Gitea for a newer release, says so, and instal **Two slips during the checks:** a blind sequence of keys on the Firmware page opened the SD card's install dialog (the page keeps its selection between visits); it was cancelled with Back, nothing installed. And my port-polling while waiting for a restart took the Debug Console's only client slot, which made the first install attempt look like a failure. +## Installing a release by itself (issue #52) + +For a device that sits on a desk, "always on the latest" may be what is wanted. Until now the daily check only said that a release was out. + +### Decisions (2026-10-11) + +| # | Decision | +|---|---| +| Q275 | **A setting, off by default:** Settings > System > Install updates, `When asked` or `By itself`. It changes what runs on the device without anyone looking, so it is never on unless the owner said so. | +| Q276 | **The owner is asked, and silence is yes:** a question over whatever is open, `Cancel` or `Accept`, that answers itself after 30 seconds by installing. The screen lights up for it if it was off. (The owner's own answer on the issue.) | +| Q277 | **The question comes before the download,** not before the restart: the download already takes the screen over and may take IRC offline for its memory, so that is the moment to ask. | +| Q278 | **Only in a quiet moment:** no key for 2 minutes, and nothing a restart would cut: a Track, a Capture, a file operation, an upload, an scp transfer, an SSH session, a web share, a noise test, another update. If one of them starts while the question is up, the question goes and comes back later with a full count. | +| Q279 | **Any newer release,** not only patch releases, and with no delay after it is published: Probation and Rollback are what protect against a bad one, and a version that rolled back here is never offered again. | +| Q280 | **Cancel lasts a day:** the release is offered again at the next daily check, for as long as it is the newest. | +| Q281 | **A record:** each update that held (and whether it installed by itself), and each one that was undone, is a dated line in `/system/updates.log` on the card. | + +### As built + +- **`lib/release/src/auto_update.h`**, host-tested (10 tests): the waiting, the question and its count, as a state machine that is told what "busy" and "idle" are. +- **The Update Service** offers the release at each background check when the setting is on (`shouldInstall`), and writes the record when Probation confirms a version or a rollback is reported. +- **The main loop** decides what busy is, puts the question up through `Screen::render`, and gives it every key while it is up. +- `update status` says whether a release is waiting for its quiet moment. + +### Checked on the device (2026-10-11) + +With the setting on, `update pretend v0.22.0` and `update daily`: + +- The question came up over the Settings App, counting down each second. **Cancel** took it down and nothing was installed. +- Left unanswered, the device downloaded v0.23.0, installed it and restarted into it. +- `/system/updates.log` got its line at each confirmed update. +- Not checked: "by itself" in the record (the release installed did not have this code yet), the 2 minutes without a key and the busy cases (host tests only), and a screen that was off lighting up. Keys sent through the Debug Console do not count as keys. + ## One firmware: the Debug Console in every build (issue #68) The issue asked for a token that could be set, so that Debug Builds could be published. The design round ended somewhere simpler: no Debug Builds. The console is in every firmware, off until switched on, with a token that belongs to the device (ADR 0010, which supersedes part of ADR 0004). diff --git a/docs/milestones/U1.md b/docs/milestones/U1.md index c92190a..891cd92 100644 --- a/docs/milestones/U1.md +++ b/docs/milestones/U1.md @@ -130,7 +130,7 @@ Settings had grown to 21 rows in one list. It now opens on five groups, each a s | Display | Brightness, Dim after, Screen off after, Theme, Light or dark, Launcher | | GNSS and radio | GNSS, Pause GNSS for LoRa, Coordinates, Probe MACs | | Network | Wi-Fi, VPN | -| System | Check for updates, Firmware, Debug Console, About | +| System | Check for updates, Install updates, Firmware, Debug Console, About | - Enter opens a group, Back returns to the groups with the selection on the one just left, and Back from the groups leaves Settings. An open group has its name over its rows. - `SettingsMenu` (`lib/apps_model`) holds the groups and which one is open; every index is into what is listed. Host-tested: every setting is in exactly one group, and no group has more rows than fit under its name. diff --git a/site/content/dev/build/flash.md b/site/content/dev/build/flash.md index 40e960e..9cc5f94 100644 --- a/site/content/dev/build/flash.md +++ b/site/content/dev/build/flash.md @@ -49,6 +49,7 @@ With no PC and no card, the device can install the project's releases itself (do - **Latest release** checks the server (Enter, or `c`) and says `v0.11.0 (new)` or `(current)`. Enter again opens the release: its version, date, size and the tag's message, with **Install** when it's newer. The download goes straight into the inactive slot, so no card is needed; the signature is checked after the first 160 bytes, before anything is written, and the image's hash at the end. The new firmware then runs on Probation as for any update. - **Older releases** lists the last ten, newest first. Opening an older one offers to go back to it, with a different question. - **Settings → System → Check for updates** (on by default): once a day, with Wi-Fi up and the clock set, the device looks at the latest release and says `v0.11.0 is out: see Settings > System > Firmware`, once per version. It installs nothing by itself, and doesn't announce a version that already failed and rolled back on this device. +- **Settings → System → Install updates** (`When asked` by default; `By itself`, issue #52): a newer release found by the daily check is installed without being asked for. The device waits until no key was pressed for 2 minutes and nothing would be cut by a restart (a Track, a Capture, a copy, an upload, an SSH session, a web share), then asks on the screen for 30 seconds, lighting it if it was off: Cancel leaves it until the next day's check; Accept, or no answer, installs it and restarts. What was installed and when, and what was undone, is written in `/system/updates.log` on the card. The connection is checked against the two ISRG roots Let's Encrypt chains end in (ADR 0009), not the usual bundle of about 130 authorities. Whatever the connection, the Update File's own signature is what decides what gets installed. diff --git a/site/content/dev/milestones/r1.md b/site/content/dev/milestones/r1.md index fad2694..ab59acd 100644 --- a/site/content/dev/milestones/r1.md +++ b/site/content/dev/milestones/r1.md @@ -8,7 +8,7 @@ docs = true source = "docs/milestones/R1.md" tag = "R1" +++ -**Status:** in progress. Shipped: CI and signed releases on Gitea (issue #5), a release for every tag; updates from Gitea (#6, **v0.11.0**); one firmware with the Debug Console in it (#68, **v0.12.0**); CI in about a minute (#74, **v0.13.0**). Not started: the Issues App (#4), automatic installs (#52), release channels (#53), resuming a download (#54). +**Status:** in progress. Shipped: CI and signed releases on Gitea (issue #5), a release for every tag; updates from Gitea (#6, **v0.11.0**); one firmware with the Debug Console in it (#68, **v0.12.0**); CI in about a minute (#74, **v0.13.0**). Built, not released yet: installing a release by itself (#52). Not started: the Issues App (#4), release channels (#53), resuming a download (#54). **Goal:** a tag is a release, built the same way every time and published where a device can find it. @@ -132,6 +132,38 @@ The device looks at the project's Gitea for a newer release, says so, and instal **Two slips during the checks:** a blind sequence of keys on the Firmware page opened the SD card's install dialog (the page keeps its selection between visits); it was cancelled with Back, nothing installed. And my port-polling while waiting for a restart took the Debug Console's only client slot, which made the first install attempt look like a failure. +## Installing a release by itself (issue #52) + +For a device that sits on a desk, "always on the latest" may be what is wanted. Until now the daily check only said that a release was out. + +### Decisions (2026-10-11) + +| # | Decision | +|---|---| +| Q275 | **A setting, off by default:** Settings > System > Install updates, `When asked` or `By itself`. It changes what runs on the device without anyone looking, so it is never on unless the owner said so. | +| Q276 | **The owner is asked, and silence is yes:** a question over whatever is open, `Cancel` or `Accept`, that answers itself after 30 seconds by installing. The screen lights up for it if it was off. (The owner's own answer on the issue.) | +| Q277 | **The question comes before the download,** not before the restart: the download already takes the screen over and may take IRC offline for its memory, so that is the moment to ask. | +| Q278 | **Only in a quiet moment:** no key for 2 minutes, and nothing a restart would cut: a Track, a Capture, a file operation, an upload, an scp transfer, an SSH session, a web share, a noise test, another update. If one of them starts while the question is up, the question goes and comes back later with a full count. | +| Q279 | **Any newer release,** not only patch releases, and with no delay after it is published: Probation and Rollback are what protect against a bad one, and a version that rolled back here is never offered again. | +| Q280 | **Cancel lasts a day:** the release is offered again at the next daily check, for as long as it is the newest. | +| Q281 | **A record:** each update that held (and whether it installed by itself), and each one that was undone, is a dated line in `/system/updates.log` on the card. | + +### As built + +- **`lib/release/src/auto_update.h`**, host-tested (10 tests): the waiting, the question and its count, as a state machine that is told what "busy" and "idle" are. +- **The Update Service** offers the release at each background check when the setting is on (`shouldInstall`), and writes the record when Probation confirms a version or a rollback is reported. +- **The main loop** decides what busy is, puts the question up through `Screen::render`, and gives it every key while it is up. +- `update status` says whether a release is waiting for its quiet moment. + +### Checked on the device (2026-10-11) + +With the setting on, `update pretend v0.22.0` and `update daily`: + +- The question came up over the Settings App, counting down each second. **Cancel** took it down and nothing was installed. +- Left unanswered, the device downloaded v0.23.0, installed it and restarted into it. +- `/system/updates.log` got its line at each confirmed update. +- Not checked: "by itself" in the record (the release installed did not have this code yet), the 2 minutes without a key and the busy cases (host tests only), and a screen that was off lighting up. Keys sent through the Debug Console do not count as keys. + ## One firmware: the Debug Console in every build (issue #68) The issue asked for a token that could be set, so that Debug Builds could be published. The design round ended somewhere simpler: no Debug Builds. The console is in every firmware, off until switched on, with a token that belongs to the device (ADR 0010, which supersedes part of ADR 0004). diff --git a/site/content/dev/milestones/u1.md b/site/content/dev/milestones/u1.md index f806582..00095d2 100644 --- a/site/content/dev/milestones/u1.md +++ b/site/content/dev/milestones/u1.md @@ -138,7 +138,7 @@ Settings had grown to 21 rows in one list. It now opens on five groups, each a s | Display | Brightness, Dim after, Screen off after, Theme, Light or dark, Launcher | | GNSS and radio | GNSS, Pause GNSS for LoRa, Coordinates, Probe MACs | | Network | Wi-Fi, VPN | -| System | Check for updates, Firmware, Debug Console, About | +| System | Check for updates, Install updates, Firmware, Debug Console, About | - Enter opens a group, Back returns to the groups with the selection on the one just left, and Back from the groups leaves Settings. An open group has its name over its rows. - `SettingsMenu` (`lib/apps_model`) holds the groups and which one is open; every index is into what is listed. Host-tested: every setting is in exactly one group, and no group has more rows than fit under its name. diff --git a/site/content/guide/settings.md b/site/content/guide/settings.md index bd37d8b..0a852c5 100644 --- a/site/content/guide/settings.md +++ b/site/content/guide/settings.md @@ -50,6 +50,7 @@ Inside a group, move with the arrows. On a toggle, a choice or a slider, left an | Setting | What it is | |---|---| | **Check for updates** | Once a day, see [Updates](/guide/updates/) | +| **Install updates** | `When asked` (the default), or `By itself`: a newer release is installed in a quiet moment, after a question that answers itself in 30 seconds. See [Updates](/guide/updates/) | | **Firmware** | The page described in [Updates](/guide/updates/) | | **Debug Console** | Off unless you switch it on. It lets a PC on the same network read the device's console and drive it, with a token shown on this page: see [the developer docs](/dev/debug/switch-it-on/). Leave it off if that means nothing to you | | **About** | The firmware version, the node number, battery, memory, uptime, clock and licence | diff --git a/site/content/guide/updates.md b/site/content/guide/updates.md index d244293..48ddcb9 100644 --- a/site/content/guide/updates.md +++ b/site/content/guide/updates.md @@ -21,7 +21,19 @@ An install needs Wi-Fi if it is a download. The new firmware is checked before a ## Check for updates -**Settings → System → Check for updates** is on by default. Once a day, with Wi-Fi up and the clock set, the device looks at the latest release and says `v0.11.0 is out: see Settings > System > Firmware`, once per version. It installs **nothing** by itself, and it does not announce a version that already failed and rolled back on this device. +**Settings → System → Check for updates** is on by default. Once a day, with Wi-Fi up and the clock set, the device looks at the latest release and says `v0.11.0 is out: see Settings > System > Firmware`, once per version. It installs **nothing** by itself unless you ask for that (below), and it does not announce a version that already failed and rolled back on this device. + +## Install updates by itself + +**Settings → System → Install updates** is `When asked` by default. Set to `By itself`, a newer release found by the daily check is installed without your going to the Firmware page: + +1. The device waits for a **quiet moment**: no key pressed for 2 minutes, and nothing a restart would cut short (a Track or a Capture recording, files being copied or received, an SSH session, a web share). +2. It then asks on the screen, over whatever is open, and lights the screen if it was off: `Update to v0.24.0?`, with a count from **30 seconds**. +3. **Cancel** (or `) leaves things as they are until the next day's check. **Accept**, or no answer when the count ends, downloads the release, installs it and restarts. + +Everything else is as for an install you ask for: the signature is checked before anything is written, the new firmware runs on Probation, and a version that rolled back on this device is not offered again. It needs **Check for updates** to be on. + +Each update that held, whoever started it, and each one that was undone, is written with its date in `/system/updates.log` on the SD card. ## Probation and Rollback diff --git a/src/main.cpp b/src/main.cpp index 6f3d6c0..73d0142 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -20,6 +20,7 @@ #include "services/web_share.h" #include "apps/gemini_app.h" #include "apps/gnss_app.h" +#include "auto_update.h" #include "apps/irc_app.h" #include "apps/launcher_app.h" #include "apps/lora_scanner_app.h" @@ -81,6 +82,12 @@ static Screen screen; static BatteryService* battery; static StorageService* storageService; static FileOps* fileOps; +static WebShare* webShare; +// Installing a release by itself (issue #52): see autoUpdateStep(). +static release::AutoUpdate autoUpdate; +static std::unique_ptr autoDialog; +static uint32_t lastKeyMs = 0; +static bool autoRedraw = false; static std::vector filesInUse(const std::string& path, bool folder); static PowerService* power; static ClockService* clockService; @@ -238,7 +245,7 @@ void setup() { apps->registerApp({"gnss", "GNSS", false, new GnssApp(*gnssService, settings, *storageService), icons::gnss}); apps->registerApp({"gemini", "Gemini", false, new GeminiApp(*geminiService), icons::gemini}); apps->registerApp({"lora", "LoRa Scanner", false, new LoraScannerApp(*radioService, *loraCapture, settings, *clockService), icons::lora}); - apps->registerApp({"storage", "Storage", false, new StorageApp(*fileOps, *storageService, *clockService, *update, *power, bus, *new WebShare(*storageService, *fileOps, *wifi)), icons::storage}); + apps->registerApp({"storage", "Storage", false, new StorageApp(*fileOps, *storageService, *clockService, *update, *power, bus, *(webShare = new WebShare(*storageService, *fileOps, *wifi))), icons::storage}); apps->registerApp({"notes", "Notes", false, new NotesApp(*fileOps, *storageService, *clockService, *power), icons::notes}); sshService = new SshService(settings); sshApp = new SshApp(*sshService, settings, *storageService); @@ -569,6 +576,9 @@ static void updateCommand(const String& args) { 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: installing by itself %s%s%s\n", settings.getBool(Setting::InstallUpdates) ? "on" : "off", + autoUpdate.waiting() ? ", waiting for a quiet moment to offer " : autoUpdate.asking() ? ", asking about " : "", + autoUpdate.waiting() || autoUpdate.asking() ? autoUpdate.tag().c_str() : ""); 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 ")) { @@ -849,8 +859,57 @@ static void screenshotKey() { } // Every key goes through here, from the keyboard or from a console's `key`. +// Installing a release by itself (issue #52, Settings > System > Install updates): once the daily +// check has found one, wait until nothing would be cut by a restart and no key was pressed for a +// while, then ask on the screen, over whatever is open. Left unanswered for 30 s, it is installed. + +// Would a restart now cut something short? +static bool busyForRestart() { + return gnssService->tracking() || loraCapture->capturing() || upload.active() || fileOps->busy() || + (scpService && scpService->busy()) || (sshService && sshService->active()) || (webShare && webShare->running()) || + (noiseTest && noiseTest->running()) || update->phase() != UpdateService::Phase::Idle || update->onProbation() || + update->giteaBusy(); +} + +static void autoUpdateStep(uint32_t now) { + static int shownSeconds = -1; + if (!settings.getBool(Setting::InstallUpdates) && (autoUpdate.waiting() || autoUpdate.asking())) autoUpdate.withdraw(); + std::string tag; + if (update->takeOffer(tag)) autoUpdate.offer(tag); + if (autoDialog && autoDialog->result() != DialogModel::kPending) autoUpdate.answer(autoDialog->result() == 1); + switch (autoUpdate.step(now, busyForRestart(), now - lastKeyMs)) { + case release::AutoUpdate::Action::None: break; + case release::AutoUpdate::Action::Ask: + autoDialog.reset(new DialogModel({"Cancel", "Accept"})); + power->onNotification(now, now + release::AutoUpdate::kAskMs); // a screen that is off lights up for it + autoRedraw = true; + break; + case release::AutoUpdate::Action::Dismiss: + autoDialog.reset(); + autoRedraw = true; + break; + case release::AutoUpdate::Action::Install: { + autoDialog.reset(); + autoRedraw = true; + std::string why = update->requestInstall(autoUpdate.tag(), true); + if (!why.empty()) + bus.publish(Event::withText(EventType::Notification, ("Update not installed: " + why).c_str(), static_cast(NotificationLevel::Warning))); + break; + } + } + int seconds = autoUpdate.asking() ? autoUpdate.secondsLeft(now) : -1; + if (seconds != shownSeconds) { + shownSeconds = seconds; + autoRedraw = true; + } +} + static void handleKey(const KeyEvent& e) { if (e.key == Key::Screenshot) return screenshotKey(); + if (autoDialog) { // the question takes every key until it is answered + autoDialog->onKey(e); + return; + } apps->handleKey(e); } @@ -1534,6 +1593,7 @@ static void loopPass() { printListingWhenReady(); M5Cardputer.update(); if (M5Cardputer.Keyboard.isChange()) { + lastKeyMs = now; bool swallow = M5Cardputer.Keyboard.isPressed() && power->onKey(now); // only woke the screen keyMapper.setTextEntry(apps->foreground().textEntryActive()); auto events = keyMapper.update(readKeys()); // always, so held keys are tracked @@ -1549,6 +1609,7 @@ static void loopPass() { bus.dispatch(); notifier->update(now); apps->update(now); + autoUpdateStep(now); if (now - lastLog > 10000) { lastLog = now; @@ -1594,10 +1655,15 @@ static void loopPass() { screen.invalidate(); redrawAfterUpdate = true; } - if (apps->takeRedraw() || toastChanged || status != lastStatus || redrawAfterUpdate) { + if (apps->takeRedraw() || toastChanged || status != lastStatus || redrawAfterUpdate || autoRedraw) { lastStatus = status; redrawAfterUpdate = false; - screen.render(*apps, status, toast); + autoRedraw = false; + if (autoDialog) { + Screen::Question question{"Update to " + autoUpdate.tag() + "?", + "It installs in " + std::to_string(autoUpdate.secondsLeft(now)) + " s unless you cancel, and the device restarts.", autoDialog.get()}; + screen.render(*apps, status, toast, &question); + } else screen.render(*apps, status, toast); update->firstFrameDrawn(); } } diff --git a/src/services/update_service.cpp b/src/services/update_service.cpp index bc34d85..ba9c8b5 100644 --- a/src/services/update_service.cpp +++ b/src/services/update_service.cpp @@ -94,6 +94,26 @@ void UpdateService::notify(const std::string& text, NotificationLevel level) { bus_.publish(Event::withText(EventType::Notification, text.c_str(), static_cast(level))); } +// A line in the record of updates on the card (issue #52): the firmware can change without +// anyone looking, so what was installed and when is written down. +void UpdateService::record(const std::string& what) { + time_t now = time(nullptr); + char when[24] = "undated"; + if (now >= kClockSetAfter) { + struct tm t; + gmtime_r(&now, &t); + strftime(when, sizeof when, "%Y-%m-%d %H:%M UTC", &t); + } + storage_.appendLine(kRecordPath, std::string(when) + " " + what); +} + +bool UpdateService::takeOffer(std::string& tag) { + if (!offer_) return false; + offer_ = false; + tag = offered_; + return true; +} + std::string UpdateService::incomingVersion() const { return incoming_; } void UpdateService::start() { @@ -108,6 +128,8 @@ void UpdateService::start() { store_.getString("ota_from", from); if (!pending.empty() && pending != versionString()) { notify("Update to " + pending + " failed, back on " + versionString(), NotificationLevel::Warning); + record(pending + " failed and was undone: back on " + versionString()); + store_.putString("ota_auto", ""); store_.putString("ota_failed", pending); // not announced again until there's a newer one (Q168) store_.putString("ota_pending", ""); } @@ -142,6 +164,14 @@ void UpdateService::tick(uint32_t nowMs) { store_.putString("ota_pending", ""); store_.putInt("ota_attempts", 0); notify(std::string("Updated to ") + versionString(), NotificationLevel::Info); + { + std::string from, automatic; + store_.getString("ota_from", from); + store_.getString("ota_auto", automatic); + record(std::string(versionString()) + " installed" + (automatic == versionString() ? " by itself" : "") + + (from.empty() ? "" : ", from " + from)); + store_.putString("ota_auto", ""); + } break; case Probation::Verdict::RollBack: // ota_pending still names this version: the previous firmware will report the failure. @@ -215,12 +245,13 @@ std::string UpdateService::failedVersion() const { return failed; } -std::string UpdateService::requestInstall(const std::string& tag) { +std::string UpdateService::requestInstall(const std::string& tag, bool automatic) { if (phase_ != Phase::Idle || giteaBusy()) return "Busy with something else"; 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"; + store_.putString("ota_auto", automatic ? tag : ""); installTag_ = tag; request_ = Request::Install; return ""; @@ -281,7 +312,14 @@ void UpdateService::serve() { 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) && + // Installing by itself (issue #52): offered at each daily check while it is the newest, + // so a "not now" lasts a day. The main loop asks on the screen when the moment is quiet. + if (r == Request::BackgroundCheck && gitea_.latest(latest) && settings_.getBool(Setting::InstallUpdates) && + release::shouldInstall(latest, runningVersion(), failedVersion())) { + announced_ = latest.tag; + offered_ = latest.tag; + offer_ = true; + } else if (r == Request::BackgroundCheck && gitea_.latest(latest) && release::shouldAnnounce(latest, runningVersion(), failedVersion(), announced_)) { announced_ = latest.tag; notify(latest.tag + " is out: Settings > System > Firmware", NotificationLevel::Info); // 48 bytes at most diff --git a/src/services/update_service.h b/src/services/update_service.h index 347c73d..032a413 100644 --- a/src/services/update_service.h +++ b/src/services/update_service.h @@ -51,7 +51,12 @@ class UpdateService : public Service { 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. - std::string requestInstall(const std::string& tag); + // `automatic`: nobody asked for it (issue #52), and the record of updates says so. + std::string requestInstall(const std::string& tag, bool automatic = false); + // A release the daily check found that may be installed without being asked for (the setting + // is on): true once for each find, with its tag. The main loop picks the moment. + bool takeOffer(std::string& tag); + static constexpr const char* kRecordPath = "/system/updates.log"; // what was installed, and when 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. @@ -85,6 +90,7 @@ class UpdateService : public Service { void listen(); void install(class UpdateSource& source, const char* via); void notify(const std::string& text, NotificationLevel level); + void record(const std::string& what); KeyValueStore& store_; WifiService& wifi_; @@ -103,7 +109,8 @@ class UpdateService : public Service { 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_; + std::string installTag_, fakeVersion_, announced_, offered_; + volatile bool offer_ = false; bool dailyCheck_ = false; uint32_t nextCheckMs_ = 90000; // not before the device has settled std::string probeHost_, probePath_; diff --git a/src/ui/screen.cpp b/src/ui/screen.cpp index 0a79160..b4713a5 100644 --- a/src/ui/screen.cpp +++ b/src/ui/screen.cpp @@ -12,12 +12,12 @@ bool Screen::begin() { return canvas_.createSprite(theme::kWidth, theme::kHeight) != nullptr; } -void Screen::render(AppManager& apps, const StatusInfo& status, const Toast* toast) { +void Screen::render(AppManager& apps, const StatusInfo& status, const Toast* toast, const Question* question) { const auto& area = theme::kContent; App& app = apps.foreground(); // An App that keeps what it drew: only the Status Bar is cleared, unless the App has changed // or a Toast or the help panel, drawn over it, has just gone. - bool overlay = apps.help().isOpen() || toast, retains = app.retainsContent(); + bool overlay = apps.help().isOpen() || toast || question, retains = app.retainsContent(); if (overlayWas_ && !overlay) lost_ = true; if (retains && &app == lastApp_ && !lost_) { canvas_.fillRect(0, 0, theme::kWidth, area.y, theme::kBackground); @@ -31,6 +31,7 @@ void Screen::render(AppManager& apps, const StatusInfo& status, const Toast* toa canvas_.setClipRect(area.x, area.y, area.w, area.h); app.draw(canvas_); if (apps.help().isOpen()) widgets::help(canvas_, apps.help()); // over the App, under the Status Bar + if (question) widgets::dialog(canvas_, question->title, question->message, *question->dialog); canvas_.clearClipRect(); widgets::statusBar(canvas_, status); if (toast) widgets::toast(canvas_, *toast); diff --git a/src/ui/screen.h b/src/ui/screen.h index bcf5b23..e581636 100644 --- a/src/ui/screen.h +++ b/src/ui/screen.h @@ -10,7 +10,12 @@ namespace roro { class Screen { public: bool begin(); // allocates the frame buffer (~32 KB at 8-bit colour) - void render(AppManager& apps, const StatusInfo& status, const Toast* toast); + // A question the device itself asks, over whatever App is open (issue #52). + struct Question { + std::string title, message; + const DialogModel* dialog; + }; + void render(AppManager& apps, const StatusInfo& status, const Toast* toast, const Question* question = nullptr); // Takes over the screen while a Firmware Update is received or about to restart. void renderUpdate(const std::string& title, const std::string& detail, int percent); Canvas& canvas() { return canvas_; }