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
|
||||
Reference in New Issue
Block a user