diff --git a/CONTEXT.md b/CONTEXT.md index dfd6ef1..45a41c6 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -123,10 +123,18 @@ Data the user explicitly starts recording, such as Wi-Fi packet captures and LoR _Avoid_: dump, log **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**: -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**: Installing a new firmware image without a USB cable: pushed over Wi-Fi from the developer's PC, or read from the SD card. diff --git a/README.md b/README.md index c9ae822..fa7cae7 100644 --- a/README.md +++ b/README.md @@ -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. +## 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 `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 ` | Appends a line to a test IRC Log (`/irc/dev/#test/.log`) | | `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 fill ` | Debug Builds: makes that many small files in a folder, to test a crowded one | | `cat ` | 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 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 | | `reboot` / `boot other` | Restart, or restart into the other app slot (a manual Rollback) | | `log level <0-5>` | ESP-IDF log level | -| `ls [folder]` / `rm ` | Lists a folder of the SD card, or deletes a file | +| `ls [folder]` / `du ` | Lists a folder of the SD card with sizes and dates, or counts the files and bytes under a path | +| `cp [-f] ` / `mv [-f] ` / `rm ` / `mkdir ` / `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 ` | 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 status` | Radio settings, who's listening, packet and error counters, noise floor, task stack | diff --git a/docs/milestones/F1.md b/docs/milestones/F1.md new file mode 100644 index 0000000..638ee5e --- /dev/null +++ b/docs/milestones/F1.md @@ -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 ` 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. diff --git a/lib/apps_model/src/settings_menu.cpp b/lib/apps_model/src/settings_menu.cpp index 176246e..b1fab38 100644 --- a/lib/apps_model/src/settings_menu.cpp +++ b/lib/apps_model/src/settings_menu.cpp @@ -23,7 +23,6 @@ const RowDef kRows[] = { {Row::Gnss, Kind::Toggle, "GNSS"}, {Row::GnssQuiet, Kind::Toggle, "Pause GNSS for LoRa"}, {Row::Coordinates, Kind::Toggle, "Coordinates"}, {Row::ProbeMacs, Kind::Toggle, "Probe MACs"}, {Row::Wifi, Kind::Page, "Wi-Fi"}, - {Row::Storage, Kind::Page, "Storage"}, {Row::Firmware, Kind::Page, "Firmware"}, {Row::About, Kind::Page, "About"}, }; diff --git a/lib/apps_model/src/settings_menu.h b/lib/apps_model/src/settings_menu.h index 176cea9..09eb0a4 100644 --- a/lib/apps_model/src/settings_menu.h +++ b/lib/apps_model/src/settings_menu.h @@ -11,7 +11,7 @@ namespace roro { // values, choice lists and validation messages. Rendering and navigation live in the App. class SettingsMenu { 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 }; explicit SettingsMenu(Settings& settings) : settings_(settings) {} diff --git a/lib/files/src/file_list.cpp b/lib/files/src/file_list.cpp new file mode 100644 index 0000000..a5b8f29 --- /dev/null +++ b/lib/files/src/file_list.cpp @@ -0,0 +1,137 @@ +#include "file_list.h" + +#include +#include +#include +#include +#include + +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(*x)), cy = std::tolower(static_cast(*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(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(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(bytes / 1048576)); + else std::snprintf(s, sizeof s, "%.1f GB", bytes / 1073741824.0); + return s; +} + +} // namespace + +void FileList::clear() { + std::vector().swap(entries_); + std::vector().swap(order_); + std::vector().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(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(names_.size()), size, modified, folder}); + names_.insert(names_.end(), name, name + len); + order_.push_back(static_cast(order_.size())); +} + +void FileList::compact() { + std::vector packed; + packed.reserve(names_.size() - wasted_); + for (Entry& e : entries_) { + const char* n = names_.data() + e.name; + e.name = static_cast(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(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(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(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(6, maxChars / 3), head = maxChars - tail - 2; + return name.substr(0, head) + ".." + name.substr(name.size() - tail); +} + +} // namespace roro::files diff --git a/lib/files/src/file_list.h b/lib/files/src/file_list.h new file mode 100644 index 0000000..cd7eb69 --- /dev/null +++ b/lib/files/src/file_list.h @@ -0,0 +1,55 @@ +#pragma once + +#include +#include +#include +#include + +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 entries_; + std::vector order_; + std::vector 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 diff --git a/lib/files/src/file_names.cpp b/lib/files/src/file_names.cpp new file mode 100644 index 0000000..2a1ad4a --- /dev/null +++ b/lib/files/src/file_names.cpp @@ -0,0 +1,99 @@ +#include "file_names.h" + +#include + +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(std::tolower(static_cast(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(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& 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 diff --git a/lib/files/src/file_names.h b/lib/files/src/file_names.h new file mode 100644 index 0000000..2181163 --- /dev/null +++ b/lib/files/src/file_names.h @@ -0,0 +1,42 @@ +#pragma once + +#include +#include +#include +#include + +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& 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 diff --git a/lib/files/src/file_views.cpp b/lib/files/src/file_views.cpp new file mode 100644 index 0000000..fdaa120 --- /dev/null +++ b/lib/files/src/file_views.cpp @@ -0,0 +1,143 @@ +#include "file_views.h" + +#include +#include +#include + +#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(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(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(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("