diff --git a/lib/apps_model/src/settings_menu.cpp b/lib/apps_model/src/settings_menu.cpp index e281ddc..e82204e 100644 --- a/lib/apps_model/src/settings_menu.cpp +++ b/lib/apps_model/src/settings_menu.cpp @@ -22,6 +22,7 @@ const RowDef kRows[] = { {Row::OffTimeout, Kind::Choice, "Screen off after"}, {Row::Sound, Kind::Toggle, "Sound & LED"}, {Row::ProbeMacs, Kind::Toggle, "Probe MACs"}, {Row::Wifi, Kind::Page, "Wi-Fi"}, {Row::Storage, Kind::Page, "Storage"}, + {Row::Firmware, Kind::Page, "Firmware"}, {Row::About, Kind::Page, "About"}, }; diff --git a/lib/apps_model/src/settings_menu.h b/lib/apps_model/src/settings_menu.h index 3dbd517..26493ed 100644 --- a/lib/apps_model/src/settings_menu.h +++ b/lib/apps_model/src/settings_menu.h @@ -11,7 +11,7 @@ namespace roro { // values, choice lists and validation messages. Rendering and navigation live in the App. class SettingsMenu { public: - enum class Row { LongName, ShortName, Region, Timezone, Brightness, DimTimeout, OffTimeout, Sound, ProbeMacs, Wifi, Storage, About }; + enum class Row { LongName, ShortName, Region, Timezone, Brightness, DimTimeout, OffTimeout, Sound, ProbeMacs, Wifi, Storage, Firmware, About }; enum class Kind { Text, Choice, Toggle, Slider, Page }; explicit SettingsMenu(Settings& settings) : settings_(settings) {} diff --git a/lib/ota/src/probation.h b/lib/ota/src/probation.h new file mode 100644 index 0000000..7569f09 --- /dev/null +++ b/lib/ota/src/probation.h @@ -0,0 +1,23 @@ +#pragma once + +#include + +namespace roro { + +// Decides when new firmware on Probation (see CONTEXT.md) has proven healthy, or has failed in a +// way that would leave no means to push a fix (Wi-Fi configured but never connecting). +class Probation { + public: + enum class Verdict { Wait, Confirm, RollBack }; + static constexpr uint32_t kHealthyAfterMs = 30000; + static constexpr uint32_t kWifiDeadlineMs = 180000; + + static Verdict judge(uint32_t uptimeMs, bool firstFrameDrawn, bool wifiConfigured, bool wifiConnected) { + if (wifiConfigured && !wifiConnected && uptimeMs >= kWifiDeadlineMs) return Verdict::RollBack; + if (uptimeMs < kHealthyAfterMs || !firstFrameDrawn) return Verdict::Wait; + if (wifiConfigured && !wifiConnected) return Verdict::Wait; + return Verdict::Confirm; + } +}; + +} // namespace roro diff --git a/src/apps/firmware_page.cpp b/src/apps/firmware_page.cpp new file mode 100644 index 0000000..5b4fa22 --- /dev/null +++ b/src/apps/firmware_page.cpp @@ -0,0 +1,103 @@ +#include "firmware_page.h" + +#include + +#include "ui/widgets.h" +#include "version.h" + +namespace roro { + +namespace { +constexpr const char* kUpdatesFolder = "/updates"; + +bool endsWith(const std::string& s, const std::string& suffix) { + return s.size() >= suffix.size() && s.compare(s.size() - suffix.size(), suffix.size(), suffix) == 0; +} +} // namespace + +void FirmwarePage::enter() { + confirm_.reset(); + { + std::lock_guard g(lock_); + files_.clear(); + listed_ = false; + } + list_.setCount(kFixed); + storage_.runJob([this]() { + std::vector found; + File dir = SD.open(kUpdatesFolder); + if (dir && dir.isDirectory()) + for (File f = dir.openNextFile(); f; f = dir.openNextFile()) + if (!f.isDirectory() && endsWith(f.name(), ".ota")) found.push_back(std::string(kUpdatesFolder) + "/" + f.name()); + std::lock_guard g(lock_); + files_ = found; + listed_ = true; + }); +} + +std::vector FirmwarePage::files() { + std::lock_guard g(lock_); + return files_; +} + +bool FirmwarePage::onKey(const KeyEvent& e) { + auto found = files(); + if (confirm_) { + confirm_->onKey(e); + int pick = list_.selected() - kFixed; + if (confirm_->result() == 1 && pick >= 0 && pick < static_cast(found.size())) update_.installFromSd(found[pick]); + if (confirm_->result() != DialogModel::kPending) confirm_.reset(); + return true; + } + list_.setCount(kFixed + static_cast(found.size())); + switch (e.key) { + case Key::Up: list_.up(); break; + case Key::Down: list_.down(); break; + case Key::Back: return false; + case Key::Select: + if (list_.selected() >= kFixed) confirm_.reset(new DialogModel({"Cancel", "Install"})); + break; + default: break; + } + return true; +} + +void FirmwarePage::draw(Canvas& c) { + auto found = files(); + bool listed; + { + std::lock_guard g(lock_); + listed = listed_; + } + list_.setCount(kFixed + static_cast(found.size())); + std::string ip = wifi_.ip(); + widgets::list( + c, list_, theme::kContent, + [&](int i) -> std::string { + switch (i) { + case kVersion: return "Version"; + case kStatus: return "Status"; + case kAddress: return "Push to"; + case kName: return "Name"; + case kSdHeader: return listed ? (found.empty() ? "No .ota files in /updates" : "On the SD card:") : "Looking on the SD card..."; + default: return " " + found[i - kFixed].substr(std::string(kUpdatesFolder).size() + 1); + } + }, + [&](int i) -> std::string { + switch (i) { + case kVersion: return versionString(); + case kStatus: return update_.onProbation() ? "on probation" : "confirmed"; + case kAddress: return ip.empty() ? "Wi-Fi not connected" : ip + ":" + std::to_string(UpdateService::kPort); + case kName: return update_.hostname() + ".local"; + case kSdHeader: return ""; + default: return "install >"; + } + }); + if (confirm_) { + int pick = list_.selected() - kFixed; + std::string name = pick >= 0 && pick < static_cast(found.size()) ? found[pick] : ""; + widgets::dialog(c, "Install update?", name + " is checked before anything is written. The device restarts after.", *confirm_); + } +} + +} // namespace roro diff --git a/src/apps/firmware_page.h b/src/apps/firmware_page.h new file mode 100644 index 0000000..b13af51 --- /dev/null +++ b/src/apps/firmware_page.h @@ -0,0 +1,47 @@ +#pragma once + +#include +#include +#include +#include + +#include "dialog_model.h" +#include "key_event.h" +#include "list_model.h" +#include "services/storage_service.h" +#include "services/update_service.h" +#include "services/wifi_service.h" +#include "ui/canvas.h" +#include "ui/theme.h" + +namespace roro { + +// Settings → Firmware: the running version and its Probation, where to push Firmware Updates, and +// the Update Files on the SD card (in /updates) to install from. +class FirmwarePage { + public: + FirmwarePage(UpdateService& update, WifiService& wifi, StorageService& storage) + : update_(update), wifi_(wifi), storage_(storage) {} + + void enter(); + bool onKey(const KeyEvent& e); // false: leave the page + void draw(Canvas& c); + + private: + enum Row { kVersion, kStatus, kAddress, kName, kSdHeader, kFixed }; + + std::vector files(); + + UpdateService& update_; + WifiService& wifi_; + StorageService& storage_; + ListModel list_{theme::kContent.h / theme::kLineHeight}; + std::unique_ptr confirm_; + + // Filled on the storage task. + std::mutex lock_; + std::vector files_; + bool listed_ = false; +}; + +} // namespace roro diff --git a/src/apps/settings_app.cpp b/src/apps/settings_app.cpp index 062c5a8..b599409 100644 --- a/src/apps/settings_app.cpp +++ b/src/apps/settings_app.cpp @@ -34,6 +34,9 @@ bool SettingsApp::onKey(const KeyEvent& e) { case Page::Wifi: if (!wifiPage_.onKey(e)) page_ = Page::Menu; return true; + case Page::Firmware: + if (!firmwarePage_.onKey(e)) page_ = Page::Menu; + return true; } return false; } @@ -75,6 +78,10 @@ bool SettingsApp::onMenuKey(const KeyEvent& e) { page_ = Page::Wifi; wifiPage_.enter(); break; + case Row::Firmware: + page_ = Page::Firmware; + firmwarePage_.enter(); + break; default: page_ = Page::About; break; } break; @@ -124,7 +131,8 @@ bool SettingsApp::onAboutKey(const KeyEvent& e) { void SettingsApp::update(uint32_t nowMs) { // Live values on Storage and About. - bool live = page_ == Page::Storage || page_ == Page::About || (page_ == Page::Wifi && wifiPage_.live()); + bool live = page_ == Page::Storage || page_ == Page::About || page_ == Page::Firmware || + (page_ == Page::Wifi && wifiPage_.live()); if (live && nowMs - lastRefreshMs_ >= 500) { lastRefreshMs_ = nowMs; requestRedraw(); @@ -183,6 +191,7 @@ void SettingsApp::draw(Canvas& c) { case Page::Storage: storagePage_.draw(c); break; case Page::About: widgets::textLines(c, aboutLines(), 0, area); break; case Page::Wifi: wifiPage_.draw(c); break; + case Page::Firmware: firmwarePage_.draw(c); break; } } diff --git a/src/apps/settings_app.h b/src/apps/settings_app.h index f822850..42d6af8 100644 --- a/src/apps/settings_app.h +++ b/src/apps/settings_app.h @@ -13,6 +13,7 @@ #include "services/battery_service.h" #include "services/clock_service.h" #include "services/storage_service.h" +#include "apps/firmware_page.h" #include "apps/storage_page.h" #include "apps/wifi_settings_page.h" #include "settings_menu.h" @@ -29,6 +30,7 @@ struct SettingsAppDeps { ClockService& clock; WifiService& wifi; SavedNetworks& savedNetworks; + UpdateService& update; }; // Settings: every user-facing setting, plus the Storage and About pages. @@ -38,7 +40,8 @@ class SettingsApp : public App { : d_(deps), menu_(deps.settings), wifiPage_(deps.settings, deps.savedNetworks, deps.wifi, deps.bus), - storagePage_(deps.storage, deps.clock, deps.bus) {} + storagePage_(deps.storage, deps.clock, deps.bus), + firmwarePage_(deps.update, deps.wifi, deps.storage) {} void onEnter() override; bool onKey(const KeyEvent& e) override; void update(uint32_t nowMs) override; @@ -48,7 +51,7 @@ class SettingsApp : public App { void draw(Canvas& c) override; private: - enum class Page { Menu, Text, Choice, Storage, About, Wifi }; + enum class Page { Menu, Text, Choice, Storage, About, Wifi, Firmware }; bool onMenuKey(const KeyEvent& e); bool onTextKey(const KeyEvent& e); @@ -61,6 +64,7 @@ class SettingsApp : public App { SettingsMenu menu_; WifiSettingsPage wifiPage_; StoragePage storagePage_; + FirmwarePage firmwarePage_; Page page_ = Page::Menu; ListModel list_{theme::kContent.h / theme::kLineHeight}; ListModel choices_{theme::kContent.h / theme::kLineHeight}; diff --git a/src/main.cpp b/src/main.cpp index 61ab354..6f7331c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -19,6 +19,7 @@ #include "services/clock_service.h" #include "services/power_service.h" #include "services/storage_service.h" +#include "services/update_service.h" #include "services/wifi_service.h" #include "settings.h" #include "storage_paths.h" @@ -43,6 +44,7 @@ static ClockService* clockService; static SavedNetworks* savedNetworks; static WifiService* wifi; static IrcService* irc; +static UpdateService* update; static Notifier* notifier; static LauncherApp launcher; static AppManager* apps; @@ -104,6 +106,7 @@ void setup() { savedNetworks = new SavedNetworks(nvs); savedNetworks->load(); wifi = new WifiService(settings, *savedNetworks, *clockService); + update = new UpdateService(nvs, *wifi, *savedNetworks, *storageService, bus, settings); 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); }; @@ -113,13 +116,14 @@ void setup() { services.add(*storageService); services.add(*wifi); services.add(*irc); + services.add(*update); apps = new AppManager(launcher); launcher.setManager(*apps); apps->registerApp({"irc", "IRC", false, new IrcApp(*irc, *clockService, bus)}); apps->registerApp({"wifi-tools", "Wi-Fi Tools", false, new WifiToolsApp(*wifi, *storageService, *clockService)}); apps->registerApp({"settings", "Settings", false, - new SettingsApp({settings, bus, *apps, *battery, *storageService, *clockService, *wifi, *savedNetworks})}); + new SettingsApp({settings, bus, *apps, *battery, *storageService, *clockService, *wifi, *savedNetworks, *update})}); apps->registerApp({"demo", "Widget demo", true, new DemoApp(bus)}); apps->registerApp({"setup", "Setup", true, new SetupApp(settings, *apps)}); @@ -222,9 +226,10 @@ static void serialCommands() { }); } if (line == "wifi status") - Serial.printf("wifi: state %d ssid '%s' rssi %d clock %s heap %u min %u\n", (int)wifi->state(), - wifi->ssid().c_str(), wifi->rssi(), clockService->displayTime().c_str(), ESP.getFreeHeap(), - ESP.getMinFreeHeap()); + Serial.printf("wifi: state %d ssid '%s' rssi %d ip %s clock %s heap %u min %u | fw %s%s\n", + (int)wifi->state(), wifi->ssid().c_str(), wifi->rssi(), wifi->ip().c_str(), + clockService->displayTime().c_str(), ESP.getFreeHeap(), ESP.getMinFreeHeap(), versionString(), + update->onProbation() ? " (on probation)" : ""); if (line == "sound off") settings.setBool(Setting::Sound, false); if (line == "sound on") settings.setBool(Setting::Sound, true); if (line == "short") { @@ -240,6 +245,10 @@ static void serialCommands() { } void loop() { +#ifdef RORO_TEST_CRASH + // Test builds only (never in a release): crash during Probation to exercise Rollback. + if (millis() > 5000) abort(); +#endif static StatusInfo lastStatus; static uint32_t lastLog = 0; uint32_t now = millis(); @@ -265,12 +274,40 @@ void loop() { Serial.printf("status: heap %u min %u\n", ESP.getFreeHeap(), ESP.getMinFreeHeap()); } + // Firmware Update: its progress takes over the screen, then the device restarts when it's safe. + static int shownPercent = -1; + static uint32_t installedAt = 0; + static bool redrawAfterUpdate = false; + switch (update->phase()) { + case UpdateService::Phase::Receiving: + if (update->percent() != shownPercent) { + shownPercent = update->percent(); + screen.renderUpdate("Firmware update", "Receiving " + update->incomingVersion(), shownPercent); + } + redrawAfterUpdate = true; + return; + case UpdateService::Phase::Installed: + if (!installedAt) installedAt = now; + // Don't pull the rug out from under someone typing, for up to a minute. + if (!apps->foreground().textEntryActive() || now - installedAt >= 60000) { + screen.renderUpdate("Restarting", "into " + update->incomingVersion(), -1); + delay(800); + ESP.restart(); + } + break; + default: + shownPercent = -1; + break; + } + if (power->screen() == ScreenState::Off) return; StatusInfo status = currentStatus(); const Toast* toast = notifier->toasts().current(now); bool toastChanged = notifier->toasts().takeChanged(); - if (apps->takeRedraw() || toastChanged || status != lastStatus) { + if (apps->takeRedraw() || toastChanged || status != lastStatus || redrawAfterUpdate) { lastStatus = status; + redrawAfterUpdate = false; screen.render(*apps, status, toast); + update->firstFrameDrawn(); } } diff --git a/src/platform/ota_device.cpp b/src/platform/ota_device.cpp new file mode 100644 index 0000000..c608979 --- /dev/null +++ b/src/platform/ota_device.cpp @@ -0,0 +1,44 @@ +#include "ota_device.h" + +#include + +#include "ota_public_key.h" + +namespace roro { + +EcdsaVerifier::EcdsaVerifier() { + mbedtls_pk_init(&key_); + // PEM parsing wants the terminating NUL counted in the length. + ready_ = mbedtls_pk_parse_public_key(&key_, reinterpret_cast(kOtaPublicKeyPem), + std::strlen(kOtaPublicKeyPem) + 1) == 0; +} + +EcdsaVerifier::~EcdsaVerifier() { mbedtls_pk_free(&key_); } + +bool EcdsaVerifier::verify(const uint8_t digest[32], const uint8_t* signature, size_t len) { + return ready_ && mbedtls_pk_verify(&key_, MBEDTLS_MD_SHA256, digest, 32, signature, len) == 0; +} + +bool EspOtaSink::begin(size_t imageSize) { + if (!slot_) return false; + open_ = esp_ota_begin(slot_, imageSize, &handle_) == ESP_OK; + return open_; +} + +bool EspOtaSink::write(const uint8_t* data, size_t len) { + return open_ && esp_ota_write(handle_, data, len) == ESP_OK; +} + +bool EspOtaSink::finish() { + if (!open_) return false; + open_ = false; + // esp_ota_end() also validates the image structure (segments, checksum) for this chip. + return esp_ota_end(handle_) == ESP_OK && esp_ota_set_boot_partition(slot_) == ESP_OK; +} + +void EspOtaSink::abort() { + if (open_) esp_ota_abort(handle_); + open_ = false; +} + +} // namespace roro diff --git a/src/platform/ota_device.h b/src/platform/ota_device.h new file mode 100644 index 0000000..b2676e6 --- /dev/null +++ b/src/platform/ota_device.h @@ -0,0 +1,38 @@ +#pragma once + +#include +#include + +#include "update_parser.h" + +namespace roro { + +// Checks Update File signatures against the public key compiled into the firmware (ADR 0003). +class EcdsaVerifier : public SignatureVerifier { + public: + EcdsaVerifier(); + ~EcdsaVerifier() override; + bool verify(const uint8_t digest[32], const uint8_t* signature, size_t len) override; + + private: + mbedtls_pk_context key_; + bool ready_ = false; +}; + +// Writes the image to the app slot that isn't running, and makes it the next boot on finish(). +class EspOtaSink : public UpdateSink { + public: + EspOtaSink() : slot_(esp_ota_get_next_update_partition(nullptr)) {} + size_t capacity() const { return slot_ ? slot_->size : 0; } + bool begin(size_t imageSize) override; + bool write(const uint8_t* data, size_t len) override; + bool finish() override; + void abort() override; + + private: + const esp_partition_t* slot_; + esp_ota_handle_t handle_ = 0; + bool open_ = false; +}; + +} // namespace roro diff --git a/src/services/storage_service.cpp b/src/services/storage_service.cpp index cfacfc7..0fe1744 100644 --- a/src/services/storage_service.cpp +++ b/src/services/storage_service.cpp @@ -15,7 +15,7 @@ namespace roro { void StorageService::start() { if (task_) return; lock_ = xSemaphoreCreateMutex(); - xTaskCreate(taskEntry, "storage", 6144, this, 1, &task_); + xTaskCreate(taskEntry, "storage", 10240, this, 1, &task_); // room for a signature check } void StorageService::stop() { @@ -75,6 +75,13 @@ void StorageService::requestDelete(std::vector paths) { if (task_) xTaskNotifyGive(task_); } +void StorageService::runJob(std::function job) { + lock(); + jobs_.push_back(std::move(job)); + unlock(); + if (task_) xTaskNotifyGive(task_); +} + bool StorageService::requestFormat() { if (formatRequested_ || !task_) return false; formatRequested_ = true; @@ -109,6 +116,13 @@ void StorageService::loop() { } if (wantListing) list(); + lock(); + std::vector> jobs; + jobs.swap(jobs_); + unlock(); + for (auto& job : jobs) + if (mounted_) job(); + // Sleep until the next batch, or until a request wakes us early. ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(kWakeMs)); } diff --git a/src/services/storage_service.h b/src/services/storage_service.h index c280f1a..a9e85a3 100644 --- a/src/services/storage_service.h +++ b/src/services/storage_service.h @@ -4,6 +4,7 @@ #include #include +#include #include #include #include @@ -37,6 +38,9 @@ class StorageService : public Service { std::vector> listing() const; // one vector per kCleanupCategories entry void requestDelete(std::vector paths); + // Runs `job` on the storage task, where card access is safe (e.g. reading an Update File). + void runJob(std::function job); + // Erases the whole card: one partition spanning the card, formatted FAT32. bool requestFormat(); bool formatting() const { return formatRequested_; } @@ -71,6 +75,7 @@ class StorageService : public Service { bool listingReady_ = false; std::vector> listing_; std::vector toDelete_; + std::vector> jobs_; volatile bool formatRequested_ = false; }; diff --git a/src/services/update_service.cpp b/src/services/update_service.cpp new file mode 100644 index 0000000..11fb45e --- /dev/null +++ b/src/services/update_service.cpp @@ -0,0 +1,196 @@ +#include "update_service.h" + +#include +#include +#include +#include + +#include + +#include "platform/identity.h" +#include "platform/ota_device.h" +#include "probation.h" +#include "update_parser.h" +#include "version.h" + +namespace roro { + +// Where an Update File comes from: the network or a file on the SD card. +class UpdateSource { + public: + virtual ~UpdateSource() = default; + // Bytes read, 0 at the end of the file, -1 on an error or a stall. + virtual int read(uint8_t* buf, size_t len) = 0; + virtual void reply(const std::string& line) { (void)line; } +}; + +namespace { + +constexpr uint32_t kStallMs = 10000; +constexpr size_t kChunk = 4096; + +class NetSource : public UpdateSource { + public: + explicit NetSource(NetworkClient& c) : c_(c) {} + int read(uint8_t* buf, size_t len) override { + uint32_t since = millis(); + while (!c_.available()) { + if (!c_.connected()) return 0; // the sender closed its side: end of file + if (millis() - since > kStallMs) return -1; + delay(5); + } + return c_.read(buf, len); + } + void reply(const std::string& line) override { + c_.print((line + "\n").c_str()); + c_.flush(); + } + + private: + NetworkClient& c_; +}; + +class FileSource : public UpdateSource { + public: + explicit FileSource(File& f) : f_(f) {} + int read(uint8_t* buf, size_t len) override { return static_cast(f_.read(buf, len)); } + + private: + File& f_; +}; + +} // namespace + +UpdateService::UpdateService(KeyValueStore& store, WifiService& wifi, SavedNetworks& saved, StorageService& storage, + EventBus& bus, const Settings& settings) + : store_(store), wifi_(wifi), saved_(saved), storage_(storage), bus_(bus), settings_(settings) { + hostname_ = "roro9stack-" + identity::defaultShortName(); +} + +void UpdateService::notify(const std::string& text, NotificationLevel level) { + bus_.publish(Event::withText(EventType::Notification, text.c_str(), static_cast(level))); +} + +std::string UpdateService::incomingVersion() const { return incoming_; } + +void UpdateService::start() { + // Probation: new firmware boots "pending verify" until it marks itself valid. + esp_ota_img_states_t state; + probation_ = esp_ota_get_state_partition(esp_ota_get_running_partition(), &state) == ESP_OK && + state == ESP_OTA_IMG_PENDING_VERIFY; + + // A pending update that isn't the running version means the bootloader rolled it back. + std::string pending, from; + store_.getString("ota_pending", pending); + store_.getString("ota_from", from); + if (!pending.empty() && pending != versionString()) { + notify("Update to " + pending + " failed, back on " + versionString(), NotificationLevel::Warning); + store_.putString("ota_pending", ""); + } + + if (!task_) xTaskCreate(taskEntry, "update", 8192, this, 1, &task_); +} + +void UpdateService::tick(uint32_t nowMs) { + if (!probation_) return; + bool wifiConfigured = settings_.getBool(Setting::WifiEnabled) && saved_.count() > 0; + bool wifiUp = wifi_.state() == WifiController::State::Connected; + switch (Probation::judge(nowMs, firstFrame_, wifiConfigured, wifiUp)) { + case Probation::Verdict::Wait: break; + case Probation::Verdict::Confirm: + esp_ota_mark_app_valid_cancel_rollback(); + probation_ = false; + store_.putString("ota_pending", ""); + notify(std::string("Updated to ") + versionString(), NotificationLevel::Info); + break; + case Probation::Verdict::RollBack: + // ota_pending still names this version: the previous firmware will report the failure. + delay(200); + esp_ota_mark_app_invalid_rollback_and_reboot(); + break; + } +} + +void UpdateService::install(UpdateSource& source, const char* via) { + EcdsaVerifier verifier; + EspOtaSink sink; + UpdateParser parser(verifier, sink, sink.capacity(), versionString()); + std::unique_ptr buf(new uint8_t[kChunk]); + incoming_.clear(); + percent_ = 0; + phase_ = Phase::Receiving; + + bool ok = false; + for (;;) { + int n = source.read(buf.get(), kChunk); + if (n < 0) break; // stalled or broken: aborted below + if (n == 0) { + ok = parser.end(); + break; + } + parser.feed(buf.get(), n); + if (incoming_.empty() && parser.state() == UpdateParser::State::Image) incoming_ = parser.version(); + percent_ = parser.percent(); + if (parser.state() == UpdateParser::State::Failed) break; + } + if (!ok && parser.error().empty()) parser.end(); // e.g. a stall: abort the slot + + if (ok) { + store_.putString("ota_pending", parser.version()); + store_.putString("ota_from", versionString()); + source.reply("OK " + parser.version() + (parser.isDowngrade() ? " (older than the installed one)" : "")); + notify("Update " + parser.version() + " installed (" + via + "), restarting", NotificationLevel::Info); + phase_ = Phase::Installed; + } else { + std::string why = parser.error().empty() ? "transfer interrupted" : parser.error(); + source.reply("ERR " + why); + notify("Update refused: " + why, NotificationLevel::Warning); + phase_ = Phase::Idle; + } +} + +void UpdateService::installFromSd(const std::string& path) { + if (phase_ != Phase::Idle) return; + storage_.runJob([this, path]() { + File f = SD.open(path.c_str()); + if (!f) { + notify("Can't open " + path, NotificationLevel::Warning); + return; + } + FileSource src(f); + install(src, "SD card"); + f.close(); + }); +} + +void UpdateService::taskEntry(void* self) { static_cast(self)->listen(); } + +void UpdateService::listen() { + NetworkServer server(kPort); + bool listening = false; + for (;;) { + bool connected = wifi_.state() == WifiController::State::Connected; + if (connected && !listening) { + server.begin(); + if (MDNS.begin(hostname_.c_str())) MDNS.addService("roro9stack", "tcp", kPort); + listening = true; + } else if (!connected && listening) { + server.end(); + MDNS.end(); + listening = false; + } + if (listening && phase_ == Phase::Idle) { + NetworkClient client = server.accept(); + if (client) { + client.setNoDelay(true); + NetSource src(client); + install(src, "Wi-Fi"); + delay(50); + client.stop(); + } + } + vTaskDelay(pdMS_TO_TICKS(200)); + } +} + +} // namespace roro diff --git a/src/services/update_service.h b/src/services/update_service.h new file mode 100644 index 0000000..adbc69d --- /dev/null +++ b/src/services/update_service.h @@ -0,0 +1,66 @@ +#pragma once + +#include + +#include + +#include "event_bus.h" +#include "key_value_store.h" +#include "service.h" +#include "services/storage_service.h" +#include "services/wifi_service.h" + +namespace roro { + +// Firmware Updates (see CONTEXT.md and docs/milestones/OTA.md): listens for signed Update Files on +// TCP 3232 while Wi-Fi is Connected, installs them from the SD card on request, keeps new firmware +// on Probation until it proves healthy, and reports a Rollback after the reboot. +class UpdateService : public Service { + public: + enum class Phase { Idle, Receiving, Installed, Failed }; + static constexpr uint16_t kPort = 3232; + + UpdateService(KeyValueStore& store, WifiService& wifi, SavedNetworks& saved, StorageService& storage, + EventBus& bus, const Settings& settings); + const char* name() const override { return "update"; } + uint32_t tickIntervalMs() const override { return 1000; } + void start() override; + void tick(uint32_t nowMs) override; + + // For the progress screen and the Firmware page. + Phase phase() const { return phase_; } + int percent() const { return percent_; } + std::string incomingVersion() const; + bool onProbation() const { return probation_; } + const std::string& hostname() const { return hostname_; } + + // Installs an Update File from the SD card (runs on the storage task). + void installFromSd(const std::string& path); + + // The main loop calls this once it has drawn a frame (part of Probation). + void firstFrameDrawn() { firstFrame_ = true; } + // True when an installed update is waiting to reboot; the main loop reboots when it's safe. + bool rebootPending() const { return phase_ == Phase::Installed; } + + private: + static void taskEntry(void* self); + void listen(); + void install(class UpdateSource& source, const char* via); + void notify(const std::string& text, NotificationLevel level); + + KeyValueStore& store_; + WifiService& wifi_; + SavedNetworks& saved_; + StorageService& storage_; + EventBus& bus_; + const Settings& settings_; + TaskHandle_t task_ = nullptr; + std::string hostname_; + volatile Phase phase_ = Phase::Idle; + volatile int percent_ = 0; + std::string incoming_; + bool probation_ = false; + volatile bool firstFrame_ = false; +}; + +} // namespace roro diff --git a/src/services/wifi_service.cpp b/src/services/wifi_service.cpp index 161d324..536c1f0 100644 --- a/src/services/wifi_service.cpp +++ b/src/services/wifi_service.cpp @@ -7,6 +7,10 @@ namespace roro { +std::string WifiService::ip() const { + return controller_.state() == WifiController::State::Connected ? WiFi.localIP().toString().c_str() : ""; +} + int WifiService::rssi() const { return controller_.state() == WifiController::State::Connected ? WiFi.RSSI() : 0; } diff --git a/src/services/wifi_service.h b/src/services/wifi_service.h index 2aba9d0..a5c2910 100644 --- a/src/services/wifi_service.h +++ b/src/services/wifi_service.h @@ -25,6 +25,7 @@ class WifiService : public Service { WifiController::State state() const { return controller_.state(); } const std::string& ssid() const { return controller_.ssid(); } int rssi() const; + std::string ip() const; // empty unless Connected // Scans for a list of nearby networks (all channels, or just one for a quick refresh); results // arrive in listScan() when listScanDone(), and listScanSeq() counts completed scans. diff --git a/src/ui/screen.cpp b/src/ui/screen.cpp index ef3e16a..67b3a62 100644 --- a/src/ui/screen.cpp +++ b/src/ui/screen.cpp @@ -2,6 +2,8 @@ #include +#include "fonts.h" + namespace roro { bool Screen::begin() { @@ -21,4 +23,23 @@ void Screen::render(AppManager& apps, const StatusInfo& status, const Toast* toa canvas_.pushSprite(&M5Cardputer.Display, 0, 0); } +void Screen::renderUpdate(const std::string& title, const std::string& detail, int percent) { + canvas_.fillSprite(theme::kBackground); + canvas_.setTextDatum(top_center); + canvas_.setFont(&fonts::bold); + canvas_.setTextColor(theme::kAccent); + canvas_.drawString(title.c_str(), theme::kWidth / 2, 34); + canvas_.setFont(&fonts::body); + canvas_.setTextColor(theme::kText); + canvas_.drawString(detail.c_str(), theme::kWidth / 2, 56); + if (percent >= 0) { + const int x = 20, y = 82, w = theme::kWidth - 40, h = 12; + canvas_.drawRect(x, y, w, h, theme::kMuted); + canvas_.fillRect(x + 1, y + 1, (w - 2) * percent / 100, h - 2, theme::kAccent); + canvas_.drawString((std::to_string(percent) + "%").c_str(), theme::kWidth / 2, y + 18); + } + canvas_.setTextDatum(top_left); + canvas_.pushSprite(&M5Cardputer.Display, 0, 0); +} + } // namespace roro diff --git a/src/ui/screen.h b/src/ui/screen.h index 558d401..3129fa2 100644 --- a/src/ui/screen.h +++ b/src/ui/screen.h @@ -11,6 +11,8 @@ 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); + // 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_; } private: diff --git a/test/test_ota/test_ota.cpp b/test/test_ota/test_ota.cpp index afbe3c1..27f7589 100644 --- a/test/test_ota/test_ota.cpp +++ b/test/test_ota/test_ota.cpp @@ -4,6 +4,7 @@ #include #include +#include "probation.h" #include "sha256.h" #include "update_parser.h" #include "version_compare.h" @@ -239,6 +240,32 @@ void test_version_comparison() { TEST_ASSERT_FALSE(versionOlder("v0.2.1", "4c0be89")); } +// ---- probation -------------------------------------------------------------- + +static int judge(uint32_t up, bool frame, bool configured, bool connected) { + return static_cast(Probation::judge(up, frame, configured, connected)); +} +static const int kWait = static_cast(Probation::Verdict::Wait); +static const int kConfirm = static_cast(Probation::Verdict::Confirm); +static const int kRollBack = static_cast(Probation::Verdict::RollBack); + +void test_probation_waits_30_seconds_and_a_first_frame() { + TEST_ASSERT_EQUAL(kWait, judge(29999, true, false, false)); + TEST_ASSERT_EQUAL(kWait, judge(60000, false, false, false)); + TEST_ASSERT_EQUAL(kConfirm, judge(30000, true, false, false)); +} + +void test_probation_needs_wifi_when_it_is_configured() { + TEST_ASSERT_EQUAL(kWait, judge(60000, true, true, false)); + TEST_ASSERT_EQUAL(kConfirm, judge(60000, true, true, true)); +} + +void test_probation_rolls_back_when_wifi_never_comes() { + TEST_ASSERT_EQUAL(kWait, judge(179999, true, true, false)); + TEST_ASSERT_EQUAL(kRollBack, judge(180000, true, true, false)); + TEST_ASSERT_EQUAL(kConfirm, judge(500000, true, false, false)); // no Wi-Fi configured: fine +} + int main() { UNITY_BEGIN(); RUN_TEST(test_sha256_known_vectors); @@ -255,5 +282,8 @@ int main() { RUN_TEST(test_image_larger_than_the_slot_is_refused_before_writing); RUN_TEST(test_downgrade_is_flagged_but_allowed); RUN_TEST(test_version_comparison); + RUN_TEST(test_probation_waits_30_seconds_and_a_first_frame); + RUN_TEST(test_probation_needs_wifi_when_it_is_configured); + RUN_TEST(test_probation_rolls_back_when_wifi_never_comes); return UNITY_END(); }