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
+144 -15
View File
@@ -4,6 +4,7 @@
#include <ff.h>
#include <sd_diskio.h>
#include <algorithm>
#include <memory>
#include "platform/pins.h"
@@ -14,7 +15,7 @@ namespace roro {
void StorageService::start() {
if (task_) return;
lock_ = xSemaphoreCreateMutex();
xTaskCreate(taskEntry, "storage", 4096, this, 1, &task_);
xTaskCreate(taskEntry, "storage", 6144, this, 1, &task_);
}
void StorageService::stop() {
@@ -26,23 +27,52 @@ void StorageService::stop() {
}
StorageState StorageService::state() const {
xSemaphoreTake(lock_, portMAX_DELAY);
lock();
StorageState copy = monitor_.state();
xSemaphoreGive(lock_);
unlock();
return copy;
}
void StorageService::taskEntry(void* self) {
auto* service = static_cast<StorageService*>(self);
for (;;) {
if (service->formatRequested_) {
service->format();
service->formatRequested_ = false;
}
service->poll();
// Sleep until the next poll, or until a format request wakes us early.
ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(kPollMs));
void StorageService::appendLine(const std::string& path, const std::string& line) {
lock();
size_t bytes = path.size() + line.size();
bool accept = monitor_.state().logsAllowed && pendingBytes_ + bytes <= kMaxPendingBytes;
if (accept) {
pending_.emplace_back(path, line);
pendingBytes_ += bytes;
} else {
dropped_++;
}
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() {
@@ -52,6 +82,38 @@ bool StorageService::requestFormat() {
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() {
if (mounted_) SD.end();
mounted_ = false;
@@ -91,9 +153,76 @@ void StorageService::poll() {
uint64_t total = present ? SD.totalBytes() : 0;
uint64_t used = present ? SD.usedBytes() : 0;
xSemaphoreTake(lock_, portMAX_DELAY);
lock();
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