M1 step 3: Log writing and Storage Clean-up

- lib/storage_model (host-tested): FAT-safe names, daily Log paths,
  dates from Log/Capture file names, CleanupPlan by category and age,
  byte formatting
- StorageService does all card I/O on its task: queued Log lines are
  written in batches each second (dropped while Logs are paused),
  plus file listing and deletion jobs
- Settings > Storage moves into StoragePage: usage, Clean up
  (category, age with size preview, confirmation), Erase SD card
- Clock: local date for Log names
- Serial: log <text>, sd list

Verified on the device: lines land in /irc/dev/#test/2026-10-02.log
with folders created as needed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
2026-10-02 14:56:06 +02:00
co-authored by Claude Opus 5.5
parent 1a0baf4ac7
commit d3948ccc3a
18 changed files with 785 additions and 74 deletions
+2
View File
@@ -48,6 +48,8 @@ sudo usermod -aG dialout "$USER"
| `sound on` / `sound off` | Toggles the Sound setting (beep + LED) | | `sound on` / `sound off` | Toggles the Sound setting (beep + LED) |
| `short` / `normal` | Screen timeouts 5 s / 10 s, or 30 s / 60 s | | `short` / `normal` | Screen timeouts 5 s / 10 s, or 30 s / 60 s |
| `wifi add <ssid><TAB><password>` | Adds a Saved Network (so credentials stay out of the repo) | | `wifi add <ssid><TAB><password>` | Adds a Saved Network (so credentials stay out of the repo) |
| `log <text>` | Appends a line to a test IRC Log (`/irc/dev/#test/<date>.log`) |
| `sd list` | Lists the files of each Storage Clean-up category |
| `wifi status` | Prints Wi-Fi state, network, signal, clock and free heap | | `wifi status` | Prints Wi-Fi state, network, signal, clock and free heap |
`scripts/flash.sh` stops a running serial log first, since it would hold the port. `scripts/flash.sh` stops a running serial log first, since it would hold the port.
+9
View File
@@ -34,6 +34,15 @@ std::string ClockModel::formatLocalTime(int64_t utcSeconds) {
return buf; return buf;
} }
std::string ClockModel::formatLocalDate(int64_t utcSeconds) {
time_t t = static_cast<time_t>(utcSeconds);
struct tm local;
localtime_r(&t, &local);
char buf[12];
std::snprintf(buf, sizeof(buf), "%04d-%02d-%02d", local.tm_year + 1900, local.tm_mon + 1, local.tm_mday);
return buf;
}
std::string ClockModel::formatAge(int64_t seconds) { std::string ClockModel::formatAge(int64_t seconds) {
char buf[24]; char buf[24];
if (seconds < 60) return "now"; if (seconds < 60) return "now";
+1
View File
@@ -21,6 +21,7 @@ class ClockModel {
static void applyTimezone(const char* posixTz); static void applyTimezone(const char* posixTz);
static std::string formatLocalTime(int64_t utcSeconds); // "HH:MM" static std::string formatLocalTime(int64_t utcSeconds); // "HH:MM"
static std::string formatLocalDate(int64_t utcSeconds); // "YYYY-MM-DD"
static std::string formatAge(int64_t seconds); // "now", "5 min ago", "3 h ago", "2 d ago" static std::string formatAge(int64_t seconds); // "now", "5 min ago", "3 h ago", "2 d ago"
private: private:
+53
View File
@@ -0,0 +1,53 @@
#include "cleanup_plan.h"
#include <cstdio>
#include "storage_paths.h"
namespace roro {
namespace {
int maxAgeDays(CleanupAge age) {
switch (age) {
case CleanupAge::OneMonth: return 30;
case CleanupAge::ThreeMonths: return 91;
case CleanupAge::SixMonths: return 182;
case CleanupAge::OneYear: return 365;
default: return -1;
}
}
} // namespace
CleanupPlan CleanupPlan::make(const std::vector<StoredFile>& files, CleanupAge age, int todayDay) {
CleanupPlan plan;
for (auto& f : files) {
if (f.path.rfind("/notes", 0) == 0) continue; // Notes are never cleaned up
bool selected = age == CleanupAge::Everything;
if (!selected) {
size_t slash = f.path.find_last_of('/');
int day = storage::fileDay(slash == std::string::npos ? f.path : f.path.substr(slash + 1));
selected = day >= 0 && todayDay - day > maxAgeDays(age);
}
if (selected) {
plan.paths.push_back(f.path);
plan.bytes += f.bytes;
}
}
return plan;
}
std::string formatBytes(uint64_t bytes) {
const char* units[] = {"B", "KB", "MB", "GB"};
double v = static_cast<double>(bytes);
int u = 0;
while (v >= 1024 && u < 3) {
v /= 1024;
u++;
}
char buf[16];
if (u == 0 || v >= 10) std::snprintf(buf, sizeof(buf), "%.0f %s", v, units[u]);
else std::snprintf(buf, sizeof(buf), "%.1f %s", v, units[u]);
return buf;
}
} // namespace roro
+40
View File
@@ -0,0 +1,40 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
namespace roro {
struct StoredFile {
std::string path;
uint64_t bytes;
};
// What Storage Clean-up offers (see CONTEXT.md). Notes are deliberately absent.
struct CleanupCategory {
const char* label;
const char* folder;
};
inline const CleanupCategory kCleanupCategories[] = {
{"IRC logs", "/irc"},
{"Probe-request logs", "/wifi/probes"},
{"Wi-Fi captures", "/captures/wifi"},
};
enum class CleanupAge { OneMonth, ThreeMonths, SixMonths, OneYear, Everything };
inline const char* const kCleanupAgeLabels[] = {"Older than 1 month", "Older than 3 months", "Older than 6 months",
"Older than 1 year", "Everything"};
// The files a clean-up would delete, and how much space that frees. Dates come from file names;
// undated files are only removed by Everything.
struct CleanupPlan {
std::vector<std::string> paths;
uint64_t bytes = 0;
static CleanupPlan make(const std::vector<StoredFile>& files, CleanupAge age, int todayDay);
};
std::string formatBytes(uint64_t bytes);
} // namespace roro
+67
View File
@@ -0,0 +1,67 @@
#include "storage_paths.h"
#include <cctype>
namespace roro::storage {
namespace {
// Howard Hinnant's days_from_civil.
int daysFromCivil(int y, int m, int d) {
y -= m <= 2;
int era = (y >= 0 ? y : y - 399) / 400;
int yoe = y - era * 400;
int doy = (153 * (m + (m > 2 ? -3 : 9)) + 2) / 5 + d - 1;
int doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
return era * 146097 + doe - 719468;
}
bool digits(const std::string& s, size_t pos, size_t n, int& out) {
if (pos + n > s.size()) return false;
out = 0;
for (size_t i = pos; i < pos + n; i++) {
if (!std::isdigit(static_cast<unsigned char>(s[i]))) return false;
out = out * 10 + (s[i] - '0');
}
return true;
}
int validDay(int y, int m, int d) {
static const int kDays[] = {31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
if (y < 1970 || m < 1 || m > 12 || d < 1 || d > kDays[m - 1]) return -1;
return daysFromCivil(y, m, d);
}
} // namespace
std::string sanitize(const std::string& name) {
std::string out;
for (char c : name) {
bool forbidden = c == '\\' || c == '/' || c == ':' || c == '*' || c == '?' || c == '"' || c == '<' ||
c == '>' || c == '|' || static_cast<unsigned char>(c) < 0x20;
out += forbidden ? '_' : c;
}
if (out.empty() || out == ".") return "_";
if (out == "..") return "_.";
return out;
}
std::string dailyLogPath(std::initializer_list<std::string> folders, const std::string& date) {
std::string path;
for (auto& f : folders) path += "/" + sanitize(f);
return path + "/" + (date.empty() ? "undated" : date) + ".log";
}
int fileDay(const std::string& name) {
int y, m, d;
if (digits(name, 0, 4, y) && name.size() > 10 && name[4] == '-' && digits(name, 5, 2, m) && name[7] == '-' &&
digits(name, 8, 2, d))
return validDay(y, m, d);
if (digits(name, 0, 4, y) && digits(name, 4, 2, m) && digits(name, 6, 2, d) && name.size() > 8 && name[8] == '-')
return validDay(y, m, d);
return -1;
}
int dayFromUtc(int64_t utcSeconds) { return static_cast<int>(utcSeconds / 86400); }
} // namespace roro::storage
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include <cstdint>
#include <initializer_list>
#include <string>
namespace roro::storage {
// Makes one path component safe for FAT: forbidden characters become '_', never empty or a dot name.
std::string sanitize(const std::string& name);
// "/irc/libera/#roro/2026-10-02.log" from {"irc", "libera", "#roro"} and a local date
// ("YYYY-MM-DD"); an empty date (clock not set yet) gives "undated.log".
std::string dailyLogPath(std::initializer_list<std::string> folders, const std::string& date);
// Day number (days since 1970-01-01) encoded in a Log ("2026-10-02.log") or Capture
// ("20261002-144500.pcap") file name, or -1 if the name carries no valid date.
int fileDay(const std::string& fileName);
int dayFromUtc(int64_t utcSeconds);
} // namespace roro::storage
+8 -43
View File
@@ -14,7 +14,6 @@ using Row = SettingsMenu::Row;
void SettingsApp::onEnter() { void SettingsApp::onEnter() {
page_ = Page::Menu; page_ = Page::Menu;
eraseDialog_.reset();
list_.setCount(menu_.count()); list_.setCount(menu_.count());
} }
@@ -28,7 +27,9 @@ bool SettingsApp::onKey(const KeyEvent& e) {
case Page::Menu: return onMenuKey(e); case Page::Menu: return onMenuKey(e);
case Page::Text: return onTextKey(e); case Page::Text: return onTextKey(e);
case Page::Choice: return onChoiceKey(e); case Page::Choice: return onChoiceKey(e);
case Page::Storage: return onStorageKey(e); case Page::Storage:
if (!storagePage_.onKey(e)) page_ = Page::Menu;
return true;
case Page::About: return onAboutKey(e); case Page::About: return onAboutKey(e);
case Page::Wifi: case Page::Wifi:
if (!wifiPage_.onKey(e)) page_ = Page::Menu; if (!wifiPage_.onKey(e)) page_ = Page::Menu;
@@ -66,7 +67,10 @@ bool SettingsApp::onMenuKey(const KeyEvent& e) {
case Kind::Slider: break; case Kind::Slider: break;
case Kind::Page: case Kind::Page:
switch (menu_.row(i)) { switch (menu_.row(i)) {
case Row::Storage: page_ = Page::Storage; break; case Row::Storage:
page_ = Page::Storage;
storagePage_.enter();
break;
case Row::Wifi: case Row::Wifi:
page_ = Page::Wifi; page_ = Page::Wifi;
wifiPage_.enter(); wifiPage_.enter();
@@ -112,21 +116,6 @@ bool SettingsApp::onChoiceKey(const KeyEvent& e) {
return true; return true;
} }
bool SettingsApp::onStorageKey(const KeyEvent& e) {
if (eraseDialog_) {
eraseDialog_->onKey(e);
if (eraseDialog_->result() == 1 && !d_.storage.requestFormat()) warn("A format is already running");
if (eraseDialog_->result() != DialogModel::kPending) eraseDialog_.reset();
return true;
}
if (e.key == Key::Select) {
if (d_.storage.state().present) eraseDialog_.reset(new DialogModel({"Cancel", "Erase"}));
else warn("No SD card");
}
if (e.key == Key::Back) page_ = Page::Menu;
return true;
}
bool SettingsApp::onAboutKey(const KeyEvent& e) { bool SettingsApp::onAboutKey(const KeyEvent& e) {
if (e.key == Key::Back) page_ = Page::Menu; if (e.key == Key::Back) page_ = Page::Menu;
if (e.key == Key::Char && e.ch == 'w') d_.apps.open("demo"); // hidden widget demo if (e.key == Key::Char && e.ch == 'w') d_.apps.open("demo"); // hidden widget demo
@@ -142,25 +131,6 @@ void SettingsApp::update(uint32_t nowMs) {
} }
} }
std::vector<std::string> SettingsApp::storageLines() const {
auto s = d_.storage.state();
std::vector<std::string> lines;
char buf[64];
if (d_.storage.formatting()) {
lines.push_back("Erasing the SD card...");
} else if (!s.present) {
lines.push_back("No SD card inserted.");
} else {
snprintf(buf, sizeof(buf), "SD card: %u MB, %d%% used", static_cast<unsigned>(s.totalBytes >> 20), s.usedPercent);
lines.push_back(buf);
lines.push_back(s.logsAllowed ? "Logs: recording" : "Logs: paused (over 90%)");
lines.push_back(s.capturesAllowed ? "Captures: allowed" : "Captures: stopped (card full)");
}
lines.push_back("");
lines.push_back("Enter: erase SD card");
return lines;
}
std::vector<std::string> SettingsApp::aboutLines() const { std::vector<std::string> SettingsApp::aboutLines() const {
std::vector<std::string> lines; std::vector<std::string> lines;
char buf[64]; char buf[64];
@@ -210,12 +180,7 @@ void SettingsApp::draw(Canvas& c) {
widgets::list(c, choices_, area, [&](int i) { return (i == current ? "* " : " ") + options[i]; }); widgets::list(c, choices_, area, [&](int i) { return (i == current ? "* " : " ") + options[i]; });
break; break;
} }
case Page::Storage: case Page::Storage: storagePage_.draw(c); break;
widgets::textLines(c, storageLines(), 0, area);
if (eraseDialog_)
widgets::dialog(c, "Erase SD card?", "Everything on the card is deleted. It becomes one FAT32 partition.",
*eraseDialog_);
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;
} }
+6 -4
View File
@@ -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/storage_page.h"
#include "apps/wifi_settings_page.h" #include "apps/wifi_settings_page.h"
#include "settings_menu.h" #include "settings_menu.h"
#include "ui/theme.h" #include "ui/theme.h"
@@ -34,7 +35,10 @@ struct SettingsAppDeps {
class SettingsApp : public App { class SettingsApp : public App {
public: public:
explicit SettingsApp(const SettingsAppDeps& deps) explicit SettingsApp(const SettingsAppDeps& deps)
: d_(deps), menu_(deps.settings), wifiPage_(deps.settings, deps.savedNetworks, deps.wifi, deps.bus) {} : d_(deps),
menu_(deps.settings),
wifiPage_(deps.settings, deps.savedNetworks, deps.wifi, deps.bus),
storagePage_(deps.storage, deps.clock, deps.bus) {}
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;
@@ -49,20 +53,18 @@ class SettingsApp : public App {
bool onMenuKey(const KeyEvent& e); bool onMenuKey(const KeyEvent& e);
bool onTextKey(const KeyEvent& e); bool onTextKey(const KeyEvent& e);
bool onChoiceKey(const KeyEvent& e); bool onChoiceKey(const KeyEvent& e);
bool onStorageKey(const KeyEvent& e);
bool onAboutKey(const KeyEvent& e); bool onAboutKey(const KeyEvent& e);
void warn(const std::string& text); void warn(const std::string& text);
std::vector<std::string> storageLines() const;
std::vector<std::string> aboutLines() const; std::vector<std::string> aboutLines() const;
SettingsAppDeps d_; SettingsAppDeps d_;
SettingsMenu menu_; SettingsMenu menu_;
WifiSettingsPage wifiPage_; WifiSettingsPage wifiPage_;
StoragePage storagePage_;
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};
LineEditor editor_{39}; LineEditor editor_{39};
std::unique_ptr<DialogModel> eraseDialog_;
int editingRow_ = 0; int editingRow_ = 0;
uint32_t lastRefreshMs_ = 0; uint32_t lastRefreshMs_ = 0;
}; };
+171
View File
@@ -0,0 +1,171 @@
#include "storage_page.h"
#include "ui/widgets.h"
namespace roro {
namespace {
constexpr int kAges = sizeof(kCleanupAgeLabels) / sizeof(kCleanupAgeLabels[0]);
constexpr int kCategories = sizeof(kCleanupCategories) / sizeof(kCleanupCategories[0]);
} // namespace
void StoragePage::enter() {
view_ = View::Main;
dialog_.reset();
main_.setCount(kRows);
main_.select(kCleanUp);
}
void StoragePage::warn(const char* text) {
bus_.publish(Event::withText(EventType::Notification, text, static_cast<int32_t>(NotificationLevel::Warning)));
}
uint64_t StoragePage::categoryBytes(int category) const {
uint64_t sum = 0;
if (category < static_cast<int>(listing_.size()))
for (auto& f : listing_[category]) sum += f.bytes;
return sum;
}
CleanupPlan StoragePage::planFor(int age) const {
int today = clock_.today();
auto cutoff = static_cast<CleanupAge>(age);
// Without a clock only "Everything" can be judged.
if (today < 0 && cutoff != CleanupAge::Everything) return {};
return CleanupPlan::make(listing_[categories_.selected()], cutoff, today);
}
bool StoragePage::onKey(const KeyEvent& e) {
if (dialog_) {
dialog_->onKey(e);
int result = dialog_->result();
if (result == 1) {
if (view_ == View::Main) {
storage_.requestFormat();
} else {
storage_.requestDelete(pending_.paths);
enter();
}
}
if (result != DialogModel::kPending) dialog_.reset();
return true;
}
switch (view_) {
case View::Main:
switch (e.key) {
case Key::Up: main_.up(); break;
case Key::Down: main_.down(); break;
case Key::Back: return false;
case Key::Select:
if (!storage_.state().present) {
warn("No SD card");
} else if (main_.selected() == kCleanUp) {
storage_.requestListing();
haveListing_ = false;
categories_.setCount(kCategories);
view_ = View::Categories;
} else if (main_.selected() == kErase) {
dialog_.reset(new DialogModel({"Cancel", "Erase"}));
}
break;
default: break;
}
return true;
case View::Categories:
switch (e.key) {
case Key::Up: categories_.up(); break;
case Key::Down: categories_.down(); break;
case Key::Back: view_ = View::Main; break;
case Key::Select:
if (!haveListing_) break;
if (listing_[categories_.selected()].empty()) {
warn("Nothing to clean up here");
break;
}
ages_.setCount(kAges);
view_ = View::Ages;
break;
default: break;
}
return true;
case View::Ages:
switch (e.key) {
case Key::Up: ages_.up(); break;
case Key::Down: ages_.down(); break;
case Key::Back: view_ = View::Categories; break;
case Key::Select:
pending_ = planFor(ages_.selected());
if (pending_.paths.empty()) warn(clock_.today() < 0 ? "Clock not set: only Everything works"
: "No files that old");
else dialog_.reset(new DialogModel({"Cancel", "Delete"}));
break;
default: break;
}
return true;
}
return true;
}
void StoragePage::draw(Canvas& c) {
const auto& area = theme::kContent;
if (!haveListing_ && storage_.listingReady()) {
listing_ = storage_.listing();
haveListing_ = true;
}
switch (view_) {
case View::Main: {
auto s = storage_.state();
widgets::list(
c, main_, area,
[](int i) -> std::string {
const char* labels[] = {"SD card", "Logs", "Captures", "Clean up", "Erase SD card"};
return labels[i];
},
[&](int i) -> std::string {
if (storage_.formatting() && i == kCard) return "erasing...";
if (!s.present) return i == kCard ? "none" : "";
switch (i) {
case kCard: return formatBytes(s.totalBytes) + ", " + std::to_string(s.usedPercent) + "% used";
case kLogs: return s.logsAllowed ? "recording" : "paused (over 90%)";
case kCaptures: return s.capturesAllowed ? "allowed" : "stopped (full)";
default: return ">";
}
});
if (dialog_)
widgets::dialog(c, "Erase SD card?", "Everything on the card is deleted. It becomes one FAT32 partition.",
*dialog_);
break;
}
case View::Categories:
if (!haveListing_) {
widgets::textLines(c, {"Listing files..."}, 0, area);
break;
}
widgets::list(
c, categories_, area, [](int i) { return std::string(kCleanupCategories[i].label); },
[this](int i) {
return std::to_string(listing_[i].size()) + " files, " + formatBytes(categoryBytes(i));
});
break;
case View::Ages:
widgets::list(
c, ages_, area, [](int i) { return std::string(kCleanupAgeLabels[i]); },
[this](int i) {
auto plan = planFor(i);
return plan.paths.empty() ? std::string("-") : formatBytes(plan.bytes);
});
if (dialog_) {
std::string message = "Delete " + std::to_string(pending_.paths.size()) + " files from " +
kCleanupCategories[categories_.selected()].label + ", freeing " +
formatBytes(pending_.bytes) + "?";
widgets::dialog(c, "Clean up", message, *dialog_);
}
break;
}
}
} // namespace roro
+49
View File
@@ -0,0 +1,49 @@
#pragma once
#include <memory>
#include <vector>
#include "cleanup_plan.h"
#include "dialog_model.h"
#include "event_bus.h"
#include "key_event.h"
#include "list_model.h"
#include "services/clock_service.h"
#include "services/storage_service.h"
#include "ui/canvas.h"
#include "ui/theme.h"
namespace roro {
// Settings → Storage: card usage, Storage Clean-up (category → age → confirm) and erasing the card.
class StoragePage {
public:
StoragePage(StorageService& storage, ClockService& clock, EventBus& bus)
: storage_(storage), clock_(clock), bus_(bus) {}
void enter();
bool onKey(const KeyEvent& e); // false: leave the page
void draw(Canvas& c);
private:
enum class View { Main, Categories, Ages };
enum Row { kCard, kLogs, kCaptures, kCleanUp, kErase, kRows };
CleanupPlan planFor(int age) const;
uint64_t categoryBytes(int category) const;
void warn(const char* text);
StorageService& storage_;
ClockService& clock_;
EventBus& bus_;
View view_ = View::Main;
ListModel main_{theme::kContent.h / theme::kLineHeight};
ListModel categories_{theme::kContent.h / theme::kLineHeight};
ListModel ages_{theme::kContent.h / theme::kLineHeight};
std::vector<std::vector<StoredFile>> listing_;
bool haveListing_ = false;
std::unique_ptr<DialogModel> dialog_;
CleanupPlan pending_;
};
} // namespace roro
+31 -5
View File
@@ -16,6 +16,7 @@
#include "services/storage_service.h" #include "services/storage_service.h"
#include "services/wifi_service.h" #include "services/wifi_service.h"
#include "settings.h" #include "settings.h"
#include "storage_paths.h"
#include "ui/notifier.h" #include "ui/notifier.h"
#include "ui/screen.h" #include "ui/screen.h"
#include "version.h" #include "version.h"
@@ -31,7 +32,7 @@ static Screen screen;
// Constructed in setup(), after the hardware and Settings are ready. // Constructed in setup(), after the hardware and Settings are ready.
static BatteryService* battery; static BatteryService* battery;
static StorageService* storage; static StorageService* storageService;
static PowerService* power; static PowerService* power;
static ClockService* clockService; static ClockService* clockService;
static SavedNetworks* savedNetworks; static SavedNetworks* savedNetworks;
@@ -61,7 +62,7 @@ static StatusInfo currentStatus() {
if (apps->foregroundTitle()) s.title = apps->foregroundTitle(); if (apps->foregroundTitle()) s.title = apps->foregroundTitle();
if (battery->estimator().hasReading()) s.batteryPercent = battery->estimator().percent(); if (battery->estimator().hasReading()) s.batteryPercent = battery->estimator().percent();
s.clock = clockService->displayTime(); s.clock = clockService->displayTime();
auto storageState = storage->state(); auto storageState = storageService->state();
s.sdPresent = storageState.present; s.sdPresent = storageState.present;
s.sdLevel = storageState.level; s.sdLevel = storageState.level;
s.compose = keyMapper.pendingCompose(); s.compose = keyMapper.pendingCompose();
@@ -90,7 +91,7 @@ void setup() {
settings.load(); settings.load();
battery = new BatteryService(bus); battery = new BatteryService(bus);
storage = new StorageService(bus); storageService = new StorageService(bus);
power = new PowerService(settings); power = new PowerService(settings);
clockService = new ClockService(settings, bus); clockService = new ClockService(settings, bus);
savedNetworks = new SavedNetworks(nvs); savedNetworks = new SavedNetworks(nvs);
@@ -101,13 +102,13 @@ void setup() {
services.add(*power); services.add(*power);
services.add(*clockService); services.add(*clockService);
services.add(*battery); services.add(*battery);
services.add(*storage); services.add(*storageService);
services.add(*wifi); services.add(*wifi);
apps = new AppManager(launcher); apps = new AppManager(launcher);
launcher.setManager(*apps); launcher.setManager(*apps);
apps->registerApp({"settings", "Settings", false, apps->registerApp({"settings", "Settings", false,
new SettingsApp({settings, bus, *apps, *battery, *storage, *clockService, *wifi, *savedNetworks})}); new SettingsApp({settings, bus, *apps, *battery, *storageService, *clockService, *wifi, *savedNetworks})});
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)});
@@ -121,6 +122,21 @@ void setup() {
} }
// Dev aid: serial commands to drive the UI without the keyboard. // Dev aid: serial commands to drive the UI without the keyboard.
static bool listingWanted = false;
static void printListingWhenReady() {
if (!listingWanted || !storageService->listingReady()) return;
listingWanted = false;
auto listing = storageService->listing();
for (size_t i = 0; i < listing.size(); i++) {
uint64_t bytes = 0;
for (auto& f : listing[i]) bytes += f.bytes;
Serial.printf("sd list: %-20s %u files, %s\n", kCleanupCategories[i].label, (unsigned)listing[i].size(),
formatBytes(bytes).c_str());
for (auto& f : listing[i]) Serial.printf(" %s (%u B)\n", f.path.c_str(), (unsigned)f.bytes);
}
}
static void serialCommands() { static void serialCommands() {
static String line; static String line;
while (Serial.available()) { while (Serial.available()) {
@@ -147,6 +163,15 @@ static void serialCommands() {
Serial.printf("wifi add: %s\n", error.empty() ? "ok" : error.c_str()); Serial.printf("wifi add: %s\n", error.empty() ? "ok" : error.c_str());
wifi->savedNetworksChanged(); wifi->savedNetworksChanged();
} }
if (line.startsWith("log ")) { // appends to a test IRC Log
std::string path = storage::dailyLogPath({"irc", "dev", "#test"}, clockService->localDate());
storageService->appendLine(path, clockService->displayTime() + " " + line.substring(4).c_str());
Serial.printf("log: queued to %s\n", path.c_str());
}
if (line == "sd list") {
storageService->requestListing();
listingWanted = true;
}
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 clock %s heap %u min %u\n", (int)wifi->state(),
wifi->ssid().c_str(), wifi->rssi(), clockService->displayTime().c_str(), ESP.getFreeHeap(), wifi->ssid().c_str(), wifi->rssi(), clockService->displayTime().c_str(), ESP.getFreeHeap(),
@@ -171,6 +196,7 @@ void loop() {
uint32_t now = millis(); uint32_t now = millis();
serialCommands(); serialCommands();
printListingWhenReady();
M5Cardputer.update(); M5Cardputer.update();
if (M5Cardputer.Keyboard.isChange()) { if (M5Cardputer.Keyboard.isChange()) {
bool swallow = M5Cardputer.Keyboard.isPressed() && power->onKey(now); // only woke the screen bool swallow = M5Cardputer.Keyboard.isPressed() && power->onKey(now); // only woke the screen
+5
View File
@@ -23,6 +23,11 @@ class ClockService : public Service {
bool set(int64_t utcSeconds, TimeSource source) { return model_.set(utcSeconds, source, millis()); } bool set(int64_t utcSeconds, TimeSource source) { return model_.set(utcSeconds, source, millis()); }
const ClockModel& model() const { return model_; } const ClockModel& model() const { return model_; }
// Local "YYYY-MM-DD" once set; otherwise empty (Logs then go to undated files).
std::string localDate() const { return model_.isSet() ? ClockModel::formatLocalDate(model_.utcNow(millis())) : ""; }
// Days since 1970-01-01 (UTC), or -1 if the clock isn't set.
int today() const { return model_.isSet() ? static_cast<int>(model_.utcNow(millis()) / 86400) : -1; }
// "14:05" once set; otherwise "--:--". // "14:05" once set; otherwise "--:--".
std::string displayTime() const { std::string displayTime() const {
return model_.isSet() ? ClockModel::formatLocalTime(model_.utcNow(millis())) : "--:--"; return model_.isSet() ? ClockModel::formatLocalTime(model_.utcNow(millis())) : "--:--";
+144 -15
View File
@@ -4,6 +4,7 @@
#include <ff.h> #include <ff.h>
#include <sd_diskio.h> #include <sd_diskio.h>
#include <algorithm>
#include <memory> #include <memory>
#include "platform/pins.h" #include "platform/pins.h"
@@ -14,7 +15,7 @@ namespace roro {
void StorageService::start() { void StorageService::start() {
if (task_) return; if (task_) return;
lock_ = xSemaphoreCreateMutex(); lock_ = xSemaphoreCreateMutex();
xTaskCreate(taskEntry, "storage", 4096, this, 1, &task_); xTaskCreate(taskEntry, "storage", 6144, this, 1, &task_);
} }
void StorageService::stop() { void StorageService::stop() {
@@ -26,23 +27,52 @@ void StorageService::stop() {
} }
StorageState StorageService::state() const { StorageState StorageService::state() const {
xSemaphoreTake(lock_, portMAX_DELAY); lock();
StorageState copy = monitor_.state(); StorageState copy = monitor_.state();
xSemaphoreGive(lock_); unlock();
return copy; return copy;
} }
void StorageService::taskEntry(void* self) { void StorageService::appendLine(const std::string& path, const std::string& line) {
auto* service = static_cast<StorageService*>(self); lock();
for (;;) { size_t bytes = path.size() + line.size();
if (service->formatRequested_) { bool accept = monitor_.state().logsAllowed && pendingBytes_ + bytes <= kMaxPendingBytes;
service->format(); if (accept) {
service->formatRequested_ = false; pending_.emplace_back(path, line);
} pendingBytes_ += bytes;
service->poll(); } else {
// Sleep until the next poll, or until a format request wakes us early. dropped_++;
ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(kPollMs));
} }
unlock();
}
void StorageService::requestListing() {
lock();
listingRequested_ = true;
listingReady_ = false;
unlock();
if (task_) xTaskNotifyGive(task_);
}
bool StorageService::listingReady() const {
lock();
bool ready = listingReady_;
unlock();
return ready;
}
std::vector<std::vector<StoredFile>> StorageService::listing() const {
lock();
auto copy = listing_;
unlock();
return copy;
}
void StorageService::requestDelete(std::vector<std::string> paths) {
lock();
toDelete_.insert(toDelete_.end(), paths.begin(), paths.end());
unlock();
if (task_) xTaskNotifyGive(task_);
} }
bool StorageService::requestFormat() { bool StorageService::requestFormat() {
@@ -52,6 +82,38 @@ bool StorageService::requestFormat() {
return true; return true;
} }
void StorageService::taskEntry(void* self) { static_cast<StorageService*>(self)->loop(); }
void StorageService::loop() {
uint32_t wakes = kPollEvery; // poll right away
for (;;) {
if (formatRequested_) {
format();
formatRequested_ = false;
wakes = kPollEvery;
}
if (wakes++ >= kPollEvery) {
wakes = 1;
poll();
}
writePending();
lock();
bool wantListing = listingRequested_;
std::vector<std::string> deleting;
deleting.swap(toDelete_);
unlock();
if (!deleting.empty()) {
remove(deleting);
poll();
}
if (wantListing) list();
// Sleep until the next batch, or until a request wakes us early.
ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(kWakeMs));
}
}
void StorageService::format() { void StorageService::format() {
if (mounted_) SD.end(); if (mounted_) SD.end();
mounted_ = false; mounted_ = false;
@@ -91,9 +153,76 @@ void StorageService::poll() {
uint64_t total = present ? SD.totalBytes() : 0; uint64_t total = present ? SD.totalBytes() : 0;
uint64_t used = present ? SD.usedBytes() : 0; uint64_t used = present ? SD.usedBytes() : 0;
xSemaphoreTake(lock_, portMAX_DELAY); lock();
monitor_.update(present, total, used); monitor_.update(present, total, used);
xSemaphoreGive(lock_); unlock();
}
void StorageService::writePending() {
lock();
std::deque<std::pair<std::string, std::string>> batch;
batch.swap(pending_);
pendingBytes_ = 0;
unlock();
if (batch.empty() || !mounted_) return;
// Keep each file's lines in order while opening each file once.
std::stable_sort(batch.begin(), batch.end(), [](const auto& a, const auto& b) { return a.first < b.first; });
File file;
std::string openPath;
for (auto& [path, line] : batch) {
if (path != openPath) {
if (file) file.close();
file = SD.open(path.c_str(), FILE_APPEND, true); // true: create missing folders
openPath = path;
}
if (file) {
file.write(reinterpret_cast<const uint8_t*>(line.data()), line.size());
file.write('\n');
}
}
if (file) file.close();
}
namespace {
void walk(File dir, std::vector<StoredFile>& out) {
for (File f = dir.openNextFile(); f; f = dir.openNextFile()) {
if (f.isDirectory()) walk(f, out);
else out.push_back({f.path(), f.size()});
}
}
} // namespace
void StorageService::list() {
std::vector<std::vector<StoredFile>> result;
for (auto& category : kCleanupCategories) {
std::vector<StoredFile> files;
if (mounted_) {
File dir = SD.open(category.folder);
if (dir && dir.isDirectory()) walk(dir, files);
}
result.push_back(std::move(files));
}
lock();
listing_ = std::move(result);
listingRequested_ = false;
listingReady_ = true;
unlock();
}
void StorageService::remove(const std::vector<std::string>& paths) {
uint64_t freed = 0;
int failed = 0;
for (auto& p : paths) {
File f = SD.open(p.c_str());
uint64_t size = f ? f.size() : 0;
if (f) f.close();
if (SD.remove(p.c_str())) freed += size;
else failed++;
}
std::string text = "Freed " + formatBytes(freed) + (failed ? " (" + std::to_string(failed) + " failed)" : "");
bus_.publish(Event::withText(EventType::Notification, text.c_str(),
static_cast<int32_t>(failed ? NotificationLevel::Warning : NotificationLevel::Info)));
} }
} // namespace roro } // namespace roro
+42 -7
View File
@@ -3,13 +3,20 @@
#include <freertos/FreeRTOS.h> #include <freertos/FreeRTOS.h>
#include <freertos/semphr.h> #include <freertos/semphr.h>
#include <deque>
#include <string>
#include <utility>
#include <vector>
#include "cleanup_plan.h"
#include "service.h" #include "service.h"
#include "storage_monitor.h" #include "storage_monitor.h"
namespace roro { namespace roro {
// Mounts the microSD card (retrying while absent) and feeds its usage to the StorageMonitor. // Owns the microSD card. All card access happens on this Service's own task (the SD driver isn't
// Runs on its own task: a failed mount blocks for seconds and must never stall the UI. // safe across tasks, and a failed mount blocks for seconds): it mounts and watches usage, writes
// queued Log lines in batches, lists files for Storage Clean-up, deletes, and formats.
class StorageService : public Service { class StorageService : public Service {
public: public:
explicit StorageService(EventBus& bus) : monitor_(bus), bus_(bus) {} explicit StorageService(EventBus& bus) : monitor_(bus), bus_(bus) {}
@@ -19,24 +26,52 @@ class StorageService : public Service {
StorageState state() const; StorageState state() const;
// Erases the whole card: one partition spanning the card, formatted FAT32. Runs on the storage // Queues one line for a Log file (created with its folders as needed). Dropped while Logs are
// task; the result arrives as a Notification. Returns false if a format is already pending. // paused (over 90 % full, or no card) or if too much is already waiting.
void appendLine(const std::string& path, const std::string& line);
uint32_t droppedLines() const { return dropped_; }
// Storage Clean-up: list every file of every category, then delete a selection.
void requestListing();
bool listingReady() const;
std::vector<std::vector<StoredFile>> listing() const; // one vector per kCleanupCategories entry
void requestDelete(std::vector<std::string> paths);
// 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_; }
private: private:
static constexpr uint32_t kPollMs = 15000; static constexpr uint32_t kWakeMs = 1000;
static constexpr uint32_t kPollEvery = 15; // wake-ups between usage checks
static constexpr size_t kMaxPendingBytes = 16 * 1024;
static void taskEntry(void* self); static void taskEntry(void* self);
void loop();
void poll(); void poll();
bool mount(); bool mount();
void format(); void format();
void writePending();
void list();
void remove(const std::vector<std::string>& paths);
void lock() const { xSemaphoreTake(lock_, portMAX_DELAY); }
void unlock() const { xSemaphoreGive(lock_); }
StorageMonitor monitor_; StorageMonitor monitor_;
bool mounted_ = false;
EventBus& bus_; EventBus& bus_;
volatile bool formatRequested_ = false; bool mounted_ = false;
TaskHandle_t task_ = nullptr; TaskHandle_t task_ = nullptr;
mutable SemaphoreHandle_t lock_ = nullptr; mutable SemaphoreHandle_t lock_ = nullptr;
// Shared with other tasks, guarded by lock_.
std::deque<std::pair<std::string, std::string>> pending_;
size_t pendingBytes_ = 0;
uint32_t dropped_ = 0;
bool listingRequested_ = false;
bool listingReady_ = false;
std::vector<std::vector<StoredFile>> listing_;
std::vector<std::string> toDelete_;
volatile bool formatRequested_ = false;
}; };
} // namespace roro } // namespace roro
@@ -0,0 +1,74 @@
#include <unity.h>
#include "cleanup_plan.h"
using namespace roro;
void setUp() {}
void tearDown() {}
static const int kToday = 20728; // 2026-10-02
static std::vector<StoredFile> files() {
return {
{"/irc/libera/#roro/2026-10-01.log", 1000}, // 1 day old
{"/irc/libera/#roro/2026-08-01.log", 2000}, // 62 days
{"/irc/libera/#roro/2025-09-01.log", 4000}, // 396 days
{"/irc/libera/#roro/undated.log", 8000},
};
}
void test_age_cutoffs_select_strictly_older_files() {
auto plan = CleanupPlan::make(files(), CleanupAge::OneMonth, kToday);
TEST_ASSERT_EQUAL(2, plan.paths.size());
TEST_ASSERT_EQUAL(6000, plan.bytes);
}
void test_a_year_keeps_everything_younger() {
auto plan = CleanupPlan::make(files(), CleanupAge::OneYear, kToday);
TEST_ASSERT_EQUAL(1, plan.paths.size());
TEST_ASSERT_EQUAL_STRING("/irc/libera/#roro/2025-09-01.log", plan.paths[0].c_str());
}
void test_undated_files_only_go_with_everything() {
auto plan = CleanupPlan::make(files(), CleanupAge::Everything, kToday);
TEST_ASSERT_EQUAL(4, plan.paths.size());
TEST_ASSERT_EQUAL(15000, plan.bytes);
}
void test_nothing_to_delete_is_an_empty_plan() {
auto plan = CleanupPlan::make({{"/irc/a/b/2026-10-02.log", 10}}, CleanupAge::OneMonth, kToday);
TEST_ASSERT_TRUE(plan.paths.empty());
TEST_ASSERT_EQUAL(0, plan.bytes);
}
void test_notes_are_never_selected() {
auto plan = CleanupPlan::make({{"/notes/2020-01-01.log", 10}}, CleanupAge::Everything, kToday);
TEST_ASSERT_TRUE(plan.paths.empty());
}
void test_categories_cover_logs_and_captures_but_not_notes() {
bool notes = false;
for (auto& c : kCleanupCategories) notes |= std::string(c.folder) == "/notes";
TEST_ASSERT_FALSE(notes);
TEST_ASSERT_TRUE(sizeof(kCleanupCategories) / sizeof(kCleanupCategories[0]) >= 3);
}
void test_size_formatting() {
TEST_ASSERT_EQUAL_STRING("512 B", formatBytes(512).c_str());
TEST_ASSERT_EQUAL_STRING("1.5 KB", formatBytes(1536).c_str());
TEST_ASSERT_EQUAL_STRING("340 MB", formatBytes(340ull * 1024 * 1024).c_str());
TEST_ASSERT_EQUAL_STRING("2.1 GB", formatBytes(2254857830ull).c_str());
}
int main() {
UNITY_BEGIN();
RUN_TEST(test_age_cutoffs_select_strictly_older_files);
RUN_TEST(test_a_year_keeps_everything_younger);
RUN_TEST(test_undated_files_only_go_with_everything);
RUN_TEST(test_nothing_to_delete_is_an_empty_plan);
RUN_TEST(test_notes_are_never_selected);
RUN_TEST(test_categories_cover_logs_and_captures_but_not_notes);
RUN_TEST(test_size_formatting);
return UNITY_END();
}
+7
View File
@@ -61,6 +61,12 @@ void test_local_time_in_brussels_summer_and_winter() {
TEST_ASSERT_EQUAL_STRING("13:00", ClockModel::formatLocalTime(k2026_01_15_1200Z).c_str()); TEST_ASSERT_EQUAL_STRING("13:00", ClockModel::formatLocalTime(k2026_01_15_1200Z).c_str());
} }
void test_local_date_in_brussels() {
ClockModel::applyTimezone(kBrussels);
// 2026-07-01 23:30Z is already July 2nd in Brussels (UTC+2)
TEST_ASSERT_EQUAL_STRING("2026-07-02", ClockModel::formatLocalDate(k2026_07_01_1200Z + 11 * 3600 + 1800).c_str());
}
int main() { int main() {
UNITY_BEGIN(); UNITY_BEGIN();
RUN_TEST(test_clock_is_unset_until_a_source_sets_it); RUN_TEST(test_clock_is_unset_until_a_source_sets_it);
@@ -70,5 +76,6 @@ int main() {
RUN_TEST(test_utc_now_survives_millis_wraparound); RUN_TEST(test_utc_now_survives_millis_wraparound);
RUN_TEST(test_relative_age_formatting); RUN_TEST(test_relative_age_formatting);
RUN_TEST(test_local_time_in_brussels_summer_and_winter); RUN_TEST(test_local_time_in_brussels_summer_and_winter);
RUN_TEST(test_local_date_in_brussels);
return UNITY_END(); return UNITY_END();
} }
@@ -0,0 +1,54 @@
#include <unity.h>
#include "storage_paths.h"
using namespace roro;
void setUp() {}
void tearDown() {}
void test_sanitize_replaces_characters_fat_forbids() {
TEST_ASSERT_EQUAL_STRING("a_b_c_d_e_f_g_h_i", storage::sanitize("a\\b/c:d*e?f\"g<h>i").c_str());
TEST_ASSERT_EQUAL_STRING("#roro", storage::sanitize("#roro").c_str());
TEST_ASSERT_EQUAL_STRING("Cl\xC3\xA9ment", storage::sanitize("Cl\xC3\xA9ment").c_str());
}
void test_sanitize_never_returns_empty_or_dot_names() {
TEST_ASSERT_EQUAL_STRING("_", storage::sanitize("").c_str());
TEST_ASSERT_EQUAL_STRING("_", storage::sanitize(".").c_str());
TEST_ASSERT_EQUAL_STRING("_.", storage::sanitize("..").c_str());
}
void test_daily_log_path() {
TEST_ASSERT_EQUAL_STRING("/irc/libera/#roro/2026-10-02.log",
storage::dailyLogPath({"irc", "libera", "#roro"}, "2026-10-02").c_str());
}
void test_daily_log_path_sanitizes_each_part() {
TEST_ASSERT_EQUAL_STRING("/irc/irc.libera.chat_6697/nick_name/undated.log",
storage::dailyLogPath({"irc", "irc.libera.chat:6697", "nick/name"}, "").c_str());
}
void test_parse_date_from_log_and_capture_names() {
TEST_ASSERT_EQUAL(20728, storage::fileDay("2026-10-02.log")); // days since 1970-01-01
TEST_ASSERT_EQUAL(20728, storage::fileDay("20261002-144500.pcap"));
TEST_ASSERT_EQUAL(-1, storage::fileDay("undated.log"));
TEST_ASSERT_EQUAL(-1, storage::fileDay("notes.txt"));
TEST_ASSERT_EQUAL(-1, storage::fileDay("2026-13-02.log"));
}
void test_day_from_utc_seconds() {
TEST_ASSERT_EQUAL(20728, storage::dayFromUtc(1790942400)); // 2026-10-02 12:00Z
TEST_ASSERT_EQUAL(0, storage::dayFromUtc(0));
}
int main() {
UNITY_BEGIN();
RUN_TEST(test_sanitize_replaces_characters_fat_forbids);
RUN_TEST(test_sanitize_never_returns_empty_or_dot_names);
RUN_TEST(test_daily_log_path);
RUN_TEST(test_daily_log_path_sanitizes_each_part);
RUN_TEST(test_parse_date_from_log_and_capture_names);
RUN_TEST(test_day_from_utc_seconds);
return UNITY_END();
}