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
+95
View File
@@ -0,0 +1,95 @@
#include "shell_log.h"
#include <algorithm>
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;
while (bytes_ > kMaxBytes && lines_.size() > 1) {
bytes_ -= lines_.front().size() + 1;
lines_.pop_front();
}
revision_++;
}
void ShellLog::add(const std::string& line) { push(line); }
void ShellLog::feed(const char* data, size_t len, uint32_t nowMs) {
for (size_t i = 0; i < len; i++) {
char c = data[i];
if (c == '\r') continue;
if (c != '\n') {
if (partial_.size() < 512) partial_ += c; // a line that never ends doesn't take the heap
continue;
}
if (wanted(partial_, nowMs)) push(partial_);
partial_.clear();
}
}
void ShellLog::clear() {
lines_.clear();
partial_.clear();
bytes_ = 0;
revision_++;
}
std::vector<std::string> commandWords(const char* helpText) {
std::vector<std::string> words;
std::string text = helpText ? helpText : "";
size_t lineStart = 0;
while (lineStart < text.size()) {
size_t lineEnd = text.find('\n', lineStart);
if (lineEnd == std::string::npos) lineEnd = text.size();
std::string line = text.substr(lineStart, lineEnd - lineStart);
// The commands stop where the description starts: three spaces.
size_t gap = line.find(" ");
std::string commands = line.substr(0, gap);
size_t at = 0;
while (at <= commands.size()) {
size_t bar = commands.find(" | ", at);
std::string one = commands.substr(at, bar == std::string::npos ? std::string::npos : bar - at);
size_t start = one.find_first_not_of(' ');
if (start != std::string::npos) {
size_t end = one.find(' ', start);
std::string word = one.substr(start, end == std::string::npos ? std::string::npos : end - start);
bool plain = !word.empty() && std::all_of(word.begin(), word.end(), [](char ch) { return ch >= 'a' && ch <= 'z'; });
if (plain && std::find(words.begin(), words.end(), word) == words.end()) words.push_back(word);
}
if (bar == std::string::npos) break;
at = bar + 3;
}
lineStart = lineEnd + 1;
}
return words;
}
std::string completeCommand(const std::string& typed, const std::vector<std::string>& words, std::vector<std::string>& matches) {
matches.clear();
if (typed.empty() || typed.find(' ') != std::string::npos) return typed;
for (auto& w : words)
if (w.rfind(typed, 0) == 0) matches.push_back(w);
if (matches.empty()) return typed;
if (matches.size() == 1) {
std::string only = matches[0] + " ";
matches.clear();
return only;
}
std::string common = matches[0];
for (auto& m : matches) {
size_t n = 0;
while (n < common.size() && n < m.size() && common[n] == m[n]) n++;
common.resize(n);
}
return common;
}
} // namespace roro
+59
View File
@@ -0,0 +1,59 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <deque>
#include <string>
#include <vector>
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).
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.
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<std::string>& 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<std::string> lines_;
std::string partial_;
size_t bytes_ = 0;
bool background_ = true, ranOne_ = false;
uint32_t lastCommandMs_ = 0, revision_ = 0;
};
// The commands `help` lists: the first word of each (`ls [folder] | du <path>` gives ls and du),
// each once, in the order they appear.
std::vector<std::string> commandWords(const char* helpText);
// Tab: `typed` with its first word completed as far as the commands agree. Unchanged when the cursor
// is past the first word, or when nothing starts with it. `matches` gets what it could become, when
// there is more than one.
std::string completeCommand(const std::string& typed, const std::vector<std::string>& words, std::vector<std::string>& matches);
} // namespace roro
+15
View File
@@ -323,6 +323,21 @@ inline constexpr KeyHelp kNotesName[] = {
{"Fn , /", "move the cursor"},
};
// shell: Shell
inline constexpr KeyHelp kShell[] = {
{"Enter", "run the line"},
{"Tab", "complete the command"},
{"Fn ; .", "lines you typed before"},
{"Alt ; .", "scroll back, forward"},
{"Ctrl b", "all output, or only yours"},
{"Fn , /", "move the cursor"},
{"Del", "delete backwards"},
{"help", "every command"},
{"clear", "an empty screen"},
{"rm -f", "delete without being asked"},
{"quit `", "leave the Shell"},
};
// system: System, any view
inline constexpr KeyHelp kSystem[] = {
{"Tab", "the next view"},
+1 -1
View File
@@ -4,7 +4,7 @@
namespace roro::files {
const char* const kFirmwareFolders[] = {"/irc", "/wifi", "/updates", "/gnss", "/gemini", "/captures", "/notes"};
const char* const kFirmwareFolders[] = {"/irc", "/wifi", "/updates", "/gnss", "/gemini", "/captures", "/notes", "/screenshots"};
const size_t kFirmwareFolderCount = sizeof kFirmwareFolders / sizeof kFirmwareFolders[0];
std::string parentOf(const std::string& path) {
+118
View File
@@ -0,0 +1,118 @@
#include "png_rgb332.h"
namespace roro::png {
uint32_t crc32(uint32_t crc, const uint8_t* data, size_t len) {
crc = ~crc;
for (size_t i = 0; i < len; i++) {
crc ^= data[i];
for (int bit = 0; bit < 8; bit++) crc = (crc >> 1) ^ (0xEDB88320u & (0u - (crc & 1)));
}
return ~crc;
}
uint32_t adler32(uint32_t adler, const uint8_t* data, size_t len) {
uint32_t a = adler & 0xFFFF, b = adler >> 16;
for (size_t i = 0; i < len; i++) {
a = (a + data[i]) % 65521;
b = (b + a) % 65521;
}
return (b << 16) | a;
}
namespace {
void be32(uint8_t* out, uint32_t v) {
out[0] = static_cast<uint8_t>(v >> 24);
out[1] = static_cast<uint8_t>(v >> 16);
out[2] = static_cast<uint8_t>(v >> 8);
out[3] = static_cast<uint8_t>(v);
}
size_t rawSize(int w, int h) { return static_cast<size_t>(w + 1) * h; } // a filter byte before each row
size_t idatSize(int w, int h) { return 2 + 5 + rawSize(w, h) + 4; } // zlib header, block header, data, adler
} // namespace
size_t Rgb332Writer::fileSize(int w, int h) {
return 8 + (12 + 13) + (12 + 768) + (12 + idatSize(w, h)) + 12; // signature, IHDR, PLTE, IDAT, IEND
}
bool Rgb332Writer::put(const uint8_t* data, size_t len, bool inIdat) {
if (inIdat) crc_ = crc32(crc_, data, len);
return sink_(data, len);
}
bool Rgb332Writer::put32(uint32_t value, bool inIdat) {
uint8_t b[4];
be32(b, value);
return put(b, 4, inIdat);
}
bool Rgb332Writer::begin() {
if (w_ <= 0 || h_ <= 0 || rawSize(w_, h_) > 65535) return false;
static const uint8_t signature[] = {0x89, 'P', 'N', 'G', '\r', '\n', 0x1A, '\n'};
if (!sink_(signature, sizeof signature)) return false;
uint8_t ihdr[4 + 13] = {'I', 'H', 'D', 'R'};
be32(ihdr + 4, static_cast<uint32_t>(w_));
be32(ihdr + 8, static_cast<uint32_t>(h_));
ihdr[12] = 8; // bits a pixel
ihdr[13] = 3; // indexed colour
ihdr[14] = ihdr[15] = ihdr[16] = 0;
uint8_t word[4];
be32(word, 13);
if (!sink_(word, 4) || !sink_(ihdr, sizeof ihdr)) return false;
be32(word, crc32(0, ihdr, sizeof ihdr));
if (!sink_(word, 4)) return false;
// The palette: every RGB332 value is its own index, as scripts/rdbg.py expands them. Sixteen
// colours at a time: this runs on a task with a small stack.
be32(word, 768);
const uint8_t plteKind[] = {'P', 'L', 'T', 'E'};
if (!sink_(word, 4) || !sink_(plteKind, 4)) return false;
uint32_t plteCrc = crc32(0, plteKind, 4);
for (int first = 0; first < 256; first += 16) {
uint8_t piece[48];
for (int i = 0; i < 16; i++) {
int v = first + i;
piece[i * 3] = static_cast<uint8_t>((v >> 5) * 255 / 7);
piece[i * 3 + 1] = static_cast<uint8_t>(((v >> 2) & 7) * 255 / 7);
piece[i * 3 + 2] = static_cast<uint8_t>((v & 3) * 255 / 3);
}
plteCrc = crc32(plteCrc, piece, sizeof piece);
if (!sink_(piece, sizeof piece)) return false;
}
be32(word, plteCrc);
if (!sink_(word, 4)) return false;
// IDAT: a zlib stream of one stored block. Its length is known, so it can be written first.
size_t raw = rawSize(w_, h_);
be32(word, static_cast<uint32_t>(idatSize(w_, h_)));
if (!sink_(word, 4)) return false;
crc_ = 0;
const uint8_t head[] = {'I', 'D', 'A', 'T', 0x78, 0x01, 0x01, static_cast<uint8_t>(raw), static_cast<uint8_t>(raw >> 8),
static_cast<uint8_t>(~raw), static_cast<uint8_t>(~raw >> 8)};
return put(head, sizeof head, true);
}
bool Rgb332Writer::row(const uint8_t* pixels) {
if (rows_ >= h_) return false;
rows_++;
const uint8_t filter = 0; // none
adler_ = adler32(adler_, &filter, 1);
adler_ = adler32(adler_, pixels, static_cast<size_t>(w_));
return put(&filter, 1, true) && put(pixels, static_cast<size_t>(w_), true);
}
bool Rgb332Writer::end() {
if (rows_ != h_) return false;
if (!put32(adler_, true)) return false;
uint8_t word[4];
be32(word, crc_);
if (!sink_(word, 4)) return false;
static const uint8_t iend[] = {0, 0, 0, 0, 'I', 'E', 'N', 'D', 0xAE, 0x42, 0x60, 0x82};
return sink_(iend, sizeof iend);
}
} // namespace roro::png
+38
View File
@@ -0,0 +1,38 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <functional>
// A PNG of the screen, written a row at a time with almost no memory (issue #67, Q209): 8-bit
// indexed colour with the 256 colours of RGB332 as its palette, and the pixels stored, not
// compressed (a "stored" deflate block), so there is nothing to compress with and nothing to buffer.
// One block holds at most 65,535 bytes: enough for the 240 x 135 screen (32,535 with its row bytes).
namespace roro::png {
uint32_t crc32(uint32_t crc, const uint8_t* data, size_t len); // running; start from 0
uint32_t adler32(uint32_t adler, const uint8_t* data, size_t len); // running; start from 1
class Rgb332Writer {
public:
using Sink = std::function<bool(const uint8_t* data, size_t len)>; // false: writing failed
Rgb332Writer(int width, int height, Sink sink) : w_(width), h_(height), sink_(std::move(sink)) {}
// The file's size, known before a byte is written.
static size_t fileSize(int width, int height);
bool begin(); // false: too big for one block, or the sink refused
bool row(const uint8_t* pixels); // `width` bytes, RRRGGGBB each
bool end();
private:
bool put(const uint8_t* data, size_t len, bool inIdat);
bool put32(uint32_t value, bool inIdat);
int w_, h_, rows_ = 0;
Sink sink_;
uint32_t crc_ = 0, adler_ = 1;
};
} // namespace roro::png