diff --git a/README.md b/README.md index 38ece9c..6921993 100644 --- a/README.md +++ b/README.md @@ -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 what the console prints while it is open; Ctrl+b hides everything but what follows your own commands. Tab completes a command's name, Fn with up and down recalls earlier lines, Alt with up and down scrolls back. `rm` asks before deleting (`rm -f` doesn't), `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's name, 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. ## Development aids @@ -185,14 +185,15 @@ The Shell App (docs/milestones/S1.md) runs the commands below on the device's ow | `irc say ` | Types into a Buffer, commands included (`irc say 0 /join #test`) | | `irc dump` | Prints IRC status, memory, and the last lines of each Buffer | | `wifi status` | Prints Wi-Fi state, network, signal, clock and free heap, then the address, gateway, DNS and NTP servers in use and where each came from | -| `info` | Firmware, uptime, last start reason, memory, Wi-Fi, the SD card and its write faults since boot, and both app slots with their versions and OTA states | +| `info` | Firmware, uptime, last start reason, memory, Wi-Fi, the SD card and its write faults since boot, **which App is in front**, and both app slots with their versions and OTA states | +| `Notes`, `Irc`, `Wifi`, `Gnss`, `Gemini`, `Lora`, `Storage`, `Shell`, `System`, `Settings` | Opens that App: a capital letter is an App, not a command | | `tasks` | FreeRTOS tasks over the next second: state, priority, lowest free stack, share of a core, each core's load, and how many passes the main loop made | | `net` | Bytes each network service has read and written since boot | | `reboot` / `boot other` | Restart, or restart into the other app slot (a manual Rollback) | | `log level <0-5>` | ESP-IDF log level | | `ls [folder]` / `du ` | 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] ` / `mv [-f] ` / `rm [-f] ` / `mkdir ` / `cancel` | What the Storage App does, with its rules: copy (folders too), move or rename, delete (a folder with what's in it), new folder. `-f` replaces a file that's in the way; a tab separates paths that hold spaces; `cancel` stops a copy or a delete | +| `cp [-f] ` / `mv [-f] ` / `rm [-r] [-f] ` / `mkdir ` / `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 | | `install ` | Update from SD with that `.ota` file, as Settings → Firmware does | | `update check` / `update list` / `update status` / `update install ` | 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 ` / `update probe ` / `update damage cut\|flip ` / `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 | diff --git a/docs/milestones/S1.md b/docs/milestones/S1.md index ba2b4a8..6af5e88 100644 --- a/docs/milestones/S1.md +++ b/docs/milestones/S1.md @@ -147,22 +147,34 @@ The console's commands could only be typed on a PC: over USB, or over Wi-Fi with |---|---| | Q204 | **An App, "Shell", in the Launcher**, in every firmware: its commands already work over USB for anyone holding the device. | | 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 | **It shows what the console prints while it is open**, replies and background alike, without the free-heap line every ten seconds. **Ctrl+b hides the background:** then only what is printed in the ten seconds after a command is kept. A reply can't be told from other output any better: `ls` and `tasks` answer later, from other tasks. | +| 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. Paths aren't completed. | -| Q209 | **`rm` asks first, `rm -f` doesn't** (in the Shell only: over the consoles, 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`. | +| Q209 | *Revised the same day.* **`rm` is Unix's, with a question.** A folder needs `-r`, here and over the consoles (where `rm ` 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`. | | 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. | +| Q213 | *Added the same day.* **An App's name with a capital opens it:** `Irc`, `Wifi`, `Gnss`, `Gemini`, `Lora`, `Storage`, `Notes`, `Shell`, `System`, `Settings` (the App's id, up to its first dash). From the Shell, without going back to the Launcher, and from the consoles too. The capital says "an App": every command of the firmware is in small letters. Tab completes them. | ### 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 two filters, the 4 KB limit, the command words out of the `help` text, and Tab (7 tests). -- **The console has a second ring** (`Console::openShellRing`), filled like the Debug Console's and independent of it. +- **`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. +- **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. -- **Cost:** 12 KB of flash, 16 bytes of static RAM. Open: 7 KB of heap (107.6 KB free before, 100.7 with it open, 106.1 after leaving). +- **Cost:** 16 KB of flash, 56 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 + +**The device crashed, and the test sent a message to an IRC channel.** The first version ran a command from inside the key handler. Driven from the Debug Console, that is: the main loop, a remote command, `key select`, the App manager, the Shell, `runCommand` a second time, the file command, and `printf` under all of it. The main loop has under 2 KB of stack to spare; `rm` on a folder went past it. The crash report decoded to exactly that chain. + +The device restarted into the Launcher, and the test script, which did not look, went on typing. Its next Enter opened IRC, which connected, and a few lines later it typed "No" into a channel and pressed Enter. One word, sent to real people, that can't be taken back. + +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 | @@ -171,7 +183,12 @@ The console's commands could only be typed on a PC: over USB, or over Wi-Fi with | Tab on `in` | `info install` is shown and the line stays; on `u` it becomes `update `; on `l`, `log ls lora loop` | | Up | The line before comes back | | `screenshot` | `/screenshots/20261007-104357.png`, 33,383 bytes. Fetched and decoded on the PC: 240 x 135, indexed, every chunk's CRC right, the pixels the Shell's screen | -| `rm /shelltest` | Asks. Cancel leaves the folder; Delete removes it. `rm -f` removes without asking | +| Output | With a Debug Console client connecting and disconnecting for every key, the Shell shows the commands and their replies and nothing else: `info`, `ls /` (answered by the storage task), `tasks` (answered a second later) | +| `rm` on a folder, without `-r` | Refused, the folder stays | +| `rm -r` on an empty folder | Removed, no question | +| `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 | +| `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 | diff --git a/lib/apps_model/src/shell_log.cpp b/lib/apps_model/src/shell_log.cpp index 973172f..4601a6b 100644 --- a/lib/apps_model/src/shell_log.cpp +++ b/lib/apps_model/src/shell_log.cpp @@ -4,12 +4,6 @@ namespace roro { -bool ShellLog::wanted(const std::string& line, uint32_t nowMs) const { - if (line.rfind("status: heap ", 0) == 0) return false; - if (background_) return true; - return ranOne_ && nowMs - lastCommandMs_ < kMineMs; // unsigned: right across the clock's wrap -} - void ShellLog::push(const std::string& line) { lines_.push_back(line); bytes_ += line.size() + 1; @@ -22,7 +16,7 @@ void ShellLog::push(const std::string& line) { void ShellLog::add(const std::string& line) { push(line); } -void ShellLog::feed(const char* data, size_t len, uint32_t nowMs) { +void ShellLog::feed(const char* data, size_t len) { for (size_t i = 0; i < len; i++) { char c = data[i]; if (c == '\r') continue; @@ -30,7 +24,7 @@ void ShellLog::feed(const char* data, size_t len, uint32_t nowMs) { if (partial_.size() < 512) partial_ += c; // a line that never ends doesn't take the heap continue; } - if (wanted(partial_, nowMs)) push(partial_); + if (partial_.rfind("status: heap ", 0) != 0) push(partial_); partial_.clear(); } } @@ -61,7 +55,9 @@ std::vector commandWords(const char* helpText) { 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); - bool plain = !word.empty() && std::all_of(word.begin(), word.end(), [](char ch) { return ch >= 'a' && ch <= 'z'; }); + // 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; diff --git a/lib/apps_model/src/shell_log.h b/lib/apps_model/src/shell_log.h index b17df65..297fe58 100644 --- a/lib/apps_model/src/shell_log.h +++ b/lib/apps_model/src/shell_log.h @@ -8,43 +8,30 @@ namespace roro { -// What the Shell App shows (issue #67): the lines the console printed, with the oldest dropped past -// a size. Everything the firmware prints passes through; the Shell keeps what's worth a place on a -// ten-line screen (Q206). +// What the Shell App shows (issue #67): the lines it was given, with the oldest dropped past a size. +// Which lines it is given is the console's business: by default, only what is printed for the +// Shell's own commands (Console::Origin). class ShellLog { public: static constexpr size_t kMaxBytes = 4096; - static constexpr uint32_t kMineMs = 10000; // how long after a command its output is taken to be - // Bytes as the console printed them: lines may arrive in pieces. - void feed(const char* data, size_t len, uint32_t nowMs); - // A line the Shell adds itself: the command typed, an answer of its own. + // Bytes as the console printed them: lines may arrive in pieces. The line with the free heap + // every ten seconds is never kept: it would push everything else off a ten-line screen. + void feed(const char* data, size_t len); + // A line the Shell adds itself. void add(const std::string& line); - // A command was run now: what follows for a while is "its". - void commandRun(uint32_t nowMs) { - lastCommandMs_ = nowMs; - ranOne_ = true; - } - - // All, or mine only: with background lines hidden, only what's printed in the ten seconds after - // a command is kept. A reply can't be told from other output any better: `ls` and `tasks` answer - // later, from other tasks. The line with the free heap every ten seconds is never kept. - void showBackground(bool show) { background_ = show; } - bool showsBackground() const { return background_; } const std::deque& lines() const { return lines_; } void clear(); uint32_t revision() const { return revision_; } // changes when the lines do private: - bool wanted(const std::string& line, uint32_t nowMs) const; void push(const std::string& line); std::deque lines_; std::string partial_; size_t bytes_ = 0; - bool background_ = true, ranOne_ = false; - uint32_t lastCommandMs_ = 0, revision_ = 0; + uint32_t revision_ = 0; }; // The commands `help` lists: the first word of each (`ls [folder] | du ` gives ls and du), diff --git a/lib/core/src/app_keys.h b/lib/core/src/app_keys.h index e9a6526..0021a55 100644 --- a/lib/core/src/app_keys.h +++ b/lib/core/src/app_keys.h @@ -329,12 +329,13 @@ inline constexpr KeyHelp kShell[] = { {"Tab", "complete the command"}, {"Fn ; .", "lines you typed before"}, {"Alt ; .", "scroll back, forward"}, - {"Ctrl b", "all output, or only yours"}, + {"Ctrl b", "your replies only, or all"}, {"Fn , /", "move the cursor"}, {"Del", "delete backwards"}, {"help", "every command"}, {"clear", "an empty screen"}, - {"rm -f", "delete without being asked"}, + {"Notes", "an App, by its name"}, + {"rm -rf", "delete without being asked"}, {"quit `", "leave the Shell"}, }; diff --git a/lib/core/src/app_manager.cpp b/lib/core/src/app_manager.cpp index 15d3042..2ee0778 100644 --- a/lib/core/src/app_manager.cpp +++ b/lib/core/src/app_manager.cpp @@ -82,6 +82,19 @@ void AppManager::handleKey(const KeyEvent& event) { if (!consumed && event.key == Key::Back) home(); } +std::string AppManager::commandFor(const char* id) { + std::string command; + for (const char* p = id; *p && *p != '-'; p++) command += *p; + if (!command.empty() && command[0] >= 'a' && command[0] <= 'z') command[0] = static_cast(command[0] - 'a' + 'A'); + return command; +} + +const AppInfo* AppManager::byCommand(const std::string& command) const { + for (auto& info : apps_) + if (!info.hidden && commandFor(info.id) == command) return &info; + return nullptr; +} + const char* AppManager::foregroundTitle() const { for (auto& info : apps_) if (info.app == foreground_) return info.title; diff --git a/lib/core/src/app_manager.h b/lib/core/src/app_manager.h index eebeb29..6ab6467 100644 --- a/lib/core/src/app_manager.h +++ b/lib/core/src/app_manager.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include "app.h" @@ -34,6 +35,13 @@ class AppManager { void handleKey(const KeyEvent& event); void update(uint32_t nowMs) { foreground_->update(nowMs); } + // The command that opens an App from the consoles and the Shell (issue #67): its id with a + // capital letter, up to the first dash. "notes" is Notes, "wifi-tools" is Wifi. The capital says + // "an App", where every command of the firmware is in small letters. + static std::string commandFor(const char* id); + // The App that command names, among the ones the Launcher lists; nullptr if there's none. + const AppInfo* byCommand(const std::string& command) const; + App& foreground() const { return *foreground_; } const char* foregroundTitle() const; // nullptr for the Launcher diff --git a/lib/files/src/file_names.cpp b/lib/files/src/file_names.cpp index 0bdc813..dc58782 100644 --- a/lib/files/src/file_names.cpp +++ b/lib/files/src/file_names.cpp @@ -96,4 +96,23 @@ bool looksLikeText(const uint8_t* data, size_t len) { return odd * 20 <= len; // a stray control character or two is still text } +RmArgs parseRm(const std::string& args) { + RmArgs out; + size_t at = 0; + while (at < args.size()) { + while (at < args.size() && args[at] == ' ') at++; + if (at >= args.size() || args[at] != '-') break; + size_t end = args.find(' ', at); + std::string flags = args.substr(at + 1, end == std::string::npos ? std::string::npos : end - at - 1); + bool known = !flags.empty(); + for (char c : flags) known = known && (c == 'r' || c == 'R' || c == 'f'); + if (!known) break; // a name that starts with a dash + for (char c : flags) (c == 'f' ? out.force : out.recursive) = true; + at = end == std::string::npos ? args.size() : end; + } + out.path = at < args.size() ? args.substr(at) : ""; + while (!out.path.empty() && out.path.back() == ' ') out.path.pop_back(); + return out; +} + } // namespace roro::files diff --git a/lib/files/src/file_names.h b/lib/files/src/file_names.h index 2181163..aebc387 100644 --- a/lib/files/src/file_names.h +++ b/lib/files/src/file_names.h @@ -39,4 +39,12 @@ FileKind kindOf(const std::string& name); bool opensAtEnd(const std::string& name); // logs bool looksLikeText(const uint8_t* data, size_t len); +// `rm`'s arguments, as Unix has them (issue #67): -r for a folder and what's in it, -f for no +// question, alone or together (-rf, -fr, -r -f), then the path, which may hold spaces. +struct RmArgs { + bool recursive = false, force = false; + std::string path; +}; +RmArgs parseRm(const std::string& args); + } // namespace roro::files diff --git a/site/content/dev/debug/commands.md b/site/content/dev/debug/commands.md index bdca96d..0ddd1dd 100644 --- a/site/content/dev/debug/commands.md +++ b/site/content/dev/debug/commands.md @@ -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 | mkdir | rm [-f] | cp [-f] | mv [-f] | cancel the SD card, with the Storage App's rules +ls [folder] | du | mkdir | rm [-r] [-f] | cp [-f] | mv [-f] | cancel the SD card, with the Storage App's rules (rm -r for a folder; -f: the Shell doesn't ask) 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 the LoRa radio, receive only lora capture start|stop a LoRa Capture to /captures/lora (pcap, LoRaTap) @@ -38,6 +38,7 @@ irc start | irc stop | irc dump | irc say install Update from SD update check | update list | update status | update install the project's releases on Gitea sd card | sd list | cat | log | burst | sound on|off | short | normal +Irc | Wifi | Gnss | Gemini | Lora | Storage | Notes | Shell | System | Settings open that App: a capital letter is an App, not a command debug status | debug off [seconds] the Debug Console over Wi-Fi (Settings > Debug Console); with seconds, it comes back debug on | debug token <16 to 64 characters> | debug token new (USB serial only) switch it on, set its token crash abort|wdt crash on purpose (to test crash reports and Safe Mode) @@ -79,14 +80,15 @@ In **Safe Mode** (see [Crashes and Safe Mode](/dev/debug/crashes/)) only a few r | `irc say ` | Types into a Buffer, commands included (`irc say 0 /join #test`) | | `irc dump` | Prints IRC status, memory, and the last lines of each Buffer | | `wifi status` | Prints Wi-Fi state, network, signal, clock and free heap, then the address, gateway, DNS and NTP servers in use and where each came from | -| `info` | Firmware, uptime, last start reason, memory, Wi-Fi, the SD card and its write faults since boot, and both app slots with their versions and OTA states | +| `info` | Firmware, uptime, last start reason, memory, Wi-Fi, the SD card and its write faults since boot, **which App is in front**, and both app slots with their versions and OTA states | +| `Notes`, `Irc`, `Wifi`, `Gnss`, `Gemini`, `Lora`, `Storage`, `Shell`, `System`, `Settings` | Opens that App: a capital letter is an App, not a command | | `tasks` | FreeRTOS tasks over the next second: state, priority, lowest free stack, share of a core, each core's load, and how many passes the main loop made | | `net` | Bytes each network service has read and written since boot | | `reboot` / `boot other` | Restart, or restart into the other app slot (a manual Rollback) | | `log level <0-5>` | ESP-IDF log level | | `ls [folder]` / `du ` | 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] ` / `mv [-f] ` / `rm [-f] ` / `mkdir ` / `cancel` | What the Storage App does, with its rules: copy (folders too), move or rename, delete (a folder with what's in it), new folder. `-f` replaces a file that's in the way; a tab separates paths that hold spaces; `cancel` stops a copy or a delete | +| `cp [-f] ` / `mv [-f] ` / `rm [-r] [-f] ` / `mkdir ` / `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 | | `install ` | Update from SD with that `.ota` file, as Settings → Firmware does | | `update check` / `update list` / `update status` / `update install ` | 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 ` / `update probe ` / `update damage cut\|flip ` / `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 | diff --git a/site/content/dev/debug/drive-the-ui.md b/site/content/dev/debug/drive-the-ui.md index f15ef9d..e368b92 100644 --- a/site/content/dev/debug/drive-the-ui.md +++ b/site/content/dev/debug/drive-the-ui.md @@ -24,6 +24,16 @@ Two things to know before you use them: **`key help` opens the help panel** (Fn+h on the device): the keys of the screen that is showing. A screenshot of it is the quickest way to learn what a screen accepts, and it is how every screen's list was checked. Any key but the arrows closes it. +## Open an App by its name + +``` +Notes # an App's name, with a capital: opens it +Shell # Irc Wifi Gnss Gemini Lora Storage Notes Shell System Settings +info # ...and `app: Notes` says which App is in front +``` + +Far better than `key home`, some `key down` and `key select`: it doesn't depend on where the Launcher's selection was. + ## Look before you press **Take a screenshot before any key that deletes, renames or installs.** A blind sequence of `key` commands goes wrong the moment the screen is not where you think it is, and the screen is often not where you think it is: a Toast, a dialog that has not closed, a different App. A sequence that was meant to open a note once renamed real data instead. @@ -36,6 +46,7 @@ scripts/rdbg.py key select scripts/rdbg.py screenshot b.png # look again before the next destructive step ``` +- **Check the App in front before typing anything.** `info` prints `app: `. A crash restarts the device into the Launcher, and a script that goes on typing is typing somewhere else: one of this project's own test scripts sent a word to an IRC channel that way. - Prefer **reading a state** to assuming it: `info`, `ls `, `cat `, `irc dump`, `gnss status`, `lora status`, `wifi status`, `update status`. - Test on a **scratch folder** on the card, not on your real files. - For anything that deletes (`rm`, a delete dialog), `ls` first and `ls` after. diff --git a/site/content/dev/milestones/s1.md b/site/content/dev/milestones/s1.md index 6672c12..0542f9b 100644 --- a/site/content/dev/milestones/s1.md +++ b/site/content/dev/milestones/s1.md @@ -155,22 +155,34 @@ The console's commands could only be typed on a PC: over USB, or over Wi-Fi with |---|---| | Q204 | **An App, "Shell", in the Launcher**, in every firmware: its commands already work over USB for anyone holding the device. | | 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 | **It shows what the console prints while it is open**, replies and background alike, without the free-heap line every ten seconds. **Ctrl+b hides the background:** then only what is printed in the ten seconds after a command is kept. A reply can't be told from other output any better: `ls` and `tasks` answer later, from other tasks. | +| 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. Paths aren't completed. | -| Q209 | **`rm` asks first, `rm -f` doesn't** (in the Shell only: over the consoles, 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`. | +| Q209 | *Revised the same day.* **`rm` is Unix's, with a question.** A folder needs `-r`, here and over the consoles (where `rm ` 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`. | | 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. | +| Q213 | *Added the same day.* **An App's name with a capital opens it:** `Irc`, `Wifi`, `Gnss`, `Gemini`, `Lora`, `Storage`, `Notes`, `Shell`, `System`, `Settings` (the App's id, up to its first dash). From the Shell, without going back to the Launcher, and from the consoles too. The capital says "an App": every command of the firmware is in small letters. Tab completes them. | ### 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 two filters, the 4 KB limit, the command words out of the `help` text, and Tab (7 tests). -- **The console has a second ring** (`Console::openShellRing`), filled like the Debug Console's and independent of it. +- **`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. +- **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. -- **Cost:** 12 KB of flash, 16 bytes of static RAM. Open: 7 KB of heap (107.6 KB free before, 100.7 with it open, 106.1 after leaving). +- **Cost:** 16 KB of flash, 56 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 + +**The device crashed, and the test sent a message to an IRC channel.** The first version ran a command from inside the key handler. Driven from the Debug Console, that is: the main loop, a remote command, `key select`, the App manager, the Shell, `runCommand` a second time, the file command, and `printf` under all of it. The main loop has under 2 KB of stack to spare; `rm` on a folder went past it. The crash report decoded to exactly that chain. + +The device restarted into the Launcher, and the test script, which did not look, went on typing. Its next Enter opened IRC, which connected, and a few lines later it typed "No" into a channel and pressed Enter. One word, sent to real people, that can't be taken back. + +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 | @@ -179,7 +191,12 @@ The console's commands could only be typed on a PC: over USB, or over Wi-Fi with | Tab on `in` | `info install` is shown and the line stays; on `u` it becomes `update `; on `l`, `log ls lora loop` | | Up | The line before comes back | | `screenshot` | `/screenshots/20261007-104357.png`, 33,383 bytes. Fetched and decoded on the PC: 240 x 135, indexed, every chunk's CRC right, the pixels the Shell's screen | -| `rm /shelltest` | Asks. Cancel leaves the folder; Delete removes it. `rm -f` removes without asking | +| Output | With a Debug Console client connecting and disconnecting for every key, the Shell shows the commands and their replies and nothing else: `info`, `ls /` (answered by the storage task), `tasks` (answered a second later) | +| `rm` on a folder, without `-r` | Refused, the folder stays | +| `rm -r` on an empty folder | Removed, no question | +| `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 | +| `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 | diff --git a/site/content/guide/shell.md b/site/content/guide/shell.md index ff3fce6..cde3e9f 100644 --- a/site/content/guide/shell.md +++ b/site/content/guide/shell.md @@ -30,13 +30,31 @@ Type a command and press Enter. `help` lists them all; the [command r ## What you see -The Shell shows **everything the firmware prints** while it is open: the answers to your commands, and whatever the rest of the system says meanwhile (IRC connecting, a packet received). On a ten-line screen that can be a lot. +**The replies to your own commands, and nothing else.** The firmware prints a lot besides: IRC connecting, a packet received, whatever a PC on the USB port or the Debug Console is asking for. None of that reaches the Shell. The firmware knows who each line is printed for, so an answer that comes a moment later from another part of it (a folder listing, `tasks`) is still yours. -Ctrl + b hides the background: then only what is printed in the **ten seconds after each of your commands** is kept, and `mine` shows in the corner. It is ten seconds and not "the answer" because some commands answer a moment later, from another part of the firmware, and nothing marks their lines as theirs. +Ctrl + b shows **everything** the firmware prints instead, and `all` shows in the corner. Press it again to go back. -## Deleting asks first +## Opening an App -`rm ` asks before it deletes, here where a slip of the finger is one key away. `rm -f ` does not ask. +Type an App's name **with a capital letter** to open it, without going back to the Launcher: + +`Irc` `Wifi` `Gnss` `Gemini` `Lora` `Storage` `Notes` `System` `Settings` + +The capital is the difference: every command is in small letters, every App starts with a capital. Tab completes them too. + +## Deleting + +`rm` works as it does on Unix, with one addition: it asks. + +| You type | What happens | +|---|---| +| `rm /notes/a.txt` | Asks, then deletes the file | +| `rm -f /notes/a.txt` | Deletes it without asking | +| `rm /captures/old` | Refused: it is a folder, and a folder needs `-r` | +| `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 | + +The folders the firmware keeps its own files in can't be removed, as in the [Storage App](/guide/storage/). ## Screenshots diff --git a/site/data/keys.toml b/site/data/keys.toml index ce0f7c3..a38c2d5 100644 --- a/site/data/keys.toml +++ b/site/data/keys.toml @@ -383,12 +383,13 @@ rows = [ ["Tab", "complete the command"], ["Fn ; .", "lines you typed before"], ["Alt ; .", "scroll back, forward"], - ["Ctrl b", "all output, or only yours"], + ["Ctrl b", "your replies only, or all"], ["Fn , /", "move the cursor"], ["Del", "delete backwards"], ["help", "every command"], ["clear", "an empty screen"], - ["rm -f", "delete without being asked"], + ["Notes", "an App, by its name"], + ["rm -rf", "delete without being asked"], ["quit `", "leave the Shell"], ] diff --git a/src/apps/shell_app.cpp b/src/apps/shell_app.cpp index a0eb857..b480fd4 100644 --- a/src/apps/shell_app.cpp +++ b/src/apps/shell_app.cpp @@ -2,7 +2,10 @@ #include +#include + #include "app_keys.h" +#include "file_names.h" #include "platform/console.h" #include "ui/fonts.h" #include "ui/theme.h" @@ -18,10 +21,15 @@ bool startsWith(const std::string& s, const char* prefix) { return s.rfind(prefi void ShellApp::onEnter() { open_ = console.openShellRing(); + console.shellShowsAll(false); // its own replies only, each time it's opened 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"); @@ -32,19 +40,20 @@ void ShellApp::onEnter() { // Nothing is kept once it's left: the ring, the lines and the list of commands all go (Q207). void ShellApp::onExit() { console.closeShellRing(); + console.shellShowsAll(false); open_ = false; log_.clear(); std::vector().swap(words_); confirm_.reset(); } -void ShellApp::update(uint32_t nowMs) { +void ShellApp::update(uint32_t) { uint8_t buf[256]; uint32_t skipped = 0; size_t n; while ((n = console.readShellSince(ringPos_, buf, sizeof buf, skipped)) > 0) { if (skipped) log_.add("[... " + std::to_string(skipped) + " bytes lost: more was printed than fits]"); - log_.feed(reinterpret_cast(buf), n, nowMs); + log_.feed(reinterpret_cast(buf), n); } if (log_.revision() != seenRevision_) { seenRevision_ = log_.revision(); @@ -53,9 +62,8 @@ void ShellApp::update(uint32_t nowMs) { } void ShellApp::runNow(const std::string& line) { - log_.commandRun(millis()); - // The runner echoes the line into the console, where the Shell reads it back like everything - // else, except a token being set, which goes nowhere (Q210): that one is shown here, masked. + // The runner echoes the line into the console, where the Shell reads it back with the reply, + // except a token being set, which goes nowhere (Q210): that one is shown here, masked. if (startsWith(line, "debug token ") && line != "debug token new") log_.add("> debug token ..."); run_(line); } @@ -65,11 +73,19 @@ void ShellApp::enter(const std::string& line) { scroll_ = 0; if (line == "quit" || line == "exit") return apps_.home(); if (line == "clear") return log_.clear(); - // Q209: deleting asks first, here where a slip of the finger is a key away. `rm -f` doesn't. - if (startsWith(line, "rm ") && !startsWith(line, "rm -f ")) { - pending_ = line; - confirm_.reset(new DialogModel({"Cancel", "Delete"})); - return; + // Q209: `rm` as Unix has it, with a question where Unix has none, since a slip of the finger is + // a key away here. A file, or a folder with something in it, is asked about unless -f says not + // 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); + 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; + } } runNow(line); } @@ -91,8 +107,8 @@ bool ShellApp::onKey(const KeyEvent& e) { return true; } if (e.key == Key::Char && e.ctrl && (e.ch == 'b' || e.ch == 'B')) { // Q206 - log_.showBackground(!log_.showsBackground()); - log_.add(log_.showsBackground() ? "Showing everything the console prints." : "Showing only what follows your commands."); + console.shellShowsAll(!console.shellShowsAll()); + log_.add(console.shellShowsAll() ? "Showing everything the console prints." : "Showing only the replies to your commands."); return true; } std::string recalled; @@ -167,14 +183,14 @@ void ShellApp::draw(Canvas& c) { if (scroll_ > 0) { c.setTextColor(theme::kWarning); c.drawString(("^ " + std::to_string(scroll_)).c_str(), area.w - 3, output.y + 1); - } else if (!log_.showsBackground()) { + } else if (console.shellShowsAll()) { c.setTextColor(theme::kMuted); - c.drawString("mine", area.w - 3, output.y + 1); + c.drawString("all", area.w - 3, output.y + 1); } c.setTextDatum(top_left); widgets::lineEditor(c, input_, {2, area.y + area.h - inputH, area.w - 4, 0}); - if (confirm_) widgets::dialog(c, "Delete?", pending_.substr(3) + ", with what's inside it. It can't be undone.", *confirm_); + if (confirm_) widgets::dialog(c, "Delete?", question_, *confirm_); } } // namespace roro diff --git a/src/apps/shell_app.h b/src/apps/shell_app.h index bc0926d..340a5ca 100644 --- a/src/apps/shell_app.h +++ b/src/apps/shell_app.h @@ -18,13 +18,18 @@ namespace roro { // place to type them, after USB serial and the Debug Console, and trusted like the first: whoever // holds the device can do all of it in Settings anyway (Q205). // -// It shows what the console prints while it is open, read from a ring of the console's that exists -// only meanwhile; nothing is kept once the App is left. +// It shows the replies to its own commands, and only those unless asked otherwise (Q206): the +// console knows who each line was printed for (Console::Origin) and fills a ring that exists only +// while the App is open. Nothing is kept once it is left. class ShellApp : public App { public: using Run = std::function; + // What `rm` is pointed at: it decides whether the Shell asks first. + enum class Target { Missing, File, EmptyFolder, FullFolder }; + using Probe = std::function; - ShellApp(Run run, const char* helpText, AppManager& apps) : run_(std::move(run)), helpText_(helpText), apps_(apps) {} + ShellApp(Run run, Probe probe, const char* helpText, AppManager& apps) + : run_(std::move(run)), probe_(std::move(probe)), helpText_(helpText), apps_(apps) {} void onEnter() override; void onExit() override; @@ -39,6 +44,7 @@ class ShellApp : public App { void runNow(const std::string& line); Run run_; + Probe probe_; const char* helpText_; AppManager& apps_; ShellLog log_; @@ -46,7 +52,7 @@ class ShellApp : public App { LineEditor input_{240}; InputHistory history_{16}; std::unique_ptr confirm_; - std::string pending_; // the `rm` being asked about + std::string pending_, question_; // the `rm` being asked about, and what's asked uint32_t ringPos_ = 0, seenRevision_ = 0; int scroll_ = 0; // wrapped lines scrolled back from the bottom bool open_ = false; diff --git a/src/main.cpp b/src/main.cpp index dcfa410..f232e00 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -3,6 +3,7 @@ #include +#include #include #include @@ -155,6 +156,7 @@ size_t getArduinoLoopTaskStackSize() { return 6144; } static void setupSafeMode(int crashes); static const char* helpText(); static void shellRun(const std::string& line); +static ShellApp::Target shellProbe(const std::string& path); void setup() { nvs.begin(); @@ -213,7 +215,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, helpText(), *apps)}); + apps->registerApp({"shell", "Shell", false, new ShellApp(shellRun, shellProbe, 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, @@ -275,10 +277,13 @@ static void setupSafeMode(int crashes) { // Dev aid: commands to drive the UI without the keyboard, from the serial port or the Debug Console. static bool listingWanted = false; +// Who asked for each reply that is printed later (Console::Origin): the Shell sees its own, only. +static Console::Origin listingFrom, fileOpFrom, updateFrom, tasksFrom; static void printListingWhenReady() { if (!listingWanted || !storageService->listingReady()) return; listingWanted = false; + Console::As as(listingFrom); auto listing = storageService->listing(); for (size_t i = 0; i < listing.size(); i++) { uint64_t bytes = 0; @@ -431,6 +436,7 @@ static void fileOpsStep() { FileOps::Status s; if (!consoleFileOp || !fileOps->finished(s)) return; consoleFileOp = false; + Console::As as(fileOpFrom); static const char* const kNames[] = {"", "ls", "du", "cp", "mv", "rm", "mkdir"}; const char* name = kNames[static_cast(s.op)]; if (!s.error.empty()) console.printf("%s: error %s\n", name, s.error.c_str()); @@ -464,6 +470,7 @@ static void printReleases(bool list) { static void updateStep() { if (!updateWatch || update->giteaBusy()) return; + Console::As as(updateFrom); if (updateWatch == 3) { console.printf("update: probe: %s\n", update->probeResult().c_str()); updateWatch = 0; @@ -478,6 +485,7 @@ static void updateCommand(const String& args) { if (update->giteaBusy()) return (void)console.println("update: busy"); args == "check" ? update->requestCheck() : update->requestList(); updateWatch = args == "check" ? 1 : 2; + updateFrom = console.origin(); } else if (args == "status") { GiteaReleases& g = update->gitea(); console.printf("update: running %s, failed here before: %s, daily check %s, heap %u\n", update->runningVersion().c_str(), @@ -494,6 +502,7 @@ static void updateCommand(const String& args) { int space = rest.indexOf(' '); update->requestProbe((space < 0 ? rest : rest.substring(0, space)).c_str(), space < 0 ? "/" : rest.substring(space + 1).c_str()); updateWatch = 3; + updateFrom = console.origin(); } else if (args.startsWith("damage ")) { // update damage cut | flip : for the next download long n = args.substring(args.lastIndexOf(' ') + 1).toInt(); args.startsWith("damage cut") ? update->damageNextDownload(n, -1) : update->damageNextDownload(-1, n); @@ -513,6 +522,7 @@ static void updateCommand(const String& args) { 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(); + const std::string everything = rest; // rm reads its own switches 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 @@ -524,9 +534,13 @@ static void fileCommand(const String& line) { 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 (command == "rm") { // as Unix has it: a folder needs -r. The questions are the Shell's (-f is its) + files::RmArgs args = files::parseRm(everything); + folder = fileOps->isFolder(args.path, exists); + if (args.path.empty()) why = "rm [-r] [-f] "; + else if (!exists) why = "It isn't there"; + else if (folder && !args.recursive) why = "It's a folder: rm -r removes it and what's in it"; + else why = fileOps->remove(args.path, folder); } else if (b.empty()) why = "two paths, please"; else { folder = fileOps->isFolder(a, exists); @@ -538,6 +552,7 @@ static void fileCommand(const String& line) { } if (!why.empty()) return (void)console.printf("%s: error %s\n", command.c_str(), why.c_str()); consoleFileOp = true; + fileOpFrom = console.origin(); } static uint32_t loopPasses = 0; // counted in loop(), for `tasks` (issue #40) @@ -547,6 +562,7 @@ static uint32_t tasksPasses = 0; static void tasksStep() { if (!tasksPending || static_cast(millis() - tasksDueMs) < 0) return; tasksPending = false; + Console::As as(tasksFrom); system_info::printTasks(console, tasksBefore, tasksTotal); console.printf("loop: %lu passes in the last second, chip %.1f C\n", (unsigned long)(loopPasses - tasksPasses), temperatureRead()); std::vector().swap(tasksBefore); @@ -558,7 +574,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 | mkdir | rm [-f] | cp [-f] | mv [-f] | cancel the SD card, with the Storage App's rules\n" + "ls [folder] | du | mkdir | rm [-r] [-f] | cp [-f] | mv [-f] | cancel the SD card, with the Storage App's rules (rm -r for a folder; -f: the Shell doesn't ask)\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 the LoRa radio, receive only\n" "lora capture start|stop a LoRa Capture to /captures/lora (pcap, LoRaTap)\n" @@ -577,6 +593,7 @@ static const char* const kHelp = "install Update from SD\n" "update check | update list | update status | update install the project's releases on Gitea\n" "sd card | sd list | cat | log | burst | sound on|off | short | normal\n" + "Irc | Wifi | Gnss | Gemini | Lora | Storage | Notes | Shell | System | Settings open that App: a capital letter is an App, not a command\n" "debug status | debug off [seconds] the Debug Console over Wi-Fi (Settings > Debug Console); with seconds, it comes back\n" "debug on | debug token <16 to 64 characters> | debug token new (USB serial only) switch it on, set its token\n" "crash abort|wdt crash on purpose (to test crash reports and Safe Mode)\n" @@ -596,6 +613,7 @@ static const char* helpText() { return kHelp; } // `screenshot [seconds]` (issue #67, Q209): the frame as it is composed, as a PNG on the card. The // pause is for getting to the screen you want: from the Shell, "now" is always the Shell. static bool shotPending = false; +static Console::Origin shotFrom; static uint32_t shotDueMs = 0; static std::atomic shotSaved{false}; @@ -611,7 +629,9 @@ static void saveScreenshot() { local.tm_hour, local.tm_min, local.tm_sec); } else snprintf(name, sizeof name, "/screenshots/shot-%lu.png", (unsigned long)(millis() / 1000)); // no clock yet std::string path = name; - storageService->runJob([path]() { + Console::Origin from = shotFrom; + storageService->runJob([path, from]() { + Console::As as(from); Canvas& frame = screen.canvas(); const uint8_t* pixels = static_cast(frame.getBuffer()); int w = frame.width(), h = frame.height(); @@ -696,10 +716,22 @@ static void runCommand(String line, bool fromSerial = false) { if (line.isEmpty()) return; if (safeMode && !safeModeCommand(line)) return (void)console.println("not available in Safe Mode"); if (line == "help") console.print(kHelp); + if (line[0] >= 'A' && line[0] <= 'Z') { // an App, by its name with a capital + const AppInfo* app = apps ? apps->byCommand(line.c_str()) : nullptr; + if (!app) { + console.printf("No App called %s:", line.c_str()); + if (apps) + for (auto* a : apps->visibleApps()) console.printf(" %s", AppManager::commandFor(a->id).c_str()); + console.println(); + } else if (apps->open(app->id)) console.printf("%s\n", app->title); + else console.println("Not now: Setup is running"); + return; + } if (line.startsWith("debug ")) return debugCommand(line.substring(6), fromSerial); if (line == "screenshot" || line.startsWith("screenshot ")) { uint32_t seconds = constrain(line.substring(10).toInt(), 0, 60); shotPending = true; + shotFrom = console.origin(); shotDueMs = millis() + seconds * 1000; if (seconds) console.printf("screenshot: in %lu s\n", (unsigned long)seconds); return; @@ -711,6 +743,7 @@ static void runCommand(String line, bool fromSerial = false) { system_info::printSystem(console); console.printf("wifi: %s, ip %s, rssi %d | sd: %s, %u write faults\n", wifi->ssid().c_str(), wifi->ip().c_str(), wifi->rssi(), storageService->state().present ? "present" : "none", (unsigned)sdLastFault().count); + if (apps) console.printf("app: %s%s\n", apps->foregroundTitle() ? apps->foregroundTitle() : "Launcher", apps->help().isOpen() ? " (the help panel is open)" : ""); console.printf("update: %s | debug console: %s\n", update->onProbation() ? "on probation" : "confirmed", !debugConsole->on() ? "off" : debugConsole->clientConnected() ? "on, a client connected" : "on"); system_info::printSlots(console, nvs); @@ -724,6 +757,7 @@ static void runCommand(String line, bool fromSerial = false) { tasksDueMs = millis() + 1000; tasksPasses = loopPasses; tasksPending = true; + tasksFrom = console.origin(); } if (line == "net") { // bytes each service has read and written since boot (S1, Q121) for (int i = 0; i < static_cast(net::User::Count); i++) { @@ -750,7 +784,9 @@ static void runCommand(String line, bool fromSerial = false) { 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() : "/"; - storageService->runJob([path]() { // card access stays on the storage task + Console::Origin from = console.origin(); + storageService->runJob([path, 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()) @@ -764,7 +800,9 @@ static void runCommand(String line, bool fromSerial = false) { 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]() { + Console::Origin from = console.origin(); + storageService->runJob([folder, count, from]() { + Console::As as(from); int made = 0; for (int i = 1; i <= count; i++) { char name[24]; @@ -950,7 +988,9 @@ static void runCommand(String line, bool fromSerial = false) { if (line.startsWith("sd put ")) startUpload(line.substring(7)); // then raw bytes: see uploadStep() if (line == "sd card") { // what the card says it is, from its CID register if (!storageService->state().present) return (void)console.println("sd card: no card"); - storageService->runJob([]() { + Console::Origin from = console.origin(); + storageService->runJob([from]() { + Console::As as(from); uint8_t cid[16]; const char* type = SD.cardType() == CARD_SDHC ? "SDHC/SDXC" : SD.cardType() == CARD_SD ? "SDSC" : "MMC or unknown"; if (!sdReadCid(cid)) return (void)console.println("sd card: the card didn't answer"); @@ -963,6 +1003,7 @@ static void runCommand(String line, bool fromSerial = false) { if (line == "sd list") { storageService->requestListing(); listingWanted = true; + listingFrom = console.origin(); } if (line.startsWith("gemini trust ")) { // gemini trust : accept a changed certificate char host[96] = "", fp[80] = ""; @@ -1078,10 +1119,43 @@ static void runCommand(String line, bool fromSerial = false) { // What the Shell App runs (issue #67): trusted like USB serial (Q205), and echoed into the console so // that a session reads the same from afar, except a token being set (Q210). +// +// The App only hands the line over: it is run from the main loop, like the consoles' commands, not +// from inside the key handler. Run from there, `rm` on a folder overflowed the main loop's stack +// (a key from the Debug Console, the App manager, the Shell, then runCommand a second time and +// printf under all of it): the loop has under 2 KB of stack to spare. +static std::deque shellLines; + static void shellRun(const std::string& line) { - String l = line.c_str(); - if (!l.startsWith("debug token ") || l == "debug token new") console.printf("> %s\n", line.c_str()); - runCommand(l, true); + if (shellLines.size() < 8) shellLines.push_back(line); +} + +static void shellCommands() { + while (!shellLines.empty()) { + std::string line = std::move(shellLines.front()); + shellLines.pop_front(); + Console::As as(Console::Origin::Shell); // what it prints, and what answers it later, is the Shell's + String l = line.c_str(); + if (!l.startsWith("debug token ") || l == "debug token new") console.printf("> %s\n", line.c_str()); + runCommand(l, true); + } +} + +// What a path is, for the Shell to know whether `rm` deserves a question (Q209). +static ShellApp::Target shellProbe(const std::string& path) { + bool exists = false; + bool folder = fileOps->isFolder(path, exists); + if (!exists) return ShellApp::Target::Missing; + if (!folder) return ShellApp::Target::File; + bool empty = true; + storageService->runAndWait([&]() { + File dir = SD.open(path.c_str()); + if (dir) { + File first = dir.openNextFile(); + empty = !first; + } + }); + return empty ? ShellApp::Target::EmptyFolder : ShellApp::Target::FullFolder; } static void serialCommands() { @@ -1172,6 +1246,7 @@ static void loopPass() { serialCommands(); remoteCommands(); + shellCommands(); tasksStep(); screenshotStep(); ipTrialStep(); diff --git a/src/platform/console.cpp b/src/platform/console.cpp index db94d73..ddfd4e6 100644 --- a/src/platform/console.cpp +++ b/src/platform/console.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include @@ -37,6 +38,20 @@ void Console::captureEspLogs() { namespace { +// The tasks printing As the Shell right now, and how deep each is in it. Under ringLock. Two at +// once is the most there is (the main loop, and the storage task finishing a listing). +struct ShellTask { + TaskHandle_t task; + uint8_t depth; +}; +ShellTask shellTasks[4] = {}; + +bool fromShellLocked(TaskHandle_t task) { + for (auto& t : shellTasks) + if (t.depth && t.task == task) return true; + return false; +} + // One ring: the last kRingBytes written, and how many were written in all. Under ringLock. bool openOne(uint8_t*& ring, uint32_t& head) { if (ring) return true; @@ -97,9 +112,45 @@ void Console::toRing(const uint8_t* data, size_t len) { data += len - kRingBytes; // only the tail can fit len = kRingBytes; } + TaskHandle_t task = xTaskGetCurrentTaskHandle(); portENTER_CRITICAL(&ringLock); writeOne(ring_, head_, data, len); - writeOne(shellRing_, shellHead_, data, len); + if (shellRing_ && (shellAll_ || fromShellLocked(task))) writeOne(shellRing_, shellHead_, data, len); + portEXIT_CRITICAL(&ringLock); +} + +Console::Origin Console::origin() const { + TaskHandle_t task = xTaskGetCurrentTaskHandle(); + portENTER_CRITICAL(&ringLock); + bool shell = fromShellLocked(task); + portEXIT_CRITICAL(&ringLock); + return shell ? Origin::Shell : Origin::System; +} + +Console::As::As(Origin origin) : entered_(false) { + if (origin != Origin::Shell) return; + TaskHandle_t task = xTaskGetCurrentTaskHandle(); + portENTER_CRITICAL(&ringLock); + ShellTask* slot = nullptr; + for (auto& t : shellTasks) + if (t.depth && t.task == task) slot = &t; + if (!slot) + for (auto& t : shellTasks) + if (!t.depth && !slot) slot = &t; + if (slot) { + slot->task = task; + slot->depth++; + entered_ = true; + } + portEXIT_CRITICAL(&ringLock); +} + +Console::As::~As() { + if (!entered_) return; + TaskHandle_t task = xTaskGetCurrentTaskHandle(); + portENTER_CRITICAL(&ringLock); + for (auto& t : shellTasks) + if (t.depth && t.task == task) t.depth--; portEXIT_CRITICAL(&ringLock); } diff --git a/src/platform/console.h b/src/platform/console.h index d071328..a3a06c9 100644 --- a/src/platform/console.h +++ b/src/platform/console.h @@ -34,10 +34,30 @@ class Console : public Print { // The ring only, for output that already reaches the serial port another way. void toRing(const uint8_t* data, size_t len); - // A second ring of the same kind, for the Shell App (issue #67): open while the App is, so that - // it can show what the console prints. The two don't know of each other. + // Who asked for what is being printed. A command typed in the Shell App prints its reply `As` + // the Shell, and so does whatever answers it later from another task (a listing read by the + // storage task, `tasks` a second on): the one that starts the work notes origin() and the one that + // prints takes it back. Everything else is the System's: logs, and the consoles' own commands. + enum class Origin : uint8_t { System, Shell }; + Origin origin() const; // of the task that asks, now + class As { + public: + explicit As(Origin origin); + ~As(); + As(const As&) = delete; + As& operator=(const As&) = delete; + + private: + bool entered_; + }; + + // A second ring of the same kind, for the Shell App (issue #67): open while the App is. It gets + // what is printed As the Shell, and nothing else, so that the Shell shows the replies to its own + // commands and not the rest (Q206); or everything, when asked. bool openShellRing(); void closeShellRing(); + void shellShowsAll(bool all) { shellAll_ = all; } + bool shellShowsAll() const { return shellAll_; } size_t readShellSince(uint32_t& pos, uint8_t* out, size_t max, uint32_t& skipped); private: @@ -45,6 +65,7 @@ class Console : public Print { uint32_t head_ = 0; // total bytes written since the ring opened; it holds the last kRingBytes of them uint8_t* shellRing_ = nullptr; uint32_t shellHead_ = 0; + volatile bool shellAll_ = false; }; extern Console console; diff --git a/src/services/gemini_service.cpp b/src/services/gemini_service.cpp index 59ab1d9..7d8478d 100644 --- a/src/services/gemini_service.cpp +++ b/src/services/gemini_service.cpp @@ -140,6 +140,7 @@ bool GeminiService::fetch(const std::string& url, bool preferSaved) { bool GeminiService::fetchToConsole(const std::string& url) { if (busy_) return false; toConsole_ = true; + consoleFrom_ = console.origin(); if (!fetch(url)) { toConsole_ = false; return false; @@ -380,7 +381,10 @@ void GeminiService::runFetch(GeminiPage& page) { } } page.ms = millis() - started; - if (toConsole_) report(page, before, lowest_); + if (toConsole_) { + Console::As as(consoleFrom_); + report(page, before, lowest_); + } } // One request, following up to kMaxRedirects redirects. diff --git a/src/services/gemini_service.h b/src/services/gemini_service.h index 3b43e05..8756b95 100644 --- a/src/services/gemini_service.h +++ b/src/services/gemini_service.h @@ -6,6 +6,7 @@ #include #include +#include "platform/console.h" #include "event_bus.h" #include "gemini_response.h" #include "key_value_store.h" @@ -95,6 +96,7 @@ class GeminiService { // `gemini get `: the same fetch, reported on the console instead of to the App. bool fetchToConsole(const std::string& url); + Console::Origin consoleFrom_ = Console::Origin::System; // who asked for that fetch: its report is theirs private: enum class Job { Fetch, Save, SaveWithLinks, Refresh, Delete, Bookmark, Download, Window }; diff --git a/test/test_app_manager/test_app_manager.cpp b/test/test_app_manager/test_app_manager.cpp index 20e11dc..018c2bd 100644 --- a/test/test_app_manager/test_app_manager.cpp +++ b/test/test_app_manager/test_app_manager.cpp @@ -260,6 +260,19 @@ void test_the_help_panel_scrolls_within_its_rows() { TEST_ASSERT_EQUAL(0, m.rows().size()); } +// Issue #67: an App is opened by its name with a capital letter. +void test_an_app_command_is_its_id_with_a_capital() { + TEST_ASSERT_EQUAL_STRING("Notes", AppManager::commandFor("notes").c_str()); + TEST_ASSERT_EQUAL_STRING("Wifi", AppManager::commandFor("wifi-tools").c_str()); + TEST_ASSERT_EQUAL_STRING("Irc", AppManager::commandFor("irc").c_str()); + Fixture f; + TEST_ASSERT_NOT_NULL(f.manager.byCommand("Notes")); + TEST_ASSERT_EQUAL_STRING("notes", f.manager.byCommand("Notes")->id); + TEST_ASSERT_NULL(f.manager.byCommand("notes")); // small letters are commands, not Apps + TEST_ASSERT_NULL(f.manager.byCommand("Demo")); // hidden: not in the Launcher, not here + TEST_ASSERT_NULL(f.manager.byCommand("Nope")); +} + int main() { UNITY_BEGIN(); RUN_TEST(test_launcher_is_in_foreground_after_begin); @@ -283,5 +296,6 @@ int main() { RUN_TEST(test_open_help_takes_every_key_and_any_but_the_arrows_closes_it); RUN_TEST(test_help_works_in_a_modal_app_and_closes_when_the_app_changes); RUN_TEST(test_the_help_panel_scrolls_within_its_rows); + RUN_TEST(test_an_app_command_is_its_id_with_a_capital); return UNITY_END(); } diff --git a/test/test_file_names/test_file_names.cpp b/test/test_file_names/test_file_names.cpp index 40730e8..f900e40 100644 --- a/test/test_file_names/test_file_names.cpp +++ b/test/test_file_names/test_file_names.cpp @@ -104,6 +104,30 @@ void test_looks_like_text() { TEST_ASSERT_TRUE(looksLikeText(nullptr, 0)); // an empty file reads as text } +void test_rm_takes_its_switches_like_unix() { + auto a = parseRm("/notes/a.txt"); + TEST_ASSERT_FALSE(a.recursive); + TEST_ASSERT_FALSE(a.force); + TEST_ASSERT_EQUAL_STRING("/notes/a.txt", a.path.c_str()); + a = parseRm("-r /gemini/saved"); + TEST_ASSERT_TRUE(a.recursive); + TEST_ASSERT_FALSE(a.force); + TEST_ASSERT_EQUAL_STRING("/gemini/saved", a.path.c_str()); + for (const char* both : {"-rf /x", "-fr /x", "-r -f /x", "-f -r /x", "-R -f /x"}) { + a = parseRm(both); + TEST_ASSERT_TRUE(a.recursive); + TEST_ASSERT_TRUE(a.force); + TEST_ASSERT_EQUAL_STRING("/x", a.path.c_str()); + } + a = parseRm("-f /a file with spaces.txt "); + TEST_ASSERT_TRUE(a.force); + TEST_ASSERT_EQUAL_STRING("/a file with spaces.txt", a.path.c_str()); + a = parseRm("-x /odd"); // not a switch of rm: taken as the name + TEST_ASSERT_FALSE(a.force); + TEST_ASSERT_EQUAL_STRING("-x /odd", a.path.c_str()); + TEST_ASSERT_EQUAL_STRING("", parseRm("-rf").path.c_str()); +} + int main() { UNITY_BEGIN(); RUN_TEST(test_path_parts); @@ -114,5 +138,6 @@ int main() { RUN_TEST(test_copy_names); RUN_TEST(test_kinds); RUN_TEST(test_looks_like_text); + RUN_TEST(test_rm_takes_its_switches_like_unix); return UNITY_END(); } diff --git a/test/test_shell_log/test_shell_log.cpp b/test/test_shell_log/test_shell_log.cpp index b480ebc..9c3ee1b 100644 --- a/test/test_shell_log/test_shell_log.cpp +++ b/test/test_shell_log/test_shell_log.cpp @@ -10,13 +10,13 @@ using namespace roro; void setUp() {} void tearDown() {} -static void feed(ShellLog& log, const std::string& text, uint32_t now) { log.feed(text.data(), text.size(), now); } +static void feed(ShellLog& log, const std::string& text) { log.feed(text.data(), text.size()); } void test_lines_arrive_in_pieces() { ShellLog log; - feed(log, "firmware: roro", 0); + feed(log, "firmware: roro"); TEST_ASSERT_EQUAL(0, log.lines().size()); - feed(log, "9stack\r\nuptime: 1s\n", 0); + feed(log, "9stack\r\nuptime: 1s\n"); TEST_ASSERT_EQUAL(2, log.lines().size()); TEST_ASSERT_EQUAL_STRING("firmware: roro9stack", log.lines()[0].c_str()); TEST_ASSERT_EQUAL_STRING("uptime: 1s", log.lines()[1].c_str()); @@ -24,38 +24,14 @@ void test_lines_arrive_in_pieces() { void test_the_heap_line_is_never_kept() { ShellLog log; - feed(log, "status: heap 105000 min 90000\nirc: connected\n", 0); + feed(log, "status: heap 105000 min 90000\nirc: connected\n"); TEST_ASSERT_EQUAL(1, log.lines().size()); TEST_ASSERT_EQUAL_STRING("irc: connected", log.lines()[0].c_str()); } -// Q206: with background lines hidden, only what's printed in the ten seconds after a command. -void test_mine_only_keeps_what_follows_a_command() { - ShellLog log; - log.showBackground(false); - feed(log, "irc: someone joined\n", 1000); // no command yet - TEST_ASSERT_EQUAL(0, log.lines().size()); - log.commandRun(5000); - feed(log, "ls: end of /\n", 5200); // the reply, a moment later, from another task - feed(log, "tasks: ...\n", 14999); - feed(log, "irc: someone left\n", 15000); // ten seconds on: background again - TEST_ASSERT_EQUAL(2, log.lines().size()); - log.showBackground(true); - feed(log, "irc: back\n", 99000); - TEST_ASSERT_EQUAL(3, log.lines().size()); -} - -void test_mine_only_holds_across_the_clock_wrap() { - ShellLog log; - log.showBackground(false); - log.commandRun(0xFFFFFF00u); - feed(log, "reply\n", 0x00000100u); // 512 ms later, past the wrap - TEST_ASSERT_EQUAL(1, log.lines().size()); -} - void test_the_oldest_lines_go_past_4_kb() { ShellLog log; - for (int i = 0; i < 200; i++) feed(log, std::string(49, 'x') + "\n", 0); // 10 KB in all + for (int i = 0; i < 200; i++) feed(log, std::string(49, 'x') + "\n"); // 10 KB in all size_t bytes = 0; for (auto& l : log.lines()) bytes += l.size() + 1; TEST_ASSERT_TRUE(bytes <= ShellLog::kMaxBytes); @@ -74,11 +50,12 @@ static const char* kHelp = "lora probe | lora status | lora rx on|off the LoRa radio\n" "log level <0-5> ESP-IDF log level\n" "sd card | sd list | cat | log | burst\n" - "get | put | screenshot (Debug Console only) binary\n"; + "get | put | 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"}; + 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()); } @@ -96,14 +73,14 @@ void test_tab_completes_the_first_word() { 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 } int main(int, char**) { UNITY_BEGIN(); RUN_TEST(test_lines_arrive_in_pieces); RUN_TEST(test_the_heap_line_is_never_kept); - RUN_TEST(test_mine_only_keeps_what_follows_a_command); - RUN_TEST(test_mine_only_holds_across_the_clock_wrap); 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);