Shell: the console's commands on the device's own screen and keyboard (#67)
CI / build (pull_request) Successful in 1m48s
Site / build (pull_request) Successful in 9s

An App in the Launcher that runs the same commands as USB serial and the
Debug Console, trusted like the first. It shows what the console prints
while it is open, through a second ring of the console's that exists only
meanwhile; Ctrl+b keeps only what follows your own commands. Tab completes
a command's name from the firmware's help text, Fn with up and down recalls
earlier lines, Alt with up and down scrolls back. `rm` asks first in the
Shell, `rm -f` doesn't. Nothing is kept once the App is left.

`screenshot [seconds]` saves the screen as a PNG in /screenshots on the
card, now or after a pause: written a row at a time, indexed colour with
RGB332 as the palette, in one stored deflate block.

487 host tests (11 new: the PNG writer, the Shell's log filter, Tab).
Checked on the device over the Debug Console: commands, Tab, history, a
screenshot fetched and decoded on the PC, rm with and without the question,
a delayed screenshot of another screen. 12 KB of flash; 7 KB of heap while
open. Decisions Q204 to Q212 in docs/milestones/S1.md. Safe Mode: #77.

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 10:53:31 +02:00
co-authored by Claude Opus 5.5
parent c278a06ca1
commit 3863d28593
29 changed files with 1112 additions and 50 deletions
+111
View File
@@ -0,0 +1,111 @@
#include <unity.h>
#include <string>
#include <vector>
#include "shell_log.h"
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); }
void test_lines_arrive_in_pieces() {
ShellLog log;
feed(log, "firmware: roro", 0);
TEST_ASSERT_EQUAL(0, log.lines().size());
feed(log, "9stack\r\nuptime: 1s\n", 0);
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());
}
void test_the_heap_line_is_never_kept() {
ShellLog log;
feed(log, "status: heap 105000 min 90000\nirc: connected\n", 0);
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
size_t bytes = 0;
for (auto& l : log.lines()) bytes += l.size() + 1;
TEST_ASSERT_TRUE(bytes <= ShellLog::kMaxBytes);
TEST_ASSERT_TRUE(log.lines().size() >= 80);
uint32_t before = log.revision();
log.add("> info");
TEST_ASSERT_EQUAL_STRING("> info", log.lines().back().c_str());
TEST_ASSERT_TRUE(log.revision() != before);
log.clear();
TEST_ASSERT_EQUAL(0, log.lines().size());
}
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"
"log level <0-5> ESP-IDF log level\n"
"sd card | sd list | cat <path> | log <text> | burst\n"
"get <path> | put <path> <size> <sha256> | screenshot (Debug Console only) binary\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"};
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());
}
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());
}
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);
return UNITY_END();
}