Updates: a newer release installs by itself, after a question
CI / build (pull_request) Successful in 2m8s
Site / build (pull_request) Successful in 11s

With Settings > System > Install updates on By itself (issue #52), a
release found by the daily check is offered once no key was pressed for
2 minutes and nothing would be cut by a restart. The question goes over
whatever App is open, with Cancel and Accept, and installs after 30 s
without an answer. Each update that held, and each one undone, is a
dated line in /system/updates.log on the card.

Tried on the device: the question, Cancel, and an install of v0.23.0
left unanswered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162FokPdvY2KsS4NBfwyWPk
This commit is contained in:
2026-10-11 17:49:09 +02:00
co-authored by Claude Opus 5.5
parent 8ba0102910
commit c3fd9909a1
13 changed files with 211 additions and 15 deletions
+69 -3
View File
@@ -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<DialogModel> autoDialog;
static uint32_t lastKeyMs = 0;
static bool autoRedraw = false;
static std::vector<std::string> 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<int32_t>(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();
}
}
+40 -2
View File
@@ -94,6 +94,26 @@ void UpdateService::notify(const std::string& text, NotificationLevel level) {
bus_.publish(Event::withText(EventType::Notification, text.c_str(), static_cast<int32_t>(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<int32_t>(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
+9 -2
View File
@@ -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_;
+3 -2
View File
@@ -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);
+6 -1
View File
@@ -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_; }