feat(ui-new): e-ink graphics on the OLED, a Trail height view

The OLED gets the arrows towards nodes in Nodes and the Path row with its
hop rings in a stored node's detail (its position on one line to make the
room); no compass rose. Trail gets a height-profile view on both. The
Status history, trail profile and calendar come from ui-core. A sim hook
turns on the advert's position for tests.

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 699d6b6ae0
commit 99bd41650f
7 changed files with 92 additions and 112 deletions
+11
View File
@@ -554,6 +554,17 @@ extern "C" EMSCRIPTEN_KEEPALIVE int sim_test_set_node_name(const char* name) {
return 1;
}
// Whether adverts carry this node's position (ADVERT_LOC_NONE / _SHARE /
// _PREFS, src/helpers/CommonCLI.h) -- off by default, so peers in a test
// mesh have no distance or direction to one another until it's on.
extern "C" EMSCRIPTEN_KEEPALIVE int sim_test_set_advert_loc(int policy) {
if (!g_sim_ready) return 0;
NodePrefs* prefs = the_mesh.getNodePrefs();
if (!prefs) return 0;
prefs->advert_loc_policy = (uint8_t)policy;
return 1;
}
// auto_off_secs (NodePrefs.h) defaults to 15 -- a real device's screen
// (and, if auto_lock is also set, the UI itself) turns off/locks 15s
// after the last input, real power-saving behaviour a battery-powered
+4 -12
View File
@@ -6,21 +6,13 @@
#include <time.h>
#include <helpers/ui/DisplayDriver.h>
#include "InfoKit.h"
#include "../ui-core/CalendarMath.h"
namespace calendar {
inline int daysIn(int year, int mon) { // mon 0..11, year as in tm (since 1900)
static const uint8_t D[] = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
const int y = year + 1900;
const bool leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;
return D[mon] + (mon == 1 && leap ? 1 : 0);
}
// The column (0 = Monday) the 1st falls in.
inline int firstCol(const struct tm& t) {
const int wday1 = ((t.tm_wday - (t.tm_mday - 1)) % 7 + 7) % 7; // 0 = Sunday
return (wday1 + 6) % 7;
}
inline int weeks(const struct tm& t) { return (firstCol(t) + daysIn(t.tm_year, t.tm_mon) + 6) / 7; }
using calmath::daysIn;
using calmath::firstCol;
using calmath::weeks;
// Its height: the day names, a rule, then a row a week.
inline int height(DisplayDriver& d, const struct tm& t) {
+2 -6
View File
@@ -9,6 +9,7 @@
#include <string.h>
#include <math.h>
#include "icons.h"
#include "../ui-core/StatusHistory.h"
namespace info {
@@ -249,12 +250,7 @@ inline void switchRow(DisplayDriver& d, int y, const char* label, bool on, bool
// The last N samples of one reading, oldest first, for a history line.
template <int N>
struct History {
int16_t v[N];
uint8_t n = 0, head = 0;
void push(int16_t x) { v[head] = x; head = (head + 1) % N; if (n < N) n++; }
int16_t at(int i) const { return v[(head + N - n + i) % N]; } // 0 = oldest
};
using History = uicore::History<N>;
// A history line in a w x h box: a dotted baseline, the samples scaled
// between their own min and max (at least `min_span` apart, so a flat
+29 -17
View File
@@ -440,10 +440,19 @@ class NearbyScreen : public UIScreen, protected NearbyModel {
int step = display.lineStep();
if (step * 5 > display.height() - hdr) step = (display.height() - hdr) / 5;
char buf[32];
snprintf(buf, sizeof(buf), "Lat: %.5f", e.lat_e6 / 1e6);
display.setCursor(2, hdr); display.print(buf);
snprintf(buf, sizeof(buf), "Lon: %.5f", e.lon_e6 / 1e6);
display.setCursor(2, hdr + step); display.print(buf);
// Without a line to spare for the path (the OLED), the position takes one
// row, unlabelled: then there are five rows either way.
const bool one_pos = hdr + step * 5 + display.getLineHeight() > display.height();
if (one_pos) {
snprintf(buf, sizeof(buf), "%.5f,%.5f", e.lat_e6 / 1e6, e.lon_e6 / 1e6);
display.drawTextEllipsized(2, hdr, display.width() - 4, buf);
} else {
snprintf(buf, sizeof(buf), "Lat: %.5f", e.lat_e6 / 1e6);
display.setCursor(2, hdr); display.print(buf);
snprintf(buf, sizeof(buf), "Lon: %.5f", e.lon_e6 / 1e6);
display.setCursor(2, hdr + step); display.print(buf);
}
const int r0 = one_pos ? -1 : 0; // the rows under the position move up one
if (e.dist_km >= 0.0f) {
char dist[12];
@@ -453,32 +462,35 @@ class NearbyScreen : public UIScreen, protected NearbyModel {
} else {
snprintf(buf, sizeof(buf), "Dist: no GPS");
}
display.setCursor(2, hdr + step * 2); display.print(buf);
display.setCursor(2, hdr + step * (2 + r0)); display.print(buf);
snprintf(buf, sizeof(buf), "Type: %s", typeName(e.type));
display.setCursor(2, hdr + step * 3); display.print(buf);
display.setCursor(2, hdr + step * (3 + r0)); display.print(buf);
char age[16];
fmtAge(age, sizeof(age), e.lastmod);
// For a live [LOC] row, label the timestamp as a position share and note
// whether the sender's identity is verified (DM) or name-only (channel).
if (e.is_live) snprintf(buf, sizeof(buf), "Sharing pos: %s %s", age, e.live_verified ? "(DM)" : "(chan)");
else snprintf(buf, sizeof(buf), "Seen: %s", age);
display.drawTextEllipsized(2, hdr + step * 4, display.width() - 4, buf);
display.drawTextEllipsized(2, hdr + step * (4 + r0), display.width() - 4, buf);
const int W = display.width(), H = display.height(), lh = display.getLineHeight();
int rows = 5;
// How a message gets there, where there's a line for it (e-ink): a chain
// from you to it, a ring per repeater on the way.
int rows = 5 + r0;
// How a message gets there: a chain from you to it, a ring per repeater
// on the way.
ContactInfo ci;
if (hdr + step * 5 + lh <= H && e.contact_idx >= 0 && the_mesh.getContactByIdx(e.contact_idx, ci)) {
const int y = hdr + step * 5;
if (hdr + step * rows + lh <= H && e.contact_idx >= 0 && the_mesh.getContactByIdx(e.contact_idx, ci)) {
const int y = hdr + step * rows;
display.setCursor(2, y);
display.print("Path:");
int x = 2 + display.getTextWidth("Path: ");
if (ci.out_path_len == 0xFF) {
display.print(" flood");
} else {
const int hops = ci.out_path_len & 63, shown = hops > 5 ? 5 : hops;
const int hops = ci.out_path_len & 63;
const int r = lh / 4 > 2 ? lh / 4 : 2, cy = y + lh / 2 - 1, gap = r + 3;
// up to 5 rings, as many as leave room for the count after them
const int fit = (W - 2 - x - display.getTextWidth("5 hops")) / (2 * r + 1 + gap) - 2;
const int cap = fit < 5 ? (fit < 1 ? 1 : fit) : 5, shown = hops > cap ? cap : hops;
for (int k = 0; k <= shown + 1; k++) { // you and it filled, the hops as rings
if (k) for (int dx = x - gap + 1; dx < x; dx += 2) display.fillRect(dx, cy, 1, 1);
info::circle(display, x + r, cy, r);
@@ -490,12 +502,12 @@ class NearbyScreen : public UIScreen, protected NearbyModel {
display.setCursor(x, y);
display.print(buf);
}
rows = 6;
rows++;
}
// Where it is, on a compass rose (north up), where the screen has the room
// (e-ink): beside the rows on a wide one, under them on a tall one.
if (e.dist_km < 0.0f) return;
if (!Features::IS_EINK || e.dist_km < 0.0f) return;
const int below = hdr + step * rows + lh;
int r, cx, cy;
if (H - below > W / 2) { r = (W < H - below ? W : H - below) / 2 - lh; cx = W / 2; cy = below + lh / 2 + r; }
@@ -650,8 +662,8 @@ public:
}
int item_h = display.lineStep();
// E-ink sets an arrow towards the node before its distance: one more cell.
const bool arrows = Features::IS_EINK && _source == SRC_STORED && _sort != SORT_TIME;
// An arrow towards the node before its distance: one more cell.
const bool arrows = _source == SRC_STORED && _sort != SORT_TIME;
int dist_col = display.width() - display.getCharWidth() * (arrows ? 9 : 7);
display.setColor(DisplayDriver::LIGHT);
+16 -34
View File
@@ -48,17 +48,13 @@ private:
CayenneLPP _lpp;
unsigned long _lpp_at = 0;
// History lines, one sample a minute (32 min). E-ink draws them as charts
// (chartBlock()) over a longer span: a sample every 5 minutes, 8 hours.
// History lines (ui-core/StatusHistory.h), one sample a minute (32 min).
// E-ink draws them as charts (chartBlock()) over a longer span: a sample
// every 5 minutes, 8 hours. Its Radio chart also has the SNR of the last
// 32 packets.
static const int HIST_N = Features::IS_EINK ? 96 : 32;
static const unsigned long SAMPLE_MS = Features::IS_EINK ? 300000 : 60000;
static constexpr const char* HIST_SPAN = Features::IS_EINK ? "8 h ago" : "32 min";
unsigned long _next_sample = 0;
uint32_t _last_rx = 0;
info::History<HIST_N> _noise, _batt, _traffic;
// The SNR of each packet heard, the last 32 (e-ink's Radio chart), x4 dB.
info::History<32> _snr;
uint32_t _snr_rx = 0;
uicore::StatusHistory<HIST_N, Features::IS_EINK ? 300000UL : 60000UL> _hist;
// A chart as a block (e-ink): `title` with the value now on its line, the
// chart under it.
@@ -124,12 +120,12 @@ private:
const int nf = (int)radio_driver.getNoiseFloor();
if (nf) snprintf(a, sizeof(a), "%d dBm", nf); else strcpy(a, "-");
if (Features::IS_EINK) {
chartBlock(d, f, rsv, "Noise", a, _noise, 6, false, HIST_SPAN);
chartBlock(d, f, rsv, "Noise", a, _hist.noise, 6, false, HIST_SPAN);
} else if (f.place(d.lineStep())) { // label, the last half hour, the value now
info::valueRow(d, f.at, "Noise", a, false, rsv, 1);
const int x0 = 1 + d.getTextWidth("Noise") + 6;
const int x1 = d.width() - rsv - 2 - d.getTextWidth(a) - 6;
if (x1 - x0 > 12) info::spark(d, x0, f.at, x1 - x0, lh - 1, _noise);
if (x1 - x0 > 12) info::spark(d, x0, f.at, x1 - x0, lh - 1, _hist.noise);
}
if (f.place(d.lineStep())) {
const float snr = radio_driver.getLastSNR();
@@ -138,7 +134,7 @@ private:
drawSignalBars(d, d.width() - rsv - 2 - d.getTextWidth(b) - 5, f.at, (int)(snr * 4));
}
snprintf(b, sizeof(b), "%.1f dB", radio_driver.getLastSNR());
if (Features::IS_EINK) chartBlock(d, f, rsv, "SNR", b, _snr, 20, false, "32 pkts");
if (Features::IS_EINK) chartBlock(d, f, rsv, "SNR", b, _hist.snr, 20, false, "32 pkts");
else row(d, f, rsv, "Last SNR", b);
}
@@ -214,12 +210,12 @@ private:
row(d, f, rsv, "Battery", a);
row(d, f, rsv, "Source", board.isExternalPowered() ? "USB" : "Battery");
if (Features::IS_EINK) {
chartBlock(d, f, rsv, "Trend", nullptr, _batt, 20, false, HIST_SPAN);
chartBlock(d, f, rsv, "Trend", nullptr, _hist.batt, 20, false, HIST_SPAN);
} else if (f.place(d.lineStep())) {
d.setCursor(1, f.at);
d.print("Trend");
const int x0 = 1 + d.getTextWidth("Trend") + 6;
info::spark(d, x0, f.at, d.width() - rsv - 2 - x0, d.getLineHeight() - 1, _batt, 20);
info::spark(d, x0, f.at, d.width() - rsv - 2 - x0, d.getLineHeight() - 1, _hist.batt, 20);
}
// Sensors: every reading but the battery (above) and the position (GPS).
@@ -259,9 +255,9 @@ private:
if (Features::IS_EINK) { // packets per 5 minutes, as bars
const int ch = 3 * lh;
if (f.place(info::chartH(d, ch) + 2))
info::chart(d, 1, f.at, d.width() - rsv - 3, ch, _traffic, 4, true, HIST_SPAN, "now");
info::chart(d, 1, f.at, d.width() - rsv - 3, ch, _hist.traffic, 4, true, HIST_SPAN, "now");
} else if (f.place(lh + 6)) {
info::spark(d, 1, f.at, d.width() - rsv - 3, lh + 3, _traffic, 4);
info::spark(d, 1, f.at, d.width() - rsv - 3, lh + 3, _hist.traffic, 4);
}
static const uint8_t MSG[] = { PAYLOAD_TYPE_TXT_MSG, PAYLOAD_TYPE_GRP_TXT };
@@ -350,26 +346,12 @@ public:
// GPS stays awake while its live tabs are on screen.
bool wantsLiveGps() const { return tab() == TAB_GPS || tab() == TAB_SKY; }
// Called from the UI loop on every pass: one sample a minute for the lines.
// Called from the UI loop on every pass; the history keeps its own pace.
void sample() {
if (Features::IS_EINK) samplePacket();
const unsigned long now = millis();
if (_next_sample && (long)(now - _next_sample) < 0) return;
_next_sample = now + SAMPLE_MS;
const int nf = (int)radio_driver.getNoiseFloor();
if (nf) _noise.push((int16_t)nf);
_batt.push((int16_t)battMv());
const uint32_t rx = totalRx();
if (_last_rx || rx) _traffic.push((int16_t)(rx - _last_rx > 30000 ? 30000 : rx - _last_rx));
_last_rx = rx;
}
// On every pass: the SNR of a packet just heard (e-ink's Radio chart).
void samplePacket() {
const uint32_t rx = totalRx();
if (rx != _snr_rx) { _snr.push((int16_t)(radio_driver.getLastSNR() * 4)); _snr_rx = rx; }
_hist.tick(millis(), (int)radio_driver.getNoiseFloor(), battMv(), totalRx(), radio_driver.getLastSNR());
}
const info::History<HIST_N>& battHistory() const { return _batt; }
const info::History<HIST_N>& battHistory() const { return _hist.batt; }
// Contacts heard from in the last hour (cached for 10 s: it walks them all).
static int heardLastHour() {
@@ -422,7 +404,7 @@ public:
auto res = _reset_menu.handleInput(c);
if (res == PopupMenu::SELECTED && _reset_menu.selectedIndex() == 0) {
diag::resetCounters();
_last_rx = 0;
_hist.countersReset();
}
return true;
}
+27 -29
View File
@@ -7,6 +7,7 @@
#include "InfoKit.h"
#include "../Trail.h"
#include "../ui-core/TrailProfile.h"
#include "../GeoUtils.h"
#include "GfxUtils.h"
#include "icons.h" // scalable mini-icons (map markers, north arrow, grid dots)
@@ -51,7 +52,7 @@ class TrailScreen : public UIScreen {
UITask* _task;
TrailStore* _store;
enum View { V_SUMMARY = 0, V_MAP = 1, V_LIST = 2, V_COUNT };
enum View { V_SUMMARY = 0, V_MAP = 1, V_PROFILE = 2, V_LIST = 3, V_COUNT };
uint8_t _view = V_SUMMARY;
int _summary_scroll = 0;
int _list_scroll = 0;
@@ -126,14 +127,16 @@ public:
// The title bar carries the view dots so the bottom hint row can be
// reclaimed for content.
const char* title = (_view == V_MAP) ? (_map_grid ? "Trail map+" : "Trail map")
: (_view == V_LIST) ? "Trail list"
: "Trail";
const char* title = (_view == V_MAP) ? (_map_grid ? "Trail map+" : "Trail map")
: (_view == V_PROFILE) ? "Trail height"
: (_view == V_LIST) ? "Trail list"
: "Trail";
drawScreenHeader(display, title, (int)_view, (int)V_COUNT);
if (_view == V_MAP) renderMap(display);
else if (_view == V_LIST) renderList(display);
else renderSummary(display);
if (_view == V_MAP) renderMap(display);
else if (_view == V_PROFILE) renderProfileView(display);
else if (_view == V_LIST) renderList(display);
else renderSummary(display);
if (_action_menu.active) _action_menu.render(display);
return _store->isActive() ? 1000 : 5000;
@@ -558,9 +561,16 @@ private:
}
}
bool hasAltitude() const {
for (int i = 0; i < _store->count(); i++) if (_store->at(i).alt_m != TRAIL_ALT_NONE) return true;
return false;
bool hasAltitude() const { return trailprofile::stats(*_store).any; }
// The profile as its own view, the whole space under the header.
void renderProfileView(DisplayDriver& d) {
const int top = d.listStart();
if (!hasAltitude()) {
d.drawTextCentered(d.width() / 2, (top + d.height()) / 2 - d.getLineHeight() / 2, "No altitude yet");
return;
}
renderProfile(d, 1, top, d.width() - 2, d.height() - 1 - top);
}
// The height over the distance walked, in x, y, w, h: a framed area, a
@@ -568,30 +578,17 @@ private:
// point under it.
void renderProfile(DisplayDriver& d, int x, int y, int w, int h) {
const int lh = d.getLineHeight(), fh = h - lh - 2; // the frame; the caption under it
const int n = _store->count();
int lo = 32767, hi = -32768, gain = 0, last = TRAIL_ALT_NONE;
for (int i = 0; i < n; i++) {
const int a = _store->at(i).alt_m;
if (a == TRAIL_ALT_NONE) continue;
if (a < lo) lo = a;
if (a > hi) hi = a;
if (last == TRAIL_ALT_NONE || a < last) last = a; // the climb, past 3 m of jitter
else if (a - last >= 3) { gain += a - last; last = a; }
}
const trailprofile::Stats st = trailprofile::stats(*_store);
int lo = st.lo, hi = st.hi;
if (hi - lo < 20) { const int mid = (hi + lo) / 2; lo = mid - 10; hi = mid + 10; }
const float total = _store->totalDistanceMeters() > 0 ? (float)_store->totalDistanceMeters() : 1.0f;
d.drawSoftRect(x, y, w, fh);
const int ix = x + 2, iy = y + 2, iw = w - 4, ih = fh - 4;
float cum = 0;
int px = -1, py = 0;
for (int i = 0; i < n; i++) {
const TrailPoint& p = _store->at(i);
if (i > 0 && !(p.flags & TRAIL_FLAG_SEG_START))
cum += TrailStore::haversineMeters(_store->at(i - 1).lat_1e6, _store->at(i - 1).lon_1e6, p.lat_1e6, p.lon_1e6);
if (p.alt_m == TRAIL_ALT_NONE) continue;
trailprofile::walk(*_store, [&](float cum, int alt) {
int cx = ix + (int)(cum / total * (iw - 1));
if (cx > ix + iw - 1) cx = ix + iw - 1;
const int cy = iy + ih - 1 - (p.alt_m - lo) * (ih - 1) / (hi - lo);
const int cy = iy + ih - 1 - (alt - lo) * (ih - 1) / (hi - lo);
if (px >= 0) {
for (int c = px + 1; c <= cx; c++) { // each column: the line, the fill under it
const int ly = py + (cy - py) * (c - px) / (cx - px);
@@ -600,10 +597,11 @@ private:
}
}
px = cx; py = cy;
}
});
const int gain = st.gain;
char a[16], b[16];
snprintf(a, sizeof(a), "+%d m", gain);
snprintf(b, sizeof(b), "max %d m", hi);
snprintf(b, sizeof(b), "max %d m", st.hi);
const int cy = y + fh + 2;
miniIconDraw(d, x, cy, ICON_ARROW_NE);
d.setCursor(x + ICON_ARROW_NE.w * miniIconScale(d) + 2, cy);
+3 -14
View File
@@ -1072,20 +1072,9 @@ public:
// The days left this month the alarm goes off on, as calendar::draw()'s
// marks: the repeat days, or a one-shot's next (today or tomorrow).
uint32_t alarmDays(const struct tm& t) const {
if (!_node_prefs || !_node_prefs->alarm_on) return 0;
const int n = calendar::daysIn(t.tm_year, t.tm_mon);
const uint8_t mask = _node_prefs->alarm_repeat_mask;
const bool passed = t.tm_hour * 60 + t.tm_min >= _node_prefs->alarm_hour * 60 + _node_prefs->alarm_min;
uint32_t days = 0;
for (int day = t.tm_mday; day <= n; day++) {
if (day == t.tm_mday && passed) continue;
const int wday = (t.tm_wday + day - t.tm_mday) % 7;
if (mask ? (mask & (1 << wday)) : true) {
days |= 1UL << (day - 1);
if (!mask) break; // one-shot: only the next
}
}
return days;
if (!_node_prefs) return 0;
return calmath::alarmDays(t, _node_prefs->alarm_on, _node_prefs->alarm_repeat_mask,
_node_prefs->alarm_hour, _node_prefs->alarm_min);
}
void drawClockPage(DisplayDriver& d, uint32_t unix_ts) {