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
+8 -43
View File
@@ -14,7 +14,6 @@ using Row = SettingsMenu::Row;
void SettingsApp::onEnter() {
page_ = Page::Menu;
eraseDialog_.reset();
list_.setCount(menu_.count());
}
@@ -28,7 +27,9 @@ bool SettingsApp::onKey(const KeyEvent& e) {
case Page::Menu: return onMenuKey(e);
case Page::Text: return onTextKey(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::Wifi:
if (!wifiPage_.onKey(e)) page_ = Page::Menu;
@@ -66,7 +67,10 @@ bool SettingsApp::onMenuKey(const KeyEvent& e) {
case Kind::Slider: break;
case Kind::Page:
switch (menu_.row(i)) {
case Row::Storage: page_ = Page::Storage; break;
case Row::Storage:
page_ = Page::Storage;
storagePage_.enter();
break;
case Row::Wifi:
page_ = Page::Wifi;
wifiPage_.enter();
@@ -112,21 +116,6 @@ bool SettingsApp::onChoiceKey(const KeyEvent& e) {
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) {
if (e.key == Key::Back) page_ = Page::Menu;
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> lines;
char buf[64];
@@ -210,12 +180,7 @@ void SettingsApp::draw(Canvas& c) {
widgets::list(c, choices_, area, [&](int i) { return (i == current ? "* " : " ") + options[i]; });
break;
}
case Page::Storage:
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::Storage: storagePage_.draw(c); break;
case Page::About: widgets::textLines(c, aboutLines(), 0, area); break;
case Page::Wifi: wifiPage_.draw(c); break;
}
+6 -4
View File
@@ -13,6 +13,7 @@
#include "services/battery_service.h"
#include "services/clock_service.h"
#include "services/storage_service.h"
#include "apps/storage_page.h"
#include "apps/wifi_settings_page.h"
#include "settings_menu.h"
#include "ui/theme.h"
@@ -34,7 +35,10 @@ struct SettingsAppDeps {
class SettingsApp : public App {
public:
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;
bool onKey(const KeyEvent& e) override;
void update(uint32_t nowMs) override;
@@ -49,20 +53,18 @@ class SettingsApp : public App {
bool onMenuKey(const KeyEvent& e);
bool onTextKey(const KeyEvent& e);
bool onChoiceKey(const KeyEvent& e);
bool onStorageKey(const KeyEvent& e);
bool onAboutKey(const KeyEvent& e);
void warn(const std::string& text);
std::vector<std::string> storageLines() const;
std::vector<std::string> aboutLines() const;
SettingsAppDeps d_;
SettingsMenu menu_;
WifiSettingsPage wifiPage_;
StoragePage storagePage_;
Page page_ = Page::Menu;
ListModel list_{theme::kContent.h / theme::kLineHeight};
ListModel choices_{theme::kContent.h / theme::kLineHeight};
LineEditor editor_{39};
std::unique_ptr<DialogModel> eraseDialog_;
int editingRow_ = 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