Public Access
Shell: Tab completes every word of a command, and * and ? stand for several files (#67)
Tab used to complete a command's first word only. It now follows the help text word by word: `lora st` gives `lora status`, `gnss track ` lists `start stop`. The words are read from the help text as written, so a new command completes with no table to keep; the Shell's own words are added in the same notation. `*` and `?` in the last part of a path, for ls, du, rm, cp and mv, from the Shell and both consoles. The command runs once for each name matched, lined up and run by the main loop as each finishes; `cancel` empties the line-up. 64 matches at most, refused whole past that. In the Shell, rm with a pattern asks once, with the count. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
@@ -158,7 +158,7 @@ A note holds up to 16 KB while it's edited. A bigger text file opens read-only i
|
||||
|
||||
## Shell
|
||||
|
||||
The Shell App (docs/milestones/S1.md) runs the commands below on the device's own screen and keyboard: no PC, no cable, no Wi-Fi. **It shows the replies to its own commands and nothing else**: the console knows who each line is printed for, so a listing read by another task a moment later is still the Shell's, and what USB or the Debug Console asked for is not. Ctrl+b shows everything instead. Tab completes a command's name and, past it, a path on the SD card (`ls /no` gives `ls /notes/`), Fn with up and down recalls earlier lines, Alt with up and down scrolls back. **An App's name with a capital opens it** (`Notes`, `Irc`, `Wifi`, `Gnss`, `Gemini`, `Lora`, `Storage`, `System`, `Settings`), from the consoles too. `rm` is Unix's, with a question: a folder needs `-r`; a file, or a folder with something in it, is asked about unless `-f` (`rm -rf`); an empty folder goes without a word. `clear` empties the screen and `quit` leaves. It is trusted like the USB port: `debug on` and `debug token` work from it. It uses about 7 KB of memory while it is open, and none otherwise.
|
||||
The Shell App (docs/milestones/S1.md) runs the commands below on the device's own screen and keyboard: no PC, no cable, no Wi-Fi. **It shows the replies to its own commands and nothing else**: the console knows who each line is printed for, so a listing read by another task a moment later is still the Shell's, and what USB or the Debug Console asked for is not. Ctrl+b shows everything instead. Tab completes a command word by word (`lora st` gives `lora status`) and, past it, a path on the SD card (`ls /no` gives `ls /notes/`), Fn with up and down recalls earlier lines, Alt with up and down scrolls back. **An App's name with a capital opens it** (`Notes`, `Irc`, `Wifi`, `Gnss`, `Gemini`, `Lora`, `Storage`, `System`, `Settings`), from the consoles too. `rm` is Unix's, with a question: a folder needs `-r`; a file, or a folder with something in it, is asked about unless `-f` (`rm -rf`); an empty folder goes without a word. `*` and `?` in a name stand for several files (`rm /notes/*.txt` asks once, with the count; 64 at most). `clear` empties the screen and `quit` leaves. It is trusted like the USB port: `debug on` and `debug token` work from it. It uses about 7 KB of memory while it is open, and none otherwise.
|
||||
|
||||
## Development aids
|
||||
|
||||
@@ -193,7 +193,7 @@ The Shell App (docs/milestones/S1.md) runs the commands below on the device's ow
|
||||
| `log level <0-5>` | ESP-IDF log level |
|
||||
| `ls [folder]` / `du <path>` | Lists a folder of the SD card with sizes and dates, or counts the files and bytes under a path |
|
||||
| `screenshot [seconds]` | The screen as a PNG in `/screenshots` on the card, now or after a pause to get to the screen you want (240 x 135, about 33 KB). Over the Debug Console a bare `screenshot` sends the screen to the PC instead |
|
||||
| `cp [-f] <from> <to>` / `mv [-f] <from> <to>` / `rm [-r] [-f] <path>` / `mkdir <path>` / `cancel` | What the Storage App does, with its rules: copy (folders too), move or rename, delete (`rm -r` for a folder and what's in it, as Unix has 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 |
|
||||
| `cp [-f] <from> <to>` / `mv [-f] <from> <to>` / `rm [-r] [-f] <path>` / `mkdir <path>` / `cancel` | What the Storage App does, with its rules: copy (folders too), move or rename, delete (`rm -r` for a folder and what's in it, as Unix has it), new folder. `-f` replaces a file that's in the way; `*` and `?` in the last part of a path (`ls`, `du`, `rm`, `cp`, `mv`) run the command for each name matched, 64 at most; 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 |
|
||||
| `update check` / `update list` / `update status` / `update install <tag>` | The project's releases on Gitea: look at the latest, list the last ten, say what's known, or download and install one |
|
||||
| `update pretend <version>` / `update probe <host>` / `update damage cut\|flip <n>` / `update daily` | Pretend to run another version (so a release counts as an update), see whether a server's certificate is accepted, cut or damage the next download, run the daily check again |
|
||||
|
||||
+12
-6
@@ -149,8 +149,8 @@ The console's commands could only be typed on a PC: over USB, or over Wi-Fi with
|
||||
| Q205 | **Trusted like USB serial**, not like the network: `debug on` and `debug token` work from it, as they do in Settings. The token is never shown. |
|
||||
| Q206 | *Revised the same day, after trying it.* **It shows the replies to its own commands, and only those.** The console knows who each line is printed for (`Console::Origin`): a command run from the Shell prints as the Shell's, and so does what answers it later from another task, which notes who asked and takes it back when it prints (`ls`, `tasks`, `du`, `cp`, `update check`, `sd list`, `screenshot`, `gemini get`). What USB or the Debug Console asked for, and the system's own lines, are not the Shell's. **Ctrl+b shows everything** instead. The first version kept whatever was printed in the ten seconds after a command, which was a guess, and a noisy one. |
|
||||
| Q207 | **Nothing while it's closed.** Open, a 4 KB ring of the console's and up to 4 KB of lines; both go when the App is left, with the list of commands for Tab. |
|
||||
| Q208 | Enter runs the line; Fn with up and down recalls the last 16; Alt with up and down scrolls back. **Tab completes** the command's first word, from the firmware's `help` text, and *(added the same day)* **past it a path on the SD card**: a folder keeps its slash to go on from, a file completed whole gets a space, several candidates are listed. Whatever the case typed, the name's own is taken, since the card doesn't tell them apart. After a file command the first slash is understood (`cat no` is `/no`). A name with a space in it isn't completed. |
|
||||
| Q209 | *Revised the same day.* **`rm` is Unix's, with a question.** A folder needs `-r`, here and over the consoles (where `rm <folder>` used to remove it with what was in it). In the Shell, a file, or a folder with something in it, is asked about unless `-f` (`-rf`, `-r -f`); an empty folder with `-r` goes without a word; what `rm` would refuse anyway, it refuses itself. Over the consoles nothing is asked: scripts delete as before. **`screenshot [seconds]`** saves the screen as a PNG in `/screenshots`, now or after a pause, since from the Shell "now" is the Shell. `get`, `put`, `coredump get` and `reset` answer `Debug Console only`. |
|
||||
| Q208 | Enter runs the line; Fn with up and down recalls the last 16; Alt with up and down scrolls back. **Tab completes** the command, **every word of it** *(the first only, at first)*: `lora st` gives `lora status`, `gnss track ` lists `start stop`, `key ` its eleven names. The words come from the firmware's `help` text, read as it is written, so a new command completes without a table to keep. *(Added the same day)* **past the command, a path on the SD card**: a folder keeps its slash to go on from, a file completed whole gets a space, several candidates are listed. Whatever the case typed, the name's own is taken, since the card doesn't tell them apart. After a file command the first slash is understood (`cat no` is `/no`). A name with a space in it isn't completed. |
|
||||
| Q209 | *Revised the same day.* **`rm` is Unix's, with a question.** A folder needs `-r`, here and over the consoles (where `rm <folder>` used to remove it with what was in it). In the Shell, a file, or a folder with something in it, is asked about unless `-f` (`-rf`, `-r -f`); an empty folder with `-r` goes without a word; what `rm` would refuse anyway, it refuses itself. Over the consoles nothing is asked: scripts delete as before. **`screenshot [seconds]`** saves the screen as a PNG in `/screenshots`, now or after a pause, since from the Shell "now" is the Shell. `get`, `put`, `coredump get` and `reset` answer `Debug Console only`. *(Added the same day)* **`*` and `?` in a name**, for `ls`, `du`, `rm`, `cp` and `mv`, here and over the consoles: `rm /notes/*.txt`, `cp /gnss/2026-10-0?.gpx /backup`. In the last part of the path only, any case, as the card has it. It is the same command once for each name matched, in the name's order; `cp` and `mv` then need a folder that exists to put them in. Over 64 matches is refused whole, as is none. In the Shell `rm` with a pattern asks **once**, with the count. |
|
||||
| Q210 | Commands are echoed as `> command` into the console, so a session reads the same from afar. **A token being set is not echoed.** |
|
||||
| Q211 | **Not in Safe Mode**, which starts no Apps: issue #77. |
|
||||
| Q212 | Its keys are a table in `app_keys.h`, so the help panel and the website have them; a page in the user guide. |
|
||||
@@ -158,13 +158,14 @@ The console's commands could only be typed on a PC: over USB, or over Wi-Fi with
|
||||
|
||||
### As built
|
||||
|
||||
- **`ShellApp`** (`src/apps/shell_app.cpp`), with its model host-tested in `lib/apps_model/src/shell_log.h`: lines arriving in pieces, the 4 KB limit, the command words out of the `help` text (Apps' names included), and Tab.
|
||||
- **`ShellApp`** (`src/apps/shell_app.cpp`), with its model host-tested in `lib/apps_model/src/shell_log.h`: lines arriving in pieces, the 4 KB limit, the words of every command out of the `help` text (Apps' names included), and Tab.
|
||||
- **Who a line is for:** `Console::As` marks the calling task as printing for the Shell while it lives, and `Console::origin()` lets a command that answers later carry that to wherever it prints. The console has a second ring for the Shell, which gets the lines marked so, or everything.
|
||||
- **The Shell hands its lines to the main loop**, which runs them like the consoles' commands. See below for why.
|
||||
- **`info` says which App is in front** (`app: Shell`): so that a hand driving the device from afar can look before it types.
|
||||
- **`screenshot`** writes the PNG a row at a time with no buffer: 8-bit indexed colour, the 256 colours of RGB332 as the palette, the pixels in one stored deflate block (`lib/files/src/png_rgb332.h`, 4 tests). 33,383 bytes for the 240 x 135 screen. A Toast says so once it is on the card, so the Toast is never in the picture.
|
||||
- **The `help` text lost its shorthand** (`update check | list | status` is written out), since Tab reads its first words from there.
|
||||
- **The `help` text lost its shorthand** (`update check | list | status` is written out), since Tab reads its words from there: `|` between two commands, `on|off` between two words, three spaces before the description. The Shell's own words (`clear`, `quit`, `key`'s names) are added in the same notation.
|
||||
- **Tab on a path** reads the folder on the storage task while the main loop waits, so it is bounded: 400 entries looked at, 24 candidates given back, and `...` after the list when there were more.
|
||||
- **A pattern** is matched in `lib/files/src/file_names.h` (`globMatch`, host-tested), and the names it matches are lined up as so many commands, which the main loop runs one after the other as each finishes. `cancel` empties the line-up. 2000 entries looked at, 64 matches at most.
|
||||
- **Cost:** 21 KB of flash, 96 bytes of static RAM. Open: 7 KB of heap (107.6 KB free before, 100.7 with it open, 106.1 after leaving).
|
||||
|
||||
### What went wrong while building it
|
||||
@@ -175,7 +176,6 @@ The device restarted into the Launcher, and the test script, which did not look,
|
||||
|
||||
Two changes came of it. The Shell now **queues** its line and the main loop runs it, at the same stack depth as a console's command. And `info` reports the App in front, which the test script now checks before every line it types.
|
||||
|
||||
### Checks on the device (2026-10-07, driven over the Debug Console with `key`)
|
||||
### Checks on the device (2026-10-07, driven over the Debug Console with `key`)
|
||||
|
||||
| Check | Result |
|
||||
@@ -190,9 +190,15 @@ Two changes came of it. The Shell now **queues** its line and the main loop runs
|
||||
| `rm -r` on a folder with files | Asks; Cancel leaves it. `rm -rf` removes it without asking |
|
||||
| `rm` on a file | Asks; Delete removes it |
|
||||
| Tab on a path | `ls /no` becomes `ls /notes/`, and again, with one note in it, the note's whole name and a space. `ls /g` lists `gemini/ gnss/`. `cat no` becomes `cat /notes/`. `rm -r /CAP` becomes `rm -r /captures/`. `ls /zz` stays as it is |
|
||||
| Tab past the first word | `lora st` becomes `lora status `; `gnss tr` becomes `gnss track ` and Tab again lists `start stop`; `key ` lists its eleven names; `upd` becomes `update ` and lists `check list status install` |
|
||||
| `ls` with a pattern | `ls /gt/*.txt` the five files, `ls /gt/*2*` the one, `ls /gt3/*6?.txt` ten of seventy. `ls /g*/x`: refused, a pattern goes in the last part |
|
||||
| `cp /gt/file-000?.txt /gt2`, `du /gt2/*1*` | Five copies; one size |
|
||||
| `rm /gt2/*` in the Shell | "The 5 that match /gt2/\*": Cancel leaves the five. `rm /gt3/*6?.txt`, Delete: ten gone, sixty left. `rm -f /gt2/*`: gone without a question |
|
||||
| `rm /gt/*` where one match is a folder | The files go, the folder stays (no `-r`) |
|
||||
| No match, and seventy | `rm: error nothing matches`; `rm: error more than 64 match: a narrower pattern, please`, and all seventy still there |
|
||||
| `No`, Tab, Enter | Completes to `Notes ` and opens Notes. `Shell` from the Debug Console opens the Shell |
|
||||
| `screenshot 4`, then Home | The picture, taken four seconds later, is of the Launcher: the pause works, and the Toast isn't in it |
|
||||
| `quit` | Back to the Launcher, and the memory comes back |
|
||||
| The help panel in the Shell | Its keys, then the ones that work everywhere |
|
||||
|
||||
**Not checked:** Ctrl+b and the Alt scroll, which `key` can't press (no modifiers): the filter itself is host-tested, the key that flips it is not; the real keyboard altogether; and the Toast after a screenshot, which was published but not looked at.
|
||||
**Not checked:** Ctrl+b and the Alt scroll, which `key` can't press (no modifiers): the filter itself is host-tested, the key that flips it is not; the real keyboard altogether; the Toast after a screenshot, which was published but not looked at; and `mv` with a pattern and `cancel` in the middle of a line-up, which share their code with `cp` and `rm` but were not run. The main loop's lowest free stack after all of it: 1.8 KB, where it was.
|
||||
|
||||
@@ -36,56 +36,79 @@ void ShellLog::clear() {
|
||||
revision_++;
|
||||
}
|
||||
|
||||
std::vector<std::string> commandWords(const char* helpText) {
|
||||
std::vector<std::string> words;
|
||||
std::string text = helpText ? helpText : "";
|
||||
size_t lineStart = 0;
|
||||
while (lineStart < text.size()) {
|
||||
size_t lineEnd = text.find('\n', lineStart);
|
||||
if (lineEnd == std::string::npos) lineEnd = text.size();
|
||||
std::string line = text.substr(lineStart, lineEnd - lineStart);
|
||||
// The commands stop where the description starts: three spaces.
|
||||
size_t gap = line.find(" ");
|
||||
std::string commands = line.substr(0, gap);
|
||||
size_t at = 0;
|
||||
while (at <= commands.size()) {
|
||||
size_t bar = commands.find(" | ", at);
|
||||
std::string one = commands.substr(at, bar == std::string::npos ? std::string::npos : bar - at);
|
||||
size_t start = one.find_first_not_of(' ');
|
||||
if (start != std::string::npos) {
|
||||
size_t end = one.find(' ', start);
|
||||
std::string word = one.substr(start, end == std::string::npos ? std::string::npos : end - start);
|
||||
// Small letters, and for an App's name a capital first.
|
||||
bool plain = !word.empty() && std::all_of(word.begin() + 1, word.end(), [](char ch) { return ch >= 'a' && ch <= 'z'; }) &&
|
||||
((word[0] >= 'a' && word[0] <= 'z') || (word[0] >= 'A' && word[0] <= 'Z'));
|
||||
if (plain && std::find(words.begin(), words.end(), word) == words.end()) words.push_back(word);
|
||||
}
|
||||
if (bar == std::string::npos) break;
|
||||
at = bar + 3;
|
||||
}
|
||||
lineStart = lineEnd + 1;
|
||||
namespace {
|
||||
|
||||
bool isWord(const std::string& w) {
|
||||
if (w.empty()) return false;
|
||||
for (size_t i = 0; i < w.size(); i++) {
|
||||
bool small = w[i] >= 'a' && w[i] <= 'z', capital = w[i] >= 'A' && w[i] <= 'Z';
|
||||
if (!(small || (i == 0 && capital))) return false;
|
||||
}
|
||||
return words;
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string completeCommand(const std::string& typed, const std::vector<std::string>& words, std::vector<std::string>& matches) {
|
||||
matches.clear();
|
||||
if (typed.empty() || typed.find(' ') != std::string::npos) return typed;
|
||||
for (auto& w : words)
|
||||
if (w.rfind(typed, 0) == 0) matches.push_back(w);
|
||||
if (matches.empty()) return typed;
|
||||
if (matches.size() == 1) {
|
||||
std::string only = matches[0] + " ";
|
||||
matches.clear();
|
||||
return only;
|
||||
std::vector<std::string> split(const std::string& text, const std::string& by) {
|
||||
std::vector<std::string> out;
|
||||
size_t at = 0;
|
||||
for (;;) {
|
||||
size_t next = text.find(by, at);
|
||||
out.push_back(text.substr(at, next == std::string::npos ? std::string::npos : next - at));
|
||||
if (next == std::string::npos) return out;
|
||||
at = next + by.size();
|
||||
}
|
||||
}
|
||||
|
||||
// The words a command can have at `index`, given the `index` words before it: added to `out`.
|
||||
void nextWords(const std::string& command, const std::vector<std::string>& before, size_t index, std::vector<std::string>& out) {
|
||||
std::vector<std::string> tokens;
|
||||
for (auto& t : split(command, " "))
|
||||
if (!t.empty()) tokens.push_back(t);
|
||||
for (size_t i = 0; i < tokens.size(); i++) {
|
||||
std::vector<std::string> either = split(tokens[i], "|"); // on|off: either of them
|
||||
bool words = true;
|
||||
for (auto& w : either) words = words && isWord(w);
|
||||
if (!words) return; // an <argument>, an [option], "...": the command's words end here
|
||||
if (i == index) {
|
||||
for (auto& w : either)
|
||||
if (std::find(out.begin(), out.end(), w) == out.end()) out.push_back(w);
|
||||
return;
|
||||
}
|
||||
if (std::find(either.begin(), either.end(), before[i]) == either.end()) return; // another command
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string completeWords(const std::string& typed, const char* helpText, std::vector<std::string>& matches) {
|
||||
matches.clear();
|
||||
std::vector<std::string> words = split(typed, " ");
|
||||
for (size_t i = 0; i + 1 < words.size(); i++)
|
||||
if (words[i].empty()) return typed; // two spaces: not ours to guess
|
||||
const std::string last = words.back();
|
||||
words.pop_back();
|
||||
if (words.empty() && last.empty()) return typed;
|
||||
|
||||
std::vector<std::string> next;
|
||||
for (auto& line : split(helpText ? helpText : "", "\n")) {
|
||||
std::string commands = line.substr(0, line.find(" ")); // the description starts at three spaces
|
||||
for (auto& command : split(commands, " | ")) nextWords(command, words, words.size(), next);
|
||||
}
|
||||
for (auto& w : next)
|
||||
if (w.rfind(last, 0) == 0) matches.push_back(w);
|
||||
if (matches.empty()) return typed;
|
||||
|
||||
std::string common = matches[0];
|
||||
for (auto& m : matches) {
|
||||
size_t n = 0;
|
||||
while (n < common.size() && n < m.size() && common[n] == m[n]) n++;
|
||||
common.resize(n);
|
||||
}
|
||||
return common;
|
||||
std::string head = typed.substr(0, typed.size() - last.size());
|
||||
if (matches.size() == 1) {
|
||||
matches.clear();
|
||||
return head + common + " ";
|
||||
}
|
||||
return head + common;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
@@ -34,14 +34,17 @@ class ShellLog {
|
||||
uint32_t revision_ = 0;
|
||||
};
|
||||
|
||||
// The commands `help` lists: the first word of each (`ls [folder] | du <path>` gives ls and du),
|
||||
// each once, in the order they appear.
|
||||
std::vector<std::string> commandWords(const char* helpText);
|
||||
|
||||
// Tab: `typed` with its first word completed as far as the commands agree. Unchanged when the cursor
|
||||
// is past the first word, or when nothing starts with it. `matches` gets what it could become, when
|
||||
// there is more than one.
|
||||
std::string completeCommand(const std::string& typed, const std::vector<std::string>& words, std::vector<std::string>& matches);
|
||||
// Tab on a command (issue #67): every word of it, read from the firmware's `help` text each time it
|
||||
// is asked, so nothing is kept in memory for it. A line of that text is commands, three spaces, then
|
||||
// what they do; commands are separated by " | ", a word like on|off is either of them, and a command
|
||||
// stops being words at its first <argument>, [option] or "...":
|
||||
// lora rx on|off | lora preset <name> the radio
|
||||
// gives "lora rx on", "lora rx off" and "lora preset". An App's name starts with a capital.
|
||||
//
|
||||
// The line with its last word completed as far as the commands that fit agree; a word completed
|
||||
// whole gets a space after it. `matches` gets the candidates when there are several. Unchanged, with
|
||||
// no matches, when no command goes on that way: then it may be a path (below).
|
||||
std::string completeWords(const std::string& typed, const char* helpText, std::vector<std::string>& matches);
|
||||
|
||||
// Tab on a later word: a path on the SD card (issue #67). What is being completed, taken apart:
|
||||
// `rm -r /notes/sh` is head "rm -r ", folder "/notes/", prefix "sh". False when the cursor is still
|
||||
|
||||
@@ -327,6 +327,7 @@ inline constexpr KeyHelp kNotesName[] = {
|
||||
inline constexpr KeyHelp kShell[] = {
|
||||
{"Enter", "run the line"},
|
||||
{"Tab", "complete: a command, a path"},
|
||||
{"* ?", "several files: /notes/*.txt"},
|
||||
{"Fn ; .", "lines you typed before"},
|
||||
{"Alt ; .", "scroll back, forward"},
|
||||
{"Ctrl b", "your replies only, or all"},
|
||||
|
||||
@@ -115,4 +115,33 @@ RmArgs parseRm(const std::string& args) {
|
||||
return out;
|
||||
}
|
||||
|
||||
bool hasGlob(const std::string& text) { return text.find_first_of("*?") != std::string::npos; }
|
||||
|
||||
bool globMatch(const std::string& pattern, const std::string& name) {
|
||||
auto lower = [](char c) { return c >= 'A' && c <= 'Z' ? static_cast<char>(c - 'A' + 'a') : c; };
|
||||
size_t p = 0, n = 0, star = std::string::npos, mark = 0;
|
||||
while (n < name.size()) {
|
||||
if (p < pattern.size() && (pattern[p] == '?' || lower(pattern[p]) == lower(name[n]))) {
|
||||
p++;
|
||||
n++;
|
||||
} else if (p < pattern.size() && pattern[p] == '*') {
|
||||
star = p++; // try it as nothing first; come back here to let it take one more
|
||||
mark = n;
|
||||
} else if (star != std::string::npos) {
|
||||
p = star + 1;
|
||||
n = ++mark;
|
||||
} else return false;
|
||||
}
|
||||
while (p < pattern.size() && pattern[p] == '*') p++;
|
||||
return p == pattern.size();
|
||||
}
|
||||
|
||||
bool splitGlob(const std::string& path, std::string& folder, std::string& pattern) {
|
||||
size_t slash = path.rfind('/');
|
||||
if (slash == std::string::npos) return false;
|
||||
pattern = path.substr(slash + 1);
|
||||
folder = slash == 0 ? "/" : path.substr(0, slash);
|
||||
return hasGlob(pattern) && !hasGlob(folder);
|
||||
}
|
||||
|
||||
} // namespace roro::files
|
||||
|
||||
@@ -47,4 +47,12 @@ struct RmArgs {
|
||||
};
|
||||
RmArgs parseRm(const std::string& args);
|
||||
|
||||
// Patterns in a path (issue #67): * for any run of characters, ? for one, in the last part of the
|
||||
// path only (/notes/*.txt, not /*/a.txt). Cases aren't told apart, as on the card.
|
||||
bool hasGlob(const std::string& text);
|
||||
bool globMatch(const std::string& pattern, const std::string& name);
|
||||
// "/notes/*.txt" taken apart: the folder ("/notes", or "/" at the top) and the pattern ("*.txt").
|
||||
// False if there's no pattern in it, or if the folder has one too.
|
||||
bool splitGlob(const std::string& path, std::string& folder, std::string& pattern);
|
||||
|
||||
} // namespace roro::files
|
||||
|
||||
@@ -19,7 +19,7 @@ net bytes each network service has read and written since boot
|
||||
reboot restart
|
||||
boot other restart into the other app slot (manual Rollback)
|
||||
log level <0-5> ESP-IDF log level (0 none ... 5 verbose)
|
||||
ls [folder] | du <path> | mkdir <path> | rm [-r] [-f] <path> | cp [-f] <from> <to> | mv [-f] <from> <to> | cancel the SD card, with the Storage App's rules (rm -r for a folder; -f: the Shell doesn't ask)
|
||||
ls [folder] | du <path> | mkdir <path> | rm [-r] [-f] <path> | cp [-f] <from> <to> | mv [-f] <from> <to> | cancel the SD card, with the Storage App's rules (rm -r for a folder; -f: the Shell doesn't ask; * and ? in a name: /notes/*.txt)
|
||||
screenshot [seconds] the screen as a PNG in /screenshots on the card, now or after a pause
|
||||
lora probe | lora status | lora rx on|off | lora preset <name> the LoRa radio, receive only
|
||||
lora capture start|stop a LoRa Capture to /captures/lora (pcap, LoRaTap)
|
||||
@@ -88,7 +88,7 @@ In **Safe Mode** (see [Crashes and Safe Mode](/dev/debug/crashes/)) only a few r
|
||||
| `log level <0-5>` | ESP-IDF log level |
|
||||
| `ls [folder]` / `du <path>` | Lists a folder of the SD card with sizes and dates, or counts the files and bytes under a path |
|
||||
| `screenshot [seconds]` | The screen as a PNG in `/screenshots` on the card, now or after a pause to get to the screen you want (240 x 135, about 33 KB). Over the Debug Console a bare `screenshot` sends the screen to the PC instead |
|
||||
| `cp [-f] <from> <to>` / `mv [-f] <from> <to>` / `rm [-r] [-f] <path>` / `mkdir <path>` / `cancel` | What the Storage App does, with its rules: copy (folders too), move or rename, delete (`rm -r` for a folder and what's in it, as Unix has 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 |
|
||||
| `cp [-f] <from> <to>` / `mv [-f] <from> <to>` / `rm [-r] [-f] <path>` / `mkdir <path>` / `cancel` | What the Storage App does, with its rules: copy (folders too), move or rename, delete (`rm -r` for a folder and what's in it, as Unix has it), new folder. `-f` replaces a file that's in the way; `*` and `?` in the last part of a path (`ls`, `du`, `rm`, `cp`, `mv`) run the command for each name matched, 64 at most; 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 |
|
||||
| `update check` / `update list` / `update status` / `update install <tag>` | The project's releases on Gitea: look at the latest, list the last ten, say what's known, or download and install one |
|
||||
| `update pretend <version>` / `update probe <host>` / `update damage cut\|flip <n>` / `update daily` | Pretend to run another version (so a release counts as an update), see whether a server's certificate is accepted, cut or damage the next download, run the daily check again |
|
||||
|
||||
@@ -157,8 +157,8 @@ The console's commands could only be typed on a PC: over USB, or over Wi-Fi with
|
||||
| Q205 | **Trusted like USB serial**, not like the network: `debug on` and `debug token` work from it, as they do in Settings. The token is never shown. |
|
||||
| Q206 | *Revised the same day, after trying it.* **It shows the replies to its own commands, and only those.** The console knows who each line is printed for (`Console::Origin`): a command run from the Shell prints as the Shell's, and so does what answers it later from another task, which notes who asked and takes it back when it prints (`ls`, `tasks`, `du`, `cp`, `update check`, `sd list`, `screenshot`, `gemini get`). What USB or the Debug Console asked for, and the system's own lines, are not the Shell's. **Ctrl+b shows everything** instead. The first version kept whatever was printed in the ten seconds after a command, which was a guess, and a noisy one. |
|
||||
| Q207 | **Nothing while it's closed.** Open, a 4 KB ring of the console's and up to 4 KB of lines; both go when the App is left, with the list of commands for Tab. |
|
||||
| Q208 | Enter runs the line; Fn with up and down recalls the last 16; Alt with up and down scrolls back. **Tab completes** the command's first word, from the firmware's `help` text, and *(added the same day)* **past it a path on the SD card**: a folder keeps its slash to go on from, a file completed whole gets a space, several candidates are listed. Whatever the case typed, the name's own is taken, since the card doesn't tell them apart. After a file command the first slash is understood (`cat no` is `/no`). A name with a space in it isn't completed. |
|
||||
| Q209 | *Revised the same day.* **`rm` is Unix's, with a question.** A folder needs `-r`, here and over the consoles (where `rm <folder>` used to remove it with what was in it). In the Shell, a file, or a folder with something in it, is asked about unless `-f` (`-rf`, `-r -f`); an empty folder with `-r` goes without a word; what `rm` would refuse anyway, it refuses itself. Over the consoles nothing is asked: scripts delete as before. **`screenshot [seconds]`** saves the screen as a PNG in `/screenshots`, now or after a pause, since from the Shell "now" is the Shell. `get`, `put`, `coredump get` and `reset` answer `Debug Console only`. |
|
||||
| Q208 | Enter runs the line; Fn with up and down recalls the last 16; Alt with up and down scrolls back. **Tab completes** the command, **every word of it** *(the first only, at first)*: `lora st` gives `lora status`, `gnss track ` lists `start stop`, `key ` its eleven names. The words come from the firmware's `help` text, read as it is written, so a new command completes without a table to keep. *(Added the same day)* **past the command, a path on the SD card**: a folder keeps its slash to go on from, a file completed whole gets a space, several candidates are listed. Whatever the case typed, the name's own is taken, since the card doesn't tell them apart. After a file command the first slash is understood (`cat no` is `/no`). A name with a space in it isn't completed. |
|
||||
| Q209 | *Revised the same day.* **`rm` is Unix's, with a question.** A folder needs `-r`, here and over the consoles (where `rm <folder>` used to remove it with what was in it). In the Shell, a file, or a folder with something in it, is asked about unless `-f` (`-rf`, `-r -f`); an empty folder with `-r` goes without a word; what `rm` would refuse anyway, it refuses itself. Over the consoles nothing is asked: scripts delete as before. **`screenshot [seconds]`** saves the screen as a PNG in `/screenshots`, now or after a pause, since from the Shell "now" is the Shell. `get`, `put`, `coredump get` and `reset` answer `Debug Console only`. *(Added the same day)* **`*` and `?` in a name**, for `ls`, `du`, `rm`, `cp` and `mv`, here and over the consoles: `rm /notes/*.txt`, `cp /gnss/2026-10-0?.gpx /backup`. In the last part of the path only, any case, as the card has it. It is the same command once for each name matched, in the name's order; `cp` and `mv` then need a folder that exists to put them in. Over 64 matches is refused whole, as is none. In the Shell `rm` with a pattern asks **once**, with the count. |
|
||||
| Q210 | Commands are echoed as `> command` into the console, so a session reads the same from afar. **A token being set is not echoed.** |
|
||||
| Q211 | **Not in Safe Mode**, which starts no Apps: issue #77. |
|
||||
| Q212 | Its keys are a table in `app_keys.h`, so the help panel and the website have them; a page in the user guide. |
|
||||
@@ -166,13 +166,14 @@ The console's commands could only be typed on a PC: over USB, or over Wi-Fi with
|
||||
|
||||
### As built
|
||||
|
||||
- **`ShellApp`** (`src/apps/shell_app.cpp`), with its model host-tested in `lib/apps_model/src/shell_log.h`: lines arriving in pieces, the 4 KB limit, the command words out of the `help` text (Apps' names included), and Tab.
|
||||
- **`ShellApp`** (`src/apps/shell_app.cpp`), with its model host-tested in `lib/apps_model/src/shell_log.h`: lines arriving in pieces, the 4 KB limit, the words of every command out of the `help` text (Apps' names included), and Tab.
|
||||
- **Who a line is for:** `Console::As` marks the calling task as printing for the Shell while it lives, and `Console::origin()` lets a command that answers later carry that to wherever it prints. The console has a second ring for the Shell, which gets the lines marked so, or everything.
|
||||
- **The Shell hands its lines to the main loop**, which runs them like the consoles' commands. See below for why.
|
||||
- **`info` says which App is in front** (`app: Shell`): so that a hand driving the device from afar can look before it types.
|
||||
- **`screenshot`** writes the PNG a row at a time with no buffer: 8-bit indexed colour, the 256 colours of RGB332 as the palette, the pixels in one stored deflate block (`lib/files/src/png_rgb332.h`, 4 tests). 33,383 bytes for the 240 x 135 screen. A Toast says so once it is on the card, so the Toast is never in the picture.
|
||||
- **The `help` text lost its shorthand** (`update check | list | status` is written out), since Tab reads its first words from there.
|
||||
- **The `help` text lost its shorthand** (`update check | list | status` is written out), since Tab reads its words from there: `|` between two commands, `on|off` between two words, three spaces before the description. The Shell's own words (`clear`, `quit`, `key`'s names) are added in the same notation.
|
||||
- **Tab on a path** reads the folder on the storage task while the main loop waits, so it is bounded: 400 entries looked at, 24 candidates given back, and `...` after the list when there were more.
|
||||
- **A pattern** is matched in `lib/files/src/file_names.h` (`globMatch`, host-tested), and the names it matches are lined up as so many commands, which the main loop runs one after the other as each finishes. `cancel` empties the line-up. 2000 entries looked at, 64 matches at most.
|
||||
- **Cost:** 21 KB of flash, 96 bytes of static RAM. Open: 7 KB of heap (107.6 KB free before, 100.7 with it open, 106.1 after leaving).
|
||||
|
||||
### What went wrong while building it
|
||||
@@ -183,7 +184,6 @@ The device restarted into the Launcher, and the test script, which did not look,
|
||||
|
||||
Two changes came of it. The Shell now **queues** its line and the main loop runs it, at the same stack depth as a console's command. And `info` reports the App in front, which the test script now checks before every line it types.
|
||||
|
||||
### Checks on the device (2026-10-07, driven over the Debug Console with `key`)
|
||||
### Checks on the device (2026-10-07, driven over the Debug Console with `key`)
|
||||
|
||||
| Check | Result |
|
||||
@@ -198,9 +198,15 @@ Two changes came of it. The Shell now **queues** its line and the main loop runs
|
||||
| `rm -r` on a folder with files | Asks; Cancel leaves it. `rm -rf` removes it without asking |
|
||||
| `rm` on a file | Asks; Delete removes it |
|
||||
| Tab on a path | `ls /no` becomes `ls /notes/`, and again, with one note in it, the note's whole name and a space. `ls /g` lists `gemini/ gnss/`. `cat no` becomes `cat /notes/`. `rm -r /CAP` becomes `rm -r /captures/`. `ls /zz` stays as it is |
|
||||
| Tab past the first word | `lora st` becomes `lora status `; `gnss tr` becomes `gnss track ` and Tab again lists `start stop`; `key ` lists its eleven names; `upd` becomes `update ` and lists `check list status install` |
|
||||
| `ls` with a pattern | `ls /gt/*.txt` the five files, `ls /gt/*2*` the one, `ls /gt3/*6?.txt` ten of seventy. `ls /g*/x`: refused, a pattern goes in the last part |
|
||||
| `cp /gt/file-000?.txt /gt2`, `du /gt2/*1*` | Five copies; one size |
|
||||
| `rm /gt2/*` in the Shell | "The 5 that match /gt2/\*": Cancel leaves the five. `rm /gt3/*6?.txt`, Delete: ten gone, sixty left. `rm -f /gt2/*`: gone without a question |
|
||||
| `rm /gt/*` where one match is a folder | The files go, the folder stays (no `-r`) |
|
||||
| No match, and seventy | `rm: error nothing matches`; `rm: error more than 64 match: a narrower pattern, please`, and all seventy still there |
|
||||
| `No`, Tab, Enter | Completes to `Notes ` and opens Notes. `Shell` from the Debug Console opens the Shell |
|
||||
| `screenshot 4`, then Home | The picture, taken four seconds later, is of the Launcher: the pause works, and the Toast isn't in it |
|
||||
| `quit` | Back to the Launcher, and the memory comes back |
|
||||
| The help panel in the Shell | Its keys, then the ones that work everywhere |
|
||||
|
||||
**Not checked:** Ctrl+b and the Alt scroll, which `key` can't press (no modifiers): the filter itself is host-tested, the key that flips it is not; the real keyboard altogether; and the Toast after a screenshot, which was published but not looked at.
|
||||
**Not checked:** Ctrl+b and the Alt scroll, which `key` can't press (no modifiers): the filter itself is host-tested, the key that flips it is not; the real keyboard altogether; the Toast after a screenshot, which was published but not looked at; and `mv` with a pattern and `cancel` in the middle of a line-up, which share their code with `cp` and `rm` but were not run. The main loop's lowest free stack after all of it: 1.8 KB, where it was.
|
||||
|
||||
@@ -23,7 +23,7 @@ Type a command and press <kbd>Enter</kbd>. `help` lists them all; the [command r
|
||||
| `update check` | Whether a newer release exists |
|
||||
| `lora status` | What the radio is set to and what it has heard |
|
||||
|
||||
- <kbd>Tab</kbd> **completes** what you are typing: the command's name first, then **a path on the SD card**. `ls /no` and Tab gives `ls /notes/`; Tab again goes on inside the folder. If several names fit, it completes as far as they agree and lists them. You can type the name in any case, and after a file command you can leave out the first slash: `cat no` and Tab gives `cat /notes/`. A name with a space in it isn't completed.
|
||||
- <kbd>Tab</kbd> **completes** what you are typing: the command, word by word (`lora st` gives `lora status`, and `gnss track ` with Tab lists `start stop`), then **a path on the SD card**. `ls /no` and Tab gives `ls /notes/`; Tab again goes on inside the folder. If several names fit, it completes as far as they agree and lists them. You can type the name in any case, and after a file command you can leave out the first slash: `cat no` and Tab gives `cat /notes/`. A name with a space in it isn't completed.
|
||||
- <kbd>Fn</kbd> with up and down brings back **lines you typed before**.
|
||||
- <kbd>Alt</kbd> with up and down **scrolls back** through what was printed.
|
||||
- `clear` empties the screen, and `quit` (or Back) leaves.
|
||||
@@ -54,6 +54,20 @@ The capital is the difference: every command is in small letters, every App star
|
||||
| `rm -r /captures/old` | Removed at once if it is empty. If not, asks first |
|
||||
| `rm -rf /captures/old` | Removed with everything in it, without asking |
|
||||
|
||||
## Several files at once
|
||||
|
||||
`*` stands for any run of characters in a name and `?` for exactly one, in `ls`, `du`, `rm`, `cp` and `mv`:
|
||||
|
||||
| You type | What happens |
|
||||
|---|---|
|
||||
| `ls /notes/*.txt` | Only the notes ending in `.txt` |
|
||||
| `du /captures/lora/*.pcap` | The size of each capture |
|
||||
| `cp /gnss/2026-10-0?.gpx /backup` | Copies the tracks of the 1st to the 9th into `/backup`, which must exist |
|
||||
| `rm /screenshots/2026*` | Asks **once**, saying how many match, then deletes them |
|
||||
| `rm -f /screenshots/*` | Deletes them all without asking |
|
||||
|
||||
The pattern goes in the **last part** of the path (`/notes/*.txt`, not `/*/a.txt`), and capitals don't matter. A folder that matches is left alone by `rm` unless you add `-r`. At most 64 names at a time: past that nothing is done, and you are asked for a narrower pattern. `cancel` stops what is left.
|
||||
|
||||
The folders the firmware keeps its own files in can't be removed, as in the [Storage App](/guide/storage/).
|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -381,6 +381,7 @@ title = "Shell"
|
||||
rows = [
|
||||
["Enter", "run the line"],
|
||||
["Tab", "complete: a command, a path"],
|
||||
["* ?", "several files: /notes/*.txt"],
|
||||
["Fn ; .", "lines you typed before"],
|
||||
["Alt ; .", "scroll back, forward"],
|
||||
["Ctrl b", "your replies only, or all"],
|
||||
|
||||
+20
-15
@@ -25,14 +25,8 @@ void ShellApp::onEnter() {
|
||||
ringPos_ = 0;
|
||||
scroll_ = 0;
|
||||
confirm_.reset();
|
||||
words_ = commandWords(helpText_);
|
||||
for (auto* app : apps_.visibleApps()) { // and the Apps, by their names with a capital
|
||||
std::string name = AppManager::commandFor(app->id);
|
||||
if (std::find(words_.begin(), words_.end(), name) == words_.end()) words_.push_back(name);
|
||||
}
|
||||
words_.push_back("help");
|
||||
words_.push_back("clear"); // the Shell's own
|
||||
words_.push_back("quit");
|
||||
// What Tab completes besides the firmware's own commands, written as `help` writes them.
|
||||
ownHelp_ = "help | clear | quit | exit\nkey up|down|left|right|select|back|home|del|tab|space|help\n";
|
||||
log_.clear();
|
||||
log_.add(open_ ? "The console's commands. `help` lists them." : "No memory for the Shell: leave an App, or stop IRC.");
|
||||
}
|
||||
@@ -43,7 +37,7 @@ void ShellApp::onExit() {
|
||||
console.shellShowsAll(false);
|
||||
open_ = false;
|
||||
log_.clear();
|
||||
std::vector<std::string>().swap(words_);
|
||||
std::string().swap(ownHelp_);
|
||||
confirm_.reset();
|
||||
}
|
||||
|
||||
@@ -78,11 +72,20 @@ void ShellApp::enter(const std::string& line) {
|
||||
// to. An empty folder goes without a word; anything `rm` would refuse anyway, it refuses itself.
|
||||
if (startsWith(line, "rm ")) {
|
||||
files::RmArgs args = files::parseRm(line.substr(3));
|
||||
Target target = args.force || args.path.empty() ? Target::Missing : probe_(args.path);
|
||||
bool ask = target == Target::File || (target == Target::FullFolder && args.recursive);
|
||||
bool ask = false;
|
||||
if (args.force || args.path.empty()) {
|
||||
} else if (files::hasGlob(args.path)) { // a pattern: one question for all it matches
|
||||
bool more = false;
|
||||
int n = count_(args.path, more);
|
||||
ask = n > 0 && !more; // none, or too many: `rm` says so itself
|
||||
question_ = "The " + std::to_string(n) + " that match " + args.path + (args.recursive ? ", folders and what's in them too" : "") + ". It can't be undone.";
|
||||
} else {
|
||||
Target target = probe_(args.path);
|
||||
ask = target == Target::File || (target == Target::FullFolder && args.recursive);
|
||||
question_ = target == Target::File ? args.path + ". It can't be undone." : args.path + " and everything in it. It can't be undone.";
|
||||
}
|
||||
if (ask) {
|
||||
pending_ = line;
|
||||
question_ = target == Target::File ? args.path + ". It can't be undone." : args.path + " and everything in it. It can't be undone.";
|
||||
confirm_.reset(new DialogModel({"Cancel", "Delete"}));
|
||||
return;
|
||||
}
|
||||
@@ -123,12 +126,14 @@ bool ShellApp::onKey(const KeyEvent& e) {
|
||||
case Key::Down:
|
||||
if (history_.down(recalled)) input_.setText(recalled);
|
||||
break;
|
||||
case Key::Tab: { // the command's name, or past it a path on the card
|
||||
case Key::Tab: { // the command, every word of it; where its words end, a path on the card
|
||||
std::vector<std::string> matches;
|
||||
PathToComplete path;
|
||||
bool more = false;
|
||||
if (splitForPath(input_.text(), path)) input_.setText(completePath(path, list_(path.folder, path.prefix, more), matches));
|
||||
else input_.setText(completeCommand(input_.text(), words_, matches));
|
||||
const std::string typed = input_.text();
|
||||
std::string done = completeWords(typed, (std::string(helpText_) + ownHelp_).c_str(), matches);
|
||||
if (done == typed && matches.empty() && splitForPath(typed, path)) done = completePath(path, list_(path.folder, path.prefix, more), matches);
|
||||
input_.setText(done);
|
||||
if (matches.size() > 1) {
|
||||
std::string all;
|
||||
for (auto& m : matches) all += (all.empty() ? "" : " ") + m;
|
||||
|
||||
@@ -30,9 +30,11 @@ class ShellApp : public App {
|
||||
// A folder's entries that start with `prefix`, whatever their case, a folder's with a slash at its
|
||||
// end: what Tab completes a path from. `more` when there were too many to give them all.
|
||||
using List = std::function<std::vector<std::string>(const std::string& folder, const std::string& prefix, bool& more)>;
|
||||
// How many names a pattern matches (/notes/*.txt), for the question `rm` asks; `more` past the limit.
|
||||
using Count = std::function<int(const std::string& pattern, bool& more)>;
|
||||
|
||||
ShellApp(Run run, Probe probe, List list, const char* helpText, AppManager& apps)
|
||||
: run_(std::move(run)), probe_(std::move(probe)), list_(std::move(list)), helpText_(helpText), apps_(apps) {}
|
||||
ShellApp(Run run, Probe probe, List list, Count count, const char* helpText, AppManager& apps)
|
||||
: run_(std::move(run)), probe_(std::move(probe)), list_(std::move(list)), count_(std::move(count)), helpText_(helpText), apps_(apps) {}
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
@@ -49,10 +51,11 @@ class ShellApp : public App {
|
||||
Run run_;
|
||||
Probe probe_;
|
||||
List list_;
|
||||
Count count_;
|
||||
const char* helpText_;
|
||||
AppManager& apps_;
|
||||
ShellLog log_;
|
||||
std::vector<std::string> words_; // the commands Tab completes: built on entering, from `help`
|
||||
std::string ownHelp_; // the Shell's own commands and the keys `key` takes, in the form of `help`'s text
|
||||
LineEditor input_{240};
|
||||
InputHistory history_{16};
|
||||
std::unique_ptr<DialogModel> confirm_;
|
||||
|
||||
+83
-7
@@ -158,6 +158,7 @@ static const char* helpText();
|
||||
static void shellRun(const std::string& line);
|
||||
static ShellApp::Target shellProbe(const std::string& path);
|
||||
static std::vector<std::string> shellList(const std::string& folder, const std::string& prefix, bool& more);
|
||||
static int shellCount(const std::string& pattern, bool& more);
|
||||
|
||||
void setup() {
|
||||
nvs.begin();
|
||||
@@ -216,7 +217,7 @@ void setup() {
|
||||
apps->registerApp({"lora", "LoRa Scanner", false, new LoraScannerApp(*radioService, *loraCapture, settings, *clockService)});
|
||||
apps->registerApp({"storage", "Storage", false, new StorageApp(*fileOps, *storageService, *clockService, *update, *power, bus)});
|
||||
apps->registerApp({"notes", "Notes", false, new NotesApp(*fileOps, *storageService, *clockService, *power)});
|
||||
apps->registerApp({"shell", "Shell", false, new ShellApp(shellRun, shellProbe, shellList, helpText(), *apps)});
|
||||
apps->registerApp({"shell", "Shell", false, new ShellApp(shellRun, shellProbe, shellList, shellCount, helpText(), *apps)});
|
||||
// Leaving the foreground App makes it save: a note being typed, when the device is powered off.
|
||||
power->beforePowerOff = []() { apps->home(); };
|
||||
apps->registerApp({"system", "System", false,
|
||||
@@ -433,6 +434,48 @@ static std::vector<std::string> filesInUse(const std::string& path, bool folder)
|
||||
// `cp`, `mv`, `rm`, `mkdir`, `du` run as the Storage App's operations do; the result prints here.
|
||||
static bool consoleFileOp = false;
|
||||
|
||||
// A pattern in a path (issue #67): `rm /notes/*.txt` is one `rm` for each name it matches, run one
|
||||
// after the other, each when the one before is done. 64 at most: more is refused, not started.
|
||||
constexpr size_t kMaxGlob = 64;
|
||||
struct QueuedFileCommand {
|
||||
std::string line;
|
||||
Console::Origin from;
|
||||
};
|
||||
static std::deque<QueuedFileCommand> fileQueue;
|
||||
static void fileCommand(const String& line);
|
||||
|
||||
// The paths a pattern matches, sorted. `more` when there were over kMaxGlob (or over 2000 entries to
|
||||
// look through); empty when the pattern isn't in the last part of the path alone.
|
||||
static std::vector<std::string> expandGlob(const std::string& path, bool& more) {
|
||||
std::vector<std::string> out;
|
||||
std::string folder, pattern;
|
||||
more = false;
|
||||
if (!files::splitGlob(path, folder, pattern) || !storageService->state().present) return out;
|
||||
storageService->runAndWait([&]() {
|
||||
File dir = SD.open(folder.c_str());
|
||||
if (!dir || !dir.isDirectory()) return;
|
||||
int seen = 0;
|
||||
for (File f = dir.openNextFile(); f; f = dir.openNextFile()) {
|
||||
if (++seen > 2000 || out.size() > kMaxGlob) {
|
||||
more = true;
|
||||
break;
|
||||
}
|
||||
if (files::globMatch(pattern, f.name())) out.push_back(files::joinPath(folder, f.name()));
|
||||
}
|
||||
});
|
||||
if (out.size() > kMaxGlob) more = true;
|
||||
std::sort(out.begin(), out.end());
|
||||
return out;
|
||||
}
|
||||
|
||||
static void fileQueueStep() {
|
||||
if (fileQueue.empty() || consoleFileOp) return; // the one before is still running
|
||||
QueuedFileCommand next = std::move(fileQueue.front());
|
||||
fileQueue.pop_front();
|
||||
Console::As as(next.from);
|
||||
fileCommand(next.line.c_str());
|
||||
}
|
||||
|
||||
static void fileOpsStep() {
|
||||
FileOps::Status s;
|
||||
if (!consoleFileOp || !fileOps->finished(s)) return;
|
||||
@@ -531,7 +574,32 @@ static void fileCommand(const String& line) {
|
||||
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 (command == "cancel") {
|
||||
fileQueue.clear(); // and what a pattern had lined up
|
||||
return fileOps->cancel();
|
||||
}
|
||||
// A pattern: the same command for each name it matches.
|
||||
files::RmArgs rmArgs = command == "rm" ? files::parseRm(everything) : files::RmArgs();
|
||||
const std::string globbed = command == "rm" ? rmArgs.path : command == "du" ? rest : command == "cp" || command == "mv" ? a : "";
|
||||
if (files::hasGlob(globbed) && storageService->state().present) {
|
||||
bool more = false, intoExists = false;
|
||||
std::string folder, pattern;
|
||||
std::vector<std::string> paths = expandGlob(globbed, more);
|
||||
if (!files::splitGlob(globbed, folder, pattern)) why = "a pattern goes in the last part of a path: /notes/*.txt";
|
||||
else if (more) why = "more than 64 match: a narrower pattern, please";
|
||||
else if (paths.empty()) why = "nothing matches";
|
||||
else if ((command == "cp" || command == "mv") && (b.empty() || !fileOps->isFolder(b, intoExists) || !intoExists)) why = "several files go into a folder that exists";
|
||||
if (!why.empty()) return (void)console.printf("%s: error %s\n", command.c_str(), why.c_str());
|
||||
console.printf("%s: %u match %s\n", command.c_str(), (unsigned)paths.size(), globbed.c_str());
|
||||
Console::Origin from = console.origin();
|
||||
for (auto& p : paths) {
|
||||
std::string one = command == "rm" ? std::string("rm ") + (rmArgs.recursive ? "-r " : "") + p
|
||||
: command == "du" ? "du " + p
|
||||
: command + (force ? " -f " : " ") + p + "\t" + b; // a tab between two paths: either may hold a space
|
||||
fileQueue.push_back({one, from});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!storageService->state().present) why = "no SD card";
|
||||
else if (command == "du") why = fileOps->count(rest);
|
||||
else if (command == "mkdir") why = fileOps->makeFolder(rest);
|
||||
@@ -575,7 +643,7 @@ static const char* const kHelp =
|
||||
"reboot restart\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"
|
||||
"ls [folder] | du <path> | mkdir <path> | rm [-r] [-f] <path> | cp [-f] <from> <to> | mv [-f] <from> <to> | cancel the SD card, with the Storage App's rules (rm -r for a folder; -f: the Shell doesn't ask)\n"
|
||||
"ls [folder] | du <path> | mkdir <path> | rm [-r] [-f] <path> | cp [-f] <from> <to> | mv [-f] <from> <to> | cancel the SD card, with the Storage App's rules (rm -r for a folder; -f: the Shell doesn't ask; * and ? in a name: /notes/*.txt)\n"
|
||||
"screenshot [seconds] the screen as a PNG in /screenshots on the card, now or after a pause\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"
|
||||
@@ -784,16 +852,21 @@ static void runCommand(String line, bool fromSerial = false) {
|
||||
fileCommand(line);
|
||||
if (line == "ls" || line.startsWith("ls ")) {
|
||||
if (!storageService->state().present) return (void)console.println("sd: no card");
|
||||
std::string path = line.length() > 3 ? line.substring(3).c_str() : "/";
|
||||
std::string path = line.length() > 3 ? line.substring(3).c_str() : "/", pattern;
|
||||
const std::string asked = path;
|
||||
if (files::hasGlob(path) && !files::splitGlob(asked, path, pattern)) // /notes/*.txt: the folder, and what to show of it
|
||||
return (void)console.println("ls: error a pattern goes in the last part of a path: /notes/*.txt");
|
||||
Console::Origin from = console.origin();
|
||||
storageService->runJob([path, from]() { // card access stays on the storage task
|
||||
storageService->runJob([path, pattern, asked, from]() { // card access stays on the storage task
|
||||
Console::As as(from);
|
||||
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());
|
||||
for (File f = dir.openNextFile(); f; f = dir.openNextFile())
|
||||
for (File f = dir.openNextFile(); f; f = dir.openNextFile()) {
|
||||
if (!pattern.empty() && !files::globMatch(pattern, f.name())) continue;
|
||||
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", asked.c_str());
|
||||
});
|
||||
}
|
||||
if (line.startsWith("install ")) update->installFromSd(line.substring(8).c_str()); // Update from SD
|
||||
@@ -1191,6 +1264,8 @@ static std::vector<std::string> shellList(const std::string& folder, const std::
|
||||
return names;
|
||||
}
|
||||
|
||||
static int shellCount(const std::string& pattern, bool& more) { return static_cast<int>(expandGlob(pattern, more).size()); }
|
||||
|
||||
static void serialCommands() {
|
||||
if (upload.active()) return readUploadBytes(); // raw file bytes, not commands
|
||||
static String line;
|
||||
@@ -1287,6 +1362,7 @@ static void loopPass() {
|
||||
noteStableOnce(now);
|
||||
updateStep();
|
||||
fileOpsStep();
|
||||
fileQueueStep();
|
||||
uploadStep();
|
||||
printListingWhenReady();
|
||||
M5Cardputer.update();
|
||||
|
||||
@@ -128,6 +128,43 @@ void test_rm_takes_its_switches_like_unix() {
|
||||
TEST_ASSERT_EQUAL_STRING("", parseRm("-rf").path.c_str());
|
||||
}
|
||||
|
||||
void test_a_pattern_matches_names() {
|
||||
TEST_ASSERT_TRUE(globMatch("*.txt", "notes.txt"));
|
||||
TEST_ASSERT_TRUE(globMatch("*.TXT", "notes.txt")); // the card doesn't tell cases apart
|
||||
TEST_ASSERT_FALSE(globMatch("*.txt", "notes.txt.bak"));
|
||||
TEST_ASSERT_TRUE(globMatch("*", "anything"));
|
||||
TEST_ASSERT_TRUE(globMatch("*", ""));
|
||||
TEST_ASSERT_TRUE(globMatch("a?c", "abc"));
|
||||
TEST_ASSERT_FALSE(globMatch("a?c", "ac"));
|
||||
TEST_ASSERT_FALSE(globMatch("a?c", "abbc"));
|
||||
TEST_ASSERT_TRUE(globMatch("2026-10-0?.log", "2026-10-07.log"));
|
||||
TEST_ASSERT_TRUE(globMatch("a*b*c", "a-then-b-then-c"));
|
||||
TEST_ASSERT_TRUE(globMatch("a*b*c", "abbbc"));
|
||||
TEST_ASSERT_FALSE(globMatch("a*b*c", "a-then-c"));
|
||||
TEST_ASSERT_TRUE(globMatch("*a", "banana")); // the star has to give back and try again
|
||||
TEST_ASSERT_FALSE(globMatch("*ab", "banana"));
|
||||
TEST_ASSERT_TRUE(globMatch("file-000?.txt", "file-0001.txt"));
|
||||
TEST_ASSERT_TRUE(globMatch("exact", "EXACT"));
|
||||
TEST_ASSERT_FALSE(globMatch("", "x"));
|
||||
TEST_ASSERT_TRUE(globMatch("**", "x"));
|
||||
}
|
||||
|
||||
void test_a_path_with_a_pattern_is_taken_apart() {
|
||||
std::string folder, pattern;
|
||||
TEST_ASSERT_TRUE(splitGlob("/notes/*.txt", folder, pattern));
|
||||
TEST_ASSERT_EQUAL_STRING("/notes", folder.c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("*.txt", pattern.c_str());
|
||||
TEST_ASSERT_TRUE(splitGlob("/*", folder, pattern));
|
||||
TEST_ASSERT_EQUAL_STRING("/", folder.c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("*", pattern.c_str());
|
||||
TEST_ASSERT_FALSE(splitGlob("/notes/a.txt", folder, pattern)); // no pattern
|
||||
TEST_ASSERT_FALSE(splitGlob("/*/a.txt", folder, pattern)); // not in a folder's name
|
||||
TEST_ASSERT_FALSE(splitGlob("/n*/a?.txt", folder, pattern));
|
||||
TEST_ASSERT_FALSE(splitGlob("*.txt", folder, pattern)); // a path starts at the top
|
||||
TEST_ASSERT_TRUE(hasGlob("a?"));
|
||||
TEST_ASSERT_FALSE(hasGlob("/plain/path"));
|
||||
}
|
||||
|
||||
int main() {
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_path_parts);
|
||||
@@ -139,5 +176,7 @@ int main() {
|
||||
RUN_TEST(test_kinds);
|
||||
RUN_TEST(test_looks_like_text);
|
||||
RUN_TEST(test_rm_takes_its_switches_like_unix);
|
||||
RUN_TEST(test_a_pattern_matches_names);
|
||||
RUN_TEST(test_a_path_with_a_pattern_is_taken_apart);
|
||||
return UNITY_END();
|
||||
}
|
||||
|
||||
@@ -46,35 +46,76 @@ void test_the_oldest_lines_go_past_4_kb() {
|
||||
|
||||
static const char* kHelp =
|
||||
"info firmware, uptime, memory\n"
|
||||
"ls [folder] | du <path> | mkdir <path> | rm <path> the SD card\n"
|
||||
"lora probe | lora status | lora rx on|off the LoRa radio\n"
|
||||
"ls [folder] | du <path> | mkdir <path> | rm [-r] [-f] <path> the SD card\n"
|
||||
"lora probe | lora status | lora rx on|off | lora preset <name> the LoRa radio\n"
|
||||
"lora sweep on [from MHz] [to MHz] | lora sweep off | lora sweep dump RSSI across a band\n"
|
||||
"gnss track start|stop | gnss nmea on|off | gnss send <sentence without $ and checksum>\n"
|
||||
"log level <0-5> ESP-IDF log level\n"
|
||||
"wifi ip <ssid> dhcp | wifi ip ... try <seconds> | wifi ip keep a Saved Network's IP setting\n"
|
||||
"sd card | sd list | cat <path> | log <text> | burst\n"
|
||||
"debug on | debug token <16 to 64 characters> | debug token new (USB serial only)\n"
|
||||
"get <path> | put <path> <size> <sha256> | screenshot (Debug Console only) binary\n"
|
||||
"Irc | Notes | Storage open that App\n";
|
||||
|
||||
void test_command_words_come_from_the_help_text() {
|
||||
auto words = commandWords(kHelp);
|
||||
const char* expected[] = {"info", "ls", "du", "mkdir", "rm", "lora", "log", "sd", "cat", "burst", "get", "put", "screenshot", "Irc", "Notes", "Storage"};
|
||||
TEST_ASSERT_EQUAL(sizeof expected / sizeof expected[0], words.size());
|
||||
for (size_t i = 0; i < words.size(); i++) TEST_ASSERT_EQUAL_STRING(expected[i], words[i].c_str());
|
||||
static std::string tab(const std::string& typed, std::vector<std::string>* candidates = nullptr) {
|
||||
std::vector<std::string> matches;
|
||||
std::string out = completeWords(typed, kHelp, matches);
|
||||
if (candidates) *candidates = matches;
|
||||
return out;
|
||||
}
|
||||
|
||||
void test_tab_completes_the_first_word() {
|
||||
auto words = commandWords(kHelp);
|
||||
std::vector<std::string> matches;
|
||||
TEST_ASSERT_EQUAL_STRING("info ", completeCommand("in", words, matches).c_str()); // the only one: with its space
|
||||
TEST_ASSERT_EQUAL(0, matches.size());
|
||||
TEST_ASSERT_EQUAL_STRING("l", completeCommand("l", words, matches).c_str()); // ls, lora, log: they agree on no more
|
||||
TEST_ASSERT_EQUAL(3, matches.size());
|
||||
TEST_ASSERT_EQUAL_STRING("mkdir ", completeCommand("m", words, matches).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("lo", completeCommand("lo", words, matches).c_str()); // lora, log
|
||||
TEST_ASSERT_EQUAL(2, matches.size());
|
||||
TEST_ASSERT_EQUAL_STRING("zz", completeCommand("zz", words, matches).c_str()); // nothing: as it was
|
||||
TEST_ASSERT_EQUAL_STRING("ls /no", completeCommand("ls /no", words, matches).c_str()); // past the first word
|
||||
TEST_ASSERT_EQUAL_STRING("", completeCommand("", words, matches).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("Notes ", completeCommand("N", words, matches).c_str()); // an App: a capital
|
||||
TEST_ASSERT_EQUAL_STRING("n", completeCommand("n", words, matches).c_str()); // and no command starts with n here
|
||||
std::vector<std::string> m;
|
||||
TEST_ASSERT_EQUAL_STRING("info ", tab("in", &m).c_str()); // the only one: with its space
|
||||
TEST_ASSERT_EQUAL(0, m.size());
|
||||
TEST_ASSERT_EQUAL_STRING("l", tab("l", &m).c_str()); // ls, lora, log: they agree on no more
|
||||
TEST_ASSERT_EQUAL(3, m.size());
|
||||
TEST_ASSERT_EQUAL_STRING("lo", tab("lo", &m).c_str()); // lora, log
|
||||
TEST_ASSERT_EQUAL(2, m.size());
|
||||
TEST_ASSERT_EQUAL_STRING("mkdir ", tab("m").c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("zz", tab("zz", &m).c_str()); // nothing: as it was
|
||||
TEST_ASSERT_EQUAL(0, m.size());
|
||||
TEST_ASSERT_EQUAL_STRING("", tab("").c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("Notes ", tab("N").c_str()); // an App: a capital
|
||||
TEST_ASSERT_EQUAL_STRING("n", tab("n").c_str()); // and no command starts with n here
|
||||
TEST_ASSERT_EQUAL_STRING("screenshot ", tab("sc").c_str());
|
||||
}
|
||||
|
||||
// Every word of a command, not only the first.
|
||||
void test_tab_completes_the_words_after_the_first() {
|
||||
std::vector<std::string> m;
|
||||
TEST_ASSERT_EQUAL_STRING("lora status ", tab("lora st").c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("lora s", tab("lora s", &m).c_str()); // status, sweep
|
||||
TEST_ASSERT_EQUAL(2, m.size());
|
||||
TEST_ASSERT_EQUAL_STRING("lora ", tab("lora ", &m).c_str()); // everything lora can be followed by
|
||||
const char* afterLora[] = {"probe", "status", "rx", "preset", "sweep"};
|
||||
TEST_ASSERT_EQUAL(5, m.size());
|
||||
for (size_t i = 0; i < m.size(); i++) TEST_ASSERT_EQUAL_STRING(afterLora[i], m[i].c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("lora rx o", tab("lora rx o", &m).c_str()); // on|off: either
|
||||
TEST_ASSERT_EQUAL(2, m.size());
|
||||
TEST_ASSERT_EQUAL_STRING("lora rx off ", tab("lora rx of").c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("lora sweep dump ", tab("lora sweep d").c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("gnss track st", tab("gnss track st", &m).c_str()); // start, stop
|
||||
TEST_ASSERT_EQUAL(2, m.size());
|
||||
TEST_ASSERT_EQUAL_STRING("gnss track stop ", tab("gnss track sto").c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("log level ", tab("log l").c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("wifi ip keep ", tab("wifi ip k").c_str()); // past the <ssid> of its neighbours
|
||||
TEST_ASSERT_EQUAL_STRING("debug token new ", tab("debug token n").c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("sd ", tab("sd ", &m).c_str());
|
||||
TEST_ASSERT_EQUAL(2, m.size()); // card, list
|
||||
}
|
||||
|
||||
// Where a command's words end, Tab has nothing to say: the Shell then tries a path.
|
||||
void test_tab_stops_where_the_arguments_start() {
|
||||
std::vector<std::string> m;
|
||||
TEST_ASSERT_EQUAL_STRING("ls /no", tab("ls /no", &m).c_str());
|
||||
TEST_ASSERT_EQUAL(0, m.size());
|
||||
TEST_ASSERT_EQUAL_STRING("rm ", tab("rm ", &m).c_str());
|
||||
TEST_ASSERT_EQUAL(0, m.size());
|
||||
TEST_ASSERT_EQUAL_STRING("lora preset Lo", tab("lora preset Lo").c_str()); // a <name>: not a word of the command
|
||||
TEST_ASSERT_EQUAL_STRING("lora status x", tab("lora status x").c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("nope st", tab("nope st").c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("lora st", tab("lora st").c_str()); // two spaces
|
||||
}
|
||||
|
||||
void test_a_later_word_is_taken_apart_as_a_path() {
|
||||
@@ -133,8 +174,9 @@ int main(int, char**) {
|
||||
RUN_TEST(test_lines_arrive_in_pieces);
|
||||
RUN_TEST(test_the_heap_line_is_never_kept);
|
||||
RUN_TEST(test_the_oldest_lines_go_past_4_kb);
|
||||
RUN_TEST(test_command_words_come_from_the_help_text);
|
||||
RUN_TEST(test_tab_completes_the_first_word);
|
||||
RUN_TEST(test_tab_completes_the_words_after_the_first);
|
||||
RUN_TEST(test_tab_stops_where_the_arguments_start);
|
||||
RUN_TEST(test_a_later_word_is_taken_apart_as_a_path);
|
||||
RUN_TEST(test_tab_completes_a_path);
|
||||
return UNITY_END();
|
||||
|
||||
Reference in New Issue
Block a user