Files
roro9stack/src/apps/system_app.cpp
T
twislaandClaude Opus 5.5 34e6714785
CI / build (pull_request) Successful in 7m14s
Site / build (pull_request) Successful in 8s
Keys: one table file for the device's help panel and the website's key tables (#72)
Every screen's keys are constant tables in lib/core/src/app_keys.h (52 of
them, each under an `// id: Title` comment). An App's help() picks the table
of the state it is in. site/tools/gen_dev_docs.py reads the same file and
writes site/data/keys.toml; the `keys` shortcode shows a screen's tables on
its guide page, and /guide/keys/ shows all of them.

The Site job fails when the data file is out of date or a page asks for a
table that doesn't exist, and now also runs when app_keys.h changes. A key
added to an App shows up on the website without anyone editing a page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-07 01:42:48 +02:00

376 lines
15 KiB
C++

#include "app_keys.h"
#include "system_app.h"
#include <Arduino.h>
#include <SD.h>
#include <esp_heap_caps.h>
#include <algorithm>
#include <cstdio>
#include "cleanup_plan.h"
#include "platform/system_info.h"
#include "sd_fault.h"
#include "services/gemini_service.h"
#include "ui/canvas.h"
#include "ui/fonts.h"
#include "ui/theme.h"
#include "ui/widgets.h"
namespace roro {
namespace {
constexpr size_t kUsers = static_cast<size_t>(net::User::Count);
// Collects what the console's `info` prints, so the System view can't drift from it (Q125).
class Lines : public Print {
public:
size_t write(uint8_t ch) override {
if (ch == '\n') {
lines.push_back(current_);
current_.clear();
} else if (ch != '\r') {
current_ += static_cast<char>(ch);
}
return 1;
}
std::vector<std::string> lines;
private:
std::string current_;
};
std::string duration(uint32_t seconds) {
char s[24];
if (seconds < 3600) std::snprintf(s, sizeof s, "%lu min %02lu s", (unsigned long)(seconds / 60), (unsigned long)(seconds % 60));
else std::snprintf(s, sizeof s, "%lu h %02lu min", (unsigned long)(seconds / 3600), (unsigned long)(seconds / 60 % 60));
return s;
}
// A filled bar for a percentage; the warning colour from 90 %.
void bar(Canvas& c, int x, int y, int w, int percent) {
c.drawRect(x, y, w, 9, theme::kMuted);
int filled = std::clamp(percent, 0, 100) * (w - 2) / 100;
if (filled > 0) c.fillRect(x + 1, y + 1, filled, 7, percent >= 90 ? theme::kWarning : theme::kAccent);
}
} // namespace
void SystemApp::onEnter() {
view_ = View::Overview;
beforeTotal_ = system_info::sampleTasks(before_);
sampledMs_ = millis();
for (size_t i = 0; i < kUsers; i++) traffic_[i] = net::traffic(static_cast<net::User>(i));
requestRedraw();
}
void SystemApp::onExit() { // nothing is kept while it's closed (Q120)
std::vector<TaskSample>().swap(before_);
std::vector<TaskRow>().swap(rows_);
std::vector<std::string>().swap(systemLines_);
for (auto& h : loadHistory_) h.clear();
heapHistory_.clear();
load_[0] = load_[1] = -1;
}
void SystemApp::help(std::vector<KeyHelp>& out) const {
if (view_ == View::Tasks) keys::add(out, keys::kSystemTasks);
else if (view_ == View::System) keys::add(out, keys::kSystemSystem);
else keys::add(out, keys::kSystem);
}
const char* SystemApp::helpTitle() const {
switch (view_) {
case View::Tasks: return "System: tasks";
case View::Memory: return "System: memory";
case View::Network: return "System: network";
case View::System: return "System: system";
default: return "System: overview";
}
}
bool SystemApp::onKey(const KeyEvent& e) {
if (e.key == Key::Tab) {
int n = static_cast<int>(View::Count), v = static_cast<int>(view_);
view_ = static_cast<View>(e.shift ? (v + n - 1) % n : (v + 1) % n);
taskTop_ = systemTop_ = 0;
requestRedraw();
return true;
}
int step = e.key == Key::Up ? -1 : e.key == Key::Down ? 1 : 0;
if (step && view_ == View::Tasks) {
taskTop_ = std::clamp(taskTop_ + step, 0, std::max(0, static_cast<int>(rows_.size()) - kTaskRows));
requestRedraw();
return true;
}
if (step && view_ == View::System) {
systemTop_ = std::clamp(systemTop_ + step, 0, std::max(0, wrappedLines_ - 8));
requestRedraw();
return true;
}
if (view_ == View::Tasks && e.key == Key::Char && (e.ch == 's' || e.ch == 'S')) { // Q122
sort_ = static_cast<TaskSort>((static_cast<int>(sort_) + 1) % 3);
sortTasks(rows_, sort_);
taskTop_ = 0;
requestRedraw();
return true;
}
return false; // Back leaves the App
}
void SystemApp::update(uint32_t nowMs) {
if (nowMs - sampledMs_ >= 1000) sample(nowMs);
}
// Once a second (Q119): the tasks' shares over that second, the cores' load, the heap, the traffic.
void SystemApp::sample(uint32_t nowMs) {
uint32_t elapsedMs = nowMs - sampledMs_;
sampledMs_ = nowMs;
std::vector<TaskSample> now;
uint32_t total = system_info::sampleTasks(now);
rows_ = taskRows(before_, beforeTotal_, now, total);
sortTasks(rows_, sort_);
before_.swap(now);
beforeTotal_ = total;
for (int core = 0; core < 2; core++) {
load_[core] = coreLoad(rows_, core);
loadHistory_[core].push(static_cast<uint8_t>(std::max(0, load_[core])));
}
heapHistory_.push(static_cast<uint16_t>(ESP.getFreeHeap() / 1024));
for (size_t i = 0; i < kUsers; i++) {
net::Traffic t = net::traffic(static_cast<net::User>(i));
rateIn_[i] = net::bytesPerSecond(traffic_[i].in, t.in, elapsedMs);
rateOut_[i] = net::bytesPerSecond(traffic_[i].out, t.out, elapsedMs);
traffic_[i] = t;
}
if (view_ == View::System) { // the slow things: only while they're on screen
Lines out;
system_info::printSystem(out);
const BatteryEstimator& b = battery_.estimator();
if (b.hasReading()) out.printf("battery: %d %%, %d.%02d V\n", b.percent(), b.millivolts() / 1000, b.millivolts() % 1000 / 10);
StorageState card = storage_.state();
if (card.present)
out.printf("sd: %s of %s used, %u write faults\n", formatBytes(card.usedBytes).c_str(), formatBytes(card.totalBytes).c_str(),
(unsigned)sdLastFault().count);
else out.println("sd: no card");
out.printf("radio: %s\n", !radio_.present() ? "none" : radio_.sweeping() ? "sweeping" : radio_.listening() ? "listening" : "asleep");
const auto& g = gnss_.state();
out.printf("gnss: %s, %d satellites used\n", !gnss_.on() ? "off" : gnss_.receiving(nowMs) ? "receiving" : "silent", g.satellitesUsed);
system_info::printSlots(out, store_);
systemLines_.swap(out.lines);
}
requestRedraw();
}
void SystemApp::footer(Canvas& c, const char* keys) {
const auto& area = theme::kContent;
c.setFont(&fonts::small);
c.setTextColor(theme::kMuted);
c.setTextDatum(top_left);
c.drawString(keys, 4, area.y + area.h - 9);
}
void SystemApp::draw(Canvas& c) {
c.setTextDatum(top_left);
switch (view_) {
case View::Overview: drawOverview(c); break;
case View::Tasks: drawTasks(c); break;
case View::Memory: drawMemory(c); break;
case View::Network: drawNetwork(c); break;
case View::System: drawSystem(c); break;
default: break;
}
}
void SystemApp::drawOverview(Canvas& c) {
const auto& area = theme::kContent;
char line[72];
c.setFont(&fonts::body);
int y = area.y + 2;
for (int core = 0; core < 2; core++) {
c.setTextColor(theme::kMuted);
std::snprintf(line, sizeof line, "Core %d", core);
c.drawString(line, 4, y);
bar(c, 52, y + 2, 130, load_[core]);
c.setTextColor(theme::kText);
if (load_[core] >= 0) std::snprintf(line, sizeof line, "%d%%", load_[core]);
else std::snprintf(line, sizeof line, "...");
c.drawString(line, 190, y);
y += theme::kLineHeight;
}
auto row = [&](const char* label, const std::string& value) {
c.setTextColor(theme::kMuted);
c.drawString(label, 4, y);
c.setTextColor(theme::kText);
c.drawString(value.c_str(), 62, y);
y += theme::kLineHeight;
};
std::snprintf(line, sizeof line, "%u KB free, lowest %u", (unsigned)(ESP.getFreeHeap() / 1024), (unsigned)(ESP.getMinFreeHeap() / 1024));
row("Memory", line);
uint32_t in = 0, out = 0;
for (size_t i = 0; i < kUsers; i++) in += rateIn_[i], out += rateOut_[i];
if (wifi_.state() == WifiController::State::Connected)
std::snprintf(line, sizeof line, "%s/s in, %s/s out", net::formatTraffic(in).c_str(), net::formatTraffic(out).c_str());
else std::snprintf(line, sizeof line, "not connected");
row("Network", line);
const BatteryEstimator& b = battery_.estimator();
if (b.hasReading()) std::snprintf(line, sizeof line, "%d%%, %d.%02d V", b.percent(), b.millivolts() / 1000, b.millivolts() % 1000 / 10);
else std::snprintf(line, sizeof line, "...");
row("Battery", line);
std::snprintf(line, sizeof line, "%s, %.0f C", duration(millis() / 1000).c_str(), temperatureRead());
row("Uptime", line);
// Both cores over the last two minutes: core 0 in the accent colour, core 1 in green.
int gx = 4, gy = y + 3, gw = static_cast<int>(kHistory) * 2 - 8, gh = area.y + area.h - 12 - gy;
if (gh > 8) {
c.drawRect(gx, gy, gw + 2, gh + 2, theme::kMuted);
for (int core = 0; core < 2; core++) {
const auto& h = loadHistory_[core];
for (size_t i = 1; i < h.size(); i++) {
int x0 = gx + 1 + static_cast<int>((i - 1) * gw / kHistory), x1 = gx + 1 + static_cast<int>(i * gw / kHistory);
c.drawLine(x0, gy + gh - h.at(i - 1) * gh / 100, x1, gy + gh - h.at(i) * gh / 100, core ? theme::kMessage : theme::kAccent);
}
}
}
}
void SystemApp::drawTasks(Canvas& c) {
const auto& area = theme::kContent;
c.setFont(&fonts::small);
c.setTextColor(theme::kMuted);
c.drawString("task", 4, area.y + 2);
c.setTextDatum(top_right);
c.drawString("core", 128, area.y + 2);
c.drawString("cpu", 176, area.y + 2);
c.drawString("stack left", area.w - 4, area.y + 2);
c.setFont(&fonts::body);
char cell[16];
for (int i = 0; i < kTaskRows && taskTop_ + i < static_cast<int>(rows_.size()); i++) {
const TaskRow& r = rows_[taskTop_ + i];
int y = area.y + 12 + i * theme::kLineHeight;
c.setTextDatum(top_left);
c.setTextColor(r.idle ? theme::kMuted : theme::kText);
c.drawString(r.name.c_str(), 4, y);
c.setTextDatum(top_right);
c.setTextColor(theme::kMuted);
c.drawString(r.core < 0 ? "any" : std::to_string(r.core).c_str(), 128, y);
c.setTextColor(r.idle ? theme::kMuted : theme::kText);
std::snprintf(cell, sizeof cell, "%u.%u%%", r.permille / 10, r.permille % 10);
c.drawString(cell, 176, y);
c.setTextColor(r.lowStack ? theme::kWarning : theme::kMuted); // Q122: under 512 bytes
c.drawString(std::to_string(r.stackFree).c_str(), area.w - 4, y);
}
c.setTextDatum(top_left);
std::string keys = std::to_string(rows_.size()) + " tasks, by " + (sort_ == TaskSort::Share ? "cpu" : sort_ == TaskSort::Stack ? "stack" : "name");
footer(c, keys.c_str());
}
void SystemApp::drawMemory(Canvas& c) {
const auto& area = theme::kContent;
char line[72];
c.setFont(&fonts::body);
c.setTextColor(theme::kText);
std::snprintf(line, sizeof line, "%.1f KB free, lowest %.1f", ESP.getFreeHeap() / 1024.0, ESP.getMinFreeHeap() / 1024.0);
c.drawString(line, 4, area.y + 2);
c.setTextColor(theme::kMuted);
std::snprintf(line, sizeof line, "largest free block %.1f KB", heap_caps_get_largest_free_block(MALLOC_CAP_8BIT) / 1024.0);
c.drawString(line, 4, area.y + 2 + theme::kLineHeight);
// Free heap over two minutes, with the floors of Q86 as lines.
int gx = 26, gy = area.y + 32, gw = area.w - gx - 6, gh = area.y + area.h - 12 - gy;
uint16_t top = 128; // KB at the top of the scale: more if the heap has been higher
for (size_t i = 0; i < heapHistory_.size(); i++) top = std::max<uint16_t>(top, heapHistory_.at(i) + 8);
auto yOf = [&](int kb) { return gy + gh - std::clamp(kb, 0, static_cast<int>(top)) * gh / top; };
c.drawRect(gx, gy, gw, gh + 1, theme::kMuted);
c.setFont(&fonts::small);
struct {
size_t bytes;
uint16_t color;
} floors[] = {{GeminiService::kStartFloor, theme::kMuted}, {GeminiService::kSteadyFloor, theme::kWarning}, {GeminiService::kTransientFloor, theme::kMessage}};
for (auto& f : floors) {
int kb = static_cast<int>(f.bytes / 1024), y = yOf(kb);
for (int x = gx + 1; x < gx + gw - 1; x += 4) c.drawPixel(x, y, f.color);
c.setTextColor(f.color);
c.setTextDatum(top_right);
c.drawString(std::to_string(kb).c_str(), gx - 3, y - 3);
}
c.setTextDatum(top_left);
for (size_t i = 1; i < heapHistory_.size(); i++) {
int x0 = gx + 1 + static_cast<int>((i - 1) * (gw - 2) / kHistory), x1 = gx + 1 + static_cast<int>(i * (gw - 2) / kHistory);
c.drawLine(x0, yOf(heapHistory_.at(i - 1)), x1, yOf(heapHistory_.at(i)), theme::kAccent);
}
footer(c, "KB free, 2 min, with the floors");
}
void SystemApp::drawNetwork(Canvas& c) {
const auto& area = theme::kContent;
char line[72];
c.setFont(&fonts::body);
WifiService::Connection n = wifi_.connection();
c.setTextColor(n.connected ? theme::kText : theme::kWarning);
if (n.connected) std::snprintf(line, sizeof line, "%s, %d dBm", wifi_.ssid().c_str(), wifi_.rssi());
else std::snprintf(line, sizeof line, "Wi-Fi isn't connected");
c.drawString(line, 4, area.y + 2);
if (n.connected) {
c.setTextColor(theme::kMuted);
std::snprintf(line, sizeof line, "%s/%d %s, gw %s", n.address.c_str(), n.prefix, n.fixed ? "fixed" : "DHCP",
n.gateway.empty() ? "none" : n.gateway.c_str());
c.drawString(line, 4, area.y + 2 + theme::kLineHeight);
}
// Per service (Q121): totals since boot, and the last second.
int y = area.y + 32;
c.setFont(&fonts::small);
c.setTextColor(theme::kMuted);
c.drawString("service", 4, y);
c.setTextDatum(top_right);
c.drawString("in", 124, y);
c.drawString("out", 168, y);
c.drawString("now, /s", area.w - 4, y);
c.setFont(&fonts::body);
y += 10;
for (size_t i = 0; i < kUsers; i++) {
bool active = rateIn_[i] || rateOut_[i];
c.setTextDatum(top_left);
c.setTextColor(active ? theme::kMessage : theme::kText);
c.drawString(net::userName(static_cast<net::User>(i)), 4, y);
c.setTextDatum(top_right);
c.setTextColor(theme::kText);
c.drawString(net::formatTraffic(traffic_[i].in).c_str(), 124, y);
c.drawString(net::formatTraffic(traffic_[i].out).c_str(), 168, y);
c.setTextColor(active ? theme::kMessage : theme::kMuted);
c.drawString(active ? net::formatTraffic(rateIn_[i] + rateOut_[i]).c_str() : "-", area.w - 4, y);
y += theme::kLineHeight;
}
c.setTextDatum(top_left);
footer(c, "bytes since boot");
}
void SystemApp::drawSystem(Canvas& c) {
const auto& area = theme::kContent;
if (systemLines_.empty()) {
c.setFont(&fonts::body);
c.setTextColor(theme::kMuted);
c.drawString("Reading...", 4, area.y + 4);
return;
}
// The console's lines are longer than the screen: wrap them, continuation lines indented.
std::vector<std::string> wrapped;
auto measure = widgets::bodyMeasure(c);
for (const std::string& line : systemLines_) {
bool first = true;
for (const std::string& part : wrapText(line, area.w - 14, measure)) {
wrapped.push_back(first ? part : " " + part);
first = false;
}
}
wrappedLines_ = static_cast<int>(wrapped.size());
systemTop_ = std::clamp(systemTop_, 0, std::max(0, wrappedLines_ - 8));
widgets::textLines(c, wrapped, systemTop_, {area.x + 2, area.y + 2, area.w - 2, area.h - 12});
}
} // namespace roro