feat(ui-lvgl): e-ink graphics in colour on the L2

Node detail: a card with a compass rose and the hop chain over the info;
the bar keeps Message / Admin / Add, Ping and Navigate, the rest goes to a
"More" group under the info (favourite toggled in place, delete by a second
tap). Nodes: an arrow by each distance. A trail's height profile in Map
tools and a saved trail's popup. Diagnostics > History: battery, noise,
SNR and traffic charts. Clock > Calendar: today, the weekend and alarm days.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-10-02 23:26:29 +02:00
co-authored by Claude Opus 5.5
parent 99bd41650f
commit 9597e1e2a4
7 changed files with 582 additions and 37 deletions
+155 -5
View File
@@ -1,7 +1,9 @@
#pragma once
// Home > Diagnostics -- ui-new's Tools > Diagnostics. Tabs Live / System /
// Font / Noise; the rows come from ui-core/Diagnostics.h. Live refreshes every second
// and its header button resets the counters (after a confirm).
// Home > Diagnostics -- ui-new's Tools > Diagnostics. Tabs Live / History /
// System / Font / Noise; the rows come from ui-core/Diagnostics.h. Live
// refreshes every second and its header button resets the counters (after a
// confirm). History charts the last 8 hours (ui-core/StatusHistory.h, as on
// e-ink): the battery, the noise floor, the SNR of the last packets, traffic.
//
// Single-TU fragment: included by ui-lvgl/UITask.cpp after DeviceScreen.h.
@@ -11,7 +13,7 @@ extern RADIO_CLASS radio; // the variant's target.cpp (the noise sweep retunes
namespace diagview {
enum : uint8_t { TAB_LIVE, TAB_SYSTEM, TAB_FONT, TAB_NOISE, TAB_COUNT };
enum : uint8_t { TAB_LIVE, TAB_HISTORY, TAB_SYSTEM, TAB_FONT, TAB_NOISE, TAB_COUNT };
static uint8_t s_tab = TAB_LIVE; // kept across visits
static lv_obj_t* s_list = nullptr;
static const int EXTRA = 4; // GPS, power, last reset, last crash (L2 only)
@@ -21,6 +23,118 @@ struct LiveVal { lv_obj_t* val; lv_obj_t* in; lv_obj_t* out; };
static LiveVal s_vals[diag::MAX_ROWS + EXTRA];
static int s_rows = 0;
// The history, a sample every 5 minutes from boot (UITask::sampleHistory()).
static uicore::StatusHistory<96, 300000UL> s_hist;
static uint32_t s_hist_shown = 0; // the history's version the charts show
static lv_obj_t* s_ch_obj[4]; // History's charts and their values now
static lv_obj_t* s_ch_now[4];
static uint32_t totalRecv() {
uint32_t n = 0;
for (uint8_t t = 0; t < 16; t++) n += the_mesh.getNumRecvByType(t);
return n;
}
// One chart's samples as drawn: a copy, so the draw callback needn't know
// which ring (of which size) they came from.
struct Chart { int16_t v[96]; int n, cap, min_span; bool bars; };
static Chart s_charts[4];
template <int N>
static Chart* chartOf(int k, const uicore::History<N>& h, int min_span, bool bars) {
Chart& c = s_charts[k];
c.n = h.n; c.cap = N; c.min_span = min_span; c.bars = bars;
for (int i = 0; i < h.n; i++) c.v[i] = h.at(i);
return &c;
}
// A history in its box, newest at the right: a line over a faint fill, or
// bars from the bottom, scaled between the samples' own min and max (at
// least min_span apart, so a steady reading stays flat).
static void onChartDraw(lv_event_t* e) {
lv_obj_t* o = (lv_obj_t*)lv_event_get_target(e);
const Chart& c = *(const Chart*)lv_event_get_user_data(e);
lv_layer_t* layer = lv_event_get_layer(e);
lv_area_t a;
lv_obj_get_coords(o, &a);
const int x0 = a.x1 + 2, w = a.x2 - a.x1 - 4, y0 = a.y1 + 3, h = a.y2 - a.y1 - 5, base = a.y2 - 1;
lv_draw_line_dsc_t ld;
lv_draw_line_dsc_init(&ld);
if (c.n < (c.bars ? 1 : 2)) { // nothing yet: a dotted baseline
ld.color = lv_color_hex(theme::TEXT_MUTED);
ld.width = 1; ld.dash_width = 2; ld.dash_gap = 4;
ld.p1.x = x0; ld.p2.x = x0 + w; ld.p1.y = ld.p2.y = base - 1;
lv_draw_line(layer, &ld);
return;
}
int lo = c.bars ? 0 : c.v[0], hi = c.v[0];
for (int i = 0; i < c.n; i++) { if (c.v[i] < lo) lo = c.v[i]; if (c.v[i] > hi) hi = c.v[i]; }
if (hi - lo < c.min_span) {
if (c.bars) hi = lo + c.min_span;
else { const int mid = (hi + lo) / 2; lo = mid - c.min_span / 2; hi = lo + c.min_span; }
}
auto X = [&](int i) { return (int32_t)(x0 + w - (c.n - 1 - i) * w / (c.cap - 1)); };
auto Y = [&](int v) { return (int32_t)(y0 + h - (v - lo) * h / (hi - lo)); };
ld.color = lv_color_hex(theme::ACCENT);
if (c.bars) { // a bar a sample, a pixel apart
const int pitch = (w + c.cap / 2) / c.cap, bw = pitch > 2 ? pitch - 1 : 1;
lv_draw_rect_dsc_t rd;
lv_draw_rect_dsc_init(&rd);
rd.bg_color = lv_color_hex(theme::ACCENT);
rd.bg_opa = LV_OPA_70;
for (int i = 0; i < c.n; i++) {
if (c.v[i] <= 0) continue;
lv_area_t r = { X(i) - bw + 1, Y(c.v[i]), X(i), base };
lv_draw_rect(layer, &rd, &r);
}
return;
}
lv_draw_line_dsc_t fill;
lv_draw_line_dsc_init(&fill);
fill.color = lv_color_hex(theme::ACCENT);
fill.opa = LV_OPA_20;
fill.width = 1;
ld.width = 2;
ld.round_start = ld.round_end = 1;
for (int i = 1; i < c.n; i++) {
const int32_t xa = X(i - 1), xb = X(i), ya = Y(c.v[i - 1]), yb = Y(c.v[i]);
for (int32_t x = (i == 1 ? xa : xa + 1); x <= xb; x++) {
fill.p1.x = fill.p2.x = x;
fill.p1.y = xb > xa ? ya + (yb - ya) * (x - xa) / (xb - xa) : yb;
fill.p2.y = base;
lv_draw_line(layer, &fill);
}
ld.p1.x = xa; ld.p1.y = ya; ld.p2.x = xb; ld.p2.y = yb;
lv_draw_line(layer, &ld);
}
}
// A chart card: the title and the value now, the chart, the span under it.
static void chartCard(lv_obj_t* parent, int k, const char* title, const char* from) {
lv_obj_t* card = infoCard(parent);
lv_obj_set_style_pad_ver(card, 6, 0);
lv_obj_set_style_pad_row(card, 2, 0);
lv_obj_t* r = lv_obj_create(card);
lv_obj_remove_style_all(r);
fillWidth(r);
lv_obj_set_height(r, LV_SIZE_CONTENT);
lv_obj_align(label(r, title, THEME_FONT_SMALL, theme::TEXT_MUTED), LV_ALIGN_LEFT_MID, 0, 0);
s_ch_now[k] = label(r, "", THEME_FONT_BODY, theme::TEXT);
lv_obj_align(s_ch_now[k], LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_t* ch = lv_obj_create(card);
lv_obj_remove_style_all(ch);
lv_obj_remove_flag(ch, LV_OBJ_FLAG_CLICKABLE);
fillWidth(ch);
lv_obj_set_height(ch, 48);
lv_obj_add_event_cb(ch, onChartDraw, LV_EVENT_DRAW_MAIN_END, &s_charts[k]);
s_ch_obj[k] = ch;
lv_obj_t* ax = lv_obj_create(card);
lv_obj_remove_style_all(ax);
fillWidth(ax);
lv_obj_set_height(ax, LV_SIZE_CONTENT);
lv_obj_align(label(ax, from, THEME_FONT_SMALL, theme::TEXT_MUTED), LV_ALIGN_LEFT_MID, 0, 0);
lv_obj_align(label(ax, "now", THEME_FONT_SMALL, theme::TEXT_MUTED), LV_ALIGN_RIGHT_MID, 0, 0);
}
static void extraRow(diag::Row* rows, int& n, const char* lbl, const char* fmt, ...) {
rows[n].label = lbl;
va_list ap; va_start(ap, fmt);
@@ -251,7 +365,7 @@ void UITask::buildDiag() {
lv_obj_set_style_pad_row(body, 4, 0);
if (s_tab == TAB_LIVE && _header) headerButton(_header, LV_SYMBOL_REFRESH " Reset", onDiagReset, 4, NULL);
static const char* TABS[] = { "Live", "System", "Font", "Noise", "" };
static const char* TABS[] = { "Live", "History", "System", "Font", "Noise", "" };
lv_obj_t* tabs = segmented(body, TABS, s_tab, lv_pct(100), 34);
lv_obj_set_style_bg_color(tabs, lv_color_hex(theme::SURFACE), LV_PART_ITEMS);
lv_obj_add_event_cb(tabs, onDiagTab, LV_EVENT_VALUE_CHANGED, NULL);
@@ -270,6 +384,14 @@ void UITask::buildDiag() {
}
s_noise_status = nullptr;
if (s_tab == TAB_HISTORY) {
chartCard(s_list, 0, "Battery", "8 h ago");
chartCard(s_list, 1, "Noise floor", "8 h ago");
chartCard(s_list, 2, "SNR, last packets", "32 packets ago");
chartCard(s_list, 3, "Packets heard, per 5 min", "8 h ago");
fillHistory();
return;
}
if (s_tab == TAB_NOISE) {
noteLabel(s_list, "What the radio hears with nothing on air, on the mesh frequency and 1.1 MHz "
"either side. Takes about 12 seconds.");
@@ -365,8 +487,35 @@ void UITask::diagSection(int sec) {
}
}
// History's charts and values from the history as it is now.
void UITask::fillHistory() {
using namespace diagview;
s_hist_shown = s_hist.version;
chartOf(0, s_hist.batt, 20, false);
chartOf(1, s_hist.noise, 6, false);
chartOf(2, s_hist.snr, 20, false);
chartOf(3, s_hist.traffic, 4, true);
for (int k = 0; k < 4; k++) lv_obj_invalidate(s_ch_obj[k]);
setTextFmt(s_ch_now[0], "%.2f V", (_batt_mv ? _batt_mv : getBattMilliVolts()) / 1000.0f);
const int nf = (int)radio_driver.getNoiseFloor();
if (nf) setTextFmt(s_ch_now[1], "%d dBm", nf); else setText(s_ch_now[1], "-");
if (s_hist.snr.n) setTextFmt(s_ch_now[2], "%.1f dB", s_hist.snr.at(s_hist.snr.n - 1) / 4.0f); else setText(s_ch_now[2], "-");
setTextFmt(s_ch_now[3], "%d", s_hist.traffic.n ? s_hist.traffic.at(s_hist.traffic.n - 1) : 0);
}
// From loop(): the history's readings (it keeps its own pace).
void UITask::sampleHistory() {
using namespace diagview;
s_hist.tick(millis(), (int)radio_driver.getNoiseFloor(), _batt_mv ? _batt_mv : getBattMilliVolts(),
totalRecv(), radio_driver.getLastSNR());
}
void UITask::refreshDiag() {
using namespace diagview;
if (_screen == SCR_DIAG && s_tab == TAB_HISTORY && !_nav_overlay && s_hist.version != s_hist_shown) {
fillHistory(); // a new sample: the charts in place, the list stays where it was scrolled
return;
}
if (_screen != SCR_DIAG || s_tab != TAB_LIVE || _nav_overlay) return;
diag::Row rows[diag::MAX_ROWS + EXTRA];
int n = allRows(rows, _core->gpsEnabled());
@@ -381,6 +530,7 @@ void UITask::diagResetPopup() {
void UITask::diagReset() {
diag::resetCounters();
diagview::s_hist.countersReset();
navClosePopup();
refreshDiag();
}