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
+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