SSH client: a terminal on another machine (#2)
CI / build (pull_request) Successful in 2m22s
Site / build (pull_request) Successful in 10s

The SSH App opens one session to a shell, over libssh (LibSSH-ESP32 5.10.0)
on mbedTLS. A server is trusted the first time on its fingerprint, and a
changed key is a warning with Cancel selected. The password is typed each
time and kept nowhere; or the device makes itself an Ed25519 key, whose
public half is shown, written to /ssh/id_ed25519.pub and printed by
`ssh status`.

lib/term is the terminal: what a shell, less, top, nano and vim send, with
sixteen colours, scroll regions, the alternate screen and 100 lines of
scrollback. Five text sizes with Ctrl and + or -, from 60x20 to 26x8, told
to the far end. The session goes on when the App is left; SSH shows in the
Status Bar. `ssh user@host` in the Shell opens the App.

Also:
- Keys that aren't characters carry Shift, Ctrl and Alt. The terminal needs
  it, and it makes Ctrl+Fn+up/down in a note and Shift+Tab in Gemini work
  from the real keyboard.
- IRC doesn't try to connect under 60 KB free: started with a session open,
  its TLS handshake took the heap down to 236 bytes.
- libssh's own curve25519 is left out of the build (scripts/libssh_filter.py):
  libsodium's has the same names.

Costs 292 KB of flash and about 50 KB of heap while a session is open; not
started under 75 KB free.

Docs: guide page, how-to, FAQ, home page, Status Bar, SD card folders, the
memory how-to, README, glossary, N1 notes with Q254 to Q266 and the checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
2026-10-08 04:38:36 +02:00
co-authored by Claude Opus 5.5
parent 7223147f26
commit 6b71aace4f
47 changed files with 2801 additions and 20 deletions
+510
View File
@@ -0,0 +1,510 @@
#include "apps/ssh_app.h"
#include <Arduino.h>
#include <algorithm>
#include <SD.h>
#include "app_keys.h"
#include "text_wrap.h"
#include "ui/fonts.h"
#include "ui/theme.h"
#include "ui/widgets.h"
namespace roro {
namespace {
constexpr uint32_t kMessageMs = 4000;
constexpr uint32_t kFrameMs = 60; // a busy terminal is drawn this often at most
std::string masked(size_t n) { return std::string(n, '*'); }
// The terminal keeps characters as the fonts have them, one byte each; drawing wants UTF-8.
void appendUtf8(std::string& out, uint8_t ch) {
if (ch < 0x80) out += static_cast<char>(ch);
else {
out += static_cast<char>(0xC0 | (ch >> 6));
out += static_cast<char>(0x80 | (ch & 0x3F));
}
}
// Alt with ; and . looks back and forward, as in the Shell (Q258): lines to move, or 0.
int scrollStep(const KeyEvent& e) {
if (!e.alt || e.ctrl) return 0;
if (e.key == Key::Up || (e.key == Key::Char && e.ch == ';')) return 4;
if (e.key == Key::Down || (e.key == Key::Char && e.ch == '.')) return -4;
return 0;
}
uint16_t colour(int index) {
uint8_t r, g, b;
term::colourRgb(index, r, g, b);
return lgfx::color565(r, g, b);
}
} // namespace
// Ctrl with + and - step through these (Q257): from 60 columns by 20 rows to 26 by 8.
const SshApp::Font SshApp::kFonts[5] = {{&fonts::tiny, 4, 6}, {&fonts::small, 5, 8}, {&fonts::medium, 6, 10}, {&fonts::body, 6, 13}, {&fonts::large, 9, 15}};
void SshApp::grid(int& cols, int& rows) const {
const auto& area = theme::kContent;
cols = area.w / kFonts[font_].w;
rows = area.h / kFonts[font_].h;
}
void SshApp::say(const std::string& text) {
message_ = text;
messageMs_ = millis();
requestRedraw();
}
void SshApp::onEnter() {
font_ = settings_.getInt(Setting::SshFont);
hosts_ = term::SshHosts(settings_.getString(Setting::SshHosts)).list();
list_.setCount(static_cast<int>(hosts_.size()) + 2);
dialog_.reset();
ask_ = Ask::None;
message_.clear();
scroll_ = 0;
// A session that was left running is found again.
SshService::State s = ssh_.state();
view_ = s == SshService::State::Idle || (s == SshService::State::Ended && view_ != View::Session) ? View::Hosts : View::Session;
shownState_ = SshService::State::Idle;
requestRedraw();
}
void SshApp::connect(const term::SshTarget& target) {
int cols, rows;
grid(cols, rows);
std::string why = ssh_.connect(target, cols, rows);
if (!why.empty()) return say(why);
password_ = LineEditor(96);
scroll_ = 0;
view_ = View::Session;
shownState_ = SshService::State::Idle;
}
std::string SshApp::connectTo(const std::string& text) {
term::SshTarget target;
std::string why = term::parseSshTarget(text, target);
if (!why.empty()) return why;
if (ssh_.state() != SshService::State::Idle && ssh_.state() != SshService::State::Ended) return "a session is open already";
font_ = settings_.getInt(Setting::SshFont);
message_.clear();
connect(target);
return view_ == View::Session ? "" : message_;
}
void SshApp::setFont(int index) {
index = std::clamp(index, 0, 4);
if (index == font_) return;
font_ = index;
settings_.setInt(Setting::SshFont, font_);
int cols, rows;
grid(cols, rows);
ssh_.resize(cols, rows); // the far end is told, and redraws for the new size
scroll_ = 0;
say(std::to_string(cols) + " x " + std::to_string(rows));
}
void SshApp::writePublicKey() {
std::string line = ssh_.publicKey() + "\n";
storage_.runJob([line]() {
if (!SD.exists("/ssh")) SD.mkdir("/ssh");
File f = SD.open(kPublicKeyPath, FILE_WRITE);
if (f) {
f.write(reinterpret_cast<const uint8_t*>(line.data()), line.size());
f.close();
}
});
}
void SshApp::help(std::vector<KeyHelp>& out) const {
if (dialog_) return keys::add(out, keys::kDialog);
switch (view_) {
case View::Hosts: return keys::add(out, keys::kSsh);
case View::Entry: return keys::add(out, keys::kText);
case View::Key: return keys::add(out, keys::kSshKey);
case View::Session:
if (ssh_.state() == SshService::State::AskPassword) return keys::add(out, keys::kText);
return keys::add(out, keys::kSshTerminal);
}
}
const char* SshApp::helpTitle() const {
switch (view_) {
case View::Entry: return "SSH: a host";
case View::Key: return "SSH: this device's key";
case View::Session: return "SSH: the terminal";
default: return nullptr;
}
}
bool SshApp::sessionKey(const KeyEvent& e) {
SshService::State state = ssh_.state();
if (state == SshService::State::AskPassword) {
switch (e.key) {
case Key::Char: password_.insert(e.ch); break;
case Key::Delete: password_.backspace(); break;
case Key::Back:
ssh_.disconnect();
break;
case Key::Select: {
std::string typed = password_.text();
password_ = LineEditor(96);
ssh_.answerPassword(typed);
break;
}
default: break;
}
return true;
}
if (state == SshService::State::Connecting) {
if (e.key == Key::Back) ssh_.disconnect();
return true;
}
if (state == SshService::State::Ended || state == SshService::State::Idle) {
if (int step = scrollStep(e)) { // what it said last can still be read
int history = 0;
ssh_.withTerminal([&](term::Terminal& t) { history = t.altScreen() ? 0 : t.historyCount(); });
scroll_ = std::clamp(scroll_ + step, 0, history);
return true;
}
ssh_.clear(); // read: its memory goes back
view_ = View::Hosts;
return true;
}
// Open: every key is the far end's, but these few.
if (e.key == Key::Char && e.ctrl && e.alt && (e.ch == 'q' || e.ch == 'Q')) return ssh_.disconnect(), true;
if (e.key == Key::Char && e.ctrl && (e.ch == '=' || e.ch == '+')) return setFont(font_ + 1), true;
if (e.key == Key::Char && e.ctrl && (e.ch == '-' || e.ch == '_')) return setFont(font_ - 1), true;
if (int step = scrollStep(e)) {
int history = 0;
ssh_.withTerminal([&](term::Terminal& t) { history = t.altScreen() ? 0 : t.historyCount(); });
scroll_ = std::clamp(scroll_ + step, 0, history);
return true;
}
bool app = false;
ssh_.withTerminal([&](term::Terminal& t) { app = t.appCursorKeys(); });
std::string bytes;
switch (e.key) {
case Key::Char: bytes = term::encodeKey(term::TermKey::Char, e.ch, e.ctrl, e.alt, app); break;
case Key::Select: bytes = term::encodeKey(term::TermKey::Enter, 0, false, e.alt, app); break;
case Key::Delete: bytes = term::encodeKey(term::TermKey::Backspace, 0, false, e.alt, app); break;
case Key::Tab: bytes = term::encodeKey(term::TermKey::Tab, 0, false, false, app); break;
// The key is printed "esc", and here that is what it is. With Alt it types the backtick it also carries.
case Key::Back: bytes = e.alt ? "`" : term::encodeKey(term::TermKey::Escape, 0, false, false, app); break;
case Key::Up: bytes = term::encodeKey(e.shift ? term::TermKey::PageUp : term::TermKey::Up, 0, false, false, app); break;
case Key::Down: bytes = term::encodeKey(e.shift ? term::TermKey::PageDown : term::TermKey::Down, 0, false, false, app); break;
case Key::Left: bytes = term::encodeKey(term::TermKey::Left, 0, false, false, app); break;
case Key::Right: bytes = term::encodeKey(term::TermKey::Right, 0, false, false, app); break;
default: break;
}
if (!bytes.empty()) {
scroll_ = 0;
ssh_.send(bytes);
}
return true;
}
bool SshApp::onKey(const KeyEvent& e) {
requestRedraw();
if (dialog_) {
dialog_->onKey(e);
int result = dialog_->result();
if (result == DialogModel::kPending) return true;
Ask asked = ask_;
ask_ = Ask::None;
dialog_.reset();
if (asked == Ask::Trust) ssh_.answerTrust(result == 1);
else if (asked == Ask::NewKey && result == 1) {
std::string why = ssh_.makeKey();
if (why.empty()) writePublicKey();
say(why.empty() ? std::string("Made, and written to ") + kPublicKeyPath : why);
} else if (asked == Ask::Forget && result == 1) {
term::SshHosts saved(settings_.getString(Setting::SshHosts));
term::SshTarget gone, other;
term::parseSshTarget(hosts_[static_cast<size_t>(list_.selected())], gone);
saved.remove(static_cast<size_t>(list_.selected()));
settings_.setString(Setting::SshHosts, saved.stored());
// Its fingerprint goes too, unless another saved host is the same server: met again, it is asked about again.
bool shared = false;
for (auto& h : saved.list()) shared = shared || (term::parseSshTarget(h, other).empty() && other.hostPort() == gone.hostPort());
if (!shared) {
term::SshKnownHosts known(settings_.getString(Setting::SshKnown));
known.forget(gone.hostPort());
settings_.setString(Setting::SshKnown, known.stored());
}
hosts_ = saved.list();
list_.setCount(static_cast<int>(hosts_.size()) + 2);
}
return true;
}
switch (view_) {
case View::Session: return sessionKey(e);
case View::Entry:
switch (e.key) {
case Key::Char: entry_.insert(e.ch); break;
case Key::Delete: entry_.backspace(); break;
case Key::Left: entry_.left(); break;
case Key::Right: entry_.right(); break;
case Key::Back: view_ = View::Hosts; break;
case Key::Select: {
term::SshTarget target;
std::string why = term::parseSshTarget(entry_.text(), target);
if (!why.empty()) say(why);
else connect(target);
break;
}
default: break;
}
return true;
case View::Key:
if (e.key == Key::Back) view_ = View::Hosts;
else if (e.key == Key::Select || (e.key == Key::Char && (e.ch == 'n' || e.ch == 'N'))) {
if (!ssh_.hasKey()) {
std::string why = ssh_.makeKey();
if (why.empty()) writePublicKey();
say(why.empty() ? std::string("Made, and written to ") + kPublicKeyPath : why);
} else {
ask_ = Ask::NewKey;
dialog_.reset(new DialogModel({"Cancel", "New key"}));
}
} else if (e.key == Key::Char && (e.ch == 'w' || e.ch == 'W') && ssh_.hasKey()) {
writePublicKey();
say(std::string("Written to ") + kPublicKeyPath);
}
return true;
case View::Hosts: {
int count = static_cast<int>(hosts_.size());
switch (e.key) {
case Key::Up: list_.up(); break;
case Key::Down: list_.down(); break;
case Key::Back: return false;
case Key::Select: {
int i = list_.selected();
if (i < count) {
term::SshTarget target;
if (term::parseSshTarget(hosts_[static_cast<size_t>(i)], target).empty()) connect(target);
} else if (i == count) {
entry_ = LineEditor(96);
view_ = View::Entry;
} else {
view_ = View::Key;
}
break;
}
case Key::Char:
if ((e.ch == 'd' || e.ch == 'D') && list_.selected() < count) {
ask_ = Ask::Forget;
dialog_.reset(new DialogModel({"Cancel", "Forget"}));
} else if (e.ch == 'n' || e.ch == 'N') {
entry_ = LineEditor(96);
view_ = View::Entry;
}
break;
default: break;
}
return true;
}
}
return true;
}
void SshApp::update(uint32_t nowMs) {
if (!message_.empty() && nowMs - messageMs_ >= kMessageMs) {
message_.clear();
requestRedraw();
}
if (view_ != View::Session) return;
SshService::State state = ssh_.state();
if (state != shownState_) {
shownState_ = state;
if (state == SshService::State::AskTrust && !dialog_) {
ask_ = Ask::Trust;
dialog_.reset(new DialogModel({"Cancel", ssh_.remembered().empty() ? "Trust it" : "Replace"}));
} else if (state != SshService::State::AskTrust && ask_ == Ask::Trust) {
dialog_.reset();
ask_ = Ask::None;
}
if (state == SshService::State::Open) { // it got in: a host worth remembering (Q262)
term::SshHosts saved(settings_.getString(Setting::SshHosts));
saved.used(ssh_.target());
settings_.setString(Setting::SshHosts, saved.stored());
hosts_ = saved.list();
list_.setCount(static_cast<int>(hosts_.size()) + 2);
list_.select(0);
}
requestRedraw();
}
uint32_t revision = ssh_.revision();
if (revision != shownRevision_ && nowMs - lastDrawMs_ >= kFrameMs) {
shownRevision_ = revision;
requestRedraw();
}
}
void SshApp::drawHosts(Canvas& c) {
const auto& area = theme::kContent;
int count = static_cast<int>(hosts_.size());
widgets::list(
c, list_, {area.x, area.y, area.w, 8 * theme::kLineHeight},
[&](int i) -> std::string { return i < count ? hosts_[static_cast<size_t>(i)] : i == count ? "New connection" : "This device's key"; },
[&](int i) -> std::string { return i < count ? "" : i == count ? ">" : ssh_.hasKey() ? ">" : "none yet"; });
}
void SshApp::drawKey(Canvas& c) {
const auto& area = theme::kContent;
c.setFont(&fonts::body);
c.setTextColor(theme::kMuted);
if (!ssh_.hasKey()) {
c.drawString("This device has no key yet.", 4, area.y + 4);
c.drawString("With one, servers that know it", 4, area.y + 4 + 2 * theme::kLineHeight);
c.drawString("ask for no password.", 4, area.y + 4 + 3 * theme::kLineHeight);
c.setTextColor(theme::kText);
c.drawString("Enter makes one.", 4, area.y + 4 + 5 * theme::kLineHeight);
return;
}
c.drawString("Its public half, for a server's", 4, area.y + 2);
c.drawString("authorized_keys file:", 4, area.y + 2 + theme::kLineHeight);
c.setFont(&fonts::small);
c.setTextColor(theme::kText);
std::string pub = ssh_.publicKey();
int y = area.y + 2 + 2 * theme::kLineHeight + 3;
for (size_t at = 0; at < pub.size() && y < area.y + area.h - 30; at += 46, y += 9) c.drawString(pub.substr(at, 46).c_str(), 4, y);
c.setFont(&fonts::small);
c.setTextColor(theme::kMuted);
c.drawString((std::string("It is also in ") + kPublicKeyPath + " (w writes it").c_str(), 4, area.y + area.h - 28);
c.drawString("again). The private half never leaves.", 4, area.y + area.h - 19);
}
void SshApp::drawTerminal(Canvas& c) {
const auto& area = theme::kContent;
const Font& f = kFonts[font_];
c.setFont(f.font);
c.setTextDatum(top_left);
ssh_.withTerminal([&](term::Terminal& t) {
int rows = std::min(t.rows(), area.h / f.h), cols = std::min(t.cols(), area.w / f.w);
int history = t.altScreen() ? 0 : t.historyCount(), back = std::min(scroll_, history);
for (int r = 0; r < rows; r++) {
int y = area.y + r * f.h, line = r - back; // negative: a line of the history
if (line < 0) {
const std::string& text = t.historyLine(history + line);
std::string shown;
for (size_t i = 0; i < text.size() && static_cast<int>(i) < cols; i++) appendUtf8(shown, static_cast<uint8_t>(text[i]));
c.setTextColor(theme::kMuted);
c.drawString(shown.c_str(), area.x, y);
continue;
}
for (int col = 0; col < cols;) { // one run of the same colours at a time
uint8_t attr = t.cell(line, col).attr;
std::string run;
int from = col;
while (col < cols && t.cell(line, col).attr == attr) appendUtf8(run, t.cell(line, col++).ch);
if (attr >> 4) c.fillRect(area.x + from * f.w, y, (col - from) * f.w, f.h, colour(attr >> 4));
if (run.find_first_not_of(' ') == std::string::npos) continue;
c.setTextColor(colour(attr & 15));
c.drawString(run.c_str(), area.x + from * f.w, y);
}
}
// The cursor: its cell with the colours swapped.
int row = t.cursorRow() + back;
if (t.cursorVisible() && row < rows && t.cursorCol() < cols && ssh_.state() == SshService::State::Open) {
const term::Cell& cell = t.cell(t.cursorRow(), t.cursorCol());
int x = area.x + t.cursorCol() * f.w, y = area.y + row * f.h;
c.fillRect(x, y, f.w, f.h, colour(cell.attr & 15));
std::string ch;
appendUtf8(ch, cell.ch);
c.setTextColor(colour(cell.attr >> 4));
if (cell.ch != ' ') c.drawString(ch.c_str(), x, y);
}
if (back) { // looking back: how far
std::string where = "-" + std::to_string(back);
c.setFont(&fonts::small);
c.setTextDatum(top_right);
c.fillRect(area.x + area.w - 5 * static_cast<int>(where.size()) - 3, area.y, 5 * static_cast<int>(where.size()) + 3, 9, theme::kAccent);
c.setTextColor(0x0000);
c.drawString(where.c_str(), area.x + area.w - 1, area.y + 1);
c.setTextDatum(top_left);
}
});
}
void SshApp::drawSession(Canvas& c) {
const auto& area = theme::kContent;
SshService::State state = ssh_.state();
if (state == SshService::State::Open || (state == SshService::State::Ended && ssh_.opened())) {
drawTerminal(c);
if (state == SshService::State::Ended) {
std::string why = ssh_.status() + ". Any key.";
if (why.size() > 47) why = ssh_.status();
c.setFont(&fonts::small);
c.fillRect(area.x, area.y + area.h - 11, area.w, 11, theme::kBackground);
c.setTextColor(theme::kWarning);
c.drawString(why.c_str(), 4, area.y + area.h - 9);
}
return;
}
c.setFont(&fonts::body);
c.setTextColor(theme::kMuted);
c.drawString(ssh_.target().text().c_str(), 4, area.y + 4);
if (state == SshService::State::AskPassword) {
std::string again = ssh_.status();
c.setTextColor(again.empty() ? theme::kText : theme::kWarning);
c.drawString(again.empty() ? "Password" : again.c_str(), 4, area.y + 4 + 2 * theme::kLineHeight);
LineEditor stars(96);
stars.setText(masked(password_.text().size()));
widgets::lineEditor(c, stars, {4, area.y + 4 + 3 * theme::kLineHeight + 4, area.w - 8, 0});
c.setFont(&fonts::small);
c.setTextColor(theme::kMuted);
c.drawString("It isn't kept. ` gives up.", 4, area.y + area.h - 10);
return;
}
// Connecting, or it never got as far as a shell: what it is doing, or why not.
bool ended = state == SshService::State::Ended;
c.setTextColor(ended ? theme::kWarning : theme::kText);
auto lines = wrapText(ssh_.status(), area.w - 8, widgets::bodyMeasure(c));
for (size_t i = 0; i < lines.size() && i < 4; i++) c.drawString(lines[i].c_str(), 4, area.y + 4 + static_cast<int>(2 + i) * theme::kLineHeight);
if (ended) {
c.setFont(&fonts::small);
c.setTextColor(theme::kMuted);
c.drawString("Any key.", 4, area.y + area.h - 10);
}
}
void SshApp::draw(Canvas& c) {
lastDrawMs_ = millis();
const auto& area = theme::kContent;
c.setTextDatum(top_left);
switch (view_) {
case View::Hosts: drawHosts(c); break;
case View::Entry:
c.setFont(&fonts::body);
c.setTextColor(theme::kMuted);
c.drawString("Who, and where", 4, area.y + 4);
widgets::lineEditor(c, entry_, {4, area.y + 22, area.w - 8, 0});
c.drawString("user@host, or user@host:port", 4, area.y + 44);
break;
case View::Key: drawKey(c); break;
case View::Session: drawSession(c); break;
}
if (!message_.empty() && !dialog_) {
c.setFont(&fonts::small);
c.fillRect(area.x, area.y + area.h - 11, area.w, 11, theme::kBackground);
c.setTextColor(theme::kWarning);
c.drawString(message_.c_str(), 4, area.y + area.h - 9);
}
if (dialog_ && ask_ == Ask::Trust) {
std::string was = ssh_.remembered(), now = ssh_.fingerprint();
// Three lines: one of words, and the fingerprint in two halves, since it is longer than a line.
std::string shown = now.size() > 25 ? now.substr(0, 25) + " " + now.substr(25) : now;
std::string host = ssh_.target().host.size() > 30 ? ssh_.target().host.substr(0, 28) + ".." : ssh_.target().host;
if (was.empty()) widgets::dialog(c, "A server not met before", host + " " + shown, *dialog_);
else widgets::dialog(c, "THE SERVER'S KEY CHANGED", "Someone in between? Now it is " + shown, *dialog_);
} else if (dialog_ && ask_ == Ask::NewKey) {
widgets::dialog(c, "A new key?", "Servers that know the old one will ask for a password again.", *dialog_);
} else if (dialog_ && ask_ == Ask::Forget) {
widgets::dialog(c, "Forget this host?", hosts_[static_cast<size_t>(std::min<int>(list_.selected(), static_cast<int>(hosts_.size()) - 1))], *dialog_);
}
}
} // namespace roro
+71
View File
@@ -0,0 +1,71 @@
#pragma once
#include <memory>
#include <string>
#include <vector>
#include "app.h"
#include "dialog_model.h"
#include "line_editor.h"
#include "list_model.h"
#include "services/ssh_service.h"
#include "services/storage_service.h"
#include "settings.h"
#include "ui/canvas.h"
namespace roro {
// The SSH App (issue #2, docs/milestones/N1.md): the saved hosts, and a terminal on the one that
// is connected. Leaving it with Home doesn't end the session; coming back finds it.
class SshApp : public App {
public:
static constexpr const char* kPublicKeyPath = "/ssh/id_ed25519.pub";
SshApp(SshService& ssh, Settings& settings, StorageService& storage) : ssh_(ssh), settings_(settings), storage_(storage) {}
void onEnter() override;
bool onKey(const KeyEvent& e) override;
bool textEntryActive() const override { return view_ == View::Entry || (view_ == View::Session && !dialog_); }
void help(std::vector<KeyHelp>& out) const override;
const char* helpTitle() const override;
void update(uint32_t nowMs) override;
void draw(Canvas& c) override;
// `ssh user@host` from the Shell or a console: "" or why not.
std::string connectTo(const std::string& target);
private:
enum class View { Hosts, Entry, Session, Key };
struct Font {
const lgfx::IFont* font;
int w, h;
};
static const Font kFonts[5];
void grid(int& cols, int& rows) const;
void connect(const term::SshTarget& target);
void setFont(int index);
bool sessionKey(const KeyEvent& e);
void drawHosts(Canvas& c);
void drawSession(Canvas& c);
void drawTerminal(Canvas& c);
void drawKey(Canvas& c);
void say(const std::string& text);
void writePublicKey();
SshService& ssh_;
Settings& settings_;
StorageService& storage_;
View view_ = View::Hosts;
ListModel list_{8};
std::vector<std::string> hosts_;
LineEditor entry_{96}, password_{96};
std::unique_ptr<DialogModel> dialog_;
enum class Ask { None, Trust, NewKey, Forget } ask_ = Ask::None;
SshService::State shownState_ = SshService::State::Idle;
std::string message_;
uint32_t messageMs_ = 0, shownRevision_ = 0, lastDrawMs_ = 0;
int font_ = 1, scroll_ = 0;
};
} // namespace roro
+32
View File
@@ -12,7 +12,9 @@
#include "apps/demo_app.h"
#include "apps/note_editor.h"
#include "apps/shell_app.h"
#include "apps/ssh_app.h"
#include "services/net_tools.h"
#include "services/ssh_service.h"
#include "services/vpn_service.h"
#include "services/web_share.h"
#include "apps/gemini_app.h"
@@ -91,6 +93,8 @@ static UpdateService* update;
static DebugConsole* debugConsole;
static VpnService* vpnService; // not in Safe Mode
static NetTools netTools; // ping, nslookup and the rest (issue #90)
static SshService* sshService; // not in Safe Mode (issue #2)
static SshApp* sshApp;
static Notifier* notifier;
static LauncherApp launcher;
static AppManager* apps;
@@ -139,6 +143,7 @@ static StatusInfo currentStatus() {
s.capturing = loraCapture && loraCapture->capturing();
if (vpnService && vpnService->wanted())
s.vpn = vpnService->state() == VpnService::State::Up ? StatusInfo::Vpn::Up : StatusInfo::Vpn::Trying;
s.ssh = sshService && sshService->active();
s.debug = !debugConsole->on() ? StatusInfo::Debug::Off : debugConsole->clientConnected() ? StatusInfo::Debug::Client : StatusInfo::Debug::On;
using WifiState = WifiController::State;
switch (wifi->state()) {
@@ -228,6 +233,9 @@ 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, *new WebShare(*storageService, *fileOps, *wifi))});
apps->registerApp({"notes", "Notes", false, new NotesApp(*fileOps, *storageService, *clockService, *power)});
sshService = new SshService(settings);
sshApp = new SshApp(*sshService, settings, *storageService);
apps->registerApp({"ssh", "SSH", false, sshApp});
apps->registerApp({"shell", "Shell", false, new ShellApp(shellRun, shellProbe, shellList, shellCount, helpText(), *apps)});
// Leaving the foreground App makes it save: a note being typed, when the device is powered off.
power->beforePowerOff = []() { apps->home(); };
@@ -680,6 +688,7 @@ static const char* const kHelp =
"ping <host> [count] [size] | nslookup <name> [server] | port <host> <port> | traceroute <host> | cancel is it there, does its name resolve, is its port open, which way; one at a time\n"
"tls <host> [port] | ntp [server] a TLS handshake: who the certificate is for, by whom, until when, and whether this device trusts it; a time server's clock against this one\n"
"ifconfig | arp | netstat the interfaces (Wi-Fi and the VPN), their addresses, the default route and the DNS servers; the neighbours heard; what listens and what is connected\n"
"ssh user@host[:port] | ssh status | ssh stop a terminal on another machine, in the SSH App; the password is asked there, never here\n"
"vpn status | vpn up [seconds] | vpn down | vpn import [path] | vpn forget | vpn auto on|off the WireGuard tunnel (Settings > VPN); import reads /vpn/wg0.conf; with seconds, it goes down by itself\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"
@@ -740,6 +749,28 @@ static void saveScreenshot() {
});
}
// `ssh ...` (issue #2, Q265): the session is the SSH App's; this opens it there.
static void sshCommand(const String& arg) {
if (!sshService) return (void)console.println("ssh: not available in Safe Mode");
SshService& s = *sshService;
if (arg == "status") {
bool live = s.state() != SshService::State::Idle && s.state() != SshService::State::Ended;
if (s.state() == SshService::State::Idle) console.println("ssh: no session");
else console.printf("ssh: %s%s%s\n", s.target().text().c_str(), live ? ", " : ": ", s.state() == SshService::State::Open ? "open" : s.status().c_str());
console.printf("ssh: this device's key: %s\n", s.hasKey() ? s.publicKey().c_str() : "none (the SSH App makes one)");
} else if (arg == "stop") {
s.disconnect();
console.println("ssh: stopped");
} else if (arg.indexOf('@') > 0) {
std::string why = sshApp->connectTo(arg.c_str());
if (!why.empty()) return (void)console.printf("ssh: error %s\n", why.c_str());
if (!apps->open("ssh")) return (void)console.println("ssh: connecting, but Setup is running");
console.printf("ssh: connecting to %s, in the SSH App\n", s.target().text().c_str());
} else {
console.println("ssh: user@host[:port] | status | stop");
}
}
// `vpn ...` (issue #8). Nothing here prints a key.
static void vpnCommand(const String& arg) {
if (!vpnService) return (void)console.println("vpn: not available in Safe Mode");
@@ -874,6 +905,7 @@ static void runCommand(String line, bool fromSerial = false) {
}
if (netTools.command(line.c_str())) return;
if (line == "cancel") netTools.cancel(); // and a copy or a delete, below
if (line == "ssh" || line.startsWith("ssh ")) return sshCommand(line.length() > 4 ? line.substring(4) : String("status"));
if (line == "vpn" || line.startsWith("vpn ")) return vpnCommand(line.length() > 4 ? line.substring(4) : String("status"));
if (line.startsWith("debug ")) return debugCommand(line.substring(6), fromSerial);
if (line == "screenshot" || line.startsWith("screenshot ")) {
+4 -1
View File
@@ -238,7 +238,10 @@ void IrcService::loop() {
retryAtMs_ = now; // reconnect as soon as Wi-Fi is back
} else if (!open_) {
if (static_cast<int32_t>(now - retryAtMs_) >= 0) {
if (!open()) scheduleRetry("could not connect to " + draftConfig().host);
// A secure connection peaks at 52 KB. Under an open SSH session there isn't that much, and
// trying anyway takes the heap down to nothing (seen on the device, issue #2).
if (esp_get_free_heap_size() < kNeedFree) scheduleRetry("not enough memory: close the SSH session");
else if (!open()) scheduleRetry("could not connect to " + draftConfig().host);
}
} else if (!conn_->connected()) {
close("");
+1
View File
@@ -25,6 +25,7 @@ namespace roro {
// session is shared with the IRC App under a lock.
class IrcService : public Service {
public:
static constexpr size_t kNeedFree = 60 * 1024; // free memory a connection attempt wants
enum class Status { Stopped, WaitingForWifi, Connecting, Registering, Online, Paused, Retrying };
IrcService(KeyValueStore& store, const std::string& defaultNick, WifiService& wifi, StorageService& storage,
+365
View File
@@ -0,0 +1,365 @@
#include "services/ssh_service.h"
#include <Arduino.h>
#include <libssh_esp32.h>
#include <libssh/libssh.h>
namespace roro {
namespace {
constexpr uint32_t kStack = 20480; // a session used 13 KB of it at most (measured)
constexpr int kPasswordTries = 3;
struct Locked {
explicit Locked(void* lock) : lock_(static_cast<SemaphoreHandle_t>(lock)) { xSemaphoreTake(lock_, portMAX_DELAY); }
~Locked() { xSemaphoreGive(lock_); }
SemaphoreHandle_t lock_;
};
std::string fingerprintOf(ssh_session s) {
ssh_key key = nullptr;
unsigned char* hash = nullptr;
size_t len = 0;
std::string out;
if (ssh_get_server_publickey(s, &key) == SSH_OK && ssh_get_publickey_hash(key, SSH_PUBLICKEY_HASH_SHA256, &hash, &len) == SSH_OK) {
if (char* text = ssh_get_fingerprint_hash(SSH_PUBLICKEY_HASH_SHA256, hash, len)) {
out = text; // "SHA256:..."
ssh_string_free_char(text);
}
ssh_clean_pubkey_hash(&hash);
}
if (key) ssh_key_free(key);
return out;
}
void startLibrary() {
static bool started = false;
if (!started) libssh_begin();
started = true;
}
} // namespace
struct SshService::Run {
SshService* service;
std::string host, user, privateKey, known;
int port, cols, rows;
};
SshService::SshService(Settings& settings) : settings_(settings), lock_(xSemaphoreCreateMutex()) {}
std::string SshService::status() const {
Locked l(lock_);
return status_;
}
std::string SshService::fingerprint() const {
Locked l(lock_);
return fingerprint_;
}
std::string SshService::remembered() const {
Locked l(lock_);
return remembered_;
}
void SshService::say(const std::string& what) {
Locked l(lock_);
status_ = what;
revision_++;
}
void SshService::end(const std::string& why) {
{
Locked l(lock_);
status_ = why;
password_.assign(password_.size(), '\0');
password_.clear();
outgoing_.clear();
}
state_ = State::Ended;
revision_++;
}
std::string SshService::connect(const term::SshTarget& target, int cols, int rows) {
if (running_) return "A session is still closing: a moment";
if (ESP.getFreeHeap() < kNeedFree) return "Not enough memory: close IRC or a Gemini page";
target_ = target;
{
Locked l(lock_);
term_.reset(new term::Terminal(cols, rows, kHistory));
term_->reply = [this](const std::string& s) { outgoing_ += s; }; // called with the lock held, from feed()
status_ = "Connecting to " + target.host;
fingerprint_.clear();
remembered_.clear();
outgoing_.clear();
resized_ = false;
}
stop_ = answered_ = trusted_ = opened_ = false;
auto* run = new Run{this, target.host, target.user, settings_.getString(Setting::SshKey),
term::SshKnownHosts(settings_.getString(Setting::SshKnown)).fingerprintOf(target.hostPort()), target.port, cols, rows};
state_ = State::Connecting;
running_ = true;
if (xTaskCreate(task, "ssh", kStack, run, 1, nullptr) != pdPASS) {
running_ = false;
delete run;
end("Not enough memory for the session");
return "Not enough memory for the session";
}
return "";
}
void SshService::clear() {
if (state_ != State::Ended || running_) return;
Locked l(lock_);
term_.reset();
status_.clear();
state_ = State::Idle;
}
void SshService::disconnect() {
stop_ = true;
answered_ = true; // whatever it was waiting for
}
void SshService::answerTrust(bool yes) {
if (state_ != State::AskTrust) return;
if (yes) { // remembered from here on; on the main loop, where settings are written
term::SshKnownHosts known(settings_.getString(Setting::SshKnown));
known.remember(target_.hostPort(), fingerprint());
settings_.setString(Setting::SshKnown, known.stored());
}
trusted_ = yes;
answered_ = true;
}
void SshService::answerPassword(const std::string& password) {
if (state_ != State::AskPassword) return;
{
Locked l(lock_);
password_ = password;
}
answered_ = true;
}
void SshService::withTerminal(const std::function<void(term::Terminal&)>& use) {
Locked l(lock_);
if (term_) use(*term_);
}
void SshService::send(const std::string& bytes) {
if (state_ != State::Open) return;
Locked l(lock_);
if (outgoing_.size() < 4096) outgoing_ += bytes;
}
void SshService::resize(int cols, int rows) {
Locked l(lock_);
if (term_) term_->resize(cols, rows);
wantCols_ = cols;
wantRows_ = rows;
resized_ = true;
revision_++;
}
void SshService::task(void* arg) {
std::unique_ptr<Run> run(static_cast<Run*>(arg));
SshService* self = run->service;
self->session(*run);
run.reset();
self->running_ = false;
vTaskDelete(nullptr);
}
void SshService::session(Run& run) {
startLibrary();
ssh_session s = ssh_new();
if (!s) return end("Not enough memory for the session");
int verbosity = SSH_LOG_NOLOG;
long timeout = 10;
ssh_options_set(s, SSH_OPTIONS_HOST, run.host.c_str());
ssh_options_set(s, SSH_OPTIONS_PORT, &run.port);
ssh_options_set(s, SSH_OPTIONS_USER, run.user.c_str());
ssh_options_set(s, SSH_OPTIONS_TIMEOUT, &timeout);
ssh_options_set(s, SSH_OPTIONS_LOG_VERBOSITY, &verbosity);
auto fail = [&](const std::string& what) {
std::string why = what;
const char* detail = ssh_get_error(s);
if (detail && *detail && what.back() == ':') {
// lwIP reports a refused connection as one reset by the peer.
std::string d = detail;
if (d.find("reset by peer") != std::string::npos || d.find("refused") != std::string::npos) why = "Nothing listens there: the connection was refused";
else if (d.find("imeout") != std::string::npos || d.find("timed out") != std::string::npos) why = "No answer from " + run.host;
else why += " " + d;
}
ssh_disconnect(s);
ssh_free(s);
end(why);
};
auto waitForAnswer = [&]() {
while (!answered_ && !stop_) vTaskDelay(pdMS_TO_TICKS(50));
answered_ = false;
return !stop_;
};
if (ssh_connect(s) != SSH_OK) return fail("No connection:");
if (stop_) return fail("Stopped");
// Is it the server it was last time? The first time, and when it has changed, the user decides.
std::string seen = fingerprintOf(s);
if (seen.empty()) return fail("The server showed no key");
if (seen != run.known) {
{
Locked l(lock_);
fingerprint_ = seen;
remembered_ = run.known;
}
say("Is this the right server?");
state_ = State::AskTrust;
revision_++;
if (!waitForAnswer() || !trusted_) return fail(stop_ ? "Stopped" : "Not trusted: not connected");
state_ = State::Connecting;
}
say("Logging in as " + run.user);
// This device's key first, if it has one and the server takes keys; then a password.
int rc = ssh_userauth_none(s, nullptr);
int methods = ssh_userauth_list(s, nullptr);
if (rc != SSH_AUTH_SUCCESS && !run.privateKey.empty() && (methods & SSH_AUTH_METHOD_PUBLICKEY)) {
ssh_key key = nullptr;
if (ssh_pki_import_privkey_base64(run.privateKey.c_str(), nullptr, nullptr, nullptr, &key) == SSH_OK) {
rc = ssh_userauth_publickey(s, nullptr, key);
ssh_key_free(key);
}
}
run.privateKey.assign(run.privateKey.size(), '\0');
for (int tries = 0; rc != SSH_AUTH_SUCCESS && tries < kPasswordTries; tries++) {
if (!(methods & (SSH_AUTH_METHOD_PASSWORD | SSH_AUTH_METHOD_INTERACTIVE))) return fail("The server takes neither this key nor a password");
say(tries ? "Wrong password" : "");
state_ = State::AskPassword;
revision_++;
if (!waitForAnswer()) return fail("Stopped");
state_ = State::Connecting;
say("Logging in as " + run.user);
std::string password;
{
Locked l(lock_);
password.swap(password_);
}
if (methods & SSH_AUTH_METHOD_PASSWORD) rc = ssh_userauth_password(s, nullptr, password.c_str());
else { // asked as questions: every one that hides its answer gets the password
rc = ssh_userauth_kbdint(s, nullptr, nullptr);
for (int round = 0; rc == SSH_AUTH_INFO && round < 4; round++) {
int n = ssh_userauth_kbdint_getnprompts(s);
for (int i = 0; i < n; i++) ssh_userauth_kbdint_setanswer(s, static_cast<unsigned>(i), password.c_str());
rc = ssh_userauth_kbdint(s, nullptr, nullptr);
}
}
password.assign(password.size(), '\0');
if (rc == SSH_AUTH_ERROR) return fail("Login failed:");
}
if (rc != SSH_AUTH_SUCCESS) return fail("Wrong password, three times");
ssh_channel ch = ssh_channel_new(s);
int cols, rows;
{
Locked l(lock_);
cols = resized_ ? wantCols_ : run.cols;
rows = resized_ ? wantRows_ : run.rows;
resized_ = false;
}
if (!ch || ssh_channel_open_session(ch) != SSH_OK || ssh_channel_request_pty_size(ch, "xterm", cols, rows) != SSH_OK ||
ssh_channel_request_shell(ch) != SSH_OK) {
if (ch) ssh_channel_free(ch);
return fail("No shell:");
}
say("");
opened_ = true;
state_ = State::Open;
revision_++;
std::string why = "The session ended";
uint8_t buf[512];
while (!stop_) {
int waiting = ssh_channel_poll_timeout(ch, 20, 0); // also where it sleeps when nothing happens
if (waiting == SSH_ERROR || waiting == SSH_EOF) break;
bool busy = false;
for (int std_err = 0; std_err < 2; std_err++) {
int n = ssh_channel_read_nonblocking(ch, buf, sizeof buf, std_err);
if (n > 0) {
Locked l(lock_);
term_->feed(buf, static_cast<size_t>(n));
revision_++;
busy = true;
}
}
std::string out;
int newCols = 0, newRows = 0;
{
Locked l(lock_);
out.swap(outgoing_);
if (resized_) {
newCols = wantCols_;
newRows = wantRows_;
resized_ = false;
}
}
if (newCols) ssh_channel_change_pty_size(ch, newCols, newRows);
if (!out.empty() && ssh_channel_write(ch, out.data(), static_cast<uint32_t>(out.size())) < 0) {
why = "The connection was lost";
break;
}
if (ssh_channel_is_eof(ch) || !ssh_channel_is_open(ch)) break;
if (!ssh_is_connected(s)) {
why = "The connection was lost";
break;
}
if (!busy && out.empty()) vTaskDelay(pdMS_TO_TICKS(5));
}
if (stop_) why = "Disconnected";
ssh_channel_close(ch);
ssh_channel_free(ch);
ssh_disconnect(s);
ssh_free(s);
end(why);
}
// On a task of its own for its stack; the main loop waits the moment it takes.
std::string SshService::makeKey() {
struct Made {
std::string priv, pub, why;
std::atomic<bool> done{false};
};
auto made = std::make_shared<Made>();
auto* arg = new std::shared_ptr<Made>(made);
auto work = [](void* p) {
std::shared_ptr<Made> m = *static_cast<std::shared_ptr<Made>*>(p);
delete static_cast<std::shared_ptr<Made>*>(p);
startLibrary();
ssh_key key = nullptr, pub = nullptr;
char *b64 = nullptr, *pub64 = nullptr;
if (ssh_pki_generate(SSH_KEYTYPE_ED25519, 0, &key) != SSH_OK) m->why = "The key couldn't be made";
else if (ssh_pki_export_privkey_base64(key, nullptr, nullptr, nullptr, &b64) != SSH_OK || ssh_pki_export_privkey_to_pubkey(key, &pub) != SSH_OK ||
ssh_pki_export_pubkey_base64(pub, &pub64) != SSH_OK)
m->why = "The key couldn't be written out";
else {
m->priv = b64;
m->pub = std::string("ssh-ed25519 ") + pub64 + " roro9stack";
}
if (b64) ssh_string_free_char(b64);
if (pub64) ssh_string_free_char(pub64);
if (pub) ssh_key_free(pub);
if (key) ssh_key_free(key);
m->done = true;
vTaskDelete(nullptr);
};
if (xTaskCreate(work, "sshkey", 16384, arg, 1, nullptr) != pdPASS) {
delete arg;
return "Not enough memory to make a key";
}
for (int waited = 0; !made->done && waited < 10000; waited += 20) delay(20);
if (!made->done) return "Making the key took too long";
if (!made->why.empty()) return made->why;
if (!settings_.setString(Setting::SshKey, made->priv) || !settings_.setString(Setting::SshPublic, made->pub)) return "The key couldn't be stored";
return "";
}
} // namespace roro
+79
View File
@@ -0,0 +1,79 @@
#pragma once
#include <atomic>
#include <functional>
#include <memory>
#include <string>
#include "event_bus.h"
#include "settings.h"
#include "ssh_hosts.h"
#include "terminal.h"
namespace roro {
// One SSH session to a shell (issue #2, docs/milestones/N1.md): the protocol is libssh's, on a
// task of its own; what it prints goes into a term::Terminal, which the SSH App draws. The
// session lasts until the far end closes it or disconnect() is called, whether the App is in
// front or not.
//
// The task and the main loop share the terminal, the bytes waiting to be sent and the state,
// under one lock. Questions for the user (is this the right server? what is the password?) are
// states the task waits in until the App answers.
class SshService {
public:
enum class State { Idle, Connecting, AskTrust, AskPassword, Open, Ended };
static constexpr size_t kNeedFree = 75 * 1024; // a session peaks at about 63 KB (measured)
static constexpr int kHistory = 100;
explicit SshService(Settings& settings);
// "" when the connection is on its way, or why not.
std::string connect(const term::SshTarget& target, int cols, int rows);
void disconnect(); // from any state; the terminal stays to be read until the next connect
void clear(); // Ended, and read: the terminal and its history are given back
State state() const { return state_; }
bool active() const { return state_ == State::Open; } // for the Status Bar
bool opened() const { return opened_; } // this session got as far as a shell
std::string status() const; // what it is doing, or why it ended
const term::SshTarget& target() const { return target_; }
// AskTrust: the server's fingerprint, and the one remembered if this is a different one.
std::string fingerprint() const;
std::string remembered() const;
void answerTrust(bool yes);
// AskPassword.
void answerPassword(const std::string& password);
// Open (and Ended, to read what is left).
bool hasTerminal() const { return static_cast<bool>(term_); }
void withTerminal(const std::function<void(term::Terminal&)>& use);
uint32_t revision() const { return revision_; }
void send(const std::string& bytes);
void resize(int cols, int rows);
// This device's own key: made once, its public half is what goes on servers.
bool hasKey() const { return !settings_.getString(Setting::SshKey).empty(); }
std::string publicKey() const { return settings_.getString(Setting::SshPublic); }
std::string makeKey(); // "" or why not
private:
struct Run; // what one connection's task works with
static void task(void* arg);
void session(Run& run);
void end(const std::string& why);
void say(const std::string& what);
Settings& settings_;
void* lock_; // SemaphoreHandle_t
std::atomic<State> state_{State::Idle};
std::atomic<bool> stop_{false}, answered_{false}, trusted_{false}, running_{false}, opened_{false};
std::atomic<uint32_t> revision_{0};
term::SshTarget target_;
std::unique_ptr<term::Terminal> term_;
std::string status_, fingerprint_, remembered_, password_, outgoing_;
int wantCols_ = 0, wantRows_ = 0;
bool resized_ = false;
};
} // namespace roro
+198
View File
@@ -221,4 +221,202 @@ const uint8_t _5x8_tf[1715] =
"\3\374\11\64b\23\63\212f\32\375\13D^[\343(\323\210I\1\376\12<^\223\363\212#\345\14\377"
"\14<^\23\63\212\62\215\230\24\0\0\0\0\4\377\377\0";
// For the terminal (issue #2): three more sizes of the same family, from the same file.
const uint8_t _4x6_tf[1451] =
"\277\0\2\2\3\3\2\4\4\4\6\0\377\5\377\5\377\0\351\1\323\5\216 \5\200\315\0!\6\351\310"
"\254\0\42\6\223\313$\25#\12\254\310\244\64T\32*\1$\11\263\307\245\241GJ\0%\10\253\310d"
"\324F\1&\11\254\310\305\24\253\230\2'\5\321\313\10(\7\362\307\251f\0)\10\262\307\304T)\0"
"*\7\253\310\244j\65+\10\253\310\305\264b\2,\6\222\307)\0-\5\213\312\14.\5\311\310\4/"
"\7\253\310Ve\4\60\10\253\310UCU\0\61\7\253\310%Y\15\62\7\253\310\65S\32\63\10\253\310"
"\314\224\27\0\64\10\253\310$\65b\1\65\10\253\310\214\250\27\0\66\7\253\310M\325\2\67\10\253\310\314"
"TF\0\70\7\253\310\255\326\2\71\7\253\310\265\344\2:\6\341\310\304\0;\7\252\307e\250\0<\7"
"\253\310\246\272\0=\6\233\311\354\1>\7\253\310\344\252\4\77\10\253\310\350\224a\2@\6\253\310-["
"A\10\253\310UC\251\0B\10\253\310\250\264\322\2C\10\253\310U\62U\0D\10\253\310\250d-\0"
"E\10\253\310\214\250\342\0F\10\253\310\214\250b\4G\10\253\310\315\244\222\0H\10\253\310$\65\224\12"
"I\7\253\310\254X\15J\7\253\310\226\252\2K\10\253\310$\265\222\12L\7\253\310\304\346\0M\10\253"
"\310\244\61\224\12N\10\253\310\252\241$\0O\7\253\310UV\5P\10\253\310\250\264b\4Q\7\263\307"
"UV\35R\10\253\310\250\264\222\12S\7\253\310\355\274\0T\7\253\310\254\330\2U\7\253\310$\327\10"
"V\10\253\310$k\244\4W\10\253\310$\65\206\12X\10\253\310$\325R\1Y\10\253\310$UV\0"
"Z\7\253\310\314T\16[\6\352\310\254J\134\7\253\310\304\134\6]\6\252\310\250j^\5\223\313\65_"
"\5\213\307\14`\6\322\313\304\0a\7\243\310-\225\4b\10\253\310D\225\324\2c\6\243\310\315,d"
"\10\253\310\246\245\222\0e\6\243\310USf\10\253\310\246\264b\2g\10\253\307\255$\27\0h\10\253"
"\310D\225\254\0i\10\253\310e$\323\0j\10\263\307fX.\0k\10\253\310\304\264\222\12l\7\253"
"\310\310\326\0m\10\243\310\244\241T\0n\7\243\310\250d\5o\7\243\310U\252\2p\10\253\307\250\264"
"b\4q\10\253\307-\225d\0r\10\243\310\244\25#\0s\7\243\310\215\274\0t\10\253\310\245\25s"
"\0u\7\243\310$+\11v\7\243\310$\253\2w\10\243\310$\65T\0x\7\243\310\244\62\25y\10"
"\253\307$\225\344\2z\7\243\310\314\224\6{\10\263\307\246$\353\0|\6\351\310\14\1}\11\263\307\344"
"\250b\212\0~\7\224\313%\225\0\240\5\200\315\0\241\6\351\310\244\1\242\10\253\310\245\21W\2\243\7"
"\253\310\246\250\32\244\10\244\310\304$U\14\245\10\253\310\244j\305\4\246\6\351\310(\1\247\10\263\307\215"
"T\311\5\250\6\213\314\244\0\251\11\264\307\251\270\226L\12\252\7\253\310\255\244\7\253\10\234\311%\25S"
"\0\254\6\223\311\314\0\255\5\213\312\14\256\10\244\311\251\261\222\2\257\5\213\314\14\260\6\233\312u\1\261"
"\10\253\310\245\225\321\0\262\6\242\311(\3\263\7\252\310(\251\0\264\6\322\313)\0\265\10\253\307$k"
"E\0\266\7\254\310\15\265z\267\5\311\312\4\270\6\322\310)\0\271\6\242\311\255\2\272\7\253\310u\243"
"\1\273\10\234\311\244\230T\2\274\10\264\307\344\32U;\275\10\264\307\344J\307,\276\11\264\307\350\230Q"
"\262\3\277\10\253\310e\230\262\0\300\10\253\310\344\224\206\12\301\10\253\310\246j\250\0\302\10\253\310\310\224"
"\206\12\303\10\253\310\215\224\206\12\304\10\253\310\244\326P\1\305\10\253\310\305\224\206\12\306\11\254\310\215\224"
"\206\252\4\307\10\263\307U\62\65\1\310\10\253\310\304\241\342\0\311\7\253\310\216\25\7\312\10\253\310\215\221"
"\342\0\313\10\253\310\244\261\342\0\314\10\253\310\304\25\323\0\315\10\253\310\216\24\323\0\316\10\253\310\245\25"
"\323\0\317\7\253\310\244\262\32\320\11\254\310\314\264\252\221\0\321\10\254\310%\225\256\12\322\10\253\310\344\224"
"T\5\323\10\253\310\246JU\0\324\10\253\310\305\224T\5\325\10\254\310\215\325\31\1\326\10\253\310\244\226"
"\252\0\327\6\233\311\244\16\330\7\253\310\35j\1\331\10\253\310\344\224\324\10\332\10\253\310\246J\215\0\333"
"\10\253\310e\224\324\10\334\10\253\310\244\234\324\10\335\10\253\310\346T&\0\336\10\253\310D\225V\4\337"
"\10\263\307U+\15\11\340\10\253\310\344\270\222\0\341\10\253\310\246\270\222\0\342\10\253\310i\264\222\0\343"
"\11\254\310%\25U\251\0\344\10\253\310\244\214V\22\345\10\253\310e\270\222\0\346\10\244\310\215\264\342\0"
"\347\10\253\307UZ%\0\350\10\253\310\344\224\246\0\351\7\253\310\246j\12\352\10\253\310\310\224\246\0\353"
"\7\253\310\244\326\24\354\7\253\310\344X\15\355\6\253\310\316j\356\7\253\310u\246\1\357\10\253\310\244,"
"\323\0\360\10\253\310\244rU\0\361\11\254\310%\225dj\1\362\10\253\310\344\230Z\0\363\10\253\310\246"
"\230Z\0\364\10\253\310e\230Z\0\365\7\253\310l\324\5\366\10\253\310\244\214\272\0\367\10\253\310e\264"
"Q\2\370\7\243\310-\265\0\371\10\253\310\344\224T\22\372\10\253\310\246J%\1\373\10\253\310e\224T"
"\22\374\10\253\310\244\234T\22\375\10\263\307\246j\304\5\376\11\263\307\304\250\322\212\0\377\11\263\307\244\234"
"F\134\0\0\0\0\4\377\377\0";
const uint8_t _6x10_tf[2000] =
"\277\0\2\2\3\4\3\5\4\6\12\0\376\7\376\7\0\1B\2\222\7\263 \5\0b\7!\7\71C"
"g\250\0\42\7\233R'Y\1#\15=B\257Li\250j\250\62%\0$\13=B\67\257z\247\264"
"#\0%\13=B/\252\356\252%\23\0&\14=B/\247\230r\225dT\1'\5\31Sg(\10"
"\273B\67\225u\1)\10\273B'\227U\11*\12-F'\247j\250v\0+\12-F\67\243\70d"
"F\21,\7\233>O\225\0-\6\15Ng\10.\7\233>/\255\4/\13=B\37e\224\273QF"
"\0\60\12=B\67\247\332Nu\4\61\14=B\67\313\224QF\31\305!\62\14=Bo\345\214\242\314"
"\31\15\1\63\14=Bgh\224\263\206:-\0\64\14=B\77\313T\246\241\63J\0\65\13=B\347"
"F\311\314H\247\5\66\13=BW\346\214\222\251\323\2\67\14=Bgh\224\63\312\65\312\0\70\13="
"Boe\235V\326i\1\71\14=Boe\251TF\71J\0:\12\273>/\255\14\323J\0;\11"
"\273>/\255\14U\11<\12\274B\77\266QF\31\5=\10\35Jgh\70\4>\13\274B'\243\214"
"\62\212m\0\77\12=Bo\345\66\312t\4@\13=Boe\271\222\225\341\2A\13=B\67\247Z"
"\217\221u\0B\14=Bg\304*\246Y\305\241\0C\14=Boe\215\62\312(\247\5D\15=B"
"g\304*\246\230b\212C\1E\14=B\347F\31\215\224QFCF\15=B\347F\31\215\224QF"
"\31\1G\14=Boe\215\62\212;-\0H\11=B'\333cd;I\10\273Bg\305\256\1J"
"\14=Bwg\224QFe\224\0K\13=B'\313T\352\24\253\34L\16=B'\243\214\62\312("
"\243\214\206\0M\12=B'\353\265\222\266\3N\12=B'\353\251\222\334:O\11=Boe\357\264"
"\0P\15=Bg\244\254\207\312(\243\214\0Q\12E>oe\257j\303\0R\13=Bg\244\254\207"
"*\253\34S\13=Boe\15\67\324i\1T\16=Bg\310\214\62\312(\243\214\42\0U\10=B"
"'\373N\13V\13=B'\333\251L\61\345\10W\12=B'\273\222Jw\0X\12=B'\353T"
"W\265\16Y\14=B'\353Tg\224QF\21Z\12=Bgh\224\273\321\20[\10\273Bg\304\316"
"\1\134\15=B'\243\14\63\314\60\303\214\2]\10\273Bgv\216\0^\7\35R\67\247:_\6\15"
">g\10`\6\22['\6a\12-Bo\303\64t\32\1b\14=B'\243\214\222\251\247R\0c"
"\12-Boe\215rZ\0d\13=B\37e\224\314-\225\12e\12-Bo\345\61\62\134\0f\14"
"=BWVy\304\214\62\312\0g\14=:oh\235FF:-\0h\13=B'\243\214\222\251\355"
"\0i\10\273B/#\331\32j\13\314:\77c]K\231\24\0k\14=B'\243\214\262L\263\312\1"
"l\7\273BG\366\32m\12-BG\265TRI\7n\10-B'\231\332\16o\11-Boe;"
"-\0p\14=:'\231z*\225QF\0q\13=:\317\334R\251\214\62\12r\13-B'\231\32"
"e\224\21\0s\12-Boe\270\341P\0t\15=B/\243<bF\31\305\250\0u\10-B'"
";\225\12v\12-B'\353T\246\34\1w\11-B'[Iu\1x\11-B'\247\272\252\3y"
"\13=:'\233Je\244\323\2z\10-Bg\350\366\20{\13\274BWe\224\64\212\31\11|\6\71"
"C\347\10}\14\274BG\243\230\221\312(I\0~\11\35R/\252$\23\0\240\5\0b\7\241\7\71"
"C'\15\1\242\14=>\67\17\25SLy\304\10\243\13=BWVyg\24\225\2\244\12-B'"
"\247\231\342\312\1\245\15E>'\353T\307!\63\312(\2\246\6\71Cg\15\247\13E>oe\64;"
"\67J\13\250\6\213^'\5\251\14=Boe\225\246J:-\0\252\12\264FoD\245j\64\2\253"
"\13.B\267\212)\346\230c\0\254\7\224Jg\344\0\255\6\214Ng\4\256\13=Bo\345\221\346\324"
"i\1\257\6\15^g\10\260\6\233R\257\13\261\13\65B\67\243\70dFq\10\262\10\254NO\305\346"
"\10\263\12\254Ng\243\244\321H\0\264\6\22[O\1\265\12\65>'\333S\251\214\0\266\16=Bo"
"\214\64RR\61\305\24S\0\267\5\11O'\270\6\22;O\1\271\7\253N/\311j\272\12\264FO"
"E\231\64\34\1\273\14.B'\346\230c\212)F\0\274\20N>/#\15\63\314hf\244S\34\31"
"\6\275\20N>/#\15\63\314heT\303\214\62\32\276\16M>G\303\234a\224Y\246\64\62\12\277"
"\12=B\67\323\31\345\326\2\300\14EB/\303\274\262\36#\353\0\301\13EB\277^Y\217\221u\0"
"\302\14EB\67\247\270\262\36#\353\0\303\14EB/*\271\262\36#\353\0\304\13EB\257\246V\326"
"cd\35\305\14EB\67\247\270\262\36#\353\0\306\13>Bw\244\262\71Fl\16\307\15M:oe"
"\215\62\312(\247]\3\310\16EB/\217\215\62\32)\243\214\206\0\311\16EB\77\215\215\62\32)\243"
"\214\206\0\312\16EB\67\216\215\62\32)\243\214\206\0\313\16EB\257\32\33e\64RF\31\15\1\314"
"\11\303B'\247\25[\3\315\11\303B\67\245\25[\3\316\11\303B\257\32)\266\6\317\11\303B'\345"
"\25[\3\320\15=Bg\304*\216T\246\70\24\0\321\13EB\67uO\225\344\326\1\322\13EB/"
"\303\274\262;-\0\323\12EB\277^\331\235\26\0\324\13EB\67\247\270\262;-\0\325\12EB\67"
"\65Wv\247\5\326\12EB\257\246Vv\247\5\327\11-B'\247\272\252\3\330\13=Bo\305+\315"
"\231\26\0\331\12EB/\303\332;-\0\332\11EB\277\314\336i\1\333\13EB\67\247\214\263;-"
"\0\334\12EB\257\306\331;-\0\335\14EB\277\314:\325\31e\24\1\336\16=B'\243\221\362P"
"\31e\224\21\0\337\13=Boe\231\312*+\5\340\14EB/\303Ln\230\206N#\341\13EB"
"\277&\67LC\247\21\342\14EB\67\247Lm\230\206N#\343\14EB\67\265\251\15\323\320i\4\344"
"\13=B\257\246\66LC\247\21\345\14EB\67\247\234\67LC\247\21\346\13.BodT\215\231\207"
"\0\347\13=:oe\215r\332\65\0\350\14EB/\303L\256<F\206\13\351\13EB\277&W\36"
"#\303\5\352\14EB\67\247L\255<F\206\13\353\13=B\257\246V\36#\303\5\354\11\303B'g"
"$[\3\355\10\303B\257\206\262\65\356\10\303B\257-[\3\357\10\273B'e\331\32\360\13=BG"
"C\271\262\235\26\0\361\12EB\67\265q\62\265\35\362\13EB/\303L\256l\247\5\363\12EB\277"
"&W\266\323\2\364\13EB\67\247L\255l\247\5\365\13EB\67\265\251\225\355\264\0\366\12=B\257"
"\246V\266\323\2\367\11-F\67SCS\21\370\12-Bo\310\225\346P\0\371\13EB/\303Le"
"\247R\1\372\12EB\277\246\262S\251\0\373\13EB\67\247\214\263S\251\0\374\12=B\257\306\331\251"
"T\0\375\14M:\277\314\246R\31\351\264\0\376\15E:'\243\221\262=TF\31\1\377\15M:\257"
"\306\331T*#\235\26\0\0\0\0\4\377\377\0";
const uint8_t _9x15_tf[2606] =
"\277\0\3\2\4\4\4\5\5\11\17\0\375\12\375\13\377\1\223\3*\12\21 \5\0\310\63!\10\261\14"
"\63\16\221\0\42\10\64{\63\42S\0#\16\206\31s\242\226a\211Z\206%j\1$\24\267\371\362\302"
"A\211\42)L\322\65\11#\251\62\210\31\0%\21\247\11sB%JJ\255q\32\265\224\22\61\1&"
"\22\247\11s\304(\213\262(T\65)\251E\225H\13'\6\61|\63\6(\14\303\373\262\222(\211z"
"\213\262\0)\14\303\373\62\262(\213z\211\222\10*\15w\71\363J\225\266-i\252e\0+\13w\31"
"\363\342\332\60dq\15,\11R\334\62\206$Q\0-\7\27I\63\16\1.\7\42\14\63\206\0/\14"
"\247\11\263\323\70-\247\345\64\6\60\15\247\11\263\266J\352k\222e\23\0\61\15\247\11\363R\61\311\242"
"\270\267a\10\62\14\247\11s\6%U\373y\30\2\63\16\247\11\63\16qZ\335\343XM\6\5\64\21"
"\247\11sS\61\311\242Z\22&\303\220\306\25\0\65\17\247\11\63\16reH\304\270\254&\203\2\66\20"
"\247\11\263\206(\215+C\42\252\326dP\0\67\16\247\11\63\16q\32\247q\32\247q\10\70\21\247\11"
"\263\266J\232d\331VI\325$\313&\0\71\17\247\11s\6%uT\206$\256FC\4:\10r\14"
"\63\206x\10;\12\242\334\62\206xH\22\5<\11\245\12\63\263\216i\7=\12G)\63\16\71>\14"
"\1>\12\245\12\63\322\216YG\0\77\16\247\11s\6%U\343\264\71\207\63\0@\21\247\11s\6%"
"\65\15J\246D\223\42\347\203\2A\15\247\11\363\322$\253\244\326\341j\15B\22\247\11\63\6)LR"
"\61\31\244\60I\215\311 \1C\15\247\11s\6%\225\373\232\14\12\0D\15\247\11\63\6)LR\77"
"&\203\4E\15\247\11\63\16ry\220\342\346a\10F\14\247\11\63\16ry\220\342\316\0G\17\247\11"
"s\6%\225\333\206\324\232\14\12\0H\12\247\11\63R\327\341\352\65I\12\245\12\63\6)\354\247AJ"
"\15\250\11\363\6\65\357S\230\15\31\0K\21\247\11\63R\61\311\242\332\230\204QV\12\223\64L\12\247"
"\11\63\342\376<\14\1M\20\247\11\63Ru[*JE\212\244H\265\6N\17\247\11\63RuT\62"
")\322\22q\265\6O\14\247\11s\6%\365\327dP\0P\15\247\11\63.\251u\30\222\270\63\0Q"
"\16\307\351r\6%\365K&U\6\65\27R\20\247\11\63.\251u\30\222(+\205I\252\6S\16\247"
"\11s\6%\265\357V\65\31\24\0T\12\247\11\63\16Y\334\337\0U\13\247\11\63R\377\232\14\12\0"
"V\21\247\11\63Rk\222EY\224U\302$L\322\14W\20\247\11\63RO\221\24I\221\24)\335\22"
"\0X\20\247\11\63R\65\311*i\234&Y%U\3Y\15\247\11\63R\65\311*i\334\33\0Z\14"
"\247\11\63\16q\332\347x\30\2[\12\304\373\62\6\255\177\33\2\134\13\247\11\63\362\70/\347\345<]"
"\12\304\372\62\206\254\177\33\4^\12Gi\363\322$\253\244\1_\7\30\370\62\16\2`\7\63\213\63\262"
"\2a\16w\11s\6=N\206!\25\225!\11b\17\247\11\63\342\226!\21U\353\250\14\11\0c\14"
"w\11s\6%\225[\223A\1d\15\247\11\263[\206D\134\35\225!\11e\15w\11s\6%U\207"
"s>(\0f\16\247\11\363\266R\26\305\341 \306\215\0g\23\247\331r\206DL\302$\214\206(\37"
"\224TM\6\5h\14\247\11\63\342\226!\21U\257\1i\12\245\12stt\354i\20j\15\326\331\62"
"u\312\332U\64&C\2k\17\247\11\63\342VMI\64\65\321\62%\15l\11\245\12\63\306\376\64\10"
"m\20w\11\63\26%\212\244H\212\244H\212\324\0n\13w\11\63\222!\21U\257\1o\14w\11s"
"\6%\365\232\14\12\0p\17\247\331\62\222!\21U\353\250\14I\134\6q\15\247\331r\206D\134\35\225"
"!\211\33r\14w\11\63\242IK\302$n\5s\15w\11s\6%\325\7]M\6\5t\14\227\11"
"\263\342p\330\342n\331\2u\20w\11\63\302$L\302$L\302$\214\206$v\16w\11\63R\65\311"
"\242\254\22&i\6w\16w\11\63RS$ER\244tK\0x\15w\11\63\322$\253\244\225\254\222"
"\6y\15\246\331\62B\337\224%\25\223!\1z\12w\11\63\16i\257\303\20{\15\305\373\262\226\260\32"
"ij\26V\7|\6\301\374\62>}\16\305\371\62\326\260\226jR\32V&\0~\12\67ys\64)"
"\322\24\0\240\5\0\310\63\241\10\261\14\63\244a\10\242\21\206\11\63\243!\211\22\251\222%Q\62D!"
"\0\243\21\247\11\363\266R\34\16b\234\212I\226$\13\0\244\16g\71\63\322d\220\262(\213\6%\15"
"\245\20\247\11\63R\65\311*\331 \206\203\30\327\0\246\10\261\374\62\6e\20\247\16\264\372r\224HT"
"\42S\42J\211\2\250\7%\232\63\62-\251\25\230\30\263\206,L\42K\224(\241\22%\222\224\204\331"
"\20\1\252\14u\71s\244\322\22EC:\10\253\15\207\31\363\242~\213\302(\214\302(\254\7F)\63"
"\256\15\255\7\25J\63\6\1\256\25\230\30\263\206,L\222I\211\22eRJJ\224\24\263!\2\257\6"
"\26\231\63\16\260\12Dks\224HJ\24\0\261\16\227\31\363\342\332\60dq\35\33\206\0\262\13dI"
"s\224(K\224l\10\263\13dIs\224\250(%\12\0\264\10\63\213\263\222\22\0\265\14\227\351\62R"
"\257\333\262\310\61\0\266\26\247\11s\206!K\264DK\222!\11\223\60\11\223\60\11\223\0\267\7\42L"
"\63\206\0\270\10\64\332\262\246D\1\271\10cIs\22\251e\272\12eIs\226LK\346A\273\16\207"
"\31\63\242\60\12\243\60\312\242~\3\274\17\247\11sR\271q\210\304$\213\62%\25\275\16\247\11sR"
"\271I\31\242\70\24\343!\276\21\247\11s\304(\315\263\250\42\211I\26eJ*\277\15\247\11\363r\270"
"\332\234\252\311\240\0\300\16\307\11s\362:\272URu\270Z\3\301\15\307\11s\333\321\255\222\252\303\325"
"\32\302\17\307\11\363\322$\253c[%U\207\253\65\303\17\267\11s\64i\307\266J\252\16Wk\0\304"
"\17\267\11s\262(\313\261\255\222\252\303\325\32\305\17\267\11\263\266\332\230d\225T\35\256\326\0\306\25\247"
"\11s\224!\312\242,\312\242lX\242,\312\242,\32\2\307\20\327\331r\6%\225\373\232\14\242\26\205"
"\32\0\310\21\307\11s\362:\66\14I\34\17Y\134\35\206\0\311\20\307\11s\333\261aH\342x\310\342"
"\352\60\4\312\22\307\11\363\322$\253#\303\220\304\361\220\305\325a\10\313\22\267\11s\262(\313\221aH"
"\342x\310\342\352\60\4\314\14\305\12\63\322\372 \205=\15\2\315\14\305\12\63\263\372 \205=\15\2\316"
"\15\305\12\263\262\244\226\16R\330\323 \317\14\265\12\63\62-\35\244\260\247A\320\25\250\10s\6-\214"
"\322$\35\302$M\322$M\302h\220\0\321\22\267\11s\64iG\322Q\311\244H\212\264D\134\3\322"
"\16\307\11s\362:\70(\251\257\311\240\0\323\15\307\11s\333\301AI}M\6\5\324\20\307\11\363\322"
"$\253C\203\222\372\232\14\12\0\325\17\267\11s\64i\207\6%\365\65\31\24\0\326\17\267\11s\262("
"\313\241AI}M\6\5\327\15w\31\63\322$\253\244\225\254\222\6\330\26\307\371\262\223A\11\267DK"
"\244H\212\224L\311\306dPb\0\331\15\307\11s\362:\226\372\65\31\24\0\332\14\307\11s\333\261\324"
"\257\311\240\0\333\16\307\11\363\322$\253#\251_\223A\1\334\16\267\11s\262(\313\221\324\257\311\240\0"
"\335\16\307\11s\333\261TM\262J\32\267\1\336\16\247\11\63\342xXR\353\60$q\31\337\24\246\11"
"\263\246,\311\222(Q\262\250\226dI\226$\12\0\340\21\267\11\263\362:\66\350q\62\14\251\250\14I"
"\0\341\20\267\11s\333\301A\217\223aHEeH\2\342\22\267\11\363\322$\253C\203\36'\303\220\212"
"\312\220\4\343\22\247\11\263\244$\322\241A\217\223aHEeH\2\344\22\247\11s\262(\313\241A\217"
"\223aHEeH\2\345\22\267\11\363\304(\324\261A\217\223aHEeH\2\346\17w\11s,Q"
"-J\6%\312\242\212\62\347\17\247\331r\6%\225[\223A\324\242P\3\350\17\267\11s\362:\70("
"\251:\234\363A\1\351\17\267\11s\333\301AI\325\341\234\17\12\0\352\21\267\11\363\322$\253C\203\222"
"\252\303\71\37\24\0\353\21\247\11s\262(\313\241AI\325\341\234\17\12\0\354\12\265\12\63\322\372\330\323"
" \355\12\265\12\363\332\221\261\247A\356\15\266\11\263\302$\312rd\355m\20\357\14\245\12\63\242$\212"
"\307\236\6\1\360\20\267\11s\242PL\362lPR\257\311\240\0\361\16\247\11s\64iG\222!\21U"
"\257\1\362\16\267\11s\362:\70(\251\327dP\0\363\15\267\11s\333\301AI\275&\203\2\364\17\267"
"\11\363\322$\253C\203\222zM\6\5\365\17\247\11s\64i\207\6%\365\232\14\12\0\366\17\247\11s"
"\262(\313\241AI\275&\203\2\367\16\227\11\363\322\65\307\206!\307\322\65\3\370\23\227\371\262\223A\311"
"\22-\221\42%S\262dPb\0\371\23\267\11s\362:\26&a\22&a\22&a\64$\1\372\22"
"\267\11s\333\261\60\11\223\60\11\223\60\11\243!\11\373\24\267\11\363\322$\253#a\22&a\22&a"
"\22FC\22\374\24\247\11s\242,\312\241\60\11\223\60\11\223\60\11\243!\11\375\17\346\331\62\333\241\320"
"\67eI\305dH\0\376\20\307\331\62\342\226!\21UuT\206$.\3\377\17\326\331r\242\366\320\67"
"eI\305dH\0\0\0\0\4\377\377\0";
} // namespace roro::fontdata
+6
View File
@@ -8,6 +8,9 @@ namespace fontdata {
extern const uint8_t _6x13_tf[];
extern const uint8_t _6x13B_tf[];
extern const uint8_t _5x8_tf[];
extern const uint8_t _4x6_tf[];
extern const uint8_t _6x10_tf[];
extern const uint8_t _9x15_tf[];
} // namespace fontdata
// Latin-1 bitmap fonts (accented letters included).
@@ -15,6 +18,9 @@ namespace fonts {
inline const lgfx::U8g2font body(fontdata::_6x13_tf); // 6x13
inline const lgfx::U8g2font bold(fontdata::_6x13B_tf); // 6x13 bold
inline const lgfx::U8g2font small(fontdata::_5x8_tf); // 5x8, Status Bar
inline const lgfx::U8g2font tiny(fontdata::_4x6_tf); // 4x6: the terminal's smallest (issue #2)
inline const lgfx::U8g2font medium(fontdata::_6x10_tf); // 6x10
inline const lgfx::U8g2font large(fontdata::_9x15_tf); // 9x15
} // namespace fonts
} // namespace roro
+1
View File
@@ -53,6 +53,7 @@ void statusBar(Canvas& c, const StatusInfo& info) {
case StatusInfo::Vpn::Up: right("VPN", kAccent); break;
case StatusInfo::Vpn::Off: break;
}
if (info.ssh) right("SSH", kAccent); // Q260: a session goes on, whatever App is in front
switch (info.debug) { // Q190: there while the console listens, bright with someone connected
case StatusInfo::Debug::On: right("DBG", kMuted); break;
case StatusInfo::Debug::Client: right("DBG", kAccent); break;
+2 -1
View File
@@ -32,12 +32,13 @@ struct StatusInfo {
bool capturing = false; // a LoRa Capture is recording (Q97)
enum class Debug { Off, On, Client } debug = Debug::Off; // the Debug Console listens (ADR 0010, Q190)
enum class Vpn { Off, Trying, Up } vpn = Vpn::Off; // the WireGuard tunnel (issue #8, Q252)
bool ssh = false; // an SSH session is open (issue #2, Q260)
bool operator==(const StatusInfo& o) const {
return title == o.title && batteryPercent == o.batteryPercent && clock == o.clock &&
sdPresent == o.sdPresent && sdLevel == o.sdLevel && compose == o.compose && wifi == o.wifi &&
wifiBars == o.wifiBars && unread == o.unread && gnss == o.gnss && gnssSatellites == o.gnssSatellites &&
tracking == o.tracking && radio == o.radio && capturing == o.capturing && debug == o.debug && vpn == o.vpn;
tracking == o.tracking && radio == o.radio && capturing == o.capturing && debug == o.debug && vpn == o.vpn && ssh == o.ssh;
}
bool operator!=(const StatusInfo& o) const { return !(*this == o); }
};