Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50fcf6b7a3 | ||
|
|
80d68ccfd7 | ||
|
|
e14eb304b5 | ||
|
|
b7aed8e91c | ||
|
|
7ab8f043d0 | ||
|
|
63bae576ef |
+10
-2
@@ -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.
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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"},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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) {}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,458 @@
|
|||||||
|
#include "file_viewer.h"
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include <SD.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <atomic>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <ctime>
|
||||||
|
|
||||||
|
#include "cleanup_plan.h"
|
||||||
|
#include "file_list.h"
|
||||||
|
#include "file_names.h"
|
||||||
|
#include "file_views.h"
|
||||||
|
#include "meshtastic_presets.h"
|
||||||
|
#include "packet_view.h"
|
||||||
|
#include "platform/ota_device.h"
|
||||||
|
#include "ui/fonts.h"
|
||||||
|
#include "ui/widgets.h"
|
||||||
|
#include "update_parser.h"
|
||||||
|
#include "version.h"
|
||||||
|
|
||||||
|
namespace roro {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
constexpr uint32_t kSliceMs = 150; // like the Storage App's operations: the Logs get their turn
|
||||||
|
constexpr size_t kPiece = 1024;
|
||||||
|
constexpr size_t kMaxPackets = 1000; // their places in the file, 4 KB
|
||||||
|
constexpr size_t kMaxPacketBytes = 271; // LoRaTap and the longest LoRa packet
|
||||||
|
|
||||||
|
// Checks an Update File as an install would, writing nothing.
|
||||||
|
struct NoSink : UpdateSink {
|
||||||
|
bool begin(size_t) override { return true; }
|
||||||
|
bool write(const uint8_t*, size_t) override { return true; }
|
||||||
|
bool finish() override { return true; }
|
||||||
|
void abort() override {}
|
||||||
|
};
|
||||||
|
|
||||||
|
std::string clockTime(uint32_t utcSeconds) {
|
||||||
|
time_t t = static_cast<time_t>(utcSeconds);
|
||||||
|
struct tm local;
|
||||||
|
localtime_r(&t, &local);
|
||||||
|
char s[12];
|
||||||
|
std::snprintf(s, sizeof s, "%02d:%02d:%02d", local.tm_hour, local.tm_min, local.tm_sec);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
struct FileViewer::Scan {
|
||||||
|
std::atomic<bool> done{false}, stop{false};
|
||||||
|
std::atomic<int> percent{0};
|
||||||
|
Mode what = Mode::Gpx;
|
||||||
|
std::string path;
|
||||||
|
// Touched by the storage task until `done`, by the viewer after.
|
||||||
|
fs::File file;
|
||||||
|
bool opened = false;
|
||||||
|
uint32_t at = 0, size = 0;
|
||||||
|
std::string error;
|
||||||
|
files::GpxSummary gpx;
|
||||||
|
files::PcapHeader pcap;
|
||||||
|
std::vector<uint32_t> packets; // where each record starts
|
||||||
|
bool morePackets = false;
|
||||||
|
std::unique_ptr<EcdsaVerifier> verifier;
|
||||||
|
NoSink sink;
|
||||||
|
std::unique_ptr<UpdateParser> parser;
|
||||||
|
std::string version;
|
||||||
|
bool genuine = false, older = false;
|
||||||
|
|
||||||
|
bool slice(); // true when there's nothing more to read
|
||||||
|
static void run(StorageService* storage, std::shared_ptr<Scan> self) {
|
||||||
|
bool over = self->stop || self->slice();
|
||||||
|
if (over) {
|
||||||
|
if (self->file) self->file.close();
|
||||||
|
self->parser.reset();
|
||||||
|
self->verifier.reset();
|
||||||
|
self->done = true;
|
||||||
|
} else {
|
||||||
|
storage->runJob([storage, self]() { run(storage, self); });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
bool FileViewer::Scan::slice() {
|
||||||
|
uint32_t t0 = millis();
|
||||||
|
if (!opened) {
|
||||||
|
opened = true;
|
||||||
|
file = SD.open(path.c_str(), FILE_READ);
|
||||||
|
if (!file) {
|
||||||
|
error = "The card refused to open it";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
size = static_cast<uint32_t>(file.size());
|
||||||
|
if (what == Mode::Ota) {
|
||||||
|
verifier.reset(new EcdsaVerifier());
|
||||||
|
parser.reset(new UpdateParser(*verifier, sink, EspOtaSink().capacity(), versionString()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::unique_ptr<uint8_t[]> piece(new uint8_t[kPiece]);
|
||||||
|
while (millis() - t0 < kSliceMs) {
|
||||||
|
if (stop) return true;
|
||||||
|
if (what == Mode::Pcap) {
|
||||||
|
if (at == 0) {
|
||||||
|
int n = file.read(piece.get(), files::kPcapHeaderSize);
|
||||||
|
pcap = files::parsePcapHeader(piece.get(), n < 0 ? 0 : n);
|
||||||
|
if (!pcap.ok) {
|
||||||
|
error = "Not a pcap file this viewer reads";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
at = files::kPcapHeaderSize;
|
||||||
|
}
|
||||||
|
files::PcapRecord r;
|
||||||
|
if (!file.seek(at)) return true;
|
||||||
|
int n = file.read(piece.get(), files::kPcapRecordSize);
|
||||||
|
if (n < 0 || !files::parsePcapRecord(piece.get(), n, r) || at + files::kPcapRecordSize + r.length > size) return true;
|
||||||
|
if (packets.size() >= kMaxPackets) {
|
||||||
|
morePackets = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
packets.push_back(at);
|
||||||
|
at += files::kPcapRecordSize + r.length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
int n = file.read(piece.get(), kPiece);
|
||||||
|
if (n <= 0) break;
|
||||||
|
at += n;
|
||||||
|
percent = size ? static_cast<int>(static_cast<uint64_t>(at) * 100 / size) : 100;
|
||||||
|
if (what == Mode::Gpx) {
|
||||||
|
gpx.feed(reinterpret_cast<const char*>(piece.get()), n);
|
||||||
|
} else {
|
||||||
|
parser->feed(piece.get(), n);
|
||||||
|
if (parser->state() == UpdateParser::State::Failed) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (at < size && !(parser && parser->state() == UpdateParser::State::Failed) && millis() - t0 >= kSliceMs) return false;
|
||||||
|
if (parser) {
|
||||||
|
genuine = parser->end();
|
||||||
|
version = parser->version();
|
||||||
|
older = parser->isDowngrade();
|
||||||
|
error = parser->error();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t FileViewer::readAt(uint32_t offset, uint8_t* into, size_t len) {
|
||||||
|
size_t got = 0;
|
||||||
|
auto file = file_;
|
||||||
|
std::string path = path_;
|
||||||
|
storage_.runAndWait([&, file, path]() {
|
||||||
|
if (!*file) *file = SD.open(path.c_str(), FILE_READ);
|
||||||
|
if (!*file || !file->seek(offset)) return;
|
||||||
|
int n = file->read(into, len);
|
||||||
|
got = n < 0 ? 0 : n;
|
||||||
|
});
|
||||||
|
return got;
|
||||||
|
}
|
||||||
|
|
||||||
|
void FileViewer::open(const std::string& path, uint32_t size) {
|
||||||
|
close();
|
||||||
|
path_ = path;
|
||||||
|
size_ = size;
|
||||||
|
file_ = std::make_shared<fs::File>();
|
||||||
|
std::string name = files::baseName(path);
|
||||||
|
files::FileKind kind = files::kindOf(name);
|
||||||
|
if (kind == files::FileKind::Unknown) { // what do its first bytes look like?
|
||||||
|
uint8_t head[256];
|
||||||
|
size_t n = readAt(0, head, sizeof head);
|
||||||
|
kind = files::looksLikeText(head, n) ? files::FileKind::Text : files::FileKind::Unknown;
|
||||||
|
}
|
||||||
|
switch (kind) {
|
||||||
|
case files::FileKind::Text: base_ = Mode::Text; break;
|
||||||
|
case files::FileKind::Gpx: base_ = Mode::Gpx; break;
|
||||||
|
case files::FileKind::Pcap: base_ = Mode::Pcap; break;
|
||||||
|
case files::FileKind::Ota: base_ = Mode::Ota; break;
|
||||||
|
default: base_ = Mode::Hex; break;
|
||||||
|
}
|
||||||
|
pager_.reset(new files::TextPager([this](uint32_t offset, uint8_t* into, size_t len) { return readAt(offset, into, len); }, size_,
|
||||||
|
kCols, kRows));
|
||||||
|
if (files::opensAtEnd(name)) pager_->toEnd();
|
||||||
|
hexTop_ = 0;
|
||||||
|
show(base_);
|
||||||
|
if (base_ == Mode::Gpx || base_ == Mode::Pcap || base_ == Mode::Ota) startScan(base_);
|
||||||
|
}
|
||||||
|
|
||||||
|
void FileViewer::startScan(Mode mode) {
|
||||||
|
scan_ = std::make_shared<Scan>();
|
||||||
|
scan_->what = mode;
|
||||||
|
scan_->path = path_;
|
||||||
|
scanShown_ = false;
|
||||||
|
shownPercent_ = -1;
|
||||||
|
auto scan = scan_;
|
||||||
|
StorageService* storage = &storage_;
|
||||||
|
storage_.runJob([storage, scan]() { Scan::run(storage, scan); });
|
||||||
|
}
|
||||||
|
|
||||||
|
void FileViewer::close() {
|
||||||
|
if (scan_) scan_->stop = true; // its job ends at the next piece and frees itself
|
||||||
|
scan_.reset();
|
||||||
|
if (file_) {
|
||||||
|
auto file = file_;
|
||||||
|
storage_.runJob([file]() {
|
||||||
|
if (*file) file->close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
file_.reset();
|
||||||
|
pager_.reset();
|
||||||
|
confirm_.reset();
|
||||||
|
std::vector<std::string>().swap(shown_);
|
||||||
|
std::vector<std::string>().swap(details_);
|
||||||
|
packets_.setCount(0);
|
||||||
|
rowsFrom_ = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
void FileViewer::show(Mode mode) {
|
||||||
|
mode_ = mode;
|
||||||
|
stale_ = true;
|
||||||
|
rowsFrom_ = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string FileViewer::title() const { return files::fitName(files::baseName(path_), 24); }
|
||||||
|
|
||||||
|
void FileViewer::scroll(int lines) {
|
||||||
|
if (mode_ == Mode::Text && pager_) {
|
||||||
|
if (lines > 0) pager_->down(lines);
|
||||||
|
else pager_->up(-lines);
|
||||||
|
} else if (mode_ == Mode::Hex) {
|
||||||
|
uint32_t rows = (size_ + 7) / 8, last = rows > static_cast<uint32_t>(kRows) ? rows - kRows : 0;
|
||||||
|
int64_t to = static_cast<int64_t>(hexTop_) + lines;
|
||||||
|
hexTop_ = static_cast<uint32_t>(std::clamp<int64_t>(to, 0, last));
|
||||||
|
}
|
||||||
|
stale_ = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void FileViewer::openPacket(int index) {
|
||||||
|
if (!scan_ || !scan_->done || index < 0 || index >= static_cast<int>(scan_->packets.size())) return;
|
||||||
|
uint8_t buf[files::kPcapRecordSize + kMaxPacketBytes];
|
||||||
|
size_t n = readAt(scan_->packets[index], buf, sizeof buf);
|
||||||
|
files::PcapRecord r;
|
||||||
|
if (!files::parsePcapRecord(buf, n, r)) return;
|
||||||
|
const uint8_t* body = buf + files::kPcapRecordSize;
|
||||||
|
size_t have = std::min<size_t>(r.length, n - files::kPcapRecordSize);
|
||||||
|
details_.clear();
|
||||||
|
char line[64];
|
||||||
|
lora::RxInfo rx;
|
||||||
|
bool tap = scan_->pcap.linkType == files::kLinkLoraTap && files::parseLoraTap(body, have, rx);
|
||||||
|
size_t skip = tap ? lora::kLoraTapSize : 0;
|
||||||
|
std::snprintf(line, sizeof line, "%s, %u bytes", clockTime(r.seconds).c_str(), static_cast<unsigned>(r.length - skip));
|
||||||
|
details_.push_back(line);
|
||||||
|
if (tap) {
|
||||||
|
std::snprintf(line, sizeof line, "%.0f dBm, SNR %.1f dB, noise %.0f", rx.rssi, rx.snr, rx.noiseFloor);
|
||||||
|
details_.push_back(line);
|
||||||
|
std::snprintf(line, sizeof line, "%.4f MHz, SF%u, %.0f kHz", rx.frequencyHz / 1e6, rx.spreadingFactor, rx.bandwidthKHz);
|
||||||
|
details_.push_back(line);
|
||||||
|
if (rx.syncWord == meshtastic::kSyncWord)
|
||||||
|
for (auto& l : lora::headerLines(body + skip, have - skip)) details_.push_back(l);
|
||||||
|
}
|
||||||
|
for (auto& l : lora::hexDump(body + skip, have - skip)) details_.push_back(l);
|
||||||
|
if (have < r.length) details_.push_back("(the first bytes only)");
|
||||||
|
detailsTop_ = 0;
|
||||||
|
mode_ = Mode::Packet;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool FileViewer::onKey(const KeyEvent& e) {
|
||||||
|
if (confirm_) {
|
||||||
|
confirm_->onKey(e);
|
||||||
|
if (confirm_->result() == 1) update_.installFromSd(path_);
|
||||||
|
if (confirm_->result() != DialogModel::kPending) confirm_.reset();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (mode_ == Mode::Packet) {
|
||||||
|
int last = std::max(0, static_cast<int>(details_.size()) - (kRows + 1));
|
||||||
|
if (e.key == Key::Up) detailsTop_ = std::max(0, detailsTop_ - 1);
|
||||||
|
else if (e.key == Key::Down) detailsTop_ = std::min(last, detailsTop_ + 1);
|
||||||
|
else if (e.key == Key::Back || e.key == Key::Select) mode_ = Mode::Pcap;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (e.key == Key::Back) return false;
|
||||||
|
if (e.key == Key::Tab) {
|
||||||
|
// The file's own view, or what it's made of: text for a Track, bytes for the others.
|
||||||
|
if (mode_ != base_) show(base_);
|
||||||
|
else show(base_ == Mode::Hex || base_ == Mode::Gpx ? Mode::Text : Mode::Hex);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
uint32_t ch = e.key == Key::Char ? e.ch : 0;
|
||||||
|
switch (mode_) {
|
||||||
|
case Mode::Text:
|
||||||
|
case Mode::Hex:
|
||||||
|
if (e.key == Key::Up) scroll(-1);
|
||||||
|
else if (e.key == Key::Down) scroll(1);
|
||||||
|
else if (e.key == Key::Left) scroll(-(kRows - 1));
|
||||||
|
else if (e.key == Key::Right) scroll(kRows - 1);
|
||||||
|
else if (ch == 't' || ch == 'T') {
|
||||||
|
if (pager_) pager_->toStart();
|
||||||
|
hexTop_ = 0;
|
||||||
|
stale_ = true;
|
||||||
|
} else if (ch == 'b' || ch == 'B') {
|
||||||
|
if (mode_ == Mode::Text && pager_) pager_->toEnd();
|
||||||
|
else scroll(INT32_MAX / 2);
|
||||||
|
stale_ = true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case Mode::Pcap:
|
||||||
|
if (e.key == Key::Up) packets_.up();
|
||||||
|
else if (e.key == Key::Down) packets_.down();
|
||||||
|
else if (e.key == Key::Left) packets_.pageUp();
|
||||||
|
else if (e.key == Key::Right) packets_.pageDown();
|
||||||
|
else if (e.key == Key::Select) openPacket(packets_.selected());
|
||||||
|
break;
|
||||||
|
case Mode::Ota:
|
||||||
|
if (e.key == Key::Select && scan_ && scan_->done && scan_->genuine) confirm_.reset(new DialogModel({"Cancel", "Install"}));
|
||||||
|
break;
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool FileViewer::update(uint32_t) {
|
||||||
|
if (!scan_) return false;
|
||||||
|
if (scan_->done && !scanShown_) {
|
||||||
|
scanShown_ = true;
|
||||||
|
if (scan_->what == Mode::Pcap) packets_.setCount(static_cast<int>(scan_->packets.size()));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
int percent = scan_->percent;
|
||||||
|
if (!scan_->done && percent != shownPercent_) {
|
||||||
|
shownPercent_ = percent;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void FileViewer::refresh() {
|
||||||
|
if (mode_ == Mode::Text && stale_ && pager_) {
|
||||||
|
shown_ = pager_->lines();
|
||||||
|
stale_ = false;
|
||||||
|
} else if (mode_ == Mode::Hex && stale_) {
|
||||||
|
uint8_t buf[kRows * 8];
|
||||||
|
size_t n = readAt(hexTop_ * 8, buf, sizeof buf);
|
||||||
|
shown_.clear();
|
||||||
|
for (size_t at = 0; at < n; at += 8) shown_.push_back(files::hexRow(hexTop_ * 8 + at, buf + at, std::min<size_t>(8, n - at)));
|
||||||
|
stale_ = false;
|
||||||
|
} else if (mode_ == Mode::Pcap && scan_ && scan_->done && rowsFrom_ != packets_.firstVisible()) {
|
||||||
|
// The rows on screen, read from the card once each time the list moves.
|
||||||
|
rowsFrom_ = packets_.firstVisible();
|
||||||
|
shown_.clear();
|
||||||
|
bool tap = scan_->pcap.linkType == files::kLinkLoraTap;
|
||||||
|
for (int i = rowsFrom_; i < rowsFrom_ + kRows && i < static_cast<int>(scan_->packets.size()); i++) {
|
||||||
|
uint8_t buf[files::kPcapRecordSize + kMaxPacketBytes];
|
||||||
|
size_t n = readAt(scan_->packets[i], buf, sizeof buf);
|
||||||
|
files::PcapRecord r;
|
||||||
|
lora::RxInfo rx;
|
||||||
|
if (!files::parsePcapRecord(buf, n, r)) {
|
||||||
|
shown_.push_back("(unreadable)");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const uint8_t* body = buf + files::kPcapRecordSize;
|
||||||
|
size_t have = std::min<size_t>(r.length, n - files::kPcapRecordSize);
|
||||||
|
if (tap && files::parseLoraTap(body, have, rx)) {
|
||||||
|
shown_.push_back(lora::row({body + lora::kLoraTapSize, have - lora::kLoraTapSize, rx.rssi, rx.snr, true,
|
||||||
|
rx.syncWord == meshtastic::kSyncWord},
|
||||||
|
clockTime(r.seconds)));
|
||||||
|
} else {
|
||||||
|
shown_.push_back(clockTime(r.seconds) + " " + std::to_string(r.length) + " bytes");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void FileViewer::draw(Canvas& c) {
|
||||||
|
const auto& area = theme::kContent;
|
||||||
|
refresh();
|
||||||
|
c.setTextDatum(top_left);
|
||||||
|
theme::Rect body{area.x, area.y + 10, area.w, kRows * theme::kLineHeight};
|
||||||
|
std::string right, keys = "Tab: hex";
|
||||||
|
bool scanning = scan_ && !scan_->done;
|
||||||
|
|
||||||
|
switch (mode_) {
|
||||||
|
case Mode::Text:
|
||||||
|
case Mode::Hex: {
|
||||||
|
c.setFont(&fonts::body);
|
||||||
|
c.setTextColor(size_ == 0 ? theme::kMuted : theme::kText);
|
||||||
|
if (size_ == 0) c.drawString("(empty)", 4, body.y + 1);
|
||||||
|
for (size_t i = 0; i < shown_.size(); i++) c.drawString(shown_[i].c_str(), 4, body.y + 1 + static_cast<int>(i) * theme::kLineHeight);
|
||||||
|
uint32_t at = mode_ == Mode::Text && pager_ ? pager_->top() : hexTop_ * 8;
|
||||||
|
if (size_ > 0) { // where in the file the screen is
|
||||||
|
int barH = 12, barY = body.y + static_cast<int>(static_cast<uint64_t>(body.h - barH) * at / size_);
|
||||||
|
c.fillRect(area.w - 2, barY, 2, barH, theme::kMuted);
|
||||||
|
}
|
||||||
|
right = formatBytes(size_);
|
||||||
|
keys = std::string("Tab: ") + (mode_ != base_ ? "back" : mode_ == Mode::Text ? "hex" : "text") + " t: top b: end";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case Mode::Gpx: {
|
||||||
|
std::vector<std::string> lines;
|
||||||
|
if (scanning) lines.push_back("Reading the Track... " + std::to_string(scan_->percent.load()) + "%");
|
||||||
|
else if (scan_ && !scan_->error.empty()) lines.push_back(scan_->error);
|
||||||
|
else if (scan_) lines = scan_->gpx.lines();
|
||||||
|
widgets::textLines(c, lines, 0, body);
|
||||||
|
right = formatBytes(size_);
|
||||||
|
keys = "Tab: the file as text";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case Mode::Pcap: {
|
||||||
|
c.setFont(&fonts::body);
|
||||||
|
c.setTextColor(theme::kMuted);
|
||||||
|
if (scanning) c.drawString("Reading the Capture...", 4, body.y + 1);
|
||||||
|
else if (scan_ && !scan_->error.empty()) c.drawString(scan_->error.c_str(), 4, body.y + 1);
|
||||||
|
else if (scan_ && scan_->packets.empty()) c.drawString("No packets in this Capture.", 4, body.y + 1);
|
||||||
|
else if (scan_) {
|
||||||
|
int from = rowsFrom_;
|
||||||
|
widgets::list(c, packets_, body, [&](int i) {
|
||||||
|
size_t row = static_cast<size_t>(i - from);
|
||||||
|
return row < shown_.size() ? shown_[row] : std::string();
|
||||||
|
});
|
||||||
|
right = std::to_string(scan_->packets.size()) + (scan_->morePackets ? "+ packets" : " packets");
|
||||||
|
keys = "Enter: the packet Tab: hex";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case Mode::Packet: widgets::textLines(c, details_, detailsTop_, {area.x + 2, area.y + 2, area.w - 2, area.h - 2}); return;
|
||||||
|
case Mode::Ota: {
|
||||||
|
std::vector<std::string> lines;
|
||||||
|
bool ok = scan_ && scan_->done && scan_->genuine;
|
||||||
|
if (scanning) {
|
||||||
|
lines.push_back("Checking it as an install would:");
|
||||||
|
lines.push_back("signature and contents, " + std::to_string(scan_->percent.load()) + "%");
|
||||||
|
} else if (scan_) {
|
||||||
|
if (!scan_->version.empty()) lines.push_back("Version " + scan_->version);
|
||||||
|
lines.push_back(std::string("Running ") + versionString());
|
||||||
|
if (ok) {
|
||||||
|
lines.push_back("Signed with the project's key, intact.");
|
||||||
|
if (scan_->older) lines.push_back("Older than what's running.");
|
||||||
|
} else {
|
||||||
|
lines.push_back("Not installable:");
|
||||||
|
lines.push_back(scan_->error.empty() ? "it ends too early" : scan_->error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
widgets::textLines(c, lines, 0, body);
|
||||||
|
right = formatBytes(size_);
|
||||||
|
keys = ok ? "Enter: install Tab: hex" : "Tab: hex";
|
||||||
|
if (confirm_) {
|
||||||
|
widgets::dialog(c, "Install update?", "The device restarts into " + scan_->version + " once it's written.", *confirm_);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.setFont(&fonts::small);
|
||||||
|
c.setTextColor(theme::kMuted);
|
||||||
|
c.setTextDatum(top_left);
|
||||||
|
c.drawString(title().c_str(), 4, area.y + 1);
|
||||||
|
c.setTextDatum(top_right);
|
||||||
|
c.drawString(right.c_str(), area.w - 3, area.y + 1);
|
||||||
|
c.setTextDatum(top_left);
|
||||||
|
c.drawString(keys.c_str(), 4, area.y + area.h - 9);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace roro
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <FS.h>
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "dialog_model.h"
|
||||||
|
#include "key_event.h"
|
||||||
|
#include "list_model.h"
|
||||||
|
#include "services/storage_service.h"
|
||||||
|
#include "services/update_service.h"
|
||||||
|
#include "text_pager.h"
|
||||||
|
#include "ui/canvas.h"
|
||||||
|
#include "ui/theme.h"
|
||||||
|
|
||||||
|
namespace roro {
|
||||||
|
|
||||||
|
// Looks inside a file for the Storage App (F1, Q134), by type: text read as it's scrolled, a hex
|
||||||
|
// dump, a Capture's packets, a Track's summary, an Update File's version and whether it's
|
||||||
|
// genuine. Tab switches to the text or the hex of the same file. Nothing here changes a file.
|
||||||
|
class FileViewer {
|
||||||
|
public:
|
||||||
|
FileViewer(StorageService& storage, UpdateService& update) : storage_(storage), update_(update) {}
|
||||||
|
|
||||||
|
void open(const std::string& path, uint32_t size);
|
||||||
|
void close();
|
||||||
|
bool onKey(const KeyEvent& e); // false: leave the viewer
|
||||||
|
bool update(uint32_t nowMs); // true: draw again
|
||||||
|
void draw(Canvas& c);
|
||||||
|
|
||||||
|
private:
|
||||||
|
enum class Mode { Text, Hex, Gpx, Pcap, Packet, Ota };
|
||||||
|
static constexpr int kRows = 8;
|
||||||
|
static constexpr int kCols = 38;
|
||||||
|
struct Scan; // what a storage job reads through a whole file for: shared with that job
|
||||||
|
|
||||||
|
size_t readAt(uint32_t offset, uint8_t* into, size_t len);
|
||||||
|
void show(Mode mode);
|
||||||
|
void startScan(Mode mode);
|
||||||
|
void refresh(); // reads what the screen shows, when it has moved
|
||||||
|
void openPacket(int index);
|
||||||
|
void scroll(int lines);
|
||||||
|
std::string title() const;
|
||||||
|
|
||||||
|
StorageService& storage_;
|
||||||
|
UpdateService& update_;
|
||||||
|
std::string path_;
|
||||||
|
uint32_t size_ = 0;
|
||||||
|
Mode mode_ = Mode::Text, base_ = Mode::Text;
|
||||||
|
std::shared_ptr<fs::File> file_; // opened and read on the storage task only
|
||||||
|
std::unique_ptr<files::TextPager> pager_;
|
||||||
|
uint32_t hexTop_ = 0; // in rows of eight bytes
|
||||||
|
std::vector<std::string> shown_; // the rows on screen
|
||||||
|
bool stale_ = true;
|
||||||
|
std::shared_ptr<Scan> scan_;
|
||||||
|
bool scanShown_ = false;
|
||||||
|
int shownPercent_ = -1;
|
||||||
|
ListModel packets_{kRows};
|
||||||
|
int rowsFrom_ = -1;
|
||||||
|
std::vector<std::string> details_;
|
||||||
|
int detailsTop_ = 0;
|
||||||
|
std::unique_ptr<DialogModel> confirm_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace roro
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
#include "storage_page.h"
|
#include "maintenance_page.h"
|
||||||
|
|
||||||
#include "ui/widgets.h"
|
#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();
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -0,0 +1,566 @@
|
|||||||
|
#include "storage_app.h"
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstdio>
|
||||||
|
|
||||||
|
#include "cleanup_plan.h"
|
||||||
|
#include "file_names.h"
|
||||||
|
#include "ui/canvas.h"
|
||||||
|
#include "ui/fonts.h"
|
||||||
|
#include "ui/widgets.h"
|
||||||
|
|
||||||
|
namespace roro {
|
||||||
|
|
||||||
|
using files::baseName;
|
||||||
|
using files::joinPath;
|
||||||
|
using files::parentOf;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
constexpr uint32_t kMessageMs = 4000;
|
||||||
|
constexpr uint32_t kQuietMs = 300; // an operation shorter than this shows no progress
|
||||||
|
|
||||||
|
const char* kindText(const std::string& name) {
|
||||||
|
switch (files::kindOf(name)) {
|
||||||
|
case files::FileKind::Text: return "Text";
|
||||||
|
case files::FileKind::Gpx: return "Track (GPX)";
|
||||||
|
case files::FileKind::Pcap: return "Capture (pcap)";
|
||||||
|
case files::FileKind::Ota: return "Update File";
|
||||||
|
default: return "File";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string plural(uint32_t n, const char* one) { return std::to_string(n) + " " + one + (n == 1 ? "" : "s"); }
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void StorageApp::onEnter() {
|
||||||
|
view_ = ESP.getFreeHeap() < kStartFloor ? View::NoMemory : View::Browse;
|
||||||
|
ask_ = Ask::None;
|
||||||
|
dialog_.reset();
|
||||||
|
message_.clear();
|
||||||
|
cardPresent_ = storage_.state().present;
|
||||||
|
// A copy left running is picked up where it is; anything else starts from a fresh listing.
|
||||||
|
if (wait_ != Wait::Work && view_ == View::Browse) open(cwd_);
|
||||||
|
else want_ = cwd_;
|
||||||
|
requestRedraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
void StorageApp::onExit() {
|
||||||
|
if (wait_ != Wait::None && wait_ != Wait::Work) {
|
||||||
|
ops_.cancel();
|
||||||
|
wait_ = Wait::None;
|
||||||
|
}
|
||||||
|
viewer_.close();
|
||||||
|
wantList_ = false;
|
||||||
|
loaded_ = false;
|
||||||
|
list_ = files::FileList(); // its memory only while the App is open
|
||||||
|
rows_.setCount(0);
|
||||||
|
std::vector<std::string>().swap(details_);
|
||||||
|
}
|
||||||
|
|
||||||
|
void StorageApp::say(const std::string& text) {
|
||||||
|
message_ = text;
|
||||||
|
messageMs_ = millis();
|
||||||
|
requestRedraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
// What's on screen stays, with its path, until the new listing is in: cwd_ changes then.
|
||||||
|
void StorageApp::open(const std::string& folder, const std::string& select) {
|
||||||
|
want_ = folder;
|
||||||
|
selectAfter_ = select;
|
||||||
|
wantList_ = true; // started from update(), once the card is free
|
||||||
|
waitSinceMs_ = millis();
|
||||||
|
}
|
||||||
|
|
||||||
|
StorageApp::Item StorageApp::selected() const {
|
||||||
|
int i = rows_.selected();
|
||||||
|
if (!loaded_ || i < 0 || i >= static_cast<int>(list_.count())) return {};
|
||||||
|
Item item;
|
||||||
|
item.path = joinPath(cwd_, list_.name(i));
|
||||||
|
item.folder = list_.folder(i);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool StorageApp::work(const std::string& why, const std::string& selectAfter) {
|
||||||
|
if (!why.empty()) {
|
||||||
|
say(why);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
wait_ = Wait::Work;
|
||||||
|
waitSinceMs_ = millis();
|
||||||
|
selectAfter_ = selectAfter;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void StorageApp::update(uint32_t nowMs) {
|
||||||
|
bool present = storage_.state().present;
|
||||||
|
if (present != cardPresent_) { // taken out, put in, or erased: back to the top
|
||||||
|
cardPresent_ = present;
|
||||||
|
cwd_ = want_ = "/";
|
||||||
|
selectAfter_.clear();
|
||||||
|
loaded_ = false;
|
||||||
|
list_.clear();
|
||||||
|
rows_.setCount(0);
|
||||||
|
wantList_ = present;
|
||||||
|
clip_ = Item();
|
||||||
|
if (view_ == View::Viewer) viewer_.close();
|
||||||
|
if (view_ == View::Details || view_ == View::Name || view_ == View::Viewer) view_ = View::Browse;
|
||||||
|
requestRedraw();
|
||||||
|
}
|
||||||
|
if (wantList_ && present && wait_ == Wait::None && !ops_.busy() && ops_.list(want_).empty()) {
|
||||||
|
wantList_ = false;
|
||||||
|
wait_ = Wait::List;
|
||||||
|
waitSinceMs_ = nowMs;
|
||||||
|
}
|
||||||
|
if (view_ == View::Viewer && viewer_.update(nowMs)) requestRedraw();
|
||||||
|
FileOps::Status s;
|
||||||
|
if (wait_ != Wait::None && ops_.finished(s)) {
|
||||||
|
onFinished(s);
|
||||||
|
requestRedraw();
|
||||||
|
}
|
||||||
|
// millis(), not nowMs: a message set by a key in this same pass is newer than the pass's own time.
|
||||||
|
uint32_t now = millis();
|
||||||
|
if ((wait_ != Wait::None || wantList_) && now - lastDrawMs_ >= 250) requestRedraw();
|
||||||
|
if (!message_.empty() && now - messageMs_ >= kMessageMs) {
|
||||||
|
message_.clear();
|
||||||
|
requestRedraw();
|
||||||
|
}
|
||||||
|
if (view_ == View::Maintenance && now - lastDrawMs_ >= 1000) requestRedraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
void StorageApp::onFinished(const FileOps::Status& s) {
|
||||||
|
Wait was = wait_;
|
||||||
|
wait_ = Wait::None;
|
||||||
|
switch (was) {
|
||||||
|
case Wait::List: {
|
||||||
|
if (!s.error.empty()) {
|
||||||
|
say(s.error);
|
||||||
|
selectAfter_.clear();
|
||||||
|
// A folder that can't be opened leaves the screen as it is; if it's the one on
|
||||||
|
// screen that went away, back to the top.
|
||||||
|
if (want_ != cwd_ && loaded_) {
|
||||||
|
want_ = cwd_;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (want_ != "/") return open("/");
|
||||||
|
list_.clear();
|
||||||
|
} else {
|
||||||
|
ops_.takeListing(list_);
|
||||||
|
list_.sort(sort_);
|
||||||
|
}
|
||||||
|
cwd_ = want_;
|
||||||
|
loaded_ = true;
|
||||||
|
int count = static_cast<int>(list_.count()) + (cwd_ == "/" ? 1 : 0);
|
||||||
|
int at = selectAfter_.empty() ? -1 : list_.find(selectAfter_);
|
||||||
|
rows_.setCount(count);
|
||||||
|
rows_.select(at >= 0 ? at : selectIndex_);
|
||||||
|
selectAfter_.clear();
|
||||||
|
selectIndex_ = 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case Wait::CountToDelete:
|
||||||
|
if (!s.error.empty()) say(s.error);
|
||||||
|
else if (!s.cancelled) askDelete(target_, s.files, s.bytes);
|
||||||
|
break;
|
||||||
|
case Wait::CountForDetails:
|
||||||
|
if (view_ != View::Details || details_.empty() || s.cancelled) break;
|
||||||
|
details_.back() = s.error.empty() ? plural(s.files, "file") + ", " + formatBytes(s.bytes) : s.error;
|
||||||
|
break;
|
||||||
|
case Wait::Work: {
|
||||||
|
std::string name = files::fitName(baseName(target_.path), 20);
|
||||||
|
if (s.exists && pasting_) {
|
||||||
|
question_ = name + " exists already in this folder. Replace it?";
|
||||||
|
ask_ = Ask::Replace;
|
||||||
|
dialog_.reset(new DialogModel({"Cancel", "Replace"}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!s.error.empty()) say(s.error);
|
||||||
|
else if (s.cancelled) say(s.op == FileOps::Op::Copy ? "Cancelled: nothing was copied" : "Cancelled after " + plural(s.files, "file"));
|
||||||
|
else {
|
||||||
|
switch (s.op) {
|
||||||
|
case FileOps::Op::Copy: say(s.files == 1 ? "Copied " + name : "Copied " + plural(s.files, "file") + " to " + name); break;
|
||||||
|
case FileOps::Op::Move: say((pasting_ ? "Moved " : "Renamed to ") + name); break;
|
||||||
|
case FileOps::Op::Delete: say(s.files == 1 ? "Deleted " + name : "Deleted " + name + ", " + plural(s.files, "file")); break;
|
||||||
|
default: say("Made " + name); break;
|
||||||
|
}
|
||||||
|
// The clipboard must not point at what has moved or gone.
|
||||||
|
bool gone = s.op == FileOps::Op::Delete || s.op == FileOps::Op::Move;
|
||||||
|
if (clip_ && gone && files::isInside(clip_.path, s.path)) clip_ = Item();
|
||||||
|
}
|
||||||
|
open(cwd_, selectAfter_);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void StorageApp::paste(bool replace) {
|
||||||
|
if (!clip_) return say("Nothing to paste: c copies, x cuts");
|
||||||
|
std::string name = baseName(clip_.path), why;
|
||||||
|
if (cut_) {
|
||||||
|
why = ops_.move(clip_.path, clip_.folder, joinPath(cwd_, name), replace);
|
||||||
|
} else {
|
||||||
|
// A copy next to its original: "a (2).txt", or the next number that's free.
|
||||||
|
if (parentOf(clip_.path) == cwd_)
|
||||||
|
for (int n = 2; n < 100 && list_.find(name) >= 0; n++) name = files::copyName(baseName(clip_.path), n);
|
||||||
|
why = ops_.copy(clip_.path, clip_.folder, cwd_, name, replace);
|
||||||
|
}
|
||||||
|
target_.path = joinPath(cwd_, name);
|
||||||
|
target_.folder = clip_.folder;
|
||||||
|
pasting_ = true;
|
||||||
|
selectIndex_ = rows_.selected(); // where to stay if nothing comes of it
|
||||||
|
work(why, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
void StorageApp::askDelete(const Item& item, uint32_t count, uint32_t bytes) {
|
||||||
|
std::string name = files::fitName(baseName(item.path), 22);
|
||||||
|
if (!item.folder) question_ = "Delete " + name + " (" + formatBytes(bytes) + ")?";
|
||||||
|
else if (count == 0) question_ = "Delete the empty folder " + name + "?";
|
||||||
|
else question_ = "Delete " + name + " and its " + plural(count, "file") + " (" + formatBytes(bytes) + ")?";
|
||||||
|
question_ += " It can't be undone.";
|
||||||
|
target_ = item;
|
||||||
|
ask_ = Ask::Delete;
|
||||||
|
dialog_.reset(new DialogModel({"Cancel", "Delete"}));
|
||||||
|
requestRedraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
void StorageApp::onAnswer(int button) {
|
||||||
|
Ask asked = ask_;
|
||||||
|
ask_ = Ask::None;
|
||||||
|
dialog_.reset();
|
||||||
|
if (button != 1) return;
|
||||||
|
switch (asked) {
|
||||||
|
case Ask::Delete:
|
||||||
|
selectIndex_ = rows_.selected();
|
||||||
|
pasting_ = false;
|
||||||
|
work(ops_.remove(target_.path, target_.folder), "");
|
||||||
|
break;
|
||||||
|
case Ask::Replace: paste(true); break;
|
||||||
|
case Ask::Maintenance:
|
||||||
|
view_ = View::Maintenance;
|
||||||
|
maintenance_.enter();
|
||||||
|
break;
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void StorageApp::showDetails(const Item& item) {
|
||||||
|
int i = rows_.selected();
|
||||||
|
std::string name = baseName(item.path);
|
||||||
|
details_.clear();
|
||||||
|
details_.push_back(name);
|
||||||
|
if (item.folder) details_.push_back("Folder");
|
||||||
|
else {
|
||||||
|
uint32_t size = list_.size(i);
|
||||||
|
std::string text = std::string(kindText(name)) + ", " + formatBytes(size);
|
||||||
|
if (size >= 1024) text += " (" + std::to_string(size) + " bytes)";
|
||||||
|
details_.push_back(text);
|
||||||
|
}
|
||||||
|
details_.push_back("Modified " + files::formatStamp(list_.modified(i)));
|
||||||
|
details_.push_back("In " + cwd_);
|
||||||
|
std::string why = ops_.whyReadOnly(item.path, item.folder);
|
||||||
|
if (!why.empty()) details_.push_back("Read-only: " + why);
|
||||||
|
if (item.folder) {
|
||||||
|
details_.push_back("Counting...");
|
||||||
|
if (ops_.count(item.path).empty()) wait_ = Wait::CountForDetails;
|
||||||
|
else details_.pop_back();
|
||||||
|
}
|
||||||
|
detailsTop_ = 0;
|
||||||
|
view_ = View::Details;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool StorageApp::onKey(const KeyEvent& e) {
|
||||||
|
requestRedraw();
|
||||||
|
if (view_ == View::NoMemory) return false;
|
||||||
|
if (view_ == View::Maintenance) {
|
||||||
|
if (!maintenance_.onKey(e)) {
|
||||||
|
view_ = View::Browse;
|
||||||
|
open(cwd_); // a Clean-up may have emptied what was shown
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (view_ == View::Viewer) {
|
||||||
|
if (!viewer_.onKey(e)) {
|
||||||
|
viewer_.close();
|
||||||
|
view_ = View::Browse;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (dialog_) {
|
||||||
|
dialog_->onKey(e);
|
||||||
|
if (dialog_->result() != DialogModel::kPending) onAnswer(dialog_->result());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (view_ == View::Details) {
|
||||||
|
if (e.key == Key::Up) detailsTop_ = std::max(0, detailsTop_ - 1);
|
||||||
|
else if (e.key == Key::Down) detailsTop_++; // clamped when drawn
|
||||||
|
else if (e.key == Key::Back || e.key == Key::Select) {
|
||||||
|
if (wait_ == Wait::CountForDetails) ops_.cancel();
|
||||||
|
view_ = View::Browse;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// A copy, a delete or a count is running: Back stops it, nothing else gets through. The same
|
||||||
|
// while a folder is being read, which nothing stops.
|
||||||
|
if (wantList_) return true;
|
||||||
|
if (wait_ != Wait::None) {
|
||||||
|
if (e.key == Key::Back && wait_ != Wait::List) ops_.cancel();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (view_ == View::Name) return onNameKey(e);
|
||||||
|
return onBrowseKey(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool StorageApp::onNameKey(const KeyEvent& e) {
|
||||||
|
switch (e.key) {
|
||||||
|
case Key::Char: editor_.insert(e.ch); break;
|
||||||
|
case Key::Delete: editor_.backspace(); break;
|
||||||
|
case Key::Left: editor_.left(); break;
|
||||||
|
case Key::Right: editor_.right(); break;
|
||||||
|
case Key::Back: view_ = View::Browse; break;
|
||||||
|
case Key::Select: {
|
||||||
|
const std::string& name = editor_.text();
|
||||||
|
std::string why = files::checkName(name);
|
||||||
|
if (!why.empty()) {
|
||||||
|
say(why);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
pasting_ = false;
|
||||||
|
bool started = true;
|
||||||
|
if (!renaming_) {
|
||||||
|
target_.path = joinPath(cwd_, name);
|
||||||
|
target_.folder = true;
|
||||||
|
started = work(ops_.makeFolder(target_.path), name);
|
||||||
|
} else if (name != baseName(target_.path)) {
|
||||||
|
std::string to = joinPath(cwd_, name);
|
||||||
|
started = work(ops_.move(target_.path, target_.folder, to), name);
|
||||||
|
if (started) target_.path = to;
|
||||||
|
}
|
||||||
|
if (started) view_ = View::Browse;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool StorageApp::onBrowseKey(const KeyEvent& e) {
|
||||||
|
auto askMaintenance = [this]() {
|
||||||
|
question_ = "Clean-up and Erase delete files for good: there is no undo.";
|
||||||
|
ask_ = Ask::Maintenance;
|
||||||
|
dialog_.reset(new DialogModel({"Back", "Go on"}));
|
||||||
|
};
|
||||||
|
if (e.key == Key::Back) {
|
||||||
|
if (cwd_ == "/" || !cardPresent_) return false; // leaves the App
|
||||||
|
open(parentOf(cwd_), baseName(cwd_));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
uint32_t ch = e.key == Key::Char ? (e.ch >= 'A' && e.ch <= 'Z' ? e.ch + 32 : e.ch) : 0;
|
||||||
|
if (ch == 'm') askMaintenance();
|
||||||
|
if (!cardPresent_ || !loaded_) return true;
|
||||||
|
|
||||||
|
Item item = selected();
|
||||||
|
switch (e.key) {
|
||||||
|
case Key::Up: rows_.up(); return true;
|
||||||
|
case Key::Down: rows_.down(); return true;
|
||||||
|
case Key::Left: rows_.pageUp(); return true;
|
||||||
|
case Key::Right: rows_.pageDown(); return true;
|
||||||
|
case Key::Select:
|
||||||
|
if (onMaintenanceRow()) askMaintenance();
|
||||||
|
else if (item && item.folder) open(item.path);
|
||||||
|
else if (item) {
|
||||||
|
viewer_.open(item.path, list_.size(rows_.selected()));
|
||||||
|
view_ = View::Viewer;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
case Key::Delete: ch = 'd'; break;
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
switch (ch) {
|
||||||
|
case 'v': paste(false); return true;
|
||||||
|
case 'n':
|
||||||
|
renaming_ = false;
|
||||||
|
editor_.setText("");
|
||||||
|
view_ = View::Name;
|
||||||
|
return true;
|
||||||
|
case 's': {
|
||||||
|
sort_ = sort_ == files::FileSort::Name ? files::FileSort::Date : sort_ == files::FileSort::Date ? files::FileSort::Size : files::FileSort::Name;
|
||||||
|
std::string keep = item ? baseName(item.path) : "";
|
||||||
|
bool onLast = onMaintenanceRow();
|
||||||
|
list_.sort(sort_);
|
||||||
|
if (!onLast) rows_.select(std::max(0, list_.find(keep)));
|
||||||
|
say(sort_ == files::FileSort::Name ? "Sorted by name" : sort_ == files::FileSort::Date ? "Sorted by date, newest first" : "Sorted by size, biggest first");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
if (!item || !(ch == 'c' || ch == 'x' || ch == 'r' || ch == 'd' || ch == 'i')) return true;
|
||||||
|
|
||||||
|
std::string name = files::fitName(baseName(item.path), 18);
|
||||||
|
if (ch == 'i') {
|
||||||
|
showDetails(item);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (ch == 'c') {
|
||||||
|
clip_ = item;
|
||||||
|
cut_ = false;
|
||||||
|
say("v pastes a copy of " + name);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Cut, rename and delete change the original: the rules of Q130 first.
|
||||||
|
std::string why = ops_.whyReadOnly(item.path, item.folder);
|
||||||
|
if (!why.empty()) {
|
||||||
|
say(why);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (ch == 'x') {
|
||||||
|
clip_ = item;
|
||||||
|
cut_ = true;
|
||||||
|
say("v moves " + name + " here");
|
||||||
|
} else if (ch == 'r') {
|
||||||
|
target_ = item;
|
||||||
|
renaming_ = true;
|
||||||
|
editor_.setText(baseName(item.path));
|
||||||
|
view_ = View::Name;
|
||||||
|
} else if (item.folder) { // 'd': what's inside is counted first (Q132)
|
||||||
|
target_ = item;
|
||||||
|
std::string busy = ops_.count(item.path);
|
||||||
|
if (!busy.empty()) say(busy);
|
||||||
|
else {
|
||||||
|
wait_ = Wait::CountToDelete;
|
||||||
|
waitSinceMs_ = millis();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
askDelete(item, 1, list_.size(rows_.selected()));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void StorageApp::draw(Canvas& c) {
|
||||||
|
lastDrawMs_ = millis();
|
||||||
|
const auto& area = theme::kContent;
|
||||||
|
c.setTextDatum(top_left);
|
||||||
|
switch (view_) {
|
||||||
|
case View::NoMemory:
|
||||||
|
c.setFont(&fonts::bold);
|
||||||
|
c.setTextColor(theme::kWarning);
|
||||||
|
c.drawString("Not enough memory", 4, area.y + 4);
|
||||||
|
c.setFont(&fonts::body);
|
||||||
|
c.setTextColor(theme::kMuted);
|
||||||
|
c.drawString("Close what's running (IRC, a page", 4, area.y + 22);
|
||||||
|
c.drawString("in Gemini) and open Storage again.", 4, area.y + 22 + theme::kLineHeight);
|
||||||
|
return;
|
||||||
|
case View::Maintenance: maintenance_.draw(c); return;
|
||||||
|
case View::Viewer: viewer_.draw(c); return;
|
||||||
|
case View::Details: {
|
||||||
|
std::vector<std::string> lines;
|
||||||
|
auto measure = widgets::bodyMeasure(c);
|
||||||
|
for (auto& d : details_)
|
||||||
|
for (auto& l : wrapText(d, area.w - 12, measure)) lines.push_back(l);
|
||||||
|
int rows = (area.h - 2) / theme::kLineHeight;
|
||||||
|
detailsTop_ = std::clamp(detailsTop_, 0, std::max(0, static_cast<int>(lines.size()) - rows));
|
||||||
|
widgets::textLines(c, lines, detailsTop_, {area.x + 2, area.y + 2, area.w - 2, area.h - 2});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
drawBrowse(c);
|
||||||
|
if (view_ == View::Name) {
|
||||||
|
theme::Rect box{16, 44, theme::kWidth - 32, 48};
|
||||||
|
c.fillRect(box.x, box.y, box.w, box.h, theme::kBackground);
|
||||||
|
c.drawRect(box.x, box.y, box.w, box.h, theme::kAccent);
|
||||||
|
c.setFont(&fonts::bold);
|
||||||
|
c.setTextColor(theme::kText);
|
||||||
|
c.drawString(renaming_ ? "Rename" : "New folder", box.x + 6, box.y + 4);
|
||||||
|
widgets::lineEditor(c, editor_, {box.x + 6, box.y + 22, box.w - 12, theme::kLineHeight + 4});
|
||||||
|
}
|
||||||
|
if (dialog_) {
|
||||||
|
const char* title = ask_ == Ask::Delete ? "Delete" : ask_ == Ask::Replace ? "Replace" : "Maintenance";
|
||||||
|
widgets::dialog(c, title, question_, *dialog_);
|
||||||
|
} else if ((wait_ == Wait::Work || wait_ == Wait::CountToDelete) && millis() - waitSinceMs_ >= kQuietMs) {
|
||||||
|
drawProgress(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void StorageApp::drawBrowse(Canvas& c) {
|
||||||
|
const auto& area = theme::kContent;
|
||||||
|
StorageState card = storage_.state();
|
||||||
|
|
||||||
|
// Where we are, and how much room is left (Q129).
|
||||||
|
c.setFont(&fonts::small);
|
||||||
|
c.setTextColor(theme::kMuted);
|
||||||
|
std::string right;
|
||||||
|
if (card.present) {
|
||||||
|
right = formatBytes(card.totalBytes - card.usedBytes) + " free";
|
||||||
|
if (loaded_ && list_.more() > 0) right = "first " + std::to_string(list_.count()) + " of " + std::to_string(list_.count() + list_.more());
|
||||||
|
}
|
||||||
|
size_t room = static_cast<size_t>((area.w - 12) / 5) - right.size() - 1;
|
||||||
|
std::string path = cwd_.size() <= room ? cwd_ : ".." + cwd_.substr(cwd_.size() - (room - 2));
|
||||||
|
c.drawString(path.c_str(), 4, area.y + 1);
|
||||||
|
c.setTextDatum(top_right);
|
||||||
|
c.drawString(right.c_str(), area.w - 3, area.y + 1);
|
||||||
|
c.setTextDatum(top_left);
|
||||||
|
|
||||||
|
theme::Rect rows{area.x, area.y + 10, area.w, kRows * theme::kLineHeight};
|
||||||
|
c.setFont(&fonts::body);
|
||||||
|
if (!card.present) {
|
||||||
|
c.setTextColor(theme::kWarning);
|
||||||
|
c.drawString("No SD card", 4, rows.y + 4);
|
||||||
|
} else if (!loaded_) {
|
||||||
|
// Nothing yet: the footer says what's being read.
|
||||||
|
} else if (rows_.count() == 0) {
|
||||||
|
c.setTextColor(theme::kMuted);
|
||||||
|
c.drawString("Nothing in this folder.", 4, rows.y + 4);
|
||||||
|
c.drawString("n makes a folder, v pastes.", 4, rows.y + 4 + theme::kLineHeight);
|
||||||
|
} else {
|
||||||
|
int entries = static_cast<int>(list_.count());
|
||||||
|
auto value = [&](int i) {
|
||||||
|
return i >= entries ? std::string(">") : files::rowDetail(list_.folder(i), list_.size(i), list_.modified(i));
|
||||||
|
};
|
||||||
|
widgets::list(
|
||||||
|
c, rows_, rows,
|
||||||
|
[&](int i) {
|
||||||
|
if (i >= entries) return std::string("Maintenance");
|
||||||
|
size_t fits = static_cast<size_t>((area.w - 22) / 6) - value(i).size();
|
||||||
|
return files::fitName(list_.name(i), fits);
|
||||||
|
},
|
||||||
|
value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footer: a message for a few seconds, what v would paste, or the keys.
|
||||||
|
c.setFont(&fonts::small);
|
||||||
|
bool showMessage = !message_.empty() && millis() - messageMs_ < kMessageMs;
|
||||||
|
c.setTextColor(showMessage ? theme::kWarning : theme::kMuted);
|
||||||
|
std::string keys = "c x v:paste r:name d:del n:new i:info s:sort";
|
||||||
|
if ((wantList_ || wait_ == Wait::List) && millis() - waitSinceMs_ >= kQuietMs) keys = "Reading " + files::fitName(want_, 30) + "...";
|
||||||
|
else if (clip_) keys = std::string("v: ") + (cut_ ? "move " : "copy ") + files::fitName(baseName(clip_.path), 20) + " here";
|
||||||
|
c.drawString(showMessage ? message_.c_str() : keys.c_str(), 4, area.y + area.h - 9);
|
||||||
|
}
|
||||||
|
|
||||||
|
void StorageApp::drawProgress(Canvas& c) {
|
||||||
|
FileOps::Status s = ops_.status();
|
||||||
|
theme::Rect box{20, 40, theme::kWidth - 40, 58};
|
||||||
|
c.fillRect(box.x, box.y, box.w, box.h, theme::kBackground);
|
||||||
|
c.drawRect(box.x, box.y, box.w, box.h, theme::kAccent);
|
||||||
|
c.setTextDatum(top_left);
|
||||||
|
c.setFont(&fonts::bold);
|
||||||
|
c.setTextColor(theme::kText);
|
||||||
|
const char* verb = s.op == FileOps::Op::Copy ? "Copying " : s.op == FileOps::Op::Delete ? "Deleting " : s.op == FileOps::Op::Count ? "Counting " : "Moving ";
|
||||||
|
c.drawString((verb + files::fitName(baseName(target_.path), 20)).c_str(), box.x + 6, box.y + 4);
|
||||||
|
|
||||||
|
c.setFont(&fonts::body);
|
||||||
|
c.setTextColor(theme::kMuted);
|
||||||
|
std::string line = plural(s.files, "file");
|
||||||
|
if (s.op == FileOps::Op::Copy && s.totalBytes > 0) {
|
||||||
|
int w = box.w - 12, done = static_cast<int>(static_cast<uint64_t>(s.doneBytes) * w / s.totalBytes);
|
||||||
|
c.drawRect(box.x + 6, box.y + 21, w, 7, theme::kMuted);
|
||||||
|
c.fillRect(box.x + 6, box.y + 21, std::min(done, w), 7, theme::kAccent);
|
||||||
|
line = formatBytes(s.doneBytes) + " of " + formatBytes(s.totalBytes);
|
||||||
|
}
|
||||||
|
c.drawString(line.c_str(), box.x + 6, box.y + 31);
|
||||||
|
c.setFont(&fonts::small);
|
||||||
|
c.drawString("Back: cancel", box.x + 6, box.y + box.h - 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace roro
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "app.h"
|
||||||
|
#include "apps/file_viewer.h"
|
||||||
|
#include "apps/maintenance_page.h"
|
||||||
|
#include "dialog_model.h"
|
||||||
|
#include "event_bus.h"
|
||||||
|
#include "file_list.h"
|
||||||
|
#include "line_editor.h"
|
||||||
|
#include "list_model.h"
|
||||||
|
#include "services/clock_service.h"
|
||||||
|
#include "services/file_ops.h"
|
||||||
|
#include "services/storage_service.h"
|
||||||
|
#include "ui/theme.h"
|
||||||
|
|
||||||
|
namespace roro {
|
||||||
|
|
||||||
|
// The Storage App (docs/milestones/F1.md): the SD card's folders and files, one item at a time
|
||||||
|
// with a clipboard (Q131). Enter opens a folder, or a file in its viewer (Q134); Back goes up;
|
||||||
|
// c copy, x cut, v paste here, r rename, d delete, n new folder, i details, s sort. At the top of the card, the last row is Maintenance:
|
||||||
|
// usage, Storage Clean-up and erasing the card, behind a warning (Q128).
|
||||||
|
class StorageApp : public App {
|
||||||
|
public:
|
||||||
|
StorageApp(FileOps& ops, StorageService& storage, ClockService& clock, UpdateService& update, EventBus& bus)
|
||||||
|
: ops_(ops), storage_(storage), bus_(bus), maintenance_(storage, clock, bus), viewer_(storage, update) {}
|
||||||
|
void onEnter() override;
|
||||||
|
void onExit() override;
|
||||||
|
bool onKey(const KeyEvent& e) override;
|
||||||
|
bool textEntryActive() const override { return view_ == View::Name; }
|
||||||
|
void update(uint32_t nowMs) override;
|
||||||
|
void draw(Canvas& c) override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
enum class View { Browse, Details, Name, Viewer, Maintenance, NoMemory };
|
||||||
|
enum class Ask { None, Delete, Replace, Maintenance };
|
||||||
|
enum class Wait { None, List, CountToDelete, CountForDetails, Work }; // what the running operation is for
|
||||||
|
static constexpr int kRows = 8;
|
||||||
|
static constexpr size_t kStartFloor = 55 * 1024; // Q86, Q136
|
||||||
|
|
||||||
|
struct Item {
|
||||||
|
std::string path;
|
||||||
|
bool folder = false;
|
||||||
|
explicit operator bool() const { return !path.empty(); }
|
||||||
|
};
|
||||||
|
|
||||||
|
void open(const std::string& folder, const std::string& select = "");
|
||||||
|
void onFinished(const FileOps::Status& s);
|
||||||
|
bool onBrowseKey(const KeyEvent& e);
|
||||||
|
bool onNameKey(const KeyEvent& e);
|
||||||
|
void onAnswer(int button);
|
||||||
|
Item selected() const; // empty on the Maintenance row or in an empty folder
|
||||||
|
bool onMaintenanceRow() const { return cwd_ == "/" && rows_.selected() == static_cast<int>(list_.count()); }
|
||||||
|
void paste(bool replace);
|
||||||
|
void askDelete(const Item& item, uint32_t files, uint32_t bytes);
|
||||||
|
void showDetails(const Item& item);
|
||||||
|
bool work(const std::string& why, const std::string& selectAfter); // an operation was asked for: did it start?
|
||||||
|
void say(const std::string& text);
|
||||||
|
void drawBrowse(Canvas& c);
|
||||||
|
void drawProgress(Canvas& c);
|
||||||
|
|
||||||
|
FileOps& ops_;
|
||||||
|
StorageService& storage_;
|
||||||
|
EventBus& bus_;
|
||||||
|
MaintenancePage maintenance_;
|
||||||
|
FileViewer viewer_;
|
||||||
|
|
||||||
|
View view_ = View::Browse;
|
||||||
|
std::string cwd_ = "/", want_ = "/"; // the folder on screen; the one being read
|
||||||
|
files::FileList list_;
|
||||||
|
files::FileSort sort_ = files::FileSort::Name;
|
||||||
|
ListModel rows_{kRows};
|
||||||
|
bool wantList_ = false, loaded_ = false, cardPresent_ = false;
|
||||||
|
std::string selectAfter_; // after the next listing: the name to select, or else the row
|
||||||
|
int selectIndex_ = 0;
|
||||||
|
|
||||||
|
Wait wait_ = Wait::None;
|
||||||
|
uint32_t waitSinceMs_ = 0, lastDrawMs_ = 0;
|
||||||
|
Item clip_, target_; // the clipboard; what a dialog, the editor or the details are about
|
||||||
|
bool cut_ = false;
|
||||||
|
Ask ask_ = Ask::None;
|
||||||
|
std::unique_ptr<DialogModel> dialog_;
|
||||||
|
std::string question_;
|
||||||
|
LineEditor editor_{60};
|
||||||
|
bool renaming_ = false; // Name view: renaming target_, or else a new folder
|
||||||
|
bool pasting_ = false; // the operation running came from v: it may ask to replace
|
||||||
|
std::vector<std::string> details_;
|
||||||
|
int detailsTop_ = 0;
|
||||||
|
std::string message_;
|
||||||
|
uint32_t messageMs_ = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace roro
|
||||||
+101
-6
@@ -14,6 +14,7 @@
|
|||||||
#include "apps/launcher_app.h"
|
#include "apps/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();
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -0,0 +1,492 @@
|
|||||||
|
#include "services/file_ops.h"
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include <SD.h>
|
||||||
|
|
||||||
|
#include <ctime>
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
#include "file_names.h"
|
||||||
|
|
||||||
|
namespace roro {
|
||||||
|
|
||||||
|
using files::baseName;
|
||||||
|
using files::joinPath;
|
||||||
|
using files::parentOf;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// The FatFs drive the card is mounted as: SDFS keeps it to itself.
|
||||||
|
struct SdDrive : fs::SDFS {
|
||||||
|
static uint8_t of(fs::SDFS& sd) { return sd.*(&SdDrive::_pdrv); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// A FAT date and time (local, as the firmware wrote them) as Unix time; 0 if there's none.
|
||||||
|
uint32_t fatTime(uint16_t date, uint16_t time) {
|
||||||
|
if (date == 0) return 0;
|
||||||
|
struct tm t = {};
|
||||||
|
t.tm_year = (date >> 9) + 80;
|
||||||
|
t.tm_mon = ((date >> 5) & 15) - 1;
|
||||||
|
t.tm_mday = date & 31;
|
||||||
|
t.tm_hour = time >> 11;
|
||||||
|
t.tm_min = (time >> 5) & 63;
|
||||||
|
t.tm_sec = (time & 31) * 2;
|
||||||
|
t.tm_isdst = -1;
|
||||||
|
time_t at = mktime(&t);
|
||||||
|
return at < 0 ? 0 : static_cast<uint32_t>(at);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Guard {
|
||||||
|
explicit Guard(SemaphoreHandle_t l) : l_(l) { xSemaphoreTake(l_, portMAX_DELAY); }
|
||||||
|
~Guard() { xSemaphoreGive(l_); }
|
||||||
|
SemaphoreHandle_t l_;
|
||||||
|
};
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
FileOps::FileOps(StorageService& storage, InUse inUse)
|
||||||
|
: storage_(storage), inUse_(std::move(inUse)), lock_(xSemaphoreCreateMutex()) {}
|
||||||
|
|
||||||
|
FileOps::Status FileOps::status() const {
|
||||||
|
Guard g(lock_);
|
||||||
|
Status s = status_;
|
||||||
|
s.running = busy_;
|
||||||
|
s.doneBytes = doneBytes_;
|
||||||
|
s.files = files_;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool FileOps::finished(Status& out) {
|
||||||
|
// The storage task drops its jobs when the card goes: nothing would ever end this one.
|
||||||
|
if (busy_ && !storage_.state().present) {
|
||||||
|
fail("The card went away");
|
||||||
|
busy_ = false;
|
||||||
|
done_ = true;
|
||||||
|
}
|
||||||
|
if (!done_.exchange(false)) return false;
|
||||||
|
out = status();
|
||||||
|
out.running = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void FileOps::takeListing(files::FileList& out) {
|
||||||
|
Guard g(lock_);
|
||||||
|
out = std::move(listing_);
|
||||||
|
listing_.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool FileOps::isFolder(const std::string& path, bool& exists) {
|
||||||
|
bool folder = false;
|
||||||
|
exists = false;
|
||||||
|
storage_.runAndWait([&]() {
|
||||||
|
fs::File f = SD.open(path.c_str());
|
||||||
|
exists = static_cast<bool>(f);
|
||||||
|
folder = f && f.isDirectory();
|
||||||
|
});
|
||||||
|
return folder;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string FileOps::whyReadOnly(const std::string& path, bool folder) const {
|
||||||
|
return files::whyReadOnly(path, inUse_(path, folder));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string FileOps::start(Op op, const std::string& path) {
|
||||||
|
if (busy_) return "The card is busy with something else";
|
||||||
|
if (!storage_.state().present) return "No SD card";
|
||||||
|
{
|
||||||
|
Guard g(lock_);
|
||||||
|
status_ = Status();
|
||||||
|
status_.op = op;
|
||||||
|
status_.path = path;
|
||||||
|
}
|
||||||
|
closeAll(); // only handles a vanished card left behind
|
||||||
|
listOpen_ = false;
|
||||||
|
op_ = op;
|
||||||
|
phase_ = 0;
|
||||||
|
undo_ = false;
|
||||||
|
cancel_ = false;
|
||||||
|
done_ = false;
|
||||||
|
doneBytes_ = 0;
|
||||||
|
files_ = 0;
|
||||||
|
countBytes_ = 0;
|
||||||
|
startMs_ = millis();
|
||||||
|
busy_ = true;
|
||||||
|
queue();
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
void FileOps::queue() {
|
||||||
|
storage_.runJob([this]() { slice(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string FileOps::list(const std::string& folder) {
|
||||||
|
from_ = folder;
|
||||||
|
return start(Op::List, folder);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string FileOps::count(const std::string& path) {
|
||||||
|
from_ = path;
|
||||||
|
return start(Op::Count, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string FileOps::copy(const std::string& from, bool folder, const std::string& intoFolder, const std::string& name, bool replace) {
|
||||||
|
if (busy_) return "The card is busy with something else";
|
||||||
|
std::string why = files::whyNotInto(from, intoFolder);
|
||||||
|
// A copy next to its original is fine: only the name must differ.
|
||||||
|
if (why == "It's already there" && name != baseName(from)) why.clear();
|
||||||
|
if (why.empty()) why = files::checkName(name);
|
||||||
|
if (!why.empty()) return why;
|
||||||
|
from_ = from;
|
||||||
|
to_ = joinPath(intoFolder, name);
|
||||||
|
replace_ = replace;
|
||||||
|
sourceIsFolder_ = folder;
|
||||||
|
return start(Op::Copy, from);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string FileOps::move(const std::string& from, bool folder, const std::string& to, bool replace) {
|
||||||
|
if (busy_) return "The card is busy with something else";
|
||||||
|
std::string why = files::whyReadOnly(from, inUse_(from, folder));
|
||||||
|
if (why.empty() && from == to) why = "It's already there";
|
||||||
|
if (why.empty() && parentOf(to) != parentOf(from)) why = files::whyNotInto(from, parentOf(to));
|
||||||
|
if (why.empty()) why = files::checkName(baseName(to));
|
||||||
|
if (!why.empty()) return why;
|
||||||
|
from_ = from;
|
||||||
|
to_ = to;
|
||||||
|
replace_ = replace;
|
||||||
|
sourceIsFolder_ = folder;
|
||||||
|
return start(Op::Move, from);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string FileOps::remove(const std::string& path, bool folder) {
|
||||||
|
if (busy_) return "The card is busy with something else";
|
||||||
|
std::string why = files::whyReadOnly(path, inUse_(path, folder));
|
||||||
|
if (!why.empty()) return why;
|
||||||
|
from_ = path;
|
||||||
|
sourceIsFolder_ = folder;
|
||||||
|
return start(Op::Delete, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string FileOps::makeFolder(const std::string& path) {
|
||||||
|
if (busy_) return "The card is busy with something else";
|
||||||
|
std::string why = files::checkName(baseName(path));
|
||||||
|
if (why.empty() && files::isInside(path, files::kGeminiCache)) why = std::string(files::kGeminiCache) + " is the Gemini App's working space";
|
||||||
|
if (!why.empty()) return why;
|
||||||
|
from_ = path;
|
||||||
|
return start(Op::MakeFolder, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
void FileOps::fail(const std::string& why) {
|
||||||
|
Guard g(lock_);
|
||||||
|
if (status_.error.empty()) status_.error = why;
|
||||||
|
}
|
||||||
|
|
||||||
|
void FileOps::closeAll() {
|
||||||
|
if (in_) in_.close();
|
||||||
|
if (out_) out_.close();
|
||||||
|
for (auto& d : dirs_)
|
||||||
|
if (d) d.close();
|
||||||
|
dirs_.clear();
|
||||||
|
paths_.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
void FileOps::finish() {
|
||||||
|
if (listOpen_) f_closedir(&listDir_);
|
||||||
|
listOpen_ = false;
|
||||||
|
closeAll();
|
||||||
|
{
|
||||||
|
Guard g(lock_);
|
||||||
|
status_.cancelled = cancel_ && status_.error.empty() && (op_ == Op::Copy || op_ == Op::Delete || op_ == Op::Count);
|
||||||
|
status_.bytes = countBytes_;
|
||||||
|
status_.ms = millis() - startMs_;
|
||||||
|
}
|
||||||
|
op_ = Op::None;
|
||||||
|
busy_ = false;
|
||||||
|
done_ = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool FileOps::enter(const std::string& path) {
|
||||||
|
fs::File d = SD.open(path.c_str());
|
||||||
|
if (!d || !d.isDirectory()) return false;
|
||||||
|
dirs_.push_back(d);
|
||||||
|
paths_.push_back(path);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One slice on the storage task, then back in the queue behind whatever else is waiting.
|
||||||
|
void FileOps::slice() {
|
||||||
|
if (!busy_) return; // given up on: the card went away (finished())
|
||||||
|
bool over = true;
|
||||||
|
switch (op_) {
|
||||||
|
case Op::List: over = sliceList(); break;
|
||||||
|
case Op::Count: over = sliceCount(); break;
|
||||||
|
case Op::Copy: over = sliceCopy(); break;
|
||||||
|
case Op::Delete: over = sliceDelete(); break;
|
||||||
|
case Op::Move: {
|
||||||
|
if (!SD.exists(from_.c_str())) fail("It isn't there any more");
|
||||||
|
else if (SD.exists(to_.c_str()) && !replace_) {
|
||||||
|
Guard g(lock_);
|
||||||
|
status_.exists = true;
|
||||||
|
status_.error = baseName(to_) + " exists already";
|
||||||
|
} else {
|
||||||
|
if (SD.exists(to_.c_str())) SD.remove(to_.c_str()); // a file: a folder in the way makes the rename fail
|
||||||
|
if (!SD.rename(from_.c_str(), to_.c_str())) fail("The card refused to move it");
|
||||||
|
else files_ = 1;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case Op::MakeFolder:
|
||||||
|
if (SD.exists(from_.c_str())) fail(baseName(from_) + " exists already");
|
||||||
|
else if (!SD.mkdir(from_.c_str())) fail("The card refused to make the folder");
|
||||||
|
break;
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
if (over) finish();
|
||||||
|
else queue();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Straight from FatFs, one pass over the folder: the Arduino File looks every entry up by name
|
||||||
|
// again for its size and its date, which made 300 entries take over two seconds.
|
||||||
|
bool FileOps::sliceList() {
|
||||||
|
uint32_t t0 = millis();
|
||||||
|
if (phase_ == 0) {
|
||||||
|
Guard g(lock_);
|
||||||
|
listing_.clear();
|
||||||
|
std::string path = std::string(1, static_cast<char>('0' + SdDrive::of(SD))) + ":" + from_;
|
||||||
|
if (f_opendir(&listDir_, path.c_str()) != FR_OK) {
|
||||||
|
status_.error = from_ + " isn't a folder";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
listOpen_ = true;
|
||||||
|
phase_ = 1;
|
||||||
|
}
|
||||||
|
while (millis() - t0 < kSliceMs) {
|
||||||
|
if (cancel_) return true;
|
||||||
|
FILINFO info;
|
||||||
|
if (f_readdir(&listDir_, &info) != FR_OK || info.fname[0] == 0) return true;
|
||||||
|
bool folder = info.fattrib & AM_DIR;
|
||||||
|
Guard g(lock_);
|
||||||
|
listing_.add(info.fname, folder ? 0 : static_cast<uint32_t>(info.fsize), fatTime(info.fdate, info.ftime), folder);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// What's inside, for "Delete saved and its 42 files?" (Q132) and for a copy's total.
|
||||||
|
bool FileOps::sliceCount() {
|
||||||
|
uint32_t t0 = millis();
|
||||||
|
if (phase_ == 0) {
|
||||||
|
phase_ = 1;
|
||||||
|
fs::File f = SD.open(from_.c_str());
|
||||||
|
if (!f) {
|
||||||
|
fail("It isn't there any more");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!f.isDirectory()) {
|
||||||
|
files_ = 1;
|
||||||
|
countBytes_ = static_cast<uint32_t>(f.size());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
f.close();
|
||||||
|
enter(from_);
|
||||||
|
}
|
||||||
|
while (!dirs_.empty() && millis() - t0 < kSliceMs) {
|
||||||
|
if (cancel_) return true;
|
||||||
|
fs::File f = dirs_.back().openNextFile();
|
||||||
|
if (!f) {
|
||||||
|
dirs_.back().close();
|
||||||
|
dirs_.pop_back();
|
||||||
|
paths_.pop_back();
|
||||||
|
} else if (f.isDirectory()) {
|
||||||
|
std::string sub = joinPath(paths_.back(), f.name());
|
||||||
|
f.close();
|
||||||
|
enter(sub);
|
||||||
|
} else {
|
||||||
|
files_++;
|
||||||
|
countBytes_ += static_cast<uint32_t>(f.size());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dirs_.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deletes files as the walk meets them and each folder once it's empty. Also how a cancelled or
|
||||||
|
// failed copy takes back what it had written (undo_).
|
||||||
|
bool FileOps::sliceDelete() {
|
||||||
|
uint32_t t0 = millis();
|
||||||
|
if (phase_ == 0) {
|
||||||
|
phase_ = 1;
|
||||||
|
fs::File f = SD.open(from_.c_str());
|
||||||
|
if (!f) {
|
||||||
|
if (!undo_) fail("It isn't there any more");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
bool folder = f.isDirectory();
|
||||||
|
f.close();
|
||||||
|
if (!folder) {
|
||||||
|
if (!SD.remove(from_.c_str())) fail("The card refused to delete it");
|
||||||
|
else if (!undo_) files_ = 1;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
enter(from_);
|
||||||
|
}
|
||||||
|
while (!dirs_.empty() && millis() - t0 < kSliceMs) {
|
||||||
|
if (cancel_ && !undo_) return true; // what's deleted stays deleted
|
||||||
|
fs::File f = dirs_.back().openNextFile();
|
||||||
|
if (!f) {
|
||||||
|
std::string done = paths_.back();
|
||||||
|
dirs_.back().close();
|
||||||
|
dirs_.pop_back();
|
||||||
|
paths_.pop_back();
|
||||||
|
if (!SD.rmdir(done.c_str())) {
|
||||||
|
fail("The card refused to delete " + baseName(done));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else if (f.isDirectory()) {
|
||||||
|
std::string sub = joinPath(paths_.back(), f.name());
|
||||||
|
f.close();
|
||||||
|
if (!enter(sub)) {
|
||||||
|
fail("The card refused to open " + baseName(sub));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
std::string path = joinPath(paths_.back(), f.name());
|
||||||
|
f.close();
|
||||||
|
if (!SD.remove(path.c_str())) {
|
||||||
|
fail("The card refused to delete " + baseName(path));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!undo_) files_++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dirs_.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phases 0 and 1 count the source (the walk of Count), 2 checks and prepares, 3 copies, 4 takes
|
||||||
|
// a failed or cancelled copy back.
|
||||||
|
bool FileOps::sliceCopy() {
|
||||||
|
uint32_t t0 = millis();
|
||||||
|
if (phase_ <= 1) {
|
||||||
|
if (!sliceCount()) return false;
|
||||||
|
phase_ = 2;
|
||||||
|
}
|
||||||
|
if (phase_ == 2) {
|
||||||
|
{
|
||||||
|
Guard g(lock_);
|
||||||
|
if (!status_.error.empty()) return true;
|
||||||
|
status_.totalBytes = countBytes_;
|
||||||
|
}
|
||||||
|
if (cancel_) return true;
|
||||||
|
files_ = 0;
|
||||||
|
StorageState card = storage_.state();
|
||||||
|
if (card.totalBytes - card.usedBytes < static_cast<uint64_t>(countBytes_) + 64 * 1024) {
|
||||||
|
fail("Not enough room on the card");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (SD.exists(to_.c_str())) {
|
||||||
|
if (!replace_) {
|
||||||
|
Guard g(lock_);
|
||||||
|
status_.exists = true;
|
||||||
|
status_.error = baseName(to_) + " exists already";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (sourceIsFolder_) {
|
||||||
|
fail("A folder with that name is in the way");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
SD.remove(to_.c_str());
|
||||||
|
}
|
||||||
|
if (sourceIsFolder_) {
|
||||||
|
if (!SD.mkdir(to_.c_str()) || !enter(from_)) {
|
||||||
|
fail("The card refused to make the folder");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
in_ = SD.open(from_.c_str(), FILE_READ);
|
||||||
|
outPath_ = to_;
|
||||||
|
out_ = SD.open(outPath_.c_str(), FILE_WRITE);
|
||||||
|
fileBytes_ = 0;
|
||||||
|
fileSize_ = in_ ? static_cast<uint32_t>(in_.size()) : 0;
|
||||||
|
if (!in_ || !out_) {
|
||||||
|
fail("The card refused to open the files");
|
||||||
|
phase_ = 4;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
phase_ = 3;
|
||||||
|
}
|
||||||
|
if (phase_ == 3) {
|
||||||
|
std::unique_ptr<uint8_t[]> piece(new uint8_t[kPiece]);
|
||||||
|
while (millis() - t0 < kSliceMs) {
|
||||||
|
if (cancel_) {
|
||||||
|
phase_ = 4;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (in_) { // a file in progress
|
||||||
|
int n = in_.read(piece.get(), kPiece);
|
||||||
|
if (n > 0 && out_.write(piece.get(), n) != static_cast<size_t>(n)) n = -1;
|
||||||
|
if (n < 0) {
|
||||||
|
fail("The card refused a write");
|
||||||
|
phase_ = 4;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (n > 0) {
|
||||||
|
doneBytes_ += n;
|
||||||
|
fileBytes_ += n;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
in_.close();
|
||||||
|
out_.close();
|
||||||
|
// Q133: the sizes are compared. A Log that grew meanwhile is copied as far as it went.
|
||||||
|
fs::File check = SD.open(outPath_.c_str(), FILE_READ);
|
||||||
|
bool same = check && static_cast<uint32_t>(check.size()) == fileBytes_ && fileBytes_ >= fileSize_;
|
||||||
|
if (check) check.close();
|
||||||
|
if (!same) {
|
||||||
|
fail("The copy of " + baseName(outPath_) + " isn't the size of the original");
|
||||||
|
phase_ = 4;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
files_++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (dirs_.empty()) return true; // a single file, or the whole folder: done
|
||||||
|
fs::File f = dirs_.back().openNextFile();
|
||||||
|
// The copy sits where the original does, relative to the two roots.
|
||||||
|
auto target = [&](const std::string& source) { return to_ + source.substr(from_.size()); };
|
||||||
|
if (!f) {
|
||||||
|
dirs_.back().close();
|
||||||
|
dirs_.pop_back();
|
||||||
|
paths_.pop_back();
|
||||||
|
} else if (f.isDirectory()) {
|
||||||
|
std::string sub = joinPath(paths_.back(), f.name());
|
||||||
|
f.close();
|
||||||
|
if (!SD.mkdir(target(sub).c_str()) || !enter(sub)) {
|
||||||
|
fail("The card refused to make " + baseName(sub));
|
||||||
|
phase_ = 4;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
std::string source = joinPath(paths_.back(), f.name());
|
||||||
|
f.close();
|
||||||
|
in_ = SD.open(source.c_str(), FILE_READ);
|
||||||
|
outPath_ = target(source);
|
||||||
|
out_ = SD.open(outPath_.c_str(), FILE_WRITE);
|
||||||
|
fileBytes_ = 0;
|
||||||
|
fileSize_ = in_ ? static_cast<uint32_t>(in_.size()) : 0;
|
||||||
|
if (!in_ || !out_) {
|
||||||
|
fail("The card refused to open " + baseName(source));
|
||||||
|
phase_ = 4;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Phase 4: take it back. The copy never replaced a folder, so everything under to_ is ours.
|
||||||
|
if (!undo_) {
|
||||||
|
closeAll();
|
||||||
|
undo_ = true;
|
||||||
|
from_ = to_;
|
||||||
|
phase_ = 0;
|
||||||
|
op_ = Op::Delete; // finish() reports it as the copy it was: status_.op stays Copy
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace roro
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <FS.h>
|
||||||
|
#include <ff.h>
|
||||||
|
#include <freertos/FreeRTOS.h>
|
||||||
|
#include <freertos/semphr.h>
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <functional>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "file_list.h"
|
||||||
|
#include "services/storage_service.h"
|
||||||
|
|
||||||
|
namespace roro {
|
||||||
|
|
||||||
|
// What the Storage App does to the card (docs/milestones/F1.md): list a folder, count what's in
|
||||||
|
// one, copy, move, delete, make a folder. One operation at a time, on the storage task like every
|
||||||
|
// card access, **in slices of about 150 ms**: a long copy re-queues itself between slices, so IRC
|
||||||
|
// Logs, a Gemini page streaming to the card or a Capture are written in between and nobody waits
|
||||||
|
// long enough to give up. The read-only rules of Q130 are checked here, whoever asks.
|
||||||
|
class FileOps {
|
||||||
|
public:
|
||||||
|
enum class Op : uint8_t { None, List, Count, Copy, Move, Delete, MakeFolder };
|
||||||
|
// The files the firmware has open right now that matter for `path` (a folder or not).
|
||||||
|
using InUse = std::function<std::vector<std::string>(const std::string& path, bool folder)>;
|
||||||
|
|
||||||
|
struct Status {
|
||||||
|
Op op = Op::None;
|
||||||
|
bool running = false;
|
||||||
|
bool cancelled = false;
|
||||||
|
bool exists = false; // a copy or move found something with that name: ask, then `replace`
|
||||||
|
std::string error; // why it failed, for a human; empty when it worked
|
||||||
|
uint32_t doneBytes = 0, totalBytes = 0; // a copy's progress
|
||||||
|
uint32_t files = 0; // files copied or deleted so far; counted, for Count
|
||||||
|
uint32_t bytes = 0; // Count: their total size
|
||||||
|
std::string path; // what it was about
|
||||||
|
uint32_t ms = 0; // how long it took, once it has ended
|
||||||
|
};
|
||||||
|
|
||||||
|
FileOps(StorageService& storage, InUse inUse);
|
||||||
|
|
||||||
|
// Each returns "" when the operation started, or why it can't. One at a time.
|
||||||
|
std::string list(const std::string& folder);
|
||||||
|
std::string count(const std::string& path);
|
||||||
|
std::string copy(const std::string& from, bool folder, const std::string& intoFolder, const std::string& name, bool replace = false);
|
||||||
|
std::string move(const std::string& from, bool folder, const std::string& to, bool replace = false);
|
||||||
|
std::string remove(const std::string& path, bool folder);
|
||||||
|
std::string makeFolder(const std::string& path);
|
||||||
|
void cancel() { cancel_ = true; }
|
||||||
|
// Why `path` can't be renamed, moved or deleted, or "": asked before the confirmation.
|
||||||
|
std::string whyReadOnly(const std::string& path, bool folder) const;
|
||||||
|
|
||||||
|
bool busy() const { return busy_; }
|
||||||
|
Status status() const; // while it runs
|
||||||
|
bool finished(Status& out); // true once, when it has ended
|
||||||
|
void takeListing(files::FileList& out); // after a List finished without error
|
||||||
|
|
||||||
|
// For the console, which has no listing to know from: is `path` a folder? Waits for the card.
|
||||||
|
bool isFolder(const std::string& path, bool& exists);
|
||||||
|
|
||||||
|
private:
|
||||||
|
static constexpr uint32_t kSliceMs = 150;
|
||||||
|
static constexpr size_t kPiece = 4096; // Q133
|
||||||
|
|
||||||
|
std::string start(Op op, const std::string& path);
|
||||||
|
void queue();
|
||||||
|
void slice(); // on the storage task
|
||||||
|
bool sliceList(); // each: true when the operation is over
|
||||||
|
bool sliceCount();
|
||||||
|
bool sliceCopy();
|
||||||
|
bool sliceDelete();
|
||||||
|
void fail(const std::string& why);
|
||||||
|
void finish();
|
||||||
|
bool enter(const std::string& path); // push a folder onto the walk
|
||||||
|
void closeAll();
|
||||||
|
|
||||||
|
StorageService& storage_;
|
||||||
|
InUse inUse_;
|
||||||
|
mutable SemaphoreHandle_t lock_; // status_, listing_
|
||||||
|
Status status_;
|
||||||
|
files::FileList listing_;
|
||||||
|
std::atomic<bool> busy_{false}, done_{false}, cancel_{false};
|
||||||
|
std::atomic<uint32_t> doneBytes_{0}, files_{0};
|
||||||
|
|
||||||
|
// The operation in hand: only the storage task touches these while it runs.
|
||||||
|
Op op_ = Op::None;
|
||||||
|
std::string from_, to_;
|
||||||
|
bool replace_ = false, sourceIsFolder_ = false, undo_ = false;
|
||||||
|
int phase_ = 0;
|
||||||
|
std::vector<fs::File> dirs_; // the folders being walked, outermost first
|
||||||
|
std::vector<std::string> paths_; // their paths
|
||||||
|
FF_DIR listDir_; // List reads the folder straight from FatFs
|
||||||
|
bool listOpen_ = false;
|
||||||
|
fs::File in_, out_; // the file being copied
|
||||||
|
std::string outPath_;
|
||||||
|
uint32_t countBytes_ = 0, startMs_ = 0;
|
||||||
|
uint32_t fileBytes_ = 0, fileSize_ = 0; // copied of the file in hand; its size when opened
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace roro
|
||||||
@@ -53,6 +53,7 @@ class GnssService : public Service {
|
|||||||
std::string startTrack(uint32_t nowMs);
|
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_; }
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
#include <unity.h>
|
||||||
|
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <ctime>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "file_views.h"
|
||||||
|
#include "loratap.h"
|
||||||
|
#include "track.h"
|
||||||
|
|
||||||
|
using namespace roro;
|
||||||
|
using namespace roro::files;
|
||||||
|
|
||||||
|
void setUp() {}
|
||||||
|
void tearDown() {}
|
||||||
|
|
||||||
|
void test_hex_row() {
|
||||||
|
const uint8_t d[] = {'H', 'e', 'l', 'l', 'o', ',', ' ', 'w', 0x00, 0xFF, 'x'};
|
||||||
|
TEST_ASSERT_EQUAL_STRING("00010 4865 6c6c 6f2c 2077 Hello, w", hexRow(0x10, d, 8).c_str());
|
||||||
|
TEST_ASSERT_EQUAL_STRING("00018 00ff 78 ..x", hexRow(0x18, d + 8, 3).c_str());
|
||||||
|
TEST_ASSERT_TRUE(hexRow(0xFFFFF, d, 8).size() <= 38);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A Track as the GNSS Service writes it, fed in pieces of every size: the same summary each time.
|
||||||
|
void test_gpx_summary() {
|
||||||
|
setenv("TZ", "UTC", 1);
|
||||||
|
tzset();
|
||||||
|
std::string file = gnss::gpx::header("test");
|
||||||
|
int64_t t0 = 1791230400; // 2026-10-05 20:00:00 UTC
|
||||||
|
for (int i = 0; i < 50; i++) file += gnss::gpx::point(48.0 + i * 0.0001, 7.0, i % 2 == 0, 200, t0 + i * 5) + "\n";
|
||||||
|
file += gnss::gpx::footer();
|
||||||
|
for (size_t piece : {size_t(1), size_t(7), size_t(64), size_t(1000), file.size()}) {
|
||||||
|
GpxSummary s;
|
||||||
|
for (size_t at = 0; at < file.size(); at += piece) s.feed(file.data() + at, std::min(piece, file.size() - at));
|
||||||
|
TEST_ASSERT_EQUAL_UINT32(50, s.points());
|
||||||
|
TEST_ASSERT_EQUAL_INT64(t0, s.start());
|
||||||
|
TEST_ASSERT_EQUAL_INT64(t0 + 245, s.end());
|
||||||
|
TEST_ASSERT_FLOAT_WITHIN(1.0f, 49 * 11.12f, static_cast<float>(s.meters())); // 0.0001 degree of latitude: 11.12 m
|
||||||
|
}
|
||||||
|
GpxSummary s;
|
||||||
|
s.feed(file.data(), file.size());
|
||||||
|
auto lines = s.lines();
|
||||||
|
TEST_ASSERT_EQUAL_size_t(4, lines.size());
|
||||||
|
TEST_ASSERT_EQUAL_STRING("50 points", lines[0].c_str());
|
||||||
|
TEST_ASSERT_EQUAL_STRING("Started 2026-10-05 20:00", lines[1].c_str());
|
||||||
|
TEST_ASSERT_EQUAL_STRING("Lasted 4 min 05 s", lines[2].c_str());
|
||||||
|
TEST_ASSERT_EQUAL_STRING("Distance 545 m", lines[3].c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_gpx_from_elsewhere_and_not_gpx() {
|
||||||
|
GpxSummary s;
|
||||||
|
std::string other = "<gpx><trk><trkseg><trkpt lon=\"7.0\" lat=\"48.0\"/><trkpt lat=\"48.01\" lon=\"7.0\">\n<ele>1</ele>\n</trkpt></trkseg></trk></gpx>";
|
||||||
|
s.feed(other.data(), other.size());
|
||||||
|
TEST_ASSERT_EQUAL_UINT32(2, s.points());
|
||||||
|
TEST_ASSERT_EQUAL_INT64(0, s.start());
|
||||||
|
TEST_ASSERT_FLOAT_WITHIN(5.0f, 1112.0f, static_cast<float>(s.meters()));
|
||||||
|
TEST_ASSERT_EQUAL_size_t(2, s.lines().size()); // no times: points and distance only
|
||||||
|
GpxSummary none;
|
||||||
|
std::string junk(10000, 'x');
|
||||||
|
none.feed(junk.data(), junk.size());
|
||||||
|
TEST_ASSERT_EQUAL_UINT32(0, none.points());
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_durations() {
|
||||||
|
TEST_ASSERT_EQUAL_STRING("12 s", formatDuration(12).c_str());
|
||||||
|
TEST_ASSERT_EQUAL_STRING("4 min 10 s", formatDuration(250).c_str());
|
||||||
|
TEST_ASSERT_EQUAL_STRING("1 h 02 min", formatDuration(3725).c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
// What the LoRa Capture Service writes reads back the same.
|
||||||
|
void test_pcap_round_trip() {
|
||||||
|
std::vector<uint8_t> file;
|
||||||
|
lora::appendPcapHeader(file);
|
||||||
|
lora::RxInfo rx;
|
||||||
|
rx.frequencyHz = 869525000;
|
||||||
|
rx.bandwidthKHz = 250;
|
||||||
|
rx.spreadingFactor = 11;
|
||||||
|
rx.rssi = -97;
|
||||||
|
rx.snr = -6.25f;
|
||||||
|
rx.noiseFloor = -106;
|
||||||
|
rx.syncWord = 0x2B;
|
||||||
|
const uint8_t payload[] = {1, 2, 3, 4, 5};
|
||||||
|
lora::appendRecord(file, 1791230400, 250000, rx, payload, sizeof payload);
|
||||||
|
lora::appendRecord(file, 1791230401, 0, rx, payload, 0);
|
||||||
|
|
||||||
|
PcapHeader h = parsePcapHeader(file.data(), file.size());
|
||||||
|
TEST_ASSERT_TRUE(h.ok);
|
||||||
|
TEST_ASSERT_EQUAL_UINT32(kLinkLoraTap, h.linkType);
|
||||||
|
size_t at = kPcapHeaderSize;
|
||||||
|
PcapRecord r;
|
||||||
|
TEST_ASSERT_TRUE(parsePcapRecord(file.data() + at, file.size() - at, r));
|
||||||
|
TEST_ASSERT_EQUAL_UINT32(1791230400, r.seconds);
|
||||||
|
TEST_ASSERT_EQUAL_UINT32(250000, r.micros);
|
||||||
|
TEST_ASSERT_EQUAL_UINT32(lora::kLoraTapSize + 5, r.length);
|
||||||
|
lora::RxInfo back;
|
||||||
|
TEST_ASSERT_TRUE(parseLoraTap(file.data() + at + kPcapRecordSize, r.length, back));
|
||||||
|
TEST_ASSERT_EQUAL_UINT32(869525000, back.frequencyHz);
|
||||||
|
TEST_ASSERT_EQUAL_FLOAT(250, back.bandwidthKHz);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(11, back.spreadingFactor);
|
||||||
|
TEST_ASSERT_EQUAL_FLOAT(-97, back.rssi);
|
||||||
|
TEST_ASSERT_EQUAL_FLOAT(-6.25f, back.snr);
|
||||||
|
TEST_ASSERT_EQUAL_FLOAT(-106, back.noiseFloor);
|
||||||
|
TEST_ASSERT_EQUAL_HEX8(0x2B, back.syncWord);
|
||||||
|
at += kPcapRecordSize + r.length;
|
||||||
|
TEST_ASSERT_TRUE(parsePcapRecord(file.data() + at, file.size() - at, r));
|
||||||
|
TEST_ASSERT_EQUAL_UINT32(lora::kLoraTapSize, r.length);
|
||||||
|
at += kPcapRecordSize + r.length;
|
||||||
|
TEST_ASSERT_EQUAL_size_t(file.size(), at);
|
||||||
|
TEST_ASSERT_FALSE(parsePcapRecord(file.data() + at, file.size() - at, r)); // the end
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_not_a_pcap() {
|
||||||
|
const uint8_t junk[24] = {'n', 'o', 'p', 'e'};
|
||||||
|
TEST_ASSERT_FALSE(parsePcapHeader(junk, sizeof junk).ok);
|
||||||
|
TEST_ASSERT_FALSE(parsePcapHeader(junk, 10).ok);
|
||||||
|
const uint8_t huge[16] = {0, 0, 0, 0, 0, 0, 0, 0, 0xFF, 0xFF, 0xFF, 0x7F};
|
||||||
|
PcapRecord r;
|
||||||
|
TEST_ASSERT_FALSE(parsePcapRecord(huge, sizeof huge, r));
|
||||||
|
lora::RxInfo rx;
|
||||||
|
TEST_ASSERT_FALSE(parseLoraTap(junk, 10, rx));
|
||||||
|
}
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
UNITY_BEGIN();
|
||||||
|
RUN_TEST(test_hex_row);
|
||||||
|
RUN_TEST(test_gpx_summary);
|
||||||
|
RUN_TEST(test_gpx_from_elsewhere_and_not_gpx);
|
||||||
|
RUN_TEST(test_durations);
|
||||||
|
RUN_TEST(test_pcap_round_trip);
|
||||||
|
RUN_TEST(test_not_a_pcap);
|
||||||
|
return UNITY_END();
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
#include <unity.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstring>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "text_pager.h"
|
||||||
|
|
||||||
|
using namespace roro::files;
|
||||||
|
|
||||||
|
void setUp() {}
|
||||||
|
void tearDown() {}
|
||||||
|
|
||||||
|
static int reads = 0;
|
||||||
|
static size_t biggestRead = 0;
|
||||||
|
|
||||||
|
static TextPager pagerFor(const std::string& text, int cols, int rows) {
|
||||||
|
return TextPager(
|
||||||
|
[&text](uint32_t offset, uint8_t* into, size_t len) {
|
||||||
|
reads++;
|
||||||
|
biggestRead = std::max(biggestRead, len);
|
||||||
|
if (offset >= text.size()) return static_cast<size_t>(0);
|
||||||
|
size_t n = std::min(len, text.size() - offset);
|
||||||
|
std::memcpy(into, text.data() + offset, n);
|
||||||
|
return n;
|
||||||
|
},
|
||||||
|
static_cast<uint32_t>(text.size()), cols, rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_lines_and_wrapping() {
|
||||||
|
std::string text = "one\r\ntwo words that are too long\n\nlast";
|
||||||
|
TextPager p = pagerFor(text, 10, 8);
|
||||||
|
auto lines = p.lines();
|
||||||
|
TEST_ASSERT_EQUAL_size_t(6, lines.size());
|
||||||
|
TEST_ASSERT_EQUAL_STRING("one", lines[0].c_str()); // the \r is dropped
|
||||||
|
TEST_ASSERT_EQUAL_STRING("two words", lines[1].c_str());
|
||||||
|
TEST_ASSERT_EQUAL_STRING("that are", lines[2].c_str());
|
||||||
|
TEST_ASSERT_EQUAL_STRING("too long", lines[3].c_str());
|
||||||
|
TEST_ASSERT_EQUAL_STRING("", lines[4].c_str());
|
||||||
|
TEST_ASSERT_EQUAL_STRING("last", lines[5].c_str());
|
||||||
|
TEST_ASSERT_TRUE(p.atEnd());
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_a_word_too_long_is_cut_and_a_space_at_the_edge_is_eaten() {
|
||||||
|
std::string text = "abcdefghijklmno\nabcde fghij klm";
|
||||||
|
TextPager p = pagerFor(text, 5, 8);
|
||||||
|
auto lines = p.lines();
|
||||||
|
TEST_ASSERT_EQUAL_STRING("abcde", lines[0].c_str());
|
||||||
|
TEST_ASSERT_EQUAL_STRING("fghij", lines[1].c_str());
|
||||||
|
TEST_ASSERT_EQUAL_STRING("klmno", lines[2].c_str());
|
||||||
|
TEST_ASSERT_EQUAL_STRING("abcde", lines[3].c_str());
|
||||||
|
TEST_ASSERT_EQUAL_STRING("fghij", lines[4].c_str());
|
||||||
|
TEST_ASSERT_EQUAL_STRING("klm", lines[5].c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_accents_count_as_one_column_and_tabs_show_as_spaces() {
|
||||||
|
std::string text = "\xC3\xA9t\xC3\xA9 ici\n\ta\x01";
|
||||||
|
TextPager p = pagerFor(text, 3, 4);
|
||||||
|
auto lines = p.lines();
|
||||||
|
TEST_ASSERT_EQUAL_STRING("\xC3\xA9t\xC3\xA9", lines[0].c_str());
|
||||||
|
TEST_ASSERT_EQUAL_STRING("ici", lines[1].c_str());
|
||||||
|
TEST_ASSERT_EQUAL_STRING(" a.", lines[2].c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scrolling down to the end and back up passes through the same line starts.
|
||||||
|
void test_up_undoes_down() {
|
||||||
|
std::string text;
|
||||||
|
for (int i = 0; i < 300; i++) {
|
||||||
|
text += "line " + std::to_string(i) + " with some words to wrap around the edge of the screen";
|
||||||
|
text += i % 7 == 0 ? "\n\n" : "\n";
|
||||||
|
}
|
||||||
|
text += "a paragraph without any newline for a long while ";
|
||||||
|
for (int i = 0; i < 200; i++) text += "word" + std::to_string(i) + " ";
|
||||||
|
text += "\nthe end";
|
||||||
|
TextPager p = pagerFor(text, 38, 9);
|
||||||
|
std::vector<uint32_t> tops;
|
||||||
|
for (int guard = 0; guard < 5000 && !p.atEnd(); guard++) {
|
||||||
|
tops.push_back(p.top());
|
||||||
|
p.down();
|
||||||
|
}
|
||||||
|
TEST_ASSERT_TRUE(p.atEnd());
|
||||||
|
TEST_ASSERT_TRUE(tops.size() > 500);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("the end", p.lines().back().c_str());
|
||||||
|
// Inside the long paragraph (further than the look-back from its start) lines may wrap from
|
||||||
|
// another place on the way up. Before it, every line start met on the way down is met again.
|
||||||
|
size_t paragraph = text.find("a paragraph without");
|
||||||
|
size_t expected = 0, met = 0;
|
||||||
|
for (uint32_t t : tops) expected += t <= paragraph;
|
||||||
|
for (int guard = 0; guard < 5000 && p.top() > 0; guard++) {
|
||||||
|
p.up();
|
||||||
|
if (p.top() > paragraph) continue;
|
||||||
|
TEST_ASSERT_TRUE(std::binary_search(tops.begin(), tops.end(), p.top()));
|
||||||
|
met++;
|
||||||
|
}
|
||||||
|
TEST_ASSERT_EQUAL_size_t(expected, met);
|
||||||
|
TEST_ASSERT_EQUAL_UINT32(0, p.top());
|
||||||
|
p.up();
|
||||||
|
TEST_ASSERT_EQUAL_UINT32(0, p.top());
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_to_end_shows_the_last_lines() {
|
||||||
|
std::string text;
|
||||||
|
for (int i = 0; i < 100; i++) text += "20:0" + std::to_string(i % 10) + " <a> message " + std::to_string(i) + "\n";
|
||||||
|
TextPager p = pagerFor(text, 38, 9);
|
||||||
|
p.toEnd();
|
||||||
|
auto lines = p.lines();
|
||||||
|
TEST_ASSERT_EQUAL_size_t(9, lines.size());
|
||||||
|
TEST_ASSERT_EQUAL_STRING("20:09 <a> message 99", lines[8].c_str());
|
||||||
|
TEST_ASSERT_EQUAL_STRING("20:01 <a> message 91", lines[0].c_str());
|
||||||
|
TEST_ASSERT_TRUE(p.atEnd());
|
||||||
|
p.down(); // nowhere to go
|
||||||
|
TEST_ASSERT_EQUAL_STRING("20:01 <a> message 91", p.lines()[0].c_str());
|
||||||
|
TEST_ASSERT_TRUE(p.percent() > 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_a_megabyte_is_read_a_kilobyte_at_a_time() {
|
||||||
|
std::string text;
|
||||||
|
while (text.size() < 1024 * 1024) text += "2026-10-05 20:00:00 <someone> says something here\n";
|
||||||
|
reads = 0;
|
||||||
|
biggestRead = 0;
|
||||||
|
TextPager p = pagerFor(text, 38, 9);
|
||||||
|
p.toEnd();
|
||||||
|
TEST_ASSERT_EQUAL_size_t(9, p.lines().size());
|
||||||
|
p.up(20);
|
||||||
|
p.down(5);
|
||||||
|
TEST_ASSERT_TRUE(biggestRead <= 1024);
|
||||||
|
TEST_ASSERT_TRUE(reads < 20);
|
||||||
|
p.toStart();
|
||||||
|
TEST_ASSERT_EQUAL_STRING("2026-10-05 20:00:00 <someone> says", p.lines()[0].c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_empty_and_short_files() {
|
||||||
|
std::string empty;
|
||||||
|
TextPager p = pagerFor(empty, 38, 9);
|
||||||
|
TEST_ASSERT_EQUAL_size_t(0, p.lines().size());
|
||||||
|
p.down();
|
||||||
|
p.up();
|
||||||
|
p.toEnd();
|
||||||
|
TEST_ASSERT_TRUE(p.atEnd());
|
||||||
|
std::string one = "no newline";
|
||||||
|
TextPager q = pagerFor(one, 38, 9);
|
||||||
|
q.toEnd();
|
||||||
|
TEST_ASSERT_EQUAL_STRING("no newline", q.lines()[0].c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
UNITY_BEGIN();
|
||||||
|
RUN_TEST(test_lines_and_wrapping);
|
||||||
|
RUN_TEST(test_a_word_too_long_is_cut_and_a_space_at_the_edge_is_eaten);
|
||||||
|
RUN_TEST(test_accents_count_as_one_column_and_tabs_show_as_spaces);
|
||||||
|
RUN_TEST(test_up_undoes_down);
|
||||||
|
RUN_TEST(test_to_end_shows_the_last_lines);
|
||||||
|
RUN_TEST(test_a_megabyte_is_read_a_kilobyte_at_a_time);
|
||||||
|
RUN_TEST(test_empty_and_short_files);
|
||||||
|
return UNITY_END();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user