Public Access
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:
@@ -0,0 +1,458 @@
|
||||
#include "file_viewer.h"
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <SD.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <cstdio>
|
||||
#include <ctime>
|
||||
|
||||
#include "cleanup_plan.h"
|
||||
#include "file_list.h"
|
||||
#include "file_names.h"
|
||||
#include "file_views.h"
|
||||
#include "meshtastic_presets.h"
|
||||
#include "packet_view.h"
|
||||
#include "platform/ota_device.h"
|
||||
#include "ui/fonts.h"
|
||||
#include "ui/widgets.h"
|
||||
#include "update_parser.h"
|
||||
#include "version.h"
|
||||
|
||||
namespace roro {
|
||||
|
||||
namespace {
|
||||
constexpr uint32_t kSliceMs = 150; // like the Storage App's operations: the Logs get their turn
|
||||
constexpr size_t kPiece = 1024;
|
||||
constexpr size_t kMaxPackets = 1000; // their places in the file, 4 KB
|
||||
constexpr size_t kMaxPacketBytes = 271; // LoRaTap and the longest LoRa packet
|
||||
|
||||
// Checks an Update File as an install would, writing nothing.
|
||||
struct NoSink : UpdateSink {
|
||||
bool begin(size_t) override { return true; }
|
||||
bool write(const uint8_t*, size_t) override { return true; }
|
||||
bool finish() override { return true; }
|
||||
void abort() override {}
|
||||
};
|
||||
|
||||
std::string clockTime(uint32_t utcSeconds) {
|
||||
time_t t = static_cast<time_t>(utcSeconds);
|
||||
struct tm local;
|
||||
localtime_r(&t, &local);
|
||||
char s[12];
|
||||
std::snprintf(s, sizeof s, "%02d:%02d:%02d", local.tm_hour, local.tm_min, local.tm_sec);
|
||||
return s;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
struct FileViewer::Scan {
|
||||
std::atomic<bool> done{false}, stop{false};
|
||||
std::atomic<int> percent{0};
|
||||
Mode what = Mode::Gpx;
|
||||
std::string path;
|
||||
// Touched by the storage task until `done`, by the viewer after.
|
||||
fs::File file;
|
||||
bool opened = false;
|
||||
uint32_t at = 0, size = 0;
|
||||
std::string error;
|
||||
files::GpxSummary gpx;
|
||||
files::PcapHeader pcap;
|
||||
std::vector<uint32_t> packets; // where each record starts
|
||||
bool morePackets = false;
|
||||
std::unique_ptr<EcdsaVerifier> verifier;
|
||||
NoSink sink;
|
||||
std::unique_ptr<UpdateParser> parser;
|
||||
std::string version;
|
||||
bool genuine = false, older = false;
|
||||
|
||||
bool slice(); // true when there's nothing more to read
|
||||
static void run(StorageService* storage, std::shared_ptr<Scan> self) {
|
||||
bool over = self->stop || self->slice();
|
||||
if (over) {
|
||||
if (self->file) self->file.close();
|
||||
self->parser.reset();
|
||||
self->verifier.reset();
|
||||
self->done = true;
|
||||
} else {
|
||||
storage->runJob([storage, self]() { run(storage, self); });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
bool FileViewer::Scan::slice() {
|
||||
uint32_t t0 = millis();
|
||||
if (!opened) {
|
||||
opened = true;
|
||||
file = SD.open(path.c_str(), FILE_READ);
|
||||
if (!file) {
|
||||
error = "The card refused to open it";
|
||||
return true;
|
||||
}
|
||||
size = static_cast<uint32_t>(file.size());
|
||||
if (what == Mode::Ota) {
|
||||
verifier.reset(new EcdsaVerifier());
|
||||
parser.reset(new UpdateParser(*verifier, sink, EspOtaSink().capacity(), versionString()));
|
||||
}
|
||||
}
|
||||
std::unique_ptr<uint8_t[]> piece(new uint8_t[kPiece]);
|
||||
while (millis() - t0 < kSliceMs) {
|
||||
if (stop) return true;
|
||||
if (what == Mode::Pcap) {
|
||||
if (at == 0) {
|
||||
int n = file.read(piece.get(), files::kPcapHeaderSize);
|
||||
pcap = files::parsePcapHeader(piece.get(), n < 0 ? 0 : n);
|
||||
if (!pcap.ok) {
|
||||
error = "Not a pcap file this viewer reads";
|
||||
return true;
|
||||
}
|
||||
at = files::kPcapHeaderSize;
|
||||
}
|
||||
files::PcapRecord r;
|
||||
if (!file.seek(at)) return true;
|
||||
int n = file.read(piece.get(), files::kPcapRecordSize);
|
||||
if (n < 0 || !files::parsePcapRecord(piece.get(), n, r) || at + files::kPcapRecordSize + r.length > size) return true;
|
||||
if (packets.size() >= kMaxPackets) {
|
||||
morePackets = true;
|
||||
return true;
|
||||
}
|
||||
packets.push_back(at);
|
||||
at += files::kPcapRecordSize + r.length;
|
||||
continue;
|
||||
}
|
||||
int n = file.read(piece.get(), kPiece);
|
||||
if (n <= 0) break;
|
||||
at += n;
|
||||
percent = size ? static_cast<int>(static_cast<uint64_t>(at) * 100 / size) : 100;
|
||||
if (what == Mode::Gpx) {
|
||||
gpx.feed(reinterpret_cast<const char*>(piece.get()), n);
|
||||
} else {
|
||||
parser->feed(piece.get(), n);
|
||||
if (parser->state() == UpdateParser::State::Failed) break;
|
||||
}
|
||||
}
|
||||
if (at < size && !(parser && parser->state() == UpdateParser::State::Failed) && millis() - t0 >= kSliceMs) return false;
|
||||
if (parser) {
|
||||
genuine = parser->end();
|
||||
version = parser->version();
|
||||
older = parser->isDowngrade();
|
||||
error = parser->error();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
size_t FileViewer::readAt(uint32_t offset, uint8_t* into, size_t len) {
|
||||
size_t got = 0;
|
||||
auto file = file_;
|
||||
std::string path = path_;
|
||||
storage_.runAndWait([&, file, path]() {
|
||||
if (!*file) *file = SD.open(path.c_str(), FILE_READ);
|
||||
if (!*file || !file->seek(offset)) return;
|
||||
int n = file->read(into, len);
|
||||
got = n < 0 ? 0 : n;
|
||||
});
|
||||
return got;
|
||||
}
|
||||
|
||||
void FileViewer::open(const std::string& path, uint32_t size) {
|
||||
close();
|
||||
path_ = path;
|
||||
size_ = size;
|
||||
file_ = std::make_shared<fs::File>();
|
||||
std::string name = files::baseName(path);
|
||||
files::FileKind kind = files::kindOf(name);
|
||||
if (kind == files::FileKind::Unknown) { // what do its first bytes look like?
|
||||
uint8_t head[256];
|
||||
size_t n = readAt(0, head, sizeof head);
|
||||
kind = files::looksLikeText(head, n) ? files::FileKind::Text : files::FileKind::Unknown;
|
||||
}
|
||||
switch (kind) {
|
||||
case files::FileKind::Text: base_ = Mode::Text; break;
|
||||
case files::FileKind::Gpx: base_ = Mode::Gpx; break;
|
||||
case files::FileKind::Pcap: base_ = Mode::Pcap; break;
|
||||
case files::FileKind::Ota: base_ = Mode::Ota; break;
|
||||
default: base_ = Mode::Hex; break;
|
||||
}
|
||||
pager_.reset(new files::TextPager([this](uint32_t offset, uint8_t* into, size_t len) { return readAt(offset, into, len); }, size_,
|
||||
kCols, kRows));
|
||||
if (files::opensAtEnd(name)) pager_->toEnd();
|
||||
hexTop_ = 0;
|
||||
show(base_);
|
||||
if (base_ == Mode::Gpx || base_ == Mode::Pcap || base_ == Mode::Ota) startScan(base_);
|
||||
}
|
||||
|
||||
void FileViewer::startScan(Mode mode) {
|
||||
scan_ = std::make_shared<Scan>();
|
||||
scan_->what = mode;
|
||||
scan_->path = path_;
|
||||
scanShown_ = false;
|
||||
shownPercent_ = -1;
|
||||
auto scan = scan_;
|
||||
StorageService* storage = &storage_;
|
||||
storage_.runJob([storage, scan]() { Scan::run(storage, scan); });
|
||||
}
|
||||
|
||||
void FileViewer::close() {
|
||||
if (scan_) scan_->stop = true; // its job ends at the next piece and frees itself
|
||||
scan_.reset();
|
||||
if (file_) {
|
||||
auto file = file_;
|
||||
storage_.runJob([file]() {
|
||||
if (*file) file->close();
|
||||
});
|
||||
}
|
||||
file_.reset();
|
||||
pager_.reset();
|
||||
confirm_.reset();
|
||||
std::vector<std::string>().swap(shown_);
|
||||
std::vector<std::string>().swap(details_);
|
||||
packets_.setCount(0);
|
||||
rowsFrom_ = -1;
|
||||
}
|
||||
|
||||
void FileViewer::show(Mode mode) {
|
||||
mode_ = mode;
|
||||
stale_ = true;
|
||||
rowsFrom_ = -1;
|
||||
}
|
||||
|
||||
std::string FileViewer::title() const { return files::fitName(files::baseName(path_), 24); }
|
||||
|
||||
void FileViewer::scroll(int lines) {
|
||||
if (mode_ == Mode::Text && pager_) {
|
||||
if (lines > 0) pager_->down(lines);
|
||||
else pager_->up(-lines);
|
||||
} else if (mode_ == Mode::Hex) {
|
||||
uint32_t rows = (size_ + 7) / 8, last = rows > static_cast<uint32_t>(kRows) ? rows - kRows : 0;
|
||||
int64_t to = static_cast<int64_t>(hexTop_) + lines;
|
||||
hexTop_ = static_cast<uint32_t>(std::clamp<int64_t>(to, 0, last));
|
||||
}
|
||||
stale_ = true;
|
||||
}
|
||||
|
||||
void FileViewer::openPacket(int index) {
|
||||
if (!scan_ || !scan_->done || index < 0 || index >= static_cast<int>(scan_->packets.size())) return;
|
||||
uint8_t buf[files::kPcapRecordSize + kMaxPacketBytes];
|
||||
size_t n = readAt(scan_->packets[index], buf, sizeof buf);
|
||||
files::PcapRecord r;
|
||||
if (!files::parsePcapRecord(buf, n, r)) return;
|
||||
const uint8_t* body = buf + files::kPcapRecordSize;
|
||||
size_t have = std::min<size_t>(r.length, n - files::kPcapRecordSize);
|
||||
details_.clear();
|
||||
char line[64];
|
||||
lora::RxInfo rx;
|
||||
bool tap = scan_->pcap.linkType == files::kLinkLoraTap && files::parseLoraTap(body, have, rx);
|
||||
size_t skip = tap ? lora::kLoraTapSize : 0;
|
||||
std::snprintf(line, sizeof line, "%s, %u bytes", clockTime(r.seconds).c_str(), static_cast<unsigned>(r.length - skip));
|
||||
details_.push_back(line);
|
||||
if (tap) {
|
||||
std::snprintf(line, sizeof line, "%.0f dBm, SNR %.1f dB, noise %.0f", rx.rssi, rx.snr, rx.noiseFloor);
|
||||
details_.push_back(line);
|
||||
std::snprintf(line, sizeof line, "%.4f MHz, SF%u, %.0f kHz", rx.frequencyHz / 1e6, rx.spreadingFactor, rx.bandwidthKHz);
|
||||
details_.push_back(line);
|
||||
if (rx.syncWord == meshtastic::kSyncWord)
|
||||
for (auto& l : lora::headerLines(body + skip, have - skip)) details_.push_back(l);
|
||||
}
|
||||
for (auto& l : lora::hexDump(body + skip, have - skip)) details_.push_back(l);
|
||||
if (have < r.length) details_.push_back("(the first bytes only)");
|
||||
detailsTop_ = 0;
|
||||
mode_ = Mode::Packet;
|
||||
}
|
||||
|
||||
bool FileViewer::onKey(const KeyEvent& e) {
|
||||
if (confirm_) {
|
||||
confirm_->onKey(e);
|
||||
if (confirm_->result() == 1) update_.installFromSd(path_);
|
||||
if (confirm_->result() != DialogModel::kPending) confirm_.reset();
|
||||
return true;
|
||||
}
|
||||
if (mode_ == Mode::Packet) {
|
||||
int last = std::max(0, static_cast<int>(details_.size()) - (kRows + 1));
|
||||
if (e.key == Key::Up) detailsTop_ = std::max(0, detailsTop_ - 1);
|
||||
else if (e.key == Key::Down) detailsTop_ = std::min(last, detailsTop_ + 1);
|
||||
else if (e.key == Key::Back || e.key == Key::Select) mode_ = Mode::Pcap;
|
||||
return true;
|
||||
}
|
||||
if (e.key == Key::Back) return false;
|
||||
if (e.key == Key::Tab) {
|
||||
// The file's own view, or what it's made of: text for a Track, bytes for the others.
|
||||
if (mode_ != base_) show(base_);
|
||||
else show(base_ == Mode::Hex || base_ == Mode::Gpx ? Mode::Text : Mode::Hex);
|
||||
return true;
|
||||
}
|
||||
uint32_t ch = e.key == Key::Char ? e.ch : 0;
|
||||
switch (mode_) {
|
||||
case Mode::Text:
|
||||
case Mode::Hex:
|
||||
if (e.key == Key::Up) scroll(-1);
|
||||
else if (e.key == Key::Down) scroll(1);
|
||||
else if (e.key == Key::Left) scroll(-(kRows - 1));
|
||||
else if (e.key == Key::Right) scroll(kRows - 1);
|
||||
else if (ch == 't' || ch == 'T') {
|
||||
if (pager_) pager_->toStart();
|
||||
hexTop_ = 0;
|
||||
stale_ = true;
|
||||
} else if (ch == 'b' || ch == 'B') {
|
||||
if (mode_ == Mode::Text && pager_) pager_->toEnd();
|
||||
else scroll(INT32_MAX / 2);
|
||||
stale_ = true;
|
||||
}
|
||||
break;
|
||||
case Mode::Pcap:
|
||||
if (e.key == Key::Up) packets_.up();
|
||||
else if (e.key == Key::Down) packets_.down();
|
||||
else if (e.key == Key::Left) packets_.pageUp();
|
||||
else if (e.key == Key::Right) packets_.pageDown();
|
||||
else if (e.key == Key::Select) openPacket(packets_.selected());
|
||||
break;
|
||||
case Mode::Ota:
|
||||
if (e.key == Key::Select && scan_ && scan_->done && scan_->genuine) confirm_.reset(new DialogModel({"Cancel", "Install"}));
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool FileViewer::update(uint32_t) {
|
||||
if (!scan_) return false;
|
||||
if (scan_->done && !scanShown_) {
|
||||
scanShown_ = true;
|
||||
if (scan_->what == Mode::Pcap) packets_.setCount(static_cast<int>(scan_->packets.size()));
|
||||
return true;
|
||||
}
|
||||
int percent = scan_->percent;
|
||||
if (!scan_->done && percent != shownPercent_) {
|
||||
shownPercent_ = percent;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void FileViewer::refresh() {
|
||||
if (mode_ == Mode::Text && stale_ && pager_) {
|
||||
shown_ = pager_->lines();
|
||||
stale_ = false;
|
||||
} else if (mode_ == Mode::Hex && stale_) {
|
||||
uint8_t buf[kRows * 8];
|
||||
size_t n = readAt(hexTop_ * 8, buf, sizeof buf);
|
||||
shown_.clear();
|
||||
for (size_t at = 0; at < n; at += 8) shown_.push_back(files::hexRow(hexTop_ * 8 + at, buf + at, std::min<size_t>(8, n - at)));
|
||||
stale_ = false;
|
||||
} else if (mode_ == Mode::Pcap && scan_ && scan_->done && rowsFrom_ != packets_.firstVisible()) {
|
||||
// The rows on screen, read from the card once each time the list moves.
|
||||
rowsFrom_ = packets_.firstVisible();
|
||||
shown_.clear();
|
||||
bool tap = scan_->pcap.linkType == files::kLinkLoraTap;
|
||||
for (int i = rowsFrom_; i < rowsFrom_ + kRows && i < static_cast<int>(scan_->packets.size()); i++) {
|
||||
uint8_t buf[files::kPcapRecordSize + kMaxPacketBytes];
|
||||
size_t n = readAt(scan_->packets[i], buf, sizeof buf);
|
||||
files::PcapRecord r;
|
||||
lora::RxInfo rx;
|
||||
if (!files::parsePcapRecord(buf, n, r)) {
|
||||
shown_.push_back("(unreadable)");
|
||||
continue;
|
||||
}
|
||||
const uint8_t* body = buf + files::kPcapRecordSize;
|
||||
size_t have = std::min<size_t>(r.length, n - files::kPcapRecordSize);
|
||||
if (tap && files::parseLoraTap(body, have, rx)) {
|
||||
shown_.push_back(lora::row({body + lora::kLoraTapSize, have - lora::kLoraTapSize, rx.rssi, rx.snr, true,
|
||||
rx.syncWord == meshtastic::kSyncWord},
|
||||
clockTime(r.seconds)));
|
||||
} else {
|
||||
shown_.push_back(clockTime(r.seconds) + " " + std::to_string(r.length) + " bytes");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void FileViewer::draw(Canvas& c) {
|
||||
const auto& area = theme::kContent;
|
||||
refresh();
|
||||
c.setTextDatum(top_left);
|
||||
theme::Rect body{area.x, area.y + 10, area.w, kRows * theme::kLineHeight};
|
||||
std::string right, keys = "Tab: hex";
|
||||
bool scanning = scan_ && !scan_->done;
|
||||
|
||||
switch (mode_) {
|
||||
case Mode::Text:
|
||||
case Mode::Hex: {
|
||||
c.setFont(&fonts::body);
|
||||
c.setTextColor(size_ == 0 ? theme::kMuted : theme::kText);
|
||||
if (size_ == 0) c.drawString("(empty)", 4, body.y + 1);
|
||||
for (size_t i = 0; i < shown_.size(); i++) c.drawString(shown_[i].c_str(), 4, body.y + 1 + static_cast<int>(i) * theme::kLineHeight);
|
||||
uint32_t at = mode_ == Mode::Text && pager_ ? pager_->top() : hexTop_ * 8;
|
||||
if (size_ > 0) { // where in the file the screen is
|
||||
int barH = 12, barY = body.y + static_cast<int>(static_cast<uint64_t>(body.h - barH) * at / size_);
|
||||
c.fillRect(area.w - 2, barY, 2, barH, theme::kMuted);
|
||||
}
|
||||
right = formatBytes(size_);
|
||||
keys = std::string("Tab: ") + (mode_ != base_ ? "back" : mode_ == Mode::Text ? "hex" : "text") + " t: top b: end";
|
||||
break;
|
||||
}
|
||||
case Mode::Gpx: {
|
||||
std::vector<std::string> lines;
|
||||
if (scanning) lines.push_back("Reading the Track... " + std::to_string(scan_->percent.load()) + "%");
|
||||
else if (scan_ && !scan_->error.empty()) lines.push_back(scan_->error);
|
||||
else if (scan_) lines = scan_->gpx.lines();
|
||||
widgets::textLines(c, lines, 0, body);
|
||||
right = formatBytes(size_);
|
||||
keys = "Tab: the file as text";
|
||||
break;
|
||||
}
|
||||
case Mode::Pcap: {
|
||||
c.setFont(&fonts::body);
|
||||
c.setTextColor(theme::kMuted);
|
||||
if (scanning) c.drawString("Reading the Capture...", 4, body.y + 1);
|
||||
else if (scan_ && !scan_->error.empty()) c.drawString(scan_->error.c_str(), 4, body.y + 1);
|
||||
else if (scan_ && scan_->packets.empty()) c.drawString("No packets in this Capture.", 4, body.y + 1);
|
||||
else if (scan_) {
|
||||
int from = rowsFrom_;
|
||||
widgets::list(c, packets_, body, [&](int i) {
|
||||
size_t row = static_cast<size_t>(i - from);
|
||||
return row < shown_.size() ? shown_[row] : std::string();
|
||||
});
|
||||
right = std::to_string(scan_->packets.size()) + (scan_->morePackets ? "+ packets" : " packets");
|
||||
keys = "Enter: the packet Tab: hex";
|
||||
}
|
||||
break;
|
||||
}
|
||||
case Mode::Packet: widgets::textLines(c, details_, detailsTop_, {area.x + 2, area.y + 2, area.w - 2, area.h - 2}); return;
|
||||
case Mode::Ota: {
|
||||
std::vector<std::string> lines;
|
||||
bool ok = scan_ && scan_->done && scan_->genuine;
|
||||
if (scanning) {
|
||||
lines.push_back("Checking it as an install would:");
|
||||
lines.push_back("signature and contents, " + std::to_string(scan_->percent.load()) + "%");
|
||||
} else if (scan_) {
|
||||
if (!scan_->version.empty()) lines.push_back("Version " + scan_->version);
|
||||
lines.push_back(std::string("Running ") + versionString());
|
||||
if (ok) {
|
||||
lines.push_back("Signed with the project's key, intact.");
|
||||
if (scan_->older) lines.push_back("Older than what's running.");
|
||||
} else {
|
||||
lines.push_back("Not installable:");
|
||||
lines.push_back(scan_->error.empty() ? "it ends too early" : scan_->error);
|
||||
}
|
||||
}
|
||||
widgets::textLines(c, lines, 0, body);
|
||||
right = formatBytes(size_);
|
||||
keys = ok ? "Enter: install Tab: hex" : "Tab: hex";
|
||||
if (confirm_) {
|
||||
widgets::dialog(c, "Install update?", "The device restarts into " + scan_->version + " once it's written.", *confirm_);
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
c.setFont(&fonts::small);
|
||||
c.setTextColor(theme::kMuted);
|
||||
c.setTextDatum(top_left);
|
||||
c.drawString(title().c_str(), 4, area.y + 1);
|
||||
c.setTextDatum(top_right);
|
||||
c.drawString(right.c_str(), area.w - 3, area.y + 1);
|
||||
c.setTextDatum(top_left);
|
||||
c.drawString(keys.c_str(), 4, area.y + area.h - 9);
|
||||
}
|
||||
|
||||
} // namespace roro
|
||||
@@ -0,0 +1,67 @@
|
||||
#pragma once
|
||||
|
||||
#include <FS.h>
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "dialog_model.h"
|
||||
#include "key_event.h"
|
||||
#include "list_model.h"
|
||||
#include "services/storage_service.h"
|
||||
#include "services/update_service.h"
|
||||
#include "text_pager.h"
|
||||
#include "ui/canvas.h"
|
||||
#include "ui/theme.h"
|
||||
|
||||
namespace roro {
|
||||
|
||||
// Looks inside a file for the Storage App (F1, Q134), by type: text read as it's scrolled, a hex
|
||||
// dump, a Capture's packets, a Track's summary, an Update File's version and whether it's
|
||||
// genuine. Tab switches to the text or the hex of the same file. Nothing here changes a file.
|
||||
class FileViewer {
|
||||
public:
|
||||
FileViewer(StorageService& storage, UpdateService& update) : storage_(storage), update_(update) {}
|
||||
|
||||
void open(const std::string& path, uint32_t size);
|
||||
void close();
|
||||
bool onKey(const KeyEvent& e); // false: leave the viewer
|
||||
bool update(uint32_t nowMs); // true: draw again
|
||||
void draw(Canvas& c);
|
||||
|
||||
private:
|
||||
enum class Mode { Text, Hex, Gpx, Pcap, Packet, Ota };
|
||||
static constexpr int kRows = 8;
|
||||
static constexpr int kCols = 38;
|
||||
struct Scan; // what a storage job reads through a whole file for: shared with that job
|
||||
|
||||
size_t readAt(uint32_t offset, uint8_t* into, size_t len);
|
||||
void show(Mode mode);
|
||||
void startScan(Mode mode);
|
||||
void refresh(); // reads what the screen shows, when it has moved
|
||||
void openPacket(int index);
|
||||
void scroll(int lines);
|
||||
std::string title() const;
|
||||
|
||||
StorageService& storage_;
|
||||
UpdateService& update_;
|
||||
std::string path_;
|
||||
uint32_t size_ = 0;
|
||||
Mode mode_ = Mode::Text, base_ = Mode::Text;
|
||||
std::shared_ptr<fs::File> file_; // opened and read on the storage task only
|
||||
std::unique_ptr<files::TextPager> pager_;
|
||||
uint32_t hexTop_ = 0; // in rows of eight bytes
|
||||
std::vector<std::string> shown_; // the rows on screen
|
||||
bool stale_ = true;
|
||||
std::shared_ptr<Scan> scan_;
|
||||
bool scanShown_ = false;
|
||||
int shownPercent_ = -1;
|
||||
ListModel packets_{kRows};
|
||||
int rowsFrom_ = -1;
|
||||
std::vector<std::string> details_;
|
||||
int detailsTop_ = 0;
|
||||
std::unique_ptr<DialogModel> confirm_;
|
||||
};
|
||||
|
||||
} // namespace roro
|
||||
@@ -1,4 +1,4 @@
|
||||
#include "storage_page.h"
|
||||
#include "maintenance_page.h"
|
||||
|
||||
#include "ui/widgets.h"
|
||||
|
||||
@@ -9,25 +9,25 @@ constexpr int kAges = sizeof(kCleanupAgeLabels) / sizeof(kCleanupAgeLabels[0]);
|
||||
constexpr int kCategories = sizeof(kCleanupCategories) / sizeof(kCleanupCategories[0]);
|
||||
} // namespace
|
||||
|
||||
void StoragePage::enter() {
|
||||
void MaintenancePage::enter() {
|
||||
view_ = View::Main;
|
||||
dialog_.reset();
|
||||
main_.setCount(kRows);
|
||||
main_.select(kCleanUp);
|
||||
}
|
||||
|
||||
void StoragePage::warn(const char* text) {
|
||||
void MaintenancePage::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 MaintenancePage::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 {
|
||||
CleanupPlan MaintenancePage::planFor(int age) const {
|
||||
int today = clock_.today();
|
||||
auto cutoff = static_cast<CleanupAge>(age);
|
||||
// Without a clock only "Everything" can be judged.
|
||||
@@ -35,7 +35,7 @@ CleanupPlan StoragePage::planFor(int age) const {
|
||||
return CleanupPlan::make(listing_[categories_.selected()], cutoff, today);
|
||||
}
|
||||
|
||||
bool StoragePage::onKey(const KeyEvent& e) {
|
||||
bool MaintenancePage::onKey(const KeyEvent& e) {
|
||||
if (dialog_) {
|
||||
dialog_->onKey(e);
|
||||
int result = dialog_->result();
|
||||
@@ -109,7 +109,7 @@ bool StoragePage::onKey(const KeyEvent& e) {
|
||||
return true;
|
||||
}
|
||||
|
||||
void StoragePage::draw(Canvas& c) {
|
||||
void MaintenancePage::draw(Canvas& c) {
|
||||
const auto& area = theme::kContent;
|
||||
if (!haveListing_ && storage_.listingReady()) {
|
||||
listing_ = storage_.listing();
|
||||
@@ -15,10 +15,10 @@
|
||||
|
||||
namespace roro {
|
||||
|
||||
// Settings → Storage: card usage, Storage Clean-up (category → age → confirm) and erasing the card.
|
||||
class StoragePage {
|
||||
// Storage → Maintenance: card usage, Storage Clean-up (category → age → confirm) and erasing the card.
|
||||
class MaintenancePage {
|
||||
public:
|
||||
StoragePage(StorageService& storage, ClockService& clock, EventBus& bus)
|
||||
MaintenancePage(StorageService& storage, ClockService& clock, EventBus& bus)
|
||||
: storage_(storage), clock_(clock), bus_(bus) {}
|
||||
|
||||
void enter();
|
||||
@@ -27,9 +27,6 @@ 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:
|
||||
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;
|
||||
@@ -70,10 +67,6 @@ bool SettingsApp::onMenuKey(const KeyEvent& e) {
|
||||
case Kind::Slider: break;
|
||||
case Kind::Page:
|
||||
switch (menu_.row(i)) {
|
||||
case Row::Storage:
|
||||
page_ = Page::Storage;
|
||||
storagePage_.enter();
|
||||
break;
|
||||
case Row::Wifi:
|
||||
page_ = Page::Wifi;
|
||||
wifiPage_.enter();
|
||||
@@ -130,8 +123,8 @@ bool SettingsApp::onAboutKey(const KeyEvent& e) {
|
||||
}
|
||||
|
||||
void SettingsApp::update(uint32_t nowMs) {
|
||||
// Live values on Storage and About.
|
||||
bool live = page_ == Page::Storage || page_ == Page::About || page_ == Page::Firmware ||
|
||||
// Live values on About and Firmware.
|
||||
bool live = page_ == Page::About || page_ == Page::Firmware ||
|
||||
(page_ == Page::Wifi && wifiPage_.live());
|
||||
if (live && nowMs - lastRefreshMs_ >= 500) {
|
||||
lastRefreshMs_ = nowMs;
|
||||
@@ -188,7 +181,6 @@ void SettingsApp::draw(Canvas& c) {
|
||||
widgets::list(c, choices_, area, [&](int i) { return (i == current ? "* " : " ") + options[i]; });
|
||||
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;
|
||||
case Page::Firmware: firmwarePage_.draw(c); break;
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
#include "services/clock_service.h"
|
||||
#include "services/storage_service.h"
|
||||
#include "apps/firmware_page.h"
|
||||
#include "apps/storage_page.h"
|
||||
#include "apps/wifi_settings_page.h"
|
||||
#include "settings_menu.h"
|
||||
#include "ui/theme.h"
|
||||
@@ -33,14 +32,13 @@ struct SettingsAppDeps {
|
||||
UpdateService& update;
|
||||
};
|
||||
|
||||
// Settings: every user-facing setting, plus the Storage and About pages.
|
||||
// Settings: every user-facing setting, plus the Wi-Fi, Firmware and About pages.
|
||||
class SettingsApp : public App {
|
||||
public:
|
||||
explicit SettingsApp(const SettingsAppDeps& deps)
|
||||
: d_(deps),
|
||||
menu_(deps.settings),
|
||||
wifiPage_(deps.settings, deps.savedNetworks, deps.wifi, deps.bus),
|
||||
storagePage_(deps.storage, deps.clock, deps.bus),
|
||||
firmwarePage_(deps.update, deps.wifi, deps.storage) {}
|
||||
void onEnter() override;
|
||||
bool onKey(const KeyEvent& e) override;
|
||||
@@ -51,7 +49,7 @@ class SettingsApp : public App {
|
||||
void draw(Canvas& c) override;
|
||||
|
||||
private:
|
||||
enum class Page { Menu, Text, Choice, Storage, About, Wifi, Firmware };
|
||||
enum class Page { Menu, Text, Choice, About, Wifi, Firmware };
|
||||
|
||||
bool onMenuKey(const KeyEvent& e);
|
||||
bool onTextKey(const KeyEvent& e);
|
||||
@@ -63,7 +61,6 @@ class SettingsApp : public App {
|
||||
SettingsAppDeps d_;
|
||||
SettingsMenu menu_;
|
||||
WifiSettingsPage wifiPage_;
|
||||
StoragePage storagePage_;
|
||||
FirmwarePage firmwarePage_;
|
||||
Page page_ = Page::Menu;
|
||||
ListModel list_{theme::kContent.h / theme::kLineHeight};
|
||||
|
||||
@@ -0,0 +1,566 @@
|
||||
#include "storage_app.h"
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
|
||||
#include "cleanup_plan.h"
|
||||
#include "file_names.h"
|
||||
#include "ui/canvas.h"
|
||||
#include "ui/fonts.h"
|
||||
#include "ui/widgets.h"
|
||||
|
||||
namespace roro {
|
||||
|
||||
using files::baseName;
|
||||
using files::joinPath;
|
||||
using files::parentOf;
|
||||
|
||||
namespace {
|
||||
constexpr uint32_t kMessageMs = 4000;
|
||||
constexpr uint32_t kQuietMs = 300; // an operation shorter than this shows no progress
|
||||
|
||||
const char* kindText(const std::string& name) {
|
||||
switch (files::kindOf(name)) {
|
||||
case files::FileKind::Text: return "Text";
|
||||
case files::FileKind::Gpx: return "Track (GPX)";
|
||||
case files::FileKind::Pcap: return "Capture (pcap)";
|
||||
case files::FileKind::Ota: return "Update File";
|
||||
default: return "File";
|
||||
}
|
||||
}
|
||||
|
||||
std::string plural(uint32_t n, const char* one) { return std::to_string(n) + " " + one + (n == 1 ? "" : "s"); }
|
||||
} // namespace
|
||||
|
||||
void StorageApp::onEnter() {
|
||||
view_ = ESP.getFreeHeap() < kStartFloor ? View::NoMemory : View::Browse;
|
||||
ask_ = Ask::None;
|
||||
dialog_.reset();
|
||||
message_.clear();
|
||||
cardPresent_ = storage_.state().present;
|
||||
// A copy left running is picked up where it is; anything else starts from a fresh listing.
|
||||
if (wait_ != Wait::Work && view_ == View::Browse) open(cwd_);
|
||||
else want_ = cwd_;
|
||||
requestRedraw();
|
||||
}
|
||||
|
||||
void StorageApp::onExit() {
|
||||
if (wait_ != Wait::None && wait_ != Wait::Work) {
|
||||
ops_.cancel();
|
||||
wait_ = Wait::None;
|
||||
}
|
||||
viewer_.close();
|
||||
wantList_ = false;
|
||||
loaded_ = false;
|
||||
list_ = files::FileList(); // its memory only while the App is open
|
||||
rows_.setCount(0);
|
||||
std::vector<std::string>().swap(details_);
|
||||
}
|
||||
|
||||
void StorageApp::say(const std::string& text) {
|
||||
message_ = text;
|
||||
messageMs_ = millis();
|
||||
requestRedraw();
|
||||
}
|
||||
|
||||
// What's on screen stays, with its path, until the new listing is in: cwd_ changes then.
|
||||
void StorageApp::open(const std::string& folder, const std::string& select) {
|
||||
want_ = folder;
|
||||
selectAfter_ = select;
|
||||
wantList_ = true; // started from update(), once the card is free
|
||||
waitSinceMs_ = millis();
|
||||
}
|
||||
|
||||
StorageApp::Item StorageApp::selected() const {
|
||||
int i = rows_.selected();
|
||||
if (!loaded_ || i < 0 || i >= static_cast<int>(list_.count())) return {};
|
||||
Item item;
|
||||
item.path = joinPath(cwd_, list_.name(i));
|
||||
item.folder = list_.folder(i);
|
||||
return item;
|
||||
}
|
||||
|
||||
bool StorageApp::work(const std::string& why, const std::string& selectAfter) {
|
||||
if (!why.empty()) {
|
||||
say(why);
|
||||
return false;
|
||||
}
|
||||
wait_ = Wait::Work;
|
||||
waitSinceMs_ = millis();
|
||||
selectAfter_ = selectAfter;
|
||||
return true;
|
||||
}
|
||||
|
||||
void StorageApp::update(uint32_t nowMs) {
|
||||
bool present = storage_.state().present;
|
||||
if (present != cardPresent_) { // taken out, put in, or erased: back to the top
|
||||
cardPresent_ = present;
|
||||
cwd_ = want_ = "/";
|
||||
selectAfter_.clear();
|
||||
loaded_ = false;
|
||||
list_.clear();
|
||||
rows_.setCount(0);
|
||||
wantList_ = present;
|
||||
clip_ = Item();
|
||||
if (view_ == View::Viewer) viewer_.close();
|
||||
if (view_ == View::Details || view_ == View::Name || view_ == View::Viewer) view_ = View::Browse;
|
||||
requestRedraw();
|
||||
}
|
||||
if (wantList_ && present && wait_ == Wait::None && !ops_.busy() && ops_.list(want_).empty()) {
|
||||
wantList_ = false;
|
||||
wait_ = Wait::List;
|
||||
waitSinceMs_ = nowMs;
|
||||
}
|
||||
if (view_ == View::Viewer && viewer_.update(nowMs)) requestRedraw();
|
||||
FileOps::Status s;
|
||||
if (wait_ != Wait::None && ops_.finished(s)) {
|
||||
onFinished(s);
|
||||
requestRedraw();
|
||||
}
|
||||
// millis(), not nowMs: a message set by a key in this same pass is newer than the pass's own time.
|
||||
uint32_t now = millis();
|
||||
if ((wait_ != Wait::None || wantList_) && now - lastDrawMs_ >= 250) requestRedraw();
|
||||
if (!message_.empty() && now - messageMs_ >= kMessageMs) {
|
||||
message_.clear();
|
||||
requestRedraw();
|
||||
}
|
||||
if (view_ == View::Maintenance && now - lastDrawMs_ >= 1000) requestRedraw();
|
||||
}
|
||||
|
||||
void StorageApp::onFinished(const FileOps::Status& s) {
|
||||
Wait was = wait_;
|
||||
wait_ = Wait::None;
|
||||
switch (was) {
|
||||
case Wait::List: {
|
||||
if (!s.error.empty()) {
|
||||
say(s.error);
|
||||
selectAfter_.clear();
|
||||
// A folder that can't be opened leaves the screen as it is; if it's the one on
|
||||
// screen that went away, back to the top.
|
||||
if (want_ != cwd_ && loaded_) {
|
||||
want_ = cwd_;
|
||||
return;
|
||||
}
|
||||
if (want_ != "/") return open("/");
|
||||
list_.clear();
|
||||
} else {
|
||||
ops_.takeListing(list_);
|
||||
list_.sort(sort_);
|
||||
}
|
||||
cwd_ = want_;
|
||||
loaded_ = true;
|
||||
int count = static_cast<int>(list_.count()) + (cwd_ == "/" ? 1 : 0);
|
||||
int at = selectAfter_.empty() ? -1 : list_.find(selectAfter_);
|
||||
rows_.setCount(count);
|
||||
rows_.select(at >= 0 ? at : selectIndex_);
|
||||
selectAfter_.clear();
|
||||
selectIndex_ = 0;
|
||||
break;
|
||||
}
|
||||
case Wait::CountToDelete:
|
||||
if (!s.error.empty()) say(s.error);
|
||||
else if (!s.cancelled) askDelete(target_, s.files, s.bytes);
|
||||
break;
|
||||
case Wait::CountForDetails:
|
||||
if (view_ != View::Details || details_.empty() || s.cancelled) break;
|
||||
details_.back() = s.error.empty() ? plural(s.files, "file") + ", " + formatBytes(s.bytes) : s.error;
|
||||
break;
|
||||
case Wait::Work: {
|
||||
std::string name = files::fitName(baseName(target_.path), 20);
|
||||
if (s.exists && pasting_) {
|
||||
question_ = name + " exists already in this folder. Replace it?";
|
||||
ask_ = Ask::Replace;
|
||||
dialog_.reset(new DialogModel({"Cancel", "Replace"}));
|
||||
return;
|
||||
}
|
||||
if (!s.error.empty()) say(s.error);
|
||||
else if (s.cancelled) say(s.op == FileOps::Op::Copy ? "Cancelled: nothing was copied" : "Cancelled after " + plural(s.files, "file"));
|
||||
else {
|
||||
switch (s.op) {
|
||||
case FileOps::Op::Copy: say(s.files == 1 ? "Copied " + name : "Copied " + plural(s.files, "file") + " to " + name); break;
|
||||
case FileOps::Op::Move: say((pasting_ ? "Moved " : "Renamed to ") + name); break;
|
||||
case FileOps::Op::Delete: say(s.files == 1 ? "Deleted " + name : "Deleted " + name + ", " + plural(s.files, "file")); break;
|
||||
default: say("Made " + name); break;
|
||||
}
|
||||
// The clipboard must not point at what has moved or gone.
|
||||
bool gone = s.op == FileOps::Op::Delete || s.op == FileOps::Op::Move;
|
||||
if (clip_ && gone && files::isInside(clip_.path, s.path)) clip_ = Item();
|
||||
}
|
||||
open(cwd_, selectAfter_);
|
||||
break;
|
||||
}
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
|
||||
void StorageApp::paste(bool replace) {
|
||||
if (!clip_) return say("Nothing to paste: c copies, x cuts");
|
||||
std::string name = baseName(clip_.path), why;
|
||||
if (cut_) {
|
||||
why = ops_.move(clip_.path, clip_.folder, joinPath(cwd_, name), replace);
|
||||
} else {
|
||||
// A copy next to its original: "a (2).txt", or the next number that's free.
|
||||
if (parentOf(clip_.path) == cwd_)
|
||||
for (int n = 2; n < 100 && list_.find(name) >= 0; n++) name = files::copyName(baseName(clip_.path), n);
|
||||
why = ops_.copy(clip_.path, clip_.folder, cwd_, name, replace);
|
||||
}
|
||||
target_.path = joinPath(cwd_, name);
|
||||
target_.folder = clip_.folder;
|
||||
pasting_ = true;
|
||||
selectIndex_ = rows_.selected(); // where to stay if nothing comes of it
|
||||
work(why, name);
|
||||
}
|
||||
|
||||
void StorageApp::askDelete(const Item& item, uint32_t count, uint32_t bytes) {
|
||||
std::string name = files::fitName(baseName(item.path), 22);
|
||||
if (!item.folder) question_ = "Delete " + name + " (" + formatBytes(bytes) + ")?";
|
||||
else if (count == 0) question_ = "Delete the empty folder " + name + "?";
|
||||
else question_ = "Delete " + name + " and its " + plural(count, "file") + " (" + formatBytes(bytes) + ")?";
|
||||
question_ += " It can't be undone.";
|
||||
target_ = item;
|
||||
ask_ = Ask::Delete;
|
||||
dialog_.reset(new DialogModel({"Cancel", "Delete"}));
|
||||
requestRedraw();
|
||||
}
|
||||
|
||||
void StorageApp::onAnswer(int button) {
|
||||
Ask asked = ask_;
|
||||
ask_ = Ask::None;
|
||||
dialog_.reset();
|
||||
if (button != 1) return;
|
||||
switch (asked) {
|
||||
case Ask::Delete:
|
||||
selectIndex_ = rows_.selected();
|
||||
pasting_ = false;
|
||||
work(ops_.remove(target_.path, target_.folder), "");
|
||||
break;
|
||||
case Ask::Replace: paste(true); break;
|
||||
case Ask::Maintenance:
|
||||
view_ = View::Maintenance;
|
||||
maintenance_.enter();
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
|
||||
void StorageApp::showDetails(const Item& item) {
|
||||
int i = rows_.selected();
|
||||
std::string name = baseName(item.path);
|
||||
details_.clear();
|
||||
details_.push_back(name);
|
||||
if (item.folder) details_.push_back("Folder");
|
||||
else {
|
||||
uint32_t size = list_.size(i);
|
||||
std::string text = std::string(kindText(name)) + ", " + formatBytes(size);
|
||||
if (size >= 1024) text += " (" + std::to_string(size) + " bytes)";
|
||||
details_.push_back(text);
|
||||
}
|
||||
details_.push_back("Modified " + files::formatStamp(list_.modified(i)));
|
||||
details_.push_back("In " + cwd_);
|
||||
std::string why = ops_.whyReadOnly(item.path, item.folder);
|
||||
if (!why.empty()) details_.push_back("Read-only: " + why);
|
||||
if (item.folder) {
|
||||
details_.push_back("Counting...");
|
||||
if (ops_.count(item.path).empty()) wait_ = Wait::CountForDetails;
|
||||
else details_.pop_back();
|
||||
}
|
||||
detailsTop_ = 0;
|
||||
view_ = View::Details;
|
||||
}
|
||||
|
||||
bool StorageApp::onKey(const KeyEvent& e) {
|
||||
requestRedraw();
|
||||
if (view_ == View::NoMemory) return false;
|
||||
if (view_ == View::Maintenance) {
|
||||
if (!maintenance_.onKey(e)) {
|
||||
view_ = View::Browse;
|
||||
open(cwd_); // a Clean-up may have emptied what was shown
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (view_ == View::Viewer) {
|
||||
if (!viewer_.onKey(e)) {
|
||||
viewer_.close();
|
||||
view_ = View::Browse;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (dialog_) {
|
||||
dialog_->onKey(e);
|
||||
if (dialog_->result() != DialogModel::kPending) onAnswer(dialog_->result());
|
||||
return true;
|
||||
}
|
||||
if (view_ == View::Details) {
|
||||
if (e.key == Key::Up) detailsTop_ = std::max(0, detailsTop_ - 1);
|
||||
else if (e.key == Key::Down) detailsTop_++; // clamped when drawn
|
||||
else if (e.key == Key::Back || e.key == Key::Select) {
|
||||
if (wait_ == Wait::CountForDetails) ops_.cancel();
|
||||
view_ = View::Browse;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// A copy, a delete or a count is running: Back stops it, nothing else gets through. The same
|
||||
// while a folder is being read, which nothing stops.
|
||||
if (wantList_) return true;
|
||||
if (wait_ != Wait::None) {
|
||||
if (e.key == Key::Back && wait_ != Wait::List) ops_.cancel();
|
||||
return true;
|
||||
}
|
||||
if (view_ == View::Name) return onNameKey(e);
|
||||
return onBrowseKey(e);
|
||||
}
|
||||
|
||||
bool StorageApp::onNameKey(const KeyEvent& e) {
|
||||
switch (e.key) {
|
||||
case Key::Char: editor_.insert(e.ch); break;
|
||||
case Key::Delete: editor_.backspace(); break;
|
||||
case Key::Left: editor_.left(); break;
|
||||
case Key::Right: editor_.right(); break;
|
||||
case Key::Back: view_ = View::Browse; break;
|
||||
case Key::Select: {
|
||||
const std::string& name = editor_.text();
|
||||
std::string why = files::checkName(name);
|
||||
if (!why.empty()) {
|
||||
say(why);
|
||||
break;
|
||||
}
|
||||
pasting_ = false;
|
||||
bool started = true;
|
||||
if (!renaming_) {
|
||||
target_.path = joinPath(cwd_, name);
|
||||
target_.folder = true;
|
||||
started = work(ops_.makeFolder(target_.path), name);
|
||||
} else if (name != baseName(target_.path)) {
|
||||
std::string to = joinPath(cwd_, name);
|
||||
started = work(ops_.move(target_.path, target_.folder, to), name);
|
||||
if (started) target_.path = to;
|
||||
}
|
||||
if (started) view_ = View::Browse;
|
||||
break;
|
||||
}
|
||||
default: break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool StorageApp::onBrowseKey(const KeyEvent& e) {
|
||||
auto askMaintenance = [this]() {
|
||||
question_ = "Clean-up and Erase delete files for good: there is no undo.";
|
||||
ask_ = Ask::Maintenance;
|
||||
dialog_.reset(new DialogModel({"Back", "Go on"}));
|
||||
};
|
||||
if (e.key == Key::Back) {
|
||||
if (cwd_ == "/" || !cardPresent_) return false; // leaves the App
|
||||
open(parentOf(cwd_), baseName(cwd_));
|
||||
return true;
|
||||
}
|
||||
uint32_t ch = e.key == Key::Char ? (e.ch >= 'A' && e.ch <= 'Z' ? e.ch + 32 : e.ch) : 0;
|
||||
if (ch == 'm') askMaintenance();
|
||||
if (!cardPresent_ || !loaded_) return true;
|
||||
|
||||
Item item = selected();
|
||||
switch (e.key) {
|
||||
case Key::Up: rows_.up(); return true;
|
||||
case Key::Down: rows_.down(); return true;
|
||||
case Key::Left: rows_.pageUp(); return true;
|
||||
case Key::Right: rows_.pageDown(); return true;
|
||||
case Key::Select:
|
||||
if (onMaintenanceRow()) askMaintenance();
|
||||
else if (item && item.folder) open(item.path);
|
||||
else if (item) {
|
||||
viewer_.open(item.path, list_.size(rows_.selected()));
|
||||
view_ = View::Viewer;
|
||||
}
|
||||
return true;
|
||||
case Key::Delete: ch = 'd'; break;
|
||||
default: break;
|
||||
}
|
||||
switch (ch) {
|
||||
case 'v': paste(false); return true;
|
||||
case 'n':
|
||||
renaming_ = false;
|
||||
editor_.setText("");
|
||||
view_ = View::Name;
|
||||
return true;
|
||||
case 's': {
|
||||
sort_ = sort_ == files::FileSort::Name ? files::FileSort::Date : sort_ == files::FileSort::Date ? files::FileSort::Size : files::FileSort::Name;
|
||||
std::string keep = item ? baseName(item.path) : "";
|
||||
bool onLast = onMaintenanceRow();
|
||||
list_.sort(sort_);
|
||||
if (!onLast) rows_.select(std::max(0, list_.find(keep)));
|
||||
say(sort_ == files::FileSort::Name ? "Sorted by name" : sort_ == files::FileSort::Date ? "Sorted by date, newest first" : "Sorted by size, biggest first");
|
||||
return true;
|
||||
}
|
||||
default: break;
|
||||
}
|
||||
if (!item || !(ch == 'c' || ch == 'x' || ch == 'r' || ch == 'd' || ch == 'i')) return true;
|
||||
|
||||
std::string name = files::fitName(baseName(item.path), 18);
|
||||
if (ch == 'i') {
|
||||
showDetails(item);
|
||||
return true;
|
||||
}
|
||||
if (ch == 'c') {
|
||||
clip_ = item;
|
||||
cut_ = false;
|
||||
say("v pastes a copy of " + name);
|
||||
return true;
|
||||
}
|
||||
// Cut, rename and delete change the original: the rules of Q130 first.
|
||||
std::string why = ops_.whyReadOnly(item.path, item.folder);
|
||||
if (!why.empty()) {
|
||||
say(why);
|
||||
return true;
|
||||
}
|
||||
if (ch == 'x') {
|
||||
clip_ = item;
|
||||
cut_ = true;
|
||||
say("v moves " + name + " here");
|
||||
} else if (ch == 'r') {
|
||||
target_ = item;
|
||||
renaming_ = true;
|
||||
editor_.setText(baseName(item.path));
|
||||
view_ = View::Name;
|
||||
} else if (item.folder) { // 'd': what's inside is counted first (Q132)
|
||||
target_ = item;
|
||||
std::string busy = ops_.count(item.path);
|
||||
if (!busy.empty()) say(busy);
|
||||
else {
|
||||
wait_ = Wait::CountToDelete;
|
||||
waitSinceMs_ = millis();
|
||||
}
|
||||
} else {
|
||||
askDelete(item, 1, list_.size(rows_.selected()));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void StorageApp::draw(Canvas& c) {
|
||||
lastDrawMs_ = millis();
|
||||
const auto& area = theme::kContent;
|
||||
c.setTextDatum(top_left);
|
||||
switch (view_) {
|
||||
case View::NoMemory:
|
||||
c.setFont(&fonts::bold);
|
||||
c.setTextColor(theme::kWarning);
|
||||
c.drawString("Not enough memory", 4, area.y + 4);
|
||||
c.setFont(&fonts::body);
|
||||
c.setTextColor(theme::kMuted);
|
||||
c.drawString("Close what's running (IRC, a page", 4, area.y + 22);
|
||||
c.drawString("in Gemini) and open Storage again.", 4, area.y + 22 + theme::kLineHeight);
|
||||
return;
|
||||
case View::Maintenance: maintenance_.draw(c); return;
|
||||
case View::Viewer: viewer_.draw(c); return;
|
||||
case View::Details: {
|
||||
std::vector<std::string> lines;
|
||||
auto measure = widgets::bodyMeasure(c);
|
||||
for (auto& d : details_)
|
||||
for (auto& l : wrapText(d, area.w - 12, measure)) lines.push_back(l);
|
||||
int rows = (area.h - 2) / theme::kLineHeight;
|
||||
detailsTop_ = std::clamp(detailsTop_, 0, std::max(0, static_cast<int>(lines.size()) - rows));
|
||||
widgets::textLines(c, lines, detailsTop_, {area.x + 2, area.y + 2, area.w - 2, area.h - 2});
|
||||
return;
|
||||
}
|
||||
default: break;
|
||||
}
|
||||
drawBrowse(c);
|
||||
if (view_ == View::Name) {
|
||||
theme::Rect box{16, 44, theme::kWidth - 32, 48};
|
||||
c.fillRect(box.x, box.y, box.w, box.h, theme::kBackground);
|
||||
c.drawRect(box.x, box.y, box.w, box.h, theme::kAccent);
|
||||
c.setFont(&fonts::bold);
|
||||
c.setTextColor(theme::kText);
|
||||
c.drawString(renaming_ ? "Rename" : "New folder", box.x + 6, box.y + 4);
|
||||
widgets::lineEditor(c, editor_, {box.x + 6, box.y + 22, box.w - 12, theme::kLineHeight + 4});
|
||||
}
|
||||
if (dialog_) {
|
||||
const char* title = ask_ == Ask::Delete ? "Delete" : ask_ == Ask::Replace ? "Replace" : "Maintenance";
|
||||
widgets::dialog(c, title, question_, *dialog_);
|
||||
} else if ((wait_ == Wait::Work || wait_ == Wait::CountToDelete) && millis() - waitSinceMs_ >= kQuietMs) {
|
||||
drawProgress(c);
|
||||
}
|
||||
}
|
||||
|
||||
void StorageApp::drawBrowse(Canvas& c) {
|
||||
const auto& area = theme::kContent;
|
||||
StorageState card = storage_.state();
|
||||
|
||||
// Where we are, and how much room is left (Q129).
|
||||
c.setFont(&fonts::small);
|
||||
c.setTextColor(theme::kMuted);
|
||||
std::string right;
|
||||
if (card.present) {
|
||||
right = formatBytes(card.totalBytes - card.usedBytes) + " free";
|
||||
if (loaded_ && list_.more() > 0) right = "first " + std::to_string(list_.count()) + " of " + std::to_string(list_.count() + list_.more());
|
||||
}
|
||||
size_t room = static_cast<size_t>((area.w - 12) / 5) - right.size() - 1;
|
||||
std::string path = cwd_.size() <= room ? cwd_ : ".." + cwd_.substr(cwd_.size() - (room - 2));
|
||||
c.drawString(path.c_str(), 4, area.y + 1);
|
||||
c.setTextDatum(top_right);
|
||||
c.drawString(right.c_str(), area.w - 3, area.y + 1);
|
||||
c.setTextDatum(top_left);
|
||||
|
||||
theme::Rect rows{area.x, area.y + 10, area.w, kRows * theme::kLineHeight};
|
||||
c.setFont(&fonts::body);
|
||||
if (!card.present) {
|
||||
c.setTextColor(theme::kWarning);
|
||||
c.drawString("No SD card", 4, rows.y + 4);
|
||||
} else if (!loaded_) {
|
||||
// Nothing yet: the footer says what's being read.
|
||||
} else if (rows_.count() == 0) {
|
||||
c.setTextColor(theme::kMuted);
|
||||
c.drawString("Nothing in this folder.", 4, rows.y + 4);
|
||||
c.drawString("n makes a folder, v pastes.", 4, rows.y + 4 + theme::kLineHeight);
|
||||
} else {
|
||||
int entries = static_cast<int>(list_.count());
|
||||
auto value = [&](int i) {
|
||||
return i >= entries ? std::string(">") : files::rowDetail(list_.folder(i), list_.size(i), list_.modified(i));
|
||||
};
|
||||
widgets::list(
|
||||
c, rows_, rows,
|
||||
[&](int i) {
|
||||
if (i >= entries) return std::string("Maintenance");
|
||||
size_t fits = static_cast<size_t>((area.w - 22) / 6) - value(i).size();
|
||||
return files::fitName(list_.name(i), fits);
|
||||
},
|
||||
value);
|
||||
}
|
||||
|
||||
// Footer: a message for a few seconds, what v would paste, or the keys.
|
||||
c.setFont(&fonts::small);
|
||||
bool showMessage = !message_.empty() && millis() - messageMs_ < kMessageMs;
|
||||
c.setTextColor(showMessage ? theme::kWarning : theme::kMuted);
|
||||
std::string keys = "c x v:paste r:name d:del n:new i:info s:sort";
|
||||
if ((wantList_ || wait_ == Wait::List) && millis() - waitSinceMs_ >= kQuietMs) keys = "Reading " + files::fitName(want_, 30) + "...";
|
||||
else if (clip_) keys = std::string("v: ") + (cut_ ? "move " : "copy ") + files::fitName(baseName(clip_.path), 20) + " here";
|
||||
c.drawString(showMessage ? message_.c_str() : keys.c_str(), 4, area.y + area.h - 9);
|
||||
}
|
||||
|
||||
void StorageApp::drawProgress(Canvas& c) {
|
||||
FileOps::Status s = ops_.status();
|
||||
theme::Rect box{20, 40, theme::kWidth - 40, 58};
|
||||
c.fillRect(box.x, box.y, box.w, box.h, theme::kBackground);
|
||||
c.drawRect(box.x, box.y, box.w, box.h, theme::kAccent);
|
||||
c.setTextDatum(top_left);
|
||||
c.setFont(&fonts::bold);
|
||||
c.setTextColor(theme::kText);
|
||||
const char* verb = s.op == FileOps::Op::Copy ? "Copying " : s.op == FileOps::Op::Delete ? "Deleting " : s.op == FileOps::Op::Count ? "Counting " : "Moving ";
|
||||
c.drawString((verb + files::fitName(baseName(target_.path), 20)).c_str(), box.x + 6, box.y + 4);
|
||||
|
||||
c.setFont(&fonts::body);
|
||||
c.setTextColor(theme::kMuted);
|
||||
std::string line = plural(s.files, "file");
|
||||
if (s.op == FileOps::Op::Copy && s.totalBytes > 0) {
|
||||
int w = box.w - 12, done = static_cast<int>(static_cast<uint64_t>(s.doneBytes) * w / s.totalBytes);
|
||||
c.drawRect(box.x + 6, box.y + 21, w, 7, theme::kMuted);
|
||||
c.fillRect(box.x + 6, box.y + 21, std::min(done, w), 7, theme::kAccent);
|
||||
line = formatBytes(s.doneBytes) + " of " + formatBytes(s.totalBytes);
|
||||
}
|
||||
c.drawString(line.c_str(), box.x + 6, box.y + 31);
|
||||
c.setFont(&fonts::small);
|
||||
c.drawString("Back: cancel", box.x + 6, box.y + box.h - 10);
|
||||
}
|
||||
|
||||
} // namespace roro
|
||||
@@ -0,0 +1,96 @@
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "app.h"
|
||||
#include "apps/file_viewer.h"
|
||||
#include "apps/maintenance_page.h"
|
||||
#include "dialog_model.h"
|
||||
#include "event_bus.h"
|
||||
#include "file_list.h"
|
||||
#include "line_editor.h"
|
||||
#include "list_model.h"
|
||||
#include "services/clock_service.h"
|
||||
#include "services/file_ops.h"
|
||||
#include "services/storage_service.h"
|
||||
#include "ui/theme.h"
|
||||
|
||||
namespace roro {
|
||||
|
||||
// The Storage App (docs/milestones/F1.md): the SD card's folders and files, one item at a time
|
||||
// with a clipboard (Q131). Enter opens a folder, or a file in its viewer (Q134); Back goes up;
|
||||
// c copy, x cut, v paste here, r rename, d delete, n new folder, i details, s sort. At the top of the card, the last row is Maintenance:
|
||||
// usage, Storage Clean-up and erasing the card, behind a warning (Q128).
|
||||
class StorageApp : public App {
|
||||
public:
|
||||
StorageApp(FileOps& ops, StorageService& storage, ClockService& clock, UpdateService& update, EventBus& bus)
|
||||
: ops_(ops), storage_(storage), bus_(bus), maintenance_(storage, clock, bus), viewer_(storage, update) {}
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
bool onKey(const KeyEvent& e) override;
|
||||
bool textEntryActive() const override { return view_ == View::Name; }
|
||||
void update(uint32_t nowMs) override;
|
||||
void draw(Canvas& c) override;
|
||||
|
||||
private:
|
||||
enum class View { Browse, Details, Name, Viewer, Maintenance, NoMemory };
|
||||
enum class Ask { None, Delete, Replace, Maintenance };
|
||||
enum class Wait { None, List, CountToDelete, CountForDetails, Work }; // what the running operation is for
|
||||
static constexpr int kRows = 8;
|
||||
static constexpr size_t kStartFloor = 55 * 1024; // Q86, Q136
|
||||
|
||||
struct Item {
|
||||
std::string path;
|
||||
bool folder = false;
|
||||
explicit operator bool() const { return !path.empty(); }
|
||||
};
|
||||
|
||||
void open(const std::string& folder, const std::string& select = "");
|
||||
void onFinished(const FileOps::Status& s);
|
||||
bool onBrowseKey(const KeyEvent& e);
|
||||
bool onNameKey(const KeyEvent& e);
|
||||
void onAnswer(int button);
|
||||
Item selected() const; // empty on the Maintenance row or in an empty folder
|
||||
bool onMaintenanceRow() const { return cwd_ == "/" && rows_.selected() == static_cast<int>(list_.count()); }
|
||||
void paste(bool replace);
|
||||
void askDelete(const Item& item, uint32_t files, uint32_t bytes);
|
||||
void showDetails(const Item& item);
|
||||
bool work(const std::string& why, const std::string& selectAfter); // an operation was asked for: did it start?
|
||||
void say(const std::string& text);
|
||||
void drawBrowse(Canvas& c);
|
||||
void drawProgress(Canvas& c);
|
||||
|
||||
FileOps& ops_;
|
||||
StorageService& storage_;
|
||||
EventBus& bus_;
|
||||
MaintenancePage maintenance_;
|
||||
FileViewer viewer_;
|
||||
|
||||
View view_ = View::Browse;
|
||||
std::string cwd_ = "/", want_ = "/"; // the folder on screen; the one being read
|
||||
files::FileList list_;
|
||||
files::FileSort sort_ = files::FileSort::Name;
|
||||
ListModel rows_{kRows};
|
||||
bool wantList_ = false, loaded_ = false, cardPresent_ = false;
|
||||
std::string selectAfter_; // after the next listing: the name to select, or else the row
|
||||
int selectIndex_ = 0;
|
||||
|
||||
Wait wait_ = Wait::None;
|
||||
uint32_t waitSinceMs_ = 0, lastDrawMs_ = 0;
|
||||
Item clip_, target_; // the clipboard; what a dialog, the editor or the details are about
|
||||
bool cut_ = false;
|
||||
Ask ask_ = Ask::None;
|
||||
std::unique_ptr<DialogModel> dialog_;
|
||||
std::string question_;
|
||||
LineEditor editor_{60};
|
||||
bool renaming_ = false; // Name view: renaming target_, or else a new folder
|
||||
bool pasting_ = false; // the operation running came from v: it may ask to replace
|
||||
std::vector<std::string> details_;
|
||||
int detailsTop_ = 0;
|
||||
std::string message_;
|
||||
uint32_t messageMs_ = 0;
|
||||
};
|
||||
|
||||
} // namespace roro
|
||||
+101
-6
@@ -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();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <sys/time.h>
|
||||
|
||||
#include "clock_model.h"
|
||||
#include "event_bus.h"
|
||||
@@ -20,7 +21,16 @@ class ClockService : public Service {
|
||||
const char* name() const override { return "clock"; }
|
||||
void start() override { applyTimezone(); }
|
||||
|
||||
bool set(int64_t utcSeconds, TimeSource source) { return model_.set(utcSeconds, source, millis()); }
|
||||
bool set(int64_t utcSeconds, TimeSource source) {
|
||||
if (!model_.set(utcSeconds, source, millis())) return false;
|
||||
// The system's own clock too (F1, Q137): the card's files are dated from it, and NTP was
|
||||
// the only thing setting it. NTP has just done so itself.
|
||||
if (source != TimeSource::Ntp) {
|
||||
timeval now = {static_cast<time_t>(utcSeconds), 0};
|
||||
settimeofday(&now, nullptr);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const ClockModel& model() const { return model_; }
|
||||
|
||||
// UTC seconds, or -1 if the clock isn't set.
|
||||
|
||||
@@ -0,0 +1,492 @@
|
||||
#include "services/file_ops.h"
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <SD.h>
|
||||
|
||||
#include <ctime>
|
||||
#include <memory>
|
||||
|
||||
#include "file_names.h"
|
||||
|
||||
namespace roro {
|
||||
|
||||
using files::baseName;
|
||||
using files::joinPath;
|
||||
using files::parentOf;
|
||||
|
||||
namespace {
|
||||
// The FatFs drive the card is mounted as: SDFS keeps it to itself.
|
||||
struct SdDrive : fs::SDFS {
|
||||
static uint8_t of(fs::SDFS& sd) { return sd.*(&SdDrive::_pdrv); }
|
||||
};
|
||||
|
||||
// A FAT date and time (local, as the firmware wrote them) as Unix time; 0 if there's none.
|
||||
uint32_t fatTime(uint16_t date, uint16_t time) {
|
||||
if (date == 0) return 0;
|
||||
struct tm t = {};
|
||||
t.tm_year = (date >> 9) + 80;
|
||||
t.tm_mon = ((date >> 5) & 15) - 1;
|
||||
t.tm_mday = date & 31;
|
||||
t.tm_hour = time >> 11;
|
||||
t.tm_min = (time >> 5) & 63;
|
||||
t.tm_sec = (time & 31) * 2;
|
||||
t.tm_isdst = -1;
|
||||
time_t at = mktime(&t);
|
||||
return at < 0 ? 0 : static_cast<uint32_t>(at);
|
||||
}
|
||||
|
||||
struct Guard {
|
||||
explicit Guard(SemaphoreHandle_t l) : l_(l) { xSemaphoreTake(l_, portMAX_DELAY); }
|
||||
~Guard() { xSemaphoreGive(l_); }
|
||||
SemaphoreHandle_t l_;
|
||||
};
|
||||
} // namespace
|
||||
|
||||
FileOps::FileOps(StorageService& storage, InUse inUse)
|
||||
: storage_(storage), inUse_(std::move(inUse)), lock_(xSemaphoreCreateMutex()) {}
|
||||
|
||||
FileOps::Status FileOps::status() const {
|
||||
Guard g(lock_);
|
||||
Status s = status_;
|
||||
s.running = busy_;
|
||||
s.doneBytes = doneBytes_;
|
||||
s.files = files_;
|
||||
return s;
|
||||
}
|
||||
|
||||
bool FileOps::finished(Status& out) {
|
||||
// The storage task drops its jobs when the card goes: nothing would ever end this one.
|
||||
if (busy_ && !storage_.state().present) {
|
||||
fail("The card went away");
|
||||
busy_ = false;
|
||||
done_ = true;
|
||||
}
|
||||
if (!done_.exchange(false)) return false;
|
||||
out = status();
|
||||
out.running = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
void FileOps::takeListing(files::FileList& out) {
|
||||
Guard g(lock_);
|
||||
out = std::move(listing_);
|
||||
listing_.clear();
|
||||
}
|
||||
|
||||
bool FileOps::isFolder(const std::string& path, bool& exists) {
|
||||
bool folder = false;
|
||||
exists = false;
|
||||
storage_.runAndWait([&]() {
|
||||
fs::File f = SD.open(path.c_str());
|
||||
exists = static_cast<bool>(f);
|
||||
folder = f && f.isDirectory();
|
||||
});
|
||||
return folder;
|
||||
}
|
||||
|
||||
std::string FileOps::whyReadOnly(const std::string& path, bool folder) const {
|
||||
return files::whyReadOnly(path, inUse_(path, folder));
|
||||
}
|
||||
|
||||
std::string FileOps::start(Op op, const std::string& path) {
|
||||
if (busy_) return "The card is busy with something else";
|
||||
if (!storage_.state().present) return "No SD card";
|
||||
{
|
||||
Guard g(lock_);
|
||||
status_ = Status();
|
||||
status_.op = op;
|
||||
status_.path = path;
|
||||
}
|
||||
closeAll(); // only handles a vanished card left behind
|
||||
listOpen_ = false;
|
||||
op_ = op;
|
||||
phase_ = 0;
|
||||
undo_ = false;
|
||||
cancel_ = false;
|
||||
done_ = false;
|
||||
doneBytes_ = 0;
|
||||
files_ = 0;
|
||||
countBytes_ = 0;
|
||||
startMs_ = millis();
|
||||
busy_ = true;
|
||||
queue();
|
||||
return "";
|
||||
}
|
||||
|
||||
void FileOps::queue() {
|
||||
storage_.runJob([this]() { slice(); });
|
||||
}
|
||||
|
||||
std::string FileOps::list(const std::string& folder) {
|
||||
from_ = folder;
|
||||
return start(Op::List, folder);
|
||||
}
|
||||
|
||||
std::string FileOps::count(const std::string& path) {
|
||||
from_ = path;
|
||||
return start(Op::Count, path);
|
||||
}
|
||||
|
||||
std::string FileOps::copy(const std::string& from, bool folder, const std::string& intoFolder, const std::string& name, bool replace) {
|
||||
if (busy_) return "The card is busy with something else";
|
||||
std::string why = files::whyNotInto(from, intoFolder);
|
||||
// A copy next to its original is fine: only the name must differ.
|
||||
if (why == "It's already there" && name != baseName(from)) why.clear();
|
||||
if (why.empty()) why = files::checkName(name);
|
||||
if (!why.empty()) return why;
|
||||
from_ = from;
|
||||
to_ = joinPath(intoFolder, name);
|
||||
replace_ = replace;
|
||||
sourceIsFolder_ = folder;
|
||||
return start(Op::Copy, from);
|
||||
}
|
||||
|
||||
std::string FileOps::move(const std::string& from, bool folder, const std::string& to, bool replace) {
|
||||
if (busy_) return "The card is busy with something else";
|
||||
std::string why = files::whyReadOnly(from, inUse_(from, folder));
|
||||
if (why.empty() && from == to) why = "It's already there";
|
||||
if (why.empty() && parentOf(to) != parentOf(from)) why = files::whyNotInto(from, parentOf(to));
|
||||
if (why.empty()) why = files::checkName(baseName(to));
|
||||
if (!why.empty()) return why;
|
||||
from_ = from;
|
||||
to_ = to;
|
||||
replace_ = replace;
|
||||
sourceIsFolder_ = folder;
|
||||
return start(Op::Move, from);
|
||||
}
|
||||
|
||||
std::string FileOps::remove(const std::string& path, bool folder) {
|
||||
if (busy_) return "The card is busy with something else";
|
||||
std::string why = files::whyReadOnly(path, inUse_(path, folder));
|
||||
if (!why.empty()) return why;
|
||||
from_ = path;
|
||||
sourceIsFolder_ = folder;
|
||||
return start(Op::Delete, path);
|
||||
}
|
||||
|
||||
std::string FileOps::makeFolder(const std::string& path) {
|
||||
if (busy_) return "The card is busy with something else";
|
||||
std::string why = files::checkName(baseName(path));
|
||||
if (why.empty() && files::isInside(path, files::kGeminiCache)) why = std::string(files::kGeminiCache) + " is the Gemini App's working space";
|
||||
if (!why.empty()) return why;
|
||||
from_ = path;
|
||||
return start(Op::MakeFolder, path);
|
||||
}
|
||||
|
||||
void FileOps::fail(const std::string& why) {
|
||||
Guard g(lock_);
|
||||
if (status_.error.empty()) status_.error = why;
|
||||
}
|
||||
|
||||
void FileOps::closeAll() {
|
||||
if (in_) in_.close();
|
||||
if (out_) out_.close();
|
||||
for (auto& d : dirs_)
|
||||
if (d) d.close();
|
||||
dirs_.clear();
|
||||
paths_.clear();
|
||||
}
|
||||
|
||||
void FileOps::finish() {
|
||||
if (listOpen_) f_closedir(&listDir_);
|
||||
listOpen_ = false;
|
||||
closeAll();
|
||||
{
|
||||
Guard g(lock_);
|
||||
status_.cancelled = cancel_ && status_.error.empty() && (op_ == Op::Copy || op_ == Op::Delete || op_ == Op::Count);
|
||||
status_.bytes = countBytes_;
|
||||
status_.ms = millis() - startMs_;
|
||||
}
|
||||
op_ = Op::None;
|
||||
busy_ = false;
|
||||
done_ = true;
|
||||
}
|
||||
|
||||
bool FileOps::enter(const std::string& path) {
|
||||
fs::File d = SD.open(path.c_str());
|
||||
if (!d || !d.isDirectory()) return false;
|
||||
dirs_.push_back(d);
|
||||
paths_.push_back(path);
|
||||
return true;
|
||||
}
|
||||
|
||||
// One slice on the storage task, then back in the queue behind whatever else is waiting.
|
||||
void FileOps::slice() {
|
||||
if (!busy_) return; // given up on: the card went away (finished())
|
||||
bool over = true;
|
||||
switch (op_) {
|
||||
case Op::List: over = sliceList(); break;
|
||||
case Op::Count: over = sliceCount(); break;
|
||||
case Op::Copy: over = sliceCopy(); break;
|
||||
case Op::Delete: over = sliceDelete(); break;
|
||||
case Op::Move: {
|
||||
if (!SD.exists(from_.c_str())) fail("It isn't there any more");
|
||||
else if (SD.exists(to_.c_str()) && !replace_) {
|
||||
Guard g(lock_);
|
||||
status_.exists = true;
|
||||
status_.error = baseName(to_) + " exists already";
|
||||
} else {
|
||||
if (SD.exists(to_.c_str())) SD.remove(to_.c_str()); // a file: a folder in the way makes the rename fail
|
||||
if (!SD.rename(from_.c_str(), to_.c_str())) fail("The card refused to move it");
|
||||
else files_ = 1;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case Op::MakeFolder:
|
||||
if (SD.exists(from_.c_str())) fail(baseName(from_) + " exists already");
|
||||
else if (!SD.mkdir(from_.c_str())) fail("The card refused to make the folder");
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
if (over) finish();
|
||||
else queue();
|
||||
}
|
||||
|
||||
// Straight from FatFs, one pass over the folder: the Arduino File looks every entry up by name
|
||||
// again for its size and its date, which made 300 entries take over two seconds.
|
||||
bool FileOps::sliceList() {
|
||||
uint32_t t0 = millis();
|
||||
if (phase_ == 0) {
|
||||
Guard g(lock_);
|
||||
listing_.clear();
|
||||
std::string path = std::string(1, static_cast<char>('0' + SdDrive::of(SD))) + ":" + from_;
|
||||
if (f_opendir(&listDir_, path.c_str()) != FR_OK) {
|
||||
status_.error = from_ + " isn't a folder";
|
||||
return true;
|
||||
}
|
||||
listOpen_ = true;
|
||||
phase_ = 1;
|
||||
}
|
||||
while (millis() - t0 < kSliceMs) {
|
||||
if (cancel_) return true;
|
||||
FILINFO info;
|
||||
if (f_readdir(&listDir_, &info) != FR_OK || info.fname[0] == 0) return true;
|
||||
bool folder = info.fattrib & AM_DIR;
|
||||
Guard g(lock_);
|
||||
listing_.add(info.fname, folder ? 0 : static_cast<uint32_t>(info.fsize), fatTime(info.fdate, info.ftime), folder);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// What's inside, for "Delete saved and its 42 files?" (Q132) and for a copy's total.
|
||||
bool FileOps::sliceCount() {
|
||||
uint32_t t0 = millis();
|
||||
if (phase_ == 0) {
|
||||
phase_ = 1;
|
||||
fs::File f = SD.open(from_.c_str());
|
||||
if (!f) {
|
||||
fail("It isn't there any more");
|
||||
return true;
|
||||
}
|
||||
if (!f.isDirectory()) {
|
||||
files_ = 1;
|
||||
countBytes_ = static_cast<uint32_t>(f.size());
|
||||
return true;
|
||||
}
|
||||
f.close();
|
||||
enter(from_);
|
||||
}
|
||||
while (!dirs_.empty() && millis() - t0 < kSliceMs) {
|
||||
if (cancel_) return true;
|
||||
fs::File f = dirs_.back().openNextFile();
|
||||
if (!f) {
|
||||
dirs_.back().close();
|
||||
dirs_.pop_back();
|
||||
paths_.pop_back();
|
||||
} else if (f.isDirectory()) {
|
||||
std::string sub = joinPath(paths_.back(), f.name());
|
||||
f.close();
|
||||
enter(sub);
|
||||
} else {
|
||||
files_++;
|
||||
countBytes_ += static_cast<uint32_t>(f.size());
|
||||
}
|
||||
}
|
||||
return dirs_.empty();
|
||||
}
|
||||
|
||||
// Deletes files as the walk meets them and each folder once it's empty. Also how a cancelled or
|
||||
// failed copy takes back what it had written (undo_).
|
||||
bool FileOps::sliceDelete() {
|
||||
uint32_t t0 = millis();
|
||||
if (phase_ == 0) {
|
||||
phase_ = 1;
|
||||
fs::File f = SD.open(from_.c_str());
|
||||
if (!f) {
|
||||
if (!undo_) fail("It isn't there any more");
|
||||
return true;
|
||||
}
|
||||
bool folder = f.isDirectory();
|
||||
f.close();
|
||||
if (!folder) {
|
||||
if (!SD.remove(from_.c_str())) fail("The card refused to delete it");
|
||||
else if (!undo_) files_ = 1;
|
||||
return true;
|
||||
}
|
||||
enter(from_);
|
||||
}
|
||||
while (!dirs_.empty() && millis() - t0 < kSliceMs) {
|
||||
if (cancel_ && !undo_) return true; // what's deleted stays deleted
|
||||
fs::File f = dirs_.back().openNextFile();
|
||||
if (!f) {
|
||||
std::string done = paths_.back();
|
||||
dirs_.back().close();
|
||||
dirs_.pop_back();
|
||||
paths_.pop_back();
|
||||
if (!SD.rmdir(done.c_str())) {
|
||||
fail("The card refused to delete " + baseName(done));
|
||||
return true;
|
||||
}
|
||||
} else if (f.isDirectory()) {
|
||||
std::string sub = joinPath(paths_.back(), f.name());
|
||||
f.close();
|
||||
if (!enter(sub)) {
|
||||
fail("The card refused to open " + baseName(sub));
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
std::string path = joinPath(paths_.back(), f.name());
|
||||
f.close();
|
||||
if (!SD.remove(path.c_str())) {
|
||||
fail("The card refused to delete " + baseName(path));
|
||||
return true;
|
||||
}
|
||||
if (!undo_) files_++;
|
||||
}
|
||||
}
|
||||
return dirs_.empty();
|
||||
}
|
||||
|
||||
// Phases 0 and 1 count the source (the walk of Count), 2 checks and prepares, 3 copies, 4 takes
|
||||
// a failed or cancelled copy back.
|
||||
bool FileOps::sliceCopy() {
|
||||
uint32_t t0 = millis();
|
||||
if (phase_ <= 1) {
|
||||
if (!sliceCount()) return false;
|
||||
phase_ = 2;
|
||||
}
|
||||
if (phase_ == 2) {
|
||||
{
|
||||
Guard g(lock_);
|
||||
if (!status_.error.empty()) return true;
|
||||
status_.totalBytes = countBytes_;
|
||||
}
|
||||
if (cancel_) return true;
|
||||
files_ = 0;
|
||||
StorageState card = storage_.state();
|
||||
if (card.totalBytes - card.usedBytes < static_cast<uint64_t>(countBytes_) + 64 * 1024) {
|
||||
fail("Not enough room on the card");
|
||||
return true;
|
||||
}
|
||||
if (SD.exists(to_.c_str())) {
|
||||
if (!replace_) {
|
||||
Guard g(lock_);
|
||||
status_.exists = true;
|
||||
status_.error = baseName(to_) + " exists already";
|
||||
return true;
|
||||
}
|
||||
if (sourceIsFolder_) {
|
||||
fail("A folder with that name is in the way");
|
||||
return true;
|
||||
}
|
||||
SD.remove(to_.c_str());
|
||||
}
|
||||
if (sourceIsFolder_) {
|
||||
if (!SD.mkdir(to_.c_str()) || !enter(from_)) {
|
||||
fail("The card refused to make the folder");
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
in_ = SD.open(from_.c_str(), FILE_READ);
|
||||
outPath_ = to_;
|
||||
out_ = SD.open(outPath_.c_str(), FILE_WRITE);
|
||||
fileBytes_ = 0;
|
||||
fileSize_ = in_ ? static_cast<uint32_t>(in_.size()) : 0;
|
||||
if (!in_ || !out_) {
|
||||
fail("The card refused to open the files");
|
||||
phase_ = 4;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
phase_ = 3;
|
||||
}
|
||||
if (phase_ == 3) {
|
||||
std::unique_ptr<uint8_t[]> piece(new uint8_t[kPiece]);
|
||||
while (millis() - t0 < kSliceMs) {
|
||||
if (cancel_) {
|
||||
phase_ = 4;
|
||||
return false;
|
||||
}
|
||||
if (in_) { // a file in progress
|
||||
int n = in_.read(piece.get(), kPiece);
|
||||
if (n > 0 && out_.write(piece.get(), n) != static_cast<size_t>(n)) n = -1;
|
||||
if (n < 0) {
|
||||
fail("The card refused a write");
|
||||
phase_ = 4;
|
||||
return false;
|
||||
}
|
||||
if (n > 0) {
|
||||
doneBytes_ += n;
|
||||
fileBytes_ += n;
|
||||
continue;
|
||||
}
|
||||
in_.close();
|
||||
out_.close();
|
||||
// Q133: the sizes are compared. A Log that grew meanwhile is copied as far as it went.
|
||||
fs::File check = SD.open(outPath_.c_str(), FILE_READ);
|
||||
bool same = check && static_cast<uint32_t>(check.size()) == fileBytes_ && fileBytes_ >= fileSize_;
|
||||
if (check) check.close();
|
||||
if (!same) {
|
||||
fail("The copy of " + baseName(outPath_) + " isn't the size of the original");
|
||||
phase_ = 4;
|
||||
return false;
|
||||
}
|
||||
files_++;
|
||||
continue;
|
||||
}
|
||||
if (dirs_.empty()) return true; // a single file, or the whole folder: done
|
||||
fs::File f = dirs_.back().openNextFile();
|
||||
// The copy sits where the original does, relative to the two roots.
|
||||
auto target = [&](const std::string& source) { return to_ + source.substr(from_.size()); };
|
||||
if (!f) {
|
||||
dirs_.back().close();
|
||||
dirs_.pop_back();
|
||||
paths_.pop_back();
|
||||
} else if (f.isDirectory()) {
|
||||
std::string sub = joinPath(paths_.back(), f.name());
|
||||
f.close();
|
||||
if (!SD.mkdir(target(sub).c_str()) || !enter(sub)) {
|
||||
fail("The card refused to make " + baseName(sub));
|
||||
phase_ = 4;
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
std::string source = joinPath(paths_.back(), f.name());
|
||||
f.close();
|
||||
in_ = SD.open(source.c_str(), FILE_READ);
|
||||
outPath_ = target(source);
|
||||
out_ = SD.open(outPath_.c_str(), FILE_WRITE);
|
||||
fileBytes_ = 0;
|
||||
fileSize_ = in_ ? static_cast<uint32_t>(in_.size()) : 0;
|
||||
if (!in_ || !out_) {
|
||||
fail("The card refused to open " + baseName(source));
|
||||
phase_ = 4;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// Phase 4: take it back. The copy never replaced a folder, so everything under to_ is ours.
|
||||
if (!undo_) {
|
||||
closeAll();
|
||||
undo_ = true;
|
||||
from_ = to_;
|
||||
phase_ = 0;
|
||||
op_ = Op::Delete; // finish() reports it as the copy it was: status_.op stays Copy
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace roro
|
||||
@@ -0,0 +1,102 @@
|
||||
#pragma once
|
||||
|
||||
#include <FS.h>
|
||||
#include <ff.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/semphr.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "file_list.h"
|
||||
#include "services/storage_service.h"
|
||||
|
||||
namespace roro {
|
||||
|
||||
// What the Storage App does to the card (docs/milestones/F1.md): list a folder, count what's in
|
||||
// one, copy, move, delete, make a folder. One operation at a time, on the storage task like every
|
||||
// card access, **in slices of about 150 ms**: a long copy re-queues itself between slices, so IRC
|
||||
// Logs, a Gemini page streaming to the card or a Capture are written in between and nobody waits
|
||||
// long enough to give up. The read-only rules of Q130 are checked here, whoever asks.
|
||||
class FileOps {
|
||||
public:
|
||||
enum class Op : uint8_t { None, List, Count, Copy, Move, Delete, MakeFolder };
|
||||
// The files the firmware has open right now that matter for `path` (a folder or not).
|
||||
using InUse = std::function<std::vector<std::string>(const std::string& path, bool folder)>;
|
||||
|
||||
struct Status {
|
||||
Op op = Op::None;
|
||||
bool running = false;
|
||||
bool cancelled = false;
|
||||
bool exists = false; // a copy or move found something with that name: ask, then `replace`
|
||||
std::string error; // why it failed, for a human; empty when it worked
|
||||
uint32_t doneBytes = 0, totalBytes = 0; // a copy's progress
|
||||
uint32_t files = 0; // files copied or deleted so far; counted, for Count
|
||||
uint32_t bytes = 0; // Count: their total size
|
||||
std::string path; // what it was about
|
||||
uint32_t ms = 0; // how long it took, once it has ended
|
||||
};
|
||||
|
||||
FileOps(StorageService& storage, InUse inUse);
|
||||
|
||||
// Each returns "" when the operation started, or why it can't. One at a time.
|
||||
std::string list(const std::string& folder);
|
||||
std::string count(const std::string& path);
|
||||
std::string copy(const std::string& from, bool folder, const std::string& intoFolder, const std::string& name, bool replace = false);
|
||||
std::string move(const std::string& from, bool folder, const std::string& to, bool replace = false);
|
||||
std::string remove(const std::string& path, bool folder);
|
||||
std::string makeFolder(const std::string& path);
|
||||
void cancel() { cancel_ = true; }
|
||||
// Why `path` can't be renamed, moved or deleted, or "": asked before the confirmation.
|
||||
std::string whyReadOnly(const std::string& path, bool folder) const;
|
||||
|
||||
bool busy() const { return busy_; }
|
||||
Status status() const; // while it runs
|
||||
bool finished(Status& out); // true once, when it has ended
|
||||
void takeListing(files::FileList& out); // after a List finished without error
|
||||
|
||||
// For the console, which has no listing to know from: is `path` a folder? Waits for the card.
|
||||
bool isFolder(const std::string& path, bool& exists);
|
||||
|
||||
private:
|
||||
static constexpr uint32_t kSliceMs = 150;
|
||||
static constexpr size_t kPiece = 4096; // Q133
|
||||
|
||||
std::string start(Op op, const std::string& path);
|
||||
void queue();
|
||||
void slice(); // on the storage task
|
||||
bool sliceList(); // each: true when the operation is over
|
||||
bool sliceCount();
|
||||
bool sliceCopy();
|
||||
bool sliceDelete();
|
||||
void fail(const std::string& why);
|
||||
void finish();
|
||||
bool enter(const std::string& path); // push a folder onto the walk
|
||||
void closeAll();
|
||||
|
||||
StorageService& storage_;
|
||||
InUse inUse_;
|
||||
mutable SemaphoreHandle_t lock_; // status_, listing_
|
||||
Status status_;
|
||||
files::FileList listing_;
|
||||
std::atomic<bool> busy_{false}, done_{false}, cancel_{false};
|
||||
std::atomic<uint32_t> doneBytes_{0}, files_{0};
|
||||
|
||||
// The operation in hand: only the storage task touches these while it runs.
|
||||
Op op_ = Op::None;
|
||||
std::string from_, to_;
|
||||
bool replace_ = false, sourceIsFolder_ = false, undo_ = false;
|
||||
int phase_ = 0;
|
||||
std::vector<fs::File> dirs_; // the folders being walked, outermost first
|
||||
std::vector<std::string> paths_; // their paths
|
||||
FF_DIR listDir_; // List reads the folder straight from FatFs
|
||||
bool listOpen_ = false;
|
||||
fs::File in_, out_; // the file being copied
|
||||
std::string outPath_;
|
||||
uint32_t countBytes_ = 0, startMs_ = 0;
|
||||
uint32_t fileBytes_ = 0, fileSize_ = 0; // copied of the file in hand; its size when opened
|
||||
};
|
||||
|
||||
} // namespace roro
|
||||
@@ -53,6 +53,7 @@ class GnssService : public Service {
|
||||
std::string startTrack(uint32_t nowMs);
|
||||
void stopTrack();
|
||||
bool tracking() const { return !trackPath_.empty(); }
|
||||
const std::string& trackPath() const { return trackPath_; }
|
||||
int trackPoints() const { return trackPoints_; }
|
||||
uint32_t trackStartMs() const { return trackStartMs_; }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user