Merge branch 'f1': the Storage App

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
2026-10-06 09:03:50 +02:00
co-authored by Claude Opus 5.5
33 changed files with 3273 additions and 40 deletions
+10 -2
View File
@@ -123,10 +123,18 @@ Data the user explicitly starts recording, such as Wi-Fi packet captures and LoR
_Avoid_: dump, log _Avoid_: dump, log
**Storage Warning**: **Storage Warning**:
The Notification raised once per boot when the SD card passes 80% full. Selecting it opens Storage Clean-up. The Notification raised once per boot when the SD card passes 80% full. It points at the Storage App, where Maintenance holds Storage Clean-up.
**Storage Clean-up**: **Storage Clean-up**:
The screen where the user deletes old Logs and Captures by category and age, with a preview of the space freed. Notes are never offered for deletion. The screen where the user deletes old Logs and Captures by category and age, with a preview of the space freed. Notes are never offered for deletion. It lives in the Storage App, under Maintenance.
**Storage App**:
The App that browses the SD card: folders and files, a clipboard for one item at a time (copy, cut, paste), rename, delete, new folder, and a viewer for each kind of file the firmware writes. The top-level folders, `/gemini/cache` and files being written are read-only.
_Avoid_: file manager, Files, explorer
**Maintenance**:
The part of the Storage App that deletes in bulk: the card's usage, Storage Clean-up and erasing the card. Reached through a warning.
_Avoid_: Settings > Storage
**Firmware Update**: **Firmware Update**:
Installing a new firmware image without a USB cable: pushed over Wi-Fi from the developer's PC, or read from the SD card. Installing a new firmware image without a USB cable: pushed over Wi-Fi from the developer's PC, or read from the SD card.
+28 -1
View File
@@ -82,6 +82,31 @@ On a page, `b` bookmarks it, `s` saves it to the SD card to read offline (a non-
The LoRa Scanner (docs/milestones/M3.md) listens with the Cap's radio and **never transmits**. The Sniffer lists what it hears, newest first: time, RSSI, SNR, and for Meshtastic packets the sender and receiver (their last 4 hex digits) and hops. Enter shows a packet's details: the Meshtastic header (which is never encrypted) and a hex dump. `p` picks one of the 7 Meshtastic presets allowed in EU868 (LongFast by default), `c` starts or stops a Capture: a pcap file with LoRaTap headers in `/captures/lora/`, for Wireshark. A Capture keeps recording with the App closed; otherwise the radio sleeps when the App isn't open. Tab switches to **Sweep**: the signal strength across 863–870 MHz in 100 kHz steps, as bars with peak hold and a waterfall, with the Sniffer's frequency marked; the Sniffer is paused meanwhile and picks up where it was. The GNSS receiver on the same Cap raises the radio's noise floor by 8 dB while it runs: Settings > "Pause GNSS for LoRa" (off by default) puts it in standby while the radio listens, except during a Track. The Status Bar shows `L` while the radio listens (bright for a moment on each packet), `SW` while sweeping, and `CAP` while capturing. The LoRa Scanner (docs/milestones/M3.md) listens with the Cap's radio and **never transmits**. The Sniffer lists what it hears, newest first: time, RSSI, SNR, and for Meshtastic packets the sender and receiver (their last 4 hex digits) and hops. Enter shows a packet's details: the Meshtastic header (which is never encrypted) and a hex dump. `p` picks one of the 7 Meshtastic presets allowed in EU868 (LongFast by default), `c` starts or stops a Capture: a pcap file with LoRaTap headers in `/captures/lora/`, for Wireshark. A Capture keeps recording with the App closed; otherwise the radio sleeps when the App isn't open. Tab switches to **Sweep**: the signal strength across 863–870 MHz in 100 kHz steps, as bars with peak hold and a waterfall, with the Sniffer's frequency marked; the Sniffer is paused meanwhile and picks up where it was. The GNSS receiver on the same Cap raises the radio's noise floor by 8 dB while it runs: Settings > "Pause GNSS for LoRa" (off by default) puts it in standby while the radio listens, except during a Track. The Status Bar shows `L` while the radio listens (bright for a moment on each packet), `SW` while sweeping, and `CAP` while capturing.
## Storage
The Storage App (docs/milestones/F1.md) shows what's on the SD card: each folder's entries with their size and date, folders first. Enter opens a folder, Back goes up; `s` sorts by name, date or size. It works on one item at a time, with a clipboard:
| Key | Does |
|---|---|
| `c` / `x` | Copies or cuts the selected file or folder; the footer shows what `v` would paste |
| `v` | Pastes it into the folder shown. A copy next to its original is named `name (2).txt`; anything in the way is asked about first |
| `r` | Renames |
| `d` | Deletes, after saying what's inside: "Delete saved and its 42 files (1.2 MB)?" |
| `n` | Makes a folder |
| `i` | Details: type, exact size, date, and why an item is read-only if it is |
A copy runs in the background of the card (about 400 KB a second) in short turns, so Logs and Captures keep being written; it shows its progress, Back cancels it and takes back what was copied, and each file's size is checked afterwards. Three things can't be changed: the top-level folders the firmware keeps its files in (what's inside them can), `/gemini/cache`, and any file being written right now (today's IRC Logs, a Track or a Capture being recorded). The App says why when it refuses. A folder with more than 256 entries shows the first 256 by name and says so.
Enter on a file opens it by type; Tab switches to the same file as a hex dump or as text:
- **Text** (`.txt`, `.log`, `.gmi`, `.csv`, and anything that looks like text): only the screen's worth is read from the card, so a file of any size opens at once. Logs open at the end. Up and Down move a line, Left and Right a page, `t` and `b` go to the top and the end.
- **Captures** (`.pcap`): the packets as the LoRa Scanner lists them; Enter shows one with its Meshtastic header and bytes.
- **Tracks** (`.gpx`): the number of points, the start, the duration and the distance.
- **Update Files** (`.ota`): the version, and whether the file would install: it's checked as an install checks it (signature and contents) without writing anything. Enter then installs it.
- **Anything else:** a hex dump.
At the top of the card the last row, **Maintenance** (also `m`), holds the card's usage, Storage Clean-up and "Erase SD card", behind a warning: those delete for good. It replaces Settings > Storage.
## Development aids ## Development aids
`scripts/serial_log.sh [seconds] [command…]` records the serial output, and can send commands to the firmware first. For example, `scripts/serial_log.sh 30 short sleep:12 burst` sets short screen timeouts, waits 12 s, then sends a burst of Toasts. `scripts/serial_log.sh [seconds] [command…]` records the serial output, and can send commands to the firmware first. For example, `scripts/serial_log.sh 30 short sleep:12 burst` sets short screen timeouts, waits 12 s, then sends a burst of Toasts.
@@ -98,6 +123,7 @@ The LoRa Scanner (docs/milestones/M3.md) listens with the Cap's radio and **neve
| `log <text>` | Appends a line to a test IRC Log (`/irc/dev/#test/<date>.log`) | | `log <text>` | Appends a line to a test IRC Log (`/irc/dev/#test/<date>.log`) |
| `sd card` | What the SD card says it is: type, size, and its identity register (maker, name, revision, serial, date) | | `sd card` | What the SD card says it is: type, size, and its identity register (maker, name, revision, serial, date) |
| `sd list` | Lists the files of each Storage Clean-up category | | `sd list` | Lists the files of each Storage Clean-up category |
| `sd fill <folder> <count>` | Debug Builds: makes that many small files in a folder, to test a crowded one |
| `cat <path>` | Prints the first ~1.2 KB of a file on the SD card | | `cat <path>` | Prints the first ~1.2 KB of a file on the SD card |
| `irc start` | Starts the IRC Service (normally done by opening the IRC App) | | `irc start` | Starts the IRC Service (normally done by opening the IRC App) |
| `irc stop` | Stops it, as `/quit` does: QUIT if connected, no more retries, and the App stays disconnected until you type | | `irc stop` | Stops it, as `/quit` does: QUIT if connected, no more retries, and the App stays disconnected until you type |
@@ -111,7 +137,8 @@ The LoRa Scanner (docs/milestones/M3.md) listens with the Cap's radio and **neve
| `net` | Bytes each network service has read and written since boot | | `net` | Bytes each network service has read and written since boot |
| `reboot` / `boot other` | Restart, or restart into the other app slot (a manual Rollback) | | `reboot` / `boot other` | Restart, or restart into the other app slot (a manual Rollback) |
| `log level <0-5>` | ESP-IDF log level | | `log level <0-5>` | ESP-IDF log level |
| `ls [folder]` / `rm <path>` | Lists a folder of the SD card, or deletes a file | | `ls [folder]` / `du <path>` | Lists a folder of the SD card with sizes and dates, or counts the files and bytes under a path |
| `cp [-f] <from> <to>` / `mv [-f] <from> <to>` / `rm <path>` / `mkdir <path>` / `cancel` | What the Storage App does, with its rules: copy (folders too), move or rename, delete (a folder with what's in it), new folder. `-f` replaces a file that's in the way; a tab separates paths that hold spaces; `cancel` stops a copy or a delete |
| `install <path>` | Update from SD with that `.ota` file, as Settings → Firmware does | | `install <path>` | Update from SD with that `.ota` file, as Settings → Firmware does |
| `lora probe` | Finds the radio: chip, oscillator, antenna switch, DIO1 interrupt, noise floor | | `lora probe` | Finds the radio: chip, oscillator, antenna switch, DIO1 interrupt, noise floor |
| `lora status` | Radio settings, who's listening, packet and error counters, noise floor, task stack | | `lora status` | Radio settings, who's listening, packet and error counters, noise floor, task stack |
+88
View File
@@ -0,0 +1,88 @@
# F1 — Files and Notes
**Status:** in progress. The Storage App (issue #3) shipped as **v0.9.0** on 2026-10-06. Notes (#19) and the card as a USB drive (#1) come after.
**Goal:** get at what's on the SD card from the device itself: browse it, look inside the files the firmware writes, copy, move, rename and delete, and keep notes. A side milestone, like G1 and S1; Files and Notes were M3's original second half (Q30, Q89).
## The Storage App (issue #3)
Until now the card could be looked at only through the Debug Console (`ls`, `get`, `put`), and Settings > Storage could only delete whole categories by age.
**On the card today:** six top-level folders, `irc`, `wifi`, `updates`, `gnss`, `gemini` and `captures`. No `notes` yet; settings are in flash, not on the card.
### Decisions (design round 2026-10-06)
| # | Decision |
|---|---|
| Q128 | An App of its own, **Storage**, in the Launcher. **Settings > Storage goes away:** its usage figures, Storage Clean-up and "Erase SD card" move into the App, under **Maintenance**, behind a warning that these delete things for good. |
| Q129 | A row shows the name, then the size or "folder", then the date modified. Folders first, then by name; `s` cycles the sort (name, date, size). The top line shows the path and the card's free space. |
| Q130 | Nothing is hidden. **Read-only:** `/gemini/cache`; any file the firmware has open right now (today's IRC log, a Track or Capture being recorded, a file being received); and the top-level folders themselves, which can't be renamed or deleted though their contents can. **Everything else, the user's own data included, can be renamed, moved or deleted**, always after a confirmation. |
| Q131 | One item at a time, with a clipboard: Enter opens; Back goes up, and leaves the App at the top; `c` copy, `x` cut, `v` paste into the current folder; `r` rename; `d` delete; `n` new folder; `i` details. |
| Q132 | Copy, move and delete work on folders too, recursively. The confirmation says what's inside: "Delete *saved* and its 42 files?". |
| Q133 | A copy is a job on the storage task in 4 KB pieces, with a progress Toast; Back cancels it. It checks free space first and asks before replacing anything. **Afterwards the sizes are compared**, not the contents: the driver is trusted since v0.6.1 (ADR 0007). A move within the card is a rename. |
| Q134 | Viewers by type. **Text** (`.txt`, `.log`, `.gmi`, `.csv`, `.gpx`, and anything that looks like text): read from the card as you scroll, so size doesn't matter; logs open at the end. **`.pcap`:** the LoRa Scanner's packet list. **`.gpx`:** a summary (start, duration, points, distance), Tab for the text. **`.ota`:** version, size, whether the signature is valid; Enter installs through Update from SD. **Anything else:** a hex dump. |
| Q135 | No editing: that comes with Notes (#19). |
| Q136 | A listing holds **up to 256 entries**, packed, about 10 KB; a bigger folder shows the first 256 by name and says how many more there are. The App refuses to open below the memory floors (Q86). |
| Q137 | **The Clock also sets the system time**, so files are dated correctly with GNSS alone and not only after NTP. A file dated before 2020 shows "-". |
| Q138 | Console: `cp`, `mv` and `mkdir`, next to `ls` and `rm`. |
| Q139 | Left out, each with its issue: selecting several items (#41), finding files by name (#42), opening a `.gmi` in the Gemini App (#43), a table view for `.csv` (#44), images (#45). |
| Q140 | Ships as **v0.9.0** when done and checked. |
### Done when
- The Storage App lists any folder of the card with sizes and dates, sorted three ways, and says so when a folder has more than 256 entries.
- A file can be copied, moved, renamed and deleted, and a folder too; a new folder can be made. Each destructive action asks first; a copy shows progress and can be cancelled.
- The read-only rules of Q130 hold, with a reason given when something is refused.
- Each viewer of Q134 opens its type, and a 1 MB text file scrolls without loading whole.
- Maintenance shows the card's usage and does what Settings > Storage did, behind its warning; Settings no longer has a Storage row; the Storage Warning points at the Storage App.
- A file written with only a GNSS Fix (no Wi-Fi) is dated correctly.
- Free heap stays above the floors with the App open, Wi-Fi and IRC on TLS.
### Work breakdown
1. **Model** (host-tested): paths and names, the read-only rules, the packed listing and its sorts, file types, sizes and dates for display, the GPX summary.
2. **Card operations:** listing a folder, copy, move, delete (recursive, counted), new folder, as storage jobs with progress and cancel; `cp`, `mv`, `mkdir`; the Clock sets the system time.
3. **The App:** browsing, the clipboard, dialogs, details.
4. **Maintenance:** usage, Clean-up and Erase moved in from Settings, with the warning.
5. **Viewers:** text, hex, `.pcap`, `.gpx`, `.ota`.
6. **Checks on the device**, recorded here.
### As built
- **`FileOps`** (`src/services/file_ops`) does the card's work for the App and for the console alike: list, count, copy, move, delete, new folder. One operation at a time on the storage task, **in turns of about 150 ms** that queue themselves again, so Log lines and a Capture are written in between. The rules of Q130 are checked there, whoever asks.
- **A listing reads the folder straight from FatFs.** Through the Arduino `File`, every entry was looked up by name again for its size and again for its date: 329 entries took over two seconds. One pass now, and it's there before the screen has redrawn. Counting, copying and deleting still walk with `File`; they show progress and can be stopped.
- **A copy shows its progress in a box in the App**, not a Toast (Q133): it has a bar and says Back cancels. A cancelled or failed copy deletes what it had written. The copy gets today's date, like `cp`.
- **The viewers** (`src/apps/file_viewer`, models in `lib/files`): text through `TextPager`, which reads about a kilobyte around the screen and wraps at spaces, 38 columns; going back a line wraps the paragraph before again, so a file reads the same in both directions. A `.pcap`, a `.gpx` and an `.ota` are read through once by a storage job, in the same 150 ms turns. An Update File is fed to the installer's own parser with a sink that writes nothing, so "would it install" is the same answer an install gives.
- **Tab** in a viewer shows the same file as hex, or as text (not in Q134).
- **Maintenance** is the last row at the top of the card, and `m` anywhere in the App. It's the old Settings > Storage page behind a dialog.
- **The Storage Warning** was only ever a Toast; "selecting it opens Storage Clean-up" (CONTEXT.md) was never built. It now reads "SD card over 80% full: see Storage".
- **Console:** `cp`, `mv`, `mkdir` (Q138), and `rm` and `du` through the same code, so `rm` now takes folders and follows the rules; `ls` shows dates. Debug Builds: `sd fill <folder> <count>` makes test files.
### Checks on the device (2026-10-06, v0.8.1-2 Debug Build)
All in a scratch folder, `/f1test`, removed afterwards.
| Check | Result |
|---|---|
| Host tests | 424 pass (411 before the viewers' models) |
| Browsing | Folders first, sizes and dates, the three sorts; a 300-file and a 329-file folder show "first 256 of 300" and "of 329" |
| New folder, rename, copy, cut and paste, delete | Each works on a file and on a folder; a copy next to its original is named `(2)`; a name in the way asks "Replace it?" |
| A folder of 11 files, 8.4 MB, copied | 19.4 s, 435 KB/s, the bar moving; two Log lines queued meanwhile were written |
| The same copy cancelled at 1.8 MB | "Cancelled: nothing was copied", and nothing was left behind |
| Delete | 341 files in 9.7 s; the dialog had counted them first |
| Read-only rules | `/irc`, `/gnss` (top-level folders), `/`, `/gemini/cache` and a folder made inside it, a folder into itself, a name with `:`; a Capture being recorded and the folder holding it; a folder under `/irc` while IRC runs. Each refused with its reason; the Capture could still be copied |
| Text | A 1 MB log opens at its last line at once; top, pages, lines; a file without an extension that looks like text opens as text |
| Hex | A 5 KB binary file; Tab from any other viewer |
| `.pcap` | A LoRa Capture: 3 packets as the Scanner lists them, Enter shows the Meshtastic header and bytes |
| `.gpx` | 400 points: start, 33 min 15 s, 4.68 km; Tab shows the text |
| `.ota` | A signed file: version, "intact", "older than what's running", Enter asks to install (not confirmed). A tampered one: "image corrupted (hash mismatch)" |
| Maintenance | The warning, then usage, Clean-up's categories and Erase (not run) |
| Date with GNSS only | NTP pointed at an address that doesn't answer, restart: the Clock came from the Fix, and a folder made then is dated 2026-10-06 08:39. A Track from the day before, written the same way by v0.8.1, shows "-" |
| Memory | IRC connected, the App open on 256 entries: 61 KB free (70 KB before opening). Lowest since boot 29.7 KB, during IRC's TLS handshake |
| Stacks | `storage` 3.1 KB free of 6 KB at worst, `loopTask` 1.5 KB |
**Not checked by hand:** how the keys feel on the device itself; everything above was driven through the Debug Console's `key` command and screenshots.
**One slip during the checks:** a scripted key sequence ran in the wrong folder and renamed `/gemini/saved` to `saved2`, then copied it to the top of the card. Both were put right at once (renamed back, the copy deleted; 7 files, 53,798 bytes, as before).
**Found on the way:** a panic at Wi-Fi join, there since v0.7.0 (SNTP started twice, issue #46). Fixed in v0.9.0.
-1
View File
@@ -23,7 +23,6 @@ const RowDef kRows[] = {
{Row::Gnss, Kind::Toggle, "GNSS"}, {Row::GnssQuiet, Kind::Toggle, "Pause GNSS for LoRa"}, {Row::Gnss, Kind::Toggle, "GNSS"}, {Row::GnssQuiet, Kind::Toggle, "Pause GNSS for LoRa"},
{Row::Coordinates, Kind::Toggle, "Coordinates"}, {Row::Coordinates, Kind::Toggle, "Coordinates"},
{Row::ProbeMacs, Kind::Toggle, "Probe MACs"}, {Row::Wifi, Kind::Page, "Wi-Fi"}, {Row::ProbeMacs, Kind::Toggle, "Probe MACs"}, {Row::Wifi, Kind::Page, "Wi-Fi"},
{Row::Storage, Kind::Page, "Storage"},
{Row::Firmware, Kind::Page, "Firmware"}, {Row::Firmware, Kind::Page, "Firmware"},
{Row::About, Kind::Page, "About"}, {Row::About, Kind::Page, "About"},
}; };
+1 -1
View File
@@ -11,7 +11,7 @@ namespace roro {
// values, choice lists and validation messages. Rendering and navigation live in the App. // values, choice lists and validation messages. Rendering and navigation live in the App.
class SettingsMenu { class SettingsMenu {
public: public:
enum class Row { LongName, ShortName, Region, Timezone, Brightness, DimTimeout, OffTimeout, Sound, Gnss, GnssQuiet, Coordinates, ProbeMacs, Wifi, Storage, Firmware, About }; enum class Row { LongName, ShortName, Region, Timezone, Brightness, DimTimeout, OffTimeout, Sound, Gnss, GnssQuiet, Coordinates, ProbeMacs, Wifi, Firmware, About };
enum class Kind { Text, Choice, Toggle, Slider, Page }; enum class Kind { Text, Choice, Toggle, Slider, Page };
explicit SettingsMenu(Settings& settings) : settings_(settings) {} explicit SettingsMenu(Settings& settings) : settings_(settings) {}
+137
View File
@@ -0,0 +1,137 @@
#include "file_list.h"
#include <algorithm>
#include <cctype>
#include <cstdio>
#include <cstring>
#include <ctime>
namespace roro::files {
namespace {
// Names compare letters only, whatever the case; ties by the bytes, so the order is total.
int compareNames(const char* a, const char* b) {
for (const char *x = a, *y = b;; ++x, ++y) {
int cx = std::tolower(static_cast<unsigned char>(*x)), cy = std::tolower(static_cast<unsigned char>(*y));
if (cx != cy) return cx < cy ? -1 : 1;
if (!cx) break;
}
return std::strcmp(a, b);
}
constexpr uint32_t kYear2020 = 1577836800;
std::string sizeText(uint32_t bytes) {
char s[16];
if (bytes < 1024) std::snprintf(s, sizeof s, "%u B", static_cast<unsigned>(bytes));
else if (bytes < 10 * 1024) std::snprintf(s, sizeof s, "%.1f KB", bytes / 1024.0);
else if (bytes < 1024 * 1024) std::snprintf(s, sizeof s, "%u KB", static_cast<unsigned>(bytes / 1024));
else if (bytes < 10u * 1024 * 1024) std::snprintf(s, sizeof s, "%.1f MB", bytes / 1048576.0);
else if (bytes < 1024u * 1024 * 1024) std::snprintf(s, sizeof s, "%u MB", static_cast<unsigned>(bytes / 1048576));
else std::snprintf(s, sizeof s, "%.1f GB", bytes / 1073741824.0);
return s;
}
} // namespace
void FileList::clear() {
std::vector<Entry>().swap(entries_);
std::vector<uint16_t>().swap(order_);
std::vector<char>().swap(names_);
more_ = wasted_ = 0;
}
bool FileList::before(const Entry& a, const Entry& b, FileSort by) const {
if (a.folder != b.folder) return a.folder;
if (!a.folder) {
if (by == FileSort::Date && a.modified != b.modified) return a.modified > b.modified;
if (by == FileSort::Size && a.size != b.size) return a.size > b.size;
}
return compareNames(names_.data() + a.name, names_.data() + b.name) < 0;
}
void FileList::add(const char* name, uint32_t size, uint32_t modified, bool folder) {
size_t len = std::strlen(name) + 1;
if (entries_.size() >= kMax) {
// Full: the new entry takes the place of the one that sorts last by name, if it sorts
// before it. The old name's bytes stay in the buffer until there's enough waste to pack.
more_++;
size_t last = 0;
for (size_t i = 1; i < entries_.size(); i++)
if (before(entries_[last], entries_[i], FileSort::Name)) last = i;
Entry candidate{static_cast<uint32_t>(names_.size()), size, modified, folder};
names_.insert(names_.end(), name, name + len);
if (!before(candidate, entries_[last], FileSort::Name)) {
names_.resize(names_.size() - len);
return;
}
wasted_ += std::strlen(names_.data() + entries_[last].name) + 1;
entries_[last] = candidate;
if (wasted_ > 2048) compact();
return;
}
if (entries_.empty()) {
entries_.reserve(32);
names_.reserve(512);
}
entries_.push_back({static_cast<uint32_t>(names_.size()), size, modified, folder});
names_.insert(names_.end(), name, name + len);
order_.push_back(static_cast<uint16_t>(order_.size()));
}
void FileList::compact() {
std::vector<char> packed;
packed.reserve(names_.size() - wasted_);
for (Entry& e : entries_) {
const char* n = names_.data() + e.name;
e.name = static_cast<uint32_t>(packed.size());
packed.insert(packed.end(), n, n + std::strlen(n) + 1);
}
names_.swap(packed);
wasted_ = 0;
}
void FileList::sort(FileSort by) {
if (wasted_) compact();
names_.shrink_to_fit();
entries_.shrink_to_fit();
order_.resize(entries_.size());
for (size_t i = 0; i < order_.size(); i++) order_[i] = static_cast<uint16_t>(i);
std::sort(order_.begin(), order_.end(), [&](uint16_t a, uint16_t b) { return before(entries_[a], entries_[b], by); });
}
int FileList::find(const std::string& name) const {
for (size_t i = 0; i < order_.size(); i++)
if (name == this->name(i)) return static_cast<int>(i);
return -1;
}
size_t FileList::bytes() const {
return entries_.capacity() * sizeof(Entry) + order_.capacity() * sizeof(uint16_t) + names_.capacity();
}
std::string formatStamp(uint32_t modified) {
if (modified < kYear2020) return "-";
time_t t = static_cast<time_t>(modified);
struct tm local;
localtime_r(&t, &local);
char s[20];
std::snprintf(s, sizeof s, "%04d-%02d-%02d %02d:%02d", local.tm_year + 1900, local.tm_mon + 1, local.tm_mday, local.tm_hour,
local.tm_min);
return s;
}
std::string rowDetail(bool folder, uint32_t size, uint32_t modified) {
if (folder) return "folder";
std::string stamp = formatStamp(modified);
return sizeText(size) + " " + (stamp == "-" ? stamp : stamp.substr(0, 10));
}
std::string fitName(const std::string& name, size_t maxChars) {
if (name.size() <= maxChars || maxChars < 8) return name.substr(0, maxChars);
size_t tail = std::min<size_t>(6, maxChars / 3), head = maxChars - tail - 2;
return name.substr(0, head) + ".." + name.substr(name.size() - tail);
}
} // namespace roro::files
+55
View File
@@ -0,0 +1,55 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <string>
#include <vector>
namespace roro::files {
enum class FileSort : uint8_t { Name, Date, Size };
// A folder's entries for the Storage App (F1, Q129, Q136): 256 at most, the names packed into
// one buffer, about 10 KB when full. A bigger folder keeps the first 256 by name, whatever order
// the card lists them in, and counts the rest.
class FileList {
public:
static constexpr size_t kMax = 256;
void clear();
void add(const char* name, uint32_t size, uint32_t modified, bool folder);
void sort(FileSort by); // folders first, by name; then files by name, newest or biggest first
size_t count() const { return entries_.size(); }
size_t more() const { return more_; } // entries that didn't fit
// By position after sort().
const char* name(size_t i) const { return names_.data() + entries_[order_[i]].name; }
uint32_t size(size_t i) const { return entries_[order_[i]].size; }
uint32_t modified(size_t i) const { return entries_[order_[i]].modified; } // Unix time, 0 if unknown
bool folder(size_t i) const { return entries_[order_[i]].folder; }
int find(const std::string& name) const; // position, or -1
size_t bytes() const; // memory held
private:
struct Entry {
uint32_t name; // offset into names_
uint32_t size, modified;
bool folder;
};
bool before(const Entry& a, const Entry& b, FileSort by) const;
void compact();
std::vector<Entry> entries_;
std::vector<uint16_t> order_;
std::vector<char> names_;
size_t more_ = 0, wasted_ = 0;
};
// What a row shows on the right (Q129): "folder", or "1.2 KB 2026-10-05". A file dated before
// 2020 was written before the clock was set: "-" (Q137). Local time.
std::string rowDetail(bool folder, uint32_t size, uint32_t modified);
std::string formatStamp(uint32_t modified);
// A name cut to `maxChars` for a row, from the middle: the start and the extension stay readable.
std::string fitName(const std::string& name, size_t maxChars); // "2026-10-05 20:00", or "-"
} // namespace roro::files
+99
View File
@@ -0,0 +1,99 @@
#include "file_names.h"
#include <cctype>
namespace roro::files {
const char* const kFirmwareFolders[] = {"/irc", "/wifi", "/updates", "/gnss", "/gemini", "/captures", "/notes"};
const size_t kFirmwareFolderCount = sizeof kFirmwareFolders / sizeof kFirmwareFolders[0];
std::string parentOf(const std::string& path) {
size_t slash = path.rfind('/');
return slash == std::string::npos || slash == 0 ? "/" : path.substr(0, slash);
}
std::string baseName(const std::string& path) {
size_t slash = path.rfind('/');
return slash == std::string::npos ? path : path.substr(slash + 1);
}
std::string joinPath(const std::string& folder, const std::string& name) {
return folder == "/" ? "/" + name : folder + "/" + name;
}
std::string extensionOf(const std::string& name) {
size_t dot = name.rfind('.');
if (dot == std::string::npos || dot == 0) return "";
std::string ext = name.substr(dot + 1);
for (char& c : ext) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
return ext;
}
bool isInside(const std::string& path, const std::string& folder) {
if (folder == "/") return true;
if (path.compare(0, folder.size(), folder) != 0) return false;
return path.size() == folder.size() || path[folder.size()] == '/';
}
std::string checkName(const std::string& name) {
if (name.empty()) return "A name can't be empty";
if (name == "." || name == "..") return "\".\" and \"..\" aren't names";
if (name.size() > 64) return "A name can be 64 characters at most";
for (char c : name) {
if (static_cast<unsigned char>(c) < 0x20) return "A name can't contain control characters";
for (char bad : std::string("/\\:*?\"<>|"))
if (c == bad) return std::string("A name can't contain ") + c;
}
if (name.back() == '.' || name.back() == ' ') return "A name can't end with a dot or a space";
return "";
}
std::string whyReadOnly(const std::string& path, const std::vector<std::string>& inUse) {
if (path == "/" || path.empty()) return "That's the card itself";
if (isInside(path, kGeminiCache)) return std::string(kGeminiCache) + " is the Gemini App's working space";
for (const std::string& open : inUse) {
if (open == path) return "It's being written right now";
if (isInside(open, path)) return "It holds a file that's being written right now";
}
for (size_t i = 0; i < kFirmwareFolderCount; i++)
if (path == kFirmwareFolders[i]) return std::string("The firmware keeps its files in ") + path;
return "";
}
std::string whyNotInto(const std::string& source, const std::string& into) {
if (isInside(into, kGeminiCache)) return std::string(kGeminiCache) + " is the Gemini App's working space";
if (isInside(into, source)) return "A folder can't go inside itself";
if (parentOf(source) == into) return "It's already there";
return "";
}
std::string copyName(const std::string& name, int n) {
size_t dot = name.rfind('.');
std::string suffix = " (" + std::to_string(n) + ")";
if (dot == std::string::npos || dot == 0) return name + suffix;
return name.substr(0, dot) + suffix + name.substr(dot);
}
FileKind kindOf(const std::string& name) {
std::string ext = extensionOf(name);
if (ext == "gpx") return FileKind::Gpx;
if (ext == "pcap") return FileKind::Pcap;
if (ext == "ota") return FileKind::Ota;
for (const char* text : {"txt", "log", "gmi", "csv", "md", "json", "ini", "conf", "ir"})
if (ext == text) return FileKind::Text;
return FileKind::Unknown;
}
bool opensAtEnd(const std::string& name) { return extensionOf(name) == "log"; }
bool looksLikeText(const uint8_t* data, size_t len) {
size_t odd = 0;
for (size_t i = 0; i < len; i++) {
uint8_t b = data[i];
if (b == 0) return false;
if (b < 0x20 && b != '\n' && b != '\r' && b != '\t') odd++;
}
return odd * 20 <= len; // a stray control character or two is still text
}
} // namespace roro::files
+42
View File
@@ -0,0 +1,42 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <string>
#include <vector>
namespace roro::files {
// Paths on the SD card are absolute and use '/': "/gemini/saved/index.gmi".
std::string parentOf(const std::string& path); // "/" for a top-level entry and for "/"
std::string baseName(const std::string& path); // "" for "/"
std::string joinPath(const std::string& folder, const std::string& name);
std::string extensionOf(const std::string& name); // lower case, without the dot; "" if none
bool isInside(const std::string& path, const std::string& folder); // a folder is inside itself
// A name typed for rename or a new folder (F1, Q131): "" if FAT and this App can take it, or why not.
std::string checkName(const std::string& name);
// The top-level folders the firmware keeps its files in. They can't be renamed or deleted;
// what's in them can (Q130).
extern const char* const kFirmwareFolders[];
extern const size_t kFirmwareFolderCount;
constexpr const char* kGeminiCache = "/gemini/cache";
// Why `path` can't be renamed, moved or deleted, or "" if it can (Q130). `inUse`: the files the
// firmware has open right now.
std::string whyReadOnly(const std::string& path, const std::vector<std::string>& inUse);
// Why `source` can't be copied or moved into the folder `into`, or "" if it can.
std::string whyNotInto(const std::string& source, const std::string& into);
// "a (2).gmi": the name of a copy made next to its original.
std::string copyName(const std::string& name, int n);
// Which viewer opens a file (Q134), from its name. Unknown: look at the first bytes.
enum class FileKind : uint8_t { Text, Gpx, Pcap, Ota, Unknown };
FileKind kindOf(const std::string& name);
bool opensAtEnd(const std::string& name); // logs
bool looksLikeText(const uint8_t* data, size_t len);
} // namespace roro::files
+143
View File
@@ -0,0 +1,143 @@
#include "file_views.h"
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include "file_list.h"
#include "track.h"
namespace roro::files {
std::string hexRow(uint32_t offset, const uint8_t* data, size_t len) {
char head[8];
std::snprintf(head, sizeof head, "%05X", static_cast<unsigned>(offset));
std::string row = head, text;
for (size_t i = 0; i < 8; i++) {
if (i % 2 == 0) row += ' ';
char hex[3] = " ";
if (i < len) {
std::snprintf(hex, sizeof hex, "%02x", data[i]);
text += data[i] >= 0x20 && data[i] < 0x7F ? static_cast<char>(data[i]) : '.';
}
row += hex;
}
return row + " " + text;
}
namespace {
// Days since 1970-01-01 (Howard Hinnant's days_from_civil): no timegm() everywhere.
int64_t daysFromCivil(int y, int m, int d) {
y -= m <= 2;
int64_t era = (y >= 0 ? y : y - 399) / 400;
int yoe = static_cast<int>(y - era * 400);
int doy = (153 * (m + (m > 2 ? -3 : 9)) + 2) / 5 + d - 1;
int doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
return era * 146097 + doe - 719468;
}
bool attribute(const std::string& element, const char* name, double& out) {
size_t at = element.find(name);
if (at == std::string::npos) return false;
const char* from = element.c_str() + at + std::strlen(name);
char* end = nullptr;
out = std::strtod(from, &end);
return end != from;
}
} // namespace
void GpxSummary::point(const std::string& element) {
double lat, lon;
if (!attribute(element, "lat=\"", lat) || !attribute(element, "lon=\"", lon)) return;
if (points_ > 0) meters_ += gnss::distanceMeters(lat_, lon_, lat, lon);
lat_ = lat;
lon_ = lon;
points_++;
size_t at = element.find("<time>");
int y, mo, d, h, mi, s;
if (at != std::string::npos && std::sscanf(element.c_str() + at + 6, "%d-%d-%dT%d:%d:%d", &y, &mo, &d, &h, &mi, &s) == 6) {
int64_t t = daysFromCivil(y, mo, d) * 86400 + h * 3600 + mi * 60 + s;
if (first_ == 0) first_ = t;
last_ = t;
}
}
void GpxSummary::feed(const char* data, size_t len) {
carry_.append(data, len);
size_t done = 0;
for (;;) {
size_t open = carry_.find("<trkpt", done);
if (open == std::string::npos) {
// Nothing begun, except perhaps the first letters of a tag at the very end.
done = carry_.size() > 6 ? carry_.size() - 6 : done;
break;
}
size_t close = carry_.find("</trkpt>", open);
size_t next = carry_.find("<trkpt", open + 6); // a point with nothing inside: <trkpt .../>
if (close == std::string::npos && next == std::string::npos) {
done = open;
break;
}
size_t end = close != std::string::npos && (next == std::string::npos || close < next) ? close + 8 : next;
point(carry_.substr(open, end - open));
done = end;
}
carry_.erase(0, done);
if (carry_.size() > 2048) carry_.erase(0, carry_.size() - 6); // not a GPX point: don't keep it
}
std::string formatDuration(int64_t seconds) {
char s[24];
if (seconds < 60) std::snprintf(s, sizeof s, "%d s", static_cast<int>(seconds));
else if (seconds < 3600) std::snprintf(s, sizeof s, "%d min %02d s", static_cast<int>(seconds / 60), static_cast<int>(seconds % 60));
else std::snprintf(s, sizeof s, "%d h %02d min", static_cast<int>(seconds / 3600), static_cast<int>(seconds % 3600 / 60));
return s;
}
std::vector<std::string> GpxSummary::lines() const {
std::vector<std::string> out;
out.push_back(std::to_string(points_) + (points_ == 1 ? " point" : " points"));
if (first_ > 0) {
out.push_back("Started " + formatStamp(static_cast<uint32_t>(first_)));
out.push_back("Lasted " + formatDuration(last_ - first_));
}
char s[32];
if (meters_ < 1000) std::snprintf(s, sizeof s, "Distance %.0f m", meters_);
else std::snprintf(s, sizeof s, "Distance %.2f km", meters_ / 1000);
out.push_back(s);
return out;
}
namespace {
uint32_t le32(const uint8_t* p) { return p[0] | p[1] << 8 | p[2] << 16 | static_cast<uint32_t>(p[3]) << 24; }
} // namespace
PcapHeader parsePcapHeader(const uint8_t* data, size_t len) {
PcapHeader h;
if (len < kPcapHeaderSize || le32(data) != 0xA1B2C3D4) return h; // little-endian, microseconds: what we write
h.ok = true;
h.linkType = le32(data + 20);
return h;
}
bool parsePcapRecord(const uint8_t* data, size_t len, PcapRecord& out) {
if (len < kPcapRecordSize) return false;
out.seconds = le32(data);
out.micros = le32(data + 4);
out.length = le32(data + 8);
return out.length <= 65535;
}
bool parseLoraTap(const uint8_t* d, size_t len, lora::RxInfo& out) {
if (len < lora::kLoraTapSize || d[0] != 0) return false;
out.frequencyHz = static_cast<uint32_t>(d[4]) << 24 | d[5] << 16 | d[6] << 8 | d[7];
out.bandwidthKHz = d[8] * 125.0f;
out.spreadingFactor = d[9];
out.rssi = d[10] - 139.0f;
out.noiseFloor = d[12] - 139.0f;
out.snr = static_cast<int8_t>(d[13]) / 4.0f;
out.syncWord = d[14];
return true;
}
} // namespace roro::files
+57
View File
@@ -0,0 +1,57 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <string>
#include <vector>
#include "loratap.h"
namespace roro::files {
// What the Storage App's viewers show of the files the firmware writes (F1, Q134).
// One row of a hex dump, eight bytes: "00010 4865 6c6c 6f2c 2077 Hello, w".
std::string hexRow(uint32_t offset, const uint8_t* data, size_t len);
// A Track (.gpx) read a piece at a time: its points, when it started and ended, how far it went.
class GpxSummary {
public:
void feed(const char* data, size_t len);
uint32_t points() const { return points_; }
int64_t start() const { return first_; } // UTC seconds, 0 if no point carried a time
int64_t end() const { return last_; }
double meters() const { return meters_; }
std::vector<std::string> lines() const; // for the screen
private:
void point(const std::string& element);
std::string carry_; // the part of a point cut by the end of a piece
uint32_t points_ = 0;
int64_t first_ = 0, last_ = 0;
double meters_ = 0, lat_ = 0, lon_ = 0;
};
// "1 h 02 min", "4 min 10 s", "12 s".
std::string formatDuration(int64_t seconds);
// A Capture (.pcap): the file's header, then one record after another.
struct PcapHeader {
bool ok = false;
uint32_t linkType = 0; // 270: LoRaTap, what the LoRa Scanner writes
};
constexpr size_t kPcapHeaderSize = 24, kPcapRecordSize = 16;
constexpr uint32_t kLinkLoraTap = 270;
PcapHeader parsePcapHeader(const uint8_t* data, size_t len);
struct PcapRecord {
uint32_t seconds = 0, micros = 0, length = 0; // length: the bytes that follow in the file
};
bool parsePcapRecord(const uint8_t* data, size_t len, PcapRecord& out); // false: not a record, stop there
// The LoRaTap header a packet starts with; false if `len` is too short for one.
bool parseLoraTap(const uint8_t* data, size_t len, lora::RxInfo& out);
} // namespace roro::files
+121
View File
@@ -0,0 +1,121 @@
#include "text_pager.h"
#include <algorithm>
namespace roro::files {
TextPager::TextPager(ReadAt read, uint32_t size, int cols, int rows)
: read_(std::move(read)), size_(size), cols_(std::max(1, cols)), rows_(std::max(1, rows)) {}
const uint8_t* TextPager::bytes(uint32_t at, size_t& len) {
len = 0;
if (at >= size_) return nullptr;
bool cached = at >= cacheAt_ && at < cacheAt_ + cache_.size();
// Wanted: a line's worth ahead, unless the cache already reaches the end of the file.
size_t ahead = cached ? cacheAt_ + cache_.size() - at : 0;
if (!cached || (ahead < kBlock / 4 && cacheAt_ + cache_.size() < size_)) {
cacheAt_ = at - std::min<uint32_t>(at, kBlock / 2); // room behind too: scrolling back is common
cache_.resize(std::min<uint32_t>(kBlock, size_ - cacheAt_));
cache_.resize(read_(cacheAt_, cache_.data(), cache_.size()));
if (at >= cacheAt_ + cache_.size()) return nullptr; // the file got shorter, or the card failed
}
len = cacheAt_ + cache_.size() - at;
return cache_.data() + (at - cacheAt_);
}
int TextPager::byteAt(uint32_t at) {
size_t len;
const uint8_t* p = bytes(at, len);
return p ? *p : -1;
}
uint32_t TextPager::nextLine(uint32_t at, std::string* text) {
size_t len;
const uint8_t* p = bytes(at, len);
if (text) text->clear();
if (!p) return size_;
size_t end = len, next = len; // the line is [0, end); the one after starts at `next`
int count = 0;
size_t lastSpace = 0;
for (size_t i = 0; i < len; i++) {
uint8_t b = p[i];
if (b == '\n') {
end = i;
next = i + 1;
break;
}
if ((b & 0xC0) == 0x80) continue; // inside a UTF-8 character
if (count == cols_) { // one character too many: wrap
if (b == ' ') end = i, next = i + 1;
else if (lastSpace > 0) end = lastSpace, next = lastSpace + 1;
else end = next = i;
break;
}
count++;
if (b == ' ') lastSpace = i;
}
if (text) {
size_t n = end > 0 && p[end - 1] == '\r' ? end - 1 : end;
text->reserve(n);
for (size_t i = 0; i < n; i++) text->push_back(p[i] == '\t' ? ' ' : (p[i] < 0x20 || p[i] == 0x7F) ? '.' : static_cast<char>(p[i]));
}
return at + static_cast<uint32_t>(std::max<size_t>(next, 1));
}
uint32_t TextPager::lineBefore(uint32_t at) {
if (at == 0) return 0;
at = std::min(at, size_);
// The paragraph the line before `at` belongs to starts after the newline before it. The byte
// just before `at` may be that line's own newline.
uint32_t from = at - 1;
if (from > 0 && byteAt(from) == '\n') from--;
uint32_t limit = at > kLookBack ? at - kLookBack : 0, start = limit;
for (uint32_t i = from + 1; i-- > limit;) {
if (byteAt(i) == '\n' && i < at - 1) {
start = i + 1;
break;
}
}
// No newline that near: any character boundary will do as a place to wrap from.
while (start > 0 && start < at && (byteAt(start) & 0xC0) == 0x80) start++;
for (uint32_t a = start;;) {
uint32_t next = nextLine(a, nullptr);
if (next >= at) return a;
a = next;
}
}
bool TextPager::atEnd() {
uint32_t a = top_;
for (int i = 0; i < rows_; i++) {
a = nextLine(a, nullptr);
if (a >= size_) return true;
}
return false;
}
void TextPager::down(int n) {
for (; n > 0 && !atEnd(); n--) top_ = nextLine(top_, nullptr);
}
void TextPager::up(int n) {
for (; n > 0 && top_ > 0; n--) top_ = lineBefore(top_);
}
void TextPager::toEnd() {
top_ = size_;
up(rows_);
}
std::vector<std::string> TextPager::lines() {
std::vector<std::string> out;
uint32_t a = top_;
for (int i = 0; i < rows_ && a < size_; i++) {
std::string text;
a = nextLine(a, &text);
out.push_back(std::move(text));
}
return out;
}
} // namespace roro::files
+50
View File
@@ -0,0 +1,50 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <functional>
#include <string>
#include <vector>
namespace roro::files {
// A text file of any size, shown a screen at a time (F1, Q134): only the part on screen is read,
// through `read`, about a kilobyte at once. Lines wrap at spaces, `cols` characters wide. Going
// back a line means finding where the paragraph before started and wrapping it again, so a file
// reads the same whichever way it was scrolled.
class TextPager {
public:
// Reads up to `len` bytes at `offset`; returns how many it got.
using ReadAt = std::function<size_t(uint32_t offset, uint8_t* into, size_t len)>;
TextPager(ReadAt read, uint32_t size, int cols, int rows);
void toStart() { top_ = 0; }
void toEnd(); // the last line at the bottom of the screen
void down(int lines = 1);
void up(int lines = 1);
std::vector<std::string> lines(); // what's on screen: tabs as spaces, control characters as dots
uint32_t top() const { return top_; }
uint32_t size() const { return size_; }
bool atEnd(); // the file's last line is on screen
int percent() const { return size_ ? static_cast<int>(static_cast<uint64_t>(top_) * 100 / size_) : 0; }
private:
static constexpr size_t kBlock = 1024; // read at once
static constexpr uint32_t kLookBack = 1024; // how far back a paragraph's start is looked for
const uint8_t* bytes(uint32_t at, size_t& len); // what's cached from `at` on
int byteAt(uint32_t at); // -1 past the end
uint32_t nextLine(uint32_t at, std::string* text); // where the line after the one at `at` starts
uint32_t lineBefore(uint32_t at);
ReadAt read_;
uint32_t size_;
int cols_, rows_;
uint32_t top_ = 0;
std::vector<uint8_t> cache_;
uint32_t cacheAt_ = 0;
};
} // namespace roro::files
+1 -1
View File
@@ -24,7 +24,7 @@ void StorageMonitor::update(bool present, uint64_t totalBytes, uint64_t usedByte
if (next.present && next.level >= 80 && !warned_) { if (next.present && next.level >= 80 && !warned_) {
warned_ = true; warned_ = true;
bus_.publish(Event::withText(EventType::Notification, "SD card over 80% full", bus_.publish(Event::withText(EventType::Notification, "SD card over 80% full: see Storage",
static_cast<int32_t>(NotificationLevel::Warning))); static_cast<int32_t>(NotificationLevel::Warning)));
} }
} }
+458
View File
@@ -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
+67
View File
@@ -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" #include "ui/widgets.h"
@@ -9,25 +9,25 @@ constexpr int kAges = sizeof(kCleanupAgeLabels) / sizeof(kCleanupAgeLabels[0]);
constexpr int kCategories = sizeof(kCleanupCategories) / sizeof(kCleanupCategories[0]); constexpr int kCategories = sizeof(kCleanupCategories) / sizeof(kCleanupCategories[0]);
} // namespace } // namespace
void StoragePage::enter() { void MaintenancePage::enter() {
view_ = View::Main; view_ = View::Main;
dialog_.reset(); dialog_.reset();
main_.setCount(kRows); main_.setCount(kRows);
main_.select(kCleanUp); 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))); 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; uint64_t sum = 0;
if (category < static_cast<int>(listing_.size())) if (category < static_cast<int>(listing_.size()))
for (auto& f : listing_[category]) sum += f.bytes; for (auto& f : listing_[category]) sum += f.bytes;
return sum; return sum;
} }
CleanupPlan StoragePage::planFor(int age) const { CleanupPlan MaintenancePage::planFor(int age) const {
int today = clock_.today(); int today = clock_.today();
auto cutoff = static_cast<CleanupAge>(age); auto cutoff = static_cast<CleanupAge>(age);
// Without a clock only "Everything" can be judged. // 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); return CleanupPlan::make(listing_[categories_.selected()], cutoff, today);
} }
bool StoragePage::onKey(const KeyEvent& e) { bool MaintenancePage::onKey(const KeyEvent& e) {
if (dialog_) { if (dialog_) {
dialog_->onKey(e); dialog_->onKey(e);
int result = dialog_->result(); int result = dialog_->result();
@@ -109,7 +109,7 @@ bool StoragePage::onKey(const KeyEvent& e) {
return true; return true;
} }
void StoragePage::draw(Canvas& c) { void MaintenancePage::draw(Canvas& c) {
const auto& area = theme::kContent; const auto& area = theme::kContent;
if (!haveListing_ && storage_.listingReady()) { if (!haveListing_ && storage_.listingReady()) {
listing_ = storage_.listing(); listing_ = storage_.listing();
@@ -15,10 +15,10 @@
namespace roro { namespace roro {
// Settings → Storage: card usage, Storage Clean-up (category → age → confirm) and erasing the card. // Storage → Maintenance: card usage, Storage Clean-up (category → age → confirm) and erasing the card.
class StoragePage { class MaintenancePage {
public: public:
StoragePage(StorageService& storage, ClockService& clock, EventBus& bus) MaintenancePage(StorageService& storage, ClockService& clock, EventBus& bus)
: storage_(storage), clock_(clock), bus_(bus) {} : storage_(storage), clock_(clock), bus_(bus) {}
void enter(); void enter();
+2 -10
View File
@@ -27,9 +27,6 @@ bool SettingsApp::onKey(const KeyEvent& e) {
case Page::Menu: return onMenuKey(e); case Page::Menu: return onMenuKey(e);
case Page::Text: return onTextKey(e); case Page::Text: return onTextKey(e);
case Page::Choice: return onChoiceKey(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::About: return onAboutKey(e);
case Page::Wifi: case Page::Wifi:
if (!wifiPage_.onKey(e)) page_ = Page::Menu; if (!wifiPage_.onKey(e)) page_ = Page::Menu;
@@ -70,10 +67,6 @@ bool SettingsApp::onMenuKey(const KeyEvent& e) {
case Kind::Slider: break; case Kind::Slider: break;
case Kind::Page: case Kind::Page:
switch (menu_.row(i)) { switch (menu_.row(i)) {
case Row::Storage:
page_ = Page::Storage;
storagePage_.enter();
break;
case Row::Wifi: case Row::Wifi:
page_ = Page::Wifi; page_ = Page::Wifi;
wifiPage_.enter(); wifiPage_.enter();
@@ -130,8 +123,8 @@ bool SettingsApp::onAboutKey(const KeyEvent& e) {
} }
void SettingsApp::update(uint32_t nowMs) { void SettingsApp::update(uint32_t nowMs) {
// Live values on Storage and About. // Live values on About and Firmware.
bool live = page_ == Page::Storage || page_ == Page::About || page_ == Page::Firmware || bool live = page_ == Page::About || page_ == Page::Firmware ||
(page_ == Page::Wifi && wifiPage_.live()); (page_ == Page::Wifi && wifiPage_.live());
if (live && nowMs - lastRefreshMs_ >= 500) { if (live && nowMs - lastRefreshMs_ >= 500) {
lastRefreshMs_ = nowMs; lastRefreshMs_ = nowMs;
@@ -188,7 +181,6 @@ void SettingsApp::draw(Canvas& c) {
widgets::list(c, choices_, area, [&](int i) { return (i == current ? "* " : " ") + options[i]; }); widgets::list(c, choices_, area, [&](int i) { return (i == current ? "* " : " ") + options[i]; });
break; break;
} }
case Page::Storage: storagePage_.draw(c); break;
case Page::About: widgets::textLines(c, aboutLines(), 0, area); break; case Page::About: widgets::textLines(c, aboutLines(), 0, area); break;
case Page::Wifi: wifiPage_.draw(c); break; case Page::Wifi: wifiPage_.draw(c); break;
case Page::Firmware: firmwarePage_.draw(c); break; case Page::Firmware: firmwarePage_.draw(c); break;
+2 -5
View File
@@ -14,7 +14,6 @@
#include "services/clock_service.h" #include "services/clock_service.h"
#include "services/storage_service.h" #include "services/storage_service.h"
#include "apps/firmware_page.h" #include "apps/firmware_page.h"
#include "apps/storage_page.h"
#include "apps/wifi_settings_page.h" #include "apps/wifi_settings_page.h"
#include "settings_menu.h" #include "settings_menu.h"
#include "ui/theme.h" #include "ui/theme.h"
@@ -33,14 +32,13 @@ struct SettingsAppDeps {
UpdateService& update; 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 { class SettingsApp : public App {
public: public:
explicit SettingsApp(const SettingsAppDeps& deps) explicit SettingsApp(const SettingsAppDeps& deps)
: d_(deps), : d_(deps),
menu_(deps.settings), menu_(deps.settings),
wifiPage_(deps.settings, deps.savedNetworks, deps.wifi, deps.bus), wifiPage_(deps.settings, deps.savedNetworks, deps.wifi, deps.bus),
storagePage_(deps.storage, deps.clock, deps.bus),
firmwarePage_(deps.update, deps.wifi, deps.storage) {} firmwarePage_(deps.update, deps.wifi, deps.storage) {}
void onEnter() override; void onEnter() override;
bool onKey(const KeyEvent& e) override; bool onKey(const KeyEvent& e) override;
@@ -51,7 +49,7 @@ class SettingsApp : public App {
void draw(Canvas& c) override; void draw(Canvas& c) override;
private: 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 onMenuKey(const KeyEvent& e);
bool onTextKey(const KeyEvent& e); bool onTextKey(const KeyEvent& e);
@@ -63,7 +61,6 @@ class SettingsApp : public App {
SettingsAppDeps d_; SettingsAppDeps d_;
SettingsMenu menu_; SettingsMenu menu_;
WifiSettingsPage wifiPage_; WifiSettingsPage wifiPage_;
StoragePage storagePage_;
FirmwarePage firmwarePage_; FirmwarePage firmwarePage_;
Page page_ = Page::Menu; Page page_ = Page::Menu;
ListModel list_{theme::kContent.h / theme::kLineHeight}; ListModel list_{theme::kContent.h / theme::kLineHeight};
+566
View File
@@ -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
+96
View File
@@ -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
View File
@@ -14,6 +14,7 @@
#include "apps/launcher_app.h" #include "apps/launcher_app.h"
#include "apps/lora_scanner_app.h" #include "apps/lora_scanner_app.h"
#include "apps/settings_app.h" #include "apps/settings_app.h"
#include "apps/storage_app.h"
#include "apps/system_app.h" #include "apps/system_app.h"
#include "apps/wifi_tools_app.h" #include "apps/wifi_tools_app.h"
#include "apps/setup_app.h" #include "apps/setup_app.h"
@@ -30,10 +31,12 @@
#include "sd_fault.h" #include "sd_fault.h"
#include "service_manager.h" #include "service_manager.h"
#include "platform/identity.h" #include "platform/identity.h"
#include "file_names.h"
#include "services/battery_service.h" #include "services/battery_service.h"
#include "services/irc_service.h" #include "services/irc_service.h"
#include "services/clock_service.h" #include "services/clock_service.h"
#include "services/debug_console.h" #include "services/debug_console.h"
#include "services/file_ops.h"
#include "services/gemini_service.h" #include "services/gemini_service.h"
#include "services/gnss_service.h" #include "services/gnss_service.h"
#include "services/power_service.h" #include "services/power_service.h"
@@ -62,6 +65,8 @@ static Screen screen;
// Constructed in setup(), after the hardware and Settings are ready. // Constructed in setup(), after the hardware and Settings are ready.
static BatteryService* battery; static BatteryService* battery;
static StorageService* storageService; static StorageService* storageService;
static FileOps* fileOps;
static std::vector<std::string> filesInUse(const std::string& path, bool folder);
static PowerService* power; static PowerService* power;
static ClockService* clockService; static ClockService* clockService;
static GnssService* gnssService; static GnssService* gnssService;
@@ -175,6 +180,7 @@ void setup() {
savedNetworks->load(); savedNetworks->load();
wifi = new WifiService(settings, *savedNetworks, *clockService); wifi = new WifiService(settings, *savedNetworks, *clockService);
update = new UpdateService(nvs, *wifi, *savedNetworks, *storageService, bus, settings); 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); irc = new IrcService(nvs, "roro_" + identity::defaultShortName(), *wifi, *storageService, *clockService, bus);
notifier = new Notifier(bus, settings); notifier = new Notifier(bus, settings);
notifier->onShow = [](uint32_t now, uint32_t until) { power->onNotification(now, until); }; 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({"gnss", "GNSS", false, new GnssApp(*gnssService, settings)});
apps->registerApp({"gemini", "Gemini", false, new GeminiApp(*geminiService)}); apps->registerApp({"gemini", "Gemini", false, new GeminiApp(*geminiService)});
apps->registerApp({"lora", "LoRa Scanner", false, new LoraScannerApp(*radioService, *loraCapture, settings, *clockService)}); 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, apps->registerApp({"system", "System", false,
new SystemApp(*wifi, *battery, *storageService, *radioService, *gnssService, nvs)}); new SystemApp(*wifi, *battery, *storageService, *radioService, *gnssService, nvs)});
apps->registerApp({"settings", "Settings", false, apps->registerApp({"settings", "Settings", false,
@@ -397,6 +404,69 @@ static std::vector<TaskSample> tasksBefore;
static uint32_t tasksTotal = 0, tasksDueMs = 0; static uint32_t tasksTotal = 0, tasksDueMs = 0;
static bool tasksPending = false; 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) static uint32_t loopPasses = 0; // counted in loop(), for `tasks` (issue #40)
#ifdef RORO_DEBUG #ifdef RORO_DEBUG
static bool loopSpin = false; // `loop spin on`: no rest between passes, to compare load and radio noise 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" "reboot restart\n"
"boot other restart into the other app slot (manual Rollback)\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" "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 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 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" "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" "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" "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" "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" "sd card | sd list | cat <path> | log <text> | burst | sound on|off | short | normal\n"
#ifdef RORO_DEBUG #ifdef RORO_DEBUG
"crash abort|wdt crash on purpose (to test crash reports and Safe Mode)\n" "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" "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 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" "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" "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" "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" "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))); esp_log_level_set("*", static_cast<esp_log_level_t>(constrain(level, 0, 5)));
console.printf("log level: %d\n", 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"); 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() : "/"; std::string path = line.length() > 3 ? line.substring(3).c_str() : "/";
storageService->runJob([list, path]() { // card access stays on the storage task storageService->runJob([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");
File dir = SD.open(path.c_str()); 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()); 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()) 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()); console.printf("ls: end of %s\n", path.c_str());
}); });
} }
if (line.startsWith("install ")) update->installFromSd(line.substring(8).c_str()); // Update from SD 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 probe" && radioService) radioService->probe(console);
if (line == "lora status" && radioService) radioService->printStatus(console); if (line == "lora status" && radioService) radioService->printStatus(console);
if ((line == "lora rx on" || line == "lora rx off") && radioService) radioService->setEcho(line.endsWith("on")); 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); if (noiseTest) noiseTest->step(now);
#endif #endif
noteStableOnce(now); noteStableOnce(now);
fileOpsStep();
uploadStep(); uploadStep();
printListingWhenReady(); printListingWhenReady();
M5Cardputer.update(); M5Cardputer.update();
+11 -1
View File
@@ -1,6 +1,7 @@
#pragma once #pragma once
#include <Arduino.h> #include <Arduino.h>
#include <sys/time.h>
#include "clock_model.h" #include "clock_model.h"
#include "event_bus.h" #include "event_bus.h"
@@ -20,7 +21,16 @@ class ClockService : public Service {
const char* name() const override { return "clock"; } const char* name() const override { return "clock"; }
void start() override { applyTimezone(); } 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_; } const ClockModel& model() const { return model_; }
// UTC seconds, or -1 if the clock isn't set. // UTC seconds, or -1 if the clock isn't set.
+492
View File
@@ -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
+102
View File
@@ -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
+1
View File
@@ -53,6 +53,7 @@ class GnssService : public Service {
std::string startTrack(uint32_t nowMs); std::string startTrack(uint32_t nowMs);
void stopTrack(); void stopTrack();
bool tracking() const { return !trackPath_.empty(); } bool tracking() const { return !trackPath_.empty(); }
const std::string& trackPath() const { return trackPath_; }
int trackPoints() const { return trackPoints_; } int trackPoints() const { return trackPoints_; }
uint32_t trackStartMs() const { return trackStartMs_; } uint32_t trackStartMs() const { return trackStartMs_; }
+8 -2
View File
@@ -87,7 +87,10 @@ void WifiService::applyServers(Why why) {
touched = true; touched = true;
} }
} }
if (!esp_sntp_enabled()) { // Our own flag, not esp_sntp_enabled(): starting is only queued for the network task, and a
// second call that looked before it ran would start SNTP twice, which ESP-IDF asserts on.
if (!sntpStarted_) {
sntpStarted_ = true;
esp_sntp_setoperatingmode(ESP_SNTP_OPMODE_POLL); esp_sntp_setoperatingmode(ESP_SNTP_OPMODE_POLL);
esp_sntp_init(); esp_sntp_init();
} else if (touched || changed) { } else if (touched || changed) {
@@ -249,7 +252,10 @@ void WifiService::tick(uint32_t nowMs) {
apply(controller_.update(nowMs, settings_.getBool(Setting::WifiEnabled) && !paused_)); apply(controller_.update(nowMs, settings_.getBool(Setting::WifiEnabled) && !paused_));
if (controller_.state() == State::Connected && nowMs - serversCheckedMs_ >= kServersEveryMs) applyServers(Why::Check); // Signed: serversCheckedMs_ can be newer than this pass's nowMs (set from millis() just above),
// and the unsigned difference then ran the check again at once, at every join.
if (controller_.state() == State::Connected && static_cast<int32_t>(nowMs - serversCheckedMs_) >= static_cast<int32_t>(kServersEveryMs))
applyServers(Why::Check);
if (ntpWaiting_ && sntp_get_sync_status() == SNTP_SYNC_STATUS_COMPLETED) { if (ntpWaiting_ && sntp_get_sync_status() == SNTP_SYNC_STATUS_COMPLETED) {
ntpWaiting_ = false; ntpWaiting_ = false;
+1
View File
@@ -86,6 +86,7 @@ class WifiService : public Service {
std::vector<ScanEntry> listScan_; std::vector<ScanEntry> listScan_;
uint32_t listScanSeq_ = 0; uint32_t listScanSeq_ = 0;
bool ntpWaiting_ = false; bool ntpWaiting_ = false;
bool sntpStarted_ = false;
bool radioInitialised_ = false; bool radioInitialised_ = false;
bool paused_ = false; // Debug Builds: off for a moment, whatever the setting says bool paused_ = false; // Debug Builds: off for a moment, whatever the setting says
bool fixed_ = false; // the network in use has a Fixed address bool fixed_ = false; // the network in use has a Fixed address
+117
View File
@@ -0,0 +1,117 @@
#include <unity.h>
#include <cstdlib>
#include <ctime>
#include <string>
#include "file_list.h"
using namespace roro::files;
void setUp() {}
void tearDown() {}
void test_entries() {
FileList list;
TEST_ASSERT_EQUAL_size_t(0, list.count());
list.add("b.txt", 1200, 1791230400, false);
list.add("saved", 0, 1791230000, true);
TEST_ASSERT_EQUAL_size_t(2, list.count());
list.sort(FileSort::Name);
TEST_ASSERT_EQUAL_STRING("saved", list.name(0)); // folders first
TEST_ASSERT_TRUE(list.folder(0));
TEST_ASSERT_EQUAL_STRING("b.txt", list.name(1));
TEST_ASSERT_EQUAL_UINT32(1200, list.size(1));
TEST_ASSERT_EQUAL_UINT32(1791230400u, list.modified(1));
}
// Q129: folders first, then by name whatever the case; `s` cycles name, date, size.
void test_sorting() {
FileList list;
list.add("zeta.log", 10, 300, false);
list.add("Alpha.log", 3000, 100, false);
list.add("beta.log", 200, 200, false);
list.add("tracks", 0, 50, true);
list.add("Captures", 0, 60, true);
list.sort(FileSort::Name);
const char* byName[] = {"Captures", "tracks", "Alpha.log", "beta.log", "zeta.log"};
for (int i = 0; i < 5; i++) TEST_ASSERT_EQUAL_STRING(byName[i], list.name(i));
list.sort(FileSort::Date); // newest first, folders still first
const char* byDate[] = {"Captures", "tracks", "zeta.log", "beta.log", "Alpha.log"};
for (int i = 0; i < 5; i++) TEST_ASSERT_EQUAL_STRING(byDate[i], list.name(i));
list.sort(FileSort::Size); // biggest first
const char* bySize[] = {"Captures", "tracks", "Alpha.log", "beta.log", "zeta.log"};
for (int i = 0; i < 5; i++) TEST_ASSERT_EQUAL_STRING(bySize[i], list.name(i));
}
// Q136: 256 entries at most. A bigger folder keeps the first 256 by name, whatever order the
// card lists them in, and says how many more there are.
void test_more_than_fits() {
FileList list;
char name[24];
for (int i = 399; i >= 0; i--) { // the card's order: backwards
std::snprintf(name, sizeof name, "2026-%03d.log", i);
list.add(name, static_cast<uint32_t>(i), 1000 + i, false);
}
TEST_ASSERT_EQUAL_size_t(256, list.count());
TEST_ASSERT_EQUAL_size_t(144, list.more());
list.sort(FileSort::Name);
TEST_ASSERT_EQUAL_STRING("2026-000.log", list.name(0));
TEST_ASSERT_EQUAL_STRING("2026-255.log", list.name(255));
TEST_ASSERT_EQUAL_UINT32(255, list.size(255));
// And it stays small: 400 names went through, the memory is for 256.
TEST_ASSERT_TRUE(list.bytes() < 12 * 1024);
}
void test_folders_are_kept_before_files_when_full() {
FileList list;
char name[24];
for (int i = 0; i < 300; i++) {
std::snprintf(name, sizeof name, "a%03d.log", i);
list.add(name, 1, 1, false);
}
list.add("zz-folder", 0, 1, true); // sorts first, so it's among the 256
list.sort(FileSort::Name);
TEST_ASSERT_EQUAL_STRING("zz-folder", list.name(0));
TEST_ASSERT_EQUAL_size_t(45, list.more());
}
void test_find_and_clear() {
FileList list;
list.add("a.txt", 1, 1, false);
list.add("b.txt", 2, 2, false);
list.sort(FileSort::Name);
TEST_ASSERT_EQUAL_INT(1, list.find("b.txt"));
TEST_ASSERT_EQUAL_INT(-1, list.find("c.txt"));
list.clear();
TEST_ASSERT_EQUAL_size_t(0, list.count());
TEST_ASSERT_EQUAL_size_t(0, list.more());
}
// Q129, Q137: what a row shows on the right. A date before 2020 was never set.
void test_display() {
setenv("TZ", "UTC", 1);
tzset();
TEST_ASSERT_EQUAL_STRING("folder", rowDetail(true, 0, 0).c_str());
TEST_ASSERT_EQUAL_STRING("1.2 KB 2026-10-05", rowDetail(false, 1200, 1791230400).c_str());
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() {
UNITY_BEGIN();
RUN_TEST(test_entries);
RUN_TEST(test_sorting);
RUN_TEST(test_more_than_fits);
RUN_TEST(test_folders_are_kept_before_files_when_full);
RUN_TEST(test_find_and_clear);
RUN_TEST(test_display);
return UNITY_END();
}
+118
View File
@@ -0,0 +1,118 @@
#include <unity.h>
#include <string>
#include <vector>
#include "file_names.h"
using namespace roro::files;
void setUp() {}
void tearDown() {}
void test_path_parts() {
TEST_ASSERT_EQUAL_STRING("/gemini/saved", parentOf("/gemini/saved/index.gmi").c_str());
TEST_ASSERT_EQUAL_STRING("/", parentOf("/gemini").c_str());
TEST_ASSERT_EQUAL_STRING("/", parentOf("/").c_str());
TEST_ASSERT_EQUAL_STRING("index.gmi", baseName("/gemini/saved/index.gmi").c_str());
TEST_ASSERT_EQUAL_STRING("", baseName("/").c_str());
TEST_ASSERT_EQUAL_STRING("/gemini/a.gmi", joinPath("/gemini", "a.gmi").c_str());
TEST_ASSERT_EQUAL_STRING("/a.gmi", joinPath("/", "a.gmi").c_str());
TEST_ASSERT_EQUAL_STRING("gmi", extensionOf("Index.GMI").c_str());
TEST_ASSERT_EQUAL_STRING("pcap", extensionOf("20261005-183340.pcap").c_str());
TEST_ASSERT_EQUAL_STRING("", extensionOf("README").c_str());
TEST_ASSERT_EQUAL_STRING("", extensionOf(".hidden").c_str());
}
void test_inside() {
TEST_ASSERT_TRUE(isInside("/gemini/cache/page0.gmi", "/gemini/cache"));
TEST_ASSERT_TRUE(isInside("/gemini/cache", "/gemini/cache"));
TEST_ASSERT_FALSE(isInside("/gemini/cached", "/gemini/cache")); // a longer name isn't inside
TEST_ASSERT_TRUE(isInside("/irc/x.log", "/"));
}
// Q131: names typed for rename and new folder.
void test_names_checked() {
TEST_ASSERT_EQUAL_STRING("", checkName("notes 2026.txt").c_str());
TEST_ASSERT_EQUAL_STRING("", checkName("#roro").c_str());
TEST_ASSERT_EQUAL_STRING("A name can't be empty", checkName("").c_str());
TEST_ASSERT_EQUAL_STRING("A name can't contain /", checkName("a/b").c_str());
TEST_ASSERT_EQUAL_STRING("A name can't contain :", checkName("12:30.txt").c_str());
TEST_ASSERT_EQUAL_STRING("A name can't contain ?", checkName("what?").c_str());
TEST_ASSERT_EQUAL_STRING("\".\" and \"..\" aren't names", checkName("..").c_str());
TEST_ASSERT_EQUAL_STRING("A name can't end with a dot or a space", checkName("notes.").c_str());
TEST_ASSERT_EQUAL_STRING("A name can't end with a dot or a space", checkName("notes ").c_str());
TEST_ASSERT_EQUAL_STRING("A name can be 64 characters at most", checkName(std::string(65, 'a')).c_str());
TEST_ASSERT_EQUAL_STRING("", checkName(std::string(64, 'a')).c_str());
}
// Q130: what can't be renamed, moved or deleted, and why.
void test_read_only() {
std::vector<std::string> inUse = {"/irc/irc.libera.chat/#roro/2026-10-06.log", "/gnss/tracks/20261006-071500.gpx"};
TEST_ASSERT_EQUAL_STRING("", whyReadOnly("/gemini/saved/a.gmi", inUse).c_str());
TEST_ASSERT_EQUAL_STRING("", whyReadOnly("/irc/irc.libera.chat/#roro/2026-10-05.log", inUse).c_str()); // yesterday's
TEST_ASSERT_EQUAL_STRING("", whyReadOnly("/updates/roro9stack-v0.8.1.ota", inUse).c_str());
TEST_ASSERT_EQUAL_STRING("", whyReadOnly("/gemini/saved", inUse).c_str()); // a folder below the top level
TEST_ASSERT_EQUAL_STRING("It's being written right now", whyReadOnly("/gnss/tracks/20261006-071500.gpx", inUse).c_str());
TEST_ASSERT_EQUAL_STRING("It holds a file that's being written right now", whyReadOnly("/gnss/tracks", inUse).c_str());
TEST_ASSERT_EQUAL_STRING("It holds a file that's being written right now", whyReadOnly("/irc/irc.libera.chat", inUse).c_str());
TEST_ASSERT_EQUAL_STRING("/gemini/cache is the Gemini App's working space", whyReadOnly("/gemini/cache/page0.gmi", inUse).c_str());
TEST_ASSERT_EQUAL_STRING("/gemini/cache is the Gemini App's working space", whyReadOnly("/gemini/cache", inUse).c_str());
TEST_ASSERT_EQUAL_STRING("The firmware keeps its files in /irc", whyReadOnly("/irc", {}).c_str());
TEST_ASSERT_EQUAL_STRING("The firmware keeps its files in /captures", whyReadOnly("/captures", {}).c_str());
TEST_ASSERT_EQUAL_STRING("That's the card itself", whyReadOnly("/", {}).c_str());
TEST_ASSERT_EQUAL_STRING("", whyReadOnly("/my stuff", {}).c_str()); // a top-level folder of the user's own
}
// Where something may be put (paste, new folder): not into the cache, not into itself.
void test_destinations() {
TEST_ASSERT_EQUAL_STRING("", whyNotInto("/gemini/saved/a.gmi", "/notes").c_str());
TEST_ASSERT_EQUAL_STRING("", whyNotInto("/gemini/saved/a.gmi", "/").c_str());
TEST_ASSERT_EQUAL_STRING("/gemini/cache is the Gemini App's working space", whyNotInto("/notes/a.txt", "/gemini/cache").c_str());
TEST_ASSERT_EQUAL_STRING("A folder can't go inside itself", whyNotInto("/gemini/saved", "/gemini/saved/sub").c_str());
TEST_ASSERT_EQUAL_STRING("A folder can't go inside itself", whyNotInto("/gemini/saved", "/gemini/saved").c_str());
TEST_ASSERT_EQUAL_STRING("It's already there", whyNotInto("/gemini/saved/a.gmi", "/gemini/saved").c_str());
}
// A copy next to the original needs another name: "a (2).gmi".
void test_copy_names() {
TEST_ASSERT_EQUAL_STRING("a (2).gmi", copyName("a.gmi", 2).c_str());
TEST_ASSERT_EQUAL_STRING("README (3)", copyName("README", 3).c_str());
TEST_ASSERT_EQUAL_STRING("archive.tar (2).gz", copyName("archive.tar.gz", 2).c_str());
}
// Q134: which viewer opens a file.
void test_kinds() {
TEST_ASSERT_TRUE(kindOf("2026-10-06.log") == FileKind::Text);
TEST_ASSERT_TRUE(kindOf("index.gmi") == FileKind::Text);
TEST_ASSERT_TRUE(kindOf("2026-10-05.CSV") == FileKind::Text);
TEST_ASSERT_TRUE(kindOf("note.txt") == FileKind::Text);
TEST_ASSERT_TRUE(kindOf("20261006-071500.gpx") == FileKind::Gpx);
TEST_ASSERT_TRUE(kindOf("20261005-183340.pcap") == FileKind::Pcap);
TEST_ASSERT_TRUE(kindOf("roro9stack-v0.8.1.ota") == FileKind::Ota);
TEST_ASSERT_TRUE(kindOf("firmware.bin") == FileKind::Unknown); // sniffed: text or a hex dump
TEST_ASSERT_TRUE(opensAtEnd("2026-10-06.log"));
TEST_ASSERT_FALSE(opensAtEnd("index.gmi"));
}
void test_looks_like_text() {
const char text[] = "line one\nline two with an \xC3\xA9\r\n\ttabbed";
TEST_ASSERT_TRUE(looksLikeText(reinterpret_cast<const uint8_t*>(text), sizeof text - 1));
const uint8_t binary[] = {0xE9, 0x05, 0x02, 0x00, 0x10, 0x00, 0x00, 0x40};
TEST_ASSERT_FALSE(looksLikeText(binary, sizeof binary));
TEST_ASSERT_TRUE(looksLikeText(nullptr, 0)); // an empty file reads as text
}
int main() {
UNITY_BEGIN();
RUN_TEST(test_path_parts);
RUN_TEST(test_inside);
RUN_TEST(test_names_checked);
RUN_TEST(test_read_only);
RUN_TEST(test_destinations);
RUN_TEST(test_copy_names);
RUN_TEST(test_kinds);
RUN_TEST(test_looks_like_text);
return UNITY_END();
}
+133
View File
@@ -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();
}
+156
View File
@@ -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();
}