Merge branch 'main' into fix/eink-button-irq-edge-capture

# Conflicts:
#	examples/companion_radio/ui-new/UITask.cpp
This commit is contained in:
MarekZegare4
2026-06-30 22:13:18 +02:00
35 changed files with 2290 additions and 720 deletions
@@ -20,7 +20,7 @@ class AutoAdvertScreen : public UIScreen {
public:
AutoAdvertScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs) {}
void enter() { _dirty = false; }
void onShow() override { _dirty = false; }
int render(DisplayDriver& display) override {
display.setTextSize(1);
@@ -51,7 +51,7 @@ public:
bool handleInput(char c) override {
if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) {
if (_dirty) the_mesh.savePrefs();
_task->savePrefsIfDirty(_dirty);
_task->gotoToolsScreen();
return true;
}
+3 -3
View File
@@ -42,7 +42,7 @@ class BotScreen : public UIScreen {
public:
BotScreen(UITask* task, NodePrefs* prefs, KeyboardWidget* kb) : _task(task), _prefs(prefs), _kb(kb) {}
void enter() {
void onShow() override {
_sel = 0;
_scroll = 0;
_kb_field = -1;
@@ -75,7 +75,7 @@ public:
"Trigger Ch", "Reply Ch", "Commands",
"Quiet from", "Quiet to" };
drawList(display, ITEM_COUNT, _sel, _scroll, [&](int i, int y, bool sel, int reserve) {
display.drawSelectionRow(0, y - 1, display.width() - reserve, display.lineStep() - 1, sel);
drawRowSelection(display, y, sel, reserve);
display.setCursor(2, y);
display.print(labels[i]);
display.setCursor(val_x, y);
@@ -141,7 +141,7 @@ public:
}
if (cancel) {
if (_dirty) the_mesh.savePrefs();
_task->savePrefsIfDirty(_dirty);
_task->gotoToolsScreen();
return true;
}
@@ -0,0 +1,298 @@
#pragma once
// Clock tools — Alarm, Countdown timer (minutnik) and Stopwatch (stoper).
// Entered with Enter on the home CLOCK page. A small top menu picks one of the
// three tools; Cancel backs out a level (tool → menu → home).
//
// This screen is pure UI. The time-critical machinery lives in UITask, which
// drives it every loop regardless of the current screen:
// • Alarm — UITask schedules an absolute fire instant from NodePrefs'
// alarm_hour/min (robust to RTC re-syncs) and rings. Here we just edit the
// persisted fields and call onAlarmChanged() to re-schedule.
// • Timer — UITask owns the running countdown (startTimer / stopTimer /
// isTimerRunning / timerRemainingMs). It rings even when off-screen.
// • Stopwatch — purely a display utility with no background action, so its
// millis() state lives here; it keeps counting while you're elsewhere.
//
// Numeric fields (alarm hour/minute, timer h/m/s) are edited with the shared
// framework DigitEditor (digit-by-digit: LEFT/RIGHT cursor, UP/DOWN +/- the
// place, min/max enforced) — the same widget Settings/Repeater use for Freq,
// rather than a hand-rolled wrap.
//
// E-INK: the running timer/stopwatch readouts would thrash a slow e-paper panel
// if redrawn every second, so on e-ink the live readout refreshes only coarsely
// (and immediately on any key); the millis() timing underneath is unaffected.
// Included by UITask.cpp after the other screen fragments.
#include "../NodePrefs.h"
#include "icons.h" // drawList / drawRowSelection
#include "DigitEditor.h" // shared digit-by-digit numeric editor
class ClockToolsScreen : public UIScreen {
UITask* _task;
NodePrefs* _prefs;
enum View : uint8_t { V_MENU, V_ALARM, V_TIMER, V_STOPWATCH };
uint8_t _view = V_MENU;
int _sel = 0, _scroll = 0; // shared list cursor
bool _alarm_dirty = false; // unsaved edits to alarm_* (persist on exit)
// Countdown config (the duration to start; the running countdown lives in UITask).
uint8_t _timer_h = 0, _timer_m = 5, _timer_s = 0;
uint8_t _timer_cur = 0; // digit cursor over HH:MM:SS, 0..5 (skips the colons)
// Stopwatch (millis — display-only, no background action).
bool _sw_running = false;
uint32_t _sw_start_ms = 0;
uint32_t _sw_accum_ms = 0;
// Shared numeric editor (alarm hour/minute) + which field it targets. The
// timer uses its own continuous digit cursor (per-place caps a single
// DigitEditor can't express), so it isn't listed here.
enum EditKind : uint8_t { EK_NONE, EK_A_HOUR, EK_A_MIN };
DigitEditor _editor;
EditKind _edit_kind = EK_NONE;
// E-ink: coarse refresh for the live readouts (millis underneath is exact).
static int liveTickMs(DisplayDriver& d, int oled_ms) { return d.isEink() ? 30000 : oled_ms; }
static void fmtClock(char* b, int n, int hh, int mm) { snprintf(b, n, "%02d:%02d", hh, mm); }
// millis → "H:MM:SS" (the countdown always shows hours).
static void fmtHMS(char* b, int n, uint32_t ms) {
uint32_t s = ms / 1000, h = s / 3600; s %= 3600;
uint32_t m = s / 60; s %= 60;
snprintf(b, n, "%lu:%02lu:%02lu", (unsigned long)h, (unsigned long)m, (unsigned long)s);
}
// Stopwatch readout: "M:SS.t" while under an hour (tenths only on a fast
// panel), "H:MM:SS" once it rolls past an hour.
static void fmtStopwatch(char* b, int n, uint32_t ms, bool tenths) {
if (ms >= 3600000UL) { fmtHMS(b, n, ms); return; }
uint32_t total_s = ms / 1000, m = total_s / 60, s = total_s % 60;
if (tenths) snprintf(b, n, "%lu:%02lu.%lu", (unsigned long)m, (unsigned long)s,
(unsigned long)((ms % 1000) / 100));
else snprintf(b, n, "%lu:%02lu", (unsigned long)m, (unsigned long)s);
}
uint32_t stopwatchMs() const {
return _sw_accum_ms + (_sw_running ? (millis() - _sw_start_ms) : 0);
}
// Step the digit under the timer cursor by dir (+1/1), wrapping within that
// place and keeping each field valid: minute/second tens 0-5, hours 0-23.
void stepTimerDigit(int dir) {
uint8_t* f; int tens_max; bool is_hours = false;
if (_timer_cur < 2) { f = &_timer_h; tens_max = 2; is_hours = true; }
else if (_timer_cur < 4) { f = &_timer_m; tens_max = 5; }
else { f = &_timer_s; tens_max = 5; }
int t = *f / 10, u = *f % 10;
if (_timer_cur % 2 == 0) { // tens place
t = (t + dir + (tens_max + 1)) % (tens_max + 1);
if (is_hours && t == 2 && u > 3) u = 3; // 2X hours can't exceed 23
} else { // units place
int umax = (is_hours && t == 2) ? 3 : 9;
u = (u + dir + (umax + 1)) % (umax + 1);
}
*f = (uint8_t)(t * 10 + u);
}
// Open the DigitEditor on a numeric field (2 integer digits, no decimals).
void editField(EditKind kind, uint8_t value, uint8_t vmax) {
_edit_kind = kind;
_editor.begin((float)value, 0.0f, (float)vmax, 2, 0);
}
// Feed a key to the open editor, write the (live) value back to its field, and
// close the editor when DigitEditor reports DONE/CANCELLED.
bool feedEditor(char c) {
DigitEditor::Result r = _editor.handleInput(c);
uint8_t v = (uint8_t)(_editor.value + 0.5f);
switch (_edit_kind) {
case EK_A_HOUR: _prefs->alarm_hour = v; _alarm_dirty = true; _task->onAlarmChanged(); break;
case EK_A_MIN: _prefs->alarm_min = v; _alarm_dirty = true; _task->onAlarmChanged(); break;
default: break;
}
if (r != DigitEditor::NONE) _edit_kind = EK_NONE; // editor closed
return true;
}
// Draw a row's value column: the live editor when this row is the one being
// edited, otherwise the static text.
void drawValue(DisplayDriver& d, int y, int valx, int reserve, bool sel,
EditKind mykind, const char* text) {
if (sel && _editor.active && _edit_kind == mykind) _editor.render(d, valx, y);
else d.drawTextEllipsized(valx, y, d.width() - valx - reserve, text);
}
// ── Sub-renders ───────────────────────────────────────────────────────────
int renderMenu(DisplayDriver& d) {
d.drawCenteredHeader("CLOCK TOOLS");
static const char* items[3] = { "Alarm", "Timer", "Stopwatch" };
if (_sel > 2) _sel = 2;
drawList(d, 3, _sel, _scroll, [&](int i, int y, bool sel, int reserve) {
drawRowSelection(d, y, sel, reserve);
d.setCursor(4, y);
d.print(items[i]);
if (i == 0 && _prefs && _prefs->alarm_on) { // show the armed alarm time
char b[8]; fmtClock(b, sizeof(b), _prefs->alarm_hour, _prefs->alarm_min);
int vx = d.width() - d.getTextWidth(b) - reserve - 2;
d.setCursor(vx, y); d.print(b);
}
});
return 60000;
}
int renderAlarm(DisplayDriver& d) {
d.drawCenteredHeader("ALARM");
if (!_prefs) return 60000;
const char* rows[3] = { "Hour", "Minute", "Armed" };
if (_sel > 2) _sel = 2;
const int valx = d.width() / 2 + 6;
drawList(d, 3, _sel, _scroll, [&](int i, int y, bool sel, int reserve) {
drawRowSelection(d, y, sel, reserve);
d.setCursor(4, y); d.print(rows[i]);
char b[6];
if (i == 0) snprintf(b, sizeof(b), "%02u", _prefs->alarm_hour);
else if (i == 1) snprintf(b, sizeof(b), "%02u", _prefs->alarm_min);
else snprintf(b, sizeof(b), "%s", _prefs->alarm_on ? "ON" : "OFF");
EditKind k = (i == 0) ? EK_A_HOUR : (i == 1) ? EK_A_MIN : EK_NONE;
drawValue(d, y, valx, reserve, sel, k, b);
});
return _editor.active ? 50 : 60000;
}
int renderTimer(DisplayDriver& d) {
d.drawCenteredHeader("TIMER");
const int cx = d.width() / 2;
if (_task->isTimerRunning()) {
char buf[16];
fmtHMS(buf, sizeof(buf), _task->timerRemainingMs());
d.setTextSize(2);
d.drawTextCentered(cx, d.listStart() + 4, buf);
d.setTextSize(1);
d.drawTextCentered(cx, d.height() - d.getLineHeight() - 1, "Ent=stop");
return liveTickMs(d, 1000);
}
// Setting mode: big HH:MM:SS with the digit under the cursor underlined.
// LEFT/RIGHT move the cursor one digit, UP/DOWN change it, Enter starts.
char buf[12];
snprintf(buf, sizeof(buf), "%02u:%02u:%02u", _timer_h, _timer_m, _timer_s);
d.setTextSize(2);
const int ty = d.listStart() + 2;
d.drawTextCentered(cx, ty, buf);
const int cw = d.getCharWidth(); // size-2 cell width
const int lh2 = d.getLineHeight();
const int sx = cx - d.getTextWidth(buf) / 2;
const int char_idx = _timer_cur + (_timer_cur / 2); // skip the two colons
d.fillRect(sx + char_idx * cw, ty + lh2, cw - 1, 2);
d.setTextSize(1);
d.drawTextCentered(cx, d.height() - d.getLineHeight() - 1, "Up/Dn=set Ent=start");
return 60000;
}
int renderStopwatch(DisplayDriver& d) {
d.drawCenteredHeader("STOPWATCH");
const int cx = d.width() / 2;
char buf[16];
bool tenths = !d.isEink() && _sw_running; // tenths only on a fast panel
fmtStopwatch(buf, sizeof(buf), stopwatchMs(), tenths);
d.setTextSize(2);
d.drawTextCentered(cx, d.listStart() + 4, buf);
d.setTextSize(1);
const char* hint = _sw_running ? "Ent=stop" : "Ent=start Dn=reset";
d.drawTextCentered(cx, d.height() - d.getLineHeight() - 1, hint);
return _sw_running ? liveTickMs(d, 100) : 60000;
}
// ── Input per view ────────────────────────────────────────────────────────
bool inputMenu(char c) {
if (c == KEY_CANCEL) { _task->gotoHomeScreen(); return true; }
if (c == KEY_UP) { _sel = (_sel > 0) ? _sel - 1 : 2; return true; }
if (c == KEY_DOWN) { _sel = (_sel < 2) ? _sel + 1 : 0; return true; }
if (c == KEY_ENTER) {
_view = (_sel == 0) ? V_ALARM : (_sel == 1) ? V_TIMER : V_STOPWATCH;
_sel = 0; _scroll = 0;
return true;
}
return true;
}
bool inputAlarm(char c) {
if (!_prefs) { if (c == KEY_CANCEL) { _view = V_MENU; _sel = 0; } return true; }
if (_editor.active) return feedEditor(c);
if (c == KEY_CANCEL) {
_task->savePrefsIfDirty(_alarm_dirty); // persist alarm fields only if edited
_task->onAlarmChanged(); // re-schedule from the new time
_view = V_MENU; _sel = 0; _scroll = 0;
return true;
}
if (c == KEY_UP) { _sel = (_sel > 0) ? _sel - 1 : 2; return true; }
if (c == KEY_DOWN) { _sel = (_sel < 2) ? _sel + 1 : 0; return true; }
if (c == KEY_ENTER) {
if (_sel == 0) editField(EK_A_HOUR, _prefs->alarm_hour, 23);
else if (_sel == 1) editField(EK_A_MIN, _prefs->alarm_min, 59);
else { _prefs->alarm_on ^= 1; _alarm_dirty = true; _task->onAlarmChanged(); }
return true;
}
return true;
}
bool inputTimer(char c) {
if (_task->isTimerRunning()) {
if (c == KEY_ENTER) { _task->stopTimer(); return true; } // stop/cancel countdown
if (c == KEY_CANCEL) { _view = V_MENU; _sel = 0; return true; } // keeps counting in background
return true; // any other key just forces a refresh (e-ink)
}
if (c == KEY_CANCEL) { _view = V_MENU; _sel = 0; return true; }
if (keyIsPrev(c)) { _timer_cur = (_timer_cur + 5) % 6; return true; } // cursor ←
if (keyIsNext(c)) { _timer_cur = (_timer_cur + 1) % 6; return true; } // cursor →
if (c == KEY_UP) { stepTimerDigit(+1); return true; }
if (c == KEY_DOWN) { stepTimerDigit(-1); return true; }
if (c == KEY_ENTER) {
uint32_t dur = (((uint32_t)_timer_h * 60 + _timer_m) * 60 + _timer_s) * 1000UL;
if (dur == 0) { _task->showAlert("Set a duration", 1000); return true; }
_task->startTimer(dur);
}
return true;
}
bool inputStopwatch(char c) {
if (c == KEY_CANCEL) { _view = V_MENU; _sel = 0; return true; } // keeps running in background
if (c == KEY_ENTER) {
if (_sw_running) { _sw_accum_ms += millis() - _sw_start_ms; _sw_running = false; }
else { _sw_start_ms = millis(); _sw_running = true; }
return true;
}
if ((c == KEY_UP || c == KEY_DOWN) && !_sw_running) { _sw_accum_ms = 0; return true; }
return true; // other keys just refresh the readout
}
public:
ClockToolsScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs) {}
void onShow() override {
_view = V_MENU; _sel = 0; _scroll = 0;
_timer_cur = 0;
_editor.active = false; _edit_kind = EK_NONE;
}
int render(DisplayDriver& display) override {
display.setTextSize(1);
display.setColor(DisplayDriver::LIGHT);
switch (_view) {
case V_ALARM: return renderAlarm(display);
case V_TIMER: return renderTimer(display);
case V_STOPWATCH: return renderStopwatch(display);
default: return renderMenu(display);
}
}
bool handleInput(char c) override {
switch (_view) {
case V_ALARM: return inputAlarm(c);
case V_TIMER: return inputTimer(c);
case V_STOPWATCH: return inputStopwatch(c);
default: return inputMenu(c);
}
}
};
@@ -27,7 +27,7 @@ class CompassScreen : public UIScreen {
public:
CompassScreen(UITask* task) : _task(task) {}
void enter() {}
void onShow() override {}
int render(DisplayDriver& display) override {
display.setTextSize(1);
@@ -37,7 +37,7 @@ class DashboardConfigScreen : public UIScreen {
public:
DashboardConfigScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs) {}
void enter() { _sel = 0; _dirty = false; }
void onShow() override { _sel = 0; _dirty = false; }
int render(DisplayDriver& display) override {
display.setTextSize(1);
@@ -65,7 +65,7 @@ public:
bool handleInput(char c) override {
if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) {
if (_dirty) the_mesh.savePrefs();
_task->savePrefsIfDirty(_dirty);
_task->gotoHomeScreen();
return true;
}
@@ -10,12 +10,35 @@ static const int FS_CHARS_MAX = 80; // max bytes per wrapped line
// fullscreen message view and the history list are never laid out in the same
// frame (opening the fullscreen view early-returns before the list loop runs),
// so one static buffer serves both. This keeps ~1.5 KB of line buffers off the
// render-call stack (see CODE_REVIEW.md "Render-path stack peak") at the cost of
// a fixed RAM allocation. Only used inside render()/wrap helpers — never across
// a yield, so the single instance is safe.
// render-call stack at the cost of a fixed RAM allocation. Only used inside
// render()/wrap helpers — never across a yield, so the single instance is safe.
static char s_wrap_trans[512];
static char s_wrap_lines[12][FS_CHARS_MAX];
// Parse a leading "@[nick] " reply prefix. Returns the message body that
// follows it (and any leading whitespace); when nick/nick_n are supplied,
// fills nick with the addressee, or "" when there's no prefix. One parser for
// both the history list (body only — see QuickMsgScreen::skipReplyPrefix) and
// the fullscreen view (which also shows the "To:" nick).
static inline const char* msgReplyBody(const char* text, char* nick = nullptr, int nick_n = 0) {
if (nick && nick_n > 0) nick[0] = '\0';
const char* body = text;
if (text[0] == '@' && text[1] == '[') {
const char* close = strchr(text + 2, ']');
if (close && close[1] == ' ' && close[2]) {
if (nick && nick_n > 0) {
int len = (int)(close - text) - 2;
if (len > nick_n - 1) len = nick_n - 1;
memcpy(nick, text + 2, len);
nick[len] = '\0';
}
body = close + 2;
}
}
while (*body == '\n' || *body == '\r' || *body == ' ') body++;
return body;
}
struct FullscreenMsgView {
int scroll;
bool active;
@@ -80,19 +103,9 @@ struct FullscreenMsgView {
const int lineH = display.getLineHeight();
const int max_px = display.width() - 6;
// detect @recipient at start of message
char to_nick[32] = "";
const char* body = text;
if (text[0] == '@' && text[1] == '[') {
const char* close = strchr(text + 2, ']');
if (close && close[1] == ' ' && close[2]) {
int len = (int)(close - text) - 2;
if (len >= (int)sizeof(to_nick)) len = sizeof(to_nick) - 1;
memcpy(to_nick, text + 2, len);
to_nick[len] = '\0';
body = close + 2;
}
}
// "@[nick] " reply prefix → "To:" header + body (shared parser).
char to_nick[32];
const char* body = msgReplyBody(text, to_nick, sizeof(to_nick));
const int cw = display.getCharWidth();
const int header_h = to_nick[0] ? (lineH * 2 + 4) : (lineH + 2);
@@ -143,8 +156,8 @@ struct FullscreenMsgView {
Result handleInput(char c) {
if (c == KEY_UP) { if (scroll > 0) scroll--; return NONE; }
if (c == KEY_DOWN) { if (scroll < _max_scroll) scroll++; return NONE; }
if (c == KEY_LEFT) return NEXT;
if (c == KEY_RIGHT) return PREV;
if (keyIsPrev(c)) return NEXT; // page between messages (encoder too)
if (keyIsNext(c)) return PREV;
if (c == KEY_CONTEXT_MENU) return REPLY;
if (c == KEY_ENTER || c == KEY_CANCEL) return CLOSE;
return NONE;
@@ -36,7 +36,7 @@ class LiveShareScreen : public UIScreen {
public:
LiveShareScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs) {}
void enter() { _dirty = false; _sel = 0; _scroll = 0; }
void onShow() override { _dirty = false; _sel = 0; _scroll = 0; }
// Resolve the configured target's display name (channel name or contact name).
void currentTargetName(char* buf, int n) {
@@ -92,7 +92,7 @@ public:
const int valx = display.width() / 2 + 6;
drawList(display, ROW_COUNT, _sel, _scroll, [&](int i, int y, bool sel, int reserve) {
Row r = rows(i);
display.drawSelectionRow(0, y - 1, display.width() - reserve, display.lineStep() - 1, sel);
drawRowSelection(display, y, sel, reserve);
display.setCursor(4, y);
display.print(r.label);
char val[24];
@@ -161,7 +161,7 @@ public:
bool handleInput(char c) override {
if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) {
if (_dirty) { the_mesh.savePrefs(); _dirty = false; }
_task->savePrefsIfDirty(_dirty);
_task->gotoToolsScreen();
return true;
}
@@ -72,7 +72,7 @@ class LocatorScreen : public UIScreen {
public:
LocatorScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs) {}
void enter() { _dirty = false; _sel = 0; _scroll = 0; _picking = false; }
void onShow() override { _dirty = false; _sel = 0; _scroll = 0; _picking = false; }
void valueLabel(Kind k, char* buf, int n) {
switch (k) {
@@ -116,7 +116,7 @@ public:
const int valx = display.width() / 2 + 6;
drawList(display, rc, _sel, _scroll, [&](int i, int y, bool sel, int reserve) {
Row r = rows(i);
display.drawSelectionRow(0, y - 1, display.width() - reserve, display.lineStep() - 1, sel);
drawRowSelection(display, y, sel, reserve);
display.setCursor(4, y);
display.print(r.label);
char val[24];
@@ -265,7 +265,7 @@ public:
display.drawCenteredHeader("PICK TARGET");
uint32_t now = rtc_clock.getCurrentTime();
drawList(display, _target_n, _pick_sel, _pick_scroll, [&](int i, int y, bool sel, int reserve) {
display.drawSelectionRow(0, y - 1, display.width() - reserve, display.lineStep() - 1, sel);
drawRowSelection(display, y, sel, reserve);
const Target& t = _targets[i];
char row[36];
if (t.kind != 1) {
@@ -293,7 +293,7 @@ public:
return true;
}
if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) {
if (_dirty) { the_mesh.savePrefs(); _dirty = false; } // engine re-seeded per edit
_task->savePrefsIfDirty(_dirty); // engine re-seeded per edit
_task->gotoToolsScreen();
return true;
}
@@ -0,0 +1,324 @@
#pragma once
// Message history store for QuickMsgScreen: two RAM ring buffers (channel + DM)
// with their per-entry delivery state (channel "relayed into mesh" echo, DM
// end-to-end ACK + auto-resend) and the per-channel unread counters. Pure
// storage + queries — no UI/phase state lives here. The screen keeps selection,
// scroll, the unread "viewing session" bookkeeping, the room-login table, and
// all rendering, and reaches entries through the accessors below.
//
// Single-TU fragment: included by UITask.cpp before QuickMsgScreen.h. AckState,
// MSG_TEXT_BUF and the two entry structs are file-scope (not nested) so the
// phase machine in QuickMsgScreen keeps referring to them unqualified.
// Outgoing-message delivery state. DM: a real end-to-end ACK (✓ delivered to
// the recipient). Channel: only a "relayed into mesh" echo from a repeater (no
// recipient ACK exists for floods), so a missing echo is NOT shown as failure.
enum AckState : uint8_t { ACK_NONE = 0, ACK_PENDING, ACK_OK, ACK_FAIL };
// History text holds a full received message. Channel messages carry the sender
// embedded as "Name: body" in the payload, so a message can be up to the
// over-the-air maximum (MAX_TEXT_LEN). Size the buffers to that + NUL, otherwise
// long messages (and Polish text, where each accented char is two UTF-8 bytes)
// get their tail clipped.
static const int MSG_TEXT_BUF = MAX_TEXT_LEN + 1;
struct ChHistEntry {
uint8_t ch_idx;
char text[MSG_TEXT_BUF];
uint32_t timestamp;
uint8_t relay_status; // AckState; only PENDING/OK used (no failure for floods)
uint32_t relay_seq; // MyMesh relay seq to match against onChannelRelayed()
};
struct DmHistEntry {
uint8_t prefix[4];
uint8_t outgoing;
char text[MSG_TEXT_BUF];
uint32_t timestamp;
uint8_t ack_status; // AckState; meaningful only when outgoing
uint32_t ack_tag; // expected_ack CRC to match against onMsgAck()
uint32_t ack_deadline_ms; // millis() by which a pending ACK must arrive
// Sender-perspective message timestamp: the send timestamp for outgoing
// (reused verbatim on resend so the recipient treats it as a retry), or the
// sender_timestamp for incoming (used to dedup retried copies). 0 = unknown.
uint32_t msg_ts;
uint8_t attempt; // last attempt number sent (outgoing); next resend = attempt+1
uint8_t resends_left; // remaining auto-resends before the marker shows ✗
};
class MessageHistory {
public:
// Shared ring for all channels combined; each slot carries a full MSG_TEXT_BUF,
// so this ring dominates RAM — kept modest to leave heap headroom (see
// DM_HIST_MAX). The DM ring is likewise bounded.
static const int CH_HIST_MAX = 48;
static const int DM_HIST_MAX = 32;
MessageHistory()
: _hist_head(0), _hist_count(0), _dm_hist_head(0), _dm_hist_count(0) {
memset(_ch_unread, 0, sizeof(_ch_unread));
}
// ── Channel ring ──────────────────────────────────────────────────────────
// Append a channel message. `viewing` = the user is currently in this
// channel's history (so it isn't counted unread). `timestamp` is the sender's
// own send time (0 = unknown — use receipt time). Returns the ring position
// (an opaque handle for armChannelRelay / chAtPos), or -1 if rejected.
int addChannelMsg(uint8_t ch_idx, const char* text, bool viewing, uint32_t timestamp = 0) {
// Guard against bogus channel indices (e.g. findChannelIdx() returned -1
// and was cast to uint8_t → 255). Storing such an entry would burn a ring
// slot for a message that no visible channel can ever surface.
if (ch_idx >= MAX_GROUP_CHANNELS) return -1;
int pos;
if (_hist_count < CH_HIST_MAX) {
pos = (_hist_head + _hist_count) % CH_HIST_MAX;
_hist_count++;
} else {
pos = _hist_head;
// Evicting the oldest entry — drop its share of the unread counter so
// the badge can't claim a message the ring no longer holds.
uint8_t evicted = _hist[pos].ch_idx;
if (evicted < MAX_GROUP_CHANNELS && _ch_unread[evicted] > 0) {
_ch_unread[evicted]--;
}
_hist_head = (_hist_head + 1) % CH_HIST_MAX;
}
_hist[pos].ch_idx = ch_idx;
_hist[pos].timestamp = timestamp ? timestamp : rtc_clock.getCurrentTime();
strncpy(_hist[pos].text, text, sizeof(_hist[pos].text) - 1);
_hist[pos].text[sizeof(_hist[pos].text) - 1] = '\0';
_hist[pos].relay_status = ACK_NONE;
_hist[pos].relay_seq = 0;
if (!viewing && _ch_unread[ch_idx] < 99) _ch_unread[ch_idx]++;
return pos;
}
// count history entries for a specific channel
int histCountForChannel(int ch_idx) const {
int n = 0;
for (int i = 0; i < _hist_count; i++) {
if (_hist[(_hist_head + i) % CH_HIST_MAX].ch_idx == (uint8_t)ch_idx) n++;
}
return n;
}
// get ring-buffer position of j-th history entry for channel (newest first)
int histEntryForChannel(int ch_idx, int j) const {
int n = 0;
for (int i = _hist_count - 1; i >= 0; i--) {
int pos = (_hist_head + i) % CH_HIST_MAX;
if (_hist[pos].ch_idx == (uint8_t)ch_idx) {
if (n == j) return pos;
n++;
}
}
return -1;
}
// Called when a repeater echo of one of our channel sends is heard.
void markChannelRelayed(uint32_t seq) {
if (seq == 0) return;
for (int i = 0; i < _hist_count; i++) {
ChHistEntry& e = _hist[(_hist_head + i) % CH_HIST_MAX];
if (e.relay_status == ACK_PENDING && e.relay_seq == seq) {
e.relay_status = ACK_OK;
return;
}
}
}
// Arm the "relayed into mesh" marker on a just-sent entry (pos from
// addChannelMsg) — MyMesh tracked the flood it originated and will report a
// heard repeater echo by seq.
void armChannelRelay(int pos, uint32_t seq) {
if (pos < 0 || pos >= CH_HIST_MAX) return;
_hist[pos].relay_status = ACK_PENDING;
_hist[pos].relay_seq = seq;
}
ChHistEntry& chAtPos(int pos) { return _hist[pos]; }
const ChHistEntry& chAtPos(int pos) const { return _hist[pos]; }
// ── Per-channel unread counters ─────────────────────────────────────────────
uint8_t chUnread(int ch) const {
return (ch >= 0 && ch < MAX_GROUP_CHANNELS) ? _ch_unread[ch] : 0;
}
void setChUnread(int ch, uint8_t v) {
if (ch >= 0 && ch < MAX_GROUP_CHANNELS) _ch_unread[ch] = v;
}
void clearAllChannelUnread() { memset(_ch_unread, 0, sizeof(_ch_unread)); }
int getTotalChannelUnread() const {
int total = 0;
for (int i = 0; i < MAX_GROUP_CHANNELS; i++) total += _ch_unread[i];
return total;
}
// ── DM ring ─────────────────────────────────────────────────────────────────
// ack_tag != 0 marks an outgoing DM as awaiting an end-to-end ACK by
// ack_deadline_ms; 0 means "sent, no confirmation possible" (no path / incoming).
// msg_ts = sender-perspective timestamp (send ts for outgoing / sender_timestamp
// for incoming); resends = remaining auto-resends for an outgoing pending DM.
void storeDMMsg(const uint8_t* pub_key, bool outgoing, const char* text,
uint32_t ack_tag = 0, uint32_t ack_deadline_ms = 0,
uint32_t msg_ts = 0, uint8_t resends = 0) {
int pos;
if (_dm_hist_count < DM_HIST_MAX) {
pos = (_dm_hist_head + _dm_hist_count) % DM_HIST_MAX;
_dm_hist_count++;
} else {
pos = _dm_hist_head;
_dm_hist_head = (_dm_hist_head + 1) % DM_HIST_MAX;
}
memcpy(_dm_hist[pos].prefix, pub_key, 4);
_dm_hist[pos].outgoing = outgoing ? 1 : 0;
// Prefer the sender's own timestamp — a room-sync replay or an
// offline-queued message held by a repeater can arrive long after it was
// actually sent, so "now" would mislabel every backlog message as fresh.
// Fall back to receipt time only when the sender's timestamp is unknown.
_dm_hist[pos].timestamp = msg_ts ? msg_ts : rtc_clock.getCurrentTime();
strncpy(_dm_hist[pos].text, text, sizeof(DmHistEntry::text) - 1);
_dm_hist[pos].text[sizeof(DmHistEntry::text) - 1] = '\0';
_dm_hist[pos].ack_status = (outgoing && ack_tag) ? ACK_PENDING : ACK_NONE;
_dm_hist[pos].ack_tag = ack_tag;
_dm_hist[pos].ack_deadline_ms = ack_deadline_ms;
_dm_hist[pos].msg_ts = msg_ts;
_dm_hist[pos].attempt = 0;
_dm_hist[pos].resends_left = (outgoing && ack_tag) ? resends : 0;
}
void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text,
uint32_t sender_timestamp = 0) {
// Drop retried copies of an incoming DM: a resend reuses the sender's
// timestamp and text but carries a fresh packet hash, so the mesh dup-filter
// lets it through. Match on prefix + sender_timestamp + text to suppress it.
if (!outgoing && sender_timestamp != 0) {
for (int i = 0; i < _dm_hist_count; i++) {
const DmHistEntry& e = _dm_hist[(_dm_hist_head + i) % DM_HIST_MAX];
if (!e.outgoing && e.msg_ts == sender_timestamp &&
memcmp(e.prefix, pub_key, 4) == 0 && strcmp(e.text, text) == 0)
return; // duplicate retry — already in history
}
}
storeDMMsg(pub_key, outgoing, text, 0, 0, outgoing ? 0 : sender_timestamp, 0);
}
int dmHistCountForContact(const uint8_t* prefix) const {
int n = 0;
for (int i = 0; i < _dm_hist_count; i++)
if (memcmp(_dm_hist[(_dm_hist_head + i) % DM_HIST_MAX].prefix, prefix, 4) == 0) n++;
return n;
}
int dmHistEntryForContact(const uint8_t* prefix, int j) const { // j=0 = newest
int n = 0;
for (int i = _dm_hist_count - 1; i >= 0; i--) {
int pos = (_dm_hist_head + i) % DM_HIST_MAX;
if (memcmp(_dm_hist[pos].prefix, prefix, 4) == 0) {
if (n == j) return pos;
n++;
}
}
return -1;
}
// Effective status for display. A pending ACK only reads as failed once its
// deadline has passed AND no auto-resends remain — while resends_left > 0 the
// entry stays pending (tickDmResends() retries / finalises it). Safety net for
// when the tick hasn't run yet; the tick is the authority that writes ACK_FAIL.
AckState dmEffectiveStatus(const DmHistEntry& e) const {
if (e.ack_status == ACK_PENDING && e.resends_left == 0 &&
(int32_t)(millis() - e.ack_deadline_ms) >= 0)
return ACK_FAIL;
return (AckState)e.ack_status;
}
// Called when an end-to-end ACK arrives (routed from MyMesh::onAckRecv).
// Marks the matching pending outgoing DM as delivered.
void markDmDelivered(uint32_t ack_crc) {
if (ack_crc == 0) return;
for (int i = 0; i < _dm_hist_count; i++) {
DmHistEntry& e = _dm_hist[(_dm_hist_head + i) % DM_HIST_MAX];
if (e.outgoing && e.ack_status == ACK_PENDING && e.ack_tag == ack_crc) {
e.ack_status = ACK_OK;
return;
}
}
}
// Periodic resend driver for outgoing DMs whose ACK deadline lapsed with no
// ACK: resend with the next attempt# (reusing the original timestamp so the
// recipient dedups) while resends remain, else mark it failed (✗).
void tickDmResends() {
uint32_t now = millis();
for (int i = 0; i < _dm_hist_count; i++) {
DmHistEntry& e = _dm_hist[(_dm_hist_head + i) % DM_HIST_MAX];
if (!e.outgoing || e.ack_status != ACK_PENDING) continue;
if ((int32_t)(now - e.ack_deadline_ms) < 0) continue; // still waiting
if (e.resends_left == 0) { e.ack_status = ACK_FAIL; continue; }
ContactInfo c;
if (!contactByPrefix(e.prefix, c)) { e.ack_status = ACK_FAIL; continue; }
uint32_t expected_ack = 0, est_timeout = 0;
uint8_t next_attempt = e.attempt + 1;
if (the_mesh.sendMessage(c, e.msg_ts, next_attempt, e.text,
expected_ack, est_timeout) > 0 && expected_ack) {
e.attempt = next_attempt;
e.ack_tag = expected_ack; // each attempt has a distinct ACK CRC
e.ack_deadline_ms = now + est_timeout + 4000;
e.resends_left--;
} else {
e.ack_status = ACK_FAIL; // couldn't compose/send — give up
}
}
}
// Recent DM contacts, newest first, deduped. Resolves the 4-byte _dm_hist
// prefix to a 6-byte pub_key prefix by walking the contact list once per
// unique sender. Returns the number filled.
int getRecentDMContacts(uint8_t out[][NodePrefs::FAVOURITE_PREFIX_LEN], int max) const {
int n = 0;
for (int i = _dm_hist_count - 1; i >= 0 && n < max; i--) {
int pos = (_dm_hist_head + i) % DM_HIST_MAX;
const uint8_t* p4 = _dm_hist[pos].prefix;
// Skip if already collected.
bool dup = false;
for (int j = 0; j < n; j++) if (memcmp(out[j], p4, 4) == 0) { dup = true; break; }
if (dup) continue;
// Find a real contact whose pub_key starts with this 4-byte prefix.
for (int idx = 0; ; idx++) {
ContactInfo c;
if (!the_mesh.getContactByIdx(idx, c)) break;
if (memcmp(c.id.pub_key, p4, 4) == 0) {
memcpy(out[n], c.id.pub_key, NodePrefs::FAVOURITE_PREFIX_LEN);
n++;
break;
}
}
}
return n;
}
DmHistEntry& dmAtPos(int pos) { return _dm_hist[pos]; }
const DmHistEntry& dmAtPos(int pos) const { return _dm_hist[pos]; }
private:
// Look up a contact by 4-byte pub_key prefix (as stored in DmHistEntry).
bool contactByPrefix(const uint8_t* prefix, ContactInfo& out) const {
int total = the_mesh.getNumContacts();
for (int i = 0; i < total; i++) {
ContactInfo c;
if (!the_mesh.getContactByIdx(i, c)) continue;
if (memcmp(c.id.pub_key, prefix, 4) == 0) { out = c; return true; }
}
return false;
}
ChHistEntry _hist[CH_HIST_MAX];
int _hist_head, _hist_count;
uint8_t _ch_unread[MAX_GROUP_CHANNELS];
DmHistEntry _dm_hist[DM_HIST_MAX];
int _dm_hist_head, _dm_hist_count;
};
+13 -26
View File
@@ -1,12 +1,7 @@
#pragma once
#include <math.h>
#include "../GeoUtils.h"
#include "NavView.h"
#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif
// ── Nearby Nodes ──────────────────────────────────────────────────────────────
// One list / detail / action-menu interaction path over two sources:
// SRC_STORED — contacts known to the mesh (distance / bearing / last-heard)
@@ -111,14 +106,13 @@ class NearbyScreen : public UIScreen {
bool useImperial() const { return _task && _task->useImperial(); }
// Full "X ago" form for the detail view, built on the shared short-age tag
// so there's one bucket ladder (see geo::fmtAgeShort).
static void fmtAge(char* buf, int n, uint32_t lastmod) {
uint32_t now = rtc_clock.getCurrentTime();
if (now < lastmod || lastmod == 0) { snprintf(buf, n, "unknown"); return; }
uint32_t age = now - lastmod;
if (age < 60) snprintf(buf, n, "%us ago", age);
else if (age < 3600) snprintf(buf, n, "%um ago", age / 60);
else if (age < 86400) snprintf(buf, n, "%uh ago", age / 3600);
else snprintf(buf, n, ">1d ago");
char s[8];
geo::fmtAgeShort(s, sizeof(s), rtc_clock.getCurrentTime(), lastmod);
if (!s[0]) { snprintf(buf, n, "unknown"); return; }
snprintf(buf, n, "%s ago", s);
}
static const char* typeName(uint8_t t) {
@@ -593,7 +587,7 @@ public:
_sort_label[0] = '\0';
}
void enter() {
void onShow() override {
_sel = _scroll = 0;
_detail = false;
_nav = false;
@@ -692,7 +686,7 @@ public:
drawList(display, _count, _sel, _scroll, [&](int idx, int y, bool sel, int reserve) {
const Entry& e = _entries[idx];
display.drawSelectionRow(0, y - 1, display.width() - reserve, item_h - 1, sel);
drawRowSelection(display, y, sel, reserve);
char filt[32];
int tx = 2;
@@ -715,20 +709,13 @@ public:
if (_source == SRC_SCAN) {
snprintf(right, sizeof(right), "%d", (int)e.rssi);
} else if (_sort == SORT_TIME) {
uint32_t now = rtc_clock.getCurrentTime();
if (e.lastmod == 0 || now < e.lastmod) snprintf(right, sizeof(right), "?");
else {
uint32_t age = now - e.lastmod;
if (age < 60) snprintf(right, sizeof(right), "%us", age);
else if (age < 3600) snprintf(right, sizeof(right), "%um", age / 60);
else snprintf(right, sizeof(right), "%uh", age / 3600);
}
geo::fmtAgeShort(right, sizeof(right), rtc_clock.getCurrentTime(), e.lastmod);
if (!right[0]) snprintf(right, sizeof(right), "?"); // unknown / RTC not synced
} else {
if (e.dist_km >= 0.0f) geo::fmtDist(right, sizeof(right), e.dist_km, useImperial());
else strncpy(right, "?GPS", sizeof(right));
}
display.setCursor(display.width() - display.getTextWidth(right) - 2 - reserve, y);
display.print(right);
display.drawTextRightAlign(display.width() - reserve - 2, y, right);
});
}
@@ -791,8 +778,8 @@ public:
_detail_refresh_ms = millis();
return true;
}
if (c == KEY_LEFT) { _filter = (_filter + F_COUNT - 1) % F_COUNT; refresh(); return true; }
if (c == KEY_RIGHT) { _filter = (_filter + 1) % F_COUNT; refresh(); return true; }
if (keyIsPrev(c)) { _filter = (_filter + F_COUNT - 1) % F_COUNT; refresh(); return true; }
if (keyIsNext(c)) { _filter = (_filter + 1) % F_COUNT; refresh(); return true; }
return false;
}
};
File diff suppressed because it is too large Load Diff
@@ -128,7 +128,7 @@ class RepeaterScreen : public UIScreen {
public:
RepeaterScreen(UITask* task) : _task(task), _dirty(false), _sel(0), _scroll(0), _item_count(1) {}
void enter() {
void onShow() override {
_dirty = false; _sel = 0; _scroll = 0;
_picker.menu.active = false; _editor.freq.active = false;
_picker.saving = false; _picker.deleting = false;
@@ -143,39 +143,21 @@ public:
display.setColor(DisplayDriver::LIGHT);
display.drawCenteredHeader("REPEATER");
const int item_h = display.lineStep();
const int start_y = display.listStart();
int visible = display.listVisible(item_h);
if (visible < 1) visible = 1;
// Config only — live forwarding stats live on Tools Diagnostics.
int total = _item_count;
if (_sel < _scroll) _scroll = _sel;
if (_sel >= _scroll + visible) _scroll = _sel - visible + 1;
int max_scroll = total - visible;
if (max_scroll < 0) max_scroll = 0;
if (_scroll > max_scroll) _scroll = max_scroll;
if (_scroll < 0) _scroll = 0;
const int reserve = scrollIndicatorReserve(display, total, visible);
for (int i = 0; i < visible && (_scroll + i) < total; i++) {
int row = _scroll + i;
int y = start_y + i * item_h;
char val[16];
bool sel = (row == _sel);
drawList(display, _item_count, _sel, _scroll, [&](int row, int y, bool sel, int reserve) {
int item = _items[row];
display.drawSelectionRow(0, y - 1, display.width() - reserve, item_h - 1, sel);
drawRowSelection(display, y, sel, reserve);
display.setCursor(2, y);
display.print(itemLabel(item));
if (item == IT_RFREQ && sel && _editor.active()) {
_editor.render(display, display.valCol(), y);
} else {
char val[16];
itemValue(item, p, val, sizeof(val));
display.drawTextRightAlign(display.width() - reserve - 2, y, val);
}
display.setColor(DisplayDriver::LIGHT);
}
drawScrollIndicator(display, start_y, visible * item_h, total, visible, _scroll);
});
display.setColor(DisplayDriver::LIGHT);
if (_picker.menu.active) _picker.menu.render(display);
return (_picker.menu.active || _editor.active()) ? 50 : 500;
@@ -229,7 +211,7 @@ public:
}
if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) {
if (_dirty) the_mesh.savePrefs();
_task->savePrefsIfDirty(_dirty);
_task->gotoToolsScreen();
return true;
}
@@ -57,7 +57,9 @@ class RingtoneEditorScreen : public UIScreen {
public:
RingtoneEditorScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs), _slot(0) {}
void enter(int slot = 0) {
// Load a melody slot for editing. Called by gotoRingtoneEditor() right after
// setCurrScreen() (whose onShow() can't carry the slot argument).
void selectSlot(int slot = 0) {
_slot = (slot == 1) ? 1 : 0;
bool s2 = (_slot == 1);
_bpm_idx = (_prefs && (s2 ? _prefs->ringtone2_bpm_idx : _prefs->ringtone_bpm_idx) < 5)
@@ -164,8 +166,8 @@ public:
bool handleInput(char c) override {
bool up = (c == KEY_UP);
bool down = (c == KEY_DOWN);
bool left = (c == KEY_LEFT || c == KEY_PREV);
bool right = (c == KEY_RIGHT || c == KEY_NEXT);
bool left = keyIsPrev(c);
bool right = keyIsNext(c);
bool enter = (c == KEY_ENTER);
bool menu_key = (c == KEY_CONTEXT_MENU);
bool cancel = (c == KEY_CANCEL);
@@ -197,7 +199,7 @@ public:
break;
case MI_SWITCH:
_task->stopMelody();
this->enter(1 - _slot);
this->selectSlot(1 - _slot);
break;
case MI_DURATION: break; // already handled by LEFT/RIGHT
case MI_BPM: break; // already handled by LEFT/RIGHT
@@ -422,7 +422,7 @@ class SettingsScreen : public UIScreen {
void renderItem(DisplayDriver& display, int item, int y, bool sel) {
NodePrefs* p = _task->getNodePrefs();
display.drawSelectionRow(0, y - 1, display.width() - _reserve, display.lineStep() - 1, sel);
drawRowSelection(display, y, sel, _reserve);
display.setCursor(2, y);
@@ -651,7 +651,7 @@ public:
}
void markClean() {
void onShow() override {
_dirty = false;
resetList();
_editor.freq.active = false;
@@ -671,7 +671,7 @@ public:
[&](int sec, int y, bool sel, int reserve, bool collapsed) {
_reserve = reserve;
display.setColor(DisplayDriver::LIGHT);
display.drawSelectionRow(0, y - 1, display.width() - reserve, display.lineStep() - 1, sel);
drawRowSelection(display, y, sel, reserve);
display.setCursor(2, y);
display.print(collapsed ? "+" : "-");
display.print(" ");
@@ -754,7 +754,7 @@ public:
}
if (c == KEY_CANCEL) {
if (_dirty) the_mesh.savePrefs();
_task->savePrefsIfDirty(_dirty);
_task->gotoHomeScreen();
return true;
}
@@ -58,7 +58,7 @@ public:
ToolsScreen(UITask* task) : _task(task) {}
// Open folded at the section list each time Tools is entered from Home.
void enter() {
void onShow() override {
static uint8_t sizes[SECTION_COUNT];
for (int i = 0; i < SECTION_COUNT; i++) sizes[i] = SECTIONS[i].count;
_acc.begin(sizes, SECTION_COUNT);
@@ -75,7 +75,7 @@ public:
_acc.render(display,
// Section header: "[+/-] <icon> Name"
[&](int sec, int y, bool sel, int reserve, bool collapsed) {
display.drawSelectionRow(0, y - 1, display.width() - reserve, display.lineStep() - 1, sel);
drawRowSelection(display, y, sel, reserve);
display.setCursor(2, y);
display.print(collapsed ? "+" : "-");
const int icon_x = 2 + cw + 2;
@@ -85,7 +85,7 @@ public:
},
// Item: indented "<icon> Label"
[&](int sec, int item, int y, bool sel, int reserve) {
display.drawSelectionRow(0, y - 1, display.width() - reserve, display.lineStep() - 1, sel);
drawRowSelection(display, y, sel, reserve);
const int icon_x = 2 + cw + 2; // align item icons under the header icon
// drawIcon(display, icon_x, y, SECTIONS[sec].tools[item].icon); // icons disabled for now, don't fit visually
display.setCursor(icon_x + g, y);
+65 -15
View File
@@ -72,14 +72,14 @@ class TrailScreen : public UIScreen {
// (Start/Stop tracking, Reset). PopupMenu stores label pointers verbatim, so
// the labels live in member buffers that get refreshed in openActionMenu()
// and after every LEFT/RIGHT cycle.
enum ActionId { ACT_MIN_DIST, ACT_UNITS, ACT_GRID, ACT_AUTOPAUSE, ACT_TOGGLE, ACT_SAVE, ACT_LOAD, ACT_RESET, ACT_EXPORT, ACT_EXPORT_SAVED, ACT_MARK, ACT_WAYPOINTS, ACT_FILE, ACT_SETTINGS,
ACT_SHARE_NOW };
enum ActionId { ACT_MIN_DIST, ACT_UNITS, ACT_GRID, ACT_AUTOPAUSE, ACT_MARK_AVG, ACT_TOGGLE, ACT_SAVE, ACT_LOAD, ACT_RESET, ACT_EXPORT, ACT_EXPORT_SAVED, ACT_MARK, ACT_WAYPOINTS, ACT_FILE, ACT_SETTINGS,
ACT_SHARE_NOW, ACT_TRACKBACK };
// The action popup is multi-level: a short main menu, plus "Trail file…" and
// "Settings…" submenus. _menu_level tracks which is open so input is routed
// correctly (settings rows cycle with LEFT/RIGHT; everything else is Enter).
// Live-share *config* lives in its own tool (Tools Live Share); the map only
// keeps the one-shot "Share my pos" action.
enum MenuLevel { ML_MAIN, ML_FILE, ML_SETTINGS };
enum MenuLevel { ML_MAIN, ML_FILE, ML_SETTINGS, ML_CONFIRM_GPS };
PopupMenu _action_menu;
uint8_t _menu_level = ML_MAIN;
uint8_t _act_map[16]; // max rows on any one level; pushAction guards the cap
@@ -88,6 +88,7 @@ class TrailScreen : public UIScreen {
char _act_units_label[24];
char _act_grid_label[16];
char _act_autopause_label[24];
char _act_mark_avg_label[24];
char _act_toggle_label[20];
static const int SUMMARY_ITEM_COUNT = 5;
@@ -96,7 +97,7 @@ public:
TrailScreen(UITask* task, TrailStore* store)
: _task(task), _store(store), _wp(task, store) {}
void enter() {
void onShow() override {
_view = V_SUMMARY;
_summary_scroll = 0;
_list_scroll = 0;
@@ -107,9 +108,10 @@ public:
_wp.reset();
}
// Enter straight into the Map view (used by the Home "Map" page). Remembers
// that we came from Home so KEY_CANCEL returns there, not to Tools.
void enterMap() { enter(); _view = V_MAP; _return_home = true; }
// Switch straight into the Map view (used by the Home "Map" page). Layered on
// top of onShow()'s reset by gotoMapScreen(); remembers we came from Home so
// KEY_CANCEL returns there, not to Tools.
void showMapView() { _view = V_MAP; _return_home = true; }
int render(DisplayDriver& display) override {
display.setTextSize(1);
@@ -135,6 +137,10 @@ public:
return _store->isActive() ? 1000 : 5000;
}
// Forward the loop tick to the waypoint UI so GPS averaging (Mark avg) can
// sample independently of the render cadence (slow on e-ink).
void poll() override { _wp.poll(); }
bool handleInput(char c) override {
// Waypoint management UI consumes all input while active.
if (_wp.active()) return _wp.handleInput(c);
@@ -143,8 +149,8 @@ public:
if (_action_menu.active) {
// LEFT/RIGHT cycles the focused value — only meaningful in the Settings
// submenu; the popup stays open so the user can keep tapping.
if (c == KEY_LEFT || c == KEY_RIGHT || c == KEY_PREV || c == KEY_NEXT) {
int dir = (c == KEY_RIGHT || c == KEY_NEXT) ? 1 : -1;
if (keyIsPrev(c) || keyIsNext(c)) {
int dir = keyIsNext(c) ? 1 : -1;
int idx = _action_menu.selectedIndex();
if (idx >= 0 && idx < _act_count && _menu_level == ML_SETTINGS)
cycleSetting((ActionId)_act_map[idx], dir);
@@ -152,6 +158,16 @@ public:
}
auto res = _action_menu.handleInput(c);
if (res == PopupMenu::SELECTED) {
// GPS-off confirmation popup: rows aren't ActionIds, route by level.
if (_menu_level == ML_CONFIRM_GPS) {
if (_action_menu.selectedIndex() == 0) { // "Enable GPS & start"
_task->toggleGPS(); // off → on (persists, shows GPS alert)
_store->setActive(true);
_task->showAlert("GPS on, tracking started", 1200);
}
_menu_level = ML_MAIN; // popup already closed by handleInput()
return true;
}
int sel = _action_menu.selectedIndex();
ActionId act = (sel >= 0 && sel < _act_count) ? (ActionId)_act_map[sel] : ACT_TOGGLE;
switch (act) {
@@ -161,35 +177,46 @@ public:
case ACT_MIN_DIST:
case ACT_UNITS:
case ACT_GRID:
case ACT_MARK_AVG:
case ACT_AUTOPAUSE: cycleSetting(act, 1); reopenSettingsAt(sel); return true;
case ACT_SHARE_NOW: shareMyLocationNow(); break;
case ACT_TOGGLE: handleToggle(); break;
case ACT_TOGGLE:
// Starting a trail with GPS switched off logs nothing and just
// sits on "Waiting for GPS fix" forever -- prompt to enable it
// first (only on boards that actually have a toggleable GPS).
if (!_store->isActive() && _task->hasGPS() && !_task->getGPSState()) {
buildGpsConfirmMenu();
return true;
}
handleToggle();
break;
case ACT_MARK: _wp.markHere(); break;
case ACT_WAYPOINTS: _wp.openList(); break;
case ACT_TRACKBACK: _wp.startTrackBack(); break;
case ACT_SAVE: handleSave(); break;
case ACT_LOAD: handleLoad(); break;
case ACT_RESET: handleReset(); break;
case ACT_EXPORT: handleExport(); break;
case ACT_EXPORT_SAVED: handleExportSaved(); break;
}
if (_cfg_dirty) { the_mesh.savePrefs(); _cfg_dirty = false; }
_task->savePrefsIfDirty(_cfg_dirty);
} else if (res == PopupMenu::CANCELLED) {
if (_cfg_dirty) { the_mesh.savePrefs(); _cfg_dirty = false; }
_task->savePrefsIfDirty(_cfg_dirty);
if (_menu_level != ML_MAIN) buildMainMenu(); // Cancel backs out of a submenu
}
return true;
}
if (c == KEY_CANCEL) {
if (_cfg_dirty) { the_mesh.savePrefs(); _cfg_dirty = false; }
_task->savePrefsIfDirty(_cfg_dirty);
if (_return_home) _task->gotoHomeScreen();
else _task->gotoToolsScreen();
return true;
}
if (c == KEY_CONTEXT_MENU) { openActionMenu(); return true; }
if (c == KEY_LEFT || c == KEY_PREV) { _view = (uint8_t)((_view + V_COUNT - 1) % V_COUNT); return true; }
if (c == KEY_RIGHT || c == KEY_NEXT) { _view = (uint8_t)((_view + 1) % V_COUNT); return true; }
if (keyIsPrev(c)) { _view = (uint8_t)((_view + V_COUNT - 1) % V_COUNT); return true; }
if (keyIsNext(c)) { _view = (uint8_t)((_view + 1) % V_COUNT); return true; }
if (_view == V_SUMMARY && c == KEY_UP) {
_summary_scroll = (_summary_scroll > 0) ? _summary_scroll - 1 : _summary_max_scroll;
return true;
@@ -298,6 +325,8 @@ private:
"Grid: %s", _map_grid ? "ON" : "OFF");
snprintf(_act_autopause_label, sizeof(_act_autopause_label),
"Auto-pause: %s", NodePrefs::trailAutoPauseLabel(p ? p->trail_autopause_idx : 0));
snprintf(_act_mark_avg_label, sizeof(_act_mark_avg_label),
"Mark avg: %s", NodePrefs::gpsAvgLabel(p ? p->gps_avg_idx : 0));
snprintf(_act_toggle_label, sizeof(_act_toggle_label),
"%s tracking", _store->isActive() ? "Stop" : "Start");
}
@@ -325,11 +354,23 @@ private:
// "Waypoints" opens the nav list — always available; it hosts the list,
// backtrack (Trail-start row) and the "+ Add by coords" entry.
pushAction(ACT_WAYPOINTS, "Waypoints...");
// Track back retraces the recorded route to its start; needs ≥2 points.
if (_store->count() >= 2) pushAction(ACT_TRACKBACK, "Track back");
pushAction(ACT_SHARE_NOW, "Share my pos");
if (fileMenuHasItems()) pushAction(ACT_FILE, "Trail file...");
pushAction(ACT_SETTINGS, "Settings...");
}
// Confirmation shown when "Start tracking" is chosen with GPS off. Rows are
// plain (not ActionIds); handled by _menu_level in the SELECTED branch.
void buildGpsConfirmMenu() {
_menu_level = ML_CONFIRM_GPS;
_act_count = 0;
_action_menu.begin("GPS is off", 2);
_action_menu.addItem("Enable GPS & start");
_action_menu.addItem("Cancel");
}
// Trail-file submenu — only the operations that make sense right now.
void buildFileMenu() {
_menu_level = ML_FILE;
@@ -352,6 +393,7 @@ private:
_action_menu.begin("Settings", 4);
pushAction(ACT_MIN_DIST, _act_min_dist_label);
pushAction(ACT_AUTOPAUSE, _act_autopause_label);
pushAction(ACT_MARK_AVG, _act_mark_avg_label);
if (_view == V_SUMMARY) pushAction(ACT_UNITS, _act_units_label);
if (_view == V_MAP) pushAction(ACT_GRID, _act_grid_label);
}
@@ -363,6 +405,7 @@ private:
if (act == ACT_UNITS && p) { cycleUnits(p, dir); _cfg_dirty = true; refreshActionLabels(); return true; }
if (act == ACT_GRID) { _map_grid = !_map_grid; refreshActionLabels(); return true; }
if (act == ACT_AUTOPAUSE && p){ cycleAutoPause(p, dir); _cfg_dirty = true; refreshActionLabels(); return true; }
if (act == ACT_MARK_AVG && p){ cycleMarkAvg(p, dir); _cfg_dirty = true; refreshActionLabels(); return true; }
return false;
}
@@ -405,6 +448,13 @@ private:
: (idx + NodePrefs::TRAIL_AUTOPAUSE_COUNT - 1) % NodePrefs::TRAIL_AUTOPAUSE_COUNT;
p->trail_autopause_idx = idx;
}
void cycleMarkAvg(NodePrefs* p, int dir) {
uint8_t idx = p->gps_avg_idx;
if (idx >= NodePrefs::GPS_AVG_COUNT) idx = 0;
idx = (dir > 0) ? (idx + 1) % NodePrefs::GPS_AVG_COUNT
: (idx + NodePrefs::GPS_AVG_COUNT - 1) % NodePrefs::GPS_AVG_COUNT;
p->gps_avg_idx = idx;
}
// One-shot manual share: build "[LOC]lat,lon" and hand it to the Messages
// screen, where the user picks a DM or channel recipient. (Auto live-share
+253 -59
View File
@@ -116,9 +116,21 @@ public:
static const int QUICK_MSGS_MAX = 10;
// ── Screen fragments — included into THIS translation unit only ───────────────
// These headers are not standalone: they are compiled solely as part of
// UITask.cpp, in the order below. Two consequences a new screen must respect:
// • Order matters. A `static inline` helper (drawList, msgReplyBody, geo::…)
// or a shared scratch buffer (FullscreenMsgView's s_wrap_*) is only visible
// to fragments included *after* the one that defines it. Add new screens
// after their dependencies.
// • Single-TU only. Some fragments define external-linkage symbols at file
// scope (e.g. NearbyScreen::FILTER_LABELS), so including any of them from a
// second .cpp is a duplicate-symbol link error. Keep them UITask-internal;
// anything genuinely shareable belongs in a real header (icons.h, GeoUtils.h).
#include "FullscreenMsgView.h"
#include "SensorPlaceholders.h"
#include "SettingsScreen.h"
#include "MessageHistory.h" // RAM history rings (DM + channel) used by QuickMsgScreen
#include "QuickMsgScreen.h"
// ── Custom screens (separate files to ease upstream merges) ───────────────────
@@ -134,6 +146,7 @@ static const int QUICK_MSGS_MAX = 10;
#include "DiagnosticsScreen.h"
#include "RepeaterScreen.h"
#include "ToolsScreen.h"
#include "ClockToolsScreen.h" // Alarm / Timer / Stopwatch (Clock page Enter)
#ifndef BATT_MIN_MILLIVOLTS
#define BATT_MIN_MILLIVOLTS 3200
@@ -462,6 +475,13 @@ class HomeScreen : public UIScreen {
}
#endif
// Alarm armed — a persistent status cue (like mute), always shown (no blink).
if (_node_prefs && _node_prefs->alarm_on) {
int alX = battLeftX - ind - ind_gap;
drawBoxedIcon(display, alX, ind, ind_h, ICON_ALARM);
battLeftX = alX;
}
// BT connection indicator (left of muted/battery icons)
int leftmostX = battLeftX;
if (_task->isSerialEnabled()) {
@@ -791,6 +811,16 @@ public:
snprintf(buf, sizeof(buf),"%s %d %s %d", wd[ti->tm_wday], ti->tm_mday, mo[ti->tm_mon], 1900 + ti->tm_year);
display.drawTextCentered(display.width() / 2, date_y, buf);
// Alarm armed: a small bell in the top-left corner. The status bar (and
// its bell) is hidden on this page, so signal the armed alarm here. Just
// the glyph — no time text — so it stays clear of the centred clock
// digits (which can reach the corner when seconds are shown), matching
// the icon-only status-bar indicator. The exact time is in Clock Tools.
if (_node_prefs && _node_prefs->alarm_on) {
display.setColor(DisplayDriver::LIGHT);
miniIconDrawTop(display, 0, 0, ICON_ALARM);
}
int sep_y = date_y + lh + 1;
int dash0 = sep_y + display.sepH() + 2;
display.fillRect(0, sep_y, display.width(), display.sepH());
@@ -1372,6 +1402,10 @@ public:
_shutdown_init = true; // need to wait for button to be released
return true;
}
if (c == KEY_ENTER && _page == HomePage::CLOCK) {
_task->gotoClockTools(); // Alarm / Timer / Stopwatch
return true;
}
if (c == KEY_CONTEXT_MENU && _page == HomePage::CLOCK) {
_task->gotoDashboardConfig();
return true;
@@ -1470,6 +1504,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
compass_screen = new CompassScreen(this);
diag_screen = new DiagnosticsScreen(this);
repeater_screen = new RepeaterScreen(this);
clock_tools = new ClockToolsScreen(this, node_prefs);
applyBrightness();
applyFont();
applyRotation();
@@ -1477,74 +1512,110 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
setCurrScreen(splash);
}
void UITask::gotoSettingsScreen() {
((SettingsScreen*)settings)->markClean();
setCurrScreen(settings);
// onShow() is invoked by setCurrScreen(), so most navigators are just that.
void UITask::gotoSettingsScreen() { setCurrScreen(settings); }
void UITask::gotoToolsScreen() { setCurrScreen(tools_screen); }
void UITask::gotoBotScreen() { setCurrScreen(bot_screen); }
void UITask::gotoNearbyScreen() { setCurrScreen(nearby_screen); }
void UITask::gotoDashboardConfig() { setCurrScreen(dashboard_config); }
void UITask::gotoTrailScreen() { setCurrScreen(trail_screen); }
void UITask::gotoCompassScreen() { setCurrScreen(compass_screen); }
void UITask::gotoDiagnosticsScreen() { setCurrScreen(diag_screen); }
void UITask::gotoRepeaterScreen() { setCurrScreen(repeater_screen); }
void UITask::gotoClockTools() { setCurrScreen(clock_tools); }
void UITask::gotoLiveShareScreen() { setCurrScreen(live_share_screen); }
void UITask::wakeForAlarm() {
if (_display != NULL) _display->turnOn();
_next_refresh = 0; // draw the alert overlay immediately
}
void UITask::gotoToolsScreen() {
((ToolsScreen*)tools_screen)->enter();
setCurrScreen(tools_screen);
// ── Clock tools engine (alarm / countdown / ring) ───────────────────────────
// Lives here, not in ClockToolsScreen, so it fires regardless of the current
// screen. The melody overrides mute (playMelody → buzzer.playForced); the ring
// auto-stops after CLOCK_RING_MS if no key dismisses it (see UITask::loop).
static const char* CLOCK_ALARM_MELODY = "alarm:d=8,o=6,b=125:c,c,c,c,p,c,c,c,c,p";
static const uint32_t CLOCK_RING_MS = 60000;
static const uint32_t CLOCK_ALARM_CATCHUP_SECS = 6 * 3600; // fire late up to 6 h, else reschedule
// Next absolute wall instant matching alarm_hour:alarm_min in local time,
// strictly after now_wall (an alarm set to the current minute waits a day).
uint32_t UITask::computeAlarmNextFire(uint32_t now_wall) const {
int tz = _node_prefs ? _node_prefs->tz_offset_hours : 0;
int64_t now_local = (int64_t)now_wall + (int64_t)tz * 3600;
time_t t = (time_t)now_local;
struct tm* ti = gmtime(&t);
int64_t sod = ti->tm_hour * 3600 + ti->tm_min * 60 + ti->tm_sec; // secs since local midnight
int64_t target = (now_local - sod)
+ (int64_t)_node_prefs->alarm_hour * 3600 + (int64_t)_node_prefs->alarm_min * 60;
if (target <= now_local) target += 86400;
return (uint32_t)(target - (int64_t)tz * 3600);
}
void UITask::fireClockAlert(const char* label) {
snprintf(_ring_label, sizeof(_ring_label), "%s", label);
_ringing = true;
_ring_until_ms = millis() + CLOCK_RING_MS;
wakeForAlarm();
showAlert(label, CLOCK_RING_MS);
playMelody(CLOCK_ALARM_MELODY);
}
void UITask::evaluateAlarm() {
if (!_node_prefs || !_node_prefs->alarm_on) return;
uint32_t now_ms = millis();
if (now_ms - _alarm_check_ms < 500) return; // ~2 Hz is plenty for a minute alarm
_alarm_check_ms = now_ms;
uint32_t now_wall = rtc_clock.getCurrentTime();
if (now_wall < 1000000000UL) return; // need a real time sync first
if (_alarm_next_fire == 0) _alarm_next_fire = computeAlarmNextFire(now_wall);
if (now_wall < _alarm_next_fire) return;
if (now_wall - _alarm_next_fire < CLOCK_ALARM_CATCHUP_SECS) {
char lbl[20];
snprintf(lbl, sizeof(lbl), "Alarm %02d:%02d", _node_prefs->alarm_hour, _node_prefs->alarm_min);
_node_prefs->alarm_on = 0; // one-shot
bool dirty = true; savePrefsIfDirty(dirty);
_alarm_next_fire = 0;
fireClockAlert(lbl);
} else {
// Clock jumped implausibly far past the target — reschedule rather than
// ringing absurdly late.
_alarm_next_fire = computeAlarmNextFire(now_wall);
}
}
void UITask::tickClockTools() {
uint32_t now_ms = millis();
// Ring maintenance: repeat the melody until dismissed or the window elapses.
if (_ringing) {
if (now_ms >= _ring_until_ms) { stopMelody(); _ringing = false; clearAlert(); }
else if (!isMelodyPlaying()) playMelody(CLOCK_ALARM_MELODY);
}
// Countdown timer (millis — sync-immune).
if (_timer_running && now_ms >= _timer_deadline_ms) {
_timer_running = false;
fireClockAlert("Timer done");
}
// Alarm (wall clock — absolute schedule for sync robustness).
evaluateAlarm();
}
// Ringtone takes a slot argument that onShow() can't carry — pass it after the
// reset (setCurrScreen's onShow runs first, then this layers the slot on top).
void UITask::gotoRingtoneEditor(int slot) {
((RingtoneEditorScreen*)ringtone_edit)->enter(slot);
setCurrScreen(ringtone_edit);
((RingtoneEditorScreen*)ringtone_edit)->selectSlot(slot);
}
void UITask::gotoBotScreen() {
((BotScreen*)bot_screen)->enter();
setCurrScreen(bot_screen);
}
void UITask::gotoNearbyScreen() {
((NearbyScreen*)nearby_screen)->enter();
setCurrScreen(nearby_screen);
}
void UITask::gotoDashboardConfig() {
((DashboardConfigScreen*)dashboard_config)->enter();
setCurrScreen(dashboard_config);
}
void UITask::gotoTrailScreen() {
((TrailScreen*)trail_screen)->enter();
setCurrScreen(trail_screen);
}
// Map is a sub-view variant of the Trail screen: reset via onShow(), then
// switch into the map view.
void UITask::gotoMapScreen() {
((TrailScreen*)trail_screen)->enterMap();
setCurrScreen(trail_screen);
((TrailScreen*)trail_screen)->showMapView();
}
void UITask::gotoCompassScreen() {
((CompassScreen*)compass_screen)->enter();
setCurrScreen(compass_screen);
}
void UITask::gotoDiagnosticsScreen() {
setCurrScreen(diag_screen);
}
void UITask::gotoRepeaterScreen() {
((RepeaterScreen*)repeater_screen)->enter();
setCurrScreen(repeater_screen);
}
void UITask::gotoLiveShareScreen() {
((LiveShareScreen*)live_share_screen)->enter();
setCurrScreen(live_share_screen);
}
void UITask::gotoLocatorScreen() {
((LocatorScreen*)locator_screen)->enter();
setCurrScreen(locator_screen);
}
void UITask::gotoAutoAdvertScreen() {
((AutoAdvertScreen*)auto_advert_screen)->enter();
setCurrScreen(auto_advert_screen);
}
void UITask::gotoLocatorScreen() { setCurrScreen(locator_screen); }
void UITask::gotoAutoAdvertScreen() { setCurrScreen(auto_advert_screen); }
// Public method to handle ping result callback
void UITask::handlePingResult(uint32_t tag, int16_t snr_out_x4, int16_t snr_back_x4, uint32_t rtt_ms) {
@@ -1643,9 +1714,9 @@ int UITask::getRecentDMContacts(uint8_t out[][NodePrefs::FAVOURITE_PREFIX_LEN],
return ((QuickMsgScreen*)quick_msg)->getRecentDMContacts(out, max);
}
void UITask::addChannelMsg(uint8_t channel_idx, const char* text) {
void UITask::addChannelMsg(uint8_t channel_idx, const char* text, uint32_t timestamp) {
_last_notif_ch_idx = (int)channel_idx;
((QuickMsgScreen*)quick_msg)->addChannelMsg(channel_idx, text);
((QuickMsgScreen*)quick_msg)->addChannelMsg(channel_idx, text, timestamp);
}
int UITask::getChannelUnreadCount() const {
@@ -1660,6 +1731,15 @@ void UITask::onChannelRelayed(uint32_t seq) {
((QuickMsgScreen*)quick_msg)->markChannelRelayed(seq);
}
void UITask::onRoomLoginResult(const uint8_t* pub_key, bool success, uint8_t permissions) {
((QuickMsgScreen*)quick_msg)->onRoomLoginResult(pub_key, success, permissions);
// Unlike the keypress-driven showAlert() calls elsewhere, this fires from a
// background mesh response with no keypress to schedule a redraw — without
// forcing one, the alert's short expiry can lapse before the next scheduled
// refresh ever draws it.
_next_refresh = 0;
}
void UITask::addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text, uint32_t sender_timestamp) {
((QuickMsgScreen*)quick_msg)->addDMMsg(pub_key, outgoing, text, sender_timestamp);
}
@@ -1783,10 +1863,23 @@ void UITask::userLedHandler() {
}
void UITask::setCurrScreen(UIScreen* c) {
// Fail safe on a null target: a screen pointer left uninitialised (member
// declared + navigator wired, but the `new XScreen()` line forgotten in
// begin()) stays nullptr thanks to the in-class initialisers. Bail here so
// that mistake is an inert no-op instead of a null deref in render()/poll().
if (!c) return;
curr = c;
c->onShow(); // central per-visit reset hook (see UIScreen::onShow)
_next_refresh = 100;
}
bool UITask::savePrefsIfDirty(bool& dirty) {
if (!dirty) return false;
the_mesh.savePrefs();
dirty = false;
return true;
}
/*
hardware-agnostic pre-shutdown activity should be done here
*/
@@ -2015,6 +2108,14 @@ void UITask::loop() {
}
#endif
// A ringing alarm/timer is dismissed by ANY key, even when locked or on another
// screen — and the queued keys are swallowed so they don't also act on the view.
if (_kq_head != _kq_tail && isRinging()) {
dismissRing();
_kq_head = _kq_tail = 0;
_next_refresh = 0;
}
if (_kq_head != _kq_tail) {
if (!_locked && curr) {
// Apply the whole queued burst, then redraw once — N taps captured during
@@ -2048,6 +2149,10 @@ void UITask::loop() {
if (curr) curr->poll();
// Alarm + countdown run regardless of the current screen / display state, so
// they're driven here (not via the current screen's poll()).
tickClockTools();
if (_display != NULL && _display->isOn()) {
if (_locked && millis() > _lock_wake_until) {
_display->turnOff();
@@ -2157,7 +2262,7 @@ void UITask::loop() {
_auto_off = millis() + AUTO_OFF_MILLIS;
}
#endif
if (!_locked && autoOffMillis() > 0 && millis() > _auto_off) {
if (!_locked && autoOffMillis() > 0 && millis() > _auto_off && !isRinging()) {
_display->turnOff();
#ifdef PIN_LED
digitalWrite(PIN_LED, LOW); // turn off status LED with display to save power
@@ -2410,6 +2515,85 @@ void UITask::clearTarget() {
resetLocator();
}
void UITask::clearTargetIfWaypoint(int32_t lat_1e6, int32_t lon_1e6) {
if (!_node_prefs || !_node_prefs->locator_has_target || _node_prefs->locator_target_kind != 0) return;
if (_node_prefs->locator_lat_1e6 != lat_1e6 || _node_prefs->locator_lon_1e6 != lon_1e6) return;
clearTarget();
the_mesh.savePrefs();
}
// CONTRACT: every NodePrefs field that keys on a contact pubkey/prefix is
// cleared here, so a removed contact can't leave a dangling reference. If you
// add such a field, add its cleanup below (and mark the field in NodePrefs.h).
// Currently covered: favourite_contacts, locator_key, loc_share_dm_prefix,
// dm_notif[], dm_melody[]. Called for both explicit removal and silent
// auto-eviction (see MyMesh CMD_REMOVE_CONTACT / onContactOverwrite).
void UITask::onContactRemoved(const uint8_t* pub_key) {
if (!_node_prefs || !pub_key) return;
bool changed = false;
int slot = findFavouriteSlot(pub_key);
if (slot >= 0) { clearFavouriteSlot(slot); changed = true; }
if (_node_prefs->locator_has_target && _node_prefs->locator_target_kind == 1
&& memcmp(_node_prefs->locator_key, pub_key, NodePrefs::FAVOURITE_PREFIX_LEN) == 0) {
clearTarget();
changed = true;
}
// Fail closed rather than guess a new recipient: a contact target that's
// gone just turns auto-share off, it doesn't fall back to some other target.
if (_node_prefs->loc_share_target_type == 1
&& memcmp(_node_prefs->loc_share_dm_prefix, pub_key, NodePrefs::FAVOURITE_PREFIX_LEN) == 0) {
_node_prefs->loc_share_enabled = 0;
changed = true;
}
// Per-contact mute/melody overrides — only 16 slots shared across every
// contact, so an orphaned entry isn't just stale, it can eventually starve
// new overrides for contacts that still exist. Keyed by a 4-byte prefix
// (narrower than the 6-byte one above), so compare only that many bytes.
for (int i = 0; i < NodePrefs::DM_NOTIF_TABLE_MAX; i++) {
if (_node_prefs->dm_notif[i].state && memcmp(_node_prefs->dm_notif[i].prefix, pub_key, 4) == 0) {
memset(&_node_prefs->dm_notif[i], 0, sizeof(_node_prefs->dm_notif[i]));
changed = true;
}
}
for (int i = 0; i < NodePrefs::DM_MELODY_TABLE_MAX; i++) {
if (_node_prefs->dm_melody[i].slot && memcmp(_node_prefs->dm_melody[i].prefix, pub_key, 4) == 0) {
memset(&_node_prefs->dm_melody[i], 0, sizeof(_node_prefs->dm_melody[i]));
changed = true;
}
}
if (changed) the_mesh.savePrefs();
}
// CONTRACT: every NodePrefs field that keys on a channel index is cleared here,
// so a channel re-added at a freed slot can't inherit the old one's settings.
// If you add such a field, add its cleanup below (and mark it in NodePrefs.h).
// Currently covered: bot_channel_idx, loc_share_channel_idx, ch_notif_melody_*.
void UITask::onChannelRemoved(uint8_t channel_idx) {
if (!_node_prefs) return;
bool changed = false;
if (_node_prefs->bot_channel_enabled && _node_prefs->bot_channel_idx == channel_idx) {
_node_prefs->bot_channel_enabled = 0;
changed = true;
}
// Fail closed, same policy as onContactRemoved()'s Live Share case.
if (_node_prefs->loc_share_target_type == 0 && _node_prefs->loc_share_channel_idx == channel_idx) {
_node_prefs->loc_share_enabled = 0;
changed = true;
}
uint64_t mask = 1ULL << channel_idx;
if (_node_prefs->ch_notif_melody_set & mask) {
_node_prefs->ch_notif_melody_set &= ~mask;
_node_prefs->ch_notif_melody_2 &= ~mask;
changed = true;
}
if (changed) the_mesh.savePrefs();
}
// Homing beeper: while armed with a target and inside the radius, emit a short
// tick whose interval shrinks linearly with distance — slow at the edge, rapid
// near the centre. Polls distance a few times a second; silent outside the
@@ -2627,6 +2811,16 @@ bool UITask::getGPSState() {
return false;
}
bool UITask::hasGPS() {
if (_sensors != NULL) {
int num = _sensors->getNumSettings();
for (int i = 0; i < num; i++) {
if (strcmp(_sensors->getSettingName(i), "gps") == 0) return true;
}
}
return false;
}
void UITask::toggleGPS() {
if (_sensors != NULL) {
// toggle GPS on/off
+84 -18
View File
@@ -68,23 +68,29 @@ class UITask : public AbstractUITask {
unsigned long _analogue_pin_read_millis = millis();
#endif
UIScreen* splash;
UIScreen* home;
UIScreen* settings;
UIScreen* quick_msg;
UIScreen* tools_screen;
UIScreen* ringtone_edit;
UIScreen* bot_screen;
UIScreen* nearby_screen;
UIScreen* dashboard_config;
UIScreen* auto_advert_screen;
UIScreen* live_share_screen;
UIScreen* locator_screen;
UIScreen* trail_screen;
UIScreen* compass_screen;
UIScreen* diag_screen;
UIScreen* repeater_screen;
UIScreen* curr;
// Registering a new screen touches 4 sites: (1) the member below, (2) the
// `new XScreen()` in begin(), (3) the gotoXScreen() declaration further down,
// (4) its one-line definition in UITask.cpp. Sites 1/3/4 are compile-checked;
// only a forgotten (2) can slip through — the nullptr initialisers here turn
// that into an inert no-op (see UITask::setCurrScreen) rather than a crash.
UIScreen* splash = nullptr;
UIScreen* home = nullptr;
UIScreen* settings = nullptr;
UIScreen* quick_msg = nullptr;
UIScreen* tools_screen = nullptr;
UIScreen* ringtone_edit = nullptr;
UIScreen* bot_screen = nullptr;
UIScreen* nearby_screen = nullptr;
UIScreen* dashboard_config = nullptr;
UIScreen* auto_advert_screen = nullptr;
UIScreen* live_share_screen = nullptr;
UIScreen* locator_screen = nullptr;
UIScreen* trail_screen = nullptr;
UIScreen* compass_screen = nullptr;
UIScreen* diag_screen = nullptr;
UIScreen* repeater_screen = nullptr;
UIScreen* clock_tools = nullptr;
UIScreen* curr = nullptr;
CayenneLPP _dash_lpp;
TrailStore _trail;
WaypointStore _waypoints;
@@ -121,6 +127,25 @@ class UITask : public AbstractUITask {
void fireLocator(bool arrived);
void locatorProximityBeeper();
// Clock tools engine — owned here (not by ClockToolsScreen) so the one-shot
// alarm and the countdown timer fire every loop regardless of the current
// screen / display state. ClockToolsScreen is pure UI over this state. The
// alarm is scheduled as an ABSOLUTE wall instant, recomputed from the stored
// time-of-day, so it survives RTC re-syncs (mesh/app/GPS/CLI all jump the
// clock) — small corrections still fire on time, a jump over the target still
// fires (late). See evaluateAlarm(). Timer + ring are millis-based.
uint32_t _alarm_next_fire = 0; // unix; 0 = (re)compute lazily once time is valid
uint32_t _alarm_check_ms = 0; // throttle the wall-clock read to ~2 Hz
bool _timer_running = false;
uint32_t _timer_deadline_ms = 0;
bool _ringing = false;
uint32_t _ring_until_ms = 0;
char _ring_label[20] = {0};
uint32_t computeAlarmNextFire(uint32_t now_wall) const;
void evaluateAlarm(); // alarm scheduling + fire detection
void fireClockAlert(const char* label); // wake + alert + melody + start ring
void tickClockTools(); // driven from loop(): ring + timer + alarm
// Course-over-ground ring — a heading source independent of trail recording.
// Filled from the same periodic GPS poll regardless of _trail.isActive().
// Heading = bearing across the window (oldest→newest) once the cumulative
@@ -218,6 +243,22 @@ public:
// Unset the active target (locator_has_target = 0). Distinct from setTarget()
// because there's no "kind" for nothing — clearing is its own operation.
void clearTarget();
// One-shot: if the active target is exactly this waypoint, clear it and
// persist immediately (setTargetNow()'s save-on-the-spot policy) — called
// from waypoint deletion so the Locator can't keep pointing at a spot
// that no longer exists.
void clearTargetIfWaypoint(int32_t lat_1e6, int32_t lon_1e6);
// A contact was removed (companion app / CLI): drop any UI reference to its
// pubkey that would otherwise dangle — a pinned favourite slot, the Locator
// target if it was this contact, the Live Share target if it was this
// contact (auto-share turns off rather than guessing a new recipient), and
// any per-contact mute/melody override (those tables have only 16 shared
// slots, so an orphan isn't just stale — it can starve other contacts).
void onContactRemoved(const uint8_t* pub_key) override;
// A channel slot was cleared: turn off anything armed against it by index
// (the bot's channel, Live Share's channel target) and drop its per-channel
// melody bit, so a future channel re-added at the same slot starts clean.
void onChannelRemoved(uint8_t channel_idx) override;
// Resolve a person target (6-byte pubkey prefix) to a current position:
// prefers an active [LOC] live share, falls back to their last-advertised
// GPS fix. Returns false when neither is known. Optional live/ts report
@@ -235,6 +276,24 @@ public:
void gotoCompassScreen();
void gotoDiagnosticsScreen();
void gotoRepeaterScreen();
void gotoClockTools(); // Alarm / Timer / Stopwatch (from the home Clock page)
// Wake the display for an alarm/timer ring (force an immediate refresh).
void wakeForAlarm();
// Clear any active alert overlay early (alarm dismiss).
void clearAlert() { _alert_expiry = 0; }
// Clock tools engine API — ClockToolsScreen drives these; the engine itself
// runs in tickClockTools() from loop() so it fires regardless of the screen.
void onAlarmChanged() { _alarm_next_fire = 0; } // re-schedule after an alarm edit
void startTimer(uint32_t duration_ms) { _timer_running = true; _timer_deadline_ms = millis() + duration_ms; }
void stopTimer() { _timer_running = false; }
bool isTimerRunning() const { return _timer_running; }
uint32_t timerRemainingMs() const {
if (!_timer_running) return 0;
uint32_t now = millis();
return (now >= _timer_deadline_ms) ? 0 : (_timer_deadline_ms - now);
}
bool isRinging() const { return _ringing; }
void dismissRing() { stopMelody(); _ringing = false; clearAlert(); }
TrailStore& trail() { return _trail; }
WaypointStore& waypoints() { return _waypoints; }
LiveTrackStore& liveTrack() { return _livetrack; }
@@ -256,10 +315,11 @@ public:
void stopMelody();
bool isMelodyPlaying();
void showAlert(const char* text, int duration_millis);
void addChannelMsg(uint8_t channel_idx, const char* text) override;
void addChannelMsg(uint8_t channel_idx, const char* text, uint32_t timestamp = 0) override;
void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text, uint32_t sender_timestamp = 0) override;
void onMsgAck(uint32_t ack_crc) override;
void onChannelRelayed(uint32_t seq) override;
void onRoomLoginResult(const uint8_t* pub_key, bool success, uint8_t permissions) override;
int getDMUnreadTotal() const;
int getMsgCount() const { return _msgcount; }
int getChannelUnreadCount() const;
@@ -333,6 +393,7 @@ public:
void cycleBuzzerMode(); // ON → OFF → Auto → ON
int getBuzzerMode(); // 0=ON, 1=OFF, 2=Auto
bool getGPSState();
bool hasGPS(); // true if this board exposes a toggleable GPS (distinct from GPS being off)
void toggleGPS();
void applyBrightness();
void setBrightnessLevel(uint8_t level);
@@ -343,6 +404,11 @@ public:
void applyPowerSave(); // hardware duty-cycle RX on/off from prefs
void applyApc(); // Adaptive Power Control on/off from prefs
void applyRadioParams(); // freq/bw/sf/cr from prefs (radio preset change)
// Save-on-exit helper for the screen `_dirty` pattern: persists NodePrefs once
// only if `dirty`, then clears the flag. Standardises the screens' exit paths
// (some used to leave the flag set, relying on onShow() to reset it) and keeps
// the "did we touch flash?" answer in one place. Returns whether it saved.
bool savePrefsIfDirty(bool& dirty);
void applyFont();
void applyRotation();
void applyFullRefreshInterval();
+144 -8
View File
@@ -20,11 +20,29 @@ class WaypointsView {
TrailStore* _store;
// Sub-modes layered over the trail views. OFF = the component is dormant.
enum Mode { OFF, LIST, NAV, ADD };
enum Mode { OFF, LIST, NAV, ADD, AVG, TRACKBACK };
uint8_t _mode = OFF;
int _sel = 0;
int _scroll = 0;
// Track-back (Tools Trail Track back): retrace the recorded trail in
// reverse using NavView. _tb_idx is the current target breadcrumb; it walks
// down to 0 (the start) as each is reached within TB_ARRIVE_M.
static const int TB_ARRIVE_M = 20;
int _tb_idx = 0;
navview::EtaTracker _tb_eta;
// GPS averaging (Tools Trail Settings Mark avg). When enabled, markHere()
// accumulates fixes for gps_avg_idx seconds and marks the mean position — a
// steadier mark than one instantaneous fix. Sampling runs in poll() on a 1 s
// gate, independent of (slow, on e-ink) redraws. int64 sums: 30 samples ×
// ~180e6 overflows int32.
long long _avg_sum_lat = 0, _avg_sum_lon = 0;
uint32_t _avg_n = 0; // fixes accumulated so far
uint32_t _avg_end_ms = 0; // millis() when the averaging window closes
uint32_t _avg_next_ms = 0; // millis() of the next sample
uint16_t _avg_total_s = 0; // configured window length (for the readout)
PopupMenu _ctx; // Rename / Delete / Send on a selected waypoint
bool _kb_active = false;
int _kb_rename_idx = -1; // -1 = marking new, ≥0 = renaming that index
@@ -99,6 +117,17 @@ class WaypointsView {
_kb_active = true;
}
// Capture a mark position and open the keyboard for its label. Shared by the
// instant "Mark here" and the end of GPS averaging. _mode goes OFF: the
// keyboard takes over the screen, and there's no sub-view to return to once
// the label is committed (active() then tracks _kb_active alone).
void beginLabel(int32_t lat, int32_t lon) {
_mode = OFF;
_mark_lat = lat; _mark_lon = lon; _mark_ts = (uint32_t)rtc_clock.getCurrentTime();
_kb_rename_idx = -1;
openKb("", WAYPOINT_LABEL_LEN - 1);
}
// Commit a mark-here / rename label from the keyboard.
void commitLabel(const char* buf) {
if (_kb_rename_idx >= 0) {
@@ -144,6 +173,23 @@ class WaypointsView {
}
}
// GPS-averaging progress screen. Sampling itself happens in poll(); this only
// reports remaining time and the running sample count.
void renderAvg(DisplayDriver& display) {
display.setColor(DisplayDriver::LIGHT);
display.drawCenteredHeader("AVERAGING GPS");
const int top = display.listStart();
const int step = display.lineStep();
int remain = (int)((int32_t)(_avg_end_ms - millis()) / 1000);
if (remain < 0) remain = 0;
char line[28];
snprintf(line, sizeof(line), "%ds left (of %us)", remain, (unsigned)_avg_total_s);
display.setCursor(2, top); display.print(line);
snprintf(line, sizeof(line), "Samples: %u", (unsigned)_avg_n);
display.setCursor(2, top + step); display.print(line);
display.setCursor(2, top + 2 * step); display.print("Cancel to abort");
}
void renderWpList(DisplayDriver& display) {
display.setColor(DisplayDriver::LIGHT);
char title[24];
@@ -153,12 +199,10 @@ class WaypointsView {
int n = wpListCount();
int total = n + 1; // final row = "+ Add by coords"
const int step = display.lineStep();
int32_t mylat, mylon; bool have = ownPos(mylat, mylon);
drawList(display, total, _sel, _scroll, [&](int row, int y, bool sel, int reserve) {
display.drawSelectionRow(0, y - 1, display.width() - reserve, step - 1, sel);
drawRowSelection(display, y, sel, reserve);
if (row == n) { // the synthetic "Add" row
display.setCursor(2, y); display.print("+ Add by coords");
@@ -189,6 +233,20 @@ class WaypointsView {
navview::draw(display, have, mylat, mylon, tlat, tlon, label, cogv, cog, useImperial());
}
// Navigate to the current track-back breadcrumb. The header doubles as the
// progress readout: "Trail start" on the last leg, else points still to go.
void renderTrackBack(DisplayDriver& display) {
if (_tb_idx < 0 || _tb_idx >= _store->count()) { _mode = OFF; return; }
const TrailPoint& t = _store->at(_tb_idx);
int32_t mylat, mylon; bool have = ownPos(mylat, mylon);
int cog; bool cogv = _task->currentCourse(cog);
char label[20];
if (_tb_idx == 0) snprintf(label, sizeof(label), "Trail start");
else snprintf(label, sizeof(label), "Back: %d pt", _tb_idx);
navview::draw(display, have, mylat, mylon, t.lat_1e6, t.lon_1e6,
label, cogv, cog, useImperial(), &_tb_eta);
}
// Resolve a combined-list row to a nav target. Row 0 is the trail start when
// a trail exists; the rest are saved waypoints.
bool rowTarget(int row, int32_t& lat, int32_t& lon, const char*& label) {
@@ -222,9 +280,70 @@ public:
int32_t lat, lon;
if (!ownPos(lat, lon)) { _task->showAlert("No GPS fix", 1000); return; }
if (_task->waypoints().full()) { _task->showAlert("Waypoints full", 1000); return; }
_mark_lat = lat; _mark_lon = lon; _mark_ts = (uint32_t)rtc_clock.getCurrentTime();
_kb_rename_idx = -1;
openKb("", WAYPOINT_LABEL_LEN - 1);
NodePrefs* p = _task->getNodePrefs();
uint8_t avg_idx = p ? p->gps_avg_idx : 0;
if (avg_idx == 0) { beginLabel(lat, lon); return; } // instant mark (default)
// Averaging: seed with the current fix, then sample for N more seconds.
_avg_total_s = NodePrefs::gpsAvgSecs(avg_idx);
_avg_sum_lat = lat; _avg_sum_lon = lon; _avg_n = 1;
uint32_t now = millis();
_avg_end_ms = now + (uint32_t)_avg_total_s * 1000;
_avg_next_ms = now + 1000;
_mode = AVG;
}
// Retrace the recorded trail back to its start. Snaps onto the route at the
// nearest recorded point, then NavView guides to each earlier breadcrumb in
// turn (poll() advances the target) until the start is reached.
void startTrackBack() {
if (_store->count() < 2) { _task->showAlert("No trail", 1000); return; }
int idx = _store->count() - 1; // default: the newest end of the trail
int32_t lat, lon;
if (ownPos(lat, lon)) { // else snap to the nearest recorded point
float best = 1e30f;
for (int i = 0; i < _store->count(); i++) {
float d = geo::haversineKm(lat, lon, _store->at(i).lat_1e6, _store->at(i).lon_1e6);
if (d < best) { best = d; idx = i; }
}
}
_tb_idx = idx;
_tb_eta.reset();
_mode = TRACKBACK;
}
// Driven from TrailScreen::poll() so the position-tracking sub-modes tick on
// the main loop, not on the (slow on e-ink) render cadence.
void poll() {
if (_mode == AVG) pollAvg();
else if (_mode == TRACKBACK) pollTrackBack();
}
// Accumulate GPS fixes while averaging; mark the mean when the window closes.
void pollAvg() {
uint32_t now = millis();
if ((int32_t)(now - _avg_next_ms) >= 0) {
int32_t lat, lon;
if (ownPos(lat, lon)) { _avg_sum_lat += lat; _avg_sum_lon += lon; _avg_n++; }
_avg_next_ms = now + 1000;
}
if ((int32_t)(now - _avg_end_ms) >= 0) { // window closed → mark the mean
if (_avg_n == 0) { _task->showAlert("No GPS fix", 1000); _mode = OFF; return; }
int32_t mlat = (int32_t)(_avg_sum_lat / (long long)_avg_n);
int32_t mlon = (int32_t)(_avg_sum_lon / (long long)_avg_n);
beginLabel(mlat, mlon); // opens the label keyboard
}
}
// Advance the track-back target when the current breadcrumb is reached; once
// at the start (index 0) and within range, announce arrival and exit.
void pollTrackBack() {
int32_t lat, lon;
if (!ownPos(lat, lon)) return;
float d_m = geo::haversineKm(lat, lon, _store->at(_tb_idx).lat_1e6,
_store->at(_tb_idx).lon_1e6) * 1000.0f;
if (d_m > (float)TB_ARRIVE_M) return;
if (_tb_idx > 0) { _tb_idx--; _tb_eta.reset(); }
else { _task->showAlert("Back at start", 1500); _mode = OFF; }
}
// Only called while active().
@@ -233,6 +352,8 @@ public:
display.setColor(DisplayDriver::LIGHT);
if (_kb_active) return _task->keyboard().render(display); // keyboard owns the screen
if (_mode == ADD) { renderAddForm(display); return 1000; }
if (_mode == AVG) { renderAvg(display); return display.isEink() ? 1000 : 300; }
if (_mode == TRACKBACK) { renderTrackBack(display); return 1000; }
if (_mode == NAV) { renderWpNav(display); return 1000; }
renderWpList(display); // LIST
if (_ctx.active) _ctx.render(display);
@@ -269,6 +390,8 @@ public:
}
} else if (sel == 1) { // Delete
if (wi >= 0 && wi < _task->waypoints().count()) {
const Waypoint& w = _task->waypoints().at(wi);
_task->clearTargetIfWaypoint(w.lat_1e6, w.lon_1e6); // don't leave the Locator pointed at a deleted spot
_task->waypoints().remove(wi);
_task->saveWaypoints();
if (_sel >= wpListCount()) _sel = wpListCount() - 1;
@@ -293,6 +416,12 @@ public:
return true;
}
// Averaging window — any cancel aborts the mark; otherwise just wait it out.
if (_mode == AVG) {
if (c == KEY_CANCEL) _mode = OFF;
return true;
}
// ADD form: scroll-edit lat/lon (magnitude) + hemisphere toggle + label.
if (_mode == ADD) {
// The digit editor, while open, consumes all input (UP/DOWN change the
@@ -307,7 +436,7 @@ public:
if (c == KEY_CANCEL) { _mode = OFF; return true; }
if (c == KEY_UP) { _add_sel = (_add_sel > 0) ? _add_sel - 1 : 3; return true; }
if (c == KEY_DOWN) { _add_sel = (_add_sel < 3) ? _add_sel + 1 : 0; return true; }
if (c == KEY_LEFT || c == KEY_RIGHT) {
if (keyIsPrev(c) || keyIsNext(c)) {
if (_add_sel == 0) _add_lat_neg = !_add_lat_neg; // N <-> S
else if (_add_sel == 1) _add_lon_neg = !_add_lon_neg; // E <-> W
return true;
@@ -328,6 +457,13 @@ public:
return true;
}
// Track-back — launched from the action menu, so Cancel returns to the
// trail views (not the waypoint list).
if (_mode == TRACKBACK) {
if (c == KEY_CANCEL) _mode = OFF;
return true;
}
// List (row 0 is the synthetic "Trail start" when a trail exists; the final
// row is "+ Add by coords"). Cancel returns control to the trail views.
int n = wpListCount();
+18
View File
@@ -150,6 +150,14 @@ MINI_ICON(ICON_ADVERT, 6, // broadcast mast + radiating waves (auto-advert)
packRow(".####."),
packRow(".####."));
MINI_ICON(ICON_ALARM, 5, // bell — an alarm is armed
packRow("..#.."),
packRow(".###."),
packRow(".###."),
packRow(".###."),
packRow("#####"),
packRow("..#.."));
MINI_ICON(ICON_TRAIL, 6, // map pin / location marker (GPS trail logging)
packRow(".####."),
packRow("######"),
@@ -499,6 +507,16 @@ inline int drawList(DisplayDriver& d, int total, int sel, int& scroll, RenderRow
return visible;
}
// Canonical selection bar for a drawList() row: spans the row width minus the
// scroll-indicator `reserve`, one pixel short of the row height, anchored one
// pixel above `y` (the row's text baseline-top). Call as the first line of a
// row callback, then draw content over it. Captures the geometry every list row
// repeated by hand; rows that intentionally differ (full-width, custom height)
// still call display.drawSelectionRow() directly.
inline void drawRowSelection(DisplayDriver& d, int y, bool sel, int reserve) {
d.drawSelectionRow(0, y - 1, d.width() - reserve, d.lineStep() - 1, sel);
}
// ── Big ASCII-art icons (skeleton, not yet used) ─────────────────────────────
// Same authoring idea as the mini-icons but for full page glyphs up to 32 px
// wide: one uint32_t per row. The existing XBM bitmaps below (logo/bluetooth/…)