Shell: the console's commands on the device's own screen and keyboard (#67) #78

Merged
twisla merged 4 commits from shell-app into main 2026-10-07 11:04:59 +00:00
29 changed files with 1112 additions and 50 deletions
Showing only changes of commit 3863d28593 - Show all commits
+4
View File
@@ -106,6 +106,10 @@ The regulatory band plan the device transmits under (here EU868). It sets the al
**Duty Cycle Budget**: **Duty Cycle Budget**:
The share of airtime the Region allows this device to transmit. When it's used up, outgoing traffic waits. The share of airtime the Region allows this device to transmit. When it's used up, outgoing traffic waits.
**Shell**:
The App that runs the console's commands on the device itself, and shows what the console prints. Trusted like the USB port, not like the network.
_Avoid_: terminal, command line, REPL
**Help panel**: **Help panel**:
The list of the keys that work on the screen you are on, opened with Fn+h anywhere (or `?` outside Text Entry). Each App answers for its current state; no screen names keys any other way, except the first-start Setup. The list of the keys that work on the screen you are on, opened with Fn+h anywhere (or `?` outside Text Entry). Each App answers for its current state; no screen names keys any other way, except the first-start Setup.
_Avoid_: hints, cheat sheet, shortcuts bar _Avoid_: hints, cheat sheet, shortcuts bar
+6 -1
View File
@@ -156,6 +156,10 @@ A new note has no file until something is typed; its file is then named after it
A note holds up to 16 KB while it's edited. A bigger text file opens read-only in the Storage App (editing any size is issue #47). The Storage App's text viewer has `e` to edit a file with the same editor, anywhere on the card, unless the file is read-only. A note holds up to 16 KB while it's edited. A bigger text file opens read-only in the Storage App (editing any size is issue #47). The Storage App's text viewer has `e` to edit a file with the same editor, anywhere on the card, unless the file is read-only.
## 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.
## Development aids ## Development aids
`scripts/serial_log.sh [seconds] [command…]` records the serial output, and can send commands to the firmware first. For example, `scripts/serial_log.sh 30 short sleep:12 burst` sets short screen timeouts, waits 12 s, then sends a burst of Toasts. `scripts/serial_log.sh [seconds] [command…]` records the serial output, and can send commands to the firmware first. For example, `scripts/serial_log.sh 30 short sleep:12 burst` sets short screen timeouts, waits 12 s, then sends a burst of Toasts.
@@ -187,7 +191,8 @@ A note holds up to 16 KB while it's edited. A bigger text file opens read-only i
| `reboot` / `boot other` | Restart, or restart into the other app slot (a manual Rollback) | | `reboot` / `boot other` | Restart, or restart into the other app slot (a manual Rollback) |
| `log level <0-5>` | ESP-IDF log level | | `log level <0-5>` | ESP-IDF log level |
| `ls [folder]` / `du <path>` | Lists a folder of the SD card with sizes and dates, or counts the files and bytes under a path | | `ls [folder]` / `du <path>` | Lists a folder of the SD card with sizes and dates, or counts the files and bytes under a path |
| `cp [-f] <from> <to>` / `mv [-f] <from> <to>` / `rm <path>` / `mkdir <path>` / `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 | | `screenshot [seconds]` | The screen as a PNG in `/screenshots` on the card, now or after a pause to get to the screen you want (240 x 135, about 33 KB). Over the Debug Console a bare `screenshot` sends the screen to the PC instead |
| `cp [-f] <from> <to>` / `mv [-f] <from> <to>` / `rm [-f] <path>` / `mkdir <path>` / `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 |
| `install <path>` | Update from SD with that `.ota` file, as Settings → Firmware does | | `install <path>` | Update from SD with that `.ota` file, as Settings → Firmware does |
| `update check` / `update list` / `update status` / `update install <tag>` | The project's releases on Gitea: look at the latest, list the last ten, say what's known, or download and install one | | `update check` / `update list` / `update status` / `update install <tag>` | The project's releases on Gitea: look at the latest, list the last ten, say what's known, or download and install one |
| `update pretend <version>` / `update probe <host>` / `update damage cut\|flip <n>` / `update daily` | Pretend to run another version (so a release counts as an update), see whether a server's certificate is accepted, cut or damage the next download, run the daily check again | | `update pretend <version>` / `update probe <host>` / `update damage cut\|flip <n>` / `update daily` | Pretend to run another version (so a release counts as an update), see whether a server's certificate is accepted, cut or damage the next download, run the daily check again |
+41
View File
@@ -136,3 +136,44 @@ M3 found the LoRa radio's noise floor about 15 dB above what the chip hears alon
**Settings > Pause GNSS for LoRa**, off by default: while the LoRa radio listens or sweeps, the GNSS receiver waits in standby, and wakes when the radio goes back to sleep (a Fix again after about 7 s here). Never during a Track. The GNSS App says "GNSS is paused" meanwhile. `gnss quiet on|off` on the console. **Settings > Pause GNSS for LoRa**, off by default: while the LoRa radio listens or sweeps, the GNSS receiver waits in standby, and wakes when the radio goes back to sleep (a Fix again after about 7 s here). Never during a Track. The GNSS App says "GNSS is paused" meanwhile. `gnss quiet on|off` on the console.
It's off by default because GNSS on by default was decided in M2 (Q58), and from M4 the radio listens all the time: then "pause while listening" means GNSS mostly off, which is a decision about position, the clock and Tracks, for M4's design round (issue #23). It's off by default because GNSS on by default was decided in M2 (Q58), and from M4 the radio listens all the time: then "pause while listening" means GNSS mostly off, which is a decision about position, the clock and Tracks, for M4's design round (issue #23).
## The Shell (issue #67)
The console's commands could only be typed on a PC: over USB, or over Wi-Fi with the Debug Console. A device in a bag, or on a network that is down, could not be asked anything. The Shell is an App that runs the same commands on the device's own screen and keyboard.
### Decisions (design round 2026-10-07)
| # | Decision |
|---|---|
| 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. |
| 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`. |
| 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. |
### 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.
- **`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).
### Checks on the device (2026-10-07, driven over the Debug Console with `key`)
| Check | Result |
|---|---|
| Open it from the Launcher, type `ls /`, Enter | The folders are listed |
| 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 |
| `screenshot 4`, then Home | The picture, taken four seconds later, is of the Launcher: the pause works, and the Toast isn't in it |
| `quit` | Back to the Launcher, and the memory comes back |
| The help panel in the Shell | Its keys, then the ones that work everywhere |
**Not checked:** Ctrl+b and the Alt scroll, which `key` can't press (no modifiers): the filter itself is host-tested, the key that flips it is not; the real keyboard altogether; and the Toast after a screenshot, which was published but not looked at.
+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"}, {"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 // system: System, any view
inline constexpr KeyHelp kSystem[] = { inline constexpr KeyHelp kSystem[] = {
{"Tab", "the next view"}, {"Tab", "the next view"},
+1 -1
View File
@@ -4,7 +4,7 @@
namespace roro::files { 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]; const size_t kFirmwareFolderCount = sizeof kFirmwareFolders / sizeof kFirmwareFolders[0];
std::string parentOf(const std::string& path) { 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
+1 -1
View File
@@ -8,6 +8,6 @@ sort_by = "weight"
eyebrow = "Developer docs" eyebrow = "Developer docs"
+++ +++
The firmware is open source (GPL-3.0) and lives on [Gitea](https://git.twis.la/twisla/roro9stack). It is built for one device, the M5Stack Cardputer ADV with the Cap LoRa-1262, and it is built to be **worked on without touching the device**: install a build over Wi-Fi, read its console, press its keys, take screenshots of it, copy files to and from its SD card, and fetch its crash dumps, all from a PC on the same network. The first section is about exactly that. The firmware is open source (GPL-3.0) and lives on [Gitea](https://git.twis.la/twisla/roro9stack). It is built for one device, the M5Stack Cardputer ADV with the Cap LoRa-1262, and it is built to be **worked on without touching the device**: install a build over Wi-Fi, read its console, press its keys, take screenshots of it, copy files to and from its SD card, and fetch its crash dumps, all from a PC on the same network. The first section is about exactly that. The same commands also run on the device itself, in the [Shell](/guide/shell/).
Some of these pages are written by hand. The rest are **generated from the repository's own documents** (the decisions, the milestone plans, the README and the firmware's own `help` text), so they are never out of date: each says which file it comes from. Some of these pages are written by hand. The rest are **generated from the repository's own documents** (the decisions, the milestone plans, the README and the firmware's own `help` text), so they are never out of date: each says which file it comes from.
+7 -5
View File
@@ -19,10 +19,11 @@ net bytes each network service has read and written since boot
reboot restart reboot restart
boot other restart into the other app slot (manual Rollback) boot other restart into the other app slot (manual Rollback)
log level <0-5> ESP-IDF log level (0 none ... 5 verbose) log level <0-5> ESP-IDF log level (0 none ... 5 verbose)
ls [folder] | du <path> | mkdir <path> | rm <path> | cp [-f] <from> <to> | mv [-f] <from> <to> | cancel the SD card, with the Storage App's rules 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
screenshot [seconds] the screen as a PNG in /screenshots on the card, now or after a pause
lora probe | lora status | lora rx on|off | lora preset <name> the LoRa radio, receive only lora probe | lora status | lora rx on|off | lora preset <name> the LoRa radio, receive only
lora capture start|stop a LoRa Capture to /captures/lora (pcap, LoRaTap) lora capture start|stop a LoRa Capture to /captures/lora (pcap, LoRaTap)
lora sweep on [from MHz] [to MHz] [step kHz] | off | dump RSSI across a band (863 870 100) lora sweep on [from MHz] [to MHz] [step kHz] | lora sweep off | lora sweep dump RSSI across a band (863 870 100)
lora custom <MHz> <BW kHz> <SF> <CR 5-8> <sync hex> [preamble] e.g. 868.1 125 7 5 34 8 (LoRaWAN) lora custom <MHz> <BW kHz> <SF> <CR 5-8> <sync hex> [preamble] e.g. 868.1 125 7 5 34 8 (LoRaWAN)
gnss quiet on|off pause the GNSS receiver while the LoRa radio listens (it costs the radio 8 dB) gnss quiet on|off pause the GNSS receiver while the LoRa radio listens (it costs the radio 8 dB)
gnss status | gnss restart | gnss track start|stop | gnss nmea on|off | gnss send <sentence without $ and checksum> gnss status | gnss restart | gnss track start|stop | gnss nmea on|off | gnss send <sentence without $ and checksum>
@@ -35,7 +36,7 @@ wifi dns <a> [b] | wifi dns always on|off | wifi ntp <a> [b] DNS and NTP serve
gemini get <url> fetch a Gemini page and report header, size, certificate, heap gemini get <url> fetch a Gemini page and report header, size, certificate, heap
irc start | irc stop | irc dump | irc say <buffer> <text> irc start | irc stop | irc dump | irc say <buffer> <text>
install <path.ota> Update from SD install <path.ota> Update from SD
update check | list | status | install <tag> the project's releases on Gitea update check | update list | update status | update install <tag> the project's releases on Gitea
sd card | sd list | cat <path> | log <text> | burst | sound on|off | short | normal sd card | sd list | cat <path> | log <text> | burst | sound on|off | short | normal
debug status | debug off [seconds] the Debug Console over Wi-Fi (Settings > Debug Console); with seconds, it comes back 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 debug on | debug token <16 to 64 characters> | debug token new (USB serial only) switch it on, set its token
@@ -47,7 +48,7 @@ lora inject <hex> [rssi] [snr] a packet into the LoRa Scanner as if received (
sd fill <folder> <count> makes that many small files there, to test a crowded folder sd fill <folder> <count> makes that many small files there, to test a crowded folder
coredump get (Debug Console only) send the raw core dump: use scripts/rdbg.py coredump coredump get (Debug Console only) send the raw core dump: use scripts/rdbg.py coredump
reset (Debug Console only) restart at once, even if the main loop is stuck reset (Debug Console only) restart at once, even if the main loop is stuck
get <path> | put <path> <size> <sha256> | screenshot (Debug Console only) binary, see rdbg.py get <path> | put <path> <size> <sha256> | screenshot (Debug Console only) binary, see rdbg.py: there, a bare `screenshot` sends the screen instead of saving it
quit close the Debug Console connection quit close the Debug Console connection
``` ```
@@ -84,7 +85,8 @@ In **Safe Mode** (see [Crashes and Safe Mode](/dev/debug/crashes/)) only a few r
| `reboot` / `boot other` | Restart, or restart into the other app slot (a manual Rollback) | | `reboot` / `boot other` | Restart, or restart into the other app slot (a manual Rollback) |
| `log level <0-5>` | ESP-IDF log level | | `log level <0-5>` | ESP-IDF log level |
| `ls [folder]` / `du <path>` | Lists a folder of the SD card with sizes and dates, or counts the files and bytes under a path | | `ls [folder]` / `du <path>` | Lists a folder of the SD card with sizes and dates, or counts the files and bytes under a path |
| `cp [-f] <from> <to>` / `mv [-f] <from> <to>` / `rm <path>` / `mkdir <path>` / `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 | | `screenshot [seconds]` | The screen as a PNG in `/screenshots` on the card, now or after a pause to get to the screen you want (240 x 135, about 33 KB). Over the Debug Console a bare `screenshot` sends the screen to the PC instead |
| `cp [-f] <from> <to>` / `mv [-f] <from> <to>` / `rm [-f] <path>` / `mkdir <path>` / `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 |
| `install <path>` | Update from SD with that `.ota` file, as Settings → Firmware does | | `install <path>` | Update from SD with that `.ota` file, as Settings → Firmware does |
| `update check` / `update list` / `update status` / `update install <tag>` | The project's releases on Gitea: look at the latest, list the last ten, say what's known, or download and install one | | `update check` / `update list` / `update status` / `update install <tag>` | The project's releases on Gitea: look at the latest, list the last ten, say what's known, or download and install one |
| `update pretend <version>` / `update probe <host>` / `update damage cut\|flip <n>` / `update daily` | Pretend to run another version (so a release counts as an update), see whether a server's certificate is accepted, cut or damage the next download, run the daily check again | | `update pretend <version>` / `update probe <host>` / `update damage cut\|flip <n>` / `update daily` | Pretend to run another version (so a release counts as an update), see whether a server's certificate is accepted, cut or damage the next download, run the daily check again |
@@ -45,6 +45,7 @@ The device sends `screenshot: rgb332 <width> <height>` and then **one byte per p
- It is read **as it stands**, while the UI may be drawing, so it can **tear**. It is for looking at, not for pixel-exact comparison. - It is read **as it stands**, while the UI may be drawing, so it can **tear**. It is for looking at, not for pixel-exact comparison.
- It is the real thing: the screenshots on this site, in the [user guide](/guide/) and the [devlog](/devlog/), were taken this way. - It is the real thing: the screenshots on this site, in the [user guide](/guide/) and the [devlog](/devlog/), were taken this way.
- **With a number, it saves to the card instead:** `screenshot 5` (or `screenshot 0`) writes a PNG to `/screenshots` on the SD card after that many seconds, as the [Shell](/guide/shell/) does. A bare `screenshot` over the console is the binary one above.
- Its main use is in a loop: send a key, wait a moment, take a screenshot, look. See [Drive the UI](/dev/debug/drive-the-ui/). - Its main use is in a loop: send a key, wait a moment, take a screenshot, look. See [Drive the UI](/dev/debug/drive-the-ui/).
## `coredump get`: the crash dump ## `coredump get`: the crash dump
+41
View File
@@ -144,3 +144,44 @@ M3 found the LoRa radio's noise floor about 15 dB above what the chip hears alon
**Settings > Pause GNSS for LoRa**, off by default: while the LoRa radio listens or sweeps, the GNSS receiver waits in standby, and wakes when the radio goes back to sleep (a Fix again after about 7 s here). Never during a Track. The GNSS App says "GNSS is paused" meanwhile. `gnss quiet on|off` on the console. **Settings > Pause GNSS for LoRa**, off by default: while the LoRa radio listens or sweeps, the GNSS receiver waits in standby, and wakes when the radio goes back to sleep (a Fix again after about 7 s here). Never during a Track. The GNSS App says "GNSS is paused" meanwhile. `gnss quiet on|off` on the console.
It's off by default because GNSS on by default was decided in M2 (Q58), and from M4 the radio listens all the time: then "pause while listening" means GNSS mostly off, which is a decision about position, the clock and Tracks, for M4's design round (issue #23). It's off by default because GNSS on by default was decided in M2 (Q58), and from M4 the radio listens all the time: then "pause while listening" means GNSS mostly off, which is a decision about position, the clock and Tracks, for M4's design round (issue #23).
## The Shell (issue #67)
The console's commands could only be typed on a PC: over USB, or over Wi-Fi with the Debug Console. A device in a bag, or on a network that is down, could not be asked anything. The Shell is an App that runs the same commands on the device's own screen and keyboard.
### Decisions (design round 2026-10-07)
| # | Decision |
|---|---|
| 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. |
| 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`. |
| 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. |
### 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.
- **`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).
### Checks on the device (2026-10-07, driven over the Debug Console with `key`)
| Check | Result |
|---|---|
| Open it from the Launcher, type `ls /`, Enter | The folders are listed |
| 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 |
| `screenshot 4`, then Home | The picture, taken four seconds later, is of the Launcher: the pause works, and the Toast isn't in it |
| `quit` | Back to the Launcher, and the memory comes back |
| The help panel in the Shell | Its keys, then the ones that work everywhere |
**Not checked:** Ctrl+b and the Alt scroll, which `key` can't press (no modifiers): the filter itself is host-tested, the key that flips it is not; the real keyboard altogether; and the Toast after a screenshot, which was published but not looked at.
+2 -2
View File
@@ -68,9 +68,9 @@ On a new device a short Setup asks four things, then never appears again. It als
## The SD card ## The SD card
Put a microSD card in the Cardputer. Without one the radio, GNSS, Wi-Fi and IRC still work, but nothing can be saved: no notes, IRC logs, Wi-Fi scan logs, GPX tracks, LoRa captures or saved Gemini pages. The [Storage App](/guide/storage/) shows what is on the card. Put a microSD card in the Cardputer. Without one the radio, GNSS, Wi-Fi and IRC still work, but nothing can be saved: no notes, IRC logs, Wi-Fi scan logs, GPX tracks, LoRa captures, screenshots or saved Gemini pages. The [Storage App](/guide/storage/) shows what is on the card.
The firmware keeps its own folders at the top of the card (`captures`, `gemini`, `gnss`, `irc`, `updates`, `wifi`, plus `notes`). You can use the card in a computer too, but those names are the firmware's. The firmware keeps its own folders at the top of the card (`captures`, `gemini`, `gnss`, `irc`, `notes`, `screenshots`, `updates`, `wifi`). You can use the card in a computer too, but those names are the firmware's.
## The keys, as the device lists them ## The keys, as the device lists them
+1 -1
View File
@@ -1,7 +1,7 @@
+++ +++
title = "Every key" title = "Every key"
description = "The keys of every screen of the firmware, as the help panel lists them on the device: one table for each screen and state." description = "The keys of every screen of the firmware, as the help panel lists them on the device: one table for each screen and state."
weight = 12 weight = 13
[extra] [extra]
tag = "Reference" tag = "Reference"
+++ +++
+1 -1
View File
@@ -1,7 +1,7 @@
+++ +++
title = "Settings" title = "Settings"
description = "The device's names, region, screen, sound, GNSS and Wi-Fi, and where firmware updates are found." description = "The device's names, region, screen, sound, GNSS and Wi-Fi, and where firmware updates are found."
weight = 10 weight = 11
[extra] [extra]
tag = "Settings" tag = "Settings"
+++ +++
+58
View File
@@ -0,0 +1,58 @@
+++
title = "Shell"
description = "The firmware's own commands, typed on the device: look at its state, the SD card, the radio and the network with no PC and no cable."
weight = 9
[extra]
tag = "Shell"
+++
The firmware has a set of **commands**, made for working on it from a PC. The Shell runs them **on the device itself**: no computer, no cable, no Wi-Fi. It is the tool for the day something is wrong and you are nowhere near a desk.
It can do real damage: `rm` deletes, `reboot` restarts, `debug on` opens the device to the network. It is the same trust as holding the device, and nothing more.
## Using it
Type a command and press <kbd>Enter</kbd>. `help` lists them all; the [command reference](/dev/debug/commands/) says what each does. A few to start with:
| Command | Shows |
|---|---|
| `info` | The firmware's version, uptime, memory, Wi-Fi, the SD card and both firmware slots |
| `wifi status` | The network, the address, and where the DNS and time servers came from |
| `ls /notes` | A folder of the SD card, with sizes and dates |
| `crash` | The last crash, if there was one |
| `update check` | Whether a newer release exists |
| `lora status` | What the radio is set to and what it has heard |
- <kbd>Tab</kbd> **completes** the command's name. If several fit, it lists them.
- <kbd>Fn</kbd> with up and down brings back **lines you typed before**.
- <kbd>Alt</kbd> with up and down **scrolls back** through what was printed.
- `clear` empties the screen, and `quit` (or Back) leaves.
## 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.
<kbd>Ctrl</kbd> + <kbd>b</kbd> 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.
## Deleting asks first
`rm <path>` asks before it deletes, here where a slip of the finger is one key away. `rm -f <path>` does not ask.
## Screenshots
```
screenshot the screen, now
screenshot 5 the screen in 5 seconds: time to go to another App
```
The picture is saved as a PNG in `/screenshots` on the SD card, named by date and time, and a Toast says so once it is written (so the Toast is never in the picture). From the Shell, "now" is always a picture of the Shell: use the pause to get to the screen you want. The [Storage App](/guide/storage/) shows the files; to look at them, take the card to a computer.
## What it costs
Nothing while it is closed. Open, about 7 KB of memory, given back when you leave: with IRC connected and a Gemini page open, that can be the difference (see [the memory limit](/howto/not-enough-memory/)).
## The keys, as the device lists them
What <kbd>Fn</kbd> + <kbd>h</kbd> shows on this screen. This table is generated from the firmware's own lists, so it is always the current one.
{{ keys(scopes=["shell"]) }}
+1 -1
View File
@@ -1,7 +1,7 @@
+++ +++
title = "System" title = "System"
description = "What the device is doing right now: load, tasks, memory, network traffic, battery and temperature. Live, and read-only." description = "What the device is doing right now: load, tasks, memory, network traffic, battery and temperature. Live, and read-only."
weight = 9 weight = 10
[extra] [extra]
tag = "System" tag = "System"
screens = ["system.png"] screens = ["system.png"]
+1 -1
View File
@@ -1,7 +1,7 @@
+++ +++
title = "Updates" title = "Updates"
description = "How the device updates itself from the project's releases, from the SD card or from a PC, and how it protects itself when an update goes wrong." description = "How the device updates itself from the project's releases, from the SD card or from a PC, and how it protects itself when an update goes wrong."
weight = 11 weight = 12
[extra] [extra]
tag = "Firmware" tag = "Firmware"
screens = ["update.png"] screens = ["update.png"]
+1
View File
@@ -19,6 +19,7 @@ Everything the firmware writes goes in a folder at the top of the card. Switch t
| Gemini bookmarks | `/gemini/bookmarks.gmi` | gemtext | | Gemini bookmarks | `/gemini/bookmarks.gmi` | gemtext |
| Files saved from Gemini that are not text | `/gemini/downloads` | whatever they were | | Files saved from Gemini that are not text | `/gemini/downloads` | whatever they were |
| Update files | `/updates` | `.ota` | | Update files | `/updates` | `.ota` |
| Screenshots (the Shell's `screenshot`) | `/screenshots` | `.png`, named by date and time |
## Rules worth knowing ## Rules worth knowing
+17
View File
@@ -375,6 +375,23 @@ rows = [
["Fn , /", "move the cursor"], ["Fn , /", "move the cursor"],
] ]
[[scope]]
id = "shell"
title = "Shell"
rows = [
["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"],
]
[[scope]] [[scope]]
id = "system" id = "system"
title = "System, any view" title = "System, any view"
+1 -1
View File
@@ -68,7 +68,7 @@
</article> </article>
{% endfor %} {% endfor %}
</div> </div>
<p class="cards-note">Plus Settings, with its Wi-Fi and Firmware pages. Every App has a page in the <a class="accent-link" href="/guide/">user guide</a>.</p> <p class="cards-note">Plus a Shell that runs the firmware's commands on the device, and Settings, with its Wi-Fi and Firmware pages. Every App has a page in the <a class="accent-link" href="/guide/">user guide</a>.</p>
</section> </section>
<section class="wrap" id="screens" aria-labelledby="screens-title"> <section class="wrap" id="screens" aria-labelledby="screens-title">
+180
View File
@@ -0,0 +1,180 @@
#include "apps/shell_app.h"
#include <Arduino.h>
#include "app_keys.h"
#include "platform/console.h"
#include "ui/fonts.h"
#include "ui/theme.h"
#include "ui/widgets.h"
namespace roro {
namespace {
bool startsWith(const std::string& s, const char* prefix) { return s.rfind(prefix, 0) == 0; }
} // namespace
void ShellApp::onEnter() {
open_ = console.openShellRing();
ringPos_ = 0;
scroll_ = 0;
confirm_.reset();
words_ = commandWords(helpText_);
words_.push_back("help");
words_.push_back("clear"); // the Shell's own
words_.push_back("quit");
log_.clear();
log_.add(open_ ? "The console's commands. `help` lists them." : "No memory for the Shell: leave an App, or stop IRC.");
}
// Nothing is kept once it's left: the ring, the lines and the list of commands all go (Q207).
void ShellApp::onExit() {
console.closeShellRing();
open_ = false;
log_.clear();
std::vector<std::string>().swap(words_);
confirm_.reset();
}
void ShellApp::update(uint32_t nowMs) {
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<const char*>(buf), n, nowMs);
}
if (log_.revision() != seenRevision_) {
seenRevision_ = log_.revision();
requestRedraw();
}
}
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.
if (startsWith(line, "debug token ") && line != "debug token new") log_.add("> debug token ...");
run_(line);
}
void ShellApp::enter(const std::string& line) {
history_.add(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;
}
runNow(line);
}
bool ShellApp::onKey(const KeyEvent& e) {
requestRedraw();
if (confirm_) {
confirm_->onKey(e);
if (confirm_->result() == DialogModel::kPending) return true;
if (confirm_->result() == 1) runNow(pending_);
else log_.add("Not deleted.");
confirm_.reset();
return true;
}
// Alt + ; / Alt + . scroll back and forward; Up / Down (Fn + ; / Fn + .) recall earlier lines.
if (e.key == Key::Char && e.alt && (e.ch == ';' || e.ch == '.')) {
if (e.ch == ';') scroll_++;
else if (scroll_ > 0) scroll_--;
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.");
return true;
}
std::string recalled;
switch (e.key) {
case Key::Char: input_.insert(e.ch); break;
case Key::Delete: input_.backspace(); break;
case Key::Left: input_.left(); break;
case Key::Right: input_.right(); break;
case Key::Up:
if (history_.up(input_.text(), recalled)) input_.setText(recalled);
break;
case Key::Down:
if (history_.down(recalled)) input_.setText(recalled);
break;
case Key::Tab: {
std::vector<std::string> matches;
input_.setText(completeCommand(input_.text(), words_, matches));
if (matches.size() > 1) {
std::string all;
for (auto& m : matches) all += (all.empty() ? "" : " ") + m;
log_.add(all);
}
break;
}
case Key::Select: {
std::string line = input_.text();
input_.setText("");
if (!line.empty()) enter(line);
break;
}
default: return false; // Back leaves the Shell
}
return true;
}
void ShellApp::help(std::vector<KeyHelp>& out) const {
if (confirm_) return keys::add(out, keys::kDialog);
keys::add(out, keys::kShell);
}
void ShellApp::draw(Canvas& c) {
const auto& area = theme::kContent;
const int inputH = theme::kLineHeight + 4;
const theme::Rect output{area.x, area.y, area.w, area.h - inputH - 1};
const int rows = output.h / theme::kLineHeight;
// Wrapped from the newest line backwards, only as far as the screen and the scroll need.
std::vector<std::pair<std::string, uint16_t>> shown; // newest first
auto measure = widgets::bodyMeasure(c);
c.setFont(&fonts::body);
const auto& lines = log_.lines();
int needed = rows + scroll_;
for (auto it = lines.rbegin(); it != lines.rend() && static_cast<int>(shown.size()) < needed; ++it) {
uint16_t color = it->rfind("> ", 0) == 0 ? theme::kAccent : theme::kText;
auto wrapped = wrapText(it->empty() ? std::string(" ") : *it, output.w - 8, measure);
for (auto w = wrapped.rbegin(); w != wrapped.rend(); ++w) shown.push_back({*w, color});
}
int total = static_cast<int>(shown.size());
if (scroll_ > total - rows) scroll_ = total > rows ? total - rows : 0;
c.setClipRect(output.x, output.y, output.w, output.h);
for (int r = 0; r < rows; r++) {
int i = scroll_ + (rows - 1 - r); // the row at the bottom is the newest
if (i >= total) continue;
c.setTextColor(shown[i].second);
c.drawString(shown[i].first.c_str(), 4, output.y + r * theme::kLineHeight + 1);
}
c.clearClipRect();
// State, not keys: how far back it's scrolled, and whether background lines are hidden.
c.setFont(&fonts::small);
c.setTextDatum(top_right);
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()) {
c.setTextColor(theme::kMuted);
c.drawString("mine", 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_);
}
} // namespace roro
+55
View File
@@ -0,0 +1,55 @@
#pragma once
#include <functional>
#include <memory>
#include <string>
#include <vector>
#include "app.h"
#include "app_manager.h"
#include "dialog_model.h"
#include "input_history.h"
#include "line_editor.h"
#include "shell_log.h"
namespace roro {
// The Shell (issue #67): the console's commands on the device's own screen and keyboard. A third
// 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.
class ShellApp : public App {
public:
using Run = std::function<void(const std::string& line)>;
ShellApp(Run run, const char* helpText, AppManager& apps) : run_(std::move(run)), helpText_(helpText), apps_(apps) {}
void onEnter() override;
void onExit() override;
bool onKey(const KeyEvent& e) override;
bool textEntryActive() const override { return !confirm_; }
void update(uint32_t nowMs) override;
void draw(Canvas& c) override;
void help(std::vector<KeyHelp>& out) const override;
private:
void enter(const std::string& line);
void runNow(const std::string& line);
Run run_;
const char* helpText_;
AppManager& apps_;
ShellLog log_;
std::vector<std::string> words_; // the commands Tab completes: built on entering, from `help`
LineEditor input_{240};
InputHistory history_{16};
std::unique_ptr<DialogModel> confirm_;
std::string pending_; // the `rm` being asked about
uint32_t ringPos_ = 0, seenRevision_ = 0;
int scroll_ = 0; // wrapped lines scrolled back from the bottom
bool open_ = false;
};
} // namespace roro
+80 -4
View File
@@ -7,7 +7,9 @@
#include <memory> #include <memory>
#include "app_manager.h" #include "app_manager.h"
#include "png_rgb332.h"
#include "apps/demo_app.h" #include "apps/demo_app.h"
#include "apps/shell_app.h"
#include "apps/gemini_app.h" #include "apps/gemini_app.h"
#include "apps/gnss_app.h" #include "apps/gnss_app.h"
#include "apps/irc_app.h" #include "apps/irc_app.h"
@@ -151,6 +153,8 @@ extern "C" bool verifyRollbackLater() { return true; } // C linkage, or the wea
size_t getArduinoLoopTaskStackSize() { return 6144; } size_t getArduinoLoopTaskStackSize() { return 6144; }
static void setupSafeMode(int crashes); static void setupSafeMode(int crashes);
static const char* helpText();
static void shellRun(const std::string& line);
void setup() { void setup() {
nvs.begin(); nvs.begin();
@@ -209,6 +213,7 @@ void setup() {
apps->registerApp({"lora", "LoRa Scanner", false, new LoraScannerApp(*radioService, *loraCapture, settings, *clockService)}); 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({"storage", "Storage", false, new StorageApp(*fileOps, *storageService, *clockService, *update, *power, bus)});
apps->registerApp({"notes", "Notes", false, new NotesApp(*fileOps, *storageService, *clockService, *power)}); apps->registerApp({"notes", "Notes", false, new NotesApp(*fileOps, *storageService, *clockService, *power)});
apps->registerApp({"shell", "Shell", false, new ShellApp(shellRun, helpText(), *apps)});
// Leaving the foreground App makes it save: a note being typed, when the device is powered off. // Leaving the foreground App makes it save: a note being typed, when the device is powered off.
power->beforePowerOff = []() { apps->home(); }; power->beforePowerOff = []() { apps->home(); };
apps->registerApp({"system", "System", false, apps->registerApp({"system", "System", false,
@@ -553,10 +558,11 @@ static const char* const kHelp =
"reboot restart\n" "reboot restart\n"
"boot other restart into the other app slot (manual Rollback)\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" "log level <0-5> ESP-IDF log level (0 none ... 5 verbose)\n"
"ls [folder] | du <path> | mkdir <path> | rm <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 [-f] <path> | cp [-f] <from> <to> | mv [-f] <from> <to> | cancel the SD card, with the Storage App's rules\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 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" "lora capture start|stop a LoRa Capture to /captures/lora (pcap, LoRaTap)\n"
"lora sweep on [from MHz] [to MHz] [step kHz] | off | dump RSSI across a band (863 870 100)\n" "lora sweep on [from MHz] [to MHz] [step kHz] | lora sweep off | lora sweep dump RSSI across a band (863 870 100)\n"
"lora custom <MHz> <BW kHz> <SF> <CR 5-8> <sync hex> [preamble] e.g. 868.1 125 7 5 34 8 (LoRaWAN)\n" "lora custom <MHz> <BW kHz> <SF> <CR 5-8> <sync hex> [preamble] e.g. 868.1 125 7 5 34 8 (LoRaWAN)\n"
"gnss quiet on|off pause the GNSS receiver while the LoRa radio listens (it costs the radio 8 dB)\n" "gnss quiet on|off pause the GNSS receiver while the LoRa radio listens (it costs the radio 8 dB)\n"
"gnss status | gnss restart | gnss track start|stop | gnss nmea on|off | gnss send <sentence without $ and checksum>\n" "gnss status | gnss restart | gnss track start|stop | gnss nmea on|off | gnss send <sentence without $ and checksum>\n"
@@ -569,7 +575,7 @@ static const char* const kHelp =
"gemini get <url> fetch a Gemini page and report header, size, certificate, heap\n" "gemini get <url> fetch a Gemini page and report header, size, certificate, heap\n"
"irc start | irc stop | irc dump | irc say <buffer> <text>\n" "irc start | irc stop | irc dump | irc say <buffer> <text>\n"
"install <path.ota> Update from SD\n" "install <path.ota> Update from SD\n"
"update check | list | status | install <tag> the project's releases on Gitea\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" "sd card | sd list | cat <path> | log <text> | burst | sound on|off | short | normal\n"
"debug status | debug off [seconds] the Debug Console over Wi-Fi (Settings > Debug Console); with seconds, it comes back\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" "debug on | debug token <16 to 64 characters> | debug token new (USB serial only) switch it on, set its token\n"
@@ -581,10 +587,61 @@ static const char* const kHelp =
"sd fill <folder> <count> makes that many small files there, to test a crowded folder\n" "sd fill <folder> <count> makes that many small files there, to test a crowded folder\n"
"coredump get (Debug Console only) send the raw core dump: use scripts/rdbg.py coredump\n" "coredump get (Debug Console only) send the raw core dump: use scripts/rdbg.py coredump\n"
"reset (Debug Console only) restart at once, even if the main loop is stuck\n" "reset (Debug Console only) restart at once, even if the main loop is stuck\n"
"get <path> | put <path> <size> <sha256> | screenshot (Debug Console only) binary, see rdbg.py\n" "get <path> | put <path> <size> <sha256> | screenshot (Debug Console only) binary, see rdbg.py: there, a bare `screenshot` sends the screen instead of saving it\n"
"quit close the Debug Console connection\n" "quit close the Debug Console connection\n"
; ;
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 uint32_t shotDueMs = 0;
static std::atomic<bool> shotSaved{false};
static void saveScreenshot() {
if (!storageService || !storageService->state().present) return (void)console.println("screenshot: error no SD card");
char name[40];
int64_t now = clockService ? clockService->utcNow() : -1;
if (now >= 0) {
time_t t = static_cast<time_t>(now);
struct tm local;
localtime_r(&t, &local);
snprintf(name, sizeof name, "/screenshots/%04d%02d%02d-%02d%02d%02d.png", local.tm_year + 1900, local.tm_mon + 1, local.tm_mday,
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]() {
Canvas& frame = screen.canvas();
const uint8_t* pixels = static_cast<const uint8_t*>(frame.getBuffer());
int w = frame.width(), h = frame.height();
if (!pixels) return (void)console.println("screenshot: error no frame");
if (!SD.exists("/screenshots")) SD.mkdir("/screenshots");
File f = SD.open(path.c_str(), FILE_WRITE);
if (!f) return (void)console.printf("screenshot: error the card refused to make %s\n", path.c_str());
png::Rgb332Writer writer(w, h, [&f](const uint8_t* data, size_t len) { return f.write(data, len) == len; });
bool ok = writer.begin();
for (int y = 0; ok && y < h; y++) ok = writer.row(pixels + y * w); // read as it stands: it may tear
ok = ok && writer.end();
f.close();
if (!ok) {
SD.remove(path.c_str());
return (void)console.println("screenshot: error the card refused a write");
}
console.printf("screenshot: saved %s (%u bytes)\n", path.c_str(), (unsigned)png::Rgb332Writer::fileSize(w, h));
shotSaved = true; // the main loop says so on the screen, after the picture is taken
});
}
static void screenshotStep() {
if (shotPending && static_cast<int32_t>(millis() - shotDueMs) >= 0) {
shotPending = false;
saveScreenshot();
}
if (shotSaved.exchange(false))
bus.publish(Event::withText(EventType::Notification, "Screenshot saved in /screenshots", static_cast<int32_t>(NotificationLevel::Info)));
}
// Commands that only touch what Safe Mode starts. // Commands that only touch what Safe Mode starts.
static bool safeModeCommand(const String& line) { static bool safeModeCommand(const String& line) {
return line == "help" || line == "info" || line == "tasks" || line == "net" || line == "reboot" || line == "boot other" || return line == "help" || line == "info" || line == "tasks" || line == "net" || line == "reboot" || line == "boot other" ||
@@ -640,6 +697,16 @@ static void runCommand(String line, bool fromSerial = false) {
if (safeMode && !safeModeCommand(line)) return (void)console.println("not available in Safe Mode"); if (safeMode && !safeModeCommand(line)) return (void)console.println("not available in Safe Mode");
if (line == "help") console.print(kHelp); if (line == "help") console.print(kHelp);
if (line.startsWith("debug ")) return debugCommand(line.substring(6), fromSerial); 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;
shotDueMs = millis() + seconds * 1000;
if (seconds) console.printf("screenshot: in %lu s\n", (unsigned long)seconds);
return;
}
// Over the Debug Console these never get here: its own task answers them.
if (line == "coredump get" || line == "reset" || line.startsWith("get ") || line.startsWith("put "))
return (void)console.println("Debug Console only: scripts/rdbg.py speaks it");
if (line == "info") { if (line == "info") {
system_info::printSystem(console); 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(), console.printf("wifi: %s, ip %s, rssi %d | sd: %s, %u write faults\n", wifi->ssid().c_str(), wifi->ip().c_str(),
@@ -1009,6 +1076,14 @@ 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).
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);
}
static void serialCommands() { static void serialCommands() {
if (upload.active()) return readUploadBytes(); // raw file bytes, not commands if (upload.active()) return readUploadBytes(); // raw file bytes, not commands
static String line; static String line;
@@ -1098,6 +1173,7 @@ static void loopPass() {
serialCommands(); serialCommands();
remoteCommands(); remoteCommands();
tasksStep(); tasksStep();
screenshotStep();
ipTrialStep(); ipTrialStep();
if (noiseTest) noiseTest->step(now); if (noiseTest) noiseTest->step(now);
noteStableOnce(now); noteStableOnce(now);
+53 -31
View File
@@ -35,38 +35,71 @@ void Console::captureEspLogs() {
if (!espLogNext) espLogNext = esp_log_set_vprintf(teeEspLog); // once: it stays, and costs nothing with the ring closed if (!espLogNext) espLogNext = esp_log_set_vprintf(teeEspLog); // once: it stays, and costs nothing with the ring closed
} }
bool Console::openRing() { namespace {
if (ring_) return true;
auto* fresh = static_cast<uint8_t*>(malloc(kRingBytes)); // 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;
auto* fresh = static_cast<uint8_t*>(malloc(Console::kRingBytes));
if (!fresh) return false; if (!fresh) return false;
portENTER_CRITICAL(&ringLock); portENTER_CRITICAL(&ringLock);
head_ = 0; head = 0;
ring_ = fresh; ring = fresh;
portEXIT_CRITICAL(&ringLock); portEXIT_CRITICAL(&ringLock);
return true; return true;
} }
void Console::closeRing() { void closeOne(uint8_t*& ring) {
portENTER_CRITICAL(&ringLock); portENTER_CRITICAL(&ringLock);
uint8_t* old = ring_; uint8_t* old = ring;
ring_ = nullptr; ring = nullptr;
portEXIT_CRITICAL(&ringLock); portEXIT_CRITICAL(&ringLock);
free(old); free(old);
} }
void writeOne(uint8_t* ring, uint32_t& head, const uint8_t* data, size_t len) { // inside the lock
if (!ring) return;
size_t at = head % Console::kRingBytes;
size_t first = std::min(len, Console::kRingBytes - at);
memcpy(ring + at, data, first);
memcpy(ring, data + first, len - first);
head += len;
}
size_t readOne(const uint8_t* ring, uint32_t head, uint32_t& pos, uint8_t* out, size_t max, uint32_t& skipped) {
portENTER_CRITICAL(&ringLock);
size_t n = 0;
skipped = 0;
if (ring) {
uint32_t from = head > Console::kRingBytes ? head - Console::kRingBytes : 0;
skipped = pos < from ? from - pos : 0;
if (pos < from) pos = from;
n = std::min<size_t>(max, head - pos);
size_t at = pos % Console::kRingBytes;
size_t first = std::min(n, Console::kRingBytes - at);
memcpy(out, ring + at, first);
memcpy(out + first, ring, n - first);
pos += n;
}
portEXIT_CRITICAL(&ringLock);
return n;
}
} // namespace
bool Console::openRing() { return openOne(ring_, head_); }
void Console::closeRing() { closeOne(ring_); }
bool Console::openShellRing() { return openOne(shellRing_, shellHead_); }
void Console::closeShellRing() { closeOne(shellRing_); }
void Console::toRing(const uint8_t* data, size_t len) { void Console::toRing(const uint8_t* data, size_t len) {
if (len > kRingBytes) { if (len > kRingBytes) {
data += len - kRingBytes; // only the tail can fit data += len - kRingBytes; // only the tail can fit
len = kRingBytes; len = kRingBytes;
} }
portENTER_CRITICAL(&ringLock); portENTER_CRITICAL(&ringLock);
if (ring_) { writeOne(ring_, head_, data, len);
size_t at = head_ % kRingBytes; writeOne(shellRing_, shellHead_, data, len);
size_t first = std::min(len, kRingBytes - at);
memcpy(ring_ + at, data, first);
memcpy(ring_, data + first, len - first);
head_ += len;
}
portEXIT_CRITICAL(&ringLock); portEXIT_CRITICAL(&ringLock);
} }
@@ -78,22 +111,11 @@ uint32_t Console::oldest() const {
} }
size_t Console::readSince(uint32_t& pos, uint8_t* out, size_t max, uint32_t& skipped) { size_t Console::readSince(uint32_t& pos, uint8_t* out, size_t max, uint32_t& skipped) {
portENTER_CRITICAL(&ringLock); return readOne(ring_, head_, pos, out, max, skipped);
size_t n = 0; }
skipped = 0;
if (ring_) { size_t Console::readShellSince(uint32_t& pos, uint8_t* out, size_t max, uint32_t& skipped) {
uint32_t from = head_ > kRingBytes ? head_ - kRingBytes : 0; return readOne(shellRing_, shellHead_, pos, out, max, skipped);
skipped = pos < from ? from - pos : 0;
if (pos < from) pos = from;
n = std::min<size_t>(max, head_ - pos);
size_t at = pos % kRingBytes;
size_t first = std::min(n, kRingBytes - at);
memcpy(out, ring_ + at, first);
memcpy(out + first, ring_, n - first);
pos += n;
}
portEXIT_CRITICAL(&ringLock);
return n;
} }
size_t Console::write(const uint8_t* data, size_t len) { size_t Console::write(const uint8_t* data, size_t len) {
+8
View File
@@ -34,9 +34,17 @@ class Console : public Print {
// The ring only, for output that already reaches the serial port another way. // The ring only, for output that already reaches the serial port another way.
void toRing(const uint8_t* data, size_t len); 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.
bool openShellRing();
void closeShellRing();
size_t readShellSince(uint32_t& pos, uint8_t* out, size_t max, uint32_t& skipped);
private: private:
uint8_t* ring_ = nullptr; uint8_t* ring_ = nullptr;
uint32_t head_ = 0; // total bytes written since the ring opened; it holds the last kRingBytes of them 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;
}; };
extern Console console; extern Console console;
+115
View File
@@ -0,0 +1,115 @@
#include <unity.h>
#include <cstring>
#include <string>
#include <vector>
#include "png_rgb332.h"
using namespace roro::png;
void setUp() {}
void tearDown() {}
static uint32_t be(const std::vector<uint8_t>& d, size_t at) {
return (uint32_t(d[at]) << 24) | (uint32_t(d[at + 1]) << 16) | (uint32_t(d[at + 2]) << 8) | d[at + 3];
}
void test_the_checksums_match_their_reference_values() {
const char* nine = "123456789";
TEST_ASSERT_EQUAL_HEX32(0xCBF43926, crc32(0, reinterpret_cast<const uint8_t*>(nine), 9));
TEST_ASSERT_EQUAL_HEX32(0x11E60398, adler32(1, reinterpret_cast<const uint8_t*>("Wikipedia"), 9));
// Running: in two pieces, the same.
uint32_t c = crc32(0, reinterpret_cast<const uint8_t*>(nine), 4);
TEST_ASSERT_EQUAL_HEX32(0xCBF43926, crc32(c, reinterpret_cast<const uint8_t*>(nine + 4), 5));
}
// Walks the chunks of what was written: every length adds up and every CRC is right.
void test_a_small_image_is_a_well_formed_png() {
std::vector<uint8_t> out;
const int w = 4, h = 3;
Rgb332Writer writer(w, h, [&](const uint8_t* d, size_t n) {
out.insert(out.end(), d, d + n);
return true;
});
TEST_ASSERT_TRUE(writer.begin());
const uint8_t rows[3][4] = {{0x00, 0xE0, 0x1C, 0x03}, {0xFF, 0x80, 0x10, 0x02}, {1, 2, 3, 4}};
for (auto& r : rows) TEST_ASSERT_TRUE(writer.row(r));
TEST_ASSERT_FALSE(writer.row(rows[0])); // no more rows than the height
TEST_ASSERT_TRUE(writer.end());
TEST_ASSERT_EQUAL(Rgb332Writer::fileSize(w, h), out.size());
const uint8_t signature[] = {0x89, 'P', 'N', 'G', '\r', '\n', 0x1A, '\n'};
TEST_ASSERT_EQUAL_MEMORY(signature, out.data(), 8);
std::vector<std::string> kinds;
std::vector<uint8_t> idat;
for (size_t at = 8; at < out.size();) {
uint32_t len = be(out, at);
std::string kind(out.begin() + at + 4, out.begin() + at + 8);
kinds.push_back(kind);
TEST_ASSERT_EQUAL_HEX32(crc32(0, out.data() + at + 4, len + 4), be(out, at + 8 + len));
if (kind == "IDAT") idat.assign(out.begin() + at + 8, out.begin() + at + 8 + len);
if (kind == "IHDR") {
TEST_ASSERT_EQUAL(w, be(out, at + 8));
TEST_ASSERT_EQUAL(h, be(out, at + 12));
TEST_ASSERT_EQUAL(8, out[at + 16]);
TEST_ASSERT_EQUAL(3, out[at + 17]); // indexed
}
if (kind == "PLTE") {
TEST_ASSERT_EQUAL(768, len);
const uint8_t* white = out.data() + at + 8 + 255 * 3;
TEST_ASSERT_EQUAL(255, white[0] + 0);
TEST_ASSERT_EQUAL(255, white[1] + 0);
TEST_ASSERT_EQUAL(255, white[2] + 0);
const uint8_t* red = out.data() + at + 8 + 0xE0 * 3;
TEST_ASSERT_EQUAL(255, red[0] + 0);
TEST_ASSERT_EQUAL(0, red[1] + 0);
}
at += 12 + len;
}
TEST_ASSERT_EQUAL(4, kinds.size());
TEST_ASSERT_EQUAL_STRING("IHDR", kinds[0].c_str());
TEST_ASSERT_EQUAL_STRING("PLTE", kinds[1].c_str());
TEST_ASSERT_EQUAL_STRING("IDAT", kinds[2].c_str());
TEST_ASSERT_EQUAL_STRING("IEND", kinds[3].c_str());
// The zlib stream: one stored, final block of (w + 1) * h bytes, then their Adler-32.
const size_t raw = (w + 1) * h;
TEST_ASSERT_EQUAL(2 + 5 + raw + 4, idat.size());
TEST_ASSERT_EQUAL_HEX8(0x78, idat[0]);
TEST_ASSERT_EQUAL(0, ((idat[0] << 8) | idat[1]) % 31); // the header's own check
TEST_ASSERT_EQUAL_HEX8(0x01, idat[2]); // final, stored
TEST_ASSERT_EQUAL(raw, idat[3] | (idat[4] << 8));
TEST_ASSERT_EQUAL(static_cast<uint16_t>(~raw), idat[5] | (idat[6] << 8));
TEST_ASSERT_EQUAL(0, idat[7]); // the first row's filter byte: none
TEST_ASSERT_EQUAL_HEX8(0xE0, idat[9]); // its second pixel
TEST_ASSERT_EQUAL_HEX32(adler32(1, idat.data() + 7, raw), be(idat, 7 + raw));
}
void test_the_screen_fits_and_a_bigger_image_is_refused() {
TEST_ASSERT_EQUAL(8 + 25 + 780 + 12 + (2 + 5 + 241 * 135 + 4) + 12, Rgb332Writer::fileSize(240, 135));
int calls = 0;
Rgb332Writer screen(240, 135, [&](const uint8_t*, size_t) { return ++calls > 0; });
TEST_ASSERT_TRUE(screen.begin());
Rgb332Writer big(320, 240, [](const uint8_t*, size_t) { return true; }); // 77,040 bytes: more than one block holds
TEST_ASSERT_FALSE(big.begin());
}
void test_a_sink_that_fails_stops_it_and_too_few_rows_dont_end() {
Rgb332Writer failing(4, 3, [](const uint8_t*, size_t) { return false; });
TEST_ASSERT_FALSE(failing.begin());
Rgb332Writer shortOne(4, 3, [](const uint8_t*, size_t) { return true; });
TEST_ASSERT_TRUE(shortOne.begin());
const uint8_t row[4] = {};
TEST_ASSERT_TRUE(shortOne.row(row));
TEST_ASSERT_FALSE(shortOne.end());
}
int main(int, char**) {
UNITY_BEGIN();
RUN_TEST(test_the_checksums_match_their_reference_values);
RUN_TEST(test_a_small_image_is_a_well_formed_png);
RUN_TEST(test_the_screen_fits_and_a_bigger_image_is_refused);
RUN_TEST(test_a_sink_that_fails_stops_it_and_too_few_rows_dont_end);
return UNITY_END();
}
+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();
}