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:
@@ -100,6 +100,9 @@ void test_display() {
|
||||
TEST_ASSERT_EQUAL_STRING("0 B -", rowDetail(false, 0, 315532800).c_str()); // 1980-01-01, FAT's zero
|
||||
TEST_ASSERT_EQUAL_STRING("2026-10-05 20:00", formatStamp(1791230400).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("-", formatStamp(0).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("notes.txt", fitName("notes.txt", 19).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("20261005-18..2.pcap", fitName("20261005-180932.pcap", 19).c_str());
|
||||
TEST_ASSERT_EQUAL_INT(19, fitName("a-very-long-folder-name-without-end", 19).size());
|
||||
}
|
||||
|
||||
int main() {
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
#include <unity.h>
|
||||
|
||||
#include <cstdlib>
|
||||
#include <ctime>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "file_views.h"
|
||||
#include "loratap.h"
|
||||
#include "track.h"
|
||||
|
||||
using namespace roro;
|
||||
using namespace roro::files;
|
||||
|
||||
void setUp() {}
|
||||
void tearDown() {}
|
||||
|
||||
void test_hex_row() {
|
||||
const uint8_t d[] = {'H', 'e', 'l', 'l', 'o', ',', ' ', 'w', 0x00, 0xFF, 'x'};
|
||||
TEST_ASSERT_EQUAL_STRING("00010 4865 6c6c 6f2c 2077 Hello, w", hexRow(0x10, d, 8).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("00018 00ff 78 ..x", hexRow(0x18, d + 8, 3).c_str());
|
||||
TEST_ASSERT_TRUE(hexRow(0xFFFFF, d, 8).size() <= 38);
|
||||
}
|
||||
|
||||
// A Track as the GNSS Service writes it, fed in pieces of every size: the same summary each time.
|
||||
void test_gpx_summary() {
|
||||
setenv("TZ", "UTC", 1);
|
||||
tzset();
|
||||
std::string file = gnss::gpx::header("test");
|
||||
int64_t t0 = 1791230400; // 2026-10-05 20:00:00 UTC
|
||||
for (int i = 0; i < 50; i++) file += gnss::gpx::point(48.0 + i * 0.0001, 7.0, i % 2 == 0, 200, t0 + i * 5) + "\n";
|
||||
file += gnss::gpx::footer();
|
||||
for (size_t piece : {size_t(1), size_t(7), size_t(64), size_t(1000), file.size()}) {
|
||||
GpxSummary s;
|
||||
for (size_t at = 0; at < file.size(); at += piece) s.feed(file.data() + at, std::min(piece, file.size() - at));
|
||||
TEST_ASSERT_EQUAL_UINT32(50, s.points());
|
||||
TEST_ASSERT_EQUAL_INT64(t0, s.start());
|
||||
TEST_ASSERT_EQUAL_INT64(t0 + 245, s.end());
|
||||
TEST_ASSERT_FLOAT_WITHIN(1.0f, 49 * 11.12f, static_cast<float>(s.meters())); // 0.0001 degree of latitude: 11.12 m
|
||||
}
|
||||
GpxSummary s;
|
||||
s.feed(file.data(), file.size());
|
||||
auto lines = s.lines();
|
||||
TEST_ASSERT_EQUAL_size_t(4, lines.size());
|
||||
TEST_ASSERT_EQUAL_STRING("50 points", lines[0].c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("Started 2026-10-05 20:00", lines[1].c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("Lasted 4 min 05 s", lines[2].c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("Distance 545 m", lines[3].c_str());
|
||||
}
|
||||
|
||||
void test_gpx_from_elsewhere_and_not_gpx() {
|
||||
GpxSummary s;
|
||||
std::string other = "<gpx><trk><trkseg><trkpt lon=\"7.0\" lat=\"48.0\"/><trkpt lat=\"48.01\" lon=\"7.0\">\n<ele>1</ele>\n</trkpt></trkseg></trk></gpx>";
|
||||
s.feed(other.data(), other.size());
|
||||
TEST_ASSERT_EQUAL_UINT32(2, s.points());
|
||||
TEST_ASSERT_EQUAL_INT64(0, s.start());
|
||||
TEST_ASSERT_FLOAT_WITHIN(5.0f, 1112.0f, static_cast<float>(s.meters()));
|
||||
TEST_ASSERT_EQUAL_size_t(2, s.lines().size()); // no times: points and distance only
|
||||
GpxSummary none;
|
||||
std::string junk(10000, 'x');
|
||||
none.feed(junk.data(), junk.size());
|
||||
TEST_ASSERT_EQUAL_UINT32(0, none.points());
|
||||
}
|
||||
|
||||
void test_durations() {
|
||||
TEST_ASSERT_EQUAL_STRING("12 s", formatDuration(12).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("4 min 10 s", formatDuration(250).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("1 h 02 min", formatDuration(3725).c_str());
|
||||
}
|
||||
|
||||
// What the LoRa Capture Service writes reads back the same.
|
||||
void test_pcap_round_trip() {
|
||||
std::vector<uint8_t> file;
|
||||
lora::appendPcapHeader(file);
|
||||
lora::RxInfo rx;
|
||||
rx.frequencyHz = 869525000;
|
||||
rx.bandwidthKHz = 250;
|
||||
rx.spreadingFactor = 11;
|
||||
rx.rssi = -97;
|
||||
rx.snr = -6.25f;
|
||||
rx.noiseFloor = -106;
|
||||
rx.syncWord = 0x2B;
|
||||
const uint8_t payload[] = {1, 2, 3, 4, 5};
|
||||
lora::appendRecord(file, 1791230400, 250000, rx, payload, sizeof payload);
|
||||
lora::appendRecord(file, 1791230401, 0, rx, payload, 0);
|
||||
|
||||
PcapHeader h = parsePcapHeader(file.data(), file.size());
|
||||
TEST_ASSERT_TRUE(h.ok);
|
||||
TEST_ASSERT_EQUAL_UINT32(kLinkLoraTap, h.linkType);
|
||||
size_t at = kPcapHeaderSize;
|
||||
PcapRecord r;
|
||||
TEST_ASSERT_TRUE(parsePcapRecord(file.data() + at, file.size() - at, r));
|
||||
TEST_ASSERT_EQUAL_UINT32(1791230400, r.seconds);
|
||||
TEST_ASSERT_EQUAL_UINT32(250000, r.micros);
|
||||
TEST_ASSERT_EQUAL_UINT32(lora::kLoraTapSize + 5, r.length);
|
||||
lora::RxInfo back;
|
||||
TEST_ASSERT_TRUE(parseLoraTap(file.data() + at + kPcapRecordSize, r.length, back));
|
||||
TEST_ASSERT_EQUAL_UINT32(869525000, back.frequencyHz);
|
||||
TEST_ASSERT_EQUAL_FLOAT(250, back.bandwidthKHz);
|
||||
TEST_ASSERT_EQUAL_UINT8(11, back.spreadingFactor);
|
||||
TEST_ASSERT_EQUAL_FLOAT(-97, back.rssi);
|
||||
TEST_ASSERT_EQUAL_FLOAT(-6.25f, back.snr);
|
||||
TEST_ASSERT_EQUAL_FLOAT(-106, back.noiseFloor);
|
||||
TEST_ASSERT_EQUAL_HEX8(0x2B, back.syncWord);
|
||||
at += kPcapRecordSize + r.length;
|
||||
TEST_ASSERT_TRUE(parsePcapRecord(file.data() + at, file.size() - at, r));
|
||||
TEST_ASSERT_EQUAL_UINT32(lora::kLoraTapSize, r.length);
|
||||
at += kPcapRecordSize + r.length;
|
||||
TEST_ASSERT_EQUAL_size_t(file.size(), at);
|
||||
TEST_ASSERT_FALSE(parsePcapRecord(file.data() + at, file.size() - at, r)); // the end
|
||||
}
|
||||
|
||||
void test_not_a_pcap() {
|
||||
const uint8_t junk[24] = {'n', 'o', 'p', 'e'};
|
||||
TEST_ASSERT_FALSE(parsePcapHeader(junk, sizeof junk).ok);
|
||||
TEST_ASSERT_FALSE(parsePcapHeader(junk, 10).ok);
|
||||
const uint8_t huge[16] = {0, 0, 0, 0, 0, 0, 0, 0, 0xFF, 0xFF, 0xFF, 0x7F};
|
||||
PcapRecord r;
|
||||
TEST_ASSERT_FALSE(parsePcapRecord(huge, sizeof huge, r));
|
||||
lora::RxInfo rx;
|
||||
TEST_ASSERT_FALSE(parseLoraTap(junk, 10, rx));
|
||||
}
|
||||
|
||||
int main() {
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_hex_row);
|
||||
RUN_TEST(test_gpx_summary);
|
||||
RUN_TEST(test_gpx_from_elsewhere_and_not_gpx);
|
||||
RUN_TEST(test_durations);
|
||||
RUN_TEST(test_pcap_round_trip);
|
||||
RUN_TEST(test_not_a_pcap);
|
||||
return UNITY_END();
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
#include <unity.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
#include "text_pager.h"
|
||||
|
||||
using namespace roro::files;
|
||||
|
||||
void setUp() {}
|
||||
void tearDown() {}
|
||||
|
||||
static int reads = 0;
|
||||
static size_t biggestRead = 0;
|
||||
|
||||
static TextPager pagerFor(const std::string& text, int cols, int rows) {
|
||||
return TextPager(
|
||||
[&text](uint32_t offset, uint8_t* into, size_t len) {
|
||||
reads++;
|
||||
biggestRead = std::max(biggestRead, len);
|
||||
if (offset >= text.size()) return static_cast<size_t>(0);
|
||||
size_t n = std::min(len, text.size() - offset);
|
||||
std::memcpy(into, text.data() + offset, n);
|
||||
return n;
|
||||
},
|
||||
static_cast<uint32_t>(text.size()), cols, rows);
|
||||
}
|
||||
|
||||
void test_lines_and_wrapping() {
|
||||
std::string text = "one\r\ntwo words that are too long\n\nlast";
|
||||
TextPager p = pagerFor(text, 10, 8);
|
||||
auto lines = p.lines();
|
||||
TEST_ASSERT_EQUAL_size_t(6, lines.size());
|
||||
TEST_ASSERT_EQUAL_STRING("one", lines[0].c_str()); // the \r is dropped
|
||||
TEST_ASSERT_EQUAL_STRING("two words", lines[1].c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("that are", lines[2].c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("too long", lines[3].c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("", lines[4].c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("last", lines[5].c_str());
|
||||
TEST_ASSERT_TRUE(p.atEnd());
|
||||
}
|
||||
|
||||
void test_a_word_too_long_is_cut_and_a_space_at_the_edge_is_eaten() {
|
||||
std::string text = "abcdefghijklmno\nabcde fghij klm";
|
||||
TextPager p = pagerFor(text, 5, 8);
|
||||
auto lines = p.lines();
|
||||
TEST_ASSERT_EQUAL_STRING("abcde", lines[0].c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("fghij", lines[1].c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("klmno", lines[2].c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("abcde", lines[3].c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("fghij", lines[4].c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("klm", lines[5].c_str());
|
||||
}
|
||||
|
||||
void test_accents_count_as_one_column_and_tabs_show_as_spaces() {
|
||||
std::string text = "\xC3\xA9t\xC3\xA9 ici\n\ta\x01";
|
||||
TextPager p = pagerFor(text, 3, 4);
|
||||
auto lines = p.lines();
|
||||
TEST_ASSERT_EQUAL_STRING("\xC3\xA9t\xC3\xA9", lines[0].c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("ici", lines[1].c_str());
|
||||
TEST_ASSERT_EQUAL_STRING(" a.", lines[2].c_str());
|
||||
}
|
||||
|
||||
// Scrolling down to the end and back up passes through the same line starts.
|
||||
void test_up_undoes_down() {
|
||||
std::string text;
|
||||
for (int i = 0; i < 300; i++) {
|
||||
text += "line " + std::to_string(i) + " with some words to wrap around the edge of the screen";
|
||||
text += i % 7 == 0 ? "\n\n" : "\n";
|
||||
}
|
||||
text += "a paragraph without any newline for a long while ";
|
||||
for (int i = 0; i < 200; i++) text += "word" + std::to_string(i) + " ";
|
||||
text += "\nthe end";
|
||||
TextPager p = pagerFor(text, 38, 9);
|
||||
std::vector<uint32_t> tops;
|
||||
for (int guard = 0; guard < 5000 && !p.atEnd(); guard++) {
|
||||
tops.push_back(p.top());
|
||||
p.down();
|
||||
}
|
||||
TEST_ASSERT_TRUE(p.atEnd());
|
||||
TEST_ASSERT_TRUE(tops.size() > 500);
|
||||
TEST_ASSERT_EQUAL_STRING("the end", p.lines().back().c_str());
|
||||
// Inside the long paragraph (further than the look-back from its start) lines may wrap from
|
||||
// another place on the way up. Before it, every line start met on the way down is met again.
|
||||
size_t paragraph = text.find("a paragraph without");
|
||||
size_t expected = 0, met = 0;
|
||||
for (uint32_t t : tops) expected += t <= paragraph;
|
||||
for (int guard = 0; guard < 5000 && p.top() > 0; guard++) {
|
||||
p.up();
|
||||
if (p.top() > paragraph) continue;
|
||||
TEST_ASSERT_TRUE(std::binary_search(tops.begin(), tops.end(), p.top()));
|
||||
met++;
|
||||
}
|
||||
TEST_ASSERT_EQUAL_size_t(expected, met);
|
||||
TEST_ASSERT_EQUAL_UINT32(0, p.top());
|
||||
p.up();
|
||||
TEST_ASSERT_EQUAL_UINT32(0, p.top());
|
||||
}
|
||||
|
||||
void test_to_end_shows_the_last_lines() {
|
||||
std::string text;
|
||||
for (int i = 0; i < 100; i++) text += "20:0" + std::to_string(i % 10) + " <a> message " + std::to_string(i) + "\n";
|
||||
TextPager p = pagerFor(text, 38, 9);
|
||||
p.toEnd();
|
||||
auto lines = p.lines();
|
||||
TEST_ASSERT_EQUAL_size_t(9, lines.size());
|
||||
TEST_ASSERT_EQUAL_STRING("20:09 <a> message 99", lines[8].c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("20:01 <a> message 91", lines[0].c_str());
|
||||
TEST_ASSERT_TRUE(p.atEnd());
|
||||
p.down(); // nowhere to go
|
||||
TEST_ASSERT_EQUAL_STRING("20:01 <a> message 91", p.lines()[0].c_str());
|
||||
TEST_ASSERT_TRUE(p.percent() > 80);
|
||||
}
|
||||
|
||||
void test_a_megabyte_is_read_a_kilobyte_at_a_time() {
|
||||
std::string text;
|
||||
while (text.size() < 1024 * 1024) text += "2026-10-05 20:00:00 <someone> says something here\n";
|
||||
reads = 0;
|
||||
biggestRead = 0;
|
||||
TextPager p = pagerFor(text, 38, 9);
|
||||
p.toEnd();
|
||||
TEST_ASSERT_EQUAL_size_t(9, p.lines().size());
|
||||
p.up(20);
|
||||
p.down(5);
|
||||
TEST_ASSERT_TRUE(biggestRead <= 1024);
|
||||
TEST_ASSERT_TRUE(reads < 20);
|
||||
p.toStart();
|
||||
TEST_ASSERT_EQUAL_STRING("2026-10-05 20:00:00 <someone> says", p.lines()[0].c_str());
|
||||
}
|
||||
|
||||
void test_empty_and_short_files() {
|
||||
std::string empty;
|
||||
TextPager p = pagerFor(empty, 38, 9);
|
||||
TEST_ASSERT_EQUAL_size_t(0, p.lines().size());
|
||||
p.down();
|
||||
p.up();
|
||||
p.toEnd();
|
||||
TEST_ASSERT_TRUE(p.atEnd());
|
||||
std::string one = "no newline";
|
||||
TextPager q = pagerFor(one, 38, 9);
|
||||
q.toEnd();
|
||||
TEST_ASSERT_EQUAL_STRING("no newline", q.lines()[0].c_str());
|
||||
}
|
||||
|
||||
int main() {
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_lines_and_wrapping);
|
||||
RUN_TEST(test_a_word_too_long_is_cut_and_a_space_at_the_edge_is_eaten);
|
||||
RUN_TEST(test_accents_count_as_one_column_and_tabs_show_as_spaces);
|
||||
RUN_TEST(test_up_undoes_down);
|
||||
RUN_TEST(test_to_end_shows_the_last_lines);
|
||||
RUN_TEST(test_a_megabyte_is_read_a_kilobyte_at_a_time);
|
||||
RUN_TEST(test_empty_and_short_files);
|
||||
return UNITY_END();
|
||||
}
|
||||
Reference in New Issue
Block a user