F1 steps 2-6: the Storage App, its viewers, and Maintenance moved in (#3)

The Storage App browses the SD card: folders first with sizes and dates,
three sorts, one item at a time with a clipboard (c, x, v), rename, delete
after counting what's inside, new folder, details. A listing holds 256
entries and says when a folder has more.

FileOps does the card's work for the App and the console alike, one
operation at a time on the storage task in turns of about 150 ms, so Logs
and Captures are still written during a long copy. A copy shows progress,
can be cancelled (what it wrote is taken back) and compares sizes after.
The read-only rules are checked there: the firmware's top-level folders,
/gemini/cache, and files being written (a Track, a Capture, an upload,
today's IRC Logs). A listing reads the folder straight from FatFs: through
the Arduino File, 329 entries took over two seconds.

Viewers by type: text read a screen at a time whatever the file's size
(logs open at the end), a hex dump, a Capture's packets as the LoRa Scanner
lists them, a Track's summary, and an Update File checked as an install
would check it, without writing anything. Tab shows any file as hex or text.

Settings > Storage is gone: usage, Storage Clean-up and Erase are the App's
Maintenance, behind a warning. The Storage Warning points there.

The Clock sets the system time whatever its source, so files are dated
correctly with a GNSS Fix alone (Q137).

Console: cp, mv, mkdir, du, cancel; rm takes folders and follows the rules;
ls shows dates; Debug Builds get `sd fill`.

424 host tests. Checked on the device: docs/milestones/F1.md.

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-06 08:45:43 +02:00
co-authored by Claude Opus 5.5
parent 7ab8f043d0
commit b7aed8e91c
28 changed files with 2660 additions and 41 deletions
+101 -6
View File
@@ -14,6 +14,7 @@
#include "apps/launcher_app.h"
#include "apps/lora_scanner_app.h"
#include "apps/settings_app.h"
#include "apps/storage_app.h"
#include "apps/system_app.h"
#include "apps/wifi_tools_app.h"
#include "apps/setup_app.h"
@@ -30,10 +31,12 @@
#include "sd_fault.h"
#include "service_manager.h"
#include "platform/identity.h"
#include "file_names.h"
#include "services/battery_service.h"
#include "services/irc_service.h"
#include "services/clock_service.h"
#include "services/debug_console.h"
#include "services/file_ops.h"
#include "services/gemini_service.h"
#include "services/gnss_service.h"
#include "services/power_service.h"
@@ -62,6 +65,8 @@ static Screen screen;
// Constructed in setup(), after the hardware and Settings are ready.
static BatteryService* battery;
static StorageService* storageService;
static FileOps* fileOps;
static std::vector<std::string> filesInUse(const std::string& path, bool folder);
static PowerService* power;
static ClockService* clockService;
static GnssService* gnssService;
@@ -175,6 +180,7 @@ void setup() {
savedNetworks->load();
wifi = new WifiService(settings, *savedNetworks, *clockService);
update = new UpdateService(nvs, *wifi, *savedNetworks, *storageService, bus, settings);
fileOps = new FileOps(*storageService, filesInUse);
irc = new IrcService(nvs, "roro_" + identity::defaultShortName(), *wifi, *storageService, *clockService, bus);
notifier = new Notifier(bus, settings);
notifier->onShow = [](uint32_t now, uint32_t until) { power->onNotification(now, until); };
@@ -204,6 +210,7 @@ void setup() {
apps->registerApp({"gnss", "GNSS", false, new GnssApp(*gnssService, settings)});
apps->registerApp({"gemini", "Gemini", false, new GeminiApp(*geminiService)});
apps->registerApp({"lora", "LoRa Scanner", false, new LoraScannerApp(*radioService, *loraCapture, settings, *clockService)});
apps->registerApp({"storage", "Storage", false, new StorageApp(*fileOps, *storageService, *clockService, *update, bus)});
apps->registerApp({"system", "System", false,
new SystemApp(*wifi, *battery, *storageService, *radioService, *gnssService, nvs)});
apps->registerApp({"settings", "Settings", false,
@@ -397,6 +404,69 @@ static std::vector<TaskSample> tasksBefore;
static uint32_t tasksTotal = 0, tasksDueMs = 0;
static bool tasksPending = false;
// What the firmware is writing right now: the Storage App and the console leave those alone (Q130).
static std::vector<std::string> filesInUse(const std::string& path, bool folder) {
std::vector<std::string> open;
if (gnssService->tracking()) open.push_back(gnssService->trackPath());
if (loraCapture->capturing()) open.push_back(loraCapture->path());
if (upload.active()) open.push_back(upload.partPath());
if (irc->running() && path != "/irc" && files::isInside(path, "/irc")) {
// Today's Logs, and any folder that may hold one: the IRC Service appends whenever a line comes.
std::string date = clockService->localDate();
std::string today = (date.empty() ? "undated" : date) + ".log";
if (folder) open.push_back(files::joinPath(path, today));
else if (files::baseName(path) == today) open.push_back(path);
}
return open;
}
// `cp`, `mv`, `rm`, `mkdir`, `du` run as the Storage App's operations do; the result prints here.
static bool consoleFileOp = false;
static void fileOpsStep() {
FileOps::Status s;
if (!consoleFileOp || !fileOps->finished(s)) return;
consoleFileOp = false;
static const char* const kNames[] = {"", "ls", "du", "cp", "mv", "rm", "mkdir"};
const char* name = kNames[static_cast<int>(s.op)];
if (!s.error.empty()) console.printf("%s: error %s\n", name, s.error.c_str());
else if (s.cancelled) console.printf("%s: cancelled\n", name);
else if (s.op == FileOps::Op::Count) console.printf("du: %s, %u files, %u bytes\n", s.path.c_str(), (unsigned)s.files, (unsigned)s.bytes);
else if (s.op == FileOps::Op::Copy) console.printf("cp: ok %u files, %u bytes in %.1f s\n", (unsigned)s.files, (unsigned)s.doneBytes, s.ms / 1000.0);
else if (s.op == FileOps::Op::Delete) console.printf("rm: ok %u files in %.1f s\n", (unsigned)s.files, s.ms / 1000.0);
else console.printf("%s: ok\n", name);
}
static void fileCommand(const String& line) {
int space = line.indexOf(' ');
std::string command = line.substring(0, space).c_str(), rest = line.substring(space + 1).c_str();
bool force = rest.rfind("-f ", 0) == 0; // replace what's there
if (force) rest = rest.substr(3);
size_t split = rest.find('\t'); // two paths: a tab between them if either has a space
if (split == std::string::npos) split = rest.find(' ');
std::string a = rest.substr(0, split), b = split == std::string::npos ? "" : rest.substr(split + 1);
std::string why;
bool exists = false, folder = false;
if (command == "cancel") return fileOps->cancel();
if (!storageService->state().present) why = "no SD card";
else if (command == "du") why = fileOps->count(rest);
else if (command == "mkdir") why = fileOps->makeFolder(rest);
else if (command == "rm") {
folder = fileOps->isFolder(rest, exists);
why = exists ? fileOps->remove(rest, folder) : "It isn't there";
} else if (b.empty()) why = "two paths, please";
else {
folder = fileOps->isFolder(a, exists);
bool intoExists = false;
bool into = fileOps->isFolder(b, intoExists); // `cp a /folder` keeps the name
if (!exists) why = "It isn't there";
else if (command == "cp") why = into ? fileOps->copy(a, folder, b, files::baseName(a), force) : fileOps->copy(a, folder, files::parentOf(b), files::baseName(b), force);
else why = fileOps->move(a, folder, into ? files::joinPath(b, files::baseName(a)) : b, force);
}
if (!why.empty()) return (void)console.printf("%s: error %s\n", command.c_str(), why.c_str());
consoleFileOp = true;
}
static uint32_t loopPasses = 0; // counted in loop(), for `tasks` (issue #40)
#ifdef RORO_DEBUG
static bool loopSpin = false; // `loop spin on`: no rest between passes, to compare load and radio noise
@@ -417,6 +487,7 @@ static const char* const kHelp =
"reboot restart\n"
"boot other restart into the other app slot (manual Rollback)\n"
"log level <0-5> ESP-IDF log level (0 none ... 5 verbose)\n"
"ls [folder] | du <path> | mkdir <path> | rm <path> | cp [-f] <from> <to> | mv [-f] <from> <to> | cancel the SD card, with the Storage App's rules\n"
"lora probe | lora status | lora rx on|off | lora preset <name> the LoRa radio, receive only\n"
"lora capture start|stop a LoRa Capture to /captures/lora (pcap, LoRaTap)\n"
"lora sweep on [from MHz] [to MHz] [step kHz] | off | dump RSSI across a band (863 870 100)\n"
@@ -431,7 +502,7 @@ static const char* const kHelp =
"wifi dns <a> [b] | wifi dns always on|off | wifi ntp <a> [b] DNS and NTP servers\n"
"gemini get <url> fetch a Gemini page and report header, size, certificate, heap\n"
"irc start | irc stop | irc dump | irc say <buffer> <text>\n"
"ls [folder] | rm <path> | install <path.ota> (Update from SD)\n"
"install <path.ota> Update from SD\n"
"sd card | sd list | cat <path> | log <text> | burst | sound on|off | short | normal\n"
#ifdef RORO_DEBUG
"crash abort|wdt crash on purpose (to test crash reports and Safe Mode)\n"
@@ -439,6 +510,7 @@ static const char* const kHelp =
"loop spin on|off make the main loop spin without resting, to compare load and radio noise\n"
"lora noise test [gnss|quiet] | lora noise report Sweep under one changed condition at a time (Wi-Fi goes off for a moment)\n"
"lora inject <hex> [rssi] [snr] a packet into the LoRa Scanner as if received (nothing is sent)\n"
"sd fill <folder> <count> makes that many small files there, to test a crowded folder\n"
"coredump get (Debug Console only) send the raw core dump: use scripts/rdbg.py coredump\n"
"reset (Debug Console only) restart at once, even if the main loop is stuck\n"
"get <path> | put <path> <size> <sha256> | screenshot (Debug Console only) binary, see rdbg.py\n"
@@ -495,20 +567,42 @@ static void runCommand(String line) {
esp_log_level_set("*", static_cast<esp_log_level_t>(constrain(level, 0, 5)));
console.printf("log level: %d\n", constrain(level, 0, 5));
}
if (line == "ls" || line.startsWith("ls ") || line.startsWith("rm ")) {
if (line.startsWith("cp ") || line.startsWith("mv ") || line.startsWith("rm ") || line.startsWith("mkdir ") ||
line.startsWith("du ") || line == "cancel")
fileCommand(line);
if (line == "ls" || line.startsWith("ls ")) {
if (!storageService->state().present) return (void)console.println("sd: no card");
bool list = !line.startsWith("rm ");
std::string path = line.length() > 3 ? line.substring(3).c_str() : "/";
storageService->runJob([list, path]() { // card access stays on the storage task
if (!list) return (void)console.printf("rm: %s %s\n", path.c_str(), SD.remove(path.c_str()) ? "removed" : "failed");
storageService->runJob([path]() { // card access stays on the storage task
File dir = SD.open(path.c_str());
if (!dir || !dir.isDirectory()) return (void)console.printf("ls: %s is not a folder\n", path.c_str());
for (File f = dir.openNextFile(); f; f = dir.openNextFile())
console.printf("%10u %s%s\n", f.isDirectory() ? 0u : (unsigned)f.size(), f.name(), f.isDirectory() ? "/" : "");
console.printf("%10u %-16s %s%s\n", f.isDirectory() ? 0u : (unsigned)f.size(),
files::formatStamp(static_cast<uint32_t>(f.getLastWrite())).c_str(), f.name(), f.isDirectory() ? "/" : "");
console.printf("ls: end of %s\n", path.c_str());
});
}
if (line.startsWith("install ")) update->installFromSd(line.substring(8).c_str()); // Update from SD
#ifdef RORO_DEBUG
if (line.startsWith("sd fill ")) { // sd fill <folder> <count>: small files, to test a crowded folder
int space = line.lastIndexOf(' ');
std::string folder = line.substring(8, space).c_str();
int count = constrain(line.substring(space + 1).toInt(), 0, 1000);
storageService->runJob([folder, count]() {
int made = 0;
for (int i = 1; i <= count; i++) {
char name[24];
snprintf(name, sizeof name, "file-%04d.txt", i);
File f = SD.open(files::joinPath(folder, name).c_str(), FILE_WRITE);
if (!f) break;
f.printf("file %d\n", i);
f.close();
made++;
}
console.printf("sd fill: %d files in %s\n", made, folder.c_str());
});
}
#endif
if (line == "lora probe" && radioService) radioService->probe(console);
if (line == "lora status" && radioService) radioService->printStatus(console);
if ((line == "lora rx on" || line == "lora rx off") && radioService) radioService->setEcho(line.endsWith("on"));
@@ -912,6 +1006,7 @@ static void loopPass() {
if (noiseTest) noiseTest->step(now);
#endif
noteStableOnce(now);
fileOpsStep();
uploadStep();
printListingWhenReady();
M5Cardputer.update();