Shell: only its own replies, Apps by their names, and rm as Unix has it (#67)
CI / build (pull_request) Successful in 1m38s
Site / build (pull_request) Successful in 10s

The Shell shows the replies to its own commands and nothing else. The
console knows who each line is printed for (Console::As, Console::origin):
a command run from the Shell prints as the Shell's, and what answers it
later from another task carries that along (ls, tasks, du, cp, update
check, sd list, screenshot, gemini get). Ctrl+b shows everything instead.
This replaces the ten-second window, which was a guess.

An App's name with a capital opens it (Notes, Irc, Wifi, Gnss, Gemini, Lora,
Storage, Shell, System, Settings), from the Shell and from the consoles.

rm needs -r for a folder, here and over the consoles. In the Shell a file,
or a folder with something in it, is asked about unless -f; an empty folder
with -r goes without a word.

The Shell now hands its line to the main loop to run: run from inside the
key handler, rm on a folder overflowed the loop's stack and crashed the
device. `info` says which App is in front.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
2026-10-07 11:35:29 +02:00
co-authored by Claude Opus 5.5
parent 3863d28593
commit 7b5df713ad
24 changed files with 414 additions and 124 deletions
+87 -12
View File
@@ -3,6 +3,7 @@
#include <SD.h>
#include <deque>
#include <atomic>
#include <memory>
@@ -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<int>(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 <bytes> | flip <offset>: 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] <path>";
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<int32_t>(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<TaskSample>().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 <path> | mkdir <path> | rm [-f] <path> | cp [-f] <from> <to> | mv [-f] <from> <to> | cancel the SD card, with the Storage App's rules\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"
"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"
@@ -577,6 +593,7 @@ static const char* const kHelp =
"install <path.ota> Update from SD\n"
"update check | update list | update status | update install <tag> the project's releases on Gitea\n"
"sd card | sd list | cat <path> | log <text> | 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<bool> 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<const uint8_t*>(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<int>(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 <host> <port> <sha256>: 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<std::string> 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();