Public Access
OTA steps 3-5: Update Service, Probation and Rollback, Update from SD
- EcdsaVerifier (mbedTLS, embedded public key) and EspOtaSink (writes the inactive app slot, esp_ota_end validates the image, then sets the boot partition) - UpdateService: listens on TCP 3232 (and mDNS roro9stack-<id>) while Wi-Fi is Connected; streams into UpdateParser; replies OK/ERR to the sender; remembers the pending version so a Rollback is reported after the reboot - Probation (host-tested): confirm after the first frame + 30 s + Wi-Fi (if configured); roll back if configured Wi-Fi never connects in 3 min - Main loop: full-screen progress while receiving; restart once installed, waiting up to 60 s for Text Entry to end - Settings > Firmware: version, Probation status, push address and name, and the .ota files in /updates on the SD card to install - StorageService.runJob() runs work on the storage task (SD installs) - wifi status prints IP and running version; RORO_TEST_CRASH test hook Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
@@ -22,6 +22,7 @@ const RowDef kRows[] = {
|
|||||||
{Row::OffTimeout, Kind::Choice, "Screen off after"}, {Row::Sound, Kind::Toggle, "Sound & LED"},
|
{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::ProbeMacs, Kind::Toggle, "Probe MACs"}, {Row::Wifi, Kind::Page, "Wi-Fi"},
|
||||||
{Row::Storage, Kind::Page, "Storage"},
|
{Row::Storage, Kind::Page, "Storage"},
|
||||||
|
{Row::Firmware, Kind::Page, "Firmware"},
|
||||||
{Row::About, Kind::Page, "About"},
|
{Row::About, Kind::Page, "About"},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ namespace roro {
|
|||||||
// values, choice lists and validation messages. Rendering and navigation live in the App.
|
// values, choice lists and validation messages. Rendering and navigation live in the App.
|
||||||
class SettingsMenu {
|
class SettingsMenu {
|
||||||
public:
|
public:
|
||||||
enum class Row { LongName, ShortName, Region, Timezone, Brightness, DimTimeout, OffTimeout, Sound, 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 };
|
enum class Kind { Text, Choice, Toggle, Slider, Page };
|
||||||
|
|
||||||
explicit SettingsMenu(Settings& settings) : settings_(settings) {}
|
explicit SettingsMenu(Settings& settings) : settings_(settings) {}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
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
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
#include "firmware_page.h"
|
||||||
|
|
||||||
|
#include <SD.h>
|
||||||
|
|
||||||
|
#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<std::mutex> g(lock_);
|
||||||
|
files_.clear();
|
||||||
|
listed_ = false;
|
||||||
|
}
|
||||||
|
list_.setCount(kFixed);
|
||||||
|
storage_.runJob([this]() {
|
||||||
|
std::vector<std::string> 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<std::mutex> g(lock_);
|
||||||
|
files_ = found;
|
||||||
|
listed_ = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::string> FirmwarePage::files() {
|
||||||
|
std::lock_guard<std::mutex> 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<int>(found.size())) update_.installFromSd(found[pick]);
|
||||||
|
if (confirm_->result() != DialogModel::kPending) confirm_.reset();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
list_.setCount(kFixed + static_cast<int>(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<std::mutex> g(lock_);
|
||||||
|
listed = listed_;
|
||||||
|
}
|
||||||
|
list_.setCount(kFixed + static_cast<int>(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<int>(found.size()) ? found[pick] : "";
|
||||||
|
widgets::dialog(c, "Install update?", name + " is checked before anything is written. The device restarts after.", *confirm_);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace roro
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
#include <mutex>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#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<std::string> files();
|
||||||
|
|
||||||
|
UpdateService& update_;
|
||||||
|
WifiService& wifi_;
|
||||||
|
StorageService& storage_;
|
||||||
|
ListModel list_{theme::kContent.h / theme::kLineHeight};
|
||||||
|
std::unique_ptr<DialogModel> confirm_;
|
||||||
|
|
||||||
|
// Filled on the storage task.
|
||||||
|
std::mutex lock_;
|
||||||
|
std::vector<std::string> files_;
|
||||||
|
bool listed_ = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace roro
|
||||||
@@ -34,6 +34,9 @@ bool SettingsApp::onKey(const KeyEvent& e) {
|
|||||||
case Page::Wifi:
|
case Page::Wifi:
|
||||||
if (!wifiPage_.onKey(e)) page_ = Page::Menu;
|
if (!wifiPage_.onKey(e)) page_ = Page::Menu;
|
||||||
return true;
|
return true;
|
||||||
|
case Page::Firmware:
|
||||||
|
if (!firmwarePage_.onKey(e)) page_ = Page::Menu;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -75,6 +78,10 @@ bool SettingsApp::onMenuKey(const KeyEvent& e) {
|
|||||||
page_ = Page::Wifi;
|
page_ = Page::Wifi;
|
||||||
wifiPage_.enter();
|
wifiPage_.enter();
|
||||||
break;
|
break;
|
||||||
|
case Row::Firmware:
|
||||||
|
page_ = Page::Firmware;
|
||||||
|
firmwarePage_.enter();
|
||||||
|
break;
|
||||||
default: page_ = Page::About; break;
|
default: page_ = Page::About; break;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -124,7 +131,8 @@ bool SettingsApp::onAboutKey(const KeyEvent& e) {
|
|||||||
|
|
||||||
void SettingsApp::update(uint32_t nowMs) {
|
void SettingsApp::update(uint32_t nowMs) {
|
||||||
// Live values on Storage and About.
|
// 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) {
|
if (live && nowMs - lastRefreshMs_ >= 500) {
|
||||||
lastRefreshMs_ = nowMs;
|
lastRefreshMs_ = nowMs;
|
||||||
requestRedraw();
|
requestRedraw();
|
||||||
@@ -183,6 +191,7 @@ void SettingsApp::draw(Canvas& c) {
|
|||||||
case Page::Storage: storagePage_.draw(c); break;
|
case Page::Storage: storagePage_.draw(c); break;
|
||||||
case Page::About: widgets::textLines(c, aboutLines(), 0, area); break;
|
case Page::About: widgets::textLines(c, aboutLines(), 0, area); break;
|
||||||
case Page::Wifi: wifiPage_.draw(c); break;
|
case Page::Wifi: wifiPage_.draw(c); break;
|
||||||
|
case Page::Firmware: firmwarePage_.draw(c); break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
#include "services/battery_service.h"
|
#include "services/battery_service.h"
|
||||||
#include "services/clock_service.h"
|
#include "services/clock_service.h"
|
||||||
#include "services/storage_service.h"
|
#include "services/storage_service.h"
|
||||||
|
#include "apps/firmware_page.h"
|
||||||
#include "apps/storage_page.h"
|
#include "apps/storage_page.h"
|
||||||
#include "apps/wifi_settings_page.h"
|
#include "apps/wifi_settings_page.h"
|
||||||
#include "settings_menu.h"
|
#include "settings_menu.h"
|
||||||
@@ -29,6 +30,7 @@ struct SettingsAppDeps {
|
|||||||
ClockService& clock;
|
ClockService& clock;
|
||||||
WifiService& wifi;
|
WifiService& wifi;
|
||||||
SavedNetworks& savedNetworks;
|
SavedNetworks& savedNetworks;
|
||||||
|
UpdateService& update;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Settings: every user-facing setting, plus the Storage and About pages.
|
// Settings: every user-facing setting, plus the Storage and About pages.
|
||||||
@@ -38,7 +40,8 @@ class SettingsApp : public App {
|
|||||||
: d_(deps),
|
: d_(deps),
|
||||||
menu_(deps.settings),
|
menu_(deps.settings),
|
||||||
wifiPage_(deps.settings, deps.savedNetworks, deps.wifi, deps.bus),
|
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;
|
void onEnter() override;
|
||||||
bool onKey(const KeyEvent& e) override;
|
bool onKey(const KeyEvent& e) override;
|
||||||
void update(uint32_t nowMs) override;
|
void update(uint32_t nowMs) override;
|
||||||
@@ -48,7 +51,7 @@ class SettingsApp : public App {
|
|||||||
void draw(Canvas& c) override;
|
void draw(Canvas& c) override;
|
||||||
|
|
||||||
private:
|
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 onMenuKey(const KeyEvent& e);
|
||||||
bool onTextKey(const KeyEvent& e);
|
bool onTextKey(const KeyEvent& e);
|
||||||
@@ -61,6 +64,7 @@ class SettingsApp : public App {
|
|||||||
SettingsMenu menu_;
|
SettingsMenu menu_;
|
||||||
WifiSettingsPage wifiPage_;
|
WifiSettingsPage wifiPage_;
|
||||||
StoragePage storagePage_;
|
StoragePage storagePage_;
|
||||||
|
FirmwarePage firmwarePage_;
|
||||||
Page page_ = Page::Menu;
|
Page page_ = Page::Menu;
|
||||||
ListModel list_{theme::kContent.h / theme::kLineHeight};
|
ListModel list_{theme::kContent.h / theme::kLineHeight};
|
||||||
ListModel choices_{theme::kContent.h / theme::kLineHeight};
|
ListModel choices_{theme::kContent.h / theme::kLineHeight};
|
||||||
|
|||||||
+42
-5
@@ -19,6 +19,7 @@
|
|||||||
#include "services/clock_service.h"
|
#include "services/clock_service.h"
|
||||||
#include "services/power_service.h"
|
#include "services/power_service.h"
|
||||||
#include "services/storage_service.h"
|
#include "services/storage_service.h"
|
||||||
|
#include "services/update_service.h"
|
||||||
#include "services/wifi_service.h"
|
#include "services/wifi_service.h"
|
||||||
#include "settings.h"
|
#include "settings.h"
|
||||||
#include "storage_paths.h"
|
#include "storage_paths.h"
|
||||||
@@ -43,6 +44,7 @@ static ClockService* clockService;
|
|||||||
static SavedNetworks* savedNetworks;
|
static SavedNetworks* savedNetworks;
|
||||||
static WifiService* wifi;
|
static WifiService* wifi;
|
||||||
static IrcService* irc;
|
static IrcService* irc;
|
||||||
|
static UpdateService* update;
|
||||||
static Notifier* notifier;
|
static Notifier* notifier;
|
||||||
static LauncherApp launcher;
|
static LauncherApp launcher;
|
||||||
static AppManager* apps;
|
static AppManager* apps;
|
||||||
@@ -104,6 +106,7 @@ void setup() {
|
|||||||
savedNetworks = new SavedNetworks(nvs);
|
savedNetworks = new SavedNetworks(nvs);
|
||||||
savedNetworks->load();
|
savedNetworks->load();
|
||||||
wifi = new WifiService(settings, *savedNetworks, *clockService);
|
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);
|
irc = new IrcService(nvs, "roro_" + identity::defaultShortName(), *wifi, *storageService, *clockService, bus);
|
||||||
notifier = new Notifier(bus, settings);
|
notifier = new Notifier(bus, settings);
|
||||||
notifier->onShow = [](uint32_t now, uint32_t until) { power->onNotification(now, until); };
|
notifier->onShow = [](uint32_t now, uint32_t until) { power->onNotification(now, until); };
|
||||||
@@ -113,13 +116,14 @@ void setup() {
|
|||||||
services.add(*storageService);
|
services.add(*storageService);
|
||||||
services.add(*wifi);
|
services.add(*wifi);
|
||||||
services.add(*irc);
|
services.add(*irc);
|
||||||
|
services.add(*update);
|
||||||
|
|
||||||
apps = new AppManager(launcher);
|
apps = new AppManager(launcher);
|
||||||
launcher.setManager(*apps);
|
launcher.setManager(*apps);
|
||||||
apps->registerApp({"irc", "IRC", false, new IrcApp(*irc, *clockService, bus)});
|
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({"wifi-tools", "Wi-Fi Tools", false, new WifiToolsApp(*wifi, *storageService, *clockService)});
|
||||||
apps->registerApp({"settings", "Settings", false,
|
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({"demo", "Widget demo", true, new DemoApp(bus)});
|
||||||
apps->registerApp({"setup", "Setup", true, new SetupApp(settings, *apps)});
|
apps->registerApp({"setup", "Setup", true, new SetupApp(settings, *apps)});
|
||||||
|
|
||||||
@@ -222,9 +226,10 @@ static void serialCommands() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (line == "wifi status")
|
if (line == "wifi status")
|
||||||
Serial.printf("wifi: state %d ssid '%s' rssi %d clock %s heap %u min %u\n", (int)wifi->state(),
|
Serial.printf("wifi: state %d ssid '%s' rssi %d ip %s clock %s heap %u min %u | fw %s%s\n",
|
||||||
wifi->ssid().c_str(), wifi->rssi(), clockService->displayTime().c_str(), ESP.getFreeHeap(),
|
(int)wifi->state(), wifi->ssid().c_str(), wifi->rssi(), wifi->ip().c_str(),
|
||||||
ESP.getMinFreeHeap());
|
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 off") settings.setBool(Setting::Sound, false);
|
||||||
if (line == "sound on") settings.setBool(Setting::Sound, true);
|
if (line == "sound on") settings.setBool(Setting::Sound, true);
|
||||||
if (line == "short") {
|
if (line == "short") {
|
||||||
@@ -240,6 +245,10 @@ static void serialCommands() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void loop() {
|
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 StatusInfo lastStatus;
|
||||||
static uint32_t lastLog = 0;
|
static uint32_t lastLog = 0;
|
||||||
uint32_t now = millis();
|
uint32_t now = millis();
|
||||||
@@ -265,12 +274,40 @@ void loop() {
|
|||||||
Serial.printf("status: heap %u min %u\n", ESP.getFreeHeap(), ESP.getMinFreeHeap());
|
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;
|
if (power->screen() == ScreenState::Off) return;
|
||||||
StatusInfo status = currentStatus();
|
StatusInfo status = currentStatus();
|
||||||
const Toast* toast = notifier->toasts().current(now);
|
const Toast* toast = notifier->toasts().current(now);
|
||||||
bool toastChanged = notifier->toasts().takeChanged();
|
bool toastChanged = notifier->toasts().takeChanged();
|
||||||
if (apps->takeRedraw() || toastChanged || status != lastStatus) {
|
if (apps->takeRedraw() || toastChanged || status != lastStatus || redrawAfterUpdate) {
|
||||||
lastStatus = status;
|
lastStatus = status;
|
||||||
|
redrawAfterUpdate = false;
|
||||||
screen.render(*apps, status, toast);
|
screen.render(*apps, status, toast);
|
||||||
|
update->firstFrameDrawn();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
#include "ota_device.h"
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
#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<const unsigned char*>(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
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <esp_ota_ops.h>
|
||||||
|
#include <mbedtls/pk.h>
|
||||||
|
|
||||||
|
#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
|
||||||
@@ -15,7 +15,7 @@ namespace roro {
|
|||||||
void StorageService::start() {
|
void StorageService::start() {
|
||||||
if (task_) return;
|
if (task_) return;
|
||||||
lock_ = xSemaphoreCreateMutex();
|
lock_ = xSemaphoreCreateMutex();
|
||||||
xTaskCreate(taskEntry, "storage", 6144, this, 1, &task_);
|
xTaskCreate(taskEntry, "storage", 10240, this, 1, &task_); // room for a signature check
|
||||||
}
|
}
|
||||||
|
|
||||||
void StorageService::stop() {
|
void StorageService::stop() {
|
||||||
@@ -75,6 +75,13 @@ void StorageService::requestDelete(std::vector<std::string> paths) {
|
|||||||
if (task_) xTaskNotifyGive(task_);
|
if (task_) xTaskNotifyGive(task_);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void StorageService::runJob(std::function<void()> job) {
|
||||||
|
lock();
|
||||||
|
jobs_.push_back(std::move(job));
|
||||||
|
unlock();
|
||||||
|
if (task_) xTaskNotifyGive(task_);
|
||||||
|
}
|
||||||
|
|
||||||
bool StorageService::requestFormat() {
|
bool StorageService::requestFormat() {
|
||||||
if (formatRequested_ || !task_) return false;
|
if (formatRequested_ || !task_) return false;
|
||||||
formatRequested_ = true;
|
formatRequested_ = true;
|
||||||
@@ -109,6 +116,13 @@ void StorageService::loop() {
|
|||||||
}
|
}
|
||||||
if (wantListing) list();
|
if (wantListing) list();
|
||||||
|
|
||||||
|
lock();
|
||||||
|
std::vector<std::function<void()>> jobs;
|
||||||
|
jobs.swap(jobs_);
|
||||||
|
unlock();
|
||||||
|
for (auto& job : jobs)
|
||||||
|
if (mounted_) job();
|
||||||
|
|
||||||
// Sleep until the next batch, or until a request wakes us early.
|
// Sleep until the next batch, or until a request wakes us early.
|
||||||
ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(kWakeMs));
|
ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(kWakeMs));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
#include <freertos/semphr.h>
|
#include <freertos/semphr.h>
|
||||||
|
|
||||||
#include <deque>
|
#include <deque>
|
||||||
|
#include <functional>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
@@ -37,6 +38,9 @@ class StorageService : public Service {
|
|||||||
std::vector<std::vector<StoredFile>> listing() const; // one vector per kCleanupCategories entry
|
std::vector<std::vector<StoredFile>> listing() const; // one vector per kCleanupCategories entry
|
||||||
void requestDelete(std::vector<std::string> paths);
|
void requestDelete(std::vector<std::string> paths);
|
||||||
|
|
||||||
|
// Runs `job` on the storage task, where card access is safe (e.g. reading an Update File).
|
||||||
|
void runJob(std::function<void()> job);
|
||||||
|
|
||||||
// Erases the whole card: one partition spanning the card, formatted FAT32.
|
// Erases the whole card: one partition spanning the card, formatted FAT32.
|
||||||
bool requestFormat();
|
bool requestFormat();
|
||||||
bool formatting() const { return formatRequested_; }
|
bool formatting() const { return formatRequested_; }
|
||||||
@@ -71,6 +75,7 @@ class StorageService : public Service {
|
|||||||
bool listingReady_ = false;
|
bool listingReady_ = false;
|
||||||
std::vector<std::vector<StoredFile>> listing_;
|
std::vector<std::vector<StoredFile>> listing_;
|
||||||
std::vector<std::string> toDelete_;
|
std::vector<std::string> toDelete_;
|
||||||
|
std::vector<std::function<void()>> jobs_;
|
||||||
volatile bool formatRequested_ = false;
|
volatile bool formatRequested_ = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
#include "update_service.h"
|
||||||
|
|
||||||
|
#include <ESPmDNS.h>
|
||||||
|
#include <SD.h>
|
||||||
|
#include <WiFi.h>
|
||||||
|
#include <esp_ota_ops.h>
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
#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<int>(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<int32_t>(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<uint8_t[]> 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<UpdateService*>(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
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <freertos/FreeRTOS.h>
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#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
|
||||||
@@ -7,6 +7,10 @@
|
|||||||
namespace roro {
|
namespace roro {
|
||||||
|
|
||||||
|
|
||||||
|
std::string WifiService::ip() const {
|
||||||
|
return controller_.state() == WifiController::State::Connected ? WiFi.localIP().toString().c_str() : "";
|
||||||
|
}
|
||||||
|
|
||||||
int WifiService::rssi() const {
|
int WifiService::rssi() const {
|
||||||
return controller_.state() == WifiController::State::Connected ? WiFi.RSSI() : 0;
|
return controller_.state() == WifiController::State::Connected ? WiFi.RSSI() : 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ class WifiService : public Service {
|
|||||||
WifiController::State state() const { return controller_.state(); }
|
WifiController::State state() const { return controller_.state(); }
|
||||||
const std::string& ssid() const { return controller_.ssid(); }
|
const std::string& ssid() const { return controller_.ssid(); }
|
||||||
int rssi() const;
|
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
|
// 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.
|
// arrive in listScan() when listScanDone(), and listScanSeq() counts completed scans.
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
#include <M5Cardputer.h>
|
#include <M5Cardputer.h>
|
||||||
|
|
||||||
|
#include "fonts.h"
|
||||||
|
|
||||||
namespace roro {
|
namespace roro {
|
||||||
|
|
||||||
bool Screen::begin() {
|
bool Screen::begin() {
|
||||||
@@ -21,4 +23,23 @@ void Screen::render(AppManager& apps, const StatusInfo& status, const Toast* toa
|
|||||||
canvas_.pushSprite(&M5Cardputer.Display, 0, 0);
|
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
|
} // namespace roro
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ class Screen {
|
|||||||
public:
|
public:
|
||||||
bool begin(); // allocates the frame buffer (~32 KB at 8-bit colour)
|
bool begin(); // allocates the frame buffer (~32 KB at 8-bit colour)
|
||||||
void render(AppManager& apps, const StatusInfo& status, const Toast* toast);
|
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_; }
|
Canvas& canvas() { return canvas_; }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
|
#include "probation.h"
|
||||||
#include "sha256.h"
|
#include "sha256.h"
|
||||||
#include "update_parser.h"
|
#include "update_parser.h"
|
||||||
#include "version_compare.h"
|
#include "version_compare.h"
|
||||||
@@ -239,6 +240,32 @@ void test_version_comparison() {
|
|||||||
TEST_ASSERT_FALSE(versionOlder("v0.2.1", "4c0be89"));
|
TEST_ASSERT_FALSE(versionOlder("v0.2.1", "4c0be89"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- probation --------------------------------------------------------------
|
||||||
|
|
||||||
|
static int judge(uint32_t up, bool frame, bool configured, bool connected) {
|
||||||
|
return static_cast<int>(Probation::judge(up, frame, configured, connected));
|
||||||
|
}
|
||||||
|
static const int kWait = static_cast<int>(Probation::Verdict::Wait);
|
||||||
|
static const int kConfirm = static_cast<int>(Probation::Verdict::Confirm);
|
||||||
|
static const int kRollBack = static_cast<int>(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() {
|
int main() {
|
||||||
UNITY_BEGIN();
|
UNITY_BEGIN();
|
||||||
RUN_TEST(test_sha256_known_vectors);
|
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_image_larger_than_the_slot_is_refused_before_writing);
|
||||||
RUN_TEST(test_downgrade_is_flagged_but_allowed);
|
RUN_TEST(test_downgrade_is_flagged_but_allowed);
|
||||||
RUN_TEST(test_version_comparison);
|
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();
|
return UNITY_END();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user