Public Access
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:
@@ -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.
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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())) : "--:--";
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user