diff --git a/CONTEXT.md b/CONTEXT.md index b420548..20944ea 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -106,6 +106,10 @@ The regulatory band plan the device transmits under (here EU868). It sets the al **Duty Cycle Budget**: 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**: 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 diff --git a/README.md b/README.md index ef6422c..38ece9c 100644 --- a/README.md +++ b/README.md @@ -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. +## 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 `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) | | `log level <0-5>` | ESP-IDF log level | | `ls [folder]` / `du ` | Lists a folder of the SD card with sizes and dates, or counts the files and bytes under a path | -| `cp [-f] ` / `mv [-f] ` / `rm ` / `mkdir ` / `cancel` | What the Storage App does, with its rules: copy (folders too), move or rename, delete (a folder with what's in it), new folder. `-f` replaces a file that's in the way; a tab separates paths that hold spaces; `cancel` stops a copy or a delete | +| `screenshot [seconds]` | The screen as a PNG in `/screenshots` on the card, now or after a pause to get to the screen you want (240 x 135, about 33 KB). Over the Debug Console a bare `screenshot` sends the screen to the PC instead | +| `cp [-f] ` / `mv [-f] ` / `rm [-f] ` / `mkdir ` / `cancel` | What the Storage App does, with its rules: copy (folders too), move or rename, delete (a folder with what's in it), new folder. `-f` replaces a file that's in the way; a tab separates paths that hold spaces; `cancel` stops a copy or a delete | | `install ` | Update from SD with that `.ota` file, as Settings → Firmware does | | `update check` / `update list` / `update status` / `update install ` | The project's releases on Gitea: look at the latest, list the last ten, say what's known, or download and install one | | `update pretend ` / `update probe ` / `update damage cut\|flip ` / `update daily` | Pretend to run another version (so a release counts as an update), see whether a server's certificate is accepted, cut or damage the next download, run the daily check again | diff --git a/docs/milestones/S1.md b/docs/milestones/S1.md index d89586e..ba2b4a8 100644 --- a/docs/milestones/S1.md +++ b/docs/milestones/S1.md @@ -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. 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. diff --git a/lib/apps_model/src/shell_log.cpp b/lib/apps_model/src/shell_log.cpp new file mode 100644 index 0000000..973172f --- /dev/null +++ b/lib/apps_model/src/shell_log.cpp @@ -0,0 +1,95 @@ +#include "shell_log.h" + +#include + +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 commandWords(const char* helpText) { + std::vector 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& words, std::vector& 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 diff --git a/lib/apps_model/src/shell_log.h b/lib/apps_model/src/shell_log.h new file mode 100644 index 0000000..b17df65 --- /dev/null +++ b/lib/apps_model/src/shell_log.h @@ -0,0 +1,59 @@ +#pragma once + +#include +#include +#include +#include +#include + +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& lines() const { return lines_; } + void clear(); + uint32_t revision() const { return revision_; } // changes when the lines do + + private: + bool wanted(const std::string& line, uint32_t nowMs) const; + void push(const std::string& line); + + std::deque lines_; + std::string partial_; + size_t bytes_ = 0; + bool background_ = true, ranOne_ = false; + uint32_t lastCommandMs_ = 0, revision_ = 0; +}; + +// The commands `help` lists: the first word of each (`ls [folder] | du ` gives ls and du), +// each once, in the order they appear. +std::vector 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& words, std::vector& matches); + +} // namespace roro diff --git a/lib/core/src/app_keys.h b/lib/core/src/app_keys.h index 2e9d250..e9a6526 100644 --- a/lib/core/src/app_keys.h +++ b/lib/core/src/app_keys.h @@ -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"}, diff --git a/lib/files/src/file_names.cpp b/lib/files/src/file_names.cpp index 2a1ad4a..0bdc813 100644 --- a/lib/files/src/file_names.cpp +++ b/lib/files/src/file_names.cpp @@ -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) { diff --git a/lib/files/src/png_rgb332.cpp b/lib/files/src/png_rgb332.cpp new file mode 100644 index 0000000..f1b999b --- /dev/null +++ b/lib/files/src/png_rgb332.cpp @@ -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(v >> 24); + out[1] = static_cast(v >> 16); + out[2] = static_cast(v >> 8); + out[3] = static_cast(v); +} + +size_t rawSize(int w, int h) { return static_cast(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(w_)); + be32(ihdr + 8, static_cast(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((v >> 5) * 255 / 7); + piece[i * 3 + 1] = static_cast(((v >> 2) & 7) * 255 / 7); + piece[i * 3 + 2] = static_cast((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(idatSize(w_, h_))); + if (!sink_(word, 4)) return false; + crc_ = 0; + const uint8_t head[] = {'I', 'D', 'A', 'T', 0x78, 0x01, 0x01, static_cast(raw), static_cast(raw >> 8), + static_cast(~raw), static_cast(~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(w_)); + return put(&filter, 1, true) && put(pixels, static_cast(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 diff --git a/lib/files/src/png_rgb332.h b/lib/files/src/png_rgb332.h new file mode 100644 index 0000000..593ea93 --- /dev/null +++ b/lib/files/src/png_rgb332.h @@ -0,0 +1,38 @@ +#pragma once + +#include +#include +#include + +// 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; // 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 diff --git a/site/content/dev/_index.md b/site/content/dev/_index.md index 4dcd798..1375bf1 100644 --- a/site/content/dev/_index.md +++ b/site/content/dev/_index.md @@ -8,6 +8,6 @@ sort_by = "weight" 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. diff --git a/site/content/dev/debug/commands.md b/site/content/dev/debug/commands.md index a55199a..bdca96d 100644 --- a/site/content/dev/debug/commands.md +++ b/site/content/dev/debug/commands.md @@ -19,10 +19,11 @@ net bytes each network service has read and written since boot reboot restart boot other restart into the other app slot (manual Rollback) log level <0-5> ESP-IDF log level (0 none ... 5 verbose) -ls [folder] | du | mkdir | rm | cp [-f] | mv [-f] | cancel the SD card, with the Storage App's rules +ls [folder] | du | mkdir | rm [-f] | cp [-f] | mv [-f] | 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 the LoRa radio, receive only 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 [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 status | gnss restart | gnss track start|stop | gnss nmea on|off | gnss send @@ -35,7 +36,7 @@ wifi dns [b] | wifi dns always on|off | wifi ntp [b] DNS and NTP serve gemini get fetch a Gemini page and report header, size, certificate, heap irc start | irc stop | irc dump | irc say install Update from SD -update check | list | status | install the project's releases on Gitea +update check | update list | update status | update install the project's releases on Gitea sd card | sd list | cat | log | burst | sound on|off | short | normal 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 @@ -47,7 +48,7 @@ lora inject [rssi] [snr] a packet into the LoRa Scanner as if received ( sd fill 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 reset (Debug Console only) restart at once, even if the main loop is stuck -get | put | screenshot (Debug Console only) binary, see rdbg.py +get | put | 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 ``` @@ -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) | | `log level <0-5>` | ESP-IDF log level | | `ls [folder]` / `du ` | Lists a folder of the SD card with sizes and dates, or counts the files and bytes under a path | -| `cp [-f] ` / `mv [-f] ` / `rm ` / `mkdir ` / `cancel` | What the Storage App does, with its rules: copy (folders too), move or rename, delete (a folder with what's in it), new folder. `-f` replaces a file that's in the way; a tab separates paths that hold spaces; `cancel` stops a copy or a delete | +| `screenshot [seconds]` | The screen as a PNG in `/screenshots` on the card, now or after a pause to get to the screen you want (240 x 135, about 33 KB). Over the Debug Console a bare `screenshot` sends the screen to the PC instead | +| `cp [-f] ` / `mv [-f] ` / `rm [-f] ` / `mkdir ` / `cancel` | What the Storage App does, with its rules: copy (folders too), move or rename, delete (a folder with what's in it), new folder. `-f` replaces a file that's in the way; a tab separates paths that hold spaces; `cancel` stops a copy or a delete | | `install ` | Update from SD with that `.ota` file, as Settings → Firmware does | | `update check` / `update list` / `update status` / `update install ` | The project's releases on Gitea: look at the latest, list the last ten, say what's known, or download and install one | | `update pretend ` / `update probe ` / `update damage cut\|flip ` / `update daily` | Pretend to run another version (so a release counts as an update), see whether a server's certificate is accepted, cut or damage the next download, run the daily check again | diff --git a/site/content/dev/debug/files-and-screens.md b/site/content/dev/debug/files-and-screens.md index 2e5a19a..29d2bb0 100644 --- a/site/content/dev/debug/files-and-screens.md +++ b/site/content/dev/debug/files-and-screens.md @@ -45,6 +45,7 @@ The device sends `screenshot: rgb332 ` 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 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/). ## `coredump get`: the crash dump diff --git a/site/content/dev/milestones/s1.md b/site/content/dev/milestones/s1.md index 85f5872..6672c12 100644 --- a/site/content/dev/milestones/s1.md +++ b/site/content/dev/milestones/s1.md @@ -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. 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. diff --git a/site/content/guide/basics.md b/site/content/guide/basics.md index 5acd999..553afbb 100644 --- a/site/content/guide/basics.md +++ b/site/content/guide/basics.md @@ -68,9 +68,9 @@ On a new device a short Setup asks four things, then never appears again. It als ## 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 diff --git a/site/content/guide/keys.md b/site/content/guide/keys.md index f224de3..6b2039a 100644 --- a/site/content/guide/keys.md +++ b/site/content/guide/keys.md @@ -1,7 +1,7 @@ +++ 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." -weight = 12 +weight = 13 [extra] tag = "Reference" +++ diff --git a/site/content/guide/settings.md b/site/content/guide/settings.md index 0cd0e83..fd0e2ff 100644 --- a/site/content/guide/settings.md +++ b/site/content/guide/settings.md @@ -1,7 +1,7 @@ +++ title = "Settings" description = "The device's names, region, screen, sound, GNSS and Wi-Fi, and where firmware updates are found." -weight = 10 +weight = 11 [extra] tag = "Settings" +++ diff --git a/site/content/guide/shell.md b/site/content/guide/shell.md new file mode 100644 index 0000000..ff3fce6 --- /dev/null +++ b/site/content/guide/shell.md @@ -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 Enter. `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 | + +- Tab **completes** the command's name. If several fit, it lists them. +- Fn with up and down brings back **lines you typed before**. +- Alt 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. + +Ctrl + b hides the background: then only what is printed in the **ten seconds after each of your commands** is kept, and `mine` shows in the corner. It is ten seconds and not "the answer" because some commands answer a moment later, from another part of the firmware, and nothing marks their lines as theirs. + +## Deleting asks first + +`rm ` asks before it deletes, here where a slip of the finger is one key away. `rm -f ` 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 Fn + h shows on this screen. This table is generated from the firmware's own lists, so it is always the current one. + +{{ keys(scopes=["shell"]) }} diff --git a/site/content/guide/system.md b/site/content/guide/system.md index 9764a7d..034d67a 100644 --- a/site/content/guide/system.md +++ b/site/content/guide/system.md @@ -1,7 +1,7 @@ +++ title = "System" 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] tag = "System" screens = ["system.png"] diff --git a/site/content/guide/updates.md b/site/content/guide/updates.md index f1976eb..8fc423e 100644 --- a/site/content/guide/updates.md +++ b/site/content/guide/updates.md @@ -1,7 +1,7 @@ +++ 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." -weight = 11 +weight = 12 [extra] tag = "Firmware" screens = ["update.png"] diff --git a/site/content/howto/sd-files.md b/site/content/howto/sd-files.md index bd34474..a15bedb 100644 --- a/site/content/howto/sd-files.md +++ b/site/content/howto/sd-files.md @@ -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 | | Files saved from Gemini that are not text | `/gemini/downloads` | whatever they were | | Update files | `/updates` | `.ota` | +| Screenshots (the Shell's `screenshot`) | `/screenshots` | `.png`, named by date and time | ## Rules worth knowing diff --git a/site/data/keys.toml b/site/data/keys.toml index c2c6343..ce0f7c3 100644 --- a/site/data/keys.toml +++ b/site/data/keys.toml @@ -375,6 +375,23 @@ rows = [ ["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]] id = "system" title = "System, any view" diff --git a/site/templates/index.html b/site/templates/index.html index b022649..d807487 100644 --- a/site/templates/index.html +++ b/site/templates/index.html @@ -68,7 +68,7 @@ {% endfor %} -

Plus Settings, with its Wi-Fi and Firmware pages. Every App has a page in the user guide.

+

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 user guide.

diff --git a/src/apps/shell_app.cpp b/src/apps/shell_app.cpp new file mode 100644 index 0000000..a0eb857 --- /dev/null +++ b/src/apps/shell_app.cpp @@ -0,0 +1,180 @@ +#include "apps/shell_app.h" + +#include + +#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().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(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 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& 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> 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(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(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 diff --git a/src/apps/shell_app.h b/src/apps/shell_app.h new file mode 100644 index 0000000..bc0926d --- /dev/null +++ b/src/apps/shell_app.h @@ -0,0 +1,55 @@ +#pragma once + +#include +#include +#include +#include + +#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; + + 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& 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 words_; // the commands Tab completes: built on entering, from `help` + LineEditor input_{240}; + InputHistory history_{16}; + std::unique_ptr 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 diff --git a/src/main.cpp b/src/main.cpp index 440a468..dcfa410 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -7,7 +7,9 @@ #include #include "app_manager.h" +#include "png_rgb332.h" #include "apps/demo_app.h" +#include "apps/shell_app.h" #include "apps/gemini_app.h" #include "apps/gnss_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; } static void setupSafeMode(int crashes); +static const char* helpText(); +static void shellRun(const std::string& line); void setup() { nvs.begin(); @@ -209,6 +213,7 @@ void setup() { apps->registerApp({"lora", "LoRa Scanner", false, new LoraScannerApp(*radioService, *loraCapture, settings, *clockService)}); apps->registerApp({"storage", "Storage", false, new StorageApp(*fileOps, *storageService, *clockService, *update, *power, bus)}); apps->registerApp({"notes", "Notes", false, new NotesApp(*fileOps, *storageService, *clockService, *power)}); + apps->registerApp({"shell", "Shell", false, new ShellApp(shellRun, helpText(), *apps)}); // Leaving the foreground App makes it save: a note being typed, when the device is powered off. power->beforePowerOff = []() { apps->home(); }; apps->registerApp({"system", "System", false, @@ -553,10 +558,11 @@ static const char* const kHelp = "reboot restart\n" "boot other restart into the other app slot (manual Rollback)\n" "log level <0-5> ESP-IDF log level (0 none ... 5 verbose)\n" - "ls [folder] | du | mkdir | rm | cp [-f] | mv [-f] | cancel the SD card, with the Storage App's rules\n" + "ls [folder] | du | mkdir | rm [-f] | cp [-f] | mv [-f] | 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 the LoRa radio, receive only\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 [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 status | gnss restart | gnss track start|stop | gnss nmea on|off | gnss send \n" @@ -569,7 +575,7 @@ static const char* const kHelp = "gemini get fetch a Gemini page and report header, size, certificate, heap\n" "irc start | irc stop | irc dump | irc say \n" "install Update from SD\n" - "update check | list | status | install the project's releases on Gitea\n" + "update check | update list | update status | update install the project's releases on Gitea\n" "sd card | sd list | cat | log | burst | sound on|off | short | normal\n" "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" @@ -581,10 +587,61 @@ static const char* const kHelp = "sd fill 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" "reset (Debug Console only) restart at once, even if the main loop is stuck\n" - "get | put | screenshot (Debug Console only) binary, see rdbg.py\n" + "get | put | 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" ; +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 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(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(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(millis() - shotDueMs) >= 0) { + shotPending = false; + saveScreenshot(); + } + if (shotSaved.exchange(false)) + bus.publish(Event::withText(EventType::Notification, "Screenshot saved in /screenshots", static_cast(NotificationLevel::Info))); +} + // Commands that only touch what Safe Mode starts. static bool safeModeCommand(const String& line) { 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 (line == "help") console.print(kHelp); 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") { 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(), @@ -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() { if (upload.active()) return readUploadBytes(); // raw file bytes, not commands static String line; @@ -1098,6 +1173,7 @@ static void loopPass() { serialCommands(); remoteCommands(); tasksStep(); + screenshotStep(); ipTrialStep(); if (noiseTest) noiseTest->step(now); noteStableOnce(now); diff --git a/src/platform/console.cpp b/src/platform/console.cpp index 00eb133..db94d73 100644 --- a/src/platform/console.cpp +++ b/src/platform/console.cpp @@ -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 } -bool Console::openRing() { - if (ring_) return true; - auto* fresh = static_cast(malloc(kRingBytes)); +namespace { + +// 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(malloc(Console::kRingBytes)); if (!fresh) return false; portENTER_CRITICAL(&ringLock); - head_ = 0; - ring_ = fresh; + head = 0; + ring = fresh; portEXIT_CRITICAL(&ringLock); return true; } -void Console::closeRing() { +void closeOne(uint8_t*& ring) { portENTER_CRITICAL(&ringLock); - uint8_t* old = ring_; - ring_ = nullptr; + uint8_t* old = ring; + ring = nullptr; portEXIT_CRITICAL(&ringLock); 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(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) { if (len > kRingBytes) { data += len - kRingBytes; // only the tail can fit len = kRingBytes; } portENTER_CRITICAL(&ringLock); - if (ring_) { - size_t at = head_ % kRingBytes; - size_t first = std::min(len, kRingBytes - at); - memcpy(ring_ + at, data, first); - memcpy(ring_, data + first, len - first); - head_ += len; - } + writeOne(ring_, head_, data, len); + writeOne(shellRing_, shellHead_, data, len); 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) { - portENTER_CRITICAL(&ringLock); - size_t n = 0; - skipped = 0; - if (ring_) { - uint32_t from = head_ > kRingBytes ? head_ - kRingBytes : 0; - skipped = pos < from ? from - pos : 0; - if (pos < from) pos = from; - n = std::min(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; + return readOne(ring_, head_, pos, out, max, skipped); +} + +size_t Console::readShellSince(uint32_t& pos, uint8_t* out, size_t max, uint32_t& skipped) { + return readOne(shellRing_, shellHead_, pos, out, max, skipped); } size_t Console::write(const uint8_t* data, size_t len) { diff --git a/src/platform/console.h b/src/platform/console.h index d9edfbc..d071328 100644 --- a/src/platform/console.h +++ b/src/platform/console.h @@ -34,9 +34,17 @@ class Console : public Print { // The ring only, for output that already reaches the serial port another way. void toRing(const uint8_t* data, size_t len); + // A second ring of the same kind, for the Shell App (issue #67): open while the App is, so that + // it can show what the console prints. The two don't know of each other. + bool openShellRing(); + void closeShellRing(); + size_t readShellSince(uint32_t& pos, uint8_t* out, size_t max, uint32_t& skipped); + private: uint8_t* ring_ = nullptr; 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; diff --git a/test/test_png/test_png.cpp b/test/test_png/test_png.cpp new file mode 100644 index 0000000..ff05f6d --- /dev/null +++ b/test/test_png/test_png.cpp @@ -0,0 +1,115 @@ +#include + +#include +#include +#include + +#include "png_rgb332.h" + +using namespace roro::png; + +void setUp() {} +void tearDown() {} + +static uint32_t be(const std::vector& 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(nine), 9)); + TEST_ASSERT_EQUAL_HEX32(0x11E60398, adler32(1, reinterpret_cast("Wikipedia"), 9)); + // Running: in two pieces, the same. + uint32_t c = crc32(0, reinterpret_cast(nine), 4); + TEST_ASSERT_EQUAL_HEX32(0xCBF43926, crc32(c, reinterpret_cast(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 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 kinds; + std::vector 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(~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(); +} diff --git a/test/test_shell_log/test_shell_log.cpp b/test/test_shell_log/test_shell_log.cpp new file mode 100644 index 0000000..b480ebc --- /dev/null +++ b/test/test_shell_log/test_shell_log.cpp @@ -0,0 +1,111 @@ +#include + +#include +#include + +#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 | mkdir | rm 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 | log | burst\n" + "get | put | 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 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(); +}