Files
roro9stack/src/services/storage_service.cpp
T
twislaandClaude Opus 5.5 7b8d9391a4 G1 step 3: the Gemini fetcher (TOFU, redirects, floors, pages via the card)
GeminiService fetches on a short-lived task and hands the App a
GeminiPage: header, the body as lines in 4 KB chunks (TextBuffer: no
large block, no doubling copies), the final URL after up to 5
redirects. Certificates are pinned on first use per host and port; a
change comes back as its own outcome with both fingerprints. No fetch
starts below 55 KB free (Q86).

With a card, the body streams to /gemini/cache/page.gmi in 1 KB pieces
while the connection is open, then loads into RAM once its memory is
back (Q87); StorageService::runAndWait (moved from the Debug Console)
keeps every card access on the storage task. Without a card: RAM, with
the steady and transient floors.

Measured with IRC connected: Cosmos (31.6 KB) went from 4.6 KB to the
whole page on the card and 20 KB on screen; lowest free heap 19.5 KB
in transfer, 43 KB once loaded. `gemini get` and `gemini trust` on the
console. 14 Gemini tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-05 00:58:01 +02:00

268 lines
7.8 KiB
C++

#include "storage_service.h"
#include <SD.h>
#include <ff.h>
#include <sd_diskio.h>
#include <algorithm>
#include <atomic>
#include <memory>
#include "platform/pins.h"
#include "platform/shared_spi.h"
namespace roro {
void StorageService::start() {
if (task_) return;
xTaskCreate(taskEntry, "storage", 6144, this, 1, &task_); // peak 3.9 KB (SD install with its signature check, M2)
}
void StorageService::stop() {
if (!task_) return;
vTaskDelete(task_);
task_ = nullptr;
if (mounted_) SD.end();
mounted_ = false;
}
StorageState StorageService::state() const {
lock();
StorageState copy = monitor_.state();
unlock();
return copy;
}
void StorageService::appendLine(const std::string& path, const std::string& line, bool capture) {
lock();
size_t bytes = path.size() + line.size();
bool allowed = capture ? monitor_.state().capturesAllowed : monitor_.state().logsAllowed;
bool accept = allowed && 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_);
}
void StorageService::runJob(std::function<void()> job) {
lock();
jobs_.push_back(std::move(job));
unlock();
if (task_) xTaskNotifyGive(task_);
}
bool StorageService::runAndWait(std::function<void()> job) {
// Shared with the job, which outlives this wait if the card is missing and it never starts.
// Exactly one side wins the state change: the job (Queued -> Running) or the wait giving up
// (Queued -> Abandoned), so an abandoned job can never touch this stack frame.
enum : int { Queued, Running, Abandoned };
struct Run {
std::atomic<int> state{Queued};
SemaphoreHandle_t done = xSemaphoreCreateBinary();
~Run() { vSemaphoreDelete(done); }
};
auto run = std::make_shared<Run>();
runJob([run, job]() {
int expected = Queued;
if (!run->state.compare_exchange_strong(expected, Running)) return;
job();
xSemaphoreGive(run->done);
});
for (int waited = 0; xSemaphoreTake(run->done, pdMS_TO_TICKS(500)) != pdTRUE; waited += 500) {
int expected = Queued;
if (waited >= 5000 && run->state.compare_exchange_strong(expected, Abandoned)) return false;
}
return true;
}
bool StorageService::requestFormat() {
if (formatRequested_ || !task_) return false;
formatRequested_ = true;
xTaskNotifyGive(task_);
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();
lock();
std::vector<std::function<void()>> jobs;
jobs.swap(jobs_);
unlock();
for (auto& job : jobs)
if (mounted_) job();
// 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;
bool ok = false;
uint8_t pdrv = sdcard_init(pins::kSdCs, &sharedSpi(), 20000000);
if (pdrv != 0xFF) {
constexpr size_t kWorkSize = 4096; // FF_MAX_SS
std::unique_ptr<uint8_t[]> work(new uint8_t[kWorkSize]);
LBA_t partitions[] = {100, 0}; // one partition, 100 % of the card
char drive[3] = {static_cast<char>('0' + pdrv), ':', 0};
MKFS_PARM options = {FM_FAT32, 0, 0, 0, 0};
ok = f_fdisk(pdrv, partitions, work.get()) == FR_OK &&
f_mkfs(drive, &options, work.get(), kWorkSize) == FR_OK;
sdcard_uninit(pdrv);
}
bus_.publish(Event::withText(EventType::Notification, ok ? "SD card formatted" : "SD card format failed",
static_cast<int32_t>(ok ? NotificationLevel::Info : NotificationLevel::Warning)));
}
bool StorageService::mount() {
return SD.begin(pins::kSdCs, sharedSpi(), 20000000, "/sd", 5, false);
}
void StorageService::poll() {
if (mounted_) {
// Detect removal: the root can no longer be opened.
File root = SD.open("/");
if (!root) {
SD.end();
mounted_ = false;
}
}
if (!mounted_) mounted_ = mount();
bool present = mounted_;
uint64_t total = present ? SD.totalBytes() : 0;
uint64_t used = present ? SD.usedBytes() : 0;
lock();
monitor_.update(present, total, used);
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